文档目录

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 章:

  1. 怎么保证计算结果一定被「用到」?(JMH 的 Blackhole)
  2. 怎么保证输入不可预测?(@State + 运行时生成)
  3. 怎么保证每一轮测量不受前一轮影响?(@Fork)

六、记录要求

跑完后,请建立 docs/experiments/E00-.../README.md,模板见 docs/experiments/_TEMPLATE/README.md,填好的示范见 docs/experiments/_TEMPLATE/example-lab0.md。

必须包含三样东西:

  1. 三个阶段的原始输出(直接贴文本,不要只写结论)。

  2. 「预期 vs 实际」表,至少三行。例如:

    我以为 实际是 结论
    0.19 ns/op 说明方法极快 4096 次加法不可能这么快 计算被 JIT 删除了
    第一轮慢是机器性能差 稳态后降到 1/5 是解释执行 + C1 阶段
  3. 至少两条进假设台账的条目(docs/experiments/README.md 的 §4)。

七、自查:你真的看懂了吗

  • 我能解释「0.19 ns/op」为什么在物理上不可能。
  • 我能说出阶段 1 里我犯了哪三个错误。
  • 我能解释为什么阶段 2 的数字是「台阶式」下降的。
  • 我知道「230 ns ÷ 4096」这个除法错在哪里。
  • 我能说出至少两种让测量变可信的手段,以及它们各自防的是什么。

八、这段代码的局限

  • 运行在开发机上,有后台进程干扰,数字仅供量级对比。
  • 只做了粗略预热,没有隔离执行(没有 fork),因此依然不可信——这正是它的教学价值:它演示了「不严谨的测量有多不可靠」,而不是要给你一个可用的基准工具。
  • 真正的基准从第 2 章开始。