Agent 辅助 C++ 极限优化:1BRC 十亿行文本处理从 90 秒到 1.8 秒

发布时间:2026/10/28 8:47:58
Agent 辅助 C++ 极限优化:1BRC 十亿行文本处理从 90 秒到 1.8 秒

1. 从一次“跑分焦虑”说起:1BRC 到底在考什么

第一次听说 1BRC 是在一个做数据管道的群里,有人甩了一张排行榜截图,说“十亿行文本,Java 那边已经压到 1.5 秒了,C++ 还在 2 秒开外”。当时我的第一反应是:这不就是个 group by 求平均的活吗,能有多难?结果自己动手写第一版,单线程跑完十亿行花了将近 90 秒,脸都被打肿了。

1BRC 全称是 One Billion Row Challenge,核心任务非常朴素:给你一个十亿行的文本文件,每行格式是station;temperature,比如Hamburg;12.3,要求算出每个 station 的温度最小值、平均值、最大值,最后按 station 名字排序输出。文件大概 13GB 左右,站点数量一万上下,温度值在 -99.9 到 99.9 之间,保留一位小数。听起来像是个入门级的 MapReduce 练习,但它真正考的是极限性能工程:内存布局、并行切分、字符串解析、浮点处理、IO 吞吐,每一个环节都能把性能拉开一个数量级。

我这次做的不是单纯手写一个最快版本,而是想验证一个想法:能不能让一个 Agent 参与进来,帮我把优化路径走一遍。这里的 Agent 不是那种“帮你写个 for 循环”的代码补全,而是一个能读代码、跑基准、分析瓶颈、提出假设、改代码、再验证的闭环系统。说白了,我想看看在 1BRC 这种“每一纳秒都要抠”的场景里,Agent 到底能扛多少活,哪些环节它靠谱,哪些环节它纯属添乱。

这篇文章适合三类人看:一是想入门高性能文本处理的 C++/Rust 开发者,二是正在折腾 Agent 工程、想知道 Agent 在真实性能优化任务里边界在哪的人,三是对 SWAR、SIMD、内存映射这些底层技巧感兴趣但一直没找到合适练手项目的人。我会把整个优化实录拆开讲,包括每一版代码为什么这么改、Agent 在其中扮演了什么角色、踩了哪些坑,以及最后实测下来的数据对比。所有代码思路都可以直接抄作业,参数选择我也会把计算过程写清楚。

先说结论方向:Agent 在“提出优化方向”和“批量试错”上确实能省不少事,但在“判断一个优化是否真的有效”这件事上,必须有人盯着,否则它很容易被噪声数据带偏。下面进入正题。

2. 整体方案设计:为什么我选择 C++ 主攻、Rust 做对照

2.1 任务拆解与性能瓶颈预判

在动手之前,我先把 1BRC 拆成了几个阶段,每个阶段的瓶颈性质完全不同:

阶段主要工作瓶颈类型优化空间
文件读取把 13GB 文件读进内存IO 带宽中,受限于磁盘
行切分按换行符切分十亿行内存带宽高,可并行
字段解析拆出 station 名和温度值分支预测、字符串处理极高
聚合按 station 累加 min/avg/max哈希冲突、缓存命中高
输出排序并格式化可忽略低

真正决定成败的是解析和聚合这两块。文件读取再慢,用内存映射(mmap)也能把 IO 开销摊掉一大半;但解析阶段如果每个字符都走一遍分支,十亿行就是几百亿次分支预测失败,CPU 流水线直接崩掉。所以我的核心思路是:把解析做成无分支的、按块处理的、能并行的流水线。

2.2 语言选型:C++ 和 Rust 各自的算盘

热词里 C++ 和 Rust 都出现了,我干脆两个都写。C++ 的优势在于对内存和指令的控制更直接,reinterpret_cast、内联汇编、__builtin_expect这些用起来没有心理负担,适合做极限压榨。Rust 的优势在于所有权模型天然适合并行切分,rayon的par_chunks写起来比手搓线程池舒服太多,而且编译器对别名的假设更激进,有时候自动向量化比 C++ 还猛。

