From 67a9db2b7485023768bd7ab379d0943da62c1172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Sun, 19 Jul 2026 18:02:08 +0200 Subject: [PATCH 1/2] flow: AUTO_MEMORIES generated memory macros (experimental) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect memory-shaped modules (firtool R*/W*/RW*_ port convention) in the RTL before synthesis, inventory them in results/memories.json, and generate .lib/.lef macro views for the ones idiomatic as SRAM macros. Synthesis blackboxes the converted modules so the liberty view wins over their behavioral bodies; floorplan through final read the generated views. ADDITIONAL_MEMORIES merges user-supplied .memories files onto the detected set, e.g. to force-convert a memory the idiomatic gate rejects. Together with RTL-MP macro placement this gives macro-based physical design results on an existing design without a memory compiler or hand-maintained fakeram files. asap7 only; the emitters are split into general structure and asap7 parameters as a first pass at separating PDK concerns. Banking and yosys/OpenROAD-SYN-based memory detection are documented as future work. The new designs/asap7/tinyRocket design demonstrates the flow end to end with no platform memory-mapping file and no checked-in fakerams; its tag_array forces conversion via a .memories override because the generic RTL has no behavioral fallback for it. Tested with `bazelisk test //flow:memories_tests` (detector, schema, idiomatic gate, and .lib/.lef emitter unit tests) and `make DESIGN_CONFIG=designs/asap7/tinyRocket/config.mk floorplan`. Signed-off-by: Øyvind Harboe --- docs/toc.yml | 2 + docs/user/AutoMemories.md | 151 ++++++ docs/user/FlowVariables.md | 4 + flow/BUILD | 82 +++- flow/Makefile | 23 + flow/designs/asap7/tinyRocket/BUILD | 3 + flow/designs/asap7/tinyRocket/config.mk | 30 ++ flow/designs/asap7/tinyRocket/constraint.sdc | 3 + .../asap7/tinyRocket/tag_array.memories | 10 + flow/designs/design.bzl | 2 +- flow/scripts/load.tcl | 7 + flow/scripts/memories/detect.py | 203 ++++++++ flow/scripts/memories/detect_test.py | 164 +++++++ flow/scripts/memories/gen_memories.py | 131 ++++++ flow/scripts/memories/gen_memories_test.py | 137 ++++++ flow/scripts/memories/idiomatic.py | 49 ++ flow/scripts/memories/idiomatic_test.py | 53 +++ flow/scripts/memories/lef.py | 115 +++++ flow/scripts/memories/lef_test.py | 85 ++++ flow/scripts/memories/liberty.py | 439 ++++++++++++++++++ flow/scripts/memories/liberty_test.py | 101 ++++ flow/scripts/memories/pdk_asap7.py | 72 +++ flow/scripts/memories/schema.py | 196 ++++++++ flow/scripts/memories/schema_test.py | 120 +++++ flow/scripts/memories/sram_area_model.py | 136 ++++++ flow/scripts/memories/sram_area_model_test.py | 71 +++ flow/scripts/read_liberty.tcl | 14 + flow/scripts/synth_preamble.tcl | 40 ++ flow/scripts/variables.json | 10 + flow/scripts/variables.yaml | 21 + 30 files changed, 2461 insertions(+), 13 deletions(-) create mode 100644 docs/user/AutoMemories.md create mode 100644 flow/designs/asap7/tinyRocket/BUILD create mode 100644 flow/designs/asap7/tinyRocket/config.mk create mode 100644 flow/designs/asap7/tinyRocket/constraint.sdc create mode 100644 flow/designs/asap7/tinyRocket/tag_array.memories create mode 100644 flow/scripts/memories/detect.py create mode 100644 flow/scripts/memories/detect_test.py create mode 100644 flow/scripts/memories/gen_memories.py create mode 100644 flow/scripts/memories/gen_memories_test.py create mode 100644 flow/scripts/memories/idiomatic.py create mode 100644 flow/scripts/memories/idiomatic_test.py create mode 100644 flow/scripts/memories/lef.py create mode 100644 flow/scripts/memories/lef_test.py create mode 100644 flow/scripts/memories/liberty.py create mode 100644 flow/scripts/memories/liberty_test.py create mode 100644 flow/scripts/memories/pdk_asap7.py create mode 100644 flow/scripts/memories/schema.py create mode 100644 flow/scripts/memories/schema_test.py create mode 100644 flow/scripts/memories/sram_area_model.py create mode 100644 flow/scripts/memories/sram_area_model_test.py 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..3299fb0d75 --- /dev/null +++ b/docs/user/AutoMemories.md @@ -0,0 +1,151 @@ +# 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 +``` + +## 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..1e92dc34cf 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,49 @@ 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"], +) + +# 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", + "lef_test", + "liberty_test", + "schema_test", + "sram_area_model_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/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..92cb6edf59 --- /dev/null +++ b/flow/scripts/memories/gen_memories.py @@ -0,0 +1,131 @@ +#!/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 detect # noqa: E402 +import idiomatic # noqa: E402 +import lef # noqa: E402 +import liberty # noqa: E402 +import pdk_asap7 # noqa: E402 +import schema # noqa: E402 + + +def run( + verilog: list[Path], + memories_files: list[Path], + platform: str, + out_dir: Path, + json_path: Path, +) -> int: + try: + params = pdk_asap7.get_platform(platform) + except ValueError as e: + sys.stderr.write(f"gen_memories: {e}\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 + + 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)) + + 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..ec56f3ca4a --- /dev/null +++ b/flow/scripts/memories/gen_memories_test.py @@ -0,0 +1,137 @@ +#!/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 gen_memories +from detect_test import BUS_STYLE, NOT_A_MEMORY, RW_STYLE + +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/lef.py b/flow/scripts/memories/lef.py new file mode 100644 index 0000000000..f1f9aa58e1 --- /dev/null +++ b/flow/scripts/memories/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 + +import liberty +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/flow/scripts/memories/lef_test.py b/flow/scripts/memories/lef_test.py new file mode 100644 index 0000000000..52679cb85c --- /dev/null +++ b/flow/scripts/memories/lef_test.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import re +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import detect +import lef +import pdk_asap7 +from detect_test import BUS_STYLE + + +class LefTest(unittest.TestCase): + def setUp(self): + self.params = pdk_asap7.ASAP7 + self.mem = detect.scan_text(BUS_STYLE)[0] + self.lef = lef.emit_lef(self.mem, self.params) + m = re.search(r"SIZE ([\d.]+) BY ([\d.]+) ;", self.lef) + self.width, self.height = float(m.group(1)), float(m.group(2)) + + def test_macro_block_with_foreign(self): + self.assertIn("MACRO mem_128x32", self.lef) + self.assertIn("FOREIGN mem_128x32 0 0 ;", self.lef) + self.assertIn("SYMMETRY X Y R90 ;", self.lef) + self.assertIn("CLASS BLOCK ;", self.lef) + self.assertIn("END LIBRARY", self.lef) + + def test_size_snapped_to_grid(self): + for value in (self.width, self.height): + steps = value / self.params.grid_um + self.assertAlmostEqual(steps, round(steps), places=3) + + def test_per_bit_signal_pins(self): + for bit in (0, 31): + self.assertIn(f"PIN W0_data[{bit}]", self.lef) + self.assertIn(f"PIN R0_data[{bit}]", self.lef) + self.assertIn("PIN R0_clk", self.lef) + self.assertIn("PIN W0_clk", self.lef) + # Pads on the pin layer, abutment-shaped, direction per pin. + block = self.lef[self.lef.index("PIN R0_data[0]") :] + block = block[: block.index("END R0_data[0]")] + self.assertIn("DIRECTION OUTPUT ;", block) + self.assertIn("SHAPE ABUTMENT ;", block) + self.assertIn(f"LAYER {self.params.pin_layer} ;", block) + + def test_power_ground_straps_interleaved(self): + vdd = self.lef[self.lef.index("PIN VDD") : self.lef.index("END VDD")] + vss = self.lef[self.lef.index("PIN VSS") : self.lef.index("END VSS")] + self.assertIn("USE POWER ;", vdd) + self.assertIn("USE GROUND ;", vss) + self.assertIn(f"LAYER {self.params.pg_layer} ;", vdd) + vdd_ys = [ + float(m.group(2)) for m in re.finditer(r"RECT ([\d.]+) ([\d.]+)", vdd) + ] + vss_ys = [ + float(m.group(2)) for m in re.finditer(r"RECT ([\d.]+) ([\d.]+)", vss) + ] + self.assertGreater(len(vdd_ys), 1) + self.assertGreater(len(vss_ys), 1) + self.assertAlmostEqual(vss_ys[0], 0.0) + self.assertAlmostEqual(vdd_ys[0], self.params.pg_vdd_offset_um) + self.assertAlmostEqual(vdd_ys[1] - vdd_ys[0], self.params.pg_strap_pitch_um) + + def test_all_shapes_inside_macro(self): + for m in re.finditer(r"RECT\s+([\d.]+) ([\d.]+) ([\d.]+) ([\d.]+) ;", self.lef): + x0, y0, x1, y1 = (float(g) for g in m.groups()) + self.assertLess(x0, x1) + self.assertLess(y0, y1) + self.assertGreaterEqual(x0, 0.0) + self.assertGreaterEqual(y0, 0.0) + self.assertLessEqual(x1, self.width) + self.assertLessEqual(y1, self.height) + + def test_obs_covers_footprint_on_all_layers(self): + obs_start = self.lef.index("\n OBS") + obs = self.lef[obs_start : self.lef.index("\n END\n", obs_start)] + for layer in self.params.obs_device_layers + self.params.obs_metal_layers: + self.assertIn(f"LAYER {layer} ;", obs) + self.assertIn(f"RECT 0 0 {self.width:.3f} {self.height:.3f} ;", obs) + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/scripts/memories/liberty.py b/flow/scripts/memories/liberty.py new file mode 100644 index 0000000000..21f5e2d376 --- /dev/null +++ b/flow/scripts/memories/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 + +import schema +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/flow/scripts/memories/liberty_test.py b/flow/scripts/memories/liberty_test.py new file mode 100644 index 0000000000..c0989ae5c4 --- /dev/null +++ b/flow/scripts/memories/liberty_test.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +import re +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import detect +import liberty +import pdk_asap7 +from detect_test import BUS_STYLE, SUBWORD_STYLE + + +class LibertyTest(unittest.TestCase): + def setUp(self): + self.params = pdk_asap7.ASAP7 + self.mem = detect.scan_text(BUS_STYLE)[0] + self.lib = liberty.emit_lib(self.mem, self.params) + + def test_bus_umbrella_with_per_bit_pins(self): + # Both the bus() block and every per-bit pin must be present: + # a bus without per-bit siblings makes yosys silently drop bit + # connections at parent instances. + self.assertIn('bus ("W0_data") {', self.lib) + self.assertIn('type ("W0_data") {', self.lib) + for bit in range(32): + self.assertIn(f'pin ("W0_data[{bit}]")', self.lib) + self.assertIn(f'pin ("R0_data[{bit}]")', self.lib) + + def test_output_bits_have_clocked_arcs(self): + block = self.lib[self.lib.index('pin ("R0_data[31]")') :] + block = block[: block.index('pin ("R0_data[30]")')] + self.assertIn("timing_type : rising_edge;", block) + self.assertIn('related_pin : "R0_clk";', block) + + def test_input_bits_have_setup_and_hold(self): + block = self.lib[self.lib.index('pin ("W0_data[31]")') :] + block = block[: block.index('pin ("W0_data[30]")')] + self.assertIn("timing_type : setup_rising;", block) + self.assertIn("timing_type : hold_rising;", block) + self.assertIn("internal_power()", block) + + def test_clock_pins(self): + for clk in ("R0_clk", "W0_clk"): + block = self.lib[self.lib.index(f'pin ("{clk}")') :] + block = block[: block.index(" }")] + self.assertIn("clock : true;", block) + self.assertIn("min_period", block) + self.assertIn("min_clock_tree_path", self.lib) + self.assertIn("max_clock_tree_path", self.lib) + + def test_power_uses_power_lut_template(self): + # OpenSTA report_power -saif reads power templates from the + # power-template registry; lu_table_template silently zeroes it. + self.assertIn("power_lut_template(power_template)", self.lib) + self.assertIn("rise_power(power_template)", self.lib) + + def test_platform_voltage(self): + self.assertIn( + f"nom_voltage : " f"{self.params.nom_voltage};", self.lib + ) + + def test_memory_group(self): + self.assertIn("address_width : 7;", self.lib) + self.assertIn("word_width : 32;", self.lib) + + def test_pre_layout_variant_has_ideal_clock(self): + pre = liberty.emit_lib(self.mem, self.params, ck_insertion_ps=0.0) + tree_values = re.findall( + r"clock_tree_path;\s*cell_rise\(scalar\) {\s*" r'values\("([\d.]+)"\);', pre + ) + self.assertTrue(tree_values) + self.assertTrue(all(float(v) == 0.0 for v in tree_values)) + default_values = re.findall( + r"clock_tree_path;\s*cell_rise\(scalar\) {\s*" r'values\("([\d.]+)"\);', + self.lib, + ) + self.assertTrue(all(float(v) > 0.0 for v in default_values)) + + def test_subword_memory_emits_every_lane(self): + mem = detect.scan_text(SUBWORD_STYLE)[0] + lib = liberty.emit_lib(mem, self.params) + for lane in range(4): + self.assertIn(f'bus ("W0_data_{lane}")', lib) + self.assertIn(f'pin ("W0_data_{lane}[7]")', lib) + # Scalar mask lanes are plain pins with constraints. + self.assertIn(f'pin ("W0_mask_{lane}")', lib) + + def test_scalar_output_pin_gets_clocked_arc(self): + mem = detect.scan_text( + SUBWORD_STYLE.replace("output [7:0] R0_data_0", "output R0_data_0") + )[0] + lib = liberty.emit_lib(mem, self.params) + block = lib[lib.index('pin ("R0_data_0")') :] + block = block[: block.index('bus ("R0_data_1")')] + self.assertIn("timing_type : rising_edge;", block) + + +if __name__ == "__main__": + unittest.main() diff --git a/flow/scripts/memories/pdk_asap7.py b/flow/scripts/memories/pdk_asap7.py new file mode 100644 index 0000000000..1fa1335956 --- /dev/null +++ b/flow/scripts/memories/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/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/memories/sram_area_model.py b/flow/scripts/memories/sram_area_model.py new file mode 100644 index 0000000000..dd79076362 --- /dev/null +++ b/flow/scripts/memories/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/flow/scripts/memories/sram_area_model_test.py b/flow/scripts/memories/sram_area_model_test.py new file mode 100644 index 0000000000..6e14d2921f --- /dev/null +++ b/flow/scripts/memories/sram_area_model_test.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import sram_area_model + + +class AreaModelTest(unittest.TestCase): + def test_anchor_order_of_magnitude(self): + # A 256x32 single-port array: storage ~246 µm² + peripheral; + # published 7 nm SP-SRAM IP of this size lands well under + # 1000 µm². + area = sram_area_model.sram_area_um2(256, 32) + self.assertGreater(area, 300.0) + self.assertLess(area, 1000.0) + + def test_monotonic_in_bits(self): + small = sram_area_model.sram_area_um2(64, 32) + large = sram_area_model.sram_area_um2(256, 32) + self.assertLess(small, large) + + def test_ports_increase_area(self): + sp = sram_area_model.sram_area_um2(256, 32) + mp = sram_area_model.sram_area_um2(256, 32, read_ports=2, write_ports=2) + self.assertLess(sp, mp) + + def test_mask_lanes_increase_area(self): + unmasked = sram_area_model.sram_area_um2(256, 32) + masked = sram_area_model.sram_area_um2(256, 32, write_mask_lanes=4) + self.assertLess(unmasked, masked) + # Zero lanes reproduces the unmasked model exactly. + self.assertEqual( + unmasked, sram_area_model.sram_area_um2(256, 32, write_mask_lanes=0) + ) + + def test_node_scaling_is_quadratic(self): + at7 = sram_area_model.sram_area_um2(256, 32, tech_nm=7) + at14 = sram_area_model.sram_area_um2(256, 32, tech_nm=14) + self.assertAlmostEqual(at14 / at7, 4.0, places=6) + + def test_leakage_anchor(self): + # 8 Kb at ~100 pW/bit plus one port's sense amps. + leak = sram_area_model.sram_leakage_pw(256, 32) + self.assertAlmostEqual(leak, 256 * 32 * 100.0 + 5000.0) + + def test_dynamic_energy_scales_with_columns(self): + narrow = sram_area_model.sram_dynamic_pj_per_access(256, 8) + wide = sram_area_model.sram_dynamic_pj_per_access(256, 64) + self.assertLess(narrow, wide) + + def test_dimensions_respect_aspect_ratio(self): + w, h = sram_area_model.sram_dimensions_um(5000.0, aspect_ratio=0.5) + self.assertAlmostEqual(w * h, 5000.0, places=6) + self.assertAlmostEqual(h / w, 0.5, places=6) + + def test_input_validation(self): + with self.assertRaises(ValueError): + sram_area_model.sram_area_um2(0, 32) + with self.assertRaises(ValueError): + sram_area_model.sram_area_um2(256, 32, read_ports=-1) + with self.assertRaises(ValueError): + sram_area_model.sram_leakage_pw(256, 32, ports=0) + with self.assertRaises(ValueError): + sram_area_model.sram_dimensions_um(-1.0) + + +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. From 4373b5def594463b88c22ba20e7117403c565cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Harboe?= Date: Sun, 19 Jul 2026 18:36:54 +0200 Subject: [PATCH 2/2] memories: carry the bazel-orfs patch that consumes AUTO_MEMORIES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bazel-orfs runs each flow stage in a sandbox where only declared outputs survive, so the generated memories.json and memories/ views must be declared as canonicalize outputs and propagated to every downstream stage. Carried here as a patch so the bazel-orfs side can be reviewed together with the feature; to be upstreamed to bazel-orfs once this lands. Signed-off-by: Øyvind Harboe --- docs/user/AutoMemories.md | 13 ++ .../memories/bazel-orfs-auto-memories.patch | 170 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 flow/scripts/memories/bazel-orfs-auto-memories.patch diff --git a/docs/user/AutoMemories.md b/docs/user/AutoMemories.md index 3299fb0d75..eddde0e660 100644 --- a/docs/user/AutoMemories.md +++ b/docs/user/AutoMemories.md @@ -145,6 +145,19 @@ python3 flow/scripts/memories/gen_memories.py \ --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) 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 +