はじめに
有給謳歌中なので、この機会にコンピューターアーキテクチャについて学び直そうと思い、パタヘネ本を読んでみました。
ボリュームが多いので、この辺復習する上で個人的に大事だと思う観点でまとめてみます。
筆者の背景知識
私自身は情報系の学部出身で、学生時代にFPGAで回路組んで簡単なCPUを作って、そこに自作のアセンブラ、コンパイラ(mini版C言語)を書いてC言語プログラムを実際に流し込む、みたいなことは経験しているため、パタヘネ本で書かれている基礎的な部分はだいたいイメージがついているという状態です。
※学生時代にその辺をしっかり学べたのは、今考えてもすごく良い経験だったなと思います
ただ、昨今の大規模データ処理・大規模AIモデル推論などで行われている最適化や、HPCとかの文脈をしっかりと知るには再度キャッチアップが必要だろうと思い、この機にこの辺も読んでみようと思ったという経緯です。
パタヘネ本は現在では第6版までになっているということで、GPU、TPUとかの話も結構盛り込まれているようです。本自体はめちゃくちゃボリュームが多いので、このブログではまず上巻(一部下巻の付録の内容も触れる)について備忘録として整理しておきます。
※深層学習に関連しそうな部分でいくと下巻が多そうなので、後編はまた別途整理して書きます。
備忘録
1章 コンピューターの抽象化とテクノロジ
導入的な話が多いのでほとんどスキップしますが、要点だけメモしておきます。
CPU性能
CPUの性能を考える時、あるプログラムを実行するのに必要なCPU実行時間は以下の式で表せる。

ハードウェア設計者の観点で性能を向上させるには、要はクロック周波数を上げるか、プログラム実行に必要なクロックサイクル数を減らせば良いことになる。
クロックサイクル数を減らすというのは、プログラム実行に必要な命令数を減らすか、命令あたりのクロック数を下げるというところになってきて、要はそのCPUや回路で実行できる命令の設計部分に関わる話。先ほどの式を分解すると以下のようになる。

