Skip to content

Commit 70271e3

Browse files
committed
[CI][binary-size] Add bloaty measurement to arm-bare-metal size job
Adds a custom bloaty data source that buckets demangled symbols into ExecuTorch-meaningful groups (runtime / extension / backends / kernels / flatbuffer / stdlib / libc / etc), and a helper script that runs bloaty against the size_test ELF, writes metadata.json + human-readable text output as a CI artifact, and appends a per-bucket markdown table to the GitHub Actions step summary. Wired into the test-arm-cortex-m-size-test job only — the existing ls -la threshold check is untouched. Other size jobs will be wired up in follow-up PRs in this stack; later PRs add the sticky PR comment and replace the coarse byte threshold with per-bucket gating. Authored with Claude.
1 parent 915a82d commit 70271e3

3 files changed

Lines changed: 369 additions & 0 deletions

File tree

.github/scripts/bloaty_diff.py

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
# All rights reserved.
4+
#
5+
# This source code is licensed under the BSD-style license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
"""Bloaty binary-size reports for CI."""
9+
10+
import argparse
11+
import csv
12+
import io
13+
import json
14+
import os
15+
import shlex
16+
import subprocess
17+
import sys
18+
from dataclasses import asdict, dataclass, field
19+
from pathlib import Path
20+
from typing import Dict, List, Optional
21+
22+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
23+
BLOATY_CONFIG = REPO_ROOT / "test" / "bloaty" / "executorch.bloaty"
24+
BLOATY_CMD = shlex.split(os.environ.get("BLOATY", "bloaty"))
25+
26+
# Buckets considered "ExecuTorch source code" for the summary table. Everything
27+
# else (stdlib, libc, startup, metadata, other) is shown separately.
28+
EXECUTORCH_SOURCE_BUCKETS = [
29+
"runtime",
30+
"extension",
31+
"backends",
32+
"kernels",
33+
"cmsis_nn",
34+
"tokenizers",
35+
"flatbuffer",
36+
]
37+
38+
39+
def _run(cmd: List[str]) -> str:
40+
"""Run a subprocess; on failure include stderr in the exception."""
41+
try:
42+
return subprocess.run(cmd, check=True, capture_output=True, text=True).stdout
43+
except subprocess.CalledProcessError as e:
44+
raise RuntimeError(
45+
f"command failed (exit {e.returncode}): {' '.join(cmd)}\n"
46+
f"stderr:\n{e.stderr}"
47+
) from e
48+
49+
50+
def run_bloaty(elf: Path, data_sources: str) -> List[Dict[str, object]]:
51+
# -n 0 defeats bloaty's default 20-row truncation. -s vm sorts by VM size
52+
# (bytes claimed in flash + RAM after load), which is what matters for
53+
# embedded targets — .bss claims RAM at runtime but has filesize 0.
54+
cmd = [
55+
*BLOATY_CMD,
56+
"-c",
57+
str(BLOATY_CONFIG),
58+
"-d",
59+
data_sources,
60+
"-n",
61+
"0",
62+
"--csv",
63+
"-s",
64+
"vm",
65+
str(elf),
66+
]
67+
out = _run(cmd)
68+
reader = csv.DictReader(io.StringIO(out))
69+
rows: List[Dict[str, object]] = []
70+
for row in reader:
71+
parsed: Dict[str, object] = {}
72+
for k in reader.fieldnames or []:
73+
if k in ("vmsize", "filesize"):
74+
parsed[k] = int(row[k])
75+
else:
76+
parsed[k] = row[k]
77+
rows.append(parsed)
78+
return rows
79+
80+
81+
def bloaty_text(elf: Path, data_sources: str, top_n: int) -> str:
82+
cmd = [
83+
*BLOATY_CMD,
84+
"-c",
85+
str(BLOATY_CONFIG),
86+
"-d",
87+
data_sources,
88+
"-n",
89+
str(top_n),
90+
"-s",
91+
"vm",
92+
str(elf),
93+
]
94+
return _run(cmd)
95+
96+
97+
def strip_copy(elf: Path, strip_tool: str) -> Path:
98+
stripped = elf.with_suffix(elf.suffix + ".stripped")
99+
_run([strip_tool, "-o", str(stripped), str(elf)])
100+
return stripped
101+
102+
103+
@dataclass
104+
class BinaryReport:
105+
job: str
106+
binary_name: str
107+
head_sha: str
108+
stripped_head: int
109+
segments_head: List[Dict[str, object]] = field(default_factory=list)
110+
sections_head: List[Dict[str, object]] = field(default_factory=list)
111+
groups_head: List[Dict[str, object]] = field(default_factory=list)
112+
groups_head_stripped: List[Dict[str, object]] = field(default_factory=list)
113+
symbols_head: List[Dict[str, object]] = field(default_factory=list)
114+
115+
116+
def atomic_write(path: Path, content: str) -> None:
117+
tmp = path.with_suffix(path.suffix + ".tmp")
118+
tmp.write_text(content)
119+
tmp.replace(path)
120+
121+
122+
def render_table(rows: List[Dict[str, object]], key: str) -> str:
123+
if not rows:
124+
return "_(no data)_"
125+
out = ["| {} | vmsize | filesize |".format(key), "|---|---:|---:|"]
126+
for r in sorted(rows, key=lambda x: -int(x["vmsize"])):
127+
if r[key] == "TOTAL":
128+
continue
129+
out.append(f"| `{r[key]}` | {r['vmsize']:,} | {r['filesize']:,} |")
130+
return "\n".join(out)
131+
132+
133+
def render_step_summary(
134+
report: BinaryReport, full_text: str, head_only_text: str
135+
) -> str:
136+
et_rows = [
137+
r
138+
for r in report.groups_head
139+
if r.get("executorch") in EXECUTORCH_SOURCE_BUCKETS
140+
]
141+
et_total = sum(int(r["vmsize"]) for r in et_rows)
142+
lines = [
143+
f"## Bloaty: `{report.job}` / `{report.binary_name}`",
144+
"",
145+
f"- head sha: `{report.head_sha}`",
146+
f"- stripped head vm size: **{report.stripped_head:,} bytes**",
147+
f"- ExecuTorch source total (unstripped, bucketed, vm): **{et_total:,} bytes**",
148+
"",
149+
"### Per-bucket sizes (unstripped, all buckets)",
150+
"",
151+
render_table(report.groups_head, "executorch"),
152+
"",
153+
"### Per-bucket sizes (stripped, ExecuTorch source only)",
154+
"",
155+
render_table(
156+
[
157+
r
158+
for r in report.groups_head_stripped
159+
if r.get("executorch") in EXECUTORCH_SOURCE_BUCKETS
160+
],
161+
"executorch",
162+
),
163+
"",
164+
"### Sections",
165+
"",
166+
render_table(report.sections_head, "sections"),
167+
"",
168+
"<details><summary>Full bloaty output</summary>",
169+
"",
170+
"```",
171+
full_text.rstrip(),
172+
"```",
173+
"",
174+
"</details>",
175+
"",
176+
"<details><summary>Head-only top symbols</summary>",
177+
"",
178+
"```",
179+
head_only_text.rstrip(),
180+
"```",
181+
"",
182+
"</details>",
183+
"",
184+
]
185+
return "\n".join(lines)
186+
187+
188+
def cmd_measure(args: argparse.Namespace) -> int:
189+
head = Path(args.head).resolve()
190+
if not head.exists():
191+
print(f"head ELF does not exist: {head}", file=sys.stderr)
192+
return 1
193+
194+
out_dir = Path(args.out).resolve()
195+
out_dir.mkdir(parents=True, exist_ok=True)
196+
197+
stripped = strip_copy(head, args.strip_tool)
198+
try:
199+
groups_head_stripped = run_bloaty(stripped, "executorch")
200+
finally:
201+
stripped.unlink(missing_ok=True)
202+
# VM size of the stripped binary — flash + RAM bytes the loader claims.
203+
# .bss adds to vm but not file, so this differs from `ls -la` for any
204+
# binary with statically-allocated buffers.
205+
stripped_size = sum(
206+
int(r["vmsize"]) for r in groups_head_stripped if r.get("executorch") != "TOTAL"
207+
)
208+
209+
segments_head = run_bloaty(head, "segments")
210+
sections_head = run_bloaty(head, "sections")
211+
groups_head = run_bloaty(head, "executorch")
212+
symbols_head = run_bloaty(head, "shortsymbols")
213+
214+
report = BinaryReport(
215+
job=args.job,
216+
binary_name=args.binary_name,
217+
head_sha=args.head_sha,
218+
stripped_head=stripped_size,
219+
segments_head=segments_head,
220+
sections_head=sections_head,
221+
groups_head=groups_head,
222+
groups_head_stripped=groups_head_stripped,
223+
symbols_head=symbols_head,
224+
)
225+
226+
atomic_write(out_dir / "metadata.json", json.dumps(asdict(report), indent=2))
227+
228+
full_text = bloaty_text(head, "segments,sections,executorch,shortsymbols", top_n=30)
229+
head_only_text = bloaty_text(head, "executorch,shortsymbols", top_n=30)
230+
atomic_write(out_dir / "full.txt", full_text)
231+
atomic_write(out_dir / "head_only.txt", head_only_text)
232+
233+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
234+
if summary_path:
235+
with open(summary_path, "a") as f:
236+
f.write(render_step_summary(report, full_text, head_only_text))
237+
238+
print(f"wrote {out_dir / 'metadata.json'}")
239+
print(f"stripped head vm size: {stripped_size:,} bytes")
240+
return 0
241+
242+
243+
def main(argv: Optional[List[str]] = None) -> int:
244+
parser = argparse.ArgumentParser(description=__doc__)
245+
sub = parser.add_subparsers(dest="cmd", required=True)
246+
247+
p_measure = sub.add_parser("measure", help="Measure an ELF with bloaty")
248+
p_measure.add_argument(
249+
"--head", required=True, help="Path to head (unstripped) ELF"
250+
)
251+
p_measure.add_argument("--job", required=True, help="CI job identifier")
252+
p_measure.add_argument(
253+
"--binary-name", required=True, help="Binary name (e.g. size_test)"
254+
)
255+
p_measure.add_argument(
256+
"--head-sha", required=True, help="Git SHA of the head commit"
257+
)
258+
p_measure.add_argument(
259+
"--strip-tool", default="strip", help="Strip tool (e.g. arm-none-eabi-strip)"
260+
)
261+
p_measure.add_argument("--out", required=True, help="Output directory")
262+
p_measure.set_defaults(func=cmd_measure)
263+
264+
args = parser.parse_args(argv)
265+
return args.func(args)
266+
267+
268+
if __name__ == "__main__":
269+
sys.exit(main())

