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
+285
View File
@@ -129,6 +129,140 @@ pub fn circular_latch(samples_us: &[u64], period_ns: i64) -> Option<(u64, u16)>
Some((mean_ns, (r * 1000.0) as u16))
}
/// Largest present spacing still treated as cadence. Anything wider is a stall (a stream pause,
/// an occluded window, a codec rebuild) and is counted separately: folding a 5-second gap in as
/// "one irregular interval" would be true but useless, and folding it in as several would make a
/// single hitch dominate the window.
const CADENCE_MAX_UNITS: usize = 8;
/// Minimum intervals before a cadence summary means anything — same evidence bar as
/// [`circular_latch`]. At any sane frame rate a 1 s window clears this many times over; it is
/// there so a window truncated by a reanchor does not publish a judder figure off three samples.
const CADENCE_MIN_SAMPLES: u32 = 8;
/// One window's present-cadence summary (see [`PresentIntervals`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PresentCadence {
/// The most common spacing, in whole panel refreshes. This is the stream's cadence ratio:
/// 1 when stream rate matches the panel, 2 for 60-on-120, 4 for 30-on-120.
pub mode_units: u8,
/// Fraction of intervals that were NOT the mode, in ‰ (same unit as the phase coherence).
/// **This is the judder number.** 0 = a perfectly regular cadence at any ratio.
pub judder_permille: u16,
/// Intervals folded into the histogram (excludes stalls and disordered samples).
pub samples: u32,
/// Spacings wider than [`CADENCE_MAX_UNITS`] — stalls, not judder. Reported so a window that
/// looks smooth *because the stream was paused* cannot be mistaken for a good one.
pub stalls: u32,
/// Present instants that did not advance (duplicate or out-of-order callbacks). A platform
/// bookkeeping signal, not a display defect — kept out of the judder ratio deliberately.
pub disordered: u32,
}
/// Present-interval distribution in whole panel refreshes — the cadence (judder) statistic.
///
/// Every other 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
/// that alternates 1 and 3, and looks perfect. Quantising the spacing between consecutive
/// on-glass instants onto the panel grid measures the thing the eye actually reacts to.
///
/// Scale-free by construction: it needs no reference clock, and the *mode* absorbs the cadence
/// ratio, so 60-on-120 and 120-on-120 are both "smooth = one tall bucket" and comparable to each
/// other. That is what makes it usable as one ruler across clients, refresh rates and stream
/// rates — including for a feature-on/feature-off A/B on the same device.
///
/// Feed it the **measured on-glass instant**, never the instant a present was *requested*:
/// requested times would measure our own intent and report a perfect cadence no matter what the
/// display did with it. Every client has the real one (Android's `OnFrameRendered` system time,
/// the desktop's `VK_KHR_present_wait` stamp, Apple's drawable `presentedTime`).
///
/// Pure state and arithmetic — no clock, no allocation. The caller owns the window: drain with
/// [`take`](Self::take) on its own 1 s tumbling boundary, per `design/stats-unification.md`.
#[derive(Debug, Clone, Default)]
pub struct PresentIntervals {
last_present_ns: i64,
/// Counts indexed by whole refreshes, `0..=CADENCE_MAX_UNITS`.
hist: [u32; CADENCE_MAX_UNITS + 1],
samples: u32,
stalls: u32,
disordered: u32,
}
impl PresentIntervals {
pub fn new() -> PresentIntervals {
PresentIntervals::default()
}
/// Forget the previous instant without discarding the window's counts. Call on any
/// discontinuity where the next present is not a continuation of this cadence (reanchor,
/// codec rebuild, surface recreate) so the gap across it is not scored as a stall.
pub fn split(&mut self) {
self.last_present_ns = 0;
}
/// Fold one on-glass instant. `period_ns` is the learned panel period
/// ([`PanelGrid::period_ns`]); a non-positive one means the grid is not known yet and the
/// sample is held as the new predecessor without being scored.
pub fn record(&mut self, present_ns: i64, period_ns: i64) {
let prev = std::mem::replace(&mut self.last_present_ns, present_ns);
if prev <= 0 || period_ns <= 0 {
return; // first sample of a run, or no grid to quantise against
}
let spacing = present_ns - prev;
if spacing <= 0 {
// A repeated or out-of-order callback. Keep the LATER instant as the predecessor so
// one disordered delivery cannot corrupt every following spacing.
self.disordered += 1;
self.last_present_ns = prev.max(present_ns);
return;
}
// Round to the nearest whole refresh: a present is "on the grid" if it is closer to this
// vblank than the next, which is exactly what the display did with it.
let units = (spacing * 2 + period_ns) / (period_ns * 2);
if units as usize > CADENCE_MAX_UNITS {
self.stalls += 1;
return;
}
self.hist[units as usize] += 1;
self.samples += 1;
}
/// This window's summary, or `None` under [`CADENCE_MIN_SAMPLES`].
pub fn summary(&self) -> Option<PresentCadence> {
if self.samples < CADENCE_MIN_SAMPLES {
return None;
}
let (mode_units, mode_count) = self
.hist
.iter()
.enumerate()
.max_by_key(|&(_, c)| *c)
.map(|(i, &c)| (i as u8, c))?;
Some(PresentCadence {
mode_units,
judder_permille: (u64::from(self.samples - mode_count) * 1000 / u64::from(self.samples))
as u16,
samples: self.samples,
stalls: self.stalls,
disordered: self.disordered,
})
}
/// Drain the window: the summary (if it clears the evidence bar) and a reset of the counts.
/// The previous instant SURVIVES the drain — the cadence continues across a window boundary,
/// and dropping it would manufacture one unscored interval per window.
pub fn take(&mut self) -> Option<PresentCadence> {
let out = self.summary();
self.hist = [0; CADENCE_MAX_UNITS + 1];
self.samples = 0;
self.stalls = 0;
self.disordered = 0;
out
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -294,3 +428,154 @@ mod panel_grid_tests {
assert_eq!(g.period_ns(), P120, "and the real grid wins it back");
}
}
#[cfg(test)]
mod cadence_tests {
use super::*;
const P: i64 = 8_333_333; // 120 Hz in ns
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
fn cadence(spacings: &[i64], n: usize) -> PresentIntervals {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for i in 0..n {
t += spacings[i % spacings.len()];
pi.record(t, P);
}
pi
}
#[test]
fn a_regular_cadence_has_no_judder() {
let s = cadence(&[P], 60).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
assert_eq!(s.samples, 60);
}
/// The property that makes this one ruler across rates: a stream at half the panel rate is
/// SMOOTH, not judder — the mode absorbs the cadence ratio.
fn ratio_is_absorbed_not_penalised(mult: i64, expect_units: u8) {
let s = cadence(&[P * mult], 40).summary().unwrap();
assert_eq!((s.mode_units, s.judder_permille), (expect_units, 0));
}
#[test]
fn sixty_on_onetwenty_reads_smooth() {
ratio_is_absorbed_not_penalised(2, 2); // 60 fps on a 120 Hz panel
ratio_is_absorbed_not_penalised(4, 4); // 30 fps on a 120 Hz panel
}
/// D3's signature: the same mean spacing as `sixty_on_onetwenty_reads_smooth`, delivered as
/// alternating 1 and 3 refreshes. Identical average frame rate, identical latency
/// percentiles — and this is the one that looks broken.
#[test]
fn the_sawtooth_that_latency_stats_cannot_see() {
let s = cadence(&[P, P * 3], 40).summary().unwrap();
assert_eq!(s.judder_permille, 500);
assert!(matches!(s.mode_units, 1 | 3));
}
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
/// Only a spacing that crosses the half-refresh boundary changes which vblank was used.
#[test]
fn jitter_inside_a_refresh_is_not_judder() {
let s = cadence(&[P + P * 2 / 5, P - P * 2 / 5], 40)
.summary()
.unwrap();
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
}
#[test]
fn a_stall_is_counted_apart_from_judder() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..20 {
t += P;
pi.record(t, P);
}
t += P * 400; // a pause, not a pacing defect
pi.record(t, P);
let s = pi.summary().unwrap();
assert_eq!((s.judder_permille, s.stalls, s.samples), (0, 1, 20));
}
#[test]
fn out_of_order_callbacks_do_not_corrupt_the_run() {
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
pi.record(t, P);
for _ in 0..10 {
t += P;
pi.record(t, P);
}
pi.record(t - P * 3, P); // a late/duplicate delivery
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(s.disordered, 1);
assert_eq!(
s.judder_permille, 0,
"keeping the later instant means the following spacings stay on the grid"
);
}
#[test]
fn an_unknown_grid_scores_nothing() {
let s = cadence(&[P], 60);
let mut pi = PresentIntervals::new();
let mut t = 1_000_000_000i64;
for _ in 0..60 {
t += P;
pi.record(t, 0); // PanelGrid has not learned a period yet
}
assert!(pi.summary().is_none());
assert!(s.summary().is_some(), "control");
}
#[test]
fn a_short_window_publishes_nothing() {
assert!(cadence(&[P], 5).summary().is_none());
}
/// The cadence continues across a window boundary — dropping the predecessor on drain would
/// silently discard one interval per window, every window.
#[test]
fn take_resets_the_counts_but_not_the_cadence() {
let mut pi = cadence(&[P], 20);
assert!(pi.take().is_some());
assert!(pi.summary().is_none(), "counts cleared");
let mut t = 1_000_000_000 + P * 20;
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
s.samples, 10,
"the first post-drain present scored against the pre-drain one"
);
}
#[test]
fn split_forgets_the_predecessor() {
let mut pi = cadence(&[P], 20);
pi.take();
pi.split();
let mut t = 5_000_000_000i64; // a reanchor: the gap across it is meaningless
for _ in 0..10 {
t += P;
pi.record(t, P);
}
let s = pi.summary().unwrap();
assert_eq!(
(s.samples, s.stalls),
(9, 0),
"the gap was not scored at all"
);
}
}