CPU アーキテクチャ 基礎

我々の curl コマンドはコンパイルされて機械命令の長い流れになる —— レジスタをプッシュ、関数へジャンプ、メモリから 1 バイト読む。 どの命令も CPU コア内の同じ小さなハードウェア群を訪れる。 5 セクションで全体像を組み立てる:fetch / decode / execute ループとそれが触れるレジスタ;パイプライニング + 対話的な 5 段ウォーカ(データハザードとストール表示);分岐予測 —— if が本来なら 20 段パイプラインを空にする状況で、 CPU がいかに稼働を保つか;アウトオブオーダー実行 —— シングルコアがサイクルあたり 1 命令を超えて維持するための簿記; 最後に クイックリファレンス —— 冷たく説明できる価値のある問い。

01

1 コア、1 つのループ

他のすべてを剥がせば、CPU は 1 つのループを永遠に走らせる小さな有限状態機械: 次の命令を取得、デコード、実行。本 primer の他のすべてのハードウェアは、 そのループを速くするために存在する。

1 コアの中で重要な構造は 3 つ:レジスタファイル(x86-64 で 16 個の名前付き 64 ビットレジスタ、ARM64 で 31 個、加えて FP とベクトルバンク)、ALU(数学回路 —— 加算、比較、ビット演算)、制御ユニット(全体を統括する小さな状態機械)。 加えてプログラムカウンタレジスタ、慣習的に PC または RIP/EIP と呼ばれ、 次に実行する命令のアドレスを保持する。

+------------------- 1 コア -------------------+
|                                              |
|  PC → 取得 → デコード → 実行 → 書き戻し       |
|         ↑      ↑         ↑       ↑           |
|       L1-I  デコーダ  ALU/FPU  レジスタ        |
|                                              |
|         ↓ syscall、割り込み、例外             |
|                                              |
+------------------- 1 コア -------------------+

ループは概念的に単純:PC のアドレスにあるバイトを L1 命令キャッシュから読み、 どの演算がエンコードされているか解析し(デコード)、レジスタファイルからオペランドを取得して 演算を実行し(実行)、結果をレジスタへ書き戻し(書き戻し)、PC を次の命令へ進めて 繰り返す。あなたのコンピュータが今まで行ってきたあらゆる操作は、これに尽きる。

クロック速度が実際に意味すること

3 GHz CPU は 1 秒に 30 億回ティックするので、1 ティック(1 クロックサイクル)は ~0.33 ns。 マーケティング的には「1 コア 1 秒に 30 億命令」と読めるが、現実はもっと乱雑。 単一命令は 1 サイクル(レジスタ上の整数加算)から数百サイクル (除算、または全キャッシュ階層をミスするロード)まで様々。 そして現代のコアは普通にサイクルあたり複数命令(IPC > 1)を維持する —— 後続 3 節で扱うトリックのおかげで。だから「3 GHz × N コア = 30N 億命令/秒」は 両方向で悪い推定。

レジスタ vs メモリ

レジスタは行動が起こる場所。あらゆる ALU 演算はレジスタからオペランドを読み、 レジスタへ結果を書く。メモリを扱うには、レジスタとメモリアドレスの間でバイトをコピーする 明示的な loadstore 命令が必要。レジスタ自身には「アドレス」がない —— 名前付き(x86 で rax、rbx ……)で、命令オペコードのビットにその名前が符号化される。 レジスタアクセスは命令実行の一部で追加時間はかからない。 メモリアクセスは 1 ns(L1)から 100 ns(DRAM)から 10 ms(ディスク)—— メモリ階層 primer で扱う。

なぜレジスタはこんなに少ないのか?符号化空間。 x86 命令の各オペランドスロットは 4 ビット (16 種類、REX 接頭辞のトリック後は 32 種類)。 レジスタを増やせば命令が長くなり、コードサイズが膨らみ、命令キャッシュ密度が悪化する。 ARM64 は 31 を選んだ —— 固定 32 ビット命令フォーマットに 5 ビットオペランドフィールドの 余地があり、設計者はビットを少々譲ってその数を得た。コンパイラはその後レジスタ割付を行う —— 各時点でどの変数をレジスタに、どれをメモリへ「スピル」するか選ぶ —— これが最も重要な最適化の 1 つ。

