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 yosys pass
(scripts/memories/extract_memories.tcl) elaborates VERILOG_FILES
into $(RESULTS_DIR)/memories_inferred.json, and
scripts/memories/gen_memories.py reads that netlist and writes:
File |
Content |
|---|---|
|
Inventory of every detected memory: geometry, full pin list with pin functions, behavioral model, and whether it was converted ( |
|
Generated Liberty view per converted memory. |
|
Ideal-clock (zero clock-tree insertion) variant for pre-CTS consumers that select lib files themselves. The Makefile flow uses |
|
Abstract LEF per converted memory. |
|
|
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#
Yosys infers the memories. scripts/memories/extract_memories.tcl
reads the design sources with the design’s frontend, runs hierarchy,
proc and memory -nomap, and writes the netlist as JSON.
scripts/memories/detect.py then takes each $mem_v2 cell in it:
depth and width from
SIZEandWIDTH, read and write port counts fromRD_PORTSandWR_PORTS, and write-mask lanes from the width ofWR_EN. Read-write ports are not inferred; a.memoriesoverride can describe them (see below).pins named
R<n>_clk/addr/en/dataandW<n>_clk/addr/en/data, plusW<n>_maskwhen there are mask lanes.the module’s name when the
$mem_v2is the only cell in its module, otherwise the cell’s name.
Two consequences, documented as deliberate scope:
Module boundary only. Synthesis blackboxes converted memories by module name, so only a memory alone in its module is replaced by a macro. A memory inferred next to other logic is named after its cell, which matches no module.
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:
{
"version": 1,
"memories": [
{
"name": "small_array",
"idiomatic": true,
"reason": "forced: a macro despite the floors"
}
]
}
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.
Write-mask lanes split the word evenly. A word that is not a whole
number of lanes, such as pulp’s tc_sram with byte enables over a
25-bit word, needs mask_lane_width in its entry. Every lane is that
wide except the last, which holds the remaining bits:
{
"name": "tag_ram",
"rows": 64,
"bits": 25,
"addr_w": 6,
"rw_ports": 1,
"mask_lanes": 4,
"mask_lane_width": 8,
"idiomatic": true,
"reason": "one read-write port, byte enables, the last 1 bit",
"pins": [
{"name": "clk", "direction": "input", "width": 1, "port_id": "RW0", "function": "clk"},
{"name": "cs", "direction": "input", "width": 1, "port_id": "RW0", "function": "en"},
{"name": "we", "direction": "input", "width": 1, "port_id": "RW0", "function": "wmode"},
{"name": "addr", "direction": "input", "width": 6, "port_id": "RW0", "function": "addr"},
{"name": "wdata", "direction": "input", "width": 25, "port_id": "RW0", "function": "data_in"},
{"name": "be", "direction": "input", "width": 4, "port_id": "RW0", "function": "mask"},
{"name": "rdata", "direction": "output", "width": 25, "port_id": "RW0", "function": "data_out"}
]
}
The lanes must cover the word with a last lane of 1 to
mask_lane_width bits; anything else is refused, as is an uneven split
without mask_lane_width.
The macro takes its lane count from the mask pin’s width, so every
mask pin must be mask_lanes wide, and a memory with mask_lanes must
have one; anything else is refused, naming the memory. An override
merged onto a detected memory keeps the detected pins: one that changes
mask_lanes must bring its pins, with a mask pin to match.
Generated views#
The views come from FakeRAM2.0’s asap7 backend
(tools/FakeRAM2.0/orfs_asap7/generate.py), which gen_memories.py runs
as run.py --orfs_asap7_backend.
Every converted memory becomes a FakeRAM2.0 single-port RAM of its depth
and width. Its other ports and write-mask lanes are not modelled, and the
views use FakeRAM2.0’s pin names, not the memory’s own.
The .lib has bus() groups addr_in, wd_in and rd_out and pins
clk, we_in and ce_in. Inputs have setup/hold constraints, rd_out
has a clock-to-out arc, and pins have internal_power() records under a
power_lut_template. <m>_pre_layout.lib has the same content as
<m>.lib.
Timing, power and leakage are FakeRAM2.0’s built-in asap7 defaults,
the same for every memory. Only area and bus widths depend on the
memory. Each bit is 2 contacted poly pitches by 10 fin pitches, and the
bit array gets 20% extra in each direction: that is the .lib area.
The .lef size is at least that array, rounded up to a multiple of
0.19 µm in width and 1.4 µm in height; a shallow memory is made taller
to leave room for its pins.
The .lef is a CLASS BLOCK abstract with signal pins on M4 stacked up
the left edge, alternating horizontal M4 VDD/VSS straps across the
macro (which the platform’s PDN macro grid connects to M5), and an OBS
covering M1 to M4.
Register files#
A register file, many read and write ports over a few words, is no
SRAM: FakeRAM’s single-port model does not describe it, and synthesised
to flops its mux trees are placed by a placer that does not know they
are an array. AUTO_MEMORIES_REGFILES lists spec files, one per RTL
module, and OpenROAD’s generate_ram builds each as an array of placed
standard cells: a flop per bit, its write logic beside it, the read
trees in columns. The spec names the module, its words and bits, its
read and write ports by the module’s own port names, and the cells:
module RegFile
mode netlist
words 16
bits 8
clock clock
read io_r0_addr io_r0_data
write io_w0_addr io_w0_data io_w0_en
cell flop DFFHQNx1_ASAP7_75t_R
...
memories/regfile_ram.py translates the spec to generate_ram_netlist
options (ports, banks, folds, the zero word, reset, read latency, write
style and priority, cells) and checks it against the module’s ports,
from synthesis: a port in one and not the other, or a data port of
another width, stops the build rather than miswire the parent. The RAM
is a block <m>_ram, and a wrapper takes the module’s place,
connecting the module’s ports to the RAM’s (an active-high reset
inverted for an active-low flop, an address narrowed or widened). What
happens next depends on AUTO_MEMORIES_MACRO_PLACE, the register files
to place as macros:
Not listed (the default): inlined. Synthesis reads the wrapper and the RAM’s netlist in the module’s place; from there on their cells are placed, sized and buffered with the rest of the design. No macro, no abstract, no dissolve. The better shape for a small design, where a macro’s outline and channels cost more core than the array saves.
Listed: a macro.
<m>_ramis a macro to synthesis and macro placement: its abstract<m>_ram.lefand a liberty of its area and pins land beside the FakeRAM views and it joinsblackboxes.txt. Inmode netlist(the only one)generate_ramplaces only the array’s core and leaves its periphery (decode, select and input buffers) to the parent, and the macro dissolves into its cells at the end of macro placement (regfile_dissolve.tcl): the core lands FIRM where the macro was placed, flipped as the macro was, on the parent’s rows; the periphery is left to global placement and the resizer, and dead logic is eliminated as at synthesis. A large design whose floorplan plans its register files lists them.A macro of its own. A register file wanted as a macro to the end is a design of its own: run its module through the flow, its spec listed and macro-placed so it dissolves there, and give the parent its abstract as a macro. Its clock tree is then built and its timing model is written from it.
mode macrostops with that advice: a timing model written fromgenerate_ram’s netlist before any clock tree would have the whole unbuffered clock net as one pin.
A word holds its value between writes either through a hold mux per
bit (write_style mux, the default here) or through one integrated
clock gate per word, enabled by its write selects (write_style clock_gate, generate_ram’s own default). Where a row holds more than
one word (bank_columns), each word’s bits sit side by side, so its
selects and write enables are no longer than its bits; and a word whose
bits would run more than 150 um is folded into more rows. tap_columns
and service_sites leave empty columns in the array for the buffers a
parent puts on the nets that cross it.
Listed register files appear in memories.json with kind regfile;
the rest have kind fakeram or flops.
Moving the dissolve into OpenROAD is left for later: it would make it faster, testable at unit level and maintained with the database it edits.
Designs to measure it on#
What a register file of placed cells buys is a shorter minimum clock
period (the read mux trees are columns, not a cloud) and a shorter
build (fewer cells for synthesis, placement and the resizer to work
on). The ORFS designs whose register file is flip-flops today and its
own module, the seam a spec needs, measured as flops against
AUTO_MEMORIES_REGFILES:
Design |
Module |
Today |
Shape |
Ready? |
|---|---|---|---|---|
asap7/riscv32i |
|
flip-flops: |
32 x 32, 2R1W, x0 reads 0 |
Yes: the ports are the spec’s ( |
asap7/ibex |
|
flip-flops: |
32 x 32, 2R1W, x0 reads 0 |
Yes: |
asap7/cva6 |
|
flip-flops: cv32a65x has |
32 x 32, 2R1W |
Not yet: the ports are packed arrays ( |
asap7/swerv_wrapper |
|
flip-flops |
32 x 32, 4R3W |
Not yet: read enables, bank ids and scan ports around the array need a seam in the RTL. |
coralnpu’s Regfile (8R4W) and FRegfile (3R2W) carry their
scoreboard and bypass logic in the same module, and picorv32 and
tinyRocket keep their register file inside the CPU module: those need a
seam in their RTL first.
Status: at parity on riscv32i, on a smaller core#
riscv32i with its register file generated and inlined (a spec in
AUTO_MEMORIES_REGFILES, zero_word 0), at riscv32i’s own
utilisation, against designs/asap7/riscv32i. To global route, 950 ps
clock, minimum period from placement parasitics on the global-route
database:
flops |
register file |
|
|---|---|---|
minimum period |
950 ps |
943 ps |
core area |
4 012 um2 |
3 503 um2 |
Both are limited by the same single-cycle path: the instruction into the register file’s read, the ALU, the data memory and back into the register file’s write. The register file’s share of it, decode and read tree, is about 260 ps in either.
What did not pay, measured on the same design:
the register file as a macro dissolved after macro placement: it does not fit riscv32i’s 62 % beside the four FakeRAMs, and at 55 % it is 959 ps on a 4 644 um2 core;
a hold mux per bit instead of the clock gate: 994 ps inlined, every array endpoint carrying the mux;
predecoded read selects: 960 ps;
a tree of 2:1 muxes steered by the address bits instead of a decoded one-hot read: 959 ps, the address bits’ fanout (about 1 000 inputs a port) needs as many buffer levels as the decode it replaces;
four-input gates in the read tree, stronger cells: 950 to 954 ps, within what any change to the placement moves.
Platform support#
asap7 only. gen_memories.py rejects any other platform. The
asap7 process parameters (layers, pin pitch, poly and fin pitch, snap grid)
are constants in ASAP7_PROCESS_CONFIG in
tools/FakeRAM2.0/orfs_asap7/generate.py, not read from the platform.
Another PDK would need its own backend there.
Trying it#
make DESIGN_CONFIG=designs/asap7/tinyRocket/config.mk synth floorplan
The generator can also be run standalone, on the
memories_inferred.json that run leaves in the results directory, to
inspect what it would do:
python3 flow/scripts/memories/gen_memories.py \
--platform asap7 --out-dir /tmp/mems --json /tmp/memories.json \
--yosys-json flow/results/asap7/tinyRocket/base/memories_inferred.json
Consuming from bazel-orfs#
Everything downstream keys off generated files, so a build system can declare them as ordinary stage outputs and transitive dependencies.
A sandboxed build system, where only declared outputs survive a step and only declared inputs are present, needs three things:
memories.jsonand thememories/directory declared as outputs of canonicalization.memories/has to be a directory rather than a file list: the per-memory file names are only known once the RTL is scanned.both staged into every later step, because the flow reads them by globbing the results dir (
load.tcltakesmemories/*.lef,read_liberty.tcltakesmemories/*.lib) rather than through a variable.memories_inferred.jsonstaged into synthesis as well. Nothing reads it there, butmakewalks the prerequisites ofyosys-dependenciesbefore running it, and each one depends on the next:yosys-dependencies: $(RESULTS_DIR)/memories.json $(RESULTS_DIR)/memories.json: $(RESULTS_DIR)/memories_inferred.json ... $(RESULTS_DIR)/memories_inferred.json: $(VERILOG_FILES) ...
With the far end of that chain absent, make rebuilds
memories_inferred.json– re-running detection at a point in the flow where the design is no longer the original RTL – and then tries to rewritememories.json.
bazel-orfs implements this.