Skip to main content

logicaffeine_compile/spatial/
link.rs

1//! The spatial link: each op of the one IR that the shared optimizer leaves the spatial chips
2//! ([`super::shim::SpatialShim`]), as the section templates it composes from.
3//!
4//! The link reads the op and the graph's tensor facts: operand names, the fills the program declares, the shapes it
5//! gives. The decisions only the spatial chips make about a whole program are taken once, before any op is linked, as
6//! the [`Prelude`]: which tables are laid out as matrices, the interval each product's operands are proven in, and the
7//! Q8_0 view each product over a stored weight reads its activation through. [`Prelude::agree_layouts`] bridges a
8//! section's layout to the one the prelude chose, where they differ.
9
10use std::collections::{BTreeMap, BTreeSet, HashSet};
11
12use super::ir::*;
13use super::passes::from_qwen_ops as q;
14use super::passes::from_tensor_op::{
15    from_elementwise_with, from_fused_row, from_matmul, from_swiglu_shaped, ElementwiseNames, FusedRowFills,
16    FusedRowNames, MatmulNames, SwiGluNames,
17};
18use super::passes::SpatialReject;
19use super::pipeline::{check_datatypes, StageNames};
20use super::target::SpatialTarget;
21use super::value_range::Interval;
22use crate::tensor_graph::front::TensorOpSpec;
23use crate::tensor_graph::graph::{Op, Sym, TensorGraph};
24use crate::tensor_recognize::{FusedRowSpec, Fill, RowReduceKind};
25
26/// The decisions the spatial chips make about a whole program before any op of it is linked.
27#[derive(Clone, Debug, Default)]
28pub struct Prelude {
29    /// The tables laid out dense — as a `rows × cols` matrix, a tile row holding `tile` rows — rather than as one flat
30    /// row ([`dense_tables`]).
31    pub dense: BTreeSet<String>,
32    /// The interval each integer tensor is proven to hold, by name: a product's operand precision is the narrowest that
33    /// holds its operands'.
34    pub ranges: BTreeMap<String, Interval>,
35    /// The view each product over a stored Q8_0 weight reads its activation through, by op.
36    pub views: BTreeMap<usize, String>,
37}
38
39impl Prelude {
40    /// The decisions for the ops `linked` of `g`, in the order they are linked: `agreed` the tables a deployment's other
41    /// programs lay out dense, `q8` the weights it stores Q8_0, and `chip` the chip of a tensor-parallel deployment each
42    /// result is computed on.
43    pub fn of(
44        g: &TensorGraph,
45        linked: &[usize],
46        target: &SpatialTarget,
47        agreed: &BTreeSet<String>,
48        ranges: BTreeMap<String, Interval>,
49        q8: &BTreeSet<String>,
50        chip: &dyn Fn(&str) -> Option<u16>,
51    ) -> Prelude {
52        // ★ ONE VIEW OF A VALUE, HOWEVER MANY PRODUCTS READ IT. A value is its name and the count of writes to that name
53        // before it, so every product of one value names one view.
54        let mut views = BTreeMap::new();
55        let mut writes: BTreeMap<&str, usize> = BTreeMap::new();
56        for &k in linked {
57            let op = &g.ops[k];
58            if matches!(op.prim(), Some(TensorOpSpec::Matmul(_))) && q8.contains(&op.ins[1]) {
59                let a = op.ins[0].as_str();
60                views.insert(k, super::passes::from_q8::q8_rows_name_on(a, writes.get(a).copied().unwrap_or(0), chip(&op.out)));
61            }
62            *writes.entry(op.out.as_str()).or_default() += 1;
63        }
64        Prelude { dense: dense_tables(g, linked, target, agreed), ranges, views }
65    }
66
67    /// ★ LAYOUT IS AGREED AT THE EDGES, ONCE, FOR EVERY OP. Each op lowers in its own layout — an attention or a rope
68    /// over flat rows, a matmul over a matrix. Where a section declares one of the program's matrices in the layout the
69    /// prelude did not choose ([`Prelude::dense`]), that section's tensor is renamed to its twin (`X__rows` flat,
70    /// `X__dense` dense) and ONE relayout section bridges the two: before the section for a matrix it reads, after it
71    /// for one it writes. A twin is made once and shared by every later reader. Carried state is updated in place and
72    /// is never bridged.
73    pub fn agree_layouts(
74        &self,
75        g: &TensorGraph,
76        ps: Vec<SpatialProgram>,
77        twins: &mut BTreeSet<String>,
78        passes: &dyn Fn(SpatialProgram) -> Result<SpatialProgram, SpatialReject>,
79        target: &SpatialTarget,
80    ) -> Result<Vec<SpatialProgram>, SpatialReject> {
81        use q::{relayout_dense_to_rows, relayout_rows_to_dense};
82        // The op's own sections agree with one another: only a tensor the op uses in ONE role throughout — read, or
83        // written — meets another op at an edge. A carry updated by one section and read by the next is neither.
84        let mut roles: BTreeMap<&str, HashSet<TensorRole>> = BTreeMap::new();
85        for p in &ps {
86            for t in &p.tensors {
87                roles.entry(t.name.as_str()).or_default().insert(t.role);
88            }
89        }
90        let (mut before, mut after) = (Vec::new(), Vec::new());
91        let mut renames: BTreeMap<String, String> = BTreeMap::new();
92        for p in &ps {
93            for t in &p.tensors {
94                if roles.get(t.name.as_str()).is_some_and(|r| r.len() > 1) {
95                    continue;
96                }
97                let Some((r, c)) = g.tensors.get(&t.name).map(|x| (x.rows, x.cols)).filter(|(r, _)| *r > 1) else {
98                    continue;
99                };
100                let section_dense = match (t.elems.rows, t.elems.cols) {
101                    d if d == (r, c) => true,
102                    d if d == (1, r * c) => false,
103                    _ => continue,
104                };
105                if section_dense == self.dense.contains(&t.name) || renames.contains_key(&t.name) {
106                    continue;
107                }
108                let twin = format!("{}__{}", t.name, if section_dense { "dense" } else { "rows" });
109                match t.role {
110                    TensorRole::Input => {
111                        if twins.insert(twin.clone()) {
112                            let from = (t.name.as_str(), &fill_of(g, &t.name));
113                            let bridge = if section_dense {
114                                relayout_rows_to_dense(from, &twin, r, c, t.dtype, target)?
115                            } else {
116                                vec![relayout_dense_to_rows(from, &twin, r, c, t.dtype, target)?]
117                            };
118                            for b in bridge {
119                                before.push(passes(b)?);
120                            }
121                        }
122                    }
123                    TensorRole::Output => {
124                        twins.insert(twin.clone());
125                        let from = (twin.as_str(), &Fill::Unknown);
126                        let bridge = if section_dense {
127                            vec![relayout_dense_to_rows(from, &t.name, r, c, t.dtype, target)?]
128                        } else {
129                            relayout_rows_to_dense(from, &t.name, r, c, t.dtype, target)?
130                        };
131                        for b in bridge {
132                            after.push(passes(b)?);
133                        }
134                    }
135                    _ => continue,
136                }
137                renames.insert(t.name.clone(), twin);
138            }
139        }
140        let renamed = ps.into_iter().map(|mut p| {
141            for t in &mut p.tensors {
142                if let Some(twin) = renames.get(&t.name) {
143                    t.name = twin.clone();
144                }
145            }
146            p
147        });
148        Ok(before.into_iter().chain(renamed).chain(after).collect())
149    }
150}
151
152/// The tables the ops `linked` of `g` lay out DENSE, with `agreed`, the tables a deployment's other programs lay out
153/// dense: every K and V an attention reads in the flash form (and a block of rows' queries and result, which the flash
154/// form reads and writes a tile of rows at a time), and every multi-row tensor an op reads or writes as a matrix (a
155/// stacked batch through a matmul, a pointwise op over a matrix). The layout is decided here, once per table, so a row
156/// write or a row read on a table and the ops that read it whole can never disagree on it.
157///
158/// ★ A ROW NORM OVER A MATRIX HAS NO LAYOUT OF ITS OWN. It reads and writes in one layout, so it takes its tensors' —
159/// the matrix when any of them is dense, a stack a matmul reads — and ties the others to it; left to itself it is the
160/// flat row every per-head norm is. The tie runs to a fixed point, since a norm's input may be another norm's output.
161pub fn dense_tables(g: &TensorGraph, linked: &[usize], target: &SpatialTarget, agreed: &BTreeSet<String>) -> BTreeSet<String> {
162    use q::{attention_reads_dense, full_attention_reads_dense};
163    let ops = || linked.iter().map(|&k| &g.ops[k]).filter_map(|op| op.prim().map(|spec| (op, spec)));
164    let matrix = |name: &str, n: usize| g.shape_of(name, n).0 > 1;
165    let mut dense = BTreeSet::new();
166    for (op, spec) in ops() {
167        let whole = || op.ins.iter().chain(std::iter::once(&op.out)).collect::<Vec<_>>();
168        let touched: Vec<&String> = match spec {
169            // A block of query rows in the flash form — causal or full — reads a TILE of query rows per item and writes
170            // its result a tile row at a time: its queries and its result are matrices too.
171            TensorOpSpec::Attention(a) if attention_reads_dense(a.h, a.kv, a.hd, a.vd, target) && a.rows > 1 => {
172                op.ins.iter().take(3).chain(std::iter::once(&op.out)).collect()
173            }
174            TensorOpSpec::Attention(a) if attention_reads_dense(a.h, a.kv, a.hd, a.vd, target) => op.ins.iter().skip(1).take(2).collect(),
175            TensorOpSpec::FullAttention(a) if full_attention_reads_dense(a.hd, target) && a.t > 1 => {
176                op.ins.iter().take(3).chain(std::iter::once(&op.out)).collect()
177            }
178            TensorOpSpec::FullAttention(a) if full_attention_reads_dense(a.hd, target) => op.ins.iter().skip(1).take(2).collect(),
179            TensorOpSpec::Matmul(m) => [(&op.ins[0], m.m > 1), (&op.ins[1], m.k > 1), (&op.out, m.m > 1)]
180                .into_iter()
181                .filter(|(_, rows)| *rows)
182                .map(|(t, _)| t)
183                .collect(),
184            TensorOpSpec::Elementwise(e) if matrix(&op.out, e.n) => whole(),
185            TensorOpSpec::SwiGlu(s) if matrix(&op.out, s.total) => whole(),
186            TensorOpSpec::Silu(u) if matrix(&op.out, u.n) => whole(),
187            TensorOpSpec::IntRequant(u) if matrix(&op.out, u.n) => whole(),
188            TensorOpSpec::IntClamp(u) if matrix(&op.out, u.n) => whole(),
189            TensorOpSpec::Relu(u) if matrix(&op.out, u.n) => whole(),
190            TensorOpSpec::Sigmoid(u) if matrix(&op.out, u.n) => whole(),
191            TensorOpSpec::Gelu(u) if matrix(&op.out, u.n) => whole(),
192            TensorOpSpec::Scale(u) if matrix(&op.out, u.n) => whole(),
193            TensorOpSpec::Softcap(u) if matrix(&op.out, u.n) => whole(),
194            TensorOpSpec::Decay(d) if d.rows > 1 => vec![&op.ins[0], &op.out],
195            TensorOpSpec::CausalConv(c) if c.rows > 1 => vec![&op.ins[0], &op.out],
196            _ => Vec::new(),
197        };
198        dense.extend(touched.into_iter().cloned());
199    }
200    dense.extend(agreed.iter().cloned());
201    loop {
202        let before = dense.len();
203        // ★ A BLOCK OF ROWS WRITTEN INTO A MATRIX IS ONE. `Set rows` into a dense table moves whole tile rows of its
204        // block, so the block is laid out as the table is.
205        for (op, spec) in ops() {
206            if let TensorOpSpec::SetRow(r) = spec {
207                if r.count > 1 && dense.contains(&op.ins[0]) {
208                    dense.insert(op.ins[1].clone());
209                }
210            }
211        }
212        for (op, spec) in ops() {
213            let TensorOpSpec::FusedRow(f) = spec else { continue };
214            if f.rows < 2 || g.shape_of(&op.out, f.rows * f.cols) != (f.rows, f.cols) {
215                continue;
216            }
217            let tied: Vec<&String> = std::iter::once(&op.ins[0])
218                .chain(f.residual.and(op.ins.get(1 + usize::from(f.weight.is_some()))))
219                .chain(std::iter::once(&op.out))
220                .collect();
221            if tied.iter().any(|t| dense.contains(*t)) {
222                dense.extend(tied.into_iter().cloned());
223            }
224        }
225        if dense.len() == before {
226            return dense;
227        }
228    }
229}
230
231/// How an op is linked: alone, as the one program a single-op source is, or as one section of a composition, its
232/// float-ness read off the whole program's walk.
233///
234/// ★ THE LAYOUT IS THE DIFFERENCE, AND IT IS NOT NEGOTIABLE. A single-op program gives every tensor the shape its
235/// source declared, which is right when the program is that one op. Inside a composition every activation is FLAT
236/// (`1 × n`), because a tensor a matmul writes as `1 × 8` and a per-head rmsnorm reads as `2 × 4` has its element 4 in
237/// two different places and no affine map reconciles them. So the ops whose natural layout is 2-D take their flat twin
238/// in a section, and only there; everything whose natural layout already IS flat is linked by the one builder both
239/// forms share.
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub enum Form {
242    Alone,
243    /// `floats`: whether the op's datapath is float — its result's kind, or an arg-max's input's — which an
244    /// activation's missing fill cannot say.
245    Section { floats: bool },
246}
247
248/// Op `k` of `g` as the template sections it composes from — one per program the composer chains. Every op is one
249/// section except the carried-state blocks, whose read half and write half are separate sections precisely so the order
250/// between them is a checkable barrier rather than an assumption (`from_qwen_ops::from_gated_delta`).
251///
252/// ★ A LINKING COMPUTES ONLY IN DATATYPES ITS CHIP HAS: every section passes [`check_datatypes`], or the op is refused.
253pub fn link(
254    g: &TensorGraph,
255    k: usize,
256    prelude: &Prelude,
257    form: Form,
258    numerics: NumericsPolicy,
259    target: &SpatialTarget,
260) -> Result<Vec<SpatialProgram>, SpatialReject> {
261    let programs = link_unchecked(g, k, prelude, form, numerics, target)?;
262    for p in &programs {
263        check_datatypes(p, target)?;
264    }
265    Ok(programs)
266}
267
268/// The fill the program declares `tensor` with: [`Fill::Unknown`] for an activation, which no declaration fills.
269fn fill_of(g: &TensorGraph, tensor: &str) -> Fill {
270    g.tensors.get(tensor).and_then(|t| t.fill.clone()).unwrap_or(Fill::Unknown)
271}
272
273/// Whether a declared fill holds floats. A fill the compiler cannot see (`Unknown`, an activation) is taken as float —
274/// the datapath every other section's activations ride; inside a composition the walk's own float analysis decides
275/// instead ([`Form::Section`]).
276fn fill_is_float(fill: &Fill) -> bool {
277    !matches!(fill, Fill::Const(_) | Fill::Pattern { .. } | Fill::Overrides { .. })
278}
279
280/// A row norm's operands by role. The spec's operand order is `[x, weight?, residual?, bias?]` — the present ones, in
281/// that order — which is exactly how the op lists them.
282fn row_norm_operands(op: &Op, g: &TensorGraph, f: &FusedRowSpec) -> (FusedRowNames, FusedRowFills) {
283    let mut rest = op.ins.iter().skip(1);
284    let mut slot = |have: bool| have.then(|| rest.next().cloned()).flatten();
285    let (weight, residual, bias) = (slot(f.weight.is_some()), slot(f.residual.is_some()), slot(f.bias.is_some()));
286    let fills = FusedRowFills {
287        x: fill_of(g, &op.ins[0]),
288        weight: weight.as_deref().map(|t| fill_of(g, t)),
289        residual: residual.as_deref().map(|t| fill_of(g, t)),
290        bias: bias.as_deref().map(|t| fill_of(g, t)),
291    };
292    (FusedRowNames { x: op.ins[0].clone(), y: op.out.clone(), weight, residual, bias }, fills)
293}
294
295fn link_unchecked(
296    g: &TensorGraph,
297    k: usize,
298    prelude: &Prelude,
299    form: Form,
300    numerics: NumericsPolicy,
301    target: &SpatialTarget,
302) -> Result<Vec<SpatialProgram>, SpatialReject> {
303    let op = &g.ops[k];
304    let Sym::Prim(spec) = &op.sym else {
305        return Err(SpatialReject::NotLowered("an op the front end did not lift — the spatial chips link every fused class by its parts"));
306    };
307    let fill = |i: usize| fill_of(g, &op.ins[i]);
308    let names = StageNames { out: op.out.clone(), ins: op.ins.clone(), fills: (0..op.ins.len()).map(fill).collect() };
309    let shape = |n: usize| g.shape_of(&op.out, n);
310    let dense = |t: &str| prelude.dense.contains(t);
311    // The datapath of an op whose element kind a section reads off the walk, and alone off its table's declared fill.
312    let floats = || match form {
313        Form::Section { floats } => floats,
314        Form::Alone => fill_is_float(&names.fills[0]),
315    };
316    let one = |r: Result<SpatialProgram, SpatialReject>| r.map(|p| vec![p]);
317    match spec {
318        TensorOpSpec::Matmul(m) => {
319            let names = MatmulNames { a: op.ins[0].clone(), b: op.ins[1].clone(), c: op.out.clone() };
320            let fills = (fill(0), fill(1));
321            if let Some(view) = prelude.views.get(&k).filter(|_| super::passes::from_q8::q8_gemv_applies(m, target)) {
322                if let Some(sections) = super::passes::from_q8::q8_gemv(m, &names, (&fills.0, &fills.1), view, target)? {
323                    return Ok(sections);
324                }
325            }
326            let ranges = (prelude.ranges.get(&names.a).copied(), prelude.ranges.get(&names.b).copied());
327            one(from_matmul(m, &names, (&fills.0, &fills.1), ranges, numerics, target))
328        }
329        // An activation whose producer is another section carries no declared fill, so the integer shortcut in the
330        // elementwise builder cannot see that the data is float. The whole-program walk CAN — it propagates float-ness
331        // from the declarations through the op graph — so a section says so.
332        TensorOpSpec::Elementwise(e) => {
333            let names = ElementwiseNames { a: op.ins[0].clone(), b: op.ins[1].clone(), c: op.out.clone() };
334            let float_activations = form == (Form::Section { floats: true });
335            one(from_elementwise_with(e, &names, (&fill(0), &fill(1)), float_activations, shape(e.n), target))
336        }
337        TensorOpSpec::SwiGlu(s) => {
338            let names = SwiGluNames { gate: op.ins[0].clone(), up: op.ins[1].clone(), y: op.out.clone() };
339            one(from_swiglu_shaped(s, &names, (&fill(0), &fill(1)), shape(s.total), target))
340        }
341        TensorOpSpec::FusedRow(f) => {
342            let (row_names, fills) = row_norm_operands(op, g, f);
343            let natural = (f.rows, f.cols);
344            // A multi-row norm in a section is the flat row every per-head norm is, unless its result is a dense table.
345            let layout = match form {
346                Form::Alone => natural,
347                Form::Section { .. } if f.rows > 1 && !dense(&op.out) => (1, f.rows * f.cols),
348                Form::Section { .. } => shape(f.rows * f.cols),
349            };
350            // ★ A ROW WIDER THAN A SITE CAN HOLD STREAMS. The whole-row chain keeps `cols_t` tiles resident and is
351            // refused outright past the accumulator budget, which at Qwen3.8's real `d = 5120` is every row in the
352            // model. The blocked form says the same arithmetic in four sections that each fit, so the choice here is
353            // about the WIDTH, never about the operation — and a stack of such rows, a batch's `B × d`, is the same four
354            // sections over the matrix.
355            //
356            // ★ THE FLAT TWIN IS THE WHOLE-PROGRAM LAYOUT DISCIPLINE, NOT AN OPTIMIZATION. A 2x4 per-head norm that stays
357            // two-dimensional produces `qr3` shaped (2, 4) while every other section still reads it flat as (1, 8), and
358            // compose refuses the conflict. Layout is an AGREEMENT between sections, so the rule cannot depend on any one
359            // section's width — it is what the tensors' other readers and writers need.
360            match form {
361                Form::Section { .. }
362                    if layout == natural && q::row_needs_blocking(f.cols, target) && f.reduce == RowReduceKind::RmsNorm && f.bias.is_none() =>
363                {
364                    q::blocked_rms_norm(f, &names, target)
365                }
366                Form::Section { .. } if f.rows > 1 && layout == (1, f.rows * f.cols) => one(q::from_row_norm_flat(f, &names, target)),
367                Form::Section { .. } if layout != natural => Err(SpatialReject::NotLowered(
368                    "a row norm whose rows cut across the rows of the matrix it normalizes — neither the matrix's tiling nor the flat row's",
369                )),
370                _ => one(from_fused_row(f, &row_names, &fills, target)),
371            }
372        }
373        TensorOpSpec::Rope(spec) => one(q::from_rope(spec, &names, target)),
374        TensorOpSpec::Silu(spec) => one(q::from_silu(spec, &names, shape(spec.n), target)),
375        TensorOpSpec::IntRequant(spec) => one(q::from_int_requant(spec, &names, shape(spec.n), target)),
376        TensorOpSpec::IntClamp(spec) => one(q::from_int_clamp(spec, &names, shape(spec.n), target)),
377        TensorOpSpec::Relu(spec) => one(q::from_relu(spec, &names, shape(spec.n), target)),
378        TensorOpSpec::LutAttention(spec) => q::from_lut_attention(spec, &names, target),
379        TensorOpSpec::Sigmoid(spec) => one(q::from_sigmoid(spec, &names, shape(spec.n), target)),
380        TensorOpSpec::Gelu(spec) => one(q::from_gelu(spec, &names, shape(spec.n), target)),
381        TensorOpSpec::Scale(spec) => one(q::from_scale(spec, &names, shape(spec.n), target)),
382        TensorOpSpec::Softcap(spec) => one(q::from_softcap(spec, &names, shape(spec.n), target)),
383        TensorOpSpec::Decay(spec) => one(q::from_decay(spec, &names, target)),
384        TensorOpSpec::L2Norm(spec) => one(q::from_l2norm(spec, &names, target)),
385        TensorOpSpec::Transpose(spec) => {
386            let floats = match form {
387                Form::Section { floats } => floats,
388                Form::Alone => true,
389            };
390            one(q::from_transpose(spec, &names, floats, target))
391        }
392        TensorOpSpec::HeadSlice(spec) => one(q::from_head_slice(spec, &names, target)),
393        TensorOpSpec::Join(spec) => one(q::from_join(spec, &names, target)),
394        TensorOpSpec::HeadMatmul(spec) => one(q::from_head_matmul(spec, &names, target)),
395        TensorOpSpec::CausalConv(spec) => q::from_causal_conv(spec, &names, target),
396        // The tables of a block step's operands and output laid out as matrices; a one-row step reads none of them as a
397        // table.
398        TensorOpSpec::GatedDelta(spec) => {
399            let tables: BTreeSet<String> = op.ins.iter().chain(std::iter::once(&op.out)).filter(|t| dense(t)).cloned().collect();
400            q::from_gated_delta(spec, &names, &tables, target)
401        }
402        TensorOpSpec::Attention(spec) => q::from_attention(spec, &names, target),
403        TensorOpSpec::FullAttention(spec) => q::from_full_attention(spec, &names, target),
404        TensorOpSpec::Moe(spec) => q::from_moe(spec, &names, target),
405        TensorOpSpec::RowGather(spec) => one(q::from_row_gather(spec, &names, floats(), dense(&op.ins[0]), target)),
406        TensorOpSpec::RowsGather(spec) => one(q::from_rows_gather(spec, &names, floats(), dense(&op.ins[0]), target)),
407        TensorOpSpec::ArgMax(spec) => q::from_argmax(spec, &names, floats(), target),
408        TensorOpSpec::SetRow(spec) => one(q::from_set_row(spec, &names, floats(), dense(&op.ins[0]), target)),
409    }
410}