rhdl_ed25519_scalable_core/frontier/costs.rs
1//! Block costs: the device, each block's LUT/DSP/BRAM/URAM, the measured
2//! ([`vivado_costs`]) and projected ([`slimmed_costs`]) tables, and the
3//! adjustments routed builds taught us (multiplier choice, constant ROM).
4
5use super::*;
6
7/// Device limits of the Alveo U280 (XCU280) that [`frontier`] packs builds
8/// into. All fields except `lut` are the physical device totals.
9#[derive(Clone, Copy, Debug)]
10pub struct Device {
11 /// CLB LUT budget (the project cap, not the device total).
12 pub lut: u32,
13 /// DSP48E2 blocks.
14 pub dsp: u32,
15 /// BRAM36 blocks.
16 pub bram36: u32,
17 /// URAM blocks.
18 pub uram: u32,
19 /// Clock in MHz; converts cycles per operation into operations per second.
20 pub mhz: f64,
21}
22
23impl Device {
24 /// The device's limits as a share.
25 pub(super) fn share(&self) -> Share {
26 Share { lut: self.lut, dsp: self.dsp, bram36: self.bram36, uram: self.uram }
27 }
28 /// The U280 at a LUT budget and the 205 MHz design clock. The DSP, BRAM36
29 /// and URAM counts are the XCU280 totals (same values as [`U280_DSP`],
30 /// [`U280_BRAM36`], [`U280_URAM`]).
31 pub fn u280(lut: u32) -> Self {
32 Device {
33 lut,
34 dsp: 9_024,
35 bram36: 2_016,
36 uram: 960,
37 mhz: 205.0,
38 }
39 }
40}
41
42/// Resources of one block, or a sum of blocks.
43#[derive(Clone, Copy, Debug, Default, PartialEq)]
44pub struct Cost {
45 /// CLB LUTs.
46 pub lut: u32,
47 /// DSP48E2 blocks.
48 pub dsp: u32,
49 /// BRAM36 blocks.
50 pub bram36: u32,
51 /// URAM blocks.
52 pub uram: u32,
53}
54
55impl Cost {
56 /// `self` plus `n` instances of `other`, field by field. Used to build a
57 /// module total from per-instance block costs.
58 pub(super) fn add(self, other: Cost, n: u32) -> Cost {
59 Cost {
60 lut: self.lut + other.lut * n,
61 dsp: self.dsp + other.dsp * n,
62 bram36: self.bram36 + other.bram36 * n,
63 uram: self.uram + other.uram * n,
64 }
65 }
66 /// Whether every resource is within the device limits.
67 pub(super) fn fits(&self, d: &Device) -> bool {
68 self.lut <= d.lut && self.dsp <= d.dsp && self.bram36 <= d.bram36 && self.uram <= d.uram
69 }
70}
71
72/// Per-instance block costs used by the frontier and the system planner.
73/// Two tables exist: [`vivado_costs`] (as built) and [`slimmed_costs`]
74/// (projected memory layout).
75#[derive(Clone, Copy, Debug)]
76pub struct Costs {
77 /// Fixed-base signing machine.
78 pub fb_machine: Cost,
79 /// SHA-512 ring.
80 pub sha_ring: Cost,
81 /// Scalar ring.
82 pub scalar_ring: Cost,
83 /// Batch-compress machine.
84 pub cp_machine: Cost,
85 /// Signing controller.
86 pub sign_controller: Cost,
87 /// Signing controller of a fused build (no compress machine: no credit
88 /// pool or compress dispatch).
89 pub sign_controller_fused: Cost,
90 /// Verify field machine, any key (derived: signing machine plus a
91 /// per-context variable-base table and its write path).
92 pub verify_machine_strict: Cost,
93 /// Verify field machine for loaded keys (derived: signing machine plus a
94 /// writable key table in URAM and its write path).
95 pub verify_machine_known_key: Cost,
96 /// Verify controller (derived from the signing controller).
97 pub verify_controller: Cost,
98 /// Scalar ring of the verify module (`k = H mod l` only).
99 pub verify_scalar_ring: Cost,
100 /// Key-loader machine of a known-key verify module (decodes `A` and
101 /// builds its table; one per module).
102 pub key_loader: Cost,
103 /// The fused small signer, `Ed25519SignCoreSmall` (1/1/1/0 with its own
104 /// SHA ring), as one block: measured in Vivado (routed). It is the
105 /// cheapest way to sign at DSig's certificate rate.
106 pub small_signer: Cost,
107 /// The block-RAM layout of the field machines, if this table's own
108 /// layout is a different one. A build may put some machines in it when
109 /// UltraRAM runs out (see [`sign_cost_within`]).
110 pub bram_layout: Option<BramLayout>,
111 /// Change per field machine when it uses the DSP-lean multiplier, as
112 /// `(LUT delta, DSP delta)` (17 x 26 tiles chained by the DSP's 17-bit
113 /// cascade shift: 150 DSPs instead of 225, rows summed in fabric). `None`
114 /// when the option is unavailable for this cost table; the planners then
115 /// skip [`Multiplier::Lean150`].
116 pub lean_mul: Option<(i32, i32)>,
117 /// Where the numbers came from (printed in reports).
118 pub source: &'static str,
119}
120
121/// Field-machine costs in the block-RAM (as-built) layout: the fallback for
122/// machines that do not fit the UltraRAM layout's URAM.
123#[derive(Clone, Copy, Debug)]
124pub struct BramLayout {
125 /// Fixed-base signing machine.
126 pub fb_machine: Cost,
127 /// Batch-compress machine.
128 pub cp_machine: Cost,
129 /// Verify machine, any key.
130 pub verify_machine_strict: Cost,
131 /// Verify machine, loaded keys.
132 pub verify_machine_known_key: Cost,
133 /// Key loader of a known-key verify module.
134 pub key_loader: Cost,
135}
136
137/// Which multiplier a module's field machines use.
138#[derive(Clone, Copy, Debug, PartialEq, Eq)]
139pub enum Multiplier {
140 /// 225 DSPs (15 x 15 tiles of 17 x 22), built and measured.
141 Dsp225,
142 /// 150 DSPs (15 x 10 tiles of 17 x 26) plus fabric row adders (projected).
143 Lean150,
144}
145
146impl Multiplier {
147 /// Human-readable label for reports.
148 pub fn name(self) -> &'static str {
149 match self {
150 Multiplier::Dsp225 => "225-DSP multiplier",
151 Multiplier::Lean150 => "150-DSP Karatsuba multiplier",
152 }
153 }
154}
155
156/// The cost table `c` rebuilt for multiplier `m`. Returns `None` for
157/// [`Multiplier::Lean150`] when `c.lean_mul` is `None`, so callers can skip
158/// that option.
159/// The cost table for multiplier `m`, or `c` itself when `m` is not
160/// offered (for callers outside this module).
161pub fn with_mul_pub(c: &Costs, m: Multiplier) -> Costs {
162 with_mul(c, m).unwrap_or(*c)
163}
164
165/// LUTs the constant ROM (`machine::ConstRom`, fix14) adds to each field
166/// machine: a literal and a mux per read port instead of LUT RAM. Routed
167/// on the 1,1,1,1 verifier (three field machines: verify, compress, key
168/// loader): 84,435 LUT against 82,184 on fix12 (007zzr, 007zzi), so 750
169/// each. One core, so provisional; the signers' machines hold other
170/// constants and are not yet routed on fix14.
171pub const CONST_ROM_LUT: i32 = 750;
172
173/// `c` with every field machine's LUTs changed by `dlut` (the multiplier
174/// and the constant ROM live in every field machine; rings and
175/// controllers keep their costs).
176pub(super) fn per_field_machine(c: &Costs, dlut: i32, ddsp: i32) -> Costs {
177 let f = |x: Cost| Cost { lut: (x.lut as i32 + dlut) as u32, dsp: (x.dsp as i32 + ddsp) as u32, ..x };
178 Costs {
179 fb_machine: f(c.fb_machine),
180 cp_machine: f(c.cp_machine),
181 verify_machine_strict: f(c.verify_machine_strict),
182 verify_machine_known_key: f(c.verify_machine_known_key),
183 key_loader: f(c.key_loader),
184 // The fallback layouts hold the same machines.
185 bram_layout: c.bram_layout.map(|a| BramLayout {
186 fb_machine: f(a.fb_machine),
187 cp_machine: f(a.cp_machine),
188 verify_machine_strict: f(a.verify_machine_strict),
189 verify_machine_known_key: f(a.verify_machine_known_key),
190 key_loader: f(a.key_loader),
191 }),
192 ..*c
193 }
194}
195
196/// The cost table for RTL before fix14 (constant banks in LUT RAM), to
197/// compare routed cores of that RTL with the table.
198pub fn before_const_rom(c: &Costs) -> Costs {
199 per_field_machine(c, -CONST_ROM_LUT, 0)
200}
201
202pub(super) fn with_mul(c: &Costs, m: Multiplier) -> Option<Costs> {
203 match m {
204 Multiplier::Dsp225 => Some(*c),
205 Multiplier::Lean150 => {
206 // Per-machine LUT and DSP deltas (e.g. +2,500 LUT, -75 DSP),
207 // applied to every block that holds one field multiplier.
208 let (dlut, ddsp) = c.lean_mul?;
209 Some(per_field_machine(c, dlut, ddsp))
210 }
211 }
212}
213
214/// Vivado 2023.2 out-of-context synthesis of 15/11/3/1, per instance.
215///
216/// The signing blocks (fixed-base machine, SHA ring, scalar ring, compress
217/// machine, controller) are measured. The verify blocks are derived from the
218/// signing blocks by adding estimated LUT and memory for what they add, and
219/// are not built. The lean multiplier is not offered here.
220pub fn vivado_costs() -> Costs {
221 // Measured fixed-base signing machine; the verify machines and the key
222 // loader below are expressed relative to it.
223 let fb = Cost { lut: 10_110, dsp: 225, bram36: 72, uram: 0 };
224 Costs {
225 fb_machine: fb,
226 sha_ring: Cost { lut: 4_567, dsp: 0, bram36: 1, uram: 0 },
227 scalar_ring: Cost { lut: 5_520, dsp: 23, bram36: 0, uram: 0 },
228 cp_machine: Cost { lut: 16_800, dsp: 225, bram36: 289, uram: 32 },
229 sign_controller: Cost { lut: 15_800, dsp: 0, bram36: 4, uram: 16 },
230 // Not measured in this table; the fused build is costed as the
231 // non-fused controller.
232 sign_controller_fused: Cost { lut: 15_800, dsp: 0, bram36: 4, uram: 16 },
233 // Derived: the signing machine plus about 2,500 LUT and 22 BRAM36 for
234 // the per-context variable-base table and its write path.
235 verify_machine_strict: Cost { lut: fb.lut + 2_500, dsp: 225, bram36: fb.bram36 + 22, uram: 0 },
236 // Derived: the signing machine plus about 1,500 LUT and a 22-URAM key
237 // table.
238 verify_machine_known_key: Cost { lut: fb.lut + 1_500, dsp: 225, bram36: fb.bram36, uram: 22 },
239 // Derived: assumed equal to the signing controller.
240 verify_controller: Cost { lut: 15_800, dsp: 0, bram36: 4, uram: 16 },
241 // Derived: assumed equal to the signing scalar ring (no reduce-only
242 // variant in the as-built table).
243 verify_scalar_ring: Cost { lut: 5_520, dsp: 23, bram36: 0, uram: 0 },
244 // Derived: a fixed-base machine plus 10 BRAM36 for table building.
245 key_loader: Cost { lut: fb.lut, dsp: 225, bram36: fb.bram36 + 10, uram: 0 },
246 // Routed at 4.878 ns: 26,408 CLB LUT, 248 DSP, 88 BRAM36 (+2 BRAM18).
247 small_signer: Cost { lut: 26_408, dsp: 248, bram36: 89, uram: 0 },
248 // This table is the block-RAM layout itself.
249 bram_layout: None,
250 lean_mul: None,
251 source: "as built: Vivado 2023.2 OOC, blocks routed at 4.878 ns (compress stage and controller from 15/11/3/1 synthesis; verify blocks derived)",
252 }
253}
254
255/// Verify-controller LUTs per verify machine (dispatch, free-slot word,
256/// output offer), fitted from the 6- and 24-machine routed fix9 verifiers;
257/// costed with each machine so the controller entry stays fixed. (Fitted
258/// at 716 on the 6- and 14-machine verifiers before the timing fixes.)
259pub const VERIFY_PER_MACHINE_LUT: u32 = 228;
260
261/// LUT change of a fixed-base machine inside a whole signing core against
262/// the machine routed alone (fitted, see [`slimmed_costs`]): synthesis
263/// shares or trims logic across the machine's boundary.
264pub const FB_IN_CORE_LUT: i32 = -1_505;
265
266/// A routed fixed-base machine's cost inside a whole core.
267pub(super) fn in_core(c: Cost) -> Cost {
268 Cost { lut: (c.lut as i32 + FB_IN_CORE_LUT) as u32, ..c }
269}
270
271/// Specialized modules: the memory layout of
272/// [`MachineMemory::SPECIALIZED`](crate::machine::MachineMemory) (banks M
273/// and A in UltraRAM, constants in a LUT ROM, the compress machine's input
274/// bank in UltraRAM), as built and **routed**: Vivado 2023.2 out of context
275/// at 4.878 ns, every block meeting timing, on the fix9 RTL (the timing
276/// fixes through e0c9caa; `measure/results.tsv`, 2026-10-18):
277///
278/// | Block | LUT | DSP | BRAM36 | URAM | WNS (ns) |
279/// |---|---:|---:|---:|---:|---:|
280/// | fixed-base machine | 14,615 | 225 | 24.5 | 32 | +0.848 |
281/// | compress machine | 16,569 | 225 | 1.5 | 64 | +0.679 |
282/// | known-key verify machine | 13,863 | 225 | 24.5 | 56 | +0.609 |
283/// | aux machine (key loader) | 14,518 | 225 | 9.5 | 48 | +0.863 |
284/// | SHA ring | 4,315 | 0 | 0 | 0 | +1.995 |
285/// | small signer (1/1/1/0, fix4) | 29,055 | 248 | 40 | 32 | +0.306 |
286/// | 150-DSP Karatsuba variants (before the fixes) | +4,367..4,441 | −75 | = | = | +0.056..+0.470 |
287///
288/// BRAM18 halves are rounded up. The scalar ring and the controllers are
289/// fitted to routed whole cores (signers, verifiers; see the entries). Still
290/// **derived, not built**: the strict verify machine (fixed-base machine
291/// plus 2,500 LUT and 22 BRAM36); the reduce-only verify scalar ring is not
292/// built, so verifiers are costed with the built signing ring.
293pub fn slimmed_costs() -> Costs {
294 let v = vivado_costs();
295 // Blocks routed on the fix9 RTL (Vivado 2023.2 OOC, 4.878 ns,
296 // 2026-10-18; `measure/results.tsv` jobs 007zq-007zzb), every one with
297 // positive slack: fb machine 14,615 LUT (WNS +0.848), verify machine
298 // 13,863 (+0.609), compress machine 16,569 (+0.679), key loader 14,518
299 // (+0.863), SHA ring 4,315 (+1.995).
300 let fb = Cost { lut: 14_615, dsp: 225, bram36: 25, uram: 32 };
301 let fix13 = Costs {
302 // In a whole core a fixed-base machine costs FB_IN_CORE_LUT less
303 // than routed alone (fitted with the rings and controller below).
304 fb_machine: in_core(fb),
305 sha_ring: Cost { lut: 4_315, ..v.sha_ring },
306 cp_machine: Cost { lut: 16_569, dsp: 225, bram36: 2, uram: 64 },
307 verify_machine_strict: Cost { lut: fb.lut + 2_500, bram36: fb.bram36 + 22, ..fb },
308 // Routed block 13,863 plus 228: the verify controller's per-machine
309 // dispatch and offer logic (fitted, see `sign_controller`).
310 verify_machine_known_key: Cost { lut: 13_863 + VERIFY_PER_MACHINE_LUT, dsp: 225, bram36: 25, uram: 56 },
311 // Signing: rings, controller and the fixed-base machines' in-core
312 // change, fitted by least squares to four routed signers (2026-10-20;
313 // `measure/accuracy.tsv`): 8,0,2,1 u6l0 (fix9), 12,0,3,1 u5l2
314 // (fix10), 4,0,1,1 and 8,0,3,1 (fix13). The routed total less the
315 // routed blocks leaves 13,702, 15,708, 12,003 and 21,675 LUT; the fit
316 // is a controller of 10,099, 7,871 per scalar ring and -1,505 per
317 // fixed-base machine, and lands within 102 LUT of all four. A ring
318 // routed alone is 6,194 (fix10), so ~1,700 per ring is its dispatch
319 // and arbitration in the core. The fused 8,0,2,0 (fix13) leaves
320 // 6,817: a controller of 3,115 with no credit pool or compress
321 // dispatch (one core, in sample). The previous fit (2,408 per ring,
322 // 8,886 controller, two cores) under-predicted 8,0,3,1 by 3.6 % and
323 // over-predicted 8,0,2,0 by 5.5 %.
324 //
325 // Verifying: fitted to the fix9 RTL (2026-10-18). The routed total
326 // less the routed blocks (machines in their built layouts, compress
327 // machine, key loader, 1,500-LUT SHA client port) leaves 13,702 LUT
328 // for sign 8,0,2,1 u6l0 (two scalar rings), 16,110 for sign 12,0,3,1
329 // u5l2 (three), 34,435 for verify 6,0,1,1 (one) and 40,954 for verify
330 // 24,0,2,1 u2l19 (two). That gives a scalar ring of 2,408 LUT, a
331 // signing controller of 8,886 and a verify controller of 30,657 + 228
332 // per machine. Before the timing fixes (six cores, 2026-10-17):
333 // 1,590, 11,240 and 17,540 + 716; the verifier's limb-serial decode
334 // and registered credit, output and dispatch paths account for most
335 // of its growth. The 24-machine verifier missed timing (-5.763 ns)
336 // and may carry replication; the 9,0,2,1 signer (fix7, +0.114 ns) is
337 // the out-of-sample check.
338 scalar_ring: Cost { lut: 7_871, ..v.scalar_ring },
339 sign_controller: Cost { lut: 10_099, ..v.sign_controller },
340 sign_controller_fused: Cost { lut: 3_115, ..v.sign_controller },
341 verify_controller: Cost { lut: 30_657, ..v.verify_controller },
342 // The verifier uses the signing scalar ring (no reduce-only ring
343 // yet); its in-core cost is fitted with the verify controller, which
344 // keeps the verify fit's 2,408.
345 verify_scalar_ring: Cost { lut: 2_408, ..v.scalar_ring },
346 key_loader: Cost { lut: 14_518, dsp: 225, bram36: 10, uram: 48 },
347 // Routed 2026-10-17 (fix4: registered SHA ready), WNS +0.306.
348 small_signer: Cost { lut: 29_055, dsp: 248, bram36: 40, uram: 32 },
349 // The 150-DSP Karatsuba multiplier (`fe17_kara`), measured in all four
350 // machines before the timing fixes: +4,367 to +4,441 LUT and -75 DSP
351 // each.
352 lean_mul: Some((4_440, -75)),
353 // Replicated machines that do not fit the UltraRAM layout use the
354 // block-RAM fallback (`MachineMemory::BRAM_BANKS`): banks M and A
355 // move to block RAM (+32 BRAM36, -32 URAM); a verify machine's key
356 // tables stay in UltraRAM. Routed on the fix9 RTL (2026-10-18): fb
357 // 14,615 LUT / 56.5 BRAM36 / 0 URAM, WNS +0.544; verify 13,866 / 56.5
358 // / 24, WNS +0.790. The other entries are unused (only replicated
359 // machines fall back).
360 bram_layout: Some(BramLayout {
361 fb_machine: in_core(Cost { lut: 14_615, dsp: 225, bram36: 57, uram: 0 }),
362 cp_machine: v.cp_machine,
363 verify_machine_strict: v.verify_machine_strict,
364 verify_machine_known_key: Cost { lut: 13_866 + VERIFY_PER_MACHINE_LUT, dsp: 225, bram36: 57, uram: 24 },
365 key_loader: v.key_loader,
366 }),
367 source: "specialized memory layout: blocks routed on the fix9 RTL (Vivado 2023.2 OOC, 4.878 ns, 2026-10-18); signing rings, controllers and in-core machine change fitted to five routed signers (fix9-fix13), verify controller to the fix9 verifiers; fix14 constant ROM +750 LUT per field machine (one routed core)",
368 ..v
369 };
370 // Today's RTL (fix14) builds the constant banks as ROM logic.
371 per_field_machine(&fix13, CONST_ROM_LUT, 0)
372}