Skip to main content

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, &regularized, &[])
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}