Host-activated kernel in HLS

A HostActivated module lowers to a control_driven_kernel: a single ap_ctrl_hs top-level function that a host launches and waits on. Its registers become s_axilite ports, its on_start becomes the function body, and each @synthesizable hook becomes a call to C++ you write by hand.

That is easier to see than to describe, so this page walks one module all the way through.

The example

simp_funy = relu(a·x + b), three scalars in and one out. It is the same module Writing it in Python builds, so the two pages are the two halves of one story. (examples/regmap/simp_fun.py.)

@dataclass
class SimpFun(HostActivated):
    cpp_kernel_name: ClassVar[str | None] = "simp_fun"
    cpp_namespace:   ClassVar[str | None] = "simp_fun_impl"

    def __post_init__(self) -> None:
        super().__post_init__()
        self.regmap = VitisRegMap({
            "x": RegField(Int32, RegAccess.RW, description="Input operand"),
            "a": RegField(Int32, RegAccess.RW, description="Multiply coefficient"),
            "b": RegField(Int32, RegAccess.RW, description="Bias term"),
            "y": RegField(Int32, RegAccess.R,  description="relu(a*x + b)"),
        })
        self.s_lite = VitisRegMapMMIFSlave(..., regmap=self.regmap, on_start=self.on_start)
        self.add_endpoint(self.s_lite)

    def on_start(self) -> ProcessGen[None]:
        y = self.compute(self.regmap.get("x"), self.regmap.get("a"), self.regmap.get("b"))
        self.regmap.set("y", y)

    @synthesizable
    def compute(self, x: Int32, a: Int32, b: Int32) -> Int32:
        return Int32(relu_affine(int(x.val), int(a.val), int(b.val)))

And here is the whole of what that generates:

#include "simp_fun.hpp"

void simp_fun(
    ap_int<32>& x,
    ap_int<32>& a,
    ap_int<32>& b,
    ap_int<32>& y
) {
#pragma HLS INTERFACE s_axilite port=x            bundle=control
#pragma HLS INTERFACE s_axilite port=a            bundle=control
#pragma HLS INTERFACE s_axilite port=b            bundle=control
#pragma HLS INTERFACE s_axilite port=y            bundle=control
#pragma HLS INTERFACE s_axilite port=return       bundle=control
    ap_int<32> _y_local = simp_fun_impl::compute(x, a, b);
    y = _y_local;
}

Four things happened. Taking them in turn.

1. Each register became a port

The four RegFields became the function’s four arguments, as references, with their C++ types resolved from the field schemas (Int32ap_int<32>). RegAccess did not change the signature — x, a, b and y are all & — because direction is carried by the register block, not by the argument.

The bundle=control on each pragma is what collects them into one AXI-Lite slave rather than four separate interfaces. That bundle is the register block the host writes.

You declared four registers and got four ports, but the block has more in it than that. VitisRegMap adds the Vitis control registers itself: ap_start and ap_done share the control word at 0x00, and 0x04/0x08/0x0c are the global and IP interrupt registers. Your application registers land at 0x10 onward, at Vitis’s 8-byte scalar stride. That is why the Python declares only x, a, b, y — the control plane is not yours to write.

2. port=return made it host-startable

#pragma HLS INTERFACE s_axilite port=return bundle=control

This is the line that matters most, and it is the one with no counterpart in the Python.

The return port carries the kernel’s block-level control protocol. HostActivated means ap_ctrl_hs — “handshake”: the block gets ap_start (“go”), ap_done (“finished”), plus ap_idle and ap_ready. A caller raises ap_start, the kernel runs to completion, raises ap_done, and stops.

Putting return in the control bundle puts that handshake inside the AXI-Lite block, so a host starts the kernel by writing bit 0 of 0x00 rather than by driving a pin.

Two consequences follow from ap_ctrl_hs, and everything about this flow follows from them:

  • The kernel is a function — arguments in, one run, a return. A testbench can call it, and Vitis will build the RTL co-simulation harness around that call for you. The free-running kernel gives both of those up.
  • ap_done is not yours to raise. VitisRegMapMMIFSlave manages it: cleared on ap_start, set when on_start returns. Returning is signalling done.

3. on_start became the body

on_start is extracted — read as source, never run, and translated statement by statement. Two statements in, two statements out:

Python C++
self.regmap.get("x") x — the port is already in scope, so the read is the identifier
y = self.compute(...) ap_int<32> _y_local = simp_fun_impl::compute(x, a, b);
self.regmap.set("y", y) y = _y_local;

regmap.get and set do not become function calls; they become a use and an assignment of the port that the register already is. The local is named _y_local rather than y precisely because y is taken — by the output port it is about to be written to.

A HwState declaration, if the module had one, would appear as a static at the top of this same function body.

4. compute became a call to code you write

This is the seam. on_start was lowered; compute was not. What codegen emitted for it is a declaration and a stub:

#include "simp_fun.hpp"

namespace simp_fun_impl {
ap_int<32> compute(ap_int<32> x, ap_int<32> a, ap_int<32> b) {
    // TODO: implement compute
    return ap_int<32>(0);
}
}

You fill that in; the checked-in simp_fun_compute_impl.cpp is the real one. The Python body of compute stays behind as the simulation golden — it is never lowered, and nothing checks the two against each other. That is what the flow’s C-simulation gate is for: it compares the C++ kernel’s outputs against what the Python model produced, bit for bit.

The namespace comes from cpp_namespace (default <kernel>_impl), which is why the call site reads simp_fun_impl::compute.

The file set

kernel_files_to_str emits simp_fun.hpp (the declaration plus schema includes), simp_fun.cpp (above), and simp_fun_compute_impl.cpp (the stub) — one stub per hook. The first two are framework-owned and rewritten on every build; the stub is sticky and never overwritten once you have edited it. That distinction, and .cpp-vs-.tpp routing for templated hooks, is Generated files.

A module with no register map

Not every host-activated kernel has a regmap. One without it is still ap_ctrl_hs — its arguments simply arrive as ordinary ports rather than registers, and its control is on raw pins instead of in an AXI-Lite block. block_scale is one.

This is easy to mistake for free-running, because both extract a method other than on_start. The difference is the protocol, and raw pins are still a handshake.

See also