Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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)」と呼ばれる技術を適用することがあります。これは、すべての小さな割り当てを記録するのではなく、一定のレートでサンプリングして統計的に推測することで、プロファイリングの負荷を軽減する手法です。一方、大きなオブジェクトの割り当ては、その影響が大きいため、通常はサンプリングされずにすべて記録されます。

問題は、以前の実装では、同じコールスタックから発生した割り当てであれば、そのオブジェクトのサイズに関わらず、単一のプロファイルエントリ(「バケット」)にまとめられてしまっていた点にありました。このため、以下のような不正確なプロファイルが生成される可能性がありました。

  1. 大きなオブジェクトへの誤ったレート調整の適用: 小さなオブジェクトと統合された結果、本来サンプリングされるべきではない大きなオブジェクトの割り当てに対しても、誤ってレート調整が適用され、その割り当て量が過小評価される。
  2. 小さなオブジェクトへのレート調整の不適用: 逆に、大きなオブジェクトと統合された結果、本来レート調整されるべき小さなオブジェクトの割り当てが、レート調整されずにすべて記録されてしまい、プロファイルデータが肥大化したり、オーバーヘッドが増加したりする。

このような不正確さは、開発者がメモリリークや非効率なメモリ使用箇所を特定する際に、誤った判断を下す原因となり得ました。このコミットは、この根本的な問題を解決し、pprofがより正確なメモリプロファイルを提供できるようにすることを目的としています。

前提知識の解説

このコミットを理解するためには、以下のGoランタイムおよびプロファイリングに関する概念を理解しておく必要があります。

  1. Goランタイム (Go Runtime): Goプログラムは、Goランタイムと呼ばれる実行環境上で動作します。ランタイムは、ガベージコレクション、スケジューリング、メモリ管理、プロファイリングなど、プログラムの実行に必要な低レベルの機能を提供します。このコミットで変更されるsrc/pkg/runtime/mprof.gocは、Goランタイムのメモリプロファイリング部分を実装しているC言語のファイルです(Goの初期バージョンでは、ランタイムの一部がCで書かれていました)。

  2. メモリプロファイリング (Memory Profiling): プログラムが実行中にどのようにメモリを割り当て、使用しているかを分析するプロセスです。Goでは、pprofツールを使用してメモリプロファイルを取得し、視覚化できます。メモリプロファイルは、メモリリークの特定、メモリ使用量の最適化、パフォーマンスのボトルネックの発見に役立ちます。

  3. pprofツール: Goに標準で付属するプロファイリングツールです。CPU、メモリ、ゴルーチン、ブロック、ミューテックスなどのプロファイルを収集し、グラフやテキスト形式で表示できます。メモリプロファイルの場合、pprofはプログラムがメモリを割り当てたコールスタックを記録し、各スタックトレースに関連付けられたメモリ量を示します。

  4. コールスタック (Call Stack): 関数呼び出しの履歴を記録するデータ構造です。プログラムがメモリを割り当てる際、その割り当てが発生した時点でのコールスタック(どの関数がどの関数を呼び出して、最終的にこの割り当てに至ったか)が記録されます。pprofは、このコールスタックをキーとしてメモリ割り当てをグループ化します。

  5. メモリ割り当ての「バケット (Bucket)」: Goのメモリプロファイラ内部では、同じコールスタックから発生したメモリ割り当てを論理的なグループ(「バケット」)にまとめます。各バケットは、特定のコールスタックに関連付けられ、そのスタックから割り当てられたメモリの総量や回数を集計します。

  6. レート調整 (Rate Adjusting) / サンプリング (Sampling): プロファイリングのオーバーヘッドを管理するための技術です。特に頻繁に発生する小さなイベント(この場合は小さなメモリ割り当て)に対して、すべてのイベントを記録するのではなく、一部を間引いて記録し、統計的に全体の量を推定します。これにより、プロファイリング中のプログラムの実行速度への影響を最小限に抑えつつ、十分な精度でプロファイル情報を収集できます。Goのメモリプロファイラでは、デフォルトで512KBごとに1バイトの割り当てをサンプリングするようになっています(つまり、平均して512KBのメモリが割り当てられるごとに1回の割り当てが記録される)。このレートはGODEBUG=mprof_sample_rate=Nで調整可能です。

  7. Small Objects vs. Large Objects: Goのメモリプロファイリングの文脈では、「rate」という閾値(デフォルトでは512KB)を基準に、割り当てられたオブジェクトが「small」か「large」かを区別します。

    • Small Objects: 割り当てサイズがrate以下(またはrateより小さい)のオブジェクト。これらはサンプリングの対象となり、プロファイリングのオーバーヘッドを減らすためにレート調整が適用されます。
    • Large Objects: 割り当てサイズがrateより大きいオブジェクト。これらは通常、サンプリングされずにすべて記録されます。なぜなら、大きな割り当ては頻度が低い一方で、個々の割り当てがメモリ使用量に与える影響が大きいため、正確に追跡する必要があるからです。

このコミットの核心は、この「small objects」と「large objects」の区別と、それらに対するレート調整の適用方法が、バケットの統合ロジックと衝突していた点にあります。

技術的詳細

このコミットの技術的な解決策は、メモリプロファイリングの「バケット」の識別ロジックに、割り当てられたオブジェクトの「サイズ」を組み込むことです。これにより、同じコールスタックから発生した割り当てであっても、サイズが異なれば別のバケットとして扱われるようになります。

