アセンブリと ISA 基礎
curl がバイトを送るとき send() を呼び、その呼び出しは syscall —— 機械をカーネルへ渡す唯一の opcode —— で終わるひと握りの命令になる。本 primer が辿るのは:コンパイラが値をレジスタに留めようとする理由; x86 と ARM を別の対象にした 2 つの決定;呼び出し規約が無言で壊れる 3 通り;そして越境そのもの、約 250 ns。
コンパイラが実際に吐くもの
ソースは人間のため、CPU が読むのはバイト。その間に 1 対 1 対応のテキスト形式と、使い切れるほど小さなレジスタ群がある。
コンパイラはソースをアセンブリに変え、アセンブラがそれを CPU の読むバイトに変える。int add(int a, int b) を -O2 で見よう。まだ何も吐かれていないので、図にあるのはその C 文と空のスロット 2 つだけだ;再生を押すか、スクラバを右へ進めるたびにアセンブリ命令が 1 つ吐かれ、その下の機械語バイトが埋まる:
注目してほしいのは、メモリがどこにもないことだ。引数はレジスタで届き、答えもレジスタで出るので、関数全体で 4 バイト:lea が 3、ret が 1。加算をしているのはその lea —— アドレスを計算して格納し、フラグには触らない。下の図がまず見せるのは x86-64 のエンコーディングだ;図の下でアーキテクチャを ARM64 に切り替えれば —— 同じソース、同じ形、違うバイト —— 命令スライダを進めて 1 命令ずつそのエンコーディングを光らせられる:
注目すべきは切り替えたときのバイト数だ。x86-64はここに 4 バイト、ARM64 は 8 バイト —— 同じ 2 操作に固定 4 バイト命令が 2 つ。
両版がここまで小さいのはレジスタのおかげで、 1 つ読むのはタダだ。x86-64 には 16 本しかない。「同時に生きる値」のスライダを引いてみよう:レジスタに住む値は上段の塗られたセルで、 16 を超えると下段が溢れを受け取りはじめる:
なぜなら 16 を超えるとコンパイラはスピルするからだ:スピルした値はスタックスロットをもらい、読むたびに L1 ロード ——レジスタが払う 0 ではなく 5 サイクルの load-to-use 遅延 —— になる。では割り当ての破綻が見える。請求は 1 読みごと、毎反復だ —— 「スピル読み」のスライダを 0 から引き、タダの被演算子の列が 5 サイクルのロードに変わるのを見よう:
は 40 サイクル —— 3 GHz で約 13 ns —— で、レジスタ被演算子なら 1 円も払わない本体に対してだ。レジスタ圧が学術論ではなく実務の話である理由がこれ。レジスタは 1 命令が名指しできるものの全部でもない:1 つの x86 アドレスは、ベース + スケール付きインデックス + ディスプレースメントを 1 命令で作る。ベース・インデックス・スケールの 3 本を動かせば、アドレスがテープに出る:
注目:ディスプレースメントのスライダを 0 にすれば ARM64 も 1 命令だ —— ldr x0, [x1, x2, lsl #3]。戻せば ARM は先に add が要る。A64 にはスケール付きインデックスの上に即値を足す形式がないからだ。アドレスにはどちらの ISA も制御しない第 2 のコストもある。メモリは 64 バイトのラインで届く —— 下の 2 ラインに沿って8 バイトのロードを引き、0x40 をまたがせてみよう:
転送の単位はバイトではなくラインなので、このロードを 0x40 の向こうへ引くと、1 ラインが2 ラインになる:タグ検索 2 回、ミスの機会 2 回。どちらになるかはソースのどこにも書いていない。値をレジスタに、アドレスを 1 命令に、ロードを 1 ライン内に —— それがここでのコンパイラの仕事で、確かめる場所はアセンブリだ。
x86 と ARM が実際に違うところ
1980 年代の 2 つの決定 —— 命令が何バイトか、レジスタが何本か —— が、いまだにあなたのノート PC とクラウド請求書の形を決めている。
x86-64 命令は 1〜15 バイト;A64 命令はきっかり 4 バイト。他のほとんどすべては、両マニュアルのこの 1 行から出てくる。下は実在する 7 命令の x86 基本ブロック、端から端まで 18 バイトだ:各命令は次が始まる場所で終わり、デコーダが開始するバイトはあなたの手にある。マーカーを境界から外してみる:
注目してほしいのは、バイト 2 でデコーダが作るものだ。フォールトしない:ほぼすべてのバイトが合法な opcode なので、誰も書いていない命令をデコードし、数バイト後に本物の列と再同期する。誤ったジャンプ先が「動くが正しくない」コードになるのはこの経路で、 ROP ガジェットがコンパイラの吐いた命令の内部から見つかる理由でもある。
命令長が 1 種類のテープには、そういう位置が存在しない —— 同じマーカーを A64 のテープに沿って引いてみよう。中身は同じ 7 命令、28 バイトだ:
長さが 1 種類しかないので、ARM64 では 4 の倍数でないオフセットは「間違った命令」ではなく PC アラインメント例外で、コアは拒否する。その保証はフロントエンドで払われている。
下の 2 本のテープは同じ 7 命令を同じバイト尺で持ち、自分の命令がどこから始まるか知っているのはA64 側だけだ:サイクルのスライダを進め、まだ切り分けられないバイト列からx86 の境界が 1 サイクルに 1 つ、左から右へ決まっていくのを見よう:
長さ走査は ARMに対応物のない部分で、しかも初回のコストであって定常コストではない:図をウォームに切り替えれば 7 つの境界はサイクル 0 で揃う。プリデコード段が長さマークを命令キャッシュに書き、 uop キャッシュは長さ問題そのものを飛ばすからだ。 x86 のホットループが ARM の 7 分の 1 で走らない理由がこれで、冷えた分岐先がそうなる理由でもある。
ウォームをタダにするシリコンこそ ARM が作らずに済むものだ。x86 がそれで買っているのは密度 —— 命令数のスライダを引き、2 本のバーを見比べよう:
比を見よう:同じ 7 命令 —— いま動かした 2 本のテープ —— で 18 バイト対 28 バイト、つまりこのブロックでは ARM64 が 56% 大きい。ただし定数ではない。命令数をまで押せば差は動く。答えは「そのブロックにたまたま何本の 1 バイトret と 5 バイト call があるか」の平均だからだ:固定 4 バイトが x86 の callに勝つ回数と、ret に負ける回数が釣り合う。
レジスタ数では取引が逆向きに走る —— 生存値の数を引き、ARM64 の 31 本と x86-64の 16 本で、どちらが先にスピルするか見よう:
ARM64 は汎用レジスタ 31 本、x86-64 は 16 本なので、スピルが始まる生存値の数はおよそ倍になる。 20 では x86 は 4 つのスタックスロットを払い、ARM はまだゼロだ。レジスタを食うコード —— JIT された JavaScript、インタプリタのループ —— はこれを直接感じる。
プログラムを壊す違いは、このどちらでもない。下ではフラグのストアとデータのストアが「見える順序」の軸に乗っている;モデルは TSO のまま、フラグを先行させようとするスライダを引いてみよう:
注目すべきは TSO が拒むことだ:ゴーストがストアの行き先を示し、矢印がそれを戻すので、読み手は必ず 42 を見る。ARM の弱順序に切り替えてを引けば、今度はフラグが先に見え、読み手は 0 を見る —— コードは 1 文字も変わらずに。
無言で失敗するので、手元では通り、Graviton が負荷でゴミを返す。直し方は release / acquire —— 両アーキともに。
呼び出し規約
レジスタとスタック配置の 1 つの取り決めが、どの言語のコードでも互いに呼べるようにする。スタックトレースと FFI はこれの成立に依存する。
Linux と macOS の x86-64 ではそれが System V AMD64 ABI で、 Windows は独自、Apple の ARM64 は AAPCS64 の変種だ。形は一致する: SysV は最初の 6 つの整数引数にレジスタを 1 本ずつ固定順で与え、以降はスタックへ置く。引数の数を 6 より先へ引く:
7 個目で何が変わるかに注目。6 個までメモリには一切触れない:どの引数もすでに呼び出し先が見る場所にある。は呼び出し前のストアと後のロードを 1 回ずつ、以降は 1 個ごとに 8 バイトのスタックを足す —— 仮引数 9 個のホット関数が構造体ポインタ版に負ける理由だ。いずれにせよ、呼び出し先がローカルを要求した瞬間にメモリは登場する。
下の図は何も積まれていない状態で開き、呼び出し先はスタックを 1 バイトも持たない;再生を押すかスクラバを進め、フレームが下へ伸びるのを見よう —— 各スロットの高さは、そのバイト数そのものだ:
call が戻り番地を push し、 prologue が rbp を push してフレームを引き、 epilogue が両方を戻し、ret が pop してジャンプする。スタック上のすべてのフレームがこの形で、だからデバッガは辿れる。その引き算も自由ではない —— ABI はすべての呼び出し地点でrsp が 16 バイト整列していることを要求する。ローカルのスライダを 16 の倍数から外し、フレームの末尾にパディングが現れるのを見よう:
注目すべきは、パディングが 15 バイトを超えず 0 を下回らないことだ:では消える。96 はすでに 16 の倍数だからだ。そのバイトが、3 段深い呼び出し先でスタック上のベクタに対するmovaps をフォールトさせない:整列を忘れた手書きアセンブリは、呼んでもいない関数でクラッシュする。契約のもう半分は、呼び出しがどのレジスタを壊してよいかだ —— スライダでレジスタ群をなぞり、それぞれの約束を読もう:呼び出し先が戻すか、呼び出し元の問題か:
7 本は呼び出しを生き延びる。呼び出し先が復元を約束するからだ;残る9 本は壊されうるので、またいで使いたい呼び出し元は自分で先に退避する。この分割は双方が相手の知らないことを知っているから存在する —— 呼び出し元はまだ要る値を、呼び出し先は触るレジスタを知っている。
リーフ関数にはもう 1 つ、128 バイト分の近道がある —— スクラッチの大きさを引き、レッドゾーンの端を越えさせてみよう:
128 バイト以内なら、レッドゾーンのおかげでリーフ関数は rsp を一切動かさずスクラッチを使える —— 呼び出しごとに 2 命令の節約だ。超えて引けば prologue が戻ってくる。シグナルハンドラはこの領域に触れてはならず、シグナル安全なコードが通常と違う数少ない場所の 1 つだ。
ここまでは双方の合意が前提だ。下では呼び出し先が約束どおりの下位ビットを読んでいる;宣言幅を下げ、上半分に何か置いてみよう:
ABI は狭い引数を下位ビットに置き、上位ビットを未定義のままにする。 C 関数が long を取るところで int と書けば、呼び出し先はビット 31 より上に直前の命令が残したものを読む —— 今日は 0、明日の無関係な変更のあとでは 0xdead だ。クラッシュはしない;間違った数を返す。
規約の破れにはもう 1 つ隠れ場所があり、そちらも無言だ —— スタックの深さを引き、ウォーカがどこまで辿れるか見よう:
では、歩きは -fomit-frame-pointer(-O2 の既定)のフレームに達しており、rbp の鎖は切れ、スタックの残りは消える。DWARF は全フレームを記述できるが、代償は 1 フレームあたりの表引きだ。 Fedora と Ubuntu が 2023〜24 年にフレームポインタを既定へ戻した理由がこれだ。
特権を変える唯一の命令
何百もの opcode のうち、機械をカーネルへ渡すのは 1 つだけ。外界に対する仕事はすべてそこを通り、しかも安くない。
x86-64 ではその命令は syscall、ARM64 では svc #0。他はユーザモードで走り、カーネルが割り当てたものしか触れない。下の図は、どのレジスタもユーザ側の値を持ったまま開く;再生を押すか、スクラバを 1 手進めて、上の 5 行が一斉にカーネル側の値へ裏返るのを見よう —— それがこの命令のすべてで、「一度に」が肝だ。ユーザコードがカーネル特権を持つ瞬間は存在しない:
注目すべきは、最初の 1 手に入っていないものだ。syscall が書くのは rcx、r11、rflags、セグメントセレクタ、rip だけ:退役した時点で CPU は ring 0 にいながら、なおユーザスタックの上を、ユーザのページテーブルで走っている。続く 3 手は entry_SYSCALL_64 の普通の命令で、真ん中が SWITCH_TO_KERNEL_CR3 —— Meltdown 以前このマクロは空に展開された。カーネルは各プロセスに写像されており、入口でページテーブルは変わらなかったからだ。いま空でないのは KPTI のせいで、2018 年に越境が高くなった主因でもある。
中に入ったカーネルは、これがどの呼び出しかをまだ知らない —— スライダで表をなぞり、raxの番号が 1 つを選ぶのを見よう:
rax の番号が関数ポインタ配列sys_call_table を索き、引数はrdi, rsi, rdx, r10, r8, r9 で届く —— 通常の規約が rcx を使う位置が r10 なのは、syscall 自身が戻り番地を rcx、フラグを r11 に置くからだ —— いま書くのを見た 2 つだ。番号は恒久 ABI で、だから read は今も 0。
越境の代金をどれだけの頻度で払うかは設計上の決定だ —— チャンクを下へ引き、遷移がコピーからバーを奪うのを見よう:
なら 1 MB が 1,048,576 回の越境、約 262 ms の純粋な遷移になり、実際のコピーの約 105 µs と対比される —— オーバーヘッドが請求の 99.96% だ。 64 KB なら 16 回・4 µs で、今度はコピーが支配する。バッファ付き I/O が存在する理由はこれで全部だ。
1 回あたりの単価はカーネルの起動方法にも依存する:下では 1 コアの 1 秒が 100 マスで、毎秒 100 万回のとき越境はすでに 25 マスを取っている。レートを動かし、それから分離を切ってみよう:
分離は 1 越境あたり約 250 ns、無効なら 70 ns。毎秒 10 万回のシステムコールを出すサービスは境界だけでコアの約 2.5% を、毎秒 100 万回なら目の前の 25 マスを費やす。まで押せば、そのコアは他に何もしない。
分離の無効化は解ではない —— 越境を減らすことが解だ。下では投入リングが満杯で、1 回のio_uring_enter が 64 個を運ぶ;バッチを下へ引き、下段の越境の列が伸び戻るのを見よう:
1 回の io_uring_enter あたり 64 操作をまとめれば 1,024 回の越境が 16 回になる;では列が埋まり、要素ごとに 1 システムコールへ逆戻りだ。SQPOLL に切り替えれば越境はゼロになる:カーネルスレッドがリングをポーリングし、ユーザ空間は共有メモリに書くだけだ。代償はコア 1 つが回り続けることで、キュー深度が高ければ見合い、低ければ見合わない。
何も払わない方法がもう 1 つあり、鍵はデータが線のどちら側にあるかだ —— 呼び出しレートを引き、ユーザモードに留まる経路と越えていく経路を見比べよう:
なぜなら vDSO のページが各プロセスへ写像されているからだ。呼び出しは下ではなく横へ進み、ring の境界を一度も越えない —— 250 ns ではなく約 25 ns。ループの中でタイムスタンプを取るもの —— トレーサ、メトリクスライブラリ、行ごとにコミット時刻を取るデータベース —— はこれの上で生きていて、たいてい本人はそれを知らない。そして無くなった日(カーネルが公開しない clocksource、seccomp フィルタ、そのページを持たないコンテナ)、コードを 1 行も変えずにスループットは 1 桁落ちる。
クイックリファレンス
経路全体を 1 つの図に、あらゆるコストを 1 本の軸に、そしてレビューで拾う価値のある赤旗を 5 つ。
send() から回線までの間で実際に何が起きるのか?
6 手、そのうち特権を変えるのは 1 手だけだ:libc が引数を載せ、eax に 44 を置き、命令を 1 つ実行する。以降はすべてカーネルで走る。下へ辿ってみる:
注目してほしいのはユーザコードの少なさだ: 6 命令、どれも測る価値はない。越境だけで約 250 ns、その後のエラー検査は 2 行だ:
mov eax, 44 ; sendto syscall ; ring 3 -> 0 cmp rax, -4095 ; エラー? jae __syscall_err ret ; rax = 送ったバイト数
引数を運ぶレジスタは?システムコールとの違いは?
通常の呼び出しは rdi, rsi, rdx, rcx, r8, r9、浮動小数は xmm0–7、戻り値は rax;呼び出し先保存は rbx, rbp, rsp, r12–r15 の 7 本で、rspも 1 本だ。スタックポインタを別の場所に置いたまま返る関数は、レジスタを壊したのではなく呼び出し元を壊している。システムコールは同じ 6 本の引数レジスタだが rcx の代わりにr10 を使う。syscall が壊すからだ。このページが名指ししたコストを 1 本の軸で、スライダでなぞろう:
vDSO とシステムコールの 10 倍を決めるのは、境界を越えるかだけだ。
いつ元が取れるのか?
perf report は時間を行ではなく命令に帰属させる。
- clobber なしのインライン
asm。 割り当て次第で、無言で起きる。 - 順序のないロックフリー。
memory_orderを名指しする。 - 幅違いの FFI 宣言。生成する。
- 要素ごとのシステムコール。105 µs 対 262 ms。
- 単一アーキのイメージ。
buildx --platform linux/amd64,linux/arm64。