Proof leg of the full-stack goal (see devphys): re-implement the
taped-out CORDIC-1 chip using a self-designed standard-cell library —
every transistor chosen, sized from measured device behavior, characterized
with our own tooling — and compare PPA against the foundry library version
that went to fabrication (TTSKY26c, commit b646d057).
- Device probe (
flow/device_probe.py): measure sky130 n/pFET drive currents in ngspice → transistor sizing rules for the library. - Cell netlists (
flow/cells.py): 9 characterized static-CMOS cells at transistor level (INV x3, BUF x3, NAND2, NOR2, DFF), one entry per physical finger, generated with the measured sizing — plus 7 physical-only cells (tie/tap/diode/fill) inflow/layout.py. - Own characterizer (
flow/characterize.py): ngspice transient measurements → NLDM Liberty + Verilog models. Delays, transitions, input caps, leakage, clk→Q, setup — all measured by us, at each of the three sky130A signoff corners (out/own_tt_025C_1v80.lib,own_ss_100C_1v60.lib,own_ff_n40C_1v95.lib;own.libremains the nominal one). See Timing corners below for why one PVT was not enough. - Synthesis PPA comparison (
flow/synth_compare.py): yosys+ABC maps the REAL CORDIC-1 RTL (../tt-cordic/src) to (a)own.liband (b)sky130_fd_sc_hd tt→out/REPORT.md. - Cell layouts (
flow/layout.py, gdstk) → KLayout DRC (flow/run_drc_all.py, officialsky130A_mr.drcdeck) + LVS (flow/run_lvs_all.py, officialsky130.lvsdeck) → LEF abstracts (flow/make_lef.py, exact pin/OBS rectangle decompositions from the signoff GDS). - Hardening (
flow/make_hardening.py→harden/): the all-own netlist (our combinational cells AND our DFF_X1) placed & routed by LibreLane in CI on the TinyTapeout 1x1 tile. - Magic-native views (
flow/magic_views.tcl+ themagic-viewsworkflow):.mag/.maglefper cell + magic DRC held to foundry-cell parity.
Same taped-out RTL, same yosys+ABC flow, two Liberty targets:
| metric | own library | sky130_fd_sc_hd | ratio own/hd |
|---|---|---|---|
| mapped cells | 1782 | 969 | 1.84 |
| chip area (µm²; all own areas from signoff layouts) | 9 106 | 8 139 | 1.12 |
| ABC critical path (ps) | 1 890 | 3 525 | 0.54 |
| meets the tapeout's 50 MHz | YES | YES | — |
v2 is the library the phase-6 routing failure demanded. v1 sized for
symmetric edges (Wp = 2.61×Wn, measured) and proved DRC/LVS-clean — then
detailed routing rejected it: the fat folded PMOS closes the cell
mid-band, so input pins have no in-cell access point (DRT-0073; tag
v1-symmetric-drive, 2.17× hd area). v2 rebuilds every cell at
Wp=1.0/Wn=0.65 single-finger — the sky130_fd_sc_hd architecture, studied
from the PDK GDS and re-implemented generatively in flow/layout.py —
which opens the mid-band and puts every pin at y≈1.19, clear of both
rail shadows. All 7 cells came out DRC-clean in TWO iterations and
LVS-matched with zero netlist overrides (flow/cells.py now carries one
device per physical finger). Cell areas equal the foundry's exactly
(3/3/5/4/6/3/3 sites), and the full-design area penalty collapsed from
2.17× to 1.09×.
The library is 16 cells: 9 characterized — INV_X1/X2/X4, BUF_X1/X2/X4,
NAND2, NOR2, DFF_X1 — plus 7 physical-only cells that complete
self-sufficiency: TIE_X1 (cross-coupled 2T tie), WELLTAP_X1, DIODE_X1
(antenna), FILL_X1–X8. (NOR3 and NAND3 were dropped after routing-cost
analysis — library design is economics; their instances remap to
NAND2/NOR2 chains and the cost above is measured, not hidden.) LVS earned its keep in
v1 by catching a double-width NFET in the BUF cells that DRC could never
see; in v2 the extractor's multifinger merge is mirrored in the reference
netlists (flow/run_lvs_all.py).
LibreLane P&R of the hybrid netlist (our 7 cells + hd dfxtp_1) at 20 ns:
routed with 0 violations — the v1 DRT-0073 pin-access blocker is dead
— antenna-clean, and timing met at every corner (worst setup slack
+3.46 ns at ss/1.60 V, worst hold +0.11 ns at ff/1.95 V). The final GDS
passes the full official KLayout deck (FEOL+BEOL+offgrid) with 0
violations after one deterministic post-processing step:
flow/heal_hvtp.py bridges 36 corner-pinches in the foundry cells' hvtp
implant — an abutment case (hd band ending/starting at the same x in
mirrored rows) that only arises when hvtp-less custom cells interleave
with hd cells; the added implant is diamond-shaped, diff-free and
electrically inert, and the healed GDS is re-checked by the full deck.
Magic's DRC/LVS are demoted to warnings in harden/config.json: magic's
CIF read of GDS-only custom cells reports tens of thousands of phantom
errors on a layout the official KLayout deck proves clean; the
magic-native views (section below) later reduced the disagreement to
exactly the tap/latch-up rules every standalone cell shows.
And it fits the tile — with every sequential and logic cell our own.
With the die pinned to the exact TinyTapeout 1x1 footprint the
fabricated chip used (161.00 × 111.52 µm), the all-own netlist (1787 own
cells incl. 191 DFF_X1; only the 18 tie cells remain foundry) places,
routes, and passes the full signoff deck with 0 violations — final hold
slack +0.006 ns and setup +12.3 ns at the worst corners, 87% utilization.
Zero-foundry milestone (lib-v1.0): the flow-inserted cells are now
ours too — TIE_X1 (cross-coupled 2T tie), WELLTAP_X1, DIODE_X1 (with
LEF antenna area), FILL_X1–X8, CTS on our buffers, sky130_fd_sc_hd__*
banned from P&R outright. The chip contains zero foundry cells:
signoff DRC 0, hold +0.016 ns / setup +13.4 ns, 65% utilization. The
decisive architectural fix: all signal pins moved to met1 (in-cell
mcon + pad) — after three rounds of DRT-vs-deck li disagreements
(same-net via pairs, rail-stub proximity), taking li out of the
router's reach entirely killed the class. The library is consumed by
downstream chips as pinned release tags (lib-v1.0).
Hard-won tuning lessons along the way: (1) a fast library makes hold overfixing expensive — the default 0.1 ns resizer margin × our 171 ps buffers meant hundreds of repair buffers; trim to ~0.005–0.02 ns. (2) Our DFF_X1 is ~150 ps faster at clk→Q than the foundry flop, which shortens every min-path and roughly quadruples hold repair — a fast flop is not free. (3) The decisive lever was none of that: LibreLane's default core margins (4/4/12/12 site-multiples) quietly spend 25% of a 1x1 tile; at 1/1/2/2 the core grows 13.5k → 16.9k µm². (4) A weak "hold buffer" cell (BUF_X1, now in the library) does NOT win OpenROAD's hold-buffer selection: the delay/area metric is evaluated at light load, where a weak output stage has no delay advantage.
flow/magic_views.tcl + the magic-views CI workflow load the signoff
GDS into magic (LibreLane container), emit .mag/.maglef views, and
run magic's full per-cell DRC judged against a foundry control group:
hd's own inv_1/dfxtp_1 are checked standalone first, and our cells
must show no rule category beyond theirs (the tap/latch-up rules every
tapless cell shows — resolved by tap cells at chip level). Status:
PASS. Getting there took two real fixes: the generated cells needed
the areaid.standardc (81/4) marker (magic relaxes contact-to-gate to
the 0.05 µm standard-cell rule only inside it), and magic caught a
genuine 45 nm contact-to-gate violation in BUF_X2 that the KLayout
deck's rule formulation misses — the two checkers are complementary,
which is exactly why shuttles run both.
flow/make_dff.py completes the library: it takes the silicon-proven
dfxtp_1 polygons and drops the hvtp implant layer, which converts
every pfet to the svt flavor this library is built on — then the result
goes through the same signoff as every hand-generated cell: official-deck
DRC (clean), KLayout LVS against the 24T netlist transcribed in
cells.py (MATCH; the four 'special' pass nfets are normalized by the
deck itself), our characterizer (clk→Q 351 ps, setup ≈ 0, D pin 1.11 fF),
our LEF. The hybrid era is over.
(Historical note: v1 and early v2 hardened with a hybrid library —
our combinational cells + the foundry flop — because the v1 template made
a custom DFF structurally impossible. That analysis is preserved in
PLAN.md.)
What v2 keeps from the measurements: svt PMOS (1.37× hvt drive,
measured) — the ~2× shorter synthesis-level critical path is that choice,
(An earlier ~4× figure was an artifact of a liberty unit bug: the load
axis was written in fF against a declared pF unit, so STA extrapolated
far below the characterized range. Found when TritonCTS refused the
tables outright; every timing number since has been re-derived.)
paid for in PMOS-off leakage (BUF_X2 ~1 nW vs single-digit pW NAND/INV
states, measured). What v2 gives up: symmetric edges (rise is ~1.7× slow)
and stack compensation (NAND2 251 ps vs INV_X1 195 ps mid-table) —
characterized honestly, not hidden. Details and cell mix:
out/REPORT.md. Every transistor's W/L:
out/own.spice / rules in out/sizing.json.
-
Phases 1–5 (probe → cells → characterize → compare → layout/DRC/LVS/LEF) run natively on Windows (ngspice + oss-cad-suite yosys + KLayout + the ciel-managed sky130A PDK); P&R and the magic checks run in CI via the LibreLane container (
harden+magic-viewsworkflows, both green). -
All 16 cells have REAL signoff layouts; every cell is DRC-clean (official KLayout deck), LVS-matched where devices exist, and at foundry-cell parity under magic DRC. Signal pins are on met1 (in-cell mcon + pad) — the router never touches li.
-
Library v1 (symmetric-drive experiment) is preserved at tag
v1-symmetric-drive; its post-mortem is inPLAN.md. -
Zero-foundry leg COMPLETE and released as
lib-v1.0— the only foundry content left is the interconnect definition itself. -
lib-v1.1— multi-PVT timing. Every cell is now characterized at all three sky130A signoff corners (tt_025C_1v80,ss_100C_1v60,ff_n40C_1v95), and the hardening config feeds them through the corner-keyedLIBvariable instead of the single-cornerEXTRA_LIBS, which LibreLane loads "indiscriminately for all timing corners". Before this, the nine STA corners (and their SDF) were byte-identical, so a measured-vs-predicted silicon gap on the vertical-slice ring oscillators had no corner spread to be attributed to. Fixing it surfaced two real defects the single-PVT flow had hidden: the DFF clk→Q measurement assumed the flop powered up with Q=0 (true at tt, false at ff → NaN tables), andcharacterize.pycould not be imported without running the whole flow.flow/check_corner_spread.pynow asserts, in CI, that the corners actually differ. See Timing corners. -
lib-v1.2— internal power, per-state leakage, RO-interior grid. The NLDM grid gained a fast-slew (20 ps) and low-load (2 fF) point so the vertical-slice ring oscillator's operating point (~50 ps, ~3.6 fF) is interior to the index box instead of extrapolated below it. Each combinational cell now emitsinternal_power(rise/fall switching energy in pJ, validated against the E = supply − ½·C·Vdd² shape sky130_fd_sc_hd ships) and per-stateleakage_power(when)(the old all-low measurement understated an inverter's average ~74×);cell_leakage_poweris the average over states. The hardened netlist is byte-identical to lib-v1.1 — only the timing/power views changed. -
lib-v1.3— load-monotonicity guard + honest documentation.flow/check_monotonic.pyasserts, in CI, that delay is monotonic in output load — physically unconditional, and the property STA leans on for a fixed driver — across every corner. It deliberately does not require slew-monotonicity: these cells are asymmetric (WP 1.0 > WN 0.65 µm, the routability sizing), so the 50-50 delay legitimately goes negative and shrinks with a slower input ramp — the output trips before the input reaches 50 % (waveform-confirmed: INV_X4 cell_fall at ff/1.5 ns/2 fF is −96 ps). Those entries are physical, present since lib-v1.0, confined to a light-load/fast- cell/slow-input region the design never signs off on, and are emitted unclamped rather than fabricated into monotonicity. No re-characterization; the vertical-slice pin is unaffected. -
lib-v1.4— defect M11: the output-transition tables were measured by crossing ordinal, not by direction. Everyrise_transition/fall_transitiontable of the five inverting cells (INV_X1/X2/X4, NAND2_X1, NOR2_X1) was negative and exchanged with its partner..meas ... cross=Ncounts the Nth crossing of a level in either direction, and both thresholds of a transition measurement sit on the same node, so on a cell whose output falls when its input rises the 0.8·Vdd crossing precedes the 0.2·Vdd one andtarg − trigruns backwards. The delay arcs were never affected — theirtrigis direction-qualified on the input — and the DFF was never affected because_dff_edgehas always usedrise=/fall=. Fixed by measuring the same way everywhere.- It was not a sign error. The magnitudes were all present, on the wrong
tables: at (20 ps, 2 fF, tt) INV_X1 shipped −11.30 ps as
rise_transitionwhen its rise is 20.97 ps, and NOR2_X1 understated its rise by 3.2× (15.68 vs 50.87 ps) — the cell with the stacked-PMOS pull-up, and the slowest ring on the vertical-slice die. Anabs()would have left both wrong. - Why nothing caught it for four releases, and what now does. OpenSTA does
not reject a negative transition, it clamps it, so vertical-slice signed
off with 20 of 21 driver rows at zero input slew and a "max slew
violations 0" that asserted nothing.
check_monotonic.py— the guard whose own docstring says a wrong-crossing regression "almost always breaks" load-monotonicity — parsed onlycell_rise/cell_fall. It now covers all four tables and additionally asserts that transitions are positive, a check available precisely because it is unavailable for delay (seelib-v1.3: negative 50-50 delays here are real and deliberately unclamped, so "a negative number in the liberty" could never have been the alarm). Against the shipped library the extended guard reports 672 non-positive transition values and 502 load-direction violations; against this one, 0 and 0. - Blast radius is exactly the defect. Re-characterizing all three corners
changes 14 value tables per corner and nothing else — the 5 inverting
cells' 7
rise_transition+ 7fall_transitiontables. Every delay, power, leakage, capacitance, area and setup/hold table is byte-identical, BUF and DFF are untouched in full, andharden/cordic_gates.vre-synthesizes byte-identical (1805 own cells, 0 foundry). This is a timing-view correction, not a design change. - Downstream: vertical-slice must re-pin and re-harden, and its
flow/ring_prediction.pyabs()workaround becomes a no-op. Its published ring numbers will move — it was driving every stage with the wrong-direction slew — so reproducing them would mean the fix had not taken.flow/v3/xcheck_liberty.pyscalesfall_transitionas a pull-down (NMOS) arc, which for the inverting cells was the PMOS pull-up; regenerateout/own_devphys_xcheck.libfrom this release.
- It was not a sign error. The magnitudes were all present, on the wrong
tables: at (20 ps, 2 fF, tt) INV_X1 shipped −11.30 ps as
-
lib-v1.5— defects M15 and M16: two attributes the tool never read. Neither was a wrong number. In both cases the measured data was correct and present in the file, addressed so that OpenSTA never looked at it — and every downstream check therefore reported a comfortable constant.- M15 — the library declared no fanout load at all. OpenSTA computes a
net's fanout by summing
fanout_loadover its sink pins, falling back to the library'sdefault_fanout_load. This library emitted neither, so every net in every design summed to 0.0,set_max_fanout 10could not be exceeded by any circuit whatsoever, anddesign__max_fanout_violation__countwas a constant. vertical-slice had that metric in itsMUST_BE_ZEROlist and quoted it as assurance — the sixth guard in this project found to be asserting a proxy, and the first that had been promoted to a signoff gate before anyone checked it could fail. Fixed with one header line,default_fanout_load : 1;. - What it was hiding, measured on the shipped routed netlist of
vertical-slice run 30942289282: 22 driver pins over the limit, worst
wire82/Yat fanout 29 against 10. And the mechanism is visible in the violators' own names —max_cap75..83,load_slew29..72,wire31..82are buffers OpenROAD'srepair_designinserted to fix capacitance and slew, which it then loaded with 20-29 sinks apiece because the fanout limit was invisible to the repair engine too. The repair pass created the nets that violate. - It is also M9's root cause. The two max-capacitance violators at tt
are
wire82/Yandmax_cap79/Y— both in that fanout list, at 29 and 27 sinks. ~62 fF of pin load before a micron of wire, against a 100 fF limit. Nothing enforced fanout, so nothing ever split them. - Why
default_fanout_load : 1and deliberately nothing else.sky130_fd_sc_hd— the reference for what is conventional — carries exactly this attribute, and has no per-pinfanout_loadand nomax_fanoutanywhere. Both alternatives were built and measured here: per-pinfanout_load : 1on all 14 input pins is exactly redundant with the header default (identical violation report), and a per-outputmax_fanout : Nwas rejected on purpose — it would be a second source of truth racing the SDC's design-level limit, and N would be a number nobody measured sitting in a characterized library beside themax_capacitancethat is measured. Fanout is the crude proxy; capacitance is the real limit. Keeping the load at exactly 1 per pin also keeps "fanout" meaning "sink count", which is whatset_max_fanout 10and every recorded violation figure assume. - M16 — every
internal_powertable was silently discarded. The power grid template was declared withlu_table_templatewhen Liberty keeps power templates in a separate namespace requiringpower_lut_template, so all 18 tables resolved to nothing. OpenSTA said so, in 18 warnings, on every read, for four library releases. Measured: reported internal power 0.00e+00 before, 5.47e-08 after — a third of total power. Found while proving M15, purely because someone finally read the log. - The guard:
flow/check_liberty_sta.py, and why it is notcheck_monotonic.py. That guard reads the liberty's numbers, and could never have caught either defect, because both numbers were right; what was wrong was whether the consuming tool could reach them — a property of the liberty and the tool together, observable only by running the tool. So the new guard runs OpenSTA on a netlist carrying a 12-sink and a 9-sink net and asserts the max-fanout check fires on one and stays quiet on the other (a check wedged at "violated" is exactly as useless as one wedged at "clean"), that no table template is left unresolved, and that reported internal power is non-zero. Verified by making it fail first: it rejects all three shipped lib-v1.4 corners, naming both defects, and passes the fixed library. Asserting thatdefault_fanout_loadappears in the text would have been one more proxy. - Blast radius. Both fixes are emission-only — no measurement changed,
and every value table is byte-identical to lib-v1.4. Downstream,
vertical-slice must re-pin, and its
gdsrun is expected to go red: the 22 violations are real and have always been there, and making the check honest is what surfaces them. The follow-on is a genuine design fix, in the shape of M12's — margin for what the optimizer cannot see, never a looser limit.
- M15 — the library declared no fanout load at all. OpenSTA computes a
net's fanout by summing
-
lib-v1.6— defects M17 and M18: the DFF's timing constraints were a placeholder and a search that could not return its own answer. Unlike M15/M16, these are wrong NUMBERS, and both were wrong in the unsafe direction. Until this release the module docstring's claim that the flow measures "setup and hold by bisection" was false for hold.-
M17 — hold was never measured. It was emitted as a literal
values("0.0")for both directions fromlib-v1.0tolib-v1.5, in the sametiming()group whose setup beside it was searched for. It was carried honestly as a deferral in this README, but the consequence was not: vertical-slice liststiming__hold_vio__countin itsMUST_BE_ZEROset, and a hold check against a requirement of zero cannot fail for the reason hold actually fails — the seventh guard in this project found to be asserting something it could not test. -
0.0 was not a conservative placeholder. Measured at tt, this flop captures a rising D placed exactly ON the clock edge, so the real hold requirement for that direction is strictly positive and the shipped 0.0 was optimistic on every min-path in the chip.
-
M18 — the setup search returned its own lower bound. Its bracket was hard-coded
lo = 0.0and taken on faith; it returnedhi. When the true boundary sat at or below zero every trial succeeded,hihalved to the floor, and the value returned was(hi-lo)/2**iters— set by the iteration count, not by the circuit.1e-9/2**12 = 0.244 ps, and0.00024ns is precisely what the tt and ff liberties shipped for five releases. It was never a measurement. -
And one number was used for both D directions, which are not equal. D reaches the master through an input inverter that passes its two edges at different speeds, so the sampling instant moves with direction. All four are now measured separately, at every corner (ps):
corner setup rise setup fall hold rise hold fall tt_025C_1v80-1.892 +20.691 +6.653 -5.554 ss_100C_1v60+19.775 +43.274 -11.047 -17.761 ff_n40C_1v95-8.911 +10.315 +12.146 +0.244 against
0.00024/0.00024/0.01978ns of setup for both directions and0.0of hold everywhere, through lib-v1.5. -
⚠️ A coincidence to know before it misleads someone:ff's hold for a falling D is 0.244 ps, which the emitter renders as0.00024— character for character the M18 floor artefact (1e-9/2**12) that this release removes. It is a genuine measurement that happens to land on a grid point of the new bracket. The two are told apart by which field: M18's0.00024was setup, at tt and ff; this one is hold, at ff only, beside three other values that are nothing like a floor. -
The one number that validates the method:
+19.775ps. ss is the only corner where the old bracket was valid — its setup boundary is genuinely positive, solo = 0.0did not truncate it — and the old flow measured0.01978ns there. The new search reproduces it. Where the old search could work it agrees; where it could not, it was returning its floor. -
The two that matter downstream.
ffis the hold corner and its hold requirement for a rising D is +12.146 ps, against vertical-slice's worst hold slack of 1.53 ps. Andssis the setup corner, where a falling D needs +43.274 ps rather than the 19.775 that was being applied to both directions.So setup for a falling D was optimistic by ~20-24 ps while the file claimed one number for both — in magnitude the larger of the two defects at tt, though M17's is the one that lands on a signoff gate. Negative values are real and are emitted as measured, as everywhere else in this flow: a negative setup means D may change slightly after the clock edge and still be captured, which is what an internally-buffered clock does.
setup + hold > 0per direction (+4.76, +15.14 ps) is the aperture, and it is the physical sanity check on the pair. -
The guard, and why it is not "assert the value is non-zero". A hold constraint is legitimately allowed to be zero or negative, so a non-zero test would be a spelling test with a false-failure mode.
check_liberty_sta.pygainedcheck_hold(), which runs OpenSTA on a two-flop probe and separates two questions that a single comparison keeps confusing: is the field reached, and is the number in it real. Reachability is proved with a synthetic constraint — force both directions to 0, then both to 1 ns, and the worst hold slack must move by exactly 1 ns. The shipped numbers are checked against thelibrary hold timeOpenSTA reports having applied. M17 is then the case where the tool faithfully applies 0.00000 ns because that is genuinely what the library says. Verified in both directions. It rejects the shipped lib-v1.5 — "zeroing the hold constraint changed worst hold slack by nothing (0.09552 ns both ways), so the shipped constraint IS zero" — and goes quiet on the same library patched with a real constraint. -
Running the guard is what caught three bugs in the guard, none of which a fail-only test would have shown.
report_worst_slackdefaults to two decimal places in ns = 10 ps granularity, coarser than the constraint being measured; a 6.65 ps delta was quantised to exactly 10.000 ps. Both reports now pass-digits 5.- It predicted that the larger requirement would bind. It does not:
OpenSTA reports the path with the smallest
arrival - required, and the arrival differs by direction too, so with rise+6.65and fall-5.55declared it reported the fall arc. It now reads back thelibrary hold timethe tool says it applied instead of guessing. - The binding arc can move between the two runs being compared. At
ff, with rise
+12.15and fall+0.24, zeroing both handed the worst path to the fall arc (whose arrival is 6.7 ps earlier), so the slack moved by 5.48 ps and matched neither constraint. Nothing was wrong with the library. That is why reachability is now proved with one synthetic value on both arcs, which cannot switch what binds.
-
_boundary()now verifies its bracket, widens it if it does not bracket, and raises rather than returning a bound dressed as a measurement. A search that cannot find the answer has to say so — silently returning the edge of the search space is how a placeholder passed for a measurement for five releases. -
Blast radius — this one changes measured data, unlike lib-v1.5. The DFF's four constraint values move; every NLDM table is untouched. Downstream, vertical-slice must re-pin and its
gdsrun is expected to show hold violations: worst hold slack there was 1.53 ps against a requirement of zero, and the requirement is now positive. Those violations are real and have always been there. The follow-on is ordinary hold repair, never a return to 0.0 and never dropping the metric fromMUST_BE_ZERO. -
⚠️ KNOWN LIMITATION OF ALL FOUR NUMBERS, STATED SO NOBODY INFERS MORE THAN IS THERE: these are CAPTURE-BOUNDARY constraints, not the degradation-criterion constraints a commercial library ships. The search asks only "did the intended value end up in the flop", which is the metastability edge — the last point at which it still resolves at all. Industry signoff instead takes the point where clk→Q has degraded by a fixed amount (commonly 10%), which lands earlier and is therefore more pessimistic. Near the boundary this flop's clk→Q blows up, and the NLDM tables beside it are measured far from that region, so setup and hold are optimistic by an unquantified margin relative to a foundry library. This is not new to lib-v1.6 — the setup search has worked this way since lib-v1.0 — but M17 is exactly the class of "a number that means less than it looks", and the fix should not quietly create a smaller one. Closing it means re-running each bisection against a clk→Q degradation target instead of a boolean, which is a change of criterion, not of code shape. -
🔴 M19, FOUND WHILE FIXING M17 AND DELIBERATELY NOT FIXED HERE: this library declares no
min_pulse_widthanywhere. The DFF'sCLKpin carries a capacitance and nothing else, so OpenSTA'scheck_min_pulse_widthhas no requirement to check and cannot fail — M17's exact shape, one pin over. It is not an omission the reference shares:sky130_fd_sc_hd__dfxtp_1— the very cell this DFF is modelled on — declares four timing types,rising_edge,setup_rising,hold_risingandmin_pulse_width; ours declares the first three and nomin_pulse_widthanywhere in the library. It matters here specifically because vertical-slice clocks a prescaler from a ring oscillator, which is where a too-short clock pulse would actually come from. Fixing it means measuring minimum high and low pulse widths — its own characterization, and a separate piece of work from this release. -
Independent corroboration that the new numbers have the right shape.
dfxtp_1's own foundry-characterized constraint tables contain negative entries, and markedly more of them on thefall_constraintof its hold arc than on the rise — the same asymmetry, in the same direction, that this measurement finds (hold rise positive, hold fall negative). Two things follow: negative constraints are normal rather than a symptom, and a single scalar shared between the two directions was never going to be right for this topology.
-
-
lib-v2.1— defect M31: the DFF's CLK→Q fall arc was measured from the power-up settle, at the ff corner only.lib-v2.0shipped aDFF_X1cell_falltable holding −7.258 to −8.499 ns in 15 of its 20 entries at*_ff_n40C_1v95. tt (0.250…0.802 ns) and ss (0.405…1.323 ns) were clean, which is why nothing looked wrong in a spot check.- What it did downstream. ff is the hold corner. A −7.4 ns launch
arc is a −7.4 ns hold violation on essentially every endpoint, so
vertical-slice's hold WNS went −0.096 → −7.461 ns and its TNS to
−1996.8 ns over 277 endpoints — a near-uniform offset, not a distribution.
Hold repair then tried to buy 7.4 ns of delay everywhere and flooded the
detailed placer:
[DPL-0036],gdsandhardenboth red. Three CI cycles were spent on placement-side theories (hold margin, target density) before anyone read the liberty's minimum. - The mechanism, from the raw log (
out/dffq_ff_n40C_1v95_00_f.log):At ff the operating point leaves Q indeterminate at 1.29 V. It settles through VDD/2 at 0.75 ns, andInitial Transient Solution q = 1.2906 <- mid-rail, above VDD/2 tcq = -7.25779e-09 targ= 7.50540e-10 trig= 8.00833e-09targ v(Q) val=VDD/2 fall=1latched onto that settle instead of the capture at 8.16 ns. Only the 100 fF column escaped, because that load slows the settle past the threshold. - ⛔ It is the defect
_dff_edge's own docstring had just finished describing. lib-v2.0 fixed exactly this on the output-transition measures withtd=6n, and then asserted "tcqwas never affected, because a targ after a trig on a DIFFERENT node is searched forward from the trig". That sentence is false — ngspice searches an explicit crossing ordinal from t=0 regardless of which node the trig sits on. The wrong belief, written down confidently next to the right fix, is what shipped the bug. - Fix:
tcqgets the sametd=6nwindow (andrise=2becomesrise=1, since the 6 ns window already excludes the precondition edge). Verified before any code changed: the failing deck returns −7.25779e–09 → +1.78155e–10, sitting correctly beside its 173.3 ps rise — and it is bit-identical on decks that were already right (tt fall 2.50339e–10, ss fall 4.04614e–10, ff rise 1.73255e–10, unchanged to every digit). A fix that moves only the broken numbers. - Guards added, because
check_monotonic.pypassed this twice over. It permits negative delay on purpose (early trip is real here; the worst legitimate value is −96 ps), and the corrupt row is still monotone in load (−7.26 → −7.24 → −7.15 → +0.41). Two physical bounds now sit beside it: (A) sequential causality — a clocked cell's CLK→Q is measured from the edge that causes it and cannot be negative (early trip is a combinational effect; this library's smallest true value is +178 ps); and (B) the input-ramp bound — no cell's output can cross 50 % more than one full 0–100 ramp (slew / 0.6) before its input does, which M31 broke by ~900x at the fastest slew while INV_X4's real −96 ps sits against a −2.5 ns bound. - Blast radius: timing only, and only the DFF's two NLDM delay tables.
Geometry, LEF, GDS, setup/hold/
min_pulse_widthand every combinational cell are untouched — their decks are byte-identical, soharden/cordic_gates.vmust come out unchanged, and that is asserted rather than assumed. This changes what we know about the chip, not the chip. - ℹ️ Produced with deck-level reuse (
STDCELLS_REUSE_DECKS=1, seeflow/common.py): only the 120 DFF clk→Q decks whose text actually changed were re-simulated; the ~2200 unchanged decks were served from the byte-identical.sp/.logpairs already inout/.characterize.pyprints the reused/simulated counts so a library built this way says so.
- What it did downstream. ff is the hold corner. A −7.4 ns launch
arc is a −7.4 ns hold violation on essentially every endpoint, so
vertical-slice's hold WNS went −0.096 → −7.461 ns and its TNS to
−1996.8 ns over 277 endpoints — a near-uniform offset, not a distribution.
Hold repair then tried to buy 7.4 ns of delay everywhere and flooded the
detailed placer:
flow/characterize.py re-measures the full library at each corner
(python characterize.py does all three; pass a corner name for one).
The delay spread is real — ss/ff ≈ 2× — and it is what turns a cold-vs-
warm ring-oscillator measurement in silicon into an attributable result
rather than a single number with no error bar. The DFF captures are now
each measured from a run preconditioned into the opposite state, so the
answer cannot depend on the power-up state; the nominal corner still
reproduces lib-v1.0 exactly (clk→Q 351 ps, setup ≈ 0).
- Next legs: the vertical-slice tapeout consumes a pinned tag (now
lib-v1.2); then v3 cells on devphys-derived custom device geometries.Deferred, neither blocking the design: measuring the DFF hold constraint (currently 0.0)— the hold constraint is MEASURED as oflib-v1.6(defect M17), and "neither blocking the design" was wrong: it fed vertical-slice'stiming__hold_vio__countgate, which could not fail while the requirement was zero. Still deferred: emitting DFF internal power.
flow/pvt_compare.py compares the custom library (lib-v2.2, three PVT
corners) against the foundry sky130_fd_sc_hd, cell-for-cell on the same
sky130 process. Every delay is the cell_rise propagation delay
bilinear-interpolated from each cell's NLDM table to one common operating
point — input slew 0.30 ns, output load 0.025 pF — used identically for both
libraries. The point lands inside every table's index range (no clamping).
The custom cells are single-Vt svt; hd is a production multi-Vt-capable
library. Reproduce with python flow/pvt_compare.py; flow/check_readme.py
asserts in CI that the tables below still match the liberties.
⛔ These are lib-v2.2 numbers and they are not small revisions of the
lib-v1.x ones this section used to carry. Two corrections landed in
between: the cells are now characterized from the extracted layout rather
than from a schematic of bare MOSFETs (M26/M34, worth +19–35 % of delay), and
leakage is state-averaged rather than measured in one input state (M29).
| cell | own tt | own ss | own ff | hd tt | hd ss | hd ff |
|---|---|---|---|---|---|---|
| INV | 284.2 | 332.9 | 267.1 | 284.7 | 371.9 | 234.9 |
| NAND2 | 288.3 | 338.5 | 270.4 | 305.4 | 418.9 | 250.3 |
| NOR2 | 444.0 | 553.4 | 400.1 | 461.0 | 712.5 | 348.5 |
| DFF | 474.5 | 670.4 | 371.1 | 524.0 | 959.6 | 339.2 |
DFF = CLK→Q rising_edge arc; gates = first input arc. Delays order ss >
tt > ff for every cell in both libraries.
Once the cells carry their own intra-cell RC the custom library is no longer
comfortably faster than hd at the nominal corner — INV_X1 and
inv_1 are now within 0.2 % of each other (284.2 vs 284.7 ps), where the
schematic-characterized library claimed a 7 % lead. The remaining lead is at
ss, where every custom cell is still faster, and on the flip-flop,
which is faster at tt and ss (474.5 vs 524.0; 670.4 vs 959.6 ps, 1.43×).
⛔ The "~1.9× faster at ss" this section used to claim was a schematic-vs-
silicon-calibrated comparison and is dead. At ff hd wins on the simple
gates, as it did before.
| cell | own | hd |
|---|---|---|
| INV | 1.247 | 1.583 |
| NAND2 | 1.252 | 1.673 |
| NOR2 | 1.383 | 2.045 |
| DFF | 1.806 | 2.829 |
The custom cells' delay swings less across the process box than hd's at this operating point. Read it as a measured comparison at 0.30 ns / 0.025 pF, not a universal robustness claim: a fixed input slew adds a corner-independent component that compresses the ratio, and a ring oscillator runs at a much lighter load and faster slew than this point.
| cell | own tt | own ss | own ff | hd tt | hd ss | hd ff |
|---|---|---|---|---|---|---|
| INV | 0.25555 | 1.35860 | 0.06596 | 0.00533 | 4.02642 | 0.00315 |
| NAND2 | 0.25763 | 1.36016 | 0.06837 | 0.00212 | 2.26812 | 0.00312 |
| NOR2 | 0.18654 | 1.15271 | 0.04670 | 0.00197 | 2.11692 | 0.00325 |
| DFF | 1.08601 | 5.42055 | 0.31024 | 0.00844 | 14.65205 | 0.01452 |
Both libraries declare leakage_power_unit : "1nW". These are state-averaged
(leakage_power(when) over every input combination) as of lib-v1.2, so the
old "lower bound, understated 27–53×" caveat no longer applies and the
temperature trend is now physical — every custom cell rises ~5× from tt to ss
and falls ~4× to ff.
The honest reading: the svt cells leak far more than hd at tt and ff, by
design — 48× on the inverter (0.256 vs 0.0053 nW) and 129× on the flop.
Per-cell area is identical — the custom cells are drawn to the same standard-cell footprints as their hd counterparts for drop-in compatibility: INV/NAND2/NOR2 = 3.7536, DFF = 20.0192 in both. Any library-level density gap vs hd is a placement / cell-count effect, not per-cell.
Defects M26 and M34. Up to lib-v1.7 the library was characterized from
cells.py::spice(), a .subckt of bare MOSFETs: no intra-cell RC at all.
lib-v2.0 added the extracted capacitance; lib-v2.2 added the extracted
resistance. Measured at tt, at the ring oscillator's own operating point
(20 ps slew, 2.5 fF load), with flow/parasitic/par_delta.py:
| model | INV_X1 | NAND2_X1 | NOR2_X1 |
|---|---|---|---|
schematic — bare MOSFETs (≤ lib-v1.7) |
24.86 ps | 30.57 ps | 44.57 ps |
+ extracted capacitance (lib-v2.0/v2.1) |
29.59 | 37.57 | 53.53 |
| coupling grounded, still no R | 28.93 | 35.95 | 52.41 |
+ extracted resistance (lib-v2.2) |
30.71 | 41.42 | 53.08 |
Read as three separable effects:
| effect | INV_X1 | NAND2_X1 | NOR2_X1 |
|---|---|---|---|
| intra-cell capacitance (M26) | +19.0 % | +22.9 % | +20.1 % |
| intra-cell resistance (M34) | +6.2 % | +15.2 % | +1.3 % |
| grounding the coupling (the cost of M34) | −2.2 % | −4.3 % | −2.1 % |
lib-v2.2 is not "the cell with its full intra-cell
RC". Magic's extresist pass splits every node, and the inter-node
coupling capacitances cannot be attributed to a split piece, so they come
back as node-to-substrate capacitance — the cell keeps its capacitance and
loses its Miller term. The two effects partly cancel, which is why a naive
before/after comparison reads only +3.8 / +10.2 / −0.8 % and why NOR2 appears
to get faster. flow/parasitic/ground_coupling.py builds the middle row so
they can be separated.
So the residual is a one-signed 2.1–4.3 % optimism, against 6.2–15.2 %
before lib-v2.2. Not eliminated — reduced, and measured rather than asserted.
Measured by bisection against the capture boundary, per direction, per corner.
Negative values are normal and the foundry's dfxtp_1 has them too.
| ff (−40 °C, 1.95 V) | tt (25 °C, 1.80 V) | ss (100 °C, 1.60 V) | |
|---|---|---|---|
| CLK→Q | 371 ps | 474 ps | 670 ps |
| setup, rising / falling D | −5.6 / +20.4 ps | +7.9 / +38.4 ps | +46.9 / +70.7 ps |
| hold, rising / falling D | +10.6 / −4.3 ps | −0.7 / −14.7 ps | −34.5 / −31.5 ps |
min_pulse_width high (20/50/300 ps CLK slew) |
95.2 / 97.2 / 144.8 ps | 135.5 / 137.7 / 223.4 ps | 210.0 / 212.9 / 342.8 ps |
min_pulse_width low |
117.2 / 124.5 / 233.2 ps | 161.4 / 165.8 / 280.8 ps | 243.7 / 244.1 / 349.6 ps |
min_pulse_width exists as of lib-v1.7: before that the library declared
none at all, so OpenSTA's check had no requirement to apply and could not
fail (M19).
For the vertical-slice silicon experiment: the three-corner characterization gives each cell a concrete tt→ss→ff delay envelope, so the fabricated ring-oscillator frequency can be checked against a predicted band rather than a single number — a reading outside the envelope flags a mischaracterized model rather than ordinary process spread. (The RO's own operating point is lighter-load / faster-slew than the PVT table's.)
sky130A PDK via pip install ciel; ciel enable --pdk-family sky130 <ver>;
ngspice (see ../devphys/tools); oss-cad-suite yosys; KLayout ≥ 0.30
(DRC/LVS decks run headless); gdstk + matplotlib (layout generation and
the contact sheet).
Apache License 2.0 — see LICENSE. Copyright (c) 2026 Joonatan Alanampa.
This matters here more than in most repos: the cell library is consumed as a
pinned release by vertical-slice,
which copies out/ into its own tree and builds a fabricated artifact from it
(see lib.lock there). Until now this repo carried no licence at all, so the
default was all-rights-reserved while three sibling repos were Apache-2.0 and
one of them was shipping these files.
.lib/.lef/.gds in out/. It
does not extend to the SkyWater sky130 PDK, its device models or its design
rules, which carry their own terms — that boundary is the same one the README
draws between self-designed and self-fabricated silicon.
