perf(latency): tier-0 attribution + tier-1 send-path levers from the latency plan
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 54s
decky / build-publish (push) Successful in 18s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 1m23s
ci / rust (push) Failing after 4m15s
arch / build-publish (push) Failing after 4m35s
deb / build-publish (push) Failing after 4m4s
docker / deploy-docs (push) Successful in 24s
ci / bench (push) Successful in 5m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 3m45s
windows-host / package (push) Failing after 9m8s
release / apple (push) Successful in 5m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 5m25s
flatpak / build-publish (push) Failing after 8m3s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m58s
android / android (push) Successful in 14m9s
apple / screenshots (push) Successful in 6m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 7m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 9m28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 12m26s

design/latency-reduction-2026-07.md T0.1/T0.2/T1.2/T1.3:

- T1.2 rate-capped front-loaded pacing: the paced overflow's budget is now
  min(0.9x slack, overflow wire time at ~3x the live encoder bitrate)
  (PUNKTFUNK_PACE_FACTOR, 0 = legacy deadline-only spread). A 300 KB-1 MB
  frame's tail leaves in ~2-5 ms instead of smearing across ~15 ms at 60 fps;
  GameStream schedule byte-identical (pins unchanged).
- T1.3 data-first wire order: packetize emits every block's data shards before
  any parity (per-block parity pools keep all blocks' parity alive for the
  second pass), so lossless completion stops waiting behind the parity tail.
  EOF = last emitted packet; receiver already order-agnostic.
- T0.1 staged 0xCF: HostTiming gains an append-extensible per-stage tail
  (queue/encode/pace us; seal+channel-wait derived as residual) - no cap bit
  needed, old peers read the 13-byte prefix. Joined client-side into
  Stats::host_{queue,encode,xfer,pace}_ms, the OSD detailed tier, and the
  probe's report.
- T0.2 true on-glass present timing: VK_KHR_present_id/present_wait enabled
  when supported; a PresentTimer waiter thread resolves each present id to
  real visibility, replacing the submit-time display stamp (which undercounts
  by up to a refresh and hides a silent-FIFO standing queue).

