3118 字
约 10 分钟
17
S05-A09 · JIT 与微基准:什么时候该相信这个数字
2024-12-31
2026-11-09

微基准报告说新实现快 3 倍,改上线后 P99 纹丝不动。两边都没错,只是测的不是同一个东西:基准测的是一段被 JIT 优化到极限的纯计算,线上跑的是这段计算夹在 IO、锁、分配与 GC 之间的一小片。

对架构决策来说,问题不是"要不要做基准",而是这个数字能外推到哪一层决策:可用于算法级选型,不能用于定容量;能否决候选,不能承诺 SLA。


1. 三个断点:为什么"快 3 倍"到线上归零

断点 基准里 线上 倍数去哪了
计算占比 被测方法是全部 只占全链路 5%–15% 被权重稀释
优化状态 单方法反复编译,C2 到顶 混合调用、类型守卫失效 测的是编译器最理想时
并发形态 单线程无争用 有锁、队列、切换 争用吃掉全部收益

这是可算的:设方法占端到端时间为 p,自身加速 s 倍,端到端加速为 1/((1-p)+p/s)。p=0.1、s=3 时端到端约 1.1 倍——与"毫无变化"一致。看到 N 倍,先问 p 是多少。


2. 失真的五个来源

这五条不是操作不规范,是语义层面的测量偏移:被测的东西已不是你以为的那个。

来源 机制 表面现象 判据
预热不足 前若干轮跑在解释器或 C1 多跑几次差数倍 随迭代数单调改善 = 未到稳态
常量折叠 输入是常量,C2 直接算出结果 耗时接近 0 换随机输入后突变
死代码消除 结果无人使用,整段被删 加更多工作反而更快 用 Blackhole 后差异出现
栈上替换 长循环中途被替换编译 分位诡异、曲线中段跳变 单次超长 + 结果双峰
测量开销 nanoTime 本身有成本 纳秒操作测出微秒 空方法基准不为 0

第五条要先量化:跑一个空基准,得分就是这套装置的本底噪声,小于该量级的差异不构成证据。这是唯一能把"差异"和"噪声"分开的锚点。

@Benchmark public void empty() {}   // 先跑它,得到测量底噪

OSR 让长循环在运行中被编译,副作用是前半段跑在旧版本、后半段跑在新版本,单次到毫秒级时测的就是混合体。JMH 的对策是切成固定批次、让替换只发生在批次边界附近——这是"自己写循环计时"几乎必然测歪的原因。


3. 稳态多久才来

解释执行 → C1 → C2 的路径见分层编译那篇,这里只要记住:"多久之后才是稳态"没有统一答案。

阶段 触发依据 典型量级(按 JDK 17 默认参数核对) 决策含义
解释执行 无条件 首个调用 冷启动路径
C1 编译 调用计数(千次量级) 毫秒到秒 短命进程可能停在 C1
C2 编译 调用 + 回边计数、队列深度 秒到数十秒 基准必须跑到这之后
稳态 队列排空、去优化停止 无固定值 只能靠日志确认

它取决于编译线程数、方法总量、去优化次数与队列积压。做法不是照抄"预热 20 秒",而是按日志核对:

java -Xlog:jit+compilation=info:file=jit.log:time,level,tags -jar app.jar
grep -c 'made not entrant' jit.log      # 去优化次数,高说明优化假设反复失效
grep 'using c2' jit.log | tail -5       # 最后进入 C2 的时间点

判据:日志尾部不再出现新的 using c2,且 made not entrant 停止增长。JMH 默认预热是秒级,常小于稳态到达时间——"JMH 也测不准"多半是预热窗口不够。


4. JMH 解决什么,不解决什么

问题 手段 是否解决
死代码消除 Blackhole.consume 解决
常量折叠 @State + 随机输入 解决(输入真随机)
预热与稳态 @Warmup + 多 fork 解决(预热够长)
测量噪声 多轮统计 + 置信区间 解决
内存分配压力 只测吞吐,不模拟堆 不解决
GC 影响 -prof gc 报分配速率 部分
真实数据分布 取决于你喂什么 不解决
并发争用 能加压,不还原调用比例 部分
@State(Scope.Thread)
public class MapBench {
    private Map<String, String> map;
    private String[] keys;

    @Setup(Level.Trial)
    public void setup() {
        map = new HashMap<>();
        keys = new String[1024];
        for (int i = 0; i < keys.length; i++) {
            keys[i] = UUID.randomUUID().toString();   // 随机键,防常量折叠
            map.put(keys[i], "v");
        }
    }

    @Benchmark @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS)
    public String get(Blackhole bh) {
        return map.get(keys[ThreadLocalRandom.current().nextInt(keys.length)]);
    }
}

Level.Trial 与 Level.Invocation 选错会直接改变结论:前者把构建成本放在测量之外,后者乘进每次调用——那时测的是构建成本,不是查询成本。


5. 指标选择

指标 适合回答 不适合 报告要求
平均耗时 单位操作典型成本 尾部体验 必须带标准差
分位 抖动与尾延迟 容量换算 给直方图,单点分位被分桶掩盖
吞吐 ops/s 单位资源处理量 单次延迟 写明线程数与批次
单次延迟含排队 用户感知 代码优劣 在接近真实的并发度下测

