Files
FPGA-Neural/CLAUDE.md
micheleandClaude Sonnet 5 f9b366d747 feat: N=16 real timing CLOSED via extra MAC pipeline stage (EXP-0097, branch n16-timing-closure)
neural_processor_packed.v: split the original single "Stage 1" (packed
DSP48E1 multiply + INT8 unpack + register) into two real stages --
Stage 1a registers the raw DSP48E1 product with zero logic in between,
Stage 1b does the carry-heavy unpack (the real critical path EXP-0094
traced) from that already-registered value. Adds exactly one real
clock cycle of latency; throughput unaffected (real valid/ready
handshaking throughout, no fixed-latency assumption downstream).

Real verification: isolated bit-exact vs 2x real neural_processor.v
(18/18 PASS, testbench fixed to latch each core's result independently
since result_valid is a one-shot pulse and the DUT is now one cycle
deeper -- not an RTL bug). Full-system functional xsim on real DDR3:
32/32 PASS. Real, full P&R: WNS=+0.269ns, WHS=+0.026ns, 0 failing
setup or hold endpoints -- N=16 TIMING CLOSES.

Also root-caused (not an RTL bug, folded into CLAUDE.md): a real
Vivado incremental-synthesis quirk silently carried forward a
N_GROUPS=2 parameter binding from an earlier sweep run despite no
-generic override and an intervening reset_run -- fixed by always
passing -generic explicitly and confirming the real elaborated value
via a post-synth DSP48E1 count.

Isolated on this branch -- does not touch the physical board already
in fabrication on v3-artix7 (N=8, unmodified).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUG92aM9m68TRc4rG55BcC
2026-09-22 00:17:18 +02:00

205 lines
13 KiB
Markdown

