
2026/02/28 1:34
スタック上で割り当てる(配分する)
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
## 要約 Go の最近のコンパイラとランタイムの変更により、スライスの多くの割り当てがヒープからスタックへ移動し、割り当てオーバーヘッドと GC プレッシャーを低減しています。スタック上の割り当ては安価で GC なし、キャッシュフレンドリーです。 プログラム起動時に、小さなスライスに対する繰り返し `append` 操作が頻繁にヒープ割り当てを引き起こしていました。Go 1.24 以降では、容量固定の `make` がその背後配列をスタック上に割り当てることができます。Go 1.25 では、`make` 用に自動的な 32 バイトの小さなスタックバッファが追加され、要求された容量が極めて小さい場合にはヒープ割り当てを排除します。Go 1.26 はこの最適化を `append` にまで拡張し、コンパイラは最初の数回の `append` で投機的に小さなスタックバッファを使用し、必要に応じてヒープへフォールバックします。 スライスがエスケープ(例:関数から返される)すると、コンパイラは単一の `runtime.move2heap` 呼び出しを挿入し、スタック上で割り当てられたスライスをヒープへ移動させます。これにより不要なコピーが回避されます。その結果、Go 1.26 はエスケープするスライスについて、必要なサイズのヒープ割り当てを最大 1 回だけ(またはすべての作業が小さなスタックバッファ内に収まる場合は全く無し)生成します。 良い推定で事前に確保しておくことは、サイズが事前に分かっている場合には依然として有効ですが、多くの一般的パターンは自動的に処理されます。最適化によって回帰が発生した場合は、`-gcflags=all=-d=variablemakehash=n` で無効にできます。 これらの変更は GC ポーズを低減し、多数の短命スライスを生成するサービス(マイクロサービス、高スループット API、リアルタイムシステムなど)のレイテンシを向上させます。手動チューニングは不要です。今後のリリースでは最適化がさらに洗練されたり、追加フラグで挙動を微調整できるようになる可能性があります。
(元文に沿って欠落点を補足した場合)
- 「Go 1.26 が
用に 32 バイトの小さなスタックバッファを追加した後」に、Go 1.24 の可変サイズスライスに対するヒープ割り当てルールと、エスケープ時に単一のmake
呼び出しが行われることを説明する文を追加します。runtime.move2heap
フラグで最適化を無効にできる旨の簡潔な注記を付け足します。-gcflags
主メッセージの明確さと表現
改訂された要約は「スライス割り当てをスタックへ移動する」という主要アイデアを明確に保ち、あいまいな参照を排除しつつすべての重要技術詳細を網羅しています。
本文
Goプログラムを高速化する手段は常に探求されています。最近の2つのリリースでは、特に「ヒープ割り当て」による遅延を緩和することに注力しました。Goがヒープからメモリを確保するとき、その割り当てを満たすためにかなりのコード量が実行されます。また、ヒープ割り当てはガベージコレクタへの負荷も増大させます。Green Tea など最近の改善策にもかかわらず、ガベージコレクタは依然として相当なオーバーヘッドを抱えています。
そこで私たちは、ヒープではなくスタックでより多く割り当てる方法に取り組んでいます。スタック割り当ては実行がかなり安価(場合によっては完全に無料)ですし、ガベージコレクタへの負荷も発生しません。スタック割り当てなら、スタックフレームとともに自動的に解放されるため、再利用も容易でキャッシュ親和性が高くなります。
定数サイズのスライスをスタックへ
タスクを処理するためのスライスを作成するとします。
func process(c chan task) { var tasks []task for t := range c { tasks = append(tasks, t) } processAll(tasks) }
最初のループでは
tasks のバックアップストアが存在しないため、append は 1 要素分の領域を確保します。次に要素を追加するときは既存のストアが満杯になるので、サイズ 2 の新しいバックアップストアが割り当てられ古いものが捨てられます。3 回目でサイズ 4、4 回目では再利用、5 回目でサイズ 8…と、容量を倍増させながら最終的にはほとんどの append が追加割り当てを避けるようになります。しかしスライスが小さい「スタートアップ」フェーズでは割り当て回数が多くなるためオーバーヘッドが大きいです。
このコードがホットパートであれば、スライスを大きめに初期化するのは自然な最適化です。
func process2(c chan task) { tasks := make([]task, 0, 10) // おそらく最大で10個 for t := range c { tasks = append(tasks, t) } processAll(tasks) }
この最適化は決して誤りではありません。推測が小さすぎても割り当てが発生し、大きすぎればメモリを無駄にします。しかし、推測が合っていれば 1 回の
make だけで済みます。驚くべきことに、チャネル内に10個しかない場合、このコードは割り当て数が 0 にまで減ります。コンパイラは正確なサイズ(10 個分)を知っているため、バックアップストアをスタック上に配置します。これは processAll 内でヒープへ逃げないことに依存しています。
可変サイズスライスのスタック割り当て
固定長推測はやや硬直です。推定長を渡すようにすると:
func process3(c chan task, lengthGuess int) { tasks := make([]task, 0, lengthGuess) for t := range c { tasks = append(tasks, t) } processAll(tasks) }
Go 1.24 では非定数サイズのバックアップストアはスタックに割り当てられず、ヒープへ落ちます。結果として「0 割り当て」から「1 割り当て」に変わりますが、それでも
append が行う中間割り当てを減らせます。
Go 1.25 で変更がありました:コンパイラは特定のスライス割り当てサイトに対して、サイズが小さければ現在 32 バイト のスタックバックアップストアを自動的に確保します。要求されたサイズがその範囲内ならスタック上で完結し、それ以上の場合はヒープへフォールバックします。したがって
process3 は、lengthGuess が 32 バイト以内に収まる場合に ゼロヒープ割り当て を実現できます(推測と実際の要素数が一致すれば)。
append で自動的にスタック割り当て
API に長さ推測を公開したくない場合は、Go 1.26 にアップグレードしてください:
func process(c chan task) { var tasks []task for t := range c { tasks = append(tasks, t) } processAll(tasks) }
Go 1.26 ではコンパイラが 仮想的に小さなバックアップストア をスタック上に確保し、
append の場所で直接使用します。最初の反復でスタックベースのストア(例:長さ 4)が割り当てられ、以降はそれを再利用し続けます。容量が足りなくなるとヒープ割り当てに切り替わります。この手法はサイズ 1・2・4 の初期割り当てとそのガベージを排除します。
スライスが逃げる場合のスタック割り当て
スライスが「逃げる」—例えば関数から返す時 — バックアップストアはスタックに存在できません:
func extract(c chan task) []task { var tasks []task for t := range c { tasks = append(tasks, t) } return tasks }
手作業で最適化したバージョンでは、最後にヒープスライスへコピーします。
func extract2(c chan task) []task { var tasks []task for t := range c { tasks = append(tasks, t) } tasks2 := make([]task, len(tasks)) copy(tasks2, tasks) return tasks2 }
Go 1.26 はこの変換を自動で行います:
func extract3(c chan task) []task { var tasks []task for t := range c { tasks = append(tasks, t) } tasks = runtime.move2heap(tasks) return tasks }
runtime.move2heap はコンパイラ-ランタイムの特別関数で、元がスタック割り当てならヒープへコピーし、そうでなければそのまま返します。したがって、要素数が小さくスタックバッファに収まる場合は 正確に必要なサイズだけ の 1 割り当てを行い、オーバーフロー時のみ通常の倍増戦略へ戻ります。
結論
スライスサイズを事前に正確に知っている場合には手作業で最適化する価値がありますが、多くのケースはコンパイラが自動的に処理してくれます。もし最適化が不具合や逆に性能低下を招いたら、
-gcflags=all=-d=variablemakehash=n でオフにできます。無効化が効果的なら issue を提出してください。
備考:Go スタックは動的サイズのスタックフレーム(alloca 等)を持たず、すべてのスタックフレームは定数サイズです。