gallery/tools/jank_eval.py
Greg Pomerantz 0a50f9b832 120Hz: fix the ALooper wake-fd race (root cause of slot drops) + phase-slip-robust referee
ROOT CAUSE of the 8-12% slot drops (uniform dec=1/0/1/0: locked, no wake
seen, dirty, no render): AOSP Looper::pollInner (system/core/libutils/
Looper.cpp) drains the wake fd in the SAME epoll batch (up to 16 fds) as
any ready input fd, and pollOnce returns the input's ident — the POLL_WAKE
result is a local that gets dropped. ALooper_wake's byte is consumed
WITHOUT PollEvent::Wake ever reaching Slint, unrecoverable. With motion
events arriving 1-4 ms after the edge (always for a real 120-240 Hz
finger; ~1 in 5 frames for 20 Hz adb input) the wake is lost and the
locked decision (saw_wake && slot-guard) drops the dirty frame for that
slot.

Fix (vendor backend): a dedicated eventfd registered with the MAIN looper
under ALOOPER_POLL_CALLBACK. The Looper invokes the C trampoline INSIDE
pollInner (before pollOnce returns, regardless of which ident is handed
back), so delivery can never be swallowed by a co-batched input response.
- frame_callback: note the edge, write the eventfd, then ALooper_wake
  (secondary). The fd is leaked: it belongs to the main looper for the
  process lifetime.
- trampoline: DRAINS the eventfd (level-triggered — without the drain the
  counter stays >0, the fd stays readable and the loop busy-spins on
  POLL_CALLBACK at ~10 kHz: 1812 spin iterations measured) and publishes
  the edge into delivered_edge.
- main loop: saw_wake |= (delivered_edge newer than last seen).
- DIRTY BACKSTOP: the 6 ms poll cap was never a backstop — timeout
  iterations have saw_wake=false so the locked decision never rendered
  (the 20-40 ms silences). Now: dirty && last render >= 2 slots old
  renders regardless of wake state.

Measured on the phone (Pixel 9 Pro, 120 Hz, sltest, identical capture):
  buildC (before): 127 drops + 12 stalls in 1142 frames
  buildE (after):    8 drops +  0 stalls in 1266 frames
Phase (p10-p90): 0.72-1.00 ms in all windows. Remaining 8/1266 (0.63%)
are slip-and-recover pairs where the UI thread is DESCHEDULED 6-8 ms
while sleeping in epoll (wakes arrive on time, cb-lat 0.5 ms) — EAS
scheduling variance, no app-side contention in sltest.

Referee fixes found while judging this data (tools/jank_eval.py):
- NEAREST-OBSERVED-EDGE slot grid: the 120 Hz panel clock phase-slips
  after idle blanking (re-locks at arbitrary phase), invalidating any
  grid anchored at ftns[0]. An entire drag window read late=230/
  dropped=24 while per-cycle tracing showed presents at edge+2.0 ms.
  slot_of_ftn assigns each present to the nearest observed hardware
  edge in a time-continuous slot domain (slip-immune: a constant delta
  shifts all slots, differences preserved; stall-accurate: ftn times
  are CLOCK_MONOTONIC).
- tools/jank_capture.py: capture is INVALID unless the screen is Awake
  AND the keyguard is down (a run on a sleeping phone silently injects
  no input: 49 IT lines, 1 present).
2026-10-30 16:08:48 -04:00

