From 9b38b6a22d054952453bc236d0d0f75fd6b8aa35 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 18:48:30 +0200 Subject: [PATCH] feat(stats): a capture records which encoder and GPU produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stage split is the thing an fps-shortfall report is diagnosed from, and it cannot be read without knowing the backend behind it: a p50 `submit` of 10 ms means "the GPU's CSC+encode throughput is the ceiling" on one backend and something else entirely on another. The capture's meta carried the mode, codec and client but neither the encoder nor the GPU, so every report so far cost a round-trip asking which one it was. Both come from `pf_gpu::active()` — the record the encoder open itself writes — so they name the branch that really opened rather than a re-derived guess that goes stale the moment a backend gains an internal fallback. Read once per capture, not per frame. The console shows them next to the mode in the recording's header. Both fields are `serde(default)`, so a recording made before this still loads and simply omits them. Refs #9 Co-Authored-By: Claude Opus 5 (1M context) --- api/openapi.json | 8 +++++ crates/punktfunk-host/src/stats_recorder.rs | 33 +++++++++++++++++++-- web/src/sections/Stats/Detail.tsx | 6 ++++ web/src/stories/lib/fixtures.ts | 2 ++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index 323af334..84a38a49 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3912,11 +3912,19 @@ "format": "int64", "minimum": 0 }, + "encoder_backend": { + "type": "string", + "description": "The encode backend that ACTUALLY opened for this session — `\"nvenc\"`, `\"vaapi\"`,\n`\"vulkan\"`, `\"amf\"`, `\"qsv\"`, `\"software\"`, … — and the GPU it runs on.\n\nRecorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms\nmeans \"the GPU's CSC+encode throughput is the ceiling\" on one backend and something else\nentirely on another, and every fps-shortfall report so far has cost a round-trip asking\nwhich one it was. Both come from `pf_gpu::active()`, the record the encoder open itself\nwrites, so they name the branch that really opened rather than a re-derived guess.\n\n`\"\"` when nothing was streaming at registration (or on a build without the record)." + }, "fps": { "type": "integer", "format": "int32", "minimum": 0 }, + "gpu": { + "type": "string", + "description": "Human-readable GPU name (`\"NVIDIA GeForce RTX 4090\"`, `\"CPU (openh264)\"`), or `\"\"`." + }, "height": { "type": "integer", "format": "int32", diff --git a/crates/punktfunk-host/src/stats_recorder.rs b/crates/punktfunk-host/src/stats_recorder.rs index c4b3271f..fc53fcc9 100644 --- a/crates/punktfunk-host/src/stats_recorder.rs +++ b/crates/punktfunk-host/src/stats_recorder.rs @@ -80,6 +80,21 @@ pub struct CaptureMeta { /// Short label / fingerprint prefix, or `""` if unknown. pub client: String, pub sample_count: u32, + /// The encode backend that ACTUALLY opened for this session — `"nvenc"`, `"vaapi"`, + /// `"vulkan"`, `"amf"`, `"qsv"`, `"software"`, … — and the GPU it runs on. + /// + /// Recorded because the stage split alone can't be read without them. A p50 `submit` of 10 ms + /// means "the GPU's CSC+encode throughput is the ceiling" on one backend and something else + /// entirely on another, and every fps-shortfall report so far has cost a round-trip asking + /// which one it was. Both come from `pf_gpu::active()`, the record the encoder open itself + /// writes, so they name the branch that really opened rather than a re-derived guess. + /// + /// `""` when nothing was streaming at registration (or on a build without the record). + #[serde(default)] + pub encoder_backend: String, + /// Human-readable GPU name (`"NVIDIA GeForce RTX 4090"`, `"CPU (openh264)"`), or `""`. + #[serde(default)] + pub gpu: String, } /// A full capture: summary + the sample time-series. The wire + on-disk shape. @@ -115,6 +130,8 @@ struct MetaSeed { fps: u32, codec: String, client: String, + encoder_backend: String, + gpu: String, } /// The in-progress capture (present iff armed). @@ -246,6 +263,12 @@ impl StatsRecorder { client: &str, ) -> u32 { let sid = self.next_sid.fetch_add(1, Ordering::Relaxed); + // The live encode backend + GPU, straight from the record the encoder open wrote. Read + // outside the lock (it takes its own) and only on the first registration path's behalf — + // this runs once per capture, not per frame. + let (encoder_backend, gpu) = pf_gpu::active() + .map(|(g, _)| (g.backend.to_string(), g.name)) + .unwrap_or_default(); let mut guard = self.live.lock().unwrap_or_else(|e| e.into_inner()); if let Some(live) = guard.as_mut() { if live.meta.is_none() { @@ -256,6 +279,8 @@ impl StatsRecorder { fps, codec: codec.to_string(), client: client.to_string(), + encoder_backend, + gpu, }); } } @@ -398,7 +423,7 @@ fn status_of(live: Option<&Live>) -> StatsStatus { /// Compute the `CaptureMeta` for an in-progress or finalizing capture (id derived from the start /// time + negotiated mode; duration from the monotonic start). fn meta_of(live: &Live) -> CaptureMeta { - let (kind, width, height, fps, codec, client) = match &live.meta { + let (kind, width, height, fps, codec, client, encoder_backend, gpu) = match &live.meta { Some(m) => ( m.kind.clone(), m.width, @@ -406,8 +431,10 @@ fn meta_of(live: &Live) -> CaptureMeta { m.fps, m.codec.clone(), m.client.clone(), + m.encoder_backend.clone(), + m.gpu.clone(), ), - None => (String::new(), 0, 0, 0, String::new(), String::new()), + None => Default::default(), }; CaptureMeta { id: capture_id(live.started_unix_ms, width, height), @@ -420,6 +447,8 @@ fn meta_of(live: &Live) -> CaptureMeta { codec, client, sample_count: live.samples.len() as u32, + encoder_backend, + gpu, } } diff --git a/web/src/sections/Stats/Detail.tsx b/web/src/sections/Stats/Detail.tsx index 1957850a..6e67340f 100644 --- a/web/src/sections/Stats/Detail.tsx +++ b/web/src/sections/Stats/Detail.tsx @@ -33,9 +33,15 @@ export const DetailCard: FC<{ {m.stats_detail_title()} {cap && ( + // Encoder + GPU ride along with the mode: the stage split below can't be + // read without knowing which backend produced it (a 10 ms `submit` means + // very different things on NVENC and on Vulkan). Older recordings predate + // the fields and simply omit them. {cap.meta.width}×{cap.meta.height}@{cap.meta.fps} ·{" "} {cap.meta.codec.toUpperCase()} + {cap.meta.encoder_backend && ` · ${cap.meta.encoder_backend}`} + {cap.meta.gpu && ` · ${cap.meta.gpu}`} )} diff --git a/web/src/stories/lib/fixtures.ts b/web/src/stories/lib/fixtures.ts index c52454a2..6b77a026 100644 --- a/web/src/stories/lib/fixtures.ts +++ b/web/src/stories/lib/fixtures.ts @@ -232,6 +232,8 @@ export const captureMetas: CaptureMeta[] = [ duration_ms: 92_000, sample_count: 92, started_unix_ms: 1_782_415_260_000, + encoder_backend: "nvenc", + gpu: "NVIDIA GeForce RTX 4090", }, { id: "cap-20260628-1903",