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

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

このコミットは、Goコンパイラのバックエンドにおける最適化に関するもので、主に以下のファイルに影響を与えています。

  • src/cmd/6g/peep.c: AMD64 (64-bit) アーキテクチャ向けGoコンパイラのピーフホール最適化パスを定義するファイル。
  • src/cmd/8g/gsubr.c: x86 (32-bit) アーキテクチャ向けGoコンパイラの汎用サブルーチンを定義するファイル。
  • src/cmd/8g/peep.c: x86 (32-bit) アーキテクチャ向けGoコンパイラのピーフホール最適化パスを定義するファイル。
  • test/{bugs/bug440.go => fixedbugs/bug440_32.go}: 既存のバグテストケースをリネームし、32-bit環境での修正を検証するためのテストファイル。
  • test/fixedbugs/bug440_64.go: 新規追加されたテストファイルで、64-bit環境での同様のバグ修正を検証します。

コミット

このコミットは、Goコンパイラ (cmd/6g および cmd/8g) において、可能な場合に短い整数(バイト B およびワード W サイズ)に対する算術演算をより大きな整数サイズ(ロング L またはクアッド Q サイズ)に昇格させることで、最適化を改善します。これにより、リンカによる部分レジスタ演算のシミュレーションを回避し、より効率的なコード生成を目指します。特に、レジスタへの MOV 命令や算術演算において、キャリーフラグの依存関係がない場合にこの最適化を適用します。

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

https://github.com/golang/go/commit/8f3c2055bd17c08d82f1ea56299802e476788307

元コミット内容

commit 8f3c2055bd17c08d82f1ea56299802e476788307
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date:   Sat Sep 1 16:40:54 2012 +0200

    cmd/6g, cmd/8g: eliminate short integer arithmetic when possible.
    
    Fixes #3909.
    Fixes #3910.
    
    R=rsc, nigeltao
    CC=golang-dev
    https://golang.org/cl/6442114

変更の背景

