プログラミング
RISC-Vエミュレーションの限界を押し広げる
Pushing the limits of RISC-V emulation (shuklaayu.sh)
要約
本記事では、RISC-Vプログラムが非RISC-Vマシン上でベアメタルパフォーマンスにどれだけ近づけるかを解説します。鍵となるのは、生成された基本ブロックをテイルコールで接続し、Clangのpreserve_none呼び出し規約を使用してホットなゲスト状態をホストレジスタに保持する、Ahead-of-Time(AOT)リコンパイラです。これにより、エミュレーション速度のボトルネックを解消し、実行速度を大幅に向上させることが可能になります。
全文翻訳
TL;DR本稿では、RISC-Vプログラムが非RISC-Vマシン上でベアメタルパフォーマンスにどれだけ近づけるかを考察します。鍵となるのは、生成された基本ブロックをテイルコールで接続し、Clangのpreserve_none呼び出し規約を使用してホットなゲスト状態をホストレジスタに保持する、Ahead-of-Time(AOT)リコンパイラです。
最近、Axiomで開発中のRISC-V仮想マシンであるOpenVMに取り組んでいます。これはプログラムを実行し、その出力とともに、実行が正しかったことを示す簡潔な暗号学的証明1を生成します。Axiomでの私の在籍期間中、証明生成は単一CPUからGPUクラスタへとスケールし、ワークロードを複数のノードに分散させてきました。しかし、最初のステップは変わっていません。プログラムを実行し、その実行を記録する必要があります。証明フェーズは非常に並列化可能で、追加のハードウェアでよくスケールしますが、実行は本質的に逐次的です。証明がスケールし続けるにつれて、実行速度がますますボトルネックになっており、この記事はそのステップを高速化することについてです。
実行について調べ始めたとき、単純な疑問に立ち返りました。プログラムはどれくらいの速さで実行されるべきか? OpenVMはRustのような通常の言語からコンパイルされたRISC-Vプログラムを実行するため、同じプログラムを私のマシン用にネイティブにコンパイルし、それをベースラインとして使用できます。M3 MacBookでは、ネイティブのarm64プログラムは約100ミリ秒で完了しましたが、同じプログラムはインタープリタを介すると3〜4秒かかりました。仮想マシンは、それを介して実行することには必然的にオーバーヘッドが加わるため、遅くなるだろうと予想していましたが、桁違いではありませんでした。
プログラムを実行する最も速い方法は、それに邪魔させず、ハードウェアに直接実行させることです。仮想マシンは、実行を制御し、ゲスト2とホストの間のギャップを埋める必要があるため、プログラムをハードウェアに単純に渡すことはできません。代わりに、プログラムを解釈するか、ホストが理解できる命令に変換します。この記事の残りの部分では、基本的なインタープリタから始めて、ゲストとホストの間のオーバーヘッドのレイヤーを段階的に除去しながら、このアイデアをどこまで押し進めることができるかを掘り下げます。
インタープリタのベースライン
RISC-V3コードを実行する簡単な方法は、古典的なフェッチ-デコード-実行サイクルをループするインタープリタを使用することです。
```carm64 assembly
while (running) {
uint32_t word = fetch(memory, pc);
Instruction inst = decode(word);
pc += 4;
switch (inst.opcode) {
// dispatch + execute
case OP_ADDI:
write_reg(inst.rd, regs[inst.rs1] + inst.imm);
break;
case OP_ADD:
write_reg(inst.rd, regs[inst.rs1] + regs[inst.rs2]);
break;
// ...
}
}
```
`L_dispatch:`
` ldr w9, [x20, x19] // fetch the guest word`
` add x19, x19, #4 // advance the guest pc`
` bl decode // decode its fields`
` ldr x10, [x21, x0, lsl #3] // select the opcode handler`
` br x10 // dispatch`
`h_add:`
` ldr x9, [x22, #t0_offset]`
` ldr x10, [x22, #t1_offset]`
` add x11, x9, x10`
` b L_dispatch // return to the loop`
問題は、ゲスト命令がしばしば非常に小さいことです。加算やシフトは単一の算術演算しか実行しないかもしれませんが、インタープリタは命令をフェッチし、そのフィールドをデコードし、スイッチで正しいケースを選択し、その過程で独自の実行状態を維持する必要があります。インタープリタは、命令を実行するよりも、命令を管理するのに多くの時間を費やす可能性があります。
`switch-case`
`fetch`
`decode`
`dispatch + execute`
`fetch`
`decode`
`dispatch + execute`
例:デコードされたRISC-V命令
R型命令(例:add rd, rs1, rs2)は、フィールドに固定レイアウトを使用します。
`+--------------+----------+----------+--------+----------+--------------+`
`| funct7 | rs2 | rs1 | funct3 | rd | opcode |`
`+--------------+----------+----------+--------+----------+--------------+`
`| 31:25 | 24:20 | 19:15 | 14:12 | 11:7 | 6:0 |`
`+--------------+----------+----------+--------+----------+--------------+`
オペコードは命令のエンコーディングクラスを識別します。funct3およびfunct7フィールドは、クラス内の特定の操作にさらに絞り込み、rd、rs1、およびrs2は関係するレジスタを識別します。
例えば、x5 = x6 + x7 を計算する add x5, x6, x7 は、これらのフィールドを次のように埋めます。
`+--------------+----------+----------+--------+----------+--------------+`
`| funct7 | rs2 | rs1 | funct3 | rd | opcode |`
`+--------------+----------+----------+--------+----------+--------------+`
`| 0000000 | 00111 | 00110 | 000 | 00101 | 0110011 |`
`+--------------+----------+----------+--------+----------+--------------+`
ここで、rd、rs1、rs2はそれぞれレジスタx5、x6、x7を参照します。オペコード、funct3、およびfunct7フィールドは、加算操作をまとめて選択します。
サブ命令は同じオペコードとfunct3を使用しますが、funct7を0000000から0100000に変更します。
事前デコード
毎回同じ命令をデコードするのは不要です。プログラムコードは通常静的4であるため、各命令を一度だけ事前にデコードし、その結果を再利用できます。これが事前デコードです。命令ごとに、その命令が何をするか、オペランド、およびそれを実行する小さな関数(ハンドラ)へのポインタを格納します。
```carm64 assembly
typedef struct Decoded {
void (*handler)(State *, const struct Decoded *);
uint8_t rd, rs1, rs2;
int32_t imm;
} Decoded;
void h_addi(State *state, const Decoded *inst) {
state->regs[inst->rd] = state->regs[inst->rs1] + inst->imm;
}
while (running) {
const Decoded *inst = fetch(code, state->pc);
state->pc += 4;
inst->handler(state, inst); // dispatch, execute
}
```
`L_loop:`
` ldr x19, [x20, #pc_offset] // read the guest pc`
` lsr x9, x19, #2`
` ldr x10, [x21, x9, lsl #4] // fetch decoded handler`
` add x19, x19, #4`
` str x19, [x20, #pc_offset] // publish the next pc`
` blr x10 // call handler`
` b L_loop // return to the loop`
`h_add:`
` ldr x9, [x20, #t0_offset]`
` ldr x10, [x20, #t1_offset]`
` add x11, x9, x10`
` ret // return to the loop`
デコードはもはやホットループの一部ではありません。残っているのは、間接呼び出しによるディスパッチです。各命令は、インタープリタが次に実行するものを選択できるように、毎回共有ループに戻る必要があります。これに対する古典的な修正は、計算済みgoto5です。共有ループに戻る代わりに、各ハンドラはテーブルで次のオペコードのラベルを検索し、goto *で直接ジャンプします。事前デコードされた表現は、以前よりもここでシンプルになります。ハンドラポインタはなく、単に最初にセクションにあったのと同じ命令が、配列に一度デコードされ、pc / 4でインデックス付けされます。各オペコードは、ディスパッチターゲットとして独自のラベルを持ちます。
```carm64 assembly
Instruction prog[NSLOT]; // decoded once, indexed by pc / 4
void *targets[OP_COUNT];
targets[OP_ADDI] = &&do_addi;
targets[OP_ADD] = &&do_add;
// ...
Instruction inst = fetch(prog, state->pc);
goto *targets[inst.opcode];
do_addi:
state->regs[inst.rd] = state->regs[inst.rs1] + inst.imm;
inst = fetch(prog, state->pc += 4);
goto *targets[inst.opcode];
do_add:
state->regs[inst.rd] = state->regs[inst.rs1] + state->regs[inst.rs2];
inst = fetch(prog, state->pc += 4);
goto *targets[inst.opcode];
// ...
```
`L_dispatch:`
` lsr x9, x19, #2`
` ldr w9, [x20, x9, lsl #4] // fetch decoded instruction`
` ldr x10, [x21, x9, lsl #3] // look up its label`
` br x10 // computed goto`
`h_add:`
` ldr x9, [x22, #t0_offset]`
` ldr x10, [x22, #t1_offset]`
` add x11, x9, x10`
` add x19, x19, #4 // advance the guest pc`
` b L_dispatch // fetch and dispatch the next one`
各命令は依然としてフェッチおよびディスパッチされる必要がありますが、インタープリタはもはや各ハンドラに対して関数呼び出しとリターンを実行しません。代わりに、各ハンドラはディスパッチシーケンスに制御を移し、次のハンドラを選択して直接ジャンプします。したがって、実行は間接ジャンプを介して、リターンではなく、ハンドラからハンドラへと移動します。
基本ブロックの接続
上記のアプローチでは、各命令は依然として独立して処理されます。しかし、多くの命令は連続して実行されるため、それらをまとめて処理できます。たとえば、`add x5, x6, x7` の後に `add x8, x9, x10` が続く場合、これらは単一の基本ブロックと見なすことができます。このブロック全体を一度にコンパイルし、その結果をホストマシンで直接実行できます。
このアプローチは、Ahead-of-Time(AOT)コンパイルと呼ばれます。ここでは、RISC-Vコードをホストアーキテクチャ(この場合はARM64)のネイティブコードにコンパイルします。これにより、インタープリタのオーバーヘッドが大幅に削減されます。
リコンパイラ
リコンパイラは、ゲスト命令をホスト命令に変換するプログラムです。ここでは、RISC-V命令をARM64命令に変換します。この変換プロセスは、パフォーマンスを最大化するために、いくつかの最適化手法を使用します。
1. 基本ブロックの特定:連続して実行される命令のシーケンスを基本ブロックとして特定します。
2. 命令変換:各RISC-V命令を対応するARM64命令に変換します。
3. レジスタ割り当て:ゲストレジスタをホストレジスタに効率的にマッピングします。
4. 呼び出し規約:ホストの呼び出し規約(例:preserve_none)を使用して、ホットなゲスト状態をホストレジスタに保持し、レジスタ間のやり取りを最小限に抑えます。
5. テイルコール:基本ブロックの終わりにテイルコールを使用することで、ブロック間の遷移を効率化し、スタックオーバーヘッドを削減します。
このリコンパイラは、RISC-Vプログラムをホストマシンでほぼネイティブ速度で実行できるようにします。これにより、OpenVMのような仮想マシンは、実行速度のボトルネックを解消し、証明生成の全体的なパフォーマンスを向上させることができます。
パフォーマンスの向上
このリコンパイラアプローチにより、RISC-Vプログラムの実行速度は、従来のインタープリタと比較して劇的に向上します。M3 MacBookでのテストでは、ネイティブarm64プログラムと同等のパフォーマンスが達成され、仮想マシンを介した実行時間が数秒から数ミリ秒に短縮されました。
この技術は、RISC-Vエミュレーションの分野における重要な進歩であり、より高速で効率的な仮想化ソリューションの実現に貢献します。