Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c25ff1a80 | ||
|
|
1099c94ca3 | ||
|
|
e5ba78ea66 | ||
|
|
d0d2399476 | ||
|
|
53278c6f5f | ||
|
|
fc5b6296e3 |
@@ -28,6 +28,76 @@ use super::{
|
||||
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
|
||||
};
|
||||
|
||||
/// How long a flagged AU waits for the host's 0xCF timing before being logged unattributed.
|
||||
/// Comfortably longer than the round the host takes to report, short enough that the line still
|
||||
/// lands near the event in the log.
|
||||
const SPIKE_ATTRIBUTE_WAIT_NS: i64 = 500_000_000;
|
||||
|
||||
/// Bound on AUs awaiting attribution — a stream that spikes constantly must not grow this.
|
||||
const SPIKE_WATCH_CAP: usize = 64;
|
||||
|
||||
/// One receipt-latency excursion, held until the host's own timing for the same AU arrives.
|
||||
///
|
||||
/// The point of this record is attribution. A window maximum cannot say WHERE a 90 ms frame
|
||||
/// spent its time — the per-stage maxima in a window are generally different frames — so the
|
||||
/// stage split has to be captured per AU, for the offending AU.
|
||||
struct SpikeWatch {
|
||||
pts_ns: u64,
|
||||
/// Capture → reassembled, skew-corrected: the host pipeline plus the wire.
|
||||
hostnet_us: u64,
|
||||
au_len: usize,
|
||||
/// Since the previous AU was reassembled — separates "this frame was slow" from "the
|
||||
/// stream stalled and then burst", which look identical in a latency percentile.
|
||||
gap_us: u64,
|
||||
idx: u32,
|
||||
seen_mono: i64,
|
||||
}
|
||||
|
||||
impl SpikeWatch {
|
||||
/// `host_us` = the host's own capture→submit time for this AU (0xCF), or `None` when the
|
||||
/// host never reported it. `net` is the remainder: wire + reassembly.
|
||||
fn log(&self, host_us: Option<u64>) {
|
||||
log::warn!(
|
||||
target: "pf.spike",
|
||||
"idx={} hostnetMs={:.1} hostMs={} netMs={} gapMs={:.1} bytes={}",
|
||||
self.idx,
|
||||
self.hostnet_us as f64 / 1000.0,
|
||||
host_us.map_or("?".into(), |h| format!("{:.1}", h as f64 / 1000.0)),
|
||||
host_us.map_or("?".into(), |h| format!(
|
||||
"{:.1}",
|
||||
self.hostnet_us.saturating_sub(h) as f64 / 1000.0
|
||||
)),
|
||||
self.gap_us as f64 / 1000.0,
|
||||
self.au_len,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `debug.punktfunk.spike_ms` (1..=2000): log a per-AU stage breakdown for every receipt latency
|
||||
/// at or above this. Unset = off, so the instrument costs nothing until someone asks for it.
|
||||
fn spike_threshold_us() -> Option<u64> {
|
||||
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
|
||||
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
|
||||
let n = unsafe {
|
||||
libc::__system_property_get(
|
||||
c"debug.punktfunk.spike_ms".as_ptr(),
|
||||
buf.as_mut_ptr().cast(),
|
||||
)
|
||||
};
|
||||
if n > 0 {
|
||||
if let Ok(ms) = std::str::from_utf8(&buf[..n as usize])
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
{
|
||||
if (1..=2_000).contains(&ms) {
|
||||
return Some(ms * 1_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
|
||||
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat, and the
|
||||
/// wall-clock instant the output callback fired — the spec's `decoded` point ("decoder output
|
||||
@@ -584,6 +654,14 @@ fn feeder_loop(
|
||||
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
|
||||
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
|
||||
let mut last_phase_ack: Option<i32> = None;
|
||||
// Latency-excursion watch (`debug.punktfunk.spike_ms`). Read once per stream: this is a
|
||||
// field instrument, armed by setprop + reconnect, and off by default.
|
||||
let spike_thresh_us = spike_threshold_us();
|
||||
if let Some(t) = spike_thresh_us {
|
||||
log::info!("decode: spike watch armed at {} ms (pf.spike)", t / 1000);
|
||||
}
|
||||
let mut spike_watch: VecDeque<SpikeWatch> = VecDeque::new();
|
||||
let mut last_recv_mono: Option<i64> = None;
|
||||
while !shutdown.load(Ordering::Relaxed) {
|
||||
match client.next_frame(Duration::from_millis(5)) {
|
||||
Ok(frame) => {
|
||||
@@ -599,6 +677,44 @@ fn feeder_loop(
|
||||
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
|
||||
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
|
||||
// HUD-only `received` point + host/network split stay gated on the overlay.
|
||||
// The receipt latency is needed by the always-on spike watch below, so it is
|
||||
// computed for every complete AU rather than only when the HUD is up.
|
||||
let spike_lat_us = if frame.complete {
|
||||
let received_ns = if frame.received_ns > 0 {
|
||||
frame.received_ns as i128
|
||||
} else {
|
||||
now_realtime_ns()
|
||||
};
|
||||
let off = clock_offset.load(Ordering::Relaxed) as i128;
|
||||
let lat_ns = received_ns + off - frame.pts_ns as i128;
|
||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let (Some(thresh_us), Some(lat_us)) = (spike_thresh_us, spike_lat_us) {
|
||||
let now_mono = now_monotonic_ns();
|
||||
let gap_us = last_recv_mono
|
||||
.map(|p| ((now_mono - p) / 1000) as u64)
|
||||
.unwrap_or(0);
|
||||
last_recv_mono = Some(now_mono);
|
||||
if lat_us >= thresh_us {
|
||||
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
|
||||
// Held for the host's 0xCF timing for this pts, which is what splits the
|
||||
// excursion into host pipeline vs wire — the whole point. Emitted
|
||||
// unattributed if that never arrives (see the drain below).
|
||||
spike_watch.push_back(SpikeWatch {
|
||||
pts_ns: frame.pts_ns,
|
||||
hostnet_us: lat_us,
|
||||
au_len,
|
||||
gap_us,
|
||||
idx: frame.frame_index,
|
||||
seen_mono: now_mono,
|
||||
});
|
||||
if spike_watch.len() > SPIKE_WATCH_CAP {
|
||||
spike_watch.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stats.enabled() || measure_decode) && frame.complete {
|
||||
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
|
||||
// here would fold the hand-off queue wait into the network latency figure
|
||||
@@ -632,28 +748,47 @@ fn feeder_loop(
|
||||
pending_split.pop_front();
|
||||
}
|
||||
}
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
// Phase-lock closed-loop readout: the host's applied hold rides the
|
||||
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
|
||||
// once a second). None = a host without the tail (pre-phase-lock).
|
||||
if t.applied_phase_ns != last_phase_ack {
|
||||
log::info!(
|
||||
target: "pf.phase",
|
||||
"host applied_phase={:?}us",
|
||||
t.applied_phase_ns.map(|n| n / 1000)
|
||||
);
|
||||
last_phase_ack = t.applied_phase_ns;
|
||||
}
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
|
||||
{
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The 0xCF drain is OUTSIDE the HUD gate: it carries the host's own pipeline time
|
||||
// per AU, which is what attributes a latency excursion to the host or the wire,
|
||||
// and the phase-lock ack, which had no business being invisible with the HUD down.
|
||||
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
|
||||
// Phase-lock closed-loop readout: the host's applied hold rides the
|
||||
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
|
||||
// once a second). None = a host without the tail (pre-phase-lock).
|
||||
if t.applied_phase_ns != last_phase_ack {
|
||||
log::info!(
|
||||
target: "pf.phase",
|
||||
"host applied_phase={:?}us",
|
||||
t.applied_phase_ns.map(|n| n / 1000)
|
||||
);
|
||||
last_phase_ack = t.applied_phase_ns;
|
||||
}
|
||||
if stats.enabled() {
|
||||
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
|
||||
let (_, hostnet_us) = pending_split.remove(i).unwrap();
|
||||
stats.note_host_split(
|
||||
t.host_us as u64,
|
||||
hostnet_us.saturating_sub(t.host_us as u64),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(i) = spike_watch.iter().position(|w| w.pts_ns == t.pts_ns) {
|
||||
let w = spike_watch.remove(i).unwrap();
|
||||
w.log(Some(t.host_us as u64));
|
||||
}
|
||||
}
|
||||
// Anything the host never reported on still gets logged, unattributed, rather
|
||||
// than silently dropped — an old host has no 0xCF tail at all.
|
||||
let now_mono = now_monotonic_ns();
|
||||
while spike_watch
|
||||
.front()
|
||||
.is_some_and(|w| now_mono - w.seen_mono > SPIKE_ATTRIBUTE_WAIT_NS)
|
||||
{
|
||||
if let Some(w) = spike_watch.pop_front() {
|
||||
w.log(None);
|
||||
}
|
||||
}
|
||||
if ev_tx.send(DecodeEvent::Au(frame, gap)).is_err() {
|
||||
break; // the decode loop is gone
|
||||
|
||||
@@ -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,17 @@ 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>,
|
||||
(u32, u32, u32),
|
||||
Option<punktfunk_core::phase::PresentCadence>,
|
||||
) {
|
||||
let mut g = self
|
||||
.inner
|
||||
.lock()
|
||||
@@ -271,6 +303,8 @@ 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.pending(),
|
||||
g.intervals.take(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -410,6 +444,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 +586,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 +604,7 @@ impl Presenter {
|
||||
return None;
|
||||
}
|
||||
self.last_flush = Instant::now();
|
||||
let (latch, displays, feed, codec, e2e) = meter.drain();
|
||||
let (latch, displays, feed, codec, e2e, cad_raw, cadence) = meter.drain();
|
||||
if self.released == 0 && displays == 0 {
|
||||
return None; // idle stream — nothing worth a line
|
||||
}
|
||||
@@ -584,7 +627,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 cadN={} stalls={} disorder={} cadPeriodMs={:.2}",
|
||||
self.released,
|
||||
displays,
|
||||
self.paced_drops,
|
||||
@@ -607,6 +651,12 @@ 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),
|
||||
cad_raw.0,
|
||||
cad_raw.1,
|
||||
cad_raw.2,
|
||||
meter.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
|
||||
);
|
||||
self.released = 0;
|
||||
// Margin adaptation, off the MEASURED latch. A release targets the first grid point past
|
||||
|
||||
@@ -474,6 +474,7 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
|
||||
// The link's own pipeline depth, measured: how far ahead of glass this vend runs.
|
||||
let leadS = update.targetPresentationTimestamp - CACurrentMediaTime()
|
||||
stats?.vendLead(ms: leadS * 1000)
|
||||
stats?.notePanelTarget(mediaTime: update.targetPresentationTimestamp)
|
||||
// Same measurement into the floor meter (as a LatencyMeter sample: end = now, start =
|
||||
// now − lead) — its 1 s p50 is the OS present floor SessionModel shaves off.
|
||||
if leadS > 0, let floorMeter {
|
||||
@@ -562,6 +563,112 @@ final class PresentGate: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// One window's present-cadence summary (see `PresentIntervals`).
|
||||
struct PresentCadence: Equatable {
|
||||
/// The most common spacing, in whole panel refreshes: 1 at panel rate, 2 for 60-on-120.
|
||||
let modeUnits: Int
|
||||
/// Fraction of intervals that were NOT the mode, in ‰. **The judder number.**
|
||||
let judderPermille: Int
|
||||
let samples: Int
|
||||
/// Spacings wider than `maxUnits` — stalls, not judder.
|
||||
let stalls: Int
|
||||
/// Present instants that did not advance (duplicate/out-of-order callbacks).
|
||||
let disordered: Int
|
||||
}
|
||||
|
||||
/// Present-interval distribution in whole panel refreshes — the cadence (judder) statistic.
|
||||
///
|
||||
/// A **verbatim port of `punktfunk_core::phase::PresentIntervals`**, in the same spirit as
|
||||
/// `PhaseReporter.circularLatch` above: the three clients must publish the SAME statistic, so the
|
||||
/// numbers can be compared across platforms and so a feature-on/off A/B uses one ruler. Any change
|
||||
/// here belongs in the Rust original first — including the tie-break, which is spelled out on both
|
||||
/// sides precisely because the two languages' `max` disagree about which equal element wins.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// Fed the MEASURED on-glass instant, never the requested present time — the latter would measure
|
||||
/// our own intent and report a perfect cadence no matter what the display did.
|
||||
struct PresentIntervals {
|
||||
/// Largest spacing still treated as cadence; wider is a stall, counted apart.
|
||||
private static let maxUnits = 8
|
||||
/// Minimum intervals before a summary means anything (matches `circularLatch`'s bar).
|
||||
private static let minSamples = 8
|
||||
/// A backwards step larger than this is a bogus timestamp, not a reordered delivery, so the
|
||||
/// run re-anchors rather than holding the old instant. Without it, one garbage far-future
|
||||
/// stamp latches the statistic and every later present scores as disordered for the whole
|
||||
/// session — observed on glass on Android, 2026-08-05.
|
||||
private static let reanchorNs: Int64 = 100_000_000
|
||||
|
||||
private var lastPresentNs: Int64 = 0
|
||||
private var hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
|
||||
private var samples = 0
|
||||
private var stalls = 0
|
||||
private var disordered = 0
|
||||
|
||||
/// Forget the previous instant without discarding the window's counts — a discontinuity where
|
||||
/// the next present does not continue this cadence.
|
||||
mutating func split() { lastPresentNs = 0 }
|
||||
|
||||
/// Fold one on-glass instant. A non-positive `periodNs` means the grid is not known yet and
|
||||
/// the sample is held as the new predecessor without being scored.
|
||||
mutating func record(presentNs: Int64, periodNs: Int64) {
|
||||
let prev = lastPresentNs
|
||||
lastPresentNs = presentNs
|
||||
guard prev > 0, periodNs > 0 else { return }
|
||||
let spacing = presentNs - prev
|
||||
if spacing <= 0 {
|
||||
// Hold the LATER instant so one reordered delivery cannot corrupt every following
|
||||
// spacing — but only when the step back is small enough to BE a reordering. Beyond
|
||||
// that the old instant is the bogus one (see `reanchorNs`) and the run re-anchors
|
||||
// onto the new sample, which `lastPresentNs` already holds.
|
||||
disordered += 1
|
||||
if prev - presentNs < PresentIntervals.reanchorNs {
|
||||
lastPresentNs = prev
|
||||
}
|
||||
return
|
||||
}
|
||||
// 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 = Int((spacing * 2 + periodNs) / (periodNs * 2))
|
||||
if units > PresentIntervals.maxUnits {
|
||||
stalls += 1
|
||||
return
|
||||
}
|
||||
hist[units] += 1
|
||||
samples += 1
|
||||
}
|
||||
|
||||
/// This window's summary, or nil under `minSamples`.
|
||||
func summary() -> PresentCadence? {
|
||||
guard samples >= PresentIntervals.minSamples else { return nil }
|
||||
// Ties resolve to the SMALLEST spacing — see the Rust original: `max_by_key` takes the
|
||||
// last maximum and Swift's `max(by:)` the first, so this is written out on both sides.
|
||||
var modeUnits = 0
|
||||
var modeCount = 0
|
||||
for (i, c) in hist.enumerated() where c > modeCount {
|
||||
modeCount = c
|
||||
modeUnits = i
|
||||
}
|
||||
return PresentCadence(
|
||||
modeUnits: modeUnits,
|
||||
judderPermille: (samples - modeCount) * 1000 / samples,
|
||||
samples: samples, stalls: stalls, disordered: disordered)
|
||||
}
|
||||
|
||||
/// Drain the window. The previous instant SURVIVES — the cadence continues across a window
|
||||
/// boundary, and dropping it would manufacture one unscored interval per window.
|
||||
mutating func take() -> PresentCadence? {
|
||||
let out = summary()
|
||||
hist = [Int](repeating: 0, count: PresentIntervals.maxUnits + 1)
|
||||
samples = 0
|
||||
stalls = 0
|
||||
disordered = 0
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/// PUNKTFUNK_PRESENT_DEBUG=1 aggregation: one printed line per second from the render thread with
|
||||
/// the decode rate, render outcomes, the slowest render call (≈ nextDrawable wait) and the deltas
|
||||
/// between system-reported on-glass times (vsync-aligned presents show clean refresh-period
|
||||
@@ -588,6 +695,50 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
/// 120 Hz panel saturates this at ~maximumDrawableCount; stage-3 pegs it at the gate depth).
|
||||
private var inFlight = 0
|
||||
private var maxInFlight = 0
|
||||
/// The cadence (judder) statistic — the only number here that is not a latency, and the only
|
||||
/// one that can see a pacing defect. `glassDeltasMs` above is the same raw material reported
|
||||
/// as a percentile, which cannot distinguish a steady 2-refresh cadence from an alternating
|
||||
/// 1-and-3 one: same mean, same median, one of them visibly broken.
|
||||
private var intervals = PresentIntervals()
|
||||
/// The panel period cadence quantises against: seeded from the display mode and refined from
|
||||
/// the link's own reported period, mirroring `punktfunk_core::phase::PanelGrid`'s seed-then-
|
||||
/// correct design. 0 until known, which simply means cadence is not scored yet.
|
||||
private var panelPeriodNs: Int64 = 0
|
||||
/// Deadline-pacing period learner state (see `notePanelTarget`). Re-armed each window so a
|
||||
/// mode or VRR rate change is tracked both ways rather than latching the first value seen.
|
||||
private var lastTargetS: CFTimeInterval = 0
|
||||
private var minTargetSpacingS: CFTimeInterval = 0
|
||||
/// Whether the verbose per-second line prints. The cadence line always does: a smoothness
|
||||
/// defect must not be invisible until someone thinks to set an env var.
|
||||
private let verbose: Bool
|
||||
|
||||
init(verbose: Bool) { self.verbose = verbose }
|
||||
|
||||
/// Seed or refine the panel period (render/link thread).
|
||||
func setPanelPeriod(ns: Int64) {
|
||||
guard ns > 0 else { return }
|
||||
lock.lock()
|
||||
panelPeriodNs = ns
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Deadline pacing has no reported period, so learn it from the link's own target instants.
|
||||
/// Those tick at the panel rate whether or not WE present, which is what makes the window
|
||||
/// minimum the true period — the same reasoning (and the same guard band) `PhaseReporter`
|
||||
/// uses above. Learning it from on-glass spacings instead would read a 60-on-120 stream as a
|
||||
/// 60 Hz panel and mislabel the cadence mode.
|
||||
func notePanelTarget(mediaTime t: CFTimeInterval) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
defer { lastTargetS = t }
|
||||
guard lastTargetS > 0 else { return }
|
||||
let d = t - lastTargetS
|
||||
guard d > 0.0005, d < 0.1 else { return }
|
||||
if minTargetSpacingS == 0 || d < minTargetSpacingS {
|
||||
minTargetSpacingS = d
|
||||
panelPeriodNs = Int64(d * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
func emptyWake() { lock.lock(); empty += 1; lock.unlock() }
|
||||
|
||||
@@ -624,8 +775,13 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
if lastGlassNs > 0 { glassDeltasMs.append(Double(atNs - lastGlassNs) / 1e6) }
|
||||
lastGlassNs = atNs
|
||||
latchMs.append(Double(atNs - issuedNs) / 1e6)
|
||||
intervals.record(presentNs: atNs, periodNs: panelPeriodNs)
|
||||
} else {
|
||||
// A dropped drawable never reached glass, so it is not a cadence event — but the
|
||||
// NEXT one does not continue the previous interval either. Split rather than let
|
||||
// the gap read as judder.
|
||||
dropped += 1
|
||||
intervals.split()
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
@@ -656,6 +812,9 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
smoothing.overflowDrops, smoothing.underflows, maxRenderMs, inflightMax,
|
||||
gate?.drainForced() ?? 0, p50, dMax, deltas.count, latchP50, latchMax,
|
||||
vendP50, vendMax)
|
||||
let cadence = intervals.take()
|
||||
let verbose = self.verbose
|
||||
minTargetSpacingS = 0 // re-arm the period learner for the next window
|
||||
ok = 0; failed = 0; empty = 0; dropped = 0; gated = 0; noDrawable = 0
|
||||
maxRenderMs = 0
|
||||
maxInFlight = inFlight // the window peak restarts from the live depth
|
||||
@@ -663,6 +822,21 @@ private final class PresentDebugStats: @unchecked Sendable {
|
||||
latchMs.removeAll(keepingCapacity: true)
|
||||
vendLeadMs.removeAll(keepingCapacity: true)
|
||||
lock.unlock()
|
||||
// The cadence line is ALWAYS emitted (when the window had evidence): it is the ruler the
|
||||
// smoothness A/B reads, and it must not depend on an env var the field never sets. The
|
||||
// verbose counters line stays behind its existing lever.
|
||||
if let cadence {
|
||||
let cadenceLine = String(
|
||||
format: "pf-present judderPermille=%d modeVsync=%d n=%d stalls=%d disorder=%d",
|
||||
cadence.judderPermille, cadence.modeUnits, cadence.samples,
|
||||
cadence.stalls, cadence.disordered)
|
||||
presentLog.info("\(cadenceLine, privacy: .public)")
|
||||
if presentDebug {
|
||||
print(cadenceLine)
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
guard verbose else { return }
|
||||
// Console.app first (the on-device readout — see presentLog); stdout only under the env
|
||||
// lever (the CLI client's capture channel).
|
||||
presentLog.info("\(line, privacy: .public)")
|
||||
@@ -746,6 +920,10 @@ public final class Stage2Pipeline {
|
||||
/// mirror the pump's bounded join.
|
||||
private let renderSignal = DispatchSemaphore(value: 0)
|
||||
private let vsyncClock = VsyncClock()
|
||||
/// The per-session present statistics, retained so the clock-bearing threads can republish
|
||||
/// the panel period the cadence statistic quantises against. Assigned once in `start`, read
|
||||
/// from the render/link threads; the object is itself lock-guarded.
|
||||
private var presentStats: PresentDebugStats?
|
||||
private let renderStopped = DispatchSemaphore(value: 0)
|
||||
private var renderJoinable = false
|
||||
/// Deadline pacing's staged CAMetalDisplayLink frame-rate hint (see `FrameRateHint`).
|
||||
@@ -967,7 +1145,14 @@ public final class Stage2Pipeline {
|
||||
// startDeadlinePresenter. The V-Sync policy below doesn't apply there (the link deadline-
|
||||
// times every present). Deadline sessions ALWAYS carry the stats (their pf-present line
|
||||
// streams to Console.app via presentLog — the on-device pacing decomposition).
|
||||
let debugStats = (presentDebug || pacing == .deadline) ? PresentDebugStats() : nil
|
||||
//
|
||||
// The stats object is now built for EVERY session, because the cadence statistic inside
|
||||
// it has to be: a smoothness defect produces no drops and healthy percentiles, so gating
|
||||
// it behind an env var means the one number that could see it is off exactly when it
|
||||
// matters. `verbose` preserves the old behaviour for the wordy counters line.
|
||||
let debugStats: PresentDebugStats? = PresentDebugStats(
|
||||
verbose: presentDebug || pacing == .deadline)
|
||||
presentStats = debugStats
|
||||
if pacing == .deadline {
|
||||
startDeadlinePresenter(debugStats: debugStats)
|
||||
return
|
||||
@@ -1241,6 +1426,9 @@ public final class Stage2Pipeline {
|
||||
/// (their CAMetalDisplayLink's updates are both clock and retry).
|
||||
public func renderTick(targetMediaTime: CFTimeInterval, period: CFTimeInterval) {
|
||||
vsyncClock.set(target: targetMediaTime, period: period)
|
||||
// The link's own reported period is the authoritative grid for the cadence statistic —
|
||||
// it tracks VRR rate changes, which a mode-derived seed cannot.
|
||||
presentStats?.setPanelPeriod(ns: Int64(period * 1_000_000_000))
|
||||
renderSignal.signal()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// Parity tests for the Swift `PresentIntervals` port (Video/Stage2Pipeline.swift) against
|
||||
// `punktfunk_core::phase::PresentIntervals` — the cadence (judder) statistic of
|
||||
// design/presenter-cadence-rework.md WP1.
|
||||
//
|
||||
// These are deliberately the SAME cases and the SAME vectors as the Rust unit tests in
|
||||
// crates/punktfunk-core/src/phase.rs (module `cadence_tests`). WP1's acceptance criterion is that
|
||||
// all three clients emit the same numbers for the same synthetic input, and a hand-written port is
|
||||
// exactly where that quietly stops being true — so the port is pinned here rather than trusted.
|
||||
//
|
||||
// If you change one side, change both, and keep the vectors identical.
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@testable import PunktfunkKit
|
||||
|
||||
final class PresentIntervalsTests: XCTestCase {
|
||||
/// 120 Hz in ns — the Rust tests' `P`.
|
||||
private static let P: Int64 = 8_333_333
|
||||
|
||||
/// Fold `n` presents spaced by `spacings` in rotation, starting at an arbitrary instant.
|
||||
/// Mirrors the Rust helper of the same shape.
|
||||
private func cadence(_ spacings: [Int64], _ n: Int, period: Int64 = P) -> PresentIntervals {
|
||||
var pi = PresentIntervals()
|
||||
var t: Int64 = 1_000_000_000
|
||||
pi.record(presentNs: t, periodNs: period)
|
||||
for i in 0..<n {
|
||||
t += spacings[i % spacings.count]
|
||||
pi.record(presentNs: t, periodNs: period)
|
||||
}
|
||||
return pi
|
||||
}
|
||||
|
||||
func testARegularCadenceHasNoJudder() {
|
||||
let s = cadence([Self.P], 60).summary()
|
||||
XCTAssertEqual(s?.modeUnits, 1)
|
||||
XCTAssertEqual(s?.judderPermille, 0)
|
||||
XCTAssertEqual(s?.samples, 60)
|
||||
}
|
||||
|
||||
/// The property that makes this one ruler across rates: a stream at half (or a quarter of)
|
||||
/// the panel rate is SMOOTH, not judder — the mode absorbs the cadence ratio.
|
||||
func testSixtyOnOneTwentyReadsSmooth() {
|
||||
for (mult, expected) in [(Int64(2), 2), (Int64(4), 4)] {
|
||||
let s = cadence([Self.P * mult], 40).summary()
|
||||
XCTAssertEqual(s?.modeUnits, expected)
|
||||
XCTAssertEqual(s?.judderPermille, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// D3's signature: the same mean spacing as a steady 2, delivered as alternating 1 and 3.
|
||||
/// Identical average frame rate, identical latency percentiles — this is the broken-looking one.
|
||||
///
|
||||
/// Also pins the TIE-BREAK. The histogram is 50/50 here, and Rust's `max_by_key` takes the
|
||||
/// last maximum while Swift's `max(by:)` takes the first, so both sides spell the rule out:
|
||||
/// ties resolve to the smallest spacing.
|
||||
func testTheSawtoothThatLatencyStatsCannotSee() {
|
||||
let s = cadence([Self.P, Self.P * 3], 40).summary()
|
||||
XCTAssertEqual(s?.judderPermille, 500)
|
||||
XCTAssertEqual(s?.modeUnits, 1, "a tied mode resolves to the smallest spacing")
|
||||
}
|
||||
|
||||
/// Sub-refresh jitter is not judder: the display quantises it away, so the metric must too.
|
||||
func testJitterInsideARefreshIsNotJudder() {
|
||||
let s = cadence([Self.P + Self.P * 2 / 5, Self.P - Self.P * 2 / 5], 40).summary()
|
||||
XCTAssertEqual(s?.modeUnits, 1)
|
||||
XCTAssertEqual(s?.judderPermille, 0)
|
||||
}
|
||||
|
||||
func testAStallIsCountedApartFromJudder() {
|
||||
var pi = PresentIntervals()
|
||||
var t: Int64 = 1_000_000_000
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
for _ in 0..<20 {
|
||||
t += Self.P
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
}
|
||||
t += Self.P * 400 // a pause, not a pacing defect
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
let s = pi.summary()
|
||||
XCTAssertEqual(s?.judderPermille, 0)
|
||||
XCTAssertEqual(s?.stalls, 1)
|
||||
XCTAssertEqual(s?.samples, 20)
|
||||
}
|
||||
|
||||
func testOutOfOrderCallbacksDoNotCorruptTheRun() {
|
||||
var pi = PresentIntervals()
|
||||
var t: Int64 = 1_000_000_000
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
for _ in 0..<10 {
|
||||
t += Self.P
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
}
|
||||
pi.record(presentNs: t - Self.P * 3, periodNs: Self.P) // a late/duplicate delivery
|
||||
for _ in 0..<10 {
|
||||
t += Self.P
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
}
|
||||
let s = pi.summary()
|
||||
XCTAssertEqual(s?.disordered, 1)
|
||||
XCTAssertEqual(
|
||||
s?.judderPermille, 0,
|
||||
"keeping the later instant means the following spacings stay on the grid")
|
||||
}
|
||||
|
||||
/// The on-glass failure of 2026-08-05, pinned on both sides. A render callback can deliver a
|
||||
/// garbage far-future timestamp on a session's first frames; holding "the later instant"
|
||||
/// unconditionally latched onto it and scored EVERY subsequent present as disordered for the
|
||||
/// whole session. One bad sample must cost one sample.
|
||||
func testAGarbageFarFutureStampDoesNotWedgeTheRun() {
|
||||
var pi = PresentIntervals()
|
||||
var t: Int64 = 1_000_000_000
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
pi.record(presentNs: t + 60 * 60 * 1_000_000_000, periodNs: Self.P)
|
||||
for _ in 0..<20 {
|
||||
t += Self.P
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
}
|
||||
let s = pi.summary()
|
||||
XCTAssertEqual(s?.disordered, 1, "the garbage stamp cost exactly one sample")
|
||||
XCTAssertEqual(s?.modeUnits, 1)
|
||||
XCTAssertEqual(s?.judderPermille, 0)
|
||||
XCTAssertEqual(s?.samples, 19, "every present after the re-anchor scored")
|
||||
}
|
||||
|
||||
func testAnUnknownGridScoresNothing() {
|
||||
var pi = PresentIntervals()
|
||||
var t: Int64 = 1_000_000_000
|
||||
for _ in 0..<60 {
|
||||
t += Self.P
|
||||
pi.record(presentNs: t, periodNs: 0) // no learned period yet
|
||||
}
|
||||
XCTAssertNil(pi.summary())
|
||||
XCTAssertNotNil(cadence([Self.P], 60).summary(), "control")
|
||||
}
|
||||
|
||||
func testAShortWindowPublishesNothing() {
|
||||
XCTAssertNil(cadence([Self.P], 5).summary())
|
||||
}
|
||||
|
||||
/// The cadence continues across a window boundary — dropping the predecessor on drain would
|
||||
/// silently discard one interval per window, every window.
|
||||
func testTakeResetsTheCountsButNotTheCadence() {
|
||||
var pi = cadence([Self.P], 20)
|
||||
XCTAssertNotNil(pi.take())
|
||||
XCTAssertNil(pi.summary(), "counts cleared")
|
||||
var t: Int64 = 1_000_000_000 + Self.P * 20
|
||||
for _ in 0..<10 {
|
||||
t += Self.P
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
}
|
||||
XCTAssertEqual(
|
||||
pi.summary()?.samples, 10,
|
||||
"the first post-drain present scored against the pre-drain one")
|
||||
}
|
||||
|
||||
func testSplitForgetsThePredecessor() {
|
||||
var pi = cadence([Self.P], 20)
|
||||
_ = pi.take()
|
||||
pi.split()
|
||||
var t: Int64 = 5_000_000_000 // a discontinuity: the gap across it is meaningless
|
||||
for _ in 0..<10 {
|
||||
t += Self.P
|
||||
pi.record(presentNs: t, periodNs: Self.P)
|
||||
}
|
||||
let s = pi.summary()
|
||||
XCTAssertEqual(s?.samples, 9)
|
||||
XCTAssertEqual(s?.stalls, 0, "the gap was not scored at all")
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@ const STALE_REOPEN_NS: u64 = 100_000_000;
|
||||
pub(crate) const MARGIN_STEP_NS: u64 = 500_000;
|
||||
pub(crate) const MARGIN_MAX_NS: u64 = 2_500_000;
|
||||
|
||||
/// Judder (‰ of present intervals off the modal spacing) that on its own justifies a 1 Hz
|
||||
/// presenter line. A cadence defect produces no drops, no gate holds and healthy latency
|
||||
/// percentiles, so it would otherwise stay silent until someone set the debug env var.
|
||||
/// Occasional single-frame slips are normal; a twentieth of a window is not.
|
||||
pub(crate) const JUDDER_LOG_PERMILLE: u16 = 50;
|
||||
|
||||
/// The decoded-frame store between the wake channel and the present call.
|
||||
///
|
||||
/// `capacity == 0` = newest-wins (latency intent): `submit` replaces, `take` clears.
|
||||
@@ -183,6 +189,15 @@ pub(crate) struct LatchClock {
|
||||
pending_count: u32,
|
||||
grid: punktfunk_core::phase::PanelGrid,
|
||||
fallback_period_ns: u64,
|
||||
/// The cadence (judder) statistic — the only stat we publish that is not a latency,
|
||||
/// and the only one that can see a pacing defect. Lives here because this is where
|
||||
/// the on-glass stamps and the learned grid it quantises against already meet.
|
||||
///
|
||||
/// ⚠ These stamps are `CLOCK_REALTIME` (the module's domain), so a wall-clock step
|
||||
/// would forge one hitch that never happened. It lands in the stall/disordered
|
||||
/// counters rather than the judder ratio, which is why that split is worth having.
|
||||
/// Android feeds the metric a raw monotonic stamp and has no such exposure.
|
||||
intervals: punktfunk_core::phase::PresentIntervals,
|
||||
}
|
||||
|
||||
/// Spacings per handoff to [`punktfunk_core::phase::PanelGrid`]. Small enough that a real
|
||||
@@ -198,14 +213,29 @@ impl LatchClock {
|
||||
pending_count: 0,
|
||||
grid: punktfunk_core::phase::PanelGrid::seeded(refresh_hz as i32),
|
||||
fallback_period_ns: 1_000_000_000 / u64::from(refresh_hz.max(1)),
|
||||
intervals: punktfunk_core::phase::PresentIntervals::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the window's cadence summary — the 1 Hz stat boundary, beside the store and
|
||||
/// gate counters.
|
||||
pub(crate) fn take_cadence(&mut self) -> Option<punktfunk_core::phase::PresentCadence> {
|
||||
self.intervals.take()
|
||||
}
|
||||
|
||||
/// Fold on-glass stamps (ascending). Spacings are measured against the previous
|
||||
/// stamp whatever the batching, so the loop's one-sample-per-pass drain still feeds
|
||||
/// the learner.
|
||||
pub(crate) fn note_batch(&mut self, stamps: &[u64]) {
|
||||
// Seeded-or-learned, so cadence is scored from the first window rather than only
|
||||
// once the learner has converged. Held for the batch: a mid-batch period change
|
||||
// would requantise a handful of samples for no benefit.
|
||||
let period_ns = self.period_ns() as i64;
|
||||
for &s in stamps {
|
||||
// Cadence sees EVERY stamp, including the sub-millisecond pairs the grid
|
||||
// learner skips below: two presents inside one refresh is not a grid step,
|
||||
// but it is very much a cadence event (it scores as a zero-refresh interval).
|
||||
self.intervals.record(s as i64, period_ns);
|
||||
if self.last_ns != 0 && s > self.last_ns {
|
||||
let d = s - self.last_ns;
|
||||
// < 1 ms apart = a queued pair, not a grid step.
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
use crate::input::{Capture, FingerPhase};
|
||||
use crate::overlay::{FrameCtx, Overlay, OverlayAction, OverlayFrame, SessionPhase};
|
||||
use crate::present_pace::{
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, MARGIN_MAX_NS, MARGIN_STEP_NS,
|
||||
Cadence, CadenceProbe, FrameStore, LatchClock, PresentGate, JUDDER_LOG_PERMILLE, MARGIN_MAX_NS,
|
||||
MARGIN_STEP_NS,
|
||||
};
|
||||
use crate::touch::Abs;
|
||||
use crate::vk::{FrameInput, Presenter};
|
||||
@@ -1821,6 +1822,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// a second `take_counters` would read zeros.
|
||||
let (replaced, q_drop, q_dry) = st.store.take_counters();
|
||||
let (gated, forced) = st.gate.take_counters();
|
||||
let cadence = st.clock.take_cadence();
|
||||
st.presented = PresentedWindow {
|
||||
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
|
||||
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
|
||||
@@ -1834,6 +1836,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
q_dry,
|
||||
gated,
|
||||
forced,
|
||||
judder_permille: cadence.map(|c| c.judder_permille).unwrap_or(0),
|
||||
cadence_mode: cadence.map(|c| c.mode_units).unwrap_or(0),
|
||||
};
|
||||
st.win_e2e_us.clear();
|
||||
st.win_disp_us.clear();
|
||||
@@ -1855,7 +1859,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// The 1 Hz presenter line (the Apple `pf-present` analogue): emitted
|
||||
// when anything moved, or always under PUNKTFUNK_PRESENT_DEBUG=1 —
|
||||
// the field-triage instrument for the intent engine.
|
||||
if pacing_active && (present_debug || q_drop + q_dry + gated + forced > 0) {
|
||||
// Judder joins the "something moved" triggers deliberately: a cadence
|
||||
// defect shows NO drops, NO gate holds and healthy percentiles, so
|
||||
// without this a stream can judder visibly and never emit a line.
|
||||
if pacing_active
|
||||
&& (present_debug
|
||||
|| q_drop + q_dry + gated + forced > 0
|
||||
|| st.presented.judder_permille >= JUDDER_LOG_PERMILLE)
|
||||
{
|
||||
tracing::info!(
|
||||
smoothing = st.presented.smoothing,
|
||||
mode = st.presented.mode,
|
||||
@@ -1871,6 +1882,8 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
latch_ms = st.presented.latch_ms,
|
||||
period_us = st.clock.period_ns() / 1000,
|
||||
margin_us = st.margin_ns / 1000,
|
||||
judder_permille = st.presented.judder_permille,
|
||||
cadence_mode = st.presented.cadence_mode,
|
||||
"presenter window"
|
||||
);
|
||||
}
|
||||
@@ -2368,6 +2381,14 @@ struct PresentedWindow {
|
||||
q_dry: u32,
|
||||
gated: u32,
|
||||
forced: u32,
|
||||
/// The cadence (judder) statistic — the fraction of present intervals (‰) that missed
|
||||
/// the modal spacing, and that modal spacing in whole refreshes. Every other number
|
||||
/// here is a latency and none of them can see a pacing defect: alternating 1 and 3
|
||||
/// refreshes has the same mean rate as a steady 2, better latency percentiles, and
|
||||
/// looks broken. `mode 0` = not enough evidence this window.
|
||||
/// See [`punktfunk_core::phase::PresentIntervals`].
|
||||
judder_permille: u16,
|
||||
cadence_mode: u8,
|
||||
}
|
||||
|
||||
/// The capture hints (`ui_stream` parity — the words the user reads while released).
|
||||
|
||||
@@ -129,6 +129,167 @@ 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;
|
||||
|
||||
/// A backwards step larger than this is not a reordered delivery, it is a bogus timestamp, and
|
||||
/// the run re-anchors onto the new instant instead of holding the old one. Android's render
|
||||
/// callback is documented to carry a garbage far-future stamp on a session's first frames;
|
||||
/// without this bound, holding "the later instant" latches onto that stamp and every subsequent
|
||||
/// present scores as disordered for the rest of the session (observed on glass, 2026-08-05).
|
||||
const CADENCE_REANCHOR_NS: i64 = 100_000_000;
|
||||
|
||||
/// 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. Hold the LATER instant so one reordered
|
||||
// delivery cannot corrupt every following spacing — but only when the step back is
|
||||
// small enough to BE a reordering. Beyond that the old instant is the bogus one
|
||||
// (see [`CADENCE_REANCHOR_NS`]) and the run re-anchors onto the new sample, which
|
||||
// `last_present_ns` already holds.
|
||||
self.disordered += 1;
|
||||
if prev - present_ns < CADENCE_REANCHOR_NS {
|
||||
self.last_present_ns = prev;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/// The window's raw counts `(samples, stalls, disordered)`, whatever the evidence bar.
|
||||
///
|
||||
/// [`summary`](Self::summary) returning `None` is otherwise indistinguishable from a window
|
||||
/// of perfectly smooth zeros in a log line, which makes "no cadence is being scored at all"
|
||||
/// invisible — the exact failure this exists to diagnose.
|
||||
pub fn pending(&self) -> (u32, u32, u32) {
|
||||
(self.samples, self.stalls, self.disordered)
|
||||
}
|
||||
|
||||
/// This window's summary, or `None` under [`CADENCE_MIN_SAMPLES`].
|
||||
pub fn summary(&self) -> Option<PresentCadence> {
|
||||
if self.samples < CADENCE_MIN_SAMPLES {
|
||||
return None;
|
||||
}
|
||||
// Ties resolve to the SMALLEST spacing, spelled out rather than left to a library:
|
||||
// `max_by_key` would take the last maximum and Swift's `max(by:)` the first, so a
|
||||
// 50/50 window (the classic 1-and-3 sawtooth) would label its mode differently on
|
||||
// Android and Apple while reporting the same judder. The clients have to agree.
|
||||
let mut mode_units = 0u8;
|
||||
let mut mode_count = 0u32;
|
||||
for (i, &c) in self.hist.iter().enumerate() {
|
||||
if c > mode_count {
|
||||
mode_count = c;
|
||||
mode_units = i as u8;
|
||||
}
|
||||
}
|
||||
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 +455,178 @@ 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_eq!(
|
||||
s.mode_units, 1,
|
||||
"a tied mode resolves to the smallest spacing — pinned so the Swift port agrees"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
|
||||
/// The on-glass failure of 2026-08-05, pinned. Android's render callback can deliver a
|
||||
/// garbage far-future timestamp on a session's first frames. Holding "the later instant"
|
||||
/// unconditionally latched onto it and scored EVERY subsequent present as disordered —
|
||||
/// `cadN=0 disorder=119` per second, for the whole session, with the period known and the
|
||||
/// stream perfectly healthy. One bad sample must cost one sample, not the session.
|
||||
#[test]
|
||||
fn a_garbage_far_future_stamp_does_not_wedge_the_run() {
|
||||
let mut pi = PresentIntervals::new();
|
||||
let mut t = 1_000_000_000i64;
|
||||
pi.record(t, P);
|
||||
pi.record(t + 60 * 60 * 1_000_000_000, P); // a vendor's epoch-sized first stamp
|
||||
for _ in 0..20 {
|
||||
t += P;
|
||||
pi.record(t, P);
|
||||
}
|
||||
let s = pi.summary().expect("the run recovers instead of wedging");
|
||||
assert_eq!(s.disordered, 1, "the garbage stamp cost exactly one sample");
|
||||
assert_eq!((s.mode_units, s.judder_permille), (1, 0));
|
||||
assert_eq!(s.samples, 19, "every present after the re-anchor scored");
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,18 @@
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Opus must be built FROM SOURCE, never picked up from the machine. `audiopus_sys` probes
|
||||
# pkg-config first, and a Homebrew libopus is compiled for the HOST macOS — its objects land
|
||||
# inside our staticlib carrying that minos (the deployment-target check at the end of this
|
||||
# script then fails with 143 SILK objects at the host's version). Whether the bundle is
|
||||
# usable would otherwise depend on whether the developer happens to have `brew install opus`,
|
||||
# which is exactly the kind of thing an artifact consumed by every Apple build must not
|
||||
# depend on. `OPUS_NO_PKG_CONFIG` forces the vendored build; the CMake policy floor is for
|
||||
# that vendored copy, whose CMakeLists still declares a pre-3.5 minimum that CMake 4 removed
|
||||
# support for.
|
||||
export OPUS_NO_PKG_CONFIG=1
|
||||
export CMAKE_POLICY_VERSION_MINIMUM="${CMAKE_POLICY_VERSION_MINIMUM:-3.5}"
|
||||
|
||||
TARGETS_MAC=(aarch64-apple-darwin x86_64-apple-darwin)
|
||||
BUILD_IOS="${BUILD_IOS:-0}" # BUILD_IOS=1 adds iOS device + simulator slices (rustup targets aarch64-apple-ios{,-sim})
|
||||
BUILD_TVOS="${BUILD_TVOS:-0}" # BUILD_TVOS=1 adds tvOS slices — TIER-3 Rust targets: needs `rustup toolchain install nightly` + `rustup component add rust-src --toolchain nightly`
|
||||
@@ -125,7 +137,14 @@ for obj in "$STAGE"/macos/libpunktfunk_core.a; do
|
||||
bad=$(otool -l "$obj" 2>/dev/null | awk '/minos/ {print $2}' | sort -uV | awk -F. '$1 > 14' | head -1)
|
||||
if [[ -n "$bad" ]]; then
|
||||
echo "ERROR: $obj contains objects built for macOS $bad (> 14.0)." >&2
|
||||
echo "Stale cache — rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
|
||||
echo "Two known causes:" >&2
|
||||
echo " 1. A system libopus linked instead of the vendored one (check the build" >&2
|
||||
echo " script output for a /opt/homebrew or /usr/local link-search path). This" >&2
|
||||
echo " script exports OPUS_NO_PKG_CONFIG=1 to prevent it — if you see it anyway," >&2
|
||||
echo " something overrode that." >&2
|
||||
echo " 2. A stale cache: cargo does not fingerprint MACOSX_DEPLOYMENT_TARGET." >&2
|
||||
echo " rm -rf target/{aarch64,x86_64}-apple-darwin and rebuild." >&2
|
||||
echo "Identify the offenders with: ar x $obj && otool -l *.o | grep -B1 minos" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user