Parallel Job Execution#

Single-threaded performance has saturated, so making hardware compilation fast means making effective use of parallel hardware. Two things work in our favour: the compute-to-data ratio of the expensive compilation steps is high, and several of those steps partition into embarrassingly parallel problems.

This tutorial runs one workload three ways – synthesizing the same adder at four datawidths – and shows where the parallelism comes from in each. The code is a single script:

./parallel.py serial
./parallel.py indexed
./parallel.py processes

Each prints its own wall-clock time. serial and processes run identical work and differ only in how it is scheduled, so those two compare directly. indexed is not a like-for-like third measurement – it deliberately synthesizes four variants per datawidth, for the reason given in that section.

The Workload#

One Design carries an rtl.<n> fileset per datawidth, each setting the Verilog parameter N:

"""Build one design carrying an "rtl.<n>" fileset per datawidth."""
design = Design("adder")
design.set_dataroot("parallel", __file__)

for n in DATAWIDTHS:
    with design.active_dataroot("parallel"), design.active_fileset(f"rtl.{n}"):
        design.set_topmodule("adder")
        design.add_file("adder.v")
        design.set_param("N", str(n))

Nothing about this is specific to parallelism – it is the same sweep-by-fileset pattern used in Multi-Job Flows.

Approach 1: Serial#

The baseline: each datawidth runs to completion before the next one starts, and within a job only one flowgraph node runs at a time.

// Approach 1: completely serial. One job at a time, one node at a time. // Rendered at build time by sphinx.ext.graphviz; do not commit an image. digraph serial { rankdir=LR; node [shape=box, style=filled, color=white, fontname="Helvetica"]; graph [fontname="Helvetica"]; subgraph cluster_8 { style=filled; color=lightgrey; label="job N8"; e8 [label="elaborate"]; s8 [label="synthesis"]; t8 [label="timing"]; e8 -> s8 -> t8; } subgraph cluster_16 { style=filled; color=lightgrey; label="job N16"; e16 [label="elaborate"]; s16 [label="synthesis"]; t16 [label="timing"]; e16 -> s16 -> t16; } subgraph cluster_n { style=filled; color=lightgrey; label="job N..."; en [label="elaborate"]; sn [label="synthesis"]; tn [label="timing"]; en -> sn -> tn; } t8 -> e16 [style=dashed, label=" wait"]; t16 -> en [style=dashed, label=" wait"]; }

design = make_design()

for n in DATAWIDTHS:
    project = make_project(design, n)
    # Pin the scheduler to a single node so nothing overlaps. This is the
    # baseline, not a recommendation.
    project.option.scheduler.set_maxnodes(1)
    project.run()


[option,scheduler,maxnodes] is pinned to 1 here only to make the baseline honest. Left alone it defaults to the number of available cores, so SiliconCompiler already overlaps independent nodes without you asking – the serial case is the one you have to opt into.

Approach 2: Index Parallelism – Inside a Job#

Indices are variants of a step operating on identical input data. They have no edges between them, so the scheduler is free to run them concurrently, and a minimum node picks the best result.

// Approach 2: index parallelism inside one job. The four synthesis indices have // no edges between them, so the scheduler runs them concurrently; "min" picks // the best result. // Rendered at build time by sphinx.ext.graphviz; do not commit an image. digraph indexed { rankdir=LR; node [shape=box, style=filled, color=white, fontname="Helvetica"]; graph [fontname="Helvetica"]; subgraph cluster_job { style=filled; color=lightgrey; label="job N8 (syn_np=4)"; elaborate; s0 [label="synthesis/0"]; s1 [label="synthesis/1"]; s2 [label="synthesis/2"]; s3 [label="synthesis/3"]; min; timing; elaborate -> s0; elaborate -> s1; elaborate -> s2; elaborate -> s3; s0 -> min; s1 -> min; s2 -> min; s3 -> min; min -> timing; } }

Flows expose this through _np arguments – syn_np here, and floorplan_np, place_np, cts_np, route_np on asicflow:

design = make_design()

for n in DATAWIDTHS:
    # Four synthesis indices per job. The jobs still run one after another,
    # but each one now uses several cores instead of one -- and produces four
    # variants rather than one, which is why this run is not a like-for-like
    # timing comparison against the other two.
    project = make_project(design, n, syn_np=4)
    project.run()


This is the approach to reach for when you want to explore – several tool configurations against the same input, with the winner selected automatically. Using Index for Optimization builds a synthesis sweep out of the same primitives.

Warning

Give the flow its own name. A target has usually already registered a flow under the default name, and constructing another with that name resolves back to its copy:

