Skip to content
VLSI Mentor

Python Part II · Module 35 · Industry VLSI Automation Projects

A Synthesis QoR Comparator

The last tool in the module is the one a reviewer reads. Two synthesis runs, a list of what moved, and a verdict — which sounds like subtraction and is not, because the metrics disagree about which direction is good, one of them cannot be expressed as a percentage at all, and the most dangerous change a design can undergo makes every single number improve.

Advanced26 min readPythonPart IIAutomationSynthesisQoR

Part II · Module 35 · Chapter 35.9 · A Synthesis QoR Comparator

1. Two Runs, One Question

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
QUESTIONS = [
    ("did anything get worse?", "a reviewer", "block the merge"),
    ("did the thing I changed help?", "the author", "keep or revert"),
    ("is this ready for sign-off?", "a lead", "schedule"),
    ("what moved by how much?", "nobody", "it is a means, not an end"),
]

print(f"{'the question':<34} {'who asks':<12} what they do with it")
for q, who, action in QUESTIONS:
    print(f"{q:<34} {who:<12} {action}")

print()
print("questions that need a verdict :", 3)
print("questions that need a table    :", 1)
print("so the output is              :", "a verdict, with the table under it")
print("and a tool that only tabulates:", "leaves the hard part to a human")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the question                       who asks     what they do with it
did anything get worse?            a reviewer   block the merge
did the thing I changed help?      the author   keep or revert
is this ready for sign-off?        a lead       schedule
what moved by how much?            nobody       it is a means, not an end

questions that need a verdict : 3
questions that need a table    : 1
so the output is              : a verdict, with the table under it
and a tool that only tabulates: leaves the hard part to a human

Three of four questions want a verdict and the fourth is nobody's. A comparator that produces a table of percentages has done the easy part and left the reviewer to decide which of twelve numbers matter and which way is good — which is exactly where review mistakes happen.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
PARTS = [
    ("--before run_a/", "the reference run's directory", "a whole run"),
    ("--after run_b/", "the candidate run's directory", "a whole run"),
]

print(f"{'part':<20} {'meaning':<34} note")
for part, meaning, note in PARTS:
    print(f"{part:<20} {meaning:<34} {note}")

print()
READS = [
    ("qor.rpt", "area, leakage, instances", "the QoR numbers"),
    ("timing.rpt", "WNS, TNS", "35.2 already parses this"),
    ("hierarchy.rpt", "the module list", "the structural check — §7"),
]
print(f"{'file in each run dir':<18} {'what it gives':<28} why")
for f, gives, why in READS:
    print(f"{f:<18} {gives:<28} {why}")

print()
print("it takes DIRECTORIES, not files:", True)
print("because a QoR comparison needs  :", "more than the QoR report — §7")
print("and the third file is the one   :", "that catches the worst change")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
part                 meaning                            note
--before run_a/      the reference run's directory      a whole run
--after run_b/       the candidate run's directory      a whole run

file in each run dir what it gives                why
qor.rpt            area, leakage, instances     the QoR numbers
timing.rpt         WNS, TNS                     35.2 already parses this
hierarchy.rpt      the module list              the structural check — §7

it takes DIRECTORIES, not files: True
because a QoR comparison needs  : more than the QoR report — §7
and the third file is the one   : that catches the worst change

The annotation takes directories rather than report files, and that is the design decision the chapter turns on. A comparator pointed at two qor.rpt files can only compare QoR, and Section 7 shows that QoR alone cannot detect the most serious thing that can happen between two runs.

3. The First Version

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re

BEFORE = """\
Report : qor
Design : axi_fifo
  Total cell area           18432.0
  Total leakage power         412.0
  Number of instances        24110
  Critical path slack        -0.173
"""
AFTER = BEFORE.replace("18432.0", "16998.0") \
              .replace("412.0", "381.0") \
              .replace("24110", "21004") \
              .replace("-0.173", "0.042")

ROW = re.compile(r"^\s{2}(.+?)\s{2,}(-?\d+\.?\d*)\s*$", re.M)


def parse(text):
    return {m.group(1).strip(): float(m.group(2)) for m in ROW.finditer(text)}


before, after = parse(BEFORE), parse(AFTER)

print("v1 — subtract and print the percentage")
print(f"  {'metric':<26} {'before':>10} {'after':>10} {'change':>10}")
for key in before:
    b, a = before[key], after[key]
    pct = (a - b) / abs(b) * 100 if b else float("nan")
    print(f"  {key:<26} {b:>10.3f} {a:>10.3f} {pct:>+9.2f}%")

print()
print("metrics compared:", len(before))
print("what the table says:", "four numbers changed")
print("what it does not say:", ["which way is good", "which changes matter",
                                "what the verdict is"])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
v1 — subtract and print the percentage
  metric                         before      after     change
  Total cell area             18432.000  16998.000     -7.78%
  Total leakage power           412.000    381.000     -7.52%
  Number of instances         24110.000  21004.000    -12.88%
  Critical path slack            -0.173      0.042   +124.28%

metrics compared: 4
what the table says: four numbers changed
what it does not say: ['which way is good', 'which changes matter', 'what the verdict is']

