logicaffeine_compile/spatial/passes/size_buffers.rs
1//! `size_buffers` — buffering as a GENERAL pass: depths from a rate model under the local-memory budget,
2//! a deterministic non-overlapping layout, typed refusals.
3//!
4//! A FIFO's depth is what Little's law asks for — the pages its producer must keep in flight to cover the
5//! own latency at the rate its consumer drains them, plus the one being filled — floored at two and capped
6//! by the site's memory, which is spent on whichever buffer is shortest. The output FIFO holds one page per
7//! sub-block; the spill buffer holds every sub-block's partial in the accumulator's width. Offsets
8//! are first-fit in buffer order per site, so group members (identical buffer sets) land at identical
9//! offsets — a multicast writes one local address on every receiver.
10
11use super::SpatialReject;
12use crate::codegen_sva::fabric_target::Precision;
13use crate::spatial::cost::{is_streaming, quantities_for_gemm};
14use crate::spatial::ir::*;
15use crate::spatial::target::{precision_bytes, SpatialTarget};
16
17/// Bytes a plan's buffers need on one site (the legality formula `tile` uses; conservative: depth 2).
18pub fn footprint_bytes_for_plan(
19 p: &SpatialProgram,
20 plan: &BlockPlan,
21 target: &SpatialTarget,
22) -> usize {
23 let out = p
24 .output_tensor()
25 .map(|t| t.dtype)
26 .unwrap_or(Precision::Bf16);
27 let operand = p
28 .stages
29 .iter()
30 .find_map(|s| match &s.kind {
31 StageKind::Compute(ComputeOp::MatmulTiles { operand, .. }) => Some(*operand),
32 _ => None,
33 })
34 .unwrap_or(out);
35 let exact = p.numerics == NumericsPolicy::Exact;
36 let in_place = super::tile::finishes_in_place(p, plan, target);
37 let tile = target.tile.elems();
38 let ob = tile * precision_bytes(operand);
39 let outb = tile * precision_bytes(out);
40 if is_streaming(plan) {
41 return 2 * ob + 2 * ob + 2 * outb;
42 }
43 let (pm, pn) = (plan.per_site_m, plan.per_site_n);
44 let BlockShape { osh, osw, ibw } = plan.block;
45 let sub = (pm / osh.max(1)) * (pn / osw.max(1));
46 // Matches the ring depth `apply_plan` lays out: three stages on a register-accumulator target,
47 // where one block per site leaves no other warps to hide a load behind.
48 let depth = 2;
49 let in0 = depth * pm * ibw * ob;
50 let in1 = depth * ibw * pn * ob;
51 // ★ THE EPILOGUE COSTS A FIXED SCRATCH, NOT THE BLOCK, where the accumulator is register-
52 // resident: each warp writes its own tiles straight to global through one tile-sized slot, so
53 // the output never stages through a ring at all. Charging the block's whole output —
54 // `osh · osw` tiles at the accumulator's width, double-buffered — is what stopped the planner
55 // affording a block tile worth running: 16 tiles is 32 KiB of a 99 KiB budget, and the 64 a
56 // competitive GEMM wants would be 128 KiB.
57 let outbuf =
58 if target.accumulator_in_registers() && target.accumulator_lane_grid(osh, osw).is_some() {
59 // One tile per accumulator lane — charged only where the epilogue really fuses, so the
60 // plan the planner PRICED is the plan the allocator lays out.
61 target.accumulator_lanes() * outb
62 } else if in_place {
63 sub * osh * osw * outb
64 } else {
65 sub.max(2) * osh * osw * outb
66 };
67 // ★ AND THE K-BLOCK SPILL COSTS NOTHING WHERE THE ACCUMULATOR NEVER LEAVES THE REGISTERS —
68 // the same reasoning as the epilogue above, one buffer along, and it was missing.
69 //
70 // `tile::apply_plan` creates this buffer only `if nb > 1 && !is_streaming(&plan) &&
71 // !target.accumulator_in_registers()`: a GPU's `wmma` fragments hold the partial sums across the
72 // whole K loop, so there is no spill to stage. This function priced it on `num_blocks > 1` alone,
73 // so the two disagreed by exactly one condition — and the planner charged 64 KiB, out of a 99 KiB
74 // budget, for a buffer the very next pass declines to create.
75 //
76 // The cost was a K depth. At 2048³ on an RTX 3060 that left the 128×128 output tile affordable at
77 // exactly ONE inner width (16 elements) when four times that fits, so the persistent kernel ran its
78 // K loop 128 times where the knob lattice's best rung runs it 64 — for identical arithmetic.
79 // (`is_streaming` is already handled by the early return at the top of this function.)
80 // An intermediate that shares the output's memory costs none of its own.
81 let interm = if plan.inner.num_blocks > 1 && !target.accumulator_in_registers() && !in_place {
82 let dt = if exact {
83 plan.acc.interm_precision()
84 } else {
85 operand
86 };
87 sub * osh * osw * tile * precision_bytes(dt)
88 } else {
89 0
90 };
91 in0 + in1 + outbuf + interm
92}
93
94/// Bytes of the busiest site's buffers as the program stands (placed) or as its plan implies (template).
95pub fn footprint_bytes(p: &SpatialProgram, target: &SpatialTarget) -> usize {
96 if p.placement.is_some() {
97 let mut per_site: std::collections::BTreeMap<Site, usize> = Default::default();
98 for b in &p.buffers {
99 if b.space == MemSpace::Acc || b.alias_of.is_some() {
100 continue;
101 }
102 *per_site
103 .entry(b.site.unwrap_or(Site::new(0, 0)))
104 .or_default() += p.buffer_bytes(b);
105 }
106 return per_site.values().copied().max().unwrap_or(0);
107 }
108 let gemm = p
109 .stages
110 .iter()
111 .any(|s| matches!(s.kind, StageKind::Compute(ComputeOp::MatmulTiles { .. })));
112 match p.work.plan {
113 Some(plan) if gemm => footprint_bytes_for_plan(p, &plan, target),
114 _ => p.buffers.iter().map(|b| p.buffer_bytes(b)).sum(),
115 }
116}
117
118/// The `size_buffers` pass.
119/// ★ A MULTICAST GROUP'S MEMBER BUFFERS ARE ONE BUFFER, REPLICATED ACROSS A RECTANGLE.
120///
121/// The hardware lands a broadcast at ONE L1 address on every receiver, so every member must agree on
122/// depth and page size — `IrViolation::GroupMemberMismatch` refuses a program where they do not. Sizing
123/// them independently is therefore not merely suboptimal, it is invalid, and so is taking a page back
124/// from one of them under memory pressure: the shrink loop below does exactly that, one buffer at a
125/// time, and on a movement-slow target it produced a program the IR rejected outright.
126///
127/// The common depth is the MINIMUM, because a shrink happened for a reason — the site could not afford
128/// the pages — and every member sits in the same L1 budget.
129fn unify_group_depths(p: &mut SpatialProgram) {
130 let Some(pl) = p.placement.clone() else {
131 return;
132 };
133 for g in &pl.groups {
134 let members: Vec<BufferId> = p
135 .stages
136 .iter()
137 .filter(|s| p.stage_group(s) == Some(g.id))
138 .filter_map(|s| s.outs.first().copied())
139 .collect();
140 let Some(depth) = members
141 .iter()
142 .filter_map(|b| match p.buffer(*b).kind {
143 BufferKind::Fifo { depth_pages, .. } => Some(depth_pages),
144 _ => None,
145 })
146 .min()
147 else {
148 continue;
149 };
150 for b in members {
151 if let BufferKind::Fifo { page_tiles, .. } = p.buffers[b.idx()].kind {
152 p.buffers[b.idx()].kind = BufferKind::Fifo {
153 depth_pages: depth,
154 page_tiles,
155 };
156 }
157 }
158 }
159}
160
161pub fn size_buffers(
162 p: &SpatialProgram,
163 target: &SpatialTarget,
164) -> Result<SpatialProgram, SpatialReject> {
165 if p.memory.is_some() {
166 return Ok(p.clone());
167 }
168 let Some(pl) = &p.placement else {
169 return Err(SpatialReject::WrongPhase {
170 pass: "size_buffers",
171 expected: "Placed",
172 found: p.phase(),
173 });
174 };
175 let plan = p.work.plan.expect("placed programs are tiled");
176 let exact = p.numerics == NumericsPolicy::Exact;
177 // Accumulator budget.
178 for s in &p.stages {
179 if let StageKind::Compute(ComputeOp::MatmulTiles { block, acc, .. }) = &s.kind {
180 let available = target.effective_acc_tiles(*acc, exact);
181 if block.acc_tiles() > available {
182 return Err(SpatialReject::AccTilesExceeded {
183 needed: block.acc_tiles(),
184 available,
185 acc: *acc,
186 });
187 }
188 }
189 }
190 let mut q = p.clone();
191 // ── Depths from a rate model, per buffer, under the site's memory budget ─────────────────────────────
192 //
193 // A bounded buffer exists to keep its consumer fed while its producer works. Little's law gives the
194 // depth that does it: the producer needs `latency / service` pages in flight, plus the one it is filling.
195 // Below that the consumer stalls every round; above it the extra page buys nothing and costs memory that
196 // another buffer could use. Two is the floor (a page landing while the previous one is read), never a
197 // default.
198 //
199 // The budget is then spent greedily on whichever buffer's stall the next page removes most, so the
200 // memory goes where the pipeline is actually short rather than uniformly.
201 if !is_streaming(&plan) {
202 if let Some(out) = p.output_tensor() {
203 let fidelity = match target.matmul_cap() {
204 Some(crate::spatial::target::ComputeCap::MatmulTile { fidelity, .. }) => *fidelity,
205 _ => crate::spatial::target::Fidelity::Exact,
206 };
207 let fused = !pl.groups.is_empty();
208 let qn = quantities_for_gemm(
209 out.tiles.rows_t,
210 out.tiles.cols_t,
211 p.work.k_tiles,
212 &plan,
213 fused,
214 fidelity,
215 target,
216 );
217 let m = &target.cycle;
218 let nb = plan.inner.num_blocks.max(1) as f64;
219 let sub = ((plan.per_site_m / plan.block.osh.max(1))
220 * (plan.per_site_n / plan.block.osw.max(1)))
221 .max(1) as f64;
222 // What the writer spends per output page.
223 let compute_per_page = m.compute_cycles(&qn) / (nb * sub);
224 // Only a buffer whose producer can RUN AHEAD benefits from depth. The operand FIFOs can: their
225 // producer is a movement engine fetching the next block while the compute engine works on this
226 // one. The output FIFO cannot: its producer is that same compute engine, which never has more
227 // than one block of sub-blocks outstanding, so a page past the sub-block count is memory spent
228 // on nothing. The spill buffer's depth is a correctness constraint, not a rate choice.
229 let want = |b: &Buffer| -> usize {
230 let BufferKind::Fifo { page_tiles, .. } = b.kind else {
231 return 0;
232 };
233 if !b.name.starts_with("in") {
234 return 0;
235 }
236 // The producer cannot refill a page until the consumer's release has travelled back to it,
237 // so what the depth must cover is the whole credit loop — the forward fetch AND the return
238 // trip — divided by the service it is hiding. Sizing on the fetch alone and adding a page is
239 // the same number only while the return fits inside one service time; below that the
240 // producer stalls every round on a buffer that looks correctly sized. This is the
241 // bandwidth-delay product `codegen_sva::choreography::credit_depth` proves two-sided on the
242 // other spatial target, so both targets answer this question the same way.
243 //
244 // The page being filled is not added on top: the loop is acquire credit, fill, push, consume,
245 // release, so that page is already inside the round trip and counting it again double-counts.
246 //
247 // Optimistic where packing is expensive: the true forward leg runs from credit acquired to
248 // page visible, so it also carries pack and push, which this DRAM term omits. The floor of
249 // two absorbs it at every shape measured; `move_cycles` is where the exact leg lives.
250 // The inputs are the TARGET's declared latencies, not the cost model's fitted ones. A depth
251 // is a resource decision, and the constants were fitted against a cycle counter that turned
252 // out not to track work at all — so a depth derived from them would be a function of noise
253 // wearing the costume of a rate model. A part that has not declared its latencies gets the
254 // vendor's double-buffering discipline, which is what every program we have run actually used.
255 let Some(c) = target.site.credit else {
256 return 2;
257 };
258 if c.service_page == 0 {
259 return 2;
260 }
261 let loop_cycles = page_tiles as u64 * c.fetch_tile as u64 + c.credit_return as u64;
262 (loop_cycles.div_ceil(c.service_page as u64) as usize).max(2)
263 };
264 let _ = compute_per_page;
265 let mut trial = q.clone();
266 for b in trial.buffers.iter_mut() {
267 if let BufferKind::Fifo {
268 depth_pages,
269 page_tiles,
270 } = b.kind
271 {
272 let d = want(b).max(depth_pages);
273 b.kind = BufferKind::Fifo {
274 depth_pages: d,
275 page_tiles,
276 };
277 }
278 }
279 unify_group_depths(&mut trial);
280 // Spend only what fits; a buffer whose extra pages do not fit keeps the depth it had.
281 while footprint_bytes(&trial, target) > target.site.local_mem_bytes {
282 // Give back the page that costs the most bytes per unit of stall it was buying.
283 let victim = trial
284 .buffers
285 .iter()
286 .enumerate()
287 .filter(|(_, b)| b.name.starts_with("in") && matches!(b.kind, BufferKind::Fifo { depth_pages, .. } if depth_pages > 2))
288 .max_by_key(|(_, b)| p.buffer_bytes(b) / b.kind.depth_pages().max(1))
289 .map(|(i, _)| i);
290 match victim {
291 Some(i) => {
292 if let BufferKind::Fifo {
293 depth_pages,
294 page_tiles,
295 } = trial.buffers[i].kind
296 {
297 trial.buffers[i].kind = BufferKind::Fifo {
298 depth_pages: depth_pages - 1,
299 page_tiles,
300 };
301 }
302 // Whatever we took, take it from every member of the group that buffer belongs to.
303 unify_group_depths(&mut trial);
304 }
305 None => break,
306 }
307 }
308 if footprint_bytes(&trial, target) <= target.site.local_mem_bytes {
309 q = trial;
310 }
311 }
312 }
313 lay_out(q, target)
314}
315
316/// Re-size every operand FIFO to `depth` pages and lay the site out again.
317///
318/// Depth is the one buffering choice the cost model cannot yet score, so it is also the one that has to be
319/// MEASURED rather than argued. This is the instrument that measures it: it takes a program the passes have
320/// already sized and re-runs the layout at a pinned depth, so two runs differ in exactly that number.
321pub fn resize_operand_fifos(
322 p: &SpatialProgram,
323 target: &SpatialTarget,
324 depth: usize,
325) -> Result<SpatialProgram, SpatialReject> {
326 let mut q = p.clone();
327 for b in q.buffers.iter_mut() {
328 if let BufferKind::Fifo { page_tiles, .. } = b.kind {
329 if b.name.starts_with("in") {
330 b.kind = BufferKind::Fifo {
331 depth_pages: depth,
332 page_tiles,
333 };
334 }
335 }
336 }
337 q.memory = None;
338 lay_out(q, target)
339}
340
341fn lay_out(q: SpatialProgram, target: &SpatialTarget) -> Result<SpatialProgram, SpatialReject> {
342 let mut q = q;
343 // Layout: first-fit per site in buffer order; aliases share their target's region.
344 let mut buffer_at = vec![
345 BufferAt {
346 site: Site::new(0, 0),
347 space: MemSpace::Local,
348 offset_bytes: 0,
349 bytes: 0
350 };
351 q.buffers.len()
352 ];
353 let mut cursor: std::collections::BTreeMap<Site, u32> = Default::default();
354 // ★ A MULTICAST GROUP'S MEMBERS ARE PLACED FIRST, AND AT ONE ADDRESS.
355 //
356 // The hardware writes a broadcast to the SAME L1 offset on every site in the rectangle — there is one
357 // destination address in the packet, not one per receiver — and the IR refuses a program where they
358 // differ (`GroupMemberMismatch`: "multicast lands at one L1 address"). First-fitting each site
359 // independently cannot honour that: a sender site holds a Load's buffer where a receiver holds a
360 // Recv's, the cursors diverge, and the group lands at two addresses.
361 //
362 // So group buffers are laid down before anything else, at an offset every member site can take, and
363 // the per-site cursors all advance past it. Deterministic: groups in id order, then buffers in id
364 // order, exactly as before.
365 let mut placed = vec![false; q.buffers.len()];
366 if let Some(pl) = q.placement.clone() {
367 for g in &pl.groups {
368 let members: Vec<BufferId> = q
369 .stages
370 .iter()
371 .filter(|s| q.stage_group(s) == Some(g.id))
372 .filter_map(|s| s.outs.first().copied())
373 .collect();
374 if members.is_empty() {
375 continue;
376 }
377 let bytes = members
378 .iter()
379 .map(|b| q.buffer_bytes(q.buffer(*b)) as u32)
380 .max()
381 .unwrap_or(0);
382 let off = members
383 .iter()
384 .filter_map(|b| q.buffer(*b).site)
385 .map(|site| cursor.get(&site).copied().unwrap_or(0))
386 .max()
387 .unwrap_or(0);
388 for b in &members {
389 let buf = q.buffer(*b);
390 let site = buf.site.unwrap_or(Site::new(0, 0));
391 buffer_at[b.idx()] = BufferAt {
392 site,
393 space: buf.space,
394 offset_bytes: off,
395 bytes: q.buffer_bytes(buf) as u32,
396 };
397 placed[b.idx()] = true;
398 cursor.insert(site, off + bytes);
399 }
400 }
401 }
402 for b in &q.buffers {
403 if placed[b.id.idx()] {
404 continue;
405 }
406 let site = b.site.unwrap_or(Site::new(0, 0));
407 let bytes = q.buffer_bytes(b) as u32;
408 let offset = match b.alias_of {
409 Some(a) => buffer_at[a.idx()].offset_bytes,
410 None if b.space == MemSpace::Acc => 0,
411 None => {
412 let c = cursor.entry(site).or_insert(0);
413 let off = *c;
414 *c += bytes;
415 off
416 }
417 };
418 buffer_at[b.id.idx()] = BufferAt {
419 site,
420 space: b.space,
421 offset_bytes: offset,
422 bytes,
423 };
424 }
425 // The budget is what the site can be CONFIGURED with, not its default. An Ampere block opts in
426 // to ~100 KiB of shared memory and the CUDA lowering emits that path
427 // (`cudaFuncSetAttribute(…MaxDynamicSharedMemorySize…)`), so measuring against the 48 KiB
428 // static default rejects plans the device runs perfectly well — and on a GEMM those are exactly
429 // the block tiles big enough to be worth running. The tiling pass filters candidates against
430 // the same number, so the two cannot disagree about what fits.
431 let budget = target.max_local_mem_bytes();
432 for (site, used) in &cursor {
433 if *used as usize > budget {
434 return Err(SpatialReject::LocalMemExceeded {
435 site: *site,
436 needed_bytes: *used as usize,
437 budget_bytes: budget,
438 });
439 }
440 }
441 q.memory = Some(MemoryMap { buffer_at });
442 q.validate()?;
443 Ok(q)
444}