Skip to content

Commit 2f6e9c9

Browse files
authored
Merge pull request #4232 from oharboe/netlist-hash-diagnostic
Netlist hash diagnostic
2 parents 69ae727 + 31fdace commit 2f6e9c9

5 files changed

Lines changed: 141 additions & 30 deletions

File tree

flow/test/test_genElapsedTime.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,36 @@ def test_no_elapsed_time(self, fake_err_output):
7979
genElapsedTime.scan_logs(["--logDir", str(self.tmp_dir.name), "--noHeader"])
8080
self.assertIn("No elapsed time found in", fake_err_output.getvalue())
8181

82+
@patch("sys.stdout", new_callable=StringIO)
83+
def test_emits_one_row_per_result_extension(self, mock_stdout):
84+
# logs/.../1_2_yosys.log accompanied by both .v and .sdc result
85+
# files should produce two rows: .v with the elapsed/peak,
86+
# .sdc with empty elapsed/peak (the row sharing the stage).
87+
log_dir = os.path.join(self.tmp_dir.name, "logs", "p", "d", "base")
88+
res_dir = os.path.join(self.tmp_dir.name, "results", "p", "d", "base")
89+
os.makedirs(log_dir)
90+
os.makedirs(res_dir)
91+
log_path = os.path.join(log_dir, "1_2_yosys.log")
92+
with open(log_path, "w") as f:
93+
f.write("Elapsed time: 00:00:10[h:]min:sec. Peak memory: 51200KB.\n")
94+
with open(os.path.join(res_dir, "1_2_yosys.v"), "w") as f:
95+
f.write("module foo\nendmodule\n")
96+
with open(os.path.join(res_dir, "1_2_yosys.sdc"), "w") as f:
97+
f.write("create_clock -period 10\n")
98+
genElapsedTime.scan_logs(["--logDir", log_dir, "--noHeader"])
99+
out = mock_stdout.getvalue()
100+
lines = [l for l in out.splitlines() if "1_2_yosys" in l]
101+
self.assertEqual(len(lines), 2, out)
102+
self.assertIn(".v", lines[0])
103+
self.assertIn(".sdc", lines[1])
104+
# elapsed (10) and peak (50) show only on the .v row
105+
self.assertIn("10", lines[0])
106+
self.assertIn("50", lines[0])
107+
# the .sdc row repeats neither the elapsed (10) nor peak (50)
108+
# but does include its own hash; check absence of those tokens
109+
self.assertNotIn(" 10 ", " " + lines[1] + " ")
110+
self.assertNotIn(" 50 ", " " + lines[1] + " ")
111+
82112
def tearDown(self):
83113
self.tmp_dir.cleanup()
84114

flow/util/checkMetadata.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,15 @@ def try_number(string):
106106
PRE = "[INFO]"
107107
CHECK = "pass"
108108
elif rule.get("level") == "warning":
109+
# Warning-level rules never fail the build, but the prior
110+
# message ("[WARN] field pass test: a == b") was misleading
111+
# when a != b -- the build_value clearly differed from the
112+
# rule_value yet "pass" implied a match. Say "differs"
113+
# instead so the diagnostic reads naturally for fields like
114+
# the netlist hash where the user wants visibility without
115+
# an error.
109116
PRE = "[WARN]"
110-
CHECK = "pass"
117+
CHECK = "differs"
111118
WARNS += 1
112119
else:
113120
PRE = "[ERROR]"

flow/util/genElapsedTime.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,24 +14,42 @@
1414
# ==============================================================================
1515

1616

