ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent驱动的1BRC优化实践:从12.4秒到3.6秒

Agent驱动的1BRC优化实践:从12.4秒到3.6秒 1BRC 这个挑战我盯了很久之前一直在用纯手写的方式做性能调优这次特意换了个玩法把 Agent 拉进整个优化流程里让它扮演“能自己跑实验、能反复改代码、还能按格式交作业”的工程助理。1BRC 的核心是处理 1 亿行气象数据对每个站点算最小/平均/最大温度能跑进几秒就算高手而 Agent 在这里干的活不是简单补全代码而是把一整套优化任务拆成可执行的循环剖析数据、生成候选方案、实施改动、回归校验、记录结果。这篇文章就是我这次“1BRC Agent 优化”的实践实录从 12.4 秒一路调到 3.6 秒中间踩了精度、分块、并发缓存行好几个坑希望给同样想做极致性能优化、或者正在琢磨怎么用好 Agent 的同学一点参考。1. 先交代一下背景1BRC到底在卷什么1.1 题目本身与硬性约束1BRCOne Billion Row Challenge简单说就是给你一个巨大的文本文件里面是约一亿行“站点名;温度值”格式的测量记录要求按站点名统计出最低温度、平均温度和最高温度最终按站点名排序输出。文件不大不小一般在 12~14GB 左右站点数量大约上万个。题目限制只能用 Java程序通过命令行接收文件路径启动时间也计入总耗时不能依赖外部库也不能用数据库或者预处理后的二进制格式。这题真正难的地方不是算法复杂度而是“量变引起质变”。一亿行的输入量下普通BufferedReader.readLine()配String.split()就已经很伤每一行要产生临时 String、String[]、Double 对象光 GC 就能拖死程序。再加上随机访问几十个并发线程去抢 station 的 HashMap你会看到 CPU 明明拉满了但耗时还是按照“每多一个千万行就线性上涨”的节奏在走。我最初用手写的 naive 版本跑了一遍耗时 12.4 秒当时就知道后面的路只能靠“减少分配、绕过不需要的抽象、让内存访问更局部”这三板斧。1.2 我为什么要用Agent来参与优化以前我做这类优化基本是凭直觉改一版、跑一下、看火焰图再改一版。这个流程本身没问题但非常吃“实验记录”的纪律性改了哪些东西、为什么改、上次跑出多少秒、这次是环境波动还是真实提升一旦实验多了脑子根本记不住。Agent 对我来说不是那种“你在对话框里问一句它给你回一段代码”的聊天工具而是能被装进一套 harness 里的自动工程助理它可以自己读文件、执行基准测试、看结果、按提示生成多组代码变体然后再把变体扔回编译和运行脚本里做回归。这里有个概念值得先理清Agent 和 harness 不是一回事。Agent 负责“决策”也就是根据当前状态决定下一步该改哪块代码、用什么优化手段harness 负责“执行环境”包括工具调用、进程沙盒、编译检查、结果回传。我这次自己做了一个很薄的 harness主要就是循环执行“Agent 提出改动 - 打补丁 - 编译 - 跑 100 万行样本 - 对比输出 - 反馈给 Agent”这个流程。这样一来Agent 的很多灵感能被快速验证而不是停在它脑内。2. Agent在优化过程中扮演的角色与整体方案2.1 先把优化目标量化让 Agent 帮你优化第一件事不是急着写代码而是把目标写成可测量的数字。我先把机器环境固定了8 核 16 线程的 Linux 机器16GB 内存JDK 21文件放在 tmpfs 上避免磁盘 IO 干扰。随后用这条命令跑了原始版本/usr/bin/time -v java CalculateAverage_baseline.java ./measurements.txt基线 12.4 秒。我又把这个跑分要求写进了 Prompt第一阶段目标 8 秒以内第二阶段 5 秒以内最终希望接近 4 秒。为什么强调分阶段因为 Agent 如果一开始就被告知“给我最快方案”它容易直接把 mmap、多线程、自定义 Map 全堆上去到时候哪一步有效、哪一步是负优化你根本说不清。分阶段让 Agent 每次只解决一个小目标实验记录会干净很多。2.2 Agent的四个迭代阶段我这次把整个优化过程切成四段数据解剖用wc -l确认行数抽前面几千行看格式统计行长度分布和站点名长度。Agent 根据这些信息判断 parse 热点在哪里。数据分析这一步很关键很多站点名是短到几个字节的长站点名会影响哈希函数选择。现状梳理用 async-profiler 跑一次基线版本看 CPU 在 readLine、parse、HashMap 上的占比。Agent 根据火焰图输出生成“优化候选树”。候选优化树Agent 会列出可尝试的优化点比如 mmap、内存分块、固定点温度表示、自定义哈希表、并行合并策略然后按预估收益排序。实现与回归每个候选方案都要打一个独立补丁跑完测试后把“是否通过、耗时、差异点”记录下来再让 Agent 决定保留下一个方案还是回滚。整个过程中我一直保留一个“裁判”逻辑任何补丁必须先通过正确性脚本再谈性能。正确性脚本很简单就是对 10 万行的小样本跑一个完全 naive 的实现和改动后的实现然后diff输出。这一步不能省因为 Agent 经常能给你一个看起来很快、但输出格式差一位小数的“优化”。2.3 为什么用Agent而不是直接手写有人可能觉得自己懂优化为什么还非要让 Agent 插手我的体会是这类优化工作里重复劳动占比比想象中高很多。比如“把某个 Double 运算改成 int 定点表示”“把一个 HashMap 换成开放寻址数组”“把循环里 String 拼接换成 byte[] 输出”这些模式本身是明确的但每种组合都要改一遍代码、跑一遍基准、再排一遍实验人工做很容易疲劳。Agent 恰恰擅长这种“多方案生成 实验矩阵”的工作。但 Agent 也有明显的短板它会一本正经地胡编 API会忽略 JVM 启动开销会把“理论复杂度”和“实际分支预测”混为一谈。所以我的定位是Agent 出方案、写补丁、跑实验我负责给它的工作划边界、审正确性、判断有没有引入脏数据。说穿了Agent 是放大你工程能力的工具不是替你背书的专家。3. 用Agent把1BRC从12秒优化到3.6秒的实操记录3.1 第一轮mmap加自定义字节解析第一刀切在最典型的浪费上。原始版本大概是这样的try (var reader Files.newBufferedReader(path, StandardCharsets.UTF_8)) { String line; while ((line reader.readLine()) ! null) { int idx line.lastIndexOf(;); String station line.substring(0, idx); double temp Double.parseDouble(line.substring(idx 1)); // update map... } }这段代码每行至少产生两个 String 对象外加一个Double装箱。一亿行就有数亿个短生命周期对象GC 压力非常大。Agent 第一轮给出的建议是把文件映射成MappedByteBuffer然后完全绕过String解析先用字节定位分号再把站点名存成字节数组温度用自己写的整数解析。温度这部分的解析其实很适合展开说。因为题目里温度固定保留一位小数比如-12.3、0.0、27.8根本不需要Double.parseDouble。我让 Agent 实现了一个parseTemperatureprivate static int parseTemperature(ByteBuffer buf, int pos, int limit) { int sign 1; if (buf.get(pos) -) { sign -1; pos; } int value 0; while (pos limit buf.get(pos) 0 buf.get(pos) 9) { value value * 10 (buf.get(pos) - 0); pos; } if (pos limit buf.get(pos) .) { value value * 10 (buf.get(pos 1) - 0); pos 2; } return sign * value; }这里返回的是一个“十分度”整数。比如-12.3解析成-12327.8解析成278。后续求平均值的时候只要保存整数式总分和个数最后输出前再手动补小数点。这一轮改完耗时从 12.4 秒降到 7.8 秒。为什么提升这么明显因为每一次Double.parseDouble背后是一整套浮点字符解析逻辑而整数解析只需要几次乘法和加法再加上省掉了 String、Double 的分配GC 压力骤降。3.2 第二轮去掉Map里的String和Double第一轮做完我当时想的是“其实已经不错了”但是 Agent 给的火焰图分析显示HashMap.put和HashMap.get的 CPU 占比仍然很高。原因很简单默认HashMap的 key 是String对象而为了构造这个String我又在解析站点名的时候把字节复制成了 char[] 和 String。而且温度统计值如果一开始用的是double[]数组后续频繁读改写也会涉及浮点运算。所以第二轮的核心是把 String 和 Double 都“赶出去”。Agent 提出用自定义开放寻址哈希表key 直接用byte[]的引用或一段字节的哈希结果value 用一个紧凑的 Entry 数组。因为站点名最多几千到一万个哈希表容量根本不需要 1 亿条只要初始容量足够大、负载因子控制在 0.5 以下就能做到每个 station 的查询接近 O(1)。简化的表结构可以这样理解int tableSize 1 16; byte[][] keys new byte[tableSize][]; long[] sumTenths new long[tableSize]; int[] minTenths new int[tableSize]; int[] maxTenths new int[tableSize]; int[] counts new int[tableSize]; long[] occupancy new long[tableSize]; // 用特殊占位标记是否有效我没有直接用HashMap因为默认 HashMap 的Entry节点是独立对象每个 station 一个对象看似不多但哈希扩容时要把所有对象 rehash 一遍过程很重。自己实现的数组式哈希表全部内存是连续分配的CPU 缓存命中率更好。第二轮改完耗时降到了 6.1 秒。3.3 第三轮并行分块的正确姿势从单线程到多线程Agent 的第一版实现是“每个线程处理一个连续的字节区间”。听起来没问题但它在实现时忽略了一个细节行尾的\n不一定正好落在分块的边界上。如果每个线程从随机偏移直接开始读很容易把一行切成两段导致解析出半个站点名或者直接把两行拼在一起。这里我用了一个比较干净的分界算法。先算出所有线程的物理分界点然后对每个分界点向后扫描找到最近的\n把\n1作为真正的逻辑切分点long[] cuts new long[parallelism 1]; cuts[0] 0; cuts[parallelism] fileSize; for (int i 1; i parallelism; i) { long pos fileSize * i / parallelism; while (pos fileSize buffer.get(pos) ! \n) { pos; } cuts[i] Math.min(fileSize, pos 1); }这样每个线程处理[cuts[i], cuts[i1])时起点和终点都恰好在完整行之间不会出现半行也不会漏行。实际开启 16 个线程后耗时降到了 4.2 秒。这个提升比预期的略小因为解析和哈希已经成了瓶颈单纯的并行并不能把 CPU 占用压到带宽上限。Agent 在这一轮还自作聪明加了“共享全局统计数组”的想法结果因为缓存行竞争反而更慢这部分细节我放到第 4 章讲。3.4 第四轮用Agent生成A/B实验矩阵到了这一步Agent 的“实验矩阵”能力开始体现出价值。它在一次循环里生成了 4 个候选补丁一个把 map 改成 long 数组按站点 ID 索引一个调整哈希表容量到 64K一个尝试在解析时用switch直接识别站点名一个把输出排序从Collections.sort改成手写的字符串基数排序。我让 harness 挨个编译运行最终只保留了哈希表容量扩到 64K 这一个改动因为另外三个改动在 1 亿行下收益不明显甚至因为代码膨胀而变慢。最终 16 线程、64K 开放寻址表、定点整数统计的版本跑到了 3.6 秒。给一个整体结果表方便你对照方案耗时关键改动原始 readLine 版12.4sBufferedReader split Double加 mmap 和自定义解析7.8s减少 String 和浮点解析加定点整数表示6.1s用 int/long 存十分度温度替换成自定义开放寻址表5.2s消除 HashMap Entry 对象并行分块 16 线程4.2s保证分界完整行扩哈希表容量到 64K3.6s降低哈希碰撞最终方案4. Agent优化翻车的几次现场与排查思路4.1 统计结果对不上浮点精度与“四舍五入”的坑第一版 Agent 优化后的代码跑得很快但正确性脚本直接挂了。问题出在平均温度的舍入规则上。原始规则要求保留一位小数通常的写法是Math.round(mean * 10.0) / 10.0。但一遇到负数Math.round的行为和你想的不一样Math.round(-0.5)返回 0而不是 -1。如果某个站点全天的温度都是 -0.05 这种值结果就会和标准输出差 0.1。更稳妥的做法是自始至终用整数表示温度总和我们统计的是每个温度乘 10 后的整数和平均值也先用整数除法得到一个小数精度为 1/10 的整数然后统一补小数点。因为全程没有浮点运算舍入规则完全可控不会出现 -0.0、 -0.5 这类边界。这个坑也提醒我一件事Agent 很容易默认“浮点运算没问题”但你得给它明确告知“必须用定点整数处理统计”。4.2 切分文件时把一行切飞了并行分块这一版Agent 刚开始给的是一个看起来更“省事”的版本long start fileSize * threadId / parallelism; while (buffer.get(start) ! \n) { start; }它的问题在于它是从start往后找第一个换行然后“跳到换行之后”开始处理。这等于直接丢弃了前半条跨边界的行。如果两个线程的起始区域经过同一行还会出现某一行完全没被处理的情况。我当时在 1 亿行上跑了三分钟没有报错但wc -l出来的聚合结果少了一百多万行。排查的时候我发现正确性脚本用的 10 万行样本太小恰好跨边界的行在一百行以内肉眼根本看不出来。后来我把分块逻辑统一改成“先算全局 cut 数组每个线程只扫自己的[cut[i], cut[i1])”这个问题才彻底消失。这也是我建议所有做大规模并行的朋友不要相信“看起来每个边界都处理到了”这种直觉一定要在超大样本上跑一遍真实校验。4.3 假共享让多线程反而变慢把并行度从 8 提到 16 的时候Agent 提供了一个“高级优化”新建一个全局的long[] counters每个线程直接在该数组里面更新自己的站点统计最后再汇总。理由是“共享 map 就不用合并了”。但实际跑出来16 线程比 8 线程还慢了 0.4 秒。原因就是 False Sharing也就是伪共享。多个线程同时更新邻近的内存地址时CPU 缓存行通常 64 字节会被不同核心来回失效哪怕每个线程写的不是同一个变量也会互相拖累。long[] counters里相邻的计数器极可能落在同一条缓存行里面更新频率又极高于是变成了一场缓存行争夺战。解决办法是回归到线程本地哈希表每个线程维护自己的一份站点统计表全部处理完后再把 16 份局部表合并到一个全局结果表。合并阶段只用一份数据冲突非常有限性能立刻恢复。Agent 当时还尝试加Contended注解来 padding但我觉得与其搞这种 JVM 特供注解不如直接用线程本地副本代码更稳也更符合 JVM 的实际语义。4.4 别让Agent的“内存优化”变成数据地雷还有一次Agent 为了省内存提出把站点名 key 做成ByteBuffer.slice()的视图而不是复制到底层byte[]。从 GC 角度看确实省内存但问题在于MappedByteBuffer的视图在并发场景下不是完全安全的而且如果底层文件的 buffer 被垃圾回收或通道关闭切片状态会变得不可预测。也就是说这只适合纯单线程实验放到 16 线程并行解析时可能在运行到第 8000 万行时突然抛IndexOutOfBoundsException。这类问题很难靠 Agent 自己发现因为它只在“小样本 短时间”下自我验证不会主动触发文件映射的生命周期边界。我后来给 harness 加了一条规则任何涉及 Buffer 或内存映射的改动必须额外跑一遍“全部 1 亿行完整流程”能不能正常退出才算通过。这个约束救了我很多次。5. 关于Agent协作的心得与边界5.1 哪些事交给Agent做最划算经过这次 1BRC 优化我总结了一下 Agent 在性能优化里最合适的任务生成多个实现变体。比如把Double改成定点整数、把HashMap改成开放寻址表、把MappedByteBuffer换成FileChannel读取这类“模式化改写”很费时间Agent 可以批量生成。做实验矩阵并记录。Agent 可以把每次跑分的时间、输出 diff、GC 情况整理成 Markdown 表格省掉我最讨厌的抄数环节。编写正确的校验脚本。Agent 很适合写一个“输入小样本比较输出”的脚本它能快速把正确性门槛立起来。分析 profile 热点。把 async-profiler 的 svg 转成文本摘要后Agent 能很快给出优化排序虽然它的排序偶尔不靠谱但至少能提供方向。5.2 哪些事必须自己审核我这次最大的体会是Agent 能帮你执行但不会替你负责。以下事情我建议每轮都手动过一遍输出格式小数位、排序规则、负数舍入任何一个细节错都会全盘失败。并发边界线程分块、合并逻辑、共享数据竞争Agent 很难证明自己的代码没有数据竞争你得靠正确性脚本和压力测试把边界卡住。测量方式不同的 JVM 预热、文件是否在内存盘、CPU 降频都会影响跑分Agent 自己往往意识不到测量噪声。我在 harness 里加了一个“编译失败即终止”的硬性规则只要 Agent 生成的补丁编译不过就直接把报错回传给 Agent 让它改绝不带病测试。这套机制看着简单但真的能挡住大量本来会浪费几分钟的无效回归。5.3 后续还可以怎么玩做完这次 1BRC我准备把实验记录沉淀成一个历史模式库每一轮的改动摘要、跑分结果、失败原因都写成结构化文本放进本地向量数据库。下次再开一个优化任务时Agent 可以先检索相似场景的历史记录避免再次踩到“浮点舍入”“文件分块”这类老坑。对于慢 SQL 优化、大数据文件处理这套“Agent 出方案 harness 回归 人工审边界”的流程也完全套得上。我现在会把 1BRC 这类任务拆成让 Agent 疯狂生成候选方案我负责给它划定边界。它负责快我负责稳最后一起把性能压到极限。如果你也在折腾 Agent 开发建议先别急着上复杂框架就从这种“跑分 回归”的闭环开始你会比我更快感觉到Agent 的真正边界在哪。
返回列表