projects / neural-network-accelerator
Fixed-Point Neural Network Accelerator
A small block of custom hardware on an FPGA that recognizes handwritten digits, and a testbench thorough enough to find the one bug the hand-written tests could not.
- ALMs495 FFs · 4 DSP blocks
- 0
- Fmax+6.3 ns setup slack at 50 MHz
- 0 MHz
- cycles per inference1.56 ms at 50 MHz
- 0
- MNIST accuracy0.04 below float32
- 0.00%
- outputs checked7 UVM tests, golden model
- 0
- transitions covered13/13 states
- 0/21
The problem
The network is small: 784 pixels in, two hidden layers of 16, ten scores out. But it still needs 12,960 multiply-adds per image, and the that runs the software on this board is the smallest of Intel's Nios II soft cores. The job of the accelerator is to take the arithmetic off the CPU and do it in hardware, reading its own operands from memory over .
Three things were hard. The number format has to fit in 16-bit memory words without wrecking accuracy; the first choice cut accuracy to around 45%. The block has to behave correctly on a real bus, where memory can say at any moment. And it has to be verified in a way that actually exercises those moments, because a written by the same person who wrote the tends to test the cases that person already thought of.
How it works
Each section is a short summary. Go deeper opens the RTL-level detail. Dotted terms have definitions.
A small SoC on a Cyclone V
A at 50 MHz, the accelerator, and seven on-chip memories share one fabric built in . The memories hold the input image (784 × 16-bit), the three weight matrices and the three bias vectors, all initialized from files. Software runs the network one layer at a time: it points the accelerator at a layer's inputs, weights and biases, starts it, and waits for done.
Seven control registers
The CPU talks to the accelerator through seven on an : start, input_base, weights_base, bias_base, input_size, output_size and relu_enable. Reading register 0 returns the done flag. That is the entire software interface.
A 13-state controller
One sequences a whole layer. It loops over output neurons; for each neuron it reads the bias, loops over every input reading x and w and accumulating their product, then rounds, applies ReLU, saturates, and writes the result back. Thirteen states, twenty-one legal transitions, and the same machine runs all three layers because software only changes the base addresses, the sizes and the ReLU flag.
Reading operands over Avalon-MM
The accelerator is its own : it computes each address itself and reads memory without the CPU. Every step needs two reads, the input x[j] and the weight w[i,j], issued one after the other, each waiting for its . Values are 16-bit but the bus is 32-bit, so bit 1 of the address picks which half of the returned word to keep.
One multiply-accumulate, reused 12,960 times
There is a single multiplier and a single accumulator. Two values multiply into a Q16.16 product, and the 64-bit accumulator adds it to a running total that started as the bias shifted left by 8 to match. Nothing is rounded until the whole dot product is done, which is the main reason the fixed-point result stays within 0.04 points of float32.
Round, ReLU, saturate
After the last input, the Q16.16 total is back to Q8.8 by adding half a unit and shifting right by 8, symmetrically for negative values. If relu_enable is set, a negative total becomes 0. Then the result is into the 16-bit range so it can never wrap.
Write-back and the done handshake
The 16-bit activation is packed into the low or high half of a 32-bit word with a matching byte-enable and written to the address the bias came from. The FSM holds the write until drops, moves to the next neuron, and after the last one raises done. It then waits for software to clear start before returning to idle, so a single start can never run a layer twice.
Interactive exhibits
Watch one image run
A real MNIST test image goes through the three layers exactly the way the hardware does it: one neuron at a time, one multiply-accumulate per input, with the cycle counter driven by the measured per-layer counts. The scores at the end are the real from the exported weights.
- cyclesof 78,102
- 0
- time at 50 MHz1× here ≈ 6,400× slower than the hardware
- 0.000 ms
- layer 1 running12,544 MACs, 75,394 cycles
- L1
The image is MNIST test image 0 as exported for the board (testing/test_images/mnist_test_00_q88.mif); the activations and logits are what the bit-accurate Q8.8 model (testing/test_accuracy.py) computes for it from the exported weights. Layer 1 is 75,394 of the 78,102 cycles, so layers 2 and 3 go by quickly; the animation pauses after each layer to show its count.
What a Q8.8 number is, and what it costs
Type a number, or drag the slider, to see how it is stored in 16 bits and what throws away. The table on the right is the tradeoff the project measured.
- integer stored
- 77 (0x004D)
- value represented
- 0.30078125
- rounding error
- +0.00078125
- saturated?
- no
Range −128 to +127.996 in steps of 1/256 = 0.0039. Anything finer than a step is rounded; anything outside the range is clamped. A 16-bit multiplier handles it directly, so no floating-point hardware is needed.
x · w of two Q8.8 numbers is a Q16.16 product: 16 fraction bits. The accumulator keeps all of them across the whole dot product and only rounds once, at the end: add 128, shift right by 8, then clamp to ±32767. That single late rounding is why the fixed-point path tracks float32 so closely.
| format | range | step | MNIST test accuracy |
|---|---|---|---|
| Q2.14 | ±2 | 1/16384 | around 45% · activations clipped |
| Q8.8 | ±128 | 1/256 | 95.27% · 9,527 / 10,000 |
| float32 | ±3.4e38 | relative 2⁻²³ | 95.31% · trained model |
- 0.04
- accuracy points given up (95.31% → 95.27%)
- 4
- DSP blocks for the whole accelerator, and 16-bit words in every memory
The first attempt, Q2.14, had 64× finer steps but only ±2 of range; hidden-layer activations clipped and accuracy fell to around 45%. Q8.8 gives up precision for range, and the network barely notices.
The duplicate-read bug
Seven tests. Six passed. The one that applied 30% random failed with 30 errors, and the reason is one if statement. Step through the read handshake below with a directed test and then with back-pressure, on the original RTL and on the fixed one. In the directed test the bug is unreachable, which is why every hand-written test passed.
cycle 0 · outer_stouter_loop_st: start the bias read.
- duplicate reads
- 0
- wrong captures
- 0
- waitrequest cycles
- 0
original · read_1_st / read_2_st / read_3_stif (acc_master_waitrequest) begin
acc_master_read <= 1'b1; // keep request alive
end else begin
acc_master_read <= 1'b0; // accepted, wait for data
endfixed · commit 26321eaif (acc_master_read && acc_master_waitrequest) begin
acc_master_read <= 1'b1; // keep request alive
end else begin
acc_master_read <= 1'b0; // accepted, wait for data
endOne neuron, two inputs, slave latency 3 cycles. Bus values are the names of the operands being fetched. Dashed segments are wrong.
Coverage: 13 of 13 states, 20 of 21 transitions
The checker watches the state register through a hierarchical probe, rejects any transition that is not in the legal table, and counts which states and arcs each test reached. The graph shows all 21 arcs. The one that is not lit is not hidden.
- states
- 13/13
- transitions
- 20/21
The one unlit arc is write_wait_st looping on itself: the state the FSM sits in while a write is stalled by waitrequest. Six of the seven tests run against a memory that never stalls a write, so that arc cannot fire in them. The seventh test, the stress test, was the only one that could reach it, and it was the one failing on the duplicate-read bug.
With the one-line fix applied, the stress test passes and, because it is the only test that applies back-pressure, it is the only one that reports 21/21. Every other test still reports 20/21 and names the missing arc in its log. Coverage here is counter-based (the Questa Starter license has no covergroups), and randomization is procedural $urandom_range.
Evidence
Real tool output only. Anything generated for explanation lives in the exhibits above and is labeled as an illustration. Boxes marked placeholder are captures still to be added.
[SEQ] layer job: 6 neurons x 4 inputs, relu=1, inputs@0x01d4 weights@0x01fc biases@0x018a
[SCB] job started: 6 neurons x 4 inputs, relu=1
UVM_ERROR acc_mem_responder.svh(84) @ 910: [MEM_RSP] read from 0x000001fe accepted
with 1 read(s) already pending - the DUT exceeded its 1 outstanding-read limit
UVM_ERROR acc_scoreboard.svh(307) @ 910: [SCB] memory read address mismatch: expected 0x000001d8, got 0x000001fe
UVM_ERROR acc_scoreboard.svh(307) @ 990: [SCB] memory read address mismatch: expected 0x00000200, got 0x000001d8
UVM_ERROR acc_scoreboard.svh(307) @ 1070: [SCB] memory read address mismatch: expected 0x000001da, got 0x000001d8
UVM_ERROR acc_mem_responder.svh(84) @ 1090: [MEM_RSP] read from 0x00000200 accepted with 1 read(s) already pending
...
UVM_ERROR : 30[FSM] checked 2400 cycles - states covered 13/13, transitions covered 21/21 [SCB] summary: 10 job(s) fully checked, 37 output(s) compared, 10 ReLU clamp(s), 19 saturation(s) [TEST] TEST PASSED UVM_ERROR : 0
26321ea. Ten randomized layer jobs under 30% waitrequest and 1 to 4 cycles of read latency, every output matched against the golden model.Notice: This is the only test that reports 21/21: the stalled-write self-loop needs waitrequest during a write, and only this test applies it.[FSM] checked 4742 cycles - states covered 13/13, transitions covered 20/21 [FSM] transitions never exercised: write_wait_st->write_wait_st [SCB] summary: 20 job(s) fully checked, 81 output(s) compared, 17 ReLU clamp(s), 43 saturation(s) [TEST] TEST PASSED
layer cycles MACs cycles/MAC cyc/neuron layer 1 (784 x 16) 75394 12544 6.010 4712.1 layer 2 ( 16 x 16) 1666 256 6.508 104.1 layer 3 ( 16 x 10) 1042 160 6.513 104.2 total 78102 12960 6.026 = 1562.0 us per inference at 50 MHz (accelerator only, no CPU overhead)
start to the edge on which done rises.Notice: Layer 1 is 75,394 of the 78,102 cycles. On the board the fabric adds latency, so this is the upper bound.read going high a second time while the first response has not come back.CLOCK_50.Notice: The project originally had no SDC, so the timing report only covered the JTAG clock; this was measured by adding create_clock -period 20 to the compiled database.Problems I hit
What went wrong, what I thought it was, how I narrowed it down, what it actually was, and what I changed.
The duplicate read that every directed test missed
found by the testbench- Symptom
acc_stress_test, the one test that applies 30% random and 1 to 4 cycles of read latency, failed with 30UVM_ERRORs in the first 1,550 ns. The other six tests, including twenty randomized jobs with the same latency range but no back-pressure, all passed. So did the original directed testbench.- Isolated by
The first error in the log is not a wrong value. It comes from the memory : a read was accepted while another was still pending, and the design is only built for one . Every scoreboard error after it is an address mismatch, each one exactly one read behind. That pattern says the datapath is fine and the request logic issued a read it should not have. The failing job was 6 neurons × 4 inputs; the errors start on the first neuron, when the read of its second weight is accepted twice.
- Root cause
In
read_1_st,read_2_standread_3_stthe "keep the request alive" branch wasif (acc_master_waitrequest) read <= 1. Once a read has been accepted the FSM stays in the same state withreadlow, waiting for . If the slave asserts waitrequest during that window, which the Avalon-MM spec allows at any time and the Platform Designer arbiter does when the CPU is also using the fabric, the branch re-assertsreadwith the same address and a second read is accepted.The slave now owes two responses. The FSM consumes the first as intended, issues its next read, and consumes the duplicate as that one. From then on every captured operand is the previous one, and the outputs are wrong for the rest of the layer.
- Fix
Only hold the request while it is actually being presented:
if (acc_master_read && acc_master_waitrequest) read <= 1, in all three read states. Commit26321ea. The stress test now passes, and because it is the only test that stalls a write, it is also the one that lights the last FSM arc: 21/21.
The first number format collapsed accuracy to around 45%
- Symptom
With weights, biases and activations stored as Q2.14, the fixed-point model scored around 45% on MNIST, against 95.31% for the float32 model it was converted from.
- Root cause
Q2.14 has 14 fraction bits but only ±2 of range. Hidden-layer activations regularly exceed that, so they were clipped at the rails and the information in them was gone before the next layer saw it.
- Fix
Move to : ±128 of range at 1/256 resolution, still 16 bits per word. The bit-accurate model then scores 95.27%, 0.04 points below float32.
Memories initialized with the wrong file layout
- Symptom
The on-chip memories came up with corrupted contents.
- Root cause
The conversion script produced byte-addressed Intel HEX files, but the Quartus memories were configured as 16-bit word-addressed. Every value landed at the wrong address.
- Fix
Generate files in the memory's own word layout (
training/convert_data_q88.py).
Python and the Nios II disagreed on the logits
- Symptom
The Python fixed-point reference and the Nios II inference produced different logits for the same image.
- Isolated by
Neither side could be trusted on its own, so the debugging had to cover both the network arithmetic and the way the fixed-point data was loaded into FPGA memory.
- Root cause
TODO(Ahmad): the README records the symptom and the two places you had to look, but not which side turned out to be wrong. Fill this in.
- Fix
Once both sides agreed, the Python model became the that the UVM scoreboard reimplements.
What I would do differently, and next
- Add the SDC on day one. The project shipped without a for
CLOCK_50, so the timing report only checked the JTAG clock. The 73 MHz figure was obtained by addingcreate_clock -period 20afterwards. One line, and it should have been in the first compile. - Register the response at the boundary. The runs from the fabric's response FIFO into the weight register, 13.05 ns. A register stage on
readdataandreaddatavalidwould cut it at the cost of one cycle of latency. - Latch the configuration at start, and stop overwriting the biases. Three tests written after the resume numbers (
acc_overflow_test,acc_rerun_test,acc_latch_test) fail against this RTL and document three more real limitations: a 32-bit temporary in the rounding path that wraps past 511 max-magnitude products, the single-shot bias overwrite, and CSRs read live during a job. All three are fixed in the rewrite below. - Stop paying latency twice per MAC. Six cycles per MAC with one read in flight is the obvious bottleneck. The in-progress rewrite issues pipelined reads with up to 16 outstanding per port and computes 16 neurons at once; in the same UVM bench it measures 936 cycles per inference against 78,102, with 16 DSP blocks. It is not yet committed or run on the board, so it stays out of the numbers above until it is.
- Measure on the board. The speedup over the Nios II software loop was never timed; the 1.56 ms is a simulation upper bound. A free-running timer around a hundred inferences would settle both.
source github.com/1ahmadkhan1/Neural-Network-FPGA
The public repository has the RTL, the training and conversion scripts, the Nios II driver and the directed testbench. The UVM environment in testbenching/uvm/ is being committed; until it lands, the numbers on this page come from local runs of that bench.