Skip to main content

rhdl_dsig_scalable_core/
config.rs

1//! The planner: predicts signatures per second and resources for every build
2//! and picks the best one for a LUT budget.
3//!
4//! # The problem
5//!
6//! A Vivado run of this design takes hours. You cannot try every size of the
7//! engine on real tools just to learn which one fits the chip and how fast it
8//! signs. So before you build anything, you want a cheap answer to two
9//! questions:
10//!
11//! 1. *Does this build fit?* (LUTs, block RAM, UltraRAM, DSPs.)
12//! 2. *How many signatures per second will it make?*
13//!
14//! This module answers both with plain arithmetic. It takes costs that were
15//! **measured once** on real place-and-route runs, multiplies them by how many
16//! copies a build has, and compares the result with the device and with the
17//! project's 420,000-LUT cap. Nothing here touches hardware; it is a
18//! spreadsheet written in Rust.
19//!
20//! # The reasoning
21//!
22//! The engine is made of identical *slices* (see [`crate::slice`]). Each slice
23//! is one complete signer. So the cost of a build is close to linear:
24//! `slices × (cost of one slice) + (things that exist once)`. The "things that
25//! exist once" are the Ed25519 certificate core ([`crate::ed25519`]) and the
26//! engine glue (the certificate unit, control and HBM I/O in
27//! [`crate::engine`]).
28//!
29//! The one big lever besides the slice count is where the AES S-boxes live.
30//! (An S-box is the 256-entry byte-substitution table at the heart of AES;
31//! Haraka-512 is built from AES rounds.) A slice has three Haraka lanes
32//! ([`crate::chain_step`]) with 494 live S-boxes each. In LUTs an S-box costs
33//! roughly 32 LUTs (an estimate, see [`LUTS_PER_LUT_SBOX`]); in block RAM or
34//! UltraRAM it costs one memory instead. LUTs are the scarce budget, and the
35//! U280 has RAM that the design does not otherwise use. So moving S-boxes
36//! into spare RAM buys LUTs, which can buy more slices.
37//!
38//! # The decision
39//!
40//! Model only the choices that matter (slices: 2, 4 or 8; S-boxes per lane in
41//! UltraRAM and block RAM), list the two sensible placements per slice count
42//! (all-LUT and RAM-filled, see [`Build::all`]), and pick the fastest one
43//! that fits ([`best`]). The binary `dsig_plan` prints the answer; the chip
44//! planner component in `planner.rs` (Cargo feature `planner`) reuses these
45//! same constants.
46//!
47//! ```text
48//!   measured per-block costs ──┐
49//!   (SLICE_LUT, ED25519_LUT,   │
50//!    GLUE_LUT, ...)            ▼
51//!                       ┌──────────────┐   Resources   ┌──────────┐
52//!   Build {slices,  ──▶ │ resources()  │ ────────────▶ │ fits(%)  │──┐
53//!     uram, bram}       └──────────────┘               └──────────┘  │
54//!        │              ┌───────────────────────────┐                ▼
55//!        └────────────▶ │ signatures_per_second(f)  │ ──▶  best(): fastest
56//!                       │ = min(slices, certs,      │      build that fits,
57//!                       │       frame writes)       │      ties → fewer LUTs
58//!                       └───────────────────────────┘
59//! ```
60//!
61//! A [`Build`] is a slice count (`DSIG_SLICES=2|4|8`) plus where each
62//! lane's 494 live AES S-boxes live: UltraRAM, block RAM or LUTs
63//! (`DSIG_SBOX_PLACEMENT`, see [`sbox_placement`](crate::sbox_placement)).
64//! Budgets are percentages of [`LUT_CAP`] (420,000 CLB LUTs, the budget DSig
65//! and Ed25519 share for the comparison).
66//!
67//! # Throughput model
68//!
69//! [`Build::signatures_per_second`] is the slowest of these stages:
70//!
71//! | Stage | Signatures per second | Why |
72//! |---|---|---|
73//! | slices | `SLICES × f / 68` | each slice makes one key per 68-cycle period ([`slice_schedule`](crate::slice_schedule)) |
74//! | certificates | `128 × f / 895` | the Ed25519 core finishes one batch per ~895 cycles with ≥ 32 in flight |
75//! | frame writes | `SLICES × port bandwidth / 2,048 B` | one HBM port per slice, 64 B per clock |
76//!
77//! times [`STEADY_STATE_EFFICIENCY`], the fraction of the slice ceiling that
78//! the cycle-accurate throughput test measures in steady state.
79//!
80//! # Resource model
81//!
82//! Per-block costs come from Vivado 2023.2 out-of-context place-and-route on
83//! the U280 part (`xcu280-fsvh2892-2L-e`), except where a field says
84//! *estimate*:
85//!
86//! | Block | CLB LUT | Source |
87//! |---|---:|---|
88//! | Slice, all S-boxes in LUTs | 127,502 | first routed `slices-2` link, in context (59,946 of them LUT-RAM, mostly S-box ROMs) |
89//! | Haraka chain-step lane | 23,902 | Vivado OOC routed |
90//! | BLAKE3 lane | 16,422 | Vivado OOC routed |
91//! | Ed25519SignCoreSmall | 25,383 | same link, in context (26,408 OOC) |
92//! | Engine glue (cert unit's BLAKE3 lane, control, HBM I/O) | 20,564 | same link: kernel 301,037 − slices − Ed25519 |
93//!
94//! S-boxes placed in RAM go in dual-port pairs ([`SboxPair`](crate::aes_table::SboxPair)):
95//! two per URAM, or two per BRAM18 (four per BRAM36).
96//! Either way it frees about [`LUTS_PER_LUT_SBOX`] LUTs. That LUT figure is an
97//! **estimate** (a 256 × 8 ROM is eight 8-input functions, four LUT6s each).
98//! [`Build::fill`] places as many S-boxes as the device's spare UltraRAM and
99//! block RAM allow, keeping [`RAM_HEADROOM`] of each free.
100//!
101//! # Worked example (the measured anchor)
102//!
103//! The all-LUT `slices-2` build is the one build that was actually routed, so
104//! the model should reproduce it:
105//!
106//! ```text
107//!   2 × SLICE_LUT      2 × 127,502 = 255,004
108//! + ED25519_LUT                       25,383
109//! + GLUE_LUT                          20,564
110//! = kernel                           300,951  (routed link: 301,037)
111//! ```
112//!
113//! `GLUE_LUT` was itself derived from that same link (kernel minus slices
114//! minus Ed25519), so matching it is a consistency check, not an independent
115//! validation. Every other build is an extrapolation from this one point plus
116//! the S-box estimate.
117
118// `fmt::Write` lets `sweep` append formatted lines to a `String` with
119// `writeln!`; the `as _` import brings the trait in without naming it.
120use std::fmt::Write as _;
121
122// ------------------------------------------------------------ device limits
123//
124// These are fixed facts about the target chip (xcu280) and the project rules.
125// Every build is checked against them in `Build::fits`.
126
127/// Project LUT cap (100 %): the comparison budget shared with Ed25519.
128///
129/// Unit: CLB LUTs. All `--lut` percentages in `dsig_plan` are percentages of
130/// this number, not of the whole device. It exists so DSig and the Ed25519
131/// baseline are compared at the same area.
132pub const LUT_CAP: u32 = 420_000;
133/// Design clock in MHz.
134///
135/// The kernel is linked at 205 MHz. Every rate in this module is "work per
136/// clock times this clock", so a slower clock scales every rate down by the
137/// same factor.
138pub const DESIGN_MHZ: f64 = 205.0;
139/// Physical BRAM36 tiles of the xcu280.
140///
141/// A BRAM36 is one 36-kbit block RAM tile; it can also be used as two BRAM18
142/// halves. Kept as `f64` because the model counts half tiles.
143pub const DEVICE_BRAM36: f64 = 2_016.0;
144/// BRAM36 tiles a kernel can actually use: the platform's dynamic region.
145///
146/// Vivado's DRC for build v4 reported 3,552 BRAM18 sites in
147/// `pblock_dynamic_region` (the rest belong to the static shell), i.e. 1,776
148/// BRAM36. Every fit check uses this, not [`DEVICE_BRAM36`]: build v4 was
149/// sized against the device and failed placement.
150pub const KERNEL_BRAM36: f64 = 1_776.0;
151/// Physical UltraRAMs of the xcu280.
152///
153/// An UltraRAM (URAM) is a larger on-chip memory block. The key caches use
154/// them, and spare ones can hold S-boxes.
155pub const DEVICE_URAM: u32 = 960;
156/// Physical DSP48E2 slices of the xcu280.
157///
158/// DSPs are hard multiply-add blocks. In this model only the Ed25519 core
159/// uses them.
160pub const DEVICE_DSP: u32 = 9_024;
161
162// --------------------------------------------------------- throughput model
163
164/// Cycles per key in one slice (the modulo schedule's period).
165///
166/// Unit: clock cycles. A slice finishes one one-time key every 68 cycles (see
167/// [`crate::slice_schedule`]), and each key signs exactly one message, so one
168/// slice makes at most `f / 68` signatures per second.
169pub const KEY_PERIOD: f64 = 68.0;
170/// Measured Ed25519 cycles per signature with ≥ 32 requests in flight.
171///
172/// Unit: clock cycles per Ed25519 signature. One Ed25519 signature certifies
173/// a whole batch of 128 keys, so the certificate ceiling is `128 × f / 895`.
174pub const ED25519_CYCLES_PER_SIGN: f64 = 895.0;
175/// Bytes written per signature (one 2 KiB frame slot, 26 beats used).
176///
177/// Unit: bytes. A beat is one 64-byte HBM write. The 1,642-byte signature
178/// plus its message index needs 26 beats; the rest of the 2 KiB slot is not
179/// written, so only 26 × 64 bytes count against port bandwidth.
180pub const FRAME_BYTES: f64 = 26.0 * 64.0;
181/// Fraction of the slice ceiling reached in steady state. Set from the
182/// cycle-accurate throughput test (`tests/throughput.rs`); 1.0 until measured.
183///
184/// Unit: a ratio in `0.0..=1.0`. It is the correction between the ideal
185/// "one key per 68 cycles" and what the simulated engine sustains. While it
186/// is 1.0 the planner reports ceilings, not measured rates.
187pub const STEADY_STATE_EFFICIENCY: f64 = 1.0;
188
189// ----------------------------------------------------------- resource model
190
191/// Measured slice cost with every S-box in LUTs (routed `slices-2` link).
192///
193/// Unit: CLB LUTs per slice. This is the largest term in the model.
194pub const SLICE_LUT: f64 = 127_502.0;
195/// BRAM36 tiles of one slice (caches in UltraRAM, S-boxes in LUTs), from two
196/// kernel syntheses: build v3 used 202 tiles at 2 slices; build v5 used
197/// 1,362.5 at 4 slices, of which 12 lanes × 88 are T-tables, leaving 306.5.
198/// The difference, (306.5 − 202) / 2 ≈ 52 per slice, is the slice's FIFOs,
199/// frame buffers and Merkle RAMs.
200///
201/// An earlier value, 11 (a routed link's 555 tiles minus the 544 its
202/// block-RAM caches used), missed those buffers. Undercounting block RAM is
203/// what sank build v4, so this figure errs high: with it the fixed part
204/// (Ed25519 87 + glue 28 = 115) slightly exceeds what v3 implies (98).
205///
206/// Unit: BRAM36 tiles per slice.
207pub const SLICE_BRAM36: f64 = 52.0;
208/// UltraRAMs of one slice (4 banks × 18), Vivado-confirmed.
209///
210/// Unit: URAMs per slice. These hold the 512-key cache
211/// ([`crate::cache_bank`]).
212pub const SLICE_URAM: u32 = 72;
213/// Haraka chain-step lanes per slice.
214///
215/// A lane is one pipelined Haraka-512 chain step ([`crate::chain_step`]).
216/// Three in series finish a whole w = 4 chain per clock
217/// ([`crate::slice_chain`]).
218pub const LANES_PER_SLICE: u32 = 3;
219/// Live S-boxes per lane.
220///
221/// The S-boxes left after the constant bytes of a chain step are folded away.
222/// This is how many S-boxes per lane can be moved out of LUTs.
223pub const SBOXES_PER_LANE: u32 = 494;
224/// LUTs per LUT-ROM S-box. **Estimate**: the routed link's 59,946 LUT-RAM
225/// per slice over its 1,482 S-boxes is ~40; 32 (8 outputs × 4 LUT6) is the
226/// conservative floor used here.
227///
228/// Unit: CLB LUTs saved per S-box moved to RAM. Choosing the low figure means
229/// the planner under-promises savings rather than over-promises them.
230pub const LUTS_PER_LUT_SBOX: f64 = 32.0;
231/// Ed25519SignCoreSmall in context (routed `slices-2` link).
232///
233/// Unit: CLB LUTs, counted once per kernel (one core serves every slice).
234pub const ED25519_LUT: f64 = 25_383.0;
235/// Ed25519SignCoreSmall BRAM36 tiles in context.
236///
237/// Unit: BRAM36 tiles, once per kernel.
238pub const ED25519_BRAM36: f64 = 87.0;
239/// Ed25519SignCoreSmall DSPs.
240///
241/// Unit: DSP48E2s, once per kernel. In this model it is the kernel's whole
242/// DSP use.
243pub const ED25519_DSP: u32 = 248;
244/// Engine glue outside slices and the Ed25519 core (routed `slices-2` link).
245///
246/// Unit: CLB LUTs, once per kernel. Derived as kernel 301,037 minus two
247/// slices minus Ed25519, so it absorbs everything not modeled separately.
248pub const GLUE_LUT: f64 = 20_564.0;
249/// Engine glue BRAM36 (certificate records, message FIFO), same link.
250///
251/// Unit: BRAM36 tiles, once per kernel.
252pub const GLUE_BRAM36: f64 = 28.0;
253
254/// Fraction of the device's UltraRAM and block RAM the planner leaves free.
255///
256/// Unit: a ratio (5 %). Filling RAM to the last tile makes placement and
257/// routing harder, so [`Build::fill`] stops short.
258pub const RAM_HEADROOM: f64 = 0.05;
259/// LUTs of one lane's UltraRAM loader (counter + one LUT S-box): an **estimate**.
260///
261/// Unit: CLB LUTs per lane. UltraRAM cannot be preloaded by the bitstream, so
262/// a lane with any UltraRAM S-box needs this small loader to write the table
263/// after reset (see [`crate::sbox_placement`]).
264pub const LOADER_LUT: f64 = 80.0;
265
266/// One buildable configuration.
267///
268/// It is exactly the set of compile-time choices that change cost and speed:
269/// the `DSIG_SLICES` and `DSIG_SBOX_PLACEMENT` build variables. [`Build::command`] prints the command that builds it.
270#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271pub struct Build {
272    /// Slices (2, 4 or 8).
273    ///
274    /// Each slice is one complete signer; more slices means more signatures
275    /// per second and proportionally more LUTs.
276    pub slices: u32,
277    /// S-boxes per lane in UltraRAM.
278    ///
279    /// Count out of [`SBOXES_PER_LANE`]; the same number in every lane.
280    pub uram_per_lane: u32,
281    /// S-boxes per lane in block RAM.
282    ///
283    /// Count out of [`SBOXES_PER_LANE`]; two share one BRAM18 (a quarter of
284    /// a tile each).
285    /// `uram_per_lane + bram_per_lane` never exceeds 494 in builds made by
286    /// [`Build::fill`].
287    pub bram_per_lane: u32,
288}
289
290/// Predicted resources.
291///
292/// The model's output for one [`Build`]: what the whole kernel would use on
293/// the device. [`Build::fits`] compares it with the `DEVICE_*` constants and
294/// [`LUT_CAP`].
295#[derive(Clone, Copy, Debug, PartialEq)]
296pub struct Resources {
297    /// CLB LUTs of the kernel.
298    ///
299    /// `f64` so the sum stays exact before any rounding for display.
300    pub lut: f64,
301    /// BRAM36 tiles.
302    ///
303    /// Can end in .5 when an odd number of BRAM18 S-boxes is placed.
304    pub bram36: f64,
305    /// UltraRAMs.
306    pub uram: u32,
307    /// DSP48E2s.
308    pub dsp: u32,
309}
310
311impl Build {
312    /// All S-boxes in LUTs.
313    ///
314    /// The baseline build: no RAM placement, closest to the routed
315    /// measurement.
316    #[must_use]
317    pub const fn lut_only(slices: u32) -> Build {
318        Build { slices, uram_per_lane: 0, bram_per_lane: 0 }
319    }
320
321    /// The most S-boxes per lane that spare UltraRAM, then spare block RAM,
322    /// can hold.
323    ///
324    /// UltraRAM is filled first, block RAM takes the remainder. Both keep
325    /// [`RAM_HEADROOM`] free.
326    #[must_use]
327    pub fn fill(slices: u32) -> Build {
328        Build::fill_within(
329            slices,
330            f64::from(DEVICE_URAM) * (1.0 - RAM_HEADROOM),
331            KERNEL_BRAM36 * (1.0 - RAM_HEADROOM),
332        )
333    }
334
335    /// Like [`Build::fill`], but only `uram` UltraRAMs and `bram36` BRAM36
336    /// tiles are available in total (the share the chip planner gives DSig).
337    ///
338    /// Why: on a shared chip DSig gets part of the RAM, not the whole device.
339    /// Filling the whole device's spare RAM would make every partial share
340    /// look too small.
341    #[must_use]
342    pub fn fill_within(slices: u32, uram: f64, bram36: f64) -> Build {
343        // Total Haraka lanes in the kernel; the placement is per lane, so the
344        // spare RAM is shared out evenly over all of them.
345        let lanes = f64::from(slices * LANES_PER_SLICE);
346        // What the build uses before any S-box moves (caches, Ed25519, glue).
347        let base = Build::lut_only(slices).resources();
348        // URAMs left after the headroom and the key caches.
349        let uram_free = uram - f64::from(base.uram);
350        // URAM S-boxes per lane (two per URAM): whole numbers only, never
351        // negative, never more than the lane has.
352        let uram = ((2.0 * uram_free / lanes).floor().max(0.0) as u32).min(SBOXES_PER_LANE);
353        // Spare block RAM in S-box slots: two per BRAM18, four per BRAM36.
354        let slots = 4.0 * (bram36 - base.bram36);
355        // Block-RAM S-boxes per lane, limited to what UltraRAM did not take.
356        let bram = ((slots / lanes).floor().max(0.0) as u32).min(SBOXES_PER_LANE - uram);
357        Build { slices, uram_per_lane: uram, bram_per_lane: bram }
358    }
359
360    /// The all-LUT and the RAM-filled build of every slice count.
361    ///
362    /// Six candidates in total: the two ends of the placement range for each
363    /// of the three slice counts. In this model, moving more S-boxes into
364    /// free RAM only lowers LUTs, so the partial placements in between are
365    /// not listed.
366    #[must_use]
367    pub fn all() -> Vec<Build> {
368        let mut builds = Vec::new();
369        // The three slice counts `DSIG_SLICES` accepts.
370        for slices in [2, 4, 8] {
371            builds.push(Build::lut_only(slices));
372            builds.push(Build::fill(slices));
373        }
374        builds
375    }
376
377    /// A short name: the slice feature plus the placement, if any.
378    ///
379    /// Used as the row label in [`sweep`], for example `slices-2` or
380    /// `slices-4 uram=… bram=…`.
381    #[must_use]
382    pub fn features(self) -> String {
383        // No S-box moved: the feature name alone identifies the build.
384        if self.uram_per_lane + self.bram_per_lane == 0 {
385            format!("slices-{}", self.slices)
386        } else {
387            format!(
388                "slices-{} uram={} bram={}",
389                self.slices, self.uram_per_lane, self.bram_per_lane
390            )
391        }
392    }
393
394    /// The command that builds it.
395    ///
396    /// So a reader can copy the planner's answer straight into a shell.
397    #[must_use]
398    pub fn command(self) -> String {
399        // The placement variable is only needed when something moves out of
400        // LUTs; otherwise the default (all LUT) applies.
401        let env = if self.uram_per_lane + self.bram_per_lane == 0 {
402            String::new()
403        } else {
404            format!(
405                "DSIG_SBOX_PLACEMENT=uram={},bram={} ",
406                self.uram_per_lane, self.bram_per_lane
407            )
408        };
409        format!(
410            "DSIG_SLICES={} {env}cargo build --profile sim",
411            self.slices
412        )
413    }
414
415    /// Predicted kernel resources.
416    ///
417    /// The cost model, one line per resource:
418    ///
419    /// ```text
420    /// lut    = slices × SLICE_LUT − lanes × (placed × 32 − loader) + Ed25519 + glue
421    /// bram36 = slices × SLICE_BRAM36 + lanes × bram_per_lane / 2 + Ed25519 + glue
422    /// uram   = slices × 72 + lanes × uram_per_lane
423    /// dsp    = Ed25519 only
424    /// ```
425    ///
426    /// `lanes = slices × 3`, `placed = uram_per_lane + bram_per_lane`, and
427    /// `loader` is [`LOADER_LUT`] when the lane has UltraRAM S-boxes.
428    #[must_use]
429    pub fn resources(self) -> Resources {
430        // Haraka lanes in the whole kernel.
431        let lanes = f64::from(self.slices * LANES_PER_SLICE);
432        // S-boxes per lane taken out of LUTs (either RAM kind).
433        let placed = f64::from(self.uram_per_lane + self.bram_per_lane);
434        // A lane only needs the after-reset loader if it has UltraRAM S-boxes.
435        let loader = if self.uram_per_lane > 0 { LOADER_LUT } else { 0.0 };
436        // Slice count as f64 for the LUT and BRAM sums.
437        let slices = f64::from(self.slices);
438        Resources {
439            // Slices pay full price, minus the LUTs freed by every placed
440            // S-box, plus a loader per lane if UltraRAM is used; then the
441            // once-per-kernel Ed25519 core and glue.
442            lut: slices * SLICE_LUT - lanes * (placed * LUTS_PER_LUT_SBOX - loader)
443                + ED25519_LUT
444                + GLUE_LUT,
445            // Two block-RAM S-boxes share a BRAM18: a quarter of a BRAM36 each.
446            bram36: slices * SLICE_BRAM36
447                + lanes * f64::from(self.bram_per_lane) / 4.0
448                + ED25519_BRAM36
449                + GLUE_BRAM36,
450            // Key-cache URAMs per slice plus one URAM per pair of UltraRAM
451            // S-boxes (rounded up per lane).
452            uram: self.slices * SLICE_URAM + self.slices * LANES_PER_SLICE * self.uram_per_lane.div_ceil(2),
453            // Only the Ed25519 core uses DSPs in this model.
454            dsp: ED25519_DSP,
455        }
456    }
457
458    /// Whether the build fits a LUT budget (percent of [`LUT_CAP`]) and the
459    /// device's block RAM, UltraRAM and DSPs.
460    ///
461    /// `lut_percent` is a percentage, so `100.0` means the full 420,000-LUT
462    /// cap. The RAM and DSP limits are the whole device, because the cap only
463    /// applies to LUTs.
464    #[must_use]
465    pub fn fits(self, lut_percent: f64) -> bool {
466        // Predicted use of every resource.
467        let r = self.resources();
468        r.lut <= f64::from(LUT_CAP) * lut_percent / 100.0
469            && r.bram36 <= KERNEL_BRAM36
470            && r.uram <= DEVICE_URAM
471            && r.dsp <= DEVICE_DSP
472    }
473
474    /// Predicted steady-state signatures per second at `mhz`, and the stage
475    /// that limits it.
476    ///
477    /// The engine is a pipeline of three stages that all must keep up, so the
478    /// slowest one sets the rate:
479    ///
480    /// ```text
481    ///  slices ─────────▶ certificates ─────────▶ frame writes
482    ///  SLICES × f / 68    128 × f / 895           SLICES × 64 B × f / (26 × 64 B)
483    ///          └──────────────── min(...) × STEADY_STATE_EFFICIENCY ──▶ sig/s
484    /// ```
485    ///
486    /// The returned name (`"slices"`, `"certificates"` or `"frame writes"`)
487    /// tells you which stage to improve next.
488    #[must_use]
489    pub fn signatures_per_second(self, mhz: f64) -> (f64, &'static str) {
490        // Clock in hertz.
491        let f = mhz * 1e6;
492        // Slice ceiling: one key (one signature) per slice every 68 cycles.
493        let slices = f64::from(self.slices) * f / KEY_PERIOD;
494        // Certificate ceiling: one Ed25519 signature per 895 cycles covers 128.
495        let certs = 128.0 * f / ED25519_CYCLES_PER_SIGN;
496        // Write ceiling: one 64-byte beat per clock per slice port, and a
497        // frame needs FRAME_BYTES of beats.
498        let writes = f64::from(self.slices) * 64.0 * f / FRAME_BYTES;
499        // Start with the slice stage and replace it with any slower stage.
500        let mut best = (slices, "slices");
501        for (rate, name) in [(certs, "certificates"), (writes, "frame writes")] {
502            if rate < best.0 {
503                best = (rate, name);
504            }
505        }
506        (best.0 * STEADY_STATE_EFFICIENCY, best.1)
507    }
508}
509
510/// The fastest build that fits the budget, if any.
511///
512/// `lut_percent` is a percentage of [`LUT_CAP`]; `mhz` is the clock to rate
513/// builds at (normally [`DESIGN_MHZ`]). Returns `None` when even the smallest
514/// build does not fit. Among equally fast builds it prefers the one with
515/// fewer LUTs, which leaves more room for timing closure.
516#[must_use]
517pub fn best(lut_percent: f64, mhz: f64) -> Option<Build> {
518    Build::all()
519        .into_iter()
520        // Drop every build that breaks the LUT budget or a device limit.
521        .filter(|b| b.fits(lut_percent))
522        .max_by(|a, b| {
523            // Predicted rates of the two builds being compared.
524            let (ra, rb) = (a.signatures_per_second(mhz).0, b.signatures_per_second(mhz).0);
525            // Faster first; then fewer LUTs.
526            ra.total_cmp(&rb).then(b.resources().lut.total_cmp(&a.resources().lut))
527        })
528}
529
530/// A human-readable table of every build.
531///
532/// One row per candidate in [`Build::all`]: name, predicted rate, limiting
533/// stage, resources and whether it fits `lut_percent`. This is what
534/// `dsig_plan --sweep` prints.
535#[must_use]
536pub fn sweep(lut_percent: f64, mhz: f64) -> String {
537    // The table is built in memory and returned, so callers decide where it
538    // goes.
539    let mut out = String::new();
540    // Header row. Writing to a String cannot fail, so the result is ignored.
541    let _ = writeln!(
542        out,
543        "{:<30} sig/s @{mhz} MHz  limit          CLB LUT   BRAM36  URAM  DSP  fits {lut_percent}%", "build"
544    );
545    for b in Build::all() {
546        // Rate and limiting stage, then resources, for this row.
547        let (rate, limit) = b.signatures_per_second(mhz);
548        let r = b.resources();
549        // Rate is printed in millions of signatures per second.
550        let _ = writeln!(
551            out,
552            "{:<30} {:>12.2}M  {:<13} {:>8.0} {:>8.0} {:>5} {:>4}  {}",
553            b.features(),
554            rate / 1e6,
555            limit,
556            r.lut,
557            r.bram36,
558            r.uram,
559            r.dsp,
560            if b.fits(lut_percent) { "yes" } else { "no" }
561        );
562    }
563    out
564}
565
566/// Sanity checks of the model itself (not of hardware).
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    /// `fill` must never ask for more UltraRAM or block RAM than the chip has.
572    #[test]
573    fn filled_builds_respect_the_device() {
574        for slices in [2, 4, 8] {
575            let r = Build::fill(slices).resources();
576            assert!(r.uram <= DEVICE_URAM && r.bram36 <= KERNEL_BRAM36, "{slices}: {r:?}");
577        }
578    }
579
580    /// The routed fact the model is anchored on: two all-LUT slices fit the
581    /// 420k cap; eight all-LUT slices (about a million LUTs) do not.
582    #[test]
583    fn two_slices_fit_the_full_budget_and_eight_do_not() {
584        assert!(Build::lut_only(2).fits(100.0));
585        assert!(!Build::lut_only(8).fits(100.0));
586    }
587
588    /// With two slices the slice stage, not certificates or writes, is the
589    /// bottleneck, and its rate is exactly `2 × f / 68`.
590    #[test]
591    fn the_slice_ceiling_is_slices_times_f_over_68() {
592        // Rate and limit of the baseline two-slice build at 205 MHz.
593        let (rate, limit) = Build::lut_only(2).signatures_per_second(205.0);
594        assert_eq!(limit, "slices");
595        assert!((rate - 2.0 * 205e6 / 68.0).abs() < 1.0);
596    }
597}