gallery/tools/fr_capture.py
Greg Pomerantz 5a8284e7a9 Port the gallery from Slint to egui (egui 0.31 / egui_glow / winit 0.30 / glow 0.16)
Slint-on-Mali was conclusively janky at its base level (sltest: 0.33-0.83%
slot drops on a scene with no app logic); egui on the same phone measured
3-7x cleaner (egtest: 0.12%, same referee/scene).

Architecture (see HANDOFF §8.11-8.12):
- winit 0.30 ApplicationHandler on android-activity 0.6, raw EGL (ES3),
  egui_glow::EguiGlow. winit 0.30 never delivers RedrawRequested on
  Android → the frame driver is app-owned: World::tick runs every loop
  iteration from about_to_wait (liveness), render when dirty or
  animating, park ~8 ms (one vblank) at rest.
- Virtualized grid painter (3 cols, visible rows ±4), GPU-resident
  textures, turbojpeg worker pool decoding off-thread.
- CRITICAL pixel-pipeline fix: RGB8→Vec<Color32> conversion happens on
  the WORKER (egui 0.31's from_rgba_unmultiplied is O(n) per-pixel,
  8-60 ms/2 MB on the UI thread → 9.57% drops + phone OOM crash).
  UI thread only MOVES the vector into ColorImage.
- Fling: per-event EMA velocity (frame-based dies at slow touch sampling),
  clamp 14000 px/s; decay exp(-7.42 dt).
