Multi-Rate Buffer (mr_buffer_et) - QICK Firmware
The Multi-Rate buffer with External Trigger (mr_buffer_et) is a
small on-chip capture buffer that taps a readout’s undecimated data
stream – the downconverted but not-yet-decimated samples, straight out of
the digital mixer, before the readout’s FIR/decimate-by-N stage. This is in
contrast to Averager + Buffer (axis_avg_buffer) - QICK Firmware’s raw buffer and the DDR4 buffer
(axis_buffer_ddr_v1), which both see the decimated stream. Because it
sits upstream of decimation, mr_buffer_et sees the full ADC-rate signal
(useful for looking at spurs, noise, or transient/settling behavior that
decimation would filter out), at the cost of a much smaller capture depth
(on-chip BRAM, not DDR4). The module lives in the qick firmware
repository under firmware/ip/mr_buffer_et/ and is exposed to Python
through qick.drivers.readout.MrBufferEt.
Note
The RTL under firmware/ip/mr_buffer_et/src/ (mr_buffer_et.sv) is
a SystemVerilog rewrite whose file header identifies it as VLNV
qick:ip:mr_buffer_et:1.2.0 / @version 1.2. However, as of this
writing the packaged component.xml in the same directory (Xilinx IP
packager metadata) still lists the legacy VHDL sources
(mr_buffer_v1_0.vhd and friends) and reports IP version 1.1, and
the Python driver’s bindto list (qick_lib/qick/drivers/readout.py)
only recognizes VLNVs ending in :1.0 and :1.1. Board designs
checked in this repository (e.g.
firmware/projects/qick_tprocv2_111_standard/bd_2023-1.tcl) also
instantiate QICK:QICK:mr_buffer_et:1.1. This document describes the
behavior of the SystemVerilog source in src/ (which is what a fresh
IP-XACT repackage would pick up), and calls out anywhere that source
adds behavior (the STATUS/capture_done register, notably) that
the current driver does not yet use. It was not verified against the
actual synthesized bitstream of any specific board release – the two
register fields the driver does use (dw_capture_reg,
dr_start_reg) are unchanged between the VHDL and SystemVerilog
implementations, so this is not expected to matter in practice.
1. General Description
mr_buffer_et (top level mr_buffer_et.sv, wrapping the core
mr_buffer.sv) captures NM parallel channels of B bits each into
NM independent BRAMs (depth 2**N per BRAM) at full write-side rate
(s_axis_aclk). A capture is armed in software (dw_capture_reg=1)
and then started by an external hardware trigger input; once the
window fills, the captured data is drained out as a single B-bit
AXI4-Stream on the read side (m_axis_aclk) when software pulses
dr_start_reg=1. Per the module’s own header comment
(firmware/ip/mr_buffer_et/src/mr_buffer_et.sv), the intended flow is:
SW writes
dw_capture_reg=1– arm the writer.External
triggerfires – BRAMs fill with2**Nsamples/channel.SW writes
dr_start_reg=1– stream the whole window out.AXI-DMA S2MM drains
M_AXISto memory (read burst =NM*2**Nbeats, ending intlast).
The NM parallel input channels exist because the source data arrives
faster than one sample per fabric clock: per the QICK demo notebook
(qick_demos/08_Special_buffers.ipynb), the MR buffer “is fed by the
downconverted but undecimated data stream coming out of the readout, so
you get 8 samples per fabric tick, or 1 sample per ADC tick.” This lines
up with the RTL: axis_dyn_readout_v1’s downsampling ratio is 8
(DOWNSAMPLING = 8 on AxisDynReadoutV1 in
qick_lib/qick/drivers/readout.py), and the default synthesis parameter
is NM=8 – so in typical firmware images, one fabric-clock beat of the
undecimated stream carries all NM samples that will land at ADC
rate in real time, and mr_buffer_et writes all NM of them into
NM separate BRAMs in that same beat.
In board designs (see e.g.
firmware/projects/qick_tprocv2_111_standard/bd_2023-1.tcl),
mr_buffer_et sits downstream of a many-to-one AXI4-Stream switch
(axis_switch_mr) whose inputs are the m0_axis ports of the board’s
dynamic readouts (axis_dyn_readout_v1) – the undecimated tap; compare
with the m1_axis port of the same readouts, which is the decimated
stream that feeds Averager + Buffer (axis_avg_buffer) - QICK Firmware. Its own m00_axis output feeds an
AXI-DMA (axi_dma_mr) that drains captured data to PS-side (CPU)
memory. In that same board design the instance is parameterized with
B=32 (a packed 16-bit I / 16-bit Q sample) and N=10 (1024-deep
per-channel BRAM), leaving NM at its RTL default of 8 – giving a
total capacity of 8*1024 = 8192 I/Q samples.
2. Synthesis Parameters
Parameter |
RTL Default |
Description |
|---|---|---|
|
8 |
Number of parallel memories/channels captured per write-side clock
( |
|
8 |
Address width of each channel’s BRAM; depth is |
|
16 |
Data width, in bits, of one sample in one channel
( |
|
32 |
AXI4-Lite data width (control-register bus). |
|
6 |
AXI4-Lite address width. With 16 32-bit registers this must be at
least |
|
0 |
When 1, |
The RTL defaults above (NM=8, N=8, B=16) are not what real
boards use – e.g. qick_tprocv2_111_standard overrides B=32 and
N=10 (see Section 1) while leaving NM at its default of 8. Total
capture depth is always cfg['maxlen'] = NM * 2**N samples (this is
also how the Python driver computes it – see Section 5).
3. Datapath
Top-level block diagram: NM independent capture/storage lanes
(axis_to_bram_trig + bram_tdp) on the write-side clock,
drained through a single bram_to_axis_nt reader on the read-side
clock – matches mr_buffer.sv’s own header description exactly.
3.1 Write side – axis_to_bram_trig (one instance per channel)
mr_buffer.sv slices the wide s_axis_tdata (NM*B bits) into
NM per-channel B-bit slices and instantiates one
axis_to_bram_trig plus one true-dual-port BRAM (bram_tdp) per
channel. Each axis_to_bram_trig (firmware/ip/mr_buffer_et/src/axis_to_bram_trig.sv)
runs a 4-state FSM:
INIT_ST -> TRIGGER_ST -> CAPTURE_ST -> END_ST
INIT_ST: idle; waits for
capture_reg=1(the synchronizeddw_capture_reg); counters held cleared.TRIGGER_ST: armed, waiting for a
triggerpulse (the arm can still be cancelled here by droppingcapture_reg).CAPTURE_ST: accepts AXI4-Stream input (
s_axis_treadyasserted) and moves data from a small internal FIFO into the channel’s BRAM. Retires toEND_STeither when the capture window fills (2**Nsamples written) orcapture_regdrops (an abort).END_ST: stops accepting new stream beats but keeps draining whatever is still queued in the FIFO into BRAM, so the tail of a capture is never lost when
capture_regdrops mid-stream. Holdso_capture_done=1(a level, not a sticky bit) until software de-assertscapture_regand the internal FIFO is empty, at which point it returns toINIT_ST.
An internal 16-deep synchronous FIFO (fifo_sync, first-word-fall-through)
decouples the AXI4-Stream handshake from the BRAM write pipeline; the
module’s own header comments state the write-side invariants explicitly:
every beat actually accepted (tvalid && tready, not tvalid alone)
is written to the FIFO exactly once, every word popped from the FIFO while
the window still has room is committed to BRAM (the write pipeline is
driven off fifo_rd_en itself, so a state transition can’t cancel an
in-flight write), and once the window is full further FIFO words are still
popped (and discarded) rather than stalling, so the FSM can still reach
fifo_empty and retire. s_axis_tready from all NM channels is
AND-reduced (mr_buffer.sv) into the block’s single s_axis_tready
output – all channels must have room, since they’re written from the same
wide input word.
o_capture_done from each of the NM per-channel FSMs is AND-reduced
in mr_buffer.sv into o_capture_done, which crosses into
s_axi_aclk through a 2-stage cdc_bit_sync and lands in the
AXI4-Lite STATUS register (register index 2, bit 0 – see Section 4).
3.3 Clock-domain crossings
Three control signals cross clock domains via cdc_gray_sync (a 2-stage,
per-bit synchronizer – despite the name, it does not gray-decode; see
cdc_gray_sync.sv, used here with N=2, M=1):
dw_capture_reg(AXI-Lite domain) ->s_axis_aclk.triggerOR’d with the debug force-trigger bit (debug_reg[0]) ->s_axis_aclk.dr_start_reg(AXI-Lite domain) ->m_axis_aclk.
and o_capture_done (from s_axis_aclk, the write-side FSMs) crosses
back into s_axi_aclk via a 2-stage cdc_bit_sync in
mr_buffer_et.sv before landing in the STATUS register.
3.4 Debug forcing
The 32-bit debug_reg (register 15, RW) has two functional bits
(mr_buffer.sv): bit 0 ORs a software-forced pulse into the hardware
trigger input before CDC, and bit 1 forces s_axis_tready high
regardless of the AND-reduced per-channel ready signals
(s_axis_tready = (&s_axis_tready_i) | s_axis_tready_force) – useful
for driving the input stream during bring-up without a real trigger
source or without every channel individually asserting ready.
4. Register Map
The AXI4-Lite interface is a generic 16-register, 32-bit-per-register
slave (axil_slv.sv, NUM_REGS=16, AXI_DW=32); with
C_S_AXI_AW=6 the register index is address bits [5:2] (byte
address = register index * 4). Only register 2 is marked read-only in
hardware (RO_MASK = 16'b0000_0000_0000_0100, i.e. bit 2); all other
registers are plain read/write and reset to 0 (mr_buffer_et.sv does
not override axil_slv’s default zero REG_INIT_VAL).
Index |
Register |
Width |
Description |
|---|---|---|---|
0 |
|
1 bit (RW) |
1 = arm the writer (capture-enable). Must be de-asserted and
re-asserted to allow a new capture (per the driver’s own
docstring comment in |
1 |
|
1 bit (RW) |
1 = start the reader (drain the captured window out on
|
2 |
|
1 bit (RO) |
Bit 0 mirrors |
3-14 |
reserved |
– |
Unused; reads as 0. |
15 |
|
32 bits (RW) |
Bit 0: force |
5. Python Interface
The block is exposed through qick.drivers.readout.MrBufferEt
(bindto covers VLNVs user.org:user:mr_buffer_et:1.0/1.1 and
QICK:QICK:mr_buffer_et:1.0/1.1 – see the version note at the top
of this page). QickSoc auto-discovers at most one MrBufferEt
instance that is actually wired to a readout (multiple would raise
RuntimeError, see qick_lib/qick/qick.py) and exposes it as
soc.mr_buf, plus two convenience methods on QickSoc itself:
from qick import *
soc = QickSoc()
# Arm the buffer for readout channel RO_CH (selects the right switch
# input, then toggles capture off/on so a fresh capture always starts
# from a clean state).
soc.arm_mr(ch=RO_CH)
# Trigger it from a program -- add mr=True to any trigger() call,
# or call it once in initialize() since later triggers are ignored
# until the buffer is re-armed.
prog = QickProgram(soccfg)
prog.declare_readout(ch=RO_CH, freq=FREQ, length=100, gen_ch=GEN_CH)
prog.trigger(mr=True)
prog.end()
prog.config_all(soc)
soc.tproc.start()
# Read back the captured window: an (n_samples, 2) array of int16, [:,0]=I, [:,1]=Q
iq = soc.get_mr()
soc.arm_mr(ch) calls mr_buf.set_switch() (routes the shared
axis_switch_mr to the given readout channel – or, if the board has no
switch, just asserts that the requested channel is the one hardwired in)
followed by mr_buf.disable()/mr_buf.enable() (toggle
dw_capture_reg 0->1 to arm a fresh capture). soc.get_mr(start=None)
calls mr_buf.transfer(start), which sets dr_start_reg=1, DMAs the
whole NM*2**N-sample window into a preallocated buffer
(self.buff, a pynq.allocate’d np.int16 array of shape
2*cfg['maxlen']) via the traced-out AXI-DMA (self.dma), clears
dr_start_reg, and returns np.copy(self.buff).reshape((-1,2))[start:].
cfg['maxlen'] is computed as 2**N * NM from the synthesis
generics read out of the block description (_init_config), matching
the TOTAL_WORDS quantity from Section 3.2.
Note
The driver’s REGISTERS dict only defines dw_capture_reg (index
0) and dr_start_reg (index 1) – it does not read the STATUS
register (index 2) described in Section 4, so capture_done is not
currently surfaced to Python; transfer() relies on the AXI-DMA
S2MM transfer completing (self.dma.recvchannel.wait()) rather than
polling hardware capture-done status.
Note
_init_config sets cfg['junk_len'] = 8 if the bound IP type
string contains mr_buffer_et:1.0, else 0 – a workaround for a
firmware bug (fixed in IP version 1.1, per git history) where the first
several samples of a transfer read back as stale/junk data.
transfer(start=None) defaults start to this junk_len and
skips that many samples from the front of the returned array. Note
that the demo notebook (qick_demos/08_Special_buffers.ipynb)
describes this as “the first 8 samples are always stale” without the
version conditional – treat the driver’s version-gated
cfg['junk_len'] as the authoritative value for a given board’s
bound IP version.