17-
def get_hash(f):
18-
# content hash for the result file alongside .log file is useful to
19-
# debug divergent results under what should be identical
20-
# builds(such as local and CI builds)
21-
for ext in [".odb", ".rtlil", ".v"]:
17+
# Primary data artifacts first, then derived/exported artifacts and
18+
# the SDC constraint file: yosys emits .v / .rtlil; OpenROAD stages
19+
# emit .odb (and often .def / .sdc); routing emits .spef; finish
20+
# emits .gds.
21+
RESULT_EXTS = [".v", ".rtlil", ".odb", ".def", ".spef", ".gds", ".sdc"]
22+
23+
24+
def get_hashes(f):
25+
"""Return [(ext, sha1), ...] for every result file alongside log
26+
`f` whose extension is in RESULT_EXTS. A yosys stage typically
27+
produces both `.v` and `.sdc`; a floorplan/route stage produces
28+
`.odb` (and often `.sdc`); the canonicalize stage produces
29+
`.rtlil`. Hashing each separately makes "the netlist changed"
30+
distinguishable from "the SDC changed" in the elapsed-time table
31+
used to triage divergent local vs CI builds.
32+
33+
Falls back to a single ("", "N/A") entry when no result file
34+
exists so the caller always emits at least one row per stage.
35+
"""
36+
results = []
37+
for ext in RESULT_EXTS:
2238
result_file = pathlib.Path(
2339
str(f).replace("logs/", "results/").replace(".log", ext)
2440
)
2541
if result_file.exists():
2642
hasher = hashlib.sha1()
27-
with open(result_file, "rb") as odb_f:
43+
with open(result_file, "rb") as rf:
2844
while True:
29-
chunk = odb_f.read(16 * 1024 * 1024)
45+
chunk = rf.read(16 * 1024 * 1024)
3046
if not chunk:
3147
break
3248
hasher.update(chunk)
33-
return hasher.hexdigest()
34-
return "N/A"
49+
results.append((ext, hasher.hexdigest()))
50+
if not results:
51+
results.append(("", "N/A"))
52+
return results
3553

3654

3755
def print_log_dir_times(logdir, args):
@@ -87,37 +105,49 @@ def print_log_dir_times(logdir, args):
87105
)
88106
break
89107

90-
odb_hash = get_hash(f)
108+
hashes = get_hashes(f)
91109

92110
if not found:
93111
print("No elapsed time found in", str(f), file=sys.stderr)
94112
continue
95113

96-
# Print the name of the step and the corresponding elapsed time
97-
format_str = "%-25s %10s %14s %20s"
114+
# Print the name of the step and the corresponding elapsed time.
115+
# One row per (stage, result-file-ext); only the first row of a
116+
# stage shows elapsed/peak.
117+
format_str = "%-25s %-6s %10s %14s %20s"
98118
if elapsedTime is not None and peak_memory is not None:
99119
if first and not args.noHeader:
100120
print(
101121
format_str
102-
% ("Log", "Elapsed/s", "Peak Memory/MB", "sha1sum result [0:20)")
122+
% (
123+
"Log",
124+
"Ext",
125+
"Elapsed/s",
126+
"Peak Memory/MB",
127+
"sha1sum result [0:20)",
128+
)
103129
)
104130
first = False
105-
print(
106-
format_str
107-
% (
108-
stem,
109-
elapsedTime,
110-
peak_memory,
111-
odb_hash[0:20],
131+
stage_first = True
132+
for ext, h in hashes:
133+
print(
134+
format_str
135+
% (
136+
stem,
137+
ext,
138+
elapsedTime if stage_first else "",
139+
peak_memory if stage_first else "",
140+
h[0:20],
141+
)
112142
)
113-
)
143+
stage_first = False
114144
if elapsedTime is not None:
115145
totalElapsed += elapsedTime
116146
if peak_memory is not None:
117147
total_max_memory = max(total_max_memory, int(peak_memory))
118148

119149
if totalElapsed != 0 and not args.match:
120-
print(format_str % ("Total", totalElapsed, total_max_memory, ""))
150+
print(format_str % ("Total", "", totalElapsed, total_max_memory, ""))
121151

122152

123153
def scan_logs(args):

flow/util/genMetrics.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# information in specific files using regular expressions
66
# -----------------------------------------------------------------------------
77

8+
import hashlib
89
import os
910
import shutil
1011
from datetime import datetime, timedelta
@@ -190,6 +191,18 @@ def git_head_commit(git_exe, folder):
190191
)
191192

192193

