From 915e04c2c328dbdd63a84a424e2738961e55e7bf Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Fri, 30 Oct 2026 13:29:50 -0400 Subject: [PATCH] 120Hz: jank_eval referee + slot-based wake guard + MISS ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/BUGS/hypotheses-120hz.md | 60 +++- src/lib.rs | 2 + tools/__pycache__/jank_eval.cpython-311.pyc | Bin 0 -> 24897 bytes tools/jank_eval.py | 302 ++++++++++++++++++ .../lib.rs | 50 +-- .../stg.rs | 10 + 6 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 tools/__pycache__/jank_eval.cpython-311.pyc create mode 100644 tools/jank_eval.py diff --git a/docs/BUGS/hypotheses-120hz.md b/docs/BUGS/hypotheses-120hz.md index 797326d..0137a3d 100644 --- a/docs/BUGS/hypotheses-120hz.md +++ b/docs/BUGS/hypotheses-120hz.md @@ -108,10 +108,56 @@ phone, 2×4 s drags, 1433 transitions: stall on the per-line flush. Batch-flush (N lines or 100 ms) needed for long runs. -### Remaining plan -1. User finger test: sltest first (minimal), then gallery with the - render-ahead build (already built, aarch64). -2. If residual jank on gallery: same sltslot.py analysis on the gallery - perf log; spikes there are textured (bigger SKD) — the absorption - should still hold; CPU-spike attack (Skia) as fallback. -3. Batch-flush fix for sltest's logger before longer phone sessions. +### 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). diff --git a/src/lib.rs b/src/lib.rs index 6404e86..3c3aede 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1479,6 +1479,7 @@ mod nblog { || msg.starts_with("CPUW ") || msg.starts_with("IT ") || msg.starts_with("SLOWEV ") + || msg.starts_with("MISS ") || msg.starts_with("OTHR ") || msg.starts_with("INVK ") || msg.starts_with("INVK_TAG ") @@ -1509,6 +1510,7 @@ mod nblog { || msg.starts_with("CPUW ") || msg.starts_with("IT ") || msg.starts_with("SLOWEV ") + || msg.starts_with("MISS ") || msg.starts_with("OTHR ") || msg.starts_with("INVK ") || msg.starts_with("INVK_TAG ") diff --git a/tools/__pycache__/jank_eval.cpython-311.pyc b/tools/__pycache__/jank_eval.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e520faaceadcc77b1981f244d0b70c1c3f77dcf4 GIT binary patch literal 24897 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 literal 0 HcmV?d00001 diff --git a/tools/jank_eval.py b/tools/jank_eval.py new file mode 100644 index 0000000..12550c1 --- /dev/null +++ b/tools/jank_eval.py @@ -0,0 +1,302 @@ +#!/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, 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): + """Mean of the deltas (telescoping — unbiased by correlated jitter). + Do NOT use a fitted period for the verdict on the phone; it is a + sanity-run convenience for logs without VSYNC edges.""" + n = len(monots) + return int((monots[-1] - monots[0])/(n-1)) if n > 1 else 8_333_333 + +def anchor_phase(monots, period): + """Circular mean of present phases (handles 0/P wrap and bimodal + wake-source phases; the anchor stays