367 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
jank_eval.py — deterministic 120 Hz scroll-jank evaluator.
Reads app-side perf logs (PRES/VSYNC lines from the vendored stg/vkprobe
instrumentation) and produces a strict, repeatable PASS/FAIL report against
the zero-jank definition (docs/BUGS/hypotheses-120hz.md):
PASS iff, in every ACTIVE window (sustained rendering):
1. no DROPPED slot (present in slot k, next present in slot k+2+)
2. no DOUBLE (two presents in the same display slot)
3. no LATE present (edge-grid phase > +5.0 ms, past the SF latch)
4. no STALL (present-to-present > 20 ms)
5. phase spread p10-p90 <= 0.5 ms (edge grid: phase within slot)
SLOT GRID (the part that takes care to get right):
* PRIMARY: the VSYNC `ftn=` field is the hardware vsync EDGE timestamp.
slot(m) = floor((m - ftn0)/P), phase = (m-ftn0) mod P in [0,P).
Exact and unambiguous for any present phase. P = median 1-slot ftn
delta (hardware truth).
* FALLBACK (no VSYNC lines, e.g. emulator sanity runs): slot =
round((m - anchor)/P) with anchor = circular mean of present phases.
Ambiguous for presents >P/2 from the anchor (documented limitation);
LATE detection is unreliable in this mode.
Windows: --windows "startMs,endMs name; ..." (ms from first present)
or auto-detected (present rate > 60/s active, < 10/s idle).
Exit: 0 = PASS, 1 = FAIL, 2 = no usable data.
Self-test: --selftest (synthesizes log FILES and runs the same main()
path end-to-end; the tool must classify each case or exit non-zero).
"""
import argparse, bisect, math, os, re, sys, tempfile
from collections import defaultdict
PRES_RE = re.compile(r"^PRES f=(\d+)\w* mon=(\d+)")
VSYNC_RE = re.compile(r"^VSYNC ftn=(\d+) mon=(\d+)")
def parse_log(lines):
pres, ftns = [], []
for l in lines:
m = PRES_RE.match(l)
if m:
pres.append((int(m.group(1)), int(m.group(2))))
continue
m = VSYNC_RE.match(l)
if m:
ftns.append(int(m.group(1)))
return pres, ftns
def vsync_period(ftns):
"""Hardware period: median of the 1-slot ftn deltas."""
if len(ftns) < 30: return None
d = sorted(b - a for a, b in zip(ftns, ftns[1:]) if 4_000_000 < b - a < 25_000_000)
return d[len(d)//2] if d else None
def fallback_period(monots):
"""Median of consecutive PRES deltas, snapped to the nearest standard
refresh period. (The telescoping MEAN is useless here: it spans idle
gaps too — 391 presents over a 23 s run fit a '59.6 ms' period on a 60 Hz
display.) The display is always a standard rate, so snap. Do NOT use a
fitted period for the verdict on the phone; VSYNC edges are the truth.
"""
if len(monots) < 2:
return 8_333_333
d = sorted(monots[i+1] - monots[i] for i in range(len(monots) - 1))
med = d[len(d)//2]
STD_MS = [1/240, 1/144, 1/120, 1/100, 1/90, 1/85, 1/72, 1/60, 1/50,
1/48, 1/40, 1/30, 1/24]
best = min(STD_MS, key=lambda r: abs(1.0/r - med/1e6))
return int(1_000_000_000 / (1.0/best))
def anchor_phase(monots, period):
"""Circular mean of present phases (handles 0/P wrap and bimodal
wake-source phases; the anchor stays <P/2 from every present as long
as the modes are <P apart)."""
sx = sy = 0.0
for m in monots[:2000]:
q = (m % period)/period * 2*math.pi
sx += math.cos(q); sy += math.sin(q)
ang = math.atan2(sy, sx)
if ang < 0: ang += 2*math.pi
return int(ang/(2*math.pi)*period)
def slot_of_ftn(m, ftns, P):
"""Assign a present to the slot of the NEAREST OBSERVED hardware edge,
in a TIME-CONTINUOUS slot domain. (2026-10-30: the cumulative grid
anchored at ftns[0] is WRONG after a display phase-slip — the 120 Hz
panel clock re-locks at an arbitrary phase after idle blanking, so every
present after the slip measures ~5-6 ms 'late' against the stale grid
while landing perfectly against the real edges. Measured on the phone:
an entire drag window (w1) read late=230/dropped=24 while per-cycle
tracing showed presents at edge +2.0 ms.)
The slot of present m is: round((ftn_i − ftns[0])/P) where ftn_i is the
nearest observed edge before m (plus one when m is in that edge's second
half). A phase-slip Δ<P shifts EVERY ftn by the same Δ, so it only adds
a constant to all slots — drops/doubles (slot DIFFERENCES) are immune,
and the phase reported is m − ftn_i (true distance from the hardware
edge). Stalls are time-accurate because ftn times are CLOCK_MONOTONIC.
When no edge was observed within the last P (chain off), extrapolate on
the plain time grid from ftns[0] (same slot domain: round-based)."""
i = bisect.bisect_right(ftns, m) - 1
if i < 0:
i = 0
d = m - ftns[i]
if d > P:
x = (m - ftns[0]) / P
s = int(x + 0.5) if x >= 0 else -int(-x + 0.5)
return s, (m - ftns[0]) - s * P
base = (ftns[i] - ftns[0]) / P
s = int(base + 0.5) if base >= 0 else -int(-base + 0.5)
if d >= P / 2:
s += 1
return s, d
def slot_of(m, P, grid):
"""grid = ('edge', ftn0) or ('anchor', anchor). Returns (slot, phase_ns).
edge: phase in [0, P) from the vsync edge (hardware truth)
anchor: phase in (-P/2, P/2] centered on the anchor (fallback)"""
mode, ref = grid
if mode == "edge":
s = (m - ref)//P
return s, m - ref - s*P
x = (m - ref)/P
s = int(x + 0.5) if x >= 0 else -int(-x + 0.5)
return s, (m - ref) - s*P
def auto_windows(pres, period):
"""NOTE: the active threshold must NOT be high — it is circular (a JANKY
app has a LOW present rate and would then not be detected as active at
all: emulated 60 Hz scroll presenting at 30-40 Hz missed a 0.75×
threshold). 0.4×rate catches a scroll running at a quarter of refresh;
idle (0-2 presents/s) stays far below it."""
if not pres: return []
t0 = pres[0][1]
rate = 1000.0/(period/1e6) # display Hz (120 phone, 60 emulator)
hi, lo = 0.4*rate, 0.1*rate
BUCK = 250_000_000
dens = defaultdict(int)
for f, m in pres:
dens[(m - t0)//BUCK] += 1
last = int((pres[-1][1] - t0)//BUCK)
wins, start = [], None
for b in range(last + 1):
r = dens.get(b, 0) * 4 # per-second rate (BUCK = 250 ms)
if r > hi and start is None:
start = b
elif r < lo and start is not None:
wins.append((start, b)); start = None
if start is not None:
wins.append((start, last + 1))
out = []
for i, (b0, b1) in enumerate(wins):
idx0 = next((k for k, (f, m) in enumerate(pres) if (m - t0)//BUCK >= b0), None)
idx1 = len(pres)
for k in range(len(pres) - 1, -1, -1):
if (pres[k][1] - t0)//BUCK < b1:
idx1 = k + 1
break
if idx0 is not None:
out.append((f"w{i}", idx0, idx1))
return out
def explicit_windows(pres, t0, spec):
out = []
for part in spec.split(";"):
part = part.strip()
if not part: continue
rng, _, name = part.partition(" ")
a, b = rng.split(",")
a, b = int(a)*1_000_000, int(b)*1_000_000
idx0 = next((k for k, (f, m) in enumerate(pres) if m >= t0 + a), None)
idx1 = len(pres)
for k in range(len(pres) - 1, -1, -1):
if pres[k][1] <= t0 + b:
idx1 = k + 1
break
if idx0 is not None:
out.append((name or f"w{len(out)}", idx0, idx1))
return out
def evaluate(pres, P, grid, windows, suspect=False, ftns=None):
"""suspect=True (fitted grid, no VSYNC edges): drops/doubles are reported
for information but excluded from the verdict — a fitted grid can't prove
them. Stalls and late-phase remain meaningful.
ftns: observed hardware vsync edges — when given, slots/phases are
assigned relative to the NEAREST observed edge (phase-slip robust); see
slot_of_ftn."""
t0 = pres[0][1]
use_ftn = ftns is not None and len(ftns) > 10
any_fail = False
total = 0
for name, i0, i1 in windows:
seg = pres[i0:i1]
total += len(seg)
if len(seg) < 10:
print(f"\n=== window '{name}' n={len(seg)} -> SKIP (short)")
continue
drops = doubles = lates = stalls = jit = 0
drop_at, stall_at, late_at, double_at = [], [], [], []
prev_s = prev_p = None
phases = []
for k in range(len(seg)):
f, m = seg[k]
if use_ftn:
s, p = slot_of_ftn(m, ftns, P)
else:
s, p = slot_of(m, P, grid)
phases.append(p)
if prev_s is not None:
gap = s - prev_s
if gap >= 2:
drops += gap - 1
drop_at.append((k, gap, (m - seg[k-1][1])/1e6))
elif gap == 0:
doubles += 1
double_at.append(k)
if p > 5_000_000:
lates += 1
late_at.append((k, p/1e6))
if k and (m - seg[k-1][1]) > 20_000_000:
stalls += 1
stall_at.append((k, (m - seg[k-1][1])/1e6))
if prev_p is not None and abs(p - prev_p) > 500_000:
jit += 1
prev_s, prev_p = s, p
ps = sorted(phases)
spread = (ps[int(len(ps)*9)//10] - ps[len(ps)//10])/1e6
ok = (lates == 0 and stalls == 0 and spread <= 0.5)
if not suspect:
ok = ok and drops == 0 and doubles == 0
any_fail = any_fail or not ok
print(f"\n=== window '{name}' n={len(seg)} "
f"[{(seg[0][1]-t0)/1e6:.0f}..{(seg[-1][1]-t0)/1e6:.0f} ms] -> "
f"{'PASS' if ok else 'FAIL'}")
print(f" dropped={drops} doubles={doubles} late={lates} stalls={stalls} "
f"phase p10-p90={spread:.2f} ms phase-jumps>0.5ms={jit}")
for k, gap, ms in drop_at[:5]:
print(f" DROP #{k}: skipped {gap-1} slot(s), gap {ms:.1f} ms")
for k, ms in stall_at[:5]:
print(f" STALL #{k}: {ms:.1f} ms")
for k, ms in late_at[:5]:
print(f" LATE #{k}: phase +{ms:.2f} ms")
for k in double_at[:5]:
print(f" DOUBLE #{k}")
print(f"\n=== SUMMARY windows={len(windows)} frames={total} -> "
+ ("PASS" if not any_fail and windows else "FAIL"))
return (not any_fail) and bool(windows)
# ---------------------------------------------------------------- self-test
def selftest():
"""Synthesize LOG FILES (VSYNC ftn + PRES lines) and run the same
run_file() path as production. Cases must be classified exactly."""
P = 8_333_333
T0 = 100_000_000
def gen(name, off, skips=(), doubles=(), stall_after=None, n=600):
lines, i = [], 0
t = T0
for i in range(n):
if i in skips:
t += P
continue
lines.append(f"VSYNC ftn={t} mon={t+200_000}")
m = t + off(i)
lines.append(f"PRES f={i} mon={m}")
if i in doubles:
lines.append(f"PRES f={i}b mon={m+300_000}")
t += P
if stall_after is not None and i == stall_after:
t += 120_000_000
path = tempfile.NamedTemporaryFile("w", suffix=".log", delete=False)
path.write("\n".join(lines))
path.close()
return path.name
cases = [
("perfect 120Hz +1ms", lambda i: 1_000_000, {}, set(), None, True,
dict(drops=0, doubles=0, lates=0, stalls=0, max_spread=0.2)),
("3-slot drop at 200", lambda i: 1_000_000, {200, 201, 202}, set(), None, False,
dict(drops=3, doubles=0, lates=0, stalls=1, max_spread=99)),
("double at 300", lambda i: 1_000_000, {}, {300}, None, False,
dict(drops=0, doubles=1, lates=0, stalls=0, max_spread=0.2)),
("late i%7==0 +6ms (past latch)", lambda i: 6_000_000 if i % 7 == 0 else 1_000_000,
{}, set(), None, False,
dict(drops=0, doubles=0, lates=1, stalls=0, max_spread=99)),
("120ms stall at 400", lambda i: 1_000_000, {}, set(), 400, False,
dict(drops=14, doubles=0, lates=0, stalls=1, max_spread=99)),
("phase swing +/-.6ms", lambda i: 1_000_000 + (i % 5 - 2)*300_000,
{}, set(), None, False,
dict(drops=0, doubles=0, lates=0, stalls=0, max_spread=1.25)),
("tiny jitter 0.3ms", lambda i: 1_000_000 + (i % 3 - 1)*150_000,
{}, set(), None, True,
dict(drops=0, doubles=0, lates=0, stalls=0, max_spread=0.31)),
]
ok_all = True
for name, off, skips, dbl, stall_after, expect_pass, exp in cases:
path = gen(name, off, skips, dbl, stall_after)
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
res, _sus = run_file(path, period_ms=None, windows_spec="")
os.unlink(path)
txt = buf.getvalue()
m = re.search(r"dropped=(\d+) doubles=(\d+) late=(\d+) stalls=(\d+).*p10-p90=([\d.]+)", txt)
if not m:
print(f" selftest {name:32s} *** could not parse report ***\n{txt}")
ok_all = False
continue
d, db, la, st, sp = int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4)), float(m.group(5))
good = (res == expect_pass) and d >= exp["drops"] and db >= exp["doubles"] \
and la >= exp["lates"] and st >= exp["stalls"] and sp <= exp["max_spread"]
ok_all = ok_all and good
print(f" selftest {name:32s} expect={'PASS' if expect_pass else 'FAIL':4s} "
f"got={'PASS' if res else 'FAIL':4s} (drops={d} dbl={db} late={la} stalls={st} "
f"spread={sp:.2f}) {'OK' if good else '*** MISMATCH ***'}")
print("self-test:", "PASS" if ok_all else "FAIL")
return 0 if ok_all else 1
def run_file(path, period_ms, windows_spec):
lines = open(path, errors="replace").read().splitlines()
pres, ftns = parse_log(lines)
if len(pres) < 50:
print("no usable PRES data"); return False, False
monots = [m for _, m in pres]
src, P = None, None
if ftns:
P = vsync_period(ftns)
if P: src = "vsync-edges"
if period_ms:
P, src = int(period_ms*1e6), "override"
if not P:
P, src = fallback_period(monots), "fallback-fit"
grid = ("edge", ftns[0]) if (src == "vsync-edges" or (period_ms and ftns)) else \
("anchor", anchor_phase(monots, P))
t0 = monots[0]
suspect = (grid[0] == "anchor")
print(f"display period: {P/1e6:.4f} ms (rate {1e6/(P/1e6):.1f} Hz, {src}; "
f"grid={'vsync-edge' if grid[0]=='edge' else 'anchor-fallback'}"
+ (" *** SUSPECT: fitted grid — only stalls are meaningful here "
"(drop/double need the hardware edge grid)" if suspect else ""))
wins = explicit_windows(pres, t0, windows_spec) if windows_spec else auto_windows(pres, P)
if not wins:
print("no active windows found"); return False, suspect
return evaluate(pres, P, grid, wins, suspect=suspect, ftns=ftns or None), suspect
def main():
ap = argparse.ArgumentParser()
ap.add_argument("file", nargs="?")
ap.add_argument("--windows", default="", help='"startMs,endMs name; ..." (ms from first present)')
ap.add_argument("--period-ms", type=float, default=None,
help="force the display period (phone: 8.3333)")
ap.add_argument("--selftest", action="store_true")
args = ap.parse_args()
if args.selftest:
sys.exit(selftest())
if not args.file:
ap.print_usage(); sys.exit(2)
ok, suspect = run_file(args.file, args.period_ms, args.windows)
if not ok:
sys.exit(1)
sys.exit(3 if suspect else 0) # 3 = PASS-but-SUSPECT (fitted grid)
if __name__ == "__main__":
main()