Stage C (T2.2) port spec — CUDA/HIP flash kernels + (optional) CpuBackend oracle
Status: ✅ DONE (C1–C4 complete, merged in PR #120, 2026-06-21,
T4-green). Plan A was taken (CpuBackend oracle first). Sibling to
cosim-phase2-cuda-hip.md (Stage C = checkpoint
T2.2) and cosim-phase2-stageB-io-port.md.
The C2/C3/C4 sections below are kept as the as-built record. Performance tuning
of the managed-memory backend is a separate follow-up (issue #122).
Goal: bring CUDA/HIP cosim to flash parity — the last stage of the CUDA/HIP
cosim track. Port gpu_apply_flash_din + gpu_flash_model_step, wire into the
batched run_edges, gate against a golden.
Done (all merged in PR #120; main SHAs)
- C1: lifted
FlashState/FlashDinParams/FlashModelParamsfrommetal.rsto the sharedmod.rsGPU-struct region (gated,size_ofasserts 48/24/32). Metal bit-identical. - C2 (
CpuBackendoracle): wiredCppSpiFlashintoCpuBackend::run_edges(per-edgeapply_flash_din+ dual-stepflash_model_step); patchedspiflash_model.ccp_d_iinit 0→0x0F to match the GPUFlashState.d_i. A 10k-edgemcu_soccosim on the no-GPUCpuBackendis byte-identical to the Metal GPU flash kernel → C++↔shader FSM equivalence proven, CpuBackend is a valid golden source. (Detail: the C2 section below.) - C3 (
a071326): portedflash_eval_commit_persistent+gpu_apply_flash_dingpu_flash_model_steptokernel_v1_impl.cuh+_cuda/_hiplaunchers; wired intoCuda/HipBackend::run_edges(Metal order); flash buffers built innewvia sharedbuild_flash_buffers_dev; stubs replaced with realFlashStatereads.FlashStatebyte offsets derived viastd::mem::offset_of!(ABI drift → compile error). T4-green.
- C4 (
7c54448): flash CI gate — 74 KBCpuBackendgoldentests/mcu_soc/expected/mcu_flash.vcd(== Metal, deterministic), self-containedtests/mcu_soc/sim_config_selfcontained.json,COSIM_SCOPE=flashincosim_cpu_check.sh, wired into Linux + CUDA/HIP T4 jobs. All three flash-gate steps T4-green (byte-identical vs golden).
Scoping findings (the part that changes the plan)
- No pure-CPU flash stepper exists.
CppSpiFlash(C++ FFI SPI/QSPI model,testbench.rs;step(clk,csn,d_o)->d_i) is instantiated inrun_cosim_generic(mod.rs:1965, loads the 16 MiB firmware) but is_-prefixed and never stepped. The--check-with-cpupath injectsbackend.flash_d_i()(the GPU's value) — it does not run a CPU flash model. SoCpuBackendflash is a stub (flash_d_i → 0x0F), and wiring a real CPU flash path is from-scratch work, not "connect the existing model." CppSpiFlashhas no reset API — reset handling (the GPUFlashState.in_reset) has no CPU analog; would need to idle the model (step with csn high) during reset.- C++
CppSpiFlashFSM vs GPUgpu_flash_model_stepshader FSM equivalence is unvalidated. They are meant to be the same SPI FSM, but byte-identical d_i sequences across all commands/edge cases were never proven. A shared CPU/GPU golden depends on this. - Fixture:
mcu_socis the only flash cosim fixture, and it is committed + self-contained —tests/mcu_soc/data/6_final.v(19.6 MB netlist) andtests/mcu_soc/software.bin(3.6 KB firmware) are both git-tracked, so it runs on a fresh checkout without thechipflowfirmware build. But it is heavy (whole SoC; full boot is 500K edges). A short run (a few k edges) exercises the deterministic boot-time flash command/address/read sequence — enough for a regression golden (flash-pin VCD or decoded flash transactions).
The decision this raises
Option (a) — "wire CppSpiFlash into CpuBackend" (chosen before finding #1–3) —
is now known to be a from-scratch CPU flash stepper plus an FSM-equivalence
proof, and it is orthogonal to the release goal: CUDA/HIP parity needs the GPU
flash kernels gated against a golden, and Metal already produces a correct one.
Two viable plans:
- Plan B-first (recommended): GPU kernels gated vs the Metal
mcu_socgolden. Do C3 (port the two flash kernels to CUDA/HIP + wire intorun_edges) and C4 (shortmcu_soccosim in CI; CUDA/HIP/Metal VCDs compared via the existingbackend-equivalenceharness, or vs a committed Metal golden). This is the direct release-critical path. TheCpuBackendflash oracle becomes an optional follow-up (own effort, with the FSM-equivalence validation built in). - Plan A (as originally chosen): CpuBackend flash oracle first. Implement the
CPU flash stepper (C2), prove it byte-identical to Metal on a short
mcu_socrun, commit that as the no-GPU golden, then C3/C4 gate CUDA/HIP against it. Completes the Tier-1-oracle story + Linux no-GPU flash coverage, but is larger and carries the equivalence risk on the critical path.
C2 — CpuBackend flash stepper: the exact dual-step convention (chosen plan a)
This is the make-or-break detail. CppSpiFlash::step(clk, csn, d_o) is a single
eval()+commit() returning p_d_i (CXXRTL agent.step() semantics,
spiflash_model.cc:165). The GPU gpu_flash_model_step is a direct port of the
same spiflash_model.cc, but per call it does a dual-step with delayed CSN
(shader :1008-1018), and is called once per edge (2×/tick):
// step 1: delayed csn + delayed d_out (processes the clock edge, samples old data)
flash_eval_commit(clk, prev_csn, prev_d_out)
// step 2: delayed csn + current d_out
d_i = flash_eval_commit(clk, prev_csn, d_out)
// then store for next edge:
prev_csn = csn // current OUTPUT csn → next edge's delayed csn
prev_d_out = d_out
where clk/csn/d_out are read from the output state slot
(clk_out_pos/csn_out_pos/d_out_pos[4]), and prev_csn/prev_d_out are the
previous edge's output values (the setup-delay model). model_prev_csn (the
model's internal prev_csn_o edge-detect state) is threaded through both evals;
because step 2 re-feeds the same prev_csn, no spurious CSN edge is seen between
the two evals — CppSpiFlash's own commit() (prev_csn_o = csn) reproduces this
automatically when called as above. Reset branch (shader :974-980): force
d_i = 0x0F, set prev_csn=csn, prev_d_out=d_out, do not step the model.
CpuBackend wiring (mod.rs): add fields flash: UnsafeCell<CppSpiFlash>,
flash_clk/csn_out_pos, flash_d_out_pos[4], flash_d_in_pos[4], flash_xmask_off,
flash_d_i, flash_prev_csn, flash_prev_d_out, flash_in_reset. In new, build
CppSpiFlash from config.flash (load firmware) + resolve positions (the
orchestration already computes these as locals at mod.rs:1812-1860 — mirror it).
In run_edges, per edge: apply_flash_din (inject self.flash_d_i into input
MISO d_in_pos, clear X-mask) before simulate_block_v1; flash_model_step
(the dual-step above) after. flash_d_i() returns self.flash_d_i (drop the
0x0F stub); flash_set_in_reset stores the flag.
Init-state equivalence hazard (confirmed). The GPU inits FlashState
(build_flash_buffers): data_width=1, prev_csn=1, model_prev_csn=1, d_i=0x0F, in_reset=1, rest 0. The C++ SpiFlashModel constructs State{data_width=1,…0}
(matches) but p_d_i = 0 (spiflash_model.cc:27) vs the GPU's d_i = 0x0F.
d_i/p_d_i is a persistent output (only written on negedge_clk during a read
command's data phase), so during command/address phases CppSpiFlash would carry
MISO=0 while the GPU carries 0x0F. The design doesn't sample MISO outside the
read-data phase (functionally harmless), but a raw flash-pin VCD diff would flag
it. Fix: change spiflash_model.cc:27 to uint8_t p_d_i = 0x0F; to match the
GPU init — safe, CppSpiFlash has no live callers (verify with a repo-wide grep
first). The reset branch already forces flash_d_i = 0x0F without stepping, so
the pre-reset-release values align once this init is fixed. Also force the CPU
reset behaviour to mirror the shader (d_i=0x0F, set prev_csn/prev_d_out, do not
step) so CppSpiFlash's internal prev_csn_o stays high through reset (csn idle
high in mcu_soc).
Validation (C2 — DONE, byte-identical): reproducible recipe (the /tmp config
is ephemeral — regenerate it):
python3 -c "import json,pathlib; c=json.loads(pathlib.Path('tests/mcu_soc/sim_config.json').read_text()); c['flash']['firmware']='tests/mcu_soc/software.bin'; pathlib.Path('/tmp/mcu_sc.json').write_text(json.dumps(c,indent=2))"
cargo build -r --bin jacquard # no-feature → CpuBackend
cargo build -r --features metal --bin jacquard
for b in cpu metal; do f=$([ $b = metal ] && echo '--features metal'); \
./target/release/jacquard cosim tests/mcu_soc/data/6_final.v --config /tmp/mcu_sc.json \
--top-module top --max-clock-edges 10000 --output-vcd /tmp/mcu_$b.vcd; done
diff /tmp/mcu_cpu.vcd /tmp/mcu_metal.vcd # byte-identical ⇒ FSMs match
~40s partitioning + ~4s sim per run. Flash is actively read by ~10k edges
(cmd 0x03 @ 0x100000). This same comparison is the C3 gate for CUDA/HIP (vs the
committed CpuBackend golden) and the C4 CI gate. (If a future port diverges, the
C++↔shader bug is in flash_eval_commit_persistent vs eval()/commit().)
C3 — CUDA/HIP flash kernels (mirror Stage B mechanics)
- Port
gpu_apply_flash_din(shader:904 — writeFlashState.d_i→ input-state MISO bits atd_in_pos, clear their X-mask) andgpu_flash_model_step(shader:943 — read clk/csn/d_out from output state, dual-step the SPI/QSPI FSM, updateFlashState) tokernel_v1_impl.cuh+extern "C"_cuda/_hiplaunchers. Also port theflash_eval_commit_persistenthelper (shader:843) — the per-eval primitivegpu_flash_model_stepcalls twice (it is the shader port ofspiflash_model.cceval()+commit()); it must live in the.cuhtoo. Needs the 16 MiB firmwareUVec<u8>(load fromconfig.flash.firmwarelikeCpuBackend::new/build_flash_buffers'sflash_data_buffer) + persistentFlashState(UVec<u8>, the event-buffer FFI pattern; init perbuild_flash_buffers:data_width=1, prev_csn=1, model_prev_csn=1, d_i=0x0F, in_reset=1). Structs already shared (C1). The launchers cross the struct/firmware buffers asu8*and cast (Stage B convention). - Wire into
CudaBackend/HipBackend::run_edgesin the Metal order:state_prep→gpu_apply_flash_din→simulate_stage × N→gpu_flash_model_step→gpu_io_step→ snapshot. Build the flash buffers innew(mirrorMetalBackend::build_flash_buffers); replace the Stage-Bflash_d_i/flash_debug_snapshotstubs with GPUFlashStatereads;flash_set_in_resetdrivesFlashState.in_reset. Model-driven-clock (JTAG) runs the peripherals atbatch=1within the same backend.
C4 — CI gate
Add a short mcu_soc (and/or jtag_minimal) flash cosim to the GPU CI jobs;
compare CUDA/HIP against the golden (Metal, or CpuBackend under Plan A). Note the
runtime cost of the 19.6 MB netlist on the runners; keep edge counts small.
Risks
- FSM equivalence (C++
CppSpiFlash↔ shadergpu_flash_model_step) — only matters under Plan A (shared CPU golden); Plan B sidesteps it (Metal is the GPU reference). - CI-only GPU validation on the T4, as in Stage B.
- Fixture weight —
mcu_socis large; a short run must still be deterministic and exercise real flash transactions.