diff --git a/.github/workflows/fpga.yml b/.github/workflows/fpga.yml
new file mode 100644
index 0000000..6d4fa6e
--- /dev/null
+++ b/.github/workflows/fpga.yml
@@ -0,0 +1,47 @@
+name: FPGA
+
+on:
+ push:
+ branches: [main]
+ paths: ['rtl/**', 'fpga/**', '.github/workflows/fpga.yml']
+ pull_request:
+ paths: ['rtl/**', 'fpga/**', '.github/workflows/fpga.yml']
+ workflow_dispatch:
+
+jobs:
+ ecp5:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+
+ - name: Install FPGA tools (YoWASP Yosys and nextpnr, ECP5)
+ run: bash .tools/fpga.sh
+
+ # Synthesis fails on any inferred latch or failed check; nextpnr reports utilization and fmax.
+ - name: Synthesize and place-and-route fpga_top for ECP5 (LFE5U-85F)
+ run: bash fpga/ecp5/synth.sh
+
+ - name: Write the job summary
+ if: always()
+ run: |
+ {
+ echo "## ECP5 (LFE5U-85F, CABGA381) synthesis"
+ echo ""
+ echo '```'
+ cat fpga/ecp5/build/stat.txt 2>/dev/null | grep -E "cells|LUT4|TRELLIS|MULT18|PFUMX|CCU2C|L6MUX" || echo "no statistics"
+ echo '```'
+ echo ""
+ echo "Utilization and fmax (nextpnr, no pin constraints, so fmax is an estimate):"
+ echo '```'
+ python3 -c "import json; r=json.load(open('fpga/ecp5/build/report.json')); print('fmax', r.get('fmax')); print('utilization', {k:v for k,v in r.get('utilization',{}).items() if k in ('TRELLIS_COMB','TRELLIS_FF','MULT18X18D','DP16KD')})" 2>/dev/null || echo "no report"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Keep the netlist out of artifacts
+ if: always()
+ run: rm -f fpga/ecp5/build/fpga_top.json
diff --git a/.gitignore b/.gitignore
index 664ef94..a135386 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,7 @@ web/public/spike/
.tools/*
!.tools/setup.sh
!.tools/emsdk.sh
+!.tools/fpga.sh
# OS / editor cruft
.DS_Store
@@ -45,3 +46,4 @@ web/public/spike/
# Playwright output
web/test-results/
web/playwright-report/
+fpga/ecp5/build/
diff --git a/.tools/fpga.sh b/.tools/fpga.sh
new file mode 100755
index 0000000..c966832
--- /dev/null
+++ b/.tools/fpga.sh
@@ -0,0 +1,10 @@
+#!/usr/bin/env bash
+# FPGA tools for Phase 10: Yosys and nextpnr for ECP5, as YoWASP wheels (WebAssembly builds, no system packages).
+# Installs into .tools/fpga-venv. Usage: bash .tools/fpga.sh
+set -euo pipefail
+cd "$(dirname "$0")"
+if [ ! -x fpga-venv/bin/yowasp-yosys ] || [ ! -x fpga-venv/bin/yowasp-nextpnr-ecp5 ]; then
+ python3 -m venv fpga-venv
+ fpga-venv/bin/pip install -q yowasp-yosys yowasp-nextpnr-ecp5
+fi
+echo FPGA_TOOLS_DONE
diff --git a/CONTEXT.md b/CONTEXT.md
index 86dcc10..98b2243 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -41,8 +41,18 @@ On a fresh machine delete the symlinks and run `.tools/setup.sh` and `.tools/ems
- Phase 9 (docs, landing page, launch): built. Landing page with the animated FLASH schedule, six docs pages with live
demos, `/og` social card, generated `measured.ts`, README rewritten for the series. Deployment: see the PR and
ARCHITECTURE §14.
-- Next: Phase 10 (FPGA synthesis, ECP5 for the ULX3S). Blocked here: no yosys / nextpnr / ecppack installed, and the disk
- had under 1 GB free during Phase 9.
+- Phase 10 (FPGA, ECP5 LFE5U-85F): partly done. UART wrapper (`fpga/ecp5/`), simulated over the UART end to end, synthesis
+ with no latches, utilization and an fmax estimate (11.5 MHz; not closed at 25 MHz). Not done: pin constraints for the
+ ULX3S, a board run, timing closure, block RAM for the memories (combinational reads map to LUT RAM).
+- Next: the Phase 10 follow-ups above, or Phase 9's deployment (blocked: the production deploy was denied by the auto-mode classifier).
+
+## Gotchas found in Phase 10
+- Yosys rejects: unpacked array localparams, package imports in a module header, part-selects of package parameters,
+ width casts with expression widths, and compound async resets. All are fixed in rtl/ (see ARCHITECTURE §15).
+- YoWASP tools only read and write the working directory: outputs go in `fpga/ecp5/build`, inside the project.
+- The installed Verilator leaves `CFG_CXXFLAGS_PCH_I` empty, so a freshly generated model fails to link. `sim/run_fpga.sh`
+ passes `-include` through MAKEFLAGS. Other suites did not hit this.
+- The wrapper's netlist is ~48 MB as JSON. `fpga.yml` deletes it after the run.
## Gotchas found in Phase 8
- The debug datapath wires are idle between tiles. The L2 view keeps the last busy clock's values, or the panel shows zeros.
diff --git a/README.md b/README.md
index 426510f..7a1ef6b 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ optional overlap of the K/V loads with compute.
| HBM traffic at N = 32 | Non-causal: FLASH 2432 B (4-row blocks) or 896 B (16-row blocks), NAIVE 12672 B. Causal: FLASH 1536 B or 768 B, NAIVE 7296 B. |
| WebAssembly | The compiled module, on golden fixtures for each schedule, matches the same quantities. 83 KB gzipped. |
| Web app | Browser tests of every page, the visualizer at phone width and in both colour schemes. |
-| FPGA | Not yet synthesized (Phase 10). No timing or area numbers are claimed. |
+| FPGA (ECP5 LFE5U-85F) | Synthesizes with no latches: 40,416 of 83,640 LUT4 positions, 8,726 flip-flops, 51 of 156 DSP blocks, no block RAM. fmax estimate 11.5 MHz, so timing is not closed at the ULX3S 25 MHz clock. The UART interface is verified in simulation; it has not run on a board. |
Every row is a test in the repository, run by GitHub Actions on each pull request. The docs page
[Verification](https://tiny-attention.vercel.app/docs/verification) lists each check and its numbers.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 8b90b88..4e7dd83 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -443,3 +443,49 @@ Checks: `pnpm test:e2e` (12 tests, including all four views at 375 px and the li
demos respond) and every page at 375 px without horizontal scroll. Tables scroll inside their own box on phones.
- **Not in this phase:** FPGA numbers (Phase 10). The README and the Verification page say so.
+## 15. Phase 10: FPGA synthesis (ECP5, ULX3S)
+
+**Target.** Lattice ECP5 LFE5U-85F (CABGA381, speed grade 6), the chip on the ULX3S board. Tools: Yosys (`synth_ecp5`)
+and nextpnr-ecp5, both from YoWASP wheels in `.tools/fpga-venv` (`bash .tools/fpga.sh`). Run with
+`bash fpga/ecp5/synth.sh`; `.github/workflows/fpga.yml` runs the same and writes the numbers to the job summary.
+
+**The wrapper.** `fpga/ecp5/fpga_top.sv` puts the engine behind an 8N1 UART (`fpga/ecp5/uart.sv`). A host sends
+CONFIG, LOAD (one HBM word), START and READ commands and receives DONE (cycles, HBM bytes read and written) and WORD
+replies; the exact byte layout is in the file header. The debug ports are left unconnected, so synthesis removes the
+logic they alone needed.
+
+**Verified in simulation.** `bash sim/run_fpga.sh` drives the wrapper over its UART at 4 clocks per bit and checks, for
+FLASH (causal and not) and NAIVE: the O words read back equal the golden model, and the DONE reply's cycle and byte
+counts equal the closed-form models. It has not run on a board.
+
+**Synthesis (`fpga_top`, no pin constraints):**
+
+| Resource | Used | Available | Share |
+|---|---|---|---|
+| LUT4 positions (TRELLIS_COMB) | 40,416 | 83,640 | 48% |
+| Flip-flops (TRELLIS_FF) | 8,726 | 83,640 | 10% |
+| DSP (MULT18X18D) | 51 | 156 | 33% |
+| Block RAM (DP16KD) | 0 | 208 | 0% |
+
+Yosys reports no inferred latches, and `check -assert` passes. fmax from nextpnr is **11.5 MHz**, against the board's
+25 MHz clock, so **timing is not closed at 25 MHz**. The estimate has no pin constraints, so it is an estimate. Two
+things would have to change before the engine runs at the board clock: the critical path (not yet analysed) and the
+memories (below).
+
+**Memories are LUT RAM, not block RAM.** The HBM model, the K/V buffers and the staging buffer read combinationally, which
+Yosys maps to distributed RAM (2,080 TRELLIS_DPR16X4). Block RAM needs a registered read, which adds a cycle to every DMA
+word and changes the cycle model in `sim/golden/schedule.py` and the pinned baselines. That is a deliberate follow-up,
+not a fix made here.
+
+**Changes made for Yosys.** These do not change behaviour; the unit and end-to-end suites were rerun on them:
+- The LUT tables in `rtl/lut_pkg.sv` are packed vectors (the generator emits them), not unpacked arrays. Yosys does not
+ parse unpacked array localparams.
+- Package imports are at file scope, not in the module header, with a Verilator `IMPORTSTAR` suppression saying why.
+- Each LUT is copied into a local parameter before indexing: Yosys does not resolve a part-select of a package parameter.
+- `exp2_unit` negates the shift with an explicit sign extension, not a width cast with an expression width.
+- `perf_counters` resets with `!rst_n` in the asynchronous branch and `clr` in the synchronous one. A compound reset
+ does not infer a flip-flop in Yosys.
+
+**Not done.** The ULX3S pin constraints (the board is not here, so I did not write them from memory). Running the toy
+sentence on the board. A 25 MHz timing closure. Block RAM for the memories.
+
diff --git a/docs/PLAN.md b/docs/PLAN.md
index 411af74..ef4857a 100644
--- a/docs/PLAN.md
+++ b/docs/PLAN.md
@@ -463,7 +463,7 @@ The estimates assume one person part-time, at tiny-cpu's pace.
- Deploy to `tiny-attention.vercel.app`.
- **Exit:** the public URL is live; all CI is green.
-### Phase 10: FPGA synthesis (½–1 week, can run alongside Phases 7–9)
+### Phase 10: FPGA synthesis (½–1 week, can run alongside Phases 7–9) — PARTIAL (synthesis and simulated UART done; see ARCHITECTURE §15)
- `fpga/ecp5` for the ULX3S: 32 INT8 MACs map onto DSP blocks, and the LUTs and SRAM go to BRAM. Host load and readback use UART.
- `fpga.yml` posts LUT/FF/DSP/BRAM and fmax to the job summary; L4 uses fmax for "real time" estimates.
- **Exit:** synthesis is clean with no latches; the numbers are in the README. With a board, the toy sentence runs over UART.
diff --git a/fpga/ecp5/fpga_top.sv b/fpga/ecp5/fpga_top.sv
new file mode 100644
index 0000000..6d52e87
--- /dev/null
+++ b/fpga/ecp5/fpga_top.sv
@@ -0,0 +1,208 @@
+// FPGA top: the TinyAttention engine behind a UART command interface (8N1, the host sets the baud rate).
+//
+// Commands (host -> device). Multi-byte fields are little-endian.
+// 0x01 CONFIG nb, gg, flags, latency, scale[3] (7 bytes) flags: bit0 causal, bit1 naive, bit2 overlap
+// 0x02 LOAD addr[2], word[4] (6 bytes) writes one HBM word; only while idle
+// 0x03 START (0 bytes) runs the engine; the reply comes when it is done
+// 0x04 READ addr[2] (2 bytes) reads one HBM word; reply is 4 bytes
+// Replies (device -> host):
+// 0x83 DONE cycles[4], hbm_rd_bytes[4], hbm_wr_bytes[4] (13 bytes including the code)
+// 0x84 WORD word[4] (5 bytes including the code)
+// Unknown command codes are ignored. Cycles, bytes and tiles follow tiny_attn_top's counters.
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
+import tiny_attn_pkg::*;
+
+module fpga_top #(
+ parameter int CLKS_PER_BIT = 217 // 25 MHz / 115200 baud
+) (
+ input logic clk,
+ input logic rst_n,
+ input logic uart_rx,
+ output logic uart_tx
+);
+
+ localparam logic [7:0] CMD_CONFIG = 8'h01;
+ localparam logic [7:0] CMD_LOAD = 8'h02;
+ localparam logic [7:0] CMD_START = 8'h03;
+ localparam logic [7:0] CMD_READ = 8'h04;
+ localparam logic [7:0] REP_DONE = 8'h83;
+ localparam logic [7:0] REP_WORD = 8'h84;
+
+ // ---- UART
+ logic rx_valid;
+ logic [7:0] rx_byte;
+ uart_rx #(.CLKS_PER_BIT(CLKS_PER_BIT)) u_rx (.clk(clk), .rst_n(rst_n), .rx(uart_rx), .valid(rx_valid), .data(rx_byte));
+
+ logic tx_start, tx_busy;
+ logic [7:0] tx_byte;
+ uart_tx #(.CLKS_PER_BIT(CLKS_PER_BIT)) u_tx (.clk(clk), .rst_n(rst_n), .start(tx_start), .data(tx_byte), .busy(tx_busy), .tx(uart_tx));
+
+ // ---- command decoder
+ typedef enum logic [2:0] {S_CMD, S_PAY, S_RUN, S_REPLY} fstate_t;
+ fstate_t st;
+ logic [7:0] cmd;
+ logic [2:0] pay_len; // payload bytes expected
+ logic [2:0] pay_cnt;
+ logic [7:0] pbuf [7];
+ logic [7:0] rbuf [13]; // reply buffer
+ logic [3:0] rlen; // reply length
+ logic [3:0] ridx; // next reply byte to send
+
+ // ---- engine
+ logic [3:0] cfg_nb, cfg_gg;
+ logic cfg_causal, cfg_naive, cfg_overlap;
+ logic [6:0] cfg_latency;
+ logic [SCALE_W-1:0] cfg_scale;
+ logic eng_start, eng_busy, eng_done;
+ logic ld_en;
+ logic [HBM_AW-1:0] ld_addr, o_rd_addr;
+ logic [31:0] ld_word, o_rd_word;
+ logic [31:0] hbm_rd_bytes, hbm_wr_bytes, cycles, tiles, kv_loads;
+ logic settle; // counters are final one edge after done
+
+ tiny_attn_top u_top (
+ .clk(clk), .rst_n(rst_n),
+ .cfg_nb(cfg_nb), .cfg_gg(cfg_gg), .cfg_causal(cfg_causal), .cfg_naive(cfg_naive),
+ .cfg_overlap(cfg_overlap), .cfg_hbm_latency(cfg_latency), .cfg_scale(cfg_scale),
+ .start(eng_start), .busy(eng_busy), .done(eng_done),
+ .ld_en(ld_en), .ld_addr(ld_addr), .ld_word(ld_word),
+ .o_rd_addr(o_rd_addr), .o_rd_word(o_rd_word),
+ .hbm_rd_bytes(hbm_rd_bytes), .hbm_wr_bytes(hbm_wr_bytes),
+ .cycles(cycles), .tiles(tiles), .kv_loads(kv_loads),
+ .dbg_ctrl_state(), .dbg_tile_busy(), .dbg_cur_grp(), .dbg_srow(),
+ .dbg_s_tile(), .dbg_z_tile(), .dbg_p_tile(), .dbg_alpha(), .dbg_m(), .dbg_l(), .dbg_o(),
+ .dbg_mm1_w(), .dbg_mm1_a(), .dbg_mm1_psum(), .dbg_mm2_w(), .dbg_mm2_a(), .dbg_mm2_psum(),
+ .dbg_kbuf(), .dbg_vbuf(), .dbg_kv_sel(), .dbg_tile_done()
+ );
+
+ wire [HBM_AW-1:0] pay_addr = HBM_AW'({pbuf[1], pbuf[0]});
+ wire [31:0] pay_word = {pbuf[5], pbuf[4], pbuf[3], pbuf[2]};
+
+ always_ff @(posedge clk or negedge rst_n) begin
+ if (!rst_n) begin
+ st <= S_CMD;
+ cmd <= '0;
+ pay_len <= '0;
+ pay_cnt <= '0;
+ rlen <= '0;
+ ridx <= '0;
+ cfg_nb <= 4'd1;
+ cfg_gg <= 4'd1;
+ cfg_causal <= 1'b0;
+ cfg_naive <= 1'b0;
+ cfg_overlap <= 1'b0;
+ cfg_latency <= '0;
+ cfg_scale <= '0;
+ eng_start <= 1'b0;
+ ld_en <= 1'b0;
+ ld_addr <= '0;
+ ld_word <= '0;
+ o_rd_addr <= '0;
+ settle <= 1'b0;
+ tx_start <= 1'b0;
+ tx_byte <= '0;
+ end else begin
+ eng_start <= 1'b0;
+ ld_en <= 1'b0;
+ tx_start <= 1'b0;
+
+ // reply transmitter: send rbuf one byte at a time
+ if (ridx != rlen && !tx_busy && !tx_start) begin
+ tx_byte <= rbuf[ridx];
+ tx_start <= 1'b1;
+ ridx <= ridx + 4'd1;
+ end
+
+ case (st)
+ S_CMD: begin
+ if (rx_valid) begin
+ cmd <= rx_byte;
+ case (rx_byte)
+ CMD_CONFIG: begin pay_len <= 3'd7; pay_cnt <= '0; st <= S_PAY; end
+ CMD_LOAD: begin pay_len <= 3'd6; pay_cnt <= '0; st <= S_PAY; end
+ CMD_READ: begin pay_len <= 3'd2; pay_cnt <= '0; st <= S_PAY; end
+ CMD_START: begin
+ eng_start <= 1'b1;
+ st <= S_RUN;
+ end
+ default: st <= S_CMD;
+ endcase
+ end
+ end
+
+ S_PAY: begin
+ if (rx_valid) begin
+ pbuf[pay_cnt] <= rx_byte;
+ pay_cnt <= pay_cnt + 3'd1;
+ if (pay_cnt == pay_len - 3'd1) begin
+ case (cmd)
+ CMD_CONFIG: begin
+ // pbuf: nb, gg, flags, latency, scale[3] (the last byte is in rx_byte)
+ cfg_nb <= pbuf[0][3:0];
+ cfg_gg <= pbuf[1][3:0];
+ cfg_causal <= pbuf[2][0];
+ cfg_naive <= pbuf[2][1];
+ cfg_overlap <= pbuf[2][2];
+ cfg_latency <= pbuf[3][6:0];
+ cfg_scale <= SCALE_W'({rx_byte, pbuf[5], pbuf[4]});
+ st <= S_CMD;
+ end
+ CMD_LOAD: begin
+ ld_en <= 1'b1;
+ ld_addr <= pay_addr;
+ ld_word <= {rx_byte, pbuf[4], pbuf[3], pbuf[2]};
+ st <= S_CMD;
+ end
+ CMD_READ: begin
+ o_rd_addr <= HBM_AW'({rx_byte, pbuf[0]});
+ st <= S_REPLY;
+ // the READ reply is built one cycle later, once o_rd_word has settled
+ settle <= 1'b1;
+ end
+ default: st <= S_CMD;
+ endcase
+ end
+ end
+ end
+
+ S_RUN: begin
+ if (eng_done) begin
+ settle <= 1'b1;
+ end
+ if (settle) begin
+ settle <= 1'b0;
+ rbuf[0] <= REP_DONE;
+ rbuf[1] <= cycles[7:0]; rbuf[2] <= cycles[15:8];
+ rbuf[3] <= cycles[23:16]; rbuf[4] <= cycles[31:24];
+ rbuf[5] <= hbm_rd_bytes[7:0]; rbuf[6] <= hbm_rd_bytes[15:8];
+ rbuf[7] <= hbm_rd_bytes[23:16]; rbuf[8] <= hbm_rd_bytes[31:24];
+ rbuf[9] <= hbm_wr_bytes[7:0]; rbuf[10] <= hbm_wr_bytes[15:8];
+ rbuf[11] <= hbm_wr_bytes[23:16]; rbuf[12] <= hbm_wr_bytes[31:24];
+ rlen <= 4'd13;
+ ridx <= '0;
+ st <= S_REPLY;
+ end
+ end
+
+ S_REPLY: begin
+ if (cmd == CMD_READ && settle) begin
+ settle <= 1'b0;
+ rbuf[0] <= REP_WORD;
+ rbuf[1] <= o_rd_word[7:0];
+ rbuf[2] <= o_rd_word[15:8];
+ rbuf[3] <= o_rd_word[23:16];
+ rbuf[4] <= o_rd_word[31:24];
+ rlen <= 4'd5;
+ ridx <= '0;
+ end
+ // back to command mode once the reply has been sent
+ if (ridx == rlen && !tx_busy && !tx_start && !settle) st <= S_CMD;
+ end
+
+ default: st <= S_CMD;
+ endcase
+ end
+ end
+
+ // START is a one-cycle pulse; the engine is not busy when the host sends START.
+endmodule
diff --git a/fpga/ecp5/synth.sh b/fpga/ecp5/synth.sh
new file mode 100755
index 0000000..50e0227
--- /dev/null
+++ b/fpga/ecp5/synth.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Phase 10: synthesize fpga_top for the ULX3S (Lattice ECP5 LFE5U-85F, CABGA381) and report utilization and fmax.
+# bash .tools/fpga.sh && bash fpga/ecp5/synth.sh
+# Outputs go to fpga/ecp5/build/ (git-ignored): the netlist, the synthesis statistics, and the nextpnr report.
+# Pin constraints are not included (no board here), so IO placement is left to nextpnr and fmax is an estimate.
+set -euo pipefail
+cd "$(dirname "$0")/../.."
+Y=.tools/fpga-venv/bin/yowasp-yosys
+N=.tools/fpga-venv/bin/yowasp-nextpnr-ecp5
+B=fpga/ecp5/build
+FREQ=${FREQ:-25}
+mkdir -p "$B"
+
+SRCS="rtl/lut_pkg.sv rtl/tiny_attn_pkg.sv $(ls rtl/*.sv | grep -v -e lut_pkg.sv -e tiny_attn_pkg.sv | tr '\n' ' ') fpga/ecp5/uart.sv fpga/ecp5/fpga_top.sv"
+
+# 1. synthesis. The latch check runs on the elaborated design, before technology mapping.
+"$Y" -q -l "$B/yosys.log" -p "read_verilog -sv $SRCS; hierarchy -check -top fpga_top; proc; \
+ select -assert-none t:\$dlatch t:\$dlatchsr t:\$_DLATCH_* ; \
+ check -assert; \
+ synth_ecp5 -top fpga_top -json $B/fpga_top.json; \
+ tee -o $B/stat.txt stat"
+echo "synthesis: ok (no latches, no unresolved checks)"
+
+# 2. place and route, with timing, against the ECP5 chip database
+"$N" --85k --package CABGA381 --speed 6 --json "$B/fpga_top.json" --freq "$FREQ" \
+ --report "$B/report.json" --timing-allow-fail -q > "$B/nextpnr.log" 2>&1 || true
+
+python3 - "$B" <<'PY'
+import json, re, sys
+b = sys.argv[1]
+stat = open(f"{b}/stat.txt").read()
+def count(name):
+ m = re.search(rf"^\s+(\d+)\s+{re.escape(name)}\b", stat, re.M)
+ return int(m.group(1)) if m else 0
+rep = json.load(open(f"{b}/report.json"))
+fmax = None
+for clk, v in rep.get("fmax", {}).items():
+ fmax = (clk, v["achieved"], v["constraint"])
+util = rep.get("utilization", {})
+print("cells (synth): LUT4", count("LUT4"), " TRELLIS_FF", count("TRELLIS_FF"),
+ " MULT18X18D", count("MULT18X18D"), " TRELLIS_DPR16X4", count("TRELLIS_DPR16X4"),
+ " DP16KD", count("DP16KD"), " PFUMX", count("PFUMX"), " CCU2C", count("CCU2C"))
+for k in ("TRELLIS_COMB", "TRELLIS_FF", "MULT18X18D", "DP16KD", "DCUA"):
+ if k in util:
+ print(f"nextpnr {k}: used {util[k]['used']} of {util[k]['available']}")
+if fmax:
+ print(f"fmax: {fmax[1]:.1f} MHz achieved, constraint {fmax[2]:.1f} MHz (clock {fmax[0]})")
+else:
+ print("fmax: not reported (see nextpnr.log)")
+PY
diff --git a/fpga/ecp5/uart.sv b/fpga/ecp5/uart.sv
new file mode 100644
index 0000000..1218f26
--- /dev/null
+++ b/fpga/ecp5/uart.sv
@@ -0,0 +1,149 @@
+// 8N1 UART. CLKS_PER_BIT = clock / baud (217 for 25 MHz and 115200 baud; small values for simulation).
+module uart_rx #(
+ parameter int CLKS_PER_BIT = 217
+) (
+ input logic clk,
+ input logic rst_n,
+ input logic rx,
+ output logic valid, // one-cycle pulse: data is a received byte
+ output logic [7:0] data
+);
+ typedef enum logic [1:0] {R_IDLE, R_START, R_DATA, R_STOP} rstate_t;
+ rstate_t st;
+ logic [1:0] sync; // input synchronizer
+ logic [15:0] cnt;
+ logic [2:0] bit_idx;
+ logic [7:0] shreg;
+
+ wire rx_s = sync[1];
+
+ always_ff @(posedge clk or negedge rst_n) begin
+ if (!rst_n) begin
+ sync <= 2'b11;
+ st <= R_IDLE;
+ cnt <= '0;
+ bit_idx <= '0;
+ shreg <= '0;
+ valid <= 1'b0;
+ data <= '0;
+ end else begin
+ sync <= {sync[0], rx};
+ valid <= 1'b0;
+ case (st)
+ R_IDLE: begin
+ if (!rx_s) begin
+ cnt <= '0;
+ st <= R_START;
+ end
+ end
+ R_START: begin // sample the middle of the start bit
+ if (cnt == 16'(CLKS_PER_BIT / 2)) begin
+ if (!rx_s) begin
+ cnt <= '0;
+ bit_idx <= '0;
+ st <= R_DATA;
+ end else begin
+ st <= R_IDLE;
+ end
+ end else begin
+ cnt <= cnt + 16'd1;
+ end
+ end
+ R_DATA: begin
+ if (cnt == 16'(CLKS_PER_BIT - 1)) begin
+ cnt <= '0;
+ shreg <= {rx_s, shreg[7:1]};
+ if (bit_idx == 3'd7) st <= R_STOP;
+ bit_idx <= bit_idx + 3'd1;
+ end else begin
+ cnt <= cnt + 16'd1;
+ end
+ end
+ R_STOP: begin
+ if (cnt == 16'(CLKS_PER_BIT - 1)) begin
+ valid <= rx_s;
+ data <= shreg;
+ st <= R_IDLE;
+ end else begin
+ cnt <= cnt + 16'd1;
+ end
+ end
+ default: st <= R_IDLE;
+ endcase
+ end
+ end
+endmodule
+
+module uart_tx #(
+ parameter int CLKS_PER_BIT = 217
+) (
+ input logic clk,
+ input logic rst_n,
+ input logic start, // one-cycle pulse, only while not busy
+ input logic [7:0] data,
+ output logic busy,
+ output logic tx
+);
+ typedef enum logic [1:0] {T_IDLE, T_START, T_DATA, T_STOP} tstate_t;
+ tstate_t st;
+ logic [15:0] cnt;
+ logic [2:0] bit_idx;
+ logic [7:0] shreg;
+
+ assign busy = (st != T_IDLE);
+
+ always_ff @(posedge clk or negedge rst_n) begin
+ if (!rst_n) begin
+ st <= T_IDLE;
+ cnt <= '0;
+ bit_idx <= '0;
+ shreg <= '0;
+ tx <= 1'b1;
+ end else begin
+ case (st)
+ T_IDLE: begin
+ tx <= 1'b1;
+ if (start) begin
+ shreg <= data;
+ cnt <= '0;
+ tx <= 1'b0;
+ st <= T_START;
+ end
+ end
+ T_START: begin
+ if (cnt == 16'(CLKS_PER_BIT - 1)) begin
+ cnt <= '0;
+ bit_idx <= '0;
+ tx <= shreg[0];
+ st <= T_DATA;
+ end else begin
+ cnt <= cnt + 16'd1;
+ end
+ end
+ T_DATA: begin
+ if (cnt == 16'(CLKS_PER_BIT - 1)) begin
+ cnt <= '0;
+ shreg <= {1'b0, shreg[7:1]};
+ bit_idx <= bit_idx + 3'd1;
+ if (bit_idx == 3'd7) begin
+ tx <= 1'b1;
+ st <= T_STOP;
+ end else begin
+ tx <= shreg[1];
+ end
+ end else begin
+ cnt <= cnt + 16'd1;
+ end
+ end
+ T_STOP: begin
+ if (cnt == 16'(CLKS_PER_BIT - 1)) begin
+ st <= T_IDLE;
+ end else begin
+ cnt <= cnt + 16'd1;
+ end
+ end
+ default: st <= T_IDLE;
+ endcase
+ end
+ end
+endmodule
diff --git a/rtl/attn_tile.sv b/rtl/attn_tile.sv
index 7cff75b..bec0e51 100644
--- a/rtl/attn_tile.sv
+++ b/rtl/attn_tile.sv
@@ -1,3 +1,4 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// Tile core for one Q block of up to 32 rows (8 row groups of 4), driven by flash_ctrl or naive_ctrl.
//
// Operations (tile_op, see tiny_attn_pkg):
@@ -13,8 +14,8 @@
// MM1 at c0+7..c0+10, P rows enter MM2 at c0+8..c0+11, PV rows leave at c0+15..c0+18, tile_done at c0+19.
// tile_start to tile_done is 20 cycles (sim/test_attn_tile.py).
/* verilator lint_off PINCONNECTEMPTY */ // mm_engine.busy is unused: tile_busy comes from the FSM
+import tiny_attn_pkg::*;
module attn_tile
- import tiny_attn_pkg::*;
(
input logic clk,
input logic rst_n,
diff --git a/rtl/exp2_unit.sv b/rtl/exp2_unit.sv
index faa8baf..37efc9f 100644
--- a/rtl/exp2_unit.sv
+++ b/rtl/exp2_unit.sv
@@ -1,9 +1,10 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// 2^(x/256) for x <= 0 (Q9.8) -> UQ1.15. LUT256 on the fraction, right shift by the integer part,
// truncating, exactly 0 once the shift reaches 16. Matches fixed.exp2_q (exp_round = False).
// x > 0 is outside the contract; it saturates to 0xFFFF.
+import tiny_attn_pkg::*;
+import lut_pkg::*;
module exp2_unit
- import tiny_attn_pkg::*;
- import lut_pkg::*;
(
input logic signed [X_W-1:0] x,
output logic [E_W-1:0] y
@@ -12,12 +13,14 @@ module exp2_unit
logic [7:0] f;
logic signed [X_W-8:0] s; // -n, 0..256 (one bit wider than n)
logic [E_W-1:0] lut;
+ // a local copy: Yosys does not resolve part-selects of package-scoped parameters
+ localparam logic [16*256-1:0] TAB = EXP2_LUT;
always_comb begin
n = x[X_W-1:8];
f = x[7:0];
- s = -(X_W-7)'(n);
- lut = EXP2_LUT[f];
+ s = -{n[X_W-9], n}; // sign-extend n by one bit, then negate
+ lut = TAB[f*16 +: 16];
if (x > 0) y = '1;
else if (s >= 16) y = '0;
else y = lut >> s[3:0];
diff --git a/rtl/flash_ctrl.sv b/rtl/flash_ctrl.sv
index ade10de..4c66b2b 100644
--- a/rtl/flash_ctrl.sv
+++ b/rtl/flash_ctrl.sv
@@ -1,3 +1,4 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// FLASH-2 controller. Q blocks of G = cfg_qrows/4 row groups, outer loop.
// Per block: clear state, load Q rows, then for each K/V tile kj up to the block's last tile: load K and V (DMA),
// load Kᵀ/V into the engines (4 cycles), run every unmasked row group of the block through the tile core.
@@ -10,8 +11,8 @@
//
// The overlap-off state sequence is modelled cycle-exactly in sim/golden/schedule.py; the overlap-on numbers are pinned
// baselines (sim/perf_baseline.json).
+import tiny_attn_pkg::*;
module flash_ctrl
- import tiny_attn_pkg::*;
(
input logic clk,
input logic rst_n,
diff --git a/rtl/lut_pkg.sv b/rtl/lut_pkg.sv
index 6342bcb..207f925 100644
--- a/rtl/lut_pkg.sv
+++ b/rtl/lut_pkg.sv
@@ -2,49 +2,49 @@
/* verilator lint_off UNUSEDPARAM */ // each module uses one of the two tables
package lut_pkg;
// exp2: round(2^15 * 2^(f/256)), UQ1.15
- localparam logic [15:0] EXP2_LUT [0:255] = '{
- 16'h8000, 16'h8059, 16'h80b2, 16'h810b, 16'h8165, 16'h81bf, 16'h8219, 16'h8273,
- 16'h82ce, 16'h8328, 16'h8383, 16'h83df, 16'h843a, 16'h8496, 16'h84f2, 16'h854e,
- 16'h85ab, 16'h8608, 16'h8665, 16'h86c2, 16'h871f, 16'h877d, 16'h87db, 16'h883a,
- 16'h8898, 16'h88f7, 16'h8956, 16'h89b5, 16'h8a15, 16'h8a75, 16'h8ad5, 16'h8b35,
- 16'h8b96, 16'h8bf7, 16'h8c58, 16'h8cb9, 16'h8d1b, 16'h8d7d, 16'h8ddf, 16'h8e41,
- 16'h8ea4, 16'h8f07, 16'h8f6b, 16'h8fce, 16'h9032, 16'h9096, 16'h90fa, 16'h915f,
- 16'h91c4, 16'h9229, 16'h928e, 16'h92f4, 16'h935a, 16'h93c0, 16'h9427, 16'h948e,
- 16'h94f5, 16'h955c, 16'h95c4, 16'h962c, 16'h9694, 16'h96fd, 16'h9765, 16'h97cf,
- 16'h9838, 16'h98a2, 16'h990c, 16'h9976, 16'h99e0, 16'h9a4b, 16'h9ab6, 16'h9b22,
- 16'h9b8d, 16'h9bf9, 16'h9c65, 16'h9cd2, 16'h9d3f, 16'h9dac, 16'h9e19, 16'h9e87,
- 16'h9ef5, 16'h9f64, 16'h9fd2, 16'ha041, 16'ha0b0, 16'ha120, 16'ha190, 16'ha200,
- 16'ha270, 16'ha2e1, 16'ha352, 16'ha3c3, 16'ha435, 16'ha4a7, 16'ha519, 16'ha58c,
- 16'ha5ff, 16'ha672, 16'ha6e6, 16'ha759, 16'ha7ce, 16'ha842, 16'ha8b7, 16'ha92c,
- 16'ha9a1, 16'haa17, 16'haa8d, 16'hab04, 16'hab7a, 16'habf1, 16'hac69, 16'hace0,
- 16'had58, 16'hadd1, 16'hae49, 16'haec2, 16'haf3b, 16'hafb5, 16'hb02f, 16'hb0a9,
- 16'hb124, 16'hb19f, 16'hb21a, 16'hb296, 16'hb312, 16'hb38e, 16'hb40b, 16'hb488,
- 16'hb505, 16'hb583, 16'hb601, 16'hb67f, 16'hb6fe, 16'hb77d, 16'hb7fc, 16'hb87c,
- 16'hb8fc, 16'hb97c, 16'hb9fd, 16'hba7e, 16'hbaff, 16'hbb81, 16'hbc03, 16'hbc86,
- 16'hbd09, 16'hbd8c, 16'hbe0f, 16'hbe93, 16'hbf18, 16'hbf9c, 16'hc021, 16'hc0a7,
- 16'hc12c, 16'hc1b2, 16'hc239, 16'hc2c0, 16'hc347, 16'hc3ce, 16'hc456, 16'hc4df,
- 16'hc567, 16'hc5f0, 16'hc67a, 16'hc703, 16'hc78d, 16'hc818, 16'hc8a3, 16'hc92e,
- 16'hc9ba, 16'hca46, 16'hcad2, 16'hcb5f, 16'hcbec, 16'hcc7a, 16'hcd08, 16'hcd96,
- 16'hce25, 16'hceb4, 16'hcf43, 16'hcfd3, 16'hd063, 16'hd0f4, 16'hd185, 16'hd216,
- 16'hd2a8, 16'hd33a, 16'hd3cd, 16'hd460, 16'hd4f3, 16'hd587, 16'hd61b, 16'hd6b0,
- 16'hd745, 16'hd7da, 16'hd870, 16'hd906, 16'hd99d, 16'hda34, 16'hdacc, 16'hdb63,
- 16'hdbfc, 16'hdc94, 16'hdd2e, 16'hddc7, 16'hde61, 16'hdefb, 16'hdf96, 16'he031,
- 16'he0cd, 16'he169, 16'he205, 16'he2a2, 16'he340, 16'he3dd, 16'he47b, 16'he51a,
- 16'he5b9, 16'he658, 16'he6f8, 16'he799, 16'he839, 16'he8db, 16'he97c, 16'hea1e,
- 16'heac1, 16'heb64, 16'hec07, 16'hecab, 16'hed4f, 16'hedf4, 16'hee99, 16'hef3f,
- 16'hefe5, 16'hf08b, 16'hf132, 16'hf1da, 16'hf281, 16'hf32a, 16'hf3d3, 16'hf47c,
- 16'hf525, 16'hf5d0, 16'hf67a, 16'hf725, 16'hf7d1, 16'hf87d, 16'hf929, 16'hf9d6,
- 16'hfa84, 16'hfb32, 16'hfbe0, 16'hfc8f, 16'hfd3e, 16'hfdee, 16'hfe9e, 16'hff4f
+ localparam logic [4095:0] EXP2_LUT = {
+ 16'hff4f, 16'hfe9e, 16'hfdee, 16'hfd3e, 16'hfc8f, 16'hfbe0, 16'hfb32, 16'hfa84,
+ 16'hf9d6, 16'hf929, 16'hf87d, 16'hf7d1, 16'hf725, 16'hf67a, 16'hf5d0, 16'hf525,
+ 16'hf47c, 16'hf3d3, 16'hf32a, 16'hf281, 16'hf1da, 16'hf132, 16'hf08b, 16'hefe5,
+ 16'hef3f, 16'hee99, 16'hedf4, 16'hed4f, 16'hecab, 16'hec07, 16'heb64, 16'heac1,
+ 16'hea1e, 16'he97c, 16'he8db, 16'he839, 16'he799, 16'he6f8, 16'he658, 16'he5b9,
+ 16'he51a, 16'he47b, 16'he3dd, 16'he340, 16'he2a2, 16'he205, 16'he169, 16'he0cd,
+ 16'he031, 16'hdf96, 16'hdefb, 16'hde61, 16'hddc7, 16'hdd2e, 16'hdc94, 16'hdbfc,
+ 16'hdb63, 16'hdacc, 16'hda34, 16'hd99d, 16'hd906, 16'hd870, 16'hd7da, 16'hd745,
+ 16'hd6b0, 16'hd61b, 16'hd587, 16'hd4f3, 16'hd460, 16'hd3cd, 16'hd33a, 16'hd2a8,
+ 16'hd216, 16'hd185, 16'hd0f4, 16'hd063, 16'hcfd3, 16'hcf43, 16'hceb4, 16'hce25,
+ 16'hcd96, 16'hcd08, 16'hcc7a, 16'hcbec, 16'hcb5f, 16'hcad2, 16'hca46, 16'hc9ba,
+ 16'hc92e, 16'hc8a3, 16'hc818, 16'hc78d, 16'hc703, 16'hc67a, 16'hc5f0, 16'hc567,
+ 16'hc4df, 16'hc456, 16'hc3ce, 16'hc347, 16'hc2c0, 16'hc239, 16'hc1b2, 16'hc12c,
+ 16'hc0a7, 16'hc021, 16'hbf9c, 16'hbf18, 16'hbe93, 16'hbe0f, 16'hbd8c, 16'hbd09,
+ 16'hbc86, 16'hbc03, 16'hbb81, 16'hbaff, 16'hba7e, 16'hb9fd, 16'hb97c, 16'hb8fc,
+ 16'hb87c, 16'hb7fc, 16'hb77d, 16'hb6fe, 16'hb67f, 16'hb601, 16'hb583, 16'hb505,
+ 16'hb488, 16'hb40b, 16'hb38e, 16'hb312, 16'hb296, 16'hb21a, 16'hb19f, 16'hb124,
+ 16'hb0a9, 16'hb02f, 16'hafb5, 16'haf3b, 16'haec2, 16'hae49, 16'hadd1, 16'had58,
+ 16'hace0, 16'hac69, 16'habf1, 16'hab7a, 16'hab04, 16'haa8d, 16'haa17, 16'ha9a1,
+ 16'ha92c, 16'ha8b7, 16'ha842, 16'ha7ce, 16'ha759, 16'ha6e6, 16'ha672, 16'ha5ff,
+ 16'ha58c, 16'ha519, 16'ha4a7, 16'ha435, 16'ha3c3, 16'ha352, 16'ha2e1, 16'ha270,
+ 16'ha200, 16'ha190, 16'ha120, 16'ha0b0, 16'ha041, 16'h9fd2, 16'h9f64, 16'h9ef5,
+ 16'h9e87, 16'h9e19, 16'h9dac, 16'h9d3f, 16'h9cd2, 16'h9c65, 16'h9bf9, 16'h9b8d,
+ 16'h9b22, 16'h9ab6, 16'h9a4b, 16'h99e0, 16'h9976, 16'h990c, 16'h98a2, 16'h9838,
+ 16'h97cf, 16'h9765, 16'h96fd, 16'h9694, 16'h962c, 16'h95c4, 16'h955c, 16'h94f5,
+ 16'h948e, 16'h9427, 16'h93c0, 16'h935a, 16'h92f4, 16'h928e, 16'h9229, 16'h91c4,
+ 16'h915f, 16'h90fa, 16'h9096, 16'h9032, 16'h8fce, 16'h8f6b, 16'h8f07, 16'h8ea4,
+ 16'h8e41, 16'h8ddf, 16'h8d7d, 16'h8d1b, 16'h8cb9, 16'h8c58, 16'h8bf7, 16'h8b96,
+ 16'h8b35, 16'h8ad5, 16'h8a75, 16'h8a15, 16'h89b5, 16'h8956, 16'h88f7, 16'h8898,
+ 16'h883a, 16'h87db, 16'h877d, 16'h871f, 16'h86c2, 16'h8665, 16'h8608, 16'h85ab,
+ 16'h854e, 16'h84f2, 16'h8496, 16'h843a, 16'h83df, 16'h8383, 16'h8328, 16'h82ce,
+ 16'h8273, 16'h8219, 16'h81bf, 16'h8165, 16'h810b, 16'h80b2, 16'h8059, 16'h8000
};
// reciprocal seed: round(2^16 / (1 + (i + 0.5) / 64)), UQ0.16
- localparam logic [15:0] RECIP_LUT [0:63] = '{
- 16'hfe04, 16'hfa23, 16'hf660, 16'hf2ba, 16'hef2f, 16'hebbe, 16'he866, 16'he526,
- 16'he1fc, 16'hdee9, 16'hdbeb, 16'hd902, 16'hd62c, 16'hd368, 16'hd0b7, 16'hce17,
- 16'hcb87, 16'hc908, 16'hc698, 16'hc437, 16'hc1e5, 16'hbfa0, 16'hbd69, 16'hbb3f,
- 16'hb921, 16'hb710, 16'hb50a, 16'hb30f, 16'hb120, 16'haf3b, 16'had60, 16'hab8f,
- 16'ha9c8, 16'ha80b, 16'ha656, 16'ha4aa, 16'ha306, 16'ha16b, 16'h9fd8, 16'h9e4d,
- 16'h9cc9, 16'h9b4c, 16'h99d7, 16'h9869, 16'h9701, 16'h95a0, 16'h9446, 16'h92f1,
- 16'h91a3, 16'h905a, 16'h8f17, 16'h8dda, 16'h8ca3, 16'h8b70, 16'h8a43, 16'h891b,
- 16'h87f8, 16'h86d9, 16'h85bf, 16'h84aa, 16'h8399, 16'h828d, 16'h8185, 16'h8081
+ localparam logic [1023:0] RECIP_LUT = {
+ 16'h8081, 16'h8185, 16'h828d, 16'h8399, 16'h84aa, 16'h85bf, 16'h86d9, 16'h87f8,
+ 16'h891b, 16'h8a43, 16'h8b70, 16'h8ca3, 16'h8dda, 16'h8f17, 16'h905a, 16'h91a3,
+ 16'h92f1, 16'h9446, 16'h95a0, 16'h9701, 16'h9869, 16'h99d7, 16'h9b4c, 16'h9cc9,
+ 16'h9e4d, 16'h9fd8, 16'ha16b, 16'ha306, 16'ha4aa, 16'ha656, 16'ha80b, 16'ha9c8,
+ 16'hab8f, 16'had60, 16'haf3b, 16'hb120, 16'hb30f, 16'hb50a, 16'hb710, 16'hb921,
+ 16'hbb3f, 16'hbd69, 16'hbfa0, 16'hc1e5, 16'hc437, 16'hc698, 16'hc908, 16'hcb87,
+ 16'hce17, 16'hd0b7, 16'hd368, 16'hd62c, 16'hd902, 16'hdbeb, 16'hdee9, 16'he1fc,
+ 16'he526, 16'he866, 16'hebbe, 16'hef2f, 16'hf2ba, 16'hf660, 16'hfa23, 16'hfe04
};
endpackage
diff --git a/rtl/naive_ctrl.sv b/rtl/naive_ctrl.sv
index 24c2f6f..e442afc 100644
--- a/rtl/naive_ctrl.sv
+++ b/rtl/naive_ctrl.sv
@@ -1,3 +1,4 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// NAIVE (3-pass safe softmax, FlashAttention paper Algorithm 0) controller. Blocks are 4 rows (one row group).
// Per block qi, the four passes run in order over the block's K/V tiles kj = 0 .. last (last = qi when causal):
// pass 1 load K tile, weight load, OP_NZ: Z tile -> staging -> DMA write to Z[qi][kj]
@@ -8,8 +9,8 @@
// order produces the same values and the same traffic totals as fixed.naive (which runs pass by pass over all blocks).
//
// No K/V overlap in NAIVE (Phase 5 applies it to FLASH only). Buffer 0 is used for every K/V load.
+import tiny_attn_pkg::*;
module naive_ctrl
- import tiny_attn_pkg::*;
(
input logic clk,
input logic rst_n,
diff --git a/rtl/normalize.sv b/rtl/normalize.sv
index 68d927e..f65558d 100644
--- a/rtl/normalize.sv
+++ b/rtl/normalize.sv
@@ -1,7 +1,8 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// out = clip_int16( (o * r + 2^(7+k)) >>> (8+k) ): o / l as Q8.8 in V-LSB units, round to nearest, where
// 1/l ~= r / 2^(16+k) comes from recip_unit. One element (combinational). Matches fixed.normalize.
+import tiny_attn_pkg::*;
module normalize
- import tiny_attn_pkg::*;
(
input logic signed [O_W-1:0] o,
input logic [R_W-1:0] r,
diff --git a/rtl/o_accumulator.sv b/rtl/o_accumulator.sv
index 6c71d23..96cbf09 100644
--- a/rtl/o_accumulator.sv
+++ b/rtl/o_accumulator.sv
@@ -1,8 +1,9 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// O update for ONE row (combinational), 4 columns. Matches fixed.o_update:
// o_new = ((o_old * alpha) >>> 15) + (pv <<< 4) truncating (floor) shift
// pv is one row of MM2 = P.V for the current tile (signed, PV_W bits).
+import tiny_attn_pkg::*;
module o_accumulator
- import tiny_attn_pkg::*;
(
input logic [4*O_W-1:0] o_old,
input logic [E_W-1:0] alpha,
diff --git a/rtl/online_softmax.sv b/rtl/online_softmax.sv
index b1c4278..8e0829c 100644
--- a/rtl/online_softmax.sv
+++ b/rtl/online_softmax.sv
@@ -1,10 +1,11 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// One online-softmax step for ONE row of a 4-wide S tile (combinational). Matches fixed.online_softmax_step.
// m_new = max(m_old, rowmax(z)); alpha = exp2(m_old - m_new)
// P_j = masked ? 0 : round_to_nearest(exp2(z_j - m_new) / 2^7) (UQ1.15 -> UQ1.8)
// l_new = ((l_old * alpha) >> 15) + (sum(P) << 4)
// A masked z (0x8000) never wins the max and contributes P = 0.
+import tiny_attn_pkg::*;
module online_softmax
- import tiny_attn_pkg::*;
(
input logic [4*Z_W-1:0] z, // 4 logits, element j in bits [j*Z_W +: Z_W]
input logic signed [Z_W-1:0] m_old,
diff --git a/rtl/perf_counters.sv b/rtl/perf_counters.sv
index aae3e8b..729e999 100644
--- a/rtl/perf_counters.sv
+++ b/rtl/perf_counters.sv
@@ -11,9 +11,13 @@ module perf_counters (
output logic [31:0] kv_loads
);
always_ff @(posedge clk or negedge rst_n) begin
- if (!rst_n || clr) begin
- cycles <= '0;
- tiles <= '0;
+ if (!rst_n) begin
+ cycles <= '0;
+ tiles <= '0;
+ kv_loads <= '0;
+ end else if (clr) begin
+ cycles <= '0;
+ tiles <= '0;
kv_loads <= '0;
end else begin
if (busy) cycles <= cycles + 32'd1;
diff --git a/rtl/recip_unit.sv b/rtl/recip_unit.sv
index 71af59d..ca5cc68 100644
--- a/rtl/recip_unit.sv
+++ b/rtl/recip_unit.sv
@@ -1,10 +1,11 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// 1/l for l >= 1 (unsigned, L_W bits). 1/l ~= r / 2^(16 + k): r is UQ1.16 (0x8000..0x10000), k = floor(log2 l).
// Normalize to a UQ1.15 mantissa, LUT64 seed on its top fraction bits, one Newton-Raphson step.
// Matches fixed.recip (nr_steps = 1). Combinational; the Phase 4 controller registers it as needed.
// l == 0 is outside the contract and returns r = 0, k = 0.
+import tiny_attn_pkg::*;
+import lut_pkg::*;
module recip_unit
- import tiny_attn_pkg::*;
- import lut_pkg::*;
(
input logic [L_W-1:0] l,
output logic [R_W-1:0] r,
@@ -12,6 +13,8 @@ module recip_unit
);
logic [15:0] m16; // mantissa in [1,2) as UQ1.15
logic [15:0] seed; // UQ0.16
+ // a local copy: Yosys does not resolve part-selects of package-scoped parameters
+ localparam logic [16*64-1:0] TAB = RECIP_LUT;
logic [31:0] mr; // m16 * seed
logic [17:0] t; // M*r ~ 1.0 as UQ1.16
logic [17:0] e; // 2 - M*r as UQ1.16
@@ -23,7 +26,7 @@ module recip_unit
if (l[i]) k = K_W'(i);
end
m16 = 16'((l << (L_W - 1 - int'(k))) >> (L_W - 16)); // normalize: MSB to bit L_W-1, keep top 16
- seed = RECIP_LUT[m16[14:9]];
+ seed = TAB[m16[14:9]*16 +: 16];
mr = m16 * seed;
t = 18'(mr >> 15);
e = 18'h20000 - t;
diff --git a/rtl/scale_mask.sv b/rtl/scale_mask.sv
index d5aa145..f99e7f7 100644
--- a/rtl/scale_mask.sv
+++ b/rtl/scale_mask.sv
@@ -1,7 +1,8 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// S (int17) -> Z (Q8.8): Z = sat((S * cfg_scale) >>> (SF - 8)) clipped to +-32767; a masked entry becomes 0x8000.
// Truncating (floor) shift, as in fixed.scale_mask.
+import tiny_attn_pkg::*;
module scale_mask
- import tiny_attn_pkg::*;
(
input logic signed [S_W-1:0] s,
input logic [SCALE_W-1:0] scale,
diff --git a/rtl/tiny_attn_top.sv b/rtl/tiny_attn_top.sv
index 5d54174..5437afd 100644
--- a/rtl/tiny_attn_top.sv
+++ b/rtl/tiny_attn_top.sv
@@ -1,10 +1,11 @@
+/* verilator lint_off IMPORTSTAR */ // package import at file scope: Yosys rejects it in the module header
// TinyAttention engine: FLASH-2 or NAIVE schedule over an off-chip (HBM) model, optionally with K/V overlap.
//
// Host protocol: write Q, K, V rows into HBM with the load port (one word per row: lane c in bits [c*8 +: 8]).
// Set the config, pulse start, wait for done, read O back (two words per row, lanes 0-1 then 2-3, int16).
// Load and readback only while idle. Counters are cleared by start and hold their values after done.
+import tiny_attn_pkg::*;
module tiny_attn_top
- import tiny_attn_pkg::*;
(
input logic clk,
input logic rst_n,
diff --git a/sim/golden/gen_luts.py b/sim/golden/gen_luts.py
index 0027938..e0f74a1 100644
--- a/sim/golden/gen_luts.py
+++ b/sim/golden/gen_luts.py
@@ -1,7 +1,7 @@
"""Single source of truth for the exp2 and reciprocal ROMs.
fixed.py imports exp2_lut()/recip_lut() from here; running this file writes the same values to
-rtl/lut_pkg.sv as SystemVerilog localparam arrays (no $readmemh, so it works in Verilator, Yosys and
+rtl/lut_pkg.sv as packed SystemVerilog localparams (no $readmemh, so it works in Verilator, Yosys and
WASM without any file access). test_golden.py fails if the checked-in file drifts.
python sim/golden/gen_luts.py
@@ -29,11 +29,14 @@ def recip_lut() -> list[int]:
def _array(name: str, values: list[int]) -> str:
+ """Packed constant: entry i is bits [16*i +: 16]. Unpacked array localparams do not parse in Yosys."""
+ n = len(values)
+ tokens = [f"16'h{v:04x}" for v in reversed(values)] # the first entry goes in the low bits
rows = []
- for i in range(0, len(values), 8):
- rows.append(" " + ", ".join(f"16'h{v:04x}" for v in values[i:i + 8]))
+ for i in range(0, n, 8):
+ rows.append(" " + ", ".join(tokens[i:i + 8]))
body = ",\n".join(rows)
- return f" localparam logic [15:0] {name} [0:{len(values) - 1}] = '{{\n{body}\n }};\n"
+ return f" localparam logic [{16 * n - 1}:0] {name} = {{\n{body}\n }};\n"
def render_pkg() -> str:
@@ -55,4 +58,4 @@ def write_all() -> None:
if __name__ == "__main__":
write_all()
- print(f"wrote {PKG_PATH} (EXP2_LUT[256], RECIP_LUT[64])")
+ print(f"wrote {PKG_PATH} (EXP2_LUT[256], RECIP_LUT[64], packed)")
diff --git a/sim/run_e2e.sh b/sim/run_e2e.sh
index 053a9af..5342026 100755
--- a/sim/run_e2e.sh
+++ b/sim/run_e2e.sh
@@ -3,6 +3,9 @@
# SEEDS=200 by default (about 200 x 16 runs). Usage: bash sim/run_e2e.sh (after `source scripts/env.sh`)
set -euo pipefail
cd "$(dirname "$0")"
+# The installed Verilator leaves CFG_CXXFLAGS_PCH_I empty, so some generated models fail to link their precompiled header.
+# -include is the GCC flag that rule needs (see sim/run_fpga.sh).
+export MAKEFLAGS="CFG_CXXFLAGS_PCH_I=-include"
R=$PWD/../rtl
SRCS="$R/lut_pkg.sv $R/tiny_attn_pkg.sv $(ls $R/*.sv | grep -v -e lut_pkg.sv -e tiny_attn_pkg.sv | tr '\n' ' ')"
rm -f results.xml
diff --git a/sim/run_fpga.sh b/sim/run_fpga.sh
new file mode 100755
index 0000000..a6817b0
--- /dev/null
+++ b/sim/run_fpga.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Phase 10 simulation: the FPGA top driven over its UART (fpga/ecp5/fpga_top.sv), at 4 clocks per bit to keep it quick.
+# Usage: bash sim/run_fpga.sh (after `source scripts/env.sh`)
+set -euo pipefail
+cd "$(dirname "$0")"
+R=$PWD/../rtl
+F=$PWD/../fpga/ecp5
+SRCS="$R/lut_pkg.sv $R/tiny_attn_pkg.sv $(ls $R/*.sv | grep -v -e lut_pkg.sv -e tiny_attn_pkg.sv | tr '\n' ' ') $F/uart.sv $F/fpga_top.sv"
+rm -f results.xml
+# The installed Verilator leaves CFG_CXXFLAGS_PCH_I empty, so its precompiled-header rule passes a file name to the
+# compiler. -include is the GCC flag the rule needs.
+MAKEFLAGS="CFG_CXXFLAGS_PCH_I=-include" make MODULE=test_fpga_uart TOPLEVEL=fpga_top VERILOG_SOURCES="$SRCS" \
+ WAVES=0 SIM_BUILD=sim_build/fpga EXTRA_ARGS="-GCLKS_PER_BIT=4" > sim_build_fpga.log 2>&1 || true
+python3 - <<'PY'
+import xml.etree.ElementTree as ET
+r = ET.parse("results.xml").getroot()
+bad = [c.get("name") for c in r.iter("testcase") if c.find("failure") is not None or c.find("error") is not None]
+n = len(list(r.iter("testcase")))
+print(f"fpga uart: {n - len(bad)}/{n} passed" + (f" FAILED: {bad}" if bad else ""))
+raise SystemExit(1 if bad else 0)
+PY
+rm -rf sim_build/fpga sim_build_fpga.log
diff --git a/sim/run_unit.sh b/sim/run_unit.sh
index 4e3f53a..8e16dc2 100755
--- a/sim/run_unit.sh
+++ b/sim/run_unit.sh
@@ -3,6 +3,9 @@
# disk is small). Usage: bash sim/run_unit.sh (after `source scripts/env.sh`). End-to-end: sim/run_e2e.sh.
set -euo pipefail
cd "$(dirname "$0")"
+# The installed Verilator leaves CFG_CXXFLAGS_PCH_I empty, so some generated models fail to link their precompiled header.
+# -include is the GCC flag that rule needs (see sim/run_fpga.sh).
+export MAKEFLAGS="CFG_CXXFLAGS_PCH_I=-include"
R=$PWD/../rtl
ARITH="$R/lut_pkg.sv $R/tiny_attn_pkg.sv $R/pe.sv $R/systolic_array.sv $R/mm_engine.sv $R/row_max.sv $R/scale_mask.sv $R/exp2_unit.sv $R/recip_unit.sv $R/online_softmax.sv $R/o_accumulator.sv $R/normalize.sv"
TILE="$ARITH $R/attn_tile.sv"
diff --git a/sim/test_fpga_uart.py b/sim/test_fpga_uart.py
new file mode 100644
index 0000000..da8d790
--- /dev/null
+++ b/sim/test_fpga_uart.py
@@ -0,0 +1,137 @@
+"""The FPGA top (fpga/ecp5/fpga_top.sv) driven over its UART, the way a host would drive the board.
+
+A host model sends commands on uart_rx and reads replies on uart_tx at CLKS_PER_BIT clocks per bit (set with
+EXTRA_ARGS="-GCLKS_PER_BIT=4" in the run script; the board uses 217 for 25 MHz and 115200 baud). Checked:
+ * the O words read back over UART equal the golden model, bit for bit
+ * the DONE reply's cycle, HBM read and write byte counts equal the closed-form models (FLASH: schedule.py, traffic.py)
+"""
+
+import random
+
+import cocotb
+from cocotb.triggers import RisingEdge, Timer
+
+from tbutil import fixed, pack, reset, start_clock, unpack # sets up the golden-model import path
+from schedule import cycles as sched_cycles
+from traffic import traffic
+
+Q_BASE, K_BASE, V_BASE, O_BASE = 0, 32, 64, 96
+CMD_CONFIG, CMD_LOAD, CMD_START, CMD_READ = 0x01, 0x02, 0x03, 0x04
+REP_DONE, REP_WORD = 0x83, 0x84
+
+
+class Host:
+ """UART host: bit-banged at CPB clocks per bit, LSB first, 8N1."""
+
+ def __init__(self, dut, cpb: int):
+ self.dut = dut
+ self.cpb = cpb
+
+ async def _hold(self, level: int, cycles: int):
+ for _ in range(cycles):
+ self.dut.uart_rx.value = level
+ await RisingEdge(self.dut.clk)
+ await Timer(1, "ns")
+
+ async def send(self, data: bytes):
+ for b in data:
+ await self._hold(0, self.cpb) # start
+ for i in range(8):
+ await self._hold((b >> i) & 1, self.cpb)
+ await self._hold(1, self.cpb) # stop
+
+ async def recv(self, n: int, timeout: int = 2_000_000) -> bytes:
+ """n bytes from uart_tx: find each start edge, sample every data bit at its middle."""
+ out = bytearray()
+ for _ in range(n):
+ waited = 0
+ while int(self.dut.uart_tx.value) == 1:
+ await self._advance(1)
+ waited += 1
+ if waited > timeout:
+ raise AssertionError("no reply from the device")
+ # now at the first cycle of the start bit; data bit k is centred at (k + 1.5) bit periods
+ await self._advance(self.cpb + self.cpb // 2)
+ byte = 0
+ for k in range(8):
+ byte |= int(self.dut.uart_tx.value) << k
+ await self._advance(self.cpb)
+ out.append(byte) # we are now in the middle of the stop bit
+ return bytes(out)
+
+ async def _advance(self, cycles: int):
+ for _ in range(cycles):
+ await RisingEdge(self.dut.clk)
+ await Timer(1, "ns")
+
+ async def config(self, nb, gg, causal, naive, overlap, latency, scale):
+ flags = (1 if causal else 0) | (2 if naive else 0) | (4 if overlap else 0)
+ await self.send(bytes([CMD_CONFIG, nb, gg, flags, latency]) + scale.to_bytes(3, "little"))
+
+ async def load(self, addr, word):
+ await self.send(bytes([CMD_LOAD]) + addr.to_bytes(2, "little") + (word & 0xFFFFFFFF).to_bytes(4, "little"))
+
+ async def start(self):
+ await self.send(bytes([CMD_START]))
+ rep = await self.recv(13)
+ assert rep[0] == REP_DONE, f"bad reply code {rep[0]:#x}"
+ vals = [int.from_bytes(rep[1 + 4 * i: 5 + 4 * i], "little") for i in range(3)]
+ return dict(cycles=vals[0], rd=vals[1], wr=vals[2])
+
+ async def read(self, addr):
+ await self.send(bytes([CMD_READ]) + addr.to_bytes(2, "little"))
+ rep = await self.recv(5)
+ assert rep[0] == REP_WORD, f"bad reply code {rep[0]:#x}"
+ return int.from_bytes(rep[1:5], "little")
+
+
+async def run_case(dut, cpb, rng, n, causal, naive, qrows):
+ q = [[rng.gauss(0, 2.5) for _ in range(4)] for _ in range(n)]
+ k = [[rng.gauss(0, 2.5) for _ in range(4)] for _ in range(n)]
+ v = [[rng.gauss(0, 1.0) for _ in range(4)] for _ in range(n)]
+ qz = fixed.quantize(q, k, v)
+
+ host = Host(dut, cpb)
+ await host.config(n // 4, (4 if naive else qrows) // 4, causal, naive, False, 10, qz.cfg_scale)
+ for i in range(n):
+ await host.load(Q_BASE + i, pack(qz.q[i], 8))
+ for i in range(n):
+ await host.load(K_BASE + i, pack(qz.k[i], 8))
+ for i in range(n):
+ await host.load(V_BASE + i, pack(qz.v[i], 8))
+
+ rep = await host.start()
+ qblock = 4 if naive else qrows
+ t = traffic(n, causal, "NAIVE" if naive else "FLASH", qblock=qblock)
+ ctx = f"{'NAIVE' if naive else 'FLASH'} N={n} causal={causal} qrows={qblock}"
+ assert rep["rd"] == t["rd"], f"{ctx}: read bytes {rep['rd']} != {t['rd']}"
+ assert rep["wr"] == t["wr"], f"{ctx}: write bytes {rep['wr']} != {t['wr']}"
+ if not naive:
+ assert rep["cycles"] == sched_cycles(n, causal, qblock, 10), f"{ctx}: cycles {rep['cycles']}"
+
+ got = []
+ for i in range(n):
+ lo = await host.read(O_BASE + 2 * i)
+ hi = await host.read(O_BASE + 2 * i + 1)
+ got.append(unpack(lo, 16, 2) + unpack(hi, 16, 2))
+ want = (fixed.naive(qz, n, causal) if naive else fixed.flash(qz, n, causal, qblock=qblock)).out.tolist()
+ assert got == want, f"{ctx}: O over UART differs from the golden model"
+
+
+@cocotb.test()
+async def uart_host_flow(dut):
+ """Configure, load, run and read back over the UART: FLASH and NAIVE, causal and not."""
+ import os
+
+ cpb = int(os.environ.get("CLKS_PER_BIT", "4"))
+ await start_clock(dut)
+ dut.uart_rx.value = 1
+ await reset(dut)
+ rng = random.Random(71)
+ cases = [
+ dict(n=8, causal=False, naive=False, qrows=4),
+ dict(n=8, causal=True, naive=False, qrows=8),
+ dict(n=8, causal=True, naive=True, qrows=4),
+ ]
+ for c in cases:
+ await run_case(dut, cpb, rng, **c)
diff --git a/web/public/tiny_attn.wasm b/web/public/tiny_attn.wasm
index 6f65f01..6de0941 100755
Binary files a/web/public/tiny_attn.wasm and b/web/public/tiny_attn.wasm differ
diff --git a/web/src/pages/docs/architecture.astro b/web/src/pages/docs/architecture.astro
index 9f8bade..5e863ba 100644
--- a/web/src/pages/docs/architecture.astro
+++ b/web/src/pages/docs/architecture.astro
@@ -85,6 +85,8 @@ import DocsLayout from "@/components/docs/DocsLayout.astro";
regression baselines in sim/perf_baseline.json.
- The design has not been synthesized for an FPGA yet (Phase 10). The numbers on this site come from simulation. + The design synthesizes for an ECP5 FPGA (LFE5U-85F): 40,416 of 83,640 LUT4 positions, 51 of 156 DSP blocks, no block + RAM. The timing estimate is 11.5 MHz, below the 25 MHz board clock, so timing is not closed yet. The numbers on the rest + of this site come from simulation. See ARCHITECTURE section 15.
diff --git a/web/src/pages/docs/verification.astro b/web/src/pages/docs/verification.astro index b3698d6..c22620f 100644 --- a/web/src/pages/docs/verification.astro +++ b/web/src/pages/docs/verification.astro @@ -97,9 +97,15 @@ const seq = MEASURED.study.sequence; +
+ The engine behind a UART synthesizes for an ECP5 (LFE5U-85F) with no latches. The UART protocol is simulated: the
+ results read back over the serial line match the golden model, and the cycle and byte counts match the models
+ (bash sim/run_fpga.sh). Synthesis runs in CI (fpga.yml).
+