logicaffeine_compile/spatial/cost.rs
1//! The calibrated cycle model — what makes "streamed vs multicast vs blocked" an EMERGENT plan decision.
2//!
3//! Every quantity is computed FROM a candidate plan's structure (tile-MACs, DRAM tile reads, circular-buffer
4//! hand-offs, multicast blocks, semaphores, kernel classes); the model turns them into predicted device cycles
5//! with a handful of constants. On Wormhole those constants are FITTED to the rows of the ttsim GEMM ledger
6//! (`benchmarks/silicon/tenstorrent_gemm.json`, written only by `scripts/tt-gemm-ledger.py` from real runs —
7//! never typed), within matched-knob groups only. Other targets carry an `uncalibrated` model whose absolute
8//! numbers are never asserted — only its structural monotonicity is. No strategy enum exists anywhere: the
9//! `tile` pass enumerates candidate plans and keeps the minimum of this function, and `fuse_readers`
10//! re-derives the same comparison for the fused/unfused pair.
11
12use serde::{Deserialize, Serialize};
13
14use super::ir::{AccPolicy, BlockPlan, BlockShape, EnginePacking, Granularity, InnerLoop};
15use super::target::{Fidelity, MeshShape, SpatialTarget};
16
17/// Path of the ttsim GEMM ledger, relative to the repo root.
18pub const CALIBRATION_JSON: &str = "benchmarks/silicon/tenstorrent_gemm.json";
19
20/// Per-cycle-model constants. Names mirror the terms of [`CycleModel::cycles`].
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
22pub struct CycleModel {
23 /// Fitted against measured points (true) or structural placeholders (false).
24 pub calibrated: bool,
25 pub c_kernel: f64,
26 /// Per-semaphore set-up. Collinear with `c_kernel` in the LOSS — and restoring it anyway, because
27 /// `n_sems` is what separates a multicast plan from a non-multicast one, so this is the only term that
28 /// prices the choice. Dropping it on loss-redundancy grounds moved the 512³ GEMM off its measured
29 /// multicast plan and cost 4% of its cycles. See the note on `unexercised`.
30 pub c_sem_setup: f64,
31 pub c_site_setup: f64,
32 /// Cycles per tile-MAC at HiFi4; the other fidelities scale it.
33 pub c_mac: f64,
34 pub f_hifi3: f64,
35 pub f_hifi2: f64,
36 pub f_lofi: f64,
37 pub f_fp32: f64,
38 pub c_acq: f64,
39 pub c_reload: f64,
40 /// Cycles the packer spends per tile. The packer and the math unit are separate pipelines that overlap
41 /// across sub-blocks, so the engine's time is the LONGER of the two, not their sum.
42 pub c_pack: f64,
43 /// Cycles a packer-accumulate drain round-trip costs the compute engine.
44 pub c_drain: f64,
45 pub c_dram_tile: f64,
46 /// Cycles of shared DRAM occupancy per tile of WHOLE-PROGRAM traffic.
47 ///
48 /// ★ This looks ~87x too small against Wormhole's DRAM roof
49 /// ([`Roofs::wormhole`](super::roofs::Roofs::wormhole): a 2048 B tile against 288 B/cycle is ~7.1
50 /// cycles/tile, not 0.08), and pricing it that way makes the planner prefer a multicast plan moving 768
51 /// tiles over the blocked one it picks today at 4,352 — a 5.7x traffic difference.
52 ///
53 /// It is deliberately NOT changed, because the multicast plan that pricing selects DOES NOT RUN:
54 /// forced at 128³ it fails to complete under ttsim in nine minutes, where the blocked plan at the same
55 /// shape finishes in seconds at PCC 1.0. This constant was fitted to MEASUREMENTS, so what looks like
56 /// mispricing is partly the fit correctly encoding that our multicast lowering is catastrophic.
57 ///
58 /// Fix the multicast hang first; then bandwidth-derived pricing is safe and unlocks roughly 4x less
59 /// DRAM traffic than ttnn.
60 pub c_dram_shared: f64,
61 pub c_cb: f64,
62 /// Per-multicast-block synchronization: the semaphore round trip and the multicast set-up, which the
63 /// model can only ever charge TOGETHER — a block pays both or neither, so no measurement can separate
64 /// them and carrying two constants would leave one direction of the fit unconstrained.
65 pub c_mcast_block: f64,
66 pub c_noc_tile: f64,
67}
68
69/// Which resource sets the pipeline's pace.
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub enum PaceBound {
72 Compute,
73 Movement(u8),
74 Sync,
75}
76
77/// A plan's cost, term by term, with the initiation interval its steady state runs at.
78#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
79pub struct CostBreakdown {
80 pub setup: f64,
81 pub compute: f64,
82 pub move_engine: (f64, f64),
83 pub sync: f64,
84 pub fill: f64,
85 pub drain: f64,
86 pub steady: f64,
87 /// Cycles between successive iterations of the steady-state loop.
88 pub ii: f64,
89 pub bound: PaceBound,
90 pub total: f64,
91}
92
93/// The structural quantities of one candidate plan (per the busiest site, plus whole-program totals).
94#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
95pub struct PlanQuantities {
96 /// The problem in tiles `(mt, nt, kt)`; points that share a problem, `fp32_acc` and `fidelity` are
97 /// measured alternatives of one another and must be ranked as the simulator ranked them.
98 pub problem: (usize, usize, usize),
99 pub sites_used: usize,
100 pub kernel_classes: usize,
101 pub n_sems: usize,
102 pub tile_macs: u64,
103 pub acquires: u64,
104 pub reload_tiles: u64,
105 pub dram_tiles_site: u64,
106 /// The site's DRAM traffic split across its movement engines, which run CONCURRENTLY: the engine with
107 /// the most work sets the pace, so the packing that balances them wins. One entry per engine.
108 pub dram_tiles_engine: (u64, u64),
109 /// Tiles the packer writes (every spill and the final output).
110 pub pack_tiles: u64,
111 /// Tile-MACs of ONE sub-block: the math the compute pipeline must finish before the packer has anything
112 /// to overlap with, so a bigger sub-block costs more fill however few of them there are.
113 pub fill_macs: u64,
114 /// Page round-trips the packer's in-place accumulate needs to return its write pointer.
115 pub drains: u64,
116 /// The packer and the math unit overlap. They can only do so when a sub-block fits in HALF the register
117 /// file, because the engine works one half while the packer drains the other; a sub-block that fills the
118 /// whole file forces the two to take turns.
119 pub pack_overlaps: bool,
120 pub dram_tiles_all: u64,
121 pub cb_handoffs: u64,
122 pub mcast_blocks: u64,
123 pub mcast_tiles: u64,
124 /// Prologue tiles per movement engine: the first block's operands, which the engines read CONCURRENTLY.
125 /// Summing them would charge a split arrangement for a serial read it never performs.
126 pub fill_tiles_engine: (u64, u64),
127 pub fill_tiles: u64,
128 pub drain_tiles: u64,
129 pub fp32_acc: bool,
130 pub fidelity: Fidelity,
131 /// Steady-state iterations the site runs: one per K block per work item. The pipeline's cost is its
132 /// initiation interval times this, so a term that does not scale with it is setup, not throughput.
133 pub iterations: u64,
134}
135
136impl CycleModel {
137 /// Structural placeholder constants (never asserted absolutely).
138 pub fn uncalibrated() -> CycleModel {
139 CycleModel {
140 calibrated: false,
141 c_kernel: 1250.0,
142 c_sem_setup: 50.0,
143 c_site_setup: 0.0,
144 c_mac: 6.5,
145 f_hifi3: 0.8,
146 f_hifi2: 0.6,
147 f_lofi: 0.4,
148 f_fp32: 1.1,
149 c_acq: 26.1824650701,
150 c_reload: 7.8319197229,
151 c_pack: 1.0,
152 c_drain: 30.0,
153 c_dram_tile: 18.0,
154 c_dram_shared: 0.0815085028,
155 c_cb: 1.5,
156 c_mcast_block: 310.0,
157 c_noc_tile: 2.5,
158 }
159 }
160
161 /// The Wormhole model, fitted by [`fit`](Self::fit) over the Wormhole rows of
162 /// `benchmarks/silicon/tenstorrent_gemm.json` at the knobs the compiler emits (HiFi4, fp32 accumulate
163 /// on and off). These constants reproduce NINETEEN OF TWENTY of the ledger's compiler-chosen plans,
164 /// which is the property that actually matters — see the note on
165 /// [`unexercised`](Self::unexercised) for why a lower-loss set of constants is not automatically a
166 /// better model.
167 ///
168 /// `f_hifi3`, `f_hifi2`, `f_lofi`, `c_mcast_block` and `c_noc_tile` sit at their structural priors and
169 /// must stay there: every measured row is HiFi4, and `steady` is a `max` whose synchronization branch
170 /// never binds on any of them, so nothing in this ledger can see those five.
171 /// `spatial_cost_identifiability_lock` enforces it.
172 pub fn wormhole_ttsim() -> CycleModel {
173 CycleModel {
174 calibrated: true,
175 c_kernel: 783.2813960073,
176 c_sem_setup: 106.1814811064,
177 c_site_setup: 2.4232492248,
178 c_mac: 4.0014415764,
179 f_hifi3: 0.8,
180 f_hifi2: 0.6,
181 f_lofi: 0.4,
182 f_fp32: 1.1,
183 c_acq: 26.1824650701,
184 c_reload: 7.8319197229,
185 c_pack: 1.0,
186 c_drain: 30.0,
187 c_dram_tile: 20.4947563410,
188 // Fitted, not the DRAM roof: see the field's note.
189 c_dram_shared: 0.0815085028,
190 c_cb: 0.0,
191 c_mcast_block: 310.0,
192 c_noc_tile: 2.5,
193 }
194 }
195
196 /// Every constant, named, paired with its slot — the ONE place the fit's index order is defined.
197 /// [`constants`](Self::constants) and [`set`](Self::set) both derive from this, so an index can never
198 /// mean one constant to the reader and another to the writer.
199 fn fields_mut(&mut self) -> Vec<(&'static str, &mut f64)> {
200 vec![
201 ("c_kernel", &mut self.c_kernel),
202 ("c_sem_setup", &mut self.c_sem_setup),
203 ("c_site_setup", &mut self.c_site_setup),
204 ("c_mac", &mut self.c_mac),
205 ("f_hifi3", &mut self.f_hifi3),
206 ("f_hifi2", &mut self.f_hifi2),
207 ("f_lofi", &mut self.f_lofi),
208 ("f_fp32", &mut self.f_fp32),
209 ("c_acq", &mut self.c_acq),
210 ("c_reload", &mut self.c_reload),
211 ("c_pack", &mut self.c_pack),
212 ("c_drain", &mut self.c_drain),
213 ("c_dram_tile", &mut self.c_dram_tile),
214 ("c_dram_shared", &mut self.c_dram_shared),
215 ("c_cb", &mut self.c_cb),
216 ("c_mcast_block", &mut self.c_mcast_block),
217 ("c_noc_tile", &mut self.c_noc_tile),
218 ]
219 }
220
221 /// The constants by name, in the fit's index order.
222 pub fn constants(&self) -> Vec<(&'static str, f64)> {
223 let mut c = self.clone();
224 c.fields_mut().into_iter().map(|(n, v)| (n, *v)).collect()
225 }
226
227 fn set(&mut self, i: usize, v: f64) {
228 *self.fields_mut().swap_remove(i).1 = v;
229 }
230
231 /// Constant `i` changed to `v` — the perturbation the identifiability lock searches with.
232 pub fn with_constant(&self, i: usize, v: f64) -> CycleModel {
233 let mut m = self.clone();
234 m.set(i, v);
235 m
236 }
237
238 fn fidelity_factor(&self, f: Fidelity) -> f64 {
239 match f {
240 Fidelity::HiFi4 | Fidelity::Exact => 1.0,
241 Fidelity::HiFi3 => self.f_hifi3,
242 Fidelity::HiFi2 => self.f_hifi2,
243 Fidelity::LoFi => self.f_lofi,
244 }
245 }
246
247 /// The compute engine's time for a plan: the math unit and the packer pipeline against each other
248 /// across sub-blocks, so the engine runs at the pace of the busier one; only the FIRST sub-block's math
249 /// has nothing to overlap with, which is what makes many small sub-blocks beat few large ones. The
250 /// acquire/release handshake belongs to the PACK side: when a sub-block fits in half the register file
251 /// the two halves pipeline and the handshake hides behind the math, and when it does not the whole thing
252 /// serialises and every handshake is exposed.
253 pub fn compute_cycles(&self, q: &PlanQuantities) -> f64 {
254 let fp32 = if q.fp32_acc { self.f_fp32 } else { 1.0 };
255 let mac = self.c_mac * self.fidelity_factor(q.fidelity) * fp32;
256 let math = q.tile_macs as f64 * mac;
257 let pack = q.pack_tiles as f64 * self.c_pack + q.acquires as f64 * self.c_acq;
258 let engine = if q.pack_overlaps {
259 math.max(pack)
260 } else {
261 math + pack
262 };
263 engine
264 + q.fill_macs as f64 * mac
265 + q.reload_tiles as f64 * self.c_reload
266 + q.drains as f64 * self.c_drain
267 }
268
269 /// The movement engines' time: they run concurrently, so the busier one sets the pace, not their sum.
270 pub fn move_cycles(&self, q: &PlanQuantities) -> f64 {
271 let engine_tiles = q.dram_tiles_engine.0.max(q.dram_tiles_engine.1);
272 engine_tiles as f64 * self.c_dram_tile
273 + q.dram_tiles_all as f64 * self.c_dram_shared
274 + q.cb_handoffs as f64 * self.c_cb
275 }
276
277 /// Predicted whole-program device cycles for a candidate plan.
278 /// The model's terms, named — what the plan spends and which resource sets its pace.
279 ///
280 /// A spatial plan's cost is not one number but a pipeline: each engine has a rate, and the slowest of
281 /// them is the initiation interval the steady state runs at. Reporting the terms is what lets the
282 /// compiler say a plan is movement-bound on its second engine rather than merely quoting a total.
283 pub fn explain(&self, q: &PlanQuantities) -> CostBreakdown {
284 let iters = q.iterations.max(1) as f64;
285 let t_compute = self.compute_cycles(q);
286 let t_move_0 = q.dram_tiles_engine.0 as f64 * self.c_dram_tile;
287 let t_move_1 = q.dram_tiles_engine.1 as f64 * self.c_dram_tile;
288 let t_shared =
289 q.dram_tiles_all as f64 * self.c_dram_shared + q.cb_handoffs as f64 * self.c_cb;
290 let t_sync =
291 q.mcast_blocks as f64 * self.c_mcast_block + q.mcast_tiles as f64 * self.c_noc_tile;
292 // No free intercept. Every program dispatches at least one kernel class, so a constant per PROGRAM
293 // and a constant per KERNEL CLASS are the same column whenever the measured rows share a class
294 // count — and the ledger's do. Removing it is safe for a reason that has nothing to do with the fit:
295 // an intercept is IDENTICAL across every candidate of one program, so it can never change which
296 // plan wins. `c_sem_setup` is the opposite and stays: `n_sems` is what distinguishes a multicast
297 // plan from a non-multicast one, so it is the only term that prices that choice.
298 let setup = self.c_kernel * q.kernel_classes as f64
299 + self.c_sem_setup * q.n_sems as f64
300 + self.c_site_setup * q.sites_used as f64;
301 let fill = q.fill_tiles_engine.0.max(q.fill_tiles_engine.1) as f64 * self.c_dram_tile;
302 let drain = q.drain_tiles as f64 * self.c_dram_tile;
303 let steady = t_compute.max(t_move_0 + t_shared).max(t_move_1).max(t_sync);
304 let bound = if steady == t_compute {
305 PaceBound::Compute
306 } else if steady == t_sync {
307 PaceBound::Sync
308 } else if steady == t_move_1 {
309 PaceBound::Movement(1)
310 } else {
311 PaceBound::Movement(0)
312 };
313 // A pipelined loop costs its prologue read, then its initiation interval once per iteration — the
314 // LAST block's compute still follows its own read, so the fill is a prologue on top of every
315 // iteration rather than a substitute for one — then its drain.
316 let ii = steady / iters;
317 CostBreakdown {
318 setup,
319 compute: t_compute,
320 move_engine: (t_move_0 + t_shared, t_move_1),
321 sync: t_sync,
322 fill,
323 drain,
324 steady,
325 ii,
326 bound,
327 total: setup + fill + steady + drain,
328 }
329 }
330
331 pub fn cycles(&self, q: &PlanQuantities) -> f64 {
332 self.explain(q).total
333 }
334
335 /// Sum of squared relative errors over measured points.
336 pub fn loss(&self, points: &[(PlanQuantities, u64)]) -> f64 {
337 points
338 .iter()
339 .map(|(q, measured)| {
340 let e = (self.cycles(q) - *measured as f64) / *measured as f64;
341 e * e
342 })
343 .sum()
344 }
345
346 /// How much worse the data term must get when a constant is frozen wrong, for that constant to count as
347 /// MEASURED. Below this, the rest of the model absorbs it and its value is an artifact of the search.
348 pub const DETERMINED_MARGIN: f64 = 1e-3;
349
350 /// The canonical point at which identifiability is judged: the unconstrained minimizer of the data
351 /// term. Determinedness is a property of a model AND a point — asking the question at two different
352 /// points gives two different answers — so every caller must ask it here.
353 pub fn reference(points: &[(PlanQuantities, u64)]) -> CycleModel {
354 let prior = CycleModel::uncalibrated();
355 let regularized = CycleModel::descend(prior.clone(), points, Some(&prior), &[]);
356 CycleModel::fit_data(points, ®ularized, &[])
357 }
358
359 /// How many contiguous folds the ledger is split into for the stability report, and how far a constant
360 /// may move when one fold is withheld before it is worth mentioning.
361 pub const JACKKNIFE_FOLDS: usize = 6;
362 pub const STABLE_MARGIN: f64 = 0.25;
363
364 /// The constants a withheld fold of the ledger moves by more than [`Self::STABLE_MARGIN`] — a DIAGNOSTIC,
365 /// reported by `spatial_cost_identifiability_lock` rather than enforced by it.
366 ///
367 /// Fold spread is real information about how thin the evidence for a constant is. It is deliberately
368 /// not a gate: the objective is piecewise (that `max`), so refits on different subsets legitimately
369 /// land in different basins, and gating on it demotes constants that carry the ledger's plan decisions.
370 pub fn unstable(&self, points: &[(PlanQuantities, u64)]) -> Vec<&'static str> {
371 let base = self.constants();
372 let mut bad: Vec<&'static str> = Vec::new();
373 for f in 0..CycleModel::JACKKNIFE_FOLDS {
374 let subset: Vec<(PlanQuantities, u64)> = points
375 .iter()
376 .enumerate()
377 .filter(|(i, _)| i % CycleModel::JACKKNIFE_FOLDS != f)
378 .map(|(_, p)| *p)
379 .collect();
380 if subset.len() < 4 {
381 continue;
382 }
383 let refit = CycleModel::fit(&subset);
384 for (i, (n, v)) in base.iter().enumerate() {
385 let w = refit.constants()[i].1;
386 if (v - w).abs() / v.abs().max(w.abs()).max(1e-12) > CycleModel::STABLE_MARGIN
387 && !bad.contains(n)
388 {
389 bad.push(n);
390 }
391 }
392 }
393 bad
394 }
395
396 /// ★ A LOSS-BASED redundancy test used to live here, and it measured the wrong thing. Recorded because
397 /// the mistake is an easy one to make twice.
398 ///
399 /// It asked whether freezing a constant at a wrong value could be absorbed by refitting the rest, and
400 /// by that criterion six of these sixteen constants looked undetermined. Returning those to their
401 /// priors and re-pinning changed almost nothing about predicted CYCLES — and changed TEN OF TWENTY
402 /// measured plan DECISIONS, the calibrated 512³ GEMM among them. The objective's valley is flat in
403 /// loss and is NOT flat in the choices the compiler makes inside it, so loss-redundancy says nothing
404 /// about whether a constant matters. The operational question is the one
405 /// `spatial_cost_model_lock::the_synchronization_terms_are_load_bearing` already asks: does changing
406 /// it change a DECISION?
407 ///
408 /// What survives from that work, because it is provable rather than empirical, is
409 /// [`unexercised`](Self::unexercised) — the constants no measured row can see at all.
410
411 /// The constants NO measured row can see — the ones whose term never binds the pace on any point, so
412 /// the loss is flat along them. A value the data cannot determine is not a measurement, and `fit`
413 /// leaves these at their structural prior rather than reporting whatever the search last touched.
414 ///
415 /// This is a property of the model AND the measurements together: a constant becomes exercised the
416 /// moment a row is measured whose plan it paces. `spatial_cost_identifiability_lock` requires every
417 /// unexercised constant to still equal its prior, so a structural guess can never be published as a fit.
418 pub fn unexercised(&self, points: &[(PlanQuantities, u64)]) -> Vec<&'static str> {
419 self.constants()
420 .into_iter()
421 .enumerate()
422 .filter(|(i, (_, v))| {
423 let probe = if *v == 0.0 { 1.0 } else { v * 2.0 + 1.0 };
424 let moved = self.with_constant(*i, probe);
425 points
426 .iter()
427 .all(|(q, _)| (self.cycles(q) - moved.cycles(q)).abs() < 1e-9)
428 })
429 .map(|(_, (n, _))| n)
430 .collect()
431 }
432
433 /// Deterministic fit of the constants to measured points. The objective is the sum of squared relative
434 /// errors, plus a ranking hinge over every pair of points that share a problem (tile shape, `fp32_acc`,
435 /// fidelity) so the model orders measured alternatives as the simulator did, plus a small ridge toward
436 /// the structural priors that pins the constants no point constrains.
437 ///
438 /// A constant no measured row can SEE cannot move from its prior here, because nothing in the objective
439 /// responds to it — see [`unexercised`](Self::unexercised), which names them, and
440 /// `spatial_cost_identifiability_lock`, which requires the published model to leave them alone.
441 ///
442 /// Same points → same constants, bit for bit.
443 pub fn fit(points: &[(PlanQuantities, u64)]) -> CycleModel {
444 let prior = CycleModel::uncalibrated();
445 if points.is_empty() {
446 return prior;
447 }
448 let mut m = CycleModel::descend(prior.clone(), points, Some(&prior), &[]);
449 m.calibrated = true;
450 m
451 }
452
453 /// The minimizer of the DATA term alone, from `start`, optionally holding constant `hold` fixed.
454 ///
455 /// The regularized [`fit`](Self::fit) is what the compiler ships, but identifiability is a question
456 /// about what the MEASUREMENTS determine — and the ridge exists precisely to pull unconstrained
457 /// directions back to the prior, so probing under it would measure the regularizer instead of the data.
458 /// `spatial_cost_identifiability_lock` uses this entry, and `hold` is how it asks whether the rest of
459 /// the model can absorb a constant it has been denied.
460 pub fn fit_data(
461 points: &[(PlanQuantities, u64)],
462 start: &CycleModel,
463 hold: &[usize],
464 ) -> CycleModel {
465 // MULTI-START, because the objective is not convex: `steady` is a `max` over four branches, so the
466 // surface is piecewise and a descent can only ever find the basin it started in. A single start
467 // made the answer depend on where the search began — the very thing the pinned constants must not
468 // do. The starts are fixed, so the result is still deterministic.
469 let held = |m: &CycleModel| {
470 let mut m = m.clone();
471 for &i in hold {
472 m = m.with_constant(i, start.constants()[i].1);
473 }
474 m
475 };
476 let mut starts = vec![start.clone(), held(&CycleModel::uncalibrated())];
477 for f in [0.25f64, 4.0] {
478 let mut s = start.clone();
479 for i in 0..s.constants().len() {
480 if !hold.contains(&i) {
481 s = s.with_constant(i, s.constants()[i].1 * f);
482 }
483 }
484 starts.push(s);
485 }
486 starts
487 .into_iter()
488 .map(|s| CycleModel::descend(s, points, None, hold))
489 .min_by(|a, b| a.loss(points).total_cmp(&b.loss(points)))
490 .expect("at least one start")
491 }
492
493 /// One descent on the data term from `start`, holding `hold` fixed — the probe both the model and
494 /// `spatial_cost_identifiability_lock` judge identifiability with.
495 ///
496 /// Single-start on purpose: the multi-start in [`fit_data`](Self::fit_data) exists to FIND the basin,
497 /// and identifiability is a question asked inside it. Probing with a second multi-start lets the two
498 /// sides of the comparison land in different basins, so the answer reports which search got lucky
499 /// rather than whether the constant carries information.
500 pub fn refit_from(
501 points: &[(PlanQuantities, u64)],
502 start: &CycleModel,
503 hold: &[usize],
504 ) -> CycleModel {
505 CycleModel::descend(start.clone(), points, None, hold)
506 }
507
508 /// Coordinate descent with **Hooke-Jeeves pattern moves**.
509 ///
510 /// Plain coordinate descent stalls in a narrow diagonal valley — which is exactly the shape correlated
511 /// constants make — and where it stalls then depends on where it started. That is not a cosmetic
512 /// flaw: it made the fit's answer an artifact of the search path, so re-fitting the same ledger from a
513 /// different point produced different constants and the pinned literals recorded a coincidence. After
514 /// each sweep the net displacement is extrapolated along, which walks the valley instead of zig-zagging
515 /// across it.
516 fn descend(
517 start: CycleModel,
518 points: &[(PlanQuantities, u64)],
519 prior: Option<&CycleModel>,
520 hold: &[usize],
521 ) -> CycleModel {
522 let n = start.constants().len();
523 let mut m = start;
524 let mut best = m.objective(points, prior);
525 for _ in 0..80 {
526 let before = m.clone();
527 let mut improved = false;
528 for i in 0..n {
529 if hold.contains(&i) {
530 continue;
531 }
532 let (v, l) = m.line_search(points, prior, i);
533 if l + 1e-12 < best {
534 best = l;
535 m = m.with_constant(i, v);
536 improved = true;
537 }
538 }
539 if !improved {
540 break;
541 }
542 let now = m.constants();
543 let was = before.constants();
544 for &step in &[3.0, 2.0, 1.0, 0.5] {
545 let mut trial = m.clone();
546 for i in 0..n {
547 if hold.contains(&i) {
548 continue;
549 }
550 trial =
551 trial.with_constant(i, (now[i].1 + (now[i].1 - was[i].1) * step).max(0.0));
552 }
553 let l = trial.objective(points, prior);
554 if l + 1e-12 < best {
555 best = l;
556 m = trial;
557 }
558 }
559 }
560 m
561 }
562
563 fn objective(&self, points: &[(PlanQuantities, u64)], prior: Option<&CycleModel>) -> f64 {
564 match prior {
565 Some(p) => self.fit_objective(points, p),
566 None => self.loss(points),
567 }
568 }
569
570 /// The best value of constant `i`, holding the others fixed: a LOG-spaced sweep over nine decades
571 /// (plus exact zero) to bracket the minimum, then ternary refinement inside the bracketing cell.
572 ///
573 /// Log-spaced because a compensating value routinely sits one or two orders of magnitude from where the
574 /// constant started: a linear span wide enough to reach it has no resolution near the origin, and one
575 /// fine enough near the origin never reaches it. The earlier geometric ladder around the current value
576 /// could only land on the rungs it happened to have, and stopped measurably short of the optimum.
577 fn line_search(
578 &self,
579 points: &[(PlanQuantities, u64)],
580 prior: Option<&CycleModel>,
581 i: usize,
582 ) -> (f64, f64) {
583 let at = |v: f64| self.with_constant(i, v).objective(points, prior);
584 let cur = self.constants()[i].1;
585 let s = if cur.abs() < 1e-12 { 1.0 } else { cur.abs() };
586 const N: usize = 180;
587 let mut best = (0.0f64, at(0.0));
588 for k in 0..=N {
589 let v = s * 10f64.powf(-6.0 + 9.0 * k as f64 / N as f64);
590 let l = at(v);
591 if l < best.1 {
592 best = (v, l);
593 }
594 }
595 if best.0 == 0.0 {
596 return best;
597 }
598 let (mut a, mut b) = (best.0 / 1.13, best.0 * 1.13);
599 for _ in 0..90 {
600 let (m1, m2) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
601 if at(m1) < at(m2) {
602 b = m2;
603 } else {
604 a = m1;
605 }
606 }
607 let v = 0.5 * (a + b);
608 let l = at(v);
609 if l < best.1 {
610 (v, l)
611 } else {
612 best
613 }
614 }
615
616 /// The fit objective: squared relative errors + pairwise ranking hinge + ridge toward `prior`.
617 fn fit_objective(&self, points: &[(PlanQuantities, u64)], prior: &CycleModel) -> f64 {
618 let mut obj = self.loss(points);
619 for (i, (qi, mi)) in points.iter().enumerate() {
620 for (qj, mj) in points.iter().skip(i + 1) {
621 let same_problem = qi.problem == qj.problem
622 && qi.fp32_acc == qj.fp32_acc
623 && qi.fidelity == qj.fidelity;
624 if !same_problem || mi == mj {
625 continue;
626 }
627 let (winner, loser, loser_measured) = if mi < mj {
628 (qi, qj, *mj)
629 } else {
630 (qj, qi, *mi)
631 };
632 let gap = (self.cycles(winner) - self.cycles(loser)) / loser_measured as f64;
633 if gap > 0.0 {
634 obj += 4.0 * gap * gap;
635 }
636 }
637 }
638 for ((_, c), (_, p)) in self.constants().into_iter().zip(prior.constants()) {
639 let d = (c - p) / p.max(1.0);
640 obj += 1e-4 * d * d;
641 }
642 obj
643 }
644}
645
646/// A candidate plan for a GEMM of `mt × nt × kt` tiles: the streaming point family or a blocked point.
647#[derive(Clone, Copy, Debug, PartialEq, Eq)]
648pub struct GemmPlan {
649 pub plan: BlockPlan,
650 pub streaming: bool,
651}
652
653/// The streaming plan (one tile per work item, per-tile FIFOs).
654pub fn streaming_plan(mt: usize, nt: usize, kt: usize, acc: AccPolicy) -> BlockPlan {
655 BlockPlan {
656 per_site_m: 1,
657 per_site_n: 1,
658 grid: (mt, nt),
659 inner: InnerLoop {
660 num_blocks: kt,
661 block_k_tiles: 1,
662 },
663 block: BlockShape {
664 osh: 1,
665 osw: 1,
666 ibw: 1,
667 },
668 acc,
669 pack_accumulate: false,
670 engines: EnginePacking::LoadsTogether,
671 granularity: Granularity::PerTile,
672 }
673}
674
675pub fn is_streaming(plan: &BlockPlan) -> bool {
676 plan.per_site_m == 1
677 && plan.per_site_n == 1
678 && plan.block
679 == BlockShape {
680 osh: 1,
681 osw: 1,
682 ibw: 1,
683 }
684}
685
686/// Structural quantities of a candidate plan, analytically from the plan (the same formulas
687/// `PlanSummary::dram_tiles_read` reproduces from the built structure).
688pub fn quantities_for_gemm(
689 mt: usize,
690 nt: usize,
691 kt: usize,
692 plan: &BlockPlan,
693 fused: bool,
694 fidelity: Fidelity,
695 target: &SpatialTarget,
696) -> PlanQuantities {
697 let sites = target.sites.sites().max(1);
698 let fp32_acc = plan.acc == AccPolicy::F32;
699 if is_streaming(plan) {
700 let items = mt * nt;
701 let sites_used = sites.min(items).max(1);
702 let w = items.div_ceil(sites_used) as u64;
703 let kt64 = kt as u64;
704 return PlanQuantities {
705 problem: (mt, nt, kt),
706 sites_used,
707 kernel_classes: 3,
708 n_sems: 0,
709 iterations: w * kt64,
710 tile_macs: w * kt64,
711 acquires: w,
712 reload_tiles: 0,
713 dram_tiles_site: 2 * w * kt64 + w,
714 dram_tiles_engine: (2 * w * kt64, w),
715 pack_tiles: w,
716 fill_macs: 1,
717 drains: 0,
718 pack_overlaps: true,
719 dram_tiles_all: 2 * (mt * nt * kt) as u64 + (mt * nt) as u64,
720 cb_handoffs: 2 * w * kt64 + w,
721 mcast_blocks: 0,
722 mcast_tiles: 0,
723 fill_tiles_engine: (1, 1),
724 fill_tiles: 2,
725 drain_tiles: 1,
726 fp32_acc,
727 fidelity,
728 };
729 }
730 let (pm, pn) = (plan.per_site_m, plan.per_site_n);
731 let (osh, osw, ibw) = (plan.block.osh, plan.block.osw, plan.block.ibw);
732 let (ny, nx) = plan.grid;
733 let blocks = ny * nx;
734 let sites_used = sites.min(blocks).max(1);
735 let items = blocks.div_ceil(sites_used) as u64;
736 let nb = (kt / ibw.max(1)).max(1) as u64;
737 let sub = ((pm / osh.max(1)) * (pn / osw.max(1))) as u64;
738 let (pm64, pn64, kt64) = (pm as u64, pn as u64, kt as u64);
739 let tile_macs = pm64 * pn64 * kt64 * items;
740 let acquires = sub * nb * items;
741 // The packer's L1 accumulate keeps the partial sum in local memory, so only the LAST block reloads the
742 // register file; without it every block after the first does.
743 let reload_tiles = if plan.pack_accumulate {
744 pm64 * pn64 * items
745 } else {
746 (nb - 1) * pm64 * pn64 * items
747 };
748 // Every hand-off the choreography makes: operand pages in, spills out, reloads back, and — under the
749 // packer's in-place accumulate — the drain that returns the intermediate write pointer to the page it is
750 // accumulating into, once per block but the last two.
751 // The drain is a compute-engine round-trip, not a movement one: it is scored with the compute pipeline.
752 let drains = if plan.pack_accumulate {
753 nb.saturating_sub(2) * sub * items
754 } else {
755 0
756 };
757 let cb_handoffs = (2 * nb + sub + (nb - 1) * sub) * items;
758 let pack_tiles = sub * nb * (osh * osw) as u64 * items;
759 let fill_macs = (osh * osw * ibw) as u64;
760 let pack_overlaps = 2 * osh * osw <= target.effective_acc_tiles(plan.acc, fp32_acc).max(1);
761 let a_shared = fused && nx > 1;
762 let b_shared = fused && ny > 1;
763 let (a_tiles, b_tiles, c_tiles) = (
764 pm64 * kt64 * items,
765 pn64 * kt64 * items,
766 pm64 * pn64 * items,
767 );
768 let dram_tiles_site = a_tiles + b_tiles + c_tiles;
769 // Which engine carries which stream. Both loads together leaves the store an engine to itself and makes
770 // the two operand reads serialise; pairing the second operand with the store overlaps a block's output
771 // write with the next block's reads.
772 let dram_tiles_engine = match plan.engines {
773 EnginePacking::LoadsTogether => (a_tiles + b_tiles, c_tiles),
774 EnginePacking::OperandWithStore => (a_tiles, b_tiles + c_tiles),
775 };
776 let a_all = if a_shared {
777 (mt * kt) as u64
778 } else {
779 (nx * mt * kt) as u64
780 };
781 let b_all = if b_shared {
782 (nt * kt) as u64
783 } else {
784 (ny * nt * kt) as u64
785 };
786 let dram_tiles_all = a_all + b_all + (mt * nt) as u64;
787 let mcast_blocks = ((a_shared as u64) + (b_shared as u64)) * nb * items;
788 let mcast_tiles = ((if a_shared { pm64 * ibw as u64 } else { 0 })
789 + (if b_shared { ibw as u64 * pn64 } else { 0 }))
790 * nb
791 * items;
792 let groups = (a_shared as usize) + (b_shared as usize);
793 let kernel_classes = match groups {
794 0 => 3,
795 1 => 4,
796 _ => 6,
797 };
798 PlanQuantities {
799 problem: (mt, nt, kt),
800 sites_used,
801 kernel_classes,
802 iterations: nb * items,
803 n_sems: 2 * groups,
804 tile_macs,
805 acquires,
806 reload_tiles,
807 dram_tiles_site,
808 dram_tiles_engine,
809 pack_tiles,
810 fill_macs,
811 drains,
812 pack_overlaps,
813 dram_tiles_all,
814 cb_handoffs,
815 mcast_blocks,
816 mcast_tiles,
817 fill_tiles_engine: match plan.engines {
818 EnginePacking::LoadsTogether => ((pm * ibw + ibw * pn) as u64, 0),
819 EnginePacking::OperandWithStore => ((pm * ibw) as u64, (ibw * pn) as u64),
820 },
821 fill_tiles: (pm * ibw + ibw * pn) as u64,
822 drain_tiles: (osh * osw) as u64,
823 fp32_acc,
824 fidelity,
825 }
826}
827
828/// The plan a ledger row printed.
829#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
830pub struct PlanDesc {
831 pub streaming: bool,
832 pub fused: bool,
833 /// The site count the program ran on when it is not the whole mesh (the single-core example, a streamed
834 /// program with fewer items than sites).
835 pub sites: Option<usize>,
836 /// The deployment mesh `(x, y)` the program was placed onto, when the row records it (a vendor example runs
837 /// on the simulator's whole grid; an IR-lowered program prints `mesh=`).
838 pub mesh: Option<(usize, usize)>,
839 /// `(n_y, n_x)` blocks.
840 pub grid: (usize, usize),
841 /// `(per_site_m, per_site_n)`.
842 pub per_site: (usize, usize),
843 pub block: Option<BlockShape>,
844 /// The row ran the SPLIT movement packing: the second operand's loader shares an engine with the store,
845 /// so a block's output write overlaps the next block's reads. Recorded because it changes the per-engine
846 /// movement the pace depends on — a reconstruction that drops it cannot tell two measured plans apart.
847 pub split: bool,
848}
849
850/// One ttsim ledger row as a calibration point.
851#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
852pub struct CalibrationPoint {
853 pub id: String,
854 pub shape: (usize, usize, usize),
855 pub chip: String,
856 pub stack: String,
857 pub program: String,
858 pub strategy: String,
859 pub knobs: String,
860 pub fidelity: Fidelity,
861 pub fp32: bool,
862 pub l1acc: bool,
863 /// fp32 packing ⇔ the program kept its partials exact.
864 pub exact: bool,
865 pub plan: PlanDesc,
866 pub cycles: u64,
867 /// What the machine actually did, when the row was measured on the instrumented simulator.
868 ///
869 /// ★ THIS, NOT `cycles`, IS WHAT RANKS TWO MEASURED PLANS. `cycles` is ttsim's bracket counter and
870 /// `ttsim_instrument_audit.json` shows it is not a function of the program — eight times the
871 /// reduction depth moves it 0.08%. A row carries it for provenance; nothing may order plans by it.
872 pub work: Option<MeasuredWork>,
873 pub pcc: f64,
874 pub pcc_floor: f64,
875 pub reproduce: String,
876 pub provenance: String,
877}
878
879/// The work meter recorded beside a calibration point: what the machine did, in counts it can report.
880#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
881pub struct MeasuredWork {
882 pub matmul_retired: u64,
883 pub tensix_inst: u64,
884 pub noc_txns: u64,
885 pub noc_bytes: u64,
886}
887
888fn parse_pair(s: &str) -> Option<(usize, usize)> {
889 let (a, b) = s.split_once('x')?;
890 Some((a.trim().parse().ok()?, b.trim().parse().ok()?))
891}
892
893fn fidelity_of(s: &str) -> Option<Fidelity> {
894 match s {
895 "LoFi" => Some(Fidelity::LoFi),
896 "HiFi2" => Some(Fidelity::HiFi2),
897 "HiFi3" => Some(Fidelity::HiFi3),
898 "HiFi4" => Some(Fidelity::HiFi4),
899 _ => None,
900 }
901}
902
903/// The blocking the tt-metal `matmul_multicore_reuse[_mcast]` programming examples choose for a shape: a
904/// transcription of `bmm_op_utils::get_large_matmul_params` (the examples' shared `bmm_op.hpp` helper under
905/// tt_metal/programming_examples/matmul) at the examples' fixed `in0_block_w = 2`, so those ledger rows (whose host
906/// prints no config line) become fully specified calibration points. Returns (per_core_M, per_core_N,
907/// out_subblock_h, out_subblock_w); `None` where the vendor returns all zeros.
908pub fn vendor_example_blocking(
909 mt: usize,
910 nt: usize,
911 num_cores_y: usize,
912 num_cores_x: usize,
913) -> Option<(usize, usize, usize, usize)> {
914 const IN0_BLOCK_W: usize = 2;
915 const SUBBLOCK_HW_CHOICES: [(usize, usize); 20] = [
916 (4, 2),
917 (2, 4),
918 (8, 1),
919 (1, 8),
920 (7, 1),
921 (1, 7),
922 (3, 2),
923 (2, 3),
924 (6, 1),
925 (1, 6),
926 (5, 1),
927 (1, 5),
928 (2, 2),
929 (4, 1),
930 (1, 4),
931 (3, 1),
932 (1, 3),
933 (2, 1),
934 (1, 2),
935 (1, 1),
936 ];
937 fn prime_factors(mut n: usize) -> Vec<usize> {
938 let mut f = Vec::new();
939 let mut d = 2;
940 while d * d <= n {
941 while n % d == 0 {
942 f.push(d);
943 n /= d;
944 }
945 d += 1;
946 }
947 if n > 1 {
948 f.push(n);
949 }
950 f
951 }
952 fn possible_products(factors: &[usize]) -> Vec<usize> {
953 let mut products = vec![1usize];
954 for &f in factors {
955 let mut next = products.clone();
956 for p in &products {
957 next.push(p * f);
958 }
959 products = next;
960 }
961 products.sort();
962 products.dedup();
963 products
964 }
965 fn maximum_block_dim(block_dim: usize) -> usize {
966 let other = 400i64 - 2 * IN0_BLOCK_W as i64 * block_dim as i64;
967 let other = other / (2 * IN0_BLOCK_W as i64 + block_dim as i64);
968 other.max(0) as usize
969 }
970 let pick_sub = |mpc: usize, npc: usize| {
971 SUBBLOCK_HW_CHOICES
972 .iter()
973 .find(|(h, w)| mpc % h == 0 && npc % w == 0)
974 .map(|&(h, w)| (mpc, npc, h, w))
975 };
976 let mut nt_fac = prime_factors(nt);
977 let mut mt_fac = prime_factors(mt);
978 let mut npc_min = 1;
979 let mut mpc_min = 1;
980 nt_fac.retain(|&e| {
981 if e > num_cores_x {
982 npc_min *= e;
983 false
984 } else {
985 true
986 }
987 });
988 mt_fac.retain(|&e| {
989 if e > num_cores_y {
990 mpc_min *= e;
991 false
992 } else {
993 true
994 }
995 });
996 if npc_min > maximum_block_dim(mpc_min) {
997 return None;
998 }
999 if mpc_min > 1 {
1000 let mut npc = npc_min;
1001 let npc_max = maximum_block_dim(mpc_min);
1002 for ele in possible_products(&nt_fac) {
1003 if ele * npc_min <= npc_max {
1004 npc = ele * npc_min;
1005 } else {
1006 break;
1007 }
1008 }
1009 if mt / mpc_min > num_cores_y || nt / npc > num_cores_x {
1010 return None;
1011 }
1012 return pick_sub(mpc_min, npc);
1013 }
1014 if npc_min > 1 {
1015 let mut mpc = mpc_min;
1016 let mpc_max = maximum_block_dim(npc_min);
1017 for ele in possible_products(&mt_fac) {
1018 if ele * mpc_min <= mpc_max {
1019 mpc = ele * mpc_min;
1020 } else {
1021 break;
1022 }
1023 }
1024 if mt / mpc > num_cores_y || nt / npc_min > num_cores_x {
1025 return None;
1026 }
1027 return pick_sub(mpc, npc_min);
1028 }
1029 let mpc_choices = possible_products(&mt_fac);
1030 for npc in possible_products(&nt_fac) {
1031 let mpc_max = maximum_block_dim(npc);
1032 let mut mpc = 1;
1033 for &ele in &mpc_choices {
1034 if ele <= mpc_max {
1035 mpc = ele;
1036 }
1037 }
1038 if mt / mpc > num_cores_y || nt / npc > num_cores_x {
1039 continue;
1040 }
1041 if let Some(found) = pick_sub(mpc, npc) {
1042 return Some(found);
1043 }
1044 }
1045 None
1046}
1047
1048/// Parse the ledger's CURRENT rows into calibration points. A row whose printed plan the model cannot
1049/// quantify (a vendor example with an unknown blocking, a ttnn op) is kept with `block: None` and excluded
1050/// from the fit by [`quantities_for_point`].
1051pub fn load_calibration_points(json: &str) -> Result<Vec<CalibrationPoint>, String> {
1052 let v: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
1053 let rows = v
1054 .get("rows")
1055 .and_then(|r| r.as_array())
1056 .ok_or("ledger has no rows array")?;
1057 let ledger_provenance = v
1058 .get("provenance")
1059 .and_then(|s| s.as_str())
1060 .unwrap_or("")
1061 .to_string();
1062 let mut out = Vec::new();
1063 for r in rows {
1064 if r.get("current").and_then(|c| c.as_bool()) == Some(false) {
1065 continue;
1066 }
1067 let shape = r.get("shape").ok_or("row without shape")?;
1068 let dim = |k: &str| {
1069 shape
1070 .get(k)
1071 .and_then(|d| d.as_u64())
1072 .map(|d| d as usize)
1073 .ok_or(format!("shape without {k}"))
1074 };
1075 let shape = (dim("m")?, dim("n")?, dim("k")?);
1076 let knobs = r.get("knobs").ok_or("row without knobs")?;
1077 let fid = knobs
1078 .get("fidelity")
1079 .and_then(|f| f.as_str())
1080 .and_then(fidelity_of)
1081 .ok_or("row without fidelity")?;
1082 let fp32 = knobs
1083 .get("fp32_dest_acc")
1084 .and_then(|b| b.as_bool())
1085 .unwrap_or(false);
1086 let l1acc = knobs
1087 .get("packer_l1_acc")
1088 .and_then(|b| b.as_bool())
1089 .unwrap_or(false);
1090 let knobs_str = format!(
1091 "{}-fp32_{}-l1acc_{}",
1092 knobs.get("fidelity").and_then(|f| f.as_str()).unwrap_or(""),
1093 fp32 as u8,
1094 l1acc as u8
1095 );
1096 let stack = r
1097 .get("stack")
1098 .and_then(|s| s.as_str())
1099 .unwrap_or("")
1100 .to_string();
1101 let program = r
1102 .get("program")
1103 .and_then(|s| s.as_str())
1104 .unwrap_or("")
1105 .to_string();
1106 let strategy = r
1107 .get("strategy")
1108 .and_then(|s| s.as_str())
1109 .unwrap_or("")
1110 .to_string();
1111 let cfg = r.get("cfg").cloned().unwrap_or(serde_json::Value::Null);
1112 let lower = strategy.to_lowercase();
1113 let streaming = lower.contains("simple")
1114 || lower.contains("stream")
1115 || program == "matmul_multi_core"
1116 || program == "matmul_single_core";
1117 let fused = lower.contains("mcast")
1118 || lower.contains("multicast")
1119 || program == "matmul_multicore_reuse_mcast";
1120 let split = lower.contains("split");
1121 let chip_str = r.get("chip").and_then(|s| s.as_str()).unwrap_or("");
1122 let sim = super::target::sim_grid(chip_str);
1123 let (grid, per_site, block) = if cfg.is_object() {
1124 let g = cfg
1125 .get("grid")
1126 .and_then(|s| s.as_str())
1127 .and_then(parse_pair)
1128 .map(|(x, y)| (y, x))
1129 .unwrap_or((0, 0));
1130 let pc = cfg
1131 .get("per_core")
1132 .and_then(|s| s.as_str())
1133 .and_then(parse_pair)
1134 .unwrap_or((0, 0));
1135 let sb = cfg
1136 .get("subblock")
1137 .and_then(|s| s.as_str())
1138 .and_then(parse_pair);
1139 let ibw = cfg
1140 .get("in0_block_w")
1141 .and_then(|n| n.as_u64())
1142 .map(|n| n as usize);
1143 let block = match (sb, ibw) {
1144 (Some((osh, osw)), Some(ibw)) => Some(BlockShape { osh, osw, ibw }),
1145 _ => None,
1146 };
1147 (g, pc, block)
1148 } else if streaming {
1149 (
1150 (shape.0.div_ceil(32), shape.1.div_ceil(32)),
1151 (1, 1),
1152 Some(BlockShape {
1153 osh: 1,
1154 osw: 1,
1155 ibw: 1,
1156 }),
1157 )
1158 } else if program == "matmul_multicore_reuse" || program == "matmul_multicore_reuse_mcast" {
1159 let (mt, nt) = (shape.0.div_ceil(32), shape.1.div_ceil(32));
1160 let (cy, cx) = sim.map(|m| (m.y, m.x)).unwrap_or((8, 8));
1161 match vendor_example_blocking(mt, nt, cy, cx) {
1162 Some((pm, pn, osh, osw)) => (
1163 (mt / pm, nt / pn),
1164 (pm, pn),
1165 Some(BlockShape { osh, osw, ibw: 2 }),
1166 ),
1167 None => ((0, 0), (0, 0), None),
1168 }
1169 } else {
1170 ((0, 0), (0, 0), None)
1171 };
1172 let cfg_mesh = cfg
1173 .get("mesh")
1174 .and_then(|s| s.as_str())
1175 .and_then(parse_pair);
1176 let cfg_sites = cfg
1177 .get("sites")
1178 .and_then(|n| n.as_u64())
1179 .map(|n| n as usize);
1180 let sites = if program == "matmul_single_core" {
1181 Some(1)
1182 } else {
1183 cfg_sites
1184 };
1185 let mesh = cfg_mesh.or_else(|| {
1186 if program == "logos_spatial" {
1187 None
1188 } else {
1189 sim.map(|m| (m.x, m.y))
1190 }
1191 });
1192 let plan = PlanDesc {
1193 streaming,
1194 fused,
1195 sites,
1196 mesh,
1197 grid,
1198 per_site,
1199 block,
1200 split,
1201 };
1202 out.push(CalibrationPoint {
1203 id: r
1204 .get("id")
1205 .and_then(|s| s.as_str())
1206 .unwrap_or("")
1207 .to_string(),
1208 shape,
1209 chip: r
1210 .get("chip")
1211 .and_then(|s| s.as_str())
1212 .unwrap_or("")
1213 .to_string(),
1214 stack,
1215 program,
1216 strategy,
1217 knobs: knobs_str,
1218 fidelity: fid,
1219 fp32,
1220 l1acc,
1221 exact: fp32,
1222 plan,
1223 cycles: r.get("cycles").and_then(|c| c.as_u64()).unwrap_or(0),
1224 work: r.get("work").and_then(|w| {
1225 let g = |k: &str| w.get(k).and_then(|v| v.as_u64());
1226 Some(MeasuredWork {
1227 matmul_retired: g("matmul_retired")?,
1228 tensix_inst: g("tensix_inst")?,
1229 noc_txns: g("noc_txns")?,
1230 noc_bytes: g("noc_bytes")?,
1231 })
1232 }),
1233 pcc: r.get("pcc").and_then(|c| c.as_f64()).unwrap_or(0.0),
1234 pcc_floor: r.get("pcc_floor").and_then(|c| c.as_f64()).unwrap_or(1.0),
1235 reproduce: r
1236 .get("reproduce")
1237 .and_then(|s| s.as_str())
1238 .unwrap_or("")
1239 .to_string(),
1240 provenance: r
1241 .get("provenance")
1242 .and_then(|s| s.as_str())
1243 .map(str::to_string)
1244 .unwrap_or_else(|| ledger_provenance.clone()),
1245 });
1246 }
1247 Ok(out)
1248}
1249
1250/// The quantities of a recorded point on `target`, when its plan is fully specified AND its knobs are ones
1251/// the compiler emits on this target (the target's declared fidelity; fp32 accumulate on or off). A row at a
1252/// fidelity the pipeline never chooses is a comparison, not a calibration point.
1253pub fn quantities_for_point(
1254 p: &CalibrationPoint,
1255 target: &SpatialTarget,
1256) -> Option<PlanQuantities> {
1257 if p.stack == "ttnn_production" || target.matmul_fidelity() != Some(p.fidelity) {
1258 return None;
1259 }
1260 // The row's deployment grid is the mesh it records; a smaller recorded SITE COUNT means the program used
1261 // only part of that mesh (the single-core example), and that is what sets its parallelism.
1262 let mesh = p
1263 .plan
1264 .mesh
1265 .map(|(x, y)| MeshShape { x, y })
1266 .unwrap_or(target.sites);
1267 let restricted;
1268 let target = match p.plan.sites {
1269 Some(n) if n < mesh.sites() => {
1270 restricted = SpatialTarget {
1271 sites: MeshShape {
1272 x: n.min(mesh.x),
1273 y: n.div_ceil(n.min(mesh.x)),
1274 },
1275 ..target.clone()
1276 };
1277 &restricted
1278 }
1279 _ if mesh != target.sites => {
1280 restricted = SpatialTarget {
1281 sites: mesh,
1282 ..target.clone()
1283 };
1284 &restricted
1285 }
1286 _ => target,
1287 };
1288 let (mt, nt, kt) = (
1289 p.shape.0.div_ceil(target.tile.rows),
1290 p.shape.1.div_ceil(target.tile.cols),
1291 p.shape.2.div_ceil(target.tile.cols),
1292 );
1293 let acc = AccPolicy::F32;
1294 let plan = if p.plan.streaming {
1295 streaming_plan(mt, nt, kt, acc)
1296 } else {
1297 let block = p.plan.block?;
1298 let (pm, pn) = p.plan.per_site;
1299 if pm == 0
1300 || pn == 0
1301 || block.ibw == 0
1302 || mt % pm != 0
1303 || nt % pn != 0
1304 || kt % block.ibw != 0
1305 {
1306 return None;
1307 }
1308 // The knobs the row RAN, not defaults: packing and in-place accumulate are exactly what separates
1309 // two rows of the same shape and knobs, so reconstructing them as defaults asks the model to rank
1310 // plans it has been made blind to.
1311 let engines = if p.plan.split {
1312 EnginePacking::OperandWithStore
1313 } else {
1314 EnginePacking::LoadsTogether
1315 };
1316 BlockPlan {
1317 per_site_m: pm,
1318 per_site_n: pn,
1319 grid: (mt / pm, nt / pn),
1320 inner: InnerLoop {
1321 num_blocks: kt / block.ibw,
1322 block_k_tiles: block.ibw,
1323 },
1324 block,
1325 acc,
1326 pack_accumulate: p.l1acc,
1327 engines,
1328 granularity: Granularity::PerTile,
1329 }
1330 };
1331 let mut q = quantities_for_gemm(mt, nt, kt, &plan, p.plan.fused, p.fidelity, target);
1332 q.fp32_acc = p.fp32;
1333 Some(q)
1334}
1335
1336#[cfg(test)]
1337#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/spatial/cost/vendor_blocking_tests.rs"]
1338mod vendor_blocking_tests;
1339
1340/// How many DRAM tile-reads an arrangement costs, given the output tile grid and the reduction depth.
1341///
1342/// ★ THE ARRANGEMENT IS AN INPUT, NOT A NAMED PLAN. The retired version took an enum of three
1343/// human-chosen plans, and `spatial_cost_model_lock::no_strategy_enum_input_exists` forbids that
1344/// vocabulary anywhere under `spatial/` — including in a comment like this one, which is why this
1345/// paragraph describes it rather than naming it. (It caught the first draft of this very comment.)
1346///
1347/// What actually determines the traffic is how operands are SHARED: no sharing re-streams both
1348/// operands per output tile; per-core-block reuse reads each once per core column and row; a multicast
1349/// fetches each tile exactly once. Those are properties of the placement the passes CHOSE, so they
1350/// arrive here as parameters and the cost model reads them off the plan.
1351///
1352/// Salvaged from the retired emitter; `codegen_tt`
1353/// keeps the public NAME as a thin wrapper so callers are unmoved.
1354pub fn dram_tiles(mt: u64, nt: u64, kt: u64, share: OperandSharing) -> u64 {
1355 match share {
1356 // Every one of mt·nt output tiles re-streams kt A-tiles and kt B-tiles.
1357 OperandSharing::None => 2 * mt * nt * kt,
1358 // A is read once per core-column, B once per core-row.
1359 OperandSharing::PerCoreBlock { grid_x, grid_y } => grid_x * mt * kt + grid_y * nt * kt,
1360 // Each tile is fetched once and multicast to everyone who needs it.
1361 OperandSharing::Multicast => (mt + nt) * kt,
1362 }
1363}
1364
1365/// How an arrangement shares its operands across the mesh — the thing that decides DRAM traffic.
1366#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1367pub enum OperandSharing {
1368 None,
1369 PerCoreBlock { grid_x: u64, grid_y: u64 },
1370 Multicast,
1371}