Every arrival, requirement and slack quoted in this topic came from somewhere: this lesson is that somewhere. It builds a small static timing calculator in plain Python that prices the recorded vipexp path stage by stage from the exact library rows, propagates arrivals forward from the launch flop and requirements backward from the capture flop, and reports the slack. Then it holds its own total against the ABC report and explains the gap honestly.
The calculator embeds the real NLDM rows copied exactly from scripts/tools/synth-libs/course.lib for the five cells on the path: the DFF clock-to-Q and output-slew rows plus the cell_rise and rise_transition rows of NAND2, NOR2, XOR2 and XNOR2. Rise and fall tables coincide entry for entry in this library, so the calculator prices the rising edge and says so where that assumption matters. Nothing is fitted, rounded or invented: each string below matches the library file character for character, and the program parses them at runtime.
read_verilog <<EOV
module vipexp(input clk, input a, b, c, d, input din, output reg qout, output t1, t2, t3, t4);
reg q1;
wire s1, s2, s3, s4;
always @(posedge clk) q1 <= din;
assign s1 = ~(q1 & a);
assign s2 = ~(s1 | b);
assign s3 = s2 ^ c;
assign s4 = ~(s3 ^ d);
always @(posedge clk) qout <= s4;
assign t1 = s1;
assign t2 = s2;
assign t3 = s3;
assign t4 = s4;
endmodule
EOV
hierarchy -check -top vipexp
synth -top vipexp -flatten
dfflegalize -cell $_DFF_P_ 01 -cell $_DFF_PN0_ 01
design -save generic
dfflibmap -liberty course.lib
tee -o abc_typ.txt abc -liberty course.lib -constr typical.constr -D 1000
clean
tee -o stat_typ.txt stat -liberty course.lib
write_json net_typ.json
select -assert-none t:$not t:$and t:$or t:$xor t:$mux t:$_NOT_ t:$_AND_ t:$_OR_ t:$_XOR_ t:$_NAND_ t:$_NOR_ t:$_DFF_P_ t:$_MUX_
select -assert-count 2 t:DFF
select -assert-count 1 t:NAND2
select -assert-count 1 t:NOR2
select -assert-count 1 t:XOR2
select -assert-count 1 t:XNOR2
check
design -load generic
dfflibmap -liberty course.lib
tee -o abc_heavy.txt abc -liberty course.lib -constr heavy.constr -D 1000
clean
select -assert-count 2 t:DFF
select -assert-min 4 t:BUF
check
log PASS: calculator path mapped under two output loadsThe script maps the familiar vipexp cover twice: the typical run every lesson prices, reporting 4 cells at area 14 with arrival 610 ps, and the heavy remap from the wire lesson at 8 cells, area 22 and 1230 ps. The calculator prices only the typical cover; the heavy run is its break-it, and the last section shows why.
Close but not equal, and the calculator should land a touch higher. Its combinational sum is 0.617 ns against 0.61 ns recorded, a gap of a few picoseconds. The ingredients match on purpose; the remaining gap is driver context and endpoint choice, itemised below, not a fudge factor.
Static timing is two sweeps over the path. The forward pass takes the latest incoming arrival plus the stage delay at each node, which is how the launch clock edge turns into the 0.78 ns arrival at the capture flop. The backward pass takes the tightest successor requirement minus the stage delay, which is how the 1.00 ns clock edge minus setup turns into the requirement at every earlier node. Slack at each node follows the topic convention, and the nodes with the smallest slack form the critical path.
On a single serial path both sweeps are simple addition, so the program below shows its work per stage instead of hiding it in a loop: clock-to-Q first, then one interpolated delay and one interpolated output slew per stage, accumulating the arrival column the timing-bars figure in the arrival lesson draws. A real tool repeats the same two sweeps over a DAG with max and min at every fanin and fanout; the arithmetic per arc is identical.
import sys
# NLDM rows copied exactly from scripts/tools/synth-libs/course.lib.
# Each string matches the library file character for character; the program
# parses them, so a transcription slip fails the table-point checks below.
DFF_CQ = 'values("0.15,0.17","0.18,0.21")'
DFF_TR = 'values("0.02,0.04","0.04,0.08")'
NAND2_D = 'values("0.08,0.1","0.11,0.14")'
NOR2_D = 'values("0.09,0.11","0.12,0.15")'
XOR2_D = 'values("0.18,0.2","0.21,0.24")'
XNOR2_D = 'values("0.18,0.2","0.21,0.24")'
TR = 'values("0.02,0.04","0.04,0.08")'
SETUP = 'values("0.05,0.06","0.06,0.07")'
CLK_SLEW = 0.05 # hand assumption, labelled model
PERIOD = 1.0 # hand-derived SDC-style period, labelled model
PIN_LOAD = 0.01 # library input-pin capacitance, from course.lib
TAP_LOAD = 0.05 # typical.constr output load, recorded constraint
STAGE_LOAD = PIN_LOAD + TAP_LOAD
RECORDED_TYP_NS = 0.610 # ABC typical-load delay, templated from the run
# Full-precision hand arithmetic for each stage (arrival, delay, out slew).
HAND = [
("clk-to-Q", 0.1633, 0.1633, 0.0289),
("NAND2", 0.2619, 0.0986, 0.0376),
("NOR2", 0.3739, 0.1120, 0.0407),
("XOR2", 0.5772, 0.2032, 0.0417),
("XNOR2", 0.7808, 0.2036, 0.0421),
]
def parse(rows):
inner = rows[len('values("'):-len('")')]
return [[float(x) for x in pair.split(",")] for pair in inner.split('","')]
def bilin(table, slew, load):
(v00, v01), (v10, v11) = table
fs = (slew - 0.01) / 0.09
fl = (load - 0.01) / 0.09
return v00 * (1 - fs) * (1 - fl) + v01 * (1 - fs) * fl + v10 * fs * (1 - fl) + v11 * fs * fl
def close(got, want, tol=0.001):
return abs(got - want) <= tol
CQ, DTR = parse(DFF_CQ), parse(DFF_TR)
STAGES = [("NAND2", parse(NAND2_D)), ("NOR2", parse(NOR2_D)),
("XOR2", parse(XOR2_D)), ("XNOR2", parse(XNOR2_D))]
PTR, SET = parse(TR), parse(SETUP)
bad = 0
def check(name, cond):
global bad
if not cond:
print("FAIL: " + name)
bad += 1
# The tables must behave like tables: exact at grid points, monotone both ways.
for name, table in [("DFF", CQ), ("NAND2", STAGES[0][1]), ("XNOR2", STAGES[3][1])]:
v00, v01 = table[0]
check(name + " corner returns entry", abs(bilin(table, 0.01, 0.01) - v00) < 1e-9)
check(name + " monotone in load", bilin(table, 0.05, 0.1) > bilin(table, 0.05, 0.01))
check(name + " monotone in slew", bilin(table, 0.1, 0.05) > bilin(table, 0.01, 0.05))
arrival = bilin(CQ, CLK_SLEW, PIN_LOAD)
slew = bilin(DTR, CLK_SLEW, PIN_LOAD)
got = [("clk-to-Q", arrival, arrival, slew)]
for name, table in STAGES:
delay = bilin(table, slew, STAGE_LOAD)
arrival = arrival + delay
slew = bilin(PTR, slew, STAGE_LOAD)
got.append((name, arrival, delay, slew))
for (name, arr, delay, out), (hname, harr, hdelay, hslew) in zip(got, HAND):
check(name + " arrival", close(arr, harr))
check(name + " delay", close(delay, hdelay))
check(name + " out slew", close(out, hslew))
combo = got[-1][1] - got[0][1]
check("combinational total", close(combo, 0.617, 0.002))
# The recording this model must track: ABC's typical-load delay, templated
# from the run, never typed. A model that drifts from the tool fails here.
check("model tracks the recording", abs(combo - RECORDED_TYP_NS) < 0.02)
setup = bilin(SET, slew, CLK_SLEW)
required = PERIOD - setup
slack = required - got[-1][1]
check("setup time", close(setup, 0.058))
check("required time", close(required, 0.942))
check("setup slack", close(slack, 0.161))
# Backward pass: the endpoint requirement walks back toward the launch,
# shedding one stage delay per node; slack at each node is required minus
# arrival, and on this serial path every node agrees with the endpoint.
node_req = [0.0] * len(got)
node_req[-1] = required
for i in range(len(got) - 2, -1, -1):
node_req[i] = node_req[i + 1] - got[i + 1][2]
for (name, arr, delay, out), req in zip(got, node_req):
check(name + " backward slack", close(req - arr, slack))
check("worst slack at capture", close(min(req - arr for (_, arr, _, _), req in zip(got, node_req)), slack))
if bad != 0:
sys.exit(1)
print("PASS: calculator arrival 0.78 ns and slack plus 0.16 ns on 35 checks")The program's combinational sum is 0.617 ns of model delay. ABC reports 0.61 ns on the same cover. Show both numbers, then explain the gap instead of hiding it: the two totals are computed from the same tables at the same loads, yet they differ by a few picoseconds, and each item below names one reason why.
Source | Combinational delay | What it covers |
|---|---|---|
Calculator (model) | 0.617 ns | Four interpolated stage delays at propagated slews and 0.06 pF per stage. |
ABC report (recorded) | 0.61 ns | Worst over all endpoints at ABC's own propagated slews, rounded to picoseconds; here that endpoint is the t4 tap net, which the recorded netlist shows the capture flop's D input shares. |
Three honest reasons for the gap, in size order. First, the input context: ABC propagates the real BUF-driver slew from typical.constr into the first stage, while the calculator assumes a 0.05 ns clock edge everywhere upstream. Second, the endpoint choice: ABC maximises over every endpoint including the taps, whose loads match the capture path here but need not in general, and over rise and fall arcs that coincide in this library. Third, rounding: ABC prints picoseconds, so each arc carries up to half a picosecond of rounding the model never sees. None of these is a fudge factor, and together they bound the gap instead of explaining it away.
Read the closeness itself with suspicion: landing within a few picoseconds is luck as much as method, and changing any assumption (the clock slew, the tap loads, the endpoint) moves the model total. What transfers to the next design is not the 0.007 ns gap but the method: same library rows, same load accounting, propagated slews, asserts that fail when any of those drift.
The heavy run in the script is the deliberate breakage, and it breaks the calculator rather than the design. Under heavy.constr the recorded cover grows a BUF per tap and the recorded arrival moves to 1230 ps, while the calculator still sums its four unbuffered stages to the same total. The model is not wrong about the circuit it prices; it prices a circuit that no longer exists. A timing model is tied to its cover: remap, and re-derive before quoting a single stage delay.
The strings match course.lib character for character (a transcription slip would fail the grid-point checks), the hand stage values match the interpolation within a picosecond, and the model total tracks the recording. It proves the model is faithful to its inputs, not that its assumptions match silicon.
No: it prices the typical cover, which the remap replaced. The gap between its total and the new recording measures the cover change (four added buffers), not a bug in the arithmetic.
Areas, arrivals and cell counts on this page come from one Yosys run of the script shown, recorded by the course checker (scripts/tools/run-rtl-testbenches.mjs with COURSE=synth): the vipexp block mapped with ABC against course.lib at a 1000 ps target with typical.constr and heavy.constr. The library is fictional, so absolute delays are teaching values. Every stage delay, arrival, requirement and slack is a model number interpolated from the exact library rows by the stdlib-only program shown, which asserts each figure it prints; the heavy-load remap is the recorded case the model no longer describes.