diff --git a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs index bb6c6ab4..26e50ee1 100644 --- a/crates/pf-encode/src/enc/linux/nvenc_cuda.rs +++ b/crates/pf-encode/src/enc/linux/nvenc_cuda.rs @@ -2754,6 +2754,60 @@ mod tests { enc.encoder_engines } + /// An NV12 frame filled with **real high-entropy content**, not the zeroed VRAM every other + /// helper here hands the encoder. + /// + /// This matters more than it looks. Under CBR the rate controller spends its quota only if + /// there is something to code; against uninitialised (driver-zeroed) buffers it emits ~300 B/AU + /// where the configured rate wants ~833 KB, so every timing taken that way measures the + /// PIXEL-proportional cost and is blind to the bits/frame regime — the regime the 4K60 HDR + /// field report actually came from. A cheap xorshift per pixel plus a per-frame seed gives both + /// spatial detail (so intra costs real bits) and inter-frame change (so P-frames cannot + /// skip-code), which is what drives the entropy coder. + /// `block` sets the spatial detail: 1 = per-pixel noise (incompressible — rate control + /// OVERSHOOTS any low target), larger = blockier and cheaper to code. Sweeping it is how the + /// bench reaches the LOW bits/frame end at all; pure noise cannot get there. + fn noise_nv12_frame(w: u32, h: u32, i: u32, block: usize) -> CapturedFrame { + let buf = DeviceBuffer::alloc_nv12(w, h).expect("alloc NV12 device buffer"); + let (uv_ptr, uv_pitch) = buf.uv.expect("NV12 buffer has a UV plane"); + let mut st = 0x2545_F491_4F6C_DD1Du64 ^ ((i as u64 + 1) << 32); + let mut next = move || { + st ^= st << 13; + st ^= st >> 7; + st ^= st << 17; + st + }; + let b = block.max(1); + let mut plane = |pw: usize, ph: usize| -> Vec { + let bw = pw.div_ceil(b); + let cells: Vec = (0..(bw * ph.div_ceil(b))) + .map(|_| (next() >> 24) as u8) + .collect(); + let mut out = Vec::with_capacity(pw * ph); + for y in 0..ph { + let row = y / b * bw; + for x in 0..pw { + out.push(cells[row + x / b]); + } + } + out + }; + let y = plane(w as usize, h as usize); + let uv = plane(w as usize, h as usize / 2); + pf_zerocopy::cuda::write_plane_from_host(buf.ptr, buf.pitch, &y, w as usize, h as usize) + .expect("upload Y plane"); + pf_zerocopy::cuda::write_plane_from_host(uv_ptr, uv_pitch, &uv, w as usize, h as usize / 2) + .expect("upload UV plane"); + CapturedFrame { + width: w, + height: h, + pts_ns: i as u64 * 16_666_667, + format: PixelFormat::Nv12, + payload: FramePayload::Cuda(buf), + cursor: None, + } + } + fn nv12_frame(w: u32, h: u32, i: u32) -> CapturedFrame { // Content is uninitialized device memory — NVENC encodes it fine; this smoke test asserts the // session/registration/encode/RFI machinery, not picture fidelity (that's the on-glass A/B). @@ -4050,6 +4104,99 @@ mod tests { std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); } + /// ON-HARDWARE — **THE BITS/FRAME CURVE**, the measurement this whole programme has been blind + /// to (WP0's real deliverable). + /// + /// Every other timing here was taken against driver-zeroed buffers, so rate control had + /// nothing to code (~300 B/AU against an 833 KB quota) and only the PIXEL-proportional half of + /// the encode cost was ever exercised. But the 4K60 HDR field report was a *bits/frame* + /// problem — 6.8 Mbit/frame — and the central hypothesis is that split's benefit and the + /// 10-bit veto's origin both live on that axis. [`noise_nv12_frame`] finally puts real entropy + /// in front of the encoder. + /// + /// Sweeps bitrate at a fixed mode, single-engine vs forced-2, and prints **bytes/AU alongside + /// every timing** — without that column a run that silently undershoots its quota looks like a + /// result instead of a non-measurement. Run on both boxes: + /// cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \ + /// nvenc_cuda_bits_per_frame_curve --nocapture + #[test] + #[ignore = "requires an NVIDIA GPU + driver — run manually (Ada .181 / Blackwell .21)"] + fn nvenc_cuda_bits_per_frame_curve() { + use std::time::Instant; + const WARMUP: u32 = 10; + const MEASURED: u32 = 24; + let (w, h, fps) = std::env::var("PF_AB_MODE") + .ok() + .and_then(|s| { + let p: Vec = s.split('x').filter_map(|v| v.parse().ok()).collect(); + (p.len() == 3).then(|| (p[0], p[1], p[2])) + }) + .unwrap_or((3840, 2160, 60)); + + std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0"); + pf_zerocopy::cuda::make_current().expect("shared CUDA context current"); + // Sweep CONTENT DETAIL, not nominal bitrate. Pure noise is incompressible, so a low + // bitrate target simply overshoots (measured: 719 KB/AU against a 104 KB quota) and every + // low row lands at the same high bits/frame — the exact blindness this test exists to fix. + // Blockier content codes cheaper, so detail is what actually moves along the axis, and the + // x-axis below is the bits/frame the encoder ACTUALLY produced, never the one requested. + let bps: u64 = 600_000_000; + println!( + "bits/frame curve @ {w}x{h}@{fps} HEVC 8-bit, REAL content, {} Mbps cap:", + bps / 1_000_000 + ); + println!(" detail | ACTUAL bits/frame | single | split-2 | ratio"); + for block in [64usize, 32, 16, 8, 4, 1] { + let frames: Vec = + (0..4).map(|i| noise_nv12_frame(w, h, i, block)).collect(); + let run = |split: &str| -> (u128, usize) { + std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", split); + let mut enc = NvencCudaEncoder::open( + Codec::H265, + PixelFormat::Nv12, + w, + h, + fps, + bps, + true, + 8, + ChromaFormat::Yuv420, + false, + 4, + ) + .expect("open NVENC CUDA session"); + let (mut times, mut bytes) = (Vec::new(), Vec::new()); + for i in 0..(WARMUP + MEASURED) { + let t0 = Instant::now(); + enc.submit_indexed(&frames[(i % 4) as usize], i) + .expect("submit"); + let mut got = 0usize; + while let Some(au) = enc.poll().expect("poll") { + got = au.data.len(); + } + if i >= WARMUP { + times.push(t0.elapsed().as_micros()); + bytes.push(got); + } + } + enc.flush().ok(); + times.sort_unstable(); + bytes.sort_unstable(); + (times[times.len() / 2], bytes[bytes.len() / 2]) + }; + let (s_us, s_bytes) = run("0"); + let (p_us, _) = run("2"); + println!( + " {block:>5}px | {:>10.2} Mbit | {s_us:>6}us | {p_us:>6}us | {:>4.2}×", + s_bytes as f64 * 8.0 / 1e6, + s_us as f64 / p_us.max(1) as f64 + ); + } + + std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE"); + std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME"); + } + /// A pre-session RFI request and nonsense ranges all correctly decline (→ caller forces IDR). /// Needs no GPU session (it short-circuits on the null encoder / range checks), so it runs in the /// normal suite — but `open` gates on the NVENC `.so`, so it skips gracefully where the NVIDIA diff --git a/crates/pf-zerocopy/src/imp/cuda.rs b/crates/pf-zerocopy/src/imp/cuda.rs index 5d5a0a47..bfb09f48 100644 --- a/crates/pf-zerocopy/src/imp/cuda.rs +++ b/crates/pf-zerocopy/src/imp/cuda.rs @@ -62,6 +62,47 @@ pub fn read_plane_to_host( Ok(host) } +/// Upload a tightly-packed host plane into a pitched device plane `(dst_ptr, dst_pitch)`. +/// Synchronous on the priority stream. The exact mirror of [`read_plane_to_host`]. +/// +/// Not a hot path and never used by a session — this exists so ENCODE BENCHMARKS can put real, +/// high-entropy content in front of the encoder. Every synthetic frame this crate could otherwise +/// produce is uninitialised device memory, which the driver hands back **zeroed**; under CBR the +/// rate controller then runs out of things to code and every measurement collapses into the +/// low-bits/frame corner (~300 B/AU against an 833 KB quota, measured). That made the entire +/// split-encode programme blind to the bits/frame regime, which is the regime the field report +/// came from. +pub fn write_plane_from_host( + dst_ptr: CUdeviceptr, + dst_pitch: usize, + src: &[u8], + width_bytes: usize, + height: usize, +) -> Result<()> { + anyhow::ensure!( + src.len() >= width_bytes * height, + "write_plane_from_host: source is {} bytes, need {}", + src.len(), + width_bytes * height + ); + let copy = CUDA_MEMCPY2D { + srcMemoryType: 1, // CU_MEMORYTYPE_HOST + srcHost: src.as_ptr() as *const c_void, + srcPitch: width_bytes, + dstMemoryType: CU_MEMORYTYPE_DEVICE, + dstDevice: dst_ptr, + dstPitch: dst_pitch, + WidthInBytes: width_bytes, + Height: height, + ..Default::default() + }; + // SAFETY: mirrors `read_plane_to_host`. `©` is a live local `#[repr(C)] CUDA_MEMCPY2D` + // outliving the synchronous call; `srcHost` addresses `src`, checked above to hold at least + // `width_bytes*height` bytes, and `dstDevice`/`dstPitch` are the caller's live pitched device + // plane. The copy is synchronous, so `src` need not outlive the call. + unsafe { copy_blocking(©, "cuMemcpy2DAsync_v2(host->dev)") } +} + /// Export a device allocation (from `cuMemAllocPitch`/`cuMemAlloc`) as a cross-process CUDA IPC /// handle — an opaque 64-byte blob another process opens with [`ipc_open`]. The allocation must /// stay alive for as long as any importer has it open. The shared context must be current.