Skip to main content

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}