Agent驱动1BRC十亿行数据优化:C++与Rust性能实战
1. 项目缘起与整体思路拆解1.1 为什么盯上了 1BRC 这个基准第一次看到 1BRCOne Billion Row Challenge十亿行数据挑战的时候我的反应和大多数人一样不就是读个文件做个聚合吗能有多难。结果真动手跑了一遍基线版本才发现事情远没有想象中那么简单。这个挑战的核心任务是给定一个气象站温度记录文件每行格式为“站点名;温度值”要求计算出每个站点的最小值、最大值和平均值并且输出结果要按站点名排序。文件规模是十亿行大约 13GB 左右的纯文本数据。这个任务看起来简单但它几乎把单机数据处理的所有瓶颈都暴露出来了文件 I/O 吞吐、字符串解析效率、哈希表性能、内存分配策略、多线程调度、浮点数精度处理每一个环节都可能成为拖后腿的短板。也正因为如此1BRC 成了检验一门语言、一套工程实践、甚至一个 Agent 优化能力的绝佳试金石。我这次做的事情不是单纯手写一个 C 或 Rust 的极限优化版本而是尝试用 Agent 的方式去驱动整个优化过程。换句话说我把 1BRC 当作一个“优化任务”让 Agent 去分析瓶颈、提出方案、生成代码、验证效果然后迭代。这个过程中涉及到的技术栈包括 C、Rust、SWARSIMD Within A Register技巧、多线程编排、内存映射 I/O 等等。最终的目标不是刷到排行榜第一而是搞清楚Agent 在性能优化这类高度依赖经验和直觉的任务上到底能走多远哪些环节它靠谱哪些环节它一定会翻车。1.2 Agent 优化和传统手写优化的本质区别传统的手写优化路径是这样的我先跑一个基线版本用 perf 或者 vtune 找到热点函数然后凭经验判断是 I/O 瓶颈还是计算瓶颈接着针对性地改代码改完再测测完再改。整个过程高度依赖个人经验老手可能三轮就到位了新手可能改十轮还在原地打转。Agent 优化的路径则完全不同。我把基线代码、性能数据、目标指标一起丢给 Agent让它自己决定下一步做什么。Agent 会先分析代码结构识别出可能的瓶颈点然后提出一个优化方案比如“把 fread 换成 mmap”、“用 SWAR 解析温度值”、“把哈希表换成开放寻址法”。我负责审核方案、执行编译和测试把结果反馈给 Agent它再决定下一步。这个过程中最让我意外的是Agent 在“提出方案”这一步表现得相当不错它能识别出很多经典的优化点甚至能给出具体的代码实现。但在“判断优先级”和“评估风险”这两件事上它经常犯错。比如它会同时提出五个优化方案但其中三个是互相冲突的或者它会忽略某个优化带来的内存开销导致程序在十亿行数据下直接 OOM。所以我的整体思路是把 Agent 当作一个“方案生成器”和“代码生成器”但把“决策权”和“验证权”牢牢握在自己手里。Agent 负责广撒网我负责收网和把关。这个分工在后续的实操中被证明是最高效的。1.3 技术选型C 和 Rust 到底怎么选这个问题我被问过很多次。1BRC 的官方挑战是 Java 写的但社区里 C 和 Rust 的实现都非常多。我这次两个版本都做了原因很简单我想看看 Agent 在不同语言上的表现差异。C 的优势在于控制力强你可以直接操作内存、内联汇编、手动控制缓存行对齐。对于 1BRC 这种极限优化场景C 能让你把每一纳秒都抠出来。但代价是代码复杂度高内存安全问题需要自己兜底Agent 生成的 C 代码经常有未定义行为比如越界读取或者悬垂指针。Rust 的优势在于安全性所有权模型和借用检查器能在编译期挡掉大量内存错误。Agent 生成的 Rust 代码编译通过的版本基本不会出现段错误。但 Rust 的代价是某些底层操作写起来更啰嗦比如你要做 SIMD 优化得用 std::simd 或者 intrinsics代码可读性会下降。我最终的结论是如果你追求极致性能并且有足够的 C 经验选 C如果你希望 Agent 生成的代码更可靠、调试成本更低选 Rust。两者在 1BRC 场景下的极限性能差距其实不大都在 1.5 秒到 2.5 秒之间取决于硬件真正的差距在于开发效率和调试成本。2. 核心细节解析与实操要点2.1 文件读取mmap 为什么比 fread 快1BRC 的第一个瓶颈永远是文件读取。十亿行、13GB 的数据如果用标准的 fread 逐块读取光是系统调用和数据拷贝的开销就能吃掉大量时间。我一开始的基线版本用的是 fread 加 1MB 缓冲区跑完整个文件大概需要 8 到 10 秒其中 I/O 占了将近 60%。换成 mmap 之后情况完全变了。mmap 把文件直接映射到进程的虚拟地址空间内核负责按需分页加载用户态代码直接通过指针访问数据省掉了 read 系统调用的数据拷贝。实测下来mmap 版本的 I/O 时间降到了 2 秒左右整体耗时直接砍半。但 mmap 不是没有坑。第一个坑是文件大小限制32 位系统上地址空间不够必须用 64 位。第二个坑是页面错误如果文件不在页缓存里第一次访问会触发大量缺页中断导致程序卡顿。我的做法是在 mmap 之后用一个单独的线程做 madvise(MADV_SEQUENTIAL) 或者 MADV_WILLNEED提前把页面加载进来。第三个坑是文件被截断或者修改mmap 的区域会失效导致 SIGBUS。对于 1BRC 这种静态文件场景这个问题不存在但生产环境中必须考虑。Agent 在这个环节的表现很有意思。我让它分析 fread 版本的瓶颈它准确指出了“系统调用开销”和“数据拷贝”两个问题并建议使用 mmap。但它没有提到 madvise 预加载也没有提到 SIGBUS 的风险。这些细节是我后来手动补上的。2.2 字符串解析SWAR 技巧的威力温度值的解析是第二个大瓶颈。每行数据的格式是“站点名;温度值”站点名长度不定温度值可能是“12.3”或者“-5.7”这种格式。如果用标准的 strtod 或者 sscanf每次解析都要做大量的分支判断和浮点运算十亿次下来开销巨大。SWAR 的核心思想是把多个字节打包到一个机器字里用一次算术运算同时处理多个字节。比如解析“12.3”这个温度值传统做法是逐个字符判断SWAR 的做法是把“12.3”这四个字节加载到一个 32 位整数里然后用位运算一次性提取出整数部分和小数部分。具体实现上我用了这样的技巧先找到分号的位置然后把分号后面的字节加载到一个 uint64_t 里。温度值的格式固定是“[-]dd.d”或者“[-]d.d”总长度不超过 5 个字节。通过掩码和移位操作可以一次性提取出符号位、十位、个位和小数位然后组合成最终的浮点值。整个过程没有分支预测失败没有浮点运算全是整数位运算。实测下来SWAR 解析比 strtod 快了将近 8 倍。Agent 在这个环节的表现让我惊讶它不仅能理解 SWAR 的原理还能生成正确的位运算代码。但它犯了一个错误它假设温度值一定是“dd.d”格式忽略了“d.d”和“-d.d”的情况。这个 bug 导致解析结果在遇到个位数温度时全部出错。我后来手动加了长度判断才修复。注意SWAR 代码的可读性极差建议在关键路径上使用并且一定要写单元测试覆盖所有可能的输入格式。2.3 哈希表设计开放寻址 vs 链式冲突站点名的聚合需要哈希表。十亿行数据站点数量大概在几百到几千个之间所以哈希表的负载因子很低冲突不会太严重。但即便如此链式哈希表的指针跳转和内存分配开销在十亿次操作下也会被放大。我最终选择了开放寻址法具体是线性探测。每个槽位存储站点名的哈希值、站点名本身或者指向站点名的指针以及聚合结果。插入和查找都是 O(1) 的而且内存局部性极好缓存命中率高。这里有一个细节站点名的比较。如果用 strcmp每次比较都要遍历字符串开销不小。我的做法是在哈希表槽位里存储站点名的长度和首字符先比较长度和首字符只有这两个都匹配时才做完整比较。这个优化在实测中又省了大概 15% 的时间。Agent 在这个环节建议我使用 std::unordered_map我直接否决了。std::unordered_map 的节点式存储导致内存分散缓存不友好而且每个节点都有额外的指针开销。对于 1BRC 这种场景手写开放寻址哈希表是唯一的选择。2.4 多线程编排怎么分片才不打架十亿行数据单线程处理再快也有限。多线程是必须的但怎么分片是个学问。最简单的做法是按行数均分比如每个线程处理 1 亿行。但问题是行的边界不一定对齐一个线程可能从某行的中间开始读导致解析错误。我的做法是先 mmap 整个文件然后按字节偏移量把文件分成 N 个块每个线程负责一个块。每个线程在开始处理之前先向后扫描找到第一个换行符确保从行首开始。同样在块结束时如果最后一个字符不是换行符就继续读到换行符为止。这样每个线程处理的都是完整的行不会出现半行解析的问题。线程间的结果合并也很关键。每个线程维护自己的局部哈希表处理完自己的块之后再把局部哈希表合并到全局哈希表。合并的时候需要加锁但因为这个操作只发生在线程结束时锁竞争非常少几乎不影响性能。Agent 在这个环节建议我使用线程池和任务队列我试了一下发现任务队列的调度开销在十亿行数据下反而成了瓶颈。最后还是回到了静态分片加局部聚合的方案。3. 实操过程与核心环节实现3.1 环境准备与基线版本搭建先说环境。我用的是一台 16 核 32 线程的机器64GB 内存NVMe SSD。操作系统是 Ubuntu 22.04编译器是 GCC 12.3 和 Clang 16。Rust 用的是 1.75 stable。这些配置对 1BRC 来说算是中上水平但远不是顶配所以优化空间很大。基线版本我用 C 写了一个最朴素的实现fread 逐块读取getline 逐行解析strtod 解析温度std::unordered_map 做聚合单线程。这个版本跑完十亿行大概需要 45 秒其中 I/O 占 20 秒解析占 15 秒哈希表操作占 10 秒。然后我把这个基线版本丢给 Agent让它分析瓶颈。Agent 给出的分析报告相当准确它指出了三个主要瓶颈文件读取的系统调用开销、字符串解析的分支预测失败、哈希表的缓存不友好。它还给出了具体的优化建议包括 mmap、SWAR、开放寻址哈希表。这些建议和我的经验判断基本一致说明 Agent 在性能分析这个环节是靠谱的。3.2 第一轮优化mmap 加多线程第一轮优化我做了两件事把 fread 换成 mmap把单线程改成多线程。mmap 的代码很简单open 文件拿到 fd然后 mmap 到内存返回一个 char* 指针。多线程方面我用了 std::thread创建了 16 个线程每个线程处理文件的一个分片。这里有一个细节分片的大小。如果分片太小线程创建和合并的开销占比就高如果分片太大负载不均衡。我的做法是让每个分片的大小大致等于文件大小除以线程数然后根据实际运行时间做微调。实测下来16 个线程、每个线程处理 800MB 左右的数据效果最好。第一轮优化之后耗时从 45 秒降到了 12 秒。I/O 时间降到了 2 秒解析时间降到了 6 秒哈希表操作降到了 3 秒线程调度和合并占了 1 秒。这个提升非常明显但离极限还有距离。3.3 第二轮优化SWAR 解析加开放寻址第二轮优化聚焦在解析和哈希表上。SWAR 解析的代码我让 Agent 生成了一版然后手动修改了边界条件。核心逻辑是这样的先找到分号的位置然后把分号后面的字节加载到一个 uint64_t 里用掩码提取出各个数字位最后组合成温度值。这里有一个关键点温度值的格式。1BRC 的数据生成器保证温度值在 -99.9 到 99.9 之间格式是“[-]dd.d”或者“[-]d.d”。所以分号后面的字节数最多是 5 个最少是 3 个。我写了一个分支判断根据字节数选择不同的解析路径。这个分支在十亿次调用中会被预测得很准因为大部分温度值都是“dd.d”格式。哈希表方面我实现了一个固定大小的开放寻址哈希表槽位数是 2 的幂次方用位运算代替取模。每个槽位存储一个 64 位的哈希值、一个指向站点名的指针、以及 min/max/sum/count 四个聚合值。插入的时候用线性探测查找的时候先比较哈希值再比较站点名。第二轮优化之后耗时从 12 秒降到了 4.5 秒。解析时间降到了 1.5 秒哈希表操作降到了 1 秒I/O 还是 2 秒。这个时候 I/O 成了最大的瓶颈因为 mmap 的页面错误开销开始显现。3.4 第三轮优化预加载与缓存优化第三轮优化针对 I/O 和缓存。I/O 方面我在 mmap 之后加了一个 madvise(MADV_WILLNEED) 调用让内核提前把页面加载到页缓存。这个操作本身是异步的不会阻塞主线程但能显著减少后续的缺页中断。实测下来I/O 时间从 2 秒降到了 1.2 秒。缓存方面我对哈希表的槽位做了对齐确保每个槽位正好占一个缓存行64 字节。这样在探测的时候一次缓存行加载就能拿到整个槽位的数据不会出现跨缓存行访问。这个优化又省了大概 0.3 秒。第三轮优化之后耗时降到了 3.8 秒。这个成绩在社区里算是中上水平离顶尖的 1.5 秒还有差距但考虑到我的硬件配置和开发时间我已经比较满意了。3.5 Rust 版本的实现差异Rust 版本我基本上是照着 C 版本的思路重写的但有几个关键差异。第一Rust 的 mmap 需要用 memmap2 这个 crateAPI 比 C 的 mmap 更安全但灵活性稍差。第二Rust 的 SWAR 实现需要用 unsafe 块因为涉及裸指针操作。第三Rust 的哈希表我用了 hashbrown 这个 crate它本身就是开放寻址的实现性能很好。Rust 版本最终耗时 4.1 秒比 C 版本慢了 0.3 秒。这个差距主要来自 Rust 的边界检查和安全抽象。但 Rust 版本的优势在于Agent 生成的代码几乎没有内存错误编译通过的版本直接就能跑调试成本低很多。4. 常见问题与排查技巧实录4.1 Agent 生成代码的典型问题在整個优化过程中Agent 生成的代码大概有 30% 是需要手动修改的。我总结了几类典型问题。第一类是边界条件遗漏。比如 SWAR 解析代码Agent 只处理了“dd.d”格式忽略了“d.d”和“-d.d”。这类问题在单元测试中很容易发现但如果没有测试就会导致结果错误。第二类是内存安全问题。Agent 生成的 C 代码经常有越界读取比如在找分号的时候没有检查字符串结尾。这类问题在十亿行数据下可能不会立即崩溃但会导致结果不稳定。第三类是性能反模式。Agent 有时候会建议使用 std::function 或者虚函数来实现多态这在热路径上会带来严重的性能损失。我一般会直接否决这类建议改用模板或者函数指针。4.2 性能瓶颈的快速定位方法定位瓶颈我主要用两个工具perf 和 vtune。perf 适合快速查看热点函数vtune 适合做深入的微架构分析。perf 的用法很简单perf record -g ./1brc然后 perf report 查看热点。我一般先看 CPU 周期花在哪些函数上如果某个函数占比超过 20%那就是重点优化对象。vtune 的用法稍微复杂一些但能给出更详细的信息比如缓存命中率、分支预测失败率、内存带宽利用率。对于 1BRC 这种场景我重点关注三个指标L1 缓存命中率、分支预测失败率、内存带宽利用率。如果 L1 命中率低于 95%说明数据局部性不好如果分支预测失败率高于 5%说明有大量不可预测的分支如果内存带宽利用率接近饱和说明 I/O 是瓶颈。4.3 常见问题速查表问题现象可能原因排查方法解决方案程序崩溃或结果不稳定内存越界或未定义行为用 AddressSanitizer 编译运行检查指针操作加边界判断多线程版本比单线程还慢锁竞争或伪共享用 perf 查看锁等待时间改用局部聚合减少共享状态解析结果错误SWAR 边界条件遗漏写单元测试覆盖所有格式补充分支判断处理短格式I/O 时间过长缺页中断频繁用 perf 查看 page-fault 次数加 madvise 预加载哈希表操作慢缓存不友好用 vtune 查看 L1 命中率改用开放寻址对齐缓存行4.4 独家避坑技巧第一个技巧在优化之前先确保基线版本的结果是正确的。我见过太多人一上来就做极限优化结果跑出来的结果是错的白白浪费了大量时间。我的做法是先用小数据集比如一百万行验证正确性然后再上十亿行做性能测试。第二个技巧每次只改一个变量。性能优化最忌讳一次改多个地方因为一旦性能下降你根本不知道是哪个改动导致的。我的做法是每轮优化只改一个点改完测完再改下一个。第三个技巧保留每个版本的代码和性能数据。我用 git 管理代码每个优化版本打一个 tag然后在 README 里记录每个版本的耗时和瓶颈分析。这样如果某个优化导致性能回退我可以快速回滚。第四个技巧不要迷信 Agent 的建议。Agent 在性能优化上的知识储备确实很强但它缺乏对具体场景的理解。比如它会建议使用 SIMD 指令但如果你的数据格式不支持向量化这个建议就是错的。我的做法是把 Agent 的建议当作候选方案而不是最终方案。第五个技巧关注内存带宽。1BRC 这种数据密集型任务内存带宽往往是最终的瓶颈。如果你的优化已经让 CPU 利用率接近 100%但耗时还是降不下来那大概率是内存带宽饱和了。这个时候再优化计算逻辑已经没有意义需要考虑减少内存访问比如用更紧凑的数据结构或者把中间结果放在缓存里。4.5 Agent 优化的边界在哪里经过这次实践我对 Agent 在性能优化上的能力边界有了比较清晰的认识。Agent 擅长的部分识别经典瓶颈I/O、解析、哈希表、生成标准优化代码mmap、SWAR、开放寻址、分析性能数据热点函数、缓存命中率。这些任务有明确的模式和最佳实践Agent 的表现相当不错。Agent 不擅长的部分判断优化优先级、评估优化风险、处理边界条件、理解具体场景的约束。这些任务需要经验和直觉Agent 目前还做不到。所以我的结论是Agent 是一个强大的辅助工具但它不能替代人的判断。在 1BRC 这种高度依赖经验的优化任务中人的角色是决策者和验证者Agent 的角色是方案生成者和代码生成者。这个分工在可预见的未来应该不会改变。最后再分享一个小技巧如果你也想用 Agent 做性能优化建议从简单的任务开始比如先让它优化一个冒泡排序看看它能不能正确识别出 O(n²) 的瓶颈并改成 O(n log n)。如果这个任务它都做不好那 1BRC 这种复杂场景就更不用指望了。我在实际使用中发现Agent 在算法层面的优化建议往往比在工程层面的建议更靠谱因为算法有明确的理论依据而工程优化更多依赖具体场景的权衡。