[インデックス 18521] ファイルの概要
このコミットは、Goコンパイラのcmd/gc
パッケージにおける、panic
で終わる関数におけるライブネス解析の正確性を修正するものです。具体的には、到達不能なRET
(return命令)がライブネス解析に誤った情報を提供し、レジスタ割り当てコードの要件と衝突する問題を解決します。
コミット
- Author: Russ Cox rsc@golang.org
- Date: Thu Feb 13 23:56:53 2014 -0500
- Commit Hash: ab9e8d068aafce0df7edca629af0549b5f54d3c9
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/ab9e8d068aafce0df7edca629af0549b5f54d3c9
元コミット内容
cmd/gc: correct liveness for func ending in panic
The registerization code needs the function to end in a RET,
even if that RET is actually unreachable.
The liveness code needs to avoid such unreachable RETs.
It had a special case for final RET after JMP, but no case
for final RET after UNDEF. Instead of expanding the special
cases, let fixjmp - which already knows what is and is not
reachable definitively - mark the unreachable RET so that
the liveness code can identify it.
TBR=iant
CC=golang-codereviews
https://golang.org/cl/63680043
変更の背景
Goコンパイラには、コードの最適化と正しい実行を保証するために、複数のフェーズが存在します。このコミットが対処している問題は、主に以下の2つのコンパイラフェーズ間の不整合に起因します。
-
レジスタ割り当て (Registerization) コードの要件: Goコンパイラのレジスタ割り当てフェーズは、関数の終端に
RET
(return)命令が存在することを前提としています。これは、たとえそのRET
命令が論理的に到達不可能であっても、レジスタ割り当てのアルゴリズムが正しく機能するために必要です。例えば、関数がpanic
で終了する場合、通常の意味でのRET
には到達しませんが、コンパイラ内部では形式的にRET
命令が生成されることがあります。 -
ライブネス解析 (Liveness Analysis) コードの要件: ライブネス解析は、特定のプログラムポイントで変数が「生きている」(将来使用される可能性がある)かどうかを判断する静的解析の一種です。この情報は、ガベージコレクションやレジスタ割り当ての効率化に不可欠です。ライブネス解析は、到達不能なコードパス、特に到達不能な
RET
命令を無視する必要があります。なぜなら、到達不能なコードは実際のプログラムの動作には影響せず、その中の変数のライブネスを考慮すると誤った結果や非効率なコード生成につながる可能性があるからです。
問題点: 以前のコンパイラでは、JMP
(無条件ジャンプ)命令の後に続く到達不能なRET
命令に対しては特別な処理が施され、ライブネス解析がそれを無視するようになっていました。しかし、panic
のような関数がUNDEF
(未定義の動作、または到達不能なコードを示す内部表現)の後にRET
命令を持つ場合、この特別な処理が適用されず、ライブネス解析が誤って到達不能なRET
を考慮してしまい、結果として誤ったライブネス情報が生成される可能性がありました。これは、レジスタ割り当てコードがRET
を必要とする一方で、ライブネス解析がそれを無視したいという矛盾を生み出していました。
このコミットは、この矛盾を解消し、panic
で終わる関数におけるライブネス解析の正確性を保証することを目的としています。
前提知識の解説
このコミットを理解するためには、以下のGoコンパイラ内部の概念と一般的なコンパイラの最適化技術に関する知識が必要です。
-
Goコンパイラ (
cmd/gc
): Go言語の公式コンパイラです。ソースコードを機械語に変換する過程で、様々な最適化や解析を行います。 -
中間表現 (IR): コンパイラがソースコードを直接機械語に変換するのではなく、まず抽象的な中間表現に変換します。この中間表現に対して様々な最適化や解析が行われます。Goコンパイラも独自のIRを使用しており、
Prog
構造体はそのIRにおける命令(オペレーション)を表します。 -
命令の種類 (
as
フィールド):Prog
構造体にはas
フィールドがあり、これは命令の種類(オペレーションコード)を示します。ARET
: Return命令。関数の終了を示します。AJMP
: Jump命令。無条件ジャンプを示します。UNDEF
: 未定義の動作、または到達不能なコードを示す内部的な命令。panic
のような関数が正常にリターンしない場合に、その後のコードが到達不能であることを示すために使用されることがあります。
-
ライブネス解析 (Liveness Analysis): データフロー解析の一種で、プログラムの各ポイントにおいて、どの変数が「ライブ」(その値が将来の計算で使用される可能性がある)であるかを決定します。ライブでない変数は、そのメモリ領域を再利用したり、レジスタから追い出したりすることができます。これは、レジスタ割り当てやガベージコレクションの効率化に不可欠です。
-
レジスタ割り当て (Register Allocation): プログラムの実行速度を向上させるために、頻繁に使用される変数をCPUのレジスタに割り当てるプロセスです。レジスタはメモリよりもはるかに高速にアクセスできます。レジスタ割り当てはライブネス解析の結果に大きく依存します。
-
基本ブロック (Basic Block): 制御フローグラフ (CFG) の構成要素で、単一のエントリポイントと単一のエグジットポイントを持つ命令のシーケンスです。基本ブロック内の命令は、常に連続して実行されます。
-
fixjmp
関数: Goコンパイラのpopt.c
ファイルに存在する関数で、ジャンプ命令の最適化や到達不能なコードの削除(デッドコードエリミネーション)を担当します。この関数は、コードの到達可能性について正確な知識を持っています。 -
p->mode = -1
:Prog
構造体のmode
フィールドは、命令の特定の属性や状態を示すために使用されます。このコミットでは、mode = -1
を「到達不能なRET
命令」を示すマーカーとして使用しています。
技術的詳細
このコミットの核心は、Goコンパイラの異なるフェーズ(レジスタ割り当てとライブネス解析)が、関数の終端にあるRET
命令に対して異なる要件を持っているという問題の解決策にあります。
問題の再確認:
- レジスタ割り当て: 関数が
RET
で終わることを期待する。これは、panic
のように実際にはリターンしない関数でも同様。 - ライブネス解析: 到達不能な
RET
命令を無視したい。到達不能なコードはライブネス情報に影響を与えないべき。
以前のバージョンでは、JMP
命令の後に続く到達不能なRET
命令に対しては、ライブネス解析がそれを無視するための特別なロジックがplive.c
に存在しました。しかし、panic
で終わる関数は、コンパイラ内部でUNDEF
命令の後に到達不能なRET
命令が続くようなコードパターンを生成することがありました。このUNDEF
のケースは既存の特別なロジックではカバーされていませんでした。
解決策:
このコミットは、特別なケースをplive.c
にさらに追加するのではなく、fixjmp
関数(popt.c
に存在)の能力を活用するという、より汎用的なアプローチを採用しています。
-
fixjmp
による到達不能なRET
のマーキング:fixjmp
関数は、デッドコードエリミネーションの一部として、到達不能なコードパスを特定する能力を持っています。このコミットでは、fixjmp
が関数の最後に存在する到達不能なARET
命令(特にpanic
などで到達不能になるもの)を検出した場合、そのARET
命令のmode
フィールドを-1
に設定するように変更されました。 このARET
命令は、レジスタ割り当ての要件を満たすために削除されずに残されますが、mode = -1
というマーカーが付与されます。コミットメッセージにあるように、この
ARET
を残す理由は、レジスタアロケータが「すべてのライブコードは、すべてのRET
命令から前方向のリンクをたどることで走査できる」という仮定に基づいているためです。もし関数の終端で無限ループが発生し、その後に続くRET
が削除されてしまうと、この仮定が崩れてしまう可能性があります。そのため、RET
は残しつつ、ライブネス解析からは無視されるようにマークされます。 -
ライブネス解析 (
plive.c
) でのマーカーの利用:plive.c
のnewcfg
関数(制御フローグラフを構築する部分)は、基本ブロックを構築する際に、命令を走査します。この変更により、newcfg
はp->link->as == ARET && p->link->mode == -1
という条件をチェックするようになりました。 もしこの条件が真であれば、それはfixjmp
によって到達不能とマークされたARET
命令であることを意味します。この場合、ライブネス解析はそこで基本ブロックの構築を停止し、到達不能なRET
を制御フローグラフに含めないようにします。これにより、ライブネス解析が誤った情報に基づいて動作するのを防ぎます。
このアプローチにより、fixjmp
が到達可能性に関する唯一の真実の源となり、ライブネス解析は単にその情報に従うだけでよくなります。これにより、コンパイラの異なる部分間の整合性が向上し、将来的に同様の問題が発生するのを防ぐことができます。
コアとなるコードの変更箇所
このコミットでは、主に以下の3つのファイルが変更されています。
-
src/cmd/gc/plive.c
: ライブネス解析に関連するコード。newcfg
関数内で、到達不能なRET
命令をスキップするための条件が変更されました。- 変更前:
if (p->as == AJMP && p->link->as == ARET && p->link->opt == nil)
(JMPの後のRETのみを考慮) - 変更後:
if(p->link != nil && p->link->as == ARET && p->link->mode == -1)
(modeが-1のRETを考慮)
-
src/cmd/gc/popt.c
: 最適化パス、特にfixjmp
関数に関連するコード。fixjmp
関数内で、関数の最後の到達不能なARET
命令にmode = -1
を設定するロジックが追加されました。if(p->link == P && p->as == ARET && last && last->as != ARET)
という条件で、関数の最後のARET
であり、かつそのARET
がデッドコードである場合に、p->mode = -1
が設定されます。
-
test/live.go
: ライブネス解析のテストケース。f10()
という新しいテスト関数が追加されました。この関数はpanic(1)
で終了し、以前のコンパイラではライブネス解析が混乱する原因となっていました。このテストケースの追加により、修正が正しく機能することを確認できます。
コアとなるコードの解説
src/cmd/gc/plive.c
の変更
--- a/src/cmd/gc/plive.c
+++ b/src/cmd/gc/plive.c
@@ -519,12 +519,10 @@ newcfg(Prog *firstp)
break;
bb->last = p;
- // Pattern match an unconditional branch followed by a
- // dead return instruction. This avoids a creating
+ // Stop before an unreachable RET, to avoid creating
// unreachable control flow nodes.
- if(p->link != nil && p->link->link == nil)
- if (p->as == AJMP && p->link->as == ARET && p->link->opt == nil)
- break;
+ if(p->link != nil && p->link->as == ARET && p->link->mode == -1)
+ break;
// Collect basic blocks with selectgo calls.
if(isselectgocall(p))
この変更は、newcfg
関数内で制御フローグラフを構築する際のロジックを修正しています。
以前は、AJMP
(無条件ジャンプ)の後に続くデッドなARET
命令を特別に処理していました。これは、JMP
によってARET
が到達不能になるケースをカバーしていました。
新しいコードでは、p->link->as == ARET && p->link->mode == -1
という条件に変わっています。これは、fixjmp
によってmode
が-1
に設定されたARET
命令(すなわち、到達不能とマークされたARET
)を検出した場合に、そこで基本ブロックの構築を停止することを意味します。これにより、JMP
だけでなく、UNDEF
など他の理由で到達不能になったARET
も適切に無視されるようになります。
src/cmd/gc/popt.c
の変更
--- a/src/cmd/gc/popt.c
+++ b/src/cmd/gc/popt.c
@@ -146,7 +146,13 @@ fixjmp(Prog *firstp)
if(p->opt == dead) {
if(p->link == P && p->as == ARET && last && last->as != ARET) {
// This is the final ARET, and the code so far doesn't have one.
- // Let it stay.
+ // Let it stay. The register allocator assumes that all live code in
+ // the function can be traversed by starting at all the RET instructions
+ // and following predecessor links. If we remove the final RET,
+ // this assumption will not hold in the case of an infinite loop
+ // at the end of a function.
+ // Keep the RET but mark it dead for the liveness analysis.
+ p->mode = -1;
} else {
if(debug['R'] && debug['v'])
print("del %P\n", p);
この変更は、fixjmp
関数内でデッドコードエリミネーションを行う際のロジックを修正しています。
p->opt == dead
は、現在の命令p
が到達不能(デッドコード)であることを示します。
if(p->link == P && p->as == ARET && last && last->as != ARET)
という条件は、以下の状況を特定します。
p->link == P
:p
が命令リストの最後である(次の命令がない)。p->as == ARET
:p
がARET
命令である。last && last->as != ARET
: 直前の命令がARET
ではない(つまり、このARET
が関数の最後のARET
である可能性が高い)。
この条件が満たされ、かつp
がデッドコードである場合、このARET
はレジスタ割り当ての要件のために削除されずに残されます。しかし、ライブネス解析がこれを無視できるように、p->mode = -1
が設定されます。コメントにもあるように、これはレジスタアロケータの仮定(すべてのライブコードはRET
命令からたどれる)を維持しつつ、ライブネス解析からはこのRET
を「デッド」として扱うためのものです。
test/live.go
の変更
--- a/test/live.go
+++ b/test/live.go
@@ -113,3 +113,11 @@ func f9() bool {
x := i9
return x != 99
}
+
+// liveness formerly confused by UNDEF followed by RET,
+// leading to "live at entry to f10: ~r1" (unnamed result).
+
+func f10() string {
+ panic(1)
+}
+
このファイルには、f10()
という新しいテスト関数が追加されています。
この関数はpanic(1)
を呼び出して終了します。panic
は通常の関数のリターンパスをたどらないため、コンパイラ内部ではその後に到達不能なRET
命令が生成される可能性があります。
このテストケースは、以前のコンパイラがこのようなシナリオでライブネス解析を誤り、「live at entry to f10: ~r1
」(f10
の入り口で~r1
がライブである)という誤った結果を出力していた問題を再現し、修正が正しく適用されたことを検証するために使用されます。~r1
は、Goコンパイラが生成する内部的なレジスタ名または一時変数名であり、この場合は関数の戻り値に関連するものです。panic
する関数では戻り値は存在しないため、これがライブであると報告されるのは誤りです。
関連リンク
- Go Code Review: https://golang.org/cl/63680043
参考にした情報源リンク
- Go言語のコンパイラに関する一般的な情報 (Go compiler internals, liveness analysis, register allocation):
- Goの公式ドキュメントやブログ記事
- Goのソースコード(特に
src/cmd/gc
ディレクトリ) - コンパイラ理論に関する一般的な教科書やオンラインリソース
- このコミットの具体的な内容を理解するために、コミットメッセージとコードの差分を詳細に分析しました。
- Goコンパイラの内部構造に関する情報は、Goのソースコード自体が最も信頼できる情報源です。