Python Model
This page builds the design in code: the two commands, the two custom leaves, and the composite that
wires them to the framework mem-streams. The whole design is
examples/fir_block/fir_block.py.
Running it
The build is a BuildDag behind the standard CLI, so every stage below is a
--through target. From the repo root:
# The first checkpoint — no toolchain, seconds. Runs the golden and fails on any mismatch.
python -m examples.fir_block.fir_block_build --through pysim
python -m examples.fir_block.fir_block_build --list-steps
python -m examples.fir_block.fir_block_build --status
The rest of the flow, in order:
--through |
what it does | needs |
|---|---|---|
pysim (default) |
run the FirBlockTB graph, check every block against the golden |
— |
codegen_dut |
the ap_ctrl_none composite top, its TCL, the port map, the headers |
— |
codegen_tb |
the XSI BFM harness, its main, and the scenario bundles | — |
csynth |
Vitis HLS C-synthesis; also re-emits rtl_fir_block.f |
Vitis |
The parameters are CLI flags, which is also the handle a parameter sweep drives:
python -m examples.fir_block.fir_block_build --through csynth --samp-w 8 --ntap 32
python -m examples.fir_block.fir_block_build --through csynth --unroll-lane
The RTL rung is a test rather than a build step, because it needs Vivado xsim and a prior csynth:
pytest tests/examples/test_fir_block.py # the pysim gates, incl. the falsification pair
pytest -m xsi tests/examples/test_fir_block_xsi.py
Two message types
The host speaks one command; the pipeline forwards a different one internally.
class FirCmd(DataList):
"""One host command on the boundary s_cmd (a plain word stream)."""
elements = {
"op": {"schema": FirOpField, "description": "LOAD_TAPS or FILTER"},
"src_off": {"schema": Word32, ...},
"n": {"schema": Word32, "description": "sample count (tap count, or block length)"},
"dst_off": {"schema": Word32, ...},
"zero_state": {"schema": Word32, "description": "FILTER: 1 = start from zeros, not the carry"},
"tx_id": {"schema": Word32, ...},
}
FirDesc is the framed internal descriptor and carries the subset the downstream stages need — the
opcode, n, dst_off, zero_state, tx_id. It drops src_off, because by the time the descriptor
is travelling the read has already been issued.
Two details are load-bearing:
opis anEnumField, so it reaches C++ as a realenum class FirOpand the kernel dispatches onFirOp::LOAD_TAPSrather than on an unchecked integer. That is also whyFirOpFieldappears inSCHEMA_CLASSESin its own right — it needs its own generated header.nis a sample count, everywhere. The mem-streams speak words. See samples and words below.
The framer
FirCmdRx is the only stage that sees a FirCmd. It reads one and frames the reader’s command stream:
def run_iter(self) -> ProcessGen[None]:
w = int(self.mem_dwidth)
cmd = yield from self.s_cmd.get_schema(FirCmd)
self._mark_start()
desc = FirDesc(op=int(cmd.op), n=int(cmd.n), dst_off=int(cmd.dst_off),
zero_state=int(cmd.zero_state), tx_id=int(cmd.tx_id))
memr = MemRCmd(addr=int(cmd.src_off), len=nwords(int(cmd.n), self.lw), fwd_bursts=1)
yield from self.cmd_out.write(np.asarray(memr.serialize(word_bw=w), dtype=np.uint64))
yield from self.cmd_out.write(np.asarray(desc.serialize(word_bw=w), dtype=np.uint64))
self._log_firing()
fwd_bursts=1 is what welds the descriptor to its data: the reader relays the next burst — the
FirDesc — as a header ahead of the data it fetches, so a descriptor can never be paired with the
wrong burst. One read per job, for both opcodes, which is what keeps the no-output opcode off a
special path.
_mark_startand_log_firingare@sim_only. Instrumentation has no hardware meaning, and the extractor drops such calls wholesale — including their arguments. Without the marker a bareself.nowread would trip the implicit-capture rule.
The compute
FirCompute holds the state and does the work. Its run_iter reads the descriptor, reads the data,
dispatches, and frames the writer’s stream:
def run_iter(self) -> ProcessGen[None]:
w = int(self.mem_dwidth)
desc = yield from self.s_in.get_schema(FirDesc)
n = int(desc.n)
nw = nwords(n, self.lw) # the stream speaks WORDS; the descriptor carries SAMPLES
data = yield from self.s_in.get(nwords_max=nw)
self._mark_start()
if int(desc.op) == FirOp.LOAD_TAPS:
self.load_taps(np.asarray(data), n, self.taps)
memw = MemWCmd(addr=int(desc.dst_off), len=0, fwd_bursts=1)
...
else:
y = self.filter_block(np.asarray(data), n, self.taps, self.carry, int(desc.zero_state))
yield self.timeout(self._compute_delay(n))
memw = MemWCmd(addr=int(desc.dst_off), len=nw, fwd_bursts=1)
...
The two branches are deliberately the same shape — descriptor, then command, then (for FILTER)
data. The only difference is len, which is 0 for a load. See
the firing that writes nothing.
The arithmetic is a hook
load_taps and filter_block are @synthesizable — they are the pysim twins of the hand-written
C++ bodies, not their source. The generated kernel does not extract them; a human wrote the C++ and
these are what the golden runs. Keeping them side by side in one class is what makes the pair
reviewable.
@synthesizable
def filter_block(self, x, n, taps: HwState, carry: HwState, zero_state: int):
t = int(self.ntap)
xs = unpack_samples(x, n, self.samp_cls, self.mem_dwidth)
prev = np.zeros(t - 1, dtype=np.int64) if zero_state else np.asarray(carry.val, dtype=np.int64)
# The window: buf[i : i+T] reversed is [x[i], x[i-1], ..., x[i-T+1]], aligned with h[0..T-1].
buf = np.concatenate([prev, xs])
win = np.lib.stride_tricks.sliding_window_view(buf, t)[:, ::-1]
prod = mult(_as_fixed(win, self.samp_cls),
_as_fixed(np.asarray(taps.val, dtype=np.int64), self.samp_cls))
acc = fixed_sum(prod, axis=1) # +ceil(log2 T) integer bits, NOT +T
y = quantize(acc, self.samp_cls)
carry.val[:] = buf[len(buf) - (t - 1):] # the next block's initial condition
return pack_samples(np.asarray(y).reshape(-1), self.samp_cls, self.mem_dwidth)
Note that this is one twin for both realizations. unroll_lane changes the RTL’s iteration
structure, not its arithmetic, so there is exactly one golden — see
the two kernels.
The sliding_window_view is the vectorized statement of the whole filter: buf is the previous tail
concatenated with this block, and every output is one row of the reversed window matrix dotted with
the taps.
Samples and words
One conversion runs through the entire design, and getting it wrong is silent:
def lane_width(mem_dwidth, samp_w):
"""LW — samples per transport word: max(1, MEM_DW // W)."""
return max(1, int(mem_dwidth) // int(samp_w))
def nwords(n, lw):
"""Transport words for n samples at LW samples per word."""
return (int(n) + int(lw) - 1) // int(lw)
Commands and descriptors count samples; MemRStream / MemWStream and the arena count words.
At the defaults (MEM_DW = 32, W = 16) LW = 2, so a 64-sample block is 32 words.
The packing itself is never hand-rolled. pack_samples / unpack_samples go through
DataArray.serialize, which is the same contract the generated
<stem>_array_utils lane routines implement in C++:
def pack_samples(stored, samp_cls, word_bw):
arr = np.asarray(stored, dtype=np.int64).reshape(-1)
cls = DataArray.specialize(samp_cls, max_shape=(max(len(arr), 1),))
return np.asarray(cls(arr).serialize(word_bw=int(word_bw)), dtype=np.uint64)
An earlier version of this example hand-rolled the packing on both sides and asserted one sample per word. It was correct, because
LWhappened to be 1 — and it would have silently diverged from the RTL the moment a width changed. If a kernel here grows a.range()to pull elements out of a word, that is the bug and not the idiom.
The composite
self.rx = FirCmdRx(..., mem_dwidth=w, samp_w=int(self.samp_w), clk=self.clk)
self.rstream = MemRStream(..., mem_dwidth=w, inband=True, platform_dir=self.platform_dir)
self.compute = FirCompute(..., ntap=int(self.ntap), samp_w=int(self.samp_w),
samp_i=int(self.samp_i), unroll_lane=bool(self.unroll_lane), ...)
self.wstream = MemWStream(..., mem_dwidth=w, inband=True, emit_done=True, ...)
for c in (self.rx, self.rstream, self.compute, self.wstream):
self.add_comp(c)
_sif("cmd_rd", self.rx.cmd_out, self.rstream.s_cmd) # [MemRCmd | FirDesc]
_sif("rdata", self.rstream.m_out, self.compute.s_in) # [FirDesc | taps-or-block]
_sif("wdata", self.compute.cmd_out, self.wstream.s_in) # [MemWCmd | FirDesc | y]
self.boundary = ["s_cmd", "m_in", "m_out", "s_done"]
Four sub-components, three framed internal edges, four boundary ports. Every internal edge is framed
(framed=True); only the host boundary is a plain word stream. boundary names which ports become
top-level ports when the graph is lowered — everything else becomes an internal FIFO.
Two of the four stages are framework components with shipped, XSI-verified timing, so this design owns exactly one custom thing worth calibrating: the compute.
Where to next
- Testbench — the graph that drives this, and the golden that judges it.
- The two kernels — the C++ the two
@synthesizablehooks above are twins of. - DUT codegen — how this graph becomes an
hls::tasktop.