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

[インデックス 14288] ファイルの概要

コミット

commit ce287933d65be61fa45a7633b90a044e2c0b31b2
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date:   Thu Nov 1 19:43:29 2012 +0100

    cmd/gc, runtime: pass PC directly to racefuncenter.
    
    go test -race -run none -bench . encoding/json
    benchmark                      old ns/op    new ns/op    delta
    BenchmarkCodeEncoder          3207689000   1716149000  -46.50%
    BenchmarkCodeMarshal          3206761000   1715677000  -46.50%
    BenchmarkCodeDecoder          8647304000   4482709000  -48.16%
    BenchmarkCodeUnmarshal        8032217000   3451248000  -57.03%
    BenchmarkCodeUnmarshalReuse   8016722000   3480502000  -56.58%
    BenchmarkSkipValue           10340453000   4560313000  -55.90%
    
    benchmark                       old MB/s     new MB/s  speedup
    BenchmarkCodeEncoder                0.60         1.13    1.88x
    BenchmarkCodeMarshal                0.61         1.13    1.85x
    BenchmarkCodeDecoder                0.22         0.43    1.95x
    BenchmarkCodeUnmarshal              0.24         0.56    2.33x
    BenchmarkCodeUnmarshalReuse         0.24         0.56    2.33x
    BenchmarkSkipValue                  0.19         0.44    2.32x
    
    Fixes #4248.
    
    R=dvyukov, golang-dev, rsc
    CC=golang-dev
    https://golang.org/cl/6815066

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/ce287933d65be61fa45a7633b90a044e2c0b31b2

元コミット内容

このコミットは、Goのレース検出器(race detector)のパフォーマンス改善を目的としています。具体的には、racefuncenter 関数にプログラムカウンタ(PC)を直接渡すように変更することで、ベンチマークにおいて大幅な速度向上(最大57%の高速化)を実現しています。

変更の概要は以下の通りです。

  • cmd/gc (Goコンパイラ) と runtime (Goランタイム) において、racefuncenter 関数が呼び出し元のPCを直接引数として受け取るように修正。
  • これにより、racefuncenter 内部でPCを取得するためのコストの高い処理(runtime·callersの呼び出し)が削減される。
  • encoding/json パッケージのベンチマーク結果が示されており、エンコーダ、マーシャラ、デコーダ、アンマーシャラ、スキップ値の各操作で大幅なパフォーマンス改善が見られる。

変更の背景

Goのレース検出器は、並行処理におけるデータ競合(data race)を検出するための強力なツールです。しかし、その検出メカニズムは実行時にオーバーヘッドを発生させることが知られています。特に、関数が呼び出されるたびにracefuncenterが実行され、その中で呼び出し元のプログラムカウンタ(PC)を取得する処理がボトルネックとなっていました。

このコミットの背景には、Go issue #4248 があります。このissueでは、レース検出器を有効にした際のパフォーマンス低下が報告されており、特にencoding/jsonのような頻繁に呼び出される関数を含むパッケージで顕著でした。既存の実装では、racefuncenterが呼び出された際に、runtime·callersという関数を使って呼び出し元のPCをスタックトレースから取得していました。このruntime·callersの呼び出しは比較的コストが高く、レース検出器のオーバーヘッドの主要な原因となっていました。

このコミットは、このパフォーマンスボトルネックを解消するために、コンパイラがracefuncenterを呼び出す際に、あらかじめ呼び出し元のPCを引数として渡すように変更することで、racefuncenter内部でのPC取得コストを削減することを目的としています。これにより、レース検出器の実行時オーバーヘッドを大幅に削減し、go test -raceの実行速度を向上させることが期待されました。

前提知識の解説

1. データ競合 (Data Race)

データ競合とは、複数のゴルーチン(Goの軽量スレッド)が同時に同じメモリ位置にアクセスし、少なくとも1つのアクセスが書き込みであり、かつそれらのアクセスが同期メカニズムによって保護されていない場合に発生するバグです。データ競合は予測不能な動作やプログラムのクラッシュを引き起こす可能性があり、並行プログラミングにおいて非常に厄介な問題です。

2. Go Race Detector (レース検出器)

Go言語には、実行時にデータ競合を検出するための組み込みツールであるレース検出器があります。go run -racego test -raceのように-raceフラグを付けてプログラムを実行またはテストすることで有効にできます。レース検出器は、メモリへのアクセスを監視し、データ競合のパターンを検出すると警告を出力します。これは、Googleが開発したThreadSanitizerという技術に基づいています。

3. プログラムカウンタ (Program Counter, PC)