= 0 else -int(-x + 0.5) + return s, (m - ref) - s*P + +def auto_windows(pres, period): + if not pres: return [] + t0 = pres[0][1] + rate = 1000.0/(period/1e6) # display Hz (120 phone, 60 emulator) + hi, lo = 0.75*rate, 0.25*rate # active vs idle density thresholds + BUCK = 500_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) * 2 + 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): + t0 = pres[0][1] + 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] + 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 = drops == 0 and doubles == 0 and lates == 0 and stalls == 0 and spread <= 0.5 + 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 = 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 + 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] + 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'})") + 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 + return evaluate(pres, P, grid, wins) + +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 = run_file(args.file, args.period_ms, args.windows) + sys.exit(0 if ok else 1) + +if __name__ == "__main__": + main() diff --git a/vendor/i-slint-backend-android-activity-1.18.1/lib.rs b/vendor/i-slint-backend-android-activity-1.18.1/lib.rs index 7d6f6c3..d8ee44d 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/lib.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/lib.rs @@ -289,33 +289,43 @@ impl i_slint_core::platform::Platform for AndroidPlatform { // means the next iteration renders too; if the content is // unchanged the extra render is invisible. // - // FRESHNESS GUARD: with the early waker (vsync.rs), TWO - // wakes can arrive per slot — the early one (~0.8 ms BEFORE - // the vsync edge) and the Choreographer's late fallback - // (~5 ms after it). The first one of each slot renders; - // the second must NOT (double render = 2× the slot budget = - // 60 Hz collapse). The intervals (measured): - // late-after-early = ~5.8 ms → must be REJECTED - // early-after-late = ~7.5 ms → must be ACCEPTED - // same-kind = ~8.33 ms → ACCEPTED - // Threshold `period − 1.5 ms` (≈6.8 ms) sits between 5.8 - // and 7.5 with ~0.7-1.0 ms of jitter margin on both sides. - // (A 0.7-period threshold rejects early-after-late and a - // 1.0-period threshold locks the cadence onto the late - // wakes — both measured to fragment the burst.) + // SLOT-BASED WAKE GUARD (hypotheses-120hz.md, 2026-07-09 (3)). + // Replaces the interval freshness guard, which had a 0.2 ms + // false-reject margin (threshold 6.83 ms vs earliest + // legitimate interval 7.03 ms after a Choreographer-phase + // render) and — worse — could not express the real rule. + // The rule: at most ONE render per display slot. A wake + // belongs to slot floor((t − E)/P) (E = last observed vsync + // edge, P = period; a stale E only shifts all indices by a + // constant, so the comparison is exact). Reject only when + // the wake's slot is the one already rendered; a wake N + // slots later (after a spike or lost wakes) MUST render — + // and the gap is logged as MISS with the wake state, which + // is the missing evidence for the stall slots. let __now_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); - let __period_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS + let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS + .load(std::sync::atomic::Ordering::Relaxed); + let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS .load(std::sync::atomic::Ordering::Relaxed); let __last = i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .load(std::sync::atomic::Ordering::Relaxed); - let __min_gap = __period_ns.saturating_sub(1_500_000); - let __fresh = __period_ns == 0 - || __now_ns.saturating_sub(__last) >= __min_gap; - if __fresh { + let __slot = |t: u64| -> i128 { + if t >= __e { ((t - __e) as i128) / (__p as i128) } + else { -((( __e - t) as i128 + __p as i128 - 1) / (__p as i128)) } + }; + let __sw = if __p == 0 { 0 } else { __slot(__now_ns) }; + let __sn = if __p == 0 || __last == 0 { i128::MIN } else { __slot(__last) }; + if __p != 0 && __last != 0 && __sw >= __sn + 2 + && stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) + { + stg::log_miss(__sw - __sn - 1, saw_wake, __now_ns); + } + let __accept = saw_wake && (__p == 0 || __sw > __sn); + if __accept { i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .store(__now_ns, std::sync::atomic::Ordering::Relaxed); } - saw_wake && __fresh + __accept } else { self.window.pending_redraw.get() }; diff --git a/vendor/i-slint-backend-android-activity-1.18.1/stg.rs b/vendor/i-slint-backend-android-activity-1.18.1/stg.rs index 8753325..3a8c9b9 100644 --- a/vendor/i-slint-backend-android-activity-1.18.1/stg.rs +++ b/vendor/i-slint-backend-android-activity-1.18.1/stg.rs @@ -172,6 +172,16 @@ pub fn log_slow_event(name: &str, us: u64, cpu_us: u64) { log::info!("SLOWEV {} {}us cpu={}us", name, us, cpu_us); } +/// Slot-ledger (hypotheses-120hz.md): a wake arrived for a slot >= 2 past +/// the last rendered slot — the intermediate slot(s) produced NO frame. +/// nowake = whether THIS wake was seen (a no-wake iteration precedes it). +/// This line is the missing evidence for the 20-100 ms stall slots: +/// saw_wake=true ⇒ the previous slots' wakes were LOST (eventfd/waker) +/// nowake context + slow poll/ev in the preceding IT line ⇒ loop blocked +pub fn log_miss(slots: i128, saw_wake: bool, now_ns: u64) { + log::info!("MISS mon={} slots={} nowake={}", now_ns, slots, if saw_wake { 1 } else { 0 }); +} + /// Jank-hunt: total `invoke_from_event_loop` pushes (INVK n=..). pub static INVK_PUSH_COUNT: AtomicU64 = AtomicU64::new(0);