Three of the four numbers are informative and the fourth is nonsense. +124.28% for a slack that went from failing to passing is the subject of Section 5; the missing direction column is Section 4.

4. The Limitation: Direction Is Per Metric

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
METRICS = [
    # name, before, after, +1 if higher is better
    ("area_um2", 18432.0, 16998.0, -1),
    ("leakage_uw", 412.0, 381.0, -1),
    ("instances", 24110.0, 21004.0, -1),
    ("max_fanout", 64.0, 48.0, -1),
    ("wns_ns", -0.173, 0.042, +1),
    ("tns_ns", -2.410, 0.000, +1),
]

print(f"{'metric':<14} {'before':>10} {'after':>10} {'change':>10} "
      f"{'wants':>7}  better?")
for name, b, a, direction in METRICS:
    pct = (a - b) / abs(b) * 100 if b else float("nan")
    better = (a > b) if direction > 0 else (a < b)
    wants = "higher" if direction > 0 else "lower"
    print(f"{name:<14} {b:>10.3f} {a:>10.3f} {pct:>+9.2f}% {wants:>7}  "
          f"{better}")

lower = sum(1 for *_, d in METRICS if d < 0)
print()
print("metrics that want a LOWER value :", lower)
print("metrics that want a HIGHER value:", len(METRICS) - lower)
print()
print("so '-7.78%' is good for area     :", True)
print("and would be bad for wns         :", True)
print("the column a percentage table    ")
print("  cannot supply                  :", "which way is good")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
metric             before      after     change   wants  better?
area_um2        18432.000  16998.000     -7.78%   lower  True
leakage_uw        412.000    381.000     -7.52%   lower  True
instances       24110.000  21004.000    -12.88%   lower  True
max_fanout         64.000     48.000    -25.00%   lower  True
wns_ns             -0.173      0.042   +124.28%  higher  True
tns_ns             -2.410      0.000   +100.00%  higher  True

metrics that want a LOWER value : 4
metrics that want a HIGHER value: 2

so '-7.78%' is good for area     : True
and would be bad for wns         : True
the column a percentage table    
  cannot supply                  : which way is good

Four metrics want a lower number and two want a higher one, so a sign in the change column carries no information on its own. The better? column is the one a reviewer actually reads, and it can only be computed if the direction is declared per metric in a table the tool owns.

That is the same move Chapter 35.5 made in storing counts rather than percentages: the raw fact is the pair of values, and every interpretation — direction, significance, verdict — is derived from a declaration the tool carries rather than from the number's shape.

5. A Percentage Is Meaningless Across Zero

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CASES = [
    ("area improves", 18432.0, 16998.0),
    ("wns, fail to pass", -0.173, 0.042),
    ("wns, worse but still passing", 0.210, 0.042),
    ("wns, pass to fail", 0.042, -0.173),
    ("tns reaches zero", -2.410, 0.000),
    ("tns from zero", 0.000, -0.310),
]

print(f"{'case':<30} {'before':>9} {'after':>9} {'percent':>12} "
      f"{'absolute':>10}")
for label, b, a in CASES:
    pct = f"{(a - b) / abs(b) * 100:+.2f}%" if b else "undefined"
    print(f"{label:<30} {b:>9.3f} {a:>9.3f} {pct:>12} {a - b:>+10.3f}")

print()
print("cases where the percentage is undefined:", 1)
print("cases where it crosses zero            :", 3)
print("cases where it is interpretable        :", 2)
print()
print("wns -0.173 -> +0.042 as a percentage:", "+124.28%")
print("what that would have to mean        :", "124% of 0.173 ns")
print("what actually happened              :", "it went from FAILING to PASSING")
print("the honest number                   :", "+0.215 ns")
print()
print("so slack is reported as             :", "an absolute delta, in ns")
print("and area as                         :", "a percentage")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case                              before     after      percent   absolute
area improves                  18432.000 16998.000       -7.78%  -1434.000
wns, fail to pass                 -0.173     0.042     +124.28%     +0.215
wns, worse but still passing       0.210     0.042      -80.00%     -0.168
wns, pass to fail                  0.042    -0.173     -511.90%     -0.215
tns reaches zero                  -2.410     0.000     +100.00%     +2.410
tns from zero                      0.000    -0.310    undefined     -0.310

cases where the percentage is undefined: 1
cases where it crosses zero            : 3
cases where it is interpretable        : 2

wns -0.173 -> +0.042 as a percentage: +124.28%
what that would have to mean        : 124% of 0.173 ns
what actually happened              : it went from FAILING to PASSING
the honest number                   : +0.215 ns

so slack is reported as             : an absolute delta, in ns
and area as                         : a percentage

Four of six percentages are undefined or uninterpretable. The last row divides by zero; three others cross zero, and a percentage change across zero has no meaning at all — -511.90% for a design that went from 42 ps of margin to 173 ps of violation is a number that cannot be compared with anything.

The absolute column is right in every row. +0.215 ns and -0.215 ns are the same magnitude in opposite directions, which is what a reviewer needs, and they stay meaningful whether or not the sign flips.

So the rule is per metric again: a metric with a natural zero and a fixed sign takes a percentage; a signed metric takes an absolute delta. Area, leakage and instance counts are always positive and a 7.78% reduction is a sentence anybody can act on. Slack is signed, and 215 picoseconds is the only honest way to say what changed.

