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
13 KiB
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, oneEXP-NNNNentry 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, thenexport 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) orNeuralProcessor.srcs/constrs_1/imports/...(XDC) was ever edited on disk after being added to the project, diff it against the livehardware/v3/...source before trusting any P&R result —add_files/update_compile_orderdo 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 (checkIS_GLOBAL_INCLUDE/the file's own path isn't underimports/) rather than get copied — for BOTH thesources_1andconstrs_1filesets. 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]andconstrs_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_orderalone did NOT makesynth_design -top <newmodule>find it (EXP-0091) — failed with "module '' not found" even though the file was correctly present,IS_ENABLED, andUSED_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: explicitlyset_property top <newmodule> [get_filesets sources_1]before callingsynth_design -top ...— the-topcommand-line flag alone wasn't sufficient this time. - A top-level module's own default parameter value can silently NOT
apply, even with no
-genericoverride on thesynth_designcommand line, an empty realGENERICproperty on the run, and no stale imported RTL copy (EXP-0097) — a real elaboration boundN_GROUPSto a value from a DIFFERENT, EARLIER-genericoverride used against the SAME top module in the SAME Vivado session/project (an N=8 sweep run before an N=16 run), despite an interveningreset_run. Most likely Vivado's own "Incremental synthesis strategy default" silently carrying forward a parameter binding. Real fix: always pass every-genericvalue EXPLICITLY on every realsynth_designcall 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 separatealways/initialblock. Blocking assignment races the DUT's ownposedge-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 ownjob_startpulse) — 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 naturalwhile(!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 onposedge, 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]whereidxis 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 (asact_tile_fetch.vdoes, 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
activesignal 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
reqsignal (not just itsactive/grant), thereqpulse must be latched STICKY across the pipeline boundary, not just registered every cycle (EXP-0094,sdram_arbiter_hier.v). A real one-shotctrl_reqpulse (e.g.act_tile_fetch.v's own S_MEMWAIT:ctrl_req <= 1'b1for 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 doestop_req_r <= leaf_reqevery 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 holdsactivelevel, and stable addr/data, for its whole transaction) — only the transientreqpulse does. - A testbench helper that fires a one-shot
reqthe same cycle asactive, unconditionally (without checkinggrantfirst), 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.vetc) already wait forgrantbefore firingreq(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/iverilogneed-sv/-g2012respectively to accept SystemVerilog-only syntax (e.g.'0) even in a plain.vfile — prefer just not using SV-only syntax in synthesizable RTL (Vivado'ssynth_designhas 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_designrun 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.vand 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.