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}