diff --git a/MODULE.bazel b/MODULE.bazel index f828679d3f..bd817bce80 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -131,6 +131,17 @@ git_override( remote = "https://github.com/povik/yosys-slang.git", ) +bazel_dep(name = "fakeram", dev_dependency = True) +git_override( + module_name = "fakeram", + commit = "6f62c655952b683cda679d80850cb557cc3c262e", + patch_strip = 1, + patches = [ + "//patches/fakeram:0001-Add-ORFS-ASAP7-backend.patch", + ], + remote = "https://github.com/The-OpenROAD-Project/FakeRAM2.0.git", +) + # --- Extensions --- register_toolchains( diff --git a/docs/toc.yml b/docs/toc.yml index 722c933ca5..be479807e0 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -19,6 +19,8 @@ entries: title: Build Using WSL - file: user/FlowVariables.md title: Flow Variables + - file: user/AutoMemories.md + title: Generated Memory Macros (AUTO_MEMORIES) - file: contrib/Metrics.md title: Metrics - file: user/InstructionsForAutoTuner.md diff --git a/docs/user/AutoMemories.md b/docs/user/AutoMemories.md new file mode 100644 index 0000000000..eddde0e660 --- /dev/null +++ b/docs/user/AutoMemories.md @@ -0,0 +1,164 @@ +# AUTO_MEMORIES: generated memory macros + +**Experimental.** AUTO_MEMORIES detects memories in a design's RTL +before synthesis and generates abstract `.lib`/`.lef` macro views for +them, so an existing design gets macro-based synthesis and physical +design results — including RTL-MP macro placement — without a memory +compiler or hand-maintained fakeram files. + +The intended audience is flows built on top of ORFS (for example +bazel-orfs based in-house flows) that need early, reasonable physical +results on designs whose memories exist only as behavioral RTL. Support +posture: if it works for you, great; if it needs fixing, the report is +itself a welcome signal that someone is using it. + +## What it does + +With `AUTO_MEMORIES=1`, a pre-synthesis step runs +`scripts/memories/gen_memories.py` over `VERILOG_FILES` and writes: + +| File | Content | +| --- | --- | +| `$(RESULTS_DIR)/memories.json` | Inventory of every detected memory: geometry, full pin list with pin functions, behavioral model, and whether it was converted (`idiomatic`) with a reason. | +| `$(RESULTS_DIR)/memories/.lib` | Generated Liberty view per converted memory. | +| `$(RESULTS_DIR)/memories/_pre_layout.lib` | Ideal-clock (zero clock-tree insertion) variant for pre-CTS consumers that select lib files themselves. The Makefile flow uses `.lib` throughout. | +| `$(RESULTS_DIR)/memories/.lef` | Abstract LEF per converted memory. | +| `$(RESULTS_DIR)/memories/blackboxes.txt` | Names of the converted modules — what synthesis blackboxes. | + +Synthesis (canonicalization) blackboxes the converted modules so the +liberty view wins over their behavioral bodies; floorplan through final +read the generated `.lib`/`.lef` alongside `ADDITIONAL_LIBS`/ +`ADDITIONAL_LEFS`. Everything downstream keys off the files above — +nothing else passes between the generator and the flow. That file-based +handoff is what lets build systems (e.g. bazel-orfs) declare the +generated artifacts as ordinary stage outputs and transitive +dependencies of the remaining stages. + +Memories that are *not* converted stay in the netlist and synthesize to +flip-flops, like any other RTL. + +## How memories are detected + +Detection is a fast Python scan (`scripts/memories/detect.py`) for +modules whose entire port list follows the firtool (CIRCT) memory port +convention: every port named `_`, e.g. `R0_addr`, +`W0_en`, `RW0_wdata`, including the subword-split forms `RW0_wdata_3` / +`W0_mask_2`. This is what Chisel/firtool emits for module-separated +memories, and what the rocket-chip generation of Chisel emitted (the +in-tree tinyRocket design). + +Two consequences, documented as deliberate scope: + +- **Module boundary only.** A memory embedded inside a larger module + (a bare `reg [7:0] mem [0:255]` next to other logic) is not detected. + Yosys's memory-inference pass sees those; FPGA tools extract them + into block RAMs. Wiring yosys up as the detector — or growing such a + pass in OpenROAD SYN, which currently has no memory inference and + therefore cannot be leaned on here either — is future work; this + feature punts on it with the simple scanner. +- **No banking.** Each detected memory maps to exactly one macro. A + memory too wide, too deep, or too ported for a single sensible macro + is not decomposed across several macros — a future extension. + +## The idiomatic gate + +Not every detected memory should be a macro. `scripts/memories/ +idiomatic.py` applies simple floors (minimum depth 16, minimum capacity +256 bits, at most 4 ports); memories below them are cheaper as +flip-flops than as a macro paying the fixed control/decode/sense-amp +floor. Rejected memories are kept in `memories.json` with +`"idiomatic": false` and a reason. + +To overrule the gate, list a `.memories` file in `ADDITIONAL_MEMORIES`: + +```json +{ + "version": 1, + "memories": [ + { + "name": "tag_array", + "idiomatic": true, + "reason": "forced: the RTL provides no behavioral fallback" + } + ] +} +``` + +Entries merge by name onto the detected set: fields the override +carries win, everything else (geometry, pins) is kept from detection. A +`.memories` entry naming a module the scanner never found is taken +whole — it must then describe its pins itself. The +`designs/asap7/tinyRocket` design demonstrates the forced-conversion +case: its `tag_array` wrapper is 4 entries deep (rejected by the gate) +but instantiates a module the sources never define, so flops are not an +option and the design forces conversion. + +## Generated views + +The `.lib` mirrors the structural shape OpenROAD's abstract writer +produces for hardened blocks: `bus()` groups **with per-bit `pin()` +records** (a bus without per-bit siblings makes yosys silently drop bit +connections at parent instances), per-port clock pins with +`min/max_clock_tree_path` arcs, setup/hold constraints on inputs, +clock-to-out arcs on outputs, and `internal_power()` records under a +`power_lut_template` so SAIF-driven power reporting is non-zero. + +The `.lef` is an abstract following the conventions of the platform's +fakeram abstracts: `CLASS BLOCK`, per-bit signal pin pads stacked along +the macro edge, interleaved horizontal power/ground straps the +platform's PDN macro grid connects to, and a full-footprint multi-layer +`OBS`. + +Timing and area come from simple parametric models +(`scripts/memories/liberty.py`, `scripts/memories/sram_area_model.py`): +log2(rows) decode depth and √bits bit-line scaling for timing; an area +model anchored to published 7 nm SP-SRAM figures (Suzuki et al., ISSCC +2018). These are budgetary models — good enough to make floorplanning, +placement, and timing behave representatively; not sign-off numbers. + +## Platform support + +**asap7 only.** The emitters are split into general structure +(`liberty.py`, `lef.py`, parameterized by a `PdkParams`) and platform +constants (`pdk_asap7.py`: pins and power straps on M4 — where the +platform's PDN macro grid connects — pin pad/pitch, strap geometry, +OBS layers, nominal voltage). Generalizing to other PDKs means +providing their `PdkParams` — the code seam exists, the calibration +work does not. `AUTO_MEMORIES=1` on any other platform fails with a +clear error. + +## Trying it + +```shell +# Unit tests (fast, no EDA tools): +bazelisk test //flow:memories_tests + +# The demo design: +make DESIGN_CONFIG=designs/asap7/tinyRocket/config.mk synth floorplan +``` + +The generator can also be run standalone to inspect what it would do: + +```shell +python3 flow/scripts/memories/gen_memories.py \ + --platform asap7 --out-dir /tmp/mems --json /tmp/memories.json \ + --verilog flow/designs/src/tinyRocket/freechips.rocketchip.system.TinyConfig.v +``` + +## Consuming from bazel-orfs + +Everything downstream keys off generated files, so a build system can +declare them as ordinary stage outputs and transitive dependencies. In +bazel-orfs each stage runs in a sandbox where only declared outputs +survive, so it additionally needs to declare `memories.json` plus the +`memories/` directory (a directory artifact — the per-memory file +names are only known at run time) as canonicalize outputs and stage +them into every downstream stage's sandbox. The bazel-orfs change that +does this is carried alongside this feature as +`flow/scripts/memories/bazel-orfs-auto-memories.patch`, to be +upstreamed to bazel-orfs once the feature lands here. + +## Variables + +- [AUTO_MEMORIES](FlowVariables.md#AUTO_MEMORIES) +- [ADDITIONAL_MEMORIES](FlowVariables.md#ADDITIONAL_MEMORIES) diff --git a/docs/user/FlowVariables.md b/docs/user/FlowVariables.md index 656004c3a6..0a28c48aeb 100644 --- a/docs/user/FlowVariables.md +++ b/docs/user/FlowVariables.md @@ -100,8 +100,10 @@ configuration file. | ADDITIONAL_GDS| Hardened macro GDS files listed here.| | | ADDITIONAL_LEFS| Hardened macro LEF view files listed here. The LEF information of the macros is immutable and used throughout all stages. Stored in the .odb file.| | | ADDITIONAL_LIBS| Hardened macro library files listed here. The library information is immutable and used throughout all stages. Not stored in the .odb file.| | +| ADDITIONAL_MEMORIES| Experimental. Space-separated list of user-supplied `.memories` files (the memories.json JSON schema) merged onto the set AUTO_MEMORIES detects — to force-convert a memory the idiomatic gate rejects, or to describe one the scanner cannot find. See docs/user/AutoMemories.md.| | | ADDITIONAL_SITES| Passed as -additional_sites to initialize_floorplan.| | | ASAP7_USE_VT| A space separated list of VT options to use with the ASAP7 standard cell library: RVT, LVT, SLVT.| RVT| +| AUTO_MEMORIES| Experimental. When set to 1, memory-shaped modules in the RTL (firtool/CIRCT `R*/W*/RW*_` port convention, module boundary only) are detected pre-synthesis, inventoried in `$(RESULTS_DIR)/memories.json`, and — when idiomatic as an SRAM macro — given generated .lib/.lef views under `$(RESULTS_DIR)/memories/`. Synthesis blackboxes those modules and all later stages read the generated views, so an existing design gets macro-based results without a memory compiler. Supported for the asap7 platform only. See docs/user/AutoMemories.md.| 0| | BALANCE_ROWS| Balance rows during placement.| 0| | BLOCKS| Blocks used as hard macros in a hierarchical flow. Do note that you have to specify block-specific inputs file in the directory mentioned by Makefile.| | | BUFFER_PORTS_ARGS| Specify arguments to the buffer_ports call during placement. Only used if DONT_BUFFER_PORTS=0.| | @@ -339,6 +341,7 @@ configuration file. - [ABC_DRIVER_CELL](#ABC_DRIVER_CELL) - [ABC_LOAD_IN_FF](#ABC_LOAD_IN_FF) - [ADDER_MAP_FILE](#ADDER_MAP_FILE) +- [ADDITIONAL_MEMORIES](#ADDITIONAL_MEMORIES) - [CACHED_REPORTS](#CACHED_REPORTS) - [CLKGATE_MAP_FILE](#CLKGATE_MAP_FILE) - [DFF_LIB_FILE](#DFF_LIB_FILE) @@ -625,6 +628,7 @@ configuration file. - [ADDITIONAL_FILES](#ADDITIONAL_FILES) - [ADDITIONAL_LEFS](#ADDITIONAL_LEFS) - [ADDITIONAL_LIBS](#ADDITIONAL_LIBS) +- [AUTO_MEMORIES](#AUTO_MEMORIES) - [BLOCKS](#BLOCKS) - [CAP_MARGIN](#CAP_MARGIN) - [CDL_FILES](#CDL_FILES) diff --git a/flow/BUILD b/flow/BUILD index c3f0599758..4f57af10d8 100644 --- a/flow/BUILD +++ b/flow/BUILD @@ -1,4 +1,5 @@ load("@bazel-orfs//:openroad.bzl", "orfs_pdk") +load("@rules_python//python:defs.bzl", "py_library", "py_test") # Expose every individual file under platforms/ as a public source-file # target, so designs in other packages can refer to e.g. @@ -31,23 +32,31 @@ exports_files( MAKEFILE_SHARED = [ "scripts/variables.json", "scripts/*.py", + "scripts/memories/*.py", "scripts/*.sh", "scripts/*.yaml", "scripts/*.mk", ] +MAKEFILE_SHARED_EXCLUDE = [ + "scripts/memories/*_test.py", +] + # makefile and scripts used by script/synth.sh steps filegroup( name = "makefile_yosys", srcs = ["Makefile"], - data = glob(MAKEFILE_SHARED + [ - "scripts/*.script", - "scripts/*.v", - "scripts/util.tcl", - "scripts/synth*.tcl", - "scripts/synth*.v", - "platforms/common/**/*.v", - ]) + [ + data = glob( + MAKEFILE_SHARED + [ + "scripts/*.script", + "scripts/*.v", + "scripts/util.tcl", + "scripts/synth*.tcl", + "scripts/synth*.v", + "platforms/common/**/*.v", + ], + exclude = MAKEFILE_SHARED_EXCLUDE, + ) + [ "//flow/util:makefile_yosys", ], visibility = ["//visibility:public"], @@ -57,10 +66,13 @@ filegroup( filegroup( name = "makefile", srcs = ["Makefile"], - data = glob(MAKEFILE_SHARED + [ - "scripts/*.tcl", - "platforms/common/**/*.v", - ]) + [ + data = glob( + MAKEFILE_SHARED + [ + "scripts/*.tcl", + "platforms/common/**/*.v", + ], + exclude = MAKEFILE_SHARED_EXCLUDE, + ) + [ "//flow/util:makefile", ], visibility = ["//visibility:public"], @@ -173,3 +185,47 @@ filegroup( "sky130hd", "sky130hs", ]] + +# AUTO_MEMORIES: pre-synthesis memory detection and .lib/.lef macro view +# generation. See docs/user/AutoMemories.md. The unit tests are the +# feature's test coverage — run with `bazelisk test //flow:memories_tests`. +py_library( + name = "memories", + srcs = glob( + ["scripts/memories/*.py"], + exclude = ["scripts/memories/*_test.py"], + ), + imports = ["scripts/memories"], + data = ["@fakeram//:all_files"], +) + +# Test fixtures (Verilog snippets) live in detect_test.py and are +# imported by the other tests. +py_library( + name = "memories_test_fixtures", + srcs = ["scripts/memories/detect_test.py"], + imports = ["scripts/memories"], + deps = [":memories"], +) + +MEMORIES_TESTS = [ + "detect_test", + "gen_memories_test", + "idiomatic_test", + "schema_test", +] + +[py_test( + name = "memories_" + test, + srcs = ["scripts/memories/{}.py".format(test)], + main = "scripts/memories/{}.py".format(test), + deps = [ + ":memories", + ":memories_test_fixtures", + ], +) for test in MEMORIES_TESTS] + +test_suite( + name = "memories_tests", + tests = ["memories_" + test for test in MEMORIES_TESTS], +) diff --git a/flow/Makefile b/flow/Makefile index d3472ee015..517dcee2c5 100644 --- a/flow/Makefile +++ b/flow/Makefile @@ -261,6 +261,28 @@ $(SDC_FILE_CLOCK_PERIOD): $(SDC_FILE) .PHONY: yosys-dependencies yosys-dependencies: $(YOSYS_DEPENDENCIES) +# AUTO_MEMORIES: detect memories in the RTL pre-synthesis and generate +# .lib/.lef macro views for them. Experimental; see +# docs/user/AutoMemories.md. +ifeq ($(AUTO_MEMORIES),1) +.PHONY: do-auto-memories +do-auto-memories: + mkdir -p $(RESULTS_DIR) + $(PYTHON_EXE) $(SCRIPTS_DIR)/memories/gen_memories.py \ + --platform $(PLATFORM) \ + --out-dir $(RESULTS_DIR)/memories \ + --json $(RESULTS_DIR)/memories.json \ + $(foreach file,$(VERILOG_FILES),--verilog $(file)) \ + $(foreach file,$(ADDITIONAL_MEMORIES),--memories $(file)) + +$(RESULTS_DIR)/memories.json: $(VERILOG_FILES) $(ADDITIONAL_MEMORIES) \ + $(wildcard $(SCRIPTS_DIR)/memories/*.py) + $(UNSET_AND_MAKE) do-auto-memories + +yosys-dependencies: $(RESULTS_DIR)/memories.json +$(RESULTS_DIR)/1_1_yosys_canonicalize.rtlil: $(RESULTS_DIR)/memories.json +endif + .PHONY: do-yosys do-yosys: yosys-dependencies $(SCRIPTS_DIR)/synth.sh $(SYNTH_SCRIPT) $(LOG_DIR)/1_2_yosys.log @@ -278,6 +300,7 @@ $(RESULTS_DIR)/1_2_yosys.v $(RESULTS_DIR)/1_2_yosys.sdc: $(RESULTS_DIR)/1_1_yosy .PHONY: clean_synth clean_synth: rm -f $(RESULTS_DIR)/1_* $(RESULTS_DIR)/mem*.json + rm -rf $(RESULTS_DIR)/memories rm -f $(REPORTS_DIR)/synth_* rm -f $(LOG_DIR)/1_* rm -f $(SYNTH_STATS) diff --git a/flow/designs/asap7/tinyRocket/BUILD b/flow/designs/asap7/tinyRocket/BUILD new file mode 100644 index 0000000000..527d6542e1 --- /dev/null +++ b/flow/designs/asap7/tinyRocket/BUILD @@ -0,0 +1,3 @@ +load("//flow/designs:design.bzl", "design") + +design(config = "config.mk") diff --git a/flow/designs/asap7/tinyRocket/config.mk b/flow/designs/asap7/tinyRocket/config.mk new file mode 100644 index 0000000000..1d2ac6962a --- /dev/null +++ b/flow/designs/asap7/tinyRocket/config.mk @@ -0,0 +1,30 @@ +export DESIGN_NICKNAME = tinyRocket +export DESIGN_NAME = RocketTile +export PLATFORM = asap7 + +# The generic tinyRocket sources only: unlike the nangate45 tinyRocket +# there is no hand-written platform memory-mapping file and no +# checked-in fakeram .lib/.lef — AUTO_MEMORIES detects the memory +# wrapper modules and generates macro views for them instead. +export VERILOG_FILES = $(DESIGN_HOME)/src/$(DESIGN_NICKNAME)/AsyncResetReg.v \ + $(DESIGN_HOME)/src/$(DESIGN_NICKNAME)/ClockDivider2.v \ + $(DESIGN_HOME)/src/$(DESIGN_NICKNAME)/ClockDivider3.v \ + $(DESIGN_HOME)/src/$(DESIGN_NICKNAME)/plusarg_reader.v \ + $(DESIGN_HOME)/src/$(DESIGN_NICKNAME)/freechips.rocketchip.system.TinyConfig.v + +export SDC_FILE = $(DESIGN_HOME)/$(PLATFORM)/$(DESIGN_NICKNAME)/constraint.sdc + +export AUTO_MEMORIES = 1 +# tag_array (4x25) falls below the idiomatic-macro floors, but this RTL +# provides no behavioral fallback (the wrapper instantiates a +# tag_array_ext module the sources never define), so its conversion is +# forced via a .memories override. +export ADDITIONAL_MEMORIES = $(DESIGN_HOME)/$(PLATFORM)/$(DESIGN_NICKNAME)/tag_array.memories + +export CORE_UTILIZATION = 40 +export CORE_MARGIN = 2 + +export PLACE_DENSITY_LB_ADDON = 0.10 +export MACRO_PLACE_HALO = 2 2 + +export TNS_END_PERCENT = 100 diff --git a/flow/designs/asap7/tinyRocket/constraint.sdc b/flow/designs/asap7/tinyRocket/constraint.sdc new file mode 100644 index 0000000000..9eb2521bb4 --- /dev/null +++ b/flow/designs/asap7/tinyRocket/constraint.sdc @@ -0,0 +1,3 @@ +current_design RocketTile + +create_clock -name core_clock -period 1600 [get_ports {clock}] diff --git a/flow/designs/asap7/tinyRocket/tag_array.memories b/flow/designs/asap7/tinyRocket/tag_array.memories new file mode 100644 index 0000000000..fc03b36d73 --- /dev/null +++ b/flow/designs/asap7/tinyRocket/tag_array.memories @@ -0,0 +1,10 @@ +{ + "version": 1, + "memories": [ + { + "name": "tag_array", + "idiomatic": true, + "reason": "forced: the RTL provides no behavioral fallback for this wrapper" + } + ] +} diff --git a/flow/designs/design.bzl b/flow/designs/design.bzl index b4efc0b1d0..44e4afb7ef 100644 --- a/flow/designs/design.bzl +++ b/flow/designs/design.bzl @@ -17,7 +17,7 @@ _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"] +_EXPORTED_EXTS = ["v", "sv", "svh", "tcl", "sdc", "def", "cfg", "lef", "lib", "gds", "gds.gz", "memories"] _EXPORTS_SENTINEL = "_orfs_design_exports_sentinel" diff --git a/flow/scripts/load.tcl b/flow/scripts/load.tcl index 6d946198d9..1b3a6c4632 100644 --- a/flow/scripts/load.tcl +++ b/flow/scripts/load.tcl @@ -23,6 +23,13 @@ proc load_design { design_file sdc_file } { read_lef $lef } } + # AUTO_MEMORIES: abstract LEFs generated pre-synthesis; globbed + # because the file names are only known at run time. + if { [env_var_equals AUTO_MEMORIES 1] } { + foreach lef [glob -nocomplain $::env(RESULTS_DIR)/memories/*.lef] { + read_lef $lef + } + } read_verilog $::env(RESULTS_DIR)/$design_file log_cmd link_design {*}[hier_options] $::env(DESIGN_NAME) } elseif { $ext == ".odb" } { diff --git a/flow/scripts/memories/bazel-orfs-auto-memories.patch b/flow/scripts/memories/bazel-orfs-auto-memories.patch new file mode 100644 index 0000000000..408c274a9b --- /dev/null +++ b/flow/scripts/memories/bazel-orfs-auto-memories.patch @@ -0,0 +1,170 @@ +From 43f2d13a2669cc590d3af06c4e74647321f68095 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?=C3=98yvind=20Harboe?= +Date: Sun, 19 Jul 2026 18:36:34 +0200 +Subject: [PATCH] rules: declare and propagate AUTO_MEMORIES stage artifacts +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +ORFS's experimental AUTO_MEMORIES flow generates results/memories.json +plus a memories/ directory of generated .lib/.lef macro views during +canonicalization, and every later stage reads them from the results +dir at tcl run time. In bazel-orfs each stage runs in a sandbox where +only declared outputs survive, so without this change the generated +views are discarded after synthesis and floorplan fails to link the +blackboxed memory modules. + +Declare memories.json and the memories/ directory (a directory +artifact: the per-memory file names are only known at run time) as +canonicalize outputs when AUTO_MEMORIES=1, carry them on the new +OrfsInfo.memories field, and stage them into every downstream stage's +sandbox via source_inputs(). AUTO_MEMORIES is rejected with +SYNTH_USE_SYN, which has no yosys canonicalize step. + +User-supplied ADDITIONAL_MEMORIES files need no new machinery: they +reach the synth sandbox like any other sources= entry. + +Signed-off-by: Øyvind Harboe +--- + private/environment.bzl | 8 ++++++++ + private/providers.bzl | 1 + + private/rules.bzl | 29 ++++++++++++++++++++++++++--- + 3 files changed, 35 insertions(+), 3 deletions(-) + +diff --git a/private/environment.bzl b/private/environment.bzl +index f643501..f69359b 100644 +--- a/private/environment.bzl ++++ b/private/environment.bzl +@@ -229,6 +229,11 @@ def source_inputs(ctx): + ctx.attr.src[OrfsInfo].additional_lefs, + ctx.attr.src[OrfsInfo].additional_libs, + ctx.attr.src[OrfsInfo].additional_libs_pre_layout, ++ # AUTO_MEMORIES artifacts (memories.json + the generated ++ # .lib/.lef directory): later stages glob them from the ++ # results dir at tcl run time, so they must be staged in ++ # every downstream stage's sandbox. ++ ctx.attr.src[OrfsInfo].memories, + ctx.attr.src[PdkInfo].files, + ctx.attr.src[PdkInfo].libs, + # Accumulate all JSON reports, so depend on previous stage. +@@ -604,6 +609,9 @@ def _artifact_name(ctx, category, name = None): + def declare_artifact(ctx, category, name): + return ctx.actions.declare_file(_artifact_name(ctx, category, name)) + ++def declare_directory_artifact(ctx, category, name): ++ return ctx.actions.declare_directory(_artifact_name(ctx, category, name)) ++ + def declare_artifacts(ctx, category, names): + """Declares multiple artifacts in one call. + +diff --git a/private/providers.bzl b/private/providers.bzl +index e2eabb2..8c3e5bf 100644 +--- a/private/providers.bzl ++++ b/private/providers.bzl +@@ -17,6 +17,7 @@ OrfsInfo = provider( + "additional_libs", + "additional_libs_pre_layout", + "arguments", ++ "memories", + ], + ) + PdkInfo = provider( +diff --git a/private/rules.bzl b/private/rules.bzl +index 6f0e502..e8379ef 100644 +--- a/private/rules.bzl ++++ b/private/rules.bzl +@@ -21,6 +21,7 @@ load( + "data_inputs", + "declare_artifact", + "declare_artifacts", ++ "declare_directory_artifact", + "deps_inputs", + "environment_string", + "extensionless_basename", +@@ -262,6 +263,7 @@ def _macro_impl(ctx): + additional_lefs = depset([]), + additional_libs = depset([]), + additional_libs_pre_layout = depset([]), ++ memories = depset([]), + arguments = depset([]), + ), + TopInfo( +@@ -519,6 +521,7 @@ def _arguments_impl(ctx): + additional_lefs = src_info.additional_lefs, + additional_libs = src_info.additional_libs, + additional_libs_pre_layout = src_info.additional_libs_pre_layout, ++ memories = src_info.memories, + arguments = depset( + [computed_json], + transitive = [src_info.arguments], +@@ -1353,6 +1356,22 @@ def _yosys_impl(ctx): + # come through arguments/stage_arguments, not extra_arguments .json files. + use_syn = all_arguments.get("SYNTH_USE_SYN") == "1" + ++ # AUTO_MEMORIES: the canonicalize step generates memories.json plus a ++ # directory of .lib/.lef macro views (file names are only known at run ++ # time, hence a directory artifact). Declared as canonicalize outputs ++ # and threaded to every later stage via OrfsInfo.memories / ++ # source_inputs(), matching where the flow's tcl globs them. ++ auto_memories = all_arguments.get("AUTO_MEMORIES") == "1" ++ if auto_memories and use_syn: ++ fail("AUTO_MEMORIES is not supported with SYNTH_USE_SYN in " + ++ str(ctx.label)) ++ memories_outputs = [] ++ if auto_memories: ++ memories_outputs = [ ++ declare_artifact(ctx, "results", "memories.json"), ++ declare_directory_artifact(ctx, "results", "memories"), ++ ] ++ + canon_logs = declare_artifacts(ctx, "logs", ["1_1_yosys_canonicalize.log"]) + + canon_output = declare_artifact(ctx, "results", CANON_OUTPUT) +@@ -1393,7 +1412,7 @@ def _yosys_impl(ctx): + canon_deps, + ], + ), +- outputs = [canon_output] + canon_logs, ++ outputs = [canon_output] + canon_logs + memories_outputs, + tools = yosys_inputs(ctx), + progress_message = "Canonicalizing RTL for %s" % module_top(ctx), + ) +@@ -1513,7 +1532,8 @@ def _yosys_impl(ctx): + config_environment(config), + ), + inputs = depset( +- [canon_output, config] + ctx.files.extra_configs, ++ [canon_output, config] + memories_outputs + ++ ctx.files.extra_configs, + transitive = [ + data_inputs(ctx), + pdk_inputs(ctx), +@@ -1553,7 +1573,8 @@ def _yosys_impl(ctx): + tools = depset(transitive = [flow_inputs(ctx)]), + ) + +- outputs = [canon_output, variables] + synth_outputs.values() ++ outputs = [canon_output, variables] + synth_outputs.values() + \ ++ memories_outputs + + # Write synth's data_arguments to a JSON so downstream stages + # inherit synth-time variables (SDC_FILE, VERILOG_FILES, SYNTH_*) +@@ -1721,6 +1742,7 @@ def _yosys_impl(ctx): + if (dep[OrfsInfo].lib_pre_layout or dep[OrfsInfo].lib) + ], + ), ++ memories = depset(memories_outputs), + arguments = depset([synth_args_json]), + ), + ctx.attr.pdk[PdkInfo], +@@ -2098,6 +2120,7 @@ def _make_impl( + additional_lefs = ctx.attr.src[OrfsInfo].additional_lefs, + additional_libs = ctx.attr.src[OrfsInfo].additional_libs, + additional_libs_pre_layout = ctx.attr.src[OrfsInfo].additional_libs_pre_layout, ++ memories = ctx.attr.src[OrfsInfo].memories, + arguments = depset( + [stage_json], + transitive = [ctx.attr.src[OrfsInfo].arguments] + +-- +2.53.0 + diff --git a/flow/scripts/memories/detect.py b/flow/scripts/memories/detect.py new file mode 100644 index 0000000000..33b7ade300 --- /dev/null +++ b/flow/scripts/memories/detect.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Detect memory modules in Verilog sources by port-naming convention. + +Scans Verilog for modules whose entire port list follows the firtool +(CIRCT) memory port convention: every port is named +``_`` — e.g. ``R0_addr``, ``W0_en``, ``RW0_wdata``, +or the subword-split variants ``RW0_wdata_3`` / ``W0_mask_2``. Such +modules are the memory wrappers emitted by Chisel/firtool (and by other +generators that follow the same convention), and are the unit +AUTO_MEMORIES turns into macros. + +Yak-shave deliberately not done in this PR: yosys could be wired up as +the memory detector instead of this ad-hoc Python scanner — its memory +inference pass also finds memories that are embedded inside a larger +module rather than separated out into a module with a name of its own +(the case FPGA tools handle). Then again, perhaps OpenROAD SYN could +become such a memory detector — it currently has no memory inference +pass, which is also why this flow cannot simply lean on yosys inference. +Punt on all of that with a fast, simple Python scanner. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import schema + +_MODULE_HEADER_RE = re.compile(r"^\s*module\s+(\w+)\b", re.MULTILINE) +# Capture every port — including names that follow a continuation comma +# without their own `input`/`output` keyword (firtool emits `R0_clk` that +# way). Each `input|output` group is a region running to the next +# direction keyword or `);`, and the names inside are comma-separated. +_DIR_REGION_RE = re.compile( + r"\b(input|output|inout)\b\s*(\[[^\]]+\])?([^()]*?)" + r"(?=\b(?:input|output|inout)\b|\);)", + re.DOTALL, +) +_BUS_WIDTH_RE = re.compile(r"\[\s*(\d+)\s*:\s*(\d+)\s*\]") +_NAME_RE = re.compile(r"\b([A-Za-z_]\w*)\b") +_PORT_PIN_RE = re.compile(r"^(RW|R|W)(\d+)_([A-Za-z][A-Za-z0-9_]*)$") +_SUBWORD_RE = re.compile(r"^(.*?)(?:_(\d+))?$") + + +def _parse_module_ports(body: str) -> list[tuple[str, str, int]]: + """Return (name, direction, width) for each port of one module body.""" + ports: list[tuple[str, str, int]] = [] + open_paren = body.find("(") + if open_paren < 0: + return ports + close = body.find(");", open_paren) + if close < 0: + return ports + portlist = body[open_paren + 1 : close + 2] + for m in _DIR_REGION_RE.finditer(portlist): + direction = m.group(1) + bracket = m.group(2) + rest = m.group(3) + width = 1 + if bracket: + bm = _BUS_WIDTH_RE.search(bracket) + if bm: + width = abs(int(bm.group(1)) - int(bm.group(2))) + 1 + else: + # Parameterized or otherwise non-literal bus width: + # disqualify the module rather than silently modeling + # the port as 1 bit wide (it falls back to flops). + return [] + for chunk in rest.split(","): + nm = _NAME_RE.search(chunk) + if not nm: + continue + name = nm.group(1) + if name in ("input", "output", "inout"): + continue + ports.append((name, direction, width)) + return ports + + +def _classify_tail(kind: str, tail: str) -> str | None: + """Map a pin-name tail to a schema pin function, or None if unknown. + + Subword-split data and mask pins (`wdata_3`, `mask_0`) map to the + same function as their unsplit form; the split only affects how many + pins carry the function. + """ + if tail in ("addr", "en", "wmode", "clk"): + return tail + base = _SUBWORD_RE.match(tail).group(1) + if base in ("mask", "wmask"): + return "mask" + if base == "wdata": + return "data_in" + if base == "rdata": + return "data_out" + if base == "data": + if kind == "R": + return "data_out" + if kind == "W": + return "data_in" + return None # RW ports must use wdata/rdata + return None + + +def classify_module( + name: str, ports: list[tuple[str, str, int]], source_file: str = "" +) -> schema.Memory | None: + """Build a Memory from a module port list, or None if not a memory. + + A module qualifies only when *every* port matches the convention — + a single unrecognized port disqualifies it, so ordinary logic + modules that happen to contain an `R0_...` name are not swept in. + """ + if not ports: + return None + pins: list[schema.Pin] = [] + groups: dict[str, list[schema.Pin]] = {} + for pname, direction, width in ports: + m = _PORT_PIN_RE.match(pname) + if not m: + return None + kind, num, tail = m.group(1), m.group(2), m.group(3) + function = _classify_tail(kind, tail) + if function is None: + return None + pin = schema.Pin( + name=pname, + direction=direction, + width=width, + port_id=kind + num, + function=function, + ) + pins.append(pin) + groups.setdefault(kind + num, []).append(pin) + + n_read = n_write = n_rw = 0 + addr_w = 0 + bits = 0 + mask_lanes = 0 + for port_id, group in groups.items(): + functions = {p.function for p in group} + if "clk" not in functions or "addr" not in functions: + return None + if port_id.startswith("RW"): + if not {"wmode", "data_in", "data_out"} <= functions: + return None + n_rw += 1 + elif port_id.startswith("R"): + if "data_out" not in functions: + return None + n_read += 1 + else: + if "data_in" not in functions: + return None + n_write += 1 + addr_w = max(addr_w, max(p.width for p in group if p.function == "addr")) + for function in ("data_in", "data_out"): + bits = max(bits, sum(p.width for p in group if p.function == function)) + mask_lanes = max( + mask_lanes, sum(p.width for p in group if p.function == "mask") + ) + if addr_w == 0 or bits == 0: + return None + return schema.Memory( + name=name, + rows=1 << addr_w, + bits=bits, + addr_w=addr_w, + read_ports=n_read, + write_ports=n_write, + rw_ports=n_rw, + mask_lanes=mask_lanes, + pins=pins, + behavioral_model={"file": source_file, "module": name}, + ) + + +def scan_text(text: str, source_file: str = "") -> list[schema.Memory]: + """Find every memory-shaped module in one Verilog text.""" + out: list[schema.Memory] = [] + # Strip block and line comments so a comment inside a port list (or + # a commented-out module) cannot confuse the port parser. + text = re.sub(r"/\*.*?\*/", "", text, flags=re.DOTALL) + text = re.sub(r"//[^\n]*", "", text) + headers = list(_MODULE_HEADER_RE.finditer(text)) + for i, m in enumerate(headers): + start = m.start() + end = headers[i + 1].start() if i + 1 < len(headers) else len(text) + memory = classify_module( + m.group(1), _parse_module_ports(text[start:end]), source_file + ) + if memory is not None: + out.append(memory) + return out + + +def scan_files(paths: list[Path]) -> list[schema.Memory]: + """Scan Verilog files; later definitions of a name win.""" + found: dict[str, schema.Memory] = {} + for path in paths: + for memory in scan_text(path.read_text(errors="replace"), str(path)): + found[memory.name] = memory + return list(found.values()) diff --git a/flow/scripts/memories/detect_test.py b/flow/scripts/memories/detect_test.py new file mode 100644 index 0000000000..6345c718f4 --- /dev/null +++ b/flow/scripts/memories/detect_test.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import detect + +# firtool-style memory with whole-bus data and mask pins, and a +# continuation-comma port (`W0_clk` carries no direction keyword of its +# own — it continues the preceding `input` group). +BUS_STYLE = """\ +module mem_128x32( + input [6:0] R0_addr, + input R0_en, + input R0_clk, + output [31:0] R0_data, + input [6:0] W0_addr, + input W0_en, + W0_clk, + input [31:0] W0_data, + input [3:0] W0_mask +); +endmodule +""" + +# Subword-split style: data and mask split into per-lane pins. +SUBWORD_STYLE = """\ +module mem_1024x32( + input [9:0] R0_addr, + input R0_en, + input R0_clk, + output [7:0] R0_data_0, + output [7:0] R0_data_1, + output [7:0] R0_data_2, + output [7:0] R0_data_3, + input [9:0] W0_addr, + input W0_en, + input W0_clk, + input [7:0] W0_data_0, + input [7:0] W0_data_1, + input [7:0] W0_data_2, + input [7:0] W0_data_3, + input W0_mask_0, + input W0_mask_1, + input W0_mask_2, + input W0_mask_3 +); +endmodule +""" + +RW_STYLE = """\ +module cache_tags( + input [1:0] RW0_addr, + input RW0_en, + input RW0_clk, + input RW0_wmode, + input [24:0] RW0_wdata_0, + output [24:0] RW0_rdata_0 +); +endmodule +""" + +# A module with one convention-shaped port among ordinary ports must +# not classify as a memory. +NOT_A_MEMORY = """\ +module controller( + input clock, + input reset, + input [6:0] R0_addr, + output done +); +endmodule +""" + + +class DetectTest(unittest.TestCase): + def test_bus_style(self): + mems = detect.scan_text(BUS_STYLE) + self.assertEqual(len(mems), 1) + m = mems[0] + self.assertEqual((m.name, m.rows, m.bits, m.addr_w), ("mem_128x32", 128, 32, 7)) + self.assertEqual( + (m.read_ports, m.write_ports, m.rw_ports, m.mask_lanes), (1, 1, 0, 4) + ) + functions = {p.name: p.function for p in m.pins} + self.assertEqual(functions["R0_data"], "data_out") + self.assertEqual(functions["W0_data"], "data_in") + self.assertEqual(functions["W0_mask"], "mask") + # The continuation-comma port is captured with its group's + # direction. + clk = next(p for p in m.pins if p.name == "W0_clk") + self.assertEqual(clk.direction, "input") + + def test_subword_style(self): + mems = detect.scan_text(SUBWORD_STYLE) + self.assertEqual(len(mems), 1) + m = mems[0] + self.assertEqual((m.rows, m.bits, m.mask_lanes), (1024, 32, 4)) + self.assertEqual((m.read_ports, m.write_ports), (1, 1)) + functions = {p.name: p.function for p in m.pins} + self.assertEqual(functions["R0_data_2"], "data_out") + self.assertEqual(functions["W0_data_2"], "data_in") + self.assertEqual(functions["W0_mask_3"], "mask") + + def test_rw_style(self): + mems = detect.scan_text(RW_STYLE) + self.assertEqual(len(mems), 1) + m = mems[0] + self.assertEqual((m.rows, m.bits, m.rw_ports), (4, 25, 1)) + functions = {p.name: p.function for p in m.pins} + self.assertEqual(functions["RW0_wmode"], "wmode") + self.assertEqual(functions["RW0_wdata_0"], "data_in") + self.assertEqual(functions["RW0_rdata_0"], "data_out") + + def test_non_memory_rejected(self): + self.assertEqual(detect.scan_text(NOT_A_MEMORY), []) + + def test_multiple_modules_in_one_text(self): + text = BUS_STYLE + NOT_A_MEMORY + RW_STYLE + names = [m.name for m in detect.scan_text(text)] + self.assertEqual(names, ["mem_128x32", "cache_tags"]) + + def test_behavioral_model_recorded(self): + mems = detect.scan_text(BUS_STYLE, source_file="rtl/mems.v") + self.assertEqual( + mems[0].behavioral_model, + { + "file": "rtl/mems.v", + "module": "mem_128x32", + }, + ) + + def test_rw_port_with_plain_data_rejected(self): + text = RW_STYLE.replace("RW0_wdata_0", "RW0_data_0") + self.assertEqual(detect.scan_text(text), []) + + def test_port_group_without_clk_rejected(self): + text = BUS_STYLE.replace("W0_clk", "W0_clock") + self.assertEqual(detect.scan_text(text), []) + + def test_comments_in_port_list_ignored(self): + text = BUS_STYLE.replace( + "output [31:0] R0_data,", + "output [31:0] R0_data, // output data for read port\n" + " /* input for clock is per port */", + ) + mems = detect.scan_text(text) + self.assertEqual(len(mems), 1) + self.assertEqual(mems[0].bits, 32) + + def test_commented_out_module_ignored(self): + text = "/*\n" + BUS_STYLE + "*/\n" + RW_STYLE + names = [m.name for m in detect.scan_text(text)] + self.assertEqual(names, ["cache_tags"]) + + def test_parameterized_width_disqualifies(self): + text = BUS_STYLE.replace("[31:0]", "[WIDTH-1:0]") + self.assertEqual(detect.scan_text(text), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/scripts/memories/gen_memories.py b/flow/scripts/memories/gen_memories.py new file mode 100644 index 0000000000..dcb261b0be --- /dev/null +++ b/flow/scripts/memories/gen_memories.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""AUTO_MEMORIES driver: detect memories, judge them, emit macro views. + +Run pre-synthesis (before canonicalization). Scans the design's Verilog +for memory-shaped modules, merges user-supplied `.memories` files +(ADDITIONAL_MEMORIES), applies the idiomatic-macro gate, then writes: + + full inventory, converted or not (memories.json) + /.lib Liberty view per converted memory + /_pre_layout.lib ideal-clock variant for pre-CTS consumers + /.lef abstract LEF per converted memory + /blackboxes.txt converted module names, one per line — + what synthesis blackboxes + +Everything downstream consumes these files; nothing else is passed +between the generator and the flow. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import subprocess +import os + +import detect # noqa: E402 +import idiomatic # noqa: E402 +import schema # noqa: E402 + + +def run( + verilog: list[Path], + memories_files: list[Path], + platform: str, + out_dir: Path, + json_path: Path, +) -> int: + if platform != "asap7": + sys.stderr.write(f"gen_memories: unsupported platform {platform}\n") + return 1 + + try: + found = detect.scan_files(verilog) + except OSError as e: + sys.stderr.write(f"gen_memories: cannot read Verilog: {e}\n") + return 1 + idiomatic.apply(found) + + try: + overrides: list[schema.Memory] = [] + for path in memories_files: + overrides.extend(schema.load(path)) + memories = schema.merge(found, overrides) + except schema.SchemaError as e: + sys.stderr.write(f"gen_memories: {e}\n") + return 1 + + out_dir.mkdir(parents=True, exist_ok=True) + json_path.parent.mkdir(parents=True, exist_ok=True) + schema.dump(memories, json_path, platform) + + converted = sorted((m for m in memories if m.idiomatic), key=lambda m: m.name) + try: + for mem in converted: + schema.validate_emittable(mem) + except schema.SchemaError as e: + sys.stderr.write(f"gen_memories: {e}\n") + return 1 + + fakeram_run = os.environ.get("FAKERAM_RUN_PY") + if not fakeram_run: + sys.stderr.write("gen_memories: FAKERAM_RUN_PY environment variable is not set.\n") + return 1 + + subprocess.check_call([ + sys.executable, fakeram_run, + "--orfs_asap7_backend", + "--output_dir", str(out_dir), + str(json_path) + ]) + + for mem in sorted(memories, key=lambda m: m.name): + verdict = "macro" if mem.idiomatic else "flops" + sys.stderr.write( + f"gen_memories: {mem.name} {mem.rows}x{mem.bits} " + f"R={mem.read_ports} W={mem.write_ports} RW={mem.rw_ports} " + f"mask_lanes={mem.mask_lanes} -> {verdict} ({mem.reason})\n" + ) + if not memories: + sys.stderr.write("gen_memories: no memory-shaped modules found\n") + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument( + "--verilog", + action="append", + default=[], + type=Path, + help="Verilog source file to scan (repeatable).", + ) + p.add_argument( + "--memories", + action="append", + default=[], + type=Path, + help="User-supplied .memories file merged onto the " + "detected set (repeatable).", + ) + p.add_argument("--platform", required=True) + p.add_argument( + "--out-dir", + required=True, + type=Path, + help="Directory for generated .lib/.lef/blackboxes.txt.", + ) + p.add_argument( + "--json", + required=True, + type=Path, + help="Path of the memories.json inventory to write.", + ) + args = p.parse_args(argv) + return run(args.verilog, args.memories, args.platform, args.out_dir, args.json) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/flow/scripts/memories/gen_memories_test.py b/flow/scripts/memories/gen_memories_test.py new file mode 100644 index 0000000000..fd74e7fdee --- /dev/null +++ b/flow/scripts/memories/gen_memories_test.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import os + +import gen_memories +from detect_test import BUS_STYLE, NOT_A_MEMORY, RW_STYLE + +# Setup FAKERAM_RUN_PY using runfiles if available, otherwise assume a relative path +if "TEST_WORKSPACE" in os.environ: + # Bazel test execution + runfiles_dir = Path(os.environ.get("RUNFILES_DIR", ".")) + candidates = list(runfiles_dir.glob("**/fakeram*/run.py")) + list(runfiles_dir.glob("**/run.py")) + fakeram_run = candidates[0] if candidates else runfiles_dir / "fakeram" / "run.py" +else: + fakeram_run = Path(__file__).resolve().parent.parent.parent.parent / "tools" / "FakeRAM2.0" / "run.py" + +os.environ["FAKERAM_RUN_PY"] = str(fakeram_run) + +FORCE_TAGS = """\ +{ + "version": 1, + "memories": [ + { + "name": "cache_tags", + "idiomatic": true, + "reason": "forced: RTL provides no behavioral fallback" + } + ] +} +""" + + +class GenMemoriesTest(unittest.TestCase): + def run_generator(self, memories_files=(), platform="asap7"): + d = Path(self.tmp.name) + rtl = d / "design.v" + rtl.write_text(BUS_STYLE + NOT_A_MEMORY + RW_STYLE) + argv = [ + "--platform", + platform, + "--out-dir", + str(d / "memories"), + "--json", + str(d / "memories.json"), + "--verilog", + str(rtl), + ] + for f in memories_files: + argv += ["--memories", str(f)] + return gen_memories.main(argv), d + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def test_flow_contract(self): + # Everything synthesis and later stages consume must be on disk: + # memories.json, per-macro .lib/_pre_layout.lib/.lef, and the + # blackbox name list — for converted memories only. + code, d = self.run_generator() + self.assertEqual(code, 0) + + doc = json.loads((d / "memories.json").read_text()) + by_name = {m["name"]: m for m in doc["memories"]} + self.assertEqual(set(by_name), {"mem_128x32", "cache_tags"}) + self.assertTrue(by_name["mem_128x32"]["idiomatic"]) + self.assertFalse(by_name["cache_tags"]["idiomatic"]) + self.assertIn("depth 4", by_name["cache_tags"]["reason"]) + + mems_dir = d / "memories" + self.assertEqual( + sorted(p.name for p in mems_dir.iterdir()), + [ + "blackboxes.txt", + "mem_128x32.lef", + "mem_128x32.lib", + "mem_128x32_pre_layout.lib", + ], + ) + self.assertEqual((mems_dir / "blackboxes.txt").read_text(), "mem_128x32\n") + + def test_additional_memories_forces_conversion(self): + force = Path(self.tmp.name) / "force.memories" + force.write_text(FORCE_TAGS) + code, d = self.run_generator(memories_files=[force]) + self.assertEqual(code, 0) + blackboxes = (d / "memories" / "blackboxes.txt").read_text().split() + self.assertEqual(sorted(blackboxes), ["cache_tags", "mem_128x32"]) + self.assertTrue((d / "memories" / "cache_tags.lib").exists()) + self.assertTrue((d / "memories" / "cache_tags.lef").exists()) + doc = json.loads((d / "memories.json").read_text()) + tags = next(m for m in doc["memories"] if m["name"] == "cache_tags") + self.assertTrue(tags["idiomatic"]) + self.assertIn("forced", tags["reason"]) + + def test_unsupported_platform_fails_clearly(self): + code, _d = self.run_generator(platform="nangate45") + self.assertEqual(code, 1) + + def test_missing_verilog_fails_cleanly(self): + code = gen_memories.main( + [ + "--platform", + "asap7", + "--out-dir", + str(Path(self.tmp.name) / "memories"), + "--json", + str(Path(self.tmp.name) / "memories.json"), + "--verilog", + str(Path(self.tmp.name) / "does-not-exist.v"), + ] + ) + self.assertEqual(code, 1) + + def test_bad_memories_file_fails(self): + bad = Path(self.tmp.name) / "bad.memories" + bad.write_text("{not json") + code, _d = self.run_generator(memories_files=[bad]) + self.assertEqual(code, 1) + + def test_no_memories_still_writes_contract_files(self): + d = Path(self.tmp.name) + rtl = d / "logic.v" + rtl.write_text(NOT_A_MEMORY) + code = gen_memories.main( + [ + "--platform", + "asap7", + "--out-dir", + str(d / "memories"), + "--json", + str(d / "memories.json"), + "--verilog", + str(rtl), + ] + ) + self.assertEqual(code, 0) + self.assertEqual(json.loads((d / "memories.json").read_text())["memories"], []) + self.assertEqual((d / "memories" / "blackboxes.txt").read_text(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/scripts/memories/idiomatic.py b/flow/scripts/memories/idiomatic.py new file mode 100644 index 0000000000..e32221eaf4 --- /dev/null +++ b/flow/scripts/memories/idiomatic.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Decide which detected memories are idiomatic as ASAP7 SRAM macros. + +A memory below these floors is cheaper as flip-flops than as an SRAM +macro (the macro pays a fixed control/decode/sense-amp floor), and a +memory with too many ports has no single-macro implementation in +ordinary SRAM compilers. Rejected memories keep `idiomatic: false` plus +a reason in memories.json and synthesize as flops. + +The thresholds are deliberately simple, first-pass policy. Banking and +multi-port decomposition (splitting a too-wide/too-ported memory across +several macros) are not handled — a future extension. A user who wants +a rejected memory converted anyway overrides it via a `.memories` file +listed in ADDITIONAL_MEMORIES. +""" + +from __future__ import annotations + +import schema + +MIN_ROWS = 16 +MIN_BITS_TOTAL = 256 +MAX_TOTAL_PORTS = 4 + + +def judge(mem: schema.Memory) -> tuple[bool, str]: + """Return (idiomatic, reason).""" + if mem.bits < 1: + return False, "no data pins" + if mem.rows < MIN_ROWS: + return False, f"depth {mem.rows} below macro floor {MIN_ROWS}" + total_bits = mem.rows * mem.bits + if total_bits < MIN_BITS_TOTAL: + return ( + False, + f"capacity {total_bits} bits below macro floor " f"{MIN_BITS_TOTAL}", + ) + if mem.total_ports() > MAX_TOTAL_PORTS: + return ( + False, + f"{mem.total_ports()} ports exceeds single-macro " + f"limit {MAX_TOTAL_PORTS}", + ) + return True, "meets ASAP7 macro floors" + + +def apply(memories: list[schema.Memory]) -> None: + for mem in memories: + mem.idiomatic, mem.reason = judge(mem) diff --git a/flow/scripts/memories/idiomatic_test.py b/flow/scripts/memories/idiomatic_test.py new file mode 100644 index 0000000000..d26fbfc040 --- /dev/null +++ b/flow/scripts/memories/idiomatic_test.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import idiomatic +import schema + + +def mem(rows, bits, read_ports=1, write_ports=1, rw_ports=0): + return schema.Memory( + name="m", + rows=rows, + bits=bits, + read_ports=read_ports, + write_ports=write_ports, + rw_ports=rw_ports, + ) + + +class IdiomaticTest(unittest.TestCase): + def test_accepts_macro_sized_memory(self): + ok, reason = idiomatic.judge(mem(64, 32)) + self.assertTrue(ok, reason) + + def test_rejects_shallow_memory(self): + ok, reason = idiomatic.judge(mem(4, 25)) + self.assertFalse(ok) + self.assertIn("depth 4", reason) + + def test_rejects_small_capacity(self): + # 16 rows x 8 bits = 128 bits: deep enough, too small overall. + ok, reason = idiomatic.judge(mem(16, 8)) + self.assertFalse(ok) + self.assertIn("capacity 128", reason) + + def test_rejects_too_many_ports(self): + ok, reason = idiomatic.judge(mem(256, 32, read_ports=4, write_ports=2)) + self.assertFalse(ok) + self.assertIn("ports", reason) + + def test_apply_sets_fields(self): + memories = [mem(64, 32), mem(4, 25)] + idiomatic.apply(memories) + self.assertTrue(memories[0].idiomatic) + self.assertFalse(memories[1].idiomatic) + self.assertTrue(memories[1].reason) + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/scripts/memories/schema.py b/flow/scripts/memories/schema.py new file mode 100644 index 0000000000..2316564b88 --- /dev/null +++ b/flow/scripts/memories/schema.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""The `.memories` file format: a JSON inventory of memory macros. + +One `.memories` file (and the flow-generated `memories.json`) describes +memories at the module boundary: geometry, the full pin list with each +pin's function, the behavioral model the RTL provides, and whether the +memory is converted to a macro (`idiomatic`). + +The file-based handoff is deliberate: everything downstream — synthesis +blackboxing, liberty/LEF consumption in later stages, build systems that +declare flow artifacts as transitive dependencies — keys off these files +rather than off in-process state. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path + +SCHEMA_VERSION = 1 + +PIN_FUNCTIONS = ("addr", "en", "wmode", "mask", "data_in", "data_out", "clk") +DIRECTIONS = ("input", "output") + + +class SchemaError(ValueError): + pass + + +@dataclass +class Pin: + name: str + direction: str # "input" / "output" + width: int + port_id: str # "R0", "W1", "RW0", ... + function: str # one of PIN_FUNCTIONS + + +@dataclass +class Memory: + name: str + rows: int = 0 + bits: int = 0 + addr_w: int = 0 + read_ports: int = 0 + write_ports: int = 0 + rw_ports: int = 0 + # Total width of the per-port write mask, 0 if unmasked. Subword-split + # mask pins (`W0_mask_0..3`) contribute one lane each. + mask_lanes: int = 0 + pins: list[Pin] = field(default_factory=list) + # The module whose RTL body simulates this memory: {"file", "module"}. + behavioral_model: dict | None = None + # True when the memory is converted to a macro (.lib/.lef emitted and + # the module blackboxed); False leaves it to synthesize as flops. + idiomatic: bool = False + reason: str = "" + + def total_ports(self) -> int: + return self.read_ports + self.write_ports + self.rw_ports + + def to_dict(self) -> dict: + return asdict(self) + + +def _pin_from_dict(d: dict) -> Pin: + try: + pin = Pin( + name=d["name"], + direction=d["direction"], + width=int(d["width"]), + port_id=d["port_id"], + function=d["function"], + ) + except KeyError as e: + raise SchemaError(f"pin record missing field {e}") from e + if pin.direction not in DIRECTIONS: + raise SchemaError(f"pin {pin.name}: bad direction '{pin.direction}'") + if pin.function not in PIN_FUNCTIONS: + raise SchemaError( + f"pin {pin.name}: unknown function " + f"'{pin.function}' (expected one of " + f"{', '.join(PIN_FUNCTIONS)})" + ) + if pin.width < 1: + raise SchemaError(f"pin {pin.name}: width must be >= 1") + if not pin.port_id: + raise SchemaError(f"pin {pin.name}: empty port_id") + return pin + + +def memory_from_dict(d: dict) -> Memory: + if "name" not in d: + raise SchemaError("memory record missing 'name'") + mem = Memory(name=d["name"]) + for key in ( + "rows", + "bits", + "addr_w", + "read_ports", + "write_ports", + "rw_ports", + "mask_lanes", + ): + if key in d: + setattr(mem, key, int(d[key])) + if "pins" in d: + mem.pins = [_pin_from_dict(p) for p in d["pins"]] + if "behavioral_model" in d: + mem.behavioral_model = d["behavioral_model"] + if "idiomatic" in d: + mem.idiomatic = bool(d["idiomatic"]) + if "reason" in d: + mem.reason = str(d["reason"]) + return mem + + +def load(path: Path) -> list[Memory]: + """Load a .memories / memories.json file.""" + try: + doc = json.loads(path.read_text()) + except json.JSONDecodeError as e: + raise SchemaError(f"{path}: not valid JSON: {e}") from e + if not isinstance(doc, dict) or "memories" not in doc: + raise SchemaError(f"{path}: expected an object with a 'memories' list") + version = doc.get("version", SCHEMA_VERSION) + if version != SCHEMA_VERSION: + raise SchemaError(f"{path}: unsupported schema version {version}") + return [memory_from_dict(m) for m in doc["memories"]] + + +def dump(memories: list[Memory], path: Path, platform: str) -> None: + doc = { + "version": SCHEMA_VERSION, + "platform": platform, + "memories": [m.to_dict() for m in sorted(memories, key=lambda m: m.name)], + } + path.write_text(json.dumps(doc, indent=2) + "\n") + + +def merge(detected: list[Memory], overrides: list[Memory]) -> list[Memory]: + """Merge user-supplied entries onto the detected set. + + An override naming a detected memory replaces only the fields it + carries (geometry and pins are kept from detection unless the + override provides its own). An override naming an unknown memory is + taken whole — it must then carry pins and geometry itself to be + emittable. + """ + by_name = {m.name: m for m in detected} + for o in overrides: + base = by_name.get(o.name) + if base is None: + by_name[o.name] = o + continue + if o.pins: + base.pins = o.pins + for key in ( + "rows", + "bits", + "addr_w", + "read_ports", + "write_ports", + "rw_ports", + "mask_lanes", + ): + value = getattr(o, key) + if value: + setattr(base, key, value) + if o.behavioral_model is not None: + base.behavioral_model = o.behavioral_model + base.idiomatic = o.idiomatic + if o.reason: + base.reason = o.reason + return list(by_name.values()) + + +def validate_emittable(mem: Memory) -> None: + """Raise SchemaError unless `mem` carries enough to emit .lib/.lef.""" + if not mem.pins: + raise SchemaError(f"memory {mem.name}: no pins") + if mem.rows < 1 or mem.bits < 1: + raise SchemaError( + f"memory {mem.name}: rows ({mem.rows}) and bits ({mem.bits}) " + f"must be >= 1" + ) + port_ids = {p.port_id for p in mem.pins} + for port_id in port_ids: + functions = {p.function for p in mem.pins if p.port_id == port_id} + if "clk" not in functions: + raise SchemaError(f"memory {mem.name}: port {port_id} has no clk pin") + if "addr" not in functions: + raise SchemaError(f"memory {mem.name}: port {port_id} has no addr pin") + if not functions & {"data_in", "data_out"}: + raise SchemaError(f"memory {mem.name}: port {port_id} has no data pin") diff --git a/flow/scripts/memories/schema_test.py b/flow/scripts/memories/schema_test.py new file mode 100644 index 0000000000..31775f1b01 --- /dev/null +++ b/flow/scripts/memories/schema_test.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import schema + + +def sample_memory(name="mem_64x32") -> schema.Memory: + return schema.Memory( + name=name, + rows=64, + bits=32, + addr_w=6, + read_ports=1, + write_ports=1, + pins=[ + schema.Pin("R0_addr", "input", 6, "R0", "addr"), + schema.Pin("R0_en", "input", 1, "R0", "en"), + schema.Pin("R0_clk", "input", 1, "R0", "clk"), + schema.Pin("R0_data", "output", 32, "R0", "data_out"), + schema.Pin("W0_addr", "input", 6, "W0", "addr"), + schema.Pin("W0_en", "input", 1, "W0", "en"), + schema.Pin("W0_clk", "input", 1, "W0", "clk"), + schema.Pin("W0_data", "input", 32, "W0", "data_in"), + ], + behavioral_model={"file": "mems.v", "module": name}, + idiomatic=True, + reason="meets ASAP7 macro floors", + ) + + +class SchemaTest(unittest.TestCase): + def test_round_trip(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "memories.json" + schema.dump([sample_memory()], path, platform="asap7") + loaded = schema.load(path) + self.assertEqual(len(loaded), 1) + self.assertEqual(loaded[0].to_dict(), sample_memory().to_dict()) + + def test_dump_is_sorted_and_versioned(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "memories.json" + schema.dump( + [sample_memory("zz"), sample_memory("aa")], path, platform="asap7" + ) + doc = json.loads(path.read_text()) + self.assertEqual(doc["version"], schema.SCHEMA_VERSION) + self.assertEqual(doc["platform"], "asap7") + self.assertEqual([m["name"] for m in doc["memories"]], ["aa", "zz"]) + + def test_merge_partial_override(self): + detected = [sample_memory()] + detected[0].idiomatic = False + detected[0].reason = "depth 4 below macro floor 16" + override = schema.Memory( + name="mem_64x32", idiomatic=True, reason="forced: no behavioral fallback" + ) + merged = schema.merge(detected, [override]) + self.assertEqual(len(merged), 1) + m = merged[0] + self.assertTrue(m.idiomatic) + self.assertEqual(m.reason, "forced: no behavioral fallback") + # Geometry and pins kept from detection. + self.assertEqual((m.rows, m.bits), (64, 32)) + self.assertEqual(len(m.pins), 8) + + def test_merge_new_entry_taken_whole(self): + extra = sample_memory("mem_extra") + merged = schema.merge([sample_memory()], [extra]) + self.assertEqual(sorted(m.name for m in merged), ["mem_64x32", "mem_extra"]) + + def test_load_rejects_bad_documents(self): + with tempfile.TemporaryDirectory() as d: + path = Path(d) / "bad.memories" + path.write_text("[]") + with self.assertRaises(schema.SchemaError): + schema.load(path) + path.write_text('{"version": 99, "memories": []}') + with self.assertRaises(schema.SchemaError): + schema.load(path) + path.write_text('{"memories": [{"rows": 4}]}') + with self.assertRaises(schema.SchemaError): + schema.load(path) + path.write_text( + '{"memories": [{"name": "m", "pins": ' + '[{"name": "p", "direction": "input", "width": 1, ' + '"port_id": "R0", "function": "banana"}]}]}' + ) + with self.assertRaises(schema.SchemaError): + schema.load(path) + + def test_validate_emittable(self): + good = sample_memory() + schema.validate_emittable(good) # no raise + with self.assertRaises(schema.SchemaError): + schema.validate_emittable(schema.Memory(name="empty")) + clkless = sample_memory() + clkless.pins = [p for p in clkless.pins if p.function != "clk"] + with self.assertRaises(schema.SchemaError): + schema.validate_emittable(clkless) + addrless = sample_memory() + addrless.pins = [p for p in addrless.pins if p.function != "addr"] + with self.assertRaises(schema.SchemaError): + schema.validate_emittable(addrless) + dataless = sample_memory() + dataless.pins = [ + p for p in dataless.pins if p.function not in ("data_in", "data_out") + ] + with self.assertRaises(schema.SchemaError): + schema.validate_emittable(dataless) + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/scripts/read_liberty.tcl b/flow/scripts/read_liberty.tcl index 14f8f06e7a..83f5469b9f 100644 --- a/flow/scripts/read_liberty.tcl +++ b/flow/scripts/read_liberty.tcl @@ -16,3 +16,17 @@ if { [env_var_exists_and_non_empty CORNERS] } { log_cmd read_liberty $libFile } } + +# AUTO_MEMORIES: liberty views generated pre-synthesis by +# scripts/memories/gen_memories.py. The file names are only known at +# run time, so they are globbed here rather than threaded through +# LIB_FILES. The _pre_layout variants (ideal clock) are for pre-CTS +# consumers that select lib files themselves. +if { [env_var_equals AUTO_MEMORIES 1] } { + foreach libFile [glob -nocomplain $::env(RESULTS_DIR)/memories/*.lib] { + if { [string match *_pre_layout.lib $libFile] } { + continue + } + log_cmd read_liberty $libFile + } +} diff --git a/flow/scripts/synth_preamble.tcl b/flow/scripts/synth_preamble.tcl index 18e27b4aa5..e72acc0bc1 100644 --- a/flow/scripts/synth_preamble.tcl +++ b/flow/scripts/synth_preamble.tcl @@ -29,6 +29,25 @@ proc read_checkpoint { file } { } } +# AUTO_MEMORIES: memory modules whose generated liberty view replaces +# their behavioral body during synthesis. The list is produced by +# scripts/memories/gen_memories.py before canonicalization; see +# docs/user/AutoMemories.md. +proc auto_memories_blackboxes { } { + if { ![env_var_equals AUTO_MEMORIES 1] } { + return {} + } + set f "$::env(RESULTS_DIR)/memories/blackboxes.txt" + if { ![file exists $f] } { + error "AUTO_MEMORIES=1 but $f is missing;\ + the do-auto-memories step must run before synthesis" + } + set fh [open $f r] + set names [read $fh] + close $fh + return $names +} + proc read_design_sources { } { # We are reading Verilog sources source $::env(SCRIPTS_DIR)/synth_stdcells.tcl @@ -80,6 +99,12 @@ proc read_design_sources { } { } } + # Blackbox AUTO_MEMORIES-detected memory modules so their generated + # liberty view wins over their behavioral bodies. + foreach m [auto_memories_blackboxes] { + lappend slang_args --blackboxed-module "$m" + } + # Add user arguments lappend slang_args {*}$::env(SYNTH_SLANG_ARGS) @@ -105,6 +130,9 @@ proc read_design_sources { } { if { [env_var_exists_and_non_empty SYNTH_BLACKBOXES] } { error "Non-empty SYNTH_BLACKBOXES unsupported with HDL frontend \"verific\"" } + if { [llength [auto_memories_blackboxes]] > 0 } { + error "AUTO_MEMORIES unsupported with HDL frontend \"verific\"" + } } elseif { ![env_var_exists_and_non_empty SYNTH_HDL_FRONTEND] } { verilog_defaults -push if { [env_var_exists_and_non_empty VERILOG_DEFINES] } { @@ -124,6 +152,18 @@ proc read_design_sources { } { chparam -set $key $value $::env(DESIGN_NAME) } + # AUTO_MEMORIES blackboxing runs before `hierarchy -check`: a + # memory wrapper may instantiate modules the sources never define + # (the behavioral body is being discarded anyway), so the check is + # only meaningful after the bodies are gone. + set auto_blackboxes [auto_memories_blackboxes] + if { [llength $auto_blackboxes] > 0 } { + hierarchy -top $::env(DESIGN_NAME) + foreach m $auto_blackboxes { + blackbox $m + } + hierarchy -check -top $::env(DESIGN_NAME) + } if { [env_var_exists_and_non_empty SYNTH_BLACKBOXES] } { hierarchy -check -top $::env(DESIGN_NAME) foreach m $::env(SYNTH_BLACKBOXES) { diff --git a/flow/scripts/variables.json b/flow/scripts/variables.json index 27ac98e231..5ce07d7e6e 100644 --- a/flow/scripts/variables.json +++ b/flow/scripts/variables.json @@ -51,6 +51,12 @@ "ADDITIONAL_LIBS": { "description": "Hardened macro library files listed here. The library information is immutable and used throughout all stages. Not stored in the .odb file.\n" }, + "ADDITIONAL_MEMORIES": { + "description": "Experimental. Space-separated list of user-supplied `.memories` files (the memories.json JSON schema) merged onto the set AUTO_MEMORIES detects \u2014 to force-convert a memory the idiomatic gate rejects, or to describe one the scanner cannot find. See docs/user/AutoMemories.md.\n", + "stages": [ + "synth" + ] + }, "ADDITIONAL_SITES": { "description": "Passed as -additional_sites to initialize_floorplan.\n", "stages": [ @@ -64,6 +70,10 @@ "All stages" ] }, + "AUTO_MEMORIES": { + "default": 0, + "description": "Experimental. When set to 1, memory-shaped modules in the RTL (firtool/CIRCT `R*/W*/RW*_` port convention, module boundary only) are detected pre-synthesis, inventoried in `$(RESULTS_DIR)/memories.json`, and \u2014 when idiomatic as an SRAM macro \u2014 given generated .lib/.lef views under `$(RESULTS_DIR)/memories/`. Synthesis blackboxes those modules and all later stages read the generated views, so an existing design gets macro-based results without a memory compiler. Supported for the asap7 platform only. See docs/user/AutoMemories.md.\n" + }, "BALANCE_ROWS": { "default": 0, "description": "Balance rows during placement.\n", diff --git a/flow/scripts/variables.yaml b/flow/scripts/variables.yaml index 5d5c03e9b8..27b1c546e1 100644 --- a/flow/scripts/variables.yaml +++ b/flow/scripts/variables.yaml @@ -189,6 +189,27 @@ SYNTH_USE_SYN: stages: - synth default: 0 +AUTO_MEMORIES: + description: > + Experimental. When set to 1, memory-shaped modules in the RTL + (firtool/CIRCT `R*/W*/RW*_` port convention, module boundary only) + are detected pre-synthesis, inventoried in + `$(RESULTS_DIR)/memories.json`, and — when idiomatic as an SRAM + macro — given generated .lib/.lef views under + `$(RESULTS_DIR)/memories/`. Synthesis blackboxes those modules and + all later stages read the generated views, so an existing design + gets macro-based results without a memory compiler. Supported for + the asap7 platform only. See docs/user/AutoMemories.md. + default: 0 +ADDITIONAL_MEMORIES: + description: > + Experimental. Space-separated list of user-supplied `.memories` + files (the memories.json JSON schema) merged onto the set + AUTO_MEMORIES detects — to force-convert a memory the idiomatic + gate rejects, or to describe one the scanner cannot find. See + docs/user/AutoMemories.md. + stages: + - synth SYNTH_MEMORY_MAX_BITS: description: > Maximum number of bits for memory synthesis. diff --git a/patches/fakeram/0001-Add-ORFS-ASAP7-backend.patch b/patches/fakeram/0001-Add-ORFS-ASAP7-backend.patch new file mode 100644 index 0000000000..d6d587fa9a --- /dev/null +++ b/patches/fakeram/0001-Add-ORFS-ASAP7-backend.patch @@ -0,0 +1,1100 @@ +diff --git a/BUILD.bazel b/BUILD.bazel +new file mode 100644 +index 0000000..16e9be4 +--- /dev/null ++++ b/BUILD.bazel +@@ -0,0 +1,5 @@ ++filegroup( ++ name = "all_files", ++ srcs = glob(["**/*"]), ++ visibility = ["//visibility:public"], ++) +diff --git a/MODULE.bazel b/MODULE.bazel +new file mode 100644 +index 0000000..f12bf1f +--- /dev/null ++++ b/MODULE.bazel +@@ -0,0 +1,4 @@ ++module( ++ name = "fakeram", ++ version = "2.0.0", ++) +diff --git a/orfs_asap7/__init__.py b/orfs_asap7/__init__.py +new file mode 100644 +index 0000000..edcf578 +--- /dev/null ++++ b/orfs_asap7/__init__.py +@@ -0,0 +1 @@ ++# ORFS ASAP7 package +diff --git a/orfs_asap7/generate.py b/orfs_asap7/generate.py +new file mode 100644 +index 0000000..0fded7a +--- /dev/null ++++ b/orfs_asap7/generate.py +@@ -0,0 +1,40 @@ ++import argparse ++import sys ++from pathlib import Path ++ ++from . import lef ++from . import liberty ++from . import pdk_asap7 ++from . import schema ++ ++def run_orfs_asap7(platform: str, out_dir: Path, json_path: Path): ++ try: ++ params = pdk_asap7.get_platform(platform) ++ except ValueError as e: ++ sys.stderr.write(f"FakeRAM2.0 orfs_asap7: {e}\n") ++ return 1 ++ ++ try: ++ memories = schema.load(json_path) ++ except schema.SchemaError as e: ++ sys.stderr.write(f"FakeRAM2.0 orfs_asap7: {e}\n") ++ return 1 ++ ++ out_dir.mkdir(parents=True, exist_ok=True) ++ converted = sorted((m for m in memories if m.idiomatic), key=lambda m: m.name) ++ ++ try: ++ for mem in converted: ++ schema.validate_emittable(mem) ++ except schema.SchemaError as e: ++ sys.stderr.write(f"FakeRAM2.0 orfs_asap7: {e}\n") ++ return 1 ++ ++ for mem in converted: ++ (out_dir / f"{mem.name}.lib").write_text(liberty.emit_lib(mem, params)) ++ (out_dir / f"{mem.name}_pre_layout.lib").write_text( ++ liberty.emit_lib(mem, params, ck_insertion_ps=0.0) ++ ) ++ (out_dir / f"{mem.name}.lef").write_text(lef.emit_lef(mem, params)) ++ (out_dir / "blackboxes.txt").write_text("".join(f"{m.name}\n" for m in converted)) ++ return 0 +diff --git a/orfs_asap7/lef.py b/orfs_asap7/lef.py +new file mode 100644 +index 0000000..2b9bf4c +--- /dev/null ++++ b/orfs_asap7/lef.py +@@ -0,0 +1,115 @@ ++#!/usr/bin/env python3 ++"""Emit an abstract LEF view for a memory macro. ++ ++The shape follows the platform's fakeram abstracts: CLASS BLOCK with a ++FOREIGN reference, per-bit signal pin pads stacked along the left edge ++(SHAPE ABUTMENT, wrapping into further columns when one edge is not ++enough), interleaved horizontal power/ground straps the platform's PDN ++macro grid can connect to, and a full-footprint multi-layer OBS so the ++parent's router treats the macro as opaque. Geometry comes from the ++parametric area model, snapped to the manufacturing grid. ++""" ++ ++from __future__ import annotations ++ ++from . import liberty ++from . import schema ++from .pdk_asap7 import PdkParams ++ ++ ++def emit_lef(mem: schema.Memory, params: PdkParams) -> str: ++ name = mem.name ++ area = liberty.predict_area_um2(mem, params.tech_nm) ++ width, height = liberty.predict_dimensions_um(area) ++ grid = params.grid_um ++ width = max(round(width / grid) * grid, 1.0) ++ height = max(round(height / grid) * grid, 1.0) ++ ++ lines: list[str] = [] ++ a = lines.append ++ a("VERSION 5.8 ;") ++ a('BUSBITCHARS "[]" ;') ++ a('DIVIDERCHAR "/" ;') ++ a("UNITS") ++ a(" DATABASE MICRONS 1000 ;") ++ a("END UNITS") ++ a("") ++ a(f"MACRO {name}") ++ a(f" FOREIGN {name} 0 0 ;") ++ a(" SYMMETRY X Y R90 ;") ++ a(f" SIZE {width:.3f} BY {height:.3f} ;") ++ a(" CLASS BLOCK ;") ++ ++ # Signal pins as small pads stacked along the left edge, like the ++ # fakeram abstracts. When one column fills up, continue in the next. ++ pad = params.pin_pad_um ++ pitch = params.pin_pitch_um ++ cursor_x = 0.0 ++ cursor_y = pitch ++ ++ def emit_pin(pin_name: str, direction: str) -> None: ++ nonlocal cursor_x, cursor_y ++ a(f" PIN {pin_name}") ++ a(f" DIRECTION {direction.upper()} ;") ++ a(" USE SIGNAL ;") ++ a(" SHAPE ABUTMENT ;") ++ a(" PORT") ++ a(f" LAYER {params.pin_layer} ;") ++ a( ++ f" RECT {cursor_x:.3f} {cursor_y:.3f} " ++ f"{cursor_x + pad:.3f} {cursor_y + pad:.3f} ;" ++ ) ++ a(" END") ++ a(f" END {pin_name}") ++ cursor_y += pitch ++ if cursor_y + pad > height: ++ cursor_y = pitch ++ cursor_x += pitch ++ ++ for _port_id, pins in liberty.ordered_port_groups(mem): ++ for pin in pins: ++ if pin.width > 1: ++ for bit in range(pin.width): ++ emit_pin(f"{pin.name}[{bit}]", pin.direction) ++ else: ++ emit_pin(pin.name, pin.direction) ++ ++ # Interleaved horizontal power/ground straps, nearly full width, ++ # matching the fakeram abstracts (VSS at offset 0, VDD offset by ++ # half the per-net pitch). The platform PDN's macro grid connects ++ # to these. ++ strap_w = params.pg_strap_w_um ++ strap_pitch = params.pg_strap_pitch_um ++ margin = params.pg_margin_um ++ x0 = margin ++ x1 = max(width - margin, x0 + grid) ++ ++ def emit_pg(pin_name: str, use: str, y_start: float) -> None: ++ a(f" PIN {pin_name}") ++ a(" DIRECTION INOUT ;") ++ a(f" USE {use} ;") ++ a(" PORT") ++ a(f" LAYER {params.pg_layer} ;") ++ y = y_start ++ while y + strap_w <= height: ++ a(f" RECT {x0:.3f} {y:.3f} {x1:.3f} {y + strap_w:.3f} ;") ++ y += strap_pitch ++ a(" END") ++ a(f" END {pin_name}") ++ ++ emit_pg("VDD", "POWER", params.pg_vdd_offset_um) ++ emit_pg("VSS", "GROUND", 0.0) ++ ++ # Full-footprint obstruction so the parent's router treats the ++ # macro as opaque (pins and straps sit on top of it, as in the ++ # fakeram abstracts). ++ a(" OBS") ++ for layer in params.obs_device_layers + params.obs_metal_layers: ++ a(f" LAYER {layer} ;") ++ a(f" RECT 0 0 {width:.3f} {height:.3f} ;") ++ a(" END") ++ a(f"END {name}") ++ a("") ++ a("END LIBRARY") ++ a("") ++ return "\n".join(lines) +diff --git a/orfs_asap7/liberty.py b/orfs_asap7/liberty.py +new file mode 100644 +index 0000000..3d0aa5a +--- /dev/null ++++ b/orfs_asap7/liberty.py +@@ -0,0 +1,439 @@ ++#!/usr/bin/env python3 ++"""Emit a Liberty (.lib) view for a memory macro. ++ ++The structural shape (bus() umbrellas WITH per-bit pin() siblings, ++quoted simple type names, per-port clock pins carrying ++min/max_clock_tree_path arcs) follows what OpenROAD's abstract writer ++produces for a hardened block. The per-bit pins are load-bearing: a ++bus() block without per-bit pin records makes yosys's bus elaboration ++silently drop bit connections at parent instances of the macro, leaving ++the synthesized parent with only scalar clk/en pins on every memory. ++ ++internal_power() records use power_lut_template — OpenSTA's ++report_power -saif looks power templates up in the power-template ++registry, and a power table declared under lu_table_template is ++silently read as zero. ++ ++Timing values come from simple parametric predictors (log2(rows) decode ++depth, √bits bit-line/sense scaling), conservative and monotonic. Two ++variants are emitted per memory: the regular .lib with a predicted ++clock-tree insertion delay, and a _pre_layout.lib with ideal clock ++(insertion = 0) for pre-CTS consumers. ++""" ++ ++from __future__ import annotations ++ ++import math ++import re ++ ++from . import schema ++from . import sram_area_model ++from .pdk_asap7 import PdkParams ++ ++_FUNCTION_ORDER = { ++ "addr": 0, ++ "en": 1, ++ "wmode": 2, ++ "mask": 3, ++ "data_in": 4, ++ "data_out": 5, ++ "clk": 6, ++} ++_SUBWORD_INDEX_RE = re.compile(r"_(\d+)$") ++ ++ ++def _subword_index(name: str) -> int: ++ m = _SUBWORD_INDEX_RE.search(name) ++ return int(m.group(1)) if m else -1 ++ ++ ++def ordered_port_groups(mem: schema.Memory) -> list[tuple[str, list[schema.Pin]]]: ++ """Group pins by port id, in canonical order. ++ ++ Ports are ordered R, W, RW by index; pins within a port follow the ++ firtool convention order (addr, en, wmode, mask, data, clk), with ++ subword-split pins ordered by their index. ++ """ ++ groups: dict[str, list[schema.Pin]] = {} ++ for p in mem.pins: ++ groups.setdefault(p.port_id, []).append(p) ++ ++ def port_key(port_id: str) -> tuple[int, int]: ++ if port_id.startswith("RW"): ++ return (2, int(port_id[2:])) ++ if port_id.startswith("R"): ++ return (0, int(port_id[1:])) ++ return (1, int(port_id[1:])) ++ ++ out: list[tuple[str, list[schema.Pin]]] = [] ++ for port_id in sorted(groups, key=port_key): ++ pins = sorted( ++ groups[port_id], ++ key=lambda p: (_FUNCTION_ORDER[p.function], _subword_index(p.name), p.name), ++ ) ++ out.append((port_id, pins)) ++ return out ++ ++ ++def _is_bus(pin: schema.Pin) -> bool: ++ return pin.width > 1 and pin.function in ("addr", "data_in", "data_out", "mask") ++ ++ ++# --------------------------------------------------------------------------- ++# Parametric predictors (picoseconds, µm², fJ, pW). ++# --------------------------------------------------------------------------- ++ ++ ++def _scale_tech(tech_nm: int) -> float: ++ return tech_nm / 7.0 ++ ++ ++def predict_access_ps(rows: int, bits: int, ports_total: int, tech_nm: int) -> float: ++ s = _scale_tech(tech_nm) ++ rows_eff = max(rows, 2) ++ bits_eff = max(bits, 1) ++ # Decoder + bitline + sense amp scaling. ++ base = 80.0 * s ++ base += 8.0 * s * math.log2(rows_eff) ++ base += 3.0 * s * math.sqrt(bits_eff) ++ base += 2.0 * s * max(ports_total - 1, 0) ++ return base ++ ++ ++def predict_setup_ps(tech_nm: int) -> float: ++ return 12.0 * _scale_tech(tech_nm) ++ ++ ++def predict_hold_ps(tech_nm: int) -> float: ++ return 6.0 * _scale_tech(tech_nm) ++ ++ ++def predict_transition_ps(tech_nm: int) -> float: ++ return 7.0 * _scale_tech(tech_nm) ++ ++ ++def predict_clk_period_ps( ++ rows: int, bits: int, ports_total: int, tech_nm: int ++) -> float: ++ return max( ++ predict_access_ps(rows, bits, ports_total, tech_nm) ++ + predict_setup_ps(tech_nm) ++ + 30.0, ++ 150.0 * _scale_tech(tech_nm), ++ ) ++ ++ ++def predict_ck_insertion_ps(tech_nm: int) -> float: ++ return 80.0 * _scale_tech(tech_nm) ++ ++ ++def predict_area_um2(mem: schema.Memory, tech_nm: int) -> float: ++ return sram_area_model.sram_area_um2( ++ rows=max(mem.rows, 1), ++ cols=max(mem.bits, 1), ++ # An RW port counts as both a read and a write port for the ++ # multi-port factor. ++ read_ports=mem.read_ports + mem.rw_ports, ++ write_ports=mem.write_ports + mem.rw_ports, ++ tech_nm=tech_nm, ++ write_mask_lanes=mem.mask_lanes, ++ ) ++ ++ ++def predict_dimensions_um(area_um2: float) -> tuple[float, float]: ++ return sram_area_model.sram_dimensions_um(area_um2, aspect_ratio=0.5) ++ ++ ++def predict_read_energy_fj(rows: int, bits: int, tech_nm: int) -> float: ++ s = _scale_tech(tech_nm) ++ return (1.5 + 0.25 * math.log2(max(rows, 2))) * max(bits, 1) * s ++ ++ ++def predict_write_energy_fj(rows: int, bits: int, tech_nm: int) -> float: ++ s = _scale_tech(tech_nm) ++ return (2.5 + 0.30 * math.log2(max(rows, 2))) * max(bits, 1) * s ++ ++ ++def predict_leakage_pw(rows: int, bits: int, ports_total: int, tech_nm: int) -> float: ++ s = _scale_tech(tech_nm) ++ rows_eff = max(rows, 2) ++ bits_eff = max(bits, 1) ++ # Leakage scales with bitcell count + sense amp count per port. ++ return (rows_eff * bits_eff * 0.05 + ports_total * bits_eff * 2.0) * s ++ ++ ++# --------------------------------------------------------------------------- ++# .lib emit ++# --------------------------------------------------------------------------- ++ ++ ++def emit_lib( ++ mem: schema.Memory, params: PdkParams, *, ck_insertion_ps: float | None = None ++) -> str: ++ """Return Liberty text for `mem`. ++ ++ `ck_insertion_ps` overrides the predicted clock-tree insertion ++ delay. Pass 0.0 for the pre-layout (ideal-clock) variant; None for ++ the default prediction. ++ """ ++ name = mem.name ++ tech_nm = params.tech_nm ++ ports_total = mem.total_ports() ++ ++ access_ps = predict_access_ps(mem.rows, mem.bits, ports_total, tech_nm) ++ setup_ps = predict_setup_ps(tech_nm) ++ hold_ps = predict_hold_ps(tech_nm) ++ trans_ps = predict_transition_ps(tech_nm) ++ clk_period_ps = predict_clk_period_ps(mem.rows, mem.bits, ports_total, tech_nm) ++ ck_ins_ps = ( ++ ck_insertion_ps ++ if ck_insertion_ps is not None ++ else predict_ck_insertion_ps(tech_nm) ++ ) ++ area = predict_area_um2(mem, tech_nm) ++ leak_pw = predict_leakage_pw(mem.rows, mem.bits, ports_total, tech_nm) ++ read_e = predict_read_energy_fj(mem.rows, mem.bits, tech_nm) ++ write_e = predict_write_energy_fj(mem.rows, mem.bits, tech_nm) ++ avg_edge_e = (read_e + write_e) / 2.0 ++ per_bit_e = max(read_e / max(mem.bits, 1), 0.5) ++ ++ groups = ordered_port_groups(mem) ++ ++ lines: list[str] = [] ++ a = lines.append ++ ++ a(f"library ({name}) {{") ++ a(' comment : "";') ++ a(" delay_model : table_lookup;") ++ a(" simulation : false;") ++ a(" capacitive_load_unit (1,fF);") ++ a(" leakage_power_unit : 1pW;") ++ a(' current_unit : "1mA";') ++ a(' pulling_resistance_unit : "1kohm";') ++ a(' time_unit : "1ps";') ++ a(' voltage_unit : "1v";') ++ a(" library_features(report_delay_calculation);") ++ a("") ++ a(" input_threshold_pct_rise : 50;") ++ a(" input_threshold_pct_fall : 50;") ++ a(" output_threshold_pct_rise : 50;") ++ a(" output_threshold_pct_fall : 50;") ++ a(" slew_lower_threshold_pct_rise : 10;") ++ a(" slew_lower_threshold_pct_fall : 10;") ++ a(" slew_upper_threshold_pct_rise : 90;") ++ a(" slew_upper_threshold_pct_fall : 90;") ++ a(" slew_derate_from_library : 1.0;") ++ a("") ++ a("") ++ a(" nom_process : 1.0;") ++ a(" nom_temperature : 0.0;") ++ a(f" nom_voltage : {params.nom_voltage};") ++ a("") ++ a(f" default_cell_leakage_power : {leak_pw:.6g};") ++ a(f" default_max_transition : {trans_ps * 4:.6g};") ++ a("") ++ a(" lu_table_template(template_1) {") ++ a(" variable_1 : total_output_net_capacitance;") ++ a(' index_1("1.0, 2.0");') ++ a(" }") ++ a(" lu_table_template(constraint_template) {") ++ a(" variable_1 : related_pin_transition;") ++ a(" variable_2 : constrained_pin_transition;") ++ a(' index_1("0.001, 0.1");') ++ a(' index_2("0.001, 0.1");') ++ a(" }") ++ a(" power_lut_template(power_template) {") ++ a(" variable_1 : input_transition_time;") ++ a(' index_1("0.001, 0.1");') ++ a(" }") ++ ++ # type ("name") — quoted simple pin name, NOT scoped. ++ for _port_id, pins in groups: ++ for pin in pins: ++ if _is_bus(pin): ++ a(f' type ("{pin.name}") {{') ++ a(" base_type : array;") ++ a(" data_type : bit;") ++ a(f" bit_width : {pin.width};") ++ a(f" bit_from : {pin.width - 1};") ++ a(" bit_to : 0;") ++ a(" }") ++ ++ a(f' cell ("{name}") {{') ++ a(f" area : {area:.4f};") ++ a(" interface_timing : true;") ++ a(" memory() {") ++ a(" type : ram;") ++ a(f" address_width : {max(mem.addr_w, 1)};") ++ a(f" word_width : {max(mem.bits, 1)};") ++ a(" }") ++ # PG-as-input pins keep the parent's PDN checker quiet. ++ a(' pin ("VDD") {') ++ a(" direction : input;") ++ a(" capacitance : 0.0000;") ++ a(" }") ++ a(' pin ("VSS") {') ++ a(" direction : input;") ++ a(" capacitance : 0.0000;") ++ a(" }") ++ ++ for _port_id, pins in groups: ++ clk_pins = [p for p in pins if p.function == "clk"] ++ clk_pn = clk_pins[0].name ++ a(f' pin ("{clk_pn}") {{') ++ a(" direction : input;") ++ a(" clock : true;") ++ a(" capacitance : 2;") ++ a(f" min_period : {clk_period_ps:.4f};") ++ a(" internal_power() {") ++ a(' when : "1";') ++ a(" rise_power(power_template) {") ++ a(f' values ("{avg_edge_e:.4f}, {avg_edge_e:.4f}");') ++ a(" }") ++ a(" fall_power(power_template) {") ++ a(f' values ("{avg_edge_e:.4f}, {avg_edge_e:.4f}");') ++ a(" }") ++ a(" }") ++ for ttype in ("min_clock_tree_path", "max_clock_tree_path"): ++ a(" timing() {") ++ a(" timing_sense : positive_unate;") ++ a(f" timing_type : {ttype};") ++ a(" cell_rise(scalar) {") ++ a(f' values("{ck_ins_ps:.4f}");') ++ a(" }") ++ a(" cell_fall(scalar) {") ++ a(f' values("{ck_ins_ps:.4f}");') ++ a(" }") ++ a(" }") ++ a(" }") ++ ++ for pin in pins: ++ if pin.function == "clk": ++ continue ++ if _is_bus(pin): ++ # Bus umbrella + per-bit pin records. The bus-close `}` ++ # lives at 4-space indent (sibling of the inner pins), ++ # matching OpenROAD's abstract-writer shape. ++ a(f' bus ("{pin.name}") {{') ++ a(f" bus_type : {pin.name};") ++ a(f" direction : {pin.direction};") ++ a(" capacitance : 0.0000;") ++ for bit in range(pin.width - 1, -1, -1): ++ bit_name = f"{pin.name}[{bit}]" ++ if pin.direction == "output": ++ a(f' pin ("{bit_name}") {{') ++ a(" direction : output;") ++ a(" capacitance : 0.0000;") ++ a(" timing() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(" timing_type : rising_edge;") ++ a(" timing_sense : non_unate;") ++ a(" cell_rise(scalar) {") ++ a(f' values("{access_ps:.4f}");') ++ a(" }") ++ a(" cell_fall(scalar) {") ++ a(f' values("{access_ps:.4f}");') ++ a(" }") ++ a(" rise_transition(scalar) {") ++ a(f' values("{trans_ps:.4f}");') ++ a(" }") ++ a(" fall_transition(scalar) {") ++ a(f' values("{trans_ps:.4f}");') ++ a(" }") ++ a(" }") ++ a(" }") ++ else: ++ a(f' pin ("{bit_name}") {{') ++ a(" direction : input;") ++ a(" capacitance : 1;") ++ a(" timing() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(" timing_type : setup_rising;") ++ a(" rise_constraint(scalar) {") ++ a(f' values("{setup_ps:.4f}");') ++ a(" }") ++ a(" fall_constraint(scalar) {") ++ a(f' values("{setup_ps:.4f}");') ++ a(" }") ++ a(" }") ++ a(" timing() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(" timing_type : hold_rising;") ++ a(" rise_constraint(scalar) {") ++ a(f' values("{hold_ps:.4f}");') ++ a(" }") ++ a(" fall_constraint(scalar) {") ++ a(f' values("{hold_ps:.4f}");') ++ a(" }") ++ a(" }") ++ a(" internal_power() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(' when : "1";') ++ a(" rise_power(power_template) {") ++ a(f' values ("{per_bit_e:.4f}, {per_bit_e:.4f}");') ++ a(" }") ++ a(" fall_power(power_template) {") ++ a(f' values ("{per_bit_e:.4f}, {per_bit_e:.4f}");') ++ a(" }") ++ a(" }") ++ a(" }") ++ a(" }") # close bus ++ else: ++ # Scalar pin (en, wmode, 1-bit mask lane, 1-bit data). ++ a(f' pin ("{pin.name}") {{') ++ a(f" direction : {pin.direction};") ++ a(" capacitance : 1;") ++ if pin.direction == "output": ++ a(" timing() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(" timing_type : rising_edge;") ++ a(" timing_sense : non_unate;") ++ a(" cell_rise(scalar) {") ++ a(f' values("{access_ps:.4f}");') ++ a(" }") ++ a(" cell_fall(scalar) {") ++ a(f' values("{access_ps:.4f}");') ++ a(" }") ++ a(" rise_transition(scalar) {") ++ a(f' values("{trans_ps:.4f}");') ++ a(" }") ++ a(" fall_transition(scalar) {") ++ a(f' values("{trans_ps:.4f}");') ++ a(" }") ++ a(" }") ++ else: ++ a(" timing() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(" timing_type : setup_rising;") ++ a(" rise_constraint(scalar) {") ++ a(f' values("{setup_ps:.4f}");') ++ a(" }") ++ a(" fall_constraint(scalar) {") ++ a(f' values("{setup_ps:.4f}");') ++ a(" }") ++ a(" }") ++ a(" timing() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(" timing_type : hold_rising;") ++ a(" rise_constraint(scalar) {") ++ a(f' values("{hold_ps:.4f}");') ++ a(" }") ++ a(" fall_constraint(scalar) {") ++ a(f' values("{hold_ps:.4f}");') ++ a(" }") ++ a(" }") ++ a(" internal_power() {") ++ a(f' related_pin : "{clk_pn}";') ++ a(' when : "1";') ++ a(" rise_power(power_template) {") ++ a(f' values ("{per_bit_e:.4f}, {per_bit_e:.4f}");') ++ a(" }") ++ a(" fall_power(power_template) {") ++ a(f' values ("{per_bit_e:.4f}, {per_bit_e:.4f}");') ++ a(" }") ++ a(" }") ++ a(" }") ++ a(" }") # close cell ++ a("}") # close library ++ a("") ++ return "\n".join(lines) +diff --git a/orfs_asap7/pdk_asap7.py b/orfs_asap7/pdk_asap7.py +new file mode 100644 +index 0000000..1fa1335 +--- /dev/null ++++ b/orfs_asap7/pdk_asap7.py +@@ -0,0 +1,72 @@ ++#!/usr/bin/env python3 ++"""PDK parameters for generated memory macro .lib/.lef files. ++ ++The emitters in liberty.py / lef.py are structured generally and take a ++PdkParams; this module holds what is platform-specific. Only asap7 is ++supported — its values match the conventions of the checked-in ++platforms/asap7 fakeram7_* artifacts (signal pins and power straps on ++M4, where the platform's PDN macro grid connects M4 to the M5 stripes). ++Supporting another PDK means adding a PdkParams for it here; a ++first-pass separation of general and asap7 concerns, generalization is ++future work. ++""" ++ ++from __future__ import annotations ++ ++from dataclasses import dataclass ++ ++ ++@dataclass(frozen=True) ++class PdkParams: ++ name: str ++ tech_nm: int ++ nom_voltage: float ++ # Layer signal/clock pins are drawn on, and the pin pad size / ++ # pitch along the macro edge. ++ pin_layer: str ++ pin_pad_um: float ++ pin_pitch_um: float ++ # Manufacturing-grid snap for the macro outline. ++ grid_um: float ++ # Horizontal power/ground straps: layer, width, per-net pitch, and ++ # the VDD offset relative to VSS (interleaved straps). ++ pg_layer: str ++ pg_strap_w_um: float ++ pg_strap_pitch_um: float ++ pg_vdd_offset_um: float ++ pg_margin_um: float ++ # Layers obstructed over the full macro footprint. Device-recognition ++ # layers go in their own tuple (empty for PDKs that do not define ++ # them — naming an unknown layer fails the parent's OpenDB LEF read). ++ obs_metal_layers: tuple[str, ...] ++ obs_device_layers: tuple[str, ...] ++ ++ ++ASAP7 = PdkParams( ++ name="asap7", ++ tech_nm=7, ++ nom_voltage=0.77, ++ pin_layer="M4", ++ pin_pad_um=0.024, ++ pin_pitch_um=0.048, ++ grid_um=0.024, ++ pg_layer="M4", ++ pg_strap_w_um=0.096, ++ pg_strap_pitch_um=0.768, ++ pg_vdd_offset_um=0.384, ++ pg_margin_um=0.048, ++ obs_metal_layers=("M1", "M2", "M3", "M4"), ++ obs_device_layers=(), ++) ++ ++PLATFORMS = {p.name: p for p in (ASAP7,)} ++ ++ ++def get_platform(name: str) -> PdkParams: ++ try: ++ return PLATFORMS[name] ++ except KeyError: ++ raise ValueError( ++ f"AUTO_MEMORIES supports only {', '.join(sorted(PLATFORMS))}; " ++ f"got platform '{name}'" ++ ) from None +diff --git a/orfs_asap7/schema.py b/orfs_asap7/schema.py +new file mode 100644 +index 0000000..2316564 +--- /dev/null ++++ b/orfs_asap7/schema.py +@@ -0,0 +1,196 @@ ++#!/usr/bin/env python3 ++"""The `.memories` file format: a JSON inventory of memory macros. ++ ++One `.memories` file (and the flow-generated `memories.json`) describes ++memories at the module boundary: geometry, the full pin list with each ++pin's function, the behavioral model the RTL provides, and whether the ++memory is converted to a macro (`idiomatic`). ++ ++The file-based handoff is deliberate: everything downstream — synthesis ++blackboxing, liberty/LEF consumption in later stages, build systems that ++declare flow artifacts as transitive dependencies — keys off these files ++rather than off in-process state. ++""" ++ ++from __future__ import annotations ++ ++import json ++from dataclasses import asdict, dataclass, field ++from pathlib import Path ++ ++SCHEMA_VERSION = 1 ++ ++PIN_FUNCTIONS = ("addr", "en", "wmode", "mask", "data_in", "data_out", "clk") ++DIRECTIONS = ("input", "output") ++ ++ ++class SchemaError(ValueError): ++ pass ++ ++ ++@dataclass ++class Pin: ++ name: str ++ direction: str # "input" / "output" ++ width: int ++ port_id: str # "R0", "W1", "RW0", ... ++ function: str # one of PIN_FUNCTIONS ++ ++ ++@dataclass ++class Memory: ++ name: str ++ rows: int = 0 ++ bits: int = 0 ++ addr_w: int = 0 ++ read_ports: int = 0 ++ write_ports: int = 0 ++ rw_ports: int = 0 ++ # Total width of the per-port write mask, 0 if unmasked. Subword-split ++ # mask pins (`W0_mask_0..3`) contribute one lane each. ++ mask_lanes: int = 0 ++ pins: list[Pin] = field(default_factory=list) ++ # The module whose RTL body simulates this memory: {"file", "module"}. ++ behavioral_model: dict | None = None ++ # True when the memory is converted to a macro (.lib/.lef emitted and ++ # the module blackboxed); False leaves it to synthesize as flops. ++ idiomatic: bool = False ++ reason: str = "" ++ ++ def total_ports(self) -> int: ++ return self.read_ports + self.write_ports + self.rw_ports ++ ++ def to_dict(self) -> dict: ++ return asdict(self) ++ ++ ++def _pin_from_dict(d: dict) -> Pin: ++ try: ++ pin = Pin( ++ name=d["name"], ++ direction=d["direction"], ++ width=int(d["width"]), ++ port_id=d["port_id"], ++ function=d["function"], ++ ) ++ except KeyError as e: ++ raise SchemaError(f"pin record missing field {e}") from e ++ if pin.direction not in DIRECTIONS: ++ raise SchemaError(f"pin {pin.name}: bad direction '{pin.direction}'") ++ if pin.function not in PIN_FUNCTIONS: ++ raise SchemaError( ++ f"pin {pin.name}: unknown function " ++ f"'{pin.function}' (expected one of " ++ f"{', '.join(PIN_FUNCTIONS)})" ++ ) ++ if pin.width < 1: ++ raise SchemaError(f"pin {pin.name}: width must be >= 1") ++ if not pin.port_id: ++ raise SchemaError(f"pin {pin.name}: empty port_id") ++ return pin ++ ++ ++def memory_from_dict(d: dict) -> Memory: ++ if "name" not in d: ++ raise SchemaError("memory record missing 'name'") ++ mem = Memory(name=d["name"]) ++ for key in ( ++ "rows", ++ "bits", ++ "addr_w", ++ "read_ports", ++ "write_ports", ++ "rw_ports", ++ "mask_lanes", ++ ): ++ if key in d: ++ setattr(mem, key, int(d[key])) ++ if "pins" in d: ++ mem.pins = [_pin_from_dict(p) for p in d["pins"]] ++ if "behavioral_model" in d: ++ mem.behavioral_model = d["behavioral_model"] ++ if "idiomatic" in d: ++ mem.idiomatic = bool(d["idiomatic"]) ++ if "reason" in d: ++ mem.reason = str(d["reason"]) ++ return mem ++ ++ ++def load(path: Path) -> list[Memory]: ++ """Load a .memories / memories.json file.""" ++ try: ++ doc = json.loads(path.read_text()) ++ except json.JSONDecodeError as e: ++ raise SchemaError(f"{path}: not valid JSON: {e}") from e ++ if not isinstance(doc, dict) or "memories" not in doc: ++ raise SchemaError(f"{path}: expected an object with a 'memories' list") ++ version = doc.get("version", SCHEMA_VERSION) ++ if version != SCHEMA_VERSION: ++ raise SchemaError(f"{path}: unsupported schema version {version}") ++ return [memory_from_dict(m) for m in doc["memories"]] ++ ++ ++def dump(memories: list[Memory], path: Path, platform: str) -> None: ++ doc = { ++ "version": SCHEMA_VERSION, ++ "platform": platform, ++ "memories": [m.to_dict() for m in sorted(memories, key=lambda m: m.name)], ++ } ++ path.write_text(json.dumps(doc, indent=2) + "\n") ++ ++ ++def merge(detected: list[Memory], overrides: list[Memory]) -> list[Memory]: ++ """Merge user-supplied entries onto the detected set. ++ ++ An override naming a detected memory replaces only the fields it ++ carries (geometry and pins are kept from detection unless the ++ override provides its own). An override naming an unknown memory is ++ taken whole — it must then carry pins and geometry itself to be ++ emittable. ++ """ ++ by_name = {m.name: m for m in detected} ++ for o in overrides: ++ base = by_name.get(o.name) ++ if base is None: ++ by_name[o.name] = o ++ continue ++ if o.pins: ++ base.pins = o.pins ++ for key in ( ++ "rows", ++ "bits", ++ "addr_w", ++ "read_ports", ++ "write_ports", ++ "rw_ports", ++ "mask_lanes", ++ ): ++ value = getattr(o, key) ++ if value: ++ setattr(base, key, value) ++ if o.behavioral_model is not None: ++ base.behavioral_model = o.behavioral_model ++ base.idiomatic = o.idiomatic ++ if o.reason: ++ base.reason = o.reason ++ return list(by_name.values()) ++ ++ ++def validate_emittable(mem: Memory) -> None: ++ """Raise SchemaError unless `mem` carries enough to emit .lib/.lef.""" ++ if not mem.pins: ++ raise SchemaError(f"memory {mem.name}: no pins") ++ if mem.rows < 1 or mem.bits < 1: ++ raise SchemaError( ++ f"memory {mem.name}: rows ({mem.rows}) and bits ({mem.bits}) " ++ f"must be >= 1" ++ ) ++ port_ids = {p.port_id for p in mem.pins} ++ for port_id in port_ids: ++ functions = {p.function for p in mem.pins if p.port_id == port_id} ++ if "clk" not in functions: ++ raise SchemaError(f"memory {mem.name}: port {port_id} has no clk pin") ++ if "addr" not in functions: ++ raise SchemaError(f"memory {mem.name}: port {port_id} has no addr pin") ++ if not functions & {"data_in", "data_out"}: ++ raise SchemaError(f"memory {mem.name}: port {port_id} has no data pin") +diff --git a/orfs_asap7/sram_area_model.py b/orfs_asap7/sram_area_model.py +new file mode 100644 +index 0000000..dd79076 +--- /dev/null ++++ b/orfs_asap7/sram_area_model.py +@@ -0,0 +1,136 @@ ++#!/usr/bin/env python3 ++"""Parametric 7 nm-class SRAM area/power model. ++ ++Coefficients are anchored to published 7 nm SP-SRAM figures (Suzuki et ++al., ISSCC 2018, "A 6T-SRAM in 7 nm FinFET CMOS" — ~0.027 µm² per bit ++cell; published foundry SRAM IP figures for peripheral overhead). The ++model represents what a real 7 nm SRAM compiler would emit for a given ++(rows, cols, ports) spec. ++""" ++ ++from __future__ import annotations ++ ++import math ++ ++# ASAP7 NAND2xp33_ASAP7_75t_R area — the smallest NAND2 in the ASAP7 RVT ++# library, the customary gate-equivalent unit for converting µm² to GE. ++NAND2_AREA_UM2 = 0.05832 ++ ++# Calibration coefficients. Tuned against published 7 nm SP-SRAM ++# references; ±10 % at the anchor and ±25 % over a 5–256 Kb sweep. ++_STORAGE_PER_BIT_UM2 = 0.030 # 7 nm-class 6T bit cell. ++_PERIMETER_COEFF = 5.0 # row decode + sense amps + write drivers. ++_CONTROL_FIXED_UM2 = 200.0 # control + redundancy floor for tiny memories. ++_PORT_FACTOR_READ = 0.25 # +25 % per extra read port. ++_PORT_FACTOR_WRITE = 0.35 # +35 % per extra write port. ++# Per-mask-lane bit-write-enable overhead. A masked-write SRAM gains a ++# column-group write-driver gate + the mask wire routed up each column ++# group; real 7 nm SP-SRAM compilers cost a per-lane write-mask option at ++# roughly one extra write-driver per lane. Modeled as a flat per-lane ++# peripheral adder so a wide masked macro is not scored identically to an ++# unmasked array of the same (rows, cols). Zero lanes (the default) ++# reproduces the unmasked model exactly. ++_WRITE_MASK_PER_LANE_UM2 = NAND2_AREA_UM2 * 4.0 # ~4 NAND2/lane. ++ ++# Power-model anchors. Leakage is anchored at 85 °C because published ++# 7 nm SRAM IP standby numbers are typically datasheet-quoted at 85 °C, ++# not 25 °C. ++_LEAKAGE_PER_BIT_PW_85C = 100.0 # mid of published 7 nm range 50-200 pW. ++_LEAKAGE_SENSE_AMP_NW_PER_PORT = 5.0 # per-port sense-amp leakage adder. ++_DYNAMIC_FJ_PER_COL = 1.0 # ~1 fJ per accessed-row column at 0.7 V. ++_DYNAMIC_WL_FJ = 5.0 # word-line drive per access. ++ ++ ++def sram_area_um2( ++ rows: int, ++ cols: int, ++ read_ports: int = 1, ++ write_ports: int = 1, ++ tech_nm: int = 7, ++ write_mask_lanes: int = 0, ++) -> float: ++ """Estimate SP/MP-SRAM macro area in µm². ++ ++ Storage scales linearly with bits and the multi-port factor. ++ Peripheral (decode + sense + write drivers) scales with √bits — real ++ SRAM compilers fold columns internally to keep the physical array ++ near-square, so the cost is perimeter-shaped regardless of the ++ logical (rows, cols) the caller asked for. ++ ++ The coefficients are anchored at 7 nm. For other nodes the result is ++ multiplied by an ideal area-scale (tech_nm / 7)² — a first-order ++ estimate, NOT a re-fit against that node's SRAM IP (advanced-node ++ SRAM famously scales worse than ideal). ++ """ ++ if rows < 1 or cols < 1: ++ raise ValueError(f"rows ({rows}) and cols ({cols}) must be >= 1") ++ if read_ports < 0 or write_ports < 0: ++ raise ValueError( ++ f"port counts must be >= 0; got " ++ f"read_ports={read_ports}, write_ports={write_ports}" ++ ) ++ if tech_nm < 1: ++ raise ValueError(f"tech_nm ({tech_nm}) must be >= 1") ++ if write_mask_lanes < 0: ++ raise ValueError(f"write_mask_lanes ({write_mask_lanes}) must be >= 0") ++ bits = rows * cols ++ port_factor = ( ++ 1.0 ++ + _PORT_FACTOR_READ * max(read_ports - 1, 0) ++ + _PORT_FACTOR_WRITE * max(write_ports - 1, 0) ++ ) ++ storage = bits * _STORAGE_PER_BIT_UM2 * port_factor ++ perimeter = math.sqrt(bits) ++ peripheral = perimeter * _PERIMETER_COEFF * port_factor + _CONTROL_FIXED_UM2 ++ # Per-write-port mask overhead: each write port carries ++ # `write_mask_lanes` bit-write-enable gates. ++ peripheral += write_mask_lanes * _WRITE_MASK_PER_LANE_UM2 * max(write_ports, 0) ++ node_area_scale = (tech_nm / 7.0) ** 2 ++ return (storage + peripheral) * node_area_scale ++ ++ ++def sram_leakage_pw(rows: int, cols: int, ports: int = 1) -> float: ++ """Standby leakage at 85 °C TT for a 7 nm-class SRAM macro, in pW. ++ ++ Per-bit leakage anchors at ~100 pW (mid of the published 7 nm IP ++ range of 50-200 pW at 85 °C). Sense-amp leakage adds ~5 nW per port. ++ """ ++ if rows < 1 or cols < 1: ++ raise ValueError(f"rows ({rows}) and cols ({cols}) must be >= 1") ++ if ports < 1: ++ raise ValueError(f"ports ({ports}) must be >= 1") ++ bits = rows * cols ++ storage_pw = bits * _LEAKAGE_PER_BIT_PW_85C ++ sense_amp_pw = ports * _LEAKAGE_SENSE_AMP_NW_PER_PORT * 1000.0 # nW → pW ++ return storage_pw + sense_amp_pw ++ ++ ++def sram_dynamic_pj_per_access(rows: int, cols: int) -> float: ++ """Per-access switching energy at 0.7 V, in pJ. ++ ++ One read or write activates a single row; all `cols` bit-line ++ columns toggle. Word-line drive adds a small fixed term. Port count ++ is deliberately not a parameter: each access costs one ++ row-activation regardless of how many ports the macro exposes — the ++ access *rate* is the caller's knob. ++ """ ++ if rows < 1 or cols < 1: ++ raise ValueError(f"rows ({rows}) and cols ({cols}) must be >= 1") ++ bitline_fj = cols * _DYNAMIC_FJ_PER_COL ++ return (bitline_fj + _DYNAMIC_WL_FJ) / 1000.0 # fJ → pJ ++ ++ ++def sram_dimensions_um( ++ area_um2: float, aspect_ratio: float = 0.5 ++) -> tuple[float, float]: ++ """Return (width, height) in µm given total area and aspect ratio. ++ ++ ``aspect_ratio`` is height/width (0.5 → wider than tall). ++ """ ++ if area_um2 <= 0: ++ raise ValueError(f"area_um2 must be positive; got {area_um2}") ++ if aspect_ratio <= 0: ++ raise ValueError(f"aspect_ratio must be positive; got {aspect_ratio}") ++ height = math.sqrt(area_um2 * aspect_ratio) ++ width = area_um2 / height ++ return width, height +diff --git a/run.py b/run.py +index f525c5f..06ae626 100755 +--- a/run.py ++++ b/run.py +@@ -8,6 +8,7 @@ from utils.class_process import Process + from utils.memory_config import MemoryConfig + from utils.memory_factory import MemoryFactory + from utils.timing_data import TimingData ++from orfs_asap7.generate import run_orfs_asap7 + + + def get_args() -> argparse.Namespace: +@@ -28,10 +29,19 @@ def get_args() -> argparse.Namespace: + required=False, + default="results", + ) ++ parser.add_argument( ++ "--orfs_asap7_backend", ++ action="store_true", ++ help="Use ORFS ASAP7 backend (expects JSON in memories.json format)", ++ ) + return parser.parse_args() + + + def main(args: argparse.Namespace): ++ if args.orfs_asap7_backend: ++ from pathlib import Path ++ return run_orfs_asap7(platform="asap7", out_dir=Path(args.output_dir), json_path=Path(args.config)) ++ + json_data = RunUtils.get_config(args.config) + # Create a process object (shared by all srams) + process = Process(json_data) +@@ -52,4 +62,4 @@ def main(args: argparse.Namespace): + ### Entry point + if __name__ == "__main__": + args = get_args() +- main(args) ++ sys.exit(main(args)) diff --git a/patches/fakeram/BUILD.bazel b/patches/fakeram/BUILD.bazel new file mode 100644 index 0000000000..89af6bdfcc --- /dev/null +++ b/patches/fakeram/BUILD.bazel @@ -0,0 +1,3 @@ +"""FakeRAM2.0 patches carried for ORFS AUTO_MEMORIES support.""" + +exports_files(glob(["*.patch"]))