HN 日本語サマリー

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

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

Size-Specialized Memory Allocation (go.dev)

9 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バイトまでの各サイズクラスのサイズ範囲の表です。 | サイズクラス | サイズ範囲 | |---|---| | 1 | 1-8 バイト | | 2 | 9-16 バイト | | 3 | 17-24 バイト | | 4 | 25-32 バイト | | 5 | 33-48 バイト | | 6 | 49-64 バイト | | 7 | 65-80 バイト | サイズクラスと割り当てにポインタが含まれているかどうかが、どのスパンから割り当てるかを決定するため、アロケータはスパンクラスと呼ばれる型を持ちます。その値は両方をエンコードします。これは sizeClass<<1 | noPointers として定義されます。サイズ特化型mallocを実装する際、アロケータの動作の多くはスパンクラスによって決定されるため、これらのスパンクラスに特化するのが最も理にかなっていることは明らかでした。私たちは、すべてのスパンクラスに対して特殊化されたmallocgcバリアントを生成します。たとえば、サイズクラス3でポインタフリーオブジェクトを割り当てる関数は mallocgcSmallNoScanSC3 と呼ばれます。Small は tiny または large でないことを意味し、NoScan はポインタがないことを意味し、SC3 はサイズクラス3(17-24バイト)を意味します。 特殊化された関数は可能な限りシンプルになるように設計されており、すべてのエッジケースの割り当てを処理できるわけではありません。そのようなケース(たとえばGCがアクティブな場合)を検出すると、より汎用的な割り当てルーチンにフォールバックします。そのため、tinyケース(常にポインタなし)用の新しい特殊化されたmallocgc関数と、各非tinyスパンクラス用の関数(サイズクラス2以上のポインタなしスパン、サイズクラス1以上のポインタ付きスパン)が得られます。これらの特殊化されたmallocgcバリアント自体は、mallocgcを呼び出すよりも高速ですが、サイズが大きくなるにつれて特殊化のメリットは減少します。割り当ては、メモリのクリアが必要になることが多く、その作業が割り当ての残りの作業を無視できるほどになります。 mallocgcよりも高速であるだけでは不十分です!サイズ特化型割り当てでは、コンパイラが割り当てる必要があるスパンクラスを知っている場合、newobjectへの呼び出しの代わりに特殊化された関数への直接呼び出しを挿入できます。しかし、コンパイラはコードが生成されるときに割り当てられるスパンクラスをしばしば知りません。たとえば、動的な長さのスライスを考えてみてください。コンパイラは割り当てのサイズを決定できないため、mallocgcへの呼び出しを維持します。mallocgc自体は、特殊化された関数が利用可能かどうか、それがどれであるかを決定する必要があり、その後呼び出す必要があります。そのため、特殊化された関数のパフォーマンスは、動的呼び出しのオーバーヘッドがあっても依然として高速であるのに十分な高さである必要があります。 このオーバーヘッドの問題だけではありません。もしそうであれば、より大きな特殊化された関数を作成し、コンパイル時にサイズがわかっている場合にコンパイラが挿入できるように予約できます。そして、それらを動的に呼び出さなければ、動的オーバーヘッドを支払う必要はありません。しかし、追加される特殊化された関数ごとに、コンパイラによって生成される実行可能ファイルのサイズが増加し、さらに重要なこととして、貴重な命令キャッシュスペースを占有します。単一のmallocgc関数は、割り当てがどれほど頻繁に発生するかにより、命令キャッシュに頻繁に配置されます。特殊化された関数が多すぎ、それらがキャッシュで利用できない場合、特殊化されたコードをキャッシュに取得するためのオーバーヘッドが、あらゆるメリットを相殺する可能性があります。そして、ICacheにある特殊化された割り当てコードが増えるほど、ユーザーコードを圧迫し、ユーザーコードの取得と実行が遅くなります。 さまざまなサイズクラスでベンチマークを実行した結果、80バイトで停止するのがスイートスポットであると判断しました。もちろん、より多くの特殊化された関数を追加することは、より多くのコードを保守することを意味します。各関数が手書きされている場合、特殊化された関数内のコードは簡単にずれ、同期が取れなくなる可能性があります。これを支援するために、特殊化された関数の共通部分を抽出し、標準ライブラリのgo/astパッケージを使用してASTを解析およびフォーマットし、golang.org/x/tools/go/ast/astutilパッケージを使用してASTを操作するインライナーを作成しました。関数の共通部分はすべて、ランタイムの残りの部分とともにビルドおよび型チェックされる標準Goコードで記述されており、ツールが問題を検出できるようにしていますが、それらは主にインライナーのスタブです。 そのため、サイズ特化型割り当て関数で改善を測定できましたが、なぜそれらは実際に高速なのでしょうか?最も明白な最適化はメモリのクリアです。mallocgcによって返されるメモリは常にゼロにする必要はありませんが、多くの場合必要であり、それを行うことが割り当て時間の大部分を占めることがあります。メモリクリア関数memclrNoHeapPointersは高度に最適化されたアセンブリで記述されていますが、小さなクリアではさらに改善できます。特殊化された関数では、クリアのサイズが定数である場合、コンパイラはmemclrNoHeapPointersへの呼び出しを、メモリをクリアするための命令を直接生成するコードに置き換えることができます。これにより、割り当ては関数呼び出しといくつかの分岐をスキップできます。これは非常に小さな割り当てでは違いを生みますが、サイズが大きくなるにつれて、関数呼び出しのオーバーヘッドは無視できるようになります。 高速なメモリクリアが最大の改善を提供する一方で、特殊化された関数で可能な他のトリックもいくつかあります。それらはスパンクラスごとに特殊化されているため、関数はスパンを取得する際にスパンクラスを計算する必要がありません。また、割り当てのサイズが定数であるため、コンパイラは割り当てに必要なブックキーピングを高速化するためのいくつかの最適化を行うことができます。そのようなケースの1つは、割り当てられたメモリ内のポインタがどこにあるかをマークすることです。特殊化された関数は、いくつかのヘルパー関数を手動でインライン化することもできます。Goコンパイラはコードをインライン化できますが、大きすぎると見なす関数はインライン化を避けます。生成されたコードで、関数本体を呼び出し元に挿入することでこれをオーバーライドできます。ジェネレータを使用すると、コピーがずれることを心配することなく、各本体のコピーを作成できます。また、ランタイムデバッグフラグなどのあまり一般的でないケースを処理するコードをスローパス関数に移動して、特殊化された関数を小さくすることもできます。 このサイズ特化型割り当ての説明が興味深いことを願っていますが、Goプログラマーとしてコードを書く際にこれらについて考える必要はありません。メモリ割り当ては少し速くなるだけで、最大のメリットは最も一般的な割り当てサイズのいくつかに、特に16バイトと24バイトの割り当てにあります。