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_word FIFOs — the stream edges (cmd_rd, rdata, desc_lc, desc_cs, wdata), each a StreamIF(framed=True). The framing bit rides the payload so a stage can relay an opaque packet it refuses to parse; the real TLAST lives only on the boundary AXIS ports (an internal FIFO cannot carry ap_axis). These lower via FramedEdge.
  • 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 via SobEdge, whose element width (32) and length come from the interface’s element_type, and pull in hls_streamofblocks.h.

…over two m_axi bundlesm_ingmem0 (the read owner, const + #pragma HLS stable) and m_outgmem1 (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/:

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 (the csynth step, 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.