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}