[インデックス 17635] ファイルの概要
このコミットは、Goコンパイラにおける冗長なnilポインタチェックを排除することを目的としています。特に、構造体のフィールドアクセス(&x.Field
)におけるnilチェックの約75%を削減し、生成されるバイナリのサイズと実行時のオーバーヘッドを削減します。Go 1.2のリリースが近い時期であったため、シンプルかつ効果的で安全なアプローチが採用されました。
コミット
commit aa0439ba65bfa762c26a3dbb3f0c0474267b5582
Author: Russ Cox <rsc@golang.org>
Date: Tue Sep 17 16:54:22 2013 -0400
cmd/gc: eliminate redundant &x.Field nil checks
This eliminates ~75% of the nil checks being emitted,
on all architectures. We can do better, but we need
a bit more general support from the compiler, and
I don't want to do that so close to Go 1.2.
What's here is simple but effective and safe.
A few small code generation cleanups were required
to make the analysis consistent on all systems about
which nil checks are omitted, at least in the test.
Fixes #6019.
R=ken2
CC=golang-dev
https://golang.org/cl/13334052
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/aa0439ba65bfa762c26a3dbb3f0c0474267b5582
元コミット内容
Goコンパイラ(cmd/gc
)において、冗長な&x.Field
形式のnilポインタチェックを排除します。これにより、全てのアーキテクチャで生成されるnilチェックの約75%が削減されます。この変更は、Go 1.2リリース直前であったため、シンプルで効果的かつ安全な方法に限定されています。nilチェックの省略に関する分析を全てのシステムで一貫させるために、いくつかの小さなコード生成のクリーンアップも行われました。
変更の背景
Go言語では、ポインタのデリファレンス(*p
)や構造体・配列のフィールドアクセス(p.Field
, p[index]
)を行う際に、ポインタp
がnil
でないことを保証するためのnilポインタチェックが自動的に挿入されます。これは、Goプログラムの安全性と堅牢性を高めるための重要な機能です。しかし、コンパイラが生成するnilチェックの中には、既にそのポインタがnilでないことが保証されている文脈で挿入される冗長なものが存在しました。
例えば、x.Field1.Field2
のようなアクセスでは、x
がnilでないことが確認された後、x.Field1
がnilでないことも確認されます。もしx
がnilでないことが既にチェック済みであれば、x.Field1
へのアクセス時に再度x
のnilチェックを行うのは冗長です。このような冗長なチェックは、生成されるバイナリのサイズを増やし、実行時のオーバーヘッド(特にチェックが頻繁に行われるループ内など)を発生させる可能性があります。
このコミットは、このような冗長なnilチェックを特定し、排除することで、コンパイラが生成するコードの効率を向上させることを目的としています。コミットメッセージにある「Fixes #6019」は、この問題がGoのIssueトラッカーで報告されていたことを示唆しています。
前提知識の解説
Goコンパイラの構造
Goコンパイラ(cmd/gc
)は、複数のフェーズに分かれて動作します。
- フロントエンド: ソースコードの字句解析、構文解析、型チェック、AST(抽象構文木)の構築を行います。
- ミドルエンド(最適化): ASTに対して様々な最適化を適用します。このコミットで導入されるnilチェックの最適化もこのフェーズの一部です。
- バックエンド(コード生成): 最適化されたASTから、ターゲットアーキテクチャの機械語コードを生成します。
nilポインタチェック
Go言語では、ポインタがnil
である場合にデリファレンスを行うと、ランタイムパニックが発生します。これを防ぐため、コンパイラはポインタのデリファレンスやフィールドアクセスが行われる前に、そのポインタがnil
でないことを確認するコード(nilチェック)を自動的に挿入します。このチェックは、通常、ポインタの値を0と比較し、0であればパニックを発生させるような命令として実装されます。
ACCHECKNIL
命令
Goコンパイラの内部では、nilチェックが必要な箇所にACCHECKNIL
という擬似命令が挿入されます。これは、最終的な機械語命令に変換される前の中間表現です。この最適化の目的は、このACCHECKNIL
命令の冗長なインスタンスを削除することです。
制御フローグラフ (Control Flow Graph, CFG)
コンパイラの最適化フェーズでは、プログラムの実行パスを表現するために制御フローグラフがよく用いられます。CFGは、プログラムの各命令やブロックをノードとし、命令の実行順序や条件分岐、ループなどをエッジで表現します。このコミットで導入される最適化は、CFG上を辿ることで、あるnilチェックが実行される前に、同じポインタに対するnilチェックが既に実行されているかどうかを判断します。
Peephole最適化
Peephole最適化は、コンパイラのバックエンドで行われる最適化の一種で、生成された機械語命令の小さなシーケンス(peephole、覗き穴)を検査し、より効率的な同等のシーケンスに置き換えるものです。このコミットでは、peep.c
ファイル群に変更が加えられており、これはPeephole最適化に関連する部分です。
技術的詳細
このコミットの主要な変更は、Goコンパイラの最適化フェーズにnilopt
という新しい関数を導入し、冗長なnilポインタチェックを削除することです。
nilopt
関数の概要
nilopt
関数は、生成された中間コード(Prog
構造体のリスト)を走査し、ACCHECKNIL
命令を特定します。そして、各ACCHECKNIL
命令について、そのチェックが冗長であるかどうかを判断し、冗長であれば削除します。
最適化の基本的な考え方は以下の通りです。
- 前方探索 (
nilwalkfwd
): あるACCHECKNIL
命令から前方(プログラムの実行順序)に探索し、そのポインタがデリファレンスされる(つまり、暗黙的にnilチェックが行われる)箇所や、ポインタの値が変更される箇所を探します。もし、明示的なACCHECKNIL
命令の前に、そのポインタがデリファレンスされるような命令(例:*x
、x.Field
)があり、かつそのオフセットが小さい場合(通常、最初のページに収まる範囲)、その暗黙的なチェックに任せることができるため、明示的なACCHECKNIL
を削除します。 - 後方探索 (
nilwalkback
): あるACCHECKNIL
命令から後方(プログラムの逆実行順序)に探索し、同じポインタに対する別のACCHECKNIL
命令や、ポインタの値が初期化される箇所を探します。もし、同じポインタに対する別のACCHECKNIL
命令が既に実行されているパスが存在する場合、現在のACCHECKNIL
は冗長であると判断し、削除します。
nilopt
の具体的なロジック
nilopt
関数は、まず制御フローグラフ(CFG)を構築します。その後、ACCHECKNIL
命令を一つずつ処理します。
- スタック変数に対するチェックの削除:
stackaddr(&p->from)
が真の場合、つまりチェック対象のポインタがスタック上のアドレスを指している場合、そのnilチェックは無条件に削除されます。これは、スタック上のアドレスは通常nilになることがないためです。 nilwalkfwd
による前方最適化:nilwalkfwd(r)
を呼び出し、現在のACCHECKNIL
命令r
から前方(実行順序)に探索します。smallindir
関数を用いて、ポインタが小さなオフセットで間接参照されているかどうかをチェックします。もし、p->from
またはp->to
がrcheck->f.prog->from
(チェック対象のポインタ)を小さなオフセットで間接参照している場合、その間接参照自体がnilチェックの役割を果たすため、現在のACCHECKNIL
は冗長と判断され、rcheck->kill = 1
が設定されます。- 他の
ACCHECKNIL
命令が見つかった場合、またはポインタの値が変更される(RightWrite
)ような命令が見つかった場合、探索を停止します。
nilwalkback
による後方最適化:nilwalkfwd
で削除されなかったACCHECKNIL
命令について、nilwalkback(r)
を呼び出し、後方(逆実行順序)に探索します。- 同じポインタに対する別の
ACCHECKNIL
命令が見つかった場合、現在のACCHECKNIL
は冗長と判断され、rcheck->kill = 1
が設定されます。 - ポインタの値が初期化される(
RightWrite
)ような命令が見つかった場合、そのパスではnilチェックがまだ行われていない可能性があるため、現在のACCHECKNIL
は保持されます。
- 同じポインタに対する別の
- 命令の削除: 最終的に
r->kill
が1
に設定されたACCHECKNIL
命令は、excise(&r->f)
によって中間コードから削除されます。
補助関数の役割
sameaddr(Addr *a, Addr *v)
: 2つのアドレスが同じポインタを指しているかどうかを判断します。regtyp(Addr *a)
: アドレスがレジスタタイプであるかどうかを判断します。stackaddr(Addr *a)
: アドレスがスタックポインタ(REGSP
)を指しているかどうかを判断します。smallindir(Addr *a, Addr *reg)
: アドレスa
がレジスタreg
をベースとした小さなオフセット(0から4095バイト)の間接参照であるかどうかを判断します。これは、メモリハードウェアがnilポインタによるアクセスを検出する際に、通常最初のページ(4KB)がマップされていないことを利用した最適化のヒントとなります。
アーキテクチャ固有の変更
このコミットは、src/cmd/5g
(ARM), src/cmd/6g
(x86), src/cmd/8g
(x86-64) の各アーキテクチャ固有のコード生成およびPeephole最適化ファイルにも変更を加えています。これは、nilチェックの削除が全てのアーキテクチャで一貫して行われるように、コード生成の挙動を微調整するためです。例えば、ggen.c
ではwarnl
メッセージが変更され、peep.c
ではsameaddr
, stackaddr
, smallindir
といった補助関数が各アーキテクチャのニーズに合わせて実装されています。
test/nilptr3.go
このコミットでは、test/nilptr3.go
という新しいテストファイルが追加されています。このテストは、様々なシナリオ(グローバル変数、ローカル変数、配列、構造体、大きな構造体、空の構造体など)におけるnilチェックの生成と削除の挙動を検証します。// ERROR "generated nil check"
や// ERROR "removed repeated nil check"
といったコメントは、コンパイラが特定の箇所でnilチェックを生成するか、または削除するかを期待していることを示しています。これにより、最適化が正しく機能していることを確認できます。
コアとなるコードの変更箇所
src/cmd/gc/popt.c
:nilopt
関数が追加され、冗長なnilチェックを削除する主要なロジックが実装されました。nilwalkback
とnilwalkfwd
という補助関数もここで定義されています。src/cmd/gc/popt.h
:nilopt
関数のプロトタイプおよび、sameaddr
,smallindir
,stackaddr
といった補助関数のプロトタイプが追加されました。src/cmd/gc/pgen.c
:compile
関数内で、レジスタ最適化(regopt
)の後にnilopt(ptxt)
が呼び出されるようになりました。これにより、nilチェックの最適化がコンパイルパイプラインに組み込まれます。また、cgen_checknil
関数も変更され、nilチェックの対象となるノードの型チェックが厳密になりました。src/cmd/gc/gen.c
:cgen_discard
関数にOIND
(間接参照)の場合の処理が追加され、cgen_checknil
が呼び出されるようになりました。src/cmd/gc/walk.c
:OIND
の場合の冗長なnilチェック生成ロジックが削除されました。src/cmd/5g/peep.c
,src/cmd/6g/peep.c
,src/cmd/8g/peep.c
: 各アーキテクチャのPeephole最適化ファイルに、sameaddr
,stackaddr
,smallindir
といった補助関数が追加されました。これらの関数は、nilopt
がアーキテクチャ固有のコード生成の挙動を考慮してnilチェックを削除するために使用されます。src/cmd/5g/ggen.c
,src/cmd/6g/ggen.c
,src/cmd/8g/ggen.c
:expandchecks
関数内のwarnl
メッセージが変更されました。src/cmd/8g/cgen.c
:OINDEX
(配列インデックス)の処理が改善され、固定サイズ配列のインデックスアクセスにおいて、定数オフセットがアドレス計算に組み込まれるようになりました。これにより、一部のケースでnilチェックの必要性が減少する可能性があります。test/nilptr3.go
: 新しいテストファイルが追加され、nilチェックの最適化が正しく機能していることを検証します。
コアとなるコードの解説
src/cmd/gc/popt.c
void
nilopt(Prog *firstp)
{
// ... (制御フローグラフの初期化など)
for(r = (NilFlow*)g->start; r != nil; r = (NilFlow*)r->f.link) {
p = r->f.prog;
if(p->as != ACHECKNIL || !regtyp(&p->from))
continue; // ACHECKNIL命令でないか、レジスタタイプでない場合はスキップ
ncheck++; // nilチェックの総数をカウント
if(stackaddr(&p->from)) {
// スタックアドレスに対するnilチェックは無条件で削除
if(debug_checknil && p->lineno > 1)
warnl(p->lineno, "removed nil check of SP address");
r->kill = 1; // 削除フラグを設定
continue;
}
nilwalkfwd(r); // 前方探索
if(r->kill) {
// 前方探索で削除が決定した場合
if(debug_checknil && p->lineno > 1)
warnl(p->lineno, "removed nil check before indirect");
continue;
}
nilwalkback(r); // 後方探索
if(r->kill) {
// 後方探索で削除が決定した場合
if(debug_checknil && p->lineno > 1)
warnl(p->lineno, "removed repeated nil check");
continue;
}
}
// killフラグが設定されたACCHECKNIL命令を実際に削除
for(r = (NilFlow*)g->start; r != nil; r = (NilFlow*)r->f.link) {
if(r->kill) {
nkill++;
excise(&r->f); // 命令を削除
}
}
// ... (統計情報の出力など)
}
static void
nilwalkback(NilFlow *rcheck)
{
Prog *p;
ProgInfo info;
NilFlow *r;
// rcheckから後方(逆実行順序)に探索
for(r = rcheck; r != nil; r = (NilFlow*)uniqp(&r->f)) {
p = r->f.prog;
proginfo(&info, p);
// ポインタの値が書き換えられている場合、そのパスではnilチェックがまだ行われていない可能性がある
if((info.flags & RightWrite) && sameaddr(&p->to, &rcheck->f.prog->from)) {
// チェック対象の値の初期化が見つかった場合、このチェックは削除できない
return;
}
// 別のACCHECKNIL命令が見つかり、同じポインタをチェックしている場合
if(r != rcheck && p->as == ACHECKNIL && sameaddr(&p->from, &rcheck->f.prog->from)) {
rcheck->kill = 1; // 現在のチェックは冗長なので削除
return;
}
}
}
static void
nilwalkfwd(NilFlow *rcheck)
{
NilFlow *r;
Prog *p;
ProgInfo info;
// rcheckから前方(実行順序)に探索
for(r = (NilFlow*)uniqs(&rcheck->f); r != nil; r = (NilFlow*)uniqs(&r->f)) {
p = r->f.prog;
proginfo(&info, p);
// ポインタが小さなオフセットで間接参照されている場合(暗黙的なnilチェック)
if((info.flags & LeftRead) && smallindir(&p->from, &rcheck->f.prog->from)) {
rcheck->kill = 1; // 現在のチェックは冗長なので削除
return;
}
if((info.flags & (RightRead|RightWrite)) && smallindir(&p->to, &rcheck->f.prog->from)) {
rcheck->kill = 1; // 現在のチェックは冗長なので削除
return;
}
// 別のnilチェックが発生した場合、探索を停止
if(p->as == ACHECKNIL)
return;
// 値が失われた場合(ポインタが書き換えられた場合)、探索を停止
if((info.flags & RightWrite) && sameaddr(&p->to, &rcheck->f.prog->from))
return;
// メモリへの書き込みが発生した場合、探索を停止
if((info.flags & RightWrite) && !regtyp(&p->to))
return;
}
}
src/cmd/gc/popt.h
void nilopt(Prog*); // nilチェック最適化関数の宣言
int sameaddr(Addr*, Addr*); // 2つのアドレスが同じかどうかをチェック
int smallindir(Addr*, Addr*); // 小さなオフセットでの間接参照かどうかをチェック
int stackaddr(Addr*); // スタックアドレスかどうかをチェック
これらの関数は、Goコンパイラのバックエンドにおける中間表現(Prog
)を操作し、nilチェックの冗長性を判断するための重要なロジックを提供します。特にnilopt
は、制御フロー分析に基づいて、プログラムの安全性とパフォーマンスのバランスを取りながら、不要なnilチェックを効率的に削除します。
関連リンク
- Go言語の公式ドキュメント: https://golang.org/doc/
- Goコンパイラのソースコード: https://github.com/golang/go/tree/master/src/cmd/compile
参考にした情報源リンク
- Go言語のソースコード(上記GitHubリンク)
- Go言語のコンパイラに関する一般的な知識
- コンパイラ最適化に関する一般的な知識(制御フロー分析、Peephole最適化など)