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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user