A path delay is the sum of its stage delays, each from a library model fed with that stage's input slew and output load. The library model is a table (NLDM): delay looked up by slew and load, interpolated between grid points. The cheaper alternative is linear: a base delay plus a load coefficient, no table. This lesson compares the two at the same loads on the first stage of the shared path, and charts where the linear fit stops telling the truth.
The stage under test is the NAND2 at the head of the vipexp path. Its course.lib cell_rise table is a two-by-two grid over 0.01-to-0.10 in both axes (slew in ns, load in pF): 0.08 and 0.10 ns on the fast-slew row, 0.11 and 0.14 ns on the slow-slew row. The program below copies those rows exactly and interpolates bilinearly, so every table number here is a library value, not an illustration.
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_narrow.txt abc -liberty nand_only.lib -constr typical.constr -D 1000
clean
tee -o stat_narrow.txt stat -liberty nand_only.lib
select -assert-count 2 t:DFF
select -assert-min 8 t:NAND2
check
log PASS: table model path mapped against two librariesThe script maps vipexp twice: against the full teaching library and against nand_only.lib, where every gate must be built from inverters and two-input NANDs. The full-library run reports 4 cells at area 14 with arrival 610 ps. The narrow run reports 14 cells at area 22 with arrival 737 ps. Same RTL, restricted cells, visibly slower: the delay model only matters through the cells the library offers.
The table still prices each NAND2 stage, but there are now more stages to price: the recorded histogram holds 8 NAND2 cells against one before, so the arrival rises from 610 ps to 737 ps. Same tables, more of them.
Fix the input slew at 0.05 ns and vary only the load, and the bilinear interpolation collapses to a straight line between the two load columns: each load fraction blends the fast-column and slow-column values at that slew. That exact straightness is a property of this 2-by-2 teaching grid, not of tables in general: production NLDM tables are about 7-by-7 and visibly nonlinear in load. The five loads below span the load axis, and the program asserts every interpolated value against hand arithmetic, plus the trustworthiness properties: grid points return their entries, the midpoint is the mean of the four corners, and delay rises monotonically both ways.
A linear fit through the two endpoints of that line (base 0.093 ns plus 0.27 ns per pF) matches the table everywhere on it, by construction. That is the fair comparison: both evaluated at the same loads with the stated slew. Where the fit holds, and where it fails, is what the next section measures.
import sys
# NAND2 cell_rise rows copied exactly from scripts/tools/synth-libs/course.lib.
# Grid: slew 0.01 and 0.1 ns (rows) against load 0.01 and 0.1 pF (columns).
TABLE = [[0.08, 0.10], [0.11, 0.14]]
SLEW = 0.05
LOADS = [0.01, 0.03, 0.05, 0.07, 0.10]
# Hand arithmetic for the five loads at the fixed slew (ns).
HAND_HOME = [0.093333, 0.098765, 0.104198, 0.109630, 0.117778]
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=1e-9):
return abs(got - want) <= tol
# Linear fit through the two endpoints at the home slew.
base = bilin(TABLE, SLEW, 0.01)
k = (bilin(TABLE, SLEW, 0.10) - base) / 0.09
fit = lambda load: base + k * (load - 0.01)
bad = 0
def check(name, cond):
global bad
if not cond:
print("FAIL: " + name)
bad += 1
check("grid point returns entry", close(bilin(TABLE, 0.01, 0.01), 0.08))
check("grid point returns entry", close(bilin(TABLE, 0.1, 0.1), 0.14))
check("midpoint is the mean", close(bilin(TABLE, 0.055, 0.055), (0.08 + 0.10 + 0.11 + 0.14) / 4))
check("monotone in load", bilin(TABLE, 0.05, 0.1) > bilin(TABLE, 0.05, 0.01))
check("monotone in slew", bilin(TABLE, 0.1, 0.05) > bilin(TABLE, 0.01, 0.05))
for load, want in zip(LOADS, HAND_HOME):
check("table at load " + str(load), abs(bilin(TABLE, SLEW, load) - want) < 0.000001)
check("fit exact on home curve", close(fit(load), bilin(TABLE, SLEW, load)))
gap = min(bilin(TABLE, 0.1, load) - fit(load) for load in LOADS)
check("fit errs off its home slew", gap > 0.015)
if bad != 0:
sys.exit(1)
print("PASS: table versus linear at the same loads, fit error above 0.015 ns elsewhere")Now move the slew to 0.10 ns and keep the same five loads and the same fit. This lesson's linear fit has no slew term, a deliberate strawman: real Liberty linear models (generic_cmos) include slope sensitivity. So the fit predicts exactly what it predicted before, while the table rises everywhere, because a slower input edge delays the output at every load. The chart below pairs them at each load.
At 0.01 pF the table says 0.110 ns and the fit says 0.093 ns; at 0.10 pF the table says 0.140 ns and the fit says 0.118 ns. The gap never drops below 0.015 ns across the five shared loads, which the program asserts. The fit is not wrong about loads: on its home slew it is exact. It is blind to slew, and this is the price of that blindness at identical loads.
The narrow-library run is the deliberate breakage: same RTL, target and loads, but only NAND2 and INV cells to build with. ABC rebuilds the path as 14 cells at area 22 with arrival 737 ps, against 4 cells at area 14 with arrival 610 ps free. The NOR2, XOR2 and XNOR2 stages each become small NAND networks, so the priced path gets longer while every price still comes from a table.
The honest boundary: a better model of the wrong cells still loses to the right cells. Model fidelity and library richness are independent axes, and the two runs move only the second.
The tables here are NLDM: one delay number per (slew, load) grid point, interpolated between points. Composite current-source models (CCS) store current waveforms instead and integrate them against the load, tracking waveform shape more faithfully at the cost of bigger libraries and slower analysis. Nothing here runs CCS: the open tools read NLDM-style tables only, so the comparison is stated, not measured, and every number above is NLDM.
Bilinear weights are products of one half and one half, so each corner weighs one quarter. Four equal quarters give the mean, exactly as the program's midpoint check confirms.
More stages to price, each still from its table. What stayed the same is the method: identical loads, target and interpolation, so the 127 ps gap is pure library restriction.
Cell counts, areas and arrivals 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 and against nand_only.lib at a 1000 ps target with typical.constr. The library is fictional, so absolute delays are teaching values. Table values are exact course.lib rows interpolated by the stdlib-only program shown; the linear fit and its error are model numbers, labelled so on the page. CCS is described, not run.