From 6d1baa0add08830aaaec8fc018393696bff04aea Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 08:34:36 +0200 Subject: [PATCH] fix(pf-encode/pyrowave): the bitrate pin holds on the WIRE, not the raw bitstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A datagram-aligned PyroWave session inflates the codec bitstream ×1.2–1.3 on its way to the wire — greedy packing of few-hundred-byte atomic block packets into 1408 B windows zero-pads most window tails, plus the 4-byte prefixes and FRAG chains. The 2026-07 field report's 1440p60 10-bit "Automatic" pin of 407 Mb/s put a measured 550 Mb/s on a 1 GbE link; nothing enforced the pin past the rate controller. New shared WireBudget (pyrowave_wire.rs, both backends): tracks the real per-frame AU/bitstream ratio as a ×1024 fixed-point EMA (prior ×1.25, weight 1/8, clamped ×1.0–×2.0) and deflates the budget handed to pyrowave's rate control by it, so the windowed AU lands on the configured rate. Sealed-datagram framing (+4.5%) and FEC parity stay uncompensated — H.26x sessions carry those on top of the configured bitrate too, and the pin must mean the same thing for every codec. Dense (non-chunked) sessions are untouched. Co-Authored-By: Claude Fable 5 --- crates/pf-encode/src/enc/linux/pyrowave.rs | 22 ++++- crates/pf-encode/src/enc/pyrowave_wire.rs | 86 ++++++++++++++++++++ crates/pf-encode/src/enc/windows/pyrowave.rs | 20 ++++- 3 files changed, 126 insertions(+), 2 deletions(-) diff --git a/crates/pf-encode/src/enc/linux/pyrowave.rs b/crates/pf-encode/src/enc/linux/pyrowave.rs index 86a66b6f..f9d1fa85 100644 --- a/crates/pf-encode/src/enc/linux/pyrowave.rs +++ b/crates/pf-encode/src/enc/linux/pyrowave.rs @@ -395,6 +395,9 @@ pub struct PyroWaveEncoder { /// packet to it, so each wire shard carries whole self-delimiting packets. `None` = /// one packet per AU (the dense MVP shape). wire_chunk: Option, + /// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the + /// WIRE, not just the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]). + wire_budget: crate::pyrowave_wire::WireBudget, bitstream: Vec, pending: VecDeque, frame_count: u64, @@ -653,6 +656,7 @@ impl PyroWaveEncoder { chroma444, frame_budget: budget_for(bitrate, fps), wire_chunk: None, + wire_budget: crate::pyrowave_wire::WireBudget::new(), bitstream: Vec::new(), pending: VecDeque::new(), frame_count: 0, @@ -1120,6 +1124,16 @@ impl PyroWaveEncoder { Ok(self.cpu_img.unwrap().2) } + /// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the + /// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is + /// a promise about the wire, not the raw bitstream (see [`crate::pyrowave_wire::WireBudget`]). + fn rate_budget(&self) -> usize { + match self.wire_chunk { + Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024), + None => self.frame_budget, + } + } + /// One frame, synchronously: ingest → CSC → pyrowave encode (recorded into our command /// buffer) → submit + fence wait (sub-ms) → packetize into an `EncodedFrame`. unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> { @@ -1175,6 +1189,8 @@ impl PyroWaveEncoder { // paths propagate untouched and the recovery (`reset()`/`Drop`) `device_wait_idle()`s // before anything touches `cmd`; a buffer that completed its one-time submit is INVALID, // which the next `begin` may implicitly reset. + // Resolved before the closure (which borrows `self` mutably for the recording calls). + let rate_budget = self.rate_budget(); let record_and_submit = (|| -> Result<()> { dev.begin_command_buffer( self.cmd, @@ -1396,7 +1412,7 @@ impl PyroWaveEncoder { ], }; let rc = pw::pyrowave_rate_control { - maximum_bitstream_size: self.frame_budget, + maximum_bitstream_size: rate_budget, }; pw::pyrowave_device_set_command_buffer( self.pw_dev, @@ -1476,6 +1492,10 @@ impl PyroWaveEncoder { // single packet, or the datagram-aligned windowed AU (§4.4). let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect(); let au = crate::pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk); + if self.wire_chunk.is_some() { + let raw: usize = pkts.iter().map(|&(_, s)| s).sum(); + self.wire_budget.observe(raw, au.len()); + } self.frame_count += 1; self.pending.push_back(EncodedFrame { data: au, diff --git a/crates/pf-encode/src/enc/pyrowave_wire.rs b/crates/pf-encode/src/enc/pyrowave_wire.rs index 0c747959..c7e02653 100644 --- a/crates/pf-encode/src/enc/pyrowave_wire.rs +++ b/crates/pf-encode/src/enc/pyrowave_wire.rs @@ -81,6 +81,61 @@ pub(crate) fn block_count_32x32(width: u32, height: u32, chroma444: bool) -> u32 count } +/// Wire-aware deflation of the per-frame rate budget for the datagram-aligned mode. +/// +/// [`build_au`]'s windowing inflates the codec bitstream on its way to the wire: greedy packing +/// of pyrowave's few-hundred-byte atomic block packets into `chunk`-sized windows leaves the +/// tail of most windows zero-padded, plus the 4-byte prefixes and FRAG-chain tails. At +/// 1440p/~850 KiB frames that is ×1.2–1.3 — the 2026-07 field report's "Automatic" 407 Mb/s +/// pin put 550 Mb/s on a 1 GbE link. The pin is a promise about the LINK, so the codec budget +/// must absorb the framing: this tracker measures the real AU/bitstream ratio per frame and +/// deflates the budget handed to pyrowave's rate control by its EMA. Sealed-datagram framing +/// (packet header + AEAD tag) and FEC parity are deliberately NOT compensated — H.26x sessions +/// carry those on top of the configured bitrate too, and the pin must mean the same thing for +/// every codec. +pub(crate) struct WireBudget { + /// EMA of `built AU bytes / packetized bitstream bytes`, ×1024 fixed point. + scale_x1024: u32, +} + +impl WireBudget { + /// Startup prior (×1024 ≈ 1.25 — the 1440p field measurement's midpoint); the EMA + /// converges onto the session's real ratio within ~a second of frames. + const PRIOR_X1024: u32 = 1280; + /// EMA weight 1/8: content-driven per-frame wobble smooths out; a mode/bitrate change + /// re-converges in ~16 frames. + const EMA_SHIFT: u32 = 3; + /// Sanity clamp on the applied scale: never inflate the budget (×1.0 floor), never + /// deflate below half (×2.0 cap — tiny explicit bitrates window very coarsely). + const MIN_X1024: u32 = 1024; + const MAX_X1024: u32 = 2048; + + pub(crate) fn new() -> WireBudget { + WireBudget { + scale_x1024: Self::PRIOR_X1024, + } + } + + /// Record one frame's measured inflation (`bitstream_len` = the packetized codec bytes the + /// rate controller budgeted; `au_len` = the windowed AU that actually reaches the wire). + pub(crate) fn observe(&mut self, bitstream_len: usize, au_len: usize) { + if bitstream_len == 0 { + return; + } + let sample = ((au_len as u64 * 1024) / bitstream_len as u64) + .clamp(Self::MIN_X1024 as u64, Self::MAX_X1024 as u64) as u32; + let ema = self.scale_x1024 as i64; + self.scale_x1024 = (ema + ((sample as i64 - ema) >> Self::EMA_SHIFT)) as u32; + } + + /// The rate-control budget that makes the WIRE hit `budget` bytes/frame under the + /// currently-measured inflation. + pub(crate) fn deflate(&self, budget: usize) -> usize { + let scale = self.scale_x1024.clamp(Self::MIN_X1024, Self::MAX_X1024) as u64; + ((budget as u64 * 1024) / scale) as usize + } +} + /// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU. /// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed /// datagram-aligned AU (a whole number of `chunk`-sized windows). @@ -259,6 +314,37 @@ mod tests { assert!(block_count_32x32(7680, 4320, false) <= u16::MAX as u32); } + /// The wire-budget tracker: converges its EMA onto the measured AU/bitstream inflation, + /// deflates the budget by exactly that ratio, and clamps runaway samples. + #[test] + fn wire_budget_converges_and_deflates() { + let mut wb = WireBudget::new(); + // Prior ≈ ×1.25 (1280/1024): the first deflation is already conservative. + assert_eq!(wb.deflate(1_024_000), 819_200); + // Feed a steady ×1.30 inflation; the EMA must converge onto it. + for _ in 0..64 { + wb.observe(1000, 1300); + } + let b = wb.deflate(1_024_000); + let expect = 1_024_000_u64 * 1000 / 1300; + assert!( + (b as i64 - expect as i64).unsigned_abs() < 8_000, + "budget {b} should approach {expect}" + ); + // A dense-ish run (×1.0) walks it back down to no deflation. + for _ in 0..64 { + wb.observe(1000, 1000); + } + assert_eq!(wb.deflate(1_024_000), 1_024_000); + // Garbage samples are clamped: an absurd ratio can at most halve the budget… + for _ in 0..256 { + wb.observe(10, 1000); + } + assert!(wb.deflate(1_024_000) >= 512_000); + // …and a zero-length observation is ignored, never a division by zero. + wb.observe(0, 1000); + } + #[test] fn stamp_color_bits_sets_range_and_hdr_bits() { let mut bs = vec![0u8; 16]; diff --git a/crates/pf-encode/src/enc/windows/pyrowave.rs b/crates/pf-encode/src/enc/windows/pyrowave.rs index a128e2d8..93fa079c 100644 --- a/crates/pf-encode/src/enc/windows/pyrowave.rs +++ b/crates/pf-encode/src/enc/windows/pyrowave.rs @@ -159,6 +159,9 @@ pub struct PyroWaveEncoder { frame_budget: usize, /// Datagram-aligned mode (plan §4.4): packetize at this boundary. `None` = one dense packet/AU. wire_chunk: Option, + /// Measured windowing inflation → rate-budget deflation, so the bitrate pin holds on the + /// WIRE, not just the raw bitstream (see [`pyrowave_wire::WireBudget`]). + wire_budget: pyrowave_wire::WireBudget, bitstream: Vec, pending: VecDeque, } @@ -288,6 +291,7 @@ impl PyroWaveEncoder { hdr16, frame_budget, wire_chunk: None, + wire_budget: pyrowave_wire::WireBudget::new(), bitstream: Vec::new(), pending: VecDeque::new(), }) @@ -441,6 +445,16 @@ impl PyroWaveEncoder { /// /// # Safety /// Runs on the single encode thread; all pyrowave calls take handles this struct owns. + /// The per-frame budget handed to pyrowave rate control: `frame_budget`, deflated by the + /// measured windowing inflation when the datagram-aligned wire is on — the bitrate pin is + /// a promise about the wire, not the raw bitstream (see [`pyrowave_wire::WireBudget`]). + fn rate_budget(&self) -> usize { + match self.wire_chunk { + Some(_) => self.wire_budget.deflate(self.frame_budget).max(64 * 1024), + None => self.frame_budget, + } + } + unsafe fn encode_frame(&mut self, frame: &CapturedFrame) -> Result<()> { // A failed `reset()` leaves the encoder destroyed and null — fail cleanly rather than // handing null to pyrowave (see the Linux twin). @@ -616,7 +630,7 @@ impl PyroWaveEncoder { sync: std::mem::zeroed(), }; let rc = pw::pyrowave_rate_control { - maximum_bitstream_size: self.frame_budget, + maximum_bitstream_size: self.rate_budget(), }; pw_check( pw::pyrowave_encoder_encode_gpu_synchronous( @@ -664,6 +678,10 @@ impl PyroWaveEncoder { } let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect(); let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk); + if self.wire_chunk.is_some() { + let raw: usize = pkts.iter().map(|&(_, s)| s).sum(); + self.wire_budget.observe(raw, au.len()); + } self.pending.push_back(EncodedFrame { data: au, pts_ns: frame.pts_ns,