コンパイラはまた積極的にインライン化を行う —— 関数呼び出しを跨いで中間値をレジスタに保持するのが難しいから; ひとたび関数境界を越えれば呼び出し規約がいくつかのレジスタの保存を強制する。 「リクエストあたり 50 個の小さな関数呼び出し」に見える現代コードはしばしば、 全関数を貫く 1 つの大きなインラインブロックとして実行され、すべてがレジスタに生きている。 多くのエンジニアがプロファイラで遭遇する「マシン vs ソース行」のミスマッチはこれが源。

ここから何が見えるか

素朴な絵 —— サイクルあたり 1 命令、逐次 —— は現代のコアの実際の実行方法ではほぼあり得ない。 次の 3 節は CPU がこのループに加える 3 層の機械を説明する:パイプライニング(1 本の逐次ストリームから同時に 5 つを処理)、分岐予測(次に何が来るかを予想して、各 ifでパイプラインを満たし続ける)、アウトオブオーダー実行(可視結果が逐次に見える限り自由に並べ替える)。 合わせると、同クロックで素朴な実装の約 10–20 倍のスループットを買う。

要点。「コアはプログラムカウンタが駆動する取得-デコード-実行ループで、 オペランドが小さなレジスタファイルを行き来する。クロック速度は上限を決めるが、 実スループットはハードウェアがそのループの背後でどれほど巧妙に作業を重ね、並べ替えるかに依存する —— 次の 3 節がその巧妙さの全貌。」

02

パイプライニング —— 同時に 5 つ

素朴な CPU は 1 命令を完了させてから次を始める —— 5 つのハードウェアユニットのうち 4 つが任意の瞬間遊んでいる。 パイプライニングのルール:待つな —— 現在の命令が次の段へ移った瞬間に次の命令を始める。

命令ループを段に分割する。古典的な 5 段 MIPS パイプラインはFetch → Decode → Execute → Memory → Writeback。 各段は自分のハードウェア(命令キャッシュ、デコーダ、ALU、load/store ユニット、レジスタファイル書き込みポート) を持ち、各段は 1 サイクル。1 段に 1 命令を保持し、1 ユニットだけが働く。 パイプラインのトリックはすべての段を同時に稼働させる —— 毎サイクル新しい命令を Fetch に投入する。4 サイクルのウォームアップ後、 パイプラインはサイクルあたり 1 命令を退役する —— 5 命令が 25 サイクルではなく 9 サイクルで完了する。

現代の x86 と ARM のコアは 5 段ではなく 10〜20 段のパイプラインを持つ。なぜそんなに多いのか? 各段は 1 クロックサイクルに収まる必要があり、現代のクロックは非常に速いから。 作業をより多くのより小さい段に分割すれば、各段がサイクルあたりで行う仕事が少なくなり、 より高いクロック周波数が許される。代価はあらゆるパイプラインをフラッシュする出来事が、 4 サイクルではなく 15+ サイクルを無駄にすること。次節がそれへの対処。

5 段パイプライン —— 段数 × サイクルのグリッド、4 シナリオ理想 —— 5 命令、9 サイクル、ハザードなし123456789サイクルF · FetchD · DecodeE · ExecuteM · MemoryW · WritebackI1I2I3I4I5I1I2I3I4I5I1I2I3I4I5I1I2I3I4I5I1I2I3I4I55 命令 / 9 サイクル → CPI = 1.80
パイプラインはサイクル 5 で定常状態:毎サイクル 1 命令が Fetch に入り、1 命令が Writeback から退役する。CPI = 9 / 5 = 1.8(逐次実行の 25 サイクルと比較)。
1 / 4
IPC = 1(サイクルあたり 1 命令)はシングルイシュー・インオーダーパイプラインの理論上限。 実ワークロードでは期待ほど達成されない。データハザードは 1 サイクル、 分岐予測ミスは取得済み未退役の全命令をフラッシュ(現代の 20 段パイプラインで 15+ スロット)、 DRAM へのキャッシュミスは ~100 サイクル。次の 2 節 —— 分岐予測とアウトオブオーダー実行 —— は、これらのコストに抗して IPC を守るために実 CPU が構築するもの。

