Tcl · Module 24
Parsing a Timing Report
Reading a timing report by eye works for one block. It does not work for a chip, and the arithmetic says why. This is the parser, built in the order its defects appear.
Intermediate22 min readtcledapart-iireportsregextiming
Part II · Module 24 · Chapter 24.1 · Parsing a Timing Report
Everything in Module 22 produced reports. This module turns them back into data.
The first thing to settle is whether that is necessary at all.
1. Why reading it by eye does not scale
A block-level timing report with a few dozen paths is readable. The numbers that matter are in a fixed place, and an engineer who knows the format finds them in seconds.
Now put a number on the full-chip case.
A design closing timing across 8 corners, with 4 path groups each, reporting the worst 50 paths per group, is 1600 path blocks per run — about 30 000 lines. A sign-off sweep runs that nightly. Comparing last night's run with tonight's, by eye, is not a slower version of the same activity; it is a different activity, and nobody does it.
2. The line that carries the answer
The report used throughout this module is a teaching mock written for these chapters — its own Version: line says so. Field names and layout differ between tools and between releases of one tool, which is Chapter 24.7's subject. What transfers is the technique.
Three paths, so three verdicts:
=== the three slack lines, as they appear ===
'slack (VIOLATED) -0.0730'
'slack (MET) 0.3120'
'slack (VIOLATED) -0.0190'The obvious first pattern:
set fh [open $path r] ; set text [read $fh] ; close $fh
set slack ""
puts " matched : [regexp {slack \(VIOLATED\)\s+(-?\d+\.\d+)} $text -> slack]"
puts " slack : $slack"=== one regexp, one answer ===
matched : 1
slack : -0.0730Braced regex, per Chapter 13.5 — a quoted one would lose its backslashes and match something else entirely. \( and \) are escaped because parentheses are grouping metacharacters and here they are literal text in the report.
It works. It also answers a question nobody asked: one violated slack, the first one in the file.
3. Every answer, not the first
set hits [regexp -all -inline {slack \((VIOLATED|MET)\)\s+(-?\d+\.\d+)} $text]
puts " elements returned : [llength $hits]"
foreach {whole status value} $hits { puts [format " %-8s %s" $status $value] }=== -all -inline, every answer ===
elements returned : 9
VIOLATED -0.0730
MET 0.3120
VIOLATED -0.0190Nine elements for three matches. -inline returns a flat list of the whole match followed by each capture group, repeated per match — so with two groups each match contributes three elements. That is why the foreach takes three variables per pass (Chapter 9.3), and why [llength $hits] is not the number of matches. Divide by one plus the group count, or iterate and count.
Getting that arithmetic wrong is the standard -inline bug: a script that reports "9 violations" from a report containing two.
4. A slack with no endpoint is not an answer
-0.0730 on its own cannot be acted on. The question is always which endpoint, and the endpoint is on a different line — fourteen lines earlier, in a different part of the block.
A single regexp cannot pair them reliably (§6 shows what happens when you try). What pairs them is a line-at-a-time loop that remembers:
proc parse_timing {path} {
set fh [open $path r]
set paths {}
set start "" ; set end "" ; set group ""
while {[gets $fh line] >= 0} {
if {[regexp {^\s*Startpoint:\s+(\S+)} $line -> s]} { set start $s ; continue }
if {[regexp {^\s*Endpoint:\s+(\S+)} $line -> e]} { set end $e ; continue }
if {[regexp {^\s*Path Group:\s+(\S+)} $line -> g]} { set group $g ; continue }
if {[regexp {^\s*slack \((VIOLATED|MET)\)\s+(-?\d+\.\d+)} $line -> status value]} {
lappend paths [dict create start $start end $end group $group \
status $status slack $value]
set start "" ; set end "" ; set group ""
}
}
close $fh
return $paths
}=== one row per path, endpoint attached ===
ENDPOINT STARTPOINT GROUP STATUS SLACK
u_mem/addr_reg_7 u_ctrl/state_reg_2 core_clk VIOLATED -0.0730
u_ctrl/cmd_reg_1 u_io/u_sync/sync_reg_0 core_clk MET 0.3120
u_mem/dout_reg_3 u_mem/u_bank0/rd_reg_3 core_clk VIOLATED -0.0190
3 path(s) parsedFour things in that loop are load-bearing.
The slack line is the one that commits a row. It is last in every block, so when it arrives the other fields are already set. Picking the right line as the commit point is the whole design of a stateful parser.
The accumulators are cleared after each row. Without that, a block missing an Endpoint: line silently inherits the previous block's endpoint — a wrong answer that looks completely plausible. This is the same stale-capture hazard as Chapter 13.9: a failed match leaves its variables untouched, so the defence is to reset deliberately rather than to trust that a match happened.
continue after each match. A line that is a Startpoint: cannot also be a slack line, and skipping the remaining tests is both faster and a statement that these cases are exclusive.
^\s* anchors every pattern to the start of the line. That matters more than it looks — §6 and Chapter 24.7 are both about patterns matching in places you did not intend.
5. What the parsed form makes answerable
set viol {}
foreach p $paths { if {[dict get $p status] eq "VIOLATED"} { lappend viol $p } }
set worst ""
foreach p $paths {
if {$worst eq "" || [dict get $p slack] < [dict get $worst slack]} { set worst $p }
}
set total 0.0
foreach p $viol { set total [expr {$total + [dict get $p slack]}] }=== now the questions a script can answer ===
violated : 2 of 3
worst : -0.0730 at u_mem/addr_reg_7
TNS : -0.0920 over 2 endpoint(s)Three lines of ordinary Tcl, and all three carry their denominator — 2 of 3, over 2 endpoint(s) — which is Chapter 22.7 §4's rule and Module 23's recurring one.
TNS (total negative slack) is the sum over violating endpoints, and it answers a question WNS cannot: whether this is one bad path or four hundred mediocre ones. WNS -0.0730 with TNS -0.0920 is two endpoints. WNS -0.0730 with TNS -30.0 is a different design.
6. The whole-file regexp that pairs the wrong things
The tempting alternative to a stateful loop is one clever pattern spanning both lines:
regexp {Endpoint:\s+(\S+).*slack \(VIOLATED\)\s+(-?\d+\.\d+)} $text -> e v=== one regexp over the whole file, greedy ===
matched : 1
endpoint: u_mem/addr_reg_7
slack : -0.0190
(the real pairing for that slack is u_mem/dout_reg_3)It matched, and it is wrong. . matches newlines, so .* ran from the first Endpoint: all the way to the last violated slack in the file, pairing the first endpoint with the third path's number. The result is a complete, plausible, incorrect row — and regexp returned 1, so nothing complains.
The reflex fix is a non-greedy quantifier:
=== non-greedy, which pairs correctly but still returns ONE ===
matched : 1
endpoint: u_mem/addr_reg_7
slack : -0.0190Identical output. The .*? changed nothing.
That is not a typo in the capture, and it is not what Chapter 13.8 would lead you to expect. It is the next section.
7. Greediness is a property of the whole regular expression
Four patterns against aXbXc, where X appears twice so the choice is observable:
=== two targets, so greediness is observable ===
a.*X -> 'aXbX'
a.*?X -> 'aX'
a+.*?X -> 'aXbX'
a+?.*X -> 'aX'Read the last two rows carefully.
a+.*?X→aXbX. The.*?is non-greedy, and it behaved greedily, because the first quantifier in the pattern is the greedya+.a+?.*X→aX. The.*is greedy, and it behaved non-greedily, because the first quantifier is the non-greedya+?.
So greediness is not per-quantifier, as it is in Perl, Python and JavaScript. It is one property of the whole expression, decided by the first quantifier that has a preference. In §6's pattern the first quantifier was the \s+ inside Endpoint:\s+, which is greedy — so the later .*? could not take effect.
Why this is a parsing bug and not a curiosity
=== and this is how it bites a parser ===
ok=1 \(VIOLATED\)\s+(-?\d+\.\d+) -> '-0.0730'
ok=1 \(VIOLATED\)\s+?(-?\d+\.\d+) -> '-0.0'
ok=1 \(VIOLATED\)\s+?(-?\d+\.\d+)$ -> '-0.0730'The middle row is the one to remember. Writing \s+? — a perfectly reasonable thing to type, meaning "at least one space, don't be greedy about it" — makes the whole pattern non-greedy, so (-?\d+\.\d+) also matches as little as it can: -0.0 instead of -0.0730.
ok=1. The match succeeded. The parser returns a number. The number is wrong by a factor of 73, and it is wrong in the safe-looking direction — a slack of -0.0 instead of -0.073 makes a violated path look like a rounding artefact.
No validator, no catch, and no amount of testing against a report whose numbers happen to end in zero will find that.
The two defences
Anchor the group. The third row above adds $, and the number comes back whole: once the match must reach the end of the line, "as little as possible" and "all of it" are the same thing. Anchoring is the general cure, because it removes the engine's freedom rather than arguing with it.
Or use a negated character class instead of a quantifier preference, as Chapter 13.8 §4 recommends — [^ ]+ or [^)]* describes the content rather than the search strategy, and cannot overshoot in either direction. Chapter 21.3 hit the limit of that approach when a pattern needed to span nested parentheses; within one line and one field it is the better habit.
And the structural defence is §4's: parse line by line. A pattern confined to one line has far less room to be creatively wrong than one spanning 30 000.
8. Exercises
Exercise 1. Why is "read the report by eye" not just a slower version of parsing it?
Exercise 2. Why does -all -inline return 9 elements for 3 matches?
Exercise 3. Why must the accumulator variables be cleared after each row is committed?
Exercise 4. Why is the slack line the right place to commit a row?
Exercise 5. WNS is −0.073 in two designs; TNS is −0.09 in one and −30.0 in the other. What is different?
Exercise 6. The whole-file pattern matched and returned a wrong pairing. What did .* do?
Exercise 7. a+.*?X matches aXbX on aXbXc. Why does the non-greedy quantifier not take effect?
Exercise 8. \s+?(-?\d+\.\d+) returns -0.0 from a line whose slack is -0.0730. Explain, and give two fixes.
Where this fits
Part of the Tcl curriculum.
