PFB Readouts (axis_pfb_readout_v2 / v3 / v4) - QICK Firmware
axis_pfb_readout_v2, axis_pfb_readout_v3, and axis_pfb_readout_v4
are QICK’s polyphase-filter-bank (PFB) readouts: unlike every other readout
documented so far (Readout v2 (axis_readout_v2) - QICK Firmware, Dynamic Readouts (axis_readout_v3 / axis_dyn_readout_v1) - QICK Firmware), a PFB readout
channelizes its ADC input into many fixed-frequency slices of the band in
hardware, then lets software/tProc pick which slice(s) to route to its
output port(s) and fine-tune each with its own DDS. One PFB readout instance
therefore behaves like several single-tone readouts operating simultaneously
on disjoint (or shared) parts of the spectrum, rather than one tunable
downconverter. All three modules live under firmware/ip/ –
axis_pfb_readout_v2/, axis_pfb_readout_v3/, axis_pfb_readout_v4/
– and are exposed to Python through qick.drivers.readout.AbsPFBReadout
and its AxisPFBReadoutV2/V3/V4 subclasses.
Note
Like most of the cores in this doc set, this is legacy RTL that has not
been ported to the newer fusesoc build system – it exists only in the
firmware/ip/ tree, not under a fusesoc core description. That does not
make it dead code: it is real, currently-shipping firmware. The flagship
ZCU216 “tpv2_std” board design (firmware/Top/216/tpv2_std/bd_2023-1.tcl,
tracked on branch feature/114-integrate-hog-fusesoc, since it predates
this branch’s docs-only history) instantiates one axis_pfb_readout_v4
alongside two axis_dyn_readout_v1 (Dynamic Readouts (axis_readout_v3 / axis_dyn_readout_v1) - QICK Firmware) – see
6. Real-World Usage: ZCU216 “tpv2_std” below.
1. General Description
All three versions share the same overall pipeline:
Polyphase filter bank + FFT – a bank of parallel FIR filters (
firs.sv/firs) followed by an FFT splits the input band into N equally-spaced, overlapping channels, each centered on a different fixed frequency (an integer multiple offs/N, wherefsis the ADC sample rate seen by the PFB). This stage is a fixed, compiled-in channelizer – its channel count and spacing are synthesis-time constants, not runtime registers.Per-channel or per-output DDS mixing – a second, small, programmable DDS fine-tunes each selected channel by up to +/- half a channel width, so the final demodulation frequency is (fixed PFB channel center) + (DDS offset), rather than only landing on one of the N coarse PFB bins.
Output routing/selection – a subset of the N PFB channels (up to NOUT of them) is routed to the block’s NOUT physical AXI-Stream output ports, which is what actually gets built (see 6. Real-World Usage: ZCU216 “tpv2_std”) – each output port typically feeds its own Averager + Buffer (axis_avg_buffer) - QICK Firmware instance downstream, one PFB readout thus driving multiple avg_buffers.
Where the three versions differ is N (PFB channel count), NOUT (selectable/routed output count), and whether the fine-tuning DDS sits before or after output selection – all verified directly from RTL below, not inferred from naming.
v2 |
v3 |
v4 |
|
|---|---|---|---|
PFB channels (N) |
8 ( |
64 ( |
64 (same |
Selectable outputs (NOUT) |
4 ( |
4 ( |
8 ( |
DDS placement |
Per PFB channel (all 8 channels always have a live DDS mixer;
|
Per output (4 independent, phase-coherent DDS blocks, one per
selected output; |
Per output (8 independent DDS blocks, same structure as v3, just doubled) |
Output-channel selection |
|
|
Same |
AXI-Lite address width |
6 bits ( |
6 bits ( |
8 bits ( |
2. axis_pfb_readout_v2
2.1 Datapath
axis_pfb_readout_v2.v instantiates a single block, pfb_dds_mux
(pfb_dds_mux.sv), which chains three sub-blocks:
pfb (
pfb.sv) –firs(the polyphase FIR bank,L=4input lanes) feeds anssrfft_8x8core (NFFT=SSR=2*L=8,B=16), which is then run throughpimod(a “pi modulation” / spectral-reordering stage). Output is 8 interleaved 32-bit (16-bit I + 16-bit Q) lanes on one 256-bit-wide AXI-Stream word (tdata_pfb) – 8 fixed PFB channels, each centered oni * fs/16for channeli(thessrfft_8x8parameterization: 8-point FFT over the PFB’s overlapped, 50%-overlap 50%-decimated stream).ddsprod_v (
ddsprod_v.sv) – instantiates 8ddsprodblocks (ddsprod.sv), one per PFB channel, always active regardless of which outputs are actually used. Eachddsprodcomplex-multiplies its channel’s PFB output against a per-channel programmable DDS (dds_0/FREQ[0-7]_REG), and its output mux (shared 2-bitOUTSEL_REGacross all 8 channels) picks between the demodulated product, the raw (delay-matched) PFB channel input, or the raw DDS tone – the same product/input/dds encoding pattern used by the single-tone readouts (Readout v2 (axis_readout_v2) - QICK Firmware).pfb_mux (
pfb_mux.sv) – a pure combinational 8-to-1 mux, replicated 4 times (one per output). Each output’s 3-bitCH[0-3]SEL_REGselects which of the 8 already-DDS-mixed channels appears on that output (m0-m3).
Because the DDS sits before the output mux (per-channel, not per-output), v2’s 4 outputs can only ever carry (a subset of) the 8 channel-DDS results computed once, shared if two outputs pick the same channel – there is no way to independently re-tune the same PFB channel differently on two outputs simultaneously.
Top-level block diagram: DDS mixing happens once per PFB channel
(ddsprod_v, always active for all 8), and pfb_mux only picks
which already-mixed channel reaches each output – the “DDS-before-mux”
ordering described above.
2.2 Register Map
Register offsets below are as wired in axi_slv.vhd (slv_reg0
.. slv_reg12) and match AxisPFBReadoutV2._init_config()’s
REGISTERS dict in qick_lib/qick/drivers/readout.py exactly.
Register |
Width |
Description |
|---|---|---|
|
32 bits each |
Per-PFB-channel DDS tuning word (one per of the 8 fixed channels, not per output). |
|
2 bits |
Shared by all 8 channel DDS muxes. 0: product (demodulated). 1: input (bypass, raw PFB channel). 2: dds (raw local-oscillator tone). |
|
3 bits each |
Maps output port N (0-3) to one of the 8 DDS-mixed PFB channels (0-7). |
3. axis_pfb_readout_v3
3.1 Datapath
axis_pfb_readout_v3.v instantiates pfb_readout (pfb_readout.v,
parameterized N=64 channels, L=4 input lanes), which chains:
pfb_top (
pfb_top.sv) – instantiates the sharedpfbchannelizer core (50%-overlap PFB into anssrfft_8x64-based 64-point FFT – channelicentered ati * fs/64), whose 64-channel output stream is time-division-multiplexed acrossL=8parallel lanes (2*Lfrom the overlap structure), i.e. 8 channels appear per clock, taking 64/8 = 8 clocks (“packets”) to cycle through all 64 channels.pfb_topinstantiates NOUT = 4pfb_chselblocks in agenerateloop, one per output – each is a TDM channel-extractor: it watches the 8-lane stream for its configured(packet, index)pair (from that output’sID[0-3]_REG) and latches the matching 32-bit (I,Q) sample onto its output. Unlike v2, no DDS mixing happens at this stage –pfb_toponly selects a raw PFB channel per output; it has noFREQ/PINCregisters of its own.ddsprod_v (a different, 4-output-specific
ddsprod_v.svfrom v2’s 8-channel one) – instantiates 4 independentddsprodblocks, one per output, after channel selection. Each output gets its own phase-coherent DDS (PINC[0-3]_REG/POFF[0-3]_REG) applied to whichever raw PFB channel that output’spfb_chselpicked. Because the DDS is per-output rather than per-channel, the same PFB channel can be routed to two different outputs and independently fine-tuned/phased on each – the opposite tradeoff from v2.
v3 has no ``OUTSEL_REG``/output-mode-selection mux at all (confirmed:
ddsprod.sv in v3/v4 has no OUTSEL_REG port, unlike v2’s; the Python
driver sets HAS_OUTSEL = False for AxisPFBReadoutV3) – each output
always carries the demodulated product, nothing else.
Top-level block diagram – the opposite ordering from v2:
pfb_chsel picks a raw channel per output first, then ddsprod_v
applies an independent per-output DDS. v4 (below) is the same diagram
with NOUT=8 instead of 4 – RTL-identical apart from the doubled
instance arrays.
3.2 Register Map
Confirmed against axi_slv.vhd (slv_reg0..``slv_reg11``, 12 registers,
6-bit address bus) and AxisPFBReadoutV3._init_config(), which builds the
same offsets programmatically (id%d_reg at 0-3, then freq%d_reg/
phase%d_reg pairs at 4/5, 6/7, 8/9, 10/11). Note the RTL register names
(ID/PINC/POFF) differ from the Python driver’s friendlier names
(id/freq/phase) for the same bits – PINC = phase increment
(frequency tuning word), POFF = phase offset.
Register (RTL / driver name) |
Width |
Description |
|---|---|---|
|
16 bits |
Selects which of the 64 PFB channels feeds output N. Lower 8 bits =
“packet” ( |
|
32 bits |
Per-output DDS frequency (fine-tuning on top of the selected PFB channel’s fixed center frequency). |
|
32 bits |
Per-output DDS phase offset. |
4. axis_pfb_readout_v4
4.1 Is v4 RTL-identical to v3?
No. This was verified directly by diffing the RTL trees, not assumed from
the Python driver. axis_pfb_readout_v4 reuses the same shared
sub-modules as v3 (pfb_top.sv, pfb_chsel.sv, pfb.sv,
pfb_switch.v, pfb_reorder.sv, pfb_ctrl.vhd, pfb_cfg.vhd are
byte-for-byte identical between the two IP directories – confirmed with
diff), but the top-level wrapper and its NOUT-sized instance arrays are
doubled from 4 to 8:
pfb_top.sv’s own header comment changes from “The number of outputs is fixed to 4 in this case” (v3) to “…fixed to 8 in this case” (v4), and itslocalparam NOUTliterally changes from4to8, adding 4 morepfb_chselgenerate-loop instances, 4 moreID[4-7]_REGports, and 4 morem[4-7]_axis_tdataoutputs.ddsprod_v.svgoes fromlocalparam N = 4(4ddsprodinstances) tolocalparam N = 8(8 instances), with matchingPINC[4-7]_REG/POFF[4-7]_REGports.axis_pfb_readout_v4.v’s top level exposes 8 AXI-Stream masters (m0_axis..``m7_axis``) instead of v3’s 4, and its AXI-Lite address bus widens from 6 bits (s_axi_awaddr[5:0]) to 8 bits (s_axi_awaddr[7:0]) to fit the doubled register count (24 vs. 12).The underlying 64-channel PFB channelizer itself (
pfb.svand everything it instantiates, including thessrfft_8x64FFT core) is unchanged between v3 and v4 – both still channelize the same 64 fixed bins from the same ADC input; only how many of those channels can be simultaneously routed out (and independently DDS-tuned) differs.
So v4 is best described as v3 with NOUT widened from 4 to 8, not a
repackaging with identical RTL, and not an independent redesign of the
channelizer. This matches the Python driver docstring/registration exactly
(AxisPFBReadoutV4(AxisPFBReadoutV3) overrides only NOUT = 8, and its
docstring says “This is identical to AxisPFBReadoutV3, but with 8 outputs
instead of 4”) – a case where the Python driver’s claim and the RTL diff
independently agree.
One thing this investigation could not determine, and does not guess at:
why v4 exists as a separate IP core (with its own VLNV,
QICK:QICK:axis_pfb_readout_v4:1.0) rather than v3 simply being
re-synthesized with a NOUT parameter – unlike N (PFB channel count,
a real Verilog parameter on axis_pfb_readout_v3/v4’s module
header), NOUT is a localparam hard-coded inside pfb_top.sv/
ddsprod_v.sv, not exposed as a module parameter, so making it
configurable would have required a nontrivial refactor of the fixed-width
port lists and register maps in both files – forking to a new top-level
(and giving it a new IP core name) was evidently the path taken instead, but
no comment or commit message read in this session confirms that reasoning.
4.2 Register Map
Same layout as v3, doubled: ID[0-7]_REG (16 bits each) at offsets 0-7,
then PINC[0-7]_REG/POFF[0-7]_REG (32 bits each) pairs at offsets
8/9, 10/11, … 22/23 – 24 registers total, matching
AxisPFBReadoutV4’s inherited _init_config() (which just runs v3’s
same offset-generation loop with self.NOUT = 8) and the widened 8-bit
AXI-Lite address bus in axis_pfb_readout_v4.v/axi_slv.v.
5. Python Interface
5.2 Version-specific subclasses
|
|
|
|
|
|---|---|---|---|---|
|
8 (class constant) |
4 (class constant) |
|
|
|
read from the bitstream’s |
4 (class constant) |
|
|
|
inherited from V3 (bitstream generic) |
8 (class constant, only override vs. V3) |
|
|
AxisPFBReadoutV2.set_freq_int(cfg) writes two registers per call: the
output’s ch%dsel_reg (which of the 8 channels to route) and that
channel’s freq%d_reg (the DDS tuning word) – note this overwrites the
shared per-channel DDS setting, so the driver’s own comment warns “it’s
assumed that channel collisions have already been checked … a collision
will break the previously set channel” if two outputs are pointed at the
same input channel with different frequencies.
AxisPFBReadoutV3.set_freq_int(cfg) validates pfb_ch/out_ch are
in-range, computes the (packet, index) pair for the ID register from
pfb_ch, and writes id/freq/phase for that output – since the
DDS is per-output here, there’s no collision risk analogous to v2’s, and the
driver comment says so explicitly (“No need to check for collisions as they
are all truly independent”). AxisPFBReadoutV4 inherits this method
unchanged; only NOUT differs.
from qick import *
soc = QickSoc()
pfb = soc.readouts[i] # an AxisPFBReadoutV3 or AxisPFBReadoutV4 instance
# Route PFB channel 5 to output 2, demodulated at 100 MHz.
pfb.set_ch(f=100.0, out_ch=2, sel='product')
Normal QICK programs never call set_ch/set_freq_int directly –
QickProgram.declare_readout() picks the PFB channel/output assignment
and frequency for you (via QickConfig.calc_ro_regs()), exactly as
described in Readout System - QICK Firmware’s Python Interface section for the single-tone
readouts.
6. Real-World Usage: ZCU216 “tpv2_std”
The flagship ZCU216 board design (firmware/Top/216/tpv2_std/bd_2023-1.tcl,
tracked on branch feature/114-integrate-hog-fusesoc) instantiates
one axis_pfb_readout_v4_0 (VLNV QICK:QICK:axis_pfb_readout_v4:1.0)
alongside two axis_dyn_readout_v1 instances (Dynamic Readouts (axis_readout_v3 / axis_dyn_readout_v1) - QICK Firmware)
and ten axis_avg_buffer instances (Averager + Buffer (axis_avg_buffer) - QICK Firmware) – tracing the
block-diagram connections (connect_bd_intf_net) confirms exactly how that
10 breaks down, rather than assuming it’s all attributable to one core:
axis_pfb_readout_v4_0exposes 8 outputs,m0_axis..``m7_axis`` (its fullNOUT=8, confirmed by 8 separateaxis_pfb_readout_v4_0_m[0-7]_axisnets in the .tcl). Each output feeds its ownaxis_broadcaster(axis_broadcaster_2throughaxis_broadcaster_9), which in turn drives its own dedicatedaxis_avg_buffer(axis_avg_buffer_2throughaxis_avg_buffer_9) – 8 of the 10 avg_buffers on this board are fed by the single PFB readout instance, one per PFB output channel.The remaining 2 avg_buffers (
axis_avg_buffer_0,axis_avg_buffer_1) are fed the same way (via their own broadcasters,axis_broadcaster_0/axis_broadcaster_1) by the twoaxis_dyn_readout_v1instances’ singlem1_axisoutputs.
So the buffer count (10) is not a property of “how many readout instances there are” (there are 3: 1 PFB + 2 dynamic) – it’s driven by output ports: 1 (per dynamic readout) x 2 + 8 (per PFB readout, its full NOUT) x 1 = 10. This is exactly the PFB-vs-single-tone distinction from 1. General Description above made concrete on real, currently-shipping hardware: one PFB readout instance does the job of 8 single-tone readout + buffer pairs, at the cost of those 8 channels sharing one fixed 64-way channelizer (they can only demodulate PFB bins independently fine-tuned by up to +/- half a bin, not arbitrary frequencies across the whole band simultaneously).
The PFB readout’s AXI-Lite control port (axis_pfb_readout_v4_0/s_axi) is
mapped in the address space at offset 0x000400101000, range 0x1000
(the .tcl’s assign_bd_address call) – a full 4 KiB page is reserved even
though the register map itself only spans 24 x 4 = 96 bytes (see
1. General Description’s address-width comparison), consistent with the
AXI-Lite page-granularity convention used elsewhere in this address map
(not specific to the PFB readout).