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

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

このコミットは、Goコンパイラのcmd/6g(AMD64アーキテクチャ向けコンパイラ)におけるメモリ操作の最適化に関するものです。具体的には、sgen関数(構造体代入やメモリコピーを生成)とclearfat関数(メモリをゼロクリア)が生成するコードにおいて、アラインメントされていないロード/ストア命令を活用することで、memmovememsetに似た操作のパフォーマンスを向上させています。特に、サイズが8バイトの倍数ではない型を操作する際に、末尾のバイト処理が高速化されます。

コミット

commit 2b93c4dd06932ee9a5770353c75956910ace1c9b
Author: Rémy Oudompheng <oudomphe@phare.normalesup.org>
Date:   Fri Feb 7 23:58:21 2014 +0100

    cmd/6g: faster memmove/memset-like code using unaligned load/stores.
    
    This changes makes sgen and clearfat use unaligned instructions for
    the trailing bytes, like the runtime memmove does, resulting in faster
    code when manipulating types whose size is not a multiple of 8.
    
    LGTM=khr
    R=khr, iant, rsc
    CC=golang-codereviews
    https://golang.org/cl/51740044

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

https://github.com/golang/go/commit/2b93c4dd06932ee9a5770353c75956910ace1c9b

元コミット内容

このコミットは、Goコンパイラ(cmd/6g)が生成するメモリコピー(sgen)およびメモリクリア(clearfat)のコードを最適化します。具体的には、これらの操作において、残りのバイト(末尾のバイト)を処理する際に、アラインメントされていないロード/ストア命令を使用するように変更します。これにより、Goランタイムのmemmove関数が既に採用している最適化手法と同様に、サイズが8バイトの倍数ではない型を操作する際のコードが高速化されます。

変更の背景

コンピュータのメモリは、通常、特定のバイト境界(アラインメント)に沿ってアクセスされると最も効率的です。例えば、64ビット(8バイト)のデータを読み書きする場合、そのデータが8バイトの倍数のアドレスから始まる場合にCPUは最も高速に処理できます。しかし、構造体や配列のサイズが常にアラインメントの倍数であるとは限りません。例えば、13バイトの構造体をコピーする場合、最初の8バイトは64ビット命令で効率的にコピーできますが、残りの5バイトはどのように処理するかが問題になります。

従来のコンパイラは、このような「末尾のバイト」を処理する際に、1バイトずつ(MOVB命令など)処理するか、あるいはアラインメントを厳密に守るために非効率なコードを生成することがありました。これは、特に多数の小さなメモリコピーやクリア操作が発生するGoのような言語では、パフォーマンスのボトルネックとなる可能性がありました。

Goランタイムのmemmove関数は、既にこのような末尾のバイト処理において、アラインメントされていないロード/ストア命令を賢く利用して効率を高めていました。このコミットの背景には、コンパイラが生成するコードも同様の最適化を取り入れ、ランタイム関数と同等の効率を達成することで、Goプログラム全体のパフォーマンスを向上させるという目的があります。特に、構造体や配列のサイズが8バイトの倍数でない場合に顕著な改善が期待されます。

前提知識の解説