.github/workflows/pull.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ jobs:
544544
submodules: 'recursive'
545545
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
546546
timeout: 90
547+
upload-artifact: bloaty-arm-${{ matrix.os }}
547548
script: |
548549
# The generic Linux job chooses to use base env, not the one setup by the image
549550
CONDA_ENV=$(conda env list --json | jq -r ".envs | .[-1]")
@@ -600,6 +601,33 @@ jobs:
600601
python .github/scripts/run_nm.py -e ${elf} -f "executorch" -p "${toolchain_prefix}"
601602
python .github/scripts/run_nm.py -e ${elf} -f "executorch_text" -p "${toolchain_prefix}"
602603
604+
# Bloaty per-bucket size report (best-effort; never fails the size job).
605+
# Runs BEFORE the in-place strip below so the head ELF is still unstripped.
606+
mkdir -p /tmp/bloaty-elfs
607+
cp "${elf}" /tmp/bloaty-elfs/head.elf
608+
(
609+
# conda-forge bloaty depends on a newer libstdc++ than the docker image
610+
# ships, so pull libstdcxx-ng into the same env and invoke via `conda run`.
611+
bloaty_env=/tmp/bloaty-conda-env
612+
if [[ ! -x "${bloaty_env}/bin/bloaty" ]]; then
613+
conda create -y -p "${bloaty_env}" -c conda-forge bloaty libstdcxx-ng || exit 1
614+
fi
615+
bloaty_cmd=("conda" "run" "--no-capture-output" "-p" "${bloaty_env}" "bloaty")
616+
"${bloaty_cmd[@]}" --version || exit 1
617+
618+
tmp_out=/tmp/bloaty-out
619+
rm -rf "${tmp_out}" && mkdir -p "${tmp_out}"
620+
BLOATY="${bloaty_cmd[*]}" python3 .github/scripts/bloaty_diff.py measure \
621+
--head /tmp/bloaty-elfs/head.elf \
622+
--job "arm-${{ matrix.os }}" \
623+
--binary-name size_test \
624+
--head-sha "${{ github.event.pull_request.head.sha || github.sha }}" \
625+
--strip-tool "${toolchain_prefix}strip" \
626+
--out "${tmp_out}" || exit 1
627+
mkdir -p artifacts-to-be-uploaded
628+
mv "${tmp_out}"/* artifacts-to-be-uploaded/
629+
) || echo "bloaty report failed; continuing"
630+
603631
# Add basic guard - TODO: refine this!
604632
${toolchain_prefix}strip ${elf}
605633
output=$(ls -la ${elf})

test/bloaty/executorch.bloaty

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Bloaty custom data source for ExecuTorch binaries.
2+
#
3+
# Buckets demangled symbols into ExecuTorch-meaningful groups.
4+
# Use: `bloaty -c test/bloaty/executorch.bloaty -d executorch <binary>`.
5+
#
6+
# `base_data_source: shortsymbols` is load-bearing: bloaty's top-level --demangle
7+
# does NOT propagate into custom data sources, and shortsymbols is the demangled-
8+
# name source (it also collapses template instantiations).
9+
#
10+
# The `kernels` bucket is a UNION of every operator/SIMD/BLAS namespace across
11+
# all backends (see the "kernels" block below — it grows as backends land op
12+
# libraries). When a regression lands in `kernels`, grep the patterns listed
13+
# there to find which family changed.
14+
#
15+
# When new namespaces land in the codebase, anything unmatched falls into
16+
# `other`. Watch this on real baselines; add rewrites as needed.
17+
18+
custom_data_source: {
19+
name: "executorch"
20+
base_data_source: "shortsymbols"
21+
22+
rewrite: { pattern: "^executorch::runtime::" replacement: "runtime" }
23+
rewrite: { pattern: "^executorch::extension::" replacement: "extension" }
24+
25+
# --- kernels (operator implementations, SIMD/BLAS helpers, per-backend op libs) ---
26+
# ADD NEW OPERATOR/KERNEL NAMESPACES HERE. These must precede the generic
27+
# `executorch::backends::` rewrite below — bloaty rewrites are first-match-wins,
28+
# so a kernel namespace nested under backends/ would otherwise land in `backends`.
29+
rewrite: { pattern: "^executorch::backends::cortex_m::" replacement: "kernels" }
30+
rewrite: { pattern: "^cortex_m_" replacement: "kernels" }
31+
rewrite: { pattern: "^executorch::vec::" replacement: "kernels" }
32+
rewrite: { pattern: "^executorch::cpublas::" replacement: "kernels" }
33+
rewrite: { pattern: "^torch::executor::native::" replacement: "kernels" }
34+
# Cadence ops live under impl::{generic,HiFi,G3,vision}::native::* and
35+
# cadence::fused_quant::native::* — all are op implementations.
36+
rewrite: { pattern: "^impl::(generic|HiFi|G3|vision)::native::" replacement: "kernels" }
37+
rewrite: { pattern: "^cadence::fused_quant::native::" replacement: "kernels" }
38+
# --- end kernels ---
39+
40+
rewrite: { pattern: "^executorch::backends::" replacement: "backends" }
41+
rewrite: { pattern: "^torch::executor::" replacement: "runtime" }
42+
rewrite: { pattern: "^executor::" replacement: "runtime" }
43+
rewrite: { pattern: "^executorch_flatbuffer" replacement: "flatbuffer" }
44+
rewrite: { pattern: "^flatbuffers::" replacement: "flatbuffer" }
45+
rewrite: { pattern: "^tokenizers::" replacement: "tokenizers" }
46+
rewrite: { pattern: "^arm_cmsis_nn_" replacement: "cmsis_nn" }
47+
48+
rewrite: { pattern: "^std::" replacement: "stdlib" }
49+
rewrite: { pattern: "^__gnu_cxx::" replacement: "stdlib" }
50+
rewrite: { pattern: "^__cxxabiv1::" replacement: "stdlib" }
51+
rewrite: { pattern: "^__gxx_personality" replacement: "stdlib" }
52+
rewrite: { pattern: "^d_(print|type|expression|special|qualified|template|name|operator|substitution|number|abi_tag|cv_qualifiers|exprlist|growable|append|encoding|class_enum_type|local_name|unqualified_name|nested_name|prefix|cv|ref|ptrmem|array|function|java|hex|index|maybe|ctor|dtor|destructor|construct|count|callid|args|java_resource|lambda|unnamed_type|parmlist|expr_primary|operator_name|left|right|child)" replacement: "stdlib" }
53+
rewrite: { pattern: "^cplus_demangle" replacement: "stdlib" }
54+
55+
rewrite: { pattern: "^_(start|init|fini)$" replacement: "startup" }
56+
rewrite: { pattern: "^__libc_" replacement: "libc" }
57+
rewrite: { pattern: "^__aeabi_" replacement: "libc" }
58+
rewrite: { pattern: "^_*memcpy" replacement: "libc" }
59+
rewrite: { pattern: "^_*memset" replacement: "libc" }
60+
rewrite: { pattern: "^_*memmove" replacement: "libc" }
61+
rewrite: { pattern: "^_*malloc" replacement: "libc" }
62+
rewrite: { pattern: "^_*free" replacement: "libc" }
63+
rewrite: { pattern: "^_*printf" replacement: "libc" }
64+
rewrite: { pattern: "^_*sprintf" replacement: "libc" }
65+
rewrite: { pattern: "^_s?v?f?i?printf_r$" replacement: "libc" }
66+
rewrite: { pattern: "^_dtoa_r" replacement: "libc" }
67+
rewrite: { pattern: "^_(sbrk|write|read|close|fstat|lseek|isatty|exit|kill|getpid|open|stat|times|unlink|wait|gettimeofday)_r?$" replacement: "libc" }
68+
69+
rewrite: { pattern: "^\\[section \\.(debug_|symtab|strtab|shstrtab)" replacement: "metadata" }
70+
71+
rewrite: { pattern: ".*" replacement: "other" }
72+
}

0 commit comments

Comments
 (0)