Validated on .21: core 185 + host 185 tests, pf-presenter 19, clippy
-D warnings across all five touched crates; loss-harness recovery curve
unchanged; C ABI harness round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 19:12:36 +02:00
parent c28b10a5b9
commit aedee2a4e3
15 changed files with 779 additions and 94 deletions
+54 -13
View File
@@ -60,6 +60,7 @@ pub(super) fn synthetic_stream(
let t = punktfunk_core::quic::HostTiming {
pts_ns,
host_us: (now_ns().saturating_sub(pts_ns) / 1000).min(u32::MAX as u64) as u32,
stages: None, // synthetic loop: no capture/encode stages to split
};
let _ = tc.send_datagram(punktfunk_core::quic::encode_host_timing_datagram(&t).into());
}
@@ -195,18 +196,26 @@ fn service_probes(
/// Seal one access unit and send it with MICROBURST pacing (the shared
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
/// only the OVERFLOW beyond that is spread across ~90% of the time to `deadline` in ADAPTIVE
/// chunks — 16 packets at today's rates, coarsening to at most 64 (the GSO-segment cap) once
/// the rate would otherwise skip every sub-floor sleep, so ≥1 Gbps frames still pace instead
/// of collapsing into an unpaced blast (plan Phase 1.2). `burst_cap` `None` = auto:
/// `max(128 KB, this AU's wire bytes / 4)`, so the burst stays a bounded fraction of a
/// high-rate frame instead of swallowing it whole (plan Phase 1.3); `Some` =
/// PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a normal-bitrate frame (≤ cap) leaves in
/// one immediate burst at ~0 added latency, while a genuine IDR / sustained-high-bitrate frame
/// (≫ cap) still spreads — keeping the freeze fix exactly where it's needed (an unpaced
/// line-rate burst overruns the kernel tx buffer → EAGAIN drop → under infinite GOP, a freeze
/// until the next keyframe). With no slack (encode ≈ interval) the budget collapses to 0 and
/// even the overflow goes out immediately, so this is never slower than unpaced.
/// only the OVERFLOW beyond that is spread across `min(~90% of the time to deadline, the time
/// the overflow needs at pace_rate_bps)` in ADAPTIVE chunks — 16 packets at today's rates,
/// coarsening to at most 64 (the GSO-segment cap) once the rate would otherwise skip every
/// sub-floor sleep, so ≥1 Gbps frames still pace instead of collapsing into an unpaced blast
/// (plan Phase 1.2). `burst_cap` `None` = auto: `max(128 KB, this AU's wire bytes / 4)`, so
/// the burst stays a bounded fraction of a high-rate frame instead of swallowing it whole
/// (plan Phase 1.3); `Some` = PUNKTFUNK_PACE_BURST_KB pinned an absolute cap. So a
/// normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added latency, while a
/// genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping the freeze fix
/// exactly where it's needed (an unpaced line-rate burst overruns the kernel tx buffer →
/// EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately,
/// so this is never slower than unpaced.
///
/// `pace_rate_bps` (latency plan T1.2) bounds the spread from above: the deadline term alone
/// smears a big frame's tail across the whole remaining interval (~15 ms at 60 fps) even when
/// the link could drain it in 23 ms. The caller passes ~3× the live encoder bitrate — a rate
/// the link is proven to carry sustained, so the bounded excursion keeps the anti-freeze
/// property while the tail leaves as soon as the link plausibly allows. `0` = uncapped
/// (legacy smoothness-only spread, and the fallback when the bitrate isn't known yet).
#[allow(clippy::too_many_arguments)]
fn paced_submit(
session: &mut Session,
@@ -216,6 +225,7 @@ fn paced_submit(
frame_index: u32,
deadline: std::time::Instant,
burst_cap: Option<usize>,
pace_rate_bps: u64,
) -> Result<PaceStat> {
let wires = session
.seal_frame_at(data, pts_ns, flags, frame_index)
@@ -224,11 +234,22 @@ fn paced_submit(
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
crate::send_pacing::inject_video_drop(&mut refs);
let wire_bytes: usize = refs.iter().map(|p| p.len()).sum();
let burst_bytes = burst_cap.unwrap_or_else(|| (wire_bytes / 4).max(128 * 1024));
let cfg = crate::send_pacing::PaceCfg {
burst_bytes: Some(burst_cap.unwrap_or_else(|| (wire_bytes / 4).max(128 * 1024))),
burst_bytes: Some(burst_bytes),
chunk: crate::send_pacing::ChunkPolicy::Adaptive { base: 16, max: 64 },
sleep_floor: std::time::Duration::from_micros(500),
};
// T1.2 rate cap: the overflow's wire time at `pace_rate_bps`. Only the bytes past the
// burst pace at all, so only they bound the budget.
let overflow_bytes = wire_bytes.saturating_sub(burst_bytes) as u64;
let cap = if pace_rate_bps > 0 && overflow_bytes > 0 {
std::time::Duration::from_nanos(
(overflow_bytes * 8).saturating_mul(1_000_000_000) / pace_rate_bps,
)
} else {
std::time::Duration::MAX
};
// Time the socket handoff per chunk and fold it into the session's SealPerf split — the
// sleeps between chunks stay excluded, so sock_ns is pure send_gso/sendmmsg time.
let mut sock_ns = 0u64;
@@ -237,6 +258,7 @@ fn paced_submit(
crate::send_pacing::PaceBudget::UntilDeadline {
deadline,
fraction: 0.9,
cap,
},
&cfg,
|chunk| {
@@ -352,6 +374,15 @@ fn send_loop(
probe_seq: bool,
) {
boost_thread_priority(false); // transmit thread: above-normal (Apollo's encoder-thread level)
// T1.2 front-loaded pacing: the paced overflow drains at `factor ×` the live encoder
// bitrate instead of stretching to the frame deadline. 3× default (the link carries 1×
// sustained, so a bounded 3× excursion is safe — WebRTC's pacer uses 2.5×);
// `PUNKTFUNK_PACE_FACTOR=0` restores the legacy deadline-only spread.
let pace_factor: f64 = std::env::var("PUNKTFUNK_PACE_FACTOR")
.ok()
.and_then(|s| s.parse().ok())
.filter(|f: &f64| f.is_finite() && *f >= 0.0)
.unwrap_or(3.0);
let mut last_perf = std::time::Instant::now();
let mut last_bytes = 0u64;
let mut last_send_dropped = 0u64;
@@ -391,6 +422,8 @@ fn send_loop(
msg.frame_index,
msg.deadline,
burst_cap,
// Live ABR-tracked encoder bitrate → pace rate; 0 (not yet known) = uncapped.
(stats.bitrate_kbps.load(Ordering::Relaxed) as f64 * 1000.0 * pace_factor) as u64,
) {
Ok(stat) => {
// First VIDEO packets are on the wire — complete the bring-up trace (P0.1;
@@ -411,6 +444,14 @@ fn send_loop(
let t = punktfunk_core::quic::HostTiming {
pts_ns: msg.capture_ns,
host_us,
// T0.1 stage split: queue + encode ride the FrameMsg (always
// measured), pace is this send's spread. The client derives
// seal/FEC + channel-wait as the residual against host_us.
stages: Some(punktfunk_core::quic::HostStages {
queue_us: msg.queue_us,
encode_us: msg.encode_us,
pace_us: stat.spread_us,
}),
};
let _ = tc.send_datagram(
punktfunk_core::quic::encode_host_timing_datagram(&t).into(),
+120 -11
View File
@@ -10,11 +10,14 @@
//! deterministic-schedule tests below):
//!
//! * **native** — the first `burst_bytes` leave immediately (one absorbed microburst), only the
//! overflow is paced across 90 % of the time left to the frame deadline in ADAPTIVE chunks:
//! 16 packets at today's rates, coarsening just enough that the per-chunk interval clears the
//! sleep floor (≤ 64, the GSO-segment cap) once the rate would otherwise skip every sleep —
//! so ≥1 Gbps frames still pace instead of blasting (no slack ⇒ budget 0 ⇒ never slower than
//! unpaced);
//! overflow is paced across `min(90 % of the time left to the frame deadline, the time the
//! overflow needs at ~3× the live stream bitrate)` in ADAPTIVE chunks: 16 packets at today's
//! rates, coarsening just enough that the per-chunk interval clears the sleep floor (≤ 64,
//! the GSO-segment cap) once the rate would otherwise skip every sleep — so ≥1 Gbps frames
//! still pace instead of blasting (no slack ⇒ budget 0 ⇒ never slower than unpaced). The
//! rate cap (latency plan T1.2) front-loads the spread: the link demonstrably carries 1× the
//! stream rate sustained, so a bounded 3× excursion is safe and a large frame's tail stops
//! waiting out the whole interval;
//! * **GameStream** — no burst stage; the whole frame spreads across a fixed ¾-frame-interval
//! budget in a BOUNDED number of steps (≤ 12, chunk ≥ 16), because on that non-RT send thread
//! every step ends in a `thread::sleep` whose overshoot must stay independent of bitrate
@@ -54,8 +57,17 @@ pub(crate) enum ChunkPolicy {
/// The time the paced (post-burst) packets spread across.
#[derive(Clone, Copy, Debug)]
pub(crate) enum PaceBudget {
/// `(deadline now-after-burst) × fraction`, collapsing to 0 with no slack (native: 0.9).
UntilDeadline { deadline: Instant, fraction: f32 },
/// `min((deadline now-after-burst) × fraction, cap)`, collapsing to 0 with no slack
/// (native: fraction 0.9). `cap` bounds the spread to the time the overflow actually needs
/// at a rate the link is proven to carry (latency plan T1.2): the deadline term alone
/// smears a large frame across the whole remaining interval even when the link could drain
/// it in a fraction of that — `Duration::MAX` = uncapped (the legacy smoothness-only
/// schedule).
UntilDeadline {
deadline: Instant,
fraction: f32,
cap: Duration,
},
/// A precomputed fixed budget (GameStream: ¾ of the frame interval).
Fixed(Duration),
}
@@ -157,10 +169,15 @@ pub(crate) fn pace_frame<T: AsRef<[u8]>, E>(
// (it overshoots the post-burst budget by the burst's few µs — harmless, sub-floor sleeps
// are skipped anyway).
let budget_est = match budget {
PaceBudget::UntilDeadline { deadline, fraction } => deadline
PaceBudget::UntilDeadline {
deadline,
fraction,
cap,
} => deadline
.checked_duration_since(start)
.unwrap_or_default()
.mul_f32(fraction),
.mul_f32(fraction)
.min(cap),
PaceBudget::Fixed(d) => d,
};
let sched = schedule(packets, cfg, budget_est);
@@ -171,10 +188,15 @@ pub(crate) fn pace_frame<T: AsRef<[u8]>, E>(
if paced {
let pace_start = Instant::now();
let budget = match budget {
PaceBudget::UntilDeadline { deadline, fraction } => deadline
PaceBudget::UntilDeadline {
deadline,
fraction,
cap,
} => deadline
.checked_duration_since(pace_start)
.unwrap_or_default()
.mul_f32(fraction),
.mul_f32(fraction)
.min(cap),
PaceBudget::Fixed(d) => d,
};
for (j, chunk) in packets[sched.burst_len..].chunks(sched.chunk).enumerate() {
@@ -489,6 +511,93 @@ mod tests {
}
}
/// The T1.2 rate cap bounds an `UntilDeadline` budget from above: with ample deadline
/// slack the cap decides the spread (and therefore the adaptive chunk sizing); a
/// `Duration::MAX` cap reproduces the legacy deadline-only schedule exactly.
#[test]
fn until_deadline_cap_bounds_the_budget() {
let cfg = PaceCfg {
burst_bytes: Some(12_000),
chunk: ChunkPolicy::Adaptive { base: 16, max: 64 },
sleep_floor: Duration::from_micros(500),
};
// 210 × 1200 B: 10 burst, 200 overflow (the adaptive test's canonical frame).
let pkts = packets(210, 1200);
// Zero cap + far deadline: the budget collapses to 0 → blast schedule (max chunks,
// no sleeps) even though the deadline alone would have spread ~90 ms.
let mut seen: Vec<usize> = Vec::new();
let stat = pace_frame(
&pkts,
PaceBudget::UntilDeadline {
deadline: Instant::now() + Duration::from_millis(100),
fraction: 0.9,
cap: Duration::ZERO,
},
&cfg,
|chunk| {
seen.push(chunk.len());
Ok::<(), std::io::Error>(())
},
)
.unwrap();
assert_eq!(seen, vec![10, 64, 64, 64, 8], "zero cap = blast schedule");
assert!(stat.paced);
assert!(
stat.spread_us < 50_000,
"zero cap must not sleep toward the deadline"
);
// A 2.5 ms cap under a ~90 ms deadline budget: the cap sizes the chunks
// (c ≥ 200 × 500 µs / 2.5 ms = 40) and the frame drains in ~2.5 ms, not ~90.
let mut seen: Vec<usize> = Vec::new();
let stat = pace_frame(
&pkts,
PaceBudget::UntilDeadline {
deadline: Instant::now() + Duration::from_millis(100),
fraction: 0.9,
cap: Duration::from_micros(2_500),
},
&cfg,
|chunk| {
seen.push(chunk.len());
Ok::<(), std::io::Error>(())
},
)
.unwrap();
assert_eq!(
seen,
vec![10, 40, 40, 40, 40, 40],
"cap drives chunk sizing"
);
assert!(
stat.spread_us < 50_000,
"capped spread must be ~2.5 ms, nowhere near the 90 ms deadline budget"
);
// MAX cap = legacy: no-slack deadline still collapses to the blast path.
let mut seen: Vec<usize> = Vec::new();
pace_frame(
&pkts,
PaceBudget::UntilDeadline {
deadline: Instant::now(),
fraction: 0.9,
cap: Duration::MAX,
},
&cfg,
|chunk| {
seen.push(chunk.len());
Ok::<(), std::io::Error>(())
},
)
.unwrap();
assert_eq!(
seen,
vec![10, 64, 64, 64, 8],
"MAX cap = legacy no-slack blast"
);
}
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
#[test]
fn drop_injection_off_by_default() {