Building it

Steps 1 to 4. Nothing here needs a toolchain; at the end of step 4 you have a graph that will run.

python -m examples.rf_loopback.rf_loopback

1. Create the Rfdc

Rfdc models a simplified emulation of the AMD RF Data Converter LogiCORE IP — specifically, the interface your logic would see. Like the AMD block it exposes two AXI4-Stream interfaces, one per direction, whose time origins can hold a fixed relation the way multi-tile synchronisation does; its RF side simulates the signals the physical converter would emit or receive. What Rfdc is, and what it is not has the full framing, including what the model deliberately does not cover.

It is one module carrying both directions, not separate ADC and DAC blocks. The reason is synchronization: the TX and RX sample counters have to hold a fixed relation, and that is a property of the converter, not of two unrelated blocks — it is what lets the two grids’ time origins have a single owner.

rfdc = Rfdc(name="rfdc", sim=sim, word=Rfsoc4x2SampWord.specialize(samp_per_word=4),
            full_scale=1.0, t0_rx=0.0, t0_tx=blk_period)

Endpoints, in two pairs — one RF interface per direction, one AXIS port per channel:

endpoint type direction
rx_rf RFSampIFRx ADC path — RF blocks in from the environment, every channel in one block
rx_stream_0 .. StreamIFMaster ADC path — AXI-Stream words out to the fabric, one port per channel
tx_stream_0 .. StreamIFSlave DAC path — AXI-Stream words in from the fabric, one port per channel
tx_rf RFSampIFTx DAC path — RF blocks out to the environment

The two stream endpoints are the ones that would cross the cut in an RTL build; the two RF endpoints stay behavioural on both sides of it.

With the metronome living in the interface, the converter is reactive on the RF side: it has no timer of its own and simply responds to block arrivals.

The parameter split

parameter binding why
n_rx, n_tx HwParam RF channels per direction on the AXIS side
word plain field, a type the whole sample geometry — samples per beat, effective bits, container bits, iq_mode, and the two packing rules a serializer cannot know
full_scale, t0_rx, t0_tx plain init-time fields one artifact serves every value

word is a plain field for a mechanical reason. HwModule.__post_init__ wraps every HwParam value in HwParamValue(int(value)), so a type-valued parameter cannot be one. Nothing is lost: an Rfdc declares no kernel_task, so none of its parameters ever reached a template argument — they were build-time structure for the models, which read them off the word. See what iq_mode means for real vs complex.

samp_rate is deliberately not on this list. It lives on the RF interface’s clock and the converter reads it at bind; t0 travels the other way and is pushed. Two declarations that can disagree is the bug both directions exist to avoid.

There is also no spc. word.samp_per_word is the structural integer; everything else at this boundary is a rate ratio — derived, and generally fractional. The Python model needs neither conversion, because it works in seconds.

full_scale is not a DynParam, and the reason is worth knowing. DynParam does not mean “binds at init”; it means emitted as a member assignment<model>.<field> = <expr>;. This value’s C++ realization is a constructor argument, riding inside the RfdcFormat literal the generated models take, so tagging it would emit an assignment to a member that does not exist. Zero would be doubly wrong — meaningless as an amplitude reference and falsy, which discover_dyn_params skips — so the constructor refuses it either way.

Bit-exact quantization

“Evaluate the effect of bit widths in Python” only means anything if the Python does what the hardware will. So quantization is the integer-backed FixedFieldap_fixed<bits_per_samp, 1> over [-1, 1), rounding and saturating, because a converter clips rather than wraps — and sample↔word packing goes through the generated array serializers, never a hand-rolled .range():

You do not write this — the Rfdc does it, on both paths. On the way in it quantizes each real sample to a stored integer and packs samp_per_word of them into a beat; on the way out it unpacks and dequantizes. Writing b for the word’s bits_per_samp — the effective width, never the container — the arithmetic is:

stored = clamp( floor( x / full_scale * 2^(b-1) + 0.5 ),  -2^(b-1),  2^(b-1) - 1 )
    x' = stored / 2^(b-1) * full_scale

floor(· + 0.5) is round-half-up (AP_RND — not round-half-away-from-zero, which disagrees on negative ties), and the clamp is the saturation (AP_SAT): a converter clips, it does not wrap. The dequantization is exact, because the scale is a power of two.

At the 4x2’s 14 effective bits in a 16-bit slot, four to a beat, that is four samples per 64-bit AXI-Stream beat — the effective width sets b above, the slot width sets the bus, and they are two numbers. The gate runs (8, 8), (16, 4), (12, 4) and (16, 2) — including a non-power-of-two width — because the bugs hand-rolled packing produces hide at exactly the awkward widths.

2. Create the source and sink

Both are file-backed, and that is the discipline rather than a convenience: one on-disk bundle drives the Python run and, later, the RTL run, so the two backends can never start from different bytes.

self.source = RfDataSource(name="src", sim=sim, in_bundle="vectors/rf_in", start_delay=0.0)
self.sink = RfDataSink(name="sink", sim=sim, out_bundle="vectors/rf_out", depth=2)

write_scenario is the single scenario writer, and it is the only thing that produces input bytes.

Two waveforms, because they test different things

RfLoopbackSim(waveform=...) takes "grid" or "sine", and the difference is not cosmetic.

"grid" draws random samples exactly on the converter’s quantization gridm / 2^(bits_per_samp-1) · full_scale for integer m. A clean loopback is then bit-identical to the input rather than close, which is what makes the packing check strict: a tolerance would hide a packing bug, and packing is what this waveform exists to test. What it deliberately does not test is quantization: on-grid samples make from_real a no-op, so rounding and saturation are never exercised at all.

