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}