あるプログラム実行に必要な命令数を減らすには、例えば、より複雑なことを可能な命令を実行できるように回路を組めば良いという話になると思いますが、そうすると大抵の場合はクロックサイクル時間をより多く取らなければいけなくなるため、簡単にはいかない話になります。
※その命令は早く実行できるようになるかもしれないが、全体の速度を下げてしまう。1つの観点だけでなく全体で最適化を考えなければいけないのがCPU設計の難しい点
命令セットが同じなら、あるプログラム実行に必要な命令数は同じになるため、CPI(命令あたりの平均クロックサイクル数)がCPU性能比較の一つの指標となります。
いずれにせよ、上式はCPU性能に影響する主要な3つの変数の関係を表しているので基本公式押さえておくべきもの。
※CPU性能で一般的なクロック周波数だけの比較では意味がない
ユニプロセッサからマルチプロセッサへの方向転換
本筋ではない歴史的背景の話だが、ユニプロセッサからマルチプロセッサに変わったのは、単に高速化のためだと思っていたが、実はマイクロプロセッサの冷却能力の制約によって、電力の限界にぶつかったことが理由ということだった。
電力=エネルギーであり、一般に消費電力を上げていくことで性能(クロック周波数)も上昇傾向にあったが、そこの限界に達したため、性能を上げるためにマルチプロセッサという方向転換になったそう。
マルチプロセッサになると、プログラムの実行性能を上げることに対して、プログラマに従来と異なり並列処理に関する工夫を強いることになるため、昔はコンピューターアーキテクチャ上のタブーとされてきたそう。
※今では当たり前になってきているため、転換には成功したと言えそう
2章 命令:コンピューターの言葉
ここでは、命令セットとしてMIPSを具体例として取り上げ、実際にどのような仕組みで命令が実行されるかを解説してくれている。
例えば、MIPSのアセンブリ言語で加算は以下のように表す。
add $s0 $s1 $s2
ここで、$s0, $s1, $2はレジスタを表す。この命令では$s1レジスタで保持されている値と$2レジスタで保持されている値を加算して$s0レジスタに反映させることを意味する。
上記はMIPSで用意されているアセンブリ言語の表記だが、これが実際には以下のフォーマットの32ビットのバイナリで表現される(MIPSで想定するレジスタ(32ビット)に格納できるサイズ)。
- op(6ビット): 命令操作コード(add, sub, lwとか)
- rs(5ビット):第1ソースオペランドのレジスタ番号
- rt(5ビット):第2ソースオペランドのレジスタ番号
- rd(5ビット):結果を収めるオペランドのレジスタ番号
- shamt(5ビット):シフト量(シフト命令で利用)
- funct(6ビット):機能コード
プログラマが書いたコードは、実際には上記のようなCPUで実行可能な大量の機械語命令に書き換えられて、実行されることになる。
命令は基本的に指定レジスタにある値について、組んだ回路を使って何らかの作用(算術演算、論理演算、シフト等)を行うことである。メモリからデータをレジスタにロードしたり、逆にレジスタからメモリにストアする命令もある(それぞれlw, sw)。
レジスタにも種類や数に限りがあるため、実際のプログラムの実行の際にはメモリを適切に組み合わせて命令が実行される。
- 設計原則1:単純性は規則性につながる
- 命令で使うオペランドの数が固定もしくは一定の範囲で決まっていれば、命令自体の表現を行う際も固定長で表現できるという大きな利点がある => ハードウェアの単純性を保つことができる
- 特に命令自体も、できるだけオペランドと同じビット数で表現するように設計されているのが基本
- 設計原則2:小さければ小さいほど高速になる
- 例えばレジスタの数やビット数を増やせば増やすほど効率よくプログラムが実行できるように思えるが、それによるクロックサイクル速度や消費電力などへの影響も考えなければいけない
- 加えて、レジスタの数が増やすと5ビットでは表現できなくなる問題もある
- 設計原則3:優れた設計には適度な妥協が必要
- 全ての命令を同じフォーマットにすると、現実的に表現するためのビット数が足りず不都合が起きる(lwでのメモリ上のアドレス指定など)
- 全ての命令で同じフォーマットにするのを妥協し、命令によってフォーマットを変えるように設計されている(R形式、I形式など)
ある命令セット・アーキテクチャの性能を最大限発揮するには、十分な数のレジスタを備えており、コンパイラはレジスタを効率よく使用しなければならない。=> 逆にいうと、レジスタの数次第で最高の命令セット・アーキテクチャは変わるとも言えそう(CPU設計の観点でいくと)
条件分岐・ループ
基本的には条件判定用の命令(beq, bneなど)と、ジャンプ命令(j: 指定のアドレスに飛ぶ)をうまく組み合わせることで実現されている。
アドレスには人間が扱いやすいようにラベルを付与できるため、それを使用してj Elseなどのように命令を書く。
コンパイラによって行われている最適化についても多少書かれている(命令の配置順序など)。
手続き(関数)呼び出し
関数呼び出しは、呼び出し元のアドレスや、引数とか戻り値を記録する用にレジスタが用意されているので、それを活用して命令を組み立てる。
流れとしては以下のようなフローになる。
- 手続きからアクセスできる場所にパラメータを置く
- 手続きに制御を移す
- 手続きの命令があるアドレスにジャンプ
- 使用するレジスタの現在の値をメモリに退避(スタック)
- 手続きに必要なメモリ資源を入手する
- 必要なタスクを実行する
- 呼び出し元のプログラムからアクセスできる場所に結果を置く
- 制御を元の位置に戻す
- メモリに退避した値をレジスタに復元
- jr命令を使って元のアドレスにジャンプ
他にも、入れ子になった関数呼び出しについても解説がある。基本的にはスタック構造上で積んでいき、終わったら順に取り出していく。スタックポインタに正しく最新の参照先アドレスを記録しておく。
引数の数が用意されたレジスタの数を超える場合、手続き内の変数がレジスタの数を超える場合もスタックが使われる。そのほか、動的にサイズが変わるものはヒープ領域が割り当てられて使われる。
32ビットの定数およびアドレスの扱い
命令は32ビットに保てばハードウェアが単純化されるという話をしたが、32ビットの定数やアドレスを扱えた方が便利な場合もある。
32ビット定数定数
一方で、定数(即値)の場合は16ビットで想定して設計されている。そのため、32ビットの定数を扱いたい場合は、16ビットずつ2回に分けてレジスタにロードする形で32ビットの定数を作る。
32ビットアドレス
アドレスの場合は、基本的に条件分岐や関数呼び出しでジャンプする先を指定するためのものであり、16ビットでは現在のプログラムを実行する際のジャンプ先数として当然足りないことが多い。そのため、32ビットを使うことになる。
条件分岐の場合は例えばbne命令などでジャンプ先のアドレスに使えるビット数は16ビットしか残っていないので、拡張する必要がある。
ここで、条件分岐の場合はジャンプ先も現在のアドレスの近くにあることが一般である(条件分岐の命令)。つまり、現在のアドレスからの差分であれば大抵の場合16ビットで表せることになる。
そのため、現在のアドレスを指すプログラムカウンタ値(PC, 32ビット)に分岐先アドレス(16ビット)を加算することで対応する(PC相対アドレシング)。
関数呼び出しの場合は、現在のアドレスの近くにあるとは限らないため、32ビットのアドレスにランダムアクセスできるのが望ましい。ただし、関数ジャンプの場合は初めからjump命令を使うので、26ビットでアドレスを指定できる。
他にもアドレシングの方式は色々あるがここでは省略。
※なお、MIPSの命令長は4バイトと決まっているので、アドレスにおいても次の命令までのバイト・アドレスではなく、語アドレスを表している(これにより、より遠くまで表せる)。
コメント
コンパイラによる最適化の文脈で、コードレイアウト最適化と呼ばれるものもあるが、ここで解説されたようにジャンプ先をできるだけ近くにして少ないビット数で収まるようにするとかも、重要な最適化処理の一つ。
goとかでプロファイラーかませてビルドとかやったことはあるが、特にこの辺はより良くなるんじゃないかと思われ(Profile-Guided Optimization)。
コンパイラでやられてる他の最適化はここに色々書かれてる。
=> コンパイラ最適化 - Wikipedia
プログラムの翻訳と起動の流れ
CとJavaの違い。


