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.
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.
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.
# 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. |
|
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.