FPGA 9x9 complex matrix-vector multiply for real-time Lindblad simulation on the Tang Nano 20K, built with an open toolchain.
  • Python 39.9%
  • Verilog 34.9%
  • C++ 20.2%
  • C 2.8%
  • Makefile 2.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-07-28 14:00:05 -06:00
analysis Regenerate all measurement evidence on the final 108 MHz bitstream 2026-07-10 20:06:45 -06:00
constraints Fix HDMI pipeline skew, reset sync, and TLVDS drive constraints 2026-04-05 21:36:05 -04:00
fixed_point Trim repo to the matvec artifact 2026-07-09 22:08:52 -06:00
lib Trim repo to the matvec artifact 2026-07-09 22:08:52 -06:00
matvec Drop core clock 135 -> 108 MHz, stress-characterized 2026-07-09 21:55:58 -06:00
.gitignore gitignore local planning notes/ (mirror qconform) 2026-07-09 21:55:58 -06:00
.woodpecker.yml Trim repo to the matvec artifact 2026-07-09 22:08:52 -06:00
Makefile Trim repo to the matvec artifact 2026-07-09 22:08:52 -06:00
README.md README: STE pass (split sentences, active voice, no em dashes) 2026-07-28 14:00:05 -06:00

Tang Nano 20K Lindblad Propagator Accelerator

FPGA implementation of a 9x9 complex matrix-vector multiply for real-time Lindblad master equation simulation. Targets the Sipeed Tang Nano 20K (Gowin GW2AR-18C) using a fully open-source toolchain.

The core algorithm applies a precomputed propagator matrix P to a vectorized density matrix rho, step after step. lindblad-bench implements the same operation in software. This version realizes it in hardware with deterministic cycle-exact timing, which suits real-time quantum feedback control where jitter matters more than raw throughput.

Architecture

   27 MHz ──► rPLL ──► 108 MHz ──┬── matvec_top.v (UART protocol FSM)
                                 │      └── matvec_9x9.v (core)
   UART RX ──────────────────────┤
   UART TX ◄─────────────────────┘

Matvec core pipeline (4 stages, 94 cycles/step amortized, 95-cycle single-step latency from a 1-cycle pipeline fill):

Stage Operation Hardware
S0 Address generation, latch rho[col] BSRAM address
S1 Delay rho 1 cycle to match BSRAM read latency Register
S2 4 parallel 16x16 multiplies (ac, bd, ad, bc) 4x DSP18
S3 Accumulate (42-bit), saturate, store result when col==8 Adder + register

Fixed-point format: Q1.15 (16-bit signed, 1 sign + 15 fractional bits, range [-1, +0.99997]). Row sums that exceed the format saturate at ±full scale.

Clock: 108 MHz (27 MHz × 4 rPLL). The operating point is stress-characterized, not STA-derived: nextpnr/apicula has no timing arcs for MULT18X18, so the DSP path is invisible to timing analysis. 135 MHz corrupts results under full-scale operands; 108 MHz passes 500 adversarial rounds (matvec/host/stress_probe.py).

Error budget (d=3 transmon trajectory, vs double precision): P quantization 5.7e-05 HS one-time. Floor truncation bounded (-1, 0] LSB per component per step. Measured drift ~1 LSB/step of trace (3.05e-05/step, 6.1% over 2000 steps). Numbers come from analysis/precision_test.py, whose fixed-point model matches the RTL bit-exact (board-verified).

Resource usage: 4 DSP18 blocks (of 48), ~880 LUTs (of 20736), P matrix in BSRAM (2 of 46 blocks, one each for the real and imaginary halves).

Project Structure

├── Makefile                    # Yosys → nextpnr → gowin_pack → openFPGALoader
├── constraints/
│   └── tangnano20k.cst         # Board pin assignments
│
├── lib/
│   ├── uart_tx.v               # 115200 baud 8N1 transmitter
│   ├── uart_rx.v               # 115200 baud 8N1 receiver (2-FF sync)
│   └── uart_tx_tb.cpp          # Verilator testbench (7 tests)
│
├── fixed_point/
│   ├── fixed_mul.v             # Single 16x16 signed multiply → Q1.15
│   ├── complex_mul.v           # 2-stage pipelined complex multiply (4 DSP18)
│   └── complex_mul_tb.cpp      # Verilator testbench (10 tests)
│
├── matvec/
│   ├── matvec_9x9.v            # 4-stage pipelined 9x9 complex matvec
│   ├── matvec_top.v            # UART protocol wrapper (P/V/S/N/T commands)
│   ├── matvec_pll_top.v        # PLL wrapper, 108 MHz
│   ├── matvec_9x9_tb.cpp       # Verilator testbench (7 tests)
│   └── host/
│       ├── matvec_host.py      # Load propagator over serial, validate vs reference
│       └── stress_probe.py     # Adversarial full-scale stress vs saturating reference
│
└── analysis/
    ├── precision_test.py       # Error budget: Q1.15 vs Q1.31 vs double
    ├── benchmark_compare.py    # FPGA cycle timing vs C reference
    ├── c_jitter_test.c         # C reference benchmark
    ├── plot_jitter_comparison.py
    └── results/                # Board-run logs backing the figures

