Skip to main content

logicaffeine_compile/spatial/passes/
compose.rs

1//! `compose` — N verified sections become ONE `SpatialProgram`: the persistent-decoder form.
2//!
3//! ★ THE UNIT OF LOWERING IS A SECTION; THE UNIT OF DEPLOYMENT IS A PROGRAM. Each section arrives here
4//! already tiled, placed, sized, scheduled and verified on its own — which is what makes each one
5//! checkable — and this pass joins them without re-deriving any of it: ids are offset, tensors are
6//! unified BY NAME, engine programs are concatenated per `(site, engine)` in section order, and every
7//! boundary gets a rendezvous every engine passes through. What was `Output` in one section and `Input`
8//! in a later one becomes an `Activation`; what the front end named as carried becomes `State`.
9//!
10//! ★ WHAT THE BARRIER BUYS, AND WHY NOTHING CHEAPER WORKS. Concatenating per engine already orders a
11//! section against the next ON THAT ENGINE. It says nothing across engines: the movement engine that
12//! loads section k+1's operand runs concurrently with the one that stores section k's result, and
13//! without a rendezvous it will read the tensor before it is written. For an activation that is a
14//! `ReadOutOfRange` — loud. For carried state it is last token's value — silent, and plausible. So the
15//! barrier is not an optimization knob; it is the thing that makes the composition mean anything.
16//!
17//! ★ THE RENDEZVOUS IS A GATHER AND A RELEASE. Every one of the `P` participating programs posts one
18//! arrival at the root (the first participant in site order); the root waits for all `P`, posts one
19//! release at each of the `S` participating sites, and every program waits for its own site's release.
20//! That is `2P + S + 1` ops and `P + S` marked-graph places per boundary, where posting every arrival at
21//! every site would be `P·S` of each — the difference between a real-width layer's certificate being
22//! its sections' size and being ten times it. `spatial_rendezvous_lock` pins the shape and breaks each
23//! of its ops to show the IR refuses the result.
24
25use std::collections::{BTreeMap, BTreeSet};
26
27use super::recognize_stages::Link;
28use super::SpatialReject;
29use crate::codegen_sva::fabric_target::Precision;
30use crate::spatial::ir::*;
31use crate::spatial::target::{precision_bytes, precision_is_int, SpatialTarget};
32
33/// The wider of two floats when one holds every value the other does: fp32 over bf16 or fp16. Two 16-bit floats of
34/// different kinds, or an integer, have no wider.
35fn wider_float(a: Precision, b: Precision) -> Option<Precision> {
36    if precision_is_int(a) || precision_is_int(b) {
37        return None;
38    }
39    match precision_bytes(a).cmp(&precision_bytes(b)) {
40        std::cmp::Ordering::Greater => Some(a),
41        std::cmp::Ordering::Less => Some(b),
42        std::cmp::Ordering::Equal => None,
43    }
44}
45
46/// Where the composer refuses — always by name, never by dropping a section.
47#[derive(Clone, Debug, PartialEq)]
48pub enum ComposeReject {
49    /// Nothing to compose.
50    NoSections,
51    /// Two sections give one tensor two different shapes or element types. Composition is only
52    /// meaningful when producer and consumer agree about where element `p` lives.
53    TensorShapeConflict {
54        name: String,
55        first: (usize, usize, Precision),
56        second: (usize, usize, Precision),
57    },
58    /// A section's schedule is missing — `compose` joins VERIFIED sections, never templates.
59    SectionNotScheduled { section: usize },
60    /// The joined program breaks a structural invariant.
61    Invalid(IrViolation),
62    /// A pass refused the joined program.
63    Reject(Box<SpatialReject>),
64    /// A phase's sections share a site and, laid out one past another, need more of its memory than it has.
65    PhaseOverflowsMemory { bytes: u32, capacity: usize },
66}
67
68impl From<IrViolation> for ComposeReject {
69    fn from(v: IrViolation) -> Self {
70        ComposeReject::Invalid(v)
71    }
72}
73
74impl From<SpatialReject> for ComposeReject {
75    fn from(v: SpatialReject) -> Self {
76        ComposeReject::Reject(Box::new(v))
77    }
78}
79
80/// How the sections are spread over the mesh.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum SectionPlacement {
83    /// Every section keeps the sites its own placement chose — sections run in sequence and REUSE the
84    /// same local memory, which is the persistent-decoder form and the only one that fits a hundred-op
85    /// forward in one site's L1.
86    Stacked,
87    /// Section `k` is shifted onto mesh site `k mod |mesh|`, so a mesh with room holds several sections
88    /// at once. Still ordered by the same barriers; this only changes WHERE, never WHEN.
89    RoundRobin,
90}
91
92/// The semaphores a section boundary adds: its rendezvous' gather and release.
93const RENDEZVOUS_SEMS: usize = 2;
94
95/// The id offsets one section is shifted by.
96struct Shift {
97    tensor: Vec<TensorId>,
98    stage: usize,
99    buffer: usize,
100    edge: usize,
101    sem: usize,
102    item: usize,
103    group: usize,
104    trip: usize,
105}
106
107impl Shift {
108    fn t(&self, id: TensorId) -> TensorId {
109        self.tensor[id.idx()]
110    }
111    fn s(&self, id: StageId) -> StageId {
112        StageId::new(self.stage + id.idx())
113    }
114    fn b(&self, id: BufferId) -> BufferId {
115        BufferId::new(self.buffer + id.idx())
116    }
117    fn e(&self, id: EdgeId) -> EdgeId {
118        EdgeId::new(self.edge + id.idx())
119    }
120    fn m(&self, id: SemId) -> SemId {
121        SemId::new(self.sem + id.idx())
122    }
123    fn i(&self, id: WorkItemId) -> WorkItemId {
124        WorkItemId::new(self.item + id.idx())
125    }
126    fn g(&self, id: GroupId) -> GroupId {
127        GroupId::new(self.group + id.idx())
128    }
129    fn r(&self, id: TripId) -> TripId {
130        TripId::new(self.trip + id.idx())
131    }
132    fn count(&self, c: LoopCount) -> LoopCount {
133        match c {
134            LoopCount::Const(n) => LoopCount::Const(n),
135            LoopCount::Trip { trip, max } => LoopCount::Trip {
136                trip: self.r(trip),
137                max,
138            },
139        }
140    }
141    fn addr(&self, a: &TileAddr) -> TileAddr {
142        TileAddr {
143            base: a.base,
144            steps: a.steps.clone(),
145            indirect: a.indirect.map(|i| IndirectTile {
146                index: self.b(i.index),
147                ..i
148            }),
149        }
150    }
151    fn pattern(&self, a: &AccessPattern) -> AccessPattern {
152        AccessPattern {
153            origin: self.addr(&a.origin),
154            ..a.clone()
155        }
156    }
157    fn compute(&self, op: &ComputeOp) -> ComputeOp {
158        match op {
159            ComputeOp::MatmulTiles { interm, .. } => {
160                let mut o = op.clone();
161                if let ComputeOp::MatmulTiles { interm: i, .. } = &mut o {
162                    *i = interm.map(|b| self.b(b));
163                }
164                o
165            }
166            ComputeOp::Bias { bias } => ComputeOp::Bias {
167                bias: self.b(*bias),
168            },
169            other => other.clone(),
170        }
171    }
172    fn handshake(&self, h: &Handshake) -> Handshake {
173        match h {
174            Handshake::Semaphore {
175                ready,
176                valid,
177                ready_count,
178            } => Handshake::Semaphore {
179                ready: self.m(*ready),
180                valid: self.m(*valid),
181                ready_count: *ready_count,
182            },
183            Handshake::Barrier { group } => Handshake::Barrier {
184                group: self.g(*group),
185            },
186            other => other.clone(),
187        }
188    }
189    fn kind(&self, k: &StageKind) -> StageKind {
190        match k {
191            StageKind::Load {
192                tensor,
193                pattern,
194                num_blocks,
195                multicast,
196            } => StageKind::Load {
197                tensor: self.t(*tensor),
198                pattern: self.pattern(pattern),
199                num_blocks: *num_blocks,
200                multicast: multicast.map(|g| self.g(g)),
201            },
202            StageKind::Store {
203                tensor,
204                pattern,
205                sub_blocks,
206            } => StageKind::Store {
207                tensor: self.t(*tensor),
208                pattern: self.pattern(pattern),
209                sub_blocks: *sub_blocks,
210            },
211            StageKind::Recv { group } => StageKind::Recv {
212                group: self.g(*group),
213            },
214            StageKind::AllReduce {
215                pool,
216                tensor,
217                combine,
218            } => StageKind::AllReduce {
219                pool: *pool,
220                tensor: self.t(*tensor),
221                combine: Box::new(self.compute(combine)),
222            },
223            StageKind::Compute(op) => StageKind::Compute(self.compute(op)),
224            StageKind::Scalar(b) => StageKind::Scalar(b.clone()),
225        }
226    }
227    /// Every id a choreography op names — the one place a missed field would silently point a composed
228    /// section at another section's buffer.
229    fn op(&self, o: &ChoreoOp) -> ChoreoOp {
230        match o {
231            ChoreoOp::Reserve { buf, pages } => ChoreoOp::Reserve {
232                buf: self.b(*buf),
233                pages: *pages,
234            },
235            ChoreoOp::Push { buf, pages } => ChoreoOp::Push {
236                buf: self.b(*buf),
237                pages: *pages,
238            },
239            ChoreoOp::Wait { buf, pages } => ChoreoOp::Wait {
240                buf: self.b(*buf),
241                pages: *pages,
242            },
243            ChoreoOp::Pop { buf, pages } => ChoreoOp::Pop {
244                buf: self.b(*buf),
245                pages: *pages,
246            },
247            ChoreoOp::ReadTiles {
248                tensor,
249                at,
250                block_h,
251                block_w,
252                stride_w,
253                stride_h,
254                into,
255            } => ChoreoOp::ReadTiles {
256                tensor: self.t(*tensor),
257                at: self.addr(at),
258                block_h: *block_h,
259                block_w: *block_w,
260                stride_w: *stride_w,
261                stride_h: *stride_h,
262                into: self.b(*into),
263            },
264            ChoreoOp::WriteTiles {
265                from,
266                tensor,
267                at,
268                block_h,
269                block_w,
270                stride_w,
271                stride_h,
272            } => ChoreoOp::WriteTiles {
273                from: self.b(*from),
274                tensor: self.t(*tensor),
275                at: self.addr(at),
276                block_h: *block_h,
277                block_w: *block_w,
278                stride_w: *stride_w,
279                stride_h: *stride_h,
280            },
281            ChoreoOp::McastSend {
282                buf,
283                pages,
284                group,
285                hold_path,
286            } => ChoreoOp::McastSend {
287                buf: self.b(*buf),
288                pages: *pages,
289                group: self.g(*group),
290                hold_path: *hold_path,
291            },
292            ChoreoOp::McastSetValid { sem, group } => ChoreoOp::McastSetValid {
293                sem: self.m(*sem),
294                group: self.g(*group),
295            },
296            ChoreoOp::SemSet { sem, value } => ChoreoOp::SemSet {
297                sem: self.m(*sem),
298                value: *value,
299            },
300            ChoreoOp::SemInc { sem, at, by } => ChoreoOp::SemInc {
301                sem: self.m(*sem),
302                at: *at,
303                by: *by,
304            },
305            ChoreoOp::SemWait { sem, until } => ChoreoOp::SemWait {
306                sem: self.m(*sem),
307                until: *until,
308            },
309            ChoreoOp::Compute {
310                op,
311                ins,
312                acc_reload,
313            } => ChoreoOp::Compute {
314                op: self.compute(op),
315                ins: ins.iter().map(|b| self.b(*b)).collect(),
316                acc_reload: acc_reload.map(|b| self.b(b)),
317            },
318            ChoreoOp::PackAcc {
319                to,
320                tiles,
321                convert,
322                accumulate,
323            } => ChoreoOp::PackAcc {
324                to: self.b(*to),
325                tiles: *tiles,
326                convert: *convert,
327                accumulate: *accumulate,
328            },
329            ChoreoOp::Barrier { group } => ChoreoOp::Barrier {
330                group: self.g(*group),
331            },
332            ChoreoOp::Loop { var, count, body } => ChoreoOp::Loop {
333                var: *var,
334                count: self.count(*count),
335                body: body.iter().map(|t| self.tagged(t)).collect(),
336            },
337            other => other.clone(),
338        }
339    }
340    fn tagged(&self, t: &TaggedOp) -> TaggedOp {
341        TaggedOp {
342            stage: self.s(t.stage),
343            op: self.op(&t.op),
344        }
345    }
346}
347
348/// The role a merged tensor takes: what every section does with it, reconciled.
349#[derive(Default)]
350struct Usage {
351    written_in: Vec<usize>,
352    read_in: Vec<usize>,
353    declared: Option<TensorRole>,
354}
355
356/// Join verified sections into one program.
357///
358/// `links` is the front end's dataflow (advisory — the roles are derived from what the SECTIONS do, so
359/// the sub-sections an op expands into are handled by the same rule as the statements themselves), and
360/// `state` names the tensors the step carries across tokens.
361pub fn compose(
362    sections: Vec<SpatialProgram>,
363    links: &[Link],
364    state: &[String],
365    placement: SectionPlacement,
366    target: &SpatialTarget,
367) -> Result<SpatialProgram, ComposeReject> {
368    compose_phased(sections, links, state, placement, None, target)
369}
370
371/// [`compose`] with the barriers named: `barrier_after[k]` closes section `k` with a rendezvous, and a section left
372/// open runs CONCURRENTLY with the one after it — sections on disjoint chips with no data between them, a phase of a
373/// tensor-parallel step. `None` closes every section but the last.
374///
375/// ★ ONE BARRIER ORDERS EVERYTHING ACROSS IT. A rendezvous after section `b` holds every op of every section after
376/// `b` until every op of every section up to `b` is done, so two sections are ordered by ANY barrier between them —
377/// which is what the certificate's ordering obligations check — and a boundary with no data across it needs none.
378pub fn compose_phased(
379    sections: Vec<SpatialProgram>,
380    links: &[Link],
381    state: &[String],
382    placement: SectionPlacement,
383    barrier_after: Option<&[bool]>,
384    target: &SpatialTarget,
385) -> Result<SpatialProgram, ComposeReject> {
386    let _ = links;
387    let closes = |k: usize| {
388        k + 1 < sections.len() && barrier_after.is_none_or(|b| b.get(k).copied().unwrap_or(true))
389    };
390    if sections.is_empty() {
391        return Err(ComposeReject::NoSections);
392    }
393    for (k, s) in sections.iter().enumerate() {
394        if s.schedule.is_none() || s.placement.is_none() || s.memory.is_none() {
395            return Err(ComposeReject::SectionNotScheduled { section: k });
396        }
397    }
398
399    // ── 1. Unify the tensors BY NAME and record what each section does with each.
400    //
401    // ★ A MERGED TENSOR HOLDS WHAT ITS PRODUCER WROTE. Two sections can legitimately DECLARE the same
402    // activation at different element types — a matmul writes an fp32 result from its fp32
403    // accumulators, and the bf16 elementwise datapath that reads it next declares the operand it can
404    // take. Only one of those is a fact about the bytes in memory: the writer's. So the producing
405    // section's declaration wins, the reader narrows on the way in (`ReadTiles` rounds to the buffer it
406    // lands in, exactly as the hardware does), and a disagreement with NO producer to settle it — or
407    // between two producers — is still refused by name, because then nothing decides.
408    let producer_decl = {
409        let mut chosen: BTreeMap<String, (TensorDecl, bool)> = BTreeMap::new();
410        for sec in sections.iter() {
411            let stored: Vec<TensorId> = sec
412                .stages
413                .iter()
414                .filter_map(|s| match &s.kind {
415                    StageKind::Store { tensor, .. } => Some(*tensor),
416                    _ => None,
417                })
418                .collect();
419            for td in &sec.tensors {
420                let writes = stored.contains(&td.id);
421                match chosen.get_mut(&td.name) {
422                    None => {
423                        chosen.insert(td.name.clone(), (td.clone(), writes));
424                    }
425                    Some((have, had_writer)) => {
426                        if have.elems != td.elems {
427                            return Err(ComposeReject::TensorShapeConflict {
428                                name: td.name.clone(),
429                                first: (have.elems.rows, have.elems.cols, have.dtype),
430                                second: (td.elems.rows, td.elems.cols, td.dtype),
431                            });
432                        }
433                        if writes && !*had_writer {
434                            // ★ THE PRODUCER DECIDES THE BYTES, NOT THE STARTING VALUE. A section always
435                            // declares its own output `Zero` — that is a placeholder for "this section
436                            // fills it", not a claim about what the tensor holds when the program starts.
437                            // For a CARRIED STATE that distinction is the whole thing: the GDN memory, the
438                            // conv ring and the KV cache are read before they are written, and their
439                            // initial value is a fact only the SOURCE declaration knows. Letting the
440                            // writer's placeholder win silently zeroed all twelve of Qwen3.8's state
441                            // tensors, which a single forward pass hides inside its tolerance and a decode
442                            // loop would not.
443                            let keep_init = matches!(td.init, TensorInit::Zero)
444                                && !matches!(have.init, TensorInit::Zero);
445                            let prior = have.init.clone();
446                            *have = td.clone();
447                            if keep_init {
448                                have.init = prior;
449                            }
450                            *had_writer = true;
451                        } else if have.dtype != td.dtype && (writes == *had_writer) {
452                            // ★ READERS ALONE MAY DISAGREE IN A FLOAT'S WIDTH. Nothing in the program writes the
453                            // tensor and one reader takes it wider than another — an fp32 island beside the bf16
454                            // datapath. It is held at the wider width, what the bytes from outside can carry, and
455                            // the narrower reader rounds it on the way in, as it does below a wider producer. Two
456                            // producers, or a float against an integer, still have nothing to decide them.
457                            match (writes, wider_float(have.dtype, td.dtype)) {
458                                (false, Some(wider)) => have.dtype = wider,
459                                _ => {
460                                    return Err(ComposeReject::TensorShapeConflict {
461                                        name: td.name.clone(),
462                                        first: (have.elems.rows, have.elems.cols, have.dtype),
463                                        second: (td.elems.rows, td.elems.cols, td.dtype),
464                                    })
465                                }
466                            }
467                        }
468                    }
469                }
470            }
471        }
472        chosen
473    };
474    let mut tensors: Vec<TensorDecl> = Vec::new();
475    let mut by_name: BTreeMap<String, TensorId> = BTreeMap::new();
476    let mut usage: Vec<Usage> = Vec::new();
477    let mut shifts: Vec<Shift> = Vec::new();
478    let (
479        mut stage_at,
480        mut buffer_at,
481        mut edge_at,
482        mut sem_at,
483        mut item_at,
484        mut group_at,
485        mut trip_at,
486    ) = (0, 0, 0, 0, 0, 0, 0);
487    for (k, sec) in sections.iter().enumerate() {
488        let mut tmap = Vec::with_capacity(sec.tensors.len());
489        for td in &sec.tensors {
490            let id = match by_name.get(&td.name) {
491                Some(id) => *id,
492                None => {
493                    let id = TensorId::new(tensors.len());
494                    let mut decl = producer_decl
495                        .get(&td.name)
496                        .map(|(d, _)| d.clone())
497                        .unwrap_or_else(|| td.clone());
498                    decl.id = id;
499                    tensors.push(decl);
500                    usage.push(Usage::default());
501                    by_name.insert(td.name.clone(), id);
502                    id
503                }
504            };
505            // The first DECLARED role a name gets is the one a plain input or weight keeps.
506            let u = &mut usage[id.idx()];
507            if u.declared.is_none() && matches!(td.role, TensorRole::Input | TensorRole::Param) {
508                u.declared = Some(td.role);
509            }
510            tmap.push(id);
511        }
512        for s in &sec.stages {
513            match &s.kind {
514                // Sections are walked in order, so a section already recorded is the last one recorded.
515                StageKind::Load { tensor, .. } => {
516                    let id = tmap[tensor.idx()];
517                    let u = &mut usage[id.idx()];
518                    if u.read_in.last() != Some(&k) {
519                        u.read_in.push(k);
520                    }
521                }
522                StageKind::Store { tensor, .. } => {
523                    let id = tmap[tensor.idx()];
524                    let u = &mut usage[id.idx()];
525                    if u.written_in.last() != Some(&k) {
526                        u.written_in.push(k);
527                    }
528                }
529                _ => {}
530            }
531        }
532        shifts.push(Shift {
533            tensor: tmap,
534            stage: stage_at,
535            buffer: buffer_at,
536            edge: edge_at,
537            sem: sem_at,
538            item: item_at,
539            group: group_at,
540            trip: trip_at,
541        });
542        stage_at += sec.stages.len();
543        buffer_at += sec.buffers.len();
544        edge_at += sec.edges.len();
545        sem_at += sec.semaphores.len() + if closes(k) { RENDEZVOUS_SEMS } else { 0 };
546        item_at += sec.work.items.len();
547        group_at += sec.placement.as_ref().map(|p| p.groups.len()).unwrap_or(0);
548        trip_at += sec.trips.len();
549    }
550
551    // ── 2. Decide the role of every merged tensor.
552    //
553    // ★ THE ROLE IS WHAT THE PROGRAM DOES, NOT WHAT A SECTION CALLED IT. A section is lowered in
554    // isolation and calls its result `Output` because from its own point of view it is one. Reading
555    // the sections TOGETHER is what tells an activation (written here, read later) from the program's
556    // actual answer (written and never read) — and the front end's carried-state list is what tells
557    // both from a memory that outlives the step.
558    let carried: BTreeSet<&str> = state.iter().map(String::as_str).collect();
559    for (i, t) in tensors.iter_mut().enumerate() {
560        let u = &usage[i];
561        let written = !u.written_in.is_empty();
562        let read = !u.read_in.is_empty();
563        if carried.contains(t.name.as_str()) {
564            t.role = TensorRole::State;
565        } else if written && read {
566            t.role = TensorRole::Activation;
567        } else if written {
568            t.role = TensorRole::Output;
569        } else {
570            t.role = u.declared.unwrap_or(TensorRole::Input);
571        }
572    }
573
574    // ── 3. Shift every id space, section by section.
575    let mut stages: Vec<Stage> = Vec::new();
576    let mut buffers: Vec<Buffer> = Vec::new();
577    let mut edges: Vec<Edge> = Vec::new();
578    let mut semaphores: Vec<Semaphore> = Vec::new();
579    let mut items: Vec<WorkItem> = Vec::new();
580    let mut groups: Vec<Group> = Vec::new();
581    let mut trips: Vec<Trip> = Vec::new();
582    let mut site_of: Vec<Site> = Vec::new();
583    let mut engine_of: Vec<EngineSlot> = Vec::new();
584    let mut buffer_map: Vec<BufferAt> = Vec::new();
585    let mut items_of_site: BTreeMap<Site, Vec<WorkItemId>> = BTreeMap::new();
586    let mut requires = Requirements::default();
587    let mut acc_tiles = 0usize;
588    let mut exact = true;
589    let mut approx_reasons: Vec<String> = Vec::new();
590    let mesh = target.sites;
591    let mesh_sites: Vec<Site> = (0..mesh.y)
592        .flat_map(|y| (0..mesh.x).map(move |x| Site::new(x, y)))
593        .collect();
594
595    // The rendezvous participants: every `(site, engine)` that runs any section, in site order. The root
596    // of every rendezvous is the first of them.
597    let order: Vec<(Site, Engine)> = {
598        let mut seen: BTreeSet<(Site, Engine)> = BTreeSet::new();
599        let mut v: Vec<(Site, Engine)> = Vec::new();
600        for (k, sec) in sections.iter().enumerate() {
601            for prog in &sec.schedule.as_ref().expect("checked").programs {
602                let site = match placement {
603                    SectionPlacement::Stacked => prog.site,
604                    SectionPlacement::RoundRobin => *mesh_sites
605                        .get(k % mesh_sites.len().max(1))
606                        .unwrap_or(&prog.site),
607                };
608                if seen.insert((site, prog.engine)) {
609                    v.push((site, prog.engine));
610                }
611            }
612        }
613        v.sort_by_key(|(s, e)| (s.y, s.x, *e));
614        v
615    };
616    let participants = order.len();
617    let root = order.first().copied();
618    let rendezvous_sites: Vec<Site> = {
619        let mut v: Vec<Site> = order.iter().map(|(s, _)| *s).collect();
620        v.sort_by_key(|s| (s.y, s.x, s.chip));
621        v.dedup();
622        v
623    };
624    let rendezvous: Vec<Option<Rendezvous>> = (0..sections.len())
625        .map(|k| {
626            closes(k).then(|| {
627                let base = shifts[k].sem + sections[k].semaphores.len();
628                Rendezvous {
629                    gather: SemId::new(base),
630                    release: SemId::new(base + 1),
631                }
632            })
633        })
634        .collect();
635
636    for (k, sec) in sections.iter().enumerate() {
637        let sh = &shifts[k];
638        // `RoundRobin` shifts a whole section onto one mesh site; `Stacked` keeps the section's own
639        // placement, so consecutive sections share local memory.
640        let move_site = |s: Site| -> Site {
641            match placement {
642                SectionPlacement::Stacked => s,
643                SectionPlacement::RoundRobin => {
644                    *mesh_sites.get(k % mesh_sites.len().max(1)).unwrap_or(&s)
645                }
646            }
647        };
648        let pl = sec.placement.as_ref().expect("checked above");
649        let mm = sec.memory.as_ref().expect("checked above");
650        for s in &sec.stages {
651            stages.push(Stage {
652                id: sh.s(s.id),
653                name: format!("s{k}.{}", s.name),
654                kind: sh.kind(&s.kind),
655                role: s.role,
656                ins: s.ins.iter().map(|b| sh.b(*b)).collect(),
657                outs: s.outs.iter().map(|b| sh.b(*b)).collect(),
658                site: s.site.map(move_site),
659                item: s.item.map(|i| sh.i(i)),
660                origin: s.origin.map(|o| sh.s(o)),
661            });
662        }
663        for b in &sec.buffers {
664            buffers.push(Buffer {
665                id: sh.b(b.id),
666                name: format!("s{k}.{}", b.name),
667                kind: b.kind,
668                dtype: b.dtype,
669                space: b.space,
670                site: b.site.map(move_site),
671                origin: b.origin.map(|o| sh.b(o)),
672                alias_of: b.alias_of.map(|a| sh.b(a)),
673            });
674            let at = mm.buffer_at[b.id.idx()];
675            buffer_map.push(BufferAt {
676                site: move_site(at.site),
677                ..at
678            });
679        }
680        for e in &sec.edges {
681            edges.push(Edge {
682                id: sh.e(e.id),
683                from: sh.s(e.from),
684                buffer: sh.b(e.buffer),
685                to: e.to.iter().map(|t| sh.s(*t)).collect(),
686                handshake: sh.handshake(&e.handshake),
687            });
688        }
689        for s in &sec.semaphores {
690            semaphores.push(Semaphore {
691                id: sh.m(s.id),
692                init: s.init,
693                role: s.role,
694                operand: s.operand,
695                sites: s.sites.iter().map(|x| move_site(*x)).collect(),
696            });
697        }
698        if let (Some(r), Some((root_site, _))) = (rendezvous[k], root) {
699            let n = participants;
700            semaphores.push(Semaphore {
701                id: r.gather,
702                init: 0,
703                role: SemRole::Barrier {
704                    n,
705                    phase: BarrierPhase::Gather,
706                },
707                operand: None,
708                sites: vec![root_site],
709            });
710            semaphores.push(Semaphore {
711                id: r.release,
712                init: 0,
713                role: SemRole::Barrier {
714                    n,
715                    phase: BarrierPhase::Release,
716                },
717                operand: None,
718                sites: rendezvous_sites.clone(),
719            });
720        }
721        for it in &sec.work.items {
722            items.push(WorkItem {
723                id: sh.i(it.id),
724                coord: it.coord,
725                out: it.out,
726            });
727        }
728        // A trip reads a program input, and the merged tensor of that name is the input it reads.
729        trips.extend(sec.trips.iter().map(|t| Trip {
730            tensor: sh.t(t.tensor),
731            ..t.clone()
732        }));
733        for g in &pl.groups {
734            groups.push(Group {
735                id: sh.g(g.id),
736                rect: g.rect,
737                sender: move_site(g.sender),
738                direction: g.direction,
739                operand: g.operand,
740                noc: g.noc,
741            });
742        }
743        for s in &sec.stages {
744            site_of.push(move_site(pl.site_of[s.id.idx()]));
745            let slot = &pl.engine_of[s.id.idx()];
746            engine_of.push(EngineSlot {
747                site: move_site(slot.site),
748                engine: slot.engine,
749                noc: slot.noc,
750            });
751        }
752        for (site, ids) in &pl.items_of_site {
753            items_of_site
754                .entry(move_site(*site))
755                .or_default()
756                .extend(ids.iter().map(|i| sh.i(*i)));
757        }
758        requires
759            .handshakes
760            .extend(sec.requires.handshakes.iter().copied());
761        requires
762            .interconnect
763            .extend(sec.requires.interconnect.iter().copied());
764        acc_tiles = acc_tiles.max(sec.acc_tiles_used);
765        match &sec.claim {
766            NumericsClaim::Exact => {}
767            NumericsClaim::Approx { reason } => {
768                exact = false;
769                if !approx_reasons.contains(reason) {
770                    approx_reasons.push(reason.clone());
771                }
772            }
773        }
774    }
775
776    // ── 4. Join the schedules per `(site, engine)`, with a rendezvous at every boundary.
777    //
778    // Section by section: each section's programs append to the participants that run them, and a closing section's
779    // rendezvous to every participant — the only part of the join that touches a participant with nothing to do in
780    // the section, and one it cannot skip, since every program passes every barrier.
781    let participant: BTreeMap<(Site, Engine), usize> =
782        order.iter().enumerate().map(|(i, p)| (*p, i)).collect();
783    let mut ops: Vec<Vec<TaggedOp>> = vec![Vec::new(); participants];
784    let mut stage_ids: Vec<Vec<StageId>> = vec![Vec::new(); participants];
785    for (k, sec) in sections.iter().enumerate() {
786        let sh = &shifts[k];
787        let own = &sec.schedule.as_ref().expect("checked").programs;
788        // The program each participant runs in section k: its own under `Stacked`; under `RoundRobin` the section's
789        // program on its engine, wherever the section placed it.
790        let runs: Vec<(usize, &EngineProgram)> = match placement {
791            SectionPlacement::Stacked => own
792                .iter()
793                .filter_map(|q| participant.get(&(q.site, q.engine)).map(|i| (*i, q)))
794                .collect(),
795            SectionPlacement::RoundRobin => order
796                .iter()
797                .enumerate()
798                .filter_map(|(i, (_, engine))| {
799                    own.iter().find(|q| q.engine == *engine).map(|q| (i, q))
800                })
801                .collect(),
802        };
803        // The rendezvous that closes section k is tagged with a stage of section k, so the schedule attributes it to
804        // the section it closes.
805        let mut anchor: BTreeMap<usize, StageId> = BTreeMap::new();
806        for (i, prog) in runs {
807            for s in &prog.stages {
808                let id = sh.s(*s);
809                stage_ids[i].push(id);
810                anchor.entry(i).or_insert(id);
811            }
812            ops[i].extend(prog.ops.iter().map(|t| sh.tagged(t)));
813        }
814        if let (Some(r), Some((root_site, _))) = (rendezvous[k], root) {
815            let fallback = sec
816                .stages
817                .first()
818                .map(|st| sh.s(st.id))
819                .unwrap_or(StageId::new(0));
820            for (i, (site, engine)) in order.iter().enumerate() {
821                let tag = anchor.get(&i).copied().unwrap_or(fallback);
822                let ops = &mut ops[i];
823                // Arrive at the root; the root gathers every arrival and releases each site; every program then
824                // waits for its own site's release.
825                ops.push(TaggedOp {
826                    stage: tag,
827                    op: ChoreoOp::SemInc {
828                        sem: r.gather,
829                        at: root_site,
830                        by: 1,
831                    },
832                });
833                if root == Some((*site, *engine)) {
834                    ops.push(TaggedOp {
835                        stage: tag,
836                        op: ChoreoOp::SemWait {
837                            sem: r.gather,
838                            until: SemCond::AtLeast(participants),
839                        },
840                    });
841                    for s in &rendezvous_sites {
842                        ops.push(TaggedOp {
843                            stage: tag,
844                            op: ChoreoOp::SemInc {
845                                sem: r.release,
846                                at: *s,
847                                by: 1,
848                            },
849                        });
850                    }
851                }
852                ops.push(TaggedOp {
853                    stage: tag,
854                    op: ChoreoOp::SemWait {
855                        sem: r.release,
856                        until: SemCond::AtLeast(1),
857                    },
858                });
859            }
860        }
861    }
862    let mut programs: Vec<EngineProgram> = Vec::new();
863    for (((site, engine), ops), mut stage_ids) in order.iter().zip(ops).zip(stage_ids) {
864        if ops.is_empty() {
865            continue;
866        }
867        stage_ids.sort();
868        stage_ids.dedup();
869        programs.push(EngineProgram {
870            site: *site,
871            engine: *engine,
872            stages: stage_ids,
873            ops,
874        });
875    }
876
877    // ── 5. The spans, in order.
878    let mut spans: Vec<SectionSpan> = Vec::with_capacity(sections.len());
879    {
880        let (mut st, mut bf, mut ed, mut sm, mut it) = (0usize, 0usize, 0usize, 0usize, 0usize);
881        for (k, sec) in sections.iter().enumerate() {
882            let sem_end = shifts[k].sem
883                + sec.semaphores.len()
884                + if rendezvous[k].is_some() {
885                    RENDEZVOUS_SEMS
886                } else {
887                    0
888                };
889            // What the section's work writes, by the one definition an uncomposed program uses: its `Output`, or — an
890            // in-place update — the first state it stores, in declaration order.
891            let out = sec.output_tensor().map(|t| shifts[k].t(t.id));
892            spans.push(SectionSpan {
893                name: sec.name.clone(),
894                stages: (st, st + sec.stages.len()),
895                buffers: (bf, bf + sec.buffers.len()),
896                edges: (ed, ed + sec.edges.len()),
897                semaphores: (sm, sem_end),
898                items: (it, it + sec.work.items.len()),
899                plan: sec.work.plan,
900                k_tiles: sec.work.k_tiles,
901                output: out,
902                barrier_after: rendezvous[k],
903            });
904            st += sec.stages.len();
905            bf += sec.buffers.len();
906            ed += sec.edges.len();
907            sm = sem_end;
908            it += sec.work.items.len();
909        }
910    }
911
912    let last = sections.last().expect("non-empty");
913    let out_name = last
914        .tensors
915        .iter()
916        .find(|t| t.role == TensorRole::Output)
917        .map(|t| t.name.clone())
918        .unwrap_or_else(|| "result".into());
919    let claim = if exact {
920        NumericsClaim::Exact
921    } else {
922        NumericsClaim::Approx {
923            reason: approx_reasons.join("; "),
924        }
925    };
926    let mut p = SpatialProgram {
927        // ★ ONE SECTION KEEPS ITS OWN NAME. `matmul_2x2x2` says what the program IS; `C_1_sections`
928        // says only how it was built. The section count earns its place in the name exactly when there
929        // is more than one section to account for — `logits_117_sections` is the whole forward, and that
930        // IS the fact worth carrying.
931        name: if sections.len() == 1 {
932            sections[0].name.clone()
933        } else {
934            format!("{out_name}_{}_sections", sections.len())
935        },
936        tile: target.tile,
937        tensors,
938        stages,
939        buffers,
940        edges,
941        semaphores,
942        sections: spans,
943        trips,
944        links: Vec::new(),
945        work: WorkList {
946            items,
947            k_tiles: last.work.k_tiles,
948            plan: last.work.plan,
949            k_trip: last.work.k_trip.map(|t| shifts[sections.len() - 1].r(t)),
950        },
951        numerics: if sections.iter().all(|s| s.numerics == NumericsPolicy::Exact) {
952            NumericsPolicy::Exact
953        } else {
954            NumericsPolicy::AllowSpillRounding
955        },
956        claim,
957        acc_tiles_used: acc_tiles,
958        mem_banks: target.site.mem_banks,
959        requires,
960        placement: Some(Placement {
961            mesh,
962            site_of,
963            engine_of,
964            groups,
965            items_of_site: items_of_site.into_iter().collect(),
966        }),
967        memory: Some(MemoryMap {
968            buffer_at: buffer_map,
969        }),
970        schedule: Some(Schedule { programs }),
971        certificate: None,
972    };
973    let high = separate_phase_memory(&mut p);
974    if high as usize > target.max_local_mem_bytes() {
975        return Err(ComposeReject::PhaseOverflowsMemory {
976            bytes: high,
977            capacity: target.max_local_mem_bytes(),
978        });
979    }
980    // Where the tensors live is decided once the program's phases and its buffers' layout are: its intermediates over
981    // their live ranges, then its carried state as the deployment of this one program. A deployment of many programs
982    // — a bundle's layer kinds, each run by many layers — plans its state again over all of them.
983    crate::spatial::residency::place_activations(&mut p, target);
984    crate::spatial::residency::plan_deployment(&mut [("program", 1, &mut p, None)], target);
985    p.validate()?;
986    Ok(p)
987}
988
989/// ★ A PHASE'S SECTIONS RUN AT ONCE, SO THEY DO NOT SHARE A BYTE. Each section lays its buffers out over its sites'
990/// local memory from the same base; sections a barrier separates reuse those bytes, sections of one phase may not.
991/// A section of a phase that shares a site with an earlier one of the phase has its buffers there moved past the
992/// earlier ones' (per memory space) — sections on disjoint sites keep their layout. Returns the highest byte any site
993/// now uses, for the caller to hold against the site's memory.
994pub fn separate_phase_memory(p: &mut SpatialProgram) -> u32 {
995    let Some(mm) = p.memory.as_mut() else {
996        return 0;
997    };
998    let mut end: BTreeMap<(Site, MemSpace), u32> = BTreeMap::new();
999    let mut high = 0u32;
1000    for sp in &p.sections {
1001        let mut mine: BTreeMap<(Site, MemSpace), u32> = BTreeMap::new();
1002        for b in sp.buffers.0..sp.buffers.1 {
1003            let buf = &p.buffers[b];
1004            if buf.space == MemSpace::Acc {
1005                continue;
1006            }
1007            let at = &mut mm.buffer_at[b];
1008            at.offset_bytes += end.get(&(at.site, at.space)).copied().unwrap_or(0);
1009            if buf.alias_of.is_none() {
1010                let e = mine.entry((at.site, at.space)).or_default();
1011                *e = (*e).max(at.offset_bytes + at.bytes);
1012                high = high.max(at.offset_bytes + at.bytes);
1013            }
1014        }
1015        for (key, e) in mine {
1016            let x = end.entry(key).or_default();
1017            *x = (*x).max(e);
1018        }
1019        if sp.barrier_after.is_some() {
1020            end.clear();
1021        }
1022    }
1023    high
1024}