summaryrefslogtreecommitdiff
path: root/tools/check_budgets.py
blob: 8219a6d0ddbe64945568c1e265a8c94472777cf7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#!/usr/bin/env python3
# Author:  cirakg & claude ai
# Summary: Compare the app's debug diagnostics (start-up time, memory) with the budgets in tools/budgets.json.
"""Compare the app's debug diagnostics with the budgets in tools/budgets.json.

Two kinds of lines are read from build/diag/<device>.log:

    DIAG first_frame startup_ms=1 mem_used=6984 mem_total=781888
        printed by the debug build (source/util/Diag.mc) after its first frame;
    DIAG glance mem_used=12552 mem_total=65536
        printed by the glance after its first draw (the simulator starts there);
    HARNESS first_frame wall_ms=2310
        added by app-diag.sh: wall-clock time from launching the app with
        monkeydo until that DIAG line appeared, which includes loading the
        program (the in-app timer can't see that part).

Usage:  check_budgets.py {perf,mem} LOG [LOG ...] [--budgets FILE] [--summary OUT.json]
Exit:   0 within budget, 1 over budget, 2 missing or unreadable diagnostics.
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

EXIT_OK, EXIT_OVER, EXIT_MISSING = 0, 1, 2

EVENT_RE = re.compile(r"\b(DIAG|HARNESS)\s+(\w+)((?:\s+\w+=-?\d+(?:\.\d+)?)+)")
PAIR_RE = re.compile(r"(\w+)=(-?\d+(?:\.\d+)?)")


def read_events(text: str) -> dict[str, dict[str, float]]:
    """The first occurrence of each event, keyed as "DIAG.first_frame" etc."""
    events: dict[str, dict[str, float]] = {}
    for match in EVENT_RE.finditer(text):
        key = f"{match.group(1)}.{match.group(2)}"
        if key not in events:
            events[key] = {name: float(value) for name, value in PAIR_RE.findall(match.group(3))}
    return events


def measure(kind: str, events: dict[str, dict[str, float]]) -> dict[str, float]:
    """The values one budget kind looks at; empty when the app never drew."""
    frame = events.get("DIAG.first_frame")
    if frame is None:
        return {}
    if kind == "perf":
        values = {"startup_ms": frame["startup_ms"]}
        harness = events.get("HARNESS.first_frame")
        if harness is not None:
            values["first_frame_wall_ms"] = harness["wall_ms"]
        return values
    total = frame["mem_total"]
    values = {"mem_used_fraction": frame["mem_used"] / total if total else 1.0}
    glance = events.get("DIAG.glance")
    if glance is not None:
        values["glance_mem_used_fraction"] = glance["mem_used"] / glance["mem_total"] if glance["mem_total"] else 1.0
    return values


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Check diagnostics against budgets.")
    parser.add_argument("kind", choices=("perf", "mem"))
    parser.add_argument("logs", type=Path, nargs="+")
    parser.add_argument("--budgets", type=Path, default=Path("tools/budgets.json"))
    parser.add_argument("--summary", type=Path)
    args = parser.parse_args(argv)

    budgets: dict[str, float] = json.loads(args.budgets.read_text(encoding="utf-8"))[args.kind]
    worst = EXIT_OK
    summary: dict[str, dict[str, float]] = {}
    for log in args.logs:
        device = log.stem
        try:
            values = measure(args.kind, read_events(log.read_text(encoding="utf-8", errors="replace")))
        except (OSError, KeyError) as err:
            print(f"[{device}] cannot read diagnostics: {err}")
            worst = max(worst, EXIT_MISSING)
            continue
        if not values:
            print(f"[{device}] no 'DIAG first_frame' line (did the app start? is this a debug build?)")
            worst = max(worst, EXIT_MISSING)
            continue
        summary[device] = values
        for name, limit in budgets.items():
            if name not in values:
                print(f"[{device}] {name}: not measured")
                worst = max(worst, EXIT_MISSING)
                continue
            value = values[name]
            over = value > limit
            print(f"[{device}] {name} = {value:.4g} (budget {limit:.4g}) {'OVER BUDGET' if over else 'ok'}")
            if over:
                worst = max(worst, EXIT_OVER)
    if args.summary:
        args.summary.parent.mkdir(parents=True, exist_ok=True)
        args.summary.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
    return worst


if __name__ == "__main__":
    sys.exit(main())