3 種類のハザード

  • データハザード(data hazard)。命令 B が読みたいレジスタを、 まだ実行中の命令 A が書き込み終えていない。パイプラインは A の結果が利用可能になるまで B をストールさせる必要がある。現代 CPU は フォワーディング(forwarding)で結果を Execute の出力から次サイクルの Execute 入力へ直接送り、ストールの大半を消す —— しかし load-use ハザード(B が A のメモリからのロード値を必要)は避けられない: 値は A が Memory を通過するまで利用できず、Execute より 1 サイクル遅い。
  • 制御ハザード(control hazard)。次命令のアドレスは、 まだパイプラインにある分岐の結果に依存する。予測がなければフェッチャは分岐解決まで ストールする —— 20 段パイプラインで 15 サイクルのバブル。 予測あり(次節)なら推測する;誤りなら、誤予測経路で取得された全てをフラッシュする。 誤予測は分岐の多いコードの支配的コスト。
  • 構造ハザード(structural hazard)。2 命令が同サイクルに同じハードウェア ユニットを欲しがる —— 例えば両者が同サイクルで唯一の load/store ポートを欲しがる。 現代設計はここにハードウェアを投じる(より多くのポート、より多くの機能ユニット)ので、 構造ハザードは性能分析でほぼ現れないが、インオーダーパイプラインがどこまで攻撃的になれるかを制約する。

パイプライニングが 1 IPC を無償で与えない理由

2 つの上限がインオーダーパイプラインの有効 IPC を制約する。第一、ハザード: あらゆるストールやフラッシュがバブルを挿入し、IPC を 1 未満に引きずる。第二、 最長段がサイクル時間を決める:1 段が 1.5 ns、他が 1.0 ns なら、クロックは 1.5 ns で 走らねばならない。設計者は各段のバランスに膨大な労力を費やす —— だから現代の Apple M シリーズや Intel コアでは、古典的な 5 「段」のそれぞれに 実際には多くのサブ段がある。

ソフトウェアエンジニアにとって興味深いのは、これがほぼ不可視であること —— 不可視でなくなるまでは。 配列上の線形ループは ~1 IPC で走る。予測不能な分岐が中にあるループは 0.5 IPC 以下に落ちる。 各反復が前回に依存するループ(ループ運搬依存)はパイプラインにかかわらず load-use レイテンシで制約される。ベクトル化とアウトオブオーダー実行はこれらの天井を打ち破るために存在する。

要点。「パイプラインは 5(または 20)命令をハードウェア段間で重ね、 定常状態スループットはサイクルあたり 1 命令。ハザード —— データ、制御、構造 —— はバブルを挿入する。 フラッシュのコストはパイプライン深度に線形にスケールし、 だから現代の 20 段パイプラインはフラッシュを避けるために大いに努力する (分岐予測、アウトオブオーダー実行)。」

03

分岐予測 —— 推測してから検証

あらゆる if、あらゆるループのバックエッジ、あらゆる仮想メソッド呼び出しは分岐。 各分岐の解決を待ってから次命令を取得すれば、20 段パイプラインは 15 サイクル深く空になる。 CPU は待たない —— 推測し、大半は当たる。

現代の分岐予測器は典型コードで 95–99% の精度を達成する。 これを実現するハードウェアは多くのエンジニアが認識する以上に洗練されている —— 現代の Intel と AMD の予測器は perceptron 風のニューラルロジック、 または TAGE(TAgged GEometric history length)予測器を使い、 数千の過去分岐結果のパターンを追跡する。詳細は世代ごとに変わる; ソフトウェアにとって重要なのは、それらが何の形状を得意とし、何を苦手とするか。

予測器が得意なこと

  • ループのバックエッジ。N 回走るループは N-1 回テイクされ 1 回テイクされない。 予測器は 1 回の実行で学習し、以後すべて当たる。これが配列上の直線反復が ピーク IPC 近くで走る理由。
  • 極端に偏った分岐。0.1% でしか失敗しないエラーチェックは、 1 回観測後に永久に「not taken」と予測される。遅いパスは遅いだけ;速いパスは速いまま。
  • パターン分岐。true / false が交互するループは、 予測器が周期を捕まえると正しく予測される。 予測器は約 16–32 結果深い反復パターンを捕まえるだけの履歴を追跡する。

