Green Tea GCとは—Go 1.26で変わるGCの仕組みと効果を実測

Green Tea GCとは—Go 1.26で変わるGCの仕組みと効果を実測 | mohablog

Go 1.26で標準のガベージコレクタがGreen Teaに置き換わりました。手元の go1.26.4 でポインタ密なワークロードを回すと、GCのCPU時間は8.6%から4.3%へ半分に。半分になった理由は、GCの走らせ方が根本から変わったことにあります。

目次

Green Tea GCで何が変わったか

Go 1.26のリリースノートは、Green TeaをデフォルトのGCにしたと明記しています。実験フラグは要りません。既存のGoコードは、ビルドし直すだけでそのまま新しいGCで動きます。

まず結果から

二分木を大量に作ってGCを酷使する合成ベンチで比べました。環境は go1.26.4 darwin/arm64GOMAXPROCS=8。旧GC(GOEXPERIMENT=nogreenteagc)ではGCがCPU時間の8.6%を食っていたのが、Green Teaでは4.3%まで下がりました。全体の実行時間も約800msから約750msへ、6%ほど短縮。細かい数字は後半の表にまとめます。

この二分木は、公式が旧GCの弱点として挙げる「グラフを飛び回るスキャン」をわざと再現したもの。効果が出やすい題材を選んでいる点は差し引いて読んでください。

デフォルト化と、戻せる猶予

時系列はこう。Go 1.25(2025年10月)で GOEXPERIMENT=greenteagc の実験機能として登場。Go 1.26(2026年2月)でデフォルトに昇格しました。旧GCへ戻す GOEXPERIMENT=nogreenteagc はまだ残っていますが、Go 1.27で消える予定。戻せるうちに、自分のワークロードで差を測っておくのが安全です。

旧GCが遅かった理由

公式ブログ “The Green Tea Garbage Collector” は、これまでのマーク方式を “A microarchitectural disaster” と表現します。大げさに見えて、理由ははっきりしています。

マーク時間の3割はメモリ待ち

Goのマークフェーズは、生きているオブジェクトをたどってポインタを追う処理。この時間の「少なくとも35%」が、ヒープメモリへのアクセス待ちで止まっている、と公式は書いています。メインメモリへの往復はキャッシュヒット時の最大100倍遅い。たどる先がキャッシュに無ければ、CPUはただ待つ。

オブジェクト単位はキャッシュに乗らない

根っこは、互いに参照し合うオブジェクトがメモリ上で近くにある保証がないこと。グラフを深さ優先でたどると、メモリ中を飛び回りながら各所で小さな仕事を少しずつやる形になります。ページを跨ぎ、ページ内でも別の場所へ跳ぶ。次に触る番地が直前まで決まらないので、CPUの投機実行もプリフェッチも効きません。公式の言う “microarchitectural disaster” はここを指しています。

しかもこの相性の悪さは年々きつくなっています。コア数は増える一方でコアあたりのメモリ帯域は細り、複数のワーカーが1本の共有ワークキューを奪い合う。NUMA環境ではアクセス先で遅延も変わる。オブジェクト単位のマークは、ハードウェアの進化と逆を向いていました。

Green Teaの仕組み

Green Teaの方針は “Work with pages, not objects”。オブジェクト単位をやめ、ページ単位でスキャンします。

ページにためてから一気に走る

ワークリストに積むのもオブジェクトではなくページ。あるページがキューに乗っている間、そこで見つけた「これからスキャンすべきオブジェクト」をためておき、まとめて処理します。マーク済みかどうかも、ヒープ全体ではなくページごとにローカルに持つ。内部では各オブジェクトに「見つけた(seen)」と「スキャン済み(scanned)」の2種類のビットを持たせ、ページ単位で突き合わせます。結果として、あちこち跳ぶ短いスキャンが、ページを左から右へ舐める長いスキャンに変わります。

スタックからキューへ