6. The Architecture

Parse three reports, declare each metric, then decide

Six blocks. Top row, left to right: the two run directories; a parser reading the QoR report, the timing report and the hierarchy report from each; and a metric declaration table giving each metric its direction, unit, change style and noise threshold. An arrow then runs downward from the declaration to the bottom row. Bottom row, right to left: a change computer producing a signed delta and a better-or-worse judgement per metric; a structural comparison of the two module lists; and the verdict stage, which combines them into one of better, worse, mixed or suspect.Two run dirsqor · timing · hierarchyParse all three35.2 reused for timingMetric declarationsdirection · unit · noiseCompute changesbetter? significant? — §4§8Compare the hierarchymissing modules — §7Verdictbetter · worse · mixed ·SUSPECTvalues12
The metric declaration — direction, unit, how to express a change and what counts as noise — is a table the tool owns, not something inferred from the numbers. The structural comparison is a separate input path because Section 7 shows no QoR metric can substitute for it.

Note that the verdict has four values and the fourth is SUSPECT. Better, worse and mixed are all statements about QoR; SUSPECT is the structural comparison overriding them, and Section 7 is why it has to be able to.

7. A Design That Lost Logic Improves Everywhere

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
METRICS = [
    ("area_um2", 18432.0, 12100.0, -1),
    ("leakage_uw", 412.0, 260.0, -1),
    ("instances", 24110.0, 14880.0, -1),
    ("wns_ns", -0.173, 0.880, +1),
    ("tns_ns", -2.410, 0.000, +1),
]

print(f"{'metric':<14} {'before':>10} {'after':>10} {'change':>10}  better?")
improved = 0
for name, b, a, d in METRICS:
    better = (a > b) if d > 0 else (a < b)
    improved += better
    pct = (a - b) / abs(b) * 100 if b else float("nan")
    print(f"{name:<14} {b:>10.3f} {a:>10.3f} {pct:>+9.2f}%  {better}")

print()
print("metrics improved         :", improved, "of", len(METRICS))
print("metrics that got worse   :", len(METRICS) - improved)
print("the QoR verdict would be :", "a large improvement")
print()
BEFORE_MODULES = {"axi_fifo", "fifo_ctrl", "fifo_mem", "axi_slave_if", "crc8"}
AFTER_MODULES = {"axi_fifo", "fifo_ctrl", "fifo_mem", "axi_slave_if"}
print("modules in the before netlist:", len(BEFORE_MODULES))
print("modules in the after netlist :", len(AFTER_MODULES))
print("missing                      :", sorted(BEFORE_MODULES - AFTER_MODULES))
print("appeared                     :", sorted(AFTER_MODULES - BEFORE_MODULES))
print()
print("the cause of every 'improvement':", "a deleted block")
print("QoR metrics that detect it      :", 0)
print("the only detector               :", "the module list")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
metric             before      after     change  better?
area_um2        18432.000  12100.000    -34.35%  True
leakage_uw        412.000    260.000    -36.89%  True
instances       24110.000  14880.000    -38.28%  True
wns_ns             -0.173      0.880   +608.67%  True
tns_ns             -2.410      0.000   +100.00%  True

metrics improved         : 5 of 5
metrics that got worse   : 0
the QoR verdict would be : a large improvement

modules in the before netlist: 5
modules in the after netlist : 4
missing                      : ['crc8']
appeared                     : []

the cause of every 'improvement': a deleted block
QoR metrics that detect it      : 0
the only detector               : the module list

Five of five metrics improved and the design lost a block. Area down 34%, leakage down 37%, instances down 38%, timing from failing to 880 ps of margin — every number moves the way everybody wants, and the reason is that crc8 is not in the netlist any more.

This is the module's thesis arriving one last time, and in its purest form. Every chapter here has found a report that was confidently wrong; this one is a report that is confidently wrong in the direction people want to believe, which is the hardest kind to catch. A reviewer looking at that table has no reason to doubt it, and the author has every reason not to.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
BEFORE = {"axi_fifo": 1, "fifo_ctrl": 1, "fifo_mem": 4, "axi_slave_if": 1,
          "crc8": 2}
AFTER = {"axi_fifo": 1, "fifo_ctrl": 1, "fifo_mem": 4, "axi_slave_if": 1}
AFTER_RENAMED = {"axi_fifo": 1, "fifo_ctrl": 1, "fifo_mem": 4,
                 "axi_slave_if": 1, "crc8_v2": 2}
AFTER_FEWER = {"axi_fifo": 1, "fifo_ctrl": 1, "fifo_mem": 2,
               "axi_slave_if": 1, "crc8": 2}


def compare_hierarchy(before, after):
    missing = sorted(set(before) - set(after))
    added = sorted(set(after) - set(before))
    changed = sorted(m for m in set(before) & set(after)
                     if before[m] != after[m])
    return {"missing": missing, "added": added, "count_changed": changed}


CASES = [("a module vanished", AFTER),
         ("a module was renamed", AFTER_RENAMED),
         ("fewer instances of one module", AFTER_FEWER)]