予測器が苦手なこと

  • パターンのないデータ依存分岐。有名な Stack Overflow の質問: 各配列要素が > 128 か分岐するループは、ソート済み配列で未ソートより約 5 倍速く走る。 ソート済み:分岐がしばらく常にテイク、その後常にノンテイク —— 完璧に予測可能。 未ソート:50/50、~50% 誤予測率 × 15 サイクルフラッシュ = 悪い日。 ホットフィルタの前の std::sort は何度も元を取れる。
  • 多くのターゲットを持つ間接分岐。多態オブジェクトのstd::vector 上の仮想メソッド呼び出し、50 ケースの enum スイッチ、 megamorphic JIT 呼び出しサイト。分岐のターゲットがデータ依存で方向だけではない。 間接分岐予測器(BTB — Branch Target Buffer)は容量が小さく問題が難しい。
  • Spectre 型の敵対的パターン。攻撃者が予測器を特定の結果を期待するように 繰り返し訓練し、その後異なるデータで分岐をトリガすると、 結果として起こる投機実行のキャッシュタイミングサイドチャネル経由で被害者メモリを漏らせる。 緩和(LFENCE、retpoline など)は影響を受けるワークロードでスループットの ~5–10% を失う。

プログラマとして何をするか

多くのコードは分岐予測を心配する必要がない。それが効くまれなホットループのため:

  • 処理前にデータをソートまたはパーティションする —— 分岐がデータ依存で、その上にタイトな内側ループがある場合。
  • 可能なら分岐を分岐なし算術で置き換える ——max(a - b) & ((a - b) >> 31) トリックで書く、 または __builtin_expect、または条件 move(cmov)。 コンパイラはできるとき自動でやる。
  • 不変な分岐を内側ループから外へホイストする。コンパイラは通常やるが、テンプレートが多く間接が多いコードでは オプティマイザが見通せない分岐を隠せる。
  • Linux 上の perf stat -e branch-misses で測る。 命令あたりのミス率が 1% 未満なら、分岐はあなたの問題ではない。

要点。「分岐予測器は典型コードで 95–99% 当て、パイプラインを満たし続ける。 外す 1–5% は現代パイプラインで各 ~15 サイクル。病的なケース —— パターンのないデータ依存分岐、多くのターゲットを持つ間接分岐 —— が ホットループ前の std::sort、分岐なし算術、 パターンタグ付きディスパッチテーブルが本当に勝つ場所。そうでなければ予測器は不可視。」

04

アウトオブオーダー実行 —— サイクルあたり 1 命令を超えて維持

パイプライン化されたインオーダー CPU は IPC 1 で頭打ち。現代のコアは普通に 3–4。 トリック:入力が揃った瞬間にプログラム順から外れて命令をディスパッチし、 その後プログラム順にコミットして、観測可能な振る舞いを変えない。

各反復が前回の結果を読むタイトなループを考える。完璧なパイプライニングがあっても、 N の出力が計算されるまで N+1 を始められない —— ループ運搬依存がすべてを直列化する。 次に各反復が独立なループを考える:a[i] をロード、b[i] を加算、c[i] へストア。パイプラインだけで 1 IPC。 しかし CPU には複数 ALU、複数ロードユニット、複数ストアユニットがある —— 独立した命令が偶然利用可能なら、それらを同時に走らせられる。難しいのは命令ストリームの中から見つけること。

アウトオブオーダー(OoO)実行がその仕組み。3 つの構造が協調する:

  • リオーダーバッファ(ROB)。プログラム順に並ぶ実行中命令の FIFO。命令はデコード時に ROB へ入り、 結果がアーキテクチャ上見えるようになったときに離れる(コミット)。 サイズは古いコアで ~150 エントリ、Apple M シリーズで ~600。
  • リザベーションステーション(RS、スケジューラとも)。入力を待つデコード済み命令のプール。各入力が利用可能になると RS がブロードキャストし、 入力が全て揃った命令が実行ユニットへ発射される —— 可能ならプログラム順から外れて。 RS が CPU の独立作業を見つける場所 —— 「並列発見」エンジン。
  • レジスタリネーミング。16(x86)または 31(ARM)のアーキテクチャ レジスタは数百の物理レジスタのエイリアス。命令が rax を書くとき、 ハードウェアは実際には新しい物理レジスタを書き、rax をそれに再マップする。 これは偽の依存を消す:両方とも rax を書く独立した 2 操作は、 それぞれが自分の物理レジスタを得るので並列に走れる。

