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}