From 8ca0388134fa4fd026497135969b168abe2849a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 12:32:34 +0200 Subject: [PATCH 1/8] util: synthesis-stage QoR check against the regular rules file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make metadata-check-synth gates a synthesis-only run on the synth__/constraints__ subset of the design's regular RULES_JSON (checkMetadata.py --only-prefix), and make metadata-synth runs synth + metadata-generate + that check. No per-variant rules files: the same rules-base.json that gates the full flow gates the fast signal, keeping the two congruent. genMetrics.py tolerates a synthesis-only tree (SDC falls back to 1_synth.sdc; grt/finish extraction is guarded; empty placeholder netlists hash as absent, matching a make run that never wrote them). synth_syn.tcl always writes the 1_synth.v gate-level netlist (write_lec_verilog, stripping physical-only masters) so the Bazel synth stage can declare it as an output in SYN mode. Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- flow/scripts/synth_syn.tcl | 7 +++++++ flow/util/checkMetadata.py | 15 +++++++++++++ flow/util/genMetrics.py | 43 +++++++++++++++++++++++++------------- flow/util/utils.mk | 18 ++++++++++++++++ 4 files changed, 68 insertions(+), 15 deletions(-) diff --git a/flow/scripts/synth_syn.tcl b/flow/scripts/synth_syn.tcl index efe1f7779b..5630bec305 100644 --- a/flow/scripts/synth_syn.tcl +++ b/flow/scripts/synth_syn.tcl @@ -75,3 +75,10 @@ orfs_write_db $::env(RESULTS_DIR)/1_synth.odb # out by OpenSTA that has no dependencies. Sole writer of # 1_synth.sdc. orfs_write_sdc $::env(RESULTS_DIR)/1_synth.sdc + +# Gate-level netlist for LEC (write_lec_verilog strips physical-only +# masters, matching the CTS-stage LEC convention) and any other netlist +# consumer. The Bazel synthesis action declares this file as an output, +# so it must be written unconditionally. +source $::env(SCRIPTS_DIR)/lec_check.tcl +write_lec_verilog 1_synth.v diff --git a/flow/util/checkMetadata.py b/flow/util/checkMetadata.py index 1a525d88a8..71c25425df 100755 --- a/flow/util/checkMetadata.py +++ b/flow/util/checkMetadata.py @@ -33,6 +33,14 @@ ) parser.add_argument("--metadata", "-m", required=True, help="The metadata file") parser.add_argument("--rules", "-r", required=True, nargs="+", help="The rules file") +parser.add_argument( + "--only-prefix", + nargs="+", + default=None, + help="Check only rules whose field starts with one of these prefixes. " + "Lets a partial run (e.g. synthesis-only) be gated by a full-flow " + "rules file without tripping the missing-field error.", +) args = parser.parse_args() with open(args.metadata) as metadataFile: @@ -46,6 +54,13 @@ else: print(f"[WARN] File {filePath} not found") +if args.only_prefix: + rules = { + field: rule + for field, rule in rules.items() + if any(field.startswith(prefix) for prefix in args.only_prefix) + } + if len(rules) == 0: print("No rules") sys.exit(1) diff --git a/flow/util/genMetrics.py b/flow/util/genMetrics.py index d845a4e065..9f20e89b16 100755 --- a/flow/util/genMetrics.py +++ b/flow/util/genMetrics.py @@ -192,9 +192,12 @@ def git_head_commit(git_exe, folder): def file_sha1(path): - """SHA-1 of `path`, or "N/A" if absent. Read in chunks so large + """SHA-1 of `path`, or "N/A" if absent or empty. Empty counts as + absent so Bazel's touched placeholder files (e.g. the canonicalize + RTLIL under SYNTH_USE_SYN or SYNTH_NETLIST_FILES) hash the same as + a make run that never wrote the file. Read in chunks so large netlists don't blow the heap.""" - if not os.path.isfile(path): + if not os.path.isfile(path) or os.path.getsize(path) == 0: return "N/A" hasher = hashlib.sha1() with open(path, "rb") as f: @@ -258,7 +261,13 @@ def extract_metrics( # Clocks # ========================================================================= - clk_list = read_sdc(resultPath + "/2_floorplan.sdc") + # Prefer the floorplan SDC; on a synthesis-only tree (e.g. the "syn" + # flow variant stops after 1_synth) fall back to the canonicalized + # synthesis SDC, which carries the same create_clock lines. + sdc_file = resultPath + "/2_floorplan.sdc" + if not os.path.isfile(sdc_file): + sdc_file = resultPath + "/1_synth.sdc" + clk_list = read_sdc(sdc_file) metrics_dict["constraints__clocks__count"] = len(clk_list) metrics_dict["constraints__clocks__details"] = clk_list @@ -277,22 +286,26 @@ def extract_metrics( # Global Route # ========================================================================= merge_jsons(logPath, metrics_dict, "5_*.json") - extractTagFromFile( - "globalroute__timing__clock__slack", - metrics_dict, - "^\\[INFO FLW-....\\] Clock .* slack (\\S+)", - logPath + "/5_1_grt.log", - ) + # Guarded so a synthesis-only tree doesn't pollute the metadata with + # "ERR" values for stages that never ran. + if os.path.isfile(logPath + "/5_1_grt.log"): + extractTagFromFile( + "globalroute__timing__clock__slack", + metrics_dict, + "^\\[INFO FLW-....\\] Clock .* slack (\\S+)", + logPath + "/5_1_grt.log", + ) # Finish # ========================================================================= merge_jsons(logPath, metrics_dict, "6_*.json") - extractTagFromFile( - "finish__timing__wns_percent_delay", - metrics_dict, - baseRegEx.format("finish slack div critical path delay", "(\\S+)"), - rptPath + "/6_finish.rpt", - ) + if os.path.isfile(rptPath + "/6_finish.rpt"): + extractTagFromFile( + "finish__timing__wns_percent_delay", + metrics_dict, + baseRegEx.format("finish slack div critical path delay", "(\\S+)"), + rptPath + "/6_finish.rpt", + ) extractGnuTime("finish", metrics_dict, logPath + "/6_report.log") diff --git a/flow/util/utils.mk b/flow/util/utils.mk index 0dbeb1ea6e..b1d723f8fb 100644 --- a/flow/util/utils.mk +++ b/flow/util/utils.mk @@ -3,6 +3,14 @@ .PHONY: metadata metadata: finish metadata-generate metadata-check +# Synthesis-only metadata: generate and check QoR after just the synth +# stage, without running the full flow. The synthesis-stage subset of +# the design's regular rules file gates the run, so no per-variant +# rules file needs to be committed. +.PHONY: metadata-synth +metadata-synth: export RULES_JSON = $(DESIGN_DIR)/rules-base.json +metadata-synth: synth metadata-generate metadata-check-synth + .PHONY: metadata-generate metadata-generate: mkdir -p $(REPORTS_DIR) @@ -25,6 +33,16 @@ metadata-check: -r $(RULES_JSON) 2>&1 \ | tee $(abspath $(REPORTS_DIR)/metadata-check.log) +# Check only the synthesis-stage subset of RULES_JSON, so a +# synthesis-only run can be gated by the design's full-flow rules file. +.PHONY: metadata-check-synth +metadata-check-synth: + $(PYTHON_EXE) $(UTILS_DIR)/checkMetadata.py \ + -m $(REPORTS_DIR)/metadata.json \ + -r $(RULES_JSON) \ + --only-prefix synth__ constraints__ 2>&1 \ + | tee $(abspath $(REPORTS_DIR)/metadata-check.log) + .PHONY: clean_metadata clean_metadata: rm -f $(REPORTS_DIR)/design-dir.txt From a498113f6acd9bb74eb8d2c54128d07cd47f7305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 12:32:34 +0200 Subject: [PATCH 2/8] designs: forced-engine flow variants and asap7 OpenROAD-SYN suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every design() gets manual full-flow variants that pin the synthesis engine regardless of the config.mk default: _syn_* forces SYNTH_USE_SYN=1 so any design can be tested under OpenROAD-SYN before its config.mk switches, and _yosys_* forces the yosys engine so the baseline stays reproducible after a switch (asap7/gcd today). Designs with a rules-base.json get two QoR gates per variant, both checking the design's regular rules file with the regular checkMetadata.py — the local counterpart of the Jenkins/dashboard signal: __test runs the full flow against all rules; __synth_test is the minutes-scale iteration tier, checking only the synth__/constraints__ subset while sharing its synthesis action with the full-flow test. //flow/designs/asap7:syn_test and :syn_synth_test aggregate the 14 suite designs (fastest first, known failures included by design — the suites are a status signal, not a green-only gate; slow designs are documented in SYN_SLOW_DESIGNS and reproduced individually). gcd's single-process comparison consumes the generated gcd_yosys_* variant instead of hand-rolling its own yosys-engine flow. Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- flow/designs/asap7/BUILD | 111 +++++++++++++++++++++++++++++++++++ flow/designs/asap7/gcd/BUILD | 18 +++--- flow/designs/design.bzl | 61 +++++++++++++++++++ 3 files changed, 182 insertions(+), 8 deletions(-) create mode 100644 flow/designs/asap7/BUILD diff --git a/flow/designs/asap7/BUILD b/flow/designs/asap7/BUILD new file mode 100644 index 0000000000..20005b4141 --- /dev/null +++ b/flow/designs/asap7/BUILD @@ -0,0 +1,111 @@ +# OpenROAD-SYN test suites for asap7 — the local counterpart of the +# Jenkins QoR signal (https://dashboard.precisioninno.com/). +# +# Every design gets manual forced-engine flow variants from the +# config.mk DSL (see flow/designs/design.bzl): _syn_* pins +# SYNTH_USE_SYN=1 and _yosys_* pins the yosys engine, regardless +# of the config.mk default. Designs with a rules-base.json get two QoR +# gates per variant, both checking the design's regular rules file with +# the regular checkMetadata.py — congruent with what Jenkins measures: +# +# bazelisk test --keep_going //flow/designs/asap7:syn_synth_test +# Fast tier: synthesis only, checks the synth__/constraints__ subset +# of rules-base.json. Minutes-scale; QoR-only (no LEC), so it cannot +# prove correctness — use it to iterate, not to conclude. +# +# bazelisk test --keep_going //flow/designs/asap7:syn_test +# Full flow, all rules-base.json gates. The real signal; shares its +# synthesis action with the fast tier. +# +# Sharpening the loop on one design or stage: +# +# bazelisk test //flow/designs/asap7/jpeg:jpeg_encoder_syn_synth_test +# bazelisk test //flow/designs/asap7/jpeg:jpeg_encoder_syn_test +# bazelisk build //flow/designs/asap7/jpeg:jpeg_encoder_syn_route +# bazelisk test //flow/designs/asap7/gcd:gcd_yosys_test # yosys A/B +# +# Both suites deliberately include designs that fail OpenROAD-SYN today +# — they are a status signal, not a green-only gate. A design can fail +# by not synthesizing at all, or by missing rules-base.json gates that +# its regular flow meets. + +# Design directory -> DESIGN_NAME (bazel target prefix), ordered +# fastest-first so --keep_going runs surface quick failures early. +# Known failures / quirks are listed last. "minimal" is excluded: it +# has no VERILOG_FILES and thus no generated design targets. +# +# Known OpenROAD-SYN failures today: +# - mock-alu: SDC references yosys-style register names (*io_out_REG*) +# absent from the SYN netlist. +# - riscv32i: slang rejects procedural assignment to nets (dmem.v). +# - mock-cpu: slang rejects endianness-mismatched part-select +# (wptr_full.v). +# - aes-block: hierarchical BLOCKS; synthesizes, but SYN area blows past +# the yosys-derived rules-base.json gate. +SYN_TEST_DESIGNS = { + "gcd": "gcd", # smoketest, fast + "uart": "uart", + "mock-alu": "MockAlu", + "riscv32i": "riscv_top", + "aes": "aes_cipher_top", + "aes_lvt": "aes_cipher_top", + "aes-mbff": "aes_cipher_top", + "jpeg": "jpeg_encoder", + "jpeg_lvt": "jpeg_encoder", + "ethmac": "ethmac", + "ethmac_lvt": "ethmac", + "mock-cpu": "mock_cpu", + "gcd-ccs": "gcd", # CCS liberty + "aes-block": "aes_cipher_top", # hierarchical BLOCKS +} + +# Designs whose OpenROAD-SYN run is too slow (or does not terminate) for +# the suites. Their generated per-design targets stay tags=["manual"] +# like all forced-engine targets, but they are deliberately left out of +# the suites so those remain runnable; reproduce individually, e.g. +# bazelisk build //flow/designs/asap7/ibex:ibex_core_syn_synth +# +# - ibex: sv_elaborate/synthesize/repair_design complete in seconds, but +# report_metrics ("Report metrics stage 1, synth...") spins in STA for +# hours (observed >2h on 48 threads with no progress). +# - cva6: synthesize itself takes ~73s, but repair_design -pre_placement +# was still running after 10+ minutes when the status sweep was cut +# off; not yet characterized to completion. +# - swerv_wrapper: largest design in the suite; not yet run to +# completion under OpenROAD-SYN in this environment. +# - riscv32i-mock-sram: hierarchical BLOCKS (fakeram) — the syn variant +# first needs the block's full PnR flow for its abstract; not yet run +# to completion. +SYN_SLOW_DESIGNS = { + "cva6": "cva6", + "ibex": "ibex_core", + "riscv32i-mock-sram": "riscv_top", + "swerv_wrapper": "swerv_wrapper", +} + +# Explicitly listed manual tests DO run through a test_suite (the manual +# tag only exempts them from wildcard expansion); the suites themselves +# are manual so `bazelisk test //flow/...` does not pull them in either. +test_suite( + name = "syn_test", + tags = ["manual"], + tests = [ + "//flow/designs/asap7/{pkg}:{name}_syn_test".format( + name = name, + pkg = pkg, + ) + for pkg, name in SYN_TEST_DESIGNS.items() + ], +) + +test_suite( + name = "syn_synth_test", + tags = ["manual"], + tests = [ + "//flow/designs/asap7/{pkg}:{name}_syn_synth_test".format( + name = name, + pkg = pkg, + ) + for pkg, name in SYN_TEST_DESIGNS.items() + ], +) diff --git a/flow/designs/asap7/gcd/BUILD b/flow/designs/asap7/gcd/BUILD index b8c3ddc518..9b63695b82 100644 --- a/flow/designs/asap7/gcd/BUILD +++ b/flow/designs/asap7/gcd/BUILD @@ -3,13 +3,13 @@ load("@orfs_designs//:designs.bzl", "DESIGNS") load("@rules_shell//shell:sh_test.bzl", "sh_test") load("//flow/designs:design.bzl", "design") -# SYNTH_USE_SYN is make-only for now: bazel-orfs's synth stage always -# runs the yosys flow and neither stages the Verilog sources nor sets -# VERILOG_FILES for the OpenROAD synthesis step, so the built-in -# synthesizer opt-in must not reach the bazel arguments. +# gcd's regular flow follows config.mk onto OpenROAD-SYN +# (SYNTH_USE_SYN=1). The single-process comparison below deliberately +# exercises the yosys netlist path (single_flow.tcl seeds from +# 1_2_yosys.v), so it consumes the forced-yosys gcd_yosys_* variant the +# config.mk DSL emits for every design (see flow/designs/design.bzl). design( config = "config.mk", - local_arguments = ["SYNTH_USE_SYN"], ) # Stage-boundary .odb/.sdc stems shared by the normal per-stage flow @@ -56,7 +56,7 @@ SINGLE_FLOW_EXTRA_OUTS = [ # this package. orfs_run( name = "gcd_single_flow", - src = ":gcd_synth", + src = ":gcd_yosys_synth", outs = [ "results/asap7/gcd/single/{}.{}".format(stem, ext) for stem in SINGLE_FLOW_STAGES.values() @@ -79,11 +79,13 @@ orfs_run( variant = "single", ) -# Normal-flow stage outputs, one filegroup per compared file. +# Normal-flow stage outputs, one filegroup per compared file. Taken from +# the yosys-engine variant above so both sides of the byte-compare start +# from the same yosys netlist. [ filegroup( name = "gcd_base_{}_{}".format(stage, ext), - srcs = [":gcd_" + stage], + srcs = [":gcd_yosys_" + stage], output_group = "{}.{}".format(stem, ext), ) for stage, stem in SINGLE_FLOW_STAGES.items() diff --git a/flow/designs/design.bzl b/flow/designs/design.bzl index b4efc0b1d0..c317e34b9a 100644 --- a/flow/designs/design.bzl +++ b/flow/designs/design.bzl @@ -1,5 +1,6 @@ """BUILD boilerplate for flow/designs/.""" +load("@bazel-orfs//:openroad.bzl", "orfs_flow") load("@orfs_designs//:designs.bzl", "orfs_design") # Per filegroup target: extensions included in the filegroup. @@ -51,6 +52,65 @@ def _export_design_files(): visibility = ["//visibility:private"], ) +def _engine_variant_targets( + name, + platform, + verilog_files, + arguments, + user_arguments, + sources, + user_sources, + macros, + stage_data, + tags): # buildifier: disable=unused-variable + """Forced-engine flow variants for one design (orfs_design extra hook). + + Emits, always tagged "manual" (even for CI designs, so only explicit + invocations such as //flow/designs/asap7:syn_test run them), two full + flows that pin the synthesis engine regardless of the config.mk + default: + + - variant "syn": SYNTH_USE_SYN=1 (OpenROAD-SYN) — lets any design be + tested under OpenROAD-SYN before its config.mk officially switches. + - variant "yosys": SYNTH_USE_SYN=0 — keeps the yosys baseline + reproducible after a design officially switches (e.g. asap7/gcd). + + When the design has a rules-base.json each variant gets the same QoR + gates as the regular flow, congruent with the Jenkins/dashboard + signal because they check the same rules file with the same script: + + - __test: the full flow gated on all of rules-base.json + (the real, dashboard-equivalent gate). + - __synth_test: the fast tier — synthesis plus a check + of only the synth__/constraints__ subset of rules-base.json. + QoR-only (no LEC), but a minutes-scale iteration signal that shares + its synth action with the full-flow variant. + + The generated __update targets must NOT be run: they + would overwrite the design's rules-base.json with the forced engine's + numbers. + """ + for engine, use_syn in [("syn", "1"), ("yosys", "0")]: + orfs_flow( + name = name, + verilog_files = verilog_files, + pdk = "//flow:" + platform, + arguments = arguments | {"SYNTH_USE_SYN": use_syn}, + user_arguments = user_arguments, + # sources carries the auto-detected rules-base.json as + # RULES_JSON, which gates the variant's tests. + sources = sources, + user_sources = user_sources, + macros = macros, + stage_data = stage_data, + variant = engine, + tags = ["manual"], + test_kwargs = {"tags": ["manual"]}, + # Sibling packages consume stage outputs (e.g. asap7/gcd's + # single-process comparison seeds from the yosys netlist). + visibility = ["//visibility:public"], + ) + def design(config = "config.mk", user_arguments = [], user_sources = [], local_arguments = []): """Standard BUILD body for flow/designs///. @@ -75,6 +135,7 @@ def design(config = "config.mk", user_arguments = [], user_sources = [], local_a user_sources = user_sources, local_arguments = local_arguments, blender = True, + extra = _engine_variant_targets, ) def files(group, extra_srcs = None): From 1408356cc20a9fdcca1341233bf7a438c943517a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 12:37:25 +0200 Subject: [PATCH 3/8] =?UTF-8?q?util:=20syn-dashboard=20=E2=80=94=20local?= =?UTF-8?q?=20markdown=20TL;DR=20of=20the=20OpenROAD-SYN=20signal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bazelisk run //flow/designs:syn-dashboard prints a GitHub-flavored markdown status table over the forced-engine QoR tests already built in this workspace: per design, pass/fail for the fast synthesis tier and the full flow under each engine, the failing rule fields harvested from checkMetadata.py output, and the age of the newest result. It reads bazel-testlogs only — builds nothing, so it is instant — and the output pastes into a PR or issue as-is: the local TL;DR counterpart of https://dashboard.precisioninno.com/. Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- flow/designs/BUILD | 11 +++ flow/util/BUILD | 10 ++- flow/util/synDashboard.py | 173 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 flow/util/synDashboard.py diff --git a/flow/designs/BUILD b/flow/designs/BUILD index 0fe28bc544..c11384ea63 100644 --- a/flow/designs/BUILD +++ b/flow/designs/BUILD @@ -1 +1,12 @@ exports_files(["BUILD"]) + +# Local TL;DR of https://dashboard.precisioninno.com/: a GitHub-markdown +# status table over the forced-engine OpenROAD-SYN QoR tests already +# built in this workspace (see flow/designs/design.bzl). Reads +# bazel-testlogs only — builds nothing, instant. +# +# bazelisk run //flow/designs:syn-dashboard +alias( + name = "syn-dashboard", + actual = "//flow/util:synDashboard", +) diff --git a/flow/util/BUILD b/flow/util/BUILD index cc21b559c6..a8e1092dbb 100644 --- a/flow/util/BUILD +++ b/flow/util/BUILD @@ -1,4 +1,4 @@ -load("@rules_python//python:defs.bzl", "py_library", "py_test") +load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test") load("@rules_python//python:pip.bzl", "compile_pip_requirements") exports_files(["open_plots.sh"]) @@ -33,6 +33,14 @@ filegroup( visibility = ["//visibility:public"], ) +# TL;DR markdown dashboard over locally built OpenROAD-SYN QoR tests; +# run via the //flow/designs:syn-dashboard alias. +py_binary( + name = "synDashboard", + srcs = ["synDashboard.py"], + visibility = ["//flow/designs:__pkg__"], +) + py_library( name = "genMetrics_lib", srcs = ["genMetrics.py"], diff --git a/flow/util/synDashboard.py b/flow/util/synDashboard.py new file mode 100644 index 0000000000..89aad79597 --- /dev/null +++ b/flow/util/synDashboard.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 + +"""TL;DR dashboard over locally built OpenROAD-SYN QoR test results. + +The local counterpart of https://dashboard.precisioninno.com/: scans +bazel-testlogs for the forced-engine QoR tests emitted by +flow/designs/design.bzl (_{syn,yosys}[_synth]_test) and prints a +GitHub-flavored markdown status table, so the output can be pasted +into a PR or issue as-is. Reads only what previous `bazelisk test` +runs left behind — it never builds anything, so it is instant. + +Run it via: + bazelisk run //flow/designs:syn-dashboard +""" + +import os +import re +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +# _{syn,yosys}[_synth]_test, as emitted by design.bzl's +# forced-engine variants. "_synth" marks the fast synthesis-only tier. +TARGET_RE = re.compile(r"^(?P.+)_(?Psyn|yosys)(?P_synth)?_test$") + +FAIL_RULE_RE = re.compile(r"^\[ERROR\] (\S+) fail test: (.*)$") +MISSING_RULE_RE = re.compile(r"^\[ERROR\] Value not found for (\S+)\.$") + +# (engine, fast) -> table column, in display order. +COLUMNS = [ + ("syn", True, "SYN synth"), + ("syn", False, "SYN flow"), + ("yosys", True, "yosys synth"), + ("yosys", False, "yosys flow"), +] + + +def workspace_root(): + """The bazel workspace root: BUILD_WORKSPACE_DIRECTORY under + `bazel run`, else the nearest ancestor holding MODULE.bazel.""" + env = os.environ.get("BUILD_WORKSPACE_DIRECTORY") + if env: + return Path(env) + for path in [Path.cwd()] + list(Path.cwd().parents): + if (path / "MODULE.bazel").is_file(): + return path + sys.exit("error: not inside a bazel workspace (no MODULE.bazel found)") + + +def test_status(test_dir): + """(passed, mtime) for one bazel-testlogs test dir, or None if the + test never produced a result. The test.xml duration is just the + metadata-check action, not the flow build, so it is not reported.""" + xml_file = test_dir / "test.xml" + if not xml_file.is_file(): + return None + try: + root = ET.parse(xml_file).getroot() + except ET.ParseError: + return None + failures = 0 + for suite in root.iter("testsuite"): + failures += int(suite.get("failures", 0)) + int(suite.get("errors", 0)) + return failures == 0, xml_file.stat().st_mtime + + +def failing_rules(test_dir): + """Failed rule fields harvested from checkMetadata.py output in + test.log.""" + log_file = test_dir / "test.log" + if not log_file.is_file(): + return [] + fields = [] + for line in log_file.read_text(errors="replace").splitlines(): + match = FAIL_RULE_RE.match(line) + if match: + fields.append(match.group(1)) + continue + match = MISSING_RULE_RE.match(line) + if match: + fields.append(match.group(1) + " (missing)") + return fields + + +def age(mtime): + seconds = max(0, time.time() - mtime) + if seconds >= 86400: + return f"{seconds / 86400:.0f}d ago" + if seconds >= 3600: + return f"{seconds / 3600:.0f}h ago" + if seconds >= 60: + return f"{seconds / 60:.0f}m ago" + return "just now" + + +def collect(root): + """{(platform, design_pkg): {(engine, fast): result}} from + bazel-testlogs, where result adds the failed-rule fields.""" + designs = {} + testlogs = root / "bazel-testlogs" / "flow" / "designs" + if not testlogs.is_dir(): + return designs + for xml_file in testlogs.glob("*/*/*/test.xml"): + target_dir = xml_file.parent + match = TARGET_RE.match(target_dir.name) + if not match: + continue + status = test_status(target_dir) + if status is None: + continue + passed, mtime = status + key = (target_dir.parent.parent.name, target_dir.parent.name) + designs.setdefault(key, {})[ + (match.group("engine"), bool(match.group("fast"))) + ] = { + "passed": passed, + "mtime": mtime, + "failing": [] if passed else failing_rules(target_dir), + } + return designs + + +def format_rules(fields, limit=3): + if len(fields) > limit: + fields = fields[:limit] + [f"+{len(fields) - limit} more"] + return ", ".join(fields) + + +def main(): + designs = collect(workspace_root()) + print("# OpenROAD-SYN local status") + print() + if not designs: + print("No forced-engine test results under bazel-testlogs yet. Build some:") + print() + print(" bazelisk test --keep_going //flow/designs/asap7:syn_synth_test") + print(" bazelisk test --keep_going //flow/designs/asap7:syn_test") + return + header = ( + ["design"] + [title for _, _, title in COLUMNS] + ["failing rules", "last run"] + ) + print("| " + " | ".join(header) + " |") + print("|" + "|".join(["---"] * len(header)) + "|") + for (platform, pkg), results in sorted(designs.items()): + cells = [] + for engine, fast, _ in COLUMNS: + result = results.get((engine, fast)) + if result is None: + cells.append("—") + else: + cells.append("✅" if result["passed"] else "❌") + # The most meaningful failure detail: the full SYN flow if it + # failed, else the fast SYN tier, else any failing variant. + failing = [] + for key in [("syn", False), ("syn", True), ("yosys", False), ("yosys", True)]: + result = results.get(key) + if result and not result["passed"] and result["failing"]: + failing = result["failing"] + break + newest = max(result["mtime"] for result in results.values()) + row = [f"{platform}/{pkg}"] + cells + [format_rules(failing), age(newest)] + print("| " + " | ".join(row) + " |") + print() + print("Each cell reflects its own last `bazelisk test` run; `last run` is the") + print("newest across the row. `—` = no local result: never run, or its build") + print("failed outright (e.g. synthesis died) — bazel records no test result") + print("for build failures, so run the suites with `--keep_going` and check") + print("their output for `FAILED TO BUILD`.") + + +if __name__ == "__main__": + main() From dbf324153808f47016d28eb977244b5d5a1e8b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 12:38:49 +0200 Subject: [PATCH 4/8] designs: README for the fast local OpenROAD-SYN testing loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the forced-engine variants, the two QoR tiers and their congruence with the Jenkins dashboard signal, and the loop philosophy: deciding what to test/build is a separate concern from displaying what exists (bazelisk run //flow/designs:syn-dashboard). Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- flow/designs/README.md | 72 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 flow/designs/README.md diff --git a/flow/designs/README.md b/flow/designs/README.md new file mode 100644 index 0000000000..84cde748d3 --- /dev/null +++ b/flow/designs/README.md @@ -0,0 +1,72 @@ +# Fast local OpenROAD-SYN testing loop + +The maintainers' QoR signal comes from Jenkins runs published at +. The targets described here are +its local counterpart: the same designs, gated by the same +`rules-base.json` files with the same `checkMetadata.py`, but built +locally through Bazel so the loop can be sharpened onto exactly the +design, engine, and stage being worked on — fast enough for an agent +(e.g. Claude) to iterate against. + +Every design gets manual forced-engine flow variants (see +`design.bzl`), regardless of the engine its config.mk selects: + +- `_syn_*` pins `SYNTH_USE_SYN=1` (OpenROAD-SYN), so any design + can be tested under SYN before its config.mk officially switches. +- `_yosys_*` pins the yosys engine, so the baseline stays + reproducible after a design switches (asap7/gcd today). + +Designs with a `rules-base.json` get two QoR tiers per variant: + +- `__synth_test` — the fast tier: synthesis only, + checked against the `synth__`/`constraints__` subset of the rules. + Minutes. QoR-only — without LEC it cannot prove correctness, so use + it to iterate, not to conclude. +- `__test` — the full flow against all rules: the real, + dashboard-equivalent gate. It reuses the fast tier's synthesis + action, so escalating from fast to full never repeats synthesis. + +## The loop + +Build/test whatever is relevant to what you are changing — one design, +one stage, one tier, or a whole suite: + +```sh +# fastest signal for one design +bazelisk test //flow/designs/asap7/jpeg:jpeg_encoder_syn_synth_test + +# the real gate for that design +bazelisk test //flow/designs/asap7/jpeg:jpeg_encoder_syn_test + +# a single stage, for triage +bazelisk build //flow/designs/asap7/jpeg:jpeg_encoder_syn_route + +# yosys A/B on an officially-SYN design +bazelisk test //flow/designs/asap7/gcd:gcd_yosys_test + +# suite-wide status (asap7): fast tier, then the full flows +bazelisk test --keep_going //flow/designs/asap7:syn_synth_test +bazelisk test --keep_going //flow/designs/asap7:syn_test +``` + +Then display what you have — deliberately a separate concern from +deciding what to test or build: + +```sh +bazelisk run //flow/designs:syn-dashboard +``` + +It prints a GitHub-markdown TL;DR table of every forced-engine test +result already in `bazel-testlogs` — pass/fail per tier and engine, +failing rule fields, result age — without building anything. Paste it +into a PR or issue as-is. Because the display reads whatever exists, +the loop stays fast: iterate on the one target you care about, and the +dashboard aggregates across everything you (or earlier sessions) have +built so far. + +The suites include designs that fail OpenROAD-SYN today on purpose — +they are a status signal, not a green-only gate. A build failure (e.g. +synthesis dies) records no test result at all, so run the suites with +`--keep_going` and check their output for `FAILED TO BUILD`; designs +too slow for the suites are listed in `SYN_SLOW_DESIGNS` +(`asap7/BUILD`) and reproduced individually. From 5022d1f254454b4d75fecfab0b19ccec159150e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 13:21:34 +0200 Subject: [PATCH 5/8] bazel: bump bazel-orfs for OpenROAD-SYN engine and fast synth QoR tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up The-OpenROAD-Project/bazel-orfs#771: SYNTH_USE_SYN=1 engine selection in the synth stage (with logs/1_synth.json declared so QoR tests can gate synth__* metrics), the fast synthesis-stage QoR pre-check emitted for any flow with rules, the orfs_update single-source cp fix, and the orfs_design extra hook that flow/designs/design.bzl uses for the forced-engine variants. Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- MODULE.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MODULE.bazel b/MODULE.bazel index f828679d3f..a36173378f 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -101,7 +101,7 @@ bazel_dep(name = "bazel-orfs", version = "0.0.1") bazel_dep(name = "bazel-orfs-verilog", dev_dependency = True) -BAZEL_ORFS_COMMIT = "6ebadeb4be5c9ada103081c9a5e668c014126616" +BAZEL_ORFS_COMMIT = "3342596c1f7017b9fda5b4a55a25a54ff2aaedf8" BAZEL_ORFS_REMOTE = "https://github.com/The-OpenROAD-Project/bazel-orfs.git" From 13ce4d0a7b5392f1343ca752b56ae2958cbc69c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 13:31:38 +0200 Subject: [PATCH 6/8] review: make metadata-synth -j-safe, explicit utf-8 in synDashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit metadata-synth ran synth/metadata-generate/metadata-check-synth as plain prerequisites, which race under make -j; run them as sequential sub-makes instead. synDashboard.py reads test logs with an explicit utf-8 encoding so non-UTF-8 host defaults cannot break parsing. Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- flow/util/synDashboard.py | 2 +- flow/util/utils.mk | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/flow/util/synDashboard.py b/flow/util/synDashboard.py index 89aad79597..7242a1a604 100644 --- a/flow/util/synDashboard.py +++ b/flow/util/synDashboard.py @@ -72,7 +72,7 @@ def failing_rules(test_dir): if not log_file.is_file(): return [] fields = [] - for line in log_file.read_text(errors="replace").splitlines(): + for line in log_file.read_text(encoding="utf-8", errors="replace").splitlines(): match = FAIL_RULE_RE.match(line) if match: fields.append(match.group(1)) diff --git a/flow/util/utils.mk b/flow/util/utils.mk index b1d723f8fb..abd1427484 100644 --- a/flow/util/utils.mk +++ b/flow/util/utils.mk @@ -6,10 +6,15 @@ metadata: finish metadata-generate metadata-check # Synthesis-only metadata: generate and check QoR after just the synth # stage, without running the full flow. The synthesis-stage subset of # the design's regular rules file gates the run, so no per-variant -# rules file needs to be committed. +# rules file needs to be committed. Sequential sub-makes rather than +# prerequisites: each step consumes the previous one's outputs, which +# plain prerequisites would race under make -j. .PHONY: metadata-synth metadata-synth: export RULES_JSON = $(DESIGN_DIR)/rules-base.json -metadata-synth: synth metadata-generate metadata-check-synth +metadata-synth: + $(MAKE) synth + $(MAKE) metadata-generate + $(MAKE) metadata-check-synth .PHONY: metadata-generate metadata-generate: From a30468e35dc4df60702a486d84136402a501f1d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 13:43:53 +0200 Subject: [PATCH 7/8] review: syn-dashboard treats corrupt test.xml as no result, unshadow loop var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Malformed failures/errors attributes now make the whole test.xml count as "no result" (—) like a ParseError, instead of raising — or worse, being swallowed per-attribute and rendering a green cell for a corrupt file. Rename the generator variable shadowing the outer result. Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- flow/util/synDashboard.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/flow/util/synDashboard.py b/flow/util/synDashboard.py index 7242a1a604..6204e8f4cb 100644 --- a/flow/util/synDashboard.py +++ b/flow/util/synDashboard.py @@ -57,11 +57,13 @@ def test_status(test_dir): return None try: root = ET.parse(xml_file).getroot() - except ET.ParseError: + failures = 0 + for suite in root.iter("testsuite"): + failures += int(suite.get("failures") or 0) + int(suite.get("errors") or 0) + except (ET.ParseError, ValueError, TypeError): + # A corrupt test.xml is "no result", not a pass — swallowing the + # error inside the sum would render a green cell for it. return None - failures = 0 - for suite in root.iter("testsuite"): - failures += int(suite.get("failures", 0)) + int(suite.get("errors", 0)) return failures == 0, xml_file.stat().st_mtime @@ -158,7 +160,7 @@ def main(): if result and not result["passed"] and result["failing"]: failing = result["failing"] break - newest = max(result["mtime"] for result in results.values()) + newest = max(r["mtime"] for r in results.values()) row = [f"{platform}/{pkg}"] + cells + [format_rules(failing), age(newest)] print("| " + " | ".join(row) + " |") print() From 5ff9d7658ad56ae59253511d8f9378e841db18cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Fri, 17 Jul 2026 13:49:18 +0200 Subject: [PATCH 8/8] synth_syn: document that write_lec_verilog prefixes RESULTS_DIR itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare-filename call keeps getting flagged in review as writing to the CWD; state the proc's contract at the call site. Co-Authored-By: Claude Fable 5 Signed-off-by: Øyvind Harboe --- flow/scripts/synth_syn.tcl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flow/scripts/synth_syn.tcl b/flow/scripts/synth_syn.tcl index 5630bec305..bd6eaf0820 100644 --- a/flow/scripts/synth_syn.tcl +++ b/flow/scripts/synth_syn.tcl @@ -79,6 +79,8 @@ orfs_write_sdc $::env(RESULTS_DIR)/1_synth.sdc # Gate-level netlist for LEC (write_lec_verilog strips physical-only # masters, matching the CTS-stage LEC convention) and any other netlist # consumer. The Bazel synthesis action declares this file as an output, -# so it must be written unconditionally. +# so it must be written unconditionally. write_lec_verilog takes a bare +# filename and prefixes $::env(RESULTS_DIR) itself, like the cts.tcl +# call sites. source $::env(SCRIPTS_DIR)/lec_check.tcl write_lec_verilog 1_synth.v