feat(client/present): the cadence statistic — measure judder, not just latency

WP1 of design/presenter-cadence-rework-implementation-plan.md. Shared core
type plus the Android binding; desktop and Apple follow.

Every stat we publish is a latency — a difference between two points on one
frame. No latency can see judder, because judder is a property of the
SEQUENCE. A stream that shows each frame one refresh early and the next one
late has excellent percentiles and looks broken; a stream whose every
interval is exactly two refreshes has worse latency than one alternating 1
and 3, and looks perfect. That blind spot is why a smoothness complaint
could not be confirmed or refuted from our own telemetry.

PresentIntervals quantises the spacing between consecutive on-glass instants
onto the learned panel grid and reports the modal spacing plus the fraction
of intervals that miss it — the judder number, in permille to match the
phase coherence already next to it. Scale-free: the mode absorbs the cadence
ratio, so 60-on-120 and 120-on-120 are both "one tall bucket" and directly
comparable. That is what makes it usable as one ruler across clients,
refresh rates and stream rates, and for a feature-on/off A/B.

Deliberate choices, each with a test:

  - fed the MEASURED on-glass instant, never the requested present time,
    which would measure our own intent and always look perfect
  - fed SurfaceFlinger's raw CLOCK_MONOTONIC render stamp, not the
    realtime-rebased one the latency stats use: cadence is about spacing,
    and a realtime clock step would forge a hitch that never happened
  - stalls (>8 refreshes) and out-of-order callbacks counted apart from the
    ratio, so a window that looks smooth because the stream was PAUSED
    cannot be mistaken for a good one
  - sub-refresh jitter is not judder: the display quantises it away, so the
    metric must too
  - the predecessor survives a window drain, else one interval per window
    would go unscored forever

Always-on via the 1 Hz pf.present line, so the HUD-off wireless A/B the
baseline measurement needs is readable from logcat. HUD surfacing waits on
the stats-unification spec amendment (plan S3) and on the in-flight HUD work.

