feat(host): pace-aware send chunking — high-rate frames pace honestly instead of blasting
ci / docs-site (push) Successful in 50s
ci / web (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 23s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 6m0s
flatpak / build-publish (push) Failing after 8m9s
docker / deploy-docs (push) Successful in 25s
deb / build-publish (push) Successful in 12m48s
android / android (push) Successful in 13m11s
arch / build-publish (push) Successful in 15m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m32s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m29s
ci / rust (push) Successful in 23m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m29s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m35s
windows-host / package (push) Successful in 14m25s
apple / swift (push) Successful in 5m13s
release / apple (push) Successful in 26m56s
apple / screenshots (push) Successful in 20m2s

Phase 1.2: the native plane's pace chunks are rate-adaptive — 16 packets at
today's rates, coarsening until the per-chunk interval clears the 500 µs sleep
floor, capped at 64 (the GSO segment limit). Decouples the syscall batch from
the pace step, so a ≥1 Gbps frame's overflow keeps real sleeps between chunks
(and costs 4× fewer syscalls) instead of collapsing into an unpaced blast.

Phase 1.3: the auto microburst cap scales with the frame — max(128 KB, the
AU's wire bytes / 4) — so high-rate frames burst a bounded quarter and pace
the rest; PUNKTFUNK_PACE_BURST_KB now pins an absolute override.

GameStream plane untouched (its schedule stays pinned by the deterministic
tests, now also asserting budget-independence). Linux GSO latch-off warns
once (was silent; USO already warned).

Linux GSO default stays OPT-IN: the post-1.2/1.3 A/B on the 2.5GbE-hop pair
(.21 → M3 Ultra) reproduced the regression bit-for-bit — 2452 Mbps sendmmsg
vs 1909 GSO peak, 0.4% loss at 1500 where sendmmsg is clean. The super-buffer
trains lose on the constrained hop in the transport path itself (per-AU
probe sends, no video pacer involved), so the block is fabric evidence, not
pacing readiness. Control sweep on this build matched the sendmmsg baseline
exactly (2452); loss-harness recovery curve identical; workspace clippy +
tests green on .21.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:38:06 +02:00
parent a2433d77cf
commit 5a384fe788
3 changed files with 156 additions and 43 deletions
+119 -19
View File
@@ -10,8 +10,11 @@
//! deterministic-schedule tests below):
//!
//! * **native** — the first `burst_bytes` leave immediately (one absorbed microburst), only the
//! overflow is paced in fixed 16-packet chunks across 90 % of the time left to the frame
//! deadline (no slack ⇒ budget 0 ⇒ never slower than unpaced);
//! 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);
//! * **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
@@ -36,6 +39,13 @@ pub(crate) struct PaceStat {
pub(crate) enum ChunkPolicy {
/// Fixed chunk size; the step count scales with the frame (native: 16).
Fixed(usize),
/// Rate-adaptive chunk size (native, plan Phase 1.2): `base` packets until the per-chunk
/// interval (`budget / steps`) would drop under the sleep floor, then the smallest chunk
/// that keeps the interval ≥ floor, capped at `max` (the 64-segment GSO super-buffer
/// limit). Zero budget (no slack — the frame blasts anyway) takes `max`: fewest syscalls
/// for the same immediate send. Decouples the syscall batch from the pace step so high
/// rates keep REAL sleeps between chunks instead of skipping every sub-floor wait.
Adaptive { base: usize, max: usize },
/// Bounded step count: `chunk = max(min_chunk, ceil(n / max_steps))` (GameStream: 16 / 12).
/// Keeps per-frame sleep overshoot independent of bitrate — see `spawn_sender`'s history.
Bounded { min_chunk: usize, max_steps: usize },
@@ -72,8 +82,15 @@ pub(crate) struct PaceSchedule {
pub(crate) steps: usize,
}
/// Compute the schedule for one frame's wire packets under `cfg`.
pub(crate) fn schedule<T: AsRef<[u8]>>(packets: &[T], cfg: &PaceCfg) -> PaceSchedule {
/// Compute the schedule for one frame's wire packets under `cfg`. `pace_budget` is the time
/// the paced overflow will spread across (resolved by the caller); only
/// [`ChunkPolicy::Adaptive`] reads it — the `Fixed`/`Bounded` schedules are budget-independent
/// (the pinned legacy planes).
pub(crate) fn schedule<T: AsRef<[u8]>>(
packets: &[T],
cfg: &PaceCfg,
pace_budget: Duration,
) -> PaceSchedule {
let burst_len = match cfg.burst_bytes {
None => 0,
Some(cap) => {
@@ -94,6 +111,20 @@ pub(crate) fn schedule<T: AsRef<[u8]>>(packets: &[T], cfg: &PaceCfg) -> PaceSche
let overflow = packets.len() - burst_len;
let (chunk, steps) = match cfg.chunk {
ChunkPolicy::Fixed(c) => (c, overflow.div_ceil(c).max(1)),
ChunkPolicy::Adaptive { base, max } => {
let c = if overflow == 0 {
base
} else if pace_budget.is_zero() {
max
} else {
// interval = budget/steps ≈ budget·c/overflow ≥ sleep_floor ⇔
// c ≥ overflow·floor/budget — the smallest such c, clamped to [base, max].
let c_min = (overflow as u128 * cfg.sleep_floor.as_nanos())
.div_ceil(pace_budget.as_nanos());
c_min.clamp(base as u128, max as u128) as usize
};
(c, overflow.div_ceil(c).max(1))
}
ChunkPolicy::Bounded {
min_chunk,
max_steps,
@@ -120,7 +151,19 @@ pub(crate) fn pace_frame<T: AsRef<[u8]>, E>(
mut send: impl FnMut(&[T]) -> Result<(), E>,
) -> Result<PaceStat, E> {
let start = Instant::now();
let sched = schedule(packets, cfg);
// Resolve the pace budget up front: adaptive chunk sizing needs it before the burst
// leaves. The paced loop below still re-anchors at `pace_start` (after the burst), so the
// sleep targets are exactly the legacy math; this entry-time estimate only sizes chunks
// (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
.checked_duration_since(start)
.unwrap_or_default()
.mul_f32(fraction),
PaceBudget::Fixed(d) => d,
};
let sched = schedule(packets, cfg, budget_est);
for chunk in packets[..sched.burst_len].chunks(sched.chunk) {
send(chunk)?;
}
@@ -257,10 +300,13 @@ mod tests {
let pkts = packets(n, len);
let sizes: Vec<usize> = pkts.iter().map(|p| p.len()).collect();
let (split, m) = legacy(&sizes, cap);
let s = schedule(&pkts, &native_cfg(cap));
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
// Two very different budgets: Fixed schedules must not read the budget at all.
for budget in [Duration::ZERO, Duration::from_millis(7)] {
let s = schedule(&pkts, &native_cfg(cap), budget);
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
}
}
}
@@ -276,20 +322,53 @@ mod tests {
for &n in &[1usize, 16, 17, 146, 192, 193, 610, 1024, 5000, 50_000] {
let pkts = packets(n, 1024);
let (chunk, steps) = legacy_pace_layout(n);
let s = schedule(&pkts, &gs_cfg());
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
assert_eq!(s.steps, steps, "n={n}: step count");
assert!(s.steps <= 12, "n={n}: step count bounded");
assert!(s.chunk >= 16, "n={n}: chunk floor");
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
// Two very different budgets: Bounded schedules must not read the budget at all.
for budget in [Duration::ZERO, Duration::from_millis(7)] {
let s = schedule(&pkts, &gs_cfg(), budget);
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
assert_eq!(s.steps, steps, "n={n}: step count");
assert!(s.steps <= 12, "n={n}: step count bounded");
assert!(s.chunk >= 16, "n={n}: chunk floor");
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
}
}
// The legacy test's exact anchors.
let s = schedule(&packets(1, 1024), &gs_cfg());
let s = schedule(&packets(1, 1024), &gs_cfg(), Duration::ZERO);
assert_eq!((s.chunk, s.steps), (16, 1));
let s = schedule(&packets(16, 1024), &gs_cfg());
let s = schedule(&packets(16, 1024), &gs_cfg(), Duration::ZERO);
assert_eq!((s.chunk, s.steps), (16, 1));
assert!(schedule(&packets(610, 1024), &gs_cfg()).steps <= 12);
assert!(schedule(&packets(610, 1024), &gs_cfg(), Duration::ZERO).steps <= 12);
}
/// The native plane's Phase-1.2 policy (plan `throughput-beyond-1gbps.md`): 16-packet
/// chunks at today's rates, coarsening only when the per-chunk interval would drop under
/// the 500 µs sleep floor, capped at the 64-segment GSO super-buffer limit; zero budget
/// (blast) takes the cap.
#[test]
fn adaptive_chunk_coarsens_with_rate() {
let cfg = PaceCfg {
burst_bytes: Some(12_000),
chunk: ChunkPolicy::Adaptive { base: 16, max: 64 },
sleep_floor: Duration::from_micros(500),
};
// 210 × 1200 B: packets 0..=9 burst (cum hits 12 000 at #10), 200 overflow.
let pkts = packets(210, 1200);
// Ample budget (100 ms): a 16-packet interval is ≫ floor → base, legacy-identical.
let s = schedule(&pkts, &cfg, Duration::from_millis(100));
assert_eq!((s.burst_len, s.chunk, s.steps), (10, 16, 13));
// 2.5 ms budget: c ≥ 200 × 500 µs / 2.5 ms = 40 → exactly 40, 5 steps × 500 µs each.
let s = schedule(&pkts, &cfg, Duration::from_micros(2_500));
assert_eq!((s.chunk, s.steps), (40, 5));
// 1 ms budget: c ≥ 100 → capped at 64 (the GSO segment limit).
let s = schedule(&pkts, &cfg, Duration::from_millis(1));
assert_eq!((s.chunk, s.steps), (64, 4));
// Zero budget (no slack — the frame blasts): max chunk = fewest syscalls.
let s = schedule(&pkts, &cfg, Duration::ZERO);
assert_eq!((s.chunk, s.steps), (64, 4));
// Whole frame under the cap: no overflow → base chunk for the burst sends.
let s = schedule(&packets(5, 1200), &cfg, Duration::ZERO);
assert_eq!((s.burst_len, s.chunk, s.steps), (5, 16, 1));
}
/// The executed chunk sequence follows the schedule exactly, on both parameterizations —
@@ -329,6 +408,27 @@ mod tests {
assert_eq!(seen, vec![16, 4]);
assert!(!stat.paced);
// Native adaptive, zero budget: the burst leaves in one ≤64-packet chunk, the overflow
// in 64-packet super-chunks (the blast path takes the coarsest syscall batching).
let pkts = packets(210, 1200);
let mut seen: Vec<usize> = Vec::new();
let stat = pace_frame(
&pkts,
PaceBudget::Fixed(Duration::ZERO),
&PaceCfg {
burst_bytes: Some(12_000),
chunk: ChunkPolicy::Adaptive { base: 16, max: 64 },
sleep_floor: Duration::from_micros(500),
},
|chunk| {
seen.push(chunk.len());
Ok::<(), std::io::Error>(())
},
)
.unwrap();
assert_eq!(seen, vec![10, 64, 64, 64, 8]);
assert!(stat.paced);
// GameStream, 146 packets: chunk = max(16, ceil(146/12)=13) = 16 → 10 paced chunks.
let pkts = packets(146, 1024);
let mut seen: Vec<usize> = Vec::new();