Skip to main content

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}