Skip to main content

rhdl_ed25519_scalable_core/frontier/
system.rs

1//! The system planner: split a [`SystemBudget`] into two [`Share`]s and pick
2//! the fastest sign and verify builds inside each ([`plan_sign_with`],
3//! [`plan_verify_with`], [`plan_system`]).
4
5use super::*;
6
7/// Bundles of the signing fixed-base program (for reports).
8pub const SIGN_BUNDLES: u32 = FB_BUNDLES;
9
10/// LUT budget that the 100 % setting means.
11pub const BASE_LUT: u32 = 420_000;
12/// DSP budget that the 100 % setting means.
13pub const BASE_DSP: u32 = 4_500;
14/// Physical CLB LUTs of the XCU280 (the hard ceiling for any LUT share).
15pub const U280_LUT: u32 = 1_303_680;
16/// Physical DSP48E2 blocks of the XCU280.
17pub const U280_DSP: u32 = 9_024;
18/// Physical BRAM36 blocks of the XCU280.
19pub const U280_BRAM36: u32 = 2_016;
20/// Physical URAM blocks of the XCU280.
21pub const U280_URAM: u32 = 960;
22
23/// The controls of the system planner: two budget sliders, two power
24/// sliders, plus the verification mode and the clock.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct SystemBudget {
27    /// LUTs as a percentage of [`BASE_LUT`] (0..200).
28    pub lut_percent: f64,
29    /// DSPs as a percentage of [`BASE_DSP`] (0..200).
30    pub dsp_percent: f64,
31    /// Share of the budget given to signing (0..100).
32    pub sign_power: f64,
33    /// Share of the budget given to verification (0..100; at most
34    /// `100 - sign_power`).
35    pub verify_power: f64,
36    /// Verification mode.
37    pub mode: VerifyMode,
38    /// Clock in MHz.
39    pub mhz: f64,
40}
41
42impl Default for SystemBudget {
43    /// 100 % LUT and DSP budgets, an even 50/50 split, known-key verification
44    /// and the 205 MHz design clock.
45    fn default() -> Self {
46        SystemBudget { lut_percent: 100.0, dsp_percent: 100.0, sign_power: 50.0, verify_power: 50.0, mode: VerifyMode::KnownKey, mhz: 205.0 }
47    }
48}
49
50/// A module's share of every resource: the limits one module (sign or
51/// verify) must fit inside.
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct Share {
54    /// LUTs.
55    pub lut: u32,
56    /// DSPs.
57    pub dsp: u32,
58    /// BRAM36 (the same fraction of the device).
59    pub bram36: u32,
60    /// URAM (the same fraction of the device).
61    pub uram: u32,
62}
63
64impl SystemBudget {
65    /// Total LUT budget.
66    pub fn luts(&self) -> u32 {
67        (BASE_LUT as f64 * self.lut_percent / 100.0) as u32
68    }
69    /// Total DSP budget, never above the device.
70    pub fn dsps(&self) -> u32 {
71        ((BASE_DSP as f64 * self.dsp_percent / 100.0) as u32).min(U280_DSP)
72    }
73    /// The share of a module that gets `power` percent. LUT and DSP are that
74    /// fraction of the budget; BRAM36 and URAM, which have no budget slider,
75    /// are that fraction of the whole device. Because the two powers sum to
76    /// at most 100, the two shares never exceed the device together.
77    pub fn share(&self, power: f64) -> Share {
78        // Fraction 0..1 of every resource.
79        let f = power.clamp(0.0, 100.0) / 100.0;
80        Share {
81            lut: (self.luts().min(U280_LUT) as f64 * f) as u32,
82            dsp: (self.dsps() as f64 * f) as u32,
83            bram36: (U280_BRAM36 as f64 * f) as u32,
84            uram: (U280_URAM as f64 * f) as u32,
85        }
86    }
87}
88
89/// One planned module.
90#[derive(Clone, Copy, Debug)]
91pub struct ModulePlan<P> {
92    /// The build ([`Profile`] or [`VerifyProfile`]).
93    pub profile: P,
94    /// Operations per second.
95    pub rate: f64,
96    /// Resources.
97    pub cost: Cost,
98    /// The share it had to fit.
99    pub share: Share,
100    /// Which share limit stops the next larger build: the resource with the
101    /// highest cost/share ratio (see `limit_of`).
102    pub limit: &'static str,
103    /// Multiplier the module's field machines use.
104    pub multiplier: Multiplier,
105}
106
107/// The planned system.
108#[derive(Clone, Copy, Debug)]
109pub struct SystemPlan {
110    /// Signing module, if anything fits its share.
111    pub sign: Option<ModulePlan<Profile>>,
112    /// Verify module, if anything fits its share.
113    pub verify: Option<ModulePlan<VerifyProfile>>,
114    /// Both together.
115    pub total: Cost,
116}
117
118/// Name of the resource that `cost` uses the largest fraction of in `share`
119/// ("LUT", "DSP", "BRAM", "URAM", or "none" if all are zero). A zero share
120/// limit is treated as 1 to avoid dividing by zero.
121pub(super) fn limit_of(cost: Cost, share: &Share) -> &'static str {
122    // Fill ratio of each resource.
123    let ratios = [
124        (cost.lut as f64 / share.lut.max(1) as f64, "LUT"),
125        (cost.dsp as f64 / share.dsp.max(1) as f64, "DSP"),
126        (cost.bram36 as f64 / share.bram36.max(1) as f64, "BRAM"),
127        (cost.uram as f64 / share.uram.max(1) as f64, "URAM"),
128    ];
129    // Largest ratio wins; on a tie the earlier entry is kept.
130    ratios.iter().fold((0.0, "none"), |a, b| if b.0 > a.0 { *b } else { a }).1
131}
132
133/// Whether every resource of `cost` is within `share`.
134pub(super) fn fits(cost: &Cost, share: &Share) -> bool {
135    cost.lut <= share.lut && cost.dsp <= share.dsp && cost.bram36 <= share.bram36 && cost.uram <= share.uram
136}
137
138/// Cycles per signature of the fused small signer with 32 requests in
139/// flight (native simulation).
140pub const SMALL_SIGNER_CYCLES: f64 = 895.0;
141
142/// LUTs of the port from a module to the shared SHA-512 pipeline: request
143/// mux, tag, and the credit-counted result FIFO (estimate until built).
144pub const SHA_CLIENT_LUT: u32 = 1_500;
145/// The shared SHA-512 pipeline is modeled as never limiting a module's own
146/// stages (the planner checks total demand against its supply instead).
147/// Plugging this many rings into the cycle model makes the SHA term about
148/// 0.27 cycles for signing, below the 2-cycle admission floor.
149pub(super) const SHARED_SHA_RINGS: u32 = 1_000;
150
151/// Best signing module within a share, with its own SHA rings.
152pub fn plan_sign(share: Share, costs: &Costs, mhz: f64) -> Option<ModulePlan<Profile>> {
153    plan_sign_with(share, costs, mhz, false)
154}
155
156/// Best signing module within a share; with `shared_sha` the module has no
157/// SHA rings and sends its three blocks per signature to the shared
158/// SHA-512 pipeline (a client port instead of rings, `sha = 0` in the
159/// profile).
160///
161/// "Best" is the highest rate; builds within 1 signature/s of the best are
162/// treated as equal and the one with fewer LUTs wins. Both multipliers are
163/// tried when the cost table offers the lean one. `None` if nothing fits.
164pub fn plan_sign_with(share: Share, costs: &Costs, mhz: f64, shared_sha: bool) -> Option<ModulePlan<Profile>> {
165    plan_sign_shaped(share, costs, mhz, shared_sha, &Shape::default())
166}
167
168/// [`plan_sign_with`] restricted to builds of `shape` (ratio, batch
169/// compression). The fused small signer counts as a build without batch
170/// compression and is skipped under a manual ratio.
171pub fn plan_sign_shaped(share: Share, costs: &Costs, mhz: f64, shared_sha: bool, shape: &Shape) -> Option<ModulePlan<Profile>> {
172    let mut best: Option<ModulePlan<Profile>> = None;
173    // Shared-SHA builds have no rings of their own: only sha = 0 is tried.
174    let shas = if shared_sha { 0..=0 } else { 1..=24 };
175    for multiplier in [Multiplier::Dsp225, Multiplier::Lean150] {
176    // Skip the lean multiplier when this cost table has no figures for it.
177    let Some(c) = with_mul(costs, multiplier) else { continue };
178    let c = &c;
179    // The fused small signer, as measured: one block with its own SHA ring,
180    // 895 cycles per signature with 32 in flight. It wins whenever the share
181    // is too small for anything else, or the needed rate is low (DSig).
182    if multiplier == Multiplier::Dsp225 && shape.ratio.is_none() && shape.compress.allows(false) && fits(&c.small_signer, &share) {
183        let rate = mhz * 1e6 / SMALL_SIGNER_CYCLES;
184        let p = Profile { fb: 1, sha: 1, sc: 1, fused: true };
185        if best.is_none_or(|b| rate > b.rate + 1.0) {
186            best = Some(ModulePlan { profile: p, rate, cost: c.small_signer, share, limit: limit_of(c.small_signer, &share), multiplier });
187        }
188    }
189    // Search ranges: up to 40 fixed-base machines, 24 SHA rings, 6 scalar
190    // rings (the same as `config::candidates`).
191    for fused in [false, true] {
192    if !shape.compress.allows(!fused) {
193        continue;
194    }
195    for fb in 1..=40 {
196        // Every layout of a machine has the same DSPs and at least the
197        // UltraRAM layout's LUTs: no larger build can fit either.
198        if fb * c.fb_machine.dsp > share.dsp || fb * c.fb_machine.lut.min(c.bram_layout.map_or(u32::MAX, |a| a.fb_machine.lut)) > share.lut {
199            break;
200        }
201        for sha in shas.clone() {
202            for sc in 1..=6 {
203                if !shape.keeps(fb, sha, sc, shared_sha) {
204                    continue;
205                }
206                let p = Profile { fb, sha, sc, fused };
207                // The client port replaces the rings in the cost.
208                let port = if shared_sha { SHA_CLIENT_LUT } else { 0 };
209                let cost = sign_cost_within(&p, c, &share, port);
210                if !fits(&cost, &share) {
211                    continue;
212                }
213                // The profile used only for the rate: in shared mode the SHA
214                // stage is made non-limiting (sha = 0 would divide by zero).
215                let model = Profile { sha: if shared_sha { SHARED_SHA_RINGS } else { sha }, ..p };
216                let rate = mhz * 1e6 / model.cycles_per_sign().0;
217                // Faster by more than 1/s, or equally fast (within 1/s) with
218                // fewer LUTs.
219                if best.is_none_or(|b| rate > b.rate + 1.0 || (rate > b.rate - 1.0 && cost.lut < b.cost.lut)) {
220                    best = Some(ModulePlan { profile: p, rate, cost, share, limit: limit_of(cost, &share), multiplier });
221                }
222            }
223        }
224    }
225    }
226    }
227    best
228}
229
230/// Best verify module within a share, with its own SHA rings.
231pub fn plan_verify(share: Share, mode: VerifyMode, costs: &Costs, mhz: f64) -> Option<ModulePlan<VerifyProfile>> {
232    plan_verify_with(share, mode, costs, mhz, false)
233}
234
235/// Best verify module within a share; `shared_sha` as in [`plan_sign_with`]
236/// (two blocks per verification). Same selection rule as
237/// [`plan_sign_with`]; `None` if nothing fits.
238pub fn plan_verify_with(share: Share, mode: VerifyMode, costs: &Costs, mhz: f64, shared_sha: bool) -> Option<ModulePlan<VerifyProfile>> {
239    plan_verify_shaped(share, mode, costs, mhz, shared_sha, &Shape::default())
240}
241
242/// [`plan_verify_with`] restricted to builds of `shape`; without batch
243/// compression the profile has `cp = 0`.
244pub fn plan_verify_shaped(
245    share: Share,
246    mode: VerifyMode,
247    costs: &Costs,
248    mhz: f64,
249    shared_sha: bool,
250    shape: &Shape,
251) -> Option<ModulePlan<VerifyProfile>> {
252    let mut best: Option<ModulePlan<VerifyProfile>> = None;
253    // Shared-SHA builds have no rings of their own: only sha = 0 is tried.
254    let shas = if shared_sha { 0..=0 } else { 1..=16 };
255    for multiplier in [Multiplier::Dsp225, Multiplier::Lean150] {
256    // Skip the lean multiplier when this cost table has no figures for it.
257    let Some(c) = with_mul(costs, multiplier) else { continue };
258    let c = &c;
259    // Search ranges: up to 64 machines, 16 SHA rings, 4 scalar rings and
260    // 0 to 2 compress machines. Known-key verification is built only with
261    // exactly one (`KnownKeyVerifyCore` always has one compress stage), so
262    // it searches cp = 1 alone. Why: offering cp = 0 planned an unbuildable
263    // 1,0,1,0 verifier for the DSig paper, costed at 63k LUT against the
264    // 82,184 routed for the buildable 1,1,1,1 (job 007zzi, 2026-10-19).
265    let cps = if matches!(mode, VerifyMode::KnownKey) { 1..=1 } else { 0..=2 };
266    for machines in 1..=64 {
267        // As for signing: DSPs and the cheapest layout's LUTs bound the size.
268        let m = mode.machine(c);
269        let alt = c.bram_layout.map_or(u32::MAX, |a| match mode {
270            VerifyMode::Strict | VerifyMode::Batch => a.verify_machine_strict.lut,
271            VerifyMode::KnownKey => a.verify_machine_known_key.lut,
272        });
273        if machines * m.dsp > share.dsp || machines * m.lut.min(alt) > share.lut {
274            break;
275        }
276        for sha in shas.clone() {
277            for sc in 1..=4 {
278                for cp in cps.clone() {
279                    if !shape.compress.allows(cp > 0) || !shape.keeps(machines, sha, sc, shared_sha) {
280                        continue;
281                    }
282                    let v = VerifyProfile { machines, sha, sc, cp };
283                    // The client port replaces the rings in the cost.
284                    let port = if shared_sha { SHA_CLIENT_LUT } else { 0 };
285                    let cost = verify_cost_within(&v, mode, c, &share, port);
286                    if !fits(&cost, &share) {
287                        continue;
288                    }
289                    // Rate model with a non-limiting SHA stage in shared mode.
290                    let model = VerifyProfile { sha: if shared_sha { SHARED_SHA_RINGS } else { sha }, ..v };
291                    let rate = mhz * 1e6 / model.cycles(mode);
292                    // Faster by more than 1/s, or equally fast with fewer LUTs.
293                    if best.is_none_or(|b| rate > b.rate + 1.0 || (rate > b.rate - 1.0 && cost.lut < b.cost.lut)) {
294                        best = Some(ModulePlan { profile: v, rate, cost, share, limit: limit_of(cost, &share), multiplier });
295                    }
296                }
297            }
298        }
299    }
300    }
301    best
302}
303
304/// Plan both modules for the budget controls. Each module is planned
305/// independently inside its own share (no SHA sharing); a module with zero
306/// power is left out.
307pub fn plan_system(b: &SystemBudget, c: &Costs) -> SystemPlan {
308    let sign_power = b.sign_power.clamp(0.0, 100.0);
309    // Verification gets at most what signing left over, so the two shares
310    // never sum to more than 100 %.
311    let verify_power = b.verify_power.clamp(0.0, 100.0 - sign_power);
312    let sign = if sign_power > 0.0 { plan_sign(b.share(sign_power), c, b.mhz) } else { None };
313    let verify = if verify_power > 0.0 { plan_verify(b.share(verify_power), b.mode, c, b.mhz) } else { None };
314    // Sum of the planned modules' resources.
315    let mut total = Cost::default();
316    if let Some(s) = &sign {
317        total = total.add(s.cost, 1);
318    }
319    if let Some(v) = &verify {
320        total = total.add(v.cost, 1);
321    }
322    SystemPlan { sign, verify, total }
323}