Skip to main content

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}