Skip to main content

rhdl_ed25519_scalable_core/
planner.rs

1//! Ed25519 components for the chip planner (`rhdl_fpga_planner`).
2//!
3//! [`Ed25519Sign`] and [`Ed25519Verify`] wrap [`crate::frontier`]'s
4//! sizing so the Ed25519 modules sit next to DSig and the SHA-512 line in
5//! one TUI, each with its own power and knobs:
6//!
7//! | Knob | Values |
8//! |---|---|
9//! | Modules | specialized (routed fix9 blocks, preferred memory layouts) / as built (2023.2 synthesis of 15/11/3/1) |
10//! | SHA-512 | shared line / own rings |
11//! | Verify mode (verify only) | known-key / strict / batch |
12//! | Block ratio | auto (best mix) / manual: keep the three weights below |
13//! | FB (or verify) machine, SHA ring, scalar ring weight | 1–32 each; the ratio in manual mode |
14//! | Batch compress | auto / on (a compress machine) / off (each machine inverts and encodes; model) |
15//!
16//! **Memory placement** (specialized table): every block starts in its
17//! preferred layout (machine banks in UltraRAM). When the share's URAM runs
18//! out, replicated machines move one at a time to their block-RAM layout
19//! ([`MachineMemory::BRAM_BANKS`](crate::machine::MachineMemory::BRAM_BANKS)),
20//! the one fallback the RTL builds; the plan detail says how many ("3 of 9
21//! machines in block RAM") and the `uram_machines` flag builds exactly
22//! that (`ED25519_URAM_MACHINES`). Nothing else moves: when block RAM runs
23//! out too, the plan holds fewer machines.
24//!
25//! **Cores.** A module is 1 to [`MAX_CORES`] identical cores, each in an
26//! equal part of the share, because one core's credit pool caps it.
27//!
28//! With the shared line a module has no SHA rings: it pays a client port
29//! ([`crate::frontier::SHA_CLIENT_LUT`]) and demands `"sha512 blocks"`
30//! (three per signature, two per verification), which the planner checks
31//! against the line's supply.
32//!
33//! [`Sha512Estimate`] stands in for the SHA-512 line component of
34//! `rhdl_sha512_pipelined` (DSig's) in builds without it: an allowance taken
35//! out of the budget (the SHA part of the LUT split), up to 432k LUTs and 20
36//! DSPs, whose throughput is modeled from its LUTs.
37
38use rhdl_fpga_planner::{BuildFlags, Component, Context, Cost, Flow, Knob, Plan, Role, SHA512_BLOCKS, Share};
39
40use crate::{
41    config::{CP_BUNDLES, CP_CONTEXTS, CREDIT_OVERHEAD, FB_BUNDLES, SIGN_CONTEXTS},
42    frontier::{self, Compress, Costs, Multiplier, Shape, VerifyMode},
43};
44
45/// Labels of the "batch compress" knob, in [`COMPRESS`] order.
46const COMPRESS_LABELS: [&str; 3] = ["auto", "on (compress machine)", "off (each machine encodes; model)"];
47/// The batch-compress choices.
48const COMPRESS: [Compress; 3] = [Compress::Auto, Compress::On, Compress::Off];
49
50/// The shape controls every Ed25519 module has: a ratio mode, three
51/// weights and batch compression.
52#[derive(Clone, Copy, Debug, PartialEq)]
53pub struct ShapeKnobs {
54    /// Keep the weights below (`false`: the planner chooses the mix).
55    pub manual: bool,
56    /// Field machines : SHA-512 rings : scalar rings.
57    pub weights: [f64; 3],
58    /// Batch compression.
59    pub compress: Compress,
60}
61
62impl ShapeKnobs {
63    /// Auto with weights `weights` ready for manual mode.
64    const fn auto(weights: [f64; 3]) -> Self {
65        ShapeKnobs { manual: false, weights, compress: Compress::Auto }
66    }
67    /// The knobs, after the module's own; `machine` names the field
68    /// machines ("FB machines" or "verify machines").
69    fn knobs(&self, machine: &str) -> Vec<Knob> {
70        vec![
71            Knob::choice("block ratio", usize::from(self.manual), &["auto (best mix)", "manual (weights below)"]),
72            Knob::range(&format!("  {machine} weight"), self.weights[0], 1.0, 32.0, 1.0),
73            Knob::range("  SHA ring weight", self.weights[1], 1.0, 32.0, 1.0),
74            Knob::range("  scalar ring weight", self.weights[2], 1.0, 32.0, 1.0),
75            Knob::choice("batch compress", COMPRESS.iter().position(|c| *c == self.compress).unwrap_or(0), &COMPRESS_LABELS),
76        ]
77    }
78    /// Set shape knob `index` (0 ratio mode .. 4 compress).
79    fn set(&mut self, index: usize, value: f64) {
80        match index {
81            0 => self.manual = value >= 1.0,
82            1..=3 => self.weights[index - 1] = value.clamp(1.0, 32.0),
83            _ => self.compress = COMPRESS[(value.max(0.0) as usize).min(2)],
84        }
85    }
86    /// The search restriction.
87    fn shape(&self) -> Shape {
88        Shape { ratio: self.manual.then_some(self.weights), compress: self.compress }
89    }
90    /// Short text for a plan detail, empty in full auto.
91    fn note(&self) -> String {
92        let mut n = String::new();
93        if self.manual {
94            let [a, b, c] = self.weights;
95            n.push_str(&format!(", ratio {a}:{b}:{c}"));
96        }
97        if self.compress != Compress::Auto {
98            n.push_str(if self.compress == Compress::On { ", batch compress on" } else { ", batch compress off" });
99        }
100        n
101    }
102}
103
104/// Most identical cores one module may be built from.
105///
106/// *Why:* one core has one controller and one credit pool: a signing core
107/// stops at 8.82M signatures/s however many field machines it gets (2,048
108/// compress credits, each held ~47.6k cycles: 23.25 cycles per signature). Beyond that, the only
109/// way to turn more LUTs and DSPs into rate is a second core beside it.
110pub const MAX_CORES: u32 = 4;
111
112/// `share` divided evenly between `n` cores.
113fn per_core(s: &Share, n: u32) -> frontier::Share {
114    frontier::Share { lut: s.lut / n, dsp: s.dsp / n, bram36: s.bram36 / n, uram: s.uram / n }
115}
116
117/// The best of `n = 1..=MAX_CORES` identical cores, each planned by `plan`
118/// in `1/n` of the share: `(n, the core's plan)`. Highest total rate wins;
119/// within 1 operation/s the cheaper build in LUTs.
120fn best_cores<P: Copy>(
121    share: &Share,
122    plan: impl Fn(frontier::Share) -> Option<frontier::ModulePlan<P>>,
123) -> Option<(u32, frontier::ModulePlan<P>)> {
124    let mut best: Option<(u32, frontier::ModulePlan<P>)> = None;
125    for n in 1..=MAX_CORES {
126        let Some(p) = plan(per_core(share, n)) else { continue };
127        let (rate, lut) = (n as f64 * p.rate, n * p.cost.lut);
128        let better = best.as_ref().is_none_or(|(m, b)| {
129            let (r, l) = (*m as f64 * b.rate, m * b.cost.lut);
130            rate > r + 1.0 || (rate > r - 1.0 && lut < l)
131        });
132        if better {
133            best = Some((n, p));
134        }
135    }
136    best
137}
138
139/// `c` times `n`.
140fn times(c: frontier::Cost, n: u32) -> Cost {
141    Cost { lut: c.lut * n, dsp: c.dsp * n, bram36: c.bram36 * n, uram: c.uram * n }
142}
143
144/// Measured single-request latency of the small signer in cycles.
145pub const SMALL_SIGNER_LATENCY: f64 = 22_240.0;
146
147/// Idle latency of one signature in cycles (model): the nonce block, a
148/// scalar job, the fixed-base walk on a 32-context barrel (one bundle per
149/// 32 cycles), the compress machine's flush wait and its batch program on a
150/// 64-context barrel, the two challenge blocks and two scalar jobs. With
151/// `own_sha` the module's SHA rings take ~81 cycles per block; on the shared
152/// line each block takes [`LINE_BLOCK_CYCLES`] (dispatch, the line, the
153/// return). Measured isolated (fix13, 8,0,2,1 on the line): 40,446 cycles
154/// against 40,406 from this model. The
155/// fused small core measured 22,240 cycles for one request (it has no
156/// compress machine, so this batch-compress model is not directly
157/// comparable).
158pub fn sign_latency_cycles(own_sha: bool) -> f64 {
159    // Three SHA-512 blocks (one nonce, two challenge) at ~81 cycles each on
160    // an idle ring, or one shared-line round trip each.
161    let sha = 3.0 * if own_sha { 81.0 } else { LINE_BLOCK_CYCLES };
162    // Fixed-base program: one bundle issues every 32 cycles per context, so
163    // one request's walk takes bundles x 32 cycles.
164    let fb = FB_BUNDLES as f64 * 32.0;
165    let compress = compress_cycles();
166    // Three scalar jobs at 32 cycles each.
167    sha + 3.0 * 32.0 + fb + compress
168}
169
170/// Idle latency of one verification in cycles (model): the two challenge
171/// blocks, one scalar job, the verify program on a 32-context barrel, and
172/// the compress machine as for signing. Measured isolated (fix13, 6,0,1,1
173/// on the line): 52,350 cycles against 52,392 from this model.
174pub fn verify_latency_cycles(mode: VerifyMode, own_sha: bool) -> f64 {
175    // Two challenge blocks at ~81 cycles each on the module's own ring, or
176    // one shared-line round trip each.
177    let sha = 2.0 * if own_sha { 81.0 } else { LINE_BLOCK_CYCLES };
178    // The mode's verify program at one bundle every 32 cycles.
179    let machine = mode.bundles() * 32.0;
180    // Same flush wait and batch program as for signing.
181    let compress = compress_cycles();
182    // One 32-cycle scalar job. The final 2 cycles are a fixed extra term
183    // that the model text above does not account for.
184    // The final + 2: reading the stored R and comparing it with the encoding.
185    sha + 32.0 + machine + compress + 2.0
186}
187
188/// One SHA-512 block's round trip on the shared line for an idle core, in
189/// cycles: the 82-cycle line plus dispatch and return. Fitted to the
190/// isolated native runs of 2026-10-19 (signer 8,0,2,1: 224 per block over
191/// the model without the line; verifier 6,0,1,1: 190).
192pub const LINE_BLOCK_CYCLES: f64 = 210.0;
193
194/// Cycles a point spends in the compress machine (model): up to 64 cycles
195/// of flush wait for a partial batch, then the batch program at one bundle
196/// every 64 cycles (64-context barrel).
197fn compress_cycles() -> f64 {
198    64.0 + CP_BUNDLES as f64 * CP_CONTEXTS as f64
199}
200
201/// Requests one core holds in flight at full load.
202///
203/// A request is admitted only when a field machine has a free context (32
204/// per machine) and a compress credit is left (2,048). The barrel runs
205/// each context at a fixed cadence whatever the load, so a machine-bound
206/// core holds its `contexts` plus the requests that have left the machines
207/// and not yet finished: `after_machine` cycles of work at one request per
208/// `cycles_per_op`. Past the credit pool the pool is the limit.
209///
210/// Measured (native, fix12/fix13): verify 6,0,1,1 holds ~404 (median
211/// saturated latency 54,533 cycles at 134.98 cycles/verification) and this
212/// gives 393; verify 1,1,1,1 holds ~86 (63,026 at 735.25) and this gives
213/// 69, since a slow core also waits to fill compress batches. Sign 8,0,2,1
214/// (fix13, 2026-10-19) holds 1,026 (median saturated latency 45,431, p90
215/// 55,844 at 46.46 cycles/signature, i.e. a mean hold of ~47.7k cycles);
216/// with the fitted [`CREDIT_OVERHEAD`] as part of `after_machine` this
217/// gives 1,012 at the measured rate and 952 at the model's 50.52.
218fn loaded_in_flight(contexts: f64, cycles_per_op: f64, after_machine: f64) -> f64 {
219    (contexts + after_machine / cycles_per_op).min(SIGN_CONTEXTS as f64)
220}
221
222/// The `a,b,c,d` profile from a plan detail that starts with `a/b/c/d`.
223fn profile_token(detail: &str) -> String {
224    detail.split_whitespace().next().unwrap_or("").replace('/', ",")
225}
226
227/// The `multiplier` build flag of a plan detail (`ED25519_MULTIPLIER` for
228/// the export: the 225-DSP unit or the 150-DSP Karatsuba unit).
229fn multiplier_flag(detail: &str) -> &'static str {
230    if detail.contains("150-DSP") { "\"LEAN150\"" } else { "\"DSP225\"" }
231}
232
233/// The `buildable` flag, and `not_buildable` listing every reason a plan
234/// cannot be exported as planned today (`measure/export_build.py` refuses
235/// such a config).
236fn buildable(gaps: &[&str]) -> BuildFlags {
237    let mut flags = vec![("buildable".into(), gaps.is_empty().to_string())];
238    if !gaps.is_empty() {
239        flags.push(("not_buildable".into(), format!("\"{}\"", gaps.join("; "))));
240    }
241    flags
242}
243
244/// Plan-detail text for a placement `[uram, lut, bram]` of `n` machines.
245fn memory_label(place: [u32; 3], n: u32) -> String {
246    match place {
247        [u, 0, 0] if u == n => "all machines in UltraRAM".into(),
248        [u, l, b] => format!("{u} of {n} machines in UltraRAM, {l} with LUT-RAM banks, {b} with block-RAM banks"),
249    }
250}
251
252/// One routed whole core: side, `a,b,c,d` profile, 150-DSP multiplier,
253/// machines in UltraRAM and with LUT-RAM banks (the rest block RAM),
254/// worst setup slack at 4.878 ns, the job and the RTL.
255struct Routed {
256    side: &'static str,
257    profile: [u32; 4],
258    lean: bool,
259    uram: u32,
260    lut: u32,
261    wns: f64,
262    job: &'static str,
263    rtl: &'static str,
264}
265
266/// Whole cores routed out of context at 205 MHz (`measure/results.tsv`),
267/// the newest RTL of each build since fix7. Older rows predate the timing
268/// fixes and say nothing about today's RTL. Add a row when a route lands.
269/// Only the 1,1,1,1 verifier is routed on fix14 (constant ROM); its LUTs
270/// grew 2.7 % and its slack went from +0.094 to +0.072, so the fix13 rows
271/// are a good guide but not a proof for fix14. The 24-machine verifier
272/// meets only with Vivado's hard route directives, by 0.008 ns; the screen
273/// says so.
274const ROUTED: &[Routed] = &[
275    Routed { side: "sign", profile: [4, 0, 1, 1], lean: false, uram: 4, lut: 0, wns: 0.024, job: "007zzl", rtl: "fix13" },
276    Routed { side: "sign", profile: [8, 0, 2, 0], lean: false, uram: 8, lut: 0, wns: 0.397, job: "007zzn", rtl: "fix13" },
277    Routed { side: "sign", profile: [8, 0, 2, 1], lean: false, uram: 6, lut: 0, wns: 0.218, job: "007zp", rtl: "fix9" },
278    Routed { side: "sign", profile: [8, 0, 2, 1], lean: true, uram: 8, lut: 0, wns: 0.103, job: "007zzp", rtl: "fix13" },
279    Routed { side: "sign", profile: [8, 0, 3, 1], lean: false, uram: 8, lut: 0, wns: 0.159, job: "007zzm", rtl: "fix13" },
280    Routed { side: "sign", profile: [9, 0, 2, 1], lean: false, uram: 9, lut: 0, wns: 0.114, job: "007zl", rtl: "fix7" },
281    Routed { side: "sign", profile: [12, 0, 3, 1], lean: false, uram: 5, lut: 2, wns: 0.050, job: "007zzd", rtl: "fix10" },
282    Routed { side: "verify", profile: [1, 1, 1, 1], lean: false, uram: 1, lut: 0, wns: 0.072, job: "007zzr", rtl: "fix14" },
283    Routed { side: "verify", profile: [6, 0, 1, 1], lean: false, uram: 6, lut: 0, wns: 0.019, job: "007zzh", rtl: "fix12" },
284    Routed { side: "verify", profile: [12, 0, 1, 1], lean: false, uram: 12, lut: 0, wns: 0.031, job: "007zzo", rtl: "fix13" },
285    Routed { side: "verify", profile: [24, 0, 2, 1], lean: false, uram: 2, lut: 19, wns: 0.008, job: "007zzq", rtl: "fix13, hard route" },
286];
287
288/// What routing says about this build's timing at 205 MHz: the routed
289/// core with the same profile, multiplier and memory layout, else one
290/// with the same profile in another layout, else that it is not routed.
291/// Timing is not part of the plan's search; this only reports it.
292fn routed_note(side: &str, profile: [u32; 4], lean: bool, place: [u32; 3]) -> String {
293    let verdict = |r: &Routed| {
294        if r.wns >= 0.0 {
295            format!("meets 205 MHz (+{:.3} ns, {} {})", r.wns, r.job, r.rtl)
296        } else {
297            format!("misses 205 MHz by {:.3} ns ({} {})", -r.wns, r.job, r.rtl)
298        }
299    };
300    let same = |r: &&Routed| r.side == side && r.profile == profile && r.lean == lean;
301    if let Some(r) = ROUTED.iter().filter(same).find(|r| r.uram == place[0] && r.lut == place[1]) {
302        format!("; routed: {}", verdict(r))
303    } else if let Some(r) = ROUTED.iter().find(same) {
304        format!("; routed in another layout ({} UltraRAM, {} LUT-RAM): {}", r.uram, r.lut, verdict(r))
305    } else {
306        "; timing: not routed".into()
307    }
308}
309
310/// The `uram_machines` and `lut_machines` flags of a plan detail, parsed
311/// back from [`memory_label`]'s text (all in UltraRAM when it says so).
312fn placement_flags(detail: &str, machines: u32) -> (u32, u32) {
313    let Some(t) = detail.split("memory ").nth(1) else { return (machines, 0) };
314    if t.starts_with("all machines in UltraRAM") {
315        return (machines, 0);
316    }
317    let num = |s: &str| s.trim().split(' ').next().and_then(|x| x.parse::<u32>().ok()).unwrap_or(0);
318    (num(t), t.split("UltraRAM, ").nth(1).map_or(0, num))
319}
320
321/// Replicated machines in a plan detail (the profile's first number).
322fn machines_of(detail: &str) -> u32 {
323    profile_token(detail).split(',').next().and_then(|t| t.parse().ok()).unwrap_or(1)
324}
325
326/// Cores in a plan detail (`"... x 3 cores: ..."`; 1 when absent).
327fn cores_of(detail: &str) -> u32 {
328    detail.split(" x ").nth(1).and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse().ok()).unwrap_or(1)
329}
330
331/// Block cost table for the "modules" knob: the routed fix9 blocks in
332/// their preferred memory layouts ([`frontier::slimmed_costs`]) or the
333/// older synthesized as-built blocks ([`frontier::vivado_costs`]). Both
334/// are Vivado numbers; what neither table built is noted per plan.
335fn costs(specialized: bool) -> Costs {
336    if specialized { frontier::slimmed_costs() } else { frontier::vivado_costs() }
337}
338
339/// Labels of the "modules" knob; index 0 is specialized, index 1 as built.
340const MODULES: [&str; 2] = ["specialized (routed)", "as built (Vivado)"];
341/// Labels of the "SHA-512" knob; index 0 is the shared line, index 1 own rings.
342const SHA: [&str; 2] = ["shared SHA-512 line", "own SHA rings"];
343
344/// The Ed25519 signing module as a chip-planner component. Its plan is the
345/// best [`frontier::plan_sign_with`] build for the share it is given.
346#[derive(Clone, Debug)]
347pub struct Ed25519Sign {
348    /// Specialized memory layout instead of the as-built one.
349    pub specialized: bool,
350    /// Hash through the shared SHA-512 line.
351    pub shared_sha: bool,
352    /// Block ratio and batch compression.
353    pub shape: ShapeKnobs,
354}
355
356/// Defaults to the specialized layout on the shared SHA-512 line, the
357/// planner choosing the mix (manual weights start at 15:11:3, the routed
358/// 15/11/3/1).
359impl Default for Ed25519Sign {
360    fn default() -> Self {
361        Ed25519Sign { specialized: true, shared_sha: true, shape: ShapeKnobs::auto([15.0, 11.0, 3.0]) }
362    }
363}
364
365/// Chip-planner interface of the signing module.
366impl Component for Ed25519Sign {
367    /// Display name in the TUI.
368    fn name(&self) -> String {
369        "Ed25519 sign".into()
370    }
371    /// Planner role, used to report delivered signatures.
372    fn role(&self) -> Role {
373        Role::Ed25519Sign
374    }
375    /// The two choice knobs; the selected index is 0 for the first label.
376    fn knobs(&self) -> Vec<Knob> {
377        let mut k = vec![
378            Knob::choice("modules", usize::from(!self.specialized), &MODULES),
379            Knob::choice("SHA-512", usize::from(!self.shared_sha), &SHA),
380        ];
381        k.extend(self.shape.knobs("FB machine"));
382        k
383    }
384    /// Applies a knob change; `value` is the chosen label index, and index
385    /// 0 is the specialized layout or the shared line. Indices 2.. are the
386    /// shape knobs ([`ShapeKnobs`]).
387    fn set_knob(&mut self, index: usize, value: f64) {
388        match index {
389            0 => self.specialized = value == 0.0,
390            1 => self.shared_sha = value == 0.0,
391            _ => self.shape.set(index - 2, value),
392        }
393    }
394    /// Best signing build within `share` at the context's clock, or `None`
395    /// when nothing fits. On the shared line the plan demands three SHA-512
396    /// blocks per signature from the line.
397    fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
398        let c = costs(self.specialized);
399        let shape = self.shape.shape();
400        let (n, p) = best_cores(share, |s| frontier::plan_sign_shaped(s, &c, cx.mhz, self.shared_sha, &shape))?;
401        let f = p.profile;
402        // The measured small signer, as opposed to a fused model build.
403        let small = f.fused && p.cost == c.small_signer;
404        let note = self.shape.note();
405        let cores = if n > 1 { format!(" x {n} cores") } else { String::new() };
406        // The fused small signer keeps its own SHA ring even when the
407        // module is set to the shared line.
408        let on_line = self.shared_sha && !small;
409        let sha = if on_line { "shared SHA".to_string() } else { format!("{} SHA", f.sha) };
410        let detail = if small {
411            format!("1/1/1/0 profile{cores}: Ed25519SignCoreSmall (fused, own SHA ring)")
412        } else {
413            // The leading `fb/sha/sc/cp` token is what `build_flags` exports.
414            let cp = if f.fused { "no compress machine (model)" } else { "1 compress" };
415            let pc = frontier::with_mul_pub(&c, p.multiplier);
416            let port = if self.shared_sha { frontier::SHA_CLIENT_LUT } else { 0 };
417            let place = frontier::sign_placement(&f, &pc, &p.share, port);
418            let routed = if self.specialized {
419                routed_note("sign", [f.fb, f.sha, f.sc, u32::from(!f.fused)], p.multiplier == Multiplier::Lean150, place)
420            } else {
421                String::new()
422            };
423            format!(
424                "{}/{}/{}/{} profile{cores}: {} FB, {sha}, {} scalar, {cp}, {}, memory {}{note}{routed}",
425                f.fb,
426                f.sha,
427                f.sc,
428                u32::from(!f.fused),
429                f.fb,
430                f.sc,
431                p.multiplier.name(),
432                memory_label(place, f.fb as u32)
433            )
434        };
435        Some(Plan {
436            cost: times(p.cost, n),
437            rate: n as f64 * p.rate,
438            unit: "sign",
439            limit: p.limit.into(),
440            detail,
441            demands: if on_line { vec![Flow { service: SHA512_BLOCKS, per_second: 3.0 * p.rate }] } else { Vec::new() },
442            supplies: Vec::new(),
443            // Both tables are Vivado blocks (routed, or synthesized as
444            // built); a fused core of more than one machine is a model.
445            measured: small || !f.fused,
446            // The small signer measured 22,240 cycles for one request.
447            latency_cycles: if small { SMALL_SIGNER_LATENCY } else { sign_latency_cycles(!on_line) },
448            // Requests in flight at full load: the fused cores hold 32 per
449            // machine; otherwise the FB machines' contexts plus the
450            // signatures past them (compress plus the fitted credit
451            // overhead, which covers queueing, the two challenge blocks and
452            // two scalar jobs), at most the 2,048 credits.
453            in_flight: n as f64
454                * if f.fused {
455                    32.0 * f.fb as f64
456                } else {
457                    loaded_in_flight(32.0 * f.fb as f64, cx.mhz * 1e6 / p.rate, compress_cycles() + CREDIT_OVERHEAD)
458                },
459            batch: None,
460            covers: Vec::new(),
461        })
462    }
463    /// Compile-time settings that would build this plan: the
464    /// `fb,sha,sc,cp` profile `build.rs` parses (`sha = 0` on the shared
465    /// line; `1,1,1,0` selects the small signer) and the SHA mode.
466    fn build_flags(&self, plan: &Plan) -> BuildFlags {
467        let profile = profile_token(&plan.detail);
468        let gaps: Vec<&str> = Vec::new();
469        let mut flags = vec![
470            ("ED25519_SCALABLE_PROFILE".into(), format!("\"{profile}\"")),
471            ("cores".into(), cores_of(&plan.detail).to_string()),
472            ("SH".into(), u8::from(self.shared_sha && profile != "1,1,1,0").to_string()),
473            ("memory".into(), if self.specialized { "\"SPECIALIZED\"" } else { "\"AS_BUILT\"" }.into()),
474            ("multiplier".into(), multiplier_flag(&plan.detail).into()),
475            ("uram_machines".into(), placement_flags(&plan.detail, machines_of(&plan.detail)).0.to_string()),
476            ("lut_machines".into(), placement_flags(&plan.detail, machines_of(&plan.detail)).1.to_string()),
477        ];
478        flags.extend(buildable(&gaps));
479        flags
480    }
481}
482
483/// The Ed25519 verification module as a chip-planner component. Its plan
484/// is the best [`frontier::plan_verify_with`] build for the share and mode.
485#[derive(Clone, Debug)]
486pub struct Ed25519Verify {
487    /// Specialized memory layout instead of the as-built one.
488    pub specialized: bool,
489    /// Hash through the shared SHA-512 line.
490    pub shared_sha: bool,
491    /// Verification mode; sets the verify program length
492    /// ([`VerifyMode::bundles`]).
493    pub mode: VerifyMode,
494    /// Block ratio and batch compression.
495    pub shape: ShapeKnobs,
496}
497
498/// Defaults to the specialized layout, the shared line and known-key mode,
499/// the planner choosing the mix (manual weights start at 16:2:1).
500impl Default for Ed25519Verify {
501    fn default() -> Self {
502        Ed25519Verify { specialized: true, shared_sha: true, mode: VerifyMode::KnownKey, shape: ShapeKnobs::auto([16.0, 2.0, 1.0]) }
503    }
504}
505
506/// Verify modes in the order of the "mode" knob's labels.
507const MODES: [VerifyMode; 3] = [VerifyMode::KnownKey, VerifyMode::Strict, VerifyMode::Batch];
508
509/// Chip-planner interface of the verification module.
510impl Component for Ed25519Verify {
511    /// Display name in the TUI.
512    fn name(&self) -> String {
513        "Ed25519 verify".into()
514    }
515    /// Planner role, used to report delivered verifications.
516    fn role(&self) -> Role {
517        Role::Ed25519Verify
518    }
519    /// Module set, SHA-512 source and verify mode, as choice knobs.
520    fn knobs(&self) -> Vec<Knob> {
521        vec![
522            Knob::choice("modules", usize::from(!self.specialized), &MODULES),
523            Knob::choice("SHA-512", usize::from(!self.shared_sha), &SHA),
524            Knob::choice(
525                "mode",
526                MODES.iter().position(|m| *m == self.mode).unwrap(),
527                &["known-key", "strict (any key)", "batch (model, not strict)"],
528            ),
529        ]
530        .into_iter()
531        .chain(self.shape.knobs("verify machine"))
532        .collect()
533    }
534    /// Applies a knob change; `value` is the chosen label index (the mode
535    /// knob indexes `MODES`).
536    fn set_knob(&mut self, index: usize, value: f64) {
537        match index {
538            0 => self.specialized = value == 0.0,
539            1 => self.shared_sha = value == 0.0,
540            2 => self.mode = MODES[value as usize],
541            _ => self.shape.set(index - 3, value),
542        }
543    }
544    /// Best verification build within `share`, or `None` when nothing fits.
545    /// On the shared line the plan demands two SHA-512 blocks per
546    /// verification.
547    fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
548        let c = costs(self.specialized);
549        let shape = self.shape.shape();
550        let (n, p) = best_cores(share, |s| frontier::plan_verify_shaped(s, self.mode, &c, cx.mhz, self.shared_sha, &shape))?;
551        let note = self.shape.note();
552        let v = p.profile;
553        let cores = if n > 1 { format!(" x {n} cores") } else { String::new() };
554        let sha = if self.shared_sha { "shared SHA".to_string() } else { format!("{} SHA", v.sha) };
555        let pc = frontier::with_mul_pub(&c, p.multiplier);
556        let port = if self.shared_sha { frontier::SHA_CLIENT_LUT } else { 0 };
557        let place = frontier::verify_placement(&v, self.mode, &pc, &p.share, port);
558        let routed = if self.specialized && self.mode == VerifyMode::KnownKey {
559            routed_note("verify", [v.machines, v.sha, v.sc, v.cp], p.multiplier == Multiplier::Lean150, place)
560        } else {
561            String::new()
562        };
563        Some(Plan {
564            cost: times(p.cost, n),
565            rate: n as f64 * p.rate,
566            unit: "verify",
567            limit: p.limit.into(),
568            // The leading `machines/sha/sc/cp` token is what `build_flags` exports.
569            detail: format!(
570                "{}/{}/{}/{} profile{cores}: {} VM, {sha}, {} scalar, {} CP{}, {}, memory {}{note}{routed}",
571                v.machines,
572                v.sha,
573                v.sc,
574                v.cp,
575                v.machines,
576                v.sc,
577                v.cp,
578                if v.cp == 0 { " (machines encode; model)" } else { "" },
579                p.multiplier.name(),
580                memory_label(place, v.machines)
581            ),
582            demands: if self.shared_sha { vec![Flow { service: SHA512_BLOCKS, per_second: 2.0 * p.rate }] } else { Vec::new() },
583            supplies: Vec::new(),
584            // Only the routed table has a built verify machine, and only
585            // the known-key one; a verifier without a compress machine is
586            // a model. As built, verify blocks are derived from sign blocks.
587            measured: self.specialized && self.mode == VerifyMode::KnownKey && v.cp != 0,
588            latency_cycles: verify_latency_cycles(self.mode, !self.shared_sha),
589            // The verify machines' contexts plus the verifications in the
590            // compress stage and the final compare, at most 2,048 per core.
591            in_flight: n as f64 * loaded_in_flight(32.0 * v.machines as f64, cx.mhz * 1e6 / p.rate, compress_cycles() + 2.0),
592            batch: None,
593            covers: Vec::new(),
594        })
595    }
596    /// Compile-time settings that would build this plan.
597    fn build_flags(&self, plan: &Plan) -> BuildFlags {
598        let mut flags: BuildFlags = vec![
599            // `machines,sha,sc,cp` for `KnownKeyVerifyCore<NV, NSHA, NSC, SH>`.
600            ("verify_profile".into(), format!("\"{}\"", profile_token(&plan.detail))),
601            ("cores".into(), cores_of(&plan.detail).to_string()),
602            ("verify_mode".into(), format!("\"{}\"", self.mode.name())),
603            ("SH".into(), u8::from(self.shared_sha).to_string()),
604            ("memory".into(), if self.specialized { "\"SPECIALIZED\"" } else { "\"AS_BUILT\"" }.into()),
605            ("multiplier".into(), multiplier_flag(&plan.detail).into()),
606            ("uram_machines".into(), placement_flags(&plan.detail, machines_of(&plan.detail)).0.to_string()),
607            ("lut_machines".into(), placement_flags(&plan.detail, machines_of(&plan.detail)).1.to_string()),
608        ];
609        let mut gaps = Vec::new();
610        if !profile_token(&plan.detail).ends_with(",1") {
611            gaps.push("KnownKeyVerifyCore has exactly one compress stage");
612        }
613        if self.mode != VerifyMode::KnownKey {
614            gaps.push("only the known-key verifier is built");
615        }
616        flags.extend(buildable(&gaps));
617        flags
618    }
619}
620
621/// Stand-in for the SHA-512 line (`rhdl_sha512_pipelined`'s component
622/// replaces it): auto-sized to demand by default (at least one 5.5k-LUT
623/// folded core), or by hand 0–432k LUTs and 0–20 DSPs.
624///
625/// Model: `LUT / 87.2k` blocks per clock, the Yosys cost of the full
626/// pipeline and (per block) of the folded cores, continuous in between.
627#[derive(Clone, Debug)]
628pub struct Sha512Estimate {
629    /// LUT allowance, out of the chip budget (0..=432,000).
630    pub lut: u32,
631    /// DSP allowance, out of the chip budget (0..=20). At 20 the DSP
632    /// adder variant of the model applies.
633    pub dsp: u32,
634    /// Sized by the planner to what the chip wants, so the line never
635    /// limits it (the default); `lut` then shows the size it settled on.
636    pub auto: bool,
637}
638
639/// The smallest line the estimate builds: one 2-stage folded core
640/// (`rhdl_sha512_pipelined::folded`, ~5.5k LUT by Yosys).
641pub const SHA_ESTIMATE_MIN_LUT: u32 = 5_500;
642
643/// Largest LUT allowance of the estimate's knob, the same as the real line's
644/// (ten pipelines' worth, 432k LUT, about 11.6 blocks per clock).
645pub const SHA_ESTIMATE_MAX_LUT: u32 = 432_000;
646
647/// The default: auto size; 65k LUTs and 20 DSPs (~0.75 block per clock)
648/// when set by hand.
649impl Default for Sha512Estimate {
650    fn default() -> Self {
651        Sha512Estimate { lut: 65_000, dsp: 20, auto: true }
652    }
653}
654
655/// Throughput model of the SHA-512 line.
656impl Sha512Estimate {
657    /// Modeled blocks per clock of the allowance.
658    pub fn blocks_per_clock(&self) -> f64 {
659        self.blocks_per_clock_in(self.lut)
660    }
661    /// Modeled blocks per clock of `lut` LUTs: LUTs divided by the
662    /// projected LUT cost of one block per clock.
663    pub fn blocks_per_clock_in(&self, lut: u32) -> f64 {
664        lut as f64 / self.lut_per_block()
665    }
666    /// LUTs per block per clock: 87,200, the Yosys count of the full
667    /// SHA-512 pipeline (folded cores cost about the same per block). The
668    /// earlier projection (37k with DSP adders) was not borne out. An
669    /// estimate, not measured.
670    fn lut_per_block(&self) -> f64 {
671        87_200.0
672    }
673    /// How far the planner may grow the line past its allowance, out of
674    /// LUTs no other module uses: 2 blocks per clock (an allowance above that
675    /// is built as given).
676    pub fn largest_lut(&self) -> u32 {
677        (2.0 * self.lut_per_block()).ceil() as u32
678    }
679}
680
681/// Chip-planner interface of the SHA-512 line stand-in.
682impl Component for Sha512Estimate {
683    /// Display name in the TUI.
684    fn name(&self) -> String {
685        "SHA-512 line (estimate)".into()
686    }
687    /// Planner role; the line supplies `"sha512 blocks"`.
688    fn role(&self) -> Role {
689        Role::Sha512
690    }
691    /// LUT allowance (0..=65,000 in steps of 1,000) and DSP allowance
692    /// (0..=20).
693    fn knobs(&self) -> Vec<Knob> {
694        vec![
695            Knob::range("LUT allowance", self.lut as f64, 0.0, f64::from(SHA_ESTIMATE_MAX_LUT), 1_000.0),
696            Knob::range("DSP allowance", self.dsp as f64, 0.0, 20.0, 1.0),
697            Knob::choice("size", usize::from(!self.auto), &["auto (never limits)", "manual (LUT allowance)"]),
698        ]
699    }
700    /// Applies a knob change: index 0 sets the LUT allowance (and makes the
701    /// size manual), 1 the DSP allowance, 2 auto or manual.
702    fn set_knob(&mut self, index: usize, value: f64) {
703        match index {
704            0 => {
705                self.lut = (value.max(0.0) as u32).min(SHA_ESTIMATE_MAX_LUT);
706                self.auto = false;
707            }
708            1 => self.dsp = value as u32,
709            _ => self.auto = value < 0.5,
710        }
711    }
712    /// The line's supply of SHA-512 blocks per second at the context's
713    /// clock, or `None` when the allowance is zero (no line) or does not fit
714    /// `share`. In a share larger than the allowance (the planner lending
715    /// it LUTs nobody else uses) the line grows, up to
716    /// [`largest_lut`](Self::largest_lut).
717    fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
718        // Auto: the share is the size the planner chose.
719        let base = if self.auto { share.lut } else { self.lut };
720        if base == 0 || share.lut < base || share.dsp < self.dsp {
721            return None;
722        }
723        let lut = share.lut.min(self.largest_lut().max(base));
724        let cost = Cost { lut, dsp: self.dsp, bram36: 0, uram: 0 };
725        let per_clock = self.blocks_per_clock_in(lut);
726        let rate = per_clock * cx.mhz * 1e6;
727        Some(Plan {
728            cost,
729            rate,
730            unit: "block",
731            limit: "allowance".into(),
732            detail: format!("{per_clock:.2} blocks/clock"),
733            demands: Vec::new(),
734            supplies: vec![Flow { service: SHA512_BLOCKS, per_second: rate }],
735            measured: false,
736            // Input register, 80 rounds, feed-forward add.
737            latency_cycles: 82.0,
738            // Little's law: blocks in the pipeline = latency x blocks/clock.
739            in_flight: 82.0 * per_clock,
740            batch: None,
741            covers: Vec::new(),
742        })
743    }
744    /// The allowance comes off the top of the budget (the SHA part of the
745    /// LUT split) rather than from the power-weighted shares.
746    fn fixed(&self) -> Option<Share> {
747        Some(Share { lut: self.lut, dsp: self.dsp, ..Share::default() })
748    }
749    /// Auto: the LUTs that hash `per_second` blocks at the clock (at least
750    /// the smallest core), or none when nothing is wanted.
751    fn size_for(&self, per_second: f64, cx: &Context) -> Option<Share> {
752        if !self.auto {
753            return None;
754        }
755        let lut = if per_second <= 0.0 {
756            0
757        } else {
758            ((per_second / (cx.mhz * 1e6) * self.lut_per_block()).ceil() as u32).max(SHA_ESTIMATE_MIN_LUT)
759        };
760        Some(Share { lut, dsp: if lut > 0 { self.dsp } else { 0 }, ..Share::default() })
761    }
762    /// Remember the auto size.
763    fn resize(&mut self, allowance: Share) {
764        if self.auto {
765            self.lut = allowance.lut;
766        }
767    }
768    /// Compile-time settings: the LUTs the line was planned with (its
769    /// allowance plus anything it grew into) and its DSPs.
770    fn build_flags(&self, plan: &Plan) -> BuildFlags {
771        vec![("sha_lut".into(), plan.cost.lut.to_string()), ("sha_dsp".into(), self.dsp.to_string())]
772    }
773}
774
775/// Chip-level checks of the three components through the shared planner.
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use rhdl_fpga_planner::{Budget, Mode, Powers, Side, Slot, apply_powers, balance_mix};
780
781    /// The default Ed25519 chip: sign, verify and the SHA-512 line, with
782    /// both modules on the shared line or on their own rings.
783    fn chip(shared: bool) -> Vec<Slot> {
784        vec![
785            Slot::new(Box::new(Ed25519Sign { shared_sha: shared, ..Default::default() }), 0.0),
786            Slot::new(Box::new(Ed25519Verify { shared_sha: shared, ..Default::default() }), 0.0),
787            Slot::new(Box::new(Sha512Estimate::default()), 0.0),
788        ]
789    }
790
791    /// Balancing the default budget for one verification per signature
792    /// gives both sides a positive rate with both SHA-512 options. Prints
793    /// the result for inspection.
794    #[test]
795    fn ed25519_paper_balanced() {
796        let b = Budget::default();
797        let cx = Context::default();
798        for shared in [false, true] {
799            let mut slots = chip(shared);
800            let powers = balance_mix(Mode::Ed25519, &b, &cx, &mut slots);
801            let p = apply_powers(Mode::Ed25519, &b, &cx, &mut slots, powers);
802            let d = &p.delivered;
803            println!(
804                "{}: {:.2}M sign + {:.2}M verify (powers {:.0}/{:.0}, limited by {} / {}), {} LUT {} DSP, latency {:.0}/{:.0} us",
805                if shared { "shared SHA line" } else { "own rings" },
806                d.sign.rate / 1e6,
807                d.verify.rate / 1e6,
808                powers.sign,
809                powers.verify,
810                d.sign.bottleneck,
811                d.verify.bottleneck,
812                p.total.lut,
813                p.total.dsp,
814                d.sign.latency_us,
815                d.verify.latency_us
816            );
817            assert!(d.sign.rate > 0.0 && d.verify.rate > 0.0);
818        }
819    }
820
821    /// The sides are independent: all power to signing gives no
822    /// verification and more signing than an even split.
823    #[test]
824    fn sides_are_independent() {
825        let b = Budget::default();
826        let cx = Context::default();
827        let mut slots = chip(true);
828        let even = apply_powers(Mode::Ed25519, &b, &cx, &mut slots, Powers::default()).delivered;
829        let sign_only = apply_powers(Mode::Ed25519, &b, &cx, &mut slots, Powers { sign: 100.0, verify: 0.0 }).delivered;
830        assert_eq!(sign_only.verify.rate, 0.0);
831        assert!(sign_only.sign.rate > even.sign.rate);
832    }
833
834    /// When UltraRAM runs short, machines move to their block-RAM layout
835    /// (the only fallback the RTL builds), and the plan says how many: the
836    /// rate stays well above what the UltraRAM alone would allow. When
837    /// block RAM runs short too, the plan holds fewer machines instead.
838    #[test]
839    fn memory_moves_to_bram_and_says_how_many() {
840        let cx = Context::default();
841        let sign = Ed25519Sign::default();
842        let full = Share { lut: 420_000, dsp: 4_500, bram36: 2_016, uram: 960 };
843        let rate = |s: Share| sign.plan(&s, &cx).map(|p| (p.rate, p.cost, p.detail));
844        let (r_full, c_full, d_full) = rate(full).unwrap();
845        assert!(d_full.contains("all machines in UltraRAM"), "{d_full}");
846        let little_uram = Share { uram: c_full.uram / 3, ..full };
847        let (r_lu, c_lu, d_lu) = rate(little_uram).unwrap();
848        println!("little URAM: {:.2}M {c_lu:?} {d_lu}", r_lu / 1e6);
849        assert!(c_lu.uram <= little_uram.uram && d_lu.contains("machines in UltraRAM,") && r_lu > 0.5 * r_full, "{d_lu}");
850        let flags = sign.build_flags(&sign.plan(&little_uram, &cx).unwrap());
851        let kept: u32 = flags.iter().find(|(k, _)| k == "uram_machines").unwrap().1.parse().unwrap();
852        assert!(kept < d_lu.split('/').next().unwrap().parse::<u32>().unwrap(), "{flags:?}");
853        let little_ram = Share { uram: 40, bram36: 400, ..full };
854        let (r_lr, c_lr, d_lr) = rate(little_ram).unwrap();
855        println!("little RAM: {:.2}M {c_lr:?} {d_lr}", r_lr / 1e6);
856        assert!(c_lr.uram <= 40 && c_lr.bram36 <= 400 && r_lr < r_lu, "{d_lr}");
857    }
858
859    /// A manual ratio is kept, and batch compression can be switched off.
860    #[test]
861    fn ratio_and_batch_compress_knobs() {
862        let cx = Context::default();
863        let share = Share { lut: 210_000, dsp: 2_250, bram36: 1_008, uram: 480 };
864        let mut sign = Ed25519Sign { shared_sha: false, ..Ed25519Sign::default() };
865        sign.set_knob(2, 1.0); // manual
866        sign.set_knob(3, 4.0); // FB 4
867        sign.set_knob(4, 2.0); // SHA 2
868        sign.set_knob(5, 1.0); // scalar 1
869        let p = sign.plan(&share, &cx).unwrap();
870        let t: Vec<u32> = profile_token(&p.detail).split(',').map(|x| x.parse().unwrap()).collect();
871        assert_eq!((t[1], t[2]), ((t[0] as f64 / 2.0).round().max(1.0) as u32, (t[0] as f64 / 4.0).round().max(1.0) as u32), "{}", p.detail);
872        sign.set_knob(6, 2.0); // compress off
873        let off = sign.plan(&share, &cx).unwrap();
874        assert!(profile_token(&off.detail).ends_with(",0"), "{}", off.detail);
875        // Known-key verification is built only with its one compress stage,
876        // so switching batch compression off leaves nothing to plan. The
877        // strict mode (a model) still plans a build without one.
878        let mut verify = Ed25519Verify::default();
879        verify.set_knob(7, 2.0);
880        assert!(verify.plan(&share, &cx).is_none());
881        verify.set_knob(2, 1.0); // strict
882        let v = verify.plan(&share, &cx).unwrap();
883        assert!(profile_token(&v.detail).ends_with(",0"), "{}", v.detail);
884        verify.set_knob(2, 0.0); // known-key
885        verify.set_knob(7, 1.0);
886        assert!(profile_token(&verify.plan(&share, &cx).unwrap().detail).ends_with(",1"));
887    }
888
889    /// The SHA-512 line is part of the LUT split: shrinking its allowance
890    /// gives its LUTs to the engines, and the rates never fall while the line
891    /// suffices (65k, 40k, 10k: the line borrows back the few LUTs it lacks
892    /// from what the engines leave). A manual allowance far below what the
893    /// line needs (2k) either borrows its way back too, or, when the engines
894    /// use every LUT, binds, and then the plan must say the line limits both
895    /// sides. Which one happens moves with small cost changes (the 2026-10-20
896    /// ring refit made 2k bind; the fix14 constant ROM, which leaves fewer
897    /// engines and LUTs to spare, made it borrow again), so the test accepts
898    /// either and checks that neither is silent. "auto" never binds.
899    #[test]
900    fn sha_line_size_trades_against_the_engines() {
901        let b = Budget::default();
902        let cx = Context::default();
903        let mut last = (0.0_f64, 0.0_f64);
904        for lut in [65_000.0, 40_000.0, 10_000.0, 2_000.0] {
905            let mut slots = chip(true);
906            slots[2].component.set_knob(0, lut);
907            let p = apply_powers(Mode::Ed25519, &b, &cx, &mut slots, Powers::default());
908            let d = &p.delivered;
909            let line = p.parts[2].1.as_ref().map_or(0, |x| x.cost.lut);
910            println!(
911                "SHA allowance {lut} LUT (built {line}): {:.2}M sign / {:.2}M verify, limited by {} / {}",
912                d.sign.rate / 1e6,
913                d.verify.rate / 1e6,
914                d.side(Side::Sign).bottleneck,
915                d.side(Side::Verify).bottleneck
916            );
917            assert!(p.total.fits(&b.chip()));
918            if d.sha_bound {
919                // Too small, and said so: the line limits both sides.
920                assert_eq!(lut, 2_000.0, "only the 2k line may bind (built {line})");
921                assert!(d.side(Side::Sign).bottleneck.contains("SHA") && d.side(Side::Verify).bottleneck.contains("SHA"));
922                continue;
923            }
924            // Powers are fixed here (not balanced), and freed LUTs change
925            // what one side lends the other, so one side can dip while the
926            // other gains: the total never falls more than 5 %, and the
927            // lower side stays within 15 %. (Routed costs, 2026-10-17: at
928            // 2k verify dips 10 % while sign gains 10 %.)
929            let total = d.sign.rate + d.verify.rate;
930            assert!(total >= 0.95 * (last.0 + last.1), "the total fell at {lut}");
931            assert!(d.sign.rate.min(d.verify.rate) >= 0.85 * last.0.min(last.1), "the lower rate fell far at {lut}");
932            last = (d.sign.rate, d.verify.rate);
933        }
934        // The default "auto" size sizes the line to what the engines want:
935        // it never limits, and it delivers at least the manual sizes did.
936        let mut slots = chip(true);
937        let p = apply_powers(Mode::Ed25519, &b, &cx, &mut slots, Powers::default());
938        let d = &p.delivered;
939        println!("SHA auto: {:.2}M sign / {:.2}M verify", d.sign.rate / 1e6, d.verify.rate / 1e6);
940        assert!(!d.sha_bound && p.total.fits(&b.chip()));
941        assert!(d.sign.rate + d.verify.rate >= 0.95 * (last.0 + last.1), "auto delivers less than 10k manual");
942    }
943}
944
945/// Ed25519 as uBFT's service ([`crate::ubft::UbftEd25519`], feature
946/// `ubft`): the signer and the verifier of `UbftEd25519<1, NV>` as two
947/// uBFT modules ([`Component::ubft`]), planned only in
948/// `Mode::UbftEd25519`, beside the replica
949/// ([`rhdl_fpga_planner::ubft::UbftReplica`]).
950///
951/// Each is a fixed build, not sized to its share: a slot needs one
952/// signature and at most 2f = 6 verifications, so a larger build would
953/// shorten nothing but the verify queue. The signer is the measured
954/// small signer (1,1,1,0, own SHA ring). The verifier is the known-key
955/// 1,1,1,1 core (routed on fix14, job 007zzr) with `NV` machines, plus
956/// the key tables the 16 slots add ([`KEY_SLOTS_16_BRAM36`]). The
957/// service's own logic (tag tables, two FIFOs, the AXI-Lite map) is not
958/// synthesized yet: [`UBFT_WRAPPER_LUT`] is an estimate.
959#[cfg(feature = "ubft")]
960pub mod ubft {
961    use rhdl_fpga_planner::{Component, Context, Cost, Knob, Plan, Role, Share, Slot, ubft::UbftReplica};
962
963    use super::{SMALL_SIGNER_LATENCY, costs, per_core, times, verify_latency_cycles};
964    use crate::frontier::{self, SMALL_SIGNER_CYCLES, VerifyMode, VerifyProfile};
965
966    /// Estimated LUTs of the service around the cores (control, tag tables,
967    /// the signature and verdict FIFOs, the AXI-Lite map), split evenly
968    /// between the two modules. A model until the service is synthesized.
969    pub const UBFT_WRAPPER_LUT: u32 = 6_000;
970    /// Extra BRAM36 per verify machine for 16 key slots instead of 8 (the
971    /// machine's key tables double; `key-slots-16`). A model.
972    pub const KEY_SLOTS_16_BRAM36: u32 = 57;
973
974    /// The signing half of the Ed25519 uBFT service.
975    #[derive(Clone, Debug, Default)]
976    pub struct Ed25519UbftSign;
977
978    impl Component for Ed25519UbftSign {
979        fn name(&self) -> String {
980            "Ed25519 uBFT sign".into()
981        }
982        fn role(&self) -> Role {
983            Role::Ed25519Sign
984        }
985        fn knobs(&self) -> Vec<Knob> {
986            Vec::new()
987        }
988        fn set_knob(&mut self, _index: usize, _value: f64) {}
989        /// The small signer plus half the service logic, if it fits.
990        fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
991            let core = times(costs(true).small_signer, 1);
992            let cost = Cost { lut: core.lut + UBFT_WRAPPER_LUT / 2, ..core };
993            cost.fits(share).then(|| Plan {
994                cost,
995                rate: cx.mhz * 1e6 / SMALL_SIGNER_CYCLES,
996                unit: "sign",
997                limit: cost.limit(share).into(),
998                detail: format!("1/1/1/0 small signer, own SHA ring; + {} LUT service logic (estimate)", UBFT_WRAPPER_LUT / 2),
999                demands: Vec::new(),
1000                supplies: Vec::new(),
1001                // The core is measured; the service logic is not.
1002                measured: false,
1003                latency_cycles: SMALL_SIGNER_LATENCY,
1004                in_flight: 32.0,
1005                batch: None,
1006                covers: Vec::new(),
1007            })
1008        }
1009        fn ubft(&self) -> bool {
1010            true
1011        }
1012    }
1013
1014    /// The verifying half of the Ed25519 uBFT service: `machines` known-key
1015    /// verify machines (1 to 4, default 1).
1016    #[derive(Clone, Debug)]
1017    pub struct Ed25519UbftVerify {
1018        /// Verify field machines (`NV`).
1019        pub machines: u32,
1020    }
1021
1022    impl Default for Ed25519UbftVerify {
1023        fn default() -> Self {
1024            Ed25519UbftVerify { machines: 1 }
1025        }
1026    }
1027
1028    impl Component for Ed25519UbftVerify {
1029        fn name(&self) -> String {
1030            "Ed25519 uBFT verify".into()
1031        }
1032        fn role(&self) -> Role {
1033            Role::Ed25519Verify
1034        }
1035        fn knobs(&self) -> Vec<Knob> {
1036            vec![Knob::range("verify machines (NV)", self.machines as f64, 1.0, 4.0, 1.0)]
1037        }
1038        fn set_knob(&mut self, _index: usize, value: f64) {
1039            self.machines = value as u32;
1040        }
1041        /// The `NV`,1,1,1 known-key verifier with 16 key slots plus half
1042        /// the service logic, if it fits.
1043        fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
1044            let c = costs(true);
1045            let v = VerifyProfile { machines: self.machines, sha: 1, sc: 1, cp: 1 };
1046            let core = times(frontier::verify_cost_within(&v, VerifyMode::KnownKey, &c, &per_core(share, 1), 0), 1);
1047            let cost = Cost {
1048                lut: core.lut + UBFT_WRAPPER_LUT / 2,
1049                bram36: core.bram36 + self.machines * KEY_SLOTS_16_BRAM36,
1050                ..core
1051            };
1052            // The service has only 16 IDs, fewer than a single machine's 32
1053            // contexts. Fixed-priority dispatch therefore keeps using machine
1054            // zero. Extra NV currently adds cost, not usable service rate.
1055            let latency = verify_latency_cycles(VerifyMode::KnownKey, true);
1056            let one = VerifyProfile { machines: 1, sha: 1, sc: 1, cp: 1 };
1057            let rate_bound = (cx.mhz * 1e6 / one.cycles(VerifyMode::KnownKey)).min(cx.mhz * 1e6 * 16.0 / latency);
1058            cost.fits(share).then(|| Plan {
1059                cost,
1060                rate: rate_bound,
1061                unit: "verify",
1062                limit: format!("{}; 16 IDs, fixed-priority dispatch", cost.limit(share)),
1063                detail: format!(
1064                    "{}/1/1/1 known-key, own SHA ring, 16 key slots (+{} BRAM, model); + {} LUT service logic (estimate)",
1065                    self.machines,
1066                    self.machines * KEY_SLOTS_16_BRAM36,
1067                    UBFT_WRAPPER_LUT / 2
1068                ),
1069                demands: Vec::new(),
1070                supplies: Vec::new(),
1071                measured: false,
1072                latency_cycles: latency,
1073                in_flight: 16.0,
1074                batch: None,
1075                covers: Vec::new(),
1076            })
1077        }
1078        fn build_flags(&self, _plan: &Plan) -> rhdl_fpga_planner::BuildFlags {
1079            vec![("RHDL_UBFT_VERIFY_MACHINES".into(), self.machines.to_string())]
1080        }
1081        fn ubft(&self) -> bool {
1082            true
1083        }
1084    }
1085
1086    /// The uBFT chip's slots: the replica, then the service's two halves.
1087    pub fn slots() -> Vec<Slot> {
1088        vec![
1089            Slot::new(Box::new(UbftReplica::default()), 0.0),
1090            Slot::new(Box::new(Ed25519UbftSign), 0.0),
1091            Slot::new(Box::new(Ed25519UbftVerify::default()), 0.0),
1092        ]
1093    }
1094}
1095
1096/// The Ed25519 uBFT chip plans the replica and both halves of the service
1097/// on the whole device; a slot's crypto time is the measured signer, the
1098/// verifier model and serial peer verification. Overlap is a separate scenario.
1099#[cfg(all(test, feature = "ubft"))]
1100mod ubft_tests {
1101    use rhdl_fpga_planner::{Budget, Context, Mode, Powers, apply_powers, ubft as u};
1102
1103    #[test]
1104    fn ubft_chip_plans_serial_crypto_without_claiming_a_baseline_ratio() {
1105        let mut slots = super::ubft::slots();
1106        let cx = Context::default();
1107        let plan = apply_powers(Mode::UbftEd25519, &Budget::whole_device(), &cx, &mut slots, Powers::default());
1108        assert!(plan.parts.iter().all(|p| p.1.is_some()), "{:?}", plan.parts);
1109        let d = &plan.delivered;
1110        for n in u::UBFT_REPLICAS {
1111            let t = u::serial_slot_crypto_us(d, n);
1112            assert_eq!(t, d.sign.latency_us + f64::from(u::quorum_verifies(n)) * d.verify.latency_us);
1113            assert!(t.is_finite() && t > 0.0);
1114        }
1115        // One signature (22,240 cycles measured) plus one verification's
1116        // latency plus one more verification at the verify rate.
1117        let expect = (super::SMALL_SIGNER_LATENCY + super::verify_latency_cycles(crate::frontier::VerifyMode::KnownKey, true)) / 205.0
1118            + 1e6 / d.verify.rate;
1119        assert!((u::slot_crypto_us(d, 4) - expect).abs() < 1e-6);
1120        println!("total {:?}", plan.total);
1121    }
1122    #[test]
1123    fn ubft_extra_verifiers_add_cost_without_inventing_service_capacity() {
1124        use rhdl_fpga_planner::{Component, Cost};
1125        let share = Cost { lut: 2_000_000, dsp: 20_000, bram36: 20_000, uram: 20_000 };
1126        let cx = Context::default();
1127        let one = super::ubft::Ed25519UbftVerify { machines: 1 }.plan(&share, &cx).unwrap();
1128        let four = super::ubft::Ed25519UbftVerify { machines: 4 }.plan(&share, &cx).unwrap();
1129        assert_eq!(one.rate, four.rate);
1130        assert_eq!((one.in_flight, four.in_flight), (16.0, 16.0));
1131        assert!(four.cost.lut > one.cost.lut);
1132        let flags = super::ubft::Ed25519UbftVerify { machines: 4 }.build_flags(&four);
1133        assert!(flags.contains(&("RHDL_UBFT_VERIFY_MACHINES".into(), "4".into())));
1134    }
1135
1136}