Skip to content

Commit 9dfc729

Browse files
committed
ci: add compare_size_results.py to flag binary size changes
Per-binary comparison script that reads the existing individual-size-result.json format (date-keyed with sha + sizes-in-KB) and reports binaries whose size changed by both >=5% and >=4 KB. This is the size-tracking counterpart of compare_test_results.py: it emits GitHub workflow annotations and writes a PR comment body to its --output path only when there is something significant to report.
1 parent b13588c commit 9dfc729

1 file changed

Lines changed: 247 additions & 0 deletions

File tree

util/compare_size_results.py

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Compare individual binary sizes between current run and reference (main branch)
4+
to flag PRs that introduce a significant change in the size of standalone
5+
(non-multicall) binaries.
6+
7+
This is the size-tracking counterpart of compare_test_results.py.
8+
9+
Both inputs are JSON files in the format produced by `make.yml` (and stored in
10+
the `uutils/coreutils-tracking` repo as `individual-size-result.json`):
11+
12+
{
13+
"Mon, 17 Apr 2023 06:42:22 +0000": {
14+
"sha": "<commit sha>",
15+
"sizes": {
16+
"ls": 1064,
17+
"date": 856,
18+
...
19+
}
20+
}
21+
}
22+
23+
Sizes are in 1K blocks (the value of `du -s`), so deltas are also in KB.
24+
25+
A change is considered "significant" when the relative delta exceeds the
26+
configured threshold (default: 5%, matching the previous shell-based check
27+
in make.yml). A small absolute floor is also applied to suppress 1KB rounding
28+
noise that would otherwise show up for tiny binaries.
29+
"""
30+
31+
import argparse
32+
import json
33+
import sys
34+
35+
# Default thresholds: 5% relative AND >= 4 KB absolute. The 5% matches the
36+
# previous inline shell check; the 4 KB floor avoids reporting single-block
37+
# rounding noise on tiny binaries (sizes are stored in 1K blocks).
38+
DEFAULT_REL_THRESHOLD = 0.05
39+
DEFAULT_ABS_THRESHOLD_KB = 4
40+
41+
42+
def load_sizes(path):
43+
"""Load a sizes JSON file and return (sha, {name: size_kb}).
44+
45+
The file is expected to be a single-entry dict keyed by date, whose
46+
value is `{"sha": ..., "sizes": {...}}` (the format produced by
47+
make.yml). For robustness, a flat `{name: size}` mapping is also
48+
accepted.
49+
"""
50+
with open(path, "r") as f:
51+
data = json.load(f)
52+
53+
if not isinstance(data, dict):
54+
raise ValueError(f"Unexpected JSON structure in {path}")
55+
56+
# date-keyed wrapper used in tracking JSON
57+
if data and all(isinstance(v, dict) and "sizes" in v for v in data.values()):
58+
# Take the (single) entry; if there are multiple, take the last one,
59+
# matching how compare_gnu_result.py picks list(d.keys())[0].
60+
entry = data[list(data.keys())[-1]]
61+
return entry.get("sha"), {k: int(v) for k, v in entry["sizes"].items()}
62+
63+
# Flat mapping
64+
return None, {k: int(v) for k, v in data.items()}
65+
66+
67+
def human_kb(size_kb):
68+
"""Render a value already expressed in KB as a human-friendly string."""
69+
sign = "-" if size_kb < 0 else ""
70+
n = abs(float(size_kb))
71+
if n < 1024:
72+
return f"{sign}{n:.0f} KB"
73+
n /= 1024
74+
if n < 1024:
75+
return f"{sign}{n:.2f} MB"
76+
n /= 1024
77+
return f"{sign}{n:.2f} GB"
78+
79+
80+
def compare(current, reference, rel_threshold, abs_threshold_kb):
81+
"""Return (significant, added, removed, totals).
82+
83+
`significant` is a list of dicts with name/old/new/delta/rel, sorted by
84+
descending |delta|.
85+
"""
86+
significant = []
87+
added = []
88+
removed = []
89+
90+
for name, new_size in current.items():
91+
if name not in reference:
92+
added.append((name, new_size))
93+
continue
94+
old_size = reference[name]
95+
delta = new_size - old_size
96+
if old_size == 0:
97+
rel = 0.0 if delta == 0 else float("inf")
98+
else:
99+
rel = delta / old_size
100+
if abs(delta) >= abs_threshold_kb and abs(rel) >= rel_threshold:
101+
significant.append(
102+
{
103+
"name": name,
104+
"old": old_size,
105+
"new": new_size,
106+
"delta": delta,
107+
"rel": rel,
108+
}
109+
)
110+
111+
for name, old_size in reference.items():
112+
if name not in current:
113+
removed.append((name, old_size))
114+
115+
significant.sort(key=lambda c: abs(c["delta"]), reverse=True)
116+
117+
common = [n for n in current if n in reference]
118+
totals = {
119+
"current": sum(current[n] for n in common),
120+
"reference": sum(reference[n] for n in common),
121+
}
122+
return significant, added, removed, totals
123+
124+
125+
def format_report(significant, added, removed, totals, rel_threshold, abs_threshold_kb):
126+
lines = []
127+
128+
total_delta = totals["current"] - totals["reference"]
129+
total_rel = total_delta / totals["reference"] if totals["reference"] else 0.0
130+
131+
lines.append(
132+
"Individual binary size comparison VS main "
133+
f"(threshold: >={rel_threshold * 100:.0f}% AND >={abs_threshold_kb} KB)."
134+
)
135+
lines.append("")
136+
lines.append(
137+
f"Total size of compared binaries: {human_kb(totals['current'])} "
138+
f"({'+' if total_delta >= 0 else ''}{human_kb(total_delta)}, "
139+
f"{total_rel * 100:+.2f}%)"
140+
)
141+
142+
if significant:
143+
lines.append("")
144+
lines.append("Significant per-binary changes:")
145+
name_w = max(len(c["name"]) for c in significant)
146+
for c in significant:
147+
sign = "+" if c["delta"] >= 0 else ""
148+
lines.append(
149+
f" {c['name']:<{name_w}} "
150+
f"{human_kb(c['old']):>10} -> {human_kb(c['new']):>10} "
151+
f"({sign}{human_kb(c['delta'])}, {c['rel'] * 100:+.2f}%)"
152+
)
153+
154+
if added:
155+
lines.append("")
156+
lines.append("New binaries:")
157+
for name, size in sorted(added):
158+
lines.append(f" {name} ({human_kb(size)})")
159+
160+
if removed:
161+
lines.append("")
162+
lines.append("Removed binaries:")
163+
for name, size in sorted(removed):
164+
lines.append(f" {name} (was {human_kb(size)})")
165+
166+
return "\n".join(lines) + "\n"
167+
168+
169+
def main():
170+
parser = argparse.ArgumentParser(
171+
description=(
172+
"Compare individual binary sizes against a reference and flag "
173+
"significant changes"
174+
)
175+
)
176+
parser.add_argument(
177+
"current_json", help="Path to current run individual-size-result.json"
178+
)
179+
parser.add_argument(
180+
"reference_json",
181+
help="Path to reference (main branch) individual-size-result.json",
182+
)
183+
parser.add_argument(
184+
"--output",
185+
help="Path to output file for the GitHub PR comment body",
186+
)
187+
parser.add_argument(
188+
"--rel-threshold",
189+
type=float,
190+
default=DEFAULT_REL_THRESHOLD,
191+
help=f"Relative change threshold (default: {DEFAULT_REL_THRESHOLD})",
192+
)
193+
parser.add_argument(
194+
"--abs-threshold",
195+
type=int,
196+
default=DEFAULT_ABS_THRESHOLD_KB,
197+
help=(f"Absolute change threshold in KB (default: {DEFAULT_ABS_THRESHOLD_KB})"),
198+
)
199+
args = parser.parse_args()
200+
201+
try:
202+
_, current = load_sizes(args.current_json)
203+
except (FileNotFoundError, json.JSONDecodeError, ValueError, KeyError) as e:
204+
sys.stderr.write(f"Error loading current sizes: {e}\n")
205+
return 1
206+
207+
try:
208+
_, reference = load_sizes(args.reference_json)
209+
except (FileNotFoundError, json.JSONDecodeError, ValueError, KeyError) as e:
210+
sys.stderr.write(f"Error loading reference sizes: {e}\n")
211+
sys.stderr.write("Skipping comparison as reference is not available.\n")
212+
return 0
213+
214+
significant, added, removed, totals = compare(
215+
current, reference, args.rel_threshold, args.abs_threshold
216+
)
217+
218+
report = format_report(
219+
significant, added, removed, totals, args.rel_threshold, args.abs_threshold
220+
)
221+
print(report)
222+
223+
# Emit GitHub workflow annotations so the changes are visible in the
224+
# Actions UI even without the PR comment. Growth is a warning,
225+
# shrinkage is an informational notice.
226+
for c in significant:
227+
msg = (
228+
f"Binary {c['name']} size changed: "
229+
f"{human_kb(c['old'])} -> {human_kb(c['new'])} "
230+
f"({'+' if c['delta'] >= 0 else ''}{human_kb(c['delta'])}, "
231+
f"{c['rel'] * 100:+.2f}%)"
232+
)
233+
level = "warning" if c["delta"] > 0 else "notice"
234+
print(f"::{level} ::{msg}", file=sys.stderr)
235+
236+
# Only write the comment file when there is something worth saying so
237+
# downstream comment-posting workflows can skip empty bodies (the
238+
# GnuComment workflow uses a similar length check).
239+
if args.output and (significant or added or removed):
240+
with open(args.output, "w") as f:
241+
f.write(report)
242+
243+
return 0
244+
245+
246+
if __name__ == "__main__":
247+
sys.exit(main())

0 commit comments

Comments
 (0)