この 3 つが協調して、シングルコアがサイクルあたり 4–6 命令をディスパッチでき、 ROB がコミット順が逐次に見えるよう保証する。有名な IPC メトリック (instructions per cycle)はこの仕組みがどれだけうまく機能しているかを測る。 構造の良いコードでは、Apple M シリーズと Intel Sapphire Rapids の両方が IPC ~3.5 付近を持続する;依存制約のコード(連鎖したポインタ追跡、タイトな reduce)では、 1 を超えるのに苦労する。

ソフトウェアにできる(できない)こと

コンパイラが大半の仕事 —— 命令スケジューリング、依存断ち、ループアンロール —— を行い、 CPU に独立命令を見つけるための材料を豊富に与える。プログラマとして、主にこう助ける:

  • 依存チェーンを切る。100 万個の数を 1 つの累算器に合計するループは、 長さ 100 万の逐次依存チェーンを持つ。4 つの累算器に合計して最後に結合する —— 同じ答え、並列度 4 倍、コンパイラはきれいに書けば自動でやってくれることが多い。
  • CPU に先を見せる。大 ROB は ~600 命令を保持できるが、 先を見る必要がある —— つまり数命令ごとに予測不能なターゲットへ分岐しない。 インライン化された関数、ホイストされたループ不変量、予測可能な分岐は、 OoO ウィンドウにより多くの作業を与える。
  • ポインタ追跡は OoO キラー。各ステップが前ノードのロードに依存する リンクリスト走査は、ALU 幅ではなくメモリレイテンシでボトルネックになる。 配列と SoA(struct-of-arrays)レイアウトは、プリフェッチャと OoO マシンが 実際に仕事を行えるようにする。これが「キャッシュフレンドリ」が 「OoO が並列ロードを走らせられるようデータを配置する」を意味する理由。

投機実行と Spectre / Meltdown

賢さには暗い面がある。投機実行 —— 結果が破棄されるかもしれない操作の実行 (誤予測や例外による)—— は歴史的にソフトウェアには不可視だった: 投機の副作用は投機が放棄されたときにロールバックされた。 Spectre と Meltdown(2018)はこれが実は真ではないことを示した: 投機中に変化したキャッシュ状態はロールバック後も残り、 慎重に作られた攻撃は、攻撃者制御アドレスの投機ロードをトリガし、その後 cache タイミングを測ることで、 被害者プロセスのメモリを読み出せる。

緩和 —— 投機障壁、間接分岐への retpoline、ユーザとカーネル間のページテーブル分離 —— は実性能コストを払う、典型的にワークロードに応じて 5–30%。 また不完全でもある:約 6 ヶ月ごとに新しいマイクロアーキテクチャサイドチャネルが浮上する。 一般的な教訓は、投機実行サイドチャネルが今やマルチテナントシステム(クラウド VM、 ブラウザ、信頼されないコードを共有ハードウェア上で走らせるあらゆるもの)の 脅威モデルの一部だということ。

要点。「OoO 実行は独立命令を早めにディスパッチし、プログラム順にコミットすることで、 シングルコアが IPC > 1 を維持できるようにする。ボトルネックは依存チェーン (ループ運搬、ポインタ追跡)とルックアヘッドウィンドウサイズ。 投機実行は Spectre / Meltdown が利用したのと同じ仕組み —— そのキャッシュ状態への観測可能な副作用は、可逆ではないことが判明した。」

05

クイックリファレンス

冷たく説明できる価値のある 6 つの核心問題と、コードレビューで一目で見抜くべき 5 つの赤旗。 まず質問を頭に刻み込む;回答骨子はその下に。

なぜ 20 段パイプラインが 5 段より速いのか、分岐予測ミスのコストがより大きいのに?

