diff --git a/MODULE.bazel b/MODULE.bazel
index f828679d3f..2e5ab2fe0c 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -106,9 +106,21 @@ BAZEL_ORFS_COMMIT = "6ebadeb4be5c9ada103081c9a5e668c014126616"
BAZEL_ORFS_REMOTE = "https://github.com/The-OpenROAD-Project/bazel-orfs.git"
# To bump version, run: bazelisk run @bazel-orfs//:bump
+#
+# The patches carry OpenROAD-SYN support (SYNTH_USE_SYN engine selection,
+# synthesis-only metadata/test chain, orfs_design extension hook) until
+# they are upstreamed; see patches/bazel-orfs/BUILD.bazel.
git_override(
module_name = "bazel-orfs",
commit = BAZEL_ORFS_COMMIT,
+ patch_strip = 1,
+ patches = [
+ "//patches/bazel-orfs:0001-synth-support-OpenROAD-SYN-engine-SYNTH_USE_SYN-1-an.patch",
+ "//patches/bazel-orfs:0002-flow-emit-metadata-test-update-chain-for-synthesis-o.patch",
+ "//patches/bazel-orfs:0003-update-copy-only-rules.json-in-the-orfs_update-scrip.patch",
+ "//patches/bazel-orfs:0004-design-caller-extension-hook-for-per-design-status-t.patch",
+ "//patches/bazel-orfs:0005-flow-synthesis-only-QoR-checks-the-synth-subset-of-t.patch",
+ ],
remote = BAZEL_ORFS_REMOTE,
)
diff --git a/docs/user/FlowVariables.md b/docs/user/FlowVariables.md
index b297be9ca7..b96c57fbba 100644
--- a/docs/user/FlowVariables.md
+++ b/docs/user/FlowVariables.md
@@ -302,6 +302,7 @@ configuration file.
| SYNTH_INSBUF| Insert input buffers on top-level input ports during synthesis. Useful to disable when doing parallel synthesis and concatenating netlists later as we're generating netlists of submodules.| 1|
| SYNTH_KEEP_MOCKED_MEMORIES| When `SYNTH_MOCK_LARGE_MEMORIES=1`, setting this to 1, will keep mocked memories (not flattening them). This preserves some of the access logic complexity and avoids optimizations outside of the mocked memory.| 1|
| SYNTH_KEEP_MODULES| Mark modules to keep from getting removed in flattening.| |
+| SYNTH_LEC_CHECK| Run a formal equivalence check between the input RTL and the OpenROAD-SYN synthesis netlist (1_synth.v). The RTL side goes through kepler-formal's experimental SystemVerilog frontend, which does not yet support all RTL constructs, so this is a separate opt-in from LEC_CHECK (which auto-enables when kepler-formal is installed and compares gate-level netlists only). Requires KEPLER_FORMAL_EXE.| 0|
| SYNTH_MEMORY_MAX_BITS| Maximum number of bits for memory synthesis. Ideally, real RAM or realistic fakeram should be used for RAMs much larger than 1024 bits. To temporarily ignore the RAM concerns and investigate other aspects of the design, consider setting `SYNTH_MOCK_LARGE_MEMORIES=1`, or adjusting `SYNTH_MEMORY_MAX_BITS`.| 4096|
| SYNTH_MINIMUM_KEEP_SIZE| For hierarchical synthesis, we keep modules of larger area than given by this variable and flatten smaller modules. The area unit used is the size of a basic nand2 gate from the platform's standard cell library. The default value is platform specific.| 0|
| SYNTH_MOCK_LARGE_MEMORIES| Reduce Yosys inferred memories larger than SYNTH_MEMORY_MAX_BITS to 1 row. Yosys will generally infer memories from behavioral Verilog code, whether the memories are in standalone modules or instantiated within some larger module. fakeram and empty Verilog memories(blackboxes) of memories will not be inferred memories by Yosys and are therefore not affected by this variable. This is useful and convenient to separate the concern of instantiating and placing memories from investigating other issues with a design, though it comes at the expense of the increased accuracy that using realistic fakemem would provide. Memories with a single 1 row will of course have unrealistically good timing and area characteristics, but timing will still correctly terminate in a register. Large port memories, typically register files, will still have the retain a lot of the port logic that can be useful to investigate issues. This can be especially useful during development of designs where the behavioral model comes first and suitable memories are matched up when the design RTL is stable. A typical use case would be Chisel which will generate a behavioral model for a memories with the required clocks, ports, etc. in addition to a computer readable file with the specification of the memories that is used to [automatically](https://chipyard.readthedocs.io/en/stable/Tools/Barstools.html/) match up suitable memory macros later in the flow. During an architectural screening study, a large range of memory configurations can be investigated quickly with this option, without getting bogged down in the concern of how to realize the memories in silicon for emphemral RTL configurations that exist only long enough to run through the ORFS flow to create a table of some characteristics of a design configuration.| 0|
@@ -345,6 +346,7 @@ configuration file.
- [DFF_LIB_FILE](#DFF_LIB_FILE)
- [DFF_MAP_FILE](#DFF_MAP_FILE)
- [LATCH_MAP_FILE](#LATCH_MAP_FILE)
+- [LEC_AUX_VERILOG_FILES](#LEC_AUX_VERILOG_FILES)
- [MIN_BUF_CELL_AND_PORTS](#MIN_BUF_CELL_AND_PORTS)
- [POST_SYNTH_TCL](#POST_SYNTH_TCL)
- [PRE_SYNTH_TCL](#PRE_SYNTH_TCL)
@@ -362,6 +364,7 @@ configuration file.
- [SYNTH_INSBUF](#SYNTH_INSBUF)
- [SYNTH_KEEP_MOCKED_MEMORIES](#SYNTH_KEEP_MOCKED_MEMORIES)
- [SYNTH_KEEP_MODULES](#SYNTH_KEEP_MODULES)
+- [SYNTH_LEC_CHECK](#SYNTH_LEC_CHECK)
- [SYNTH_MEMORY_MAX_BITS](#SYNTH_MEMORY_MAX_BITS)
- [SYNTH_MINIMUM_KEEP_SIZE](#SYNTH_MINIMUM_KEEP_SIZE)
- [SYNTH_MOCK_LARGE_MEMORIES](#SYNTH_MOCK_LARGE_MEMORIES)
diff --git a/flow/designs/asap7/BUILD b/flow/designs/asap7/BUILD
new file mode 100644
index 0000000000..0e18cbff5b
--- /dev/null
+++ b/flow/designs/asap7/BUILD
@@ -0,0 +1,186 @@
+# OpenROAD-SYN status suite for asap7.
+#
+# Every asap7 design gets manual _syn_* targets from the config.mk
+# DSL (see flow/designs/design.bzl): a synthesis-only variant "syn" flow
+# with SYNTH_USE_SYN=1 forced, a QoR gate checking the synthesis-stage
+# subset of the design's rules-base.json (no per-variant rules files in
+# git), and a best-effort _syn_qor / _yosys_qor pair for the
+# comparison graph. This package aggregates them:
+#
+# bazelisk test //flow/designs/asap7:syn_test
+# bazelisk build //flow/designs/asap7:syn_qor_png
+#
+# The suite deliberately includes designs that fail OpenROAD-SYN today —
+# it is a status report, not a green-only gate. A design can fail by not
+# synthesizing at all, or by missing the rules-base.json gates that its
+# regular flow meets (e.g. aes-block's SYN area blows past the yosys
+# reference).
+
+# Design directory -> DESIGN_NAME (bazel target prefix), ordered
+# fastest-first so --keep_going runs surface quick failures early.
+# Expected failures / quirks are listed last. "minimal" is excluded: it
+# has no VERILOG_FILES and thus no generated design targets.
+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 aggregators. Their generated per-design targets stay tags=["manual"]
+# like all syn targets, but they are deliberately left out of syn_test and
+# the QoR graph 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",
+}
+
+# Designs whose OpenROAD-SYN synthesis fails today. They stay in
+# syn_test (failing honestly) and in the QoR graph (best-effort FAIL
+# columns), but the clock-period plot depends on each design's generated
+# synthesis rules snapshot, which these cannot produce.
+# - 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).
+SYN_FAILING_DESIGNS = [
+ "mock-alu",
+ "mock-cpu",
+ "riscv32i",
+]
+
+# Generated synthesis QoR snapshots (rules.json from the syn variant's
+# update_rules stage) for designs whose OpenROAD-SYN synthesis passes.
+# These are bazel outputs, not checked-in files: the syn variant's QoR
+# gate checks the synthesis subset of rules-base.json instead, so no
+# rules-syn.json files live in git.
+[
+ filegroup(
+ name = "{pkg}_syn_rules".format(pkg = pkg),
+ srcs = [
+ "//flow/designs/asap7/{pkg}:{name}_syn_update_rules".format(
+ name = name,
+ pkg = pkg,
+ ),
+ ],
+ output_group = "rules.json",
+ )
+ for pkg, name in SYN_TEST_DESIGNS.items()
+ if pkg not in SYN_FAILING_DESIGNS
+]
+
+# Explicitly listed manual tests DO run through a test_suite (the manual
+# tag only exempts them from wildcard expansion); the suite itself is
+# 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()
+ ],
+)
+
+# The syn-vs-yosys QoR comparison graph plus the runtime A/B chart. A
+# plain build target: the per-design _qor inputs are best-effort (marker
+# JSON on failure), so this always builds and failing designs render as
+# hatched FAIL columns.
+#
+# Refresh the checked-in copies embedded by the status PR with:
+# bazelisk build //flow/designs/asap7:syn_qor_png
+# cp -f bazel-bin/flow/designs/asap7/openroad-syn-{qor,runtime}.png \
+# flow/docs/images/
+# Effective clock period (target - gated worst slack) across stages,
+# one series per design, OpenROAD-SYN synthesis point overlaid. The base
+# series comes from the checked-in rules-base.json; the SYN point comes
+# from the generated synthesis rules snapshot (the syn variant's
+# update_rules output), so building this runs synthesis for the passing
+# designs. Same refresh pattern as syn_qor_png, into
+# flow/docs/images/openroad-syn-clock-period.png.
+genrule(
+ name = "clock_period_png",
+ srcs = [
+ "//flow/designs/asap7/{pkg}:rules-base.json".format(pkg = pkg)
+ for pkg in SYN_TEST_DESIGNS
+ if pkg not in SYN_FAILING_DESIGNS
+ ] + [
+ ":{pkg}_syn_rules".format(pkg = pkg)
+ for pkg in SYN_TEST_DESIGNS
+ if pkg not in SYN_FAILING_DESIGNS
+ ],
+ outs = ["openroad-syn-clock-period.png"],
+ cmd = "$(location //flow/util:plotClockPeriod) --output $@ " + " ".join([
+ ("--design {pkg} " +
+ "--rules-base $(location //flow/designs/asap7/{pkg}:rules-base.json) " +
+ "--rules-syn $(location :{pkg}_syn_rules)").format(
+ pkg = pkg,
+ )
+ for pkg in SYN_TEST_DESIGNS
+ if pkg not in SYN_FAILING_DESIGNS
+ ]),
+ tags = ["manual"],
+ tools = ["//flow/util:plotClockPeriod"],
+)
+
+genrule(
+ name = "syn_qor_png",
+ srcs = [
+ "//flow/designs/asap7/{pkg}:{name}_{kind}_qor".format(
+ name = name,
+ kind = kind,
+ pkg = pkg,
+ )
+ for pkg, name in SYN_TEST_DESIGNS.items()
+ for kind in [
+ "syn",
+ "yosys",
+ ]
+ ],
+ outs = [
+ "openroad-syn-qor.png",
+ "openroad-syn-runtime.png",
+ ],
+ cmd = "$(location //flow/util:plotSynQoR) " +
+ "--output $(location openroad-syn-qor.png) " +
+ "--runtime-output $(location openroad-syn-runtime.png) " + " ".join([
+ ("--design {pkg} " +
+ "--syn $(location //flow/designs/asap7/{pkg}:{name}_syn_qor) " +
+ "--base $(location //flow/designs/asap7/{pkg}:{name}_yosys_qor)").format(
+ name = name,
+ pkg = pkg,
+ )
+ for pkg, name in SYN_TEST_DESIGNS.items()
+ ]),
+ tags = ["manual"],
+ tools = ["//flow/util:plotSynQoR"],
+)
diff --git a/flow/designs/asap7/gcd/BUILD b/flow/designs/asap7/gcd/BUILD
index b8c3ddc518..7d1d5a2843 100644
--- a/flow/designs/asap7/gcd/BUILD
+++ b/flow/designs/asap7/gcd/BUILD
@@ -1,15 +1,29 @@
-load("@bazel-orfs//:openroad.bzl", "orfs_run")
+load("@bazel-orfs//:openroad.bzl", "orfs_flow", "orfs_run")
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.
design(
config = "config.mk",
- local_arguments = ["SYNTH_USE_SYN"],
+)
+
+# gcd's regular flow follows config.mk onto OpenROAD-SYN
+# (SYNTH_USE_SYN=1), but the single-process comparison below
+# deliberately exercises the yosys netlist path (single_flow.tcl seeds
+# from 1_2_yosys.v). Give the comparison its own yosys-engine flow;
+# manual so only the gcd_single_flow_test suite builds it.
+orfs_flow(
+ name = "gcd",
+ arguments = {
+ k: v
+ for k, v in DESIGNS["asap7/gcd"]["arguments"].items()
+ if k != "SYNTH_USE_SYN"
+ },
+ pdk = "//flow:asap7",
+ sources = DESIGNS["asap7/gcd"]["sources"],
+ tags = ["manual"],
+ variant = "yosys",
+ verilog_files = DESIGNS["asap7/gcd"]["verilog_files"],
)
# Stage-boundary .odb/.sdc stems shared by the normal per-stage flow
@@ -56,7 +70,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 +93,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..9879461b7e 100644
--- a/flow/designs/design.bzl
+++ b/flow/designs/design.bzl
@@ -1,6 +1,8 @@
"""BUILD boilerplate for flow/designs/."""
+load("@bazel-orfs//:openroad.bzl", "orfs_flow")
load("@orfs_designs//:designs.bzl", "orfs_design")
+load("//flow/designs:qor.bzl", "orfs_qor")
# Per filegroup target: extensions included in the filegroup.
# bazel-orfs's config_mk_parser produces these target names from
@@ -17,7 +19,9 @@ _GROUPS = {
# cross-package references resolve. Kept tight on purpose: globbing "*"
# silently exposes LICENSE/.gitignore/etc. as the public API surface.
# gds/gds.gz are inputs in hierarchical flows via ADDITIONAL_GDS.
-_EXPORTED_EXTS = ["v", "sv", "svh", "tcl", "sdc", "def", "cfg", "lef", "lib", "gds", "gds.gz"]
+# json covers rules-*.json so QoR reporting targets (e.g.
+# //flow/designs/asap7:clock_period_png) can consume them per file.
+_EXPORTED_EXTS = ["v", "sv", "svh", "tcl", "sdc", "def", "cfg", "lef", "lib", "gds", "gds.gz", "json"]
_EXPORTS_SENTINEL = "_orfs_design_exports_sentinel"
@@ -51,6 +55,74 @@ def _export_design_files():
visibility = ["//visibility:private"],
)
+def _syn_status_targets(
+ name,
+ platform,
+ verilog_files,
+ arguments,
+ user_arguments,
+ sources,
+ user_sources,
+ macros,
+ stage_data,
+ tags): # buildifier: disable=unused-variable
+ """OpenROAD-SYN status targets 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):
+
+ - a synthesis-only variant "syn" flow with SYNTH_USE_SYN=1 forced,
+ regardless of the config.mk default: _syn_synth plus — when
+ the design has a rules-base.json — the _syn_test QoR gate
+ (checking the synthesis-stage subset of rules-base.json, so no
+ per-variant rules file is committed) and _syn_update_rules,
+ whose generated rules.json is the synthesis QoR snapshot that
+ aggregating plots consume.
+ - a best-effort _syn_qor / _yosys_qor pair whose
+ qor.json outputs feed the syn-vs-yosys QoR comparison graph.
+ """
+ qor_sources = {k: v for k, v in sources.items() if k != "RULES_JSON"}
+ orfs_flow(
+ name = name,
+ verilog_files = verilog_files,
+ pdk = "//flow:" + platform,
+ arguments = arguments | {"SYNTH_USE_SYN": "1"},
+ user_arguments = user_arguments,
+ # sources carries the auto-detected rules-base.json as RULES_JSON;
+ # the syn variant's test checks its synthesis-stage subset.
+ sources = sources,
+ user_sources = user_sources,
+ macros = macros,
+ stage_data = stage_data,
+ variant = "syn",
+ last_stage = "synth",
+ tags = ["manual"],
+ test_kwargs = {"tags": ["manual"]},
+ # The platform-level aggregation package consumes the
+ # update_rules stage's generated rules.json.
+ visibility = ["//visibility:public"],
+ )
+ for qor_variant, qor_use_syn in [("syn_qor", True), ("yosys_qor", False)]:
+ orfs_qor(
+ name = "%s_%s" % (name, qor_variant),
+ use_syn = qor_use_syn,
+ module_top = name,
+ variant = qor_variant,
+ verilog_files = verilog_files,
+ pdk = "//flow:" + platform,
+ # user_arguments are dropped by the synth-stage argument
+ # filter; designs whose synthesis depends on user .tcl hooks
+ # may fail their best-effort QoR run and render as FAILED.
+ arguments = arguments,
+ sources = qor_sources,
+ deps = macros,
+ stage_data = stage_data,
+ tags = ["manual"],
+ # Consumed by the platform-level aggregation package (e.g.
+ # //flow/designs/asap7:syn_qor_png).
+ visibility = ["//visibility:public"],
+ )
+
def design(config = "config.mk", user_arguments = [], user_sources = [], local_arguments = []):
"""Standard BUILD body for flow/designs///.
@@ -75,6 +147,7 @@ def design(config = "config.mk", user_arguments = [], user_sources = [], local_a
user_sources = user_sources,
local_arguments = local_arguments,
blender = True,
+ extra = _syn_status_targets,
)
def files(group, extra_srcs = None):
diff --git a/flow/designs/qor.bzl b/flow/designs/qor.bzl
new file mode 100644
index 0000000000..b8a3aa8c54
--- /dev/null
+++ b/flow/designs/qor.bzl
@@ -0,0 +1,184 @@
+"""Best-effort synthesis QoR targets for the OpenROAD-SYN status report.
+
+orfs_qor() runs synthesis (yosys or OpenROAD-SYN, chosen per target) plus
+metadata-generate in one action and always produces a single qor.json: a
+copy of the synthesis-only metadata.json on success, or a small failure
+marker {"orfs_qor_failed": true, ...} when any step fails. This lets the
+aggregating QoR comparison graph (//flow/designs/asap7:syn_qor_png) be a
+plain `bazel build` target that renders failing designs instead of
+refusing to build. The strict per-design _syn_test targets are
+unaffected — only the QoR reporting pipeline is best-effort.
+
+This is ORFS status-suite policy layered on bazel-orfs plumbing, so it
+lives here (loading bazel-orfs's helper .bzls) rather than in bazel-orfs.
+"""
+
+load(
+ "@bazel-orfs//private:attrs.bzl",
+ "synth_attrs",
+ "yosys_attrs",
+)
+load(
+ "@bazel-orfs//private:environment.bzl",
+ "EXPAND_VERILOG_DIRS",
+ "config_content",
+ "config_environment",
+ "config_overrides",
+ "data_arguments",
+ "data_inputs",
+ "declare_artifact",
+ "deps_inputs",
+ "flow_environment",
+ "flow_inputs",
+ "merge_arguments",
+ "module_top",
+ "orfs_additional_arguments",
+ "pdk_inputs",
+ "required_arguments",
+ "verilog_arguments",
+ "yosys_environment",
+ "yosys_inputs",
+)
+load(
+ "@bazel-orfs//private:providers.bzl",
+ "OrfsInfo",
+)
+load(
+ "@bazel-orfs//private:stages.bzl",
+ "get_sources",
+ "get_stage_args",
+)
+
+def _qor_impl(ctx):
+ all_arguments = merge_arguments(
+ data_arguments(ctx) |
+ required_arguments(ctx),
+ orfs_additional_arguments(
+ [dep[OrfsInfo] for dep in ctx.attr.deps],
+ use_pre_layout = True,
+ ),
+ )
+ use_syn = all_arguments.get("SYNTH_USE_SYN") == "1"
+
+ config = declare_artifact(ctx, "results", "1_synth.mk")
+ ctx.actions.write(
+ output = config,
+ content = config_content(
+ ctx,
+ all_arguments,
+ [file.path for file in ctx.files.extra_configs],
+ ),
+ )
+
+ qor = declare_artifact(ctx, "reports", "qor.json")
+
+ if use_syn:
+ targets = ["do-1_synth", "metadata-generate"]
+ else:
+ targets = [
+ "yosys-dependencies",
+ "do-yosys-canonicalize",
+ "do-yosys",
+ "do-1_synth",
+ "metadata-generate",
+ ]
+
+ marker = json.encode({
+ "design": module_top(ctx),
+ "orfs_qor_failed": True,
+ "variant": ctx.attr.variant,
+ })
+
+ ctx.actions.run_shell(
+ arguments = ["--file", ctx.file._makefile.path] + targets,
+ command = EXPAND_VERILOG_DIRS + """
+if {make} "$@"; then
+ cp "$(dirname {qor})/metadata.json" {qor}
+else
+ echo '{marker}' > {qor}
+fi
+""".format(
+ make = ctx.executable._make.path,
+ marker = marker,
+ qor = qor.path,
+ ),
+ env = config_overrides(
+ ctx,
+ verilog_arguments(ctx.files.verilog_files) |
+ flow_environment(ctx) |
+ yosys_environment(ctx) |
+ config_environment(config),
+ ),
+ inputs = depset(
+ [config] + ctx.files.verilog_files + ctx.files.extra_configs,
+ transitive = [
+ data_inputs(ctx),
+ pdk_inputs(ctx),
+ deps_inputs(ctx),
+ ],
+ ),
+ outputs = [qor],
+ tools = depset(transitive = [yosys_inputs(ctx), flow_inputs(ctx)]),
+ progress_message = "Best-effort synthesis QoR for %s (%s)" % (
+ module_top(ctx),
+ ctx.attr.variant,
+ ),
+ )
+
+ return [DefaultInfo(files = depset([qor]))]
+
+_orfs_qor_rule = rule(
+ implementation = _qor_impl,
+ attrs = yosys_attrs() |
+ synth_attrs() |
+ {
+ "_stage": attr.string(
+ default = "synth",
+ ),
+ },
+)
+
+def orfs_qor(
+ name,
+ use_syn,
+ module_top,
+ variant,
+ verilog_files,
+ pdk,
+ arguments = {},
+ sources = {},
+ deps = [],
+ stage_data = {},
+ **kwargs):
+ """Best-effort synthesis QoR target that always builds.
+
+ Args:
+ name: target name (convention: _syn_qor / _yosys_qor).
+ use_syn: True runs OpenROAD-SYN (SYNTH_USE_SYN=1), False forces the
+ yosys flow — regardless of what the design's arguments say.
+ module_top: Verilog top module name.
+ variant: FLOW_VARIANT; must be distinct from any orfs_flow() variant
+ in the same package so declared output paths do not collide.
+ verilog_files: design sources.
+ pdk: PDK label.
+ arguments: config.mk argument dict (synth-stage vars are selected).
+ sources: config.mk source-label dict (routed to the data attr).
+ deps: macro abstracts.
+ stage_data: dict keyed by stage with extra data labels.
+ **kwargs: forwarded to the rule (e.g. tags, visibility).
+ """
+ _orfs_qor_rule(
+ name = name,
+ module_top = module_top,
+ variant = variant,
+ verilog_files = verilog_files,
+ pdk = pdk,
+ deps = deps,
+ arguments = get_stage_args(
+ "synth",
+ arguments = arguments | {"SYNTH_USE_SYN": "1" if use_syn else "0"},
+ sources = sources,
+ ),
+ data = get_sources("synth", {}, sources) + stage_data.get("synth", []),
+ **kwargs
+ )
diff --git a/flow/docs/images/openroad-syn-clock-period.png b/flow/docs/images/openroad-syn-clock-period.png
new file mode 100755
index 0000000000..44b71c09a0
Binary files /dev/null and b/flow/docs/images/openroad-syn-clock-period.png differ
diff --git a/flow/docs/images/openroad-syn-qor.png b/flow/docs/images/openroad-syn-qor.png
new file mode 100755
index 0000000000..6126452179
Binary files /dev/null and b/flow/docs/images/openroad-syn-qor.png differ
diff --git a/flow/docs/images/openroad-syn-runtime.png b/flow/docs/images/openroad-syn-runtime.png
new file mode 100755
index 0000000000..cd5f62a2aa
Binary files /dev/null and b/flow/docs/images/openroad-syn-runtime.png differ
diff --git a/flow/scripts/lec_check.tcl b/flow/scripts/lec_check.tcl
index 3b3384c055..ac7c431b00 100644
--- a/flow/scripts/lec_check.tcl
+++ b/flow/scripts/lec_check.tcl
@@ -6,6 +6,18 @@ proc lec_check_enabled { } {
}]
}
+# Synthesis (RTL vs netlist) LEC is a separate opt-in: the RTL side needs
+# kepler-formal's experimental SystemVerilog frontend, which does not yet
+# support all RTL constructs, so it must not ride LEC_CHECK's
+# auto-enable-when-installed behavior (flow/settings.mk).
+proc synth_lec_check_enabled { } {
+ return [expr {
+ [env_var_equals SYNTH_LEC_CHECK 1]
+ && [info exists ::env(KEPLER_FORMAL_EXE)]
+ && [file executable $::env(KEPLER_FORMAL_EXE)]
+ }]
+}
+
proc write_lec_verilog { filename } {
set remove_cells [find_physical_only_masters]
if { [env_var_exists_and_non_empty REMOVE_CELLS_FOR_LEC] } {
@@ -46,8 +58,9 @@ proc write_lec_script { step file1 file2 } {
close $outfile
}
-proc run_lec_test { step file1 file2 } {
- write_lec_script $step $file1 $file2
+# Run kepler-formal on a previously written ${step}_lec_test.yml and
+# fail with a message naming `what` if the log reports a difference.
+proc run_lec_config { step what } {
# tclint-disable-next-line command-args
eval exec $::env(KEPLER_FORMAL_EXE) --config $::env(OBJECTS_DIR)/${step}_lec_test.yml
try {
@@ -57,8 +70,72 @@ proc run_lec_test { step file1 file2 } {
set count 0
}
if { $count > 0 } {
- error "Repair timing output failed lec test"
+ error "$what failed lec test"
} else {
- puts "Repair timing output passed lec test"
+ puts "$what passed lec test"
}
}
+
+proc run_lec_test { step file1 file2 } {
+ write_lec_script $step $file1 $file2
+ run_lec_config $step "Repair timing output"
+}
+
+# Write a slang command file describing the design RTL exactly as
+# synth_syn.tcl elaborates it, so kepler-formal's SystemVerilog frontend
+# sees the same design. Not replicated (slang defaults differ):
+# --compat=vcs, --ignore-assertions, --no-implicit-memories, --std;
+# designs that depend on those may fail LEC for frontend reasons.
+proc write_sv_rtl_flist { path } {
+ set out [open $path w]
+ puts $out "-D SYNTHESIS"
+ if { [env_var_exists_and_non_empty VERILOG_DEFINES] } {
+ # Flag/value token stream (e.g. "-D NAME"); keep on one line.
+ puts $out $::env(VERILOG_DEFINES)
+ }
+ if { [env_var_exists_and_non_empty VERILOG_INCLUDE_DIRS] } {
+ foreach dir $::env(VERILOG_INCLUDE_DIRS) {
+ puts $out "+incdir+$dir"
+ }
+ }
+ dict for {key value} [env_var_or_empty VERILOG_TOP_PARAMS] {
+ puts $out "-G $key=$value"
+ }
+ if { [env_var_exists_and_non_empty SYNTH_SLANG_ARGS] } {
+ puts $out $::env(SYNTH_SLANG_ARGS)
+ }
+ foreach fn $::env(VERILOG_FILES) {
+ puts $out [string trim $fn]
+ }
+ close $out
+}
+
+# YAML config comparing the input RTL (slang frontend) against a
+# gate-level netlist + liberty models.
+proc write_sv_lec_script { step rtl_flist gate_flist } {
+ set outfile [open "$::env(OBJECTS_DIR)/${step}_lec_test.yml" w]
+ puts $outfile "format: systemverilog"
+ puts $outfile "sv_design1_flist: $rtl_flist"
+ puts $outfile "sv_design1_top: $::env(DESIGN_NAME)"
+ puts $outfile "sv_design2_flist: $gate_flist"
+ puts $outfile "sv_design2_top: $::env(DESIGN_NAME)"
+ puts $outfile "liberty_files:"
+ foreach libFile $::env(LIB_FILES) {
+ puts $outfile " - $libFile"
+ }
+ puts $outfile "log_file: $::env(LOG_DIR)/${step}_lec_check.log"
+ close $outfile
+}
+
+# Pre- vs post-synthesis LEC: input RTL against the 1_synth.v netlist
+# written by synth_syn.tcl. Caller guards with lec_check_enabled.
+proc run_synth_lec_test { } {
+ set rtl_flist $::env(OBJECTS_DIR)/1_synth_rtl.f
+ set gate_flist $::env(OBJECTS_DIR)/1_synth_gate.f
+ write_sv_rtl_flist $rtl_flist
+ set out [open $gate_flist w]
+ puts $out "$::env(RESULTS_DIR)/1_synth.v"
+ close $out
+ write_sv_lec_script 1_synth $rtl_flist $gate_flist
+ run_lec_config 1_synth "Synthesis output"
+}
diff --git a/flow/scripts/synth_syn.tcl b/flow/scripts/synth_syn.tcl
index efe1f7779b..f8e58149c2 100644
--- a/flow/scripts/synth_syn.tcl
+++ b/flow/scripts/synth_syn.tcl
@@ -75,3 +75,17 @@ 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 (kepler-formal compares it against the input
+# RTL) 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
+
+# Pre- vs post-synthesis LEC. Explicit opt-in via SYNTH_LEC_CHECK=1 —
+# unlike the CTS-stage LEC it does not auto-enable when kepler-formal is
+# installed, because the RTL side depends on kepler's experimental
+# SystemVerilog frontend.
+if { [synth_lec_check_enabled] } {
+ run_synth_lec_test
+}
diff --git a/flow/scripts/variables.json b/flow/scripts/variables.json
index bc9be9bdea..823a55d643 100644
--- a/flow/scripts/variables.json
+++ b/flow/scripts/variables.json
@@ -490,6 +490,7 @@
"LEC_AUX_VERILOG_FILES": {
"description": "Additional Verilog files (e.g. blackbox stubs) to include in LEC equivalence checks. Appended to the generated Verilog netlist before running the formal equivalence check.\n",
"stages": [
+ "synth",
"cts"
]
},
@@ -1309,6 +1310,13 @@
"synth"
]
},
+ "SYNTH_LEC_CHECK": {
+ "default": 0,
+ "description": "Run a formal equivalence check between the input RTL and the OpenROAD-SYN synthesis netlist (1_synth.v). The RTL side goes through kepler-formal's experimental SystemVerilog frontend, which does not yet support all RTL constructs, so this is a separate opt-in from LEC_CHECK (which auto-enables when kepler-formal is installed and compares gate-level netlists only). Requires KEPLER_FORMAL_EXE.\n",
+ "stages": [
+ "synth"
+ ]
+ },
"SYNTH_MEMORY_MAX_BITS": {
"default": 4096,
"description": "Maximum number of bits for memory synthesis. Ideally, real RAM or realistic fakeram should be used for RAMs much larger than 1024 bits. To temporarily ignore the RAM concerns and investigate other aspects of the design, consider setting `SYNTH_MOCK_LARGE_MEMORIES=1`, or adjusting `SYNTH_MEMORY_MAX_BITS`.\n",
diff --git a/flow/scripts/variables.yaml b/flow/scripts/variables.yaml
index 24802995b2..ced33f1e65 100644
--- a/flow/scripts/variables.yaml
+++ b/flow/scripts/variables.yaml
@@ -1579,12 +1579,24 @@ LEC_CHECK:
default: 0
stages:
- cts
+SYNTH_LEC_CHECK:
+ description: >
+ Run a formal equivalence check between the input RTL and the
+ OpenROAD-SYN synthesis netlist (1_synth.v). The RTL side goes through
+ kepler-formal's experimental SystemVerilog frontend, which does not
+ yet support all RTL constructs, so this is a separate opt-in from
+ LEC_CHECK (which auto-enables when kepler-formal is installed and
+ compares gate-level netlists only). Requires KEPLER_FORMAL_EXE.
+ default: 0
+ stages:
+ - synth
LEC_AUX_VERILOG_FILES:
description: >
Additional Verilog files (e.g. blackbox stubs) to include in LEC
equivalence checks. Appended to the generated Verilog netlist before
running the formal equivalence check.
stages:
+ - synth
- cts
REMOVE_CELLS_FOR_LEC:
description: >
diff --git a/flow/util/BUILD b/flow/util/BUILD
index cc21b559c6..aa9f3105e2 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"])
@@ -39,6 +39,26 @@ py_library(
imports = ["."],
)
+# Renders the OpenROAD-SYN vs yosys synthesis QoR comparison graph from
+# per-design qor.json pairs (see //flow/designs/asap7:syn_qor_png).
+# matplotlib comes from the @orfs-pip hub, so `bazelisk build` needs no
+# host python packages.
+py_binary(
+ name = "plotSynQoR",
+ srcs = ["plotSynQoR.py"],
+ visibility = ["//visibility:public"],
+ deps = ["@orfs-pip//matplotlib"],
+)
+
+# Renders effective clock period across flow stages from the checked-in
+# rules-*.json QoR gates (see //flow/designs/asap7:clock_period_png).
+py_binary(
+ name = "plotClockPeriod",
+ srcs = ["plotClockPeriod.py"],
+ visibility = ["//visibility:public"],
+ deps = ["@orfs-pip//matplotlib"],
+)
+
py_test(
name = "genMetrics_test",
srcs = ["genMetrics_test.py"],
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..55c818bc81 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,9 +261,26 @@ 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
+ # Scalar clock period (min across clocks) so rule files can carry it:
+ # checkMetadata.py cannot compare the details list, and QoR plots need
+ # the period to turn per-stage worst slack into an effective period.
+ periods = []
+ for clk in clk_list:
+ try:
+ periods.append(float(clk.split()[-1]))
+ except (ValueError, IndexError):
+ pass
+ if periods:
+ metrics_dict["constraints__clocks__period"] = min(periods)
# Floorplan
# =========================================================================
@@ -277,22 +297,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/genRuleFile.py b/flow/util/genRuleFile.py
index 358d59231e..5733c07367 100755
--- a/flow/util/genRuleFile.py
+++ b/flow/util/genRuleFile.py
@@ -95,12 +95,35 @@ def gen_rule_file(
"round_value": False,
"compare": "<=",
},
+ "synth__design__instance__count__stdcell": {
+ "mode": "padding",
+ "padding": 15,
+ "round_value": True,
+ "compare": "<=",
+ },
+ "synth__timing__setup__ws": {
+ "mode": "period_padding",
+ "padding": 5,
+ "round_value": False,
+ "compare": ">=",
+ },
+ "synth__timing__setup__tns": {
+ "mode": "period_padding",
+ "padding": 20,
+ "round_value": False,
+ "compare": ">=",
+ },
# clock
"constraints__clocks__count": {
"mode": "direct",
"round_value": True,
"compare": "==",
},
+ "constraints__clocks__period": {
+ "mode": "direct",
+ "round_value": False,
+ "compare": "==",
+ },
# place
"placeopt__design__instance__area": {
"mode": "padding",
diff --git a/flow/util/plotClockPeriod.py b/flow/util/plotClockPeriod.py
new file mode 100644
index 0000000000..fdcf5d3347
--- /dev/null
+++ b/flow/util/plotClockPeriod.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""Plot effective clock period across flow stages from rules-*.json.
+
+For every design, rules-base.json carries the QoR-gated setup worst
+slack of the design's regular flow at the synth/cts/globalroute/finish
+stages, and rules-syn.json carries the OpenROAD-SYN synthesis-only
+gate. Effective period = clock period - worst slack, i.e. the period
+the design would need to meet timing. One line per design (regular
+flow), with the OpenROAD-SYN post-synthesis point overlaid as a diamond
+so the two synthesis engines can be compared without running any flow.
+
+The values are QoR gates (padded by genRuleFile.py), not measurements —
+both engines are padded identically, so the comparison is fair, but the
+absolute numbers include rule margin.
+
+Usage:
+ plotClockPeriod.py --output png \\
+ --design gcd --rules-base .../rules-base.json --rules-syn .../rules-syn.json \\
+ [--design ... --rules-base ... --rules-syn ...]
+
+Deterministic output (Agg backend, fixed geometry, no timestamps).
+"""
+
+import argparse
+import json
+import sys
+
+import matplotlib
+
+matplotlib.use("Agg")
+
+import matplotlib.pyplot as plt # noqa: E402
+from matplotlib.ticker import ScalarFormatter # noqa: E402
+
+STAGES = ["synth", "cts", "globalroute", "finish"]
+
+TEXT_PRIMARY = "#0b0b0b"
+TEXT_SECONDARY = "#52514e"
+SURFACE = "#fcfcfb"
+SYN_MARKER_EDGE = "#0b0b0b"
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", required=True, help="Output PNG path")
+ parser.add_argument(
+ "--title",
+ default="Effective clock period across stages (rules-*.json gates, asap7)",
+ help="Figure title",
+ )
+ parser.add_argument("--design", action="append", default=[], help="Design name")
+ parser.add_argument(
+ "--rules-base", action="append", default=[], help="rules-base.json path"
+ )
+ parser.add_argument(
+ "--rules-syn", action="append", default=[], help="rules-syn.json path"
+ )
+ args = parser.parse_args()
+ if not (len(args.design) == len(args.rules_base) == len(args.rules_syn)):
+ parser.error(
+ "--design/--rules-base/--rules-syn must be given the same number of times"
+ )
+ if not args.design:
+ parser.error("at least one design is required")
+ return args
+
+
+def load_rules(path):
+ try:
+ with open(path) as f:
+ return json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ print(f"[WARN] cannot read {path}: {e}", file=sys.stderr)
+ return {}
+
+
+def rule_number(rules, field):
+ rule = rules.get(field)
+ if not rule:
+ return None
+ value = rule.get("value")
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ return float(value)
+ return None
+
+
+def main():
+ args = parse_args()
+
+ fig, ax = plt.subplots(figsize=(9.0, 7.0), dpi=120)
+ fig.patch.set_facecolor(SURFACE)
+ ax.set_facecolor(SURFACE)
+
+ cmap = plt.get_cmap("tab20")
+ x = range(len(STAGES))
+ skipped = []
+ plotted = 0
+
+ for idx, (design, base_path, syn_path) in enumerate(
+ zip(args.design, args.rules_base, args.rules_syn)
+ ):
+ base = load_rules(base_path)
+ syn = load_rules(syn_path)
+ period = rule_number(syn, "constraints__clocks__period") or rule_number(
+ base, "constraints__clocks__period"
+ )
+ if period is None:
+ skipped.append(design)
+ continue
+
+ color = cmap(idx % 20)
+ ys = [
+ (
+ period - ws
+ if (ws := rule_number(base, f"{stage}__timing__setup__ws")) is not None
+ else None
+ )
+ for stage in STAGES
+ ]
+ xs = [i for i, y in zip(x, ys) if y is not None]
+ vals = [y for y in ys if y is not None]
+ if vals:
+ ax.plot(
+ xs,
+ vals,
+ marker="o",
+ markersize=4,
+ linewidth=1.6,
+ color=color,
+ zorder=2,
+ )
+ # Direct label at the line end; text in ink, mark carries color.
+ ax.annotate(
+ design,
+ (xs[-1], vals[-1]),
+ textcoords="offset points",
+ xytext=(6, 0),
+ fontsize=7,
+ color=TEXT_PRIMARY,
+ va="center",
+ zorder=3,
+ )
+ plotted += 1
+
+ syn_ws = rule_number(syn, "synth__timing__setup__ws")
+ if syn_ws is not None:
+ ax.plot(
+ [0],
+ [period - syn_ws],
+ marker="D",
+ markersize=7,
+ linestyle="none",
+ markerfacecolor=color,
+ markeredgecolor=SYN_MARKER_EDGE,
+ markeredgewidth=0.8,
+ zorder=4,
+ )
+
+ ax.set_xticks(list(x))
+ ax.set_xticklabels(STAGES, fontsize=9)
+ ax.set_yscale("log")
+ for set_formatter in [ax.yaxis.set_major_formatter, ax.yaxis.set_minor_formatter]:
+ formatter = ScalarFormatter()
+ formatter.set_scientific(False)
+ set_formatter(formatter)
+ ax.set_ylabel("effective clock period: target - worst slack (ps, log)", fontsize=9)
+ ax.grid(axis="y", which="both", color="#e5e4e0", linewidth=0.6, zorder=0)
+ ax.tick_params(colors=TEXT_SECONDARY, labelsize=8)
+ for spine in ax.spines.values():
+ spine.set_visible(False)
+ ax.margins(x=0.12)
+
+ title = args.title
+ if skipped:
+ title += "\n(no period rule yet: {})".format(", ".join(skipped))
+ ax.set_title(title, fontsize=11, color=TEXT_PRIMARY, loc="left")
+
+ handles = [
+ plt.Line2D([], [], marker="o", linewidth=1.6, color=TEXT_SECONDARY),
+ plt.Line2D(
+ [],
+ [],
+ marker="D",
+ linestyle="none",
+ markersize=7,
+ markerfacecolor=SURFACE,
+ markeredgecolor=SYN_MARKER_EDGE,
+ ),
+ ]
+ ax.legend(
+ handles,
+ ["regular flow (rules-base.json)", "OpenROAD-SYN synth (rules-syn.json)"],
+ loc="upper right",
+ fontsize=8,
+ frameon=False,
+ )
+
+ fig.tight_layout()
+ fig.savefig(args.output, facecolor=fig.get_facecolor())
+ print(f"Wrote {args.output} ({plotted} designs, {len(skipped)} skipped)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/flow/util/plotSynQoR.py b/flow/util/plotSynQoR.py
new file mode 100644
index 0000000000..33eff3e765
--- /dev/null
+++ b/flow/util/plotSynQoR.py
@@ -0,0 +1,434 @@
+#!/usr/bin/env python3
+"""Plot synthesis QoR of OpenROAD-SYN vs yosys across designs.
+
+Consumes per-design qor.json pairs produced by bazel-orfs's best-effort
+orfs_synth_qor targets (_syn_qor / _yosys_qor): each file
+is either a synthesis-only metadata.json or a failure marker
+{"orfs_qor_failed": true, ...}. Designs whose synthesis failed are drawn
+as hatched FAIL columns rather than omitted, so the output is a status
+report of OpenROAD-SYN across the design suite.
+
+Usage:
+ plotSynQoR.py --output qor.png \\
+ --design gcd --syn gcd_syn.json --base gcd_yosys.json \\
+ --design aes --syn aes_syn.json --base aes_yosys.json ...
+
+The output is deterministic (Agg backend, fixed geometry, no timestamps)
+so the PNG can be checked in and diffed meaningfully.
+"""
+
+import argparse
+import json
+import sys
+
+import matplotlib
+
+matplotlib.use("Agg")
+
+import matplotlib.pyplot as plt # noqa: E402
+from matplotlib.ticker import ScalarFormatter # noqa: E402
+from matplotlib.transforms import blended_transform_factory # noqa: E402
+
+# Palette: diverging pair and A/B categorical pair (validated for CVD
+# separation and contrast on a light surface), neutral grays for small
+# deltas and failure states.
+BETTER = "#2a78d6"
+WORSE = "#c44536"
+SERIES_YOSYS = "#2a78d6"
+SERIES_SYN = "#eb6834"
+NEUTRAL = "#6a6965"
+FAIL_FACE = "#e8e7e3"
+FAIL_EDGE = "#52514e"
+TEXT_PRIMARY = "#0b0b0b"
+TEXT_SECONDARY = "#52514e"
+SURFACE = "#fcfcfb"
+
+# Deltas within this band (percent or ps) are drawn neutral: they carry
+# no better/worse verdict at this resolution.
+NEUTRAL_BAND = 1.0
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", required=True, help="Output PNG path")
+ parser.add_argument(
+ "--runtime-output",
+ default=None,
+ help="Optional second PNG: absolute synthesis runtime A/B "
+ "(yosys vs OpenROAD-SYN) per design",
+ )
+ parser.add_argument(
+ "--title",
+ default="OpenROAD-SYN vs yosys: synthesis QoR (asap7)",
+ help="Figure title",
+ )
+ parser.add_argument(
+ "--design", action="append", default=[], help="Design name (repeat)"
+ )
+ parser.add_argument(
+ "--syn",
+ action="append",
+ default=[],
+ help="qor.json from the OpenROAD-SYN run (repeat, pairs with --design)",
+ )
+ parser.add_argument(
+ "--base",
+ action="append",
+ default=[],
+ help="qor.json from the yosys baseline run (repeat, pairs with --design)",
+ )
+ args = parser.parse_args()
+ if not (len(args.design) == len(args.syn) == len(args.base)):
+ parser.error("--design/--syn/--base must be given the same number of times")
+ if not args.design:
+ parser.error("at least one --design/--syn/--base triple is required")
+ return args
+
+
+def load_qor(path):
+ """Load a qor.json; returns (metrics_dict, failed)."""
+ try:
+ with open(path) as f:
+ data = json.load(f)
+ except (OSError, json.JSONDecodeError) as e:
+ print(f"[WARN] treating {path} as failed: {e}", file=sys.stderr)
+ return {}, True
+ return data, bool(data.get("orfs_qor_failed"))
+
+
+def get_number(metrics, key):
+ value = metrics.get(key)
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ return float(value)
+ return None
+
+
+def percent_delta(syn, base):
+ if syn is None or base is None or base == 0:
+ return None
+ return 100.0 * (syn - base) / abs(base)
+
+
+# Panel definitions: (title, y-axis label, function(syn_metrics,
+# base_metrics) -> value or None). Percent panels: negative = SYN smaller.
+def _area_delta(syn, base):
+ return percent_delta(
+ get_number(syn, "synth__design__instance__area__stdcell"),
+ get_number(base, "synth__design__instance__area__stdcell"),
+ )
+
+
+def _count_delta(syn, base):
+ return percent_delta(
+ get_number(syn, "synth__design__instance__count__stdcell"),
+ get_number(base, "synth__design__instance__count__stdcell"),
+ )
+
+
+def runtime_seconds(metrics):
+ """Synthesis runtime in seconds: synth__elapsed_seconds when numeric,
+ else gnu-time's synth__runtime__total ("[H:]M:SS.frac")."""
+ value = get_number(metrics, "synth__elapsed_seconds")
+ if value is not None:
+ return value
+ total = metrics.get("synth__runtime__total")
+ if not isinstance(total, str):
+ return None
+ parts = total.split(":")
+ try:
+ seconds = float(parts[-1])
+ if len(parts) > 1:
+ seconds += 60 * int(parts[-2])
+ if len(parts) > 2:
+ seconds += 3600 * int(parts[-3])
+ except ValueError:
+ return None
+ return seconds
+
+
+def _runtime_delta(syn, base):
+ return percent_delta(runtime_seconds(syn), runtime_seconds(base))
+
+
+def _syn_ws(syn, base):
+ return get_number(syn, "synth__timing__setup__ws")
+
+
+PANELS = [
+ (
+ "Std-cell area, SYN vs yosys",
+ "Δ area (%)",
+ _area_delta,
+ "delta",
+ False,
+ ),
+ (
+ "Std-cell instance count, SYN vs yosys",
+ "Δ instances (%)",
+ _count_delta,
+ "delta",
+ False,
+ ),
+ (
+ "Synthesis runtime, SYN vs yosys",
+ "Δ runtime (%)",
+ _runtime_delta,
+ "delta",
+ False,
+ ),
+ (
+ "OpenROAD-SYN setup worst slack after synthesis "
+ "(no yosys equivalent metric)",
+ "WS (ps)",
+ _syn_ws,
+ "absolute",
+ True,
+ ),
+]
+
+
+def bar_color(value, kind, higher_is_better):
+ if kind == "absolute":
+ return NEUTRAL
+ if abs(value) <= NEUTRAL_BAND:
+ return NEUTRAL
+ improved = value > 0 if higher_is_better else value < 0
+ return BETTER if improved else WORSE
+
+
+def render_runtime_ab(path, title, loaded):
+ """Absolute synthesis runtime A/B: yosys vs OpenROAD-SYN per design.
+
+ Grouped bars on a log axis (runtimes span seconds to hours across the
+ suite). A failed side renders as a hatched marker column instead of a
+ bar. Note the yosys number is the main yosys step (canonicalization
+ excluded), the SYN number is the whole synth_syn.tcl run.
+ """
+ designs = [entry[0] for entry in loaded]
+ n = len(designs)
+ fig, ax = plt.subplots(figsize=(max(8.0, 0.75 * n + 2.0), 5.0), dpi=120)
+ fig.patch.set_facecolor(SURFACE)
+ ax.set_facecolor(SURFACE)
+
+ width = 0.38
+ finite = []
+ for _, syn, syn_failed, base, base_failed in loaded:
+ for metrics, failed in [(base, base_failed), (syn, syn_failed)]:
+ value = None if failed else runtime_seconds(metrics)
+ if value:
+ finite.append(value)
+ for i, (name, syn, syn_failed, base, base_failed) in enumerate(loaded):
+ for offset, metrics, failed, color in [
+ (-width / 2, base, base_failed, SERIES_YOSYS),
+ (width / 2, syn, syn_failed, SERIES_SYN),
+ ]:
+ value = None if failed else runtime_seconds(metrics)
+ if value is None:
+ ax.text(
+ i + offset,
+ 0.5,
+ "FAIL" if failed else "n/a",
+ transform=blended_transform_factory(ax.transData, ax.transAxes),
+ rotation=90,
+ ha="center",
+ va="center",
+ fontsize=7,
+ color=FAIL_EDGE,
+ zorder=3,
+ )
+ else:
+ ax.bar(i + offset, value, width=width, color=color, zorder=2)
+ ax.text(
+ i + offset,
+ value * 1.08,
+ f"{value:.0f}" if value >= 10 else f"{value:.1f}",
+ ha="center",
+ va="bottom",
+ fontsize=7,
+ color=TEXT_PRIMARY,
+ zorder=3,
+ )
+
+ ax.set_yscale("log")
+ if finite:
+ # Explicit headroom so on-bar value labels stay inside the axes;
+ # ax.margins is additive and misbehaves on a log scale.
+ ax.set_ylim(min(finite) / 1.6, max(finite) * 1.6)
+ for set_formatter in [ax.yaxis.set_major_formatter, ax.yaxis.set_minor_formatter]:
+ formatter = ScalarFormatter()
+ formatter.set_scientific(False)
+ set_formatter(formatter)
+ ax.set_ylabel(
+ "synthesis runtime (s, log) — yosys main step vs whole SYN run",
+ fontsize=9,
+ color=TEXT_SECONDARY,
+ )
+ ax.set_xticks(range(n))
+ ax.set_xticklabels(designs, rotation=45, ha="right", fontsize=8)
+ ax.grid(axis="y", which="both", color="#e5e4e0", linewidth=0.6, zorder=0)
+ ax.tick_params(colors=TEXT_SECONDARY, labelsize=8)
+ for spine in ax.spines.values():
+ spine.set_visible(False)
+ ax.set_title(title, fontsize=11, color=TEXT_PRIMARY, loc="left")
+ ax.legend(
+ [
+ plt.Rectangle((0, 0), 1, 1, color=SERIES_YOSYS),
+ plt.Rectangle((0, 0), 1, 1, color=SERIES_SYN),
+ ],
+ ["yosys", "OpenROAD-SYN"],
+ loc="upper left",
+ fontsize=8,
+ frameon=False,
+ )
+ fig.tight_layout()
+ fig.savefig(path, facecolor=fig.get_facecolor())
+ print(f"Wrote {path}")
+
+
+def main():
+ args = parse_args()
+
+ designs = args.design
+ loaded = []
+ for name, syn_path, base_path in zip(designs, args.syn, args.base):
+ syn, syn_failed = load_qor(syn_path)
+ base, base_failed = load_qor(base_path)
+ loaded.append((name, syn, syn_failed, base, base_failed))
+
+ n = len(designs)
+ x = range(n)
+ fig, axes = plt.subplots(
+ len(PANELS),
+ 1,
+ figsize=(max(8.0, 0.75 * n + 2.0), 3.0 * len(PANELS)),
+ sharex=True,
+ dpi=120,
+ )
+ fig.patch.set_facecolor(SURFACE)
+
+ for ax, (title, ylabel, fn, kind, higher_is_better) in zip(axes, PANELS):
+ ax.set_facecolor(SURFACE)
+ values = []
+ for name, syn, syn_failed, base, base_failed in loaded:
+ values.append(None if (syn_failed or base_failed) else fn(syn, base))
+
+ finite = [v for v in values if v is not None]
+ span = max((abs(v) for v in finite), default=1.0) or 1.0
+
+ for i, ((name, syn, syn_failed, base, base_failed), value) in enumerate(
+ zip(loaded, values)
+ ):
+ if syn_failed or base_failed:
+ # Hatched full-height FAIL column: the point of this graph
+ # is to show failures, not hide them.
+ ax.axvspan(
+ i - 0.4,
+ i + 0.4,
+ facecolor=FAIL_FACE,
+ edgecolor=FAIL_EDGE,
+ hatch="//",
+ linewidth=0.6,
+ zorder=1,
+ )
+ which = "SYN" if syn_failed else "yosys"
+ if syn_failed and base_failed:
+ which = "both"
+ # x in data coords, y centered in the panel regardless of
+ # the y-range, so the label never clips at a panel edge.
+ ax.text(
+ i,
+ 0.5,
+ f"{which} FAIL",
+ transform=blended_transform_factory(ax.transData, ax.transAxes),
+ rotation=90,
+ ha="center",
+ va="center",
+ fontsize=7,
+ color=FAIL_EDGE,
+ zorder=3,
+ )
+ elif value is None:
+ ax.text(
+ i,
+ 0.5,
+ "n/a",
+ transform=blended_transform_factory(ax.transData, ax.transAxes),
+ rotation=90,
+ ha="center",
+ va="center",
+ fontsize=7,
+ color=TEXT_SECONDARY,
+ zorder=3,
+ )
+ else:
+ ax.bar(
+ i,
+ value,
+ width=0.66,
+ color=bar_color(value, kind, higher_is_better),
+ zorder=2,
+ )
+ offset = 0.04 * span
+ ax.text(
+ i,
+ value + (offset if value >= 0 else -offset),
+ f"{value:+.1f}" if kind == "delta" else f"{value:.0f}",
+ ha="center",
+ va="bottom" if value >= 0 else "top",
+ fontsize=7,
+ color=TEXT_PRIMARY,
+ zorder=3,
+ )
+
+ ax.axhline(0, color=TEXT_SECONDARY, linewidth=0.8, zorder=2)
+ ax.set_title(title, fontsize=10, color=TEXT_PRIMARY, loc="left")
+ ax.set_ylabel(ylabel, fontsize=9, color=TEXT_SECONDARY)
+ ax.margins(y=0.18)
+ ax.grid(axis="y", color="#e5e4e0", linewidth=0.6, zorder=0)
+ ax.tick_params(colors=TEXT_SECONDARY, labelsize=8)
+ for spine in ax.spines.values():
+ spine.set_visible(False)
+
+ axes[-1].set_xticks(list(x))
+ axes[-1].set_xticklabels(designs, rotation=45, ha="right", fontsize=8)
+
+ fig.suptitle(args.title, fontsize=12, color=TEXT_PRIMARY)
+ handles = [
+ plt.Rectangle((0, 0), 1, 1, color=BETTER),
+ plt.Rectangle((0, 0), 1, 1, color=WORSE),
+ plt.Rectangle((0, 0), 1, 1, color=NEUTRAL),
+ plt.Rectangle(
+ (0, 0),
+ 1,
+ 1,
+ facecolor=FAIL_FACE,
+ edgecolor=FAIL_EDGE,
+ hatch="//",
+ ),
+ ]
+ fig.legend(
+ handles,
+ [
+ "SYN better",
+ "SYN worse",
+ f"within ±{NEUTRAL_BAND:g}% / absolute",
+ "synthesis failed",
+ ],
+ loc="upper right",
+ fontsize=8,
+ frameon=False,
+ )
+ fig.tight_layout(rect=(0, 0, 1, 0.96))
+ fig.savefig(args.output, facecolor=fig.get_facecolor())
+ print(f"Wrote {args.output}")
+
+ if args.runtime_output:
+ render_runtime_ab(
+ args.runtime_output,
+ "Synthesis runtime A/B: yosys vs OpenROAD-SYN (asap7)",
+ loaded,
+ )
+
+
+if __name__ == "__main__":
+ main()
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
diff --git a/patches/bazel-orfs/0001-synth-support-OpenROAD-SYN-engine-SYNTH_USE_SYN-1-an.patch b/patches/bazel-orfs/0001-synth-support-OpenROAD-SYN-engine-SYNTH_USE_SYN-1-an.patch
new file mode 100644
index 0000000000..2de7f72cfd
--- /dev/null
+++ b/patches/bazel-orfs/0001-synth-support-OpenROAD-SYN-engine-SYNTH_USE_SYN-1-an.patch
@@ -0,0 +1,306 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?=C3=98yvind=20Harboe?=
+Date: Thu, 16 Jul 2026 16:14:51 +0200
+Subject: [PATCH 1/5] synth: support OpenROAD-SYN engine (SYNTH_USE_SYN=1) and
+ declare 1_synth.json
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+SYNTH_USE_SYN=1 in the merged arguments switches the synthesis stage from
+the yosys flow to OpenROAD's built-in synthesizer: a single do-1_synth
+action (synth_syn.tcl) consuming the staged Verilog sources directly.
+Yosys canonicalization outputs are stubbed (lint-mode precedent) so the
+provider tail is engine-agnostic. Parallel/hierarchical yosys synthesis
+vars are ignored in SYN mode, matching the Makefile.
+
+Also declare logs/1_synth.json (written by flow.sh's openroad -metrics)
+in both engine modes and surface it via LoggingInfo.jsons: without it,
+generate_metadata never saw any synth__* metrics and metadata-check
+failed on rules-base.json's synth rules.
+
+orfs_test: honor the existing cmd attr instead of hardcoding
+metadata-check.
+
+Co-Authored-By: Claude Fable 5
+Signed-off-by: Øyvind Harboe
+---
+ private/rules.bzl | 176 ++++++++++++++++++++++++++++++++++------------
+ 1 file changed, 131 insertions(+), 45 deletions(-)
+
+diff --git a/private/rules.bzl b/private/rules.bzl
+index 2c77c53..00925d4 100644
+--- a/private/rules.bzl
++++ b/private/rules.bzl
+@@ -573,8 +573,9 @@ if [ ! -e external ]; then
+ # Needed as of Bazel >= 8
+ ln -sf $(realpath $(pwd)/..) external
+ fi
+-{make} --file {makefile} {moreargs} metadata-check
++{make} --file {makefile} {moreargs} {cmd}
+ """.format(
++ cmd = ctx.attr.cmd,
+ make = ctx.executable._make.short_path,
+ makefile = ctx.file._makefile.path,
+ moreargs = environment_string(
+@@ -744,9 +745,14 @@ orfs_run_executable = rule(
+
+ CANON_OUTPUT = "1_1_yosys_canonicalize.rtlil"
+ SYNTH_OUTPUTS = ["1_2_yosys.v", "1_2_yosys.sdc", "1_synth.sdc", "mem.json"]
++
++# Outputs of the OpenROAD-SYN synthesis flow (SYNTH_USE_SYN=1). synth_syn.tcl
++# always writes the ODB, the canonicalized SDC and a gate-level Verilog
++# netlist (LEC input); none of the yosys-flow files exist in this mode.
++SYN_OUTPUTS = ["1_synth.odb", "1_synth.sdc", "1_synth.v"]
+ SYNTH_REPORTS = ["synth_stat.txt", "synth_mocked_memories.txt"]
+
+-def _yosys_parallel_synth(ctx, config, canon_output, synth_outputs, synth_logs, synth_reports, num_partitions, save_odb, all_arguments = {}):
++def _yosys_parallel_synth(ctx, config, canon_output, synth_outputs, synth_logs, synth_jsons, synth_reports, num_partitions, save_odb, all_arguments = {}):
+ """Parallel synthesis: keep → kept-json → N partitions → merge.
+
+ Yosys is not deterministic when using host threads, so SYNTH_NUM_PARTITIONS
+@@ -1101,7 +1107,12 @@ def _yosys_parallel_synth(ctx, config, canon_output, synth_outputs, synth_logs,
+ if save_odb:
+ odb_commands = [_make_cmd(ctx)] + generation_commands(
+ [synth_outputs["1_synth.odb"]],
+- )
++ ) + [
++ # flow.sh writes logs/1_synth.json during do-1_synth; guarantee a
++ # parseable declared output either way (genMetrics json.load()s it).
++ "[ -s {p} ] || echo '{{}}' > {p}".format(p = f.path)
++ for f in synth_jsons
++ ]
+ ctx.actions.run_shell(
+ arguments = [
+ "--file",
+@@ -1123,7 +1134,7 @@ def _yosys_parallel_synth(ctx, config, canon_output, synth_outputs, synth_logs,
+ deps_inputs(ctx),
+ ],
+ ),
+- outputs = [synth_outputs["1_synth.odb"], synth_outputs["1_synth.sdc"]],
++ outputs = [synth_outputs["1_synth.odb"], synth_outputs["1_synth.sdc"]] + synth_jsons,
+ tools = yosys_and_flow_tools,
+ )
+
+@@ -1152,41 +1163,55 @@ def _yosys_impl(ctx):
+ ),
+ )
+
++ # Engine selection: SYNTH_USE_SYN=1 in the merged arguments switches the
++ # synthesis stage from the yosys flow to OpenROAD's built-in synthesizer
++ # (the Makefile's synth_syn path). Analysis-time only: the value must
++ # come through arguments/stage_arguments, not extra_arguments .json files.
++ use_syn = all_arguments.get("SYNTH_USE_SYN") == "1"
++
+ canon_logs = declare_artifacts(ctx, "logs", ["1_1_yosys_canonicalize.log"])
+
+ canon_output = declare_artifact(ctx, "results", CANON_OUTPUT)
+
+- # SYNTH_NETLIST_FILES will not create an .rtlil file or reports, so we need
+- # an empty placeholder in that case.
+- commands = [_make_cmd(ctx)] + generation_commands(
+- canon_logs + [canon_output],
+- )
++ if use_syn:
++ # OpenROAD-SYN has no yosys canonicalization; stub its outputs
++ # (lint-mode precedent) so the provider/deploy tail below is
++ # identical in both engine modes. genMetrics.py treats the empty
++ # RTLIL like a missing file when hashing.
++ for f in canon_logs + [canon_output]:
++ ctx.actions.write(output = f, content = "")
++ else:
++ # SYNTH_NETLIST_FILES will not create an .rtlil file or reports, so we need
++ # an empty placeholder in that case.
++ commands = [_make_cmd(ctx)] + generation_commands(
++ canon_logs + [canon_output],
++ )
+
+- ctx.actions.run_shell(
+- arguments = [
+- "--file",
+- ctx.file._makefile_yosys.path,
+- "yosys-dependencies",
+- "do-yosys-canonicalize",
+- ],
+- command = EXPAND_VERILOG_DIRS + " && ".join(commands),
+- env = config_overrides(
+- ctx,
+- verilog_arguments(ctx.files.verilog_files) |
+- yosys_environment(ctx) |
+- config_environment(config),
+- ),
+- inputs = depset(
+- [config] + ctx.files.verilog_files + ctx.files.extra_configs,
+- transitive = [
+- data_inputs(ctx),
+- pdk_inputs(ctx),
+- deps_inputs(ctx),
++ ctx.actions.run_shell(
++ arguments = [
++ "--file",
++ ctx.file._makefile_yosys.path,
++ "yosys-dependencies",
++ "do-yosys-canonicalize",
+ ],
+- ),
+- outputs = [canon_output] + canon_logs,
+- tools = yosys_inputs(ctx),
+- )
++ command = EXPAND_VERILOG_DIRS + " && ".join(commands),
++ env = config_overrides(
++ ctx,
++ verilog_arguments(ctx.files.verilog_files) |
++ yosys_environment(ctx) |
++ config_environment(config),
++ ),
++ inputs = depset(
++ [config] + ctx.files.verilog_files + ctx.files.extra_configs,
++ transitive = [
++ data_inputs(ctx),
++ pdk_inputs(ctx),
++ deps_inputs(ctx),
++ ],
++ ),
++ outputs = [canon_output] + canon_logs,
++ tools = yosys_inputs(ctx),
++ )
+
+ num_partitions = int(all_arguments.get("SYNTH_NUM_PARTITIONS", "0"))
+ if num_partitions == 0 and all_arguments.get("SYNTH_KEEP_MODULES"):
+@@ -1194,31 +1219,92 @@ def _yosys_impl(ctx):
+ # when NUM_CPUS-based auto-detection hasn't run (direct orfs_synth call).
+ kept_count = len(all_arguments["SYNTH_KEEP_MODULES"].split(" "))
+ num_partitions = max(1, kept_count)
++ if use_syn:
++ # Parallel/hierarchical synthesis is a yosys-flow concept; the
++ # Makefile's SYNTH_USE_SYN path ignores SYNTH_KEEP_MODULES and
++ # SYNTH_NUM_PARTITIONS (e.g. asap7/swerv_wrapper carries both
++ # with SYNTH_USE_SYN=1), so never take the partitioned branch.
++ num_partitions = 0
+
+ save_odb = ctx.attr.save_odb
+-
+- synth_logs = declare_artifacts(ctx, "logs", ["1_2_yosys.log", "1_2_yosys_metrics.log"] + (["1_synth.log"] if save_odb else []))
+-
+- synth_outputs = {}
+- for output in SYNTH_OUTPUTS + (["1_synth.odb"] if save_odb else []):
+- synth_outputs[output] = declare_artifact(ctx, "results", output)
++ if use_syn and not save_odb:
++ fail("SYNTH_USE_SYN=1 always writes 1_synth.odb; save_odb=False is " +
++ "not supported in " + str(ctx.label))
++
++ # logs/1_synth.json: metrics that flow.sh's `openroad -metrics` writes
++ # during do-1_synth. Declared so generate_metadata (which merges
++ # logs/1_*.json via genMetrics.py) sees the synth__* metrics.
++ if use_syn:
++ synth_logs = declare_artifacts(ctx, "logs", ["1_synth.log"])
++ synth_jsons = declare_artifacts(ctx, "logs", ["1_synth.json"])
++ synth_outputs = {o: declare_artifact(ctx, "results", o) for o in SYN_OUTPUTS}
++ else:
++ synth_logs = declare_artifacts(ctx, "logs", ["1_2_yosys.log", "1_2_yosys_metrics.log"] + (["1_synth.log"] if save_odb else []))
++ synth_jsons = declare_artifacts(ctx, "logs", ["1_synth.json"] if save_odb else [])
++ synth_outputs = {}
++ for output in SYNTH_OUTPUTS + (["1_synth.odb"] if save_odb else []):
++ synth_outputs[output] = declare_artifact(ctx, "results", output)
+
+ synth_reports = declare_artifacts(ctx, "reports", SYNTH_REPORTS)
+
+ variables = declare_artifact(ctx, "results", "1_synth.vars")
+
++ # Ensure declared metrics .json outputs exist and are parseable even if
++ # make did not write them; genMetrics.py json.load()s every 1_*.json, so
++ # the fallback must be "{}" rather than a touched empty file.
++ json_fallback = [
++ "[ -s {p} ] || echo '{{}}' > {p}".format(p = f.path)
++ for f in synth_jsons
++ ]
++
+ if ctx.attr.lint:
+ # Lint mode: only canonicalization runs; stub remaining synth outputs.
+ for f in synth_outputs.values() + synth_logs + synth_reports + [variables]:
+ ctx.actions.write(output = f, content = "")
++ for f in synth_jsons:
++ ctx.actions.write(output = f, content = "{}")
++ elif use_syn:
++ # OpenROAD-SYN synthesis: one openroad invocation (synth_syn.tcl via
++ # do-1_synth) consumes the staged Verilog sources directly, so
++ # VERILOG_FILES is set from ctx.files.verilog_files exactly like the
++ # yosys canonicalize action does. Uses the full flow makefile
++ # filegroup (synth_syn.tcl sources load.tcl etc.), no yosys tools.
++ commands = [_make_cmd(ctx)] + generation_commands(
++ synth_logs + synth_reports,
++ ) + json_fallback
++ ctx.actions.run_shell(
++ arguments = [
++ "--file",
++ ctx.file._makefile.path,
++ "do-1_synth",
++ ],
++ command = EXPAND_VERILOG_DIRS + " && ".join(commands),
++ env = config_overrides(
++ ctx,
++ verilog_arguments(ctx.files.verilog_files) |
++ flow_environment(ctx) |
++ config_environment(config),
++ ),
++ inputs = depset(
++ [config] + ctx.files.verilog_files + ctx.files.extra_configs,
++ transitive = [
++ data_inputs(ctx),
++ pdk_inputs(ctx),
++ deps_inputs(ctx),
++ ],
++ ),
++ outputs = synth_outputs.values() + synth_logs + synth_jsons + synth_reports,
++ tools = flow_inputs(ctx),
++ progress_message = "OpenROAD-SYN synthesis for %s" % module_top(ctx),
++ )
+ elif num_partitions > 0:
+- _yosys_parallel_synth(ctx, config, canon_output, synth_outputs, synth_logs, synth_reports, num_partitions, save_odb, all_arguments)
++ _yosys_parallel_synth(ctx, config, canon_output, synth_outputs, synth_logs, synth_jsons, synth_reports, num_partitions, save_odb, all_arguments)
+ else:
+ # SYNTH_NETLIST_FILES will not create an .rtlil file or reports, so we need
+ # an empty placeholder in that case.
+ commands = [_make_cmd(ctx)] + generation_commands(
+ synth_logs + synth_outputs.values() + synth_reports,
+- )
++ ) + json_fallback
+ ctx.actions.run_shell(
+ arguments = [
+ "--file",
+@@ -1242,7 +1328,7 @@ def _yosys_impl(ctx):
+ deps_inputs(ctx),
+ ],
+ ),
+- outputs = synth_outputs.values() + synth_logs + synth_reports,
++ outputs = synth_outputs.values() + synth_logs + synth_jsons + synth_reports,
+ tools = depset(transitive = [yosys_inputs(ctx), flow_inputs(ctx)]),
+ )
+
+@@ -1324,7 +1410,7 @@ def _yosys_impl(ctx):
+ exe,
+ config = config_short,
+ make = make,
+- genfiles = [config_short] + outputs + canon_logs + synth_logs,
++ genfiles = [config_short] + outputs + canon_logs + synth_logs + synth_jsons,
+ )
+
+ # Collect all files needed for deployment (tools, PDK, stage inputs).
+@@ -1364,7 +1450,7 @@ def _yosys_impl(ctx):
+ name = ctx.attr.name + "_deps",
+ )
+
+- _runfiles_files = [config_short, make] + outputs + canon_logs + synth_logs + ctx.files.extra_configs
++ _runfiles_files = [config_short, make] + outputs + canon_logs + synth_logs + synth_jsons + ctx.files.extra_configs
+ _runfiles_common = depset(
+ transitive = [
+ deps_inputs(ctx),
+@@ -1444,7 +1530,7 @@ def _yosys_impl(ctx):
+ logs = depset(canon_logs + synth_logs),
+ reports = depset(synth_reports),
+ drcs = depset([]),
+- jsons = depset([]),
++ jsons = depset(synth_jsons),
+ ),
+ ]
+
diff --git a/patches/bazel-orfs/0002-flow-emit-metadata-test-update-chain-for-synthesis-o.patch b/patches/bazel-orfs/0002-flow-emit-metadata-test-update-chain-for-synthesis-o.patch
new file mode 100644
index 0000000000..3200ce00b8
--- /dev/null
+++ b/patches/bazel-orfs/0002-flow-emit-metadata-test-update-chain-for-synthesis-o.patch
@@ -0,0 +1,75 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?=C3=98yvind=20Harboe?=
+Date: Thu, 16 Jul 2026 16:16:38 +0200
+Subject: [PATCH 2/5] flow: emit metadata/test/update chain for synthesis-only
+ flows
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+When last_stage="synth", orfs_flow() now emits the same
+generate_metadata -> test -> update_rules -> update target chain as the
+full flow, fed directly from the synth stage and gated on RULES_JSON.
+With FLOW_VARIANT-scoped rules (rules-.json) this enables
+synthesis-only QoR gates such as rules-syn.json for the OpenROAD-SYN
+status suite.
+
+Co-Authored-By: Claude Fable 5
+Signed-off-by: Øyvind Harboe
+---
+ private/flow.bzl | 45 +++++++++++++++++++++++++++++++++++++++++++++
+ 1 file changed, 45 insertions(+)
+
+diff --git a/private/flow.bzl b/private/flow.bzl
+index aa8f3e3..0cedbe0 100644
+--- a/private/flow.bzl
++++ b/private/flow.bzl
+@@ -829,3 +829,48 @@ def _orfs_pass(
+ logs = [rules_name],
+ **_strip_tool_kwargs(**update_kwargs)
+ )
++ elif steps[-1].stage == "synth" and start_stage <= 1 and not mock_area:
++ # Synthesis-only flow (last_stage = "synth"): emit the same
++ # metadata → test → update_rules → update chain as the full flow,
++ # fed straight from the synth stage. genMetrics.py tolerates a
++ # synthesis-only tree (no 2_floorplan.sdc, no stage 2..6 logs).
++ # Gated on RULES_JSON like the full-flow test: without rules
++ # there is nothing for metadata-check to check.
++ test_args = get_stage_args(
++ TEST_STAGE_IMPL.stage,
++ stage_arguments,
++ arguments,
++ sources,
++ )
++ if "RULES_JSON" in test_args:
++ do_step(
++ GENERATE_METADATA_STAGE_IMPL,
++ steps[-1],
++ data = [
++ # Stage synth's logs (1_synth.log, 1_synth.json) via
++ # data_runfiles; source_inputs deliberately omits
++ # LoggingInfo.logs, and genMetrics.py reads both.
++ _step_name(name, variant, "synth"),
++ ],
++ kwargs = kwargs,
++ )
++ do_step(
++ TEST_STAGE_IMPL,
++ GENERATE_METADATA_STAGE_IMPL,
++ kwargs = kwargs | {"tags": []} | test_kwargs,
++ )
++ rules_name = do_step(
++ UPDATE_RULES_IMPL,
++ GENERATE_METADATA_STAGE_IMPL,
++ kwargs = kwargs,
++ more_kwargs = kwargs,
++ )
++ update_kwargs = dict(kwargs)
++ update_kwargs.pop("substeps", None)
++ update_kwargs.pop("lint", None)
++ orfs_update(
++ name = _step_name(name, variant, "update"),
++ rules_json = sources["RULES_JSON"][0],
++ logs = [rules_name],
++ **_strip_tool_kwargs(**update_kwargs)
++ )
diff --git a/patches/bazel-orfs/0003-update-copy-only-rules.json-in-the-orfs_update-scrip.patch b/patches/bazel-orfs/0003-update-copy-only-rules.json-in-the-orfs_update-scrip.patch
new file mode 100644
index 0000000000..03516d06a0
--- /dev/null
+++ b/patches/bazel-orfs/0003-update-copy-only-rules.json-in-the-orfs_update-scrip.patch
@@ -0,0 +1,38 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?=C3=98yvind=20Harboe?=
+Date: Thu, 16 Jul 2026 16:35:57 +0200
+Subject: [PATCH 3/5] update: copy only rules.json in the orfs_update script
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+The update_rules stage's DefaultInfo carries update_rules.args.mk next
+to rules.json, so the generated cp had two sources and a file target:
+'cp: target ...rules-base.json: Not a directory'. Filter to rules.json.
+
+Co-Authored-By: Claude Fable 5
+Signed-off-by: Øyvind Harboe
+---
+ private/flow.bzl | 9 ++++++++-
+ 1 file changed, 8 insertions(+), 1 deletion(-)
+
+diff --git a/private/flow.bzl b/private/flow.bzl
+index 0cedbe0..c071af8 100644
+--- a/private/flow.bzl
++++ b/private/flow.bzl
+@@ -406,7 +406,14 @@ logs="{logs}"
+ cp $logs $BUILD_WORKSPACE_DIRECTORY/$rules_json
+ """.format(
+ rules_json = ctx.file.rules_json.path,
+- logs = " ".join([log.short_path for log in ctx.files.logs]),
++ # The update_rules stage's DefaultInfo carries rules.json plus
++ # bookkeeping files (update_rules.args.mk); only rules.json may
++ # be copied — cp with several sources needs a directory target.
++ logs = " ".join([
++ log.short_path
++ for log in ctx.files.logs
++ if log.basename == "rules.json"
++ ]),
+ ),
+ )
+
diff --git a/patches/bazel-orfs/0004-design-caller-extension-hook-for-per-design-status-t.patch b/patches/bazel-orfs/0004-design-caller-extension-hook-for-per-design-status-t.patch
new file mode 100644
index 0000000000..65e1cc4950
--- /dev/null
+++ b/patches/bazel-orfs/0004-design-caller-extension-hook-for-per-design-status-t.patch
@@ -0,0 +1,73 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?=C3=98yvind=20Harboe?=
+Date: Thu, 16 Jul 2026 16:23:49 +0200
+Subject: [PATCH 4/5] design: caller extension hook for per-design status
+ targets
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+orfs_design(extra = ) invokes the callable with the fully
+processed design data (labels converted, local/user arguments routed) so
+consumers can attach additional per-design targets — e.g. ORFS's
+OpenROAD-SYN status flows (a forced-SYNTH_USE_SYN synthesis-only variant
+plus best-effort QoR reporting) — without orfs_design knowing their
+policy.
+
+Co-Authored-By: Claude Fable 5
+Signed-off-by: Øyvind Harboe
+---
+ private/orfs_design.bzl | 26 +++++++++++++++++++++++++-
+ 1 file changed, 25 insertions(+), 1 deletion(-)
+
+diff --git a/private/orfs_design.bzl b/private/orfs_design.bzl
+index c663be0..e85b80b 100644
+--- a/private/orfs_design.bzl
++++ b/private/orfs_design.bzl
+@@ -51,7 +51,7 @@ def _convert_sources(sources, pkg):
+ result[var] = converted
+ return result
+
+-def orfs_design(name = None, config = "config.mk", platform = None, design = None, designs = None, mock_openroad = None, mock_yosys = None, user_arguments = [], user_sources = [], local_arguments = [], blender = False): # buildifier: disable=unused-variable
++def orfs_design(name = None, config = "config.mk", platform = None, design = None, designs = None, mock_openroad = None, mock_yosys = None, user_arguments = [], user_sources = [], local_arguments = [], blender = False, extra = None): # buildifier: disable=unused-variable
+ """Create orfs_flow() targets for a design based on its parsed config.mk.
+
+ Usage:
+@@ -101,6 +101,12 @@ def orfs_design(name = None, config = "config.mk", platform = None, design = Non
+ have no BlenderGDS stackup (see blender_supports_pdk in
+ private/blender.bzl), so callers can flip this on globally
+ without having to enumerate supported PDKs.
++ extra: optional callable invoked after the real flow with the
++ fully-processed design data (name, platform, verilog_files,
++ arguments, user_arguments, sources, user_sources, macros,
++ stage_data, tags) so callers can attach additional
++ per-design targets (extra flow variants, reporting targets)
++ without orfs_design knowing their policy.
+ """
+ if designs == None:
+ fail("orfs_design() requires designs: load orfs_design from @orfs_designs//:designs.bzl")
+@@ -245,6 +251,24 @@ def orfs_design(name = None, config = "config.mk", platform = None, design = Non
+ blender = blender and blender_supports_pdk("//flow:" + platform),
+ )
+
++ # Caller extension hook: invoked with the fully-processed design data
++ # (labels converted, local/user arguments routed) so consumers can
++ # attach additional per-design targets — e.g. ORFS's OpenROAD-SYN
++ # status flows — without teaching orfs_design about their policy.
++ if extra:
++ extra(
++ name = name,
++ platform = platform,
++ verilog_files = verilog_files,
++ arguments = arguments,
++ user_arguments = user_args,
++ sources = sources,
++ user_sources = user_srcs,
++ macros = macros if macros else [],
++ stage_data = {"synth": extra_data} if extra_data else {},
++ tags = tags,
++ )
++
+ # Lint flow — fast validation with mock-openroad (only if configured)
+ if mock_openroad:
+ lint_kwargs = dict(
diff --git a/patches/bazel-orfs/0005-flow-synthesis-only-QoR-checks-the-synth-subset-of-t.patch b/patches/bazel-orfs/0005-flow-synthesis-only-QoR-checks-the-synth-subset-of-t.patch
new file mode 100644
index 0000000000..97650273d7
--- /dev/null
+++ b/patches/bazel-orfs/0005-flow-synthesis-only-QoR-checks-the-synth-subset-of-t.patch
@@ -0,0 +1,77 @@
+From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?=C3=98yvind=20Harboe?=
+Date: Thu, 16 Jul 2026 19:38:13 +0200
+Subject: [PATCH 5/5] flow: synthesis-only QoR checks the synth subset of the
+ full-flow rules
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+The synthesis-only chain's test now runs make metadata-check-synth so a
+design's regular rules file (e.g. rules-base.json) can gate the synth
+variant directly — only the synthesis-stage subset of the rules is
+checked, and no per-variant rules file needs to be committed. The
+_update target is no longer emitted for synthesis-only flows: it
+would overwrite the design's full-flow rules with synthesis-only rules.
+The update_rules stage target remains as the generated synthesis QoR
+snapshot for aggregating consumers.
+
+Co-Authored-By: Claude Fable 5
+Signed-off-by: Øyvind Harboe
+---
+ private/flow.bzl | 32 ++++++++++++++++----------------
+ 1 file changed, 16 insertions(+), 16 deletions(-)
+
+diff --git a/private/flow.bzl b/private/flow.bzl
+index c071af8..41cc7ed 100644
+--- a/private/flow.bzl
++++ b/private/flow.bzl
+@@ -837,12 +837,20 @@ def _orfs_pass(
+ **_strip_tool_kwargs(**update_kwargs)
+ )
+ elif steps[-1].stage == "synth" and start_stage <= 1 and not mock_area:
+- # Synthesis-only flow (last_stage = "synth"): emit the same
+- # metadata → test → update_rules → update chain as the full flow,
+- # fed straight from the synth stage. genMetrics.py tolerates a
+- # synthesis-only tree (no 2_floorplan.sdc, no stage 2..6 logs).
+- # Gated on RULES_JSON like the full-flow test: without rules
+- # there is nothing for metadata-check to check.
++ # Synthesis-only flow (last_stage = "synth"): emit a metadata →
++ # test → update_rules chain fed straight from the synth stage.
++ # genMetrics.py tolerates a synthesis-only tree (no
++ # 2_floorplan.sdc, no stage 2..6 logs). Gated on RULES_JSON like
++ # the full-flow test; the RULES_JSON is typically the design's
++ # full-flow rules file, so the test runs make metadata-check-synth,
++ # which checks only the synthesis-stage subset of the rules.
++ #
++ # Unlike the full flow there is deliberately no _update
++ # target: it would overwrite the design's full-flow rules file
++ # with synthesis-only rules. The update_rules stage target still
++ # exists — its generated rules.json is the synthesis QoR snapshot
++ # consumers can aggregate (e.g. plots) without checking rules
++ # files into git.
+ test_args = get_stage_args(
+ TEST_STAGE_IMPL.stage,
+ stage_arguments,
+@@ -865,19 +873,11 @@ def _orfs_pass(
+ TEST_STAGE_IMPL,
+ GENERATE_METADATA_STAGE_IMPL,
+ kwargs = kwargs | {"tags": []} | test_kwargs,
++ more_kwargs = {"cmd": "metadata-check-synth"},
+ )
+- rules_name = do_step(
++ do_step(
+ UPDATE_RULES_IMPL,
+ GENERATE_METADATA_STAGE_IMPL,
+ kwargs = kwargs,
+ more_kwargs = kwargs,
+ )
+- update_kwargs = dict(kwargs)
+- update_kwargs.pop("substeps", None)
+- update_kwargs.pop("lint", None)
+- orfs_update(
+- name = _step_name(name, variant, "update"),
+- rules_json = sources["RULES_JSON"][0],
+- logs = [rules_name],
+- **_strip_tool_kwargs(**update_kwargs)
+- )
diff --git a/patches/bazel-orfs/BUILD.bazel b/patches/bazel-orfs/BUILD.bazel
new file mode 100644
index 0000000000..ae485d0fa4
--- /dev/null
+++ b/patches/bazel-orfs/BUILD.bazel
@@ -0,0 +1,9 @@
+"""OpenROAD-SYN support carried as git_override patches on bazel-orfs.
+
+Applied on top of the pinned BAZEL_ORFS_COMMIT (see MODULE.bazel). To be
+upstreamed to The-OpenROAD-Project/bazel-orfs once the OpenROAD-SYN
+status-suite churn settles; regenerate with git format-patch from a
+branch based on the pinned commit.
+"""
+
+exports_files(glob(["*.patch"]))