[インデックス 14563] ファイルの概要
このコミットは、GoコンパイラのARMアーキテクチャ向けバックエンドであるcmd/5g
における、固定長配列のnilチェックに関するバグ修正です。特に、ARMv5アーキテクチャで発生するアラインメント違反によるトラップ(例外)を回避するために、メモリロード命令をAMOVW
(ワード単位の移動)からMOVB
(バイト単位の移動)に変更しています。
コミット
commit 54e8d504e835127e9fcb71d4b5a9acd6f78f4482
Author: Dave Cheney <dave@cheney.net>
Date: Thu Dec 6 08:01:33 2012 +1100
cmd/5g: use MOVB for fixed array nil check
Fixes #4396.
For fixed arrays larger than the unmapped page, agenr would general a nil check by loading the first word of the array. However there is no requirement for the first element of a byte array to be word aligned, so this check causes a trap on ARMv5 hardware (ARMv6 since relaxed that restriction, but it probably still comes at a cost).
Switching the check to MOVB ensures alignment is not an issue. This check is only invoked in a few places in the code where large fixed arrays are embedded into structs, compress/lzw is the biggest offender, and switching to MOVB has no observable performance penalty.
Thanks to Rémy and Daniel Morsing for helping me debug this on IRC last night.
R=remyoudompheng, minux.ma, rsc
CC=golang-dev
https://golang.org/cl/6854063
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/54e8d504e835127e9fcb71d4b5a9acd6f78f4482
元コミット内容
このコミットは、GoコンパイラのARMアーキテクチャ向けコードジェネレータ(cmd/5g
)において、固定長配列のnilチェック処理を改善するものです。具体的には、大きな固定長配列が構造体に埋め込まれている場合に、コンパイラが生成するnilチェックコードが、ARMv5プロセッサでアラインメント違反によるトラップを引き起こす問題を修正します。
従来のnilチェックでは、配列の先頭ワード(4バイト)をロードすることでチェックを行っていました。しかし、バイト配列の先頭要素はワードアラインメントされている必要がないため、非アラインメントアドレスからのワードロードがARMv5では例外を発生させていました。このコミットでは、このチェックをMOVB
(バイト単位のロード)命令に切り替えることで、アラインメントの問題を解消しています。この変更は、compress/lzw
パッケージのような、大きな固定長配列を多用する箇所に影響しますが、パフォーマンスへの影響は無視できるレベルであるとされています。
変更の背景
この変更は、Go言語のIssue #4396「cmd/5g: use MOVB for fixed array nil check」を修正するために行われました。
問題の根本原因は、Goコンパイラ(cmd/5g
)が、特定の条件下で生成するコードにありました。大きな固定長配列(特にバイト配列)が構造体内に埋め込まれている場合、コンパイラは、その配列へのアクセスが有効なメモリ領域を指していることを確認するための「nilチェック」を生成します。このチェックは、配列の先頭アドレスから最初のワード(4バイト)を読み込むことで行われていました。
しかし、ARMv5アーキテクチャでは、ワード(4バイト)単位のメモリロード命令(AMOVW
など)は、そのアドレスが4の倍数である(ワードアラインメントされている)ことを厳密に要求します。バイト配列の場合、その先頭アドレスが必ずしもワードアラインメントされているとは限りません。例えば、構造体内の前のフィールドが3バイトで終わる場合、次のバイト配列の先頭アドレスは4の倍数にならない可能性があります。このような非アラインメントアドレスからワードをロードしようとすると、ARMv5プロセッサはハードウェアトラップ(例外)を発生させ、プログラムがクラッシュしていました。
ARMv6以降のアーキテクチャでは、非アラインメントアクセスに対する制限が緩和され、多くの場合、ハードウェアが自動的に処理しますが、それでもパフォーマンスコストが発生する可能性があります。このバグは、特にARMv5環境でGoプログラムを実行する際に顕著な問題となっていました。
前提知識の解説
-
Goコンパイラと
cmd/5g
:- Go言語のコンパイラは、ソースコードを機械語に変換するツールです。Goはクロスコンパイルをサポートしており、異なるアーキテクチャ向けのコンパイラが提供されています。
cmd/5g
は、GoコンパイラのARMアーキテクチャ(32ビット)向けのバックエンドを指します。Goのツールチェーンでは、各アーキテクチャに数字と文字の組み合わせで名前が付けられており、5
はARMを、g
はGoコンパイラを示します。cgen.c
は、Goコンパイラのコード生成(code generation)部分を担うC言語のソースファイルです。Goのコンパイラの一部はC言語で書かれています。
-
メモリのアラインメント(Memory Alignment):
- メモリのアラインメントとは、データがメモリ上で特定の境界(アドレスが特定の数値の倍数)に配置されることを指します。例えば、4バイトのデータ(ワード)は、アドレスが4の倍数である場所に配置されるのが一般的です。
- 多くのCPUアーキテクチャでは、効率的なメモリアクセスや、特定の命令の実行のために、データのアラインメントを要求します。非アラインメントアドレスからのアクセスは、パフォーマンスの低下、またはハードウェアトラップ(例外)を引き起こす可能性があります。
-
ARMアーキテクチャと非アラインメントアクセス:
- ARM(Advanced RISC Machine)は、モバイルデバイスなどで広く使われているCPUアーキテクチャです。
- ARMv5: この世代のARMプロセッサは、ワード(4バイト)やハーフワード(2バイト)のロード/ストア命令に対して、厳密なアラインメントを要求します。非アラインメントアドレスからのアクセスは、通常、アラインメントフォルト(Alignment Fault)と呼ばれるハードウェア例外を発生させます。
- ARMv6以降: ARMv6以降のアーキテクチャでは、非アラインメントアクセスに対するハードウェアサポートが導入され、多くの場合、例外を発生させることなく処理できるようになりました。しかし、それでもアラインメントされたアクセスに比べてパフォーマンスが低下する可能性があります。
-
nilチェックと「unmapped page」:
- Go言語では、ポインタが
nil
であるかどうかをチェックすることは非常に重要です。配列は値型ですが、構造体の一部として埋め込まれた場合、その配列が配置されているメモリ領域が有効であるかどうかのチェックが必要になることがあります。 - 「unmapped page」(マップされていないページ)は、オペレーティングシステムによってプロセスのアドレス空間に割り当てられていないメモリページを指します。通常、アドレス空間の最下位アドレス(例: 0x0)付近は、意図しない
nil
ポインタの逆参照を防ぐために、意図的にマップされていない領域として設定されます。 - コンパイラが生成するnilチェックは、しばしば、アクセスしようとしているアドレスがこの「unmapped page」の境界を越えていないかを確認するために、そのアドレスから少量のデータを読み込もうとします。もしアドレスが不正な場合、この読み込みがトラップを引き起こし、プログラムの異常終了を検出します。
- Go言語では、ポインタが
-
アセンブリ命令
AMOVW
とMOVB
:AMOVW
: ARMアセンブリにおける「Move Word」命令に相当します。これは4バイト(1ワード)のデータをメモリからレジスタへ、またはレジスタからメモリへ移動させる命令です。ARMv5では、この命令が実行される際、アドレスがワードアラインメントされている必要があります。MOVB
: ARMアセンブリにおける「Move Byte」命令に相当します。これは1バイトのデータをメモリからレジスタへ、またはレジスタからメモリへ移動させる命令です。バイト単位のアクセスは、通常、アラインメントの制約が緩やかであり、任意のアドレスから1バイトを読み込むことができます。
技術的詳細
このコミットの技術的詳細を理解するためには、Goコンパイラのコード生成ロジックとARMアーキテクチャのメモリモデルを深く掘り下げる必要があります。
Goコンパイラのcmd/5g
(ARM向け)では、agen
、igen
、agenr
といった関数がアドレス生成やコード生成の主要な部分を担っています。これらの関数内で、固定長配列(特にuint8
、つまりバイト配列)が構造体に埋め込まれている場合のnilチェックロジックが問題となっていました。
問題のコードは、配列のベースアドレスが有効であることを確認するために、そのアドレスから最初のワード(4バイト)を読み込もうとしていました。これは、Goのランタイムが、不正なポインタアクセス(特にnil
ポインタの逆参照)を検出するために、アドレス空間の最下位部分をマップされていないページとして設定していることを利用した一般的な手法です。もし配列のベースアドレスがこのマップされていないページ内にある場合、そこから読み込もうとするとハードウェアトラップが発生し、ランタイムがエラーを捕捉できます。
しかし、このnilチェックがAMOVW
(Move Word)命令を使用して実装されていたことが問題でした。AMOVW
は、ARMv5プロセッサでは、アクセスするメモリのアドレスが4バイト境界にアラインメントされていることを厳密に要求します。Goの構造体レイアウトでは、バイト配列が必ずしもワードアラインメントされたアドレスに配置されるとは限りません。例えば、以下のような構造体を考えます。
type MyStruct struct {
Header uint16 // 2 bytes
Padding uint8 // 1 byte
Data [4096]uint8 // 4096 bytes
}
この場合、Data
フィールドの開始アドレスは、Header
とPadding
の合計3バイトの後に続くため、4の倍数にならない可能性があります。もしMyStruct
のインスタンスがメモリ上に配置され、Data
フィールドが非アラインメントアドレスから始まる場合、コンパイラが生成したAMOVW
命令によるnilチェックは、ARMv5上でアラインメントフォルトを引き起こし、プログラムがクラッシュします。
このコミットでは、この問題を解決するために、AMOVW
命令をMOVB
(Move Byte)命令に置き換えています。MOVB
命令は、1バイト単位のロードであるため、アラインメントの制約がありません。任意のアドレスから1バイトを読み込むことが可能です。これにより、バイト配列の先頭がワードアラインメントされていなくても、nilチェックが安全に実行できるようになります。
変更は、src/cmd/5g/cgen.c
内のagen
、igen
、agenr
関数に集中しています。これらの関数は、Goの型システムとメモリレイアウトに基づいて、アセンブリコードを生成する役割を担っています。具体的には、nl->type->type->width >= unmappedzero
という条件(配列のサイズが「unmapped page」のサイズ以上であるかどうかのチェック)が真である場合に、nilチェックのコードが生成されます。この部分で、従来のAMOVW
を用いたワードロードから、MOVB
を用いたバイトロードへと変更されています。
この修正は、compress/lzw
パッケージのように、大きな固定長バイト配列を多用するGoの標準ライブラリにも影響を与えます。コミットメッセージによると、この変更によるパフォーマンス上のペナルティは観測されていないとのことです。これは、nilチェックが頻繁に実行されるパスではないこと、および1バイトロードと4バイトロードのパフォーマンス差がこの特定のコンテキストでは無視できるレベルであるためと考えられます。
コアとなるコードの変更箇所
変更は主に src/cmd/5g/cgen.c
ファイルにあります。
--- a/src/cmd/5g/cgen.c
+++ b/src/cmd/5g/cgen.c
@@ -559,7 +559,6 @@ agen(Node *n, Node *res)
{
Node *nl;
Node n1, n2, n3;
- Prog *p1;
int r;
if(debug['g']) {
@@ -704,10 +703,13 @@ agen(Node *n, Node *res)
if(nl->type->type->width >= unmappedzero) {
regalloc(&n1, types[tptr], N);
gmove(res, &n1);
- p1 = gins(AMOVW, &n1, &n1);
- p1->from.type = D_OREG;
- p1->from.from.offset = 0;
+ regalloc(&n2, types[TUINT8], &n1); // 新しいレジスタn2をuint8型で確保
+ n1.op = OINDREG; // n1を間接レジスタ参照として設定
+ n1.type = types[TUINT8]; // n1の型をuint8に設定
+ n1.xoffset = 0; // オフセットを0に設定
+ gmove(&n1, &n2); // n1(アドレス0のバイト)をn2に移動
regfree(&n1);
+ regfree(&n2); // n2を解放
}
nodconst(&n1, types[TINT32], n->xoffset);
regalloc(&n2, n1.type, N);
@@ -737,8 +739,7 @@ ret:
void
igen(Node *n, Node *a, Node *res)
{
- Node n1;
- Prog *p1;
+ Node n1, n2; // n2が追加
int r;
if(debug['g']) {
@@ -785,10 +786,13 @@ igen(Node *n, Node *a, Node *res)
if(n->left->type->type->width >= unmappedzero) {
regalloc(&n1, types[tptr], N);
gmove(a, &n1);
- p1 = gins(AMOVW, &n1, &n1);
- p1->from.type = D_OREG;
- p1->from.offset = 0;
+ regalloc(&n2, types[TUINT8], &n1); // 新しいレジスタn2をuint8型で確保
+ n1.op = OINDREG; // n1を間接レジスタ参照として設定
+ n1.type = types[TUINT8]; // n1の型をuint8に設定
+ n1.xoffset = 0; // オフセットを0に設定
+ gmove(&n1, &n2); // n1(アドレス0のバイト)をn2に移動
regfree(&n1);
+ regfree(&n2); // n2を解放
}
}
a->op = OINDREG;
@@ -957,10 +961,13 @@ agenr(Node *n, Node *a, Node *res)
if(isfixedarray(nl->type) && nl->type->width >= unmappedzero) {
regalloc(&n4, types[tptr], N);
gmove(&n3, &n4);
- p1 = gins(AMOVW, &n4, &n4);
- p1->from.type = D_OREG;
- p1->from.offset = 0;
+ regalloc(&tmp, types[TUINT8], &n4); // 新しいレジスタtmpをuint8型で確保
+ n4.op = OINDREG; // n4を間接レジスタ参照として設定
+ n4.type = types[TUINT8]; // n4の型をuint8に設定
+ n4.xoffset = 0; // オフセットを0に設定
+ gmove(&n4, &tmp); // n4(アドレス0のバイト)をtmpに移動
regfree(&n4);
+ regfree(&tmp); // tmpを解放
}
// constant index
また、この修正を検証するための新しいテストケースが追加されています。
test/fixedbugs/issue4396a.go
test/fixedbugs/issue4396b.go
これらのテストは、大きなバイト配列を含む構造体を定義し、その配列の要素にアクセスすることで、非アラインメントアクセスがトラップを引き起こすシナリオを再現しようとしています。特に、echo "4" > /proc/cpu/alignment
というコマンドで、LinuxカーネルのCPUアラインメントチェックを厳しく設定することで、この問題をより確実に再現できるように指示されています。
コアとなるコードの解説
変更の核心は、gins(AMOVW, &n1, &n1)
のような行が削除され、代わりにバイト単位のロードを行う一連の命令に置き換えられている点です。
変更前(問題のあるコードのパターン):
p1 = gins(AMOVW, &n1, &n1);
p1->from.type = D_OREG;
p1->from.offset = 0;
これは、n1
が指すアドレス(ベースアドレス)からオフセット0の場所にあるワード(4バイト)を読み込み、その結果をn1
に格納するアセンブリ命令(AMOVW
)を生成しようとしていました。D_OREG
はレジスタ間接参照を示し、offset = 0
はベースアドレスからのオフセットが0であることを意味します。ARMv5では、このワードロードが非アラインメントアドレスに対して実行されるとトラップが発生します。
変更後(修正されたコードのパターン):
regalloc(&n2, types[TUINT8], &n1); // 新しいレジスタn2をuint8型で確保
n1.op = OINDREG; // n1を間接レジスタ参照として設定
n1.type = types[TUINT8]; // n1の型をuint8に設定
n1.xoffset = 0; // オフセットを0に設定
gmove(&n1, &n2); // n1(アドレス0のバイト)をn2に移動
regfree(&n1);
regfree(&n2); // n2を解放
この新しいコードは、以下のステップでバイト単位のロードを実行します。
regalloc(&n2, types[TUINT8], &n1);
:n2
という新しいNode
(コンパイラ内部のデータ構造)を、uint8
型(1バイト)のデータを保持するためのレジスタとして割り当てます。n1
は、ロード元のアドレスを保持するレジスタです。n1.op = OINDREG;
:n1
を「間接レジスタ参照」として設定します。これは、n1
が直接の値ではなく、メモリ上のアドレスを指すことを意味します。n1.type = types[TUINT8];
:n1
が指すメモリ位置から読み込むデータの型をuint8
(1バイト)に設定します。n1.xoffset = 0;
:n1
が指すベースアドレスからのオフセットを0に設定します。つまり、ベースアドレスそのものから読み込みます。gmove(&n1, &n2);
: これは、n1
が指すメモリ位置(オフセット0のバイト)からデータを読み込み、それをn2
に移動させるアセンブリ命令(実質的にはMOVB
命令)を生成します。regfree(&n1);
とregfree(&n2);
: 使用したレジスタを解放します。
この変更により、コンパイラは、固定長配列のnilチェックを行う際に、ワードアラインメントの制約を受けないバイト単位のロード命令を生成するようになります。これにより、ARMv5のような厳密なアラインメント要件を持つアーキテクチャでも、非アラインメントアドレスからのアクセスが安全に行えるようになり、プログラムのクラッシュが回避されます。
関連リンク
- Go Issue #4396: cmd/5g: use MOVB for fixed array nil check
- Go CL 6854063: cmd/5g: use MOVB for fixed array nil check
参考にした情報源リンク
- ARM Architecture Reference Manual (特にメモリモデルとアラインメントに関する章)
- Go Compiler Source Code (特に
src/cmd/5g/cgen.c
の周辺コード) - Go Language Specification (特に配列と構造体のメモリレイアウトに関する記述)
- Linuxカーネルの
/proc/cpu/alignment
に関するドキュメントや記事 (ARMの非アラインメントアクセス挙動の制御について) - GoのIssueトラッカーやメーリングリストでの議論(Issue #4396に関連するもの)
- Dave Cheney氏のブログやGoに関する技術記事(GoコンパイラやARMアーキテクチャに関する知見)
- ARM Alignment - Stack Overflow
- Unaligned memory access - Wikipedia