Cosim Backend Portability
Status: Active — design captured, not yet scheduled. Issue: #105. Architecture: ADR 0017 — Cosim execution model (see Amendment 2026-06-07: backend-portable cosim — target architecture).
cosim is Metal-only today: run_cosim lives in src/sim/cosim_metal.rs
(gated #[cfg(feature = "metal")]) and cmd_cosim hard-errors on other
backends. This plan is the staging to reach the target architecture in
ADR 0017: a backend-agnostic orchestration layer, a batch-granular
CosimBackend trait, and a 3-tier GpuPeripheral model — then CPU and
CUDA/HIP backends.
Goal & non-goals
Goal: jacquard cosim runs on CPU (reference, no GPU) and on CUDA/HIP
with batching, not just Metal — reusing the existing scheduler,
peripheral models, and VCD machinery.
Non-goals:
- Changing the batch/scheduler execution model of ADR 0017 (untouched; the trait is made batch-granular to preserve it, not change it).
- Matching Metal cosim throughput on the CPU path (it's a reference/oracle).
- The user-extensible single-source peripheral API (Tier 3) up front — core peripherals get hand-written GPU kernels first (Phase 2); the portable authoring path is Phase 3.
What's already portable (no work needed)
- Peripheral protocol models (Tier 1) —
src/sim/models/*.rs(gpio, uart, i2c, spi, jtag, bus_trace) are pure CPU Rust implementingPeripheralModel(models/mod.rs:56). The semantic ground truth and the cross-backend equivalence oracle; reusable as-is across all backends. - CPU design step —
cpu_reference::simulate_block_v1is the exact CPU equivalent of onesimulate_v1_stagethreadgroup. The existingrun_cosim--check-with-cpupath (cosim_metal.rs:4282–4504) already runsstate_prep+apply_flash_din+simulate_block_v1per block on the CPU — a working prototype of the Path A backend step. - The scheduler / batch-size policy / VCD / event drain logic is
GPU-agnostic in intent; it operates on
&[u32]state andVec<BitOp>ops.
The seam (batch-granular)
#![allow(unused)] fn main() { /// Design execution + state ownership. One impl per backend. The /// orchestration layer (scheduler, models, VCD, drains) calls this. /// /// Batch-granular by construction: measurements (ADR 0017) show Metal runs /// 100% batched on GPU-peripheral designs, so a single-edge method would /// regress it ~1000×. The orchestration decides N (`force_single_edge`); /// the backend runs N edges however it likes. trait CosimBackend { /// Build native schedule storage ONCE from the backend-agnostic /// description; the backend retains+owns it (opaque to orchestration). fn init_schedule(&mut self, edges: Vec<(StatePrepParams, Vec<BitOp>)>); /// Mutable view of one edge's ops (reset/model/clock-edge patching). /// Metal: slice over the shared MTLBuffer (zero-copy; write IS upload). /// CUDA/HIP: slice over host mirror, marks edge dirty (uploaded lazily). fn edge_ops_mut(&mut self, edge_idx: usize) -> &mut [BitOp]; /// output→input copy + apply that edge's BitOps + clear driven X-mask. fn state_prep(&mut self, edge_idx: usize, xmask_state_offset: u32); /// Run N consecutive scheduler edges from `start_edge`, snapshotting each /// output slot into the ring. Flushes dirty edges first (no-op on Metal). /// Metal: one command buffer w/ GPU peripherals inside. CPU/CUDA-fallback: /// per-edge loop with CPU peripherals. fn run_edges(&mut self, start_edge: usize, n: usize, ring: &mut Ring); /// Read-only view of the current output slot (VCD, model step_edge, drains). fn output_state(&self) -> &[u32]; /// Mutable input slot (reset/constant init, flash MISO injection). fn input_state_mut(&mut self) -> &mut [u32]; } /// GPU-side peripheral kernel (Tier 2). Runs inside the backend's batch so /// reactive designs can batch on a discrete GPU. CPU `PeripheralModel` /// (Tier 1) is the reference + fallback when no GpuPeripheral exists. trait GpuPeripheral { fn encode_step(&self, /* backend-specific encoder */); } }
MetalSimulator becomes the MetalBackend impl; CpuBackend and
Cuda/HipBackend are added. The backend owns the schedule storage —
the orchestration hands it the description once via init_schedule and keeps
only scalars (edges_per_period, gcd_ps); it does not hold a parallel
Vec the backend re-materialises (that would add a per-dispatch copy and
regress Metal's zero-copy path). Mutation goes through edge_ops_mut
(zero-copy on Metal; host-mirror + dirty-flag + lazy upload on CUDA/HIP). The
orchestration owns the batch-size policy and the CPU peripheral models; it
delegates the design step, state, and (Phase 2+) GPU peripheral steps to the
backend.
Why batching must be a backend concern
cosim is per-edge dispatch in the reactive sense (inputs depend on
outputs), which is why CUDA/HIP avoid the sim command's cooperative
single-launch + grid.sync — the hardest CUDA feature to port. But
reactive and per-command-buffer are different axes: Metal batches up to
BATCH_SIZE reactive edges into one command buffer because its peripherals
run on the GPU inside the batch. On a discrete GPU, going per-command-buffer
means a PCIe round-trip every edge, which is why batching (hence GPU
peripherals) is required for CUDA/HIP perf, not optional — so the CUDA/HIP
backend ships with its GPU peripherals (Phase 2), not as a later add-on.
See ADR 0017, Layer 3 and Consequences.
Phases
Phase 0 — Extract the seam (Metal-only refactor, zero behaviour change)
Introduce the batch-granular CosimBackend trait and make the schedule
backend-neutral; MetalSimulator becomes MetalBackend. The physical
module split (cosim/mod.rs orchestration + cosim/metal.rs) is separable
— the trait can land in-place in cosim_metal.rs first, split later. Must
leave Metal cosim bit-identical.
- Move the schedule storage into
MetalBackend(built once viainit_schedule); the orchestration keeps onlyedges_per_period+gcd_ps. CurrentlyScheduleBuffersholdsmetal::Bufferpairs at the call site. - Convert the ops-update helpers (
update_model_driven_in_ops,update_reset_in_ops,patch_model_clock_edges) from in-place*mut BitOpwrites overcontents()tobackend.edge_ops_mut(edge_idx). On Metal this returns a slice over the same shared buffer (zero-copy, identical behaviour). This conversion is the main refactor friction (and resolves the closure-borrow issue) — but it stays zero-copy on Metal. Consolidate the privateDone — #115.simulate_block_v1copies ontocpu_reference.- Move the ~20 ad-hoc
simulator.device.new_buffer(...)allocations inrun_cosimintoMetalBackend.
Entry: #113 (sim cross-backend equivalence) merged. ✅
Exit: Metal cosim CI (dual_uart, apb_trace, JTAG-minimal,
xprop_cosim) all green, byte-identical output VCDs vs pre-refactor
(harness: re-run the fixtures, cmp against a pre-refactor golden).
Phase 1 — Path A: CpuBackend + Linux cosim CI
- Implement
CpuBackend:state_prep(output→input copy + BitOps + X-mask clear — the loop atcosim_metal.rs:4286–4301),run_edgesviacpu_reference::simulate_block_v1per block (N effectively 1), plainVec<u32>state. - Peripherals stay on CPU (Tier 1, already are). The GPU flash kernel's FSM
is a simple SPI state machine; reuse the existing
CppSpiFlashFFI (cosim_metal.rs:2493) rather than writing a third copy. - Wire
cmd_cosimto selectCpuBackendwhen--features metalis absent (or via an explicit--backend cpu), removing the hard-error. - Move the cosim regression tests to a Linux CI job — Metal-only today;
CPU cosim lets
xprop_cosim(cosim mode),dual_uart, andapb_tracerun on freeubuntu-latest.
Entry: Phase 0 merged.
Exit: cargo run --bin jacquard -- cosim … (no GPU features) runs the
existing cosim fixtures on Linux CI and passes their checkers.
Phase 2 — Path B: Cuda/HipBackend mirroring Metal (GPU peripherals, batched)
Refined 2026-06-19 (see ADR 0017 Amendment 2026-06-19 and the detail doc
cosim-phase2-cuda-hip.md): there is one CUDA/HIP cosim backend, mirroringMetalBackend(GPU design step + GPU peripherals + variable batching + managed memory). The earlier "checkpoint 2a = CPU-peripheral CUDA backend" is dropped — no production backend runs peripherals on the CPU, so it would be a confusing one-off.CpuBackendstays the pure-CPU oracle; the "per-edge fallback" isbatch=1of the GPU backend, not a CPU-peripheral path.
Bisectability comes from staging on fixtures (each exercises a different kernel subset), not from a throwaway backend:
- Stage A — design step + batched orchestration.
CudaBackend/HipBackendover thecosim_state_prep+cosim_simulate_stagekernels +init_schedule/edge_ops_mut(managed buffers, dirty-edge upload) + the batchedrun_edges/VCD-ring path. Validate againstxprop_cosim(logic fixtures, no peripherals). - Stage B —
gpu_io_step(UART + bus). Validatedual_uart+apb_trace. - Stage C — flash kernels (
gpu_apply_flash_din,gpu_flash_model_step). Validate flash/JTAG fixtures.
GPU peripheral kernels are two impls (shared *_impl.cuh for CUDA+HIP, plus
the existing .metal). Define the GpuPeripheral seam in Stage B/C; the Tier-1
CpuBackend is the per-kernel equivalence oracle. Gate GPU-backed cosim CI on
tesla4-runner.
Entry: Phase 1 merged (seam + CPU reference proven). Exit:
cosim --features cuda on each fixture runs batched (batch > 1), output
VCD byte-identical across CPU/Metal/CUDA/HIP; JTAG matches at batch=1.
Phase 3 — (Future) single-source peripherals (Tier 3, user-extensible API)
A user authors a peripheral once (restricted-Rust subset or a small
peripheral-FSM IR) that compiles to the CPU model + each GPU backend's
kernel — the user-extensible peripheral API. Slots into the GpuPeripheral
seam defined in Phase 2 without reworking orchestration. Big effort; demand-
driven (first external/user-defined peripheral type).
Testing strategy
- Reuse existing fixtures:
tests/xprop_cosim/(cosim mode),tests/dual_uart/,tests/apb_trace/,tests/jtag_minimal/. - Cross-backend equivalence (the correctness backstop): extend the #113
harness (
scripts/ci/compare_backend_vcds.py) to reactive designs — run the same cosim on CPU / Metal / CUDA and assert byte-identical output VCDs; the CPUPeripheralModelis ground truth and each Tier-2 GPU kernel must match it. - Bit-identical Phase 0 gate: capture a pre-refactor golden of all
fixtures, re-run +
cmpafter each refactor step. - Linux CI: Phase 1 is the unlock — cosim regression coverage on free
ubuntu-latestinstead of the single self-hosted Metal runner.
Risks
- Refactor drift (Phase 0): the bit-identical-Metal exit criterion is
load-bearing; the equivalence test + golden
cmp+ existing checkers guard it. - Shared-memory ops mutation (Phase 0): the in-place
*mut BitOpwrites are the trickiest part to de-Metal; the trait's explicit upload point is the fix. - Flash FSM (Phase 1): the GPU flash kernel and the
CppSpiFlashFFI must agree. Prefer reusingCppSpiFlashto avoid a third copy. - Per-edge device read (Phase 2 fallback path): the CPU-peripheral fallback reads device state every edge — slow by design, used only for CPU-side models; checkpoint 2b (GPU peripherals) is what keeps the common case batched. Not a correctness risk.
- Tier-2 kernel divergence (Phase 2 checkpoint 2b): two kernel families
(CUDA+HIP shared
*_impl.cuh, Metal.metal) per peripheral; equivalence tests against the CPU model are the guard, and Tier 3 eventually removes the hand-maintenance. - Phase 2 size: merging the backend bring-up with the GPU-peripheral ports makes Phase 2 large; the internal 2a/2b checkpoint keeps it incrementally verifiable (per-edge equivalence before adding batching).
Sequencing relative to other backend-alignment work
- Independent of #104
(CUDA/HIP
simtiming) — both bring CUDA/HIP toward Metal parity and are now T4-testable, but touch different code paths. - Complements the
simcross-backend equivalence test (#113) and the proposed single-sourcesimulate_block_v1macro prelude — those harden thesimcompute kernel; this plan adds the cosim driver. - CDC/island batching (multi-clock plan MC.1→MC.4) is the long-term fix for the per-edge tail of CPU-side-model designs (e.g. JTAG) — orthogonal to and larger than this seam; not a prerequisite.