Gates: punktfunk-core 189 tests green (10 new); cargo ndk check + clippy
arm64 clean — the 5 remaining warnings are pre-existing and in other files.
This commit is contained in:
2026-08-05 22:40:43 +02:00
parent 4a0d0ce587
commit fc5b6296e3
3 changed files with 338 additions and 7 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ unsafe extern "C" fn on_frame_rendered(
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
// Always-on half: the presenter's pf-present line reads these with the HUD off.
t.meter.note_latch(latch_us);
t.meter.note_latch(latch_us, system_nano);
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
+52 -6
View File
@@ -22,7 +22,7 @@
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
use std::sync::Mutex;
use std::time::Instant;
@@ -152,6 +152,10 @@ pub(super) struct PresentMeter {
/// This device delivers render callbacks at all (API ≥ 33 and the platform accepted the
/// registration). Until one arrives, `undisplayed` is meaningless and the rail stays down.
confirms: AtomicBool,
/// The learned panel period the cadence statistic quantises against, republished by
/// [`Presenter::pump`] (the callback thread has no access to the vsync clock). 0 until the
/// grid is known, which simply means cadence is not scored yet.
panel_period_ns: AtomicI64,
}
struct PresentMeterInner {
@@ -166,6 +170,9 @@ struct PresentMeterInner {
/// Capture→decoded end-to-end µs (skew-corrected, clamped) — always on for the same reason:
/// the wireless A/B's headline without having to reach the on-screen HUD.
e2e_us: Vec<u64>,
/// The cadence (judder) statistic — the only stat here that is not a latency, and the only
/// one that can see a pacing defect. See [`punktfunk_core::phase::PresentIntervals`].
intervals: punktfunk_core::phase::PresentIntervals,
}
impl PresentMeter {
@@ -177,19 +184,33 @@ impl PresentMeter {
feed_us: Vec::with_capacity(256),
codec_us: Vec::with_capacity(256),
e2e_us: Vec::with_capacity(256),
intervals: punktfunk_core::phase::PresentIntervals::new(),
}),
undisplayed: AtomicI32::new(0),
confirms: AtomicBool::new(false),
panel_period_ns: AtomicI64::new(0),
}
}
/// Republish the learned panel period for the cadence statistic (presenter thread).
pub(super) fn set_panel_period(&self, period_ns: i64) {
self.panel_period_ns.store(period_ns, Ordering::Relaxed);
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
///
/// Also the glass budget's CONFIRM: this frame left the BufferQueue, so one outstanding
/// release is settled. Clamped at zero — the legacy `arrival` path renders without going
/// through [`Presenter::pump`], so confirms can outnumber counted releases.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
///
/// `present_mono_ns` is SurfaceFlinger's own render timestamp, raw on `CLOCK_MONOTONIC` —
/// deliberately not the realtime-rebased instant the latency stats use. Cadence is a
/// statistic about *spacing*, and a realtime clock step (NTP) would forge a hitch that never
/// happened. Garbage stamps need no special handling here: an implausible one lands in the
/// stall or disordered counters rather than the judder ratio.
pub(super) fn note_latch(&self, latch_us: Option<u64>, present_mono_ns: i64) {
self.confirms.store(true, Ordering::Relaxed);
let period_ns = self.panel_period_ns.load(Ordering::Relaxed);
let _ = self
.undisplayed
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
@@ -200,6 +221,7 @@ impl PresentMeter {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.displays += 1;
g.intervals.record(present_mono_ns, period_ns);
if let Some(l) = latch_us {
if g.latch_us.len() < 4096 {
g.latch_us.push(l);
@@ -258,7 +280,16 @@ impl PresentMeter {
}
#[allow(clippy::type_complexity)] // one caller unpacks it in place; a struct would be noise
fn drain(&self) -> (Vec<u64>, u64, Vec<u64>, Vec<u64>, Vec<u64>) {
fn drain(
&self,
) -> (
Vec<u64>,
u64,
Vec<u64>,
Vec<u64>,
Vec<u64>,
Option<punktfunk_core::phase::PresentCadence>,
) {
let mut g = self
.inner
.lock()
@@ -271,6 +302,7 @@ impl PresentMeter {
std::mem::take(&mut g.feed_us),
std::mem::take(&mut g.codec_us),
std::mem::take(&mut g.e2e_us),
g.intervals.take(),
)
}
}
@@ -410,6 +442,11 @@ impl Presenter {
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
// The callback thread scores cadence but cannot see the vsync clock — republish the grid
// it quantises against. Relaxed: a period change is rare and one stale sample is noise.
if let Some(c) = clock {
meter.set_panel_period(c.panel_period_ns().max(c.period_ns()));
}
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
if let Some(f) = &self.inflight {
if now_mono_ns >= f.reopen_at_ns {
@@ -547,7 +584,11 @@ impl Presenter {
/// `pace` (decoded→release) / `latch` (release→displayed) /
/// `feed`+`codec` (the decode stage split: received→queued hand-off/slot wait + the
/// codec-pure queued→decoded time) / `e2e` (capture→decoded, skew-corrected — the wireless
/// A/B headline) / `vsync` (the measured panel period).
/// A/B headline) / `vsync` (the measured panel period) /
/// `judder` (‰ of present intervals off the modal spacing — the cadence statistic, and the
/// only number here that can see a pacing defect) / `mode` (the modal spacing in refreshes:
/// 1 at panel rate, 2 for 60-on-120) / `stalls` + `disorder` (excluded from the ratio; see
/// [`punktfunk_core::phase::PresentIntervals`]).
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
@@ -561,7 +602,7 @@ impl Presenter {
return None;
}
self.last_flush = Instant::now();
let (latch, displays, feed, codec, e2e) = meter.drain();
let (latch, displays, feed, codec, e2e, cadence) = meter.drain();
if self.released == 0 && displays == 0 {
return None; // idle stream — nothing worth a line
}
@@ -584,7 +625,8 @@ impl Presenter {
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} \
feedMs p50={:.2} max={:.2} codecMs p50={:.2} max={:.2} \
e2eMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
vsyncMs={:.2} panelMs={:.2}",
vsyncMs={:.2} panelMs={:.2} \
judder={}permille mode={}vsync stalls={} disorder={}",
self.released,
displays,
self.paced_drops,
@@ -607,6 +649,10 @@ impl Presenter {
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ns as f64 / 1e6,
cadence.map(|c| c.judder_permille).unwrap_or(0),
cadence.map(|c| c.mode_units).unwrap_or(0),
cadence.map(|c| c.stalls).unwrap_or(0),
cadence.map(|c| c.disordered).unwrap_or(0),
);
self.released = 0;
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past