print(f"{'case':<32} {'missing':<12} {'added':<12} instance count changed")
for label, after in CASES:
    r = compare_hierarchy(BEFORE, after)
    print(f"{label:<32} {str(r['missing']):<12} {str(r['added']):<12} "
          f"{r['count_changed']}")

print()
print("all three are structural changes:", True)
print("all three are invisible in QoR  :", True)
print("the rename looks like           :", "one missing and one added")
print("and that pairing is             :", "exactly what a reviewer must see")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
case                             missing      added        instance count changed
a module vanished                ['crc8']     []           []
a module was renamed             ['crc8']     ['crc8_v2']  []
fewer instances of one module    []           []           ['fifo_mem']

all three are structural changes: True
all three are invisible in QoR  : True
the rename looks like           : one missing and one added
and that pairing is             : exactly what a reviewer must see

Three structural changes, three different signatures, none visible in QoR. The third is the subtlest: every module is still present and fifo_mem is instantiated twice instead of four times, so a check that only compares sets of module names misses it entirely. Comparing instance counts catches it.

The rename case is reported as one missing and one added rather than as a rename, which is the right behaviour: the tool cannot know that crc8_v2 is the same logic as crc8, and guessing would let a genuine deletion hide behind an unrelated addition.

8. Noise Is Per Metric Too

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
DECLARATIONS = [
    ("area_um2", "um2", -1, "percent", 0.5),
    ("leakage_uw", "uW", -1, "percent", 2.0),
    ("instances", "cells", -1, "percent", 0.5),
    ("wns_ns", "ns", +1, "absolute", 0.005),
    ("tns_ns", "ns", +1, "absolute", 0.010),
]

OBSERVED = [("area_um2", 18432.0, 18487.0), ("leakage_uw", 412.0, 416.5),
            ("instances", 24110.0, 24118.0), ("wns_ns", 0.210, 0.179),
            ("tns_ns", 0.000, 0.000)]

print(f"{'metric':<12} {'style':<9} {'change':>12} {'threshold':>10}  signal?")
for name, b, a in OBSERVED:
    decl = next(d for d in DECLARATIONS if d[0] == name)
    _, unit, direction, style, noise = decl
    if style == "percent":
        change = (a - b) / abs(b) * 100 if b else 0.0
        shown = f"{change:+.3f}%"
        significant = abs(change) > noise
    else:
        change = a - b
        shown = f"{change:+.3f} {unit}"
        significant = abs(change) > noise
    print(f"{name:<12} {style:<9} {shown:>12} {noise:>10.3f}  {significant}")

print()
print("metrics observed      :", len(OBSERVED))
print("changes above noise   :",
      sum(1 for name, b, a in OBSERVED
          for d in [next(x for x in DECLARATIONS if x[0] == name)]
          if (abs((a - b) / abs(b) * 100) > d[4] if d[3] == "percent" and b
              else abs(a - b) > d[4])))
print()
print("0.298% of area is     :", "noise")
print("0.031 ns of WNS is    :", "signal")
print("a single global 1%    ")
print("  threshold would     :", "hide the WNS change and report the area one")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
metric       style           change  threshold  signal?
area_um2     percent        +0.298%      0.500  False
leakage_uw   percent        +1.092%      2.000  False
instances    percent        +0.033%      0.500  False
wns_ns       absolute     -0.031 ns      0.005  True
tns_ns       absolute     +0.000 ns      0.010  False

metrics observed      : 5
changes above noise   : 1

0.298% of area is     : noise
0.031 ns of WNS is    : signal
a single global 1%    
  threshold would     : hide the WNS change and report the area one

One of five changes is signal, and a global threshold would have picked the wrong one. A single 1% rule reports the leakage move as significant and the WNS move as noise, which is backwards: 31 picoseconds off a 210 ps margin is the one change in that table a reviewer needs to see.

The thresholds differ because the metrics differ in how reproducible they are. Leakage estimates move by a per cent between runs of the same netlist with a different tool version; a WNS number from a fixed netlist and fixed constraints is stable to a few picoseconds. The noise floor is a property of the measurement, not a policy, and Chapter 29.2 Section 7's rule applies: it has to be written down.

9. The Verdict

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
from dataclasses import dataclass


@dataclass(frozen=True)
class Change:
    metric: str
    before: float
    after: float
    shown: str
    better: bool
    significant: bool


def verdict(changes, structural):
    """Four outcomes. SUSPECT overrides everything."""
    if structural["missing"] or structural["count_changed"]:
        return "SUSPECT"
    moved = [c for c in changes if c.significant]
    if not moved:
        return "NO CHANGE"
    better = [c for c in moved if c.better]
    worse = [c for c in moved if not c.better]
    if worse and better:
        return "MIXED"
    return "BETTER" if better else "WORSE"


CLEAN = {"missing": [], "added": [], "count_changed": []}
LOST = {"missing": ["crc8"], "added": [], "count_changed": []}


def C(name, better, significant):
    return Change(name, 0.0, 0.0, "", better, significant)


SCENARIOS = [
    ("nothing moved", [C("area", True, False), C("wns", False, False)], CLEAN),
    ("area down, timing held", [C("area", True, True),
                                C("wns", True, False)], CLEAN),
    ("area down, timing worse", [C("area", True, True),
                                 C("wns", False, True)], CLEAN),
    ("everything worse", [C("area", False, True),
                          C("wns", False, True)], CLEAN),
    ("everything better, a module gone", [C("area", True, True),
                                          C("wns", True, True)], LOST),
]

