Skip to main content

logicaffeine_compile/spatial/passes/
place.rs

1//! `place` — pin every work item, stage and buffer to a site and an engine.
2//!
3//! Blocked plans map output block `(oy, ox)` to site `(x = ox, y = oy)` — the alignment that lets a row of
4//! sites share its A blocks and a column its B blocks (a sequential `n % grid_x` mapping would break reader
5//! fusion). Streaming plans reproduce tt-metal's `split_work_to_cores` exactly (column-major core-range
6//! selection, x-fastest offsets inside each range), so the streamed program is the vendor's cycle-for-cycle.
7//! After placement stages and buffers are explicit PER-SITE INSTANCES (`origin` remembers the template), and
8//! every engine slot carries the NOC the target's rule assigns.
9
10use super::SpatialReject;
11use crate::spatial::cost::is_streaming;
12use crate::spatial::ir::*;
13use crate::spatial::target::{Interconnect, MeshShape, SpatialTarget};
14
15/// tt-metal's `split_work_to_cores(grid, units, row_wise = false)` + the host's per-range iteration:
16/// `(site, first unit, unit count)` in assignment order.
17pub fn split_work_to_sites(mesh: MeshShape, units: usize) -> Vec<(Site, usize, usize)> {
18    let (nx, ny) = (mesh.x, mesh.y);
19    let max_cores = nx * ny;
20    if units == 0 || max_cores == 0 {
21        return Vec::new();
22    }
23    let target = units.min(max_cores);
24    // Column-major enumeration of the first `n` cores from `start`, as num_cores_to_corerangeset (row_wise=false):
25    // a partial column to finish the start column, then full columns, then a partial column.
26    fn ranges_from(start: (usize, usize), n: usize, nx: usize, ny: usize) -> Vec<Rect> {
27        let mut out = Vec::new();
28        let (mut sx, mut sy) = start;
29        let mut left = n;
30        if sy != 0 && left > ny - sy {
31            out.push(Rect {
32                x0: sx as u16,
33                y0: sy as u16,
34                x1: sx as u16,
35                y1: (ny - 1) as u16,
36            });
37            left -= ny - sy;
38            sx += 1;
39            sy = 0;
40        }
41        if left > ny {
42            let full = left / ny;
43            out.push(Rect {
44                x0: sx as u16,
45                y0: sy as u16,
46                x1: (sx + full - 1) as u16,
47                y1: (ny - 1) as u16,
48            });
49            left -= full * ny;
50            sx += full;
51            sy = 0;
52        }
53        if left > 0 {
54            out.push(Rect {
55                x0: sx as u16,
56                y0: sy as u16,
57                x1: sx as u16,
58                y1: (sy + left - 1) as u16,
59            });
60        }
61        let _ = nx;
62        out
63    }
64    let (g1, g2, u1, u2) = if units % target == 0 {
65        (
66            ranges_from((0, 0), target, nx, ny),
67            Vec::new(),
68            units / target,
69            0,
70        )
71    } else {
72        let more = units % target;
73        let g1 = ranges_from((0, 0), more, nx, ny);
74        let last = g1.last().unwrap();
75        let start2 = if (last.y1 as usize) != ny - 1 {
76            (last.x1 as usize, last.y1 as usize + 1)
77        } else {
78            (last.x1 as usize + 1, 0)
79        };
80        (
81            g1,
82            ranges_from(start2, target - more, nx, ny),
83            units / target + 1,
84            units / target,
85        )
86    };
87    let mut out = Vec::new();
88    let mut offset = 0;
89    for (ranges, per) in [(g1, u1), (g2, u2)] {
90        for r in ranges {
91            for site in r.sites() {
92                out.push((site, offset, per));
93                offset += per;
94            }
95        }
96    }
97    out
98}
99
100/// The `place` pass.
101pub fn place(p: &SpatialProgram, target: &SpatialTarget) -> Result<SpatialProgram, SpatialReject> {
102    if p.placement.is_some() {
103        return Ok(p.clone());
104    }
105    let Some(plan) = p.work.plan else {
106        return Err(SpatialReject::WrongPhase {
107            pass: "place",
108            expected: "Tiled",
109            found: p.phase(),
110        });
111    };
112    let has_movement = p
113        .stages
114        .iter()
115        .any(|s| matches!(s.kind, StageKind::Load { .. } | StageKind::Store { .. }));
116    if has_movement && target.site.movement_engines == 0 {
117        return Err(SpatialReject::TooManyEngines {
118            site: Site::new(0, 0),
119            needed: 1,
120            available: 0,
121        });
122    }
123    // Two engines run concurrently, so the plan's packing decides what overlaps what. One engine: everything
124    // on it, loads then stores per item.
125    let two = target.site.movement_engines >= 2;
126    let (load_engine, store_engine) = if two { (1u8, 0u8) } else { (0u8, 0u8) };
127    // The second operand's loader, and the store, share an engine under `OperandWithStore`.
128    let split = two && plan.engines == EnginePacking::OperandWithStore;
129    // The operand a Load stage carries: the tensors are declared A, B, …, so the first input tensor is A.
130    let operand_of = |kind: &StageKind| -> Option<usize> {
131        match kind {
132            StageKind::Load { tensor, .. } => p
133                .tensors
134                .iter()
135                .filter(|t| t.role != TensorRole::Output)
136                .position(|t| t.id == *tensor),
137            _ => None,
138        }
139    };
140    // Items → sites.
141    let mut items_of_site: Vec<(Site, Vec<WorkItemId>)> = Vec::new();
142    // ★ WHERE SITE ADJACENCY MEANS NOTHING, SPREAD THE ITEMS LINEARLY.
143    //
144    // The 2-D `(col % sites.x, row % sites.y)` map exists because a Tensix mesh is PHYSICAL: a row
145    // of sites shares a multicast panel, so which site an item lands on decides what it can be
146    // handed rather than fetch. A target with no multicast has no such structure — a site is just
147    // an index — and the 2-D map then actively hurts: a 16×16 block grid on a 112×1 mesh collapses
148    // onto **16** sites, because every item in a column takes `col % 112 = col`. Sixteen CUDA
149    // blocks on a 28-SM GPU leaves most of the machine idle.
150    //
151    // Linear placement is the same `split_work_to_cores` transcription the streaming family
152    // already uses, and it fills every site it is given.
153    let flat_sites = !target.provides_interconnect(Interconnect::MulticastRect);
154    if flat_sites
155        || is_streaming(&plan)
156        || p.stages
157            .iter()
158            .all(|s| !matches!(s.kind, StageKind::Compute(ComputeOp::MatmulTiles { .. })))
159    {
160        for (site, start, count) in split_work_to_sites(target.sites, p.work.items.len()) {
161            let ids: Vec<WorkItemId> = (start..start + count).map(WorkItemId::new).collect();
162            items_of_site.push((site, ids));
163        }
164    } else {
165        let mut by_site: std::collections::BTreeMap<Site, Vec<WorkItemId>> = Default::default();
166        for it in &p.work.items {
167            let site = Site::new(
168                it.coord.1 % target.sites.x.max(1),
169                it.coord.0 % target.sites.y.max(1),
170            );
171            by_site.entry(site).or_default().push(it.id);
172        }
173        items_of_site = by_site.into_iter().collect();
174    }
175    // Deterministic site order: y outer, x inner.
176    items_of_site.sort_by_key(|(s, _)| (s.y, s.x));
177    let rule = target.noc_rule;
178    let result = p.output_tensor().map(|t| t.id);
179    // A result stored in parts — each store its own part of the item's block — keeps every store's shape.
180    let result_in_parts = p
181        .stages
182        .iter()
183        .filter(|s| matches!(&s.kind, StageKind::Store { tensor, .. } if Some(*tensor) == result))
184        .count()
185        > 1;
186    let out_tiles = p
187        .output_tensor()
188        .map(|t| (t.tiles.rows_t, t.tiles.cols_t))
189        .unwrap_or((0, 0));
190    let mut stages = Vec::new();
191    let mut buffers = Vec::new();
192    let mut edges = Vec::new();
193    let mut site_of = Vec::new();
194    let mut engine_of = Vec::new();
195    // Which buffer each operand and the matmul's output live in, so a site's PAGES can be sized from the
196    // block that site actually covers.
197    let (mut a_buf, mut b_buf, mut out_buf, mut spill_buf) = (None, None, None, None);
198    for st in &p.stages {
199        match &st.kind {
200            StageKind::Load { .. } => match operand_of(&st.kind) {
201                Some(0) => a_buf = st.outs.first().copied(),
202                Some(1) => b_buf = st.outs.first().copied(),
203                _ => {}
204            },
205            StageKind::Compute(ComputeOp::MatmulTiles { interm, .. }) => {
206                out_buf = st.outs.first().copied();
207                spill_buf = *interm;
208            }
209            _ => {}
210        }
211    }
212    // The output shares its pages with the spill where the reduction finishes in place (`tile::finishes_in_place`).
213    let out_shared =
214        spill_buf.is_some_and(|s| out_buf.is_some() && p.buffer(s).alias_of == out_buf);
215    // ★ AND THE PANEL RESIZE BELONGS TO THE MATMUL THAT DEFINES A PANEL.
216    //
217    // The operand pages below are re-sized to `h · ibw` and `ibw · w` — the block of A and of B one
218    // site's K-block consumes. `ibw` is the INNER-BLOCK WIDTH of a matmul plan and means nothing
219    // without one: a fused row op still gets a plan (its grid, its per-site block) but `ibw` is 1, so
220    // the formula silently shrank a `cols_t`-tile row operand to a SINGLE tile while every access map
221    // reading it still stepped a whole row. Nothing downstream could see the row was now narrower than
222    // its own stride — the Tensix printer refused it by name ("an offset, a stride or an indirect term
223    // the broadcast family cannot address"), and a printer that could not name it would have read past
224    // the page instead. A program with no matmul has no panel, so it keeps the pages it declared.
225    if out_buf.is_none() {
226        a_buf = None;
227        b_buf = None;
228    }
229    for (site, items) in &items_of_site {
230        let sname = |n: &str| format!("{n}@{},{}", site.x, site.y);
231        let buf_base = buffers.len();
232        // The grid position this site's work sits at — only defined when the site holds exactly one item,
233        // which is the case for every mesh-FITTING plan. A wrapped plan's site holds several blocks and
234        // its patterns stay nominal, which is correct because wrapped grids are divisor-derived.
235        //
236        // ★ AND IT COMES FROM THE ITEM'S RECT, BECAUSE THAT IS WHAT SAYS WHAT THE ITEM COVERS.
237        //
238        // `site_block(coord)` re-derives the extent from the item's GRID POSITION, which is only the same
239        // answer while `coord` IS a grid position. A section whose items carry a LOGICAL coordinate — an
240        // attention step indexes its work by `(rep, kv-head)` so its loads can reach `k` without an
241        // integer division, a gated-delta step by `(head, a-block)` — then has its block computed from
242        // the wrong axis. `site_block` reads the second index as a column block and returns WIDTH ZERO
243        // past the first, so `place` emits a degenerate clone and those items silently write NOTHING:
244        // measured as 384 of 512 attention outputs coming back as zero, with exactly the `rep == 0` heads
245        // correct.
246        //
247        // `WorkItem::out` is not a second opinion about the extent — it IS the extent, and `site_block`
248        // is one way of computing it from the plan. Taking the rect directly agrees wherever the two
249        // agree (every mesh-fitting single-op plan, where `coord` is the grid position) and is right
250        // where they do not.
251        let site_block = if items.len() == 1 {
252            let it = &p.work.items[items[0].idx()];
253            p.work.plan.map(|pl| {
254                let (h, w) = (it.out.h_t.max(1), it.out.w_t.max(1));
255                let fit = |nominal: usize, extent: usize| {
256                    (1..=nominal.min(extent))
257                        .rev()
258                        .find(|d| extent % d == 0)
259                        .unwrap_or(1)
260                };
261                (h, w, fit(pl.block.osh, h), fit(pl.block.osw, w))
262            })
263        } else {
264            None
265        };
266        for b in &p.buffers {
267            // ★ AND THE PAGE IS SIZED FROM THE BLOCK THIS SITE COVERS.
268            //
269            // A boundary site's page was still the NOMINAL block, so its multicast sent a full panel where
270            // a shorter one exists — 7 rows of 3 tiles where the last holds 2, which is 420 tiles moved
271            // for 400 that exist. The closed form (correctly) predicted 400 and the choreography did 420,
272            // and the 20-tile gap per axis is exactly the 40 tiles `the_closed_form_agrees_with_the_
273            // choreography_it_summarizes` reported. Sending bytes that are not there is not a modelling
274            // gap; it is the emitter reading past the operand.
275            let kind = match (site_block, p.work.plan) {
276                (Some((h, w, osh, osw)), Some(pl)) => match b.kind {
277                    BufferKind::Fifo { depth_pages, .. } if Some(b.id) == a_buf => {
278                        BufferKind::Fifo {
279                            depth_pages,
280                            page_tiles: h * pl.block.ibw,
281                        }
282                    }
283                    BufferKind::Fifo { depth_pages, .. } if Some(b.id) == b_buf => {
284                        BufferKind::Fifo {
285                            depth_pages,
286                            page_tiles: pl.block.ibw * w,
287                        }
288                    }
289                    // A spill accumulated in place, and an output it shares, hold one page per sub-block THIS site
290                    // walks: the packer's write pointer has to come back to the first page every K block, and the
291                    // two views have to meet page for page when the last block packs.
292                    BufferKind::Fifo { .. } if Some(b.id) == out_buf && out_shared => {
293                        BufferKind::Fifo {
294                            depth_pages: (h / osh.max(1)) * (w / osw.max(1)),
295                            page_tiles: osh * osw,
296                        }
297                    }
298                    BufferKind::Fifo {
299                        depth_pages,
300                        page_tiles,
301                    } if Some(b.id) == out_buf => BufferKind::Fifo {
302                        depth_pages,
303                        page_tiles: page_tiles.min(osh * osw).max(1),
304                    },
305                    BufferKind::Interm { .. } if Some(b.id) == spill_buf => BufferKind::Interm {
306                        depth_pages: (h / osh.max(1)) * (w / osw.max(1)),
307                        page_tiles: osh * osw,
308                    },
309                    other => other,
310                },
311                _ => b.kind,
312            };
313            buffers.push(Buffer {
314                id: BufferId::new(buffers.len()),
315                name: sname(&b.name),
316                kind,
317                dtype: b.dtype,
318                space: b.space,
319                site: Some(*site),
320                origin: Some(b.origin.unwrap_or(b.id)),
321                alias_of: b.alias_of.map(|a| BufferId::new(buf_base + a.idx())),
322            });
323        }
324        let stage_base = stages.len();
325        for s in &p.stages {
326            let remap = |b: &BufferId| BufferId::new(buf_base + b.idx());
327            let mut kind = s.kind.clone();
328            // ★ THIS SITE'S PATTERNS DESCRIBE THIS SITE'S WORK.
329            //
330            // Every stage is cloned per site here, so a boundary site can carry an extent narrower than
331            // the plan's nominal block — which is what a mesh whose dimensions do not divide the tile
332            // count requires. `BlockPlan::site_block` is the one definition of what a site covers, and
333            // `work_of_plan` PRICES from the same function, so the planner cannot rank on a block the
334            // emitter does not lay out. On a dividing grid every site gets the nominal block and all of
335            // this is the identity.
336            if let Some((h, w, osh, osw)) = site_block {
337                // ★ AN OPERAND PANEL EXISTS ONLY WHERE A MATMUL DEFINES ONE. A's rows and B's columns follow
338                // the output block; in a program with no matmul a load's block has nothing to do with the
339                // item's rect, and narrowing "the first two inputs" to it silently cut a gathered K block, an
340                // expert's weight chunk or a partial tree's fan-in down to one tile.
341                if let (Some(_), StageKind::Load { pattern, .. }) = (out_buf, &mut kind) {
342                    match operand_of(&s.kind) {
343                        Some(0) => pattern.block_h = pattern.block_h.min(h),
344                        Some(1) => pattern.block_w = pattern.block_w.min(w),
345                        _ => {}
346                    }
347                }
348                match &mut kind {
349                    // ★ THE ITEM'S BLOCK IS A BLOCK OF THE RESULT, so only the result's store takes its shape. A
350                    // section writing a second tensor of another shape — a split softmax's statistics beside its
351                    // values — addresses it by its own pattern, which the item's rectangle does not describe; so does
352                    // each store of a result stored in parts.
353                    StageKind::Store {
354                        tensor,
355                        pattern,
356                        sub_blocks,
357                    } if Some(*tensor) == result && !result_in_parts => {
358                        // ★ THE ORIGIN'S STRIDES MOVE WITH THE SUB-BLOCK. Narrowing the extent alone left
359                        // `LV_SUB_H` striding by the NOMINAL sub-block height, so a boundary site stepped
360                        // over rows it does not own and wrote past the tensor —
361                        // `ReadOutOfRange { tensor: 2, tile: 1024 }` on a 1024-tile output. An extent and
362                        // the stride that walks it are one fact, and must change together.
363                        for (v, step) in pattern.origin.steps.iter_mut() {
364                            if *v == super::LV_SUB_H {
365                                *step = osh * out_tiles.1;
366                            } else if *v == super::LV_SUB_W {
367                                *step = osw;
368                            }
369                        }
370                        pattern.block_h = osh;
371                        pattern.block_w = osw;
372                        *sub_blocks = (h / osh.max(1), w / osw.max(1));
373                    }
374                    StageKind::Compute(ComputeOp::MatmulTiles {
375                        block, sub_blocks, ..
376                    }) => {
377                        block.osh = osh;
378                        block.osw = osw;
379                        *sub_blocks = (h / osh.max(1), w / osw.max(1));
380                    }
381                    _ => {}
382                }
383            }
384            kind.remap_buffers(&|b| remap(&b));
385            let (engine, noc) = match s.kind.role() {
386                SiteRole::Compute => (Engine::Compute, None),
387                SiteRole::Movement => {
388                    let e = match &s.kind {
389                        // Under the split packing the second operand's loader joins the store's engine.
390                        StageKind::Store { .. } => store_engine,
391                        _ if split && operand_of(&s.kind) == Some(1) => store_engine,
392                        _ => load_engine,
393                    };
394                    (
395                        Engine::Movement(e),
396                        rule.map(|r| {
397                            NocId(if e == load_engine && two {
398                                r.receiver_noc
399                            } else {
400                                r.writer_noc_default
401                            })
402                        }),
403                    )
404                }
405            };
406            stages.push(Stage {
407                id: StageId::new(stages.len()),
408                name: sname(&s.name),
409                kind,
410                role: s.role,
411                ins: s.ins.iter().map(remap).collect(),
412                outs: s.outs.iter().map(remap).collect(),
413                site: Some(*site),
414                item: if items.len() == 1 {
415                    Some(items[0])
416                } else {
417                    None
418                },
419                origin: Some(s.origin.unwrap_or(s.id)),
420            });
421            site_of.push(*site);
422            engine_of.push(EngineSlot {
423                site: *site,
424                engine,
425                noc,
426            });
427        }
428        for e in &p.edges {
429            edges.push(Edge {
430                id: EdgeId::new(edges.len()),
431                from: StageId::new(stage_base + e.from.idx()),
432                buffer: BufferId::new(buf_base + e.buffer.idx()),
433                to: e
434                    .to
435                    .iter()
436                    .map(|t| StageId::new(stage_base + t.idx()))
437                    .collect(),
438                handshake: e.handshake.clone(),
439            });
440        }
441    }
442    let mut q = p.clone();
443    q.stages = stages;
444    q.buffers = buffers;
445    q.edges = edges;
446    q.placement = Some(Placement {
447        mesh: target.sites,
448        site_of,
449        engine_of,
450        groups: Vec::new(),
451        items_of_site,
452    });
453    q.validate()?;
454    Ok(q)
455}