3.3 Kahn 程序網路:Fluxa 的時間模型#
對應原始碼:
fluxa/sim/src/interp.rs(非同步直譯器)、fluxa/sim/src/runtime.rs(兩種排程器)、fluxa/docs/Fluxa語言規格v0.1.md(規格) 上一節:3.2 測試平台與 initial 區塊的編譯
3.3.1 高階合成最難的問題#
第 1.2.6 節提過:HLS 的核心挑戰是語義落差。C 是順序執行的語言,硬體是並行的。工具必須「猜測」:
- 這個迴圈能不能展開?
- 這個陣列索引能不能變成一個 port?
- 迴圈能不能流水化?
- 猜錯了怎麼辦?
主流 HLS 工具的答案是註解 + 代價模型:
void fir(int *x, int *y) {
#pragma HLS PIPELINE
for (int i = 0; i < N; i++) {
acc += x[i] * COEF[i];
}
}
#pragma HLS PIPELINE 是一個要求,不是保證。工具會盡力而為,但使用者無法確定結果。
Fluxa 走的是另一條路:把「猜測」換成「宣告」。
3.3.2 Fluxa 的語言骨架#
Fluxa 是 2026 年設計的高階合成語言(規格檔案日期 2026-10-29)。它有四種宣告形式,也就是三個時間層次加組合邏輯:
| 形式 | 意義 | 內部可用 |
|---|---|---|
comb fn | 零週期,無迴圈 | — |
fn | 純函數,無狀態 | — |
task | Kahn 程序網路節點 | carry、pipeline 迴圈、阻塞式 send/recv、spawn、parallel |
stage | 週期精確(Filament 風格的值區間) | ;; 時脈邊界、非阻塞 try_recv/empty |
規格 §2.7 的上下文表由型別檢查器強制,不是文法:
| 構造 | comb fn | fn | task | stage |
|---|---|---|---|---|
carry | ✗ | ✗ | ✓ | ✓ |
pipeline 迴圈 | ✗ | ✗ | ✓ | ✗ |
| 阻塞式通道 send/recv | ✗ | ✗ | ✓ | ✓ |
try_recv / empty | ✗ | ✗ | ✗ | ✓ |
spawn、parallel | ✗ | ✗ | ✓ | ✗ |
;; | ✗ | ✗ | ✗ | ✓ |
這個表格就是 Fluxa 的設計哲學:
非阻塞接收是唯一會破壞決定性的操作,所以把它關在
stage層裡,而stage層不在行為模擬器的範圍內。
3.3.3 型別系統:所有整數都是範圍型別#
這是 Fluxa 最激進的設計。
uN ≡ int<0..=2^N-1> iN ≡ int<-2^(N-1)..=2^(N-1)-1>
位寬是從範圍推導出來的,不是使用者輸入的。 型別檢查器推導表達式的值域,硬體的位寬就等於值域需要的位數。
推導規則(一套規則管所有整數型別):
(T-ADD) int<a..=b> + int<c..=d> : int<a+c..=b+d>
(T-SUB) e1 - e2 : int<a-d..=b-c>
(T-MUL) e1 * e2 : int<min P..=max P>, P={ac,ad,bc,bd}
(T-SHL) e << k : int<a·2^k..=b·2^k>
(T-WRAP) e1 +% e2 : 同 e1 的型別(明確的環繞)
一個好處是:硬體的位寬永遠夠用,不會溢位。 這與 Verilog 的「上下文決定寬度 + 隱式截斷」形成對比。
定點數:
(T-FADD) fixed<I1,F1> + fixed<I2,F2> : fixed<max(I1,I2)+1, max(F1,F2)>
(T-FMUL) fixed<I1,F1> * fixed<I2,F2> : fixed<I1+I2, F1+F2>
定點數的小數位數相加而不是四捨五入,是硬體乘法器最自然的行為。
3.3.4 通道:語言的第一級概念#
task Fir(x: in chan<i16>, y: out chan<i16>) {
let mut hist: [i16; TAPS - 1] @reg = [0; TAPS - 1];
pipeline(ii = 1) for s in x {
let win: [i16; TAPS] = concat([s], hist);
let acc = (0..TAPS).unrolled()
.map(|k| win[k] * COEF[k])
.tree_sum();
hist = win[0..TAPS - 1];
y.send(acc.to::<i16>(round = nearest, ovf = sat));
}
}
x 是通道,for s in x 是從通道取出元素。 這不是迴圈——這是「從上游取資料,直到上游關閉」。
三個關鍵語意:
(a) 通道是有界 FIFO,send/recv 都是阻塞的。
- 通道滿時
send阻塞; - 通道空時
recv阻塞。
(b) 端點是線性資源(linear resource)。
/// 線性所有權轉移:端點必須由 from 擁有,轉給 to
pub fn transfer(&self, v: &Value, from: usize, to: usize, line: u32) -> R<()> {
match v {
Value::Tx(ch) | Value::Rx(ch) => {
let owner = if is_tx { &mut w.chans[*ch].tx_owner } else { &mut w.chans[*ch].rx_owner };
if *owner != from {
return msg(line, format!("{} endpoint of channel `{}` was already moved (channel endpoints are linear)", ...));
}
*owner = to; Ok(())
}
...
}
}
spawn 消耗端點;轉移後再用就是錯誤。端點不能被複製,也不能被靜默丟棄。
這是 Rust 的線性型別在硬體描述裡的應用:它讓「兩個人用同一個通道」這種 bug 在編譯期就被抓到。
(c) 任務結束時關閉它仍擁有的 Tx 端點。
/// 結束任務時關閉其仍擁有的 Tx 端點(等同硬體的 end-of-stream)
pub fn close_owned_tx(&self, task: usize, v: &Value) { ... }
所以 for v in rx { ... } 會自然終止——上游任務結束 → 通道關閉 → recv 回傳 None → 迴圈結束。這是硬體的 end-of-stream 訊號,用語言的線性所有權自然表達。
3.3.5 決定性:整個語言設計的樞紐#
規格 §3.10 的性質 5:
延遲不敏感等價:一個
task程式在每個輸出通道上的 token 序列,與參考語意(Kahn 程序網路)一致,且與實作延遲和 FIFO 深度無關。深度不足只會降低吞吐量,不會改變結果(除非死結)。
這是一個非常強的承諾。它的後果是深遠的:
- 模擬器可以用任意排程。 結果一樣。
- 合成後的硬體可以用任意通道深度。 結果一樣。
- 模擬器與硬體的結果必須逐 token 相同。 這給了 Fluxa 一個極強的等價性判準(第 4.3 節):不是「波形看起來像」,而是「token 序列完全相同」。
它的代價是:不能有非阻塞操作。
// 執行期也拒絕非阻塞操作
(Value::Rx(_), "try_recv") | (Value::Rx(_), "empty") | (Value::Rx(_), "len") => {
return self.err(format!(
"`{}` is non-deterministic and is only allowed in `stage` code (task-level programs must use blocking recv)", name));
}
「非阻塞」為什麼破壞決定性? 想象兩個任務都對同一個通道做 try_recv:誰拿到 token 取決於排程。而阻塞式 recv 中,等待本身就是排程點,但所有等待同一個通道的任務會以 FIFO 順序被喚醒——這是確定的。
這是 KPN 理論的原始結果(Kahn 1966):只要通訊是同步的(blocking send/recv),程式的行為就與排程無關。
3.3.6 波前免費:脈動陣列的優雅之處#
規格 §5.3 的例子是 Fluxa 設計最漂亮的地方:
為什麼不需要手寫 skew:每個 PE 的
recv()都是阻塞式,必須兩個輸入都到才觸發,所以資料在陣列中自然形成斜向波前。傳統 HLS 要手動寫延遲暫存器,這裡由握手語意免費取得。
看看 fluxa/examples/gemm.fx:
task Pe(a_in: in chan<i8>, b_in: in chan<i8>, a_out: out chan<i8>, b_out: out chan<i8>,
k: u16, c_out: out chan<i32>) {
carry acc: i32 = 0;
pipeline(ii = 1) for _ in 0..k {
let a = a_in.recv();
let b = b_in.recv();
acc += a * b;
a_out.send(a);
b_out.send(b);
}
c_out.send(acc);
}
沒有任何延遲暫存器,沒有任何手寫的 skew 排程。 阻塞式 recv 意味著一個 PE 只有在左右兩邊的資料都到了才會動作,這自動產生了斜向的資料波前。
這是語言設計改變硬體結構的最好例子。 在傳統 HLS 裡,同樣的 systolic array 需要:
- 手動決定 PE 之間的延遲;
- 寫出延遲暫存器陣列;
- 保證每個 PE 的啟動順序。
Fluxa 把這三件事變成語義的必然結果。
3.3.7 @reg:硬體暫存器的語意#
pub struct Var {
pub val: Value,
pub ty: Option<RtType>,
/// `@reg`:迭代內讀到舊值,寫入在迭代結束提交
pub reg: bool,
pub pending: Option<Value>,
}
@reg 的語意是:迭代內讀到舊值,寫入在迭代結束時提交。
fn commit_regs(&mut self) {
for sc in self.scopes.iter_mut() {
for v in sc.values_mut() { if let Some(p) = v.pending.take() { v.val = p; } }
}
}
這完全匹配硬體移位暫存器的行為。在 unroll(展開)之後,hist = win[0..TAPS-1] 這類語句就自然變成一個移位暫存器鏈。
這是 Fluxa 裡「宣告硬體結構」的第三個機制(前兩個是通道與 carry)。
3.3.8 carry:累積器的寬度是使用者宣告的#
carry acc: i32 = 0;
carry 宣告一個跨迭代累積的變數。它的寬度由使用者決定,而不是由值域推導。
為什麼?因為累積器的寬度在硬體上是設計決策:
- 寬度足夠 → 精確累加,但面積大;
- 寬度不足 → 環繞,但面積小(這就是
err_overflow.fx展示的錯誤)。
因此 Fluxa 不做值域推導,而是讓使用者負責,並在合成時檢查:
// fluxa/synth/src/ty.rs 的 mul()
// 寬度照值域推導,但 carry 變數是「使用者指定」的型別,
// 所以合成時要檢查累加的結果是否放得下
err_overflow.fx 就是這個檢查的反例:carry acc: i24 太窄,容不下 8 個 i16*i16。
carry 與 @reg 的分工:
| 機制 | 跨迭代的值 | 提交時機 |
|---|---|---|
@reg | 舊值(迭代內讀) | 迭代結束 |
carry | 新值(迭代內讀到新值) | 立即 |
carry 變數在迭代內讀到的是已經累加過的值,所以它對應硬體上「餵回自己的累加器」。
3.3.9 pipeline(ii = n):宣告 initiation interval#
pipeline(ii = 1) for s in x {
...
}
ii 是「啟動間隔」:每 \(n\) 個時脈可以啟動一次新的迭代。ii = 1 意味著完全流水化(每個時脈一個結果)。
Fluxa 的 pipeline 迴圈有嚴格的規則,在降階時強制:
PipeOp::Recv(d, c) => {
if !sends.is_empty() {
return Err(format!("`{}` is received after a send in the same iteration", ...));
}
if !seen.insert(c.clone()) {
return Err(format!("`{}` is received twice in an iteration (one token a clock): use ii = 2", ...));
}
}
PipeOp::Send(c, v) => {
if sends.iter().any(|(x,_)| *x == c) {
return Err(format!("`{}` is sent twice in an iteration (one token a clock): use ii = 2", ...));
}
}
規則:一個迭代裡,同一個通道不能收兩次,也不能送兩次。
為什麼? 因為 ii = 1 意味著「一個時脈一個迭代」。如果一個迭代要從同一個通道收兩個 token,那兩個 token 必須在同一個時脈內到達——除非通道深度是 0(硬體上不可能)。所以必須 ii = 2。
這是把硬體的物理限制寫進語言規則的一個漂亮例子。 使用者得到的是一個明確的錯誤訊息,而不是一個「工具做不到」的模糊結果。
還有降級(rollback)機制:
// 若流水化形式不可行但 ii > 1 被要求,降階會回滾
// (記錄 mark = (blocks.len(), temps.len(), n))並退回一般的 FSM 迴圈
// 若 ii == 1,錯誤就變成:
Err(e) => Err(format!("line {}: error[E0412]: pipeline(ii = {}) cannot be met: {}", line, ii, e));
ii > 1 允許降級,ii = 1 不允許。 因為 ii = 1 是效能承諾,降級會違反承諾;ii > 1 只是「至少這個速率」,降級到更慢是可以接受的。
這個「承诺 vs 請求」的區分,是工具設計中非常精細的一層。
3.3.10 兩種排程器#
fluxa/sim/src/runtime.rs 實作了兩種排程器,共用同一份 World 狀態。
3.3.10.1 原生排程器:一個 task 一條 OS 執行緒#
// 每個 task 一條執行緒,8 MB 堆疊
std::thread::Builder::new().stack_size(8 * 1024 * 1024)
每條執行緒跑一個 future。直譯器在原生模式下其實從不回傳 Pending(等待是由 Condvar 阻塞的),所以只需要一次 poll 加 yield_now。
死結偵測:
w.blocked += 1;
let my_epoch = w.epoch;
if w.blocked >= w.live {
w.deadlock = true;
// 產生報告,每個被阻塞的 task 一行
w.deadlock_report = Some(format!("deadlock: all {} live task(s) are blocked\n{}", w.live, lines.join("\n")));
return Err(RtErr::Deadlock);
}
epoch 機制是這段程式碼最微妙的地方:
epoch 機制:任何狀態變更(send/recv/close/任務結束/spawn)都 epoch++ 並把 blocked 歸零,等待者醒來後必須重新檢查並重新登記,避免「通知已送出但對方尚未醒來」造成的誤判。
任何狀態變更呼叫 World::bump(),它同時做兩件事:epoch 加一,並且 blocked 歸零。
為什麼需要這個? 考慮:
task A: recv(x) // 阻塞,blocked = 1
task B: send(x) // 這個 send 喚醒 A
在 A 真正從 Condvar 醒來之前,B 的 send 已經呼叫了 bump(),把 blocked 歸零。此時如果 C 也阻塞了,blocked 計數就不對了。
有了 epoch,等待者醒來後會重新檢查自己的條件(通道裡真的有東西了嗎?),如果還沒滿足就重新登記計數。
這個機制是所有「用計數偵測死結」的系統都需要的,而它是最容易被寫錯的地方。
3.3.10.2 協作式排程器:給 WebAssembly 用#
wasm32 沒有執行緒。所以所有 task 都是單一執行緒上的 future:
struct YieldOnce(bool);
impl Future for YieldOnce {
fn poll(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<()> {
if self.0 { Poll::Ready(()) } else { self.0 = true; Poll::Pending }
}
}
Shared::run_coop 用一個無操作 waker(RawWakerVTable 的四個函式都忽略 null 指標)輪詢各個 task:
pub fn run_coop<'a, T>(&self, mut main: Pin<Box<dyn Future<Output = R<T>> + 'a>>) -> R<T> {
loop {
let epoch = self.lock().epoch;
if let Poll::Ready(r) = main.as_mut().poll(&mut cx) { return r; }
// 輪詢每個 task 一次;新 spawn 的 task 加入佇列
let mut w = self.lock();
if w.epoch == epoch {
/* 整整一輪都沒有任何進展 → 死結 */
}
}
}
死結的判準不同:協作式排程器是「一整輪 epoch 都沒變」。這比原生版的「blocked >= live」更簡單也更保守。
兩種排程器必須給出相同的結果——這是 KPN 決定性的直接測試。fluxa/sim/tests/examples.rs 對每個測試都跑兩種排程器,並斷言它們的通過/失敗與第一行訊息一致。
3.3.11 120 位精確整數#
pub enum Value {
Unit, Bool(bool), Int(i128), Float(f64),
Fixed(i128, u32), // (raw, fraction bits);值 = raw / 2^frac
Str(String), Array(Vec<Value>), Tuple(Vec<Value>),
Range(i128, i128), // 半開 [lo,hi)
Tx(usize), Rx(usize) // 通道端點 = 通道索引
}
pub const MAX_WIDTH: u32 = 120;
語言承諾整數運算精確到 120 位。 這比硬體的 64 位寬鬆太多了——但這正是「行為模擬」該做的事:模擬器不該因為硬體位寬而改變結果,否則「模擬通過但硬體錯」就無法避免。
兩種轉換的區分很重要:
| 意義 | 失敗時 | |
|---|---|---|
coerce(v, t) | 隱式,只能無損加寬 | 報錯,提示用 .to::<T>() |
convert(v, t, round, ovf) | 顯式,可指定捨入與溢位模式 | 依 ovf 決定 |
// 窄化固定點值(有小數位)會被拒絕:
// "fractional bits would be lost"
這個區分把「會不會溢位」變成語言層面的顯式選擇,而不是硬體的意外。
3.3.12 非同步直譯器:boxed future 的必要性#
直譯器是 async 的,因為通道操作是唯一的 await 點:
ExprKind::Method(recv, "recv") => {
return match self.sh.recv(self.task, *ch, line).await? {
Some(v) => Ok(v),
None => self.err("recv() on a closed, empty channel"),
};
}
但 eval → exec_block → exec_stmt → eval 是互相遞迴的。光用 async fn 會讓 future 巢狀到無限深,爆堆疊。
解法是每個遞迴入口都 boxing:
/// 一個 boxed future:打破非同步直譯器的遞迴(eval -> ... -> eval)
pub type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub fn eval<'a>(&'a mut self, e: &'a Expr) -> BoxFut<'a, R<Value>> { Box::pin(self.eval_impl(e)) }
fn exec_body<'a>(&'a mut self, b: &'a Block) -> BoxFut<'a, R<Value>> { Box::pin(self.exec_body_impl(b)) }
每次 boxing 都有成本(一次堆積配置),但它保證了堆疊深度與程式碼深度無關。對於一個語言層級的直譯器,這是必要的取捨。
3.3.13 tree_sum 與硬體adder tree 對應#
fn tree_sum(mut items: Vec<Value>) -> Result<Value, String> {
while items.len() > 1 {
let mut next = Vec::with_capacity(items.len() / 2 + 1);
for ch in items.chunks(2) {
if ch.len() == 2 { next.push(binop(BinOp::Add, &ch[0], &ch[1])?); }
else { next.push(ch[0].clone()); }
}
items = next;
}
Ok(items.remove(0))
}
模擬器裡的 tree_sum 建的是一棵平衡加法樹——這不是任意的選擇,而是硬體 adder tree 的直接對應。合成器(第 4.3 節)也會產生同樣形狀的平衡樹。
這是「模擬器與硬體共享結構」的一個小而重要的實例:如果模擬器用線性累加、硬體用平衡樹,浮點加法的結合律誤差就會讓兩者結果不同。整數不會,但定點數與浮點會。
3.3.14 兩種語言的時間模型對照#
flowchart TB
subgraph V["Veryl:離散事件 + 時脈"]
V1["always_ff 在時脈緣執行"]
V2["共享變數"]
V3["同一個 cycle 內,所有 FF 同時更新"]
end
subgraph F["Fluxa:Kahn 程序網路"]
F1["task 是節點,通道是邊"]
F2["recv/send 是阻塞的"]
F3["與排程無關"]
end| Veryl | Fluxa | |
|---|---|---|
| 時間的來源 | 時脈 | 通道握手 |
| 資料的載體 | 共享變數 | 通道(FIFO) |
| 同一時刻的行為 | 所有 always_ff 同時更新 | 各 task 獨立推進,順序不確定 |
| 決定性 | 單一時脈域內確定 | 全域排程無關 |
| 硬體對應 | 正反器 + 組合邏輯 | FIFO + 狀態機 + 資料路徑 |
| 適合 | 狀態機、算術管線 | 濾波器陣列、矩陣乘法、資料流 |
兩者可以描述同一顆硬體。 這不是巧合——rueda 的 Fluxa 合成器把 task 降階成 FSM + 資料路徑(第 4.3 節),得到的網表與 Veryl 寫出來的網表是同一種東西。
3.3.15 為什麼要有兩種語言#
| 需求 | Veryl 適合 | Fluxa 適合 |
|---|---|---|
| 一顆 RISC-V CPU | ✓(明確的時脈與狀態) | △(可以但彆扭) |
| 一個矩陣乘法器陣列 | △(要手寫波前排程) | ✓(免費) |
| 需要精確的週期數 | ✓ | ✗(KPN 不承諾) |
| 需要位元精確的行為 | ✓(四態) | ✓(120 位) |
| 需要快速探索演算法 | ✗ | ✓ |
兩者在 rueda 裡共用同一個後端(網表 IR),所以一個專案可以混用:一個用 Fluxa 寫的 FIR 濾波器,接到一個用 Veryl 寫的 UART 控制器上。
下一章:第 4 章 合成 — 把語意變成閘。