Welcome to the documentation for Jacquard, a GPU-accelerated RTL logic simulator.
Use the sidebar to navigate between topics, or start with the Getting Started guide — it runs four bundled designs in a few seconds with no synthesis. To prepare and run your own RTL, see the Synthesis Flow; for UVM/cocotb/SVA questions, Testbench Interop.
Start here if you’re considering a feature contribution or want to understand Jacquard’s overall direction.
Project Scope & Guarantees: Top-level contract — what Jacquard is for, what it isn’t, licensing and architecture constraints, stability tiers.
Why Jacquard: Honest positioning vs. STA tools and event-driven simulators; what’s unique, what isn’t, and what output interface would let users extract the value.
Timing Correctness: Scoped requirements for timing accuracy, validation, and the forthcoming timing IR.
Timing Model Extensions: Pre-spike design notes for δ(T) dynamic delay, clock-tree skew, and wire delay at scale. Formalised in Decision 0007.
Post-Phase-0 Roadmap: Sequencing of Phase 1+ work covering structured timing output (Decision 0008) and timing model fidelity (Decision 0007). (OpenTimer integration was originally Phase 1’s centrepiece; Decision 0003 was Superseded by the spike — OpenSTA out of process is now the sole STA path per Decision 0001.)
Decisions: Design decisions and their rationale (numbered, per-decision). See the index for status and how the decisions relate.
Implementation Plans: Phased implementation plans with entry and exit criteria. See the index for status and reading order.
Spikes: Time-boxed experiments and their outcomes.
jacquard cosim runs GPU-resident peripheral models (SPI flash, UART, Wishbone)
alongside the design so inputs can react to outputs cycle-by-cycle. It runs on
Metal, CUDA, and HIP (plus a CPU fallback).
# Drive the design from a JSON testbench config; write an output VCD
cargo run -r --features metal --bin jacquard -- cosim \
design.v --config sim_config.json --output-vcd out.vcd
When running Jacquard, look for these diagnostic outputs:
netlist has X pins, Y aig pins, Z and gates # AIG complexity
current: N endpoints, try M parts # Partition count
Built script for B blocks, reg/io state size S # Final script
WARN (GATESIM_VCDI_MISSING_PI) ... # VCD issues!
Tracked live on GitHub — see the
open issues and the
priority:high
label. The long-standing ones:
VCD hierarchy mismatch — Jacquard expects a flat top-level VCD; most
testbenches emit hierarchical ones. Workaround: --input-vcd-scope (see
Troubleshooting VCD). Tracking:
#142.
Complex FSM simulation — some FSM designs (e.g. safe.v) don’t simulate
correctly; under investigation. Tracking:
#143.
Format-string preservation — Yosys may drop gem_format attributes, so
$display messages show placeholders. This is an upstream Yosys limitation;
the workaround is to extract format strings from the pre-synthesis JSON.
This document states what Jacquard is for, what it is deliberately not for, and the constraints under which it is built. It is the top-level contract that scoped requirements docs (timing-correctness.md, future equivalents) inherit from.
If you are a contributor deciding whether a feature or change fits Jacquard, start here.
Jacquard is a GPU-accelerated gate-level simulator for synthesized digital circuits. It exists to make pre-silicon functional verification of synthesized designs substantially faster than CPU-based simulators, by mapping the design onto a GPU’s massively parallel execution model.
It is a descendant of NVIDIA Research’s GEM project, maintained by Rob Taylor and community contributors.
Jacquard ships with AIGPDK, a simple and-inverter standard cell library aligned to the GPU schema’s internal representation. It also accepts Liberty-described cells from open-source PDKs (SKY130) and commercial PDKs (under a private test track; see Decision 0004).
Gate-level simulation of synthesized Verilog netlists against AIGPDK, SKY130, and similar Liberty-described cell libraries.
Stimulus from static VCD input, from co-simulated GPU-resident peripheral models (SPI flash, UART, and similar), or from combinations of both for SoC-scale functional verification.
Synchronous designs clocked by one or more clocks of known frequency.
GPU-backed simulation across CUDA (NVIDIA), HIP (AMD), and Metal (Apple Silicon).
Back-annotated timing simulation from SDF, including setup/hold violation detection.
Correctness validation against permissive third-party simulators and STA tools.
Jacquard does not aim to cover the following areas. Feature work requiring cross-cutting changes to the GPU schema or core architecture is declined on scope grounds unless this document is first amended. Contributions that interface Jacquard with external tools covering these areas are welcome where noted.
Mixed-signal or analog simulation. Digital gate-level only. Interfacing to external analog / mixed-signal simulators via cosim hooks is in scope for future contribution.
RTL-level simulation. Input is a synthesized netlist, not behavioural RTL. Synthesis (via Yosys or commercial tools) happens upstream.
Sign-off STA. Jacquard performs functional simulation with setup/hold guardrails. Sign-off timing analysis is delegated to dedicated STA tools; Jacquard validates its results against them but does not aim to replace them.
Incremental or interactive editing of running simulations. Each Jacquard run starts from a complete netlist and stimulus. Contributions that embed Jacquard’s engine into interactive or incremental workflows (REPLs, debuggers, IDE integrations) as an external driver are welcome and considered outside this project’s direct scope rather than non-goals.
All code linked into the Jacquard binary must be under a permissive license (MIT / Apache-2 / BSD-3 or equivalent). GPL tools may be invoked as subprocesses. This keeps Jacquard commercially usable by downstream integrators.
Jacquard commits to three GPU backends: CUDA, HIP, and Metal. A change that lands on one backend without a plan for the others is a regression in product surface. Feature-parity timing is negotiable; eventual feature-parity is not.
These are structural properties of the current GPU schema:
Sequential logic is currently edge-triggered and synchronous: a raw latch in the logic, and asynchronous sequential (self-timed) logic, are not modelled today — the GPU schema’s scheduling assumes synchronous clocking. (Async set/reset on flip-flops is supported; so are clock gating via CKLNQD and latch-based memory mapped to RAM — see the latch note in architecture/simulation-engine.md.) Extending it to support async sequential approaches is open territory; contributions with a viable approach are welcome.
Circuits fit the boomerang block shape: 8191-signal input/output and 4095 intermediate-pin limits per partition, 64 SRAM output groups. Very wide designs may require manual --level-split tuning.
Numerics are 4-state at partition granularity (X-capable or not), not per-bit.
Results are validated against at least one independent third-party tool per format. No single parse path (Jacquard’s or otherwise) is its own reference. See timing-correctness.md for the detailed validation contract on timing.
Stable with caveats. SKY130 flow; known to work on the MCU SoC reference design, with a history of PDK-specific issues resolved over time.
Evolving. Timing simulation: sim-path arrival tracking and setup/hold violation detection now work across Metal, CUDA, and HIP (Decision 0008), with per-DFF clock-arrival skew folded in (Decision 0007 Pillar B); cosim-path timing output (--timing-report) is not yet wired. Multi-clock scheduling. X-propagation semantics. SDF parser is hand-rolled and has received multiple reactive fixes.
Planned. Private commercial-PDK test track (see Decision 0004).
Contributors can expect stable-tier behaviour to remain stable across releases. Evolving and experimental tiers may change shape between releases; reasonable migration notes will be provided.
Product surface before performance. Jacquard’s speed advantage is its value proposition; optimizations that compromise correctness, validation, or portability are declined.
Permissive-license contributors win. Any change that pushes Jacquard toward GPL-contamination is rejected; subprocess-based integrations with GPL tools are acceptable.
Non-goals hold until amended. Extending into latches, interactive stimulus, or RTL simulation is not a bug fix; it is a scope change and requires this document to be updated first.
Validation is not optional. New parsers, new formats, new simulation modes require an independent third-party reference at least for representative cases.
Honest stability labels. Moving a feature from “evolving” to “stable” requires evidence (regression coverage, oracle-backed validation, absence of known silent-failure modes), not just time in the codebase.
Jacquard is three tools; install only what your task needs:
Tool
What it’s for
Install
jacquard
the simulator (sim / cosim)
Homebrew · cargo binstall · prebuilt release · from source
opensta-to-ir
SDF → timing-IR (only for the timing / post-PnR path)
ships with jacquard (same release / Homebrew formula)
netlist-graph
post-synthesis signal-name discovery (companion to the tracing docs)
PyPI (uvx / pip)
Availability. The prebuilt-binary, Homebrew, and PyPI channels go
live with the first tagged release (v0.1.0). Until then, build the
simulator from source and run netlist-graph
from the repo with uv run. The design behind this layout is
Decision 0018.
The cleanest path on a Mac. Requires an Apple Silicon machine with a
Metal GPU. The Homebrew formula is built with --features synth, so
behavioral RTL input works out of the box — see Accepted RTL surface.
To try a release candidate before it ships, install from the prerelease
tap instead (it tracks the latest -rc tag):
Installs the jacquard binary (+ timing_analysis) on macOS/Metal. The
crate is the jacquard-sim package (the binary it installs is still
jacquard); the --git form is required because it’s not on crates.io
(its dependencies are a vendored fork carrying in-flight patches), so binstall
reads the [package.metadata.binstall] pkg-url straight from the repo.
Why jacquard-sim, not jacquard. The package is named jacquard-sim
because the crate name jacquard is taken on crates.io by an unrelated
project (an AT-Protocol client library) — so cargo install jacquard would
build that, not this. Naming our package jacquard-sim makes resolution
unambiguous. --disable-strategies compile,quick-install is kept as
belt-and-suspenders: it turns a missing prebuilt binary into a clean hard
error rather than any source-build fallback. (validate-install.yml uses the
same guard.)
Linux is not binstall-able: there are two GPU backends (CUDA, HIP) for
one target triple, so it can’t be auto-selected — use the release tarball
for your backend, a container, or build from source.
Release binaries are built with --features synth and support behavioral RTL
input directly.
Runtime dependency — Homebrew LLVM. The prebuilt macOS binary links
Homebrew LLVM’s libc++ and libomp (the build uses LLVM clang for
OpenMP, via the mt-kahypar partitioner), so it needs brew install llvm
to run. The Homebrew install handles this automatically
(depends_on "llvm"); binstall and the raw tarball do not, so
install LLVM first.
Download jacquard-<version>-<target>.tar.gz from the
releases page, extract,
and put jacquard, timing_analysis, and opensta-to-ir on your PATH.
The GPU kernel is embedded, but the binary still needs Homebrew LLVM at
runtime (brew install llvm) — see the note above.
Installed yowasp-yosys Python package, found automatically:
pip install yowasp-yosys
Fetch-from-release is a planned follow-up (not yet implemented).
Prebuilt and Homebrew binaries include the synth feature; the wasm is still
found via the flag, env var, or installed Python package until auto-fetch ships.
Pure Python — install from PyPI, no GPU or Rust needed:
uvx netlist-graph search design.gv psel # one-off, no install
pip install netlist-graph # or install it
From a Jacquard checkout you can also run it without installing:
uv run netlist-graph … (it’s a workspace member). See
signal tracing for what it’s used for.
For post-PnR timing simulation you also need PDK Liberty files, fetched
with volare/ciel (pinned in
the root pyproject.toml). opensta-to-ir converts SDF to the Jacquard
timing IR (.jtir) that jacquard sim --timing-ir / cosim --timing-ir
consume. Pure functional (pre-PnR) runs need none of this — see
signal tracing § pre-PnR functional runs.
This guide gets you from a fresh clone to four working simulations, in
increasing order of realism. Every command here runs against designs that ship
in the repository — no synthesis, no large downloads, no extra EDA tools.
Each one is also a CI job, so it is known-green.
Already have your own RTL? Pass it directly to jacquard sim design.v …
— the simulator auto-detects behavioral Verilog / SystemVerilog and
synthesizes it transparently (see Accepted RTL surface).
For peak GPU performance, or to use a commercial synthesizer (DC) for best
mapping quality, see Synthesis Flow. For UVM/cocotb/SVA
questions see Testbench Interop.
A built jacquard binary. Either install it (see Installation)
or build from a clone:
git clone https://github.com/gpu-eda/Jacquard.git
cd Jacquard
git submodule update --init --recursive
cargo build -r --features metal --bin jacquard # macOS / Apple Silicon
The examples below use cargo run -r --features metal --bin jacquard -- … so
they work straight from a clone. If you installed the binary (e.g.
brew install gpu-eda/homebrew-tap/jacquard), replace that prefix with just
jacquard. On Linux, swap --features metal for --features cuda (NVIDIA) or
--features hip (AMD), and replace the trailing 1 (NUM_BLOCKS) with 2× your
GPU’s SM/CU count.
That MATCH is your “Jacquard runs on this machine” checkpoint. The last
argument, 1, is NUM_BLOCKS — always 1 for Metal; on CUDA/HIP set it to 2×
your GPU’s SM/CU count.
jacquard sim drives the design from a fixed input waveform. Real testbenches
need to react to the design’s outputs. That is what jacquard cosim is for: it
runs peripheral models (UART, SPI flash, JTAG, bus monitors) as GPU kernels
alongside the design, so inputs can depend on outputs cycle-by-cycle.
The dual_uart design is two independent UART transmitters that send "Hi" and
"OK". Cosim decodes the serial bits back into bytes — no input VCD required;
clock and reset come from the config.
cargo run -r --features metal --bin jacquard -- cosim \
tests/dual_uart/dual_uart_synth.gv \
--config tests/dual_uart/sim_config.json \
--top-module dual_uart_top \
--max-clock-edges 10000
Verify both channels decoded correctly:
python3 tests/dual_uart/check_pass.py target/test-out/dual_uart_events.json
# console: b'Hi' OK
# debug: b'OK' OK
# PASS: both UART channels decoded correctly
Tiers 1 and 2 use Jacquard’s abstract AIG cell library. Real designs come out of
synthesis mapped to a real PDK (process design kit). Jacquard decomposes
real standard cells — SKY130 and GF180MCU today — into its AIG
representation directly, so a synthesized netlist simulates as-is.
A pre-place-and-route netlist is the output of logic synthesis, before layout
and before any SDF timing annotation. This is the fastest, most common
gate-level verification step. logic_cone is a small SKY130 netlist (real
sky130_fd_sc_hd__* cells) that ships in the repo:
cargo run -r --features metal --bin jacquard -- sim \
tests/timing_test/sky130_timing/logic_cone.v \
tests/timing_test/sky130_timing/logic_cone.vcd \
/tmp/logic_cone_out.vcd \
1 --input-vcd-scope logic_cone
No --sdf flag: this is pure functional simulation of a real-PDK netlist. The
circuit is 4 DFFs → nand2/nor2/and2/inv cone → DFF, computing
Q = a & !b & !c & !d (registered). Its sibling inv_chain.v (16 SKY130
inverters = identity) is a cleaner “output tracks input, delayed two cycles”
check if you want one.
Add --sdf logic_cone.sdf --sdf-corner typ to layer Liberty cell delays on
top — that moves you from functional to timing-aware simulation, covered
in Timing Simulation.
To see Jacquard handle a real, large design on a real PDK end-to-end, the
wafer.space chess chip_top (~227,000 cells, GlobalFoundries open-source
GF180MCU PDK) is wired up as a smoke-test recipe. The post-P&R netlist is
~200 MB so it is not committed — you regenerate it from the upstream
LibreLane flow. Full
step-by-step recipe, expected timings, and what it exercises:
tests/gf180mcu_chess_chip_top/README.md.
This is the bridge to your own designs: synthesize to SKY130 or GF180MCU, then
run jacquard sim exactly as in 3a.
Jacquard is a gate-level emulator — it maps a synthesized and-inverter
graph onto a virtual manycore GPU processor (see
Decision 0014). So the direct input to
jacquard sim / cosim is a gate-level Verilog netlist: structural
Verilog whose leaf cells are aigpdk, SKY130, or GF180MCU standard cells.
Behavioral RTL is the intended design input — via a synthesis step. You
bring your RTL, synthesize it to a gate-level netlist (memory mapping +
logic synthesis to aigpdk.lib cells), and Jacquard emulates the result at
5–40×. Synthesis is currently a separate, documented step — see
Synthesis Flow — because synthesis quality directly sets
Jacquard’s performance, so it’s a deliberate knob (open-source Yosys works;
a commercial tool like DC gives better QoR).
Roadmap: an integrated jacquard build design.v on-ramp (Yosys via
YoWASP, no manual synthesis) is planned — see
Decision 0021 and
#162. Until then, synthesize
first, then sim.
Behavioral constructs (always, if/case, reg, parameters, generate,
function/task, #delay) are not read by Jacquard directly — the
synthesizer elaborates them away. If you feed raw behavioral RTL to sim, it
will fail to parse; run it through synthesis first.
The netlist parser (sverilogparse, consumed by netlistdb) accepts the
structural subset a synthesizer emits:
Structure
Multiple module … endmodule per file; // and /* */ comments; (* … *)
attributes (parsed and ignored).
Both port-list styles: non-ANSI (module m(a, b); with body input/output
decls) and ANSI (module m(input [7:0] a, output b);). A wire/reg
net-type keyword in an ANSI header is accepted and ignored.
Declarations
input / output / inout / wire, scalar or bus ([hi:lo]), with
comma-separated names (wire a, b, c;).
Cell instantiations
CELL_TYPE inst (.pin(expr), …); — named-port connections only. An empty
.pin() (unconnected) is accepted and dropped.
Assigns
assign lhs = rhs;, both sides full wire expressions.
Wire expressions (in assigns, port connections, and .name(expr) hookups)
Form
Example
Identifier (scalar or whole bus)
w
Bit-select
w[3]
Part-select / slice ([hi:lo] or [lo:hi])
w[7:0]
Concatenation
{a, b[3:1], 4'b0101}
Sized literal (x/z allowed for bin/oct/hex)
4'b01xz, 8'hFF, 10'd42
Unary NOT
~w
Parenthesised grouping
(w)
Identifiers — standard ([A-Za-z_][A-Za-z0-9_$]*) and escaped
(\name-until-whitespace ).
Single top module — auto-detected as the module no other instantiates; pass
--top-module <name> if ambiguous. Cyclic instantiation is rejected.
Hierarchy is flattened — multi-level module hierarchy is supported and
flattened at load.
Leaf-cell pin directions come from the cell library (aigpdk / SKY130 /
GF180MCU); an unknown cell’s pins default to Unknown with a warning.
inout on the design boundary is parsed but modelled as Unknown
(not fully supported); prefer split input/output where possible.
Edge-triggered flops only — a raw LATCH cell is rejected (async
set/reset is fine). Clock gating uses CKLNQD; latch/register-file memory maps
to RAM through the memory-synthesis step (see Synthesis Flow).
Immediate assertions are already lowered through synthesis today, as
GEM_ASSERT cells (see the assertion handling in src/aigpdk.rs).
Broader SVA (SystemVerilog Assertions) is planned — see
Testbench Interop § Roadmap. The concrete near-term slice is the
X-barrier $isunknown assertion work: a spec-file-driven !$isunknown
check against the X-mask (#106)
and lowering an $isunknown SVA subset from RTL to that spec
(#107), building on selective
X-propagation (Decision 0016).
jacquard sim and jacquard cosim accept behavioral Verilog / SystemVerilog
directly — no separate synthesis step, no external toolchain. This page
describes what the on-ramp synthesizes, what it drops, and where the coverage
boundary lies.
One-line summary: the accepted surface is whatever YoWASP Yosys (with
the yosys-slangread_slang frontend) can synthesize to an aigpdk netlist.
Jacquard delegates the elaboration and synthesis; it does not implement a
Verilog front-end itself.
Fetch from a pinned release (implemented; the default when the options above
aren’t set): a sha256-verified yosys.wasm is downloaded from the
gpu-eda/yowasp-yosys fork and cached under $XDG_CACHE_HOME/jacquard. The
RTL on-ramp reference describes the full resolution
order and why the fork wasm is the default.
Version caveat. Pin to yowasp-yosys==0.64.0.0.post1131 (the version in
the project’s uv.lock, verified to carry read_slang). Newer wheels ship a
wasm built with the WebAssembly exception-handling proposal, which the current
embedded wasmtime engine cannot load yet (pip install yowasp-yosys gets the
latest and may fail with “exception refs not supported”). Loading newer modules
is a tracked Phase-4 follow-up; until then, pip install 'yowasp-yosys==0.64.0.0.post1131'.
Released jacquard binaries are built with --features synth and include
the Yosys WASM runtime. Source builds must add --features synth explicitly:
cargo build -r --features metal,synth --bin jacquard.
A binary built without--features synth gives an actionable error when
handed behavioral RTL, pointing at the synth-enabled build path.
The compiled Yosys module is cached under $XDG_CACHE_HOME/jacquard by
content hash — only the first run of a given wasm pays the ~20 s cranelift
compile. The synthesized netlist is also cached keyed by (design source + synth
script + wasm), so repeat sim runs skip synthesis entirely.
The embedded wasm (pinned yowasp-yosys 0.64.0.0.post1131) bundles
yosys-slang — a near-complete SystemVerilog-2017 elaborator. Jacquard
probes for read_slang at startup and uses it when present; it falls back
gracefully to Yosys’s built-in read_verilog -sv for older wasm modules.
read_slang coverage is substantially broader than read_verilog -sv:
packages, interfaces, structs, enums, and most SV-2017 constructs are handled
by the slang elaborator before Yosys sees any RTL. If you observe a
“not supported” message, check whether it names slang or read_verilog — the
fallback has a narrower surface.
Asynchronous reset / set DFFs (async reset is supported; latches are not)
Case and if/else chains
Parameterized modules and generate blocks
Inferred memories (mapped to RAMGEM by memlib_yosys.txt — see below)
SystemVerilog-2017 (via yosys-slang read_slang):
Packages and package imports
Interfaces (including modports)
always_ff, always_comb, always_latch
logic, enum, struct, union (synthesizable forms)
Parameters with complex types; advanced generate (if, for, case)
Casting and type conversions (synthesizable)
This is not the narrow subset accepted by Yosys’s built-in
read_verilog -sv; it is the slang elaborator’s synthesizable coverage, which
tracks the SystemVerilog-2017 standard closely.
The synthesis script applies three project-specific transformations on top of
standard Yosys synthesis:
RTL construct
Result
Notes
$assert, $assume, immediate assert property
GEM_ASSERT cell
Visible in simulation as assertion failures. $cover is silently dropped.
$display (→ Yosys $print)
GEM_DISPLAY cell
The cell is emitted, but the on-ramp does not yet write the display-info JSON companion the CPU side needs to decode format strings — so $display text is not surfaced through the on-ramp path yet (tracked as a Phase-4 follow-up).
Inferred synchronous memories
$__RAMGEM_SYNC_ (RAMGEM)
Mapped via aigpdk/memlib_yosys.txt. Asynchronous-read ports generate a warning.
The techmap rules are in aigpdk/gem_formal.v. Pass
--strip-assertions (if available on your build) to use chformal -remove and
drop GEM_ASSERT cells instead, for a pure logic netlist.
Concurrent SVA → synthesizable checker synthesis is partial. Turning
concurrent SystemVerilog Assertions (property / sequence bound to
assert property with a clock) into gate-level checkers is a Yosys formal-flow
capability — independent of slang’s ability to parse SVA. This is tracked in
issues #106 and #107. Immediate assertions (assert (cond) inside procedural
blocks) synthesize correctly via the GEM_ASSERT mapping above.
Testbench-only constructs are dropped. Synthesis discards constructs that
have no synthesizable meaning: #delay timing controls, most initial blocks
(other than register initialization), $finish, $stop, and similar testbench
procedural code. These are silently dropped by Yosys during elaboration, not
simulated. If a $display is inside an initial or inside a #delay-driven
block, it may be dropped too.
Latches are not supported. Jacquard’s GPU emulator core is synchronous
only — see docs/synthesis-flow.md for the latch constraint. A latch inferred
from RTL causes a synthesis or AIG-load error.
YoWASP Yosys produces functional-grade QoR — correct results but not
optimized for GPU speed. Synthesis quality is the primary tuner of simulation
throughput (the AIG the GPU emulates is only as good as the mapping). For peak
performance, synthesize your design with a commercial synthesizer (DC) or a
native Yosys install, and point jacquard sim at the resulting gate-level
netlist directly. See docs/synthesis-flow.md for the performance path.
This prose describes the intended surface. Because the accepted RTL surface is
whatever the embedded Yosys frontend synthesizes, the authoritative measure
is an empirical pass/fail coverage table driven through
sv-tests (or a curated subset) — a
Phase 4 follow-up tracked in docs/plans/rtl-onramp-sim-integration.md. Until
that table exists, hand-claimed feature lists for a delegated frontend are
inherently approximate. Report gaps as bugs; they will be tracked in the
coverage table when it ships.
New to Jacquard? Start with Getting Started, which
runs bundled designs in a few seconds with no synthesis. This page is the
deeper path: preparing your own RTL for peak GPU performance using a
commercial synthesizer (DC) or a native Yosys install.
Behavioral RTL shortcut:jacquard sim design.v in.vcd out.vcd N accepts
behavioral Verilog / SystemVerilog directly — synthesis is automatic and
transparent. See Accepted RTL surface. This page covers
the performance path where synthesis quality is the primary tuner of
simulation throughput.
Caveats: jacquard sim drives the design from a static input waveform (e.g. VCD); for reactive testbenches use jacquard cosim. Storage must be edge-triggered flip-flops — latches (level-sensitive storage) are not supported. Asynchronous set/reset on flip-flops is fine (async reset is not the restriction); what’s excluded is latch-based / level-sensitive sequential logic.
Dataset: Some (namely, netlists after AIG transformation in Steps 1-2 below, and reference VCDs) input data is available here .
Go to aigpdk directory where you can download aigpdk.lib, aigpdk_nomem.lib, aigpdk.v, and memlib_yosys.txt. You will need them later in the flow.
Before continuing, make sure your design’s logic storage is edge-triggered
flip-flops — a raw latch left in the gate-level logic is rejected (async
set/reset on flip-flops is fine; async reset is not the restriction). Two
structured latch uses are still supported: clock gates map to the CKLNQD
integrated clock-gating cell — replace any RTL clock gates manually with
CKLNQD instantiations from aigpdk.v — and latch-based register files /
memory are captured by the memory-synthesis step below (memory_libmap →
RAM), not left as raw latches.
Also, you are advised to be familiar with where memory blocks (e.g., caches) are implemented in your design so you can check that the memory blocks are mapped correctly later.
This step makes use of the open-source Yosys synthesizer to recognize and map the memory blocks automatically.
Download and compile the latest version of Yosys. Then run yosys shell with the following synthesis script.
# replace this with paths to your RTL code, and add `-I`, `-D`, `-sv` etc when necessary
read_verilog xx.v yy.v top.v
# replace TOP_MODULE with your top module name
hierarchy -check -top TOP_MODULE
# simplify design before mapping
proc;;
opt_expr; opt_dff; opt_clean
memory -nomap
# map the rams
# point -lib path to your downloaded memlib_yosys.txt
memory_libmap -lib path/to/memlib_yosys.txt -logic-cost-rom 100 -logic-cost-ram 100
The memory_libmap command will output a list of RAMs it found and mapped.
If you see $__RAMGEM_SYNC_ (naming inherited from GEM), it means the mapping is successful.
If you see $__RAMGEM_ASYNC_, it means this RAM is found to have asynchronous READ port. You need to confirm if it is the case.
If it is a synchronous one but accidentally recognized as asynchronous, you might need to patch the RTL code to fix it. There might be multiple reasons it cannot be recognized as synchronous. For example, when the read and write clocks are different.
If it is indeed asynchronous, check its size. If its size is very small and affordable to be synthesized using registers and mux trees (which is very expensive for large RAM banks), you can remove the $__RAMGEM_ASYNC_ block in memlib_yosys.txt, re-run Yosys to force the use of registers.
If you see using FF mapping for memory, it means the memory is recognized, but due to it being nonstandard (e.g., special global reset or nontrivial initialization), Jacquard will fall back to registers and mux trees. If the size of the memory is small, this is usually not an issue. Otherwise, you are advised to try other implementations.
After a successful mapping, use the following command to write out the mapped RTL as a single Verilog file.
write_verilog memory_mapped.v
Check the correctness of this step by simulating memory_mapped.v with your reference CPU simulator.
This step maps all combinational and sequential logic into a special set of standard cells we defined in aigpdk.lib.
The quality of synthesis is directly tied to Jacquard’s final performance, so we suggest you use a commercial synthesis tool like DC. You can also use Yosys to complete this if you do not have access to a commercial synthesis tool.
Check the correctness of this step by simulating gatelevel.gv with your reference CPU simulator.
# if you exited Yosys in step 2, you can read back in your memory_mapped.v yourself.
# read_verilog memory_mapped.v
# hierarchy -check -top TOP_MODULE
# synthesis
synth -flatten
delete t:$print
# change path/to/aigpdk_nomem.lib to a correct path. same for other commands.
dfflibmap -liberty path/to/aigpdk_nomem.lib
opt_clean -purge
abc -liberty path/to/aigpdk_nomem.lib
opt_clean -purge
techmap
abc -liberty path/to/aigpdk_nomem.lib
opt_clean -purge
# write out
write_verilog gatelevel.gv
Jacquard automatically partitions the design at startup using mt-kahypar-sc hypergraph partitioning.
If partitioning fails due to deep circuits (which often shows as trying to partition a circuit with only 0 or 1 endpoints), try adding a --level-split option to force a stage split. For example --level-split 30 or --level-split 20,40.
Jacquard automatically detects the correct VCD scope containing your design’s ports. In most cases, you don’t need to specify --input-vcd-scope. If auto-detection fails or you need to override it, use:
# Metal
cargo run -r --features metal --bin jacquard -- sim path/to/gatelevel.gv path/to/input.vcd path/to/output.vcd 1 --input-vcd-scope "testbench/dut"
# CUDA
cargo run -r --features cuda --bin jacquard -- sim path/to/gatelevel.gv path/to/input.vcd path/to/output.vcd NUM_BLOCKS --input-vcd-scope "testbench/dut"
Use slash separators (/) for hierarchical paths, not dots. See troubleshooting-vcd.md for details.
The simulated output ports value will be stored in output.vcd.
Caveat: The actual GPU simulation runtime will also be outputted. You might see a long time before GPU enters due to reading and parsing input.vcd. You are recommended to develop your own pipeline to feed the input waveform into Jacquard’s GPU kernels.
Jacquard supports two ways to feed timing data into the simulator:
--timing-ir <path.jtir> — pre-converted Jacquard timing IR. This is the canonical path and requires no external tools at run time. Generate the IR ahead of time with the standalone opensta-to-ir tool (see crates/opensta-to-ir/).
--sdf <path.sdf> --liberty <path.lib> — raw SDF, converted to IR on the fly. This subprocesses OpenSTA, which must be installed on the user’s machine.
When using --sdf, Jacquard locates OpenSTA in this order:
JACQUARD_OPENSTA_BIN environment variable.
<repo-root>/scripts/build-opensta.sh --print-binary (the canonical install path during development; the script builds the version vendored at vendor/opensta/).
sta on PATH.
Jacquard requires OpenSTA 3.1.0 or newer, matching the commit pinned at vendor/opensta/. The pinned version is the only one with end-to-end test coverage; newer OpenSTA versions are accepted with a warning, older versions are a hard error.
The simplest way to get a known-good OpenSTA is to build the vendored copy from the Jacquard repo:
Then either set JACQUARD_OPENSTA_BIN to the path printed by ./scripts/build-opensta.sh --print-binary, or just let Jacquard find it automatically — the build script’s output is searched by default.
A common first question: “Can I point my existing UVM / cocotb / SVA testbench
at Jacquard?”
Short answer: not today. Jacquard is a gate-level engine, not a drop-in
replacement for a SystemVerilog or Python simulator’s testbench runtime. This
page explains what works now, what is on the roadmap, and the fallback that
works for any flow today.
You do not need framework integration to use Jacquard with a UVM, cocotb, or
plain-Verilog testbench. The universal path:
Run your existing testbench against any simulator that can dump a VCD
(Verilator, Icarus, a commercial simulator, …).
Dump the design’s top-level input pins to a VCD.
Replay that VCD through Jacquard with jacquard sim.
This is exactly what jacquard sim already is — a recorded-waveform replay — so
it works regardless of how the stimulus was generated. The trade-off is that the
replay is open-loop: it reproduces the recorded inputs, so it can’t react to
divergent design behaviour. For closed-loop reactive stimulus, model the
peripheral as a cosim kernel instead.
These are directions under consideration, not commitments or dated milestones:
SVA (SystemVerilog Assertions) — planned. Jacquard already lowers a class
of immediate assertions through synthesis (GEM_ASSERT cells; see the
assertion handling in aigpdk.rs). Broader SVA support is the next step here.
Running UVM test suites — design settled, unbuilt. The shape is
Decision 0022: split the testbench the way
emulators have since SCE-MI. Sequences, randomisation and checking stay on the
host; the driver’s timed half is rewritten as a synthesizable transactor and
compiled into the AIG beside the design (possible since the
RTL on-ramp shipped), so it wiggles pins
at GPU speed. Existing UVM drivers don’t port as-is, and that isn’t a gap we
can close: they drive every cycle, and cosim runs 1024 edges per dispatch with
the CPU absent, which is where the speed comes from.
cocotb — needs more work. A naive bridge would marshal Python ↔ GPU every
cycle, which would dominate runtime and erase the GPU speedup. Same cliff as
above, same fix: exchange transactions, not cycles. Making cocotb performant
against Jacquard needs more design thinking, not just a shim.
If any of these is blocking for you, the record-and-replay path above is the
recommended interim approach.
Status: Honest assessment of where Jacquard fits in an EDA flow alongside dedicated STA tools (OpenTimer/OpenSTA) and event-driven simulators (Verilator, iverilog, CVC). Includes a survey of what timing information Jacquard exposes today and what would let users actually consume it.
This is not a marketing document. The goal is for a contributor or user to read it and decide accurately whether Jacquard helps them — and, if it does, how to extract the answer they need.
Jacquard’s unique value is vector-driven timing analysis at GPU scale: answering “did this stimulus violate setup/hold at any DFF, on which cycle, on which signal?” for designs large enough that SDF-annotated event-driven sim is too slow to finish in useful time.
Everything else Jacquard offers is offered, often better, by the standard flow:
For functional sim: Verilator is faster on small designs.
For timing: OpenSTA gives more accurate answers than Jacquard, vector-independent.
For glitch / metastability: event-driven sim with SDF (CVC, iverilog) sees behaviours Jacquard’s lockstep kernel structurally cannot.
Jacquard becomes the right tool when (design size × vector length) exceeds what event-driven SDF-annotated sim can handle, and you specifically want vector-driven timing answers.
STA is not optional even with Jacquard. Jacquard does not replace OpenSTA; it complements it. The right framing is “STA proves no bad vectors exist; Jacquard proves your real workload runs cleanly within those bounds.” OpenSTA is also a hard runtime dependency for any timing-aware Jacquard flow — the timing IR is produced by opensta-to-ir, which subprocesses OpenSTA. See Decision 0001.
The intersection where Jacquard wins is narrow but real:
Activity-driven setup/hold sweep at scale. Run a long workload (boot trace, architectural validation, NoC congestion stimulus) on a large design at GPU speed; get a per-cycle violation report. STA can’t tell you “this real workload trips violation X at cycle 12,847”; CVC can but won’t finish in time on big designs.
Arrival-time distributions for power/activity analysis. Per-signal arrival histograms across millions of cycles → useful for worst-case-power analysis informed by actual switching activity. STA gives you nothing here; CVC could but slowly.
Failure forensics. When a functional test fails, answering “was this a timing issue?” without rerunning under a different simulator. Jacquard’s timing-VCD output ties violations to cycle/signal/path — useful when you already have it from the same run.
Fast iteration during timing closure. Change a constraint, resynthesise, re-run a long test — Jacquard’s loop time is short enough to make this practical on big designs in a way iverilog+SDF isn’t.
Worst-case path enumeration. STA tells you the top-N critical paths over all possible inputs. Jacquard sees only what your stimulus exercises. If your testbench misses a critical path, Jacquard’s “no violations” report is silent on it; OpenSTA would flag it.
True min-delay analysis. OpenSTA does proper min-delay path search. Jacquard’s hold check is per-DFF against actual stimulus only.
Per-pair CRPR. OpenSTA applies common-path-pessimism removal as a launch/capture credit on each path. Jacquard consumes per-DFF clock arrival from opensta-to-ir and folds it into setup/hold (see timing-model-extensions.md, Part B Stages 1+2 — landed), but treats the launch reference as 0 — i.e. the per-pair CRPR credit is intentionally not modelled at this stage. Stage 3 in the same doc is the lever if Stage 1+2 pessimism turns out to matter on a real design.
SDC-aware constraint handling. False paths, multi-cycle paths, generated clocks, async groups — OpenSTA reads SDC and respects it. Jacquard doesn’t read SDC at the timing layer.
Coverage by construction. STA covers every path by definition. Dynamic sim covers only what’s exercised.
Vector-independent confidence. “This design meets timing” is something STA can claim; Jacquard can only claim “this design met timing on these vectors.”
The honest comparison isn’t “Jacquard vs. Verilator + OpenTimer.” It’s “Jacquard vs. iverilog/CVC-with-SDF + OpenTimer.” On the timing-sim side specifically:
Glitch propagation. CVC/iverilog with inertial or transport delay see intra-cycle pulses. Jacquard’s lockstep cycle-accurate kernel does not.
Per-pin wire delay fidelity. CVC consumes SDF interconnect records per-receiver, per-edge, with rise/fall distinction. Jacquard collapses to per-cell-max (see timing-model-extensions.md, Part C).
Per-DFF setup/hold without per-word collapse pessimism. Jacquard collapses all DFFs in a 32-bit state word to min(setup), min(hold); CVC checks each flop individually.
Async event handling. Real $setup/$hold checks across asynchronous control. Jacquard explicitly assumes synchronous designs.
So today, accuracy-per-vector goes to CVC; throughput goes to Jacquard.
Jacquard’s timing fidelity gap with CVC is closeable. The work in timing-model-extensions.md — δ(T), clock-tree skew, per-receiver wire delay — closes much of it while preserving GPU throughput. The further along that path the project goes, the more “Jacquard” looks like “GPU-accelerated SDF-annotated event-driven sim, with the inherent limits the cycle-accurate kernel imposes (no glitches, lockstep cycles)” — i.e. CVC’s report quality at Verilator’s speed, on designs where neither alone suffices.
Jacquard’s unique value depends on getting the timing information out of a run in a form users can act on. Phase 1 of the post-Phase-0 roadmap (Decision 0008) closed the gap between “data Jacquard has” and “answers users want” for setup/hold violations.
The kernel writes setup/hold violation events to a per-block event buffer (csrc/kernel_v1.metal:554-576). The host drains the buffer each cycle (src/event_buffer.rs), resolves the state-word index to a hierarchical DFF site name via WordSymbolMap, and emits:
[cycle 12847] SETUP VIOLATION at top/cpu/regs[7][bit 22] [word=412]: arrival=2150ps setup=80ps slack=-30ps
[cycle 12847] HOLD VIOLATION at top/cpu/state[bit 3] [word=412]: arrival=12ps hold=20ps slack=-8ps
The bare [word=N] suffix is preserved for grep/tooling compatibility; up to four DFFs per word are named, with +N more truncation beyond that.
Machine-readable, CI-friendly. Sample at tests/timing_ir/sample_reports/two_violations.json; full schema in src/timing_report.rs (SCHEMA_VERSION = "1.0.0"). Stability contract per Decision 0008: additive-only extensions, breaking changes bump the major.
SimStats { setup_violations, hold_violations, ... } is available to in-process consumers (src/event_buffer.rs). Only counts; full detail flows through the structured report path.
The shipped worst_slack ranking is populated only from observed violation events. Surfacing “where am I close to the edge” on a run that passed timing requires GPU-side near-miss instrumentation (emit slack events whenever |slack| falls below a configurable threshold). Useful for proactive signoff regression. Separate workstream — needs a kernel change.
Per-signal arrival histogram dump for matched signal patterns, as JSON or CSV. Foundation for activity-based power analysis and “is my actual timing margin healthy” reporting.
Read OpenSTA’s worst-N critical-path report and produce coverage output: of those paths, which were exercised by the stimulus, at what observed arrival. Closes the loop between vector-driven and static analysis.
Given a flagged DFF, walk the max-of-fanin chain backward to the source AIG pin / primary input, emitting per-edge contributions. Most expensive item on the wishlist; only useful once symbolic names are in place (which they now are).
The current Metal sim path routes runtime violations through process_events (which is what feeds the resolver, structured report, and text summary). The CUDA, HIP, and cosim paths don’t yet share that plumbing — they detect violations on the GPU but don’t drain through process_events. Independent plumbing follow-up; doesn’t affect the Metal user experience.
Listed in Decision 0008 as part of the JSON report’s wishlist. Not in v1.0.0 of the schema; will be added (additively) when the GPU kernel emits transition events.
Decision 0008’s summary template includes both. Corner is missing because the metadata struct doesn’t carry it through from the IR yet; margin percentage is trivially derivable from slack_ps / clock_period_ps and was omitted to keep the v1 summary terse.
This document is the contract for Jacquard’s timing correctness story. It defines what must be true; decisions under docs/architecture/decisions/ define how; plans under docs/plans/ execute them.
Inherits constraints and non-goals from project-scope.md — in particular the permissive-license requirement for linked code and the synchronous-only design assumption.
Addresses three known weaknesses from the April 2026 architecture review:
Timing-model pessimism from the packed-32 ALU (no single fix; needs refinement path for sign-off use).
Weak correctness observability (CPU reference and GPU kernel share Rust source, so representation bugs pass both sides).
Hand-rolled SDF parser fragility on real post-P&R output from production tools.
Out of scope (tracked elsewhere, addressed later): hard schema limits (no latches, static VCD), three-backend maintenance cost, GPU-side IO model scaling, silent-failure engineering patterns beyond parsers, partitioner non-determinism.
When Jacquard’s results disagree with OpenSTA, Jacquard is wrong until demonstrated otherwise. In the shipped release, OpenSTA is never invoked from the jacquard runtime binary, and never linked (it is GPL; Jacquard stays permissive). Subprocess invocation from CI, test harnesses, and the standalone opensta-to-ir preprocessing tool is acceptable. During development before first release, a runtime subprocess invocation may exist as a contributor-ergonomics convenience (see Decision 0006) — this is explicitly interim and is removed before release. Divergence is never accepted silently: it is fixed, explicitly justified in-doc, or filed as a bug.
Every format we consume (SDF, Liberty, SPEF, Verilog) is validated against at least one third-party tool’s parse of the same file. This is the primary defense against representation bugs that would otherwise affect Jacquard’s primary and reference paths simultaneously.
Every parser and pipeline stage emits a success count (cells parsed, arcs matched, annotations applied). The test harness asserts thresholds. Zero-match is never silently acceptable. A pipeline that quietly succeeds with the wrong data is a bug.
Any timing-value representation natively supports multiple PVT corners. Single-corner shortcuts become tech debt the moment commercial flows arrive; the shape is enforced upfront.
The original requirement was an in-process STA engine that computes per-endpoint arrival/slack from Liberty + SPEF, linked directly (subject to the permissive-license constraint in project-scope.md), cross-checking Jacquard’s SDF-derived timing at load time and on demand during sim.
Preferred implementation was OpenTimer; the SKY130 spike (docs/architecture/decisions/spikes/opentimer-sky130.md) found OpenTimer’s input pipeline unfit for OpenROAD-flow outputs, and Decision 0003 was Superseded (commit d002bde). The cross-check role is now performed out of process by OpenSTA via opensta-to-ir (Decision 0001 — sole STA path); the in-process variant is parked until a fit-for-purpose permissive option appears (libreda-sta or in-house walker, both behind a future decision).
The OpenSTA test corpus is vendored (or submoduled) into the repository and used as a regression fixture. Every IR converter runs against the corpus; converter-to-converter diffs must be explained or fixed before merge.
Jacquard’s own regression designs are run through OpenSTA (subprocess) and compared against Jacquard’s output. Runs nightly or pre-release, not per-PR, due to runtime cost.
For any sim run that reports timing violations, or for any user-requested critical-path report, top-K paths are reported with:
Full per-stage path trace with per-edge delay.
Pessimism delta: the gap between the packed-thread max arrival and the actual arrival along this specific path (exposes the magnitude of the packed-32 ALU’s worst-case accounting).
Provenance of each delay (SDF-asserted / Liberty-computed / defaulted).
A private test track for commercial PDKs exists, gated on per-PDK environment variables (e.g. <VENDOR>_PDK_PATH). Tests skip cleanly when PDK files are unavailable; CI runs with PDK access execute them. No PDK-derived artifacts are committed. Details: docs/architecture/decisions/0004-private-pdk-testing.md.
IR consumption at sim startup is effectively O(1) of IR size (binary, memory-mapped, zero-copy). The expensive SDF→IR conversion is a one-time preprocessing step, not an every-sim cost.
Given identical inputs and tool versions, every converter produces byte-identical IR. Tool version bumps may change output but must be explicit (recorded in IR metadata).
Each converter (SDF→IR, Liberty→IR, OpenSTA→IR, reference-STA→IR) is testable in isolation, without the full Jacquard build. Converters are separate crates or binaries. Consumers of IR need not know which converter produced it.
Items not settled by this document; they resolve in decisions, spike outcomes, or phase plans:
Exact IR schema format (FlatBuffers / Cap’n Proto / other). Tracked in Decision 0002.
Whether OpenTimer handles SKY130 Liberty robustly. Resolved: spike failed Q2 on SKY130; Decision 0003 Superseded.
Whether SPEF gets its own IR or is embedded in the timing IR. Deferred; likely separate.
Whether the IR is Jacquard-local or shared across a broader tooling ecosystem. External decision; answer affects investment level and schema stability requirements.
This document supersedes the “±5% arrival tolerance” convention from docs/timing-validation.md once phase 0 ships. Until then, the existing convention remains in effect for designs not yet covered by oracle-backed CI.
See also:timing-correctness.md — forward-looking validation contract and timing IR requirements (in progress). The document below describes current behaviour.
This document explains GEM’s boomerang evaluation architecture and how timing simulation with per-gate delays can be implemented efficiently on GPU.
What “HIP” means above. HIP is two quite different things, and for most of
this project’s life the table only ever meant the first:
HIP-over-CUDA — hip-runtime-nvidia, built with the CUDA toolkit and
executed on an NVIDIA GPU (HIP Tests (NVIDIA backend), tesla4-runner).
Source-compatible with CUDA and, in practice, CUDA semantics throughout.
HIP on real ROCm — an actual AMD GPU (HIP Tests (ROCm backend)). This
had never been built, let alone run, until 2026-07-15, and sim did not run
there at all until the non-cooperative fallback landed: the device has no
cooperative launch, so the grid-wide barrier sim relies on is unavailable
and the host drives one launch per (cycle, stage) instead. See
architecture/decisions/spikes/amd-laptop-backend.md.
Both are now covered by CI and both pass, so the ✅s above are accurate for
each. The distinction still matters when reading a green tick: it is what hid
#203 — an out-of-bounds read
that nvcc silently truncated back into range, so CUDA and HIP-over-CUDA were
accidentally correct while ROCm faulted. Arrival/violation arithmetic itself
is host-side Rust (#195) and therefore backend-independent.
GEM simulates And-Inverter Graphs (AIGs) where every node is either:
A primary input (value comes from VCD stimulus)
An AND gate with two inputs (possibly inverted)
Traditional simulation evaluates gates in topological order, which is inherently serial. GPUs excel at massive parallelism - thousands of threads doing the same operation on different data. GEM bridges this gap with the boomerang architecture.
The boomerang structure is a hierarchical reduction tree that maps an AIG onto GPU threads. It’s called “boomerang” because data flows down the tree during reduction, then results are written back out at various levels - like a boomerang going out and returning.
Thread position refers to threadIdx.x - which of the 256 threads we’re addressing. Each thread position processes 32 signals in parallel using SIMD operations.
Jacquard’s GPU timing is intentionally conservative — it may over-estimate arrival times
but will never under-estimate them. This is important for setup violation detection:
false positives are safe, false negatives would miss real bugs.
There are three independent sources of conservatism, each adding to the overestimate:
The GPU kernel tracks a single u16 arrival per thread position. It cannot distinguish
between rising and falling signal transitions because each thread processes 32 packed
Boolean signals simultaneously — there’s no per-bit transition direction available.
How it works: For each cell, inject_timing_to_script() computes:
Impact: For the SKY130 inv_chain test (16 inverters), rise delays average ~10ps
larger than fall delays. In a real inverter chain, transitions alternate (rise→fall→rise),
so half the cells use the smaller fall delay. Jacquard uses the larger rise delay for all.
Measured: 80ps overestimate on 1235ps (6.5%) for 16 inverters with ~10ps rise/fall
asymmetry per cell.
For multi-input cells (AND gates, MUXes), INTERCONNECT delays to different input pins
may differ significantly. Jacquard takes the maximum across all input pins:
Impact: If an AND gate has input A arriving via a 10ps wire and input B via a
200ps wire, Jacquard assigns 200ps to the cell regardless of which input is on the
critical path. An event-driven simulator would correctly propagate the 10ps arrival
on input A independently.
When this matters: Designs with highly asymmetric routing (e.g., one input is
local, another crosses the chip). Well-routed designs typically have balanced wire
delays to multi-input cells.
Each thread position holds 32 independent Boolean signals. Jacquard tracks one arrival
per thread position (the maximum across all 32 signals):
Thread 50: [signal_A: 5ps, signal_B: 23ps, signal_C: 8ps, ...]
Tracked: arrival[50] = 23ps (max of all 32)
Impact: If signals with very different timing are packed into the same thread,
the fastest signals inherit the slowest signal’s arrival time.
Mitigation: The bit-packing algorithm can sort signals by estimated timing before
assignment (see “Timing-Aware Bit Packing” section). This keeps similar-timing signals
together, reducing the max approximation error.
These sources are multiplicative in the worst case. For the inv_chain test:
Source
Contribution
Notes
max(rise, fall)
+80ps
8 inverters × 10ps asymmetry
max wire delay
+8ps
8 wires × 1ps asymmetry
max per thread
0ps
Only 1 signal per thread in this test
Total overestimate
88ps / 7.1%
vs CVC transition-accurate result
For larger designs with more routing asymmetry and denser bit packing, the combined
overestimate could be larger. The bit-packing sort (Source 3) is the most actionable
mitigation.
The inv_chain design (2 DFFs + 16 SKY130 inverters) was validated against CVC
(open-src-cvc), an event-driven Verilog simulator with native SDF back-annotation:
Both simulators agree on CLK→Q delay (350ps) because the DFF has a single output
transition direction per clock edge. The chain delay differs because CVC tracks
actual rise/fall polarity through each inverter.
To run the CVC comparison locally:
bash tests/timing_test/cvc/run_cvc.sh
Requires Docker (builds CVC from source on first run).
The GPU kernel checks timing constraints per state word (32 signals) after the boomerang evaluation completes. Arrivals and constraints use u16 picosecond values (range 0–65535 ps). Arithmetic is performed in u32 to avoid overflow when summing arrival + setup:
// After boomerang completes, before next cycle
// arrival: u16 max accumulated delay for this 32-signal group
// constraint_word: packed [setup_ps:16][hold_ps:16]
u16 setup_ps = constraint_word >> 16;
u16 hold_ps = constraint_word & 0xFFFF;
// Setup check: skip when arrival == 0 (no data propagated, e.g. first cycle
// or DFF with constant inputs)
if (arrival > 0 && (u32)arrival + (u32)setup_ps > clock_period_ps) {
int slack = (int)clock_period_ps - (int)arrival - (int)setup_ps;
write_event(event_buffer, EVENT_TYPE_SETUP_VIOLATION,
cycle, io_offset + threadIdx.x,
(u32)slack, (u32)arrival, (u32)setup_ps);
}
// Hold check: no arrival > 0 guard (hold violations matter even at cycle 0)
if ((u32)arrival < (u32)hold_ps) {
int slack = (int)arrival - (int)hold_ps;
write_event(event_buffer, EVENT_TYPE_HOLD_VIOLATION,
cycle, io_offset + threadIdx.x,
(u32)slack, (u32)arrival, (u32)hold_ps);
}
Each thread position holds 32 signals packed into a u32. When tracking timing with one arrival value per thread position, we approximate all 32 signals as having the same arrival time (the maximum).
This approximation is accurate when signals in the same thread have similar timing. But the default placement algorithm uses first-fit for bit assignment:
#![allow(unused)]
fn main() {
// Default: first available slot
for i in 0..hier[selected_level].len() {
if hier[selected_level][i] == usize::MAX {
slot_at_level = i; // First-fit, not timing-aware
break;
}
}
}
This can result in signals with very different timing sharing a thread:
Thread 50 (accidental grouping):
bit 0: level 5, ~5ps arrival
bit 1: level 12, ~12ps arrival ← 7ps difference!
bit 2: level 6, ~6ps arrival
Thread 50 (timing-aware grouping):
bit 0: level 5, ~5ps arrival
bit 1: level 5, ~5ps arrival ← similar timing
bit 2: level 6, ~6ps arrival
Before assigning bit positions, sort signals by their estimated arrival time:
#![allow(unused)]
fn main() {
// Collect nodes at this level
let mut nodes_to_place: Vec<_> = candidates
.filter(|n| level[n] == selected_level)
.collect();
// Sort by arrival time (level as proxy, or actual timing if available)
nodes_to_place.sort_by_key(|n| arrival_estimate[n]);
// Place in sorted order - similar timing ends up in same thread
for (slot, node) in nodes_to_place.iter().enumerate() {
place_bit(..., slot, *node);
}
}
See also:timing-correctness.md — forward-looking validation contract and timing IR requirements (in progress). The document below describes current behaviour.
Guide to enabling, reading, and debugging setup/hold timing violations in GEM.
Setup and hold violations occur when data arrives too late (setup) or too early (hold) relative to the clock edge at a flip-flop. GEM checks for these violations during GPU simulation by tracking arrival times — the accumulated gate delay from primary inputs or DFF outputs through combinational logic to the next DFF data input.
Approximation model: GEM tracks one arrival time per 32-signal group (one GPU thread position). The arrival is the maximum across all 32 signals in the group. This is conservative: it may over-report violations but will never miss a real one. See Reducing False Positives for details.
(WS-P1.1.a, 2026-05-02: state-word indices are now resolved to symbolic
hierarchical signal names. The bare [word=N] suffix is preserved for
grep compatibility. Words packing more than 4 DFFs truncate with a
+N more suffix.)
Field
Meaning
cycle
Simulation cycle where the violation occurred
word
State word index — identifies a group of 32 DFF data inputs
arrival
Maximum accumulated gate delay to this word’s signals (picoseconds)
setup
DFF setup time constraint from SDF/Liberty (picoseconds)
A one-screen human summary printed to stdout at end of run. Reuses the
same data the JSON report builds (so --timing-report and
--timing-summary cost the same; only the output channel differs).
Sample output:
=== Jacquard Timing Summary ===
Design: my_cpu.gv
Vectors: boot.vcd (1000 cycles)
Clock period: 1000 ps
Timing source: my_cpu.jtir
Violations:
Setup: 5
Hold: 2
Total: 7
Worst slack:
Setup: -150ps at top/cpu/regs[7][bit 22] [word=5] (cycle 87)
Hold: -40ps at top/cpu/state[bit 3] [word=12] (cycle 91)
Total negative: setup (TNS)=-620ps hold (THS)=-80ps
Top 2 by violation count (of 2 total words with violations):
top/cpu/regs[7][bit 22] [word=5] (5 violations): worst setup=-150ps hold=- arrival=950ps
top/cpu/state[bit 3] [word=12] (2 violations): worst setup=- hold=-40ps arrival=10ps
The Worst slack block reports the four standard signoff metrics:
Metric
Meaning
WNS (Worst Negative Slack)
The single most-negative setup slack across the whole run — the worst setup path.
WHS (Worst Hold Slack)
The single most-negative hold slack.
TNS (Total Negative Slack)
Sum of all negative setup slacks (stats.total_setup_slack_ps in the JSON). Captures the aggregate severity, not just the worst path.
THS (Total Hold Slack)
Sum of all negative hold slacks (stats.total_hold_slack_ps).
TNS/THS reflect every observed violation regardless of the per-cycle
list cap, and are never optimistic: if the GPU event buffer overflows,
dropped events only make the true totals more negative.
The format is for human inspection — explicitly not a stable
parseable contract. Tools that need to script against the data should
use --timing-report JSON.
For CI integration and downstream tooling, pass --timing-report <path>
to get an end-of-run JSON document. The schema is versioned (Decision 0008’s
stability contract: additive-only extensions, breaking changes bump
the major). Sample at tests/timing_ir/sample_reports/two_violations.json;
authoritative type definitions in src/timing_report.rs.
per_word is sorted by total violation count desc, then by word_id.
worst_slack.setup / .hold are top-10 by closest-to-violation slack
(most negative first). Caveats:
The “even when no violation occurred” half of WS-P1.1.d (per-DFF
closest-to-violation tracking when the design never tripped a
violation) needs GPU-side near-miss instrumentation and is not in
v1.0.0; for now, worst_slack is populated only from actual violation
events.
--timing-report is wired on all three sim backends (Metal, CUDA,
HIP). The cosim path does not yet route runtime violations through
process_events — bringing it in is independent plumbing.
The violations array is capped at 100,000 records by default
(~8 MB JSON). Override or disable the cap with
--timing-report-max-violations <N> (0 = unbounded). Setup/hold
totals, events_dropped, and worst_slack rankings always reflect
every observed event; only the per-cycle list is bounded.
stats.violations_truncated reports how many records were dropped
because the cap was reached.
In gpu_sim, violations are logged with word IDs that map directly to the output_map positions. Each word covers bit positions word * 32 through word * 32 + 31.
Use the netlist_graph tool to trace the combinational logic cone feeding the DFF. After uv sync --group dev, the netlist-graph console script is on the workspace’s uv run path — no cd required:
# Find the DFF data input driver chain
uv run netlist-graph drivers design.v "dff_name.D" -d 10
# Search for DFFs matching a pattern
uv run netlist-graph search design.v "dff_out*"
Discovered signal names can be passed directly into jacquard sim --trace-signals <file> / jacquard cosim --trace-signals <file> (one name per line) to surface them in the output VCD alongside top-level IO.
GEM tracks one arrival time per 32-signal group (one GPU thread position). The tracked value is the maximum arrival across all 32 signals in that thread. This means:
Conservative: If any signal in the group has a long path, the arrival for the entire group reflects that worst case. Violations may be reported for signals that individually meet timing.
Never misses real violations: A real violation always results in a reported violation (the max is >= any individual signal’s arrival).
Timing-aware bit packing groups signals with similar arrival times into the same thread, reducing the approximation error. See docs/timing-simulation.md § “Timing-Aware Bit Packing” for details.
Setup violations on many words, same cycle: The clock period is likely too tight for the design. The combinational logic depth exceeds what can settle in one clock period. Try increasing the clock period.
Setup violation on a single word: A critical path through one specific logic cone. Use netlist_graph drivers to trace the path and identify the bottleneck.
Hold violation: Rare with SKY130 process (negative hold times clamp to 0 in the SDF). If seen, the design likely has minimum-delay paths that are too short. Check for direct connections between DFF outputs and nearby DFF inputs with minimal combinational logic.
Violations only on first cycle: The arrival > 0 guard in the GPU kernel skips setup checks when arrival is zero (meaning no data has propagated through combinational logic yet). If you see violations on cycle 0, they are hold violations — setup violations on cycle 0 are suppressed by design.
Note: The ±5% tolerance convention described here will be superseded by timing-correctness.md once phase 0 ships (OpenSTA oracle + timing IR + diff harness). Content below remains accurate for current behaviour.
This document describes how GEM validates timing simulation accuracy against reference simulators (CVC, Icarus Verilog). It covers test cases, comparison metrics, known simulator differences, and acceptance criteria.
GEM’s timing simulation (--timed and --enable-timing flags) must be validated against independent reference simulators to ensure correctness. We use two reference tools:
CVC (Cadence Verilog Compiler): For post-layout SDF-annotated designs (commercial tool, high confidence)
Icarus Verilog: For structural Verilog and Liberty-based timing (open source, good for pre-layout)
Definition: Gate-level delays and arrival times computed by GEM match reference simulator within acceptable margins.
Known differences (simulator-specific semantics):
Metric
GEM
CVC
Notes
Q arrival
Full combo path delay (CLK→dff_in.Q + chain + interconnect)
Final gate CLK→Q only
GEM is conservative: includes downstream logic
Setup slack
Time from data arrival to DFF setup deadline
Similar
Usually aligned
Hold slack
Time from data change to DFF hold deadline
Similar
Can differ due to arrival rounding
Why the difference? GEM’s boomerang architecture computes cumulative delays through pipeline stages, resulting in conservative estimates that over-predict delays by including full combinational paths. CVC only reports final DFF gate delays.
Tolerance:
Functional output: Exact match required
Arrival times: ±5% acceptable (due to architectural differences)
Purpose: Pre-layout tests catch timing issues early without P&R turnaround (30+ min). Library-only delays provide floor (lower bound); post-layout adds routing parasitics for final validation.
The test drives opensta-to-ir --liberty NAME=PATH (WS2.4) three times
with the genuine sky130 typ/slow/fast Liberty files and asserts the
emitted timing IR carries three corners with different setup values
(slow > typ > fast). This is the load-bearing check that
--timing-corner actually means something — the companion
aigpdk_dff_emits_per_corner_timing_values only validates IR shape.
One-time setup on a fresh Mac:
uv sync --group dev # installs volare
uv run volare enable c6d73a35f524070e85faff4a6a9eef49553ebc2b
# Optional override if the PDK lives elsewhere:
export SKY130_LIBERTY_DIR=/path/to/sky130_fd_sc_hd/lib
The pinned volare hash lives in pyproject.toml::[tool.jacquard.pdks.sky130].
The test skips cleanly with an install hint when the PDK isn’t found,
so it’s safe to run on machines without sky130 installed.
Running:
cd crates/opensta-to-ir
cargo test --test opensta_integration sky130_multi_corner -- --nocapture
GF180MCU PDK (in progress — phased per docs/plans/gf180mcu-enablement.md):
uv sync --group dev
uv run volare enable --pdk gf180mcu 559a117b163cef2f920f33f30f6f690aa0b47e4c
Liberty lands under ~/.volare/volare/gf180mcu/versions/<hash>/gf180mcuC/libs.ref/gf180mcu_fd_sc_mcu{7,9}t5v0/liberty/.
Both 7-track and 9-track standard-cell libraries are pulled together by
volare. Cell models are vendored as submodules at
vendor/gf180mcu_fd_sc_mcu7t5v0/ and vendor/gf180mcu_fd_sc_mcu9t5v0/.
Pre-layout tests compare designs without P&R parasitics, using only library cell timing:
Metric
Tolerance
Notes
Functional output
0 (exact match)
Logic correctness required
Arrival time
±10%
Library delays only; no routing variance
Setup/hold slack
±10ps
Rounding differences expected
Why loose tolerances? Pre-layout designs have no P&R uncertainty. Functional correctness is critical (exact match); timing metrics can vary due to simulator implementation differences.
Why: Boomerang architecture evaluates in hierarchical stages. To know Q arrival, you sum delays from all stages.
Mitigation: Compare Jacquard Q arrival against CVC RESULT: total_delay (if available), or accept that arrival times differ but functional output matches.
Conservative model: GEM accumulates delays pessimistically. This is intentional—designs validated under the GEM model are guaranteed to meet timing in actual P&R.
Trade-off: Over-predicting delays means GEM may flag timing violations that won’t occur in silicon. This is preferable to under-predicting (which would give false confidence).
Check SDF parsing: Did parser successfully load all timing data?
Enable debug logging: --sdf-debug
Look for “unmatched SDF instances” warnings
Check initialization: Are both simulators starting from same state?
Compare first 5-10 cycles after reset
Verify reset logic is synchronized
Compare against Jacquard non-timed version:
# Run without timing VCD
cargo run -r --features metal --bin jacquard -- sim \
design.v stimulus.vcd functional_output.vcd 1
# Does functional output match CVC?
compare_outputs.py functional_output.vcd cvc_output.vcd
Status: Idea / pre-spike. Not scheduled. Captured here so the architecture sketch survives the next session-clear.
Scope: Three related extensions to Jacquard’s timing model, all aimed at making setup/hold reporting more honest without abandoning the cycle-accurate boomerang kernel.
Dynamic delay — per-gate δ(T) inspired by the Involution Delay Model (Maier 2021, arXiv:2107.06814). Captures pulse-width-dependent delay degradation that fixed δ∞ misses on near-threshold paths.
Clock-tree skew — per-DFF clock arrival accounting. Today every DFF on a clock is treated as if it captures simultaneously; SDF clock-buffer arcs and clock-net interconnect are silently dropped during AIG construction.
Wire delay at scale — per-receiver interconnect delay applied to the right edge in the AIG, and explicit modelling of inter-partition wires. Today wire delay is collapsed to a max-per-destination-cell scalar — fine for sky130 short routes, increasingly wrong as we move to faster clocks, finer processes, and large many-core/NoC designs.
All three share the same insight: the data the model needs is already in the TimingIR. The work is at the consumer layer (flatten.rs, aig.rs, the kernel arrival math), not the IR or the partitioner.
Per-AIG-pin arrival is a single ushort accumulated by max through the boomerang reduction. There is no event scheduling — arrival is a scalar that rides alongside the Boolean evaluation in lockstep with cycle ticks.
A per-gate dynamic delay model that makes δ a function of T (time since the gate’s last output transition). The distinguishing property: input pulses with Δᵢ → 0 have diminishing effect on the output. The model handles pulse-width degradation faithfully and is the only model proven to solve the short-pulse-filtration problem. The paper notes ~80–590% CPU overhead vs. inertial delay on a CPU event-driven simulator.
True IDM needs event scheduling and intra-cycle pulse observability — neither is available in Jacquard’s lockstep cycle-accurate kernel. We cannot model glitch suppression or metastability oscillation traces without either sub-cycle ticks or a different kernel architecture.
What we can do is enrich the per-gate delay used in arrival propagation so setup/hold reporting reflects realistic pulse degradation on marginal paths.
The δ(T) parameters have to come from per-cell SPICE characterisation. For sky130 we’d characterise each sky130_fd_sc_hd__*_* cell once, check the result into the repo, and ship it as a sidecar table consumed by the IR builder. This is the expensive one-off — the paper flags characterisation cost as the unsolved part of making IDM “truly competitive.”
Per-corner δ(T) propagating through arrival → setup/hold reports that distinguish “just meets timing under δ∞” from “fails under realistic pulse degradation”.
sky130_pdk.rs decomposes vendor functional Verilog into AIG nodes while preserving cell identity through aigpin_cell_origins. We can attach δ(T) at the original sky130 cell granularity even after AIG flattening — that structural property is what makes any of this tractable. Cells from a hand-coded library without origin tracking would be much harder.
Status: Stages 1 + 2 implemented (2026-05-01). Per-DFF clock arrival
is carried through the IR (ClockArrival table) and folded into
per-DFF setup/hold via DFFConstraint::effective_setup_hold before
the per-word collapse. Producer landed in c403cc8; consumer
fold-in in 6767c3e. The narrative below describes the original
motivation; the Staged plan at the end of this part records what
shipped and what remains (Stage 3, conditional).
Clocks in Jacquard are walked back from each DFF through buffers/inverters/clock-gates, terminating at an InputClockFlag(pinid, is_negedge) (src/aig.rs:441, :477, :495-560). Recognised cells: INV/BUF/CKLNQD and the sky130 equivalents inv*, clkinv*, buf*, clkbuf*, clkdlybuf*, lpflow_*.
Two consequences:
Clock-tree cells produce no AIG pin. They collapse into a polarity flag on the DFF. Since aigpin_cell_origins only lists cells that produced AIG pins, the timing-IR arcs on those cells (IOPATH records on clkbuf_8, etc.) match no AIG pin in load_timing_from_ir and are silently discarded.
Clock-net interconnect is dropped the same way.interconnect_delays records keyed by net endpoints have no destination cell to attach to, so they fall on the floor.
Net effect: every DFF on a given clock domain is treated as having identical clock arrival, i.e. perfect skew. The current setup/hold check is honest about combinational-path delay but blind to clock-tree topology.
For a sky130 MCU SoC at ~25 ns clock period this is fine functionally; for any timing claim near the period boundary it’s misleading. Intra-domain clock-tree skew on sky130 is typically O(50–200 ps) — small relative to a 25 ns period, but exactly the order of magnitude that determines whether a path “barely meets” or “barely fails” setup.
TimingIR arcs on clock cells (.jtir already contains them; we just don’t consume them).
The AIG clock walk in aig.rs:495–560 already iterates the clock-side cells of each DFF in order. It just doesn’t accumulate their delays. Adding a dff_clock_origins: Vec<Vec<cellid>> parallel structure costs O(num_dffs × clock_depth) memory — negligible.
OpenSTA can compute per-DFF clock arrival end-to-end. (OpenTimer was the original primary STA candidate per Decision 0003 but the spike Superseded it; Decision 0001 makes OpenSTA the sole STA path, called out of process via opensta-to-ir.) Per-pair common-path-pessimism removal (CRPR) is fundamentally a launch/capture credit, not a per-DFF property — so what shipped is per-DFF capture-side arrival, treating launch as a 0-reference. This is the form in the IR today:
Populated by opensta-to-ir’s Tcl driver via [all_registers -clock_pins] + [::sta::vertex_worst_arrival_path]. Consumer code never touches the netlist — it just looks up each DFF’s clock arrival.
Hold margin = data_arrival - (clock_arr_capture - clock_arr_launch + hold)
Per-launch/per-capture pairing is awkward in the current per-word-collapsed constraint buffer, so the implementation folds the capture-side clock arrival into the per-DFF effective setup/hold before packing, via DFFConstraint::effective_setup_hold:
effective_setup = setup - clock_arrival_capture (clamped to [0, u16::MAX])
effective_hold = hold + clock_arrival_capture (clamped to [0, u16::MAX])
The GPU kernel runs unchanged — the same packed (setup<<16)|hold word it already consumes now carries skew-aware values. Launch arrival is treated as zero (ref) — pessimistic for paths whose launch DFF also has a long clock path, but a clean first cut. Stage 3 below addresses that pessimism if measurement justifies it.
“could we partition a design effectively to do this somewhat accurately without sacrificing too much?”
Today partitioning (src/repcut.rs) is hypergraph-cut on logic connectivity. DFFs co-located by logic affinity may have very different clock arrivals.
The pessimism cost: build_timing_constraint_buffer collapses all DFFs in a 32-bit state word to min(setup) and min(hold). If a word holds DFFs with clock arrival 50 ps and 200 ps, the per-word effective setup is the worst of both — i.e. we report timing as if every DFF in that word saw the worst skew in the word. That’s a 150 ps pessimism for the lucky DFF.
Three options, ranked:
Do nothing. For typical sky130 SoCs at ≥10 ns clock periods, intra-word skew (≤200 ps worst-case) vs. period (10 000+ ps) is ≤2%. Worth-it threshold for the optimisation: when designs run close enough to the period that 2% pessimism flips genuine passes into reported violations. Likely never for sky130. Plausibly relevant for designs running at ≥1 GHz on a more aggressive PDK.
Skew-bucket the DFF constraint packing, not the partitioning. Group DFFs into clock-arrival buckets after partitioning, and emit one constraint word per bucket-within-partition rather than collapsing everything in the word. Increases constraint-buffer size by O(num_buckets) but doesn’t disturb the partitioner. Probably the right answer if we ever need to.
Skew-aware partitioning. Add a soft objective to repcut.rs that prefers grouping DFFs by clock arrival. Degrades cut quality (more inter-partition logic edges → more state shuffling). Almost certainly worse than option 2 for the same accuracy gain.
So: yes we have the info, no we probably don’t need to repartition, and the constraint-collapsing pessimism is the real lever — either accept it (option 1) or break it bucket-wise (option 2).
In sky130 at 25 ns clock periods, wire delay is a small perturbation on gate delay and the lumped model is fine. The picture changes in two regimes:
Faster clocks. Wire delay is a fixed physical quantity (RC-dominated); period shrinks; wire fraction of the budget grows.
Finer processes (e.g. 22nm and below). Gate delays scale down with feature size; wire RC scales unfavourably (resistance per square goes up, capacitance per length stays roughly flat). The classic “reverse scaling” inflection: gates get faster, long wires don’t. Typical 22nm: inverter delay 5–15 ps, local short wires 5–20 ps, global routes 50–500 ps, multi-mm wires 1+ ns without repeaters.
Large many-core/NoC SoCs. Inter-tile mesh links can span multiple millimetres; chip-level signals have wire delays comparable to or larger than entire combinational stages.
For a many-small-core NoC at 22nm, wire delay on inter-core links is typically the dominant timing factor. Any model that can’t represent it accurately will misreport the critical paths.
The IR side is already in shape. crates/timing-ir/schemas/timing_ir.fbs carries InterconnectDelay { net, from_pin, to_pin, delay[corner] } per receiver, and opensta-to-ir populates it from SDF.
The lossy step is the consumer in src/flatten.rs:1850-1872:
#![allow(unused)]
fn main() {
let mut wire_delays_per_cell: HashMap<usize, (u64, u64)> = HashMap::new();
// ... for each InterconnectDelay record:
let entry = wire_delays_per_cell.entry(dest_cellid).or_insert((0, 0));
entry.0 = entry.0.max(d); // rise
entry.1 = entry.1.max(d); // fall (same value!)
}
Three layers of pessimism stacked here:
Keyed by destination cell, not destination pin. A cell with two inputs from very different routes loses per-pin fidelity.
Max across inputs of the same cell. Worst-case incoming wire is applied to every output of the cell.
No rise/fall distinction on wire delay. SDF carries both; we collapse to one number.
Then in arrival propagation (csrc/kernel_v1.metal:220-255):
new_arr = max(arr_a, arr_b) + gate_delay
where gate_delay = intrinsic + max_wire_into_cell. The mathematically correct propagation is:
These are equivalent only when the input with the worst arrival also has the worst wire. When they don’t coincide — common on a NoC node where one input comes from a long mesh hop and another from local logic — the current model over-reports by max_wire − actual_wire_on_critical_input.
For sky130 small designs this gap is in the noise. For 22nm with 10× variation between local and global wire delays, it’s the difference between “this path meets timing” and “STA reports a violation that doesn’t exist.”
A NoC tile naturally maps to one (or a few) partition(s). The inter-tile links — the long, wire-dominated, timing-critical ones — are precisely the partition-crossing signals. Today wire delay sits on the destination cell’s gate_delays slot, evaluated inside the destination partition’s boomerang reduction. The wire is a property of the crossing, not the destination cell, and should ideally be modelled at the partition I/O boundary, where src/sim/cosim_metal.rs already shuffles state between partitions.
This is the inverse alignment of the clock-tree case. There partitioning didn’t help with skew accounting. Here partitioning is load-bearing: tile-aligned partitions naturally expose the small set of edges that deserve careful wire-delay modelling, and let intra-partition logic stay on the fast lumped path.
InterconnectDelay records exist per receiver. SDF carries them. opensta-to-ir emits them.
Per-input-pin granularity is in the IR (to_pin includes the local pin name). The consumer just discards it via to_pin.rfind('/') to derive dest_inst.
Rise/fall distinction is in the schema (delay: [TimingValue] per corner; rise/fall could be on top via the same pattern as TimingArc). For SDF-back-annotated flows the rise/fall split usually comes from the SDF; we’d need to confirm opensta-to-ir preserves both edges.
What’s missing today:
Tier-1 plumbing: AIG-pin-level wire delay per fanout. Current gate_delays: Vec<PackedDelay> is keyed by AIG pin (the output side); to do per-input-edge correctly we want delay attached to the edge, not the node. Either add a parallel wire_delays: HashMap<(src_aigpin, dst_aigpin), PackedDelay> or refactor toward an edge-attributed AIG.
Tier-2 plumbing: a “partition-crossing arc” concept in cosim_metal.rs. Currently inter-partition state shuffle moves bits with no associated arrival bump. Adding a per-edge ps adjustment is straightforward in principle; finding the right place in the shuffle pipeline matters.
The IR-size concern bites here. InterconnectDelay is roughly 100–200 bytes per record; a 22nm SoC with 10⁶–10⁷ nets is a .jtir file in the hundreds-of-MB to multi-GB range.
Mitigations:
Streaming load: today TimingIrFile::from_path reads the whole buffer. Could mmap and lazy-decode, since FlatBuffers is offset-based.
Sharding: split IR per partition or per top-level module. Adds a build-time step but bounds memory per process.
Drop intra-cell wires from IR generation: SDF often has microscopic interconnect records that lump into the destination’s own pin-cap. Filter these out at the opensta-to-ir builder. Loss is genuinely negligible.
Worth measuring before committing to mitigations — sky130 NVDLA-scale today is fine; the question is what 22nm + N-tile mesh looks like.
For NoC designs partitioning becomes a positive lever (unlike the clock-tree case where it was neutral). Two specific levers:
Tile-aligned partitions. If repcut.rs finds tile-aligned cuts naturally (likely, given typical tile-to-tile connectivity sparsity), inter-partition arcs are a small, well-defined set of NoC links. Worth verifying with a representative design — a partitioning report keyed by signal name pattern (*_link_*, noc_*, configurable) would expose whether the partitioner’s logic-affinity score is already aligned with tile boundaries or whether we need to bias it.
NoC-link partitioning hint. Add a soft bias to repcut that prefers cutting nets matching a configured regex. Same partitioning machinery, configurable input. Cost: degrades cut quality if the hint conflicts with logic affinity. Likely worth it for explicitly tile-decomposed designs where the user knows the tile boundaries; not worth it for flat designs.
The point of any of this is to make Tier-2 cheap: if the inter-partition arc set is small, per-edge wire delay on those crossings costs almost nothing.
These are upstream concerns. SDF from a crosstalk-aware STA flow already carries pessimistic delays; OCV (on-chip variation) is similarly baked into the chosen corner. Jacquard consumes whatever the IR was generated against. Worth a one-line note in the user-facing docs that the timing report’s accuracy is bounded by the SDF/STA flow it was built from — Jacquard does not invent crosstalk pessimism.
Soft bias in repcut for cutting flagged nets; partition report by tile
src/repcut.rs and CLI flags
None
1–2 weeks
For a sky130 use case Stage 1+2 likely covers everything you’d notice. For 22nm NoC, Stages 1–3 are the meaningful set; Stage 5 is the optimisation that makes Stage 3 cheap.
δ(T) characterisation cost. One-off SPICE per cell-type per corner. Cheaper if we lean on existing ECSM/CCSM data already in vendor Liberty rather than re-running SPICE. Worth investigating before committing to Stage 2.
Whose clock arrival is authoritative? Resolved by Pillar B Stage 1+2: OpenSTA-computed per-DFF arrival via opensta-to-ir, treating launch as 0-reference. Per-pair CRPR credit is intentionally not modelled at this stage (see Stage 3 in the staged plan above).
Interaction. Does δ(T) on clock-tree buffers matter? Probably not enough to model — clock buffers are sized for fast edges and operate far from their pulse-degradation regime. But the framework should be able to express “ignore δ(T) on clock domain” cleanly.
Validation oracle. CVC and Icarus already serve as functional oracles; for skew-aware and wire-aware reporting OpenSTA’s slack report (via opensta-to-ir / direct subprocess) is the ground truth for unit tests. (Decision 0003 originally nominated OpenTimer for this role; superseded by the spike outcome — OpenSTA carries the role end-to-end now.)
IR size at 22nm scale. Open question whether .jtir for a representative many-core NoC fits in available memory under the current eager-load model. Needs measurement before committing to streaming mitigations.
Edge-attributed AIG. Per-receiver wire delay wants delay attached to AIG edges, not nodes. Today the AIG is node-attributed (gate_delays: Vec<PackedDelay> indexed by aigpin). A clean Tier-1 implementation may push toward edge attribution, with downstream effects on the boomerang reduction script layout. Worth a small spike before the main implementation.
Partition-crossing format. Adding per-edge wire delay to cosim_metal.rs inter-partition transfers needs a precise place in the existing pipeline. Currently the shuffle moves Boolean state words without arrival; the natural place is alongside the writeout-arrival path that already exists for setup/hold checking, but the alignment isn’t 1:1 because partition crossings happen at logic boundaries, not capture-DFF boundaries.
docs/timing-correctness.md — forward-looking validation contract; this doc extends rather than replaces.
docs/timing-simulation.md — boomerang architecture; the kernel-side context.
docs/timing-validation.md — current ±5% acceptance criteria; would tighten under δ(T).
docs/architecture/decisions/0002-timing-ir.md — IR design rationale; schema additions here follow the “lossless extension” principle.
docs/architecture/decisions/0001-opensta-as-oracle.md — STA path; OpenSTA out of process is committed (post-supersedure of Decision 0003).
docs/architecture/decisions/archive/0003-opentimer-primary-sta.md — Superseded. Original in-process STA proposal; spike Q2 fail moved Jacquard to OpenSTA-only. See docs/architecture/decisions/spikes/opentimer-sky130.md.
By default a Jacquard output VCD contains only top-level IO.
--trace-signals <FILE> surfaces user-selected internal nets in that
VCD alongside the top-level ports — so you can watch a DFF’s Q, a
controller state bit, or an SRAM port wire without re-synthesizing or
exposing it as a port.
It is available on both jacquard sim and jacquard cosim, and is
observe-only: traced nets are read out each tick, never driven.
Each name in the file is resolved against the netlist, registered as a
primary output before partitioning (so it gets a state-buffer slot),
and emitted on the same path as the top-level IO. It works uniformly for
sequential (DFF Q) and combinational nets — anything that has a name in
the netlist database.
This is the raw-wire counterpart to
bus transaction tracing: --trace-signals gives you
per-cycle waveforms of individual nets; bus tracing gives you decoded
transaction records. Use this when you want a waveform; use bus tracing
when you want READ 0x40 => 0x1.
# JTAG debug-module state (comments and blank lines are ignored)
chip_core.dm.haltreq_q[0]
chip_core.dm.haltreq_q[1]
# Yosys-internal nets — same syntax works
chip_core.sram_u._00147_
# A whole bus, one bit per line
data0_obs[0]
data0_obs[1]
Blank lines and lines whose first non-whitespace character is # are
skipped.
Hierarchy uses . as the separator; a trailing [N] selects a bus
bit.
A leading backslash (Verilog escaped-identifier syntax) is stripped.
A real example ships in tests/jtag_minimal/trace_signals.txt.
Post-synthesis net names are ambiguous — Yosys may flatten a hierarchy
into one escaped identifier (\soc.sram.read_port__data), expand a bus
into per-bit scalars (soc.bus__addr[3]), or preserve real structural
hierarchy. Rather than guess, the resolver tries multiple candidate
interpretations of each name and takes the first that matches the netlist
database, so the same syntax works across all three conventions.
Unresolved names warn, they don’t abort. A bad name logs a warning
and is skipped; the rest of the list still registers. A trailing
summary line reports how many signals registered vs. were dropped, so a
mistyped list surfaces clearly at startup:
Traced nets appear in whichever VCD the run already emits:
Command
Flag
Traced nets appear in
jacquard sim
--trace-signals <FILE>
the output VCD
jacquard cosim
--trace-signals <FILE>
the --output-vcd output only
They show up as ordinary VCD wires next to the top-level IO, named by the
string you put in the trace file.
cosim: traced nets land in --output-vcdonly. The
--stimulus-vcd carries primary inputs and does not include them, so
if you trace a net and look in the stimulus VCD you’ll see nothing.
--output-vcd does not require timing data — see
Pre-PnR functional runs.
--output-vcd is the functional output path too — it does not require
--timing-ir/SDF. Run a synthesized (pre-PnR) netlist through cosim
with --output-vcd out.vcd and you get chip outputs and traced nets per
cycle, with transitions at clock edges (no arrival-time offsets). This is
the right mode for functional / 4-state X-pessimism debugging, where
there is no timing data to supply yet. Adding --timing-ir later only
adds arrival-time offsets to the same VCD.
A top-level inout pad is split into two observables in the output VCD:
<pad>__out (the value the core drives) and <pad>__oe (the pad’s
output-enable). The raw <pad> net reads the pad’s input side, so on
an output-only or undriven cycle it can look flat — watch <pad>__out /
<pad>__oe to see what the design is driving. Example:
bidir_PAD[12]__out, bidir_PAD[12]__oe. These appear automatically;
you don’t need to list them in the trace file.
The recommended way to observe SRAM port activity is wire-level tracing
rather than the env-var-gated JACQUARD_SRAM_DUMP. netlist-graph can
discover the port wires and emit a trace file directly:
# 1. Discover SRAM port wire names from the netlist
uv run netlist-graph sram-ports design.v --cell-type SRAM -o sram_trace.txt
# 2. Surface them in the VCD with full per-tick accuracy
jacquard cosim design.v --config sim.json \
--trace-signals sram_trace.txt --output-vcd out.vcd
# 3. Post-process the VCD to reconstruct bus values
tests/jtag_minimal/ uses --trace-signals to surface the debug
module’s observable outputs (dmactive_obs, haltreq_obs,
data0_obs[0..31]) so the test’s pass criterion can check that the magic
value 0xCAFEBABE lands in data0_obs:
The name doesn’t exist post-synthesis under any candidate spelling. Find the real name with netlist-graph search; the net may have been renamed or optimized away.
Signal registered but flat in the VCD
It may resolve to a constant after optimization (the startup log notes constants are skipped), or the cone was stripped. Confirm it’s a live net with netlist-graph drivers.
Nothing appears in the VCD
For cosim, traced nets only land in --output-vcd (not --stimulus-vcd) — make sure you passed it. Also check the startup summary reports a non-zero registered count.
Registration happens at AIG construction, before partitioning, which is
why the list must be supplied via the CLI flag (not a runtime env var).
The mechanism lives in src/sim/trace_signals.rs; emission piggybacks on
emit_extra_observables in src/sim/vcd_io.rs. The same multi-candidate
resolver backs bus-trace pin binding (see
bus tracing and Decision 0013).
jacquard cosim can decode on-chip bus transactions and emit them in a
compact, transaction-level form — one row per transfer, rather than raw
per-cycle waveforms. You declare the bus interfaces to watch in
sim_config.json; cosim observes their pins on the GPU each tick and
runs the protocol decode on the CPU, writing decoded transactions to a
CSV file.
This is observe-only: the tracer watches signals the design already
drives, it never drives anything. It adds no measurable simulation
overhead when no buses are configured.
Bus tracing is the structured, protocol-aware counterpart to
--trace-signals, which surfaces raw internal
nets in the output VCD. Use --trace-signals when you want waveforms of
individual wires; use bus tracing when you want decoded
READ 0x40 => 0x1 records.
For the GPU to read a bus pin each tick, that net must (1) exist in the
post-synthesis netlist under a resolvable name and (2) survive into the
simulation’s output state. Two consequences:
Names must survive synthesis. The resolver uses the same
multi-candidate matcher as --trace-signals, so Yosys-flattened
(\soc.dm.psel), scalar-expanded (soc.dm.paddr[3]), and
structurally-hierarchical names all work. But synthesis is free to
rename or delete combinational nets. The robust pattern is to make
the bus signals registers (their DFF Q outputs keep their names),
or to annotate the RTL nets with (* keep *).
Constant-folded bits read as 0 — correctly. If a design only ever
drives, say, addresses 0x00 and 0x04, synthesis folds every
address bit except paddr[2] to a constant. The startup log then
shows e.g. addr 1/8 bits. This is expected: the tracer reconstructs
the full value correctly because the dropped bits are genuinely 0.
pready / pslverr are allowed to be absent. A common case is an
always-ready slave that ties pready high — it folds to a constant,
fails to resolve, and the tracer correctly treats the bus as
always-ready.
tests/apb_trace/ is a self-contained, synthesizable APB3 system used
as the CI regression. Its master issues a fixed program — two writes
then two reads — to a register-file slave, and check.py asserts the
decoded CSV. See tests/apb_trace/README.md.
psel/penable did not resolve … this bus will not capture
The prefix is wrong, or the nets were optimized away. Find the real names with uv run netlist-graph search <netlist> psel, then fix prefix or add signals overrides.
Zero transactions decoded
Gate never asserted. Check that psel/penable resolve (startup log) and that the bus is actually exercised within --max-clock-edges.
Address or data always 0
paddr/pwdata/prdata nets didn’t resolve (renamed/folded). Confirm with netlist-graph search; mark the RTL nets (* keep *) and re-synthesize.
Reads return stale/wrong data
The slave must present prdata during the ACCESS phase. Register prdata so its value is stable when psel & penable are high.
jacquard cosim reports a per-edge timing breakdown of the co-simulation
loop, including ground-truth GPU-execution time from device
timestamps — free of the instrumentation overhead that a full GPU
trace (Metal System Trace / nsys) imposes on a workload of thousands of
tiny dispatches per batch.
The breakdown always prints in the human-readable profiling summary at
the end of a run. Passing --cosim-perf-json <PATH> additionally writes
a flat JSON record for CI to log and track.
The cosim loop batches edges into GPU command buffers (BATCH_SIZE
edges each). Per batch, the CPU: builds the command buffer (encode),
commits it, then waits for the GPU to finish (spin), then drains ring
buffers (VCD/UART). The existing summary times these CPU phases with
Instant deltas. This report adds the GPU-side truth:
Metal: MTLCommandBuffer.GPUStartTime/GPUEndTime — host-clock
timestamps the driver records for free; GPUEndTime − GPUStartTime is
the batch’s GPU-execution wall. Read after waitUntilCompleted (the
driver posts the timestamps only at full completion).
CUDA / HIP: cudaEvent / hipEvent elapsed time — follow-up
(the trait seam exists; the impls land in a later PR validated on the
GPU CI runner).
CPU backend: none — gpu_* fields are 0 / gpu_exec_recorded: false.
The seam is one CosimBackend trait method,
last_batch_gpu_seconds() -> Option<f64>, called by the orchestration
after each batch’s wait(). The generic loop owns the CPU timing and
report assembly; each backend supplies only its GPU clock.
GPU exec is a separate measure from the CPU categories — it overlaps
the GPU wait (spin) line (the CPU wall spent waiting is the GPU
execution). Its % is the GPU-busy share of the instrumented wall.
The mcu-soc-metal job runs the mcu_soc cosim with --cosim-perf-json
and posts the JSON to the GitHub step summary (report-only — no
perf gate). This builds a timing history before we commit to a
regression threshold: GPU/thermal noise on shared runners needs a
measured noise floor first, and a premature gate would flake. Adding a
gate (fail if total_us_per_edge regresses beyond a tuned threshold vs
a committed baseline) is the natural follow-up once the history exists,
alongside the CUDA/HIP last_batch_gpu_seconds impls so every tested
architecture reports.
Metal System Trace / nsys instrument every Metal/CUDA call. For a batch
of ~2048 tiny dispatches that overhead is large and systematically
distorts the picture — it inflated an early measurement to make the GPU
look ~88% idle when device timestamps show it ~88% busy. Command-buffer
timestamps cost nothing and are the trustworthy signal for steady-state
throughput; reserve full traces for one-off per-dispatch investigation
(see docs/gpu-capture.md).
jacquard cosim (Metal backend) can emit an Xcode .gputrace document
capturing the exact Metal command stream of the GPU simulation — every
compute dispatch, buffer binding, and blit for a bounded window of edge
batches. This is the detailed counterpart to a system-level
xctrace --template "Metal System Trace" recording: the system trace shows
when GPU work runs relative to the CPU, while a .gputrace shows what
each dispatch does (threadgroup sizes, buffer arguments, per-dispatch
timing, dependency DAG, and — after an Xcode replay — shader occupancy and
memory-bandwidth counters).
Capture is env-gated and zero-overhead when off: with no
JACQUARD_GPU_CAPTURE set, none of the capture code runs.
The Metal backend commits one command buffer per edge batch
(MetalSimulator::encode_and_commit_gpu_batch). Each batch encodes, for
every edge in it, the full per-edge dispatch chain:
Capture brackets an MTLCaptureManager scope (destination
GpuTraceDocument, scoped to the simulator’s command queue) around the
window [SKIP, SKIP + BATCHES):
Start — immediately before the first in-window batch’s command buffer
is created, so the trace opens on a clean batch boundary.
Stop — after the last in-window batch is committed; that command
buffer is waited to completion first so its GPU work is recorded before
stopCapture() finalizes the document.
A stale bundle at the target path is removed first (Metal refuses to
overwrite). Any failure (missing METAL_CAPTURE_ENABLED, unsupported
destination) is logged and the simulation continues normally, uncaptured.
Batch 0 is the reset/warm-up batch and is usually small and unrepresentative.
JACQUARD_GPU_CAPTURE_SKIP=1 (or higher) captures a steady-state batch.
The batch size is BATCH_SIZE edges (see src/sim/cosim/mod.rs), so a
single captured batch already contains the complete per-edge dispatch stream
repeated across the batch — enough to profile the kernel without a giant
multi-batch trace.
The .gputrace records the command stream and per-dispatch timing out of
the box. Occupancy, bandwidth, and cache-hit counters require an Xcode
replay with shader profiling enabled: open the trace in Xcode, press
Replay, and Xcode writes the counter streamData back into the bundle.
Only then do the profiler_gpu_counters / profiler_gpu_scheduling
analyses have data to read.
This document proposes adding selective unknown-value (X) propagation to Jacquard’s
gate-level simulator. Rather than uniformly upgrading the entire simulator to
four-state logic (2x storage, ~2-3x ALU cost), we use static analysis at
compile time to identify which signals can carry X values, and only apply
X-aware simulation to the affected partitions. The rest of the design continues
to run with the existing fast two-state kernel.
Jacquard currently simulates in pure two-state Boolean logic (0/1). All DFFs and
SRAMs start at 0. This is fast but has two significant drawbacks:
Undetected initialisation bugs: If a design reads a register before it
has been written through a proper reset sequence, the simulator silently
returns 0 instead of flagging the value as unknown. Real hardware would
produce an arbitrary bit pattern.
SRAM initialisation masking: SRAMs read as all-zeros before being
written. A design that depends on SRAM initialisation may appear to work
in simulation but fail on silicon.
No RTL/gate-level mismatch detection: RTL simulators (Icarus, VCS,
Questa) propagate X values that expose these bugs. When comparing Jacquard’s
gate-level results against an RTL reference, false mismatches arise because
Jacquard resolves unknowns to zero.
Naively upgrading the entire simulator to four-state would halve throughput
(double storage per signal, double ALU per gate). The key insight is that in a
well-designed SoC after reset, typically <5% of signals are genuinely
X-capable (uninitialised memories, clock-domain crossings, registers without
reset). We should only pay the overhead where it matters.
Jacquard’s core IR is an And-Inverter Graph (AIG). Every gate is an AND with
optional input inversions. The boomerang reduction tree computes:
ret = (a ^ xora) & ((b ^ xorb) | orb)
Where xora/xorb encode inversions and orb encodes pass-through (when
orb = 0xFFFFFFFF, input b is forced to 1, making the gate a buffer for a).
The AND gate has a favourable property for X propagation:
a
b
a AND b
0
0
0
0
1
0
0
X
0
1
0
0
1
1
1
1
X
X
X
0
0
X
1
X
X
X
X
A known-zero on either input forces the output to known-zero regardless of X
on the other input. This means X does not spread as aggressively through AND
logic as it would through XOR or MUX logic. AIG-based designs have a natural
tendency to “absorb” X values at AND gates with known-zero inputs.
NOT (inversion) simply preserves the X mask: NOT(X) = X, NOT(0) = 1,
NOT(1) = 0.
At AIG construction time, identify all X sources – signals whose initial
value is unknown:
DFF Q outputs: Every DFF output is an X source at cycle 0. In real
hardware, flip-flop power-on state is indeterminate.
SRAM read ports: All 32 data-output pins of each RAMBlock are X
sources. Memory contents are undefined until written.
Undriven primary inputs: If a primary input is not driven by the
testbench VCD, it should be marked X. (Currently Jacquard warns about missing
PI signals; this would upgrade that warning to X propagation.)
These are identified directly from AIG.drivers (variant DFF(_) and
SRAM(_)) and from AIG.dffs / AIG.srams.
Compute the forward cone of influence from all X sources through the AIG.
Since AIG pins are guaranteed to be in topological order, this is a single
linear-time forward pass:
x_capable = BitVec::zeros(num_aigpins + 1)
// Mark X sources
for each DFF:
x_capable.set(dff.q)
for each SRAM:
for each read data pin:
x_capable.set(pin)
// Forward propagate (pins are in topological order)
for aigpin in 1..=num_aigpins:
if let AndGate(a_iv, b_iv) = drivers[aigpin]:
a = a_iv >> 1 // strip inversion bit
b = b_iv >> 1
if x_capable[a] || x_capable[b]:
x_capable.set(aigpin)
This is O(V + E) on the AIG – negligible compared to partitioning.
Sequential propagation: X-capability also propagates through DFF feedback
loops. If a DFF’s D input is X-capable, its Q output remains X-capable (even
after the first clock edge, because it may have captured an X value). The
analysis iterates until a fixpoint:
loop:
changed = false
for each DFF:
d_pin = dff.d_iv >> 1
if x_capable[d_pin] && !x_capable[dff.q]:
x_capable.set(dff.q)
changed = true
if !changed:
break
// Re-run forward cone from newly-marked DFF Q outputs
In practice this converges in 1-2 iterations because most feedback loops
go through DFFs that are already marked.
After partitioning (mt-kahypar), classify each partition:
X-capable: Contains at least one X-capable aigpin, OR reads input state
from an X-capable partition’s output. Run with the X-aware kernel variant.
X-free: All signals are provably not-X. Run with the existing fast
two-state kernel.
The classification must account for inter-partition communication:
loop:
changed = false
for each partition P:
if P is already X-capable:
continue
for each global-read in P's script:
source_word = identify source partition and state word
if source partition is X-capable:
mark P as X-capable
changed = true
break
if !changed:
break
For X-capable partitions, each signal carries a sideband X mask alongside
its value:
v (value bit): The Boolean value. When x = 1, this is a “best guess”
(we use 0 by convention, matching Jacquard’s current behaviour for
backwards-compatible output).
x (X-mask bit): 1 = unknown, 0 = known.
This doubles the per-signal storage within X-capable partitions only.
State buffer (current):
[word 0] [word 1] ... [word N-1] ← N u32 words, 32 signals each
State buffer (with X sideband):
[word 0] [word 1] ... [word N-1] ← value words (unchanged)
[word N] [word N+1] ... [word 2N-1] ← X-mask words (new, same layout)
X-free partitions only read/write the value section. X-capable partitions
read/write both sections. The sideband occupies the same state-buffer address
space with a fixed offset of state_size words.
// Effective inputs after inversion and OR-bypass
a_eff = a_v ^ xora
b_eff = (b_v ^ xorb) | orb
b_eff_x = b_x & ~orb // OR-bypass forces bits to known-1
// Value: same as before (X bits treated as 0 in value lane)
ret_v = a_eff & b_eff
// X mask: result is X when both inputs are not-known-zero AND at
// least one input is X
//
// known_zero_a = ~a_x & ~a_eff (not X, and effective value is 0)
// known_zero_b = ~b_eff_x & ~b_eff
// ret_x = (a_x | b_eff_x) & ~known_zero_a & ~known_zero_b
//
// Expanded:
ret_x = (a_x | b_eff_x) & (a_eff | a_x) & (b_eff | b_eff_x)
The X-mask computation adds 5 extra bitwise operations per boomerang level
(2 OR, 2 AND, 1 AND-NOT – plus loading the X mask values). This is roughly
1.5-1.6x the CPU ALU work per gate, but only within X-capable partitions.
When signals cross from an X-capable partition to another partition:
X-capable -> X-capable: Both value and X-mask words are communicated
through the state buffer. The reading partition loads both.
X-capable -> X-free: At the boundary, we assert that no X values
cross. If an X-mask bit is set for a signal read by an X-free partition,
this is a simulation error (the design has an X reaching a region we
statically proved should be X-free). Report it and optionally halt.
X-free -> X-capable: The reading partition loads the value word normally
and treats the X-mask as all-zero for those inputs. No overhead.
After the design’s reset sequence completes, most DFFs will hold known values
and the X-mask will be all-zeros across most of the state. The simulator can
detect this:
every K cycles (e.g., K = 1000):
for each X-capable partition:
if all X-mask words in this partition's state are zero:
switch partition to fast two-state kernel
This gives the best of both worlds: full X-propagation during initialisation
(when it matters most), then automatic fallback to maximum throughput once
the design is in steady state.
For designs with a clear reset phase, this means the performance overhead
of X-propagation is confined to the first few thousand cycles.
The overall impact on a full simulation run is estimated at 5-15% throughput
reduction compared to current two-state, in exchange for catching
initialisation bugs that would otherwise escape to silicon.
A Two-State Methodology for RTL Logic Simulation (1999): Eliminated X entirely using random two-state initialisation, arguing it catches more bugs than X-optimistic RTL simulation. Their technique for handling Z-state boundaries is relevant to our boundary protocol.
Essent (Beamer, 2020-2021): Demonstrated that partitioning a compiled simulator by signal activity yields significant speedups. Their activity-proportional execution is analogous to our dynamic X narrowing – skip work for partitions where nothing interesting is happening.
Synopsys VCS T-Prop: Taint propagation in VCS uses a parallel sideband bit per signal to track data flow for security verification. Our X-mask is architecturally identical – a sideband bit that propagates alongside the value using different rules.
Chris Drake, “Improving Verilog Four State Logic” (2024): Argues that Verilog conflates “uninitialised” and “don’t care” uses of X, and proposes splitting them. Our approach naturally supports this: the X-mask tracks only uninitialised/unknown values, not synthesis don’t-cares (which are already resolved by the synthesis tool before Jacquard sees the netlist).
Z (high-impedance): Jacquard does not support tri-state buses and this
proposal does not add support. Z would require a third state bit and bus
resolution logic.
X-optimism in RTL control flow: Since Jacquard operates on gate-level
netlists (post-synthesis), there are no if/case statements to
be X-optimistic about. Gate-level X propagation through AND/OR truth tables
is naturally correct (though potentially X-pessimistic – see below).
X-pessimism reduction: Gate-level simulation is inherently X-pessimistic
in some cases (e.g., a XOR a should be 0 even if a is X, but
standard gate-level propagation gives X). Since AIGs decompose XOR into
AND/NOT, this pessimism is present. Addressing this would require symbolic
analysis or reconvergence detection, which is out of scope.
Strength modelling: No weak/strong drive strength tracking. All known
values are “strong.”
SRAM X granularity: Conservative whole-SRAM approach – all reads return
X until any write occurs. This is pessimistic but correct and simple.
Per-address tracking (requiring 8192 x 32 bits of shadow state per SRAM
block) is a potential follow-up for reduced pessimism.
Reset-aware analysis: Skipped for v1 – all DFF Q outputs start as X
regardless of whether they have async reset. Identifying reset nets is
fragile (varies by synthesis tool and coding style), and the fixpoint
iteration naturally resolves DFFs that become non-X after reset propagates.
VCD X output: Yes – write_output_vcd_xprop() emits Value::X when
the X-mask bit is set for a primary output. This is the primary user-visible
benefit. The vcd-ng crate already supports Value::X. Downstream tools
that only handle two-state VCD would need updating, but Verilog-standard
four-state VCD is widely supported.
Partition-level granularity: X-awareness is applied at partition
granularity (entire partition runs X-aware kernel or not). This provides
better steady-state performance than per-signal tracking when most
partitions are X-free (~95% typical), at the cost of slightly more
pessimism at partition boundaries.
Runtime CLI flag: X-propagation is controlled by --xprop on jacquard sim,
rather than a compile-time feature flag. No new Cargo dependencies are needed.
State buffer layout: When xprop is enabled, the state buffer doubles.
Value words occupy [0 .. reg_io_state_size) and X-mask words occupy
[reg_io_state_size .. 2*reg_io_state_size) per cycle. Per-partition
metadata word 8 stores the is_x_capable flag, and word 9 stores the
xmask_state_offset (equal to reg_io_state_size for X-capable
partitions). X-free partitions ignore both fields and run unchanged.
When you run jacquard cosim --xprop and a signal reads x (unknown), the
question is always why — which uninitialised state or undriven input is
feeding that X forward? This guide covers the two-command workflow that
answers it as a static query, without the trace→guess→re-run loop.
jacquard xsources — enumerate where X originates in the design.
netlist-graph xroots — given a signal, find which of those X-sources
reach it (its backward “X-root” frontier).
See Decision 0016 for the X-propagation
semantics and issue #98 for
the design rationale.
This builds the static AIG (no GPU) and writes a JSON manifest of every
X-source. --config is required only to classify undriven-input sources
(the driven-set complement); without it, only DFF and SRAM sources are
emitted.
net is jacquard’s canonical net name (after assign-merging), which may
differ from the raw name in the Verilog. cell is the driving instance
and is stable across tools — xroots resolves DFF/SRAM sources by cell, so
the naming difference does not matter.
The undriven-input classification reflects the config’s declared
drivers: clock(s), reset, constant_inputs, constant_ports, and each
peripheral’s pins (flash/uart/gpio/jtag). An input outside that set is
reported as undriven.
A reset-connected DFF is emitted as reset-dff. It is X at power-up but
resolves once reset asserts, so xroots traverses through it rather
than stopping (see below).
xroots walks backward from <signal> through the driver cone — and
through DFF data (D) pins, the reverse of the forward X-prop fixpoint —
skipping clock/set/reset pins. It stops at each persistent X-source
(unreset-dff / sram-read / undriven-input), reporting the nearest
frontier:
Reset DFFs (reset-dff) are traversed through (their data cone is
followed), because reset defines them — so the frontier surfaces the real
persistent roots behind them, not the reset flop itself.
If no X-source reaches the signal, xroots says so — under --xprop that
signal should be X-free.
xroots also runs without --xsources, classifying X-sources from the
netlist alone (DFF Q by reset-pin presence, SRAM reads, and genuinely
undriven internal nets). This cannot classify undriven-input sources (it
does not know the cosim driven set), so pass jacquard xsources --config
output when you need those.
The frontier is a static over-approximation: it reports X-sources whose
cone reaches the signal, not whether the X is live on a given cycle (an
unreset DFF may resolve once its own data cone settles from known inputs).
It tells you what to initialise/drive to make the signal defined.
Name round-tripping is exact for flattened post-synthesis netlists (the
target of these tools). DFF/SRAM sources resolve by cell instance, which is
robust; undriven-input sources resolve by name.
Dominator analysis (X-sources that every path passes through — guaranteed
roots) is a planned follow-up; v1 reports the reachable frontier.
jacquard cosim --jtag-server <PORT> opens a live
remote_bitbang JTAG socket alongside a running GPU
co-simulation. An external debugger — OpenOCD, then gdb on top of it —
attaches and inspects the design through its RISC-V Debug Module
(DM): halt/resume/step the hart, read/write GPRs, CSRs, PC and memory,
and load firmware — exactly as the same firmware would be debugged on
real silicon.
It is the interactive sibling of --jtag-replay.
Where --jtag-replay <FILE> plays back a recordedremote_bitbang
byte stream (deterministic, one-directional), --jtag-server <PORT>
opens a live socket and lets the connected client drive the same
configured TCK/TMS/TDI/(TRST) pins, stepping the design in lock-step with
each debug transaction and answering TDO reads from the design’s live
output.
The two are mutually exclusive — pick a recorded stream or a live
client, not both.
The JTAG TAP pins are declared once in sim_config.json, shared by both
the replay and server paths. TCK/TMS/TDI/TRST are design inputs; TDO
is a design output, so the server needs tdo_gpio to answer TDO
reads:
trst_gpio should be set for a RISC-V Debug TAP: the DTM resets on
negedge trst_n, and the server injects a one-shot power-on TRST pulse
at startup so a debugger that never asserts TRST (stock OpenOCD with
reset_config none) still resets the DTM — mirroring a real chip’s
power-on TAP reset. (Tunable/disable via the JACQUARD_JTAG_TRST_PULSE
env var; see How it works.) TAPs that genuinely reset via a
five-cycle TMS=1 sequence can omit it.
tdo_gpio is optional for --jtag-replay (replay never answers R)
but required for useful --jtag-server use: without it, every TDO
read returns 0 and the debugger cannot read the design back. cosim
warns at startup if it is missing.
The TCK clock domain must also appear in clocks[] so the multi-clock
scheduler allocates it a tick slot; the model overrides TCK at each byte
transition. See tests/jtag_minimal/sim_config.json for a complete,
working example.
cosim builds the design, binds 127.0.0.1:9999, and blocks waiting for
one client before the run starts:
JTAG server `jtag_0`: listening on 127.0.0.1:9999, hold_edges=8 (...);
waiting for a remote_bitbang client (e.g. OpenOCD)…
Pass --jtag-server 0 to bind an OS-assigned free port instead of a
fixed one — read the actual port from the listening on 127.0.0.1:<port>
line. Use this when several jacquard instances debug concurrently (or
in CI) so they never collide; if a fixed port is already taken, the bind
fails with an actionable error.
Once a client connects, the design steps forward one remote_bitbang
transaction at a time, paced by the client. By default a single
connection is served; when the client disconnects (or sends Q), the
session ends and the simulation free-runs to its edge budget.
Add --jtag-reconnect to keep the server alive across disconnects:
when the debugger detaches, the server preserves the design state and
waits to accept() the next client — so you can restart OpenOCD
without re-running the slow cosim setup (each fresh attach gets a clean
DTM reset). The run then never stops on its own; Ctrl-C/kill to end it.
Edge budget.--max-clock-edges does not stop the run while a
debug client is attached — the session is paced by the client and would
otherwise die mid-inspect. The cap is honoured again once the client
disconnects (so the post-session free-run is still bounded). You can
leave --max-clock-edges at its default for interactive use.
--irlen (default 5), --host, --chipname and --dmi-timeout are all
configurable; jacquard jtag-openocd-config --help lists them. The
emitted config wires up the remote_bitbang driver, a RISC-V Debug TAP,
and the target, and ends with init. For reference, the equivalent
hand-written config (mirroring tests/jtag_minimal/scripts/openocd.cfg):
adapter driver remote_bitbang
remote_bitbang host localhost
remote_bitbang port 9999
transport select jtag
adapter speed 1
set _CHIPNAME jtag
jtag newtap $_CHIPNAME cpu -irlen 5
set _TARGETNAME $_CHIPNAME.cpu
target create $_TARGETNAME riscv -chain-position $_TARGETNAME
# The JTAG protocol is bit-serial and the GPU server is per-edge while
# stepping — keep the DMI timeout generous.
riscv set_command_timeout_sec 60
Run it against the launched server:
openocd -f openocd.cfg
OpenOCD examines the TAP, brings up the DM (dmactive=1), and exposes a
gdb remote on :3333 by default.
The cosim main loop is synchronous (step_edge → run_edges → wait → repeat). A live debug session inverts time control: the client is
the clock source, so step_edgeblocking on a socket read is the
correct synchronisation — no async executor, no background thread.
While a client is connected the JTAG model reports is_active() == true, which forces the existing single-edge (batch=1) dispatch —
the same fine-grained stepping --jtag-replay already uses. Each cosim
edge processes one remote_bitbang step.
On each R (read-TDO) command the model samples the design’s live TDO
from the output state (output_state[tdo_pos]) and writes the ASCII
'0'/'1' back over the socket — the only response the protocol
requires.
TCK/TMS/TDI/TRST drive through the usual
overrides → BitOps → state_prep path, unchanged from replay.
Power-on TRST pulse. A RISC-V DTM resets on negedge trst_n. On
real silicon the power-on reset supplies that edge; in a recorded
capture the harness pulses TRST before the debugger runs. But stock
OpenOCD with reset_config none never asserts TRST — so without help
the DTM would never reset and examine would fail with “dtmcontrol is
0” / “scan chain interrogation: all zeroes”. The live server therefore
injects one brief TRST pulse at startup (deasserted → asserted →
released, timed in cosim edges), reproducing the recorded stream’s
leading u…r. Replay is unaffected (it drives TRST from the stream),
and an explicit client TRST assertion still wins after the pulse. The
window is [hold_edges, 5·hold_edges) by default; override or disable
it with JACQUARD_JTAG_TRST_PULSE="<from>,<to>" / ="off" (edges) if
a design needs different timing.
The batched fast path is untouched when no client is attached: a design
with a jtag config but no --jtag-server/--jtag-replay flag simply
leaves the JTAG inputs floating and runs at full throughput.
This works on any cosim backend (CPU / Metal / CUDA / HIP) — it is the
CPU-side model plus batch=1 of the GPU backend, with no per-backend
kernel work. See
Decision 0017 (Amendment 2026-06-21) for
the execution-model rationale and
Decision 0013 (Amendment 2026-06-21)
for the tdo_gpio config surface.
One client at a time. A single connection is served at once;
--jtag-reconnect accepts a new one after the previous disconnects.
Simultaneous clients and multi-tap chains are future work.
Performance. An attached session loses edge batching for its
duration — inherent and acceptable, since interactive debug is slow
relative to free-running simulation. Throughput is unaffected when no
client is attached.
X-propagation. Under --xprop, the debug read path is two-state
in v1 (TDO resolves through the design as 0/1). X-aware debug reads are
a planned refinement (J5 in the plan).
Reset interplay. The design’s own reset (reset_gpio) and the JTAG
TAP reset (driven by the client) proceed concurrently, mirroring real
hardware where the debugger drives JTAG while the chip resets.
A model-level loopback unit test
(live_source_loops_back_tdo_over_socket in src/sim/models/jtag.rs)
drives the FSM through a real TcpStream and reads back a sampled TDO
bit — GPU-free and deterministic, runs in cargo test.
The jtag-minimal-cosim-server CI job streams the samebitbang.rec the --jtag-replay gate uses over the live socket (via
tests/jtag_minimal/scripts/bitbang_client.py) and asserts the design
reaches the same data0_obs == 0xCAFEBABE. This pins live-vs-replay
drive equivalence with zero external tooling — but reaches
0xCAFEBABE via a DMI write, so it does not exercise a correct TDO
read.
The jtag-minimal-openocd CI job drives the live server with
real OpenOCD (tests/jtag_minimal/scripts/openocd_debug.sh):
examine reads IDCODE/DTMCS back over TDO, then it writes and reads
back DATA0, asserting IDCODE == 0xdeadbeef and the DMI read-back
== 0xcafebabe. This is the only gate that exercises the live TDO
read path (and the power-on TRST-pulse fix). Run it locally with any
port:
Fake hart.jtag_minimal is a bare DTM+DM with no real CPU, so
full RISC-V target examination stops at Failed to read MISA and
OpenOCD exits non-zero — expected. DMI access (write/read DATA0) is
the contract the gate checks. A design with a real hart examines
fully.
This guide documents the process of enabling a new process design kit (PDK) for
gate-level simulation in Jacquard. It is based on the SKY130 enablement and captures
every integration point.
Jacquard natively supports AIGPDK (its own synthesis library of AND gates, DFFs, and
SRAMs). Supporting a foundry PDK like SKY130 requires teaching the simulator how
to interpret the PDK’s standard cells: their pin directions, their boolean
function, and which ones are sequential.
There are three pathways for enabling new cells; pick based on what
you’re adding:
Cell-model IR descriptor (recommended — the modern path, decision
0019). A standard-cell library — pin directions, boolean functions,
sequential roles, and timing — is captured in one generated JSON
descriptor produced from the library’s Liberty by the
liberty-to-cellir converter. Jacquard consumes the descriptor at
runtime; no per-PDK Rust is required. This is how a brand-new PDK is
added (vendor the library + generate a descriptor) and how a
proprietary library you cannot vendor is simulated (--cell-descriptor).
See “Pathway A” below. This supersedes the legacy Rust workflow
(Steps 1–8) for all combinational logic today, with sequential
consumption completing across the built-in PDKs in the Decision 0019 C3
series.
Runtime cell library (--cell-library + .cells.toml
manifest). For third-party IP, hard macros, foundry memories, and
any other cells that don’t need new AIG decomposition rules — i.e.
cells that act as opaque outputs (RAM macros), filler/cap blocks,
or IO pads. See Decision 0010 and docs/plans/archive/declarative-cell-metadata.md
for the recipe. No Jacquard PR required — users ship a manifest
alongside their netlist. See “Adding third-party IP via runtime
manifest” at the end.
Legacy per-PDK Rust (Steps 1–8 below). The original pathway: pin
tables, classifiers, decomposition functions, and AIG builder hooks
hand-written in Rust. Being retired by the cell-model IR cutover
(Decision 0019) — retained here as reference for the machinery the
descriptor replaces. Do not add a new PDK this way; use Pathway A.
If you’re adding just a memory macro or other behaviourally-opaque IP,
skip ahead to “Adding third-party IP via runtime manifest” at the
end of this document — it’s a 6-line TOML entry, not a Rust PR.
A cell-model IR descriptor is a portable, versioned, generated JSON
file carrying everything per-cell-type about a library — L1 pin
directions, L2 combinational logic (as a pre-decomposed AIG), L3
sequential roles/classification, and L4 timing characterization — from
one source: the library’s Liberty. Adding a PDK is no longer a Jacquard
code change; it is a data generation step. (Decision 0019.)
The liberty-to-cellir converter reads a Liberty .lib (Liberty-first;
a functional .v is consulted only as a fallback / cross-check) and emits
the descriptor:
The converter derives the corner (PVT) from the Liberty’s
operating_conditions/default_operating_conditions, compiles each cell’s
function into an AIG, extracts ff/latch sequential roles, and (where a
.v is present and per-cell) cross-checks logic + timing-arc topology,
surfacing any disagreement. It prints a summary
(cells=… combinational=… l3_sequential=… l4_timing=… corners=… cross_check_mismatches=…). A monolithic single-.lib-per-corner
commercial library and a per-cell split library are both handled.
Each descriptor declares the cell-name prefix(es) it covers (D8).
Jacquard auto-matches a netlist’s cell types against the bundled
descriptors by prefix; --cell-descriptor <file.json> is the explicit
override (and the path for proprietary libraries). Precedence:
--cell-descriptor (explicit file) > --bundled-descriptor <name>
(explicit bundled) > auto-match by prefix > the default-fallback
descriptor (used when no vendor prefix matches — this is how AIGPDK, whose
cells have no common prefix, is selected).
Generate + embed at build time.build.rs runs the converter over
the pinned vendored .lib and embeds the descriptor into the binary
(descriptors are not checked in — CI regenerates them
deterministically, D7). Add the descriptor to bundled_descriptors::ALL
with its declared prefix (sg13g2_).
That’s it. A SG13G2 netlist auto-selects the descriptor by prefix;
combinational cells splice from it with no IHP-specific code. Adding the
PDK touched no ihp_*.rs — only a submodule, a build.rs generation
line, and a registry entry.
A foundry/NDA library that can never live in vendor/ is simulated
entirely at runtime — the Liberty never leaves your machine:
# 1. Generate a descriptor from your own Liberty (offline, on your machine):
cargo run --release --manifest-path crates/liberty-to-cellir/Cargo.toml -- \
/path/to/pdk-gf130/.../GF013bcd_sc6_1p5_a0_TT_1P50V_25C_max.lib \
--functional-v /path/to/pdk-gf130/.../GF013bcd_sc6_1p5_a0.v \
-o gf130.cellir.json
# 2. Point jacquard at it — no Jacquard build, no vendoring:
jacquard sim my_chip.gv stim.vcd out.vcd 1 --cell-descriptor gf130.cellir.json
The generator is the only tool that touches raw foundry files; the
released jacquard binary is library-agnostic. (A --corner <name> flag
selects among a multi-corner descriptor’s corners, defaulting to the
descriptor’s default_corner.)
Combinational logic is fully descriptor-driven for every built-in PDK
(GF180, SKY130, AIGPDK, IHP) and for proprietary libraries via
--cell-descriptor.
Sequential consumption is descriptor-driven for GF180 today and is
being wired for the other PDKs in the Decision 0019 C3 series; the descriptor
already carries the L3 sequential data for all of them.
A library whose functional .v is a single flat-module file (common for
commercial PDKs) skips the optional .v logic cross-check at generation
(the descriptor is still emitted; validate via simulation) — improving the
flat-.v indexer is tracked follow-up.
RAM/SRAM macros are not covered by the descriptor — they use the
runtime manifest (Decision 0011), below.
This section documents the original hand-written-Rust workflow, which the
cell-model IR (Pathway A, above) is retiring under Decision 0019. It is kept as
a reference for the machinery the descriptor replaces and for the
still-live paths during the cutover. To add a new PDK, use Pathway A —
do not write per-PDK Rust. The RAM/macro manifest section that follows
Step 8 remains fully current.
The CellLibrary enum tracks known libraries. detect_library() iterates cell
names and returns the detected library (or Mixed if cells from multiple
libraries are found – this is an error).
For a new PDK: Add a variant to CellLibrary, write an is_<pdk>_cell()
function, and update detect_library().
For SKY130, direction_of() is a large match statement covering ~80 cell types
with all their pin names. This is tedious but straightforward – for each cell,
list which pins are inputs and which are outputs.
Sources for pin directions:
The PDK’s Liberty (.lib) files list pin directions
The PDK’s behavioral Verilog models declare input/output ports
LEF files also contain pin direction information
For a new PDK: Implement the trait for all cells that appear in your target
netlists. You can start with just the cells used in your design and add others
as needed.
These are handled specially in the AIG builder – their outputs become state
elements rather than combinational logic.
Critical: Use an explicit whitelist, not prefix matching. PDK naming
collisions will silently break simulation if you guess wrong (e.g., SKY130’s
dlygate4sd3 starts with “dl” but is a combinational delay buffer, not a
latch).
Derivation method: Grep the PDK’s behavioral Verilog models for DFF/latch
primitives:
for cell in $(ls vendor/<pdk>/cells/); do
vfile="vendor/<pdk>/cells/$cell/<pdk>__${cell}.behavioral.v"
if [ -f "$vfile" ] && grep -qE 'udp_dff|udp_dlatch' "$vfile"; then
echo "$cell"
fi
done
For PDKs that don’t use Verilog UDPs, look for always @(posedge blocks or
check the Liberty file’s ff and latch groups.
Cells with more than one output (e.g., half-adder ha with SUM and COUT,
full-adder fa). These need special handling because the AIG builder processes
one output pin at a time.
These are gate-level Verilog using primitives like and, or, nand, nor,
not, xor, xnor, buf. The parser (parse_functional_model()) extracts
these into a topologically-ordered list of BehavioralGate structures.
Some cells (typically muxes) use Verilog User-Defined Primitives with truth
tables. The parser (parse_udp()) converts these to a row-based representation,
which is then evaluated as sum-of-products during AIG decomposition.
Only models for cell types actually present in the design are loaded. Sequential
cells are skipped (their behavior is hardcoded in the AIG builder). Tie cells
are also skipped (constant generation is trivial).
For a new PDK: If the PDK uses the same Verilog gate primitive syntax, the
existing parsers should work. If it uses behavioral Verilog (assign statements,
always blocks), the parser would need extension.
CellInputs has named fields for all possible input pins across all SKY130
cells (A, B, C, D, A_N, B_N, S, S0, S1, CIN, SET_B, RESET_B, etc.). The
set_pin() method maps netlist pin names to AIG pin indices.
For a new PDK: If the PDK introduces pin names not in the current struct,
add new fields.
Combinational cells: Call decompose_with_pdk() and wire the resulting
AND gates into the AIG
For a new PDK: The three-phase structure is reusable. You need PDK-specific
implementations of each phase that handle the new cell types’ pin names and
reset/set conventions.
Cell type extraction: Verify prefix/suffix stripping
Pin directions: Spot-check common cells
Behavioral model parsing: Parse each cell type, verify gate count
Decomposition correctness: For each combinational cell, exhaustively
test all input combinations against the PDK’s truth table:
#![allow(unused)]
fn main() {
#[test]
fn test_all_cells_vs_pdk() {
let pdk = load_test_pdk();
for (cell_type, model) in &pdk.models {
// For each input combination:
// 1. Evaluate behavioral model directly
// 2. Decompose to AIG and evaluate AIG
// 3. Assert outputs match
}
}
}
This test exists in src/sky130_pdk.rs as test_all_cells_vs_pdk and
covers every combinational cell against every input combination.
Cell name collisions: Do not use prefix matching for cell classification.
dlygate4sd3 starts with “dl” but is not a latch. Always derive the
exhaustive list from behavioral models.
Active-low vs active-high resets: SKY130 uses active-low RESET_B and
SET_B. Other PDKs may use active-high. Get this wrong and every DFF will
be stuck.
Multi-output cells: The AIG builder processes one output pin at a time.
If a cell has both Q and Q_N outputs (e.g., dfbbp), the second output
must be derived from the first (Q_N = NOT Q), not decomposed independently.
Liberty file size: SKY130’s liberty files are 12MB+. If your PDK has
similarly large files, ensure the parser doesn’t OOM or timeout.
Power/ground pins: Post-layout netlists often include VPWR/VGND pins.
Use the unpowered netlist variant (.nl.v not .pnl.v in OpenLane2) or
handle power pins as constants in the pin provider.
Hold-time repair buffers: P&R tools insert delay buffers (like
dlygate4sd3) that must be treated as combinational. If your PDK’s delay
cells have names that collide with sequential cell prefixes, the whitelist
approach prevents misclassification.
If you’re adding a memory macro, IO pad, hard block, or filler library
— anything that doesn’t need new AIG decomposition rules — the runtime
cell-library pathway (Decision 0010) is the right route. No Jacquard PR
required. Ship a Verilog blackbox file plus a TOML manifest alongside
your design.
The blackbox just declares the cell’s module + port directions. The
foundry typically ships this (<library>__blackbox.v). Example for the
OCD GF180MCU SRAM:
There are two modes depending on whether the manifest includes a
[cells.NAME.ram] port-mapping sub-table:
Opaque mode (no ram sub-table, schema v1.0+): the cell’s
output pins become X-source slots in the AIG. The SRAM’s internal
memory behaviour is not modelled. Sufficient for designs whose
CPU executes from boot ROM / register file and never reads SRAM
contents at the timescales Jacquard simulates.
Explicit-port mode (with ram sub-table, schema v1.1+, decision
0011): outputs are wired to a real AIG-backed RAMBlock, writes
populate per-entry storage, reads return what was written. Real
memory modelling end-to-end. Use this when the CPU reads its own
SRAM (the common case for any design beyond heartbeat
verification).
Schema (full example, mirroring the upstream OCD GF180MCU SRAM):
Field semantics, defaults, and the multi-port-SRAM/async/wider-than-32-bit
out-of-scope items are documented in
Decision 0011. Polarity defaults
to low; clock edge defaults to pos; mask granularity defaults
to bit. All three control pins (chip_enable / write_enable /
write_mask) are optional — omit them for sync SRAMs without
those signals.
The ELF’s PT_LOAD segments are packed into the SRAM’s backing
storage before tick 0; the lowest loadable virtual address is taken
as SRAM address 0. Single-SRAM designs only — multi-SRAM
instance-targeting is a future schema extension (issue #80).
filler, endcap, tap — physical-only, contribute no logic.
io_pad_input / io_pad_output / io_pad_bidir — pad-level
behaviour (parallel to the built-in gf180mcu_ws_io__* family).
dff, latch, clock_gate, delay, multi_output — recognised
but the v1.0 schema doesn’t yet expose enough port semantics to
drive AIG construction for these. Coming in the port-mapping
schema (future decision). For now, declaring these kinds documents
intent without changing behaviour.
NEW: GEM now automatically detects the correct VCD scope containing your design’s ports. In most cases, you don’t need to specify --input-vcd-scope manually.
WARN (GATESIM_VCDI_MISSING_PI) Primary input port (HierName(), "reset", None) not present in the VCD input
WARN (GATESIM_VCDI_MISSING_PI) Primary input port (HierName(), "din", Some(3)) not present in the VCD input
GEM expects VCD signals at absolute top-level with no module hierarchy prefix. The signal names must exactly match the synthesized module’s port names.
Diagrams in the docs are written as D2 text and rendered to
inline SVG at build time. They are theme-adaptive: colours and fonts come from the
active mdBook theme, so a diagram reads correctly in light, navy, coal, ayu, and
rust, and recolours on a live theme switch. This page is the how-to; the why
(D2 over mermaid, the layout choices) is in
the redesign spike.
```d2
vars: { d2-config: { pad: 16 } }
direction: down
a: First box
b: Second box
a -> b: does a thing
```
The mdbook-d2 preprocessor (configured
in book.toml) shells out to the d2 binary and replaces the block with an inline
SVG. Nothing else is needed — no image files, no per-diagram wiring.
These keep diagrams legible and consistent. They come out of the spike; follow them
unless you have a specific reason not to.
Lay regions out vertically; reserve horizontal flow for genuine pipelines.
On-screen text size is the native font scaled by
content_column_width / diagram_native_width — a wide diagram shrinks its text
into an unreadable hairline when fit to the column. A tall, narrow diagram keeps
text near body size and scrolls vertically, which is natural on the web. Set
direction: down at the top level and only use direction: right inside a
container that is a real left-to-right pipeline.
One abstraction level per diagram. A single overview, then per-stage detail
if needed — never one diagram that tries to show everything.
Show what, not why. The rationale belongs in the decision record the
diagram’s page links to, not in the picture.
Keep pad small.vars: { d2-config: { pad: 16 } } trims D2’s default 100px
canvas border (mdbook-d2 exposes no pad config, so set it in the source).
Do not set colours or fonts in the D2 source. The stylesheet owns them (next
section). The one exception is a sentinel fill used as a colour hook.
The global layout engine is elk (set in book.toml), which handles containers
and edge routing better than the default dagre.
D2 bakes one fixed palette into the SVG. theme/d2-diagrams.css repaints it from
the active mdBook theme’s CSS variables (--fg, --bg) — author CSS overrides the
SVG’s baked fill=/stroke= attributes, so no SVG post-processing is needed. The
same stylesheet strips the <pre> code-block chrome that mdbook-d2 wraps each SVG
in, and scales the diagram to the content column.
Three details in that stylesheet are load-bearing, and each fixes a real bug — know
them before you touch it:
Shape colours need !important. D2 sets shape colour through an embedded<style> using .d2-<hash> .stroke-B1-style selectors, specificity (0,2,0),
with a per-diagram hash we can’t match directly. Element selectors score lower
and lose, so shape fill/stroke overrides use !important. (Text scores high
enough via .text to win without it.)
Match rect[class], never bare rect. D2’s connection arrows use an SVG
<mask> whose unclassed <rect>s mean “visible here / hole there”. A bare rect
rule repaints those mask rects too, turning the mask opaque and clipping every
connection line to nothing — invisible arrows whose stroke still computes
correctly. Rendered shapes always carry a D2 colour class; mask rects never do,
so rect[class] skips them.
Region accents use a sentinel fill. D2 gives no stable per-shape hook (user
classes aren’t emitted; the auto-generated classes are base64 of the shape path).
To colour a specific region, set a sentinel hex in the D2 source
(style.fill: "#4f8bff") and remap it in CSS
(rect[fill="#4f8bff" i] { fill: rgba(...) !important; ... }). The remap is a
translucent tint, which is what makes it read over both light and dark
backgrounds. The cosim diagram uses #4f8bff / #3cbe78 / #aa78f0 for its
three regions.
A D2 PNG render (d2 file.d2 out.png) shows the baked palette, not the themed
result, so it cannot confirm the styling. Render the built page in headless Chrome
instead:
To force a theme, seed localStorage before mdBook’s boot script runs by injecting
<script>localStorage.setItem('mdbook-theme','navy')</script> right after <head>
in a copy of the built HTML. To read the computed style of an element (e.g. to
confirm a connection line’s stroke actually resolved), inject a getComputedStyle
probe that writes the result into document.title and read it back with
--dump-dom. This is how the invisible-arrows bug was found — the PNG render hid it
completely.
You need the Rust toolchain (2021 edition) and the GPU SDK
for the backend you’re building. Nothing else.
git submodule update --init --recursive
The submodule init is load-bearing, not boilerplate — and its failure is
silent.build.rs bakes the standard-cell pin tables into the binary by
reading the vendored PDK cell libraries (vendor/sky130_fd_sc_hd,
vendor/gf180mcu_fd_sc_mcu7t5v0, vendor/gf180mcu_fd_sc_mcu9t5v0). If one of
those submodules is absent, build.rs skips it and the build succeeds —
but the resulting binary carries an incomplete pin table and panics at
runtime on any netlist using an omitted cell (Unknown SKY130 cell (no pin-table entry for type '…')), which looks like a Jacquard bug rather than a
missing checkout. So the --recursive above is required, and any script that
inits submodules selectively — a CI matrix that only needs
vendor/eda-infra-rs, or a historical-build / bisect harness — must also init
the PDK cell submodules, or it will silently produce a crippled binary.
The GPU backend is a feature, and you pick exactly one:
cargo build -r --features metal --bin jacquard # macOS, Apple Silicon
cargo build -r --features cuda --bin jacquard # NVIDIA, CUDA toolkit
cargo build -r --features hip --bin jacquard # AMD, ROCm
Two features are worth knowing about:
synth adds the embedded YoWASP Yosys engine, which is what lets
sim/cosim take behavioral RTL instead of a gate-level netlist (see
Accepted RTL Surface). It costs real compile time —
wasmtime and cranelift — so it is opt-in for local builds, and on for release
binaries. Combine it with a backend: --features metal,synth.
No GPU feature at all still builds dump-paths, so timing analysis and
netlist validation work on a machine with no GPU SDK installed.
cargo test # library + integration tests, no GPU needed
cargo bench --bench event_buffer # criterion micro-benchmarks, no GPU needed
cargo bench --bench xprop
For the GPU path, two flags matter more than the test suite:
jacquard sim ... --check-with-cpu # run a CPU baseline and compare
jacquard sim ... --max-clock-edges 1000 # bound a long run while bisecting
# (edges, not cycles: 2 per cycle)
--check-with-cpu is the one to reach for when a kernel change produces
plausible-looking waveforms: it is the difference between “it ran” and “it is
right”.
Benchmark designs (NVDLA, Rocket, Gemmini) live in benchmarks/dataset/, a
submodule. NVDLA is the smallest and the usual first thing to try.
A GPU block is a fixed budget, and src/pe.rs has to fit each partition inside
it:
at most 8191 unique inputs and 8191 unique outputs per partition
(for SRAMs and DFFs, outputs count every enable and bus pin, and holes mean
the effective capacity can be as low as 4095);
at most 4095 intermediate pins alive at any stage;
at most 64 SRAM output groups — that is 8192 / (32 × 4).
When a design doesn’t fit you get single endpoint cannot map. The answer is
usually --level-split, which forces more stages so each one is smaller:
Each of these is needed by one workflow and nothing else. Editing Rust, C++ or
kernel sources needs none of them: the timing-IR bindings are checked in, and
OpenSTA is only for the timing-correctness corpus.
Tool
Used for
macOS (Homebrew)
Linux (Debian/Ubuntu)
flatc
regenerating timing-IR bindings when editing crates/timing-ir/schemas/timing_ir.fbs
brew install flatbuffers
apt install flatbuffers-compiler
mdbook
building the docs locally
brew install mdbook
cargo install mdbook
OpenSTA
building vendored vendor/opensta/ for opensta-to-ir and the timing-correctness CI corpus
brew bundle --file vendor/opensta/Brewfile, then scripts/build-opensta.sh
see vendor/opensta/Dockerfile.ubuntu22.04, then scripts/build-opensta.sh
Python tooling (PDK fetchers, harness utilities) belongs in the workspace’s uv
dev group rather than an ad-hoc pip install: add it under [dependency-groups]
in the root pyproject.toml, uv sync --group dev, and run it with uv run.
SUMMARY.md is the table of contents, and a page missing from it isn’t rendered
at all — which is why docs/scripts/check_doc_links.py validates links against
the rendered page set rather than against files on disk. Run it before pushing
docs; CI does too.
The published site keeps main’s docs at the root and a frozen copy of each
release under /vX.Y.Z/, with a picker to move between them. Release notes link
to the pinned copy, so a link in an old release keeps meaning what it meant. See
Release Process.
Decisions record choices worth
understanding later. A record is immutable and past-tense: when a decision
changes, a new record supersedes the old rather than rewriting it. A record’s
status is a claim about the code, so check it against the code. Current state
lives in the architecture reference, not the record.
Plans hold work in progress; deferred work gets a plan
and an issue rather than being lost.
Handoffs are working memory, not history. One per
active thread, folded into the decisions or plans and deleted when resolved.
Prose style is the house style for the docs’ writing.
Release Process covers cutting a release, the RC-first
flow, and what’s automated.
The through-line: a sentence that says how the tool behaves is a verifiable
claim. That applies to docs, --help text, and a decision’s status alike; check it
against the code before writing it.
House style for Jacquard’s prose docs: the docs/ book, decision records, and
plans. It covers how sentences read. How docs are structured — the reference /
decision / spike split, the three kinds of claim, stable-ID cross-links — lives in
the decisions README and the
redesign spike; this
page is only about the writing.
Be direct and have opinions. State the point first, then support it. Use specific
names and numbers, not vague claims (BATCH_SIZE = 1024, not “a fixed batch size”).
Trust the reader to see what matters without labels like “significant” or
“important”. Write to be understood by a non-native speaker and by an LLM reading a
single retrieved section, so keep each section self-contained and restate its
subject rather than leaning on “as described above”.
A present-tense sentence in a reference doc is a verifiable claim about the code —
check it before writing it, and keep it current when the code moves. Rationale is
past-tense and lives in decision records, which don’t get rewritten when the code
changes. This is the whole point of the layer split; see the decisions README.
Vary sentence and paragraph length. Don’t write uniform blocks.
Avoid mid-sentence interjections. Don’t bracket a qualifier inside a sentence
with paired dashes or parentheses. Promote it to its own sentence or move it to
the end. Write “Only X-capable partitions run the X-aware kernel. In a typical SoC
after reset, under 5% are X-capable.” — not “partitions — typically under 5% after
reset — run the kernel”.
A single dash introducing an appositive or definition is fine, and is the house
pattern for naming a thing: “a hierarchical binary tree — the boomerang”. Keep it
to the end of a clause, not the middle of one.
Lightweight by design. Jacquard is a single-binary Rust project with
vendor/ submodules; releases are git tags + a CHANGELOG entry. No
crates.io publication, no pre-built binaries (until/unless that
demand surfaces).
SemVer, starting once the first numbered
release ships. Pre-1.0 versions (0.x.0) carry the standard SemVer
caveat: minor bumps may include breaking changes; the public
contracts (--timing-report schema, IR layout) are documented in their
own decisions and follow stricter rules.
Stable contracts (additive-only, breaking changes require a major
bump and a deprecation window):
--timing-report JSON schema — src/timing_report.rs::SCHEMA_VERSION,
governed by Decision 0008.
Timing IR FlatBuffers schema — crates/timing-ir/schemas/timing_ir.fbs,
governed by Decision 0002.
CLI flags, log message formats, and --timing-summary text output are
not stable parseable contracts; consumers that need to script
against them should use --timing-report JSON.
Verify CI is green on main for all three GPU backends (Metal,
CUDA, HIP) plus the unit-test, opensta-to-ir, and lint jobs. If any
GPU runner is offline, hold the release until it’s restored — see
.github/workflows/ci.yml. Do not
ship a binary the CI hasn’t built.
Roll the [Unreleased] section in CHANGELOG.md
into a numbered version block. Format follows
Keep a Changelog. Update
the link references at the bottom of the file. Leave a fresh empty
[Unreleased] section at the top.
Bump the Rust crate version to match:
python3 scripts/bump_version.py <X.Y.Z>. The three first-party
Rust crates (jacquard, opensta-to-ir, timing-ir) ship together
in one tarball and carry a single shared version, so this script sets
all three at once — never edit their [package].version by hand.
Then cargo build to update Cargo.lock. (netlist-graph versions
independently — see its own netlist-graph-v* tag flow.) The release
workflow re-runs bump_version.py --check <tag> as a verify-guard and
aborts before publishing if the tag and the crates disagree.
Commit:chore: release v<X.Y.Z> with the standard
Co-developed-by trailer.
Tag:git tag -a v<X.Y.Z> -m "v<X.Y.Z>" then git push --tags.
Create a GitHub release from the tag. Body = the CHANGELOG
section for that version. No artefacts attached unless someone has
asked for them.
The Homebrew formula is auto-bumped by release.yml — no manual step
(this closes the drift that had left the tap stale at 0.2.3). On every
release tag the bump-tap job rewrites packaging/homebrew/jacquard.rb’s
url/version/sha256 from the just-published tarball + .sha256 and
pushes Formula/jacquard.rb to the tap:
final release → gpu-eda/homebrew-tap (brew install gpu-eda/tap/jacquard);
prerelease (RC) → gpu-eda/homebrew-tap-prerelease
(brew install gpu-eda/tap-prerelease/jacquard), so RCs are
brew install-able for staging without touching the stable channel.
packaging/homebrew/jacquard.rb is the template — edit it only to change
the formula’s structure (deps, install, test); its version pin is a
placeholder the job overwrites. Requires the one-time org setup:
secrets.HOMEBREW_TAP_TOKEN (a token with contents: write on both tap
repos) and the gpu-eda/homebrew-tap-prerelease repo.
Release notes come from the CHANGELOG, and doc links are pinned to the release
— both automatic in release.yml:
Notes body = the CHANGELOG section for the tag’s version. A prerelease
(X.Y.Z-rc.N) has no dated section, so the extractor falls back to
[Unreleased] — RCs ship the same curated draft you’ll ship at promotion.
So: write the notes in [Unreleased].
Lead with a user-facing overview. Before the technical ### Added /
### Changed sections, open with a short “What this means for you” block —
a few benefit-framed bullets (what a user can now do, not just what changed).
The technical changelog then gives the detail. This becomes the release intro
and is the first thing a reader sees.
Doc links are version-pinned. The extractor rewrites `docs/foo.md`
references into [docs/foo.md](https://gpu-eda.github.io/Jacquard/<tag>/foo.html)
— the mdBook page frozen for this release. So keep CHANGELOG doc references
as repo-relative `docs/foo.md` (clean in-repo); the workflow does the
URL rewrite.
Versioned docs deploy. The docs-version job publishes the book to
gh-pages /(tag)/ on every release tag (keep_files: true), while the
main push keeps deploying “latest” to the site root. Version subdirs
accumulate side by side; the pinned links above resolve to them.
The version picker (theme/version-picker.js) reads versions.json at
the site root and lets a reader move between main’s docs and the frozen
releases. main is the default and stays at the root, so anyone arriving
without a version in the URL reads main HEAD; a pinned build is flagged in the
control, since someone who followed a link out of a release note has no other
cue that the page is frozen. versions.json is regenerated after each deploy
by docs/scripts/refresh_doc_versions.sh, derived from the directories actually
published rather than accumulated — so pruning an old version directory also
removes it from the picker, and the control can never offer a 404. Release
candidates are deliberately excluded: their docs stay published (RC notes link
into them) but they would crowd out the releases people want.
Optional but recommended before a user-facing release: prove the
documented install commands work against a staging artifact before
promoting. There is no “test crates registry”, so a GitHub prerelease
is the staging tier for the binary channels.
Sequence matters — do NOT roll main to the final version before the RC
validates. During the RC window mainis the candidate: it stays at
X.Y.Z-rc.N with the changelog still under [Unreleased]. Only the
Promote step (below) rolls [Unreleased] → [X.Y.Z] and bumps to the
final X.Y.Z. Rolling the release onto main first leaves main
advertising a version that has no release — the version pin and dated
changelog claim X.Y.Z is shipped while only the RC tag exists, which also
breaks cargo binstall --git (it reads main’s version and fetches a
non-existent vX.Y.Z tarball). Each new RC is a main commit bumped to the
next -rc.N; main == the latest RC tag throughout.
Cut an RC. On main at X.Y.Z-rc.N (changelog under [Unreleased]),
python3 scripts/bump_version.py <X.Y.Z>-rc.<N>, commit, tag
v<X.Y.Z>-rc.<N>, push the tag. release.yml detects the SemVer
pre-release suffix and publishes a GitHub prerelease (never shown as
“Latest”) with the Metal tarball attached, and bump-tap pushes the
formula to gpu-eda/homebrew-tap-prerelease.
Validate. Dispatch the Validate install (staging) workflow
(validate-install.yml) with that tag. It runs the real install
commands against the prerelease asset on macOS:
cargo binstall (asset-fetch via the [package.metadata.binstall]
override, compile fallback disabled so a missing asset fails hard);
brew install of an RC formula (the source-of-truth formula repointed
at the prerelease tarball + its .sha256, installed from a throwaway
local tap).
Promote. A green run means the channels work. Bump to the final
<X.Y.Z> (drop the -rc.<N>), commit, tag v<X.Y.Z>, push — the same
flow as a normal release below.
The netlist-graph (PyPI) channel validates separately via its own
workflow_dispatch → TestPyPI path (see publish-netlist-graph.yml); it
versions independently of the Rust crates.
Submodule pins (unless deliberately bumping a vendored dep).
The vendor/opensta/ submodule pin is the version named in
crates/opensta-to-ir::MIN_TESTED_OPENSTA_VERSION. If you bump the
submodule, also bump the constant and the version-probe test — see
WS-RH.1 in docs/plans/post-phase-0-roadmap.md.
WS-RH.1 (OpenSTA detection + version check) shipped.
Metal CI on macos-runner-1 green (re-enabled in commit 12e98df,
2026-05-12).
CUDA CI on nvidia-runner-1 green on main. Currently
disabled in .github/workflows/ci.yml (if: ${{ false }},
~line 268). Re-enable when hardware lands.
HIP CI on the AMD runner green on main. Currently disabled
in .github/workflows/ci.yml (if: ${{ false }}, ~line 357).
Re-enable when the AMD runner is online.
Prebuilt CUDA/HIP binaries (Decision 0018 Phase 4), when produced, must
build with JACQUARD_CUDA_ARCH=all-major so the kernel ships portable
SASS for every major arch (sm_50…sm_120 on CUDA ≥ 12.8, Blackwell
included) plus PTX for the newest — see the README § CUDA target
architecture. Local dev uses JACQUARD_CUDA_ARCH=native instead. The
nvidia1.local Blackwell box (sm_120, CUDA 12.8) is a candidate
self-hosted CUDA release runner.
Vendored-dep license posture confirmed
(gzz2000/eda-infra-rs#2
— sverilogparse AGPL declaration acknowledged as a typo; workspace
Apache-2.0 governs).
Cargo.toml::license = "Apache-2.0" set.
NOTICE file enumerating vendored deps + their licenses.
Bump vendor/eda-infra-rs submodule once upstream pushes the
sverilogparse Cargo.toml correction; remove the corresponding
footnote in NOTICE. Maintainer acknowledged the typo on
2026-05-02 but hasn’t pushed the fix as of 2026-05-13. Verify
with git -C vendor/eda-infra-rs fetch && git log origin/master --oneline.
CUDA / HIP runtime violation routing through
process_events — done (commit 24723b5, issue #104). sim_cuda /
sim_hip now dispatch the timed-batched path and drain violation
events, so --timing-report / --timing-summary / --timed are no
longer Metal-only.
Project license is Apache-2.0 (LICENSE). Vendored-dep posture is
enumerated in NOTICE. Summary:
vendor/eda-infra-rs/ — Apache-2.0 (workspace). The sverilogparse
sub-crate’s stale AGPL-3.0-only declaration in Cargo.toml is a
typo per upstream maintainer
(gzz2000/eda-infra-rs#2);
governed by the workspace LICENSE. Submodule pin will be bumped when
upstream pushes the correction.
vendor/sky130_fd_sc_hd/ — Apache-2.0.
vendor/opensta/ — GPL-3 (subprocess only per Decision 0001 + Decision 0006
§ Amendment; never linked, never bundled).
Handoffs in this project are ephemeral working memory, not historical record. They exist to bridge a single session boundary — when you stop working and someone else (Claude or human) picks up — and they are deleted once the work they describe is resolved.
This document defines what a handoff is, what it isn’t, when to write one, and exactly what to do when one is resolved.
Decision rationale, technical context, and project state all have natural homes:
Decisions (docs/architecture/decisions/) capture architectural choices and their why.
Design docs (docs/timing-model-extensions.md, etc.) capture how things work.
Plan docs (docs/plans/archive/phase-0-ir-and-oracle.md, post-phase-0-roadmap.md) capture what’s left and the next workstream slices.
When that content lives in a handoff instead, two things go wrong:
It’s not where contributors look. A new contributor reading the README → SUMMARY → decision chain shouldn’t have to dig through a stack of resolved handoff docs to find load-bearing decisions or the current state of a workstream.
It rots out of sync with reality. Handoffs are point-in-time snapshots. A “STATUS: RESOLVED” banner doesn’t help when the thing referenced has moved or changed; the canonical doc is what should hold the current truth.
The discipline closes this gap by forcing migration before deletion. Every load-bearing piece of a handoff lands in its proper home (decision / design doc / plan doc) before the handoff file is removed.
A handoff lives in its own dedicated directory, separate from the persistent plan docs whose content it eventually feeds: a single markdown file at docs/handoffs/<topic>-handoff.md containing exactly what the next session needs to pick up where you left off:
Goal & next-up — what this session was trying to do, and what the very next concrete action is.
Done this session — commits landed, with one-line summaries.
Open follow-ups — the work that wasn’t done, with enough scope detail to start cold.
Critical context — gotchas, surprising findings, environment specifics that aren’t obvious from the code or docs yet.
Verification — the command(s) the next session runs to confirm the work is in the state you say it is.
One handoff per active thread of work — not one globally. A thread is a
distinct arc someone could pick up cold (the RTL on-ramp, the cell-model-IR
migration, a triage sweep). Concurrent threads get concurrent handoffs, each
named by topic (<topic>-handoff.md). There’s still no chain: a thread holds
at most one live handoff, and a resolved one is folded-and-deleted (see below),
not archived behind its successor.
Treat the handoff count as a WIP signal. If the number of live handoffs
climbs past roughly 2× the number of people actively working, that’s a smell,
not a badge — more parallel threads than the team can hold context on. Read it as
a prompt to resolve, consolidate, or drop threads, not to open more. (Earlier
revisions of this doc mandated “exactly one handoff at a time”; that didn’t match
engineering reality, where several independent arcs are legitimately in flight at
once. The per-thread rule plus the WIP heuristic replaces it.)
Not a decision log. Decisions go in decision records. If you find yourself writing “we chose X over Y because Z” in a handoff, that paragraph belongs in a decision record (or an existing record’s “Consequences” / “Walk-back” section).
Not a design doc. “How clock arrival flows from OpenSTA Tcl through the IR into the GPU constraint buffer” is a design topic; it lives in docs/timing-model-extensions.md Part B, not in a handoff’s “Critical context” section.
Not a status dashboard for the project. Workstream status lives in plan docs — phase-0-ir-and-oracle.md for current-phase WS state, post-phase-0-roadmap.md for forward-looking sequencing. A handoff cites those, doesn’t reproduce them.
Not a historical record.git log is the historical record. Handoffs that survive past their resolution turn into noise that misleads new contributors.
Leaves work in a partial state that someone else might pick up cold.
Captures non-obvious context the next session needs (e.g. “the OpenSTA Tcl find_timing proc rejects -full_update; use ::sta::find_timing_cmd 1 directly”).
Documents the next concrete step with enough scope to start without re-discovering it.
If the session ended at a clean stopping point (everything merged, all decisions documented in decision records or plans, nothing surprising), don’t write a handoff. The plan doc already says what’s next.
The two-location split is deliberate: handoffs live at docs/handoffs/<topic>-handoff.md while in flight; their content migrates into the persistent docs (docs/architecture/decisions/, docs/plans/, design docs under docs/) at resolution. The handoff file then gets removed; nothing about the work is lost because everything load-bearing has a permanent home elsewhere.
When a handoff’s work is done — whether in the next session or several sessions later — every load-bearing piece of it must be migrated to its proper home before the handoff file is deleted:
If the handoff says…
It belongs in…
“We chose approach X over Y because Z”
The relevant decision’s Decision/Consequences section, or a new decision if no fit exists
“Future scope for WS-N: do A then B then C”
The plan doc’s WS-N section (phase-0-ir-and-oracle.md or successor)
“Gotcha: OpenSTA’s Tcl X behaves Y”
A code comment near the Tcl call site, or a design doc if the gotcha cuts across files
“Build dep Z is required on Linux”
The build script’s apt-suggestion / Brewfile / README install section
“Subsystem A doesn’t yet do B”
Plan doc as a new open item, or a decision-tracked walk-back if it’s a deferred design choice
“Run cargo test --feature foo to verify”
The verification block in the relevant plan doc, or a test-running section in CLAUDE.md
After migration, the handoff file is removed in the same commit as the migration:
git rm docs/handoffs/<topic>-handoff.md
git add <files-receiving-the-migrated-content>
git commit -m "$(cat <<'EOF'
docs: resolve <topic> handoff — fold into <where-it-went>
<one-paragraph summary of what was migrated and where>
Co-developed-by: Claude Code v<version> (<model-id>)
EOF
)"
The commit message records what migrated where — that’s the audit trail. git log -- docs/handoffs/ then shows the project’s handoff history (one add, one delete per session) without needing the files themselves to live forever.
When you do need to write one, use this skeleton. Replace placeholders inline; delete sections that don’t apply (better to omit a section than fill it with “N/A”).
# Handoff — <Topic> (one-line summary of what this session left open)
**Created:** YYYY-MM-DD
**Working tree:** clean | <state if not clean>
**Branch:** main | <branch>
## Goal & next-up
**Goal of this session:** <what you were trying to do, in 1–3 sentences>
**Next session should pick up:** <the very next concrete action, by name. Reference the plan doc section if applicable.>
**Verification command:**
```sh
<commands the next session runs to confirm this handoff's claimed state>
# Expect: <what success looks like>
<Things the next session needs to know that aren’t yet in the code/docs. Be honest about what’s truly load-bearing — anything obvious from git log or a quick grep doesn’t belong here.>
Resume in a new session with:
```
/resume_handoff docs/handoffs/-handoff.md
```
## Tooling
The `create_handoff` and `resume_handoff` skills (from various Claude Code orchestration toolkits) generate and consume handoffs. They're optional — the discipline above is the load-bearing artifact. A handoff written by hand following this template is just as valid.
If you use one of those skills, expect it to default to YAML format under `thoughts/shared/handoffs/` with database indexing. **That doesn't apply to this project.** Override it: produce markdown at `docs/handoffs/<topic>-handoff.md` and skip the database step. The skill activation is informational; the project's convention takes precedence.
The current design of Jacquard, by area. Each area is a reference doc: what
the code does today, present tense, no rationale and no dead history. The why
behind each choice lives in the decision records, linked from
the section it explains. The not-yet-built work lives in each doc’s
Implementation-status section and in plans.
If you’re an agent or a new reader, start here: this page maps every area to its
current-state doc and the decisions behind it.
Decision 0019 (cell-model IR) is cross-cutting: it supplies both a cell’s logic
(PDK enablement) and its timing characterization (timing correctness), so both
rows list it. See the redesign spike
for the design.
Reference — the current design as it stands in the code. The why lives in the
decision records linked from each section; the not-yet-built work is under
Implementation status. Present tense means “true
today”; if a claim here is stale, the code moved and this doc didn’t.
Jacquard’s timing story has two halves. Getting a number is the simulation
engine’s job: per-gate delays ride the boomerang’s
arrival propagation and come out as setup/hold reports. Trusting that number is
this doc’s job — where the delays come from, how they’re validated, and what
they mean. Static timing analysis (STA) is a solved problem; Jacquard doesn’t
re-solve it. It treats an existing STA tool as ground truth and builds a
narrow, auditable pipe from that tool’s output into the GPU kernel. The accuracy
tolerances and validation requirements this pipe must meet are the
Timing Correctness Requirements contract.
OpenSTA is both jobs at once: the tool Jacquard’s own numbers are checked
against, and the only static timing analyzer Jacquard talks to at all. An
earlier plan paired OpenSTA with an in-process reference engine (OpenTimer),
so a divergence between Jacquard and OpenSTA could be triangulated against a
third source; a spike found OpenTimer’s input pipeline unfit for
OpenROAD-flow output, and that plan is superseded. Where Jacquard’s results
disagree with OpenSTA’s past a declared tolerance, Jacquard is wrong until
proven otherwise — the divergence gets fixed, justified in writing, or filed
as a bug. Decision 0001.
OpenSTA is GPL-3.0; Jacquard is permissively licensed, so nothing links it.
Every call to OpenSTA crosses a subprocess boundary, driven by
opensta-to-ir, a standalone preprocessing tool that also owns the
Liberty/Verilog/SDF/SPEF/SDC parsing on OpenSTA’s side. opensta-to-ir may
be invoked ahead of time to produce a .jtir file, or subprocessed live by
jacquard sim --sdf <path> — both are permitted in the shipped runtime, as
long as OpenSTA itself is discovered on PATH rather than bundled into a
Jacquard release artefact. Decision 0006.
OpenSTA is vendored as a git submodule at vendor/opensta/, pinned to a
specific revision for CI reproducibility, but never built as part of
Jacquard — subprocess calls use whatever OpenSTA binary the environment
provides. The submodule’s real payload is its test corpus. A small, curated
primary regression corpus (tests/timing_ir/corpus/) runs on every CI
job; a much larger stress corpus, referenced by manifest into the
submodule rather than duplicated, runs nightly to catch parser crashes and
malformed IR on dialects Jacquard doesn’t otherwise see. As shipped, the
primary corpus holds one entry (aigpdk_dff_chain); SKY130/NVDLA/MCU-SoC
entries are pending on a CI SKY130-Liberty install strategy, and the stress
corpus’s manifest is still empty. Decision 0005.
Everything OpenSTA computes for a design reaches Jacquard through one
channel: the timing IR, a schema-versioned FlatBuffers format with a JSON
sidecar for CI diffs and human inspection. opensta-to-ir is the producer;
flatten.rs::load_timing_from_ir is the consumer, turning per-cell arcs into
the PackedDelay (rise/fall picoseconds) and DFFConstraint (setup/hold)
structures the simulation engine bakes into its script. The IR is
multi-corner natively — values are min/typ/max across a declared PVT corner
set, selectable at sim time via --timing-corner — and every arc carries
provenance (asserted from the input file, computed by OpenSTA, or
defaulted). Unrecognised vendor annotations pass through as typed
VendorExtension blobs rather than being silently dropped. The IR’s scope is
deliberately narrow: per-design timing annotation only, never a netlist
representation, a timing graph, or cell characterization.
Decision 0002.
Per-cell-type characterization — setup/hold, clock-to-Q, the numbers that
don’t vary by instance — is a different axis and, today, comes from a
separate runtime path: liberty_parser::TimingLibrary, parsed from a .lib
file at sim startup. Decision 0019 proposes folding that parse, plus a
cell’s logic and sequential classification, into one generated cell-model
IR per library, with the simulation corner picked explicitly via
--corner rather than read from the netlist or SDF. That descriptor doesn’t
exist yet; it’s Proposed, not built — see
Implementation status.
Decision 0019.
The hand-rolled src/sdf_parser.rs is gone. Every path that once consumed
SDF directly now consumes timing IR instead, which is what makes OpenSTA a
required runtime dependency for any timing-aware flow, not just a CI
concern: jacquard sim --sdf <path> and the standalone opensta-to-ir tool
both subprocess OpenSTA to produce the IR that flatten.rs then loads.
OpenSTA’s own read_verilog accepts only structural Verilog — cell
instantiations and bare-net assigns, no RTL operators or bit-selects. Some
flows wrap a clean post-P&R netlist in a thin RTL shim (an active-low OEB
patch, for instance), which OpenSTA’s reader rejects outright.
opensta-to-ir filters each --verilog input at invocation time, extracting
just the module <top> … endmodule block before handing it to OpenSTA, so
wrapper modules are simply never seen. What opensta-to-ir cannot check is
whether the Verilog handed to it is the right stage for a given SDF — a
pre-P&R synthesis netlist looks structurally identical to the post-P&R body
but is missing the hundreds of thousands of P&R-inserted cells the SDF
actually references, and OpenSTA quietly drops SDF entries whose endpoints
aren’t in the loaded design. Picking the right netlist stays the caller’s
job. Decision 0009.
Once the IR is loaded, flatten.rs overlays it on the AIG: each cell’s
delay lands on the AIG pin it drives, and inject_timing_to_script bakes
that value into the flattened script’s
per-gate padding slot as picoseconds the kernel reads directly during
boomerang reduction. DFFConstraint values become the per-word setup/hold
check the kernel runs at each cycle boundary. See
../timing-simulation.md for the full mechanics
of that propagation, including the three independent sources of conservative
overestimate in today’s packed-32 model (max-of-rise/fall per cell, max wire
delay across a cell’s input pins, max arrival across the 32 signals sharing
a thread) — the model never under-reports a violation, which is what setup
checking needs, at the cost of some false positives.
Getting a timing number out of a run used to mean grepping stderr for a
state-word index. Symbolic violation messages now name the hierarchical
signal directly (top/cpu/regs[7][bit 22]); --timing-report <path.json>
emits a schema-versioned, end-of-run document with per-DFF worst arrival and
slack, a per-cycle violation list, and top-N worst-slack rankings even where
no violation occurred; --timing-summary is the fast human-readable
counterpart for scripts and quick inspection. All three ship for sim on
Metal, CUDA, and HIP. Decision 0008.
OpenSTA is the only STA path. There is no in-process reference engine;
a run that needs timing data needs an OpenSTA-produced (or pre-converted)
IR. Decision 0001.
The Verilog fed to opensta-to-ir must be the post-P&R structural
body, matching what the SDF was generated against. opensta-to-ir
strips RTL wrapper modules automatically; it cannot detect a
wrong-design-stage substitution, since a synthesis netlist and a P&R
netlist can share a module name while differing by orders of magnitude in
cell count. Decision 0009.
The timing model is conservative, not exact. Rise/fall collapse, max
wire delay per cell, and max arrival per packed thread all push arrival
times up, never down. See ../timing-simulation.md.
Timed (arrival-annotated) cosim runs on Metal only, and the structured
--timing-report JSON is sim-only; the cosim runtime
hasn’t wired either through yet.
Synchronous, edge-triggered logic only, inherited from the
simulation engine — there’s no timing
model for a level-sensitive latch or asynchronous sequential feedback to
annotate in the first place.
No linking of GPL code, ever, and no bundling of OpenSTA in a Jacquard
release artefact. Subprocess invocation of a user-installed OpenSTA is the
only sanctioned integration path. Decision 0006.
Built and in use: OpenSTA as the sole out-of-process STA oracle; the
FlatBuffers + JSON timing IR with multi-corner support and provenance
tagging; the opensta-to-ir subprocess path (both pre-converted --timing-ir
and live sim --sdf); the structural-Verilog wrapper filter; per-gate delay
and per-DFF setup/hold injection into the flattened script; GPU-side
setup/hold violation detection on Metal, CUDA, and HIP for sim; and the
structured output surface (symbolic violations, --timing-report,
--timing-summary, worst-slack ranking) for those same three backends.
Pillar B (per-DFF clock-tree skew via a ClockArrival IR table, folded into
the setup/hold check) is also landed, ahead of its own roadmap’s schedule.
Decided but not yet built:
Native Rust SDF→IR converter (Phase 3), which would remove the
OpenSTA subprocess from the shipped runtime entirely. No longer
release-gating; deferred indefinitely until bandwidth or commercial demand
reopens it. Decision 0006.
cosim’s structured timing report and timed simulation on
CPU/CUDA/HIP. Today only Metal cosim carries arrival annotations, and
none of the --timing-report/--timing-summary surface reaches cosim.
Decision 0008.
The cell-model IR (Decision 0019) — Proposed, not started. Per-cell
timing characterization stays on the runtime liberty_parser::TimingLibrary
path until this lands; --corner-based cell-library selection doesn’t
exist yet. Decision 0019.
The timing-model fidelity roadmap (Decision 0007) — Proposed. Of its
three pillars, only Pillar B (clock-tree skew, above) has landed. Still
open: Pillar C Tier 1 (per-receiver wire delay, gated on Decision 0007
acceptance), Pillar A (dynamic per-gate delay, δ(T), gated on per-cell
SPICE characterisation and explicitly scheduled after Pillars B and C),
and Pillar C Tiers 2–3 (inter-partition wire delay, NoC-aware partitioning
hints). Scheduling detail in
../plans/post-phase-0-roadmap.md.
Decision 0008’s optional outputs — arrival histograms, an
OpenSTA-critical-path cross-reference, and DFF path-back-trace. Scheduled
per user demand once the required items above see use.
Decision 0005 — OpenSTA
vendored as a pinned submodule; primary vs. stress test corpus split.
Decision 0006 — the SDF
preprocessing model: deleting the hand-rolled parser, opensta-to-ir as
the subprocess bridge, and the (now relaxed) rules on runtime subprocess
invocation.
Decision 0008 — structured
timing output as a first-class deliverable: symbolic violations,
--timing-report, --timing-summary, worst-slack ranking.
Decision 0009 —
OpenSTA’s structural-only Verilog reader, and where the wrapper-stripping
and design-stage responsibilities sit.
Decision 0019 — Proposed: a generated
cell-model IR unifying per-cell-type logic, classification, and L4 timing
characterization, replacing the runtime Liberty parse.
Decision 0007 —
Proposed: the three-pillar roadmap (dynamic delay, clock-tree skew, wire
delay at scale) for closing the accuracy gap with an event-driven
reference simulator.
Reference — the current design as it stands in the code. The why lives in the
decision records linked from each section; the not-yet-built work is under
Implementation status. Present tense means “true
today”; if a claim here is stale, the code moved and this doc didn’t.
The simulation engine compiles a gate-level netlist into a packed GPU script and
evaluates it as a branch-free Boolean processor. A design is converted once, at
the start of a run, into an and-inverter graph; the graph is partitioned to fit a
fixed GPU block geometry, and each partition becomes a stream of u32
instructions the kernel executes with no per-gate dispatch. This is the path
behind jacquard sim (static-input replay) and the substrate the
cosim runtime drives reactively.
Compilation is a fixed sequence of lowering stages, each narrowing the design
toward the GPU’s fixed shape:
NetlistDB parses structural (gate-level) Verilog (sverilogparse) into a
flattened database of cells, pins, and nets with CSR connectivity. Behavioural
RTL is synthesised to this form first; see the RTL on-ramp.
AIG rewrites all combinational logic to one uniform AND-with-invert node
type, so the kernel needs no opcode decode (Decision 0014).
StagedAIG splits combinational cones too deep for one boomerang tree into
sequential major stages.
Partitions distribute the AIG’s endpoint groups across GPU blocks under
fixed resource limits, via hypergraph partitioning.
FlattenedScriptV1 packs each partition into the u32 instruction stream the
kernel reads directly (Decision 0015).
Partitioning and script generation run automatically at simulation start; the
user only sets NUM_BLOCKS (2× the GPU’s SM/CU count; 1 for Metal).
All combinational logic is one and-inverter graph. Every node in a
combinational cone is a DriverType:
#![allow(unused)]
fn main() {
pub enum DriverType {
AndGate(usize, usize), // two operands, each carrying an inversion bit
InputPort(usize), // primary input
InputClockFlag(usize, u8), // clock edge (posedge / negedge) detector
DFF(usize), // D flip-flop output
SRAM(usize), // memory block output
Tie0, // constant zero
}
}
Only AndGate has combinational fan-in. Each operand is encoded aigpin << 1 | invert, so one node type covers the whole {AND, NAND, OR, NOR} family and
inverters/buffers fold into the inversion bit rather than becoming nodes. This
uniformity is the load-bearing property: because every combinational node is the
same (a XOR xa) AND (b XOR xb) operation, the boomerang tree evaluates them all
with a single instruction pattern. Rationale, and why AIG over BDDs / LUTs / MIG /
direct netlist execution: Decision 0014.
Construction (src/aig.rs, AIG::from_netlistdb) is technology-independent and
descriptor-driven. Every cell type is looked up in a cell-model-IR descriptor that
carries its pre-decomposed AIG, and that AIG is spliced straight in
(Decision 0019). The built-in PDKs (AIGPDK,
SKY130, GF180MCU, IHP SG13G2) ship generated descriptors, and cells outside them
supply the same kind of declarative metadata
(Decision 0010); both take the one
descriptor path. How a PDK is onboarded: PDK enablement. A
legacy per-PDK behavioural-model decomposition (decompose_with_pdk) survives only
as a fallback for cell types no descriptor covers. A structural cache deduplicates
identical sub-expressions, and AIG pins come out in topological order, which the
downstream level computation and scheduling rely on.
The AIG’s outputs are grouped into endpoint groups — the units of work a
partition must realise: a primary output, a DFF (data + clock-enable), a RAM block
(address/data/enables), a $stop/$finish control node, a $display node, or an
inter-stage boundary pin from staging. Each group bundles the signals that must be
evaluated together; the partitioner uses the group’s input set for connectivity and
the partition executor uses it for resource accounting.
When a design’s combinational depth exceeds one 8192-wide boomerang tree,
src/staging.rs splits the AIG into major stages at user-specified level
thresholds (--level-split 30, or --level-split 20,40 for two cuts). Each stage
carries its own primary inputs (the pins produced by earlier stages, or the design’s
real inputs for the first), the live boundary pins it must forward, and the endpoint
groups whose depth falls within it. Major stages run sequentially — the kernel loops
over them, writing boundary values to the state buffer and re-reading them in the
next stage. Staging trades extra sequential dispatches for fitting the fixed
boomerang; without it, designs with cones deeper than ~50 levels fail partitioning
outright. Mapping details: Decision 0015.
Endpoint groups are distributed across GPU blocks by RepCut (src/repcut.rs),
which builds a weighted hypergraph and cuts it with mt-kahypar. A hypergraph,
not a graph, because one shared AIG node reached by many endpoint groups is a single
hyperedge across all of them — cutting it costs one global-memory read shared by
every block that needs the signal, which pairwise edges cannot express. The cut
minimises Sum-of-External-Degrees, which is exactly that cross-block read count.
mt-kahypar deliberately over-partitions (≈2× the block count); process_partitions()
in src/pe.rs then greedily merges partitions back, scoring merge candidates by
AIG-node-bitset overlap, trying merges speculatively in parallel with
cancel-on-success, and rejecting any merge that would add boomerang stages beyond a
degradation bound. The result is 2–4× fewer partitions than the raw solution, each
validated to fit the boomerang’s resource limits (Constraints).
One GPU block (CUDA/HIP) or threadgroup (Metal) evaluates one partition by reducing
its endpoint groups’ fan-in cones through a hierarchical binary tree — the
boomerang. The tree has BOOMERANG_NUM_STAGES = 13 levels (2^13 = 8192 leaf
positions); each thread owns 32 bits, so a block is 8192 / 32 = 256 threads
(NUM_THREADS_V1). The 13 levels map to three GPU mechanisms:
Levels
Width
Mechanism
hier[0]
8192 → 4096
256 threads, shared-memory reduction
hier[1–3]
4096 → 512
shared-memory reduction, barrier between levels
hier[4–7]
512 → 32
warp / SIMD shuffle (__shfl_down_sync / simd_shuffle_down), no barrier
hier[8–12]
32 → 1
bit operations within one u32 on thread 0
Every position computes (a XOR xora) AND (b XOR xorb) OR orb — the AIG’s
AND-with-invert, with orb all-ones turning a position into a pass-through. One
instruction pattern, zero branch divergence, maximal SIMT utilisation across all
backends. When a partition needs more than one 8192-wide tree, a shuffle
permutation (16-bit index pairs in the script) re-routes signals from shared
memory back into thread registers between stages. Full derivation:
Decision 0015.
src/flatten.rs packs each partition into FlattenedScriptV1, a u32 stream read
sequentially by the kernel, with four sections: a metadata block (256 u32 of
per-partition control fields at fixed indices, plus the write-out hook table that
maps each thread to the boomerang stage+position where it captures its output); a
global-read permutation that gathers each thread’s input bits from the state
buffer; the boomerang sections (per stage, the shuffle permutation plus the
xora/xorb/orb AND-gate flags, with a padding slot reused to carry per-gate
gate-delay picoseconds for timing runs); and the global write-out that commits
results, SRAM ports, and output duplicates back to the state buffer. The metadata
index layout is the load-bearing contract between flatten.rs and every kernel.
The global-read permutation carries one subtle cross-backend detail. A read’s word
index has bit 31 set when the value is an inter-stage intermediate rather than
previous-cycle state. Every decoder must strip that flag from the index before
indexing: flatten.rs, the CPU reference, the shared CUDA/HIP kernel_v1_impl.cuh,
and kernel_v1.metal. Cancelling the flag by offsetting the base pointer instead
forms an out-of-bounds address. That bug placed staged reads 2^32 words past the
buffer on ROCm, the one backend that computes the full address rather than
truncating it to 32 bits (#203).
The same FlattenedScriptV1 runs on four backends behind one seam. The GPU kernels
each evaluate one partition per block: csrc/kernel_v1.cu and csrc/kernel_v1.hip.cpp
share kernel_v1_impl.cuh, and csrc/kernel_v1.metal is the Metal port. sim takes
a grid-wide barrier between stages, which needs cooperative launch; the
cosim runtime sidesteps that so its CUDA/HIP backends run without
it. A CPU reference kernel (--check-with-cpu) evaluates the same script bit-for-bit
and is the cross-backend equivalence oracle. Every backend is expected to produce
identical results on the same design, and that equivalence is checked in CI.
By default the engine is two-state: uninitialised DFF and SRAM outputs resolve to 0,
which hides init bugs and causes false mismatches against four-state RTL simulators.
--xprop turns on selective four-state simulation. Static analysis identifies
X-source signals (uninitialised DFFs, SRAM reads), and forward-cone analysis marks
each partition X-capable or X-free. Only X-capable partitions run the X-aware kernel
variant and pay its ~2× storage and ALU cost, held in X-mask words appended to the
state buffer; the rest keep the fast two-state path. In a typical SoC after reset,
under ~5% of partitions are X-capable. Output VCD then carries x values, and
--check-with-cpu has an X-aware reference. Design choices and the seven-phase
implementation are in Decision 0016 and
docs/selective-x-propagation.md. The reactive
extension belongs to the cosim runtime: it adds undriven input
pads as a third X-source and maintains the X-mask per edge.
assert() and $display/$write survive synthesis as GEM_ASSERT / GEM_DISPLAY
cells (via the gem_formal.v techmap over Yosys $check/$print cells). The AIG
records their bit positions in the script; after each GPU step the CPU reads those
positions and acts on them. An assertion fires a configurable action (log, pause, or
terminate, bounded by max_failures). A display reconstructs its message from format
strings held in JSON metadata and argument bits read from the state buffer.
Synchronous, edge-triggered logic only. Sequential state is D flip-flops
capturing on clock edges. What is not modelled is a raw level-sensitive latch
left in the logic, and asynchronous sequential (self-timed) feedback. Three
common latch-derived structures are supported: asynchronous set/reset on
flip-flops (it lowers to an AIG overlay), clock gating via the CKLNQD integrated
clock-gating cell, and latch-based register files mapped to $__RAMGEM_SYNC_ SRAM
cells by memory synthesis. See the latch note in
Decision 0014.
8191 unique inputs and 8191 unique outputs per partition — the 8192 boomerang
leaf/write-out slots minus Tie0. Wide buses or highly-connected cones force finer
partitioning, which raises inter-block reads.
4095 intermediate pins alive per stage, and 64 SRAM output groups per
partition (each SRAM consumes 4 of the 256 write-out slots). SRAM-heavy designs
may need finer partitioning than gate count alone implies.
Fixed boomerang geometry. The 8192-leaf, 256-thread, 13-stage tree is one
hardcoded design, uniform across CUDA, HIP, and Metal rather than tuned per GPU
architecture. It assumes 32-bit lanes and a 32-wide SIMD reduction at the shuffle
tier (levels 4–7). There is no occupancy-tuning knob; retargeting the geometry to a
different architecture would mean redesigning the hierarchy depth and the script
bit-packing.
When a single endpoint cannot be mapped, --level-split forces stage splits.
Built and in use: the full NetlistDB → AIG → StagedAIG → Partitions →
FlattenedScriptV1 → kernel pipeline; all four backends (CUDA, HIP, Metal, CPU
reference) with cross-backend equivalence in CI; RepCut hypergraph partitioning with
greedy merge-back; selective X-propagation (all seven phases, including the CPU
reference and Criterion overhead benchmarks); and assertion/display support.
Decided but not yet built:
Dynamic X narrowing — periodically re-scanning the X-mask and hot-swapping a
partition from the X-aware kernel back to the fast path once its cone is X-free.
Distinct from the shipped static X analysis. Decision 0016.
Per-address SRAM X — reads currently return X until the first write to the
memory; finer per-address tracking is deferred. Decision 0016.
Decision 0014 — AIG as the simulation IR:
the uniform AND-invert node, the inversion-bit encoding, the decomposition path,
and the EndpointGroup abstraction.
Decision 0015 — the boomerang
execution model: the 13-level reduction tree, the GPU resource limits, RepCut
hypergraph partitioning, and the FlattenedScriptV1 format.
Reference — the current design as it stands in the code. The why lives in the
decision records linked from each section; the not-yet-built work is under
Implementation status. Present tense means “true
today”; if a claim here is stale, the code moved and this doc didn’t.
Cosim runs a GPU-simulated design alongside reactive peripheral models that
drive and observe the design’s pins every clock edge. Unlike sim, which
replays a fixed input VCD, cosim lets an input depend on a design output
cycle by cycle: a SPI flash serves firmware in response to the addresses the
design issues, a UART decodes the bytes it transmits, a JTAG client single-steps
it. Rationale for the execution model: Decision 0017.
CPU (between batches)GPU — one batch, per edgeGPU→CPU ring buffersOrchestration driverPeripheral models (Tier 1)observe → advance FSM → driveRing drainstate_prepinject(flash MISO)simulate xNsample + advance(bidirectional)observe(UART, bus trace)MultiClockSchedulerBatch policy(force_single_edge)dispatch batchpin drives (BitOps)writewritedrain after batchoutputs
Consecutive scheduler edges are grouped into batches of up to
BATCH_SIZE = 1024 and dispatched as one GPU command buffer. Between batches the
CPU steps its peripheral models, drains the ring buffers, and patches the next
batch’s pin drives. A batch runs with no CPU interaction inside it, which is why
cosim is GPU-bound rather than round-trip-bound.
The batch collapses to a single edge whenever any peripheral model reports
is_active(). A JTAG replay mid-transmission, or a connected interactive debug
client, holds the design at one edge per dispatch for as long as it needs
per-edge coupling. This is the force_single_edge path; it is correct but slow,
and it is the exception, not the norm. The why, and the measured batch-utilisation
that justifies the fixed size, are in Decision 0017.
One orchestration driver runs above a CosimBackend trait; each backend owns the
design state and runs N consecutive edges per call.
CpuBackend — the per-edge reference model. It is the cross-backend
equivalence oracle and the reason cosim regressions run on free Linux CI.
Throughput is not its job.
MetalBackend — runs a batch in one command buffer with GPU peripherals
inside it. Unified memory makes the pin drives zero-copy: the CPU’s write to
an edge’s ops is the upload.
CudaBackend / HipBackend — run the same simulate kernel over managed
memory, sidestepping the cooperative-launch grid sync that only sim needs.
They ship with their GPU peripherals so reactive designs batch from the start;
ops upload only for edges the CPU actually changed.
Every backend produces a byte-identical output VCD on the same reactive design.
That equivalence is the correctness contract for the whole runtime, checked in
CI against CpuBackend goldens.
A peripheral is one shape on either substrate: observe some design-output
bits, advance an FSM over persistent state, then drive some design-input
bits and/or emit decoded records. Where it runs follows a rule: a model that
drives inputs each edge (must react to output) can run on the CPU; a model that
only observes outputs, or exchanges data bidirectionally, runs on the GPU for
zero-copy access to the state buffer. Rationale and the full contract:
Decision 0013.
GPU peripherals come in two patterns:
Observe-only — one post-simulate kernel per edge, reading output state into
a ring buffer. UART TX decode and bus trace.
Bidirectional — a pre-simulate kernel injecting into input state plus a
post-simulate kernel sampling and advancing the FSM. SPI flash and QSPI PSRAM.
Three tiers back each peripheral. Tier 1 is the CPU PeripheralModel (always
present, the semantic ground truth). Tier 2 is a hand-written GPU kernel for the
core set — flash, UART, bus trace — one shared *_impl.cuh for CUDA and HIP plus
one .metal. Tier 3, single-source peripheral compilation for user-defined
peripherals, is not yet built.
Config is plural where instances can repeat, following one back-compat pattern
(a legacy singular key folds into instance 0 of a new Vec):
Peripheral
Plural
State
Clocks, GPIO, UART
yes
built
QSPI memory (flash + writable PSRAM)
yes
built; independent backing stores, CS-gated shared bus
GPU peripherals write into fixed-size ring buffers in device memory; the CPU
drains each after a batch completes, from a local read_head up to the
GPU-written write_head. No synchronisation beyond command-buffer completion is
needed. This is the only channel out of the batch today, and it is the GPU→CPU
half of the flow-controlled I/O work. When VCD output
is on, a separate per-edge snapshot ring lets a batch stay larger than one edge
without a CPU read after every edge.
The MultiClockScheduler computes a deterministic interleaving of edges across
clock domains. The scheduler tick is the GCD of all half-periods and phase
offsets; the schedule repeats at the LCM of all full periods. A scheduler
edge is one tick; a clock cycle is two half-periods of one domain. UART
baud dividers, reset duration, and the --max-clock-edges flag all count edges,
not cycles — conflating the two is a real bug class, not a pedantic distinction.
The schedule length is capped at one million ticks, which rejects
non-commensurable clock ratios rather than building an unbounded schedule.
Built and in use: batch dispatch, the multi-clock scheduler, all four backends
with cross-backend equivalence, the CPU and GPU peripheral tiers (1 and 2), plural
configs, and the interactive JTAG server. These are the sections above without a
“not yet.”
Decided but not yet built:
Flow-controlled external I/O across the batch boundary — bounded pipes, an
adaptive commit window replacing the fixed BATCH_SIZE, per-tap projection, a
socket reactor, and a CPU→GPU input pipe. Proposed in
Decision 0022; nothing built.
Tier 3 single-source peripherals — one peripheral definition compiled to
CPU and every GPU backend. Decision 0017.
Bus trace beyond APB3 — AHB-Lite/AHB5 decode and annotated-VCD output;
migrating the hardcoded Wishbone trace onto the config-driven monitor.
Decision 0013.
Timed cosim on CPU/CUDA/HIP, and the cosim structured timing report.
Decision 0017.
Reference — the current design as it stands in the code. The why lives in the
decision records linked from each section; the not-yet-built work is under
Implementation status. Present tense means “true
today”; if a claim here is stale, the code moved and this doc didn’t.
jacquard sim and jacquard cosim accept behavioral RTL directly — no
separate build step, no external toolchain. Pass a .v/.sv file where you’d
otherwise pass a gate-level netlist, and the same command classifies the
input, synthesizes it if needed, caches the result, and simulates it on the
GPU. This is a front end onto the simulation engine:
the emulator core is unchanged, and only ever consumes a synthesized
structural netlist. Rationale for embedding a synthesis engine at all:
Decision 0021.
synthesize → aigpdk netlist → simulate, via the embedded Yosys, caching the result
The command always prints what it decided, for example design.v: behavioral RTL → synthesized [YoWASP Yosys, functional QoR] → <cache>, so synthesis is
never silent. --rtl and --netlist override auto-detection when it guesses
wrong; --emit-synth <path> dumps the intermediate gate-level netlist for
inspection or fixture authoring. Full detection table and CLI surface:
the accepted RTL surface.
Synthesis runs as Yosys compiled to WebAssembly, executed in-process from
Rust (src/synth.rs), not a vendored native build and not a Python
subprocess. YoWASP ships Yosys as a single self-contained yosys.wasm, with
abc compiled in-tree into that module and called in-process — WASI has no
exec, so there is no separate abc process to shell out to. src/synth.rs
embeds wasmtime, loads the wasm module, preopens the
design, the aigpdk support files, and a temp work directory under WASI, and
runs a generated synthesis script through it. No Python interpreter and no
external toolchain: jacquard sim design.v … goes from RTL to waveform out of
the single jacquard binary. This is behind the opt-in synth feature
(cargo build --features metal,synth) because wasmtime and cranelift are
heavy to compile; a binary built without it gives an actionable error on
behavioral input instead of failing to build at all.
The SystemVerilog front end is yosys-slang (read_slang), a
near-complete SystemVerilog-2017 elaborator bundled in the pinned wasm.
src/synth.rs probes for read_slang once per wasm module (help read_slang, cached for the process) and uses it when present; on an older
wasm without it, the script falls back to Yosys’s built-in read_verilog -sv,
whose accepted subset is narrower. Which frontend ran is logged, and it
factors into the cache key below.
Two independent caches make repeat runs cheap. The compiled wasm module is
cached under $XDG_CACHE_HOME/jacquard, keyed by content hash, so only the
first run of a given yosys.wasm pays the cranelift compile — later runs
deserialize the cached module. The synthesized netlist is cached separately,
keyed by a hash of the design source, the generated script, the wasm bytes,
and the embedded aigpdk support files (aigpdk_nomem.lib, memlib_yosys.txt,
gem_formal.v), so editing any of those correctly invalidates a hit — a repeat
sim/cosim run of unchanged RTL skips synthesis entirely.
The yosys.wasm module itself is fetched on first use rather than bundled
into the binary. locate_yosys_wasm resolves it in order: an explicit
--yosys-wasm path, the JACQUARD_YOSYS_WASM environment variable, or a
pinned release fetched (sha256-verified) from the gpu-eda/yowasp-yosys fork
and cached under $XDG_CACHE_HOME/jacquard/wasm/<tag>. That fork, not a stock
YoWASP wheel, is the default source: it carries the patched abc &origins
path the on-ramp’s provenance mapping (below) depends on, and a stock wheel
would silently drop source locations instead of failing loudly.
On-ramp — YoWASP Yosys, the default whenever sim/cosim are handed
behavioral RTL. Easy: one command, nothing to install beyond the synth
feature. Functional-grade QoR.
Performance — bring your own synthesizer (Synopsys DC, or a native
Yosys install) to produce a gate-level .gv, then point jacquard sim at
it directly (the “gate-level, built-in PDK” row above). Synthesis quality is
the primary tuner of simulation throughput, since the AIG the GPU emulates
is only as good as the mapping. Full flow: Synthesis flow.
The two tracks share the same aigpdk libraries and memory-mapping rules
(memlib_yosys.txt); the on-ramp runs the same synthesis shape the
performance-path docs describe manually, just from an embedded engine instead
of a standalone Yosys or DC invocation.
The accepted behavioral subset is whatever the embedded frontend can
synthesize to an aigpdk netlist — Jacquard delegates elaboration to Yosys
rather than implementing a Verilog front end itself. Verilog-2005 (assign,
always @(*)/@(posedge/negedge), synchronous and async-reset flip-flops,
case/if chains, parameterized modules and generate, inferred memories) is
supported through Yosys’s built-in reader. Through read_slang, the
SystemVerilog-2017 surface is substantially broader: packages, interfaces
(including modports), always_ff/always_comb/always_latch,
logic/enum/struct/union, and advanced generate. Full subset table
and known gaps: the accepted RTL surface.
Three project-specific techmaps run on top of standard synthesis, defined in
aigpdk/gem_formal.v:
GEM_ASSERT — $assert/$assume/immediate assert property, visible
in simulation as assertion failures.
GEM_DISPLAY — $display (via Yosys $print); the cell is emitted by
the on-ramp, though the display-info JSON companion the CPU side needs to
decode format strings isn’t written yet on this path.
RAMGEM ($__RAMGEM_SYNC_) — inferred synchronous memories, mapped via
aigpdk/memlib_yosys.txt, the same memory-synthesis step the performance
path runs by hand.
Testbench-only constructs (#delay, most initial blocks, $finish,
$stop) have no synthesizable meaning and are dropped during elaboration.
Latches aren’t supported: the emulator core is synchronous only, and a latch
inferred from RTL is a synthesis or AIG-load error.
The on-ramp maps to aigpdk cells via abc_new, abc9’s AIGER/XAIGER path,
rather than the classic abc -liberty flow. XAIGER round-trips the design
through an in-memory AIGER “y” channel that carries Yosys’s (* src *)
attributes through std-cell mapping, where the classic BLIF-based flow drops
them — BLIF has no object identity to hang provenance off. sverilogparse
captures those \src locations into netlistdb.cell_src, and
AIG::aigpin_src_locations threads them into the AIG, so xsources and
--trace-signals can report an RTL file:line instead of a flattened gate
name. A signal resolving to binary_to_gray.sv:15 instead of
$auto$…$1234 is the visible result. Full write-up:
RTL-source provenance.
The emulator core does not change. The input to the AIG/boomerang
pipeline is always a synthesized structural netlist; behavioral elaboration
stays Yosys’s job. Jacquard does not reimplement an RTL front end, so the
accepted surface is bounded by what the embedded Yosys accepts, not by
anything Jacquard-specific.
A QoR ceiling on the easy path. YoWASP Yosys is functional-grade —
correct, not tuned for GPU throughput. Peak performance still wants DC or a
native Yosys install on the performance path.
Concurrent-SVA synthesis is partial. Turning property/sequence-based
assert property into gate-level checkers is a separate Yosys formal-flow
capability, independent of read_slang’s ability to parse SVA, and it’s
still incomplete (issues #106, #107). Immediate assertions synthesize
correctly through the GEM_ASSERT mapping regardless.
Released binaries must be built --features synth. The feature is
opt-in at compile time because wasmtime/cranelift are heavy to build, but
because behavioral input is a capability of sim/cosim themselves rather
than a separate command, a released binary without it fails behavioral input
with a build-without-synth message rather than silently rejecting RTL.
Built and in use: the on-ramp itself (behavioral input to sim/cosim,
three-way auto-detection, the embedded wasmtime-hosted Yosys engine,
read_slang with fallback, both caching layers, fetch-on-first-use of the
pinned provenance wasm), and RTL-source provenance — abc_new/XAIGER mapping,
\src capture through sverilogparse/netlistdb, and its surfacing in
xsources and --trace-signals. These are the sections above without a “not
yet.”
Decided but not yet built:
An empirical sv-tests coverage table. The accepted-language-surface
prose above describes the intended surface. The authoritative measure is
pass/fail coverage driven through
sv-tests, which hasn’t shipped.
Decision 0021.
Concurrent-SVA → synthesizable-checker synthesis (#106, #107) — a Yosys
formal-flow gap, not something src/synth.rs can work around.
Source locations in --timing-report. Provenance reaches
--trace-signals and xsources/xroots today; threading \src into the
structured timing report is the one deferred piece of the provenance plan.
RTL-source provenance.
Decision 0021 — behavioral RTL
support: folding synthesis into sim/cosim, the embedded wasmtime/Yosys
engine, the two synthesis tracks, and the RTL-source-provenance roadmap.
Decision 0014 — the emulator
model: why synthesis is a front end at all, and why Jacquard doesn’t
elaborate behavioral RTL itself.
Decision 0019 — the cell-model IR: how a
built-in PDK’s descriptor supplies logic and timing together, and how
--corner selects among them.
Reference — the current design as it stands in the code. The why lives in the
decision records linked from each section; the not-yet-built work is under
Implementation status. Present tense means “true
today”; if a claim here is stale, the code moved and this doc didn’t.
A PDK gives Jacquard two things about every cell type it contains: the
combinational or sequential logic the simulation engine
splices into the design’s AIG, and the timing characterization a --timing-report
run reads. Both come from one source, a per-library cell-model IR descriptor
(liberty-to-cellir converter, Decision 0019),
and both the built-in PDKs and a user’s own foundry library go through the same
descriptor mechanism. Cells a descriptor doesn’t cover, such as third-party IP,
memory macros, and filler and pad cells, get the same kind of declarative
treatment through a second, smaller path: a hand-authored TOML manifest
(Decision 0010). One mechanism for
generated, per-library data; one for hand-declared, per-cell data. Neither
requires a Jacquard code change to onboard a new cell.
Every combinational and sequential cell type is looked up in a cell-model IR
descriptor: a generated, versioned JSON file carrying everything Jacquard needs
about a library, keyed by cell type — pin directions (L1), the cell’s logic as a
pre-decomposed AIG (L2), sequential pin roles and classification (L3), and
per-corner timing characterization (L4). Construction of the design AIG
(AIG::from_netlistdb) looks up each cell type in the resolved descriptor and
splices its pre-built AIG straight in; there’s no per-cell decomposition work at
simulation start.
The liberty-to-cellir converter (crates/liberty-to-cellir) produces a
descriptor from a Liberty .lib, consulting a functional .v only as a fallback
or cross-check for logic Liberty under-specifies. It derives the corner from the
Liberty’s operating conditions, compiles each cell’s function string to an AIG,
and extracts ff/latch sequential roles. A library with multiple corners keeps
all of them in the one descriptor, keyed by corner name; a --corner <name> run
flag picks which one to simulate against, defaulting to the descriptor’s declared
default_corner. The simulation corner is always user-selected — never inferred
from the netlist (synthesis’s setup corner is recorded nowhere Jacquard reads) or
from an SDF header, since an SDF’s delays already annotate a specific design’s
instances rather than describe the library.
A netlist’s descriptor is resolved in order: an explicit --cell-descriptor <file.json>, an explicit --bundled-descriptor <name>, auto-match by the
descriptor’s declared cell-name prefix, then a default-fallback descriptor for
cells with no vendor prefix. This is how AIGPDK is selected: its cells share no
common prefix, so it’s the fallback rather than a prefix match. A netlist mixing
cell-name tracks that both match a prefix (for example GF180MCU’s 7-track and
9-track libraries in one design) is ambiguous and auto-selection rejects it —
pick the library explicitly instead.
Third-party IP, hard macros, and other cells that don’t need new AIG decomposition
rules take a second, lighter path: a user-supplied Verilog file
(--cell-library <path>.v, repeatable) gives sverilogparse the cell’s pin
directions, and a co-located <library>.cells.toml manifest (autoloaded, or
passed via --cell-manifest) declares each cell’s kind — ram, filler,
io_pad_input, clock_gate, and so on. This is the RuntimeCellLibrary path
(src/cell_library.rs, Decision 0010):
no Jacquard PR to add a cell, just a manifest shipped alongside the design. Where
a manifest and a resolved descriptor could both classify a cell, the manifest is
the hand-authored override layer over the generated IR, not a competing source.
A manifest entry with kind = "ram" has two modes. Without a [cells.NAME.ram]
sub-table it’s opaque: the cell’s outputs become X-source slots and no memory
behavior is modeled, which is enough for a design that never reads back what it
wrote to that memory at the timescales Jacquard simulates. With the sub-table
present it’s explicit
(Decision 0011): pin roles for clock,
chip-enable, write-enable, write-mask, address, and data are declared, and the
cell gets a real AIG-backed RAMBlock with per-entry backing storage — writes
populate it, reads return what was written. sim_config.json’s sram_init can
then preload that storage from an ELF’s PT_LOAD segments before tick 0.
Four cell libraries ship as bundled descriptors: AIGPDK (Jacquard’s own
synthesis library of AND gates, DFFs, clock gates, and SRAMs, and the
default-fallback descriptor), SKY130, GF180MCU, and IHP SG13G2 — the
last onboarded purely by vendoring its Liberty and generating a descriptor, with
no per-PDK Rust written for it. Each is regenerated in CI from its pinned
vendored submodule and embedded into the binary at build time rather than
committed as a generated blob, so a released jacquard binary carries no runtime
dependency on the vendored PDK sources.
The full step-by-step procedure is in docs/adding-a-pdk.md:
vendoring a library’s Liberty as a submodule, running the converter, registering a
bundled descriptor, or pointing --cell-descriptor at a generated file for a
library that can never be vendored. That guide also documents a
legacy hand-written-Rust workflow (per-PDK pin tables, classifiers, and
decomposition functions); it’s kept as a reference for the machinery the
descriptor replaces, not a path to take for a new PDK.
A contributor with commercial-PDK access under NDA can’t commit that PDK’s
Liberty, SDF, or other characterization files to the public repository. The
private-PDK test track (Decision 0004)
gates such tests on a per-PDK environment variable pointing at a licensed
directory: tests skip cleanly with a “PDK not available” message when the
variable is unset, and run fully when it points at a readable PDK. Only the test
harness and PDK-agnostic structural fixtures are committed — never a PDK-derived
artifact.
A cell type must ultimately resolve to a DriverType the design AIG
understands (AndGate, DFF, SRAM, or Tie0), inherited from the
simulation engine’s synchronous, edge-triggered model.
A cell matching neither a descriptor, the legacy per-PDK fallback, nor a
manifest kind fails cell recognition.
The RAM port-mapping schema is opinionated: single-port (1RW), synchronous
only, and a write-mask that’s bit- or byte-granular, not arbitrary. Depth is
capped at 2^AIGPDK_SRAM_ADDR_WIDTH (8192 entries today) and width at 32 bits.
Multi-port and asynchronous SRAMs are out of scope.
sram_init ELF preload works only for a single-SRAM design today; matching
ELF segments to multiple SRAM instances by address is not yet built.
A commercial library whose functional model is one flat-module .v file
skips the converter’s logic cross-check at generation time — the descriptor
still gets emitted, but that cross-check needs validating through simulation
instead.
The private-PDK test track’s open-source path (GF180MCU_LIBERTY_DIR) is
wired; the NDA-gated, per-vendor *_PDK_PATH pattern for commercial PDKs is
not.
Built and in use: descriptor-driven combinational logic for all four built-in
PDKs and for any library reachable via --cell-descriptor; CI-regenerated,
build-time-embedded bundled descriptors with prefix auto-selection and explicit
override; the --cell-library / .cells.toml runtime manifest path with the
kind discriminator; the explicit RAM port-mapping schema with real backing
storage and single-SRAM ELF preload; and the open-source-PDK slice of the
private-PDK test track.
Decided but not yet built:
Descriptor-driven sequential logic for every built-in PDK. GF180MCU
consumes its descriptor’s sequential roles today; the other built-ins still
route some flip-flop and clock-gate variants through preserved hand-written
pin-name matches. Decision 0019.
The NDA-gated commercial-PDK testing track — per-vendor *_PDK_PATH
environment variables, licensed-runner gating — described in the decision but
not implemented; only the open-source GF180MCU path ships.
Decision 0004.
Multi-SRAM sram_init targeting — matching ELF segments to more than one
SRAM instance by virtual-address overlap. Decision 0011.
Flat single-module functional-.v cross-checking at descriptor-generation
time, for commercial libraries that ship one undifferentiated file rather than
one file per cell. Decision 0019.
A descriptor-backed pin-direction provider, retiring the remaining
build-time pin-table generation for cells the descriptor’s L1 already covers.
Decision 0019.
Decision 0004 — private PDK testing
track: env-gated per-PDK test suites so NDA-bound PDK files never enter the
public repository.
Decision 0010 — declarative
cell metadata: the --cell-library + .cells.toml manifest path, classifying
third-party cells by kind without a Jacquard code change.
Decision 0011 — RAM port-mapping
schema: promotes a manifest-declared RAM from opaque to explicit-port, with
real backing storage.
Decision 0019 — cell-model IR: one
generated, per-library descriptor carrying pin directions, combinational logic
as a pre-built AIG, sequential roles, and per-corner timing, consumed at
runtime with no per-PDK Rust.
Reference — the current design as it stands in the code. The why lives in the
decision records linked from each section; the not-yet-built work is under
Implementation status. Present tense means “true
today”; if a claim here is stale, the code moved and this doc didn’t. This
page is the architecture view of why Jacquard ships the way it does; for the
install commands themselves see Installation, and for
the mechanics of cutting a release see Release process.
Jacquard is three artifacts, not one: the jacquard simulator (Rust, GPU-compiled),
its sibling CPU tool opensta-to-ir, and the Python companion netlist-graph.
Each ships through the channel that fits it, rather than forcing all three
through one mechanism. Decision 0018
is the rationale; this page describes what’s live.
jacquard + opensta-to-ir ship together, one release, one tag, coordinated
versioning via scripts/bump_version.py:
GitHub Releases — a prebuilt tarball per GPU target
(jacquard-<version>-macos-arm64-metal.tar.gz today). The Metal kernel is
embedded in the binary (include_bytes! + new_library_with_data), so the
tarball needs no sidecar .metallib.
cargo binstall --git — reads the [package.metadata.binstall] pkg-url
override in Cargo.toml and fetches the release tarball instead of
compiling. The package name is jacquard-sim, though the binary it installs
is still jacquard. Cargo.toml explains why: the crate name jacquard is
already taken on crates.io by an unrelated project.
Homebrew tap (gpu-eda/homebrew-tap) — brew install gpu-eda/tap/jacquard
installs both bins and declares depends_on "llvm", covering the runtime
dependency described below. A parallel homebrew-tap-prerelease tap serves
release-candidate tags for staging validation.
jacquard is not published to crates.io: its dependencies are vendored path
deps (vendor/eda-infra-rs/*, a fork carrying in-flight patches), so
cargo install jacquard-sim from the registry isn’t possible. cargo binstall --git sidesteps this by reading the binstall metadata straight from the repo
rather than from a registry entry.
netlist-graph ships independently, via PyPI, with its own version and
its own OIDC-trusted-publish workflow (publish-netlist-graph.yml). It has no
GPU dependency and no path deps, so it’s a plain noarch wheel — uvx netlist-graph or pip install netlist-graph.
The simulator itself never ships via PyPI; PyPI is the Python companion’s
channel, not the GPU binary’s. See the Python-engine question
below for why a wheel for the simulator is a live but undecided question of
its own.
cargo-binstall selects a release asset by target triple. macOS/Metal has
one GPU backend for aarch64-apple-darwin, so the triple alone identifies
the right asset. Linux’s x86_64-unknown-linux-gnu triple would have to
cover two backends, CUDA and HIP, that don’t map to distinct triples: the
same triple can’t resolve to two different binaries. There’s no
target-triple axis to disambiguate on, so Linux installs stay a manual
choice: download the tarball for your backend, use a container, or build
from source.
Release and Homebrew binaries are built with --features synth, so the
RTL on-ramp works without a separate
synthesis step; a binary built without synth still simulates pre-synthesized
gate-level netlists and gives an actionable error on behavioral input. GPU
backend and synth are orthogonal features — metal, cuda, and hip each
select a backend, synth is additive on top of any of them.
Today only the Metal target has a release build. CUDA and HIP release
binaries are gated on self-hosted NVIDIA/AMD release runners; until those
land, CUDA/HIP users build from source (cargo build -r --features cuda,synth / --features hip,synth).
Decision 0020 drafts a binary
wheel that embeds the compiled jacquard binary, so pip install jacquard
would yield a working simulator with no separate install step. That decision
is Draft, not ratified. Its own direction note prefers a native PyO3
binding over the subprocess-bundled wheel it worked out in detail: an
in-process engine is a materially better Python surface, and building
per-platform wheels (cibuildwheel + dylib repair) is the same hard problem
either way, so it’s worth doing once for a PyO3 extension rather than
building the subprocess-wheel machinery first and replacing it later. Which
of the two ships first is an open question tracked in
issue #161. Nothing from
this decision is built.
No single universal binary. Each GPU backend is a separate build
(metal / cuda / hip); there is no fat binary spanning backends, and
the release matrix has one row per target.
Linux is not binstall-able, per above.
The release tarball, a container, or a source build are the only paths.
The synth feature must be on in released binaries. It’s what makes
the behavioral RTL on-ramp (jacquard sim design.v …)
work without a separate synthesis invocation; a binary without it only
accepts gate-level netlists.
The Metal binary needs Homebrew LLVM at runtime. It links Homebrew
LLVM’s libc++ and libomp, since the mt-kahypar partitioner uses OpenMP
via LLVM clang. The raw tarball and a cargo binstall install both ship
the bare binary, which fails to launch without brew install llvm first.
The Homebrew formula handles this itself via depends_on "llvm".
opensta-to-ir has no independent release channel. It ships inside
the same tarball/formula as jacquard; there’s no scenario where you get
one without the other from the release channels.
Built and in use: GitHub Releases for macOS/Metal, cargo binstall --git,
the Homebrew tap (stable + prerelease), staged install validation
(validate-install.yml) against a published (pre)release, netlist-graph on
PyPI via OIDC trusted publishing, single shared crate versioning via
scripts/bump_version.py, and the relocatable (embedded-metallib) Metal
binary that makes any of this possible. These are the sections above without
a “not yet.”
Decided but not yet built:
CUDA/HIP prebuilt release binaries — Decision 0018 Phase 4, gated on
self-hosted NVIDIA/AMD release runners; Linux stays source-build until
then. Phasing: ../plans/distribution.md.
Container image as a channel — Decision 0018 Phase 6, deferred by the
original decision rather than scheduled.
eda-infra-rs upstreaming, the path to a crates.io publish and a plain
cargo install — Decision 0018 Phase 7.
../plans/distribution.md § Phase 7.
The Python engine wheel (subprocess-bundled or PyO3) — Decision 0020,
Draft and not ratified; the subprocess-vs-PyO3 choice is open, tracked in
issue #161 and
../plans/python-engine-binary-wheel.md.
Decision 0018 —
distribution and installation model: the channel-per-artifact split, why
Linux isn’t binstall-able, the Homebrew tap, and the still-open CUDA/HIP
and container phases.
Decision 0020 — Python
engine as a bundled binary wheel: Draft, not ratified; subprocess-wheel vs.
PyO3 is an open follow-on decision.
A decision record captures a choice worth understanding later: the context, the
options considered, and the rationale for the one taken. It is the why behind a
piece of the architecture. The what the code does now lives in the
architecture reference layer, one doc per area; each decision
links forward to the area doc it shaped, and each area section links back to the
decisions behind it.
Because the reference layer carries current state, a decision record does not have
to. So it is written the way history should be: immutable and past-tense. A
decision is a record of a choice at the moment it was made, not a description of
the system now. It is not rewritten when the code moves — the reference doc is
edited instead. When a decision itself changes, a new record supersedes the
old; the old one’s status becomes Superseded and it moves to
archive/. This is the Nygard model, and it is what stops a decision
record from rotting: history can’t go stale.
This reverses an earlier “living decision record, edited in place” convention. It
does not bring back dated Amendment blocks accreting inside one document —
both models reject those. It changes only what happens when a decision changes:
edit the reference doc to match the code, and write a new record for the new why.
A record’s Status is a claim about the codebase, not an aspiration. Before
setting or changing it, verify the claim against the implementation — read the
code; don’t trust the previous status or a feature’s “done” framing.
Don’t bump Proposed → Accepted just because a design merged. Confirm the
decision is actually in effect in the code.
When a design is ratified but only partly built, use Accepted (partial)
and add an ## Implementation status section splitting implemented (with
file references) from deferred (with the specific gap).
Decision 0012 is the worked example.
Deferred work gets a home: a plan under docs/plans/ and a tracking issue,
cross-linked from the status section, so the unbuilt half isn’t lost.
Present-tense state belongs in the reference layer, where the same rule applies:
a sentence telling a reader how the tool behaves today is a verifiable claim —
check it against the code before writing it.
This split exists because one document can’t age three kinds of claim well, and
bundling them is what made the old records go stale:
Why the decision was made — the rationale, and the problem that prompted
it. This is a decision record’s lasting value; the code and git log show the
what, only the record holds the why. Written as the state at decision time
(past tense) and never rewritten when the code moves. The problem is worth
keeping even after it’s solved.
What the current architecture is — what the code does today. A verifiable
present-tense claim, kept in step with the code. This is what goes stale, so it
lives in the reference layer, not here. Each “what” links back
to the “why” that produced it.
What’s decided but not built yet — the gap between a decision and the code.
Lives in an ## Implementation status section (on the reference doc, or on a
Proposed record whose whole body is bucket 3), and drains into (2) as each
piece lands, with a plan or issue so the unbuilt half isn’t lost.
The failure mode is present-tensing all three the same way, so a reader can’t tell
whether a sentence is why we did it, what the code does, or what we still
intend. A human skims past a wrong present-tense claim; a model repeats it as
generated code — which is why the reference layer stays ruthlessly current and
this layer stays rationale-only.
0014 / 0015 document the core simulation pipeline: 0014 explains why the AIG
(and-inverter graph) is the simulation IR — its uniform AND-gate structure
enables the boomerang reduction tree and eliminates per-cell dispatch in the GPU
kernel. 0015 describes the boomerang execution model itself — the 13-level
hierarchical reduction tree, the GPU resource limits it imposes (8191 inputs,
8191 outputs, 4095 intermediates, 64 SRAM groups per partition), the hypergraph
partitioning that distributes work across GPU blocks, and the packed instruction
format (FlattenedScriptV1) consumed by the kernel. Together they document the
path from gate-level Verilog to GPU kernel execution that the GEM paper describes.
0001 / 0003 / 0005 / 0006 describe the timing oracle stack: OpenSTA as the
ground truth (0001), vendored at a pinned revision with its own corpus reused
(0005), driving SDF preprocessing out-of-process (0006). The earlier OpenTimer
in-process plan (0003) was retired after the spike
(spikes/opentimer-sky130.md) and is archived.
0002 is the data contract those tools talk over — a JSON timing IR consumed
by Jacquard, produced by opensta-to-ir.
0004 governs how PDK-specific testing happens for NDA-bound contributors
without leaking files into the public repo.
0007 / 0008 are the forward-looking pair: 0008 (Approved) defines the
structured timing output Jacquard owes downstream flows; 0007 (Proposed)
sketches the model-fidelity work needed to back those outputs at scale (δ(T),
clock-tree skew, wire delay). Scheduling for both lives in
../../plans/post-phase-0-roadmap.md.
0013 / 0017 cover the cosim runtime, whose current state is
../cosim-runtime.md: 0013 documents the peripheral model
architecture (CPU-side PeripheralModel trait, GPU-side kernel patterns, ring
buffers, plural-config convention); 0017 documents the execution model (batch
dispatch loop, multi-clock scheduler, edges-vs-cycles semantics).
0016 accepts the selective X-propagation design documented in
docs/selective-x-propagation.md. The full
seven-phase design lives there; the record is a thin acceptance record with a
summary of key choices.
Pick the next number (highest existing + 1). The 4-digit number is a stable ID,
cited as “Decision NNNN” from docs, code, and commits — it must not be reused.
Filename: NNNN-short-kebab-title.md. The rendered title comes from the
SUMMARY.md link text and the # H1; keep the number out of the reading title.
Start with # Decision NNNN — <title> and a **Status:** line — set it to
match the code, not the intent (see Keeping status honest).
Standard sections: Context, Decision, Consequences — written in past tense, as
the state at decision time.
Add the row to the index above, and a forward-link to the area doc in the
reference layer whose current state this decision shaped.
When a decision changes later, do not edit its record: write a new record
that supersedes it, set the old one’s status to Superseded, and move the old
file to archive/ with
python3 docs/scripts/move_doc.py docs/architecture/decisions/<file>.md docs/architecture/decisions/archive/,
which repoints every link to it.
Amendment (2026-06-25): Decision §1’s claim that “OpenSTA is never
invoked from the jacquard runtime binary” is no longer true. opensta-to-ir
is a direct dependency (Cargo.toml) and is the shipping SDF path
(src/sim/setup.rsload_sdf_via_opensta_to_ir); Decision 0006’s amendment
ratified invoking it as a subprocess at runtime. The original §1 text is
retained below as the record of the initial decision.
Jacquard’s current correctness validation for timing relies on its own CPU reference simulator (--check-with-cpu), which shares the Rust source tree, data structures, and parsers with the GPU simulation path. Representation bugs (e.g., hierarchical SDF prefix mismatch, inverter-collapse issues) have passed both paths silently because they affect both.
Historical regressions have been caught only by comparing against genuinely external tools — specifically CVC for functional simulation and, by implication, OpenSTA for timing. No format or tool inside Jacquard is currently treated as authoritative.
OpenSTA is widely deployed in open-source EDA (SKY130, OpenLane2, OpenROAD) and has the largest effective test surface of any open-source STA tool for the Liberty + SDF + Verilog + SPEF stack. It is licensed under GPL-3.0 and also sold commercially.
Jacquard requires permissive licensing for code linked into its binary (see ../project-scope.md).
OpenSTA is the ground-truth oracle for timing correctness and the sole STA path used by Jacquard.
In the shipped release, OpenSTA is never invoked from the jacquard runtime binary, and never linked. Subprocess invocation from CI pipelines, test harnesses, and the standalone opensta-to-ir preprocessing tool (see Decision 0006) is acceptable — GPL’s reciprocal requirements do not cross a subprocess boundary (“mere aggregation”) and so Jacquard’s permissive licensing is preserved. Pre-release, a runtime subprocess invocation may exist as a contributor-ergonomics convenience (per Decision 0006); it is removed before release.
All timing, STA, and parser-related code paths are validated against OpenSTA on (a) a vendored subset of OpenSTA’s own test corpus, and (b) representative Jacquard test designs.
OpenSTA is also Jacquard’s only STA path, not just its oracle. Decision 0003 originally proposed an in-process reference STA via OpenTimer to complement this oracle role; the spike (../spikes/opentimer-sky130.md) found OpenTimer’s input pipeline unfit for OpenROAD-flow outputs (commit d002bde superseded Decision 0003). The role OpenTimer would have played — providing per-DFF clock arrival, structured timing data for the IR, etc. — now sits with OpenSTA, called out of process via opensta-to-ir. OpenSTA is therefore a required runtime dependency for any timing-aware Jacquard flow, not just for CI validation.
Where Jacquard’s output disagrees with OpenSTA’s output past a declared tolerance, Jacquard is wrong until proven otherwise. Divergence is either fixed, explicitly justified in writing, or filed as a bug.
OpenSTA is a required runtime dependency for timing-aware Jacquard flows (post §3 expansion), not merely a CI/validation dependency. Users running jacquard sim --timing-ir ... need a .jtir produced by opensta-to-ir, which subprocesses OpenSTA. Documented in ../why-jacquard.md.
“Oracle-diff clean” becomes a required CI gate for timing-related PRs, run nightly or pre-release (not per-PR — OpenSTA runs on large designs can be slow).
OpenSTA bugs may produce false-positive divergences. The expectation is to file upstream rather than work around silently. A pinned OpenSTA version in CI avoids drift. With OpenSTA now also the only STA path (not just the oracle), upstream regressions land in users’ hands too — pinning matters more than before.
A vendored OpenSTA test corpus (or git submodule) is added to the repo as a fixture. Licensing of specific test inputs is verified per file before inclusion.
No second STA tool to maintain. The original Decision 0003 proposal would have given Jacquard a permissive-licensed in-process reference; the spike showed that’s not achievable today with OpenTimer. A future ADR may revisit libreda-sta or an in-house walker if an in-process reference is wanted.
Decision 0002 — timing IR (the concrete diff format used for oracle comparison).
Decision 0003 — Superseded. OpenTimer in-process reference; spike Q2 fail moved Jacquard to OpenSTA-only. See ../spikes/opentimer-sky130.md for the spike outcome.
../why-jacquard.md — user-facing consequence: OpenSTA as a runtime dependency.
Amendment (2026-06-25): The vendor-extension type names in the
Decision are stale. The schema uses a VendorSource enum with variants
Cadence / Synopsys / Mentor / Other (timing_ir.fbs), not the
VendorCadence / VendorSynopsys / VendorOther names written below;
the Mentor variant was added and isn’t mentioned in the original text.
Jacquard currently parses SDF directly in src/sdf_parser.rs, a hand-rolled parser that has accumulated reactive fixes (empty () delays, (COND …) pin specs, backslash escapes, edge-qualified timing checks, TIMINGCHECK stripping workarounds for OpenLane2 output). Each new production failure has been a one-off patch.
Commercial tool output adds dialect variation (Cadence, Synopsys extensions). Future parser paths (Liberty, SPEF) and future reference tools (OpenSTA, OpenTimer) each carry their own data models. A format-per-consumer coupling structure will continue to spread parser complexity into the simulator.
The project needs:
A stable format we consume, with parser complexity isolated from simulator complexity.
A format that can be diffed between producers (two parsers of the same file must agree).
A format that supports multi-corner PVT values natively — commercial flows require this; single-corner shortcuts become retrofit pain.
Preservation of vendor-specific annotations so information is not silently discarded.
Fast consumption at sim startup (SDF parsing is currently on the critical path).
Introduce a timing intermediate representation (timing IR) for SDF-equivalent annotation data.
Binary format: FlatBuffers. Zero-copy reads, schema evolution, cross-language (Rust, C++ for OpenTimer adapter, Python for tooling).
Text sidecar: JSON, produced via FlatBuffers’ JSON round-trip, for CI diffs and human inspection.
Schema versioning: explicit version field, compatible-evolution rules stated in schema comments. Breaking changes require a major version bump and migration notes.
Multi-corner native: timing values are min / typ / max across a declared set of PVT corners. Single-corner designs are represented as a single-element corner set.
Vendor extension passthrough: typed VendorExtension variants (VendorCadence, VendorSynopsys, VendorOther) carry unrecognised annotations as byte-typed blobs with source labels. Consumers opt in to understanding them; the IR never silently drops them.
Per-arc provenance: each timing arc records source tool, source file, and origin category — asserted (from SDF / input), computed (derived by an STA tool), defaulted (fallback because no better value was available). Provenance is inspectable at consumer side.
Scope boundary: the IR represents timing annotation data only. It is not a netlist representation, not a timing graph, not cell characterization. Attempts to extend it toward those adjacent formats are rejected — they become separate IRs if needed.
A new schema and format to maintain. Scope discipline is load-bearing: if the IR creeps toward being a full STA framework, it becomes duplicate work with OpenSTA/OpenTimer.
Parser complexity moves out of src/sdf_parser.rs (and its future rewrite, per ADR covering #3) into a focused converter crate. Unit-testable in isolation.
A diff-based test corpus becomes natural: multiple converters on the same input must produce equivalent IR. This is the enforcement mechanism for Decision 0001’s oracle pattern.
Vendor extensions do not require Jacquard code changes — only converter updates.
Startup parse cost drops: reading binary IR is near-instant. SDF-to-IR conversion becomes a one-time preprocessing step, not repeated per sim.
Adopting FlatBuffers adds a code-generation step to the build, via flatc. Build hygiene (checked-in generated code, pinned flatc version, or a build-script integration) is required.
If the IR is ever shared across other tooling beyond Jacquard, its stability contract tightens. Flagged in open questions on timing-correctness.md; not resolved here.
The scope boundary above (“not cell characterization … they become
separate IRs if needed”) is now realised by
Decision 0019. Two timing artifacts must not be
conflated: this IR is per-design instance annotation (TimingArc { cell_instance }, SDF-equivalent for a specific netlist), whereas
per-cell-type timing characterization (setup/hold, clock→Q, today’s
liberty_parser::TimingLibrary) is a library property and lives in the
ADR-0019 cell-model IR alongside the cell’s logic. The two are orthogonal
axes — per-design vs per-library — not two halves of one scope. This
ADR’s decision is unchanged.
Decision 0001 — OpenSTA oracle (IR is the diff format).
Decision 0003 — Superseded. OpenTimer was the proposed in-process reference STA; spike Q2 fail moved Jacquard to OpenSTA-only via opensta-to-ir. See ../spikes/opentimer-sky130.md.
Decision 0004 — private PDK testing (IR enables portable fixtures without leaking PDK data).
Amendment (2026-06-25): Phase 0 shipped (2026-05-02), so the
“plumbing tracked in the phase-0 plan” rider no longer points at anything
in flight. What actually landed is the open-source GF180MCU path
(GF180MCU_LIBERTY_DIR); the commercial / NDA PDK track described in
the Decision body (the *_PDK_PATH env-gated, CI-runner-restricted flow)
is not yet implemented.
Some contributors and operators have access to commercial PDKs (GlobalFoundries, TSMC, and others) under NDA or licensing agreements that prohibit public redistribution of PDK files. Whether a given contributor has access is itself typically under NDA and not publicly known.
Commercial PDK Liberty libraries are substantially richer and quirkier than open-source alternatives — they include cell variants, conditional timing arcs, vendor-specific annotations, and characterization detail not present in SKY130 or AIGPDK. Several parser bugs live only on commercial PDK output.
SKY130-only coverage is insufficient for a sim tool used on commercial flows, and adding commercial PDK files to a public repository is not an option regardless of who operates the project.
The standard industry pattern for testing against proprietary PDKs is environment-gated test suites: tests run when the contributor has licensed access, and skip cleanly when they don’t.
Establish a private PDK test track gated on per-PDK environment variables (e.g. TSMC_PDK_PATH, and similar — one per PDK).
Tests check for the required env var(s) and skip with a clear “PDK not available” message when unset.
When env vars point to a readable PDK directory, tests execute fully.
Only the test harness, expected structural outputs, and IR fixtures (where the PDK vendor licensing permits) are committed.
No PDK-derived artifacts (.lib, .sdf, .spef, characterization data) are committed to the public repository under any circumstances.
CI runners with configured PDK access execute the private track; public PRs from non-licensed contributors see the private tests as skipped, not as failures. Which runners have access is determined by whoever operates CI; this ADR does not name specific organisations.
The timing IR (Decision 0002) makes this feasible: converter output and diff results can be checked in as fixtures where they contain no PDK-licensed data. Expected behaviour can be asserted in terms of IR structure rather than in terms of specific cell timings that would leak characterization data.
Contributors without PDK access cannot locally reproduce PDK-specific bugs. They rely on maintainer CI for validation.
A separate setup doc for licensed contributors is required (not public). Points at env-var configuration, test runner invocation, and PDK-file staging expectations.
Fixture schema must be PDK-agnostic enough that structural assertions don’t implicitly leak cell-characterization data. Review process must check new fixtures against this rule before merge.
Bugs found via private PDK testing are, where possible, distilled into minimal public reproducers. The private track is not a place to park unreviewable tests — every private test should ideally surface a public fixture once the bug’s essence is extracted.
CI cost rises (licensed runners). Runs are nightly or pre-release rather than per-PR.
Amendment (2026-06-25): The corpus contents described below
(SKY130 MCU SoC, NVDLA, AIGPDK examples, representative SDFs) are
aspirational, not current. As shipped, the primary corpus contains a
single entry — aigpdk_dff_chain — with the SKY130/MCU/NVDLA entries
marked pending (blocked on a sky130-Liberty CI strategy), and the
stress corpus is empty (tests/timing_ir/stress/manifest.toml →
entries = [], pending the Phase 1 stress runner). The vendoring +
corpus-split architecture is implemented as described.
Under Decision 0001, OpenSTA is the ground-truth oracle for timing correctness, invoked as a subprocess. Phase 0 (../../plans/archive/phase-0-ir-and-oracle.md) requires:
A reproducible, pinned OpenSTA reference so CI diffs are comparable run-to-run.
Access to OpenSTA’s test inputs for stress testing our OpenSTA-driven converters.
Separately, a primary regression corpus representative of Jacquard’s actual use cases.
Two questions were considered jointly: (a) how we pin / reference the OpenSTA codebase, and (b) how we use their test data.
On vendoring source: OpenSTA is licensed GPL-3.0. Copying its source into Jacquard’s repository as committed code creates licensing ambiguity for a permissive-licensed project. Git submodules are conventionally treated differently — the parent repository pins a commit reference, does not incorporate the submodule’s source into its own commits, and inherits no license obligations from the submodule’s presence. This convention is widely relied on in permissive projects that depend on GPL tooling at arm’s length.
On test data: OpenSTA’s corpus exercises OpenSTA’s concerns — Liberty parsing edge cases, SI-aware analysis, timing-check variants specific to its engine. Much of it does not exercise anything Jacquard does, and some of it exercises features Jacquard deliberately does not support. Using it as the primary regression corpus would optimise for the wrong target: our converters would be validated against files OpenSTA cares about, not files Jacquard actually encounters.
Its real value to Jacquard is as a stress / robustness corpus: a large bank of real-world-ish timing files that exercise parser edge cases and dialect variants. A converter that survives their entire corpus is more robust than one validated against a hand-curated subset.
OpenSTA is vendored as a git submodule at vendor/opensta/.
The submodule is not built from Jacquard’s build. Jacquard’s subprocess invocations use whatever OpenSTA binary is installed in the developer or CI environment.
The submodule exists for two purposes only: (a) pinning a specific OpenSTA version for CI reproducibility, (b) providing in-tree access to its test corpus without redistribution.
Licensing: by git-submodule convention, the submodule’s GPL-3.0 licence does not extend to the parent repository. This is the standard interpretation; contributors redistributing binaries or compiled artefacts should nonetheless verify the interpretation applies to their specific jurisdiction and use.
If a stress-corpus file exposes a bug, a minimal reproducer may be distilled and added to the primary corpus. When doing so:
Verify the specific file’s licence before copying. OpenSTA’s overall GPL-3.0 licence does not imply every test input is GPL-3.0 — some test inputs are vendor-derived or public-domain.
Prefer distilling a synthetic minimal reproducer over copying the original file wholesale.
CI reproducibility: pinned submodule means we control when OpenSTA version changes land. Bumping the pin is an explicit, reviewable step.
Repository size grows by OpenSTA’s submodule size (multi-megabyte) but not by test-data duplication.
Maintenance cadence: periodic submodule pin updates are a known maintenance item. Not frequent, but not zero.
Primary regression corpus stays lean and directly relevant; developers can reproduce corpus-level failures locally without pulling the entire submodule.
Stress-corpus failures are treated as bugs against our converter, never as bugs against OpenSTA’s test inputs.
Licensing posture is conventionally defensible; if stronger legal assurance is ever required, the submodule can be replaced by the external-install-only option (drop the submodule, rely purely on whatever OpenSTA is installed) at the cost of losing in-tree test access.
The original Decision treated subprocess invocation of OpenSTA from the shipped Jacquard runtime as license-incompatible, requiring Phase 3 (native Rust SDF→IR converter) to land before first release. On review of GPL-3 § 5 (“aggregate”) and the FSF interpretation of subprocess/IPC boundaries, this restriction is more conservative than necessary. The relevant facts:
The interface is arms-length: standard EDA interchange formats (Liberty / Verilog / SDF / SPEF / SDC) in, our own IR JSON (Decision 0002) out. No shared data structures, no headers, no linking.
We do not bundle OpenSTA in any Jacquard distribution. The user installs OpenSTA themselves; user-side combination of separately-distributed programs is not “distribution of a combined work” under GPL-3.
The original “no runtime subprocess” rule was effectively a commercial-perception buffer, not a strict licensing requirement.
Revised bright lines (these supersede the original “Shipped release” sub-section):
No linking of GPL code into the Jacquard binary. Unchanged.
No bundling of OpenSTA (or any GPL tool) in Jacquard distribution artefacts (release tarballs, Homebrew formulae, Docker images that ship as Jacquard releases). If a packager wants to bundle, they take on GPL distribution obligations themselves.
Subprocess invocation of user-installed OpenSTA from the shipped runtime is permitted.jacquard sim input.sdf may keep its opensta-to-ir subprocess hook in shipped releases, provided OpenSTA is discovered on PATH rather than bundled.
Phase 3 reclassification. Native Rust SDF→IR converter is no longer release-gating. It remains a goal — for ergonomics (no OpenSTA install required) and for downstream commercial integrators whose legal teams treat any GPL touchpoint as risk — but ships when bandwidth allows, not as a release blocker. Roadmap consequences are tracked in ../plans/post-phase-0-roadmap.md § Phase 3.
Corequisite — OpenSTA detection and version check (release-blocking). Relaxing the no-runtime-subprocess rule is conditional on the shipped runtime giving users a meaningful error when OpenSTA is missing or out-of-date. Today (src/sim/setup.rs:248-264), missing OpenSTA only emits a warn! and the simulation proceeds with no timing data loaded — acceptable during development, ships as a UX bug. Concretely, before first release we must:
Hard-fail (not warn) when --sdf is requested and OpenSTA cannot be located.
Probe OpenSTA’s version on first invocation and fail with a remediation message if it is older than the version pinned in vendor/opensta/ (per Decision 0005).
Warn-but-proceed if the detected version is newer than the latest tested version, naming the version in the warning.
Document the OpenSTA dependency in docs/synthesis-flow.md.
Tracked as WS-RH.1 in ../plans/post-phase-0-roadmap.md § Release hardening.
Code-comment cleanup follow-up. The INTERIM per Decision 0006 / Pre-release only tags in src/sim/setup.rs (lines ~176, ~228, ~286) and src/bin/jacquard.rs (~187) describe a premise that no longer applies. Folded into WS-RH.1 (../plans/post-phase-0-roadmap.md § Release hardening) rather than spun out as a separate cleanup commit.
The original Context, Decision (Phase 0 + Phase 3), and Walk-back sections below are retained for historical record. Where they conflict with the bright lines above, the bright lines win.
Jacquard’s hand-rolled SDF parser (src/sdf_parser.rs) has accumulated reactive maintenance over time — empty () delays, (COND …) pin specs, escape handling, edge-qualified timing checks, TIMINGCHECK-stripping workarounds for OpenLane2 output. Each production failure has been a one-off patch. The timing-correctness review flagged this as issue #3, and a native Rust grammar-based replacement is the Phase 3 deliverable.
Concurrently, Decision 0001 establishes OpenSTA as the timing correctness oracle (subprocess, never linked, GPL), and Decision 0002 introduces a timing IR that decouples parsing from consumption.
Two facts together shape the decision:
No release pressure. Release can happen after Phase 3 lands. We are not forced to keep the hand-rolled parser alive while waiting on Phase 3.
Permissive-license constraint applies to the shipped binary. Subprocess invocation of GPL tooling is acceptable — does not trigger reciprocal obligations — and during pre-release development, even in-runtime subprocess invocation does not violate the constraint because no runtime binary is being distributed.
Given these, maintaining the hand-rolled parser through Phase 0–2 is unnecessary. OpenSTA’s mature dialect coverage can substitute, via subprocess, while we build toward a native Rust replacement at our own pace.
Delete src/sdf_parser.rs and the SDF→Jacquard-internal-types code path. All paths that previously consumed SDF now consume timing IR.
Ship opensta-to-ir as a standalone preprocessing tool that consumes Liberty + Verilog + SDF + SPEF + SDC and emits timing IR. Subprocess-based on OpenSTA. Production-quality: stable CLI, documented exit codes, clear diagnostics.
Canonical runtime path is jacquard sim --timing-ir <path>, consuming pre-converted IR. This path works without OpenSTA on the user’s machine — pre-converted IR is sufficient.
Interim ergonomic path: during development (pre-release only), jacquard sim input.sdf subprocesses opensta-to-ir internally to produce IR on the fly. This is a contributor convenience, not a shipping feature. Flag exists in code as pre-release only with a clear comment tying back to this ADR.
Native Rust SDF→IR converter replaces the OpenSTA subprocess call inside jacquard sim input.sdf. Grammar-based (nom / pest), validated against OpenSTA on the corpus per Decision 0001.
No OpenSTA invocation from the jacquard runtime binary. The native Rust converter handles SDF inputs directly.
opensta-to-ir remains as an alternative preprocessing tool. Users who want OpenSTA-computed timing may use it; subprocess model preserves permissive licensing.
If OpenSTA dialect coverage proves insufficient during Phase 0 — e.g., a current Jacquard-supported SDF fails to parse — add dialect shims to opensta-to-ir’s post-processing. Reinstating the hand-rolled parser is the last resort, not the first.
If the Phase 3 Rust rewrite stalls — ship the first release with preprocessing-only (no jacquard sim input.sdf path), remove the interim subprocess, and land the native converter in a later release. No information lost; users preprocess manually. This is already the post-release shape for opensta-to-ir; it’s only the jacquard sim input.sdf convenience that would be deferred.
If OpenSTA becomes unmaintainable or disappears — the submodule pin (Decision 0005) remains authoritative for the integrated version. A forked submodule can maintain any necessary patches.
Jacquard’s repository stops carrying a hand-rolled SDF parser as a reactive-maintenance target. Bugs in SDF interpretation between Phase 0 and Phase 3 are OpenSTA’s problem (upstream) or opensta-to-ir post-processing’s problem, not Jacquard’s core codebase’s problem.
Pre-release ergonomic one-step workflow for contributors is preserved.
Contributors running Jacquard on a new design (no pre-converted IR) must have OpenSTA installed during Phase 0 through Phase 3. For existing primary-corpus designs, pre-converted IR is checked in; no OpenSTA needed.
Release-time check is unambiguous: either the runtime subprocess is replaced by native code, or it is removed entirely. Both outcomes satisfy the permissive-licensing constraint for the shipped binary.
Test corpus regenerable: if OpenSTA updates change IR output, golden files are regenerated deliberately (reviewable diff), not silently.
Amendment (2026-06-25): The roadmap is still Proposed/unbuilt, but
several src/ line references below have drifted as the code moved — most
notably the wire-delay lumping code, cited as flatten.rs:1850-1872, now
lives around flatten.rs:2030-2051 (the old location is unrelated code).
Treat the file:line citations as approximate; verify against current
flatten.rs / aig.rs before relying on them.
Jacquard’s timing model today consumes SDF-equivalent annotations via the timing IR (Decision 0002), produced and validated by OpenSTA called out of process (Decision 0001 — sole STA path; Decision 0003’s in-process OpenTimer alternative was Superseded by the spike). The accuracy contract at present is “±5% on arrival times against CVC reference” per timing-validation.md. This is acceptable for sky130-class designs at ≥10 ns clock periods.
Three structural simplifications in the current implementation become accuracy bottlenecks at scale:
Static δ∞ per gate. No pulse-degradation modelling. Glitch behaviour and short-pulse propagation cannot be represented. The Involution Delay Model (Maier 2021, arXiv:2107.06814) demonstrates this is the root cause of inertial-delay’s known failure modes, and provides a model that’s both faithful and implementable.
Zero clock-tree skew. During AIG construction (src/aig.rs:495-560), clock buffers/inverters/gating cells collapse to a single polarity flag on the DFF. SDF arcs and interconnect on the clock tree are silently dropped. Every DFF on a clock domain is treated as capturing simultaneously.
Per-cell-max wire delay.src/flatten.rs:1850-1872 lumps all interconnect arrivals at a destination cell into a single max value, with no rise/fall distinction. Adequate for short local routes; incorrect for long routes where wire delay rivals or exceeds gate delay (typical of NoCs at 22nm and faster).
The full design analysis is in docs/timing-model-extensions.md. This ADR captures the decision to commit to closing these three gaps as a roadmap, sets the staged ordering, and constrains how the implementation may evolve.
Adopt a three-pillar roadmap for closing the fidelity gap with CVC, while preserving Jacquard’s GPU-throughput advantage. All three pillars are consumer-side work (src/flatten.rs, src/aig.rs, src/sim/cosim_metal.rs, the kernel arrival math); none require schema changes inconsistent with Decision 0002 nor abandoning the cycle-accurate boomerang kernel architecture.
Per-gate dynamic delay parameterised on T (time since last output transition). Three accuracy tiers:
Static IDM. Bake worst-case δ(T) into existing per-thread script slot using STA pulse-width estimates. No kernel change.
Dynamic δ(T). Add last_transition_ps and last_value persistent buffers per AIG pin; kernel evaluates δ(T) from a small per-cell LUT during arrival propagation.
Sub-cycle ticks. Multiple arrival propagations per logical cycle, enabling true glitch suppression. Out of scope by this ADR. Would require a different kernel architecture; if pursued, requires its own ADR superseding this one.
Per-DFF clock arrival accounting via TimingIR extension (ClockArrival table) populated by OpenSTA via opensta-to-ir (Decision 0001 — Decision 0003’s OpenTimer alternative is Superseded). Per-pair CRPR is intentionally not modelled at this stage; per-DFF capture-side arrival is, treating launch as the 0-reference. Consumed by extending DFFConstraint with a clock_arrival_ps: i16 field, folded into the existing per-word setup/hold check in src/flatten.rs via DFFConstraint::effective_setup_hold. No kernel change for the baseline case; bucketed packing is an option if pessimism becomes material. Stages 1+2 landed: commits c403cc8 (producer) and 6767c3e (consumer).
Tier 1: Per-receiver consumption. Key wire delay by (src_aigpin, dst_aigpin) edge in the AIG, with rise/fall distinction preserved. Mostly a src/flatten.rs:1850-1872 rewrite. No kernel change.
Tier 2: Inter-partition arc delay. Explicit modelling of wire delay on partition-crossing signals. Touches src/sim/cosim_metal.rs shuffle pipeline. Required for many-core/NoC designs at advanced processes.
Tier 3: NoC-aware partitioning hints. Soft bias in src/repcut.rs favouring cuts on flagged net patterns. Optional optimisation that makes Tier 2 cheap on tile-decomposed designs.
Pillar B Stage 1+2 is the cheapest accuracy improvement. Originally gated on the (now Superseded) OpenTimer integration; landed early on top of the OpenSTA-out-of-process path instead. See commits c403cc8/6767c3e.
Pillar C Tier 1 is independent of which STA tool feeds the IR and can proceed any time.
Pillar A Stage 1 (Static IDM) is the cheapest δ(T) entry point, gated on per-cell SPICE characterisation effort. Schedule this only after Pillars B and C land — δ(T) compounds on top of correct wire/skew baseline; doing it earlier risks chasing characterisation noise that’s actually wire-delay error.
Pillar C Tier 2 lands when a real many-core/NoC use case appears in the test corpus and Tier 1 measurement shows it’s needed.
Pillar A Stage 2 (Dynamic δ(T)) is a substantial implementation; schedule only when Stage 1 reports indicate the value is real, and a contributor with the analog-characterisation domain expertise is willing to lead it.
Pillar A Stage 3 (Sub-cycle ticks) is explicitly out of scope of this ADR.
Each pillar lands with regression coverage extending timing-validation.md’s ±5% tolerance. Tighter tolerances may apply per pillar (Pillar B should achieve ≤±2% on skew-aware paths with OpenSTA-fed per-DFF arrival as currently implemented; Pillar C Tier 1 should achieve ≤±3% on long-wire paths).
Each pillar must demonstrate no regression on the existing primary corpus before merge.
The IR schema may be extended (additive only) per Decision 0002 to carry pillar-specific data. Extensions require a minor schema bump and a documented consumer-version compatibility note.
The “±5%” line in timing-validation.md becomes a per-pillar specification rather than a single number. The doc is updated as each pillar lands.
crates/timing-ir/schemas/timing_ir.fbs accumulates additive extensions for clock arrival and per-cell δ(T) parameters. Schema versioning per Decision 0002 governs.
No changes to the cycle-accurate boomerang kernel architecture. The cost of preserving that architecture is permanent: no glitch propagation, no metastability oscillation, no asynchronous handling. These remain non-goals (per project-scope.md) unless a future ADR explicitly supersedes this position.
Per-cell SPICE characterisation effort is acknowledged as the long-pole risk for Pillar A. If characterisation cost proves prohibitive, Pillar A reduces to “Stage 1 only, using Liberty-derived ECSM/CCSM data as approximation,” and the gap with CVC’s full IDM fidelity remains open. This is acceptable; Pillar A Stage 2 is not a release-gating commitment.
Jacquard’s positioning (why-jacquard.md) becomes coherent: STA-complement-not-replacement, vector-driven timing at GPU scale, fidelity comparable to CVC where the cycle-accurate kernel architecture allows.
If a pillar’s measurement shows the accuracy gain is smaller than expected, descope it. Each pillar’s first stage is sized to deliver measurable improvement; if it doesn’t, later stages of that pillar are deferred or abandoned.
If the IR schema extensions cause downstream tooling friction, fall back to vendor-extension passthrough (VendorExtension in timing_ir.fbs) until the typed schema stabilises. Already supported.
OpenTimer integration was retired (Decision 0003 Superseded by the spike outcome). Pillar B did not need the documented fallback to manual clock-tree accumulation in src/aig.rs — OpenSTA’s per-pin arrival via opensta-to-ir covers the same ground without the per-pair CRPR credit (deferred to Stage 3 if measurement justifies it).
Amendment (2026-06-25): Two refinements to claims below.
(1) The structured outputs (--timing-report <json>, --timing-summary,
symbolic violation messages) are no longer Metal-only — CUDA and HIP
sim now route setup/hold violations through process_events (commit
24723b5; jacquard.rs sim_cuda/sim_hip). The remaining gap is the
cosim path, which does not yet emit --timing-report. (2) Any
reference to a --worst-slack-n flag is aspirational — it is not
implemented; the report’s worst_slack arrays use a fixed top-N.
Original decision unchanged below.
Jacquard produces timing information today through three channels: timed VCD (--timed), per-violation clilog::warn! messages on stderr, and an in-process SimStats counter. The why-jacquard.md analysis identifies a gap between the timing data Jacquard has internally and the answers users actually need from a flow:
User question
Today
Did my workload trip any violations?
SimStats counts (in-process API only)
Which DFFs nearly missed timing?
Not extractable without parsing stderr
Show me arrival distribution per signal
Reconstructable from –timed via post-processing only
Which DFF was that violation on?
State-word index + manual lookup
What path caused the worst arrival?
Not available
Run this in CI and fail if any violation
Possible only via stderr grep
The most acute problem: stderr violation messages identify a state-word index, not a signal name. Mapping back to “which DFF, which path” requires manual investigation. On a violating design the message volume can be enormous (one warning per word per cycle per type). The data needed to do better — hierarchical signal names, DFF instance paths, per-DFF arrival distributions — already exists in the netlistdb and event buffer; it is simply not surfaced in usable form.
This ADR is about making Jacquard’s timing output useful in a real flow rather than merely produced. The substantive work in Decision 0007 (model fidelity) is wasted if no one can extract the answers.
The full design analysis is in docs/why-jacquard.md, “Output interface” section.
Treat structured, machine-readable timing output as a first-class shipping deliverable, not an optional improvement. Land the work in priority order, where priority is set by user impact per implementation cost not by technical interest.
The following are required for Jacquard to be considered usable for vector-driven timing analysis in a real flow. They land before any further fidelity work past Decision 0007 Pillar B Stage 1+2.
Symbolic violation messages. Replace state-word indices with hierarchical signal names in stderr violation output. Mapping data already exists in netlistdb. Cost: contained edit in src/event_buffer.rs:305-338 plus name-resolution helper. Highest UX impact per LoC of any improvement on this list.
--timing-report <path.json>. Structured JSON document at end-of-run containing:
Per-DFF worst arrival, worst slack, violation count over the run.
Per-cycle violation list (cycle, signal name, hierarchical path, arrival, constraint, slack).
Aggregate stats: total violations, distribution buckets, peak arrival per clock domain.
Run metadata: clock period, SDF/IR file, design hash, vector source.
Required for CI integration and any downstream tooling. Schema versioned; additive extension policy mirrors crates/timing-ir.
--timing-summary. Fast text summary, no VCD. Designed for scripts and human inspection of long runs. Contents:
Vectors run, clock period, corner.
Setup/hold violation totals.
Worst-slack DFF (setup and hold) with hierarchical path.
Peak arrival per writeout vs clock budget, with margin percentage.
Cost: trivial wrapper over (2)’s data.
Per-DFF worst-slack ranking. Top-N DFFs by closest-to-violation slack across the entire run, even when no violation occurred. Surfaces “where am I close to the edge” without requiring a violation to actually trip. Output as part of (2) and (3); also accessible via a dedicated --worst-slack-n N flag for quick inspection.
The following are higher-value-but-lower-priority. They land after the four required items above, in any order driven by user demand.
--arrival-histogram <pattern>. Per-signal arrival histogram dump for matched signal patterns, as JSON or CSV. Foundation for activity-based power analysis.
--sta-cross-reference <opensta-paths.txt>. Cross-reference OpenSTA’s critical-path report against observed worst arrivals. Closes the loop between vector-driven and static analysis. Coverage-style “of the top-N STA paths, which were exercised, and at what observed arrival.” (Originally framed against OpenTimer; Decision 0003 was Superseded — OpenSTA is the only STA tool Jacquard interoperates with now.)
Path-back-trace from worst-arrival DFF. Given a flagged DFF, walk the max-of-fanin chain backward to the source AIG pin / primary input, emitting the path with per-edge contribution. Most expensive item on this list; only useful once the cheaper items are in place.
All new outputs are opt-in via flags. Existing stderr behaviour and --timed semantics are unchanged.
Symbolic violation messages (item 1) do change existing stderr format. This is intentional: the current state-word-index format is not a stable contract and is not consumed by any known automation. Format change documented in changelog at land time.
The --timing-report JSON is a stable consumer-facing format. Schema versioned. Additive-only extensions per the IR convention; breaking changes require a major version bump and a transition period.
--timing-summary is human-readable and explicitly not stable for parsing. Tools should consume the JSON.
Stderr violation messages remain human-oriented; tools should not parse them.
Jacquard becomes usable in CI without bespoke stderr parsing. Existing users who scrape stderr will need to migrate to the JSON report; the migration window is the release in which symbolic messages land.
The SimStats in-process API gains a public counterpart: end-of-run JSON. This raises the bar for changes to either — they must agree.
Documentation gains a “Jacquard timing report format” reference page. Sample reports from the corpus designs are checked in to tests/timing_ir/corpus/ alongside golden IR.
The why-jacquard.md positioning becomes truthful: the user-facing claim “vector-driven setup/hold answers at GPU scale” is backed by an interface that delivers them.
If the JSON schema causes consumer-tooling friction, the format may be extended additively but not narrowed. Existing consumers must continue to work. If a fundamental rethink is required, ship a v2 alongside v1 with a deprecation window.
If symbolic name resolution is too slow at scale (millions of DFFs, very long runs), the resolution step becomes opt-in via flag, with the existing state-word-index format retained as a fast-path default. No evidence yet that this is a problem; treated as a deferred consequence.
If users specifically want the path-back-trace (item 7) before the cheaper items are scheduled, it can be promoted, but only once items 1–4 are in place. Path-back-trace without symbolic names is unusable.
Items 1–4 are a single workstream, ~2 weeks total. They constitute the “Jacquard is now usable” bar. Items 5–7 are scheduled per user demand after that.
Amendment (2026-06-25): The claim that the filter has “integration
test coverage in tests/opensta_integration.rs” is imprecise — the
module-filtering tests are unit tests in src/verilog_filter.rs
(tests/opensta_integration.rs covers the OpenSTA end-to-end run, not
the filter). Decision unchanged.
OpenSTA’s read_verilog Tcl command is structural-only: it accepts cell
instantiations and bare-net assign statements but rejects RTL
operators (~, &, |, ^), bit-selects in assigns, and ranged
concatenations. Violations surface as Error: <file> line <N>, syntax error and exit 1. This is a long-standing OpenSTA limitation, not a
flag.
Two patterns make this surprising in practice — both have already
caught us once:
Final-stage outputs from the LibreLane/OpenROAD flow are
sometimes wrapped. LibreLane itself only ever reads structural
netlists (<design>.pnl.v — verified locally on
chip_top.pnl.v: zero RTL operators, single module). The wrapping
is added by downstream integration tooling — for the SkyWater
openframe flow, chipflow’s harness wraps the LibreLane output in
openframe_project_wrapper to patch active-low OEB pins into the
pad ring, producing the assign gpio_oeb[0] = ~( ... ); pattern.
The combined file (tests/mcu_soc/data/6_final.v) contains both
the readable-by-OpenSTA structural top module and the
wrapper’s unreadable RTL. The SDF was generated against the inner
top, not the wrapper — matching what LibreLane’s own STA saw.
Post-synthesis Verilog has the right form but the wrong cells.
Pre-P&R synthesis output (e.g. top_synth.v) is fully structural
and uses the same module name top as the post-P&R body, so it
looks like an acceptable substitute. It is not: the SDF
references hundreds of thousands of P&R-inserted cells
(clkbuf_regs_* CTS buffers, ANTENNA_* diodes, delaybuf_*,
fillers) that simply do not exist in synthesis output. OpenSTA
quietly drops SDF entries whose endpoints are not in the loaded
design; the resulting IR back-annotates only the surviving subset.
Concrete numbers from the MCU SoC fixture: top_synth.v has
31,500 cells; module top inside 6_final.v has 266,746. Feeding
top_synth.v would silently drop ~88% of the design’s structure.
Past convention (docs/plans/ws3-cosim-sdf-followup.md, pre
2026-05-18) recommended substituting top_synth.v to dodge the
wrapper-parse error. The contemporaneous verification log
(28162 matched, 2090 unmatched) reported the jtir-to-cosim-netlist
match rate, not SDF coverage against the jtir — high surface
“working” while the IR was missing most of the design’s real
timing. That recommendation is retracted in the same change as this
ADR lands.
The “structural-only” constraint is owned by opensta-to-ir,
not by the caller. Specifically:
opensta-to-ir filters Verilog inputs at invocation time. For
each --verilog file, it extracts the module <--top> … endmodule block before handing files to OpenSTA. Files that do
not contain module <--top> (sub-module-only files in
hierarchical designs) are passed through unchanged. The wrapper
modules that LibreLane + wafer.space integration adds — and any
future analogues — are simply not seen by OpenSTA. Implementation
in crates/opensta-to-ir/src/verilog_filter.rs; integration test
coverage in tests/opensta_integration.rs.
The cell-set match against the SDF is the caller’s
responsibility.opensta-to-ir cannot determine programmatically
whether a given Verilog input is the right design stage for a
given SDF. The CI fixture comment in prepare-mcu-soc-jtir
captures the rule for sky130 mcu_soc; copy the spirit (use the
post-P&R structural body, not synthesis output) when adding new
fixtures, but don’t copy a per-design extraction recipe — there
no longer is one to copy.
Architectural alternative (separate concern): the upstream
chipflow harness could preserve LibreLane’s pre-wrap <top>.pnl.v
alongside its wrapped <top>_final.v output. That would make
opensta-to-ir’s in-tool extraction a no-op for the common chipflow
case, but it would not obviate the filter — third-party LibreLane +
wafer.space users (hazard3 and future tapeouts using the vanilla
flow) hit the same wrapper pattern. The filter is the right place
for the fix because it covers both opensta-to-ir as a CLI and
jacquard sim --sdf (which subprocesses opensta-to-ir).
End-user runs of jacquard sim --sdf <path> and the standalone
opensta-to-ir tool both transparently handle the LibreLane +
wafer.space wrapper pattern. No flags, no preprocessing recipe in
user-facing docs.
Match-rate metrics in the IR consumer measure jtir coverage
against the consuming netlist, not against the source SDF. A
high match rate is necessary but not sufficient — confirm the jtir
contains the post-P&R cell population separately (e.g. by spot
checking for clkbuf_regs_* / ANTENNA_* arcs in the IR JSON
sidecar) before declaring a flow “working”.
The filter assumes module <--top> … endmodule is line-anchored
in the Verilog source. Machine-generated post-P&R netlists meet
this; hand-rolled Verilog that opens a module mid-line would not.
If that ever surfaces, upgrade the filter to use a real Verilog
tokenizer (sverilogparse is already a workspace dependency).
This ADR retroactively retracts the top_synth.v recommendation
in docs/plans/ws3-cosim-sdf-followup.md; that doc is corrected
in the same change.
Amendment (2026-06-25): The work this ADR “deferred to a future ADR”
(explicit RAM port mapping) was delivered as Decision 0011
— schema v1.1 (src/cell_library.rs accepts "1.0" and "1.1"). The
“Deferred” section below should be read as delivered, not open.
PDK enablement today is per-PDK code + vendored Verilog (see
src/sky130.rs, src/gf180mcu.rs, src/gf180mcu_pdk.rs, the
build.rs pin-table scanner). Adding a new cell family — third-party
IP memories, hard macros, foundry-supplied blocks — requires
vendoring Verilog into jacquard/vendor/, extending the build.rs
scanner, editing prefix matchers (is_<pdk>_cell,
extract_cell_type), and adding entries to hand-curated matches!()
lists (is_filler_cell, is_io_pad_cell, is_sequential_cell,
is_multi_output_cell, …). Each of those last is data masquerading
as code; PR #64 (2026-05-18 power-pin + wired-filler shortcuts for
wafer.space) is the most recent example of the pattern.
The acute trigger is gf180mcu_ocd_ip_sram__sram1024x8m8wm1 — Tim
Edwards’ OCD 3.3V port of the GF180MCU SRAM IP, used in a downstream
wafer.space tapeout. The cell is third-party IP (not in Jacquard’s
vendor/), doesn’t match is_gf180mcu_cell’s prefix walk
(fd_* / ws_* only), has no pin table, and isn’t filler-stubbable.
Issue #67 captures
the discussion.
The same pattern will repeat for every wafer.space tapeout that
includes IP outside Jacquard’s vendored library — hazard3, future
chips. Code-gating each one through a Jacquard PR doesn’t scale.
PDK enablement gains a declarative metadata path alongside the
existing built-in classifiers. The decision separates cleanly into
two tiers; this ADR commits to Tier 1 + a minimal Tier 2 slice now,
and explicitly defers the larger Tier 2 schema (port-mapping
semantics) to a future ADR after real adoption data.
sverilogparse (already a workspace dependency) parses user-supplied
Verilog files at startup and populates the LeafPinProvider for
every module … endmodule block found. Handles input / output /
inout. Replaces the build.rs scanner for newly-added cells;
existing built-in tables stay as fallback.
Flag is repeatable: --cell-library a.v --cell-library b.v for
designs that pull in multiple IP libraries. Files are parsed in
order; later files override earlier ones for collisions (with a
warning).
Each cell library may be accompanied by a TOML manifest declaring
the kind of each cell — the same classification today’s
is_filler_cell / is_sequential_cell / etc. encode in
matches!() lists. Manifest path mirrors the library path
(foo.v → foo.cells.toml) and is loaded automatically when
present; an explicit --cell-manifest <PATH>.toml flag overrides
the autoloading behaviour.
Schema versioning: top-level schema_version is mandatory. v1.x
additive rule — new optional keys / new kind values are
non-breaking; semantics of existing kind values must not narrow.
aig.rs today has two hardcoded RAM detection paths:
celltype == "$__RAMGEM_SYNC_" (line 775, port_r/port_w resolution
from Yosys memlib_yosys.txt) and starts_with("CF_SRAM_") (line
1006, .DO output resolution for ChipFlow’s single-port
convention). Neither matches gf180mcu_ocd_ip_sram_* or arbitrary
third-party SRAM IP.
In v1.0, kind = "ram" allocates a RAMBlock slot in opaque
mode: the cell’s outputs are routed to X-source slots, no port
resolution is attempted, no memory behaviour is modelled. This is
sufficient for designs whose CPU executes from boot ROM / register
file and never reads SRAM contents at the timescales Jacquard
simulates (the heartbeat-verification use case driving this work).
The existing compute_x_sources test path at src/aig.rs:3247-3273
already validates the X-source convergence shape.
When real memory modelling is required, future schema versions add
explicit port mapping ([cells.NAME.ports] sub-tables) — the
opaque mode stays as the documented fallback.
The new path activates only for cells none of the existing
recognisers match AND that have a manifest entry. All existing
tests stay green without churn.
Port-mapping schema ([cells.NAME.ports] sub-tables, polarity
annotations, bus-width inference, write-enable encoding). This is
a small behavioural description language doing more than
classification; needs concrete adoption data before its schema is
fixed.
Built-in classifier removal.sky130.rs /
gf180mcu.rs / gf180mcu_pdk.rs classification tables stay as
fallback through the entire migration. Removal happens only after
the manifest pathway is the source of truth for at least one PDK
in production.
build.rs pin-table scanner removal. Same rule: removed LAST,
after manifests cover the built-in PDKs.
Third-party IP unblocks without Jacquard PRs. Users ship a
<library>.cells.toml alongside their <library>.v; CI flows
point --cell-library at both. The driving wafer.space tapeout’s
chip_top.pnl.v clears gf180mcu_ocd_ip_sram__sram1024x8m8wm1
by shipping a six-line manifest entry.
The “vendor + edit code + extend lists” PR workflow for new IP
becomes “ship a manifest, no Jacquard change”. docs/adding-a-pdk.md
evolves to document the manifest pathway as the primary route.
The opaque-RAM semantics is honest about what v1.0 delivers — no
silent partial memory modelling. The contract is “RAMBlock
allocated, outputs X-source, no read/write behaviour” until a
future schema version adds explicit ports.
Existing built-in PDK code stays load-bearing through the
transition. No risk of regression in sky130 / gf180mcu test
flows during the migration.
Amendment (2026-06-25): The “SRAM preload” consequence is now wired
(TestbenchConfig::sram_init, src/sim/cosim/mod.rs), but the shipped
path handles only the single-SRAM case (the design must have exactly
one SRAM; multi-SRAM designs fail with an explicit error). The
virtual-address-overlap matching of segments to multiple SRAM instances
described below is not yet built (issue #103). Schema unchanged.
Decision 0010 shipped a minimal Tier 2
slice with one kind discriminator per cell. For kind = "ram"
specifically, v1.0 declares the cell-as-opaque: the AIG allocates a
RAMBlock slot but routes outputs to X-source slots without
resolving read/write port semantics. That’s sufficient for “design
boots from ROM, never reads SRAM contents” cases but fails the
moment a real CPU writes to SRAM and expects to read its data back.
The acute trigger is the JTAG-DM firmware-load path enabled by
PR #78: OpenOCD
walks a debug-module sequence that culminates in abstract-memory
writes into the design’s SRAM, then jumps the CPU to that memory.
Because the SRAM is opaque (no backing storage, writes go nowhere),
the CPU boots to garbage. Issue
#80 captures the
symptom and notes that wiring SramInitConfig is the smaller sibling
problem — pre-loading SRAM contents at tick 0 — but the bigger gap
is that kind = "ram" doesn’t model writes at all.
Decision 0010 § “Deferred to a future ADR” listed the port-mapping
schema explicitly:
Port-mapping schema ([cells.NAME.ports] sub-tables, polarity
annotations, bus-width inference, write-enable encoding). This is
a small behavioural description language doing more than
classification; needs concrete adoption data before its schema is
fixed.
The OCD GF180MCU SRAM (gf180mcu_ocd_ip_sram__sram1024x8m8wm1) — a
real third-party IP cell behind the apitronix-semiconductor /
hazard3 / future wafer.space tapeout pipelines — gives us the
concrete adoption-data input. This ADR fixes the schema against
that worked example.
Read semantics: on posedge CLK, when !CEN && GWEN → Q = mem[A].
Write semantics: on posedge CLK, when !CEN && !GWEN && !(&WEN) →
mem[A][i] = D[i] for each i where !WEN[i].
The schema needs to capture: per-pin polarity (active-low vs
active-high), per-pin role (clock / chip-enable / write-enable /
mask / address / data-in / data-out), bus widths (derived from the
Verilog declaration; not redeclared), mask granularity (per-bit vs
per-byte vs none).
Extend the <library>.cells.toml schema with an optional ram
sub-table on entries declaring kind = "ram". Presence of the
sub-table promotes a cell from opaque (v1.0 semantics) to
explicit — outputs are properly wired to the AIG-backed
RAMBlock, writes populate backing storage, reads return what was
written.
depth (required, integer): number of addressable entries.
Must satisfy depth ≤ 2^AIGPDK_SRAM_ADDR_WIDTH (8192 today).
width (required, integer 1..=32): bit-width of each entry.
Capped at 32 by RAMBlock’s fixed-size port arrays.
clock (required, table): pin is the clock input pin name;
edge defaults to "pos". "neg" is accepted (matches gf180mcu
dffnq-family negedge convention).
chip_enable (optional, table): pin + polarity (default
"low"). When the pin’s effective level is inactive, the cell
neither reads nor writes for that cycle. Omit for sync SRAMs that
are always-enabled.
write_enable (optional, table): pin + polarity (default
"low"). Gates all writes regardless of mask. The OCD SRAM’s
GWEN. Omit for SRAMs without a global write-enable.
write_mask (optional, table): per-bit / per-byte write
enables. pin is the mask pin name; polarity defaults to
"low"; granularity is "bit" (default) or "byte". The mask
width must match width (bit) or width / 8 (byte). Omit for
SRAMs without per-bit masking — in that case the global
write_enable controls the whole word.
address / data_in / data_out (required,
string): pin names. Bus widths are read from the Verilog (via
sverilogparse) — not re-declared here.
Cells declaring kind = "ram"without the ram sub-table fall
back to v1.0 opaque mode — outputs route to X-source slots, no
backing storage, no port resolution. The contract is unchanged for
existing consumers.
Cells with an explicit ram block allocate a RAMBlock with
port_r_* and port_w_* arrays populated from resolved pin
positions. The simulator’s existing GPU-side SRAM machinery handles
reads, writes, and per-entry backing memory; no new kernel work is
required.
The top-level schema_version field bumps from "1.0" to "1.1".
v1.0 manifests continue to parse — the ram sub-table is purely
additive. Loaders that don’t recognise the new sub-table (none
today; this ADR ships the loader simultaneously) would treat
flagged cells as opaque RAMs, which is a graceful degradation.
TestbenchConfig::sram_init (an existing schema field declared in
src/testbench.rs but unwired today — issue
#80) becomes
load-bearing once explicit-port RAMs have backing storage. The
preload path:
Parse ELF segments from sram_init.elf_path.
Match segments to SRAM instances by virtual-address overlap with
declared SRAM regions.
Write segment bytes into each matched SRAM’s backing memory at
tick 0.
Schema extensions to SramInitConfig (instance targeting,
multi-section support) land alongside the implementation but don’t
require an ADR — purely additive JSON schema work.
The OCD GF180MCU SRAM (and any structurally similar third-party
IP — 1RW, sync, optional per-bit mask) becomes simulable end-to-end
via the manifest pathway. Real CPU writes populate real memory.
The opaque-mode fallback stays load-bearing for cells the consumer
hasn’t taken the time to schema-map — important so the cell-library
pathway doesn’t require schema work just to load a cell library.
JTAG-DM-driven firmware load (PR #78 stage 1) becomes end-to-end
testable in cosim. Closes the chicken-and-egg loop for designs
whose firmware-load mechanism is what cosim is trying to validate.
The schema is opinionated: 1-port (1RW), sync-only, write-mask is
bit OR byte (not arbitrary). Multi-port SRAMs (2RW, 1R1W),
async SRAMs, and write-mask-with-stripes encodings are explicitly
out of scope. Adding them is a future schema version (v1.2+);
doesn’t break v1.1 manifests.
Multi-port SRAMs. Most foundry IPs in our ecosystem are
single-port. Dual-port designs are a meaningful follow-up but
not driven by any in-tree fixture today.
Async (non-clocked) SRAMs. Hardly seen in synthesised
digital designs at modern PDKs. Not modeled.
Width > 32 bits. Bounded by RAMBlock’s array sizes;
consumers wider than 32 should split into multiple instances.
Built-in classifier removal. Same rule as Decision 0010 — the
$__RAMGEM_SYNC_ and CF_SRAM_* recognisers stay as fallback;
manifest-declared RAMs supplement, don’t replace.
Status: Accepted — design accepted and partially implemented. The
Current architecture:Cosim runtime.
reproducibility core (§1) and scheduler-domain jitter on the VCD
timeline (§2, partial) are built; model-driven jitter (§3), setup/hold
integration (§5), the gcd_ps/2 guard, and true coincident-edge
perturbation (§4) are not yet. The sections below describe the decided
design; see Implementation status for what is
built versus deferred. Remaining work is tracked in
issue #92 and
../plans/cdc-jitter-completion.md.
Amendment (2026-06-25): The Implementation status table cites
cosim_metal.rs for the scheduler and jitter draw — that file reference
is stale. The MultiClockScheduler, per-domain PRNG setup, and jitter
draw loop all live in src/sim/cosim/mod.rs (the unified multi-backend
cosim entrypoint); src/sim/cosim/metal.rs holds only the MetalBackend
struct. The implemented/deferred split itself is still accurate.
The multi-clock scheduler (MultiClockScheduler in cosim_metal.rs)
pre-computes a fixed LCM-based edge schedule: every clock domain fires
at perfectly rational offsets forever. Real hardware doesn’t do that —
PLL jitter, clock-tree skew, and propagation delay make coincident
edges land in unpredictable order. CDC synchronizers are designed to
tolerate this, but RTL bugs (missing synchronizers, gray-code errors,
handshake protocol violations) only surface when edge alignment varies
from the ideal.
The motivating incident was
PR #89 / run 26413667030:
a scheduler index bug caused sys_clk to fire at TCK’s period, making
CDC synchronizers between the JTAG and system clock domains marginal.
The test passed intermittently because Metal GPU scheduling jitter
shifted the effective phase relationship. Once the bug was fixed
(commit 5bb07c3), determinism was restored — but the experience
highlighted that no deliberate mechanism exists to stress-test CDC
paths under controlled timing skew.
Additionally, cosim’s model-driven clocks (JtagReplayModel,
SpiFlashModel, etc.) override the scheduler’s periodic pattern with
software-driven edges. These introduce a distinct CDC concern:
model-driven clock transitions are phase-locked to the host-side
dispatch loop, not to the design’s system clock. The same jitter
injection infrastructure must cover both scheduler-derived and
model-driven clock edges.
The multi-clock plan (docs/plans/multi-clock-and-stimulus-architecture.md)
lists “CDC verification mode: jitter injection on coincident edges and
random X-injection on detected async-source paths” as a future
capability. This ADR formalises the design for the jitter injection
half; X-injection is deferred to a follow-up ADR that depends on MC.1
(island partitioner) landing.
Simulation runs that use any non-deterministic feature (jitter, future
partition randomisation, model-driven timing offsets) are governed by a
run-parameters file (--run-params <path>):
{
"master_seed": 8429173640281
}
From the master seed, a per-domain sub-seed is derived for each
clock domain and each model-driven clock (e.g.
sub_seed = hash(master_seed, domain_name)). Each domain gets its own
independent PRNG stream. This ensures reproducibility even when the
number of PRNG draws per domain is path-dependent — a reactive model
that fires more or fewer edges based on design output doesn’t
contaminate another domain’s displacement sequence.
Behaviour:
--run-params <path> supplied, file exists: load parameters from it.
The run is a deterministic replay.
--run-params <path> supplied, file does not exist: generate a
master seed from system entropy, write the file immediately
(before the simulation loop starts), then run. The user gets
reproducibility even if the process crashes mid-simulation.
No --run-params flag: generate a master seed, write to a default
location (<output_dir>/run_params.json next to the output VCD)
before simulation begins. Always persisted — the user can
re-run any simulation by passing the written file back.
The master seed is also logged at INFO level and included in the VCD
header comment, so even without the file the seed is recoverable from
logs.
Rationale: “random testing that can’t be replayed isn’t testing,” but
forcing users to pick seeds upfront discourages use. Writing the file
before simulation means every run — even a crashed one — is
reproducible after the fact. Per-domain streams mean the seed alone is
sufficient; no displacement log is needed.
For CI seed sweeps, a wrapper generates N parameter files with
sequential seeds and fans out runs. Each failure ships with its
parameter file as an artifact — gh run download gives you
everything needed to reproduce locally.
At each edge, the scheduler draws a signed displacement from a uniform
distribution [-jitter_ps, +jitter_ps] and shifts the edge forward or
backward within the GCD granularity window. The resulting edge still
fires within the same GCD tick (no reordering across ticks), but the
effective arrival time recorded in the state buffer (and honoured by
setup/hold checkers) shifts. Disabling jitter (jitter_ps: 0) is the
default and produces today’s ideal-clock behaviour.
Constraint: jitter must not exceed gcd_ps / 2; larger values would
re-order edges across GCD ticks and require a fundamentally different
scheduling model.
Model-driven clocks (JTAG TCK, SPI SCK, etc.) bypass the scheduler’s
periodic edges. Their jitter path is different:
A --cdc-model-jitter-ps <N> flag (or per-model jitter_ps in the
config) specifies the budget for model-driven transitions.
After patch_model_clock_edges fires the edge, the arrival-time
offset recorded in the timing state is displaced by a PRNG-drawn
value from the same seeded generator.
This does NOT delay the functional edge (the DFF still samples on
the same tick) — it shifts the timing-model arrival so that
setup/hold checks against the receiving domain see a different
margin each run.
The functional-vs-timing split means jitter injection doesn’t change
combinational propagation (which would require an event-driven kernel),
only the timing oracle’s view of when edges “really” arrived. This is
consistent with Jacquard’s philosophy: functional correctness is
cycle-accurate, timing is an overlay.
When two domains have edges scheduled at the same GCD tick (coincident
edges), their relative order is undefined in real hardware. The jitter
mechanism naturally handles this: if domain A’s jitter shifts it
+100ps and domain B’s shifts it -50ps, the timing model sees A after
B, which may differ from the next run’s draw. This exercises both
“A-before-B” and “B-before-A” orderings over a seed sweep without
needing explicit permutation logic.
Setup/hold checker (timing_report.rs): already receives
arrival-time offsets. Jittered arrivals feed directly into the
existing violation detection — a jitter-induced setup violation
appears in --timing-report output with the jittered arrival
annotated.
VCD ring buffer: records the jittered arrival time so waveform
viewers show the displaced edge.
X-prop (future): when MC.1 identifies CDC boundaries, X-injection
on violated paths can use the same PRNG stream for correlated
randomisation.
--check-with-cpu: the CPU baseline does NOT apply jitter
(it doesn’t model timing at all). Jitter-mode results should not be
compared against the CPU baseline. The flag combination
--run-params (with jitter enabled) + --check-with-cpu should
warn or error.
The design above is accepted in full; the code implements part of it.
This section is the source of truth on what is built. Remaining items are
tracked in issue #92 /
../plans/cdc-jitter-completion.md.
Uniform [-jitter_ps, +jitter_ps] draw per domain per tick
cosim_metal.rs
Jitter displacement applied to the timing-VCD event timestamp
cosim_metal.rs (inside the --output-vcd block)
master_seed logged at INFO
cosim_metal.rs
--check-with-cpu + jitter warning
cosim_metal.rs
Deferred (issue #92):
Part
ADR §
Gap
Setup/hold integration
§2, §5
Jitter shifts only the VCD base timestamp; it does not feed the per-signal arrival offsets, so it produces no --timing-report violations. Also: jitter currently has no effect unless --output-vcd is set.
Model-driven clock jitter
§3
No --cdc-model-jitter-ps flag or patch_model_clock_edges path; only scheduler domains jitter.
True coincident-edge perturbation
§4
A single global displacement (last firing domain wins) is applied to the shared timestamp rather than independent per-domain displacement.
gcd_ps / 2 constraint
§2
Not validated.
Persist seed unconditionally
§1
Without --run-params or --output-vcd, the seed is generated but not written.
master_seed in VCD header comment
§1, §5
INFO log only.
--cdc-jitter-seed CI sweep
Consequences
The replay mechanism is --run-params; no dedicated CI sweep step yet.
CI can run a small seed sweep (via --run-params) as a lightweight
CDC stress test on every PR, catching synchroniser failures that the
ideal-clock schedule hides.
Users debugging real silicon CDC failures can replay the exact jitter
pattern that triggered the issue.
The design is forward-compatible with X-injection (the PRNG
infrastructure and per-domain budgets are reusable).
Model-driven clocks get explicit jitter coverage rather than relying
on accidental GPU scheduling delays.
No kernel changes required — jitter is a host-side timing-model
overlay on the existing edge schedule.
X-injection on CDC paths. Requires MC.1’s island partitioner to
identify which DFF outputs cross domains. Separate ADR once MC.1
lands.
Frequency sweep / DFS simulation. Changing a clock’s period
mid-simulation is orthogonal to jitter. Captured in the multi-clock
plan as a future axis.
Per-path jitter profiles. Real jitter isn’t uniform — PLLs have
period jitter (Gaussian), recovered clocks have cycle-to-cycle jitter
(bounded), external clocks have frequency offset (deterministic
drift). V1 uses uniform; richer distributions can be added later
without API changes (the seed + budget interface is distribution-agnostic).
Status: Accepted — the architecture is implemented and in use across
Current architecture:Cosim runtime.
multiple peripherals (multi-UART #90, config-driven APB3 bus tracing).
The “Target architecture” section below tracks the remaining, optional
refactors; the conventions it establishes are already followed.
Amendment (2026-06-25): Two body claims are now stale. (1) “Cosim is
Metal-only today” — CUDA (src/sim/cosim/cuda.rs) and HIP
(src/sim/cosim/hip.rs) backends now implement the full peripheral stack
(gpu_io_step, flash FSM, ring-buffer drain); the “Metal-only”
characterisation applied only to the prebuilt distribution (Decision 0018),
not the source build. (2) The note that step_edge receives an empty
output_state slice was resolved by the interactive JTAG work — it now
receives &backend.state()[state_size..] (cosim/mod.rs). The original
text below is retained for the record.
Amendment (2026-07-09): Plural QSPI memories may now share one SCK/SIO
bus, selected by distinct CS lines (config: same clk_gpio/d0_gpio,
distinct csn_gpio). This requires CS-gated MISO injection: a deselected
instance (prev_csn high) presents high-Z and must not drive. Previously
gpu_apply_flash_din (and the CpuBackend mirror) wrote every instance’s
d_i to its d_in_pos unconditionally, so on a shared bus the
last-iterated (typically deselected) memory clobbered the selected driver.
The gate lives in gpu_apply_flash_din (Metal + the shared CUDA/HIP
kernel_v1_impl.cuh) and CpuBackend::apply_flash_din_gated; regression:
tests/qspi_shared_bus/ (golden + content, all/qspi scopes) plus the
flash_din_tests unit tests. Independent-pin plurality (the original Stage
B/C design, tests/multi_mem_cosim/) is unaffected — a deselected flash on
its own pins was always ignored by the design; the gate only changes the
now-shared case.
Jacquard’s cosim mode runs reactive peripheral models alongside the
GPU-simulated design: SPI flash serves firmware, UART decodes serial
output, JTAG replays debug sessions, GPIO drives/observes pins, and
Wishbone trace captures bus transactions. The architecture evolved
organically; this ADR documents the current design, identifies the
abstractions emerging from it, and establishes conventions for
extending it.
Peripheral work splits across CPU and GPU. The boundary follows a
simple rule: models that drive input pins (must react to design
output each edge) run on the CPU; models that observe output pins
(pure consumers of post-simulation state) or exchange data
bidirectionally with the design run on the GPU for zero-copy access
to the state buffer.
Some peripherals span both domains. UART has a CPU-side RX driver
(feeds bytes into the design’s RX input pin) and a GPU-side TX decoder
(reads the design’s TX output pin).
apply_action is how the InputDispatcher feeds queued stimulus
commands to models. is_active signals that the model is mid-
transmission and needs per-edge granularity (forces batch size to 1).
step_edge has a default that just calls contribute_overrides —
stateless models (GPIO) only need the latter.
Models are registered into a Vec<Box<dyn PeripheralModel>> at
startup. Each batch boundary, the loop calls step_edge on every
model; models write their pin drives into a shared ModelOverrides
map. These overrides are patched in-place into pre-allocated BitOp
arrays (built at startup with placeholder entries for model-driven
positions) and applied via the state_prep GPU kernel.
Note:step_edge currently receives an empty output_state
slice — GPU output state is not read back per-edge for CPU-side
models. GPIO and UART RX don’t need it; I²C and SPI bus observation
will require wiring the output state readback when those models are
completed.
The dispatch is peripheral-agnostic: state_prep applies whatever
BitOp array it receives. Clock edges, reset, GPIO, UART RX, and
JTAG TCK/TMS/TDI are all entries in the same ops buffer.
GPU-side models fall into two categories distinguished by their
data-flow relationship to the simulation:
Observe-only (post-simulate): The model reads output state after
simulation and produces results (decoded bytes, bus traces) into a
ring buffer. It never writes to input state. One kernel call per
edge, after simulate_v1_stage.
Bidirectional (pre+post simulate): The model both reads the
design’s outputs and injects data into the design’s inputs. This
requires two kernel calls per edge — one before simulation (inject
response data into input state) and one after (read request signals
from output state, advance the model’s FSM).
GPU-side models produce output into fixed-size ring buffers in device
memory. The CPU drains these after each GPU batch completes, reading
from a local read_head up to the GPU-written write_head. No
synchronisation beyond Metal’s command buffer completion is needed.
Observe-only (UART + Wishbone + AHB/APB bus trace)
All run on thread 0 only — the per-tick work is a trivial FSM step.
gpu_io_step combines three logically independent observe-only models,
gated by n_uarts > 0, has_trace, and n_buses > 0 respectively.
The Wishbone trace (build_wb_trace_params) hardcodes one SoC’s signal
names (cpu.fetch.ibus__cyc, spiflash.ctrl.wb_bus__ack, …) directly
in source. The AHB/APB bus tracer generalizes it into a config-driven,
protocol-aware monitor that is the model for future bus tracing:
Pin binding: protocol pin names (psel, paddr, …) are resolved
to output-state positions via resolve_to_state_pos in
trace_signals.rs — the same multi-candidate resolver --trace-signals
uses, so Yosys-flattened / scalar-expanded / structural naming all
work. The pins are registered as observables before partitioning (via
DesignArgs::extra_observable_signals) so they get state-buffer slots.
GPU capture / CPU decode split: the kernel is protocol-agnostic —
it packs a raw beat (addr, wdata, rdata, ctrl flags) into the ring
buffer on the protocol’s gating edge (psel & penable & pready for
APB), using rising-edge detection so exactly one beat is recorded per
completed transfer. The protocol FSM (phase pairing, burst tracking,
response decode) lives in plain, unit-testable Rust in
src/sim/models/bus_trace.rs. APB3 is stateless (one beat = one
transaction); AHB pairing is the Phase-2 extension.
Output: decoded transactions stream to CSV via --bus-trace-csv;
annotated-VCD emission is a planned follow-up.
This is observe-only, so it slots into the existing post-simulate
pattern. Migrating the hardcoded WbTrace onto this mechanism (expressing
the VexRiscv ibus/dbus as configured buses) is a clean follow-up.
The two patterns (observe-only, bidirectional) and the common
conventions (ring buffers, params structs, per-instance config arrays)
should be codified so new peripherals follow a template:
New GPU-side models declare which pattern they follow:
Observe-only: register a post-simulate kernel. Receives output state (read-only), writes to ring buffer.
Bidirectional: register a pre-simulate kernel (inject into input state) and a post-simulate kernel (read output state, advance FSM).
Today this registration is implicit in cosim_metal.rs’s
encode_and_commit_gpu_batch. Formalizing it is a future step —
the convention is sufficient while the model count is small.
Cosim is Metal-only today. CUDA/HIP paths (kernel_v1_impl.cuh)
implement the core simulation kernel but have no gpu_io_step or
flash kernels. When CUDA/HIP cosim is added, the same two-pattern
taxonomy applies — the kernel implementations will differ but the
Rust-side buffer allocation, config resolution, and drain logic can
be shared via feature-gated code in cosim_metal.rs (or a
future cosim_common.rs).
The interactive JTAG/DM debug server (--jtag-server,
#124) is the live-socket
sibling of --jtag-replay. Its config additions follow this ADR’s conventions;
the execution-model decisions are in
Decision 0017’s 2026-06-21 amendment. Recorded here:
JtagConfig gains tdo_gpio: Option<usize> (src/testbench.rs). TDO is
a design output, so unlike the existing tck/tms/tdi/trst_gpio (inputs,
resolved via input_bits) it resolves via the output-bit map. It is the
first JTAG pin that the model observes rather than drives — added
Option so replay configs without it keep working.
Resolves the “output_state readback not yet wired” note above for the
JTAG case. The interactive server is the first CPU-side PeripheralModel that
must read a design output (TDO, to answer remote_bitbangR), so it wires
the real output slice into step_edge — the same plumbing the scaffolded
I²C/SPI models were noted as needing. See Decision 0017’s amendment for the
execution-model detail.
New CLI flag --jtag-server <PORT>, mutually exclusive with
--jtag-replay. Reuses the same jtag peripheral pin mapping and
--jtag-hold-cycles semantics; opens a remote_bitbang TCP server and drives
the configured pins live from the connected OpenOCD/gdb client. JTAG stays in
the “Not yet” plural column (TAP daisy-chain suffices — single instance).
The Flash peripheral went plural and gained a writable RAM mode, completing
Phase 3. This resolves the plural-qspi-memory working handoff (folded here).
Plural config (Stage A, #170).flash: Option<FlashConfig> → qspi_memory: Vec<QspiMemoryConfig> with the
standard back-compat pattern: the legacy flash key folds into instance 0 via
effective_qspi_memory() (mirrors effective_uarts()). FlashConfig is now a
type alias of QspiMemoryConfig. Each instance carries its own GPIO map, backing
size, and (Stage C) firmware.
N-instance GPU kernels (Stage B, #171).FlashDinParams/FlashModelParams are wrapped in *All { u32 n_flashes; u32 _pad[3]; T[MAX_QSPI_MEMS] } blocks (exactly BusTraceParamsAll, the “target
architecture” convention above). FlashModelParams gains a per-instance
data_offset; flash_data is one concatenated buffer with each instance’s
store at its offset (independent backing stores). FlashState is an N-slot array;
both kernels loop f < n_flashes instead of the old has_flash guard. Mirrored
across Metal (kernel_v1.metal) and the shared CUDA/HIP kernel
(kernel_v1_impl.cuh). Gotcha fixed in-flight: flash_set_in_reset must drive
every slot’s reset line, not just slot 0.
Writable QSPI PSRAM / RAM mode (#159).
Opt-in FlashConfig fields (writable, enter_qpi_cmd, quad_write_cmd,
read_dummy_cycles, size_bytes; firmware now optional) turn an instance into
an APS6404L-class PSRAM: enter-QPI (0x35) latches 4-lane command sampling, quad-
write (0x38) stores into the now-writable backing store, quad-read (0xEB) inserts
3 + read_dummy/2 dummy boundaries. Mirrored across the CPU CppSpiFlash oracle,
Metal, and CUDA/HIP with #[repr(C)] size asserts in lockstep (FlashState 52B,
FlashModelParams 52B); the CUDA/HIP flash_data buffer became a writable
UnsafeCell<UVec<u8>>. Unset options ⇒ byte-identical to the read-only flash.
Tests.tests/multi_mem_cosim/ (3 flashes + 2 SRAMs, independent stores,
all scope) and tests/qspi_psram/ (write→read round-trip, all + a dedicated
qspi scope for the GPU suite). Goldens captured on CpuBackend, byte-identical to
Metal/CUDA/HIP (Backend Equivalence CI green). Motivating use case: a C64-subset
GF180 tapeout whose main RAM is external QSPI PSRAM.
Jacquard simulates gate-level RTL designs on GPUs by converting
technology-mapped netlists into an executable form. The choice of
intermediate representation (IR) determines how easily the design maps
to GPU hardware, how much the representation compresses, and what
classes of optimisation are available at compile time.
Gate-level netlists arrive from synthesis tools (Yosys, Synopsys DC)
mapped to a variety of cell libraries: the project’s own AIGPDK
library, SKY130, or GF180MCU. Each library uses different cell names
and pin conventions; the IR must abstract over these while preserving
the combinational and sequential semantics exactly.
The GEM paper (Guo et al., “GEM: GPU-Accelerated Emulator-Inspired
RTL Simulation,” DAC 2025) describes a “virtual Boolean processor”
that evaluates combinational logic as a tree of AND-with-invert
operations — directly motivating an and-inverter graph.
Only AndGate has combinational fan-in. The two operands carry an
inversion bit in their LSB (aigpin_id << 1 | invert), giving the
full {AND, NAND, NOR, OR} family with a single node type.
Inverters and buffers are absorbed into the inversion bits rather than
creating separate nodes, keeping the graph compact.
This uniformity is the key property: because every combinational node
is the same (a XOR xa) AND (b XOR xb) operation, the boomerang
reduction tree (Decision 0015) can execute them all with a single GPU
instruction pattern — no opcode decode, no per-cell dispatch.
The conversion is implemented in src/aig.rs via
AIG::from_netlistdb_impl(). It handles three cell library
families:
Library
Strategy
AIGPDK (native)
Cells are already AND gates, DFFs, SRAMs — direct mapping
SKY130
Load Verilog behavioural models from vendor/sky130_fd_sc_hd/, decompose each cell into AND gates via decompose_with_pdk()
GF180MCU
Load behavioural models from vendor/gf180mcu_fd_sc_mcu7t5v0/, decompose similarly
RuntimeCellLibrary
User-supplied cell metadata (Decision 0010) for cells outside vendored PDKs
The decomposition process for technology-specific cells:
Clock tracing: Identify sequential cells (DFFs, SRAMs), trace
clock pins to primary inputs, create InputClockFlag drivers for
posedge/negedge detection.
Iterative DFS: Walk the netlist in topological order. For each
unvisited output pin, recursively decompose driving cells into AND
gates using the PDK behavioural models. An and_gate_cache
deduplicates structurally identical sub-expressions.
Multi-output cells: SKY130 cells like full adders with
multiple outputs get special handling — shared sub-expressions are
computed once and reused via postprocess hooks.
Fanout construction: After all pins are processed, CSR-format
fanout arrays are built for efficient traversal.
AIG pins are guaranteed to be in topological order (pin i is
defined before any pin that depends on it), which the downstream
pipeline relies on for level computation and scheduling.
Each variant bundles the signals that must be evaluated together: a
DFF needs both its D input and clock-enable; an SRAM needs address,
data, and write-enable buses. The for_each_input() method
enumerates all AIG pins feeding an endpoint group, which the
hypergraph partitioner (RepCut) uses to build connectivity and the
partition executor (pe.rs) uses to determine resource requirements.
This grouping is important because the boomerang reduction tree
produces results in 32-bit-aligned write-out slots. Endpoint groups
that share a write-out slot are co-located in the hierarchy; groups
that need different clock-enable conditions (e.g., two DFFs with
different clocks driving the same data pin) generate “output
duplicates” that consume additional write-out capacity.
BDDs (Binary Decision Diagrams): BDDs can represent Boolean
functions canonically but suffer from exponential blowup for many
practical circuits (e.g., multipliers). The canonical form is useful
for equivalence checking but unnecessary for simulation, where we
just need to evaluate. BDDs also have no natural mapping to the
GPU’s SIMT execution model.
Truth tables / LUTs: Lookup tables scale exponentially with input
count. A 6-input LUT (as in Xilinx FPGAs) covers individual cells
efficiently but doesn’t compose — cascading LUTs requires separate
evaluation steps. AIGs compose naturally: the output of one AND gate
feeds the input of the next, forming a tree that maps directly to the
boomerang hierarchy.
Technology-mapped netlist (direct execution): Keeping the
original cell library would require per-cell-type dispatch in the GPU
kernel — a conditional branch per node. GPU SIMT execution penalises
warp divergence heavily; a uniform operation eliminates this entirely.
The conversion cost (one-time decomposition at compile time) is
negligible compared to the simulation runtime.
MIG (Majority-Inverter Graph): MIGs are a more compact
representation (3-input majority gates) but the 3-input structure
doesn’t map as cleanly to binary reduction trees. AIGs are the
industry standard for synthesis and verification tools (ABC, AIGER
format), making interop straightforward.
The AIG’s key advantage is that it reduces the GPU kernel to a single
bit-parallel operation repeated across a hierarchical tree — no
opcode dispatch, no conditional branching, maximum SIMT utilisation.
The boomerang reduction tree (Decision 0015) works because every node is
the same AND-with-invert operation. A heterogeneous IR would
require per-node dispatch and break the hierarchical reduction
pattern.
Technology independence: the same GPU kernel and partition executor
handle AIGPDK, SKY130, and GF180MCU designs. Adding a new PDK
requires only a decomposition module, not kernel changes.
Structural deduplication via and_gate_cache reduces graph size
when multiple cells share sub-expressions.
The inversion-bit encoding (pin_iv = aigpin << 1 | invert)
eliminates inverter/buffer nodes entirely — these are free in
hardware too, so the IR’s size correlates better with actual
simulation cost than a technology-mapped netlist would.
Constrains:
No latches or async logic.
Amendment (2026-06-25): “No latches” is too blunt. The
constraint is specifically a raw LATCH cell left in the logic.
Two structured latch-derived uses are supported and not rejected:
clock gating via the CKLNQD integrated clock-gating cell (an
ICG is internally a latch + AND, identified as a gated clock —
aig.rs:845), and latch-based register files / memory mapped to
$__RAMGEM_SYNC_ SRAM cells by the memory-synthesis step
(aig.rs:830). Asynchronous set/reset on flip-flops is also
supported (it lowers to an AIG overlay, wire_dff_reset_set_overlay);
“async reset” was never the restriction. What remains unsupported is
raw level-sensitive latches in the logic and asynchronous sequential
(self-timed) feedback. See docs/architecture/simulation-engine.md
§ “No Latch or Asynchronous Sequential Logic Support”.
Original decision: The AIG assumes clean register
boundaries: DFFs capture on clock edges, combinational logic is
acyclic between registers. Level-sensitive latches and
combinational loops would require iterative evaluation that the
current pipeline doesn’t support (see docs/architecture/simulation-engine.md
§ “Known Issues”).
Decomposition quality matters. A poor decomposition of a
complex cell (e.g., a mux-heavy datapath cell) can produce a deep
AND tree that requires more boomerang stages. The SKY130 and
GF180MCU decompositions are hand-tuned for the common cells; exotic
cells from other PDKs may decompose sub-optimally.
No gate-delay preservation in the AIG itself. The AIG is a
functional (Boolean) representation. Timing information from
Liberty/SDF is loaded separately and overlaid onto the AIG’s pin
structure via gate_delays and aigpin_cell_origins. This means
the AIG construction can re-order or deduplicate nodes without
worrying about timing — but it also means the timing model must
reconstruct the mapping from AIG pins back to physical cells.
Once the design is converted to an AIG (Decision 0014), the combinational
logic must be mapped onto GPU hardware for parallel evaluation. GPUs
offer massive parallelism but impose rigid constraints: fixed thread
counts per block, limited shared memory, and synchronous SIMT
execution within a warp/SIMD group.
The GEM paper (Guo et al., “GEM: GPU-Accelerated Emulator-Inspired
RTL Simulation,” DAC 2025) introduces a “virtual Boolean processor”
organised as a boomerang hierarchical reduction tree. This ADR
documents how the boomerang maps to GPU hardware, the resource limits
it imposes, and the partitioning and instruction-generation pipeline
that stays within those limits.
A single GPU block (CUDA/HIP) or threadgroup (Metal) executes one
partition of the design. Each partition evaluates a subset of the
AIG’s endpoint groups (DFFs, primary outputs, SRAMs, etc.) by
reducing their combinational fan-in cones through a hierarchical
binary tree called the boomerang.
The boomerang has BOOMERANG_NUM_STAGES = 13 levels, giving a
reduction width of 2^13 = 8192 leaf positions. Each thread in the
block handles 32 bits (one u32), so the block uses 8192 / 32 = 256 threads (NUM_THREADS_V1 in flatten.rs).
The 13 hierarchy levels map to three GPU execution tiers:
Shared memory reduction with barrier between levels; only threads in [hier_width, 2×hier_width) compute — the rest idle
hier[4–7]
512 → 32
Warp/SIMD shuffle (__shfl_down_sync / simd_shuffle_down) — no barrier needed
hier[8–12]
32 → 1
Bit-level operations within a single u32 on thread 0
At each level, every position computes (a XOR xora) AND (b XOR xorb) OR orb — the same AND-with-invert operation from the AIG. When
orb is all-ones, the position acts as a pass-through (forwarding
input a unchanged). This single instruction pattern handles AND
gates, inversions, and wiring with zero branch divergence.
Between boomerang stages (when the AIG is too deep for a single
8192-wide tree), a shuffle permutation redistributes results from
shared memory back to thread-local registers. The shuffle is encoded
as 16-bit index pairs in the script, allowing arbitrary re-routing of
signals between stages.
The boomerang’s fixed geometry imposes hard resource limits on each
partition. These are documented in src/pe.rs on the Partition
struct:
Resource
Limit
Derivation
Unique inputs
8191
8192 leaf positions minus Tie0. Each input occupies a leaf slot; duplicates consume additional slots. Global-read rounds pack multiple state words into each thread’s initial register.
Unique outputs
8191
Write-out slots in the boomerang hierarchy, addressed by stage+position pairs. Outputs include DFF data pins, primary outputs, and SRAM port signals.
Intermediate pins per stage
4095
The hier[1] level has 2^(13-1) = 4096 positions. One position is reserved for Tie0. Intermediates are AIG pins that are produced in one boomerang stage and consumed in the next.
SRAM output groups
64
8192 / (32 * 4) = 64. Each SRAM occupies 4 write-out groups (32-bit read-data, address, write-data, write-enable). BOOMERANG_MAX_WRITEOUTS = 1 << (13 - 5) = 256 total write-out slots, of which SRAMs consume 4 each.
Write-out slots are 32-bit-aligned groups within the hier[1] level.
The total write-out capacity is BOOMERANG_MAX_WRITEOUTS = 256.
SRAMs and “output duplicates” (same data pin driven by DFFs with
different clock enables) consume write-out slots from this budget. A
quick_reject() pre-check catches obvious overflows before the
expensive full build.
When a partition exceeds these limits, Partition::build_one()
returns None and the partitioner must split the endpoint set
further.
The design’s endpoint groups are distributed across GPU blocks by
RepCut (src/repcut.rs), which constructs a weighted hypergraph
and partitions it using mt-kahypar.
Why a hypergraph, not a graph: In a standard graph, an edge
connects exactly two vertices. But a single AIG node (an AND gate
deep in the combinational cone) may be shared by many endpoint groups
— its “edge” in the connectivity structure is a hyperedge spanning
all groups that depend on it. Modelling this as pairwise graph edges
would lose the information that cutting this one node simultaneously
affects all connected groups. Hypergraph partitioning minimises the
actual communication cost (shared signals that must be read from
global memory by multiple blocks).
Why mt-kahypar: mt-kahypar is a state-of-the-art multilevel
hypergraph partitioner with LargeK support (many partitions in one
pass) and parallel execution. The implementation uses:
Preset::LargeK — optimised for k >> 2.
epsilon = 0.2 — 20% imbalance tolerance, giving the partitioner
flexibility to reduce cut while keeping partitions roughly equal.
Objective::Soed — Sum of External Degrees, which counts how many
partition boundaries each hyperedge crosses. This directly
correlates with the number of global memory reads each block must
perform.
Vertex weights proportional to estimated evaluation cost (accounting
for sub-graph size and fanout sharing).
Hyperedge weights equal to the number of AIG nodes with that
endpoint reachability pattern.
Hyperedge size cap at 1000 nodes (reservoir-sampled beyond that) to
keep partitioning tractable for signals with extreme fanout.
The hypergraph construction itself is the bottleneck for large
designs: for each AIG node, RepCut computes a bitset of which
endpoint groups it can reach via forward traversal. Nodes with
identical reachability sets are clustered into a single hyperedge.
This is done in parallel across bitset blocks (REPCUT_BITSET_BLOCK_SIZE = 4096) using Rayon.
mt-kahypar produces an initial partition assignment, but the
partition count is typically much larger than needed (set to 2x the
number of GPU blocks). process_partitions() in pe.rs then
aggressively merges partitions:
Bitset-based overlap scoring: For each pair of partitions,
compute the union of their AIG node bitsets. The merge cost is
|union| - max(|A|, |B|) — lower is better, indicating more
shared sub-graph. This is O(num_aigpins/64) per pair instead of
full DFS.
Speculative parallel trials: Merge candidates are sorted by
overlap cost. Up to parallel_trial_stride merges are attempted
in parallel using Rayon, with a cancel-on-success AtomicBool
to abort remaining trials once a valid merge is found. The stride
doubles on each iteration.
Quality gate: A merged partition is rejected if it would
increase the maximum boomerang stage count beyond
max_original_nstages + max_stage_degrad. This prevents merges
that technically fit in resource limits but would degrade
simulation throughput by adding extra boomerang stages.
Blacklisting: Failed merge attempts are blacklisted for that
partition to avoid redundant retries. Cancelled (interrupted by a
parallel success) trials are not blacklisted — the merge may
still be valid in a future iteration.
The result: 2x-4x fewer partitions than the initial hypergraph
solution, with each partition fully validated to fit within boomerang
resource limits.
src/flatten.rs converts partitions into FlattenedScriptV1 — a
packed u32 instruction stream consumed directly by the GPU kernel.
The script encodes:
Metadata section (256 u32): Per-partition control fields at
fixed indices, followed by the write-out hook table:
Index
Field
Purpose
0
num_stages
Boomerang stage count
1
is_last_part
Flag: last partition in the design
2
num_ios
Number of write-out endpoints
3
io_offset
Start offset in global state buffer
4
num_srams
SRAM block count
5
sram_offset
SRAM start offset
6
num_global_read_rounds
Input read rounds
7
num_output_duplicates
Output duplication count
8
is_x_capable
X-propagation flag (Decision 0016)
9
xmask_state_offset
X-mask offset (when X-capable)
128..255
write-out hook table
Maps each thread to the boomerang stage+position where it captures its output
This layout is the load-bearing contract between Rust
(flatten.rs) and the GPU kernel (kernel_v1.metal,
kernel_v1_impl.cuh).
Global-read permutation (2 × NUM_THREADS_V1 per round):
Each thread gets an index into the global state buffer and a
bitmask. The thread reads one u32 from global memory and extracts
the bits indicated by the mask using a pext-like loop. Rounds
are packed to maximise throughput (each thread accumulates up to
32 bits across rounds). An index high-bit flag distinguishes
previous-cycle state from current-cycle inter-stage intermediates.
That flag is a cross-backend wire format: one encoder
(src/flatten.rs sets bit 31 of the word index) and four
independent hand-written decoders (src/flatten.rs,
src/sim/cpu_reference.rs, csrc/kernel_v1_impl.cuh for CUDA and
HIP, and csrc/kernel_v1.metal). Every decoder must clear the flag
from the index. Do not bias the base pointer by -2^31 to cancel
the flag out of the subscript instead: that forms an out-of-bounds
pointer whose behaviour is a compiler’s to define, and the sign error
it invites put every staged read 2^32 words past the buffer — visible
only on ROCm, which is the one backend that computes the address
exactly rather than narrowing it to 32 bits (#203).
16 u32 per thread: shuffle permutation (16-bit index pairs
selecting source bits from shared memory)
4 u32 per thread: AND-gate flags (xora, xorb, orb) plus a
padding slot reused for gate-delay injection (u16 picoseconds)
Global write-out: SRAM and output-duplicate permutations,
clock-enable conditions, and data-inversion flags for committing
results back to the state buffer.
The entire script is uploaded to device memory once and read
sequentially by the kernel. Script reads are overlapped with
computation via double-buffering (reading the next stage’s data while
computing the current stage’s AND gates).
When a design’s combinational depth exceeds the boomerang’s capacity,
src/staging.rs splits the AIG into major stages at user-
specified level thresholds (--level-split 30 or --level-split 20,40).
Each major stage gets its own StagedAIG with:
primary_inputs: the AIG pins produced by previous stages (or the
design’s actual primary inputs for the first stage).
primary_output_pins: live AIG pins at the split boundary that
must be forwarded to the next stage.
endpoints: the original AIG endpoint groups whose combinational
depth falls within this stage.
Major stages execute sequentially on the GPU (the kernel loops over
them). Between stages, intermediate values are written to the
output state buffer and re-read by the next stage’s global-read
permutation (indicated by the high-bit flag in the index).
Staging trades latency (more sequential kernel dispatches) for
fitting within the 8192-wide boomerang. Without it, designs with
50-level combinational paths would fail partitioning entirely.
Fixed, branch-free GPU kernel. The kernel has no per-node
dispatch — every thread executes the same AND-XOR-OR instruction at
every boomerang level. This maximises SIMT utilisation across
CUDA, HIP, and Metal.
Deterministic shared-memory budget. The 256-thread, 8192-bit
boomerang uses a fixed amount of shared memory (threadgroup memory
on Metal), independent of the design. No dynamic allocation, no
shared-memory pressure variation between blocks.
Scalable partitioning. The hypergraph partitioner + greedy
merge naturally adapts to designs from hundreds to millions of
gates. Larger designs get more partitions; the GPU kernel is the
same.
Technology independence at the kernel level. The GPU kernel
knows nothing about AIGPDK, SKY130, or GF180MCU. It executes
packed u32 scripts. All cell-library knowledge is absorbed during
AIG construction and script generation.
Constrains:
8191-input/output ceiling per partition. Designs with extremely
wide buses or highly connected sub-circuits may require aggressive
partitioning, which increases inter-partition communication (global
memory reads). The --level-split option helps by splitting deep
cones into multiple stages, but wide cones remain fundamentally
limited by the 8192-slot boomerang.
Write-out slot scarcity for SRAM-heavy designs. Each SRAM
consumes 4 write-out slots. With BOOMERANG_MAX_WRITEOUTS = 256,
a partition can hold at most 64 SRAMs — and fewer when output
duplicates also need slots. Designs with many small memories may
need finer partitioning than their gate count alone would suggest.
Fixed thread count. The 256-thread block size is hardcoded
(NUM_THREADS_V1). On GPUs where the SM/CU could benefit from
larger blocks (e.g., occupancy tuning), there’s no flexibility.
Changing this would require redesigning the boomerang hierarchy
depth and the bit-packing in the script format.
Script size grows with partition depth. Each boomerang stage
adds ~20 × 256 = 5120 u32 entries to the script. Very deep
partitions (many boomerang stages) produce large scripts that may
pressure GPU memory bandwidth for the script reads, though
double-buffering mitigates this.
Amendment (2026-06-25): Stage-count correction. The body says
“Stages 1–6 are implemented; Stage 7 (dynamic X narrowing) is a future
enhancement.” Per docs/selective-x-propagation.md, all seven stages
are implemented — Stage 7 is the Criterion benchmarks
(benches/xprop.rs), not dynamic narrowing. Dynamic X narrowing
(periodic X-mask scan, partition-kernel hot-swap) is a separate, still
unbuilt enhancement.
Jacquard’s default two-state (0/1) simulation silently resolves
uninitialised DFF and SRAM outputs to zero. This masks
initialisation bugs that real hardware would expose as unknown (X)
values, and creates false mismatches when comparing against
four-state RTL simulators.
Naively upgrading the entire simulator to four-state logic would
double storage and roughly halve throughput. In a well-designed SoC
after reset, typically less than 5% of signals are genuinely
X-capable.
Implement selective X-propagation controlled by the --xprop
CLI flag. Static analysis at compile time identifies X-source
signals (uninitialised DFFs, SRAM read ports); forward-cone
computation classifies each partition as X-capable or X-free. Only
X-capable partitions run an X-aware kernel variant; the rest
continue with the fast two-state path.
The full seven-phase design, implementation details, and design
rationale are in
docs/selective-x-propagation.md.
Stages 1–6 are implemented; Stage 7 (dynamic X narrowing) is a
future enhancement.
The original decision wired --xprop into the sim (static-input)
path only. The reactive cosim path is two-state, so JTAG-replay /
peripheral runs silently zero-init uninitialised state
(#95). This amendment
extends selective X-propagation to cosim.
Two points the original design did not address, because the static
sim path never had to:
Undriven input pads are X. In a reactive run, peripheral models
drive only some input bits each edge (clock, reset, JTAG/UART
pins, configured constants). Every primary-input bit not in that
driven set is unconnected and must be X, not 0.
Bidir pad reads. A bi_24t pad’s core-read was originally
modelled Y = PAD (tristate not modelled); since PAD is an undriven
primary input, bidir reads fell out of rule (1) as X — safe (false-X,
never false-0) but pessimistic for the OE=1 loopback. The
combinationally-correct read Y = OE ? A : external is now modelled as
a mux in the AIG (#96,
implemented — see the dated subsection below). (An earlier draft of this
amendment proposed a per-edge OE→input feedback with one-edge latency —
that was wrong; the correct read is combinational.)
So the X-source taxonomy is now three-way: uninitialised DFF,
uninitialised SRAM (both as before), and undriven input pads (which
subsumes bidir reads under the current Y = PAD model). The first two
are sequential power-up X; the third is the reactive IO X-source
specific to cosim.
The Metal kernel is already X-capable, so this is host-side reactive
plumbing (state-buffer expansion, per-edge X-mask maintenance, and an
observe-kernel output-offset fix for the doubled layout). Phasing and
risks are in docs/plans/archive/cosim-xprop.md.
Implementing the cosim extension surfaced a latent bug in the shipped
sim path too: the power-up X-mask seed
(expand_states_for_xprop) was built as “all-X, then clear every
input_map position.” But input_map contains the DFF-Q
combinational-read positions, not just primary input ports — so
uninitialised DFFs were read as known 0 and X never originated. --xprop
was therefore silently two-state for any sequential design (the
gate-level X math was unit-tested, but no end-to-end test asserted X
surfacing from an uninitialised DFF).
The seed is now built by vcd_io::xprop_xmask_template: all-known, set
X only at genuine X-source positions (uninitialised DFF Q reads + SRAM
reads), excluding primary inputs (nets present in input_layout) and
constant-pinned DFFs (const_zero_pos = input_layout.len()). The cosim
path additionally seeds the output slot’s X-mask, since its per-edge
state_prep copies output→input before the first simulate. This
corrects design choice #3 above (“all DFFs start as X”): the intent was
always X at DFF positions; the implementation had inverted it for
DFF-feedback reads.
The “undriven input pad → X” rule from this amendment is now implemented
for cosim. compute_x_capable_pins(treat_inputs_as_x_sources) (gated by
DesignArgs::xprop_undriven_inputs, set only by the cosim path) marks
input cones X-capable; vcd_io::xprop_xmask_template_cosim seeds every
primary input as X; and the GPU kernels clear the X-mask of each bit they
drive each edge — state_prep for the build_edge_ops driven set
(clock/reset/constants/model pins) and gpu_apply_flash_din for the SPI
MISO bits it writes directly (they bypass state_prep). The complement —
genuinely undriven inputs — stays X. sim keeps inputs known (driven from
the VCD) and pays no extra X-aware cost. End-to-end guards covering the
DFF and undriven-input X-sources, in both sim and cosim, live in
tests/xprop_cosim/ (CI, fatal).
Point #2 above is now implemented (#96). AIG::from_netlistdb’s bi_24t
branch builds Y = OE ? A : external combinationally in the AIG —
OR(AND(OE, A), AND(!OE, PAD)) via the De Morgan idiom already used by
wire_dff_reset_set_overlay — instead of the conservative Y = PAD. The
external arm is the same undriven PAD primary input (X under rule (1) until
a peripheral model drives it); the OE=1 arm reads the core’s own drive
A, so the loopback is X-exact (known whenever A is) and the
two-state read returns A, not the external stim. This removes bidir reads
from the “undriven input → X” subsumption for the OE=1 case; they are now
exact rather than conservatively-X. in_c/in_s stay Y = PAD. Without
both A and OE pins the conservative Y = PAD still stands. Unit test:
aig::gf180mcu_chip_top_tests::bi_24t_models_tristate_readback evaluates
the full Y truth table. (#107’s $isunknown x-assert work can now assert
bidir read-backs go definite when OE is asserted.)
Amendment (2026-06-25): The original body describes cosim as
Metal-only (run_cosim in cosim_metal.rs, cmd_cosim hard-erroring on
other backends) and step_edge receiving an empty output_state. Both
are now stale: CudaBackend and HipBackend implement CosimBackend
(src/sim/cosim/cuda.rs, hip.rs; dispatched from jacquard.rs), and
step_edge receives the real output slice. The later in-body amendments
(2026-06-07/06-19/06-21) document this architecture; this note flags that
the original “Metal-only” wording reflects the state at initial
acceptance, not current reality.
The cosim mode runs a GPU-simulated design alongside reactive
peripheral models (flash, UART, JTAG, GPIO) that drive and observe
design pins each clock edge. The execution model must balance two
competing needs: GPU throughput (which favours large batches of
edges dispatched as a single command buffer) and peripheral
responsiveness (which requires CPU-side model updates between edges).
This ADR documents the batch dispatch loop, the multi-clock
scheduler, and the time-domain abstractions that tie them together.
The cosim main loop groups consecutive scheduler edges into
batches of up to BATCH_SIZE = 1024 edges. Each batch is
encoded into a single Metal command buffer and dispatched to the
GPU. Between batches, CPU-side peripheral models (PeripheralModel:: step_edge) run, ring buffers are drained, and model overrides are
compiled into BitOp arrays for the next batch.
CPU-side models cannot observe intra-batch state changes — they see
the output state only after the batch completes. For peripherals
that require per-edge responsiveness (e.g. JTAG replay with tight
hold-cycle requirements), the batch is forced to size 1 when any
model reports is_active() == true.
The batch size trades off GPU utilisation against peripheral
latency. Smaller batches → more Metal command buffer submissions
per second → higher overhead. Larger batches → staler CPU-side
model state. 1024 was chosen empirically as a sweet spot:
For peripheral-free simulation: amortises ~1ms of command buffer
overhead across 1024 edges ≈ 1µs/edge overhead.
For active peripherals (JTAG, stimulus-driven): the is_active
fallback to batch=1 ensures correctness regardless of batch size.
The batch size only affects cosim; the sim command processes the
entire VCD in one GPU dispatch.
Each scheduler edge has pre-allocated Metal buffers for its
StatePrepParams and BitOp array (ScheduleBuffers::edge_buffers).
These are allocated once at startup — not per-dispatch — to avoid
allocation latency in the hot loop. The schedule repeats with period
edges_per_period (= LCM schedule length); edge N reuses
buffer N % edges_per_period.
The MultiClockScheduler computes a deterministic interleaving of
edges across clock domains. Given N clocks with potentially
different periods and phase offsets:
Compute gcd_ps = GCD of all half-periods and phase offsets.
This is the scheduler tick — the minimum time quantum.
Compute lcm_ps = LCM of all full periods. This is the
schedule period — the point at which the edge pattern repeats.
schedule_len = lcm_ps / gcd_ps — number of ticks per period.
For each tick, compute which domains have rising/falling edges
based on (tick_ps - phase_offset) % half_period == 0.
The schedule length is capped at 1,000,000 ticks. This prevents
degenerate clock ratios (e.g. primes) from producing unbounded
schedules. If the cap is hit, the assertion fires with a message
suggesting the clocks may not be commensurable at the configured
resolution.
A scheduler edge is one tick of the scheduler (duration =
gcd_ps). A clock cycle is two half-periods of a given domain
(= rising + falling edge). The ratio sched_ticks_per_sys_clk_cycle = clock_period_ps / gcd_ps converts between them. Note this ratio is the
number of scheduler ticks per sys_clk period, which is 2 only when
gcd_ps equals the half-period (single-clock or harmonic multi-clock);
non-commensurate periods or phase offsets make it larger.
This distinction is load-bearing for peripheral timing:
UART baud rate dividers count edges, not clock cycles.
Reset duration counts edges.
The --max-clock-edges CLI flag counts edges.
Confusing edges with clock cycles was the root cause of the UART
baud rate bug fixed in commit a263e47 — edges_per_period (the
LCM schedule length) was used where sched_ticks_per_sys_clk_cycle was
needed, doubling the bit time in multi-clock designs.
After each batch completes, the CPU drains three categories of
GPU-side state:
Peripheral ring buffers — UART channels and Wishbone trace
channel, drained from local read_head to GPU-written
write_head. See Decision 0013 for struct conventions.
VCD snapshot buffer — when --stimulus-vcd or --output-vcd is
enabled, a separate ring buffer (2 × state_size words per edge)
captures per-tick output state on the GPU. The CPU drains it
after each batch to write VCD transitions. This mechanism is what
enables BATCH_SIZE > 1 even with VCD output — without it, the
CPU would need to read output state after every single edge.
CPU reference check — when --check-with-cpu is active, the
CPU replays the batch with the reference kernel and compares.
No synchronisation beyond Metal’s command buffer completion is
needed — all drains happen after waitUntilCompleted.
The batch dispatch model means CPU-side peripheral models see
output state with up to BATCH_SIZE edges of latency. This is
acceptable for all current peripherals; models that need tighter
coupling set is_active() = true.
The 1M tick schedule cap prevents pathological memory use but
rejects exotic clock ratios. A min-heap scheduler (proposed in
docs/plans/multi-clock-and-stimulus-architecture.md as MC.2)
would remove this limit.
The edges-vs-cycles distinction must be maintained carefully in
any code that converts user-facing “cycles” to internal “ticks”.
The sched_ticks_per_sys_clk_cycle helper exists for this purpose.
Pre-allocated schedule buffers consume O(schedule_len) Metal
buffer pairs at startup. Each schedule entry creates two Metal
buffer objects (params + ops). For typical single-clock designs
this is 2 entries = 4 buffer objects; for complex multi-clock
designs it can reach thousands of entries, but each buffer is
small (tens of bytes).
The execution model above is Metal-only — run_cosim lives in
cosim_metal.rs (gated #[cfg(feature = "metal")]) and cmd_cosim
hard-errors on other backends. This amendment records the target
architecture for making cosim backend-portable (CPU reference + CUDA/HIP),
tracked as #105. It
supersedes the incremental 2026-06-05 note (whose “per-edge on every
backend” framing the measurements below correct). It describes the
steady-state design; the staging to reach it lives in
docs/plans/cosim-backend-portability.md.
It does not change the batch/scheduler model above — it factors where
each part runs and along what seam.
The cosim loop was instrumented (telemetry in the run summary:
single_edge_batches, mean/max batch) to measure how often the batched
fast path (batch > 1) runs versus forced single-edge dispatch
(force_single_edge = any_model_active, plus diagnostic modes). Per-edge
handover is the only mode needing a true per-edge CPU↔GPU round-trip.
Fixture
Edges
Batched (edges)
Single-edge commits
Commits
dual_uart
10,000
100%
0
11
apb_trace
200
100%
0
2
xprop_cosim
40
100%
0
2
jtag_minimal
4,000,000
97.4%
102,310
106,117
Designs whose peripherals have GPU-side halves (UART, APB bus-trace, SPI
flash — the gpu_io_step / gpu_flash_model_step kernels) run 100%
batched: CPU↔GPU handover is at BATCH_SIZE-edge boundaries, not per
clock edge. Even jtag_minimal (CPU-side JTAG replay, the most
per-edge-heavy fixture) batches 97% of edges — but its 102k single-edge
commits are 96% of all submits and dominate its wall-clock. Batching is
the dominant path; per-edge is the exception (CPU-side models + diagnostic
modes). This drives every decision below.
Everything that is not GPU-specific moves above the seam and operates on
&[u32] state + Vec<BitOp> edge ops: the MultiClockScheduler,
build_edge_ops, the batch-size policy (force_single_edge), peripheral
coordination, the input dispatcher, reset/constant init, VCD writing, and
event/ring-buffer draining. The batch-size decision stays here, unchanged.
Owns the [2 × state_size] design state and runs the design. Crucially it
is batch-granular, not single-edge — the measurements show a literal
simulate_edge-per-edge trait would collapse Metal’s 100%-batched designs
to one command-buffer submit per edge (~1000× regression). The trait method
is therefore “run N consecutive scheduler edges, applying each edge’s ops
and snapshotting each output slot to the ring”, plus state_prep
(output→input copy + apply BitOps + clear driven X-mask) and
input_state()/output_state() accessors. MetalSimulator becomes
MetalBackend; CpuBackend and Cuda/HipBackend are added.
The backend owns the schedule storage (opaque to the orchestration). The
edge ops are a tiny, fixed, repeating set (edges_per_period entries, =2 for
single-clock) built once; the orchestration must not hold a parallel copy
that the backend re-materialises each dispatch (that would add a per-dispatch
copy and, on Metal, regress today’s zero-copy unified-memory path). Instead:
init_schedule(edges: Vec<(StatePrepParams, Vec<BitOp>)>) hands the
backend-agnostic description to the backend once; the backend materialises
its native buffers and retains them. The orchestration keeps only scalars
(edges_per_period, gcd_ps).
edge_ops_mut(edge_idx) -> &mut [BitOp] is how reset / model-driven /
clock-edge patching mutates ops. Metal returns a slice straight over the
shared MTLBuffer — zero-copy, the write is the upload (exactly today’s
behaviour). CUDA/HIP return a slice over a host mirror and mark the edge
dirty; run_edges uploads only dirty edges before launch. Ops change rarely
(reset transitions; only while a CPU-side model is active), so this is near
zero in practice — and the buffers are KB-scale regardless.
This replaces the earlier “neutral Vec + backend re-materialises” sketch,
which risked needless CPU↔GPU traffic and double bookkeeping.
MetalBackend runs N edges in one command buffer with GPU peripherals
inside (today’s encode_and_commit_gpu_batch).
CpuBackend runs the per-edge loop via cpu_reference::simulate_block_v1
— the reference/oracle, and the unlock for cosim regression on free
Linux CI (today Metal-only). N is effectively 1; throughput is not the
point. It also validates the per-edge orchestration path that the CUDA/HIP
fallback reuses.
Cuda/HipBackend run the existing simulate kernel, sidestepping the
cooperative_groups grid-sync that only the sim command needs (the
hardest CUDA feature to port). They ship with their Tier-2 GPU
peripherals (Layer 3) so reactive designs batch from the start; the per-edge
path is the permanent fallback for CPU-side models (e.g. JTAG replay).
Batching a reactive design requires the peripheral to run inside the
batch — i.e. on the GPU — because the peripheral consumes each edge’s output
to drive the next edge’s input. On Metal this is hidden by unified memory;
on a discrete GPU, per-edge means a PCIe round-trip every edge (~1–2 µs
each way), which over millions of edges is likely slower than the CPU
backend. Therefore GPU-side peripherals are architecturally required for
the CUDA/HIP perf story — not an optional optimization. The decision
(2026-06-07) is to make GPU peripherals the primary path, with a 3-tier
model mirroring the CosimBackend seam:
Tier 1 — CPU reference model (PeripheralModel, src/sim/models/*.rs,
exists). The semantic ground truth, the cross-backend equivalence oracle,
and the fallback for any (backend, peripheral) lacking a GPU kernel. Always
present.
Tier 2 — hand-written GPU kernels for core peripherals (now). Because
CUDA and HIP already share kernel_v1_impl.cuh, a core peripheral is two
implementations, not three: one shared *_impl.cuh (CUDA + HIP) and one
.metal. Tractable for the small in-core set (flash, UART, bus-trace) and
matching the existing simulate-kernel precedent.
Tier 3 — single-source peripheral compilation (later; the
user-extensible peripheral API). Hand-written kernels don’t scale to
user-defined peripherals; the endgame is a user writing a peripheral once
(restricted-Rust subset or a small peripheral-FSM IR) that compiles to CPU
every GPU backend. This domain (peripheral FSMs) is far narrower than the
general cross-shader-tool port previously rejected, so an in-house IR is
the tractable route.
The GpuPeripheral seam is defined at Tier 2 so Tier 3 slots in without
reworking the orchestration.
The seam above only sketched Tier 2 as encode_step(encoder). This fills in
what a peripheral is, so the CPU model (Tier 1), the GPU kernel (Tier 2), and
the eventual single source (Tier 3) all express the same contract rather
than two parallel ones.
Every peripheral — on either substrate, input-driving or output-observing — is
the same shape:
observe some design-output bits → advance an FSM (over persistent
state + const params) → drive some design-input bits and/or emit
decoded records.
The CPU PeripheralModel trait (src/sim/models/mod.rs:56) is already this
contract and already bidirectional:
One trait already covers the whole spectrum via optional halves: GPIO is
input-only (default step_edge just contributes overrides), UART-TX decode /
bus-trace are output-only (empty driven_positions), SPI flash is
bidirectional. So a single interface genuinely covers input and output — the
doubt about “enough commonality” is unfounded: the commonality is the
FSM-over-IO-bits shape, and six models already share it.
The GPU half is not yet unified — it is three bespoke kernels with the same
skeleton but no common trait:
Kernel
reads
FSM state
writes
role
gpu_apply_flash_din
states, FlashState
—
states (MISO)
input inject
gpu_flash_model_step
states, flash_data
FlashState
FlashState
output observe + FSM
gpu_io_step
states
UartDecoderState
UartChannel/BusTraceChannel rings
output decode → ring
Every kernel is kernel(device u32* states, device FsmState* state, constant Params& params, [device Ring* out], [const Data* in]).
Target shape — one logical contract, two substrates:
CPU substrate = today’s step_edge (Rust over &[u32]; drives via
ModelOverrides).
GPU substrate (GpuPeripheral) = encode_step(encoder, states_buf, fsm_buf, params_buf, ring_buf) running the same FSM over device u32* states.
Consistency anchor = the shared #[repr(C)] FSM-state + params structs.
These already exist on both sides but are hand-synced (“must match Metal UartChannel”, cosim_metal.rs:178); that duplication is the tax Tier 3
removes by generating the Rust step, the GPU kernel, and the one struct
from a single FSM definition.
The decision that makes it consistent: all input drives are (position, value) pairs applied through the one state_prep/ops path. Today this is the
inconsistent part — CPU models drive indirectly (overrides → BitOps → state_prep, so drives land clock-edge-aligned), but gpu_apply_flash_din
writes states directly. They are the same logical operation done two ways.
Normalising flash’s direct-write into FSM-produced ops applied by state_prep
makes input application uniform across every peripheral and both substrates, and
removes flash’s special case. (Flash writes directly today only because its MISO
depends on the FSM computed that same edge — expressible as ops the FSM emits.)
What deliberately does not unify (substrate detail below the contract):
output draining (GPU needs ring buffers because the CPU cannot observe
intra-batch state; CPU models emit immediately — same events out contract,
different plumbing), and the FSM body itself (Rust vs kernel, single-sourced
only by Tier 3’s IR). These divergences are expected and bounded.
Phase-1 implication: the CPU UART-TX decoder added in Phase 1 (no GPU
equivalent exists today — models/uart.rs only has an RX-line receiver
decode) must be written to this contract, with its FSM state mirroring
UartDecoderState’s fields, so the Phase-2 GpuPeripheral kernel and the
Tier-3 single source fold into one definition rather than a third parallel one.
The CPU PeripheralModel (Tier 1) is ground truth. The cross-backend
equivalence harness (#113,
today sim-only) extends to cosim: every backend’s output VCD must be
byte-identical on the same reactive design, and every GPU-peripheral kernel
(Tier 2) must match its CPU model. This is the backstop for the whole effort.
Speculative batching — keep peripherals on the CPU, batch optimistically,
and roll back on divergence (the multi-clock plan’s MC.4 island run-ahead /
MC.5 record-and-replay). It avoids writing GPU kernels but is
non-deterministic in throughput and substantially more complex. Rejected as
the primary path; retained as the natural fallback for user CPU
peripherals that are not GPU-portable. Cross-shader tools (Slang, Ferrox)
remain rejected for the design kernel; Tier 3’s narrow peripheral-FSM IR is
the cross-GPU answer for peripherals.
The 102k JTAG single-edge round-trips are exactly the “cosim CPU↔GPU
round-trip measured as the bottleneck” trigger for MC.3 (streaming
stimulus) and the motivation for MC.4 (per-island multi-rate batching).
Both are orthogonal to and larger than this seam (MC.4 needs the MC.1
island partitioner) — the long-term fix for the per-edge tail, not a
prerequisite here.
The sim cooperative-launch model and the cosim per-edge/batch model
remain distinct per backend; this unifies the cosim driver, not the two
execution models.
ScheduleBuffers currently stores metal::Buffer pairs and the ops-update
helpers write Metal shared memory in place. These move into the backend
(built once via init_schedule, mutated via edge_ops_mut) rather than
becoming an orchestration-owned Vec the backend re-materialises — which
keeps Metal zero-copy and lets CUDA/HIP upload only dirty edges. Converting
the in-place *mut BitOp shared-memory mutation to edge_ops_mut is the
main refactor friction (and resolves the closure-borrow issue too).
CUDA/HIP cosim ships with Tier-2 GPU peripherals so reactive designs
batch from the start (Phase 2 lands the backend + GPU peripherals together,
rather than a per-edge-only intermediate that would be unusably slow). The
per-edge path remains as the fallback for CPU-side models (e.g. JTAG
replay), where the per-edge tail is addressed later by the multi-clock
plan’s MC.3/MC.4, not by this seam.
Refines the Layer-2/3 phasing above with an implementation decision (made while
building Phase 2). There is exactly one CUDA/HIP cosim backend, and it
mirrors MetalBackend: GPU design step + GPU peripherals + variable
batching + managed memory (cudaMallocManaged/hipMallocManaged, the closest
analog to Metal’s unified StorageModeShared).
No CPU-peripheral CUDA/HIP backend. An earlier plan sketched a bring-up
“checkpoint 2a” — a CUDA backend running all peripherals on the CPU,
per-edge — as a stepping stone. That is dropped: no production backend
works that way (Metal never runs peripherals on the CPU), so it would
introduce a backend shape that exists nowhere else and obscure the
architecture. CpuBackend stays the pure-CPU reference oracle; Metal and
CUDA/HIP are the GPU backends with the same shape.
“Per-edge fallback” means batch=1 of the GPU backend, not a
CPU-peripheral path. Model-driven-clock designs (JTAG) run the same
GPU-peripheral backend at batch=1; only a small output read-back per edge
feeds the CPU-side clock model. The Tier-1 CPU PeripheralModel remains the
per-peripheral fallback solely for a (backend, peripheral) pair that
genuinely lacks a GPU kernel (e.g. a future user-defined Tier-3 peripheral
before its kernel exists) — not for the core flash/UART/bus set, which get
Tier-2 GPU kernels on CUDA/HIP.
Bisectability comes from staging on fixtures, not from a throwaway
backend. Each fixture exercises a different kernel subset, so the single
backend is brought up in stages: A (design step only → xprop_cosim, no
peripherals), B (gpu_io_step → dual_uart + apb_trace), C (flash kernels
→ flash/JTAG). Every stage is the real architecture, gated against the
committed CPU/Metal goldens on the T4. Staging detail:
docs/plans/archive/cosim-phase2-cuda-hip.md.
The execution model above assumes the peripheral pace is internal: the loop
drives time and models react each edge (recorded JTAG replay, queued stimulus,
GPU peripherals). The interactive JTAG debug server (--jtag-server,
#124) introduces the inverse:
an external client (OpenOCD/gdb) drives the pace, one remote_bitbang
transaction at a time. This amendment records how that fits the model without
new machinery — and the one latent contract gap it forces closed. It does
not change the batch loop or scheduler.
An interactive peripheral is a Tier-1 CPU PeripheralModel that blocks on
external I/O. Its step_edge blocks reading the next bitbang byte from the
socket; because the client is the clock source, this blocking is the
synchronisation. is_active() stays true while a client is connected, so
the existing force_single_edge gate drives the design at batch=1 for the
session’s duration — the same mechanism JTAG replay already uses. No async
executor and no background thread are needed for a single connection; the
blocking accept happens once before the main loop. The batched fast path is
unaffected when no client is attached.
This makes the contract’s “observe” half real. The Decision section and
Decision 0013 both note step_edge is handed an empty output_state today
(cosim/mod.rs:2987 passes &[]) because no CPU-side model yet reads a design
output. An interactive debugger must answer remote_bitbangR (read-TDO)
with the live TDO bit, so output_state must carry the real design-output
slice (&backend.state()[state_size..]). This generalises beyond JTAG — it is
the same wiring the scaffolded I²C/SPI observation models need — so it is a
closure of the standing TODO, not a JTAG special case.
TDO read-back is the contract’s “emit” half pointed at an external client.
The peripheral contract is observe → advance FSM → drive and/or emit. A live
debug server adds one I/O direction: it emits a response to an external
socket mid-edge (the R reply) rather than into a ring buffer or VCD. The
observe/drive halves (TDO sample, TCK/TMS/TDI drive via the usual
overrides → BitOps → state_prep path) are unchanged.
Per-backend: the interactive path is the CPU-side model plus batch=1 of the
GPU backend (the “per-edge fallback” of the 2026-06-19 amendment), so it works
on any cosim backend once the host-side plumbing lands — no kernel work.
Implementation staging: docs/plans/jtag-debug-server.md.
Folded in from a now-deleted implementation-plan doc
(cosim-timing-support.md) whose objective largely shipped. Open items
remaining:
Timed cosim is Metal-only. Arrival-annotated VCD output works on the
Metal backend (metal.rs threads arrival_state_offset; the driver writes
per-net arrival_ps). The CPU, CUDA, and HIP backends assert
!script.timing_arrivals_enabled — they don’t yet support timed cosim.
Extending arrival tracking to the other CosimBackend impls is open.
Cosim --timing-report (structured JSON) is not wired. The structured
report is sim-only; see Decision 0008’s 2026-06-25 amendment. The cosim path
emits arrival-annotated VCD but not the JSON report.
Current architecture:Distribution.
binaries) and Phase 7 (eda-infra-rs upstreaming) remain open — see the
amendment below.
Amendment (2026-06-25) — built and shipped. The distribution layer
this ADR proposed is now operational; the original “no release
artifacts; install = clone + submodules + cargo build” premise is
historical. What’s live:
GitHub Releases (macOS arm64/Metal): from v0.2.1 (first attached,
working binary) through v0.2.3. release.yml builds, smoke-tests
the relocated tarball, and publishes (prereleases via a draft, for the
immutable-releases repo).
cargo binstall --git: [package.metadata.binstall] pkg-url
override in Cargo.toml.
Homebrew tapgpu-eda/homebrew-tap: formula at
packaging/homebrew/jacquard.rb (v0.2.3); depends_on "llvm".
Staging install-validation (validate-install.yml): a
workflow_dispatch gate running the documented cargo binstall +
brew install against a published (pre)release. RC tags
(vX.Y.Z-rc.N) publish as prereleases and gate promotion.
Single shared crate version via scripts/bump_version.py +
verify-guard; Cargo.lock tracked; repo URLs corrected to gpu-eda.
Docs: docs/installation.md, docs/release-process.md
(incl. the staging-validation gate).
Still open: Phase 4 (CUDA/HIP prebuilt binaries — gated on
self-hosted NVIDIA/AMD release runners; Linux stays source-build),
Phase 6 (container image — deferred per the original decision), and
Phase 7 (eda-infra-rs upstreaming, the path to a crates.io publish —
see docs/plans/distribution.md § Phase 7). The original proposal
follows unchanged.
TL;DR. In the context of shipping Jacquard to users (and to
docs-dogfooding agents), facing the fact that it is a GPU-compiled Rust
binary with vendored path-dependency submodules plus two companion
tools, we chose a tiered, channel-per-artifact model — Rust binaries
via GitHub Releases + cargo-binstall + a Homebrew tap (Metal), and the
Python netlist-graph companion via PyPI — accepting that we maintain
per-GPU-target release binaries and three coordinated release cadences,
rather than forcing everything through one channel.
“Install Jacquard” is not one artifact. The toolset is three pieces:
Tool
Language
Role
GPU?
jacquard
Rust
the simulator (sim / cosim)
yes — Metal / CUDA / HIP, compiled via the ucc build script
opensta-to-ir
Rust
SDF → timing-IR preprocessing (timing path)
no (CPU)
netlist-graph
Python
post-synthesis signal-name discovery; the companion the tracing docs (signal-tracing.md, bus-tracing.md) lean on
no
Constraints that rule options out:
jacquard is not crates.io-publishable. Its dependencies are
vendored path deps (vendor/eda-infra-rs/* — a fork carrying
in-flight patches), and the build needs git submodules, a GPU SDK, and
a C++/CUDA/Metal compile. cargo install from source works but is slow
and needs the full toolchain — not “easy.”
jacquard is not a natural PyPI package. It’s a GPU binary; a
maturin wheel would mean per-backend wheels (Metal macOS-arm64 only,
CUDA huge and driver-coupled, HIP).
netlist-graphis PyPI-ready today — self-contained
(networkx + click only), a netlist-graph console script, no
workspace path deps.
Today there are no release artifacts: install = clone + submodules +
cargo build -r --features <backend>. That blocks easy adoption and
makes docs-dogfooding (a fresh agent following the docs) start from a
heavy source build.
Distribute each artifact through the channel that fits it:
Rust binaries (jacquard + opensta-to-ir) →
GitHub Releases prebuilt binaries, one per GPU target
(macos-arm64-metal, linux-x64-cuda, linux-x64-hip).
cargo-binstall support via [package.metadata.binstall] so
cargo binstall jacquard fetches the release asset.
Homebrew tap (gpu-eda/homebrew-tap) for the macOS/Metal path:
brew install gpu-eda/tap/jacquard.
opensta-to-ir ships alongsidejacquard (same release, same
formula) — it’s a sibling CPU bin in the same cargo workspace.
netlist-graph (Python) → PyPI: uvx netlist-graph /
pip install netlist-graph. Versioned independently of the Rust
bins.
The simulator never ships via PyPI. PyPI is for the Python
companion only.
Versioning: the two Rust bins share the workspace version and a
single tag (coordinated); netlist-graph versions independently. This
is effectively Jacquard’s first numbered release — see
release-process.md.
Homebrew scope: the formula installs the Rust bins only;
netlist-graph is documented as a separate uvx/pip line rather than a
formula dependency, keeping the formula simple and the Python tool
independently installable.
Rollout is Metal-first: the macOS-arm64 Metal binary ships now (the
self-hosted macOS runner exists). CUDA and HIP release binaries are
gated on the NVIDIA / AMD runners being stood up; until then those
targets remain source-build.
Install tiers (documented where each is first needed):
pure functional cosim → jacquard only;
signal-name debugging → add netlist-graph;
timing / post-PnR → add opensta-to-ir + a PDK (volare/ciel).
PyPI wheel for the simulator (maturin). Rejected: per-backend
wheels, CUDA wheel size and driver coupling, Metal macOS-arm64-only.
PyPI fits the pure-Python companion, not the GPU binary. (This is the
natural-sounding option; it doesn’t survive contact with the GPU
backends.)
crates.io publish + cargo install. Blocked by the vendored
path-dependency fork, and still requires a GPU SDK + a multi-minute
compile. Not “easy install.”
Container image as the primary channel. Good for reproducible
CUDA/Linux, but a poor fit for the macOS/Metal majority path and heavy
for a quick jacquard --help. Kept as a possible additional channel
(deferred), not the primary.
One-line installs on the platforms that matter; docs-dogfood agents
can brew install instead of building from source.
Release CI must build a per-target matrix; the CUDA/HIP rows are
runner-gated, so the first release is Metal-only and the matrix fills
in as runners land.
Three artifacts to release: two Rust bins (coordinated, one tag) and
netlist-graph (independent). Coordinated Rust versioning keeps this
to two cadences, not three.
New cross-repo surface to own: a homebrew-tap repo and a PyPI
project + publish credentials (trusted publisher).
The stale repository = "…/ChipFlow/Jacquard" URLs (both
Cargo.tomls) must be corrected to gpu-eda as part of this work.
Prebuilt binaries require a relocatable kernel. The Metal binary
currently loads its .metallib from a compile-time build-tree path
(env!("METALLIB_PATH")), so a shipped binary must embed or bundle the
kernel first. Tracked as Phase 1a in the plan.
If maintaining per-backend release binaries proves too costly, fall
back to container images for CUDA/HIP and keep Releases + Homebrew
for Metal only — the channels are independent, so this is a per-target
retreat, not a redesign.
If the Homebrew tap is more upkeep than it’s worth, drop it and keep
cargo-binstall + the release tarballs; the formula is the thinnest
layer to remove.
Everything Jacquard needs to know about a standard cell is a property
of the cell type and comes from the same Liberty library — its pin
directions, its combinational logic, its sequential/classification
nature, and its timing characterization:
Per-cell-type fact
What Jacquard needs
Where it lives today
L1 — pin directions
input vs output per pin
build-time baked from vendored submodules (build.rs → GF180MCU_PIN_TABLE/SKY130_PIN_TABLE), hand-coded for AIGPDK; partly user-suppliable via --cell-library <v> (Decision 0010 Tier 1)
L2 — combinational logic
the boolean function of each output, to build the AIG
read at runtime from a hardcodedvendor/…/<cell>.functional.v and decomposed by pdk_decomp (src/aig.rs:1895); fully hand-coded for AIGPDK
L3 — sequential & classification
is the cell a DFF/latch/clock-gate/SRAM/tie/filler, and what are its pin roles (clock, D, Q, async set/reset, enable; SRAM ports)?
hand-coded per-PDK Rust (src/pdk.rs, src/gf180mcu_pdk.rs, src/sky130_pdk.rs, pin-name matches in src/aig.rs:2080-2260) — data masquerading as code (Decision 0010’s framing)
parsed at runtime from a .lib into liberty_parser::TimingLibrary (src/liberty_parser.rs), consumed in src/aig.rs:2793 / src/flatten.rs (incl. as a liberty_fallback); already user-suppliable via a .lib path
This is the easy thing to conflate. There are two:
Per-cell-type characterization (L4 above) — setup/hold, clock→Q,
delays — a library property, one value per cell type, straight from
the .lib.
Per-design annotation — the timing IR (Decision 0002),
TimingArc { cell_instance: "<hierarchical instance path>" } — SDF for
a specific netlist’s instances, produced per-design by SDF/OpenSTA.
Only L4 is a cell-library fact. The timing IR is orthogonal to a cell
library — it annotates a design, not a library — and stays its own IR.
Decision 0002 drew exactly this line and predicted this ADR:
“The IR represents timing annotation data only. It is not … cell
characterization. Attempts to extend it toward those adjacent formats
are rejected — they become separate IRs if needed.”
Cell characterization is that separate IR. It is not a sibling of the
timing IR (same scope, different half); it is a different axis
entirely — per-cell-type library facts vs per-instance design annotation.
Decision 0010 opened a declarative path for some of L1–L3 (--cell-library
for L1, a .cells.toml manifest for cell kind), and Decision 0011 added a
real port-mapping schema for kind = "ram". But Decision 0010 explicitly
deferred the larger schema — sequential pin-roles, full L3 — “to a
future ADR after real adoption data”, L2 was never addressed (functional
models still come from a hardcoded vendor/ path), and L4 lives in a
parallel runtime .lib parser. Four facts about one cell type, from one
Liberty file, arrive through four different mechanisms.
Two concrete gaps make this acute, both surfaced by
issue #130:
Selection. A post-P&R netlist built against the 9-track library
(gf180mcu_fd_sc_mcu9t5v0) is simulated against the 7-track
functional models, because the path is hardcoded
(src/aig.rs:1895). There is no way to point cosim at the library the
netlist actually instantiates. (build.rs only assert_eq!s ports,
not functional bodies, so “7t == 9t” is unenforced for logic.)
Libraries we cannot vendor. A proprietary/NDA foundry library can
never live in jacquard/vendor/. Today L2 and L3 are only satisfiable
by baking the library into the binary, so such libraries simply cannot
be simulated — there is no all-runtime path.
The project already proved the shape of the fix on the timing side: the
timing IR (Decision 0002) is a portable, versioned, generated, diff-able
structured form produced from Liberty by a focused converter crate, with
the vendored sources demoted to a generation-time input. This ADR applies
the same shape to the cell library — but to all of its per-cell-type
facts at once, in one descriptor, because they share one source.
Introduce a cell-model IR: a portable, versioned, generated,
JSON-first structured descriptor that carries everything per-cell-type
about a library — L1 directions, L2 combinational logic, L3
sequential/classification, and L4 timing characterization — in one
file per library. Jacquard core consumes the cell-model IR (and the
hand-override manifest from Decision 0010/0011) as its only source of cell
semantics: no per-PDK Rust classifiers, no build.rs pin-table
generation, no runtime functional.v parsing, and no runtime .lib
parsing.
The ADR-0002 timing IR is unchanged and orthogonal — it annotates a
specific design’s instances; nothing in it is per-library.
All five facts (L1–L4 + classification) live in one cell-model IR per
library, because they all come from one Liberty file and are all keyed by
cell type. This folds inliberty_parser::TimingLibrary (L4): the
runtime stops parsing .lib and reads pre-extracted timing from the
descriptor. It does not touch the per-design timing IR (Decision 0002),
which is a different axis (per-instance design annotation) and keeps its
own crate/format. The only cross-reference the two need is the netlist
itself — an instance knows its cell type — so there is no shared-schema
join to co-design between them.
The cell-model IR is machine-generated, not hand-written, so its
primary on-disk form is JSON — diff-able, inspectable, no flatc
dependency to read. (This differs from the timing IR, which went
FlatBuffers-first because timing data is per-instance and large; cell
models are per-cell-type and small.) A FlatBuffers encoding is a
deferred escape hatch if size or startup cost ever demands it (see
D3). Schema is explicitly versioned, same evolution discipline as ADR
0002.
Each combinational cell’s logic is stored as a pre-decomposed AIG
(and-inverter nodes, with input-pin → node and output-pin → node maps),
not as boolean expressions or truth tables. Rationale: the runtime
splices the cell’s AIG directly into the design AIG with no
decomposition work — minimal startup time and memory, and it removes
pdk_decomp / functional-Verilog parsing from jacquard core entirely.
Decomposition moves into the generator (D6). If the aggregate AIG payload
grows unwieldy in JSON, encode it with FlatBuffers (the D2 escape hatch).
Sequential cells carry pin-role metadata — clock pin + edge, D /
next-state, Q/QN, async set/reset pin + polarity, enable — plus their
combinational next-state function as the same pre-built AIG (D3),
consumed by the existing DriverType::DFF path (replacing the hardcoded
pin-name matches in src/aig.rs:2080-2260). Cell classification
(std, dff, latch, clock_gate, ram, tie_high/low, filler,
endcap, tap, io_pad_*) is a declared kind. This is the sequential
analogue of Decision 0011’s RAM port schema, which stands as the worked
precedent; RAM keeps its ADR-0011 schema.
The descriptor carries the per-cell-type timing — setup/hold, clock→Q,
DFF/SRAM timing — keyed by the same cell type as the logic; this is the
data liberty_parser::TimingLibrary holds today, extracted once by the
generator instead of parsed from a .lib at every run. At runtime this
replacesTimingLibrary::from_file; the per-design timing IR (Decision 0002)
still layers a specific design’s instance arrivals on top, unchanged.
Multi-corner lives in the one descriptor, keyed by corner — the same
shape the timing IR already uses (crates/timing-ir: an ordered corner-name
set + a corner_index per value, with min/typ/max as the within-corner
derate triple). Corner is the outer key; min/typ/max is the orthogonal inner
derate — not a reinterpretation of the triple. File-per-corner is rejected
because L1–L3 (directions, function, sequential roles, and especially the
D3 AIG — the bulk of the descriptor) are corner-invariant: only L4’s
numbers vary across ss/tt/ff, so a per-corner file would duplicate the
expensive AIG to vary the cheap timing. Corner-keyed L4 dedups the AIG,
keeps one descriptor = one library (which D8 selection assumes), and
degrades to a one-entry corner map for the common single-corner flow. A
corner-overlay-file split is a deferred size escape hatch (parallel to D2),
to be decided from a real descriptor size — not built now.
The simulation corner is user-selected, not read from the netlist or
SDF. A --corner <name> run flag picks among the descriptor’s corner set,
defaulting to a descriptor-declared default_corner (typically tt). The
netlist is structural and records no corner; synthesis used a setup corner
to choose cells but writes it nowhere Jacquard reads. SDF carries PVT header
fields (VOLTAGE/PROCESS/TEMPERATURE), but they are the wrong source on
both counts: when an SDF is present its delays already encode the corner
and feed the orthogonal timing IR (Decision 0002), so L4 corner selection does
not apply to annotated instances; and L4 matters precisely in the no-SDF
cosim path (today’s liberty_fallback), where there is no SDF header to
read. There are two different corners — the setup corner synthesis
chose for (a flow fact, recorded nowhere) and the simulation corner the
user wants to observe — and only the user knows the latter, so user-passed
is the correct source, not a fallback. This also makes today’s implicit
single-corner choice (whatever .lib was passed) explicit. When an SDF is
supplied, its PVT header may be surfaced as an informational cross-check
(SDF says ss_125C, you selected tt_025C), never as the selector. This
mirrors opensta-to-ir, which already treats corner identity as declared
input, not auto-detected.
The IR is produced by a converter crate mirroring opensta-to-ir:
Liberty → cell-model IR — emitting all of L1–L4 from a single pass over
the .lib, with functional.v consulted only as a fallback for the L2
combinational cases Liberty under-specifies (see the resolved L2 open
question below). It reuses, not reimplements, the existing machinery:
pdk_decomp relocates into a shared library the generator links, and the
Liberty group-walker (src/liberty_parser.rs, which today reads only the
L4 timing groups) is extended to also read function / ff / latch /
cell-class. The generator — not jacquard core — owns all Verilog/Liberty
parsing and AIG decomposition; it is unit-testable in isolation, off the
runtime critical path.
Cross-check when both sources are present. For a library that ships
both Liberty and functional.v (the open PDKs), the generator validates
the two against each other and surfaces any disagreement as a
generation-time diagnostic rather than silently trusting one. Two checks:
Logic (L2/L3). Build the combinational AIG from the Liberty
functionand from the .v gate/UDP netlist and check the two for
logical equivalence; likewise the sequential next-state/reset from
Liberty ff/latch against the .v sequential UDP.
Timing (L4). For standard cells, the .vspecify block carries
timing arc topology but no values (zero-placeholder SDF scaffold), so
the check is arc-set agreement, not value comparison: every Liberty
timing() group (related_pin + timing_type) should correspond to a
.vspecify path or timing check and vice versa, and across a
multi-corner library every corner .lib should carry the same
cell/arc set (only the values differ). For macros/SRAM, whose
behavioural .v often embeds real hardcoded timing (access time,
setup/hold) that can diverge from the macro’s Liberty, the check extends
to a value comparison and flags the divergence. A missing, extra, or
mis-typed arc, a cross-corner arc-set difference, or a macro value
divergence — on either source — is surfaced. Liberty remains the
authoritative L4 source either way.
This is the same diff discipline Decision 0001/0002 use, applied across the L2
logic sources, the L4 arc topology, and the corner set; it is the
structural check build.rs’s port-only assert_eq! never had — directly
the silent-substitution class #130 names.
The built-in PDKs — AIGPDK, SKY130, GF180MCU, and IHP SG13G2 (added
through the IR; see D7a) — are bundled as generated cell-model-IR files,
regenerated in CI from the pinned vendored PDKs rather than checked in as
artifacts. CI runs the converter (D6) over the pinned submodules and
embeds the resulting descriptors into the binary at build time; the
descriptors are not committed blobs. The vendored PDKs therefore become
inputs to cell-library generation, not a runtime dependency of jacquard
core — core depends on neither the cell submodules nor pdk_decomp at
runtime, and a released binary is self-contained.
This is the deliberate contrast with the timing-IR bindings (which are
checked in): cell descriptors are larger, fully derived, and have a
deterministic generator, so committing them would only add generated blobs
and drift risk. Build-time generation replaces today’s build.rs
pin-table generation, so source builds keep the same vendored-submodule
requirement they already have. Diff-ability (D2) is preserved as a
capability — CI regenerates deterministically and the generator ships a
descriptor diff tool (mirroring timing-ir-diff) — rather than via git
history; drift and provenance are enforced by the CI regeneration + the D6
cross-check, not by a committed reference.
IHP’s open SG13G2 PDK (IHP-Open-PDK)
is added as a new built-in target — Jacquard has no existing per-PDK
Rust for it — so it is the proof of the headline consequence that adding
a PDK is no longer a Jacquard code change: it is onboarded purely by
vendoring its submodule and generating a descriptor. It is also the
cleanest Liberty-first validation: every SG13G2 combinational cell
carries a function string and every sequential cell a complete ff
group (with reset polarity), exercising the D6 Liberty-first path end to
end, in contrast to GF180’s functional.v-primary legacy path.
Each descriptor declares the cell-name prefix(es) it covers. Jacquard
auto-matches a netlist against the bundled descriptors (replacing the
hardcoded prefix detection in src/pdk.rs); --cell-descriptor <foo.json> is the explicit override and the path for proprietary
libraries. The ADR-0010 .cells.toml becomes the hand-authored
override layer over the generated IR — same data model, different
provenance.
Library-agnostic binary. A proprietary library works with zero
Jacquard changes: the user generates a descriptor on their own machine
from their Liberty (which never leaves it) and points jacquard at it. The
generator is the only tool that touches raw foundry files.
Large core simplification.src/gf180mcu.rs, src/gf180mcu_pdk.rs,
src/sky130*.rs special-cases, the PdkVariant enum, the build.rs
pin-table generators, and the hardcoded vendor paths all retire in
favour of one IR consumer. Adding a PDK stops being a Jacquard PR — and
IHP SG13G2 (D7a) is the worked proof: a new built-in PDK onboarded
with zero per-PDK Rust, purely by vendoring + generating a descriptor.
#130 dissolves. “Which functional models?” becomes “which descriptor
matches this netlist” — derived or --cell-descriptor-overridden — and
the 7t/9t silent-substitution risk goes away because each library has
its own generated descriptor.
Retires the runtime .lib parse and unifies “bring your own
library.” L4 folds in, so liberty_parser::TimingLibrary leaves the
runtime for the generator. Today you already point at your own .lib
for timing while the logic is baked from vendor/; now both come from
one descriptor — exactly the proprietary-library goal.
New schema + converter crate to maintain, under the same scope
discipline Decision 0002 demands: the cell-model IR is per-cell-type library
facts only — not a netlist, not per-design annotation, not a
placement/physical model. Creep is rejected.
Verification moves to a diff/round-trip discipline (Decision 0001/0002
pattern): since descriptors are CI-regenerated rather than committed
(D7), the gate is that CI regeneration is deterministic plus a logic
round-trip — does the IR’s AIG reproduce a reference of the cell? — and
the D6 cross-check across sources. Together these generalise today’s
build.rs port-only assert_eq! into an actual logic check, closing the
gap #130 names.
Sequential fidelity is the risk surface. Mapping Liberty ff
(clear / preset / clear_preset_var) onto Jacquard’s DFF +
async-reset model is where bugs will hide; it gets dedicated generator
tests against the existing GF180/SKY130 behaviour as the oracle.
L2 source of truth — Resolved (D6): Liberty-first, .v as a
bounded fallback. A survey of open and commercial (Liberate-generated)
standard-cell libraries found the same shape in all of them: Liberty
carries function for every combinational cell and ff/latch groups
(clocked_on/next_state/clear/preset, with reset polarity) for
every sequential cell, while the behavioural .v models sequential cells
as a vendor UDP wrapped in notifier/$setuphold timing-check
machinery. So Liberty is the cleaner and sufficient source for L2
function and L3 ff/latch — it states the next-state/reset logic
directly, whereas the .v buries it in simulation scaffolding. The
generator reads Liberty first and consults .v/UDP only for the L2
combinational cases Liberty under-specifies (cells with no function
string, or UDP truth tables whose exact X-semantics you want, e.g. some
muxes). This is the converter’s target; the existing GF180/SKY130
functional.v path is fine as the first converter input in plan C1.
Two bounded sub-points:
L4 timing is Liberty-exclusive for standard cells. A stdcell .vspecify block carries timing arc topology (which arcs/checks exist)
but zero/placeholder values — it is an SDF back-annotation scaffold,
redundant with the .lib’s own arc set. So the cross-check on stdcell
timing is arc-set agreement, not value comparison (see D6).
Exception — macros/SRAM. A memory/macro behavioural .v often
embeds real, hardcoded timing (access time, setup/hold) that can
diverge from the macro’s Liberty — here the two sources disagree on
values, not just topology. (The GF180 SRAM model is a suspected case,
not yet diffed; SRAM timing is already a special path in Jacquard, with
a hardcoded conservative read-delay fallback in
src/liberty_parser.rs.) The generator must surface that disagreement
(D6); L4 still comes from Liberty as the single authoritative source,
but the divergence is a real correctness signal worth flagging rather
than hiding.
Violation-response is out of scope. The .v’s notifier→UDP-x
corruption-on-violation is a simulator policy, not a per-cell-type
library fact: the constraint values it needs are already L4 from
Liberty, and the response is owned by Jacquard’s own timing-violation
subsystem (docs/timing-violations.md: report-based, X only under
--xprop). It is not extracted into the IR.
L4 multi-corner shape — Resolved (D5): all corners in the one
descriptor, L4 keyed by corner (mirroring the timing IR’s corner-set +
corner_index; min/typ/max stays the orthogonal within-corner derate).
Simulation corner is user-selected via --corner (default
default_corner), not inferred from the netlist or SDF — the setup
corner is recorded nowhere Jacquard reads, and SDF delays already encode
the corner via the orthogonal timing IR. Corner-overlay-file split deferred
as a size escape hatch.
Bundled-descriptor provenance — Resolved (D7): regenerated in
CI from pinned vendored PDKs and embedded at build time, not checked
in as artifacts. Build-time generation replaces the build.rs pin-table
step (source builds keep the existing submodule requirement); diff-ability
and drift control move to deterministic CI regeneration + the generator’s
descriptor diff tool + the D6 cross-check.
Migration shape — Resolved: per-PDK cutover. The IR consumer
runs alongside the per-PDK Rust and each PDK is migrated independently,
keeping the suite green throughout, rather than a single switch. IHP
SG13G2 (D7a) is added in the same per-PDK fashion — but greenfield (no
existing Rust to retire), so it doubles as the zero-per-PDK-Rust proof.
Orthogonal to Decision 0002 — the timing IR is
per-design instance annotation; the cell-model IR is per-cell-type
library facts (incl. L4 timing characterization). Different axes, not
siblings — but the cell-model IR realises 0002’s predicted “separate
IR for cell characterization”, and reuses its versioning + diff
discipline.
Extends Decision 0010 — supplies the
“future ADR” 0010 deferred for the heavy (L2/L3) schema, folds in the L4
.lib characterization, and recasts the .cells.toml path as a
hand-override over generated IR.
Builds on Decision 0011 — RAM keeps its
port schema; it is the worked precedent for the D4 sequential schema.
Feeds Decision 0014 — the cell-model IR’s
L2 payload is AIG (D3), spliced into the design AIG at load.
Direction note (2026-07-01). This ADR drafts a subprocess-bundled binary
wheel (embed the CLI, keep PR #53’s subprocess API). On review, a native
PyO3 binding is the preferred long-term direction — an in-process engine is
a materially better “first-class” Python surface than shelling out to a
bundled binary, and if we’re going to invest in per-platform wheels
(cibuildwheel + dylib repair) either way, doing it once for a PyO3 extension
avoids building the subprocess-wheel machinery only to replace it. We are
not doing this now; the decision (subprocess-wheel-first vs. straight to
PyO3) is deferred and tracked in #161. The
subprocess-wheel design below is preserved as the worked alternative and as
the analysis of the shared hard part (per-platform wheels + vendoring the
libc++/libomp runtime tail), which a PyO3 binding faces too.
Jacquard ships as a Rust CLI (jacquard sim / cosim / map). Batch
automation, regression sweeps, and result analysis are naturally Python work,
and PR #53 drafted a Python API for exactly that: JacquardConfig, sim() /
map(), SimResult / DesignStats, and a run_regression() harness.
PR #53 as drafted is a pure-Python subprocess wrapper: a hatchling
noarch wheel with dependencies = [] that locates a separately installedjacquard binary (JACQUARD_BIN env → PATH → shutil.which) and shells out
via subprocess.run. That means a user must install two things from two
channels — the binary (brew / cargo binstall, per Decision 0018) and the
Python package — and keep their versions in step by hand.
We want a first-class Python engine: pip install jacquard yields a
working simulator with no separate binary step. That is a distribution
decision PR #53 does not make, and it is the subject of this ADR.
Two forces shape it:
The binary is self-contained except for a runtime-library tail. The
sim Metal kernel is embedded via include_bytes! (Decision 0018 / v0.2.1), so
the relocated binary needs no sidecar .metallib. But it links Homebrew
LLVM’s libc++ and libomp (via mt-kahypar/OpenMP), so a bare binary
fails to launch without LLVM present — the same defect that broke the
v0.2.1 install channels and is worked around today by the Homebrew formula’s
depends_on "llvm". A wheel has no depends_on; those dylibs must travel
inside the wheel.
The GPU backend is a per-platform build.metal (macOS/arm64) is the
shipping backend; cuda/hip are Linux and heavy. A universal wheel is not
possible; wheels are per-platform, and the backend matrix must be phased.
Ship the Python engine as a binary wheel that embeds the compiled jacquard
binary and its runtime libraries, built with cibuildwheel, published to
PyPI. Keep PR #53’s subprocess API and config/result model unchanged — the
wheel changes packaging, not the Python surface.
Concretely:
Adopt PR #53’s API (config, runner, result, regression,
errors) as the package’s Python layer, moved into the uv workspace
(python/jacquard/, a 5th workspace member alongside netlist_graph,
chipflow_harness, mcu_soc, root).
Embed the binary. The wheel carries the release binary at
jacquard/_bin/jacquard (per-platform). runner.find_jacquard_binary()
grows a step 0: prefer the packaged _bin/jacquard, then fall back to
its existing env → PATH → which chain (so a dev pointing JACQUARD_BIN
at a local build still wins, and a source checkout with no embedded binary
still works).
Vendor the runtime tail into the wheel. cibuildwheel’s repair step
(delocate on macOS, auditwheel on Linux) copies libc++/libomp (and
any other non-system dylib the binary links) into the wheel and rewrites the
install names, so pip install needs no Homebrew LLVM. This is the crux and
the main risk; the plan spikes it first.
Phase the platform matrix (docs/plans/...):
P1 — macOS/arm64 + Metal. The shipping backend; validates the whole
embed-plus-delocate story on the platform we already release for.
P2 — Linux/x86_64 CPU fallback. The cosim CPU backend (Decision 0017) with
no GPU runtime, so pip install works in plain CI containers.
P3 — CUDA / HIP. Gated on Decision 0018 Phase 4 (prebuilt GPU binaries) and
on the wheel-size / CUDA-runtime questions below; may ship as separate
extras (jacquard[cuda]) or a manylinux variant rather than the default
wheel.
Publish via the established OIDC path. Mirror
publish-netlist-graph.yml (trusted publishing, no stored token; TestPyPI
dry-run on workflow_dispatch, real PyPI on tag). cibuildwheel replaces the
single uv build with a per-platform build matrix.
Version with the binary, not independently. The wheel embeds a specific
jacquard build, so its version tracks the binary release (extend
scripts/bump_version.py), unlike netlist-graph which versions
independently. A wheel’s embedded binary and its Python API are one artifact.
A native PyO3/maturin binding would give in-process state access (drive the
sim, peek signals without a subprocess) — a deeper “engine.” We defer it:
It multiplies the build matrix by the GPU-feature axis inside the extension
module, where the runtime-library and feature-gating problems are far harder
than embedding an already-built binary.
PR #53’s subprocess API is backend-agnostic: it works for metal/cuda/hip
unchanged, because the binary owns the backend. A binding does not.
The subprocess wheel delivers the user-visible win (pip install → working
simulator) now; PyO3 is a strictly larger follow-on that a later ADR can take
up if in-process access becomes a real requirement.
pip install jacquard is self-contained on supported platforms — the
headline win. The two-channel version-skew problem goes away.
cibuildwheel + delocate/auditwheel become release-critical infrastructure.
A new failure surface (dylib repair) that the plan de-risks with a P1 spike
and a post-build “install into a clean venv and run sim” smoke gate,
mirroring the existing relocated-tarball user-acceptance gate.
Wheels are large (embedded binary + vendored dylibs; CUDA especially).
Acceptable for Metal/CPU; a gating question for P3.
The PyPI name jacquard must be secured (see open questions). Until
then, TestPyPI validates the pipeline.
netlist-graph stays a separate, independently-versioned noarch package.
This ADR is only about the simulator engine wheel.
Ship PR #53 as-is (pure-Python noarch + separate binary). Simplest, no
cibuildwheel, and a fine interim — but it is not the first-class,
single-command install the goal calls for; it defers the real distribution
decision rather than making it. Rejected as the end state; effectively
subsumed as pre-P1 (the API lands first, the embed follows).
Native PyO3/maturin bindings. Highest ceiling, deferred — see above.
Document-and-require the LLVM runtime (as the raw tarball does) instead of
vendoring dylibs. Pushes the v0.2.1-class failure onto every pip user;
rejected — self-containment is the entire point.
PyPI name. Is jacquard available/securable? If not, jacquard-eda or
gpu-eda-jacquard, aliasing the import name. Blocks nothing until publish.
CUDA/HIP wheel viability. Size and CUDA-runtime bundling may make P3 an
extras/manylinux special case rather than a default wheel — decide in P3 with
data, and log/document any platform the wheel silently omits.
delocate coverage of libomp. OpenMP runtime vendoring has known
sharp edges (duplicate-runtime aborts if a user’s other packages also load
libomp); the P1 spike must confirm a clean load in a mixed environment.
Revised 2026-07-03 (pre-ratification, still Proposed): the entry point
moved from a standalone jacquard build command to folding synthesis
into sim/cosim — RTL is simulated with one command, no separate build
step. The embedded-Yosys/wasmtime engine (Decision §2) is unchanged; only
the surface changed. The original build-command shape is preserved in
“Alternatives considered” for the audit trail. Rationale below.
Relates to:Decision 0014 (AIG / emulator model —
why synthesis is a front-end at all), the Python-engine work
(#161, Decision 0020 pending) that
hosts this, Decision 0018 (distribution),
docs/synthesis-flow.md (the manual flow this wraps),
#162 (implementation tracking).
Jacquard is described as a “GPU-accelerated RTL simulator”, and behavioral
RTL is the intended design input. But it is an emulator (GEM =
GPU-Emulator-inspired; Decision 0014): it maps a
synthesized and-inverter graph onto a virtual manycore, exactly as an
FPGA-based emulator runs a synthesized bitstream, not behavioral source. So the
input to jacquard sim / cosim is a gate-level netlist —
structural Verilog mapped to aigpdk / SKY130 / GF180MCU cells — and the parser
(sverilogparse) is structural-only.
Behavioral RTL reaches that point through synthesis, which today is a
manual, external step: the user runs Yosys (memory_libmap → aigpdk.lib
logic synthesis) or a commercial tool per docs/synthesis-flow.md. This is a
genuine capability — RTL designs run fine — but it is a UX cliff:
Newcomers can’t tell what the tool accepts. (Repeated external feedback: “it
wasn’t clear what your netlist input language support was.”)
“Bring RTL, then run this Yosys script, then point sim at the output” is a
multi-tool ceremony before the first waveform.
Two forces shape the fix:
Synthesis quality drives Jacquard’s speed.synthesis-flow.md is
explicit: the AIG the GPU emulates is only as good as the mapping, and a
commercial synthesizer (DC) yields better QoR than Yosys. So synthesis is a
real quality knob, not pure friction — we must not hide it in a way that
silently caps performance.
A synthesizer is embeddable now, cheaply.YoWASP
ships Yosys as WebAssembly wheels (yowasp-yosys) — no system Yosys, no C++
vendoring, cross-platform. It is already in this workspace’s uv.lock
(transitively via amaranth[builtin-yosys] in the mcu_soc design).
Add an embedded synthesis front-end so behavioral RTL is a first-class
input, while keeping the emulator’s synthesized-netlist core unchanged:
sim / cosim accept behavioral RTL directly — synthesis is a
transparent, cached pre-processor inside those commands, not a separate
user step. There is no jacquard build command. On the input path, the
command classifies what it was handed and dispatches three ways:
Input
Structural parse (sverilogparse)
Cell family
Action
Gate-level, built-in PDK
parses
matches an is_*_stdcell family
simulate directly — the embedded descriptor supplies logic + timing (corner via --corner, Decision 0019)
Gate-level, unknown PDK
parses
matches nothing
error, actionable: “gate-level netlist with unrecognized cells — pass --cell-descriptor <path>” (Decision 0019 D8). Not synthesized — it is already a netlist.
Behavioral RTL
fails (always/if/case/operators are not structural)
n/a
synthesize → aigpdk → simulate, via the embedded Yosys (Decision §2), caching the result
The command prints what it decided (e.g. design.v: behavioral RTL → synthesized [YoWASP Yosys, functional QoR] → <cache>), so synthesis is
never silent (honouring force #1 below). --rtl / --netlist override the
auto-detection; a syntax error in a netlist that falls through to the synth
path surfaces both the structural and the Yosys diagnostics. The
gate-level artefact is dumpable for inspection/fixtures via --emit-synth <path>. RTL in, waveform out, one command — driving the existingdocs/synthesis-flow.md scripts (memlib_yosys.txt → aigpdk.lib).
Yosys as WebAssembly, executed in-process from Rust — not a vendored
native build, and not via the Python yowasp-yosys wrapper. YoWASP ships
Yosys as a single self-contained yosys.wasm (abc is compiled in-tree
into that module and called in-process — WASI has no exec), and the
yowasp-runtime that runs it is a thin harness over
wasmtime, which has a first-class Rust crate. So
the synthesis engine is a Rust component (src/synth.rs) embedded in the
jacquard binary that embeds wasmtime, loads the (bundled or
fetch-on-first-use) yosys.wasm, preopens the design + aigpdk library
files + a temp dir under WASI, and runs the existing synthesis script —
caching the compiled module and the synthesized netlist to disk (by
content hash) exactly as yowasp-runtime does. It is invoked transparently
by sim/cosim on the behavioral-input path (Decision §1), behind the
opt-in synth feature (wasmtime+cranelift are heavy to compile). No
Python interpreter and no external toolchain:jacquard sim design.v … is
RTL-to-waves from the single jacquard binary. This decouples the on-ramp
from the Python-engine packaging decision (Decision 0020 / #161) — it needs
neither PyO3 nor a subprocess-bundled wheel.
Two synthesis tracks, kept explicit (honouring force #1):
On-ramp: YoWASP Yosys — easy, functional; the default when sim/cosim
are handed behavioral RTL.
Performance: bring-your-own DC (or native Yosys) → gatelevel.gv →
jacquard sim directly (the “gate-level, built-in PDK” row above).
Documented as the path to peak GPU speed.
The emulator core does not change. Synthesis is a transparent
pre-processor inside sim/cosim that produces the same structural
netlist users synthesize by hand today; the AIG/boomerang pipeline (ADR
0014/0015) and the structural sverilogparse input are untouched — the
behavioral path simply feeds them a just-synthesized netlist instead of a
hand-written one. Behavioral elaboration stays Yosys’s job — we do not
reimplement an RTL front-end.
Landed (2026-07). RTL in → source-annotated waves out. The on-ramp maps via
abc_new (abc9/aiger2 XAIGER path) so Yosys (* src *) origins survive
std-cell mapping, and the WS-B ingestion (sverilogparse → netlistdb.cell_src
→ AIG::aigpin_src_locations) surfaces them in xsources + --trace-signals.
Two facts below were corrected in flight: (1) the fork wasm builds via the
develop-0.64 Makefile recipe (wasi-sdk 27 + yosys-slang whole-archive), NOT
CMake/wasi-sdk-33 — the patched yosys fork is Makefile-only and CMake dropped
read_slang. (2) The in-process &origins risk flagged below is resolved —
origins survive the in-process WASI abc_new round-trip (100% \src on comb +
sequential). The provenance wasm is built + released by the
gpu-eda/yowasp-yosys fork’s own CI; jacquard fetches the pinned release
(A2). aigpdk-specific mapping needs read_liberty -lib + hierarchy -purge_lib
before abc_new and a single mapping pass. Details:
docs/plans/archive/rtl-source-provenance.md.
A patched toolchain carrying the origin-shell\src pass-through — berkeley-abc #487 (vOrigins/&origins) +
robtaylor/yosys@src-retention-y-ext — keeps RTL source locations alive through
std-cell mapping. Jacquard could then thread \src through netlistdb/AIG so
GPU-sim results speak RTL — --trace-signals, timing violations, and
X-debugging reporting source lines instead of flattened gate names.
The tractable route is building our own yosys.wasm from source rather than
depending on the stock upstream wheel. YoWASP’s build (build.sh: CMake +
wasi-sdk 33 via wasi-sdk-p1.cmake, zlib/libffi/readline/editline/tcl disabled;
recon’d 2026-07-04 — supersedes an earlier “wasi-sdk 27 / Makefile CONFIG=wasi”
description) compiles abc in-tree from yosys’s own bundled abc/ submodule —
verified against the shipped yosys.wasm, which embeds the abc engine (the build
path …/yosys-src/abc/src/base/abci/… and the full set of &-prefixed GIA
commands are present in the module). So a provenance build is mechanical: fork
YoWASP/yosys (now on
Codeberg; the old GitHub repo was archived read-only 2026-03-11), repoint
yosys-src → robtaylor/yosys@src-retention-y-ext and yosys’s bundled abc
submodule → robtaylor/abc@origin-tracking-clean (#487), and rerun build.sh.
Provenance then ships self-contained in one .wasm — not blocked on upstream
YoWASP adopting the patches, only on the patches themselves (abc#487 in review).
Key open risk to spike before committing to WASM provenance: origin-shell
validated \src retention through external abc (ABCEXTERNAL, temp .aig
round-trips). The WASM build calls abc in-process, a different integration
surface — the &origins data must survive the in-process call path, not a
temp-file hand-off. This is unproven and is the first Phase-2 task. A large build
effort overall; tracked as Phase 2 in #162, out of scope for ratifying the
on-ramp.
RTL becomes a first-class, single-command input — the onboarding cliff is
removed, and “what does it accept?” has a clean answer: your RTL (or a
pre-synthesized netlist).
The accepted-RTL surface is defined and documented, not implicit. Because
synthesis is delegated to Yosys, the accepted behavioral subset is the
embedded YoWASP Yosys frontend — and that frontend includes yosys-slang
(read_slang), a near-complete SystemVerilog-2017 elaborator, verified
present in the pinned yowasp-yosys 0.64.0.0.post1131 wasm (read_slang +
495 slang symbols). So SystemVerilog language coverage is strong
(packages, interfaces, advanced generate, structs/enums, most of SV), not the
narrow built-in-read_verilog subset. The remaining bound is concurrent-SVA
synthesis — turning SVA into synthesizable checkers is a separate Yosys
formal capability, still partial (#106/#107), independent of slang’s parsing.
On top of the frontend sit the project’s techmaps (assertions → GEM_ASSERT,
$display → GEM_DISPLAY, memories → RAMGEM), minus dropped testbench-only
constructs. This gets a dedicated docs/accepted-rtl.md, whose authoritative
form is an empirical coverage table driven through
sv-tests (follow-up) rather than a
hand-claimed feature list — hand-claims about a delegated frontend would
violate the “verify, don’t assert” bar.
A QoR ceiling on the easy path. YoWASP Yosys is functional-grade; peak GPU
performance still wants DC. The auto-synth path prints its QoR tier and the
docs must state this, so the on-ramp isn’t mistaken for the performance path.
A Rust wasmtime dependency + a yosys.wasm asset — no new Python
dependency. The synth feature is opt-in at compile time (heavy cranelift
build), but because behavioral input is now a sim/cosim capability rather
than a separate command, released binaries must be built --features synth
— otherwise sim design.v on RTL fails with a build-without-synth message.
A pre-synthesized netlist still simulates with the feature off. The ~39 MB
wasm is either bundled into the binary or fetched to a cache on first use
(open sub-decision, #162). Resolved (2026-07, Phase 2): fetch-on-first-use.locate_yosys_wasm (src/synth.rs) resolves --yosys-wasm → JACQUARD_YOSYS_WASM
→ a pinned provenance wheel downloaded (sha256-verified, cached) from a
gpu-eda/yowasp-yosys release. Provenance forces this: the abc_new origins flow
needs the fork wasm, so a bundled/stock wasm won’t do (see Phase 2 below).
Decouples the on-ramp from Decision 0020 / #161: because synthesis runs Yosys
from Rust via wasmtime, it needs neither the PyO3 binding nor a
subprocess-bundled Python wheel. The Python-engine packaging call can be made
independently; the RTL on-ramp no longer waits on it.
Timing stays descriptor-aligned, not .lib-coupled. The on-ramp does not
reintroduce a --liberty runtime path: for built-in PDKs, logic and timing
come from the embedded cell-model descriptor (Decision 0019 D5), corner-selected
via --corner. --liberty / --cell-descriptor remain for user/custom PDKs.
Provenance (Phase 2) is a large, dependency-gated follow-on, not promised
by this ADR.
Keep synthesis external (status quo), document better. The honest interim
and still the performance path — but rejected as the end state: it leaves the
onboarding cliff that prompted this.
Vendor/embed native Yosys (C++). Heavy build + distribution burden across
macOS/Linux/arch. YoWASP’s WASM wheels are the lightweight embed that makes
this decision cheap.
Python-hosted front-end via the yowasp-yosys wrapper (the original shape
of this ADR). Rejected once inspection showed the wrapper is a ~1 KB shim and
yowasp-runtime a ~100-line harness over wasmtime: hosting build in Python
would force the user to have Python + pip + the wheel present (reintroducing an
install dependency) and couple the on-ramp to the unresolved #161 packaging
decision — all to avoid porting a small WASI harness the Rust wasmtime crate
supports directly. scripts/local_synth.py (which drives
yowasp_yosys.run_yosys for the sky130 flow) remains a useful reference for the
synthesis-script content, even though the runtime is now Rust.
A Nix environment instead of (or alongside) YoWASP. origin-shell is itself
a Nix flake pinning patched yosys + abc + librelane. A Nix devshell is a viable
alternative back-end for jacquard build: native binaries (better QoR and
wall-clock than WASM), fully reproducible, and it carries the same patches with
far less build engineering than a bespoke WASM toolchain — at the cost of
requiring Nix on the user’s machine. The two are complementary — YoWASP:
zero-install, cross-platform, pip-native, with a WASM speed/QoR ceiling; Nix:
native performance + reproducibility for users already in a Nix flow, and the
natural vehicle for the Phase-2 patched (\src) toolchain before/without a
WASM build. jacquard build should abstract the synthesis back-end so it can
dispatch to whichever (YoWASP wheel, Nix devshell, or a plain system Yosys/DC)
is present.
A standalone jacquard build <design.v> command producing gatelevel.gv
as an explicit user step (the original shape of this ADR; implemented on the
first cut of PR #167). Revised away before ratification: it reinstated the very
multi-command ceremony (“build, then point sim at the output”) the ADR set
out to remove, and the artefact boundary it justified turned out unneeded — the
committed *_synth.gv test fixtures are each written once by the test that
introduces them and never regenerated, so no standing “rebuild the fixtures”
workflow depends on a build command; --emit-synth covers one-off fixture
authoring. Folding synthesis into sim/cosim (Decision §1) keeps the
one-command promise while the same src/synth.rs engine still backs it.
Elaborate behavioral RTL directly inside Jacquard (no synthesis). Rejected:
it contradicts the emulator model (Decision 0014) and would reimplement a Verilog
front-end Yosys already provides — enormous scope for no architectural gain.
Relates to:Decision 0013 (peripheral models and the GPU→CPU ring buffers, which are half of this channel already), Decision 0017 (batch dispatch, where BATCH_SIZE lives), Decision 0021 (the RTL on-ramp that makes a synthesizable transactor possible), and Testbench Interop.
Proposed; none of this is built. The code today has a fixed BATCH_SIZE = 1024 (Decision 0017), the GPU→CPU ring buffers from Decision 0013, and the per-edge full readback the cost section describes. What this ADR adds, the adaptive window, per-tap GPU-side projection, the CPU→GPU input pipe, the reactor, and any SCE-MI adapter, is unbuilt. The design sections below are written in the present tense as the intended shape, not current behaviour.
Everything external to the simulation crosses one seam: the batch. A testbench driving stimulus in, a video renderer streaming samples out, a debugger reaching in and reading back are all I/O across the same GPU↔CPU boundary, and one constant governs every one of them.
#![allow(unused)]
fn main() {
/// Batch size for backend dispatch: number of consecutive scheduler edges run
/// in one `run_edges` call (no per-tick CPU interaction within a batch).
const BATCH_SIZE: usize = 1024;
}
The parenthetical is the whole story. 1024 scheduler edges execute with the CPU absent, which is why cosim is GPU-compute-bound (the simulate kernel is about 72% of per-edge GPU time; see the Cosim Perf Report). Nothing crosses the boundary in between, and that is the only reason the number holds. Any external I/O that reaches across inside a batch pulls the boundary back to per-edge and deletes the point of using a GPU.
A UVM driver does exactly that. It reaches through a virtual interface and wiggles pins on every clock, which forces BATCH_SIZE to 1 and a CPU round-trip per edge. So the question was never one of effort. Anything that has to observe or drive every cycle is incompatible with the engine, and no implementation makes it otherwise.
The bound is per-cycle specifically, not latency in general, and the difference is easy to miss. A consumer that wants low latency at a domain cadence (a video tap at vsync, roughly 16 ms; a link at a packet boundary) crosses on that design signal rather than every clock. That cadence is coarse, so the batch stays large and the GPU stays busy. Latency-sensitive I/O and deep batches coexist; only per-cycle interaction collapses the batch to 1.
The signal-streaming and video tap (#184) measured about half its wall-clock on the CPU side. The natural guess was the socket, but the socket write is already non-blocking and drops on backpressure, so that isn’t where the time went. It goes to the full per-edge state readback the tap rides on. Moving state across the boundary is the expensive part, not the syscall, so the fix is a correctly shaped and sized pipe rather than a faster socket. That is what this ADR is about.
The decision that both applications below build on: cross the batch boundary only through bounded, flow-controlled FIFOs, sized so either side can run ahead of the other up to the pipe’s depth without changing the result. This is latency-insensitive I/O, and it is settled prior art rather than an invention.
Emulators standardised the answer decades ago. SCE-MI (Accellera, v2.4, 2016) splits the testbench into an untimed host side and a timed emulator side, with buffered, flow-controlled, message-oriented pipes of configurable depth between them. The property that matters is that a transactor may run ahead of the host up to the pipe’s buffering, which is the same statement as “no per-tick CPU interaction within a batch” reached independently under the same constraint. Jacquard being emulator-inspired (GEM) isn’t a coincidence.
FireSim / Golden Gate (MIDAS II) is the same architecture in open source, as readable code rather than a PDF. The vocabulary maps cleanly:
SCE-MI
FireSim
here
transactor / BFM
bridge (target RTL + host software)
synthesizable BFM in the AIG, or a CPU-side tap
pipe
token channel
the CPU↔GPU rings
untimed / timed split
host-decoupling
CPU model / GPU batch
Golden Gate’s idea outlasts its vocabulary. It decomposes the target into a dataflow graph of latency-insensitive models, so either side can run ahead or stall without changing the result. That is the general form of what BATCH_SIZE does by hand, and why FireSim is both accelerated and deterministic. We can’t lift the implementation (Chisel/FIRRTL onto FPGAs against Verilog/AIG onto GPUs), but it is the closest prior art with source, and the place to look before inventing channel semantics.
One value controls everything: the commit window, meaning how many edges the GPU runs before it lets the CPU drain. At the GPU boundary the buffer is the window. The upstream (GPU→CPU) ring holds one batch’s snapshots, so its depth is the window. The input (CPU→GPU) pipe is pre-loaded to feed the window, so its depth is window / crossing-rate. Nobody sets those depths; you size the window and they fall out. Today’s fixed BATCH_SIZE = 1024 is the degenerate case this replaces, a fixed window and therefore a fixed depth, wrong at both ends: too small for bulk throughput, too large for interactive latency.
The stall is still a real risk, and it is the same cliff from either side. If the window outruns what the pipes can hold, the batch stalls and the GPU idles on a starved input or a full output ring. But since the depths derive from the window, that can’t come from mis-sizing a buffer. It comes only from pushing the window past a genuine limit, and three limits bound it. Memory is one, because a deeper window needs a bigger ring and device memory is the ceiling; this is why per-tap projection matters, since small entries make deep windows affordable. Latency is the second, because a big window delays a latency-sensitive consumer, so a latency budget or a flush signal caps it. Input supply is the third, because an input-gating pipe fills only as fast as the host produces, and if it starves the window shrinks on its own.
So the caller never picks a depth. It declares intent (a latency budget or a flush signal, and a backpressure policy), and the window adapts within those limits with every depth derived. The one buffer that isn’t window-derived is the downstream CPU→socket ring, the slack between draining the GPU ring and writing the socket, and even there the knob is the policy rather than a size: DropOldest for video (a generous default, overflow drops) or Block for lossless JTAG. A caller can pin a depth as an optional override when the workload is known and warm-up should be skipped, but that is the exception, not the contract.
Our vocabulary diverges from SCE-MI’s here, and it is worth being precise. A settable depth is SCE-MI’s FIFO semantics, not its pipe depth, which is query-only (§5.8.5.7). That settable case surfaces here only at the optional override and the socket buffer. Most depth isn’t set at all; it is the window.
Three levers realise the adaptive window, in rising order of sophistication. The first is mode selection: bound the window by the tightest attached consumer’s class (small for interactive, large for bulk) plus a backpressure clamp. It is predictable and the right first move. The second is the flush signal, a designated design signal that closes the window when it fires, vsync for a video tap or a packet boundary for a link. The window becomes “until the next domain event”, which stays large in edge terms so GPU throughput is untouched, while delivery tracks the domain’s own cadence. This is the concrete form of the per-cycle-versus-domain-cadence distinction from earlier, and SCE-MI pipes already carry it as eom end-of-message markers and auto-flush (§5.8), prior art to borrow rather than reinvent. The third, for when a tight consumer and a bulk consumer share a run, decouples the GPU commit window (kept large) from per-consumer drain cadence by having the GPU publish a progress counter the drain polls. That costs mid-batch GPU→CPU visibility (a device-written atomic plus a CPU poll, cheap on Metal and managed memory but worth confirming per backend), and it is deferred until the flush signal proves insufficient. Continuous AIMD control is deferred further still.
Judge any of it by crossings retired per batch (transactions in, frames out), not by features supported.
Backpressure policy belongs to each pipe, not to the system, because the two directions want opposite things. A lossless input blocks: a dropped transaction or bitbang command corrupts the session, so the pipe is sized and the producer waits when it fills. A streaming output drops: latest-wins, never stall the sim, since a late frame is worth less than a stalled GPU.
The deeper asymmetry is that outputs decouple and inputs don’t. An output tap is fire-and-forget into a ring, and the sim never waits on it. An input-driving pipe is different in kind, because the sim’s next step depends on the message. Flow control makes the I/O asynchronous, with no syscall on the hot path, but it can’t remove that dependency, so when a gating input’s pipe is empty and the sim needs the next message, it stalls. That isn’t a bug to engineer away; it is what interactive debug is, and it is why the JTAG-server run sits at a large edge ceiling with the sim as the fast side waiting on OpenOCD.
Cosim is byte-identical across CpuBackend, CUDA, HIP and Metal, and that equivalence is the correctness oracle. The pipes can fill and drain whenever they like, but the sim’s consumption and sampling points stay deterministic, tied to sim time and FIFO order (TCP preserves order) and never to wall-clock. Windowing changes where we sync, never what the sim computes. Replay and bulk capture stay bit-exact regardless of window size or when a socket was serviced; only genuinely-live interactive sessions run on wall-clock time, and those were never golden-compared. The moment a message’s wall-clock arrival could change which edge it lands on, the equivalence breaks. That is the line the design can’t cross.
The input case is pointing a UVM, cocotb or Rust testbench at Jacquard, and the model is SCE-MI’s split. The untimed side (sequences, randomisation, checking) runs on the host, as UVM in a real simulator, cocotb, or plain Rust; the model doesn’t care which. The timed side is a transactor written as synthesizable SystemVerilog, compiled through the on-ramp (Decision 0021) into the AIG next to the DUT, so it lives inside the batch and wiggles pins at GPU speed. Pipes sit between them, as above.
How the transactor attaches is the part that shows the architecture already fits, and it corrects a mistake worth stating plainly: there is no DPI running on the emulator fabric, and there wouldn’t be on ours. A SCE-MI transactor is two pieces, split exactly where our design puts a FIFO boundary. Its synthesizable half touches only a structural pipe endpoint, an interface exposing ready/valid/message wires (scemi_input_pipe and scemi_output_pipe), which the on-ramp synthesises like any RTL with the endpoint as a blackbox boundary cell. The DPI and the scemi_pipe_c_* calls are host-side glue that attaches to that endpoint and marshals to the untimed side. The synthesised transactor never calls C; it drives structural wires, and the host runtime supplies the driver.
That is why SCE-MI is a plausible future interface rather than something the design forecloses. We aren’t implementing the SCE-MI API now, but our pipe is the same FIFO-at-a-boundary plus host-driver split that SCE-MI needs, so a conformant front-end could slot on later as an adapter over the same rings. Leaving it unbuilt for now is a deliberate choice, and the design keeps it reachable.
The one mechanism we’re missing is the input pipe. The GPU→CPU direction (responses, monitors) already exists as the Decision 0013 ring buffers; the CPU→GPU direction (transactions in) doesn’t. It has to be pre-loaded before dispatch rather than streamed, because ulib’s H2D is synchronous and uploading per edge reintroduces the very stall the batch avoids. Load a batch’s worth of messages into device memory and let a GPU-side feeder pop them as the transactor asserts ready.
Running the untimed side on the GPU instead is a category error, not a long tail of missing features. The AIG is elaborated once into a static script (FlattenedScriptV1), so there is no heap for UVM’s new() and factory, no event queue for fork/join and phasing (throwing that queue away is the speed thesis), no solver for randomize(), no strings or associative arrays for uvm_config_db, and only immediate assertions (temporal SVA needs runtime automata). Building all that is writing a SystemVerilog simulator, and it would run at CPU speed while dragging the GPU into a per-edge handshake, a slow simulator running inside a fast one.
The reason to adopt SCE-MI’s semantics but not chase its API is the ecosystem, or the lack of one. SCE-MI exists to talk to Palladium, Veloce and ZeBu; open-source UVM targets simulators, which have no boundary to amortise, so no public UVM testbench uses SCE-MI pipes. A survey of the field turned up exactly one open SCE-MI implementation, a small single-author reference last touched in 2019 (narenkn/scemi_lib), and no open synthesizable transactor library at all, since the AXI, APB and UART BFMs are commercial VIP. Conformance would buy interop with proprietary emulators we can’t obtain or test against, which is unfalsifiable and low-value. The spec and its headers are public, so we borrow the shape; the code and the transactor corpus aren’t there to reuse. The with-source model to mirror is FireSim/Golden Gate, whose bridges and token channels are the same split in readable code under their own API. Study their channel semantics before inventing ours.
Two costs are real and worth stating to anyone asking. The transactor is ours to write, per protocol, with the untimed side adapted to speak transactions; commercial users escape that only because a marketplace sells them the BFM. And if this is to be more than a demo, the honest investment is an open library of on-ramp-synthesizable protocol transactors, the thing that would produce the examples we currently lack. Anyone hoping to lift a testbench off GitHub and press go should read that as a no.
The output and interactive case is a video renderer, the signal-stream scope (#184), a --jtag-server driving OpenOCD in and reading back, or --trace-signals observing. These multiply, they run in both directions at once, and they need a structure rather than per-tap socket calls threaded through the loop.
A Tap declares what it needs, and the runtime decides how to serve it:
Field
Meaning
direction
Out, In (drives pins), or InOut (JTAG)
projection
the signal set it observes or drives, its slice of state rather than the whole thing
cadence
every edge, on a strobe, or on a clock domain (JTAG’s TCK)
backpressure
DropOldest / Block / Coalesce, governing the socket-side ring
latency_budget / flush_signal
the tap’s intent, which bounds the window; ring depths derive from it
depth (optional)
override the derived depth for a known workload
The field that is deliberately absent is a required depth. Per the sizing section, the tap declares intent and the depths follow from the window.
The model produces two FIFO boundaries sized by different constraints. Upstream are the per-tap GPU→CPU rings, generalising the Decision 0013 buffers from UART and bus traces to any projection. Downstream are per-tap socket rings owned by a reactor: one mio-style thread over every tap fd, doing accept, read and write, so N taps compose without a thread each and no socket syscall lands on the hot path. The sim loop touches this at two points per batch, draining input rings (blocking only for a gating input) and pushing output rings (never blocking).
The projection is what actually recovers the #184 cost, and it is separate from the reactor. If the GPU packs only a tap’s projected signals at the tap’s cadence, each ring entry is a few bytes rather than the whole 2×state_size, so the ring can be deep without the per-edge full readback that was the 50%. Off-thread sockets are cleaner but small; the projection is the win. Keeping them as separate workstreams stops the reactor being credited with a fix it didn’t make.
One coherent model covers both cases, because direction, backpressure policy, and endpoint (a GPU-side feeder or the CPU reactor) are parameters of one flow-control design rather than separate designs. A third crossing slots in instead of re-deriving channels, and BATCH_SIZE stops being a single wrong number, following the workload and the attached consumers instead.
The costs are equally concrete. Existing UVM drivers don’t port; their timed half is rewritten as a BFM (standard SCE-MI and TBX work, but real), and a host adapter over DPI or a socket is still needed on the untimed side. The model suits protocol transactors and targeted taps, not big memories: a BFM carries little state, whereas a 16 MB flash backing store still wants a bespoke kernel rather than an AIG memory, which gives Decision 0013’s target architecture a second answer where some peripherals could simply be RTL. It also adds surface to get right, namely a lock-free SPSC ring on the hot path, per-backend GPU-side projection packing, the reactor, and the CPU→GPU feeder. The determinism contract has to be tested rather than assumed, since a live-versus-replay divergence would be silent. When it does fail, the failure is a window pushed past a limit (a starved input, a full output, or memory), reached by over-running the window rather than by picking a wrong buffer size.
A per-cycle bridge (naive cocotb or DPI) is rejected for throughput because it forces BATCH_SIZE to 1. It is worth naming honestly, though: this is the signal-level acceleration mode commercial emulators do ship, keeping the driver and co-modelling pin values every clock, with the accelerator idling on the host. Legitimate for bring-up and debug, not a throughput path, and exactly the cliff this ADR avoids.
Full SCE-MI API conformance is deferred rather than rejected. The architecture is shaped to admit it later, but with no open conformant peer and no open transactor corpus there is nothing to validate against, so it would buy interop only with proprietary emulators. Adopt the semantics now and leave the door open.
Running a UVM runtime on the GPU is rejected for the reasons above: it is a SystemVerilog simulator, slower than the thing it replaces. Moving only the socket to a thread is necessary but not sufficient, since it leaves the per-edge readback where the time actually goes. A thread per tap doesn’t scale, which is what the reactor is for. Continuous AIMD window control from day one is premature; mode selection plus a flush signal is predictable and likely captures most of the benefit. Record-and-replay works today and stays the recommended interim path, but it isn’t reactive, so stimulus can’t depend on the design. Vendoring the SCE-MI spec into docs/ is rejected because it is Accellera’s copyrighted work with no update path, and we need the split and the pipes, not the DPI binding details; cite it, don’t copy it.
SCE-MI (Standard Co-Emulation Modeling Interface), Accellera: the standard downloads. v2.4 (2016) is current, and the spec prints the C and SV pipe API in its appendices, including query-only pipe depth (§5.8.5.7) and blocking plus non-blocking transfer with eom and auto-flush (§5.8).
narenkn/scemi_lib: the only open-source SCE-MI implementation found (function-based plus a pipes reference, MIT, single-author, last touched 2019). Useful for API shape and for seeing the structural-endpoint-versus-host-DPI split, not a foundation to build on.
Status: Superseded (2026-05-01). Spike (../spikes/opentimer-sky130.md) failed Q2 — OpenTimer’s input pipeline cannot handle real OpenROAD-flow .v/.spef for SKY130 designs with bus ports. Fallback is OpenSTA subprocess validation only (Decision 0001); a future ADR may revisit libreda-sta or an in-house walker if an in-process reference is wanted later.
Jacquard needs an in-process reference STA path to:
Validate SDF-derived timing against an independent computation at load time and on demand (requirement R2 in timing-correctness.md).
Provide exact per-edge arrival for top-K critical paths (requirement R4, pessimism-delta reporting).
OpenSTA (Decision 0001) is the ground-truth oracle but runs only as a subprocess — unsuitable for per-run, in-process checking. A linked alternative is needed.
Options surveyed:
OpenTimer (MIT, C++17). Parses .lib / .v / .spef / .sdc directly. Won TAU Timing Analysis Contests (2014 1st, 2015 2nd, 2016 1st); industry “Golden Timer” for benchmark comparisons. Actively maintained (latest push 2025-12-26 as of this writing). Does not parse SDF — timing is computed from Liberty + parasitics.
libreda-sta (Rust, permissive). Young framework, self-described as “basic components.” Unknown whether it handles SKY130 Liberty robustly. Lower maturity risk than OpenTimer.
Tatum (MIT, C++). Analysis engine only; does not parse Liberty/SDF/Verilog. Using Tatum would require supplying our own parsers, so it does not solve the problem directly.
In-house Rust walker. Author-shared blind spots with Jacquard’s main pipeline reduce the independence benefit.
Subject to the spike’s success, OpenTimer becomes Jacquard’s in-process reference STA, integrated via C++ FFI (bindgen or equivalent).
Linked directly; MIT licence satisfies project-scope.md.
Computes timing from .lib + .spef independently of any SDF-derived path. This is an accepted (and arguably preferable) property: the reference path shares no parsing with Jacquard’s SDF consumer, so a parse bug on either side is detectable rather than mutually masked.
Emits timing IR (per Decision 0002) so its output is directly diffable against Jacquard’s SDF-derived IR.
Spike criteria in ../spikes/opentimer-sky130.md. On spike failure, fallback is to drop the in-process reference entirely and rely on OpenSTA subprocess validation (Decision 0001). This weakens per-PR feedback on timing correctness but is not fatal.
C++ FFI dependency; bindgen-generated bindings; build complexity rises modestly.
Direct linking preserves permissive licensing (MIT).
Three-way cross-check becomes the default in CI: Jacquard (SDF path) vs OpenTimer (Liberty+SPEF path) vs OpenSTA (subprocess, full ground truth). Three-way disagreement localises bugs to SDF parse / delay model / tool issue cleanly.
OpenTimer does not parse SDF. To use it in Jacquard’s current flow, OpenLane2 (or equivalent) must produce SPEF alongside SDF. This plumbing change is tracked in the phase-0 plan.
OpenTimer’s maturity is measured in contest benchmarks, not SKY130 real-flow output. Spike must verify it handles our actual Liberty and SPEF. The spike is structured to fail fast if it does not.
If OpenTimer is dropped post-spike, alternative in-process references (libreda-sta, in-house) can be revisited; this ADR would be superseded rather than amended.
Time box: Half a day. Extend by up to one day if initial signs are positive but hitting specific SKY130 quirks. Abort and fall back if first-four-hours progress is blocked.
Determine whether OpenTimer (MIT, C++17) can reliably parse and analyse Jacquard’s real-flow inputs — SKY130 Liberty and OpenLane2 MCU SoC post-P&R output — well enough to serve as Jacquard’s in-process reference STA (per Decision 0003).
The outcome resolves Decision 0003’s Pending Spike status to either Accepted or Superseded.
OpenTimer clone and local build (MIT licence, standard CMake).
SKY130 Liberty file(s) matching the corner the MCU SoC flow uses. At minimum sky130_fd_sc_hd__tt_025C_1v80.lib.
MCU SoC post-P&R output: synthesised .v, SDC, and — critically — .spef. Check that the current OpenLane2 invocation is configured to produce SPEF; if not, enable it. OpenTimer requires SPEF, it does not consume SDF.
Jacquard’s current timing-analysis binary output on the same design for comparison.
OpenSTA installed locally, for three-way comparison.
Pass: clean parse, no warnings that indicate misinterpreted cells.
Partial: parses but warns on specific cells — in particular sky130_fd_sc_hd__dlygate4sd3_* or anything with non-trivial conditional timing. Document which cells and whether their timing is discarded or mishandled.
Fail: parse errors, segfaults, or silently-wrong output on recognised cells.
Run OpenSTA on the same inputs, compare top-20 critical endpoints’ arrivals. Declare tolerance: ±5% on arrival time, ±10 ps absolute floor for very short paths.
Pass: all top-20 endpoints within tolerance.
Partial: most within tolerance, a small number of outliers traceable to specific delay-model differences (e.g., CCS vs NLDM).
Fail: systematic disagreement suggesting OpenTimer is computing something meaningfully different. Investigate; if the disagreement is on SKY130 cell interpretation (a PDK handling issue) this is essentially a fail for our purposes.
Compare worst-slack and top-K endpoint lists (not exact values — pessimism differences are expected and documented). Observe:
Pass: top-K lists overlap substantially; worst-slack is on a comparable path.
Informational: any systematic discrepancy tells us what the pessimism delta actually looks like in practice. This data informs R4 (critical-path refinement reporting) whether OpenTimer is adopted or not.
Decision 0003 → Accepted with SKY130 cell workarounds documented, or → Superseded if the workarounds are too invasive.
Fail on any
—
—
Decision 0003 → Superseded. Fall back to OpenSTA-subprocess-only validation. Revisit libreda-sta or in-house walker as alternatives in a follow-up ADR.
OpenTimer 2.1.0 and OpenSTA 3.1.0 cloned to Jacquard-depends/ and built
locally. Build notes in that repo’s README.md.
SKY130 Liberty already on disk via volare:
~/.volare/volare/sky130/versions/c6d73a35f524070e85faff4a6a9eef49553ebc2b/sky130A/libs.ref/sky130_fd_sc_hd/lib/sky130_fd_sc_hd__tt_025C_1v80.lib.
Spike artefacts kept in this worktree under spike-out/ (gitignored —
reproducible from Jacquard-depends/).
W celllib.cpp:274] unexpected lut template variable normalized_voltage
The normalized_voltage axis appears in exactly one place in the Liberty:
the library-level normalized_driver_waveform("driver_waveform_template")
block, which is CCS-driver-waveform data. No per-cell timing arc references
it — cell_rise/cell_fall/rise_constraint/fall_constraint all use
the NLDM templates del_1_7_7, vio_3_3_1, constraint_3_0_1. So the
warning has no impact on arrival/slack computation under NLDM, which is
what OpenTimer does anyway.
Operational note: OpenTimer’s read_celllib is lazy — the parse only
runs when an action like update_timing (or report_*) forces taskflow
execution. Issuing dump_celllib immediately after read_celllib reports
“celllib not found” because the read hasn’t fired yet. Always insert
update_timing before any inspection command.
The documented read_celllib -min|-max <file> syntax silently no-ops; bare
read_celllib <file> loads the lib as both min and max corners. Filed as a
docs/build mismatch in our Jacquard-depends/README.md.
Used OpenSTA’s bundled gcd_sky130hd example (a canonical SKY130-HD GCD
with .v, .sdc, .spef, .lib) as a fast smoke test before tackling
MCU-SoC SPEF generation. If OpenTimer can’t handle this, the MCU-SoC
effort is wasted.
OpenSTA baseline: clean run, period 5 ns, top arrival 4.82 ns,
WNS 0.00, slack 0.09 met. 0.28 s wall, zero warnings.
OpenTimer: could not produce a single timing path. The result was
no critical path found, wns = nan, tns = nan — even after working
around the following issues, each of which had to be discovered and
patched manually:
#
Issue
Workaround tried
Status
1
`read_celllib -min
-max ` (the documented syntax) silently no-ops
bare read_celllib <file> loads as both corners
2
dump_* after read_* reports state-not-loaded because the read is lazy
insert update_timing before any inspection
works
3
Tap cells in post-P&R Verilog (sky130_fd_sc_hd__tapvpwrvgnd_*) trigger 1040 cell not found in celllib errors and abort the netlist load
strip tap cell instances from Verilog
works
4
OpenTimer’s bundled SDC parser uses pre-TCL-8.5 syntax (trace variable VAR w CMD); fails on the system’s TCL 8.6 with bad option "variable" and produces zero parsed commands — even on OpenTimer’s own bundled examples
patch ot/sdc/sdcparsercore.tcl:144 to trace add variable sdc_version write __set_v
works (one-line fix; should be upstreamed)
5
OpenSTA-style SDC with set period 5 / expr $period * 0.2 / [all_inputs] parses as zero commands
hand-write a literal SDC with create_clock -name clk -period 5 [get_ports clk]
works for trivial constraints; non-trivial SDC remains uncovered
6
SPEF *PORTS section (standard SPEF, IEEE 1481, emitted by OpenROAD/OpenLane) is rejected with a parse error pointing at the first port line
strip *PORTS block from SPEF before reading
works
7
Verilog bus ports (input [31:0] req_msg;) are not bit-blasted by OpenTimer’s Verilog parser, but post-P&R SPEF references the bus as bit-indexed nets (req_msg[0], req_msg[1], …). 48 bus-element nets fail to match between netlist and SPEF
none found
blocking
8
After all of the above, two interior pins (_251_:B, _218_:B) report “not found in rctree” and the timing graph remains disconnected enough that no path can be reported
not investigated further
blocking
Issues 7 and 8 mean that on a SKY130 design with bus ports — i.e. any
design that talks to the rest of the world — OpenTimer cannot compute
arrivals from a standard OpenROAD .v/.spef pair without inputs being
pre-processed by code that doesn’t exist.
The cumulative finding is not “OpenTimer mishandles a few SKY130 cells”.
It is that OpenTimer’s input pipeline (Verilog parser, SPEF parser,
bundled SDC parser) is incomplete relative to what real OpenROAD-flow
outputs contain, and the gaps fall on hot paths (bus ports, tap cells,
modern TCL, OpenROAD-emitted SPEF). The cells themselves parse fine (Q1);
it’s the surrounding ecosystem that doesn’t.
Q3 (cross-check vs OpenSTA) and Q4 (correlation with Jacquard’s
timing-analysis) both depend on OpenTimer producing arrivals. With Q2
unable to produce a single path, they’re moot for this spike.
Decision 0003 → Superseded. Per the spike’s decision matrix
(“Fail on any → Decision 0003 → Superseded. Fall back to OpenSTA-subprocess-only
validation”), the right move is to retire the in-process-OpenTimer plan
and lean on OpenSTA-subprocess validation (Decision 0001) as the sole timing
reference. A follow-up ADR should consider libreda-sta or an in-house
walker if an in-process reference is still wanted later.
OpenTimer’s strengths (in-process C++17, taskflow-based, MIT, fast for
the academic benchmarks it ships with) are real, but the input-pipeline
gaps are large enough that adopting it would mean owning a non-trivial
fork — the opposite of what a “lightweight in-process reference” is
supposed to be.
The Liberty parser is genuinely capable (Q1 passed cleanly on the 12 MB
SKY130 NLDM lib in 120 ms), so OpenTimer remains an option for future
narrow tasks like Liberty introspection, but not as the STA engine.
OpenSTA bundles gcd_sky130hd.{v,sdc,spef} and sky130hd_tt.lib.gz —
a cleaner SKY130 smoke-test fixture than anything we’d have produced
from chipflow in the time we had.
~/.volare/volare/sky130/versions/c6d73a35f524070e85faff4a6a9eef49553ebc2b/sky130A/...
is the live SKY130 PDK already on this machine (chipflow installs it).
No need to fetch it separately.
Status: The compute-API question is answered — stay on HIP, do not port
to OpenCL (see “Revised conclusion”). ROCm now builds, runs, and passes all 14
cosim goldens byte-identically, guarded by the permanent HIP Tests (ROCm backend) CI job — and on APU-class silicon, which is better news than this doc
originally thought (see the correction below).
sim now runs on ROCm too — it never had, because the device has no
cooperative launch — via a non-cooperative fallback; see “sim on a device
without cooperative launch” below.
What remains open is the last mile: whether the laptop archs
(gfx1103/gfx1150/gfx1151) run correctly, which needs silicon we don’t
have.
Question: what does Jacquard need in order to run on an AMD laptop, and is
the answer HIP/ROCm, OpenCL, Vulkan, or something else?
The HIP Tests (NVIDIA backend) job builds hip-runtime-nvidia and runs on
tesla4-runner: HIP-over-CUDA, never ROCm. The org’s self-hosted AMD runner sat
online and idle because nothing targeted it (#198).
Probing it (2026-07-15) showed the box itself is fine — ROCm 7.2.4, hipcc (HIP
7.2.53211), hipconfig --platform = amd, and a trivial HIP kernel compiles and
runs correctly on the GPU:
--- native ---
result: 1 2 3 4 5 6 7 8
NATIVE: OK
It reports gfx1030, despite a gfx1036 runner label — and the original
version of this spike drew the wrong conclusion from that, which is worth
recording because the error ran for a while and pointed the whole investigation
the wrong way.
Corrected (2026-07-16, measured on the runner): the label is right and the
report is spoofed. The runner is the Raphael iGPU integrated into nvidia1’s
own Ryzen 5 7600 — lspci says 1002:164e (Raphael), which is gfx1036, and
the amd-runner container image bakes in HSA_OVERRIDE_GFX_VERSION=10.3.0:
So the claim that “green HIP CI on that runner would tell us nothing about an
AMD laptop” was backwards, and wrong in our favour. The runner is an
integrated RDNA2 GPU — an APU, on the same support tier as the laptop parts we
care about, not a discrete board on ROCm’s main matrix. The 14/14 ROCm cosim
goldens are therefore already passing on APU-class silicon. That is a far
better laptop proxy than a discrete gfx1030 would have been.
Note what that also means: gfx1036 (Raphael) appears nowhere on ROCm’s
supported lists (see the table below) — and it works anyway, via the override.
That is a data point about how binding those lists really are.
Prior assumption — “ROCm doesn’t do laptop APUs, you need
HSA_OVERRIDE_GFX_VERSION” — is out of date. ROCm 7.2.1 added official
Ryzen APU support. But it’s narrower than “AMD laptops”:
Not listed: gfx1103 (Phoenix / Hawk Point — Ryzen 7040/8040), gfx1035
(Rembrandt), gfx1036 (Raphael), gfx90c (Cezanne). One search summary claimed
gfx1103 was supported; the authoritative page does not list it. Treat as
unconfirmed pending the research below.
Support is also version-churny — ROCm#5339 is titled “Confusing rocm
support for gfx1151”, and reports suggest 6.4.2 had gfx1151 but not gfx1150,
with both in the 7.13.0 preview matrix.
So even on supported laptop silicon, ROCm is: two gfx targets, one Ubuntu
point release, a specific OEM kernel, and a BIOS change. That is a demanding
contract to put in front of someone who just wants to simulate a netlist.
Host side mirrors this: cuda.rs 690, hip.rs 695, metal.rs 2116.
AMD support currently costs ~226 lines because HIP is source-compatible with
CUDA. Metal is the honest precedent for a backend that isn’t: a whole parallel
kernel plus 3× the host code. Any move off HIP moves AMD from the first column
to the second.
kernel_v1_impl.cuh:623 calls cooperative_groups::this_grid().sync() — a
grid-wide barrier inside the kernel, via hipLaunchCooperativeKernel.
Neither OpenCL nor Vulkan has a device-wide barrier primitive.
cosim does not have this problem, and says so itself:
Unlike the sim scan above, cosim is reactive (inputs depend on outputs), so
the host drives one scheduler edge at a time over a 2-slot [input|output]
state. These kernels are NON-cooperative ordinary launches — the host loops
major stages and each launch is the grid-wide barrier — so cosim never needs
the cooperative grid.sync the scan relies on. They mirror Metal’s state_prep
and simulate_v1_stage.
So the realistic scope of any portable-compute backend is cosim only, sim
stays on CUDA/HIP — unless sim is restructured into N host-driven launches,
which is a barrier per sync point and is precisely what the cooperative launch
exists to avoid.
Zero templates in the impl; only 26 CUDA qualifiers
(__device__/__global__/__shared__/__forceinline__). It’s C-like CUDA,
so a port is transliteration plus the sync problem, not a fight with a type
system.
The cross-backend goldens already exist — CpuBackend == Metal == CUDA ==
HIP, byte-identical. A new backend gets a correctness oracle on day one. The
timestamp maths is host-side Rust (#195), so goldens should match outright.
Two kernel implementations become three. Every kernel-level change lands three
times and must stay byte-identical against the goldens. That tax is forever and
is larger than the port. Plus: a Device::OpenCL variant in vendored ulib
(the [CPU] [CUDA] [HIP] [Metal] device-ID layout is positional — additive, but
a submodule change), 24 CosimBackend methods, and a packaging change (OpenCL
compiles kernels at runtime from source/SPIR-V, unlike the build-time ucc
compile).
llama.cpp / ollama have driven these parts in anger far longer than any vendor
matrix reflects. Their pain on AMD laptops is real and well documented. It is
also, almost entirely, rocBLAS pain — which we don’t have.
gfx1103 (Radeon 780M), llama.cpp#20839 — three failure modes:
Flash-Attention WMMA kernel: “no device code compatible with HIP arch
1300”, tuned for discrete RDNA3;
The decisive line in that issue: “The problem didn’t exist in older llama.cpp
versions (~late 2024 vintage) that embedded HIP kernels directly rather than
calling rocBLAS externally.” Vulkan works there, ~2 s/generation slower than
a working ROCm build.
gfx1151 (Strix Halo), llama.cpp#13565 — an officially supported
part where HIP is 2.5× slower than Vulkan (pp512: HIP 348 tok/s vs Vulkan 881).
Tellingly, compiling for gfx1100 and spoofing HSA_OVERRIDE_GFX_VERSION=11.0.0
reaches ~599 — faster than the native gfx1151 path. Both hit max clock, so
it isn’t hardware; it’s untuned rocBLAS/Tensile kernels for the arch. Still open.
See also ROCm#5643 (hipBLASLt falls back on gfx1151 as unsupported) and
the community’s custom rocBLAS builds for gfx1103 — an entire cottage
industry of rebuilding the library per arch.
No BLAS. Zero references to rocblas/hipblas/cublas/tensile
anywhere in csrc/ or src/. Failure modes 2 and 3, and the gfx1151
performance gap, are all rocBLAS/Tensile artefacts.
No matrix intrinsics. No wmma/mfma/matrix_core. Failure mode 1 is a
WMMA kernel. We simulate AND gates.
One arch-specific intrinsic, __shfl_down_sync (kernel_v1_impl.cuh:273)
— a standard warp shuffle, fine on RDNA.
wave32 is required and satisfied.kernel_v1.hip.cpp hard-rejects
warpSize != 32 (CDNA/GCN wave64 unsupported). gfx1103 (RDNA3), gfx1150
and gfx1151 (RDNA3.5) are all wave32 — the laptop parts are exactly the
shape we want.
We’re the “embedded HIP kernels directly” case that worked on gfx1103.
We only emit code for gfx1030 and gfx1100 — both discrete. No
gfx1103, no gfx1150, no gfx1151. That is why the CI runner works (it
reports gfx1030) and why a laptop wouldn’t: not because HIP can’t, but because
we never compiled for it.
There is already an escape hatch — UCC_HIP_TARGETS, comma-separated — in a
fork we control. Custom HIP kernels can be compiled for any arch the compiler
knows; only rocBLAS needs per-arch prebuilt libraries, and we don’t use it.
Do not port to OpenCL. The cost is a third 1400-line kernel plus a permanent
3× tax on every kernel change, and sim can’t port at all (no device-wide
barrier). The premise that motivated it — “ROCm won’t reach AMD laptops” — did
not survive contact: with two small fixes the existing HIP backend compiles for
every laptop arch and passes 13/14 goldens on real ROCm hardware. We were three
commits from ROCm, not one backend.
Both blockers were ours, not AMD’s, and both were hidden by the same thing:
hip-build installs the CUDA Toolkit and targets hip-runtime-nvidia, so the
HIP path had only ever been compiled with CUDA headers and CUDA semantics on
hand. HIP Tests (NVIDIA backend) was an accurate job name that nobody read
literally.
What’s left for laptops specifically is unproven but no longer speculative:
compiling for gfx1103/gfx1150/gfx1151 works; whether they run correctly
needs silicon we don’t have.
Ran it on the AMD runner (ROCm 7.2.4) with
UCC_HIP_TARGETS=gfx1030,gfx1100,gfx1103,gfx1150,gfx1151. Two findings, and the
second is bigger than the spike’s original question.
1. hipcc accepts every laptop target. The compiler was invoked as
and raised no objection to any arch. The arch list is not a barrier — consistent
with the research: only rocBLAS needs per-arch prebuilt libraries, and we don’t
use it.
2. We have never compiled against real ROCm at all. The build died here:
csrc/types.hpp:26:10: fatal error: 'math_constants.h' file not found
26 | #include <math_constants.h>
1 error generated when compiling for gfx1030.
The guard fires for HIP device compilation, but math_constants.h is a CUDA
toolkit header. ROCm ships hip/hip_math_constants.h instead. It has never
been caught because hip-buildinstalls the CUDA Toolkit (“no GPU needed to
compile”) and builds hip-runtime-nvidia: the HIP path has only ever been
compiled with CUDA headers on the include path.
So the HIP Tests (NVIDIA backend) job name is exact, and nobody read it that
literally: the HIP backend is HIP-over-CUDA only. It has never been built,
let alone run, on ROCm — on a laptop, a discrete Radeon, or anything else. Note
this fails on gfx1030, the arch we nominally support and the one our own
runner is. Laptop support was never the first blocker; it’s the second.
This is a one-header fix in a fork we control, but it must be fixed before any
claim about ROCm — laptop or otherwise — can be tested.
Two blockers, both invisible until something compiled against real ROCm. Both
fixed; the third finding is open.
Blocker 1 — CUDA header on the HIP path.81184fa (“Add HIP (AMD GPU)
backend support”) widened ulib’s guard from #ifdef __NVCC__ to
#if defined(__NVCC__) || defined(__HIP_DEVICE_COMPILE__), dragging the
CUDA-only <math_constants.h> into HIP device compilation. Upstream is
unaffected — its #ifdef __NVCC__ is correct for a CUDA header; the bug is
ours, introduced with the HIP patch. Nothing uses the CUDART_* constants that
header provides; the macros beneath it want nanf/nan/INFINITY from
<cmath>. Fixed by including what’s used.
Blocker 2 — the lane mask is CUDA-shaped. With the header fixed, the compile
reached our one arch-specific intrinsic and died:
amd_warp_sync_functions.h:297:62: error: static assertion failed due to
requirement 'sizeof(unsigned int) == 8': The mask must be a 64-bit integer.
CUDA’s __shfl_*_sync takes a 32-bit mask; HIP-on-AMD takes a 64-bit one
(an AMD wave can be 64 lanes) and static-asserts it. Every call site in the
shared kernel was a hard compile error on ROCm, and invisible on CUDA and on
HIP-over-CUDA — which is all we had ever built. Fixed with a lane_mask_t
typedef (64-bit under __HIP_PLATFORM_AMD__, unsigned otherwise). It has to be
a type, not a constant: one mask is a ragged tail (0xffffffff >> n) whose
value must survive widening. No-op off AMD, and Metal goldens stayed 14/14.
UCC_HIP_TARGETS=gfx1030,gfx1100,gfx1103,gfx1150,gfx1151
→ build: SUCCESS (all five archs, incl. every laptop target)
And on the AMD runner’s real gfx1030 — the first time Jacquard has ever
executed on ROCm — the goldens are byte-identical to the CpuBackend/Metal
captures:
13 of 14 on the first run — 14 of 14 once the one failure was fixed. The
cross-backend equivalence the goldens assert (CpuBackend == Metal == CUDA ==
HIP) had never been tested against ROCm. It holds, and finding where it didn’t
was worth the whole exercise.
Memory access fault by GPU node-1 on address 0x7501bce00000.
Reason: Page not present or supervisor privilege.
multi_mem_split is multi_mem with --level-split 10. The non-split
fixture passed on the same hardware and the same binary; only the staged path
faulted.
The cause was ours, in simulate_block_v1’s staged-IO read. Bit 31 of a word
index flags “read this cycle’s inter-stage intermediates from the output slot”
(§5 of Decision 0015). The kernel decoded that flag by biasing the base pointer to
cancel it out of the subscript — with a signed1 << 31, which is
INT_MIN. So the pointer moved *+*2^31 words instead of −2^31, and the
subsequent [idx] (bit 31 set, so idx ≥ 2^31) added another 2^31: every staged
read landed 2^32 words — 16 GiB — past the buffer. Unreachable without
--level-split, since staged_io_map is empty when the design is one stage,
and unreachable in stage 0, which reads primary inputs and DFFs. Hence:
stage 0 survives, stage 1 faults.
Why only ROCm. The instinct that ROCm is a bounds-checking oracle was
right, but the mechanism is more interesting than “CUDA tolerates an OOB
access”. nvcc narrows the address arithmetic to 32 bits, which truncates the
overflow back to the intended offset — so CUDA and HIP-over-CUDA were silently
correct, not silently wrong. Metal had the same decode written with an
unsigned 1u << 31 and was correct outright. ROCm was the only backend that
computed the address the way the language says to, and it faulted. The bug was
not that ROCm is strict; it was that we relied on a compiler to paper over
undefined behaviour, and one of them declined.
The fix decodes by clearing the flag from the index instead — the idiom
CpuBackend already used (src/sim/cpu_reference.rs) — in all three kernels.
That removes the UB rather than correcting its sign, so no backend’s address
arithmetic can resurrect the class. multi_mem_split now passes on ROCm
byte-identically to the golden, and Metal is re-verified 14/14 locally.
Worth noting where it sat: level-split + SRAM is exactly where #186 lived (a
staged endpoint index read against the original AIG’s accounting). Two staged-
index bugs in the same neighbourhood, the second only visible on a runtime that
checks. Both were real bugs in Jacquard, surfaced by ROCm, not ROCm quirks.
Compile-only test.Done — see above. It answered a bigger question
than it asked. Fix the types.hpp CUDA-header leak in vendored ulib, then
re-run; only then is “does it build for laptop archs” a meaningful question.
Run the goldens on our own AMD runner.Done — 14/14. The first run
was 13/14; the one failure became #203 and is fixed (a signed 1 << 31
put every staged-IO read 2^32 words out of bounds; see above). Traced with
AMD_SERIALIZE_KERNEL=3 to the exact kernel and dispatch — rocgdb hangs,
that route is the one that works. The goldens are now guarded permanently by
the HIP Tests (ROCm backend) job in ci.yml, which is worth keeping for
its own sake: ROCm is the only backend that computes addresses exactly
rather than narrowing them to 32 bits, so it is our only standing oracle for
OOB/UB in the shared kernel.
Then find real silicon. Compiling is necessary, not sufficient. The
cross-backend goldens make the check a byte-diff, not a judgement call. Our
runner is APU-class (gfx1036, spoofed to gfx1030), so it stands in better
than first thought — but it is RDNA2 Raphael, not RDNA3/3.5 Phoenix or Strix,
so it still cannot answer for gfx1103/gfx1150/gfx1151.
scripts/amd-laptop-probe.sh is a self-contained volunteer test: it compiles
a ~40-line HIP program using exactly Jacquard’s kernel surface (wave32 +
__shfl_down_sync + __syncthreads, nothing else), runs it, cross-compiles
for each laptop arch, and prints a pasteable report. No Jacquard build, no
root, nothing installed.
Only if 1 or 2 fails, revisit portable compute — and then Vulkan, not
OpenCL: it’s what actually works on these parts today per the evidence above,
it isn’t deprecated, and the runner’s vulkan/cubecl labels suggest prior
thought. CubeCL being Rust-native likely beats hand-written OpenCL C as a
third kernel.
Fix the runner label.Void — the label was right all along. It says
gfx1036, the hardware isgfx1036, and the gfx1030 report is an
HSA_OVERRIDE_GFX_VERSION=10.3.0 spoof baked into the amd-runner image
(see “Why this is a question at all”). Nothing to fix. If anything is worth
changing it is the override, not the label — though it is what makes an
unsupported-tier APU work at all, so leave it be.
Ship the cooperative-launch check in scripts/amd-laptop-probe.sh.Done — the probe now reports PROBE_COOP_VERDICT alongside the wave32 /
shuffle result. Whether the laptop archs support cooperative launch remains
unknown and unknowable to us (we have measured exactly one AMD GPU, and it
cannot), but a volunteer’s report now answers it. sim works either way; the
answer only decides fast path vs fallback.
Still open: whether sim (not just cosim) matters on a laptop; if cosim-only is
acceptable the problem shrinks either way.
Until this was fixed, sim had never run on ROCm. It died in
csrc/kernel_v1.hip.cpp at the hipLaunchCooperativeKernel call with
unspecified launch failure, on the simplest design that exists — 1 block,
1 stage, 6 cycles. Cosim was unaffected: it is reactive, so it already drives
one ordinary launch per scheduler edge and never needs a device-wide barrier.
The device simply does not support the mechanism, and says so:
A trivialgrid.sync kernel fails at 1 block while a plain launch of the same
shape succeeds. It is not our kernel, our launch config, or occupancy.
It is also not the spoof. The obvious suspicion — that cooperativeLaunch=0
is an artefact of running gfx1030 code on gfx1036 silicon — was tested directly,
compiling the probe with --offload-arch=gfx1036 and running it with
env -u HSA_OVERRIDE_GFX_VERSION. The result is identical to the spoofed
run, field for field. The limitation is the hardware’s.
Do not generalise this to “RDNA2 can’t do cooperative launch.” We have
measured one integrated 2-CU Raphael APU. A discrete gfx1030 may well report
1. That is what the volunteer probe (next step 5) is for.
This sharpens — but does not overturn — the stay-on-HIP conclusion. The argument
against porting leaned partly on “cosim-only is the realistic scope, sim stays
on CUDA/HIP”, and on APU-class AMD we were briefly cosim-only in fact, which is
the exact limitation that argument used to dismiss portable compute.
Not a port — a host loop over cycles × stages where each launch is the barrier,
which is precisely what Metal (no device-wide barrier either) has always done
and what cosim already did. csrc/kernel_v1.hip.cpp now queries
hipDeviceAttributeCooperativeLaunch and picks:
supported → one hipLaunchCooperativeKernel for the whole run, unchanged;
not supported → num_cycles × num_major_stages ordinary launches of
simulate_v1_stage.
The enabling move was generalising cosim’s stage kernel into one
simulate_v1_stage parameterised by current_cycle, matching Metal’s
long-standing shape. Cosim passes current_cycle = 0, so its behaviour is
unchanged by construction; sim’s fallback passes the real cycle index.
Verified on the runner: sim matches the CpuBackend reference
(--check-with-cpu) on a 6-cycle single-stage design and on mcu_soc
(--level-split 10, 13 cycles × 2 major stages — i.e. the staged-IO path with
both loops non-trivial), and the full gpu_test_suite.sh — sim, X-prop, the
timed launcher + report, and all 14 cosim goldens — passes on ROCm.
Cost: a launch per stage rather than one per run. HIP Tests (ROCm backend) is
the only CI job that exercises this path, since CUDA and HIP-over-CUDA both
support cooperative launch.
Let a separate process read a bundle of chip output pins in near-real-time and render them live — the first use being a debug video view of the C64’s 4-bit colorIndex + hsync/vsync pads. The tap is observe-only: it samples design outputs and streams them out a socket. It never drives design inputs, so it stays out of the cycle-accurate reactive path.
This came out of a wider question about a “generic GPIO model” for cosim. The two concrete C64 cases split cleanly: video (this spike) is pure output observation, while the CIA1 keyboard/joystick GPIO is the bidirectional tri-state case and is tracked separately (see “Relationship to the GPIO work” below).
The original idea was to reuse QEMU’s virtio-gpio + vhost-user backend. Rejected for this use, for the record so it isn’t re-litigated:
virtio-gpio and vhost-user are untimed and asynchronous (“set line 5 high, eventually”). Cosim is cycle-accurate per clock edge. A live raster needs per-pixel samples in order, which the virtio model throws away.
virtio-gpio is a logical line-command protocol (get/set value, direction, IRQ). Video out is a wire-level bit bundle sampled at pixel rate. The impedance mismatch is high.
vhost-user carries real weight (shared-memory negotiation, virtqueue rings, feature negotiation) to expose a semantically poor interface.
virtio-gpio remains the right tool for a different goal — running unmodified guest software against the RTL’s GPIO under QEMU — but that is not what a debug video view needs.
The hard part is already solved. When any VCD output is active, cosim enables a device-resident per-edge snapshot ring (vcd_ring_dev), blits one full [input_state | output_state] snapshot per edge on the GPU, and reads the ring back D2H after each batch. That gives correct per-tick values without forcing batch=1. The tap reuses this ring; it adds no GPU code.
A tap is a second sink alongside the output-VCD writer in the per-edge drain loop. For each edge in the batch it extracts the bundle’s bits from the snapshot’s output_state, and (subject to the trigger below) packs one sample and appends it to a per-batch buffer. After the edge loop, the buffer is written to the socket in one non-blocking write.
Transport is a UNIX-domain socket. Cosim listens; the renderer connects and disconnects freely. If no client is attached, or the socket would block, cosim drops the batch’s samples. Stalling the simulation on a slow renderer is the wrong tradeoff for a debug tool.
Because the tap drives nothing, it does not set is_active() and does not force single-edge batches. Throughput is unaffected; end-to-end latency is one batch (BATCH_SIZE = 1024 scheduler edges by default). Lower latency is a smaller batch, at throughput cost — a knob, not a redesign.
One byte per sample for the video bundle: [ vsync<<5 | hsync<<4 | colorIndex[3:0] ]. The stream is contiguous per-tick samples; the renderer reframes from hsync/vsync edges, so no timestamps are needed in the common case. Each batch write is optionally prefixed with a u64 tick base so the renderer can resync after a drop. Given drop-on-backpressure, the resync prefix is worth having.
The format generalises: a bundle is an ordered list of signal names plus a socket path, packed LSB-first into as many bytes as the bit count needs. Video is the first consumer; the same sink can later stream CIA ports, a bus address, or anything into Surfer or another live viewer.
colorIndex only changes at the ~8 MHz dot rate, so sampling every scheduler edge oversamples and floods the socket with duplicate bytes. A trigger controls when a sample is emitted. Three modes, one field:
strobe: "<net>" — emit on the rising edge of a design net (a pixel clock-enable). The design itself says when a pixel is ready, so this is correct regardless of clock topology. Recommended.
clock: "<name>", divider: N — emit every Nth edge of a named scheduler clock domain. Correct under multiple clock domains because it is domain-relative.
every: N — emit every Nth scheduler edge. Simplest; correct when there is a single master clock.
Default is every: 1.
The multi-clock subtlety that makes this worth writing down: a scheduler “edge” is one entry in the interleaved MultiClockScheduler schedule, and consecutive edges can belong to different domains. So “every Nth edge” drifts against any one clock the moment there is more than one domain. Both strobe and clock/divider avoid this by binding to something the scheduler tracks per edge. The drain loop already reads exactly this — scheduler.schedule[sched_idx].domain_edges[di] tells it which domains fired at each edge (see the jitter code at mod.rs:3445-3461). Strobe mode goes one better: it binds to a net the design toggles, so tick topology never enters the reasoning.
The C64 core is a single-master-clock design with clock enables, MiSTer style. clk32 (32 MHz, top-level clk_PAD) is the only clock. enablePixel is a one-cycle strobe generated in a rising_edge(clk32) process, asserted on eight evenly spaced sysCycle states — every 4th clk32 cycle, so a regular 8 MHz dot strobe (rtl/c64_system.vhd:224-240). The multi-clock worry does not apply here; both strobe and every: 4 would work, and strobe needs no tuning.
Names survive the LibreLane flow in escaped, lowercased, hierarchical form. Confirmed present in the post-PnR netlist (librelane/runs/.../chip_top.pnl.v):
Purpose
Net in post-PnR netlist
Source
Pixel strobe (trigger)
\i_chip_core.c64_u.enablepixel
c64_system.vhd:110,228-238
colorIndex[3:0] pad value
\bidir_CORE2PAD[1..4]
chip_core.sv pad map
hsync pad value
\bidir_CORE2PAD[5]
chip_core.sv:63PAD_VIC_HSYNC=5
vsync pad value
\bidir_CORE2PAD[6]
chip_core.sv:64PAD_VIC_VSYNC=6
per-pad output-enable (Case 2)
\bidir_CORE2PAD_OE[n]
pad ring
\bidir_CORE2PAD[n] is the core-driven side of bidir pad n, which is what we want for the design’s output value. The --trace-signals multi-candidate resolver (src/sim/trace_signals.rs:320resolve_to_state_pos) is built for escaped/flattened/hierarchical names, so passing enablePixel or the full hierarchical path should resolve; confirm each with netlist-graph search before wiring.
The renderer is a separate process (Python + an SDL-ish surface, or a small Rust app). It reads the byte stream, frames the raster from hsync/vsync edges, applies the C64 16-colour palette (fpga64_rgbcolor.vhd exists in the RTL as the reference LUT but is not instantiated on-die), and blits live. All video semantics live in the renderer; cosim only forwards bits.
Per-edge drain loop: mod.rs:3414-3532. backend.vcd_snapshot(edge_in_batch) returns [input_state | output_state]; a bit is (output_state[pos>>5] >> (pos&31)) & 1. The tap slots in beside the output-VCD writer at mod.rs:3438.
Output-VCD state / signal→position mapping is built at mod.rs:2958-2991 via vcd_io::setup_cosim_output_vcd → OutputVCDMapping.out2vcd. The tap’s bundle resolution mirrors this, using trace_signals::resolve_to_state_pos.
The snapshot ring is gated by vcd_enabled (mod.rs:2998) / enable_vcd_ring on the CosimBackend trait. The tap must ensure the ring is enabled even when no output VCD path is set.
Config surface: extend TestbenchConfig (src/testbench.rs) with a video_taps / signal_streams vector, following the plural-peripheral convention (Decision 0013). A CLI --video-stream shorthand is optional sugar.
Rough size: about 40 lines of Rust for the sink plus config plumbing, and about 60 lines for a minimal renderer.
The tap is implemented in-tree (src/sim/cosim/signal_stream.rs, config in
src/testbench.rs). The C64-specific renderer and config fragment live under
video-streaming-tap/ next to this doc — a uv-runnable pygame renderer and
the signal_streams block to merge into the C64 cocotb/sim_config.json. They
are C64-specific and will move to the c64-tapeout project; they sit here so the
spike runs end-to-end. See video-streaming-tap/README.md.
Run it end-to-end: a real cosim run streaming to the renderer, confirming the C64 net names resolve. The tap and renderer exist; this is the remaining validation.
Confirm colorIndex bit order at the pad — whether \bidir_CORE2PAD[1] is colorIndex[0] or [3] — against the out_drive assignment in chip_core.sv. Cheap to check; wrong order just permutes the palette.
Backpressure policy: drop whole batches vs a bounded ring with newest-wins. Start with drop-whole-batch; revisit if the picture tears badly.
Whether to land the generic “signal-bundle stream” surface now or start video-specific and generalise once a second consumer appears. Leaning generic, since the CIA case will want it.
This spike is Case 1 (output observation) of the broader generic-GPIO investigation. Case 2 — the CIA1 PA/PB keyboard-matrix and joystick GPIO — is the bidirectional tri-state case and is separate work: it drives inputs reactively, needs is_active()/batch=1, and needs open-drain + pull-up wired-AND resolution. This spike incidentally found the net Case 2 will need: per-pad output-enable is \bidir_CORE2PAD_OE[n] at the synthesized top, which answers “how is the CIA tri-state $oe expressed” for the GpioMapping plumbing when that work starts.
“ADR” is jargon. The phrase “Architecture Decision Record” doesn’t tell a
newcomer what’s inside, and enough people bounce off the term that it’s worth
dropping.
An ADR conflates three kinds of claim that age at different
rates: why a decision
was made, what the code does now, and what’s decided but unbuilt. Bundling them
in one document is what makes ADRs go stale. The why is historical and should
never be rewritten; the what has to track the code; the unbuilt part shrinks
as things land. One document can’t age all three well.
There’s no by-area entry point. The ADRs are a numbered pile. A reader who wants
“how does cosim I/O work today” has to know it’s spread across 0013, 0017 and
0022 and reconstruct it.
Split the pile along the seam that already exists in the three-buckets model:
what the code is now, versus why it got that way.
Architecture reference — the what. One document per area, describing the
current design as it stands in the code. This is the top-level entry point,
organised by area, and the thing a reader lands on first. It’s living: kept in
step with the code, rewritten in place when the code moves.
Decision log — the why. The rationale and the alternatives rejected,
each entry tied to the moment it was decided. This is what the ADRs already
are; they become the decision log, reframed. Historical by nature, written in
past tense, not rewritten when the code changes.
Status — the unbuilt. The gap between a decision and the code. Lives in
an Implementation status section on the architecture doc (and a plans/
entry for the work), and drains into the what as each piece lands.
Spikes stay, as the evidence. A spike is neither a what nor a why; it’s
the experiment a decision rested on. They keep their own home and get referenced
from the decisions they informed, the way ADR
0003 already cites the
OpenTimer spike that killed it. A decision cites its
evidence; the spike itself is a dated record and doesn’t get rewritten.
The link runs both ways. Each architecture section points to the decisions that
shaped it, so a reader on the current shape can reach the reasoning in one hop.
Each decision points forward to the architecture doc it produced, so a reader in
the log can see what became of it, and back to the spikes it rested on. The
whats point to the whys, and the whys point to their evidence.
This is deliberately less destructive than renaming every ADR into an area
document. The decision records keep their numbers and their content; they move
under a “decision log” heading and get forward-links. The new writing is the
architecture layer on top.
Diátaxis gives this split its
name and its rule. Its reference type (what you consult while working:
factual, present-tense, structured for lookup) is the architecture layer; its
explanation type (background, the why, written for study) is the decision log.
Diátaxis names our exact failure mode too: explanation bleeds into reference,
dragging time-bound claims into a doc that’s supposed to state what is. The rule
that follows is the whole anti-staleness law: no rationale, and no dead history,
in a reference doc.
The ADR README currently says ADRs are living documents,
edited in place to stay current. That rule exists only because the ADR is the
sole doc, so it has to carry current state, so it has to be edited when the code
moves. Add a reference layer that carries current state, and the pressure to
edit the decision record vanishes.
So the redesign flips the mutability rules by layer, and the flip is the point:
The architecture reference is living and present-tense, edited in place.
The “keep it current” rule moves here, where it belongs.
The decision log goes back to immutable and past-tense: a decision is a
record of a choice at a moment, not a description of the system now. A changed
decision is a new record that supersedes the old, not a rewrite. This is the
Nygard model, and it’s what stops a decision record from rotting: history
can’t go stale.
This does partly reverse the living-ADR convention I committed earlier, and
that’s worth saying plainly rather than quietly. It doesn’t bring back the thing
we actually disliked, dated “Amendment” blocks accreting inside one document,
which both models reject. It changes only what happens when a decision itself
changes: edit the reference doc to match the code, and write a new decision
record for the changed why.
arch-3-cosim-runtime.md — an architecture-reference doc.
dec-0022-flow-controlled-io.md — a decision-log entry (was Decision 0022).
The number is a stable ID, not a reading order. Decision entries keep their
existing four-digit ADR numbers, so every “Decision 0022” reference already in the
docs, commits and issues still resolves. Architecture docs get their own short
sequence per the taxonomy below.
The filename is for identity and stable links. The title a reader sees is set
by the SUMMARY.md link text and the document’s # H1, both ours to choose, so
the rendered nav can read “Cosim runtime” while the file stays
arch-3-cosim-runtime.md.
“Architecture” is the top-level home, chosen over “ADR” precisely because the
term is opaque to newcomers. Everything hangs under it: the map first, then the
current-state docs, then the decision log, with spikes nested under the log as
the evidence decisions rested on.
docs/architecture/
README.md # the map — by-area index, the first thing a reader hits
arch-1-timing-correctness.md # current state, the "what", one per area
arch-3-cosim-runtime.md
...
decisions/ # the decision log — the "why" (today's ADRs, reframed)
README.md # decision index (today's adr/README.md)
dec-0013-...md
dec-0022-...md
spikes/ # the evidence a decision rested on
spike-amd-laptop-backend.md
spike-architecture-doc-redesign.md
archive/ # reversed/dropped decisions (today's adr/archive/)
Rendered nav (SUMMARY.md) mirrors it, with human titles:
Two placement calls in this tree are worth stating rather than defaulting.
Spikes live under decisions/ because a spike is the experiment a decision
rested on, so it belongs beside the decision that cites it, not floating at the
top level as today. The decision log is a subdirectory of architecture/, not
a sibling, so “Architecture” is genuinely the single top-level home and the why
sits one level below the what it explains. Both are reversible; neither blocks
the taxonomy work.
Two placements are genuinely ambiguous and worth deciding rather than guessing.
Cell metadata (0010, 0011) is written by PDK enablement but read by timing, so
it could sit in either. The X-propagation work (0016) spans the engine and
cosim. Overlaps like these are the real cost of an area split, and where a
framework’s guidance (below) should earn its keep.
arch-3-cosim-runtime.md would open on the current design and carry the
cross-links:
# Cosim runtime
Cosim runs reactive peripheral models as GPU kernels alongside the design, so
inputs can depend on design outputs cycle by cycle. [What the code does today,
in the present tense: the batch dispatch loop, the multi-clock scheduler, the
peripheral model trait, the GPU→CPU ring buffers.]
The batch is the core constraint: `BATCH_SIZE` scheduler edges run with the CPU
absent. See [dec-0017](...) for why the loop is shaped this way, and
[dec-0013](...) for the peripheral model architecture the rings come from.
## External I/O
[Current state once built. Today this links to dec-0022 as decided-but-unbuilt.]
## Implementation status
- Batch dispatch, multi-clock scheduler, peripheral rings — built
(dec-0013, dec-0017).
- Flow-controlled I/O across the batch boundary — decided, unbuilt
(dec-0022, plan: ...).
The decision entries lose nothing. dec-0022 keeps the full rationale it has
now and gains one forward-link: “current state: arch-3, External I/O.” This area
is the sharp test because it exercises all three buckets at once — built
peripherals, a proposed I/O design, and the why behind both.
No modelling tool. Structurizr, C4-as-code and the like earn their keep when one
model feeds dozens of views that must stay mutually consistent as it changes,
which is a system-of-services problem. Jacquard is one binary with a compute
pipeline; the model fits in a doc, so a modelling tool buys single-source views
we don’t need at the cost of a DSL and a build step. Same reasoning as rejecting
arc42’s full apparatus.
The firm rule is diagrams as text, never checked-in images. A text diagram
diffs in a PR, lives next to the prose, and is readable by an agent; a PNG rots
invisibly, hides what changed from a reviewer, and is an opaque blob to a model.
Keep them sparse and follow C4’s discipline: one abstraction level per diagram,
and the diagram shows what, never why. A straight pipeline stays as an ASCII
block (zero-dependency, reads fine); reach for a rendered diagram only where the
shape is genuinely two-dimensional (the backend/peripheral/ring relationships,
the three-tier model).
The format choice is D2, decided by rendering the same real diagram (the
cosim batch boundary) both ways and comparing. Both are declarative text, so the
AI reader is indifferent; the choice turned on layout quality and the render
path.
D2 kept a nested node inside its container box, coloured the three
containers cleanly, and routed the boundary-crossing edges with few crossings.
It needs the d2 binary wherever docs build (mdbook-d2 shells out to it),
and GitHub won’t render it inline in a PR.
Mermaid floated an unconnected node far from its group, its subgraph
colouring didn’t take, and the layout was more cramped on the same graph.
It renders client-side with no binary and inline on GitHub.
The one cost of D2, no inline GitHub render, is minor here: the canonical
rendered form is the mdBook site on GitHub Pages, not GitHub’s Markdown preview,
and both render there. The layout gap is real and lands exactly on the 2-D shapes
that are the only diagrams we’ll draw. mdbook-d2 is wired into book.toml (with
layout = "elk", which beat dagre on the container shapes) and the docs CI job;
theme/d2-diagrams.css strips the code-block chrome the preprocessor wraps each
SVG in. The worked cosim doc carries the first diagram as a live test of the path.
Two techniques from getting the first diagram to look right, both reusable:
Theme-matching. D2 bakes one palette into the SVG, so a light diagram lands on
a navy page (mdBook’s default dark theme). theme/d2-diagrams.css repaints every
shape from the active theme’s CSS variables (--fg, --bg) — author CSS
overrides D2’s baked fill=/stroke= — so the diagram follows a live theme
switch. Three gotchas worth knowing before the next diagram: D2 sets shape colour
via an embedded .d2-<hash> .stroke-B1 rule whose specificity (0,2,0) beats
element selectors, so shape colours need !important; the connection <mask>
contains unclassed <rect>s, so a bare rect fill rule corrupts the mask and
clips every line — scope to rect[class]; and per-region accent colour needs a
hook D2 doesn’t otherwise give, so the three regions carry a sentinel fill in
the source that CSS remaps to a translucent (hence theme-adaptive) tint.
Verifying style without a live browser. The diagram is an SVG styled by page
CSS, so a static D2 render can’t show the real result — render the built page in
headless Chrome instead (--headless --screenshot, or --dump-dom after an
injected getComputedStyle probe to read the actual applied stroke/fill). Seed
localStorage['mdbook-theme'] before the page’s boot script to force a theme.
This caught the invisible-lines bug that a D2 PNG render hid completely.
Legibility has one non-obvious lever: on-screen text size is the native font
scaled by column_width / diagram_native_width, because the SVG is fit to the
content column. A wide diagram shrinks its text into the floor. Bumping the D2
font doesn’t help (boxes grow with it, so the ratio is unchanged) — the fix is to
keep the diagram narrow, laying regions out vertically and reserving horizontal
flow for genuine pipelines. The cosim diagram at ~1177px native keeps text near
two-thirds of body size in the column; a wide left-to-right version of the same
graph dropped it to a quarter. Prefer tall over wide: vertical scroll is natural
on the web, horizontal isn’t.
A survey of the usual frameworks (sources at the end) mostly confirms the
split and lends a few concrete shapes. The one-line reading of each:
ADRs (Nygard). The five-field core (title, status, context, decision,
consequences) is all a decision record needs; MADR’s option-by-option
apparatus is worth it only for a genuinely contested call. The staleness
everyone complains about is a category error: an ADR is point-in-time history,
and history doesn’t rot if you write it in past tense and supersede rather than
edit. Link each record to the PR that implemented it. Take the core, the
immutability, and the PR link.
Diátaxis. The reference-versus-explanation cut is the theoretical spine of
this whole redesign, as above. Take it as the organising rule.
arc42. Don’t adopt the 12 sections. Five earn their keep as the skeleton
for each architecture-reference doc: a building-block view (the pipeline, one
level deep), crosscutting concepts (the things that span modules: multi-backend
kernel parity, AIG invariants, the partition resource limits), constraints
(synchronous logic only), risks and tech debt, and a glossary. The glossary
matters more than usual here because the domain is jargon-dense (AIG, boomerang
stage, repcut, endpoint group) and an LLM reader hallucinates undefined terms.
Skip runtime, deployment, and formal quality-scenario sections.
C4. Not as a framework. Two habits only: one abstraction level per diagram
(a single Level-1 pipeline diagram, then per-stage zoom-ins, never one
god-diagram), and diagrams show what, never why.
RFCs / design docs (Rust model). This is the home for decided-but-unbuilt:
an accepted-but-unimplemented proposal plus a tracking issue is exactly bucket
three, and the tracking link is what makes it self-liquidating as work lands.
We already have docs/plans/ reaching for this; formalise the link, skip the
process ceremony (no voting, no FCP).
ISO 42010. Ignore the standard. Steal one idea: every view exists to answer
a named reader’s concern, and one of our readers is now an LLM coding agent.
The AI-reader findings are the part with the least settled evidence and the most
direct payoff, so they’re worth stating as rules rather than aspirations:
Self-contained sections. An agent retrieves a chunk, not the tree. Each
section should stand alone: restate its subject, avoid “as described above.”
This is the same discipline as Diátaxis reference writing.
Stable-ID cross-links, never prose pointers. “See dec-0021” resolves; “see
the synthesis decision” doesn’t. This is why the decision numbers stay stable.
No stale present-tense claims in a reference doc. A human skims past a wrong
claim; a model repeats it as generated code. That raises the cost of the
staleness failure and is the strongest reason to keep the reference layer
ruthlessly current and rationale-free.
Curate, don’t generate. One measured study
(arXiv 2601.20404) found LLM-written
context files reduced task success in most settings. Write only what an agent
can’t get by reading the code.
A one-screen map. A docs/architecture/README.md an agent hits first,
linking every area doc and its decisions by stable ID.
llms.txt is the one piece to skip unless we publish a docs site; it solves
HTML-ingestion, which an in-repo Markdown reader doesn’t have. The cross-tool
AGENTS.md convention (shared agent context, tool-specifics left in CLAUDE.md)
is worth adopting on its own track, separate from this.
On naming, the field backs the scheme already chosen, with one refinement:
encode the class as a directory, not a filename prefix. docs/architecture/,
docs/architecture/decisions/ and docs/plans/ carry the class; the file inside is just
NNNN-kebab-title.md. Cite by number, keep the number out of the H1 title, and
assign the number at merge to dodge cross-PR collisions.
Phased implementation plans with entry and exit criteria. Plans live
here when the work spans multiple commits and needs an explicit
scheduling artefact. Once shipped, the plan moves to archive/ as a
historical record (Status flipped to Implemented) rather than being
deleted, so the phasing is recoverable later.
For short-lived working memory between sessions, see
../handoff-discipline.md — that lives
in docs/handoffs/ and is deliberately kept separate from the
persistent plans here.
Shipped plans move to archive/ so this directory lists only live
work. They stay in the repo because decision records cite them as the record of
how each decision was carried out.
Filename: short kebab-case (<topic>.md or
<ws-or-phase>-<topic>.md).
Start with # Plan — <title> and a **Status:** line.
Where the plan executes a specific decision or work stream, name them
in a **Predecessors:** / **decisions:** block near the top so the
dependency graph is explicit.
Add the row to the table above.
When the plan ships, flip its status, move its row to the Archive
table, and move the file with the doc-move tool, all in one commit:
The tool git mvs the plan into archive/ and repoints every link and
docs/plans/ path in the repo, including source comments. Move its
SUMMARY.md entry under “Completed plans” by hand, then run
python3 docs/scripts/check_doc_links.py, which fails if you forget.
Status: Active. Phase 1 (structured timing output, Decision 0008 — Accepted 2026-06-25) and release hardening (WS-RH.1) shipped; Pillar B Stages 1+2 (Decision 0007) landed early. Phase 2 (Pillar C Tier 1 — per-receiver wire delay) remains gated on Decision 0007 acceptance, which is still Proposed as of 2026-06-26.
This document orders the work captured in those two decisions alongside the in-flight tail of Phase 0. It is a scheduling doc, not a design doc — design lives in the decisions and in docs/timing-model-extensions.md / docs/why-jacquard.md.
Phase 0 (phase-0-ir-and-oracle.md): WS1–WS5 + WS2.2 + WS2.4 all landed. WS2.4 multi-corner shipped 2026-05-02 across four commits (5822343 consumer, 530bb36 builder, 59fde04 producer, plus the integration test). Open items: sky130-based corpus entries (gated on a CI sky130-Liberty install strategy) and peripheral wiring for I²C/SPI when a fuller mcu_soc fixture lands.
OpenTimer spike (../architecture/decisions/spikes/opentimer-sky130.md): resolved 2026-05-01 — Superseded. Q1 (Liberty parse) passed cleanly on SKY130; Q2 (arrival computation) failed on the canonical OpenSTA-bundled GCD example after eight input-pipeline workarounds (bus ports, OpenROAD-emitted SPEF, modern TCL, tap cells). Per the spike’s decision matrix, Decision 0003 is now Superseded (commit d002bde). OpenSTA out-of-process is committed as Jacquard’s sole STA path — opensta-to-ir is the canonical preprocessor; no in-process reference STA is planned. A future decision may revisit libreda-sta or an in-house walker if an in-process reference is wanted later, but not on this roadmap.
Pillar B Stages 1+2 (per Decision 0007): landed.ClockArrival IR table + opensta-to-ir Tcl emission in commit c403cc8; DFFConstraint.clock_arrival_ps + skew-aware fold-in in build_timing_constraint_buffer in 6767c3e. Closed Pillar B’s main accuracy lever ahead of this roadmap’s original Phase 2 schedule.
Decision 0006 amended 2026-05-02: subprocess invocation of user-installed OpenSTA from the shipped runtime is now permitted (no linking, no bundling). Phase 3 (native Rust SDF→IR) is no longer release-gating — see § Phase 3 below. New release-hardening workstream WS-RH.1 (OpenSTA detection + version check) is required before first release; see § Release hardening.
Phase 0 exit criteria met (per phase-0-ir-and-oracle.md).
OpenTimer integration was originally Phase 1’s centrepiece (former WS-P1.1) but was retired when the spike Superseded Decision 0003. With OpenSTA-out-of-process as the sole STA path, Phase 1 is now anchored on user-visible output rather than a second STA tool.
The four required items from Decision 0008. Single workstream because they share infrastructure.
WS-P1.1.a — Symbolic violation messages.Shipped 2026-05-02 in commit 0432d9a. New WordSymbolMap in src/flatten.rs built once at sim startup; process_events gained an optional resolver closure; sim_metal threads it through. Setup/hold violation messages now name DFFs as top/cpu/regs[7][bit 22] [word=42] instead of bare word 42. CUDA/HIP sim paths don’t currently route runtime violations through process_events (separate plumbing gap, not blocked on this format change).
WS-P1.1.b — --timing-report <path.json>.Shipped 2026-05-02 in commit 58a7a04. New src/timing_report.rs module with serde-derived TimingReport (schema_version 1.0.0); process_events takes a ReportingCtx bundling the optional resolver + violation observer (signature back to 5 args); sim_metal builds the report end-to-end. Sample fixture at tests/timing_ir/sample_reports/two_violations.json; schema documented in docs/timing-violations.md. WS-P1.1.d’s worst-slack ranking is included (top-10 per kind from violation events). Caveats: closest-to-violation tracking in non-violating runs needs GPU near-miss instrumentation (deferred); violations array is unbounded (opt-in cap is the natural follow-up); CUDA/HIP/cosim paths don’t route runtime violations through process_events yet.
WS-P1.1.c — --timing-summary text output.Shipped 2026-05-02 in commit 44e70a0. New TimingReport::format_summary() formatter; --timing-summary CLI flag; TimingReportConfig refactored to support either / both / neither output. Text writes to stdout. Deferred from Decision 0008’s wishlist: “corner” (metadata struct doesn’t carry it yet) and “margin percentage” (derivable from existing fields). Both are documented in code as known gaps.
WS-P1.1.d — Per-DFF worst-slack ranking. Partially shipped in 58a7a04 alongside WS-P1.1.b: top-10 per kind from observed violation events. Remaining: closest-to-violation tracking when no violation occurred — needs GPU near-miss instrumentation, deferred to a separate workstream.
Tail of Phase 0 work that didn’t gate WS3 completion. Listed for completeness.
WS2.4: multi-corner CLI flag in opensta-to-ir.Shipped 2026-05-02 (commits 5822343 / 530bb36 / 59fde04).
WS4: corpus + runner + regen helper + CI hookup shipped 2026-05-02 with the seed entry aigpdk_dff_chain (covers all four IR record types). One follow-up: add sky130-based corpus entries (inv_chain_pnr, mcu_soc subset) once a CI sky130-Liberty install strategy is decided.
Peripheral wiring for I²C/SPI when a fuller mcu_soc fixture lands.
(WS5 — parser-success assertions on the Liberty parser path and on opensta-to-ir — was already shipped; see phase-0-ir-and-oracle.md § WS5.)
These are not gated by any new decision; pick them up as bandwidth allows.
Exit criteria for Phase 1:
✅ Symbolic violation messages live; old state-word-index format gone (commit 0432d9a).
✅ --timing-report JSON shipping; sample fixture at tests/timing_ir/sample_reports/two_violations.json (commit 58a7a04).
✅ --timing-summary available (commit 44e70a0).
✅ Worst-slack ranking included in both report and summary (top-10 from violations; non-violating-run tracking still requires GPU near-miss instrumentation, separate workstream).
✅ why-jacquard.md updated; old “Output interface” section now describes the shipped surface, “Still on the wishlist” carries the deferred items.
Phase 1 closed. Phase 2 entry now blocked only on Decision 0007 acceptance.
Pillar B Stages 1 and 2 (per-DFF clock arrival in the IR + setup/hold fold-in) landed early, in commits c403cc8 and 6767c3e — directly on top of the OpenSTA-out-of-process producer rather than the OpenTimer integration originally planned. Phase 2 is therefore anchored on Pillar C Tier 1 (per-receiver wire delay), with Pillar B Stage 3 only if measurement justifies it.
WS-P2.1.a — Edge-attributed wire delay. Rewrite of src/flatten.rs:1850-1872 to key wire delay per fanout; fold into source-side gate_delay per fanout target. ~3–5 days.
WS-P2.1.b — Rise/fall preservation. Carry per-edge rise/fall through the consumer; honour both in PackedDelay accumulation. ~1–2 days, after WS-P2.1.a.
WS-P2.1.c — Validation. Long-route corpus addition; tolerance ≤±3% on long-wire paths.
Stages 1+2 collapsed all DFFs in a 32-bit state word to min(setup), min(hold) after folding the per-DFF clock arrival in. For most current designs the per-word collapse pessimism is small relative to clock period; for designs running close to the period boundary, splitting each word into clock-arrival buckets eliminates the collapse loss without disturbing the partitioner. See Stage 3 in docs/timing-model-extensions.md Part B.
Land only if Stage 1+2 measurement on a representative design shows the per-word collapse materially over-reports violations; otherwise defer indefinitely. Effort if pursued: ~3–5 days, touches src/flatten.rs:1722-1761 and the kernel’s constraint indexing.
Small touch-ups to ensure Phase 1 outputs continue to work as model fidelity changes. JSON report fields, summary metrics, etc. Folded into WS-P2.1 / WS-P2.2 PRs as needed.
Exit criteria for Phase 2:
Per-receiver wire delay landed; long-route paths reported within ≤±3% of CVC.
timing-model-extensions.md Parts B and C marked Implemented with cross-references to landed code (Part B already updated post-Stage-1+2).
timing-validation.md updated with per-pillar tolerances.
Deferred indefinitely as of 2026-05-02 per amended Decision 0006. No longer release-gating: shipped Jacquard binaries may subprocess user-installed OpenSTA via opensta-to-ir, provided OpenSTA is not bundled and not linked. The user-facing capability gap is “OpenSTA must be on PATH for jacquard sim input.sdf,” surfaced by WS-RH.1 below with a clear error message.
Reasons to revive:
A downstream commercial integrator’s legal team rejects subprocess-of-GPL-tool even with no bundling/linking.
OpenSTA dialect coverage gaps appear that are easier to fix in our own parser than via opensta-to-ir post-processing.
Bandwidth opens up and the team wants the zero-runtime-dependency story for its own ergonomics.
Effort estimate (unchanged from the original Decision 0006 framing): grammar-based (nom / pest), validated against OpenSTA on the WS4 corpus per Decision 0001. Probably 2–3 weeks of focused work. Not scheduled.
Pre-first-release work that became necessary when Decision 0006 § Amendment relaxed the no-runtime-subprocess rule. These are blockers for first release, not for any specific Phase.
Status:Shipped 2026-05-02 in commit c9c393b. All scope items below are landed; this entry is preserved as a brief reference. Test coverage: 9 unit tests for the version parser + 6 integration tests for the locator across the missing / too-old / newer-than-tested / unparseable / failing-probe paths.
Why: With the shipped runtime now allowed to subprocess opensta-to-ir, a user invoking jacquard sim input.sdf on a machine without OpenSTA — or with an untested OpenSTA version — must get an actionable error rather than silent timing-data loss. Pre-WS-RH.1, missing OpenSTA only emitted a warn! and the simulation proceeded with no timing information loaded. That was acceptable during development but shipped as a UX bug.
Scope:
Promote missing-OpenSTA from warning to hard error when --sdf is provided. Today’s silent-fallback behaviour is fine for --liberty-only runs but wrong when SDF was explicitly requested. Error message must name the env var (JACQUARD_OPENSTA_BIN), the PATH lookup, and link to install instructions. ~0.5 day.
Pin a tested OpenSTA version range. Record the version we test against in vendor/opensta/ (already pinned via submodule per Decision 0005) and surface that as a MIN_TESTED_OPENSTA_VERSION / MAX_TESTED_OPENSTA_VERSION constant in crates/opensta-to-ir/src/opensta.rs. Need to choose a version-detection mechanism — OpenSTA’s -version flag output format is the obvious target; check whether it’s stable across the versions we care about. ~0.5 day.
Version probe at first invocation. On first call to find_opensta() per process, run <binary> -version, parse the version, and:
If older than min-tested → hard error with remediation message (“rebuild via scripts/build-opensta.sh or upgrade your system OpenSTA”).
If newer than max-tested → warn but proceed (“untested OpenSTA version vN.M; please report any timing discrepancies”).
Cache the result for the rest of the process. ~1 day.
Document the dependency in docs/synthesis-flow.md. Single section: required tooling, install paths, version range, what jacquard sim does and doesn’t need OpenSTA for. ~0.5 day.
Test coverage: unit tests for the version-string parser (with sample -version outputs from the pinned version and a synthetic too-old version); an integration test that points JACQUARD_OPENSTA_BIN at a stub script and confirms the error path. ~0.5 day.
Stale-framing cleanup (folded in here per 2026-05-02 decision rather than spun out separately):
Reword INTERIM per Decision 0006 / Pre-release only markers in source: src/sim/setup.rs:176,228,286, src/bin/jacquard.rs:187, src/sim/cosim_metal.rs:2053, src/testbench.rs:255-257. Replace with “subprocess wrapper per Decision 0006 § Amendment” or similar — these paths are no longer interim.
Update docs/plans/archive/phase-0-ir-and-oracle.md lines 152, 161, 172 — drop “tagged for pre-release removal” framing; the subprocess wrapper is now the shipping mechanism, not a temporary bridge.
Audit docs/plans/archive/ws3-delete-sdf-parser.md for the same stale framing and update.
~0.5 day total for the cleanup.
Total: ~3.5 days. Single PR, owned by whoever picks up release prep.
Open question: does OpenSTA emit a stable -version string, or do we need to scrape git describe from a build-time-recorded commit? If -version is unreliable, fall back to recording the submodule commit at crates/opensta-to-ir build time and comparing — this is cheaper than version-string sniffing and avoids the “user has a custom build” problem.
Items below land when (a) a real use case appears that demands them, and (b) bandwidth is available. Each gets its own decision amendment / new decision before scheduling, since the cost is non-trivial.
Cheapest δ(T) entry point. Lands only after Pillars B and C confirm the wire/skew baseline is correct — characterisation work done before that risks chasing wire-delay error masquerading as δ(T) error.
Effort: 1–2 day spike to validate value, then ~1 week implementation, plus per-cell SPICE characterisation effort (long-pole risk).
Required for many-core/NoC designs at advanced processes. Lands when a representative such design appears in the test corpus and Tier 1 measurement shows it is needed.
Optional optimisation that makes Tier 2 cheap on tile-decomposed designs. Lands only if Tier 2 lands and partitioning quality on tile designs proves measurably suboptimal.
Pillar measurement shows smaller-than-expected gain. Each pillar’s later stages are deferred or abandoned per Decision 0007’s walk-back clause. Pillar B Stage 3 is explicitly conditional on this signal.
OpenSTA upstream regressions. With OpenSTA as the sole STA path, an upstream behaviour change reaches us through opensta-to-ir’s output. Mitigation: pin OpenSTA in CI (per Decision 0001) and rely on the regression corpus to surface drift.
CRPR pessimism on tight designs. Stage 1+2 fold-in treats launch=0; a design with very heterogeneous launch arrivals will see pessimism on paths whose launch DFF also has a long clock path. Stage 3 is the lever if this matters; otherwise live with it.
Status: Phases 0–6 shipped (2026-05-12 / 13). Phase 7
(wafer.space test-run-1 design integration) deferred pending design
availability. Subsequent follow-ups also landed (2026-05-14):
IO pad behavioural decomposition (__in_c, __in_s, __bi_24t,
plus filler classification for the wafer.space gf180mcu_ws_*
families) and bidir A/OE observability surfacing as
<port>__out / <port>__oe extra primary outputs — see commits
aa312b8, c23d583, 207cc80. These extended GF180MCU support
from “synthesized-core-only” to “full chip_top including pad ring”,
validated end-to-end on a 227k-cell wafer.space chess chip_top
netlist. This document is now a recap of what landed; the
forward-looking deferred items are in § Follow-on cleanup at the
bottom.
Predecessors:
SKY130 enablement (reference recipe in docs/adding-a-pdk.md).
Multi-corner Liberty plumbing — WS2.4 + the sky130 multi-corner
integration test (crates/opensta-to-ir/tests/opensta_integration.rs),
shipped 2026-05-12.
Decisions: None new shipped. docs/adding-a-pdk.md is the canonical
integration-points checklist; this plan applied that recipe to
GF180MCU with both 7-track (gf180mcu_fd_sc_mcu7t5v0) and 9-track
(gf180mcu_fd_sc_mcu9t5v0) standard-cell libraries.
GF180MCU is now at the same support tier as SKY130:
Timing path — opensta-to-ir accepts GF180MCU Liberty files
and emits IR; the multi-corner integration test at
crates/opensta-to-ir/tests/opensta_integration.rs::gf180mcu_multi_corner_emits_per_corner_values
asserts per-corner setup/hold values differ correctly across
tt/ss/ff PVT corners.
Simulation path — jacquard sim runs gate-level GF180MCU
netlists on the GPU. Cell-type detection, pin direction tables,
sequential/tie/multi-output classification, behavioural model
parsing (with UDP support for sequential elements), and AIG
decomposition are all wired through AIG::from_netlistdb.
Validation — synthetic DFF+inverter fixture at
tests/timing_test/gf180mcu_timing/. Real wafer.space test-run-1
design integration is deferred (Phase 7, gated on design
availability).
End state mirrors today’s SKY130 support: CellLibrary::GF180MCU
detected, decomposed to AIG, simulated on Metal/CUDA/HIP, with a
golden-IR corpus entry covering the timing-IR side.
GF180MCU support was a release prerequisite per session 2026-05-12.
The wafer.space ecosystem (https://github.com/wafer-space/gf180mcu)
is the near-term commercial demand driver; the upstream
google/gf180mcu-pdk is the
canonical PDK that the wafer.space variant builds on.
One enum variant for GF180MCU.CellLibrary::GF180MCU covers
both 7t5v0 and 9t5v0 prefixes. Matches the SKY130 precedent
(CellLibrary::SKY130 covers seven prefixes).
Both 7t and 9t fully supported. Unlike SKY130 (only hd is
decomposed), both GF180MCU standard-cell variants are first-class
for cell detection, pin direction, classification, and AIG
decomposition. Cell models for 7t and 9t are byte-identical per
cell type (verified at build time in build.rs); decomposition
reads from the 7t submodule and reuses for 9t.
Two separate submodules for vendoring cell models, mirroring
the per-library SKY130 split:
vendor/gf180mcu_fd_sc_mcu7t5v0/
vendor/gf180mcu_fd_sc_mcu9t5v0/
Install path:volare pinned hash under
[tool.jacquard.pdks.gf180mcu] in pyproject.toml alongside the
existing sky130 entry. Variant: gf180mcuC.
Reset polarity: GF180MCU uses active-low resets/sets
(pin names RN, SETN) — same AIG formula shape as SKY130’s
RESET_B/SET_B. The “n” prefix in cell names like
dffnq/dffnrnq/icgtn indicates a negative-edge clock
(pin CLKN), not reset polarity (resolving Open Q3 from the
original plan).
Generation strategy: build-time via build.rs::generate_gf180mcu_pin_table,
which scans vendor/gf180mcu_fd_sc_mcu{7,9}t5v0/cells/, parses
.functional.v, cross-asserts 7t/9t pin layouts match, emits
$OUT_DIR/gf180mcu_pins.rs. New precedent vs SKY130’s
hand-rolled match arms (see § Follow-on cleanup item 1).
a7c0618 — Phase 4b prep: UDP loader for gf180mcu_pdk
(parses UDP_GF018hv5v_mcu_sc7_TT_1P8V_25C_verilog_nonpg_*_FF_UDP
and friends from the vendored PDK).
459317e — Phase 4b: AIG hooks for sequential cells (DFFs,
latches, scan-DFFs, clock-gating cells icgtp/icgtn).
gf180mcu_preprocess pre-creates DFF Q pins; gf180mcu_postprocess
applies async set/reset using the active-low RN/SETN convention
via the same AIG formula as SKY130. Negative-edge clock cells
use CLKN instead of CLK (handled in trace_clock_pin).
3006f59 — Phase 4b boolean-equivalence tests covering DFF,
latch, scan-DFF, and clock-gating cells via multi-step
truth-table evaluation.
Audit-only — no per-PDK branch was missing GF180MCU handling. The
auto-detection in AIG::from_netlistdb already covers every CLI
surface (sim / cosim / dump-paths all route through
setup::load_design). Cleanup: stale Phase 4b panic comments in
src/sim/setup.rs and src/aig.rs; field doc comments on CLI
arguments refreshed to mention GF180MCU alongside AIGPDK / SKY130.
Integration test: gf180mcu_multi_corner_emits_per_corner_values
in crates/opensta-to-ir/tests/opensta_integration.rs. Loads
three real PVT corners (typ=tt_025C_5v00, slow=ss_125C_4v50,
fast=ff_n40C_5v50) at the 5.0 V operating point and asserts
per-corner setup TimingValues differ correctly across PVT.
Skips gracefully when the volare-installed PDK isn’t present
(gated on find_gf180mcu_lib_dir() returning Some; matches
the sky130 test’s skip pattern). $GF180MCU_LIBERTY_DIR overrides
the volare default path.
These are nice-to-have refactors flagged during the GF180MCU work
but deliberately out of scope for the enablement effort itself.
Update 2026-05-19: Items 1, 2, and 4 are now subsumed by
Decision 0010 — Declarative cell metadata
and its companion plan declarative-cell-metadata.md. The manifest
pathway converts these from “Rust refactor” projects into “move data
out of code as part of the migration to manifest-as-source-of-truth”
— happens once, gets all three at once.
build.rs pin-table generator for SKY130 too. Subsumed by
Decision 0010 § “Deferred to a future decision — build.rs pin-table
scanner removal.” Removed LAST in the manifest migration, after
manifests cover the built-in PDKs.
Physical relocation of shared PDK decomp infrastructure out
of sky130_pdk.rs into pdk_decomp.rs. Still relevant for the
built-in (Rust-decomp) pathway, since Decision 0010 keeps that path
load-bearing for cells with real AIG decomposition rules. Move
when a third PDK exercises the surface.
CellLibrary enum location. Currently lives in src/sky130.rs
even though it represents all PDKs. Moving to a neutral home
(src/pdk.rs or src/lib.rs) is a trivial mechanical refactor.
Independent of Decision 0010.
IO and PR libraries. Now solved by the Decision 0010 manifest
pathway. gf180mcu_fd_io and gf180mcu_fd_pr cells can be
declared via kind = "io_pad_*" / kind = "filler" / kind = "tap" etc. in user-supplied manifests — no Jacquard PR needed.
CI install strategy for GF180MCU Liberty. Both the sky130 and
gf180mcu multi-corner tests currently skip when the PDK isn’t
installed locally. CI integration (volare-on-CI or a vendored
minimal Liberty subset) is the same blocker that gates the
inv_chain_pnr sky130 corpus entry — out of scope for the GF180
enablement effort itself. Unrelated to Decision 0010.
Reset polarity — GF180MCU is active-low (RN/SETN); same
AIG formula as SKY130’s RESET_B/SET_B.
Negative-edge clocks — cells like dffnq/dffnrnq/icgtn
use pin name CLKN instead of CLK. The “n” prefix is a clock
marker, not a reset-polarity marker.
Power pins — GF180MCU operates at 5V nominal (vs SKY130’s
1.8V). Both follow VDD/VSS naming. Corner names follow
tt_025C_5v00 shape and parse cleanly through the generic
TimingLibrary loader.
Cell pin names differ from SKY130 — inverter is I/ZN
(not A/Y); DFF is CLK/D/Q/notifier. The notifier
port wires the UDP delay-model wrapper but is unused for logic
simulation.
Cell-name collisions between 7t5v0 and 9t5v0 — both have
nand2_1 etc. Detection keys on the full prefix, not the base
type. Auto-handled by is_gf180mcu_cell.
Phase 3.4 deleted src/sdf_parser.rs. The jacquard sim subcommand kept
SDF input working (Phase 3.3 wired --sdf through
setup::load_sdf_via_opensta_to_ir, an internal subprocess wrapper that
calls the opensta-to-ir crate to convert SDF→IR). The jacquard cosim
subcommand chose Option B of the phase 3.4 handoff: drop --sdf
entirely rather than thread --liberty through. As a result, cosim now
only accepts pre-converted IR via --timing-ir.
Retraction (2026-05-18). Earlier versions of this section
recommended feeding tests/mcu_soc/data/top_synth.v (post-synthesis,
pre-P&R) to opensta-to-ir to dodge a parse error on 6_final.v’s
chipflow integration wrapper. That was wrong: top_synth.v is
missing the ~236K cells P&R inserts (clkbuf_regs_* CTS buffers,
ANTENNA_* diodes, delaybuf_*, fillers), so OpenSTA silently drops
every SDF entry referencing a P&R-inserted cell and the resulting IR
is missing the bulk of the design’s timing. The “28162 matched /
2090 unmatched” verification log we celebrated at the time measured
jtir records against the cosim-loaded netlist, not SDF coverage
against the jtir — high surface match rate, materially incomplete
IR. See Decision 0009 (OpenSTA Verilog reader input constraints) for
the broader rule.
opensta-to-ir now transparently extracts module <--top> from each
input file before invoking OpenSTA (implementation in
crates/opensta-to-ir/src/verilog_filter.rs). For the chipflow
mcu_soc case this strips the openframe_project_wrapper module
automatically; the same handling kicks in for any LibreLane +
wafer.space user (hazard3 and future tapeouts) whose final netlist
carries an integration wrapper around the structural top.
# Convert SDF → IR once. Pass 6_final.v directly; the wrapper module
# is dropped automatically.
opensta-to-ir \
--liberty /path/to/sky130_fd_sc_hd__tt_025C_1v80.lib \
--verilog tests/mcu_soc/data/6_final.v \
--sdf tests/mcu_soc/data/6_final.sdf \
--top top \
--output tests/mcu_soc/data/6_final.jtir
# Run cosim with the pre-converted IR. Cosim loads 6_final.v (the
# wrapper) because that's what carries GPIO ports. The IR consumer's
# hierarchy-prefix detection strips the `top_inst/` prefix from the
# wrapper's cell paths so they match the IR's instance names.
cargo run -r --features metal --bin jacquard -- cosim \
tests/mcu_soc/data/6_final.v \
--config tests/mcu_soc/sim_config_sky130.json \
--top-module openframe_project_wrapper \
--timing-ir tests/mcu_soc/data/6_final.jtir
tests/mcu_soc/sim_config_sky130.json no longer carries sdf_file /
sdf_corner (the fields would be silently ignored if added back;
cosim does not consume them).
tests/mcu_soc/events_reference.json was wired into the sky130 cosim
config as part of phase 3.4 verification. End-to-end pipeline result
on a 3M-tick run:
67 UART bytes captured; the reference’s 155 UART events end at
timestamp 4,187,182. All 67 captured payloads match the reference’s
leading bytes (decoded UART output: ....: nyaa~!\nSoC type: CA7F100F\nFlash ID: CA7CA7FF\nQuad mode). No payload divergence.
15 non-UART entries in the reference (cxxrtl-emitted SPI deselect
events with payload: "") are filtered out at parse time by the
tolerant deserializer in cosim_metal.rs::run_cosim. Without that
filter the comparison panicked on the first SPI entry.
Retraction. Earlier drafts of this section claimed Jacquard’s
--max-cycles counts half-cycles. That was a misdiagnosis based on
reading MultiClockScheduler::new (which does emit per-edge raw
entries) without noticing the pairing layer at
src/sim/cosim_metal.rs:2604-2675 that collapses them into one
paired buffer per cycle. Today, --max-cycles N correctly counts
N full clock cycles: each cosim tick does one fall-edge dispatch
plus one rise-edge dispatch and DFFs capture once per tick. Verified
via --stimulus-vcd trace (5 ticks → simulated time spans 0–200000
ps for a 40 ns period clock, exactly 5 cycles).
The actual unit difference vs chipflow’s cxxrtl harness:
chipflow’s num_steps is the count of tick() calls; each tick()
bumps ++timestamp twice (once after the negedge dispatch, once
after the posedge), so the events_reference.jsontimestamp
field counts clock edges (a full cycle = posedge-to-posedge =
2 edges). The harness:
auto tick = [&]() {
{{interface}}.step(timestamp);
top.clk.set(false); agent.step(); ++timestamp; // post-negedge (odd)
top.clk.set(true); agent.step(); ++timestamp; // post-posedge (even)
};
for (int i = 0; i < num_steps; i++) tick();
See chipflow-lib/chipflow/common/sim/main.cc.jinja:32-74.
The half-tick timestamp is an intentional design, not a bug:
parity tags each event with the clock phase it fired on (useful for
verification of async paths).
chipflow’s num_steps therefore doubles as an edge budget: 3 M
num_steps = 3 M edges = 1.5 M full clock cycles.
To compare a Jacquard cosim run against today’s events_reference.json,
divide reference timestamps by 2 to convert edges → cycles. Empirical
spot-check on mcu_soc/sky130: byte-0 in Jacquard at --max-cycles 200000 arrives at tick 28682; reference timestamp 58290 / 2 = 29145
cycles; ratio 0.984× (simulators agree on simulated time within 2%).
The earlier “67 of 155 events captured” gap is not a budget
issue — chipflow drives input stimulus via design/tests/input.json
and reference events 69+ require those driven inputs. The input-stimulus
dispatcher was added in commit 4a1a989, and the mcu_soc/sky130 cosim
now matches the cut-down chipflow reference 1:1 (90/90 events).
The earlier “Jacquard ~14% slower per byte than cxxrtl” claim relied
on a phantom half-cycle correction; it is also retracted. There is
no rate gap to explain at this level.
Cosim’s internal granularity moved from full clock cycles to scheduler
edges, aligning Jacquard’s CLI 1:1 with chipflow’s num_steps and
unlocking per-edge event timestamping. Section retained for context on
the unit conventions captured above.
Add --liberty to CosimArgs (src/bin/jacquard.rs). Plumb it
through DesignArgs::liberty (currently hardcoded None in
cmd_cosim). Also passthrough --top-module if not already.
Add --sdf, --sdf-corner, --sdf-debug back to CosimArgs.
Make them mutually exclusive with --timing-ir (clap
conflicts_with = "timing_ir").
Re-add TimingSimConfig::sdf_file / sdf_corner (optional) —
plus a new liberty_file field for the OpenSTA invocation. Update
tests/mcu_soc/sim_config_sky130.json to use the new shape.
Restore the cosim config-file fallback: in
src/sim/cosim_metal.rs::run_cosim, when timing is not yet enabled
and the config provides SDF + Liberty paths, call
setup::load_sdf_via_opensta_to_ir. Match the priority order:
CLI > config.timing.* > nothing.
Update --output-vcd error message to mention --sdf again.
Rebuilding a hand-rolled SDF parser. (See Decision 0006 — the durable
replacement is the native Rust SDF→IR converter, tracked separately as
Phase 3 in the original phasing.)
Adding cosim-specific corner-selection beyond what jacquard sim
already offers. The IR’s min/typ/max triple is selected via
ir_corner0_max (currently always max); changing that is a separate
concern that affects both subcommands.
cargo build --features metal
cargo test --lib
# Manual smoke test of the previous mcu_soc workflow:
cargo run -r --features metal --bin jacquard -- cosim \
tests/mcu_soc/data/6_final.v \
--config tests/mcu_soc/sim_config_sky130.json \
--liberty <path>/sky130.lib \
--sdf tests/mcu_soc/data/6_final.sdf
Should produce equivalent results to the pre-3.4 hand-rolled-parser
path within the IR’s representational bounds (single-value
interconnect delays, max corner selection).
If Option A is never picked up before first release, the existing IR-only
cosim surface is fine — contributors using SDF can pre-convert via
opensta-to-ir and pass the resulting .jtir. The follow-up exists as a
contributor-ergonomics improvement, not a correctness gap.
Status: Captured architectural thinking. Most phases here are
demand-driven and will only be picked up when a real-world workload requires
them. Phases 1 and 2 may be worth scheduling on their own merits in a future
release; the rest are written down so the design space is on record when the
need appears.
This is a design-space doc, not a scheduling doc. It complements
post-phase-0-roadmap.md (which schedules committed work) by capturing the
architecture for two related areas — multi-clock-domain support and stimulus
generation — that today have working but limited implementations.
The conversation that produced this doc was about supporting cosim against
external testbench environments (UVM, CocoTB) and external clock sources (PHY,
audio, DFS). Two observations crystallised the architecture:
Real designs partition into large synchronous islands with thin
boundaries. External-clock and DFS scenarios look intractable until you
notice that <1 % of nets typically cross domains; the bulk of the design is
batchable inside one island.
Stimulus generation and stimulus consumption don’t have to share a
loop. Today cosim couples the testbench tick-by-tick to the GPU dispatch.
Decoupling them — via streaming or full precompute — turns the GPU from a
ping-ponging coprocessor into a stream consumer.
Both observations point at architecture changes that compose cleanly with each
other, with the existing multi-clock plumbing, and with the existing X-prop /
timing-arrival infrastructure.
Multi-clock-domain functional support.MultiClockScheduler in
src/sim/cosim_metal.rs:1347 builds a tick-by-tick edge schedule over the
LCM of all domains’ periods (with GCD granularity). DFFs are tagged by clock
domain via clock_pin2aigpins in src/aig.rs:209. Each scheduler tick
asserts only the firing domains’ posedge/negedge flag bits; the GPU kernel
gates DFF write-back on those flags, so non-firing domains’ DFFs hold.
LCM constraint. The scheduler asserts schedule_len <= 1_000_000
(cosim_metal.rs:1376). Commensurable periods (PLL-derived) work; truly
non-commensurable external clocks (audio, USB-recovered, DFS-mid-flight) hit
the cap.
Cosim stimulus.InputDispatcher (src/sim/input_stim.rs) consumes a
chipflow-compatible wait/action/stop JSON command list. Peripheral
models (src/sim/models/) drain queued actions per edge and emit events.
Generation is interleaved with the GPU dispatch loop — every tick (or every
few ticks) round-trips through the host.
VCD replay path.jacquard sim already runs from a precomputed input
VCD with no host-side reactive logic. This is, in effect, the “Level 1”
precomputed-edge mode described below; the gap is between cosim’s reactive
loop and sim’s flat replay, not in the kernel itself.
CDC checking. None today. SDF setup/hold checks exist
(src/timing_report.rs) but are not wired through any CDC-specific path.
A static analysis pass partitions the AIG into islands: maximal connected
sets of gates whose transitive fanin/fanout stays inside one clock domain.
Whatever’s left is the boundary — combinational gates and DFFs whose data
cones cross domains. In real designs the boundary is small, dominated by
synchronizers (2FF), async FIFO control, and handshake glue.
Per-island execution lets the GPU:
Skip evaluation of an island whose state hasn’t changed.
Batch K consecutive ticks of a fast island into one kernel launch when the
slow island has no edges in the window.
Treat the boundary as a small mailbox (source-island outputs read by
destination-island reads) rather than a global state vector.
This is essentially functional partitioning for parallel discrete-event
simulation, but the GPU dataflow model gets more benefit than a CPU sim
because batched dataflow is exactly what a fast island’s run-ahead window
wants.
The cosim host loop is the throughput floor today. Decoupling has three
levels:
Replay — the testbench has already produced a complete input VCD; the
GPU just plays it back. Today’s jacquard sim is this case.
Streaming buffer — testbench runs in a separate thread feeding a ring
buffer of (tick, input_op) tuples. GPU consumes batches. As long as the
producer keeps up on average, the GPU never stalls. Works because most
ticks have no input change and peripheral state machines run far slower than
the kernel.
Record-and-replay with divergence detection — pass 1 runs full cosim and
records every input transition; pass 2 replays at line-rate while
checksumming outputs against the recorded run. If outputs diverge, abort and
fall back. Wins decisively for regression CI where most runs confirm
“nothing externally observable changed”.
Each phase is independently shippable. The phase numbering here is local to
this doc and should not be confused with the timing-IR phase numbering in
post-phase-0-roadmap.md.
Phase
Topic
Trigger
MC.1
Static island partitioner (analysis only, emits metadata)
Standalone-useful for CDC reporting; could land in a future release without further work
Demand-driven; deferred until measurement shows residual sync overhead after MC.4
MC.3/MC.4 trigger is now measured (2026-06-07). Instrumenting the cosim
loop’s batch utilisation (see Decision 0017 amendment, Measured batch
utilisation) shows GPU-peripheral designs run 100% batched, but
jtag_minimal — CPU-side JTAG replay — emits 102,310 single-edge command
buffers out of 106,117 (96% of all submits; 2.6% of edges). Those per-edge
CPU↔GPU round-trips dominate its wall-clock: this is the MC.3
“round-trip measured as the bottleneck” trigger. The structural fix is
MC.4 — the fast sys_clk island runs ahead/batched while only the slow
model-driven tck boundary needs per-edge handover — which is why MC.4
depends on the MC.1 island partitioner. Both are orthogonal to the #105
backend-portability seam (which preserves today’s batch model unchanged).
Walk the AIG; for each gate compute the set of clock domains its transitive
fanin/fanout touches. Tag gates as island-internal (fanin and fanout both
inside one domain) or boundary (touches more than one domain on either
side). Emit per-island gate counts and a list of boundary gates as metadata
on the existing FlattenedScript.
What it enables on its own, even with no runtime change:
Diagnostic: “this design has 14 inter-domain combinational paths from
audio_clk → core_clk and 2 the other way”. Useful for designers
reviewing CDC structure.
Data structure that MC.2 / MC.4 / CDC reporting all need.
Sanity-check on the “<1 %” boundary-surface assumption for the workloads
that motivate further phases.
Classification policy for derived signals (e.g. a sync-FIFO read pointer in
clock_b qualified by an output of a sync chain from clock_a): classify
aggressively. Only gates whose direct fanin includes pins from multiple
domains are boundary; downstream gates fed by a domain-tagged
pre-synchronizer output inherit that domain. This pushes the boundary in as
close to the structural CDC crossing as possible and is what makes the
“<1 %” claim hold on real designs — a lazy classification that propagated
“multi-domain” forward through every downstream cone would yield a
boundary surface that swallowed half the design.
Code locations: extends aig.rs (domain analysis on DriverType) and
flatten.rs (metadata on FlattenedScriptV1). No kernel changes.
Replace MultiClockScheduler’s precomputed Vec<TickEdges> with a min-heap
of (next-edge-time, domain) pairs. Pop the next edge, dispatch, push the
domain’s next edge back. No LCM constraint; non-commensurable periods are
free. DFS support falls out: when the DUT writes a clock-control register,
the host updates the heap entry’s period.
DFS hook design: explicit, not generic signal-watching. The cosim config
declares (control_signal, period_table) pairs; the host polls the named
bit each tick (cheap — one bit) and updates the heap. Generic
“call-back-on-arbitrary-signal” is rejected as too coupled.
Code locations: MultiClockScheduler::new and build_edge_ops in
cosim_metal.rs. Same per-domain flag emission, different scheduling
backend.
InputDispatcher becomes a trait; today’s FileDispatcher is one
implementation. New implementations:
ThreadedDispatcher — runs peripheral models on a separate thread; emits
(tick, input_op) into a lock-free SPSC ring buffer; GPU loop consumes
batches.
StreamDispatcher — same shape but the producer is a JSON-lines stream
over a Unix socket / stdio (this is also the bridge to UVM/CocoTB peer
testbenches).
Latency budget: the producer must be at least one tick ahead of the
consumer. For transaction-level workloads this is easy (peripheral state
machines run orders of magnitude slower than the GPU). For sub-cycle
reactive loops it isn’t, and those workloads stay on the synchronous path.
Code locations: refactor input_stim.rs around a trait; new module for
ring-buffer plumbing; cosim main loop drains a batch per dispatch instead of
one tick.
Build per-island execution scripts (and one boundary script) from the
metadata MC.1 produces. Cosim main loop becomes:
#![allow(unused)]
fn main() {
loop {
let (next_t, domain) = scheduler.peek();
let lookahead = scheduler.next_other_domain_edge(domain) - now;
let edges_in_window = lookahead / domain.period;
dispatch(island_script[domain], edges = edges_in_window);
dispatch(boundary_script); // only if boundary signals changed
advance_clock(now + edges_in_window * domain.period);
}
}
Boundary mailbox lives in shared state-buffer slots that the source island’s
script writes and the destination island’s script reads. Repcut continues to
partition each island’s script across GPU blocks independently.
Tight-boundary gates (combinationally fed by both domains) force a sync
point on every edge of either side; MC.1’s metadata identifies these so the
runtime knows when batching can extend.
Add --record-stimulus to cosim that emits a complete tick-by-tick input
VCD and a per-tick output checksum. Add --replay-stimulus to sim (or a
new mode) that consumes the VCD, runs at line-rate, and verifies the
checksum each batch.
Divergence handling is two-tier, not just abort:
Mismatch in watched signals (the existing cosim signals_of_interest
set, or a --watch CLI argument) → abort and require re-recording. This
is the genuine “the design’s externally observable behaviour changed”
case — the recording is now stale and replay is unsafe.
Mismatch in unwatched signals → warn-and-continue against the
recorded transitions. Internal microarchitectural changes that don’t
move the observable surface are normal during development; aborting on
them defeats the purpose of accelerating regression CI, where most runs
exist to confirm “nothing externally observable changed”.
The watchset is the user-visible policy lever — it specifies what
“externally observable” means for this design. Default to the cosim
output signals (the natural CI invariant) plus any user-declared
checkpoint signals.
Useful primarily as a regression-CI accelerator. Doesn’t help one-off
runs.
Cross-test sharing. A single design accumulates many test cases. The
natural extension of record-and-replay is to share the design-side
specialized kernel across all tests in the suite and vary only the
stimulus recording. For a suite of N tests against one design, recording
costs N× pass-1 (one per test, on demand or in parallel) but replay costs
N× line-rate-kernel-launches sharing the same compiled state-buffer
layout. That’s a multiplicative win on top of per-test record-and-replay
and is the actual leverage point for full-suite CI throughput.
Speculation staircase for hot boundaries: value prediction → protocol
pattern recognition → control-slice reachable-set enumeration → full
case enumeration. Each tier larger and cheaper-to-skip. Add a “case”
dimension to the kernel dispatch only if measured sync overhead after
MC.4 justifies it.
AOT trace compilation: when stimulus is fully known (replay mode),
compile the schedule offline — fold constant inputs into AIG constants,
merge no-op ticks, sort transitions by domain. Profile-guided
specialization for designs with lots of “configured once at boot” inputs.
Composes directly with MC.5: a recording is a complete stimulus trace,
so the AOT compiler can fold every input value into the kernel
unconditionally. The resulting binary is valid only until either the
design or the recording changes, so the lifecycle model is “compile per
(design SHA, recording SHA) pair, cache for the test session,
invalidate on either source changing”. Acceptable cost for a 100×-replay
regression run; not for one-off interactive sim.
CDC verification mode: jitter injection on coincident edges and
random X-injection on detected async-source paths. Reuses MC.1’s
boundary metadata and existing X-prop infrastructure. Distinct from
static CDC checking (Spyglass, Real Intent), which is explicitly out
of scope — that’s a different product. The jitter-injection half is
designed in Decision 0012 and partly
built; remaining work is tracked in
issue #92 /
cdc-jitter-completion.md. X-injection
stays deferred until MC.1 lands.
These come up adjacent and are worth being clear about:
Pin-level VPI / GPI fidelity. Implementing enough VPI for unmodified
cocotb / SystemVerilog testbenches. The surface area is enormous and
Jacquard would be lying about delta cycles, NBA regions, #delay
semantics, and X-propagation behaviour. Use transaction-level peer
protocols (the natural extension of input.json over a socket) instead.
Metastability simulation. No RTL simulator does this; CDC verification
is structural/formal (Spyglass, JasperGold-CDC, Real Intent) and a
separate product.
Structural CDC checking (synchronizer recognition rules, gray-code
analysis). Different product. MC.1’s boundary metadata enables a light
diagnostic but not a verification flow.
DUT-internal #delay. Requires an event-driven kernel; destroys the
batched dataflow that gives Jacquard its speedup. Permanently
unsupported.
Async resets / latches in DUT. Same reason. Permanently unsupported
(already documented in CLAUDE.md).
The user observation in the originating discussion was that MC.1 and MC.2 are
worth carrying in a future release on their own merits, ahead of any specific
workload demand. Rationale:
MC.1 has standalone diagnostic value. A “boundary report” for any
multi-clock design — count of cross-domain combinational paths, location
of inter-domain DFF samples — is useful to any user reviewing CDC
structure, independent of whether the runtime ever uses the partition.
MC.2 lifts a real correctness limit. The current LCM cap silently
fails on legitimate designs (any audio-clock SoC, anything with DFS).
Replacing precompute with a min-heap is a small, contained change that
removes a category of “your design doesn’t fit” errors.
Both are foundational for the rest of the architecture. Doing them
early means later phases pick up cleanly.
If MC.1 + MC.2 ship in isolation, they don’t commit Jacquard to any of the
later phases. Each later phase remains demand-driven.
This plan tracks implementation work for the cosim peripheral model
framework. Decision 0013 documents the architecture (two execution domains,
observe-only vs bidirectional GPU patterns, ring buffers, plural config
convention); this doc tracks the concrete workstreams.
Add name: Option<String> to UartConfig. Add
uarts: Vec<UartConfig> to TestbenchConfig. Add
effective_uarts() mirroring effective_clocks():
#![allow(unused)]
fn main() {
pub fn effective_uarts(&self) -> Vec<UartConfig> {
let mut out = self.uarts.clone();
if let Some(ref u) = self.uart {
out.insert(0, u.clone());
}
out
}
}
Existing "uart": {...} configs work unchanged. New form:
"uarts": [{"name": "console", ...}, {"name": "debug", ...}].
Both may coexist; uart is prepended to uarts.
UartDecoderState and UartChannel structs unchanged — the device
buffers hold [MAX_UARTS] elements. gpu_io_step buffer signature
unchanged (same 6 slots); the UART decode block becomes a loop over
n_uarts.
Trace AHB5, AHB-Lite, and APB3 bus transactions in cosim, compactly,
without baking signal names into source. Output as CSV (machine-readable
transaction table) and annotated VCD (transactions as a signal group for
waveform viewers). Decode site: GPU capture + CPU protocol FSM (the kernel
stays dumb; protocol semantics live in testable Rust).
Order: APB3 first (validate against the Hazard3 JTAG-DM APB DMI in
tests/jtag_minimal/), then AHB-Lite, then AHB5.
The existing “Wishbone bus trace” (build_wb_trace_params,
cosim_metal.rs:1277; gpu_io_step, kernel_v1.metal:1182) proves the
mechanism — a GPU observe-only peripheral that packs a compact per-tick
entry into a ring buffer only when the bus is active/changed, drained by the
CPU — but it is hardcoded to one VexRiscv-style SoC (literal names
cpu.fetch.ibus__cyc, spiflash.ctrl.wb_bus__ack, …). We generalize that
mechanism into a config-driven, protocol-aware monitor. It is observe-only
(we watch design outputs, never drive), so it fits the decision-0013 GPU
observe-only peripheral pattern, and gets the effective_*()-style plural
config for free.
Two existing pieces are reused:
Multi-candidate name resolution in src/sim/trace_signals.rs — handles
Yosys-flattened / scalar-expanded / structural hierarchical naming. Refactor
the candidate generator into a shared helper so the bus tracer binds pins the
same way --trace-signals does.
Extra-observables VCD path (emit_extra_observables, vcd_io.rs:635) —
the model for emitting synthesized signals into the output VCD.
The hardcoded WbTrace is left intact for now (it has a passing test);
migrating it onto the general mechanism is a clean follow-up, not a
prerequisite.
APB3 FSM: GPU gates capture on psel & penable & pready (access-phase
complete), so each captured beat is a complete transaction. dir = pwrite,
data = pwrite ? pwdata : prdata, resp = pslverr.
AHB FSM: GPU gates capture on hready high (pipeline advance) and records
htrans, haddr, hwrite, hsize, hburst, hwdata, hrdata, hresp. CPU keeps a
1-deep pending address-phase record and pairs address beat N with the data on
beat N+1; tracks burst beat counter from hburst/htrans==SEQ.
Pure-Rust, unit-tested with synthetic beat sequences — no GPU required. This is
the testability win of CPU-side decode.
The kernel computes the per-protocol gate, and on a gating edge packs one
BusTraceEntry (bus id in flags high bits). No FSM, no pairing on GPU.
gpu_io_step currently uses buffer slots 0–5 (UART + WbTrace). Add slots 6–7
for BusTraceParams[] + BusTraceChannel. Metal allows ≫8 buffers, so extend
the existing dispatch rather than adding a kernel.
Rust mirrors of the structs in cosim_metal.rs (next to WbTraceParams),
build_bus_trace_params() resolving pins for each configured bus, buffer
allocation sized MAX_BUS_TRACES, and a per-bus read head in the drain loop
(near cosim_metal.rs:4057) feeding each BusTraceDecoder.
Extract the multi-candidate name → AIG-pin / state-position resolver
(currently internal to trace-signal registration) into a reusable helper
callable from build_bus_trace_params. Keeps one source of truth for the
Yosys/scalar/structural naming conventions.
CSV (--bus-trace-csv <PATH>): drain-time, one row per BusTransaction.
Header: tick,bus,protocol,dir,addr,data,resp,burst. Trivial — lands in
Phase 1.
Annotated VCD: synthesized per-bus VCD vars ({bus}_addr,
{bus}_wdata/{bus}_rdata, {bus}_dir, {bus}_resp) that value-change at
transaction-complete ticks. This needs a new “virtual signal” emission
path in vcd_io.rs: unlike existing extra-observables (raw nets sampled
per tick from the state buffer), these are sparse CPU-decoded events the VCD
writer must interleave by tick. Bigger plumbing → Phase 3. Dovetails with
the wire-bundle-scripting / Surfer direction in project memory.
Phase 1 is complete (APB3 end-to-end + CSV). Validated by
tests/apb_trace/ — a dedicated synthesized APB3 design (the Hazard3
JTAG-DM post-PnR netlist drops the APB addr/data nets during flattening,
so a names-preserved design was built instead). CI step:
Run APB3 bus-trace cosim (Decision 0013). Phases 2–3 remain.
Phase 2 — AHB-Lite + AHB5. Pipeline pairing, burst tracking, AHB5 extra
signals. Unit-test the AHB FSM. Needs an AHB design to integration-test
against (open question — see below).
Phase 3 — Annotated VCD. Virtual-signal emission path in vcd_io.rs.
Follow-up — migrate WbTrace onto the general mechanism (express the
VexRiscv ibus/dbus as configured buses), then delete the hardcoded path.
AHB integration test design. APB3 validates on the existing Hazard3
JTAG-DM. Phase 2 needs an AHB-Lite/AHB5 design — do we have one, or synthesize
a small AHB peripheral (like tests/dual_uart/)?
Per-bus ring vs shared ring. One BusTraceChannel with a bus-id field
(simpler allocation) vs one ring per bus (no cross-bus contention). Start
shared; revisit if a hot multi-bus design overflows.
CUDA/HIP. Cosim is Metal-only today; no kernel changes needed elsewhere
now, but the general design should port cleanly when CUDA cosim lands.
This generalizes the cosim peripheral architecture — update decision-0013
(plural-peripheral configs) to record the config-driven bus-monitor pattern and
the GPU-capture/CPU-decode split, once Phase 1 is real.
Implemented: the run-parameters file + per-domain seeded PRNG
(src/sim/run_params.rs), jitter_ps per ClockConfig, the uniform
per-domain draw, and a jitter displacement applied to the timing-VCD
event timestamp (cosim_metal.rs, inside the --output-vcd block
only). So today jitter perturbs the waveform timeline but nothing
else — it does not reach the setup/hold checker, model-driven clocks,
or coincident-edge ordering.
The goal of this plan is to make jitter actually stress CDC paths, then
extend it to model-driven clocks and tidy the loose ends, so Decision 0012’s
present-tense design fully matches the code.
Right now jitter_displacement only adjusts the VCD base_timestamp
(cosim_metal.rs:~3928-3948) and is computed inside the timing-VCD
emission block, so it has no effect without --output-vcd and never
influences violations.
Hoist the per-tick per-domain displacement draw out of the VCD block
so it is available whenever jitter_active, independent of
--output-vcd.
Apply each domain’s displacement to the arrival offsets that
setup/hold checking consumes (the arrival_state section), not just
the VCD base timestamp — so a jittered edge can move a margin across
the setup/hold boundary and surface in --timing-report.
True per-domain perturbation (decision §4): keep a displacement per
firing domain this tick rather than the current single global value
(the loop overwrites jitter_displacement with the last domain’s
draw). Coincident edges from domains A and B then move independently,
exercising both orderings over a seed sweep.
Verify: a small two-domain design with a deliberately marginal CDC path;
assert that a seed sweep produces both “no violation” and “violation”
outcomes, and that a fixed seed reproduces exactly.
Model-driven clocks (JtagReplayModel, SPI SCK, …) bypass the scheduler
and currently get no jitter.
Add --cdc-model-jitter-ps <N> (and/or per-model jitter_ps in
config) → a budget + seeded stream via RunParams::domain_seed(model_name).
After a model fires its edge, displace the timing-model arrival
for that transition (not the functional edge — the DFF still samples
on the same tick), mirroring the Phase 1 arrival-offset path.
Verify: extend tests/jtag_minimal (model-driven TCK) with a model
jitter budget; confirm reproducibility and that TCK→sys_clk CDC margins
vary by seed.
gcd_ps / 2 constraint (decision §2): at startup, error (or clamp with
a loud warning) if any jitter_ps > scheduler.gcd_ps / 2, since larger
values would reorder edges across GCD ticks.
Always persist the seed (decision §1): when neither --run-params nor
--output-vcd is given, RunParams::generate() currently does not
write the file. Persist to a default path unconditionally so every run
is replayable.
master_seed in the VCD header (decision §1/§5): emit the master seed as
a VCD header comment in vcd_io.rs, so the seed is recoverable from an
output artifact, not just the INFO log.
Once jitter feeds violations (Phase 1), add a lightweight CI step: run
the marginal-CDC design across a few sequential seeds, upload each run’s
run_params.json as an artifact, fail if an unexpected violation
appears. Gives every PR a cheap CDC regression.
Implements Decision 0018. Goal:
a fresh user (or a docs-dogfooding agent) can install Jacquard in one
line on macOS/Metal, with CUDA/HIP following as runners land.
Done in d2afde4: the metallib is embedded via include_bytes! +
new_library_with_data, verified by running the release binary copied
outside target/. The original problem, for the record:
Blocker found while scoping Phase 1.MetalSimulator::new
(cosim_metal.rs:337) loads the GPU kernel via
new_library_with_file(env!("METALLIB_PATH")) — a compile-time
absolute path into the build’s target/.../out/ucc_metal/. A prebuilt
binary moved to another machine panics with Failed to load metallib:
the .metallib isn’t bundled and the path doesn’t exist there.
A distributable binary must locate its kernel without that build-tree
path. Preferred fix: embed the metallib in the binary —
include_bytes!(env!("METALLIB_PATH")) + new_library_with_data, so the
binary is self-contained (no sidecar file). Alternative: ship the
.metallib next to the executable and resolve relative to
current_exe(), with the env! path as a dev fallback. Embedding is
cleaner for distribution.
(CUDA/HIP likely have an analogous kernel-loading assumption; check when
Phase 4 lands.)
This must merge before Phase 1 produces a usable artifact.
Tap repo provisioned:gpu-eda/homebrew-tap
created via brew tap-new (brew test-bot CI + pr-pull bottle
publishing). Formula staged at packaging/homebrew/jacquard.rb
(brew-style clean apart from homebrew-core-only Sorbet sigils; installs
jacquard + opensta-to-ir, tests --version). Remaining:
At the first release: open a PR to the tap adding Formula/jacquard.rb
with the released url / version / sha256 (the release emits a
.sha256). The tap CI installs + tests it on the PR.
Subsequent releases: brew bump-formula-pr, or a release-CI step that
opens the PR automatically (stretch).
Then brew install gpu-eda/tap/jacquard → both bins on PATH.
Add linux-x64-cuda and linux-x64-hip rows to the release.yml
matrix once the NVIDIA / AMD self-hosted runners are registered
(tracks the same work re-enabling CUDA/HIP CI).
New docs/installation.md (in SUMMARY.md) presenting the tiers:
brew install / cargo binstall for the simulator, uvx netlist-graph for signal analysis, opensta-to-ir + PDK for timing.
Source-build remains documented as the fallback / contributor path.
Trim the README “Build” section to point at the install page for users
while keeping the from-source instructions for contributors.
The simulator can’t be published to crates.io while its core deps are a
vendored fork (vendor/eda-infra-rs → ChipFlow/eda-infra-rs) carrying
patches not on the registry (the path deps declare a version but resolve to the
fork’s patched code; Decision 0018). De-forking — getting every fork change
upstreamed + released so jacquard can depend on published
gzz2000/eda-infra-rs crates — is the long-term unblock. Not required for
the binary-distribution channels (those work today); tracked here as the path to
crates.io / dependency hygiene. (opensta-to-ir / timing-ir are ours to
version + name as we like, so they are not a blocker.)
Audit (2026-06-23): the fork (e4e3db0, branch master) is 13 commits
ahead, 1 behind upstream gzz2000/eda-infra-rs. Upstream merged none of ours;
it only added the license-string fix (026070c). Status of our 13 commits:
Versioning: coordinated version across the two Rust bins
(jacquard + opensta-to-ir), single tag; netlist-graph versioned
independently.
Homebrew scope: the formula installs the Rust bins only; the docs
carry the uvx netlist-graph line separately. netlist-graph is not a
formula dependency.
Tag scheme:v<X.Y.Z> triggers the Rust release;
netlist-graph-v<X.Y.Z> triggers the PyPI publish. The two workflows
filter on their own tag prefixes so they never collide.
Status: Phases 0–2 shipped (PR #118, 2026-06-17; PR #120, 2026-06-21).
Phase 3 (single-source peripherals) remains future, demand-driven work.
The per-phase plans are in archive/.
Issue:#105.
Architecture:Decision 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
Decision 0017: a backend-agnostic orchestration layer, a batch-granular
CosimBackend trait, and a 3-tier GpuPeripheral model — then CPU and
CUDA/HIP backends.
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 Decision 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.
Peripheral protocol models (Tier 1) — src/sim/models/*.rs (gpio,
uart, i2c, spi, jtag, bus_trace) are pure CPU Rust implementing
PeripheralModel (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_v1 is the exact CPU
equivalent of one simulate_v1_stage threadgroup. The existing
run_cosim--check-with-cpu path (cosim_metal.rs:4282–4504) already
runs state_prep + apply_flash_din + simulate_block_v1 per 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 and Vec<BitOp> ops.
#![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 (Decision 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.
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 Decision 0017, Layer 3 and Consequences.
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 intoMetalBackend (built once via
init_schedule); the orchestration keeps only edges_per_period + gcd_ps.
Currently ScheduleBuffers holds metal::Buffer pairs 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 BitOp
writes over contents() to backend.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 private simulate_block_v1 copies onto
cpu_reference.Done — #115.
Move the ~20 ad-hoc simulator.device.new_buffer(...) allocations in
run_cosim into MetalBackend.
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).
Implement CpuBackend: state_prep (output→input copy + BitOps +
X-mask clear — the loop at cosim_metal.rs:4286–4301), run_edges via
cpu_reference::simulate_block_v1 per block (N effectively 1), plain
Vec<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 CppSpiFlash FFI
(cosim_metal.rs:2493) rather than writing a third copy.
Wire cmd_cosim to select CpuBackend when --features metal is 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, and apb_trace
run on free ubuntu-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.
Refined 2026-06-19 (see Decision 0017 Amendment 2026-06-19 and the detail doc
cosim-phase2-cuda-hip.md): there is one
CUDA/HIP cosim backend, mirroring MetalBackend (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.
CpuBackend stays the pure-CPU oracle; the “per-edge fallback” is batch=1
of 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/HipBackend
over the cosim_state_prep + cosim_simulate_stage kernels + init_schedule
/ edge_ops_mut (managed buffers, dirty-edge upload) + the batched
run_edges/VCD-ring path. Validate against xprop_cosim (logic fixtures, no
peripherals).
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.
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).
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 CPU PeripheralModel is 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 + cmp after each refactor step.
Linux CI: Phase 1 is the unlock — cosim regression coverage on free
ubuntu-latest instead of the single self-hosted Metal runner.
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 BitOp writes
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 CppSpiFlash FFI
must agree. Prefer reusing CppSpiFlash to 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).
Independent of #104
(CUDA/HIP sim timing) — both bring CUDA/HIP toward Metal parity and are
now T4-testable, but touch different code paths.
Complements the sim cross-backend equivalence test (#113) and the
proposed single-source simulate_block_v1 macro prelude — those harden
the sim compute 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.
Status: J1–J4 implemented (interactive --jtag-server lands; J5
single-step/breakpoints deferred). User guide: docs/jtag-debug.md.
Tracks #124.
Goal: add an interactive JTAG debug server to jacquard cosim so an
external debugger (OpenOCD → gdb) can attach to a running GPU co-simulation and
inspect the design through its RISC-V Debug Module — read/halt/resume/step
architected state (GPRs, CSRs, PC, memory), exactly as the same firmware would
be debugged on real silicon.
--jtag-server <port> is the interactive sibling of the existing
--jtag-replay <stream>: instead of replaying a recorded remote_bitbang byte
stream, it opens a live remote_bitbang TCP socket and drives the same
configured TCK/TMS/TDI/TRST pins from the connected client, stepping the design
in lock-step with debug transactions.
cosim already drives a design’s DTM/DM via --jtag-replay to load firmware.
The same DTM/DM, fed a live bitbang socket instead of a recording, exposes
the full RISC-V external-debug interface. The architectural pieces are in place:
JtagReplayModel (src/sim/models/jtag.rs) already parses the entire
remote_bitbang byte alphabet (0–7 pin drives, r/s/t/u reset, R TDO
read, B/b blink, Q quit) and maps it to TCK/TMS/TDI/TRST drives.
PeripheralModel (src/sim/models/mod.rs:56) is already the right
contract: step_edge(output_state, overrides, emitted) is bidirectional, and
is_active() already forces batch=1 (single-edge dispatch) while JTAG
is live — exactly the fine-grained stepping a debug session needs. The
batched fast path is untouched when no client is attached.
cosim already resolves the jtag peripheral’s pin mapping from
sim_config.json and patches model-driven pins into the per-edge BitOp
ops via state_prep.
So the live server is largely “swap the recorded Vec<u8> cursor for a live
socket, and answer R from real TDO.” No GPU/kernel change, no backend
change, no PeripheralModel trait change.
D1 — Single-threaded blocking socket; no async, no thread. The cosim loop
is synchronous (step_edge → run_edges → wait → repeat). A live debug
session inverts time control: the client (OpenOCD) drives the pace, so
step_edge blocking on a socket read is the correct synchronisation. With
is_active()==true forcing batch=1, each edge processes one bitbang step.
No executor or background thread is required for a single connection.
D2 — Wire output_state into step_edge (resolve the standing TODO).
Both Decision 0017 and Decision 0013 note step_edge is handed an empty output_state
“until I²C/SPI observation needs it.” The interactive server is the first CPU
model that must read a design output (TDO). Replace the &[] at
cosim/mod.rs:2987 with the real output slice. This also unblocks the
scaffolded I²C/SPI models — a general improvement, not a JTAG special case.
D3 — Abstract the byte source, not the model. Introduce
enum JtagSource { Replay { bytes: Vec<u8>, cursor: usize }, Live(TcpStream) }
in jtag.rs; the single change point is bytes[cursor] →
source.next_byte_blocking(). JtagReplayModel keeps its FSM; only its
input feed changes. Replay behaviour is byte-for-byte identical.
D4 — TDO read-back over the socket. On R, sample TDO from
output_state[tdo_pos] and write the ASCII '0'/'1' back to the
TcpStream — the only response the remote_bitbang protocol requires.
tdo_pos is resolved from the new JtagConfig.tdo_gpio via the design’s
output-bit map after construction.
D5 — --jtag-server <PORT>; mutually exclusive with --jtag-replay. Bind
a TcpListener and accept() (blocking) in the instantiation block before
the main loop — single connection for v1, stored in the model. Q ends the
session; the sim can continue free-running (decision: log and continue, vs
exit — see open questions).
D6 — Honour the OpenOCD remote_bitbang contract exactly. Reuse the
existing alphabet handling; B/b (blink) stay no-ops, R is the sole
response byte, s/u (SRST) map to the existing reset handling. Mirrors
OpenOCD’s remote_bitbang.c so stock OpenOCD connects unmodified.
Real OpenOCD+gdb attach is interactive and heavy for CI, so the CI gate is a
self-contained loopback test, with manual OpenOCD as a documented recipe:
V1 — loopback integration test (CI). A Rust test acts as the
remote_bitbangclient over a localhost socket: it feeds the same
recorded stream the jtag_minimal fixture already uses (bitbang.rec),
services R reads, and asserts the design reaches the same
data0_obs == 0xCAFEBABE as the replay path. This exercises the full live
socket → TDO read-back → drive path end-to-end with zero external tooling,
and pins live-vs-replay equivalence. Runs on the CPU/Metal cosim backends like
the existing jtag-minimal-cosim job.
V2 — manual OpenOCD + gdb recipe (docs). A docs/jtag-debug.md guide:
jacquard cosim … --jtag-server 9999, an OpenOCD remote_bitbang config,
gdb … target remote, info registers / x/ / load. The issue names the
cocotb RemoteBitbangServer as the behavioural precedent to match.
✅ done (guide + --help; manual OpenOCD/gdb left to operators)
J5 (later)
Single-step / breakpoints via DM step/triggers; X-aware debug under --xprop
follows from attach; see open questions
⏳ deferred
CUDA/HIP note: the interactive path is the CPU-side model + batch=1 of the
GPU backend (per Decision 0017’s “per-edge fallback”), so it works on any cosim
backend once J1–J3 land; no per-backend kernel work.
TDO sample timing.R must sample TDO at the protocol-correct point
(after the TCK edge the client just clocked). The model already defers TCK by
one edge (pending_tck) for TMS/TDI settle; confirm the output slot read on
R reflects the just-clocked state. The loopback test (V1) catches
misalignment via the data0_obs assert.
Performance. Interactive sessions lose edge batching for their duration
(inherent and acceptable — debug is slow). The batched fast path is
unaffected when no client is attached.
--xprop interaction. The DM debug-load path’s X-behaviour was
addressed in #102. Initial
debug runs two-state; X-aware read under --xprop is a J5 refinement, not
the initial ask.
Single vs multi client / session end. v1 is one connection; Q either
exits cosim or lets it free-run — pick the gdb-friendly behaviour during J3.
--max-clock-edges semantics. An attached session may outlive the edge
budget; decide whether --jtag-server disables/relaxes the cap while a client
is connected.
Decision 0013 (peripheral model architecture) — Amendment 2026-06-21 (tdo_gpio
config surface; --jtag-server as the interactive sibling of --jtag-replay).
Existing replay path: src/sim/models/jtag.rs, tests/jtag_minimal/
(bitbang.rec, sim_config.json, the jtag-minimal-cosim CI gate).
Implementation plan for Decision 0020:
turn PR #53’s subprocess API into a pip install jacquard self-contained
binary wheel built with cibuildwheel.
Deferred (2026-07-01). Decision 0020 is a draft — a native PyO3 binding is
the preferred long-term direction, and whether we build this subprocess-wheel
path at all is deferred (#161). P0 (adopt
PR #53’s API into the uv workspace) is worth doing regardless — it’s the
Python surface either approach exposes. P1–P3 (embed-and-delocate the
subprocess binary) are on hold pending the subprocess-vs-PyO3 decision; note
the shared hard part (per-platform wheels + libc++/libomp vendoring)
carries over to a PyO3 extension either way.
The Python surface is PR #53’s — config / runner / result /
regression / errors. This plan changes packaging and adds a binary; it
does not redesign the API.
Reuse, don’t reinvent, the release machinery. The binary to embed is the
one release.yml already builds (cargo build --release --features metal --bin jacquard, metallib embedded). The publish path is
publish-netlist-graph.yml’s OIDC trusted-publishing shape. The smoke idea
is scripts/ci/user_acceptance_smoke.sh (install relocated → run sim).
Every phase ends green in CI with an install-into-clean-venv smoke test,
and any platform the wheel does not cover is log-ged, not silently
dropped.
Adopt PR #53 as a uv workspace member; ship nothing to PyPI yet.
Move #53’s python/jacquard/ in as the 5th workspace member; wire it into
the root pyproject.toml workspace list next to netlist_graph /
chipflow_harness / mcu_soc.
Keep find_jacquard_binary()’s current env → PATH → which chain (no
embedded binary yet); it resolves a dev’s local target/release/jacquard.
CI: a python-api job — uv sync, ruff, pytest — plus one integration
test that builds jacquard and drives sim() against a tiny fixture
(guarded like the other Metal jobs; reuses the metal-build artifact from
the build/test split).
Exit: #53’s tests green in CI; from jacquard import sim works against a
locally built binary. #53 can be closed/superseded by this branch.
The whole decision rests on “embed the binary + vendor its dylibs into a wheel that
launches with no Homebrew LLVM.” Prove it here on the platform we already
release for.
Embed step. Package build copies the release binary to
jacquard/_bin/jacquard. find_jacquard_binary() gains step 0: prefer the
packaged _bin/jacquard; existing chain remains the fallback.
cibuildwheel + delocate. Configure cibuildwheel for macos/arm64,
with delocate repairing the wheel — copying libc++/libomp in and
rewriting install names. Confirm with otool -L that the repaired binary
references @loader_path-relative dylibs, not /opt/homebrew.
Clean-environment smoke (the gate). In a runner without Homebrew LLVM
on the load path (or with it hidden), pip install the wheel into a fresh
venv and run a real sim — the pip-user analogue of the relocated-tarball
user-acceptance gate. This is the test that would have caught v0.2.1.
libomp duplicate-runtime check. Load jacquard in a venv that also imports
a numpy/scipy stack (which ship their own libomp) and confirm no
duplicate-OpenMP abort.
Exit: a macOS/arm64 wheel that pip installs and runs sim on a clean
box; TestPyPI upload validated via workflow_dispatch.
Gate: Decision 0018 Phase 4 (prebuilt CUDA/HIP binaries) must exist first —
this reuses those binaries, it does not invent GPU CI.
Decide with data: default manylinux wheel vs. jacquard[cuda] extra vs.
a separate package — driven by wheel size and CUDA-runtime bundling. log
and document whichever platforms the default wheel omits.
Exit: a documented, working install path for at least one GPU backend on
Linux, or a recorded decision to keep GPU on the tarball/binstall channel.
New publish-jacquard-wheel.yml, modeled on publish-netlist-graph.yml:
cibuildwheel build matrix → artifact → TestPyPI on workflow_dispatch
(dry-run) → PyPI via OIDC trusted publishing on a release tag. No stored
token; configure the trusted publisher once (mind the case-sensitive
gpu-eda/Jacquard repo claim — the same gotcha netlist-graph hit).
Version tracks the binary release via scripts/bump_version.py (the embedded
binary and the wheel are one artifact), not netlist-graph’s independent line.
Extend docs/installation.md (a pip install jacquard path) and
docs/release-process.md (the wheel channel + its staging-validation gate).
When a signal reads X under jacquard cosim --xprop, finding why is a
manual trace→guess→re-run loop: the VCD can only report wires you already
chose to trace, so you must half-know the answer to ask the question. But the
information is static: an X originates at a known X-source (unreset DFF Q,
SRAM read port, undriven primary input) and propagates forward. So “what
makes S read X?” = “which X-sources lie in S’s backward cone?” — a pure
netlist query.
Driven-input set comes from jacquard, not re-derived in Python. The
authoritative driven set (clock/reset/constant/peripheral pins + GPIO→port
mapping) is computed in Rust during cosim setup. A new jacquard xsources
subcommand dumps the X-source set (including the undriven-input complement)
as JSON; netlist-graph xroots consumes it. No drift, single source of
truth.
Dominators deferred. v1 ships the frontier query + classification +
--emit-trace. Dominators (X-sources every path passes through) need a
careful formulation over the DFF-feedback cyclic graph and land as a
scoped follow-up once the frontier query proves useful.
Reuses the existing cosim setup path to build the AIG + NetlistDB and the
driven-input set, then:
DFF-Q / SRAM-read X-sources from AIG::compute_x_sources()
(src/aig.rs:3277) — already enumerates these as AIG pins.
Undriven primary inputs = primary-input bits not in the cosim driven
set (the same complement cosim --xprop treats as X; see
cosim-xprop.md X-source taxonomy rows 3–4).
Name resolution: map each X-source AIG pin → hierarchical net name via
the existing aigpin → (cell_id, cell_type, output_pin_name) map
(src/aig.rs:265) and NetlistDB pin/net names. WordSymbolMap
(src/flatten.rs:138) is the precedent for this AIG→name translation.
kind ∈ {unreset-dff, sram-read, undriven-input}. The net names are
emitted in the same conventions --trace-signals resolves, so they round-trip
into the Python tool and back into a confirming --xprop run.
Note on “unreset”: jacquard marks all DFF Qs as X at cycle 0; a DFF
with a connected reset resolves once reset asserts. For a static backward
query the useful classification is “is this DFF reset-connected?” — emitted
as unreset-dff only when no reset/set pin is wired. This is a static
over-approximation (documented in the command help).
Reverse-reachability from the net through find_drivers, continuing
through DFF data pins (the same data-path-through-registers walk as
logic_cone(through_regs=True) restricted to _DFF_DATA_PINS — reuses
PR1’s corrected _is_register). Clock/reset pins are not followed.
X-source set:
With --xsources: use the manifest’s authoritative net set +
classification (covers undriven-input, which the netlist alone can’t
classify).
Without it: classify natively from the netlist — DFF-Q nets (cells
_is_register matches) and SRAM read-port nets — and emit
undriven-input as unknown (warn that --xsources is needed for the
driven-set complement). Genuinely-undriven internal nets (PR1’s
[undriven — X-source] leaves) are reported as candidate roots.
Frontier: BFS outward; when a reached net is an X-source, record it and
stop expanding past it (it is a root). Non-source nets keep expanding.
Frontier = X-sources reachable without passing through another X-source.
Report: frontier X-sources, classified and grouped by kind, nearest
first (BFS depth). --emit-trace <file> writes them as a --trace-signals
list (one net per line, # kind comments) so a confirming --xprop run is
one command.
Rust: xsources unit test on a small netlist+config — assert DFF-Q,
SRAM-read, and undriven-input nets appear with correct kind; reset-
connected DFFs are notunreset-dff.
Python: synthetic netlist with a known X-source behind two logic
levels and a reset-defined path; assert the frontier finds the source, the
classification matches the manifest, --emit-trace output round-trips, and
the “no manifest” mode warns.
Integration: xsources on tests/timing_test/minimal_build →
xroots --emit-trace → feed back to cosim --xprop --trace-signals and
confirm the surfaced wires carry X (smoke test, gated on Metal availability).
Decisions:0021 (Revised 2026-07-03 —
synth folds into sim/cosim, no build command), aligns with
0019 (descriptor-supplied logic + timing) and
0018 (wasm distribution).
Predecessors: #167 shipped the src/synth.rs embedded-Yosys engine and a
jacquard build subcommand + synth feature (CI green, draft). This plan keeps
the engine and removes the standalone command, wiring synthesis into the
simulation input path instead.
Behavioral RTL becomes a first-class one-command input:
jacquard sim design.v in.vcd out.vcd N (and the cosim equivalent) detects
behavioral RTL, synthesizes it transparently to an aigpdk netlist (cached), and
simulates — no separate build step, no Python, no external toolchain. A
pre-synthesized netlist continues to simulate unchanged.
Parse succeeds → enumerate instantiated cells, test against the built-in
stdcell recognizers (the is_*_stdcell helpers introduced by #160 / Decision 0019):
All recognized → gate-level, built-in PDK → simulate directly.
Logic + timing come from the embedded descriptor (--corner); no .lib.
Any unrecognized → gate-level, unknown PDK → error, listing the
unrecognized cell types: “gate-level netlist with unrecognized cells —
pass --cell-descriptor <path>” (Decision 0019 D8). Do not synthesize.
Parse fails → treat as behavioral → synthesize (embedded Yosys) →
parse the result → simulate. If synthesis also fails, surface both the
structural parse error and the Yosys diagnostics (a genuine netlist syntax
error must not masquerade as “tried to synthesize your RTL”).
Overrides:--rtl forces the synth path; --netlist forces
direct-structural (skip detection). Transparency: always print the decision,
including the QoR tier on the synth path (e.g.
design.v: behavioral RTL → synthesized [YoWASP Yosys, functional QoR] → <cache>).
Detection is a heuristic on a structural-only parser; the explicit flags are
the escape hatch. Pure net-alias assigns parse structurally (they occur in
gate-level too) — only real behavioral constructs (always, arithmetic/logic
assign, if/case) trip the parse and route to synth. Confirm the exact
is_*_stdcell API against src/ during Phase 1 (do not assume names).
Extract the synthesis invocation from cmd_build into a reusable
synth::synthesize(design: &Path, opts) -> Result<PathBuf> returning the
cached gate-level .gv path. Cache keyed by content hash of (design sources +
synth script + yosys.wasm hash), under $XDG_CACHE_HOME/jacquard alongside the
compiled-module cache.
Use read_slang for the SV frontend. The pinned yowasp-yosys 0.64.0.0.post1131 wasm bundles yosys-slang (verified: read_slang +
495 slang symbols in the 39 MB module) — a near-complete SV-2017 elaborator,
far beyond built-in read_verilog -sv. Update synth_script to front SV input
with read_slang (fall back to read_verilog -sv if read_slang errors or is
absent in an older wasm — probe help read_slang once and cache the result, so
the on-ramp degrades gracefully on a pre-slang module).
Add the input-dispatch classifier (above) to the shared sim/cosim netlist
load path.
Add CLI flags to SimArgs / CosimArgs: --emit-synth <path> (dump the
intermediate netlist), --rtl, --netlist.
DeleteBuild, BuildArgs, cmd_build from src/bin/jacquard.rs.
Graceful message when the binary is built without --features synth and given
behavioral input: point at the synth-enabled build / release binary.
Exit:jacquard sim counter.v in.vcd out.vcd 1 --features synth runs
end-to-end (behavioral → waves); the three #167 validation designs (counter,
assert_test, mem_test) simulate from RTL directly; a gate-level fixture still
simulates with the feature off; an unknown-cell netlist errors toward
--cell-descriptor; --emit-synth writes a parseable .gv; no build
subcommand exists.
Add --features synth to release.yml and user-acceptance.yml so shipped
binaries include the on-ramp.
Add a CI job to ci.yml: cargo build --release --features synth + a
jacquard sim <behavioral.v> smoke run. Resolve yosys.wasm sourcing in CI
(install a pinned yowasp-yosys wheel and discover it, matching local
verification; or fetch the release asset once increment 2 lands).
Confirm --features synth compile time is tolerable in CI (cache the cranelift
build if needed).
Exit: CI proves sim consumes behavioral RTL; release/UA binaries contain
the feature; green on the branch.
Rewrite the RTL flow in docs/getting-started.md, docs/installation.md, and
any synthesis-flow.md cross-links to the single-command story.
Write docs/accepted-rtl.md — the accepted behavioral-RTL surface. The
honest framing: what sim/cosim accept as behavioral input is exactly what
the embedded YoWASP Yosys frontend synthesizes — and that frontend bundles
yosys-slang (read_slang, verified in the pinned 0.64 wasm), plus the
project’s techmaps and minus testbench-only constructs. Document:
Supported (delegated to Yosys + slang): synthesizable Verilog-2005 and a
broad SystemVerilog-2017 surface via slang (packages, interfaces,
structs/enums, always_ff/always_comb, parameters, advanced generate,
memories) — not the narrow built-in read_verilog -sv subset.
Project-specific mappings: immediate assertions → GEM_ASSERT
(--strip-assertions removes via chformal); $display → GEM_DISPLAY;
inferred memories → RAMGEM via memlib_yosys.txt.
Known limits:concurrent-SVA → checker synthesis is partial (a Yosys
formal-flow bound, independent of slang’s parsing; #106/#107);
testbench-only constructs (#delays, most initial, TB $display) are
dropped by synthesis, not simulated.
State plainly that the authoritative accepted-surface is the empirical
coverage table (Phase 4), not this prose — prose is the orientation.
Cross-link from getting-started.md.
Fix stale jacquard map references (CLAUDE.md, docs) → dump-paths.
docs/plans/archive/cell-model-ir.md status — reconciled with main during rebase:
its status is now “Largely delivered” (C3 + the D5 L4-from-descriptor runtime
wiring landed on main via de8255f3), so no further edit here.
Update docs/handoffs/adr-0021-behavioral-rtl-handoff.md (or resolve/fold it
per handoff-discipline once this ships).
Exit: No doc describes a jacquard build command or the old multi-tool
ceremony; map/build stale refs gone.
Load exception-handling wasm — newer yowasp-yosys wheels build the wasm
with the WebAssembly exception-handling proposal, which our wasmtime engine
(Engine::default(), no gc feature) rejects: “exception refs not supported
without the exception handling feature.”Config::wasm_exceptions(true) exists
but is #[cfg(feature = "gc")], so it needs the gc feature and a spike
confirming wasm-EH actually runs yosys (not just parses). Until then CI + docs
pin yowasp-yosys==0.64.0.0.post1131. Removing that pin is the exit criterion.
Fetch yosys.wasm from GitHub release (increment 2, Decision 0018): publish the
pinned wasm as a Jacquard release asset; first behavioral run fetches to cache
sha256-verifies.
--synth-target sky130|gf180 — synthesize to a real PDK (uses #160
descriptors) for timing-accurate on-ramp runs.
Empirical SV/Verilog coverage table — turn the docs/accepted-rtl.md
prose surface into a measured pass/fail matrix by running
SymbiFlow/sv-tests (or a curated
subset) through the embedded YoWASP Yosys frontend and recording which
constructs synthesize. Because the accepted surface is the Yosys frontend,
this is the only trustworthy way to enumerate it (vs hand-claiming a feature
list). Publish the table into docs/accepted-rtl.md; automatable as a CI job
so the coverage claim stays current as the pinned yosys.wasm moves.
Wire L4-from-descriptor onto the runtime timing path — landed on
main (de8255f3, Decision 0019 D5): all runtime timing paths now source L4 from
the cell-model-IR descriptor, so --corner on-ramp timing for built-in PDKs is
live. No longer a follow-up.
Project manifest (Jacquard.toml) — collapse the positional
sim netlist in.vcd out.vcd N arg soup and hold synth-target/top/sources,
referencing the existing sim_config.json. Its own decision when scheduled.
Status: Implemented — historical record. All five work streams
(WS1 schema, WS2 opensta-to-ir producer, WS3 SDF parser deletion +
interim runtime hook, WS4 diff harness + CI, WS5 parser-success
assertions) shipped through 2026-05-02. All eight exit criteria are
met. Ongoing scheduling for timing-model fidelity work has moved to
post-phase-0-roadmap.md. The per-WS detail and embedded status
markers below are preserved for the implementation record.
Deliver the minimum viable infrastructure to enforce Jacquard’s timing correctness contract:
A stable timing intermediate representation (IR) for SDF-equivalent annotations.
An OpenSTA-driven subprocess converter that produces IR from the same inputs Jacquard consumes.
A converter that produces IR from Jacquard’s existing SDF parser output.
A CI diff harness that fails loud on converter disagreement.
Parser-success assertions on the SDF and Liberty paths.
After phase 0, Jacquard’s timing pipeline has an enforced external reference. Silent failures (zero-match SDF, mis-scoped hierarchical prefixes, unexpected cell drops) surface as CI failures rather than correctness regressions detected in the field.
A representative test design committed to the repo with inputs needed for both Jacquard and OpenSTA (.v + .lib + .sdf minimum; .spef if available). Candidate: tests/timing_test/inv_chain_pnr or the MCU SoC subset, whichever is smaller for first-pass iteration.
OpenSTA available on developer machines and CI runners (installation documented).
Done. Shipped as the timing-ir crate (508baaf initial,
2432d41 simplification). Schema at
crates/timing-ir/schemas/timing_ir.fbs; per-DFF CLOCK_ARRIVAL
records added later in c403cc8 (Pillar B Stage 1, beyond original
WS1 scope). JSON round-trip verified via crates/timing-ir/tests/.
Produce the FlatBuffers schema (schemas/timing_ir.fbs) and generated Rust bindings.
Fields (minimum viable; extend only with written justification):
SchemaVersion { major, minor, patch }.
Corner { name, process, voltage, temperature }; IR holds a list of corners.
CornerValue { corner_index, min, typ, max } for multi-corner floats.
build.rs integration for code generation (or checked-in generated Rust with a flatc pin).
A tiny timing-ir crate exposing read/write helpers.
JSON round-trip via flatc --json verified in a unit test.
Scope guard: if you find yourself adding fields that represent computed timing graphs, cell electrical characterisation, or netlist structure, stop and re-read Decision 0002.
Per Decision 0006, opensta-to-ir is a shipped preprocessing tool, not merely a validation helper. Post-release it remains as an alternative preprocessing path for users who want OpenSTA-computed timing.
A Tcl script runnable by OpenSTA that loads Liberty + Verilog + SDF + (optionally) SPEF + SDC, then emits a machine-readable dump of timing annotations.
A production-quality standalone Rust binary opensta-to-ir that parses OpenSTA’s dump and emits timing IR (binary + JSON sidecar). Stable CLI, documented exit codes, clear diagnostics, man-page-worthy --help.
Invocation wrapper handling OpenSTA subprocess lifecycle, stderr capture, exit-code checking, and error propagation up through opensta-to-ir’s own exit code.
Assertion: if OpenSTA reports < expected-count cells, exit non-zero with a clear diagnostic.
Ships as part of Jacquard’s release artefacts (binary distributable, documented in user-facing docs).
--liberty NAME=PATH syntax), and the integration test
aigpdk_dff_emits_per_corner_timing_values. The historical scope
notes below are kept for reference but are no longer “open work”.
The IR schema (crates/timing-ir/schemas/timing_ir.fbs) supports per-corner TimingValue vectors today, but every record lands in the IR with a single TimingValue keyed at corner_index = 0. Both producer (opensta-to-ir) and consumer (flatten.rs) treat the world as single-corner. Multi-corner support has three pieces:
Producer (Tcl + Rust binary):
crates/opensta-to-ir/tcl/dump_timing.tcl: replace single read_liberty + hardcoded CORNER 0 default tt 1.0 25.0 with OpenSTA’s define_corners + per-corner read_liberty -corner $name. The existing arc / setup-hold / wire / clock-arrival walks already key by (cell, …); wrap each in a per-corner loop and call [edge arc_delays $arc -corner $c]. Verify the exact -corner syntax against the locally built OpenSTA before relying on it (similar to the vertex_worst_arrival_path probe done for clock arrival in commit c403cc8).
crates/opensta-to-ir/src/main.rs: rework --liberty PATH to accept --corner NAME=PATH[,V=…,T=…,P=…] repeats. Validate at least one corner.
crates/opensta-to-ir/src/builder.rs: today each ARC / SETUP_HOLD / INTERCONNECT / CLOCK_ARRIVAL line lands as one IR record with one TimingValue. Multi-corner emits multiple lines per (cell, driver, load, corner_index) from Tcl; the builder dedupes them into one IR record carrying a [TimingValue] vector. Mechanical.
Consumer (jacquard root):
Add --timing-corner <NAME> to SimArgs / CosimArgs in src/bin/jacquard.rs; resolve to an index by walking ir.corners().
Replace flatten.rs::ir_corner0_max(...) (used in ~5 sites) with ir_corner_max(idx). Thread the resolved index through load_timing_from_ir.
Fixture: sky130 ships multi-corner Liberty (tt_025C_1v80, ss_-40C_1v62, ff_125C_1v95) on disk via volare under ~/.volare/... on dev machines that have run the cosim work. Wire two corners against the existing DFF / chain integration tests for a synthetic-but-real fixture; no external decision is needed before starting.
Land in this order: fixture probe (~hour, verifies the OpenSTA Tcl -corner flag works as expected) → producer (Tcl + binary + builder) → consumer (CLI + flatten plumbing) → integration test exercising both corners. The risk concentrates in the first hour; everything after that is mechanical.
Per Decision 0006, Jacquard’s hand-rolled SDF parser is deleted in Phase 0 rather than maintained through later phases. The runtime gains a new IR input path; the old SDF input path becomes an interim convenience wrapper over WS2.
Delete src/sdf_parser.rs and the SDF→Jacquard-internal-types code path. Remove all direct consumers.
Add jacquard sim --timing-ir <path> as the canonical post-release timing input. Loads a pre-converted timing IR file, consumes it into the simulator’s internal structures.
Retarget the existing --timing-sdf / --enable-timing CLI behaviour: when SDF is provided, jacquard sim subprocesses opensta-to-ir internally to produce IR on the fly, then consumes it. Code site tagged “INTERIM per Decision 0006; removed before first release.”
Verify no remaining imports of the deleted module. Verify all existing tests that previously used the hand-rolled parser now pass via the interim hook or via checked-in IR fixtures.
No runtime behaviour regression on Jacquard’s timing-related regression suite; any design that currently works must still work after WS3.
Reframed 2026-05-02; corpus + runner shipped 2026-05-02. The original WS4 was framed as “WS2 vs WS3 IR diff” (OpenSTA-derived against Jacquard’s hand-rolled SDF parser-derived). WS3 deleted that parser; the diff has only one side now. Three reframings were considered: Option A (golden-IR regression corpus for opensta-to-ir) was chosen as the Phase 0 closure; Option B (end-to-end behavioural diff cxxrtl/CVC vs Jacquard cosim event traces) belongs in timing-validation.md as a Phase 1+ extension; Option C (cross-tool diff vs a future native Rust SDF→IR parser) is Phase 3 work per Decision 0006.
Deliverables:
A test binary timing-ir-diff that reads two IR files and produces a structured diff (missing arcs, mismatched delays past tolerance, mismatched provenance). Shipped in crates/timing-ir/src/bin/timing-ir-diff.rs.
OpenSTA vendored as a git submodule at vendor/opensta/. Not built from Jacquard’s build at runtime; present for CI version pinning, the opensta-to-ir integration tests, and stress-corpus access (see Decision 0005). Shipped.
A primary regression corpus at tests/timing_ir/corpus/ — Jacquard-specific designs with checked-in expected.jtir (and a expected.json sidecar via flatc --json for human-readable diffs). Shipped 2026-05-02 with the seed entry aigpdk_dff_chain (a minimal aigpdk DFF + AND with back-annotated wire delay; covers ARC + SETUP_HOLD + CLOCK_ARRIVAL + INTERCONNECT in a self-contained fixture). Sky130 entries (inv_chain_pnr, mcu_soc subset) remain to be added — the inputs exist under tests/timing_test/, but a CI strategy for installing the sky130 Liberty (likely volare) lands with them.
A stress corpus at tests/timing_ir/stress/ — a manifest file listing paths into vendor/opensta/<test-tree-subdir>/. Run nightly or pre-release. Exit criterion: no crashes, no hangs, no malformed IR; numerical agreement with OpenSTA not required. Manifest format specced in tests/timing_ir/stress/README.md; entries pending.
A regression test that, for each design in the primary corpus, runs opensta-to-ir on its inputs and diffs against expected.jtir via timing_ir::diff::diff_irs with the per-design tolerance from manifest.toml. Shipped as crates/opensta-to-ir/tests/corpus.rs::corpus_designs_match_golden_ir. Skips gracefully when OpenSTA isn’t built; fails loud with a structured diff when there’s a mismatch.
A regenerate-goldens helper for the OpenSTA-pin-bump workflow: bump submodule, run regen, review the diff, commit golden + submodule together. Shipped as scripts/regenerate-corpus-goldens.sh. Iterates tests/timing_ir/corpus/*/manifest.toml, runs opensta-to-ir per entry with the manifest-specified flags, refreshes both expected.jtir and the expected.json sidecar via flatc --json. Accepts entry names as positional args for targeted regen.
A diff-machinery mutation test that perturbs a known-good IR and asserts timing-ir-diff flags it. Shipped in crates/timing-ir/tests/diff.rs: delay_mismatch_past_tolerance_detected, delay_mismatch_within_tolerance_is_clean, arc_only_in_a_detected, arc_only_in_b_detected.
CI hookup landed 2026-05-02. The opensta-to-ir-tests job in .github/workflows/ci.yml builds CUDD (cached), builds OpenSTA via scripts/build-opensta.sh (cached on the submodule SHA), and runs cargo test inside crates/opensta-to-ir — covering the corpus regression test, the CLI tests, and the OpenSTA-driven integration tests on every PR. scripts/build-opensta.sh was extended to honour a CUDD_DIR env var so the CI job can hand it the source-built CUDD location without bypassing the script.
What this catches: OpenSTA upstream regressions, dump-format / Tcl-driver regressions, accidental schema-breaking changes in timing_ir.fbs, builder bugs in opensta-to-ir/src/builder.rs, and the diff machinery itself (via the mutation tests that perturb an IR and assert timing-ir-diff flags the perturbation).
What this doesn’t catch: behavioural divergence between Jacquard and a reference simulator. That’s timing-validation.md’s job (CVC/iverilog event-trace comparison) — the mcu_soc/sky130 90/90 reference match is the current one-design instance, generalisable in Phase 1+.
Done. Both halves shipped pre-this-section being marked.
Deliverables (all live):
Assertions in Jacquard’s Liberty parsing code: non-zero cells parsed on non-empty input. Implemented as TimingLibrary::parse (src/liberty_parser.rs:297-309); rejects with a clear diagnostic naming the input byte count and pointing at the explicit override.
Assertions in opensta-to-ir (WS2): non-zero IOPATHs / timing arcs resolved on non-trivial SDF input. Implemented as the --min-arcs N CLI flag (default 1) in the binary (crates/opensta-to-ir/src/main.rs:71-77, :112-121); exits with code EXIT_MIN_ARCS_FAILED = 3 (see :17) and a diagnostic naming the produced count, the threshold, and the override flag.
A way to override thresholds for intentionally-empty test inputs: TimingLibrary::parse_unchecked (src/liberty_parser.rs:316) for the Liberty path, --allow-empty-parse flag for the opensta-to-ir path.
Tests covering both halves: liberty_parser::parse_rejects_library_input_with_zero_cells and parse_unchecked_accepts_zero_cell_library; opensta-to-ir::cli::cli_min_arcs_failure_exit_3 (covers both the failure and the --allow-empty-parse override).
(Original-plan assertions for Jacquard’s SDF parser are obsolete — WS3 deleted the parser they were to guard.)
Schema round-trip (WS1). Construct a small IR in Rust, serialize to binary, deserialize, assert equality. Same for JSON.
OpenSTA converter unit tests (WS2). For a hand-crafted tiny design, invoke the converter, assert IR contents match expectation.
Jacquard converter unit tests (WS3). Same, on the same tiny design, through Jacquard’s parser.
Corpus diff (WS4). For each design in the primary corpus, freshly produced opensta-to-ir output diffs clean against the checked-in golden expected.jtir within per-design tolerance.
Parser-success assertion tests (WS5). Feed empty Liberty, empty SDF, and non-empty-but-no-match Liberty. Each should fail loud with a clear diagnostic, not proceed silently.
Tolerances:
Delay values: ±5% or ±5 ps absolute floor, whichever is larger. Rationale: matches the existing timing-validation.md convention; per-design overrides allowed via manifest.toml.
Missing arcs: zero tolerance. Every arc in the golden IR must appear in the freshly produced one (and vice versa).
Phase 0 is complete when all of the following hold:
✅ schemas/timing_ir.fbs checked in (crates/timing-ir/schemas/timing_ir.fbs); round-trip unit tests in crates/timing-ir/tests/.
✅ opensta-to-ir binary production-quality with stable CLI, documented exit codes, primary-corpus support. See ws2-opensta-to-ir.md (Implemented).
✅ src/sdf_parser.rs deleted; --timing-ir <path> canonical; --timing-sdf is a subprocess wrapper over opensta-to-ir (per Decision 0006 § Amendment, the shipping mechanism — Phase 3 native Rust parser deferred indefinitely). See ws3-delete-sdf-parser.md (Implemented).
✅ OpenSTA vendored at vendor/opensta/ (Decision 0005).
✅ timing-ir-diff runs in CI on the primary corpus (opensta-to-ir-tests job), passes cleanly, fails loud on regressions. Mutation tests in crates/timing-ir/tests/diff.rs.
✅ Parser-success assertions live on both halves: TimingLibrary::parse and opensta-to-ir --min-arcs. See WS5 above.
✅ No regression observed in Jacquard’s timing-related tests after WS3 cutover.
✅ timing-validation.md carries the forward-pointing note (line 3) explicitly stating its ±5% convention will be superseded by timing-correctness.md once Phase 0 ships. Phase 0 has shipped; that supersession is now effective in practice (the corpus tolerance is set per-design via manifest.toml). Removing the in-doc note is a small follow-up if anyone authoring against the page would benefit.
Native Rust SDF→IR converter. The hand-rolled parser is removed in Phase 0 WS3 (per Decision 0006); the native Rust replacement is Phase 3 work, deferred indefinitely per Decision 0006 § Amendment (no longer release-gating). SDF input ships via the opensta-to-ir subprocess wrapper. See post-phase-0-roadmap.md § Phase 3 for revival triggers.
OpenTimer integration. Depends on the spike; tracked in ../architecture/decisions/spikes/opentimer-sky130.md and its resulting phase-1 plan.
Private PDK test track. Tracked in Decision 0004; plumbing deferred to its own phase.
SPEF IR. Separate from timing-annotation IR per Decision 0002.
Licensing verification on vendored OpenSTA corpus. Per-file check needed before inclusion. May reduce corpus size if restrictive; acceptable.
FlatBuffers build integration friction. If build.rs codegen causes cross-compilation or CI issues, fall back to checked-in generated code with a documented flatc version. Pick one approach and stick to it; flip-flopping is worse than either option.
Tolerance tuning. Initial ±5% may prove too loose (hides bugs) or too tight (false positives from numerical differences). Plan to re-tune after first real-design data arrives.
WS3 cutover risk. Deleting the hand-rolled SDF parser risks regressing designs that depend on behaviour it currently provides. Exit criterion 7 requires a clean regression run before WS3 is considered complete. If coverage gaps emerge, walk-back options per Decision 0006 apply: add dialect shims to opensta-to-ir, or (now that Phase 3 is deferred) keep the hand-rolled parser available behind a feature flag until dialect parity is reached.
OpenSTA dialect coverage. OpenSTA may not accept every SDF dialect Jacquard’s hand-rolled parser has been patched to handle. Such cases are tracked as either opensta-to-ir post-processing fixes or upstream OpenSTA contributions. Under no condition is the fix to reinstate the hand-rolled parser unless walk-back per Decision 0006 is formally triggered.
Status: Implemented — historical record. All five phases (2.1–2.5)
plus Pillar B Stage 1 (per-DFF CLOCK_ARRIVAL records) and release
hardening (WS-RH.1 OpenSTA version probe) have shipped. The crate
lives at crates/opensta-to-ir/. Current scheduling for further
timing-model fidelity work is tracked in post-phase-0-roadmap.md.
Deliver a production-quality preprocessing tool that consumes a design’s timing inputs and emits a timing-ir file suitable for downstream Jacquard consumption. End-to-end:
The Rust CLI invokes OpenSTA as a subprocess, writes the Tcl driver script to a temp directory, runs sta -f $tmpdir/dump.tcl, captures the dump file, and converts to IR. The Tcl driver lives at crates/opensta-to-ir/tcl/dump_timing.tcl and is embedded in the binary via include_str!() so the binary is self-contained at runtime — no separate Tcl file needs to ship alongside it.
OpenSTA is located via scripts/build-opensta.sh --print-binary first (the canonical install path for the vendored submodule), then falling back to a PATH lookup, then --opensta-bin <PATH> override.
Reasons for this shape:
OpenSTA’s structured Tcl API (get_timing_edges, get_timing_arcs_from, etc.) gives access to OpenSTA’s internalised timing graph directly. Walking it is simpler than parsing OpenSTA’s SDF output back through a second-generation parser.
The Tcl script is the only OpenSTA-specific code; the Rust side is format-only and can later be reused with other producers (Phase 3 native Rust parser, future OpenTimer adapter).
A simple line-oriented record format. Each line is one annotation. Fields are tab-separated. Strings with tabs/newlines are quoted with simple "..." and \t/\n escaping. Header / footer lines mark the document.
Why line-oriented (not JSON): Tcl emits this trivially with puts. Rust parses it with a BufReader line-at-a-time, no streaming-JSON parser. Mismatched lines fail loud at the unit level, not after parsing 100MB of nested JSON.
The format is a private interface between the bundled Tcl script and the bundled Rust binary — both ship together in one release artefact. We reserve the right to change the format any time as long as both sides update.
opensta-to-ir [OPTIONS] --output <PATH>
Inputs (at least one liberty + one verilog required):
--liberty <PATH>... One or more Liberty files (-r overlay supported by OpenSTA).
--verilog <PATH>... One or more Verilog netlists.
--sdf <PATH> Optional. Back-annotated delays.
--spef <PATH> Optional. Parasitics; required for SPEF-based delay calc.
--sdc <PATH> Optional. Constraints (clocks, input delays).
--top <NAME> Top-level module name. Required.
--corner <NAME>... Corner name(s). Default: "default".
Output:
--output <PATH> IR binary output path (.jtir).
--json <PATH> Optional. JSON sidecar via flatc round-trip.
Behaviour:
--opensta-bin <PATH> Override the OpenSTA executable path. Default: probe via
`scripts/build-opensta.sh --print-binary`, then fall back to PATH.
--keep-tmp Keep the Tcl script and dump file in $TMPDIR for debugging.
--min-arcs <N> Fail if fewer than N timing arcs are emitted. Default: 1.
--allow-empty-parse Disable the --min-arcs check. For test fixtures only.
--strict-tcl Treat OpenSTA Tcl warnings as errors.
-v, --verbose Echo OpenSTA's stderr to ours. Default: capture and replay only on failure.
Exit codes:
0 IR produced successfully.
1 OpenSTA returned an error.
2 Tcl dump format error or IR-build failure.
3 Parser-success assertion failed (--min-arcs not met).
4 Argument validation error.
Internal flow:
Validate args (required files exist, top name non-empty).
Locate OpenSTA binary; verify version is in supported range.
Tcl script calls define_corners once with the union, then iterates foreach corner [get_corners] { ... } and emits CORNER + ARC/INTERCONNECT/SETUP_HOLD lines tagged with the corner index.
Single-corner designs use one entry — same code path, no special case.
PVT extraction (process / voltage / temperature) — OpenSTA exposes these via Liberty’s operating conditions. Tcl extracts via the corner’s pvt object. If unavailable, process="?", voltage=0.0, temperature=0.0.
Per phase-0-ir-and-oracle.md WS5: “Assertions in opensta-to-ir: non-zero IOPATHs / timing arcs resolved on non-trivial SDF input. Exit non-zero with a clear diagnostic when below threshold.”
Implementation:
--min-arcs <N> flag with default 1.
After IR is built, count TimingArc records in the buffer.
If below threshold and --allow-empty-parse was not passed, exit code 3 with message: opensta-to-ir: produced N timing arcs (--min-arcs <M>); use --allow-empty-parse for empty-fixture tests.
Liberty parser-success assertion already lives in Jacquard’s TimingLibrary::parse (see commit 5db131e) — opensta-to-ir invokes OpenSTA’s own Liberty reader rather than Jacquard’s, so it surfaces missing-cell issues via OpenSTA’s exit status (not our concern at this layer).
inv_chain_pnr (already in tests/timing_test/): smallest design with real SKY130 cells and SDF. Verify single arc per inverter, correct rise/fall, single corner.
MCU SoC subset: representative of the real Jacquard flow. Verify the count of arcs matches a known baseline; spot-check a handful of arrival times against report_timing output.
Multi-corner synthetic: hand-built tiny design with ss/tt/ff Liberty corners, verify the IR carries 3 corner records and 3 sets of values per arc.
Unit tests (Rust): dump-format parser tested against synthetic dump strings (no OpenSTA needed).
Integration tests (Rust + OpenSTA): invoke the binary against committed fixtures, diff the resulting IR against golden IR via timing-ir-diff. Each integration test gates itself on scripts/build-opensta.sh --print-binary succeeding — when the OpenSTA binary is unbuilt, tests skip with a clear “run scripts/build-opensta.sh” message rather than failing. CI runs them after building OpenSTA via the script.
Failure-mode tests: missing OpenSTA, malformed Tcl dump, zero-arc input, missing required argument — each surfaces the expected exit code.
A new CI job runs opensta-to-ir on each tests/timing_ir/corpus/<name>/inputs/ and diffs against expected.jtir via timing-ir-diff. Fails loud on diff or exit-code regression.
CI corpus integration; golden-IR fixtures for representative designs.
WS4 corpus job in CI; WS2 task complete.
✅ Shipped (90558bb). Runner: cargo test -p opensta-to-ir corpus.
Beyond original WS2 scope:
Pillar B Stage 1 — per-DFF CLOCK_ARRIVAL records (c403cc8). Adds clock arrival times to the IR so downstream consumers can compute per-DFF setup/hold margins without re-running OpenSTA. Test: dff_with_sdc_clock_emits_clock_arrival. Tracked separately in post-phase-0-roadmap.md.
Release hardening WS-RH.1 — hard-fail on missing or too-old OpenSTA, with version probe and usage diagnostics (c9c393b). Tests: locate_accepts_min_tested_version, locate_flags_newer_than_tested. Tracked in post-phase-0-roadmap.md § Release hardening.
WS3 (delete src/sdf_parser.rs + wire interim runtime hook) was unblocked once Phase 2.3 minimum landed and has also shipped — see ws3-delete-sdf-parser.md.
OpenSTA version pinning — Resolved by WS-RH.1 (c9c393b). Binary probes OpenSTA’s version_string, accepts a [MIN_TESTED, MAX_TESTED] range, prints a usage diagnostic with the supported range on mismatch.
OpenSTA installation — Resolved. scripts/build-opensta.sh ships with --print-binary for the dependency probe; integration tests skip cleanly when the binary isn’t built. Documented in the script’s --help and the post-Phase-0 roadmap.
Tcl-script versioning — Resolved. # format-version: 1 header check is enforced in dump.rs; the binary refuses unknown versions with an explicit error.
Conditional arcs (SDF COND) — Partial. The condition field is plumbed end-to-end (dump format → Rust parser → IR builder), but the Tcl emission side does not yet populate it for conditional variants. Defer until a real design surfaces a COND arc that needs distinguishing.
Still open / deferred:
Long-running designs: streaming dump emission (Tcl flushing line-by-line, Rust incremental read) — defer until profiling on a real SoC shows memory pressure.
Strict Tcl error handling: --strict-tcl flag was specced but not implemented. Current behaviour captures all stderr and replays on failure; no warning-to-error upgrade path. Land if it becomes a real CI hygiene concern.
OpenSTA’s Tcl API is large and not all of it is documented. Some primitives we’ll need (e.g., per-corner delay values for a specific arc) may require digging through Sta.cc. Mitigation: budget time, lean on report_path text output as a fallback if the structured API proves opaque for a given query.
OpenSTA may be slow on big designs — the structured walk over millions of arcs is single-threaded. Mitigation: --keep-tmp for profiling, accept slow phase-0 runs, optimise later if it blocks CI.
Format drift between Tcl and Rust — both sides advance together; the format-version line plus version-mismatch fail-loud catches drift. Add a unit test that the Rust parser rejects an unexpected version line.
Status: Implemented — kept as historical record. Note: the “interim” / “pre-release-only” framing throughout this document describes the original Decision 0006 model. Per Decision 0006 § Amendment (2026-05-02), the runtime subprocess wrapper is now the shipping mechanism — Phase 3 (native Rust SDF→IR) is no longer release-gating. This document is preserved for the implementation phasing record; for current shipping intent see Decision 0006 § Amendment and post-phase-0-roadmap.md § Phase 3.
Phase: 0 (executes WS3 from phase-0-ir-and-oracle.md).
Predecessors: WS2 phases 2.1 + 2.3-minimum (delay arcs + setup/hold checks landed). Sufficient IR coverage for runtime cutover.
Decisions: 0002 (IR), 0006 (SDF preprocessing model + interim cutover; amended 2026-05-02).
Delete src/sdf_parser.rs and migrate src/flatten.rs’s timing-loading to consume the timing IR directly. Wire jacquard sim --timing-ir <PATH> as the canonical input path, and (per Decision 0006) keep --timing-sdf <PATH> working pre-release as a contributor-ergonomics convenience that internally subprocesses opensta-to-ir.
End state:
No hand-rolled SDF parsing in the Jacquard codebase.
Runtime SDF input still works (via internal subprocess) until first release.
flatten.rs consumes timing_ir::TimingIR<'_> for arc / setup / hold loading.
All flatten.rs tests that previously hand-built SDF strings are migrated to build IR fixtures via the timing-ir crate’s FlatBuffers builders.
src/flatten.rs — load_timing_from_sdf(...) is the only non-test consumer; iterates SdfFile.get_cell(path), uses SdfDelay for wire delays, TimingCheckType::Setup/Hold for check identification. ~200 lines of integration plus 7+ test fixtures that build SDF strings inline.
src/sim/setup.rs — translates --sdf-corner CLI string into SdfCorner and calls SdfFile::parse_file.
Index ir.timing_arcs() / ir.setup_hold_checks() by cell_instance (build a HashMap<&str, _> once).
cell.iopaths
Filter timing arcs by cell_instance == path.
cell.timing_checks
Filter setup/hold checks by cell_instance == path.
SdfDelay { rise, fall, ... }
TimingArc.rise_delay() / .fall_delay() (per-corner); take corner 0 max for now.
TimingCheckType::Setup / ::Hold
SetupHoldCheck.setup() / .hold() per record.
cell.interconnect_delays
ir.interconnect_delays() — empty until WS2.2 lands; tolerate.
The hierarchy-prefix detection (lines 1793-1820 of current flatten.rs) is independent of source format — same logic applies, just use IR’s cell_instance strings instead of SDF’s. Keep the heuristic.
Add --timing-ir <PATH> flag that loads IR directly via TimingIrFile::from_path.
Retarget --timing-sdf <PATH> (and the existing --sdf-corner) to: spawn opensta-to-ir as a subprocess, capture its IR output, call load_timing_from_ir. Mark the code site INTERIM per Decision 0006.
The interim hook needs Liberty + Verilog paths to feed opensta-to-ir; the jacquard sim CLI already takes those, so plumb them through.
Keep --sdf-corner for backward compat — the interim wrapper passes it as --corner to opensta-to-ir.
#![allow(unused)]
fn main() {
let sdf_content = r#"(DELAYFILE ... )"#;
let sdf = SdfFile::parse_str(sdf_content, SdfCorner::Typ).expect("...");
flat.load_timing_from_sdf(&aig, &netlistdb, &sdf, ...);
}
After cutover:
#![allow(unused)]
fn main() {
let ir_buf = build_test_ir(&TestIrSpec {
arcs: vec![ /* (cell, from, to, rise_max, fall_max) */ ],
setup_hold: vec![ /* (cell, d, clk, edge, setup, hold) */ ],
});
let ir = root_as_timing_ir(&ir_buf).unwrap();
flat.load_timing_from_ir(&aig, &netlistdb, &ir, ...);
}
A build_test_ir helper in flatten.rs::tests mirrors build_ir_with_arcs from crates/timing-ir/tests/diff.rs. Single source of truth would be nicer; for now duplicate it (deduplication is a future cleanup).
Add src/sim/timing_ir_loader.rs and flatten.rs::load_timing_from_ir (parallel to _from_sdf). No CLI surface, no deletions. Unit-test the new function with a small synthetic IR.
New function compiles + passes unit test; existing _from_sdf path still works.
3.2
Add jacquard sim --timing-ir <PATH> CLI flag wired to load_timing_from_ir. End-to-end test: pre-generate IR via opensta-to-ir, run jacquard sim --timing-ir, compare against the existing --timing-sdf baseline.
A representative timing test (e.g., one of the existing tests/timing_test/) produces matching VCD output via both paths.
3.3
Retarget --timing-sdf to subprocess opensta-to-ir internally, then consume IR. Tag the code site INTERIM per Decision 0006.
Existing --timing-sdf regression tests pass through the new path.
3.4
Delete src/sdf_parser.rs. Migrate flatten.rs test fixtures from SDF strings to IR builders. Migrate aig.rs test imports.
All cargo test --lib tests pass; src/sdf_parser.rs is gone; the only crate::sdf_parser:: reference is git log.
Each phase exits cleanly. Phase 3.4 is the irreversible deletion — gates on phases 3.1-3.3 having green CI on the migration tests.
Hierarchy separator: SDF uses ., OpenSTA’s default divider is /. Our IR’s cell_instance strings come from OpenSTA so use /. The flatten.rs hierarchy-prefix detection logic uses .. After cutover, the logic needs to use /. Verify by running on a hierarchical design (MCU SoC) before declaring 3.4 ready.
--sdf-corner semantics under IR: today this picks one of Min/Typ/Max from SDF triples. The IR has min/typ/max per TimingValue already; the corner selection becomes “pick which of the three to use” applied per-arc rather than per-file. Document the mapping.
Default-corner consistency: WS2 emits default as the corner name. Pre-existing Jacquard tests may not look at corner names — need to spot-check.
liberty_fallback semantics: today, for cells absent from SDF, we fall back to Liberty-computed delays. Under IR, OpenSTA-computed values are already in the IR’s arcs (as Origin::Computed). So liberty_fallback is potentially dead. Decide whether to drop it in 3.4 or keep as safety net.
Multi-corner (post-WS2.4): when WS2.4 lands, the IR will have multiple corners. flatten.rs currently picks one. Define the per-corner selection contract — explicit corner-name CLI flag, or default to a named corner.
flatten.rs test churn: 7+ test fixtures need rewrites. Each is a focused mechanical change but the bulk adds up. Mitigation: a build_test_ir helper standardizes the pattern.
Hidden-bug exposure: the existing SDF parser had quirks. The IR parser has different ones (or none). Migration may surface bugs that were latent. Treat any test failure during 3.4 as a real bug, not “just adjust the test.”
Hierarchy-separator regression: if not caught in phase 3.2 testing (which tests on a single design), it could land in 3.4 and break a hierarchical design that wasn’t previously regression-tested. Mitigation: include a hierarchical design in the 3.2 verification matrix.
Cutover timing: WS3 lands while WS2.2 (interconnects) and WS2.4 (multi-corner) are still pending. flatten.rs’s cutover assumes those will land later — test fixtures should not depend on interconnect delays or multi-corner behaviour for at-least-3.4 to pass.
If 3.4 surfaces blocking issues, Decision 0006 already permits deferring deletion: keep src/sdf_parser.rs alive but tagged LEGACY — superseded by IR consumer; remove before first release, and ship preprocessing-only for the interim. The runtime SDF subprocess wrapper covers the contributor ergonomics. The native Rust SDF parser rewrite (Phase 3 in the original phasing) is the durable replacement.
0011 — RAM port-mapping schema
(the port-mapping extension originally deferred by Decision 0010)
Issues:#67,
#80.
Driving designs: the wafer.space chip_top.pnl.v blocked on
gf180mcu_ocd_ip_sram__sram1024x8m8wm1, then the JTAG-DM workflow
in PR #78 surfacing the need for real RAM backing storage.
Originally scoped to one slice (Tier 1 + minimal Tier 2 — opaque
kind = "ram" with no port resolution). Expanded mid-flight when
the JTAG-DM workflow (PR #78) surfaced the need for explicit-port
RAMs with real backing storage:
--cell-library <PATH> CLI flag on jacquard sim,
jacquard cosim. Repeatable. Each path is parsed via
sverilogparse at startup; results merged into a runtime
LeafPinProvider extension.
<PATH>.cells.toml autoload + --cell-manifest <PATH>
override. TOML schema as in Decision 0010 § Tier 2. Required
field schema_version = "1.0". Per-cell kind discriminator,
v1.0 vocabulary.
New code path in aig.rs: after PdkVariant::classify falls
through (no built-in match), consult manifest. For
kind = "ram", allocate RAMBlock in opaque mode — outputs
routed to X-source slots, no port resolution.
Tests: TOML parsing unit tests; integration test exercising
a synthetic kind = "ram" cell through AIG construction + sim
(mini fixture, not the full tapeout design).
Doc update — docs/adding-a-pdk.md: new section “Adding
third-party IP via manifest”, linked from existing per-PDK
recipes.
Other kind values beyond what the tapeout fixture exercises
end-to-end (ram, plus filler if cheap parity demo). Adding
other kinds is data-only and can land per-need.
Migration of built-in sky130.rs / gf180mcu.rs classifiers to
manifest data. Stays in this codebase as the fallback.
aig.rs integration — manifest threaded through, new fallback path for kind = "ram" opaque mode. Add the compute_x_sources-style test exercising the new path.
P4
Smoke test against a representative reduced fixture; confirm jacquard sim clears gf180mcu_ocd_ip_sram_*. The full downstream-tapeout netlist is the real-world target but not in-tree.
P5
Doc update (adding-a-pdk.md); update gf180mcu-enablement.md § Follow-on cleanup to mark items 1/2/3 superseded by this work.
Each phase is its own commit. No squashing until the spike feedback
loop confirms shape.
Autoload path discovery: spec says foo.v → foo.cells.toml
sibling. Does that handle the multi-file library case (a.v +
b.v sharing one manifest)? Probably yes — autoload each
sibling, merge into the single CellManifest. Explicit
--cell-manifest flag still wins for users who want a single
consolidated file.
Conflict policy: if a cell name appears both in a built-in
classifier AND in a manifest, built-in wins (per Decision 0010
integration ordering). Warn on conflict to surface accidental
collisions.
Empty-library noise: parsing a .v file containing only
(* blackbox *) modules with no logic should succeed without
warnings, since that’s the expected shape for IP libraries.
Memory contents simulation for kind = "ram" in v1.0. Documented
in Decision 0010 § “kind = ram semantics in v1.0”.
Stable opaque-RAM port routing beyond “outputs are X-source
slots”. The set of outputs is what sverilogparse reports; if a
cell’s port list changes, the routing follows.
Update 2026-06-03: while extending to cosim we found the seed
template was broken for both paths — expand_states_for_xprop
cleared the X-mask for all input_map positions, which includes DFF-Q
feedback reads, so uninitialised DFFs read as known 0 and X never
surfaced even in sim. Fixed via vcd_io::xprop_xmask_template
(X at genuine X-sources only) plus output-slot seeding in run_cosim.
See the handoff and decision-0016 amendment. Phases 1, 2, 5, the seed fix,
phase 3 (undriven inputs → X: compute_x_capable_pins(treat_inputs_as_x)
gated by DesignArgs::xprop_undriven_inputs; xprop_xmask_template_cosim
seeds inputs X; state_prep + gpu_apply_flash_din clear the X-mask of
each bit they drive), phase 6 (end-to-end tests/xprop_cosim/
guards in CI, sim + cosim) and phase 4 (observe-kernel output-offset:
correct by construction — gpu_io_step reads the value half via
uart_params.state_size = effective_state_size; guarded by re-running the
APB3 + dual-UART cosims under --xprop and asserting identical decoded
output) are done. All phases of #95 are complete; the bidir
tristate-mux read Y = OE ? A : external is separately tracked as #96.
--xprop is now wired into bothsim and cosim. (Historically it
was sim-only and cosim always ran two-state, silently resolving
uninitialised DFF/SRAM and undriven inputs to 0 — false agreement
against 4-state RTL; that gap is what this plan closed.)
What the work built on (already existed and was reused):
The Metal simulate_v1_stage kernel is already X-capable
(sram_xmask buffer, xmask_state_offset, the X-mask read logic in
kernel_v1.metal). cosim dispatches the same kernel — no GPU core
change.
DesignArgs.xprop already threads into script.xprop_enabled (via
setup); cmd_cosim just hardcodes xprop: false.
Host machinery on the sim side: expand_states_for_xprop, the
sram_xmask shadow (init 0xFFFF_FFFF), split_xprop_states,
write_output_vcd_xprop.
So this is host-side reactive plumbing, not new kernel work.
X originates from four places; cosim must model all four:
Source
X when…
Mechanism
Uninitialised DFF
power-up, before first clocked write
expand_states_for_xprop seeds the X-mask half (as in sim)
Uninitialised SRAM
before first write to a cell
sram_xmask shadow init 0xFFFF_FFFF, carried across ticks
Undriven input pad
no model / constant / clock / reset drives it
input X-mask = X for every primary-input bit not in the driven set
Bidir pad input side
OE deasserted and nothing external drives it
per-edge: input X-mask = OE ? known : X, reading OE (the __oe observable)
The first two are sequential power-up X (Decision 0016’s original scope). The
last two are new — input/IO X-sources the sim static-VCD path never
had to model, and the reason this is more than “carry the sim machinery
over.”
Driven set (bits that are known, X-mask cleared): scheduler-driven
clock(s) + reset, each peripheral model’s driven_positions(), and
constant_inputs / constant_ports. The complement of the driven set
within the primary inputs is X.
Expanded the cosim state buffer via expand_states_for_xprop and
allocated the sram_xmask shadow (init 0xFFFF_FFFF), bound at
simulate_v1_stage buffer(7) through the dispatch chain.
No write_params change needed: is_x_capable / xmask_state_offset
are baked into the script at flatten time and state_size already
uses effective_state_size (so the layout scales automatically).
Init: every primary-input X-mask bit = X, except the driven set
(clock/reset/model/constants), which start known. The
expand_states_for_xprop template clears all inputs to known, so
Phase 3 re-marks the undriven inputs back to X.
Per edge: wherever state_prep / model ModelOverrides drive an
input bit, also clear that bit’s X-mask (driven ⇒ known). Undriven
bits stay X.
Bidir: no special handling in this issue. A bi_24t pad’s
core-read is modelled Y = PAD (tristate not modelled — aig.rs),
and the PAD net is an undriven primary input, so bidir reads fall out
of the generic undriven-input rule above as X — conservative and
safe (an unmodelled bidir read surfaces as unknown, never a false
0/1). The earlier “per-edge OE→input feedback with one-edge latency”
idea was wrong: the correct read is combinational
Y = OE ? A : external, which requires modelling the tristate mux in
the AIG. That is deferred to
#96; until it lands,
an OE=1 loopback reads pessimistic-X (safe).
gpu_io_step’s output reads (READ_OUT_BIT → states[state_size + …])
for UART, Wishbone, and bus-trace turned out to be already correct
under xprop: state_size here is uart_params.state_size, set to the
full effective_state_size, so states[state_size + word] indexes the
output slot and word < reg_io_state_size lands in the value half (value
is at the front of [value | xmask | …]). The earlier worry that “the
offset shifts” was unfounded — no Rust/kernel change was needed.
Guard added so it can’t silently regress: CI re-runs the APB3
bus-trace and dual-UART cosims under --xprop and asserts the decoded
transactions/bytes are identical to the two-state runs (both designs are
fully reset-driven, so phase-3 undriven-input X never reaches the traced
signals). If a future layout change made the observe reads hit the xmask
half, the decoded values would corrupt and the checkers would fail.
cosim emit path uses a write_output_vcd_xprop-equivalent so traced
nets and top-level IO emit x (not 0) where unknown; the bidir
__out/__oe split already exists and should reflect X correctly.
A small reactive test design with (a) an unreset register and (b) an
unconnected input pad — assert the output VCD shows x until each is
resolved (clocked write / model drive), 0/1 after. (Bidir
read-back correctness is deferred to #96.)
Where feasible, extend the CPU sanity_check_cpu_xprop parity to a
cosim scenario.
Bidir read-back is pessimistic-X until #96
models the tristate mux (Y = OE ? A : external). Safe (false-X, not
false-0); resolve by driving the pad’s external side with a model.
Observe-offset regression (Phase 4): the highest-risk interaction;
the bus-trace code is days old. Needs an explicit test under --xprop.
Performance: the state buffer doubles again on top of any timing
expansion; the VCD ring-buffer snapshot grows. Measure on a real
JTAG-replay run.
SRAM xmask carry: confirm the shadow persists correctly across the
batched/single-tick dispatch modes the cosim loop uses.
Amend Decision 0016: record the cosim extension and broaden the
X-source taxonomy to include undriven input pads + bidir-OE (the
original decision covered only sequential power-up X).
Fold the IO X-source rules into docs/selective-x-propagation.md.
Update the cosim --xprop help + docs/installation.md once shipped.
C1 (foundation + #130) — ✅ landed (#132). #130 is fixed by default: a 9T
netlist auto-selects its own 9T descriptor.
C1b (converter generalization) — ✅ landed (#155): corner from Liberty PVT,
ff internal-state-var (IQ). Surfaced by running the converter against the
proprietary GF130 (GF013BCD) library.
SKY130 .lib.json reader — ✅ landed (#155): SKY130 ships only
.lib.json, so liberty-parse reads it.
C3a (IHP SG13G2, zero-Rust new PDK) — ✅ landed (#155): vendored as a
sparse/shallow submodule + a generated descriptor, no ihp_*.rs.
C3.3 (cut over + drop runtime vendor/ dep) — ✅ landed (#160): the
runtime binary is self-contained for standard cells (load_pdk_models has no
runtime callers), and PdkVariant is deleted (grep -rn PdkVariant src/ = 0).
C4 (proprietary workflow + clear_preset) — 🟡 partial: clear_preset
set-dominant field landed (#155); docs/adding-a-pdk.md reframed around the
descriptor workflow (#155). The proprietary sequential end-to-end test and
the round-trip honesty fix remain (see Remaining follow-ups).
Goal: make Jacquard core consume a single, generated, JSON cell-model
IR carrying all per-cell-type facts of a library — L1 directions, L2
combinational AIG, L3 sequential/classification, and L4 timing
characterization — so any library, including proprietary ones the
authors can’t vendor, is selectable at runtime, and the per-PDK Rust, the
hardcoded vendor/ paths, and the runtime .lib parse all retire.
Each step is independently useful and de-risks the next: step 1 ships the
#130 fix and validates the format on real cells before the heavier L3
schema is committed; step 2 is the schema-fidelity work; step 3 is the
cleanup that the first two earn.
Relocate pdk_decomp into a shared lib. Define the cell-model-IR JSON schema for L1 directions + L2 combinational AIG (D3) — the corner of the schema needed to build the AIG. Write the converter crate (Liberty function/functional.v → IR). Redirect one PDK’s (GF180MCU) stdcell logic to consume the IR.
A 9T netlist (e.g. tests/jtag_minimal) simulates against its own generated 9T descriptor; result byte-identical to the current 7T-substituted run where they truly agree, and the round-trip logic check passes.
C2 — L3 sequential + L4 timing schema
Add the D4 sequential pin-role schema (clock+edge, D/next-state, Q, async set/reset+polarity, enable) + classification kinds, and the D5 L4 timing block (setup/hold, clock→Q, DFF/SRAM timing). Extend the converter to emit both from Liberty ff/latch and the timing groups it already parses, with L4 keyed by corner (one descriptor, all corners; mirrors the timing IR). Wire the consumer to replace the hardcoded DFF pin-name matches (src/aig.rs:2080-2260) and to read L4 from the IR instead of TimingLibrary::from_file, selecting the corner via --corner (default default_corner).
A design with sequential GF180/SKY130 cells simulates and times from the IR with no per-PDK Rust DFF handling and no runtime .lib parse; equivalence vs the current hardcoded + liberty_parser path (the oracle).
C3 — bundle + cut over + selection
Regenerate bundled descriptors for all built-in PDKs (AIGPDK/SKY130/GF180) in CI from the pinned vendored submodules and embed at build time — not checked in (D7); build-time generation replaces the build.rs pin-table step. Selection by descriptor-declared prefix + --cell-descriptor (D8). Drop the runtime vendor/ cell dependency, pdk_decomp, and liberty_parser::TimingLibrary from core; retire gf180mcu*.rs/sky130*.rs classifiers, PdkVariant, and the build.rs pin-table generators.
The existing PDK regression suite (incl. timed runs) passes consuming only build-time-generated descriptors; vendored cell submodules are no longer a runtime dependency of jacquard core, and CI regeneration is deterministic.
C3a — IHP SG13G2 (new PDK, zero Rust)
Vendor the IHP-Open-PDK SG13G2 stdcells as a submodule; add it to the bundle by generating a descriptor only — no per-PDK Rust (D7a). Exercises the Liberty-first path cleanly (every SG13G2 cell has function; every flop a full ff group with reset polarity).
An SG13G2 gate-level design simulates and times purely from its generated descriptor, with no ihp_*.rs in core — the worked proof that adding a PDK is no longer a Jacquard code change.
C4 (later)
A documented proprietary-library workflow: user runs the generator on their own Liberty, gets a descriptor, simulates — no Jacquard build. Honesty fix: the round-trip logic check replaces build.rs’s port-only assert_eq!.
docs/adding-a-pdk.md recipe; a synthetic “private” library exercised end-to-end in a test.
Discovered during implementation; folded here from the working handoff so they
survive its deletion. None reopen the runtime vendor/ stdcell dependency or
reintroduce PdkVariant.
Descriptor-drive AIGPDK DFF/DFFSR. AIGPDK’s two internal flops are
still on a literal-match path. Their descriptor L3 is well-formed
(clock=CLK/rising, next_state=D, async S/R active-low, reset-dominant); the
conversion is gate-covered (every AIGPDK/IHP fixture exercises them). Do this
first — it is bounded and safe.
Descriptor-drive the preserved edfxtp/icgtp/icgtn seq branches. The
legacy SET_B/RESET_B/RN/SETN sequential-wiring branches remain because
they still serve preserved cells: SKY130 edfxtp (its data-enable folds
into next_state via the ff state var IQ, so ir_seq_input_wireable
returns false) and GF180 icgtp/icgtn clock gates (emit no L3). No gated
design exercises these, so add a small edfxtp/icgtp fixture first, then
descriptor-drive them and delete the residual literals + the per-PDK
classifiers.
Gate IHP sequential. IHP ff cells are code-supported (previously
panicked) but ungated — no flop-bearing SG13G2 fixture exists (needs synthesis
infra to produce an IHP gate-level netlist). Add one to lock in IHP sequential.
Flat-module .v cross-check indexer. Commercial libraries (GF130, IHP)
ship one flat-module .v, so the D6 logic cross-check reports 0 models indexed
and does not run at generation (the descriptor is still emitted; correctness is
validated via simulation, and the converter now warns). Teach the indexer
flat-module .v so commercial descriptors are logic-cross-checked at
generation.
Descriptor-backed LeafPinProvider + delete build.rs pin-table gen.
Confirmed feasible (descriptor L1 covers every stdcell’s scalar pins; chain:
power-pin → filler → descriptor L1 → preserved pad/IP/SRAM tables). Not yet
wired; the per-PDK LeafPinProviders + build.rs::generate_pin_table +
generated modules remain (inert for stdcell logic, but still the parse-time
pin-direction source). This is the last mechanical cleanup.
C4 — proprietary sequential end-to-end test + round-trip honesty fix. GF130
descriptor generation is proven (the C4 proof), but no proprietary
sequential simulation test exists; add a synthetic “private” library
exercised end-to-end. Separately, the round-trip logic check should replace
build.rs’s port-only assert_eq! (the plan’s C4 honesty fix).
clear_preset for true set-dominant sequential sim. The field is emitted
and the consumer honors it, but a proprietary user simulating set-dominant
flops still needs the non-GF180 sequential path fully descriptor-driven
(items 1–2) to observe the correct behaviour end-to-end.
Sequential fidelity (C2). Liberty ffclear/preset/
clear_preset_var → Jacquard async-reset DFF is the bug-prone mapping;
gate it on equivalence against the current hardcoded behaviour.
L2 source (Liberty-first; resolved in Decision 0019 D6). The converter
reads Liberty function/ff/latch first and falls back to
functional.v/UDP only where Liberty under-specifies. C1 must therefore
exercise both ends: a clean function cell and a cell that needs the
.v/UDP fallback (a UDP-modelled mux). Where both sources exist, the
converter cross-checks them (logic equivalence; timing arc-set agreement;
macro/SRAM timing-value divergence) and surfaces disagreement — the
structural check build.rs’s port-only assert_eq! never had (#130).
AIG payload size (D2/D3). If the JSON AIG is unwieldy for a full
library, switch that payload to the FlatBuffers escape hatch — decide at
C1 from the real GF180 descriptor size.
Migration ordering (C3) — resolved: per-PDK cutover. Each PDK
migrates independently with the IR consumer running alongside the per-PDK
Rust, keeping the suite green; a single switch is riskier. IHP (C3a) is
added the same way but greenfield (no Rust to retire).
Bundled-descriptor provenance — resolved: CI-regenerated (D7). Not
checked in; embedded at build time from pinned submodules. C3 must wire
the generation into the build/CI and prove it deterministic.
Identifier alignment (D1). The shared cell/pin-name fragment must be
fixed in C1 before two IRs exist that would diverge.
2026-07-15 — shipped in v0.3.0. Behavioral RTL → sim → a signal resolves
to its RTL line, in a released binary. src/synth.rs maps via abc_new and
emits \src; the provenance fork lives at gpu-eda/yowasp-yosys, whose CI
builds + validates + releases the wheel, and jacquard fetches that pinned
release on first use (no PAT, no artifact expiry). aigpdk mapping needed two
fixes the sky130 A0 didn’t surface: read_liberty -lib + hierarchy -purge_lib
before a single abc_new pass. Verified against third-party RTL (PULP
common-cells via core-v-mcu): the emitted netlist carries
(* src = "binary_to_gray.sv:15.8" *).
Status:Shipped (v0.3.0) with one deferred item and one known toolchain
bug. Per-section ✅ markers below remain authoritative for detail.
WS-A — done. The forked provenance wasm builds and carries origins through
the in-process WASI abc_new/aiger2 XAIGER-"y" path (100% \src coverage
on comb + seq2). A1 (harden/pin) + A2 (distribute) + the src/synth.rs →
abc_new integration all landed (#176).
WS-B — B0–B3 done (sverilogparse capture → netlistdb.cell_src →
AIG::aigpin_src_locations, surfaced in xsources and --trace-signals).
Deferred: B3’s timing-report surface — src locations in --timing-report.
That’s the one piece of the original plan not shipped.
Known bug in the toolchain this plan adopted:abc_new hard-traps (wasm
unreachable) on a design that reaches it with zero primary I/O — bisected;
neither the constant clock nor emptiness is the trigger, one port of either
direction avoids it, and the failure is inside the ABC9 sub-pass. Whether
&origins causes it or it’s stock abc9 is open; the A/B is blocked because
a stock YoWASP wheel won’t load under our wasmtime (built with wasm exception
handling, which we don’t enable). Tracking:
robtaylor/origin-shell#1
upstream, #211 here (whose
reporting half is fixed — a trap now names the pass instead of printing 27 wasm
frames). Low impact for real designs: a portless module is testbench-shaped.
Decisions:0021 Phase 2 (the roadmap this
realises), 0014 (AIG core the provenance
rides through), 0018 (wasm
distribution).
Predecessors: Decision 0021 Phase 1 (the on-ramp — sim/cosim synthesize
behavioral RTL via embedded YoWASP Yosys). This plan makes the results of that
simulation speak RTL source locations instead of flattened gate names.
Thread \src (RTL source file:line, Yosys’s source-provenance attribute)
from synthesis all the way to Jacquard’s user-facing outputs, so that
--trace-signals, timing-violation reports, and X-debugging (xsources/xroots)
report RTL source locations rather than post-synthesis flattened gate names.
“Why is spiflash.ctrl.\$auto$…$1234 X?” becomes “spiflash.v:88”.
The toolchain must emit\src. Stock YoWASP Yosys drops source
provenance through std-cell mapping (abc). Carrying it needs a patched
toolchain — the origin-shell\src pass-through: berkeley-abc #487 (vOrigins/&origins) +
robtaylor/yosys@src-retention-y-ext.
Jacquard must ingest\src. Even with a perfect provenance netlist,
Jacquard throws it away today: sverilogparsediscards all attributes
(vendor/eda-infra-rs/sverilogparse/src/sverilognom.rs:44 — “we regard
attributes as comments”), and netlistdb has no source-location field.
These are independent and should be de-risked in parallel: WS-B (ingestion)
can be prototyped end-to-end against a hand-annotated(* src=… *) netlist
before the real toolchain (WS-A) exists. Only invest in the heavy wasm build
(A1) once both the A0 spike passes and WS-B is proven.
Reframed after reading origin-shell (2026-07-04). The gate is not
“does \src survive in-process abc” — origins ride the XAIGER "y"
channel (an in-memory AIGER round-trip via the aiger2 reader/writer, keyed
by object id), so external-vs-in-process abc is irrelevant to the channel.
origin-shell’s real lesson: provenance cannot survive the classic
abc/BLIF path at all (BLIF has no object identity), so the std-cell flow
must move to abc9/abc_new. Data path (origin-shell POC):
abc_new: write_xaiger2 → ABC (&read; &origins; &dch -f; &nf) → read_xaiger2.
This means two things Jacquard’s own synth.rs must change, independent of
the patched wasm:
src/synth.rs maps with classic abc -liberty (lines 241/244) → must
become the abc_new flow (&dch -f; &nf, scratchpad -set abc9.origins_max N) to carry origins.
It writes with write_verilog -noattr (line 247) — -noattrstrips
\src. Must drop it (attribute-selective if needed).
QoR caveat:abc9 std-cell mapping is “the road less travelled” upstream
(YosysHQ/yosys#5679 removed abc9 -liberty; the yosys fork carries the
aiger2 std-cell path). Bare &nf is 9–22% worse area than classic abc;
&dch -f; &nf recovers to parity. So the flow change carries a QoR
validation obligation, not just a wiring change.
abc_new/&nf exists in the stockyosys.wasm we already ship (just without
the &origins patch). So the flow migration + its QoR risk can be proven before
any fork/build work, moving the “road less travelled” risk off the patched-wasm
critical path.
In src/synth.rs, switch the two abc -liberty aigpdk_nomem.lib passes to the
abc_new&dch -f; &nf flow against aigpdk_nomem.lib; keep -noattr for now
(no origins to preserve yet).
Exit: the on-ramp still produces a correct aigpdk netlist via abc_new, and
QoR (cell count / area from stat) is at parity with the classic-abc baseline
on the counter/assert/mem designs.
A′ spike result (2026-07-05, stock yowasp-yosys 0.64) — the early signal
fired. Ran abc_new -script +&dch,-f;&nf -liberty aigpdk_nomem.lib on a small
design (scratchpad /tmp/claude/aprime):
Combinational-only (adder) → works (maps to $_NAND_/$_XNOR_/…).
Sequential (a posedge flop) → FAILS: ERROR: Bad connection $auto$ff.cc:337:slice$150/D ~ \d [1] (abc_new marked “experimental”).
classic abc baseline on the same design → works, 33 cells (23 AND2 + 4
DFF + 6 INV).
The ff.cc error is precisely the flop-handling that
robtaylor/yosys@src-retention-y-ext fixes (origin-shell lists “the
ff.cc/memory_map/mem.cc fixes”). So A′ cannot be fully de-risked on the
stock wasm — the yosys fork is required even to make abc_newfunction on
sequential logic, not just to carry origins. Consequence: A′ folds into A0 —
the first real test is building the forked wasm and running the abc_new flow
there (getting flow-correctness + QoR + origins in one shot), since stock can
only validate the purely-combinational sub-case.
With A′ proving the abc_new flow works, A0 adds the origins: build the
patched wasm and confirm \src rides the XAIGER "y" channel through mapping.
The spike is the build (the wasm is the A1 artifact — no throwaway work).
Build inputs — ✅ ALL WIRED + VERIFIED 2026-07-06 (the two repoints below are
done; recon was against Codeberg YoWASP/yosys, 2026-07-04). Chain:
robtaylor/yowasp-yosys @ yowasp-yosys-integration (NEW; mirror of Codeberg, base develop-0.64)
└─ yosys-src → robtaylor/yosys@src-retention-y-ext (bcc5698)
└─ abc → robtaylor/abc@origin-tracking-clean (2daf32f2, #487) ← abc is nested in yosys-src
└─ yosys-slang-src → povik/yosys-slang (SV frontend, upstream)
✅ yowasp overlay: created robtaylor/yowasp-yosys (full mirror; default
develop = clean mirror, yowasp-yosys-integration carries the repoint), repointed
yosys-src → robtaylor/yosys@src-retention-y-ext.
✅ Inside the yosys fork: repointed robtaylor/yosys@src-retention-y-ext’s abc
submodule YosysHQ/abc → robtaylor/abc@origin-tracking-clean (commit bcc5698).
Without this the build would have yosys-side \src retention but stock abc, and
provenance would die at mapping.
Build mechanics — ⚠️ CORRECTED 2026-07-06 (supersedes the “CMake + wasi-sdk 33”
recon; that was develop HEAD, the WRONG base for our fork). Two hard constraints
force the develop-0.64 Makefile recipe, not develop’s CMake:
robtaylor/yosys@src-retention-y-ext is Makefile-only (no CMakeLists.txt) —
develop’s cmake -S yosys-src cannot build the patched fork at all.
develop’s CMake migration (61073ed "CI: switch to CMake based builds")
dropped yosys-slang — its wasm has no read_slang, the SV frontend the
on-ramp depends on. develop also moved to wasi-sdk 33.
So base the overlay’s integration branch on develop-0.64: build.sh is the
Makefile flow (make -f yosys-src/Makefile CONFIG=wasi, -flto, -Wl,-z,stack-size=8M)
wasi-sdk 27 + flex-2.6.4 built from source + yosys-slang built via cmake
and whole-archive-linked (libyosys-slang.a) — the exact recipe that built the
pinned yowasp-yosys 0.64 the on-ramp uses. It hardcodes x86_64-linux wasi-sdk →
Linux/Docker/CI only (done: the A0 CI runs ubuntu-latest).
A0 CI (authored 2026-07-06):robtaylor/yowasp-yosys/.github/workflows/provenance-wasm.yml
— build (compile → WASI, link slang, smoke read_slang/abc_new, upload wasm+wheel)
provenance-check (origin-shell’s abc_new origins flow on comb+seq2 vs sky130,
\src coverage; seq2@0% fails). Residual risk CI will surface: develop-0.64’s
yosys-slang pin 4e53d772 targets yosys 0.64 (Apr); our fork’s yosys is ~Jun 2026, so
slang may need a newer pin to compile.
Repoint (steps 1–2 above), run build.sh, and run the A′ abc_new synth flow
with origins_max set and -noattr dropped on a small multi-module design;
measure the \src coverage — the % of mapped cells carrying a src
attribute. Crib origin-shell’s test/src_coverage.sh harness (loads write_json
output, counts 'src' in cell['attributes'] over mapped cells) — reuse it near
verbatim.
Go/no-go: high \src coverage on mapped cells → you already hold the
provenance wasm (A1 done); proceed to harden (A1) + distribute (A2). Low/zero →
the aiger2"y" channel or abc &origins isn’t functioning in the wasm build;
diagnose before investing further.
✅ A0 GO (2026-07-06, CI run 28779240456). The provenance wasm builds and
carries origins: comb 4/4 = 100%, seq2 88/88 = 100%\src coverage on
mapped sky130 cells. Origins survive the in-process WASI abc_new round-trip
(including sequential logic) — the decision’s key open risk is resolved. Getting here
also required rebasing robtaylor/abc@origin-tracking-clean onto current
berkeley-abc/master (it was 97 commits behind, missing the #ifdef __wasm guards
around abc’s system() calls → wasm-ld: undefined symbol: system). CI:
robtaylor/yowasp-yosys/.github/workflows/provenance-wasm.yml.
Fallbacks if it fails: (a) debug the aiger2/&origins path in the wasm
build (the channel is XAIGER round-trip, not exec, so the failure is in the
reader/writer or the patch, not “in-process abc” per se); (b) Nix native
toolchain — origin-shell is already a Nix flake with the validated flow, so a
native back-end for jacquard sim --synth-backend nix is the lowest-risk way to
ship provenance if the wasm path proves stubborn; (c) native-only provenance,
on-ramp wasm stays provenance-free.
The A0 build, productionized. Thin harness fork, not our own build recipe:
YoWASP/yosys is just a build harness, so the overlay’s whole delta is the two
submodule repoints — trivial to rebase when upstream moves, and it beats
hand-rolling a WASI/LTO build (where subtle divergence bites hardest). The durable
maintenance is keeping the existingyosys/abc patch branches rebased on
upstream — unavoidable in any approach, already owned. Per
[~/.claude/FORKED_DEPS_WORKFLOW.md], carry each of the three on an
-integration branch.
Pinning — track our branches, don’t freeze SHAs. These are our actively
developed branches (we’ll tweak the \src patches, and want to fold in upstream
review of abc#487), so pinning them to frozen SHAs would fight normal development.
Instead:
Track by branch, not SHA, for the three forks we own (yosys/abc/yowasp
overlay) — new patches and upstream-review changes flow through automatically;
when a patch merges upstream, retarget the branch to upstream.
Pin only genuinely-third-party build inputs we don’t control and need
reproducible: wasi-sdk version, flex, and the rest of build.sh’s toolchain.
Stamp provenance into each built artifact, not the source: when CI builds a
released wasm, record the exact yosys/abc/yowasp SHAs (release notes /
asset metadata). That gives release-level reproducibility — any shipped wasm
is recreatable — without freezing the dev branches. Develop freely; ship
traceably.
Exit strategy: upstream the patches (abc#487 → yosys → a YoWASP build knob) to
shrink the forks toward zero.
Exit: a pinned, CI-reproducible wasm that runs the existing synth_script
and emits (* src *) on mapped cells; byte-diff vs the stock wasm shows only
provenance additions.
Escape hatch (own-recipe): only if A0 shows build.sh is
gnarly/undermaintained enough that rebasing the overlay is painful — then
reimplement a minimal pinned WASI build in-repo. Default is the thin fork.
Publish as a Jacquard release asset (shares the Phase-4 fetch-from-release
mechanism). Decide: is provenance the default on-ramp wasm, or an opt-in
(--yosys-wasm/a provenance flag) larger asset? Recommend opt-in first.
Stop discarding (* … *) (sverilognom.rs:44). Parse src (and keep the door
open for other attrs) and attach to the cell/wire in the SVerilog AST.
vendor/eda-infra-rs is a vendored submodule → fork + integration branch per
~/.claude/FORKED_DEPS_WORKFLOW.md.
Exit: a (* src="f.v:12" *)-annotated netlist round-trips through
SVerilog::parse_* with the attribute retrievable per cell.
✅ B0 DONE (2026-07-06).gpu-eda/eda-infra-rs@jacquard-integrationa0772f4;
SVerilogCell gains src: Option<CompactString>. skip_whitespace_and_comment
no longer treats (* ... *) as whitespace; leading_attributes captures/skips
at the 3 grammar leading edges (module / port / module-body item). Proven by
sverilogparsetest_src_attribute (+ tests/attributes.v): parse + round-trip.
Caveat: attributes are now only accepted at those leading edges (narrows vs
the old “ignored anywhere” — fine for structural netlists).
Add an optional per-cell (and where meaningful, per-net) source_loc on
NetlistDB, populated from B0. Keep it Option — most cells post-synthesis may
have 0 provenance.
Exit:NetlistDB::from_sverilog_file exposes \src for annotated cells;
zero overhead / behaviour change when absent.
✅ B1 DONE (2026-07-06).gpu-eda/eda-infra-rs@jacquard-integration4f7f0ec;
NetlistDB.cell_src: Vec<Option<CompactString>> parallel to celltypes/cellnames,
populated in insert_cell from SVerilogCell.src through flattening. None for
top cell + synthesised inverters + un-annotated cells. Proven by
netlistdb/tests/provenance.{rs,v}.
Thread a mapping from AIG nodes / endpoint groups back to \src so a
sim-visible signal can be traced to a source line. Provenance is lossy by
nature (abc merges/splits nodes): design for 0, 1, or many source locations
per signal; never assert exactly one.
Exit: given an annotated netlist, a chosen output/endpoint resolves to its
source location(s) through the built FlattenedScript.
✅ B2 DONE (2026-07-06, Jacquard 68cc938b).AIG::aigpin_src_locations
composes the AIG’s existing per-pin cell-origin map (aigpin_cell_origins, built
for SDF back-annotation) with netlistdb.cell_src (B1) — no new state threaded
through the AIG builder. Returns 0/1/many de-duplicated locations; a primary
output resolves directly. The resolver is AIG-level (the AIG is retained alongside
the FlattenedScript). Proven by aig::path_mapping_tests + prov_annotated.v.
X-debugging xsources / xroots (docs/x-debugging.md) — the highest-value
target: report the RTL line of an X-source, not the flattened gate.
Exit: an end-to-end run on a synthetic annotated netlist prints source
locations in all three; a design with no provenance is unchanged.
⏳ B3 IN PROGRESS (2026-07-06) — 2 of 3 consumers done.
✅ xsources (ec7b648f): optional src on the XSource record from
cell_src[cell_id]; schema 1.0→1.1. (xprop_demo_synth.gv already carried
(* src *) → provenance flows through the real pipeline.)
✅ --trace-signals (ca55c786): logs each traced net’s RTL src via the B2
resolver at registration.
⏳ timing-violation reports — deferred (largest; governed schema). Path:
word_id → cell_id via dff_constraints; add src to DffSiteName →
ViolationRecord. A word packs many DFFs → 0/1/many. Decision 0008 permits
additive → bump SCHEMA_VERSION1.2.0→1.3.0 + decision note.
Parallel: the whole A-track and B0–B3 have no dependency; B is provable
against a hand-written annotated netlist, and A′ against the stock wasm.
A′ first within WS-A: proves the abc_new flow + QoR on the stock wasm
before any fork/build, moving the “road less travelled” QoR risk off the
patched-build critical path.
Barrier: full end-to-end (RTL → provenance wasm → surfaced source lines)
needs A1+A2 and B0–B3.
Kill-switch: if A0 fails, WS-B still delivers value for any externally
produced provenance netlist (DC/native Yosys with origins); the on-ramp just
doesn’t auto-generate it until a fallback toolchain lands.
abc9/aiger2"y" channel in the wasm build — the gating unknown (A0):
does the XAIGER origins round-trip function in the WASI-built yosys+abc?
QoR of the abc_new std-cell flow — abc9 std-cell mapping is unofficial
upstream; &nf alone is 9–22% worse area, &dch -f; &nf recovers to parity.
A′ must confirm parity on Jacquard’s designs before committing the flow change.
sverilogparse fork maintenance — vendored submodule; carry the patch on a
-integration branch per FORKED_DEPS_WORKFLOW; upstreamable (attribute capture
is generally useful).
Does \src survive Yosys flatten (separate from abc)? origin-shell
targets the full flow, but confirm in A′/A0, since the on-ramp flattens.
Provenance granularity — post-optimization a gate may map to 0/1/many source
lines; the reporting (B3) and IR (B2) must not assume 1:1.
Asset size — a second (provenance) wasm ~doubles the fetched-asset story;
A2’s opt-in-vs-default call interacts with Phase-4 distribution.
Status: ✅ SHIPPED — merged to main with Phase 0 in PR #118
(2026-06-17). Kept as the as-built record of Phase 1 of #105.
Parent plan:cosim-backend-portability.md (staging).
Architecture:Decision 0017 — Amendment 2026-06-07.
Base: stacks on Phase 0 (branch cosim-backend-seam-phase0, PR #118 —
CosimBackend trait + MetalBackend extracted, Metal bit-identical).
jacquard cosim runs on a CPU reference backend with no GPU feature,
reusing the scheduler, peripheral models, and VCD machinery — and the existing
cosim regression fixtures run on free ubuntu-latest CI. Throughput is not a
goal (the CPU backend is the oracle); correctness parity with Metal is.
After Phase 0 the CosimBackend trait exists, but everything still lives in
src/sim/cosim_metal.rs gated #[cfg(feature = "metal")] — the trait,
BitOp, run_cosim, the patchers, MetalBackend. run_cosim’s setup + loop
still touch Metal directly at ~54 .contents() / 20 new_buffer / 19
MTLResourceOptions sites (xprop X-mask seeding, flash-state init, SRAM fill,
set_flash_din, --check-with-cpu reads, VCD-ring drain, UART/bus channel
drains). For a CpuBackend to compile and run without metal, the
orchestration must become backend-agnostic and these sites must move behind the
seam.
Site categories (run_cosim body): flash ~38, sram ~22, bus/wb-trace ~21,
states ~13, uart ~12, vcd-ring 3.
The backend owns its allocation and initialisation. run_cosim becomes
generic over B: CosimBackend and only: builds the backend-agnostic
descriptions (schedule ops, init seeds, peripheral configs), calls
B::new(...), then drives the per-edge loop through trait methods. Each backend
allocates + initialises its own storage inside new. This is the clean Layer-1
(agnostic orchestration) / Layer-2 (backend) split; it keeps the trait surface
small at the cost of larger per-backend constructors (the Metal constructor is
just today’s setup block relocated).
Revised per plan review (2026-06-07).flash_d_i is not a trait method
— Metal and CPU update flash d_i at different points in the dispatch cycle
(GPU gpu_flash_model_step vs CppSpiFlash::step), so surfacing it through
the seam couples ordering. Instead CpuBackend::run_edges calls
CppSpiFlash::step() internally and injects the result into the input state;
d_i never crosses the seam. flash_set_in_reset stays (called beforerun_edges).
#![allow(unused)]
fn main() {
// Design state (Phase 0 deferral #1). Full [2 × effective_state_size] —
// input slot followed by output slot.
fn state(&self) -> &[u32];
fn state_mut(&mut self) -> &mut [u32];
fn sram(&self) -> &[u32]; // final dump / equivalence compare
// Flash reset line (set before run_edges; both flash FSMs honour it).
fn flash_set_in_reset(&mut self, in_reset: bool);
// Per-edge output snapshot for VCD — replaces run_edges' `metal::Buffer`
// (Phase 0 deferral #2). Slot layout is `[input_state | output_state]`,
// matching today's Metal ring. The orchestration drains agnostic &[u32]
// slots: `for i in 0..batch { let s = backend.vcd_snapshot(i); .. }`.
// CpuBackend (N=1) fills slot 0 before run_edges returns.
fn vcd_snapshot(&self, edge_in_batch: usize) -> &[u32]; // [2 × eff_state_size]
}
run_edges loses its Option<&metal::Buffer> parameter; whether to capture
snapshots becomes backend state set at construction (enable_vcd: bool). The
current raw ring.contents() pointer-math drain (cosim_metal.rs ~4065–4198)
is refactored to the vcd_snapshot(i) loop in step 1.
Input-driving peripherals already run CPU-side (PeripheralModel,
models/*.rs) via the patchers — backend-agnostic, no change. Output
decode differs by backend:
new CPU decoder (port the UartDecoderState FSM, currently a GPU-side mirror, to a step(output_state) CPU fn)
Wishbone trace (legacy WbTraceParams)
gpu_io_step kernel
stub — debug-only eprintln! path; CpuBackend leaves write_head=0 so the drain is a silent no-op (acknowledged, not ported)
GPIO and JTAG/UART-RX are input-only drivers (models/*.rs, already CPU)
— no output decode. The step_edge(&[], ..) placeholder for model output-state
(cosim_metal.rs ~3907) stays empty in Phase 1: the Phase-1 fixtures
(dual_uart, apb_trace, xprop_cosim) have no model that reads design
output, so this is sound; wiring state() into step_edge is deferred with
the I²C/SPI-model work.
So CpuBackend’s run_edges (N=1, iterating blocks × num_major_stages):
state_prep (output→input copy + apply edge BitOps + clear driven X-mask —
the loop at cosim_metal.rs ~4286–4301) → cpu_reference::simulate_block_v1
per block/stage → CPU flash/UART/bus decode reading the new output state. The
bus + flash decoders exist; only the UART TX decoder needs a CPU port (small:
shift-register baud FSM).
cosim/mod.rs (NOT gated): CosimBackend trait, BitOp, StatePrepParams,
the patchers + ModelDrivenClockState, the agnostic run_cosim<B>, the
scheduler/VCD/drain glue, and CpuBackend. Public API (run_cosim,
CosimOpts, CosimResult) re-exported here.
De-Metal the trait surface + VCD ring. Add state/state_mut/sram/
flash_set_in_reset/vcd_snapshot; move the VCD ring into MetalBackend;
drop metal::Buffer from run_edges; refactor the raw ring.contents()
drain (~4065–4198) to the vcd_snapshot(i) loop. Route the loop-body
diagnostic reads (--check-with-cpu, dff-dump ~4205, trace-signals
~4278, deep-diag ~4634, post_reset_state_snapshot) — ~100 lines of
direct states_buffer.contents() — through state()/sram(). Metal
bit-identical. Exit assertion: grep metal:: over the run-cosim body +
trait shows only the soon-to-move construction block.
Move buffer setup+init into MetalBackend::new, as three checkpoints,
each Metal bit-identical:
De-Metal the run_cosim body + make it generic. Split into three
bit-identical checkpoints (the loop body, not the construction block, is the
real work — step 1 deferred the diagnostic-read routing, so it lands here):
3a(done, d5a029f) — add state/state_mut/sram to the trait +
MetalBackend (sram_len field); route the ~15 read-only
states_buffer/sram_data_buffer loop-body reads through state()/
sram(). run_cosim stays concrete-typed.
3b-i — route the remaining concrete-field reads (groups C+D) off
MetalBackend fields, decoded-records seam (Decision 0017 Layer 3):
Flash diagnostics (C):FlashModelParams/FlashDinParams reads become
agnostic locals (derived from config+gpio_map, as build_flash_buffers
does). FlashState reads → flash_d_i() -> u8 (functional, for
--check-with-cpu) + flash_debug_snapshot() -> FlashDebug (agnostic
struct mirroring the printed fields); the tick-0 raw-bytes/offsetof dump
stays Metal-internal behind a debug trait method (not deleted).
Peripheral drains (D):drain_uart_tx() -> Vec<(usize,u8)>,
drain_bus_beats() -> Vec<RawBeat>, drain_wb_trace_debug() (legacy
eprintln, Metal-internal; CpuBackend no-op), uart_decoder_debug(ch).
The read cursors (uart_read_heads, wb/bus_trace_read_head) move into
MetalBackend; bus_lanes/uart_names/event-dispatch/tick stay in the
agnostic orchestration. run_cosim stays concrete-typed.
Exit: zero backend.<field>.contents() in the loop body.
3b-ii — assemble MetalBackend::new (the deferred step-2 finale:
build_flash+build_io+build_state+struct literal+init_schedule),
route the pre-loop stimulus deposits through state_mut(), flip to
run_cosim<B: CosimBackend> constructing the backend via the fat
constructor, update the jacquard.rs call site. Metal bit-identical.
Exit: no metal:: / MTLResourceOptions token outside the Metal
constructor + impl.
Module splitcosim_metal.rs → cosim/{mod,metal}.rs. mod.rs must
import zerometal::*; ScheduleBuffers/MetalSimulator/
create_ops_buffer/create_prep_params_buffer and the GPU IO structs live
entirely in cosim/metal.rs (gated). Audit pub use, src/sim/mod.rs,
src/bin/jacquard.rs, docs, CI. Gate: cargo check --lib (no feature)
must compile (it currently can’t — cosim_metal is fully gated). Metal
bit-identical with --features metal.
Implement CpuBackend — Vec<u32> state sized
effective_state_size() * 2, Vec<u32> sram, Vec<Vec<BitOp>> schedule.
run_edges (blocks × stages) via cpu_reference::simulate_block_v1; flash
via CppSpiFlash::step (internal, injects d_i into input state); bus via
BusTraceDecoder; new CPU UART decoder. CpuBackend::new asserts!script.timing_arrivals_enabled and !(xprop_enabled && sram_storage_size > 0)
(see Risks). --check-with-cpu becomes a no-op-with-warning under CpuBackend
(the backend is the reference — never compare it to itself).
Wire cmd_cosim — select CpuBackend when no GPU feature (or explicit
--backend cpu); remove the hard-error (jacquard.rs ~1684–1691, the
cmd_cosim branch — not the sim hard-error at ~507).
Linux CI job — ubuntu-latest runs xprop_cosim (cosim mode),
dual_uart, apb_trace via CpuBackend, asserting their checkers + the
cross-backend VCD equivalence (below).
Metal bit-identical after steps 1–4: /tmp/claude/cosim_fixtures.sh +
shasum -c against the Phase 0 golden; jtag_minimal 4M PASS.
CpuBackend correctness (steps 5–7): extend the #113 cross-backend harness
(scripts/ci/compare_backend_vcds.py) to cosim — run the same fixture on CPU
vs Metal, assert byte-identical output VCDs. The CPU PeripheralModel is
ground truth.
cargo test --lib (no feature) must compile + pass once the trait/CpuBackend
are non-gated.
Step 2 size — relocating ~54 sites is the bulk; split into 2a/2b/2c
(above), each Metal bit-identical, so the diff stays reviewable/bisectable.
Module-split compile gate (step 4) — any stray metal:: import in
mod.rs breaks cargo check --lib (no feature). Step 1’s exit assertion +
step 3’s token sweep front-load this; step 4 only moves code.
UART CPU decoder divergence — equivalence-test against Metal’s
gpu_io_step output on dual_uart before relying on it.
simulate_block_v1 X-mask handling — the CPU stepper must replicate the
GPU state_prep X-mask clear for --xprop parity (xprop_cosim is the
guard — but note it is SRAM-less, so SRAM-xprop is not covered, below).
SRAM xprop unsupported on CpuBackend — cpu_reference::simulate_block_v1
(cpu_reference.rs:17) takes no sram_xmask, so --xprop on a
SRAM-containing design would read SRAM as always-known (no X). CpuBackend::newasserts!(xprop_enabled && sram_storage_size > 0) until resolved. Logic-
only xprop (the xprop_cosim fixture) is fine.
Timed cosim deferred — arrival readback rides the GPU ring;
CpuBackend::newasserts!timing_arrivals_enabled. effective_state_size()
(flatten.rs:1582) still sizes the Vec, but the arrival section stays zero.
--check-with-cpu self-comparison — disabled-with-warning under CpuBackend
(it would compare simulate_block_v1 to itself). Step 5.
Generic run_cosim<B> monomorphisation — only two backends; negligible.
Stacked-PR rebase — #118 must merge (or this rebases) before Phase 2; the
handoff’s stacked-PR gotcha applies.
CUDA/HIP backend + Tier-2 GPU peripherals; the GpuPeripheral trait. Phase 1
defines no GPU-peripheral seam — only the CPU reference path + the agnostic
orchestration that Phase 2 builds on.
Status: ✅ SHIPPED — merged to main in PR #120 (2026-06-21,
rebase). Track 0 (#104 sim timing) + Stages A/B/C (T1.1–T2.2) all done and
T4-green. Remaining: T2.3 (optional, below) + the performance follow-up
(issue #122 — managed-memory profiling/tuning; the track closed on
correctness, performance is untuned).
Goal (maintainer): “close off CUDA and HIP before release” — bring both GPU
backends to Metal parity on the two paths that lag today:
#105 Phase 2 — cosim on CUDA/HIP with batching (Metal-only today;
a --features cuda build currently falls through to CpuBackend).
Architecture:Decision 0017 (Layer 1/2/3 +
peripheral contract); staging in
cosim-backend-portability.md (this is the
Phase-2 detail doc, sibling to cosim-phase1-cpu-backend.md).
Release bar (decided): full batched cosim (checkpoints 2a and 2b), not a
per-edge-only intermediate. Per-edge-only CUDA is an unusably-slow artifact
(PCIe round-trip per edge); 2b is what makes it a real backend.
This work cannot be built or run on the dev machine (Apple Silicon → Metal
only). Every CUDA/HIP iteration is a CI round-trip on tesla4-runner. The
workflow consequences:
Maximise local confidence before each push: cargo check-equivalent reasoning,
mirror the proven Metal path line-for-line, keep diffs reviewable.
Batch changes per push to minimise CI cycles; expect red→green iteration on
the T4 for genuinely GPU-dependent bugs (struct ABI mismatch, fence scope,
__shfl width).
The cross-backend equivalence harness (compare_backend_vcds.py) and the
Phase-1 CPU goldens (tests/*/expected/) are the correctness oracle — CUDA/HIP
output must be byte-identical to CPU/Metal.
Why first: smallest, independent, zero kernel work, and it exercises the
full CUDA/HIP build + T4 CI loop before the hard cosim work. Confirmed by
investigation:
The kernel-side timing logic is already in the shared
csrc/kernel_v1_impl.cuh (simulate_block_v1 arrival writeback at :530-532,
setup/hold write_event at :546-553).
The timed C launchers already exist: simulate_v1_noninteractive_timed_cuda
(kernel_v1.cu:50), ..._timed_hip (kernel_v1.hip.cpp:70) — both call the
same kernel as Metal, just passing timing_constraints + event_buffer
through instead of nullptr.
ucc::bindgen will auto-surface them as ucci::simulate_v1_noninteractive_timed
/ ucci_hip::... (suffix stripped) with no build.rs change.
Today sim_cuda/sim_hip accept timing_constraints but call the untimedsimulate_v1_noninteractive_simple_scan (jacquard.rs:1196 / :1365) and
drop the arg.
The gap is entirely Rust (~120-150 lines):
Un-gate TimingReportConfig (jacquard.rs:13) and its impl from
#[cfg(feature="metal")] → cfg(any(metal,cuda,hip)) (struct has no
Metal-specific fields).
Un-gate report_cfg construction in cmd_sim (jacquard.rs:518-550) for
cuda/hip.
sim_cuda: add report_cfg: &TimingReportConfig param; when
timing_constraints.is_some() → allocate Box<EventBuffer>, call
ucci::simulate_v1_noninteractive_timed(...), then process_events() once
post-run feeding ReportBuilder; else keep simple_scan. Add the
expand_states_for_arrivals call (mirror sim_metal :804-807) when
script.timing_arrivals_enabled.
sim_hip: identical.
Update the two cmd_sim call sites (jacquard.rs:564-580) to pass
&report_cfg.
Known limitation (acceptable, pre-existing): the CUDA/HIP sim path is a
single bulk cooperative launch, so the EventBuffer is drained once at
end-of-run, not per-cycle. MAX_EVENTS=1024 with an overflow flag; no
early-exit on $finish from events. Out of scope to change here.
Verification: extend the cuda/hip CI jobs to run the existing timing
fixture (tests/timing_test/dff_test_synth.gv with constraints) and assert the
timing report matches Metal’s. Add the timing VCD to backend-equivalence.
Exit:jacquard sim --features cuda … --timing-report produces the same
violations/report as Metal on the T4; --timed arrival VCD matches.
The originally-planned checkpoint 2a (a CUDA backend with CPU peripherals,
per-edge) is dropped. No production backend works that way — Metal always runs
peripherals on the GPU, falling to batch=1 (not to CPU peripherals) for
model-driven-clock designs (JTAG). A CPU-peripheral CUDA variant would be a
bring-up crutch that exists nowhere else and muddies the architecture.
Target: a single CudaBackend/HipBackend that mirrors MetalBackend —
GPU design step + GPU peripherals + variable batching + managed memory.
CpuBackend remains the pure-CPU reference oracle. Bisectability (2a’s only real
benefit) is recovered by staging on the fixtures, since each exercises a
different kernel subset — no separate backend:
Stage A — CudaBackend with the design step only (cosim_state_prep +
cosim_simulate_stage, landed in T1.1) + batched orchestration + managed
memory + VCD ring. Validate against xprop_cosim (4 logic fixtures: no
flash/UART/bus). Proves the whole pipeline end-to-end with zero peripherals.
Stage B — port gpu_io_step (UART + bus). Validate dual_uart +
apb_trace.
Stage C — port the flash kernels (gpu_apply_flash_din,
gpu_flash_model_step). Validate the flash/JTAG fixtures.
Every stage is the real architecture, gated against the committed CPU/Metal
goldens on the T4.
CUDA/HIP have zero cosim kernels today. Add, mirroring the Metal shader:
simulate_v1_stage — a non-cooperative per-stage __global__
(num_blocks blocks × 256 threads), one major stage per launch; host loops
stages, each launch is the grid barrier. The body is the already-shared
simulate_block_v1 (impl.cuh:32) — the new global is a thin wrapper indexing
blocks_start[stage_i*num_blocks + blockIdx.x], input slot states[0], output
slot states[state_size] (current_cycle=0, 2-slot ping-pong). This avoids
cooperative launch entirely for cosim (the hard-to-port grid.sync stays
sim-only). Mirror the Metal SimParams ABI exactly.
state_prep — port from shader:679 (output→input copy + per-BitOp set +
driven-bit X-mask clear at xmask_state_offset). Use __threadfence() (device
scope) between copy and bit-ops to match Metal’s mem_device barrier.
Add extern "C"_cuda/_hip launchers to kernel_v1.cu/.hip.cpp. Replicate
SimParams and StatePrepParams#[repr(C)] structs in a device header with
identical field order/padding (see the GPU-struct catalogue captured in the
Phase-2 investigation, folded into the appendix below).
Metal uses StorageModeShared (one physical pointer; CPU write = upload). CUDA/
HIP have no arbitrary-host zero-copy. Decision: use managed memory
(cudaMallocManaged/hipMallocManaged) for the v1 backend — it is the closest
functional analog to Metal’s unified buffers and keeps the backend code
structurally identical to MetalBackend (CPU casts the pointer directly; the
driver migrates pages). Revisit pinned-host + explicit-mirror only if managed
memory’s page-migration cost dominates (profile on the T4 in 2b). The
edge_ops_mut / state / sram / drain accessors in the trait already isolate
this — only the backend struct changes.
Buffers to allocate in CudaBackend::new (sizes per the Metal catalogue):
states (2×state_size), sram_data, sram_xmask, blocks_start,
blocks_data (reuse the ulib UVec device copies where they exist),
event_buffer, optional timing_constraints, and the per-edge schedule
(StatePrepParams, Vec<BitOp>) pairs.
New src/sim/cosim/cuda.rs (and hip.rs), #[cfg(feature="cuda")] /
#[cfg(feature="hip")], implementing CosimBackend exactly like MetalBackend:
new — allocate managed buffers (1b).
init_schedule — store per-edge (params, ops) in managed buffers.
edge_ops_mut — slice over the managed ops buffer (write = visible to GPU
after the next launch in-stream; no explicit flush with managed memory, but
add cudaStreamSynchronize discipline at the documented points).
run_edges — batch BATCH_SIZE edges into one cudaStream_t, mirroring
Metal’s encode_and_commit_gpu_batch. Per edge: cosim_state_prep →
gpu_apply_flash_din → cosim_simulate_stage × num_major_stages →
gpu_flash_model_step → gpu_io_step → memcpy output→VCD-ring slot. Stream
ordering gives sequential execution (no inter-kernel barriers). (Stage A wires
only state_prep + simulate; B adds gpu_io_step; C adds the flash kernels.)
state/state_mut/sram — direct managed-pointer slices.
drain_uart_tx/drain_bus_beats/flash_d_i — read the GPU ring buffers
(managed-memory cursors), exactly like MetalBackend::drain_* (Stages B/C).
wait — cudaEvent_t recorded at end-of-batch + cudaEventSynchronize
(replaces Metal SharedEvent).
jacquard.rs:1784-1789 currently: metal → run_cosim, else → run_cosim_cpu.
Add run_cosim_cuda/run_cosim_hip shims (run_cosim_generic::<CudaBackend>)
with the same priority order as sim (metal > cuda > hip > cpu).
Verification: add jacquard cosim invocations to the cuda/hip CI jobs,
output VCD byte-identical to the CPU golden in tests/*/expected/ and to
Metal. Stage A: xprop_cosim (batched, no peripherals). Stage B: dual_uart +
apb_trace. Stage C: flash/JTAG fixtures. Model-driven-clock designs (JTAG) run
GPU peripherals at batch=1 within the same backend — not a separate path.
Each is single-thread (thread 0) work — straightforward ports. Replicate the
full GPU-struct ABI (FlashState, FlashDinParams, FlashModelParams,
UartParams/UartChannel/UartDecoderState, BusTraceParamsAll/
BusTraceChannel, etc.) — exhaustive field layouts captured in the appendix.
As each peripheral kernel lands, wire it into CudaBackend::run_edges (Stage B
= gpu_io_step; Stage C = the flash kernels) and switch the corresponding
drain_*/flash_d_i accessors to read the GPU ring buffers. Define the
GpuPeripheral seam (Decision 0017 Layer 3) here so Phase 3 (Tier-3 single-source)
can slot in later. CpuBackend (Tier-1) is the per-kernel equivalence
oracle — equivalence-test each GPU kernel against its CPU model.
Verification:cosim --features cuda on each fixture (Stage B/C) runs
batched (telemetry batch > 1), output VCD byte-identical to the CPU/Metal
goldens on tesla4-runner. Model-driven-clock designs (JTAG) match at batch=1.
compare_backend_vcds.py is N-way and backend-agnostic (no change needed).
Add to the cuda and hip-on-nvidia jobs a jacquard cosim step per fixture,
upload the VCDs, and extend backend-equivalence with cosim comparisons
(cuda vs hip vs metal vs the committed CPU golden). This closes the current gap:
cosim is CI-covered on CPU only; sim equivalence covers GPU only.
⬜ optional/not started — backend-equivalence (cosim) green
T0–T2.2 all ✅ merged in PR #120 (2026-06-21). Only T2.3 remains (optional;
the flash gate already achieves cross-backend equivalence transitively by diffing
each backend against the same committed golden). Performance tuning of the v1
managed-memory backend is tracked separately in issue #122.
CUDA and HIP land together at each step (shared *_impl.cuh; HIP = a thin
second launcher + mod ucci_hip).
CI-only validation — the dominant friction. Mitigate by mirroring Metal
exactly and batching pushes.
GPU-struct ABI drift — any field-order/padding mismatch between the device
header and Rust #[repr(C)] silently corrupts. Add static-size asserts both
sides; the equivalence test catches behavioural drift.
Managed-memory perf — page migration may dominate; profile in 2b, fall
back to pinned+mirror only if needed (isolated behind the trait).
__shfl_down_sync width / fence scope — simd_shuffle_down → __shfl_down_sync(0xFFFFFFFF,…); mem_device → __threadfence(). Classic per-arch GPU bugs; surface only on the T4.
Two kernel families per peripheral (CUDA+HIP shared impl, Metal .metal) —
equivalence tests against the CPU model are the guard; Tier 3 removes the
hand-maintenance eventually.
The exhaustive Metal cosim spec (every kernel signature, the per-edge dispatch
ordering, all device-buffer layouts, the full GPU #[repr(C)] struct catalogue,
and the Metal-specific constructs needing CUDA analogs) was captured during the
2026-06-17 Phase-2 investigation. Primary sources to port from:
csrc/kernel_v1.metal (kernels), src/sim/cosim/metal.rs (MetalBackend,
run_edges, encode_*, drains), csrc/kernel_v1_impl.cuh (shared
simulate_block_v1). The #104 gap analysis (Rust-only, ~120-150 lines) and the
build/FFI/CI mechanism (ucc bindgen auto-surfaces _cuda/_hip launchers; no
build.rs change to add kernels) are recorded in the same investigation.
Status: ✅ SHIPPED — landed as 80a2993e and merged in PR #120
(2026-06-21). Kept as the as-built spec. Sibling detail doc to
cosim-phase2-cuda-hip.md (the authoritative plan;
Stage B = its checkpoint T2.1). Resumed from
docs/handoffs/backend-alignment-handoff.md.
Goal: bring CudaBackend/HipBackend from design-step-only (Stage A) to
GPU peripherals (UART + bus) running inside a batched launch, byte-identical
to the CPU/Metal goldens, validated CI-only on tesla4-runner. Two decisions
already taken by the maintainer this session:
GPU peripherals, not a CPU-peripheral crutch (the dropped “2a”).
Batch in this stage — rework run_edges off its current per-edge
DEVICE.synchronize() into one async launch sequence + a single end-of-batch
sync, mirroring Metal’s encode_and_commit_gpu_batch.
CI gate: dual_uart + apb_trace cosim VCD/CSV byte-identical to
tests/{dual_uart,apb_trace}/expected/ on cuda and hip.
Stage-B fixtures exercise UART + APB3 only. The legacy Wishbone (WbTrace*)
path in gpu_io_step is dead for dual_uart/apb_trace (has_trace == 0,
n_buses drives APB). Port the WB block for ABI/structural parity with Metal but
it is not on the Stage-B critical path (no WB fixture until later).
Exact field order/padding from kernel_v1.metal:1041-1163. Constants:
MAX_UARTS, UART_CHANNEL_CAP (already defined for Metal — confirm the values
and re-declare in the .cuh), WB_TRACE_MAX_ADR_BITS=30, …_DAT_BITS=32,
WB_TRACE_CHANNEL_CAP=16384, MAX_BUS_TRACES=4, BUS_TRACE_MAX_ADR_BITS=32,
…_DAT_BITS=32, BUS_TRACE_CHANNEL_CAP=16384, BUS_PROTO_APB3=0.
ABI guard (required):static_assert(sizeof(X) == …) on both the C side and
const _: () = assert!(size_of::<X>() == …) on the Rust side for every struct
that crosses FFI. ABI drift is the #1 risk (silent corruption); the Rust ABI
already exists at metal.rs:113-250 and is the size-of-truth.
The Rust #[repr(C)] mirrors at metal.rs:113-250 are #[cfg(feature="metal")].
Action: lift UartParams/UartPerChannelConfig/UartChannel/
UartDecoderState/BusTraceParamsAll/BusTraceParams/BusTraceEntry/
BusTraceChannel (+ WB structs) into the agnostic parent (mod.rs,
non-gated) so all three backends share one ABI definition, then have metal.rs,
cuda.rs, hip.rsuse super:: them. This removes the triple-maintenance the
plan’s Risk “two kernel families” warns about. (build_bus_trace/
BusTracePositions are already agnostic in mod.rs — this extends that pattern.)
Direct transliteration of kernel_v1.metal:1180-1354. Metal→CUDA mechanical
substitutions:
kernel void → __global__ void; [[buffer(n)]] args → plain pointers;
tid [[thread_position_in_threadgroup]] → threadIdx.x. Single-thread work:
keep the if (tid != 0) return; guard → if (threadIdx.x != 0) return;.
device/constant qualifiers dropped. uchar → unsigned char / u8,
u32 already aliased in the .cuh.
The READ_OUT_BIT macro (metal.rs-shader :1186) ports verbatim (reads
states[state_size + (pos>>5)] — the output slot; 0xFFFFFFFF ⇒ 0).
No barriers needed — pure thread-0 serial logic, no simd_*/threadfence.
The UART FSM (4-state: IDLE/START/DATA/STOP), WB trace, and APB3 rising-edge
gate logic copy line-for-line. The CPU FSM at mod.rs:3923-4039 is the
already-verified Rust twin — cross-check the port against it (same
cycles_per_bit/2 midpoints, same value |= tx << bits_received, same
gate = sel & en & rdy + rising-edge (prev>>b)&1 == 0).
Follow the existing cosim_state_prep_cuda (kernel_v1.cu:84) pattern exactly —
extern "C", raw pointer args, <<<1, 256>>> launch, checkCudaErrors. ucc
strips _cuda/_hip and appends the Device arg, surfacing
ucci::gpu_io_step(...) automatically (no build.rs change). Signature mirrors
the kernel: (u32* states, UartDecoderState* uart_state, const UartParams* uart_params, UartChannel* uart_channel, WbTraceChannel* wb_channel, const WbTraceParams* wb_params, BusTraceChannel* bus_channel, const BusTraceParamsAll* bus_params).
HIP launcher is the same body in .hip.cpp (shared .cuh kernel).
VCD-ring snapshot launcher (needed for §6 batching): add
cosim_snapshot_{cuda,hip} — cudaMemcpyAsync(ring + edge_off*2*state_size, states, 2*state_size*4, cudaMemcpyDeviceToDevice, 0). This is the CUDA analog of
Metal’s per-edge blit (metal.rs:732-742); it lets each edge’s [input|output]
slot be retained in a device ring while states is overwritten by the next
edge — without it, batching (no per-edge sync/readback) loses all but the final
snapshot.
wb_params/wb_channel (via build_wb_trace_params — also agnostic-lift or
cfg-gate; low priority, no Stage-B fixture).
bus_params: UVec<u8>-backed BusTraceParamsAll + bus_channel (header +
BUS_TRACE_CHANNEL_CAP entries, byte-sized buffer). Build params from
build_bus_trace(aig, netlistdb, script, config.effective_bus_traces())
(mod.rs:149) → pack BusTracePositions into BusTraceParams (the packing
loop lives in metal.rsbuild_bus_trace_params:1126 — extract the
positions→BusTraceParamsAll packer into mod.rs so cuda/hip/metal share it,
same move already done for the lanes).
Current Stage A (cuda.rs:342): per edge → state_prep, simulate_stage × N, DEVICE.synchronize(), host read-back into VCD ring. The per-edge sync
is what makes it not-yet-batched.
Target (mirror encode_and_commit_gpu_batch): enqueue all edges’ kernels
on the default stream with no intervening sync, one DEVICE.synchronize() at
the very end, then read the ring + drain channels once. Per edge in 0..batch:
upload edge ops UVec (retain in a Vec for the whole batch — async
launches read it after the call returns; dropping early = UB),
ucci::gpu_io_step(...) (NEW — UART/bus capture into the device rings),
if enable_vcd: ucci::cosim_snapshot(...) → device ring slot edge_offset.
Then oneDEVICE.synchronize(). Then: read the VCD ring UVec back to host →
Vec<Vec<u32>> for vcd_snapshot; UART/bus channels are managed/UVec so
drain_* reads them after the sync.
Notes / invariants:
Flash kernels (gpu_apply_flash_din, gpu_flash_model_step) are Stage C —
omit from the dispatch chain here (Metal’s encode_*flash* calls are skipped).
wait/vcd_snapshot semantics unchanged from Stage A (token still unused; the
single end-of-batch sync replaces per-edge).
Ops-buffer lifetime is the one new correctness hazard vs Stage A (which synced
immediately so the UVec could drop). Collect Vec<UVec<u32>>, drop after sync.
The VCD ring UVec is sized batch_capacity * 2 * state_size; grow lazily to the
largest batch seen (mirror Metal’s ring sizing).
The check script already supports dual_uart + apb_trace under
COSIM_SCOPE=all (scripts/ci/cosim_cpu_check.sh:97-110). Stage A restricted the
cuda/hip steps to COSIM_SCOPE=logic (ci.yml:580/:734). Stage B change:
flip those two steps to COSIM_SCOPE=all (or a new logic+io scope that adds
just UART/bus, deferring flash/JTAG to Stage C). The goldens at
tests/{dual_uart,apb_trace}/expected/ already exist (Phase-1 CPU goldens, =
Metal). No new fixtures, no compare_backend_vcds.py change.
ulib stream/async semantics. The whole batching rework assumes the ucci
launchers enqueue async on the default stream and DEVICE.synchronize() is the
only barrier (true of the current launchers — they <<<>>> + cudaGetLastError,
no inner sync). Verify ulib UVec host→device upload doesn’t itself force a
sync mid-batch (if &mut ops_uvec triggers a blocking copy each call, the
“batch” still serialises on the host — acceptable functionally, but not the
perf win; flag if so). This is the one assumption that could force a design
change (e.g. a single batched C launcher that loops internally).
VCD ring memory.batch * 2 * state_size * 4 B device-resident. Large
designs × large batch could be significant; Metal already pays this. Size to
the max batch lazily.
ABI drift — mitigated by §2 static asserts both sides + the byte-identical
gate.
UartChannel is 16 + UART_CHANNEL_CAP bytes — large struct in a UVec;
confirm UVec<UartChannel> device alloc handles the [u8; CAP] inline array
(vs a flat UVec<u8> view). A flat byte-buffer + manual offset (like the bus
channel) may be cleaner than UVec<UartChannel>.
CUDA + HIP land together — shared .cuh kernel + ABI; HIP is a second
launcher in .hip.cpp + mod ucci_hip in hip.rs. Keep cuda.rs/hip.rs
diffs identical (they’re 452-line twins today).
CI: flip COSIM_SCOPE to include UART/bus on cuda/hip
—
green
B0 is local-verifiable (Metal) and de-risks the ABI before any T4 round-trip —
do it first. B1+B2 batch into one push (kernel is useless without the backend).
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.
C1: lifted FlashState/FlashDinParams/FlashModelParams from metal.rs
to the shared mod.rs GPU-struct region (gated, size_of asserts 48/24/32).
Metal bit-identical.
C2 (CpuBackend oracle): wired CppSpiFlash into CpuBackend::run_edges
(per-edge apply_flash_din + dual-step flash_model_step); patched
spiflash_model.ccp_d_i init 0→0x0F to match the GPU FlashState.d_i. A
10k-edge mcu_soc cosim on the no-GPU CpuBackend is byte-identical to the
Metal GPU flash kernel → C++↔shader FSM equivalence proven, CpuBackend is a
valid golden source. (Detail: the C2 section below.)
gpu_flash_model_step to kernel_v1_impl.cuh + _cuda/_hip launchers;
wired into Cuda/HipBackend::run_edges (Metal order); flash buffers built in
new via shared build_flash_buffers_dev; stubs replaced with real
FlashState reads. FlashState byte offsets derived via std::mem::offset_of!
(ABI drift → compile error). T4-green.
C4 (7c54448): flash CI gate — 74 KB CpuBackend golden
tests/mcu_soc/expected/mcu_flash.vcd (== Metal, deterministic), self-contained
tests/mcu_soc/sim_config_selfcontained.json, COSIM_SCOPE=flash in
cosim_cpu_check.sh, wired into Linux + CUDA/HIP T4 jobs. All three flash-gate
steps T4-green (byte-identical vs golden).
No pure-CPU flash stepper exists.CppSpiFlash (C++ FFI SPI/QSPI model,
testbench.rs; step(clk,csn,d_o)->d_i) is instantiated in run_cosim_generic
(mod.rs:1965, loads the 16 MiB firmware) but is _-prefixed and never
stepped. The --check-with-cpu path injects backend.flash_d_i() (the GPU’s
value) — it does not run a CPU flash model. So CpuBackend flash is a stub
(flash_d_i → 0x0F), and wiring a real CPU flash path is from-scratch work,
not “connect the existing model.”
CppSpiFlash has no reset API — reset handling (the GPU FlashState.in_reset)
has no CPU analog; would need to idle the model (step with csn high) during reset.
C++ CppSpiFlash FSM vs GPU gpu_flash_model_step shader 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_soc is the only flash cosim fixture, and it is committed +
self-contained — tests/mcu_soc/data/6_final.v (19.6 MB netlist) and
tests/mcu_soc/software.bin (3.6 KB firmware) are both git-tracked, so it runs
on a fresh checkout without the chipflow firmware 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).
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_soc golden.
Do C3 (port the two flash kernels to CUDA/HIP + wire into run_edges) and C4
(short mcu_soc cosim in CI; CUDA/HIP/Metal VCDs compared via the existing
backend-equivalence harness, or vs a committed Metal golden). This is the direct
release-critical path. The CpuBackend flash 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_soc
run, 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.
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) beforesimulate_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) butp_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().)
Port gpu_apply_flash_din (shader:904 — write FlashState.d_i → input-state MISO
bits at d_in_pos, clear their X-mask) and gpu_flash_model_step (shader:943 —
read clk/csn/d_out from output state, dual-step the SPI/QSPI FSM, update
FlashState) to kernel_v1_impl.cuh + extern "C"_cuda/_hip launchers.
Also port the flash_eval_commit_persistent helper (shader:843) — the
per-eval primitive gpu_flash_model_step calls twice (it is the shader port of
spiflash_model.cceval()+commit()); it must live in the .cuh too.
Needs the 16 MiB firmware UVec<u8> (load from config.flash.firmware like
CpuBackend::new / build_flash_buffers’s flash_data_buffer) + persistent
FlashState (UVec<u8>, the event-buffer FFI pattern; init per
build_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 as u8* and cast (Stage B convention).
Wire into CudaBackend/HipBackend::run_edges in the Metal order: state_prep →
gpu_apply_flash_din → simulate_stage × N → gpu_flash_model_step →
gpu_io_step → snapshot. Build the flash buffers in new (mirror
MetalBackend::build_flash_buffers); replace the Stage-B flash_d_i /
flash_debug_snapshot stubs with GPU FlashState reads; flash_set_in_reset
drives FlashState.in_reset. Model-driven-clock (JTAG) runs the peripherals at
batch=1 within the same backend.
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.
FSM equivalence (C++ CppSpiFlash ↔ shader gpu_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_soc is large; a short run must still be deterministic
and exercise real flash transactions.