Skip to main content

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}