最常犯的错是拿吞吐回答延迟:吞吐高只说明单位时间完成得多,完全可以在吞吐峰值时 P99 很难看。


6. 基准该在哪跑

维度 要求 不满足时的偏移方向
CPU 型号代际 与生产同代 代际 IPC 差异可盖过被测差异
JDK 版本 完全一致含小版本 编译器与 GC 默认值随版本变
JVM 参数 同 GC、同堆 小堆下的分配成本与大堆不同
核数 一致或标注 影响编译线程与内联预算
频率策略 关节能降频 变频使方差大于差异
邻居负载 独占 噪声吃小差异
cpupower frequency-set -g performance          # 固定频率
taskset -c 2-5 java -jar benchmarks.jar -f 2 -wi 5 -i 5 -w 2s -r 2s
java -version 2>&1 | head -3                   # 版本必须记进报告

没有"通用基准机":结论适用范围严格等于测量环境范围。跨机器比较两个实现,等于把两个未知偏移量相减。


7. 负向优化

现象 机制 处理
加缓存后单次更慢 未命中时多一次查找与驱逐 先测命中率
提取小方法后变慢 内联预算被挤占,热点不再内联 看内联日志,合并而非拆分
改并行后单请求更慢 拆分与合并成本高于收益 低于阈值的任务别拆
调参后短暂变快又回落 只是推迟了去优化 观察 made not entrant 趋势

识别负向优化的关键是看改动是否移动了成本位置:把 CPU 成本换成内存成本、把计算换成分配,都会在基准里更快、在线上更慢。


8. 能回答与不能回答

能回答 不能回答
两算法在同一输入规模下的常数因子差异 端到端延迟
同语义数据结构的选型 系统容量与实例数
某段代码是否值得优化(占 p 的比例) 加机器能否解决问题
重构是否让该函数变快 用户体验是否改善

判据:主语是"这段代码"还是"这个系统"——前者可基准,后者只能画像加压测。


9. 外推的三种错误

  1. 线性外推:100ns × QPS = CPU 占用,忽略 GC、锁、队列、序列化,通常低估一个数量级以上。
  2. 比例外推:快 3 倍所以机器省 2/3,只在 p≈1 且无线程池排队时成立。
  3. 绝对外推:把基准纳秒数当线上延迟的一部分,而线上延迟由排队主导。

三者共同点是把稳态单线程数字当成并发系统期望值。修正方式是换工具:容量问题用压测,瓶颈问题用画像。


10. 何时该画像而不是基准

情境 基准 持续画像 理由
候选算法选型 合适 不需要 无现网负载可采
P99 劣化定位热点 不合适 合适 真实分布只在现网
优化后确认收益 对照必需 验证必需 问题不同
容量规划 不合适 不合适 要的是排队模型
依赖升级前后 可选 合适 差异常在交互处

现网有真实负载时,一次短窗口采样比一周基准更有信息量——它测的是真实的 p 和调用比例。

回归化是另一笔账:进 CI 就是每次合并都跑,成本是固定机器时长加不稳定的判定阈值。可行做法是只对已确认的关键路径跑短基准,阈值设成"退化超过某个百分比才失败",并允许人工重设基线:

java -jar benchmarks.jar '.*OrderPath.*' -f 1 -wi 3 -i 3 -w 1s -r 1s -rf json -rff bench.json
# 与基线比对;阈值按本机方差定

共享 runner 的方差常大于被测差异,这类门禁在没有专用机器的团队里会先变成噪声源,再被关掉。


11. 排错:六种"数字不可信"

现象 原因 处理
多跑几次差几倍 预热不足或未 fork 独立 JVM 加 @Fork、延长预热、看编译日志
耗时接近 0 死代码消除或常量折叠 加 Blackhole、随机输入
结果双峰 OSR 混合两种产物 缩短粒度,改批次
加功能反而更快 工作量被部分消除 校验副作用是否真发生
本机复现不出数字 环境不同 记录环境,同环境内比较
基准稳定线上抖动 线上有 GC 与争用 改画像 + -prof gc 看分配速率

12. 边界:不必做基准的情况

情境 更合适的做法 理由
瓶颈在 IO 或锁 线程栈 + 锁 profile 被测代码不在关键路径
差异小于噪声 不下结论 噪声内差异不构成依据
一次性选型决策 任务型基准或 PoC 微基准不代表集成后表现
调用极少 直接读代码 收益小于维护成本
结论会改架构 压测 + 原型 架构决策要系统级证据

13. 自测

  1. 微基准快 3 倍、线上无变化,先算哪个量才能判断这现象是否正常?
  2. 空基准的得分在整套测量里起什么作用?
  3. 为什么"预热 20 秒"不是可以照抄的参数?
  4. Level.Trial 与 Level.Invocation 选错会测到什么?
  5. 吞吐提升与 P99 恶化同时出现,两个数字各自约束什么?
  6. 什么信号说明该关掉基准、改用现网画像?
S05-A09 · JIT 与微基准:什么时候该相信这个数字
https://www.clxhxhhr.top/posts/6244/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。