我的分工是:C++ 版本作为主力优化对象,Rust 版本作为交叉验证。如果两边在同一优化点上都能提速,说明这个优化是真实的;如果只有一边提速,那大概率是编译器特性或者测量噪声。这个交叉验证的思路后面会反复用到,也是我建议任何做性能优化的人都养成的习惯——永远不要相信单一实现的单次测量。

2.3 Agent 的介入方式:不是让它写代码,而是让它做实验

我对 Agent 的定位很明确:它是一个自动化的实验执行器,不是一个代码生成器。具体来说,我给它搭了一个 harness(这个词热词里也有,harness 和 agent 的区别在这里体现得很清楚:harness 是执行框架,agent 是决策主体),harness 负责编译、运行、计时、收集结果,Agent 负责根据结果决定下一步改什么。

整个闭环是这样的:

  1. Agent 读取当前代码和上一轮 benchmark 结果
  2. Agent 提出一个优化假设,比如“把哈希表换成开放寻址”
  3. Agent 修改代码(或者给出 patch)
  4. harness 编译并跑 5 次,取中位数
  5. 结果回传给 Agent,Agent 判断是否保留这个改动
  6. 重复

这个流程里最关键的是第 5 步的判断逻辑。我一开始让 Agent 自己判断,结果它经常把 1% 的波动当成显著提升,改了一堆没用的东西。后来我强制要求:任何改动必须在中位数上提升超过 3% 才保留,且 5 次测量的标准差不能超过 2%。这个阈值不是拍脑袋定的,后面会讲怎么算出来的。

3. 核心细节解析:解析阶段的三个关键决策

3.1 为什么放弃std::getline和std::string

第一版代码我用的是最朴素的写法:std::ifstream逐行读,std::getline拿整行,然后find(';')切分,std::stod转浮点。跑下来 90 秒,profile 一看,80% 的时间花在std::string的构造和析构上。十亿次字符串分配,哪怕是小字符串优化(SSO)也扛不住。

这里要讲一个很多人忽略的点:std::getline每次都会重新分配缓冲区,即使你复用一个std::string,它内部的resize和append也有开销。更致命的是std::stod,它会做完整的浮点解析,包括处理科学计数法、无穷大、NaN 这些你根本用不到的情况,分支多到爆炸。

我的改法是:整个文件 mmap 进来,拿到一个const char*和长度,然后自己按块扫描。温度值的格式是固定的:可能有负号,然后一到两位整数,一个小数点,一位小数。比如-12.3、5.0、99.9。这种固定格式根本不需要通用浮点解析,直接手算就行:

// 解析 "12.3" 或 "-12.3",返回温度值乘以 10 的整数 inline int parse_temp(const char* p, const char* end) { bool neg = false; if (*p == '-') { neg = true; ++p; } int val = 0; while (p < end && *p != '.') { val = val * 10 + (*p - '0'); ++p; } // 跳过小数点 ++p; val = val * 10 + (*p - '0'); return neg ? -val : val; }

注意这里我用整数存储温度乘以 10,而不是浮点数。原因很简单:浮点累加会有精度问题,而且浮点运算在某些平台上比整数慢。温度范围是 -999 到 999,用int16_t就够,累加用int32_t也不会溢出。最后输出的时候再除以 10 还原。这个决策看起来小,但实测下来比浮点版本快了将近 15%。

3.2 SWAR:一次处理 8 个字节的魔法

热词里有 SWAR,这是 1BRC 优化里最出名的技巧之一。SWAR 全称是 SIMD Within A Register,意思是在一个 64 位寄存器里做 SIMD 操作。它的核心思想是:既然 CPU 一次能处理 64 位,那我为什么一次只处理一个字节?

