tools/jank_eval.py: strict PASS/FAIL jank evaluator (the referee).
- slot grid from VSYNC ftn edges (hardware truth); anchor fallback only
- PASS iff per active window: no dropped slot, no same-slot double, no
present past the SF latch, no >20ms present gap, phase spread <=0.5ms
- self-test: 7 synthetic log files through the same main() path, all
classified correctly; validated on the emulator (60 Hz emu display
detected, active windows found)
- re-judges slt4 honestly: my 'clean' drag-1 window is FAIL (30.6ms +
21ms stalls, 20 past-latch presents, 1.1ms spread) — the user saw it
lib.rs/stg.rs: slot-based wake guard replaces the interval freshness
guard (0.2ms false-reject margin). A wake renders iff its edge-grid slot
is strictly after the last rendered slot; a gap of >=2 slots emits
MISS {mon, slots, nowake} — the missing evidence for stall slots
(wakes lost vs loop blocked vs guard).
sltest: log drain batch-flush (64 lines) — per-line FUSE flush stalled
the drain thread for minutes (file frozen, process alive).
gallery: MISS lines added to the perf-log allowlist.
164 lines
9.2 KiB
Markdown
164 lines
9.2 KiB
Markdown
# 120 Hz zero-jank campaign — hypothesis log
|
||
|
||
Target: sltest (minimal 100-row grid, no textures) on the phone's Mali G715.
|
||
Definition of ZERO JANK (measured from PRES mon= + VSYNC ftn= lines):
|
||
1. no display-slot skip: for consecutive presents, slot_index(n+1) == slot_index(n)+1 (100%)
|
||
2. no same-slot double: two presents never land in the same display slot
|
||
3. phase stable: present phase within its slot varies < 0.5 ms
|
||
Test: 2x `input swipe 640 1900 640 900 4000` (slow drags) + fling, ~10 s.
|
||
A "burst" = consecutive presents with gaps < 20 ms; evaluate inside bursts only.
|
||
|
||
## Hypotheses (order = priority)
|
||
|
||
- H6 [sltest confound]: sltest FileLogger is blocking (flush per line) on the
|
||
render path (PRES/STG/IT at ~240 lines/s).
|
||
FIX: non-blocking drain-thread logger. TEST: rebuild, re-measure baseline.
|
||
- H1: early waker misses edges (thread descheduled / nanosleep jitter) →
|
||
render starts late (edge+5 late wake wins) → present phase bimodal.
|
||
TEST: render-start phase histogram vs edges; early-wake hit rate.
|
||
- H2: Skia render core variance → present phase jitter.
|
||
TEST: VK SKD distribution in sltest (trivial scene, expect <1.5 ms).
|
||
- H3: wgpu/Mali submit+present latency variance (CLR/SKF/ORD/PRE).
|
||
TEST: VK PRE / CLR / SKF distributions in sltest.
|
||
- H4: event-loop overhead (timers, change tracker, spin iterations) eats the
|
||
slot budget.
|
||
TEST: IT poll/timers/ev/fe distributions; iters/s during scroll.
|
||
- H5: CPU scheduling of the UI thread (deschedule mid-slot, core migration,
|
||
frequency).
|
||
TEST: wall vs CPU split for the render window (vkprobe thread_cpu_ns).
|
||
- H7 [method confound]: adb `input swipe` motion is bursty (~20-60 Hz) →
|
||
grid movement itself is non-uniform regardless of render cadence.
|
||
TEST: pointer-location overlay + user finger, later.
|
||
|
||
## Rules
|
||
- One hypothesis per build; keep the change minimal; measure before/after
|
||
with the identical test; record numbers here.
|
||
- A hypothesis is RULED OUT only when the measurement it predicts is absent
|
||
(not "the fix didn't help").
|
||
- Phone hygiene before each run: clean task (sz=1), ResumedActivity,
|
||
dismiss 16KB dialog (sltest is debuggable), record log byte offsets.
|
||
|
||
## Results
|
||
|
||
### 2026-07-09 — sltest baseline + model correction (builds slt1-slt3)
|
||
|
||
- **H6 RULED OUT / FIXED**: sltest's blocking FileLogger replaced with a
|
||
non-blocking drain-thread logger. No crash, no behavior change beyond
|
||
log I/O coming off the render path.
|
||
- **MODEL CORRECTION (major)**: the "Choreographer callback arrives 2.5-6 ms
|
||
after the edge" belief from earlier sessions was WRONG — it came from the
|
||
STG `wake=` field, which actually measures the poll's block time (first
|
||
event), not callback latency. Measured from the VSYNC line itself
|
||
(ftn = vsync edge, mon = callback ran): **callback delivery = edge+0.52 ms
|
||
median, p90 0.65, max 1.8 ms** (sltest, phone). The callback fires ON the
|
||
edge.
|
||
- **H5 RULED OUT for the poll phase**: `pcpu` (CPU time inside
|
||
poll_events) = 2% of wall — the UI thread sleeps in epoll normally; it is
|
||
not spinning or descheduled while waiting. The wake→poll path is healthy:
|
||
poll start ≈ edge−2.3, byte at edge+0.5, return at edge+0.5, render 2.3-4.3
|
||
ms, present ≈ edge+3-4 ms — INSIDE the 5.5 ms deadline at the median.
|
||
(The early-waker's edge−0.8 wake is redundant with the on-edge
|
||
Choreographer wake — both are fine; the waker stays as fallback.)
|
||
- **Baseline zero-jank metrics (sltest, phone, 991 transitions)**:
|
||
DROPPED 0.71%, WASTED 0.91%, phase p10-p90 = 2.64-4.13 ms (1.5 ms
|
||
spread), 12.5% consecutive phase jumps >1 ms. Median behavior is correct;
|
||
the visible judder is the variance.
|
||
- **H2/H3 CONFIRMED (residual cause)**: per-frame render = 3.5 ms median
|
||
but spikes to 10.7 ms. Stage breakdown (sltest, phone): SKD (Skia draw)
|
||
1.07 ms median / 5.2 max; CLR (wgpu clear submit) 0.8 / 3.2; PRE 1.0 / 2.5;
|
||
SKF 0.3; ORD 0.35. Spikes push presents past the deadline (the drops) and
|
||
shake the phase (the judder). GPU submit overhead (2.45 ms median) exceeds
|
||
the actual scene draw (1.07 ms) — four wgpu submits per frame.
|
||
- **Next (implemented, phone-measurement pending)**: RENDER-AHEAD
|
||
re-enabled under the corrected model. With the wake on the edge, the held
|
||
frame is presented at the START of the render (edge+~1.5 ms, constant
|
||
phase, 4 ms margin) and a spike only stretches the PREPARE phase of the
|
||
frame held for the next slot — spikes stop delaying visible frames.
|
||
Behavior verified on the emulator (scroll, settle, mid-drag pause/resume,
|
||
no AlreadyAcquired, no black window). The phone is the only remaining
|
||
test: expect drops → ~0 and phase spread → <0.5 ms IF spikes were the
|
||
cause; `rcpu` (CPU time inside do_render) classifies any residual spikes
|
||
(CPU work vs GPU/driver block).
|
||
- **Emulator caveat**: the emulated Choreographer fires 9-10 ms late and the
|
||
SF pipeline is slow — the emulator cannot reproduce or validate 120 Hz
|
||
cadence; it is for behavior verification only. Phone remains the test
|
||
target.
|
||
|
||
### 2026-07-09 (2) — render-ahead on the phone (run slt4)
|
||
|
||
Render-ahead re-enabled (held frame presented at render START). sltest,
|
||
phone, 2×4 s drags, 1433 transitions:
|
||
- Steady burst (401 frames / 3.3 s): phase 0.39-1.06 ms (p10-p90,
|
||
spread 0.67 — was 1.5), slot jumps 1/401 (0.25%), doubles 0.
|
||
- Overall: DROPPED 0.70%, WASTED 0.70% — concentrated in the
|
||
17/62/87-frame bursts at drag START/STOP (locked↔unlocked transition +
|
||
fling decay), not in steady scroll.
|
||
- Render wall: med 3.68 ms, p90 3.67, p99 3.9 — tight.
|
||
- **Spike classifier (rcpu): all 13 spikes >6 ms are CPU-bound**
|
||
(rcpu≈wall, e.g. 17.5/15.2 ms) — Skia/driver CPU work, NOT
|
||
GPU/driver blocking. Render-ahead absorbs them: 13 spikes → 1 skipped
|
||
frame in the burst.
|
||
- Status: steady-state minimal-app scroll is effectively zero-jank by the
|
||
measured definition. Remaining work: (a) user finger-perception test on
|
||
sltest AND the gallery (textures, 8580 files), (b) if anything remains,
|
||
attack the 1.3% CPU spikes (Skia), (c) burst-boundary transitions
|
||
(drag start/stop) if visible.
|
||
- sltest logging bug noted: the non-blocking drain thread went silent
|
||
mid-idle (file frozen, process alive) — likely a /sdcard FUSE flush
|
||
stall on the per-line flush. Batch-flush (N lines or 100 ms) needed for
|
||
long runs.
|
||
|
||
### 2026-07-09 (3) — jank_eval.py: the referee
|
||
|
||
User rightly rejected ad-hoc analysis (and my over-optimistic
|
||
"effectively zero-jank" claim for the slt4 run). Built
|
||
`tools/jank_eval.py`: strict PASS/FAIL against the zero-jank definition
|
||
(no dropped slot, no same-slot double, no present past the SF latch
|
||
(+5 ms edge phase), no >20 ms present gap, phase spread p10-p90 <= 0.5 ms)
|
||
per active window.
|
||
|
||
- SLOT GRID: VSYNC `ftn=` edges = hardware truth (slot = floor((m-ftn0)/P),
|
||
P = median 1-slot ftn delta). Anchor-fit fallback only for VSYNC-less
|
||
logs (documented ambiguity limit >P/2).
|
||
- Self-test: 7 synthetic LOG FILES run through the same main() path —
|
||
perfect / 3-slot drop / double / past-latch late / 120 ms stall /
|
||
phase swing / sub-threshold jitter — all classified correctly.
|
||
- Emulator validated: correctly detects the 60 Hz emulated display,
|
||
finds active windows, reports the known emulated-SF jank. Window
|
||
thresholds scale with display rate.
|
||
- Estimator lessons (documented in the tool): median-of-deltas is biased
|
||
by correlated jitter (1.8% for a 0.15 ms sawtooth!); a residual-score
|
||
period search is non-minimizing at the true P (nearest-slot
|
||
quantization); floor-vs-round slot assignment flips frames near the
|
||
anchor. The hardware edge grid avoids all of it.
|
||
|
||
**Honest re-judgement of slt4 (my 2-minute phone test), via the new tool**
|
||
(window w0 = drag 1, n=452, 120.066 Hz):
|
||
dropped=4 (incl. one 30.6 ms + one 21.0 ms stall), late=20
|
||
(+5.1..+7.4 ms, early in the drag), phase spread 1.11 ms, 163 phase
|
||
jumps >0.5 ms -> FAIL. The user's "I saw jank during the test" is
|
||
confirmed by the data. (w1-w3 = post-test drain-stall corruption +
|
||
user phone usage, excluded.)
|
||
|
||
### Diagnosis state (what the CPU profiling did and did NOT answer)
|
||
- Answered: per-frame CPU work when a frame RUNS = 3.7 ms median, tight
|
||
p90. It is not missing compute; there is no CPU work missing.
|
||
- UNANSWERED (now targeted): why NO frame ran in the stall slots, and
|
||
why renders started past the latch. Candidates ranked:
|
||
1. FRESHNESS GUARD misfire: threshold 6.83 ms vs earliest legitimate
|
||
interval after a Choreographer-phase render 7.03 ms = 0.2 ms margin;
|
||
any jitter false-rejects a render (and a reject can't be "undone" —
|
||
the held frame waits a full extra slot).
|
||
2. Lost wakes: locked do_render = saw_wake && fresh; if BOTH the early
|
||
waker and the Choreographer fail to deliver a Wake event for a slot,
|
||
the frame silently vanishes. No counter exists for this today.
|
||
3. Loop blocked (driver/system call) for tens of ms — rcpu split will
|
||
show wall≫rcpu.
|
||
- Next build (phone): slot-based guard (reject only if the wake's slot
|
||
<= last rendered slot, slot from the edge grid) + a per-slot render
|
||
ledger that logs every missed slot WITH its reason (no-wake /
|
||
guard-reject / not-animating / blocked). jank_eval is the referee.
|
||
- sltest logger: batch-flush fix still pending (drain thread went silent
|
||
~120 s into process B; PRES lines use a separate path so present data
|
||
survived).
|