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}