プログラムはどう動くか
この章でわかること:
- Rustのコードが機械語に変換され、CPUで実行されるまでの流れ
- レジスタとは何か。CPUが命令を実行する基本サイクル
- CPUの計算がどれくらい速いか(実測)
- コンパイラが生成した機械語を自分で確認する方法
CPUは何をする機械か
CPU(central processing unit)は、突き詰めると単純な機械です。 メモリ(memory)に置かれた命令(instruction)を順に読み取って実行する、 という動作を繰り返し続けています。
命令とは「この数とこの数を足す」「メモリのこの場所から値を読む」といった、
ごく小さな操作の指示です。命令は0と1の並びで表現され、
この形式を機械語(machine code)と呼びます。
プログラムとは機械語の命令の列であり、データと同じようにメモリに置かれます。
この図のサイクルを、現代の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]: レジスタrdiとrsiの値を足し、結果をレジスタeaxに入れますret: 関数から戻ります
命令名(leaやret)に続くeaxや[rdi + rsi]のような
「命令が操作する対象」をオペランド(operand)と呼びます。
対象になれるのはレジスタ、命令に埋め込まれた定数、
メモリ上の位置などで、何をいくつ取れるかは命令ごとに決まっています。
引数aとbはレジスタrdiとrsiに入って渡され、
戻り値はレジスタ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本あります(
rax、rdi、rsiなど。eaxはraxの下位32ビットを指す別名です) - ARM64には31本あります(
x0〜x30。w0はx0の下位32ビット)
CPUの計算命令は、主にレジスタ(または命令に埋め込まれた定数)を対象にします。 ARM64ではメモリ上のデータを計算に使うには必ずロード命令でレジスタに 読み込む必要があります。x86-64にはメモリ上の値を直接足せる命令もありますが、 その場合も内部ではロードしてから計算しています。
このためコンパイラは、よく使う変数をできるだけレジスタに割り当てようとします。
Rustのコードでlet x = ...と書いても、xがメモリに置かれるとは限りません。
多くの場合、xはレジスタ上にのみ存在し、メモリには書き出されません。
この「変数をどのレジスタに割り当てるか」の決定はレジスタ割り付け
(register allocation)と呼ばれ、コンパイラの重要な処理の1つです。
命令サイクルとクロック
CPUが1つの命令を処理する流れは、大きく3段階に分けられます。
- フェッチ(fetch): メモリから次の命令を読み取ります
- デコード(decode): 命令の種類と対象(どのレジスタか等)を解釈します
- 実行(execute): 計算やメモリアクセスを行い、結果をレジスタなどに書き戻します
この流れを進める周期的な信号の速さがクロック周波数(clock frequency)です。 例えば3GHzのCPUでは、この信号が1秒間に30億回発生します。 1回分(1サイクル)は約0.33ナノ秒です。整数の加算のような単純な命令は、 おおよそ1サイクルで実行できます。
実験: CPUの速さを測る
実際に測ります。
次のコードは1億回の加算を行い、かかった時間を表示します。
コード中のwrapping_addは「あふれたら2^64で回り込む足し算」です。
通常の+はdebugビルドではあふれをエラーにする検査が入るため、
「回り込んでよい」と明示して、後で行う2つのビルドの比較条件を揃えています。
「▶ 実行」を押してください(Rust Playground上で実行されます)。
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ビルドでは、コンパイラは変数sumやiを常にメモリ上に置き、
ループのたびに読み書きします。1回の加算のために、
実際には数十個の命令が実行されているのです。
では、最適化を有効にした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は待つことになります。 現代のプログラムの速度を最も左右するのは、この待ち時間です。 次章では、メモリとキャッシュの仕組みを説明します。