Commit Graph

8 Commits

Author SHA1 Message Date
32aa2af9cb 120Hz: app-owned frame driver (spin) — the OS no longer paces renders
The user's ask: 'put in our own frame rate timer that doesn't drop
frames so we are not depending on the OS.' The OS can still own the
display latch (SF) — render-ahead presents ~1 ms after the edge with
~7 ms margin, so owning the RENDER clock is sufficient.

WHILE ANIMATING, the UI thread no longer waits on any OS wake:
  * poll is non-blocking (timeout 0); the loop spins at ~3-4k iters/s
    (250 us hrtimer park after each no-render iteration — an hrtimer
    park cannot be swallowed the way ALooper timeouts were, measured
    4-6 ms late from inside kernel/epoll)
  * the render decision is grid-driven: render when in a not-yet-
    rendered slot AND >=0.3 ms past its edge. No saw_wake.
  * the Choreographer + eventfd remain only as the GRID SENSOR.

Two grid bugs found and fixed while landing this (both made the first
driver build DOUBLES 8-10% of frames):
  * ANCHOR POLICY (wgpu_30_surface::note_vsync_edge): VSYNC_EDGE_NS is
    a SMOOTH grid anchor, not 'last observed edge'. A callback that
    fits the extrapolation (<1.5 ms deviation) does not move the
    anchor — a LATE callback re-anchoring shifts the anchor by
    (interval - P) and makes an already-rendered slot look NEW to a
    value-space guard. Period updates now require single-slot deltas.
  * BOOKKEEPING in SLOT-INDEX space (last_rendered_slot), not edge
    values, and ALL render paths mark it: locked accept, unlocked
    dirty render, and the render-ahead FLUSH (the flush not marking
    caused the +1.1/+1.2 true doubles after animating flickers).
  * POINTER window 200 -> 400 ms: bursty input (adb drags; finger
    pauses) expired 'animating' mid-drag, firing the locked->unlocked
    flush on top of the dirty render (double per flicker, ~5%).
  * flush now also skips when the iteration already rendered.

Phone, sltest (3s+1s+3s adb drags, jank_eval referee):
  buildC (pre-wake-fd-fix):   127 drops + 12 stalls
  slt14-spin (this driver):     2 drops +  1 stall + 8 dbl + 4 late
                                in 978 frames; phase p10-p90 0.86-0.90 ms
  gallery (real textures, same driver): drag windows 6+2 drops,
  7+11 doubles in 820 frames; phase p10-p90 0.73-0.95 ms
Remaining (visible next): drag-START warmup (first frame 20-40 ms:
CPU clock ramp after idle — the 'fling start jank' the user reports)
and isolated 2-slot drops (gallery: FullCache insert/eviction cost on
the UI thread).