print(f"{'scenario':<36} verdict")
for label, changes, structural in SCENARIOS:
    print(f"{label:<36} {verdict(changes, structural)}")

print()
print("verdicts used    :", 5)
print("MIXED is a real  ")
print("  verdict        :", "not a failure to decide")
print("and SUSPECT       ")
print("  overrides       :", "a table where every metric improved")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
scenario                             verdict
nothing moved                        NO CHANGE
area down, timing held               BETTER
area down, timing worse              MIXED
everything worse                     WORSE
everything better, a module gone     SUSPECT

verdicts used    : 5
MIXED is a real  
  verdict        : not a failure to decide
and SUSPECT       
  overrides       : a table where every metric improved

Five scenarios, five distinct verdicts, and the last one overrides a table in which every metric improved. MIXED is the one worth defending: a comparator that collapses "area down 8%, timing worse by 30 ps" into a single better-or- worse answer has thrown away the trade-off that is the entire content of the comparison.

NO CHANGE is also a real verdict rather than an absence of one. A run whose every move is below its noise floor is a run that confirms a change was neutral, which is often exactly what the author wanted to know.

10. The Comparator, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import re
from dataclasses import dataclass
from pathlib import Path

EXIT_OK, EXIT_WORSE, EXIT_SUSPECT = 0, 1, 3


@dataclass(frozen=True)
class Metric:
    name: str
    unit: str
    direction: int          # +1 higher is better, -1 lower is better
    style: str              # "percent" or "absolute"
    noise: float


METRICS = {
    "area_um2": Metric("area_um2", "um2", -1, "percent", 0.5),
    "leakage_uw": Metric("leakage_uw", "uW", -1, "percent", 2.0),
    "instances": Metric("instances", "cells", -1, "percent", 0.5),
    "wns_ns": Metric("wns_ns", "ns", +1, "absolute", 0.005),
    "tns_ns": Metric("tns_ns", "ns", +1, "absolute", 0.010),
}

LABELS = {"Total cell area": "area_um2",
          "Total leakage power": "leakage_uw",
          "Number of instances": "instances",
          "Worst negative slack": "wns_ns",
          "Total negative slack": "tns_ns"}

ROW = re.compile(r"^\s{2}(.+?)\s{2,}(-?\d+\.?\d*)\s*$", re.M)
HIER = re.compile(r"^\s{2}(\S+)\s+(\d+)\s*$", re.M)


@dataclass(frozen=True)
class Change:
    metric: str
    before: float
    after: float
    shown: str
    better: bool
    significant: bool


def parse_values(text):
    out = {}
    for m in ROW.finditer(text):
        key = LABELS.get(m.group(1).strip())
        if key:
            out[key] = float(m.group(2))
    return out


def parse_hierarchy(text):
    return {m.group(1): int(m.group(2)) for m in HIER.finditer(text)}


def compute(before, after):
    changes = []
    for name in sorted(set(before) & set(after)):
        decl = METRICS[name]
        b, a = before[name], after[name]
        if decl.style == "percent" and b:
            value = (a - b) / abs(b) * 100
            shown = f"{value:+.2f}%"
        else:
            value = a - b
            shown = f"{value:+.3f} {decl.unit}"
        better = (a > b) if decl.direction > 0 else (a < b)
        changes.append(Change(name, b, a, shown, better,
                              abs(value) > decl.noise))
    return changes


def compare_hierarchy(before, after):
    return {"missing": sorted(set(before) - set(after)),
            "added": sorted(set(after) - set(before)),
            "count_changed": sorted(m for m in set(before) & set(after)
                                    if before[m] != after[m])}


def verdict(changes, structural):
    if structural["missing"] or structural["count_changed"]:
        return "SUSPECT"
    moved = [c for c in changes if c.significant]
    if not moved:
        return "NO CHANGE"
    better = [c for c in moved if c.better]
    worse = [c for c in moved if not c.better]
    if better and worse:
        return "MIXED"
    return "BETTER" if better else "WORSE"


def report(changes, structural, v):
    lines = [f"verdict: {v}", ""]
    for c in changes:
        mark = ("  " if not c.significant else
                "+ " if c.better else "- ")
        note = "" if c.significant else "   (within noise)"
        lines.append(f"{mark}{c.metric:<12} {c.before:>10.3f} -> "
                     f"{c.after:>10.3f}  {c.shown:>12}{note}")
    if structural["missing"] or structural["added"] \
       or structural["count_changed"]:
        lines.append("")
        lines.append("structural differences:")
        for kind in ("missing", "added", "count_changed"):
            if structural[kind]:
                lines.append(f"  {kind:<14} {structural[kind]}")
    code = {"SUSPECT": EXIT_SUSPECT, "WORSE": EXIT_WORSE,
            "MIXED": EXIT_WORSE}.get(v, EXIT_OK)
    return "\n".join(lines), code


