Skip to content

Commit b01a1d3

Browse files
committed
package_core: add size delta calculation in CI
Use the new ci_calc_size_reports.py script to calculate size deltas for PRs, using the report archived at the time base commit of the PR was created as reference. The resulting delta information is added to the JSON report files, and a summary table is printed in the logs. The delta information is also used by the report-size-deltas workflow to post a comment in the PR with the size delta details. Signed-off-by: Luca Burelli <l.burelli@arduino.cc>
1 parent 84f52df commit b01a1d3

3 files changed

Lines changed: 308 additions & 1 deletion

File tree

.github/workflows/package_core.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,11 +407,13 @@ jobs:
407407
pattern: "*-report-*"
408408
merge-multiple: true
409409

410-
- run: |
410+
- name: Generate workflow summary
411+
run: |
411412
# gather the array of job metadata (especially name and ID) for the current workflow run
412413
export WORKFLOW_JOBS=$(gh run view ${{ github.run_id }} --attempt ${{ github.run_attempt }} --json jobs --jq '.jobs')
413414
# Run the log inspection script
414415
extra/ci_inspect_logs.py result summary full_log
416+
cat result
415417
416418
# Display the summary and full log in the step summary
417419
cat summary >> $GITHUB_STEP_SUMMARY
@@ -431,6 +433,12 @@ jobs:
431433
tar jchf size-reports-${{ needs.build-env.outputs.CORE_HASH }}.tar.bz2 arduino-*.json
432434
fi
433435
436+
- name: Compute code size changes
437+
if: ${{ github.event_name == 'pull_request' }}
438+
run: |
439+
export GITHUB_BASE_SHA=$(git describe --always origin/${GITHUB_BASE_REF})
440+
extra/ci_calc_size_reports.py ${GITHUB_BASE_SHA} sketches-reports/
441+
434442
# upload comment request artifact (will be retrieved by leave_pr_comment.yml)
435443
- name: Archive comment information
436444
uses: actions/upload-artifact@v4
@@ -440,6 +448,15 @@ jobs:
440448
path: comment-request/
441449
retention-days: 1
442450

451+
# upload size delta report artifact (will be retrieved by report_size_deltas.yml)
452+
- name: Archive size deltas report information
453+
uses: actions/upload-artifact@v4
454+
if: ${{ github.event_name == 'pull_request' }}
455+
with:
456+
name: sketches-reports
457+
path: sketches-reports/
458+
retention-days: 1
459+
443460
# upload new official test size artifact (for AWS storage)
444461
- name: Archive sketch report information
445462
uses: actions/upload-artifact@v4
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Copyright (c) Arduino s.r.l. and/or its affiliated companies
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
name: Report size deltas
5+
6+
on:
7+
workflow_run:
8+
workflows: ["Package, test and upload core"]
9+
types:
10+
- completed
11+
12+
permissions:
13+
contents: read
14+
pull-requests: write
15+
16+
jobs:
17+
build:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- uses: arduino/report-size-deltas@v1.1.0

