Skip to main content

logicaffeine_compile/spatial/passes/
shard.rs

1//! ════════════════════════════════════════════════════════════════════════════════════════════════
2//! TENSOR PARALLELISM — one program over many RDUs, and the reduction that makes it mean something.
3//!
4//! An SN50 reaches its token throughput by splitting one model across chips; the published 16-chip
5//! tensor-parallel run is the shape this pass exists to express. Before it, our tensor parallelism was
6//! arithmetic in `spatial/fit.rs` — weight bytes DIVIDED by a chip count and reported to fit. That says
7//! a sharded model would fit. It does not say what a sharded model COMPUTES.
8//!
9//! ★ WHAT A ROW-PARALLEL SHARD ACTUALLY IS. For `C(m×n) = A(m×k)·B(k×n)`, split the CONTRACTION axis
10//! `k` into `chips` slices. Chip `c` holds `A[:, kc]` and `B[kc, :]` and computes
11//!
12//! ```text
13//! C_c = A[:, kc] · B[kc, :]
14//! ```
15//!
16//! which has the FULL `m×n` shape and is a PARTIAL SUM — it is missing every term outside its slice.
17//! The answer is `C = Σ_c C_c`, and that sum is the all-reduce. This is the whole hazard: `C_c` is
18//! correctly shaped, fully written, and wrong. A shard that never reduces returns `Ok` with `1/chips`
19//! of the answer, so the reduction is not an optimization to be scheduled later — it is part of the
20//! meaning of the program, and [`crate::spatial::certificate`] carries an obligation that says so.
21//!
22//! ★ THE CHIP IS A COORDINATE, NOT A COMPILATION. Sharding does not produce `chips` programs. It
23//! produces ONE program whose sites carry a chip index, because the all-reduce is an edge that spans
24//! chips and there is no single-chip program in which that edge can be written down. Everything
25//! downstream — placement, buffer sizing, handshake synthesis, the certificate — then sees the
26//! collective the way it sees every other hand-off.
27//!
28//! ★ THE COLLECTIVE'S RECEIVE SIDE IS STILL MISSING, and the IR is right to refuse it. `validate_ports`
29//! records the rule: "inter-site edges name the SOURCE buffer at the sender; the receivers own their own
30//! copies." A cross-site edge is therefore NOT a producer of the sender's buffer — so a reduce that reads
31//! the per-chip partial buffers directly leaves each of them with zero producers
32//! (`FifoProducerCount { buffer, count: 0 }`). That is not a technicality: it is the IR insisting that
33//! data crossing a chip boundary LANDS SOMEWHERE, in a buffer the receiving site owns. `StageKind::Recv`
34//! is already exactly this shape for multicast, and the collective must follow it — one receive per chip
35//! at the reducing site, the fold reading those local copies. Until that lands, `shard` builds a program
36//! the validator refuses, which is the honest state: it never returns a shard it cannot stand behind.
37//!
38//! ★ WHY THIS RUNS ON A PLACED PROGRAM. `place` and `tile` are no-ops once their results exist, so
39//! sharding a verified program and re-running the pinned passes rebuilds exactly the parts that must
40//! change (buffers, handshakes, schedule, certificate) and preserves the tiling decision the cost model
41//! already made. The chip assignment this pass writes therefore survives, which it would not if the
42//! program were re-placed from scratch.
43//! ════════════════════════════════════════════════════════════════════════════════════════════════
44
45use crate::codegen_sva::fabric_target::Precision;
46use crate::spatial::ir::*;
47use crate::spatial::passes::from_tensor_op::ident_map;
48use crate::spatial::passes::from_tensor_op::MatmulNames;
49use crate::spatial::passes::SpatialReject;
50use crate::spatial::passes::{LV_GEN_COL, LV_GEN_ROW};
51use crate::spatial::pipeline::run_passes;
52use crate::spatial::pipeline::template_from_spec;
53use crate::spatial::target::HandshakeKind;
54use crate::spatial::target::SpatialTarget;
55use crate::tensor_recognize::{Fill, MatmulSpec};
56
57/// How a program is split across chips.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct ShardPlan {
60    /// How many RDUs the program is spread over. `1` is the identity.
61    pub chips: usize,
62}
63
64impl ShardPlan {
65    /// Split every matmul along its CONTRACTION axis: each chip computes a full-shape partial, and the
66    /// partials are summed. This is the half of Megatron tensor parallelism that needs a collective.
67    pub fn row_parallel(chips: usize) -> ShardPlan {
68        ShardPlan { chips }
69    }
70}
71
72#[derive(Clone, Debug, PartialEq)]
73pub enum ShardError {
74    /// There is nothing to shard: no matmul, so no contraction axis to split.
75    NothingToShard,
76    /// The contraction axis does not divide evenly. Refused rather than rounded — a rounded shard drops
77    /// or double-counts a slice of the contraction, and the sum of the partials is then quietly wrong.
78    AxisNotDivisible {
79        axis: &'static str,
80        extent: usize,
81        chips: usize,
82    },
83    /// The slice would not be a whole number of native tiles, so a chip's operand could not be loaded by
84    /// the tile-addressed movement stages.
85    SliceNotTileAligned {
86        extent: usize,
87        chips: usize,
88        tile: usize,
89    },
90    /// A fill that cannot be sliced faithfully: the shard's elements would not be the original's.
91    UnsliceableFill { tensor: String, why: &'static str },
92    /// The target offers no hand-off that crosses a site, so a collective cannot be expressed on it at
93    /// all — every edge it provides means "these two stages share a site".
94    NoCrossChipHandshake { target: &'static str },
95    /// The re-run of the pinned passes rejected the sharded program.
96    Passes(SpatialReject),
97}
98
99/// The front door speaks `Fill`; a lowered program carries `TensorInit`. A slice keeps the ORIGINAL
100/// element range (a slice of `(p % 7) - 3` still lies in `[-3, 3]`), so the recovered fill is what decides
101/// the operand precision for the per-chip shape exactly as it did for the whole.
102fn fill_of(init: &TensorInit) -> Fill {
103    match init {
104        TensorInit::Zero => Fill::Const(0),
105        TensorInit::Pattern { modulus, shift } => Fill::Pattern {
106            modulus: *modulus,
107            shift: *shift,
108        },
109        TensorInit::ConstF(b) => Fill::ConstF(*b),
110        TensorInit::Values(v) => Fill::Overrides {
111            base: Box::new(Fill::Const(0)),
112            sets: v.iter().copied().enumerate().collect(),
113        },
114        TensorInit::FloatValues(v) => Fill::FloatOverrides {
115            base: 0,
116            sets: v.iter().copied().enumerate().collect(),
117        },
118        TensorInit::RandomBf16 { .. } | TensorInit::External => Fill::Unknown,
119    }
120}
121
122/// A tensor's flat element slice for chip `c` of `chips`, along `axis`.
123#[derive(Clone, Copy, PartialEq, Eq)]
124enum Axis {
125    Rows,
126    Cols,
127}
128
129/// Slice a deterministic fill so chip `c`'s elements are the ORIGINAL elements at their original
130/// positions. A fill that is a function of the flat index cannot simply be carried over: chip 1's
131/// element 0 is the whole tensor's element `offset`, not element 0.
132fn slice_init(
133    t: &TensorDecl,
134    axis: Axis,
135    c: usize,
136    chips: usize,
137) -> Result<TensorInit, ShardError> {
138    let (rows, cols) = (t.elems.rows, t.elems.cols);
139    let unsliceable = |why: &'static str| {
140        Err(ShardError::UnsliceableFill {
141            tensor: t.name.clone(),
142            why,
143        })
144    };
145    match &t.init {
146        // Zero slices to zero, and External stays external: the host supplies each chip its own shard,
147        // which is exactly what a real checkpoint load does.
148        TensorInit::Zero => Ok(TensorInit::Zero),
149        // ★ A CONSTANT IS CONSTANT ON EVERY SLICE. The shard of a tensor whose every element is `b` is a
150        // tensor whose every element is `b` — no elements need looking at, which is the whole point of
151        // holding it symbolically.
152        TensorInit::ConstF(b) => Ok(TensorInit::ConstF(*b)),
153        TensorInit::External => Ok(TensorInit::External),
154        TensorInit::Pattern { .. } | TensorInit::Values(_) => {
155            let mut vals = Vec::new();
156            match axis {
157                Axis::Rows => {
158                    let per = rows / chips;
159                    for r in c * per..(c + 1) * per {
160                        for j in 0..cols {
161                            vals.push(t.init.element(r * cols + j).ok_or_else(|| {
162                                ShardError::UnsliceableFill {
163                                    tensor: t.name.clone(),
164                                    why: "a fill shorter than the tensor",
165                                }
166                            })?);
167                        }
168                    }
169                }
170                Axis::Cols => {
171                    let per = cols / chips;
172                    for r in 0..rows {
173                        for j in c * per..(c + 1) * per {
174                            vals.push(t.init.element(r * cols + j).ok_or_else(|| {
175                                ShardError::UnsliceableFill {
176                                    tensor: t.name.clone(),
177                                    why: "a fill shorter than the tensor",
178                                }
179                            })?);
180                        }
181                    }
182                }
183            }
184            Ok(TensorInit::Values(vals))
185        }
186        TensorInit::FloatValues(v) => {
187            let mut vals = Vec::new();
188            match axis {
189                Axis::Rows => {
190                    let per = rows / chips;
191                    for r in c * per..(c + 1) * per {
192                        for j in 0..cols {
193                            vals.push(*v.get(r * cols + j).ok_or_else(|| {
194                                ShardError::UnsliceableFill {
195                                    tensor: t.name.clone(),
196                                    why: "a float fill shorter than the tensor",
197                                }
198                            })?);
199                        }
200                    }
201                }
202                Axis::Cols => {
203                    let per = cols / chips;
204                    for r in 0..rows {
205                        for j in c * per..(c + 1) * per {
206                            vals.push(*v.get(r * cols + j).ok_or_else(|| {
207                                ShardError::UnsliceableFill {
208                                    tensor: t.name.clone(),
209                                    why: "a float fill shorter than the tensor",
210                                }
211                            })?);
212                        }
213                    }
214                }
215            }
216            Ok(TensorInit::FloatValues(vals))
217        }
218        // The vendor's generator is a function of the whole tensor's shape; a slice of it is not the
219        // slice of the values it would have produced, so there is no faithful shard.
220        TensorInit::RandomBf16 { .. } => {
221            unsliceable("a vendor bf16 random fill is not reproducible slice-wise")
222        }
223    }
224}
225
226fn sliced_decl(
227    t: &TensorDecl,
228    axis: Axis,
229    c: usize,
230    chips: usize,
231    id: TensorId,
232    tile: usize,
233) -> Result<TensorDecl, ShardError> {
234    let init = slice_init(t, axis, c, chips)?;
235    let (rows, cols) = match axis {
236        Axis::Rows => (t.elems.rows / chips, t.elems.cols),
237        Axis::Cols => (t.elems.rows, t.elems.cols / chips),
238    };
239    let tiles = TileGrid {
240        rows_t: rows.div_ceil(tile),
241        cols_t: cols.div_ceil(tile),
242    };
243    Ok(TensorDecl {
244        id,
245        name: format!("{}_c{c}", t.name),
246        role: t.role,
247        elems: ElemShape { rows, cols },
248        tiles,
249        dtype: t.dtype,
250        layout: t.layout,
251        space: t.space,
252        init,
253        range: t.range,
254        chip: Some(ChipId(c as u16)),
255    })
256}
257
258/// The hand-off a partial crosses a chip boundary on: the first of `Token`, `Semaphore`, `Barrier` the target provides.
259/// A local hand-off (`Fifo`, `ValidReady`) means "these two stages share a site", which a chip boundary is not.
260pub(crate) fn crossing_kind(target: &SpatialTarget) -> Result<HandshakeKind, ShardError> {
261    [
262        HandshakeKind::Token,
263        HandshakeKind::Semaphore,
264        HandshakeKind::Barrier,
265    ]
266    .into_iter()
267    .find(|k| target.provides_handshake(*k))
268    .ok_or(ShardError::NoCrossChipHandshake {
269        target: target.name,
270    })
271}
272
273/// The edge a crossing of kind `kind` is spelled as.
274pub(crate) fn crossing_handshake(kind: HandshakeKind) -> Handshake {
275    match kind {
276        HandshakeKind::Token => Handshake::Token,
277        _ => Handshake::Semaphore {
278            ready: SemId::new(0),
279            valid: SemId::new(1),
280            ready_count: 1,
281        },
282    }
283}
284
285/// The tensor a stage loads, if it is a Load.
286fn loaded_tensor(s: &Stage) -> Option<TensorId> {
287    match &s.kind {
288        StageKind::Load { tensor, .. } => Some(*tensor),
289        _ => None,
290    }
291}
292
293/// Split `p` over `plan.chips` RDUs along the contraction axis, insert the all-reduce, and re-run the
294/// pinned passes so the collective is scheduled and certified like any other hand-off.
295pub fn shard(
296    p: &SpatialProgram,
297    plan: &ShardPlan,
298    target: &SpatialTarget,
299) -> Result<SpatialProgram, ShardError> {
300    let chips = plan.chips;
301    if chips <= 1 {
302        return Ok(p.clone());
303    }
304
305    // ── The matmul, and the two operands whose contraction axis is being split.
306    let mm = p
307        .stages
308        .iter()
309        .find(|s| matches!(s.kind, StageKind::Compute(ComputeOp::MatmulTiles { .. })))
310        .ok_or(ShardError::NothingToShard)?;
311    let producer_of = |buf: BufferId| {
312        p.edges
313            .iter()
314            .find(|e| e.buffer == buf)
315            .map(|e| p.stage(e.from))
316    };
317    let lhs_stage = mm
318        .ins
319        .first()
320        .and_then(|b| producer_of(*b))
321        .ok_or(ShardError::NothingToShard)?;
322    let rhs_stage = mm
323        .ins
324        .get(1)
325        .and_then(|b| producer_of(*b))
326        .ok_or(ShardError::NothingToShard)?;
327    let lhs_t = loaded_tensor(lhs_stage).ok_or(ShardError::NothingToShard)?;
328    let rhs_t = loaded_tensor(rhs_stage).ok_or(ShardError::NothingToShard)?;
329    let lhs = p
330        .tensors
331        .iter()
332        .find(|t| t.id == lhs_t)
333        .ok_or(ShardError::NothingToShard)?;
334    let rhs = p
335        .tensors
336        .iter()
337        .find(|t| t.id == rhs_t)
338        .ok_or(ShardError::NothingToShard)?;
339
340    // The contraction extent, agreed by both operands.
341    let extent = lhs.elems.cols;
342    if extent != rhs.elems.rows {
343        return Err(ShardError::NothingToShard);
344    }
345    if extent % chips != 0 {
346        return Err(ShardError::AxisNotDivisible {
347            axis: "the contraction axis",
348            extent,
349            chips,
350        });
351    }
352    let tile = p.tile.cols.max(1);
353    if (extent / chips) % tile != 0 {
354        return Err(ShardError::SliceNotTileAligned {
355            extent,
356            chips,
357            tile,
358        });
359    }
360
361    // ── ONE CHIP'S PROGRAM, COMPILED FROM A SPEC FOR THE SLICE IT ACTUALLY HOLDS.
362    //
363    // ★ WHY THE SLICE IS RE-LOWERED FROM A SPEC RATHER THAN EDITED INTO THE LOWERED PROGRAM. Lowering
364    // BAKES THE TILING IN. A matmul over a 512-deep contraction lowers to Load stages carrying
365    // `num_blocks: 16` and a `MatmulTiles` carrying the same — numbers that live in the STAGES, not only
366    // in `work.plan`. Slicing the tensor declarations and clearing the plan therefore produces a program
367    // whose tensors say 256 deep and whose stages still say sixteen K blocks: the matmul waits on pages
368    // the loads never push, and the choreography fails its counting balance. (Measured, not supposed:
369    // `CountingImbalance { buffer: BufferId(0), reserved: 0, pushed: 0, popped: 1 }` with tensors
370    // correctly sliced to `a_c0 32x256` and stages still reading `nb=16`.)
371    //
372    // So the shard goes back to the FRONT DOOR with the dimensions it actually has, and the pinned passes
373    // choose the block plan that belongs to a contraction of `k/chips`. Every chip has the same shape and
374    // differs only in data, so this is compiled once and replicated.
375    let out = p
376        .tensors
377        .iter()
378        .find(|t| t.role == TensorRole::Output)
379        .ok_or(ShardError::NothingToShard)?;
380    let names = MatmulNames {
381        a: lhs.name.clone(),
382        b: rhs.name.clone(),
383        c: out.name.clone(),
384    };
385    let spec = MatmulSpec {
386        m: lhs.elems.rows,
387        n: rhs.elems.cols,
388        k: extent / chips,
389        a: Default::default(),
390        b: Default::default(),
391        c: Default::default(),
392    };
393    let (fa, fb) = (fill_of(&lhs.init), fill_of(&rhs.init));
394    let proto = template_from_spec(
395        &spec,
396        &names,
397        (&fa, &fb),
398        (lhs.range, rhs.range),
399        p.numerics,
400        target,
401    )
402    .and_then(|t| run_passes(t, target))
403    .map_err(ShardError::Passes)?;
404
405    // ── Per-chip tensors. Chip 0's slices are already in `proto`; every chip gets its own ids so the
406    // shards are distinct tensors with distinct data, which is what "chip 0 holds 1/chips of the
407    // weights" has to mean if it means anything.
408    let mut tensors: Vec<TensorDecl> = proto
409        .tensors
410        .iter()
411        .filter(|t| t.id != lhs_t && t.id != rhs_t)
412        .cloned()
413        .collect();
414    let mut next_tensor = proto.tensors.iter().map(|t| t.id.0).max().unwrap_or(0) + 1;
415    let mut lhs_of_chip = Vec::with_capacity(chips);
416    let mut rhs_of_chip = Vec::with_capacity(chips);
417    for c in 0..chips {
418        let a = TensorId(next_tensor);
419        next_tensor += 1;
420        tensors.push(sliced_decl(lhs, Axis::Cols, c, chips, a, tile)?);
421        lhs_of_chip.push(a);
422        let b = TensorId(next_tensor);
423        next_tensor += 1;
424        tensors.push(sliced_decl(rhs, Axis::Rows, c, chips, b, tile)?);
425        rhs_of_chip.push(b);
426    }
427
428    // ── Replicate the per-chip pipeline, then ONE all-reduce, then the store it feeds.
429    let store = proto
430        .stages
431        .iter()
432        .find(|s| matches!(s.kind, StageKind::Store { .. }))
433        .ok_or(ShardError::NothingToShard)?
434        .clone();
435    let per_chip: Vec<&Stage> = proto.stages.iter().filter(|s| s.id != store.id).collect();
436
437    let mut stages: Vec<Stage> = Vec::new();
438    let mut buffers: Vec<Buffer> = Vec::new();
439    let mut edges: Vec<Edge> = Vec::new();
440    let mut next_stage = 0u32;
441    let mut next_buffer = 0u32;
442    let mut partial_of_chip = Vec::with_capacity(chips);
443    // The proto stage each emitted stage was cloned from, so the ENGINE assignment is inherited rather
444    // than invented. Which engine a movement stage runs on is a scheduling decision the placer already
445    // made — two operand loads share a site but not an engine, and collapsing them onto one serializes a
446    // hand-off the choreography counts on.
447    let mut from_proto: Vec<StageId> = Vec::new();
448
449    for c in 0..chips {
450        let chip = ChipId(c as u16);
451        let on_chip = |s: Option<Site>| s.map(|s| Site::on_chip(chip, s.x as usize, s.y as usize));
452        let mut buf_map: Vec<(BufferId, BufferId)> = Vec::new();
453        for b in &proto.buffers {
454            let id = BufferId(next_buffer);
455            next_buffer += 1;
456            buf_map.push((b.id, id));
457            // ★ THE BUFFER THAT LEAVES THE CHIP HOLDS A PARTIAL SUM, and its only consumer is the
458            // collective on another chip. A `Fifo` is the IR's LOCAL hand-off and is held to exactly one
459            // producing and one consuming edge — a count that inter-site edges deliberately do not
460            // contribute to, since "the receivers own their own copies". `Interm` is the kind the IR
461            // already has for a partial-sum buffer, and it carries no local-pairing obligation, so it is
462            // both the legal and the honest spelling for a shard's outgoing partial.
463            // Chip 0 reduces in place, so ITS partial never leaves; only the other chips' do.
464            let leaves_chip = c != 0
465                && proto.stages.iter().any(|s| {
466                    s.outs.contains(&b.id)
467                        && proto
468                            .edges
469                            .iter()
470                            .any(|e| e.buffer == b.id && e.to.contains(&store.id))
471                });
472            let kind = match (leaves_chip, b.kind) {
473                (
474                    true,
475                    BufferKind::Fifo {
476                        depth_pages,
477                        page_tiles,
478                    },
479                ) => BufferKind::Interm {
480                    depth_pages,
481                    page_tiles,
482                },
483                (_, k) => k,
484            };
485            buffers.push(Buffer {
486                id,
487                name: format!("{}_c{c}", b.name),
488                kind,
489                dtype: b.dtype,
490                space: b.space,
491                site: on_chip(b.site),
492                origin: None,
493                alias_of: None,
494            });
495        }
496        let mb = |old: BufferId| {
497            buf_map
498                .iter()
499                .find(|(o, _)| *o == old)
500                .map(|(_, n)| *n)
501                .expect("every buffer mapped")
502        };
503        let mut stage_map: Vec<(StageId, StageId)> = Vec::new();
504        for s in &per_chip {
505            let id = StageId(next_stage);
506            next_stage += 1;
507            stage_map.push((s.id, id));
508            from_proto.push(s.id);
509            let kind = match &s.kind {
510                StageKind::Load {
511                    tensor,
512                    pattern,
513                    num_blocks,
514                    multicast,
515                } => {
516                    let t = if *tensor == lhs_t {
517                        lhs_of_chip[c]
518                    } else if *tensor == rhs_t {
519                        rhs_of_chip[c]
520                    } else {
521                        *tensor
522                    };
523                    StageKind::Load {
524                        tensor: t,
525                        pattern: pattern.clone(),
526                        num_blocks: *num_blocks,
527                        multicast: *multicast,
528                    }
529                }
530                other => other.clone(),
531            };
532            stages.push(Stage {
533                id,
534                name: format!("{}_c{c}", s.name),
535                kind,
536                role: s.role,
537                ins: s.ins.iter().map(|b| mb(*b)).collect(),
538                outs: s.outs.iter().map(|b| mb(*b)).collect(),
539                site: on_chip(s.site),
540                item: s.item,
541                origin: None,
542            });
543        }
544        let ms = |old: StageId| {
545            stage_map
546                .iter()
547                .find(|(o, _)| *o == old)
548                .map(|(_, n)| *n)
549                .expect("every stage mapped")
550        };
551        for e in &proto.edges {
552            if e.to.contains(&store.id) {
553                partial_of_chip.push(mb(e.buffer));
554                continue;
555            }
556            edges.push(Edge {
557                id: EdgeId(edges.len() as u32),
558                from: ms(e.from),
559                buffer: mb(e.buffer),
560                to: e.to.iter().map(|t| ms(*t)).collect(),
561                handshake: e.handshake.clone(),
562            });
563        }
564    }
565
566    if partial_of_chip.len() != chips {
567        return Err(ShardError::NothingToShard);
568    }
569
570    // ── The collective, on chip 0 beside the store that consumes its result.
571    let out_tensor = match &store.kind {
572        StageKind::Store { tensor, .. } => *tensor,
573        _ => return Err(ShardError::NothingToShard),
574    };
575    let seed = store
576        .ins
577        .first()
578        .and_then(|b| proto.buffers.iter().find(|x| x.id == *b));
579    let reduced = BufferId(next_buffer);
580    let home = store
581        .site
582        .map(|s| Site::on_chip(ChipId(0), s.x as usize, s.y as usize));
583    buffers.push(Buffer {
584        id: reduced,
585        name: "allreduce_out".into(),
586        kind: seed.map(|b| b.kind).unwrap_or(BufferKind::Fifo {
587            depth_pages: 2,
588            page_tiles: 1,
589        }),
590        dtype: seed.map(|b| b.dtype).unwrap_or(Precision::Int64),
591        space: seed.map(|b| b.space).unwrap_or(MemSpace::Local),
592        site: home,
593        origin: None,
594        alias_of: None,
595    });
596    // ★ THE COMBINE IS AN N-ARY ELEMENTWISE FOLD, nothing more. `Sum` over `chips` operands is
597    // `Add(Add(op0, op1), op2)…` on identity maps — the closed algebra's general form, so the collective
598    // needs no print arm, no evaluator arm and no cost arm of its own.
599    let width = target.tile.cols;
600    let combine = ComputeOp::Generic {
601        iters: vec![
602            IterSpec {
603                var: LV_GEN_ROW,
604                extent: target.tile.rows,
605                kind: IterKind::Parallel,
606            },
607            IterSpec {
608                var: LV_GEN_COL,
609                extent: width.max(1),
610                kind: IterKind::Parallel,
611            },
612        ],
613        ins: (0..chips).map(|_| ident_map(width)).collect(),
614        out: ident_map(width),
615        body: (1..chips).fold(TensorBody::Operand(0), |acc, i| {
616            TensorBody::Add(Box::new(acc), Box::new(TensorBody::Operand(i)))
617        }),
618    };
619    let reduce_id = StageId(next_stage);
620    next_stage += 1;
621    stages.push(Stage {
622        id: reduce_id,
623        name: format!("allreduce_{chips}_chips"),
624        kind: StageKind::AllReduce {
625            pool: ReducePool::Sum,
626            tensor: out_tensor,
627            combine: Box::new(combine),
628        },
629        role: SiteRole::Compute,
630        ins: partial_of_chip.clone(),
631        outs: vec![reduced],
632        site: home,
633        item: store.item,
634        origin: None,
635    });
636    from_proto.push(store.id);
637    let store_id = StageId(next_stage);
638    stages.push(Stage {
639        id: store_id,
640        name: store.name.clone(),
641        kind: store.kind.clone(),
642        role: store.role,
643        ins: vec![reduced],
644        outs: Vec::new(),
645        site: home,
646        item: store.item,
647        origin: None,
648    });
649    from_proto.push(store.id);
650    // ★ A PARTIAL CROSSES A CHIP BOUNDARY, so its edge may not carry a LOCAL hand-off. A Fifo or
651    // ValidReady edge means "these two stages share a site"; the IR refuses one whose endpoints do not
652    // (`CrossSiteLocalEdge`), which is the invariant catching the difference between a collective and an
653    // ordinary producer/consumer pair. The handshake is taken from the target rather than hardcoded so
654    // the lane is not SN50-shaped by accident.
655    let local = crate::spatial::passes::from_tensor_op::local_handshake(target)
656        .map_err(ShardError::Passes)?;
657    let crossing = crossing_kind(target)?;
658    for (c, b) in partial_of_chip.iter().enumerate() {
659        let from = stages
660            .iter()
661            .find(|s| s.outs.contains(b))
662            .map(|s| s.id)
663            .expect("a partial has a producer");
664        // The collective reduces ON chip 0, so chip 0's own partial is a SAME-SITE hand-off and must use
665        // the local one; only the other chips' partials cross. An inter-site handshake between two stages
666        // that share a site is refused just as firmly as the reverse (`LocalCrossSiteEdge`), and rightly:
667        // each spelling is a claim about where the data has to travel.
668        let handshake = if c == 0 {
669            // The target's own local hand-off, not a bare `Fifo`: `Fifo` is the TEMPLATE's abstract
670            // spelling and SN50 does not provide it (its local kind is ValidReady).
671            local.clone()
672        } else {
673            crossing_handshake(crossing)
674        };
675        edges.push(Edge {
676            id: EdgeId(edges.len() as u32),
677            from,
678            buffer: *b,
679            to: vec![reduce_id],
680            handshake,
681        });
682    }
683    edges.push(Edge {
684        id: EdgeId(edges.len() as u32),
685        from: reduce_id,
686        buffer: reduced,
687        to: vec![store_id],
688        handshake: local.clone(),
689    });
690
691    // ── The placement, rewritten with the chip coordinate so `place` leaves it alone.
692    let placement = proto.placement.as_ref().map(|pl| Placement {
693        mesh: pl.mesh,
694        site_of: stages
695            .iter()
696            .map(|s| s.site.unwrap_or(Site::new(0, 0)))
697            .collect(),
698        engine_of: stages
699            .iter()
700            .enumerate()
701            .map(|(i, s)| {
702                let inherited = from_proto.get(i).and_then(|o| pl.engine_of.get(o.idx()));
703                // Inherit the placer's engine choice, EXCEPT where the role disagrees: the collective is
704                // the one stage with no counterpart in the per-chip program, and it folds, so it belongs
705                // on a compute engine rather than on the movement engine its neighbouring store uses.
706                let engine = match inherited.map(|e| e.engine) {
707                    Some(e) if (e == Engine::Compute) == (s.role == SiteRole::Compute) => e,
708                    _ if s.role == SiteRole::Compute => Engine::Compute,
709                    _ => Engine::Movement(0),
710                };
711                EngineSlot {
712                    site: s.site.unwrap_or(Site::new(0, 0)),
713                    engine,
714                    noc: inherited.and_then(|e| e.noc),
715                }
716            })
717            .collect(),
718        groups: pl.groups.clone(),
719        items_of_site: {
720            let mut v: Vec<(Site, Vec<WorkItemId>)> = Vec::new();
721            for (site, items) in &pl.items_of_site {
722                for c in 0..chips {
723                    v.push((
724                        Site::on_chip(ChipId(c as u16), site.x as usize, site.y as usize),
725                        items.clone(),
726                    ));
727                }
728            }
729            v
730        },
731    });
732
733    // ── Dense tensor ids. Removing the two whole operands and appending `2*chips` slices leaves the
734    // surviving tensors at their OLD ids and the list out of step with them; every index-addressed lookup
735    // downstream then reads the wrong declaration. Renumber by position and rewrite every stage that
736    // names a tensor.
737    let remap: Vec<(TensorId, TensorId)> = tensors
738        .iter()
739        .enumerate()
740        .map(|(i, t)| (t.id, TensorId(i as u32)))
741        .collect();
742    let rt = |old: TensorId| {
743        remap
744            .iter()
745            .find(|(o, _)| *o == old)
746            .map(|(_, n)| *n)
747            .unwrap_or(old)
748    };
749    for (i, t) in tensors.iter_mut().enumerate() {
750        t.id = TensorId(i as u32);
751    }
752    for st in &mut stages {
753        match &mut st.kind {
754            StageKind::Load { tensor, .. } | StageKind::Store { tensor, .. } => {
755                *tensor = rt(*tensor)
756            }
757            StageKind::AllReduce { tensor, .. } => *tensor = rt(*tensor),
758            _ => {}
759        }
760    }
761
762    let sharded = SpatialProgram {
763        name: format!("{}_tp{chips}", p.name),
764        tile: proto.tile,
765        tensors,
766        stages,
767        buffers,
768        edges,
769        semaphores: Vec::new(),
770        sections: Vec::new(),
771        trips: proto
772            .trips
773            .iter()
774            .map(|t| Trip {
775                tensor: rt(t.tensor),
776                ..t.clone()
777            })
778            .collect(),
779        links: Vec::new(),
780        work: proto.work.clone(),
781        numerics: proto.numerics,
782        claim: proto.claim.clone(),
783        acc_tiles_used: proto.acc_tiles_used,
784        mem_banks: proto.mem_banks,
785        requires: {
786            // The shard introduces a hand-off the single-chip program never needed; `requires` is the
787            // program's own declaration of what its target must provide, so it has to name it.
788            let mut r = proto.requires.clone();
789            r.handshakes.insert(crossing);
790            r
791        },
792        placement,
793        memory: None,
794        schedule: None,
795        certificate: None,
796    };
797    run_passes(sharded, target).map_err(ShardError::Passes)
798}