def write_run(path, area, leak, inst, wns, tns, modules):
    d = Path(path)
    d.mkdir(parents=True, exist_ok=True)
    (d / "qor.rpt").write_text(
        f"Report : qor\n  Total cell area           {area}\n"
        f"  Total leakage power         {leak}\n"
        f"  Number of instances        {inst}\n", encoding="utf-8")
    (d / "timing.rpt").write_text(
        f"Report : timing\n  Worst negative slack       {wns}\n"
        f"  Total negative slack       {tns}\n", encoding="utf-8")
    (d / "hierarchy.rpt").write_text(
        "Report : hierarchy\n"
        + "".join(f"  {m} {n}\n" for m, n in sorted(modules.items())),
        encoding="utf-8")


def load(path):
    d = Path(path)
    values = parse_values((d / "qor.rpt").read_text(encoding="utf-8"))
    values.update(parse_values((d / "timing.rpt").read_text(encoding="utf-8")))
    hier = parse_hierarchy((d / "hierarchy.rpt").read_text(encoding="utf-8"))
    return values, hier


MODULES = {"axi_fifo": 1, "fifo_ctrl": 1, "fifo_mem": 4, "axi_slave_if": 1,
           "crc8": 2}

write_run("run_a", 18432.0, 412.0, 24110, -0.173, -2.410, MODULES)
write_run("run_b", 16998.0, 381.0, 21004, 0.042, 0.000, MODULES)
write_run("run_c", 12100.0, 260.0, 14880, 0.880, 0.000,
          {k: v for k, v in MODULES.items() if k != "crc8"})

for label, after_dir in (("run_a -> run_b", "run_b"),
                         ("run_a -> run_c", "run_c")):
    before_v, before_h = load("run_a")
    after_v, after_h = load(after_dir)
    changes = compute(before_v, after_v)
    structural = compare_hierarchy(before_h, after_h)
    v = verdict(changes, structural)
    text, code = report(changes, structural, v)
    print(f"=== {label}")
    for line in text.splitlines():
        print("   ", line)
    print("    exit:", code)
    print()

print("metrics declared :", len(METRICS))
print("verdicts possible:", 5)
print("the SUSPECT case  ")
print("  exits           :", EXIT_SUSPECT)
print("and 33.8 reserved 3 for:", "an input problem — a netlist is an input")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== run_a -> run_b
    verdict: BETTER
    
    + area_um2      18432.000 ->  16998.000        -7.78%
    + instances     24110.000 ->  21004.000       -12.88%
    + leakage_uw      412.000 ->    381.000        -7.52%
    + tns_ns           -2.410 ->      0.000     +2.410 ns
    + wns_ns           -0.173 ->      0.042     +0.215 ns
    exit: 0

=== run_a -> run_c
    verdict: SUSPECT
    
    + area_um2      18432.000 ->  12100.000       -34.35%
    + instances     24110.000 ->  14880.000       -38.28%
    + leakage_uw      412.000 ->    260.000       -36.89%
    + tns_ns           -2.410 ->      0.000     +2.410 ns
    + wns_ns           -0.173 ->      0.880     +1.053 ns
    
    structural differences:
      missing        ['crc8']
    exit: 3

metrics declared : 5
verdicts possible: 5
the SUSPECT case  
  exits           : 3
and 33.8 reserved 3 for: an input problem — a netlist is an input

Two comparisons with nearly identical tables and opposite verdicts. Both show every metric improving; the second says SUSPECT and exits 3, and the only difference between them is one line in hierarchy.rpt.

That is the clearest statement of what this module has been about. The numbers are the same shape, both reports are internally consistent, and the one piece of information that distinguishes a real improvement from a deleted block is in a file the obvious tool would never have opened.

Note the exit code: 3, which Chapter 33.8 reserved for an input problem. A netlist missing a module is an input problem — the synthesis run was given something other than the design — and giving it the same code as WORSE would tell a CI job to treat a vanished block as a timing regression.

11. How It Is Tested

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import io
import unittest
from dataclasses import dataclass


@dataclass(frozen=True)
class Metric:
    name: str
    unit: str
    direction: int
    style: str
    noise: float


METRICS = {
    "area_um2": Metric("area_um2", "um2", -1, "percent", 0.5),
    "wns_ns": Metric("wns_ns", "ns", +1, "absolute", 0.005),
}


def change_of(name, before, after):
    d = METRICS[name]
    if d.style == "percent" and before:
        value = (after - before) / abs(before) * 100
    else:
        value = after - before
    better = (after > before) if d.direction > 0 else (after < before)
    return value, better, abs(value) > d.noise


def compare_hierarchy(before, after):
    return {"missing": sorted(set(before) - set(after)),
            "added": sorted(set(after) - set(before)),
            "count_changed": sorted(m for m in set(before) & set(after)
                                    if before[m] != after[m])}


def verdict(flags, structural):
    """flags: list of (better, significant)."""
    if structural["missing"] or structural["count_changed"]:
        return "SUSPECT"
    moved = [(b, s) for b, s in flags if s]
    if not moved:
        return "NO CHANGE"
    better = [b for b, _ in moved if b]
    worse = [b for b, _ in moved if not b]
    if better and worse:
        return "MIXED"
    return "BETTER" if better else "WORSE"


CLEAN = {"missing": [], "added": [], "count_changed": []}


