Custom Hooks
Most of a component lowers to C++ automatically — Component Code Generation
walks the synthesizable subset of your Python and emits the kernel structure. But some datapaths are
beyond what the extractor can lower: a tight complex MAC, a custom pipeline, anything where you want
hand-tuned HLS pragmas and exact ap_fixed intermediates. For those you write the kernel body
yourself — a custom hook — and Waveflow drops it into the generated kernel in place of extracted
code.
Auto-generated vs. hand-written
This is the hand-written side of hardware generation; the auto-generated side is Component Code Generation. The boundary is one decorator:
| Auto-generated | Hand-written (here) | |
|---|---|---|
| Source | the method’s Python body | a .cpp / .tpp you write |
| Lowered by | the HwStmt extractor |
nothing — codegen emits a call to your C++ |
| Use when | the body is in the synthesizable subset | the datapath needs hand-tuned HLS C++ |
The mechanism: @synthesizable
A method decorated @synthesizable with no synth_fn is a
stub: the extractor does not lower its Python body — codegen instead emits a call to a
user-written C++ function. The method’s Python body is still the simulation model (it runs in
PySim); only its C++ is hand-written. So the same method is bit-exact-checked in Python
and synthesized from your hand-written file.
from waveflow.hw.synth import synthesizable
class SimpFunComponent(HwComponent):
@synthesizable
def compute(self, x: Int32, a: Int32, b: Int32) -> Int32:
return Int32(relu_affine(int(x.val), int(a.val), int(b.val))) # == the golden
(From examples/regmap/simp_fun.py. The Python body is the
bit-exact golden; the C++ contract it must match is the hand-written
simp_fun_compute_impl.cpp.) Codegen finds the
C++ by the {kernel}_{method}_impl.{cpp,tpp} convention, or you name it explicitly with
@synthesizable(impl_file="…"). Writing a hook is the full contract.
Which pattern? A decision guide
The three hook patterns differ by who moves the data — and that is also the order of increasing difficulty. Ask what your operand looks like:
| Your operand is… | Who moves the data | The hook body | Pattern |
|---|---|---|---|
| a fixed-size block you can name up front | codegen (auto-gen read_array/write_array in run_proc) |
pure compute over a materialized C++ array | Block |
| a stream that arrives incrementally | the hook’s own lane loop | read loop + compute + write, with TLAST/framing |
Stream |
| a data-dependent memory region (strided rows, gather/scatter) | the datapath itself, over the m_axi port |
strided read_array_lane + the datapath |
Complex |
| a resident window/tile (a sliding window needs random access) | the hook owns the whole load-compute-store pipeline | three HLS functions in a #pragma HLS DATAFLOW region |
Dataflow |
Start at Writing a hook for the mechanism (it uses the simplest case — a scalar hook with no data movement), then jump to the pattern that matches your operand.
In this section
- Writing a hook — the
@synthesizablecontract, the namespace, the bit-exact Python sibling,#pragma HLS INLINE, and.cppvs.tpp, walked through the scalarsimp_funhook. - Block — load, compute, store — auto-generated
read_array/write_arrayI/O, a pure-compute hook. The array generalization of the regmap kernel. - Stream — process as you read — the lane loop over an AXI-Stream port (
read_axi4_stream_lane/write_axi4_stream_lane,pflanes,TLAST). - Complex — data-dependent addressing — driving the
m_axiport from the datapath (read_array_lanewith a running pointer), and the two VMAC csynth gotchas. - Dataflow — load, compute, store pipeline — the hook owns the whole pipeline: three HLS functions in a
#pragma HLS DATAFLOWregion (partitioned-BRAM ping-pong + FIFO), the synthesizable side of the double-buffered timing model. Therowwise_firexample. - Memory command queue — the advanced case: a hook that is the synthesizable half of a transport interface (the
queue_getring dequeue). - Kernel transfer reference — the in-kernel transfer-call cheat sheet (
read_array_lane/read_array_slice/ stream variants) and the Python↔C++ mapping table.
See also
- Component Code Generation — the auto-generated structure your hook plugs into.
- Hardware Components — declaring the component (ports,
HwParam) the hook belongs to. - Serialization / raw arrays — the generated packing methods a hook calls.
Table of contents
- Writing a hook - The hook contract, walked through the simplest hook — the scalar simp_fun compute. The @synthesizable stub form (Python body = bit-exact golden, C++ hand-written), the cpp_namespace, #pragma HLS INLINE so the hook fuses into the top, and the .cpp-vs-.tpp rule (non-templated scalar -> .cpp; HwParam-width-templated -> .tpp).
- Block — load, compute, store - The block hook pattern: the load and store are auto-generated in run_proc (read_array / write_array lower to read_array_slice / write_array_slice bursts), so the hook is pure compute over a materialized C++ array. The array generalization of the scalar regmap kernel, walked through examples/block_scale.
- Stream — process as you read - The stream hook pattern: data arrives incrementally on an AXI-Stream port, so the hook owns its own lane loop — read pf lanes, UNROLL the compute, write pf lanes — carrying TLAST framing and returning an error status. Walked through the poly evaluator in examples/stream_inband.
- Complex — data-dependent addressing - The complex hook pattern: the access pattern itself depends on runtime command fields (strided rows, per-row gather), so the datapath drives the m_axi port — a read_array_lane loop over a running word pointer. Walked through VMAC, with the two csynth gotchas (scalar *_core args; #pragma HLS INLINE so m_axi binds to the top).
- Dataflow — load, compute, store pipeline - The dataflow hook pattern: the hook owns the WHOLE load-compute-store pipeline — three HLS functions wired in a per-row #pragma HLS DATAFLOW region over a partitioned-BRAM ping-pong (load→compute) and an hls::stream FIFO (compute→store), each stage at II=1. Used when a sliding window forces a resident, randomly-addressable row buffer (you cannot stream it). Walked through examples/rowwise_fir.
-
Memory command queue - The synthesizable side of the AXI-MM command queue: AXIMMQueue.get lowers to AXIMMQueueGetStmt, emitted as a call to the hand-written ring-dequeue hook queue_get
in aximm_queue_impl.tpp — read (head,tail), poll while empty, read one slot, advance head with a power-of-two mask, deserialize the typed command. - Kernel transfer reference - The in-kernel transfer-call cheat sheet: the m_axi memory calls (read_array_slice for a resident range, the read_array_lane lane loop for throughput) and the stream calls (read_stream_lane / read_axi4_stream_lane with TLAST), the common loop shapes, and the Python-to-C++ mapping table the pattern pages draw on.