このコミットを理解するためには、以下の概念が役立ちます。

  1. メモリのアラインメント (Memory Alignment): CPUは、メモリからデータを読み書きする際に、特定のバイト境界にデータが配置されていることを期待します。例えば、64ビットCPUは通常、8バイトの倍数のアドレスから64ビットデータを読み込むと最も効率的です。この境界を「アラインメント」と呼びます。データがアラインメントされていないアドレスに配置されている場合(「アンアラインメント」または「非アラインメント」)、CPUはデータを複数回に分けて読み込む必要があったり、特別な処理が必要になったりするため、パフォーマンスが低下する可能性があります。

  2. ロード/ストア命令 (Load/Store Instructions): CPUがメモリからデータをレジスタに読み込む操作を「ロード」、レジスタからメモリにデータを書き込む操作を「ストア」と呼びます。これらの命令には、読み書きするデータのサイズ(バイト、ワード、ダブルワード、クアッドワードなど)に応じたバリエーションがあります。

    • MOVB: 1バイトを移動 (Move Byte)
    • MOVL: 4バイトを移動 (Move Long)
    • MOVQ: 8バイトを移動 (Move Quadword) 「アラインメントされていないロード/ストア」とは、データのアラインメント境界をまたいで、一度に複数のバイトを読み書きできる命令を指します。多くの現代のCPUは、アラインメントされていないアクセスをハードウェアでサポートしており、ソフトウェアで1バイトずつ処理するよりも高速です。
  3. memmovememcpy:

    • memcpy: メモリブロックをコピーする関数です。ソースとデスティネーションのメモリ領域が重なっていないことを前提とします。重なっている場合に使うと未定義の動作を引き起こす可能性があります。
    • memmove: メモリブロックをコピーする関数です。ソースとデスティネーションのメモリ領域が重なっていても正しく動作することを保証します。重なっている場合は、コピー元が破壊される前にコピー先の領域にデータが移動されるように、適切な方向(前方または後方)からコピーを行います。 このコミットでは「memmove-like」という表現が使われており、重なりを考慮したコピーの文脈で最適化が行われていることを示唆しています。
  4. memset: メモリブロックを指定されたバイト値で埋める関数です。例えば、メモリ領域をゼロで埋める(ゼロクリアする)際によく使用されます。

  5. Goコンパイラ (cmd/6g): Go言語のコンパイラは、ターゲットアーキテクチャごとに異なるバックエンドを持っています。cmd/6gは、AMD64(x86-64)アーキテクチャ向けのGoコンパイラのバックエンドを指します。このコンパイラは、GoのソースコードをAMD64の機械語に変換する役割を担っています。

  6. sgenclearfat: これらはGoコンパイラの内部関数です。

    • sgen: 構造体の代入や、一般的なメモリブロックのコピー操作に対応する機械語を生成する役割を担います。
    • clearfat: 「fat object」(例えば、大きな構造体や配列)のメモリ領域をゼロで埋める(クリアする)機械語を生成する役割を担います。

技術的詳細

このコミットの核心は、sgenclearfat関数が、メモリコピーやクリア操作の「末尾のバイト」を処理する方法を変更した点にあります。

sgen関数の変更点

sgen関数は、メモリコピーの際に、まず8バイト単位(MOVQ)で可能な限りコピーし、残りのバイトを処理します。変更前は、残りのバイトが4バイト以上であれば4バイト単位(MOVL)でコピーし、さらに残ったバイトを1バイト単位(MOVB)でコピーしていました。

変更後は、残りのバイトcの処理ロジックがより洗練されています。

  • 特殊ケースの処理: w < 4(全体のサイズが4バイト未満)、c <= 1(残りが1バイト以下)、またはodst < osrc && osrc < odst+w(メモリ領域が重なっており、memmoveのセマンティクス上、後方からコピーする必要がある場合)といった特定の条件下では、引き続き1バイト単位のMOVBで処理します。これは、これらのケースではアラインメントされていない大きなロード/ストアが適切でないか、オーバーヘッドが大きい可能性があるためです。
  • 4バイト単位のアンアラインド処理: w < 8(全体のサイズが8バイト未満)またはc <= 4(残りが4バイト以下)の場合、TINT32(32ビット/4バイト)の型を使用して、アラインメントされていない4バイトのロード/ストアを行います。
    • nodsi.xoffset = 0; noddi.xoffset = 0; gmove(&nodsi, &noddi);:これは、残りのブロックの先頭から4バイトをコピーします。
    • nodsi.xoffset = c-4; noddi.xoffset = c-4; gmove(&nodsi, &noddi);:これは、残りのブロックの末尾から4バイトをコピーします。 この2つの操作を組み合わせることで、例えば5バイト残っている場合でも、先頭から4バイト、末尾から4バイト(この場合、中央の3バイトが重複してコピーされる)という形で、効率的に処理できます。これにより、1バイトずつコピーするよりも高速になります。
  • 8バイト単位のアンアラインド処理: それ以外の場合(全体のサイズが8バイト以上で、残りが4バイトより大きい場合)、TINT64(64ビット/8バイト)の型を使用して、アラインメントされていない8バイトのロード/ストアを行います。
    • nodsi.xoffset = c-8; noddi.xoffset = c-8; gmove(&nodsi, &noddi);:これは、残りのブロックの末尾から8バイトをコピーします。 これにより、例えば13バイト残っている場合、最初の8バイトは通常のMOVQで処理され、残りの5バイトは末尾から8バイトのアンアラインドMOVQで処理されます(この場合、3バイトが重複してコピーされる)。

