DUT codegen
The Python model is a graph: six leaves, eight internal edges, four boundary ports.
Generating the DUT kernel is walking that graph and lowering it to a free-running Vitis HLS top —
the same lowering mem_copy uses, with nothing added for the gather.
What an hls::task top is
Vitis HLS hls::task is the free-running execution model. A task is a body the runtime re-fires on
its own whenever its input streams have data — there is no host start/done handshake, so the top
is declared ap_ctrl_none. Tasks connected by hls::stream FIFOs run concurrently and overlap:
il_compute gathers job j while il_load is already filling job j+1’s blocks. That is the
concurrent flow’s model, and it is why the interleaver’s six stages
become six tasks wired by channels rather than one sequential function.
The contrast is ap_ctrl_hs (the sequential flow): a kernel the host
launches once and waits on. A free-running task cannot be driven that way — which is also why it cannot
be verified by Vitis C/RTL cosim, and is instead run through XSI.
The generated top
InterleaverInband is a hierarchical FreeRunMod: its __post_init__ calls add_comp for six
children and add_if for eight edges, and names four boundary ports. composite_top_spec
(waveflow/build/composite_gen.py) reads exactly those
three things off the built parent and derives the top — no per-node special-casing. Each child’s
kernel_task() signature is resolved, endpoint by
endpoint, to either a boundary port or an internal channel; render_top emits it:
// gen/interleaver_inband.cpp — GENERATED by waveflow (build/composite_gen.py::render_top)
void interleaver_inband(hls::stream<ap_uint<64> >& s_cmd, const ap_uint<64>* m_in,
ap_uint<64>* m_out, hls::stream<ap_uint<64> >& s_done) {
#pragma HLS INTERFACE axis port=s_cmd
#pragma HLS INTERFACE m_axi port=m_in offset=slave bundle=gmem0 depth=8192
#pragma HLS stable variable=m_in
#pragma HLS INTERFACE m_axi port=m_out offset=slave bundle=gmem1 depth=8192
#pragma HLS INTERFACE axis port=s_done
#pragma HLS INTERFACE ap_ctrl_none port=return
hls_thread_local hls::stream<streamutils::framed_word<64> > cmd_rd; // cmd_rx -> reader
hls_thread_local hls::stream<streamutils::framed_word<64> > rdata; // reader -> il_load
hls_thread_local hls::stream<streamutils::framed_word<64> > desc_lc; // il_load -> il_compute
hls_thread_local hls::stream<streamutils::framed_word<64> > desc_cs; // il_compute -> il_store
hls_thread_local hls::stream<streamutils::framed_word<64> > wdata; // il_store -> writer
hls_thread_local hls::stream_of_blocks<ap_uint<32>[N], 2> p_blk; // il_load -> il_compute
hls_thread_local hls::stream_of_blocks<ap_uint<32>[N], 2> x_blk; // il_load -> il_compute
hls_thread_local hls::stream_of_blocks<ap_uint<32>[N], 2> y_blk; // il_compute -> il_store
hls_thread_local hls::task t0(il_cmd_rx_framed_task<64>, s_cmd, cmd_rd);
hls_thread_local hls::task t1(mem_r_stream_framed_task<64>, cmd_rd, m_in, rdata);
hls_thread_local hls::task t2(il_load_inband_task<64, N>, rdata, desc_lc, p_blk, x_blk);
hls_thread_local hls::task t3(il_compute_inband_task<64, N>, desc_lc, p_blk, x_blk, desc_cs, y_blk);
hls_thread_local hls::task t4(il_store_inband_task<64, N>, desc_cs, y_blk, wdata);
hls_thread_local hls::task t5(mem_w_stream_framed_done_task<64, 8>, wdata, m_out, s_done);
}
Six tasks, wired by hls_thread_local channels of two kinds:
- Five
framed_wordFIFOs — the stream edges (cmd_rd,rdata,desc_lc,desc_cs,wdata), each aStreamIF(framed=True). The framing bit rides the payload so a stage can relay an opaque packet it refuses to parse; the realTLASTlives only on the boundary AXIS ports (an internal FIFO cannot carryap_axis). These lower viaFramedEdge. - Three
stream_of_blocks<ap_uint<32>[N], 2>— the SOB edges (p_blk,x_blk,y_blk), the depth-2 ping-pong PIPO block RAMs that let one stage write the next job’s block while the neighbour reads this job’s. These lower viaSobEdge, whose element width (32) and length come from the interface’selement_type, and pull inhls_streamofblocks.h.
…over two m_axi bundles — m_in→gmem0 (the read owner, const + #pragma HLS stable) and
m_out→gmem1 (the write owner) — and two boundary AXIS ports (s_cmd in, s_done out). Bundles
are assigned by policy in boundary order (bundle_map), not stated on the ports. The single-source facts
here are the graph’s: the edge kind is the interface’s type (StreamIF framed → framed FIFO,
StreamOfBlocksIF → SOB), and the port direction is the endpoint’s type — the same StreamEdge +
SobEdge vocabulary that renders a mem_copy composite renders this one, with no interleaver-specific
branch.
N is a template arg; n is runtime
N — the block capacity, baked concrete at generate time — is a compile-time template argument on
the four custom tasks. The runtime length n rides the IlDesc descriptor through the
framed edges, so one synthesized RTL processes any job with n ≤ N; the block, burst, and loop
bounds are N, and n sizes each firing. There is no #define and no while — the width and capacity
are template arguments, and the task runtime supplies the re-firing.
The bodies are all hand-written
The top generates; every task body does not. composite_top_spec instantiates the bodies but never
writes one — they are separate artifacts, copied verbatim into include/ by MemStreamStep
(waveflow/build/streamutils.py).
The four custom leaves are width/param-templated headers under
waveflow/build/:
il_cmd_rx_framed_task.h— reads theInterleaverCmdand frames the reader’s twoMemRCmds + the relayed descriptor;il_load_inband_task.h— deserializes P and X intop_blk/x_blkviaread_framed_stream_lane;il_compute_inband_task.h— the gather,y_blk[i] = x_blk[p_blk[i]], under three SOB locks;il_store_inband_task.h— serializesy_blkback to framed words and frames the writer’sMemWCmd.
None of these is lowered from run_iter. Each constructs DataSchemas (IlDesc, MemRCmd, MemWCmd),
drives framed_word channels, and holds stream-of-blocks read/write locks — none of which is in the
code generator’s vocabulary. So each body’s run_iter (Python model) is a pysim
golden whose only tie to its C++ is a test.
The two mem-stream leaves are framework bodies —
mem_r_stream_framed_task.h and
mem_w_stream_framed_done_task.h — the same
headers mem_copy composes. They are hand-written too, for the harder reason: a body that owns an
m_axi port is never generated (bundle naming, depth, and the offset register are decisions the
emitter does not make).
So the dividing line is the one mem_copy draws — the top generates, the bodies are copied — but the
interleaver falls entirely on the copied side. Unlike mem_copy, it has no generated stream-only
leaf: mem_copy’s page notes a leaf whose run_iter is just get → @synthesizable hook → write can
have its body generated by TaskBodyStep. The interleaver has no such leaf — all four of its custom
stages construct schemas, frame channels, or use SOBs, so all four are hand-written.
Building it
generate_inband()
(examples/interleaver/interleaver_inband.py)
emits all of this — the headers, the composite top, its csynth .tcl, and the DUT port map:
from examples.interleaver.interleaver_inband import generate_inband
generate_inband(out_dir="build/il", mem_dwidth=64, n=512) # -> build/il/gen/interleaver_inband.cpp
It runs without the toolchain up to the csynth call; the RTL-timing build
(measure_compute_spans.py) calls it as its
first step. It generates:
gen/interleaver_inband.cpp— the top above;interleaver_inband.tcl— drives Vitis HLS C-synthesis (thecsynthstep, which produces the RTL the RTL-timing rung drives);xsi/interleaver_inband_ports.h— the DUT’s port map, which the generated testbench harness includes;
plus, via gen_headers, the command structs, streamutils / memmgr, the block element type’s
array-utils (read_framed_stream_lane), and the six copied task bodies. Everything generated carries a
DO NOT EDIT banner, and a regenerate overwrites a hand-edit — so don’t hand-edit them.
csynth result
Verified on Vitis HLS 2025.1: interleaver_inband C-synthesizes clean as an ap_ctrl_none
free-running top. All six tasks synthesize; the two m_axi bundles (gmem0 read, gmem1 write) and
the five framed_word FIFOs + three ping-pong PIPO block RAMs (p_blk / x_blk / y_blk) come
out as the graph specifies; timing closes at Fmax ≈ 111 MHz against a 100 MHz target. Because the top
is ap_ctrl_none, csynth is where the C++ is checked — the behaviour is verified through
XSI, not Vitis cosim.
Next
Testbench codegen — how the InterleaverInbandTB graph becomes the XSI BFM harness
that drives this top.