From bfd6f94a2a03f4a2caa8c9ab97a7c6de2aec872b Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Tue, 19 May 2026 08:47:22 +0100 Subject: [PATCH 1/5] test(opensta-to-ir): poll for execve-ready in stub script helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locate_and_check tests' write_stub_script helper occasionally hit ETXTBSY ("Text file busy") on Linux when invoking the freshly-written stub binary — close(2) does not synchronously decrement the inode's writer_count, so an immediate execve can race. The test then sees LocateError::VersionProbeFailed { kind: ExecutableFileBusy } instead of the expected exit-code-driven variant. Add sync_all + a brief no-op execve probe loop after drop so the helper only returns once the file is safely executable. Bounded at 2s with a panic-on-timeout to keep CI runtime predictable. Surfaced via PR #63 CI run 25938446354. Co-developed-by: Claude Code v2.1.143 (claude-opus-4-7) --- .../opensta-to-ir/tests/locate_and_check.rs | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/crates/opensta-to-ir/tests/locate_and_check.rs b/crates/opensta-to-ir/tests/locate_and_check.rs index 4e7d7ef..e2ae3a5 100644 --- a/crates/opensta-to-ir/tests/locate_and_check.rs +++ b/crates/opensta-to-ir/tests/locate_and_check.rs @@ -15,11 +15,14 @@ use tempfile::TempDir; /// Write `script` to `dir/sta`, mark it executable, and return the path. /// -/// Opens the file with mode 0o755 in a single syscall and explicitly drops -/// the writer before returning. Doing chmod after `std::fs::write` returns -/// leaves a window where Linux can fail an immediate `execve` with -/// ETXTBSY ("Text file busy"), which this test then sees as an -/// `io::ErrorKind::ExecutableFileBusy` spawn failure. +/// Opens the file with mode 0o755 in a single syscall, `sync_all`s, and +/// drops the writer — then briefly polls a no-op `execve` until the +/// kernel stops returning ETXTBSY ("Text file busy"). Without the poll, +/// Linux occasionally rejects an immediate execve even after the writer +/// is closed (the inode's writer_count decrement is not synchronous with +/// `close(2)`), which surfaces in `locate_and_check` as `VersionProbeFailed +/// { kind: ExecutableFileBusy }` and breaks the test's exit-code +/// assertions. fn write_stub_script(dir: &TempDir, script: &str) -> PathBuf { let path = dir.path().join("sta"); let mut f = std::fs::OpenOptions::new() @@ -29,10 +32,39 @@ fn write_stub_script(dir: &TempDir, script: &str) -> PathBuf { .open(&path) .unwrap(); f.write_all(script.as_bytes()).unwrap(); + f.sync_all().unwrap(); drop(f); + wait_for_execve_ready(&path); path } +/// Block (briefly) until `path` can be `execve`'d without ETXTBSY. +fn wait_for_execve_ready(path: &std::path::Path) { + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match Command::new(path) + .arg("--__probe-ready") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(mut child) => { + let _ = child.wait(); + return; + } + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + if Instant::now() > deadline { + panic!("execve still returning ETXTBSY after 2s for {path:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(e) => panic!("unexpected spawn error probing {path:?}: {e}"), + } + } +} + /// Stub that prints the given version on `-version` and exits 0. fn write_version_stub(dir: &TempDir, version_output: &str) -> PathBuf { let script = format!( From bbfec1dc1c4d323e63e8e7e17f3f3bdf75b67436 Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Tue, 19 May 2026 08:47:36 +0100 Subject: [PATCH 2/5] feat(opensta-to-ir): transparently extract --top module from each input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenSTA's read_verilog is structural-only: RTL operators (~, &, |), bit-selects in assigns, and ranged concatenations cause a syntax error. The LibreLane + wafer.space integration flow wraps the post-P&R structural body in a thin RTL module (e.g. openframe_project_wrapper) that patches the chip-frame pad ring using exactly those operators. Every user pushing a tapeout through this pipeline — chipflow's mcu_soc, hazard3, future wafer.space designs — hits the same wrapper-parse failure. Add a Verilog input filter that runs before the OpenSTA subprocess invocation: for each --verilog file, extract `module <--top> … endmodule` if present; pass the file through unchanged otherwise. Files without a matching module declaration (sub-module-only files in hierarchical designs) still link cleanly. Implementation in src/verilog_filter.rs with 7 unit tests covering the multi-module, single-module, absent-module, prefix-collision, indented-keyword, no-port-list, and comment-with-endmodule cases. Integration in opensta::run via a per-file pre-pass that writes filtered outputs into the existing OpenSTA tempdir. A second class of pitfall — substituting a pre-P&R synthesis netlist to dodge the wrapper-parse error — is documented in ADR 0009 and retracted from the prior ws3-cosim-sdf-followup recipe. That path silently drops SDF entries for cells P&R inserts (CTS buffers, antenna diodes, fillers) and produces materially incomplete IR. No CLI changes — the behaviour is transparent. Co-developed-by: Claude Code v2.1.143 (claude-opus-4-7) --- crates/opensta-to-ir/src/lib.rs | 1 + crates/opensta-to-ir/src/opensta.rs | 41 +++- crates/opensta-to-ir/src/verilog_filter.rs | 184 ++++++++++++++++++ .../adr/0009-opensta-verilog-reader-inputs.md | 114 +++++++++++ docs/plans/ws3-cosim-sdf-followup.md | 58 +++--- 5 files changed, 369 insertions(+), 29 deletions(-) create mode 100644 crates/opensta-to-ir/src/verilog_filter.rs create mode 100644 docs/adr/0009-opensta-verilog-reader-inputs.md diff --git a/crates/opensta-to-ir/src/lib.rs b/crates/opensta-to-ir/src/lib.rs index f70fa19..c34bc3b 100644 --- a/crates/opensta-to-ir/src/lib.rs +++ b/crates/opensta-to-ir/src/lib.rs @@ -11,3 +11,4 @@ pub mod builder; pub mod dump; pub mod opensta; +pub mod verilog_filter; diff --git a/crates/opensta-to-ir/src/opensta.rs b/crates/opensta-to-ir/src/opensta.rs index 4aabece..7b7cddb 100644 --- a/crates/opensta-to-ir/src/opensta.rs +++ b/crates/opensta-to-ir/src/opensta.rs @@ -381,7 +381,19 @@ pub fn run( }) .collect::>() .join("\n"); - let verilog_arg = paths_to_lines(inv.verilog); + + // Filter each verilog input through the named-module extractor. + // LibreLane + wafer.space final.v files have the structural top + // wrapped in an integration module (e.g. openframe_project_wrapper) + // that uses RTL operators OpenSTA's reader rejects. Extracting + // just the named --top from each file leaves any other modules + // — including offending wrappers — behind. Files that don't + // contain a `module ` declaration are passed through + // unchanged so hierarchical designs with sub-modules split across + // multiple files still link cleanly. See ADR 0009. + let filtered_verilog_paths = + filter_verilog_inputs(inv.verilog, inv.top, dir.path())?; + let verilog_arg = paths_to_lines(&filtered_verilog_paths); let mut cmd = Command::new(binary); cmd.arg("-no_init") @@ -440,6 +452,33 @@ fn paths_to_lines(paths: &[PathBuf]) -> String { .join("\n") } +/// Pre-extract the `top` module from each input file so OpenSTA's +/// structural-only Verilog reader doesn't choke on integration +/// wrappers. Files that don't contain `module ` are passed +/// through unchanged. Filtered outputs are written into `tmp_dir` +/// with unique names; the returned paths replace the original +/// `--verilog` paths in the OpenSTA invocation. See ADR 0009. +fn filter_verilog_inputs( + inputs: &[PathBuf], + top: &str, + tmp_dir: &Path, +) -> Result, InvokeError> { + let mut out = Vec::with_capacity(inputs.len()); + for (idx, src) in inputs.iter().enumerate() { + let content = std::fs::read_to_string(src).map_err(InvokeError::Spawn)?; + match crate::verilog_filter::extract_named_module(&content, top) { + Some(extracted) => { + let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("input"); + let dst = tmp_dir.join(format!("filtered_{idx:02}_{stem}.v")); + std::fs::write(&dst, extracted).map_err(InvokeError::Spawn)?; + out.push(dst); + } + None => out.push(src.clone()), + } + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/opensta-to-ir/src/verilog_filter.rs b/crates/opensta-to-ir/src/verilog_filter.rs new file mode 100644 index 0000000..9006c03 --- /dev/null +++ b/crates/opensta-to-ir/src/verilog_filter.rs @@ -0,0 +1,184 @@ +//! Verilog input filtering for OpenSTA's structural-only reader. +//! +//! OpenSTA's `read_verilog` accepts cell instantiations and bare-net +//! assigns only. RTL operators (`~`, `&`, `|`, `^`), bit-selects in +//! assigns, and ranged concatenations cause a parse-time `syntax +//! error`. The LibreLane + wafer.space integration flow wraps the +//! post-P&R structural body in an integration module (e.g. +//! `openframe_project_wrapper`) that patches the chip-frame pad ring +//! using exactly those operators. The wrapped file contains both a +//! readable-by-OpenSTA structural module *and* the unreadable +//! integration wrapper. +//! +//! `extract_named_module` lifts just the named module's +//! `module … endmodule` block out of the file, leaving any other +//! modules — including offending wrappers — behind. Multi-file +//! invocations get this treatment per-file; files that do not +//! contain the named module are passed to OpenSTA unchanged so +//! hierarchical designs that split sub-modules across files still +//! link cleanly. +//! +//! See ADR 0009 for the broader rule and rationale. + +/// Extract the `module … endmodule` block from `content`. +/// +/// Returns `None` if no module declaration matching `name` is found, +/// in which case callers should pass the file through unchanged +/// (it likely contains different sub-modules that OpenSTA should +/// still link). +/// +/// Recognition is anchored at line start so embedded comments and +/// string literals are safe; the `module ` identifier must be +/// followed by a non-identifier byte to avoid prefix-matches (`top` +/// must not match `top_synth`). Line-comment `//` and block-comment +/// `/* … */` between `module` and the name are not currently +/// supported — machine-generated post-P&R netlists put them on +/// dedicated lines, so this hasn't surfaced. +pub fn extract_named_module(content: &str, name: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + let start = find_module_start(&lines, name)?; + let end = find_endmodule(&lines, start)?; + let mut out = lines[start..=end].join("\n"); + out.push('\n'); + Some(out) +} + +fn find_module_start(lines: &[&str], name: &str) -> Option { + let needle = format!("module {name}"); + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + let Some(after) = trimmed.strip_prefix(&needle) else { + continue; + }; + let next_byte = after.as_bytes().first().copied(); + // Identifier must be terminated. Valid follows: whitespace, '(', + // ';', or end of line. Reject alphanumerics and '_' (which + // would mean `name` is just a prefix of a longer identifier). + let terminated = match next_byte { + None => true, + Some(b) => !(b.is_ascii_alphanumeric() || b == b'_'), + }; + if terminated { + return Some(idx); + } + } + None +} + +fn find_endmodule(lines: &[&str], start: usize) -> Option { + // Verilog forbids nested modules, so the first `endmodule` after + // `start` terminates the block. Anchor at line start to ignore + // the token inside comments or string literals. + for (idx, line) in lines.iter().enumerate().skip(start + 1) { + let trimmed = line.trim_start(); + if trimmed == "endmodule" + || trimmed.starts_with("endmodule ") + || trimmed.starts_with("endmodule\t") + || trimmed.starts_with("endmodule;") + || trimmed.starts_with("endmodule//") + || trimmed.starts_with("endmodule/*") + { + return Some(idx); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_named_module_from_two_module_file() { + let v = "\ +module wrapper(in, out); + assign out = ~in; +endmodule + +module top(a, b, q); + AND2 g0 (.A(a), .B(b), .Y(q)); +endmodule +"; + let extracted = extract_named_module(v, "top").unwrap(); + assert!(extracted.contains("AND2 g0")); + assert!(!extracted.contains("assign out = ~in")); + assert!(extracted.contains("endmodule")); + } + + #[test] + fn extracts_when_top_is_first_module() { + let v = "\ +module top(a, q); + BUF g0 (.A(a), .Y(q)); +endmodule + +module other; +endmodule +"; + let extracted = extract_named_module(v, "top").unwrap(); + assert!(extracted.contains("BUF g0")); + assert!(!extracted.contains("module other")); + } + + #[test] + fn returns_none_when_module_absent() { + let v = "\ +module other(a, q); + BUF g0 (.A(a), .Y(q)); +endmodule +"; + assert!(extract_named_module(v, "top").is_none()); + } + + #[test] + fn does_not_match_prefix_identifier() { + // `top` must not match `top_synth`. + let v = "\ +module top_synth(a, q); + BUF g0 (.A(a), .Y(q)); +endmodule +"; + assert!(extract_named_module(v, "top").is_none()); + } + + #[test] + fn handles_indented_module_keyword() { + // Some netlists indent module declarations (rare but valid). + let v = "\ + module top(a, q); + BUF g0 (.A(a), .Y(q)); + endmodule +"; + let extracted = extract_named_module(v, "top").unwrap(); + assert!(extracted.contains("BUF g0")); + } + + #[test] + fn extracts_module_with_no_port_list() { + let v = "\ +module top; + initial $finish; +endmodule +"; + let extracted = extract_named_module(v, "top").unwrap(); + assert!(extracted.contains("initial $finish")); + } + + #[test] + fn ignores_endmodule_in_comment() { + // Block-comment endmodule inside the wanted module shouldn't + // truncate the extraction. (Current implementation anchors on + // line-start of `endmodule` token, so a block comment whose + // content starts a line with `endmodule` would still trigger; + // this test documents the common case where `endmodule` is + // embedded inline in a comment.) + let v = "\ +module top(a, q); + // marker only: endmodule (this is not the real one) + BUF g0 (.A(a), .Y(q)); +endmodule +"; + let extracted = extract_named_module(v, "top").unwrap(); + assert!(extracted.contains("BUF g0")); + } +} diff --git a/docs/adr/0009-opensta-verilog-reader-inputs.md b/docs/adr/0009-opensta-verilog-reader-inputs.md new file mode 100644 index 0000000..8f56f2e --- /dev/null +++ b/docs/adr/0009-opensta-verilog-reader-inputs.md @@ -0,0 +1,114 @@ +# ADR 0009 — OpenSTA Verilog reader input constraints + +**Status:** Accepted. + +## Context + +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: line , 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: + +1. **Final-stage outputs from the LibreLane/OpenROAD flow are + sometimes wrapped.** LibreLane itself only ever reads structural + netlists (`.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. + +2. **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. + +## Decision + +The "structural-only" constraint is **owned by `opensta-to-ir`**, +not by the caller. Specifically: + +1. **`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`. + +2. **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 `.pnl.v` +alongside its wrapped `_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`). + +## Consequences + +- End-user runs of `jacquard sim --sdf ` 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. + +## Links + +- ADR 0001 — OpenSTA as oracle and sole STA path (the upstream + tool whose constraints these are). +- ADR 0006 — SDF preprocessing model (the surrounding flow that + consumes these inputs). +- `docs/plans/ws3-cosim-sdf-followup.md` — the prior workaround + this ADR corrects. diff --git a/docs/plans/ws3-cosim-sdf-followup.md b/docs/plans/ws3-cosim-sdf-followup.md index e430074..bb2c1af 100644 --- a/docs/plans/ws3-cosim-sdf-followup.md +++ b/docs/plans/ws3-cosim-sdf-followup.md @@ -27,35 +27,44 @@ only accepts pre-converted IR via `--timing-ir`. The `tests/mcu_soc/` cosim flow that used to load SDF via the testbench config now needs an explicit pre-conversion step. -### Important: feed `top_synth.v` to OpenSTA, not `6_final.v` - -OpenSTA's Verilog parser only accepts gate-level structural Verilog. -`tests/mcu_soc/data/6_final.v` is the `openframe_project_wrapper` — -a thin wrapper around the synthesized `top` module — and contains -RTL-style `assign` statements (`~`, concatenations, bit-selects) that -OpenSTA rejects with a syntax error around line 135 (`assign -gpio_oeb[0] = ~( \io$soc_flash_clk$oe );`). - -The actual gate-level netlist that the SDF was generated against is -`tests/mcu_soc/data/top_synth.v`, with module name `top`. The SDF's -`(DESIGN "top")` header confirms this. Use `top_synth.v` + `--top top` -for the OpenSTA invocation: +### Feed `6_final.v` directly to `opensta-to-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 **ADR 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. ```sh -# 1. Convert SDF → IR once. Use top_synth.v (clean gate-level), NOT -# 6_final.v (the wrapper has RTL assigns that crash OpenSTA). +# 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/top_synth.v \ + --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 -# 2. Run cosim with the pre-converted IR. Cosim still loads 6_final.v -# (the wrapper) because that's what carries GPIO ports. The IR -# consumer's hierarchy-prefix detection automatically strips the -# 'top_inst/' prefix from the wrapper's cell paths so they match the -# IR's instance names. +# 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 \ @@ -63,13 +72,6 @@ cargo run -r --features metal --bin jacquard -- cosim \ --timing-ir tests/mcu_soc/data/6_final.jtir ``` -Verified working on sky130 mcu_soc (2026-04-29): -`Loaded IR timing: 28162 matched, 2090 unmatched (251 Liberty -fallback), 0 wire delays, 3756 DFF constraints` → -`SIMULATION: PASSED`. The "0 wire delays" is the documented WS2.2 -gap — `opensta-to-ir` doesn't yet emit `interconnect_delays`, so wire -contributions are missing from timing. Returns when WS2.2 lands. - `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). From 2e6f7328a601d5c269033ffd801b56de05807316 Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Tue, 19 May 2026 08:49:58 +0100 Subject: [PATCH 3/5] =?UTF-8?q?ci:=20rename=20--max-cycles=20=E2=86=92=20-?= =?UTF-8?q?-max-clock-edges=20+=20pipefail=20(subsumes=20#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cosim/sim CLI was renamed --max-cycles → --max-clock-edges on main but the MCU SoC workflow steps still passed the old flag. PR #63 fixed this in isolation; folding the same change here so #65 can be evaluated against CI on its own. Three call sites updated (first cosim 500K, second cosim 10K, replay sim 10K). Also adds `set -o pipefail` to the two cosim steps that lacked it, so future failures inside the `tee` pipeline surface immediately rather than greening through. Closes #62. Supersedes #63 — close PR #63 after this merges. Co-developed-by: Claude Code v2.1.143 (claude-opus-4-7) --- .github/workflows/ci.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01734fa..b7fc518 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -620,10 +620,11 @@ jobs: - name: Run GPU co-simulation (500K ticks) timeout-minutes: 30 run: | + set -o pipefail cargo run --release --features metal --bin jacquard -- cosim \ tests/mcu_soc/data/6_final.v \ --config tests/mcu_soc/sim_config.json --top-module top \ - --max-cycles 500000 \ + --max-clock-edges 500000 \ 2>&1 | tee cosim_output.txt - name: Verify UART boot output @@ -647,10 +648,11 @@ jobs: - name: Capture stimulus + timing VCD via cosim (10K ticks) timeout-minutes: 15 run: | + set -o pipefail cargo run --release --features metal --bin jacquard -- cosim \ tests/mcu_soc/data/6_final.v \ --config tests/mcu_soc/sim_config.json --top-module top \ - --max-cycles 10000 \ + --max-clock-edges 10000 \ --sdf tests/mcu_soc/data/6_final_stripped.sdf \ --sdf-corner typ \ --stimulus-vcd tests/mcu_soc/stimulus.vcd \ @@ -667,7 +669,7 @@ jobs: tests/mcu_soc/jacquard_replay_mcu.vcd \ 1 \ --top-module top \ - --max-cycles 10000 \ + --max-clock-edges 10000 \ 2>&1 | tee metal_mcu_replay.txt - name: Report simulation results From c028bba4deda2e668bc3678e2b36e71f91e383b3 Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Tue, 19 May 2026 08:50:44 +0100 Subject: [PATCH 4/5] ci: build MCU SoC timing IR on Linux for Metal cosim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCU SoC Metal Simulation job's second cosim invocation was passing flags cosim never accepted (--sdf, --sdf-corner) — historically masked by clap rejecting `--max-cycles` first. Cosim only accepts pre-built timing IR (--timing-ir <.jtir>); raw SDF support is the documented post-phase-0-roadmap follow-up. Add a new Linux job (prepare-mcu-soc-jtir) that builds OpenSTA, installs the pinned sky130A PDK via volare, builds opensta-to-ir, and runs it against tests/mcu_soc/data/6_final.sdf to produce a 6_final.jtir artifact. The Metal job `needs:` this job, downloads the artifact, and feeds it to cosim via --timing-ir. The 6_final.v file has a chipflow integration wrapper module (openframe_project_wrapper) that uses RTL operators OpenSTA's parser rejects. opensta-to-ir's Verilog input filter handles this transparently — see the feat(opensta-to-ir) commit earlier in this PR and ADR 0009 for details. Also drop the redundant "Strip SDF timing checks" step (it overwrote a tracked file with identical content; raw SDF is no longer fed to cosim) and document the Python tooling convention in CLAUDE.md (uv dev dependencies for Python tools; volare for PDK install with a forward note on the upstream ciel rename). Co-developed-by: Claude Code v2.1.143 (claude-opus-4-7) --- .github/workflows/ci.yml | 137 ++++++++++++++++++++++++++++++++++++--- CLAUDE.md | 17 +++++ 2 files changed, 146 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7fc518..d304f65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -552,6 +552,128 @@ jobs: name: benchmark-results path: benchmark_results.txt + # Pre-generate the MCU SoC timing IR (.jtir) on Linux so the macOS + # Metal runner — which doesn't have sky130 Liberty or OpenSTA — can + # consume it via `cosim --timing-ir`. Cosim does not accept raw SDF + # (see `docs/plans/post-phase-0-roadmap.md` — adding a subprocess + # wrapper is tracked there as a follow-up). + prepare-mcu-soc-jtir: + name: Prepare MCU SoC timing IR + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Init vendor/opensta submodule + run: git submodule update --init vendor/opensta + + - name: Resolve OpenSTA submodule SHA + id: opensta-sha + run: | + sha=$(git ls-tree HEAD vendor/opensta | awk '{print $3}') + echo "sha=$sha" >> "$GITHUB_OUTPUT" + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install OpenSTA build deps + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake bison flex libfl-dev swig \ + tcl-dev tcl-tclreadline libeigen3-dev libfmt-dev wget + + - name: Cache CUDD + id: cache-cudd + uses: actions/cache@v4 + with: + path: ~/cudd-3.0.0 + key: cudd-3.0.0-v1 + + - name: Build CUDD from source + if: steps.cache-cudd.outputs.cache-hit != 'true' + run: | + cd "$HOME" + wget -q https://raw.githubusercontent.com/davidkebo/cudd/main/cudd_versions/cudd-3.0.0.tar.gz + tar -xf cudd-3.0.0.tar.gz + cd cudd-3.0.0 + ./configure + make -j"$(nproc)" + + # Shares the cache key with the opensta-to-ir-tests job so whichever + # runs first warms it for the other. + - name: Cache OpenSTA build + id: cache-opensta + uses: actions/cache@v4 + with: + path: vendor/opensta/build + key: opensta-${{ steps.opensta-sha.outputs.sha }}-cudd-3.0.0-v1 + + - name: Build OpenSTA + if: steps.cache-opensta.outputs.cache-hit != 'true' + env: + CUDD_DIR: /home/runner/cudd-3.0.0 + run: scripts/build-opensta.sh + + - name: Cache cargo (opensta-to-ir) + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + crates/opensta-to-ir/target/ + key: ${{ runner.os }}-cargo-opensta-to-ir-${{ hashFiles('crates/opensta-to-ir/Cargo.toml', 'crates/timing-ir/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo-opensta-to-ir- + + # sky130 Liberty isn't checked in. Volare (pinned in pyproject.toml + # under [tool.jacquard.pdks.sky130]) fetches the open_pdks + # distribution. Cache `~/.volare` keyed on the pinned hash so the + # ~500MB download happens once and warm runs reuse it. + - name: Cache volare PDK (sky130A) + id: cache-volare + uses: actions/cache@v4 + with: + path: ~/.volare + key: volare-sky130A-c6d73a35f524070e85faff4a6a9eef49553ebc2b + + - name: Install sky130A PDK via volare + if: steps.cache-volare.outputs.cache-hit != 'true' + run: | + uv sync --group dev + uv run volare enable c6d73a35f524070e85faff4a6a9eef49553ebc2b + + - name: Build opensta-to-ir + working-directory: crates/opensta-to-ir + run: cargo build --release --bin opensta-to-ir + + - name: Generate MCU SoC timing IR + # opensta-to-ir transparently extracts `module <--top>` from + # each input file before invoking OpenSTA (see ADR 0009), so + # the chipflow `openframe_project_wrapper` in 6_final.v — + # which uses RTL operators OpenSTA's parser rejects — is + # filtered out automatically. + run: | + LIB=$HOME/.volare/volare/sky130/versions/c6d73a35f524070e85faff4a6a9eef49553ebc2b/sky130A/libs.ref/sky130_fd_sc_hd/lib/sky130_fd_sc_hd__tt_025C_1v80.lib + crates/opensta-to-ir/target/release/opensta-to-ir \ + --liberty "$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 + + - name: Upload MCU SoC timing IR artifact + uses: actions/upload-artifact@v4 + with: + name: mcu-soc-jtir + path: tests/mcu_soc/data/6_final.jtir + # Run GPU simulation on macOS with pre-built post-P&R netlist mcu-soc-metal: name: MCU SoC Metal Simulation @@ -560,6 +682,7 @@ jobs: # (mcu-soc-cvc, mcu-soc-comparison) gate on this via `needs:`. runs-on: macos-runner-1 timeout-minutes: 30 + needs: [prepare-mcu-soc-jtir] steps: - uses: actions/checkout@v4 with: @@ -638,12 +761,11 @@ jobs: exit 1 fi - - name: Strip SDF timing checks for GPU simulation - run: | - # Remove TIMINGCHECK directives from SDF (timing checks can cause parser errors) - uv run tests/mcu_soc/cvc/strip_sdf_checks.py \ - tests/mcu_soc/data/6_final.sdf \ - tests/mcu_soc/data/6_final_stripped.sdf + - name: Download MCU SoC timing IR + uses: actions/download-artifact@v4 + with: + name: mcu-soc-jtir + path: tests/mcu_soc/data/ - name: Capture stimulus + timing VCD via cosim (10K ticks) timeout-minutes: 15 @@ -653,8 +775,7 @@ jobs: tests/mcu_soc/data/6_final.v \ --config tests/mcu_soc/sim_config.json --top-module top \ --max-clock-edges 10000 \ - --sdf tests/mcu_soc/data/6_final_stripped.sdf \ - --sdf-corner typ \ + --timing-ir tests/mcu_soc/data/6_final.jtir \ --stimulus-vcd tests/mcu_soc/stimulus.vcd \ --timing-vcd tests/mcu_soc/jacquard_timed_mcu.vcd \ 2>&1 | tee cosim_stimulus.txt diff --git a/CLAUDE.md b/CLAUDE.md index b2548dd..211382b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -170,6 +170,23 @@ uv run netlist-graph path tests/timing_test/minimal_build/6_final.v "gpio_in[40] See `docs/timing-violations.md` for the full guide on enabling GPU-side setup/hold violation checks, interpreting violation reports, and tracing violations back to source signals using `netlist_graph`. +## Python tooling + +Python tools (PDK fetchers, build scripts, harness utilities) belong in +the workspace's `uv` dev dependency group, not as ad-hoc system pip +installs. Add them under `[dependency-groups].dev` in the root +`pyproject.toml`; install with `uv sync --group dev`; invoke via +`uv run `. CI mirrors this — see the dev group declarations and +PDK install steps in `.github/workflows/ci.yml`. + +PDK Liberty / tech files are fetched via **volare** (pinned in +`[tool.jacquard.pdks.*]` in the root `pyproject.toml`, hashes mirrored +into `crates/opensta-to-ir/tests/opensta_integration.rs`). Volare has +been renamed to **ciel** upstream +() — the existing `volare` +PyPI package still works at the pinned version, but a migration to the +`ciel` package is open follow-up. + ## Releases Cutting a release is a lightweight, manual procedure: roll the From 9121afe153a90fdb6007a6eb615f096c9a64cee0 Mon Sep 17 00:00:00 2001 From: Rob Taylor Date: Tue, 19 May 2026 08:51:06 +0100 Subject: [PATCH 5/5] ci(mcu-soc-cvc): align CVC build command with cvc-reference job mcu-soc-cvc previously ran `make -f makefile.cvc64` which upstream removed in commit 1c5e043e (2026-03-13). The cvc-reference job was fixed at the same time; mcu-soc-cvc kept the stale invocation because the job had been transitively skipped (mcu-soc-metal failed first, so this job never ran). Now that mcu-soc-metal is green it ran for the first time and hit the deleted makefile. Mirror the cvc-reference job's working build: top-level src/Makefile with the same CFLAGS workaround, output at ../build64/cvc64. Co-developed-by: Claude Code v2.1.143 (claude-opus-4-7) --- .github/workflows/ci.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d304f65..4f9298d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1096,11 +1096,14 @@ jobs: sudo apt-get install -y --no-install-recommends build-essential zlib1g-dev git clone --depth 1 https://github.com/cambridgehackers/open-src-cvc.git ~/cvc-src cd ~/cvc-src/src - make -f makefile.cvc64 -j$(nproc) \ - OPTFLGS="" \ - CFLAGS="-Wall -I../pli_incs -D__RHEL6X__ -fno-pie -no-pie -O0" + # Upstream refactored the build (commit 1c5e043e, 2026-03-13): + # makefile.cvc64 → top-level Makefile; output now at + # ../build64/cvc64. Mirrors the working invocation in the + # cvc-reference job earlier in this workflow. + make -j$(nproc) \ + CFLAGS="-pipe -Wall -Wno-incompatible-pointer-types -Wno-implicit-function-declaration -I../pli_incs -D__RHEL6X__ -fno-pie -no-pie -O0" mkdir -p ~/cvc/bin - cp cvc64 ~/cvc/bin/ + cp ../build64/cvc64 ~/cvc/bin/ - name: Add CVC to PATH run: echo "$HOME/cvc/bin" >> "$GITHUB_PATH"