The design rules
These are not style preferences. Each one makes a design wrong if you break it, and every one was paid for by a real run that lost samples.
They are scattered across the pages before this one because that is where each was discovered. Collected here because a reader needs them before writing code, not after.
1–4 make a design correct. 5–7 make it checkable.
1. Never stall the converter’s stream — and the law is the ingress’s alone
A converter cannot be back-pressured. Whatever the fabric is not ready for at the instant it is
presented is gone, with no protocol signal to say so. So the stage holding a converter’s boundary
port must be unconditionally fast: one word in, one word out, TREADY low for at most a cycle.
The asymmetry is the part people get backwards. The law applies to the ingress and to nothing behind it. A stage behind a buffer may block for as long as it likes — nothing upstream loses data while it waits, because the ingress keeps draining the port. Copying “never block” onto the stage behind the buffer makes that stage wrong: a capture buffer that may not wait cannot wait for samples that have not arrived, which is half of what a capture buffer is for.
Knowing which task the law is about is the whole skill.
The evidence. A pass-through that read a whole 64-word block before writing it held
TREADYlow for its entire write phase. Over eight blocks the ADC produced 512 words and the fabric accepted 440 — 72 were dropped, and nothing but the counter said so. Splitting it into an ingress task and a block stage took that to 512, and that number was not earned: the converter model was accepting every word the instant it was offered, so the relay was never held up on its output and never had to stall its input. Against a DAC that withholdsTREADYuntil its own grid asks — which is what a converter does — the same design accepts 450 and drops 62.
The lesson is the rule, not the arithmetic: a design that touches the boundary itself has to solve never-stall itself, and a read-then-write relay cannot. No FIFO depth fixes it, because the stall is structural — while the stage writes, it is not reading.
An ingress that writes a BRAM port satisfies this rule structurally in the other direction — a
BRAM port has no handshake to refuse it, so there is no depth to size and no argument to get wrong.
That is what RfSampBuf does, and it is why pattern B is the default: examples/rf_blk_delay runs
the same converters through a sample buffer at each end and drops zero.
2. Two tasks is not the same thing as overlap
Splitting a module into two hls::tasks does not by itself make it stop stalling its input. What
matters is the shape of the task that holds the port.
A stage that consumes a whole block and only then emits one has a non-consuming phase, and it has it whatever channel sits behind it — a deeper FIFO downstream does not shorten the phase during which that stage is not reading. Only a body whose entire firing is one read and one write has no such phase.
So: split for overlap, but check what the boundary task’s firing actually is. “It’s two tasks now” is not the claim; “the task on the port fires in one word” is.
3. Buffer ≥ stall × word rate
The elastic buffer does not go away when you restructure. Splitting into tasks moves it — out of the boundary port, where a depth cannot be declared, and into an internal channel, where it can.
Size it for the worst contiguous stretch during which the stage behind it is not consuming, times the
rate words arrive at. In the loopback that is one whole block, which is why the internal channel is
declared depth = nwords_blk and appears in the RTL as a depth-64 FIFO.
The corollary, which is rule 7: you cannot satisfy this rule by making the boundary port deeper, because you cannot make the boundary port deeper.
4. Port capacity is not design capacity
Rfdc refuses samp_rate > samp_per_word · f_axis. That check is real and it is not the one you
need: it is the port’s capacity, one word per fabric cycle.
The design behind the port is usually slower. Divide by the slowest stage’s per-word cost:
cap = f_axis * samp_per_word / RfSampBufRx.cycles_per_word # max over the buffer's stages
if samp_rate > cap:
raise ValueError(...) # with the arithmetic in the message
A module’s throughput is part of its interface contract, not an implementation detail. Declare it
next to the body it describes, measure it from csynth, and check the pairing in the testbench.
Divide by the slowest stage, not by the one that touches the converter. Every sample crosses every stage, so a buffer sustains what its slowest one does. “The capture may block freely” is a safety property — it is allowed to stall because nothing upstream loses data while it waits — and reading it as a throughput exemption is how a ceiling gets quoted that nothing can reach. Measured per-word costs today: ingress 1, capture 2, loader 1, player 1, so the RX half sits at half the port and the loop sits there with it.
The evidence. The capture design’s first RTL run — 256 MSa/s into a 300 MHz fabric at one sample per word, with an ingress firing every 2 cycles — lost 1695 of 4096 samples. The port check passed:
1 · 300e6is more than256e6. The design check, which did not exist yet, would have failed:1 · 300e6 / 2is not.
pysim reported a clean run at the time, and that is no longer true: RfSampBufIngress’s twin charges
cycles_per_word per word, so a run at the ingress’s own ceiling reports 1280 of 4096 dropped
without a toolchain.
That ceiling has moved twice since. The ingress became a while (1) loop at II=1 on 2026-08-18, so
it now absorbs one word per cycle and the boundary keeps up all the way to the port. What it did
not do is lift the buffer, because the capture behind the memory is still 2 — so check_rate
refuses at half the port while the boundary counter stays clean, and the loss in between moves from
“refused at the port” to “overwritten before the capture reaches it”. Two counters, two failures, and
the page says so because one number can no longer stand in for the other.
See rule 5 for what the counters are for, and the fidelity boundary for the loss shape that is still invisible: one inside a block period, which is a different design and a different module.
5. The counters are the contract
underrun, overrun, dropped, too_old. Assert them on every converter-connected run.
They are not diagnostics; they are the only evidence. A design that lost a quarter of its samples still finishes, still produces well-formed output, and still passes every functional check on the data that did arrive. Backpressure protects you against over-production and nothing protects you against under-production — a starved grid emits well-formed zeros and a stalled consumer simply sees fewer blocks.
adc_if.assert_clean() # nothing lost on the way in
dac_if.assert_clean(startup_blocks=dut.blk_latency) # exactly the declared transient, no more
assert_clean checks the count and the grid index, so a steady-state fault cannot hide inside a
transient’s budget and a module that over-declares its latency fails too.
Drive each counter off zero at least once
A counter that has never counted is not evidence that it works. Inject the faults deliberately and assert predicted values, not observed ones:
| fault | knob | predicted |
|---|---|---|
| late producer | source.start_delay |
one underrun per block period missed |
| stalled consumer | sink.stall_after, sink.depth |
n_blk − consumed − depth overruns |
A prediction that tracks a knob — change depth, watch the count follow — is a model of the buffer.
A number that happened to match once is not.
6. Timestamp from the sample index, never from arrival time
An RfBlock carries its grid index alongside its samples, and a command such as RxCmd names a
window in sample index — the converter’s running count, not a buffer address or a wall-clock time.
Arrival time is backend-dependent; the sample index is not.
The index half. examples/rf_blk_delay asks for block k at sample index k·256 and places it
at k·256 + 1024, and both backends honour that exactly — every block, bit-exact. That relation has
never moved, through four separate re-timings of the machinery underneath it.
The arrival half. examples/rf_loopback runs the same five nodes in both backends and disagrees
about when the first data block comes out. The startup transient is
2 blocks in pysim and 1 at RTL.
Neither is wrong, and the cause is not a bug in either — pysim paces the RF side on the edge’s
metronome and XSI paces it on the source, so the RTL ADC has its first block at t = 0 and pysim’s
does not. A 1-block difference in arrival, with every surviving block bit-identical.
Watch how unstable the arrival number is, and how stable the index one is. rf_blk_delay’s own
arrival skew was 64 samples, then 4, and is now 0 — moved once by pipelining an unrelated body and
once by correcting a cycle of phase in the converter model. The loopback’s transient was 1, then 2,
and is 1 again, for two different reasons neither of which was the design. Meanwhile the index
relation did not move at all, in any of it. Anything derived from arrival inherits every one of
those; anything derived from the sample index inherits none, because sample n is at
t0 + n / samp_rate in both backends by construction.
The practical payoff is that a host can ask for “100 samples around the event I timestamped” and mean something exact. A drop then leaves a visible gap in the indices rather than silently shifting everything after it — loss is legible in the data as well as in the count.
7. Internal depth is physical; a boundary port’s is not
#pragma HLS STREAM depth= works on a channel inside a top and is ignored on a top-level
argument (HLS 214-387) — in one pragma placement, silently. A boundary port is 2 deep whatever
your Python says.
That is why composite_top_spec refuses a depth= on an interface that becomes a boundary port. A
depth that is silently 2 is worse than no depth at all: the number in the Python reads like a fact,
and a testbench that declared 128 made pysim model a queue the hardware does not have.
The consequence for design is rule 3: elastic buffering in front of a converter has to be a task plus an internal channel. There is no number you can raise instead.
Where each rule came from
| rule | page |
|---|---|
| 1, 2, 3 | The fidelity boundary |
| 4 | Connecting the fabric side |
| 5 | Connecting the RF side, block sampling |
| 6 | Block sampling |
| 7 | Connecting the fabric side |
The full diagnoses live in plans/adc_model.md and plans/behavioral_edges.md. This page keeps one
sentence of each, because a rule needs its evidence and does not need its lab notebook.
Source of truth: tests/examples/test_rf_loopback_xsi.py, tests/examples/test_rf_samp_buf_rx_xsi.py,
waveflow/hw/rf_samp_buf.py.