Cost: ~60-70% of one core during active scroll; the perpetual
runnability also forces EAS big-core placement (the thread was
measured at 820 MHz on little cores during scroll — a large part of
the gallery's finger jank).
2026-10-30 18:21:50 -04:00
b97b6515d5 120Hz: Choreographer chain self-sustains on (animating||dirty); EW gates the wake; MISS v2 forensics; ctxsw discriminator + present CPU split
vsync.rs:
- set_state(animating, dirty) replaces set_animating. The Choreographer
  callback chain re-posts while animating||dirty: gating on animating
  alone let the chain die during flings (pointer inactivity >200ms) —
  with EW disabled the loop then froze (measured: 30 frames in 4s,
  then silence). The dirty mirror is pushed every main-loop iteration
  (<=8ms stale); the 6ms poll backstop bounds chain-break damage.
- EW_ON now gates the WAKE itself: the early waker raced the
  Choreographer (fires at edge+0.52ms measured) and its independent
  phase caused the -4..+4ms render-phase wander. Choreographer is the
  single wake source.
- MISS lines carry forensics: sw/sn (wake slot vs last rendered slot),
  eage (ms since last observed vsync edge), lastage (ms since last
  render) — distinguishes chain-stall / loop-stuck / guard-reject /
  no-wake-iteration without a second run.
stg.rs:
- ctxsw_sample() reads /proc/self/task/<pid>/stat for voluntary/involuntary
  context switches, CONDITIONAL on iteration >5ms (per-iteration procfs
  reads were a measured confounder: 20-50us each, pushed phase across
  the slot boundary, 10x worse drops). vsw/nvol on the IT line.
- precpu: CPU time inside Queue::present on the PRES line (pre_us >>
  precpu = blocked on binder/fence/SF; pre_us ~= precpu = CPU-bound).
wgpu_30_surface.rs: EW_ON moved here (vkprobe) so vsync.rs can gate the
wake without a new crate dependency.
lib.rs: poll backstop uses the dirty state; 6ms cap during active phases.
2026-10-30 14:51:06 -04:00
915e04c2c3 120Hz: jank_eval referee + slot-based wake guard + MISS ledger
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.
2026-10-30 13:29:50 -04:00
8f06b85376 120Hz: re-enable render-ahead under the corrected timing model
The 'callback arrives 3-6 ms late' model that invalidated render-ahead in
earlier sessions was a mislabeled STG field: the Choreographer callback
actually fires at edge+0.5 ms (measured from the VSYNC ftn/mon pair). With
the wake ON the edge, the held frame presented at render START lands at
edge+~1.5 ms (constant phase, 4 ms of deadline margin), and render spikes
(5-10 ms, the measured cause of the residual 1% drops + 1.5 ms phase
spread) only stretch the PREPARE phase of the frame held for the NEXT
slot — spikes stop delaying visible frames.

- lib.rs: RENDER_AHEAD_ACTIVE = locked again (was false)
- lib.rs/stg.rs: IT lines gain rcpu (CPU us inside do_render) — spike
  classifier: rcpu≈render ⇒ Skia/driver CPU work; rcpu≪render ⇒ blocked
  on GPU/driver
- hypotheses-120hz.md: model correction + sltest baseline + results

Emulator behavior verified: scroll, settle (transition flush), mid-drag
pause/resume (no black window), no AlreadyAcquired panics, held-frame
presents at 0.6-2.1 ms. Phone cadence measurement pending (phone out for
user use ~2h).
2026-10-30 12:12:44 -04:00
c72bfa481d 120Hz hunt: EW wake probe + poll/render CPU-time splits; sltest baseline
Measurements only, no behavior change:
- vsync.rs: EW lines (every early-waker landing, EW_ON flag, off by default)
- lib.rs/stg.rs: IT lines gain dec (locked/saw_wake/pr_before/do_render) and
  pcpu (CPU us inside poll_events — 2% = asleep in epoll, rules out CPU
  spin/deschedule as the wake latency)

Findings (docs/BUGS/hypotheses-120hz.md, sltest baseline):
- Choreographer callback fires at edge+0.52ms (p90 0.65) — the 'late
  Choreographer' model from earlier sessions was a mislabeled STG field
  (poll block time, not callback latency). The pipeline actually works at
  median: present at edge+~4ms, inside the 5.5ms deadline.
- sltest zero-jank definition measured: 98.8% of frames land in the next
  display slot; residual = 0.5-0.7% drops + 0.7-0.9% doubles + 1.4ms
  phase spread (p10-p90) = the visible micro-judder.
- Residual cost is VARIANCE, not median: per-frame render 3.5ms median
  (SKD 1.07 + CLR 0.8 + PRE 1.0 + SKF/ORD 0.65) with spikes to 10.7ms
  (SKD max 5.2, CLR max 3.2, PRE max 2.5). Spikes push presents past the
  deadline (drops) and shake the phase (judder).
Next: classify spikes (CPU work vs GPU/driver block) via render-window
CPU splits, then kill the top source.
2026-10-30 11:59:39 -04:00
7c2d0f354e 120Hz: early waker thread + render freshness guard (vsync-edge pacing)
The Choreographer vsync callback reaches the UI thread only 2.5-6 ms
AFTER the vsync edge (measured on the phone). With a 2.5-5 ms Skia
render plus present, that left no phase margin inside the 8.3 ms slot:
51% of frames landed one slot late (judder) and ~4% two slots late
(hitches), on every build tested so far.

Attempted fixes, all measured and retired (see commit history):
- render-ahead hold (present previous frame at next render start):
  the held present lands after the NEXT wake's 3.2-6 ms Choreographer
  latency -> 52% one-slot late.
- present-at-end + present GATE (sleep on the UI thread to quantize
  the present to the vsync grid): the gate sleep (2.5-4 ms) added to
  the slot budget (wake 5.3 + core 3 + gate 3 + present 1 = 12.3 ms >
  8.3 ms) -> cadence collapsed to 60 Hz.

Working fix (this commit):
- vsync.rs: an 'early waker' thread learns the vsync grid from the
  callback's frame_time_nanos (vkprobe::note_vsync_edge) and wakes the
  main looper ~0.8 ms BEFORE each edge while animating. Render start
  moves from edge+2.5..6 to edge-0.8..+0.5, so the present lands at a
  natural ~edge+3..5 ms phase with no UI-thread sleep.
- lib.rs: a render freshness guard (min gap = period - 1.5 ms) makes
  the FIRST wake of each slot (early or the late Choreographer
  fallback) render and rejects the second (double render = 60 Hz
  collapse). Threshold sits between late-after-early (5.8 ms, reject)
  and early-after-late (7.5 ms, accept).
- lib.rs: locked renders no longer consume pending_redraw (it carried
  the motion invalidations; consuming it starved the follow-up
  unlocked iteration -> 60 Hz collapse).
- stg.rs: IT lines now log the render decision (locked/saw_wake/
  pr_before/do_render); vkprobe logs gate sleep (GATE_US, now 0).

Measured (phone, 8580-file grid, slow 4 s drags):
  in-burst cadence: 8.29-8.44 ms median, p90 8.6-10.2 ms
  (was 8.36 median / 10.2 p90 with 3.7-20% two-slot gaps)
  in-burst >16.7 ms gaps: 0-2 per burst (was 3-13)
  present-to-present: 27% exact one slot, 33% one-slot-late steady
  state (imvisible), 3% f-jumps (Skia core 2.5-5 ms variance).
Residual judder lever: Skia core cost variance (next step).

Test hygiene reminders (HANDOFF 8.10): verify ResumedActivity, keep
the task clean (sz=1), record the perf-log byte offset before each
run (the file accumulates across process launches).
2026-10-30 11:06:58 -04:00
2f4768f354 Fix 120Hz scroll jank end-to-end: render-ahead present + full-cache eviction off the UI thread
Three root causes, all fixed and verified on the Pixel 9 Pro:

1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the
   render to the vsync wake; RENDER_AHEAD presents the previous frame at
   the start of render() (phase = wake + ~1.1 ms) and holds the new
   frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never
   merely dropped, before the next acquire (AlreadyAcquired panic at
   launch otherwise); discard_pending_frame presents too.

2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled
   through a ~20-cell viewport forever (137 decodes/20 s) and each
   eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes,
   ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone
   cooldown on evicted paths (idle prefetch skips, urgent tap bypasses)
   + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us).

