Timing closure and PPA exploration for LibreLane designs.
You have a design that hardens. You do not know how fast it actually runs, which
synthesis options are worth using, or which module is eating the critical path.
Finding out by hand means editing config.yaml, launching a run, waiting,
opening metrics.json, writing the number down, and repeating — for hours.
ppa does that loop for you and shows you the answer.
$ ppa fmax --stage pre
==> closing timing on heichips26_e2spike (stage pre, start 12.0 ns)
PASS fmax_pre_00_12.00ns 12.00 ns 85.3 MHz +0.283 ns ws 104478 um2 15s
PASS fmax_pre_01_11.77ns 11.77 ns 85.7 MHz +0.097 ns ws 104478 um2 15s
tag stage period fmax_mhz setup_ws setup_vio area_um2 ok
------------------- ----- ------ -------- -------- --------- -------- ---
fmax_pre_00_12.00ns pre 12.00 85.3 +0.283 0 104478 yes
* fmax_pre_01_11.77ns pre 11.77 85.7 +0.097 0 104478 yes
Fmax 85.7 MHz at 11.77 ns (setup ws +0.097 ns, tag fmax_pre_01_11.77ns)
re-run with --apply to write CLOCK_PERIOD: 11.767 into config.yamlTwo runs, thirty seconds, done.
OpenROAD clamps timing__setup__wns at zero.
The moment a design passes, WNS reads 0.000 no matter whether there are 3 ns of
headroom left or 3 ps. Every naive Fmax search steers on WNS, sees zero, decides
it has converged, and stops at whatever period you happened to start from. You
then ship a chip clocked 30 % below what the silicon can do and never find out.
timing__setup__ws is the raw worst slack — positive with headroom, negative
without. This tool reads ws everywhere, which is what makes the search work at
all, and it is why ppa finds Fmax in two runs instead of a dozen bisection
steps.
The same clamp is why ppa reports an Fmax for failing runs too. A run with
−1.3 ns of slack still tells you exactly what the design can do; throwing that
information away is what makes manual searches slow.
pip install git+https://github.com/OutBlade/librelane-ppaOr, from a checkout:
pip install -e ".[dev]"Pure Python, one dependency (PyYAML), works on 3.9+. LibreLane itself is not a
Python dependency — ppa shells out to the librelane on your PATH, so run it
from inside whatever nix-shell / container your flow already uses.
cd your-design/ # anywhere at or below the design directory
ppa info # what did it find, and can it run?
ppa fmax --stage pre # how fast is this design, really?ppa info finds the config on its own (flow/librelane/config.yaml,
openlane/config.json, or macros/*/flow/librelane/config.*), picks up PDK and
PDK_ROOT from the environment or from a PDK checkout in the repo, and tells you
what is missing before you burn an hour on a run that was never going to start:
$ ppa info
design heichips26_e2spike
config .../macros/heichips26_e2spike/flow/librelane/config.yaml
pdk ihp-sg13cmos5l
pdk root .../IHP-Open-PDK
clock 10.0 ns
runs .../flow/librelane/runs
database .../ppa_results.json (12 runs)
ok everything needed for a run is in place| command | what it does |
|---|---|
ppa info |
show the detected design and check the environment |
ppa fmax |
drive CLOCK_PERIOD to the fastest value that still closes |
ppa sweep |
run a grid of config variants and rank them |
ppa run |
one run with explicit overrides |
ppa paths |
where the critical path actually spends its time |
ppa report |
every recorded run, as a table, Markdown, CSV or JSON |
ppa compare |
the delta between two runs |
ppa pareto |
the speed/area trade-off front |
ppa clean |
remove generated config files |
Every command takes --stage, which decides how much of the flow to run:
| stage | runs to | typical cost | use it for |
|---|---|---|---|
synth |
Yosys | seconds | area only |
pre |
OpenROAD.STAPrePNR |
~15 s | ranking options, bracketing Fmax |
cts / route |
the matching OpenROAD step | minutes | debugging a specific stage |
post |
OpenROAD.STAPostPNR |
~1 h | the number you actually quote |
full |
the whole Classic flow | hours | signoff, DRC and LVS included |
The intended workflow is explore with pre, confirm with post. Pre-PnR STA
on this design lands within 2 % of the post-PnR answer at 1/300th of the cost.
ppa fmax --stage pre # start from the config's CLOCK_PERIOD
ppa fmax --stage post --start 12 # the number you quote in the datasheet
ppa fmax --stage pre --guard 0.3 # keep 300 ps of margin
ppa fmax --stage pre --apply # write the winning period back into config.yamlThe search is not a bisection. Each run reports how much slack it has, and
next_period = period - slack + guard
is the arrival time of the critical path, i.e. the shortest period that can
possibly work. Aiming straight at it converges in two or three runs from either
direction — whether the design started out passing or failing. --guard adds a
margin you keep on purpose; --tolerance (default 50 ps) decides when squeezing
further stops being worth another run.
--apply edits CLOCK_PERIOD in the real config in place and leaves a comment
saying where the number came from.
ppa sweep --set "SYNTH_STRATEGY=AREA 0,AREA 2,DELAY 0,DELAY 2" --jobs 2Repeat --set for a full grid (--set A=1,2 --set B=x,y runs four points).
Values containing spaces are fine as long as you quote them. --jobs overlaps
runs, which is close to free because LibreLane is mostly single-threaded.
Real output from the e2spike neuromorphic accelerator at 11.77 ns, IHP
ihp-sg13cmos5l, slow corner:
| SYNTH_STRATEGY | setup ws | setup vio | area µm² | verdict |
|---|---|---|---|---|
AREA 0 (default) |
+0.099 | 0 | 104 478 | best |
AREA 2 |
−3.262 | 69 | 103 807 | 66 MHz, 0.6 % smaller |
DELAY 0 |
−8.680 | 210 | 116 372 | slower and 11 % bigger |
DELAY 2 |
−8.688 | 262 | 113 656 | slower and 9 % bigger |
The "delay" strategies are 43 % slower and 11 % larger than the default. That is the kind of result worth having before someone spends a week tuning the wrong knob.
This is the part a slack number cannot tell you. ppa paths parses OpenROAD's
max.rpt, throws away the launch and capture clock trees, and maps what is left
back onto the RTL hierarchy using the net names that survived synthesis.
$ ppa paths --run fmax_post_00_12.00ns --top 6
==> fmax_post_00_12.00ns, corner nom_slow_1p08V_125C, 885 reported paths
# slack delay depth group from to
- ------ ------ ----- ----- ------- -------
0 +0.146 11.509 24 clk _13816_ _14219_
1 +0.158 11.532 24 clk _13816_ _14218_
2 +0.266 11.427 23 clk _13816_ _14226_
worst path: 11.509 ns through 24 cells
launched in U_MICRO_BRAIN_TOP.U_CORE_TOP.U_FC_CTRL
captured in U_MICRO_BRAIN_TOP.U_CORE_TOP.U_FC_CTRL
split by kind of cell
████████████████████████ 6.507 ns 56.5% repair / buffering inserted by OpenROAD
██████████████████░░░░░░ 5.002 ns 43.5% logic cells
split by RTL module
████████████████████████ 11.509 ns 100.0% U_MICRO_BRAIN_TOP.U_CORE_TOP.U_FC_CTRL
slowest cells on the path
1.978 ns _09873_/Y sg13cmos5l_nand4_1 fanout 5
0.958 ns _09928_/Y sg13cmos5l_nor2_1 fanout 1
0.785 ns fanout148/X sg13cmos5l_buf_1 fanout 8
0.603 ns hold2598/X sg13cmos5l_dlygate4sd3_1 fanout 1Two actionable facts fall out of that in one command: the entire critical path
lives inside a single module, and 56 % of it is buffering the tool inserted,
not logic anybody wrote. Both point at concrete fixes; neither is visible in
metrics.json.
Only some net names survive synthesis — combinational cones come out as _00925_
or net669. A flat net is attributed to the module that last drove a named one,
which is where that cone came from. It is a heuristic, and it is the only mapping
back to RTL that survives flattening.
--hold does the same for min.rpt. With no --corner, the corner with the
least slack is chosen for you.
Every run ever executed is appended to ppa_results.json next to the design, so
the history outlives the terminal it was produced in.
ppa report # everything
ppa report --stage post --passing # only post-PnR runs that closed
ppa report --sort fmax_mhz --desc --format md # paste straight into a PR
ppa report --all --format csv > runs.csv # all 20 metrics, for a spreadsheet
ppa compare fmax_pre_01_11.77ns fmax_post_00_12.00ns
ppa pareto --stage pre # undominated speed/area points$ ppa compare fmax_pre_01_11.77ns fmax_post_00_12.00ns
fmax_pre_01_11.77ns -> fmax_post_00_12.00ns
period 11.77 12.00 +0.233 (+2.0%)
fmax_mhz 85.7 84.4 -1.326 (-1.5%)
setup_ws +0.097 +0.146 +0.050 (+51.2%)
hold_ws -0.102 +0.274 +0.375
hold_vio 600 0 -600 (-100.0%)
area_um2 104478 199771 +95293 (+91.2%)
power_w 0.0104 0.0171 +0.007 (+64.6%)
seconds 15.1 4994.2 +4979 (+32974.2%)That single table is a good example of why pre-PnR numbers need a health warning: the Fmax estimate held to 1.5 %, the 600 pre-PnR hold violations were an artefact that CTS repaired, and the area nearly doubled once fill and repair cells landed.
ppa_results.json is plain JSON — greppable, diffable, and safe to commit next
to the design. Every run stores its tag, stage, overrides, effective period,
runtime, exit status and 20 metrics:
setup/hold ws · wns · tns · violation counts · instance area · cell count ·
total and leakage power · max slew/cap/fanout violations · inferred latches ·
unmapped cells · Magic DRC errors · Netgen LVS errors · antenna violations.
--corner picks the per-corner variant of every metric that has one; metrics
that are corner independent (area, cell count) are kept as they are.
Both useful modes are one line each:
- name: the design must still close timing at the configured period
run: ppa run --stage pre && ppa report --last 1 --passing --format csv | grep -q .
- name: track Fmax over time
run: ppa fmax --stage pre && ppa report --format md >> "$GITHUB_STEP_SUMMARY"Output detects a non-TTY and drops colour and the self-overwriting progress line
automatically, so CI logs stay readable. NO_COLOR and --no-color also work.
For each point of a search, ppa writes a _ppa_<tag>.yaml next to your real
config — that location is not optional, because LibreLane resolves dir::
paths relative to the config file, so a variant written to /tmp would silently
lose your RTL and SDC files. It then invokes
librelane _ppa_<tag>.yaml --pdk <pdk> --pdk-root <root> --manual-pdk \
--run-tag <tag> --to <step>
streams the output to runs/<tag>/ppa.log, showing only the current step on one
line, harvests runs/<tag>/final/metrics.json, and deletes the generated config.
Your config.yaml is never touched unless you ask for --apply.
--dry-run prints the exact commands and runs nothing. --keep-configs leaves
the generated files behind so you can run one by hand. ppa clean removes
leftovers from an interrupted session.
pip install -e ".[dev]"
pytest -q # 72 tests, ~1 s, no EDA tools required
ruff check .The suite ships a fake librelane on PATH that models a design with a fixed
critical-path arrival time — and reproduces the WNS clamp, so a regression that
starts steering on wns instead of ws fails the tests instead of quietly
losing you 30 % of your clock.
Written against LibreLane 3.x with the IHP ihp-sg13cmos5l PDK, on a HeiChips /
TinyTapeout style repository layout. Nothing is specific to that PDK or that
layout: metric names come from LibreLane, report parsing comes from OpenSTA, and
both the config location and the PDK are auto-detected or overridable with
-C / --pdk / --pdk-root. It should work unchanged on sky130 and gf180 via
OpenLane 2 configs.
Apache-2.0.