跳轉到

8.1 veryl-emu:週期式編譯模擬#

對應原始碼:veryl/emu/src/ir.rs、veryl/emu/src/gen.rs、veryl/emu/src/wasm.rs、veryl/emu/src/elab.rs、veryl/emu/tests/check.sh 下一節:8.2 fluxa-emu


8.1.1 為什麼需要編譯式模擬#

第 3 章的模擬器是事件驅動直譯器:每次運算走一次語法樹,變數查雜湊表,值是 enum,每個 always_comb 是一段程式碼。

它的優點是精確(包含四態 x);缺點是慢。

對於「跑 Linux」這種工作,需要的是速度。所以需要第二個模擬器:

直譯模擬器 編譯模擬器
對應 Verilog event-driven simulator Verilator
語意 四態 二態
速度 慢 快 100–1000 倍
產出 日誌 可執行檔

Verilator 的做法是:把 RTL 轉成 C++,用宿主編譯器編譯。

veryl-emu 的做法更激進:直接產生 WebAssembly 二進位,用自己手寫的編碼器。

為什麼不用 Verilator 的路子?

  1. 不需要宿主編譯器——Rust 產物直接就是 wasm,可以跑在任何瀏覽器裡;
  2. 不需要 C++ 工具鏈——整個 workspace 只有 Rust;
  3. 跨平台——同一個 .wasm 在 Node、瀏覽器、任何 wasm 執行環境都能跑。

8.1.2 兩態#

編譯模擬器是二態的:沒有 x,也沒有 z。

為什麼可以? 因為在 Verilator 式的模型裡,「x」只出現在:

  • 開機時未初始化的正反器;
  • 某些不確定的運算結果(除以零、越界)。

Verilator 的做法是給這些值一個預設值(通常是 0),並在特定情況下報警告。

veryl-emu 的做法:

// x / z 變成 0;x 的結果(除以零、越界索引)變成 0

這個取捨是安全的,因為在真實硬體上,開機狀態本來就是不確定的,而把它確定為 0 只是選了一個具體的行為。

8.1.3 型別化 IR#

veryl/emu/src/ir.rs 的核心:

