Expand description
One bank of a slice’s key cache: 34,816 rows of one 18-byte chain node.
§The problem: keys are made long before they are spent
The key factory finishes one one-time key every 68 clocks. A key may not sign anything until its batch certificate (the Ed25519 signature over the batch’s Merkle root) comes back from the shared cert unit, and that takes many batches’ worth of time. Until then the key has to wait somewhere on chip. That waiting room is the key cache.
What waits is the part of the key a signature reveals. W-OTS+ with w = 4
(the one-time signature scheme DSig uses) has 68 hash chains per key. A
signature reveals one node of each chain: which node depends on the
message digit (0, 1, 2 or 3) for that chain. So the cache stores four
nodes per chain (“positions” 0 to 3), each one 18-byte hash value
(ELEMENT_BYTES).
§The decision: four banks, one per position
The factory produces all four positions of a chain in the same clock. If
they all went into one memory, that memory would need four write ports.
Instead a slice has four banks, one per position. Each bank gets one write
per chain, and each bank has the identical row layout. When the signer
reads a chain, it presents the same row address to all four banks and keeps
the node whose position matches the digit. This module is one such bank;
Slice builds four of them.
key cache of one slice (4 banks; this file is one of them)
row bank0 (pos 0) bank1 (pos 1) bank2 (pos 2) bank3 (pos 3)
0 slot 0 ch 0 slot 0 ch 0 slot 0 ch 0 slot 0 ch 0
1 slot 0 ch 1 slot 0 ch 1 ... ...
...
67 slot 0 ch 67
68 slot 1 ch 0 row = slot × 68 + chain
... slot = key number mod 512
34,815 slot 511 ch 67 cell = one 18-byte node§The power-of-two problem
A slice caches 512 keys × 68 chains, one bank per chain position. RHDL’s
[SyncBRAM] always holds a power-of-two number of rows, and rounding
34,816 up to 65,536 would almost double the UltraRAM. So a bank is built from
two SyncBRAMs whose sizes add up exactly:
row < 32,768 ──▶ low : SyncBRAM<[b8; 18], 15> (32,768 rows)
row (16 bits) ─┤ ├─▶ mux ─▶ node
row ≥ 32,768 ──▶ high : SyncBRAM<[b8; 18], 11> ( 2,048 rows)
which half was read ─▶ DFF ────────────┘
(then one output register)Address bit 15 picks the half: 32,768 is 0x8000, so every row with bit 15
set lives in high. The low 15 bits (for low) or the low 11 bits (for
high) address the row inside the half. Rows 34,816 and up would also land
in high, but they are never addressed.
§The timing story
Each half’s 32,768 rows span a cascade of several UltraRAMs. A read that
ripples through that cascade and straight into logic took 5.3 ns in the
first Vivado run (the slice’s critical path), more than a 205 MHz cycle. So
each half’s output passes through READ_PIPE more registers before the
half-select mux, where Vivado folds them into the UltraRAM output and
cascade registers.
Folding is also how the second run went wrong. In build v5 the pipe
registers had a reset, which UltraRAM cannot absorb, so one of them
stayed in the fabric. Build v6 made them reset-free
([DFF::resetless]); Vivado then moved both into the 8-deep cascade,
and the last UltraRAMs’ unregistered output fed the mux, the signer’s
control and its packer across an SLR boundary: the worst path of v6
(-0.916 ns, 795 of its 2,000 worst paths). So the mux result now goes
through one more register, out, which Vivado cannot fold into the RAM
(a LUT sits in front of it). Data appears CACHE_READ_LATENCY cycles
after its address. Reading and writing the same row in one cycle returns the old
contents (read-before-write).
cycle 0 read address on the bank's input port
cycle 1 `inputs` register holds it and drives both RAM addresses
cycle 2 RAM read register holds the node read_high[0] = bit 15
cycle 3 low_pipe[0] / high_pipe[0] read_high[1]
cycle 4 low_pipe[1] / high_pipe[1] read_high[2] ──▶ mux ──▶ out
cycle 5 `out` drives the bank's outputThe “which half” bit travels through as many registers as the data, so the mux at the end always selects the half that the same address went to. A new address can be presented every cycle; the nodes come out one per cycle, in order, five cycles later.
Rows are slot × 68 + chain (cache_address_kernel); rows at or beyond
CACHE_ROWS are never addressed.
Both halves are SyncBRAMs marked [RamStyle::Ultra] (a vendored
rhdl-fpga option that emits ram_style = "ultra"). Without it, Vivado put
each bank in ~136 block RAMs spread over the die, and the address and
write-enable fan-out missed 205 MHz by 2.2 ns. The bank also registers its
incoming address, data and write enable once, next to the RAMs.
§Neighbors
- Writes come from the factory: its
cache_writeoutput is turned into a RAM port bybank_port_kernelincrate::slice. - Reads come from the signer, which knows
CACHE_READ_LATENCYand carries a token alongside each read so it knows what the returning node is for. - A slot is overwritten only after its old key was burned; that rule is
enforced by the slice’s admission limit
(
ADMIT_LIMIT), not by the bank. The bank itself is plain storage.
Structs§
- Cache
Bank - One cache bank. Ports match
SyncBRAM<[b8; 18], 16>.
Constants§
- CACHE_
READ_ LATENCY - Cycles from presenting a read address to its node on the output: one
input register, the RAM’s own read register,
READ_PIPE, then the output register after the half-select mux. - HIGH_
ROWS - Rows in the high half: 2^11 = 2,048 (rows
32,768..34,816). - LOW_
ROWS - Rows in the low half: 2^15 = 32,768 (rows
0..32,768). - READ_
PIPE - Extra output registers after each RAM read (see the module docs).
Functions§
- cache_
bank_ kernel - Route each row to its half; delay both halves’ data and the “which half” bit equally; select at the end.