# FPGA-Neural — project instructions for Claude Code
Hardware neural accelerator for a custom PCB: bare **Xilinx XC7A100T-CSG324-2**
(Artix-7) chip + real DDR3, designed and assembled by the user themselves —
never a Digilent/dev-board purchase. An ESP32 is the host/central processor,
talking to the FPGA over a dedicated SPI bus (FPGA is slave there) and, via
the FPGA, through to a separate config flash used only for FPGA bootstrapping
(FPGA is master on that second, physically distinct SPI bus).
Active branch: **`v3-artix7`**. `hardware/v3/` is the current, real target.
`hardware/v2/` is the archived ECP5 baseline (frozen, DSP-count-limited,
superseded — do not build on it, some of its RTL is still *reused*
unmodified by v3, e.g. `layer_prefetch_ctrl.v`/`layer_weight_buffer.v`).
`hardware/v1/` is older still, reference only.
## Read first
- `docs/PHYSICAL_REALIZATION.md` — every real pin assignment, part number,
timing number, and memory-layout convention needed for the physical board
and for host (ESP32) firmware. Keep it in sync with reality — if a pin
assignment or timing number changes, update this file in the same commit.
- `docs/ARCHITECTURE_ANALYSIS.md` — real, measured bottleneck analysis
(DDR3 bandwidth is the real ceiling, not DSP count — see it before
proposing to scale core count) and ranked recommended interventions.
Update it whenever a recommendation from it gets built or a new real
bottleneck is found.
- `hardware/v2/logs/experiments.log` — the real project history, one
`EXP-NNNN` entry per real experiment/change (context/method/result/
decision/next_action). Read the tail before starting new work; append a
new entry for anything non-trivial you do. This log — not memory, not
chat history — is the authoritative record of what's been tried and why.
## Toolchains (real paths, already working — don't re-diagnose from scratch)
- **Vivado 2026.1**: `source /home/michele/tools_cache/Xilinx/2026.1/Vivado/settings64.sh`,
then `export LD_LIBRARY_PATH="/home/michele/tools_cache/Xilinx/2026.1/Vivado/lib/lnx64.o/Ubuntu/24:$LD_LIBRARY_PATH"`
(this machine's Ubuntu is too new for Vivado's own OS detection; the
LD_LIBRARY_PATH points at Vivado's own bundled compat libs — not a real
distro package, must be set every session).
- **OSS CAD Suite** (iverilog/vvp for fast plain-Verilog sims, no Xilinx
primitives): `source /home/michele/tools_cache/oss-cad-suite/environment`.
- Real Vivado project: `Vivado/NeuralProcessor/NeuralProcessor.xpr` — the
MIG DDR3 IP lives there, real, already generated for the exact part.
## Hard-won lessons (do not re-derive these the slow way)
- **Vivado's imported source copies go stale silently — RTL *and*
constraint (.xdc) files alike.** If a project file under
`NeuralProcessor.srcs/sources_1/imports/...` (RTL) or
`NeuralProcessor.srcs/constrs_1/imports/...` (XDC) was ever edited on
disk *after* being added to the project, diff it against the live
`hardware/v3/...` source before trusting any P&R result — `add_files`/
`update_compile_order` do NOT auto-refresh it, and a stale copy produces
no error, just silently wrong (old) synthesis/implementation results
(EXP-0078 for RTL; EXP-0084 for the XDC — the stale constraints file was
old enough to still have PRE-EXP-0077 pin assignments, predating the
flash bridge entirely, and its own real bug fixes silently didn't take
effect across multiple P&R re-runs until the staleness itself was
diagnosed and fixed). Prefer adding new files so they stay a direct
reference (check `IS_GLOBAL_INCLUDE`/the file's own path isn't under
`imports/`) rather than get copied — for BOTH the `sources_1` and
`constrs_1` filesets. **A real MIG IP regeneration (re-running the
wizard, even just to change one field like Clock Period) can re-trigger
a WHOLESALE project source rescan that silently re-imports the ENTIRE
RTL tree plus constraints back to stale copies in one shot — not just
the file(s) the regeneration itself touched** (EXP-0086: a second
wizard run, needed only to revert Clock Period, silently reverted 9
already-fixed RTL files plus the top XDC all at once). After ANY IP
regeneration, check every fileset (`get_files -of_objects
[get_filesets sources_1]` and `constrs_1`) for `*/imports/*` paths
before trusting the next P&R — don't assume only the previously-fixed
files are still direct references.
- **Adding a brand-new TOP-LEVEL module** (not just a sub-module) to the
project: `add_files` + `update_compile_order` alone did NOT make
`synth_design -top <newmodule>` find it (EXP-0091) — failed with
"module '<newmodule>' not found" even though the file was correctly
present, `IS_ENABLED`, and `USED_IN: synthesis`. Before assuming an RTL
bug, verify the RTL independently first (a clean Icarus elaboration
with small stub modules for any real Xilinx primitives it can't resolve,
e.g. `mig_7series_0`/`STARTUPE2`, is enough to rule that out cheaply).
Real fix: explicitly `set_property top <newmodule> [get_filesets
sources_1]` *before* calling `synth_design -top ...` — the `-top`
command-line flag alone wasn't sufficient this time.
- **A top-level module's own default parameter value can silently NOT
apply**, even with no `-generic` override on the `synth_design`
command line, an empty real `GENERIC` property on the run, and no
stale imported RTL copy (EXP-0097) — a real elaboration bound
`N_GROUPS` to a value from a DIFFERENT, EARLIER `-generic` override
used against the SAME top module in the SAME Vivado session/project
(an N=8 sweep run before an N=16 run), despite an intervening
`reset_run`. Most likely Vivado's own "Incremental synthesis
strategy default" silently carrying forward a parameter binding.
Real fix: always pass every `-generic` value EXPLICITLY on every
real `synth_design` call for a parameterized top-level, never rely
on "no override = the RTL's own default" once that module has EVER
been synthesized with a different override earlier in the same
project — and confirm the real elaborated value afterward (e.g. a
post-synth DSP48E1/cell count check) before trusting anything
downstream, don't assume the log's own "Parameter ... bound to"
line will be checked in time otherwise.
- **Testbench stimulus must use nonblocking assignment (`<=`), not blocking
(`=`), when driving a DUT's inputs from a separate `always`/`initial`
block.** Blocking assignment races the DUT's own `posedge`-triggered
always block under Icarus and can silently corrupt data OR miss a one-shot
pulse entirely (causing a real hang) — hit and fixed repeatedly (EXP-0073,
0075, 0077) before this became standing practice. If a new Icarus
testbench shows shuffled/duplicated fields or an inexplicable hang,
suspect this class of bug before assuming the RTL is wrong.
**Same race family, a DIFFERENT real trigger (EXP-0090)**: driving
stimulus on `@(posedge clk)` — even with the SAME `=`/handshake shape
already proven safe elsewhere in this project (e.g. `tb_packed_slot.v`'s
own `job_start` pulse) — still races the DUT's own posedge-triggered
sampling when a task issuing that pulse is called BACK-TO-BACK with ZERO
real simulated gap (no natural `while(!done)`-style polling delay between
calls, e.g. a tight submission loop). Confirmed via real signal tracing:
every logical push registered as TWO real, identical DUT-side writes.
Every prior working example of this pulse pattern happened to always have
a real gap between calls, so the race was never exercised until a tight
back-to-back loop (`neural_director_grouped.v`'s own test) hit it. Fix:
drive stimulus changes on `@(negedge clk)` instead — the DUT still
samples on `posedge`, so a negedge-driven change can never race it,
regardless of how tightly consecutive pulses are issued.
- **Never use a runtime-indexed part-select** (`data[idx*W +: W]` where `idx`
is a signal, not a constant) on a wide bus in anything synthesizable — a
known real Fmax killer (`weight_tile_gather.v`'s own header, EXP-0061).
Use fixed shift-concat, or (as `act_tile_fetch.v` does, EXP-0079) design
the memory layout so only a fixed slice is ever needed. The current real
P&R timing margin is thin (WNS +0.013ns, EXP-0078) — there is no slack to
absorb a new critical path.
- **A one-shot-pulse requester on a shared/arbitrated bus must see its own
grant the SAME cycle its own `active` signal first asserts** — a
registered/one-cycle-late grant silently loses the request forever
(EXP-0066's own real bug, now a standing design rule for every arbiter/
requester pair in this project).
- **When PIPELINING/hierarchically staging an arbiter's own `req`
signal (not just its `active`/`grant`), the `req` pulse must be
latched STICKY across the pipeline boundary, not just registered
every cycle** (EXP-0094, `sdram_arbiter_hier.v`). A real one-shot
`ctrl_req` pulse (e.g. `act_tile_fetch.v`'s own S_MEMWAIT: `ctrl_req
<= 1'b1` for exactly one cycle) is captured fine by a SINGLE-level
arbiter (the winning requester's own grant and the physical
controller's readiness to capture it are the SAME decision, always
same-cycle). Once a SECOND arbitration level is added downstream
(e.g. a top-level arbiter deciding which of several leaf groups gets
the real shared port), a leaf's own LOCAL grant no longer guarantees
the top level is free to act on it that same cycle — if the pipeline
register between levels just does `top_req_r <= leaf_req` every
cycle, the transient one-shot pulse reverts to 0 before the top level
gets around to it (e.g. busy with a different leaf), silently losing
the request — same EXP-0066 lost-pulse class, newly exposed at the
hierarchy boundary. Fix: `pending_r <= (pending_r | leaf_req) &
~dispatched;` (set on first pulse, clear only once the top level
confirms real dispatch) feeds the pipeline register instead of the
bare transient signal. `active`/addr/data fields don't need this (a
real requester holds `active` level, and stable addr/data, for its
whole transaction) — only the transient `req` pulse does.
- **A testbench helper that fires a one-shot `req` the same cycle as
`active`, unconditionally (without checking `grant` first), is only
safe for a flat, single-level, uncontended arbiter test** — copying
it verbatim into a test for a hierarchical/pipelined arbiter (EXP-0094)
can itself cause a spurious hang, unrelated to any real RTL bug. Real
requesters in this project (`act_tile_fetch.v` etc) already wait for
`grant` before firing `req` (S_MEMWAIT) — match that in any new
testbench helper, don't assume the naive same-cycle-fire pattern
generalizes.
- **Give every new testbench a real cycle-counted watchdog from the
start** (EXP-0094) — one testbench, copied from a simpler precedent
that didn't need one, had none; a real protocol bug spun Icarus
forever at ~99% CPU with zero output instead of failing cleanly.
Every other testbench in this project already has a `wd`-counted
watchdog inside its own completion-wait loop — don't skip it "just
this once."
- **`xvlog`/`iverilog` need `-sv`/`-g2012`** respectively to accept
SystemVerilog-only syntax (e.g. `'0`) even in a plain `.v` file — prefer
just not using SV-only syntax in synthesizable RTL (Vivado's `synth_design`
has no such escape hatch at all).
- **Verify real component availability (LCSC) before committing to a part**
— the user has asked for this explicitly more than once. Don't guess
availability or specs from training data; search when it matters.
- **Real, measured numbers only — never estimate/guess a timing or
performance figure and present it as fact.** Out-of-context synthesis is
not a real signoff; only a real in-context `place_design`/`route_design`
run on the actual top-level module counts. If a number is a projection
(not measured), say so explicitly and show the real numbers it's built
from.
## Working discipline
- Fork before promote: don't edit an already-verified, in-use RTL file in
place for a new experiment — copy/fork it, verify the fork, then decide
whether to promote it. (Established V2-era convention, still followed in
V3.)
- One variable at a time: verify a new module in isolation before wiring it
into a larger system; verify the larger system before trusting a P&R
number built on top of it.
- Root-cause every anomaly via hierarchical signal tracing — never guess or
paper over an unexplained result. Several real bugs in this project were
found exactly this way, not by inspection.
- After ANY RTL change to logic that's part of the real synthesis target
(`hardware/v3/rtl/n2_system_ddr3_top.v` and its dependents), re-run a real
P&R before claiming it's still timing-clean — the margin is thin enough
that this is not optional caution, it's load-bearing.
- Commit messages end with the attribution lines already configured for this
session (Co-Authored-By + Claude-Session) — keep using them.