3. (earlier) Skia final flush + Mailbox present mode (vendor, M3).

Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK
lines for cross-thread closure timing and push-site tagging.

Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact
120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero
>33 ms); PRES phase median ~994 us, zero late frames. Docs updated
(docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference).

Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch),
vkshim profiling tooling, backend made a non-optional dep, debuggable
flag for simpleperf (REMOVE before shipping).
2026-10-30 02:49:23 -04:00
d5bbb41bbd Jank-hunt perf feature: FR/STG/DECODE instrumentation, off by default
Residual scroll jank at 120 Hz (suspected Slint event-loop bug) needs
per-frame + per-stage timing the logcat ring buffer can't hold. New
cargo feature `perf` (default OFF — all sites are cfg-gated, zero
overhead when off):

- FR: per-frame app-side render cost + frame gap, from the rendering
  notifier (BeforeRendering/AfterRendering).
- STG: event-loop stage costs (wake/timers/events/poll/lastrender) from
  timestamps instrumented into the pinned vendor copy of
  i-slint-backend-android-activity (stg.rs; Cargo.toml now depends on
  it directly + optional under `perf`).
- DECODE: per-thumbnail-job cost on the workers.
- All lines mirrored to /sdcard/Download/.gallery_perf.log (logcat
  rotates too fast at ~120 lines/s).

Format + reading guide: docs/BUGS/scroll-jank-120hz.md.
2026-10-29 08:11:51 -04:00