- Painter::image uv is NORMALIZED (0..1) — a screen-space uv made every
  cell a solid rectangle (the emulator's solid-color test images hid it).
- Memory caps for a personal phone: full cache 6 entries / 16 MP.

Verified on emulator: grid, scroll, fling, viewer, back-key. Phone
deploy: thumbnails render, 8580 files. Pending: user finger test +
fr_capture phone run (per-pixel fix expected to cut render cost
18.8 ms → <8.3 ms).

Deletes: vendor/ (instrumented Slint tree), shim/, build.rs, gputex,
vkshim, baseline60, main.slint — the Slint zero-jank campaign is
concluded in docs/BUGS/.
2026-10-31 13:50:46 -04:00

250 lines
10 KiB
Python

#!/usr/bin/env python3
"""
fr_capture.py — end-to-end 120 Hz scroll-jank capture for FR-line apps
(egtest / sltest family).
The jank_eval/jank_capture pair needs Slint's vendored PRES/VSYNC lines;
FR-line harnesses (sltest, egtest) log app-side frame gaps instead. This
tool runs the same disciplined capture pipeline and the same referee that
produced the sltest reference numbers (drops = present gaps > 1.8x the
median, within one process's log):
fr_capture.py capture --app wow.gmp.egtest --flicks 5 [--tag egui031]
1. device + build identity (apk sha256, versionName) -> PROVENANCE
2. force-stop, DELETE fr.log (one process per file)
3. cold launch, verify ResumedActivity
4. scripted flicks: N fast swipes (350 ms, 1600 px) with 2 s settles,
one slow drag (1200 ms) in the middle
5. verify still ResumedActivity, force-stop
6. pull the complete log (exactly one process)
7. report: provenance + gap verdict + per-drop context
(t, gap, neighbours — isolated vs chain) + render-cost stats
Exit: 0 = no drops, 1 = drops found / invalid capture.
fr_capture.py report FILE.log re-analyze a pulled log
fr_capture.py --selftest offline validation
Note (honesty): the gap clock is the APP's clock (swap-completion to
swap-completion), not the display slot grid. That is the same referee
used for all sltest A/B numbers, which keeps egtest-vs-sltest
apples-to-apples; jank_eval's slot grid remains the stricter referee
for builds that emit PRES/VSYNC.
"""
import argparse, hashlib, os, re, subprocess, sys, time
ADB_SERIAL = os.environ.get("ADB_SERIAL", "")
FR_RE = re.compile(r"^FR\s+([\d.]+)\s+gap=\s*([\d.]+)\s+cost=\s*([\d.]+)\s+drew=(\d)")
def adb(*args, timeout=120):
cmd = ["adb"]
if ADB_SERIAL: cmd += ["-s", ADB_SERIAL]
cmd += list(args)
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def adb_shell(cmd, timeout=120):
return adb("shell", cmd, timeout=timeout)
# ------------------------------------------------------------------ analysis
def analyze(lines, label="log"):
frames = []
for ln in lines:
m = FR_RE.match(ln.strip())
if m:
frames.append((float(m.group(1)), float(m.group(2)),
float(m.group(3)), int(m.group(4))))
if len(frames) < 30:
print(f" {label}: only {len(frames)} FR lines — nothing to judge");
return None
# drop the first frame (gap=0) and process-suspend artifacts
gaps = [(t, g, c) for (t, g, c, d) in frames[1:] if 0 < g < 200]
if len(gaps) < 20:
print(f" {label}: only {len(gaps)} usable gaps — nothing to judge");
return None
gs = sorted(g for (_, g, _) in gaps)
med = gs[len(gs) // 2]
p90 = gs[int(len(gs) * 0.90)]
p99 = gs[int(len(gs) * 0.99)]
mx = gs[-1]
thr = 1.8 * med
drops = [(t, g) for (t, g, _) in gaps if g > thr]
cs = sorted(c for (_, _, c) in gaps)
return {
"n": len(gaps), "med": med, "p90": p90, "p99": p99, "max": mx,
"thr": thr, "drops": drops,
"fps": 1000.0 / med,
"cost_med": cs[len(cs) // 2], "cost_p99": cs[int(len(cs) * 0.99)],
"cost_max": cs[-1],
"frames": frames,
}
def print_report(res, title=""):
if res is None:
return 1
drops = res["drops"]
pct = 100.0 * len(drops) / res["n"]
print(f"\n=== JANK (gap referee, {title}) ===")
print(f"frames={res['n']} median gap={res['med']:.2f} ms (~{res['fps']:.0f} fps)")
print(f"p90={res['p90']:.2f} p99={res['p99']:.2f} max={res['max']:.2f} "
f"threshold={res['thr']:.2f} ms (1.8x med)")
print(f"render cost: med={res['cost_med']:.2f} ms p99={res['cost_p99']:.2f} "
f"max={res['cost_max']:.2f}")
print(f"DROPS: {len(drops)} of {res['n']} ({pct:.2f}%)")
if drops:
print(f"{'t(ms)':>10} {'gap(ms)':>9} context (prev, next gap)")
shown = 0
for i, (t, g, c, d) in enumerate(res["frames"][1:], start=1):
if g > res["thr"]:
p = res["frames"][i - 1][1] if i >= 1 else 0.0
n = res["frames"][i + 1][1] if i + 1 < len(res["frames"]) else 0.0
kind = "chain" if (p > res["thr"] or n > res["thr"]) else "isolated"
print(f"{t:10.1f} {g:9.2f} prev={p:7.2f} next={n:7.2f} {kind}")
shown += 1
if shown >= 25:
print(f"... {len(drops) - 25} more")
break
return 0 if not drops else 1
# -------------------------------------------------------------------- capture
def capture(app, flicks, tag="", manual=0):
ok = True
r = adb_shell("getprop ro.boot.cpu; getprop ro.product.model")
print("=== CAPTURE ===")
print(f"device: serial={ADB_SERIAL or '(default)'} model={r.stdout.strip()!r}")
# build identity
path = adb_shell(f"pm path {app}").stdout.strip().replace("package:", "").strip()
sha = adb_shell(f"sha256sum {path}").stdout.split()[0] if path else "?"
ver = adb_shell(f"dumpsys package {app} | grep versionName | head -1").stdout.strip()
print(f"app: {app} {ver} apk sha256={sha[:16]}… tag={tag or '-'}")
if not path:
print("INVALID: package not installed"); return 1
# Three log conventions: egtest/harness apps write
# /sdcard/Android/data/<app>/files/fr.log; tttest (Tauri) writes
# .../files/Documents/fr.log (Tauri's document_dir); the gallery
# mirrors its FR lines to /sdcard/Download/.gallery_perf.log.
log = f"/sdcard/Android/data/{app}/files/fr.log"
alts = [
f"/sdcard/Android/data/{app}/files/Documents/fr.log",
"/sdcard/Download/.gallery_perf.log",
]
adb_shell(f"am force-stop {app}"); time.sleep(1.5)
adb_shell(f"rm -f {log} " + " ".join(alts)); time.sleep(0.5)
# NativeActivity apps (slint/egui harnesses) vs real activities
# (Tauri → .MainActivity): try the specific entry, fall back to the
# package's default LAUNCHER activity via monkey.
adb_shell(f"am start -n {app}/android.app.NativeActivity")
time.sleep(3)
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
if app not in r.stdout:
adb_shell(f"monkey -p {app} -c android.intent.category.LAUNCHER 1")
time.sleep(3)
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
if app not in r.stdout:
print("INVALID: app not ResumedActivity after cold launch"); return 1
print("launched (ResumedActivity verified)")
if manual > 0:
# THE USER'S FINGER is the input: no scripted swipes. Just watch
# them scroll for `manual` seconds and measure what they made
# the renderer do. (adb `input swipe` injects at ~20 Hz —
# bursty, not representative of a real touch stream.)
print(f"!!! MANUAL: scroll the app normally for {manual}s — GO !!!")
for _ in range(manual):
time.sleep(1)
sys.stderr.write(".")
print("")
time.sleep(2.0)
else:
# scripted drags
H = 2856
flick = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 350"
slow = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 1200"
for i in range(flicks):
if i == flicks // 2:
adb_shell(slow); time.sleep(3.0)
else:
adb_shell(flick); time.sleep(2.0)
if i % 2 == 0: # flip direction to cover up-scrolls too
adb_shell("input swipe 640 600 640 2200 350"); time.sleep(2.0)
time.sleep(2.0)
r = adb_shell("dumpsys activity activities | grep -E 'ResumedActivity|topResumedActivity'")
adb_shell(f"am force-stop {app}"); time.sleep(1.0)
if app not in r.stdout:
print("WARNING: app not resumed right before stop (backgrounded?)")
# Pick whichever log the app actually wrote.
for alt in alts:
if adb_shell(f"[ -f {alt} ] && [ $(wc -c < {alt}) -gt 0 ] && echo y").stdout.strip() == "y":
log = alt
break
dest = os.path.join(os.path.dirname(os.path.abspath(__file__)), f".fr_{app.replace('.','_')}.log")
r = adb("pull", log, dest, timeout=300)
if r.returncode != 0:
print(f"INVALID: log pull failed: {r.stderr.strip()}"); return 1
with open(dest) as f:
lines = f.readlines()
print(f"pulled {len(lines)} lines -> {dest}")
res = analyze(lines, label="capture")
rc = print_report(res, title=tag or app)
return rc
# -------------------------------------------------------------------- selftest
def selftest():
# synthetic: 1200 frames at 8.33 ms, 4 injected drops (two chains)
lines, t, prev = [], 0.0, None
import random; random.seed(7)
for i in range(1200):
gap = 8.33 + random.uniform(-0.15, 0.15)
if i in (300, 301, 700, 1100):
gap = 25.0
t += gap
lines.append(f"FR {t:11.2f} gap={gap:7.2f} cost={2.0:6.2f} drew=1")
lines.insert(0, "FR 0.00 gap= 0.00 cost=180.00 drew=1")
res = analyze(lines)
assert res is not None
assert res["n"] == 1200, res["n"]
assert len(res["drops"]) == 4, res["drops"]
assert abs(res["med"] - 8.33) < 0.05
# no-drop case
lines2, t = [], 0.0
for i in range(1200):
gap = 8.33 + random.uniform(-0.1, 0.1)
t += gap
lines2.append(f"FR {t:11.2f} gap={gap:7.2f} cost=2.00 drew=1")
res2 = analyze(lines2)
assert len(res2["drops"]) == 0
# too-small case
assert analyze(["FR 0 0 0 1"] * 5) is None
print("selftest OK (drop detection, clean case, short-log case)")
return 0
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd")
c = sub.add_parser("capture")
c.add_argument("--app", required=True)
c.add_argument("--flicks", type=int, default=6)
c.add_argument("--tag", default="")
c.add_argument("--manual", type=int, default=0,
help="seconds to wait for the USER to scroll (no scripted swipes)")
r = sub.add_parser("report")
r.add_argument("file")
ap.add_argument("--selftest", action="store_true")
a = ap.parse_args()
if a.selftest:
return selftest()
if a.cmd == "capture":
return capture(a.app, a.flicks, a.tag, manual=a.manual)
if a.cmd == "report":
with open(a.file) as f:
return print_report(analyze(f.readlines()), title=a.file)
ap.print_help()
return 2
if __name__ == "__main__":
sys.exit(main())