| #!/usr/bin/env python3 |
| """Build base branch (master) and current tree, then compare code size metrics. |
| |
| Creates cmake-metrics/<board>/{base,build} directories for each board. |
| With --combined, also writes cmake-metrics/_combined/metrics_compare.md aggregating |
| all boards into a single comparison. |
| |
| Usage: |
| python tools/metrics_compare_base.py -b raspberry_pi_pico |
| python tools/metrics_compare_base.py -b raspberry_pi_pico -b raspberry_pi_pico2 |
| python tools/metrics_compare_base.py -b raspberry_pi_pico -f portable/raspberrypi |
| python tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc |
| python tools/metrics_compare_base.py -b raspberry_pi_pico -e device/cdc_msc --bloaty |
| python tools/metrics_compare_base.py --ci # first board of each arm-gcc family, combined |
| python tools/metrics_compare_base.py -b pico -b pico2 --combined # aggregate listed boards |
| """ |
| import argparse |
| import glob |
| import json |
| import os |
| import re |
| import shlex |
| import subprocess |
| import sys |
| |
| TINYUSB_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| METRICS_DIR = os.path.join(TINYUSB_ROOT, 'cmake-metrics') |
| |
| def tinyusb_src_filter(checkout_dir): |
| """Return a path-substring filter that uniquely matches TinyUSB stack source files |
| in `checkout_dir`. The substring is the absolute path to the checkout's `src/` |
| dir — collision-free with vendored deps (pico-sdk, lwip, FreeRTOS, etc.) which |
| live at unrelated paths.""" |
| return os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep |
| |
| verbose = False |
| |
| |
| def run(cmd, **kwargs): |
| """Run a command. cmd must be a list (no shell=True). On `timeout=`-induced |
| TimeoutExpired, return a CompletedProcess with rc=124 instead of letting the |
| exception propagate, so the caller can fall through to error reporting and |
| worktree cleanup rather than crashing with a traceback.""" |
| if not isinstance(cmd, list): |
| raise TypeError('run() requires a list, got str — fix the caller') |
| if verbose: |
| print(f' $ {" ".join(shlex.quote(str(c)) for c in cmd)}') |
| try: |
| return subprocess.run(cmd, capture_output=True, text=True, **kwargs) |
| except subprocess.TimeoutExpired as e: |
| msg = f'Command timed out after {e.timeout}s: {" ".join(shlex.quote(str(c)) for c in cmd)}' |
| stderr = (e.stderr or '') + ('\n' if e.stderr else '') + msg |
| return subprocess.CompletedProcess(cmd, 124, stdout=(e.stdout or ''), stderr=stderr) |
| |
| |
| def symlink_deps(main_root, worktree_dir): |
| """Symlink dependency directories (fetched by tools/get_deps.py) from the main |
| checkout into the temporary worktree. Without this, the base build fails because |
| the worktree doesn't have the untracked deps.""" |
| def link_subdirs(rel_parent): |
| src_parent = os.path.join(main_root, rel_parent) |
| dst_parent = os.path.join(worktree_dir, rel_parent) |
| if not os.path.isdir(src_parent): |
| return |
| os.makedirs(dst_parent, exist_ok=True) |
| for entry in os.listdir(src_parent): |
| src = os.path.join(src_parent, entry) |
| dst = os.path.join(dst_parent, entry) |
| if os.path.isdir(src) and not os.path.exists(dst): |
| os.symlink(src, dst) |
| |
| # lib/* and tools/* deps (e.g. lib/lwip, tools/linkermap) |
| link_subdirs('lib') |
| link_subdirs('tools') |
| # hw/mcu/<vendor>/<dep> (e.g. hw/mcu/raspberry_pi/Pico-PIO-USB) |
| hw_mcu = os.path.join(main_root, 'hw', 'mcu') |
| if os.path.isdir(hw_mcu): |
| for vendor in os.listdir(hw_mcu): |
| link_subdirs(os.path.join('hw', 'mcu', vendor)) |
| |
| |
| def ci_first_boards(): |
| """Return the first board (alphabetical) of each arm-gcc CI family.""" |
| matrix_py = os.path.join(TINYUSB_ROOT, '.github', 'workflows', 'ci_set_matrix.py') |
| if not os.path.isfile(matrix_py): |
| return [] |
| ret = run([sys.executable, matrix_py]) |
| if ret.returncode != 0: |
| return [] |
| try: |
| data = json.loads(ret.stdout) |
| except json.JSONDecodeError: |
| return [] |
| families = data.get('arm-gcc', []) |
| boards = [] |
| bsp_root = os.path.join(TINYUSB_ROOT, 'hw', 'bsp') |
| for family in families: |
| family_boards = sorted( |
| d for d in os.listdir(os.path.join(bsp_root, family, 'boards')) |
| if os.path.isdir(os.path.join(bsp_root, family, 'boards', d)) |
| ) if os.path.isdir(os.path.join(bsp_root, family, 'boards')) else [] |
| if family_boards: |
| boards.append(family_boards[0]) |
| return boards |
| |
| |
| def build_board(src_dir, build_dir, board, example=None): |
| """Configure and build examples for a board. Returns True on success. |
| |
| When `example` is given, only that target is built (`cmake --build --target NAME`), |
| keeping single-example workflows fast. |
| """ |
| os.makedirs(build_dir, exist_ok=True) |
| ret = run(['cmake', '-B', build_dir, '-G', 'Ninja', |
| f'-DBOARD={board}', '-DCMAKE_BUILD_TYPE=MinSizeRel', |
| os.path.join(src_dir, 'examples')]) |
| if ret.returncode != 0: |
| print(f' Error configuring {board}: {ret.stderr}') |
| return False |
| cmd = ['cmake', '--build', build_dir] |
| if example: |
| cmd += ['--target', os.path.basename(example)] |
| ret = run(cmd, timeout=600) |
| if ret.returncode != 0: |
| print(f' Error building {board}: {ret.stderr}') |
| return False |
| return True |
| |
| |
| def generate_metrics(build_dir, out_basename, filters, example=None): |
| """Run metrics.py combine on .map.json files. Returns metrics json path or None. |
| |
| `filters` is a list of substrings; metrics.py keeps a compile unit if its path |
| contains any of them. |
| """ |
| if example: |
| patterns = glob.glob(f'{build_dir}/{example}/*.map.json') |
| else: |
| patterns = glob.glob(f'{build_dir}/**/*.map.json', recursive=True) |
| if not patterns: |
| print(f' Error: no .map.json files in {build_dir}' + (f' for {example}' if example else '')) |
| return None |
| |
| metrics_py = os.path.join(TINYUSB_ROOT, 'tools', 'metrics.py') |
| cmd = [sys.executable, metrics_py, 'combine'] |
| for f in filters: |
| cmd += ['-f', f] |
| cmd += ['-j', '-q', '-o', out_basename, *patterns] |
| ret = run(cmd) |
| if ret.returncode != 0: |
| print(f' Error: {ret.stderr}') |
| return None |
| return f'{out_basename}.json' |
| |
| |
| def main(): |
| global verbose |
| |
| parser = argparse.ArgumentParser(description='Compare code size metrics with base branch') |
| parser.add_argument('-b', '--board', action='append', default=[], |
| help='Board name (repeatable). Required unless --ci is given.') |
| parser.add_argument('-f', '--filter', action='append', default=None, |
| help='Path-substring filter (repeatable). When given, ' |
| 'overrides the default and is applied to BOTH base and ' |
| 'current builds. Default: each side\'s own absolute ' |
| '<checkout>/src/ path, which uniquely matches TinyUSB ' |
| 'stack code without colliding with vendored deps.') |
| parser.add_argument('--base-branch', default='master', |
| help='Base branch to compare against (default: master)') |
| parser.add_argument('-e', '--example', action='append', default=None, |
| help='Compare specific example (repeatable, e.g. -e device/cdc_msc -e host/cdc_msc_hid)') |
| parser.add_argument('--bloaty', action='store_true', |
| help='Use bloaty for detailed section/symbol diff (requires -e)') |
| parser.add_argument('--ci', action='store_true', |
| help='Add the first board of every arm-gcc CI family. Implies --combined.') |
| parser.add_argument('--combined', action='store_true', |
| help='Aggregate map.json files across all boards into one comparison ' |
| '(in cmake-metrics/_combined/), instead of (or in addition to) per-board.') |
| parser.add_argument('-v', '--verbose', action='store_true', |
| help='Print build commands') |
| args = parser.parse_args() |
| verbose = args.verbose |
| |
| if args.bloaty and not args.example: |
| parser.error('--bloaty requires -e/--example') |
| |
| if args.ci: |
| args.combined = True |
| ci_boards = ci_first_boards() |
| if not ci_boards: |
| parser.error('--ci: failed to derive boards from .github/workflows/ci_set_matrix.py') |
| # Append, dedup, preserve order |
| seen = set(args.board) |
| for b in ci_boards: |
| if b not in seen: |
| args.board.append(b) |
| seen.add(b) |
| |
| if not args.board: |
| parser.error('at least one -b BOARD is required (or pass --ci)') |
| |
| metrics_py = os.path.join(TINYUSB_ROOT, 'tools', 'metrics.py') |
| worktree_dir = os.path.join(METRICS_DIR, '_worktree') |
| |
| # Per-side filters: when no override is given, each build uses its own |
| # absolute <checkout>/src/ path so we only match TinyUSB stack code from that |
| # checkout (and never vendored-dep `src/` like pico-sdk/src/...). |
| if args.filter: |
| base_filters = cur_filters = list(args.filter) |
| else: |
| base_filters = [tinyusb_src_filter(worktree_dir)] |
| cur_filters = [tinyusb_src_filter(TINYUSB_ROOT)] |
| |
| # Step 1: Create worktree for base branch |
| print(f'[1/5] Setting up {args.base_branch} worktree...') |
| if os.path.isdir(worktree_dir): |
| run(['git', '-C', TINYUSB_ROOT, 'worktree', 'remove', '--force', worktree_dir]) |
| # --detach: check out the ref at a detached HEAD instead of trying to claim the |
| # branch. Lets us add a worktree of `master` even if master is already checked |
| # out elsewhere (main repo, another worktree). |
| ret = run(['git', '-C', TINYUSB_ROOT, 'worktree', 'add', '--detach', |
| worktree_dir, args.base_branch]) |
| if ret.returncode != 0: |
| print(f'Error creating worktree: {ret.stderr}') |
| sys.exit(1) |
| |
| # Symlink dependency dirs (lib/*, hw/mcu/*/*, tools/*) so the worktree builds. |
| symlink_deps(TINYUSB_ROOT, worktree_dir) |
| |
| try: |
| examples = args.example or [None] |
| # For --combined: track every (base_build, cur_build) pair so we can aggregate at the end. |
| built_pairs = [] |
| |
| for board in args.board: |
| print(f'\n=== {board} ===') |
| board_dir = os.path.join(METRICS_DIR, board) |
| base_build = os.path.join(board_dir, 'base') |
| cur_build = os.path.join(board_dir, 'build') |
| |
| # Build only the requested examples (or all if -e not given). Single-example |
| # mode used to build everything and filter at metric time — that was wasted work. |
| board_failed = False |
| for example in examples: |
| build_label = f' --target {os.path.basename(example)}' if example else '' |
| print(f'[2/5] Building {args.base_branch} for {board}{build_label}...') |
| if not build_board(worktree_dir, base_build, board, example): |
| board_failed = True |
| break |
| print(f'[3/5] Building current for {board}{build_label}...') |
| if not build_board(TINYUSB_ROOT, cur_build, board, example): |
| board_failed = True |
| break |
| if board_failed: |
| continue |
| |
| built_pairs.append((board, base_build, cur_build)) |
| |
| for example in examples: |
| suffix = f'_{example.replace("/", "_")}' if example else '' |
| label = f' ({example})' if example else '' |
| |
| # Step 4: Generate metrics |
| print(f'[4/5] Generating metrics for {board}{label}...') |
| base_json = generate_metrics(base_build, os.path.join(board_dir, f'base_metrics{suffix}'), |
| base_filters, example) |
| cur_json = generate_metrics(cur_build, os.path.join(board_dir, f'build_metrics{suffix}'), |
| cur_filters, example) |
| if not base_json or not cur_json: |
| continue |
| |
| # Step 5: Compare |
| out_base = os.path.join(board_dir, f'metrics_compare{suffix}') |
| print(f'[5/5] Comparing {board}{label}...') |
| ret = run([sys.executable, metrics_py, 'compare', '-m', '-o', out_base, base_json, cur_json]) |
| print(ret.stdout) |
| |
| # Optional: bloaty diff |
| if args.bloaty and example: |
| elf_name = os.path.basename(example) |
| base_elf = os.path.join(base_build, example, f'{elf_name}.elf') |
| cur_elf = os.path.join(cur_build, example, f'{elf_name}.elf') |
| if os.path.exists(base_elf) and os.path.exists(cur_elf): |
| # Bloaty expects one regex; OR-join all filters (current side |
| # for the new ELF, base side for the base ELF). |
| bloaty_regex = '(' + '|'.join( |
| re.escape(f) for f in (cur_filters + base_filters) |
| ) + ')' |
| bloaty_common = ['bloaty', '--domain=vm', f'--source-filter={bloaty_regex}'] |
| print(f'--- bloaty sections ---') |
| ret = run(bloaty_common + ['-d', 'compileunits,sections', cur_elf, '--', base_elf]) |
| print(ret.stdout) |
| print(f'--- bloaty symbols ---') |
| ret = run(bloaty_common + ['-d', 'compileunits,symbols', '-s', 'vm', |
| cur_elf, '--', base_elf]) |
| print(ret.stdout) |
| else: |
| print(f' bloaty: ELF not found') |
| |
| # Optional combined comparison across all boards. |
| # Aggregates the per-board metrics JSONs (not raw map.json globs) so the argv |
| # stays small even with --ci spanning many boards. |
| if args.combined and built_pairs: |
| combined_dir = os.path.join(METRICS_DIR, '_combined') |
| os.makedirs(combined_dir, exist_ok=True) |
| |
| # Use the no-suffix per-board JSONs (whole-board metrics). Combined mode |
| # is meant for board-level sweeps; -e/--example combinations skip combined. |
| base_jsons, cur_jsons = [], [] |
| for board, _, _ in built_pairs: |
| bj = os.path.join(METRICS_DIR, board, 'base_metrics.json') |
| cj = os.path.join(METRICS_DIR, board, 'build_metrics.json') |
| if os.path.isfile(bj) and os.path.isfile(cj): |
| base_jsons.append(bj) |
| cur_jsons.append(cj) |
| |
| if not base_jsons or not cur_jsons: |
| print(' combined: no per-board metrics found (did you pass -e? skip --combined with -e)') |
| else: |
| print(f'\n=== combined ({len(base_jsons)} boards) ===') |
| base_out = os.path.join(combined_dir, 'base_metrics') |
| cur_out = os.path.join(combined_dir, 'build_metrics') |
| |
| # Per-board JSONs are already filtered to TinyUSB-only files; combine |
| # without re-filtering so we don't accidentally drop entries. |
| def _combine(out_basename, inputs): |
| cmd = [sys.executable, metrics_py, 'combine', |
| '-j', '-q', '-o', out_basename, *inputs] |
| return run(cmd) |
| |
| ret = _combine(base_out, base_jsons) |
| if ret.returncode != 0: |
| print(f' combined base error: {ret.stderr}') |
| else: |
| ret = _combine(cur_out, cur_jsons) |
| if ret.returncode != 0: |
| print(f' combined current error: {ret.stderr}') |
| else: |
| out_combined = os.path.join(combined_dir, 'metrics_compare') |
| ret = run([sys.executable, metrics_py, 'compare', '-m', |
| '-o', out_combined, f'{base_out}.json', f'{cur_out}.json']) |
| print(ret.stdout) |
| print(f' combined report: {out_combined}.md') |
| finally: |
| print(f'\nCleaning up worktree...') |
| run(['git', '-C', TINYUSB_ROOT, 'worktree', 'remove', '--force', worktree_dir]) |
| |
| |
| if __name__ == '__main__': |
| main() |