プログラムカウンタは、CPUが次に実行する命令のアドレスを保持するレジスタです。関数呼び出しの際には、呼び出し元の命令の次のアドレス(リターンアドレス)がスタックに保存され、関数が終了した後にそのアドレスに戻って実行を再開します。レース検出器では、どのコードがメモリにアクセスしたかを特定するために、このPC情報が重要になります。

4. racefuncenterracefuncexit

Goのレース検出器は、コンパイル時にコードを「計測(instrumentation)」することで機能します。具体的には、各関数のエントリポイントとエグジットポイントに特別な関数呼び出しを挿入します。

  • racefuncenter: 関数が呼び出された直後に実行され、その関数の実行が開始されたことをレース検出器に通知します。
  • racefuncexit: 関数が終了する直前に実行され、その関数の実行が終了したことをレース検出器に通知します。 これらの関数は、レース検出器がゴルーチンの実行コンテキストを追跡し、メモリアクセスを監視するために使用されます。

5. runtime·callers

runtime·callersは、Goのランタイムが提供する関数で、現在のゴルーチンのコールスタックをウォークし、指定された数の呼び出し元のPC(プログラムカウンタ)を取得するために使用されます。これはデバッグやプロファイリング、そしてレース検出器のようなツールで、実行コンテキストを特定するために利用されます。しかし、スタックをウォークする処理は比較的コストが高く、頻繁に呼び出されるとパフォーマンスに影響を与えます。

6. cmd/gc

cmd/gcは、Go言語の公式コンパイラです。Goのソースコードを機械語に変換する役割を担っています。レース検出器の計測処理は、このコンパイラによって行われます。

7. runtime

runtimeは、Goプログラムの実行を管理するGo言語のランタイムシステムです。ガベージコレクション、ゴルーチン管理、スケジューリング、システムコールなど、低レベルの機能を提供します。レース検出器のコアロジックの一部もこのランタイム内に実装されています。

技術的詳細

このコミットの核心は、Goコンパイラ(cmd/gc)とGoランタイム(src/pkg/runtime/race.c)間の連携を最適化し、レース検出器のオーバーヘッドを削減することにあります。

変更前のアプローチ

変更前は、cmd/gcが生成するコードは、各関数のエントリでracefuncenter()を引数なしで呼び出していました。racefuncenter関数は、その内部でruntime·callers(2, &pc, 1)を呼び出すことで、自身の呼び出し元(つまり、計測対象の関数)のPCを取得していました。 runtime·callersはスタックフレームを遡ってPCを特定するため、その処理には一定のコストがかかります。特に、encoding/jsonのような、多くの小さな関数が頻繁に呼び出されるようなシナリオでは、このPC取得のオーバーヘッドが累積し、全体のパフォーマンスに大きな影響を与えていました。

変更後のアプローチ

このコミットでは、以下の変更が導入されました。

  1. racefuncenterのシグネチャ変更:

    • src/cmd/gc/builtin.csrc/cmd/gc/runtime.goにおいて、racefuncenter関数のシグネチャがfunc racefuncenter()からfunc racefuncenter(uintptr)に変更されました。これにより、racefuncenterは呼び出し元のPCを直接引数として受け取ることができるようになります。
  2. コンパイラによるPCの直接渡し:

    • src/cmd/gc/racewalk.cにおいて、コンパイラがracefuncenterを呼び出す際に、呼び出し元のPCを計算して引数として渡すように変更されました。
    • 具体的には、nodpc = nod(OXXX, nil, nil); *nodpc = *nodfp; nodpc->type = types[TUINTPTR]; nodpc->xoffset = -widthptr;というコードが追加され、nodpcというノードが呼び出し元のPCを表すように設定されます。
    • そして、nd = mkcall("racefuncenter", T, nil, nodpc);という形で、racefuncenterの呼び出しにこのnodpcが引数として追加されます。
    • このPCの計算は、getcallerpc(呼び出し元のPCを取得する)という概念に基づいています。x86アーキテクチャでは、フレームポインタ(FP)からのオフセット(-widthptr)を使ってPCを直接取得できるため、runtime·callersのような高コストなスタックウォークが不要になります。ただし、コメントにBUG: this will not work on arm.とあるように、当時のARMアーキテクチャではこの直接取得が機能しない可能性が示唆されています。
  3. racefuncenter内部ロジックの変更:

    • src/pkg/runtime/race.cにおいて、runtime·racefuncenter関数の実装が変更されました。
    • 変更前は無条件にruntime·callers(2, &pc, 1);を呼び出していましたが、変更後はuintptr pcを引数として受け取るようになり、if(pc == (uintptr)runtime·lessstack)という条件分岐が追加されました。
    • runtime·lessstackは、スタックが不足している(スタックが分割されている)場合に設定される特別な値です。もしコンパイラから渡されたPCがruntime·lessstackである場合、それはスタックが分割されており、直接渡されたPCが正確でない可能性があることを意味します。この場合にのみ、フォールバックとしてruntime·callers(2, &pc, 1);を呼び出して、スタックをウォークして正確なPCを取得します。
    • 通常の場合(スタックが分割されていない場合)は、コンパイラから直接渡されたPCをそのまま使用するため、runtime·callersの呼び出しがスキップされ、大幅なパフォーマンス改善に繋がります。

