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.

buf is firing-local, not state. It is filled in LOAD and fully consumed in MULT, so nothing has to survive to the next firing — which is why it is an ordinary local and not an add_state declaration. 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 against VLEN.

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_in and y_in are 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 jLW 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, not nlane, and guards the write. LW is 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 at LW=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 to samp_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 numpy int16 arithmetic and wraps. A saturating ap_fixed here 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 + W2 bits for a multiply, max(W1, W2) + 1 for 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 * int16 promotes to int and int * int overflows, 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.