Skip to main content

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}