段数が増えると各段の仕事量が減り、より高い周波数でクロックを走らせられる。 深いパイプラインも定常状態でサイクルあたり 1 命令を退役する —— 短いものと同様 —— が各サイクルが短い。フラッシュコストは深さに線形に増えるが、 分岐予測器が典型コードで > 95% 当てて補償する。 純効果は正:高クロック × 同等の定常スループット >(時折大きい)フラッシュペナルティ。

分岐予測を 90 秒で説明。

あらゆる if、ループバックエッジ、間接呼び出しが分岐。 予測なしではパイプラインは分岐解決までストールする —— 20 段パイプラインで 15 サイクルバブル。 現代の予測器は neural/TAGE アルゴリズムで過去 32+ の分岐結果を追跡し、典型コードで 95–99% 当てる。 誤予測は取得済み未退役の全命令をフラッシュする。病的なケースはパターンのないデータ依存分岐 (データをソート)と多くのターゲットを持つ間接分岐(megamorphic 呼び出し)。

インオーダーとアウトオブオーダー実行の違いは?

インオーダー:CPU はプログラムが並べる順序で厳密に命令を実行する。 IPC 上限は 1、ハザードでストールするとそれより低い。 アウトオブオーダー:CPU は入力が揃った瞬間にプログラム順から外れて命令をディスパッチし、 その後リオーダーバッファ経由でプログラム順に結果をコミットする。 IPC 上限は 4–6 まで上がる、複数 ALU で複数独立命令が並列に走れるから。

なぜソート済み配列の反復がタイトな分岐で未ソートを時に上回るのか?

有名な Stack Overflow ケース:arr[i] > 128 で分岐するループ。 ソート済み配列では分岐は前半常にテイク、後半常にノンテイク —— 予測器は 1 周走れば学習する。 未ソートデータでは結果は ~50/50 ランダムで予測は助けにならない。 深いパイプラインでは各誤予測 15+ サイクル。事前ソートからの 5–6 倍高速化は実在し、perf stat ですぐ表れる。

IPC は何を測り、何が制約するか?

IPC = サンプル上で平均された instructions per cycle。現代コアの理論上限は 4–6(発射幅)。 実ワークロードはおおむね減少順に:依存チェーン(ループ運搬、ポインタ追跡)、 分岐誤予測(分岐の多いコードで)、キャッシュミス(メモリ律速時)、構造ハザード、で制約される。 キャッシュミスのあるメモリ律速コードはしばしば IPC 1 をかなり下回る; 依存のないタイトな算術ループは 3–4 を達成し得る。

投機実行とは何か、2018 年以降何が危険になったのか?

投機実行 = CPU が破棄されうる結果の操作を実行すること(分岐誤予測や例外送出による)。 仮定は「破棄された結果は副作用を持たない」。Spectre と Meltdown(2018)はこれが偽であることを示した: 投機中に変化したキャッシュ状態はロールバック後も残り、慎重に作られた攻撃は 攻撃者制御アドレスの投機ロードをトリガし、cache タイミングを測ることで被害者メモリを読める。 緩和はワークロードに応じて 5–30% 性能コスト。

コードレビューの赤旗

  • ホット内側ループでの megamorphic 呼び出し。具体型が変化するヘテロジニアスコレクションで呼ぶ仮想メソッド。 間接分岐予測器(BTB)は容量が小さく追いつけない。
  • 上流でソートされていないデータ依存分岐。ソートまたはパーティションされていない入力値で分岐するループは大量に誤予測する。 データに構造があると知っているなら、一度ソート / バケット化し、後続のすべてのパスで恩恵を得る。
  • 数値リダクション内のループ運搬依存チェーン。100 万値を走る 1 つの累算器は和全体を直列化する。複数の累算器を使うか、 コンパイラの -ffast-math モード(FP に対する全 caveat 付き)に頼る。
  • 数値カーネル内の関数ポインタ / 仮想呼び出し。各呼び出しが間接分岐。ボディが小さければ、CPU はターゲット予測に実行より多くの時間を費やす。
  • シングルスレッドループで各反復が前回のストア値をロード。ストアロードフォワーディングはストアバッファを逃れる依存をカバーできない。 単純な算術でも IPC が 0.5 付近で止まる現象として現れる。