Hardening parameterized modules (uniquify)#

Note

Beta feature. Uniquify (Uniquified) is new; its API and generated output may change in a future release.

The hardened-module tutorial hardened a single, unparameterized module and instantiated it in a parent – packaging the macro, blackboxing the RTL and injecting the library all by hand. This tutorial tackles the case that flow cannot: a parameterized module instantiated with several different parameter values.

How this differs from the hardened-module tutorial

The hardened-module tutorial teaches the underlying mechanism – harden a module, blackbox it, inject the macro – by hand for one fixed module. If you have not read it, start there.

This tutorial is about the problem that mechanism alone cannot solve. A hardened macro is a fixed netlist with no parameters, so a post-synthesis block named heartbeat no longer has an N for a parent’s heartbeat #(.N(8)) to bind to – and the parent uses N = 8, 24 and 48. Uniquified resolves this automatically: it discovers which parameter values are actually used, generates a hardenable variant for each plus a wrapper that restores the original interface, and drives the whole harden-and-inject flow for every variant at once.

Given a design and the names of the parameterized modules to harden, Uniquified:

  1. enumerates every concrete parameter combination the modules are actually instantiated with (by elaborating the design with slang),

  2. generates, for each combination, a parameter-free variant to harden (e.g. heartbeat__N8) plus a parameterized wrapper that keeps the original module’s name and interface and dispatches to the matching variant,

  3. registers filesets for those sources on the design, then lets you build each variant into a macro and wire up the wrappers and macros into a parent ASIC project.

This mirrors how lambdalib maps an abstract cell onto hardened implementations, but generated automatically from how the modules are used.

Note

Unparameterized modules are handled too. A module with no overridable parameters is already hardenable, so no wrapper is generated for it – it is hardened directly and blackboxed at wireup, i.e. Uniquified does exactly what the hardened-module tutorial does by hand. A single Uniquified can therefore harden a mix of parameterized and unparameterized modules in one shot.

All of the code below is contained in the example script. To inspect what uniquify generates (no EDA tools required):

smake main

and to run the full flow (requires yosys and OpenROAD):

smake harden

The design#

The parent instantiates a parameterized heartbeat counter with several distinct widths (N), plus a prescaler. Note that N=24 is used twice – uniquify hardens it only once.

heartbeat_top.v#
// Parent design. It depends on the parameterized `heartbeat` and `prescaler`
// modules (each defined in its own file / SiliconCompiler Design) and
// instantiates them with several distinct parameter values.
module heartbeat_top (
    input        clk,
    input        nreset,
    output       out_fast,
    output       out_slow,
    output       out_slow2,
    output       out_slowest,
    output [3:0] cnt_a,
    output [7:0] cnt_b
);
    // Four heartbeat instances, three distinct parameterizations (N=24 merges).
    heartbeat #(
        .N(8)
    ) u_fast (
        .clk(clk),
        .nreset(nreset),
        .out(out_fast)
    );
    heartbeat #(
        .N(24)
    ) u_slow (
        .clk(clk),
        .nreset(nreset),
        .out(out_slow)
    );
    heartbeat #(
        .N(24)
    ) u_slow2 (
        .clk(clk),
        .nreset(nreset),
        .out(out_slow2)
    );
    heartbeat #(
        .N(48)
    ) u_slowest (
        .clk(clk),
        .nreset(nreset),
        .out(out_slowest)
    );

    // Two prescaler parameterizations.
    prescaler #(
        .W(4)
    ) u_ps4 (
        .clk(clk),
        .nreset(nreset),
        .count(cnt_a)
    );
    prescaler #(
        .W(8)
    ) u_ps8 (
        .clk(clk),
        .nreset(nreset),
        .count(cnt_b)
    );
