feat(stats): wire real scale/transfer/encode timing from EncState

Oracle step 4 (option A) — give the scale_*, transfer_*, encode_* stats
fields real producers instead of misleading zeros. The fields existed in
FrameTimings and PipelineStats already; producers just weren't passing
non-zero values.

  - avhw.rs: new EncodeStages { scale_us, transfer_us, encode_us } struct.
    EncState::encode_frame (HW VAAPI path) now times the filter graph
    separately from avcodec_send_frame, returning EncodeStages. transfer_us
    is honestly 0 because the HW path never reads back to CPU.
    SwEncState::encode_frame (SW fallback path) returns EncodeStages too;
    there import_and_scale bundles GPU scale + GPU→CPU readback into one
    call, so scale_us includes transfer for SW. Documented inline.

  - state.rs: StreamingEncoder::encode_frame return type bumps from
    Result<()> to Result<EncodeStages>; wlr-screencopy path now feeds
    real per-stage timings into FrameTimings instead of just total_us.

  - state_portal.rs: HW portal path (enc.encode_frame) now extracts
    stages.scale_us / stages.transfer_us / stages.encode_us into
    FrameTimings. Removed the now-unused t_encode_start binding.

Deferred (documented):
  - state_portal.rs SW portal path (line 525) calls import_and_scale +
    enc_thread separately and bypasses SwEncState::encode_frame. To wire
    scale/transfer timing there too, either route through SwEncState or
    thread timing out of import_and_scale. Out of scope for this commit.
  - SW path lumps transfer into scale_us. Splitting requires extending
    import_and_scale's return type — left as a follow-up if operational
    need arises (current default is HW VAAPI).

Oracle audit 2026-06-28 step 4 (option A: integrate, not delete).

