[インデックス 18526] ファイルの概要
このコミットは、Goランタイムのメモリプロファイリングにおけるバグ修正に関するものです。具体的には、同じコールスタックから割り当てられた大小異なるオブジェクトが、メモリプロファイルにおいて誤って単一のエントリとして扱われる問題を解決します。これにより、pprof
ツールが生成するメモリプロファイルの精度が向上します。
コミット
commit e71d147750dc4dce115c5614fc96877aa08da596
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Fri Feb 14 13:20:41 2014 +0400
runtime: fix mem profile when both large and small objects are allocated at the same stack
Currently small and large (size>rate) objects are merged into a single entry.
But rate adjusting is required only for small objects.
As a result pprof either incorrectly adjusts large objects
or does not adjust small objects.
With this change objects of different sizes are stored in different buckets.
LGTM=rsc
R=golang-codereviews, gobot, rsc
CC=golang-codereviews
https://golang.org/cl/59220049
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/e71d147750dc4dce115c5614fc96877aa08da596
元コミット内容
Goランタイムのメモリプロファイラにおいて、同じコールスタックから割り当てられた大小のオブジェクトが単一のプロファイルエントリに統合されてしまう問題を修正します。この問題により、pprof
が大きなオブジェクトに対して誤ったレート調整を適用したり、小さなオブジェクトに対してレート調整を適用しなかったりするため、メモリプロファイルが不正確になる可能性がありました。この変更により、異なるサイズのオブジェクトは異なるバケットに格納されるようになります。
変更の背景
Goのメモリプロファイリングツールであるpprof
は、プログラムのメモリ使用状況を分析する上で非常に強力です。pprof
は、メモリ割り当てをコールスタックに基づいてグループ化し、どのコードパスがどれだけのメモリを割り当てているかを可視化します。
しかし、このコミット以前のGoランタイムのメモリプロファイラには、特定のシナリオでプロファイルの精度が低下するという問題がありました。その問題とは、同じコールスタックから、サイズが大きく異なるオブジェクト(「small objects」と「large objects」)が両方とも割り当てられた場合に発生していました。
Goのメモリプロファイラは、プロファイリングのオーバーヘッドを削減するために、特に小さなオブジェクトの割り当てに対して「レート調整(rate adjusting)」または「サンプリング(sampling)」と呼ばれる技術を適用することがあります。これは、すべての小さな割り当てを記録するのではなく、一定のレートでサンプリングして統計的に推測することで、プロファイリングの負荷を軽減する手法です。一方、大きなオブジェクトの割り当ては、その影響が大きいため、通常はサンプリングされずにすべて記録されます。
問題は、以前の実装では、同じコールスタックから発生した割り当てであれば、そのオブジェクトのサイズに関わらず、単一のプロファイルエントリ(「バケット」)にまとめられてしまっていた点にありました。このため、以下のような不正確なプロファイルが生成される可能性がありました。
- 大きなオブジェクトへの誤ったレート調整の適用: 小さなオブジェクトと統合された結果、本来サンプリングされるべきではない大きなオブジェクトの割り当てに対しても、誤ってレート調整が適用され、その割り当て量が過小評価される。
- 小さなオブジェクトへのレート調整の不適用: 逆に、大きなオブジェクトと統合された結果、本来レート調整されるべき小さなオブジェクトの割り当てが、レート調整されずにすべて記録されてしまい、プロファイルデータが肥大化したり、オーバーヘッドが増加したりする。
このような不正確さは、開発者がメモリリークや非効率なメモリ使用箇所を特定する際に、誤った判断を下す原因となり得ました。このコミットは、この根本的な問題を解決し、pprof
がより正確なメモリプロファイルを提供できるようにすることを目的としています。
前提知識の解説
このコミットを理解するためには、以下のGoランタイムおよびプロファイリングに関する概念を理解しておく必要があります。
-
Goランタイム (Go Runtime): Goプログラムは、Goランタイムと呼ばれる実行環境上で動作します。ランタイムは、ガベージコレクション、スケジューリング、メモリ管理、プロファイリングなど、プログラムの実行に必要な低レベルの機能を提供します。このコミットで変更される
src/pkg/runtime/mprof.goc
は、Goランタイムのメモリプロファイリング部分を実装しているC言語のファイルです(Goの初期バージョンでは、ランタイムの一部がCで書かれていました)。 -
メモリプロファイリング (Memory Profiling): プログラムが実行中にどのようにメモリを割り当て、使用しているかを分析するプロセスです。Goでは、
pprof
ツールを使用してメモリプロファイルを取得し、視覚化できます。メモリプロファイルは、メモリリークの特定、メモリ使用量の最適化、パフォーマンスのボトルネックの発見に役立ちます。 -
pprof
ツール: Goに標準で付属するプロファイリングツールです。CPU、メモリ、ゴルーチン、ブロック、ミューテックスなどのプロファイルを収集し、グラフやテキスト形式で表示できます。メモリプロファイルの場合、pprof
はプログラムがメモリを割り当てたコールスタックを記録し、各スタックトレースに関連付けられたメモリ量を示します。 -
コールスタック (Call Stack): 関数呼び出しの履歴を記録するデータ構造です。プログラムがメモリを割り当てる際、その割り当てが発生した時点でのコールスタック(どの関数がどの関数を呼び出して、最終的にこの割り当てに至ったか)が記録されます。
pprof
は、このコールスタックをキーとしてメモリ割り当てをグループ化します。 -
メモリ割り当ての「バケット (Bucket)」: Goのメモリプロファイラ内部では、同じコールスタックから発生したメモリ割り当てを論理的なグループ(「バケット」)にまとめます。各バケットは、特定のコールスタックに関連付けられ、そのスタックから割り当てられたメモリの総量や回数を集計します。
-
レート調整 (Rate Adjusting) / サンプリング (Sampling): プロファイリングのオーバーヘッドを管理するための技術です。特に頻繁に発生する小さなイベント(この場合は小さなメモリ割り当て)に対して、すべてのイベントを記録するのではなく、一部を間引いて記録し、統計的に全体の量を推定します。これにより、プロファイリング中のプログラムの実行速度への影響を最小限に抑えつつ、十分な精度でプロファイル情報を収集できます。Goのメモリプロファイラでは、デフォルトで512KBごとに1バイトの割り当てをサンプリングするようになっています(つまり、平均して512KBのメモリが割り当てられるごとに1回の割り当てが記録される)。このレートは
GODEBUG=mprof_sample_rate=N
で調整可能です。 -
Small Objects vs. Large Objects: Goのメモリプロファイリングの文脈では、「rate」という閾値(デフォルトでは512KB)を基準に、割り当てられたオブジェクトが「small」か「large」かを区別します。
- Small Objects: 割り当てサイズが
rate
以下(またはrate
より小さい)のオブジェクト。これらはサンプリングの対象となり、プロファイリングのオーバーヘッドを減らすためにレート調整が適用されます。 - Large Objects: 割り当てサイズが
rate
より大きいオブジェクト。これらは通常、サンプリングされずにすべて記録されます。なぜなら、大きな割り当ては頻度が低い一方で、個々の割り当てがメモリ使用量に与える影響が大きいため、正確に追跡する必要があるからです。
- Small Objects: 割り当てサイズが
このコミットの核心は、この「small objects」と「large objects」の区別と、それらに対するレート調整の適用方法が、バケットの統合ロジックと衝突していた点にあります。
技術的詳細
このコミットの技術的な解決策は、メモリプロファイリングの「バケット」の識別ロジックに、割り当てられたオブジェクトの「サイズ」を組み込むことです。これにより、同じコールスタックから発生した割り当てであっても、サイズが異なれば別のバケットとして扱われるようになります。
具体的な変更点は以下の通りです。
-
Bucket
構造体の変更:src/pkg/runtime/mprof.goc
ファイル内のBucket
構造体に、uintptr size;
フィールドが追加されました。struct Bucket { // ... 既存のフィールド ... uintptr hash; // hash of size + stk <-- コメントが変更 uintptr size; // <-- 新しく追加されたフィールド uintptr nstk; uintptr stk[1]; };
この
size
フィールドは、そのバケットが表すメモリ割り当てのサイズを保持します。hash
フィールドのコメントも「hash of stk」から「hash of size + stk」に変更され、ハッシュ計算にサイズが考慮されることが明示されています。 -
stkbucket
関数のシグネチャ変更: メモリ割り当てのコールスタックに基づいて適切なバケットを取得または作成するstkbucket
関数のシグネチャが変更され、size
引数が追加されました。// 変更前: // static Bucket* stkbucket(int32 typ, uintptr *stk, int32 nstk, bool alloc) // 変更後: static Bucket* stkbucket(int32 typ, uintptr size, uintptr *stk, int32 nstk, bool alloc)
これにより、
stkbucket
関数は、バケットを識別する際にコールスタックだけでなく、割り当てサイズも考慮できるようになります。 -
バケットのハッシュ計算ロジックの変更:
stkbucket
関数内で、バケットのハッシュ値を計算する際に、新しく追加されたsize
引数の値がハッシュに組み込まれるようになりました。// ... 既存のスタックハッシュ計算 ... // hash in size h += size; h += h<<10; h ^= h>>6; // finalize h += h<<3; h ^= h>>11;
この変更により、同じコールスタックであっても、割り当てサイズが異なれば異なるハッシュ値が生成され、結果として異なるバケットにマッピングされる可能性が高まります。
-
既存バケット検索ロジックの変更:
stkbucket
関数内で、既存のバケットを検索する際の比較条件にb->size == size
が追加されました。// 変更前: // if(b->typ == typ && b->hash == h && b->nstk == nstk && // runtime·mcmp((byte*)b->stk, (byte*)stk, nstk*sizeof stk[0]) == 0) // 変更後: if(b->typ == typ && b->hash == h && b->size == size && b->nstk == nstk && runtime·mcmp((byte*)b->stk, (byte*)stk, nstk*sizeof stk[0]) == 0)
これにより、既存のバケットを見つけるためには、タイプ、ハッシュ、スタックトレースの長さ、スタックトレースの内容に加えて、割り当てサイズも完全に一致する必要があります。これにより、同じコールスタックでもサイズが異なる割り当ては、必ず新しいバケットとして扱われるようになります。
-
新しいバケットの初期化: 新しいバケットが作成される際に、
b->size = size;
という行が追加され、割り当てサイズがバケットに保存されるようになりました。 -
stkbucket
の呼び出し箇所の更新:runtime·MProf_Malloc
関数(メモリ割り当てプロファイリングのエントリポイント)では、stkbucket
を呼び出す際に、実際に割り当てられたsize
が新しい引数として渡されるようになりました。// 変更前: // b = stkbucket(MProf, stk, nstk, true); // 変更後: b = stkbucket(MProf, size, stk, nstk, true);
runtime·blockevent
関数(ブロックプロファイリングのエントリポイント)では、stkbucket
を呼び出す際に、size
引数に0
が渡されるようになりました。これは、ブロックプロファイリングにおいては割り当てサイズが関係ないため、ダミーの値が渡されていることを示します。// 変更前: // b = stkbucket(BProf, stk, nstk, true); // 変更後: b = stkbucket(BProf, 0, stk, nstk, true);
これらの変更により、Goランタイムのメモリプロファイラは、割り当てサイズをバケット識別の重要な要素として扱うようになり、大小異なるオブジェクトが同じコールスタックから割り当てられた場合でも、それぞれ独立したプロファイルエントリとして正確に集計されるようになりました。これにより、pprof
が生成するメモリプロファイルの信頼性と有用性が大幅に向上します。
コアとなるコードの変更箇所
このコミットのコアとなる変更は、src/pkg/runtime/mprof.goc
ファイルに集中しています。
-
Bucket
構造体へのsize
フィールドの追加:--- a/src/pkg/runtime/mprof.goc +++ b/src/pkg/runtime/mprof.goc @@ -67,7 +67,8 @@ struct Bucket int64 cycles; }; }; - uintptr hash; + uintptr hash; // hash of size + stk + uintptr size; uintptr nstk; uintptr stk[1]; };
-
stkbucket
関数のシグネチャ変更とsize
のハッシュへの組み込み:--- a/src/pkg/runtime/mprof.goc +++ b/src/pkg/runtime/mprof.goc @@ -79,7 +80,7 @@ static uintptr bucketmem;\ \ // Return the bucket for stk[0:nstk], allocating new bucket if needed.\ static Bucket*\ -stkbucket(int32 typ, uintptr *stk, int32 nstk, bool alloc)\ +stkbucket(int32 typ, uintptr size, uintptr *stk, int32 nstk, bool alloc)\ {\ int32 i;\ uintptr h;\ @@ -100,12 +101,17 @@ stkbucket(int32 typ, uintptr *stk, int32 nstk, bool alloc)\ h += h<<10;\ h ^= h>>6;\ }\ + // hash in size + h += size;\ + h += h<<10;\ + h ^= h>>6;\ + // finalize h += h<<3;\ h ^= h>>11;\ \ i = h%BuckHashSize;\ for(b = buckhash[i]; b; b=b->next)\ - if(b->typ == typ && b->hash == h && b->nstk == nstk &&\ + if(b->typ == typ && b->hash == h && b->size == size && b->nstk == nstk &&\ runtime·mcmp((byte*)b->stk, (byte*)stk, nstk*sizeof stk[0]) == 0)\ return b;\ \ @@ -117,6 +123,7 @@ stkbucket(int32 typ, uintptr *stk, int32 nstk, bool alloc)\ runtime·memmove(b->stk, stk, nstk*sizeof stk[0]);\ b->typ = typ;\ b->hash = h;\ + b->size = size;\ b->nstk = nstk;\ b->next = buckhash[i];\ buckhash[i] = b;\
-
runtime·MProf_Malloc
とruntime·blockevent
でのstkbucket
呼び出しの更新:--- a/src/pkg/runtime/mprof.goc +++ b/src/pkg/runtime/mprof.goc @@ -231,7 +238,7 @@ runtime·MProf_Malloc(void *p, uintptr size, uintptr typ)\ runtime·printf(">)n");\ printstackframes(stk, nstk);\ }\ - b = stkbucket(MProf, stk, nstk, true);\ + b = stkbucket(MProf, size, stk, nstk, true);\ b->recent_allocs++;\ b->recent_alloc_bytes += size;\ runtime·unlock(&proflock);\ @@ -296,7 +303,7 @@ runtime·blockevent(int64 cycles, int32 skip)\ \ nstk = runtime·callers(skip, stk, nelem(stk));\ runtime·lock(&proflock);\ - b = stkbucket(BProf, stk, nstk, true);\ + b = stkbucket(BProf, 0, stk, nstk, true);\ b->count++;\ b->cycles += cycles;\ runtime·unlock(&proflock);\
コアとなるコードの解説
このコミットの核心は、Goランタイムのメモリプロファイリングシステムが、メモリ割り当てをグループ化する際の「識別子」に、割り当てられたオブジェクトのサイズを含めるように変更した点にあります。
-
Bucket
構造体へのsize
フィールドの追加:Bucket
構造体は、特定のコールスタックから発生したメモリ割り当ての統計情報を保持するためのデータ構造です。以前は、この構造体はコールスタックの情報(stk
とnstk
)と、そのハッシュ値(hash
)のみを識別子として使用していました。uintptr size;
フィールドが追加されたことで、各Bucket
インスタンスは、それが表す割り当ての「サイズ」情報も保持するようになりました。これにより、同じコールスタックから発生した割り当てであっても、サイズが異なれば異なるBucket
として区別できるようになります。hash
フィールドのコメントが「hash of size + stk」に変更されたのは、この新しい識別ロジックを反映しています。 -
stkbucket
関数の役割と変更:stkbucket
関数は、メモリプロファイリングにおいて最も重要な関数の一つです。この関数は、与えられたコールスタック(stk
とnstk
)に対応するBucket
を見つけるか、存在しない場合は新しく作成する役割を担っています。- シグネチャの変更:
stkbucket
関数にuintptr size
という新しい引数が追加されました。これは、この関数がバケットを識別する際に、コールスタックだけでなく、割り当てサイズも考慮する必要があることを示しています。 - ハッシュ計算の変更:
h += size; h += h<<10; h ^= h>>6;
という行が追加され、計算されるハッシュ値h
にsize
の値が組み込まれるようになりました。これにより、同じコールスタックであっても、size
が異なれば異なるハッシュ値が生成され、ハッシュテーブル上での衝突が減り、異なるバケットにマッピングされる可能性が高まります。 - バケット検索ロジックの変更:
if
文の条件にb->size == size
が追加されました。これは、既存のBucket
を見つけるためには、そのBucket
に格納されているsize
が、現在処理している割り当てのsize
と完全に一致する必要があることを意味します。この変更が、大小異なるオブジェクトを別々のバケットに分離する上で最も重要な部分です。これにより、たとえコールスタックが同じでも、割り当てサイズが異なれば、既存のバケットとは「異なる」と判断され、新しいバケットが作成されるか、適切なサイズのバケットが検索されるようになります。 - 新しいバケットの初期化: 新しく
Bucket
が作成される際に、b->size = size;
によって、そのバケットが表す割り当てのサイズが正確に記録されるようになりました。
- シグネチャの変更:
-
runtime·MProf_Malloc
とruntime·blockevent
からの呼び出し:runtime·MProf_Malloc
は、Goプログラムがメモリを割り当てた際に呼び出されるランタイム関数で、メモリプロファイリングの主要なエントリポイントです。この関数からstkbucket
を呼び出す際に、実際に割り当てられたメモリのsize
を正確に渡すようになりました。これにより、メモリプロファイリングシステムは、各割り当てのサイズ情報を利用して、より粒度の高いバケット管理を行えるようになります。runtime·blockevent
は、ゴルーチンがブロックされたイベントをプロファイリングするための関数です。この関数からstkbucket
を呼び出す際には、size
引数に0
が渡されています。これは、ブロックイベントのプロファイリングにおいては、メモリ割り当てのサイズは関係ないため、ダミーの値が渡されていることを示しています。このことは、stkbucket
関数がメモリプロファイリング(MProf
)とブロックプロファイリング(BProf
)の両方で再利用されていることを示唆しています。
これらの変更の組み合わせにより、Goのメモリプロファイリングシステムは、同じコールスタックから発生した割り当てであっても、そのサイズに基づいて異なるプロファイルエントリとして区別できるようになりました。これにより、小さなオブジェクトに対するレート調整が大きなオブジェクトに誤って適用されたり、その逆が発生したりする問題が解消され、pprof
が生成するメモリプロファイルの正確性が大幅に向上しました。
関連リンク
- Goのプロファイリングに関する公式ドキュメント: https://go.dev/doc/diagnose-profiling
pprof
ツールの詳細: https://github.com/google/pprof- Goのメモリプロファイリングの仕組みに関する議論(関連する可能性のある情報源):
- Go issue tracker (memory profiling): https://github.com/golang/go/issues?q=is%3Aissue+memory+profiling
参考にした情報源リンク
- コミットハッシュ:
e71d147750dc4dce115c5614fc96877aa08da596
- GitHub上のコミットページ: https://github.com/golang/go/commit/e71d147750dc4dce115c5614fc96877aa08da596
- Go CL (Change List) 59220049: https://golang.org/cl/59220049 (これはコミットメッセージに記載されているリンクであり、詳細なレビューコメントや変更の経緯が含まれている可能性があります。)
- Goのメモリプロファイリングに関する一般的な知識(Goのドキュメントやブログ記事など)
- Go Blog: Profiling Go Programs: https://go.dev/blog/pprof
- Go source code for runtime/mprof.go (現在のGoバージョンではGoで書かれているが、概念は共通): https://github.com/golang/go/blob/master/src/runtime/mprof.go
- Go source code for runtime/mprof.goc (コミット当時のファイル): https://github.com/golang/go/blob/e71d147750dc4dce115c5614fc96877aa08da596/src/pkg/runtime/mprof.goc (コミット時点のファイル内容を確認するために参照)
- Goのメモリプロファイリングにおけるサンプリングレートに関する情報: https://go.dev/doc/diagnose-profiling#Memory