Go 1.27のメモリ割り当て高速化—80バイト以下で31%、-raceでは効かない

24バイトの構造体を1つヒープに置くコストが、Go 1.27 で 8.051ns から 5.513ns に落ちました。31%の短縮です。ただし96バイトの構造体では、同じ測り方をしても差が出ません。

目次

31%の中身をGOEXPERIMENTの前後で測る

Go 1.27 リリースノートの Runtime 節、“Faster memory allocation” はこう書いています。

The compiler now generates calls to size-specialized memory allocation routines, reducing the cost of some small (<80 byte) memory allocations by up to 30%.

この変更には opt-out が用意されています。ビルド時に GOEXPERIMENT=nosizespecializedmalloc を渡せば、割り当て経路は Go 1.26 までの汎用版。同じソース・同じマシンで前後を測れるので、コンパイラのバージョン差が結果に混ざりません。

ベンチはb.Loopで書く

24バイトの構造体を、ポインタを含まないもの(point)と含むもの(tagged)の2種類で用意しました。境界の確認用に80バイトと96バイトのスライスも足しています。

package gomalloc

import "testing"

type point struct{ X, Y, Z int64 } // 24B ポインタなし

type tagged struct { // 24B ポインタあり(string)
	Name string
	ID   int64
}

var (
	sinkP *point
	sinkT *tagged
	sinkB []byte
)

func BenchmarkNoScan24(b *testing.B) {
	for b.Loop() {
		sinkP = &point{}
	}
}

func BenchmarkScan24(b *testing.B) {
	for b.Loop() {
		sinkT = &tagged{}
	}
}

func BenchmarkSlice80(b *testing.B) {
	for b.Loop() {
		sinkB = make([]byte, 80)
	}
}

func BenchmarkSlice96(b *testing.B) {
	for b.Loop() {
		sinkB = make([]byte, 96)
	}
}

結果の代入先をパッケージ変数にしているのは、エスケープ解析でスタック割り当てに逃げられないようにするためです。b.Loop() の使い方は Goのb.Loopで正確なベンチマークを書く に書いた通りで、ループ内の値が最適化で消える心配はここでは要りません。

benchstatで有意差を見る

ON と OFF をそれぞれ -count=8 で回し、出力をファイルに落とします。

$ go test -run=X -bench=. -benchmem -count=8 . > on.txt
$ GOEXPERIMENT=nosizespecializedmalloc go test -run=X -bench=. -benchmem -count=8 . > off.txt
$ go run golang.org/x/perf/cmd/benchstat@latest on.txt off.txt

benchstat のインストールは要りません。go run にバージョン付きパスを渡せばその場で取ってきて実行してくれます。

goos: darwin
goarch: arm64
pkg: gomalloc
cpu: Apple M5 Pro
             │   on.txt    │              off.txt               │
             │   sec/op    │   sec/op     vs base               │
NoScan24-18    5.513n ± 1%   8.051n ± 2%  +46.05% (p=0.000 n=8)
Scan24-18      6.319n ± 1%   9.657n ± 2%  +52.82% (p=0.000 n=8)
Slice80-18     10.47n ± 3%   11.62n ± 1%  +10.98% (p=0.000 n=8)
Slice96-18     12.94n ± 3%   12.96n ± 4%        ~ (p=0.574 n=8)

OFF 側が基準に対して何%遅いかが出ています。ON を基準に読み替えると、24バイトのポインタなしで31.5%短縮、ポインタありで34.6%短縮allocs/opB/op は両者で完全に一致していて、割り当ての回数もサイズも変わっていません。速くなったのは1回あたりの手続きだけ。

OFF 側の数字が本当に Go 1.26 相当かは、実機で確かめられます。go.mod の go ディレクティブを go 1.26 に落として GOTOOLCHAIN=go1.26.4 で回すと、NoScan24 は 8.062〜8.301ns、Scan24 は 9.252〜9.611ns。OFF の 8.051ns / 9.657ns とほぼ重なります。opt-out 側は旧バージョンの代役として通用しました。

改善幅が大きいのは、むしろ Scan24 の方でした。ポインタを含む型は割り当てのたびにヒープビットマップを書きます。生成コードにも writeHeapBitsSmall の処理がそのまま埋め込まれていて、手順が多いぶん削れる幅も大きく出た形です。

効くのは80バイト「以下」まで

リリースノートの表記は <80 byte ですが、実装を読むと条件は80バイト以下です。1バイトぶんずれています。

テーブルの長さが境界を決めている

ダウンロード済みのツールチェーンのソースを開くと、分岐は runtime/malloc.gomallocgc 冒頭にあります。

if sizeSpecializedMallocEnabled && size < uintptr(len(mallocNoScanTable)) {
	if typ == nil || !typ.Pointers() {
		if size >= maxTinySize {
			return mallocNoScanTable[size](size, typ, needzero)
		}
		return mallocgcTinySC2(size, typ, needzero)
	} else {
		if !needzero {
			throw("objects with pointers must be zeroed")
		}
		return mallocScanTable[size](size, typ, needzero)
	}
}

