Skip to content

Commit 68e12bc

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 68e12bc

3 files changed

Lines changed: 370 additions & 0 deletions

File tree

.github/scripts/bloaty_diff.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+
# 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(
82+
elf: Path,
83+
data_sources: str,
84+
top_n: int,
85+
source_filter: Optional[str] = None,
86+
) -> str:
87+
cmd = [
88+
*BLOATY_CMD,
89+
"-c",
90+
str(BLOATY_CONFIG),
91+
"-d",
92+
data_sources,
93+
"-n",
94+
str(top_n),
95+
"-s",
96+
"vm",
97+
]
98+
if source_filter is not None:
99+
cmd += ["--source-filter", source_filter]
100+
cmd.append(str(elf))
101+
return _run(cmd)
102+
103+
104+
def strip_copy(elf: Path, strip_tool: str) -> Path:
105+
stripped = elf.with_suffix(elf.suffix + ".stripped")
106+
_run([strip_tool, "-o", str(stripped), str(elf)])
107+
return stripped
108+
109+
110+
@dataclass
111+
class BinaryReport:
112+
job: str
113+
binary_name: str
114+
head_sha: str
115+
stripped_head: int
116+
segments_head: List[Dict[str, object]] = field(default_factory=list)
117+
sections_head: List[Dict[str, object]] = field(default_factory=list)
118+
groups_head: List[Dict[str, object]] = field(default_factory=list)
119+
groups_head_stripped: List[Dict[str, object]] = field(default_factory=list)
120+
symbols_head: List[Dict[str, object]] = field(default_factory=list)
121+
122+
123+
def atomic_write(path: Path, content: str) -> None:
124+
tmp = path.with_suffix(path.suffix + ".tmp")
125+
tmp.write_text(content)
126+
tmp.replace(path)
127+
128+
129+
def render_table(rows: List[Dict[str, object]], key: str) -> str:
130+
if not rows:
131+
return "_(no data)_"
132+
out = ["| {} | vmsize | filesize |".format(key), "|---|---:|---:|"]
133+
for r in sorted(rows, key=lambda x: -int(x["vmsize"])):
134+
if r[key] == "TOTAL":
135+
continue
136+
out.append(f"| `{r[key]}` | {r['vmsize']:,} | {r['filesize']:,} |")
137+
return "\n".join(out)
138+
139+
140+
def render_step_summary(
141+
report: BinaryReport, full_text: str, head_only_text: str
142+
) -> str:
143+
et_rows = [
144+
r
145+
for r in report.groups_head
146+
if r.get("executorch") in EXECUTORCH_SOURCE_BUCKETS
147+
]
148+
et_total = sum(int(r["vmsize"]) for r in et_rows)
149+
lines = [
150+
f"## Bloaty: `{report.job}` / `{report.binary_name}`",
151+
"",
152+
f"- head sha: `{report.head_sha}`",
153+
f"- stripped head vm size: **{report.stripped_head:,} bytes**",
154+
f"- ExecuTorch source total (unstripped, bucketed, vm): **{et_total:,} bytes**",
155+
"",
156+
"### Per-bucket sizes (unstripped, all buckets)",
157+
"",
158+
render_table(report.groups_head, "executorch"),
159+
"",
160+
"<details><summary>Full bloaty output</summary>",
161+
"",
162+
"```",
163+
full_text.rstrip(),
164+
"```",
165+
"",
166+
"</details>",
167+
"",
168+
"<details><summary>Top ExecuTorch source symbols</summary>",
169+
"",
170+
"```",
171+
head_only_text.rstrip(),
172+
"```",
173+
"",
174+
"</details>",
175+
"",
176+
]
177+
return "\n".join(lines)
178+
179+
180+
def cmd_measure(args: argparse.Namespace) -> int:
181+
head = Path(args.head).resolve()
182+
if not head.exists():
183+
print(f"head ELF does not exist: {head}", file=sys.stderr)
184+
return 1
185+
186+
out_dir = Path(args.out).resolve()
187+
out_dir.mkdir(parents=True, exist_ok=True)
188+
189+
stripped = strip_copy(head, args.strip_tool)
190+
try:
191+
groups_head_stripped = run_bloaty(stripped, "executorch")
192+
finally:
193+
stripped.unlink(missing_ok=True)
194+
# VM size of the stripped binary — flash + RAM bytes the loader claims.
195+
# .bss adds to vm but not file, so this differs from `ls -la` for any
196+
# binary with statically-allocated buffers.
197+
stripped_size = sum(
198+
int(r["vmsize"]) for r in groups_head_stripped if r.get("executorch") != "TOTAL"
199+
)
200+
201+
segments_head = run_bloaty(head, "segments")
202+
sections_head = run_bloaty(head, "sections")
203+
groups_head = run_bloaty(head, "executorch")
204+
symbols_head = run_bloaty(head, "shortsymbols")
205+
206+
report = BinaryReport(
207+
job=args.job,
208+
binary_name=args.binary_name,
209+
head_sha=args.head_sha,
210+
stripped_head=stripped_size,
211+
segments_head=segments_head,
212+
sections_head=sections_head,
213+
groups_head=groups_head,
214+
groups_head_stripped=groups_head_stripped,
215+
symbols_head=symbols_head,
216+
)
217+
218+
atomic_write(out_dir / "metadata.json", json.dumps(asdict(report), indent=2))
219+
220+
# executorch first → groups all symbols by bucket; sections then symbols
221+
# show what's inside each. Skipping `segments` (uninformative at this level).
222+
full_text = bloaty_text(head, "executorch,sections,shortsymbols", top_n=30)
223+
# Filter the head-only top-symbols dump to ExecuTorch source buckets only,
224+
# so stdlib / libc / startup / metadata / other don't crowd it out.
225+
head_only_text = bloaty_text(
226+
head,
227+
"executorch,shortsymbols",
228+
top_n=30,
229+
source_filter="|".join(EXECUTORCH_SOURCE_BUCKETS),
230+
)
231+
atomic_write(out_dir / "full.txt", full_text)
232+
atomic_write(out_dir / "head_only.txt", head_only_text)
233+
234+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
235+
if summary_path:
236+
with open(summary_path, "a") as f:
237+
f.write(render_step_summary(report, full_text, head_only_text))
238+
239+
print(f"wrote {out_dir / 'metadata.json'}")
240+
print(f"stripped head vm size: {stripped_size:,} bytes")
241+
return 0
242+
243+
244+
def main(argv: Optional[List[str]] = None) -> int:
245+
parser = argparse.ArgumentParser(description=__doc__)
246+
sub = parser.add_subparsers(dest="cmd", required=True)
247+
248+
p_measure = sub.add_parser("measure", help="Measure an ELF with bloaty")
249+
p_measure.add_argument(
250+
"--head", required=True, help="Path to head (unstripped) ELF"
251+
)
252+
p_measure.add_argument("--job", required=True, help="CI job identifier")
253+
p_measure.add_argument(
254+
"--binary-name", required=True, help="Binary name (e.g. size_test)"
255+
)
256+
p_measure.add_argument(
257+
"--head-sha", required=True, help="Git SHA of the head commit"
258+
)
259+
p_measure.add_argument(
260+
"--strip-tool", default="strip", help="Strip tool (e.g. arm-none-eabi-strip)"
261+
)
262+
p_measure.add_argument("--out", required=True, help="Output directory")
263+
p_measure.set_defaults(func=cmd_measure)
264+
265+
args = parser.parse_args(argv)
266+
return args.func(args)
267+
268+
269+
if __name__ == "__main__":
270+
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)