bigywater.tech
Go · 47

边界检查消除:哪些写法能让编译器省掉检查

每次 s[i] 都要检查 i 是否在范围内,编译器会尽量证明“一定在范围内”并去掉检查。这篇不靠猜测,直接看汇编里还剩多少个 panic 出口:哪些写法能让检查消失,哪些提示写法有效、哪些反而没用,以及检查到底占多少成本。

0 处
range、i < len(s)、i&7、uint(i) < uint(len(s))、倒序循环、clampN 的汇编里没有 panicBounds 调用
4 → 1
fourConst 的 4 处检查,加一句 _ = s[3] 之后合成 1 处
0.3%
关掉全部边界检查(-B)后,i < n 求和循环快的比例

哪些写法能消除检查,哪些提示有效

实测 · 本机执行输出
  rangeLoop      (没有 panic 调用点)
  rangeValue     (没有 panic 调用点)
  lenLoop        (没有 panic 调用点)
  unknownN       1 处 CALL runtime.panicBounds
  hintBefore     2 处 CALL runtime.panicBounds
  hintUnguarded  1 处 CALL runtime.panicBounds
  clampN         (没有 panic 调用点)
  resliceFirst   1 处 CALL runtime.panicBounds
  fourConst      4 处 CALL runtime.panicBounds
  fourConstHint  1 处 CALL runtime.panicBounds
  maskIndex      (没有 panic 调用点)
  modIndex       1 处 CALL runtime.panicBounds
  uintIndex      (没有 panic 调用点)
  twoIdx         (没有 panic 调用点)
  reverse        (没有 panic 调用点)
  backwards      (没有 panic 调用点)
  sliceExpr      2 处 CALL runtime.panicBounds
  fromData       1 处 CALL runtime.panicBounds

失败的边界检查在汇编里是一个对 runtime.panicBounds 的调用点,数调用点就知道还剩几处检查(-d=ssa/check_bce/debug=1 也能列出,但它的行号归属不如汇编直观,所以这里用汇编)。每个被测函数都加了 //go:noinline,避免内联后被调用点的常量把检查消掉。

编译器的证明基于“下标的取值范围”:range 与 i < len(s) 天然在范围内;i&7 用在长度为 8 的数组上、先判断 uint(i) < uint(len(s))、倒序到 0 也都能被证明。clampN(先把 n 夹到 len(s))让循环里的检查消失,是比较稳妥的写法。

在循环前写 _ = s[3] 能把后面几个常量下标的检查合并成这一处(4 处变 1 处)。但提示要写对:不带 if 的 _ = s[n-1] 让循环里的检查消失;带 if n > 0 守卫的版本反而留下 2 处(守卫让编译器没能把“n ≤ len(s)”用于整个循环)。这是对实验现象的描述,编译器内部的证明规则我没有读源码。

成本

实测 · 本机执行输出
  默认编译:
    range 遍历(没有检查)                       中位 296.53 ns/op(最小 296.13 / 最大 301.88)
    i < n,n 来自参数(每次迭代检查)                 中位 296.91 ns/op(最小 296.56 / 最大 299.94)
    先 _ = s[n-1] 再循环                     中位 298.35 ns/op(最小 297.69 / 最大 301.59)
    下标来自另一个切片(无法消除)                      中位 312.35 ns/op(最小 309.04 / 最大 312.98)
  -gcflags=-B(关闭边界检查):
    range 遍历(没有检查)                       中位 296.07 ns/op(最小 295.94 / 最大 299.31)
    i < n,n 来自参数(每次迭代检查)                 中位 296.13 ns/op(最小 295.76 / 最大 299.80)
    先 _ = s[n-1] 再循环                     中位 296.12 ns/op(最小 295.84 / 最大 299.10)
    下标来自另一个切片(无法消除)                      中位 309.91 ns/op(最小 309.06 / 最大 312.05)

在顺序读的求和循环里,默认编译与 -B(关掉所有边界检查)的差别只有 0.3% 到 0.8%:检查是一个几乎总是不跳转的比较,分支预测很准。所以不要为了省检查把代码写得难懂;只有热循环里每个元素都做很少事、而且编译器没能消除检查时才值得去调整。

越界时的行为

实测 · 本机执行输出
    runtime error: index out of range [5] with length 3

越界时仍然是运行时 panic,消息里写明下标与长度。消除检查不改变语义:只有编译器能证明下标一定在范围内时才会去掉检查。

分步演示

边界检查消除
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。边界检查出口数来自 go build -gcflags=-S 的汇编(统计函数里对 runtime.panicBounds 的 CALL 指令个数);-B 是关闭边界检查的编译选项;基准测试每个变体 7 轮取中位数,函数 //go:noinline
    • 没有验证:amd64 上的汇编、多维下标与 map/字符串下标的检查、prove 优化的完整规则、BCE 与向量化/循环展开的交互、-d=ssa/check_bce 与汇编调用点数之间差异的原因(两者统计的对象不同,没有深究)
    京ICP备2026067502号-1京公网安备11010502063487号