logicaffeine_compile/spatial/target.rs
1//! `SpatialTarget` — the described MESH a spatial program is placed onto.
2//!
3//! One descriptor per chip class: how many sites, what each site holds (local memory, accumulator tiles,
4//! movement engines, NOCs), the native compute tile, which interconnect and handshake primitives exist, and
5//! which compute capabilities (tile matmul, elementwise, row-reduce, a scalar datapath) the site offers. The
6//! passes read ONLY this descriptor; every number is cited at its constructor, and the fields that a public
7//! datasheet does not give are listed in `modeled` so the descriptor never pretends to know them.
8//!
9//! The scalar datapath capability EMBEDS the existing [`FabricTarget`] descriptor — the fabric lane is a
10//! consumer of the spatial IR, not a sibling of it.
11
12use std::collections::BTreeSet;
13
14use serde::Serialize;
15
16use super::cost::CycleModel;
17use super::ir::AccPolicy;
18use super::part::{MemoryKind, PartResources};
19use super::roofs::Roofs;
20use crate::codegen_sva::fabric_target::{FabricTarget, MemHierarchy, Precision};
21
22/// The mesh of sites: `x` columns × `y` rows.
23#[derive(
24 Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, serde::Deserialize,
25)]
26pub struct MeshShape {
27 pub x: usize,
28 pub y: usize,
29}
30
31impl MeshShape {
32 pub fn sites(&self) -> usize {
33 self.x * self.y
34 }
35}
36
37/// Geometry of one native compute tile — 32×32 on Tensix, a 16×16 fragment on a tensor core, 1×1 for a
38/// scalar fabric cell.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, serde::Deserialize)]
40pub struct TileShape {
41 pub rows: usize,
42 pub cols: usize,
43}
44
45impl TileShape {
46 pub const TENSIX: TileShape = TileShape { rows: 32, cols: 32 };
47 pub const WMMA16: TileShape = TileShape { rows: 16, cols: 16 };
48 pub const SCALAR: TileShape = TileShape { rows: 1, cols: 1 };
49
50 pub fn elems(&self) -> usize {
51 self.rows * self.cols
52 }
53}
54
55/// How sites exchange data.
56#[derive(
57 Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, serde::Deserialize,
58)]
59pub enum Interconnect {
60 /// One site writes a block into a rectangle of sites in one NOC transaction (Tensix multicast).
61 MulticastRect,
62 /// Wires between adjacent sites only (our fabric).
63 NearestNeighbour,
64 /// Any site to any site (the RDU's switch fabric).
65 Crossbar,
66 /// Sites share data only through the global memory (a GPU's L2/DRAM).
67 GlobalMemory,
68 /// Threads inside ONE site share a scratchpad (a thread block's shared memory).
69 SharedMemoryBlock,
70}
71
72/// The synchronization primitive an edge may use.
73#[derive(
74 Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, serde::Deserialize,
75)]
76pub enum HandshakeKind {
77 Fifo,
78 Semaphore,
79 Barrier,
80 ValidReady,
81 Token,
82}
83
84/// Matmul math fidelity of a tile engine.
85#[derive(
86 Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, serde::Deserialize,
87)]
88pub enum Fidelity {
89 LoFi,
90 HiFi2,
91 HiFi3,
92 HiFi4,
93 Exact,
94}
95
96impl Fidelity {
97 /// The identifier tt-metal's `MathFidelity::` enum uses — also the token on the `LOGOS_MM_PASS` line
98 /// and the value of `LOGOS_TT_FIDELITY`.
99 pub fn as_str(self) -> &'static str {
100 match self {
101 Fidelity::LoFi => "LoFi",
102 Fidelity::HiFi2 => "HiFi2",
103 Fidelity::HiFi3 => "HiFi3",
104 Fidelity::HiFi4 => "HiFi4",
105 Fidelity::Exact => "Exact",
106 }
107 }
108 /// Parse that identifier.
109 pub fn parse(s: &str) -> Option<Fidelity> {
110 match s {
111 "LoFi" => Some(Fidelity::LoFi),
112 "HiFi2" => Some(Fidelity::HiFi2),
113 "HiFi3" => Some(Fidelity::HiFi3),
114 "HiFi4" => Some(Fidelity::HiFi4),
115 "Exact" => Some(Fidelity::Exact),
116 _ => None,
117 }
118 }
119}
120
121/// The three numerics knobs the measurement harness varies, and the PCC the program then requires of
122/// itself. `scripts/tt-matmul-measure.sh` sets them as `LOGOS_TT_FIDELITY` / `_FP32` / `_L1ACC`, and every
123/// ledger row records what the program PRINTED rather than what was asked for — so this parser is part of
124/// the measurement contract, not a convenience.
125///
126/// ★ SALVAGED FROM THE RETIRED EMITTER, and narrowed on the way in. It used to carry its own math-fidelity
127/// enum beside this crate's [`Fidelity`] — two spellings of one concept, each with its own `parse`/`as_str`
128/// — and a register-budget helper that halved the tile count under fp32 accumulate while taking a SECOND
129/// chip descriptor to do it. [`SpatialTarget::effective_acc_tiles`] already expresses that exactly, so the
130/// dependency on a duplicate description of the part dissolved rather than moved.
131#[derive(Clone, Copy, Debug, PartialEq)]
132pub struct NumericsKnobs {
133 pub fidelity: Fidelity,
134 /// Accumulate in fp32 in the register file: halves the tile budget, raises PCC.
135 pub fp32_dest_acc: bool,
136 /// Accumulate K-block partials in local memory through the packer instead of reloading registers.
137 pub packer_l1_acc: bool,
138 /// The PCC the emitted program requires of itself before it prints `LOGOS_MM_PASS`.
139 pub pcc_floor: f64,
140}
141
142impl Default for NumericsKnobs {
143 /// ★ ttnn's OWN bf16 default numerics — HiFi2, bf16 dest-accumulate — so a head-to-head against the
144 /// vendor is matched by construction rather than by remembering to pass a flag. Defaulting to HiFi4
145 /// would silently compare our four-pass arithmetic against the vendor's two-pass and call the
146 /// difference a compiler result.
147 fn default() -> Self {
148 NumericsKnobs {
149 fidelity: Fidelity::HiFi2,
150 fp32_dest_acc: false,
151 packer_l1_acc: false,
152 pcc_floor: 0.99,
153 }
154 }
155}
156
157impl NumericsKnobs {
158 /// Read the harness's environment contract.
159 pub fn from_env() -> Result<Self, String> {
160 let get = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
161 Self::from_vars(
162 get("LOGOS_TT_FIDELITY").as_deref(),
163 get("LOGOS_TT_FP32").as_deref(),
164 get("LOGOS_TT_L1ACC").as_deref(),
165 )
166 }
167
168 /// The pure core of [`NumericsKnobs::from_env`] — every refusal is typed and names the variable, so a
169 /// mistyped knob stops the measurement instead of silently recording a different program.
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 = NumericsKnobs::default();
176 if let Some(f) = fidelity {
177 k.fidelity = Fidelity::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 | Some("0") => Ok(false),
184 Some("1") => Ok(true),
185 Some(other) => Err(format!("{name} must be 0|1, got {other:?}")),
186 }
187 };
188 k.fp32_dest_acc = flag("LOGOS_TT_FP32", fp32)?;
189 k.packer_l1_acc = flag("LOGOS_TT_L1ACC", l1acc)?;
190 Ok(k)
191 }
192
193 /// The numerics policy these knobs express: fp32 accumulate is what keeps partial sums exact.
194 pub fn policy(&self) -> crate::spatial::ir::NumericsPolicy {
195 if self.fp32_dest_acc {
196 crate::spatial::ir::NumericsPolicy::Exact
197 } else {
198 crate::spatial::ir::NumericsPolicy::AllowSpillRounding
199 }
200 }
201}
202
203/// Which operand stays resident in the compute engine while the other streams.
204#[derive(
205 Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, serde::Deserialize,
206)]
207pub enum DataflowPolicy {
208 OutputStationary,
209 WeightStationary,
210}
211
212/// How a matrix engine's sub-block API runs one compute op over an `osh × osw` sub-block and its `ibw` inner
213/// steps: the calls it makes and the operand tiles it loads.
214#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
215pub enum BlockApi {
216 /// One call per inner step. The unpacker holds one operand tile along the sub-block's shorter side and
217 /// re-loads the other operand's tiles for each: `min(osh, osw) + osh · osw` tiles a step. Tensix's
218 /// `matmul_block` (`llk_unpack_AB_matmul`: `reuse_a = ct_dim >= rt_dim`, `t_dim` held tiles, each running
219 /// the other operand's row of the block).
220 Unpacker,
221 /// One call over the whole inner block, its A and B panels loaded once: `osh · ibw + ibw · osw` tiles. The
222 /// fabric's systolic array, which pays its fill once per call.
223 Systolic,
224 /// One fragment step per inner step. Each lane of the accumulator grid
225 /// ([`SpatialTarget::accumulator_lane_grid`]) loads its own A rows and B columns: `wn · osh + wm · osw` tiles
226 /// a step on a `wm × wn` grid, and two per output tile where no grid splits the sub-block evenly. The GPU's
227 /// `wmma` loop.
228 Fragments,
229}
230
231/// Whether a matrix engine's packer can ADD into a local-memory page instead of overwriting it.
232#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
233pub enum PackerAccumulate {
234 /// The packer only overwrites.
235 No,
236 /// The packer adds into a local-memory page, so a blocked reduction's partial sum stays in local memory
237 /// between K blocks instead of round-tripping through the register file each block.
238 InLocalMemory,
239 /// …and the page it adds into can be the OUTPUT's: the intermediate and the output share one local region
240 /// (two indices of one tt-metal `CircularBufferConfig`, Tensix `pack_reconfig_l1_acc`), so a reduction
241 /// whose partial already is the result finishes in the page the writer stores, with no reload.
242 IntoTheOutput,
243}
244
245/// A compute capability a site offers.
246#[derive(Clone, Debug, PartialEq, Serialize)]
247pub enum ComputeCap {
248 MatmulTile {
249 precisions: BTreeSet<Precision>,
250 fidelity: Fidelity,
251 fp32_acc: bool,
252 dataflows: BTreeSet<DataflowPolicy>,
253 /// The engine accepts a whole sub-block per call (Tensix `matmul_block`), by this API; `None` takes one
254 /// tile pair per call, two loads.
255 block_api: Option<BlockApi>,
256 /// Accumulator width of the integer path (64 on the exact fabrics, 32 on a tensor core).
257 acc_int_bits: u32,
258 /// The largest PE array the engine builds, `(contraction rows, output columns)`: a matmul's block is cut to it
259 /// and its contraction runs across several passes. `None` where each matmul's block sets the array (a fabric
260 /// sized per program) or the engine's tile does (Tensix, a tensor core).
261 max_array: Option<(usize, usize)>,
262 /// Whether the packer can ADD into a local-memory page instead of overwriting it, and into which.
263 packer_accumulate: PackerAccumulate,
264 /// A spilled partial in the accumulator's own width can be reloaded into the register file. On
265 /// Wormhole and Blackhole under this tt-metal build the simulator refuses the Float32 unpack that
266 /// reload needs (`tensix_unpacr: unpack_to_dst=0 in_data_format=0 out_data_format=0`) even with the
267 /// buffer marked `UnpackToDestFp32`, so an EXACT blocked plan may not spill there — it keeps its
268 /// partials in the register file (one K block) or streams.
269 wide_spill_reload: bool,
270 /// ★ THE ACCUMULATOR LIVES IN THE COMPUTE ENGINE'S OWN REGISTERS and survives the WHOLE
271 /// reduction, so a blocked plan never spills a partial at all.
272 ///
273 /// This is the GPU: a `wmma` fragment is register-resident and the K loop runs inside the
274 /// accumulate region, which is exactly what the CUDA lowering emits (and why it refuses an
275 /// accumulator spill outright — "the fragments never leave the registers").
276 ///
277 /// Tensix is the other case: DST is a shared register file that must be DRAINED per
278 /// sub-block, so a multi-block reduction spills its partial to local memory and reloads
279 /// it. Conflating the two made every blocked GPU plan illegal under exact numerics — the
280 /// spill it was refused for is one it would never perform — leaving only the streaming
281 /// family, which computes ONE output tile per item and measured 214 GOPS against the knob
282 /// lattice's 16,591 at 2048³.
283 acc_in_registers: bool,
284 },
285 Elementwise {
286 precisions: BTreeSet<Precision>,
287 },
288 RowReduce {
289 precisions: BTreeSet<Precision>,
290 },
291 /// A per-site scalar datapath — the CGRA descriptor, embedded.
292 Scalar(FabricTarget),
293}
294
295/// The latencies a bounded buffer's credit loop is sized against, in cycles.
296///
297/// These are ARCHITECTURAL facts about a part, not fitted ones, and the distinction is the whole point of the
298/// type. A FIFO depth is a resource decision — too shallow stalls the producer every round, too deep spends
299/// local memory another buffer needed — and deriving it from a regression against a cycle counter makes it
300/// only as trustworthy as that counter. Ours turned out not to track work at all, which would have made every
301/// depth a function of noise. A target that cannot cite these numbers declares `None` and gets the vendor's
302/// double-buffering discipline instead of an invented rate.
303#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
304pub struct CreditLatency {
305 /// Cycles to fetch one tile into local memory.
306 pub fetch_tile: u32,
307 /// Cycles for a consumer's credit to travel back to the producer.
308 pub credit_return: u32,
309 /// Cycles the consumer spends draining one page.
310 pub service_page: u32,
311}
312
313/// What one site holds.
314#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
315pub struct SiteSpec {
316 /// Local memory budget for buffers (L1 / shared memory / BRAM), in bytes.
317 pub local_mem_bytes: usize,
318 /// A larger budget available on explicit opt-in (a GPU's dynamic shared memory), if any.
319 pub local_mem_opt_in_bytes: Option<usize>,
320 /// Accumulator capacity in native tiles (DST register file / register fragments / systolic columns).
321 pub acc_tiles: usize,
322 /// Accumulating in fp32 halves the accumulator capacity (Tensix DST).
323 pub fp32_acc_halves_acc_tiles: bool,
324 /// Independent data-movement engines per site (Tensix: RISCV_0 + RISCV_1).
325 pub movement_engines: u8,
326 /// Network-on-chip channels a site can drive.
327 pub nocs: u8,
328 /// Declared credit-loop latencies, when the part's are known. `None` means undeclared, NOT zero.
329 pub credit: Option<CreditLatency>,
330 /// Cycles from a read address to its data in the site's local memory, where the lowering schedules each read
331 /// itself: `Some(0)` for a flop array read combinationally, `Some(1)` for a synchronous SRAM or block RAM. `None`
332 /// where the hardware sequences its memory reads and the lowering never sees their timing.
333 pub local_mem_read_latency: Option<u32>,
334 /// The banks the site's local memory interleaves its elements over, element `e` in bank `e mod banks`: a power of
335 /// two, the consecutive elements one cycle reads or writes wherever the run starts. 1 where a memory serves an
336 /// element a cycle, or the hardware sequences its own accesses.
337 pub mem_banks: usize,
338 /// The lanes of the site's vector unit: the widest run of consecutive coordinates its Generic unit takes a
339 /// point, a power of two no wider than the banks, sized to the part as its PE array is — each lane is a copy of
340 /// every body the unit computes. 1 where the site has no vector unit.
341 #[serde(default = "one_lane")]
342 pub vector_lanes: usize,
343 /// The one integer width the site's elementwise datapath computes in, where an integer it stores is the integer it
344 /// computes and nothing narrower widens on its way in: an RDU's PCU computes integers in its index datatype
345 /// (`RduChipFacts::index_datatype`, INT32). `None` where a narrower stored integer widens as it is read — a GPU's
346 /// registers, the fabric's sign extension — so a buffer may be stored at any integer the datapath lists.
347 #[serde(default)]
348 pub int_compute: Option<Precision>,
349 /// The widest signed product one multiplier slice of the site takes, `(wide, narrow)` operand bits: a 7-series
350 /// DSP48E1's 25 × 18. Synthesis splits a wider product over a cascade of slices. `None` where the site's multiplies
351 /// are no fixed slices — standard cells, a GPU's units.
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub multiplier: Option<(u32, u32)>,
354}
355
356fn one_lane() -> usize {
357 1
358}
359
360/// The NOC-direction convention a mesh imposes on multicast roles — measured, not chosen: on Wormhole the
361/// in0 senders must multicast on NOC 0 and the receivers listen on NOC 1 (all-on-one-NOC inverted the
362/// multicast rectangle under ttsim); the writer takes the complement, except the first column.
363#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
364pub struct NocRule {
365 pub sender_noc: u8,
366 pub receiver_noc: u8,
367 pub writer_noc_default: u8,
368 pub writer_noc_first_column: u8,
369}
370
371/// Whether NOC writes retire in issue order (Wormhole) or need an explicit flush before a dependent
372/// semaphore multicast (Blackhole).
373#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
374pub enum WriteOrdering {
375 Ordered,
376 NeedsFlush,
377}
378
379/// The described mesh.
380#[derive(Clone, Debug, PartialEq, Serialize)]
381pub struct SpatialTarget {
382 pub name: &'static str,
383 pub sites: MeshShape,
384 pub site: SiteSpec,
385 pub tile: TileShape,
386 pub interconnect: BTreeSet<Interconnect>,
387 pub handshakes: BTreeSet<HandshakeKind>,
388 pub compute: Vec<ComputeCap>,
389 pub dram: MemHierarchy,
390 pub precisions: BTreeSet<Precision>,
391 pub noc_rule: Option<NocRule>,
392 pub write_ordering: WriteOrdering,
393 pub cycle: CycleModel,
394 /// Fields whose values are MODELED (no public datasheet number) — pinned by the target lock so the
395 /// descriptor never overstates what it knows.
396 pub modeled: &'static [&'static str],
397 /// The rates the chip sustains, each cited: what a program's work is held against.
398 pub roofs: Roofs,
399 /// The programmable part this fabric is printed onto, when it is one: what the lowering spends as a resource
400 /// budget. `None` for an abstract target (a GPU roofline, an RDU, the sky fabric) that no budget bounds.
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub part: Option<PartResources>,
403}
404
405/// Bytes per element of a precision.
406pub fn precision_bytes(p: Precision) -> usize {
407 match p {
408 Precision::Int8 | Precision::Fp8 => 1,
409 Precision::Int16 | Precision::Bf16 | Precision::Fp16 => 2,
410 Precision::Int32 | Precision::Fp32 => 4,
411 Precision::Int64 => 8,
412 }
413}
414
415/// Whether a precision is an integer format.
416pub fn precision_is_int(p: Precision) -> bool {
417 matches!(
418 p,
419 Precision::Int8 | Precision::Int16 | Precision::Int32 | Precision::Int64
420 )
421}
422
423/// Bit width of an integer precision (the operand truncation the systolic reference applies).
424pub fn precision_int_bits(p: Precision) -> Option<u32> {
425 match p {
426 Precision::Int8 => Some(8),
427 Precision::Int16 => Some(16),
428 Precision::Int32 => Some(32),
429 Precision::Int64 => Some(64),
430 _ => None,
431 }
432}
433
434/// The signed integer precision of exactly `bits` bits, if the dial has one.
435pub fn int_precision_of_bits(bits: u32) -> Option<Precision> {
436 match bits {
437 8 => Some(Precision::Int8),
438 16 => Some(Precision::Int16),
439 32 => Some(Precision::Int32),
440 64 => Some(Precision::Int64),
441 _ => None,
442 }
443}
444
445/// The largest value a signed integer precision holds.
446pub fn int_precision_max(p: Precision) -> Option<i64> {
447 precision_int_bits(p).map(|b| {
448 if b >= 64 {
449 i64::MAX
450 } else {
451 (1i64 << (b - 1)) - 1
452 }
453 })
454}
455
456impl SpatialTarget {
457 /// Tenstorrent Wormhole (n150/n300): 8×8 worker grid, 1464 KiB L1 per Tensix, an 8-tile DST that halves
458 /// under fp32 accumulate, two data-movement RISC-Vs, two NOCs, rectangle multicast. The matmul FPU is
459 /// float-primary — `Int8` is deliberately ABSENT from the tile precisions (int8 `matmul_tiles` measured
460 /// 0/4096 correct under ttsim on both Wormhole and Blackhole); int8 GEMMs run as bf16 + SFPU requant.
461 pub fn wormhole() -> SpatialTarget {
462 SpatialTarget {
463 name: "wormhole_b0",
464 sites: MeshShape { x: 8, y: 8 },
465 site: SiteSpec {
466 local_mem_bytes: 1464 * 1024,
467 local_mem_opt_in_bytes: None,
468 acc_tiles: 8,
469 fp32_acc_halves_acc_tiles: true,
470 movement_engines: 2,
471 nocs: 2,
472 credit: None,
473 local_mem_read_latency: None,
474 mem_banks: 1,
475 vector_lanes: 1,
476 int_compute: None,
477 multiplier: None,
478 },
479 tile: TileShape::TENSIX,
480 interconnect: [Interconnect::MulticastRect, Interconnect::GlobalMemory]
481 .into_iter()
482 .collect(),
483 handshakes: [HandshakeKind::Fifo, HandshakeKind::Semaphore]
484 .into_iter()
485 .collect(),
486 compute: vec![
487 ComputeCap::MatmulTile {
488 precisions: [Precision::Bf16, Precision::Fp16].into_iter().collect(),
489 fidelity: Fidelity::HiFi4,
490 fp32_acc: true,
491 dataflows: [DataflowPolicy::OutputStationary].into_iter().collect(),
492 block_api: Some(BlockApi::Unpacker),
493 // ★ IT CANNOT RELOAD A WIDE SPILL, and reconfiguring the unpacker does not change
494 // that. RE-MEASURED 2026-09-10, and the measurement is the whole point.
495 //
496 // This was flipped to `true` on the reasoning that the garbage reload (PCC 0.165 at
497 // 640³) was a property of the LOWERING rather than the silicon, since the reload had
498 // learned to emit `copy_tile_to_dst_init_short_with_dt` and to put source A back with
499 // `reconfig_data_format_srca`. The lowering does emit both — and the device still
500 // refuses. Running the exact-numerics 640³ plan the flip unlocked:
501 //
502 // LOGOS_MM_CFG strategy=blocked-split grid=5x5 per_core=4x4 in0_block_w=4
503 // [4907] ERROR: UndefinedBehavior:
504 // tensix_unpacr: unpack_to_dst=0 in_data_format=0 out_data_format=0
505 //
506 // — the same refusal `spatial_choreography_semantics_lock` recorded, unchanged. The
507 // consequence of the flip was not a faster plan, it was a program the device REJECTS:
508 // every exact-numerics matmul big enough to block emitted a kernel that aborts at
509 // runtime. Falling back to the streaming family is not a lost optimization, it is the
510 // only family that runs.
511 //
512 // A backend returning `Ok` proves a program was PRINTED. Only the simulator says
513 // whether it runs — and nothing in the type system could have caught this.
514 wide_spill_reload: false,
515 acc_in_registers: false,
516 packer_accumulate: PackerAccumulate::IntoTheOutput,
517 acc_int_bits: 32,
518 max_array: None,
519 },
520 ComputeCap::Elementwise {
521 precisions: [Precision::Bf16, Precision::Fp16, Precision::Fp32]
522 .into_iter()
523 .collect(),
524 },
525 ComputeCap::RowReduce {
526 precisions: [Precision::Bf16, Precision::Fp16, Precision::Fp32]
527 .into_iter()
528 .collect(),
529 },
530 ComputeCap::Scalar(FabricTarget::wearable()),
531 ],
532 dram: MemHierarchy {
533 sram_kib: 64 * 1464,
534 hbm_gib: 0,
535 ddr_gib: 12,
536 },
537 precisions: [
538 Precision::Bf16,
539 Precision::Fp16,
540 Precision::Fp32,
541 Precision::Int8,
542 ]
543 .into_iter()
544 .collect(),
545 noc_rule: Some(NocRule {
546 sender_noc: 0,
547 receiver_noc: 1,
548 writer_noc_default: 0,
549 writer_noc_first_column: 1,
550 }),
551 write_ordering: WriteOrdering::Ordered,
552 cycle: CycleModel::wormhole_ttsim(),
553 modeled: &[],
554 roofs: Roofs::wormhole(),
555 part: None,
556 }
557 }
558
559 /// Tenstorrent Blackhole: 13×10 compute-with-storage grid, 1536 KiB of L1 per Tensix, eight DRAM
560 /// channels of 3.984 GiB, and NOC writes that need an explicit flush before a dependent semaphore
561 /// multicast. The accumulator is the same eight-tile half-sync window as Wormhole's.
562 ///
563 /// ★ `..wh` says "the same part, these fields different", which is true of the NOC rule, the
564 /// precisions and the compute capabilities — and was silently ALSO true of Wormhole's L1 size, its
565 /// SRAM total and its DRAM capacity, none of which are Blackhole's. `size_buffers` shrinks a program
566 /// to `local_mem_bytes` and `tile.rs` blocks against it, so inheriting 1464 KiB cost this chip 72 KiB
567 /// of usable L1 on every program it ever compiled. The three lines below are read out of the vendor's
568 /// `blackhole_140_arch.yaml` and held there by `spatial_chip_facts_lock`.
569 pub fn blackhole() -> SpatialTarget {
570 let wh = SpatialTarget::wormhole();
571 let sites = MeshShape { x: 13, y: 10 };
572 let l1_kib = 1536;
573 SpatialTarget {
574 name: "blackhole",
575 sites,
576 // ★ THE ACCUMULATOR IS THE HALF-SYNC WINDOW, AND IT IS THE SAME EIGHT ON BOTH PARTS.
577 //
578 // Blackhole's DST is not bigger than Wormhole's: the vendor's `tensix_types.h` gives both
579 // parts a 16x16 dest face, so `DEST_NUM_TILES_FP16` is 16 on each and the half is 8. Our
580 // compute kernels bracket their math with `tile_regs_acquire`/`tile_regs_commit`, which is
581 // `DstSync::SyncHalf` — the packer drains one half while math fills the other — so a single
582 // acquire reaches eight tiles, never sixteen.
583 //
584 // This said 16, and nothing caught it until a shape appeared whose plan could USE more than
585 // eight: at 2048x1024x512 the planner chose a 4x4 sub-block, the kernel packed sixteen tiles
586 // through an eight-tile window, and the program returned PCC 0.872 having retired EXACTLY the
587 // right arithmetic. 640^3 passed on the same chip only because its sub-block was four tiles.
588 // Held to the vendor header by `spatial_chip_facts_lock`.
589 site: SiteSpec {
590 local_mem_bytes: l1_kib * 1024,
591 ..wh.site
592 },
593 dram: MemHierarchy {
594 sram_kib: sites.sites() * l1_kib,
595 hbm_gib: 0,
596 ddr_gib: 31,
597 },
598 write_ordering: WriteOrdering::NeedsFlush,
599 cycle: CycleModel::uncalibrated(),
600 // The descriptor publishes 140 functional workers; 13×10 is the compute-with-storage grid the
601 // part exposes. The DST depth is no longer modelled — it is read from the vendor's
602 // `tensix_types.h` and locked.
603 modeled: &["sites"],
604 roofs: Roofs::blackhole(),
605 ..wh
606 }
607 }
608
609 /// Our own Sky130/FPGA fabric: ONE site (the whole array), nearest-neighbour wires, valid/ready and
610 /// FIFO handshakes, an exact-arithmetic tile engine (the proven weight-/output-stationary systolic
611 /// array), and the embedded CGRA scalar datapath `FabricTarget::full(rows, cols, 64)`.
612 pub fn sky_fabric(rows: usize, cols: usize) -> SpatialTarget {
613 let scalar = FabricTarget::full(rows, cols, 64);
614 SpatialTarget {
615 name: "sky130-fabric",
616 sites: MeshShape { x: 1, y: 1 },
617 site: SiteSpec {
618 local_mem_bytes: scalar.mem_depth * 8,
619 local_mem_opt_in_bytes: None,
620 acc_tiles: cols,
621 fp32_acc_halves_acc_tiles: false,
622 movement_engines: 1,
623 nocs: 0,
624 credit: None,
625 local_mem_read_latency: Some(0),
626 mem_banks: 1,
627 vector_lanes: 1,
628 int_compute: None,
629 multiplier: None,
630 },
631 tile: TileShape::SCALAR,
632 interconnect: [Interconnect::NearestNeighbour].into_iter().collect(),
633 handshakes: [HandshakeKind::ValidReady, HandshakeKind::Fifo]
634 .into_iter()
635 .collect(),
636 compute: vec![
637 ComputeCap::MatmulTile {
638 precisions: [
639 Precision::Int8,
640 Precision::Int16,
641 Precision::Int64,
642 Precision::Bf16,
643 ]
644 .into_iter()
645 .collect(),
646 fidelity: Fidelity::Exact,
647 fp32_acc: true,
648 dataflows: [
649 DataflowPolicy::WeightStationary,
650 DataflowPolicy::OutputStationary,
651 ]
652 .into_iter()
653 .collect(),
654 // The systolic array runs one sub-block over its whole inner block per call: the lowering issues
655 // one `Matmul` per compute op, whatever the granularity, and each call pays the array's fill.
656 block_api: Some(BlockApi::Systolic),
657 wide_spill_reload: true,
658 acc_in_registers: false,
659 packer_accumulate: PackerAccumulate::InLocalMemory,
660 acc_int_bits: 64,
661 max_array: None,
662 },
663 ComputeCap::Elementwise {
664 precisions: [Precision::Int8, Precision::Int16, Precision::Int64]
665 .into_iter()
666 .collect(),
667 },
668 ComputeCap::Scalar(scalar.clone()),
669 ],
670 dram: scalar.mem,
671 precisions: [
672 Precision::Int8,
673 Precision::Int16,
674 Precision::Int64,
675 Precision::Bf16,
676 ]
677 .into_iter()
678 .collect(),
679 noc_rule: None,
680 write_ordering: WriteOrdering::Ordered,
681 cycle: CycleModel::uncalibrated(),
682 modeled: &["acc_tiles"],
683 roofs: Roofs::sky_fabric(),
684 part: None,
685 }
686 }
687
688 /// Our fabric on the Artix-7 XC7A100T of the Nexys A7-100T: [`sky_fabric`](Self::sky_fabric)'s machine with its
689 /// local memory in the part's block RAM — 135 blocks of 36 Kb (Xilinx DS180, Table 3) — read one cycle after its
690 /// address, as a block RAM's registered read port returns it, and an 8×8 PE array: 64 of the part's 240 DSP48
691 /// slices (DS180, Table 3).
692 pub fn xc7a100t_fabric(rows: usize, cols: usize) -> SpatialTarget {
693 SpatialTarget::xc7_fabric("xc7a100t-fabric", PartResources::xc7a100t(), 8, 8, rows, cols)
694 }
695
696 /// Our fabric on the Artix-7 XC7A200T of the Nexys Video: 365 blocks of 36 Kb, read as
697 /// [`xc7a100t_fabric`](Self::xc7a100t_fabric) reads its own, and a 16×16 PE array: 256 of the part's 740 DSP48
698 /// slices (DS180, Table 3).
699 pub fn xc7a200t_fabric(rows: usize, cols: usize) -> SpatialTarget {
700 SpatialTarget::xc7_fabric("xc7a200t-fabric", PartResources::xc7a200t(), 16, 16, rows, cols)
701 }
702
703 /// This target on boards joined by a link: each board one chip of a tensor-parallel program, the link the hand-off
704 /// a partial or an activation crosses from one to the other, a token with each word. A chip boundary needs that
705 /// hand-off ([`HandshakeKind::Token`]); a hand-off on one board keeps its own.
706 pub fn linked(mut self) -> SpatialTarget {
707 self.handshakes.insert(HandshakeKind::Token);
708 self
709 }
710
711 /// A 7-series part's fabric: each 36 Kb block holds 32 Kb of data beside its 4 Kb of parity, the PE array is
712 /// `array × array`, a matmul's block cut to it, and each memory is as many banks as the array has columns, so a row
713 /// of operands moves in a cycle.
714 fn xc7_fabric(name: &'static str, part: PartResources, array: usize, lanes: usize, rows: usize, cols: usize) -> SpatialTarget {
715 let sky = SpatialTarget::sky_fabric(rows, cols);
716 let mut compute = sky.compute.clone();
717 for c in compute.iter_mut() {
718 if let ComputeCap::MatmulTile { max_array, .. } = c {
719 *max_array = Some((array, array));
720 }
721 }
722 let blocks = part.memory(MemoryKind::Block36).expect("a 7-series part has block RAM").count.value as usize;
723 let (mw, mh) = part.multiplier.value;
724 SpatialTarget {
725 name,
726 site: SiteSpec {
727 local_mem_bytes: blocks * 4096,
728 local_mem_read_latency: Some(1),
729 mem_banks: array,
730 vector_lanes: lanes,
731 int_compute: None,
732 multiplier: Some((mw, mh)),
733 ..sky.site
734 },
735 compute,
736 roofs: Roofs::xc7_fabric(),
737 part: Some(part),
738 ..sky
739 }
740 }
741
742 /// The SambaNova SN40L RDU, derived from [`RduChipFacts::sn40l`](super::rdu_chip::RduChipFacts::sn40l):
743 /// 1040 PCUs (the sites — the published `compute_tiles`), 520 MiB of on-chip SRAM spread over them, a
744 /// crossbar switch fabric, token / valid-ready / barrier handshakes, and the embedded
745 /// `FabricTarget::rdu_shaped()` scalar datapath. The mesh aspect, the per-PCU accumulator depth and the
746 /// tile geometry are not public — listed in `modeled`, and cited on the facts' unit model.
747 pub fn sn40l_rdu() -> SpatialTarget {
748 super::rdu_chip::RduChipFacts::sn40l().spatial_target()
749 }
750
751 /// The SambaNova SN50 RDU, derived from [`RduChipFacts::sn50`](super::rdu_chip::RduChipFacts::sn50):
752 /// 432 MB of on-chip SRAM, 64 GB HBM2E, DDR5 up to 512 GB, the same crossbar / handshake / compute
753 /// envelope as the SN40L, and the embedded `FabricTarget::sn50_shaped()` scalar datapath. The PCU/PMU
754 /// counts are not public, so the mesh, the per-site memory and the silicon tile figures join the
755 /// `modeled` list beside the aspect, the accumulator and the tile.
756 pub fn sn50_rdu() -> SpatialTarget {
757 super::rdu_chip::RduChipFacts::sn50().spatial_target()
758 }
759
760 /// The local RTX 3060 (sm_86): 28 SMs, each site a persistent thread block with 48 KiB static shared
761 /// memory (99 KiB on opt-in, from the CUDA chip descriptor), 16×16 tensor-core fragments, one producer
762 /// engine (cp.async warps), intra-block shared memory + global memory only (no clusters / DSMEM on
763 /// sm_86), pipeline barriers. The accumulator fragment budget per warp is modeled.
764 pub fn rtx3060() -> SpatialTarget {
765 let chip = crate::codegen_cuda::chip::CudaChip::rtx3060();
766 SpatialTarget {
767 name: "rtx3060",
768 // ★ SITES ARE CUDA BLOCKS, NOT SMs — and there must be far more than SMs.
769 //
770 // One block per SM is 8 warps of the 48 an Ampere SM holds: 17% occupancy, with nothing
771 // to run while a load is outstanding. A GPU hides DRAM latency by having OTHER WARPS,
772 // and a block here needs only ~16 KiB of shared memory, so many fit. Sizing the mesh at
773 // the SM count is a Tensix idea — there a site is a physical core — and on a GPU it
774 // left most of the machine idle.
775 //
776 // MEASURED at 2048³ (the ratchet's own shape), everything else equal:
777 // 28 → 2,397 GOPS · 112 → 5,389 · 168 → 5,370 · 224 → 5,366 · 448 → 5,848
778 // The knee is at ~4 blocks per SM; past that the grid simply exceeds the item count and
779 // the extra sites stand empty, which costs nothing. 448 is chosen so a GEMM's items are
780 // never artificially serialised onto too few blocks.
781 //
782 // Adjacency carries no meaning here — no multicast — so `place` spreads items across
783 // them linearly rather than by the 2-D map a physical mesh needs.
784 sites: MeshShape { x: 448, y: 1 },
785 site: SiteSpec {
786 local_mem_bytes: 48 * 1024,
787 local_mem_opt_in_bytes: Some(chip.smem_kib_per_block * 1024),
788 acc_tiles: 64,
789 fp32_acc_halves_acc_tiles: false,
790 movement_engines: 1,
791 nocs: 0,
792 credit: None,
793 local_mem_read_latency: None,
794 mem_banks: 1,
795 vector_lanes: 1,
796 int_compute: None,
797 multiplier: None,
798 },
799 tile: TileShape::WMMA16,
800 interconnect: [Interconnect::SharedMemoryBlock, Interconnect::GlobalMemory]
801 .into_iter()
802 .collect(),
803 handshakes: [
804 HandshakeKind::Barrier,
805 HandshakeKind::Fifo,
806 HandshakeKind::Semaphore,
807 ]
808 .into_iter()
809 .collect(),
810 compute: vec![
811 ComputeCap::MatmulTile {
812 precisions: [Precision::Bf16, Precision::Fp16, Precision::Int8]
813 .into_iter()
814 .collect(),
815 fidelity: Fidelity::Exact,
816 fp32_acc: true,
817 dataflows: [DataflowPolicy::OutputStationary].into_iter().collect(),
818 block_api: Some(BlockApi::Fragments),
819 wide_spill_reload: false,
820 acc_in_registers: true,
821 packer_accumulate: PackerAccumulate::No,
822 acc_int_bits: 32,
823 max_array: None,
824 },
825 ComputeCap::Elementwise {
826 precisions: chip.precisions.iter().copied().collect(),
827 },
828 ComputeCap::RowReduce {
829 precisions: chip.precisions.iter().copied().collect(),
830 },
831 ],
832 dram: MemHierarchy {
833 sram_kib: 28 * 128,
834 hbm_gib: 0,
835 ddr_gib: 12,
836 },
837 precisions: chip.precisions.iter().copied().collect(),
838 noc_rule: None,
839 write_ordering: WriteOrdering::Ordered,
840 cycle: CycleModel::uncalibrated(),
841 modeled: &["acc_tiles"],
842 roofs: Roofs::rtx3060(),
843 part: None,
844 }
845 }
846
847 pub fn provides_handshake(&self, h: HandshakeKind) -> bool {
848 self.handshakes.contains(&h)
849 }
850
851 pub fn provides_interconnect(&self, i: Interconnect) -> bool {
852 self.interconnect.contains(&i)
853 }
854
855 /// The target can reload a spilled partial of the accumulator's own width.
856 pub fn reloads_wide_spills(&self) -> bool {
857 matches!(
858 self.matmul_cap(),
859 Some(ComputeCap::MatmulTile {
860 wide_spill_reload: true,
861 ..
862 })
863 )
864 }
865
866 /// The accumulator survives the whole reduction in the compute engine's own registers, so a
867 /// blocked plan never spills a partial — see [`ComputeCap::MatmulTile::acc_in_registers`].
868 pub fn accumulator_in_registers(&self) -> bool {
869 matches!(
870 self.matmul_cap(),
871 Some(ComputeCap::MatmulTile {
872 acc_in_registers: true,
873 ..
874 })
875 )
876 }
877
878 /// Independent accumulator LANES a site runs a block's tiles across — CUDA warps on a GPU, one
879 /// elsewhere (a Tensix site has a single DST register file).
880 /// ★ HOW MANY SITES OF THIS FOOTPRINT A PHYSICAL UNIT HOLDS AT ONCE — the price of a deeper stage.
881 ///
882 /// On Tensix a site IS a core, so the answer is one and a deeper contraction stage costs nothing but
883 /// L1. On a GPU a site is a thread block and an SM holds several; the number is set by whichever of
884 /// registers and shared memory runs out first, and THAT is what makes depth a trade rather than a win.
885 ///
886 /// `tile::best_candidate_for` ranks register-accumulator plans by arithmetic intensity, and
887 /// `intensity_key` cancels the contraction depth exactly — so depth fell to a tiebreak that maximized
888 /// it, reasoning that "a deeper stage amortises the pipeline round-trip over more multiplies". True,
889 /// and silent about the price. MEASURED at 2048³ bf16 on an RTX 3060, one 128×128 output tile:
890 ///
891 /// ibw 1 (K=16): 24 KiB, 2 blocks/SM → 5,429 GOPS
892 /// ibw 2 (K=32): 40 KiB, 2 blocks/SM → 5,743 GOPS ← the deepest stage that keeps its occupancy
893 /// ibw 4 (K=64): 72 KiB, 1 block/SM → 4,102 GOPS
894 ///
895 /// The amortisation is real and the occupancy cliff is bigger. K=32 is also exactly the `BK` the
896 /// hand-tuned knob lattice picks for this shape, which is the reassurance that the rule is the
897 /// machine's and not ours.
898 ///
899 /// The constants are the CUDA lowering's own (64K registers and 100 KiB of shared memory per SM); this
900 /// is where they live so the planner and the emitter cannot disagree about occupancy — a duplicate of
901 /// this rule would drift the first time either moved.
902 pub fn resident_sites_per_unit(&self, footprint_bytes: usize, osh: usize, osw: usize) -> usize {
903 if !self.accumulator_in_registers() {
904 return 1;
905 }
906 const REGS_PER_UNIT: usize = 65536;
907 const SMEM_PER_UNIT: usize = 100 * 1024;
908 let lanes = self.accumulator_lanes();
909 let per_lane_tiles = (osh * osw).div_ceil(lanes).max(1);
910 let regs_est = per_lane_tiles * 8 + 48;
911 let threads = lanes * 32;
912 let by_regs = REGS_PER_UNIT / (regs_est * threads).max(1);
913 let by_smem = if footprint_bytes == 0 {
914 16
915 } else {
916 SMEM_PER_UNIT / footprint_bytes.max(1)
917 };
918 by_regs.min(by_smem).clamp(1, 16)
919 }
920
921 pub fn accumulator_lanes(&self) -> usize {
922 if self.accumulator_in_registers() {
923 8
924 } else {
925 1
926 }
927 }
928
929 /// ★ HOW A BLOCK'S `osh × osw` OUTPUT TILES SPLIT ACROSS THE SITE'S ACCUMULATOR LANES.
930 ///
931 /// A lane that owns a RECTANGLE of `(osh/wm) × (osw/wn)` tiles loads that many A-fragments and
932 /// B-fragments per contraction step and does their product — the reuse that makes a GEMM a
933 /// GEMM. The split is chosen to make the rectangle as SQUARE as possible: for a fixed area the
934 /// squarest rectangle has the smallest perimeter, and the perimeter is what is loaded.
935 ///
936 /// `None` when no split divides the block evenly. THE PLANNER MUST NOT OFFER SUCH A BLOCK on a
937 /// register-accumulator target: its lanes cannot each own a rectangle, so the epilogue cannot
938 /// write straight to global and the output has to stage through a ring the plan was not sized
939 /// for. (It was: a 13×3 block sized against a 4-tile scratch wrote 39 tiles into it, which the
940 /// device reported as an illegal access.)
941 pub fn accumulator_lane_grid(&self, osh: usize, osw: usize) -> Option<(usize, usize)> {
942 let lanes = self.accumulator_lanes();
943 let mut best: Option<(usize, usize)> = None;
944 for wm in 1..=lanes {
945 if lanes % wm != 0 {
946 continue;
947 }
948 let wn = lanes / wm;
949 if osh % wm != 0 || osw % wn != 0 {
950 continue;
951 }
952 if best.map_or(true, |(bm, bn)| osh / wm + osw / wn < osh / bm + osw / bn) {
953 best = Some((wm, wn));
954 }
955 }
956 best
957 }
958
959 /// The target's packer can accumulate into a local-memory page.
960 pub fn packer_accumulates(&self) -> bool {
961 matches!(
962 self.matmul_cap(),
963 Some(ComputeCap::MatmulTile {
964 packer_accumulate: PackerAccumulate::InLocalMemory
965 | PackerAccumulate::IntoTheOutput,
966 ..
967 })
968 )
969 }
970
971 /// The target's packer can accumulate into the output's own page, its intermediate sharing the output's memory.
972 pub fn packer_accumulates_into_the_output(&self) -> bool {
973 matches!(
974 self.matmul_cap(),
975 Some(ComputeCap::MatmulTile {
976 packer_accumulate: PackerAccumulate::IntoTheOutput,
977 ..
978 })
979 )
980 }
981
982 /// The tile-matmul capability, if the site has a matrix engine.
983
984 pub fn matmul_cap(&self) -> Option<&ComputeCap> {
985 self.compute
986 .iter()
987 .find(|c| matches!(c, ComputeCap::MatmulTile { .. }))
988 }
989
990 /// Whether the matrix engine accepts `p` as an operand precision.
991 pub fn matmul_supports(&self, p: Precision) -> bool {
992 match self.matmul_cap() {
993 Some(ComputeCap::MatmulTile { precisions, .. }) => precisions.contains(&p),
994 _ => false,
995 }
996 }
997
998 /// The same machine with its matrix engine declared at a different fidelity.
999 ///
1000 /// A Tensix core runs bf16 matmul at any of LoFi/HiFi2/HiFi3/HiFi4 — the fidelity is how many passes the
1001 /// engine makes, not a property of the part — but the descriptor pinned one value, so the pipeline could
1002 /// only ever emit HiFi4. That left us unable to emit at ttnn's OWN bf16 default (HiFi2), which is the
1003 /// configuration the vendor's tuned op actually runs, so the one knob group where a like-for-like
1004 /// head-to-head matters had nothing of ours in it at all.
1005 ///
1006 /// The refusal this unblocks is worth keeping in mind: the emitter still refuses to PRINT a fidelity it
1007 /// did not run (`cost.rs` gates on `matmul_fidelity()`), and that guard is untouched. This changes what
1008 /// the machine is declared to do, not what a program may claim about itself.
1009 pub fn at_fidelity(mut self, fidelity: Fidelity) -> SpatialTarget {
1010 for cap in &mut self.compute {
1011 if let ComputeCap::MatmulTile { fidelity: f, .. } = cap {
1012 *f = fidelity;
1013 }
1014 }
1015 self
1016 }
1017
1018 /// The fidelity the matrix engine is declared at — the one the pipeline emits.
1019 pub fn matmul_fidelity(&self) -> Option<Fidelity> {
1020 match self.matmul_cap() {
1021 Some(ComputeCap::MatmulTile { fidelity, .. }) => Some(*fidelity),
1022 _ => None,
1023 }
1024 }
1025
1026 /// Whether the matrix engine can accumulate in fp32.
1027 pub fn matmul_fp32_acc(&self) -> bool {
1028 matches!(
1029 self.matmul_cap(),
1030 Some(ComputeCap::MatmulTile { fp32_acc: true, .. })
1031 )
1032 }
1033
1034 /// Accumulator width of the matrix engine's integer path.
1035 pub fn matmul_acc_int_bits(&self) -> u32 {
1036 match self.matmul_cap() {
1037 Some(ComputeCap::MatmulTile { acc_int_bits, .. }) => *acc_int_bits,
1038 _ => 64,
1039 }
1040 }
1041
1042 /// The integer precisions a buffer is stored at, narrowest first: those the elementwise datapath lists, or — on a
1043 /// site that computes integers in one width and widens nothing ([`SiteSpec::int_compute`]) — that width alone.
1044 pub fn storable_ints(&self) -> Vec<Precision> {
1045 let ps = self.compute.iter().find_map(|c| match c {
1046 ComputeCap::Elementwise { precisions } => Some(precisions),
1047 _ => None,
1048 });
1049 [Precision::Int8, Precision::Int16, Precision::Int32, Precision::Int64]
1050 .into_iter()
1051 .filter(|p| ps.is_some_and(|ps| ps.contains(p)) && self.site.int_compute.is_none_or(|c| c == *p))
1052 .collect()
1053 }
1054
1055 /// The largest PE array the matrix engine builds, `(contraction rows, output columns)`, if it caps one.
1056 pub fn matmul_max_array(&self) -> Option<(usize, usize)> {
1057 match self.matmul_cap() {
1058 Some(ComputeCap::MatmulTile { max_array, .. }) => *max_array,
1059 _ => None,
1060 }
1061 }
1062
1063 /// Whether the matrix engine takes whole sub-blocks per call.
1064 pub fn matmul_block_api(&self) -> bool {
1065 matches!(
1066 self.matmul_cap(),
1067 Some(ComputeCap::MatmulTile {
1068 block_api: Some(_),
1069 ..
1070 })
1071 )
1072 }
1073
1074 /// The sub-block API a compute op of `granularity` runs on: the engine's own, or none where the op is handed
1075 /// a tile pair at a time.
1076 fn block_api_for(&self, granularity: super::ir::Granularity) -> Option<BlockApi> {
1077 match (granularity, self.matmul_cap()) {
1078 (
1079 super::ir::Granularity::PerSubBlock,
1080 Some(ComputeCap::MatmulTile { block_api, .. }),
1081 ) => *block_api,
1082 _ => None,
1083 }
1084 }
1085
1086 /// Calls the matrix engine makes for one compute op over `block`: by the engine's [`BlockApi`], or one per
1087 /// tile pair, `osh · osw · ibw`.
1088 pub fn matmul_calls(
1089 &self,
1090 block: super::ir::BlockShape,
1091 granularity: super::ir::Granularity,
1092 ) -> u64 {
1093 let (osh, osw, ibw) = (block.osh as u64, block.osw as u64, block.ibw as u64);
1094 match self.block_api_for(granularity) {
1095 Some(BlockApi::Unpacker) | Some(BlockApi::Fragments) => ibw,
1096 Some(BlockApi::Systolic) => 1,
1097 None => osh * osw * ibw,
1098 }
1099 }
1100
1101 /// Operand tiles the matrix engine loads for one compute op over `block`: by the engine's [`BlockApi`], or
1102 /// two for each tile-pair call.
1103 pub fn operand_loads(
1104 &self,
1105 block: super::ir::BlockShape,
1106 granularity: super::ir::Granularity,
1107 ) -> u64 {
1108 let (osh, osw, ibw) = (block.osh as u64, block.osw as u64, block.ibw as u64);
1109 match self.block_api_for(granularity) {
1110 Some(BlockApi::Unpacker) => (osh.min(osw) + osh * osw) * ibw,
1111 Some(BlockApi::Systolic) => (osh + osw) * ibw,
1112 Some(BlockApi::Fragments) => match self.accumulator_lane_grid(block.osh, block.osw) {
1113 Some((wm, wn)) => (wn as u64 * osh + wm as u64 * osw) * ibw,
1114 None => 2 * osh * osw * ibw,
1115 },
1116 None => 2 * osh * osw * ibw,
1117 }
1118 }
1119
1120 /// The dataflow policies the matrix engine realizes, in descriptor order.
1121 pub fn matmul_dataflows(&self) -> Vec<DataflowPolicy> {
1122 match self.matmul_cap() {
1123 Some(ComputeCap::MatmulTile { dataflows, .. }) => dataflows.iter().copied().collect(),
1124 _ => Vec::new(),
1125 }
1126 }
1127
1128 pub fn has_elementwise(&self) -> bool {
1129 self.compute
1130 .iter()
1131 .any(|c| matches!(c, ComputeCap::Elementwise { .. }))
1132 }
1133
1134 pub fn has_row_reduce(&self) -> bool {
1135 self.compute
1136 .iter()
1137 .any(|c| matches!(c, ComputeCap::RowReduce { .. }))
1138 }
1139
1140 /// The precisions the elementwise (tile) datapath computes in — what a `Generic` stage reads and writes.
1141 pub fn elementwise_precisions(&self) -> BTreeSet<Precision> {
1142 self.compute
1143 .iter()
1144 .find_map(|c| match c {
1145 ComputeCap::Elementwise { precisions } => Some(precisions.clone()),
1146 _ => None,
1147 })
1148 .unwrap_or_default()
1149 }
1150
1151 /// ★ THE TYPE AN INDEX RIDES. An index is an ADDRESS — a token id, a cache position, a block start — so it
1152 /// is computed in an address-wide integer: the narrowest one of at least 32 bits the elementwise datapath
1153 /// has that HOLDS the index space `[0, max]`. On an RDU that is INT32, the widest integer the PCU has.
1154 /// `Err` names the precision the range would need — what the target is missing — so an index space past
1155 /// the chip's integers is refused, never wrapped.
1156 pub fn index_precision_for(&self, max: u64) -> Result<Precision, Precision> {
1157 let wide = [Precision::Int32, Precision::Int64];
1158 let holds = |p: Precision| int_precision_max(p).is_some_and(|m| max <= m as u64);
1159 let have = self.elementwise_precisions();
1160 match wide.into_iter().find(|p| have.contains(p) && holds(*p)) {
1161 Some(p) => Ok(p),
1162 None => Err(wide
1163 .into_iter()
1164 .find(|p| holds(*p))
1165 .unwrap_or(Precision::Int64)),
1166 }
1167 }
1168
1169 /// Whether a `Generic` stage may hold a value of precision `p`: a precision the elementwise datapath
1170 /// computes in, or the matrix engine's fp32 accumulator width — the accumulation path every reduction
1171 /// scratch rides.
1172 pub fn generic_holds(&self, p: Precision) -> bool {
1173 self.elementwise_precisions().contains(&p)
1174 || (p == Precision::Fp32 && self.matmul_fp32_acc())
1175 }
1176
1177 /// The embedded scalar (CGRA) datapath descriptor, if the site has one.
1178 pub fn scalar_target(&self) -> Option<&FabricTarget> {
1179 self.compute.iter().find_map(|c| match c {
1180 ComputeCap::Scalar(t) => Some(t),
1181 _ => None,
1182 })
1183 }
1184
1185 /// Accumulator tiles available under an accumulation policy — the ONE place fp32 halving lives. The
1186 /// halving is paid only when the fp32 partial sums must survive packing (`exact`); a program that accepts
1187 /// bf16 spill rounding keeps the full accumulator file.
1188 pub fn effective_acc_tiles(&self, acc: AccPolicy, exact: bool) -> usize {
1189 if acc == AccPolicy::F32 && exact && self.site.fp32_acc_halves_acc_tiles {
1190 (self.site.acc_tiles / 2).max(1)
1191 } else {
1192 self.site.acc_tiles
1193 }
1194 }
1195
1196 /// Bytes of one native tile at precision `p`.
1197 pub fn tile_bytes(&self, p: Precision) -> usize {
1198 self.tile.elems() * precision_bytes(p)
1199 }
1200
1201 /// The largest local-memory budget a site can be configured with.
1202 pub fn max_local_mem_bytes(&self) -> usize {
1203 self.site
1204 .local_mem_opt_in_bytes
1205 .unwrap_or(self.site.local_mem_bytes)
1206 .max(self.site.local_mem_bytes)
1207 }
1208
1209 /// Every preset, for matrix locks.
1210 /// The same chip on a different deployment grid: the compute-with-storage mesh a device exposes depends on
1211 /// harvesting and on the dispatch mode (fast dispatch reserves a row of Tensix cores for the dispatch kernels).
1212 pub fn with_sites(self, x: usize, y: usize) -> SpatialTarget {
1213 SpatialTarget {
1214 sites: MeshShape { x, y },
1215 ..self
1216 }
1217 }
1218
1219 /// Wormhole as the cycle-accurate simulator presents it: `wormhole_b0_80_arch.yaml` (10 Tensix rows, none
1220 /// harvested) under slow dispatch exposes an 8×9 compute-with-storage grid — 72 cores, one row reserved. A
1221 /// production n150 (one harvested row, fast dispatch) exposes 8×8. Every vendor example measured on the
1222 /// simulator spread its work over these 72 cores; a program placed onto 8×8 leaves a ninth row idle.
1223 pub fn wormhole_ttsim() -> SpatialTarget {
1224 let grid = sim_grid("wormhole_b0").expect("the simulated wormhole grid");
1225 SpatialTarget::wormhole().with_sites(grid.x, grid.y)
1226 }
1227
1228 // There is deliberately no `blackhole_ttsim()`: `blackhole()`'s 13×10 already IS the grid
1229 // `sim_grid("blackhole")` reports, so the twin would be the preset itself. Wormhole needs one only
1230 // because its preset is the production n150 (8×8, one row harvested) and the simulator exposes 8×9.
1231
1232 pub fn presets() -> Vec<SpatialTarget> {
1233 vec![
1234 SpatialTarget::wormhole(),
1235 SpatialTarget::blackhole(),
1236 SpatialTarget::sky_fabric(64, 64),
1237 SpatialTarget::sn40l_rdu(),
1238 SpatialTarget::sn50_rdu(),
1239 SpatialTarget::rtx3060(),
1240 ]
1241 }
1242}
1243
1244/// The compute-with-storage grid the cycle-accurate simulator exposes for a chip id (slow dispatch):
1245/// `wormhole_b0_80_arch.yaml` → 8×9, `blackhole_140_arch.yaml` → 13×10 (one column of the 14×10 reserved).
1246/// Read from the vendor examples' `compute_with_storage_grid_size()` on each descriptor.
1247pub fn sim_grid(chip: &str) -> Option<MeshShape> {
1248 match chip {
1249 "wormhole_b0" => Some(MeshShape { x: 8, y: 9 }),
1250 "blackhole" => Some(MeshShape { x: 13, y: 10 }),
1251 _ => None,
1252 }
1253}