| #!/usr/bin/env python3 |
| """Code size of TinyUSB examples. |
| |
| `report` builds the working tree in cmake-code-size/<board>/build and tabulates each elf's |
| per-file section sizes in cmake-code-size/<board>/report[_<ex>].md. |
| |
| `diff` builds the current tree in cmake-code-size/<board>/build and takes the base branch's |
| (master) sizes from CI's stored snapshots, else builds it in cmake-code-size/<board>/base |
| (--base-source); it pairs the elfs by (board, elf path) and reports |
| each pair's per-file flash/RAM deltas in cmake-code-size/<board>/diff[_<ex>].md; with |
| --combined, cmake-code-size/_combined/diff.md covers every board's pairs. |
| |
| The sizes come from --engine; each ENGINES entry maps (elf, filters) to |
| {'files': {key: {'flash', 'ram'}}, 'all': {'flash', 'ram'}, 'sections': {key: {section: |
| size}}, 'symbols': {key: {section: {name: size}}}}, keyed by the source path after the |
| matching filter, the same for every engine. |
| - membrowse: `membrowse report` symbols. Membrowse 1.2.9 truncates `source_file`, |
| so paths come from `object_file`. |
| - linkermap: input sections of the elf's GNU ld map, by object path; its symbols are |
| those input sections. |
| - bloaty: `bloaty -d compileunits,sections,symbols` VM sizes, by DWARF compile unit. |
| Every engine takes flash/RAM from the elf's section and program headers |
| (section_buckets()). |
| |
| Usage: |
| python tools/code_size.py report -b raspberry_pi_pico -e device/cdc_msc --symbols |
| python tools/code_size.py diff -b raspberry_pi_pico |
| python tools/code_size.py diff -b raspberry_pi_pico -b raspberry_pi_pico2 |
| python tools/code_size.py diff -b raspberry_pi_pico -f portable/raspberrypi |
| python tools/code_size.py diff -b raspberry_pi_pico -e device/cdc_msc |
| python tools/code_size.py diff -b raspberry_pi_pico -e device/cdc_msc --bloaty |
| python tools/code_size.py diff -b raspberry_pi_pico --engine linkermap --json |
| python tools/code_size.py diff --ci # CI-pinned boards, combined |
| python tools/code_size.py diff -b raspberry_pi_pico -b raspberry_pi_pico2 --combined # combine listed boards |
| """ |
| import argparse |
| import collections |
| import concurrent.futures |
| import contextlib |
| import csv |
| import functools |
| import glob |
| import importlib.util |
| import io |
| import json |
| import os |
| import re |
| import runpy |
| import shlex |
| import shutil |
| import signal |
| import struct |
| import subprocess |
| import sys |
| import time |
| |
| import build_utils |
| from membrowse_cli import (IDF_LD_SCRIPTS, NINJA_UTF8_REMEDY, extract_ld_scripts, extract_defsyms, |
| link_command, report_inputs) |
| |
| # resolved like tinyusb_src_filter(), so a symlinked checkout still matches its filter |
| TINYUSB_ROOT = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) |
| CODE_SIZE_DIR = os.path.join(TINYUSB_ROOT, 'cmake-code-size') |
| WINDOWS = os.name == 'nt' |
| # a master run's snapshots are the same for every checkout; XDG ignores a relative XDG_CACHE_HOME |
| _cache_home = os.environ.get('LOCALAPPDATA' if WINDOWS else 'XDG_CACHE_HOME', '') |
| BASELINE_CACHE_DIR = os.path.join(_cache_home if os.path.isabs(_cache_home) else os.path.expanduser('~/.cache'), |
| 'tinyusb', 'code-size-baseline') |
| CI_PINNED_BOARDS = os.path.join(TINYUSB_ROOT, '.github', 'ci-pinned-boards.json') |
| # a diff's side names when git cannot give their commit hashes |
| SIDE_LABELS = ('base', 'new') |
| _RAM_REGION_HINTS = ('ram', 'tcm', 'ddr') |
| _FLASH_REGION_HINTS = ('flash', 'rom') |
| |
| |
| def _find_ninja_build_dir(elf_path): |
| """Nearest ancestor of `elf_path` containing build.ninja, or None.""" |
| d = os.path.dirname(os.path.abspath(elf_path)) |
| while True: |
| if os.path.isfile(os.path.join(d, 'build.ninja')): |
| return d |
| parent = os.path.dirname(d) |
| if parent == d: |
| return None |
| d = parent |
| |
| |
| def _link_settings(elf_path): |
| """Return the ELF's build dir, and the linker scripts and defsyms of its link.""" |
| build_dir = _find_ninja_build_dir(elf_path) |
| if build_dir is None: |
| raise RuntimeError(f'no build.ninja found above {elf_path} - cannot ' |
| f'determine its linker scripts') |
| commands = link_command('ninja', build_dir, elf_path) |
| # ESP-IDF links its generated scripts by bare name via -L |
| idf_ld = [os.path.join(build_dir, p) for p in IDF_LD_SCRIPTS] |
| if any(map(os.path.isfile, idf_ld)): |
| if not all(map(os.path.isfile, idf_ld)): |
| raise RuntimeError(f'ESP-IDF build {build_dir} lacks one of {", ".join(idf_ld)}') |
| return build_dir, idf_ld, extract_defsyms(commands) |
| ld_scripts = extract_ld_scripts(commands) |
| if not ld_scripts: |
| raise RuntimeError(f'no linker script found in the ninja build graph ' |
| f'for {elf_path}') |
| return build_dir, ld_scripts, extract_defsyms(commands) |
| |
| |
| def report_for_elf(elf_path): |
| """Run membrowse local report on one elf, return parsed JSON dict.""" |
| build_dir, ld_scripts, defsyms = _link_settings(elf_path) |
| cmd = (['membrowse', 'report'] + report_inputs(elf_path, ld_scripts, defsyms) |
| + ['--json', '--all-symbols']) |
| # from the link's working dir, as membrowse_cli.report() does |
| r = subprocess.run(cmd, capture_output=True, text=True, cwd=build_dir) |
| if r.returncode != 0: |
| raise RuntimeError(f'membrowse report failed for {elf_path}: {r.stderr}') |
| try: |
| return json.loads(r.stdout) |
| except json.JSONDecodeError as e: |
| raise RuntimeError(f'malformed membrowse report for {elf_path}: {e}') from e |
| |
| |
| def _classify_region(name): |
| """Classify by region name; membrowse 1.2.9 reports parsed regions as UNKNOWN.""" |
| n = (name or '').lower() |
| if any(h in n for h in _FLASH_REGION_HINTS): |
| return 'flash' |
| if any(h in n for h in _RAM_REGION_HINTS): |
| return 'ram' |
| return None |
| |
| |
| def _relative_key(src, filters): |
| """Source path relative to the first matching filter, object suffix stripped, |
| or None when no filter matches.""" |
| if WINDOWS: |
| src = src.replace('\\', '/') |
| for f in filters: |
| # Windows paths are case-insensitive; the key keeps the recorded case |
| m = re.search(re.escape(f), src, re.IGNORECASE if WINDOWS else 0) |
| if not m: |
| continue |
| key = src[m.end():] |
| for suffix in ('.obj', '.o'): |
| if key.endswith(suffix): |
| return key[:-len(suffix)] |
| return key |
| return None |
| |
| |
| SHT_NOBITS = 8 |
| SHT_ARRAYS = (14, 15, 16) # INIT_ARRAY, FINI_ARRAY, PREINIT_ARRAY |
| SHF_WRITE, SHF_ALLOC = 0x1, 0x2 |
| PT_LOAD = 1 |
| _NOT_COUNTED = frozenset() |
| |
| |
| def elf_layout(path): |
| """Section headers [(name, type, flags, addr, size)] and PT_LOAD segments |
| [(vaddr, paddr, memsz)] of an ELF32/64 file of either byte order. Raises |
| RuntimeError for an unreadable or malformed file.""" |
| try: |
| with open(path, 'rb') as f: |
| data = f.read() |
| if data[:4] != b'\x7fELF': |
| raise RuntimeError(f'{path} is not an ELF file') |
| return _elf_layout(data) |
| except (OSError, struct.error, IndexError, ValueError, UnicodeDecodeError) as e: |
| raise RuntimeError(f'cannot read ELF headers of {path}: {e}') from e |
| |
| |
| def _elf_layout(data): |
| e = '<' if data[5] == 1 else '>' |
| if data[4] == 2: |
| phoff, shoff = struct.unpack_from(e + 'QQ', data, 0x20) |
| phentsize, phnum, shentsize, shnum, shstrndx = struct.unpack_from(e + '5H', data, 0x36) |
| phdr = lambda o: struct.unpack_from(e + 'II6Q', data, o) # noqa: E731 |
| load = lambda p: (p[3], p[4], p[6]) # noqa: E731 |
| shdr = lambda o: struct.unpack_from(e + 'IIQQQQIIQQ', data, o) # noqa: E731 |
| else: |
| phoff, shoff = struct.unpack_from(e + 'II', data, 0x1c) |
| phentsize, phnum, shentsize, shnum, shstrndx = struct.unpack_from(e + '5H', data, 0x2a) |
| phdr = lambda o: struct.unpack_from(e + '8I', data, o) # noqa: E731 |
| load = lambda p: (p[2], p[3], p[5]) # noqa: E731 |
| shdr = lambda o: struct.unpack_from(e + '10I', data, o) # noqa: E731 |
| loads = [load(p) for p in (phdr(phoff + i * phentsize) for i in range(phnum)) if p[0] == PT_LOAD] |
| headers = [shdr(shoff + i * shentsize) for i in range(shnum)] |
| strtab = headers[shstrndx][4] |
| |
| def name(n): |
| return data[strtab + n:data.index(b'\0', strtab + n)].decode() |
| return [(name(h[0]), h[1], h[2], h[3], h[5]) for h in headers[1:]], loads |
| |
| |
| def _map_path(elf): |
| """The elf's GNU ld map: `<elf>.map`, or ESP-IDF's `<name>.map` beside it.""" |
| idf = os.path.splitext(elf)[0] + '.map' |
| return idf if not os.path.isfile(elf + '.map') and os.path.isfile(idf) else elf + '.map' |
| |
| |
| def map_regions(map_path): |
| """[(name, origin, length)] of a GNU ld map's Memory Configuration.""" |
| if not os.path.isfile(map_path): |
| raise RuntimeError(f'no linker map {map_path}') |
| regions, inside = [], False |
| with open(map_path, encoding='utf-8', errors='replace') as f: |
| for line in f: |
| if line.startswith('Memory Configuration'): |
| inside = True |
| elif line.startswith('Linker script and memory map'): |
| break |
| elif inside: |
| m = re.match(r'(\S+)\s+0x([0-9a-fA-F]+)\s+0x([0-9a-fA-F]+)', line) |
| if m and m.group(1) != '*default*': |
| regions.append((m.group(1), int(m.group(2), 16), int(m.group(3), 16))) |
| return regions |
| |
| |
| def section_buckets(elf, map_path): |
| """{section: flash/RAM bucket set} for every section of `elf`, empty when not |
| allocated. A file-backed section loaded from elsewhere is a flash copy plus its |
| run location; one running where it loads is flash unless writable, when the |
| map region holding it decides. Raises RuntimeError rather than guess.""" |
| sections, loads = elf_layout(elf) |
| if any(s[0] == '.flash.appdesc' for s in sections): |
| return _esp_idf_buckets(sections) |
| regions = map_regions(map_path) |
| buckets = {} |
| for name, sh_type, flags, addr, size in sections: |
| if not flags & SHF_ALLOC: |
| buckets[name] = _NOT_COUNTED |
| continue |
| seg = next((s for s in loads if s[0] <= addr < s[0] + s[2]), None) |
| if seg is None: |
| if size: |
| raise RuntimeError(f'{elf}: allocated section {name} lies in no PT_LOAD segment') |
| buckets[name] = _NOT_COUNTED |
| continue |
| if sh_type == SHT_NOBITS: |
| buckets[name] = frozenset({'ram'}) |
| continue |
| kinds = {_classify_region(r) for r, origin, length in regions if origin <= addr < origin + length} |
| if seg[1] + addr - seg[0] != addr: |
| # a run address in flash is an alias (xmc4500 cached/uncached), not a RAM copy |
| buckets[name] = frozenset({'flash'}) if kinds == {'flash'} else frozenset({'flash', 'ram'}) |
| elif not flags & SHF_WRITE: |
| buckets[name] = frozenset({'flash'}) |
| elif len(kinds) == 1 and None not in kinds: |
| buckets[name] = frozenset(kinds) # RAM-only images (raspberrypi_zero .data) |
| elif sh_type in SHT_ARRAYS: |
| buckets[name] = frozenset({'flash'}) # NXP .init_array in m_text |
| else: |
| raise RuntimeError(f'{elf}: writable section {name} at {addr:#x} runs where it ' |
| f'loads, in no map region recognized as flash or RAM') |
| return buckets |
| |
| |
| def _esp_idf_buckets(sections): |
| """section_buckets() of an ESP-IDF app (it has `.flash.appdesc`), whose ELF runs every |
| section where it loads. IDF's sections.ld names each output section in a flash-mapped region |
| `.flash*`; esptool's elf2image stores the rest in the image too, for the bootloader to load |
| into RAM (bin_image.py is_flash_addr()).""" |
| buckets = {} |
| for name, sh_type, flags, _addr, _size in sections: |
| if not flags & SHF_ALLOC or (sh_type == SHT_NOBITS and name.startswith('.flash')): |
| buckets[name] = _NOT_COUNTED # reserved flash-window address space, not storage |
| elif sh_type == SHT_NOBITS: |
| buckets[name] = frozenset({'ram'}) |
| elif name.startswith('.flash'): |
| buckets[name] = frozenset({'flash'}) |
| else: |
| buckets[name] = frozenset({'flash', 'ram'}) |
| return buckets |
| |
| |
| # CMake replaces the start of an over-long object name with its md5 (cmLocalGenerator.cxx, |
| # cmLocalGeneratorShortenObjectName): <target>.dir/<md5>/<rest of the source path>.obj |
| _SHORTENED_OBJECT_RE = re.compile(r'\.dir/[0-9a-f]{32}/') |
| # first output, rule and first input of a build.ninja edge; '$' escapes a path's ':' and ' ' |
| _NINJA_EDGE_RE = re.compile(r'build ((?:[^$:\s]|\$.)+)(?: (?:[^$:]|\$.)*)?: \S+ ((?:[^$\s]|\$.)+)') |
| |
| |
| @functools.lru_cache(maxsize=None) |
| def _shortened_object_sources(build_dir): |
| """{shortened object: source} of the compile edges in build_dir's build.ninja.""" |
| path = os.path.join(build_dir, 'build.ninja') |
| sources = {} |
| try: |
| with open(path, encoding='utf-8') as f: |
| for line in f: |
| m = _NINJA_EDGE_RE.match(line) |
| if m and _SHORTENED_OBJECT_RE.search(m[1]): |
| obj, src = (re.sub(r'\$(.)', r'\1', p).replace('\\', '/') for p in m.groups()) |
| sources[obj] = src |
| except OSError as e: |
| raise RuntimeError(f'cannot read {path}: {e}') from e |
| except UnicodeDecodeError as e: |
| raise RuntimeError(f'{path} is not UTF-8: {NINJA_UTF8_REMEDY}') from e |
| return sources |
| |
| |
| class _Sizes: |
| """Accumulates one elf's filtered per-file flash/RAM, section and symbol sizes, |
| and its total flash/RAM.""" |
| def __init__(self, filters, elf): |
| self.filters, self.elf, self.files, self.all = filters, elf, {}, {'flash': 0, 'ram': 0} |
| self.sections, self.symbols = {}, {} |
| |
| def _shortened_key(self, path): |
| """Key of the source build.ninja compiles to a CMake-shortened object; the object |
| path no longer holds the filtered dir.""" |
| shortened = f'{path}: CMake shortened this object path to fit CMAKE_OBJECT_PATH_MAX (250 on Windows) and' |
| build_dir = _find_ninja_build_dir(self.elf) |
| if build_dir is None: |
| raise RuntimeError(f'{shortened} no build.ninja was found above {self.elf} to name its source - ' |
| 'use a shorter checkout path') |
| src = _shortened_object_sources(build_dir).get(path.replace('\\', '/')) |
| if not src: |
| raise RuntimeError(f'{shortened} the build.ninja above {self.elf} has no compile edge for it ' |
| 'to name its source - use a shorter checkout path') |
| return _relative_key(src, self.filters) |
| |
| def add(self, path, section, buckets, size, name): |
| if path and WINDOWS and _SHORTENED_OBJECT_RE.search(path.replace('\\', '/')): |
| key = self._shortened_key(path) # a filter could match the kept tail of the source path |
| else: |
| key = _relative_key(path, self.filters) if path else None |
| for b in buckets: |
| self.all[b] += size |
| if key is not None: |
| self.files.setdefault(key, {'flash': 0, 'ram': 0})[b] += size |
| if key is not None and buckets: |
| per_file = self.sections.setdefault(key, {}) |
| per_file[section] = per_file.get(section, 0) + size |
| per_section = self.symbols.setdefault(key, {}).setdefault(section, {}) |
| per_section[name] = per_section.get(name, 0) + size |
| |
| def result(self): |
| return {'files': self.files, 'all': self.all, 'sections': self.sections, |
| 'symbols': self.symbols} |
| |
| |
| @functools.lru_cache(maxsize=None) |
| def _dwarf_sources(elf): |
| """{basename: {full source path}} of the elf's DWARF compile units.""" |
| try: |
| from elftools.elf.elffile import ELFFile # pyelftools, a membrowse dependency |
| except ImportError as e: |
| raise RuntimeError(f'pyelftools is needed to resolve {elf}\'s source paths ({e})') |
| sources = {} |
| with open(elf, 'rb') as f: |
| elffile = ELFFile(f) |
| if not elffile.has_dwarf_info(): |
| return sources |
| for cu in elffile.get_dwarf_info().iter_CUs(): |
| top = cu.get_top_DIE().attributes |
| if 'DW_AT_name' in top: |
| comp_dir = top['DW_AT_comp_dir'].value.decode() if 'DW_AT_comp_dir' in top else '' |
| path = os.path.normpath(os.path.join(comp_dir, top['DW_AT_name'].value.decode())) |
| sources.setdefault(os.path.basename(path), set()).add(path) |
| return sources |
| |
| |
| def _source_path(sym, elf, filters): |
| """A symbol's source path. An archive member has no object path and membrowse 1.2.9 |
| keeps only the basename of its source, so the DWARF compile unit of that name gives it |
| back (ESP-IDF links TinyUSB from an archive).""" |
| path = sym.get('object_file') or sym.get('source_file') |
| if not path or '/' in path: |
| return path |
| candidates = _dwarf_sources(elf).get(path, set()) |
| if len(candidates) == 1: |
| return next(iter(candidates)) |
| if any(_relative_key(c, filters) for c in candidates): |
| raise RuntimeError(f'{elf}: {path} names several compile units ({", ".join(sorted(candidates))}), ' |
| f'one of them filtered; cannot tell which holds {sym.get("name")}') |
| return path |
| |
| |
| def membrowse_sizes(elf, filters): |
| """Symbols of `membrowse report`; linker-defined symbols without a section |
| (`__StackLimit`) are not counted.""" |
| buckets = section_buckets(elf, _map_path(elf)) |
| sizes = _Sizes(filters, elf) |
| for sym in report_for_elf(elf).get('symbols', []): |
| section = sym.get('section') |
| if not sym.get('size') or not section: |
| continue |
| if section not in buckets: |
| raise RuntimeError(f'membrowse symbol {sym.get("name")} is in section {section}, not in {elf}') |
| sizes.add(_source_path(sym, elf, filters), section, buckets[section], sym['size'], sym.get('name')) |
| return sizes.result() |
| |
| |
| @functools.cache |
| def _load_module(name, path): |
| """The Python file at `path`, imported once as `name`.""" |
| spec = importlib.util.spec_from_file_location(name, path) |
| module = importlib.util.module_from_spec(spec) |
| spec.loader.exec_module(module) |
| return module |
| |
| |
| @functools.cache |
| def _linkermap(): |
| path = os.path.join(TINYUSB_ROOT, 'tools', 'linkermap', 'linkermap.py') |
| if not os.path.isfile(path): |
| raise FileNotFoundError(f'{path} not found - run `python3 tools/get_deps.py tools/linkermap`') |
| return _load_module('linkermap', path) |
| |
| |
| def linkermap_sizes(elf, filters): |
| """Input sections of the elf's map by full object path; analyze_map() is not used, |
| it keeps only four sections. An archive member |
| (`lib.a(x.o)`) has no source dir, so it counts in 'all' only. The symbols are the |
| input sections (`.text.foo`), not the labels inside one.""" |
| map_path = _map_path(elf) |
| buckets = section_buckets(elf, map_path) |
| with open(map_path, encoding='utf-8', errors='replace') as f: |
| sections = _linkermap().parseSections(f) |
| if sections is None: |
| raise RuntimeError(f'{map_path}: no Memory Configuration, not a GNU ld map') |
| sizes = _Sizes(filters, elf) |
| for out in sections: |
| if not out.children: # memory regions |
| continue |
| if out.section not in buckets: |
| raise RuntimeError(f'{map_path}: output section {out.section} is not in {elf}') |
| for obj in out.children: |
| sizes.add(obj.path[0], out.section, buckets[out.section], obj.size, obj.section) |
| return sizes.result() |
| |
| |
| def bloaty_sizes(elf, filters): |
| """VM size per DWARF compile unit, section and symbol; bytes outside any section |
| (`[LOAD #0 [RX]]` padding, loaded ELF headers) are not counted.""" |
| r = subprocess.run(['bloaty', '--csv', '-n', '0', '--domain=vm', |
| '-d', 'compileunits,sections,symbols', elf], capture_output=True, text=True) |
| if r.returncode != 0: |
| raise RuntimeError(f'bloaty failed for {elf}: {r.stderr.strip()}') |
| rows = csv.DictReader(io.StringIO(r.stdout)) |
| if not {'compileunits', 'sections', 'symbols', 'vmsize'} <= set(rows.fieldnames or ()): |
| raise RuntimeError(f'unexpected bloaty csv columns for {elf}: {rows.fieldnames}') |
| buckets = section_buckets(elf, _map_path(elf)) |
| sizes = _Sizes(filters, elf) |
| for row in rows: |
| section = row['sections'] |
| try: |
| size = int(row['vmsize']) |
| except (TypeError, ValueError) as e: |
| raise RuntimeError(f'malformed bloaty csv row for {elf}: {row}') from e |
| if not size or not section or section.startswith('['): |
| continue |
| if section not in buckets: |
| raise RuntimeError(f'bloaty section {section} is not in {elf}') |
| unit = row['compileunits'] |
| sizes.add(None if unit.startswith('[') else unit, section, buckets[section], size, row['symbols']) |
| return sizes.result() |
| |
| |
| # all_label: what the engine's 'all' total sums (totals of different engines are |
| # not comparable); install: how to get its tool when `sizes` raises FileNotFoundError |
| Engine = collections.namedtuple('Engine', 'sizes all_label install') |
| MEMBROWSE_CI_VERSION = '1.2.12' # every CI install pins this; attribution can differ across versions |
| ENGINES = { |
| 'membrowse': Engine(membrowse_sizes, 'all symbols', f'`pip install membrowse=={MEMBROWSE_CI_VERSION}`'), |
| 'linkermap': Engine(linkermap_sizes, 'all input sections', |
| '`python3 tools/get_deps.py tools/linkermap`'), |
| 'bloaty': Engine(bloaty_sizes, 'all accounted sections', |
| 'bloaty on PATH (https://github.com/google/bloaty)'), |
| } |
| |
| |
| def engine_missing(engine): |
| """Whether `engine`'s tool is absent; membrowse and bloaty are commands of that name.""" |
| if engine == 'linkermap': |
| try: |
| _linkermap() |
| except FileNotFoundError: |
| return True |
| return False |
| return not shutil.which(engine) |
| |
| |
| def _md_escape(text): |
| """`text` safe in Markdown prose: gcc quotes as `name', which would open a code span.""" |
| return text.replace('`', '\\`') |
| |
| |
| def _fmt(delta): |
| return f'+{delta}' if delta > 0 else str(delta) |
| |
| |
| _MD_CELL = str.maketrans({**{c: f'&#{ord(c)};' for c in '&<>|`\\[]\r\n'}, '@': '@\u200b'}) |
| |
| |
| def _md_cell(text): |
| """A table cell safe from names that would open HTML, a link or code span, end or split |
| the row or @mention someone (a zero-width space breaks the mention).""" |
| return text.translate(_MD_CELL) |
| |
| |
| def md_table(header, rows, total=None, *, left=(0,)): |
| """Markdown table padded so its columns also line up as plain text: the `left` |
| columns left-aligned, the others right-aligned. Cells are strings; a `total` row |
| follows the rows under a plain rule.""" |
| header = [_md_cell(c) for c in header] |
| rows = [[_md_cell(c) for c in r] for r in rows] |
| total = [_md_cell(c) for c in total] if total else total |
| body = rows + [total] if total else rows |
| widths = [max(len(r[i]) for r in [header] + body) for i in range(len(header))] |
| |
| def line(cells): |
| return '| ' + ' | '.join(c.ljust(w) if i in left else c.rjust(w) |
| for i, (c, w) in enumerate(zip(cells, widths))) + ' |' |
| sep = '|' + '|'.join('-' * (w + 2) if i in left else '-' * (w + 1) + ':' for i, w in enumerate(widths)) + '|' |
| # only the header's rule is a delimiter; this one renders as a row, so colons would show as text |
| rule = sep.replace(':', '-') |
| return '\n'.join([line(header), sep] + [line(r) for r in rows] + ([rule, line(total)] if total else [])) |
| |
| |
| def compare_reports(base_by_file, cur_by_file, labels=SIDE_LABELS): |
| """Markdown per-file delta table; files sorted by |flash delta| desc, TOTAL over all files. |
| Each region cell is `base → new`, both values padded so they line up down the column; |
| `labels` name the two sides in the header.""" |
| zero = {'flash': 0, 'ram': 0} |
| header = [f'File ({labels[0]} → {labels[1]})', 'Flash', 'Flash Δ', 'RAM', 'RAM Δ'] |
| rows = [(path, base_by_file.get(path, zero), cur_by_file.get(path, zero)) |
| for path in sorted(set(base_by_file) | set(cur_by_file))] |
| changed = sorted((r for r in rows if any(_delta(r[1], r[2]))), key=lambda r: -abs(_delta(r[1], r[2])[0])) |
| shown = changed + [('TOTAL', _files_total(base_by_file), _files_total(cur_by_file))] |
| bw = {k: max(len(str(b[k])) for _, b, _ in shown) for k in zero} |
| cw = {k: max(len(str(c[k])) for _, _, c in shown) for k in zero} |
| |
| def cells(b, c): |
| return [cell for k in zero for cell in (f'{b[k]:>{bw[k]}} → {c[k]:>{cw[k]}}', _fmt(c[k] - b[k]))] |
| *table, total_row = [[path, *cells(b, c)] for path, b, c in shown] |
| if not changed and rows: |
| table.append(['_no per-file changes_'] + [''] * (len(header) - 1)) |
| return md_table(header, table, total_row) + '\n' |
| |
| |
| def _section_deltas(b, c, path): |
| """{section: Δ} of one file.""" |
| bs, cs = b['sections'].get(path, {}), c['sections'].get(path, {}) |
| return {sec: cs.get(sec, 0) - bs.get(sec, 0) for sec in set(bs) | set(cs)} |
| |
| |
| def _symbol_deltas(b, c, path): |
| """{(section, name): Δ} of one file's changed symbols.""" |
| bs, cs = b['symbols'].get(path, {}), c['symbols'].get(path, {}) |
| deltas = {} |
| for sec in set(bs) | set(cs): |
| bn, cn = bs.get(sec, {}), cs.get(sec, {}) |
| for name in set(bn) | set(cn): |
| if cn.get(name, 0) != bn.get(name, 0): |
| deltas[(sec, name)] = cn.get(name, 0) - bn.get(name, 0) |
| return deltas |
| |
| |
| def _changed_files(b, c, symbols): |
| """(path, section Δs, symbol Δs) of each file whose sections, or with `symbols` |
| whose symbols, changed; a file's changes can cancel in flash/RAM.""" |
| rows = [] |
| for path in sorted(set(b['sections']) | set(c['sections'])): |
| secs = _section_deltas(b, c, path) |
| syms = _symbol_deltas(b, c, path) if symbols else {} |
| if any(secs.values()) or syms: |
| rows.append((path, secs, syms)) |
| return rows |
| |
| |
| def _row_label(symbols, engine): |
| if not symbols: |
| return 'File' |
| return 'File / input section' if engine == 'linkermap' else 'File / symbol' |
| |
| |
| def size_table(sizes, symbols, engine): |
| """linkermap's table: a row per file, a column per output section, then size, % of |
| the filtered total and TOTAL; with `symbols` each file's symbols follow it.""" |
| by_file = sizes['sections'] |
| cols = sorted({sec for secs in by_file.values() for sec in secs}, reverse=True) |
| total = sum(sum(secs.values()) for secs in by_file.values()) |
| |
| def row(name, secs): |
| size = sum(secs.values()) |
| pct = f'{100 * size / total:.1f}%' if total else '-' |
| return [name] + [str(secs.get(sec, 0)) for sec in cols] + [str(size), pct] |
| |
| lines = [] |
| for path, secs in sorted(by_file.items(), key=lambda kv: (-sum(kv[1].values()), kv[0])): |
| lines.append(row(path, secs)) |
| if symbols: |
| syms = [((sec, name), n) for sec, names in sizes['symbols'].get(path, {}).items() |
| for name, n in names.items()] |
| for (sec, name), n in sorted(syms, key=lambda kv: (-kv[1], kv[0])): |
| lines.append(row(f'â”” {name}', {sec: n})) |
| total_row = row('TOTAL', {sec: sum(secs.get(sec, 0) for secs in by_file.values()) for sec in cols}) |
| return md_table([_row_label(symbols, engine)] + cols + ['size', '%'], lines, total_row) + '\n' |
| |
| |
| def render_report(sizes, engine, failures=(), boards=(), symbols=False, files_label='TinyUSB'): |
| """Markdown report of one tree's elfs keyed by (board, elf path), None for a |
| failed one; `failures` are (elf id, stage, message). One elf is inline, several |
| get a summary table and each its own table in <details>; `files_label` names the |
| filtered files' totals.""" |
| sized = {i: s for i, s in sizes.items() if s is not None} |
| status = 'INCOMPLETE' if failures or not sized else 'complete' |
| all_label = ENGINES[engine].all_label |
| lines = [f'**Coverage ({status}, {engine}):** {len(sized)} of {len(sizes)} elfs sized'] |
| if boards: |
| lines.append('- boards: ' + ', '.join(f'`{b}`' for b in boards)) |
| for elf_id, stage, message in failures: |
| lines.append(f'- FAILED `{_label(elf_id)}` {stage}: {_md_escape(message)}') |
| lines.append('') |
| if not sized: |
| return '\n'.join(lines + ['_no sized elfs_', '']) |
| |
| def totals(s): |
| src = _files_total(s['files']) |
| return (f'{files_label} Flash {src["flash"]}, RAM {src["ram"]}; ' |
| f'{all_label} Flash {s["all"]["flash"]}, RAM {s["all"]["ram"]}') |
| |
| if len(sized) == 1: |
| (elf_id, s), = sized.items() |
| return '\n'.join(lines + [f'`{_label(elf_id)}` {totals(s)}', '', size_table(s, symbols, engine)]) |
| lines.append(md_table(['Elf', f'{files_label} Flash', f'{files_label} RAM', f'{all_label} Flash', f'{all_label} RAM'], |
| [[_label(i)] + [str(t[k]) for t in (_files_total(s['files']), s['all']) for k in ('flash', 'ram')] |
| for i, s in sized.items()])) |
| for elf_id, s in sized.items(): |
| lines += ['', f'<details><summary>{_label(elf_id)}</summary>', '', |
| f'{totals(s)}', '', size_table(s, symbols, engine), '</details>'] |
| return '\n'.join(lines) + '\n' |
| |
| |
| def delta_table(b, c, symbols, engine): |
| """linkermap's table shape as deltas: a row per changed file, a column per changed |
| output section, then size Δ and TOTAL; with `symbols` each file's changed |
| symbols follow it.""" |
| rows = _changed_files(b, c, symbols) |
| if not rows: |
| return '_no section changes_\n' |
| cols = sorted({sec for _, secs, _ in rows for sec, d in secs.items() if d} |
| | {sec for _, _, syms in rows for sec, _ in syms}, reverse=True) |
| label = _row_label(symbols, engine) |
| |
| def row(name, deltas): |
| return [name] + [_fmt(deltas.get(sec, 0)) for sec in cols] + [_fmt(sum(deltas.values()))] |
| |
| lines = [] |
| total = {} |
| for path, secs, syms in sorted(rows, key=lambda r: (-abs(sum(r[1].values())), r[0])): |
| lines.append(row(path, secs)) |
| for (sec, name), d in sorted(syms.items(), key=lambda kv: (-abs(kv[1]), kv[0])): |
| lines.append(row(f'â”” {name}', {sec: d})) |
| for sec, d in secs.items(): |
| total[sec] = total.get(sec, 0) + d |
| return md_table([label] + cols + ['size Δ'], lines, row('TOTAL', total)) + '\n' |
| |
| |
| def pair_elfs(base, cur): |
| """Pair two {elf_id: elf_sizes, or None for a failed report} maps. |
| |
| Returns (pairs, base_only, cur_only): `pairs` maps each id with a report on |
| both sides to (base, cur); ids on one side only are listed, never paired. |
| """ |
| pairs = {i: (base[i], cur[i]) for i in sorted(base.keys() & cur.keys()) |
| if base[i] is not None and cur[i] is not None} |
| return pairs, sorted(base.keys() - cur.keys()), sorted(cur.keys() - base.keys()) |
| |
| |
| def _delta(b, c): |
| return c['flash'] - b['flash'], c['ram'] - b['ram'] |
| |
| |
| def _file_deltas(b, c): |
| """{path: (flash Δ, RAM Δ)} over the union of both sides' files.""" |
| zero = {'flash': 0, 'ram': 0} |
| return {path: _delta(b['files'].get(path, zero), c['files'].get(path, zero)) |
| for path in sorted(set(b['files']) | set(c['files']))} |
| |
| |
| def _files_total(by_file): |
| return {k: sum(f[k] for f in by_file.values()) for k in ('flash', 'ram')} |
| |
| |
| def _label(elf_id): |
| board, elf = elf_id |
| return f'{board}: {elf}' if elf else board |
| |
| |
| def _extreme(value_id): |
| delta, elf_id = value_id |
| return f'{_fmt(delta)} ({_label(elf_id)})' if delta else '0' |
| |
| |
| def _aligned_ranges(spans): |
| """`min → max` cells of one right-aligned column, both numbers right-aligned so the arrows |
| line up when rendered: leading figure spaces (a digit's width, not collapsed by HTML) pad each, |
| U+2212 minus is as wide as `+` where `-` is narrower, and no-break spaces keep a cell on one |
| line in a table too wide for the page. Trailing padding would not do: a browser lets it hang.""" |
| pad = '\u2007' |
| spans = [(_fmt(lo).replace('-', '\u2212'), _fmt(hi).replace('-', '\u2212')) for lo, hi in spans] |
| lo_w, hi_w = (max((len(s[i]) for s in spans), default=0) for i in (0, 1)) |
| return [f'{lo.rjust(lo_w, pad)}\u00a0→\u00a0{hi.rjust(hi_w, pad)}' for lo, hi in spans] |
| |
| |
| def _comment_build(value_id, metric_changed): |
| """A comment's build cell for one extreme: `board/example` when its Δ is nonzero, `—` for |
| a zero end of a changed metric, empty when the metric changed in no build.""" |
| delta, (board, elf) = value_id |
| if delta: |
| return f'{board}/{os.path.splitext(os.path.basename(elf))[0]}' |
| return '—' if metric_changed else '' |
| |
| |
| def _file_stats(file_deltas): |
| """Per file: pairs present/changed and the (Δ, elf id) extremes of each |
| metric, the first id winning ties. `file_deltas` maps sorted elf ids to |
| _file_deltas().""" |
| entries = {} |
| for elf_id, deltas in file_deltas.items(): |
| for path, d in deltas.items(): |
| entries.setdefault(path, []).append((elf_id, d)) |
| stats = {} |
| for path, es in entries.items(): |
| stats[path] = {'present': len(es), 'changed': sum(any(d) for _, d in es)} |
| for i, k in enumerate(('flash', 'ram')): |
| lo = min(es, key=lambda e: e[1][i]) |
| hi = max(es, key=lambda e: e[1][i]) |
| stats[path][k] = [(lo[1][i], lo[0]), (hi[1][i], hi[0])] |
| return stats |
| |
| |
| def _pair_tables(b, c, symbols, engine, labels): |
| """A pair's Flash/RAM file table and its section delta table.""" |
| return compare_reports(b['files'], c['files'], labels), delta_table(b, c, symbols, engine) |
| |
| |
| def _pair_changed(b, c, symbols): |
| return (any(_delta(b['all'], c['all'])) or any(any(d) for d in _file_deltas(b, c).values()) |
| or bool(_changed_files(b, c, symbols))) |
| |
| |
| def _range(lo, hi): |
| return _fmt(lo) if lo == hi else f'{_fmt(lo)} → {_fmt(hi)}' |
| |
| |
| COMMENT_TERMS = {'base-only': 'missing in this PR', 'current-only': 'new in this PR (no master size yet)', |
| 'base': 'master', 'current': 'PR'} |
| |
| |
| def _problems(base_only, cur_only, failures, terms=None): |
| """Report lines for one-sided elfs and failures, each id named; `terms` rewords the report's words.""" |
| word = lambda w: (terms or {}).get(w, w) # noqa: E731 |
| lines = [f'- {word(label)}: ' + ', '.join(f'`{_label(i)}`' for i in ids) |
| for label, ids in (('base-only', base_only), ('current-only', cur_only)) if ids] |
| return lines + [f'- FAILED `{_label(elf_id)}` {word(side)} {stage}: {_md_escape(message)}' |
| for elf_id, side, stage, message in failures] |
| |
| |
| def _changed_paths(stats): |
| """The changed files of _file_stats(), largest |Δ| first.""" |
| return sorted((p for p, s in stats.items() if s['changed']), |
| key=lambda p: (-max(abs(v[0]) for k in ('flash', 'ram') for v in stats[p][k]), p)) |
| |
| |
| def render_comment(pairs, engine, base_only=(), cur_only=(), failures=(), symbols=False, files_label='TinyUSB'): |
| """The PR comment's view as (body, footnote): a row per changed file with its min and max |
| Δ over the builds containing it and the build of each, so each changed driver shows however |
| heavy the others are. The footnote explains the whole-firmware totals, when the body has them.""" |
| file_deltas = {i: _file_deltas(b, c) for i, (b, c) in pairs.items()} |
| changed = [i for i, (b, c) in pairs.items() if _pair_changed(b, c, symbols)] |
| head = (f'{len(pairs)} builds on {len({board for board, _elf in pairs})} boards compared, ' |
| f'{len(changed)} changed') |
| footnote = '' |
| if changed: |
| flash, ram = zip(*(_delta(pairs[i][0]['all'], pairs[i][1]['all']) for i in changed)) |
| head += f'. Whole firmware¹ Flash Δ {_range(min(flash), max(flash))}, RAM Δ {_range(min(ram), max(ram))}' |
| footnote = f'\n¹ Sum of {ENGINES[engine].all_label} measured by {engine}, not the exact image size.\n' |
| problems = _problems(base_only, cur_only, failures, COMMENT_TERMS) |
| lines = [head, ''] + (['Not compared:', '', *problems, ''] if problems else []) |
| if not changed: |
| return '\n'.join(lines + ['_no changes_' if pairs else '_no comparable pairs_', '']), footnote |
| stats = _file_stats(file_deltas) |
| paths, metrics = _changed_paths(stats), ('flash', 'ram') |
| ranges = [_aligned_ranges([(stats[p][k][0][0], stats[p][k][1][0]) for p in paths]) for k in metrics] |
| rows = [[p, *cells] + [_comment_build(v, any(d for d, _i in stats[p][k])) for k in metrics for v in stats[p][k]] |
| for p, *cells in zip(paths, *ranges)] |
| if rows: |
| lines += [f'{files_label} file size: min → max change across builds containing the file, ' |
| 'and the build (board/example) of each; — marks a zero end.', '', |
| md_table(['File', 'Flash Δ', 'RAM Δ', 'Flash Δ min build', 'Flash Δ max build', |
| 'RAM Δ min build', 'RAM Δ max build'], rows, left=(0, 3, 4, 5, 6))] |
| other = sum(not any(any(d) for d in file_deltas[i].values()) for i in changed) |
| if other: |
| lines += ['', f'{other} other build{"" if other == 1 else "s"} changed without a ' |
| f'{files_label} file-size change.'] |
| return '\n'.join(lines) + '\n', footnote |
| |
| |
| def render_pairs(pairs, matched, engine, base_only=(), cur_only=(), failures=(), boards=(), symbols=False, |
| labels=SIDE_LABELS, files_label='TinyUSB'): |
| """Markdown report over paired elfs keyed by (board, elf path). |
| |
| Every statistic is over per-pair deltas, sized by `engine`. `matched` counts |
| ids built on both sides, compared or not; |
| `failures` are (elf id, side, stage, message), the elf path None for a |
| board-level failure. Failures or unmatched elfs mark the report INCOMPLETE. |
| `boards` lists the requested boards, so an unchanged or failed one is named. |
| `symbols` adds each file's changed symbols to each pair's section table; `labels` |
| name the base and current sides; `files_label` names the filtered files' totals. |
| """ |
| file_deltas = {i: _file_deltas(b, c) for i, (b, c) in pairs.items()} |
| changed = [i for i, (b, c) in pairs.items() if _pair_changed(b, c, symbols)] |
| status = _status(failures, base_only, cur_only, pairs) |
| all_label = ENGINES[engine].all_label |
| lines = [f'**Coverage ({status}, {engine}):** {len(pairs)} of {matched} matched elf pairs ' |
| f'compared, {len(changed)} changed'] |
| if boards: |
| lines.append('- boards: ' + ', '.join(f'`{b}`' for b in boards)) |
| lines += _problems(base_only, cur_only, failures) + [''] |
| |
| if len(pairs) == 1: |
| (elf_id, (b, c)), = pairs.items() |
| df, dr = _delta(b['all'], c['all']) |
| lines += [f'`{_label(elf_id)}` {all_label}: Flash Δ {_fmt(df)}, RAM Δ {_fmt(dr)}', '', |
| *_pair_tables(b, c, symbols, engine, labels)] |
| return '\n'.join(lines) |
| if not pairs: |
| return '\n'.join(lines + ['_no comparable pairs_', '']) |
| if not changed: |
| return '\n'.join(lines + ['_no changes_', '']) |
| |
| src = {i: _delta(_files_total(pairs[i][0]['files']), _files_total(pairs[i][1]['files'])) for i in changed} |
| pair_rows = [[_label(i)] + [_fmt(d) for d in src[i] + _delta(pairs[i][0]['all'], pairs[i][1]['all'])] |
| for i in changed] |
| lines.append(md_table(['Pair', f'{files_label} Flash Δ', f'{files_label} RAM Δ', f'{all_label} Flash Δ', |
| f'{all_label} RAM Δ'], pair_rows)) |
| |
| stats = _file_stats(file_deltas) |
| rows = _changed_paths(stats) |
| lines += ['', '_Changed / present: pairs where the file\'s Flash or RAM total changed / compared pairs ' |
| 'that contain it._', '', |
| md_table(['File', 'Changed / present', 'Flash Δ min', 'Flash Δ max', 'RAM Δ min', 'RAM Δ max'], |
| [[path, f'{stats[path]["changed"]}/{stats[path]["present"]}'] |
| + [_extreme(v) for k in ('flash', 'ram') for v in stats[path][k]] for path in rows])] |
| |
| for elf_id in changed: |
| b, c = pairs[elf_id] |
| lines += ['', f'<details><summary>{_label(elf_id)}</summary>', '', |
| *_pair_tables(b, c, symbols, engine, labels), '</details>'] |
| return '\n'.join(lines) + '\n' |
| |
| |
| def _status(failures, base_only, cur_only, pairs): |
| return 'INCOMPLETE' if failures or base_only or cur_only or not pairs else 'complete' |
| |
| |
| def _elf_record(elf_id): |
| board, elf = elf_id |
| return {'board': board, 'elf': elf} |
| |
| |
| def _json_sizes(sizes, symbols): |
| """An elf's sizes for JSON, symbols included only when `symbols` is set.""" |
| return sizes if symbols else {k: v for k, v in sizes.items() if k != 'symbols'} |
| |
| |
| def _filter_failure(engine): |
| return (f'no {engine} sizes matched filters - check them, or a change in ' |
| f'{engine} output broke its parsing (try another --engine to isolate)') |
| |
| |
| def compare_sides(base, cur, engine, failures=(), boards=(), scope=None, symbols=False, labels=SIDE_LABELS, |
| files_label='TinyUSB'): |
| """Pair two sides and render them. Returns (md, failures, ok, data, changed). |
| |
| `failures` comes back with a filter failure for `scope` added when no |
| compared pair matched a file (wrong filters, or an engine output change |
| broke its parsing); pass `scope=None` when each scope was already checked. |
| `ok` is False on any failure or when nothing was compared. `data` is the |
| report's raw paired sizes, for JSON, symbols included only when `symbols` is set; |
| `changed` the pairs whose sizes changed, {elf id: (base, current)}. |
| `files_label` is render_pairs()'. |
| """ |
| pairs, base_only, cur_only = pair_elfs(base, cur) |
| failures = list(failures) |
| if scope and pairs and not any(b['files'] or c['files'] for b, c in pairs.values()): |
| failures.append((scope, 'both', 'filter', _filter_failure(engine))) |
| md = render_pairs(pairs, len(base.keys() & cur.keys()), engine, base_only, cur_only, failures, boards, |
| symbols, labels, files_label) |
| data = { |
| 'engine': engine, |
| 'boards': list(boards), |
| 'status': _status(failures, base_only, cur_only, pairs), |
| 'pairs': [{**_elf_record(i), 'base': _json_sizes(b, symbols), 'current': _json_sizes(c, symbols)} |
| for i, (b, c) in pairs.items()], |
| 'base_only': [_elf_record(i) for i in base_only], |
| 'current_only': [_elf_record(i) for i in cur_only], |
| 'failures': [{**_elf_record(i), 'side': side, 'stage': stage, 'message': message} |
| for i, side, stage, message in failures], |
| } |
| changed = {i: (b, c) for i, (b, c) in pairs.items() if _pair_changed(b, c, symbols)} |
| return md, failures, bool(pairs) and not failures, data, changed |
| |
| |
| 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 filter_arg(os.path.realpath(os.path.join(checkout_dir, 'src')) + os.sep) |
| |
| |
| def filter_arg(path): |
| """A path filter in the form sizes are matched in: on Windows, past the drive with '/', |
| as CMake names an out-of-tree object D_/a/.../src/x.c.obj and DWARF D:/a/...""" |
| return os.path.splitdrive(path)[1].replace('\\', '/') if WINDOWS else path |
| |
| |
| verbose = False |
| TERMINATE_GRACE = 10 # s a timed-out command gets to exit on SIGTERM before SIGKILL |
| KILL_DRAIN = 1 # s to read what a killed command left in its pipes |
| |
| |
| def run(cmd, timeout=None): |
| """Run a command. cmd must be a list (no shell=True). On `timeout`, SIGTERM the |
| command, SIGKILL it if it outlives a grace period, and return a CompletedProcess |
| with rc=124 instead of raising TimeoutExpired, so the caller can fall through to |
| error reporting and worktree cleanup rather than crashing with a traceback. Any |
| other exception, e.g. exit_on_termination()'s exit, stops the command the same way |
| and is re-raised: Popen's exit would otherwise wait for it.""" |
| 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)}') |
| # the command stays in our process group, so Ctrl-C or a hangup reaches it directly |
| with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, |
| encoding='utf-8', errors='replace') as proc: |
| try: |
| out, err = proc.communicate(timeout=timeout) |
| except BaseException as stopped: |
| if WINDOWS: # terminate() and kill() stop only the command, not ninja's compilers |
| with contextlib.suppress(OSError, subprocess.SubprocessError): |
| subprocess.run(['taskkill', '/PID', str(proc.pid), '/T', '/F'], |
| capture_output=True, timeout=TERMINATE_GRACE) |
| # SIGTERM lets ninja stop its jobs, which it runs in process groups of their own; |
| # a descendant can outlive the SIGKILL holding the pipes open, so stop reading then |
| for stop, wait in ((proc.terminate, TERMINATE_GRACE), (proc.kill, KILL_DRAIN)): |
| stop() |
| try: |
| out, err = proc.communicate(timeout=wait) |
| break |
| except subprocess.TimeoutExpired as e: |
| out, err = ((b or b'').decode(errors='replace') for b in (e.stdout, e.stderr)) |
| if not isinstance(stopped, subprocess.TimeoutExpired): |
| raise |
| msg = f'Command timed out after {timeout}s: {" ".join(shlex.quote(str(c)) for c in cmd)}' |
| return subprocess.CompletedProcess(cmd, 124, stdout=out, stderr=err + ('\n' if err else '') + msg) |
| return subprocess.CompletedProcess(cmd, proc.returncode, stdout=out, stderr=err) |
| |
| |
| def exit_on_termination(): |
| """Exit on a SIGTERM or SIGHUP sent to this process alone as on Ctrl-C: through run()'s |
| stop of its command and main()'s worktree removal, instead of orphaning the build.""" |
| for name in ('SIGTERM', 'SIGHUP'): # Windows has no SIGHUP |
| if hasattr(signal, name): |
| signal.signal(getattr(signal, name), lambda sig, _frame: sys.exit(128 + sig)) |
| |
| |
| def symlink_deps(main_root, worktree_dir): |
| """Symlink each dependency the worktree's own tools/get_deps.py lists, when fetched in |
| the main checkout: the worktree lacks these untracked dirs, and a base revision can |
| name paths the current manifest has renamed or dropped.""" |
| manifest = runpy.run_path(os.path.join(worktree_dir, 'tools', 'get_deps.py')) |
| for rel in manifest['deps_all']: |
| src = os.path.join(main_root, rel) |
| dst = os.path.join(worktree_dir, rel) |
| if os.path.isdir(src) and not os.path.lexists(dst): |
| os.makedirs(os.path.dirname(dst), exist_ok=True) |
| try: |
| os.symlink(src, dst, target_is_directory=True) |
| except OSError as e: |
| if getattr(e, 'winerror', None) == 1314: # ERROR_PRIVILEGE_NOT_HELD |
| sys.exit(f'Error: cannot symlink {rel} into the base worktree: enable Windows Developer ' |
| 'Mode, or use --base-source ci when CI stored a baseline') |
| raise |
| |
| |
| def short_hash(checkout): |
| """`checkout`'s short HEAD hash, `-dirty` when tracked files differ from it; None |
| when git cannot tell. `--exclude='*'` keeps tag names out.""" |
| ret = run(['git', '-C', checkout, 'describe', '--always', '--dirty', '--exclude=*']) |
| return ret.stdout.strip() or None if ret.returncode == 0 else None |
| |
| |
| def invalid_boards(boards): |
| """Boards that are not a plain name: a board names a cmake-code-size/<board> dir that |
| gets removed before the build can reject it, so `..` or a path must not pass.""" |
| return [b for b in boards if not re.fullmatch(r'[A-Za-z0-9_-]+', b)] |
| |
| |
| def unknown_boards(boards): |
| """Boards of no family in this checkout: build_utils would skip every example of one, |
| so a typo must fail instead.""" |
| import build # tools/build.py; its import has no side effects |
| with contextlib.chdir(TINYUSB_ROOT): # build.py reads hw/bsp from the cwd |
| return [b for b in boards if build.find_family(b) is None] |
| |
| |
| def missing_examples(examples, sha=None): |
| """Examples not named role/name (build.py's -e check), or with no examples/<example> |
| dir in this checkout nor in commit `sha` when given: one of either tree reaches the |
| report as one-sided, a typo must fail.""" |
| import build # tools/build.py; its import has no side effects |
| return [e for e in examples if not build.EXAMPLE_RE.fullmatch(e) |
| or not os.path.isdir(os.path.join(TINYUSB_ROOT, 'examples', e)) |
| and (sha is None or run(['git', '-C', TINYUSB_ROOT, 'cat-file', '-e', f'{sha}:examples/{e}']).returncode)] |
| |
| |
| def ci_pinned_boards(): |
| """Boards of .github/ci-pinned-boards.json: CI's membrowse set, which covers every |
| dcd/hcd driver not waived in its `uncovered` list (drivers-coverage hook).""" |
| with open(CI_PINNED_BOARDS) as f: |
| return [entry['board'] for entry in json.load(f)['boards']] |
| |
| |
| class Phase: |
| """A progress line ` label… 1.2s`, `FAILED after 1.2s` or `skipped: <why>`; with -v |
| the commands print between the label and its time, so the time gets a line of its own.""" |
| def __init__(self, label): |
| self.label, self.start = label, time.monotonic() |
| print(f' {label}…', end='\n' if verbose else ' ', flush=True) |
| |
| def done(self, failed=False, skipped=None): |
| took = (f'skipped: {skipped}' if skipped else |
| f'{"FAILED after " if failed else ""}{time.monotonic() - self.start:.1f}s') |
| print(f' {self.label}: {took}' if verbose else took) |
| |
| |
| _NINJA_PROGRESS = re.compile(r'\[\d+/\d+\] ') |
| |
| |
| def output_excerpt(ret, lines=20): |
| """Up to `lines` of each nonempty stream of a failed command: from ninja's first |
| `FAILED:` block when there is one, else the stream's tail. ninja reports a compile |
| error on stdout, leaving stderr empty, and the jobs already running when it failed |
| finish after it, so their `[n/m]` progress lines are dropped.""" |
| def excerpt(stream): |
| kept = [line for line in stream.rstrip().splitlines() if not _NINJA_PROGRESS.match(line)] |
| first = next((i for i, line in enumerate(kept) if line.startswith('FAILED:')), None) |
| return kept[-lines:] if first is None else kept[first:first + lines] |
| return [line for stream in (ret.stdout, ret.stderr) if stream.strip() for line in excerpt(stream)] |
| |
| |
| def build_error(ret, src_dir): |
| """A failed build's first error for the report, from its whole output: the first |
| diagnostic, else its last line that is not ninja's progress, `FAILED:` or stop line |
| (its own `ninja: error:` stays), with paths relative to `src_dir`.""" |
| out = '\n'.join(stream for stream in (ret.stdout, ret.stderr) if stream) |
| lines = [line.strip() for line in out.splitlines() |
| if line.strip() and not _NINJA_PROGRESS.match(line) |
| and not line.startswith(('ninja: build stopped', 'ninja: Entering directory', 'FAILED: '))] |
| error = build_utils.first_error(out) or (lines[-1] if lines else 'no output') |
| root = src_dir.rstrip(os.sep) |
| for prefix in {root + os.sep, root.replace(os.sep, '/') + '/'}: # Windows: CMake passes the compiler D:/a/... |
| error = error.replace(prefix, '') |
| return error |
| |
| |
| ESP_IDF_IMAGE = 'espressif/idf:tinyusb' # .github/actions/setup_toolchain/espressif's tag |
| ESP_IDF_MISSING = (f'ESP-IDF: source $IDF_PATH/export.sh, or docker with {ESP_IDF_IMAGE} ' |
| f'(docker tag the "esp-idf" image of .github/actions/setup_toolchain/toolchain.json as {ESP_IDF_IMAGE}, ' |
| f'as CI does)') |
| |
| |
| def _link_hops(path): |
| """The paths a symlink resolves through, its real path last.""" |
| hops = [] |
| while os.path.islink(path): |
| path = os.path.normpath(os.path.join(os.path.dirname(path), os.readlink(path))) |
| hops.append(path) |
| return hops |
| |
| |
| def is_espressif(board, src_dir=TINYUSB_ROOT): |
| return os.path.isdir(os.path.join(src_dir, 'hw', 'bsp', 'espressif', 'boards', board)) |
| |
| |
| def _idf_image(): |
| return bool(shutil.which('docker')) and run(['docker', 'image', 'inspect', ESP_IDF_IMAGE]).returncode == 0 |
| |
| |
| def esp_without_idf(boards, examples=None, base_built=False): |
| """The error for espressif `boards` no exported ESP-IDF or CI's image can build, else None. |
| A board builds nothing that skips each of `examples` here, unless `base_built` and one is |
| absent here, which that base may have.""" |
| import build # tools/build.py; its import has no side effects |
| |
| def builds(board): |
| kept, dropped = build.select_examples(board, examples, root=TINYUSB_ROOT) |
| return bool(kept) or base_built and build.NO_SUCH_EXAMPLE in dropped.values() |
| esp = [b for b in boards if is_espressif(b) and (not examples or builds(b))] |
| if esp and WINDOWS: |
| return f'{", ".join(esp)} need ESP-IDF, which code_size.py does not support on Windows' |
| if esp and not (shutil.which('idf.py') or _idf_image()): |
| return f'{", ".join(esp)} need {ESP_IDF_MISSING}' |
| return None |
| |
| |
| def _idf_command(src_dir, build_dir, name): |
| """The argv that runs idf.py on `src_dir` into `build_dir`: an exported ESP-IDF, else CI's |
| image as this user in a container `name`, with both mounted at their own paths so the |
| elfs' DWARF matches the filters, and each symlinked dependency's real dir at every path |
| its link goes through (a base worktree's links point at the checkout's, which may point |
| at the main checkout's). None when neither is available.""" |
| if shutil.which('idf.py'): |
| return ['idf.py'] |
| if not _idf_image(): |
| return None |
| # the image enables ccache; kept here it serves the next run (its default hash_dir keeps |
| # one tree's DWARF paths out of the other's objects) |
| cache = os.path.join(CODE_SIZE_DIR, '_ccache') |
| os.makedirs(cache, exist_ok=True) |
| mounts = {p: os.path.realpath(p) for p in map(os.path.abspath, (src_dir, build_dir, cache))} |
| for dep in runpy.run_path(os.path.join(src_dir, 'tools', 'get_deps.py'))['deps_all']: |
| path = os.path.join(src_dir, dep) |
| mounts.update((hop, os.path.realpath(path)) for hop in _link_hops(path)) |
| cmd = ['docker', 'run', '--rm', '--name', name, '--user', f'{os.getuid()}:{os.getgid()}', |
| '-e', 'HOME=/tmp', '-e', f'CCACHE_DIR={os.path.abspath(cache)}'] |
| for target, source in sorted(mounts.items()): |
| cmd += ['-v', f'{source}:{target}'] |
| return cmd + [ESP_IDF_IMAGE, 'idf.py'] |
| |
| |
| def _build_idf(src_dir, build_dir, board, example): |
| """Build each ESP-IDF example project's app, as tools/build.py does (its bootloader is |
| not sized); the first failure stops.""" |
| import build # tools/build.py; its import has no side effects |
| examples = build.select_examples(board, None if example is None else [example], root=src_dir)[0] |
| if not examples: |
| return subprocess.CompletedProcess([], 1, '', f'{board} builds no example') |
| container = f'tinyusb-code-size-{os.getpid()}' |
| idf = _idf_command(src_dir, build_dir, container) |
| if idf is None: |
| return subprocess.CompletedProcess([], 1, '', f'{board} needs {ESP_IDF_MISSING}') |
| for ex in examples: |
| ret = None |
| try: |
| ret = run(idf + ['-C', os.path.join(src_dir, 'examples', ex), '-B', os.path.join(build_dir, ex), |
| '-GNinja', f'-DBOARD={board}', 'app'], timeout=600) |
| finally: |
| # stopping the docker CLI leaves its container building; --rm only removes an exited one |
| if idf[0] == 'docker' and (ret is None or ret.returncode == 124): |
| run(['docker', 'rm', '-f', container]) |
| if ret.returncode != 0: |
| break |
| return ret |
| |
| |
| class Skipped(str): |
| """build_board()'s result for an example its tree does not build: why.""" |
| |
| |
| class Absent(Skipped): |
| """An example its tree does not have: a diff's other side may, so it never makes a scope skipped.""" |
| |
| |
| def build_board(src_dir, build_dir, board, example, label): |
| """Configure and build examples for a board as a `label` progress phase, printing |
| an excerpt of the output on failure. Returns None on success, Skipped when `example` |
| is one the tree does not build for the board, else build_error(). |
| |
| When `example` is given, only that target is built (`ninja -C DIR NAME`), |
| keeping single-example workflows fast. An espressif board builds each example as |
| its own ESP-IDF project. |
| """ |
| import build # tools/build.py; its import has no side effects |
| phase = Phase(label) |
| if example: |
| kept, dropped = build.select_examples(board, [example], root=src_dir) |
| if not kept: |
| skip = (Absent(f'{example} is not in this tree') if dropped[example] == build.NO_SUCH_EXAMPLE |
| else Skipped(f'{board} does not build {example}')) |
| phase.done(skipped=skip) |
| return skip |
| os.makedirs(build_dir, exist_ok=True) |
| if is_espressif(board, src_dir): |
| ret = _build_idf(src_dir, build_dir, board, example) |
| else: |
| name = example and os.path.basename(example) |
| # build.py always passes its default toolchain |
| ret = run(build.cmake_configure_cmd(board, build_dir, [f'-DTOOLCHAIN={build.DEFAULT_TOOLCHAIN}'], |
| os.path.join(src_dir, 'examples'))) |
| # skip.txt/only.txt only mirror the CMake family filter: the configure is the truth |
| if ret.returncode == 0 and name and build.registered_examples(build_dir, [example]) == []: |
| skip = Skipped(f'{board} has no {name} target') |
| phase.done(skipped=skip) |
| return skip |
| if ret.returncode == 0: |
| # ninja itself, not `cmake --build`: cmake does not pass a timeout's SIGTERM on |
| ret = run(['ninja', '-C', build_dir] + ([name] if name else []), timeout=600) |
| failed = ret.returncode != 0 |
| phase.done(failed=failed) |
| if not failed: |
| return None |
| print('\n'.join(' ' + line for line in output_excerpt(ret))) |
| return build_error(ret, src_dir) |
| |
| |
| def report_path(board, example, kind='diff'): |
| """A scope's report path without extension: cmake-code-size/<board>/<kind>[_<ex>].""" |
| suffix = f'_{example.replace("/", "_")}' if example else '' |
| return os.path.join(CODE_SIZE_DIR, board, f'{kind}{suffix}') |
| |
| |
| def drop_stale_reports(boards, examples, kind): |
| """Remove the reports a run will write before anything can fail: a run that stops |
| early must not leave a previous run's report for a reader to take for this one's, |
| since cmake-code-size/ is gitignored and persists. .json too: a run without --json |
| must not leave an older one beside a newer .md.""" |
| for board in boards: |
| for example in examples: |
| for ext in ('md', 'json'): |
| stale_path = f'{report_path(board, example, kind)}.{ext}' |
| if os.path.isfile(stale_path): |
| os.remove(stale_path) |
| |
| |
| def generate_sizes(build_dir, filters, example=None, engine='membrowse'): |
| """Return (sizes, errors) for the scope's elfs. |
| |
| `sizes` maps each elf path relative to `build_dir` to its |
| ENGINES[engine] sizes, or to None when that failed; `errors` |
| lists (relative elf path, message), the path None when no elf can be |
| sized at all. |
| """ |
| # escape the dir, not the wildcards: a checkout path is a path, not a pattern |
| root = glob.escape(build_dir) |
| # <role>/<example>/*.elf: deeper elfs are helpers, e.g. pico-sdk's bs2_default.elf, and |
| # cmake's _deps/ holds fetched tools, e.g. picotool's enc_bootloader.elf |
| elfs = sorted(e for e in glob.glob(f'{root}/{example or "*/*"}/*.elf') |
| if not os.path.relpath(e, build_dir).startswith('_deps' + os.sep)) |
| if not elfs: |
| return {}, [(None, f'no .elf files in {build_dir}')] |
| |
| sizer = ENGINES[engine].sizes |
| |
| def report(elf): |
| try: |
| return sizer(elf, filters), None |
| except RuntimeError as e: |
| return None, str(e) |
| |
| # report errors are caught here, not as tracebacks after both builds already ran |
| try: |
| with concurrent.futures.ThreadPoolExecutor() as pool: |
| results = list(pool.map(report, elfs)) |
| except FileNotFoundError as e: |
| return {}, [(None, f'{engine} not found ({e}) - install {ENGINES[engine].install}, ' |
| f'or pick another --engine')] |
| sizes, errors = {}, [] |
| for elf, (elf_sizes, error) in zip(elfs, results): |
| rel = os.path.relpath(elf, build_dir).replace(os.sep, '/') # one elf id on every host |
| sizes[rel] = elf_sizes |
| if error: |
| errors.append((rel, error)) |
| return sizes, errors |
| |
| |
| def size_tree(build_dir, board, example, filters, engine): |
| """A built tree's scope sized: ({(board, elf): sizes}, [((board, elf), stage, message)]), |
| generate_sizes() keyed by board.""" |
| sizes, errors = generate_sizes(build_dir, filters, example, engine) |
| return {(board, rel): v for rel, v in sizes.items()}, [((board, rel), 'report', msg) for rel, msg in errors] |
| |
| |
| def diff_summary(data, changed, files_label='TinyUSB'): |
| """Console lines of one diff from its JSON-shaped `data` and its `changed` pair count: |
| coverage, the single pair's filtered Δ, unmatched elfs and failures.""" |
| pairs = {(p['board'], p['elf']): (p['base'], p['current']) for p in data['pairs']} |
| line = f'{len(pairs)} pair{"" if len(pairs) == 1 else "s"}, {changed} changed' |
| if len(pairs) == 1: |
| (b, c), = pairs.values() |
| df, dr = _delta(_files_total(b['files']), _files_total(c['files'])) |
| line += f'; {files_label} Flash Δ {_fmt(df)}, RAM Δ {_fmt(dr)}' |
| lines = [line if data['status'] == 'complete' else f'INCOMPLETE: {line}'] |
| for key in ('base_only', 'current_only'): |
| if data[key]: |
| lines.append(f'{key.replace("_", "-")}: ' + ', '.join(_label((r['board'], r['elf'])) for r in data[key])) |
| lines += [f'FAILED {_label((f["board"], f["elf"]))} {f["side"]} {f["stage"]}: {f["message"]}' |
| for f in data['failures']] |
| return lines |
| |
| |
| def report_summary(sizes, failures, ok, files_label='TinyUSB'): |
| """Console lines of one report: coverage, the single elf's filtered total, failures.""" |
| sized = [s for s in sizes.values() if s is not None] |
| line = f'{len(sized)} of {len(sizes)} elfs sized' |
| if len(sized) == 1: |
| src = _files_total(sized[0]['files']) |
| line += f'; {files_label} Flash {src["flash"]}, RAM {src["ram"]}' |
| lines = [line if ok else f'INCOMPLETE: {line}'] |
| return lines + [f'FAILED {_label(i)} {stage}: {message}' for i, stage, message in failures] |
| |
| |
| def print_result(lines, prefix='', tables=()): |
| """A scope's result lines, then `tables` (Markdown, or an elf's label heading its |
| tables), indented under its phases.""" |
| for i, line in enumerate(lines): |
| print(f' {prefix}{line}' if i == 0 else f' {line}') |
| for table in tables: |
| print('\n' + '\n'.join(f' {row}' for row in table.rstrip('\n').split('\n'))) |
| if tables: |
| print() |
| |
| |
| def _shown(path): |
| """`path` relative to the working directory when under it.""" |
| try: |
| rel = os.path.relpath(path) |
| except ValueError: # on another Windows drive |
| return path |
| return path if rel.startswith('..') else rel |
| |
| |
| def write_report(path, md, data=None): |
| """Write a report to `path`.md, and `data` to `path`.json when given, printing |
| their paths.""" |
| # a board's dir exists only once something built there |
| os.makedirs(os.path.dirname(path), exist_ok=True) |
| with open(f'{path}.md', 'w') as f: |
| f.write(md) |
| print(f' report: {_shown(path)}.md') |
| if data is not None: |
| with open(f'{path}.json', 'w') as f: |
| json.dump(data, f, indent=1, sort_keys=True) |
| f.write('\n') |
| print(f' json: {_shown(path)}.json') |
| |
| |
| def _focused(boards, examples): |
| """One board and one example: the console also gets that scope's tables.""" |
| return len(boards) == 1 and bool(_single_example(examples)) |
| |
| |
| def _labelled(tables_by_elf, elfs): |
| """Console tables of {elf id: tables}, each elf's under its label when the scope |
| has several `elfs`, shown or not.""" |
| many = elfs > 1 |
| return [t for elf_id, tables in tables_by_elf.items() |
| for t in ([f'{_label(elf_id)}:'] if many else []) + list(tables)] |
| |
| |
| def _single_example(examples): |
| """The board header's ` / <example>` when one example is sized.""" |
| return f' / {examples[0]}' if len(examples) == 1 and examples[0] else '' |
| |
| |
| def _scope_label(examples, example): |
| """A phase label's ` <example>` suffix, needed only when a board has several.""" |
| return f' {example}' if example and len(examples) > 1 else '' |
| |
| |
| def _build_failed(example, error): |
| return f'build failed{f" ({example})" if example else ""}: {error}' |
| |
| |
| def run_report(args): |
| """`report`: build and size the working tree, one report per board and example.""" |
| filters = args.filter or [tinyusb_src_filter(TINYUSB_ROOT)] |
| files_label = 'filtered' if args.filter else 'TinyUSB' |
| examples = args.example or [None] |
| drop_stale_reports(args.board, examples, 'report') |
| focused = _focused(args.board, examples) |
| print(f'report working tree · {args.engine}') |
| failed = False |
| for n, board in enumerate(args.board, 1): |
| print(f'[{n}/{len(args.board)}] {board}{_single_example(examples)}') |
| build = os.path.join(CODE_SIZE_DIR, board, 'build') |
| shutil.rmtree(build, ignore_errors=True) |
| # each scope is its own report, so one example's build failure spares the others |
| for example in examples: |
| scope = _scope_label(examples, example) |
| sizes, failures = {}, [] |
| error = build_board(TINYUSB_ROOT, build, board, example, f'build{scope}') |
| if isinstance(error, Skipped): |
| continue |
| if error: |
| failures.append(((board, None), 'build', _build_failed(example, error))) |
| else: |
| phase = Phase(f'size{scope}') |
| sizes, failures = size_tree(build, board, example, filters, args.engine) |
| # per scope, as diff: an example need not link TinyUSB (board_test) |
| sized = [s for s in sizes.values() if s is not None] |
| if sized and not any(s['files'] for s in sized): |
| failures.append(((board, None), 'filter', _filter_failure(args.engine))) |
| phase.done(failed=bool(failures)) |
| md = render_report(sizes, args.engine, failures, [board], args.symbols, files_label) |
| ok = not failures and any(s is not None for s in sizes.values()) |
| failed |= not ok |
| data = None |
| if args.json: |
| data = {'engine': args.engine, 'boards': [board], 'filters': filters, |
| 'status': 'complete' if ok else 'INCOMPLETE', |
| 'elfs': [{**_elf_record(i), 'sizes': _json_sizes(s, args.symbols)} |
| for i, s in sizes.items() if s is not None], |
| 'failures': [{**_elf_record(i), 'stage': stage, 'message': message} |
| for i, stage, message in failures]} |
| tables = _labelled({i: [size_table(s, args.symbols, args.engine)] for i, s in sizes.items() if s}, |
| len(sizes)) if focused else () |
| print_result(report_summary(sizes, failures, ok, files_label), f'{example}: ' if scope else '', |
| tables) |
| write_report(report_path(board, example, 'report'), md, data) |
| return 1 if failed else 0 |
| |
| |
| def run_diff(args, requested_sha, unsupported): |
| """`diff`: size the working tree against `requested_sha`, its CI snapshots or a local |
| build, one report per board and example, plus one over every board with --combined. |
| `unsupported` names the options that need a local base.""" |
| worktree_dir = os.path.join(CODE_SIZE_DIR, '_worktree') |
| files_label = 'filtered' if args.filter else 'TinyUSB' |
| |
| # 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)] |
| |
| examples = args.example or [None] |
| combined_dir = os.path.join(CODE_SIZE_DIR, '_combined') |
| if args.combined: |
| shutil.rmtree(combined_dir, ignore_errors=True) |
| drop_stale_reports(args.board, examples, 'diff') |
| try: |
| base_source, reason, baseline, snapshots = resolve_base(args.base_source, unsupported, args.baseline_repo, |
| requested_sha) |
| except BaselineUnavailable as e: |
| print(f'Error: CI base unavailable: {e} (--base-source local builds it)') |
| return 1 |
| provenance = {'requested': args.base_source or 'default', 'effective': base_source, 'reason': reason, |
| 'requested_sha': requested_sha, 'base_sha': baseline['sha'] if baseline else requested_sha, |
| 'baseline': baseline} |
| if baseline: |
| subject = _commit_subject(baseline['sha']) |
| base_line = (f'Base: CI snapshots of {_clean(baseline["sha"][:10])}' + (f' "{subject}"' if subject else '') |
| + (f', {baseline["note"]}' if baseline.get('note') else '') |
| + f' {_clean(baseline.get("url"), 200)}') |
| else: |
| base_line = f'Base: local build of {requested_sha[:10]}' + (f' (CI base unavailable: {reason})' if reason else '') |
| |
| if os.path.isdir(worktree_dir): |
| # twice: a killed `worktree add` leaves it locked `initializing` |
| run(['git', '-C', TINYUSB_ROOT, 'worktree', 'remove', '--force', '--force', worktree_dir]) |
| failed = False |
| try: |
| if base_source == 'local': |
| # --detach: check out the commit at a detached HEAD instead of trying to claim |
| # the branch, which may be checked out elsewhere (main repo, another worktree) |
| ret = run(['git', '-C', TINYUSB_ROOT, 'worktree', 'add', '--detach', worktree_dir, requested_sha]) |
| if ret.returncode != 0: |
| print(f'Error creating worktree: {ret.stderr}') |
| sys.exit(1) |
| symlink_deps(TINYUSB_ROOT, worktree_dir) |
| else: |
| base_shards, base_failures = baseline_shards(snapshots, baseline, args.board) |
| # a failure of the downloaded run itself, not of one board, belongs in every report |
| run_failures = [f for f in base_failures if f[0][0] not in args.board] |
| membrowse_version = _membrowse_version() |
| base_sha = provenance['base_sha'] |
| current_rev = short_hash(TINYUSB_ROOT) |
| base_label = short_hash(worktree_dir) if base_source == 'local' else base_sha[:10] |
| labels = (base_label or SIDE_LABELS[0], current_rev or SIDE_LABELS[1]) |
| print(f'diff {args.base_branch} ({labels[0]}) vs working tree ({labels[1]}) · {args.engine}') |
| if base_source == 'ci' or reason: |
| print(f' {base_line}') |
| focused = _focused(args.board, examples) |
| |
| def report_data(data): |
| """The JSON report for --json, None without it.""" |
| if not args.json: |
| return None |
| return {**data, 'base_ref': args.base_branch, 'base_sha': base_sha, 'current_rev': current_rev, |
| 'base_source': provenance, |
| 'filters': {'base': base_filters if base_source == 'local' else None, 'current': cur_filters}} |
| |
| def preface(warnings): |
| """The report's base provenance and metadata warnings, above its tables.""" |
| return '\n'.join([base_line] + [f'- {w}' for w in warnings]) + '\n\n' |
| |
| def write_scope(board, example, sides, failures, warnings, symbols, phase=None): |
| """Compare one scope's sides, print its result and write its report: (ok, failures).""" |
| scope = _scope_label(examples, example) |
| md, failures, ok, data, changed = compare_sides( |
| sides['base'], sides['current'], args.engine, failures, [board], scope=(board, None), |
| symbols=symbols, labels=labels, files_label=files_label) |
| md = preface(warnings) + md |
| data['warnings'] = warnings |
| if phase: |
| phase.done(failed=not ok) |
| tables = _labelled({i: _pair_tables(b, c, symbols, args.engine, labels) for i, (b, c) in changed.items()}, |
| len(data['pairs'])) if focused else () |
| print_result(diff_summary(data, len(changed), files_label), f'{example}: ' if scope else '', tables) |
| write_report(report_path(board, example), md, report_data(data)) |
| return ok, failures |
| |
| # --combined: every board's elf sizes and failures, paired at the end |
| combined_sides = {'base': {}, 'current': {}} |
| combined_failures = [] |
| combined_warnings = [] |
| combined_symbols = args.symbols |
| skipped_scopes = 0 |
| # side: (checkout, filters, build phase label) |
| trees = {'base': (worktree_dir, base_filters, f'build {args.base_branch}'), |
| 'current': (TINYUSB_ROOT, cur_filters, 'build current')} |
| local_sides = ['base', 'current'] if base_source == 'local' else ['current'] |
| |
| for n, board in enumerate(args.board, 1): |
| print(f'[{n}/{len(args.board)}] {board}{_single_example(examples)}') |
| board_dir = os.path.join(CODE_SIZE_DIR, board) |
| build_dirs = {'base': os.path.join(board_dir, 'base'), 'current': os.path.join(board_dir, 'build')} |
| for build_dir in build_dirs.values(): |
| shutil.rmtree(build_dir, ignore_errors=True) |
| |
| build_failure, skipped = None, {} |
| for example in examples: |
| for side in local_sides: |
| src, _filters, label = trees[side] |
| error = build_board(src, build_dirs[side], board, example, label + _scope_label(examples, example)) |
| if isinstance(error, Skipped): |
| skipped[side, example] = error |
| elif error: |
| build_failure = ((board, None), side, 'build', _build_failed(example, error)) |
| break |
| if build_failure: |
| break |
| if build_failure: |
| failed = True |
| # still write each scope's report, with the build failure in it |
| combined_failures.append(build_failure) |
| for example in examples: |
| write_scope(board, example, {'base': {}, 'current': {}}, [build_failure], [], args.symbols) |
| continue |
| |
| warnings, symbols, shard = [], args.symbols, base_shards.get(board) if base_source == 'ci' else None |
| # nothing built, no compiler to compare: every scope here is skipped or fails without one |
| if shard and not all(('current', e) in skipped for e in examples): |
| warnings = metadata_warnings(board, shard, _cmake_compiler(build_dirs['current']), membrowse_version) |
| if symbols and not all('symbols' in s for s in shard['elfs'].values()): |
| symbols = False |
| warnings.append(f'{board}: symbols omitted: the CI snapshot has none') |
| combined_symbols = combined_symbols and symbols |
| combined_warnings += warnings |
| for w in warnings: |
| print(f' WARNING {w}') |
| |
| for example in examples: |
| all_skipped = all(type(skipped.get((side, example))) is Skipped for side in local_sides) |
| phase = None if all_skipped else Phase(f'size and compare{_scope_label(examples, example)}') |
| sides, failures = {'base': {}, 'current': {}}, [] |
| for side in local_sides: |
| if (side, example) not in skipped: |
| _src, filters, _label = trees[side] |
| sides[side], errors = size_tree(build_dirs[side], board, example, filters, args.engine) |
| failures += [(i, side, stage, msg) for i, stage, msg in errors] |
| if base_source == 'ci': |
| failures += run_failures + [f for f in base_failures if f[0][0] == board] |
| cur_elfs = {elf: s for (_b, elf), s in sides['current'].items()} |
| if shard: |
| sides['base'], scoped, sides['current'], _out = _pair_base_shard( |
| board, shard, cur_elfs, [example] if example else None) |
| failures += scoped |
| else: # no usable baseline: nothing of this board is new, none compared |
| sides['current'] = {} |
| # a CI base proves the skip only when its leg selected the example |
| base_covers = base_source == 'local' or ( |
| shard is not None and (shard['_examples'] is None or example in shard['_examples'])) |
| if all_skipped and base_covers and not failures and not any(sides.values()): |
| skipped_scopes += 1 |
| continue |
| |
| ok, failures = write_scope(board, example, sides, failures, warnings, symbols, phase) |
| failed |= not ok |
| for side, sizes in sides.items(): |
| combined_sides[side].update(sizes) |
| combined_failures += failures |
| |
| if args.bloaty and example: |
| elf_name = os.path.basename(example) |
| base_elf = os.path.join(build_dirs['base'], example, f'{elf_name}.elf') |
| cur_elf = os.path.join(build_dirs['current'], 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}'] |
| for title, options in (('sections', ['-d', 'compileunits,sections']), |
| ('symbols', ['-d', 'compileunits,symbols', '-s', 'vm'])): |
| print(f'--- bloaty {title} ---') |
| ret = run(bloaty_common + options + [cur_elf, '--', base_elf]) |
| if ret.returncode == 0: |
| print(ret.stdout) |
| else: |
| print(f' bloaty FAILED (exit {ret.returncode})') |
| print('\n'.join(' ' + line for line in output_excerpt(ret))) |
| failed = True |
| else: |
| print(' bloaty: ELF not found') |
| |
| if args.combined and skipped_scopes == len(args.board) * len(examples): |
| print('combined: all scopes skipped') |
| elif args.combined: |
| print(f'combined ({len(args.board)} boards)') |
| # every scope was filter-checked above, and its failures carried over |
| # a board's or the run's baseline failure is in each of its scopes' failures |
| md, _failures, ok, data, changed = compare_sides( |
| combined_sides['base'], combined_sides['current'], args.engine, list(dict.fromkeys(combined_failures)), |
| args.board, symbols=combined_symbols, labels=labels, files_label=files_label) |
| md = preface(combined_warnings) + md |
| data['warnings'] = combined_warnings |
| failed |= not ok |
| print_result(diff_summary(data, len(changed), files_label) |
| + ([f' {len(combined_warnings)} metadata warnings, see the report'] if combined_warnings else [])) |
| write_report(os.path.join(combined_dir, 'diff'), md, report_data(data)) |
| finally: |
| # an add stopped by a signal leaves it too, locked `initializing` |
| ret = run(['git', '-C', TINYUSB_ROOT, 'worktree', 'remove', '--force', '--force', worktree_dir]) \ |
| if os.path.isdir(worktree_dir) else None |
| if ret and ret.returncode != 0: |
| print(f'Error removing worktree {worktree_dir}: {ret.stderr.strip()}') |
| failed = True |
| return 1 if failed else 0 |
| |
| |
| SNAPSHOT_SCHEMA = 1 |
| _SHA_RE = re.compile(r'[0-9a-f]{40}') |
| |
| |
| def _cmake_compiler(build_dir): |
| """{'id', 'version', 'name', 'build_type'} of a configured build dir, from CMake's own |
| compiler detection (not the host gcc), '' where CMake recorded none.""" |
| info = {'id': '', 'version': '', 'name': '', 'build_type': ''} |
| found = glob.glob(os.path.join(glob.escape(build_dir), 'CMakeFiles', '*', 'CMakeCCompiler.cmake')) |
| if not found: # ESP-IDF: one project per <role>/<example>, all on the board's one toolchain |
| projects = sorted(glob.glob(os.path.join(glob.escape(build_dir), '*', '*', 'CMakeFiles'))) |
| return _cmake_compiler(os.path.dirname(projects[0])) if projects else info |
| for path in found: |
| with open(path) as f: |
| text = f.read() |
| for key, var in (('id', 'CMAKE_C_COMPILER_ID'), ('version', 'CMAKE_C_COMPILER_VERSION'), |
| ('name', 'CMAKE_C_COMPILER')): |
| m = re.search(rf'^set\({var} "([^"]*)"\)', text, re.M) |
| if m: |
| info[key] = m.group(1) |
| # without .exe, so a Windows build's name matches a Linux snapshot's |
| info['name'] = re.sub(r'\.exe$', '', os.path.basename(info['name']), flags=re.I) |
| cache = os.path.join(build_dir, 'CMakeCache.txt') |
| if os.path.isfile(cache): |
| with open(cache) as f: |
| m = re.search(r'^CMAKE_BUILD_TYPE:\w+=(.*)$', f.read(), re.M) |
| info['build_type'] = m.group(1) if m else '' |
| return info |
| |
| |
| def _git_shas(event): |
| """(sha, base_sha, head_sha) of the checkout: a pull_request build checks out GitHub's |
| merge commit, whose first parent is the base branch tip it was built against and |
| second parent the PR head; any other build is its own base and head.""" |
| ret = run(['git', '-C', TINYUSB_ROOT, 'rev-parse', 'HEAD', 'HEAD^1', 'HEAD^2'] |
| if event == 'pull_request' else ['git', '-C', TINYUSB_ROOT, 'rev-parse', 'HEAD']) |
| shas = ret.stdout.split() if ret.returncode == 0 else [] |
| if not shas or not all(_SHA_RE.fullmatch(s) for s in shas): |
| raise RuntimeError(f'cannot resolve the checkout commits for a {event} build: {ret.stderr.strip()}') |
| return (shas[0], shas[1], shas[2]) if len(shas) == 3 else (shas[0], shas[0], shas[0]) |
| |
| |
| def _membrowse_version(): |
| """The installed membrowse's version, '' when it cannot be read.""" |
| try: |
| from importlib.metadata import version |
| return version('membrowse') |
| except Exception: |
| return '' |
| |
| |
| def snapshot_boards(families, boards, examples, defines=()): |
| """The CI-pinned boards a build leg produced, as the Membrowse upload step picks them: |
| each family's pinned boards that build one of `examples` (build.py's |
| resolve_ci_boards, boards-only), plus each `-b` board that is pinned. `defines` are |
| the leg's -D tokens, which can enable examples (MAX3421_HOST=1).""" |
| import build # tools/build.py; its import has no side effects |
| pinned = ci_pinned_boards() |
| # build.py skips a -b board that builds none of the examples, before configuring it |
| picked = [b for b in boards if b in pinned and build.builds_any(b, examples, defines)] |
| for family in families: |
| picked += build.resolve_ci_boards(CI_PINNED_BOARDS, family, True, examples, extra_defines=defines) |
| return list(dict.fromkeys(picked)) |
| |
| |
| def board_snapshot(board, build_dir, examples, filters, defines=()): |
| """(elfs, failures) of one board's build dir: {elf path: sizes} of every sized elf, |
| and [{'elf', 'stage', 'message'}]. A missing build dir, an example of the scope the |
| board builds but has no elf for, or an elf that failed to size is a failure, never a |
| zero; a board whose elfs all match no TinyUSB file fails the filter check.""" |
| if not os.path.isdir(build_dir): |
| return {}, [{'elf': None, 'stage': 'build', 'message': f'no build dir {_shown(build_dir)}'}] |
| elfs, failures = {}, [] |
| scopes = [e for e in dict.fromkeys(examples) |
| if not build_utils.skip_example(e, board, defines)] if examples else [None] |
| for example in scopes: |
| sizes, errors = generate_sizes(build_dir, filters, example) |
| for rel, s in sizes.items(): |
| if s is not None and not _valid_sizes(s): |
| errors.append((rel, 'sizes are not integer byte counts of files, sections and symbols')) |
| elif s is not None: |
| elfs[rel] = s |
| failures += [{'elf': rel, 'stage': 'build' if rel is None else 'report', |
| 'message': f'{example}: {msg}' if example and rel is None else msg} |
| for rel, msg in errors] |
| if elfs and not any(s['files'] for s in elfs.values()): |
| failures.append({'elf': None, 'stage': 'filter', 'message': _filter_failure('membrowse')}) |
| return elfs, failures |
| |
| |
| def run_snapshot(args): |
| """`snapshot`: size the CI-pinned boards a build leg already built, one |
| code-size-<board>.json each, for `compare` against another run's snapshots.""" |
| os.chdir(TINYUSB_ROOT) # build_utils and build.py resolve hw/bsp and examples from the cwd |
| sha, base_sha, head_sha = _git_shas(args.event) |
| membrowse_version = _membrowse_version() |
| filters = args.filter or [tinyusb_src_filter(TINYUSB_ROOT)] |
| defines = tuple(args.define_symbol) |
| boards = snapshot_boards(args.families, args.board, args.example, defines) |
| # a variant leg (--build-name) reports its one board under the build name |
| names = {args.build_name or b: b for b in boards} |
| os.makedirs(args.output, exist_ok=True) |
| # written even with no board: `compare` tells a leg that ran from one that never did |
| with open(os.path.join(args.output, 'leg.json'), 'w') as f: |
| json.dump({'schema': SNAPSHOT_SCHEMA, 'examples': args.example, 'boards': list(names), |
| 'build_outcome': args.build_outcome, 'sha': sha, 'base_sha': base_sha, 'head_sha': head_sha}, |
| f, sort_keys=True) |
| if not boards: |
| print('snapshot: no CI-pinned board in this leg') |
| return 0 |
| failed = False |
| for board, base_board in names.items(): |
| build_dir = os.path.join(args.build_root, f'cmake-build-{board}') |
| elfs, failures = board_snapshot(base_board, build_dir, args.example, filters, defines) |
| # the leg's commits, examples and build outcome are in its leg.json |
| data = {'schema': SNAPSHOT_SCHEMA, 'board': board, 'engine': 'membrowse', |
| 'membrowse_version': membrowse_version, 'compiler': _cmake_compiler(build_dir), |
| 'elfs': {rel: _json_sizes(s, args.symbols) for rel, s in sorted(elfs.items())}, |
| 'failures': failures} |
| path = os.path.join(args.output, f'code-size-{board}.json') |
| with open(path, 'w') as f: |
| json.dump(data, f, sort_keys=True, separators=(',', ':')) |
| failed |= bool(failures) |
| print(f' {board}: {len(elfs)} elfs sized, {len(failures)} failures -> {_shown(path)}') |
| for fail in failures: |
| print(f' FAILED {fail["elf"] or board} {fail["stage"]}: {fail["message"]}') |
| return 1 if failed else 0 |
| |
| |
| _NAME_RE = re.compile(r'[A-Za-z0-9_.+/-]+') |
| COMMENT_LIMIT = 60000 # GitHub caps a comment at 65536 characters |
| |
| |
| def _clean(text, limit=300): |
| """Snapshot text for a report: a snapshot is PR-built data, so nothing in it may open |
| Markdown, HTML or an @mention.""" |
| return re.sub(r'[^A-Za-z0-9_.,:;=()/+ -]', '?', str(text))[:limit] |
| |
| |
| def _is_count(value): |
| return isinstance(value, int) and not isinstance(value, bool) |
| |
| |
| def _region(value): |
| return isinstance(value, dict) and set(value) == {'flash', 'ram'} and all(map(_is_count, value.values())) |
| |
| |
| def _int_map(value, depth): |
| """Whether `value` is `depth` levels of str-keyed dicts over byte counts.""" |
| return isinstance(value, dict) and all( |
| isinstance(k, str) and (_int_map(v, depth - 1) if depth > 1 else _is_count(v)) for k, v in value.items()) |
| |
| |
| def _valid_sizes(s): |
| return (isinstance(s, dict) and {'files', 'all', 'sections'} <= set(s) <= {'files', 'all', 'sections', 'symbols'} |
| and _region(s['all']) and isinstance(s['files'], dict) |
| and all(isinstance(k, str) and _region(v) for k, v in s['files'].items()) |
| and _int_map(s['sections'], 2) and ('symbols' not in s or _int_map(s['symbols'], 3))) |
| |
| |
| def _is_name(value): |
| return isinstance(value, str) and bool(_NAME_RE.fullmatch(value)) and '..' not in value.split('/') |
| |
| |
| def _names(value, none_ok=False): |
| return (none_ok and value is None) or (isinstance(value, list) and all(map(_is_name, value))) |
| |
| |
| def _commits(data): |
| """(sha, base_sha, head_sha), or None unless all are 40-hex.""" |
| shas = tuple(data.get(k) for k in ('sha', 'base_sha', 'head_sha')) |
| return shas if all(isinstance(s, str) and _SHA_RE.fullmatch(s) for s in shas) else None |
| |
| |
| def _scope_error(data): |
| legs, fam = data.get('legs'), data.get('family_examples', {}) |
| if not isinstance(data.get('code_changed'), bool): |
| return 'code_changed is not a boolean' |
| if not (isinstance(legs, list) and all(isinstance(leg, dict) and isinstance(leg.get('toolchain'), str) |
| and isinstance(leg.get('arg'), str) for leg in legs)): |
| return 'legs are not {toolchain, arg} strings' |
| if not (isinstance(fam, dict) and all(map(_names, fam.values()))): |
| return 'family_examples is not a map of example lists' |
| return None |
| |
| |
| def _leg_error(data): |
| if not isinstance(data.get('build_outcome'), str): |
| return 'build_outcome is not a string' |
| if not _names(data.get('boards')): |
| return 'boards is not a list of board names' |
| if 'examples' not in data or not _names(data['examples'], none_ok=True): |
| return 'examples is not a list of example names' |
| return None if _commits(data) else 'commit hashes are not 40-hex' |
| |
| |
| def _shard_error(data, filename): |
| board = data.get('board') |
| if not (_is_name(board) and filename == f'code-size-{board}.json'): |
| return 'board name does not match the file' |
| if data.get('engine') != 'membrowse': |
| return 'unsupported engine' |
| elfs, failures = data.get('elfs'), data.get('failures') |
| if not isinstance(elfs, dict) or not isinstance(failures, list): |
| return 'no elfs/failures' |
| for elf, sizes in elfs.items(): |
| if not _is_name(elf): |
| return 'invalid elf path' |
| if not _valid_sizes(sizes): |
| return f'{elf}: sizes are not integer byte counts' |
| if not all(isinstance(f, dict) and 'elf' in f and (f['elf'] is None or _is_name(f['elf'])) |
| and isinstance(f.get('stage'), str) and isinstance(f.get('message'), str) for f in failures): |
| return 'failures are not {elf, stage, message} records' |
| return None |
| |
| |
| def load_snapshots(root): |
| """Every snapshot file under `root` (one directory per downloaded artifact): |
| {'scope': scope.json or None, 'legs': {artifact dir: leg.json}, 'shards': {board: shard}, |
| 'errors': [(file, why)]}. A shard records its artifact dir as '_leg'. An unusable file |
| is an error, never guessed at.""" |
| run = {'scope': None, 'legs': {}, 'shards': {}, 'errors': []} |
| for dirpath, _dirs, files in sorted(os.walk(root)): |
| artifact = os.path.basename(dirpath) |
| for name in sorted(f for f in files if f.endswith('.json')): |
| rel = os.path.relpath(os.path.join(dirpath, name), root) |
| try: |
| with open(os.path.join(dirpath, name)) as f: |
| data = json.load(f) |
| except (OSError, ValueError): |
| run['errors'].append((rel, 'unreadable JSON')) |
| continue |
| if not isinstance(data, dict) or data.get('schema') != SNAPSHOT_SCHEMA: |
| why = 'unsupported snapshot schema' |
| elif name == 'scope.json': |
| why = _scope_error(data) |
| run['scope'] = None if why else data |
| elif name == 'leg.json': |
| why = _leg_error(data) |
| if not why: |
| run['legs'][artifact] = data |
| elif name.startswith('code-size-'): |
| why = _shard_error(data, name) |
| if not why and data['board'] in run['shards']: |
| why = f'second snapshot of {data["board"]}' |
| if not why: |
| run['shards'][data['board']] = {**data, '_leg': artifact} |
| else: |
| why = 'not a snapshot file' |
| if why: |
| run['errors'].append((rel, why)) |
| return run |
| |
| |
| def _leg_examples(scope, arg): |
| """The examples build_util.yml builds for a scope leg: its arg's own -e plus the scope's |
| family map entry for that arg, None for all.""" |
| return sorted(set(re.findall(r' -e ([^ ]+)', arg) + scope.get('family_examples', {}).get(arg, []))) or None |
| |
| |
| def _in_scope(elf, examples): |
| """Whether an elf path (<role>/<example>/<name>.elf) is one of `examples`, all when None.""" |
| return examples is None or '/'.join(elf.split('/')[:2]) in examples |
| |
| |
| def _usable_shards(run, side, failures, boards=None): |
| """`run`'s shards listed in the boards of their own leg, whose build succeeded, each given |
| its leg's examples as '_examples'. Returns ({board: shard}, {leg commits}); every |
| rejection is added to `failures`, only of `boards` when given.""" |
| shards, commits = {}, {_commits(leg) for leg in run['legs'].values()} |
| for rel, why in run['errors']: |
| board = re.fullmatch(r'code-size-(.+)\.json', os.path.basename(rel)) |
| if boards is None or not board or board.group(1) in boards: |
| failures.append(((_clean(rel), None), side, 'snapshot', _clean(why))) |
| for board, shard in sorted(run['shards'].items()): |
| if boards is not None and board not in boards: |
| continue |
| leg = run['legs'].get(shard['_leg']) |
| if leg is None or board not in leg['boards']: |
| failures.append(((board, None), side, 'snapshot', 'not a board of its leg\'s build')) |
| elif leg['build_outcome'] != 'success': |
| failures.append(((board, None), side, 'build', f'the build step ended {_clean(leg["build_outcome"])}')) |
| else: |
| shards[board] = {**shard, '_examples': leg['examples']} |
| for _artifact, leg in sorted(run['legs'].items()): |
| failures += [((board, None), side, 'snapshot', 'the leg left no snapshot of it') for board in leg['boards'] |
| if board not in run['shards'] and (boards is None or board in boards)] |
| return shards, commits |
| |
| |
| def _scope_failures(scope, legs): |
| """Legs of the scope that left no leg.json, or measured other examples than it selected, |
| and legs that are not of it.""" |
| failures, expected = [], set() |
| for leg in scope['legs']: |
| tag = re.sub(r' -e [^ ]+', '', leg['arg']) # build_util.yml's ARTIFACT_TAG |
| artifact = f'code-size-{leg["toolchain"]}-{tag}' |
| expected.add(artifact) |
| if artifact not in legs: |
| failures.append(((_clean(artifact), None), 'current', 'snapshot', 'the build leg left no snapshot')) |
| elif (legs[artifact]['examples'] is not None # all examples covers any selection |
| and sorted(set(legs[artifact]['examples'])) != _leg_examples(scope, leg['arg'])): |
| failures.append(((_clean(artifact), None), 'current', 'scope', 'measured other examples than selected')) |
| failures += [((_clean(a), None), 'current', 'scope', 'not a leg of this run') for a in sorted(set(legs) - expected)] |
| return failures |
| |
| |
| def _base_commit_failure(commits, baseline): |
| """The failure when a baseline run's snapshots are of several commits, or not of the |
| selected `baseline` ({'sha'}, or None for any), else None.""" |
| shas = {c[0] for c in commits} |
| if len(shas) > 1 or (baseline and shas and shas != {baseline.get('sha')}): |
| return (('commits', None), 'base', 'snapshot', 'the snapshots are not of the selected baseline commit') |
| return None |
| |
| |
| def _pair_base_shard(board, base, cur_elfs, examples): |
| """One board's current elfs ({elf: sizes}) against its baseline shard, within |
| `examples` (None for all): (base sizes, base failures, current sizes, baseline elfs |
| outside `examples`). A current elf whose baseline failed is left out: that failure is |
| the baseline's, never a new elf here.""" |
| sizes, failures, failed, outside = {}, [], set(), 0 |
| for f in base['failures']: |
| if f['elf'] is None or _in_scope(f['elf'], examples): |
| failures.append(((board, f['elf']), 'base', _clean(f['stage']), _clean(f['message']))) |
| failed.add(f['elf']) |
| for elf, s in base['elfs'].items(): |
| if _in_scope(elf, examples): |
| sizes[(board, elf)] = s |
| else: |
| outside += 1 |
| return sizes, failures, {(board, elf): s for elf, s in cur_elfs.items() if elf not in failed}, outside |
| |
| |
| def compare_runs(base, cur, baseline=None, symbols=False): |
| """Compare two load_snapshots() runs, `cur` defining the scope. Returns |
| (full Markdown, comment Markdown, JSON data). `baseline` is code_size_ci.py's |
| description of the base run ({'sha', 'url', 'exact', 'note'}), or None. Anything that |
| leaves coverage short is a failure; a comparable difference is a note.""" |
| notes, failures = [], [] |
| scope = cur['scope'] |
| if scope is None: |
| failures.append((('scope', None), 'current', 'snapshot', 'no usable scope manifest')) |
| elif not (scope['legs'] or cur['legs'] or cur['shards'] or cur['errors']): |
| md = 'Nothing to measure: no code change, or no CI-pinned build leg selected.\n' |
| return md, f'## Code size\n\n{md}', {'status': 'nothing measured'} |
| else: |
| failures += _scope_failures(scope, cur['legs']) |
| cur_shards, cur_commits = _usable_shards(cur, 'current', failures) |
| base_shards, base_commits = _usable_shards(base, 'base', failures, set(cur_shards)) |
| labels = SIDE_LABELS |
| if len(cur_commits) > 1: |
| failures.append((('commits', None), 'current', 'snapshot', 'the snapshots are of different commits')) |
| cur_shards = {} |
| elif cur_commits: |
| (_sha, base_sha, head_sha), = cur_commits |
| labels = (base_sha[:10], head_sha[:10]) |
| if commit_failure := _base_commit_failure(base_commits, baseline): |
| failures.append(commit_failure) |
| base_shards = {} |
| |
| sides, no_baseline, outside = {'base': {}, 'current': {}}, [], 0 |
| for board, shard in sorted(cur_shards.items()): |
| failures += [((board, f['elf']), 'current', _clean(f['stage']), _clean(f['message'])) for f in shard['failures']] |
| b = base_shards.get(board) |
| if b is None: |
| no_baseline.append(board) |
| failures.append(((board, None), 'base', 'snapshot', 'no baseline snapshot')) |
| continue |
| for key in ('membrowse_version', 'compiler'): |
| if b.get(key) != shard.get(key): |
| notes.append(f'{board}: {key} differs from the baseline ({_clean(b.get(key))} -> ' |
| f'{_clean(shard.get(key))}): comparable, not exact') |
| base_sizes, base_failures, cur_sizes, out = _pair_base_shard(board, b, shard['elfs'], shard['_examples']) |
| sides['base'].update(base_sizes) |
| sides['current'].update(cur_sizes) |
| failures += base_failures |
| outside += out |
| if outside: |
| notes.append(f'{outside} baseline elfs are of examples this PR did not build (outside coverage)') |
| |
| if symbols and not all('symbols' in s for side in sides.values() for s in side.values()): |
| symbols = False |
| notes.append('symbols omitted: some snapshots were taken without them') |
| head = [] |
| if baseline: |
| kind = 'exact' if baseline.get('exact') else 'approximate' |
| head.append((f'Baseline: {_clean(baseline["sha"][:10])} ({kind}) {_clean(baseline.get("url"), 200)}' |
| if baseline.get('sha') else 'Baseline: unavailable') |
| + (f' - {_clean(baseline["note"])}' if baseline.get('note') else '')) |
| head += [f'- {n}' for n in notes] |
| boards = sorted(cur_shards) |
| full, _fails, _ok, data, _changed = compare_sides(sides['base'], sides['current'], 'membrowse', failures, boards, |
| symbols=symbols, labels=labels) |
| pairs, base_only, cur_only = pair_elfs(sides['base'], sides['current']) |
| body, footnote = render_comment(pairs, 'membrowse', base_only, cur_only, failures, symbols) |
| preface = '\n'.join(head) + ('\n\n' if head else '') |
| comment = f'## Code size\n\n{preface}{body}' |
| truncated = '\n\n_Truncated: see the full report._\n' |
| if len(comment) + len(footnote) > COMMENT_LIMIT: |
| comment = comment[:COMMENT_LIMIT - len(footnote) - len(truncated)].rsplit('\n', 1)[0] + truncated |
| comment += footnote |
| data.update({'notes': notes, 'baseline': baseline, 'no_baseline': no_baseline, 'outside': outside}) |
| return preface + full, comment, data |
| |
| |
| def run_compare(args): |
| """`compare`: compare a run's snapshots against a baseline run's, no build.""" |
| baseline = None |
| if args.baseline_info: |
| with open(args.baseline_info) as f: |
| baseline = json.load(f) |
| base = load_snapshots(args.base) # os.walk of a missing directory yields nothing |
| full, comment, data = compare_runs(base, load_snapshots(args.current), baseline, args.symbols) |
| os.makedirs(args.output, exist_ok=True) |
| write_report(os.path.join(args.output, 'code-size'), full, data) |
| with open(os.path.join(args.output, 'comment.md'), 'w') as f: |
| f.write(comment) |
| print(f' comment: {_shown(os.path.join(args.output, "comment.md"))}') |
| return 0 |
| |
| BASELINE_REPO = 'hathach/tinyusb' |
| DOWNLOAD_TIMEOUT = 600 # seconds for one run's code-size artifacts |
| |
| |
| class BaselineUnavailable(Exception): |
| """The CI base cannot be had: no gh, no auth, no baseline run, a base off master, or a |
| failed transfer. Never a problem inside downloaded snapshots: those are failures.""" |
| |
| |
| def _download_baseline(ci, repo, run_id): |
| """The snapshot dir of run `run_id`'s code-size-* artifacts, downloaded once per run |
| attempt into the cache and published whole; a re-run during the download is retried once.""" |
| attempt = ci.gh(f'repos/{repo}/actions/runs/{run_id}')['run_attempt'] |
| for _ in range(2): |
| dest = os.path.join(BASELINE_CACHE_DIR, repo.replace('/', '_'), f'{run_id}-{attempt}') |
| if os.path.isfile(os.path.join(dest, 'manifest.json')): |
| return os.path.join(dest, 'snapshots') |
| tmp = f'{dest}.tmp-{os.getpid()}' |
| shutil.rmtree(tmp, ignore_errors=True) |
| try: |
| ret = run(['gh', 'run', 'download', str(run_id), '-R', repo, '-p', 'code-size-*', |
| '-D', os.path.join(tmp, 'snapshots')], timeout=DOWNLOAD_TIMEOUT) |
| if ret.returncode != 0: |
| raise BaselineUnavailable(f'gh run download {run_id}: {_clean(ret.stderr) or f"exit {ret.returncode}"}') |
| now = ci.gh(f'repos/{repo}/actions/runs/{run_id}')['run_attempt'] |
| if now != attempt: # a re-run replaced artifacts mid-download: this copy is of neither attempt |
| attempt = now |
| continue |
| try: |
| with open(os.path.join(tmp, 'manifest.json'), 'w') as f: |
| json.dump({'repo': repo, 'run_id': run_id, 'run_attempt': attempt}, f, indent=1, sort_keys=True) |
| os.rename(tmp, dest) |
| except OSError as e: |
| if not os.path.isfile(os.path.join(dest, 'manifest.json')): # else another run published it first |
| raise BaselineUnavailable(f'caching run {run_id}: {e}') from e |
| return os.path.join(dest, 'snapshots') |
| finally: |
| shutil.rmtree(tmp, ignore_errors=True) # gone after a successful rename |
| raise BaselineUnavailable(f'run {run_id} was re-run during the download') |
| |
| |
| def ci_baseline(repo, sha): |
| """(snapshot dir, baseline info) of `repo`'s stored master snapshots for `sha`, else |
| its nearest first-parent ancestor's (approximate), as code_size_ci.py picks a PR's. |
| Raises BaselineUnavailable.""" |
| if not shutil.which('gh'): |
| raise BaselineUnavailable('gh not found') |
| sys.modules.setdefault('code_size', sys.modules[__name__]) # its `import code_size` is this module, not a copy |
| ci = _load_module('code_size_ci', os.path.join(TINYUSB_ROOT, '.github', 'scripts', 'code_size_ci.py')) |
| try: |
| info = ci.lookup_sha(repo, sha, 'yours') |
| if info['sha'] is None: |
| raise BaselineUnavailable(info['note']) |
| return _download_baseline(ci, repo, info['run_id']), info |
| except (RuntimeError, KeyError, TypeError, ValueError) as e: # gh auth, transport or an unexpected reply |
| raise BaselineUnavailable(f'the baseline lookup failed: {_clean(str(e))}') from e |
| except OSError as e: # gh could not be started, e.g. EMFILE |
| raise BaselineUnavailable(f'running gh failed: {_clean(e.strerror or e)}') from e |
| |
| |
| def resolve_base(base_source, unsupported, repo, sha): |
| """A diff's base as (source, reason, baseline info, snapshots): the CI snapshots of |
| `sha`, unless `base_source` is 'local' or `unsupported` options need a local base. With |
| no `base_source`, an unavailable CI base falls back to a local build and `reason` says |
| why; with 'ci' it raises BaselineUnavailable.""" |
| if base_source == 'local': |
| return 'local', None, None, None |
| if unsupported: |
| return 'local', f'{", ".join(unsupported)} needs a local base', None, None |
| print(f'CI base: looking up {repo} snapshots of {sha[:10]}…', flush=True) |
| try: |
| snapshot_dir, baseline = ci_baseline(repo, sha) |
| except BaselineUnavailable as e: |
| if base_source == 'ci': |
| raise |
| return 'local', str(e), None, None |
| return 'ci', None, baseline, load_snapshots(snapshot_dir) |
| |
| |
| def baseline_shards(snapshots, baseline, boards): |
| """The base side of a diff from a downloaded run (load_snapshots()): ({board: shard}, |
| failures). Only shards of `boards` from a successful leg of the |
| `baseline` commit count; anything else is a base failure, never a zero, and a failure |
| of the run itself (an unreadable file, other commits) is keyed by what failed, not a |
| board. Shards of other boards, e.g. -DMA variants, are excluded, never substituted.""" |
| failures = [] |
| shards, commits = _usable_shards(snapshots, 'base', failures, set(boards)) |
| if commit_failure := _base_commit_failure(commits, baseline): |
| failures.append(commit_failure) |
| shards = {} |
| failures += [((board, None), 'base', 'snapshot', 'no baseline snapshot') for board in boards |
| if board not in shards and not any(f[0][0] == board for f in failures)] |
| return shards, failures |
| |
| |
| def _commit_subject(sha): |
| """`sha`'s subject from the local history, '' when it is not there (not fetched).""" |
| ret = run(['git', '-C', TINYUSB_ROOT, 'log', '-1', '--format=%s', sha]) |
| return ret.stdout.strip() if ret.returncode == 0 else '' |
| |
| |
| def metadata_warnings(board, shard, compiler, membrowse_version): |
| """A board's warnings when its CI base was measured unlike its current build: the |
| board is still compared, the warning says what its deltas may include.""" |
| base_c = shard.get('compiler') if isinstance(shard.get('compiler'), dict) else {} |
| warnings = [] |
| unknown = [side for side, c in (('base', base_c), ('current', compiler)) |
| if not all(c.get(k) for k in ('id', 'name', 'version'))] |
| if unknown: |
| warnings.append(f'{board}: compiler unknown on the {" and ".join(unknown)} side: ' |
| 'comparability cannot be established') |
| elif any(base_c.get(k) != compiler.get(k) for k in ('id', 'name', 'version', 'build_type')): |
| def show(c): |
| return ' '.join(_clean(c[k]) for k in ('id', 'name', 'version', 'build_type') if c.get(k)) |
| warnings.append(f'{board}: base built with {show(base_c)}, current with {show(compiler)}: ' |
| 'deltas may include the toolchain change') |
| base_m = shard.get('membrowse_version') |
| unknown = [side for side, v in (('base', base_m), ('current', membrowse_version)) if not v] |
| if unknown: |
| warnings.append(f'{board}: membrowse version unknown on the {" and ".join(unknown)} side: ' |
| 'comparability cannot be established') |
| elif base_m != membrowse_version: |
| warnings.append(f'{board}: base measured with membrowse {_clean(base_m)}, current with ' |
| f'{_clean(membrowse_version)}: per-file attribution may differ') |
| return warnings |
| |
| |
| def example_arg(value): |
| example = (value.replace('\\', '/') if WINDOWS else value).rstrip('/') |
| if not example: |
| raise argparse.ArgumentTypeError(f'{value!r} names no example') |
| return example |
| |
| |
| def main(): |
| global verbose |
| |
| common = argparse.ArgumentParser(add_help=False) |
| common.add_argument('-b', '--board', action='append', default=[], |
| help='Board name (repeatable). Required unless diff --ci is given.') |
| common.add_argument('-f', '--filter', action='append', default=None, type=filter_arg, |
| help='Path-substring filter (repeatable). When given, ' |
| 'overrides the default and is applied to every build. ' |
| 'Default: each build\'s own absolute <checkout>/src/ path, ' |
| 'which uniquely matches TinyUSB stack code without colliding ' |
| 'with vendored deps.') |
| common.add_argument('-e', '--example', action='append', default=None, type=example_arg, |
| help='Size specific example (repeatable, e.g. -e device/cdc_msc -e host/cdc_msc_hid)') |
| common.add_argument('--engine', choices=sorted(ENGINES), |
| default='membrowse', |
| help='Where per-file sizes come from: membrowse (default, symbols with ' |
| 'ELF-header flash/RAM), linkermap (the GNU ld map\'s input ' |
| 'sections) or bloaty (DWARF compile units)') |
| common.add_argument('--symbols', action='store_true', |
| help='Show each file\'s symbols under it (linkermap: its input sections); ' |
| 'with --json, include them in the JSON') |
| common.add_argument('--json', action='store_true', |
| help='Also write each report\'s sizes as .json next to its .md') |
| common.add_argument('-v', '--verbose', action='store_true', |
| help='Print build commands') |
| |
| top = argparse.ArgumentParser(description='Code size of TinyUSB examples') |
| sub = top.add_subparsers(dest='command', required=True) |
| report_parser = sub.add_parser('report', parents=[common], |
| help='size the working tree (cmake-code-size/<board>/report*.md)') |
| parser = sub.add_parser('diff', parents=[common], |
| help='diff code size against a base ref (cmake-code-size/<board>/diff*.md)') |
| parser.add_argument('--base-branch', default='master', |
| help='Base branch to compare against (default: master)') |
| parser.add_argument('--bloaty', action='store_true', |
| help='Also print bloaty\'s section and symbol diff of each -e example ' |
| '(console only, whatever --engine)') |
| parser.add_argument('--ci', action='store_true', |
| help='Add the CI-pinned boards (.github/ci-pinned-boards.json, covering ' |
| 'every dcd/hcd driver not waived there). Implies --combined.') |
| parser.add_argument('--combined', action='store_true', |
| help='Also write one comparison over every board ' |
| '(cmake-code-size/_combined/diff.md), in addition to per-board.') |
| parser.add_argument('--base-source', choices=('ci', 'local'), default=None, |
| help='Where the base sizes come from. Default: try the stored CI snapshots of ' |
| '--base-branch, fall back to a local build; ci: require CI; local: build ' |
| 'the base, no GitHub lookup.') |
| parser.add_argument('--baseline-repo', default=BASELINE_REPO, |
| help=f'Repository whose master snapshots are the CI base (default: {BASELINE_REPO})') |
| snap = sub.add_parser('snapshot', help='size the CI-pinned boards a CI build leg already built, ' |
| 'one code-size-<board>.json each (no build)') |
| snap.add_argument('families', nargs='*', default=[], help='Families the leg built (as tools/build.py)') |
| snap.add_argument('-b', '--board', action='append', default=[], help='Board the leg built (repeatable)') |
| snap.add_argument('-e', '--example', action='append', default=None, type=example_arg, |
| help='Examples the leg built (repeatable); omit when it built all') |
| snap.add_argument('--build-name', help='A variant leg\'s build name (as tools/build.py): its build dir, and ' |
| 'the board it reports as') |
| snap.add_argument('-D', '--define-symbol', action='append', default=[], |
| help='A variant leg\'s build-system define (as tools/build.py); it needs --build-name') |
| snap.add_argument('--cflag', action='append', default=[], |
| help='A variant leg\'s compiler flag (as tools/build.py); it needs --build-name') |
| snap.add_argument('-o', '--output', required=True, help='Directory for the code-size-<board>.json files') |
| snap.add_argument('--build-root', default=os.path.join(TINYUSB_ROOT, 'cmake-build'), |
| help='Where tools/build.py put cmake-build-<board> (default: cmake-build)') |
| snap.add_argument('--event', default=os.environ.get('GITHUB_EVENT_NAME', 'push'), |
| help='CI event; pull_request reads base/head from the merge commit ' |
| '(default: $GITHUB_EVENT_NAME, else push)') |
| snap.add_argument('--build-outcome', default='success', help='The Build step\'s outcome, recorded as is') |
| snap.add_argument('-f', '--filter', action='append', default=None, type=filter_arg, |
| help='Path-substring filter (repeatable); default: this checkout\'s <checkout>/src/') |
| snap.add_argument('--symbols', action='store_true', help='Include each file\'s symbols') |
| snap.add_argument('-v', '--verbose', action='store_true', help='Print commands') |
| cmp = sub.add_parser('compare', help='compare a CI run\'s snapshots against a baseline run\'s ' |
| '(code-size.md, code-size.json, comment.md; no build)') |
| cmp.add_argument('current', help='The run\'s downloaded artifacts, one directory each') |
| cmp.add_argument('base', help='The baseline run\'s downloaded artifacts; a missing directory is no baseline') |
| cmp.add_argument('-o', '--output', required=True, help='Directory for the reports') |
| cmp.add_argument('--baseline-info', help='JSON describing the baseline run: {sha, url, exact, note}') |
| cmp.add_argument('--symbols', action='store_true', help='Show each changed file\'s symbols in the full report') |
| args = top.parse_args() |
| verbose = getattr(args, 'verbose', False) |
| |
| if args.command == 'compare': |
| return run_compare(args) |
| |
| if args.command == 'snapshot': |
| # a leg whose build did not succeed may have skipped the membrowse install too; |
| # its snapshot still records that outcome, and any elf it left fails to size |
| if engine_missing('membrowse') and args.build_outcome == 'success': |
| snap.error(f'membrowse not found - install {ENGINES["membrowse"].install}') |
| if invalid := invalid_boards(args.board + ([args.build_name] if args.build_name else [])): |
| snap.error(f'invalid board name: {", ".join(invalid)}') |
| if args.build_name and (args.families or len(args.board) != 1): |
| snap.error('--build-name names exactly one -b board and no families') |
| if (args.define_symbol or args.cflag) and not args.build_name: |
| snap.error('-D or --cflag makes a variant, which needs --build-name to report under') |
| return run_snapshot(args) |
| |
| if engine_missing(args.engine): |
| sub.choices[args.command].error(f'{args.engine} not found - install {ENGINES[args.engine].install}, ' |
| f'or pick another --engine') |
| |
| if args.command == 'report': |
| if not args.board: |
| report_parser.error('at least one -b BOARD is required') |
| if invalid := invalid_boards(args.board): |
| report_parser.error(f'invalid board name: {", ".join(invalid)}') |
| if unknown := unknown_boards(args.board): |
| report_parser.error(f'unknown board: {", ".join(unknown)}') |
| if missing := missing_examples(args.example or []): |
| report_parser.error(f'no such example: {", ".join(missing)}') |
| if error := esp_without_idf(args.board, args.example): |
| report_parser.error(error) |
| return run_report(args) |
| |
| if args.bloaty and not args.example: |
| parser.error('--bloaty requires -e/--example') |
| if args.bloaty and not shutil.which('bloaty'): |
| parser.error('--bloaty requires bloaty on PATH') |
| |
| if args.ci: |
| args.combined = True |
| args.board = list(dict.fromkeys(args.board + ci_pinned_boards())) |
| |
| if not args.board: |
| parser.error('at least one -b BOARD is required (or pass --ci)') |
| if invalid := invalid_boards(args.board): |
| parser.error(f'invalid board name: {", ".join(invalid)}') |
| if unknown := unknown_boards(args.board): |
| parser.error(f'unknown board: {", ".join(unknown)}') |
| # CI snapshots hold membrowse sizes under the TinyUSB filter, and no elf |
| unsupported = [opt for opt, used in (('-f', args.filter), ('--bloaty', args.bloaty), |
| (f'--engine {args.engine}', args.engine != 'membrowse')) if used] |
| # before any board builds: a --ci run would otherwise fail its espressif boards last |
| if error := esp_without_idf(args.board, args.example, args.base_source == 'local' or bool(unsupported)): |
| parser.error(error) |
| if args.base_source == 'ci' and unsupported: |
| parser.error(f'--base-source ci cannot be used with {", ".join(unsupported)}') |
| |
| ret = run(['git', '-C', TINYUSB_ROOT, 'rev-parse', '--verify', '--quiet', f'{args.base_branch}^{{commit}}']) |
| if ret.returncode != 0: |
| parser.error(f'--base-branch {args.base_branch} names no commit') |
| requested_sha = ret.stdout.strip() |
| if missing := missing_examples(args.example or [], requested_sha): |
| parser.error(f'no such example here or in {args.base_branch}: {", ".join(missing)}') |
| return run_diff(args, requested_sha, unsupported) |
| |
| |
| if __name__ == '__main__': |
| exit_on_termination() |
| sys.exit(main()) |