Timing-closure ECOs of the track recipe¶
The closure loop of scripts/rtl2gds.py (hold_closure.Flow.close, track
recipe, sizing_profile set) repairs DRV, setup and hold on complete
layouts: every iteration is import, place, CTS, route, native extraction
and RustSTA (pinned 0.4.3, 26bd6eb) at tt+nom / ss+max / ff+min. This page
describes the ECOs added for the larger designs (buffer insertion,
whatif-verified sizing, path-aware hold fixing, seeded timing-driven
placement) and the loop order. Receipts of earlier rounds
(docs/evidence/rtl2gds-final2.json, docs/evidence/rtl2gds-closure.json,
docs/evidence/critical-path.json) are historical evidence kept for
reproducibility, not current benchmarks.
Nothing here changes an SDC, Liberty, RC profile, threshold, deck or status
rule. Every ECO is a typed rustroad.eco-plan.v1 applied by
rustroad apply-eco (or written by rustroad hold-eco), every decision is
re-checked on the next complete layout, and RustSTA is the only timing
oracle inside the loop.
Loop order and bounds (Flow.sized_step)¶
One decision per layout, at most budgets.max_hold_iterations (8) layouts:
- DRV / setup (
closure_eco): any max_transition / max_capacitance / max_fanout violation, or setup < 0 at tt/ss -> buffer insertion pluswhatifsizing, applied as one plan; timing-driven net weights for the next placement while setup fails. - Hold (
hold_eco, path-aware, partial plans allowed) when hold is below the ECO target. After the first DRV/setup ECO, a layout that needs both gets both: the hold plan first (it is bound to the timed netlist), then the DRV/setup plan applied on top of it, never re-buffering a sink a hold chain moved. - Antenna (unchanged) on a setup-, hold- and DRV-clean layout.
DRV/setup ECOs stop (the loop goes on to hold) after two consecutive
layouts on which the previous one improved neither the DRV count nor the
worst tt/ss setup slack by 0.05 ns (SETUP_STAGNATION_*). The loop ends
when everything is closed, when nothing applies, or at the layout bound.
From the second layout on, placement is seeded from the previous
layout (place --seed): unchanged cells keep their positions up to
legalization displacement, so a small ECO does not reshuffle every wire
(without it, every re-placement moved marginal DRV violations to new nets).
Buffer insertion (scripts/closure_eco.plan_buffering)¶
Inputs: the RustSTA drv report (it lists every pin, so the driver's total
load per corner comes from its max_capacitance row and each pin's slew from
its max_transition row), the pins reports at ss and tt (per-pin setup
slack), the layout DEF (integer DBU positions) and the sizing profile's
Liberty drive data (below).
- Nets planned: a transition or capacitance above 0.95 x its limit (planning margin: the next layout moves every wire) or a fanout above its limit, at any corner, on a net driven by an instance of the mapped netlist or by an input port. Skipped, with the reason recorded: a driver whose Liberty model meets 0.8 x the limit at its own load (the slew comes from upstream), a small-fanout net a larger driver can fix (left to sizing), a net whose sinks are already ECO buffers (no stacking), CTS nets.
- Clusters: the movable sinks (instance input pins and output ports
read through an
assign) are split by recursive bisection on their positions (larger spread axis, ties by name) until each cluster's load (pin capacitances + wire capacitance per DBU of this net x cluster HPWL) is within what the strongest buffer drives at 0.6 x the transition limit and 16 sinks; a second level is built when more than 16 buffers would load the driver. Each cluster gets the buffer meeting the target with the smallest estimated delay (its own delay plus the driver's extra delay for its input capacitance) among those whose input capacitance fits the driver's budget. The analytic placer then pulls the buffer to its cluster. - Setup guard: a sink with less than 0.75 ns of setup slack never goes behind a buffer; any other moves only if its estimated slack after the edit (driver delay at the new load + buffer delay + 0.35 x buffer output slew, against today's driver delay + 0.35 x slew) stays >= 0.15 ns. The estimated cost of each moved sink is charged to every pin downstream of it through combinational cells (max over paths, bounded walk), so two buffers of one plan cannot spend the same path's slack twice. Without these rules every stage of a critical path could get its own buffer.
- Input ports are buffered the same way (port nets cannot be resized).
- Critical-sink isolation: on a net with failing setup, sinks well above the net's worst slack move behind one buffer (same guard) so the driver sees less load.
- Bounds: 512 buffers, 32 clusters per net, 16 sinks per cluster, two levels, 4,096 sinks per net, 2,000,000 pin visits of the guard walks.
buffer_sinks (rustroad_formats::eco) is the ECO primitive: all named
sinks of ONE net and the named output ports read through assign move
behind one new buffer (rr_eco_buf_<k>); the net keeps its driver and
every other sink. Buffer masters: the sizing profile's identity-function
families (Liberty-derived, cell policy applied), accepted only after the
same BufferCell::check_identity as hold cells; the gate's cell_proofs
proves every inserted master with Yosys at each corner Liberty.
Sizing with RustSTA whatif (Flow.whatif_sizing)¶
A resize keeps every net, so ruststa whatif evaluates it exactly on the
layout's SPEF (bit-identical to a full analysis). First, one call evaluates
two bulk candidates: every DRV driver (worst first) one size up, and two
sizes up, within the tap gap and the utilization cap; the better one is
accepted as a whole only under the same rule as a single resize (below).
Then the single-resize rounds: candidates are drivers of (near-)violating
nets (one or two sizes up, or one down: the largest member is not always
the fastest edge at a light load, GF180 aoi22_4 vs aoi22_2), ECO buffers
loading such a net (one size down), and the cells on the worst setup paths
of up to 48 failing endpoints (one down, one and two up), within the tap gap
and a 74 % standard-cell utilization of the rows that also bounds buffer
insertion. Each round evaluates every candidate on top of the resizes
accepted so far and accepts up to 12 improving ones on distinct instances:
fewer DRV violations without
losing worst setup (or keeping it >= 0), a better worst setup slack, or an
equal worst and a better total; a resize may lower hold below the ECO target
by at most 0.5 ns (the hold ECO repairs it next). The next round's base
re-checks the accepted set as a whole and keeps only the single best edit of
a round that made it worse. Bounds: 6 rounds, 48 candidates per round, 128
candidates per layout and 640 per closure loop, both scaled by 200 / logic
instances within 0.5x..4x (counter 512 per layout, mult 64): counts, never
measured time, so the determinism rebuild evaluates exactly the same
candidates.
The earlier sizing step (one size up on every setup-path cell, model-free) is gone: it could make the ideal-wire setup slack worse.
Path-aware hold fixing (rustroad hold-eco --pins-report ...)¶
With pins reports (setup slack of every pin at ss and tt, same STA
revision, bound like the other reports), hold-eco places delay on the
worst hold path instead of in front of the endpoint:
- Points of a violating endpoint: every sink pin of its worst hold
path (
netstages;launchand clock pins never), and for each of them the whole net feeding it when it has at most 4 input-pin sinks (one chain in front of every sink, e.g. a small input port). A point is kept only if it lies on the worst path at every corner where the endpoint violates. - Setup room of a point: the setup slack of the pin it delays every path through (the sink pin, or the net's driver / input port) at ss and tt, minus the delay already planned on points of the same path, minus the 0.05 ns setup margin, against the chain's max(rise, fall) delay per corner.
- Choice: endpoints largest need first; among the feasible points the one on the most violating paths, then the most downstream (a point after a reconvergence delays every parallel branch at once; branch pins of a multi-input gate fixed one by one cost one layout each), then the smallest chain (same multiset search as before). The loop plans with 0.03 ns above its ECO target (estimates at one representative load under-deliver). Delay planned on a shared point is credited to every later endpoint whose path crosses it.
- Infeasible: an endpoint none of whose points has the room is listed
(
HOLD_ECO_INFEASIBLE) with the remaining need per corner and the setup budget of each point as evidence.--allow-partialapplies the feasible part (statuspartial); without it the plan fails as before. Without--pins-reportthe planner is the original endpoint planner (same edited netlists; the plan JSON has three more parameter fields), which the legacy recipe (pipeline) keeps using.
Net-level chains are emitted as one buffer_sinks (the sink-side cell)
plus insert_buffer on its input for the rest of the chain. The
removed_hold_cell control removes a chain wherever it sits (every pin it
drives returns to the original net).
Timing-driven, seeded placement (rustroad place)¶
--net-weights FILE(rustroad.net-weights.v1): per-net integer weights in milli-units (1000 = 1.0, at most 16,000) scale the bound-to-bound springs and the detailed-placement HPWL cost; unit weights reproduce the unweighted placement bit for bit. The loop writes them while setup fails:1000 + 4000 x crit^2,crit = clamp((worst + 1 ns - slack) / 1 ns)of the net driver's setup slack (worst of ss/tt). Unknown names are counted (unmatched), never guessed.--seed FILE(rustroad.placement-seed.v1, instance -> lower-left DBU): seeded cells target their seed, new cells the mean of their seeded or fixed net neighbours (4 propagation passes), then the same legalizer and detailed placement as global placement; no global solve. The report gives seeded / unseeded / unmatched counts and the displacement.- Both are stored with the physical bundle, so the CTS re-placement uses them too; integer DBU in and out, deterministic, work-bounded.
rustroad --workspace <temporary-layout> export-placement-seed > seed.json
captures a placed snapshot for a fresh ECO workspace. The command holds a
shared workspace lock and leaves HEAD unchanged. It omits generated filler
and tap instances by their recorded roles, retaining logical instances,
fixed macros and clock buffers. The source records the revision, design,
DBU scale and imported netlist SHA-256. Output is bounded by one million cells
and a conservative 64 MiB encoded-size limit. A seed is a legalization target;
new ECO cells still require placement, and final CTS is performed once in the
fresh workspace. Fresh end-to-end measurements must generate the seed during
that run and include the temporary placement/CTS and ECO planning costs.
Liberty drive data (scripts/build_sizing_profile.py)¶
cells[].drive.<corner>: input pin capacitances and, for one-output
families, the worst delay and output transition (max of rise/fall, over
combinational and edge arcs) at 0.2 ns input slew and 5 / 50 fF, by
bilinear lookup inside each corner Liberty table (no extrapolation). The
planners interpolate linearly between the two points; estimates only
choose an edit, the next layout decides.
Critical-path report (scripts/critical_path.py)¶
For finished gate runs: RustSTA without parasitics and with the ideal clock
on the synthesized and on the final closure netlist (pre-layout), and the
gate's own final native-SPEF analysis (post-route), worst setup at ss and
tt, the implied minimum period period - worst slack, and the worst path's
end points. GF180 without SPEF is ideal (no wire-load table), SKY130 is
wireload_lumped (Liberty default wire load, an upper bound of ideal).
GF180 DRV dont_use¶
The ten GF180 families that cannot reach 1.0 ns at all are excluded from
synthesis by the owner's dont_use rule (scripts/derive_drv_dont_use.py,
fixtures/flow/gf180-drv-dont-use.json; derivation in docs/rtl2gds.md).
Remaining transition violations are left to the closure loop above.