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}