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/.
250 lines
10 KiB
Python
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())
|