Files
FPGA-Neural/tools/pinout/gen_lpf.py
T
micheleandClaude Sonnet 5 55c827bedf feat: PSRAM page-mode reads + graph engine (Type #2) + real pinout/IRQ pins
PSRAM page-mode read burst support in psram_controller.v: enables the
ISSI IS66WVE4M16EBLL-70BLI's page mode via its configuration-register
software-access sequence at boot (disabled by default on the real
chip), then keeps CE#/OE# asserted after a read so a same-page
continuation only pays tAPA (20ns) instead of a full tAA (70ns)
random access, with automatic tCEM-safe session closing. Only a WRITE
closes the page -- byte-enable changes do not, since
int8_memory_access.v alternates them on nearly every access and an
early implementation attempt that treated them as a close condition
measured a real regression (53.25->61.25 cycles/edge) before being
corrected (53.25->37.53 cycles/edge, +42% gather bandwidth).
sim/psram_model.v gained independent tAPA/tAA and tCEM enforcement
(with a real Verilog same-timestep event-ordering race found and
fixed via a #0 sync) so the regression proves real timing compliance,
not just data correctness. New sim/psram_page_mode_tb.v; full 26-file
regression suite re-run clean. Real nextpnr-ecp5 Fmax re-measured on
the full spi_neuron_top system: 75.73MHz (P2, up from 55.59MHz) and
65.13MHz (P8) -- still under the 80MHz target but not regressed, with
the critical path confirmed (not assumed) to remain entirely inside
neuron_parallel's accumulate chain, never psram_controller.

Also includes this session's other already-validated work: the graph
engine (Type #2 sparse-graph network: act_buffer, graph_engine,
netasm host assembler), real CABGA381 pinout (.lpf, place&route
verified) and physical IRQ_N/DATA_READY_N pins, and Phase 7 timing
closure logs -- all previously uncommitted, documented in WORKLOG.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LH3jPeJ3eFMfF2v8SQhpkk
2026-09-03 17:12:05 +02:00

103 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""
Generates synth/ecp5/spi_neuron_top.lpf (LOCATE/IOBUF constraints) for
spi_neuron_top's SPI + PSRAM ports on the real LFE5U-45F-8BG381C part,
using Project Trellis's own device database as the source of ball/bank/
dual-function data (the same data nextpnr-ecp5 itself uses) -- not
invented numbers.
Requires prjtrellis installed (Homebrew: `brew install prjtrellis`) and
its iodb.json for LFE5U-45F. See docs/FPGA-Neural-Hardware-Design.md §7
for the full placement rationale (bank/die-edge geometry, why banks 2+3
hold the PSRAM bus and bank 7 holds SPI/clock/reset).
Re-run this whenever the port list of rtl/spi_neuron_top.v's top-level
SPI/PSRAM interface changes (ADDR_WIDTH, MEM_DATA_WIDTH, etc.) -- it does
NOT try to read the RTL; the port list/widths are hardcoded below and
must be kept in sync by hand.
"""
import json
import re
import glob
import sys
CLK_BALL = "H5" # GR_PCLK7_0, bank 7 -- dedicated global clock pad
ADDR_BITS = 23 # ADDR_WIDTH (byte address); only [21:0] carry real address, see §3
DATA_BITS = 16 # MEM_DATA_WIDTH
def find_iodb():
candidates = glob.glob(
"/opt/homebrew/Cellar/prjtrellis/*/share/trellis/database/ECP5/LFE5U-45F/iodb.json"
) + glob.glob(
"/usr/share/trellis/database/ECP5/LFE5U-45F/iodb.json"
)
if not candidates:
sys.exit("prjtrellis iodb.json not found -- install prjtrellis (brew install prjtrellis)")
return candidates[0]
def load_balls(iodb_path, package="CABGA381"):
d = json.load(open(iodb_path))
pkg = d["packages"][package]
meta_idx = {}
for m in d["pio_metadata"]:
meta_idx.setdefault((m["col"], m["row"], m["pio"]), []).append(m)
out = {}
for ball, info in pkg.items():
key = (info["col"], info["row"], info["pio"])
metas = meta_idx.get(key, [])
bank = metas[0]["bank"] if metas else None
funcs = sorted(set(m.get("function", "") for m in metas if m.get("function")))
out[ball] = dict(col=info["col"], row=info["row"], bank=bank, funcs=funcs)
return out
def bank_balls(rows, bank, exclude=()):
items = [(b, v) for b, v in rows.items() if v["bank"] == bank and b not in exclude]
plain = sorted((x for x in items if not x[1]["funcs"]), key=lambda x: (x[1]["row"], x[1]["col"], x[0]))
special = sorted((x for x in items if x[1]["funcs"]), key=lambda x: (x[1]["row"], x[1]["col"], x[0]))
return plain + special
def assign(rows):
psram_pool = bank_balls(rows, 2) + bank_balls(rows, 3)
ctrl_pool = bank_balls(rows, 7, exclude={CLK_BALL})
a = {}
for i, (ball, _) in enumerate(psram_pool[0:ADDR_BITS - 1]):
a[f"psram_a[{i}]"] = ball
for i, (ball, _) in enumerate(psram_pool[ADDR_BITS - 1:ADDR_BITS - 1 + DATA_BITS]):
a[f"psram_dq[{i}]"] = ball
ctrl_names = ["psram_ce_n", "psram_oe_n", "psram_we_n", "psram_lb_n", "psram_ub_n", "psram_zz_n"]
for name, (ball, _) in zip(ctrl_names, psram_pool[ADDR_BITS - 1 + DATA_BITS:ADDR_BITS - 1 + DATA_BITS + 6]):
a[name] = ball
a[f"psram_a[{ADDR_BITS - 1}]"] = psram_pool[ADDR_BITS - 1 + DATA_BITS + 6][0] # always-0 spare bit
# Application SPI + reset + host attention pins (irq_n/data_ready_n,
# added 2026-09-03), all in bank 7 alongside clk -- kept away from
# the PSRAM bus (banks 2+3) per the same "opposite edges" rationale
# as the SPI signals.
spi_names = ["sclk", "mosi", "miso", "cs_n", "rst", "irq_n", "data_ready_n"]
for name, (ball, _) in zip(spi_names, ctrl_pool[0:7]):
a[name] = ball
a["clk"] = CLK_BALL
return a
def write_lpf(assignment, path):
lines = ["BLOCK ASYNCPATHS;", "BLOCK RESETPATHS;", ""]
for sig, ball in sorted(assignment.items()):
lines.append(f'LOCATE COMP "{sig}" SITE "{ball}";')
lines.append(f'IOBUF PORT "{sig}" IO_TYPE=LVCMOS33;')
lines.append("")
open(path, "w").write("\n".join(lines))
if __name__ == "__main__":
out_path = sys.argv[1] if len(sys.argv) > 1 else "synth/ecp5/spi_neuron_top.lpf"
rows = load_balls(find_iodb())
assignment = assign(rows)
write_lpf(assignment, out_path)
print(f"wrote {len(assignment)} signal constraints to {out_path}")