ワークリストの順序も変えています。従来は深さ優先(LIFO)。Green Teaは幅優先(FIFO)寄りにして、ページ上にオブジェクトがたまるのを待ってからまとめて処理する。公式の “Green Tea example” では、同じグラフのスキャンが7回から4回に減り、1回が長くなる様子を図で示しています。1ページの2%だけをスキャンする粗いやり方でも旧来の飛び回り方式より速い、という計測結果まで載っています。局所性がそれだけ効くということ。

AVX-512で1ページを数命令に

ページ単位にすると規則性が生まれ、SIMDが使えるようになります。公式の “Vector acceleration” 節では、AVX-512でページのメタデータを2レジスタに載せ、スキャンの主要ステップを数サイクルで回すと説明。公式の “AVX-512 scanning kernel” 節は、ページのビットマップを取り出して交差を取り、64バイト単位でポインタを集める手順を並べています。ただしこれが効くのはIntel Ice Lake / AMD Zen 4以降のamd64だけ。arm64や古いx86では、ベクトル分の上乗せは無く、ページ単位スキャンそのものの効果だけになります。後述の実測は arm64 なので、SIMD抜きの素の効果を見ていることになります。

Green Tea有効/無効を実測で比べる

どれだけ変わるか、手元で測りました。GCが狙う「小さいオブジェクトの大量スキャン」を再現するため、ポインタだらけの二分木を作っては捨てます。

計測に使ったコード

type node struct {
	left, right *node
	val         int
}

func build(depth int) *node {
	if depth == 0 {
		return &node{}
	}
	return &node{left: build(depth - 1), right: build(depth - 1), val: depth}
}

func main() {
	start := time.Now()
	var sink int
	for i := 0; i < 120; i++ {
		t := build(18) // 1本あたり約26万ノード
		sink += t.val
	}
	_ = sink
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	fmt.Printf("elapsed=%v numGC=%d gcCPUfraction=%.4f pauseTotal=%v\n",
		time.Since(start).Round(time.Millisecond),
		m.NumGC, m.GCCPUFraction, time.Duration(m.PauseTotalNs))
}

実行結果(Green Teaデフォルト):

elapsed=756ms numGC=331 gcCPUfraction=0.0429 pauseTotal=9.146359ms

ベンチの取り方そのものは Goのb.Loopで正確なベンチマークを書く に寄せると安定します。ここではGC挙動の差だけを見たいので、runtime.MemStats を直接読みました。

CPU時間は半減、STWはむしろ増える

両方を3回ずつ回した代表値がこちら。

指標Green Tea(デフォルト)旧GC(nogreenteagc)
実行時間約750ms約795ms
GCのCPU割合(GCCPUFraction)4.3%8.6%
GC回数331186
STW停止の合計約9.8ms約5.2ms

GCが使うCPU時間はほぼ半分。ここは狙い通り。ただ表をよく見ると、GC回数はGreen Teaの方が多く、STWの合計もむしろ長い。最初この結果を見たときは測り間違えたかと思いました。gctraceを並べると理由が見えます。

# 旧GC
gc 135 @0.544s 8%: 0.016+2.1+0.013 ms clock, 0.13+0.005/4.0/8.8+0.10 ms cpu, 7->10->9 MB
# Green Tea
gc 235 @0.541s 4%: 0.015+0.29+0.010 ms clock, 0.12+0.005/0.18/0.18+0.080 ms cpu, 13->13->1 MB

中央の 4.0/8.80.18/0.18 が並行マークのCPU時間(ms)です。旧GCが1サイクルで数msかけていたマークを、Green Teaは0.2ms前後で終えています。マークが軽くなったぶんペーサーの配分も変わり、GCの回数自体はむしろ増えました。回数が増えれば、1回が短くてもSTWの合計は伸びる。それでも1回のSTWはサブmsで、トータルのGC CPUは半分。GCを多用するアプリほど、この差がそのまま効きます。

効かないワークロードもある

合成ベンチはGCを意図的に酷使しているので、効果は出やすい方です。実アプリはそうとは限りません。DoltHubはデータベースDoltでsysbenchの oltp_read_write を回し、Green Tea有無で 73.20 tx/s と 73.36 tx/s、レイテンシ中央値はどちらも 13.22ms で「差なし」と報告しています。公式も「多くの実プログラムでは性能差はさほど出ない」と釘を刺した上で、GCを多用するプログラムで10〜40%のGCオーバーヘッド削減という言い方。この10〜40%という幅は、自分のアプリで測って初めて意味を持ちます。

逆に効きやすいのは、小さいオブジェクトが大量に絡み合うヒープです。ツリーやグラフ、ノード数の多いキャッシュ、ポインタで連結したデータ構造。ここはまさに旧GCが飛び回っていた領域で、ページ単位スキャンの効き目が出ます。大きなバイトスライス中心でポインタの少ないヒープや、そもそもGCがほとんど動かないアプリでは、体感の差はほぼ出ません。

使い方と切り替え

特別な設定は要りません。Go 1.26でビルドすれば既定でGreen Tea。旧GCに戻したいときだけフラグを触ります。

# 旧GCでビルド(ビルド時に指定)
GOEXPERIMENT=nogreenteagc go build ./...

# 現在の設定を確認
go env GOEXPERIMENT

チューニングとは別レイヤ

GCアルゴリズムの入れ替えと、GOGCGOMEMLIMIT でのチューニングは別の話です。Green Teaにしても、GCの発火頻度を決めるのは相変わらずこの2つ。GOMEMLIMIT でヒープの上限を決めておけば、GCの中身が変わってもメモリの天井は据え置けます。

GCが重いと感じたとき、いきなり GOGC を上げてヒープを膨らませるのは筋が悪い。まず runtime.MemStatsGCCPUFraction で、そもそもGCが何%使っているかを測る。数%なら、GCを削るより他を疑う番です。ここは GOMAXPROCSをコンテナのCPU制限に合わせる話 と同じで、現状を数字にしてから動かす順番になります。

移行前に見るところ

移行は自動ですが、挙動は変わります。見ておく点はこちら。

  • arm64・古いx86はSIMD分の上乗せが無い。amd64(Ice Lake / Zen 4以降)ほどの伸びは期待しない
  • GC回数やSTWの分布は変わる。pause監視のしきい値を持っているなら見直す
  • 戻せるのはGo 1.26の間だけnogreenteagc は1.27で消える。差を測るなら今のうち

まとめ

  • Go 1.26でGCがGreen Teaにデフォルト切り替え。コード変更は不要で、ビルドし直すだけ
  • 仕組みは「オブジェクト単位からページ単位のスキャンへ」。局所性が上がり、マークのメモリ待ちが減る
  • 手元のarm64合成ベンチでGCのCPU割合は8.6%から4.3%へ。ただしGC回数とSTW合計は増えた
  • amd64(Ice Lake / Zen 4以降)ではAVX-512でさらに1割ほど上乗せ
  • 効果はワークロード依存。実DBベンチでは差が出ない例もある。自分のアプリで測ってから判断する
  • 旧GCへ戻す nogreenteagc はGo 1.27で消える

FAQ

既存コードの書き換えは必要?

不要です。GCの内部実装が変わるだけで、APIも挙動の契約も同じ。Go 1.26でビルドし直せば、そのまま新GCで動きます。

効果があったか確認するには?

runtime.MemStatsGCCPUFraction を読むか、GODEBUG=gctrace=1 を付けて起動します。前者はプログラム全体でGCが使ったCPUの割合、後者は1サイクルごとのマーク時間とヒープ推移が行単位で出ます。

arm64でも効果はある?

あります。ページ単位スキャンはCPUアーキテクチャに依らないので、本記事のarm64ベンチでもGCのCPU割合は半分になりました。amd64だけの恩恵はAVX-512による1割ほどの上乗せ分で、素の改善はarmでも受けられます。

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