Skip to main content

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}