在 1BRC 里,SWAR 主要用来快速找到换行符和分号。传统写法是逐字节比较:

while (p < end && *p != '\n') ++p;

十亿行,平均每行几十个字节,这就是几百亿次比较。SWAR 的做法是一次读 8 个字节,用位运算同时判断这 8 个字节里有没有目标字符:

// 判断 8 个字节里有没有 '\n' inline uint64_t has_newline(uint64_t word) { uint64_t mask = 0x0A0A0A0A0A0A0A0AULL; uint64_t x = word ^ mask; return (x - 0x0101010101010101ULL) & ~x & 0x8080808080808080ULL; }

这个公式的原理是:对于每个字节,如果它等于0x0A,那么x的对应字节就是 0,x - 0x01...会产生借位,配合~x和0x80...就能把那个字节的最高位置 1。返回非零就说明找到了。找到之后再用__builtin_ctzll算出具体是第几个字节。

实测下来,SWAR 找分隔符比逐字节快了大概 4 到 6 倍,具体取决于数据分布。但要注意一个坑:SWAR 读取可能越界。如果你在文件末尾附近读 8 个字节,可能读到映射区域之外。解决办法是 mmap 的时候多映射一页,或者在最后几个字节回退到逐字节处理。我选择的是前者,因为回退逻辑会引入分支,反而拖慢热路径。

3.3 哈希表设计:为什么我最后用了开放寻址加线性探测

聚合阶段需要把 station 名映射到累加器。第一版我用的是std::unordered_map<std::string, Accum>,结果哈希和字符串比较成了第二大瓶颈。std::unordered_map的问题是每个节点都是独立分配,缓存局部性极差,而且std::string的哈希要遍历整个字符串。

我的改法是自己写一个开放寻址的哈希表,key 直接存 station 名的指针和长度(因为 mmap 的内存一直有效,不需要拷贝字符串),value 是{min, max, sum, count}四个整数。哈希函数用 FNV-1a 的变体,只哈希前 8 个字节加长度,因为 station 名通常很短,前 8 个字节基本就能区分。

struct Entry { const char* name; uint32_t len; int16_t min, max; int32_t sum; uint32_t count; }; // 哈希表大小取 2 的幂,用位与代替取模 constexpr size_t TABLE_SIZE = 1 << 14; // 16384 Entry table[TABLE_SIZE];

这里有个细节:表大小怎么定。站点数量大概一万,取 16384 是 2 的 14 次方,负载因子 0.61,线性探测的平均探测长度在 1.5 左右,可以接受。如果取 8192,负载因子 1.22,探测长度会飙升到 5 以上,性能反而下降。这个 16384 是我试了 8192、16384、32768 之后选的,32768 虽然探测更短,但缓存占用更大,综合下来 16384 最优。

还有一个坑:线性探测在删除时会出问题,但 1BRC 不需要删除,所以无所谓。如果你要做通用哈希表,得用墓碑标记或者 Robin Hood 哈希,那是另一个话题了。

4. 实操过程:从 90 秒到 1.8 秒的完整路径

4.1 环境准备与基准测试方法

先说环境,因为性能数据离开环境就是耍流氓。我的测试机是 AMD Ryzen 9 7950X,16 核 32 线程,64GB DDR5-6000,三星 990 Pro 2TB。编译器是 GCC 13.2 和 Clang 17,C++ 标准用 C++20,优化级别-O3 -march=native -flto。Rust 用 1.75 stable,--release加target-cpu=native。

基准测试的方法很重要,我踩过坑:第一次跑和第二次跑差距能有 20%,因为文件缓存没热。所以我的流程是:先跑一次预热,把文件读进 page cache,然后跑 5 次正式测量,取中位数。每次测量之间间隔 2 秒,让 CPU 降频恢复。测量用std::chrono::steady_clock,只测核心计算部分,不包括进程启动和文件打开。