project.set_flow(SynthesisFlow(syn_np=4))                 # silently syn_np=1
project.set_flow(SynthesisFlow(name="sweep", syn_np=4))   # four indices

The failure mode is quiet: the run succeeds, just without the extra indices. Check with project.get("flowgraph", project.option.get_flow(), field="schema").get_nodes().

Approach 3: Process Parallelism – Across Jobs#

Index parallelism cannot help when the runs differ in their inputs: four datawidths are four different elaborations, so they are four different flows. Because they share nothing, they can run as independent processes.

// Approach 3: process parallelism across jobs. Each datawidth is an independent // flow in its own OS process; nothing is shared, so all of them start at once. // Rendered at build time by sphinx.ext.graphviz; do not commit an image. digraph processes { rankdir=LR; node [shape=box, style=filled, color=white, fontname="Helvetica"]; graph [fontname="Helvetica"]; pool [label="multiprocessing.Pool", shape=ellipse, style=filled, color=darkgrey, fontcolor=white]; subgraph cluster_8 { style=filled; color=lightgrey; label="process 1 -- job N8"; e8 [label="elaborate"]; s8 [label="synthesis"]; t8 [label="timing"]; e8 -> s8 -> t8; } subgraph cluster_16 { style=filled; color=lightgrey; label="process 2 -- job N16"; e16 [label="elaborate"]; s16 [label="synthesis"]; t16 [label="timing"]; e16 -> s16 -> t16; } subgraph cluster_32 { style=filled; color=lightgrey; label="process 3 -- job N32"; e32 [label="elaborate"]; s32 [label="synthesis"]; t32 [label="timing"]; e32 -> s32 -> t32; } subgraph cluster_64 { style=filled; color=lightgrey; label="process 4 -- job N64"; e64 [label="elaborate"]; s64 [label="synthesis"]; t64 [label="timing"]; e64 -> s64 -> t64; } pool -> e8; pool -> e16; pool -> e32; pool -> e64; }

# Each datawidth is an independent flow with no data shared between them,
# so they can run as separate processes.
#
# Two constraints shape this, and both come from run() starting processes of
# its own inside each worker:
#
# ProcessPoolExecutor, not multiprocessing.Pool -- Pool's workers are
# daemonic, and a daemonic process may not have children, so run() dies with
# "daemonic processes are not allowed to have children".
#
# get_process_context(), not the interpreter default -- SiliconCompiler pins
# the same context for its own workers, and the default changed to forkserver
# on Linux in Python 3.14.
with ProcessPoolExecutor(len(DATAWIDTHS), mp_context=get_process_context()) as pool:
    for n, area in pool.map(_run_one, DATAWIDTHS):
        print(f"N={n:<3} cellarea={area}")


The worker returns a metric rather than the project, because what crosses a process boundary has to be picklable:

"""Worker body: a complete, independent run in its own process."""
project = make_project(make_design(), n)
project.run()
return n, project.history(f"N{n}").get(
    "metric", "cellarea", step="synthesis", index="0")


Warning

Do not use multiprocessing.Pool for this. Its workers are daemonic, and a daemonic process may not have children. Project.run() forks a worker per flowgraph node, so inside a Pool it fails with daemonic processes are not allowed to have children before any tool starts. ProcessPoolExecutor does not make its workers daemonic, which is why it is used above. Plain multiprocessing.Process also works.

Note

Guard your script. SiliconCompiler forks its own node workers on Linux, but a script that itself starts processes must be import-safe: on macOS and Windows the child re-imports the file, and without if __name__ == "__main__": it recurses instead of running.

Choosing Between Them#

Approach

Use when

Bounded by

Index

Runs share an input and differ in tool settings; you want the best result picked for you.

[option,scheduler,maxnodes], and the width the flow was built with (_np).

Process

Runs differ in their inputs – different designs, parameters, or targets – and you want all the results.

Your pool size, and memory: each process holds a full toolchain.

Both

A sweep where each point also explores settings.

Multiply the two; it is easy to oversubscribe.

The two compose, and that is where oversubscription starts: a pool of 4 processes each running a flow with syn_np=4 can ask for 16 concurrent tool invocations, each of which may itself be multi-threaded. Bound it with [option,scheduler,maxnodes] and [option,scheduler,maxthreads] – see Control how much of the machine a run uses.

See also

  • Compilation process – steps, indices, and how the flowgraph is executed.

  • Multi-Job Flows – chaining and sweeping jobs, and reading results back out of history.

  • Remote processing – moving the same flows onto a cluster, where the ceiling is much higher than one workstation.