HN 日本語サマリー

← 一覧へ戻る
プログラミング

サイズ特化型メモリ割り当て

Size-Specialized Memory Allocation (go.dev)

24 pointsby spacey3 コメント

要約

Go 1.27では、80バイト以下のメモリ割り当てにおいてパフォーマンスが向上しました。これは、特定のサイズクラスとポインタの有無に応じて生成される、最適化された特殊な割り当て関数によるものです。これにより、割り当て処理のオーバーヘッドが削減され、特に頻繁な割り当てが発生するプログラムで速度向上が期待できます。

全文翻訳

サイズ特化型メモリ割り当て

Go 1.27 には、80 バイト以下のメモリ割り当てを高速化する機能が導入されました。割り当て速度は最大 20-30% 向上し、割り当て処理が多いプログラムでは最大 1% の高速化が見込まれます。Go ランタイムは、特定のサイズを割り当てるための専用関数を追加することで、これらの割り当てのパフォーマンスを向上させています。これらの専用関数は、より高速で最適化しやすい特定の仮定を行うことができます。この記事では、この仕組みと、それがプログラムをどのように高速化するのかを説明します。

ヒープ割り当ては、ランタイムの mallocgc 関数によって作成されます。この関数は、割り当てサイズとポインタを含むかどうかという情報が必要です。コンパイラがオブジェクトがヒープにエスケープするか、動的に割り当てる必要があると判断すると、newobject の呼び出しが挿入されます。これは、オブジェクトのサイズとポインタを含むかどうかを抽出し、それらを mallocgc に渡す単純なラッパー関数です。

これらの 2 つの情報が、アロケータが行うほとんどの作業を決定します。サイズは重要です。なぜなら、アロケータは「サイズクラス」と呼ばれるサイズの範囲を定義しているからです。大きすぎず小さすぎないほとんどの割り当てでは、サイズクラスの最大サイズに合わせたメモリブロックのリストから空きオブジェクトを取得します。たとえば、サイズクラス 3 は 17-24 バイトです。したがって、17 バイトを割り当てても 24 バイトを割り当てても、アロケータは 24 バイトオブジェクトのリストで利用可能な次の空き 24 バイトオブジェクトを提供します。以下は、80 バイトまでの各サイズクラスのサイズ範囲の表です。ガベージコレクタに必要なブックキーピングのため、割り当てにポインタが含まれるかどうかに応じて、別々の空きリスト(スパンと呼びます)があります。

サイズクラスサイズの範囲
11-8 バイト
29-16 バイト
317-24 バイト
425-32 バイト
533-48 バイト
649-64 バイト
765-80 バイト

サイズクラスと割り当てにポインタが含まれるかどうかが、どのスパンから割り当てるかを決定するため、アロケータにはスパンクラスという型があります。その値は両方をエンコードします。これは sizeClass<<1 | noPointers として定義されます。サイズ特化型 malloc を実装する際、アロケータの動作の多くはスパンクラスによって決定されるため、これらのスパンクラスに特化するのが最も理にかなっていることは明らかでした。

各スパンクラスに対して、専用の mallocgc バリアントを生成します。たとえば、サイズクラス 3 でポインタを含まないオブジェクトを割り当てる関数は mallocgcSmallNoScanSC3 と呼ばれます。Small は小さすぎず大きすぎないことを意味し、NoScan はポインタを含まないことを意味し、SC3 はサイズクラス 3 (17-24 バイト) を意味します。専用関数は可能な限りシンプルに設計されており、割り当て中のすべてのエッジケースを処理できるわけではありません。そのようなケース(GC がアクティブな場合など)を検出すると、より汎用的な割り当てルーチンにフォールバックします。

そのため、非常に小さいケース(常にポインタなし)と、各非小さいスパンクラス(サイズクラス 2 以上でポインタなしのスパン、サイズクラス 1 以上でポインタありのスパン)に対して、新しい専用 mallocgc 関数が得られます。これらの専用 mallocgc バリアント自体は mallocgc を呼び出すよりも高速ですが、サイズが大きくなるにつれて特化のメリットは減少します。割り当てはメモリのクリアによって支配されることが多く、ある時点で割り当ての残りの作業は無視できるようになります。また、mallocgc より速いだけでは不十分です!サイズ特化型割り当てでは、コンパイラが割り当てるべきスパンクラスを知っている場合、newobject への呼び出しの代わりに、専用関数への直接呼び出しを挿入できます。しかし、コンパイラはコード生成時に割り当てのスパンクラスをしばしば知りません。たとえば、動的な長さのスライスを考えてみてください。コンパイラは割り当てサイズを決定できないため、mallocgc への呼び出しを維持し、mallocgc 自体が専用関数が利用可能かどうか、どれであるかを判断し、それを呼び出す必要があります。したがって、専用関数のパフォーマンスは、動的な呼び出しのオーバーヘッドがあってもなお高速であるほど高くなければなりません。

