mirror of
https://github.com/meshtastic/firmware.git
synced 2026-08-01 19:08:59 -04:00
* CI: track RAM (.data+.bss) in size reports and gate on per-env budgets
The 2.8.0 nRF52840 heap regression (99% heap in field reports) shipped
invisibly because CI only tracked flash. On nRF52840 the heap arena is the
linker gap after .bss, so every byte of static .data+.bss growth shrinks the
usable heap 1:1 - RAM needs the same guardrails flash already has.
What's added:
- bin/platformio-custom.py emits ram_bytes (.data + .bss from the ELF, via
the toolchain size tool) into each .mt.json manifest. Heap/stack
placeholder sections are deliberately excluded.
- bin/collect_sizes.py records {flash_bytes, ram_bytes} per env;
bin/size_report.py grows RAM and RAM-delta columns in the PR size report.
Older artifacts without ram_bytes (and legacy int-schema baselines)
degrade to "n/a" instead of crashing.
- bin/ram_budgets.json: per-env RAM/flash budgets, enforced only for envs
listed there. Seeded with rak4631: ram 113,000 (current 110,948 + ~2 KB
slack), flash 786,000 (current 765,192 + ~20 KB; the app region is
0x27000..0xEA000 = 798,720 and the image must stay clear of the
warm-store ring guard in extra_scripts/nrf52_warm_region.py).
- New size-budget-gate CI job runs size_report.py --enforce-budgets and
fails the build on violation; the informational firmware-size-report job
now also renders budget usage into the PR comment.
- src/main.cpp: opt-in boot heap watermark (-DMESHTASTIC_HEAP_WATERMARK_CHECK)
logs LOG_ERROR when less than 20% of the heap is free at the end of
setup(). Off by default; skipped on platforms without heap accounting.
How budgets are raised: deliberately, never automatically. If a change needs
more headroom, bump the env's limit in bin/ram_budgets.json in the same PR
and justify the increase in the PR description.
Verified: python3 bin/test_size_scripts.py (23/23 pass, including ram_bytes
parsing, n/a fallback, and over/under/missing-env budget-gate cases).
* Address review: fail the budget gate closed, fix RAM section matching
- size-budget-gate workflow: drop continue-on-error on the manifest
download and the empty-dir fallback, so the job fails when the data it
gates on cannot be fetched.
- size_report.py --enforce-budgets now fails closed on every
missing-data path instead of trivially passing: empty collected sizes,
a budgeted env that was not built, or a manifest without the budgeted
metric. Report-only mode keeps rendering those as n/a.
- load_budgets() rejects zero/negative/non-integer budgets with a clear
error (a typo'd budget could previously crash budget_markdown with
ZeroDivisionError or silently skip the check); the percentage render
keeps a defensive guard for direct callers.
- compute_ram_bytes(): count RISC-V small-data sections (.sdata/.sbss)
and exclude ESP-IDF .rtc.* sections, which live outside the
heap-competing SRAM.
- Trim the heap-watermark comment in main.cpp to two lines.
bin/test_size_scripts.py: 27/27 - the two fail-open assertions are
flipped to fail-closed, with new report-only counterparts plus cases for
empty sizes under enforcement and malformed budgets.
384 lines
14 KiB
Python
384 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
|
|
"""Compare firmware size reports and generate a markdown summary.
|
|
|
|
Usage:
|
|
size_report.py <new_sizes.json> [--baseline <label>:<old_sizes.json>]...
|
|
[--budgets <budgets.json>] [--enforce-budgets]
|
|
|
|
Examples:
|
|
# Compare PR against develop and master baselines
|
|
size_report.py pr.json --baseline develop:develop.json --baseline master:master.json
|
|
|
|
# Single baseline comparison
|
|
size_report.py pr.json --baseline develop:develop.json
|
|
|
|
# No baselines - shows sizes with blank delta columns
|
|
size_report.py pr.json
|
|
|
|
# Render budget usage in the report (informational)
|
|
size_report.py pr.json --budgets bin/ram_budgets.json
|
|
|
|
# Enforce budgets: exit 1 when any env in the budgets file exceeds a limit
|
|
size_report.py pr.json --budgets bin/ram_budgets.json --enforce-budgets
|
|
|
|
Sizes JSON schema (produced by bin/collect_sizes.py):
|
|
{"<env>": {"flash_bytes": <int>, "ram_bytes": <int>}}
|
|
The legacy schema {"<env>": <flash_bytes>} (older baseline artifacts) is still
|
|
accepted; missing ram_bytes renders as "n/a".
|
|
|
|
Budgets JSON schema (bin/ram_budgets.json):
|
|
{"<env>": {"ram_bytes": <budget>, "flash_bytes": <budget>}}
|
|
Keys starting with "_" are comments. Only envs present in the budgets file are
|
|
gated, and only for the metrics they list. ram_bytes is the static RAM
|
|
footprint (.data + .bss): on nRF52840 the heap arena is just the linker gap
|
|
after .bss, so every byte of static growth shrinks the usable heap 1:1 - which
|
|
is how the 2.8.0 heap regression shipped without CI noticing. Budgets are
|
|
raised deliberately (edit the JSON in the PR that needs the headroom and
|
|
justify it), never automatically.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
|
|
RAM_LABEL = "RAM (.data+.bss)"
|
|
FLASH_LABEL = "flash"
|
|
|
|
|
|
def load_sizes(path):
|
|
with open(path) as f:
|
|
return normalize_sizes(json.load(f))
|
|
|
|
|
|
def normalize_sizes(raw):
|
|
"""Normalize a sizes dict to {board: {"flash_bytes": int, "ram_bytes": int}}.
|
|
|
|
Accepts both the current schema (dict values) and the legacy schema where
|
|
each value was the flash size as a plain int. Unknown/malformed entries are
|
|
dropped rather than crashing on old artifacts.
|
|
"""
|
|
sizes = {}
|
|
for board, val in raw.items():
|
|
if isinstance(val, bool):
|
|
continue
|
|
if isinstance(val, int):
|
|
sizes[board] = {"flash_bytes": val}
|
|
elif isinstance(val, dict):
|
|
entry = {}
|
|
for key in ("flash_bytes", "ram_bytes"):
|
|
if isinstance(val.get(key), int) and not isinstance(val[key], bool):
|
|
entry[key] = val[key]
|
|
if entry:
|
|
sizes[board] = entry
|
|
return sizes
|
|
|
|
|
|
def format_delta(n):
|
|
"""Format byte delta with sign and human-friendly suffix."""
|
|
sign = "+" if n > 0 else ""
|
|
if abs(n) >= 1024:
|
|
return f"{sign}{n:,} ({sign}{n / 1024:.1f} KB)"
|
|
return f"{sign}{n:,}"
|
|
|
|
|
|
def compute_deltas(entry, baselines, key):
|
|
"""Per-baseline delta of entry[key], or None when either side is missing."""
|
|
deltas = []
|
|
current = entry.get(key)
|
|
for _, old_sizes in baselines:
|
|
old = (
|
|
old_sizes.get(entry["_board"], {}).get(key) if current is not None else None
|
|
)
|
|
deltas.append(current - old if old is not None else None)
|
|
return deltas
|
|
|
|
|
|
def generate_markdown(new_sizes, baselines, top_n=5):
|
|
"""Generate a single table with flash/RAM columns and deltas per baseline.
|
|
|
|
baselines: list of (label, old_sizes_dict), may be empty
|
|
"""
|
|
labels = [label for label, _ in baselines]
|
|
|
|
# Build rows: (board, entry, flash_deltas, ram_deltas, max_abs_delta)
|
|
rows = []
|
|
for board in sorted(new_sizes):
|
|
entry = dict(new_sizes[board])
|
|
entry["_board"] = board
|
|
flash_deltas = compute_deltas(entry, baselines, "flash_bytes")
|
|
ram_deltas = compute_deltas(entry, baselines, "ram_bytes")
|
|
max_abs = max(
|
|
(abs(d) for d in flash_deltas + ram_deltas if d is not None), default=0
|
|
)
|
|
rows.append((board, entry, flash_deltas, ram_deltas, max_abs))
|
|
|
|
rows.sort(key=lambda r: r[4], reverse=True)
|
|
|
|
# Summary line (flash-based, matching the historical summary)
|
|
sections = []
|
|
summary_parts = [f"{len(new_sizes)} targets"]
|
|
for i, (label, _) in enumerate(baselines):
|
|
increases = sum(1 for _, _, fd, _, _ in rows if fd[i] is not None and fd[i] > 0)
|
|
decreases = sum(1 for _, _, fd, _, _ in rows if fd[i] is not None and fd[i] < 0)
|
|
net = sum(fd[i] for _, _, fd, _, _ in rows if fd[i] is not None)
|
|
parts = []
|
|
if increases:
|
|
parts.append(f"{increases} increased")
|
|
if decreases:
|
|
parts.append(f"{decreases} decreased")
|
|
if parts:
|
|
parts.append(f"net {format_delta(net)}")
|
|
summary_parts.append(f"vs `{label}`: {', '.join(parts)}")
|
|
else:
|
|
summary_parts.append(f"vs `{label}`: no changes")
|
|
|
|
if not baselines:
|
|
summary_parts.append("no baseline available yet")
|
|
|
|
sections.append(f"**{' | '.join(summary_parts)}**\n")
|
|
|
|
# Table header
|
|
header = "| Target | Flash |"
|
|
separator = "|--------|------:|"
|
|
for label in labels:
|
|
header += f" vs `{label}` |"
|
|
separator += "----------:|"
|
|
header += " RAM |"
|
|
separator += "----:|"
|
|
for label in labels:
|
|
header += f" RAM vs `{label}` |"
|
|
separator += "----------:|"
|
|
sections.append(header)
|
|
sections.append(separator)
|
|
|
|
def format_delta_cell(d, missing=""):
|
|
if d is None:
|
|
return f" {missing} |".replace(" ", " ")
|
|
if d == 0:
|
|
return " 0 |"
|
|
icon = "📈" if d > 0 else "📉"
|
|
return f" {icon} {format_delta(d)} |"
|
|
|
|
def format_row(board, entry, flash_deltas, ram_deltas):
|
|
flash = entry.get("flash_bytes")
|
|
ram = entry.get("ram_bytes")
|
|
row = (
|
|
f"| `{board}` | {flash:,} |"
|
|
if flash is not None
|
|
else f"| `{board}` | n/a |"
|
|
)
|
|
for d in flash_deltas:
|
|
row += format_delta_cell(d)
|
|
row += f" {ram:,} |" if ram is not None else " n/a |"
|
|
for d in ram_deltas:
|
|
# RAM data may be missing on one side (older artifacts): show n/a
|
|
row += format_delta_cell(d, missing="n/a")
|
|
return row
|
|
|
|
# Top N rows always visible
|
|
top = rows[:top_n]
|
|
for board, entry, flash_deltas, ram_deltas, _ in top:
|
|
sections.append(format_row(board, entry, flash_deltas, ram_deltas))
|
|
|
|
# Remaining rows in expandable section
|
|
rest = rows[top_n:]
|
|
if rest:
|
|
sections.append("")
|
|
sections.append(
|
|
f"<details><summary>Show {len(rest)} more target(s)</summary>\n"
|
|
)
|
|
sections.append(header)
|
|
sections.append(separator)
|
|
for board, entry, flash_deltas, ram_deltas, _ in rest:
|
|
sections.append(format_row(board, entry, flash_deltas, ram_deltas))
|
|
sections.append("\n</details>")
|
|
|
|
sections.append("")
|
|
return "\n".join(sections)
|
|
|
|
|
|
def load_budgets(path):
|
|
"""Load bin/ram_budgets.json, skipping "_comment"-style keys.
|
|
|
|
Fails loudly on malformed entries: a typo'd budget (zero, negative, or
|
|
non-integer) must not silently weaken or crash the gate.
|
|
"""
|
|
with open(path) as f:
|
|
raw = json.load(f)
|
|
budgets = {
|
|
env: limits
|
|
for env, limits in raw.items()
|
|
if not env.startswith("_") and isinstance(limits, dict)
|
|
}
|
|
for env, limits in budgets.items():
|
|
for key in ("ram_bytes", "flash_bytes"):
|
|
if key not in limits:
|
|
continue
|
|
budget = limits[key]
|
|
if not isinstance(budget, int) or isinstance(budget, bool) or budget <= 0:
|
|
print(
|
|
f"Error: invalid budget in {path}: {env}.{key} = {budget!r} "
|
|
"(must be a positive integer)",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
return budgets
|
|
|
|
|
|
def check_budgets(new_sizes, budgets, fail_on_missing=False):
|
|
"""Compare measured sizes against per-env budgets.
|
|
|
|
With fail_on_missing=False a budgeted env (or metric) absent from the
|
|
measured sizes is reported as "n/a" but does not fail - right for the
|
|
informational report. The enforcing gate passes fail_on_missing=True so
|
|
missing data fails closed instead of trivially passing.
|
|
Returns (violations, rows): violations is a list of human-readable failure
|
|
strings; rows is a list of (env, metric_label, measured, budget, over)
|
|
tuples for reporting, with measured=None when the data is unavailable.
|
|
"""
|
|
violations = []
|
|
rows = []
|
|
for env in sorted(budgets):
|
|
measured = new_sizes.get(env)
|
|
for key, label in (("ram_bytes", RAM_LABEL), ("flash_bytes", FLASH_LABEL)):
|
|
budget = budgets[env].get(key)
|
|
if not isinstance(budget, int) or isinstance(budget, bool):
|
|
continue
|
|
value = measured.get(key) if measured is not None else None
|
|
if value is None:
|
|
rows.append((env, label, None, budget, False))
|
|
if fail_on_missing:
|
|
reason = (
|
|
"env was not built in this run"
|
|
if measured is None
|
|
else "manifest is missing this metric"
|
|
)
|
|
violations.append(
|
|
f"{env} {label}: no measurement to check against the "
|
|
f"budget ({reason}); refusing to pass the gate blind"
|
|
)
|
|
continue
|
|
over = value > budget
|
|
rows.append((env, label, value, budget, over))
|
|
if over:
|
|
violations.append(
|
|
f"{env} {label}: {value:,} bytes exceeds the budget of "
|
|
f"{budget:,} bytes (over by {value - budget:,})"
|
|
)
|
|
return violations, rows
|
|
|
|
|
|
def budget_markdown(rows):
|
|
"""Render budget usage as a markdown section for the PR comment."""
|
|
if not rows:
|
|
return ""
|
|
lines = [
|
|
"### Size budgets",
|
|
"",
|
|
"| Env | Metric | Measured | Budget | Used |",
|
|
"|-----|--------|---------:|-------:|-----:|",
|
|
]
|
|
for env, label, value, budget, over in rows:
|
|
if value is None:
|
|
lines.append(f"| `{env}` | {label} | n/a | {budget:,} | n/a |")
|
|
continue
|
|
# load_budgets() rejects non-positive budgets; guard anyway for direct callers
|
|
pct = 100.0 * value / budget if budget > 0 else float("inf")
|
|
status = f"{pct:.1f}%" + (" ❌ **OVER BUDGET**" if over else "")
|
|
lines.append(f"| `{env}` | {label} | {value:,} | {budget:,} | {status} |")
|
|
lines.append("")
|
|
lines.append(
|
|
"*Budgets live in `bin/ram_budgets.json` and are raised deliberately in "
|
|
"the PR that needs the headroom - never automatically.*"
|
|
)
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Compare firmware size reports")
|
|
parser.add_argument("new_sizes", help="Path to new sizes JSON")
|
|
parser.add_argument(
|
|
"--baseline",
|
|
action="append",
|
|
default=[],
|
|
metavar="LABEL:PATH",
|
|
help="Baseline to compare against (e.g. develop:develop.json)",
|
|
)
|
|
parser.add_argument(
|
|
"--top",
|
|
type=int,
|
|
default=5,
|
|
help="Number of top changes to show before collapsing (default: 5)",
|
|
)
|
|
parser.add_argument(
|
|
"--budgets",
|
|
metavar="PATH",
|
|
help="Path to per-env budgets JSON (e.g. bin/ram_budgets.json)",
|
|
)
|
|
parser.add_argument(
|
|
"--enforce-budgets",
|
|
action="store_true",
|
|
help="Exit 1 when any env exceeds its budget (requires --budgets)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.enforce_budgets and not args.budgets:
|
|
print("Error: --enforce-budgets requires --budgets", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
new_sizes = load_sizes(args.new_sizes)
|
|
|
|
# Silence output when no targets were built - repo maintainer choice.
|
|
# Under enforcement that would be a silent pass, so fail closed instead.
|
|
if not new_sizes:
|
|
if args.enforce_budgets:
|
|
print(
|
|
f"Error: --enforce-budgets requested but no sizes were found in "
|
|
f"{args.new_sizes}; refusing to pass the budget gate blind",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
return
|
|
|
|
baselines = []
|
|
for b in args.baseline:
|
|
if ":" not in b:
|
|
print(f"Error: baseline must be LABEL:PATH, got '{b}'", file=sys.stderr)
|
|
sys.exit(1)
|
|
label, path = b.split(":", 1)
|
|
baselines.append((label, load_sizes(path)))
|
|
|
|
md = generate_markdown(new_sizes, baselines, top_n=args.top)
|
|
|
|
violations = []
|
|
if args.budgets:
|
|
budgets = load_budgets(args.budgets)
|
|
violations, rows = check_budgets(
|
|
new_sizes, budgets, fail_on_missing=args.enforce_budgets
|
|
)
|
|
budget_md = budget_markdown(rows)
|
|
if budget_md:
|
|
md += "\n" + budget_md
|
|
|
|
print(md)
|
|
|
|
if violations and args.enforce_budgets:
|
|
print("\nRAM/flash budget check FAILED:", file=sys.stderr)
|
|
for v in violations:
|
|
print(f" - {v}", file=sys.stderr)
|
|
print(
|
|
"\nOn nRF52840 the heap arena is the linker gap after .bss, so every\n"
|
|
"byte of static RAM growth shrinks the usable heap 1:1. Budgets are\n"
|
|
"raised deliberately, never automatically: if this growth is intended\n"
|
|
"and reviewed, raise the env's limit in bin/ram_budgets.json in this\n"
|
|
"same PR and justify the increase in the PR description.",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|