"sine" is a windowed sinusoid, and it exists to close exactly that gap. A sine does not land on the grid, so from_real really rounds, and at 0.9 of full scale it is near enough the rail to exercise saturation. The golden stays exact — no tolerance — it is just stated against the quantized input:

assert np.array_equal(captured[k + n_lat], to_real(from_real(sent[k])))

That is the paragraph worth keeping: two waveforms, because one proves the packing is exact and the other proves the quantizer is exercised at all, and neither substitutes for the other. tests/examples/test_rf_loopback.py asserts the difference directly — the grid waveform survives a round trip through from_real/to_real unchanged, and the sine does not.

The sine is also the one you can see. Here is what the source plays, read back out of the bundle it was written to:

The source's windowed sinusoid: eight blocks of 256 samples, with a four-block burst in the middle

8 blocks of 256 samples, and the burst occupies the middle 4 of them — 1024 samples, silent either side. Deliberately away from block 0, so that the DAC’s startup zero-fill on the next page cannot be confused with the window simply being closed.

3. Wire the graph

Four edges, and only two kinds:

adc_if = RFSampIF(name="adc_if", sim=sim, samp_clk=self.samp_clk,
                  n_ch=1, blksize=256, n_blk=8)
adc_if.bind("tx", self.source.rf_ep)
adc_if.bind("rx", self.rfdc.rx_rf)
edge type domain
source → rfdc.rx_rf RFSampIF RF — blocks of real samples, on the sample clock
rfdc.rx_streams[ch] → DUT StreamIF fabric — an ordinary AXI-Stream, one per channel
DUT → rfdc.tx_streams[ch] StreamIF fabric — an ordinary AXI-Stream
rfdc.tx_rf → sink RFSampIF RF

Only the StreamIF pair would cross the cut in an RTL build; the RFSampIF pair stays behavioural on both sides of it.

No depth= on the AXIS edges, and that is a correction rather than an omission. These become the DUT’s top-level ports, and a top-level argument cannot carry a FIFO depth: Vitis ignores the pragma (HLS 214-387) and the RTL gets the default of 2. composite_top_spec now refuses the declaration outright — see the fidelity boundary.

Graph and procedure are separate objects

The same split as mem_copy: RfLoopbackTB.__post_init__ builds only structure, because a component graph is data and can be walked, while a run-and-check function is code and cannot. RfLoopbackSim owns the scenario, the run and the golden.

4. The DUT, and why it is two tasks

RfSampPassThrough is a FreeRunMod: one burst in, the same burst out — as two tasks over an internal channel.

s_in --> [RfSampIngress] --blk_fifo (depth = nwords_blk)--> [RfSampBlockRelay] --> s_out

Free-running is the honest kind here — logic sitting between two converter ports has no host to start it and re-fires on each arriving block. The payload behaviour is trivial so that the loopback golden is exact rather than approximate: any difference between what went in and what came out is the plumbing, not an algorithm.

The structure is not trivial, and it is not decoration. It was one task — read a whole block, then write it — and that design dropped 72 of 512 samples at RTL. A converter cannot be back-pressured, so a stage that stops reading its input for 64 cycles at a stretch loses whatever arrives meanwhile. Splitting the read from the write is what lets block k+1 arrive while block k is going out — necessary, and not sufficient: it takes the loss to 62, not to zero, because the block stage still finishes a write before the next read and the DAC paces that write. See the RTL page’s correction. The structure below is still the right first move; what it does not do is make this design safe against a converter at both ends, which is what pattern B is for.

class RfSampIngress(FreeRunMod):        # never stops reading the boundary port
    def run_iter(self):
        words = yield from self.s_in.get()
        yield from self.w_out.write(words)


class RfSampBlockRelay(FreeRunMod):     # allowed to be busy: it holds a block
    def run_iter(self):
        blk = yield from self.blk_in.get_schema(self.blk_words)
        self.count_burst()
        yield from self.s_out.write(blk)

The channel between them is declared one block deep, and that declaration is honoured because the channel is internal: #pragma HLS STREAM depth= works inside a top and is ignored on a top-level argument. That asymmetry is why the elastic buffer has to be a task plus a channel rather than a bigger number on the port.

The DUT declares what the loop costs

The DAC grid is a metronome, not a queue: it emits a block every period whether or not the samples for it have arrived. And DAC block k cannot carry ADC block k — the ADC only delivers block k at the instant the DAC period for it comes due, so the loop costs at least one block index however fast the fabric is. A zero-latency fabric would not close it either.

So the pipeline declares what it costs:

class RfSampPassThrough(FreeRunMod):
    blk_latency: HwParam[int] = 1      # >= 1 for any block-processing module

and blk_latency = 0 is refused at construction rather than reported later as a symptom — a loop that claims to be free is not a slow system, it is not a system.

The testbench then sums the path: loop_blk_latency = 1 + dut.blk_latency, where the extra term is the ADC’s own hop — a converter cannot emit samples it has not collected, so a block exists at its grid tick and is transmitted across the following period. Two blocks in total, and that is the number the next page checks against.

Two things this deliberately does not do. It does not stagger the tile epochs: t0_rx == t0_tx is what MTS gives you, and buying pipeline latency by starting a tile late would model the thing MTS exists to prevent. And it does not treat the resulting first-block underrun as a fault — a converter fed by a pipeline must underrun until the data reaches it, which is exactly why a real design primes its buffer before enabling the tile.

Next

  • Running it — the three claims, and the two faults that make the counters mean something.

Source of truth: examples/rf_loopback/rf_loopback.py, waveflow/hw/rfdc.py, tests/examples/test_rf_loopback.py.