Tcl · Module 30
A Multi-Corner Sign-Off Report Script
Six scenarios, six directories, two aggregates and one distribution — and the measured reason the single worst number is the wrong summary.
Advanced24 min readtcledapart-iistasignoffcase-study
Part II · Module 30 · Chapter 30.2 · A Multi-Corner Sign-Off Report Script
A sign-off script's job is not to run timing analysis. It is to run it in every scenario that was promised, keep the results apart, and summarise them in a way that cannot mislead. All three of those are decisions about data structures rather than about timing.
The script here is 180 lines. Its three structural choices each have a measured consequence, and one of its own checks turned out never to fire.
1. The scenario list is data
A mode list, a corner list, and the pairs that were deliberately dropped — with the reason attached to the drop:
# ---- the scenario list, as data with a reason per drop (26.4) -----------
set ::MODES {
{functional 5.0}
{scan_capture 5.0}
}
set ::CORNERS {
{ss_0p72v_125c 1.30}
{ff_0p88v_m40c 0.72}
}
set ::PRUNE {
{scan_capture * hold {capture is one edge; hold is covered by functional}}
}
proc pruned {mode corner check} {
foreach rule $::PRUNE {
lassign $rule m c k why
if {[string match $m $mode] && [string match $c $corner] && [string match $k $check]} {
return $why
}
}
return ""
}The tool commands these scripts drive are the same teaching mock introduced in Chapter 30.1 §2; the slack numbers below come from a deterministic function of period and derate, not from a vendor. The mechanism — scenario expansion, per-scenario reports, aggregation — is the real thing, and it is what the chapter is about.
Writing the drop as {scan_capture * hold {capture is one edge; hold is covered by functional}} does three things that a deleted line cannot. It survives review, because the reason is next to the rule. It gets printed with the results, so a reader of the report knows the matrix was pruned. And it is checkable: a later script can ask whether the reason is still true.
This is Chapter 26.4's argument, and the shape is the same one Chapter 29.1 wanted everywhere — the thing you decided, recorded where the decision is used.
proc required_scenarios {} {
set out {}
foreach m $::MODES {
foreach c $::CORNERS {
foreach check {setup hold} {
set why [pruned [lindex $m 0] [lindex $c 0] $check]
if {$why eq ""} {
lappend out [dict create mode [lindex $m 0] period [lindex $m 1] \
corner [lindex $c 0] derate [lindex $c 1] check $check]
}
}
}
}
return $out
}required_scenarios is the whole of the scenario logic: the cross-product, minus the pruned pairs, as a list of dicts. Nothing downstream asks why a scenario is in the list, which is what makes the list the single place to change.
2. Each scenario writes its own directory
proc scenario_dir {root s} {
return [file join $root "[dict get $s mode]__[dict get $s corner]__[dict get $s check]"]
}
proc write_scenario_report {root s rows} {
set dir [scenario_dir $root $s]
file mkdir $dir
set fh [open [file join $dir timing.rpt] w]
foreach k {mode corner check period derate} {
puts $fh [format "%-8s %s" $k [dict get $s $k]]
}
puts $fh [string repeat - 48]
foreach r [lsort -real -index 3 $rows] {
puts $fh [format "%-16s slack %8.4f" [dict get $r endpoint] [dict get $r slack]]
}
set worst [dict get [lindex [lsort -real -index 3 $rows] 0] slack]
puts $fh [string repeat - 48]
puts $fh [format "worst %s slack: %.4f" [dict get $s check] $worst]
close $fh
return $worst
}The directory name is built from the scenario's own fields:
=== the six directories the run produced ===
functional__ff_0p88v_m40c__hold
functional__ff_0p88v_m40c__setup
functional__ss_0p72v_125c__hold
functional__ss_0p72v_125c__setup
scan_capture__ff_0p88v_m40c__setup
scan_capture__ss_0p72v_125c__setupSix directories, and you can read the scenario out of each name without opening anything. Compare the alternative that every flow starts with — timing1.rpt through timing6.rpt, or one timing.rpt overwritten six times — and the cost of the alternative is that a number can no longer be traced back to the conditions that produced it. That is Chapter 26.6's point, and it is also half of Chapter 29.5's: a result you cannot attribute to a scenario is a result you cannot defend.
Each report also repeats its own scenario at the top, so the file is self-describing even if it is moved:
=== one scenario's own report, in full ===
mode functional
corner ss_0p72v_125c
check setup
period 5.0
derate 1.30
------------------------------------------------
u_cmd_reg/D slack -0.4280
u_sync_reg/D slack -0.0250
u_sel_reg/D slack 0.3780
u_state_reg/D slack 0.7810
u_dout_reg/D slack 1.1840
u_addr_reg/D slack 1.5870
------------------------------------------------
worst setup slack: -0.4280The rows are sorted worst first, which costs one lsort -real -index 3 and means the first line after the rule is the one that matters.
3. The run
# ---- the driver ---------------------------------------------------------
proc run_signoff {root} {
set scenarios [required_scenarios]
set total [expr {[llength $::MODES] * [llength $::CORNERS] * 2}]
puts " scenarios: [llength $scenarios] required of $total in the cross-product"
foreach rule $::PRUNE {
puts [format " dropped : %-13s %-14s %-6s %s" {*}$rule]
}
puts ""
set results {}
foreach s $scenarios {
set rows [analyse $s]
set worst [write_scenario_report $root $s $rows]
lappend results [dict merge $s [dict create worst $worst rows $rows]]
puts [format " %-13s %-14s %-6s worst %8.4f -> %s" \
[dict get $s mode] [dict get $s corner] [dict get $s check] $worst \
[file tail [scenario_dir $root $s]]]
}
return $results
} scenarios: 6 required of 8 in the cross-product
dropped : scan_capture * hold capture is one edge; hold is covered by functional
functional ss_0p72v_125c setup worst -0.4280 -> functional__ss_0p72v_125c__setup
functional ss_0p72v_125c hold worst 0.0632 -> functional__ss_0p72v_125c__hold
functional ff_0p88v_m40c setup worst 1.9268 -> functional__ff_0p88v_m40c__setup
functional ff_0p88v_m40c hold worst -0.0096 -> functional__ff_0p88v_m40c__hold
scan_capture ss_0p72v_125c setup worst -0.4280 -> scan_capture__ss_0p72v_125c__setup
scan_capture ff_0p88v_m40c setup worst 1.9268 -> scan_capture__ff_0p88v_m40c__setup6 required of 8 and the dropped rule printed underneath. A reader cannot mistake this for a full cross-product, and cannot mistake the pruning for an accident.
Two things in that table are worth noticing before the summary. Setup fails in the slow corner and holds in the fast one — -0.4280 at ss_0p72v_125c, +1.9268 at ff_0p88v_m40c. And hold does the opposite: +0.0632 slow, -0.0096 fast. That inversion is the entire reason multi-corner sign-off exists, and it is also the reason the next section matters.
4. Aggregate per check, never across
proc summarise {results} {
puts ""
puts " === per check, never across (24.4 section 5) ==="
set by [dict create]
foreach r $results { dict lappend by [dict get $r check] $r }
dict for {check rs} $by {
set worst "" ; set at ""
foreach r $rs {
if {$worst eq "" || [dict get $r worst] < $worst} {
set worst [dict get $r worst] ; set at "[dict get $r mode]/[dict get $r corner]"
}
}
set viol 0
foreach r $rs { if {[dict get $r worst] < 0} { incr viol } }
puts [format " %-6s worst %8.4f at %-28s %d of %d scenario(s) violating" \
$check $worst $at $viol [llength $rs]]
} === per check, never across (24.4 section 5) ===
setup worst -0.4280 at functional/ss_0p72v_125c 2 of 4 scenario(s) violating
hold worst -0.0096 at functional/ff_0p88v_m40c 1 of 2 scenario(s) violatingNow the measurement that justifies the structure. Here are the same six scenarios aggregated both ways:
=== the SAME six scenarios, aggregated the two ways ===
across every check (one number) : worst -0.4280
setup only : worst -0.4280
hold only : worst -0.0096
the single number is the SETUP number; the hold violation is invisible in itThe single worst number is -0.4280 — which is the setup number. The hold violation at -0.0096 is real, it is a different physical problem needing a different fix, and it does not appear anywhere in the one-number summary. Not as a smaller number, not as a footnote: it is gone, because a minimum over a mixed set reports whichever member happened to be most negative.
This is Chapter 24.4 §5's rule arriving as an arithmetic fact rather than as advice. Setup slack and hold slack are not comparable quantities; taking a minimum across them is a type error that the arithmetic cannot refuse.
Each line also carries how many scenarios violated, not just the worst: 2 of 4 and 1 of 2. One failing corner out of four is a corner problem; four out of four is a design problem.
5. The distribution
A worst number says how bad the worst path is. It says nothing at all about how many paths are bad, and those lead to different work.
# ---- the slack distribution: the thing a worst number cannot say -------
proc histogram {slacks {bins 6}} {
set lo [lindex [lsort -real $slacks] 0]
set hi [lindex [lsort -real $slacks] end]
if {$hi <= $lo} { set hi [expr {$lo + 1.0}] }
set w [expr {($hi - $lo) / double($bins)}]
set counts {}
for {set b 0} {$b < $bins} {incr b} { lappend counts 0 }
foreach v $slacks {
set b [expr {int(($v - $lo) / $w)}]
if {$b >= $bins} { set b [expr {$bins - 1}] }
lset counts $b [expr {[lindex $counts $b] + 1}]
}
set out {}
for {set b 0} {$b < $bins} {incr b} {
set from [expr {$lo + $b * $w}]
set to [expr {$from + $w}]
set n [lindex $counts $b]
# Mark any bin that CONTAINS a negative slack. A marker that only
# fires when a whole bin is below zero never fires at all, because
# the lowest bin starts at the worst value and usually crosses zero.
set mark [expr {$from < 0 ? "*" : " "}]
lappend out [format " %s %8.4f .. %8.4f %-20s %d" \
$mark $from $to [string repeat "#" $n] $n]
}
return $out
} === the setup slack distribution across every required scenario ===
24 path(s), 4 violating; * marks a bin containing violations
* -0.4280 .. 0.1505 #### 4
0.1505 .. 0.7289 ## 2
0.7289 .. 1.3074 #### 4
1.3074 .. 1.8859 ## 2
1.8859 .. 2.4643 ###### 6
2.4643 .. 3.0428 ###### 6
the distribution is what says 4 paths fail, not just that the worst is -0.428024 paths, 4 violating. The shape is as informative as the count: the violations sit in one bin at the bottom of an otherwise healthy spread, with most paths comfortably positive. That is a handful of specific paths — an ECO. The same worst number with a histogram whose mass sits below zero is a clock period that is wrong, and no amount of path fixing will reach it.
6. The marker that never fired
The * in that output marks a bin containing a violation. It was first written to mark a bin that is entirely below zero:
set mark [expr {$to <= 0 ? "*" : " "}]which reads perfectly and never once fired. Both versions, on the same six bins:
=== the histogram marker, as first written and as fixed ===
BIN FROM TO as fixed as first written
0 -0.4280 0.1505 * (never fires)
1 0.1505 0.7289 (never fires)
2 0.7289 1.3074 (never fires)
3 1.3074 1.8859 (never fires)
4 1.8859 2.4643 (never fires)
5 2.4643 3.0428 (never fires)
'the whole bin is below zero' is never true: bin 0 starts at the worst
value and ends above zero, so the marker marked nothing at allBin 0 runs from -0.4280 to +0.1505. The lowest bin always starts at the worst value, and because the data span is much wider than the violation, it always ends above zero — so "the whole bin is below zero" is unsatisfiable for any input with both violations and healthy paths, which is every input anybody runs this on. The condition is not wrong about the data; it is wrong about what it was asked.
set mark [expr {$from < 0 ? "*" : " "}]This is Chapter 30.1 §8's unreachable archive gate again, and the two together are worth stating as one rule: a condition you wrote and never saw fire has not been tested, and the most likely reason it never fired is that it cannot. The test is cheap — print both columns once, as above — and it is the only thing that distinguishes a working marker from a decoration.
7. What this script still cannot tell you
Honest limits, because a sign-off script that claims completeness is the dangerous kind:
- It cannot tell you the scenario list is right. It reports
6 required of 8and the reason for the drop; whether that reason is true is a judgement, re-litigated per project. The script's contribution is making the judgement visible rather than implicit. - It cannot tell you a scenario ran. Every row above is present because
analysewas called. A scenario whose analysis crashed would need the third status that Chapter 30.5 builds — this script has pass and fail, not pass, fail and never-ran. - It cannot tell you the paths are the same paths.
2 of 4 scenarios violatingcounts scenarios. Whether the same endpoint fails in both or two different endpoints fail once each is a question the per-scenario reports answer and the summary does not. - A distribution can still hide a bimodal failure. Six bins over a 3.5 ns span is 0.58 ns per bin; two distinct failure populations 0.2 ns apart land in one bar.
Exercises
- Add
{functional ss_0p72v_125c setup {}}to::PRUNEand run it. How many scenarios remain, and what does the summary's worst setup number become? - Change
summariseto take one minimum across all six scenarios. Which violation disappears from the report, and what would a reader conclude? - Restore the
$to <= 0marker and run the script on a design where every setup path violates. Does the marker fire now? Why does that make it worse rather than better? - The
2 of 4 scenario(s) violatingcount is per scenario. Change it to count violating endpoints across scenarios, and say which of the two you would put in a sign-off summary. - Make
analyseraise for one scenario. What does the script report, and what should it report? - Increase the histogram to 24 bins. Does the conclusion of section 5 change, and what new problem appears?
Answers
- Five scenarios, and the worst setup becomes
-0.4280still — becausescan_capture/ss_0p72v_125chas the identical worst value. Which is the uncomfortable result: dropping a scenario changed nothing visible, so a reviewer reading only the summary cannot tell the matrix shrank. The5 required of 8line and the printed reason are the only evidence, which is why they are printed. - The hold violation at
-0.0096. The report would say worst-0.4280, a reader would open the setup scenario it names, fix or waive that path, and ship with a hold violation nobody looked at. The number was never wrong; it was answering a different question from the one asked. - Yes, and that is the trap. On an all-violating design bin 0 can lie entirely below zero, so the marker fires and looks correct — meaning the bug is invisible in exactly the case you would use to test it. A check that works on catastrophic input and silently stops working on realistic input is worse than one that never works, because the first test passes.
- Endpoints give you severity, scenarios give you breadth, and a sign-off summary needs both —
4 of 24 paths in 2 of 4 scenariosis the line that cannot be misread. A single count of either kind can be made to look benign by choosing the other denominator. - It stops with an uncaught error and no summary at all, which is better than a wrong summary and worse than the alternative: record the scenario as not analysed, print it in the table, and refuse a verdict — exactly the third status Chapter 30.5 implements. Two outcomes cannot express "I do not know".
- The conclusion holds — the violations still occupy the bottom of the range and the mass is still positive — but with 24 bins over 24 paths most bars are 0 or 1 and the shape becomes noise. A histogram needs enough samples per bin to have a shape; the honest rule is far fewer bins than paths, and at 24 paths six bins is already generous.
Where this fits
Part of the Tcl curriculum.
