Skip to main content

logicaffeine_compile/codegen_sva/
systolic.rs

1//! systolic — the weight-stationary spatial GEMM tile (Phase 1).
2//!
3//! Today's GEMM lowering is a 1-D unrolled dot-product **reduction** (P multiply tiles → one accumulator →
4//! one output per operand pass): a MAC *lane*. A real accelerator (TPU MXU, a SambaNova PCU in GEMM mode)
5//! keeps the weights **stationary** in a 2-D array of PEs, streams activation rows through, and produces a
6//! whole output row **in parallel** — so each weight is read once and reused across the entire activation
7//! stream (the operand-reuse the reduction lane lacks), and N outputs settle per cycle instead of 1.
8//!
9//! A [`Systolic`] tile is `K` rows × `N` cols of int8 multiply-accumulate PEs. PE(r, c) holds the stationary
10//! weight `W[r][c]`. Each cycle one activation row `A[i][0..K]` is applied and one output row
11//! `O[i][0..N]`, with `O[i][c] = Σ_r int8(A[i][r]) · int8(W[r][c])`, is registered out. [`eval_systolic`] is
12//! the reference semantics; [`emit_systolic_verilog`] is the registered array; the per-PE datapath
13//! `psum + a·w` is SAT-certified ≡ its abstract form by the existing placer + bitblast (see the lock).
14//!
15//! The datatype is int8 operands accumulated in wrapping `i64` (the VM's `Int`), so for int8-range inputs the
16//! tile is byte-identical to a plain integer GEMM — and to the VM. Operand truncation is `x as i8 as i64`,
17//! matched exactly by the RTL's `$signed(x[7:0])` sign-extension, so eval ≡ RTL for ALL inputs.
18
19/// A weight-stationary GEMM tile: `k` contraction rows × `n` output columns of int8 MAC PEs, weights baked.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct Systolic {
22    /// Contraction depth (array rows / dot length).
23    pub k: usize,
24    /// Output width (array cols / outputs per cycle).
25    pub n: usize,
26    /// Stationary weights, `k*n` row-major: `weights[r*n + c] = W[r][c]` (int8 range).
27    pub weights: Vec<i64>,
28}
29
30impl Systolic {
31    /// Build a `k×n` weight-stationary tile; `weights` is `k*n` row-major.
32    pub fn new(k: usize, n: usize, weights: Vec<i64>) -> Systolic {
33        assert_eq!(weights.len(), k * n, "weights must be k*n row-major");
34        Systolic { k, n, weights }
35    }
36
37    /// The stationary weight held by PE(r, c).
38    pub fn w(&self, r: usize, c: usize) -> i64 {
39        self.weights[r * self.n + c]
40    }
41
42    /// Weight-reads to run an `m`-row activation stream: `k*n` — each weight loaded ONCE and reused across all
43    /// `m` rows (the reuse a reduction lane lacks, which re-fetches `k*n*m`).
44    pub fn weight_reads(&self) -> usize {
45        self.k * self.n
46    }
47
48    /// Cycles to stream `m` activation rows through the tile: `m` (one row in, one row out per cycle) — versus
49    /// `m*n*k` for a 1-cycle-per-MAC scalar reduction computing the same `m*n` outputs.
50    pub fn cycles(&self, m: usize) -> usize {
51        m
52    }
53}
54
55/// Sign-extend `x` from its low `bits` (the int-precision truncation; `bits >= 64` is a no-op). Matches the
56/// RTL's `$signed(x[bits-1:0])`.
57pub fn trunc_signed(x: i64, bits: u32) -> i64 {
58    if bits >= 64 {
59        x
60    } else {
61        let sh = 64 - bits;
62        (x << sh) >> sh
63    }
64}
65
66/// Reference semantics: `O(M×N) = A(M×K) · W(K×N)`, int8 operands accumulated in wrapping `i64`.
67/// `acts` is `m*k` row-major (`A[i][r] = acts[i*k + r]`); returns `O` as `m*n` row-major.
68pub fn eval_systolic(sys: &Systolic, acts: &[i64], m: usize) -> Vec<i64> {
69    eval_systolic_bits(sys, acts, m, 8)
70}
71
72/// Precision-parameterized integer semantics: operands truncated to `bits` (8/16/32/64), products
73/// accumulated in wrapping `i64` — the integer precision dial (Phase 4).
74pub fn eval_systolic_bits(sys: &Systolic, acts: &[i64], m: usize, bits: u32) -> Vec<i64> {
75    assert_eq!(acts.len(), m * sys.k, "activations must be m*k row-major");
76    let mut out = vec![0i64; m * sys.n];
77    for i in 0..m {
78        for c in 0..sys.n {
79            let mut acc: i64 = 0;
80            for r in 0..sys.k {
81                let a = trunc_signed(acts[i * sys.k + r], bits);
82                let w = trunc_signed(sys.w(r, c), bits);
83                acc = acc.wrapping_add(a.wrapping_mul(w));
84            }
85            out[i * sys.n + c] = acc;
86        }
87    }
88    out
89}
90
91/// Round `x` to bf16 (8-bit exponent, 7-bit mantissa) with round-to-nearest-even — the float precision dial
92/// (Phase 4). Idempotent, and a no-op on values already representable in bf16.
93pub fn round_bf16(x: f64) -> f64 {
94    let f = x as f32;
95    if !f.is_finite() {
96        return f as f64;
97    }
98    let bits = f.to_bits();
99    let rounding_bias = 0x7fff + ((bits >> 16) & 1); // round-to-nearest-even on the low 16 bits
100    let rounded = bits.wrapping_add(rounding_bias) & 0xFFFF_0000;
101    f32::from_bits(rounded) as f64
102}
103
104/// bf16 GEMM: operands rounded to bf16, products and accumulation in f32 (the standard bf16 MAC), returned as
105/// f64. `weights` is `k*n` row-major, `acts` is `m*k` row-major.
106pub fn eval_systolic_bf16(weights: &[f64], acts: &[f64], m: usize, k: usize, n: usize) -> Vec<f64> {
107    assert_eq!(weights.len(), k * n);
108    assert_eq!(acts.len(), m * k);
109    let mut out = vec![0f64; m * n];
110    for i in 0..m {
111        for c in 0..n {
112            let mut acc: f32 = 0.0;
113            for r in 0..k {
114                let a = round_bf16(acts[i * k + r]) as f32;
115                let w = round_bf16(weights[r * n + c]) as f32;
116                acc += a * w;
117            }
118            out[i * n + c] = acc as f64;
119        }
120    }
121    out
122}
123
124/// fp32 GEMM: operands, products and accumulation in f32 (the PCU's native 32-bit FU MAC — Plasticine's
125/// format), returned as f64. `weights` is `k*n` row-major, `acts` is `m*k` row-major.
126pub fn eval_systolic_f32(weights: &[f64], acts: &[f64], m: usize, k: usize, n: usize) -> Vec<f64> {
127    assert_eq!(weights.len(), k * n);
128    assert_eq!(acts.len(), m * k);
129    let mut out = vec![0f64; m * n];
130    for i in 0..m {
131        for c in 0..n {
132            let mut acc: f32 = 0.0;
133            for r in 0..k {
134                acc += (acts[i * k + r] as f32) * (weights[r * n + c] as f32);
135            }
136            out[i * n + c] = acc as f64;
137        }
138    }
139    out
140}
141
142/// Compute a `K×N` GEMM by TILING into `tk×tn` sub-tiles (Phase 2). Each weight sub-block is loaded ONCE
143/// into the on-chip scratchpad and reused across all `m` activation rows; `k`-direction tiles are
144/// accumulated. Byte-identical to [`eval_systolic`] on the full matrix — so the tiling is proven correct by
145/// equality to the untiled reference (which is itself ≡ the oracle ≡ VM). `acts` is `m*k` row-major.
146pub fn eval_tiled_gemm(sys: &Systolic, acts: &[i64], m: usize, tk: usize, tn: usize) -> Vec<i64> {
147    let (k, n) = (sys.k, sys.n);
148    assert!(tk >= 1 && tn >= 1, "tile dims must be positive");
149    assert_eq!(acts.len(), m * k, "activations must be m*k row-major");
150    let mut out = vec![0i64; m * n];
151    let mut c0 = 0;
152    while c0 < n {
153        let cn = (c0 + tn).min(n);
154        let mut r0 = 0;
155        while r0 < k {
156            let rk = (r0 + tk).min(k);
157            let (sub_k, sub_n) = (rk - r0, cn - c0);
158            // Weight sub-block resident in the scratchpad for this (k-tile, n-tile), reused across all m rows.
159            let mut wsub = Vec::with_capacity(sub_k * sub_n);
160            for r in r0..rk {
161                for c in c0..cn {
162                    wsub.push(sys.w(r, c));
163                }
164            }
165            let sub = Systolic::new(sub_k, sub_n, wsub);
166            let mut asub = Vec::with_capacity(m * sub_k);
167            for i in 0..m {
168                for r in r0..rk {
169                    asub.push(acts[i * k + r]);
170                }
171            }
172            let osub = eval_systolic(&sub, &asub, m); // m × sub_n
173            for i in 0..m {
174                for cc in 0..sub_n {
175                    let o = &mut out[i * n + (c0 + cc)];
176                    *o = o.wrapping_add(osub[i * sub_n + cc]);
177                }
178            }
179            r0 = rk;
180        }
181        c0 = cn;
182    }
183    out
184}
185
186/// A fused transformer-block stage over a weight-stationary GEMM tile (Phase 3): the GEMM, its fixed-point
187/// requantization `(acc·M) >> S` (arithmetic shift = floor-divide by `2^S`), a per-column bias add, and a
188/// ReLU — all fused into ONE spatial pass so the intermediates are streamed, never materialized to memory.
189#[derive(Clone, Debug)]
190pub struct FusedBlock {
191    pub sys: Systolic,
192    pub requant_m: i64,
193    pub requant_s: u32,
194    /// Per-output-column bias (`n` entries).
195    pub bias: Vec<i64>,
196}
197
198/// Evaluate the fused block: `O[i][c] = ReLU( ((A·W)[i][c] · M) >> S + bias[c] )` — one streamed pass, no
199/// intermediate tensor written back. Byte-identical to computing the four stages separately (proven by the
200/// lock), so the fusion is correctness-preserving; the win is that nothing lands in DRAM between stages.
201pub fn eval_fused_block(fb: &FusedBlock, acts: &[i64], m: usize) -> Vec<i64> {
202    let n = fb.sys.n;
203    assert_eq!(
204        fb.bias.len(),
205        n,
206        "bias must have one entry per output column"
207    );
208    let gemm = eval_systolic(&fb.sys, acts, m);
209    let mut out = vec![0i64; m * n];
210    for i in 0..m {
211        for c in 0..n {
212            let rq = fb.requant_m.wrapping_mul(gemm[i * n + c]) >> fb.requant_s; // arithmetic shift = requant
213            let biased = rq.wrapping_add(fb.bias[c]);
214            out[i * n + c] = biased.max(0); // ReLU
215        }
216    }
217    out
218}
219
220/// The UNFUSED reference: the same computation with each stage materialized into its own buffer (a DRAM
221/// round-trip per stage). Equal to [`eval_fused_block`] — the composition proof — but it writes `2·m·n`
222/// intermediate elements the fused pass never does. Returns `(result, intermediate_writes)`.
223pub fn eval_unfused_block(fb: &FusedBlock, acts: &[i64], m: usize) -> (Vec<i64>, usize) {
224    let n = fb.sys.n;
225    let gemm = eval_systolic(&fb.sys, acts, m); // materialized tensor #1 (m·n writes)
226    let mut requant = vec![0i64; m * n]; // materialized tensor #2 (m·n writes)
227    for i in 0..m {
228        for c in 0..n {
229            requant[i * n + c] = fb.requant_m.wrapping_mul(gemm[i * n + c]) >> fb.requant_s;
230        }
231    }
232    let mut out = vec![0i64; m * n];
233    for i in 0..m {
234        for c in 0..n {
235            out[i * n + c] = requant[i * n + c].wrapping_add(fb.bias[c]).max(0);
236        }
237    }
238    (out, 2 * m * n) // two intermediate tensors written+read that the fused pass streams instead
239}
240
241/// The systolic microarchitecture dial. Both schedules compute the identical GEMM (`eval_systolic`); they
242/// trade against each other rather than dominate, so both stay live (see the dominance characterization in
243/// `pipelined_systolic_lock`). [`SystolicSchedule::Combinational`] accumulates each column with a K-deep adder
244/// tree — one output row per clock, zero fill latency, but the critical path grows with K. [`Pipelined`](SystolicSchedule::Pipelined)
245/// registers the activation (east) and partial sum (south) at every PE so the only path between flip-flops is
246/// one mul + one add — the critical path is K-INDEPENDENT (a real MXU stays at GHz at 128×128), at the cost of
247/// `K+N−1` fill latency and the extra registers. Default is `Combinational` (smallest; correct for low clocks).
248///
249/// [`Pipelined`]: SystolicSchedule::Pipelined
250#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
251pub enum SystolicSchedule {
252    #[default]
253    Combinational,
254    Pipelined,
255}
256
257/// Emit the weight-stationary array under the chosen [`SystolicSchedule`] at precision `bits`. Both schedules
258/// are observationally equivalent (`eval_systolic`); this is the single retargeting seam an emitter/target
259/// selects the microarchitecture through.
260pub fn emit_systolic_scheduled(
261    name: &str,
262    sys: &Systolic,
263    schedule: SystolicSchedule,
264    bits: u32,
265) -> String {
266    match schedule {
267        SystolicSchedule::Combinational => emit_systolic_verilog_bits(name, sys, bits),
268        SystolicSchedule::Pipelined => emit_systolic_pipelined_verilog_bits(name, sys, bits),
269    }
270}
271
272/// Emit the registered weight-stationary array as synthesizable Verilog. Ports: `clk`, `rst`, the `k`
273/// activation inputs `a0..a{k-1}` (64-bit; the low 8 bits are the int8 operand), and the `n` registered
274/// outputs `o0..o{n-1}`. Weights are baked as signed 64-bit constants (the sign-extended int8). One output
275/// row is produced per clock; `o{c}` on cycle `t+1` is the dot of the activation row applied at cycle `t`.
276pub fn emit_systolic_verilog(name: &str, sys: &Systolic) -> String {
277    emit_systolic_verilog_bits(name, sys, 8)
278}
279
280/// Emit the weight-stationary array at a chosen integer precision `bits` (8/16/32/64) — the precision dial's
281/// RTL (Phase 4). Operands are the low `bits` of each input, sign-extended; weights baked as sign-extended
282/// two's-complement constants. Matches [`eval_systolic_bits`] for ALL inputs.
283pub fn emit_systolic_verilog_bits(name: &str, sys: &Systolic, bits: u32) -> String {
284    let (k, n) = (sys.k, sys.n);
285    let hi = bits.min(64) - 1;
286    let mut s = String::new();
287    s.push_str(&format!("module {name}(\n  input clk,\n  input rst,\n"));
288    for r in 0..k {
289        s.push_str(&format!("  input signed [63:0] a{r},\n"));
290    }
291    for c in 0..n {
292        let comma = if c + 1 < n { "," } else { "" };
293        s.push_str(&format!("  output reg signed [63:0] o{c}{comma}\n"));
294    }
295    s.push_str(");\n");
296    // operand = low `bits`, sign-extended to 64 (matches `trunc_signed(x, bits)` in eval_systolic_bits).
297    for r in 0..k {
298        s.push_str(&format!(
299            "  wire signed [63:0] sa{r} = $signed(a{r}[{hi}:0]);\n"
300        ));
301    }
302    s.push_str("  always @(posedge clk) begin\n    if (rst) begin\n");
303    for c in 0..n {
304        s.push_str(&format!("      o{c} <= 64'sd0;\n"));
305    }
306    s.push_str("    end else begin\n");
307    for c in 0..n {
308        let terms: Vec<String> = (0..k)
309            .map(|r| {
310                let w = trunc_signed(sys.w(r, c), bits);
311                format!("sa{r} * 64'sh{:016X}", w as u64)
312            })
313            .collect();
314        s.push_str(&format!("      o{c} <= {};\n", terms.join(" + ")));
315    }
316    s.push_str("    end\n  end\nendmodule\n");
317    s
318}
319
320// ─────────────────────────────────────────────────────────────────────────────────────────────────
321// PIPELINED, register-staged weight-stationary systolic array (the TPU-MXU microarchitecture). Unlike the
322// combinational tile above (a K-deep adder tree per column → critical path grows with K), each PE registers
323// its activation (flowing EAST) and its partial sum (flowing SOUTH), so the only combinational path between
324// flip-flops is ONE multiply + ONE add — the critical path is K-INDEPENDENT (why a real MXU stays at GHz at
325// 128×128). On-chip input skew (row r delayed r cycles) + output de-skew (col c delayed N-1-c) keep the clean
326// row-in/row-out interface; row i's output emerges at cycle i + L, L = K+N-1.
327// ─────────────────────────────────────────────────────────────────────────────────────────────────
328
329/// The fill latency of the pipelined array: row `i`'s output emerges at cycle `i + L`, `L = K + N − 1`.
330/// Shared by the golden model, the RTL emitter, and the lock so they cannot disagree.
331pub fn pipelined_latency(k: usize, n: usize) -> usize {
332    k + n - 1
333}
334
335/// Cycle-accurate golden model of the pipelined weight-stationary array — steps the register planes exactly
336/// as the emitted RTL does (activation EAST, partial-sum SOUTH, on-chip skew/de-skew), so the RTL must match
337/// it cycle-for-cycle. Returns `(O row-major, L)` where `O` ≡ [`eval_systolic_bits`] (the math is unchanged;
338/// only the timing differs). `acts` is `m*k` row-major.
339pub fn eval_systolic_pipelined(
340    sys: &Systolic,
341    acts: &[i64],
342    m: usize,
343    bits: u32,
344) -> (Vec<i64>, usize) {
345    eval_systolic_pipelined_acc(sys, acts, m, bits, ACC_BITS_FULL)
346}
347
348/// [`eval_systolic_pipelined`] with an explicit accumulator width `acc_bits` — the partial-sum register wraps
349/// (two's-complement) at `acc_bits`, exactly like the `acc_bits`-wide `ps_reg` in the emitted RTL. With
350/// `acc_bits = 64` this is bit-identical to [`eval_systolic_pipelined`]; with [`ACC_BITS_MXU`] it models the
351/// int32-accumulator tape and is lossless whenever the true accumulation fits (guarded in the lock).
352pub fn eval_systolic_pipelined_acc(
353    sys: &Systolic,
354    acts: &[i64],
355    m: usize,
356    bits: u32,
357    acc_bits: u32,
358) -> (Vec<i64>, usize) {
359    assert_eq!(acts.len(), m * sys.k, "activations must be m*k row-major");
360    let (k, n) = (sys.k, sys.n);
361    let l = pipelined_latency(k, n);
362    let w = |r: usize, c: usize| trunc_signed(sys.w(r, c), bits);
363    let mut a_reg = vec![0i64; k * n]; // activation register per PE (flows EAST)
364    let mut ps_reg = vec![0i64; k * n]; // partial-sum register per PE (flows SOUTH)
365    let mut skew: Vec<Vec<i64>> = (0..k).map(|r| vec![0i64; r]).collect(); // input skew: row r depth r
366    let mut deskew: Vec<Vec<i64>> = (0..n).map(|c| vec![0i64; n - 1 - c]).collect(); // output de-skew: col c depth n-1-c
367    let mut out = vec![0i64; m * n];
368    for t in 0..(m + l + 1) {
369        // present activation row t (zeros while draining) into the input-skew delay lines
370        let a_top: Vec<i64> = (0..k)
371            .map(|r| {
372                if t < m {
373                    trunc_signed(acts[t * k + r], bits)
374                } else {
375                    0
376                }
377            })
378            .collect();
379        let mut a_skew = vec![0i64; k];
380        for r in 0..k {
381            if r == 0 {
382                a_skew[0] = a_top[0];
383            } else {
384                a_skew[r] = skew[r][r - 1]; // emerges after r cycles
385                for j in (1..r).rev() {
386                    skew[r][j] = skew[r][j - 1];
387                }
388                skew[r][0] = a_top[r];
389            }
390        }
391        // PE next-state, computed from CURRENT registers (non-blocking semantics), then committed
392        let mut a_next = vec![0i64; k * n];
393        let mut ps_next = vec![0i64; k * n];
394        for r in 0..k {
395            for c in 0..n {
396                let a_in = if c == 0 {
397                    a_skew[r]
398                } else {
399                    a_reg[r * n + (c - 1)]
400                };
401                let ps_in = if r == 0 { 0 } else { ps_reg[(r - 1) * n + c] };
402                a_next[r * n + c] = a_in;
403                ps_next[r * n + c] =
404                    trunc_signed(ps_in.wrapping_add(a_in.wrapping_mul(w(r, c))), acc_bits);
405            }
406        }
407        // output de-skew: sample the current bottom-row partial sum, delay col c by n-1-c
408        let mut o_now = vec![0i64; n];
409        for c in 0..n {
410            let bottom = ps_reg[(k - 1) * n + c];
411            let d = n - 1 - c;
412            if d == 0 {
413                o_now[c] = bottom;
414            } else {
415                o_now[c] = deskew[c][d - 1];
416                for j in (1..d).rev() {
417                    deskew[c][j] = deskew[c][j - 1];
418                }
419                deskew[c][0] = bottom;
420            }
421        }
422        // row i's aligned output emerges at cycle i + L
423        if t >= l && (t - l) < m {
424            let i = t - l;
425            out[i * n..i * n + n].copy_from_slice(&o_now);
426        }
427        a_reg = a_next;
428        ps_reg = ps_next;
429    }
430    (out, l)
431}
432
433/// [`eval_systolic_pipelined`] without the latency — the `O` matrix only, for equivalence tests.
434pub fn eval_systolic_pipelined_o(sys: &Systolic, acts: &[i64], m: usize, bits: u32) -> Vec<i64> {
435    eval_systolic_pipelined(sys, acts, m, bits).0
436}
437
438/// Emit the PIPELINED weight-stationary array (int8) as synthesizable Verilog. See
439/// [`emit_systolic_pipelined_verilog_bits`]; the critical path is ONE mul + ONE add (K-independent).
440pub fn emit_systolic_pipelined_verilog(name: &str, sys: &Systolic) -> String {
441    emit_systolic_pipelined_verilog_bits(name, sys, 8)
442}
443
444/// Default accumulator width — 64-bit, so [`emit_systolic_pipelined_verilog_bits`] is bit-identical to the
445/// i64 model for EVERY input. Real int8 MXUs (and the taped tiles) use a narrower [`ACC_BITS_MXU`] accumulator.
446pub const ACC_BITS_FULL: u32 = 64;
447/// The TPU-MXU-standard int8 accumulator width (int32). Narrows the inter-PE partial-sum bus so a systolic
448/// grid routes without congestion; lossless for int8 with contraction depth K where |Σ a·w| < 2³¹.
449pub const ACC_BITS_MXU: u32 = 32;
450
451/// Emit the pipelined weight-stationary array at precision `bits` with a full (64-bit) accumulator.
452pub fn emit_systolic_pipelined_verilog_bits(name: &str, sys: &Systolic, bits: u32) -> String {
453    emit_systolic_pipelined_verilog_bits_acc(name, sys, bits, ACC_BITS_FULL)
454}
455
456/// Emit the pipelined, register-staged weight-stationary systolic array at operand precision `bits` and
457/// accumulator width `acc_bits`. Structure (matches [`eval_systolic_pipelined_acc`] register-for-register):
458/// per PE an activation register (`a_reg`, flows EAST, `bits` wide) and a partial-sum register (`ps_reg`,
459/// flows SOUTH, `acc_bits` wide) with `ps_reg[r][c] <= ps_in + a_in·W[r][c]` — the only combinational path
460/// between flip-flops is one `bits×bits` multiply + one add, so the critical path does NOT grow with K.
461/// On-chip input-skew (row r delayed r cycles) and output de-skew (col c delayed N-1-c) keep the clean
462/// `clk/rst/a0..a{k-1}/o0..o{n-1}` interface; `o{c}` on cycle `t` holds output row `t-L` (L=K+N-1). Narrowing
463/// `acc_bits` to [`ACC_BITS_MXU`] shrinks the inter-PE bus for a congestion-free tape and is lossless while
464/// the true accumulation fits `acc_bits` (guarded in the lock).
465pub fn emit_systolic_pipelined_verilog_bits_acc(
466    name: &str,
467    sys: &Systolic,
468    bits: u32,
469    acc_bits: u32,
470) -> String {
471    pe_array_verilog(name, sys.k, sys.n, bits, acc_bits, WeightSource::Baked(sys))
472}
473
474/// Where a PE array's weights come from: baked into the netlist as constants, or loaded at run time through
475/// a row port (`w_load`, `w_row`, `w_data` = one packed row of `n` weights) — the spatial IR's
476/// `Compute(MatmulTiles)` stage on our fabric loads its weight block, then streams activations.
477#[derive(Clone, Copy)]
478pub enum WeightSource<'a> {
479    Baked(&'a Systolic),
480    Loaded(WeightStart),
481}
482
483/// What a loaded array's weight registers hold before their rows are first loaded.
484#[derive(Clone, Copy, Debug, PartialEq, Eq)]
485pub enum WeightStart {
486    /// What the register powers up with: every row is loaded before the array multiplies, as in a GEMM as deep as the
487    /// array.
488    PowerUp,
489    /// Zero, the register's initial value: a matmul shallower than the array loads only its first rows, and the rest
490    /// multiply the zero activations it streams through them into zero, never into an unknown a register powered up
491    /// with. A 7-series configuration and a simulator start a register at its initial value; an ASIC flop holds
492    /// whatever it powers up with, which the zero activations still multiply into zero.
493    Zero,
494}
495
496/// A weight-stationary PE array whose weights are loaded through a port and hold what they power up with until then:
497/// `pe_array_verilog(.., Loaded(PowerUp))`. Cycle-for-cycle identical to the baked array once the same weights are
498/// loaded (locked).
499pub fn emit_systolic_pipelined_loadable_verilog(
500    name: &str,
501    k: usize,
502    n: usize,
503    bits: u32,
504    acc_bits: u32,
505) -> String {
506    emit_systolic_pipelined_loadable_verilog_starting(name, k, n, bits, acc_bits, WeightStart::PowerUp)
507}
508
509/// [`emit_systolic_pipelined_loadable_verilog`] with its weight registers starting at `start`.
510pub fn emit_systolic_pipelined_loadable_verilog_starting(
511    name: &str,
512    k: usize,
513    n: usize,
514    bits: u32,
515    acc_bits: u32,
516    start: WeightStart,
517) -> String {
518    pe_array_verilog(name, k, n, bits, acc_bits, WeightSource::Loaded(start))
519}
520
521fn pe_array_verilog(
522    name: &str,
523    k: usize,
524    n: usize,
525    bits: u32,
526    acc_bits: u32,
527    weights: WeightSource,
528) -> String {
529    let hi = bits.min(64) - 1;
530    // narrow the STREAMING operand to its real precision so synthesis infers a `bits×bits` multiplier, not a
531    // 64×64 one — an int8 tile then routes with an 8×8 multiplier per PE.
532    let aw = bits.min(64); // activation datapath width
533    let pw = (2 * bits).min(acc_bits); // product width
534    let ah = acc_bits.min(64) - 1; // accumulator MSB
535    let mut s = String::new();
536    s.push_str(&format!("module {name}(\n  input clk,\n  input rst,\n"));
537    if matches!(weights, WeightSource::Loaded(_)) {
538        let rw = (usize::BITS - (k.max(2) - 1).leading_zeros()).max(1);
539        s.push_str(&format!(
540            "  input w_load,\n  input [{}:0] w_row,\n  input [{}:0] w_data,\n",
541            rw - 1,
542            n as u32 * bits - 1
543        ));
544    }
545    for r in 0..k {
546        s.push_str(&format!("  input signed [63:0] a{r},\n"));
547    }
548    for c in 0..n {
549        let comma = if c + 1 < n { "," } else { "" };
550        s.push_str(&format!("  output signed [63:0] o{c}{comma}\n"));
551    }
552    s.push_str(");\n");
553    for r in 0..k {
554        s.push_str(&format!(
555            "  wire signed [{}:0] sa{r} = $signed(a{r}[{hi}:0]);\n",
556            aw - 1
557        ));
558    }
559    // input-skew registers (row r has depth r) and the skewed-activation reads (activation-width)
560    for r in 0..k {
561        for j in 0..r {
562            s.push_str(&format!("  reg signed [{}:0] skew_{r}_{j};\n", aw - 1));
563        }
564    }
565    for r in 0..k {
566        if r == 0 {
567            s.push_str(&format!("  wire signed [{}:0] a_sk0 = sa0;\n", aw - 1));
568        } else {
569            s.push_str(&format!(
570                "  wire signed [{}:0] a_sk{r} = skew_{r}_{};\n",
571                aw - 1,
572                r - 1
573            ));
574        }
575    }
576    // PE registers + combinational in-wires: activation (east) is activation-width, partial sum (south) is 64-bit
577    for r in 0..k {
578        for c in 0..n {
579            s.push_str(&format!("  reg signed [{}:0] a_reg_{r}_{c};\n", aw - 1));
580            s.push_str(&format!("  reg signed [{ah}:0] ps_reg_{r}_{c};\n"));
581            let a_in = if c == 0 {
582                format!("a_sk{r}")
583            } else {
584                format!("a_reg_{r}_{}", c - 1)
585            };
586            s.push_str(&format!(
587                "  wire signed [{}:0] a_in_{r}_{c} = {a_in};\n",
588                aw - 1
589            ));
590            let ps_in = if r == 0 {
591                format!("{acc_bits}'sd0")
592            } else {
593                format!("ps_reg_{}_{c}", r - 1)
594            };
595            s.push_str(&format!(
596                "  wire signed [{ah}:0] ps_in_{r}_{c} = {ps_in};\n"
597            ));
598            // bits×bits product (exact int8 → `pw` bits), sign-extended into the acc_bits accumulate
599            match weights {
600                WeightSource::Baked(sys) => {
601                    let w = trunc_signed(sys.w(r, c), bits);
602                    let wmask = if bits >= 64 {
603                        u64::MAX
604                    } else {
605                        (1u64 << bits) - 1
606                    };
607                    let wv = (w as u64) & wmask;
608                    s.push_str(&format!(
609                        "  wire signed [{}:0] prod_{r}_{c} = a_in_{r}_{c} * {bits}'sh{wv:X};\n",
610                        pw - 1
611                    ));
612                }
613                WeightSource::Loaded(start) => {
614                    let init = match start {
615                        WeightStart::PowerUp => String::new(),
616                        WeightStart::Zero => format!(" = {bits}'sd0"),
617                    };
618                    s.push_str(&format!("  reg signed [{}:0] w_reg_{r}_{c}{init};\n", bits - 1));
619                    s.push_str(&format!(
620                        "  wire signed [{}:0] prod_{r}_{c} = a_in_{r}_{c} * w_reg_{r}_{c};\n",
621                        pw - 1
622                    ));
623                }
624            }
625        }
626    }
627    // output de-skew registers (col c has depth n-1-c) and the output reads
628    for c in 0..n {
629        let d = n - 1 - c;
630        for j in 0..d {
631            s.push_str(&format!("  reg signed [{ah}:0] deskew_{c}_{j};\n"));
632        }
633        if d == 0 {
634            s.push_str(&format!("  assign o{c} = ps_reg_{}_{c};\n", k - 1));
635        } else {
636            s.push_str(&format!("  assign o{c} = deskew_{c}_{};\n", d - 1));
637        }
638    }
639    s.push_str("  always @(posedge clk) begin\n    if (rst) begin\n");
640    for r in 0..k {
641        for j in 0..r {
642            s.push_str(&format!("      skew_{r}_{j} <= {aw}'sd0;\n"));
643        }
644    }
645    for r in 0..k {
646        for c in 0..n {
647            s.push_str(&format!("      a_reg_{r}_{c} <= {aw}'sd0;\n"));
648            s.push_str(&format!("      ps_reg_{r}_{c} <= {acc_bits}'sd0;\n"));
649        }
650    }
651    for c in 0..n {
652        for j in 0..(n - 1 - c) {
653            s.push_str(&format!("      deskew_{c}_{j} <= {acc_bits}'sd0;\n"));
654        }
655    }
656    s.push_str("    end else begin\n");
657    for r in 1..k {
658        s.push_str(&format!("      skew_{r}_0 <= sa{r};\n"));
659        for j in 1..r {
660            s.push_str(&format!("      skew_{r}_{j} <= skew_{r}_{};\n", j - 1));
661        }
662    }
663    for r in 0..k {
664        for c in 0..n {
665            s.push_str(&format!("      a_reg_{r}_{c} <= a_in_{r}_{c};\n"));
666            s.push_str(&format!(
667                "      ps_reg_{r}_{c} <= ps_in_{r}_{c} + prod_{r}_{c};\n"
668            ));
669        }
670    }
671    for c in 0..n {
672        let d = n - 1 - c;
673        if d > 0 {
674            s.push_str(&format!("      deskew_{c}_0 <= ps_reg_{}_{c};\n", k - 1));
675            for j in 1..d {
676                s.push_str(&format!("      deskew_{c}_{j} <= deskew_{c}_{};\n", j - 1));
677            }
678        }
679    }
680    s.push_str("    end\n  end\n");
681    if matches!(weights, WeightSource::Loaded(_)) {
682        s.push_str("  always @(posedge clk) begin\n    if (w_load) begin\n");
683        for r in 0..k {
684            s.push_str(&format!("      if (w_row == {r}) begin\n"));
685            for c in 0..n {
686                s.push_str(&format!(
687                    "        w_reg_{r}_{c} <= $signed(w_data[{}:{}]);\n",
688                    (c as u32 + 1) * bits - 1,
689                    c as u32 * bits
690                ));
691            }
692            s.push_str("      end\n");
693        }
694        s.push_str("    end\n  end\n");
695    }
696    s.push_str("endmodule\n");
697    s
698}
699
700/// A bench that loads `sys`'s weights into the loadable array while both arrays sit in reset, releases both
701/// on the same edge, streams `acts` (m rows) through both, and compares every output every cycle. Prints
702/// `OUT o <i*n+c> <value>` for the loadable array's aligned output rows (row i sampled after edge i+L-1),
703/// `LATENCY <L>`, and `PASS:` iff the two arrays never differed.
704pub fn emit_loadable_vs_baked_testbench(
705    baked: &str,
706    loadable: &str,
707    sys: &Systolic,
708    acts: &[i64],
709    m: usize,
710    bits: u32,
711    acc_bits: u32,
712) -> String {
713    let (k, n) = (sys.k, sys.n);
714    let (_, l) = eval_systolic_pipelined_acc(sys, acts, m, bits, acc_bits);
715    let rw = (usize::BITS - (k.max(2) - 1).leading_zeros()).max(1);
716    let wmask: u64 = if bits >= 64 {
717        u64::MAX
718    } else {
719        (1u64 << bits) - 1
720    };
721    let mut tb =
722        String::from("`timescale 1ns/1ps\nmodule tb_pe;\n  reg clk = 0, rst = 1, w_load = 0;\n");
723    tb.push_str(&format!(
724        "  reg [{}:0] w_row = 0;\n  reg [{}:0] w_data = 0;\n",
725        rw - 1,
726        n as u32 * bits - 1
727    ));
728    for r in 0..k {
729        tb.push_str(&format!("  reg signed [63:0] a{r} = 0;\n"));
730    }
731    for c in 0..n {
732        tb.push_str(&format!("  wire signed [63:0] ob{c}, ol{c};\n"));
733    }
734    let ac: Vec<String> = (0..k).map(|r| format!(".a{r}(a{r})")).collect();
735    let ob: Vec<String> = (0..n).map(|c| format!(".o{c}(ob{c})")).collect();
736    let ol: Vec<String> = (0..n).map(|c| format!(".o{c}(ol{c})")).collect();
737    tb.push_str(&format!(
738        "  {baked} u_baked(.clk(clk), .rst(rst), {}, {});\n",
739        ac.join(", "),
740        ob.join(", ")
741    ));
742    tb.push_str(&format!("  {loadable} u_loadable(.clk(clk), .rst(rst), .w_load(w_load), .w_row(w_row), .w_data(w_data), {}, {});\n", ac.join(", "), ol.join(", ")));
743    tb.push_str("  integer fails = 0;\n  always #5 clk = ~clk;\n  initial begin\n    rst = 1; @(posedge clk); #1;\n");
744    for r in 0..k {
745        // Pack row r: weight c at bits [c*bits +: bits].
746        let mut packed = String::new();
747        for c in (0..n).rev() {
748            let w = (trunc_signed(sys.w(r, c), bits) as u64) & wmask;
749            packed.push_str(&format!("{:0width$b}", w, width = bits as usize));
750        }
751        tb.push_str(&format!(
752            "    w_load = 1; w_row = {r}; w_data = {}'b{packed}; @(posedge clk); #1;\n",
753            n as u32 * bits
754        ));
755    }
756    tb.push_str("    w_load = 0; @(posedge clk); #1 rst = 0;\n");
757    let cmp: Vec<String> = (0..n).map(|c| format!("ob{c} !== ol{c}")).collect();
758    for e in 0..(m + l) {
759        for r in 0..k {
760            let v = if e < m { acts[e * k + r] } else { 0 };
761            tb.push_str(&format!("    a{r} = 64'sh{:016X};\n", v as u64));
762        }
763        tb.push_str("    @(posedge clk); #1;\n");
764        tb.push_str(&format!(
765            "    if ({}) begin $display(\"MISMATCH at edge {e}\"); fails = fails + 1; end\n",
766            cmp.join(" || ")
767        ));
768        if e + 1 >= l && (e + 1 - l) < m {
769            let i = e + 1 - l;
770            for c in 0..n {
771                tb.push_str(&format!(
772                    "    $display(\"OUT o {} %0d\", ol{c});\n",
773                    i * n + c
774                ));
775            }
776        }
777    }
778    tb.push_str(&format!("    $display(\"LATENCY {l}\");\n"));
779    tb.push_str("    if (fails == 0) $display(\"PASS: loadable == baked\"); else $display(\"FAIL: %0d cycles differ\", fails);\n    $finish;\n  end\nendmodule\n");
780    tb
781}
782
783// ══════════════════════════════════════════════════════════════════════════════════════════════════
784// OUTPUT-STATIONARY variant — the SN40L PCU's other dataflow. An R×C array of stationary accumulators;
785// activations stream EAST, weights stream SOUTH, and PE(i,j) accumulates O[i][j] = Σ_t A[i][t]·W[t][j]
786// in place over K contraction beats. Same PE core (`acc + a·w`), same K-independent pipelined critical path.
787// ══════════════════════════════════════════════════════════════════════════════════════════════════
788
789/// An output-stationary tile: an `r×c` grid of accumulators computing `O = A·W` with contraction depth `k`
790/// (`A` is r×k row-major, `W` is k×c row-major, `O` is r×c row-major). Both operands stream in; O stays put.
791#[derive(Clone, Debug)]
792pub struct SystolicOs {
793    pub r: usize,
794    pub c: usize,
795    pub k: usize,
796}
797
798impl SystolicOs {
799    pub fn new(r: usize, c: usize, k: usize) -> Self {
800        SystolicOs { r, c, k }
801    }
802}
803
804/// Reference int8 GEMM for the output-stationary tile: `O[i][j] = Σ_t A[i][t]·W[t][j]` (wrapping i64).
805pub fn eval_systolic_os(os: &SystolicOs, a: &[i64], w: &[i64], bits: u32) -> Vec<i64> {
806    let (r, c, k) = (os.r, os.c, os.k);
807    let mut o = vec![0i64; r * c];
808    for i in 0..r {
809        for j in 0..c {
810            let mut acc = 0i64;
811            for t in 0..k {
812                let ai = trunc_signed(a[i * k + t], bits);
813                let wj = trunc_signed(w[t * c + j], bits);
814                acc = acc.wrapping_add(ai.wrapping_mul(wj));
815            }
816            o[i * c + j] = acc;
817        }
818    }
819    o
820}
821
822/// The cycle at which the last accumulator (`i=r-1, j=c-1`) has absorbed its final product and is drainable.
823pub fn pipelined_os_latency(r: usize, c: usize, k: usize) -> usize {
824    k + r + c - 2
825}
826
827/// Cycle-accurate output-stationary model — mirrors the RTL register-for-register. Activation `A[i][t]` enters
828/// row `i` skewed by `i`, flows east; weight `W[t][j]` enters col `j` skewed by `j`, flows south; every cycle
829/// each PE does `acc <= acc + a_in·w_in` (skew/drain fill zeros contribute nothing). At PE(i,j) on cycle `T`
830/// both operands carry contraction index `t = T-i-j`, so `acc[i][j]` converges to `O[i][j]`. Returns
831/// `(O, latency)` with `latency = pipelined_os_latency`.
832pub fn eval_systolic_os_pipelined(
833    os: &SystolicOs,
834    a: &[i64],
835    w: &[i64],
836    bits: u32,
837) -> (Vec<i64>, usize) {
838    eval_systolic_os_pipelined_acc(os, a, w, bits, ACC_BITS_FULL)
839}
840
841/// [`eval_systolic_os_pipelined`] with an explicit accumulator width `acc_bits` — each PE's accumulator wraps
842/// (two's-complement) at `acc_bits`, exactly like the `acc_bits`-wide `acc_reg` in the emitted OS RTL.
843pub fn eval_systolic_os_pipelined_acc(
844    os: &SystolicOs,
845    a: &[i64],
846    w: &[i64],
847    bits: u32,
848    acc_bits: u32,
849) -> (Vec<i64>, usize) {
850    let (r, c, k) = (os.r, os.c, os.k);
851    let mut acc = vec![0i64; r * c];
852    let mut a_reg = vec![0i64; r * c];
853    let mut w_reg = vec![0i64; r * c];
854    let l = pipelined_os_latency(r, c, k);
855    // run through the last drainable cycle
856    for t_cyc in 0..=l {
857        // skewed operand injections at the array edges
858        let a_west = |i: usize| -> i64 {
859            // A[i][t] enters row i at cycle t, delayed by i → present at cycle t_cyc when t_cyc-i ∈ [0,k)
860            if t_cyc >= i && t_cyc - i < k {
861                trunc_signed(a[i * k + (t_cyc - i)], bits)
862            } else {
863                0
864            }
865        };
866        let w_north = |j: usize| -> i64 {
867            if t_cyc >= j && t_cyc - j < k {
868                trunc_signed(w[(t_cyc - j) * c + j], bits)
869            } else {
870                0
871            }
872        };
873        let mut a_next = vec![0i64; r * c];
874        let mut w_next = vec![0i64; r * c];
875        let mut acc_next = acc.clone();
876        for i in 0..r {
877            for j in 0..c {
878                let a_in = if j == 0 {
879                    a_west(i)
880                } else {
881                    a_reg[i * c + (j - 1)]
882                };
883                let w_in = if i == 0 {
884                    w_north(j)
885                } else {
886                    w_reg[(i - 1) * c + j]
887                };
888                a_next[i * c + j] = a_in;
889                w_next[i * c + j] = w_in;
890                acc_next[i * c + j] = trunc_signed(
891                    acc[i * c + j].wrapping_add(a_in.wrapping_mul(w_in)),
892                    acc_bits,
893                );
894            }
895        }
896        a_reg = a_next;
897        w_reg = w_next;
898        acc = acc_next;
899    }
900    (acc, l)
901}
902
903/// [`eval_systolic_os_pipelined`] without the latency — the `O` matrix only.
904pub fn eval_systolic_os_pipelined_o(os: &SystolicOs, a: &[i64], w: &[i64], bits: u32) -> Vec<i64> {
905    eval_systolic_os_pipelined(os, a, w, bits).0
906}
907
908/// Emit the output-stationary pipelined array (int8) as synthesizable Verilog. See
909/// [`emit_systolic_os_pipelined_verilog_bits`].
910pub fn emit_systolic_os_pipelined_verilog(name: &str, os: &SystolicOs) -> String {
911    emit_systolic_os_pipelined_verilog_bits(name, os, 8)
912}
913
914/// Emit the output-stationary pipelined array at precision `bits` with a full (64-bit) accumulator.
915pub fn emit_systolic_os_pipelined_verilog_bits(name: &str, os: &SystolicOs, bits: u32) -> String {
916    emit_systolic_os_pipelined_verilog_bits_acc(name, os, bits, ACC_BITS_FULL)
917}
918
919/// Emit the register-staged output-stationary array at precision `bits`. Ports: `clk/rst`, `aw{i}` (west
920/// activation stream for row i), `wn{j}` (north weight stream for col j), and `acc{i}_{j}` (the stationary
921/// accumulator outputs, continuously readable). Per PE: `a_reg` (flows east), `w_reg` (flows south),
922/// `acc_reg <= acc_reg + a_in·w_in`. The only combinational path between flip-flops is one mul + one add —
923/// K-independent, exactly like the weight-stationary pipelined tile. Skew of the streamed operands is applied
924/// on-chip (row i / col j delayed i / j cycles), so the caller drives un-skewed streams.
925pub fn emit_systolic_os_pipelined_verilog_bits_acc(
926    name: &str,
927    os: &SystolicOs,
928    bits: u32,
929    acc_bits: u32,
930) -> String {
931    let (r, c) = (os.r, os.c);
932    let hi = bits.min(64) - 1;
933    // both operands stream, so both are narrowed to their real precision → a `bits×bits` multiplier per PE;
934    // the accumulator is `acc_bits` wide (bit-identical to the acc_bits model; lossless while it fits).
935    let ow = bits.min(64); // streamed-operand width
936    let pw = (2 * bits).min(acc_bits); // product width
937    let ah = acc_bits.min(64) - 1; // accumulator MSB
938    let mut s = String::new();
939    s.push_str(&format!("module {name}(\n  input clk,\n  input rst,\n"));
940    for i in 0..r {
941        s.push_str(&format!("  input signed [63:0] aw{i},\n"));
942    }
943    for j in 0..c {
944        s.push_str(&format!("  input signed [63:0] wn{j},\n"));
945    }
946    for i in 0..r {
947        for j in 0..c {
948            let last = i + 1 == r && j + 1 == c;
949            let comma = if last { "" } else { "," };
950            s.push_str(&format!("  output signed [63:0] acc{i}_{j}{comma}\n"));
951        }
952    }
953    s.push_str(");\n");
954    // truncated edge injections (operand-width)
955    for i in 0..r {
956        s.push_str(&format!(
957            "  wire signed [{}:0] saw{i} = $signed(aw{i}[{hi}:0]);\n",
958            ow - 1
959        ));
960    }
961    for j in 0..c {
962        s.push_str(&format!(
963            "  wire signed [{}:0] swn{j} = $signed(wn{j}[{hi}:0]);\n",
964            ow - 1
965        ));
966    }
967    // input skew: row i activation delayed i cycles; col j weight delayed j cycles (operand-width)
968    for i in 0..r {
969        for d in 0..i {
970            s.push_str(&format!("  reg signed [{}:0] askew_{i}_{d};\n", ow - 1));
971        }
972    }
973    for j in 0..c {
974        for d in 0..j {
975            s.push_str(&format!("  reg signed [{}:0] wskew_{j}_{d};\n", ow - 1));
976        }
977    }
978    for i in 0..r {
979        if i == 0 {
980            s.push_str(&format!("  wire signed [{}:0] a_edge0 = saw0;\n", ow - 1));
981        } else {
982            s.push_str(&format!(
983                "  wire signed [{}:0] a_edge{i} = askew_{i}_{};\n",
984                ow - 1,
985                i - 1
986            ));
987        }
988    }
989    for j in 0..c {
990        if j == 0 {
991            s.push_str(&format!("  wire signed [{}:0] w_edge0 = swn0;\n", ow - 1));
992        } else {
993            s.push_str(&format!(
994                "  wire signed [{}:0] w_edge{j} = wskew_{j}_{};\n",
995                ow - 1,
996                j - 1
997            ));
998        }
999    }
1000    // PE registers + in-wires: activation (east) + weight (south) are operand-width, accumulator is 64-bit
1001    for i in 0..r {
1002        for j in 0..c {
1003            s.push_str(&format!("  reg signed [{}:0] a_reg_{i}_{j};\n", ow - 1));
1004            s.push_str(&format!("  reg signed [{}:0] w_reg_{i}_{j};\n", ow - 1));
1005            s.push_str(&format!("  reg signed [{ah}:0] acc_reg_{i}_{j};\n"));
1006            let a_in = if j == 0 {
1007                format!("a_edge{i}")
1008            } else {
1009                format!("a_reg_{i}_{}", j - 1)
1010            };
1011            let w_in = if i == 0 {
1012                format!("w_edge{j}")
1013            } else {
1014                format!("w_reg_{}_{j}", i - 1)
1015            };
1016            s.push_str(&format!(
1017                "  wire signed [{}:0] a_in_{i}_{j} = {a_in};\n",
1018                ow - 1
1019            ));
1020            s.push_str(&format!(
1021                "  wire signed [{}:0] w_in_{i}_{j} = {w_in};\n",
1022                ow - 1
1023            ));
1024            s.push_str(&format!(
1025                "  wire signed [{}:0] prod_{i}_{j} = a_in_{i}_{j} * w_in_{i}_{j};\n",
1026                pw - 1
1027            ));
1028            s.push_str(&format!("  assign acc{i}_{j} = acc_reg_{i}_{j};\n"));
1029        }
1030    }
1031    s.push_str("  always @(posedge clk) begin\n    if (rst) begin\n");
1032    for i in 0..r {
1033        for d in 0..i {
1034            s.push_str(&format!("      askew_{i}_{d} <= {ow}'sd0;\n"));
1035        }
1036    }
1037    for j in 0..c {
1038        for d in 0..j {
1039            s.push_str(&format!("      wskew_{j}_{d} <= {ow}'sd0;\n"));
1040        }
1041    }
1042    for i in 0..r {
1043        for j in 0..c {
1044            s.push_str(&format!("      a_reg_{i}_{j} <= {ow}'sd0;\n"));
1045            s.push_str(&format!("      w_reg_{i}_{j} <= {ow}'sd0;\n"));
1046            s.push_str(&format!("      acc_reg_{i}_{j} <= {acc_bits}'sd0;\n"));
1047        }
1048    }
1049    s.push_str("    end else begin\n");
1050    for i in 1..r {
1051        s.push_str(&format!("      askew_{i}_0 <= saw{i};\n"));
1052        for d in 1..i {
1053            s.push_str(&format!("      askew_{i}_{d} <= askew_{i}_{};\n", d - 1));
1054        }
1055    }
1056    for j in 1..c {
1057        s.push_str(&format!("      wskew_{j}_0 <= swn{j};\n"));
1058        for d in 1..j {
1059            s.push_str(&format!("      wskew_{j}_{d} <= wskew_{j}_{};\n", d - 1));
1060        }
1061    }
1062    for i in 0..r {
1063        for j in 0..c {
1064            s.push_str(&format!("      a_reg_{i}_{j} <= a_in_{i}_{j};\n"));
1065            s.push_str(&format!("      w_reg_{i}_{j} <= w_in_{i}_{j};\n"));
1066            s.push_str(&format!(
1067                "      acc_reg_{i}_{j} <= acc_reg_{i}_{j} + prod_{i}_{j};\n"
1068            ));
1069        }
1070    }
1071    s.push_str("    end\n  end\nendmodule\n");
1072    s
1073}
1074
1075/// The stationary weights a signed-off weight-stationary tile bakes: a fixed int8 pattern, `k*n` row-major.
1076pub fn bist_tile_weights(k: usize, n: usize) -> Vec<i64> {
1077    (0..k * n)
1078        .map(|i| {
1079            let mut x = (i as u64).wrapping_mul(0x9e3779b97f4a7c15).wrapping_add(1);
1080            x ^= x >> 29;
1081            ((x % 255) as i64) - 127
1082        })
1083        .collect()
1084}
1085
1086/// The self-test wrapper a weight-stationary tile `core` is signed off in: a 64-bit LFSR drives the `k` int8
1087/// activation lanes and the `n` registered outputs fold into a MISR, so only `clk`, `rst` and `sig` cross the block's
1088/// boundary and no pad shapes its timing.
1089pub fn emit_systolic_bist_wrapper(core: &str, k: usize, n: usize) -> String {
1090    let a_wires: String = (0..k)
1091        .map(|r| format!("  wire signed [63:0] a{r} = $signed(lfsr[{}:{}]);\n", r * 8 % 57 + 7, r * 8 % 57))
1092        .collect();
1093    let o_wires: String = (0..n).map(|c| format!("  wire signed [63:0] o{c};\n")).collect();
1094    let aconn: Vec<String> = (0..k).map(|r| format!(".a{r}(a{r})")).collect();
1095    let oconn: Vec<String> = (0..n).map(|c| format!(".o{c}(o{c})")).collect();
1096    let ofold: String = (0..n).map(|c| format!("o{c}")).collect::<Vec<_>>().join(" ^ ");
1097    format!(
1098        "module {core}_realbist(input clk, input rst, output sig);\n\
1099         \x20 reg [63:0] lfsr;\n  reg [63:0] misr;\n{a_wires}{o_wires}\
1100         \x20 {core} arr(.clk(clk), .rst(rst), {aconn}, {oconn});\n\
1101         \x20 always @(posedge clk) begin\n\
1102         \x20   if (rst) begin\n      lfsr <= 64'hD1CEF00D12345678;\n      misr <= 64'd0;\n    end else begin\n\
1103         \x20     lfsr <= {{lfsr[62:0], lfsr[63]^lfsr[62]^lfsr[60]^lfsr[59]}};\n\
1104         \x20     misr <= {{misr[62:0], misr[63]}} ^ ({ofold});\n    end\n  end\n\
1105         \x20 assign sig = ^misr;\nendmodule\n",
1106        aconn = aconn.join(", "),
1107        oconn = oconn.join(", "),
1108    )
1109}
1110
1111/// The self-test wrapper an output-stationary tile `core` is signed off in: the LFSR drives the `r` west activation
1112/// and `c` north weight lanes, and the `r × c` stationary accumulators fold into the MISR through registers, row by
1113/// row. Each row's accumulators fold into a row word, the row words into two half-folds (`foldA`, `foldB`), and the
1114/// MISR XORs those. The fold stays off the critical path, so the block's critical path is the array's PE, and no
1115/// register reads more than one row's accumulators, so no fold gathers every accumulator at one point of the die.
1116pub fn emit_systolic_os_bist_wrapper(core: &str, r: usize, c: usize) -> String {
1117    let aw: String = (0..r)
1118        .map(|i| format!("  wire signed [63:0] aw{i} = $signed(lfsr[{}:{}]);\n", i * 8 % 57 + 7, i * 8 % 57))
1119        .collect();
1120    let wn: String = (0..c)
1121        .map(|j| format!("  wire signed [63:0] wn{j} = $signed(lfsr[{}:{}]);\n", (j * 8 + 4) % 57 + 7, (j * 8 + 4) % 57))
1122        .collect();
1123    let accw: String = (0..r)
1124        .flat_map(|i| (0..c).map(move |j| format!("  wire signed [63:0] acc{i}_{j};\n")))
1125        .collect();
1126    let conns: Vec<String> = (0..r)
1127        .map(|i| format!(".aw{i}(aw{i})"))
1128        .chain((0..c).map(|j| format!(".wn{j}(wn{j})")))
1129        .chain((0..r).flat_map(|i| (0..c).map(move |j| format!(".acc{i}_{j}(acc{i}_{j})"))))
1130        .collect();
1131    let fold = |rows: std::ops::Range<usize>| {
1132        if rows.is_empty() {
1133            "64'd0".to_string()
1134        } else {
1135            rows.map(|i| format!("rowfold{i}")).collect::<Vec<_>>().join(" ^ ")
1136        }
1137    };
1138    let half = r.div_ceil(2);
1139    let (fold_a, fold_b) = (fold(0..half), fold(half..r));
1140    let row_regs: String = (0..r).map(|i| format!("  reg [63:0] rowfold{i};\n")).collect();
1141    let row_resets: String = (0..r).map(|i| format!("      rowfold{i} <= 64'd0;\n")).collect();
1142    let row_steps: String = (0..r)
1143        .map(|i| {
1144            let row = (0..c).map(|j| format!("acc{i}_{j}")).collect::<Vec<_>>().join(" ^ ");
1145            format!("      rowfold{i} <= {row};\n")
1146        })
1147        .collect();
1148    format!(
1149        "module {core}_realbist(input clk, input rst, output sig);\n\
1150         \x20 reg [63:0] lfsr;\n  reg [63:0] misr;\n  reg [63:0] foldA;\n  reg [63:0] foldB;\n{row_regs}{aw}{wn}{accw}\
1151         \x20 {core} arr(.clk(clk), .rst(rst), {conns});\n\
1152         \x20 always @(posedge clk) begin\n\
1153         \x20   if (rst) begin\n      lfsr <= 64'hD1CEF00D12345678;\n      misr <= 64'd0;\n      foldA <= 64'd0;\n      foldB <= 64'd0;\n{row_resets}    end else begin\n\
1154         \x20     lfsr <= {{lfsr[62:0], lfsr[63]^lfsr[62]^lfsr[60]^lfsr[59]}};\n\
1155{row_steps}\
1156         \x20     foldA <= {fold_a};\n      foldB <= {fold_b};\n\
1157         \x20     misr <= {{misr[62:0], misr[63]}} ^ foldA ^ foldB;\n    end\n  end\n\
1158         \x20 assign sig = ^misr;\nendmodule\n",
1159        conns = conns.join(", "),
1160    )
1161}
1162
1163/// When row `i`'s results of a streamed tile are readable: `period` cycles after the tile's first beat
1164/// the next tile's first beat arrives and moves each PE's finished sum into its result register as it
1165/// passes, `i + j` cycles in, where it stays `period` cycles. Every column of row `i` holds it
1166/// `period + i + c` cycles after the tile's first beat: column `j` from `period + i + j + 1` until
1167/// `2·period + i + j`, delayed where that window would close before column `c - 1`'s opens
1168/// ([`os_streaming_deskew`]).
1169pub fn os_streaming_result_offset(period: usize, i: usize, c: usize) -> usize {
1170    period + i + c
1171}
1172
1173/// The cycles column `j`'s results wait so a whole row is readable in one cycle at
1174/// [`os_streaming_result_offset`]: `c - k - j` where that is positive. A tile's period is at least its
1175/// contraction `k`, and column `j` holds its result for a period from `period + i + j + 1`, so it covers
1176/// the row's read cycle with this delay at every period a tile can have; an array no wider than its
1177/// contraction waits for nothing.
1178pub fn os_streaming_deskew(os: &SystolicOs, j: usize) -> usize {
1179    os.c.saturating_sub(os.k + j)
1180}
1181
1182/// Emit the output-stationary array for tile after tile. Ports: `clk/rst`, `first` (high on each tile's
1183/// first contraction beat), `aw{i}` / `wn{j}` (the un-skewed operand streams of [`emit_systolic_os_pipelined_verilog_bits_acc`])
1184/// and `res{i}_{j}` (the finished sums of the previous tile). `first` rides the activation wavefront, so
1185/// it reaches PE(i,j) with the tile's first products: there the PE moves its sum into `res_reg` and
1186/// restarts from that product — no reset and no idle beat between tiles. Column `j`'s result waits
1187/// [`os_streaming_deskew`] cycles, so each row is readable all at once at [`os_streaming_result_offset`].
1188pub fn emit_systolic_os_streaming_verilog(name: &str, os: &SystolicOs, bits: u32, acc_bits: u32) -> String {
1189    let (r, c) = (os.r, os.c);
1190    let hi = bits.min(64) - 1;
1191    let ow = bits.min(64);
1192    let pw = (2 * bits).min(acc_bits);
1193    let ah = acc_bits.min(64) - 1;
1194    let mut s = String::new();
1195    s.push_str(&format!("module {name}(\n  input clk,\n  input rst,\n  input first,\n"));
1196    for i in 0..r {
1197        s.push_str(&format!("  input signed [63:0] aw{i},\n"));
1198    }
1199    for j in 0..c {
1200        s.push_str(&format!("  input signed [63:0] wn{j},\n"));
1201    }
1202    for i in 0..r {
1203        for j in 0..c {
1204            let comma = if i + 1 == r && j + 1 == c { "" } else { "," };
1205            s.push_str(&format!("  output signed [63:0] res{i}_{j}{comma}\n"));
1206        }
1207    }
1208    s.push_str(");\n");
1209    for i in 0..r {
1210        s.push_str(&format!("  wire signed [{}:0] saw{i} = $signed(aw{i}[{hi}:0]);\n", ow - 1));
1211    }
1212    for j in 0..c {
1213        s.push_str(&format!("  wire signed [{}:0] swn{j} = $signed(wn{j}[{hi}:0]);\n", ow - 1));
1214    }
1215    // Input skew: row i's activation and first flag delayed i cycles, column j's weight delayed j.
1216    for i in 0..r {
1217        for d in 0..i {
1218            s.push_str(&format!("  reg signed [{}:0] askew_{i}_{d};\n  reg fskew_{i}_{d};\n", ow - 1));
1219        }
1220    }
1221    for j in 0..c {
1222        for d in 0..j {
1223            s.push_str(&format!("  reg signed [{}:0] wskew_{j}_{d};\n", ow - 1));
1224        }
1225    }
1226    for i in 0..r {
1227        if i == 0 {
1228            s.push_str(&format!("  wire signed [{}:0] a_edge0 = saw0;\n  wire f_edge0 = first;\n", ow - 1));
1229        } else {
1230            s.push_str(&format!(
1231                "  wire signed [{}:0] a_edge{i} = askew_{i}_{d};\n  wire f_edge{i} = fskew_{i}_{d};\n",
1232                ow - 1,
1233                d = i - 1
1234            ));
1235        }
1236    }
1237    for j in 0..c {
1238        if j == 0 {
1239            s.push_str(&format!("  wire signed [{}:0] w_edge0 = swn0;\n", ow - 1));
1240        } else {
1241            s.push_str(&format!("  wire signed [{}:0] w_edge{j} = wskew_{j}_{};\n", ow - 1, j - 1));
1242        }
1243    }
1244    // PEs: activation and first flag flow east, weight south; the sum restarts where the flag arrives.
1245    for i in 0..r {
1246        for j in 0..c {
1247            s.push_str(&format!(
1248                "  reg signed [{o}:0] a_reg_{i}_{j};\n  reg signed [{o}:0] w_reg_{i}_{j};\n  reg f_reg_{i}_{j};\n  reg signed [{ah}:0] acc_reg_{i}_{j};\n  reg signed [{ah}:0] res_reg_{i}_{j};\n",
1249                o = ow - 1
1250            ));
1251            let (a_in, f_in) = if j == 0 {
1252                (format!("a_edge{i}"), format!("f_edge{i}"))
1253            } else {
1254                (format!("a_reg_{i}_{}", j - 1), format!("f_reg_{i}_{}", j - 1))
1255            };
1256            let w_in = if i == 0 { format!("w_edge{j}") } else { format!("w_reg_{}_{j}", i - 1) };
1257            s.push_str(&format!(
1258                "  wire signed [{o}:0] a_in_{i}_{j} = {a_in};\n  wire f_in_{i}_{j} = {f_in};\n  wire signed [{o}:0] w_in_{i}_{j} = {w_in};\n  wire signed [{}:0] prod_{i}_{j} = a_in_{i}_{j} * w_in_{i}_{j};\n",
1259                pw - 1,
1260                o = ow - 1
1261            ));
1262            let d = os_streaming_deskew(os, j);
1263            for k in 0..d {
1264                s.push_str(&format!("  reg signed [{ah}:0] dres_{i}_{j}_{k};\n"));
1265            }
1266            let src = if d == 0 { format!("res_reg_{i}_{j}") } else { format!("dres_{i}_{j}_{}", d - 1) };
1267            s.push_str(&format!("  assign res{i}_{j} = {src};\n"));
1268        }
1269    }
1270    s.push_str("  always @(posedge clk) begin\n    if (rst) begin\n");
1271    for i in 0..r {
1272        for d in 0..i {
1273            s.push_str(&format!("      askew_{i}_{d} <= {ow}'sd0;\n      fskew_{i}_{d} <= 1'b0;\n"));
1274        }
1275    }
1276    for j in 0..c {
1277        for d in 0..j {
1278            s.push_str(&format!("      wskew_{j}_{d} <= {ow}'sd0;\n"));
1279        }
1280    }
1281    for i in 0..r {
1282        for j in 0..c {
1283            s.push_str(&format!(
1284                "      a_reg_{i}_{j} <= {ow}'sd0;\n      w_reg_{i}_{j} <= {ow}'sd0;\n      f_reg_{i}_{j} <= 1'b0;\n      acc_reg_{i}_{j} <= {a}'sd0;\n      res_reg_{i}_{j} <= {a}'sd0;\n",
1285                a = ah + 1
1286            ));
1287            for k in 0..os_streaming_deskew(os, j) {
1288                s.push_str(&format!("      dres_{i}_{j}_{k} <= {a}'sd0;\n", a = ah + 1));
1289            }
1290        }
1291    }
1292    s.push_str("    end else begin\n");
1293    for i in 1..r {
1294        s.push_str(&format!("      askew_{i}_0 <= saw{i};\n      fskew_{i}_0 <= first;\n"));
1295        for d in 1..i {
1296            s.push_str(&format!(
1297                "      askew_{i}_{d} <= askew_{i}_{p};\n      fskew_{i}_{d} <= fskew_{i}_{p};\n",
1298                p = d - 1
1299            ));
1300        }
1301    }
1302    for j in 1..c {
1303        s.push_str(&format!("      wskew_{j}_0 <= swn{j};\n"));
1304        for d in 1..j {
1305            s.push_str(&format!("      wskew_{j}_{d} <= wskew_{j}_{};\n", d - 1));
1306        }
1307    }
1308    for i in 0..r {
1309        for j in 0..c {
1310            s.push_str(&format!(
1311                "      a_reg_{i}_{j} <= a_in_{i}_{j};\n      w_reg_{i}_{j} <= w_in_{i}_{j};\n      f_reg_{i}_{j} <= f_in_{i}_{j};\n\
1312                 \x20     if (f_in_{i}_{j}) begin\n        res_reg_{i}_{j} <= acc_reg_{i}_{j};\n        acc_reg_{i}_{j} <= prod_{i}_{j};\n      end else begin\n        acc_reg_{i}_{j} <= acc_reg_{i}_{j} + prod_{i}_{j};\n      end\n"
1313            ));
1314            let d = os_streaming_deskew(os, j);
1315            if d > 0 {
1316                s.push_str(&format!("      dres_{i}_{j}_0 <= res_reg_{i}_{j};\n"));
1317                for k in 1..d {
1318                    s.push_str(&format!("      dres_{i}_{j}_{k} <= dres_{i}_{j}_{};\n", k - 1));
1319                }
1320            }
1321        }
1322    }
1323    s.push_str("    end\n  end\nendmodule\n");
1324    s
1325}