Skip to main content

Module cache_bank

Module cache_bank 

Source
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 output

The “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_write output is turned into a RAM port by bank_port_kernel in crate::slice.
  • Reads come from the signer, which knows CACHE_READ_LATENCY and 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§

CacheBank
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.