0.10 Lab 0:先被骗一次
上一节:0.9 从 C++ 到 JVM:哪些直觉必须修正 | 下一节:第 1 章 指标与目标 配套代码:00-introduction/10-lab0 预计时长:60 分钟(编码 20 分钟 + 观察与记录 40 分钟)
一、这个 Lab 想让你获得什么
不是学会写基准测试——那是第 2 章的事。
而是:亲手制造一次「数据完全错误,但看起来很正常」的经历,并把这种不适感记住。
因为你之后每一次看到漂亮的性能数字,都需要先怀疑一次。这种怀疑能力没法靠读书获得,只能靠被骗过一次。
二、实验的三个阶段
阶段 1:制造一个荒谬的数字(不消费返回值)
写一个「看起来有计算量」的函数,例如:对一个 4096 个元素的 IntArray 求和。
然后用 System.nanoTime() 包住一个循环调用它,打印每次迭代的平均耗时。故意不做任何防护:
- 不预热;
- 不消费返回值;
- 输入数组用规律数据(
IntArray(4096) { it })。
跑三次,观察数字。
阶段 2:加上防护,看数字怎么变
只改两件事:
- 消费返回值(把结果累加到一个外部的变量里,防止被优化掉);
- 输入改成运行时随机数据(防止常量折叠)。
再跑 20 轮,观察数字如何从「几百纳秒」逐步下降到稳定值。
阶段 3:观察预热曲线
每 2000 次迭代采样一次耗时,打印 60 个采样点,画出(或观察)曲线,找到进入平台的时刻。
三、你应该观察到什么(预期现象)
⚠️ 你的具体数字会和我这里不同(取决于 CPU、JDK 版本、是否向量化),但现象的形态应该一致。
阶段 1 的预期:荒谬
round 0: 131.74 ns/op ← 第一次很慢:类加载 + 解释执行
round 1: 0.21 ns/op ← 荒谬!比一次内存访问还快
round 2: 0.19 ns/op ← 依然荒谬
为什么荒谬:在现代 CPU 上,一次内存访问大约 1 纳秒,一次加法大约 0.3 纳秒。4096 次加法不可能在 0.19 纳秒内完成——这个数字在物理上不可能。
原因是:计算结果没人用,JIT 把整个循环删掉了(死代码消除,DCE)。你测的是一个空循环。
阶段 2 的预期:逐步收敛
第 0 轮: 842.5 ns/op ← 解释执行
第 1 轮: 402.1 ns/op ← C1 编译完成
第 2 轮: 268.3 ns/op ← 开始进入 C2
第 3 轮: 241.9 ns/op
第 4 轮: 233.6 ns/op
第 5–19 轮: 229.8 ± 5.4 ← 稳态
这就是「预热」的可见形态:性能不是一次到位的,而是分几个台阶降下来的。
阶段 3 的预期:渐近的平台
block 0: 1180.42 ns/op
block 2: 341.77
block 4: 252.10
block 8: 231.55 ← 大约在这里进入平台
block 20: 229.41
block 59: 229.87
关键观察:平台不是「突然到达」的,而是渐近的。这解释了为什么「预热 10 轮」这种固定写法不如「观察方差何时收敛」。
四、一个可能让你意外的发现
算出稳态后,你很可能会做这样一个除法:
229.8 ns / 4096 个元素 ≈ 0.056 ns/元素
这个数字也是错的——它意味着每秒能处理 178 亿个元素,远超单核的标量吞吐能力。
正确的解释是:C2 已经把这个循环自动向量化了(SuperWord 优化),一条 SIMD 指令可以同时加 8 个或 16 个 int。所以:
4096 个元素 / 229.8 ns ≈ 17.8 个元素/ns ≈ 每纳秒处理 17.8 个
这告诉我们一件重要的事:不要用「单元素成本 × 元素个数」去推算总耗时,因为优化器会改变单位成本本身。这个教训在第 2 章会反复出现。
五、记录要求
请照 docs/experiments/_TEMPLATE/example-lab0.md 的格式,建立 docs/experiments/E00-.../README.md,其中必须包含三样东西:
- 每一轮的原始输出(直接贴文本,不要只写「稳定在 230 ns」)。
- 「预期 vs 实际」表,三列:我以为 / 实际是 / 结论。至少写三行。
- 至少两条进假设台账的条目,例如:
- 「第一轮慢是因为机器性能差」→ 已排除(是解释执行 + C1 阶段)
- 「0.19 ns/op 说明这个方法极快」→ 已排除(是死代码消除)
顺便把索引 docs/experiments/README.md 里的 E00 那一行也填上。
六、验收标准
- 阶段 1 跑出了物理上不可能的数字(小于 1 ns/op),并且你能解释它为什么不可能。
- 阶段 2 观察到了从几百纳秒降到稳态的台阶式下降。
- 阶段 3 找到了大致的稳态起点(第几个 block)。
- 你能解释「为什么 0.19 ns/op 是假的」,并说出至少两种让测量变可信的手段。
- 实验档案里有「预期 vs 实际」表,且至少两条进了假设台账。
七、常见问题
Q:我跑出来的数字不荒谬,一直稳定在 1000 ns 左右,为什么?
A:可能你无意中已经消费了结果(比如把结果打印出来了),或者 JDK 版本没有做这个优化。检查两点:① 循环体的返回值是否被使用了;② 数组是不是被 println 或别的方式「用掉」了。也可以把循环次数加大,看数字是否随规模变化——如果完全不随规模变化,说明计算被删掉了。
Q:为什么第一次跑特别慢? A:包含三部分开销:JVM 启动、类加载、以及最初的解释执行阶段。这也是为什么性能测试必须区分「冷启动」和「稳态」。
Q:这个 Lab 测出来的数字有意义吗? A:没有。 这里的环境(你的开发机、默认 JVM 参数、单线程、无 IO)不具备任何代表性。这个 Lab 的价值全在「观察测量如何出错」的过程,不在数字本身。真正有意义的基准从第 2 章开始。
Q:我需要装 JMH 吗? A:这一章不需要。第 2 章会正式引入 JMH 和 kotlinx-benchmark——那时你会看到它们如何自动帮你避开这里手动踩的每一个坑。
八、这一 Lab 与后续章节的联系
| 你在这里遇到的 | 第几章会正式解决 |
|---|---|
| 死代码消除、常量折叠 | 第 2 章(JMH 的 Blackhole、@State) |
| 预热与稳态 | 第 2 章(@Warmup、@Measurement) |
| 数字抖动、需要多次运行 | 第 5 章(噪声底线、CV、统计检验) |
| 「这个数字代表什么」 | 第 1 章(指标定义与测量点) |
| 「测出来之后怎么记录」 | 现在(docs/experiments/) |
做完这个 Lab,你就有了一个具体的、亲历过的例子——后面每次讨论「数据可信度」时,你都可以回到这个例子。