pub enum K {
    Const(Vec<u64>),          // 小端 64 位元 limb,遮罩到寬度
    Sig(usize),
    Elem(usize, Box<E>),      // 陣列元素(越界:0)
    Bit(Box<E>, Box<E>),      // base 的第 idx 位(越界:0)
    Slice(Box<E>, Box<E>),    // base 從第 lo 位起的 w 位
    Not(Box<E>), Neg(Box<E>), LNot(Box<E>),
    Red(char, bool, Box<E>),  // 歸約:'&'、'|'、'^',然後取反?
    Bin(&'static str, Box<E>, Box<E>),
    Cmp(&'static str, bool, Box<E>, Box<E>),   // == != < <= > >=,有號?
    LAnd(Box<E>, Box<E>), LOr(Box<E>, Box<E>),
    Shift(&'static str, Box<E>, Box<E>),
    Tern(Box<E>, Box<E>, Box<E>),
    Concat(Vec<E>),           // 最高位項目在前
    Rep(u32, Box<E>),         // n 份的值
    Resize(Box<E>),           // 轉成節點寬度:截斷或延伸(有號則符號延伸)
    Call(usize, Vec<E>),
}

pub struct E { pub k: K, pub w: u32, pub s: bool }

每個運算式都帶著已決定的寬度 w 與符號性 s。 這是「編譯期決定一切」的具體體現。

檔案裡的警告:

寬度規則必須與 veryl_sim::sim::Sim::eval 在執行期套用的規則一致

這是「同一語意兩份實作」的必然成本(第 1.2.7 節)。緩解手段是 veryl/emu/tests/check.sh 的差異測試。

8.1.3.1 迴圈展開#

/// 常數邊界且迭代次數不超過這個數的迴圈會被展開
const UNROLL: u32 = 128;

fn subst_e(e: &Expr, var: usize, v: &Val) -> Expr { ... }   // 把迴圈變數換成常數
fn subst_s(s: &Stmt, var: usize, v: &Val) -> Stmt { ... }

展開是在模擬器的 IR 上做的(用替換),不是重新實作一套展開器。

好處:陣列索引與位元選取變成固定位址與固定位移,程式碼生成器可以直接最佳化。

迴圈變數在迴圈後保留最後的值,與 veryl-sim 一致。

8.1.4 記憶體佈局#

pub const FINISH: u32 = 0;   // $finish 旗標
pub const QPTR:   u32 = 4;   // 寫入佇列的指標
pub const MUTE:   u32 = 16;  // 16 + 4k = 第 k 個停止訊號的靜音旗標
const BASE: u32 = 64;       // 訊號從這裡開始
const QUEUE_BYTES: u32 = 1 << 20;

每個訊號是一串小端 64 位元 limb。 陣列的元素一個接一個連續放置。寬度以上的位元永遠是 0(「正規化」)。

這個佈局的意義:整個設計的狀態在一塊連續記憶體裡,而這塊記憶體可以直接變成 wasm 的線性記憶體。

8.1.5 程序排程:Tarjan 的強連通分量#

/// 依賴順序的強連通分量(Tarjan,迭代式)
fn sccs(n: usize, succ: &[Vec<usize>]) -> Vec<Vec<usize>> { ... }

依賴圖是從讀寫集合建立的(ir.rs 的 rw_s / reads_e)。

  • 無環的程序依賴順序執行;
  • 循環群組(程序之間的組合迴路)編譯成一個函式,在執行期重複執行直到輸出不再變化(最多 100 次),用一個暫存區比較群組的輸出。

「迭代直到不變」是 delta cycle 的編譯期版本——它把模擬器的收斂語意直接翻譯成迴圈。

Tarjan 是迭代式實作(不是遞迴),因為遞迴會在深圖上爆棧。

8.1.6 時脈別名#

// 時脈透過埠連接到實例(child.clk = clk):追蹤到根源
let root = |mut s: usize| { let mut n = 0; while let Some(&t) = alias.get(&s) { s = t; n += 1; if n > 100 { break; } } s };

「一個設計只能有一個時脈」 是 Verilator 的限制,veryl-emu 也一樣:

more than one clock (or clock edge) is not supported yet

100 次的上限是防止別名循環無限。

8.1.7 活性分析:只跑「髒」的程序#

/// 儲存 sig 的 limb 到 dest;當它們與訊號目前的值不同時,它的讀取者變成髒的
fn put(&mut self, sig: usize, dest: u32, limbs: &[(u32, u32)]) {
    let m = self.marks(sig);
    if !m.is_empty() {
        let cur = self.lay.off[sig];
        for (i, &(k, l)) in limbs.iter().enumerate() {
            self.f.i32c(0).load64(cur + 8 * k).local_get(l).op(I64_NE);
            if i > 0 { self.f.op(I32_OR); }
        }
        self.f.if_(); self.emit_marks(&m); self.f.end();
    }
    for &(k, l) in limbs { self.f.i32c(0).local_get(l).store64(dest + 8 * k); }
}

這是編譯模擬器的核心最佳化:每個組合程序有一個「髒」旗標;只有當它的輸入改變時才重跑。

settle() 會先比較那些「沒有程序寫」的訊號(輸入)與它們的快取值,改變了就標記讀取者。

這個機制讓「一個時脈內只有少數程序需要重算」成為可能,這是編譯模擬器比直譯器快的主要原因。

8.1.8 非阻塞指派:最難的部分#

在 always_ff 裡,指派是非阻塞的。編譯模擬器要模擬這個。

三種情況,三種機制:

對象 機制
某個 always_ff 也會讀取的暫存器 影子副本:邊緣時把區域拷到影子,主體寫影子,之後影子拷回
某個 always_ff 會讀取的陣列元素 寫入佇列:邊緣時把 [位址, limbs, 值…] 加入佇列,之後重放
其他 直接就地寫

8.1.8.1 影子副本#

// posedge
if rlen > 0 { pe.i32c(lay.shadow_base).i32c(lay.region.0).i32c(rlen).memcopy(); }  // 區域 -> 影子
pe.i32c(0).i32c(lay.qbase).store32(QPTR);                                        // 重置佇列
for &i in &ff_funcs { pe.call(i); }                                              // always_ff 主體
if rlen > 0 { pe.i32c(lay.region.0).i32c(lay.shadow_base).i32c(rlen).memcopy(); }  // 影子 -> 區域
// 然後重放寫入佇列
pe.call(settle_i);

為什麼需要影子? 因為 always_ff 主體裡的讀取必須看到舊值。影子區域就是「舊值的副本」。

「被讀取的暫存器集合」是一個靜態分析的結果——如果某個暫存器只被寫不被讀,就不需要影子(直接寫就對了)。

RV64 SoC 的延遲區域很小:30 個暫存器、240 個位元組(history.md v2.12)。

8.1.8.2 寫入佇列#

// 佇列項目:[位址, limbs, 值...]
self.f.local_get(p).local_get(addr).i32c(off as i32).op(I32_ADD).store32(0);
self.f.local_get(p).i32c(v.len() as i32).store32(4);
for (k, &x) in v.iter().enumerate() { self.f.local_get(p).local_get(x).store64(8 + 8 * k as u32); }
self.f.i32c(0).load32(QPTR).i32c(8 + 8 * v.len() as u32).op(I32_ADD).store32(QPTR);
// 溢位:trap
self.f.i32c(0).load32(QPTR).i32c(self.lay.qend as i32).op(I32_GE_U).if_().op(UNREACHABLE).end();

為什麼陣列元素需要佇列而不是影子?

因為一個陣列可能有 256 MB。為了一個元素做影子副本是不可行的。

寫入佇列的方案:把「寫哪個位址、寫什麼值」記錄下來,主體結束後重放。

「重放」的順序很重要:必須按記錄順序重放,才能保證「後寫的覆蓋先寫的」。

溢位時 trap(UNREACHABLE)——這是一個健全性檢查:如果佇列滿了,行為未定義,寧可崩潰。

8.1.9 程式碼生成#

8.1.9.1 值的表示#

用 i64 limb 在區域變數裡。

運算 實作
resize 符號延伸用 0 - sign_bit;零延伸直接;最頂端 limb 遮罩
add / sub 多 limb 帶進位/借位(借位 = a<b | (d < borrow_in))
shr_const / shl_const 常數位移 = 直接搬 limb
shr_dyn / shl_dyn 動態位移分解成 k = amt >> 6 與 r = amt & 63,用 SELECT 選 limb
cmp </<=/>/>=:頂端 limb 比較(有號時符號延伸),較低 limb 以字典序折疊
divrem 只有單 limb;無號用 DIV_U/REM_U(除零保護 SELECT(b==0 ? 1 : b, b==0));有號取絕對值( $
cond 條件直接產生 i32 區域變數(Cg::cond 快速路徑),避免繞道 i64

divrem 只支援單 limb 是一個限制。因為 wasm 沒有硬體除法指令,多 limb 除法必須用迴圈實作。

cmp 的字典序折疊是正確的:兩個多位元組無號整數的大小由最高不同位元決定。從高到低比較,一旦不同就決定。

8.1.9.2 函式#

函式主體變成獨立的 wasm 函式(fn:<name>),返回值放在一個專門的訊號裡;return 存值然後 ret。

8.1.9.3 除錯輔助#

// VERYL_EMU_CHUNK=1:每個程序有自己的具名函式(給 node --cpu-prof 用)
// VERYL_EMU_COUNT:加上每個程序的執行計數

wasm 的 name 自訂段帶著函式名稱。

這個設計說明一件事:可觀察性是工具的一部分。 如果不能指出「哪個程序在跑」「跑了多少次」,效能問題就無從診斷。

8.1.10 WebAssembly 編碼器#

veryl/emu/src/wasm.rs 是一個手寫的、零相依的 wasm 二進位寫出器。

  • 一個記憶體(匯出為 mem),以 64 KB 頁配置;
  • 兩種函式型別:() -> ()(0)與 (i32) -> i32(1)。

為什麼要自己寫?

  1. 零相依(符合整個專案的風格);
  2. 完全控制輸出的位元組(對 web/wasm 的整合重要);
  3. 体积最小。

匯出的介面:

// settle():收斂
// posedge():時脈上升沿
// run(n):跑 n 個時脈

「匯出三個函式」是編譯模擬器的最小介面,而且介面小到可以在 JavaScript 裡輕易驅動。

8.1.11 差異測試#

veryl/emu/tests/check.sh 是這個專案最重要的驗證手段之一。

做法:同一個測試平台,分別跑:

  1. veryl/sim(事件驅動直譯器,四態);
  2. veryl/emu(編譯模擬器,二態,wasm)。

結果必須完全一致。

# check.sh 的結構(概念)
reda sim tests/check.veryl --vcd sim.vcd
node js/emu.mjs emu.wasm check.vcd    # 產生 emu.vcd
diff sim.vcd emu.vcd

比較 VCD 是關鍵:不只是比較最終結果,而是比較完整的波形。

這個差異測試抓到的東西:

  • 寬度規則不一致(第 8.1.3 節的警告);
  • 非阻塞語意的邊界情況;
  • 陣列索引越界的處理;
  • 迴圈展開後的語意差異。

這是本章最值得學的一點:當一個語言有兩套實作(直譯 + 編譯)時,差異測試不是可選的工程紀律,而是正確性的一部分。因為語言的語意是由「兩個實作的一致性」定義的。

8.1.12 效能#

引擎 速度 備註
veryl-sim(直譯) 慢 四態、精確
veryl-emu(wasm) 約 500 萬指令/秒 二態、快速
riscv-emu(wasm + JIT) 約 176 MIPS 指令集層級

「500 萬指令/秒」 是在瀏覽器裡。這是「開 Linux」的門檻:Linux 開機大約執行幾百萬到幾千萬條指令,所以需要 1 到 10 秒。

8.1.13 週期式模擬的侷限#

編譯模擬器不能模擬「零延遲迴路」的行為,因為真實硬體上組合迴路會振盪。

編譯模擬器假設設計是同步的(所有狀態變化都在時脈緣發生)。

四態語意被捨棄——這對 RTL 設計驗證(找出 x 相關的 bug)是一個真實的限制。

因此兩個模擬器是互補的,不是取代關係:

用在哪裡
reda sim 開發初期、找 x bug、驗證測試平台
reda emu 系統級、跑 Linux、量效能

下一節:8.2 fluxa-emu:狀態機到程式碼