rhdl_dsig_scalable_core/cache_bank.rs
1//! One bank of a slice's key cache: 34,816 rows of one 18-byte chain node.
2//!
3//! # The problem: keys are made long before they are spent
4//!
5//! The [key factory](crate::slice_factory) finishes one one-time key every 68
6//! clocks. A key may not sign anything until its *batch certificate* (the
7//! Ed25519 signature over the batch's Merkle root) comes back from the shared
8//! [cert unit](crate::cert_unit), and that takes many batches' worth of time.
9//! Until then the key has to wait somewhere on chip. That waiting room is the
10//! key cache.
11//!
12//! What waits is the part of the key a signature reveals. W-OTS+ with w = 4
13//! (the one-time signature scheme DSig uses) has 68 hash *chains* per key. A
14//! signature reveals one *node* of each chain: which node depends on the
15//! message digit (0, 1, 2 or 3) for that chain. So the cache stores four
16//! nodes per chain ("positions" 0 to 3), each one 18-byte hash value
17//! ([`ELEMENT_BYTES`]).
18//!
19//! # The decision: four banks, one per position
20//!
21//! The factory produces all four positions of a chain in the same clock. If
22//! they all went into one memory, that memory would need four write ports.
23//! Instead a slice has four banks, one per position. Each bank gets one write
24//! per chain, and each bank has the identical row layout. When the signer
25//! reads a chain, it presents the same row address to all four banks and keeps
26//! the node whose position matches the digit. This module is one such bank;
27//! [`Slice`](crate::slice::Slice) builds four of them.
28//!
29//! ```text
30//! key cache of one slice (4 banks; this file is one of them)
31//!
32//! row bank0 (pos 0) bank1 (pos 1) bank2 (pos 2) bank3 (pos 3)
33//! 0 slot 0 ch 0 slot 0 ch 0 slot 0 ch 0 slot 0 ch 0
34//! 1 slot 0 ch 1 slot 0 ch 1 ... ...
35//! ...
36//! 67 slot 0 ch 67
37//! 68 slot 1 ch 0 row = slot × 68 + chain
38//! ... slot = key number mod 512
39//! 34,815 slot 511 ch 67 cell = one 18-byte node
40//! ```
41//!
42//! # The power-of-two problem
43//!
44//! A slice caches 512 keys × 68 chains, one bank per chain position. RHDL's
45//! [`SyncBRAM`] always holds a power-of-two number of rows, and rounding
46//! 34,816 up to 65,536 would almost double the UltraRAM. So a bank is built from
47//! two `SyncBRAM`s whose sizes add up exactly:
48//!
49//! ```text
50//! row < 32,768 ──▶ low : SyncBRAM<[b8; 18], 15> (32,768 rows)
51//! row (16 bits) ─┤ ├─▶ mux ─▶ node
52//! row ≥ 32,768 ──▶ high : SyncBRAM<[b8; 18], 11> ( 2,048 rows)
53//! which half was read ─▶ DFF ────────────┘
54//! (then one output register)
55//! ```
56//!
57//! Address bit 15 picks the half: 32,768 is `0x8000`, so every row with bit 15
58//! set lives in `high`. The low 15 bits (for `low`) or the low 11 bits (for
59//! `high`) address the row inside the half. Rows 34,816 and up would also land
60//! in `high`, but they are never addressed.
61//!
62//! # The timing story
63//!
64//! Each half's 32,768 rows span a cascade of several UltraRAMs. A read that
65//! ripples through that cascade and straight into logic took 5.3 ns in the
66//! first Vivado run (the slice's critical path), more than a 205 MHz cycle. So
67//! each half's output passes through [`READ_PIPE`] more registers *before* the
68//! half-select mux, where Vivado folds them into the UltraRAM output and
69//! cascade registers.
70//!
71//! Folding is also how the second run went wrong. In build v5 the pipe
72//! registers had a reset, which UltraRAM cannot absorb, so one of them
73//! stayed in the fabric. Build v6 made them reset-free
74//! ([`DFF::resetless`]); Vivado then moved *both* into the 8-deep cascade,
75//! and the last UltraRAMs' unregistered output fed the mux, the signer's
76//! control and its packer across an SLR boundary: the worst path of v6
77//! (-0.916 ns, 795 of its 2,000 worst paths). So the mux result now goes
78//! through one more register, `out`, which Vivado cannot fold into the RAM
79//! (a LUT sits in front of it). Data appears [`CACHE_READ_LATENCY`] cycles
80//! after its address. Reading and writing the same row in one cycle returns the *old*
81//! contents (read-before-write).
82//!
83//! ```text
84//! cycle 0 read address on the bank's input port
85//! cycle 1 `inputs` register holds it and drives both RAM addresses
86//! cycle 2 RAM read register holds the node read_high[0] = bit 15
87//! cycle 3 low_pipe[0] / high_pipe[0] read_high[1]
88//! cycle 4 low_pipe[1] / high_pipe[1] read_high[2] ──▶ mux ──▶ out
89//! cycle 5 `out` drives the bank's output
90//! ```
91//!
92//! The "which half" bit travels through as many registers as the data, so the
93//! mux at the end always selects the half that *the same* address went to.
94//! A new address can be presented every cycle; the nodes come out one per
95//! cycle, in order, five cycles later.
96//!
97//! Rows are `slot × 68 + chain` ([`cache_address_kernel`]); rows at or beyond
98//! [`CACHE_ROWS`] are never addressed.
99//!
100//! Both halves are `SyncBRAM`s marked [`RamStyle::Ultra`] (a vendored
101//! rhdl-fpga option that emits `ram_style = "ultra"`). Without it, Vivado put
102//! each bank in ~136 block RAMs spread over the die, and the address and
103//! write-enable fan-out missed 205 MHz by 2.2 ns. The bank also registers its
104//! incoming address, data and write enable once, next to the RAMs.
105//!
106//! # Neighbors
107//!
108//! * Writes come from the [factory](crate::slice_factory): its `cache_write`
109//! output is turned into a RAM port by
110//! [`bank_port_kernel`](crate::slice::bank_port_kernel) in [`crate::slice`].
111//! * Reads come from the [signer](crate::slice_signer), which knows
112//! [`CACHE_READ_LATENCY`] and carries a token alongside each read so it
113//! knows what the returning node is for.
114//! * A slot is overwritten only after its old key was burned; that rule is
115//! enforced by the slice's admission limit
116//! ([`ADMIT_LIMIT`](crate::slice::ADMIT_LIMIT)), not by the bank. The bank
117//! itself is plain storage.
118//!
119//! [`cache_address_kernel`]: crate::slice_factory::cache_address_kernel
120
121use crate::ELEMENT_BYTES;
122use crate::slice_factory::{CACHE_ADDRESS_BITS, CACHE_ROWS};
123use rhdl::prelude::*;
124use rhdl_fpga::core::{
125 dff::DFF,
126 ram::synchronous::{In as RamIn, RamStyle, SyncBRAM, Write as RamWrite},
127};
128
129/// Extra output registers after each RAM read (see the module docs).
130///
131/// Two registers give Vivado room to absorb the UltraRAM cascade delay at
132/// 205 MHz. Changing this changes [`CACHE_READ_LATENCY`], which the signer
133/// follows automatically.
134pub const READ_PIPE: usize = 2;
135/// Cycles from presenting a read address to its node on the output: one
136/// input register, the RAM's own read register, [`READ_PIPE`], then the
137/// output register after the half-select mux.
138///
139/// With `READ_PIPE = 2` this is 5 cycles. The
140/// [signer](crate::slice_signer) sizes its in-flight read tokens by it.
141pub const CACHE_READ_LATENCY: usize = 3 + READ_PIPE;
142
143/// Registers from the RAM read to the output (RAM register + [`READ_PIPE`]).
144///
145/// This is the length of the `read_high` delay line, so the "which half" bit
146/// arrives at the mux in the same cycle as its data.
147const RAM_TO_OUTPUT: usize = 1 + READ_PIPE;
148/// Index of the last "which half" register (the one that drives the mux).
149const LAST: usize = RAM_TO_OUTPUT - 1;
150/// Index of the last data register in `low_pipe` / `high_pipe` (the one the
151/// mux reads).
152const TAIL: usize = READ_PIPE - 1;
153
154/// Rows in the low half: 2^15 = 32,768 (rows `0..32,768`).
155pub const LOW_ROWS: usize = 1 << 15;
156/// Rows in the high half: 2^11 = 2,048 (rows `32,768..34,816`).
157pub const HIGH_ROWS: usize = 1 << 11;
158
159// The two halves must hold exactly the bank's rows, no more and no less.
160const _: () = assert!(LOW_ROWS + HIGH_ROWS == CACHE_ROWS);
161// The kernel picks the half with bit 15 of a 16-bit address.
162const _: () = assert!(CACHE_ADDRESS_BITS == 16);
163
164/// One cache bank. Ports match `SyncBRAM<[b8; 18], 16>`.
165///
166/// To its user it looks like a single 65,536-row synchronous RAM with one
167/// read port and one write port (only the first 34,816 rows are real), with a
168/// read latency of [`CACHE_READ_LATENCY`] instead of 1.
169#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
170pub struct CacheBank {
171 /// Address, data and write enable, registered next to the RAMs.
172 inputs: DFF<RamIn<[b8; ELEMENT_BYTES], 16>>,
173 /// Rows `0..32,768`, addressed by the low 15 address bits. UltraRAM.
174 low: SyncBRAM<[b8; ELEMENT_BYTES], 15>,
175 /// Rows `32,768..34,816`, addressed by the low 11 address bits. UltraRAM.
176 high: SyncBRAM<[b8; ELEMENT_BYTES], 11>,
177 /// [`READ_PIPE`] registers behind the low half's read output.
178 low_pipe: [DFF<[b8; ELEMENT_BYTES]>; READ_PIPE],
179 /// [`READ_PIPE`] registers behind the high half's read output.
180 high_pipe: [DFF<[b8; ELEMENT_BYTES]>; READ_PIPE],
181 /// Delay line for "this read went to the high half", one entry per data
182 /// register after the RAM, so it reaches the mux with its data.
183 read_high: [DFF<bool>; RAM_TO_OUTPUT],
184 /// The selected node, registered after the mux (see the module docs).
185 out: DFF<[b8; ELEMENT_BYTES]>,
186}
187
188impl Default for CacheBank {
189 /// Both halves in UltraRAM, no write pending, data registers don't-care.
190 ///
191 /// The RAMs start with unknown contents; UltraRAM cannot be preloaded,
192 /// and nothing reads a row before the factory has written it.
193 fn default() -> Self {
194 Self {
195 // No write enabled and address 0 at power-up.
196 inputs: DFF::new(RamIn::<[b8; ELEMENT_BYTES], 16> {
197 read_addr: b16(0),
198 write: RamWrite::<[b8; ELEMENT_BYTES], 16> {
199 addr: b16(0),
200 value: [b8(0); ELEMENT_BYTES],
201 enable: false,
202 },
203 }),
204 // `RamStyle::Ultra` asks Vivado for UltraRAM (see module docs).
205 low: SyncBRAM::default().with_style(RamStyle::Ultra),
206 high: SyncBRAM::default().with_style(RamStyle::Ultra),
207 // Data pipes need no reset: their contents are ignored until a
208 // real read flows through.
209 low_pipe: core::array::from_fn(|_| DFF::resetless()),
210 high_pipe: core::array::from_fn(|_| DFF::resetless()),
211 read_high: core::array::from_fn(|_| DFF::new(false)),
212 out: DFF::resetless(),
213 }
214 }
215}
216
217/// Input: one read address plus one optional write per cycle. Output: the
218/// node read [`CACHE_READ_LATENCY`] cycles earlier.
219impl SynchronousIO for CacheBank {
220 type I = RamIn<[b8; ELEMENT_BYTES], 16>;
221 type O = [b8; ELEMENT_BYTES];
222 type Kernel = cache_bank_kernel;
223}
224
225/// Route each row to its half; delay both halves' data and the "which half"
226/// bit equally; select at the end.
227///
228/// * `input`: this cycle's read address and optional write (16-bit rows,
229/// 18-byte value). It is only registered here; it takes effect next cycle.
230/// * `q`: the current register and RAM outputs.
231/// * Returns the 18-byte node for the address presented
232/// [`CACHE_READ_LATENCY`] cycles ago, and the next register/RAM inputs.
233///
234/// The write goes to exactly one half (bit 15 picks it), so a row is never
235/// stored twice. Reads go to both halves at once; the mux discards the wrong
236/// one at the end.
237#[allow(clippy::needless_range_loop)]
238#[kernel]
239pub fn cache_bank_kernel(
240 _clock_reset: ClockReset,
241 input: RamIn<[b8; ELEMENT_BYTES], 16>,
242 q: CacheBankQ,
243) -> ([b8; ELEMENT_BYTES], CacheBankD) {
244 // Everything below works on last cycle's (registered) inputs.
245 let held = q.inputs;
246 // Bit 15 of the read address: true when the row lives in the high half.
247 let read_high = (held.read_addr & b16(0x8000)) != b16(0);
248 // Same test for the write address: picks which half's write enable fires.
249 let write_high = (held.write.addr & b16(0x8000)) != b16(0);
250 // Both halves see the same (truncated) addresses and data; only the
251 // write enable differs. `resize()` keeps the low 15 or 11 bits.
252 let mut d = CacheBankD {
253 low: RamIn::<[b8; ELEMENT_BYTES], 15> {
254 read_addr: held.read_addr.resize(),
255 write: RamWrite::<[b8; ELEMENT_BYTES], 15> {
256 addr: held.write.addr.resize(),
257 value: held.write.value,
258 enable: held.write.enable && !write_high,
259 },
260 },
261 high: RamIn::<[b8; ELEMENT_BYTES], 11> {
262 read_addr: held.read_addr.resize(),
263 write: RamWrite::<[b8; ELEMENT_BYTES], 11> {
264 addr: held.write.addr.resize(),
265 value: held.write.value,
266 enable: held.write.enable && write_high,
267 },
268 },
269 // Register this cycle's request for use next cycle.
270 inputs: input,
271 // Placeholders; the pipe stages are filled in just below.
272 low_pipe: q.low_pipe,
273 high_pipe: q.high_pipe,
274 read_high: q.read_high,
275 out: q.out,
276 };
277 // Shift: RAM output enters stage 0; stage k feeds stage k + 1.
278 d.low_pipe[0] = q.low;
279 d.high_pipe[0] = q.high;
280 for k in 1..READ_PIPE {
281 d.low_pipe[k] = q.low_pipe[k - 1];
282 d.high_pipe[k] = q.high_pipe[k - 1];
283 }
284 // The half-select bit enters its own delay line in step with the RAM read.
285 d.read_high[0] = read_high;
286 for k in 1..RAM_TO_OUTPUT {
287 d.read_high[k] = q.read_high[k - 1];
288 }
289 // Final mux: the oldest half-select bit picks between the two oldest
290 // data registers. Both belong to the same read. Its result is
291 // registered once more before it leaves the bank.
292 d.out = if q.read_high[LAST] {
293 q.high_pipe[TAIL]
294 } else {
295 q.low_pipe[TAIL]
296 };
297 (q.out, d)
298}
299
300/// Simulation tests for the split and the read pipeline.
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 /// A recognizable test pattern for row `r` (each byte depends on `r` and
306 /// the byte position), so a wrong row or a wrong half shows up at once.
307 fn row(r: u128) -> [b8; ELEMENT_BYTES] {
308 core::array::from_fn(|i| b8((r * 7 + i as u128 * 13) & 0xff))
309 }
310
311 /// Run one full clock (low then high edge) with read address `read` and
312 /// an optional `(row, value)` write; returns the bank output after it.
313 fn step(
314 bank: &CacheBank,
315 state: &mut <CacheBank as Synchronous>::S,
316 read: u128,
317 write: Option<(u128, [b8; ELEMENT_BYTES])>,
318 ) -> [b8; ELEMENT_BYTES] {
319 // With no write, present address 0 with the enable low.
320 let (addr, value) = write.unwrap_or((0, [b8(0); ELEMENT_BYTES]));
321 let input = RamIn::<[b8; ELEMENT_BYTES], 16> {
322 read_addr: b16(read),
323 write: RamWrite::<[b8; ELEMENT_BYTES], 16> {
324 addr: b16(addr),
325 value,
326 enable: write.is_some(),
327 },
328 };
329 bank.sim(clock_reset(clock(false), reset(false)), input, state);
330 bank.sim(clock_reset(clock(true), reset(false)), input, state)
331 }
332
333 /// Present one read (and optional write), then idle until its data is out.
334 fn read(
335 bank: &CacheBank,
336 state: &mut <CacheBank as Synchronous>::S,
337 row: u128,
338 write: Option<(u128, [b8; ELEMENT_BYTES])>,
339 ) -> [b8; ELEMENT_BYTES] {
340 let mut out = step(bank, state, row, write);
341 // Idle cycles until the read has crossed the whole pipeline.
342 for _ in 1..CACHE_READ_LATENCY {
343 out = step(bank, state, 0, None);
344 }
345 out
346 }
347
348 /// Rows at both edges of the 32,768 split (and the last real row) read
349 /// back correctly, singly and back to back, and same-cycle read/write is
350 /// read-before-write.
351 #[test]
352 fn rows_on_both_sides_of_the_split_read_back_after_the_pipeline() {
353 let bank = CacheBank::default();
354 let mut state = bank.init();
355 // First rows, last low row, first high rows, last real row.
356 let rows = [0, 1, 32_767, 32_768, 32_769, 34_815];
357 for &r in &rows {
358 step(&bank, &mut state, 0, Some((r, row(r))));
359 }
360 for &r in &rows {
361 assert_eq!(read(&bank, &mut state, r, None), row(r), "row {r}");
362 }
363 // Back-to-back reads stream one node per cycle, in order.
364 let mut out = Vec::new();
365 for (k, &r) in rows
366 .iter()
367 .chain([0_u128; CACHE_READ_LATENCY - 1].iter())
368 .enumerate()
369 {
370 let o = step(&bank, &mut state, r, None);
371 // Skip the outputs that predate the first read.
372 if k + 1 >= CACHE_READ_LATENCY {
373 out.push(o);
374 }
375 }
376 assert_eq!(out, rows.map(row).to_vec());
377 // Read-before-write: a same-cycle write is not visible to that read.
378 let fresh = [b8(0xAA); ELEMENT_BYTES];
379 assert_eq!(read(&bank, &mut state, 1, Some((1, fresh))), row(1));
380 assert_eq!(read(&bank, &mut state, 1, None), fresh);
381 }
382
383 /// The generated Verilog asks for UltraRAM for both halves and does not
384 /// try to preload them (UltraRAM has no initial contents).
385 #[test]
386 fn both_halves_lower_to_ultraram_without_initial_contents() -> Result<(), RHDLError> {
387 let hdl = CacheBank::default()
388 .descriptor("bank".into())?
389 .hdl_unchecked()?
390 .modules
391 .pretty();
392 assert_eq!(hdl.matches("ram_style = \"ultra\"").count(), 2, "{hdl}");
393 // Registers have `initial` blocks for their reset values; the memories
394 // must have no preloaded rows.
395 assert!(!hdl.contains("mem[0] ="), "UltraRAM cannot be preloaded");
396 Ok(())
397 }
398}