RustではじめるCPUとGPU

プログラムはどう動くか

この章でわかること:

  • Rustのコードが機械語に変換され、CPUで実行されるまでの流れ
  • レジスタとは何か。CPUが命令を実行する基本サイクル
  • CPUの計算がどれくらい速いか(実測)
  • コンパイラが生成した機械語を自分で確認する方法

CPUは何をする機械か

CPU(central processing unit)は、突き詰めると単純な機械です。 メモリ(memory)に置かれた命令(instruction)を順に読み取って実行する、 という動作を繰り返し続けています。

命令とは「この数とこの数を足す」「メモリのこの場所から値を読む」といった、 ごく小さな操作の指示です。命令は01の並びで表現され、 この形式を機械語(machine code)と呼びます。 プログラムとは機械語の命令の列であり、データと同じようにメモリに置かれます。

メモリ

命令

値の読み書き

CPU

次の命令へ

① 命令を読む
(フェッチ)

② 解釈する
(デコード)

③ 実行する

レジスタ
(作業用の記憶場所)

命令の列
(プログラム)

データ

この図のサイクルを、現代のCPUは1秒間に数十億回繰り返しています。 本書のPart Iは、この単純なサイクルが「どう速くされているか」を 順に説明する構成になっています。

Rustのコードが機械語になるまで

Rustのソースコードは、コンパイラ(rustc)によって機械語に変換されます。 cargo buildで得られる実行ファイルの中身は、ほぼ機械語の命令列です。

機械語は数値の並びで人間には読みにくいため、命令を1つずつ人間が読める 記法で書き直したアセンブリ(assembly)という表現を使います。 機械語とアセンブリは1対1に対応しており、「機械語を読む」ことは 実質「アセンブリを読む」ことだと考えて問題ありません。

次の関数がどんな機械語になるか見てみます。

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

x86-64(IntelやAMDのCPU)向けにコンパイルすると、次のアセンブリが生成されます。

add:
	lea	eax, [rdi + rsi]
	ret

たった2命令です。1行ずつ読みます。

  • lea eax, [rdi + rsi]: レジスタrdirsiの値を足し、結果をレジスタeaxに入れます
  • ret: 関数から戻ります

命令名(learet)に続くeax[rdi + rsi]のような 「命令が操作する対象」をオペランド(operand)と呼びます。 対象になれるのはレジスタ、命令に埋め込まれた定数、 メモリ上の位置などで、何をいくつ取れるかは命令ごとに決まっています。

引数abはレジスタrdirsiに入って渡され、 戻り値はレジスタeaxに入れて返す取り決めになっています。 この取り決めは呼び出し規約(calling convention)と呼ばれ、 ここではmacOSやLinuxのx86-64での規約(System V)で説明しています (Windowsは別のレジスタを使います)。 いずれにせよ、Rustのa + bはCPUのたった1つの加算命令に対応しています。

同じ関数をARM64(Apple SiliconやスマートフォンのCPU)向けに コンパイルすると、次のアセンブリが生成されます。

add:
	add	w0, w1, w0
	ret

命令の書き方は違いますが、「レジスタ2つを足して結果をレジスタに置き、戻る」 という構造は同じです。CPUが受け付ける命令の種類と形式のことを 命令セット(instruction set architecture、ISA)と呼び、 x86-64やARM64はその代表です。コンパイラは同じRustコードから、 命令セットごとに異なる機械語を生成します。

lea が加算に使われる理由

leaは本来アドレス計算用の命令ですが、「2つのレジスタの和を 別のレジスタに書ける」ため、x86-64ではただの加算にもよく使われます。 アセンブリを読むと、このように命令を本来の用途以外に使う例がたびたび出てきます。

ARM64版で w0 が2回登場する理由

add w0, w1, w0では、w0が計算のソースと書き込み先を 兼ねています。これはARM64の呼び出し規約が、第1引数のレジスタと 戻り値のレジスタをどちらもx0(32ビット幅ならw0)と定めている ためです。命令は両ソースを読んでから書き込むので、引数aを 結果で上書きしても問題ありません(aはこの加算で使い終わる、 という判断はレジスタ割り付けの処理で行われます)。

証拠として、第1引数をそのまま返す関数はARM64ではretの1命令に なります(値が最初から戻り値の位置にあるため、転送が不要です)。 一方、第2引数を返す関数にはmov x0, x1という転送命令が現れます。

pub fn first(a: i32, _b: i32) -> i32 { a }  // → ret のみ
pub fn second(_a: i32, b: i32) -> i32 { b } // → mov x0, x1 / ret

x86-64はこの点で対照的です。戻り値レジスタeaxは引数レジスタでは ないため、結果を必ずeaxへ転送する必要があります。上のleaの利用は、 3オペランドの加算で転送と計算を1命令で済ませる手段でもあります。

レジスタ: CPU内部の記憶場所

先ほどから出ているレジスタ(register)は、CPU内部にある少数の 高速な記憶場所です。例えば、机の上に置ける数枚のメモ用紙のようなものです。 数は少ないものの、メモリへのアクセスを介さずに1サイクル程度で読み書きできます。

  • x86-64には64ビット幅の汎用レジスタが16本あります(raxrdirsiなど。 eaxraxの下位32ビットを指す別名です)
  • ARM64には31本あります(x0x30w0x0の下位32ビット)

CPUの計算命令は、主にレジスタ(または命令に埋め込まれた定数)を対象にします。 ARM64ではメモリ上のデータを計算に使うには必ずロード命令でレジスタに 読み込む必要があります。x86-64にはメモリ上の値を直接足せる命令もありますが、 その場合も内部ではロードしてから計算しています。

