Skip to content
VLSI Mentor

Perl Part II · Module 23 · Static Timing Analysis Report Parsing

Top-N Endpoints and Slack Histograms

A timing report can hold a hundred thousand endpoints, and nobody reads a hundred thousand rows. Two views make it reviewable: the worst N endpoints, and a bucketed distribution. Both are a few lines of Perl, and all three of the obvious ways to write those lines are wrong in a way that still prints a clean table — a slice that invents elements, a bucket that is twice as wide as the others, and a tie that comes out in a different order every time you run it.

Advanced13 min readPerlPart IISTA Reports

Part II · Module 23 · Chapter 23.10 · Top-N Endpoints and Slack Histograms

1. The Twenty That Matter

The fixture for this chapter is bigger than the one the rest of the module used, because four endpoints cannot show a distribution. It is a four-lane sixteen-bit adder block — 208 flops and 423 gates — whose netlist was checked against a behavioural reference by simulation before any timing was derived from it.

Azvya Education Pvt. Ltd.VLSI Mentor
topn.pl
use strict;
use warnings;
use POSIX qw(floor);

# ---- 1. the population: every endpoint and its slack, from one report
my %slack;
{
    open my $fh, "<", "reports/block_slow.pt.rpt" or die "report: $!";
    my $ep;
    while (my $l = <$fh>) {
        $ep = $1 if $l =~ /^\s*Endpoint:\s+(\S+)/;
        if (defined $ep && $l =~ /^\s*slack \((MET|VIOLATED)\)\s+(\S+)/) {
            $slack{$ep} = $2 + 0;
            undef $ep;
        }
    }
    close $fh;
}
printf "endpoints read : %d\n", scalar keys %slack;
printf "violating      : %d\n", scalar grep { $slack{$_} < 0 } keys %slack;

# ---- 2. top-N worst: the twenty that matter
my $N = 20;
my @by_slack = sort { $slack{$a} <=> $slack{$b} or $a cmp $b } keys %slack;
my @worst = @by_slack[0 .. $N - 1];
print "\n==== top $N worst endpoints ====\n";
printf "%3s  %-12s %9s  %s\n", "#", "ENDPOINT", "SLACK", "";
for my $k (0 .. $#worst) {
    printf "%3d  %-12s %+9.4f  %s\n", $k + 1, $worst[$k], $slack{$worst[$k]},
        $slack{$worst[$k]} < 0 ? "VIOLATED" : "";
}

