Skip to content

Commit fd715af

Browse files
committed
Add code-size skill and integrate metrics_compare_base.py tool
- Introduced a `code-size` skill under `.claude/skills` for evaluating TinyUSB code size changes between the base branch and current branch. - Added `metrics_compare_base.py`, automating code size comparison with granular options for examples, boards, and CI-wide runs. - Updated `AGENTS.md` to include quick references and usage guidance for the new feature.
1 parent 47f2228 commit fd715af

4 files changed

Lines changed: 340 additions & 16 deletions

File tree

.claude/skills/code-size/SKILL.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
---
2+
name: code-size
3+
description: Use when comparing TinyUSB code size between a base ref (master by default) and the current branch to evaluate the size impact of changes. Three granularities — single example on one board (with optional bloaty), all examples on one board, or all examples across CI families combined.
4+
---
5+
6+
# Code Size Comparison
7+
8+
Compare TinyUSB code size between a base ref (default `master`) and the current branch using `tools/metrics_compare_base.py`. Three granularities — pick the narrowest one that exercises your change:
9+
10+
| Granularity | When to use | Command |
11+
|---|---|---|
12+
| **single example, one board** | Focused change touching one feature | `-b BOARD -e device/cdc_msc` |
13+
| **all examples, one board** | Per-board regression sweep | `-b BOARD` |
14+
| **all examples, all CI families (combined)** | Pre-merge full check | `--ci` |
15+
16+
The script handles the full base-vs-branch dance:
17+
1. Creates a temporary git worktree of the base ref under `cmake-metrics/_worktree/`.
18+
2. Builds the base in `cmake-metrics/<board>/base/`.
19+
3. Builds the current tree in `cmake-metrics/<board>/build/`.
20+
4. Runs `tools/metrics.py compare` and writes `cmake-metrics/<board>/metrics_compare.md`.
21+
5. Removes the worktree on exit.
22+
23+
`--combined` (auto-set by `--ci`) also produces `cmake-metrics/_combined/metrics_compare.md` aggregating across all boards.
24+
25+
## Choosing arguments
26+
27+
Infer from the user's request:
28+
29+
- **Board(s):** named board → `-b BOARD` (repeatable). "All boards" / "CI" / "full sweep" → `--ci` (first board of each arm-gcc family). Default to a fast board (`raspberry_pi_pico`) if unspecified for an iterative check.
30+
- **Example:** named example → `-e <group>/<name>` (e.g. `-e device/cdc_msc`). "All examples" → omit `-e`.
31+
- **Bloaty:** only with `-e`. Use when the user wants a section/symbol-level breakdown for a single binary.
32+
- **Base ref:** default `master`. Override with `--base-branch <ref>` (tag or commit also works).
33+
- **Filter:** default `tinyusb/src` (only counts TinyUSB stack code, not example/BSP). Change only if asked.
34+
35+
## Common invocations
36+
37+
```bash
38+
# Single example, one board (linkermap, fastest):
39+
python3 tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc
40+
41+
# Same with bloaty for section/symbol breakdown:
42+
python3 tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc --bloaty
43+
44+
# All examples for one board:
45+
python3 tools/metrics_compare_base.py -b raspberry_pi_pico
46+
47+
# Multiple boards, one combined report:
48+
python3 tools/metrics_compare_base.py -b raspberry_pi_pico -b raspberry_pi_pico2 --combined
49+
50+
# Full CI sweep (first board per arm-gcc family, combined):
51+
python3 tools/metrics_compare_base.py --ci
52+
53+
# Compare against a tag/commit instead of master:
54+
python3 tools/metrics_compare_base.py -b raspberry_pi_pico --base-branch v0.18.0
55+
```
56+
57+
## Outputs
58+
59+
- **Per-board:** `cmake-metrics/<board>/metrics_compare.md` (and `_<example>.md` when `-e` is set)
60+
- **Combined (with `--combined`/`--ci`):** `cmake-metrics/_combined/metrics_compare.md`
61+
- **Bloaty:** printed to stdout as section + symbol diffs
62+
63+
## Timing
64+
65+
- Single example, single board: ~30 s
66+
- All examples, single board: ~60-90 s
67+
- `--ci` (all arm-gcc families, first board each): 4-8 minutes (parallel build)
68+
69+
Use timeouts ≥ 10 minutes (600000 ms) for `--ci`.
70+
71+
## Reporting results
72+
73+
After running:
74+
- Show the markdown report's summary table to the user.
75+
- Highlight any rows with non-zero diff in `tinyusb/src` paths — those are the actual stack-size deltas.
76+
- If the diff is unexpected, follow up with a single-example `--bloaty` run to localize.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,4 @@ BrowseInfo
5656
.cmake_build
5757
README_processed.rst
5858
.worktrees
59+
cmake-metrics/

