Skip to main content

logicaffeine_compile/codegen_sva/
gpu_simt_rtl.rs

1//! gpu_simt_rtl — the GPU processor edge: a tuneable SIMT data-parallel engine in silicon.
2//!
3//! The NPU (`fabric_rtl::LoopFabric`) is a REDUCTION — one body streamed by a loop counter,
4//! many→one. A GPU is the opposite: **N independent spatial lanes, each running the SAME
5//! compiled straight-line kernel (the "shader") on its OWN data, producing N INDEPENDENT
6//! outputs (a MAP)**, each lane gated by its own predicate bit (SIMT divergence). The lanes are
7//! WIRED (spatial) and all compute in ONE cycle — no warp scheduler, no fetch/decode/dispatch —
8//! exactly the deterministic Futamura primitive the other edges use, applied across lanes.
9//!
10//! One parametrized engine specializes to every mode (the "best of all worlds"):
11//!   * **SIMT** — `predicated = true`: a per-lane mask; a masked lane contributes 0 (inert).
12//!   * **SIMD** — the special case where every lane is unmasked (`mask` all-ones).
13//!   * **map-then-reduce** — `reduce = Some(op)`: the N lane outputs feed a cross-lane
14//!     warp-reduce, which is exactly the NPU's accumulator (an `AluOp` fold) placed spatially.
15//!   * **uniform vs varying** — each kernel input is either a broadcast scalar shared by every
16//!     lane (a GPU "uniform") or a per-lane vector element (a "varying").
17//!
18//! **Physical interface.** Exposing every lane's I/O as top-level ports would be thousands of
19//! pins (unroutable on an FPGA, an absurd pad ring on an ASIC). Like the other accelerators
20//! (`dram_executor`, `infinichip`), the tile has a NARROW streaming interface — a 64-bit `din`
21//! loaded slot-by-slot into an internal lane register file, a `go` pulse that runs all lanes in
22//! parallel (the spatial SIMT step), and a `din`-width `dout` read back by `raddr`. The parallel
23//! COMPUTE is the GPU essence; the streaming I/O is how a real accelerator talks to memory.
24//!
25//! A lane IS the existing certified straight-line fabric residual (`fabric_rtl::FabricOp`), so
26//! lane arithmetic is not reinvented: the kernel is compiled from English by the REAL compiler
27//! (`residual_from_source`), each lane is a verbatim spatial copy, and the map is proven ≡ the
28//! VM lane-by-lane — by direct model comparison and, on a narrow width, EXHAUSTIVELY by our own
29//! certified SAT (`fabric_to_bounded` + `prove_unsat`). 64-bit signed wrapping, bit-identical to
30//! the VM's `Int` (its fixed-width projection), matching `fabric_rtl`.
31
32use crate::codegen_sva::fabric_rtl::{
33    eval_fabric, fabric_to_bounded, residual_from_source, AluOp, FabricOp, Src,
34};
35use crate::codegen_sva::fanout::bounded_fanout_select;
36use crate::codegen_sva::mul_tree::{mul_tree, mul_tree_levels};
37use crate::codegen_sva::sva_to_verify::{BitVecBoundedOp, BoundedExpr};
38
39/// The datapath multiply strategy for the lane's `a·x + b` arithmetic — a first-class, tuneable,
40/// SAT-certified knob. Every strategy is functionally identical (`a·x + b`, 64-bit wrapping); they
41/// trade cycle count against physical (routing / antenna) friendliness.
42#[derive(Clone, Debug, PartialEq)]
43pub enum LaneArith {
44    /// Per-op combinational body — the whole kernel evaluates in ONE cycle. A WIDE combinational
45    /// `*`: fast (single-cycle) but met1-antenna-dense on Sky130.
46    Combinational,
47    /// The 64×64 multiply STRUCTURALLY split into three 32×32 partial products, pipelined over
48    /// `stages` registered stages — real GPU-ALU pipelining. Smaller multipliers → shorter nets,
49    /// but still a WIDE combinational `*` per partial product.
50    DecomposedFma { stages: usize },
51    /// A SEQUENTIAL shift-add multiply over the datapath width: ONE reused adder per lane, one
52    /// bit of the multiplier consumed per cycle (WIDTH cycles). Bounded fan-out (like a word-serial
53    /// multiplier) → NO wide `*` anywhere → antenna-trivial. Only valid for the `a·x+b` kernel.
54    ///
55    /// `select_slice` is the fan-out / antenna knob. The per-cycle conditional-add partial product
56    /// `mp[0] ? mc : 0` is emitted as `select_slice` `(* keep *)` copies of the multiplier LSB, each
57    /// gating its OWN `WIDTH / select_slice`-bit group of `mc` — so no single select net spiders
58    /// across the full datapath (the met1-antenna root cause). SMALLER `select_slice` = each copy
59    /// gates FEWER mux bits = lower fan-out = less antenna, at the cost of MORE `(* keep *)` wires;
60    /// LARGER = the reverse. It MUST divide the datapath width evenly (`WIDTH % select_slice == 0`,
61    /// asserted at emit time), so the byte-groups tile `mc` exactly with no dropped or misaligned bit.
62    SequentialFma { select_slice: usize },
63    /// ONE multiply shared by every lane, an aligned tree of adds over the 64 partial products
64    /// (`mul_tree`, no `*`) registered after every `every`-th level and after the last. Each lane's `a·x`
65    /// enters it in the clock the lane's `x` is loaded, so the products stream out while the rest of
66    /// the operands load; as each emerges its lane's masked `+ b` is registered and folded into the
67    /// warp-reduce. Only valid for the `a·x+b` kernel with a per-lane `x`.
68    Streamed { every: usize },
69}
70
71/// A tuneable SIMT data-parallel engine — the GPU tile.
72#[derive(Clone, Debug)]
73pub struct SimtEngine {
74    /// Lane width N (the SIMD/SIMT width). Tuneable — the whole engine is parametric in it.
75    pub n_lanes: usize,
76    /// The kernel's input arity (parameter count of the compiled lane program).
77    pub n_in: usize,
78    /// `uniform[k]` = input k is a broadcast scalar shared by every lane (a GPU "uniform");
79    /// otherwise input k is a per-lane "varying" (its own value per lane). Length `n_in`.
80    pub uniform: Vec<bool>,
81    /// The compiled straight-line kernel (the "shader") — one `FabricOp` per SSA value, from
82    /// the real compiler's dispatch-free residual. Every lane runs this verbatim.
83    pub body: Vec<FabricOp>,
84    /// SSA index of the value driving each lane's output.
85    pub result: usize,
86    /// Per-lane predication (SIMT divergence): a `mask` input gates each lane (masked → 0).
87    pub predicated: bool,
88    /// Optional cross-lane reduce tail (the warp-reduce) — the NPU accumulator, placed spatially.
89    pub reduce: Option<AluOp>,
90    /// The lane's multiply strategy — a tuneable, SAT-certified knob (see `LaneArith`). All three
91    /// strategies compute the same `a·x + b` (64-bit wrapping); they trade cycle count against
92    /// physical friendliness (`SequentialFma` is antenna-trivial, the combinational forms are dense).
93    pub arith: LaneArith,
94}
95
96impl SimtEngine {
97    /// Build a SIMT engine from an English lane program. `fn_name` is the kernel (shader); its
98    /// straight-line body becomes the per-lane datapath. `uniform` marks which parameters are
99    /// broadcast scalars (shared) vs per-lane vectors; its length must equal the kernel arity.
100    pub fn from_lane_source(
101        src: &str,
102        fn_name: &str,
103        n_lanes: usize,
104        uniform: Vec<bool>,
105        predicated: bool,
106        reduce: Option<AluOp>,
107    ) -> SimtEngine {
108        let (body, result, n_in) = residual_from_source(src, fn_name);
109        assert_eq!(
110            uniform.len(),
111            n_in,
112            "uniform flags ({}) must match kernel arity ({n_in})",
113            uniform.len()
114        );
115        SimtEngine {
116            n_lanes,
117            n_in,
118            uniform,
119            body,
120            result,
121            predicated,
122            reduce,
123            arith: LaneArith::SequentialFma { select_slice: 8 },
124        }
125    }
126}
127
128/// One streamed load slot: a broadcast uniform input, the packed mask word, or a per-lane
129/// varying input. The load order (uniforms, then mask, then per-lane varyings) is the single
130/// source of truth shared by the RTL load FSM, the testbench, and the golden hex writer.
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub enum Slot {
133    Uniform(usize),
134    Mask,
135    Varying(usize, usize), // (lane j, input k)
136}
137
138/// The canonical load-slot sequence for an engine.
139pub fn load_slots(eng: &SimtEngine) -> Vec<Slot> {
140    let mut s = Vec::new();
141    for k in 0..eng.n_in {
142        if eng.uniform[k] {
143            s.push(Slot::Uniform(k));
144        }
145    }
146    if eng.predicated {
147        s.push(Slot::Mask);
148    }
149    for j in 0..eng.n_lanes {
150        for k in 0..eng.n_in {
151            if !eng.uniform[k] {
152                s.push(Slot::Varying(j, k));
153            }
154        }
155    }
156    s
157}
158
159/// A residual operand named from lane `j`'s internal registers — a uniform input is the shared
160/// reg `u{k}`, a varying input is the per-lane reg `x{k}_l{j}`, an SSA value is `v{k}_l{j}`, a
161/// constant is baked in.
162fn src_reg(uniform: &[bool], s: Src, j: usize) -> String {
163    match s {
164        Src::In(k) => {
165            if uniform[k] {
166                format!("u{k}")
167            } else {
168                format!("x{k}_l{j}")
169            }
170        }
171        Src::Val(k) => format!("v{k}_l{j}"),
172        Src::Const(c) => {
173            if c < 0 {
174                format!("-64'sd{}", (c as i128).unsigned_abs())
175            } else {
176                format!("64'sd{c}")
177            }
178        }
179    }
180}
181
182/// The Verilog separator for a reduce op (masked lanes already emit 0 = Add's identity).
183fn reduce_sep(op: AluOp) -> &'static str {
184    match op {
185        AluOp::Add => " + ",
186        AluOp::Sub => " - ",
187        AluOp::Mul => " * ",
188        AluOp::And => " & ",
189        AluOp::Or => " | ",
190        AluOp::Xor => " ^ ",
191        AluOp::Shl => " << ",
192        AluOp::Shr => " >>> ",
193        AluOp::UShr => " >> ",
194    }
195}
196
197/// Emit the SIMT engine as synthesizable RTL: a narrow streaming interface (a 64-bit `din` loaded
198/// slot-by-slot into an internal lane register file, a `go` pulse that runs ALL lanes in one cycle
199/// — the spatial SIMT step — and a `dout` read back by `raddr`) wrapped around N verbatim copies
200/// of the certified kernel, with per-lane predication and an optional warp-reduce. Module name
201/// `gpu_{name}`. Readback: `raddr` in `0..N` selects a lane output; `raddr == N` selects the
202/// reduced scalar (when a reduce is present).
203pub fn emit_gpu_simt_verilog(name: &str, eng: &SimtEngine) -> String {
204    if let LaneArith::Streamed { every } = eng.arith {
205        return emit_gpu_simt_streamed_verilog(name, eng, every);
206    }
207    let n = eng.n_lanes;
208    // The i64 datapath width — the `a`/`x`/`b` register width, and the sequential multiply's step
209    // count. This is INTENTIONALLY fixed at 64: it is the VM's fixed-width `Int` projection (the same
210    // width `fabric_rtl` and the ≡VM/SAT locks certify against), NOT a free tuning knob — moving to
211    // 32- or 128-bit is a value-TYPE change (a different `Int` projection), not an emitter parameter.
212    const WIDTH: usize = 64;
213    // Translate the strategy knob: `p` = pipeline stages of the decomposed FMA (0 = not decomposed),
214    // `is_seq` = the sequential shift-add, `select_slice` = its per-cycle-select fan-out slice (only
215    // meaningful when `is_seq`). Combinational is the `p == 0 && !is_seq` case.
216    let (p, is_seq, select_slice) = match &eng.arith {
217        LaneArith::Combinational => (0usize, false, 0usize),
218        LaneArith::DecomposedFma { stages } => (*stages, false, 0usize),
219        LaneArith::SequentialFma { select_slice } => (0usize, true, *select_slice),
220        LaneArith::Streamed { .. } => unreachable!("the streamed engine is emitted above"),
221    };
222    if is_seq {
223        assert_eq!(
224            eng.n_in, 3,
225            "the sequential (shift-add) FMA needs a 3-input a·x+b kernel"
226        );
227        // The fan-out slice must tile the datapath exactly — each of the `select_slice` `(* keep *)`
228        // copies gates a `WIDTH / select_slice`-bit group of `mc`, so no bit is dropped or misaligned.
229        assert!(
230            select_slice > 0,
231            "SequentialFma select_slice must be positive"
232        );
233        assert_eq!(
234            WIDTH % select_slice,
235            0,
236            "SequentialFma select_slice ({select_slice}) must divide the datapath width ({WIDTH}) evenly"
237        );
238    }
239    let slots = load_slots(eng);
240
241    // ---- register declarations ----
242    let mut decls = String::new();
243    for k in 0..eng.n_in {
244        if eng.uniform[k] {
245            decls.push_str(&format!("  reg [63:0] u{k};\n"));
246        }
247    }
248    if eng.predicated {
249        decls.push_str(&format!("  reg [{}:0] mask;\n", n - 1));
250    }
251    for j in 0..n {
252        for k in 0..eng.n_in {
253            if !eng.uniform[k] {
254                decls.push_str(&format!("  reg [63:0] x{k}_l{j};\n"));
255            }
256        }
257    }
258    // Pipelined FMA (p > 0): the 64×64 multiply is STRUCTURALLY split into three 32×32 partial
259    // products, held in these stage-1 registers. Smaller multipliers → shorter internal nets →
260    // far less antenna (no diode flood) → a clean, fast signoff. Only for the a·x + b kernel.
261    if p > 0 {
262        assert_eq!(
263            eng.n_in, 3,
264            "the pipelined (decomposed) FMA needs a 3-input a·x+b kernel"
265        );
266        for j in 0..n {
267            decls.push_str(&format!(
268                "  reg [63:0] pll_l{j};\n  reg [63:0] phl_l{j};\n  reg [63:0] plh_l{j};\n"
269            ));
270        }
271    }
272    // Sequential FMA: per lane, the running accumulator `acc`, the left-shifting multiplicand `mc`
273    // (= a), and the right-shifting multiplier `mp` (= x). ONE reused adder per lane consumes one
274    // bit of `mp` per cycle — a word-serial multiplier, bounded fan-out, antenna-trivial. No `*`.
275    if is_seq {
276        for j in 0..n {
277            decls.push_str(&format!(
278                "  reg [63:0] acc_l{j};\n  reg [63:0] mc_l{j};\n  reg [63:0] mp_l{j};\n"
279            ));
280        }
281    }
282    for j in 0..n {
283        decls.push_str(&format!("  reg [63:0] outr{j};\n"));
284    }
285    if eng.reduce.is_some() {
286        decls.push_str("  reg [63:0] redr;\n");
287    }
288    decls.push_str("  reg [15:0] lc;\n");
289    if p > 0 {
290        decls.push_str("  reg [7:0] pcnt;\n  reg running;\n");
291    }
292    if is_seq {
293        decls.push_str("  reg [7:0] scnt;\n  reg running;\n");
294    }
295
296    // ---- combinational lane datapaths (read the loaded regs) ----
297    let mut lanes = String::new();
298    for j in 0..n {
299        if is_seq {
300            // Sequential a·x + b (a=In0, x=In1, b=In2). The multiply is folded into the FSM's
301            // shift-add over `acc`/`mc`/`mp`; here the combinational tail just adds `b` and applies
302            // the per-lane predicate. `acc` holds a·x after WIDTH steps.
303            //
304            // The step's conditional-add partial product `mp[0] ? mc : 0` is emitted here as a
305            // fanout-bounded, `(* keep *)`-sliced wire `pp_l{j}`: `select_slice` copies of the select
306            // bit `mp[0]`, each gating its own WIDTH/select_slice-bit group of `mc`. Every copy ==
307            // mp[0], so `pp == (mp[0] ? mc : 0)` bit-for-bit — the FSM add becomes `acc += pp`. This
308            // bounds the select net's fanout (the met1-antenna root cause) without changing the value.
309            // The slicing IS the reusable `bounded_fanout_select` primitive — this is its founding
310            // caller (the pit-of-success helper any wide one-bit select should route through).
311            let (msel_decls, pp_expr) = bounded_fanout_select(
312                "msel",
313                &format!("_l{j}"),
314                &format!("mp_l{j}[0]"),
315                &format!("mc_l{j}"),
316                WIDTH,
317                select_slice,
318            );
319            lanes.push_str(&msel_decls);
320            lanes.push_str(&format!("  wire [{}:0] pp_l{j} = {pp_expr};\n", WIDTH - 1));
321            let b = src_reg(&eng.uniform, Src::In(2), j);
322            lanes.push_str(&format!("  wire [63:0] k_l{j} = acc_l{j} + {b};\n"));
323            let g = if eng.predicated {
324                format!("mask[{j}] ? k_l{j} : 64'sd0")
325            } else {
326                format!("k_l{j}")
327            };
328            lanes.push_str(&format!("  wire [63:0] g_l{j} = {g};\n"));
329        } else if p > 0 {
330            // Decomposed a·x + b (a=In0, x=In1, b=In2). Split each operand into 32-bit halves; the
331            // three 32×32 partial products (pll/phl/plh) are registered in `pipe_latch`. Then, mod
332            // 2^64: a·x = al·xl + ((ah·xl + al·xh) mod 2^32) << 32 (the ah·xh·2^64 term vanishes).
333            let a = src_reg(&eng.uniform, Src::In(0), j);
334            let x = src_reg(&eng.uniform, Src::In(1), j);
335            let b = src_reg(&eng.uniform, Src::In(2), j);
336            lanes.push_str(&format!(
337                "  wire [31:0] al_l{j} = {a}[31:0]; wire [31:0] ah_l{j} = {a}[63:32];\n"
338            ));
339            lanes.push_str(&format!(
340                "  wire [31:0] xl_l{j} = {x}[31:0]; wire [31:0] xh_l{j} = {x}[63:32];\n"
341            ));
342            lanes.push_str(&format!(
343                "  wire [63:0] mid_l{j} = {{(phl_l{j}[31:0] + plh_l{j}[31:0]), 32'h0}};\n"
344            ));
345            lanes.push_str(&format!("  wire [63:0] ax_l{j} = pll_l{j} + mid_l{j};\n"));
346            lanes.push_str(&format!("  wire [63:0] k_l{j} = ax_l{j} + {b};\n"));
347            let g = if eng.predicated {
348                format!("mask[{j}] ? k_l{j} : 64'sd0")
349            } else {
350                format!("k_l{j}")
351            };
352            lanes.push_str(&format!("  wire [63:0] g_l{j} = {g};\n"));
353        } else {
354            for (i, op) in eng.body.iter().enumerate() {
355                lanes.push_str(&format!(
356                    "  wire [63:0] v{i}_l{j} = {};\n",
357                    op.op.verilog(
358                        &src_reg(&eng.uniform, op.a, j),
359                        &src_reg(&eng.uniform, op.b, j)
360                    )
361                ));
362            }
363            let kres = format!("v{}_l{j}", eng.result);
364            let g = if eng.predicated {
365                format!("mask[{j}] ? $signed({kres}) : 64'sd0")
366            } else {
367                format!("$signed({kres})")
368            };
369            lanes.push_str(&format!("  wire [63:0] g_l{j} = {g};\n"));
370        }
371    }
372    let reduce_decl = if let Some(op) = eng.reduce {
373        let terms: Vec<String> = (0..n).map(|j| format!("$signed(g_l{j})")).collect();
374        format!("  wire [63:0] creduced = {};\n", terms.join(reduce_sep(op)))
375    } else {
376        String::new()
377    };
378
379    // ---- readback mux: raddr in 0..N selects a lane; raddr==N selects the reduced scalar ----
380    let mut dout = String::new();
381    for j in 0..n {
382        dout.push_str(&format!("raddr == {j} ? outr{j} : "));
383    }
384    let dout_default = format!("outr{}", n - 1);
385    dout.push_str(if eng.reduce.is_some() {
386        "redr"
387    } else {
388        &dout_default
389    });
390
391    // ---- load FSM: capture din into the slot addressed by lc ----
392    let mut load_case = String::new();
393    for (idx, slot) in slots.iter().enumerate() {
394        let lhs = match slot {
395            Slot::Uniform(k) => format!("u{k} <= din;"),
396            Slot::Mask => format!("mask <= din[{}:0];", n - 1),
397            Slot::Varying(j, k) => format!("x{k}_l{j} <= din;"),
398        };
399        load_case.push_str(&format!("        {idx}: {lhs}\n"));
400    }
401
402    // ---- free-running stage-1 latches: the three 32×32 partial products ----
403    let mut pipe_latch = String::new();
404    if p > 0 {
405        for j in 0..n {
406            pipe_latch.push_str(&format!("      pll_l{j} <= al_l{j} * xl_l{j};\n"));
407            pipe_latch.push_str(&format!("      phl_l{j} <= ah_l{j} * xl_l{j};\n"));
408            pipe_latch.push_str(&format!("      plh_l{j} <= al_l{j} * xh_l{j};\n"));
409        }
410    }
411
412    // ---- compute stage: latch every lane output + the reduced scalar together ----
413    let mut compute = String::new();
414    for j in 0..n {
415        compute.push_str(&format!("        outr{j} <= g_l{j};\n"));
416    }
417    if eng.reduce.is_some() {
418        compute.push_str("        redr <= creduced;\n");
419    }
420
421    // ---- sequential shift-add step: for each lane, add the shifted multiplicand when the current
422    // multiplier bit is set, then shift `mc` up and `mp` down. Runs every cycle while `running` and
423    // `scnt > 0` (it depends on `scnt`, so it lives INSIDE the FSM, not free-running). ----
424    let mut seq_step = String::new();
425    if is_seq {
426        for j in 0..n {
427            seq_step.push_str(&format!(
428                "\x20\x20\x20\x20\x20\x20\x20\x20 acc_l{j} <= acc_l{j} + pp_l{j};\n\
429                 \x20\x20\x20\x20\x20\x20\x20\x20 mc_l{j}  <= mc_l{j} << 1;\n\
430                 \x20\x20\x20\x20\x20\x20\x20\x20 mp_l{j}  <= mp_l{j} >> 1;\n"
431            ));
432        }
433    }
434    // ---- sequential FMA load: zero the accumulator, seed the multiplicand (=a) and multiplier (=x). ----
435    let mut seq_load = String::new();
436    if is_seq {
437        for j in 0..n {
438            let a = src_reg(&eng.uniform, Src::In(0), j);
439            let x = src_reg(&eng.uniform, Src::In(1), j);
440            seq_load.push_str(&format!(
441                "\x20\x20\x20\x20\x20\x20\x20 acc_l{j} <= 64'd0; mc_l{j} <= {a}; mp_l{j} <= {x};\n"
442            ));
443        }
444    }
445
446    // ---- go/run FSM: single-cycle (Combinational), drain `pipe` cycles (DecomposedFma), or run a
447    // WIDTH-step shift-add (SequentialFma). ----
448    let fsm = if is_seq {
449        format!(
450            "\x20\x20\x20\x20\x20 if (go) begin running <= 1'b1; scnt <= 8'd{WIDTH}; done <= 1'b0;\n\
451             {seq_load}\
452             \x20\x20\x20\x20\x20 end\n\
453             \x20\x20\x20\x20\x20 else if (running) begin\n\
454             \x20\x20\x20\x20\x20\x20\x20 if (scnt > 8'd0) begin\n\
455             {seq_step}\
456             \x20\x20\x20\x20\x20\x20\x20\x20 scnt <= scnt - 8'd1;\n\
457             \x20\x20\x20\x20\x20\x20\x20 end else begin\n\
458             {compute}\
459             \x20\x20\x20\x20\x20\x20\x20\x20 done <= 1'b1; running <= 1'b0;\n\
460             \x20\x20\x20\x20\x20\x20\x20 end\n\
461             \x20\x20\x20\x20\x20 end\n"
462        )
463    } else if p > 0 {
464        format!(
465            "\x20\x20\x20\x20\x20 if (go) begin running <= 1'b1; pcnt <= 8'd{p}; done <= 1'b0; end\n\
466             \x20\x20\x20\x20\x20 else if (running) begin\n\
467             \x20\x20\x20\x20\x20\x20\x20 if (pcnt > 8'd1) pcnt <= pcnt - 8'd1;\n\
468             \x20\x20\x20\x20\x20\x20\x20 else begin\n\
469             {compute}\
470             \x20\x20\x20\x20\x20\x20\x20\x20 done <= 1'b1; running <= 1'b0;\n\
471             \x20\x20\x20\x20\x20\x20\x20 end\n\
472             \x20\x20\x20\x20\x20 end\n"
473        )
474    } else {
475        format!(
476            "\x20\x20\x20\x20\x20 if (go) begin\n\
477             {compute}\
478             \x20\x20\x20\x20\x20\x20\x20 done <= 1'b1;\n\
479             \x20\x20\x20\x20\x20 end\n"
480        )
481    };
482    let rst_extra = if p > 0 || is_seq {
483        " running <= 1'b0;"
484    } else {
485        ""
486    };
487    let pipe_note = if is_seq {
488        format!(" The per-lane FMA is a SEQUENTIAL shift-add over {WIDTH} bits (one reused adder per lane, bounded fan-out → antenna-trivial); a result is ready {} cycles after `go` (done pulses).", WIDTH + 1)
489    } else if p > 0 {
490        format!(" The per-lane FMA is PIPELINED {p} stages (real GPU-ALU pipelining); a result is ready `pipe` cycles after `go` (done pulses).")
491    } else {
492        String::new()
493    };
494
495    let pred_note = if eng.predicated {
496        ", gated by a per-lane mask (SIMT divergence)"
497    } else {
498        ""
499    };
500    let red_note = if eng.reduce.is_some() {
501        ", then a cross-lane warp-reduce (raddr==N)"
502    } else {
503        ""
504    };
505    format!(
506        "// gpu_{name} — a tuneable SIMT data-parallel engine: {n} spatial lanes, each running the\n\
507         // SAME compiled straight-line kernel (the shader) on its own data{pred_note}{red_note}.\n\
508         // The lanes are WIRED (spatial) — no warp scheduler, no instruction fetch/decode/opcode\n\
509         // dispatch.{pipe_note} Narrow streaming I/O: `din` is loaded slot-by-slot into the lane\n\
510         // register file, `raddr` reads results back. SIMD = all lanes unmasked; map-then-reduce\n\
511         // feeds the NPU accumulator. 64-bit, wrapping like the VM's Int; proven ≡ the VM by our SAT.\n\
512         module gpu_{name}(input clk, input rst, input load, input [63:0] din,\n\
513         \x20                input go, input [7:0] raddr, output [63:0] dout, output reg done);\n\
514         {decls}{lanes}{reduce_decl}  assign dout = {dout};\n\
515         \x20 always @(posedge clk) begin\n\
516         {pipe_latch}\
517         \x20\x20\x20 if (rst) begin\n\
518         \x20\x20\x20\x20\x20 lc <= 16'd0; done <= 1'b0;{rst_extra}\n\
519         \x20\x20\x20 end else begin\n\
520         \x20\x20\x20\x20\x20 if (load) begin\n\
521         \x20\x20\x20\x20\x20\x20\x20 case (lc)\n\
522         {load_case}\
523         \x20\x20\x20\x20\x20\x20\x20\x20 default: ;\n\
524         \x20\x20\x20\x20\x20\x20\x20 endcase\n\
525         \x20\x20\x20\x20\x20\x20\x20 lc <= lc + 16'd1;\n\
526         \x20\x20\x20\x20\x20 end\n\
527         {fsm}\
528         \x20\x20\x20 end\n\
529         \x20 end\n\
530         endmodule\n"
531    )
532}
533
534/// Does the lane kernel compute `a·x + b` (inputs 0, 1, 2), wrapping, on the extremes and a spread of words?
535pub(crate) fn computes_affine_fma(eng: &SimtEngine) -> bool {
536    let words = [0i64, 1, -1, 2, i64::MAX, i64::MIN, 0x5555_5555_5555_5555, -0x1234_5678_9ABC_DEF1, 1 << 32, 3];
537    eng.n_in == 3
538        && words.iter().all(|&a| {
539            words.iter().all(|&x| {
540                let b = a.rotate_left(7) ^ x;
541                eval_fabric(&eng.body, &[a, x, b], eng.result) == a.wrapping_mul(x).wrapping_add(b)
542            })
543        })
544}
545
546/// The streamed engine ([`LaneArith::Streamed`]): the same ports and load order as every SIMT engine, its lanes
547/// sharing one pipelined multiply that each lane's `x` enters as it loads.
548fn emit_gpu_simt_streamed_verilog(name: &str, eng: &SimtEngine, every: usize) -> String {
549    let n = eng.n_lanes;
550    assert!(every >= 1, "a streamed multiply registers after every `every`-th level, every >= 1");
551    assert!(computes_affine_fma(eng), "the streamed multiply serves the a·x + b kernel");
552    assert!(!eng.uniform[1], "the streamed multiply issues a lane's a·x when its x loads: x is per-lane");
553    let (identity, fold) = match eng.reduce {
554        None => ("64'd0", ""),
555        Some(AluOp::Add) => ("64'd0", "redr + g"),
556        Some(AluOp::Or) => ("64'd0", "redr | g"),
557        Some(AluOp::Xor) => ("64'd0", "redr ^ g"),
558        Some(AluOp::And) => ("{64{1'b1}}", "redr & g"),
559        Some(op) => panic!("the streamed engine folds a warp-reduce of Add, Or, Xor or And, not {op:?}"),
560    };
561    let levels = mul_tree_levels(64);
562    let tree = mul_tree("mt", "ma", "mx", 64, |l| (l + 1) % every == 0 || l + 1 == levels);
563    let lat = tree.latency as usize;
564    let tag = (usize::BITS - (n.max(2) - 1).leading_zeros()) as usize;
565    let a_of = |j: usize| src_reg(&eng.uniform, Src::In(0), j);
566    let b_of = |j: usize| src_reg(&eng.uniform, Src::In(2), j);
567
568    let mut decls = String::new();
569    for k in [0usize, 2] {
570        if eng.uniform[k] {
571            decls.push_str(&format!("  reg [63:0] u{k};\n"));
572        } else {
573            for j in 0..n {
574                decls.push_str(&format!("  reg [63:0] x{k}_l{j};\n"));
575            }
576        }
577    }
578    if eng.predicated {
579        decls.push_str(&format!("  reg [{}:0] mask;\n", n - 1));
580    }
581    decls.push_str(&format!("  reg [63:0] ma, mx; reg mv; reg [{}:0] ml;\n", tag - 1));
582    for (r, bits) in &tree.regs {
583        decls.push_str(&format!("  reg [{}:0] {r};\n", bits - 1));
584    }
585    decls.push_str(&format!("  reg pv [1:{lat}]; reg [{}:0] pl [1:{lat}];\n", tag - 1));
586    for j in 0..n {
587        decls.push_str(&format!("  reg [63:0] outr{j};\n"));
588    }
589    decls.push_str("  reg [63:0] g, redr; reg gv, waiting; reg [15:0] lc; reg [7:0] nd;\n");
590
591    let lane = format!("pl[{lat}]");
592    let b_lane = if eng.uniform[2] {
593        "u2".to_string()
594    } else {
595        (0..n).rev().fold(String::from("64'd0"), |acc, j| format!("{lane} == {j} ? {} : {acc}", b_of(j)))
596    };
597    let mut wires = tree.wires.clone();
598    wires.push_str(&format!("  wire [63:0] k_out = {} + ({b_lane});\n", tree.product));
599    let kept = if eng.predicated { format!("mask[{lane}] ? k_out : 64'd0") } else { "k_out".to_string() };
600    wires.push_str(&format!("  wire [63:0] g_out = {kept};\n"));
601
602    let mut load_case = String::new();
603    for (idx, slot) in load_slots(eng).iter().enumerate() {
604        let lhs = match *slot {
605            Slot::Uniform(k) => format!("u{k} <= din;"),
606            Slot::Mask => format!("mask <= din[{}:0];", n - 1),
607            Slot::Varying(j, 1) => format!("begin ma <= {}; mx <= din; mv <= 1'b1; ml <= {tag}'d{j}; end", a_of(j)),
608            Slot::Varying(j, k) => format!("x{k}_l{j} <= din;"),
609        };
610        load_case.push_str(&format!("        {idx}: {lhs}\n"));
611    }
612    let moves: String = tree.moves.iter().map(|(r, e)| format!("    {r} <= {e};\n")).collect();
613    let mut lanes_out = String::new();
614    for j in 0..n {
615        lanes_out.push_str(&format!("        if ({lane} == {tag}'d{j}) outr{j} <= g_out;\n"));
616    }
617    let shift: String = (2..=lat).map(|k| format!("    pv[{k}] <= pv[{}]; pl[{k}] <= pl[{}];\n", k - 1, k - 1)).collect();
618    let mut dout = String::new();
619    for j in 0..n {
620        dout.push_str(&format!("raddr == {j} ? outr{j} : "));
621    }
622    dout.push_str(if eng.reduce.is_some() { "redr" } else { "64'd0" });
623    let fold_line = if eng.reduce.is_some() { format!("      if (gv) redr <= {fold};\n") } else { String::new() };
624    format!(
625        "// gpu_{name} — a SIMT engine of {n} lanes sharing one pipelined multiply (an aligned tree of adds, {lat} \
626         register stages):\n\
627         // each lane's a·x enters it in the clock its x loads, and its masked a·x + b is folded into the warp-reduce \
628         as it emerges.\n\
629         // 64-bit, wrapping like the VM's Int.\n\
630         module gpu_{name}(input clk, input rst, input load, input [63:0] din,\n\
631         \x20                input go, input [7:0] raddr, output [63:0] dout, output reg done);\n\
632         {decls}{wires}  assign dout = {dout};\n\
633         \x20 always @(posedge clk) begin\n\
634         {moves}\x20   pv[1] <= mv; pl[1] <= ml;\n\
635         {shift}\x20   if (rst) begin\n\
636         \x20     lc <= 16'd0; done <= 1'b0; waiting <= 1'b0; nd <= 8'd0; mv <= 1'b0; gv <= 1'b0; redr <= {identity};\n\
637         \x20   end else begin\n\
638         \x20     mv <= 1'b0;\n\
639         \x20     if (load) begin\n\
640         \x20       case (lc)\n\
641         {load_case}\
642         \x20       default: ;\n\
643         \x20       endcase\n\
644         \x20       lc <= lc + 16'd1;\n\
645         \x20     end\n\
646         \x20     gv <= pv[{lat}];\n\
647         \x20     if (pv[{lat}]) begin\n\
648         \x20       g <= g_out;\n\
649         {lanes_out}\
650         \x20     end\n\
651         {fold_line}\
652         \x20     if (gv) nd <= nd + 8'd1;\n\
653         \x20     if (go) waiting <= 1'b1;\n\
654         \x20     if ((go || waiting) && nd == 8'd{n}) begin done <= 1'b1; waiting <= 1'b0; end\n\
655         \x20   end\n\
656         \x20 end\n\
657         endmodule\n"
658    )
659}
660
661/// Evaluate the SIMT engine (the pure-Rust reference the silicon is checked against — N calls to
662/// the already-proven `eval_fabric`, one per lane). `lane_inputs[j]` are lane j's `n_in` kernel
663/// inputs (uniform inputs are set equal across lanes by the caller); `mask[j]` gates lane j when
664/// predicated (masked → 0). Returns the per-lane outputs and the optional warp-reduced scalar.
665pub fn eval_gpu_simt(
666    eng: &SimtEngine,
667    lane_inputs: &[Vec<i64>],
668    mask: &[bool],
669) -> (Vec<i64>, Option<i64>) {
670    assert_eq!(lane_inputs.len(), eng.n_lanes, "one input vector per lane");
671    if eng.predicated {
672        assert_eq!(mask.len(), eng.n_lanes, "one mask bit per lane");
673    }
674    let mut outs = Vec::with_capacity(eng.n_lanes);
675    for (j, li) in lane_inputs.iter().enumerate() {
676        assert_eq!(li.len(), eng.n_in, "lane {j} needs {} inputs", eng.n_in);
677        let k = eval_fabric(&eng.body, li, eng.result);
678        let active = !eng.predicated || mask[j];
679        outs.push(if active { k } else { 0 });
680    }
681    let reduced = eng.reduce.map(|op| {
682        // masked lanes are 0 (Add's identity), so the reduce = the fold over active lanes.
683        let seed = match op {
684            AluOp::Add
685            | AluOp::Sub
686            | AluOp::Or
687            | AluOp::Xor
688            | AluOp::Shl
689            | AluOp::Shr
690            | AluOp::UShr => 0,
691            AluOp::Mul => 1,
692            AluOp::And => -1, // identity for bitwise AND (all ones); GPU reductions use Add/Sub/Mul in practice
693        };
694        outs.iter().copied().fold(seed, |acc, x| op.eval(acc, x))
695    });
696    (outs, reduced)
697}
698
699/// The BoundedExpr of ONE lane's predicated output: `mask ? kernel : 0` — the SIMT divergence
700/// atom (a certified select over the compiled kernel). Uses the single-lane input names `in{k}`
701/// (all lanes are byte-identical modulo the lane suffix, so one lane's proof covers the array).
702pub fn simt_masked_lane_to_bounded(eng: &SimtEngine, width: u32) -> BoundedExpr {
703    let kernel = fabric_to_bounded(&eng.body, eng.result, width);
704    let cond = BoundedExpr::BitVecBinary {
705        op: BitVecBoundedOp::Eq,
706        left: Box::new(BoundedExpr::BitVecVar("mask".to_string(), width)),
707        right: Box::new(BoundedExpr::BitVecConst { width, value: 1 }),
708    };
709    BoundedExpr::BitVecIte {
710        cond: Box::new(cond),
711        then: Box::new(kernel),
712        else_: Box::new(BoundedExpr::BitVecConst { width, value: 0 }),
713    }
714}
715
716/// The BoundedExpr of the warp-reduce tail — a left fold of `op` over the lane-output variables
717/// `out0..out{N-1}`. Lowers the reduce so our SAT can certify it equals an independent fold (that
718/// every lane is summed, none dropped or duplicated).
719pub fn simt_reduce_to_bounded(eng: &SimtEngine, op: AluOp, width: u32) -> BoundedExpr {
720    let bop = match op {
721        AluOp::Add => BitVecBoundedOp::Add,
722        AluOp::Sub => BitVecBoundedOp::Sub,
723        AluOp::Mul => BitVecBoundedOp::Mul,
724        AluOp::And => BitVecBoundedOp::And,
725        AluOp::Or => BitVecBoundedOp::Or,
726        AluOp::Xor => BitVecBoundedOp::Xor,
727        AluOp::Shl => BitVecBoundedOp::Shl,
728        AluOp::Shr => BitVecBoundedOp::AShr,
729        AluOp::UShr => BitVecBoundedOp::Shr,
730    };
731    let mut acc = BoundedExpr::BitVecVar("out0".to_string(), width);
732    for j in 1..eng.n_lanes {
733        acc = BoundedExpr::BitVecBinary {
734            op: bop,
735            left: Box::new(acc),
736            right: Box::new(BoundedExpr::BitVecVar(format!("out{j}"), width)),
737        };
738    }
739    acc
740}
741
742/// The BoundedExpr of the SEQUENTIAL shift-add multiply `a·x`, UNROLLED over `width` bits, for
743/// single-lane inputs `a = in0`, `x = in1`. This is the exact combinational image of the `SequentialFma`
744/// FSM's `width`-step recurrence: `acc += (x[i] ? a<<i : 0)` for i in 0..width. Our own certified SAT
745/// proves this shift-add equals the hardware `a*x` for ALL inputs — so the strategy switch is FREE
746/// (no divergence). `b` and the predicate are the same tail the other strategies share, already
747/// certified by `gpu_kernel_is_certified_equal_to_the_vm_element_op` / `gpu_predication_is_certified`.
748pub fn simt_sequential_fma_to_bounded(width: u32) -> BoundedExpr {
749    let a = BoundedExpr::BitVecVar("in0".to_string(), width);
750    let x = BoundedExpr::BitVecVar("in1".to_string(), width);
751    let mut acc = BoundedExpr::BitVecConst { width, value: 0 };
752    for i in 0..width {
753        // The i-th multiplier bit `x[i]` (a 1-bit extract).
754        let xi = BoundedExpr::BitVecExtract {
755            high: i,
756            low: i,
757            operand: Box::new(x.clone()),
758        };
759        let cond = BoundedExpr::BitVecBinary {
760            op: BitVecBoundedOp::Eq,
761            left: Box::new(xi),
762            right: Box::new(BoundedExpr::BitVecConst { width: 1, value: 1 }),
763        };
764        // `a << i` — the multiplicand shifted to the i-th place (the FSM's left-shifting `mc`).
765        let shifted = BoundedExpr::BitVecBinary {
766            op: BitVecBoundedOp::Shl,
767            left: Box::new(a.clone()),
768            right: Box::new(BoundedExpr::BitVecConst {
769                width,
770                value: i as u64,
771            }),
772        };
773        let term = BoundedExpr::BitVecIte {
774            cond: Box::new(cond),
775            then: Box::new(shifted),
776            else_: Box::new(BoundedExpr::BitVecConst { width, value: 0 }),
777        };
778        acc = BoundedExpr::BitVecBinary {
779            op: BitVecBoundedOp::Add,
780            left: Box::new(acc),
781            right: Box::new(term),
782        };
783    }
784    acc
785}
786
787/// The BoundedExpr of ONE emitted sliced partial product `pp` — the fan-out-bounded image of the
788/// single-select mux `mp[0] ? mc : 0`. It models the emitted Verilog exactly:
789/// `pp = { {G{s}} & mc[hi:lo] , ... }` over `select_slice` groups of `G = width / select_slice`
790/// bits, where every `msel` copy equals the ONE select bit `s = mp0`. Group `g` occupies bits
791/// `[g*G .. g*G+G-1]`, and its value is `s==1 ? mc[hi:lo] : 0` (a `G`-wide select). Building `pp`
792/// from these per-group selects and proving `pp == (s==1 ? mc : 0)` CERTIFIES the byte-slicing drops
793/// or misaligns no bit — closing the gap where the slice was previously only iverilog-checked.
794///
795/// `s = BitVecVar("mp0", 1)`, `mc = BitVecVar("mc", width)`. `width % select_slice` must be 0 (the
796/// same tiling invariant the emitter asserts). Groups are concatenated high-to-low so group 0 lands
797/// at the LSB, matching `{ group[N-1], ..., group[1], group[0] }`.
798pub fn simt_sliced_pp_to_bounded(select_slice: usize, width: u32) -> BoundedExpr {
799    assert!(select_slice > 0, "select_slice must be positive");
800    assert_eq!(
801        width as usize % select_slice,
802        0,
803        "select_slice ({select_slice}) must divide width ({width}) evenly"
804    );
805    let group = width / select_slice as u32;
806    let s = BoundedExpr::BitVecVar("mp0".to_string(), 1);
807    let mc = BoundedExpr::BitVecVar("mc".to_string(), width);
808    // `s == 1` — every `(* keep *)` copy `mselK` is this ONE bit, so one condition covers them all.
809    let cond = BoundedExpr::BitVecBinary {
810        op: BitVecBoundedOp::Eq,
811        left: Box::new(s),
812        right: Box::new(BoundedExpr::BitVecConst { width: 1, value: 1 }),
813    };
814    // Build each group's G-wide select `s==1 ? mc[hi:lo] : 0`, then concat groups high→low.
815    let group_pp = |g: u32| -> BoundedExpr {
816        let hi = (g + 1) * group - 1;
817        let lo = g * group;
818        BoundedExpr::BitVecIte {
819            cond: Box::new(cond.clone()),
820            then: Box::new(BoundedExpr::BitVecExtract {
821                high: hi,
822                low: lo,
823                operand: Box::new(mc.clone()),
824            }),
825            else_: Box::new(BoundedExpr::BitVecConst {
826                width: group,
827                value: 0,
828            }),
829        }
830    };
831    // Start from the highest group; fold the rest below it (BitVecConcat(high, low)).
832    let top = select_slice as u32 - 1;
833    let mut pp = group_pp(top);
834    for g in (0..top).rev() {
835        pp = BoundedExpr::BitVecConcat(Box::new(pp), Box::new(group_pp(g)));
836    }
837    pp
838}
839
840/// A SOFT signoff advisory (NEVER a lock, NEVER blocks emission): the wide combinational strategies
841/// (`Combinational`, `DecomposedFma`) at datapath widths >= 32 are met1-antenna-dense on Sky130 —
842/// even after fan-out splitting and low utilization, a handful of met1 area-ratio violations remain
843/// (empirically ~10 on the N=4 flagship). `SequentialFma` (one reused adder per lane, bounded
844/// fan-out) is antenna-trivial. Returns `Some(warning)` for the dense envelope, `None` otherwise.
845pub fn signoff_advisory(arith: &LaneArith, width: u32, n_lanes: usize) -> Option<String> {
846    let dense = matches!(
847        arith,
848        LaneArith::Combinational | LaneArith::DecomposedFma { .. }
849    );
850    if dense && width >= 32 {
851        Some(format!(
852            "signoff advisory: {arith:?} at width {width} across {n_lanes} lane(s) is a WIDE \
853             combinational multiply — met1-antenna-dense on Sky130 (expect met1 area-ratio \
854             violations even at low utilization). Consider LaneArith::SequentialFma (one reused \
855             adder per lane, bounded fan-out) for an antenna-trivial signoff. This is advisory \
856             only — emission is unaffected."
857        ))
858    } else {
859        None
860    }
861}
862
863/// The value a load slot carries for a given `(lane_inputs, mask)` vector.
864fn slot_value(slot: Slot, lane_inputs: &[Vec<i64>], mask: &[bool]) -> u64 {
865    match slot {
866        Slot::Uniform(k) => lane_inputs[0][k] as u64,
867        Slot::Mask => {
868            let mut m: u64 = 0;
869            for (j, &b) in mask.iter().enumerate() {
870                if b {
871                    m |= 1 << j;
872                }
873            }
874            m
875        }
876        Slot::Varying(j, k) => lane_inputs[j][k] as u64,
877    }
878}
879
880/// Build the golden input/output hex, computing the outputs with `eval_gpu_simt` — the reference
881/// the silicon lock separately certifies bit-identical to the VM (so these goldens ARE the VM
882/// oracle). The input hex is one word per load slot per vector (uniforms, mask, then per-lane
883/// varyings — the `load_slots` order); the output hex is each lane output then the reduced scalar.
884/// `vectors[t]` provides `(lane_inputs, mask)` for vector t. Returns `(in_hex, out_hex)`.
885pub fn write_gpu_hex(eng: &SimtEngine, vectors: &[(Vec<Vec<i64>>, Vec<bool>)]) -> (String, String) {
886    let slots = load_slots(eng);
887    let mut hin = String::new();
888    let mut hout = String::new();
889    for (lane_inputs, mask) in vectors {
890        for &slot in &slots {
891            hin.push_str(&format!("{:016x}\n", slot_value(slot, lane_inputs, mask)));
892        }
893        let (outs, reduced) = eval_gpu_simt(eng, lane_inputs, mask);
894        for o in &outs {
895            hout.push_str(&format!("{:016x}\n", *o as u64));
896        }
897        if let Some(r) = reduced {
898            hout.push_str(&format!("{:016x}\n", r as u64));
899        }
900    }
901    (hin, hout)
902}
903
904/// The per-vector count of input hex words (one per load slot).
905fn inputs_per_vector(eng: &SimtEngine) -> usize {
906    load_slots(eng).len()
907}
908
909/// The per-vector count of output hex words: each lane output, then the reduced scalar (if any).
910fn outputs_per_vector(eng: &SimtEngine) -> usize {
911    eng.n_lanes + if eng.reduce.is_some() { 1 } else { 0 }
912}
913
914/// A self-checking iverilog testbench: for each of `n_vectors` vectors, reset the load counter,
915/// stream the load slots from `gpu_{name}_in.hex`, pulse `go` (all lanes compute in one cycle),
916/// then read back every lane output + the reduced scalar via `raddr` and compare against the VM
917/// oracle in `gpu_{name}_out.hex`. Prints PASS/FAIL. Layout matches `write_gpu_hex`.
918pub fn emit_gpu_simt_testbench(name: &str, eng: &SimtEngine, n_vectors: usize) -> String {
919    let ipv = inputs_per_vector(eng);
920    let opv = outputs_per_vector(eng);
921    let in_last = n_vectors * ipv - 1;
922    let out_last = n_vectors * opv - 1;
923    // Read back the reduced scalar too (at raddr == N) when a reduce is present.
924    let n_reads = opv; // == n (+1 if reduce)
925                       // Cycles to wait after `go` before the result is latched: Combinational = 0 (single-cycle),
926                       // DecomposedFma{s} = s (pipeline drain), SequentialFma = WIDTH+1 = 65 (the shift-add run + latch).
927    let wait_cycles = match &eng.arith {
928        LaneArith::Combinational => 0,
929        LaneArith::DecomposedFma { stages } => *stages,
930        LaneArith::SequentialFma { .. } => 65,
931        LaneArith::Streamed { .. } => 0,
932    };
933    let pipe_wait = if matches!(eng.arith, LaneArith::Streamed { .. }) {
934        "while (!done) begin @(posedge clk); #1; end ".to_string()
935    } else if wait_cycles > 0 {
936        format!("repeat ({wait_cycles}) @(posedge clk); ")
937    } else {
938        String::new()
939    };
940    format!(
941        "// tb_gpu_{name} — the SIMT engine's N-lane map (+ warp-reduce) vs the RUNNING VM's output.\n\
942         `timescale 1ns/1ps\n\
943         module tb_gpu_{name};\n\
944         \x20 reg clk = 0; reg rst, load, go; reg [63:0] din; reg [7:0] raddr;\n\
945         \x20 wire [63:0] dout; wire done;\n\
946         \x20 reg  [63:0] oin  [0:{in_last}];\n\
947         \x20 reg  [63:0] oout [0:{out_last}];\n\
948         \x20 integer i, s, r, errors = 0;\n\
949         \x20 gpu_{name} dut(.clk(clk), .rst(rst), .load(load), .din(din), .go(go),\n\
950         \x20                .raddr(raddr), .dout(dout), .done(done));\n\
951         \x20 always #5 clk = ~clk;\n\
952         \x20 initial begin\n\
953         \x20\x20\x20 $readmemh(\"gpu_{name}_in.hex\", oin);\n\
954         \x20\x20\x20 $readmemh(\"gpu_{name}_out.hex\", oout);\n\
955         \x20\x20\x20 rst = 1; load = 0; go = 0; din = 0; raddr = 0;\n\
956         \x20\x20\x20 for (i = 0; i < {n_vectors}; i = i + 1) begin\n\
957         \x20\x20\x20\x20\x20 rst = 1; @(posedge clk); #1 rst = 0;\n\
958         \x20\x20\x20\x20\x20 for (s = 0; s < {ipv}; s = s + 1) begin\n\
959         \x20\x20\x20\x20\x20\x20\x20 din = oin[i*{ipv}+s]; load = 1; @(posedge clk); #1;\n\
960         \x20\x20\x20\x20\x20 end\n\
961         \x20\x20\x20\x20\x20 load = 0; go = 1; @(posedge clk); #1 go = 0; {pipe_wait}\n\
962         \x20\x20\x20\x20\x20 for (r = 0; r < {n_reads}; r = r + 1) begin\n\
963         \x20\x20\x20\x20\x20\x20\x20 raddr = r; #1;\n\
964         \x20\x20\x20\x20\x20\x20\x20 if (dout !== oout[i*{opv}+r]) begin errors = errors + 1; $display(\"FAIL vec %0d read %0d\", i, r); end\n\
965         \x20\x20\x20\x20\x20 end\n\
966         \x20\x20\x20 end\n\
967         \x20\x20\x20 if (errors == 0) $display(\"PASS: {n_vectors} SIMT evaluations match the VM (N-lane map, no warp scheduler)\");\n\
968         \x20\x20\x20 else $display(\"FAIL: %0d SIMT mismatches\", errors);\n\
969         \x20\x20\x20 $finish;\n\
970         \x20 end\n\
971         endmodule\n"
972    )
973}