具体的な変更点は以下の通りです。

  1. 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」に変更され、ハッシュ計算にサイズが考慮されることが明示されています。

  2. 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関数は、バケットを識別する際にコールスタックだけでなく、割り当てサイズも考慮できるようになります。

  3. バケットのハッシュ計算ロジックの変更: stkbucket関数内で、バケットのハッシュ値を計算する際に、新しく追加されたsize引数の値がハッシュに組み込まれるようになりました。

    // ... 既存のスタックハッシュ計算 ...
    
    // hash in size
    h += size;
    h += h<<10;
    h ^= h>>6;
    // finalize
    h += h<<3;
    h ^= h>>11;
    

    この変更により、同じコールスタックであっても、割り当てサイズが異なれば異なるハッシュ値が生成され、結果として異なるバケットにマッピングされる可能性が高まります。

  4. 既存バケット検索ロジックの変更: 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)
    

    これにより、既存のバケットを見つけるためには、タイプ、ハッシュ、スタックトレースの長さ、スタックトレースの内容に加えて、割り当てサイズも完全に一致する必要があります。これにより、同じコールスタックでもサイズが異なる割り当ては、必ず新しいバケットとして扱われるようになります。

  5. 新しいバケットの初期化: 新しいバケットが作成される際に、b->size = size;という行が追加され、割り当てサイズがバケットに保存されるようになりました。

  6. 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ファイルに集中しています。

  1. 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];
     };
    
  2. 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;\
    
  3. runtime·MProf_Mallocruntime·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ランタイムのメモリプロファイリングシステムが、メモリ割り当てをグループ化する際の「識別子」に、割り当てられたオブジェクトのサイズを含めるように変更した点にあります。

  1. Bucket構造体へのsizeフィールドの追加: Bucket構造体は、特定のコールスタックから発生したメモリ割り当ての統計情報を保持するためのデータ構造です。以前は、この構造体はコールスタックの情報(stknstk)と、そのハッシュ値(hash)のみを識別子として使用していました。 uintptr size;フィールドが追加されたことで、各Bucketインスタンスは、それが表す割り当ての「サイズ」情報も保持するようになりました。これにより、同じコールスタックから発生した割り当てであっても、サイズが異なれば異なるBucketとして区別できるようになります。hashフィールドのコメントが「hash of size + stk」に変更されたのは、この新しい識別ロジックを反映しています。

  2. stkbucket関数の役割と変更: stkbucket関数は、メモリプロファイリングにおいて最も重要な関数の一つです。この関数は、与えられたコールスタック(stknstk)に対応するBucketを見つけるか、存在しない場合は新しく作成する役割を担っています。

    • シグネチャの変更: stkbucket関数にuintptr sizeという新しい引数が追加されました。これは、この関数がバケットを識別する際に、コールスタックだけでなく、割り当てサイズも考慮する必要があることを示しています。
    • ハッシュ計算の変更: h += size; h += h<<10; h ^= h>>6;という行が追加され、計算されるハッシュ値hsizeの値が組み込まれるようになりました。これにより、同じコールスタックであっても、sizeが異なれば異なるハッシュ値が生成され、ハッシュテーブル上での衝突が減り、異なるバケットにマッピングされる可能性が高まります。
    • バケット検索ロジックの変更: if文の条件にb->size == sizeが追加されました。これは、既存のBucketを見つけるためには、そのBucketに格納されているsizeが、現在処理している割り当てのsizeと完全に一致する必要があることを意味します。この変更が、大小異なるオブジェクトを別々のバケットに分離する上で最も重要な部分です。これにより、たとえコールスタックが同じでも、割り当てサイズが異なれば、既存のバケットとは「異なる」と判断され、新しいバケットが作成されるか、適切なサイズのバケットが検索されるようになります。
    • 新しいバケットの初期化: 新しくBucketが作成される際に、b->size = size;によって、そのバケットが表す割り当てのサイズが正確に記録されるようになりました。
  3. runtime·MProf_Mallocruntime·blockeventからの呼び出し:

    • runtime·MProf_Mallocは、Goプログラムがメモリを割り当てた際に呼び出されるランタイム関数で、メモリプロファイリングの主要なエントリポイントです。この関数からstkbucketを呼び出す際に、実際に割り当てられたメモリのsizeを正確に渡すようになりました。これにより、メモリプロファイリングシステムは、各割り当てのサイズ情報を利用して、より粒度の高いバケット管理を行えるようになります。
    • runtime·blockeventは、ゴルーチンがブロックされたイベントをプロファイリングするための関数です。この関数からstkbucketを呼び出す際には、size引数に0が渡されています。これは、ブロックイベントのプロファイリングにおいては、メモリ割り当てのサイズは関係ないため、ダミーの値が渡されていることを示しています。このことは、stkbucket関数がメモリプロファイリング(MProf)とブロックプロファイリング(BProf)の両方で再利用されていることを示唆しています。

これらの変更の組み合わせにより、Goのメモリプロファイリングシステムは、同じコールスタックから発生した割り当てであっても、そのサイズに基づいて異なるプロファイルエントリとして区別できるようになりました。これにより、小さなオブジェクトに対するレート調整が大きなオブジェクトに誤って適用されたり、その逆が発生したりする問題が解消され、pprofが生成するメモリプロファイルの正確性が大幅に向上しました。

関連リンク

参考にした情報源リンク