その他
その他以下の話題にも触れているが省略。
CとJavaの違い、MIPSと、ARMv7/v8、RISC-V、x86の違い、x86の歴史など。
3章 コンピュータにおける算術演算
基本
算術演算特有で起こる問題であるオーバーフローについて、発生条件や判定方法などが解説されている。
その他、各種算術演算を実現するためのハードウェア、論理回路の概説や、高速化の歴史なども解説がある。
ここでは、MIPSにおいて実際に算術論理演算(AND、OR、加算、減算)を行うための基本部品であるALUを解説する。
1ビットALU
ALUは基本的に4つの信号を受け取り、2つの信号を出力する。
具体的には、入力としてa, b, キャリーイン, 制御入力を受け取り、出力として演算結果とキャリーアウトを返す。
キャリーインは下位の桁からの繰り上げで、キャリーアウトは上位の桁への繰り上げである。
制御入力はALUに依頼する操作を指定するために、下図のマルチプレクサに渡され、求める演算結果を出力させる。

実際にはもう少し複雑な機能を持たせるために、以下のようになる。

32ビットALU
上述したALUを32ビット分並べて組み合わせて使います。

実際には、ゼロ判定回路なども追加されている。ALUで可能な演算と制御信号は以下の通り。

この基本部品であるALUを使って、乗算や除算など他の演算の回路も組み立てられています(乗除算はシフトも組み合わせている)。
ちなみにこの構成だと、各ALUが隣のALUの繰り上げを受け取れる必要があるため、一つの加算を実行するにも32ステップが必要になります。
これでは効率が悪いので、実際には高速化のための工夫もされています。
浮動小数点数
浮動小数点数は、小数を含む実数を表現でき、さらに非常に大きな値や小さな値を効率よく表現できます。以下のような表記のイメージです。
1.234 x 104
2進数の場合は以下のような感じを想定してください。
1.101 x 25
ここで、浮動小数点数を表すには、仮数部と指数部という概念が存在します。
上記例では、仮数部は1.101、指数部は5となります。要は仮数部と指数部を表せれば実数を表せます。
32bitの浮動小数点(float)の場合、以下のように対応します。
[符号1bit][指数部8bit][仮数部23bit]
仮数部を多くとった方が有効桁数(つまり精度)は上げれますが、その分指数部は減って表せる範囲は狭くなります。この辺りはトレードオフになっており、どちらを優先するかでいくつかの型が用意されています。

