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

$(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/<m>.lib

Generated Liberty view per converted memory.

$(RESULTS_DIR)/memories/<m>_pre_layout.lib

Ideal-clock (zero clock-tree insertion) variant for pre-CTS consumers that select lib files themselves. The Makefile flow uses <m>.lib throughout.

$(RESULTS_DIR)/memories/<m>.lef

Abstract LEF per converted memory.

$(RESULTS_DIR)/memories/blackboxes.txt

<module> <area> per converted module — what synthesis blackboxes, and the area in um² its .lib states, which SYNTH_MINIMUM_KEEP_SIZE costs it by.

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 SIZE and WIDTH, read and write port counts from RD_PORTS and WR_PORTS, and write-mask lanes from the width of WR_EN. Read-write ports are not inferred; a .memories override can describe them (see below).

  • pins named R<n>_clk/addr/en/data and W<n>_clk/addr/en/data, plus W<n>_mask when there are mask lanes.

  • the module’s name when the $mem_v2 is 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>_ram is a macro to synthesis and macro placement: its abstract <m>_ram.lef and a liberty of its area and pins land beside the FakeRAM views and it joins blackboxes.txt. In mode netlist (the only one) generate_ram places 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 macro stops with that advice: a timing model written from generate_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

regfile

flip-flops: reg [31:0] rf[31:0], mapped to flops (no AUTO_MEMORIES)

32 x 32, 2R1W, x0 reads 0

Yes: the ports are the spec’s (zero_word 0). Also exercises OPENROAD_HIERARCHICAL=1 beside two FakeRAM macros.

asap7/ibex

ibex_register_file_ff

flip-flops: RegFile = RegFileFF, the default

32 x 32, 2R1W, x0 reads 0

Yes: unused names test_en_i and dummy_instr_id_i, ports the array does not use, and async_reset rst_ni low resets the flops to 0 as ibex does; simulated against ibex’s RTL.

asap7/cva6

ariane_regfile (ariane_regfile_ff.sv)

flip-flops: cv32a65x has FpgaEn 0

32 x 32, 2R1W

Not yet: the ports are packed arrays (raddr_i[1:0][4:0]), one port per slice, which a spec cannot yet address; asynchronous reset as ibex.

asap7/swerv_wrapper

dec_gpr_ctl_*

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.json and the memories/ 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.tcl takes memories/*.lef, read_liberty.tcl takes memories/*.lib) rather than through a variable.

  • memories_inferred.json staged into synthesis as well. Nothing reads it there, but make walks the prerequisites of yosys-dependencies before 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 rewrite memories.json.

bazel-orfs implements this.

Variables#