Skip to main content

logicaffeine_compile/spatial/lower/
cuda.rs

1//! The spatial IR as a PERSISTENT CUDA kernel (sm_80+).
2//!
3//! A site is a resident thread block that loops over ITS work items — the same static placement every other
4//! target runs, so no atomic work counter appears. A FIFO buffer is a shared-memory ring whose pages are
5//! `cp.async` commit groups: `Reserve` names the next slot, a load is every thread's share of the page in
6//! 16-byte `cp.async` copies, `Push` commits them as one group, `Wait` waits until nothing but the prefetched
7//! stage is still in flight and then crosses a block barrier (a thread's wait completes only its own copies;
8//! the barrier publishes everyone's), and `Pop` crosses a block barrier so no thread refills a slot another
9//! still reads. The ring has no run-time flow control, so the lowering proves that no ring is filled past its
10//! depth before it prints (`ring_occupancy`). A tile matmul is WMMA fragments held per warp across the whole
11//! reduction. Every statement is the image of one choreography op, which is what the census asserts.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use serde::{Deserialize, Serialize};
16
17use super::{
18    require_one_plan, require_verified, Census, SpatialArtifact, SpatialBackend, SpatialLowerError,
19};
20use crate::codegen_sva::fabric_target::Precision;
21use crate::spatial::ir::*;
22use crate::spatial::passes::{LV_ITEM, LV_ITEM_X, LV_ITEM_Y, LV_SUB_H, LV_SUB_W};
23use crate::spatial::target::SpatialTarget;
24
25mod general;
26
27#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
28pub struct PersistentBudget {
29    pub smem_bytes: usize,
30    /// The kernel needs `cudaFuncAttributeMaxDynamicSharedMemorySize` — its DYNAMIC block exceeds 48 KiB.
31    pub dynamic_opt_in: bool,
32    /// The RINGS could not stay static (ptxas caps static `__shared__` at 48 KiB whatever the attribute
33    /// says), so they are carved out of the dynamic block.
34    pub rings_dynamic: bool,
35    /// Bytes of the dynamic block: the epilogue scratch always, plus the rings when they had to move.
36    pub dyn_bytes: usize,
37    pub regs_est: usize,
38    pub blocks_per_sm: usize,
39    pub threads: usize,
40}
41
42#[derive(Clone, Debug, PartialEq)]
43pub struct CudaArtifact {
44    pub program_cu: String,
45    pub bench_cu: String,
46    pub budget: PersistentBudget,
47    pub census: Census,
48    /// Where the program runs as one block in a fixed order, the same program as one block per site, its engines
49    /// handing off through rings and semaphores at run time.
50    pub general: Option<Box<CudaArtifact>>,
51}
52
53pub struct CudaPersistentBackend {
54    pub target: SpatialTarget,
55}
56
57impl CudaPersistentBackend {
58    pub fn new(target: SpatialTarget) -> Self {
59        CudaPersistentBackend { target }
60    }
61}
62
63/// Warps per block. Four is the occupancy sweet spot on sm_86 for a WMMA tile block and divides every
64/// sub-block count the tile pass produces.
65/// Warps per block — the site's accumulator LANES, read from the target so the planner's split and
66/// the emitted `NTHREAD` cannot disagree. It was a fixed 4; eight lanes hold twice the accumulator
67/// tiles per block, which is what a competitive GEMM's block tile needs.
68fn nwarp(target: &SpatialTarget) -> usize {
69    target.accumulator_lanes()
70}
71const WARP: usize = 32;
72
73/// Shared-memory regions start on a 16-byte boundary — wmma loads want one.
74fn align16(n: usize) -> usize {
75    (n + 15) & !15
76}
77
78/// The C type a precision's operand fragments take, and the type its accumulator takes.
79fn wmma_types(operand: Precision) -> Option<(&'static str, &'static str, &'static str)> {
80    match operand {
81        // (operand C type, accumulator C type, the printf/host element type)
82        Precision::Int8 => Some(("signed char", "int", "int")),
83        Precision::Bf16 => Some(("__nv_bfloat16", "float", "float")),
84        Precision::Fp16 => Some(("__half", "float", "float")),
85        _ => None,
86    }
87}
88
89struct Ctx<'a> {
90    p: &'a SpatialProgram,
91    te: usize,
92    tr: usize,
93    tc: usize,
94    grid_nx: usize,
95    /// Buffer template → (depth pages, page elements, C element type).
96    rings: BTreeMap<BufferId, (usize, usize, &'static str)>,
97    operand: Precision,
98    acc_t: &'static str,
99    /// The block's `(osh, osw)` output-tile shape, so the multiply and the store read ONE
100    /// warp-to-tile map. Two maps would put a warp's accumulators in another warp's tiles.
101    acc_shape: Option<(usize, usize)>,
102    /// ★ THE OUTPUT BUFFER WHOSE RING IS REPLACED BY A PER-WARP SCRATCH.
103    ///
104    /// Staging the whole block's output through shared memory costs `osh · osw` tiles at the
105    /// ACCUMULATOR's width, double-buffered — the largest ring in the kernel, and the one that
106    /// capped how big a block tile the planner could afford. At 16 output tiles it is 32 KiB of a
107    /// 99 KiB budget; at the 64 tiles a competitive GEMM wants it would be 128 KiB, which is why
108    /// the planner could never get there.
109    ///
110    /// It buys nothing. Each warp already holds its own tiles in registers, so it can store ONE
111    /// fragment to a tile-sized slot of its own and write that straight to global — a scratch of
112    /// `NWARP` tiles, independent of the block size. The epilogue's ring, its pipeline, its two
113    /// barriers and its block-wide copy all go away with it.
114    fused_out: Option<BufferId>,
115    /// How the block's tiles split across the site's accumulator lanes — resolved ONCE from the
116    /// target, so the multiply, the store and the planner all read the same rule.
117    lane_grid: Option<(usize, usize)>,
118    /// The site's accumulator lanes (CUDA warps per block).
119    lanes: usize,
120}
121
122fn ring(ctx: &Ctx, b: BufferId) -> BufferId {
123    ctx.p.buffer(b).origin.unwrap_or(b)
124}
125
126/// A buffer's ring template, before a `Ctx` exists.
127fn ring_of(p: &SpatialProgram, b: BufferId) -> BufferId {
128    p.buffer(b).origin.unwrap_or(b)
129}
130
131fn var_name(v: LoopVar) -> String {
132    match v {
133        x if x == LV_ITEM => "item".into(),
134        x if x == LV_SUB_H => "sh".into(),
135        x if x == LV_SUB_W => "sw".into(),
136        x => format!("lv{}", x.0),
137    }
138}
139
140/// A tile address as C, over the loop variables in scope.
141fn addr_expr(at: &TileAddr, scope: &BTreeSet<LoopVar>) -> String {
142    let mut terms = vec![format!("{}", at.base)];
143    for (v, stride) in &at.steps {
144        if *stride == 0 {
145            continue;
146        }
147        let n = if *v == LV_ITEM_Y {
148            "oy".to_string()
149        } else if *v == LV_ITEM_X {
150            "ox".to_string()
151        } else if scope.contains(v) {
152            var_name(*v)
153        } else {
154            continue;
155        };
156        terms.push(format!("{n} * {stride}"));
157    }
158    terms.join(" + ")
159}
160
161struct Emit {
162    s: String,
163    ind: usize,
164}
165
166impl Emit {
167    fn l(&mut self, line: impl AsRef<str>) {
168        for _ in 0..self.ind {
169            self.s.push_str("    ");
170        }
171        self.s.push_str(line.as_ref());
172        self.s.push('\n');
173    }
174    fn open(&mut self, line: impl AsRef<str>) {
175        self.l(format!("{} {{", line.as_ref()));
176        self.ind += 1;
177    }
178    fn close(&mut self) {
179        self.ind -= 1;
180        self.l("}");
181    }
182}
183
184/// Is `op` a Wait (or, with `pop`, a Pop) on a ring the kernel keeps — the ops whose run one block barrier
185/// closes.
186fn ring_op(ctx: &Ctx, op: Option<&TaggedOp>, pop: bool) -> bool {
187    match op.map(|t| &t.op) {
188        Some(ChoreoOp::Wait { buf, .. }) if !pop => ctx.fused_out != Some(ring(ctx, *buf)),
189        Some(ChoreoOp::Pop { buf, .. }) if pop => ctx.fused_out != Some(ring(ctx, *buf)),
190        _ => false,
191    }
192}
193
194/// Print `ops`. `in_flight` is how many commit groups the printed code has issued past the stage a Wait
195/// reads: zero in straight-line code, the prefetched producer half's commits inside a rotated consumer half.
196fn emit_ops(
197    ctx: &Ctx,
198    e: &mut Emit,
199    ops: &[TaggedOp],
200    scope: &mut BTreeSet<LoopVar>,
201    in_flight: usize,
202) -> Result<(), SpatialLowerError> {
203    for (i, t) in ops.iter().enumerate() {
204        let next = ops.get(i + 1);
205        match &t.op {
206            // The fused output has no ring, so its flow-control ops have nothing to control: the
207            // accumulators are already where the epilogue reads them.
208            ChoreoOp::Reserve { buf, .. } if ctx.fused_out == Some(ring(ctx, *buf)) => {}
209            ChoreoOp::Push { buf, .. } if ctx.fused_out == Some(ring(ctx, *buf)) => {}
210            ChoreoOp::Wait { buf, .. } if ctx.fused_out == Some(ring(ctx, *buf)) => {}
211            ChoreoOp::Pop { buf, .. } if ctx.fused_out == Some(ring(ctx, *buf)) => {}
212            ChoreoOp::Reserve { buf, .. } => e.l(format!("const int wslot_{0} = wctr_{0} % DEPTH_{0}; ++wctr_{0};", ring(ctx, *buf).idx())),
213            ChoreoOp::Push { .. } => e.l("__pipeline_commit();"),
214            // One barrier closes a run of waits: it is what makes every thread's completed copies visible to
215            // every other thread, and one crossing does that for the whole run.
216            ChoreoOp::Wait { buf, .. } => {
217                e.l(format!("__pipeline_wait_prior({in_flight}); const int rslot_{0} = rctr_{0} % DEPTH_{0};", ring(ctx, *buf).idx()));
218                if !ring_op(ctx, next, false) {
219                    e.l("__syncthreads();");
220                }
221            }
222            ChoreoOp::Pop { buf, .. } => {
223                e.l(format!("++rctr_{};", ring(ctx, *buf).idx()));
224                if !ring_op(ctx, next, true) {
225                    e.l("__syncthreads();");
226                }
227            }
228            ChoreoOp::ReadBarrier | ChoreoOp::WriteBarrier | ChoreoOp::Flush { .. } => {}
229            // A page is filled in 16-byte chunks, each thread issuing its own share — the width a single
230            // `cp.async` moves. One block-collective copy per tile splits a tile over every thread in pieces
231            // too small for that path, and without a 16-byte size guarantee the copy cannot take it at all.
232            ChoreoOp::ReadTiles { tensor, at, block_h, block_w, stride_w, stride_h, into } => {
233                let r = ring(ctx, *into).idx();
234                let te = ctx.te;
235                let base = addr_expr(at, scope);
236                e.open("");
237                e.l(format!("constexpr int chunks = (int)(sizeof(*ring_{r}) * {te} / 16);"));
238                e.l(format!("static_assert(sizeof(*ring_{r}) * {te} % 16 == 0, \"a tile is a whole number of 16-byte chunks\");"));
239                e.open(format!("for (int c = tid; c < {block_h} * {block_w} * chunks; c += NTHREAD)"));
240                e.l(format!("const int tl = c / chunks, off = c % chunks, bh = tl / {block_w}, bw = tl % {block_w};"));
241                e.l(format!("const long long tile = ({base}) + (long long)bh * {stride_h} + (long long)bw * {stride_w};"));
242                e.l(format!("__pipeline_memcpy_async(reinterpret_cast<char*>(&ring_{r}[wslot_{r} * PAGE_{r} + tl * {te}]) + off * 16, reinterpret_cast<const char*>(&t{}[tile * {te}]) + off * 16, 16);", tensor.idx()));
243                e.close();
244                e.close();
245            }
246            // ★ THE FUSED EPILOGUE: each warp's accumulators go straight to global, one tile at a
247            // time through a slot of its own. No output ring, no pipeline, no block-wide copy —
248            // and the shared memory that ring used to hold is what pays for a bigger block tile.
249            ChoreoOp::WriteTiles { from, tensor, at, block_w, stride_w, stride_h, .. }
250                if ctx.fused_out == Some(ring(ctx, *from)) =>
251            {
252                let te = ctx.te;
253                let base = addr_expr(at, scope);
254                let (osh, osw) = ctx.acc_shape.expect("a fused epilogue knows its block shape");
255                let (wm, wn) = ctx.lane_grid.expect("a fused epilogue has an even lane grid");
256                let (rm, rn) = (osh / wm, osw / wn);
257                let acct = ctx.acc_t;
258                // Its OWN names: the multiply declares `wi`/`wj` from the same split, and where the
259                // schedule puts both in one scope a shared name is a compile error.
260                e.l(format!("const int ewi = warp / {wn}, ewj = warp % {wn};"));
261                e.l("const int lane = tid % 32;");
262                e.open(format!("for (int i = 0; i < {rm}; ++i)"));
263                e.open(format!("for (int j = 0; j < {rn}; ++j)"));
264                e.l(format!("const int bh = ewi * {rm} + i, bw = ewj * {rn} + j;"));
265                e.l(format!("const long long tile = ({base}) + (long long)bh * {stride_h} + (long long)bw * {stride_w};"));
266                e.l(format!("nvcuda::wmma::store_matrix_sync(&epi_scratch[warp * {te}], accf[i * {rn} + j], {}, nvcuda::wmma::mem_row_major);", ctx.tc));
267                // The warp wrote the slot with `store_matrix_sync`, which is warp-synchronous but
268                // not a memory fence for the reads that follow — the same 32 lanes must see it.
269                e.l("__syncwarp();");
270                e.open(format!("for (int el = lane; el < {te}; el += 32)"));
271                e.l(format!("t{}[tile * {te} + el] = (long long) epi_scratch[warp * {te} + el];", tensor.idx()));
272                e.close();
273                e.l("__syncwarp();");
274                e.close();
275                e.close();
276                let _ = (block_w, acct);
277            }
278            ChoreoOp::WriteTiles { from, tensor, at, block_h, block_w, stride_w, stride_h } => {
279                let r = ring(ctx, *from).idx();
280                let te = ctx.te;
281                let base = addr_expr(at, scope);
282                e.open(format!("for (int bh = 0; bh < {block_h}; ++bh)"));
283                e.open(format!("for (int bw = 0; bw < {block_w}; ++bw)"));
284                e.l(format!("const long long tile = ({base}) + (long long)bh * {stride_h} + (long long)bw * {stride_w};"));
285                e.open(format!("for (int el = tid; el < {te}; el += NTHREAD)"));
286                e.l(format!("t{}[tile * {te} + el] = (long long)ring_{r}[rslot_{r} * PAGE_{r} + (bh * {block_w} + bw) * {te} + el];", tensor.idx()));
287                e.close();
288                e.close();
289                e.close();
290            }
291            ChoreoOp::AcquireAcc { .. } => {
292                e.open("for (int q = 0; q < PERW; ++q)");
293                e.l("nvcuda::wmma::fill_fragment(accf[q], 0);");
294                e.close();
295            }
296            ChoreoOp::ReleaseAcc => {}
297            ChoreoOp::Compute { op, ins, acc_reload } => match op {
298                ComputeOp::MatmulTiles { block, sub_blocks, .. } => {
299                    if acc_reload.is_some() {
300                        return Err(SpatialLowerError::Unsupported("an accumulator spill on the GPU (the fragments never leave the registers)"));
301                    }
302                    let BlockShape { osh, osw, ibw } = *block;
303                    let per_site_n = sub_blocks.1 * osw;
304                    let (ra, rb) = (ring(ctx, ins[0]).idx(), ring(ctx, ins[1]).idx());
305                    let (te, tc) = (ctx.te, ctx.tc);
306                    // A single sub-block leaves no loop, so its index is a constant.
307                    let sh = if scope.contains(&LV_SUB_H) { "sh" } else { "0" };
308                    let sw = if scope.contains(&LV_SUB_W) { "sw" } else { "0" };
309                    match ctx.lane_grid {
310                        // A warp owns a RECTANGLE: load `rm` A-fragments and `rn` B-fragments once
311                        // per K step, then do every product with them. This is the reuse a GEMM is.
312                        Some((wm, wn)) => {
313                            let (rm, rn) = (osh / wm, osw / wn);
314                            e.l(format!("const int wi = warp / {wn}, wj = warp % {wn};"));
315                            e.open(format!("for (int kk = 0; kk < {ibw}; ++kk)"));
316                            e.l(format!("nvcuda::wmma::fragment<nvcuda::wmma::matrix_a, 16, 16, 16, OPT, nvcuda::wmma::row_major> af[{rm}];"));
317                            e.l(format!("nvcuda::wmma::fragment<nvcuda::wmma::matrix_b, 16, 16, 16, OPT, nvcuda::wmma::row_major> bf[{rn}];"));
318                            e.open(format!("for (int i = 0; i < {rm}; ++i)"));
319                            e.l(format!("nvcuda::wmma::load_matrix_sync(af[i], &ring_{ra}[rslot_{ra} * PAGE_{ra} + (({sh} * {osh} + wi * {rm} + i) * {ibw} + kk) * {te}], {tc});"));
320                            e.close();
321                            e.open(format!("for (int j = 0; j < {rn}; ++j)"));
322                            e.l(format!("nvcuda::wmma::load_matrix_sync(bf[j], &ring_{rb}[rslot_{rb} * PAGE_{rb} + (kk * {per_site_n} + {sw} * {osw} + wj * {rn} + j) * {te}], {tc});"));
323                            e.close();
324                            e.open(format!("for (int i = 0; i < {rm}; ++i)"));
325                            e.open(format!("for (int j = 0; j < {rn}; ++j)"));
326                            e.l(format!("nvcuda::wmma::mma_sync(accf[i * {rn} + j], af[i], bf[j], accf[i * {rn} + j]);"));
327                            e.close();
328                            e.close();
329                            e.close();
330                        }
331                        // No even split — correct and slow rather than wrong.
332                        None => {
333                            e.open("for (int q = 0; q < PERW; ++q)");
334                            e.l(format!("const int ot = warp + q * {};", ctx.lanes));
335                            e.l(format!("if (ot >= {}) break;", osh * osw));
336                            e.l(format!("const int oi = ot / {osw}, oj = ot % {osw};"));
337                            e.open(format!("for (int kk = 0; kk < {ibw}; ++kk)"));
338                            e.l("nvcuda::wmma::fragment<nvcuda::wmma::matrix_a, 16, 16, 16, OPT, nvcuda::wmma::row_major> af;");
339                            e.l("nvcuda::wmma::fragment<nvcuda::wmma::matrix_b, 16, 16, 16, OPT, nvcuda::wmma::row_major> bf;");
340                            e.l(format!("nvcuda::wmma::load_matrix_sync(af, &ring_{ra}[rslot_{ra} * PAGE_{ra} + (({sh} * {osh} + oi) * {ibw} + kk) * {te}], {tc});"));
341                            e.l(format!("nvcuda::wmma::load_matrix_sync(bf, &ring_{rb}[rslot_{rb} * PAGE_{rb} + (kk * {per_site_n} + {sw} * {osw} + oj) * {te}], {tc});"));
342                            e.l("nvcuda::wmma::mma_sync(accf[q], af, bf, accf[q]);");
343                            e.close();
344                            e.close();
345                        }
346                    }
347                }
348                other => return Err(SpatialLowerError::Unsupported(compute_name(other))),
349            },
350            ChoreoOp::PackAcc { to, .. } if ctx.fused_out == Some(ring(ctx, *to)) => {}
351            ChoreoOp::PackAcc { to, tiles, accumulate, .. } => {
352                if *accumulate {
353                    return Err(SpatialLowerError::Unsupported("an accumulating epilogue on the GPU (the fragments never leave the registers)"));
354                }
355                let r = ring(ctx, *to).idx();
356                let te = ctx.te;
357                // The store must use the SAME warp-to-tile map the multiply used, or a warp writes
358                // its accumulators into another warp's tiles — which is a wrong answer, not a slow
359                // one. `ctx.acc_shape` carries the block's `(osh, osw)` so both sites read one map.
360                match ctx.acc_shape.and_then(|(osh, osw)| ctx.lane_grid.map(|g| (osh, osw, g))) {
361                    Some((_osh, osw, (wn_m, wn_n))) if *tiles == _osh * osw => {
362                        let (rm, rn) = (_osh / wn_m, osw / wn_n);
363                        e.l(format!("const int pwi = warp / {wn_n}, pwj = warp % {wn_n};"));
364                        e.open(format!("for (int i = 0; i < {rm}; ++i)"));
365                        e.open(format!("for (int j = 0; j < {rn}; ++j)"));
366                        e.l(format!("nvcuda::wmma::store_matrix_sync(&ring_{r}[wslot_{r} * PAGE_{r} + ((pwi * {rm} + i) * {osw} + pwj * {rn} + j) * {te}], accf[i * {rn} + j], {}, nvcuda::wmma::mem_row_major);", ctx.tc));
367                        e.close();
368                        e.close();
369                    }
370                    _ => {
371                        e.open("for (int q = 0; q < PERW; ++q)");
372                        e.l(format!("const int ot = warp + q * {};", ctx.lanes));
373                        e.l(format!("if (ot >= {tiles}) break;"));
374                        e.l(format!("nvcuda::wmma::store_matrix_sync(&ring_{r}[wslot_{r} * PAGE_{r} + ot * {te}], accf[q], {}, nvcuda::wmma::mem_row_major);", ctx.tc));
375                        e.close();
376                    }
377                }
378            }
379            ChoreoOp::SemSet { .. } | ChoreoOp::SemInc { .. } | ChoreoOp::SemWait { .. } | ChoreoOp::McastSetValid { .. } | ChoreoOp::McastSend { .. } => {
380                return Err(SpatialLowerError::Unsupported("a multicast handshake on sm_86 (no cluster-wide multicast)"))
381            }
382            ChoreoOp::Barrier { .. } => return Err(SpatialLowerError::Unsupported("a group barrier on the GPU lowering")),
383            ChoreoOp::Loop { count: LoopCount::Trip { .. }, .. } => {
384                return Err(SpatialLowerError::Unsupported("a loop whose count the program reads from its data (a trip) — the persistent kernel's loop bounds are fixed when it is printed"))
385            }
386            ChoreoOp::Loop { var, count, body } => {
387                let v = var_name(*var);
388                let bound = if *var == LV_ITEM { "n_items".to_string() } else { count.bound().to_string() };
389                // ★ SOFTWARE-PIPELINE THE K LOOP, or the double buffer buys nothing.
390                //
391                // The choreography reads as acquire → copy → commit → WAIT → compute, and printed
392                // literally that is what the kernel does: it issues the loads for this K block and
393                // immediately waits for them, so a ~500-cycle global latency is fully exposed every
394                // block and `DEPTH = 2` is two slots that are never both in use. The whole point of
395                // an async copy is to have the NEXT block's loads in flight while this block's
396                // multiply runs.
397                //
398                // So the producer half is ROTATED one iteration ahead: its copies are issued for
399                // block 0 before the loop, and inside the loop the copies for block i+1 are issued
400                // before the wait for block i. The address expressions are functions of the loop
401                // variable, so a shadowed binding is all the rotation needs — nothing about the
402                // choreography changes, only when its producer half is printed.
403                //
404                // Block i+1's commits are then in flight while block i is awaited, so the consumer half
405                // waits for all but those. The last block has no successor to prefetch; it commits as many
406                // empty groups instead, which keeps that count exact on every iteration.
407                if let Some((prod, cons)) = rotation(ctx, body) {
408                    let n = count.bound();
409                    let commits = commits_of(ctx, &prod);
410                    e.open("");
411                    e.l(format!("const int {v} = 0;"));
412                    emit_ops(ctx, e, &prod, &mut scope.clone(), 0)?;
413                    e.close();
414                    e.open(format!("for (int {v} = 0; {v} < {bound}; ++{v})"));
415                    scope.insert(*var);
416                    e.open(format!("if ({v} + 1 < {n})"));
417                    e.l(format!("const int {v}_pf = {v} + 1;"));
418                    e.open("");
419                    e.l(format!("const int {v} = {v}_pf;"));
420                    emit_ops(ctx, e, &prod, &mut scope.clone(), 0)?;
421                    e.close();
422                    e.close();
423                    if commits > 0 {
424                        e.open("else");
425                        for _ in 0..commits {
426                            e.l("__pipeline_commit();");
427                        }
428                        e.close();
429                    }
430                    emit_ops(ctx, e, &cons, scope, commits)?;
431                    e.close();
432                    scope.remove(var);
433                    continue;
434                }
435                e.open(format!("for (int {v} = 0; {v} < {bound}; ++{v})"));
436                if *var == LV_ITEM {
437                    e.l("const int item_id = ITEM_LIST[item_off + item];");
438                    e.l(format!("const int oy = item_id / {}, ox = item_id % {};", ctx.grid_nx.max(1), ctx.grid_nx.max(1)));
439                }
440                scope.insert(*var);
441                emit_ops(ctx, e, body, scope, 0)?;
442                scope.remove(var);
443                e.close();
444            }
445        }
446    }
447    Ok(())
448}
449
450/// The producer and consumer halves of a loop body the printer rotates ([`pipelined_halves`]), when every ring
451/// the producer fills can hold the two blocks a rotation keeps resident at once — the one being read and the
452/// one being prefetched. A shallower ring prints literally, without the prefetch.
453fn rotation(ctx: &Ctx, body: &[TaggedOp]) -> Option<(Vec<TaggedOp>, Vec<TaggedOp>)> {
454    let (prod, cons) = pipelined_halves(body)?;
455    let mut pages: BTreeMap<BufferId, usize> = BTreeMap::new();
456    for t in &prod {
457        if let ChoreoOp::Reserve { buf, .. } = &t.op {
458            *pages.entry(ring(ctx, *buf)).or_default() += 1;
459        }
460    }
461    let fits = pages.iter().all(|(r, n)| {
462        ctx.fused_out == Some(*r) || ctx.rings.get(r).is_some_and(|(depth, ..)| 2 * n <= *depth)
463    });
464    fits.then_some((prod, cons))
465}
466
467/// The commit groups `ops` prints: one per Push on a ring the kernel keeps.
468fn commits_of(ctx: &Ctx, ops: &[TaggedOp]) -> usize {
469    ops.iter().filter(|t| matches!(&t.op, ChoreoOp::Push { buf, .. } if ctx.fused_out != Some(ring(ctx, *buf)))).count()
470}
471
472/// ★ NO RING IS FILLED PAST ITS DEPTH. A ring has no run-time flow control — a `Reserve` names the next slot
473/// and trusts it is free — so the printed schedule must never hold more pages of a ring than it has slots,
474/// or a copy lands on a page a consumer has not read yet. That is a wrong answer, not a hang, so it is
475/// proved here, on the same rotation the printer takes, before a line is printed.
476///
477/// A slot is claimed at its `Reserve` and freed at its `Pop`, one page per op — the printer's slot counters
478/// step by one, so an op that moves several pages at once is refused rather than mis-indexed. A loop that runs
479/// more than once must leave every ring as it found it, so one iteration from the entry state bounds them all;
480/// a rotated loop holds its prefetched block on top of the one being read.
481fn ring_occupancy(
482    ctx: &Ctx,
483    ops: &[TaggedOp],
484    held: &mut BTreeMap<BufferId, usize>,
485) -> Result<(), SpatialLowerError> {
486    for t in ops {
487        match &t.op {
488            ChoreoOp::Reserve { buf, pages }
489            | ChoreoOp::Push { buf, pages }
490            | ChoreoOp::Wait { buf, pages }
491            | ChoreoOp::Pop { buf, pages }
492                if ctx.fused_out != Some(ring(ctx, *buf)) && *pages != 1 =>
493            {
494                return Err(SpatialLowerError::Unsupported("a FIFO op that moves more than one page at once (the persistent kernel's ring slots are one page each)"));
495            }
496            ChoreoOp::Reserve { buf, .. } if ctx.fused_out != Some(ring(ctx, *buf)) => {
497                let r = ring(ctx, *buf);
498                let depth = ctx.rings.get(&r).map(|(d, ..)| *d).unwrap_or(0);
499                let n = held.entry(r).or_default();
500                *n += 1;
501                if *n > depth {
502                    return Err(SpatialLowerError::TooBig {
503                        what: "pages of a ring held before its consumer drains them",
504                        need: *n,
505                        have: depth,
506                    });
507                }
508            }
509            ChoreoOp::Pop { buf, .. } if ctx.fused_out != Some(ring(ctx, *buf)) => {
510                let r = ring(ctx, *buf);
511                if let Some(n) = held.get_mut(&r) {
512                    *n -= 1;
513                    if *n == 0 {
514                        held.remove(&r);
515                    }
516                }
517            }
518            ChoreoOp::Loop { count, body, .. } => {
519                let entry = held.clone();
520                let once = matches!(count, LoopCount::Const(1));
521                match rotation(ctx, body) {
522                    Some((prod, cons)) if !once => {
523                        ring_occupancy(ctx, &prod, held)?;
524                        let steady = held.clone();
525                        ring_occupancy(ctx, &prod, held)?;
526                        ring_occupancy(ctx, &cons, held)?;
527                        if *held != steady {
528                            return Err(SpatialLowerError::Unsupported(
529                                "a rotated loop whose iterations do not drain what they fill",
530                            ));
531                        }
532                        ring_occupancy(ctx, &cons, held)?;
533                    }
534                    _ => ring_occupancy(ctx, body, held)?,
535                }
536                if !once && *held != entry {
537                    return Err(SpatialLowerError::Unsupported("a loop whose iterations do not drain what they fill (a ring with no flow control would overrun)"));
538                }
539            }
540            _ => {}
541        }
542    }
543    Ok(())
544}
545
546fn compute_name(op: &ComputeOp) -> &'static str {
547    match op {
548        ComputeOp::Generic { .. } => {
549            "the general compute form on the GPU lowering (S2 gives it an arm)"
550        }
551        ComputeOp::Requant { .. } => "a requant stage on the GPU lowering",
552        ComputeOp::Bias { .. } => "a bias stage on the GPU lowering",
553        ComputeOp::Relu => "a relu stage on the GPU lowering",
554        ComputeOp::Scalar(_) => "a scalar stage on the GPU lowering",
555        ComputeOp::MatmulTiles { .. } => "a matmul stage",
556    }
557}
558
559/// One CUDA block runs a whole site, so the site's engine programs (which run concurrently on separate
560/// engines everywhere else) become ONE sequential program. The merge is the choreography's own dataflow
561/// order: loop nests are aligned by variable and merged recursively; inside a segment an engine's head op is
562/// emitted unless it waits on a buffer another engine has not pushed yet. That is exactly the interleaving
563/// `eval_choreography` produces, with no reordering across a buffer's producer and consumer.
564fn merge_programs(progs: &[Vec<TaggedOp>], site: Site) -> Result<Vec<TaggedOp>, SpatialLowerError> {
565    // Split each program into (ops before the next loop, the loop, the rest).
566    fn next_loop(ops: &[TaggedOp]) -> Option<usize> {
567        ops.iter()
568            .position(|t| matches!(t.op, ChoreoOp::Loop { .. }))
569    }
570    let mut heads: Vec<&[TaggedOp]> = progs.iter().map(|v| v.as_slice()).collect();
571    let mut out = Vec::new();
572    loop {
573        // The prefix segment: everything before each program's next loop.
574        // Where to cut each program before ordering. A loop that CO-ITERATES with another program's loop
575        // (same variable) is a merge point and must be cut at. A loop nobody co-iterates with is just an op,
576        // and cutting at it strands any other program waiting on what that loop fills — the block then cannot
577        // be serialised at all, though a perfectly good order exists.
578        //
579        // Absorbing such a loop is only safe when it runs ONCE: emitting a longer loop whole would push more
580        // pages than the ring holds before any consumer drains it, trading a compile-time refusal for a
581        // runtime hang. A multi-trip loop with no co-iterating partner keeps the refusal, which is honest.
582        let firsts: Vec<Option<(usize, LoopVar, LoopCount)>> = heads
583            .iter()
584            .map(|h| {
585                next_loop(h).and_then(|i| match &h[i].op {
586                    ChoreoOp::Loop { var, count, .. } => Some((i, *var, *count)),
587                    _ => None,
588                })
589            })
590            .collect();
591        let shared: std::collections::BTreeSet<LoopVar> = firsts
592            .iter()
593            .flatten()
594            .filter(|(_, v, _)| firsts.iter().flatten().filter(|(_, w, _)| w == v).count() > 1)
595            .map(|(_, v, _)| *v)
596            .collect();
597        let cuts: Vec<usize> = heads
598            .iter()
599            .zip(&firsts)
600            .map(|(h, f)| match f {
601                Some((i, v, c)) if shared.contains(v) || !matches!(c, LoopCount::Const(1)) => *i,
602                Some((i, _, _)) => next_loop(&h[i + 1..]).map(|j| i + 1 + j).unwrap_or(h.len()),
603                None => h.len(),
604            })
605            .collect();
606        let segs: Vec<Vec<TaggedOp>> = heads
607            .iter()
608            .zip(&cuts)
609            .map(|(h, c)| h[..*c].to_vec())
610            .collect();
611        // The tails are what each program still has to run after this segment — including the loops. A wait
612        // must be held back for a push that lives in one of them, not just for one in the segment itself.
613        let tails: Vec<&[TaggedOp]> = heads.iter().zip(&cuts).map(|(h, c)| &h[*c..]).collect();
614        out.extend(order_segment(&segs, &tails, site)?);
615        // Every program that still has ops must now be at a loop; merge the loops that share a variable.
616        let loops: Vec<Option<(LoopVar, LoopCount, Vec<TaggedOp>, StageId)>> = heads
617            .iter()
618            .zip(&cuts)
619            .map(|(h, c)| match h.get(*c) {
620                Some(TaggedOp {
621                    stage,
622                    op: ChoreoOp::Loop { var, count, body },
623                }) => Some((*var, *count, body.clone(), *stage)),
624                _ => None,
625            })
626            .collect();
627        if loops.iter().all(|l| l.is_none()) {
628            return Ok(out);
629        }
630        let var = loops.iter().flatten().map(|(v, ..)| *v).next().unwrap();
631        if loops.iter().flatten().any(|(v, ..)| *v != var) {
632            return Err(SpatialLowerError::Unsupported(
633                "engine programs whose loop nests do not align (the GPU runs one block per site)",
634            ));
635        }
636        let (count, stage) = loops
637            .iter()
638            .flatten()
639            .map(|(_, c, _, s)| (*c, *s))
640            .next()
641            .unwrap();
642        let bodies: Vec<Vec<TaggedOp>> = loops
643            .iter()
644            .flatten()
645            .map(|(_, _, b, _)| b.clone())
646            .collect();
647        let body = merge_programs(&bodies, site)?;
648        out.push(TaggedOp {
649            stage,
650            op: ChoreoOp::Loop { var, count, body },
651        });
652        heads = heads
653            .iter()
654            .zip(&cuts)
655            .zip(&loops)
656            .map(|((h, c), l)| {
657                if l.is_some() {
658                    &h[*c + 1..]
659                } else {
660                    &h[h.len()..]
661                }
662            })
663            .collect();
664    }
665}
666
667/// Emit a segment's ops so no consumer runs before the producer that fills its buffer.
668/// Does this op sequence still push `buf`, at any loop depth?
669///
670/// The scan has to descend into loop bodies. A producer whose push sits inside a loop is exactly the shape a
671/// GEMM's operand feed takes, and a scan that only looks at the current straight-line segment cannot see it —
672/// so the consumer's wait is emitted first, and a block that serialises both roles waits for data it has not
673/// produced yet. The kernel then runs forever rather than deadlocking visibly, because a persistent kernel
674/// spinning on a pipeline looks exactly like one doing work.
675fn pushes_buf(ops: &[TaggedOp], buf: BufferId) -> bool {
676    ops.iter().any(|t| match &t.op {
677        ChoreoOp::Push { buf: b, .. } => *b == buf,
678        ChoreoOp::Loop { body, .. } => pushes_buf(body, buf),
679        _ => false,
680    })
681}
682
683/// Does this op sequence still raise semaphore `sem` at `site` — an increment or a set — at any loop depth?
684fn raises_sem(ops: &[TaggedOp], sem: SemId, site: Site) -> bool {
685    ops.iter().any(|t| match &t.op {
686        ChoreoOp::SemInc { sem: s, at, .. } => *s == sem && *at == site,
687        ChoreoOp::SemSet { sem: s, .. } => *s == sem,
688        ChoreoOp::Loop { body, .. } => raises_sem(body, sem, site),
689        _ => false,
690    })
691}
692
693/// Buffers this program fills, at any loop depth.
694fn collect_pushes(ops: &[TaggedOp], out: &mut std::collections::BTreeSet<usize>) {
695    for t in ops {
696        match &t.op {
697            ChoreoOp::Push { buf, .. } => {
698                out.insert(buf.idx());
699            }
700            ChoreoOp::Loop { body, .. } => collect_pushes(body, out),
701            _ => {}
702        }
703    }
704}
705
706/// Buffers this program drains, at any loop depth.
707fn collect_waits(ops: &[TaggedOp], out: &mut std::collections::BTreeSet<usize>) {
708    for t in ops {
709        match &t.op {
710            ChoreoOp::Wait { buf, .. } => {
711                out.insert(buf.idx());
712            }
713            ChoreoOp::Loop { body, .. } => collect_waits(body, out),
714            _ => {}
715        }
716    }
717}
718
719fn order_segment(
720    segs: &[Vec<TaggedOp>],
721    tails: &[&[TaggedOp]],
722    site: Site,
723) -> Result<Vec<TaggedOp>, SpatialLowerError> {
724    let mut idx = vec![0usize; segs.len()];
725    let mut out = Vec::new();
726    let pending_push =
727        |segs: &[Vec<TaggedOp>], idx: &[usize], buf: BufferId, skip: usize| -> bool {
728            segs.iter().enumerate().any(|(i, s)| {
729                i != skip
730                    && (pushes_buf(&s[idx[i]..], buf)
731                        || tails.get(i).map(|t| pushes_buf(t, buf)).unwrap_or(false))
732            })
733        };
734    // A semaphore wait is a wait on the engine of this site that still raises it; a raise from another site is another
735    // block's, which runs beside this one.
736    let pending_raise = |segs: &[Vec<TaggedOp>], idx: &[usize], sem: SemId, skip: usize| -> bool {
737        segs.iter().enumerate().any(|(i, s)| {
738            i != skip
739                && (raises_sem(&s[idx[i]..], sem, site) || tails.get(i).map(|t| raises_sem(t, sem, site)).unwrap_or(false))
740        })
741    };
742    loop {
743        let mut progressed = false;
744        for i in 0..segs.len() {
745            while idx[i] < segs[i].len() {
746                let blocked = match &segs[i][idx[i]].op {
747                    ChoreoOp::Wait { buf, .. } => pending_push(segs, &idx, *buf, i),
748                    ChoreoOp::SemWait { sem, .. } => pending_raise(segs, &idx, *sem, i),
749                    _ => false,
750                };
751                if blocked {
752                    break;
753                }
754                out.push(segs[i][idx[i]].clone());
755                idx[i] += 1;
756                progressed = true;
757            }
758        }
759        if idx.iter().zip(segs).all(|(k, s)| *k == s.len()) {
760            return Ok(out);
761        }
762        if !progressed {
763            return Err(SpatialLowerError::Unsupported(
764                "engine programs the block cannot serialize without deadlock",
765            ));
766        }
767    }
768}
769
770/// The largest per-warp accumulator fragment count the schedule needs.
771/// ★ HOW THE BLOCK'S OUTPUT TILES ARE SPLIT ACROSS ITS WARPS — the single decision that makes the
772/// difference between a GEMM and a fragment-loading loop.
773///
774/// A warp that owns a RECTANGLE of `(rm × rn)` output tiles loads `rm` A-fragments and `rn`
775/// B-fragments per K step and does `rm · rn` multiplies with them. A warp that owns a strided SET
776/// of tiles reuses nothing: it loads BOTH fragments for every tile, every step. At 8×8 tiles over
777/// 4 warps that is 32 loads per step against 8 — the reason the emitted kernel measured 1,267 GOPS
778/// against the knob lattice's 16,500.
779///
780/// The split is chosen to make the warp's rectangle as SQUARE as possible, because `rm + rn` is
781/// what is loaded and `rm · rn` is what is computed: for a fixed area the squarest rectangle has
782/// the smallest perimeter, which is exactly the arithmetic-intensity argument. `None` when no
783/// split divides the block evenly — then the caller keeps the strided form, which is correct and
784/// slow rather than wrong.
785/// Split a loop body into its PRODUCER half (reserve/copy/commit — the async loads) and its
786/// CONSUMER half (wait/compute/pop), when the body really has that shape: every producer op first,
787/// then every consumer op, and at least one of each.
788///
789/// `None` when the body interleaves them, which is a body whose producer cannot be rotated ahead
790/// without reordering something that is not a load. The caller then prints it literally — correct,
791/// and without the prefetch.
792fn pipelined_halves(body: &[TaggedOp]) -> Option<(Vec<TaggedOp>, Vec<TaggedOp>)> {
793    let is_producer = |op: &ChoreoOp| {
794        matches!(
795            op,
796            ChoreoOp::Reserve { .. }
797                | ChoreoOp::Push { .. }
798                | ChoreoOp::ReadTiles { .. }
799                | ChoreoOp::ReadBarrier { .. }
800        )
801    };
802    let is_consumer = |op: &ChoreoOp| {
803        matches!(
804            op,
805            ChoreoOp::Wait { .. } | ChoreoOp::Pop { .. } | ChoreoOp::Compute { .. }
806        )
807    };
808    let split = body.iter().position(|t| !is_producer(&t.op))?;
809    if split == 0 || split == body.len() {
810        return None;
811    }
812    if !body[split..].iter().all(|t| is_consumer(&t.op)) {
813        return None;
814    }
815    Some((body[..split].to_vec(), body[split..].to_vec()))
816}
817
818fn per_warp_tiles(p: &SpatialProgram, lanes: usize) -> usize {
819    let mut max = 0usize;
820    if let Some(sch) = &p.schedule {
821        for prog in &sch.programs {
822            walk_ops(
823                &prog.ops.iter().map(|t| t.op.clone()).collect::<Vec<_>>(),
824                1,
825                &mut |op, _| {
826                    if let ChoreoOp::Compute {
827                        op: ComputeOp::MatmulTiles { block, .. },
828                        ..
829                    } = op
830                    {
831                        max = max.max(block.osh * block.osw);
832                    }
833                    if let ChoreoOp::PackAcc { tiles, .. } = op {
834                        max = max.max(*tiles);
835                    }
836                },
837            );
838        }
839    }
840    max.div_ceil(lanes).max(1)
841}
842
843impl SpatialBackend for CudaPersistentBackend {
844    fn name(&self) -> &str {
845        self.target.name
846    }
847    fn target(&self) -> &SpatialTarget {
848        &self.target
849    }
850    fn lower(&self, p: &SpatialProgram) -> Result<SpatialArtifact, SpatialLowerError> {
851        require_verified(p)?;
852        // A program the tile GEMM kernel cannot hold — any op but a tile matmul, plans per section, a data-dependent
853        // address, a handshake between sites — prints as the general program.
854        if general::wants_general(p) {
855            return general::lower(p, &self.target).map(SpatialArtifact::Cuda);
856        }
857        require_one_plan(p)?;
858        if p.has_indirect_access() {
859            return Err(SpatialLowerError::Unsupported("an indirect (data-dependent) tile or element access on the GPU lowering (gathers are B-P5)"));
860        }
861        let pl = p
862            .placement
863            .as_ref()
864            .ok_or(SpatialLowerError::Unsupported("a placed program"))?;
865        if let Some(g) = pl.groups.first() {
866            return Err(SpatialLowerError::GroupSpansSites { group: g.id });
867        }
868        if !p.semaphores.is_empty() {
869            return Err(SpatialLowerError::Unsupported(
870                "a semaphore handshake on sm_86 (no cluster-wide multicast)",
871            ));
872        }
873        let sched = p
874            .schedule
875            .as_ref()
876            .ok_or(SpatialLowerError::Unsupported("a scheduled program"))?;
877        let plan = p
878            .work
879            .plan
880            .ok_or(SpatialLowerError::Unsupported("a tiled program"))?;
881        if p.tile.rows != 16 || p.tile.cols != 16 {
882            return Err(SpatialLowerError::Unsupported(
883                "a tile shape other than WMMA's 16×16",
884            ));
885        }
886        // The operand precision the matmul stages agree on.
887        let operand = p
888            .stages
889            .iter()
890            .find_map(|s| match &s.kind {
891                StageKind::Compute(ComputeOp::MatmulTiles { operand, .. }) => Some(*operand),
892                _ => None,
893            })
894            .ok_or(SpatialLowerError::Unsupported(
895                "a program without a tile matmul",
896            ))?;
897        let (opt, acct, _) =
898            wmma_types(operand).ok_or(SpatialLowerError::Precision { needed: operand })?;
899        // int8 fragments accumulate in int32: the reduction must not overflow it, else the GPU would disagree
900        // with the IR's 64-bit accumulate.
901        if operand == Precision::Int8 {
902            let k = p.work.k_tiles as i128 * p.tile.cols as i128;
903            if k * 127 * 127 >= i32::MAX as i128 {
904                return Err(SpatialLowerError::TooBig {
905                    what: "an int8 reduction that overflows the int32 fragment accumulator",
906                    need: (k * 127 * 127) as usize,
907                    have: i32::MAX as usize,
908                });
909            }
910        }
911        let mut rings: BTreeMap<BufferId, (usize, usize, &'static str)> = BTreeMap::new();
912        for b in &p.buffers {
913            let (depth, page) = match b.kind {
914                BufferKind::Fifo {
915                    depth_pages,
916                    page_tiles,
917                }
918                | BufferKind::Interm {
919                    depth_pages,
920                    page_tiles,
921                } => (depth_pages, page_tiles * p.tile.elems()),
922                BufferKind::Accumulator { .. } => continue,
923            };
924            // Inputs carry operands, outputs carry the accumulator's type.
925            let ty = if p
926                .stages
927                .iter()
928                .any(|s| s.outs.contains(&b.id) && matches!(s.kind, StageKind::Compute(_)))
929            {
930                acct
931            } else {
932                opt
933            };
934            rings.insert(b.origin.unwrap_or(b.id), (depth, page, ty));
935        }
936        let acc_shape = Some((plan.block.osh, plan.block.osw));
937        // The epilogue fuses only where the accumulator is register-resident (so there is nothing
938        // to drain), the warp grid divides the block (so each warp owns a rectangle it can write
939        // itself), and the output really is the matmul's own output buffer.
940        let lane_grid = self
941            .target
942            .accumulator_lane_grid(plan.block.osh, plan.block.osw);
943        let fused_out = if self.target.accumulator_in_registers() && lane_grid.is_some() {
944            p.output_tensor()
945                .and_then(|_| {
946                    p.stages.iter().find(|s| {
947                        matches!(s.kind, StageKind::Compute(ComputeOp::MatmulTiles { .. }))
948                    })
949                })
950                .and_then(|s| s.outs.first().copied())
951                .map(|b| ring_of(p, b))
952        } else {
953            None
954        };
955        let ctx = Ctx {
956            p,
957            te: p.tile.elems(),
958            tr: p.tile.rows,
959            tc: p.tile.cols,
960            grid_nx: plan.grid.1,
961            rings,
962            operand,
963            acc_t: acct,
964            acc_shape,
965            fused_out,
966            lane_grid,
967            lanes: nwarp(&self.target),
968        };
969        // One engine program per site, all structurally equal (the same static schedule): print the first.
970        let sites: Vec<Site> = pl.items_of_site.iter().map(|(s, _)| *s).collect();
971        // Producers before consumers. Serialising a site's engine programs onto ONE block makes their order a
972        // correctness property: a program that waits on a buffer another program fills must be emitted after
973        // it, or the block waits for data it has not produced. Scheduler order is arbitrary with respect to
974        // that, so sort by dataflow first and let the merge interleave within it.
975        let mut site_progs: Vec<Vec<TaggedOp>> = sched
976            .programs
977            .iter()
978            .filter(|pr| pr.site == sites[0])
979            .map(|pr| pr.ops.clone())
980            .collect();
981        let fills: Vec<std::collections::BTreeSet<usize>> = site_progs
982            .iter()
983            .map(|ops| {
984                let mut f = Default::default();
985                collect_pushes(ops, &mut f);
986                f
987            })
988            .collect();
989        let drains: Vec<std::collections::BTreeSet<usize>> = site_progs
990            .iter()
991            .map(|ops| {
992                let mut d = Default::default();
993                collect_waits(ops, &mut d);
994                d
995            })
996            .collect();
997        let mut order: Vec<usize> = (0..site_progs.len()).collect();
998        order.sort_by(|a, b| {
999            let a_feeds_b = !fills[*a].is_disjoint(&drains[*b]);
1000            let b_feeds_a = !fills[*b].is_disjoint(&drains[*a]);
1001            match (a_feeds_b, b_feeds_a) {
1002                (true, false) => std::cmp::Ordering::Less,
1003                (false, true) => std::cmp::Ordering::Greater,
1004                _ => a.cmp(b),
1005            }
1006        });
1007        site_progs = order.into_iter().map(|i| site_progs[i].clone()).collect();
1008        let ops = merge_programs(&site_progs, sites[0])?;
1009        ring_occupancy(&ctx, &ops, &mut BTreeMap::new())?;
1010        let perw = per_warp_tiles(p, nwarp(&self.target));
1011        let smem: usize = ctx
1012            .rings
1013            .values()
1014            .map(|(d, pg, ty)| d * pg * c_type_bytes(ty))
1015            .sum();
1016        // ★ THE EPILOGUE SCRATCH LIVES IN THE DYNAMIC BLOCK, AND THE RINGS ONLY MOVE IF THEY MUST.
1017        //
1018        // ptxas caps STATIC `__shared__` at 48 KiB whatever `cudaFuncSetAttribute` says, and the cap covers
1019        // every static declaration. The rings are the only ones: the epilogue scratch is 8 KiB touched once per
1020        // epilogue, so it always goes dynamic, and the rings follow only when they alone exceed the cap —
1021        // sending them dynamic costs 12% (measured, 5,743 GOPS static against 5,056 dynamic at the same bytes
1022        // and the same two blocks per SM).
1023        let epi_bytes = if ctx.fused_out.is_some() {
1024            ctx.lanes * ctx.te * c_type_bytes(acct)
1025        } else {
1026            0
1027        };
1028        let rings_dynamic = smem > 48 * 1024;
1029        let dyn_bytes = align16(epi_bytes) + if rings_dynamic { align16(smem) } else { 0 };
1030        let dynamic = dyn_bytes > 48 * 1024;
1031        let threads = nwarp(&self.target) * WARP;
1032        let regs_est = perw * 8 + 48;
1033        // ONE definition of occupancy, shared with the planner — `tile::best_candidate_for` ranks plans by
1034        // it, so a copy here would let the emitter and the search disagree about the machine.
1035        let blocks_per_sm =
1036            self.target
1037                .resident_sites_per_unit(smem + epi_bytes, plan.block.osh, plan.block.osw);
1038        let budget = PersistentBudget {
1039            smem_bytes: smem,
1040            dynamic_opt_in: dynamic,
1041            rings_dynamic,
1042            dyn_bytes,
1043            regs_est,
1044            blocks_per_sm,
1045            threads,
1046        };
1047        let program_cu = print_program(&ctx, &ops, &sites, perw, &budget, false)?;
1048        let bench_cu = print_program(&ctx, &ops, &sites, perw, &budget, true)?;
1049        Ok(SpatialArtifact::Cuda(CudaArtifact {
1050            program_cu,
1051            bench_cu,
1052            budget,
1053            census: Census::of(p),
1054            general: None,
1055        }))
1056    }
1057}
1058
1059fn c_type_bytes(ty: &str) -> usize {
1060    match ty {
1061        "signed char" => 1,
1062        "__nv_bfloat16" | "__half" => 2,
1063        _ => 4,
1064    }
1065}
1066
1067fn print_program(
1068    ctx: &Ctx,
1069    ops: &[TaggedOp],
1070    sites: &[Site],
1071    perw: usize,
1072    budget: &PersistentBudget,
1073    bench: bool,
1074) -> Result<String, SpatialLowerError> {
1075    let p = ctx.p;
1076    let pl = p.placement.as_ref().unwrap();
1077    let mut e = Emit {
1078        s: String::new(),
1079        ind: 0,
1080    };
1081    e.l(format!(
1082        "// {} — a persistent CUDA kernel printed from the spatial IR; each block is one IR site.",
1083        p.name
1084    ));
1085    e.l("#include <cuda_pipeline.h>");
1086    e.l("#include <mma.h>");
1087    e.l("#include <cuda_bf16.h>");
1088    e.l("#include <cuda_fp16.h>");
1089    e.l("#include <cstdio>");
1090    e.l("#include <cstdlib>");
1091    e.l("#include <vector>");
1092    e.l("#include <cstring>");
1093    e.l("");
1094    e.l(format!(
1095        "typedef {} OPT;",
1096        wmma_types(ctx.operand).unwrap().0
1097    ));
1098    e.l(format!("typedef {} ACCT;", ctx.acc_t));
1099    e.l(format!("#define NTHREAD {}", ctx.lanes * WARP));
1100    e.l(format!("#define PERW {perw}"));
1101    // The epilogue scratch is first in the dynamic block; the rings follow only when they had to leave
1102    // static memory. Every region starts on a 16-byte boundary because wmma loads want one.
1103    let mut dyn_off = 0usize;
1104    if ctx.fused_out.is_some() {
1105        e.l(format!("#define SMEM_OFF_EPI {dyn_off}"));
1106        dyn_off += align16(ctx.lanes * ctx.te * c_type_bytes(ctx.acc_t));
1107    }
1108    for (b, (depth, page, ty)) in &ctx.rings {
1109        e.l(format!("#define DEPTH_{} {depth}", b.idx()));
1110        e.l(format!("#define PAGE_{} {page}", b.idx()));
1111        if budget.rings_dynamic {
1112            dyn_off = (dyn_off + 15) & !15;
1113            e.l(format!("#define SMEM_OFF_{} {dyn_off}", b.idx()));
1114            dyn_off += depth * page * c_type_bytes(ty);
1115        }
1116    }
1117    if dyn_off > 0 {
1118        e.l(format!("#define DYN_SMEM {}", (dyn_off + 15) & !15));
1119    }
1120    // ★ THE WORK LIST IS THE PLACEMENT ITSELF, item by item — not a start plus a count.
1121    //
1122    // `item_start + item` assumes each site's items are a CONTIGUOUS run. They are whenever the
1123    // block grid fits the mesh; when it does not, `place` wraps items round-robin and a site owns
1124    // a STRIDED set. Reading a strided set as a contiguous run made site `s` process items
1125    // `s..s+n` — so sites overlapped, most items were never computed at all, and the output tiles
1126    // nobody claimed stayed ZERO. (Measured at 256³: 8 sites each claiming 8 items covered 15 of
1127    // the 64, and 50,175 of 65,536 output elements were wrong.)
1128    //
1129    // A flat list indexed by a per-site offset is exact for ANY placement, which is what "the
1130    // emitted program is a function of the IR" has to mean: the lowering reads the placement
1131    // rather than assuming its shape.
1132    let mut flat: Vec<String> = Vec::new();
1133    let mut offs: Vec<String> = Vec::new();
1134    let mut counts: Vec<String> = Vec::new();
1135    for s in sites.iter() {
1136        offs.push(flat.len().to_string());
1137        let items = pl.items_at(*s);
1138        counts.push(items.len().to_string());
1139        flat.extend(items.iter().map(|i| i.idx().to_string()));
1140    }
1141    if flat.is_empty() {
1142        flat.push("0".to_string());
1143    }
1144    e.l(format!(
1145        "__device__ __constant__ int ITEM_LIST[{}] = {{{}}};",
1146        flat.len(),
1147        flat.join(", ")
1148    ));
1149    e.l(format!(
1150        "__device__ __constant__ int ITEM_OFF[{}] = {{{}}};",
1151        offs.len(),
1152        offs.join(", ")
1153    ));
1154    e.l(format!(
1155        "__device__ __constant__ int ITEM_COUNT[{}] = {{{}}};",
1156        sites.len(),
1157        counts.join(", ")
1158    ));
1159    e.l("");
1160    // Kernel signature: every tensor as a device pointer, in id order.
1161    let mut params: Vec<String> = Vec::new();
1162    for t in &p.tensors {
1163        let ty = if t.role == TensorRole::Output {
1164            "long long".to_string()
1165        } else {
1166            wmma_types(ctx.operand).unwrap().0.to_string()
1167        };
1168        let cst = if t.role == TensorRole::Output {
1169            ""
1170        } else {
1171            "const "
1172        };
1173        params.push(format!("{cst}{ty}* __restrict__ t{}", t.id.idx()));
1174    }
1175    e.l(format!(
1176        "__global__ __launch_bounds__(NTHREAD, {}) void logos_spatial_persistent({})",
1177        budget.blocks_per_sm,
1178        params.join(", ")
1179    ));
1180    e.open("");
1181    e.l("const int tid = threadIdx.x;");
1182    e.l("const int warp = tid / 32;");
1183    e.l("const int site = blockIdx.x;");
1184    e.l("const int item_off = ITEM_OFF[site];");
1185    e.l("const int n_items = ITEM_COUNT[site];");
1186    if budget.dyn_bytes > 0 {
1187        // ptxas caps STATIC __shared__ at 48 KiB regardless of `cudaFuncSetAttribute`, which only
1188        // raises the DYNAMIC limit. The epilogue scratch always lives here; the rings join it only when
1189        // they do not fit statically.
1190        e.l("extern __shared__ __align__(16) char logos_dyn_smem[];");
1191    }
1192    for (b, (_, _, ty)) in &ctx.rings {
1193        let k = b.idx();
1194        if budget.rings_dynamic {
1195            e.l(format!(
1196                "{ty}* ring_{k} = reinterpret_cast<{ty}*>(logos_dyn_smem + SMEM_OFF_{k});"
1197            ));
1198        } else {
1199            e.l(format!(
1200                "__shared__ __align__(16) {ty} ring_{k}[DEPTH_{k} * PAGE_{k}];"
1201            ));
1202        }
1203        e.l(format!(
1204            "int wctr_{k} = 0, rctr_{k} = 0; (void)wctr_{k}; (void)rctr_{k};"
1205        ));
1206    }
1207    e.l("nvcuda::wmma::fragment<nvcuda::wmma::accumulator, 16, 16, 16, ACCT> accf[PERW];");
1208    if ctx.fused_out.is_some() {
1209        // One tile per warp — the whole epilogue's shared memory, whatever the block tile is.
1210        e.l("ACCT* epi_scratch = reinterpret_cast<ACCT*>(logos_dyn_smem + SMEM_OFF_EPI);");
1211    }
1212    // With one item per site the schedule keeps no item loop, so the item's coordinates are constants.
1213    let has_item_loop = ops
1214        .iter()
1215        .any(|t| matches!(t.op, ChoreoOp::Loop { var, .. } if var == LV_ITEM));
1216    if !has_item_loop {
1217        // ★ A SITE WITH NO WORK MUST DO NONE. When every site holds at most one item the schedule
1218        // keeps no item loop — and without this guard a site holding ZERO items still ran the body
1219        // once, reading past its (empty) slice of the work list and writing a tile nobody asked
1220        // for, over a tile some other site had computed correctly. A block grid smaller than the
1221        // mesh is ordinary: it is what a big block tile on a small problem produces.
1222        e.l("if (n_items == 0) { return; }");
1223        e.l("const int item_id = ITEM_LIST[item_off];");
1224        e.l(format!(
1225            "const int oy = item_id / {}, ox = item_id % {};",
1226            ctx.grid_nx.max(1),
1227            ctx.grid_nx.max(1)
1228        ));
1229        e.l("(void)oy; (void)ox;");
1230    }
1231    let mut scope = BTreeSet::new();
1232    emit_ops(ctx, &mut e, ops, &mut scope, 0)?;
1233    e.close();
1234    e.l("");
1235    // Host: read the inputs, tilize, launch, print (or time).
1236    let a = &p.tensors[0];
1237    let b = &p.tensors[1];
1238    let out = p
1239        .output_tensor()
1240        .ok_or(SpatialLowerError::Unsupported("an output tensor"))?;
1241    let (m, k, n) = (a.elems.rows, a.elems.cols, b.elems.cols);
1242    let (tr, tc, te) = (ctx.tr, ctx.tc, ctx.te);
1243    e.open("int main()");
1244    e.l(format!("const int M = {m}, K = {k}, N = {n};"));
1245    e.l(format!(
1246        "const long long AW = {}LL * {}LL * {te};",
1247        a.tiles.rows_t, a.tiles.cols_t
1248    ));
1249    e.l(format!(
1250        "const long long BW = {}LL * {}LL * {te};",
1251        b.tiles.rows_t, b.tiles.cols_t
1252    ));
1253    e.l(format!(
1254        "const long long CW = {}LL * {}LL * {te};",
1255        out.tiles.rows_t, out.tiles.cols_t
1256    ));
1257    e.l("std::vector<double> ah((size_t)M * K, 0.0), bh((size_t)K * N, 0.0);");
1258    if bench {
1259        e.l("for (size_t i = 0; i < ah.size(); ++i) ah[i] = (double)((int)(i % 7) - 3);");
1260        e.l("for (size_t i = 0; i < bh.size(); ++i) bh[i] = (double)((int)(i % 11) - 5);");
1261    } else {
1262        e.l("{ FILE* f = fopen(\"a.txt\", \"r\"); if (!f) { fprintf(stderr, \"a.txt\\n\"); return 2; } for (size_t i = 0; i < ah.size(); ++i) { double v; if (fscanf(f, \"%lf\", &v) != 1) { fprintf(stderr, \"a.txt short\\n\"); return 2; } ah[i] = v; } fclose(f); }");
1263        e.l("{ FILE* f = fopen(\"b.txt\", \"r\"); if (!f) { fprintf(stderr, \"b.txt\\n\"); return 2; } for (size_t i = 0; i < bh.size(); ++i) { double v; if (fscanf(f, \"%lf\", &v) != 1) { fprintf(stderr, \"b.txt short\\n\"); return 2; } bh[i] = v; } fclose(f); }");
1264    }
1265    e.l("std::vector<OPT> at(AW), bt(BW);");
1266    e.l("memset(at.data(), 0, at.size() * sizeof(OPT)); memset(bt.data(), 0, bt.size() * sizeof(OPT));");
1267    e.l(format!("for (int i = 0; i < M; ++i) for (int j = 0; j < K; ++j) at[((long long)(i / {tr}) * {} + j / {tc}) * {te} + (i % {tr}) * {tc} + j % {tc}] = (OPT)ah[(size_t)i * K + j];", a.tiles.cols_t));
1268    e.l(format!("for (int i = 0; i < K; ++i) for (int j = 0; j < N; ++j) bt[((long long)(i / {tr}) * {} + j / {tc}) * {te} + (i % {tr}) * {tc} + j % {tc}] = (OPT)bh[(size_t)i * N + j];", b.tiles.cols_t));
1269    e.l("OPT *da, *db; long long* dc;");
1270    e.l("cudaMalloc(&da, AW * sizeof(OPT)); cudaMalloc(&db, BW * sizeof(OPT)); cudaMalloc(&dc, CW * sizeof(long long));");
1271    e.l("cudaMemcpy(da, at.data(), AW * sizeof(OPT), cudaMemcpyHostToDevice);");
1272    e.l("cudaMemcpy(db, bt.data(), BW * sizeof(OPT), cudaMemcpyHostToDevice);");
1273    e.l("cudaMemset(dc, 0, CW * sizeof(long long));");
1274    let smem_arg = if budget.dyn_bytes > 0 {
1275        ", DYN_SMEM"
1276    } else {
1277        ""
1278    };
1279    if budget.dynamic_opt_in {
1280        e.l("cudaFuncSetAttribute(logos_spatial_persistent, cudaFuncAttributeMaxDynamicSharedMemorySize, DYN_SMEM);");
1281    }
1282    e.l(format!("const int GRID = {};", sites.len()));
1283    if bench {
1284        e.l("cudaEvent_t e0, e1; cudaEventCreate(&e0); cudaEventCreate(&e1);");
1285        e.l(format!(
1286            "logos_spatial_persistent<<<GRID, NTHREAD{}>>>(da, db, dc); cudaDeviceSynchronize();",
1287            smem_arg
1288        ));
1289        e.l("const int ITERS = 20;");
1290        e.l("cudaEventRecord(e0);");
1291        e.l(format!("for (int it = 0; it < ITERS; ++it) logos_spatial_persistent<<<GRID, NTHREAD{}>>>(da, db, dc);", smem_arg));
1292        e.l("cudaEventRecord(e1); cudaEventSynchronize(e1);");
1293        // A launch that failed times nothing, and `ops / 0` prints an infinite rate that beats any bar.
1294        e.l("{ cudaError_t err = cudaGetLastError(); if (err != cudaSuccess) { fprintf(stderr, \"%s\\n\", cudaGetErrorString(err)); return 1; } }");
1295        e.l("float ms = 0; cudaEventElapsedTime(&ms, e0, e1);");
1296        e.l("const double ops = 2.0 * (double)M * (double)N * (double)K * (double)ITERS;");
1297        e.l("printf(\"GOPS %.1f\\n\", ops / (ms * 1e6));");
1298    } else {
1299        e.l(format!(
1300            "logos_spatial_persistent<<<GRID, NTHREAD{}>>>(da, db, dc);",
1301            smem_arg
1302        ));
1303        e.l("cudaError_t err = cudaDeviceSynchronize();");
1304        e.l("if (err != cudaSuccess) { fprintf(stderr, \"%s\\n\", cudaGetErrorString(err)); return 1; }");
1305        e.l("std::vector<long long> ct(CW);");
1306        e.l("cudaMemcpy(ct.data(), dc, CW * sizeof(long long), cudaMemcpyDeviceToHost);");
1307        e.l(format!("for (int i = 0; i < M; ++i) for (int j = 0; j < N; ++j) printf(\"%lld\\n\", ct[((long long)(i / {tr}) * {} + j / {tc}) * {te} + (i % {tr}) * {tc} + j % {tc}]);", out.tiles.cols_t));
1308    }
1309    e.l("cudaFree(da); cudaFree(db); cudaFree(dc);");
1310    e.l("return 0;");
1311    e.close();
1312    Ok(e.s)
1313}