0.10 配套代码:Lab 0 —— 先被骗一次
对应小节:0.10 Lab 0:先被骗一次 目标:亲手制造一个物理上不可能的性能数字,然后看它如何被修正。
一、阶段 1:故意写出一个坏基准
三个「错误」同时犯:不预热、不消费返回值、输入是编译期可见的规律数据。
// src/main/kotlin/lesson00/Lab0Naive.kt
package lesson00
/** 被测函数:对 4096 个 int 求和 */
fun sumArray(data: IntArray): Long {
var s = 0L
for (v in data) s += v
return s
}
fun main() {
val data = IntArray(4096) { it } // 错误 ③:规律数据,利于常量折叠
repeat(3) { round ->
val t0 = System.nanoTime()
repeat(100_000) {
sumArray(data) // 错误 ②:返回值没人用 → 死代码消除
}
val t1 = System.nanoTime() // 错误 ①:完全没有预热
println("round %d: %8.2f ns/op".format(round, (t1 - t0) / 100_000.0))
}
}
预期输出:
round 0: 131.74 ns/op ← 第一次很慢:类加载 + 解释执行
round 1: 0.21 ns/op ← 荒谬!
round 2: 0.19 ns/op ← 依然荒谬
为什么 0.19 ns/op 是不可能的:现代 CPU 上一次内存访问约 1 ns、一次加法约 0.3 ns。4096 次加法不可能在 0.19 ns 内完成。 结果没人用,JIT 把整个循环删掉了。
二、阶段 2:只修两个错,看数字怎么变
// src/main/kotlin/lesson00/Lab0Consuming.kt
package lesson00
import kotlin.random.Random
private var sink = 0L // 全局副作用,阻止死代码消除
fun main() {
// 修正 ③:运行时随机数据,防止常量折叠
val rnd = Random(42)
val data = IntArray(4096) { rnd.nextInt() }
repeat(20) { round ->
val t0 = System.nanoTime()
repeat(10_000) {
sink += sumArray(data) // 修正 ②:消费返回值
}
val t1 = System.nanoTime()
println("round %2d: %8.2f ns/op".format(round, (t1 - t0) / 10_000.0))
}
println("sink = $sink") // 确保结果真的被使用
}
预期输出(台阶式下降):
round 0: 842.50 ns/op ← 解释执行
round 1: 402.10 ns/op ← C1 编译完成
round 2: 268.30 ns/op ← 开始进入 C2
round 3: 241.90 ns/op
round 4: 233.60 ns/op
round 5: 229.80 ns/op ← 稳态开始
...
round 19: 229.41 ns/op
sink = 8979428301234
这就是「预热」的可见形态:性能分几个台阶降下来,而不是一次到位。
三、阶段 3:把预热曲线画出来
// src/main/kotlin/lesson00/Lab0WarmupCurve.kt
package lesson00
import kotlin.random.Random
fun main() {
val rnd = Random(42)
val data = IntArray(4096) { rnd.nextInt() }
var sinkLocal = 0L
// 每 2000 次迭代采样一次,共 60 个采样点
repeat(60) { block ->
val t0 = System.nanoTime()
repeat(2_000) { sinkLocal += sumArray(data) }
val nsPerOp = (System.nanoTime() - t0) / 2_000.0
println("block %2d: %10.2f ns/op".format(block, nsPerOp))
}
println("sink = $sinkLocal")
}
预期输出:
block 0: 1180.42 ns/op ← 解释执行
block 1: 612.08 ns/op ← C1
block 2: 341.77 ns/op ← C2 开始介入
block 4: 252.10 ns/op
block 8: 231.55 ns/op ← 大约在这里进入平台
block 20: 229.41 ns/op
block 59: 229.87 ns/op
关键观察:进入平台是渐近的,不是突然的。所以「预热 10 轮」这种固定写法不如「观察方差何时收敛」(第 5 章会讲怎么量化)。
四、一个额外的发现:230 ns 也不能直接除
看到稳态是 229.8 ns 处理 4096 个元素,你很可能会算:
229.8 / 4096 ≈ 0.056 ns/元素
这个数字也是错的。它意味着每秒能处理 178 亿个元素,远超标量运算能力。正确的解释是 C2 做了自动向量化(SuperWord 优化),一条 SIMD 指令同时加多个 int:
4096 个元素 / 229.8 ns ≈ 17.8 个元素/ns
教训:不要用「单元素成本 × 元素个数」推算总耗时——优化器会改变单位成本本身。
五、进阶实验:让「荒谬」变成「可信」
如果你想直观看到 JMH 是如何保护你的,可以先手动加一个「伪 Blackhole」:把所有结果写进一个 volatile 变量,并把输入数组改成运行时生成的更大规模数据。你会发现:
- 数字回到几百纳秒的合理量级;
- 但依然不稳定——因为每轮之间的 JIT profile 互相污染、没有 fork 隔离、分支预测过于理想。
这些残留问题正是第 2 章 JMH 要解决的。做完本 Lab 后,请带着这三个问题进入第 2 章:
- 怎么保证计算结果一定被「用到」?(JMH 的
Blackhole) - 怎么保证输入不可预测?(
@State+ 运行时生成) - 怎么保证每一轮测量不受前一轮影响?(
@Fork)
六、记录要求
跑完后,请建立 docs/experiments/E00-.../README.md,模板见 docs/experiments/_TEMPLATE/README.md,填好的示范见 docs/experiments/_TEMPLATE/example-lab0.md。
必须包含三样东西:
-
三个阶段的原始输出(直接贴文本,不要只写结论)。
-
「预期 vs 实际」表,至少三行。例如:
我以为 实际是 结论 0.19 ns/op 说明方法极快 4096 次加法不可能这么快 计算被 JIT 删除了 第一轮慢是机器性能差 稳态后降到 1/5 是解释执行 + C1 阶段 -
至少两条进假设台账的条目(
docs/experiments/README.md的 §4)。
七、自查:你真的看懂了吗
- 我能解释「0.19 ns/op」为什么在物理上不可能。
- 我能说出阶段 1 里我犯了哪三个错误。
- 我能解释为什么阶段 2 的数字是「台阶式」下降的。
- 我知道「230 ns ÷ 4096」这个除法错在哪里。
- 我能说出至少两种让测量变可信的手段,以及它们各自防的是什么。
八、这段代码的局限
- 运行在开发机上,有后台进程干扰,数字仅供量级对比。
- 只做了粗略预热,没有隔离执行(没有 fork),因此依然不可信——这正是它的教学价值:它演示了「不严谨的测量有多不可靠」,而不是要给你一个可用的基准工具。
- 真正的基准从第 2 章开始。