class Comparator(unittest.TestCase):

    # --- direction is per metric
    def test_area_down_is_better(self):
        _, better, _ = change_of("area_um2", 18432.0, 16998.0)
        self.assertTrue(better)

    def test_area_up_is_worse(self):
        _, better, _ = change_of("area_um2", 16998.0, 18432.0)
        self.assertFalse(better)

    def test_slack_up_is_better(self):
        _, better, _ = change_of("wns_ns", -0.173, 0.042)
        self.assertTrue(better)

    def test_slack_down_is_worse(self):
        _, better, _ = change_of("wns_ns", 0.042, -0.173)
        self.assertFalse(better)

    # --- percentage vs absolute
    def test_slack_uses_an_absolute_delta(self):
        value, _, _ = change_of("wns_ns", -0.173, 0.042)
        self.assertAlmostEqual(value, 0.215, places=3)

    def test_slack_is_not_a_percentage(self):
        """§5: the percentage would be +124.28, which is meaningless."""
        value, _, _ = change_of("wns_ns", -0.173, 0.042)
        self.assertNotAlmostEqual(value, 124.28, places=1)

    def test_a_zero_baseline_does_not_divide(self):
        value, _, _ = change_of("wns_ns", 0.0, -0.310)
        self.assertAlmostEqual(value, -0.310, places=3)

    # --- noise
    def test_a_small_area_move_is_noise(self):
        _, _, sig = change_of("area_um2", 18432.0, 18487.0)
        self.assertFalse(sig)

    def test_a_small_slack_move_is_signal(self):
        _, _, sig = change_of("wns_ns", 0.210, 0.179)
        self.assertTrue(sig)

    # --- structural
    def test_a_vanished_module_is_detected(self):
        got = compare_hierarchy({"a": 1, "crc8": 2}, {"a": 1})
        self.assertEqual(got["missing"], ["crc8"])

    def test_a_renamed_module_is_one_missing_and_one_added(self):
        got = compare_hierarchy({"crc8": 2}, {"crc8_v2": 2})
        self.assertEqual((got["missing"], got["added"]),
                         (["crc8"], ["crc8_v2"]))

    def test_fewer_instances_is_detected(self):
        got = compare_hierarchy({"fifo_mem": 4}, {"fifo_mem": 2})
        self.assertEqual(got["count_changed"], ["fifo_mem"])

    def test_identical_hierarchies_are_clean(self):
        got = compare_hierarchy({"a": 1}, {"a": 1})
        self.assertEqual(got, CLEAN)

    # --- the verdict
    def test_all_better_is_better(self):
        self.assertEqual(verdict([(True, True)], CLEAN), "BETTER")

    def test_all_worse_is_worse(self):
        self.assertEqual(verdict([(False, True)], CLEAN), "WORSE")

    def test_both_is_mixed(self):
        self.assertEqual(verdict([(True, True), (False, True)], CLEAN),
                         "MIXED")

    def test_nothing_significant_is_no_change(self):
        self.assertEqual(verdict([(True, False), (False, False)], CLEAN),
                         "NO CHANGE")

    def test_a_missing_module_overrides_everything_better(self):
        """§7: every metric improved and the design lost a block."""
        flags = [(True, True), (True, True), (True, True)]
        self.assertEqual(verdict(flags, {"missing": ["crc8"], "added": [],
                                         "count_changed": []}), "SUSPECT")


def run(case):
    suite = unittest.TestLoader().loadTestsFromTestCase(case)
    res = unittest.TextTestRunner(stream=io.StringIO(), verbosity=0).run(suite)
    return {"run": res.testsRun, "failures": len(res.failures),
            "ok": res.wasSuccessful()}


print("comparator suite:", run(Comparator))
print()
print("direction tests  :", 4)
print("expression tests :", 3)
print("noise tests      :", 2)
print("structural tests :", 4)
print("verdict tests    :", 5)
print()
print("the test that encodes the chapter:")
print("  test_a_missing_module_overrides_everything_better")
print("asserting that                   :",
      "three improvements do not outvote one deletion")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
comparator suite: {'run': 18, 'failures': 0, 'ok': True}

direction tests  : 4
expression tests : 3
noise tests      : 2
structural tests : 4
verdict tests    : 5

the test that encodes the chapter:
  test_a_missing_module_overrides_everything_better
asserting that                   : three improvements do not outvote one deletion

Eighteen behaviours in five groups, and no synthesis tool. Every decision in the comparator takes numbers as arguments, so the whole of it is Chapter 34.3's testable middle layer — which is what a comparison tool should be, since its inputs are reports rather than tools.

test_slack_is_not_a_percentage is Chapter 33.9's pattern once more: it asserts the specific wrong value 124.28 is absent, because a test checking only that the answer is 0.215 would pass under an implementation that happened to be right for this fixture and wrong in general.

test_a_missing_module_overrides_everything_better is the chapter in one assertion. Three significant improvements and one missing module must produce SUSPECT, and an implementation that counted votes would return BETTER.

12. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
# A
b, a = -0.173, 0.042
print("A:", round((a - b) / abs(b) * 100, 2), round(a - b, 3))

# B
print("B:", (a < b), (a > b))

# C
try:
    print("C:", (0.310 - 0.0) / abs(0.0) * 100)