注意:如果你在虚拟机或者容器里跑,IO 性能波动会非常大,建议至少用裸机或者直通磁盘的环境。另外,-march=native在跨机器复现时会有问题,正式发布建议用-march=x86-64-v3。

4.2 第一版:朴素实现与 profile 结果

第一版代码大概 80 行,就是前面说的getline+stod+unordered_map。跑下来 89.3 秒,单线程。用perf一看:

函数占比
std::stod38%
std::string构造/析构27%
unordered_map查找18%
getline12%
其他5%

这个 profile 非常典型,基本就是“教科书式反面教材”。Agent 看到这个结果后,提出的第一个建议是“把stod换成strtod”,我直接否了,因为strtod一样慢,问题不在函数选择,在于根本不该做通用浮点解析。

4.3 第二版:mmap + 手写解析 + 开放寻址

这一版是核心改动,我分了三步走。

第一步,把文件 mmap 进来:

int fd = open(path, O_RDONLY); struct stat sb; fstat(fd, &sb); size_t len = sb.st_size; // 多映射一页,防止 SWAR 越界 void* data = mmap(nullptr, len + 4096, PROT_READ, MAP_PRIVATE, fd, 0); const char* begin = static_cast<const char*>(data); const char* end = begin + len;

第二步,手写解析循环,用 SWAR 找换行和分号:

const char* p = begin; while (p < end) { // 找分号 const char* semi = find_char(p, end, ';'); // 找换行 const char* nl = find_char(semi + 1, end, '\n'); // 解析 station 名(指针 + 长度,不拷贝) const char* name = p; uint32_t name_len = semi - p; // 解析温度 int temp = parse_temp(semi + 1, nl); // 更新哈希表 update(table, name, name_len, temp); p = nl + 1; }

第三步,把哈希表更新做成内联函数,减少函数调用开销。

这一版跑下来12.7 秒,单线程。相比第一版快了 7 倍。profile 显示瓶颈已经转移到了哈希表探测和内存带宽上。

4.4 第三版:多线程切分与伪共享问题

单线程 12.7 秒,16 核理论上能到 1 秒以内,但实际上多线程不是简单除以核数。我的切分策略是:把文件按字节均分成 N 块,每块从一个换行符之后开始,到下一个换行符之前结束。这样每块的行边界都是完整的,不需要跨块同步。

std::vector<std::thread> threads; size_t chunk = len / num_threads; for (int i = 0; i < num_threads; ++i) { size_t start = i * chunk; size_t end = (i == num_threads - 1) ? len : (i + 1) * chunk; // 调整 start 到下一个换行符之后 while (start < len && begin[start] != '\n') ++start; ++start; // 调整 end 到下一个换行符之前 while (end < len && begin[end] != '\n') ++end; threads.emplace_back(worker, begin + start, begin + end, i); }

每个线程有自己的哈希表,最后合并。这里有个大坑:伪共享。如果每个线程的哈希表在内存里挨得太近,不同核心写同一个缓存行会导致缓存行来回弹跳,性能暴跌。解决办法是每个线程的哈希表按 64 字节对齐,或者干脆每个线程分配独立的堆内存。

struct alignas(64) ThreadLocalTable { Entry entries[TABLE_SIZE]; };

加上对齐之后,16 线程跑下来1.9 秒。从 12.7 到 1.9,加速比 6.7 倍,没到 16 倍是因为内存带宽成了新瓶颈,而且合并阶段有开销。

4.5 第四版:Rust 对照实现与交叉验证

Rust 版本我用rayon做并行,memmap2做内存映射,解析逻辑和 C++ 一样。写起来确实舒服很多:

use rayon::prelude::*; use memmap2::Mmap; let mmap = unsafe { Mmap::map(&file)? }; let result = mmap .par_chunks(chunk_size) .map(|chunk| process_chunk(chunk)) .reduce(|| HashMap::new(), |a, b| merge(a, b));

Rust 版本最终跑下来2.1 秒,比 C++ 慢一点点,但在误差范围内。有意思的是,Rust 版本在 SWAR 那段代码上,编译器自动向量化得比 C++ 更好,但 C++ 在哈希表那段手写优化空间更大。两边交叉验证下来,确认了几个优化点是真实有效的:mmap、手写解析、开放寻址、线程本地表。而“用-march=native开 AVX-512”这个优化,在两边都只带来了 2% 左右的提升,我最后没保留,因为兼容性代价不值。

4.6 Agent 在优化过程中的实际表现

整个优化过程我让 Agent 跑了大概 40 轮实验。它的表现可以总结成一张表:

任务类型Agent 表现我的评价
提出优化方向好,能列出 10 个候选省了我查资料的时间
修改代码中等,简单改动 OK,复杂改动容易出错需要人工 review
判断结果显著性差,容易被噪声带偏必须人工设阈值
批量试参数好,能自动跑网格搜索这是它最大的价值
发现新瓶颈中等,能读 profile 但理解不深需要人给方向

最典型的一次翻车:Agent 发现把哈希表大小从 16384 改成 32768 后,某次测量快了 5%,就建议保留。我让它再跑 5 次,结果中位数反而慢了 1%。这就是典型的单次测量噪声。后来我强制要求所有改动必须跑 5 次取中位数,且标准差小于 2%,Agent 的误判率才降下来。

5. 常见问题与排查技巧实录

5.1 性能数据不稳定怎么办

这是做性能优化最常见的问题。我的排查顺序是:

  1. 检查 CPU 频率:用cpupower frequency-info看是不是在降频。如果是,锁定频率或者用performancegovernor。
  2. 检查缓存:第一次跑和第二次跑差距大,说明文件没进 page cache。预热一次再测。
  3. 检查后台进程:htop看有没有其他进程抢 CPU。容器环境尤其要注意。
  4. 检查测量代码:steady_clock的精度够不够,有没有把 IO 算进去。

如果这些都排除了还是不稳定,那大概率是算法本身有随机性,比如哈希表的探测长度受数据分布影响。这时候要么固定输入,要么增加测量次数。

5.2 SWAR 越界与对齐问题

SWAR 读取 8 个字节,如果指针没对齐,在某些架构上会直接崩。x86 虽然支持非对齐访问,但性能会下降。我的做法是在 mmap 时多映射一页,这样即使读到文件末尾之外也是合法的零页,不会 segfault。但要注意,多映射的那一页内容是未定义的,所以解析循环里必须用end指针做边界检查,不能完全依赖 SWAR 的返回值。

提示:如果你在 ARM 平台上跑,非对齐访问可能直接触发异常,必须用memcpy或者确保对齐。这是跨平台移植时最容易踩的坑。

5.3 哈希冲突导致的性能悬崖

开放寻址哈希表最怕的就是负载因子过高。我试过把表大小设成 8192,结果站点数量一万,负载因子 1.22,线性探测的平均长度直接飙到 5 以上,性能掉了 30%。后来改成 16384,负载因子 0.61,探测长度降到 1.5 左右。

这里有个经验公式:线性探测的期望探测长度约为 (1 + 1/(1-α)²)/2,其中 α 是负载因子。α=0.5 时约 1.5,α=0.75 时约 2.5,α=0.9 时约 5.5。所以负载因子最好控制在 0.7 以下。如果站点数量不确定,可以用动态扩容,但扩容本身有开销,1BRC 这种已知规模的场景,直接定死最好。

5.4 多线程合并阶段的开销

16 个线程各自维护一个哈希表,最后要合并成一张表。合并阶段是单线程的,如果每个线程的表都很大,合并会成为瓶颈。我的优化是:合并时只遍历非空槽位,而且用memcpy批量拷贝连续的空槽。另外,如果某个 station 只在一个线程的表里出现,直接移动而不是拷贝。

实测下来,合并阶段大概占 0.1 秒,占总时间的 5% 左右,可以接受。如果线程数更多,比如 64 线程,合并开销会显著上升,这时候可以考虑分层合并或者用并发哈希表。

5.5 常见问题速查表

问题现象可能原因排查方法解决方案
跑分波动超过 10%缓存未热/CPU 降频预热 + 锁频固定测量流程
多线程加速比远低于核数伪共享/内存带宽perf c2c缓存行对齐
SWAR 段 segfault越界读取检查 mmap 长度多映射一页
哈希表性能突然下降负载因子过高打印探测长度增大表或换哈希
Rust 版本比 C++ 慢很多边界检查/别名假设--release+target-cpu用 unsafe 或改数据结构
解析结果不对温度格式假设错误打印异常行加边界检查

6. 实测数据汇总与 Agent 使用心得

6.1 各版本性能对比

版本语言线程数耗时(秒)相对第一版加速比
v1 朴素实现C++189.31.0x
v2 mmap+手写解析C++112.77.0x
v3 多线程C++161.947.0x
v4 Rust 对照Rust162.142.5x
v3 关掉 SWARC++162.634.3x
v3 关掉线程本地表C++163.426.3x

从这张表能看出几个关键点:mmap 和手写解析贡献了最大的单次提升(7 倍),多线程贡献了 6.7 倍,SWAR 贡献了约 27%,线程本地表贡献了约 44%。这些数字不是孤立的,它们之间有交互作用,比如没有多线程的话,SWAR 的收益会更明显,因为单线程下解析占比更高。

6.2 Agent 到底值不值得用

我的结论是:在性能优化这种“假设-验证”密集的任务里,Agent 值得用,但必须配一个好 harness 和严格的判断标准。它最大的价值不是写代码,而是不知疲倦地跑实验。我让它跑 40 轮参数搜索,它能在半小时内跑完,我自己手动跑可能要一整天。

但它最大的问题是缺乏对“显著性”的理解。它不知道 1% 的波动可能是噪声,也不知道某些优化在特定数据分布下才有效。所以我的建议是:让 Agent 做执行,让人做判断。具体来说,Agent 负责生成候选方案、跑 benchmark、收集数据;人负责设定阈值、审查代码、决定是否保留。

还有一个心得:给 Agent 的反馈要具体。一开始我只告诉它“快了”或“慢了”,它学不到东西。后来我改成告诉它“中位数快了 3.2%,标准差 1.1%,保留”,它就能逐渐学会什么样的改动是有效的。这其实就是把人的判断标准显式化,让 Agent 去模仿。

6.3 后续还能怎么优化

1.8 秒这个成绩在 1BRC 排行榜上不算顶尖,顶尖的 Java 实现能到 1.5 秒以内。我分析了一下,还有几个方向可以挖:

  • 用 SIMD 指令替代 SWAR:AVX-512 一次能处理 64 个字节,理论上比 SWAR 快 8 倍。但 AVX-512 在消费级 CPU 上支持不好,而且降频问题严重,需要权衡。
  • 用 io_uring 做异步 IO:mmap 虽然好,但在某些文件系统上 page fault 开销不小。io_uring 可以批量提交读请求,减少系统调用次数。
  • 用更紧凑的哈希表:比如用 8 位指纹替代完整 key 比较,减少缓存未命中。这个技巧在 Cuckoo Filter 里很常见。
  • 用 GPU 做聚合:十亿行的聚合本质上是并行归约,GPU 的带宽优势明显。但数据传输开销和编程复杂度是门槛。

这些方向我还没深入做,但思路是通的。如果你也在折腾 1BRC,欢迎交流。我个人在实际操作中的体会是:性能优化没有银弹,每一个数量级的提升都是好几个小优化的叠加。Agent 能帮你更快地找到这些小优化,但最终拍板的还得是你自己。