文档目录

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:加上防护,看数字怎么变

只改两件事:

  1. 消费返回值(把结果累加到一个外部的变量里,防止被优化掉);
  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,其中必须包含三样东西:

  1. 每一轮的原始输出(直接贴文本,不要只写「稳定在 230 ns」)。
  2. 「预期 vs 实际」表,三列:我以为 / 实际是 / 结论。至少写三行。
  3. 至少两条进假设台账的条目,例如:
    • 「第一轮慢是因为机器性能差」→ 已排除(是解释执行 + 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,你就有了一个具体的、亲历过的例子——后面每次讨论「数据可信度」时,你都可以回到这个例子。