194+
def file_sha1(path):
195+
"""SHA-1 of `path`, or "N/A" if absent. Read in chunks so large
196+
netlists don't blow the heap."""
197+
if not os.path.isfile(path):
198+
return "N/A"
199+
hasher = hashlib.sha1()
200+
with open(path, "rb") as f:
201+
for chunk in iter(lambda: f.read(16 * 1024 * 1024), b""):
202+
hasher.update(chunk)
203+
return hasher.hexdigest()
204+
205+
193206
def merge_jsons(root_path, output, files):
194207
paths = sorted(glob(os.path.join(root_path, files)))
195208
for path in paths:
@@ -249,6 +262,15 @@ def extract_metrics(
249262
rptPath + "/synth_stat.txt",
250263
)
251264

265+
# Netlist hashes: fingerprints of the canonical RTLIL (pre-ABC) and
266+
# the final post-synthesis Verilog so the rules-base.json check
267+
# (level=warning) flags when bazel-built vs make-built yosys
268+
# disagree for the same RTL.
269+
metrics_dict["synth__canonical_netlist__hash"] = file_sha1(
270+
resultPath + "/1_1_yosys_canonicalize.rtlil"
271+
)
272+
metrics_dict["synth__netlist__hash"] = file_sha1(resultPath + "/1_2_yosys.v")
273+
252274
# Clocks
253275
# =========================================================================
254276
clk_list = read_sdc(resultPath + "/2_floorplan.sdc")

flow/util/genRuleFile.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,11 @@ def gen_rule_file(
4646

4747
# dict format
4848
# 'metric_name': {
49-
# 'mode': <str>, one of ['direct', 'sum_fixed', 'period', 'padding',
50-
# 'period_padding', 'abs_padding', 'metric']
49+
# 'mode': <str>, one of ['direct', 'literal', 'sum_fixed', 'period',
50+
# 'padding', 'period_padding', 'abs_padding',
51+
# 'metric']. 'literal' propagates the metric
52+
# value verbatim (e.g. a hash string) and
53+
# skips all numeric padding/rounding.
5154
# 'padding': <float>, percentage of padding to use
5255
# 'fixed': <float>, sum this number instead of using % padding
5356
# 'round_value': <bool>, use the rounded value for the rule
@@ -71,6 +74,21 @@ def gen_rule_file(
7174
"level": "warning",
7275
},
7376
# synth
77+
# Yosys netlist hash fingerprints. `mode: literal` propagates
78+
# the string value verbatim; `level: warning` means a mismatch
79+
# surfaces as a [WARN] diagnostic in checkMetadata.py without
80+
# failing the build, matching how rules-base.json already
81+
# treats warning counts.
82+
"synth__canonical_netlist__hash": {
83+
"mode": "literal",
84+
"compare": "==",
85+
"level": "warning",
86+
},
87+
"synth__netlist__hash": {
88+
"mode": "literal",
89+
"compare": "==",
90+
"level": "warning",
91+
},
7492
"synth__design__instance__area__stdcell": {
7593
"mode": "padding",
7694
"padding": 15,
@@ -279,7 +297,7 @@ def gen_rule_file(
279297
if ":" in field:
280298
field = field.replace(":", "__")
281299
processed_fields.add(field)
282-
if isinstance(metrics[field], str):
300+
if isinstance(metrics[field], str) and option["mode"] != "literal":
283301
print(f"[WARNING] Skipping string field {field} = {metrics[field]}")
284302
continue
285303

@@ -291,6 +309,9 @@ def gen_rule_file(
291309
if option["mode"] == "direct":
292310
rule_value = metrics[field]
293311

312+
elif option["mode"] == "literal":
313+
rule_value = metrics[field]
314+
294315
elif option["mode"] == "sum_fixed":
295316
rule_value = metrics[field] + option["padding"]
296317

@@ -342,10 +363,11 @@ def gen_rule_file(
342363
print(f"[ERROR] Metric {field} has invalid mode {option['mode']}.")
343364
sys.exit(1)
344365

345-
if option["round_value"] and not isinf(rule_value):
346-
rule_value = int(round(rule_value))
347-
else:
348-
rule_value = float(f"{rule_value:.3g}")
366+
if option["mode"] != "literal":
367+
if option["round_value"] and not isinf(rule_value):
368+
rule_value = int(round(rule_value))
369+
else:
370+
rule_value = float(f"{rule_value:.3g}")
349371

350372
preserve_old_rule = (
351373
True

0 commit comments

Comments
 (0)