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_specrender_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