extra/ci_calc_size_reports.py

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright (c) Arduino s.r.l. and/or its affiliated companies
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
# Script to analyze CI test logs, download previously generated size reports,
7+
# and calculate size deltas in the official JSON report format.
8+
#
9+
# This scripts expects the following arguments:
10+
# - <prev_sha>: SHA of the base report to get, used for delta calculation
11+
# - <reports_folder>: directory to populate with the delta reports
12+
#
13+
# The script performs the following actions:
14+
# - downloads the previous report archive from AWS
15+
# - extracts it in the output folder
16+
# - for each JSON report file in the output folder:
17+
# - calculates the delta with the corresponding report in the
18+
# current directory (if it exists and both reports are valid)
19+
# - updates the output report file with the delta information.
20+
21+
from collections import defaultdict
22+
import copy
23+
import json
24+
import os
25+
from pathlib import Path
26+
import requests
27+
import re
28+
import sys
29+
import tarfile
30+
31+
def extract_url(url, file_pattern, sha, local_path):
32+
"""
33+
Download and extract a tar.bz2 archive from a URL, trying with truncated
34+
versions of the SHA until a match is found. The archive is expected to
35+
contain size reports in JSON format. The extracted files will be placed in
36+
the specified local path. If no archive is found for the given SHA, a
37+
FileNotFoundError is raised.
38+
"""
39+
40+
session = requests.Session()
41+
42+
# try with the input SHA, then with truncated versions
43+
# until we find a match or run out of characters
44+
while len(sha) > 6:
45+
file = file_pattern.format(sha)
46+
archive_url = f"{url}/{file}"
47+
archive_path = Path(local_path) / file
48+
try:
49+
os.makedirs(local_path, exist_ok=True)
50+
response = session.get(archive_url)
51+
if response.status_code == 404:
52+
sha = sha[:-1]
53+
continue
54+
# raise other errors
55+
response.raise_for_status()
56+
with open(archive_path, 'wb') as f:
57+
f.write(response.content)
58+
print(f"Downloaded {archive_url}")
59+
break
60+
except Exception as e:
61+
print(f"Error downloading {url}: {e}")
62+
raise
63+
64+
if not archive_path.exists():
65+
raise FileNotFoundError(f"Could not find size archive for SHA {sha}")
66+
67+
try:
68+
with tarfile.open(archive_path, 'r:bz2') as tar:
69+
tar.extractall(path=local_path)
70+
print(f"Extracted {archive_path} to {local_path}")
71+
except Exception as e:
72+
print(f"Error extracting {archive_path}: {e}")
73+
raise
74+
75+
os.remove(archive_path)
76+
77+
def update_final_deltas(final, abs_max, delta):
78+
"""
79+
Update the final delta values for a given entry (e.g. "flash" or "RAM for
80+
global variables") based on a new delta calculation from a sketch.
81+
"""
82+
83+
if not "maximum" in final:
84+
final["maximum"] = abs_max
85+
final["delta"] = {}
86+
for key in delta: # rel/abs
87+
val = delta[key]
88+
final["delta"][key] = {
89+
"minimum": val,
90+
"maximum": val
91+
}
92+
else:
93+
final["maximum"] = max(final["maximum"], abs_max)
94+
for key in delta: # rel/abs
95+
val = delta[key]
96+
final["delta"][key]["minimum"] = min(final["delta"][key]["minimum"], val)
97+
final["delta"][key]["maximum"] = max(final["delta"][key]["maximum"], val)
98+
99+
def range_str(delta):
100+
"""
101+
Format a delta value (with "absolute" and "relative" keys) as a string for
102+
display in the summary table.
103+
"""
104+
105+
if not delta:
106+
return "N/A"
107+
108+
dmin = delta['absolute']['minimum']
109+
dmax = delta['absolute']['maximum']
110+
if not dmin and not dmax:
111+
return "==="
112+
113+
dmin_str = f"{int(dmin)}" if dmin <= 0 else f"+{int(dmin)}"
114+
dmax_str = f"{int(dmax)}" if dmax <= 0 else f"+{int(dmax)}"
115+
if dmin == dmax:
116+
return dmin_str
117+
else:
118+
return f"{dmin_str}..{dmax_str}"
119+
120+
if not len(sys.argv) == 3:
121+
print("Usage: ci_size_reports.py <prev_sha> <output_folder>")
122+
sys.exit(1)
123+
124+
prev_sha = sys.argv[1]
125+
output_folder = sys.argv[2]
126+
127+
headers = [ "Package", "Board", "Options", "Flash", " RAM ", "Tests", f"vs {prev_sha}" ]
128+
formats = [ "{{:<{}}}", "{{:<{}}}", "{{:<{}}}", "{{:^{}}}", "{{:^{}}}", "{{:>{}}}", "{{:<{}}}" ]
129+
data_lines = []
130+
col_widths = [ len(header) for header in headers ]
131+
132+
name_regexp = re.compile(r'(libraries|examples)/([^/]+)/(examples/|extras/)?(.*)')
133+
134+
# get the previous data to the output folder
135+
extract_url("https://downloads.arduino.cc/cores/zephyr/size-reports",
136+
"size-reports-{}.tar.bz2", prev_sha, output_folder)
137+
138+
# find every JSON file in the output folder
139+
old_jsons = []
140+
max_package_len = 0
141+
max_board_len = 0
142+
max_mode_len = 0
143+
for dirent in os.scandir(output_folder):
144+
if dirent.is_file() and dirent.name.endswith(".json"):
145+
package, board, opts = dirent.name[:-5].split('-')[1:4]
146+
old_jsons.append(( package, board, opts, dirent ))
147+
148+
# calculate deltas and update output files
149+
for package, board, opts, dirent in sorted(old_jsons):
150+
with open(dirent.path, 'r') as f:
151+
old_report_data = json.load(f)
152+
# test if the current file exists
153+
if not os.path.exists(dirent.name):
154+
# no: remove the old report to prevent confusion
155+
os.remove(dirent.path)
156+
continue
157+
with open(dirent.name, 'r') as f:
158+
new_report_data = json.load(f)
159+
160+
# the output file will be a copy of the new data, plus some fields
161+
output_report_data = copy.deepcopy(new_report_data)
162+
sketch_now_missing = 0
163+
sketch_was_missing = 0
164+
updated_sketches = 0
165+
finals = defaultdict(dict)
166+
167+
output_sketches = output_report_data['boards'][0]['sketches']
168+
169+
group_lines = []
170+
171+
for sketch in sorted(output_sketches, key=lambda s: s['name']):
172+
match = name_regexp.search(sketch['name'])
173+
display_name = f"{match.group(2)} {match.group(4)}" if match else sketch['name']
174+
175+
sketch_ok = sketch['compilation_success']
176+
old_sketch = next((s for s in old_report_data['boards'][0]['sketches'] if s['name'] == sketch['name']), None)
177+
old_sketch_ok = old_sketch and old_sketch['compilation_success']
178+
179+
if not sketch_ok:
180+
# compile time issue in the new data, skip
181+
if old_sketch_ok:
182+
group_lines.append(( display_name, "-- compile failed" ))
183+
sketch_now_missing += 1
184+
continue
185+
if not old_sketch_ok:
186+
# compile time issue or missing old data, skip
187+
group_lines.append(( display_name, "-- old data missing" ))
188+
sketch_was_missing += 1
189+
continue
190+
deltas = {}
191+
for entry in sketch['sizes']: # list, name is "flash" or "RAM for global variables"
192+
key = entry['name']
193+
old_match = next((e for e in old_sketch['sizes'] if e['name'] == entry['name']), None)
194+
if not old_match:
195+
# major group missing in old data, skip
196+
group_lines.append(( display_name, f"-- old group mismatch" ))
197+
continue
198+
# add "previous" data from the old report
199+
entry['previous'] = old_match['current']
200+
curr_abs = entry['current']['absolute']
201+
prev_abs = entry['previous']['absolute']
202+
if isinstance(curr_abs, str) or isinstance(prev_abs, str):
203+
# "N/A", ignore delta for this entry
204+
continue
205+
deltas[key] = entry['delta'] = {
206+
'absolute': curr_abs - prev_abs,
207+
'relative': ((curr_abs - prev_abs) * 100 // prev_abs) / 100 if prev_abs > 0 else "N/A"
208+
}
209+
update_final_deltas(finals[key], entry['maximum'], entry['delta'])
210+
211+
# update display name in output file
212+
sketch['name'] = display_name
213+
214+
if not deltas:
215+
group_lines.append(( display_name, "-- no matches" ))
216+
else:
217+
updated_sketches += 1
218+
ram_delta = deltas["RAM for global variables"]['absolute'] if "RAM for global variables" in deltas else "N/A"
219+
flash_delta = deltas["flash"]['absolute'] if "flash" in deltas else "N/A"
220+
group_lines.append(( display_name, f"{flash_delta:6} {ram_delta:6}" ))
221+
222+
if group_lines:
223+
print(f"::group::{package} {board} {opts}")
224+
max_name_len = max(len(display_name) for display_name, _ in group_lines)
225+
for display_name, text in group_lines:
226+
print(f"{package} {board} {opts} {display_name:{max_name_len}} {text}")
227+
print(f"::endgroup::")
228+
229+
# update the board-specific 'sizes' list with the new deltas
230+
output_report_data['boards'][0]['sizes'] = []
231+
for entry_name, final_values in finals.items():
232+
output_report_data['boards'][0]['sizes'].append({
233+
"name": entry_name,
234+
**final_values
235+
})
236+
237+
# overwite old file with new data + valid deltas
238+
with open(dirent.path, 'w') as f:
239+
json.dump(output_report_data, f, indent=2)
240+
241+
# generate a line for the summary table
242+
changes = f"{sketch_now_missing} lost" if sketch_now_missing else ""
243+
changes += ", " if sketch_now_missing and sketch_was_missing else ""
244+
changes += f"{sketch_was_missing} new" if sketch_was_missing else ""
245+
data_line = [ package, board, opts,
246+
range_str(finals["flash"].get('delta')),
247+
range_str(finals["RAM for global variables"].get('delta')),
248+
updated_sketches or '-',
249+
changes ]
250+
251+
col_widths = [ max(len(str(data_line[i])), col_widths[i]) for i in range(len(data_line)) ]
252+
data_lines.append(data_line)
253+
254+
# format and output table
255+
format_string = "| " + " | ".join([ formats[i].format(col_widths[i]) for i in range(len(col_widths)) ]) + " |"
256+
spacer_string = "+-" + "-+-".join([ "-"*col_widths[i] for i in range(len(col_widths)) ]) + "-+"
257+
258+
print(spacer_string)
259+
print(format_string.format(*headers))
260+
261+
last_package = None
262+
for data_line in data_lines:
263+
if data_line[0] != last_package:
264+
last_package = data_line[0]
265+
print(spacer_string)
266+
else:
267+
data_line[0] = ""
268+
print(format_string.format(*data_line))
269+
270+
print(spacer_string)

0 commit comments

Comments
 (0)