この変更は、Goコンパイラが特定の条件下でバイト(8ビット)やワード(16ビット)サイズの整数演算を扱う際に発生していたバグ(Issue #3909 および #3910)を修正するために導入されました。

具体的には、Goコンパイラの最適化フェーズにおいて、部分レジスタ(例: 32ビットレジスタの低位8ビットや16ビット)に対する操作が正しく扱われないことがありました。x86/x64アーキテクチャでは、AX, BX, CX, DX といった汎用レジスタの低位バイトやワードに直接アクセスする命令が存在しますが、それ以外のレジスタ(例: RBP, RSP, RSI, RDI など)の部分レジスタに対する操作は、リンカによってフルサイズのレジスタ操作にシミュレートされることが一般的です。このシミュレーションはオーバーヘッドを伴い、またコンパイラの最適化器がレジスタの状態を正確に追跡するのを困難にすることがありました。

特に、splitContractIndex のような関数で、16ビットや32ビットの移動が最適化器によって誤解され、結果として不正な値が生成されるという問題が報告されていました。このコミットは、このような「短い整数演算」を、安全かつ効率的な場合に、より大きなサイズのレジスタ演算に昇格させることで、これらのバグを修正し、生成されるコードの品質を向上させることを目的としています。

前提知識の解説

Goコンパイラ (cmd/6g, cmd/8g)

Go言語のコンパイラは、ターゲットアーキテクチャごとに異なるバックエンドを持っています。

  • cmd/6g: AMD64 (x86-64) アーキテクチャ向けのGoコンパイラです。64ビットレジスタと命令セットを利用します。
  • cmd/8g: x86 (IA-32) アーキテクチャ向けのGoコンパイラです。32ビットレジスタと命令セットを利用します。

これらのコンパイラは、Goのソースコードを中間表現に変換し、最終的にターゲットアーキテクチャの機械語に変換します。この過程で、様々な最適化パスが適用されます。

ピーフホール最適化 (Peephole Optimization)

ピーフホール最適化は、コンパイラの最適化手法の一つです。生成された機械語コードの小さな「窓(peephole)」を覗き込み、非効率な命令シーケンスをより効率的な同等のシーケンスに置き換えることで機能します。例えば、MOV AX, AX のような冗長な命令を削除したり、複数の命令を単一のより強力な命令に置き換えたりします。このコミットで変更されている peep.c ファイルは、このピーフホール最適化のロジックを含んでいます。

x86/x64アーキテクチャにおけるレジスタとデータサイズ

x86/x64アーキテクチャのCPUは、異なるサイズのデータを扱うためのレジスタと命令を提供します。

  • バイト (Byte, B): 8ビット。例: AL, BL, CL, DL (32/64ビットレジスタの低位8ビット)。
  • ワード (Word, W): 16ビット。例: AX, BX, CX, DX (32/64ビットレジスタの低位16ビット)。
  • ロング (Long, L): 32ビット。例: EAX, EBX, ECX, EDX (32ビットレジスタ全体、または64ビットレジスタの低位32ビット)。
  • クアッド (Quad, Q): 64ビット。例: RAX, RBX, RCX, RDX (64ビットレジスタ全体)。

特定のレジスタ(AX, BX, CX, DX)は、その部分レジスタ(AL, AH, AX など)に直接アクセスする命令を持っています。しかし、他のレジスタ(RBP, RSP, RSI, RDI など)の部分レジスタへのアクセスは、通常、フルサイズのレジスタを操作し、その結果をマスクするなどしてシミュレートされます。このシミュレーションは、コードの複雑性を増し、パフォーマンスを低下させる可能性があります。

キャリーフラグ (Carry Flag, CF)

CPUのステータスレジスタ(FLAGSレジスタ)には、様々なフラグが含まれています。キャリーフラグ (CF) は、算術演算(加算、減算など)の結果がレジスタの最大値を超えた(オーバーフローした)場合にセットされるフラグです。このフラグは、多倍長整数演算や、ADC (Add with Carry) や SBB (Subtract with Borrow) のような命令で前の演算のキャリー/ボローを引き継ぐ際に非常に重要です。

このコミットでは、短い整数演算をより大きなサイズの演算に昇格させる際に、キャリーフラグが後続の命令で必要とされているかどうかを慎重にチェックします。もしキャリーフラグが必要な場合、演算のサイズを変更するとキャリーフラグの挙動が変わる可能性があり、プログラムの正しさを損なうため、最適化は適用されません。

ゼロ拡張 (Zero Extension) と符号拡張 (Sign Extension)

小さいサイズの値を大きいサイズのレジスタに移動する際、新しい上位ビットをどのように埋めるかという問題があります。

  • ゼロ拡張 (Zero Extension): 上位ビットをすべて0で埋めます。符号なし整数や、符号付き整数でも値が正の場合に用いられます。
  • 符号拡張 (Sign Extension): 最上位ビット(符号ビット)の値を上位ビットにコピーして埋めます。符号付き整数で、値の符号を保持したい場合に用いられます。

このコミットでは、特にメモリからレジスタへの MOV 命令において、ゼロ拡張命令 (AMOVBQZX, AMOVWQZX など) を利用して、バイトやワードの値をロングやクアッドレジスタに移動する際に、上位ビットが確実にゼロで埋められるようにしています。これにより、部分レジスタへの書き込みが、レジスタ全体を破壊することなく、期待通りの結果をもたらすようになります。

技術的詳細

このコミットの主要な変更点は、cmd/6gcmd/8g の両方のコンパイラに elimshortmov という新しいピーフホール最適化関数を導入したことです。この関数は、バイト (B) やワード (W) サイズの算術演算やデータ移動命令を、より大きなレジスタサイズ(6g ではクアッド Q8g ではロング L)の命令に変換することを試みます。

elimshortmov 関数の目的とロジック

elimshortmov 関数は、レジスタをターゲットとする短い整数演算命令を走査し、以下の条件に基づいて変換を行います。

  1. 単項演算子 (INC, DEC, NEG, NOT) の変換:

    • AINCB, AINCW -> AINCQ (6g) / AINCL (8g)
    • ADECB, ADECW -> ADECQ (6g) / ADECL (8g)
    • ANEGB, ANEGW -> ANEGQ (6g) / ANEGL (8g)
    • ANOTB, ANOTW -> ANOTQ (6g) / ANOTL (8g) これらの変換は、ターゲットレジスタが部分レジスタではなく、フルサイズのレジスタとして扱われることを保証します。
  2. 二項演算子 (MOV, ADD, SUB, MUL, IMUL, AND, OR, XOR, SHL) の変換:

    • ソースオペランドがレジスタまたは定数である場合、これらの演算はより大きなサイズの命令に変換されます。
      • AMOVB, AMOVW -> AMOVQ (6g) / AMOVL (8g)
      • AADDB, AADDW -> AADDQ (6g) / AADDL (8g)
      • ASUBB, ASUBW -> ASUBQ (6g) / ASUBL (8g)
      • AMULB, AMULW -> AMULQ (6g) / AMULL (8g)
      • AIMULB, AIMULW -> AIMULQ (6g) / AIMULL (8g)
      • AANDB, AANDW -> AANDQ (6g) / AANDL (8g)
      • AORB, AORW -> AORQ (6g) / AORL (8g)
      • AXORB, AXORW -> AXORQ (6g) / AXORL (8g)
      • ASHLB, ASHLW -> ASHLQ (6g) / ASHLL (8g)
    • キャリーフラグの考慮: ADDSUB のような演算では、キャリーフラグがセットされる可能性があります。elimshortmov は、needc(p->link) を呼び出して、現在の命令の直後の命令がキャリーフラグを必要としているかどうかをチェックします。もし必要としている場合、演算のサイズを変更するとキャリーフラグの挙動が変わる可能性があるため、この最適化は適用されません。これにより、多倍長演算などのキャリーフラグに依存するコードの正しさが保証されます。
  3. ゼロ拡張命令への変換:

    • ソースオペランドがレジスタや定数ではない場合(通常はメモリからのロード)、MOV 命令は明示的なゼロ拡張命令に変換されます。
      • AMOVB -> AMOVBQZX (6g) / AMOVBLZX (8g)
      • AMOVW -> AMOVWQZX (6g) / AMOVWLZX (8g)
    • これにより、バイトやワードの値をより大きなレジスタにロードする際に、上位ビットが確実にゼロで埋められ、レジスタの残りの部分が不定な値で汚染されるのを防ぎます。

needc 関数の役割

needc 関数は、与えられた Prog (プログラム命令) がキャリーフラグを必要とするかどうかを判断します。このコミットでは、needc 関数に ADDB, ADDW, SUBB, SUBW, RCRB, RCRW などの短い整数演算命令が追加されました。これにより、elimshortmov がこれらの命令の後にキャリーフラグが使用される可能性を正確に検出し、安全に最適化を適用できるようになります。

gsubr.c の変更

src/cmd/8g/gsubr.c では、AORB 命令が AORL に変更され、その前に gins(AMOVL, ncon(0), &cx); が追加されています。これは、特定のビット操作において、バイトサイズの OR 演算ではなく、ロング(32ビット)サイズの OR 演算を使用するように変更されたことを示しています。cx レジスタをゼロクリアしてから ASETCC (Set byte on condition code) の結果を cx に格納し、それを axOR するというシーケンスは、より堅牢で予測可能な動作を保証します。特に、ASETCC はバイトレジスタに結果を書き込むため、その後の OR 演算がフルサイズのレジスタに対して行われるようにすることで、部分レジスタの曖昧さを排除しています。

最適化のメリット

  • リンカのシミュレーション回避: x86/x64アーキテクチャでは、AX, BX, CX, DX 以外のレジスタの部分レジスタへの書き込みは、リンカによってフルサイズのレジスタ操作にシミュレートされることがあります。このシミュレーションはオーバーヘッドを伴い、またコンパイラの最適化器がレジスタの状態を正確に追跡するのを困難にすることがありました。短い整数演算をより大きなサイズの演算に昇格させることで、このシミュレーションを回避し、より直接的で効率的な機械語コードを生成できます。
  • コードの正確性の向上: 部分レジスタの曖昧な挙動に起因するバグ(Issue #3909, #3910)を修正し、コンパイラが生成するコードの信頼性を高めます。
  • パフォーマンスの向上: シミュレーションのオーバーヘッドを削減し、より効率的な命令を使用することで、生成されるバイナリの実行速度が向上する可能性があります。

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

src/cmd/6g/peep.c および src/cmd/8g/peep.c

両方の peep.c ファイルに elimshortmov 関数が追加され、peep 関数内で呼び出されています。

// src/cmd/6g/peep.c (抜粋)
 static void elimshortmov(Reg *r); // 新しいプロトタイプ宣言

 // ...

 peep(void)
 {
     // ...
     // byte, word arithmetic elimination.
     elimshortmov(r);
     // ...
 }

 // movb elimination.
 // movb is simulated by the linker
 // when a register other than ax, bx, cx, dx
 // is used, so rewrite to other instructions
 // when possible.  a movb into a register
 // can smash the entire 32-bit register without
 // causing any trouble.
 static void
 elimshortmov(Reg *r)
 {
     Prog *p;

     for(r=firstr; r!=R; r=r->link) {
         p = r->prog;
         if(regtyp(&p->to)) { // ターゲットがレジスタの場合
             switch(p->as) {
             case AINCB:
             case AINCW:
                 p->as = AINCQ; // 6gではQに昇格
                 break;
             // ... 他の単項演算子も同様に昇格
             }
             if(regtyp(&p->from) || p->from.type == D_CONST) { // ソースがレジスタまたは定数の場合
                 // move or artihmetic into partial register.
                 // from another register or constant can be movl.
                 // we don't switch to 64-bit arithmetic if it can
                 // change how the carry bit is set (and the carry bit is needed).
                 switch(p->as) {
                 case AMOVB:
                 case AMOVW:
                     p->as = AMOVQ; // 6gではQに昇格
                     break;
                 case AADDB:
                 case AADDW:
                     if(!needc(p->link)) // キャリーフラグが不要な場合のみ
                         p->as = AADDQ; // 6gではQに昇格
                     break;
                 // ... 他の二項演算子も同様に昇格
                 }
             } else { // ソースがレジスタや定数ではない場合(メモリなど)
                 // explicit zero extension
                 switch(p->as) {
                 case AMOVB:
                     p->as = AMOVBQZX; // 6gではゼロ拡張MOV命令に変換
                     break;
                 case AMOVW:
                     p->as = AMOVWQZX; // 6gではゼロ拡張MOV命令に変換
                     break;
                 }
             }
         }
     }
 }

src/cmd/8g/peep.celimshortmov も同様のロジックですが、命令の昇格先が L (ロング、32ビット) になります。

src/cmd/6g/peep.c および src/cmd/8g/peep.cneedc 関数

needc 関数に新しい命令が追加され、キャリーフラグの依存関係をより正確に検出できるようになりました。

// src/cmd/6g/peep.c (抜粋)
 static int
 needc(Prog *p)
 {
     switch(p->as) {
     // ... 既存の命令
     case ARCRB: // 新規追加
     case ARCRW: // 新規追加
     case AADDB: // 新規追加
     case AADDW: // 新規追加
     case ASUBB: // 新規追加
     case ASUBW: // 新規追加
     // ...
         return 1;
     }
     return 0;
 }

src/cmd/8g/gsubr.c

gmove 関数内で、AORB 命令が AORL に変更され、その前に cx レジスタをゼロクリアする命令が追加されました。

// src/cmd/8g/gsubr.c (抜粋)
 gmove(Node *f, Node *t)
 {
     // ...
     p1 = gins(ASHRL, ncon(1), &ax);
     p1->from.index = D_DX;    // double-width shift DX -> AX
     p1->from.scale = 0;
     gins(AMOVL, ncon(0), &cx); // cxをゼロクリア
     gins(ASETCC, N, &cx);
     gins(AORL, &cx, &ax); // AORBからAORLに変更
     gins(ASHRL, ncon(1), &dx);
     // ...
 }

コアとなるコードの解説

elimshortmov の詳細な動作

elimshortmov 関数は、コンパイラが生成したアセンブリ命令のリスト (Reg 構造体のリンクリスト) を走査します。各命令 p について、そのターゲットオペランド (p->to) がレジスタであるかどうかを regtyp(&p->to) で確認します。

  1. 単項演算の昇格: AINCB (Increment Byte), AINCW (Increment Word) などの命令は、それぞれ AINCQ (Increment Quad) や AINCL (Increment Long) に直接変換されます。これは、これらの命令がレジスタ全体に影響を与えても問題ないためです。例えば、INC ALINC EAX に変換されても、AL の値は正しくインクリメントされ、上位ビットは影響を受けません(または、影響を受けても後続の命令でその上位ビットが使用されない限り問題ありません)。

  2. 二項演算の昇格(ソースがレジスタまたは定数): AMOVB (Move Byte), AADDB (Add Byte) などの命令で、ソースオペランド (p->from) がレジスタまたは定数である場合、これらの命令はより大きなサイズの命令に昇格されます (AMOVQ/AMOVL, AADDQ/AADDL など)。

    • MOV 命令: MOV 命令は、部分レジスタへの書き込みがレジスタ全体を破壊する可能性があるため、フルサイズの MOV に変換されます。例えば、MOV AL, BLMOV EAX, EBX に変換され、AL の値が BL にコピーされるだけでなく、EAX の残りのビットも EBX の対応するビットで上書きされます。しかし、この最適化は、ターゲットレジスタの残りのビットが後続の命令で利用されない場合にのみ安全です。
    • 算術演算 (ADD, SUB など): これらの演算は、キャリーフラグの依存関係がない場合にのみ昇格されます。if(!needc(p->link)) のチェックが重要です。もし次の命令がキャリーフラグを必要とする場合(例: ADC 命令)、バイトやワード演算をロングやクアッド演算に昇格させると、キャリーフラグの計算方法が変わる可能性があり、プログラムの正しさを損なうため、最適化は適用されません。
  3. ゼロ拡張 MOV 命令への変換(ソースがメモリなど): AMOVBAMOVW 命令で、ソースオペランドがレジスタや定数ではない場合(例: メモリからのロード)、明示的なゼロ拡張命令 (AMOVBQZX, AMOVWQZX, AMOVBLZX, AMOVWLZX) に変換されます。これは、メモリからバイトやワードを読み込んでレジスタに格納する際に、レジスタの残りの上位ビットが確実にゼロで埋められるようにするためです。これにより、レジスタに不定な値が残ることを防ぎ、予測可能な動作を保証します。

needc 関数の拡張

needc 関数は、特定の命令が実行された後にキャリーフラグが重要になるかどうかを判断します。このコミットでは、AADDB, AADDW, ASUBB, ASUBW, ARCRB, ARCRW といったバイト/ワードサイズの算術・シフト命令が needc のチェック対象に追加されました。これにより、elimshortmov がこれらの命令の後にキャリーフラグが使用される可能性を正確に検出し、安全に最適化を適用できるようになります。

gsubr.cAORB から AORL への変更

src/cmd/8g/gsubr.c の変更は、特定のビット操作のコンテキストで行われています。元のコードでは、ASETCC (Set byte on condition code) 命令の結果(バイトサイズ)を cx レジスタに格納し、その後に AORB (OR Byte) 命令で cxax の低位バイトを論理和していました。

新しいコードでは、gins(AMOVL, ncon(0), &cx); を追加して cx レジスタ全体をゼロクリアしてから ASETCC を実行し、その後 AORL (OR Long) 命令で cxax のロング(32ビット)部分を論理和しています。

  • ASETCC は結果をバイトレジスタに書き込みますが、cx をゼロクリアすることで、cx の上位ビットが確実にゼロになります。
  • その後の AORL は、ax レジスタの低位32ビットと cx レジスタの低位32ビットを論理和します。これにより、バイトサイズの OR ではなく、フルサイズの OR が行われ、部分レジスタの曖昧さが解消されます。これは、特に32ビット環境でのレジスタの整合性を保つ上で重要です。

これらの変更は、Goコンパイラが生成するアセンブリコードの品質と正確性を向上させ、特に部分レジスタの扱いに関する既存のバグを修正することを目的としています。

関連リンク

参考にした情報源リンク

  • Go言語のソースコード (特に src/cmd/6g/peep.c, src/cmd/8g/peep.c, src/cmd/8g/gsubr.c)
  • x86/x64 アセンブリ言語の知識
  • コンパイラ最適化(ピーフホール最適化)に関する一般的な知識
  • Go言語のIssueトラッカー (Issue #3909, #3910)
  • Gerrit Code Review (CL 6442114)