Overriding the generated task

A free-running leaf normally declares nothing: its task body is generated from run_iter, and its KernelTask descriptor — the C++ name, the header, the argument order, the template arguments — is derived from the module by the same helpers that emit the body.

Sometimes the framework cannot write the body. Then you write it, and kernel_task() is how you hand it over.

When this happens

The body owns an m_axi master. Task-body emission refuses these outright:

MemRStream has m_axi masters ['m_mem']; task-body emission is stream-only today.

That is a scope boundary, not a law of HLS — a free-running task may carry m_axi, and the framework’s own mem_r_stream_task.h does. The emitter simply has not answered what an m_axi body needs (bundle naming, depth, who owns the offset=slave register), so it refuses rather than emit something unreviewed.

The body is outside the extractor’s vocabulary. Constructing descriptors, driving a framed_word channel, holding stream-of-blocks locks — none of that is in the fixed list of shapes the extractor recognizes.

Both cases are the same answer: a fixed, hand-written, reviewed task body, named through kernel_task().

The override

MemRStream is the worked case (waveflow/hw/mem_stream.py):

def kernel_task(self) -> KernelTask:
    return KernelTask(
        "mem_r_stream_framed_task",       # task_fn      — the C++ function
        "mem_r_stream_framed_task.h",     # header       — where it lives
        ("s_cmd", "m_mem", "m_out"),      # signature    — endpoint attrs, IN ARGUMENT ORDER
        template_args=(int(self.mem_dwidth),),
    )

which the composite generator turns into:

hls_thread_local hls::task t1(mem_r_stream_framed_task<64>, cmd, m_in, copy_data);

Why none of this is derivable

Look at the third argument. ("s_cmd", "m_mem", "m_out") is stream, m_axi, stream — and that order is a fact about the C++, not about the Python. Whoever wrote the header chose it. The module’s endpoints could be declared in any order at all, and nothing about the Python implies that the memory pointer belongs in the middle.

signature is the map between the two: the endpoint attribute names, in task-argument order. The generator walks it, resolves each name to either a boundary port or an internal channel — decided by what that endpoint was bound to — and emits the call. That is the seam that lets the top be derived from the graph while the body stays hand-written.

The same applies to task_fn and header: nobody can guess that MemRStream’s body is called mem_r_stream_framed_task rather than mem_r_stream_task. And template_args are the concrete values to bake into the C++ template, in the order the template declares them.

run_iter stays

Overriding kernel_task does not remove run_iter. It stays as the pysim golden: the model that says what the hand-written C++ is supposed to do. It is no longer extracted — the body you named is used instead — but it is still what the Python simulation runs, and what the RTL is checked against.

Nothing checks the C++ against the Python except a test. That is the standing arrangement for hand-written bodies everywhere in the framework, hooks included.

What the base class does otherwise

The default kernel_task() derives all four fields, and overriding is detected by identity against that base method — the same way _kind() detects a run_iter override. So:

  kernel_task()
generated leaf derived; declare nothing
hand-written leaf override
composite raises — a composite has no task of its own, only its children do

A generated leaf that declared one anyway would be restating what the generator already knows, and nothing cross-checks the two — a mismatched name surfaces as a csynth error about a missing function, not a Waveflow one. That is exactly why the derivation exists.

One field carries a second job worth knowing about: task_fn is also the component’s identity key for calibration, so renaming a hand-written task renames its calibration data.

See also