使用unsafe消除Go中的边界检查

Lobsters Hottest 工具

摘要

本文解释了如何在Go中使用unsafe指针算术来消除编译器无法移除的边界检查,从而提升热点路径的性能。它讨论了边界检查的开销和传统的BCE方法,然后介绍了unsafe方法。

<p><a href="https://lobste.rs/s/1kpfog/eliminating_go_bound_checks_with_unsafe">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/07 08:14

# 使用 unsafe 消除 Go 边界检查 来源:https://blog.andr2i.com/posts/2026-07-06-eliminating-go-bound-checks-with-unsafe 热路径优化:当你能证明边界检查确实不必要时,使用 unsafe 指针运算消除 Go 编译器无法移除的边界检查。 2026 年 7 月 6 日 *属于**优化目录**系列的一部分:* 1. 当浮点除法胜过整数除法 (https://blog.andr2i.com/posts/2026-06-08-optimization-catalog-when-float-division-beats-integer-division) 2. 4 字节填充如何使数组清空快 49% (https://blog.andr2i.com/posts/2026-06-22-optimization-catalog-how-4-bytes-of-padding-make-array-clearing-49-faster) 3. **使用 unsafe 消除 Go 边界检查***\(本文\)* --- 边界检查消除 (BCE) 可能是 Go 世界中最稳健、最高效的优化技术之一。在我看来,当我开始优化任何 Go 热路径时,这是首选技术。为什么它如此稳健?因为它减少了热路径中的指令数量和分支数量。单凭这一点就很好,因为它减少了浪费的周期,但除此之外还有额外的好处。如果你的代码已经遇到缓存容量和/或冲突缺失,减少指令数量可以显著改善这些问题。我们说的是 L1 指令缓存、微操作缓存,以及可能的前端分支预测缓存。还有寄存器压力,BCE 也能帮上忙。虽然 BCE 如此稳健,但边界检查容易检测,有时也相对容易消除。长话短说,BCE 通常是值得首先尝试的快速胜利。然而,有时用传统方法消除边界检查并不容易,这时 unsafe 技术就登场了,而这正是本文的内容。 首先,什么是边界检查?Go 是一种安全语言,提供了一些保证,例如确保你无法访问切片越界元素。为此,编译器添加了一堆汇编代码,确保当访问越界索引时运行时 panic。让我通过这个小例子快速说明: `` func load(src []byte, i int) byte { return src[i] } `` 如果我使用 `-B` 标志(禁用边界检查)编译它,会产生这个简洁的汇编: `` 0x71d MOVQ AX, 0x8(SP) 0x722 MOVZX 0(AX)(DI*1), AX 0x726 RET `` 移除 `-B` 标志后的汇编显示了边界检查带来的开销: `` + 0x796 PUSHQ BP + 0x797 MOVQ SP, BP 0x79a MOVQ AX, 0x10(SP) + 0x79f CMPQ DI, BX + 0x7a2 JAE 0x7aa 0x7a4 MOVZX 0(AX)(DI*1), AX + 0x7a8 POPQ BP 0x7a9 RET + 0x7aa CALL 0x7af [1:5]R_CALL:runtime.panicBounds + 0x7af NOPL `` 当然,这个汇编差异有点过于夸张。这个函数很小,原本是个叶子函数,但启用 BC 后它有了一个 CALL,导致增加了 `PUSHQ/POPQ BP` 和 `MOVQ SP, BP`。任何有 CALL 的函数都不再是叶子函数,并且无论是否有边界检查都会加上这些序言指令。但即使忽略这一点,我的意思是仍然存在开销。但实际上,你知道吗?我越想越觉得这不算夸张。如果你有一个很小的函数,BCE 将其转换为叶子函数,从而消除了 CALL 开销,那这就是一个合理的 BCE 相关优化。 顺便说一下,你并不需要查看汇编来找到边界检查。编译器可以用以下命令列出所有边界检查: `` go build -gcflags="-d=ssa/check_bce/debug=1" . `` 在切换到使用 unsafe 之前,我们先看看处理 BC 的传统方式。如果你能“证明”边界检查是不必要的,Go 编译器通常可以消除它们。通过先访问上/下边界,然后再循环剩余范围来证明。这是一个来自真实代码库的例子: `` func matchLen(a, b []byte, limit int) int { + a = a[:limit] + b = b[:len(a)] i := 0 - for ; limit >= 8; limit -= 8 { + for ; i <= len(a)-8; i += 8 { xor := loadU64(a[i:]) ^ loadU64(b[i:]) if xor != 0 { return i + bits.TrailingZeros64(xor)/8 } - i += 8 } - for ; limit > 0 && a[i] == b[i]; limit-- { - i++ + for ; i < len(a) && a[i] == b[i]; i++ { } return i } `` 在循环条件中使用 `i <= len(a)-8` 可以让编译器消除边界检查,因为它现在可以确定所有对 `a` 的访问都在范围内。`b = b[:len(a)]` 消除了循环中与 `b` 相关的边界检查。 每个 Go 版本,编译器在边界检查消除方面都变得越来越智能。通常有很好的方式向编译器提示 BCE(如果你想要更多传统示例,请查看 this post (https://go101.org/optimizations/5-bce.html)),但有时(可能,在下一个 Go 版本中就能实现)无法用传统方法消除 BC,这时 unsafe 就派上用场了。 ⚠️ 注意,这里我们说的是编译器无法确定边界检查不必要,但程序员可以确定的情况。如果你无法证明边界检查是不必要的,那就不要消除它,编译器插入它是出于很好的理由。我认为这很明显,但为了以防万一还是需要提一下。 好了,现在我们来用 unsafe 消除一些边界检查吧?我将使用在我的 brotli 库 (https://github.com/molecule-man/go-brrr) 中找到的最佳示例。其中有一个无处不在的函数不可避免地引入了 BC。binary.LittleEndian.Uint32 (https://cs.opensource.google/go/go/+/refs/tags/go1.26.4:src/encoding/binary/binary.go;drc=3c4102bfd4dc87cba19152af834754170b863b39;l=90): `` // Uint32 returns the uint32 representation of b[0:4]. func (littleEndian) Uint32(b []byte) uint32 { _ = b[3] // 边界检查提示给编译器;参见 golang.org/issue/14808 return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24 } `` 这个函数以 little endian 顺序从切片中读取 4 个字节,在将数据按位打包到字节切片中的地方很有用。你在代码中看到它已经通过给编译器一个提示来尝试消除边界检查:`_ = b[3]` 保留了 `b[3]` 的一个边界检查,但消除了索引 0-2 的边界检查。然而,还是留了一个边界检查,任何不必要的代码在热路径中都是浪费。下面是一个 unsafe 示例 (https://github.com/molecule-man/go-brrr/blob/main/internal/encoder/le_unsafe.go#L15-L18) 消除了所有边界检查(这还将加载函数转换为叶子函数,并移除了 `CALL` 开销): `` //go:build !purego && (amd64 || 386 || arm64 || loong64 || ppc64le || wasm) package encoder import "unsafe" func loadU32LE(b []byte, i uint) uint32 { (4) (3) (2) (1) return *(*uint32)(unsafe.Add(unsafe.Pointer(unsafe.SliceData(b)), i)) } `` 注意函数签名发生了变化。调用标准库变体的方式是这样:`binary.LittleEndian.Uint32(data[offset:])`。而 unsafe 变体的调用方式是这样:`loadU32LE(data, offset)`。这很重要,因为如果你保留 `(data[offset:])`,在调用方仍然会有一个边界检查,确保 `data[offset]` 在范围内。还要注意 `go:build` 指令。这个技巧只适用于那些数据在内存中本身就是 little endian 顺序的机器。顺便说一下,这些都不是什么新颖的东西。像 klauspost/compress (https://github.com/klauspost/compress) 这样的性能关键库就依赖于相同的 unsafe little endian 加载;这只是一个不广为人知的小众技术。 让我们逐步分析这个例子: 1. `unsafe.SliceData(b)` 返回与 `&b[0]` 完全相同的结果——指向切片第一个元素的指针。如果我们使用 `b[0]`,就会引入一个我们试图消除的边界检查。unsafe.SliceData 的好处是,与 `&b[0]` 不同,你可以在空切片上使用它。 2. `unsafe.Pointer` 将从 `unsafe.SliceData` 返回的 `*byte` 转换为 `unsafe.Add` 所需的 `unsafe.Pointer` 类型。 3. `unsafe.Add(ptr, i)` 返回 `&b[i]` 的 unsafe.Pointer 表示, 4. 最后转换为 `*uint32` 并解引用。 你可能会说,为了消除一个边界检查,我引入了 3 个函数调用,但如果我们查看汇编,可以看到 Go 编译器消除了所有边界检查并内联了所有调用: `` MOVQ AX, 0x8(SP) MOVL 0(AX)(DI*1), AX RET `` 那么标准库 LE 加载器和手写 unsafe 加载器之间的性能差异有多大呢?让我们做基准测试。 展开查看基准测试代码`` package bce import ( "encoding/binary" "testing" "unsafe" ) func loadU32LE(b []byte, i uint) uint32 { return *(*uint32)(unsafe.Add(unsafe.Pointer(unsafe.SliceData(b)), i)) } var sink uint32 func BenchmarkLoadU32LE(b *testing.B) { data := make([]byte, 4096) b.SetBytes(int64(len(data))) for b.Loop() { var acc uint32 for i := uint(0); i+4 <= uint(len(data)); i += 4 { acc += loadU32LE(data, i) } sink = acc } } func BenchmarkStdUint32(b *testing.B) { data := make([]byte, 4096) b.SetBytes(int64(len(data))) for b.Loop() { var acc uint32 for i := 0; i+4 <= len(data); i += 4 { acc += binary.LittleEndian.Uint32(data[i:]) } sink = acc } } `` `` goos: linux goarch: amd64 pkg: bcetest cpu: 12th Gen Intel(R) Core(TM) i5-12500 BenchmarkLoadU32LE 22644585 273.7 ns/op 14966.04 MB/s BenchmarkStdUint32 9922156 600.7 ns/op 6818.58 MB/s `` unsafe 版本快了 2 倍多。你可能会说不能完全信任微基准测试,当被基准测试的代码被真实的代码库包围时,情况完全不同。好吧,说得有道理。下面是真实世界压缩匹配查找器在类似生产负载下进行真实工作之前和之后的基准测试,这是将标准库 LE 加载器替换为 unsafe 加载器 (https://github.com/andybalholm/brotli/pull/69) 的结果: `` pkg: github.com/andybalholm/brotli/matchfinder │ before.txt │ after.txt │ │ B/s │ B/s vs base │ Trio 90.39Mi ± 0% 99.99Mi ± 0% +10.62% (p=0.000 n=30) `` 明显的缺点:它不安全,哎。编译器为你添加的所有验证现在都必须由程序员证明是真正不必要的。正如我 之前抱怨过 (https://blog.andr2i.com/posts/2026-05-03-notes-from-optimizing-cpu-bound-go-hot-paths#no-opt-in-hints) 的那样,我希望 Go 有 `nobounds` 编译器提示,但它没有,所以我们剩下的唯一可行选择是使用 unsafe 指针运算。 ← 上一篇 4 字节填充如何使数组清空快 49% (https://blog.andr2i.com/posts/2026-06-22-optimization-catalog-how-4-bytes-of-padding-make-array-clearing-49-faster)

相似文章

优化CPU密集型Go热路径的笔记

Hacker News Top

本文讨论了CPU密集型Go代码的性能优化技术,指出了泛型和接口抽象因无法内联而产生的局限性,并主张在热路径中使用代码复制。文章通过一个Brotli移植示例和深入基准测试进行了说明。

Go 中过度的空指针检查

Lobsters Hottest

一篇博客文章讨论了 Go 中过度的空指针检查如何可能表明代码不清晰和错误处理实践不佳,主张早期失败并显式建模不可用的依赖。

改进 C# 内存安全

Hacker News Top

微软宣布对 C# 16 中的 unsafe 关键字进行重新设计,以强制执行内存安全契约,使 unsafe 操作变得可见并由编译器强制执行,预览版将在 .NET 11 中发布,正式版在 .NET 12 中发布。

计算goto实现高效调度表 (2012)

Hacker News Top

解释了使用GCC的计算goto扩展来提升字节码虚拟机调度表性能的方法,并与传统的switch语句进行了对比,附带了一个简单示例。

无 Unsafe 代码的垃圾回收

Hacker News Top

safe-gc 是一个全新的 Rust 库,它完全不用 unsafe 代码就实现了垃圾回收器,通过“堆索引”而非直接解引用指针来保证内存安全。