Capstone: A Hardware Accelerator
A streaming dot-product engine fed by DMA, measured against NumPy on the ARM cores; when acceleration pays and when it does not; where to go next.
1. The goal: an accelerator you can measure
This chapter puts everything together. You will design a streaming dot-product engine in Verilog and VHDL, verify it under random back-pressure, place it between the two channels of an AXI DMA, and drive it from a Jupyter notebook with NumPy arrays. Then you will do the most important thing an engineer can do with an accelerator: measure it honestly against the CPU — and understand why, for this particular kernel, the CPU may well win.
The dot product of two vectors \(\mathbf{x}, \mathbf{y}\) of length \(L\) is\(\;\sum_{i=0}^{L-1} x_i y_i\), one multiply-accumulate (MAC) per element pair. It is the heart of filters (Chapter 18), matrix products and neural-network layers, and it maps perfectly onto one DSP48E1 slice.
The architecture decision, made once and kept: data in by MM2S, results out by S2MM, both as AXI-Stream. Results could instead be read from an AXI-Lite register, but that costs one slow MMIO access per vector (Chapter 21); streaming them back lets one DMA call process thousands of vectors.
2. Designing the engine
Input format. Each 32-bit beat carries one element pair: \(x_i\) in bits [15:0] and\(y_i\) in bits [31:16], both signed 16-bit (two's complement, Chapter 1). Interleaving the two vectors means one DMA channel is enough and the engine never waits for one operand while holding the other.
Where does a vector end? AXI-Stream marks the end of a packet with TLAST. The AXI DMA raises TLAST on the last beat of each transfer, so one transfer per vector works — but costs the full Python overhead per vector. To batch \(M\) vectors in one transfer, the engine also accepts a length vlen from a register: a vector ends after vlen beats or on TLAST. With vlen = 0, only TLAST counts.
Pipeline. Stage 1 multiplies; stage 2 adds the product to a 48-bit accumulator. On the last beat of a vector, stage 2 outputs the final sum on the master stream and clears the accumulator, so the next vector starts without a gap. Both stages map onto one DSP48E1 slice, which has exactly a 25×18 multiplier, a pipeline register after it, and a 48-bit accumulator.
How wide must the accumulator be? The largest product magnitude is\((-2^{15})^2 = 2^{30}\), so a vector of length \(L\) can reach \(L \cdot 2^{30}\):
A 32-bit accumulator would overflow after only two worst-case products. The result is sign-extended to 64 bits so that NumPy can read it directly as int64.
3. The dot-product engine in Verilog and VHDL
// Streaming dot-product engine.
// Input : one 32-bit AXI-Stream beat per element pair, x = tdata[15:0],
// y = tdata[31:16], both signed 16-bit.
// A vector ends on s_axis_tlast, or after vlen beats if vlen != 0
// (AXI DMA raises TLAST only at the end of a whole transfer, so a batch of
// many vectors in one transfer needs vlen).
// Output: one 64-bit beat per vector, the 48-bit sum sign-extended;
// m_axis_tlast is set on the result of the vector that ended with
// s_axis_tlast, which closes the S2MM transfer.
module dot_engine (
input wire clk,
input wire rst,
input wire [15:0] vlen, // 0 = use TLAST only
input wire s_axis_tvalid,
output wire s_axis_tready,
input wire [31:0] s_axis_tdata,
input wire s_axis_tlast,
output reg m_axis_tvalid = 1'b0,
input wire m_axis_tready,
output reg [63:0] m_axis_tdata = 64'd0,
output reg m_axis_tlast = 1'b0
);
// The whole pipeline moves only when the output slot is free or being
// emptied; otherwise everything holds (AXI rule: keep data until taken).
wire adv = !m_axis_tvalid || m_axis_tready;
wire in_xfer = s_axis_tvalid && adv;
assign s_axis_tready = adv;
wire signed [15:0] x = s_axis_tdata[15:0];
wire signed [15:0] y = s_axis_tdata[31:16];
reg [15:0] cnt = 16'd0; // beats so far in this vector
wire vec_end = s_axis_tlast || (vlen != 16'd0 && cnt == vlen - 16'd1);
// stage 1: multiply (maps to the DSP48E1 multiplier + its M register)
reg signed [31:0] prod = 32'sd0;
reg p_valid = 1'b0, p_end = 1'b0, p_last = 1'b0;
// stage 2: accumulate (48 bits, like the DSP48E1 accumulator)
reg signed [47:0] acc = 48'sd0;
wire signed [47:0] sum = acc + prod;
always @(posedge clk) begin
if (rst) begin
cnt <= 16'd0;
p_valid <= 1'b0;
acc <= 48'sd0;
m_axis_tvalid <= 1'b0;
end else if (adv) begin
p_valid <= in_xfer;
prod <= x * y;
p_end <= vec_end;
p_last <= s_axis_tlast;
if (in_xfer)
cnt <= vec_end ? 16'd0 : cnt + 16'd1;
m_axis_tvalid <= 1'b0;
if (p_valid) begin
if (p_end) begin
m_axis_tdata <= {{16{sum[47]}}, sum};
m_axis_tvalid <= 1'b1;
m_axis_tlast <= p_last;
acc <= 48'sd0;
end else
acc <= sum;
end
end
end
endmoduleThe flow control is the simplest correct scheme for a short pipeline: one signal adv enables every register. When the output holds a result the sink has not taken, adv is low, s_axis_tready is low, and nothing moves. This makes s_axis_tready depend combinationally on m_axis_tready — fine for a block this small; if timing became a problem, the skid buffer of Chapter 19 on the input or output would cut that path without losing throughput.
TLAST to finish its transfer). Get those right in simulation first; then the board works the first time.4. Verification under back-pressure
The testbench sends twelve vectors through the engine while a 32-bit LFSR inserts random idle cycles on the input (about 25 %) and drops m_axis_tready about half the time. It computes every expected dot product itself in 64-bit arithmetic and checks value, order and m_axis_tlast. The vectors cover the corner cases: lengths 1, 2 and 3 (shorter than the pipeline), a 300-element vector of\(-32768 \times -32768\) whose sum \(300 \cdot 2^{30} \approx 3.2\times10^{11}\) needs more than 32 bits, and five vectors of length 8 batched in one packet with vlen = 8 and TLAST only at the very end.
`timescale 1ns/1ps
// Sends 12 vectors with random gaps and random back-pressure:
// phase 1 (vlen = 0): 6 vectors of lengths 1, 2, 3, 5, 16, 100, each with TLAST,
// then one of 300 beats with x = y = -32768 (overflow test)
// phase 2 (vlen = 8): 5 vectors of 8 in ONE packet, TLAST only on the last beat
// The testbench computes every expected dot product itself and checks results,
// order and m_axis_tlast. Data come from a 32-bit LFSR, identical in VHDL.
module dot_engine_tb;
reg clk = 0, rst = 1;
always #5 clk = ~clk; // 100 MHz, like FCLK_CLK0
reg [15:0] vlen = 0;
reg s_tvalid = 0, s_tlast = 0, m_tready = 0;
reg [31:0] s_tdata = 0;
wire s_tready, m_tvalid, m_tlast;
wire [63:0] m_tdata;
dot_engine dut (.clk(clk), .rst(rst), .vlen(vlen),
.s_axis_tvalid(s_tvalid), .s_axis_tready(s_tready), .s_axis_tdata(s_tdata), .s_axis_tlast(s_tlast),
.m_axis_tvalid(m_tvalid), .m_axis_tready(m_tready), .m_axis_tdata(m_tdata), .m_axis_tlast(m_tlast));
function [31:0] lfsr32(input [31:0] v); // Galois, taps 0x80200003
lfsr32 = {1'b0, v[31:1]} ^ (v[0] ? 32'h80200003 : 32'h0);
endfunction
reg [31:0] data_lfsr = 32'h1234_5678, gap_lfsr = 32'hCAFE_F00D, rdy_lfsr = 32'h0BAD_BEEF;
reg signed [63:0] expected [0:15];
reg exp_last [0:15];
integer n_exp = 0, n_got = 0, errors = 0;
reg signed [63:0] checksum = 0;
// one beat: x, y from the LFSR (or forced), random idle cycles first
task beat(input last, input force_min);
reg signed [15:0] x, y;
begin
while (gap_lfsr[1:0] == 2'b00) begin // ~25 % idle cycles
@(negedge clk) s_tvalid = 0;
gap_lfsr = lfsr32(gap_lfsr);
end
gap_lfsr = lfsr32(gap_lfsr);
data_lfsr = lfsr32(data_lfsr);
s_tdata = force_min ? 32'h8000_8000 : data_lfsr;
@(negedge clk);
s_tvalid = 1; s_tlast = last;
@(posedge clk);
while (!s_tready) @(posedge clk); // hold until accepted
end
endtask
// a vector of n beats; tlast_at_end = 1 marks its last beat with TLAST
task vector(input integer n, input tlast_at_end, input result_last, input force_min);
integer i;
reg signed [63:0] acc;
begin
acc = 0;
for (i = 0; i < n; i = i + 1) begin
beat(tlast_at_end && i == n - 1, force_min);
acc = acc + $signed(s_tdata[15:0]) * $signed(s_tdata[31:16]);
end
expected[n_exp] = acc; exp_last[n_exp] = result_last; n_exp = n_exp + 1;
end
endtask
// sink: random TREADY (~50 %), check each result in order
always @(posedge clk) begin
if (m_tvalid && m_tready) begin
if (m_tdata !== expected[n_got] || m_tlast !== exp_last[n_got]) begin
$display("FAIL: result %0d = %0d (last=%b), expected %0d (last=%b)",
n_got, $signed(m_tdata), m_tlast, expected[n_got], exp_last[n_got]);
errors = errors + 1;
end
checksum = checksum + $signed(m_tdata);
n_got = n_got + 1;
end
end
always @(negedge clk) begin
rdy_lfsr = lfsr32(rdy_lfsr);
m_tready = rdy_lfsr[0];
end
integer k;
initial begin
repeat (3) @(posedge clk);
rst = 0;
vector(1, 1, 1, 0); vector(2, 1, 1, 0); vector(3, 1, 1, 0);
vector(5, 1, 1, 0); vector(16, 1, 1, 0); vector(100, 1, 1, 0);
vector(300, 1, 1, 1); // 300 * 2^30: needs > 32 bits
@(negedge clk) s_tvalid = 0;
wait (n_got == n_exp);
vlen = 8; // phase 2: batched vectors
for (k = 0; k < 5; k = k + 1)
vector(8, k == 4, k == 4, 0);
@(negedge clk) s_tvalid = 0;
wait (n_got == n_exp);
repeat (2) @(posedge clk);
if (errors == 0)
$display("PASS: %0d dot products correct, checksum = 0x%h, last = %0d",
n_got, checksum, expected[n_exp - 1]);
else
$display("FAIL: %0d errors", errors);
$finish;
end
endmoduleiverilog -g2012 -o tb dot_engine.v dot_engine_tb.v && vvp tb # PASS: 12 dot products correct, checksum = 0x0000004a67967c8f, last = 339160294 ghdl -a --std=08 dot_engine.vhd dot_engine_tb.vhd ghdl -e --std=08 dot_engine_tb && ghdl -r --std=08 dot_engine_tb # PASS: 12 dot products correct, checksum = 0x0000004A67967C8F, last = 339160294 (@6135 ns)
Both simulations finish at 6135 ns with the same checksum over all twelve results: the two languages describe the same hardware, cycle for cycle.
5. The block design
- Start from the Chapter 20 flow (Vivado version matching your PYNQ image). Create a block design named
dotwith the ZYNQ7 Processing System; run block automation. In the PS configuration, enableS_AXI_HP0. - Bring in
dot_engine, either by packaging it as IP (Chapter 20; associateclkwith both stream interfaces and declarerstas an active-high reset) or by dragging the source file into the block design as a module reference. Vivado inferss_axisandm_axisas AXI-Stream interfaces from the port names. - Add AXI Direct Memory Access. Configure it: scatter-gather off (simple mode, which the PYNQ DMA driver uses), both read (MM2S) and write (S2MM) channels on, buffer-length register width 26 bits, MM2S stream width 32 bits, S2MM stream width 64 bits.
- Connect DMA
M_AXIS_MM2S→dot_engine/s_axisanddot_engine/m_axis→ DMAS_AXIS_S2MM. Our engine has noTKEEP; the DMA's unconnectedTKEEPinput should default to “all bytes valid”, which is what we want — check for a warning about it when you validate the design. - Add AXI GPIO, one channel, 16 bits, all outputs; connect its output to
vlen. Connectdot_engine/rstto the active-highperipheral_resetoutput of the Processor System Reset block. - Run connection automation: the DMA's and GPIO's control ports go to M_AXI_GP0, the DMA's memory ports (
M_AXI_MM2S,M_AXI_S2MM) to S_AXI_HP0, all onFCLK_CLK0(100 MHz here). - Validate, create the HDL wrapper, generate the bitstream, and copy
dot.bit+dot.hwhto the board. Check the timing summary: with one DSP and short paths, 100 MHz should close easily.
m_axis_tlast, the S2MM channel never completes and recvchannel.wait() hangs forever. Likewise if you ask S2MM for more bytes than the engine will send. In our design the last input beat carries TLAST from MM2S, which produces TLAST on the last result. Size the receive buffer as exactly (number of vectors × 8) bytes.6. The notebook: send vectors, compare with NumPy
The notebook packs \(M = 1024\) pairs of random int16 vectors of length \(L = 256\) into one contiguous buffer (1 MB), sets vlen = 256, runs one DMA transfer in each direction, and compares the 1024 results with NumPy computed in 64-bit integers — exact, so the comparison must be bit-for-bit equal.
import numpy as np
from pynq import Overlay, allocate
ol = Overlay('dot.bit') # dot.bit + dot.hwh, same folder
dma = ol.axi_dma_0
vlen = ol.axi_gpio_0 # AXI GPIO driving dot_engine's vlen input
M, L = 1024, 256 # 1024 vectors of 256 elements, one transfer
rng = np.random.default_rng(0)
X = rng.integers(-2**15, 2**15, size=(M, L), dtype=np.int16)
Y = rng.integers(-2**15, 2**15, size=(M, L), dtype=np.int16)
inbuf = allocate(shape=(M * L,), dtype=np.uint32) # one beat = (y << 16) | x
outbuf = allocate(shape=(M,), dtype=np.int64) # one 64-bit result per vector
def pack(X, Y, buf):
buf[:] = (X.view(np.uint16).ravel().astype(np.uint32)
| (Y.view(np.uint16).ravel().astype(np.uint32) << 16))
def run_hw():
dma.recvchannel.transfer(outbuf) # arm S2MM first
dma.sendchannel.transfer(inbuf)
dma.sendchannel.wait()
dma.recvchannel.wait()
vlen.write(0x0, L) # GPIO data register at offset 0: vlen = 256
pack(X, Y, inbuf)
run_hw()
ref = np.einsum('ij,ij->i', X.astype(np.int64), Y.astype(np.int64))
print('match:', np.array_equal(outbuf, ref))Before trusting it on the board, check the packing on your PC: unpack the 32-bit words into \(x\) and\(y\) with the same bit fields as the HDL, compute the sums, and compare with einsum. A mistake here (for example, swapping the halves or forgetting the sign) gives wrong results that look like a hardware bug.
7. Measuring speed-up honestly
A speed-up is a ratio of two times, and both must be measured fairly. The rules:
- Time the whole job. From data in a NumPy array to results in a NumPy array: packing into the DMA buffer, both transfers, waiting, and (if needed) unpacking. Timing only the hardware hides the costs that usually dominate.
- Warm up. The first call of anything includes one-time costs (page faults, caches, lazy initialisation). Run both versions a few times before timing.
- Repeat and take the median. Linux schedules other work; single measurements on the board vary noticeably. Report the median of many runs, and look at the spread.
- Compare against good software. NumPy's vectorised routines, not a Python
forloop. Beating a Python loop by 100× proves nothing about the hardware. - Check correctness every time. A fast wrong answer is not a speed-up.
import time
def bench(fn, reps=50, warmup=5):
"""Median wall-clock time of fn() in seconds, after warm-up runs."""
for _ in range(warmup):
fn()
t = []
for _ in range(reps):
t0 = time.perf_counter()
fn()
t.append(time.perf_counter() - t0)
return float(np.median(t))
Xf, Yf = X.astype(np.float32), Y.astype(np.float32)
t_np_int = bench(lambda: np.einsum('ij,ij->i', X.astype(np.int64), Y.astype(np.int64)))
t_np_float = bench(lambda: np.einsum('ij,ij->i', Xf, Yf)) # inputs already converted
t_hw = bench(run_hw) # DMA in + compute + DMA out
t_hw_e2e = bench(lambda: (pack(X, Y, inbuf), run_hw())) # including packing in Python
n_mac = M * L
for name, t in [('NumPy int64', t_np_int), ('NumPy float32', t_np_float),
('FPGA, data already packed', t_hw), ('FPGA, end to end', t_hw_e2e)]:
print(f'{name:28s} {t*1e3:8.2f} ms {n_mac / t / 1e6:7.1f} M MAC/s')
print(f'speed-up vs NumPy int64 (end to end): {t_np_int / t_hw_e2e:.2f}x')What should you expect? The engine does one MAC per cycle: \(10^8\) MAC/s at 100 MHz, if the DMA keeps it fed at 400 MB/s. The ARM Cortex-A9 with NumPy runs a dot product at a rate of the same order of magnitude, and the end-to-end hardware time also includes the packing, which is itself a NumPy pass over the data. Do not be surprised if the speed-up is around 1× — or below. That is not a failed lab; it is the lesson of the next section.
8. Why a dot product is memory-bound
The key quantity is arithmetic intensity: how many operations a kernel performs per byte it has to move.
This is the roofline model. For our dot product each 4-byte beat brings one multiply and one add:\(I = 2/4 = 0.5\) op/B. With \(B = 400\) MB/s, at most 0.2 GOP/s — no matter how many DSPs you throw at it. The PL's 220 DSP slices could do \(220 \times 2 \times 100\,\text{MHz} = 44\) GOP/s, but only for kernels with \(I \gtrsim 110\) op/B. And the ARM reads the same DDR at a comparable rate, so for a low-intensity kernel the two are bound by the same memory.
Then there is Amdahl's law. If a fraction \(f\) of a program's runtime is accelerated by a factor \(s\), the overall speed-up is
Even an infinitely fast accelerator cannot beat \(1/(1-f)\). Data packing and transfer that the software version did not need count as part of \(1-f\), or even make the program slower. The model below plots both ideas. Its bandwidth and ARM numbers are assumptions (labelled in the code); the DSP count is from the datasheet. Change them and see which conclusions survive.
Click Run to execute the Python code
Code will be executed with Python 3 on the server
| Kernel | Ops per byte | Bound on the PYNQ-Z2 | Worth accelerating? |
|---|---|---|---|
| vector add | ≈ 0.17 | bandwidth | no — the ARM is as fast |
| dot product | 0.5 | bandwidth | rarely (only to free the CPU) |
| 3×3 convolution | ≈ 9 | bandwidth, but above the ARM | yes, a few times faster |
| 64-tap FIR | ≈ 32 | bandwidth, far above the ARM | yes |
| matrix multiply (n = 64) | ≈ 43, grows with n | approaches compute | yes, the classic case |
9. What does benefit from the FPGA
The dot product is a fine teaching vehicle precisely because it does not flatter the hardware. Kernels that do pay off share at least one of these properties:
- High arithmetic intensity. Data are reused many times once on chip: matrix products, convolutions, long FIR filters (Chapter 18). Block RAM keeps the reused data next to the DSPs.
- Bit-level operations. CRCs, hashing, error-correcting codes, bit permutations, population counts, cryptographic rounds: a CPU spends many instructions per bit; LUTs do them in one level of logic.
- Custom precision. A 9-bit or 4-bit multiply, or binary neural networks, use a fraction of a DSP or only LUTs. Many small operators fit where a CPU still uses its 32-bit datapath.
- Streaming with low, fixed latency. Data that arrive from pins — an ADC, a camera, a network PHY — can be processed sample by sample with a latency of a few clock cycles and no jitter, without ever going through DDR. A CPU under Linux cannot promise that.
- Offloading. Even with no speed-up, hardware that runs alone frees the ARM for other work, and may use less energy per operation.
10. Where next
You can now go from a blank file to Python driving your own verified hardware. Some directions from here:
- High-level synthesis (HLS). Vitis HLS compiles annotated C/C++ into RTL with AXI interfaces generated for you. It is very productive for loops and arithmetic; knowing RTL, as you now do, is what lets you understand its reports (initiation interval, latency, resource use) and fix the result when it is not what you expected.
- Open-source flows. Yosys (synthesis) and nextpnr (place and route) are mature for Lattice iCE40 and ECP5 FPGAs. Support for Xilinx 7-series, including Zynq-7000, comes from Project X-Ray and the openXC7 toolchain and is still experimental: good for learning and small PL designs, not yet a replacement for Vivado on a full Zynq system with the PS.
- Bigger boards. The Kria KV260 (Zynq UltraScale+ MPSoC, quad-core Cortex-A53, more DSPs and bandwidth) runs PYNQ too, and is aimed at vision. RFSoC boards add multi-gigasample ADCs and DACs on the same chip — the ultimate streaming-from-pins platform, also with PYNQ support.
- Keep learning. Read the AMBA AXI specification once (it is shorter than you fear); study the DSP48E1 user guide to learn what one slice can do; read other people's well-verified RTL; and keep the habit from this course: simulate first, measure honestly, and only then believe the board.
Practice Problems
vlen register a safe limit?dma.sendchannel.wait() after starting the transfer in an earlier cell, and compared with the first call of a NumPy function. List the three things wrong with this measurement.