AGENTS.md

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -134,28 +134,23 @@ cd docs && sphinx-build -b html . _build # ~2.5 s
134134

135135
## Code Size Metrics
136136

137-
Verify size impact before committing.
137+
Verify size impact before committing. Invoke the `code-size` skill (`.claude/skills/code-size/SKILL.md`) — it wraps `tools/metrics_compare_base.py` to handle the base-vs-branch worktree + build + compare flow.
138138

139-
**Single-board (iterative, ~30 s):**
139+
Quick reference:
140140
```bash
141-
rm -rf cmake-build
142-
python3 tools/build.py -b raspberry_pi_pico --target all --target tinyusb_metrics
143-
python3 tools/metrics.py combine -j -m -f tinyusb/src cmake-build/cmake-build-*/metrics.json
144-
```
141+
# Single example, one board:
142+
python3 tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc
143+
# Add --bloaty for section/symbol breakdown.
145144

146-
**Compare vs master:** run the above on master, `mv metrics.json metrics_master.json`, switch branch, rebuild, then:
147-
```bash
148-
python3 tools/metrics.py compare -m -f tinyusb/src metrics_master.json metrics.json
149-
```
145+
# All examples, one board:
146+
python3 tools/metrics_compare_base.py -b raspberry_pi_pico
150147

151-
**Full CI (all arm-gcc families, 2-4 min):**
152-
```bash
153-
rm -rf cmake-build
154-
FAMILIES=$(python3 .github/workflows/ci_set_matrix.py | python3 -c "import sys,json;d=json.load(sys.stdin);print(' '.join(d.get('arm-gcc',[])))")
155-
python3 tools/build.py --one-first --target all --target tinyusb_metrics $FAMILIES
156-
python3 tools/metrics.py combine -j -m -f tinyusb/src cmake-build/cmake-build-*/metrics.json
148+
# All arm-gcc CI families combined (pre-merge sweep, 4-8 min):
149+
python3 tools/metrics_compare_base.py --ci
157150
```
158151

152+
Reports land in `cmake-metrics/<board>/metrics_compare.md` (per-board) and `cmake-metrics/_combined/metrics_compare.md` (with `--combined`/`--ci`).
153+
159154
## Static Analysis (PVS-Studio)
160155

161156
Requires `compile_commands.json` (CMake `-DCMAKE_EXPORT_COMPILE_COMMANDS=ON`).