このためコンパイラは、よく使う変数をできるだけレジスタに割り当てようとします。 Rustのコードでlet x = ...と書いても、xがメモリに置かれるとは限りません。 多くの場合、xはレジスタ上にのみ存在し、メモリには書き出されません。 この「変数をどのレジスタに割り当てるか」の決定はレジスタ割り付け (register allocation)と呼ばれ、コンパイラの重要な処理の1つです。

命令サイクルとクロック

CPUが1つの命令を処理する流れは、大きく3段階に分けられます。

  1. フェッチ(fetch): メモリから次の命令を読み取ります
  2. デコード(decode): 命令の種類と対象(どのレジスタか等)を解釈します
  3. 実行(execute): 計算やメモリアクセスを行い、結果をレジスタなどに書き戻します

この流れを進める周期的な信号の速さがクロック周波数(clock frequency)です。 例えば3GHzのCPUでは、この信号が1秒間に30億回発生します。 1回分(1サイクル)は約0.33ナノ秒です。整数の加算のような単純な命令は、 おおよそ1サイクルで実行できます。

実験: CPUの速さを測る

実際に測ります。 次のコードは1億回の加算を行い、かかった時間を表示します。 コード中のwrapping_addは「あふれたら2^64で回り込む足し算」です。 通常の+はdebugビルドではあふれをエラーにする検査が入るため、 「回り込んでよい」と明示して、後で行う2つのビルドの比較条件を揃えています。 「▶ 実行」を押してください(Rust Playground上で実行されます)。

1億回の加算(最適化なし: debugビルド)
use std::time::Instant;

fn main() {
    let n: u64 = 100_000_000; // 1億回
    let start = Instant::now();

    let mut sum: u64 = 0;
    for i in 0..n {
        sum = sum.wrapping_add(i);
    }

    let elapsed = start.elapsed();
    println!("合計: {sum}");
    println!("経過時間: {elapsed:?}");
    println!(
        "1秒あたり約 {:.1} 億回の加算",
        n as f64 / elapsed.as_secs_f64() / 1e8
    );
}

Playground上では1秒弱、毎秒1億回強のペースで実行されるはずです (共有環境なので、実行のたびに値はばらつきます)。

3GHz級のCPUなら1秒に30億サイクルあるのに、 なぜ毎秒1億回程度しか加算できないのでしょうか。

答えは、これがdebugビルド(最適化なしのコンパイル)だからです。 debugビルドでは、コンパイラは変数sumiを常にメモリ上に置き、 ループのたびに読み書きします。1回の加算のために、 実際には数十個の命令が実行されているのです。

では、最適化を有効にしたreleaseビルドで同じコードを実行してみます。

同じコード(最適化あり: releaseビルド)
use std::time::Instant;

fn main() {
    let n: u64 = 100_000_000; // 1億回
    let start = Instant::now();

    let mut sum: u64 = 0;
    for i in 0..n {
        sum = sum.wrapping_add(i);
    }

    let elapsed = start.elapsed();
    println!("合計: {sum}");
    println!("経過時間: {elapsed:?}");
    println!(
        "1秒あたり約 {:.1} 億回の加算",
        n as f64 / elapsed.as_secs_f64() / 1e8
    );
}

経過時間は数百ナノ秒程度、「毎秒数千兆回の加算」という 物理的にありえない数字が表示されたはずです。

コンパイラはこのループを解析して 「0からn-1までの総和は公式で計算できる」と判断し、 ループそのものを削除して、数回の掛け算と足し算に置き換えました。 1億回の加算は、実行時には一度も行われていません。

この実験から、本書全体で前提とする2つの事実がわかります。

  • 性能の話はreleaseビルドを前提にします。 debugビルドの実行時間には意味がありません
  • コンパイラは、意味を保ったままコードの構造を大きく書き換える装置であり、 書いたとおりに翻訳する装置ではありません。 その仕組みは 6章 コンパイラがしていることで扱います

生成された機械語を自分で確認するには

コンパイラが何をしたかは、推測ではなく生成されたアセンブリで確認できます。 簡単な方法を2つ挙げます。

  • Compiler Explorer: ブラウザ上でコードを書くと、 生成されるアセンブリが即座に表示されるサイトです。言語にRustを選び、 コンパイラオプションに-O(最適化あり)を指定して使います
  • cargo asm: ローカルのプロジェクトの関数のアセンブリを表示する cargoサブコマンドです(8章で導入します)

本書でも「本当にそうなっているか」をアセンブリで確認する場面が たびたび出てきます。

まとめ

  • CPUは「メモリから命令をフェッチ→デコード→実行」を繰り返す機械です
  • プログラムは機械語の命令列としてメモリに置かれます。 アセンブリは機械語の人間可読な表現です
  • レジスタはCPU内部の少数・高速な記憶場所で、計算は原則レジスタ上で行われます
  • 単純な加算は1命令・約1サイクルで実行されます。3GHzのCPUは1秒に30億サイクル動作します
  • debugビルドとreleaseビルドの性能はまったく別物です。 コンパイラはループを削除するほど大きくコードを書き換えます

CPUは1秒に数十億回の計算ができます。しかし、計算対象のデータが メモリから届かなければ、CPUは待つことになります。 現代のプログラムの速度を最も左右するのは、この待ち時間です。 次章では、メモリとキャッシュの仕組みを説明します。