# ---- 3. the registry idiom on a population SMALLER than N
print "\n==== the same slice on a 12-endpoint report ====\n";
my %small = map { $_ => $slack{$_} } @by_slack[0 .. 11];
{
    my @s = sort { $small{$a} <=> $small{$b} } keys %small;
    my @slice = @s[0 .. 19];                 # the idiom, unchanged
    my $undef = grep { !defined } @slice;
    printf "population %d, slice asked for 20 -> %d element(s), %d undefined\n",
        scalar(keys %small), scalar(@slice), $undef;
    my @safe = @s[0 .. ($N > @s ? $#s : $N - 1)];
    printf "clamped slice -> %d element(s), %d undefined\n",
        scalar(@safe), scalar(grep { !defined } @safe);
}

# ---- 4. the histogram, two ways of finding the bucket
my $W = 0.05;
my (%int_b, %flo_b);
for my $ep (keys %slack) {
    $int_b{ int($slack{$ep} / $W) }++;
    $flo_b{ floor($slack{$ep} / $W) }++;
}
print "\n==== bucket width 0.05: int() against floor() ====\n";
printf "int()   distinct buckets: %d\n", scalar keys %int_b;
printf "floor() distinct buckets: %d\n", scalar keys %flo_b;
printf "int()   bucket 0 holds %d endpoint(s)\n", $int_b{0} // 0;
printf "floor() bucket 0 holds %d endpoint(s)\n", $flo_b{0} // 0;
printf "floor() bucket -1 holds %d endpoint(s)\n", $flo_b{-1} // 0;
print "\nthe endpoints int() puts in bucket 0:\n";
for my $ep (sort { $slack{$a} <=> $slack{$b} } keys %slack) {
    next unless int($slack{$ep} / $W) == 0;
    printf "  %-12s %+8.4f   floor bucket %+d  %s\n", $ep, $slack{$ep},
        floor($slack{$ep} / $W), $slack{$ep} < 0 ? "VIOLATED" : "MET";
}
printf "\nint() bucket 0 spans %.2f to %.2f  (width %.2f)\n", -$W, $W, 2 * $W;
printf "every other int() bucket spans %.2f\n", $W;

# ---- 5. how much of the full-range histogram is empty
my @ks = sort { $a <=> $b } keys %flo_b;
printf "\nfull range: buckets %d .. %d = %d slots, %d non-empty (%.0f%% empty)\n",
    $ks[0], $ks[-1], $ks[-1] - $ks[0] + 1, scalar(@ks),
    100 * (1 - @ks / ($ks[-1] - $ks[0] + 1));

# ---- 6. the histogram people actually read: clipped, with an overflow row
print "\n==== slack distribution, clipped at +0.40 ====\n";
my $CLIP = 0.40;
my %h;
my $over = 0;
for my $ep (keys %slack) {
    if ($slack{$ep} >= $CLIP) { $over++; next }
    $h{ floor($slack{$ep} / $W) }++;
}
for my $b (sort { $a <=> $b } keys %h) {
    my $lo = $b * $W;
    printf "  %+6.2f .. %+6.2f  %-20s %2d%s\n", $lo, $lo + $W,
        "#" x $h{$b}, $h{$b}, $lo < 0 ? "   <- VIOLATED" : "";
}
printf "  %+6.2f and above  %-20s %2d\n", $CLIP, "", $over;
printf "\ntotal %d = %d shown + %d above the clip\n",
    scalar(keys %slack), scalar(keys %slack) - $over, $over;

# ---- 7. a tie across the cut, measured across SEPARATE processes
print "\n==== a cut that lands inside a tie ====\n";
my %count;
$count{ sprintf "%.4f", $slack{$_} }++ for keys %slack;
my ($big) = sort { $count{$b} <=> $count{$a} } keys %count;
printf "largest group of equal slacks: %d endpoints at %+.4f\n", $count{$big}, $big;
# hash order is fixed WITHIN a process, so three loops in one process prove
# nothing. Each sample below is a separate perl process.
if (!$ENV{TIE_CHILD}) {
    my (%plain, %broken);
    for my $run (1 .. 5) {
        local $ENV{TIE_CHILD} = 1;
        my $out = `perl "$0" 2>/dev/null`;
        $plain{$1}++  if $out =~ /^no tie-breaker: (.*)$/m;
        $broken{$1}++ if $out =~ /^with tie-breaker: (.*)$/m;
    }
    printf "5 separate processes, places 69-72 of the sort:\n";
    printf "  no tie-breaker   : %d distinct ordering(s)\n", scalar keys %plain;
    printf "  with a name tie-breaker: %d distinct ordering(s)\n",
        scalar keys %broken;
    printf "  the stable answer: %s\n", (keys %broken)[0]
        if keys %broken == 1;
} else {
    my @a = (sort { $slack{$a} <=> $slack{$b} } keys %slack)[68 .. 71];
    my @b = (sort { $slack{$a} <=> $slack{$b} or $a cmp $b } keys %slack)[68 .. 71];
    printf "no tie-breaker: %s\n", join " ", @a;
    printf "with tie-breaker: %s\n", join " ", @b;
    exit 0;
}

# ---- 8. what each view answers, and what it hides
print "\n==== the two views answer different questions ====\n";
printf "top-20 says    : worst %+.4f at %s; %d of the 20 are violating,\n",
    $slack{$worst[0]}, $worst[0], scalar grep { $slack{$_} < 0 } @worst;
printf "                 so %d rows describe endpoints that already pass\n",
    scalar grep { $slack{$_} >= 0 } @worst;
my $near = grep { $slack{$_} >= 0 && $slack{$_} < 0.05 } keys %slack;
printf "histogram says : %d violating, %d more within 0.05 of failing,\n",
    scalar(grep { $slack{$_} < 0 } keys %slack), $near;
printf "                 and %d of %d endpoints are above the clip\n",
    $over, scalar keys %slack;
my %lane;
for my $ep (@worst) { $lane{$1}++ if $ep =~ /^sf(\d)_\d+/ }
printf "top-20 by lane : %s\n",
    join ", ", map { "lane $_ = $lane{$_}" } sort keys %lane;
printf "every one of the 20 is a sum flop, %d per lane -- the finding is the\n",
    $lane{0};
print "carry chain, not any one lane\n";
printf "\nrows needed: full-range histogram %d, clipped %d, top-20 list %d\n",
    $ks[-1] - $ks[0] + 1, scalar(keys %h) + 1, $N;
Azvya Education Pvt. Ltd.VLSI Mentor
Captured output
endpoints read : 80
violating      : 6

==== top 20 worst endpoints ====
  #  ENDPOINT         SLACK  
  1  sf3_15/D       -0.2580  VIOLATED
  2  sf2_15/D       -0.2070  VIOLATED
  3  sf1_15/D       -0.1560  VIOLATED
  4  sf0_15/D       -0.1050  VIOLATED
  5  sf3_14/D       -0.0820  VIOLATED
  6  sf2_14/D       -0.0310  VIOLATED
  7  sf1_14/D       +0.0200  
  8  sf0_14/D       +0.0710  
  9  sf3_13/D       +0.0940  
 10  sf2_13/D       +0.1450  
 11  sf1_13/D       +0.1960  
 12  sf0_13/D       +0.2470  
 13  sf3_12/D       +0.2700  
 14  sf2_12/D       +0.3210  
 15  sf1_12/D       +0.3720  
 16  sf0_12/D       +0.4230  
 17  sf3_11/D       +0.4460  
 18  sf2_11/D       +0.4970  
 19  sf1_11/D       +0.5480  
 20  sf0_11/D       +0.5990  

==== the same slice on a 12-endpoint report ====
population 12, slice asked for 20 -> 20 element(s), 8 undefined
clamped slice -> 12 element(s), 0 undefined

==== bucket width 0.05: int() against floor() ====
int()   distinct buckets: 55
floor() distinct buckets: 56
int()   bucket 0 holds 2 endpoint(s)
floor() bucket 0 holds 1 endpoint(s)
floor() bucket -1 holds 1 endpoint(s)

the endpoints int() puts in bucket 0:
  sf2_14/D      -0.0310   floor bucket -1  VIOLATED
  sf1_14/D      +0.0200   floor bucket +0  MET

int() bucket 0 spans -0.05 to 0.05  (width 0.10)
every other int() bucket spans 0.05

full range: buckets -6 .. 52 = 59 slots, 56 non-empty (5% empty)

==== slack distribution, clipped at +0.40 ====
   -0.30 ..  -0.25  #                     1   <- VIOLATED
   -0.25 ..  -0.20  #                     1   <- VIOLATED
   -0.20 ..  -0.15  #                     1   <- VIOLATED
   -0.15 ..  -0.10  #                     1   <- VIOLATED
   -0.10 ..  -0.05  #                     1   <- VIOLATED
   -0.05 ..  +0.00  #                     1   <- VIOLATED
   +0.00 ..  +0.05  #                     1
   +0.05 ..  +0.10  ##                    2
   +0.10 ..  +0.15  #                     1
   +0.15 ..  +0.20  #                     1
   +0.20 ..  +0.25  #                     1
   +0.25 ..  +0.30  #                     1
   +0.30 ..  +0.35  #                     1
   +0.35 ..  +0.40  #                     1
   +0.40 and above                       65

total 80 = 15 shown + 65 above the clip

==== a cut that lands inside a tie ====
largest group of equal slacks: 16 endpoints at +2.6110
5 separate processes, places 69-72 of the sort:
  no tie-breaker   : 5 distinct ordering(s)
  with a name tie-breaker: 1 distinct ordering(s)
  the stable answer: cff_12/D cff_13/D cff_14/D cff_15/D

==== the two views answer different questions ====
top-20 says    : worst -0.2580 at sf3_15/D; 6 of the 20 are violating,
                 so 14 rows describe endpoints that already pass
histogram says : 6 violating, 1 more within 0.05 of failing,
                 and 65 of 80 endpoints are above the clip
top-20 by lane : lane 0 = 5, lane 1 = 5, lane 2 = 5, lane 3 = 5
every one of the 20 is a sum flop, 5 per lane -- the finding is the
carry chain, not any one lane

rows needed: full-range histogram 59, clipped 15, top-20 list 20

The top-N itself is one line, and it is Chapter 5.9's sort with Chapter 5.3's slice on the end:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
my @worst = (sort { $slack{$a} <=> $slack{$b} } keys %slack)[0 .. $N - 1];

<=>, never cmp — a string sort puts -1.5 after -0.2, which is the wrong end of the list for the one thing you are looking for.

2. The Slice That Grows

Now the first defect, and it is in the obvious code:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
population 12, slice asked for 20 -> 20 element(s), 8 undefined
clamped slice -> 12 element(s), 0 undefined

A list slice extends a short list rather than truncating it. Asking for [0 .. 19] of twelve elements returns twenty values, the last eight undef. Perl does not complain at the slice; it complains later, somewhere else, when something uses one of those undefs — or it does not complain at all and your report has eight blank rows.

This is not a contrived case. A report written with -max_paths 10 has ten endpoints in it. So does a report whose run died early. Either way the summary script asks for twenty and gets twelve real answers padded with eight holes.

The clamp is small:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
my $last = $N > @sorted ? $#sorted : $N - 1;
my @worst = @sorted[0 .. $last];

And then say the number out loud in the heading — top 12 of 12 rather than top 20 — because a reader who sees "top 20" above twelve rows has to work out which of the two numbers is lying.

3. The Bucket At Zero Is The Wrong Width

The histogram is also one line, and the registry's own form of it uses int:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$bucket{ int($slack{$_} / 0.05) }++ for keys %slack;

That looks right and is wrong in exactly one place:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int()   bucket 0 holds 2 endpoint(s)
floor() bucket 0 holds 1 endpoint(s)
floor() bucket -1 holds 1 endpoint(s)

the endpoints int() puts in bucket 0:
  sf2_14/D      -0.0310   floor bucket -1  VIOLATED
  sf1_14/D      +0.0200   floor bucket +0  MET

int() bucket 0 spans -0.05 to 0.05  (width 0.10)
every other int() bucket spans 0.05

int truncates toward zero, not downward. So int(-0.031 / 0.05) is int(-0.62) which is 0, and int(0.020 / 0.05) is int(0.40) which is also 0. Both land in bucket 0, which therefore spans -0.05 to +0.05 — double the width of every other bucket, and straddling the sign.

The consequence is the measurement above: a VIOLATED endpoint and a MET endpoint in the same histogram row, in the one row a reviewer reads hardest. Every other row in the table is honest, the totals add up, and the bucket that decides whether the block is failing is the one that is wrong.

POSIX::floor rounds downward for negatives too, which is what bucketing means:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
use POSIX qw(floor);
$bucket{ floor($slack{$_} / 0.05) }++ for keys %slack;

Negative bucket indices are then real: bucket -1 is "failing by up to 0.05", and that is a row worth having. The same trap applies to any signed quantity you bucket — a day offset, a temperature delta, a percentage change.

4. A Tie At The Cut

The third defect only appears when you run the script twice.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
largest group of equal slacks: 16 endpoints at +2.6110
5 separate processes, places 69-72 of the sort:
  no tie-breaker   : 5 distinct ordering(s)
  with a name tie-breaker: 1 distinct ordering(s)

Sixteen endpoints share a slack, because sixteen bits of one register have identical logic in front of them — which is normal, not a coincidence. sort with a comparison that returns 0 for all sixteen leaves them in the order keys produced, and keys order is chosen per process (6.6), so five processes gave five different orderings.

For the worst-20 that may not matter, since the cut is far from the tie. It matters the moment anything cuts inside a tie block: a per-lane top-5, a "ten best to confirm the fix", a diff of this run's list against last night's. That diff is the painful one — the lists differ, nothing changed, and someone spends an afternoon on it. Chapter 17.8 made exactly this a rule for whole reports, and a top-N list is a report.

Add a second key and the comparison never returns 0:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
sort { $slack{$a} <=> $slack{$b} or $a cmp $b } keys %slack

Measured: one ordering in five processes. The tie-breaker does not have to be meaningful. It has to be total.

5. The Two Views Answer Different Questions

With the defects fixed, the useful finding is that the two views do not say the same thing about the same eighty numbers:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
top-20 says    : worst -0.2580 at sf3_15/D; 6 of the 20 are violating,
                 so 14 rows describe endpoints that already pass
histogram says : 6 violating, 1 more within 0.05 of failing,
                 and 65 of 80 endpoints are above the clip
top-20 by lane : lane 0 = 5, lane 1 = 5, lane 2 = 5, lane 3 = 5

A fixed N is the wrong size almost always. Here it spent 14 of its 20 rows on endpoints nobody needs to look at. On a block with 300 violations it would show 20 of them and hide the rest. What a reviewer actually wants is every violation plus a few of the nearest passes — grep { $_ < 0 } first, then top up to a floor of five or ten. The count is the output, not the input.

The distribution adds the thing a worst-slack number cannot: how many are nearly failing. One endpoint sits within 0.05 of zero. If a fix moves the whole block by 0.1 that endpoint joins the violations, and the worst-slack number never hinted at it.

And the shape of the top-20 is a third finding neither view was built for. Five endpoints per lane, all four lanes, every one a sum flop — so this is not one bad lane and not one bad cell. It is the carry chain, in all four copies of it. One number cannot say that; a list of twenty names says it at a glance.

So print both, and print the distribution clipped:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rows needed: full-range histogram 59, clipped 15, top-20 list 20

The full range is 59 bucket rows for a block whose entire problem lives in seven of them. Clipping at a threshold and adding one and above overflow row gives 15 rows that carry the same information — and the overflow row keeps the total honest. That is the rule worth carrying out of this module: a reduced report says what it reduced, so the total in the last line still equals the number of endpoints the tool reported.

6. What To Remember

  • Two views make a large timing report reviewable: the worst N endpoints, and a bucketed distribution.
  • Sort slack with <=>, never cmp — a string sort puts -1.5 after -0.2.
  • Measured: a list slice EXTENDS a short list. [0 .. 19] over 12 elements returned 20 values, 8 undefined — it does not truncate.
  • Clamp the upper index and print the real count in the heading (top 12 of 12).
  • int truncates toward zero, so int($s / $w) makes the bucket at zero DOUBLE WIDTH and straddle the sign.
  • Measured: that bucket held a VIOLATED endpoint and a MET endpoint in the same row — the one row a reviewer reads hardest.
  • Use POSIX::floor for any signed quantity you bucket; negative bucket indices are real and useful.
  • Measured: a tie at the cut gave 5 distinct orderings in 5 processes, because keys order is per-process.
  • Add a total tie-breaker — or $a cmp $b — and it was 1 ordering in 5. It need not be meaningful, only total.
  • A fixed N is almost always the wrong size — 14 of 20 rows described endpoints that already pass.
  • Select by verdict, then top up: every violation plus a few nearest passes, so the count is an output.
  • The distribution says how many are NEARLY failing — one endpoint within 0.05 of zero, invisible in any worst-slack number.
  • The shape of the list is a finding too: 5 per lane across all four lanes, every one a sum flop, names the carry chain.
  • Clip the histogram and keep an overflow row — 15 rows instead of 59, and the total still adds up to the endpoint count.

That closes Module 23. The module started with the one block a timing report is made of and ended with two views of a whole block's worth of them, and the pattern in between was the same every time: the number the tool prints is reliable, and the number you derive from it is only as good as the one edge case you did not try. Module 24 turns to the output side — coverage and regression reporting — where the inputs are no longer one tool's report but hundreds of runs, and the question becomes how to aggregate them without losing the detail that explains a failure.

Where this fits

Part of the Perl curriculum.