Skip to main content

logicaffeine_compile/spatial/
tensor_parallel.rs

1//! ════════════════════════════════════════════════════════════════════════════════════════════════
2//! TENSOR PARALLELISM FOR A WHOLE PROGRAM — Megatron over RDUs, as ONE spatial program.
3//!
4//! `passes::shard` splits ONE matmul's contraction over chips and sums the partials. A model layer is a chain of
5//! ops, and Megatron splits the chain, not an op: the projections into a layer are split by COLUMN (each chip owns
6//! whole heads, whole FFN columns — its outputs are complete slices), the projections out of it by ROW (each chip
7//! holds a PARTIAL of the full output), and the partials are summed by an all-reduce, twice a layer. Between the
8//! all-reduces every chip works on its own heads alone; the residuals and norms are the replicated spine.
9//!
10//! ★ THE DECOMPOSITION IS A PROGRAM, SO IT IS WRITTEN AS ONE. A generator (`qwen38::gen_qwen38_tp_decode_*`) writes
11//! the tensor-parallel layer as ordinary LOGOS: every chip's slice spelled with its own tensors (`Wq3_c1`), and
12//! every all-reduce as the left fold of `plus` it means. That source RUNS on the VM, so the decomposition itself is
13//! checked against the unsharded layer before the compiler ever sees it. Alongside it comes a [`TpManifest`]: the
14//! chip of every chip-local tensor, the slice of the full checkpoint tensor each one is (the host's loading map), and
15//! each collective's partials and fold statements.
16//!
17//! ★ THE COMPILER THEN LOWERS IT AS IT LOWERS ANYTHING, with two differences. Every section is RELOCATED to the chip
18//! of what it computes ([`relocate`]); and a collective's fold statements are not lowered as additions on one chip —
19//! ONE [`allreduce_section`] replaces them, reading each chip's partial where it lives and folding them all. The
20//! certificate's shard obligation holds the result to the rule that makes the split mean anything: data derived from
21//! a chip's own tensors reaches the replicated spine only through an all-reduce over EVERY chip.
22//! ════════════════════════════════════════════════════════════════════════════════════════════════
23
24use std::collections::{BTreeMap, BTreeSet};
25
26use serde::Serialize;
27
28use super::ir::*;
29use super::passes::from_qwen_ops::elem_precision;
30use super::passes::from_tensor_op::{
31    elementwise_int, ident_map, items_per_tile_grid, space, tile_grid_steps, Template,
32};
33use super::passes::shard::{crossing_handshake, crossing_kind, ShardError};
34use super::passes::SpatialReject;
35use super::pipeline::{run_passes, run_passes_uncertified, SectionCertification};
36use super::target::SpatialTarget;
37use crate::codegen_sva::fabric_target::Precision;
38
39/// Which axis of a full tensor a chip's slice cuts.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
41pub enum SliceAxis {
42    Rows,
43    Cols,
44}
45
46/// A chip's tensor as ranges of a full tensor `of` (`of_shape` whole) along one axis, concatenated in order: each
47/// `(start, len)` is `len` rows (or columns) from `start`. What a host loads onto the chip, and how a test fills the
48/// slice with the full tensor's values.
49///
50/// ★ ONE RANGE IS THE COMMON CASE, NOT THE RULE. A chip's query heads are one run of columns; its Gated-DeltaNet
51/// value heads are not — this checkpoint TILES its keys across the value heads (value head `h` reads key head
52/// `h mod HK`), so the value heads that read a chip's key heads recur every `HK` heads, one block per recurrence.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
54pub struct TpSlice {
55    pub of: String,
56    pub of_shape: (usize, usize),
57    pub axis: SliceAxis,
58    pub ranges: Vec<(usize, usize)>,
59}
60
61impl TpSlice {
62    /// The slice's extent along its axis.
63    pub fn len(&self) -> usize {
64        self.ranges.iter().map(|(_, n)| n).sum()
65    }
66
67    /// The full tensor's coordinate along the axis of the slice's coordinate `x`.
68    fn full_coord(&self, x: usize) -> usize {
69        let mut at = x;
70        for (start, n) in &self.ranges {
71            if at < *n {
72                return start + at;
73            }
74            at -= n;
75        }
76        panic!(
77            "coordinate {x} is past the slice's {} of `{}`",
78            self.len(),
79            self.of
80        )
81    }
82
83    /// The full tensor's flat index of the slice's element `(r, c)`.
84    pub fn full_index(&self, r: usize, c: usize) -> usize {
85        match self.axis {
86            SliceAxis::Rows => self.full_coord(r) * self.of_shape.1 + c,
87            SliceAxis::Cols => r * self.of_shape.1 + self.full_coord(c),
88        }
89    }
90
91    /// The slice's own `(rows, cols)`.
92    pub fn shape(&self) -> (usize, usize) {
93        match self.axis {
94            SliceAxis::Rows => (self.len(), self.of_shape.1),
95            SliceAxis::Cols => (self.of_shape.0, self.len()),
96        }
97    }
98}
99
100/// One all-reduce: `out = Σ partials`, the partials in chip order (`partials[c]` lives on chip `c`), spelled in the
101/// source as the fold statements `folds` — the last of which binds `out`.
102#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
103pub struct TpCollective {
104    pub out: String,
105    pub partials: Vec<String>,
106    pub folds: Vec<String>,
107}
108
109/// How a tensor-parallel program is spread over `chips` RDUs.
110#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
111pub struct TpManifest {
112    pub chips: usize,
113    /// Every chip-local tensor, by name. A tensor not named here is replicated: the spine every chip shares.
114    pub chip_of: BTreeMap<String, u16>,
115    /// Every chip-local tensor that is a slice of a full checkpoint (or carried-state) tensor.
116    pub slices: BTreeMap<String, TpSlice>,
117    pub collectives: Vec<TpCollective>,
118}
119
120impl TpManifest {
121    pub fn new(chips: usize) -> Self {
122        TpManifest {
123            chips,
124            ..Default::default()
125        }
126    }
127
128    /// Record `name` as chip `chip`'s tensor, a slice of `slice.of` when given.
129    pub fn place(&mut self, name: &str, chip: usize, slice: Option<TpSlice>) {
130        self.chip_of.insert(name.to_string(), chip as u16);
131        if let Some(s) = slice {
132            self.slices.insert(name.to_string(), s);
133        }
134    }
135
136    /// The collective whose fold statements include `binder`, if any.
137    pub fn collective_folding(&self, binder: &str) -> Option<&TpCollective> {
138        self.collectives
139            .iter()
140            .find(|c| c.folds.iter().any(|f| f == binder))
141    }
142
143    /// The chip a statement binding `name` runs on: its tensor's chip, or the root (chip 0) for the replicated spine.
144    pub fn chip_running(&self, name: &str) -> u16 {
145        self.chip_of.get(name).copied().unwrap_or(0)
146    }
147}
148
149/// What a chip allows of a tensor-parallel program: what `base` allows, less a fused op over more than one chip.
150///
151/// ★ A FUSED OP RUNS ON ONE CHIP. Every statement of the program runs where the manifest puts what it binds — a chip's
152/// own tensor on that chip, the replicated spine on every chip — and a section is relocated whole to one chip
153/// ([`relocate`]). So a fusion whose members bind tensors of different chips, or a chip's tensor and the spine's, would
154/// move a chip's work onto another chip; and a fusion holding a collective's fold would lower that fold as a sum on the
155/// spine where the program means an all-reduce over every chip. Both are refused here, by the chip the optimizer asks
156/// before it fuses; every other fusion is `base`'s to answer.
157pub struct TensorParallelShim<'a> {
158    pub base: &'a dyn crate::tensor_graph::shim::Shim,
159    pub manifest: &'a TpManifest,
160}
161
162impl crate::tensor_graph::shim::Shim for TensorParallelShim<'_> {
163    fn name(&self) -> String {
164        format!("{}:tp{}", self.base.name(), self.manifest.chips)
165    }
166
167    fn supports(&self, class: crate::tensor_graph::symbols::ClassId) -> crate::tensor_graph::shim::Support {
168        self.base.supports(class)
169    }
170
171    fn fits(&self, op: &crate::tensor_graph::graph::Op, g: &crate::tensor_graph::graph::TensorGraph) -> Result<(), String> {
172        if matches!(op.sym, crate::tensor_graph::graph::Sym::Fused(_)) {
173            let members = crate::tensor_graph::fuse::leaves(op.clone());
174            if let Some(m) = members.iter().find(|m| self.manifest.collective_folding(&m.out).is_some()) {
175                return Err(format!("`{}` folds a collective, which lowers as its all-reduce", m.out));
176            }
177            let chip = |m: &crate::tensor_graph::graph::Op| self.manifest.chip_of.get(&m.out).copied();
178            if let Some(m) = members.iter().find(|m| chip(m) != chip(&members[0])) {
179                return Err(format!("`{}` and `{}` run on different chips", members[0].out, m.out));
180            }
181        }
182        self.base.fits(op, g)
183    }
184
185    fn activation(
186        &self,
187        consumer: &crate::tensor_graph::graph::Op,
188        operand: usize,
189        g: &crate::tensor_graph::graph::TensorGraph,
190    ) -> Option<crate::tensor_graph::graph::QuantFormat> {
191        self.base.activation(consumer, operand, g)
192    }
193
194    fn rules(&self) -> Vec<(crate::tensor_graph::shim::Position, crate::tensor_graph::fuse::ChipRule<'_>)> {
195        self.base.rules()
196    }
197
198    fn numerics(&self) -> Option<crate::tensor_graph::numerics::Contract> {
199        self.base.numerics()
200    }
201}
202
203/// A tensor one chip's statement reads and another chip's statement writes: it crosses from the writer's chip to the
204/// reader's, once, before the reader's first statement that reads it.
205#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
206pub struct ChipTransfer {
207    pub tensor: String,
208    pub from: u16,
209    pub to: u16,
210}
211
212/// The tensors that cross between `m`'s chips when each statement runs on [`TpManifest::chip_running`] its binder: for
213/// each statement, every operand the latest earlier statement to write it wrote on another chip, in program order, each
214/// `(tensor, reader's chip)` once. An operand no statement writes — a table, a host-written input — crosses nothing:
215/// each chip that reads it holds its own.
216pub fn transfers(stages: &[super::passes::recognize_stages::RecognizedStage], m: &TpManifest) -> Vec<ChipTransfer> {
217    let mut writer: BTreeMap<&str, u16> = BTreeMap::new();
218    let mut out: Vec<ChipTransfer> = Vec::new();
219    for s in stages {
220        let here = m.chip_running(&s.out);
221        for name in &s.ins {
222            let Some(&from) = writer.get(name.as_str()) else {
223                continue;
224            };
225            if from != here && !out.iter().any(|t| t.tensor == *name && t.to == here) {
226                out.push(ChipTransfer {
227                    tensor: name.clone(),
228                    from,
229                    to: here,
230                });
231            }
232        }
233        writer.insert(&s.out, here);
234    }
235    out
236}
237
238/// Where chip `chip`'s program `p` halts for `transfers`: each tensor it sends, after the last section that stores it;
239/// each it receives, before the first section that loads it — `None` for the first section, the host's to put there
240/// before the chip starts. Halts at one boundary are one [`LinkPoint`]; they come in program order.
241pub fn link_points(p: &SpatialProgram, chip: u16, transfers: &[ChipTransfer]) -> Result<Vec<LinkPoint>, TpReject> {
242    let touch = |name: &str| -> (Vec<usize>, Vec<usize>, Option<TensorId>) {
243        let (mut stores, mut loads) = (Vec::new(), Vec::new());
244        for (k, sec) in p.sections.iter().enumerate() {
245            for st in &p.stages[sec.stages.0..sec.stages.1] {
246                match &st.kind {
247                    StageKind::Store { tensor, .. } if p.tensor(*tensor).name == name => stores.push(k),
248                    StageKind::Load { tensor, .. } if p.tensor(*tensor).name == name => loads.push(k),
249                    _ => {}
250                }
251            }
252        }
253        (stores, loads, p.tensors.iter().find(|t| t.name == name).map(|t| t.id))
254    };
255    let unlinked = |t: &ChipTransfer, why: &'static str| TpReject::Unlinked {
256        tensor: t.tensor.clone(),
257        chip,
258        why,
259    };
260    fn point(at: &mut BTreeMap<Option<usize>, LinkPoint>, after: Option<usize>) -> &mut LinkPoint {
261        at.entry(after).or_insert(LinkPoint {
262            after,
263            sends: Vec::new(),
264            receives: Vec::new(),
265        })
266    }
267    let mut at: BTreeMap<Option<usize>, LinkPoint> = BTreeMap::new();
268    for t in transfers {
269        if t.from == chip {
270            let (stores, _, id) = touch(&t.tensor);
271            let (Some(last), Some(id)) = (stores.last(), id) else {
272                return Err(unlinked(t, "it sends a tensor no section of it stores"));
273            };
274            point(&mut at, Some(*last)).sends.push(id);
275        }
276        if t.to == chip {
277            let (stores, loads, id) = touch(&t.tensor);
278            if !stores.is_empty() {
279                return Err(unlinked(t, "it receives a tensor a section of its own stores"));
280            }
281            let (Some(first), Some(id)) = (loads.first(), id) else {
282                return Err(unlinked(t, "it receives a tensor no section of it loads"));
283            };
284            point(&mut at, first.checked_sub(1)).receives.push(id);
285        }
286    }
287    Ok(at.into_values().collect())
288}
289
290/// One step of the relay a host runs between chips' programs that halt at their [`LinkPoint`]s.
291#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
292pub enum RelayStep {
293    /// Start chip `chip`, or continue it past the halt it stands at, and let it run to its halt `to` — to its end when `to`
294    /// is its count of halts. The host waits on a chip only when it next copies from it or runs it.
295    Run { chip: u16, to: usize },
296    /// Read `tensor` from chip `from`, which stands at or past the halt that sends it, and write it into chip `to`.
297    Copy { tensor: String, from: u16, to: u16 },
298}
299
300/// The host's relay over `chips`' halts: round by round, each chip that can is run to its next halt — once the tensors
301/// the halt it stands at receives have been sent and copied — so chips whose next work needs nothing of each other run
302/// at once. Refused, with where each chip stands, when no chip can move before all have finished.
303pub fn relay_schedule(chips: &[SpatialProgram], transfers: &[ChipTransfer]) -> Result<Vec<RelayStep>, String> {
304    let named = |p: &SpatialProgram, ids: &[TensorId]| -> Vec<String> { ids.iter().map(|t| p.tensor(*t).name.clone()).collect() };
305    let mut at: Vec<Option<usize>> = vec![None; chips.len()];
306    let mut sent: BTreeSet<String> = BTreeSet::new();
307    let mut copied: BTreeSet<(String, u16)> = BTreeSet::new();
308    let mut steps = Vec::new();
309    while at.iter().zip(chips).any(|(a, p)| *a != Some(p.links.len())) {
310        let mut moved = false;
311        for (c, p) in chips.iter().enumerate() {
312            if at[c] == Some(p.links.len()) {
313                continue;
314            }
315            let wants: Vec<String> = at[c].map(|i| named(p, &p.links[i].receives)).unwrap_or_default();
316            if !wants.iter().all(|t| sent.contains(t) || copied.contains(&(t.clone(), c as u16))) {
317                continue;
318            }
319            for t in wants {
320                if copied.insert((t.clone(), c as u16)) {
321                    let from = transfers
322                        .iter()
323                        .find(|x| x.tensor == t && x.to == c as u16)
324                        .map(|x| x.from)
325                        .ok_or_else(|| format!("chip {c} receives {t}, which no transfer brings it"))?;
326                    steps.push(RelayStep::Copy { tensor: t, from, to: c as u16 });
327                }
328            }
329            let next = at[c].map_or(0, |i| i + 1);
330            steps.push(RelayStep::Run { chip: c as u16, to: next });
331            if let Some(l) = p.links.get(next) {
332                sent.extend(named(p, &l.sends));
333            }
334            at[c] = Some(next);
335            moved = true;
336        }
337        if !moved {
338            return Err(format!("no chip can move: each stands at {at:?} of {:?} halts, with {sent:?} sent", chips.iter().map(|p| p.links.len()).collect::<Vec<_>>()));
339        }
340    }
341    Ok(steps)
342}
343
344/// Why a tensor-parallel lowering refused.
345#[derive(Clone, Debug, PartialEq)]
346pub enum TpReject {
347    /// A section that multicasts: its groups are rectangles of `(x, y)`, which cannot name a chip.
348    Multicast {
349        program: String,
350    },
351    /// No hand-off on the target crosses a chip boundary.
352    NoCrossing(ShardError),
353    Passes(SpatialReject),
354    /// A chip's program cannot halt for a transfer: it sends a tensor no section of it stores, or receives one no section
355    /// of it loads, or one a section of its own stores.
356    Unlinked {
357        tensor: String,
358        chip: u16,
359        why: &'static str,
360    },
361}
362
363impl From<SpatialReject> for TpReject {
364    fn from(e: SpatialReject) -> Self {
365        TpReject::Passes(e)
366    }
367}
368
369fn on_chip(s: &mut Site, chip: u16) {
370    s.chip = chip;
371}
372
373/// A scheduled one-chip section of the statement binding `binder`, moved onto the chip it runs on: every site it
374/// names — its stages', buffers', placement's, memory map's, semaphores', engine programs' and the remote semaphore
375/// posts in them — and every tensor it touches marked with its chip: the one `m` gives it, or, for a tensor the
376/// section itself stores on behalf of a chip-local statement (a blocked op's intermediate), that chip; the
377/// replicated spine stays unmarked. The section computes exactly what it did; it now does so on another RDU. Its
378/// certificate is dropped with the schedule digest it was bound to.
379pub fn relocate(
380    p: &SpatialProgram,
381    binder: &str,
382    m: &TpManifest,
383) -> Result<SpatialProgram, TpReject> {
384    let chip = m.chip_running(binder);
385    let chip_local = m.chip_of.contains_key(binder);
386    let mut q = p.clone();
387    if q.placement.as_ref().is_some_and(|pl| !pl.groups.is_empty()) {
388        return Err(TpReject::Multicast {
389            program: q.name.clone(),
390        });
391    }
392    super::passes::pack::for_each_site_mut(&mut q, &mut |s| s.chip = chip);
393    let stored: BTreeSet<TensorId> = q
394        .stages
395        .iter()
396        .filter_map(|s| match &s.kind {
397            StageKind::Store { tensor, .. } => Some(*tensor),
398            _ => None,
399        })
400        .collect();
401    // A weight read as it is stored Q8_0 is its values and scales: each lives where the chip's slice of the weight does.
402    let chip_of = |name: &str| {
403        m.chip_of.get(name).copied().or_else(|| {
404            super::passes::from_q8::stored_part(name).and_then(|(w, _)| m.chip_of.get(w).copied())
405        })
406    };
407    for t in &mut q.tensors {
408        t.chip = chip_of(&t.name)
409            .or((chip_local && stored.contains(&t.id)).then_some(chip))
410            .map(ChipId);
411    }
412    q.certificate = None;
413    Ok(q)
414}
415
416/// ONE all-reduce as a section: `out = Σ_c partials[c]` over a `rows × cols` tensor, tile by tile — chip `c`'s
417/// partial Loaded on chip `c` (where it lives) and handed across the chip boundary, all of them folded by an
418/// `AllReduce` on chip 0, the sum stored as the replicated `out`.
419///
420/// ★ BUILT AS A SUM, THEN SPREAD. The fold is lowered first as what it is on one chip — a `chips`-ary elementwise
421/// sum, tiled, placed and scheduled by the pinned passes — and then each operand's Load moves to its chip, its
422/// hand-off becomes one that crosses a chip boundary, and the fold becomes the `AllReduce` whose combine it already
423/// is. The passes run again from the placement, so the collective is sized and choreographed like any other hand-off,
424/// and certified as `certification` says: on its own, or as a section of the composed program. The one-chip sum is
425/// only scheduled — its certificate would be dropped with the placement it was bound to.
426///
427/// ★ AN INTEGER SUM IS EXACT. Float partials (`floats`) are summed in the target's element precision, an approximation
428/// whose order the claim names. Integer partials — an int8 model's accumulators — are summed in the widest integer the
429/// elementwise datapath holds, exactly, in any order, and their storage is narrowed by `narrow` as every integer
430/// section's is before it is tiled.
431pub fn allreduce_section(
432    col: &TpCollective,
433    shape: (usize, usize),
434    floats: bool,
435    target: &SpatialTarget,
436    certification: SectionCertification,
437    narrow: &mut dyn FnMut(SpatialProgram) -> SpatialProgram,
438) -> Result<SpatialProgram, TpReject> {
439    let chips = col.partials.len();
440    let (rows, cols) = shape;
441    let dt: Precision = if floats {
442        elem_precision(target)?
443    } else {
444        elementwise_int(target).ok_or(TpReject::Passes(SpatialReject::MissingCompute {
445            needed: "an integer elementwise datapath",
446            target: target.name,
447        }))?
448    };
449    let mut t = Template::new(target)?;
450    let parts: Vec<TensorId> = col
451        .partials
452        .iter()
453        .map(|n| t.tensor(n, TensorRole::Input, rows, cols, dt, TensorInit::External))
454        .collect();
455    let out = t.tensor(
456        &col.out,
457        TensorRole::Output,
458        rows,
459        cols,
460        dt,
461        TensorInit::Zero,
462    );
463    let (rows_t, cols_t) = (
464        rows.div_ceil(target.tile.rows),
465        cols.div_ceil(target.tile.cols),
466    );
467    let steps = tile_grid_steps(rows_t, cols_t);
468    let ins: Vec<BufferId> = (0..chips)
469        .map(|c| t.buffer(&format!("partial_{c}"), 1, dt))
470        .collect();
471    let loads: Vec<StageId> = (0..chips)
472        .map(|c| {
473            t.load(
474                &format!("load_{}", col.partials[c]),
475                parts[c],
476                steps.clone(),
477                1,
478                ins[c],
479            )
480        })
481        .collect();
482    let summed = t.buffer("sum", 1, dt);
483    let w = target.tile.cols;
484    let combine = ComputeOp::Generic {
485        iters: space(target, w, None),
486        ins: vec![ident_map(w); chips],
487        out: ident_map(w),
488        body: (1..chips).fold(TensorBody::Operand(0), |acc, i| {
489            TensorBody::Add(Box::new(acc), Box::new(TensorBody::Operand(i)))
490        }),
491    };
492    let fold = t.stage(
493        &format!("allreduce_{}", col.out),
494        StageKind::Compute(combine),
495        ins.clone(),
496        vec![summed],
497    );
498    for (l, b) in loads.iter().zip(&ins) {
499        t.edge(*l, *b, fold);
500    }
501    let st = t.store(&format!("store_{}", col.out), out, steps, 1, summed);
502    t.edge(fold, summed, st);
503    let (acc, claim) = if floats {
504        (
505            AccPolicy::F32,
506            NumericsClaim::Approx {
507                reason: format!("all-reduce of {chips} partials in {dt:?}"),
508            },
509        )
510    } else {
511        (AccPolicy::I64, NumericsClaim::Exact)
512    };
513    let proto = run_passes_uncertified(
514        narrow(t.finish(
515            format!("allreduce_{}_{chips}_chips", col.out),
516            items_per_tile_grid(rows_t, cols_t),
517            acc,
518            claim,
519            target,
520        )?),
521        target,
522    )?;
523
524    // ── Spread: each operand's Load, and the buffer it fills, onto its chip.
525    let crossing = crossing_kind(target).map_err(TpReject::NoCrossing)?;
526    let mut q = proto;
527    let chip_of_tensor: BTreeMap<TensorId, u16> = parts
528        .iter()
529        .enumerate()
530        .map(|(c, t)| (*t, c as u16))
531        .collect();
532    let mut chip_of_buffer: BTreeMap<BufferId, u16> = BTreeMap::new();
533    for s in &mut q.stages {
534        match &s.kind {
535            StageKind::Load { tensor, .. } if chip_of_tensor.contains_key(tensor) => {
536                let c = chip_of_tensor[tensor];
537                if let Some(x) = &mut s.site {
538                    on_chip(x, c);
539                }
540                chip_of_buffer.extend(s.outs.iter().map(|b| (*b, c)));
541            }
542            StageKind::Compute(op @ ComputeOp::Generic { .. }) => {
543                s.kind = StageKind::AllReduce {
544                    pool: ReducePool::Sum,
545                    tensor: out,
546                    combine: Box::new(op.clone()),
547                };
548            }
549            _ => {}
550        }
551    }
552    // ★ A PARTIAL THAT LEAVES ITS CHIP IS NOT A LOCAL HAND-OFF. A `Fifo` is held to one producing and one consuming
553    // edge on its own site, and an edge that crosses a chip is neither — the receiver owns what lands. `Interm` is the
554    // IR's kind for a partial sum, and carries no local pairing (as `passes::shard` spells its partials).
555    for b in &mut q.buffers {
556        if let (Some(c), Some(x)) = (chip_of_buffer.get(&b.id), &mut b.site) {
557            on_chip(x, *c);
558            if let (
559                true,
560                BufferKind::Fifo {
561                    depth_pages,
562                    page_tiles,
563                },
564            ) = (*c != 0, b.kind)
565            {
566                b.kind = BufferKind::Interm {
567                    depth_pages,
568                    page_tiles,
569                };
570            }
571        }
572    }
573    for e in &mut q.edges {
574        if chip_of_buffer.get(&e.buffer).is_some_and(|c| *c != 0) {
575            e.handshake = crossing_handshake(crossing);
576        }
577    }
578    if let Some(pl) = &mut q.placement {
579        for (i, s) in q.stages.iter().enumerate() {
580            if let Some(x) = s.site {
581                pl.site_of[i] = x;
582                pl.engine_of[i].site = x;
583            }
584        }
585        let mut hosting: BTreeSet<(Site, Vec<WorkItemId>)> = BTreeSet::new();
586        for s in q
587            .stages
588            .iter()
589            .filter_map(|s| s.site)
590            .filter(|x| x.chip != 0)
591        {
592            let home = Site { chip: 0, ..s };
593            hosting.insert((s, pl.items_at(home).to_vec()));
594        }
595        pl.items_of_site.extend(hosting);
596    }
597    for t in &mut q.tensors {
598        t.chip = chip_of_tensor.get(&t.id).map(|c| ChipId(*c));
599    }
600    q.requires.handshakes.insert(crossing);
601    q.semaphores.clear();
602    q.memory = None;
603    q.schedule = None;
604    q.certificate = None;
605    Ok(match certification {
606        SectionCertification::Composed => run_passes_uncertified(q, target)?,
607        SectionCertification::EachSection => run_passes(q, target)?,
608    })
609}
610
611/// The phases one chip already holds, as a pointer forest: a held phase points past itself, and a lookup follows the
612/// pointers to the first free phase and points everything it passed straight at it — amortized constant time, however
613/// many phases the chip holds.
614#[derive(Default)]
615struct FreePhases {
616    /// `next[x] == x` for a free phase; every phase at or past the end is free.
617    next: Vec<usize>,
618}
619
620impl FreePhases {
621    /// The first free phase at or after `x`.
622    fn first_free(&mut self, x: usize) -> usize {
623        let mut root = x;
624        while root < self.next.len() && self.next[root] != root {
625            root = self.next[root];
626        }
627        let mut y = x;
628        while y < self.next.len() && self.next[y] != y {
629            let n = self.next[y];
630            self.next[y] = root;
631            y = n;
632        }
633        root
634    }
635
636    /// Hold phase `x`, which is free.
637    fn take(&mut self, x: usize) {
638        if self.next.len() <= x {
639            let from = self.next.len();
640            self.next.extend(from..=x);
641        }
642        self.next[x] = x + 1;
643    }
644}
645
646/// What a section touches, as far as its phase is concerned: the tensors it reads and writes, by name, and its chips.
647#[derive(Clone, Debug, Default, PartialEq, Eq)]
648pub struct SectionTouch {
649    pub reads: BTreeSet<String>,
650    pub writes: BTreeSet<String>,
651    pub chips: BTreeSet<u16>,
652}
653
654impl SectionTouch {
655    pub fn of(p: &SpatialProgram) -> Self {
656        let mut t = SectionTouch::default();
657        for s in &p.stages {
658            match &s.kind {
659                StageKind::Load { tensor, .. } => {
660                    t.reads.insert(p.tensor(*tensor).name.clone());
661                }
662                StageKind::Store { tensor, .. } => {
663                    t.writes.insert(p.tensor(*tensor).name.clone());
664                }
665                _ => {}
666            }
667            if let Some(site) = s.site {
668                t.chips.insert(site.chip);
669            }
670        }
671        t
672    }
673}
674
675/// ★ THE CHIPS RUN AT ONCE. The source writes chip 0's slice of an op, then chip 1's; composed in that order with a
676/// barrier after each, chip 1 would wait out chip 0's whole slice. Two sections may share a PHASE — run with no
677/// barrier between them — when they are on disjoint chips and neither touches what the other writes. See
678/// [`phase_order`].
679pub fn phases(sections: &[SpatialProgram]) -> (Vec<usize>, Vec<bool>) {
680    phase_order(&sections.iter().map(SectionTouch::of).collect::<Vec<_>>())
681}
682
683/// Place each section, in source order, in the earliest phase after every section it depends on (a read of what an
684/// earlier one writes, a write of what an earlier one reads or writes — the carried state included) where its chips
685/// are free, and return the sections' order, phase by phase, with `barrier_after[k]` closing the last section of each
686/// phase.
687///
688/// A section depends on an earlier one exactly when some name witnesses it, so the phase it must follow is the latest
689/// phase any of its names was written in (or, for a name it writes, read in) — the pairwise maximum over the sections
690/// before it, taken per name. The first phase from there with its chips free is read off each chip's `FreePhases`:
691/// jumping to the latest of the chips' next free phases skips only phases some chip holds, so the first phase every
692/// chip agrees on is the earliest free one. Both keep the placement linear in the sections, where a walk phase by
693/// phase is quadratic in a batch whose independent sequences all start past the same phase.
694pub fn phase_order(touches: &[SectionTouch]) -> (Vec<usize>, Vec<bool>) {
695    let (mut written_in, mut read_in): (BTreeMap<&str, usize>, BTreeMap<&str, usize>) =
696        (BTreeMap::new(), BTreeMap::new());
697    let mut phase_of: Vec<usize> = Vec::with_capacity(touches.len());
698    let mut free: BTreeMap<u16, FreePhases> = BTreeMap::new();
699    for t in touches {
700        let raw = t.reads.iter().filter_map(|n| written_in.get(n.as_str()));
701        let war_waw = t
702            .writes
703            .iter()
704            .flat_map(|n| [written_in.get(n.as_str()), read_in.get(n.as_str())])
705            .flatten();
706        let after = raw.chain(war_waw).map(|ph| ph + 1).max().unwrap_or(0);
707        let mut ph = after;
708        loop {
709            let next = t
710                .chips
711                .iter()
712                .map(|c| free.entry(*c).or_default().first_free(ph))
713                .max()
714                .unwrap_or(ph);
715            if next == ph {
716                break;
717            }
718            ph = next;
719        }
720        for c in &t.chips {
721            free.entry(*c).or_default().take(ph);
722        }
723        phase_of.push(ph);
724        for n in &t.reads {
725            let r = read_in.entry(n.as_str()).or_insert(ph);
726            *r = (*r).max(ph);
727        }
728        for n in &t.writes {
729            let w = written_in.entry(n.as_str()).or_insert(ph);
730            *w = (*w).max(ph);
731        }
732    }
733    let mut order: Vec<usize> = (0..touches.len()).collect();
734    order.sort_by_key(|&k| (phase_of[k], k));
735    let barrier_after = order
736        .iter()
737        .enumerate()
738        .map(|(pos, &k)| {
739            order
740                .get(pos + 1)
741                .is_some_and(|&next| phase_of[next] != phase_of[k])
742        })
743        .collect();
744    (order, barrier_after)
745}