このオーバーヘッドの問題だけが問題ではありません。もしそうであれば、より大きな専用関数を作成し、コンパイル時にサイズがわかっている場合にコンパイラが挿入できるように予約できます。動的に呼び出さなければ、動的なオーバーヘッドを支払う必要もありません。しかし、追加される専用関数ごとに、コンパイラによって生成される実行可能ファイルのサイズが増加し、さらに重要なことに、貴重な命令キャッシュスペースを占有します。単一の mallocgc 関数は、割り当てが頻繁に発生するため、命令キャッシュに常駐していることがよくあります。専用関数が多すぎ、キャッシュで利用できない場合、専用コードをキャッシュに取得するためのオーバーヘッドが、あらゆるメリットを相殺してしまう可能性があります。また、ICache にある専用割り当てコードが増えるほど、ユーザーコードを圧迫し、ユーザーコードの取得と実行が遅くなります。さまざまなサイズクラスで停止するベンチマークを多数実行した結果、80 バイトで停止するのがスイートスポットであると判断しました。

もちろん、より多くの専用関数を追加することは、より多くのコードを保守する必要があることを意味します。各関数が手書きされている場合、専用関数内のコードは簡単にずれ、同期が取れなくなる可能性があります。これを支援するために、専用関数の共通部分を抽出し、標準ライブラリの go/ast パッケージを使用して解析およびフォーマットし、golang.org/x/tools/go/ast/astutil パッケージを使用して AST を操作するインライナーを作成しました。関数の共通部分はすべて標準 Go コードで記述されており、ランタイムの他の部分と一緒にビルドおよび型チェックされるため、ツールで問題を検出できますが、それらは主にインライナーのスタブです。

サイズ特化型割り当て関数で改善を測定できましたが、なぜ実際に高速なのでしょうか?最も明白な最適化はメモリのクリアです。mallocgc によって返されるメモリは常にゼロにする必要はありませんが、多くの場合必要であり、その実行に割り当て時間の大部分が費やされることがあります。メモリクリア関数 memclrNoHeapPointers は高度に最適化されたアセンブリで記述されていますが、小さいクリアではさらに改善できます。専用関数では、クリアのサイズが定数である場合、コンパイラは memclrNoHeapPointers への呼び出しを、メモリをクリアするための命令を直接生成するコードに置き換えることができます。これにより、割り当ては関数呼び出しといくつかの分岐をスキップできます。これは非常に小さい割り当てでは違いを生みますが、サイズが大きくなるにつれて、関数呼び出しのオーバーヘッドは無視できるようになります。

より高速なメモリクリアが最大の改善をもたらす一方で、専用関数で可能なその他のトリックもいくつかあります。スパンクラスごとに特化されているため、関数はスパンを取得する際にスパンクラスを計算する必要がありません。また、割り当てサイズが定数であるため、コンパイラは割り当てに必要なブックキーピングを高速化するためのいくつかの最適化を行うことができます。その一例が、割り当てメモリ内のポインタのマーキングです。専用関数は、ヘルパー関数のいくつかを手動でインライン化することもできます。Go コンパイラはコードをインライン化できますが、大きすぎると見なす関数をインライン化することは避けます。生成されたコードでは、関数本体を呼び出し元に挿入することで、これをオーバーライドできます。ジェネレーターを使用すると、コピーがずれることを心配することなく、各本体のコピーを生成できます。また、ランタイムデバッグフラグなどのあまり一般的でないケースを処理するコードを、専用関数を小さくするためにスローパス関数に移動することもできました。

このサイズ特化型割り当ての説明が興味深いものであることを願っていますが、Go プログラマであるあなたがコードを書く際に、これらについて考える必要はありません。メモリ割り当ては少し速くなるだけで、特に 16 バイトと 24 バイトの割り当てなど、最も一般的な割り当てサイズの一部に最大のメリットがあります。これらの割り当ては、2 つまたは 3 つの 64 ビット値で構成されるため、インターフェイス値や文字列(2 つの値を持つ)、またはスライス(3 つの値を持つ)などの割り当てを含みます。サイズ特化型 malloc の動作を調整し、命令キャッシュ効果の影響を最小限に抑えるために多くの時間を費やしました。当初は Go 1.26 でサイズ特化型割り当てをリリースする予定でしたが、追加のチューニングを行い、追加コードサイズを可能な限り削減するために、さらに 1 つのリリースを待つことにしました。

サイズ特化型割り当てから改善されたパフォーマンスを得るために、あなたがする必要があるのは、Go 1.27 でプログラムをビルドすることだけです。メモリ割り当てとガベージコレクションのパフォーマンスを向上させるためのより具体的なアクションに興味がある場合は、Go Garbage Collector Optimization Guide をお読みください。

サイズ特化型割り当てがコードでリグレッションを引き起こさないと確信していますが、必要に応じて、GOEXPERIMENT=nosizespecializedmalloc を使用してプログラムをビルドすることで無効にできます。もしサイズ特化型割り当てで発生している問題を解決するためにこれが必要な場合は、調査できるように go.dev/issue/new で問題を報告してください。