Block — load, compute, store
The simplest way to use a hook is to not let it touch the port at all. If your operand is a fixed-size block you can name up front, read the whole block into a buffer, hand the buffer to a pure-compute hook, and write the result back — all in the extractable kernel body. Codegen generates the load and the store for you; you write only the math.
This is the array generalization of the scalar regmap kernel
(examples/regmap/simp_fun.py): there the
register I/O is auto-generated by the s_axilite path and
compute(x, a, b) is a pure scalar function. Here the array I/O is
auto-generated by the m_axi path and compute(x, n) is a pure block function. The
worked example is examples/block_scale
— y[i] = A*x[i] + B over a resident int32 block.
The split: movement in run_proc, math in the hook
run_proc is the extractable kernel body. It does the data movement with
read_array / write_array on the m_axi master, and
delegates the math to the compute hook:
def run_proc(self) -> ProcessGen[None]:
cmd = yield from self.s_in.get(BlockCmd)
x = yield from self.m_mem.read_array(Int32, cmd.n, cmd.x_addr, max_count=self.max_n)
y = yield from self.compute(x, cmd.n) # the hook — pure compute
yield from self.m_mem.write_array(y, Int32, cmd.y_addr, cmd.n, max_count=self.max_n)
@synthesizable
def compute(self, x: BlockBuf, n: int) -> ProcessGen[BlockBuf]:
return block_affine(np.asarray(x)[:int(n)]) # == the golden; C++ is hand-written
yield # unreachable — makes this a generator
max_count is the compile-time bound on the buffer (here the max_n HwParam);
cmd.n is the runtime length actually transferred. Every buffer carries its own
bound — there is no global fallback — so the generated kernel can size each static
local array.
What codegen generates
The read_array / write_array calls are not hooks — they are in the
synthesizable subset, so the extractor lowers them to
read_array_slice / write_array_slice bursts over the resident
[0, n) range. Only compute becomes a call to your C++:
// generated gen/block_scale.cpp — run_proc lowered
BlockCmd cmd;
cmd.read_axi4_stream<32>(s_in);
static ap_int<32> x[max_n];
int32_array_utils::read_array_slice<32>(
m_mem + memmgr::byte_addr_to_word_index<32>(cmd.x_addr), 0, cmd.n, x); // the load
static ap_int<32> y[256];
block_scale_impl::compute(x, cmd.n, y); // your hook
int32_array_utils::write_array_slice<32>(
y, m_mem + memmgr::byte_addr_to_word_index<32>(cmd.y_addr), 0, cmd.n); // the store
Both static buffers carry the same compile-time bound MAX_N (256), shown two ways:
the operand buffer as the max_n HwParam (it came from the max_count=self.max_n
argument) and the result buffer as the resolved literal 256 (from the BlockBuf
bound on compute’s return). cmd.n is the runtime length actually transferred.
read_array_slice<WORD_BW>(mem + word_index(addr), i0, i1, buf) moves an element
range [i0, i1) between the port and a resident buffer, in element coordinates —
the kernel never divides by the packing factor. It is the resident-range access shape;
the reference lists it alongside the throughput read_array_lane
loop (which complex.md uses for data-dependent addressing).
The hook is pure compute
Because the load and store already happened, the hook only sees a materialized C++
array. It never references m_mem:
// block_scale_compute_impl.cpp — the whole hook
namespace block_scale_impl {
void compute(ap_int<32> x[256], int n, ap_int<32> out[256]) {
#pragma HLS INLINE
for (int i = 0; i < n; ++i) {
#pragma HLS PIPELINE II=1
out[i] = ap_int<32>(3) * x[i] + ap_int<32>(-4); // y = A*x + B
}
}
} // namespace block_scale_impl
An array-typed return lowers to a void function with an appended out-parameter (HLS
can’t return an array by value), so the signature codegen calls is compute(x, n,
out). The hook is non-templated — it has no stream argument carrying an HwParam
width — so it lives in a plain .cpp, not a .tpp (see
the .cpp vs .tpp rule). Its Python sibling
(block_affine) is the bit-exact golden, so csim validates the .cpp against it.
When to use it
Reach for the block pattern when:
- the operand is a fixed-size block whose length you know up front (bounded by a
compile-time
max_count); - you don’t need cycle-level streaming — the whole block is resident before compute;
- random access within the block is fine (it is a plain C++ array).
Move on when the data won’t fit or arrives incrementally — process it as it streams (stream.md) — or when the access pattern itself depends on runtime command fields (gather/scatter, strided rows) — drive the port from the datapath (complex.md).
See also
- Writing a hook — the
@synthesizablemechanism and the.cppvs.tpprule. - Kernel transfer reference —
read_array_slice/write_array_sliceand the rest of the in-kernel calls. examples/block_scale— the worked, cosim-verified example.