Skip to main content

logicaffeine_compile/spatial/passes/
tile.rs

1//! `tile` — choose the per-site block plan from the legal lattice by the cost model.
2//!
3//! Candidates: the per-tile STREAMING family (one output tile per work item, single-tile FIFOs, tt-metal's
4//! work split over the whole mesh) and every BLOCKED point of the divisor lattice (`per_site_m | Mt`,
5//! `per_site_n | Nt` with the block grid inside the mesh, `osh | per_site_m`, `osw | per_site_n`,
6//! `osh·osw ≤` the accumulator budget, `ibw | Kt`), each legal only if its buffers fit local memory. Every
7//! candidate is scored for both reader arrangements (fused/multicast where the target can, unfused) with the
8//! target's cycle model; the minimum wins. Deterministic tie-break: fewer bytes, then fewer sites, then the
9//! lexicographically smallest plan. The winning arrangement is NOT stored — `fuse_readers` re-derives it
10//! from the same cost function.
11
12use super::size_buffers::footprint_bytes_for_plan;
13use super::{SpatialReject, LV_ITEM_X, LV_ITEM_Y, LV_KBLOCK, LV_SUB_H, LV_SUB_W};
14use crate::spatial::cost::{is_streaming, quantities_for_gemm, streaming_plan, PlanQuantities};
15use crate::spatial::ir::*;
16use crate::spatial::target::{HandshakeKind, Interconnect, SpatialTarget};
17use crate::spatial::work::{work_of_plan, Objective, RankKey, Work};
18
19/// One scored point of the lattice.
20#[derive(Clone, Debug, PartialEq)]
21pub struct Candidate {
22    pub plan: BlockPlan,
23    pub fused: bool,
24    pub quantities: PlanQuantities,
25    pub cycles: f64,
26    /// What this plan makes the machine do — the quantity the search actually ranks by, predicted in
27    /// closed form and checked against both the built choreography and the simulator's work meter.
28    pub work: Work,
29    pub footprint_bytes: usize,
30    /// The grid's extent does not divide the tile count, so the last row or column of sites carries what is
31    /// left ([`BlockPlan::site_block`]).
32    pub ragged: bool,
33}
34
35/// ★ O(√n), AND THE ORDER IS THE SAME ASCENDING LIST THE TRIAL SCAN PRODUCED.
36///
37/// This was `(1..=n).filter(|d| n % d == 0).collect()` — n trial divisions and a heap
38/// allocation, called from INSIDE the five-deep candidate nest below. That is affordable
39/// while `n` is a tile count on a 32×32-tile part (640 elements = 20 tiles, so n ≤ 20),
40/// and it is not affordable on a 1×1-tile part, where the tile count IS the element count:
41/// the same 640-element GEMM makes every bound in that nest 640 instead of 20, which is
42/// 96× the lattice points and 169× the integers trial-divided. That is where this pass's
43/// superlinear time and memory on scalar-tile targets came from.
44///
45/// Pairing `d` with `n / d` visits only up to √n, then one sort restores the exact
46/// ascending order the old scan emitted — so the candidate lattice is enumerated in an
47/// IDENTICAL order and `best_candidate_for` breaks ties exactly as before. Same output,
48/// less work: this is a speed fix, not a planning change.
49fn divisors(n: usize) -> Vec<usize> {
50    let mut v = Vec::new();
51    let mut d = 1usize;
52    while d * d <= n {
53        if n % d == 0 {
54            v.push(d);
55            let co = n / d;
56            if co != d {
57                v.push(co);
58            }
59        }
60        d += 1;
61    }
62    v.sort_unstable();
63    v
64}
65
66/// The accumulator of a template's matmul stage (the only tensor op the lattice applies to).
67fn matmul_acc(p: &SpatialProgram) -> Option<AccPolicy> {
68    p.stages.iter().find_map(|s| match &s.kind {
69        StageKind::Compute(ComputeOp::MatmulTiles { acc, .. }) => Some(*acc),
70        _ => None,
71    })
72}
73
74/// Every legal candidate of `p` on `target`, scored.
75pub fn candidates(p: &SpatialProgram, target: &SpatialTarget) -> Vec<Candidate> {
76    candidates_where(p, target, &|_| true)
77}
78
79/// Every legal candidate of `p` on `target`, scored, where the plans a caller will keep decide whether the grid may
80/// outgrow the mesh: items wrap onto sites only when no blocked plan the caller keeps fits the mesh. A caller that
81/// restricts the arrangement (a calibration run pinning the packer's accumulate) gets the best plan of that
82/// arrangement, not the streaming fallback of a round the plans it discards had filled.
83pub fn candidates_where(
84    p: &SpatialProgram,
85    target: &SpatialTarget,
86    keep: &dyn Fn(&BlockPlan) -> bool,
87) -> Vec<Candidate> {
88    let Some(acc) = matmul_acc(p) else {
89        return Vec::new();
90    };
91    let out = p.output_tensor().expect("a template has an output tensor");
92    let (mt, nt, kt) = (out.tiles.rows_t, out.tiles.cols_t, p.work.k_tiles);
93    let exact = p.numerics == NumericsPolicy::Exact;
94    let acc_budget = target.effective_acc_tiles(acc, exact);
95    let can_fuse = target.provides_interconnect(Interconnect::MulticastRect)
96        && target.provides_handshake(HandshakeKind::Semaphore);
97    // The packer's L1 accumulate needs a packer that can add into a page, and only pays when the reduction
98    // has more than two blocks (the vendor's own predicate: the drain costs a page round-trip per block).
99    let pack_acc_ok = target.packer_accumulates();
100    let two_engines = target.site.movement_engines >= 2;
101    // Exact numerics spill at the accumulator's width; a target that cannot reload that width may not spill.
102    //
103    // ★ UNLESS THE ACCUMULATOR NEVER LEAVES THE REGISTERS. A GPU's `wmma` fragment is
104    // register-resident and the K loop runs inside the accumulate region, so a blocked plan there
105    // performs no spill at all — the reload it would be refused for is one it would never do.
106    // Conflating "cannot reload a spill" with "must spill" made every blocked GPU plan illegal
107    // under exact numerics, leaving only the streaming family: ONE output tile per item, measured
108    // 214 GOPS against the knob lattice's 16,591 at 2048³.
109    let acc_resident = target.accumulator_in_registers();
110    let wide_spill_ok = target.reloads_wide_spills() || !exact || acc_resident;
111    let fidelity = match target.matmul_cap() {
112        Some(crate::spatial::target::ComputeCap::MatmulTile { fidelity, .. }) => *fidelity,
113        _ => crate::spatial::target::Fidelity::Exact,
114    };
115    let mut cands = Vec::new();
116    let push = |cands: &mut Vec<Candidate>, plan: BlockPlan, fused: bool| {
117        let footprint = footprint_bytes_for_plan(p, &plan, target);
118        // ★ THE BUDGET IS WHAT THE SITE CAN BE CONFIGURED WITH, not its default. An Ampere block
119        // opts in to ~100 KiB of shared memory (`cudaFuncSetAttribute`), and the CUDA lowering
120        // already emits that path — measuring against the 48 KiB default rejected every block tile
121        // big enough to be worth running, so the planner could only pick tiles whose arithmetic
122        // intensity was hopeless.
123        if footprint > target.max_local_mem_bytes() {
124            return;
125        }
126        let q = quantities_for_gemm(mt, nt, kt, &plan, fused, fidelity, target);
127        let cycles = target.cycle.cycles(&q);
128        let Some(work) = work_of_plan(p, &plan, fused, target) else {
129            return;
130        };
131        let ragged = mt % plan.per_site_m != 0 || nt % plan.per_site_n != 0;
132        cands.push(Candidate {
133            plan,
134            fused,
135            quantities: q,
136            cycles,
137            work,
138            footprint_bytes: footprint,
139            ragged,
140        });
141    };
142    push(&mut cands, streaming_plan(mt, nt, kt, acc), false);
143    // Two rounds. The first offers only block grids that FIT the mesh, which is every plan we have measured.
144    // The second, taken only when the first yielded no blocked plan at all, allows the grid to exceed the mesh
145    // so items wrap onto sites — the difference between a blocked plan and streaming on a large GEMM.
146    //
147    // Wrapping is a fallback rather than a free candidate on purpose: a wrapped plan re-reads its operands
148    // once per item, and the cost model is not yet accurate enough to arbitrate that against a mesh-fitting
149    // plan (worst absolute error 55% on the current ledger). Offering both would let a model we know to be
150    // wrong overturn decisions the simulator has already settled.
151    // ★ THE DIVISOR LISTS ARE LOOP-INVARIANT — COMPUTE THEM ONCE.
152    // `divisors(kt)` sat in the INNERMOST position, so it was rebuilt (and reheap-allocated)
153    // once per (allow_wrap, pm, pn, osh, osw); `divisors(pm)`/`divisors(pn)` were rebuilt one
154    // and two levels deeper than the value they depend on. Hoisting changes no value and no
155    // order — it is the same lattice, enumerated the same way, without the rebuilds.
156    let div_mt = divisors(mt);
157    let div_nt = divisors(nt);
158    let div_kt = divisors(kt);
159    // ★ A GRID EXTENT NEED NOT DIVIDE THE TILE COUNT — the boundary block carries what is left.
160    //
161    // Deriving the grid only from divisors means a reachable extent must be a divisor of the tile count
162    // that also fits the mesh. On a 10x13 mesh a 64x64-tile output caps at 8x8 = 64 of 130 cores, because
163    // 64's divisors jump 8 -> 16 and straddle both bounds. Measured over 256 shapes, Blackhole averages
164    // 57% of its mesh and NOT ONE shape reaches all 130 — 13 is prime, and no power of two has a divisor
165    // of 13. Wormhole's 8x8 is the friendly case, which is why this went unnoticed for the whole campaign.
166    //
167    // `BlockPlan::site_block` defines what each site covers; `place` emits its patterns from it and
168    // `work_of_plan` prices from it, so the planner cannot rank a block the emitter will not lay out.
169    //
170    // Offered ONLY where the grid FITS the mesh: then `items == ny*nx <= sites`, every site holds exactly
171    // one block, and its extent is well defined. A wrapped plan's site holds several blocks at once and
172    // could not describe two different extents with one stage — so wrap stays divisor-derived.
173    // Every divisor is still enumerated (for a divisor `pm`, `div_ceil` IS `mt / pm`), so this strictly
174    // ADDS candidates and the objective still chooses among them.
175    let extents = |n: usize, mesh: usize| -> Vec<usize> {
176        let mut v = divisors(n);
177        v.extend((1..=mesh).map(|g| n.div_ceil(g)));
178        v.sort_unstable();
179        v.dedup();
180        v
181    };
182    let ragged_mt = extents(mt, target.sites.y);
183    let ragged_nt = extents(nt, target.sites.x);
184    // ★ WHY THE GRID IS STILL DERIVED FROM DIVISORS — and exactly what must change first.
185    //
186    // A grid extent that does not divide the tile count would let a 64x64-tile output use 10x13 of
187    // Blackhole's mesh instead of 8x8: measured over 256 shapes, that chip averages 57% of its cores and
188    // NOT ONE shape reaches all 130, because 13 is prime and no power of two has a divisor of 13. The
189    // prize is 1.52x the cores (`examples/probe_mesh_utilization.rs`).
190    //
191    // Two of the three things such a grid needs are already here. `WorkItem.out` is a `TileRect` with its
192    // own `h_t`/`w_t`, so a smaller boundary block needs no new IR — and `apply_plan` below already clamps
193    // items to the tensor. The Tensix backend already emits one kernel per program class.
194    //
195    // What is NOT here is the operand READ. `AccessPattern` has an affine ORIGIN and a CONSTANT extent
196    // (`block_h`, `block_w`), and the pattern lives on the STAGE, shared by every site — so a boundary
197    // site cannot describe a narrower read, and enumerating the wider grid makes the pipeline's own
198    // `verify` reject the program: `ReadOutOfRange { tensor: 1, tile: 1024 }` on a B operand that holds
199    // exactly 1024 tiles. (Tried, measured, reverted — the enumeration change alone is one line.)
200    //
201    // The fix is to give `AccessPattern` the meaning it should always have had: the rectangle INTERSECTED
202    // with the tensor's tile grid, never past the end. That is `eval` skipping instead of faulting, and a
203    // runtime `min` in the readers — small in each place and touching four printers plus every kernel
204    // census, which is why it is named here rather than done in passing.
205    // `spatial_tile_lock::the_planner_fills_a_mesh_whose_dimensions_do_not_divide_the_tile_count` is the
206    // ignored test that states it.
207    for allow_wrap in [false, true] {
208        if allow_wrap
209            && cands
210                .iter()
211                .any(|c| !is_streaming(&c.plan) && keep(&c.plan))
212        {
213            break;
214        }
215        for &pm in if allow_wrap { &div_mt } else { &ragged_mt } {
216            let ny = mt.div_ceil(pm);
217            let div_pm = divisors(pm);
218            for &pn in if allow_wrap { &div_nt } else { &ragged_nt } {
219                let nx = nt.div_ceil(pn);
220                let div_pn = divisors(pn);
221                // A block grid LARGER than the mesh is legal: `place` wraps items onto sites modulo the mesh and
222                // the cost model already charges `blocks / sites` items per site. Requiring the grid to fit forces
223                // a per-site block big enough to cover the whole output, whose buffers exceed local memory on any
224                // large GEMM — which is why an 8192-square problem could only stream.
225                //
226                // Fusion is the exception, and for a structural reason: a multicast group shares one operand panel
227                // down a mesh row or column, and once items wrap, a row of the mesh holds blocks from several
228                // different block-rows that share no panel. So a fused candidate must still fit the mesh.
229                let fits_mesh = ny <= target.sites.y && nx <= target.sites.x;
230                if !fits_mesh && !allow_wrap {
231                    continue;
232                }
233                // ★ RAGGED IN AT MOST ONE AXIS — a MEASURED build-time bound, not a taste.
234                //
235                // Each distinct block shape is its own kernel class, and tt-metal compiles a separate RISC-V
236                // kernel per class. Ragged in one axis gives two shapes (interior, edge); ragged in BOTH gives
237                // four (interior, right, bottom, corner) and multiplies the kernels to build. Measured: a 7x7
238                // grid ragged both ways at 640x640x1280 blew past a 45-minute build timeout, where an 8x9 grid
239                // ragged only in rows at 4096x256x256 built in about eight minutes and measured 72 sites at
240                // 10,755 cycles against the recorded 64 sites at 11,175.
241                //
242                // The utilisation given up is small: on Blackhole's 10x13 mesh a 64x64-tile output reaches 104
243                // of 130 sites with one ragged axis against 130 with two, and 64 with neither. Most of the
244                // prize, a bounded number of kernels.
245                if mt % pm != 0 && nt % pn != 0 {
246                    continue;
247                }
248                for &osh in &div_pm {
249                    for &osw in &div_pn {
250                        if osh * osw > acc_budget {
251                            continue;
252                        }
253                        // ★ ON A REGISTER-RESIDENT TARGET THE BLOCK IS THE SUB-BLOCK. One accumulate
254                        // region spans the whole K reduction, so a second sub-block would need a second
255                        // set of accumulators live at the same time — which is not what the single
256                        // `AcquireAcc` the synthesis emits provides.
257                        if acc_resident && (pm != osh || pn != osw) {
258                            continue;
259                        }
260                        // …and its tiles must split evenly across the site's accumulator lanes, or the
261                        // epilogue cannot write straight to global and the plan is sized for a scratch
262                        // it would overrun.
263                        if acc_resident && target.accumulator_lane_grid(osh, osw).is_none() {
264                            continue;
265                        }
266                        for &ibw in &div_kt {
267                            // A matrix engine that caps its PE array takes a block of at most its rows of contraction
268                            // and its columns of output; the rest of the contraction runs as further blocks.
269                            if let Some((rows, cols)) = target.matmul_max_array() {
270                                if ibw * target.tile.cols > rows || osw * target.tile.cols > cols {
271                                    continue;
272                                }
273                            }
274                            let plan = BlockPlan {
275                                granularity: if target.matmul_block_api() {
276                                    Granularity::PerSubBlock
277                                } else {
278                                    Granularity::PerTile
279                                },
280                                per_site_m: pm,
281                                per_site_n: pn,
282                                grid: (ny, nx),
283                                inner: InnerLoop {
284                                    num_blocks: kt / ibw,
285                                    block_k_tiles: ibw,
286                                },
287                                block: BlockShape { osh, osw, ibw },
288                                acc,
289                                pack_accumulate: false,
290                                engines: EnginePacking::LoadsTogether,
291                            };
292                            if is_streaming(&plan) {
293                                continue;
294                            }
295                            // A plan that spills needs the target to be able to reload a partial of the
296                            // accumulator's width; under exact numerics the spill IS that width.
297                            //
298                            // This covers the packer's in-place accumulate too, MEASURED: its blocks 0…nb-2 do
299                            // avoid the reload, but the last block still reloads once to pack the result out, and
300                            // that single reload is the same illegal wide unpack. A reload-free last block needs
301                            // the store to read the accumulated intermediate directly; until it does, a spilling
302                            // plan under exact numerics is refused rather than emitted for the device to reject.
303                            let reload_ok = !(exact && kt / ibw > 1 && !wide_spill_ok);
304                            // The packer's L1 accumulate is a plan choice wherever the target offers it and the
305                            // reduction has enough blocks for the in-place sum to pay for its drain. Its last block
306                            // reloads the partial once, unless the partial is the result and the packer finishes in
307                            // the output's own page: then it reloads nothing, and it is how an exact reduction splits
308                            // where the target cannot reload a partial of the accumulator's width.
309                            let l1 = BlockPlan {
310                                pack_accumulate: true,
311                                ..plan
312                            };
313                            let l1_ok = finishes_in_place(p, &l1, target)
314                                || (pack_acc_ok && kt / ibw > 2 && reload_ok);
315                            // The movement packing is a plan choice too: pairing the second operand's loader with
316                            // the store overlaps a block's output write with the next block's operand reads.
317                            let packings: &[EnginePacking] = if two_engines {
318                                &[
319                                    EnginePacking::LoadsTogether,
320                                    EnginePacking::OperandWithStore,
321                                ]
322                            } else {
323                                &[EnginePacking::LoadsTogether]
324                            };
325                            for &engines in packings {
326                                for (base, ok) in [(plan, reload_ok), (l1, l1_ok)] {
327                                    if !ok {
328                                        continue;
329                                    }
330                                    let arranged = BlockPlan { engines, ..base };
331                                    push(&mut cands, arranged, false);
332                                    if can_fuse && fits_mesh && (nx > 1 || ny > 1) {
333                                        push(&mut cands, arranged, true);
334                                    }
335                                }
336                            }
337                        }
338                    }
339                }
340            }
341        }
342    }
343    cands
344}
345
346/// ★ THE RANKING, AND WHAT IT IS MADE OF. The leading terms come from `Objective::key` over the plan's
347/// PREDICTED WORK — bytes and transactions the choreography will actually issue, checked against the
348/// machine by `spatial_work_model_lock`. Everything after them is a deterministic tie-break so that two
349/// plans the objective cannot separate are still ordered the same way on every run.
350///
351/// It used to lead with `c.cycles`, a regression against ttsim's bracket counter. That counter is not a
352/// function of the program — 8x the reduction depth moves it 0.08% — so the leading term of the plan
353/// search was noise, and at 512^3 it chose the plan that issues 2.94x the instructions and moves 3.40x
354/// the bytes of the one beside it. `cycles` is kept on the candidate for the calibration harness and no
355/// longer decides anything.
356fn plan_key(
357    c: &Candidate,
358    objective: Objective,
359) -> (
360    RankKey,
361    usize,
362    usize,
363    usize,
364    usize,
365    usize,
366    usize,
367    usize,
368    bool,
369) {
370    (
371        objective.key(&c.work),
372        c.footprint_bytes,
373        c.quantities.sites_used,
374        c.plan.per_site_m,
375        c.plan.per_site_n,
376        c.plan.block.osh,
377        c.plan.block.osw,
378        c.plan.block.ibw,
379        c.fused,
380    )
381}
382
383/// The winning candidate (cost-minimal, deterministic tie-break).
384///
385/// ★ ON A REGISTER-ACCUMULATOR TARGET THE RANKING IS ARITHMETIC INTENSITY, not the cycle model.
386///
387/// The model is a roofline fitted on TENSIX measurements — its own doc records a worst absolute
388/// error of 55% on that ledger — and it has never been fitted for a GPU. Asked to order GPU plans
389/// it preferred a 32-tile block over the 64-tile block that measured FASTER, which is the failure
390/// mode of using a model outside the data it was fitted on.
391///
392/// So for that target the choice is made on the physics we are sure of. A block computing
393/// `osh · osw` output tiles over `ibw` contraction tiles does `osh · osw · ibw` tile-multiplies per
394/// `(osh + osw) · ibw` tiles read, so intensity rises with the block and a GEMM at this size is
395/// bandwidth-bound — bigger is faster until a resource binds, and the candidate list only contains
396/// plans that already fit. Ties fall back to the model, so nothing about the Tensix path changes.
397pub fn best_candidate(p: &SpatialProgram, target: &SpatialTarget) -> Option<Candidate> {
398    best_candidate_for(p, target, Objective::default())
399}
400
401/// ★ ON A TARGET WHOSE TIME IS EXACT, THE PLANNER TIMES ITS CANDIDATES. The fabric's cycle twin
402/// ([`fabric_cycles_within`](crate::spatial::lower::fabric::fabric_cycles_within)) is cycle-exact against its RTL, so
403/// the planner builds candidates in the work model's order and times each one, and the fastest is the plan. A model
404/// stands in for time only where nothing can measure it; on the fabric every engine change would otherwise leave the
405/// model choosing a plan the twin can show is slower.
406///
407/// The timing is bounded and deterministic: the candidates share [`TWIN_PLANNING_CYCLES`] cycles of simulation, each
408/// one stopping once it is slower than the fastest so far — it can no longer win — so a program small enough for the
409/// fabric is timed through every candidate, and a large one is timed through as many of the model's best as the
410/// budget reaches. Only the fastest timed candidate is certified, and the first that certifies is the plan.
411///
412/// `None` on every other target — the fabric's lowering is the one that states its local memory's read latency — and
413/// where no candidate builds and finishes within the budget.
414pub fn fastest_on_the_twin(p: &SpatialProgram, target: &SpatialTarget) -> Option<Candidate> {
415    target.site.local_mem_read_latency?;
416    let mut cands = candidates(p, target);
417    cands.sort_by_key(|c| plan_key(c, Objective::default()));
418    let mut budget = TWIN_PLANNING_CYCLES;
419    let mut timed: Vec<(u64, usize, Candidate, SpatialProgram)> = Vec::new();
420    for (rank, c) in cands.into_iter().enumerate() {
421        if budget == 0 {
422            break;
423        }
424        let Ok(built) = crate::spatial::pipeline::build_candidate_uncertified(p, &c, target) else { continue };
425        let fastest = timed.iter().map(|t| t.0).min().unwrap_or(u64::MAX);
426        let limit = budget.min(fastest);
427        match crate::spatial::lower::fabric::fabric_cycles_within(&built, target, limit) {
428            Some(cycles) => {
429                budget -= cycles;
430                timed.push((cycles, rank, c, built));
431            }
432            None => budget -= limit,
433        }
434    }
435    timed.sort_by_key(|(cycles, rank, ..)| (*cycles, *rank));
436    timed
437        .into_iter()
438        .find(|(.., built)| super::verify::verify(built, target).is_ok())
439        .map(|(_, _, c, _)| c)
440}
441
442/// The cycles of simulation the planner spends timing one program's candidates on the fabric
443/// ([`fastest_on_the_twin`]): enough to time every candidate of a program the size of a decode step's section, a
444/// fraction of a second of the twin.
445pub const TWIN_PLANNING_CYCLES: u64 = 4_000_000;
446
447/// The winning candidate under a stated objective.
448pub fn best_candidate_for(
449    p: &SpatialProgram,
450    target: &SpatialTarget,
451    objective: Objective,
452) -> Option<Candidate> {
453    let cands = candidates(p, target);
454    if target.accumulator_in_registers() {
455        // ★ PREDATES THE WORK MODEL. This branch exists because the CYCLE model was fitted on Tensix and
456        // ordered GPU plans backwards; the work model is derived rather than fitted and has no such
457        // excuse, but nothing here can measure a GPU, so the intensity ranking stays until a measurement
458        // lane exists to justify replacing it. Changing it blind would be trading a checked heuristic for
459        // an unchecked one.
460        return cands.into_iter().max_by_key(|c| {
461            (
462                intensity_key(c),
463                // ★ OCCUPANCY BEFORE DEPTH. Intensity cancels `ibw` exactly, so depth used to decide on
464                // its own — and a deeper stage buys fewer pipeline round-trips WITH shared memory, which
465                // on this target is how many sites a unit can hold. Measured at 2048³ (see
466                // `SpatialTarget::resident_sites_per_unit`): K=16 → 5,429 GOPS, K=32 → 5,743, K=64 →
467                // 4,102, the last one having fallen from two resident blocks to one. The rule is the
468                // deepest stage that does not cost a block, not the deepest stage.
469                target.resident_sites_per_unit(
470                    c.footprint_bytes,
471                    c.plan.block.osh,
472                    c.plan.block.osw,
473                ),
474                stage_depth_key(c),
475                std::cmp::Reverse(plan_key(c, objective)),
476            )
477        });
478    }
479    best_among(&cands, objective).cloned()
480}
481
482/// The candidate `objective` chooses among `cands`: the admissible ones ([`admissible`]), ranked by
483/// `plan_key`.
484pub fn best_among(cands: &[Candidate], objective: Objective) -> Option<&Candidate> {
485    admissible(cands, objective)
486        .into_iter()
487        .min_by_key(|c| plan_key(c, objective))
488}
489
490/// The candidates `objective` may choose among.
491///
492/// ★ A RAGGED GRID ONLY WHERE IT COSTS NOTHING IN TOTAL WORK. `Balanced` ranks the busiest site's arithmetic
493/// second, so it favours a grid that spreads the arithmetic thinner even where the spread moves more bytes in
494/// more transactions than an evenly divided plan beside it — at 640³ on the Wormhole twin, a ragged 7×5 grid
495/// against the 5×5 that beats it on every meter, a benefit the simulator cannot show. So under `Balanced` a
496/// ragged plan is admissible only where no evenly divided plan beats it on every predicted total: bytes moved,
497/// transactions and compute-engine calls. The comparison is against the even plans only: against every plan it
498/// would choose the fewest sites, since a plan on fewer sites moves fewer bytes in total, and refuse the
499/// parallelism the objective exists to buy. Every other objective names its own axis and admits every plan;
500/// the roofline prices the spread a ragged grid buys against the bytes and transactions it costs, at the
501/// target's cited rates, which is the measurement this rule stands in for.
502pub fn admissible(cands: &[Candidate], objective: Objective) -> Vec<&Candidate> {
503    if objective != Objective::Balanced {
504        return cands.iter().collect();
505    }
506    let front = even_front(cands);
507    cands
508        .iter()
509        .filter(|c| !c.ragged || !front.iter().any(|t| beats(*t, totals(c))))
510        .collect()
511}
512
513/// The predicted totals a ragged plan answers for: bytes moved, transactions, compute-engine calls.
514fn totals(c: &Candidate) -> (u64, u64, u64) {
515    (c.work.noc_bytes, c.work.noc_txns, c.work.compute_calls)
516}
517
518/// `a` is no worse than `b` on every total, and better on one.
519fn beats(a: (u64, u64, u64), b: (u64, u64, u64)) -> bool {
520    a.0 <= b.0 && a.1 <= b.1 && a.2 <= b.2 && a != b
521}
522
523/// The evenly divided plans' totals that no other even plan beats — the only ones that can beat anything,
524/// since beating is transitive. A few dozen points out of thousands of candidates. In ascending order a point
525/// can be beaten only by one before it, so one pass against the front kept so far finds it.
526fn even_front(cands: &[Candidate]) -> Vec<(u64, u64, u64)> {
527    let mut points: Vec<(u64, u64, u64)> = cands.iter().filter(|c| !c.ragged).map(totals).collect();
528    points.sort_unstable();
529    points.dedup();
530    let mut front: Vec<(u64, u64, u64)> = Vec::new();
531    for p in points {
532        if !front.iter().any(|f| beats(*f, p)) {
533            front.push(p);
534        }
535    }
536    front
537}
538
539/// A blocked plan's arithmetic intensity, as an integer ratio scaled to compare exactly: tile
540/// multiplies per operand tile read. Streaming plans score zero — they read both operands per
541/// multiply, which is the whole reason they are the fallback.
542fn intensity_key(c: &Candidate) -> usize {
543    if is_streaming(&c.plan) {
544        return 0;
545    }
546    let BlockShape { osh, osw, ibw } = c.plan.block;
547    let reads = (osh + osw) * ibw;
548    if reads == 0 {
549        return 0;
550    }
551    (osh * osw * ibw * 1024) / reads
552}
553
554/// How many contraction tiles a stage carries. Ties in intensity are broken toward a DEEPER stage:
555/// every stage pays a pipeline round-trip (acquire, commit, wait, release) and a deeper one amortises
556/// it over more multiplies, so between two plans of equal intensity the one with fewer, larger stages
557/// does the same arithmetic with less synchronisation.
558fn stage_depth_key(c: &Candidate) -> usize {
559    if is_streaming(&c.plan) {
560        0
561    } else {
562        c.plan.block.ibw
563    }
564}
565
566/// ★ WHERE THE PARTIAL ALREADY IS THE RESULT, THE REDUCTION FINISHES IN THE OUTPUT'S PAGE. Exact numerics hold
567/// the partial sums at the accumulator's own width; where the output is stored at that width and nothing
568/// computes after the matmul, the last K block's sum is the result itself. A target whose packer adds into the
569/// output's page ([`PackerAccumulate::IntoTheOutput`](crate::spatial::target::PackerAccumulate::IntoTheOutput))
570/// then accumulates every block there — the intermediate shares the output's memory (`Buffer::alias_of`) — and
571/// never reloads the partial into the register file, which Wormhole and Blackhole cannot do at that width.
572///
573/// ★ ONLY WHERE THE SUM IS THE SAME IN EVERY ORDER. The packer adds a block's sum, formed from zero in the
574/// register file, to the partial: it re-associates the reduction at every block boundary, where exact numerics
575/// promise a straight K-ascending accumulation. The two agree bit for bit exactly when every partial sum is an
576/// integer the accumulator holds exactly, which the program's claim states (`NumericsClaim::Exact`: integer
577/// operands whose bound times K stays inside fp32's mantissa). On data whose range is unknown the reduction
578/// keeps its order.
579pub fn finishes_in_place(p: &SpatialProgram, plan: &BlockPlan, target: &SpatialTarget) -> bool {
580    let computes_after = p.stages.iter().any(|s| {
581        matches!(
582            s.kind,
583            StageKind::Compute(_) | StageKind::Scalar(_) | StageKind::AllReduce { .. }
584        ) && !matches!(s.kind, StageKind::Compute(ComputeOp::MatmulTiles { .. }))
585    });
586    target.packer_accumulates_into_the_output()
587        && plan.pack_accumulate
588        && plan.inner.num_blocks > 1
589        && !is_streaming(plan)
590        && p.numerics == NumericsPolicy::Exact
591        && p.claim == NumericsClaim::Exact
592        && p.output_tensor()
593            .is_some_and(|o| o.dtype == plan.acc.interm_precision())
594        && !computes_after
595}
596
597/// Apply a plan to a template: patterns, block shape, spill buffer, work items.
598pub fn apply_plan(p: &SpatialProgram, plan: BlockPlan, target: &SpatialTarget) -> SpatialProgram {
599    let mut q = p.clone();
600    let out = q.output_tensor().unwrap().clone();
601    let (mt, nt, kt) = (out.tiles.rows_t, out.tiles.cols_t, q.work.k_tiles);
602    let (pm, pn) = (plan.per_site_m, plan.per_site_n);
603    let BlockShape { osh, osw, ibw } = plan.block;
604    let nb = plan.inner.num_blocks;
605    let sub_blocks = (pm / osh, pn / osw);
606    // Buffers: page = one block of the operand, one sub-block of the output.
607    let (mut in0, mut in1, mut outb) = (None, None, None);
608    for s in &q.stages {
609        match &s.kind {
610            StageKind::Load { tensor, .. } if tensor.idx() == 0 => in0 = s.outs.first().copied(),
611            StageKind::Load { tensor, .. } if tensor.idx() == 1 => in1 = s.outs.first().copied(),
612            StageKind::Compute(ComputeOp::MatmulTiles { .. }) => outb = s.outs.first().copied(),
613            _ => {}
614        }
615    }
616    let (in0, in1, outb) = (
617        in0.expect("load_a"),
618        in1.expect("load_b"),
619        outb.expect("mm out"),
620    );
621    let in_place = finishes_in_place(p, &plan, target);
622    // ★ A DEEPER OPERAND RING WHERE OCCUPANCY CANNOT HIDE LATENCY. A persistent GPU kernel runs
623    // ONE block per site — 8 warps of the 48 an SM can hold — so there are no other warps to run
624    // while a load is outstanding, and the only thing covering DRAM latency is how far ahead the
625    // pipeline can read. Three stages let the loads for block i+2 be in flight while block i
626    // computes; two only ever cover one block's worth.
627    //
628    // A Tensix site is the opposite case — its two movement engines and its own scheduler cover
629    // the latency — so it keeps the double buffer it was measured with.
630    // MEASURED: three stages made it SLOWER (2,397 → 2,060 GOPS). The extra page costs shared
631    // memory, and shared memory is what the block tile is bought with — the deeper pipeline covered
632    // less latency than the smaller tile lost in arithmetic intensity. Two stages.
633    let operand_depth = 2;
634    q.buffers[in0.idx()].kind = BufferKind::Fifo {
635        depth_pages: operand_depth,
636        page_tiles: pm * ibw,
637    };
638    q.buffers[in1.idx()].kind = BufferKind::Fifo {
639        depth_pages: operand_depth,
640        page_tiles: ibw * pn,
641    };
642    // ★ WHERE THE EPILOGUE IS FUSED, THE OUTPUT BUFFER IS A PER-WARP SCRATCH. A register-resident
643    // accumulator writes its tiles straight to global one at a time through a slot of its own, so
644    // the block's output never stages through a ring — and the ring it used to need was the
645    // largest in the kernel (`osh · osw` tiles at the accumulator's width, double-buffered), the
646    // one thing stopping the planner from affording a block tile worth running.
647    //
648    // Four tiles: one per warp, which is what the lowering declares and what the footprint model
649    // charges, so the plan the planner priced is the plan the allocator lays out.
650    q.buffers[outb.idx()].kind =
651        if target.accumulator_in_registers() && target.accumulator_lane_grid(osh, osw).is_some() {
652            // One tile per accumulator lane — the fused epilogue's whole shared-memory cost, whatever
653            // the block tile is. Sized only where the epilogue REALLY fuses; a block whose tiles do not
654            // split across the lanes still stages through a full ring, and sizing that as a scratch is
655            // what the device reported as an illegal access.
656            BufferKind::Fifo {
657                depth_pages: target.accumulator_lanes(),
658                page_tiles: 1,
659            }
660        } else if in_place {
661            // The intermediate shares this buffer's memory page for page, so both hold exactly one page per
662            // sub-block and their write pointers meet at the same page when the last block packs.
663            BufferKind::Fifo {
664                depth_pages: sub_blocks.0 * sub_blocks.1,
665                page_tiles: osh * osw,
666            }
667        } else {
668            BufferKind::Fifo {
669                depth_pages: (sub_blocks.0 * sub_blocks.1).max(2),
670                page_tiles: osh * osw,
671            }
672        };
673    let operand = q.buffers[in0.idx()].dtype;
674    // A spill buffer exists only where the compute engine spills: a blocked plan with more than one K block.
675    // The streaming family accumulates every K step in the register file and never spills.
676    // A spill buffer exists only where the accumulator has to be DRAINED between K blocks. Where it
677    // is register-resident (the GPU) the partial never touches memory, and emitting a buffer for it
678    // would be a construct the lowering then refuses.
679    let interm = if nb > 1
680        && !crate::spatial::cost::is_streaming(&plan)
681        && !target.accumulator_in_registers()
682    {
683        let dtype = if q.numerics == NumericsPolicy::Exact {
684            plan.acc.interm_precision()
685        } else {
686            operand
687        };
688        let id = BufferId::new(q.buffers.len());
689        q.buffers.push(Buffer {
690            id,
691            name: "interm".into(),
692            kind: BufferKind::Interm {
693                depth_pages: sub_blocks.0 * sub_blocks.1,
694                page_tiles: osh * osw,
695            },
696            dtype,
697            space: MemSpace::Local,
698            site: None,
699            origin: None,
700            alias_of: in_place.then_some(outb),
701        });
702        Some(id)
703    } else {
704        None
705    };
706    for s in q.stages.iter_mut() {
707        match &mut s.kind {
708            StageKind::Load {
709                tensor,
710                pattern,
711                num_blocks,
712                ..
713            } if tensor.idx() == 0 => {
714                *pattern = AccessPattern {
715                    origin: TileAddr {
716                        base: 0,
717                        steps: vec![(LV_ITEM_Y, pm * kt), (LV_KBLOCK, ibw)],
718                        indirect: None,
719                    },
720                    block_h: pm,
721                    block_w: ibw,
722                    stride_w: 1,
723                    stride_h: kt,
724                };
725                *num_blocks = nb;
726            }
727            StageKind::Load {
728                tensor,
729                pattern,
730                num_blocks,
731                ..
732            } if tensor.idx() == 1 => {
733                *pattern = AccessPattern {
734                    origin: TileAddr {
735                        base: 0,
736                        steps: vec![(LV_ITEM_X, pn), (LV_KBLOCK, ibw * nt)],
737                        indirect: None,
738                    },
739                    block_h: ibw,
740                    block_w: pn,
741                    stride_w: 1,
742                    stride_h: nt,
743                };
744                *num_blocks = nb;
745            }
746            StageKind::Store {
747                pattern,
748                sub_blocks: sb,
749                ..
750            } => {
751                *pattern = AccessPattern {
752                    origin: TileAddr {
753                        base: 0,
754                        steps: vec![
755                            (LV_ITEM_Y, pm * nt),
756                            (LV_ITEM_X, pn),
757                            (LV_SUB_H, osh * nt),
758                            (LV_SUB_W, osw),
759                        ],
760                        indirect: None,
761                    },
762                    block_h: osh,
763                    block_w: osw,
764                    stride_w: 1,
765                    stride_h: nt,
766                };
767                *sb = sub_blocks;
768            }
769            StageKind::Compute(ComputeOp::MatmulTiles {
770                block,
771                sub_blocks: sb,
772                num_blocks,
773                interm: it,
774                granularity,
775                ..
776            }) => {
777                // A whole sub-block per call where the engine takes one: the unpacker, the math unit and the
778                // packer then pipeline across the sub-block instead of restarting per tile.
779                *granularity = plan.granularity;
780                *block = plan.block;
781                *sb = sub_blocks;
782                *num_blocks = nb;
783                *it = interm;
784            }
785            _ => {}
786        }
787    }
788    // A compute chain after the matmul carries the matmul's page shape; a row-vector operand (a bias) is
789    // loaded per sub-block column.
790    let stage_ids: Vec<StageId> = q.stages.iter().map(|s| s.id).collect();
791    for sid in stage_ids {
792        let (kind, ins, outs) = {
793            let s = &q.stages[sid.idx()];
794            (s.kind.clone(), s.ins.clone(), s.outs.clone())
795        };
796        match kind {
797            StageKind::Compute(ComputeOp::MatmulTiles { .. }) => {}
798            StageKind::Compute(_) | StageKind::Scalar(_) => {
799                if let (Some(i), Some(o)) = (ins.first(), outs.first()) {
800                    let page = q.buffers[i.idx()].kind.page_tiles();
801                    if let BufferKind::Fifo { depth_pages, .. } = q.buffers[o.idx()].kind {
802                        q.buffers[o.idx()].kind = BufferKind::Fifo {
803                            depth_pages,
804                            page_tiles: page,
805                        };
806                    }
807                }
808            }
809            StageKind::Load { tensor, .. }
810                if tensor.idx() >= 2 && q.tensors[tensor.idx()].tiles.rows_t == 1 =>
811            {
812                let cols_t = q.tensors[tensor.idx()].tiles.cols_t;
813                if let StageKind::Load {
814                    pattern,
815                    num_blocks,
816                    ..
817                } = &mut q.stages[sid.idx()].kind
818                {
819                    *pattern = AccessPattern {
820                        origin: TileAddr {
821                            base: 0,
822                            steps: vec![(LV_ITEM_X, pn), (LV_SUB_W, osw)],
823                            indirect: None,
824                        },
825                        block_h: 1,
826                        block_w: osw,
827                        stride_w: 1,
828                        stride_h: cols_t,
829                    };
830                    *num_blocks = 1;
831                }
832                if let Some(o) = outs.first() {
833                    if let BufferKind::Fifo { depth_pages, .. } = q.buffers[o.idx()].kind {
834                        q.buffers[o.idx()].kind = BufferKind::Fifo {
835                            depth_pages,
836                            page_tiles: osw,
837                        };
838                    }
839                }
840            }
841            _ => {}
842        }
843    }
844    let (ny, nx) = plan.grid;
845    let mut items = Vec::with_capacity(ny * nx);
846    for oy in 0..ny {
847        for ox in 0..nx {
848            // The boundary row and column carry what is LEFT, which is what makes a non-dividing grid
849            // legal: items already own their extent, so a smaller last block needs no new IR.
850            let (row0, col0) = (oy * pm, ox * pn);
851            let h_t = pm.min(mt.saturating_sub(row0));
852            let w_t = pn.min(nt.saturating_sub(col0));
853            if h_t == 0 || w_t == 0 {
854                continue;
855            }
856            items.push(WorkItem {
857                id: WorkItemId::new(items.len()),
858                coord: (oy, ox),
859                out: TileRect {
860                    row0_t: row0,
861                    col0_t: col0,
862                    h_t,
863                    w_t,
864                },
865            });
866        }
867    }
868    q.work.items = items;
869    q.work.plan = Some(plan);
870    q
871}
872
873/// The `tile` pass.
874pub fn tile(p: &SpatialProgram, target: &SpatialTarget) -> Result<SpatialProgram, SpatialReject> {
875    if p.work.plan.is_some() {
876        return Ok(p.clone());
877    }
878    if p.phase() != Phase::Template {
879        return Err(SpatialReject::WrongPhase {
880            pass: "tile",
881            expected: "Template",
882            found: p.phase(),
883        });
884    }
885    let Some(acc) = matmul_acc(p) else {
886        // Non-matmul templates (elementwise, row ops, scalar) carry a trivial one-block plan.
887        return trivial_plan(p, target);
888    };
889    let out = p.output_tensor().unwrap();
890    let exact = p.numerics == NumericsPolicy::Exact;
891    let best = fastest_on_the_twin(p, target).or_else(|| best_candidate(p, target)).ok_or(SpatialReject::NoLegalTiling {
892        rows_t: out.tiles.rows_t,
893        cols_t: out.tiles.cols_t,
894        k_tiles: p.work.k_tiles,
895        acc_tiles: target.effective_acc_tiles(acc, exact),
896        sites: target.sites.sites(),
897    })?;
898    let q = apply_plan(p, best.plan, target);
899    q.validate()?;
900    Ok(q)
901}
902
903/// Programs without a tile matmul (elementwise / row ops / scalar bodies): one block per work item with
904/// unit sub-blocks — the streaming family, whose legality is the local-memory fit.
905fn trivial_plan(
906    p: &SpatialProgram,
907    target: &SpatialTarget,
908) -> Result<SpatialProgram, SpatialReject> {
909    let out = p.output_tensor().expect("an output tensor");
910    let (mt, nt) = (out.tiles.rows_t, out.tiles.cols_t);
911    // The template's own work granularity (a row op works a whole tile-row per item); per tile otherwise.
912    let acc = AccPolicy::for_result(out.dtype);
913    let mut q = p.clone();
914    if q.work.items.is_empty() {
915        let mut items = Vec::with_capacity(mt * nt);
916        for oy in 0..mt {
917            for ox in 0..nt {
918                items.push(WorkItem {
919                    id: WorkItemId::new(items.len()),
920                    coord: (oy, ox),
921                    out: TileRect {
922                        row0_t: oy,
923                        col0_t: ox,
924                        h_t: 1,
925                        w_t: 1,
926                    },
927                });
928            }
929        }
930        q.work.items = items;
931    }
932    let (h, w) = q
933        .work
934        .items
935        .first()
936        .map(|it| (it.out.h_t.max(1), it.out.w_t.max(1)))
937        .unwrap_or((1, 1));
938    // An in-place update of carried state works only its items' blocks (see `validate_plan`).
939    let grid = if out.role.is_fully_covered() {
940        (mt.div_ceil(h), nt.div_ceil(w))
941    } else {
942        (1, q.work.items.len())
943    };
944    let plan = BlockPlan {
945        per_site_m: h,
946        per_site_n: w,
947        grid,
948        inner: InnerLoop {
949            num_blocks: 1,
950            block_k_tiles: 1,
951        },
952        block: BlockShape {
953            osh: h,
954            osw: w,
955            ibw: 1,
956        },
957        acc,
958        pack_accumulate: false,
959        engines: EnginePacking::LoadsTogether,
960        granularity: Granularity::PerTile,
961    };
962    q.work.k_tiles = q.work.k_tiles.max(1);
963    q.work.plan = Some(plan);
964    let footprint = q.buffers.iter().map(|b| q.buffer_bytes(b)).sum::<usize>();
965    // The budget is what the site can be configured with, as the matmul plans' is.
966    if footprint > target.max_local_mem_bytes() {
967        return Err(SpatialReject::NoLegalTiling {
968            rows_t: mt,
969            cols_t: nt,
970            k_tiles: q.work.k_tiles,
971            acc_tiles: q.acc_tiles_used,
972            sites: target.sites.sites(),
973        });
974    }
975    q.validate()?;
976    Ok(q)
977}
978
979// ── The block-lattice search, as algebra ────────────────────────────────────────────────────────────────
980//
981// `candidates` above searches this same lattice against a calibrated cycle model and a full `SpatialProgram`.
982// What follows is the lattice ITSELF — divisors, the three legality rules, and a heuristic ranking — stated
983// over nothing but three tile counts and a part's limits. The Tenstorrent facade is expressed in terms of it
984// rather than carrying its own copy, and `tt_tiling_specification_lock` restates it independently and
985// requires the answer to be cost-minimal, so the two can never drift into disagreeing about what is legal.
986
987/// What a part can hold, in the units the lattice is filtered by. A named-part preset produces one of these;
988/// so does a [`SpatialTarget`], which is what makes the search portable rather than Tenstorrent-shaped.
989#[derive(Debug, Clone, Copy, PartialEq, Eq)]
990pub struct TileLimits {
991    /// Output tiles the accumulator register file holds per acquire — the reuse sub-block ceiling.
992    pub max_acc_tiles: usize,
993    /// Site-grid width (max sites across the N dimension).
994    pub sites_x: usize,
995    /// Site-grid height (max sites across the M dimension).
996    pub sites_y: usize,
997    /// Per-site local SRAM (KiB) — budgets the resident operand blocks, which bounds the inner block width.
998    pub local_kib: usize,
999}
1000
1001/// A tiling / blocking config for the blocked matmul kernel — the knobs an autotuner searches, in 32×32
1002/// tiles. [`best_tile_config`] fills this from a cost model; the vendor hand-tunes the equivalent.
1003#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1004pub struct TileConfig {
1005    /// Site columns (each owns `per_core_n` output tile-columns).
1006    pub grid_x: usize,
1007    /// Site rows (each owns `per_core_m` output tile-rows).
1008    pub grid_y: usize,
1009    /// Output tile-rows computed per site.
1010    pub per_core_m: usize,
1011    /// Output tile-cols computed per site.
1012    pub per_core_n: usize,
1013    /// Inner block width — contraction tiles streamed per block (bigger ⇒ more reuse per DRAM read).
1014    pub in0_block_w: usize,
1015    /// Accumulator-resident output sub-block height.
1016    pub out_subblock_h: usize,
1017    /// Accumulator-resident output sub-block width.
1018    pub out_subblock_w: usize,
1019}
1020
1021impl TileConfig {
1022    /// Output tiles held in the accumulator register file per acquire — must be ≤ [`TileLimits::max_acc_tiles`].
1023    pub fn dst_tiles(&self) -> usize {
1024        self.out_subblock_h * self.out_subblock_w
1025    }
1026    /// Sites used.
1027    pub fn num_cores(&self) -> usize {
1028        self.grid_x * self.grid_y
1029    }
1030}
1031
1032/// Estimated per-site cost of a tiling (lower = faster) — a heuristic RANKING model, not an absolute cycle
1033/// count. It captures the three levers that move blocked-matmul time: (1) **parallelism** — the per-site
1034/// output-tile work `per_core_m·per_core_n`, minimized by using more sites (dominant term); (2) **reuse** —
1035/// the number of accumulator acquires per site `(per_core_m/osh)·(per_core_n/osw)·(kt/in0_block_w)`,
1036/// minimized by a bigger sub-block and wider inner block; (3) **reload** — the spill/reload traffic when the
1037/// contraction is blocked.
1038pub fn tiling_cost(kt: usize, cfg: &TileConfig) -> u64 {
1039    let per_core_out = (cfg.per_core_m * cfg.per_core_n) as u64;
1040    let num_blocks = (kt / cfg.in0_block_w) as u64;
1041    let acquires = (cfg.per_core_m / cfg.out_subblock_h) as u64
1042        * (cfg.per_core_n / cfg.out_subblock_w) as u64
1043        * num_blocks;
1044    let reload = if num_blocks > 1 { acquires } else { 0 };
1045    per_core_out * 1_000_000 + acquires * 100 + reload
1046}
1047
1048/// Rough local-memory footprint (in tiles) of the resident double-buffered operand blocks for `cfg` — this
1049/// is what bounds `in0_block_w`.
1050pub fn l1_block_tiles(cfg: &TileConfig) -> usize {
1051    2 * (cfg.per_core_m * cfg.in0_block_w + cfg.per_core_n * cfg.in0_block_w)
1052        + cfg.per_core_m * cfg.per_core_n
1053}
1054
1055/// **Smart-lattice autotune**: search the divisor lattice of the tiling space — `per_core_m | mt`,
1056/// `per_core_n | nt`, `out_subblock_h | per_core_m`, `out_subblock_w | per_core_n`, `in0_block_w | kt` —
1057/// keeping only points LEGAL on this part (grid fits, the accumulator holds the sub-block, blocks fit local
1058/// memory), and return the minimum-[`tiling_cost`] point. This is the compiler *choosing* the config the
1059/// vendor hand-tunes, and it adapts to the part's limits: a larger accumulator or grid unlocks larger,
1060/// faster lattice points.
1061pub fn best_tile_config(mt: usize, nt: usize, kt: usize, limits: &TileLimits) -> TileConfig {
1062    // Rough local tile budget: a bf16 tile is 2 KiB, so ~ local_kib/2 tiles, held back for output/overhead.
1063    let budget_tiles = (limits.local_kib / 2).saturating_sub(8);
1064    let mut best: Option<TileConfig> = None;
1065    let mut best_cost = u64::MAX;
1066    // Loop-invariant, same as in `candidates` above: `divisors(kt)` was being rebuilt at the
1067    // innermost level once per (pcm, pcn, osh, osw). Same lists, same order, computed once.
1068    let div_mt = divisors(mt);
1069    let div_nt = divisors(nt);
1070    let div_kt = divisors(kt);
1071    for &pcm in div_mt.iter().filter(|&&d| mt / d <= limits.sites_y) {
1072        let div_pcm = divisors(pcm);
1073        for &pcn in div_nt.iter().filter(|&&d| nt / d <= limits.sites_x) {
1074            let div_pcn = divisors(pcn);
1075            for &osh in &div_pcm {
1076                for &osw in &div_pcn {
1077                    if osh * osw > limits.max_acc_tiles {
1078                        continue;
1079                    }
1080                    for &ibw in &div_kt {
1081                        let cfg = TileConfig {
1082                            grid_x: nt / pcn,
1083                            grid_y: mt / pcm,
1084                            per_core_m: pcm,
1085                            per_core_n: pcn,
1086                            in0_block_w: ibw,
1087                            out_subblock_h: osh,
1088                            out_subblock_w: osw,
1089                        };
1090                        if l1_block_tiles(&cfg) > budget_tiles {
1091                            continue;
1092                        }
1093                        let cost = tiling_cost(kt, &cfg);
1094                        if cost < best_cost {
1095                            best_cost = cost;
1096                            best = Some(cfg);
1097                        }
1098                    }
1099                }
1100            }
1101        }
1102    }
1103    // The lattice always contains the trivial point (1×1 sub-block, 1-wide block), so this never fails.
1104    best.unwrap_or(TileConfig {
1105        grid_x: 1,
1106        grid_y: 1,
1107        per_core_m: mt,
1108        per_core_n: nt,
1109        in0_block_w: 1,
1110        out_subblock_h: 1,
1111        out_subblock_w: 1,
1112    })
1113}
1114
1115impl SpatialTarget {
1116    /// The part's limits in the units [`best_tile_config`] filters by, so any target can drive the lattice
1117    /// search without a bespoke preset of its own.
1118    pub fn tile_limits(&self, acc: AccPolicy, exact: bool) -> TileLimits {
1119        TileLimits {
1120            max_acc_tiles: self.effective_acc_tiles(acc, exact),
1121            sites_x: self.sites.x,
1122            sites_y: self.sites.y,
1123            local_kib: self.max_local_mem_bytes() / 1024,
1124        }
1125    }
1126}