Module Code Generation
A key feature of Waveflow is that it generates HLS and related code from certain
HwModules — you write the module once in Python (its ports, its parameters,
its behavior) and the generator emits build-ready C++ from that single source.
It is automatic for everything mechanical: the top-level function and its signature, the
#pragma HLS INTERFACE directives, the AXI-Lite regmap struct, the C++ type lowering, the testbench
harness. It is semi-automatic where the datapath needs hand-tuned HLS: a method marked
@synthesizable is a hook boundary, so the generator emits its declaration and
a // TODO stub, and you write the body. The generated wrapper is the part you never have to write;
the hook is the part no generator can guess. Authoring those bodies is the next chapter,
Custom Hooks; this chapter is the structure they plug into.
Targets
Each distinct code output is a target. The vocabulary is shared verbatim with
Hardware modules and Flows and lives in one place in code
(waveflow/hw/codegen_targets.py), so the two cannot drift
apart.
Each kind of module declares the targets that exist for it, as potential_targets:
| Target | Declared by | Flow | Status |
|---|---|---|---|
control_driven_kernel |
HostActivated |
1 | Built |
sequential_vitis_tb |
SeqTB |
1 | Built |
composite_kernel |
FreeRunMod — leaf or composite |
2 | Built |
sequential_xsi_tb |
a testbench FreeRunMod graph |
2 | Built |
bitstream |
— | 3 | Named, not implemented |
One name, composite_kernel, covers a free-running leaf and a composite: a leaf is the 1-task
degenerate case, and composite_top_spec walks both. There is no separate free_running_kernel.
Four of the five are built — Flows 1 and 2 end to end. bitstream is named rather than
silent, which is what lets check() answer precisely rather than failing obscurely; it is the future
work of the bitstream flow.
The single source for this list is
waveflow/hw/codegen_targets.py, which also records which
are implemented — so this table and the code cannot drift silently.
potential_, notsupported_. A class declares the paths that exist for its kind — not a promise about any particular module. Whether this module actually makes it down one ischeck()’s answer, not the class’s. Synthesizability is a codegen axis, not a class fact (taxonomy).
Validation and generation
Generating a target takes one input — a source. A source is a Python class: the HwModule
you want realized (SimpFun), or the SeqTB that drives it
(SimpFunTBHls). You never name a method; the entry follows from the module’s kind
(HostActivated → on_start, a standalone FreeRunMod → run_iter, SeqTB → main, and a
composite FreeRunMod has no body at all — its codegen is the sub-component graph).
Generation is then two steps over the same (source × target) pair:
validate(source, target) — can this source be lowered to this target?
emit(source, target) — write the C++
generate(source, target) = validate + emit
check(source, target) = validate -> (ok, err_msg)
Splitting them is what makes codegen fail-loud: the source is inspected first, and one that cannot be lowered raises rather than quietly emitting something wrong. Where possible the error names the actual problem and the fix — not just “cannot synthesize”.
check is the same validation with the exception turned into a verdict. It is what makes “certain
HwModules” precise rather than folklore — you can ask:
>>> from waveflow.build.codegen_check import check
>>> check(SimpFun)
(True, None)
>>> check(SimpFun, "bitstream")
(False, "'bitstream' is not a potential target for SimpFun; its potential targets are {'control_driven_kernel'}")
check knows no rules of its own. It runs the real validation, throws the result away, and
reports what happened — so it cannot claim a rule codegen does not enforce, nor miss one it does. A
separate “lightweight” checker would drift; running the same code is the design. The rules live in the
Extractor, and adding one there makes check report it for free.
What validation does not cover
Validation checks the parts Waveflow generates. It says nothing about the parts you write: a custom hook body is never verified — codegen emits its declaration and a stub, and the C++ you fill in is yours. Nothing checks that it matches the Python it was derived from, or that it is correct at all. That is what the example’s C-simulation and co-simulation are for.
In this section
The section is one page per target, then the shared mechanism. Module structure is the frame all four share; then host-activated, free-running (and its composite form), sequential testbench, and XSI testbench are how each is realized. The pages after those — interfaces, extractor, generated files, templating — are the machinery they have in common. How to describe a module in Python is the previous section, Hardware modules and Flows.
Start with Automatic vs. manual — where the generator stops and you begin — then Module structure for how a module becomes a kernel, and Extractor for what your body may contain. Those three are what you need to write a module. The rest is reference: reach for it when you look inside the generated C++, which mostly means when you write a hook.
- Automatic vs. manual — what codegen writes and what you write: everything structural is generated; the compute inside a
@synthesizablehook is yours. - Module structure — the frame all four targets share: one top-level function per module, which method is extracted for which kind, entry-is-extracted vs hook-is-not, and the contract for when a module lowers at all.
- Elaboration —
elaborate(cls, params), the sim-free entry that builds a module purely to read its structure, and the param-purity contract that lets codegen be keyed by(class, param-set)instead of by an instance. - Host-activated kernel in HLS — the
control_driven_kernel:ap_ctrl_hs, thes_axiliteregister block, andon_startas the body. - Host launch lifecycle — the Python model of that handshake:
VitisRegMap’s control block, theap_start/ap_donecycleVitisRegMapMMIFSlaveruns aroundon_start, and theBoundRegMaphost surface. - Endpoint kinds — the boundary-kind vocabulary, where each kind is declared, and the two tables that consume it: the HLS port emitter and the XSI BFM lookup. One dispatch, both backends — which is why a BFM model is not hand-written.
- Endpoint interfaces — how each declared endpoint (stream / m_axi / regmap) is realized as a Vitis port (
hls::stream/m_axi/s_axilite) and how a slave endpoint’s handler binds. - Free-running kernel in HLS — the
composite_kernel, 1-task case: the task body that is one firing, theap_ctrl_nonetop, andKernelTask. - Overriding the generated task — handing over a hand-written task body with
KernelTask, for anm_axiowner or anything outside the extractor’s vocabulary. - A module realized as Verilog — the
rtl_moduletarget: declaring pre-written Verilog to be instantiated beside the kernel (the memory a kernel cannot contain), the endpoint → port-name chain, and the rule that its read latency has one source. - Free-running composite in HLS — the same target with several tasks: internal channels from
add_if, tasks fromadd_comp, and the boundary derived from what was left unbound. - Extractor — the synthesizable subset: what the rules are, why each exists, and
checkas their callable form. - Generated files — the two file lifecycles (framework-owned
gen/vs sticky hook impls),.cppvs.tpprouting, and naming. - Templating — the C++ realization of parameterization: how
HwParamlowers (concrete widths /.tpptemplate params),HwConst(deferred), and howparam_supportsemits variant kernels. - Sequential testbench — how a
SeqTB’smain()lowers to asequential_vitis_tb. - XSI testbench in HLS — how a testbench graph lowers to a
sequential_xsi_tb: participants map to pre-written BFM models, and the scenario lives in burst bundles rather than in the C++.
See also
- Hardware modules and Flows — the end-to-end recipe per target: which build steps run, in what order, and how the result is verified. This section is the per-target mechanics; that section is the story.
- Hardware Modules — the Python
HwModulethis section generates C++ for. - Custom Hooks — the hand-written synthesizable kernel bodies that plug into a generated module.
- Build System — the
BuildDagthat drives these codegen steps end to end.
Table of contents
- Automatic vs. manual - Where the generator stops. Everything structural is automatic — the kernel signature, every interface pragma, the regmap struct, the type lowering, the entry body's shape, the testbench harness. What you write by hand is the compute inside a @synthesizable hook, and nothing else. For simp_fun that is 95 generated lines against six you write. A kernel with no hand-tuned datapath (CmdRx) needs nothing manual at all. Over time more of the compute becomes automatic, including via AI agents.
- Module structure - The frame shared by all four targets: every generating HwModule produces ONE top-level function whose arguments are its endpoints, which method is extracted follows from the module's kind, the entry IS extracted while @synthesizable hooks are NOT, and a module lowers iff it is structurally flat and its body passes the extractor. The per-target realizations are the four pages that follow.
- Elaboration - elaborate(cls, params) is the single sim-free entry that builds an HwModule purely to read its structure — endpoints, sub-modules, interfaces, boundary. The contract is that structure is a pure function of the HwParam/HwConst values, so codegen is keyed by (class, param-set) rather than by any instance; assert_param_pure enforces it by elaborating twice and comparing a name-agnostic signature. Not to be confused with xelab RTL elaboration, which is the same idea one stage later and one language down.
- Host-activated kernel in HLS - A HostActivated module lowers to a control_driven_kernel: one ap_ctrl_hs top-level function whose s_axilite register block carries the application registers plus the Vitis control word. Walks simp_fun (y = relu(a*x + b)) from Python to generated C++ — the regmap becoming ports, the pragma that makes the kernel host-startable, on_start becoming the body, and the hook left as a hand-written stub.
- Host launch lifecycle - How a host starts a HostActivated kernel and learns that it finished, modelled in SimPy — VitisRegMap's ap_ctrl_hs control block (the 0x00 word, the 0x10 user-field base), the ap_start / ap_done handshake VitisRegMapMMIFSlave runs around on_start, and the BoundRegMap host surface (bind_master / start / poll_end). Includes what the model does not reproduce about real ap_ctrl_hs, and the planned host-artifact generators.
- Free-running kernel in HLS - A FreeRunMod lowers to a composite_kernel: an ap_ctrl_none top whose body is nothing but hls::task instantiations, plus one task body per leaf. Walks the vector-square toy (y = x*x over a 4-float Vec) from Python to generated C++ — the task body that is ONE FIRING rather than a loop, the top that instantiates it, and why the two are separate artifacts.
- Overriding the generated task - When the framework cannot write a leaf's task body — an m_axi owner, or anything outside the extractor's vocabulary — the module hands over a body you wrote by overriding kernel_task() with a KernelTask. Explains what each field is, why none of them is derivable in that case (the parameter order is a fact about the C++), and that run_iter stays as the pysim golden.
- A module realized as Verilog - The third realization hook. A module declares pre-written Verilog with rtl_module() and it is instantiated beside the generated kernel — never generated from Python. Covers why a memory shared between two tasks cannot live inside a Vitis kernel, the endpoint → C++ parameter → Vitis port-name chain, the rule that the read latency has exactly one source (the .v publishes it, the pragma is derived from it), the declared resource footprint, and the conformance gap nothing static can close.
- Free-running composite in HLS - The same composite_kernel target with more than one task. Walks mem_copy (Sequencer -> MemRStream -> MemWStream) from graph to generated top: one hls_thread_local channel per internal interface, one hls::task per child, boundary ports derived from which endpoints were left unbound, and the ownership rule about which task may hold m_axi.
-
Sequential testbench - The sequential_vitis_tb target: a SeqTB's main() lowers to a C++ int main() emitted as
_tb.cpp. Same extractor as a kernel, different rule profile — a testbench may build a DUT, read files, push/pop streams and call dut.run(), which a kernel body may not. The body must be straight-line: spawning a SimPy process is rejected, not future work. - XSI testbench in HLS - How a testbench FreeRunMod graph is realized as a cycle-based XSI BFM: tb_top_spec walks the graph, each participant maps to a pre-written C++ BFM model via bfm_model(), and render_tb_harness plus render_tb_main emit a harness that drives the elaborated RTL in xsim. The target is sequential_xsi_tb, and it exists because Vitis cannot co-simulate an ap_ctrl_none DUT.
- Endpoint kinds - The boundary-kind vocabulary — the seven names an endpoint can lower to, where each one is declared, and the two tables that consume them. One dispatch feeds both backends: the HLS port emitter and the XSI BFM lookup read the same kind, which is why a BFM model is not something you write by hand.
-
Endpoint interfaces - How each declared endpoint on an HwModule is realized as a Vitis HLS port: stream endpoints become hls::stream
>& with #pragma HLS INTERFACE axis; m_axi masters become ap_uint * with m_axi offset=slave bundle=gmem; a VitisRegMapMMIFSlave becomes s_axilite register-field ports plus the ap_start/ap_done control protocol. Also how a slave endpoint's handler binds to the kernel body. - Extractor - Extraction reads a component's entry method as source (it never runs it), parses it, and translates it into HwStmt — a small hardware IR of ~25 statement types that sits between the Python and the C++. Only a limited set of Python statements and a fixed vocabulary of endpoint operations can be extracted today; anything else raises SynthesisError rather than emitting doubtful C++. check(source, target) is the same rules as a predicate.
-
Generated files - Emitting the resolved HwStmt tree as files. Two lifecycles: gen/ files are framework-owned and rewritten every run; hook impl files are sticky — written once if absent, then yours forever. Kernel tops are global functions; hooks live in a
_impl namespace. The .tpp extension marks a templated hook, and a stale extension is an error. -
Templating - The C++ realization of component parameterization: HwParam values lower to concrete literal widths in the top kernel signature and to template parameters in .tpp hooks (HwParamValue carries the param name so the emitter chooses name vs literal); HwConst lowers to static constexpr (currently deferred); and param_supports emits one concrete
_ top per variant from a single class.