tools/metrics_compare_base.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
#!/usr/bin/env python3
2+
"""Build base branch (master) and current tree, then compare code size metrics.
3+
4+
Creates cmake-metrics/<board>/{base,build} directories for each board.
5+
With --combined, also writes cmake-metrics/_combined/metrics_compare.md aggregating
6+
all boards into a single comparison.
7+
8+
Usage:
9+
python tools/metrics_compare_base.py -b raspberry_pi_pico
10+
python tools/metrics_compare_base.py -b raspberry_pi_pico -b raspberry_pi_pico2
11+
python tools/metrics_compare_base.py -b raspberry_pi_pico -f portable/raspberrypi
12+
python tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc
13+
python tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc --bloaty
14+
python tools/metrics_compare_base.py --ci # first board of each arm-gcc family, combined
15+
python tools/metrics_compare_base.py -b pico -b pico2 --combined # aggregate listed boards
16+
"""
17+
import argparse
18+
import glob
19+
import json
20+
import os
21+
import subprocess
22+
import sys
23+
24+
TINYUSB_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25+
METRICS_DIR = os.path.join(TINYUSB_ROOT, 'cmake-metrics')
26+
27+
verbose = False
28+
29+
30+
def run(cmd, **kwargs):
31+
if verbose:
32+
print(f' $ {cmd}')
33+
return subprocess.run(cmd, shell=True, capture_output=True, text=True, **kwargs)
34+
35+
36+
def ci_first_boards():
37+
"""Return the first board (alphabetical) of each arm-gcc CI family."""
38+
matrix_py = os.path.join(TINYUSB_ROOT, '.github', 'workflows', 'ci_set_matrix.py')
39+
if not os.path.isfile(matrix_py):
40+
return []
41+
ret = run(f'{sys.executable} {matrix_py}')
42+
if ret.returncode != 0:
43+
return []
44+
try:
45+
data = json.loads(ret.stdout)
46+
except json.JSONDecodeError:
47+
return []
48+
families = data.get('arm-gcc', [])
49+
boards = []
50+
bsp_root = os.path.join(TINYUSB_ROOT, 'hw', 'bsp')
51+
for family in families:
52+
family_boards = sorted(
53+
d for d in os.listdir(os.path.join(bsp_root, family, 'boards'))
54+
if os.path.isdir(os.path.join(bsp_root, family, 'boards', d))
55+
) if os.path.isdir(os.path.join(bsp_root, family, 'boards')) else []
56+
if family_boards:
57+
boards.append(family_boards[0])
58+
return boards
59+
60+
61+
def build_board(src_dir, build_dir, board, example=None):
62+
"""Configure and build examples for a board. Returns True on success."""
63+
os.makedirs(build_dir, exist_ok=True)
64+
ret = run(f'cmake -B {build_dir} -G Ninja -DBOARD={board} -DCMAKE_BUILD_TYPE=MinSizeRel '
65+
f'{os.path.join(src_dir, "examples")}')
66+
if ret.returncode != 0:
67+
print(f' Error configuring {board}: {ret.stderr}')
68+
return False
69+
target = f'--target {os.path.basename(example)}' if example else ''
70+
ret = run(f'cmake --build {build_dir} {target}', timeout=600)
71+
if ret.returncode != 0:
72+
print(f' Error building {board}: {ret.stderr}')
73+
return False
74+
return True
75+
76+
77+
def generate_metrics(build_dir, out_basename, filter_str, example=None):
78+
"""Run metrics.py combine on .map.json files. Returns metrics json path or None."""
79+
if example:
80+
patterns = glob.glob(f'{build_dir}/{example}/*.map.json')
81+
else:
82+
patterns = glob.glob(f'{build_dir}/**/*.map.json', recursive=True)
83+
if not patterns:
84+
print(f' Error: no .map.json files in {build_dir}' + (f' for {example}' if example else ''))
85+
return None
86+
87+
metrics_py = os.path.join(TINYUSB_ROOT, 'tools', 'metrics.py')
88+
ret = run(f'{sys.executable} {metrics_py} combine -f {filter_str} -j -q '
89+
f'-o {out_basename} {" ".join(patterns)}')
90+
if ret.returncode != 0:
91+
print(f' Error: {ret.stderr}')
92+
return None
93+
return f'{out_basename}.json'
94+
95+
96+
def main():
97+
global verbose
98+
99+
parser = argparse.ArgumentParser(description='Compare code size metrics with base branch')
100+
parser.add_argument('-b', '--board', action='append', default=[],
101+
help='Board name (repeatable). Required unless --ci is given.')
102+
parser.add_argument('-f', '--filter', default='tinyusb/src',
103+
help='Path filter for metrics (default: tinyusb/src)')
104+
parser.add_argument('--base-branch', default='master',
105+
help='Base branch to compare against (default: master)')
106+
parser.add_argument('-e', '--example', action='append', default=None,
107+
help='Compare specific example (repeatable, e.g. -e device/cdc_msc -e host/cdc_msc_hid)')
108+
parser.add_argument('--bloaty', action='store_true',
109+
help='Use bloaty for detailed section/symbol diff (requires -e)')
110+
parser.add_argument('--ci', action='store_true',
111+
help='Add the first board of every arm-gcc CI family. Implies --combined.')
112+
parser.add_argument('--combined', action='store_true',
113+
help='Aggregate map.json files across all boards into one comparison '
114+
'(in cmake-metrics/_combined/), instead of (or in addition to) per-board.')
115+
parser.add_argument('-v', '--verbose', action='store_true',
116+
help='Print build commands')
117+
args = parser.parse_args()
118+
verbose = args.verbose
119+
120+
if args.bloaty and not args.example:
121+
parser.error('--bloaty requires -e/--example')
122+
123+
if args.ci:
124+
args.combined = True
125+
ci_boards = ci_first_boards()
126+
if not ci_boards:
127+
parser.error('--ci: failed to derive boards from .github/workflows/ci_set_matrix.py')
128+
# Append, dedup, preserve order
129+
seen = set(args.board)
130+
for b in ci_boards:
131+
if b not in seen:
132+
args.board.append(b)
133+
seen.add(b)
134+
135+
if not args.board:
136+
parser.error('at least one -b BOARD is required (or pass --ci)')
137+
138+
metrics_py = os.path.join(TINYUSB_ROOT, 'tools', 'metrics.py')
139+
linkermap_dir = os.path.join(TINYUSB_ROOT, 'tools', 'linkermap')
140+
worktree_dir = os.path.join(METRICS_DIR, '_worktree')
141+
142+
# Step 1: Create worktree for base branch
143+
print(f'[1/5] Setting up {args.base_branch} worktree...')
144+
if os.path.isdir(worktree_dir):
145+
run(f'git -C {TINYUSB_ROOT} worktree remove --force {worktree_dir}')
146+
ret = run(f'git -C {TINYUSB_ROOT} worktree add {worktree_dir} {args.base_branch}')
147+
if ret.returncode != 0:
148+
print(f'Error creating worktree: {ret.stderr}')
149+
sys.exit(1)
150+
151+
# Ensure linkermap is available
152+
wt_linkermap = os.path.join(worktree_dir, 'tools', 'linkermap')
153+
if not os.path.exists(wt_linkermap) and os.path.exists(linkermap_dir):
154+
os.symlink(linkermap_dir, wt_linkermap)
155+
156+
try:
157+
examples = args.example or [None]
158+
# For --combined: track every (base_build, cur_build) pair so we can aggregate at the end.
159+
built_pairs = []
160+
161+
for board in args.board:
162+
print(f'\n=== {board} ===')
163+
board_dir = os.path.join(METRICS_DIR, board)
164+
base_build = os.path.join(board_dir, 'base')
165+
cur_build = os.path.join(board_dir, 'build')
166+
167+
# Step 2: Build base (all examples, cmake will skip already-built)
168+
print(f'[2/5] Building {args.base_branch} for {board}...')
169+
if not build_board(worktree_dir, base_build, board):
170+
continue
171+
172+
# Step 3: Build current
173+
print(f'[3/5] Building current for {board}...')
174+
if not build_board(TINYUSB_ROOT, cur_build, board):
175+
continue
176+
177+
built_pairs.append((board, base_build, cur_build))
178+
base_filter = args.filter.replace('tinyusb/', '', 1) if args.filter.startswith('tinyusb/') else args.filter
179+
180+
for example in examples:
181+
suffix = f'_{example.replace("/", "_")}' if example else ''
182+
label = f' ({example})' if example else ''
183+
184+
# Step 4: Generate metrics
185+
print(f'[4/5] Generating metrics for {board}{label}...')
186+
base_json = generate_metrics(base_build, os.path.join(board_dir, f'base_metrics{suffix}'),
187+
base_filter, example)
188+
cur_json = generate_metrics(cur_build, os.path.join(board_dir, f'build_metrics{suffix}'),
189+
args.filter, example)
190+
if not base_json or not cur_json:
191+
continue
192+
193+
# Step 5: Compare
194+
out_base = os.path.join(board_dir, f'metrics_compare{suffix}')
195+
print(f'[5/5] Comparing {board}{label}...')
196+
ret = run(f'{sys.executable} {metrics_py} compare -m -o {out_base} {base_json} {cur_json}')
197+
print(ret.stdout)
198+
199+
# Optional: bloaty diff
200+
if args.bloaty and example:
201+
elf_name = os.path.basename(example)
202+
base_elf = os.path.join(base_build, example, f'{elf_name}.elf')
203+
cur_elf = os.path.join(cur_build, example, f'{elf_name}.elf')
204+
if os.path.exists(base_elf) and os.path.exists(cur_elf):
205+
src_filter = f'--source-filter={args.filter}' if args.filter else ''
206+
print(f'--- bloaty sections ---')
207+
ret = run(f'bloaty --domain=vm -d compileunits,sections {src_filter} {cur_elf} -- {base_elf}')
208+
print(ret.stdout)
209+
print(f'--- bloaty symbols ---')
210+
ret = run(f'bloaty --domain=vm -d compileunits,symbols -s vm {src_filter} {cur_elf} -- {base_elf}')
211+
print(ret.stdout)
212+
else:
213+
print(f' bloaty: ELF not found')
214+
215+
# Optional combined comparison across all boards
216+
if args.combined and built_pairs:
217+
combined_dir = os.path.join(METRICS_DIR, '_combined')
218+
os.makedirs(combined_dir, exist_ok=True)
219+
base_filter = args.filter.replace('tinyusb/', '', 1) if args.filter.startswith('tinyusb/') else args.filter
220+
base_maps = []
221+
cur_maps = []
222+
for _board, base_build, cur_build in built_pairs:
223+
base_maps += glob.glob(f'{base_build}/**/*.map.json', recursive=True)
224+
cur_maps += glob.glob(f'{cur_build}/**/*.map.json', recursive=True)
225+
if not base_maps or not cur_maps:
226+
print(' combined: no map.json files collected, skipping')
227+
else:
228+
print(f'\n=== combined ({len(args.board)} boards) ===')
229+
base_out = os.path.join(combined_dir, 'base_metrics')
230+
cur_out = os.path.join(combined_dir, 'build_metrics')
231+
ret = run(f'{sys.executable} {metrics_py} combine -f {base_filter} -j -q '
232+
f'-o {base_out} {" ".join(base_maps)}')
233+
if ret.returncode != 0:
234+
print(f' combined base error: {ret.stderr}')
235+
else:
236+
ret = run(f'{sys.executable} {metrics_py} combine -f {args.filter} -j -q '
237+
f'-o {cur_out} {" ".join(cur_maps)}')
238+
if ret.returncode != 0:
239+
print(f' combined current error: {ret.stderr}')
240+
else:
241+
out_combined = os.path.join(combined_dir, 'metrics_compare')
242+
ret = run(f'{sys.executable} {metrics_py} compare -m '
243+
f'-o {out_combined} {base_out}.json {cur_out}.json')
244+
print(ret.stdout)
245+
print(f' combined report: {out_combined}.md')
246+
finally:
247+
print(f'\nCleaning up worktree...')
248+
run(f'git -C {TINYUSB_ROOT} worktree remove --force {worktree_dir}')
249+
250+
251+
if __name__ == '__main__':
252+
main()

0 commit comments

Comments
 (0)