Free-running kernel in HLS
A FreeRunMod lowers to a composite_kernel: an ap_ctrl_none top
whose entire body is hls::task instantiations. There is no ap_start, no ap_done, and no control
loop — the runtime re-fires each task on every new job.
One target covers both shapes, because a leaf is the 1-task degenerate case of a composite and the same generator walks both. This page takes the leaf; the composite is next.
The example
Square — one 4-lane vector of floats in, its element-wise square out. One firing consumes one
vector. (examples/toy/toy.py.)
class Vec(DataArray):
element_type = Float32
static = True
max_shape = (4,)
@dataclass
class Square(FreeRunMod):
cpp_kernel_name: ClassVar[str | None] = "square"
def __post_init__(self) -> None:
super().__post_init__()
self.x_in = StreamIFSlave( name=f"{self.name}_x_in", sim=self.sim, bitwidth=32)
self.y_out = StreamIFMaster(name=f"{self.name}_y_out", sim=self.sim, bitwidth=32)
self.add_endpoint(self.x_in)
self.add_endpoint(self.y_out)
def run_iter(self) -> ProcessGen[None]:
x = yield from self.x_in.get_schema(Vec) # one n-vector
y = self.square(x)
yield from self.y_out.write(y)
@synthesizable
def square(self, x: Vec) -> Vec:
return x * x # element-wise y = x²
Two artifacts, not one
Unlike a host-activated kernel, which is one function, a free-running module generates two separate things:
| Artifact | Comes from | Generated by |
|---|---|---|
| the task body — one firing | run_iter |
task_files_to_str |
| the top — ports, pragmas, task instantiations | the module graph | composite_top_spec → render_top |
They are separate because they come from different kinds of source. The body is a method, extracted
statement by statement. The top is structure — what add_endpoint, add_comp and add_if
recorded — and never comes from a function body at all. For a leaf that structure is trivial (one
task, no internal edges), which is exactly why a leaf needs no separate path.
1. The task body
static void square_task(
hls::stream<ap_uint<32> >& x_in,
hls::stream<ap_uint<32> >& y_out
) {
Vec x;
x.read_stream<32>(x_in);
Vec y = square_impl::square(x);
y.write_stream<32>(y_out);
}
The Python’s three statements map across directly — x_in.get_schema(Vec) became a declaration plus
read_stream, the hook call became a call, y_out.write(y) became write_stream. What is
interesting is what is absent.
There is no loop. run_iter had none either, and that is the point: one invocation of this
function is one firing. In simulation the base class loops run_iter forever, but that loop is the
discrete-event stand-in for the runtime re-firing the task — it is not part of the design and is not
emitted. So there is no “before the loop”, and anything that must survive between firings cannot
be a local. It needs HwState, which lands as a static at the top of this
body.
There is no INTERFACE pragma. The body sees plain hls::stream<ap_uint<32>>& references and
does not know whether they are boundary ports or internal FIFOs. The top owns the interface. That
separation is what lets one body be wired to a top-level port in one design and to an internal channel
in another.
It is static and lives in a header, so it has internal linkage — the same shape as the hand-written
task bodies in waveflow/build/.
One shape rule shows up here: y is named before it is written. write(self.square(x)) would not
extract, because a call nested inside write(...) is not one of the
extractor’s statement shapes.
2. The hook
As with any target, @synthesizable marks where the generator stops:
#include "square_task.h"
namespace square_impl {
Vec square(Vec x) {
// TODO: implement square
return Vec{};
}
}
x * x in the Python is the simulation golden, not a lowering. The C++ is yours, and nothing
checks the two against each other. For this flow that job belongs to the
XSI gate, which compares the RTL’s output against what the Python model produced.
3. The top
Derived from the graph, and for a leaf almost entirely pragma:
void square(
hls::stream<ap_uint<32> >& x_in,
hls::stream<ap_uint<32> >& y_out
) {
#pragma HLS INTERFACE axis port=x_in
#pragma HLS INTERFACE axis port=y_out
#pragma HLS INTERFACE ap_ctrl_none port=return
hls_thread_local hls::task t0(square_task, x_in, y_out);
}
ap_ctrl_none port=return is the counterpart to a host-activated kernel’s s_axilite port=return,
and it is the whole difference between the two flows. No handshake at all: no ap_start, no
ap_done. The block is never started and never finishes; it consumes its input as data arrives and
stalls when the stream is empty. Its pace comes from back-pressure, not from a caller — which is also
why Vitis cannot co-simulate it, and why verification drops to
RTL through an XSI BFM.
hls_thread_local gives the task static storage duration: spawned once, persisting for the life of
the design rather than created per call.
A leaf declares no boundary. The port list comes from kernel_task()’s signature, so the top’s
parameter list and the task’s call arguments are literally the same list and cannot disagree.
Direction comes from each endpoint’s type — a StreamIFSlave is an input, a StreamIFMaster an
output — so nothing is stated twice.
Nothing else to declare
A leaf declares no boundary and no task descriptor. The port list, the task’s C++ name, its header, and its template arguments are all derived from the module — by the same helpers that emit the body, so the two cannot disagree about any of them.
The one thing that is not derivable is a body the framework did not write: nobody can guess the
function name, the header, or the parameter order someone else chose. That is
Overriding the generated task, and it is also how a leaf that owns an
m_axi master gets a body at all — task-body emission refuses those, a scope boundary rather than a
law of HLS.
See also
- Overriding the generated task — handing over a body you wrote.
- Free-running composite in HLS — the same generator with several tasks and internal channels.
- Concurrent (free-running) — the flow and when to choose it.
- Writing it in Python — the authoring side.
- XSI testbench — how this top is verified at RTL.