機械学習周りでよく出てくるbfloatは、精度は下げてでもできるだけ表現範囲を広くするという考え方です。
機械学習では計算量やメモリ使用量を抑えるために少ないビット数で数値を表現したいが、オーバーフローやアンダーフローが発生すると困るという都合があります。逆に精度は多少ずれても問題になりにくかったため、よく使われていたと思います。
※ただし、LLMになってからは精度も求められてむしろ通常floatに戻ってきている
なお、演算操作は浮動小数点数の場合には扱いがさらに複雑になります。
SIMD
この辺りは普段のプログラミングにおいても重要になってくる部分。
"SIMD = Single Instruction, Multiple Data"であり、"1つの命令で、複数のデータを同時に処理する"概念です。
例えば、256ビットのレジスタがある場合、単精度のfloat値は4つ入れることができる。演算命令もその4つに対してまとめて行うことで、1ステップで4つ分の演算を完了させることができる。汎用のレジスタは基本64ビットだが、SIMD用に使われるレジスタだと現在では512ビットやそれ以上のものも出てきている。
SIMD用のレジスタ・演算を使うことで、配列計算、画像処理、音声処理、機械学習など、様々なベクトルや行列データを扱うような様々な処理で高速化が可能。GPUほどではないが、CPUでもある程度行列演算を高速化することが可能。
pythonでnumpyを使うと裏でこの辺の最適化を勝手にしてくれている。
4章 プロセッサ
ここでは、概要だけ触れておきます。
クロック方式
プロセッサは基本的にクロック信号に同期して動作するように設計されています。クロックが1回進むごとに、各回路が一斉に状態を更新することで、プログラムの実行が進んでいきます。
クロックは固定のサイクル時間で、電圧の高圧を切り替えて送られてくる信号です。このクロックの立ち上がりもしくは立ち下がりだけをアクティブとして、状態変化を引き起こすのが、現在主流のエッジ・トリガー方式です。


クロックのエッジのタイミングで、その時の状態の書き出しが行われます。そのため、エッジのタイミングではすべての信号が安定している必要があります。つまり、命令操作で経由する組み合わせ回路内のすべての信号が安定するのに十分なクロックサイクル時間を設定する必要があります。

この制約がクロック周期の下限を決定するため、設計原則で説明されたように、闇雲に複雑な回路を組むわけにもいかなくなってきます。複雑な命令を1クロックで可能にする回路を組んだとしても、クロックサイクル時間を増やさなければいけなくなると全体の命令実行に影響を及ぼします。この辺りがCPU設計の難しさの一つです。
一方で応用として、実際には1クロック・サイクルすべてを要さずに完了できる操作も一部あるが、そのような場合には、立ち上がりではなく立ち下がりも利用して、半クロック・サイクルで対応できるようにしている技術もあります(後述するパイプライン)。
フリップフロップ
状態を保存するためのメモリ要素の基礎的なものとして、フリップフロップがある。
フリップフロップは基本的にレジスタを構成するために使われる。
フリップフロップの実態は以下の通りで、クロック信号とデータ信号を受け取る回路によって構成されている。
Dフリップフロップの構造(※ChatGPTで生成)
このように自身の出力を受け取るような回路にすることで、メモリ要素として機能させることができる。これを32ビット分用意してレジスタを構成する。
省略するが、SRAMも似たようなフィードバックループによる回路として組まれている(用途は異なる)。DRAMはコンデンサとトランジスタが記憶の主要素。
パイプライン
下図で説明されているように、命令実行にはいくつかのステップがあるが、各ステップを分類すると使うリソースが異なることが分かる。アイドル状態になってしまっているリソースを有効活用するために、複数命令を並列でパイプライン的に実行することで、CPUの性能を向上させることができる。

だだし、実際にはそう簡単に性能を向上させることが可能なわけではなく、いくつかハザードと呼ばれるような問題が生じたり、ハードウェア的な制約もあります。この辺は複雑な話になってくるのでここでは省略します。
感想
ざっくりポイントを絞って整理しました。
学生時代にCPU構築した際にも、性能向上のために色んな工夫とか考えたりしたことはありますが、汎用的に性能を向上させるのは至難の業だということが改めて分かりました。
一方で、ドメイン特化の方向であれば結構色んな工夫ができそうで、改善をあーだこーだ考える分には楽しそうだなと思いました(もちろん実現性含めると簡単な話ではないですが)。
下巻はGPU含め、その辺の話にまで踏み込んでいるそうです(こちらも時間ができたら読む予定)。