境界はテーブルの長さそのものです。runtime/malloc_tables_generated.go の宣言は var mallocNoScanTable = [81]func(size uintptr, typ *_type, needzero bool) unsafe.Pointer で、要素数は81。つまり size < 81、80バイトちょうどまでが対象に入ります。

この81という数字の出どころは runtime/_mkmalloc/constants.go の30行目、specializedMallocMax = 80 です。なぜ80で切ったのか。同じディレクトリの mkmalloc.go にコメントが残っていました。

Only generate specialized functions for sizes up to specializedMallocMax. We’ve noticed limited benefit (or sometimes worse performance) for specialized functions for larger sizes, and having too many functions causes icache issues.

大きいサイズでは効果が薄いだけでなく、関数を増やしすぎると命令キャッシュを圧迫する。Goチームはその両方を理由に上限を置いています。生成された runtime/malloc_generated.go は2,232行、専用関数は17個。サイズクラス SC1 から SC7 までを scan/noscan で分けた形です。

80バイトと96バイトの実測

先ほどの benchstat がこの境界をそのまま映しています。80バイトのスライスは 10.47ns11.62ns で 9.9%短縮、96バイトは 12.94ns12.96ns で有意差なし(p=0.574)。

スライスではなく96バイトの構造体でも同じでした。int64 を12本並べた型を -count=9 で測ると 12.65ns12.69ns、p=0.374。80バイトを1バイトでも超えた時点で、ON と OFF は区別が付かなくなります。

リリースノートの文言を鵜呑みにして「80バイトは対象外」と読むと、実際の境界を1バイト取り違えます。

自分のコードで効いているかは go tool compile -S で分かる

ベンチを回さなくても、コンパイラが専用関数を呼んでいるかどうかだけならアセンブリで確かめられます。

24バイトは専用関数、96バイトはnewobject

3種類の構造体を返すだけのファイルを用意します。

package gomalloc

type pt struct{ X, Y, Z int64 }                            // 24B ポインタなし
type tg struct {                                           // 24B ポインタあり
	Name string
	ID   int64
}
type bg struct{ a, b, c, d, e, f, g, h, i, j, k, l int64 } // 96B

func NewPt() *pt { return &pt{} }
func NewTg() *tg { return &tg{} }
func NewBg() *bg { return &bg{} }

専用関数を呼んでいるかどうかは、コンパイル結果の CALL 命令に出ます。

$ go tool compile -S asm.go 2>&1 | grep -E 'CALL\s+runtime\.(malloc|newobject)'
0x0028 00040 (asm.go:10)	CALL	runtime.mallocgcSmallNoScanSC3(SB)
0x0028 00040 (asm.go:11)	CALL	runtime.mallocgcSmallScanNoHeaderSC3(SB)
0x0020 00032 (asm.go:12)	CALL	runtime.newobject(SB)

24バイトの2つは mallocgcSmallNoScanSC3mallocgcSmallScanNoHeaderSC3、どちらもサイズクラス3の専用関数を直接呼んでいます。96バイトの NewBg だけが従来どおり runtime.newobject です。

同じコマンドを OFF で通すと、3つとも newobject に戻りました。

$ GOEXPERIMENT=nosizespecializedmalloc go tool compile -S asm.go 2>&1 | grep -E 'CALL\s+runtime\.(malloc|newobject)'
0x0020 00032 (asm.go:10)	CALL	runtime.newobject(SB)
0x0020 00032 (asm.go:11)	CALL	runtime.newobject(SB)
0x0020 00032 (asm.go:12)	CALL	runtime.newobject(SB)

ホットパスに newobject しか並んでいないなら、その割り当ては80バイトを超えているか、サイズがコンパイル時に決まっていないかのどちらかです。構造体をあと1フィールド削れば専用関数に乗る、という判断がこの出力だけでできます。

サイズがコンパイル時に決まらない場合

make([]byte, n) のように長さが実行時に決まる場合、コンパイラは runtime.makeslice を呼ぶだけで専用関数へは直行できません。それでも効果は出ます。先ほど引用した malloc.go の分岐が、mallocNoScanTable[size] というテーブル引きで実行時にディスパッチしているためです。

長さを実行時に決めた形で測った結果がこちら。-count=4 の平均で、96バイト行は誤差の範囲です。

要求サイズONOFF短縮
8バイト4.61ns5.35ns13.9%
32バイト6.71ns8.29ns19.0%
48バイト7.93ns9.26ns14.4%
72バイト10.65ns11.48ns7.2%
96バイト13.10ns12.79ns差なし

コンパイル時に直接呼べる形と比べると短縮幅は小さくなりますが、96バイトで消える境界は同じです。

-raceを付けたビルドでは無効になる

有効化の条件は runtime/malloc.go の1039行目に定数として畳み込まれています。

const sizeSpecializedMallocEnabled = goexperiment.SizeSpecializedMalloc && GOOS != "plan9" &&
	!asanenabled && !raceenabled && !msanenabled && !valgrindenabled

-race を付けたビルドでは raceenabled が真になり、専用関数の経路そのものが消えます。コンパイラの出力にもそのまま表れます。