except ZeroDivisionError:
    print("C:", "ZeroDivisionError", round(0.310 - 0.0, 3))

# D
before = {"a": 1, "crc8": 2}
after = {"a": 1}
print("D:", sorted(set(before) - set(after)),
      sorted(set(after) - set(before)))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 124.28 0.215
B: False True
C: ZeroDivisionError 0.31
D: ['crc8'] []

A — the same change expressed two ways: a meaningless 124.28% and an honest +0.215 ns. Section 5 in one line.

B — and the direction, which is what the percentage's sign fails to carry: the slack went up, which for slack is better, and for area would be worse.

C — a zero baseline raises rather than producing a number, which is why the style declaration exists: an absolute delta has no denominator and cannot fail this way.

D — the structural comparison is two set differences, and that is the whole mechanism that catches the module's most dangerous report. Four lines of code against five metrics that all agreed the design had improved.

13. Exercises

Exercise 1 — four questions. Ask your own QoR report which of them it answers.

Exercise 2 — a verdict, not a table. Write the one-line verdict your last comparison should have produced.

Exercise 3 — directories, not files. Say what a comparator pointed at two qor.rpt files cannot see.

Exercise 4 — your own v1. Subtract two reports and print percentages.

Exercise 5 — the nonsense cell. Find the value in your table that is not interpretable.

Exercise 6 — declare direction. Write the direction for every metric your flow reports.

Exercise 7 — the same sign, two meanings. Give an example from your own metrics.

Exercise 8 — across zero. Compute a percentage change for a slack that changed sign and say what it would mean.

Exercise 9 — the zero baseline. Find the metric in your set that can be exactly zero.

Exercise 10 — percent or absolute. Classify each of your metrics and justify the split.

Exercise 11 — five of five. Construct the lost-logic case for your own design and confirm every metric improves.

Exercise 12 — the four ordinary causes. For each, say what the RTL diff would look like.

Exercise 13 — compare the module lists. Add the structural check to your comparator.

Exercise 14 — instance counts too. Explain what a set-only comparison misses.

Exercise 15 — do not guess a rename. Say why reporting one missing and one added is better than inferring.

Exercise 16 — your noise floors. Measure each metric's run-to-run spread on an unchanged netlist.

Exercise 17 — a global threshold. Work out which of your changes it would misclassify.

Exercise 18 — noise is a measurement property. Explain why it is not a policy.

Exercise 19 — four verdicts. Implement BETTER, WORSE, MIXED and SUSPECT.

Exercise 20 — defend MIXED. Say what collapsing it loses.

Exercise 21 — NO CHANGE is a result. Describe when an author wants it.

Exercise 22 — SUSPECT overrides. Confirm it wins over a table of improvements.

Exercise 23 — the exit code. Decide what SUSPECT exits and why not 1.

Exercise 24 — assert the wrong value is absent. Add the assertNotAlmostEqual for your percentage trap.

Exercise 25 — three improvements, one deletion. Write the verdict test and say what a vote-counting implementation would return.

Exercise 26 — the whole module. Pick one of the nine tools in this module and apply Chapter 33's testing and Chapter 34's structure to it end to end.

14. Summary

  • Three of four questions a comparison must answer want a verdict, and a table of percentages leaves the hard part to the reviewer.
  • The annotation takes directories, not report files, because the most serious difference between two runs is not in the QoR report at all.
  • Direction is per metric: four of six want a lower number and two want a higher one, so the sign of a change carries no information on its own.
  • A percentage is meaningless across zero. WNS from −0.173 to +0.042 computes as +124.28%, and a design going from 42 ps of margin to 173 ps of violation computes as −511.90%.
  • Four of six percentage changes were undefined or uninterpretable, and the absolute delta was right in all six.
  • So the expression style is declared per metric: a positive metric with a natural zero takes a percentage, a signed metric takes an absolute delta.
  • A design that lost a module improved on all five metrics — area −34%, leakage −37%, instances −38%, timing from failing to 880 ps — and no QoR metric can detect it.
  • It is confidently wrong in the direction people want to believe, which is the hardest kind to catch, and the usual causes are ordinary: a tied-off input, a false generate, a renamed module, a commented-out line.
  • Compare module names and instance counts. Three structural changes have three different signatures, and a set-only comparison misses the one where every module is still present.
  • Report a rename as one missing and one added, because inferring the pairing would let a genuine deletion hide behind an unrelated addition.
  • Noise thresholds are per metric and are a property of the measurement. 0.298% of area is noise and 0.031 ns of WNS is signal; a global 1% rule gets both backwards.
  • Four verdicts, and MIXED is a real one — collapsing "area down 8%, timing worse by 30 ps" into better-or-worse discards the trade-off that is the comparison's whole content.
  • SUSPECT overrides every improvement and exits 3, because a netlist missing a module is an input problem rather than a regression.
  • Two comparisons with nearly identical tables and opposite verdicts, separated by one line in a file the obvious tool would never have opened.
  • Eighteen behaviours tested with no synthesis tool, because every decision takes numbers as arguments.

That closes Module 35. Nine tools, each built from a one-line version that was correct and unusable, and in every single case the limitation that forced the real design was the same shape: a number that was right about the wrong thing.

Where this fits

Part of the Python curriculum.