All 79 unit tests + 3 integration tests pass. clippy: 0 errors.
This commit is contained in:
dailz
2026-06-28 14:22:45 +08:00
parent 2ac37a1dd1
commit a6560cff6c
3 changed files with 72 additions and 17 deletions
+51 -4
View File
@@ -192,6 +192,18 @@ impl Drop for AvHwFrameCtx {
}
}
/// Per-stage timing breakdown for one encode cycle on the hardware path.
/// Returned by [`EncState::encode_frame`] so callers can fold the numbers
/// into [`crate::stats::FrameTimings`]. `transfer_us` is always 0 on the HW
/// path because the frame stays on the GPU; the SW path's struct (if added
/// later) would carry a real readback measurement.
#[derive(Debug, Default, Clone, Copy)]
pub struct EncodeStages {
pub scale_us: u64,
pub transfer_us: u64,
pub encode_us: u64,
}
/// Test whether `drm_device` can import the PipeWire DMA-BUF frame via VAAPI.
pub fn test_dma_buf_import(drm_device: &Path, frame: &PwDmaBufFrame) -> Result<()> {
let hw_dev = AvHwDevCtx::new_vaapi(drm_device)?;
@@ -577,7 +589,7 @@ impl EncState {
&self.frames_rgb
}
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<()> {
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<EncodeStages> {
let mut filter_src_ctx = self
.video_filter
.get("in")
@@ -589,11 +601,18 @@ impl EncState {
.ok_or_else(|| anyhow::anyhow!("filter 'out' not found"))?;
let mut filter_sink = filter_sink_ctx.sink();
// Scale stage = filter graph push + pull (scale_vaapi for resolution
// change + format conversion to NV12). Timed separately from the
// actual avcodec_send_frame so the per-stage stats answer "where is
// latency?" honestly. See Oracle audit 2026-06-28 step 4.
let scale_start = Instant::now();
// SAFETY: hw_frame is a valid VAAPI hardware frame from capture.
filter_src
.add(hw_frame)
.map_err(|e| anyhow::anyhow!("Filter source add failed: {e}"))?;
let mut scale_us = 0u64;
let mut encode_us = 0u64;
loop {
let mut filtered = ff::frame::Video::empty();
match filter_sink.frame(&mut filtered) {
@@ -605,6 +624,11 @@ impl EncState {
Err(ff::Error::Other { errno }) if errno == ffi::EAGAIN => break,
Err(e) => bail!("Filter sink get frame failed: {e}"),
}
// First successful pull closes the scale-stage measurement; later
// pulls (rare extras) roll into encode time.
if scale_us == 0 {
scale_us = scale_start.elapsed().as_micros() as u64;
}
let pts = filtered.pts().unwrap_or(0);
if self.starting_timestamp.is_none() {
@@ -612,6 +636,7 @@ impl EncState {
}
let start_ts = self.starting_timestamp.unwrap();
let encode_start = Instant::now();
// SAFETY: avcodec_send_frame sends a valid NV12 VAAPI surface to the encoder.
let ret =
unsafe { ffi::avcodec_send_frame(self.enc_video.as_mut_ptr(), filtered.as_ptr()) };
@@ -619,9 +644,15 @@ impl EncState {
bail!("avcodec_send_frame failed: {}", ff_err(ret));
}
self.drain_encoder(start_ts)?;
encode_us += encode_start.elapsed().as_micros() as u64;
}
Ok(())
Ok(EncodeStages {
scale_us,
// HW path stays on GPU — no CPU readback, transfer is N/A.
transfer_us: 0,
encode_us,
})
}
pub fn flush(&mut self) -> Result<()> {
@@ -1562,9 +1593,25 @@ impl SwEncState {
self.import.frames_rgb()
}
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<()> {
pub fn encode_frame(&mut self, hw_frame: &ff::frame::Video) -> Result<EncodeStages> {
// SW path: import_and_scale bundles GPU filter graph (scale) + GPU→CPU
// readback (transfer) into one call. Timing them separately requires
// extending import_and_scale's signature; for now both roll into
// scale_us and transfer_us stays 0 with this comment as the honest
// statement. Oracle audit 2026-06-28 step 4.
let scale_start = Instant::now();
let cpu_frame = self.import.import_and_scale(hw_frame)?;
self.encode.encode_cpu_frame(&cpu_frame).map(|_| ())
let scale_us = scale_start.elapsed().as_micros() as u64;
let encode_start = Instant::now();
self.encode.encode_cpu_frame(&cpu_frame)?;
let encode_us = encode_start.elapsed().as_micros() as u64;
Ok(EncodeStages {
scale_us,
transfer_us: 0,
encode_us,
})
}
pub fn flush(&mut self) -> Result<()> {
+12 -5
View File
@@ -126,7 +126,7 @@ impl StreamingEncoder {
}
}
fn encode_frame(&mut self, hw_frame: &ffmpeg_next::frame::Video) -> anyhow::Result<()> {
fn encode_frame(&mut self, hw_frame: &ffmpeg_next::frame::Video) -> anyhow::Result<crate::avhw::EncodeStages> {
match self {
StreamingEncoder::Mp4(enc) => enc.encode_frame(hw_frame),
StreamingEncoder::WebRtc(enc) => enc.encode_frame(hw_frame),
@@ -626,16 +626,23 @@ impl<S: CaptureSource> State<S> {
};
if should_encode {
let encode_start = Instant::now();
if let Err(e) = enc.encode_frame(&surface) {
tracing::error!("encode_frame failed: {}", e);
self.errored = true;
}
match enc.encode_frame(&surface) {
Ok(stages) => {
let encode_elapsed = encode_start.elapsed().as_micros() as u64;
self.stats.record_encode(&FrameTimings {
scale_us: stages.scale_us,
transfer_us: stages.transfer_us,
encode_us: stages.encode_us,
total_us: encode_elapsed,
..Default::default()
});
}
Err(e) => {
tracing::error!("encode_frame failed: {}", e);
self.errored = true;
}
}
}
self.stats_frames += 1;
if let Some(last) = self.stats_last_time {
if last.elapsed() >= std::time::Duration::from_secs(10) {
+5 -4
View File
@@ -494,7 +494,6 @@ impl StatePortal {
}?;
let import_us = t_import_start.elapsed().as_micros() as u64;
let t_encode_start = Instant::now();
// 设置帧的显示时间戳(PTS),基于已编码帧序号
// SAFETY: vaapi_frame is the freshly-imported valid AVFrame returned by
@@ -504,15 +503,17 @@ impl StatePortal {
}
// 送入编码器完成:缩放 → 回读 → 格式转换 → H.264 编码
enc.encode_frame(&vaapi_frame)?;
let stages = enc.encode_frame(&vaapi_frame)?;
let total_us = t_import_start.elapsed().as_micros() as u64;
let encode_us = t_encode_start.elapsed().as_micros() as u64;
let encode_us = stages.encode_us;
self.frames_encoded += 1;
// 记录帧计时到管道统计(import + encode 内部各阶段暂不可分离,用 total 覆盖
// 记录帧计时到管道统计(scale 来自 filter graphtransfer 在 HW 路径恒为 0
let timings = FrameTimings {
import_us,
scale_us: stages.scale_us,
transfer_us: stages.transfer_us,
encode_us,
total_us,
..Default::default()