logicaffeine_compile/codegen_sva/wpe_floorplan.rs
1//! The whole-program engine's memory, planned against a real process — before place-and-route.
2//!
3//! The WPE runs arbitrary LOGOS bytecode streamed over a wire, and it has run on two Artix-7
4//! boards for months. Every number that describes its memory, though, was chosen for an FPGA:
5//!
6//! * `HEAP = 32768` is a power of two because a RAMB36E1 is 32768x1, so a 15-bit address lands
7//! on exactly one tile with no depth-cascade — dodging a yosys `memory_libmap` mis-map that
8//! silently aliased every heap access above 4096 and made the transformer return a wrong
9//! answer on silicon. Nothing about any program wanted 32768; the measured working set is
10//! ~18,300 words.
11//! * `NREG = 512` is a power of two for the same reason, against a measured ceiling of 175.
12//! That one is paid for `DEPTH` times over, because the frame stack `sfile` is `DEPTH x NREG`.
13//! * The `_lo`/`_hi` split of every 64-bit array exists to force TDP-W36 and dodge a parity-lane
14//! mis-wire.
15//!
16//! An ASIC has no RAMB tiles, so the first two buy nothing and cost square millimetres. (The third
17//! is a stroke of luck: it makes every array exactly 32 bits wide, which is exactly the width of
18//! sky130's SRAM macros, so no width-banking is needed.)
19//!
20//! This module therefore SOLVES the profile instead of inheriting it: measured program demands in,
21//! a cap profile and a full floorplan out, or a typed refusal. It answers the question that
22//! actually gates the tapeout — *what does this engine have to give up to fit a shuttle die, and
23//! what is left over for the logic* — with arithmetic over the real macro inventory in
24//! [`crate::codegen_sva::pdk_target`], rather than with a guess that place-and-route discovers
25//! eight hours later.
26//!
27//! The array table is checked against the emitted Verilog by `wpe_floorplan_lock`, so an area
28//! model that has drifted away from the RTL it describes fails a test instead of a tapeout.
29
30use crate::codegen_sva::pdk_target::{
31 Implementation, MemoryRequest, PdkTarget, PdkUnsupported, MEASURED_CELLS_PER_MM2,
32};
33use crate::codegen_sva::wprog_engine_rtl::{
34 eval_wprog_config_run, wprog_config_image_unchecked, WProgConfig,
35};
36
37/// What an array is FOR — which is what decides whether it can live off-chip.
38///
39/// Off-chip residence is not a property of size. It is a property of access pattern: an array that
40/// is read every cycle by combinational logic cannot cross a package boundary at any capacity,
41/// while one the FSM already reads through an ISSUE/WAIT/LATCH triplet can absorb an N-cycle
42/// transaction as a latency change to a structure that already exists.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ArrayRole {
45 /// The program's runtime state plane (`heap_*`). Bulk, latency-tolerant, already registered.
46 Heap,
47 /// The call-frame spill area (`sfile_*`), touched only at Call and Return.
48 FrameStack,
49 /// The program image itself (`cell_*`) — what the loader streams in and the interpreter walks.
50 ConfigImage,
51 /// Per-block metadata (`blk_*`, `wr_*`, `liv_reg`, `arg_op`): small, random-access, hot.
52 BlockTable,
53 /// The register file and its write buffer (`file_*`, `param_buf`, `wbuf_*`).
54 RegisterFile,
55 /// Per-block value scratch (`v_*`), the constant table, and the tiny return stacks.
56 Scratch,
57}
58
59/// One addressed array, exactly as the emitted RTL declares it.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct WpeArray {
62 pub name: &'static str,
63 pub words: u32,
64 pub bits: u32,
65 /// Read and written in the same cycle, so a single-port macro cannot express it.
66 pub dual_port: bool,
67 /// Read COMBINATIONALLY — the value is consumed in the same cycle the address is presented.
68 /// An SRAM macro cannot serve this at any speed, because a macro read is registered by
69 /// construction. Such an array is flip-flops, or the FSM grows a wait state. Marking it here
70 /// is what stops the planner from producing a floorplan that cannot be built.
71 pub combinational_read: bool,
72 pub role: ArrayRole,
73}
74
75impl WpeArray {
76 pub fn bits_total(&self) -> u64 {
77 u64::from(self.words) * u64::from(self.bits)
78 }
79}
80
81/// The engine's cap profile — the ten numbers that size every memory in it.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct WpeProfile {
84 pub nreg: usize,
85 pub maxblk: usize,
86 pub maxcell: usize,
87 pub maxwr: usize,
88 pub maxarg: usize,
89 pub maxliv: usize,
90 pub maxk: usize,
91 pub heap: usize,
92 pub depth: usize,
93 pub maxv: usize,
94}
95
96impl WpeProfile {
97 /// Every dimension of `d` this profile does not cover, as (the engine parameter, the demand, the cap). Empty
98 /// exactly when a program with those demands loads and runs without a refusal or a trap.
99 pub fn shortfalls(&self, d: &WpeDemands) -> Vec<(&'static str, usize, usize)> {
100 [
101 ("NREG", d.registers, self.nreg),
102 ("MAXBLK", d.blocks, self.maxblk),
103 ("MAXCELL", d.cells, self.maxcell),
104 ("MAXWR", d.writes, self.maxwr),
105 ("MAXARG", d.call_args, self.maxarg),
106 ("MAXLIV", d.liveins, self.maxliv),
107 ("MAXK", d.konsts, self.maxk),
108 ("HEAP", d.heap_words, self.heap),
109 ("DEPTH", d.depth, self.depth),
110 ("MAXV", d.cells_per_block, self.maxv),
111 ]
112 .into_iter()
113 .filter(|&(_, need, cap)| need > cap)
114 .collect()
115 }
116
117 /// The `[usize; 10]` order `emit_wprog_engine_soc_verilog_backend` expects.
118 pub fn from_caps(c: [usize; 10]) -> Self {
119 Self {
120 nreg: c[0],
121 maxblk: c[1],
122 maxcell: c[2],
123 maxwr: c[3],
124 maxarg: c[4],
125 maxliv: c[5],
126 maxk: c[6],
127 heap: c[7],
128 depth: c[8],
129 maxv: c[9],
130 }
131 }
132
133 pub fn caps(&self) -> [usize; 10] {
134 [
135 self.nreg,
136 self.maxblk,
137 self.maxcell,
138 self.maxwr,
139 self.maxarg,
140 self.maxliv,
141 self.maxk,
142 self.heap,
143 self.depth,
144 self.maxv,
145 ]
146 }
147
148 /// `HBANK0`/`HBANK1` exactly as `wpe_soc_template.v` computes them. The split is an FPGA
149 /// artifact — it exists so each half maps to a 14-bit-indexed RAMB tile instead of the
150 /// least-fuzzed 32K depth-cascade corner, which prjxray mis-encodes BELOW the netlist. It is
151 /// mirrored here because the model's job is to describe the RTL that exists, not the RTL that
152 /// ought to; an ASIC backend should elide the dummy bank when `HEAP <= 16384`.
153 fn heap_banks(&self) -> (u32, u32) {
154 if self.heap > 16384 {
155 (16384, (self.heap - 16384) as u32)
156 } else {
157 (self.heap as u32, 1)
158 }
159 }
160
161 /// Evaluate an RTL array bound (`[0:<expr>]`) to a WORD COUNT — so `MAXBLK-1` yields `MAXBLK`.
162 /// Supports the forms the template actually uses: an identifier, a product of two, and a
163 /// trailing `-1`. Returns `None` for anything it does not understand, so a new declaration
164 /// shape fails the lock loudly rather than being silently skipped.
165 pub fn eval_depth(&self, expr: &str) -> Option<u32> {
166 let (hb0, hb1) = self.heap_banks();
167 let lookup = |t: &str| -> Option<u64> {
168 let t = t.trim();
169 if let Ok(n) = t.parse::<u64>() {
170 return Some(n);
171 }
172 Some(match t {
173 "NREG" => self.nreg as u64,
174 "MAXBLK" => self.maxblk as u64,
175 "MAXCELL" => self.maxcell as u64,
176 "MAXWR" => self.maxwr as u64,
177 "MAXARG" => self.maxarg as u64,
178 "MAXLIV" => self.maxliv as u64,
179 "MAXK" => self.maxk as u64,
180 "HEAP" => self.heap as u64,
181 "DEPTH" => self.depth as u64,
182 "MAXV" => self.maxv as u64,
183 "HBANK0" => u64::from(hb0),
184 "HBANK1" => u64::from(hb1),
185 _ => return None,
186 })
187 };
188 // `<product> - 1`, where `<product>` is one or two identifiers joined by `*`.
189 let (head, minus) = match expr.rsplit_once('-') {
190 Some((h, t)) => (h, t.trim().parse::<u64>().ok()?),
191 None => (expr, 0),
192 };
193 let mut v = 1u64;
194 for f in head.split('*') {
195 v = v.checked_mul(lookup(f)?)?;
196 }
197 u32::try_from(v.checked_sub(minus)? + 1).ok()
198 }
199
200 /// Every addressed array the engine declares, mirroring `wpe_soc_template.v`.
201 ///
202 /// `config_bram` selects the streamed (`MemBackend::ConfigBram`) build, which is the one that
203 /// would be taped out: it EXCISES `file0` (the block-entry register snapshot) in favour of the
204 /// read-before-write buffer. Charging a dead array against the die would inflate the estimate.
205 pub fn arrays(&self, config_bram: bool) -> Vec<WpeArray> {
206 use ArrayRole::*;
207 let (hb0, hb1) = self.heap_banks();
208 let (nreg, maxblk) = (self.nreg as u32, self.maxblk as u32);
209 let (maxcell, maxwr) = (self.maxcell as u32, self.maxwr as u32);
210 let (maxarg, maxliv, maxk) = (self.maxarg as u32, self.maxliv as u32, self.maxk as u32);
211 let (depth, maxv) = (self.depth as u32, self.maxv as u32);
212 let sfile = depth.saturating_mul(nreg);
213
214 let mut a: Vec<WpeArray> = Vec::new();
215 let mut push = |name, words, bits, comb, role| {
216 a.push(WpeArray {
217 name,
218 words,
219 bits,
220 dual_port: true,
221 combinational_read: comb,
222 role,
223 });
224 };
225
226 for n in [
227 "blk_livein_ptr",
228 "blk_n_livein",
229 "blk_cell_ptr",
230 "blk_n_cells",
231 "blk_write_ptr",
232 "blk_n_writes",
233 "blk_term_kind",
234 "blk_t0",
235 "blk_t1",
236 "blk_top",
237 "blk_cdst",
238 "blk_cargc",
239 "blk_cargp",
240 ] {
241 push(n, maxblk, 32, false, BlockTable);
242 }
243 for n in [
244 "cell_kind",
245 "cell_code",
246 "cell_op0",
247 "cell_op1",
248 "cell_op2",
249 "cell_op3",
250 ] {
251 push(n, maxcell, 32, false, ConfigImage);
252 }
253 push("wr_op", maxwr, 32, false, BlockTable);
254 push("wr_reg", maxwr, 32, false, BlockTable);
255 // Deliberately flip-flops in the RTL (`ram_style = "registers"`) and read in the same
256 // cycle the address is formed — these are the five the audit found, and they are small
257 // enough that keeping them as flops costs less than the wait state would.
258 push("arg_op", maxarg, 32, true, BlockTable);
259 push("liv_reg", maxliv, 32, true, BlockTable);
260 push("konst", maxk, 64, true, Scratch);
261 push("sret", depth, 16, true, Scratch);
262 push("sdst", depth, 16, true, Scratch);
263
264 push("file_lo", nreg, 32, false, RegisterFile);
265 push("file_hi", nreg, 32, false, RegisterFile);
266 if !config_bram {
267 push("file0", nreg, 64, true, RegisterFile);
268 }
269 push("param_buf", nreg, 64, false, RegisterFile);
270 push("wbuf_val", nreg, 64, true, RegisterFile);
271 push("wbuf_tgt", nreg, 16, true, RegisterFile);
272
273 push("v_lo", maxv, 32, false, Scratch);
274 push("v_hi", maxv, 32, false, Scratch);
275 push("sfile_lo", sfile, 32, false, FrameStack);
276 push("sfile_hi", sfile, 32, false, FrameStack);
277 push("heap_b0_lo", hb0, 32, false, Heap);
278 push("heap_b0_hi", hb0, 32, false, Heap);
279 push("heap_b1_lo", hb1, 32, false, Heap);
280 push("heap_b1_hi", hb1, 32, false, Heap);
281 a
282 }
283
284 pub fn total_bits(&self, config_bram: bool) -> u64 {
285 self.arrays(config_bram)
286 .iter()
287 .map(WpeArray::bits_total)
288 .sum()
289 }
290}
291
292/// What the corpus actually demands — every field a MEASURED ceiling across the lowering benchmark
293/// suite, never a guess. `depth` and `heap_words` are the two that cannot be read off a config and
294/// must be obtained by RUNNING the program; they are also the two the hardware gets silently wrong
295/// (the engine advances `sp` past `DEPTH` with no bound check), which is how a DEPTH=4 engine once
296/// shipped against DEPTH=8 programs and answered `fib` with 8 instead of 21.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298pub struct WpeDemands {
299 pub blocks: usize,
300 pub registers: usize,
301 pub cells: usize,
302 pub cells_per_block: usize,
303 pub writes: usize,
304 pub liveins: usize,
305 pub call_args: usize,
306 pub konsts: usize,
307 pub depth: usize,
308 pub heap_words: usize,
309}
310
311/// Headroom over a measured demand: +15%, then rounded up to a multiple of 16.
312///
313/// Enough that a program somewhat larger than anything in the suite still runs; small enough that
314/// the headroom is not itself the thing that does not fit. There are deliberately NO powers of two
315/// here — that constraint came from RAMB tile mapping, and on an ASIC the only granularity that
316/// costs anything is the SRAM macro's minimum depth, which the planner accounts for directly.
317const fn with_headroom(n: usize) -> usize {
318 let raised = n * 115 / 100;
319 (raised + 15) / 16 * 16
320}
321
322/// The MEASURED ceiling across the whole lowering benchmark suite, from
323/// `wprog_all_benchmarks_lane::print_max_dims_across_all_benchmarks` (35 programs). `depth` and
324/// `heap_words` are the two that cannot be read off a config and must be obtained by RUNNING the
325/// program — and `depth` is the one the hardware gets silently wrong, because the engine advances
326/// `sp` past `DEPTH` with no bound check. That is how a DEPTH=4 engine once shipped against
327/// DEPTH=8 programs and answered `fib` with 8 instead of 21.
328pub const MEASURED_SUITE: WpeDemands = WpeDemands {
329 blocks: 38, // sha256
330 registers: 175, // nbody
331 cells: 920, // sha256
332 cells_per_block: 450, // sha256
333 writes: 395, // sha256
334 liveins: 258, // the streamed transformer
335 call_args: 24, // the recursion demo
336 konsts: 91, // the postsynth 90-value sum
337 depth: 26, // fib, MEASURED by running it
338 heap_words: 18_300, // the transformer's working set
339};
340
341/// The ASIC cap profile, DERIVED from [`MEASURED_SUITE`] rather than written down.
342///
343/// A second hand-maintained cap table is exactly the thing that rots: the one in
344/// `examples/wpe_memory_plan.rs` had already drifted from the RTL it claimed to describe, missing
345/// `liv_reg`/`arg_op`/`konst`/`sret`/`sdst` entirely. This one cannot drift from the demands,
346/// because it is computed from them at compile time.
347pub const ASIC_PROFILE: WpeProfile = MEASURED_SUITE.solve();
348
349impl WpeDemands {
350 /// What one program demands of an engine: every table the loader receives, read off the image it is sent
351 /// as, and the frame depth and heap words its runs over `args` reach — the two a config cannot tell, and
352 /// the two the engine traps on at run time rather than refusing at load.
353 pub fn of(cfg: &WProgConfig, args: &[&[i64]]) -> WpeDemands {
354 let runs: Vec<_> = args
355 .iter()
356 .map(|a| eval_wprog_config_run(cfg, a, &[]))
357 .collect();
358 WpeDemands {
359 depth: runs.iter().map(|r| r.peak_depth).max().unwrap_or(0),
360 heap_words: runs.iter().map(|r| r.heap.len()).max().unwrap_or(0),
361 ..WpeDemands::loaded(cfg)
362 }
363 }
364
365 /// The demands a host can read off a config without running it: every table the loader receives. Depth
366 /// and heap words are zero — the engine traps those on die.
367 pub fn loaded(cfg: &WProgConfig) -> WpeDemands {
368 let img = wprog_config_image_unchecked(cfg);
369 WpeDemands {
370 blocks: img.blk_term_kind.len(),
371 registers: img.nreg,
372 cells: img.cell_kind.len(),
373 cells_per_block: img.blk_n_cells.iter().copied().max().unwrap_or(0) as usize,
374 writes: img.wr_reg.len(),
375 liveins: img.liv_reg.len(),
376 call_args: img.arg_op.len(),
377 konsts: img.konst.len(),
378 depth: 0,
379 heap_words: 0,
380 }
381 }
382
383 /// The smallest profile that covers every measured demand with stated headroom.
384 ///
385 /// `const` so [`ASIC_PROFILE`] is derived rather than transcribed.
386 pub const fn solve(&self) -> WpeProfile {
387 WpeProfile {
388 nreg: with_headroom(self.registers),
389 maxblk: with_headroom(self.blocks),
390 maxcell: with_headroom(self.cells),
391 maxwr: with_headroom(self.writes),
392 maxarg: with_headroom(self.call_args),
393 maxliv: with_headroom(self.liveins),
394 maxk: with_headroom(self.konsts),
395 heap: with_headroom(self.heap_words),
396 depth: with_headroom(self.depth),
397 maxv: with_headroom(self.cells_per_block),
398 }
399 }
400
401 /// Non-const alias kept for callers that read better this way.
402 pub fn smallest_covering_profile(&self) -> WpeProfile {
403 self.solve()
404 }
405}
406
407/// Where one array ends up.
408#[derive(Debug, Clone, PartialEq)]
409pub enum Residence {
410 Macros {
411 macro_name: &'static str,
412 instances: u32,
413 },
414 Flops {
415 cells: u64,
416 },
417 /// Behind the memory edge, off the die entirely.
418 OffChip,
419}
420
421#[derive(Debug, Clone, PartialEq)]
422pub struct Placement {
423 pub array: WpeArray,
424 pub residence: Residence,
425 pub area_mm2: f64,
426}
427
428/// A solved floorplan: where every array lives, what it costs, and what is left for the logic.
429#[derive(Debug, Clone, PartialEq)]
430pub struct WpeFloorplan {
431 pub profile: WpeProfile,
432 pub placements: Vec<Placement>,
433 pub macro_mm2: f64,
434 pub flop_mm2: f64,
435 /// Total on-die memory area — macros plus flop-backed arrays.
436 pub memory_mm2: f64,
437 /// Die area the requested logic needs at the measured standard-cell density.
438 pub logic_mm2: f64,
439 pub logic_cells: u64,
440 pub macro_instances: u32,
441 pub off_chip_bits: u64,
442}
443
444/// Why an engine cannot be placed. Typed, so a caller cannot mistake a refusal for a plan.
445#[derive(Debug, Clone, PartialEq)]
446pub enum WpeUnplaceable {
447 /// The memory plus the logic is larger than the vehicle's user area.
448 ExceedsUserArea {
449 memory_mm2: f64,
450 logic_mm2: f64,
451 available_mm2: f64,
452 },
453 /// The vehicle has no fixed user area, so "fits" is not a question this can answer.
454 NoUserArea { pdk: &'static str },
455 /// The process cannot build one of the arrays at all.
456 Process {
457 array: &'static str,
458 why: PdkUnsupported,
459 },
460}
461
462/// Plan every array of `profile` onto `pdk`, sending the roles in `off_chip` behind the memory
463/// edge, and check the result against the vehicle's user area alongside `logic_cells` of engine.
464///
465/// An array whose role is off-chip costs no die area and contributes to `off_chip_bits`. An array
466/// that is read combinationally is never given a macro, because a macro read is registered and the
467/// placement would be unbuildable — the planner refuses to produce a floorplan it knows is a lie.
468pub fn plan_wpe(
469 pdk: &'static PdkTarget,
470 profile: &WpeProfile,
471 off_chip: &[ArrayRole],
472 logic_cells: u64,
473) -> Result<WpeFloorplan, WpeUnplaceable> {
474 let Some(ua) = pdk.user_area else {
475 return Err(WpeUnplaceable::NoUserArea { pdk: pdk.name });
476 };
477
478 let (mut macro_mm2, mut flop_mm2, mut off_chip_bits, mut macro_instances) = (0.0, 0.0, 0u64, 0);
479 let mut placements = Vec::new();
480
481 for array in profile.arrays(true) {
482 if off_chip.contains(&array.role) {
483 off_chip_bits += array.bits_total();
484 placements.push(Placement {
485 array,
486 residence: Residence::OffChip,
487 area_mm2: 0.0,
488 });
489 continue;
490 }
491
492 // A combinationally-read array is flops by construction — asking `plan_memory` would let
493 // it answer "macro", which is smaller and unbuildable.
494 let (residence, area) = if array.combinational_read {
495 let a = flop_area_mm2(array.bits_total());
496 (
497 Residence::Flops {
498 cells: (a * MEASURED_CELLS_PER_MM2) as u64,
499 },
500 a,
501 )
502 } else {
503 let req = MemoryRequest {
504 words: array.words,
505 bits: array.bits,
506 dual_port: array.dual_port,
507 needs_multi_corner: true,
508 };
509 match pdk.plan_memory(req) {
510 Ok(p) => match p.implementation {
511 Implementation::Flops { cells } => (Residence::Flops { cells }, p.area_mm2),
512 Implementation::Macros {
513 macro_name,
514 instances,
515 ..
516 } => (
517 Residence::Macros {
518 macro_name,
519 instances,
520 },
521 p.area_mm2,
522 ),
523 },
524 // One array being larger than the whole user area is an AREA fact about this
525 // floorplan, not a process refusal — keep its cost and let the aggregate check
526 // below report the real total, which is what a reader needs to see.
527 Err(PdkUnsupported::ExceedsUserArea { needed_mm2, .. }) => (
528 Residence::Macros {
529 macro_name: "(over-area)",
530 instances: 0,
531 },
532 needed_mm2,
533 ),
534 Err(why) => {
535 return Err(WpeUnplaceable::Process {
536 array: array.name,
537 why,
538 });
539 }
540 }
541 };
542 match &residence {
543 Residence::Macros { instances, .. } => {
544 macro_mm2 += area;
545 macro_instances += instances;
546 }
547 Residence::Flops { .. } => flop_mm2 += area,
548 Residence::OffChip => {}
549 }
550 placements.push(Placement {
551 array,
552 residence,
553 area_mm2: area,
554 });
555 }
556
557 let memory_mm2 = macro_mm2 + flop_mm2;
558 let logic_mm2 = logic_cells as f64 / MEASURED_CELLS_PER_MM2;
559 if memory_mm2 + logic_mm2 > ua.mm2() {
560 return Err(WpeUnplaceable::ExceedsUserArea {
561 memory_mm2,
562 logic_mm2,
563 available_mm2: ua.mm2(),
564 });
565 }
566 Ok(WpeFloorplan {
567 profile: *profile,
568 placements,
569 macro_mm2,
570 flop_mm2,
571 memory_mm2,
572 logic_mm2,
573 logic_cells,
574 macro_instances,
575 off_chip_bits,
576 })
577}
578
579fn flop_area_mm2(bits: u64) -> f64 {
580 bits as f64 * crate::codegen_sva::pdk_target::FLOP_BIT_UM2 / 1.0e6
581}