Skip to main content

logicaffeine_compile/spatial/
work.rs

1//! THE WORK MODEL — what a program makes the machine do, counted from the program itself.
2//!
3//! ★ THIS IS NOT A FITTED MODEL, AND THAT IS THE POINT. Every number here is a COUNT of things the
4//! choreography does — tiles read, transactions initiated, matmuls retired — walked off the same
5//! `Schedule` the backends print from. There is no regression, no free parameter, and no way for the
6//! model to describe a program the compiler does not emit, because it reads the emitted structure.
7//!
8//! It exists because the alternative was measured and found to be unusable. `ttsim_instrument_audit.json`
9//! sweeps the reduction depth 8x and watches every clock on the simulator stand still: the bracket counter
10//! moves 0.08%, the device's own per-core cycle register 0.3%. A cost model fitted to either is fitted to
11//! noise, and the consequence was not theoretical — at 512^3 it ranked the plan issuing 2.9x the
12//! instructions and 4.1x the NOC transactions FIRST, because that plan happened to score a lower clock.
13//!
14//! What the simulator can report is what the machine did, and the instrumented build does: Tensix
15//! instructions issued, NOC transactions, NOC bytes, and matmuls RETIRED. Over that same 8x sweep those
16//! move 8.11x / 7.40x / 7.59x / 8.000x. So this module PREDICTS them and
17//! `spatial_work_model_lock` checks the prediction against the machine. A disagreement is a bug in the
18//! compiler's model of itself — never a constant to be re-fitted away.
19//!
20//! ONE law is closed-form and exact, and it anchors everything else:
21//!
22//!   matmul_retired = mt * nt * kt * MVMUL_PER_TILE_MATMUL
23//!
24//! Two programs computing the same GEMM retire the same count whoever emitted them, so with it holding a
25//! difference in instructions or bytes is a difference in HOW the same computation was carried out. That
26//! is what makes an efficiency claim checkable rather than rhetorical.
27
28use serde::{Deserialize, Serialize};
29
30use super::ir::{
31    walk_tagged_counted, BufferId, BufferKind, ChoreoOp, ComputeOp, SpatialProgram, StageKind,
32};
33use super::target::SpatialTarget;
34use crate::codegen_sva::fabric_target::Precision;
35
36/// MVMUL issues one 32x32 tile-matmul retires PER FIDELITY PASS. The matrix unit walks the tile in
37/// 16 steps, and math fidelity is literally how many times it walks it: LoFi takes the operands' top
38/// mantissa bits in one pass, HiFi4 makes four. So the fidelity knob is not a fitted scale factor on
39/// the compute term — it is an exact integer multiplier on the work, and the machine says so.
40///
41/// MEASURED at 640^3, same program, three fidelities: LoFi 128,000 / HiFi2 256,000 / HiFi4 512,000
42/// retired, against 8,000 tile-matmuls. 16, 32 and 64 per tile-matmul exactly.
43pub const MVMUL_PER_TILE_MATMUL_PER_PASS: u64 = 16;
44
45/// Passes the matrix unit makes over a tile at each math fidelity.
46pub fn fidelity_passes(f: super::target::Fidelity) -> u64 {
47    match f {
48        super::target::Fidelity::LoFi => 1,
49        super::target::Fidelity::HiFi2 => 2,
50        super::target::Fidelity::HiFi3 => 3,
51        super::target::Fidelity::HiFi4 => 4,
52        // A target whose tile matmul is exact does not decompose its operands at all: one pass, full
53        // precision. That is the integer/fabric engines, not a Tensix fidelity setting.
54        super::target::Fidelity::Exact => 1,
55    }
56}
57
58/// MVMULs one tile-matmul retires at a fidelity.
59pub fn mvmul_per_tile_matmul(f: super::target::Fidelity) -> u64 {
60    MVMUL_PER_TILE_MATMUL_PER_PASS * fidelity_passes(f)
61}
62
63/// What a program costs the machine, in the units the machine can actually report.
64///
65/// Two views, because they are two different questions and a compiler that owns a Pareto frontier has
66/// to keep them apart:
67///
68///   * the DEVICE TOTALS are what the whole machine spends — the energy question, and the only view the
69///     work meter measures, so the only one that can be validated here;
70///   * the BUSIEST SITE is what one site spends — the balance question, and what a latency model on
71///     silicon would be built from. It is PREDICTED STRUCTURE and is deliberately not called a time:
72///     ttsim has no clock that measures the program, so nothing here may be quoted as speed.
73///
74/// Minimizing the totals alone would collapse a small problem onto ONE site — that genuinely does move
75/// the fewest bytes — which is why the balance term exists and why the objective is stated rather than
76/// assumed.
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub struct Work {
79    /// MVMULs that retire. Closed-form and exact; the conservation law.
80    pub matmul_retired: u64,
81    /// NOC transactions initiated. One per tile on an interleaved buffer, one per multicast.
82    pub noc_txns: u64,
83    /// Bytes moved over the NOC, DRAM traffic, multicast delivery and four-byte handshakes alike. This
84    /// is what the work meter counts, so this is the one that gets validated against the machine.
85    pub noc_bytes: u64,
86    /// The DATA subset of `noc_bytes`: tiles, with the four-byte control writes left out.
87    ///
88    /// ★ THE OBJECTIVE RANKS ON THIS, NOT ON `noc_bytes`, and the reason is a pathology it caused. A
89    /// semaphore atomic moves four bytes and costs a transaction SLOT; putting those four bytes into a
90    /// bandwidth term let a plan win on bandwidth by eight bytes while losing on transactions by 2,160.
91    /// Bandwidth and request-rate are different resources and the model keeps them apart.
92    pub payload_bytes: u64,
93    /// Tensix instructions issued across the device.
94    pub tensix_inst: u64,
95    /// Matrix-engine API invocations the choreography issues across the device.
96    ///
97    /// ★ THE ISSUE-COST TERM, AND IT IS A COUNT RATHER THAN A FIT. Two plans can move exactly the same
98    /// bytes in exactly the same transactions and still differ enormously in what the compute engine is
99    /// asked to do, because the SUB-BLOCK decides how much arithmetic one call covers: at
100    /// 4096x4096x256 a 1x8 sub-block issues 16,384 `matmul_block` calls and a 1x1 sub-block issues
101    /// 131,072 for identical movement — measured 1,781,904 Tensix instructions against 6,383,760, a
102    /// 3.58x difference the movement axes cannot see at all.
103    ///
104    /// Predicting the instruction count itself would need per-call constants fitted to something; the
105    /// CALL COUNT needs nothing — it is read off the same choreography as everything else, and at equal
106    /// movement and equal retired arithmetic, fewer calls is unambiguously less work.
107    pub compute_calls: u64,
108    /// Operand tiles the matrix engines load across the device, each engine by its own rule
109    /// ([`SpatialTarget::operand_loads`]). At equal calls the sub-block's SHAPE decides it: the fabric's array
110    /// is fed a square sub-block's operands in fewer elements than a long one of the same area, and Tensix's
111    /// unpacker, which holds one operand and re-loads the other for every held tile, the reverse.
112    pub engine_operand_tiles: u64,
113    /// Sites the program actually placed work on.
114    pub sites_used: u64,
115    /// Data bytes crossing the BUSIEST site's own network interface.
116    pub site_noc_bytes: u64,
117    /// Data bytes crossing the BUSIEST MOVEMENT ENGINE of any site.
118    ///
119    /// ★ A TENSIX SITE HAS TWO MOVEMENT ENGINES AND THEY RUN CONCURRENTLY, so what paces a site is not what
120    /// it moves but what its busier engine moves. This is the term that tells apart two plans which fetch
121    /// exactly the same tiles and differ only in which engine carries which stream — issuing both
122    /// operand reads together leaves the store an engine to itself and serialises the reads; pairing the
123    /// second operand with the store overlaps a block's write with the next block's reads. Without it
124    /// the two are indistinguishable, and the compiler picked between them by coin. A site with one
125    /// engine (`SiteSpec::movement_engines`) carries every stream on it.
126    pub engine_noc_bytes: u64,
127    /// NOC transactions the busiest site initiates.
128    pub site_noc_txns: u64,
129    /// MVMULs the busiest site retires.
130    pub site_matmul_retired: u64,
131    /// The DRAM subset of `noc_bytes`: operand reads and output writes of the tensors off the chip's SRAM tier,
132    /// excluding L1-to-L1 multicast — what leaves the chip. A tensor the program holds in SRAM
133    /// ([`super::residency`]) moves on chip, and is counted in `onchip_bytes` instead.
134    ///
135    /// ★ THIS IS THE SHARED RESOURCE, and on a GEMM it is the one that binds. Wormhole has SIX DRAM
136    /// channels against sixty-four sites, so a plan whose sites each fetch their own operands puts an
137    /// order of magnitude more pressure on DRAM than one that fetches each tile once and multicasts it —
138    /// even though both deliver the same bytes to the same sites. A model that looked only at per-site
139    /// traffic would call those two plans equal, and they are not.
140    pub dram_bytes: u64,
141    /// The tiles behind `dram_bytes`, whatever each tensor's width: what a lane that carries one element per
142    /// cycle is paced by, where a bus is paced by the bytes.
143    pub dram_tiles: u64,
144    /// Tile bytes of the tensors the program holds in its SRAM tier — intermediates and carried state it keeps on
145    /// chip, read and written across the crossbar without leaving it.
146    pub onchip_bytes: u64,
147}
148
149impl Work {
150    /// The per-axis ratio of `self` against `other`, for reporting an efficiency comparison. Only
151    /// meaningful where `matmul_retired` agrees — otherwise the two did not compute the same thing.
152    pub fn comparable_with(&self, other: &Work) -> bool {
153        self.matmul_retired == other.matmul_retired
154    }
155}
156
157/// Tiles one page of a buffer holds.
158fn page_tiles(p: &SpatialProgram, b: BufferId) -> u64 {
159    p.buffers
160        .iter()
161        .find(|x| x.id == b)
162        .map(|x| match x.kind {
163            BufferKind::Fifo { page_tiles, .. } | BufferKind::Interm { page_tiles, .. } => {
164                page_tiles as u64
165            }
166            BufferKind::Accumulator { tiles } => tiles as u64,
167        })
168        .unwrap_or(1)
169}
170
171fn tile_bytes_of(p: &SpatialProgram, t: &SpatialTarget, b: BufferId) -> u64 {
172    p.buffers
173        .iter()
174        .find(|x| x.id == b)
175        .map(|x| t.tile_bytes(x.dtype) as u64)
176        .unwrap_or(0)
177}
178
179/// What the program makes the machine do. Walks the scheduled choreography — the same structure the
180/// backends print — so the model cannot drift from the code that runs.
181///
182/// A program without a schedule has not been through `synthesize_handshakes` and has no movement to
183/// count; the compute law still holds, so that is what comes back.
184///
185/// A loop run to a trip counts its trip's greatest count: the work is the program's bound. [`work_at`] is the work
186/// of one run.
187pub fn work_of(p: &SpatialProgram, t: &SpatialTarget) -> Work {
188    work_counted(p, t, &|c| c.bound())
189}
190
191/// The work of the run in which each trip `t` counts `counts[t]` — a decode step's at its position.
192pub fn work_at(p: &SpatialProgram, t: &SpatialTarget, counts: &[usize]) -> Work {
193    work_counted(p, t, &|c| c.at(counts))
194}
195
196fn work_counted(
197    p: &SpatialProgram,
198    t: &SpatialTarget,
199    count: &dyn Fn(super::ir::LoopCount) -> usize,
200) -> Work {
201    let mut w = Work::default();
202    let per_tile = mvmul_per_tile_matmul(
203        t.matmul_fidelity()
204            .unwrap_or(super::target::Fidelity::HiFi4),
205    );
206    let Some(sched) = p.schedule.as_ref() else {
207        return w;
208    };
209    let mut per_site: std::collections::BTreeMap<super::ir::Site, Work> = Default::default();
210    let mut per_engine: std::collections::BTreeMap<(super::ir::Site, super::ir::Engine), u64> =
211        Default::default();
212    for prog in &sched.programs {
213        let before = per_site
214            .get(&prog.site)
215            .map(|w: &Work| w.payload_bytes)
216            .unwrap_or(0);
217        let sw = per_site.entry(prog.site).or_default();
218        walk_tagged_counted(&prog.ops, 1, count, &mut |tag, mult| match &tag.op {
219            // A tile read from an interleaved DRAM buffer is ONE transaction per tile: the pages a
220            // block spans are strided across banks, so there is nothing contiguous to coalesce. That
221            // is why `noc_txns` tracks tiles moved so exactly, and it is the term a sharded layout
222            // would attack.
223            // A transfer moves its tensor's stored bytes; the reader converts them on arrival to its buffer's
224            // format, and the writer's packer converts before they leave.
225            ChoreoOp::ReadTiles {
226                tensor,
227                block_h,
228                block_w,
229                ..
230            } => {
231                let tiles = mult * (*block_h as u64) * (*block_w as u64);
232                let bytes = tiles * t.tile_bytes(p.tensor(*tensor).dtype) as u64;
233                sw.noc_txns += tiles;
234                sw.noc_bytes += bytes;
235                sw.payload_bytes += bytes;
236                if p.tensor(*tensor).space == super::ir::TensorSpace::Sram {
237                    sw.onchip_bytes += bytes;
238                } else {
239                    sw.dram_bytes += bytes;
240                    sw.dram_tiles += tiles;
241                }
242            }
243            ChoreoOp::WriteTiles {
244                tensor,
245                block_h,
246                block_w,
247                ..
248            } => {
249                let tiles = mult * (*block_h as u64) * (*block_w as u64);
250                let bytes = tiles * t.tile_bytes(p.tensor(*tensor).dtype) as u64;
251                sw.noc_txns += tiles;
252                sw.noc_bytes += bytes;
253                sw.payload_bytes += bytes;
254                if p.tensor(*tensor).space == super::ir::TensorSpace::Sram {
255                    sw.onchip_bytes += bytes;
256                } else {
257                    sw.dram_bytes += bytes;
258                    sw.dram_tiles += tiles;
259                }
260            }
261            // ★ A MULTICAST IS ONE COMMAND AND IT IS COUNTED ONCE. The payload is issued once at the
262            // sender's network interface and the fabric replicates it down the reserved path, so what
263            // crosses the NOC is the payload — not the payload times the rectangle. That is exactly what
264            // the hardware buys, and exactly why a multicast plan moves less than a blocked one: a
265            // blocked plan makes every site fetch its own copy from DRAM.
266            //
267            // Predicting this and being wrong is what found the SIMULATOR's atomic over-count. The
268            // residual was one semaphore atomic per receiver per K block, at 4,222 bytes each, because
269            // the instrument was reading an atomic's instruction word as a transfer length.
270            ChoreoOp::McastSend { buf, pages, .. } => {
271                let per_send = (*pages as u64) * page_tiles(p, *buf) * tile_bytes_of(p, t, *buf);
272                sw.noc_txns += mult * commands_for(per_send);
273                sw.noc_bytes += mult * per_send;
274                sw.payload_bytes += mult * per_send;
275            }
276            // A semaphore increment is a 32-bit read-modify-write: one command, four bytes.
277            ChoreoOp::SemInc { by, .. } => {
278                sw.noc_txns += mult * (*by as u64).max(1);
279                sw.noc_bytes += mult * 4 * (*by as u64).max(1);
280            }
281            // The valid flag rides the same reserved path as the data it follows: one word, once.
282            ChoreoOp::McastSetValid { .. } => {
283                sw.noc_txns += mult;
284                sw.noc_bytes += mult * 4;
285            }
286            ChoreoOp::Compute { op, .. } => {
287                sw.matmul_retired += mult * retired_of(op, per_tile);
288                sw.compute_calls += mult * calls_of(op, t);
289                if let super::ir::ComputeOp::MatmulTiles {
290                    block, granularity, ..
291                } = op
292                {
293                    sw.engine_operand_tiles += mult * t.operand_loads(*block, *granularity);
294                }
295            }
296            _ => {}
297        });
298        *per_engine.entry((prog.site, prog.engine)).or_default() +=
299            per_site[&prog.site].payload_bytes - before;
300    }
301    // The device totals are the sum; the balance terms are the worst site, because a plan is only as
302    // well spread as its busiest participant.
303    for (_, s) in &per_site {
304        w.matmul_retired += s.matmul_retired;
305        w.noc_txns += s.noc_txns;
306        w.noc_bytes += s.noc_bytes;
307        w.dram_bytes += s.dram_bytes;
308        w.dram_tiles += s.dram_tiles;
309        w.onchip_bytes += s.onchip_bytes;
310        w.payload_bytes += s.payload_bytes;
311        w.compute_calls += s.compute_calls;
312        w.engine_operand_tiles += s.engine_operand_tiles;
313        w.site_noc_bytes = w.site_noc_bytes.max(s.payload_bytes);
314        w.site_noc_txns = w.site_noc_txns.max(s.noc_txns);
315        w.site_matmul_retired = w.site_matmul_retired.max(s.matmul_retired);
316    }
317    w.sites_used = per_site.len() as u64;
318    w.engine_noc_bytes = per_engine.values().copied().max().unwrap_or(0);
319    w.noc_txns += HOST_TXNS_PER_SITE * w.sites_used;
320    w
321}
322
323/// NOC transactions the HOST issues per participating site, outside the kernel choreography: the program
324/// dispatch handshake and the read-back of that site's cycle buffer. The choreography cannot contain them
325/// — they are the runtime's, not the kernel's — but the meter counts them, so a model that predicts what
326/// the meter will say has to.
327///
328/// It is TWO, measured exactly and at every mesh the ledger holds: 16 sites 144 -> 176, 25 sites
329/// 4,400 -> 4,450, 64 sites 4,352 -> 4,480. Not a fitted constant — a count of two host operations.
330pub const HOST_TXNS_PER_SITE: u64 = 2;
331
332/// The largest payload one NOC command carries. A transfer longer than this is split by
333/// `ncrisc_noc_fast_write_any_len` into burst-sized commands, each of which the network interface
334/// issues — and counts — separately.
335///
336/// ★ THIS IS WHY A MULTICAST IS NOT ONE TRANSACTION. It is one COMMAND per burst, and a multicast page
337/// is usually far larger than a burst: a 4x10 tile page is 81,920 bytes, which is ten of them. Modelling
338/// a multicast as a single transaction under-counted the busiest plans by 10%, and the term was found by
339/// reading `ncrisc_noc_fast_write_any_len` rather than by fitting anything.
340///
341/// Wormhole: `NOC_MAX_BURST_WORDS (256) * NOC_WORD_BYTES (32)`, from tt-metal's
342/// `wormhole/noc/noc_parameters.h`. It belongs on the target descriptor the day a chip disagrees.
343pub const NOC_MAX_BURST_BYTES: u64 = 8192;
344
345/// NOC commands a transfer of `bytes` costs.
346pub fn commands_for(bytes: u64) -> u64 {
347    bytes.div_ceil(NOC_MAX_BURST_BYTES).max(1)
348}
349
350/// Matrix-engine invocations one compute op issues. The engine's block API decides how much arithmetic a single
351/// call covers ([`SpatialTarget::matmul_calls`]): `matmul_block` is handed a whole `osh x osw` sub-block per
352/// inner step, the fabric's array a sub-block over its whole inner block, `matmul_tiles` one tile at a time —
353/// same arithmetic, `osh * osw` times the calls.
354fn calls_of(op: &super::ir::ComputeOp, t: &SpatialTarget) -> u64 {
355    match op {
356        super::ir::ComputeOp::MatmulTiles {
357            block, granularity, ..
358        } => t.matmul_calls(*block, *granularity),
359        _ => 0,
360    }
361}
362
363/// MVMULs one compute op retires at a fidelity. Only the matrix engine retires them; the vector path
364/// does not touch the matrix unit at all, so it contributes nothing to this law.
365fn retired_of(op: &super::ir::ComputeOp, per_tile: u64) -> u64 {
366    match op {
367        // One invocation covers ONE sub-block over ONE inner block: `osh * osw` output tiles accumulating
368        // over `ibw` inner steps, whether the engine is handed it a sub-block at a time (`matmul_block`)
369        // or a tile at a time (`matmul_tiles`) — the granularity changes the call count, never the
370        // arithmetic. The surrounding choreography loops over the sub-blocks and the K blocks, and
371        // `walk_ops` carries those multiplicities in.
372        super::ir::ComputeOp::MatmulTiles { block, .. } => {
373            (block.osh as u64) * (block.osw as u64) * (block.ibw as u64) * per_tile
374        }
375        _ => 0,
376    }
377}
378
379/// ★ THE FLOOR THE WHOLE MODEL IS ANCHORED ON: the fewest MVMULs a GEMM of this shape CAN retire at
380/// this fidelity — the tile-matmuls the mathematics demands, times the passes the fidelity costs.
381///
382/// It is a floor and not an identity, and the difference is the point. A program that retires MORE is
383/// doing arithmetic the problem does not require, and that is a real and measurable inefficiency rather
384/// than a different way of being right. Measured at 640^3: every program this compiler emits, and every
385/// tt-metal example, retires exactly this floor — 8,000 tile-matmuls. The vendor's own default heuristic
386/// retires 8,820, because it rounds 20 tiles up to 21 in two of the three dimensions: **10.25% more
387/// arithmetic than the problem contains**, at every fidelity.
388///
389/// Tile padding IS part of the floor: a 33-row operand occupies two tile rows and the machine pays for
390/// both. What is not part of it is padding a program adds on top of that.
391pub fn gemm_retired_floor(
392    m: usize,
393    n: usize,
394    k: usize,
395    tile: super::target::TileShape,
396    f: super::target::Fidelity,
397) -> u64 {
398    (m.div_ceil(tile.rows) as u64)
399        * (n.div_ceil(tile.cols) as u64)
400        * (k.div_ceil(tile.cols) as u64)
401        * mvmul_per_tile_matmul(f)
402}
403
404/// The tile widths a GEMM's movement is priced at. A transfer moves its tensor's stored bytes, so each operand
405/// is read at its own tensor's width and the result written at the output's — an exact accumulator writes a
406/// wider result than the operands it reads (fp32 under bf16, int32 or int64 under int8) — and a multicast
407/// moves the sender's buffer, at that buffer's width.
408#[derive(Clone, Copy, Debug, PartialEq, Eq)]
409pub struct GemmWidths {
410    pub a: u64,
411    pub b: u64,
412    pub a_buffer: u64,
413    pub b_buffer: u64,
414    pub out: u64,
415}
416
417impl GemmWidths {
418    /// The widths of `p`'s GEMM on `t`: the tensors its matrix stage's operand buffers are loaded from (an
419    /// operand no stage loads moves at its buffer's width), those buffers, and the output. `None` for a
420    /// program with no two-operand tile matmul or no output.
421    pub fn of(p: &SpatialProgram, t: &SpatialTarget) -> Option<GemmWidths> {
422        let bytes = |d: Precision| t.tile_bytes(d) as u64;
423        let buffer = |b: BufferId| p.buffers.iter().find(|x| x.id == b).map(|x| x.dtype);
424        let loaded = |b: BufferId| {
425            p.stages.iter().find_map(|s| match &s.kind {
426                StageKind::Load { tensor, .. } if s.outs.contains(&b) => {
427                    Some(p.tensor(*tensor).dtype)
428                }
429                _ => None,
430            })
431        };
432        let out = bytes(p.output_tensor()?.dtype);
433        p.stages.iter().find_map(|s| match &s.kind {
434            StageKind::Compute(ComputeOp::MatmulTiles { .. }) if s.ins.len() == 2 => {
435                let (ab, bb) = (buffer(s.ins[0])?, buffer(s.ins[1])?);
436                Some(GemmWidths {
437                    a: bytes(loaded(s.ins[0]).unwrap_or(ab)),
438                    b: bytes(loaded(s.ins[1]).unwrap_or(bb)),
439                    a_buffer: bytes(ab),
440                    b_buffer: bytes(bb),
441                    out,
442                })
443            }
444            _ => None,
445        })
446    }
447}
448
449/// ★ THE SAME WORK, PRICED FROM A PLAN INSTEAD OF A BUILT PROGRAM.
450///
451/// [`work_of`] walks a scheduled choreography, which is exact but needs the whole pipeline to have run.
452/// A plan search has thousands of candidates — 4,209 at 512^3 — and cannot build them all, so it needs
453/// the same quantity as a closed form over the plan.
454///
455/// These are not two models. `spatial_work_model_lock::the_closed_form_is_the_built_choreography_on_*`
456/// requires this to equal [`work_of`] of the program [`build_candidate`](super::pipeline::build_candidate)
457/// emits, count for count, on every target, and [`work_of`] is in turn checked against the machine. The chain
458/// is closed form == choreography == simulator, each link tested, so the planner ranks by a number the
459/// hardware has already agreed to.
460///
461/// Everything it prices with is read off the GEMM template `p` and its target `t` — the tile extents, the
462/// widths ([`GemmWidths`]), the matrix engine's fidelity, the sites and the movement engines per site — so no
463/// caller can hand it a width or an engine count the emitter does not use. `None` when `p` has no GEMM.
464///
465/// The movement law, read off the choreography and confirmed at every shape in the ledger:
466///
467/// * A blocked plan makes every site fetch its own operands: `nx*mt*kt + ny*nt*kt` tiles read.
468/// * A multicast plan reads each shared operand tile from DRAM ONCE and multicasts it ONCE, so it pays
469///   for that tile TWICE on the NOC and only twice. That is the whole win: a blocked plan pays `nx` and
470///   `ny` times instead of two.
471/// * Every plan writes its output once: `mt*nt`.
472/// * A streaming plan re-reads both operands for every output tile: `2*mt*nt*kt`.
473pub fn work_of_plan(
474    p: &SpatialProgram,
475    plan: &super::ir::BlockPlan,
476    fused: bool,
477    t: &SpatialTarget,
478) -> Option<Work> {
479    let widths = GemmWidths::of(p, t)?;
480    let out = p.output_tensor()?;
481    let (mt, nt, kt) = (out.tiles.rows_t, out.tiles.cols_t, p.work.k_tiles);
482    let fidelity = match t.matmul_cap() {
483        Some(super::target::ComputeCap::MatmulTile { fidelity, .. }) => *fidelity,
484        _ => super::target::Fidelity::Exact,
485    };
486    let sites = t.sites.sites();
487    let two_engines = t.site.movement_engines >= 2;
488    let (mt64, nt64, kt64) = (mt as u64, nt as u64, kt as u64);
489    let per_tile = mvmul_per_tile_matmul(fidelity);
490    let retired = mt64 * nt64 * kt64 * per_tile;
491    if super::cost::is_streaming(plan) {
492        let items = (mt64 * nt64).div_ceil((sites as u64).max(1));
493        let tiles = 2 * mt64 * nt64 * kt64 + mt64 * nt64;
494        let bytes = mt64 * nt64 * (kt64 * (widths.a + widths.b) + widths.out);
495        let reads = items * kt64 * (widths.a + widths.b);
496        let store = items * widths.out;
497        return Some(Work {
498            matmul_retired: retired,
499            compute_calls: (mt64 * nt64 * kt64).max(1) / (plan.block.ibw as u64).max(1)
500                * t.matmul_calls(plan.block, plan.granularity),
501            engine_operand_tiles: (mt64 * nt64 * kt64).max(1) / (plan.block.ibw as u64).max(1)
502                * t.operand_loads(plan.block, plan.granularity),
503            noc_txns: tiles + HOST_TXNS_PER_SITE * (mt64 * nt64).min(sites as u64).max(1),
504            noc_bytes: bytes,
505            payload_bytes: bytes,
506            dram_bytes: bytes,
507            dram_tiles: tiles,
508            onchip_bytes: 0,
509            tensix_inst: 0,
510            sites_used: (mt64 * nt64).min(sites as u64).max(1),
511            site_noc_bytes: reads + store,
512            // A streaming site reads its two operands on one engine and stores on the other, so the
513            // reads pace it; a site with one engine carries both.
514            engine_noc_bytes: if two_engines {
515                reads.max(store)
516            } else {
517                reads + store
518            },
519            site_noc_txns: items * (2 * kt64 + 1),
520            site_matmul_retired: items * kt64 * per_tile,
521        });
522    }
523    let (ny, nx) = plan.grid;
524    let (ny64, nx64) = (ny as u64, nx as u64);
525    let (pm, pn) = (plan.per_site_m as u64, plan.per_site_n as u64);
526    let ibw = plan.block.ibw as u64;
527    let blocks = (ny64 * nx64).max(1);
528    let items = blocks.div_ceil((sites as u64).max(1));
529    // Which operand is shared is a property of the placement the passes chose, never of a named
530    // strategy: A is multicast across a grid ROW when there is more than one column to share it with.
531    let a_shared = fused && nx64 > 1;
532    let b_shared = fused && ny64 > 1;
533    let a_dram = if a_shared {
534        mt64 * kt64
535    } else {
536        nx64 * mt64 * kt64
537    };
538    let b_dram = if b_shared {
539        nt64 * kt64
540    } else {
541        ny64 * nt64 * kt64
542    };
543    let a_mcast = if a_shared { mt64 * kt64 } else { 0 };
544    let b_mcast = if b_shared { nt64 * kt64 } else { 0 };
545    let dram_tiles = a_dram + b_dram + mt64 * nt64;
546    let dram_bytes = a_dram * widths.a + b_dram * widths.b + mt64 * nt64 * widths.out;
547    let payload_bytes = dram_bytes + a_mcast * widths.a_buffer + b_mcast * widths.b_buffer;
548    // A multicast rectangle costs ONE transaction whatever its payload, which is why a multicast plan's
549    // transaction count falls further than its byte count does. Three things cross the NOC per block:
550    // the payload, the valid flag that follows it down the same reserved path, and — from every RECEIVER
551    // back to its sender — the ready atomic that says the last block has been consumed. That last term
552    // is the one a byte-only reading of the plan misses, and it dominates: sixteen groups of eight sites
553    // at 512^3 is 112 atomics per block against 32 sends.
554    let nb = (kt / plan.block.ibw.max(1)).max(1) as u64;
555    // The two operands have DIFFERENT panel sizes whenever the per-site block is not square — A is
556    // `per_site_m x ibw`, B is `ibw x per_site_n` — so they split into different numbers of bursts and
557    // charging both at the larger over-counts. (Measured: 4,352 predicted against 4,224 at 2048x1024x512,
558    // where the block is 8x4.)
559    let handshakes = if fused {
560        (a_shared as u64) * ny64 * (nx64 - 1) + (b_shared as u64) * nx64 * (ny64 - 1)
561    } else {
562        0
563    };
564    // Each payload is split into burst-sized commands; the valid flag that follows it down the same
565    // reserved path is one word, so one command.
566    // ★ PRICED PER GRID ROW AND COLUMN, because a boundary block's panel is SMALLER and splits into
567    // fewer bursts. `BlockPlan::site_block` is the one definition of what each site covers, and `place`
568    // emits its patterns from the same function — a second copy of that rule would drift, and a planner
569    // that ranks by a closed form the emitter does not honour is exactly the defect already removed from
570    // `size_buffers`. On a grid whose extents divide, every row is the nominal block and these sums are
571    // `ny * (...)` and `nx * (...)` exactly as before.
572    let a_bursts: u64 = if a_shared {
573        (0..ny)
574            .map(|i| commands_for(plan.site_block(mt, nt, i, 0).0 as u64 * ibw * widths.a_buffer))
575            .sum()
576    } else {
577        0
578    };
579    let b_bursts: u64 = if b_shared {
580        (0..nx)
581            .map(|j| commands_for(ibw * plan.site_block(mt, nt, 0, j).1 as u64 * widths.b_buffer))
582            .sum()
583    } else {
584        0
585    };
586    let flags = (a_shared as u64) * ny64 + (b_shared as u64) * nx64;
587    // The payload's bytes are its tiles'; only the flags and the ready atomics are four-byte words.
588    let control = (flags + handshakes) * nb;
589    let mcast_commands = (a_bursts + b_bursts) * nb + control;
590    // ★ THE BUSIEST SITE. Every site writes its own output block; what it READS depends on whether it
591    // fetches its operands or is handed them. In a multicast plan the counter sits at the ISSUER, so a
592    // receiver initiates almost nothing and the pace is set by a SENDER — and the busiest sender is the
593    // one at the CORNER, which sends A down its row AND B down its column, reading each from DRAM and
594    // multicasting each. Assuming one operand there under-counted it by half, which the choreography
595    // walk caught: 68 tiles predicted against 132 actually issued at 512^3.
596    //
597    // Without this term at all the search would collapse a small problem onto one site, which genuinely
598    // does move the fewest bytes in total and is obviously wrong.
599    // An operand the corner site SENDS costs it twice — once to read from DRAM, once to multicast — and
600    // an operand it merely fetches for itself costs it once. Sharing is per-axis, so this is too: a plan
601    // that multicasts A down its rows but has only one grid row to share B along pays double for A and
602    // single for B, and assuming both would over-charge it.
603    let a_part = pm * kt64 * (widths.a + if a_shared { widths.a_buffer } else { 0 }) * items;
604    let b_part = pn * kt64 * (widths.b + if b_shared { widths.b_buffer } else { 0 }) * items;
605    let store = pm * pn * widths.out * items;
606    // Which engine carries which stream. Loading both operands together leaves the store an engine to
607    // itself; pairing the second operand with the store overlaps this block's write with the next
608    // block's reads. The busier engine paces the site, and a site with one engine carries every stream.
609    let engine_noc_bytes = match (two_engines, plan.engines) {
610        (false, _) => a_part + b_part + store,
611        (true, super::ir::EnginePacking::LoadsTogether) => (a_part + b_part).max(store),
612        (true, super::ir::EnginePacking::OperandWithStore) => a_part.max(b_part + store),
613    };
614    // The corner's transactions: a tile read or written is one, a multicast is one per burst and one for
615    // its flag. A multicast plan's grid fits the mesh, so the corner carries one block.
616    let corner_sends = nb
617        * ((a_shared as u64) * (commands_for(pm * ibw * widths.a_buffer) + 1)
618            + (b_shared as u64) * (commands_for(ibw * pn * widths.b_buffer) + 1));
619    let site_noc_txns = (pm * kt64 + pn * kt64 + pm * pn) * items + corner_sends;
620    // Invocations of the matrix engine and the operand tiles it loads, per sub-block per K block, by its block
621    // API (`SpatialTarget::matmul_calls`, `SpatialTarget::operand_loads`), summed over the sites that actually
622    // carry work: a boundary site walks fewer sub-blocks, and on a dividing grid every site is the nominal block.
623    let (compute_calls, engine_operand_tiles) = plan
624        .site_blocks(mt, nt)
625        .iter()
626        .map(|(_, (h, w, osh, osw))| {
627            let sub = (*h as u64 / (*osh as u64).max(1)) * (*w as u64 / (*osw as u64).max(1));
628            let block = super::ir::BlockShape {
629                osh: *osh,
630                osw: *osw,
631                ibw: plan.block.ibw,
632            };
633            (
634                sub * nb * t.matmul_calls(block, plan.granularity),
635                sub * nb * t.operand_loads(block, plan.granularity),
636            )
637        })
638        .fold((0u64, 0u64), |(c, l), (dc, dl)| (c + dc, l + dl));
639    let (compute_calls, engine_operand_tiles) =
640        (compute_calls * items, engine_operand_tiles * items);
641    Some(Work {
642        matmul_retired: retired,
643        compute_calls,
644        engine_operand_tiles,
645        noc_txns: dram_tiles
646            + mcast_commands
647            + HOST_TXNS_PER_SITE * blocks.min((sites as u64).max(1)),
648        noc_bytes: payload_bytes + 4 * control,
649        payload_bytes,
650        dram_bytes,
651        dram_tiles,
652        onchip_bytes: 0,
653        tensix_inst: 0,
654        sites_used: blocks.min((sites as u64).max(1)),
655        site_noc_bytes: a_part + b_part + store,
656        engine_noc_bytes,
657        site_noc_txns,
658        site_matmul_retired: pm * pn * kt64 * items * per_tile,
659    })
660}
661
662/// ★ WHAT THE COMPILER IS MINIMIZING, SAID OUT LOUD — and the knob for changing it.
663///
664/// This is a POLICY, not a fitted weight, and the distinction is the whole point. The lattice of legal
665/// plans is enormous (4,209 at 512^3) and every one of them computes the same answer; the conservation
666/// law says they even retire the same arithmetic. What separates them is how much of the machine they
667/// spend carrying operands to that arithmetic, and WHICH of those costs matters is a property of the
668/// deployment, not of the mathematics. So it is named, defaulted, and overridable — never inferred, and
669/// never a constant fitted to a simulator.
670///
671/// It is not a strategy enum. It names no plan, tiling or arrangement; it names an axis to minimize, and
672/// which plan wins is still whatever the search finds. Two objectives over the same lattice pick two
673/// different plans and a human wrote down neither of them.
674///
675/// ★ THE FRONTIER IS REAL AND IT IS THE POINT. `Balanced` and `Energy` genuinely disagree: the plan that
676/// spends the least machine in total is not always the one that spreads best across it, and a compiler
677/// that pretends otherwise is hiding a trade-off rather than owning it.
678/// A plan's rank under an [`Objective`]: exact counts compared in order, most significant first.
679pub type RankKey = [u128; 7];
680
681#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
682pub enum Objective {
683    /// The shared resource, then the arithmetic spread, then the movement spread, then transactions.
684    ///
685    /// The default, and here is the whole argument, in the order the terms bind.
686    ///
687    ///   1. **DRAM is SHARED.** Wormhole has six channels against sixty-four sites, so a plan whose
688    ///      sites each fetch their own operands leans on the one resource nobody can add more of. It
689    ///      leads.
690    ///   2. **The busiest site's ARITHMETIC.** A plan that reads DRAM once and then does all the
691    ///      multiplying on a handful of cores ties on the first term and is obviously wrong — it is not
692    ///      using the machine. This is why the fabric has sixty-four sites at all, so it comes second,
693    ///      and it is what refuses the single-site plan that minimizing traffic alone walks straight
694    ///      into.
695    ///   3. **The busiest MOVEMENT ENGINE.** Two sites can move the same tiles and still differ: issuing
696    ///      both operand reads on one engine serialises them, pairing the second with the store overlaps
697    ///      a block's write with the next block's reads. The busier engine paces the site.
698    ///   4. **The matrix engine's calls, then the operand tiles it loads.** Same bytes, same arithmetic: a
699    ///      sub-block that covers eight tiles per call asks for an eighth of the calls a one-tile sub-block
700    ///      does, and at equal calls the sub-block's shape decides what the engine loads, by its own rule.
701    ///   5. **Transactions.** Same tiles, same engines, different block depth — this is what is left, and
702    ///      it is last because it is the axis the model knows least about.
703    ///
704    /// Nothing here is tuned. It is lexicographic over exact integer counts and invents no rate, no
705    /// bandwidth and no energy constant; the only machine facts it leans on are that DRAM is shared,
706    /// that sites are not, and how many movement engines a site has.
707    ///
708    /// It ranks only admissible plans: a ragged grid is one only where no evenly divided plan beats it on
709    /// every predicted total ([`admissible`](crate::spatial::passes::tile::admissible)).
710    #[default]
711    Balanced,
712    /// The whole device's movement, whatever the spread.
713    ///
714    /// ★ AND IT COLLAPSES ONTO ONE SITE, which is worth stating rather than hiding. A single site reads
715    /// each operand tile exactly once — that IS the traffic minimum — so asking only for less traffic
716    /// asks for no parallelism. Measured at 512^3: this picks a 16x16 per-site block on ONE core,
717    /// 1,572,864 bytes against `Balanced`'s 2,621,952. It is a real corner of the frontier and it is
718    /// occasionally what someone wants (a batch of tiny problems, one per core); it is not a default,
719    /// and calling it "energy" would have been a lie, because energy is power times TIME.
720    Traffic,
721    /// Transactions first, for a NOC limited by requests in flight rather than by bytes.
722    Transactions,
723    /// Issue slots first, for a fabric whose instruction bandwidth binds before its NOC does.
724    Issue,
725    /// An explicit per-event weighting — it takes its numbers from the caller rather than from a fit.
726    Weighted {
727        per_byte: f64,
728        per_txn: f64,
729        per_inst: f64,
730    },
731    /// The roofline: the plan whose slowest resource finishes first, each count of the work over the cited
732    /// roof of the resource it loads ([`super::roofs::Rates`]), with `Balanced`'s key as the exact tiebreak.
733    ///
734    /// It prices what `Balanced` only orders. `Balanced` puts DRAM bytes ahead of the busiest site's
735    /// arithmetic whatever the two cost; the roofline asks which of them the chip will wait on, at rates read
736    /// from the vendor's own documents, so a plan that moves a few more bytes to spread the arithmetic wins
737    /// exactly when the arithmetic is the bound. A resource with no roof is not priced. The cycles are rounded
738    /// up to a whole cycle, so the rates decide which plans tie and the counts order the ties exactly.
739    Roofline(super::roofs::Rates),
740}
741
742impl Objective {
743    /// The roofline objective for programs whose traffic and operands are `element`s on `target`.
744    pub fn roofline(
745        target: &SpatialTarget,
746        element: crate::codegen_sva::fabric_target::Precision,
747    ) -> Objective {
748        Objective::Roofline(super::roofs::Rates::of(target, element))
749    }
750
751    /// Rank a plan's work; lower is better: exact counts compared in order, most significant first, each in a
752    /// place of its own and the unused places zero. The integer variants compare EXACTLY — no float rounding
753    /// ever decides a plan — the weighted one is scaled and rounded once, at the end, and the roofline's
754    /// bound is rounded up to a whole cycle before the exact counts break its ties.
755    pub fn key(self, w: &Work) -> RankKey {
756        let n = |x: u64| x as u128;
757        match self {
758            // Movement settled, the engine's own call count decides: same bytes, same transactions, same
759            // arithmetic — but a sub-block that covers eight tiles per call asks the machine for an eighth of
760            // the work a one-tile sub-block does. At equal calls, what the engine loads for them.
761            Objective::Balanced => [
762                n(w.dram_bytes),
763                n(w.site_matmul_retired),
764                n(w.engine_noc_bytes),
765                n(w.compute_calls),
766                n(w.engine_operand_tiles),
767                n(w.noc_txns),
768                0,
769            ],
770            Objective::Traffic => [
771                n(w.payload_bytes),
772                n(w.dram_bytes),
773                n(w.noc_txns),
774                n(w.site_matmul_retired),
775                0,
776                0,
777                0,
778            ],
779            Objective::Transactions => [
780                n(w.noc_txns),
781                n(w.payload_bytes),
782                n(w.site_matmul_retired),
783                n(w.engine_noc_bytes),
784                0,
785                0,
786                0,
787            ],
788            Objective::Issue => [
789                n(w.compute_calls),
790                n(w.payload_bytes),
791                n(w.site_matmul_retired),
792                n(w.engine_noc_bytes),
793                0,
794                0,
795                0,
796            ],
797            Objective::Weighted {
798                per_byte,
799                per_txn,
800                per_inst,
801            } => {
802                let s = per_byte * w.noc_bytes as f64
803                    + per_txn * w.noc_txns as f64
804                    + per_inst * w.tensix_inst as f64;
805                [
806                    (s * 1e6).round().max(0.0) as u128,
807                    n(w.site_matmul_retired),
808                    0,
809                    0,
810                    0,
811                    0,
812                    0,
813                ]
814            }
815            Objective::Roofline(rates) => {
816                let cycles = rates.roofline(w).cycles().ceil().max(0.0) as u128;
817                let b = Objective::Balanced.key(w);
818                [cycles, b[0], b[1], b[2], b[3], b[4], b[5]]
819            }
820        }
821    }
822
823    /// Every objective, for reporting the frontier rather than a single winner.
824    pub const ALL: [Objective; 4] = [
825        Objective::Balanced,
826        Objective::Traffic,
827        Objective::Transactions,
828        Objective::Issue,
829    ];
830}