From a model to silicon (SODA)#
SODA Synthesizer compiles a
machine learning model into accelerator RTL. Its front end, soda-opt,
works in MLIR: a model exported to the TOSA dialect is lowered to
linalg, the compute worth accelerating is outlined into a kernel, that kernel is
optimized for high-level synthesis, and an HLS tool turns it into
Verilog.
SiliconCompiler drives that front end and then keeps going. Everything from the generated Verilog onward – synthesis, place-and-route, signoff – is the ordinary ASIC flow, so a model reaches GDSII in a single job, with one manifest recording what every stage did.
Who this is for / prerequisites
You should be comfortable with the quickstart build
and with the idea of a design whose sources are not Verilog (see
Hardware design frontends).
You do not need to know MLIR to follow along; the one thing worth
knowing is that a .mlir file is a program in a dialect, and that the
flow’s job is to rewrite it into progressively lower-level dialects until it
is something an HLS tool can read.
The full flow needs mlir, soda, bambu, yosys, openroad,
opensta and klayout, plus FreePDK45 from lambdapdk.
Installing the toolchain#
Two installs, and the order matters:
sc-install mlir soda
mlir builds llvm-project – mlir-opt, mlir-translate, llvm-link,
opt and clang – at the revision soda-opt is developed against.
soda then builds soda-opt and soda-translate against that install.
The order matters and is not a convenience: soda-opt is an out-of-tree MLIR
project that links against MLIR’s C++ libraries, so it can only be built against
that exact LLVM revision. A distribution mlir-tools package will not do, and
the soda script stops with an explanation rather than guessing. Naming both
on one command line is enough: they are installed left to right, and a failure in
mlir stops before soda is attempted.
The whole group, including the backend, is one command:
sc-install -group asic-soda
Important
The HLS step needs a Bambu built against clang 16. LLVM 19 emits opaque
pointers (ptr) unconditionally – typed pointers were removed in LLVM 17
– and an older Bambu front end rejects the IR outright:
error: expected type
define void @forward_kernel(ptr %0, ptr %1, ptr %2) {
^
SiliconCompiler’s install-bambu.sh gives Bambu clang-16 on ubuntu24 and
ubuntu26, but clang-11 on ubuntu22 and clang-8 on ubuntu20. So those two are
where the flow runs end to end; on the older pair the MLIR front end works and
high-level synthesis does not. Nothing preflights it, so an unusable Bambu
surfaces at the last node rather than at the start – bambu --help lists
the front ends it was built with, and I386_CLANG16 is the one to look for.
Pointing Bambu at the clang this toolchain installs is not an option, which is
the obvious thing to try: Bambu compiles a set of plugins against each
supported clang’s internals, so the versions it accepts are built in rather
than configured, and there is no --with-clang=<path>. Its configure
knows clang 4-13 and 16 – on the pinned release and on upstream main
alike – so clang 19 cannot be used until Bambu itself ports its plugins to
it.
The example#
examples/soda holds mm, mm-no_weights model: a stateless
batched matrix multiply, which is what torch.matmul over a [1, 4, 8] and
a [1, 8, 4] tensor exports to.
// Batched matrix multiply, in the TOSA dialect.
//
// This is soda-benchmarks "mm-no_weights" model
// (examples/pytorch-to-gds/mm-no_weights/torchscript.py), a stateless
// torch.matmul over a [1, 4, 8] and a [1, 8, 4] tensor, exported through
// torch-mlir with output_type="tosa". Regenerate it with `smake model`, which
// runs the same export -- it is checked in so that the flow can be built
// without a PyTorch and torch-mlir install.
//
// The entry function must be named `forward`: soda-opt outlines the kernel it
// finds into `<function>_kernel`, and that outlined kernel -- `forward_kernel`
// -- is what Bambu synthesizes and what the design's topmodule has to be.
module attributes {torch.debug_module_name = "MM"} {
func.func @forward(%arg0: tensor<1x4x8xf32>, %arg1: tensor<1x8x4xf32>) -> tensor<1x4x4xf32> {
%0 = tosa.matmul %arg0, %arg1 : (tensor<1x4x8xf32>, tensor<1x8x4xf32>) -> tensor<1x4x4xf32>
return %0 : tensor<1x4x4xf32>
}
}
It is checked in so that the flow can be built without a PyTorch install.
smake model regenerates it by re-running the export, which is the one target
that needs pip install -r requirements.txt in the example directory first;
nothing else here does.
Two things about this file decide everything downstream.
The entry function is called forward. soda-opt outlines the kernel it
finds into <function>_kernel, so the synthesizable kernel here is
forward_kernel.
The topmodule is therefore forward_kernel, not mm. Bambu is
pointed at the design’s topmodule, and everything after it – synthesis, the
SDC, the final GDS – names that kernel. Getting this wrong points the
HLS tool at a function that does not exist; the soda task notices and says
so, but it is easier to get right the first time.
Building it#
The design’s sources are MLIR rather than Verilog. Everything else about it is an ordinary design:
class SODADesign(Design):
"""The ``mm`` design: a TOSA model plus the constraints its kernel is built to.
The topmodule is ``forward_kernel`` rather than ``mm`` because soda-opt
outlines the kernel it finds into ``<function>_kernel``, and that outlined
kernel is what Bambu synthesizes and what everything downstream sees.
"""
def __init__(self):
super().__init__()
self.set_name("mm")
self.set_dataroot("soda", __file__)
with self.active_dataroot("soda"):
# The model, in TOSA. `smake model` regenerates it. The fileset is
# named for what it holds rather than "rtl"; nothing keys off the
# name, and the front end looks for a .mlir file across all of them.
with self.active_fileset("soda"):
self.set_topmodule("forward_kernel")
self.add_file("mm.mlir")
# Read three times over a build: by Bambu as its high-level
# synthesis target, then by synthesis, then by place-and-route.
with self.active_fileset("sdc"):
self.add_file("mm.sdc")
mm.sdc constrains the clock port Bambu gives the RTL it generates, which is
called clock:
# Bambu names the clock port of the RTL it generates "clock", so the constraint
# has to be attached to that port. The period here is what the HLS scheduler
# targets, and the same file then constrains synthesis and place-and-route.
create_clock [get_ports clock] -name core_clock -period 5
set_input_delay 1.0 -clock core_clock [all_inputs]
set_output_delay 1.0 -clock core_clock [all_outputs]
That one file is read three times over a build: by Bambu as the period its scheduler has to hit, then by synthesis, then by place-and-route. Changing it changes the hardware, not just the report.
Selecting the flow is the whole of the rest – a model reaches GDSII in one job, and the only thing that says “SODA” is which flow was chosen:
def asic(strategy: str = "optimized", jobname: Optional[str] = None) -> ASIC:
"""Takes the model all the way to GDSII.
Args:
strategy (str): The soda-opt strategy to use.
jobname (str, optional): The job name. Defaults to the strategy.
Returns:
ASIC: The completed project.
"""
project = _project()
project.set_flow(SODAASICFlow(strategy=strategy))
_configure(project)
project.option.set_jobname(jobname or f"asic-{strategy}")
project.run()
project.summary()
project.snapshot()
return project
Run it, and every other stage, as an smake target:
cd examples/soda
smake elaborate # MLIR to Verilog: soda-opt plus Bambu, nothing else
smake syn # ...and on through synthesis and timing
smake asic # ...and on to GDSII
What the flow does#
SODAASICFlow is the ordinary ASIC flow with the SODA front end in front of
it. The front end on its own is one of the flows in
sodaflow, and it is worth knowing what each of its
nodes is for, because that is where a build goes wrong:
Node |
Tool |
What it does |
|---|---|---|
|
|
Lowers the TOSA operations to their linalg equivalents, still on tensors. |
|
|
Puts that linalg on buffers and turns the function’s result into an out-parameter. Hardware has no notion of returning a tensor. |
|
|
Outlines the kernel, optimizes it for HLS, lowers it to the LLVM dialect. This is the node the three strategies differ in. |
|
|
Emits LLVM IR, then strips the |
|
|
Compiles a C support module to LLVM IR – by default the bundled
|
|
|
Merges that module in, but only if the kernel calls it. Bufferized code
lowers |
|
|
High-level synthesis: LLVM IR to Verilog. |
Everything downstream of convert is the flow any Verilog design uses.
The three strategies#
The soda node is where SODA earns its name, and it offers three ways to
lower the outlined kernel:
baselineNo HLS-oriented optimization: linalg straight to the LLVM dialect. This is the reference – what the HLS tool makes of code nobody prepared for it.
optimizedRuns
soda-opt’s Bambu pipeline first: the loop nest is tiled, given local buffers, unrolled and scalar-replaced. This is the default, and the difference between it and the baseline is the result the SODA papers report.transformedApplies a schedule written in MLIR’s transform dialect instead of the fixed pipeline, then lowers. This is how a design expresses a strategy the pipeline’s knobs cannot. It needs a schedule to be worth running, so unlike the other two it is not exercised by the example – write the schedule first, then hand it to the task.
Each strategy is a flow of its own, so picking one is picking a flow:
from siliconcompiler.flows.sodaflow import SODABaselineElaborationFlow
# The front end on its own: MLIR in, Verilog out.
project.set_flow(SODABaselineElaborationFlow())
Any flow that elaborates a design takes one as its frontend, which is the
whole of how the SODA path reaches synthesis and GDSII – there is no SODA
backend, and no language string to spell:
project.set_flow(SynthesisFlow(frontend=SODABaselineElaborationFlow()))
project.set_flow(ASICFlow(frontend=SODABaselineElaborationFlow()))
SODAASICFlow is that last line with the strategy named instead, which is
what the example uses:
project.set_flow(SODAASICFlow(strategy="baseline"))
make.py in the example runs the comparison the strategies exist for:
smake compare
which synthesizes the baseline and the optimized kernel and prints Bambu’s own estimate beside what synthesis actually produced – how well the HLS tool predicted the hardware, and how much the optimization bought.
Turning the knobs#
Every stage of the optimization pipeline is a task setter, so a design can be
swept over them without touching the flow. Reach the task with find_task
once the flow is in the project:
from siliconcompiler.tools.soda.opt import OptimizedTask
project.set_flow(SODAASICFlow())
soda = OptimizedTask.find_task(project)
soda.set_soda_fullunrolls(3) # unroll one more level of the loop nest
soda.set_soda_tilesize(2) # tile every affine loop
soda.add_soda_permutation([1, 2, 0])
soda.set_soda_buffertrick(False) # no local buffers for the loop nest
Important
A flow copies the task it is handed, so configuring a task object before
passing it to Flowgraph.node() has no effect. find_task after
set_flow is the way to reach the one the flow will actually run.
set_soda_fullunrolls is the one that moves the result most: each application
unrolls one more level, trading cells for cycles. smake unrolled 3 in the
example synthesizes with a different depth so the trade is visible.
The transform-dialect strategy takes its schedule the same way. The schedule is
your own MLIR file, holding a transform.named_sequence @__transform_main that
rewrites the outlined kernel; it is required, so the flow stops at validation
without one rather than lowering the kernel unrewritten:
from siliconcompiler.tools.soda.opt import TransformedTask
project.set_flow(SODAASICFlow(strategy="transformed"))
TransformedTask.find_task(project).set_soda_schedule("my_schedule.mlir")
The support module the kernel is linked against is a knob too – any C file whose LLVM IR should end up inside the kernel goes here, and the module it produces is named after it:
from siliconcompiler.tools.mlir.compile import RuntimeTask
from siliconcompiler.tools.mlir.link import LinkTask
RuntimeTask.find_task(project).set_mlir_source("helpers.c")
# ...which the link step picks up as "the upstream module that is not the
# kernel", so it needs no name of its own. What it does need is the symbol
# that decides whether merging is worth it:
LinkTask.find_task(project).set_mlir_requiredsymbol("myHelper")
and the HLS step has its own:
from siliconcompiler.tools.bambu.convert import ConvertTask as BambuConvertTask
bambu = BambuConvertTask.find_task(project)
bambu.set_bambu_memorychannels(2)
bambu.set_bambu_experimentalsetup("BAMBU-BALANCED-MP")
# Anything without a setter of its own still reaches the command line:
bambu.add_commandline_option("--max-sim-cycles=2000000")
The target supplies the rest. FreePDK45’s Nangate45 library already carries
--device=nangate45 for Bambu.
Where to look when it fails#
The front end fails in a small number of recognizable ways:
The soda node emits nothing to outline. The module reached it without a
kernel to find – usually because bufferize did not produce linalg on
buffers. Read bufferize/0/outputs/<top>.mlir; if it still mentions tosa.
or tensor<, the lowering is what to fix.
The kernel has the wrong name. The soda node warns when the kernel it
outlined is not the design’s topmodule. Rename the topmodule to match, or point
set_soda_anchorfunc at the function you meant.
Bambu rejects the LLVM IR. Look at link/0/outputs/<top>.ll. An unresolved
memrefCopy means the runtime node did not run or the link node
decided it was not needed; translate’s intrinsic strip covers the other
common case.
A pass name is not recognized. soda-opt and mlir-opt have to come from
the same llvm-project revision. sc-install mlir then sc-install soda, in
that order, is what keeps them in step.
Bambu reports error: expected type on a ptr argument. Its front-end
compiler is older than clang 16 and cannot read opaque pointers. See the install
note above; this is a property of the Bambu build, not of the design.
For the pass-by-pass view, turn on the IR dump – verbose, but it is the only practical way to see which pass broke a lowering:
OptimizedTask.find_task(project).set_soda_printirafterall(True)