perf(latency): adaptive IDD pipeline depth — depth-1 default, escalate on contention
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
apple / swift (push) Successful in 1m17s
decky / build-publish (push) Successful in 25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
ci / bench (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 25s
apple / screenshots (push) Successful in 6m22s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 14m53s
deb / build-publish (push) Successful in 16m23s
windows-host / package (push) Successful in 19m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m33s
ci / rust (push) Successful in 21m0s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 53s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
apple / swift (push) Successful in 1m17s
decky / build-publish (push) Successful in 25s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 14s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 16s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
ci / bench (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 25s
apple / screenshots (push) Successful in 6m22s
arch / build-publish (push) Successful in 11m59s
android / android (push) Successful in 14m53s
deb / build-publish (push) Successful in 16m23s
windows-host / package (push) Successful in 19m49s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m33s
ci / rust (push) Successful in 21m0s
From the 2026-07-17 on-glass finding (.173 RTX 4090): the IDD-push capturer's
depth-2 pipeline measured ~13ms of glass-to-glass latency over depth-1 at
60fps (17ms → 4ms) — the AU is ready in µs but depth-2 holds it a whole frame
interval unpolled while N+1 is submitted. Depth-2 only earns its keep under
GPU contention (it overlaps the convert of N+1 with the encode of N, avoiding
the depth-1 ~50fps collapse). So the native encode loop now runs depth-1 by
default and escalates to the capturer's max ONLY when it can't hold cadence at
depth-1 (a leaky-bucket over 'the frame's work overran its pacing deadline',
with a startup warmup skip), then holds there for the session (no oscillation;
de-escalation is a v2 item). PUNKTFUNK_IDD_ADAPTIVE=0 pins the capturer's full
depth (pre-adaptive behaviour); no effect where the capturer's max is already
1 (every non-IDD backend). GameStream plane untouched.
Pairs with the shipped REALTIME auto-gate (1bc156aa): uncontended sessions get
the ~13ms latency cut, genuinely GPU-bound ones keep the depth-2 overlap AND
the REALTIME priority. Validated: .21 clippy -D warnings (host --features nvenc).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -201,6 +201,20 @@ fn frame_driven_enabled() -> bool {
|
||||
*ON.get_or_init(|| std::env::var("PUNKTFUNK_FRAME_DRIVEN").as_deref() != Ok("0"))
|
||||
}
|
||||
|
||||
/// Adaptive pipeline depth (latency plan, from the 2026-07-17 on-glass finding on a `.173` RTX
|
||||
/// 4090): the capturer's pipeline depth of 2 measured **~13 ms of glass-to-glass latency** over
|
||||
/// depth 1 at 60 fps (17 ms → 4 ms) — the AU is ready in µs but depth-2 holds it a whole frame
|
||||
/// interval unpolled while N+1 is submitted. Depth-2 exists to overlap the convert of N+1 with
|
||||
/// the encode of N under GPU contention (the depth-1 ~50 fps collapse), so run **depth-1 by
|
||||
/// default** and escalate to the capturer's max ONLY when the loop can't hold its cadence at
|
||||
/// depth-1 (the contention tell), then stick there for the session (escalate-and-hold — no
|
||||
/// oscillation; de-escalation is a v2 item). `PUNKTFUNK_IDD_ADAPTIVE=0` pins the capturer's full
|
||||
/// depth (the pre-adaptive behaviour). Off when the capturer's max depth is already 1.
|
||||
fn idd_adaptive_enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var("PUNKTFUNK_IDD_ADAPTIVE").as_deref() != Ok("0"))
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1131,6 +1145,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
Vec<u32>,
|
||||
Vec<u32>,
|
||||
) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
// Adaptive pipeline depth (see [`idd_adaptive_enabled`]): run depth-1 for latency and
|
||||
// escalate to the capturer's max on sustained cadence overrun. `cur_depth` is the live
|
||||
// target (clamped to the capturer's current max each iteration — a rebuild can change it);
|
||||
// `behind_score` is a leaky bucket over the "fell behind the cadence deadline" signal;
|
||||
// `depth_frames` skips the startup warmup so first-frame bring-up cost can't false-escalate.
|
||||
let mut cur_depth: usize = 1;
|
||||
let mut behind_score: u32 = 0;
|
||||
let mut depth_frames: u64 = 0;
|
||||
// ~20 net behind-frames (≈0.3 s sustained) escalates; a lone hitch decays away. Warmup skips
|
||||
// the first ~1 s so bring-up (display acquire, encoder open) never triggers it.
|
||||
const DEPTH_ESCALATE: u32 = 20;
|
||||
const DEPTH_BEHIND_CAP: u32 = 60;
|
||||
const DEPTH_WARMUP_FRAMES: u64 = 60;
|
||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||
// Mid-stream session switch (the box flipped Gaming↔Desktop): rebuild the WHOLE backend in
|
||||
// place — a different compositor at the SAME client mode — keeping the Session + send thread
|
||||
@@ -1718,7 +1745,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
// How deep to pipeline (1 = synchronous submit→poll, the original behaviour). The IDD-push
|
||||
// capturer hands a rotating ring of output textures, so it returns >1; other capturers default 1.
|
||||
let depth = capturer.pipeline_depth().max(1);
|
||||
// Adaptive (default): start at 1 for latency, `cur_depth` escalates on sustained overrun (the
|
||||
// tail below). Pinned to the capturer's max when adaptive is off or the max is already 1.
|
||||
let max_depth = capturer.pipeline_depth().max(1);
|
||||
let depth = if idd_adaptive_enabled() {
|
||||
cur_depth.clamp(1, max_depth)
|
||||
} else {
|
||||
max_depth
|
||||
};
|
||||
let submit_ns = now_ns();
|
||||
// Wire pts: a fresh frame anchors at its capture-delivery stamp (`CapturedFrame.pts_ns`,
|
||||
// stamped when the capture thread handed it over) so client-measured latency covers
|
||||
@@ -1934,6 +1968,32 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
"encode stall detected — encoder rebuilt in place, forcing an IDR");
|
||||
last_au_at = std::time::Instant::now();
|
||||
}
|
||||
// Adaptive-depth escalate signal (measured BEFORE the trailing sleep): "behind" = the
|
||||
// frame's work overran its cadence deadline `next`, so the trailing sleep would be
|
||||
// zero/negative. At depth-1 that means the synchronous poll (encode + WDDM wait) can't
|
||||
// fit a frame interval — the contention case pipelining is for — so escalate to the
|
||||
// capturer's max and hold there. Leaky bucket + warmup skip reject one-off hitches and
|
||||
// bring-up. Once escalated, `cur_depth` stays (no de-escalation in v1).
|
||||
if idd_adaptive_enabled() && cur_depth < max_depth {
|
||||
depth_frames += 1;
|
||||
if depth_frames > DEPTH_WARMUP_FRAMES {
|
||||
let behind = std::time::Instant::now() >= next;
|
||||
behind_score = if behind {
|
||||
(behind_score + 1).min(DEPTH_BEHIND_CAP)
|
||||
} else {
|
||||
behind_score.saturating_sub(1)
|
||||
};
|
||||
if behind_score >= DEPTH_ESCALATE {
|
||||
cur_depth = max_depth;
|
||||
tracing::info!(
|
||||
depth = cur_depth,
|
||||
"IDD pipeline depth escalated — encode can't hold cadence at depth-1 \
|
||||
(GPU contention); pipelining for the rest of the session (latency \
|
||||
trade for throughput)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if frame_driven_enabled() && capturer.supports_arrival_wait() {
|
||||
// T1.1 frame-driven trigger: instead of sleeping out the whole tick and then
|
||||
// SAMPLING (which holds a frame that arrived just after the previous sample for up
|
||||
|
||||
Reference in New Issue
Block a user