Files
cleanyourtv/tools/flamechart.py
T
simandClaude Opus 5.5 21282c6f96 tools: flamechart.py, a before/after flame chart from tools/trace.py traces
The second of the performance tools proposed in #564: a PNG for a pull request that shows where a
change's time went.

- One panel per trace, one lane per thread, every slice on the same time scale, so the faster run is
  visibly shorter. Slices matching --highlight are magenta and totalled, a match's children once,
  with the change against the first panel.
- A folder draws its median run (by time to the first frame, or the median frame time for nav), not
  its best, and the panel says which run it is.
- --window first-frame (start to first frame drawn), start:MS, or all; --threads picks the lanes.
- It reuses trace_report.py's queries: start_window() now returns the start and first-frame times
  the report already worked out.
- Pillow joins perfetto in the optional tools/requirements-perf.txt. Drawing uses Pillow's own font,
  so it renders the same on any system.
- tools/flamechart.md says how to draw the two charts a Home change usually needs, and how to read
  one honestly.

Tests run without the query engine and, apart from the one drawing test, without Pillow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:44:15 +10:00

246 lines
11 KiB
Python

#!/usr/bin/env python3
"""A before/after flame chart, as a PNG for a pull request, from traces recorded with tools/trace.py.
One panel per trace, one lane per thread, every slice drawn on the same time scale, so the faster run is
visibly shorter. Slices whose name matches --highlight are drawn in magenta, and their time is summed
(nested slices once), with the change against the first panel.
tools/flamechart.py traces/before traces/after --highlight '^(activityResume|RV OnBindView)$' \\
--title 'Back to Home: Home press to first frame' --out chart.png
BEFORE and AFTER are .pftrace files or folders; for a folder the median run is drawn (by the time to the
first frame, or for nav traces by the median frame time). Needs the optional packages in
tools/requirements-perf.txt. Read tools/flamechart.md first.
"""
import argparse
import colorsys
import hashlib
import os
import re
import statistics
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import trace_report # noqa: E402 the same trace queries the report uses
MAGENTA = (214, 51, 132)
ROW = 16
LEFT = 150
MS = 1e6
def window(q, package, spec, nav):
"""(t0, t1) in ns: first-frame (launcher start to first frame drawn), start:MS, or all."""
upid, main, _ = trace_report.threads(q, package)
if main is None:
raise ValueError(f"no {package} process in the trace")
if spec == "all" or nav:
row = q(f"select min(s.ts) as t0, max(s.ts + s.dur) as t1 from slice s join thread_track tt on s.track_id = tt.id "
f"join thread t using(utid) where t.upid = {upid}")[0]
return row["t0"], row["t1"]
anchor, end = trace_report.start_window(q, package)
if anchor is None:
raise ValueError("no launcher start in the trace (bindApplication or performStart); try --window all")
if spec.startswith("start:"):
return anchor - 500_000, anchor + int(float(spec[6:]) * MS)
if end is None:
raise ValueError("no first frame after the start; try --window start:1500")
return anchor - 500_000, end + 500_000
def lanes(q, package, names, t0, t1):
"""[(label, slices)] for each lane: main, RenderThread, or any thread name in the launcher's process."""
upid, main, render = trace_report.threads(q, package)
out = []
for name in names:
if name == "main":
utid = main
elif name == "RenderThread":
utid = render
else:
rows = q(f"select utid from thread where upid = {upid} and name = '{name}' order by utid limit 1")
utid = rows[0]["utid"] if rows else None
if utid is None:
out.append((name, []))
continue
rows = q(f"select s.id, s.ts, s.dur, s.depth, s.name, s.parent_id from slice s join thread_track tt "
f"on s.track_id = tt.id where tt.utid = {utid} and s.ts < {t1} and s.ts + s.dur > {t0} "
f"and s.dur > 0 order by s.ts")
out.append(("main thread" if name == "main" else name, rows))
return out
def highlighted(rows, rx, t0, t1):
"""(ms, ids): time inside the window of slices matching rx, a match's children counted once; and the ids drawn magenta."""
hit, total = set(), 0
for s in sorted(rows, key=lambda r: (r["depth"], r["ts"])):
if s["parent_id"] in hit:
hit.add(s["id"])
elif rx.search(s["name"]):
hit.add(s["id"])
total += min(s["ts"] + s["dur"], t1) - max(s["ts"], t0)
return total / MS, hit
def median_run(paths, package, open_trace):
"""The run whose time to the first frame (or nav frame time) is the median of the set."""
if len(paths) == 1:
return paths[0]
scored = []
for path, metrics in zip(paths, (m for _, m in trace_report.measure(paths, package, open_trace))):
score = metrics.get("first frame", metrics.get("frame median"))
if score is not None:
scored.append((score, path))
if not scored:
return paths[0]
scored.sort()
return scored[(len(scored) - 1) // 2][1]
def colour(name):
"""A warm, muted colour per slice name, numbers ignored, so the same work looks the same in both panels."""
h = int(hashlib.md5(re.sub(r"\d+", "", name).encode()).hexdigest()[:6], 16) / 0xFFFFFF
r, g, b = colorsys.hls_to_rgb(0.08 + 0.12 * h, 0.72, 0.55)
return int(r * 255), int(g * 255), int(b * 255)
def lane_height(rows):
return (max((r["depth"] for r in rows), default=0) + 1) * ROW
def tick_step(span_ms):
"""Axis ticks every 10 ms on a short chart, up to every second on a long one."""
for limit, step in ((120, 10), (300, 25), (700, 50), (1600, 100), (4000, 250)):
if span_ms <= limit:
return step
return 1000
def draw(panels, rx, title, subtitle, out, width=1600):
"""panels: [{label, note, t0, t1, lanes}]. Writes the PNG and returns its size."""
from PIL import Image, ImageDraw, ImageFont
def font(size):
return ImageFont.load_default(size=size)
small, normal, bold, big = font(11), font(13), font(15), font(19)
span = max((p["t1"] - p["t0"]) / MS for p in panels)
plot = width - LEFT - 95
heights = [52 + sum(lane_height(rows) + 26 for _, rows in p["lanes"]) + 10 for p in panels]
im = Image.new("RGB", (width, 90 + sum(heights) + 30), "white")
d = ImageDraw.Draw(im)
d.text((20, 14), title, fill="black", font=big)
d.text((20, 42), subtitle, fill="#555", font=normal)
d.rectangle([width - 430, 18, width - 416, 32], fill=MAGENTA)
d.text((width - 410, 18), "highlighted: " + rx.pattern[:52], fill="#333", font=small)
totals = [sum(highlighted(rows, rx, p["t0"], p["t1"])[0] for _, rows in p["lanes"]) for p in panels]
y = 80
for i, p in enumerate(panels):
dur = (p["t1"] - p["t0"]) / MS
d.text((20, y), p["label"], fill="black", font=bold)
info = f"{dur:.1f} ms shown · highlighted {totals[i]:.1f} ms" + (f" · {p['note']}" if p.get("note") else "")
d.text((20 + d.textlength(p["label"], font=bold) + 14, y + 1), info, fill="#444", font=normal)
if i and totals[0] > 0:
change = f"highlighted {totals[i] - totals[0]:+.1f} ms ({100 * (totals[i] - totals[0]) / totals[0]:+.0f}%) " \
f"vs {panels[0]['label'].lower()}"
d.text((width - 20 - d.textlength(change, font=bold), y), change, fill=MAGENTA, font=bold)
y += 26
x_end = LEFT + plot * dur / span
d.line([LEFT, y + 6, x_end, y + 6], fill="#999")
step, t = tick_step(span), 0
while t <= dur + 1e-6:
x = LEFT + plot * t / span
d.line([x, y + 2, x, y + 10], fill="#999")
d.text((x + 2, y - 8), f"{t:g}", fill="#777", font=small)
t += step
d.text((x_end + 6, y), f"{dur:.0f} ms", fill="#222", font=bold)
y += 18
for label, rows in p["lanes"]:
h = lane_height(rows)
d.text((20, y + 2), label, fill="#222", font=normal)
d.rectangle([LEFT, y, x_end, y + h], fill="#f6f6f6")
_, hit = highlighted(rows, rx, p["t0"], p["t1"])
for s in rows:
a, b = max(s["ts"], p["t0"]), min(s["ts"] + s["dur"], p["t1"])
x0 = LEFT + plot * (a - p["t0"]) / MS / span
x1 = LEFT + plot * (b - p["t0"]) / MS / span
if x1 - x0 < 0.6:
continue
yy = y + s["depth"] * ROW
d.rectangle([x0, yy, max(x0 + 1, x1 - 1), yy + ROW - 2], fill=MAGENTA if s["id"] in hit else colour(s["name"]))
if x1 - x0 > 46:
text = s["name"]
while text and d.textlength(text, font=small) > x1 - x0 - 6:
text = text[:-2]
d.text((x0 + 3, yy + 1), text, fill="white" if s["id"] in hit else "#222", font=small)
y += h + 26
y += 10
im.save(out)
return im.size
def parse(argv):
p = argparse.ArgumentParser(description="Before/after flame chart PNG from tools/trace.py traces. See tools/flamechart.md.")
p.add_argument("traces", nargs="+", metavar="TRACE", help="before, then after: .pftrace files or folders (median run)")
p.add_argument("--out", required=True, help="the PNG to write")
p.add_argument("--highlight", default="^(activityResume|RV OnBindView)$",
help="regex of slice names to draw in magenta and total (default: onResume and tile binding)")
p.add_argument("--window", default="first-frame",
help="first-frame (start to first frame drawn, default), start:MS, or all")
p.add_argument("--threads", default="main,RenderThread", help="lanes, comma-separated (default main,RenderThread)")
p.add_argument("--labels", default="Before,After", help="panel labels, comma-separated")
p.add_argument("--title", default="Launcher: start to first frame", help="the chart's title")
p.add_argument("--subtitle", default="", help="a line under the title: the TV, the build, what magenta means")
p.add_argument("--package", default="com.gathin.tvhome")
p.add_argument("--engine", metavar="PATH", help="a trace_processor_shell to use instead of downloading one")
p.add_argument("--width", type=int, default=1600)
args = p.parse_args(argv)
if len(args.traces) > 4:
p.error("at most four traces (panels)")
if not re.fullmatch(r"first-frame|all|start:\d+(\.\d+)?", args.window):
p.error("--window is first-frame, all or start:MS")
if not trace_report.PACKAGE.match(args.package):
p.error(f"not a package name: {args.package!r}")
try:
args.rx = re.compile(args.highlight)
except re.error as e:
p.error(f"--highlight is not a regex: {e}")
names = [n.strip() for n in args.threads.split(",") if n.strip()]
if not names or not all(re.fullmatch(r"[A-Za-z0-9 _.:#-]+", n) for n in names):
p.error("--threads takes thread names such as main,RenderThread")
args.thread_names = names
return args
def main(argv=None):
args = parse(sys.argv[1:] if argv is None else argv)
try:
import PIL # noqa: F401
except ImportError:
sys.exit("flamechart.py needs Pillow: tools/venv/bin/pip install -r tools/requirements-perf.txt")
open_trace = trace_report.opener(args.engine)
labels = [l.strip() for l in args.labels.split(",")]
panels = []
for i, item in enumerate(args.traces):
paths = trace_report.expand([item])
if not paths:
sys.exit(f"no .pftrace files in {item}")
path = median_run(paths, args.package, open_trace)
nav = trace_report.scenario_of(path) == "nav"
q, close = open_trace(path)
try:
t0, t1 = window(q, args.package, args.window, nav)
data = lanes(q, args.package, args.thread_names, t0, t1)
except ValueError as e:
sys.exit(f"{path}: {e}")
finally:
close()
note = f"median of {len(paths)} runs ({os.path.basename(path)})" if len(paths) > 1 else os.path.basename(path)
panels.append({"label": labels[i] if i < len(labels) else f"Run {i + 1}", "note": note,
"t0": t0, "t1": t1, "lanes": data})
size = draw(panels, args.rx, args.title, args.subtitle, args.out, args.width)
print(f"{args.out}: {size[0]}x{size[1]}")
if __name__ == "__main__":
main()