Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/workflows/fpga.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ web/public/spike/
.tools/*
!.tools/setup.sh
!.tools/emsdk.sh
!.tools/fpga.sh

# OS / editor cruft
.DS_Store
Expand All @@ -45,3 +46,4 @@ web/public/spike/
# Playwright output
web/test-results/
web/playwright-report/
fpga/ecp5/build/
10 changes: 10 additions & 0 deletions .tools/fpga.sh
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

2 changes: 1 addition & 1 deletion docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
208 changes: 208 additions & 0 deletions fpga/ecp5/fpga_top.sv
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading