プログラムはどう動くか
この章でわかること:
- Rustのコードが機械語に変換され、CPUで実行されるまでの流れ
- レジスタとは何か。CPUが命令を実行する基本サイクル
- CPUの計算がどれくらい速いか(実測)
- コンパイラが生成した機械語を自分の目で確認する方法
CPUは何をする機械か
Section titled “CPUは何をする機械か”CPU(central processing unit)は、突き詰めると単純な機械です。 メモリ(memory)に置かれた命令(instruction)を順に読み取って実行する、 という動作をひたすら繰り返しています。
命令とは「この数とこの数を足す」「メモリのこの場所から値を読む」といった、
ごく小さな操作の指示です。命令は0と1の並びで表現され、
この形式を機械語(machine code)と呼びます。
プログラムとは機械語の命令の列であり、データと同じようにメモリに置かれます。
flowchart LR
subgraph mem ["メモリ"]
inst["命令の列<br/>(プログラム)"]
data["データ"]
end
subgraph cpu ["CPU"]
direction TB
f["① 命令を読む<br/>(フェッチ)"] --> d["② 解釈する<br/>(デコード)"] --> e["③ 実行する"]
e -.->|次の命令へ| f
reg["レジスタ<br/>(作業用の記憶場所)"]
e <--> reg
end
inst -->|命令| f
data <-->|値の読み書き| e
この図のサイクルを、現代のCPUは1秒間に数十億回まわしています。 本書のPart Iは、この単純なサイクルが「どう速くされているか」を 一段ずつ見ていく構成になっています。
Rustのコードが機械語になるまで
Section titled “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コードから、 命令セットごとに異なる機械語を生成します。
レジスタ — CPUの手元にある記憶場所
Section titled “レジスタ — CPUの手元にある記憶場所”先ほどから出ているレジスタ(register)は、CPU内部にある少数の 高速な記憶場所です。例えば、机の上に置ける数枚のメモ用紙のようなものです。 枚数は少ないものの、手を伸ばさず一瞬で読み書きできます。
- 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つです。
命令サイクルとクロック
Section titled “命令サイクルとクロック”CPUが1つの命令を処理する流れは、大きく3段階に分けられます。
- フェッチ(fetch) — メモリから次の命令を読み取る
- デコード(decode) — 命令の種類と対象(どのレジスタか等)を解釈する
- 実行(execute) — 計算やメモリアクセスを行い、結果をレジスタなどに書き戻す
この流れを刻むテンポがクロック周波数(clock frequency)です。 例えば3GHzのCPUは、1秒間に30億回の「拍」を刻みます。 1拍(1サイクル)は約0.33ナノ秒です。整数の加算のような単純な命令は、 おおよそ1サイクルで実行できます。
実験: CPUの速さを体感する
Section titled “実験: 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章 コンパイラがしていることで扱います
生成された機械語を自分で確認するには
Section titled “生成された機械語を自分で確認するには”コンパイラが何をしたかは、推測ではなく生成されたアセンブリで確認できます。 手軽な方法を2つ挙げます。
- Compiler Explorer — ブラウザ上でコードを書くと、
生成されるアセンブリが即座に表示されるサイトです。言語にRustを選び、
コンパイラオプションに
-O(最適化あり)を指定して使います cargo asm— 手元のプロジェクトの関数のアセンブリを表示する cargoサブコマンドです(8章で導入します)
本書でも「本当にそうなっているか」をアセンブリで確認する場面が たびたび出てきます。
- CPUは「メモリから命令をフェッチ→デコード→実行」を繰り返す機械です
- プログラムは機械語の命令列としてメモリに置かれます。 アセンブリは機械語の人間可読な表現です
- レジスタはCPU内部の少数・高速な記憶場所で、計算は原則レジスタ上で行われます
- 単純な加算は1命令・約1サイクル。3GHzのCPUは1秒に30億サイクル刻みます
- debugビルドとreleaseビルドの性能はまったく別物です。 コンパイラはループを削除するほど大胆にコードを書き換えます
CPUは1秒に数十億回の計算ができます。しかし、計算対象のデータが メモリから届かなければ、CPUはただ待つことになります。 実は現代のプログラムの速度を最も左右するのは、この「待ち」です。 次章では、メモリとキャッシュの仕組みを見ていきます。