Skip to content
Ahmad Khan

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.

Dec 2025 – Jul 2026SystemVerilog · UVM 1.2 · Questa · Quartus Prime 18.1 · Nios II/e · Platform Designer · PythonGitHub ↗
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.

Nios II/e50 MHz · writes CSRs, polls doneOn-chip M10K memoryinput 784×16 · W1-W3 · B1-B3accelerator.sv · Avalon-MM slave s1 + masterCSR files1 slave · 7 registersstart, base×3, size×2, reluControl FSM13 states · 21 arcsone dense layer per startAddress generatorbase + 2·indexAvalon-MM master32-bit read / writewaitrequest · readdatavalidMACx·w: 16×16 → Q16.1664-bit accumulatorRound · ReLU · SaturateQ16.16 → Q8.8clamp to ±32767Write packhalfword byteenabledone → s1 readback7 CSR writesreadswritesaddrbias, x, wactivationctlQ8.8 everywhere16-bit words in memoryQ16.16 only inside the MACLegendclick a block to jump to its sectionthe section you are reading lights its block
Redrawn from data/system_architecture.svg and accelerator.sv. Click a block to jump to its section; the section you are reading lights its block.

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.

input · 784 px · Q8.8L1 784 → 16 ReLUL2 16 → 16 ReLUL3 16 → 100123456789MAC · one per cycle groupacc = B[0] + Σ x[j]·w[0,j]neuron 0 / 16input j = 0 / 784ReLU on · Q16.16 accumulator
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.

stored as 16 bits · round(value × 256)
0000000001001101
← 8 integer bits (two's complement, sign first)8 fraction bits →
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.

inside the MAC

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.

three number formats, one network
formatrangestepMNIST test accuracy
Q2.14±21/16384around 45% · activations clipped
Q8.8±1281/25695.27% · 9,527 / 10,000
float32±3.4e38relative 2⁻²³95.31% · trained model
the trade
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.

test
RTL
clkstateouterread_1innerread_2read_3dotinnerread_2read_3dotinnerdone_innreadaddressB[0]B[0]x[0]w[0,0]x[1]w[0,1]waitrequestreaddatavalidreaddataB[0]B[0]x[0]w[0,0]x[1]w[0,1]reads pending12121register ←bias←B[0]x←B[0]w←x[0]x←w[0,0]w←x[1]dupwrongwrongwrongwrong051015202530
Illustrative timing diagram, not a simulation capture.

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
end
fixed · 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
end

One 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.

run
states
13/13
transitions
20/21
never exercisedidleouter_loopread_1inner_loopread_2read_3dotdone_innsaturatewritewrite_waitdone_outwait_starthover or tab to an arc for its meaning
why 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.

after the fix

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.

log excerpt · stress test, original RTLacc_stress_test.log · original read logic, re-run 2026-09-18
[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
The first failure of the stress test against the original read logic. The July run that found the bug was overwritten by later regressions, so this log was regenerated on 2026-09-18 by reverting the one-line fix in a scratch copy and running the same test.Notice: The very first error comes from the memory , not the scoreboard: a second read was accepted while one was still pending. Every scoreboard error after it is an address that arrived one read late.
log excerpt · stress test, fixed RTLacc_stress_test.log · regress.sh legacy, 2026-09-18
[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
The same test after commit 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.
log excerpt · coverage without back-pressureacc_random_test.log · regress.sh legacy, 2026-09-18
[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
Twenty randomized layer jobs with 1 to 4 cycles of read latency and no back-pressure. The checker names the one arc it never saw.
log excerpt · cycle-exact yardstickacc_perf_test.log · regress.sh legacy, 2026-09-18
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)
The real 784-16-16-10 network at the real Platform Designer addresses, ideal 1-cycle memory, counted from the edge that samples 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.
Questa waveformquesta-duplicate-read.png
The master port in the failing stress run.Notice: Look for read going high a second time while the first response has not come back.
Questa waveformquesta-read-after-fix.png
The same window after the fix.
Quartus fitter reportquartus-fitter-summary.png
Resource usage of the accelerator alone.
Quartus Timing Analyzerquartus-timing-fmax.png
Fmax and setup slack on 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.
Nios II consolenios-console-inference.png
The accelerator's answer, as printed by the CPU.
Board photoboard-de1soc.jpg
The hardware.

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 30 UVM_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_st and read_3_st the "keep the request alive" branch was if (acc_master_waitrequest) read <= 1. Once a read has been accepted the FSM stays in the same state with read low, 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-asserts read with 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. Commit 26321ea. 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.

Step through it in the waveform player

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.

See the format comparison

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 adding create_clock -period 20 afterwards. 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 readdata and readdatavalid would 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.