プログラミング
unsafe を使った Go の境界チェックの排除
Eliminating Go bounds checks with unsafe (blog.andr2i.com)
要約
この記事では、Go のランタイム境界チェックを排除する方法について解説しています。境界チェックはコードの安全性を提供しますが、パフォーマンスのボトルネックになることがあります。特にホットパスでは、`unsafe` パッケージのポインタ演算を利用して、コンパイラが自動的に削除できない境界チェックをプログラム側で安全に排除する方法が紹介されています。これにより、コードの実行速度が向上し、CPUキャッシュの効率も改善される可能性があります。
全文翻訳
unsafe を使った Go の境界チェックの排除
ホットパスの最適化: コンパイラが削除できない境界チェックを、それらが本当に不要であることを証明できる場合に、unsafe なポインタ演算で排除する
2026年7月6日
最適化カタログシリーズの一部:
浮動小数点除算が整数除算に勝る方法
4バイトのパディングが配列クリアを49%高速化する方法
unsafe を使った Go の境界チェックの排除(この記事)
境界チェックの排除(BCE)は、おそらく Go の世界で最も堅牢で生産性の高い最適化手法の一つです。私が Go のホットパスの最適化を始めるときは、いつもこれを使います。なぜこれほど堅牢なのでしょうか?それは、ホットパスにおける命令数と分岐数を減らすからです。これだけでも、無駄なサイクルを削減できるため素晴らしいのですが、さらに利点があります。コードがすでにキャッシュ容量や競合ミスに悩まされている場合、命令数を減らすことでそれらを大幅に改善できます。L1 icache、uop cache、そしておそらくフロントエンドの分岐予測キャッシュについて話しています。レジスタの圧力についても、BCE はそれに役立ちます。非常に堅牢でありながら、境界チェックは検出が容易で、時には排除も比較的簡単です。手短に言うと、BCE は通常、最初に試す価値のある簡単な成果です。しかし、時には従来の手段で境界チェックを排除するのが容易ではなく、そこで unsafe なテクニックが登場します。そして、この記事はそのことについてです。
まず、境界チェックとは何でしょうか?
Go は安全な言語であり、いくつかの保証を提供します。例えば、範囲外のスライス要素にアクセスできないという保証です。これを実現するために、コンパイラは、範囲外のインデックスがアクセスされた場合にランタイムがパニックを起こすようにするアセンブリコードを大量に追加します。この小さな例で簡単に説明しましょう。
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 についてヒントを与える良い方法はしばしばありますが(より従来の例を探している場合は、この投稿を参照してください)、時には従来の手段で BC を排除することが不可能(まだ、次の Go バージョンでは可能になるかもしれません)であり、そこで unsafe が登場します。
⚠️ ここで話しているのは、コンパイラが境界チェックが不要であることを決定できないが、プログラマはできる、というケースについてです。境界チェックが不要であることを証明できない場合は、それを排除しないでください。コンパイラは正当な理由でそれを挿入しています。これは明白だと思いますが、念のため注意しておきます。
さて、unsafe を使って境界チェックをいくつか排除してみましょうか?私の brotli ライブラリで見つけた最良の例を使用します。それに付随して避けられない BC をもたらす、遍在する 1 つの関数があります。binary.LittleEndian.Uint32:
// Uint32 は b[0:4] の uint32 表現を返します。
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
}
この関数は、バイトスライスのリトルエンディアン順で 4 バイトを読み取り、ビットパックされたデータをバイトスライスに格納する場所で役立ちます。コードを見ると、コンパイラにヒントを与えることで境界チェックを排除しようとしていることがわかります:`_ = b[3]`。これは `b[3]` のための 1 つの境界チェックを維持しますが、インデックス 0-2 の境界チェックを排除します。しかし、1 つの境界チェックが残り、不要なコードはホットパスでは無駄です。これは、全ての境界チェックを排除する(そしてロード関数をリーフ関数に変換し、CALL オーバーヘッドを削除する) unsafe な例です。
//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))
}
関数のシグネチャが変更されたことに注意してください。stdlib のバリアントは次のように呼び出します:`binary.LittleEndian.Uint32(data[offset:])`。そして unsafe なバリアントは次のように呼び出します:`loadU32LE(data, offset)`。これは重要です。もし `(data[offset:])` を維持した場合、呼び出し側で 1 つの境界チェックが残り、`data[offset]` が範囲内にあることを確認することになります。また、`go:build` 命令にも注意してください。このトリックは、リトルエンディアン順でデータをメモリに配置するマシンでしか機能しません。これらはどれも新しいものではありません。パフォーマンスが重要なライブラリである klauspost/compress は、全く同じ unsafe なリトルエンディアンロードに依存しています。それは単に広く知られていないニッチなテクニックです。例を見てみましょう:`unsafe.SliceData(b)` は、`&b[0]` が返すものと全く同じ結果を返します。それはスライスの最初の要素へのポインタです。もし `b[0]` を使用した場合、境界チェックを導入することになり、それを排除しようとしています。`unsafe.SliceData` の良い点は、`&b[0]` とは異なり、空のスライスにも使用できることです。`unsafe.Pointer` は、`unsafe.SliceData(b)` から返される `*byte` を `unsafe.Add` に必要な `unsafe.Pointer` 型に変換します。`unsafe.Add(ptr, i)` は、`&b[i]` の `unsafe.Pointer` 表現を返します。これは最終的に `*uint32` にキャストされ、逆参照されます。単一の境界チェックを排除するために 3 つの関数呼び出しを導入したと言うかもしれませんが、アセンブリを確認すると、Go コンパイラが全ての境界チェックを排除し、全ての呼び出しをインライン化していることがわかります。
MOVQ AX, 0x8(SP)
MOVL 0(AX)(DI*1), AX
RET
stdlib の 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)