The kernel
vec_mult_task.h is hand-written and declared through
kernel_task(). It is worth reading closely
because every resource number in this example traces back to two lines of it — one buffer
declaration and one pragma.
template <int DWID, int VLEN>
static void vec_mult_task(hls::stream<ap_uint<DWID> >& s_in,
hls::stream<ap_uint<DWID> >& z_out) {
typedef vm_au::value_type samp_t;
const int LW = vm_au::lane_capacity<DWID>();
const int SAMP_W = samp_t::width; // from the type, never a literal
VecCmd cmd;
cmd.read_stream<DWID>(s_in);
const int n = (int)cmd.n;
samp_t buf[VLEN];
#pragma HLS ARRAY_PARTITION variable=buf cyclic factor=LW dim=1
samp_t xlane[LW], ylane[LW], zlane[LW];
#pragma HLS ARRAY_PARTITION variable=xlane complete dim=1
...
}
buf is partitioned cyclic; the three lane arrays are partitioned complete. The difference
is what each one is for: buf is storage that must serve LW accesses a cycle, so it stays a memory
split into banks, while xlane/ylane/zlane are LW-element staging registers written and read
whole every beat — completely partitioning them makes them registers rather than a memory at all.
bufis firing-local, not state. It is filled inLOADand fully consumed inMULT, so nothing has to survive to the next firing — which is why it is an ordinary local and not anadd_statedeclaration.fir_block’s taps are the contrasting case: they carry across firings and must be declared, because the generator has to give them a lifetime the C++ scope does not. Here the whole buffer dies with the job, and its cost is still paid — that is the point the resource model makes: it is declared againstVLEN.
Why it buffers
x and y share one port, so they arrive sequentially. The kernel has to hold x while y
streams past:
LOAD: read ceil(n/LW) words of x into buf
MULT: read ceil(n/LW) words of y, multiply against buf, write z
The plausible reason is the wrong one. You might think a buffer is needed because two stream reads cannot issue in the same iteration. They can. With two separate input streams,
x_inandy_inare distinct FIFOs with independent ports and their reads schedule in the same beat — measured, that design pipelines at II=1 and uses zero BRAM.A shared port is what makes storage unavoidable. The buffer is a consequence of the interface, not of the arithmetic and not of the read count. That distinction is the reason a resource model has to be keyed on structure rather than on what a kernel appears to compute.
Why it partitions
The MULT beat consumes LW samples of buf at once. An unpartitioned array is a two-port memory,
so more than two lanes would serialize and II would rise above 1.
#pragma HLS ARRAY_PARTITION variable=buf cyclic factor=LW dim=1
cyclic factor=LW puts element i in bank i % LW. Because lane j of word i is element
i*LW + j, lane j always reads bank j — LW conflict-free accesses per cycle.
That single pragma converts a throughput requirement into a memory cost, and the cost is a
ceiling rather than a ratio: each of the LW banks is VLEN/LW
deep, and a bank shallower than one block still occupies a whole one.
Note VLEN, not n. The buffer is sized by the compile-time bound, so the area is paid whatever
length actually arrives.
The ragged final beat
n need not be a multiple of LW, so the last beat carries fewer lanes:
LOAD:
for (int i = 0; i < n; i += LW) {
#pragma HLS PIPELINE II=1
const int nlane = (n - i < LW) ? (n - i) : LW; // ragged final beat
vm_au::read_stream_lane<DWID>(s_in, xlane, nlane);
for (int j = 0; j < LW; ++j) {
#pragma HLS UNROLL
if (j < nlane) buf[i + j] = xlane[j];
}
}
Two things to notice, because they are where a vectorized body goes wrong:
- The inner loop runs to
LW, notnlane, and guards the write.LWis a compile-time constant, so the loop unrolls; a runtime bound would not. read_stream_lane(..., nlane)moves a runtime number of elements. That is the generated serializer doing the partial-word extraction — never a hand-rolled.range(), per array utils. The bug it prevents hides atLW=1.
This is also where the example’s LUT cost comes from. A runtime lane count at runtime positions is
a variable-position mux — a crossbar — and that is why LUT grows as LW² rather than linearly. See
the LW² basis term.
The testbench checks n ∈ {1, 7, 63, 64, 65, 253} for exactly this reason: a
full-length run never reaches the partial beat.
The multiply
MULT has the same beat structure as LOAD — same ragged nlane, same inner loop run to the
compile-time LW and guarded rather than bounded. What is new is the arithmetic, and one line of it
is where the twin can silently drift:
MULT:
for (int i = 0; i < n; i += LW) {
#pragma HLS PIPELINE II=1
#pragma HLS LOOP_TRIPCOUNT max=VLEN
const int nlane = (n - i < LW) ? (n - i) : LW;
vm_au::read_stream_lane<DWID>(s_in, ylane, nlane);
for (int j = 0; j < LW; ++j) {
#pragma HLS UNROLL
ap_int<2 * SAMP_W> p = buf[i + j] * ylane[j];
zlane[j] = (samp_t)p;
}
vm_au::write_stream_lane<DWID>(zlane, z_out, nlane);
}
This is the loop the DSP rule prices: LW multiplies per beat,
SAMP_W-bit operands, one DSP each. It reads buf LW samples at a time — the access the cyclic
partition exists to make conflict-free — and writes through the generated write_stream_lane, the
mirror of the read on the way in.
The product wraps; it does not saturate. The multiply widens to
ap_int<2 * SAMP_W>and the result is cast straight back tosamp_t(ap_int<16>), discarding the high half. That is not carelessness about overflow — it is what makes the C++ agree with the Python golden, which is numpyint16arithmetic and wraps. A saturatingap_fixedhere would be the more careful choice and the wrong one: every product that overflowed would differ from Python, and only for operands large enough to reach the corner, so a short test would never see it. The csim twin check is what holds the two definitions together.
Cast where you narrow, never where you widen
That warning is about the one cast on the line, and the rule generalizes:
In
ap_int/ap_fixed, arithmetic already returns a result type wide enough to be exact —W1 + W2bits for a multiply,max(W1, W2) + 1for an add. So cast only where you deliberately narrow. A widening cast tells the tool nothing it did not already know.
ap_int<16> * ap_int<16> is ap_int<32>, so p is exact with no cast at all. Widening the
operands first would make the product ap_int<64> and push a silent 32-bit truncation into the
assignment — harmless at SAMP_W = 16, and wrong the moment it changes. Casting everywhere also
costs you the signal: a reader can no longer tell which cast is a decision and which is noise, and
here exactly one of them is a decision.
The C habit is backwards here, which is why this is worth stating. In C,
int16 * int16promotes tointandint * intoverflows, so C programmers correctly learn to cast up before multiplying —(int64_t)a * b. With AP types that reflex is unnecessary and harmful. (It still applies to native operands; mixing native and AP types in one expression is where it bites.)
The companion rule is on the same line: widths come from the parameter, never from a literal.
const int SAMP_W = samp_t::width is taken from the type itself, so ap_int<2 * SAMP_W> cannot go
stale. The check to apply to any such line is “if SAMP_W changed tomorrow, would this still be
right?” — a hard-coded ap_int<2 * 16> fails it silently, returning a positive number for a
negative product.
LOOP_TRIPCOUNT max=VLEN is for the synthesis report, not the hardware. n is a runtime value,
so without it HLS cannot bound the latency it prints. It is a deliberate over-estimate — the loop
actually runs ceil(n/LW) times — which is safe precisely because the pragma never reaches the
generated logic.
The response
VecResp resp;
resp.tx_id = cmd.tx_id;
resp.write_stream<DWID>(z_out);
Same id in, same id out — the transaction closes.
Why hand-written
Every in-band framer and stateful compute body in this tree is hand-written, for the same reason:
constructing a descriptor, driving a partitioned buffer across two loop phases, and handling a runtime
partial beat are all outside the extractor’s fixed
vocabulary. Nothing could derive the function name or the parameter order, so kernel_task() states
them and run_iter remains the golden.
That leaves two bodies for one behaviour, which is a liability until something checks them against each other — see Testbench.
Next
- Testbench — the pysim golden and the csim twin check.