End-to-end MNIST digit-classifier on a Tang Nano 20K (Gowin GW2AR-18) FPGA. A tiny CNN β one 3Γ3 conv channel, ReLU, 2Γ2 max-pool, and a 169β10 fully connected layer β runs entirely on-chip in fixed-point INT8 arithmetic. Trained in PyTorch, quantized, and loaded into on-chip BSRAM/pROM blocks.
The terminal shows the input image as ASCII art, then the FPGA's LEDs light up to spell the predicted digit in binary (active-low: lit LED = 1 bit). See demo_flash.sh to reproduce.
Two flavors of the design live in this repo, sharing the same compute datapath:
- LED version β image baked into the bitstream; result shown on six on-board LEDs. Headless "power on β see the answer" demo.
- UART version β image streamed in over UART at runtime; result sent back as one byte. Used for development and batch verification from a host PC.
Two versions of the convolution itself ship in this repo, selectable via a Verilog parameter:
- v1 (main) β 1-multiplier serial scan. ~27 cycles per output pixel. The original design that shipped to silicon.
- v2 (v2-parallel-conv) β 9-multiplier streaming MAC array with 2Γ28 line buffers and a 3Γ3 register window. 1 output pixel per cycle. Bit-identical to v1 at the FC output; validated in iverilog (8.25Γ fewer compute cycles on the same test image) and flashed to the live board.
| Model | Test set | Accuracy |
|---|---|---|
| PyTorch float32 (CPU) | MNIST test (10000) | 91.80% |
| PyTorch float32 (CPU) | 50 sampled images | 96.0% |
| FPGA chip (INT8 quantized, fixed-point) | 50 sampled images | 94.0% |
The chip accuracy was produced by a bit-accurate simulator (model/hw_sim.py) that performs the exact same fixed-point operations the FPGA does and reads the exact same .mi byte streams the FPGA loads into its ROMs at config time. We separately validated the simulator against the real hardware: the live FPGA classifies each individual image to the same digit the simulator predicts. See model/batch_meta.json for the full per-image breakdown.
The quantization gap (chip 94.0% vs CPU 96.0% on the same 50 images) is the cost of compressing the model to INT8 weights + an acc >> 8 activation scale that fits in a acc[15:8] output byte. Larger models with more channels could close this gap.
Host PC (Python Script)
β² β
tx_out β β rx_in
(serial) β β (serial)
β βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Tang Nano 20K Boundary β
β β
β ββββββββββββββ ββββββββββββββββββββββββββββ β
β β βrx_byte[7:0] β β β
β β UART RX/TX ββββββββββββββββΆβ Input Image SRAM β β
β β β write_en β (Single-Port, Hard IP) β β
β βββββββ²βββββββ βββββββββββββββ²βββββββββββββ β
β β β β
β β read_addr[9:0] β
β βpred_digit[3:0] β β
β βtx_start βββββββββββββββββ β β
β β β βββββββ β
β β β Main Control β β
β β β FSM βrom_addr[15:0],read_en
β β β βββββββ β
β β βββββββββ¬ββββββββ β β
β β start_layer (held) β βΌ β
β β layer_type β βββββββββββββββββββββββββ β
β β β β Weights pROM (IP) β β
β β layer_done β βββββββββββββββββββββββββ€ β
β β βΌ β Bias pROM (IP) β β
β βββββββ΄ββββββββββββββββββββββββββ΄ββββββββββββββββββββββββ β
β β β β
β β Compute Pipeline (Conv -> Pool -> FC) ββββΌβ weight_val[7:0]
β β ββββΌβ bias_val[31:0]
β ββββββββββββββββββββββββββββββ²βββββββββββββββββββββββββββ β
β β β
β pixel_val[7:0] β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
[ Input Image SRAM ] [ Weight pROM ]
β β
pixel_val[7:0] weight_val[7:0]
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββ
β MULTIPLIER β βββ (Only ONE multiplier)
ββββββββββββββββββββ¬βββββββββββββββββββββ
β
(16-bit signed)
β
βΌ
βββββββββββββββββββββββββββββββββββββββββ
βββββΆ β ADDER β βββ (Replaces an 8-adder tree)
β ββββββββββββββββββββ¬βββββββββββββββββββββ
β β
β βΌ
β βββββββββββββββββββββββββββββββββββββββββ
βββββββ€ ACCUMULATOR REGISTER β
ββββββββββββββββββββ¬βββββββββββββββββββββ
β
β (Outputs only after 9 tap cycles)
βΌ
[ ReLU & >> 8 ]
β
βΌ
conv_out[7:0]
> ~27 cycles / output pixel Β· 9 MACs serialized Β· 0 line-buffer storage
[ Weight pROM ]
β
βΌ
βββββββββββββββββββββββββ
β Preload FSM (~10 cyc) β β fetch weights[0..8]
βββββββββββββ¬ββββββββββββ
βΌ
w[0..8] (registered)
β
[ Input Image SRAM ] β
β β
pixel_in[7:0] β
β β
βΌ β
ββββββββββββββββββ β
β Line buffer 0 β (28 deep)
ββββββββββ¬ββββββββ β
βΌ β
ββββββββββββββββββ β
β Line buffer 1 β (28 deep)
ββββββββββ¬ββββββββ β
βΌ β
ββββββββββββββββββββββ β
β 3Γ3 register window β ββ
ββββββββββ¬βββββββββββββ β
βΌ βΌ
ββββββββββββββββββββββββββββ
β 9 PARALLEL MULTIPLIERS β
ββββββββββββββ¬ββββββββββββββ
β 9 Γ 16-bit products
βΌ
ββββββββββββββββββββββββββββ
β 8-input ADDER TREE β (combinational)
ββββββββββββββ¬ββββββββββββββ
β
βΌ
[ ReLU & >>> 8 ]
β
βΌ
conv_out[7:0]
> 1 cycle / output pixel Β· 9 parallel MACs Β· 2 Γ 28-byte line buffers Β· Total conv: ~786 cycles for 26Γ26 output (vs ~17,000 in v1)
[ Max Pool Pipeline ] [ Weight pROM ]
β β
pool_pixel_val[7:0] weight_val[7:0]
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββ
β MULTIPLIER β
ββββββββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββ
βββββΆ β ADDER β
β ββββββββββββββββββββ¬βββββββββββββββββββββ
β β
ββββββ[ FC Accumulator Register (32-bit) ]
β
β (After all 169 FC weights summed for one digit)
βΌ
βββββββββββββββββββββββββββββββββββββββββ [ Bias pROM ]
β FINAL BIAS ADDER β ββββββββ bias_val[31:0]
ββββββββββββββββββββ¬βββββββββββββββββββββ
β
βΌ
(Total Score + Bias)
β
βΌ
βββββββββββββββββββββββββββββββββββββββββ
β ARGMAX COMPARATOR β
β (if Score > highest_score) β βββββΆ [ predicted_digit ]
βββββββββββββββββββββββββββββββββββββββββ
| ROM/RAM | Depth | Width | Contents |
|---|---|---|---|
mem_image_ram |
784 | 8 | 28Γ28 uint8 image |
weights_rom |
1699 | 8 | Addr 0β8: conv 3Γ3 kernel |
Addr 9β1698: FC weights (169 Γ 10 digits, row-major) |
| bias_rom | 11 | 32 | Addr 0: unused placeholder
Addr 1β10: FC biases (int32, two's complement) |
LED version uses Gowin SP/pROM hard IPs with .mi init files. UART version uses inferred reg-array memories with $readmemh.
The Python model is float32. The FPGA is fixed-point. To make them match, the training script enforces several constraints that aren't optional:
| Constraint | Why |
|---|---|
nn.Conv2d(..., bias=False) |
Hardware conv MAC chain has no bias adder; a learned conv bias would be silently dropped. |
Pixel input scale: float [0,1] β uint8 [0,255] |
transforms.ToTensor() gives float; FPGA reads bytes. Implicit Γ255 scale. |
Conv weight quantization: round(w * 127/max(w)) |
Adjusts float values into 8-bit signed integer capacity. |
Conv output: (acc >> 8) clamped to [0,255] after ReLU |
Drops 8 bits β Γ·256 β accommodates accumulated 9-MAC range. |
FC bias quantization: round(b * fc_scale * conv_scale * 255/256) |
FC bias is added to a hardware accumulator that's already at scale conv_scale Γ fc_scale. Scaling biases by fc_scale alone (the obvious choice) makes them ~100Γ too small. |
The chip originally shipped with conv_serial.v β one multiplier, ~27 cycles per output pixel. A parallel conv_sliding_win.v + mac_array_3x3.v design existed in the repo but was set aside; it turned out to contain several real bugs (not polish issues), which is why v1 went with the conservative serial path.
The v2-parallel-conv branch goes back and finishes that work properly:
| Bug in the legacy prototype | Fix in v2 |
|---|---|
mac_array_3x3 added the FC bias into every conv output |
Removed β conv has no bias (nn.Conv2d(bias=False)) |
All 9 weight ports wired to the same weight_in ("simplified for now") |
New preload FSM fetches the 9 conv kernel weights into a register file |
done fired on the last input pixel β missed the trailing MAC outputs |
Explicit 3-cycle drain after last mac_valid_in |
| No 2-cycle ROM/SRAM latency model β window contents off by one row | All addressing offset for the actual posedgeβposedge chain |
The new compute_pipeline.v exposes a parameter PARALLEL_CONV (default 1) that selects between the two conv implementations via generate:
compute_pipeline #(.PARALLEL_CONV(1)) u_compute (...); // v2 (default)
compute_pipeline #(.PARALLEL_CONV(0)) u_compute (...); // v1
| Metric (sim, single image) | v1 (serial) | v2 (parallel) |
|---|---|---|
| Conv multipliers | 1 | 9 |
| Line-buffer storage | 0 B | ~56 B |
| Conv throughput | ~27 cyc/px | 1 px/cyc |
**Compute cycles to layer_done** |
20,632 | 2,501 (8.25Γ) |
| Predicted digit (same image) | 5 | 5 (bit-identical) |
For one-shot MNIST inference both finish faster than a human can blink. v2 isn't faster to a user; what it demonstrates is that the parallel datapath actually works on real silicon, end-to-end, with bit-identical math to the serial reference.
The Tang Nano 20K's reset button reads stuck-low on the board we tested. To avoid holding the design in permanent reset, the top module synthesizes its own POR:
reg [3:0] por_cnt = 4'd0;
reg safe_rst_n_r = 1'b0;
always @(posedge clk) begin
if (por_cnt != 4'd15) begin
por_cnt <= por_cnt + 4'd1;
safe_rst_n_r <= 1'b0;
end else begin
safe_rst_n_r <= 1'b1;
end
end
wire safe_rst_n = safe_rst_n_r;
This matters more than it looks. Without a real reset pulse Gowin's synthesizer leaves some FFs at undefined power-on values β most damagingly fc_layer.highest_score, which needs to start at -2 Γ 10βΉ for the argmax comparison to work. Standalone initial begin β¦ end blocks turned out to be unreliable on this toolchain; inline-declaration initializers (reg [3:0] x = 4'd0;) and a real reset pulse work.
.
βββ top_mnist_accel.v # UART-version top (this dir is the UART build)
βββ control_unit.v # UART FSM: IDLE β LOAD_IMG β COMPUTE β TX_RESULT
βββ compute_pipeline/
β βββ compute_pipeline.v # conv + pool + fc orchestration; PARALLEL_CONV selects v1/v2
β βββ conv_serial.v # v1: serial 3Γ3 convolution (1 mul)
β βββ conv_sliding_win.v # v2: streaming 3Γ3 conv (line buffers + 3Γ3 window)
β βββ mac_array_3x3.v # v2: 9-MAC adder tree + ReLU + quantize
β βββ max_pool_2x2.v # streaming 2Γ2 max-pool
β βββ fc_layer.v # 169β10 FC, argmax with bias
βββ mem_image_ram.v # 784Γ8 inferred RAM (UART writes, compute reads)
βββ mem_weights_rom.v # 1699Γ8 weights + 11Γ32 biases, $readmemh
βββ uart_rx.v Β· uart_tx.v # 115200-baud serial peripherals
βββ pins.cst # Tang Nano 20K pin mapping
βββ tb_top.v # iverilog testbench (sends 784 bytes via UART)
βββ weights.hex / weights.mi # quantized weight ROM (.hex for $readmemh, .mi for Gowin IP)
βββ bias.hex / model/bias.mi # quantized FC bias ROM
βββ image.mi # currently-loaded test image (28Γ28 bytes)
βββ model/
β βββ train.py # PyTorch model + quantization + ROM export
β βββ hw_sim.py # Python hardware-accurate inference simulator
βββ software/
βββ send_image.py # host-side serial driver for the UART version
LED-version sources live separately under the Gowin project tree. They are the same modules with two differences: the top uses LEDs/baked image instead of UART/streamed image, and the memories are Gowin SP/pROM hard IPs instead of inferred RAM.
python -m venv venv
source venv/bin/activate
pip install torch torchvision numpy
python model/train.py # writes model/weights.hex and model/bias.mi
v1 (serial conv) on main:
iverilog -g2012 -o sim_v1.vvp \
tb_top.v top_mnist_accel.v control_unit.v \
compute_pipeline/compute_pipeline.v \
compute_pipeline/conv_serial.v \
compute_pipeline/max_pool_2x2.v \
compute_pipeline/fc_layer.v \
mem_image_ram.v mem_weights_rom.v \
uart_rx.v uart_tx.v
vvp sim_v1.vvp
v2 (parallel conv) on v2-parallel-conv:
iverilog -g2012 -o sim_v2.vvp \
tb_top.v top_mnist_accel.v control_unit.v \
compute_pipeline/compute_pipeline.v \
compute_pipeline/conv_serial.v \
compute_pipeline/conv_sliding_win.v \
compute_pipeline/mac_array_3x3.v \
compute_pipeline/max_pool_2x2.v \
compute_pipeline/fc_layer.v \
mem_image_ram.v mem_weights_rom.v \
uart_rx.v uart_tx.v
vvp sim_v2.vvp
tb_top.v prints RESULT predicted_digit=N compute_cycles=N on layer_done. Both builds must predict the same digit on the same image β that's the equivalence check.
model/hw_sim.py runs the same fixed-point math in Python against the same .mi byte streams β useful for verifying what the hardware should predict before reflashing.
- Open the Gowin project β or create a new one targeting
GW2AR-LV18QN88C8/I7with the Verilog sources from this tree andpins.cst. - Regenerate the SP/pROM IPs pointing at
image.mi,weights.mi,bias.mi. - Synthesize β Place & Route β Program Device.
# Opens /dev/tty.usbserial-* at 115200, sends 784 bytes, reads 1 byte back.
python software/send_image.py path/to/digit.png
Power on. Wait 1 s. Read LEDs:
| LED | Meaning |
|---|---|
| 5 | Heartbeat (toggles β3 Hz; confirms FPGA clocking) |
| 4 | Before math: ~fsm_started Β· After math: ~predicted_digit[3] |
| 3 | ~math_done (on = math finished) |
| 2:0 | ~predicted_digit[2:0] |
LEDs are active-low β output 0 lights the LED. Example: digit 7 = 0111 β LEDs 0/1/2 ON, 3 ON, 4 OFF, 5 blinking.
Things that bit us during bringup, preserved here so they don't bite again:
- Dead reset paths don't bake INIT values on Gowin. Hardwiring
safe_rst_n = 1'b1makes everyif (!rst_n) β¦ else β¦block dead code, and Gowin won't extract the reset values as FF init attributes. Use a POR counter. - FC weight fetch had a 1-cycle off-by-one. ROM is bypass-mode (1-cycle latency); the FSM was setting
rom_addr_out <= 9and burning a cycle inS_WAIT_ROM, sobuffer[0]got multiplied byweights[10]instead ofweights[9]. Fix: start at8. - FC argmax needs to be seeded. Without
if (digit_counter == 0 || score > highest_score), an image where all 10 dot products are negative leaveswinning_digitstuck at its init value. - The hardware has no conv bias adder. Training with
bias=Trueonnn.Conv2dsilently throws away a learned parameter and corrupts ReLU thresholds. - FC bias must be scaled by
conv_scale Γ fc_scale, not justfc_scale, because it adds into an already-scaled accumulator. predicted_digitis 4 bits but the board has 6 LEDs. Wire the high bit to LED4 (mux'd withfsm_startedpre-math) or you can't distinguish 0/8, 1/9, 2/10.
- Single-port ROM means the 9 conv weights can't be fetched in parallel. The parallel MAC array needs all 9 weights simultaneously, but
mem_weights_romonly delivers one byte per cycle. Solution: a 10-cycle preload FSM that walks ROM addresses 0..8 once at the start of inference and latches into areg signed [7:0] w [0:8]register file. Streaming then runs from registers, with the ROM idle (free for FC to use later). - The ROM and SRAM both have 2-cycle issueβread latency. Registered output on the memory module + the always-block delay =
weight_in(T) = w_rom[rom_addr at end of T-2]. Forgetting this gives every weight an off-by-one and zero correct outputs. The preload schedule has to interleave issues and latches so the first stream cycle seesram[0]exactly. - Pipeline drain matters. The MAC array has 2 register stages (products β adder tree β output). After the last
mac_valid_inpulse,donecannot fire for at least 2 more cycles or the last conv output gets dropped before max-pool can consume it. The current implementation waits 3 cycles to be safe. - Window validity is geometric, not temporal. The 3Γ3 window's bottom-right corner walks the input in raster order; a valid output requires the corner to be at
(row >= 2, col >= 2). Whencolwraps from 27 β 0 at a row boundary, the leftmost two outputs of the new row are invalid βmac_valid_inmust drop. Easy to get wrong by 26 outputs. - Generate-blocks let v1 and v2 coexist.
compute_pipeline.vuses aparameter PARALLEL_CONV+generate / ifto instantiate eitherconv_serialorconv_sliding_win. The unused module is optimized out by synthesis β no extra fabric cost β but iverilog still typechecks both branches, so you find dead-code bugs early. - Iverilog testbench at 50 MHz silently breaks UART injection.
uart_rxdefaults toparameter CLK_FREQ = 27_000_000. The sim testbench clocks the chip at 50 MHz without overriding the parameter, so the receiver samples every bit twice and the FSM transitions toCOMPUTEmid-injection with a partially-loaded RAM. Both v1 and v2 sim with this bug, which is why both predict the same "wrong" digit β the v1βv2 equivalence check works regardless. On real hardware with a 27 MHz clock or the LED-version baked image, the chip predicts correctly.
The pins.cst file contains the Tang Nano 20K pin mapping (clock at pin 4, LEDs at pins 15β20, UART/reset pins as configured).
MIT.