$ go tool compile -race -S asm.go 2>&1 | grep -E 'CALL\s+runtime\.(malloc|newobject)'
0x0028 00040 (asm.go:10)	CALL	runtime.newobject(SB)
0x0028 00040 (asm.go:11)	CALL	runtime.newobject(SB)
0x0028 00040 (asm.go:12)	CALL	runtime.newobject(SB)

24バイトの2つも newobject に戻りました。実際に go test -race で同じベンチを回すと、ON と OFF の差が無くなりました。Scan2437.41ns37.53ns で p=1.000、NoScan24 に至っては OFF 側がわずかに速く出ています。素のビルドの 6.319ns と比べて6倍近く遅いのは race detector 自体のコストで、そちらに完全に埋もれた形です。CI のベンチを -race 付きで回している場合、この変更の効果はそこには出てきません。

実際のプログラムでは3.6%に落ちる

リリースノートは30%の直後に、規模感の但し書きを付けています。

Improvements vary depending on the workload, but the overall improvement is expected to be ~1% in real allocation-heavy programs.

json.Marshalで測る

5回の割り当てが走る構造体を encoding/json でシリアライズして、同じ手順で比較しました。1回のマイクロベンチではマシンのノイズに負けるので、ON と OFF を交互に6セット、-benchtime=2s で回しています。

             │  j2_on.txt  │             j2_off.txt             │
             │   sec/op    │   sec/op     vs base               │
JSONMarshal-18  364.6n ± 1%   378.2n ± 1%  +3.73% (p=0.000 n=12)

3.6%短縮。マイクロベンチの31%からは一桁落ちますが、リリースノートの「~1%」よりは大きく出ました。5回のうち何回が80バイト以下に収まるかで結果が変わるので、この数字は自分のワークロードで測り直す前提のものです。

交互に回さないと数字が壊れる

最初は ON を8回連続、OFF を8回連続で回していました。そのときの JSON ベンチは OFF 側が ±70% というばらつきを出し、見かけ上266%の差が付きました。裏で走っていた別プロセスの影響がそのまま片側に乗った形でした。交互に回して測り直すと、ばらつきは両側とも±1%に収まっています。

割り当てを速くするより減らす方が効くケースも当然あります。使い回しで割り当て自体を消す sync.Pool の挙動は Go sync.Poolが1回のGCで空になる条件 にまとめました。3.6%を取りにいく前に、そもそも割り当てを減らせないかを見る方が先です。

バイナリは67,872バイト増える

専用関数を17個ぶん抱え込むので、その分バイナリが太ります。net/http を使うだけの最小サーバーで比較した結果がこちら。

ビルドバイナリサイズ差分go version -m の表示
既定8,250,178バイトgo1.27.1
OFF8,182,306バイト-67,872バイトgo1.27.1-X:nosizespecializedmalloc

リリースノートの「about 60 KB」とほぼ一致します。全体に対しては0.83%なので、組み込み向けにサイズを削り込んでいるのでなければ気にする幅ではありません。

運用で効いてくるのは右端の列です。ビルド済みのバイナリに go version -m を通すと、opt-out されたバイナリだけツールチェーン名に -X:nosizespecializedmalloc が付くので、手元とCIでビルドフラグが揃っているかを成果物から確認できます。

Go 1.28でopt-outが消える

リリースノートは GOEXPERIMENT の説明に続けて、期限を明示しています。

This opt-out setting is expected to be removed in Go 1.28.

Go 1.28 が来たら前後比較の手段が無くなるので、割り当てが支配的なコードを抱えているなら Go 1.27 のうちに一度測っておく価値があります。Goチームは Please file an issue if you notice any regressions. とも書いていて、遅くなるケースの報告を待っている段階です。

GOEXPERIMENT の綴りを間違えたときのエラーには、少し引っかかりました。

$ GOEXPERIMENT=nosizespecialisedmalloc go build .
go: unknown GOEXPERIMENT sizespecialisedmalloc

「no付きで指定したのに no無しの名前で怒られる」ので、一瞬どちらを直せばいいのか迷います。正しい綴りは specialized、アメリカ式のzです。

まとめ

  • Go 1.27 の size-specialized malloc は、24バイトの構造体で 31.5%(ポインタありなら34.6%)の短縮。割り当て回数とサイズは変わらない
  • 境界は size < 81。リリースノートの <80 byte と違い、80バイトちょうども対象に入る
  • go tool compile -SmallocgcSmallNoScanSC3 のような専用関数が呼ばれていれば効いている。runtime.newobject のままなら対象外

効かない条件と、引き換えに払うものも押さえておきます。

  • -race・asan・msan・valgrind のいずれかが有効なビルドでは無効になる
  • encoding/json のシリアライズ全体では3.6%短縮。バイナリは67,872バイト増える
  • opt-out の GOEXPERIMENT=nosizespecializedmalloc は Go 1.28 で削除予定

PGO のときも実測すると効果は2.28%に留まりました。見出しの30%は80バイト以下を連打したときの数字で、実際のコードにそのまま乗ることはありません。ホットパスの割り当てサイズを go tool compile -S で確かめてから、ベンチを組むかどうかを決めれば足ります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次