clearfat関数の変更点

clearfat関数は、メモリ領域をゼロで埋める際に、まず8バイト単位(MOVQ)でゼロを書き込み、残りのバイトを処理します。変更前は、残りのバイトが4バイト以上であればREP STOSB(ALレジスタの値をDIが指すメモリにCX回書き込む)を使用して4バイト単位でゼロを書き込み、さらに残ったバイトを1バイト単位でSTOSBで書き込んでいました。

変更後は、残りのバイトcの処理ロジックが以下のように変更されています。

  • 8バイト単位のアンアラインド処理: w >= 8(全体のサイズが8バイト以上)かつc >= 4(残りが4バイト以上)の場合、AMOVQ(MOVQ - 8バイト移動)命令を使用して、末尾から8バイトをゼロで埋めます。p->to.offset = c-8;がこのオフセットを指定します。
  • 4バイト単位のアンアラインド処理: c >= 4(残りが4バイト以上)の場合、AMOVL(MOVL - 4バイト移動)命令を使用して、末尾から4バイトをゼロで埋めます。p->to.offset = c-4;がこのオフセットを指定します。
    • コードにはif(c > 4)のブロックが二重に存在するように見えますが、これは残りのバイトが4バイトより大きい場合に、先頭から4バイトと末尾から4バイトの2つの4バイト書き込みを行う意図があると考えられます。
  • 1バイト単位の処理: それ以外の場合、引き続き1バイト単位のSTOSBでゼロを埋めます。

これらの変更により、sgenclearfatは、Goランタイムのmemmoveが既に行っているように、アラインメントされていないアクセスを効率的に利用して、末尾のバイト処理を高速化します。これにより、特に構造体や配列のサイズが8バイトの倍数ではない場合に、コンパイラが生成するコードのパフォーマンスが向上します。

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

src/cmd/6g/cgen.c (sgen関数)

--- a/src/cmd/6g/cgen.c
+++ b/src/cmd/6g/cgen.c
@@ -1436,14 +1436,33 @@ sgen(Node *n, Node *ns, int64 w)
 			gins(AMOVSQ, N, N);	// MOVQ *(SI)+,*(DI)+
 			q--;
 		}
-
-		if(c >= 4) {
-			gins(AMOVSL, N, N);	// MOVL *(SI)+,*(DI)+
-			c -= 4;
-		}
-		while(c > 0) {
-			gins(AMOVSB, N, N);	// MOVB *(SI)+,*(DI)+
-			c--;
+		// copy the remaining c bytes
+		if(w < 4 || c <= 1 || (odst < osrc && osrc < odst+w)) {
+			while(c > 0) {
+				gins(AMOVSB, N, N);	// MOVB *(SI)+,*(DI)+
+				c--;
+			}
+		} else if(w < 8 || c <= 4) {
+			nodsi.op = OINDREG;
+			noddi.op = OINDREG;
+			nodsi.type = types[TINT32];
+			noddi.type = types[TINT32];
+			if(c > 4) {
+				nodsi.xoffset = 0;
+				noddi.xoffset = 0;
+				gmove(&nodsi, &noddi);
+			}
+			nodsi.xoffset = c-4;
+			noddi.xoffset = c-4;
+			gmove(&nodsi, &noddi);
+		} else {
+			nodsi.op = OINDREG;
+			noddi.op = OINDREG;
+			nodsi.type = types[TINT64];
+			noddi.type = types[TINT64];
+			nodsi.xoffset = c-8;
+			noddi.xoffset = c-8;
+			gmove(&nodsi, &noddi);
 		}
 	}
 

