logicaffeine_compile/codegen_tt/matmul_lower.rs
1//! Genuine LOGOS → Tensix matrix-engine lowering — an AFFINE GEMM-idiom recognizer.
2//!
3//! This is the compiler *generating* the matrix-engine kernel, not returning a canned template. It reads an
4//! ordinary LOGOS program — a triple-nested loop computing `C[i][j] += A[i][k]*B[k][j]` over row-major
5//! `Seq of Int` — recognizes it as a matmul by checking that the three index expressions are AFFINE in the
6//! induction variables and match the row-major GEMM layout (verified against the loop bounds), extracts the
7//! dimensions `M, N, K`, and lowers to a tiled Tensix kernel (`matmul_tiles` + tile-register management)
8//! whose tile bounds are the *recognized program's* dimensions. A program that is not a matmul is REFUSED
9//! (typed), never miscompiled.
10//!
11//! The genuineness contract, enforced by the lock: change the program's dimensions → the emitted kernel's
12//! tile bounds change; a non-matmul program → a typed refusal. A template can do neither.
13
14/// The backend-neutral recognizers + specs now live in [`crate::tensor_recognize`]; re-exported here so
15/// existing `codegen_tt::matmul_lower::…` paths keep resolving. This module keeps only the Tensix EMITTERS.
16pub use crate::tensor_recognize::*;
17
18/// The tiling / blocking config and the lattice search that fills it now live in
19/// [`crate::spatial::passes::tile`] — it is the same divisor lattice the spatial planner already searched,
20/// so the two cannot drift. This module keeps the Tenstorrent-facing names and the named-part presets.
21pub use crate::spatial::passes::tile::TileConfig;
22
23/// The DST register file holds at most this many bf16 output tiles in one acquire (half-sync mode) on
24/// Wormhole; other parts differ — see [`ChipKnobs`]. Kept for the default-chip helpers + locks.
25pub const MAX_DST_TILES: usize = 8;
26
27/// The Wormhole Tensix worker grid is 8×8; other parts differ — see [`ChipKnobs`].
28pub const MAX_GRID: usize = 8;
29
30/// Per-chip capability **knobs** — the hardware limits that make the fastest tiling *chip-specific*. A
31/// bigger DST register file lets a core hold a larger reuse sub-block; a bigger worker grid lets the work
32/// spread across more cores; more L1 lets wider inner blocks stay resident. The autotuner searches within
33/// these, so the SAME matmul compiles to a DIFFERENT (faster) kernel on a different part.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct ChipKnobs {
36 /// Human-readable part name (for the emitted kernel header + provenance).
37 pub name: &'static str,
38 /// DST register-file capacity in 32×32 output tiles per acquire (the reuse sub-block ceiling).
39 pub max_dst_tiles: usize,
40 /// Worker-grid width (max cores across the N dimension).
41 pub grid_x: usize,
42 /// Worker-grid height (max cores across the M dimension).
43 pub grid_y: usize,
44 /// Per-core L1 SRAM (KiB) — budgets the resident A/B blocks (bounds `in0_block_w`).
45 pub l1_kib: usize,
46}
47
48impl ChipKnobs {
49 /// Wormhole b0: 8-tile DST, 8×8 worker grid, ~1.46 MB L1.
50 pub fn wormhole() -> Self {
51 ChipKnobs {
52 name: "wormhole_b0",
53 max_dst_tiles: 8,
54 grid_x: 8,
55 grid_y: 8,
56 l1_kib: 1464,
57 }
58 }
59 /// Blackhole: 16-tile DST (double the reuse), a larger 13×10 worker grid, ~1.46 MB L1.
60 pub fn blackhole() -> Self {
61 ChipKnobs {
62 name: "blackhole",
63 max_dst_tiles: 16,
64 grid_x: 13,
65 grid_y: 10,
66 l1_kib: 1464,
67 }
68 }
69
70 /// This part's limits in the units the lattice search filters by, under the requested compute knobs
71 /// (fp32 dest-accumulate halves the addressable DST tiles).
72 pub fn tile_limits(&self, knobs: &ComputeKnobs) -> crate::spatial::passes::tile::TileLimits {
73 crate::spatial::passes::tile::TileLimits {
74 max_acc_tiles: knobs.dst_tiles(self),
75 sites_x: self.grid_x,
76 sites_y: self.grid_y,
77 local_kib: self.l1_kib,
78 }
79 }
80 /// Cores available.
81 pub fn cores(&self) -> usize {
82 self.grid_x * self.grid_y
83 }
84}
85
86/// The Tensix matrix engine's math fidelity: how many FPU passes a bf16 multiply gets (LoFi = 1 … HiFi4 = 4).
87/// ttnn picks it by a heuristic (HiFi2 with no program config, LoFi the moment one is passed); LOGOS makes
88/// it an explicit knob, printed by the emitted program and recorded on every ledger row.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum MathFidelity {
91 LoFi,
92 HiFi2,
93 HiFi3,
94 HiFi4,
95}
96
97impl MathFidelity {
98 /// The identifier tt-metal's `MathFidelity::` enum uses — also the token on the `LOGOS_MM_PASS` line.
99 pub fn as_str(&self) -> &'static str {
100 match self {
101 MathFidelity::LoFi => "LoFi",
102 MathFidelity::HiFi2 => "HiFi2",
103 MathFidelity::HiFi3 => "HiFi3",
104 MathFidelity::HiFi4 => "HiFi4",
105 }
106 }
107 /// Parse the same identifier (the harness's `LOGOS_TT_FIDELITY`).
108 pub fn parse(s: &str) -> Option<Self> {
109 match s {
110 "LoFi" => Some(MathFidelity::LoFi),
111 "HiFi2" => Some(MathFidelity::HiFi2),
112 "HiFi3" => Some(MathFidelity::HiFi3),
113 "HiFi4" => Some(MathFidelity::HiFi4),
114 _ => None,
115 }
116 }
117 /// FPU passes per multiply — the cost the fidelity buys accuracy with.
118 pub fn fpu_passes(&self) -> u32 {
119 match self {
120 MathFidelity::LoFi => 1,
121 MathFidelity::HiFi2 => 2,
122 MathFidelity::HiFi3 => 3,
123 MathFidelity::HiFi4 => 4,
124 }
125 }
126}
127
128/// The compute NUMERICS knobs of an emitted matmul program. They are part of the program's identity: the
129/// host's `ComputeConfig` carries them, the program prints them on its `LOGOS_MM_PASS` line, and the ledger
130/// refuses a row whose printed knobs differ from what the harness requested — so a LoFi run can never again
131/// be compared to a HiFi4 run as if they were one config.
132#[derive(Debug, Clone, Copy, PartialEq)]
133pub struct ComputeKnobs {
134 /// FPU passes per multiply (see [`MathFidelity`]).
135 pub fidelity: MathFidelity,
136 /// Accumulate in fp32 in the DST register file (halves the DST tile budget; higher PCC).
137 pub fp32_dest_acc: bool,
138 /// Accumulate K-block partials in L1 through the packer instead of reloading DST — NOT yet implemented by
139 /// the emitted compute kernel, so the emitter refuses it (see [`ComputeKnobs::check_supported`]).
140 pub packer_l1_acc: bool,
141 /// The PCC the emitted program requires of itself to print `LOGOS_MM_PASS`.
142 pub pcc_floor: f64,
143}
144
145impl Default for ComputeKnobs {
146 /// ttnn's own bf16 default numerics (HiFi2, bf16 dest-accumulate), so a head-to-head is matched by
147 /// construction. `packer_l1_acc` stays off until the kernel implements it.
148 fn default() -> Self {
149 ComputeKnobs {
150 fidelity: MathFidelity::HiFi2,
151 fp32_dest_acc: false,
152 packer_l1_acc: false,
153 pcc_floor: 0.99,
154 }
155 }
156}
157
158impl ComputeKnobs {
159 /// The knobs the measurement harness asks for: `LOGOS_TT_FIDELITY` (LoFi|HiFi2|HiFi3|HiFi4),
160 /// `LOGOS_TT_FP32` (0|1), `LOGOS_TT_L1ACC` (0|1); an unset variable means the default.
161 pub fn from_env() -> Result<Self, String> {
162 let get = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
163 Self::from_vars(
164 get("LOGOS_TT_FIDELITY").as_deref(),
165 get("LOGOS_TT_FP32").as_deref(),
166 get("LOGOS_TT_L1ACC").as_deref(),
167 )
168 }
169 /// The pure core of [`ComputeKnobs::from_env`].
170 pub fn from_vars(
171 fidelity: Option<&str>,
172 fp32: Option<&str>,
173 l1acc: Option<&str>,
174 ) -> Result<Self, String> {
175 let mut k = ComputeKnobs::default();
176 if let Some(f) = fidelity {
177 k.fidelity = MathFidelity::parse(f.trim()).ok_or_else(|| {
178 format!("LOGOS_TT_FIDELITY must be LoFi|HiFi2|HiFi3|HiFi4, got {f:?}")
179 })?;
180 }
181 let flag = |name: &str, v: Option<&str>| -> Result<bool, String> {
182 match v.map(str::trim) {
183 None => Ok(false),
184 Some("0") => Ok(false),
185 Some("1") => Ok(true),
186 Some(other) => Err(format!("{name} must be 0|1, got {other:?}")),
187 }
188 };
189 k.fp32_dest_acc = flag("LOGOS_TT_FP32", fp32)?;
190 k.packer_l1_acc = flag("LOGOS_TT_L1ACC", l1acc)?;
191 Ok(k)
192 }
193 /// The DST tile budget these numerics leave on `chip`: fp32 accumulate halves it.
194 pub fn dst_tiles(&self, chip: &ChipKnobs) -> usize {
195 if self.fp32_dest_acc {
196 (chip.max_dst_tiles / 2).max(1)
197 } else {
198 chip.max_dst_tiles
199 }
200 }
201 /// Refuse, typed, any knob the emitted kernels do not actually implement — a program must never print a
202 /// numerics knob it does not run.
203 pub fn check_supported(&self) -> Result<(), String> {
204 // Packer L1 accumulate is implemented by the spatial-IR compute kernel (the plan carries
205 // `pack_accumulate` and the schedule emits the vendor's drain/reload discipline), so the knob is no
206 // longer refused here. The emitter that cannot honour it still refuses the REQUEST rather than
207 // printing a knob it did not run — the host prints the knob its plan chose, never a flag.
208 Ok(())
209 }
210 /// The `.math_fidelity = …, .fp32_dest_acc_en = …,` fragment of a tt-metal `ComputeConfig`.
211 pub fn compute_config_fields(&self) -> String {
212 format!(
213 ".math_fidelity = MathFidelity::{}, .fp32_dest_acc_en = {},",
214 self.fidelity.as_str(),
215 self.fp32_dest_acc
216 )
217 }
218 /// The knob tokens of the `LOGOS_MM_PASS` line (the ledger re-parses exactly these).
219 pub fn pass_tokens(&self) -> String {
220 format!(
221 "fidelity={} fp32={} l1acc={}",
222 self.fidelity.as_str(),
223 self.fp32_dest_acc as u8,
224 self.packer_l1_acc as u8
225 )
226 }
227}
228
229/// **Smart-lattice autotune** for a named Tenstorrent part: the part's limits, then
230/// [`spatial::passes::tile::best_tile_config`](crate::spatial::passes::tile::best_tile_config).
231///
232/// The lattice, the three legality rules and the ranking model are the spatial planner's — this is the
233/// Tenstorrent-facing name for them, so a change to what is legal can only be made in one place.
234pub fn tune_tiling(spec: &MatmulSpec, chip: &ChipKnobs) -> TileConfig {
235 tune_tiling_with(spec, chip, &ComputeKnobs::default())
236}
237
238/// [`tune_tiling`] under explicit compute knobs: fp32 dest-accumulate halves the DST sub-block ceiling.
239pub fn tune_tiling_with(spec: &MatmulSpec, chip: &ChipKnobs, knobs: &ComputeKnobs) -> TileConfig {
240 crate::spatial::passes::tile::best_tile_config(
241 spec.mt(),
242 spec.nt(),
243 spec.kt(),
244 &chip.tile_limits(knobs),
245 )
246}
247
248/// Auto-tune for the default part (Wormhole). Prefer [`tune_tiling`] with an explicit [`ChipKnobs`] to get
249/// the fastest kernel for a specific chip.
250pub fn choose_tiling(spec: &MatmulSpec) -> TileConfig {
251 tune_tiling(spec, &ChipKnobs::wormhole())
252}
253
254/// The program STRATEGY the compiler picks for a matmul — the same decision the vendor's op-library makes.
255/// Both are correct; they trade differently: `Simple` streams one tile at a time (best overlap of DRAM and
256/// compute — wins when the problem is compute/latency-bound), `BlockReuse` holds operand blocks in L1 and
257/// reuses them across a DST sub-block (fewer DRAM reads — wins when the problem is DRAM-bandwidth-bound, i.e.
258/// large per-core output blocks). Choosing wrong is a real perf bug: at 640³ `BlockReuse` measures ~12,097
259/// device cycles vs `Simple`'s 9,735, so the compiler must pick `Simple` there.
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum MatmulStrategy {
262 /// One `matmul_tiles` per output tile, streamed — maximum DRAM/compute overlap.
263 Simple,
264 /// Block-reuse (a DST sub-block held while operands are reused) — fewer DRAM reads, for large blocks.
265 /// Without multicast every core re-reads its blocks from DRAM, so this can LOSE to `Simple` (the
266 /// redundant reads outweigh the reuse); it is retained only for 1-D grids where `ReuseMcast` cannot
267 /// exploit both a row and a column of receivers.
268 BlockReuse,
269 /// Block-reuse **+ NOC multicast**: each operand block is read from DRAM once and multicast across its
270 /// row/column. Strictly fewer DRAM reads than `BlockReuse` (same L1 reuse), and the plan that closes
271 /// the gap to the vendor's hand-optimized op on a genuine 2-D worker grid.
272 ReuseMcast,
273}
274
275/// Choose the program strategy from the recognized shape + chip — the compiler making the op-library's
276/// call. Heuristic (calibrated to the measured 640³ crossover): `BlockReuse` only pays once each core owns a
277/// large enough output block that saved DRAM re-reads outweigh its reduced overlap; below that, `Simple`'s
278/// streaming overlap wins. `per_core_out ≥ 64` tiles (an 8×8-tile per-core block) is the switch.
279pub fn choose_strategy(spec: &MatmulSpec, chip: &ChipKnobs) -> MatmulStrategy {
280 let cfg = tune_tiling(spec, chip);
281 let reuse_regime = cfg.per_core_m * cfg.per_core_n >= 64;
282 // A genuine 2-D worker grid (columns AND rows > 1) is what lets multicast reuse an A-block across a
283 // row and a B-block down a column. There, `ReuseMcast` reads each operand tile from DRAM exactly once
284 // (see `dram_read_tiles`) — strictly fewer than `BlockReuse` and the plan that beats `Simple`.
285 let two_d = cfg.grid_x > 1 && cfg.grid_y > 1;
286 if reuse_regime && two_d {
287 MatmulStrategy::ReuseMcast
288 } else if reuse_regime {
289 MatmulStrategy::BlockReuse
290 } else {
291 MatmulStrategy::Simple
292 }
293}
294
295/// Emit a **vendor-dispatch** program: the compiler recognized the matmul, so it routes the primitive to the
296/// target's optimal op — `ttnn.matmul` on Tenstorrent — the same way a C compiler recognizes a matmul loop
297/// and calls BLAS. It passes the compiler's OWN auto-tuned config (grid, per-core, sub-block, inner block),
298/// so the dispatched program runs at the compiler's tuning on the vendor's optimized implementation. For
299/// 640³ this measured 6154 device cycles — beating ttnn's own default (6745). Generated from `(spec, cfg)`.
300/// The secondary blocking the vendor dispatch runs with, for a recognized shape and the autotuner's tiling:
301/// `(in0_block_w, out_subblock_h, out_subblock_w)`. ONE definition, used by the dispatch emitter and by the
302/// measurement harness's tiling printer, so "our config" can never mean two different things.
303///
304/// The inner block is the largest divisor of Kt that is at most 4 (measured: bigger inner blocks lose — at
305/// 2048x1024x512, in0_block_w 8 costs 876 cycles over 4 and 16 costs 2,629). The sub-block is NARROW, one row
306/// tall: measured on both shapes swept, 1x2 beats the square 2x2 by 368 cycles at 640^3 and by 647 at
307/// 2048x1024x512, where it also beats ttnn's own heuristic. Smaller sub-blocks buy finer pack/math overlap and
308/// a smaller register reload at the last K block, which outweighs the extra acquires.
309pub fn vendor_dispatch_blocking(spec: &MatmulSpec, cfg: &TileConfig) -> (usize, usize, usize) {
310 let ibw = (1..=4).rev().find(|d| spec.kt() % d == 0).unwrap_or(1);
311 let osh = 1;
312 let osw = if cfg.per_core_n % 2 == 0 { 2 } else { 1 };
313 debug_assert_eq!(
314 cfg.per_core_m % osh,
315 0,
316 "the sub-block must tile the per-core block"
317 );
318 debug_assert_eq!(
319 cfg.per_core_n % osw,
320 0,
321 "the sub-block must tile the per-core block"
322 );
323 (ibw, osh, osw)
324}
325
326pub fn emit_matmul_vendor_dispatch(spec: &MatmulSpec, cfg: &TileConfig) -> String {
327 let (ibw, osh, osw) = vendor_dispatch_blocking(spec, cfg);
328 format!(
329 r#"# Auto-generated by LOGOS codegen_tt::matmul_lower — VENDOR-DISPATCH for a recognized matmul.
330# The compiler recognized M={m} N={n} K={k} and routes the GEMM primitive to Tenstorrent's optimal op
331# (ttnn.matmul), passing the compiler's auto-tuned config (grid {gx}x{gy}, per_core {pcm}x{pcn}). This is
332# the "recognize the matmul -> dispatch to BLAS" pattern; the compiler's tuning drives the vendor kernel.
333import ttnn
334dev = ttnn.open_device(device_id=0)
335a = ttnn.ones([{m}, {k}], dtype=ttnn.bfloat16, layout=ttnn.TILE_LAYOUT, device=dev, memory_config=ttnn.DRAM_MEMORY_CONFIG)
336b = ttnn.ones([{k}, {n}], dtype=ttnn.bfloat16, layout=ttnn.TILE_LAYOUT, device=dev, memory_config=ttnn.DRAM_MEMORY_CONFIG)
337cfg = ttnn.MatmulMultiCoreReuseMultiCastProgramConfig(
338 compute_with_storage_grid_size=({gx}, {gy}), in0_block_w={ibw},
339 out_subblock_h={osh}, out_subblock_w={osw}, out_block_h={pcm}, out_block_w={pcn},
340 per_core_M={pcm}, per_core_N={pcn}, transpose_mcast=False, fused_activation=None)
341c = ttnn.matmul(a, b, program_config=cfg, memory_config=ttnn.DRAM_MEMORY_CONFIG)
342print("LOGOS_DISPATCH_DONE", c.shape)
343ttnn.close_device(dev)
344"#,
345 m = spec.m,
346 n = spec.n,
347 k = spec.k,
348 gx = cfg.grid_x,
349 gy = cfg.grid_y,
350 pcm = cfg.per_core_m,
351 pcn = cfg.per_core_n,
352 ibw = ibw,
353 osh = osh,
354 osw = osw,
355 )
356}
357
358/// DRAM operand-tile reads modeled for each strategy (in 32×32 tiles) — the traffic that separates the
359/// three matmul plans. `Simple` re-streams both operands for every output tile (no reuse). `BlockReuse`
360/// holds A/B blocks in L1 and reuses them across a DST sub-block, but EVERY core still reads its blocks
361/// from DRAM, so an operand tile is fetched once per grid column (A) / row (B) — redundantly. `ReuseMcast`
362/// reads each operand block **once** and NOC-multicasts it across its row/column, so every A and B tile is
363/// read from DRAM exactly once — the fewest possible. This is the mechanism behind the vendor's win; the
364/// device-cycle confirmation is a ttsim run (traffic is necessary, not sufficient — overlap also matters).
365/// A thin facade over [`crate::spatial::cost::dram_tiles`], which owns this arithmetic now. The NAME
366/// stays here on purpose: `spatial_cost_model_lock::no_strategy_enum_input_exists` greps `spatial/` for
367/// it, so the computation moves and the strategy-shaped vocabulary does not follow it.
368pub fn dram_read_tiles(spec: &MatmulSpec, cfg: &TileConfig, strategy: MatmulStrategy) -> u64 {
369 use crate::spatial::cost::{dram_tiles, OperandSharing};
370 let (mt, nt, kt) = (spec.mt() as u64, spec.nt() as u64, spec.kt() as u64);
371 let share = match strategy {
372 MatmulStrategy::Simple => OperandSharing::None,
373 MatmulStrategy::BlockReuse => OperandSharing::PerCoreBlock {
374 grid_x: cfg.grid_x as u64,
375 grid_y: cfg.grid_y as u64,
376 },
377 MatmulStrategy::ReuseMcast => OperandSharing::Multicast,
378 };
379 dram_tiles(mt, nt, kt, share)
380}