rhdl_ed25519_scalable_core/config.rs
1//! The planner: predicts throughput and resources for every profile and
2//! picks one from a LUT and DSP budget.
3//!
4//! A [`Profile`] is `fb/sha/sc` plus `fused`; written as four numbers
5//! (`15/11/3/1`) the last one is 1 for one batch-compress machine and 0 for
6//! fused mode, the same order as `ED25519_SCALABLE_PROFILE`. Budgets ([`Budget`]) are
7//! percentages of the project caps: [`LUT_CAP`] (260,000; up to 200 %) and
8//! [`DSP_CAP`] (7,000; up to the U280's 9,024). The planner reports the
9//! smallest LUT percentage that still gives a working signer
10//! ([`min_lut_percent`]) and the DSP percentage beyond which more DSPs do not
11//! help ([`useful_dsp_percent`]).
12//!
13//! # Throughput model
14//!
15//! [`Profile::cycles_per_sign`] is the slowest of these stages:
16//!
17//! | Stage | Cycles per signature | 15/11/3/1 |
18//! |---|---|---:|
19//! | fixed-base machines | `FB_BUNDLES / fb / UTIL_MACHINE` (fused: plus the SHA term) | 26.94 |
20//! | SHA-512 rings | `3 · 81 / sha / UTIL_SHA` | 24.93 |
21//! | scalar rings | `2 · 32 / sc / UTIL_RING` | 23.70 |
22//! | batch compression | `CP_BUNDLES / CP_BATCH / UTIL_MACHINE` | 13.63 |
23//! | compress credits | `(FB_BUNDLES·32 + CP_BUNDLES·CP_CONTEXTS + CREDIT_OVERHEAD) / 2048` | 23.25 |
24//! | admission | 2 | 2.00 |
25//!
26//! The utilizations are conservative (the fixed-base machines measured
27//! 0.99–0.997); [`CREDIT_OVERHEAD`] is fitted to measured runs. 15/11/3/1
28//! measured 26.43 against the predicted 26.94.
29//!
30//! # Resource model
31//!
32//! [`yosys_calibrated`] uses Yosys leaf maps (in `mapping/`) plus structural
33//! adjustments for blocks not yet mapped; [`structural`] is a hand estimate.
34//! DSPs trade for LUTs the way Vivado does under `synth_design -max_dsp`: a
35//! multiply tile without a DSP becomes fabric, and spare DSPs absorb wide
36//! adders (`USE_DSP`).
37//!
38//! **The largest uncertainty**: about 8,600 of each multiplier's LUTs are
39//! its 225 chain additions `P = PCIN + A·B`. Yosys packs that pattern into
40//! DSPs for 7-series but not UltraScale+; Vivado infers such cascades.
41//! [`calibrated`] assumes packing (15/11/3/1 ≈ 219k LUT); with
42//! `yosys_calibrated(false)` it would not fit 260k. Only Vivado settles it.
43//!
44//! **Every resource number here is an estimate**, and the clock
45//! ([`DESIGN_MHZ`]) is a target until routed.
46//!
47//! # Using it
48//!
49//! ```text
50//! cargo run --release --bin ed25519_plan -- --lut 100 --dsp 100 --mhz 205
51//! cargo run --release --bin ed25519_plan -- --lut 100 --dsp 100 --target 7000000
52//! cargo run --release --bin ed25519_plan -- --sweep
53//! cargo run --release --bin ed25519_plan -- --lut 100 --dsp 100 --out plans/s100
54//! cargo tuner-ed25519 # at the repository root: the planner TUI, Ed25519 only
55//! ```
56//!
57//! `--out` writes the plan, its `ED25519_SCALABLE_PROFILE` value and a
58//! Vivado `synth.tcl` that rejects an over-budget result. The fast way to
59//! iterate on the model is `tools/hostmodel` (no hardware build).
60
61use std::fmt::Write as _;
62
63/// Project LUT cap (100 %), in LUTs. A project budget, well below the
64/// device's [`DEVICE_LUT`].
65pub const LUT_CAP: u32 = 260_000;
66/// Project DSP cap (100 %), in DSP48E2 blocks. Below the device's
67/// [`DEVICE_DSP`], so DSP percentages may exceed 100 (up to ~128.9 %).
68pub const DSP_CAP: u32 = 7_000;
69/// Physical DSP48E2 count of the xcu280 (device data sheet). Hard ceiling
70/// on any DSP budget.
71pub const DEVICE_DSP: u32 = 9_024;
72/// Design clock in MHz (the project's target frequency, not yet a routed
73/// result). Used to turn cycles per signature into signatures per second.
74pub const DESIGN_MHZ: f64 = 205.0;
75/// Physical BRAM36 count of the xcu280. [`plan`] rejects profiles above it;
76/// there is no separate project BRAM cap.
77pub const DEVICE_BRAM36: u32 = 2_016;
78/// Physical LUT count of the xcu280 (for reference; budgets use
79/// [`LUT_CAP`]).
80pub const DEVICE_LUT: u32 = 1_303_680;
81
82/// One block's resources, per instance. [`Profile::estimate`] multiplies
83/// each row by the instance count and then trades DSPs for LUTs using
84/// `tiles`/`lut_per_tile` and `adder_dsps`/`lut_per_adder_dsp`.
85#[derive(Clone, Copy, Debug, Default, PartialEq)]
86pub struct BlockCost {
87 /// Fabric LUTs with every multiply tile in a DSP and adders in fabric
88 /// (the baseline before any DSP trade).
89 pub lut: u32,
90 /// Flip-flops. Not traded against DSPs.
91 pub ff: u32,
92 /// DSP-mappable multiply tiles (17 x 22 or smaller). Each tile uses one
93 /// DSP48E2 when DSPs are available.
94 pub tiles: u32,
95 /// LUTs one tile costs when mapped to fabric instead of a DSP
96 /// (structural estimate in both tables).
97 pub lut_per_tile: u32,
98 /// Wide fabric adders that `USE_DSP` could move into DSPs, counted in
99 /// DSPs they would occupy. Only spare DSPs (after tiles) take them.
100 pub adder_dsps: u32,
101 /// LUTs saved per DSP when those adders move (structural estimate).
102 pub lut_per_adder_dsp: u32,
103 /// BRAM36 primitives (BRAM18s rounded into BRAM36 equivalents).
104 pub bram36: u32,
105}
106
107/// Per-block costs used by [`Profile::estimate`]. Built either by
108/// [`structural`] (hand estimate) or [`yosys_calibrated`] (Yosys leaf maps
109/// plus structural adjustments); replace rows with Vivado measurements as
110/// they become available.
111#[derive(Clone, Copy, Debug)]
112pub struct CostTable {
113 /// Fixed-base field machine (multiplier, adder, scanner, banks). One per
114 /// `Profile::fb`.
115 pub fb_machine: BlockCost,
116 /// Batch-compress field machine (64 contexts of 32 points). Zero or one
117 /// instance (none in fused mode).
118 pub cp_machine: BlockCost,
119 /// Interleaved SHA-512 ring. One per `Profile::sha`.
120 pub sha_ring: BlockCost,
121 /// Digit-serial scalar ring. One per `Profile::sc`.
122 pub scalar_ring: BlockCost,
123 /// Controller base cost (admission, contexts, routing between blocks),
124 /// one instance.
125 pub controller: BlockCost,
126 /// Extra controller LUTs per fixed-base machine (arbitration, masks).
127 pub controller_lut_per_fb: u32,
128 /// Where these numbers came from; printed in every report so an estimate
129 /// is never mistaken for a measurement.
130 pub source: &'static str,
131}
132
133/// The built-in structural estimate: hand-counted from the block structure,
134/// never synthesized. The crate's unit tests use it because it does not
135/// depend on mapping results.
136pub fn structural() -> CostTable {
137 CostTable {
138 fb_machine: BlockCost {
139 lut: 9_000,
140 ff: 22_000,
141 tiles: 225,
142 lut_per_tile: 265,
143 adder_dsps: 30,
144 lut_per_adder_dsp: 40,
145 bram36: 75,
146 },
147 cp_machine: BlockCost {
148 lut: 7_000,
149 ff: 20_000,
150 tiles: 225,
151 lut_per_tile: 265,
152 adder_dsps: 18,
153 lut_per_adder_dsp: 40,
154 bram36: 360,
155 },
156 sha_ring: BlockCost {
157 lut: 3_000,
158 ff: 7_000,
159 tiles: 0,
160 lut_per_tile: 0,
161 adder_dsps: 38,
162 lut_per_adder_dsp: 32,
163 bram36: 0,
164 },
165 scalar_ring: BlockCost {
166 lut: 3_000,
167 ff: 11_000,
168 tiles: 23,
169 lut_per_tile: 300,
170 adder_dsps: 12,
171 lut_per_adder_dsp: 40,
172 bram36: 0,
173 },
174 controller: BlockCost {
175 lut: 7_000,
176 ff: 9_000,
177 tiles: 0,
178 lut_per_tile: 0,
179 adder_dsps: 0,
180 lut_per_adder_dsp: 0,
181 bram36: 80,
182 },
183 controller_lut_per_fb: 300,
184 source: "structural estimate (unsynthesized)",
185 }
186}
187
188/// Leaf costs from Yosys 0.55 `synth_xilinx -family xcup` maps of the
189/// generated netlists (`mapping`), with `packed` selecting
190/// whether the multiplier's 225 column-chain additions are absorbed into the
191/// DSP48E2 post-adders (`P = PCIN + A*B`).
192///
193/// Yosys 0.55 does not pack those additions for UltraScale+ (it does for
194/// 7-series, and Vivado infers such MACC cascades), so `packed = false` is
195/// the raw Yosys count and `packed = true` subtracts the 8,600 LUTs and
196/// 9,675 FFs of those adders and accumulator registers. Neither is Vivado
197/// utilization. The controller has not been mapped separately; its row stays
198/// a structural estimate.
199pub fn yosys_calibrated(packed: bool) -> CostTable {
200 // LUTs and FFs of the multiplier's chain adders and their accumulator
201 // registers (from the Yosys map); subtracted from both field machines
202 // when the post-adders are assumed packed into DSPs.
203 let (mul_adder_lut, mul_adder_ff) = if packed { (8_600, 9_675) } else { (0, 0) };
204 CostTable {
205 // Yosys map: 17,805 LUT + 133 RAM32M16 (8 LUTs each) + 28,053 FF, 225 DSP,
206 // 70 BRAM36 + 3 BRAM18 (counted as 72 BRAM36),
207 // plus ~500 LUT for the second bank-L slot and wider scan fields (structural).
208 fb_machine: BlockCost {
209 lut: 19_370 - mul_adder_lut,
210 ff: 28_053 - mul_adder_ff,
211 tiles: 225,
212 lut_per_tile: 265,
213 adder_dsps: 30,
214 lut_per_adder_dsp: 40,
215 bram36: 72,
216 },
217 // Not mapped yet: fixed-base machine without the scanner's table,
218 // 64 contexts, banks M and I 2^12 / 2^13 deep (structural adjustment).
219 cp_machine: BlockCost {
220 lut: 17_500 - mul_adder_lut,
221 ff: 27_000 - mul_adder_ff,
222 tiles: 225,
223 lut_per_tile: 265,
224 adder_dsps: 18,
225 lut_per_adder_dsp: 40,
226 bram36: 340,
227 },
228 // Yosys map: 4,577 LUT, 7,263 FF.
229 sha_ring: BlockCost {
230 lut: 4_577,
231 ff: 7_263,
232 tiles: 0,
233 lut_per_tile: 0,
234 adder_dsps: 38,
235 lut_per_adder_dsp: 32,
236 bram36: 0,
237 },
238 // Yosys map: 4,285 LUT, 14,550 FF, 23 DSP.
239 scalar_ring: BlockCost {
240 lut: 4_285,
241 ff: 14_550,
242 tiles: 23,
243 lut_per_tile: 300,
244 adder_dsps: 12,
245 lut_per_adder_dsp: 40,
246 bram36: 0,
247 },
248 // Not mapped: structural estimate, larger than `structural()`'s.
249 controller: BlockCost {
250 lut: 12_000,
251 ff: 11_000,
252 tiles: 0,
253 lut_per_tile: 0,
254 adder_dsps: 0,
255 lut_per_adder_dsp: 0,
256 bram36: 90,
257 },
258 controller_lut_per_fb: 400,
259 source: if packed {
260 "Yosys xcup leaf map, DSP post-adders packed (Vivado-like); controller structural"
261 } else {
262 "raw Yosys xcup leaf map (post-adders in fabric); controller structural"
263 },
264 }
265}
266
267/// The planner's default cost table: [`yosys_calibrated`] with packed
268/// post-adders. Use `yosys_calibrated(false)` for the pessimistic bound.
269pub fn calibrated() -> CostTable {
270 yosys_calibrated(true)
271}
272
273/// Bundles (issue slots) in the fixed-base program, from the microcode
274/// compiler (see `programs`). Each bundle occupies one machine slot, so this
275/// is the machine time per signature in cycles at full utilization.
276pub const FB_BUNDLES: u32 = 392;
277/// Bundles of the fused fixed-base + per-point inversion program (compiler
278/// output). Used instead of [`FB_BUNDLES`] in fused mode.
279pub const FUSED_BUNDLES: u32 = 657;
280/// Bundles of one 32-point batch compression (compiler output); shared by
281/// the [`CP_BATCH`] points of the batch.
282pub const CP_BUNDLES: u32 = 423;
283/// Points per compression batch (one batch inversion amortized over them).
284pub const CP_BATCH: u32 = 32;
285/// Batch-compress machine contexts (barrel slots, each holding one batch).
286pub const CP_CONTEXTS: u32 = 64;
287/// Sign contexts (requests in flight). Caps the compress credit pool.
288pub const SIGN_CONTEXTS: u32 = 2048;
289/// Credit hold time in cycles beyond the two programs' issue latency
290/// (queueing, batch filling, hashing). Calibrated from native simulation: with 1,024
291/// credits, 12/6/2/1 and 13/8/2/1 measured 45.7 and 44.1 cycles/signature,
292/// i.e. ~45k cycles per credit against 15.7k + 22.0k of issue latency for
293/// the programs of that time (492 fixed-base bundles, the older compress
294/// machine). With today's programs the issue latency in the formula is
295/// `FB_BUNDLES·32 + CP_BUNDLES·CP_CONTEXTS` = 12,544 + 27,072 cycles.
296/// Rechecked 2026-10-19 on fix13 8,0,2,1: 1,026 signatures in flight at
297/// 46.46 cycles/signature is a mean hold of ~47.7k cycles, i.e. 39.6k + 8.1k.
298pub const CREDIT_OVERHEAD: f64 = 8_000.0;
299
300/// Sustainable utilization of a SHA-512 ring, as a fraction of its ideal
301/// rate (conservative simulation fit, see `cycles_per_sign`).
302pub const UTIL_SHA: f64 = 0.886;
303/// Sustainable utilization of a barrel field machine (0.997 measured in
304/// simulation; 0.97 kept as margin). Planned builds on the shared SHA line
305/// (native simulation, 2026-10-16): 9,0,2,1 ran 41.83 cycles/signature on a
306/// full line (effective utilization 1.04; this model says 44.90), 44.76 on
307/// a fold10 line (0.97) and 4,0,1,1 96.18 on fold4 (1.02; model 101.03), so
308/// the margin covers the slowest measured line.
309pub const UTIL_MACHINE: f64 = 0.97;
310/// Assumed sustainable utilization of a scalar ring (not measured).
311pub const UTIL_RING: f64 = 0.9;
312
313/// A buildable signer profile: how many of each replicated block the
314/// signer instantiates. [`Profile::env_value`] turns it into the
315/// `ED25519_SCALABLE_PROFILE` string that `build.rs` reads.
316#[derive(Clone, Copy, Debug, PartialEq, Eq)]
317pub struct Profile {
318 /// Fixed-base machines (each a 32-context barrel field machine).
319 pub fb: u32,
320 /// SHA-512 rings (nonce and challenge hashing).
321 pub sha: u32,
322 /// Scalar rings (the mod-L scalar jobs).
323 pub sc: u32,
324 /// No compress machine; each fixed-base program inverts its own point.
325 /// Saves the compress machine's area at the cost of a longer fixed-base
326 /// program ([`FUSED_BUNDLES`]).
327 pub fused: bool,
328}
329
330/// Resource and throughput estimate of a profile under a DSP limit, as
331/// returned by [`Profile::estimate`]. All values are model output, not
332/// measurements.
333#[derive(Clone, Debug)]
334pub struct Estimate {
335 /// The profile being estimated.
336 pub profile: Profile,
337 /// Estimated LUTs, after the DSP/LUT trade.
338 pub lut: u32,
339 /// Estimated FFs.
340 pub ff: u32,
341 /// Estimated DSPs used (tiles first, then adders), at most `max_dsp`.
342 pub dsp: u32,
343 /// Estimated BRAM36.
344 pub bram36: u32,
345 /// DSP limit passed to synthesis (`synth_design -max_dsp`).
346 pub max_dsp: u32,
347 /// Whether adders should be pushed into DSPs (`USE_DSP`); true when any
348 /// adder DSPs were assigned.
349 pub adders_in_dsp: bool,
350 /// Steady-state cycles per signature (slowest stage of the cycle model).
351 pub cycles_per_sign: f64,
352 /// Name of the limiting stage.
353 pub bottleneck: &'static str,
354 /// Signatures per second at the assumed clock (`mhz · 1e6 /
355 /// cycles_per_sign`), a projection.
356 pub signs_per_sec: f64,
357}
358
359/// Cycle model, build string and resource estimate of a profile.
360impl Profile {
361 /// Steady-state cycles per signature and the limiting stage.
362 ///
363 /// Each stage's cost is its per-signature service time divided by its
364 /// instance count and sustainable utilization; the pipeline runs at the
365 /// rate of the slowest stage, so the maximum is returned.
366 pub fn cycles_per_sign(&self) -> (f64, &'static str) {
367 // Fused mode runs the longer program that also inverts the point.
368 let fb_bundles = if self.fused { FUSED_BUNDLES } else { FB_BUNDLES };
369 // Ideal service time per signature divided by the sustainable
370 // utilization. The SHA factor was fitted to 12/6/2/1 (45.7 measured
371 // against an ideal 40.5), a run later found to be credit-limited, so
372 // it is conservative; 13/8/2/1 measured 37.97 against the fixed-base
373 // ideal 37.85 (utilization 0.997, modeled as 0.97).
374 // Three blocks per signature, ~81 cycles per block per ring.
375 let sha = 3.0 * 81.0 / self.sha as f64 / UTIL_SHA;
376 // One bundle per machine cycle, machines work in parallel.
377 let fb = fb_bundles as f64 / self.fb as f64 / UTIL_MACHINE;
378 let mut stages = vec![
379 // In fused mode a request's admission credit is a fixed-base
380 // context, held from admission through hashing, so SHA time adds
381 // to the context's occupancy instead of overlapping it (1/1/1/0
382 // measured ~1,000 cycles/signature against this model's 1,055).
383 if self.fused {
384 (fb + sha, "fixed-base contexts (credit held through SHA)")
385 } else {
386 (fb, "fixed-base machines")
387 },
388 (sha, "SHA-512 rings"),
389 // Two 31-step jobs per signature, one step per cycle per ring
390 // (modeled as 32 cycles each).
391 (2.0 * 32.0 / self.sc as f64 / UTIL_RING, "scalar rings"),
392 // Free-context FIFO and SHA dispatch admit one request per 2 cycles.
393 (2.0, "admission"),
394 ];
395 if !self.fused {
396 // Little's law: a compress-slot credit is held from admission
397 // until its batch retires. Hold time in cycles = fixed-base
398 // program latency on a 32-context barrel (one bundle every 32
399 // cycles) + compress program latency on a 64-context barrel +
400 // the fitted overhead.
401 let hold = (FB_BUNDLES * 32 + CP_BUNDLES * CP_CONTEXTS) as f64 + CREDIT_OVERHEAD;
402 // Credits = compress slots (64 x 32 = 2,048), capped by the sign
403 // contexts. Rate = credits / hold, so cycles per signature =
404 // hold / credits.
405 let credits = (CP_CONTEXTS * CP_BATCH).min(SIGN_CONTEXTS) as f64;
406 stages.push((hold / credits, "compress credits (latency x rate)"));
407 // One compress program serves a whole batch of CP_BATCH points.
408 stages.push((
409 CP_BUNDLES as f64 / CP_BATCH as f64 / UTIL_MACHINE,
410 "batch compression",
411 ));
412 }
413 // The slowest stage limits throughput.
414 stages
415 .into_iter()
416 .fold((0.0, "none"), |a, b| if b.0 > a.0 { b } else { a })
417 }
418
419 /// Compile-time profile string understood by `build.rs`:
420 /// `"fb,sha,sc,compress"` with compress 1 for a batch-compress machine and
421 /// 0 for fused mode.
422 pub fn env_value(&self) -> String {
423 format!(
424 "{},{},{},{}",
425 self.fb,
426 self.sha,
427 self.sc,
428 if self.fused { 0 } else { 1 }
429 )
430 }
431
432 /// Estimate resources under `max_dsp` DSPs with `table` block costs, and
433 /// project throughput at `mhz` MHz.
434 ///
435 /// DSPs go where they save the most fabric: multiply tiles first, kinds
436 /// in decreasing `lut_per_tile` (any tile without a DSP costs that many
437 /// LUTs), then wide adders in decreasing `lut_per_adder_dsp`. Within a
438 /// kind, instances are filled one after another, so a short budget
439 /// leaves whole later instances in fabric, as a `-max_dsp` cap does.
440 pub fn estimate(&self, table: &CostTable, max_dsp: u32, mhz: f64) -> Estimate {
441 // Number of compress machines: none in fused mode.
442 let cp = if self.fused { 0 } else { 1 };
443 // (per-instance cost, instance count) for every block kind.
444 let blocks: [(BlockCost, u32); 5] = [
445 (table.fb_machine, self.fb),
446 (table.cp_machine, cp),
447 (table.sha_ring, self.sha),
448 (table.scalar_ring, self.sc),
449 (table.controller, 1),
450 ];
451 // Controller growth per fixed-base machine (arbitration, masks).
452 let mut lut = table.controller_lut_per_fb * self.fb;
453 let mut ff = 0;
454 let mut bram = 0;
455 // LUTs of tiles that did not get a DSP.
456 let mut fabric_tile_lut = 0u64;
457 // Baseline totals with all tiles in DSPs and adders in fabric.
458 for (b, n) in blocks {
459 lut += b.lut * n;
460 ff += b.ff * n;
461 bram += b.bram36 * n;
462 }
463 // Tiles take DSPs first, the kinds that lose the most LUTs per tile
464 // first; a short budget leaves the least costly tiles in fabric.
465 let mut by_tile = blocks;
466 by_tile.sort_by(|a, b| b.0.lut_per_tile.cmp(&a.0.lut_per_tile));
467 let mut dsp_left = max_dsp;
468 let mut dsp = 0;
469 for (b, n) in by_tile {
470 for _ in 0..n {
471 let in_dsp = b.tiles.min(dsp_left);
472 dsp_left -= in_dsp;
473 dsp += in_dsp;
474 fabric_tile_lut += u64::from((b.tiles - in_dsp) * b.lut_per_tile);
475 }
476 }
477 lut += fabric_tile_lut as u32;
478 // Spare DSPs absorb wide adders, the largest savings per DSP first.
479 let mut by_adder = blocks;
480 by_adder.sort_by(|a, b| b.0.lut_per_adder_dsp.cmp(&a.0.lut_per_adder_dsp));
481 let mut adders_in_dsp = false;
482 for (b, n) in by_adder {
483 for _ in 0..n {
484 let moved = b.adder_dsps.min(dsp_left);
485 if moved > 0 {
486 adders_in_dsp = true;
487 }
488 dsp_left -= moved;
489 dsp += moved;
490 lut = lut.saturating_sub(moved * b.lut_per_adder_dsp);
491 }
492 }
493 let (cycles, bottleneck) = self.cycles_per_sign();
494 Estimate {
495 profile: *self,
496 lut,
497 ff,
498 dsp,
499 // The compress machine's BRAM is in the `cp_machine` row (count 0
500 // when fused).
501 bram36: bram,
502 max_dsp,
503 adders_in_dsp,
504 cycles_per_sign: cycles,
505 bottleneck,
506 signs_per_sec: mhz * 1e6 / cycles,
507 }
508 }
509}
510
511/// Planner request: resource allowances and the clock to project at.
512#[derive(Clone, Copy, Debug)]
513pub struct Budget {
514 /// LUT allowance, percent of [`LUT_CAP`] (0..=200).
515 pub lut_percent: f64,
516 /// DSP allowance, percent of [`DSP_CAP`] (0..=DEVICE_DSP/DSP_CAP).
517 pub dsp_percent: f64,
518 /// Assumed clock in MHz, for projection only (does not affect which
519 /// profiles fit).
520 pub mhz: f64,
521 /// Stop growing once this rate is reached, in signatures per second
522 /// (0 = maximize). Rates above it count as equal, so the smallest
523 /// profile reaching it wins.
524 pub target_sps: f64,
525}
526
527/// Conversion of the percentage sliders into absolute limits.
528impl Budget {
529 /// LUT limit in LUTs (rounded down).
530 pub fn luts(&self) -> u32 {
531 (LUT_CAP as f64 * self.lut_percent / 100.0).floor() as u32
532 }
533 /// DSP limit in DSPs (rounded down), never above the device.
534 pub fn dsps(&self) -> u32 {
535 ((DSP_CAP as f64 * self.dsp_percent / 100.0).floor() as u32).min(DEVICE_DSP)
536 }
537}
538
539/// Every profile the planner considers: 1..=40 fixed-base machines,
540/// 1..=24 SHA rings, 1..=6 scalar rings, with and without fusing (the same
541/// ranges as [`crate::frontier`]). The search is exhaustive, so these
542/// ranges bound what the planner can return; at 200 % of the LUT cap the
543/// best profile stays well inside them.
544pub fn candidates() -> Vec<Profile> {
545 let mut out = Vec::new();
546 for fb in 1..=40 {
547 for sha in 1..=24 {
548 for sc in 1..=6 {
549 for fused in [false, true] {
550 // Fusing only pays off while one compress machine would idle.
551 if fused && fb > 4 {
552 continue;
553 }
554 out.push(Profile { fb, sha, sc, fused });
555 }
556 }
557 }
558 }
559 out
560}
561
562/// Best profile for a budget: highest rate (capped at the target), then
563/// fewest LUTs, then fewest DSPs. `None` when nothing fits. A profile fits
564/// when its LUTs, DSPs and BRAM36 are within the budget and the device.
565pub fn plan(budget: &Budget, table: &CostTable) -> Option<Estimate> {
566 let luts = budget.luts();
567 let dsps = budget.dsps();
568 let mut best: Option<Estimate> = None;
569 for p in candidates() {
570 let e = p.estimate(table, dsps, budget.mhz);
571 if e.lut > luts || e.dsp > dsps || e.bram36 > DEVICE_BRAM36 {
572 continue;
573 }
574 // With a target, every rate at or above it compares equal, so the
575 // tie-break (fewer LUTs) picks the smallest profile that reaches it.
576 let capped = |x: f64| {
577 if budget.target_sps > 0.0 {
578 x.min(budget.target_sps)
579 } else {
580 x
581 }
582 };
583 let better = match &best {
584 None => true,
585 Some(b) => {
586 // Rates within one part per million are a tie (float noise),
587 // broken by fewer LUTs, then fewer DSPs.
588 let (r, rb) = (capped(e.signs_per_sec), capped(b.signs_per_sec));
589 r > rb * 1.000_001
590 || ((r - rb).abs() <= rb * 1e-6
591 && (e.lut < b.lut || (e.lut == b.lut && e.dsp < b.dsp)))
592 }
593 };
594 if better {
595 best = Some(e);
596 }
597 }
598 best
599}
600
601/// Smallest LUT percentage at which some profile fits under `dsp_percent`
602/// (the lower end of the LUT slider), counting only profiles whose BRAM
603/// fits the device. Infinity if no candidate exists.
604pub fn min_lut_percent(dsp_percent: f64, table: &CostTable) -> f64 {
605 // Reuse Budget's DSP rounding and device clamp; the LUT field is unused.
606 let dsps = Budget {
607 lut_percent: 0.0,
608 dsp_percent,
609 mhz: DESIGN_MHZ,
610 target_sps: 0.0,
611 }
612 .dsps();
613 candidates()
614 .iter()
615 .map(|p| p.estimate(table, dsps, DESIGN_MHZ))
616 .filter(|e| e.bram36 <= DEVICE_BRAM36)
617 .map(|e| e.lut)
618 .min()
619 .map(|l| 100.0 * l as f64 / LUT_CAP as f64)
620 .unwrap_or(f64::INFINITY)
621}
622
623/// DSP percentage above which no profile within 200 % LUTs uses more DSPs:
624/// the DSP count of the best plan with the largest LUT and DSP allowances.
625pub fn useful_dsp_percent(table: &CostTable) -> f64 {
626 // Maximum sliders: 200 % LUTs and the whole device's DSPs.
627 let budget = Budget {
628 lut_percent: 200.0,
629 dsp_percent: 100.0 * DEVICE_DSP as f64 / DSP_CAP as f64,
630 mhz: DESIGN_MHZ,
631 target_sps: 0.0,
632 };
633 plan(&budget, table)
634 .map(|e| 100.0 * e.dsp as f64 / DSP_CAP as f64)
635 .unwrap_or(0.0)
636}
637
638/// Human-readable report of a plan: profile, budget, resource estimate with
639/// its source, cycle model and the synthesis settings to use.
640pub fn describe(e: &Estimate, budget: &Budget, table: &CostTable) -> String {
641 let mut s = String::new();
642 let p = e.profile;
643 let _ = writeln!(
644 s,
645 "profile: {} fixed-base machine(s), {} SHA-512 ring(s), {} scalar ring(s), {}",
646 p.fb,
647 p.sha,
648 p.sc,
649 if p.fused {
650 "fused compression"
651 } else {
652 "one batch-compress machine"
653 }
654 );
655 let _ = writeln!(
656 s,
657 "budget: {} LUT ({:.0}% of {}), {} DSP ({:.0}% of {})",
658 budget.luts(),
659 budget.lut_percent,
660 LUT_CAP,
661 budget.dsps(),
662 budget.dsp_percent,
663 DSP_CAP
664 );
665 let _ = writeln!(
666 s,
667 "estimate: {} LUT, {} FF, {} DSP, {} BRAM36 [{}]",
668 e.lut, e.ff, e.dsp, e.bram36, table.source
669 );
670 let _ = writeln!(
671 s,
672 "model: {:.2} cycles/signature (limited by {}), {:.3} M signatures/s at an ASSUMED {} MHz",
673 e.cycles_per_sign,
674 e.bottleneck,
675 e.signs_per_sec / 1e6,
676 budget.mhz
677 );
678 let _ = writeln!(
679 s,
680 "synthesis: -max_dsp {}{}",
681 e.max_dsp,
682 if e.adders_in_dsp {
683 ", USE_DSP=yes on adder-heavy hierarchies"
684 } else {
685 ""
686 }
687 );
688 s
689}
690
691/// JSON record of a plan (`schema` `scalable-plan-1`). `status` is always
692/// `estimate-unmeasured`; measured counts come from the `measured.json` that
693/// [`synth_tcl`] writes.
694pub fn plan_json(e: &Estimate, b: &Budget) -> String {
695 let p = e.profile;
696 format!(
697 r#"{{
698 "schema": "scalable-plan-1",
699 "status": "estimate-unmeasured",
700 "budget": {{"lut_percent": {}, "dsp_percent": {}, "lut_limit": {}, "dsp_limit": {}, "assumed_mhz": {}, "target_sps": {}}},
701 "profile": {{"fixed_base_machines": {}, "sha_rings": {}, "scalar_rings": {}, "compress_machine": {}}},
702 "env": "ED25519_SCALABLE_PROFILE={}",
703 "estimate": {{"lut": {}, "ff": {}, "dsp": {}, "bram36": {}, "cycles_per_sign": {:.3}, "bottleneck": "{}", "projected_sps": {:.0}}},
704 "synthesis": {{"max_dsp": {}, "adders_in_dsp": {}}}
705}}
706"#,
707 b.lut_percent,
708 b.dsp_percent,
709 b.luts(),
710 b.dsps(),
711 b.mhz,
712 b.target_sps,
713 p.fb,
714 p.sha,
715 p.sc,
716 !p.fused,
717 p.env_value(),
718 e.lut,
719 e.ff,
720 e.dsp,
721 e.bram36,
722 e.cycles_per_sign,
723 e.bottleneck,
724 e.signs_per_sec,
725 e.max_dsp,
726 e.adders_in_dsp
727 )
728}
729
730/// Vivado out-of-context synthesis gate enforcing the plan's budget.
731///
732/// The script synthesizes the exported Verilog with `-max_dsp`, writes the
733/// utilization reports and `measured.json`, and fails if the measured LUTs
734/// or DSPs exceed the budget.
735pub fn synth_tcl(e: &Estimate, b: &Budget) -> String {
736 let p = e.profile;
737 // Top module name used by `export::selected_signer_hdl`.
738 let top = "scalable_sign_core";
739 // File stem matching what `export_scalable` writes for this profile.
740 let stem = format!(
741 "scalable_sign_core_{}_{}_{}_{}",
742 p.fb,
743 p.sha,
744 p.sc,
745 u8::from(!p.fused)
746 );
747 // Only emit USE_DSP properties when the estimate assigned adder DSPs.
748 let use_dsp = if e.adders_in_dsp {
749 // Wide adders of the SHA rings and field add/sub units into DSP48E2s.
750 "set_property USE_DSP yes [get_cells -quiet -hierarchical -filter {NAME =~ *sha*}]\n\
751 set_property USE_DSP yes [get_cells -quiet -hierarchical -filter {NAME =~ *add*}]\n"
752 } else {
753 ""
754 };
755 format!(
756 r#"# Generated by ed25519_plan. Hardware comes exclusively from RHDL.
757# vivado -mode batch -source synth.tcl -tclargs /abs/path/generated/{stem}.v
758set_param general.maxThreads 4
759if {{$argc != 1}} {{ error "expected the generated {stem}.v" }}
760read_verilog [lindex $argv 0]
761read_xdc constraints.xdc
762synth_design -rtl -top {top} -part xcu280-fsvh2892-2L-e -mode out_of_context
763{use_dsp}synth_design -top {top} -part xcu280-fsvh2892-2L-e -mode out_of_context -max_dsp {dsp}
764report_utilization -file utilization.rpt
765report_timing_summary -file synthesis_timing.rpt
766set u [report_utilization -return_string]
767if {{![regexp -line {{^\|\s*(CLB|Slice) LUTs\*?\s*\|\s*([0-9]+)\s*\|}} $u _ _ luts]}} {{ error "cannot parse LUTs" }}
768if {{![regexp -line {{^\|\s*(CLB|Slice) Registers\s*\|\s*([0-9]+)\s*\|}} $u _ _ ffs]}} {{ error "cannot parse registers" }}
769set dsps [llength [get_cells -hier -filter {{REF_NAME =~ DSP*}}]]
770set fd [open measured.json w]
771puts $fd [format {{{{"profile":"{env}","dsp_limit":{dsp},"requested_mhz":{mhz},"luts":%d,"ffs":%d,"dsps":%d,"evidence":"Vivado OOC synthesis utilization.rpt"}}}} $luts $ffs $dsps]
772close $fd
773if {{$luts > {lut} || $dsps > {dsp}}} {{ error "budget exceeded: $luts LUT, $dsps DSP" }}
774write_checkpoint -force synthesized.dcp
775"#,
776 stem = stem,
777 top = top,
778 use_dsp = use_dsp,
779 dsp = e.max_dsp,
780 lut = b.luts(),
781 mhz = b.mhz,
782 env = p.env_value()
783 )
784}
785
786/// Write `plan.json`, `plan.txt`, `synth.tcl` and `constraints.xdc` to `dir`.
787/// Nothing is launched; the files are inputs for a reserved synthesis host.
788/// The XDC constrains the clock port to the budget's assumed clock (period
789/// in ns = 1000 / MHz).
790pub fn write_plan(
791 dir: &std::path::Path,
792 e: &Estimate,
793 b: &Budget,
794 table: &CostTable,
795) -> std::io::Result<()> {
796 std::fs::create_dir_all(dir)?;
797 std::fs::write(dir.join("plan.json"), plan_json(e, b))?;
798 std::fs::write(dir.join("plan.txt"), describe(e, b, table))?;
799 std::fs::write(dir.join("synth.tcl"), synth_tcl(e, b))?;
800 std::fs::write(
801 dir.join("constraints.xdc"),
802 format!(
803 "create_clock -name core -period {:.6} [get_ports {{clock_reset[0]}}]\n",
804 1000.0 / b.mhz
805 ),
806 )
807}
808
809/// Unit tests of the planner model. They use [`structural`] costs so they do
810/// not change when mapping calibrations change.
811#[cfg(test)]
812mod tests {
813 use super::*;
814
815 /// The project goal is reachable on paper: at 100 % LUT and DSP and the
816 /// design clock, some profile reaches 7 M signatures/s within the caps.
817 #[test]
818 fn seven_million_profile_fits_project_caps() {
819 let table = structural();
820 let budget = Budget {
821 lut_percent: 100.0,
822 dsp_percent: 100.0,
823 mhz: DESIGN_MHZ,
824 target_sps: 7.0e6,
825 };
826 let e = plan(&budget, &table).expect("a 7M/s profile within the caps");
827 println!("{}", describe(&e, &budget, &table));
828 assert!(e.signs_per_sec >= 7.0e6);
829 assert!(e.lut <= LUT_CAP && e.dsp <= DSP_CAP);
830 }
831
832 /// With no DSPs at all some profile still fits within 200 % LUTs, and the
833 /// plan uses zero DSPs and the fused (smallest) layout. This keeps the
834 /// DSP slider's lower end meaningful.
835 #[test]
836 fn zero_dsp_still_has_a_functional_profile() {
837 let table = structural();
838 let min = min_lut_percent(0.0, &table);
839 assert!(min.is_finite() && min <= 200.0, "min LUT% at 0 DSP: {min}");
840 let budget = Budget {
841 lut_percent: min.ceil(),
842 dsp_percent: 0.0,
843 mhz: DESIGN_MHZ,
844 target_sps: 0.0,
845 };
846 let e = plan(&budget, &table).unwrap();
847 assert_eq!(e.dsp, 0);
848 assert!(e.profile.fused);
849 }
850
851 /// Raising the LUT allowance (20 % to 200 % in 10 % steps) never lowers
852 /// the planned rate. The planner searches exhaustively, so a larger
853 /// budget is a superset; a decrease would mean a broken comparison.
854 #[test]
855 fn more_lut_never_lowers_throughput() {
856 let table = structural();
857 let mut last = 0.0;
858 for lut in (20..=200).step_by(10) {
859 let b = Budget {
860 lut_percent: lut as f64,
861 dsp_percent: 100.0,
862 mhz: DESIGN_MHZ,
863 target_sps: 0.0,
864 };
865 if let Some(e) = plan(&b, &table) {
866 assert!(e.signs_per_sec >= last);
867 last = e.signs_per_sec;
868 }
869 }
870 }
871}