endmodule
heartbeat.v#
// A parameterized module, instantiated with several distinct N values by the
// parent. Hardening it requires knowing which concrete N values are actually
// used -- that is what the uniquify enumeration answers.
module heartbeat #(
    parameter N = 8
) (
    input      clk,
    input      nreset,
    output reg out
);
    reg [N-1:0] counter_reg;
    always @(posedge clk or negedge nreset) begin
        if (!nreset) begin
            counter_reg <= {(N) {1'b0}};
            out <= 1'b0;
        end else begin
            counter_reg <= counter_reg + 1'b1;
            out <= (counter_reg == {(N) {1'b1}});
        end
    end
endmodule

Step 1: Define the modules and the parent#

Each parameterized module to be hardened must be its own reusable Design (uniquify aliases the module’s RTL to a generated wrapper, so it needs a design to alias). The parent depends on their RTL so it elaborates.

The parameterized modules#
        with self.active_fileset("rtl"), self.active_dataroot(_DATAROOT):
            self.set_topmodule("heartbeat")
            self.add_file("heartbeat.v")


class Prescaler(Design):
    """Parameterized `prescaler` module (its own reusable design)."""
    def __init__(self):
        super().__init__("prescaler")
        self.set_dataroot(_DATAROOT, __file__)
        with self.active_fileset("rtl"), self.active_dataroot(_DATAROOT):
            self.set_topmodule("prescaler")
            self.add_file("prescaler.v")


class HeartbeatTop(Design):
    """Parent design instantiating the parameterized submodules."""
    def __init__(self):
The parent design#
        with self.active_fileset("rtl"), self.active_dataroot(_DATAROOT):
            self.set_topmodule("heartbeat_top")
            # The parent depends on the submodule RTL so it elaborates.
            self.add_file("heartbeat_top.v")
            self.add_depfileset(Heartbeat(), depfileset="rtl", fileset="rtl")
            self.add_depfileset(Prescaler(), depfileset="rtl", fileset="rtl")


def uniquify():
    """Set up uniquification of the target modules on a fresh parent design.

Step 2: Construct the Uniquified helper#

Pass the parent design (or a Project) and the module names to uniquify. Construction enumerates the parameter combinations and generates the wrappers/variants in memory – it writes nothing to disk and runs no tools – and registers the new filesets on the design.

Setting up uniquification#
_DATAROOT = "uniquify"


    filesets are registered on the design, but nothing is hardened yet.
    """
    return Uniquified(HeartbeatTop(), ["heartbeat", "prescaler"])


def _configure_freepdk45(project):
    """Configure a variant/parent ASIC run for FreePDK45."""

The filesets registered on the design are:

  • rtl.<module>.wrapper – the parameterized wrapper (keeps the module’s name and interface), one per module.

  • rtl.hardened.<variant> – one parameter-free variant per combination, the top that gets hardened into a macro.

  • rtl.wrapper – an aggregate that pulls every per-module wrapper at once.

Step 3: Inspect what was generated#

The helper exposes its results as plain state. The main function prints the variants and fileset names, then writes the sources so you can read a wrapper:

Inspecting the results#
        print(f"'{module}' -> {len(variants)} variant(s): {variants}")
    print()
    print("wrapper filesets:  ", uq.wrapper_filesets)
    print("hardened filesets: ", uq.hardened_filesets)

    # Materialize the generated sources (construction is disk-side-effect free),
    # then show one wrapper: it keeps the module name + parameters and dispatches
    # to the hardened variants.
    uq.write()
    with open(os.path.join(uq.outdir, "heartbeat.wrapper.v")) as fobj:
        print("\n" + "=" * 64 + "\nGenerated wrapper for 'heartbeat':\n")
        print(fobj.read())


if __name__ == "__main__":
    main()

For this design that reports three heartbeat variants (N = 8, 24, 48 – the duplicate N=24 merged) and two prescaler variants:

'heartbeat' -> 3 variant(s): ['heartbeat__N24', 'heartbeat__N48', 'heartbeat__N8']
'prescaler' -> 2 variant(s): ['prescaler__W4', 'prescaler__W8']

The generated wrapper keeps the heartbeat name and its N parameter, and uses a generate block to select the matching hardened variant. If a design ever requests a parameter combination that was not hardened, elaboration fails loudly via $error rather than silently building the wrong thing:

heartbeat.wrapper.v (generated)#
module heartbeat #(
    parameter N = 8
) (
    input      clk,
    input      nreset,
    output out
);
    generate
        if ((N == 24)) begin : g_heartbeat__N24
            heartbeat__N24 u_impl (.clk(clk), .nreset(nreset), .out(out));
        end
        else if ((N == 48)) begin : g_heartbeat__N48
            heartbeat__N48 u_impl (.clk(clk), .nreset(nreset), .out(out));
        end
        else if ((N == 8)) begin : g_heartbeat__N8
            heartbeat__N8 u_impl (.clk(clk), .nreset(nreset), .out(out));
        end
        else begin : g_invalid
            $error("no hardened variant of heartbeat exists for the requested parameters");
        end
    endgenerate
endmodule

Step 4: Harden the variants#

Uniquified.build() hardens the selected variants (all of them by default) into StdCellLibrary macros. Pass a target – a SiliconCompiler target callback fn(project) that configures the PDK, flow and constraints on each variant’s ASIC run – exactly as you would for a normal build:

The target used for each variant (and the parent)#

    freepdk45_demo(project)
    project.constraint.area.set_density(5.0)
    # The demo PDK has no power grid; skip IR-drop analysis.
    for task in OpenROADPSMParameter.find_task(project):
        task.set_openroad_psmenable(False)


def harden():
    """Full flow: harden every variant and build the parent with the macros.
Hardening every variant#
    project.add_fileset("rtl")
    _configure_freepdk45(project)

Each variant is built under its own job (jobname=<variant>) beneath the helper’s libdir, and the resulting macro is persisted so a later run reuses it. build returns a {variant: StdCellLibrary} mapping. Pass parallel=True to harden the variants concurrently, or macros="heartbeat" / macros="heartbeat__N*" to rebuild a subset.

Step 5: Wire up the parent#

Finally, build the parent. This is where the mechanism from the hardened-module tutorial – alias the module’s RTL, inject the macro with add_asiclib – is applied, but you do not write it out by hand: Uniquified.wireup() does it for every variant at once. It aliases each module’s RTL to its generated wrapper (so synthesis elaborates the dispatch instead of the original parameterized RTL, the one twist beyond the hardened-module flow) and injects all the hardened macros:

Building the parent with the wrappers and macros#
    """
    uq = uniquify()
    uq.build(target=_configure_freepdk45, parallel=True)

    project = ASIC(uq.design)
    project.add_fileset("rtl")
    _configure_freepdk45(project)
    uq.wireup(project)

    project.run()
    project.summary()


def main():
    uq = uniquify()

Note

wireup requires every used variant to have a built (or loaded) macro, and raises otherwise. The generated wrapper has a branch for each enumerated variant, so an unbuilt-but-used variant still matches its own branch (it does not reach the $error default) – what fails is the missing hardened implementation. The $error branch only guards parameter combinations that were never enumerated at all. Call Uniquified.build() first, or Uniquified.load_macros() to reuse macros from a previous run.

Conclusion#

Starting from a parent that instantiates a parameterized module with several different parameter values, uniquify:

  • discovered the concrete parameterizations in use,

  • generated a hardenable variant for each and a parameterized wrapper to dispatch to them,

  • hardened the variants into reusable macros, and

  • wired the wrappers and macros into the parent build.

See the Uniquify API for the full class reference.