パフォーマンスへの影響

この変更により、runtime·callersの呼び出しが大幅に削減され、レース検出器のオーバーヘッドが劇的に減少しました。コミットメッセージに示されているベンチマーク結果は、その効果を明確に示しています。encoding/jsonパッケージの各種ベンチマークで、実行時間が46%から57%も短縮され、MB/sの処理速度も約2倍に向上しています。これは、レース検出器を有効にした状態での開発やテストがより快適になることを意味します。

コアとなるコードの変更箇所

このコミットにおける主要なコード変更は以下の4つのファイルにわたります。

  1. src/cmd/gc/builtin.c:

    • runtimeimport文字列内のracefuncenterのシグネチャが変更されました。
    • - \t\"func @\\\"\\\".racefuncenter()\\n\"
    • + \t\"func @\\\"\\\".racefuncenter(? uintptr)\\n\"
    • これは、コンパイラが認識するracefuncenter関数の型定義を、uintptr型の引数を取るように更新しています。
  2. src/cmd/gc/racewalk.c:

    • racewalk関数内で、racefuncenterを呼び出す際のロジックが変更されました。
    • 変更前は引数なしでmkcall("racefuncenter", T, nil)を呼び出していました。
    • 変更後、nodpcという新しいノードが導入され、これに呼び出し元のPC情報が格納されます。
    • + \tNode *nodpc;
    • + \tnodpc = nod(OXXX, nil, nil);
    • + \t*nodpc = *nodfp;
    • + \tnodpc->type = types[TUINTPTR];
    • + \tnodpc->xoffset = -widthptr;
    • そして、racefuncenterの呼び出しにこのnodpcが引数として追加されます。
    • - \tnd = mkcall(\"racefuncenter\", T, nil);
    • + \tnd = mkcall(\"racefuncenter\", T, nil, nodpc);
    • これにより、コンパイラが生成するコードが、racefuncenterにPCを直接渡すようになります。
  3. src/cmd/gc/runtime.go:

    • Goコンパイラが使用するランタイム関数のGo言語側の宣言が変更されました。
    • - func racefuncenter()
    • + func racefuncenter(uintptr)
    • これは、builtin.cでの変更と整合性を保つためのものです。
  4. src/pkg/runtime/race.c:

    • ランタイムにおけるruntime·racefuncenter関数のC言語実装が変更されました。
    • - runtime·racefuncenter(void)
    • + runtime·racefuncenter(uintptr pc)
    • 引数としてpcを受け取るようになりました。
    • 内部のPC取得ロジックが変更されました。
    • - \tuintptr pc;
    • - \truntime·callers(2, &pc, 1);
    • + \t// If the caller PC is lessstack, use slower runtime·callers
    • + \t// to walk across the stack split to find the real caller.
    • + \tif(pc == (uintptr)runtime·lessstack)
    • + \t\truntime·callers(2, &pc, 1);
    • これにより、pcruntime·lessstackでない限り、runtime·callersの呼び出しがスキップされ、パフォーマンスが向上します。

コアとなるコードの解説

src/cmd/gc/racewalk.c の変更点

@@ -33,6 +33,7 @@ racewalk(Node *fn)
  {
  	int i;
  	Node *nd;
 +	Node *nodpc;
  	char s[1024];
  
  	if(myimportpath) {
@@ -42,10 +43,14 @@ racewalk(Node *fn)
  		}
  	}
  
 -	// TODO(dvyukov): ideally this should be:
 -	// racefuncenter(getreturnaddress())
 -	// because it's much more costly to obtain from runtime library.
 -	nd = mkcall("racefuncenter", T, nil);
 +	// nodpc is the PC of the caller as extracted by
 +	// getcallerpc. We use -widthptr(FP) for x86.
 +	// BUG: this will not work on arm.
 +	nodpc = nod(OXXX, nil, nil);
 +	*nodpc = *nodfp;
 +	nodpc->type = types[TUINTPTR];
 +	nodpc->xoffset = -widthptr;
 +	nd = mkcall("racefuncenter", T, nil, nodpc);
  	fn->enter = list(fn->enter, nd);
  	nd = mkcall("racefuncexit", T, nil);
  	fn->exit = list(fn->exit, nd); // works fine if (!fn->exit)

この部分が、コンパイラがracefuncenterを呼び出す際に、呼び出し元のPCを引数として渡すように変更された核心です。

  • Node *nodpc;: nodpcという新しいNodeポインタが宣言されます。これは、プログラムカウンタを表すAST(抽象構文木)ノードになります。
  • nodpc = nod(OXXX, nil, nil);: 新しいノードが作成されます。OXXXは汎用的なオペレーションタイプです。
  • *nodpc = *nodfp;: ここが重要な部分です。nodfpはフレームポインタ(FP)を表すノードです。この行は、nodpcnodfpと同じ構造を持つようにコピーしています。つまり、nodpcがスタックフレーム内のアドレスを指すように設定されます。
  • nodpc->type = types[TUINTPTR];: nodpcの型がuintptr(符号なしポインタサイズ整数)に設定されます。これはPCがアドレスであることを示します。
  • nodpc->xoffset = -widthptr;: ここで、nodpcが指すアドレスが、フレームポインタから-widthptr(ポインタのサイズ分)だけオフセットされた位置に設定されます。これは、x86アーキテクチャにおいて、呼び出し元のリターンアドレス(PC)が通常、現在の関数のフレームポインタの直前(負のオフセット)に格納されていることを利用しています。これにより、スタックをウォークすることなく、直接リターンアドレスを取得できます。
  • nd = mkcall("racefuncenter", T, nil, nodpc);: 最後に、racefuncenter関数を呼び出すためのノードが作成されます。以前は引数なしでしたが、nodpcが第4引数として追加され、呼び出し元のPCがracefuncenterに直接渡されるようになります。

src/pkg/runtime/race.c の変更点

@@ -70,11 +70,13 @@ runtime·raceread(uintptr addr)
  
  // Called from instrumented code.
  void
 -runtime·racefuncenter(void)
 +runtime·racefuncenter(uintptr pc)
  {
 -	uintptr pc;
 +	// If the caller PC is lessstack, use slower runtime·callers
 +	// to walk across the stack split to find the real caller.
 +	if(pc == (uintptr)runtime·lessstack)
 +		runtime·callers(2, &pc, 1);
  
 -	runtime·callers(2, &pc, 1);
  	m->racecall = true;
  	runtime∕race·FuncEnter(g->goid-1, (void*)pc);
  	m->racecall = false;

この部分が、ランタイム側のracefuncenterの実装変更です。

  • - runtime·racefuncenter(void) から + runtime·racefuncenter(uintptr pc): 関数シグネチャが変更され、uintptr型のpcを引数として受け取るようになりました。このpcは、コンパイラによって計算され、直接渡された呼び出し元のPCです。
  • if(pc == (uintptr)runtime·lessstack): この条件分岐が追加されました。runtime·lessstackは、Goのスタックが動的に拡張される際に、スタックが分割されている状態を示す特別な値です。
    • もし、コンパイラから渡されたpcruntime·lessstackと等しい場合、それはスタックが分割されており、直接渡されたPCが正確でない可能性があることを意味します。この場合、フォールバックとしてruntime·callers(2, &pc, 1);を呼び出し、スタックをウォークして正確なPCを取得します。
    • runtime·callers(2, &pc, 1)2は、現在の関数(runtime·racefuncenter)とその呼び出し元(計測対象の関数)をスキップして、そのさらに呼び出し元のPCを取得することを意味します。1は取得するPCの数です。
  • 通常の場合(pcruntime·lessstackでない場合)、runtime·callersの呼び出しはスキップされます。これにより、高コストなスタックウォークが不要になり、パフォーマンスが大幅に向上します。
  • m->racecall = true; runtime∕race·FuncEnter(g->goid-1, (void*)pc); m->racecall = false;: この部分は変更されていません。FuncEnterは、実際にレース検出器のコアロジックにPCとゴルーチンIDを渡して、関数のエントリを記録する部分です。

関連リンク

参考にした情報源リンク

  • Go Race Detector: https://go.dev/doc/articles/race_detector
  • ThreadSanitizer: https://clang.llvm.org/docs/ThreadSanitizer.html
  • Goのスタック管理 (Stack Split): https://go.dev/doc/articles/go_mem.html (直接的な言及はないが、スタックの動的拡張の背景知識として)
  • Goのコンパイラとランタイムに関する一般的な情報源 (例: Goの公式ドキュメント、Goのソースコード)
  • GoのAST (Abstract Syntax Tree) とコンパイラの内部構造に関する情報 (例: Goのコンパイラソースコードの解説記事など)
  • x86アーキテクチャにおけるスタックフレームとリターンアドレスの配置に関する情報