src/cmd/6g/ggen.c (clearfat関数)

--- a/src/cmd/6g/ggen.c
+++ b/src/cmd/6g/ggen.c
@@ -1016,7 +1016,8 @@ void
 clearfat(Node *nl)
 {
 	int64 w, c, q;
-	Node n1, oldn1, ax, oldax;
+	Node n1, oldn1, ax, oldax, di, z;
+	Prog *p;
 
 	/* clear a fat object */
 	if(debug['g'])
@@ -1048,10 +1049,23 @@ clearfat(Node *nl)
 		q--;
 	}
 
-	if(c >= 4) {
-		gconreg(AMOVQ, c, D_CX);
-		gins(AREP, N, N);	// repeat
-		gins(ASTOSB, N, N);	// STOB AL,*(DI)+
+	z = ax;
+	di = n1;
+	if(w >= 8 && c >= 4) {
+		di.op = OINDREG;
+		di.type = z.type = types[TINT64];
+		p = gins(AMOVQ, &z, &di);
+		p->to.scale = 1;
+		p->to.offset = c-8;
+	} else if(c >= 4) {
+		di.op = OINDREG;
+		di.type = z.type = types[TINT32];
+		p = gins(AMOVL, &z, &di);
+		if(c > 4) {
+			p = gins(AMOVL, &z, &di);
+			p->to.scale = 1;
+			p->to.offset = c-4;
+		}
 	} else
 	while(c > 0) {
 		gins(ASTOSB, N, N);	// STOB AL,*(DI)+

コアとなるコードの解説

sgen関数の変更解説

sgen関数は、メモリコピーのコード生成を担当します。変更の目的は、残りのバイト(c)を効率的に処理することです。

  • 変更前:

    if(c >= 4) {
        gins(AMOVSL, N, N); // MOVL *(SI)+,*(DI)+
        c -= 4;
    }
    while(c > 0) {
        gins(AMOVSB, N, N); // MOVB *(SI)+,*(DI)+
        c--;
    }
    

    これは、まず4バイト単位でコピーを試み、その後は1バイト単位で残りをコピーするという単純なロジックでした。AMOVSLは4バイトの移動、AMOVSBは1バイトの移動を意味します。

  • 変更後: 新しいコードは、残りのバイト数cと全体のサイズwに基づいて、より賢い戦略を採用します。

    if(w < 4 || c <= 1 || (odst < osrc && osrc < odst+w)) {
        // 小さいサイズ、単一バイト、または重なりがあり後方コピーが必要な場合
        while(c > 0) {
            gins(AMOVSB, N, N); // 1バイトずつコピー
            c--;
        }
    } else if(w < 8 || c <= 4) {
        // 全体が8バイト未満、または残りが4バイト以下の場合
        nodsi.op = OINDREG;
        noddi.op = OINDREG;
        nodsi.type = types[TINT32]; // 32ビット(4バイト)型を使用
        noddi.type = types[TINT32];
        if(c > 4) { // 残りが4バイトより大きい場合(例: 5, 6, 7バイト)
            nodsi.xoffset = 0;
            noddi.xoffset = 0;
            gmove(&nodsi, &noddi); // 先頭から4バイトをコピー
        }
        nodsi.xoffset = c-4;
        noddi.xoffset = c-4;
        gmove(&nodsi, &noddi); // 末尾から4バイトをコピー(重複する可能性あり)
    } else {
        // 全体が8バイト以上で、残りが4バイトより大きい場合
        nodsi.op = OINDREG;
        noddi.op = OINDREG;
        nodsi.type = types[TINT64]; // 64ビット(8バイト)型を使用
        noddi.type = types[TINT64];
        nodsi.xoffset = c-8;
        noddi.xoffset = c-8;
        gmove(&nodsi, &noddi); // 末尾から8バイトをコピー(重複する可能性あり)
    }
    

    このロジックは、残りのバイトを処理する際に、可能な限り大きな単位(4バイトまたは8バイト)でアラインメントされていないロード/ストアを実行します。例えば、残りが5バイトの場合、変更前は1バイトのMOVBを5回実行していましたが、変更後は先頭から4バイト、末尾から4バイトの2回のMOVL(合計8バイトの読み書きだが、実際には5バイト分が埋まる)で処理します。これにより、CPUの命令実行効率が向上し、高速化が図られます。

clearfat関数の変更解説

clearfat関数は、メモリ領域をゼロで埋めるコード生成を担当します。

  • 変更前:

    if(c >= 4) {
        gconreg(AMOVQ, c, D_CX);
        gins(AREP, N, N);   // repeat
        gins(ASTOSB, N, N); // STOB AL,*(DI)+
    } else
    while(c > 0) {
        gins(ASTOSB, N, N); // STOB AL,*(DI)+
    }
    

    これは、4バイト以上残っている場合はREP STOSB(ALレジスタのゼロ値をDIが指すメモリにCX回書き込む)で4バイト単位のゼロクリアを試み、その後は1バイト単位でSTOSBを実行していました。

  • 変更後: 新しいコードは、残りのバイト数cと全体のサイズwに基づいて、より効率的なゼロクリア戦略を採用します。

    z = ax; // axレジスタにゼロ値が格納されていると仮定
    di = n1; // n1はデスティネーションノード
    if(w >= 8 && c >= 4) {
        // 全体が8バイト以上で、残りが4バイト以上の場合
        di.op = OINDREG;
        di.type = z.type = types[TINT64]; // 64ビット(8バイト)型を使用
        p = gins(AMOVQ, &z, &di); // ゼロ値を8バイト単位で移動
        p->to.scale = 1;
        p->to.offset = c-8; // 末尾から8バイトをゼロクリア
    } else if(c >= 4) {
        // 残りが4バイト以上の場合
        di.op = OINDREG;
        di.type = z.type = types[TINT32]; // 32ビット(4バイト)型を使用
        p = gins(AMOVL, &z, &di); // ゼロ値を4バイト単位で移動
        if(c > 4) { // 残りが4バイトより大きい場合
            p = gins(AMOVL, &z, &di); // 再度4バイト移動(末尾から)
            p->to.scale = 1;
            p->to.offset = c-4; // 末尾から4バイトをゼロクリア
        }
    } else
    while(c > 0) {
        gins(ASTOSB, N, N); // 1バイトずつゼロクリア
    }
    

    clearfatの変更もsgenと同様に、残りのバイトを処理する際に、可能な限り大きな単位(4バイトまたは8バイト)でアラインメントされていないストアを実行します。これにより、memsetのようなゼロクリア操作も高速化されます。特に、if(c > 4)のブロックが二重になっている部分は、残りのバイトが5〜7バイトの場合に、先頭から4バイトと末尾から4バイトの2つの4バイト書き込みを行うことで、効率的にゼロクリアを行う意図があると考えられます。

これらの変更は、Goコンパイラが生成するコードの品質を向上させ、特にメモリを頻繁にコピーまたはクリアするGoプログラムの実行速度に良い影響を与えます。

関連リンク

参考にした情報源リンク

  • Go言語のソースコード (特にsrc/cmd/6g/cgen.csrc/cmd/6g/ggen.cの該当コミット)
  • x86-64アセンブリ言語の命令セットに関する知識
  • メモリのアラインメントとアンアラインドアクセスに関する一般的なコンピュータアーキテクチャの知識
  • memmovememsetの動作に関するC標準ライブラリの知識