Test Results

All Verilator testbenches pass:

$ make sim PROJECT=lib SIM_TOP=uart_tx SIM_SRC=lib/uart_tx.v
7/7 tests passed.

$ make sim PROJECT=fixed_point SIM_TOP=complex_mul SIM_SRC="fixed_point/complex_mul.v fixed_point/fixed_mul.v"
10/10 tests passed.

$ make sim PROJECT=matvec
  Test 1: Identity matrix...        Completed in 94 cycles.   PASS
  Test 2: Known non-trivial P...    Completed in 94 cycles.   PASS (bit-exact match with C reference)
  Test 3: Multi-step (10 steps)...  Completed in 940 cycles (94.0 cycles/step).  PASS (10-step bit-exact match)
  Test 4: N=0 steps (no-op)...      Completed in 0 cycles.    PASS (rho unchanged)
  Test 5: Overflow saturates...     PASS (clamps at +32767/-32768, matches saturating ref)
  Test 6: Random P/rho, 50 rounds.. PASS (50 rounds bit-exact)
  Test 7: Back-to-back == N=2...    PASS (paths agree; mid-run write ignored)
7/7 tests passed.

On hardware: a 100-step trajectory is bit-exact vs the Python reference. 500 adversarial full-scale rounds are bit-exact at 108 MHz.

UART Protocol

Serial commands at 115200 baud (8N1) control the matvec core. All multi-byte values are little-endian.

Command Payload Response Description
P 324 bytes (81 complex Q1.15 entries) K Load propagator matrix
V 36 bytes (9 complex Q1.15 entries) K Load state vector rho
S none 36 bytes (rho result) Single propagation step
N 2 bytes (uint16 LE step count) 36 bytes (rho result) N steps, single result
T none 4 bytes (uint32 LE) Read hardware cycle counter

Each complex Q1.15 entry is 4 bytes: re_lo, re_hi, im_lo, im_hi.

Toolchain

Fully open-source, no vendor IDE required:

Tool Version Purpose
Yosys 0.63+ Synthesis
nextpnr-himbaechel 0.10+ Place & route (Apicula database)
gowin_pack Bitstream generation
openFPGALoader JTAG programming
Verilator 5.x Simulation & testbenches

Install via oss-cad-suite for a single-archive setup.

Quick Start

# Build and flash (defaults: PROJECT=matvec, TOP=matvec_pll_top, FREQ=108)
make flash

# Run all testbenches
make sim PROJECT=lib SIM_TOP=uart_tx SIM_SRC=lib/uart_tx.v
make sim PROJECT=fixed_point SIM_TOP=complex_mul SIM_SRC="fixed_point/complex_mul.v fixed_point/fixed_mul.v"
make sim PROJECT=matvec

# Validate against Python reference (board must be connected)
python matvec/host/matvec_host.py --port /dev/ttyUSB1 --steps 100

# Adversarial timing stress (board must be connected)
python matvec/host/stress_probe.py 500

# Precision analysis (no hardware needed)
python analysis/precision_test.py --steps 2000 --plot

Context

This project is a learning vehicle for FPGA-based quantum simulation, built alongside lindblad-bench (a bare-metal C implementation of the same algorithm, optimized for CPU cache hierarchies and SIMD). The FPGA version trades throughput for deterministic latency. A single step completes in exactly 95 clock cycles (94 amortized over a multi-step run) with zero jitter. Real-time feedback control of quantum hardware requires that property.

Hardware

  • Board: Sipeed Tang Nano 20K (~$25)
  • FPGA: Gowin GW2AR-LV18QN88C8/I7
    • 20,736 LUTs
    • 48 DSP18 blocks
    • 828 Kbit block SRAM
    • 64 Mbit SDRAM (not used)
  • Clock: 27 MHz crystal oscillator
  • I/O: USB-C (JTAG + UART via BL616), HDMI connector, 2 user buttons, 6 LEDs

License

MIT