feat(stats): expose duplicate_frames_skipped counter (closes #20)

Final piece of #20. The EncodeOutcome::SkippedDuplicate variant was
introduced in #19 but its count was invisible — silent Ok(_) arm in
encode_thread_loop. Now exposed as a stat.

Changes:

- stats.rs: PipelineStats gains duplicate_frames_skipped (window delta)
  and prev_duplicate_frames_skipped (running total). Snapshot field
  added. Display format places it after over_budget (both are counters).
  Reset clears window delta but preserves running total (same pattern
  as pipewire_dropped).

- state_portal.rs: EncodeThread struct gains duplicate_count:
  Arc<AtomicU64>. Cloned for encode_thread_loop, stored for main-thread
  reads. encode_thread_loop now explicitly matches SkippedDuplicate and
  increments with Ordering::Relaxed. Stats snapshot code reads atomic
  and calls set_duplicate_frames_skipped after timing drain.

What this enables:

Diagnosing encoded_fps health. Examples:
  - capture_fps=60 encoded_fps=30 duplicate_frames_skipped=30
    → healthy: encoder at 30fps target, 30 frames were true duplicates
  - capture_fps=60 encoded_fps=5 duplicate_frames_skipped=0
    → problem: frames not being dedup'd but encoder can't keep up
  - capture_fps=1.7 encoded_fps=1.7 duplicate_frames_skipped=0
    → healthy static: low fps because KWin damage-driven delivery

Original #20 issues status:
  - 'encoded_fps stuck at ~30': FIXED via #19 (EncodeOutcome enum), now
    tracks capture_fps when below 30
  - 'filler masking real fps': FIXED via #15/#18 (filler deleted)
  - 'duplicate count invisible': FIXED via this commit
  - 'unique_encoded_fps / delivered_fps': not implemented, deemed
    unnecessary now that the core metrics are trustworthy

Tests:
- cargo build --release: 0 new warnings (19 baseline preserved)
- cargo test: 96 lib + 3 integration, 0 failed
- SAFETY comments preserved verbatim
- 2 files changed, +45/-6 lines
This commit is contained in:
dailz
2026-06-21 10:04:41 +08:00
parent 631934458c
commit a06a41f5f2
2 changed files with 45 additions and 6 deletions
+24 -4
View File
@@ -34,6 +34,7 @@ struct EncodeThread {
handle: Option<std::thread::JoinHandle<()>>,
input_tx: crossbeam_channel::Sender<CpuNv12Frame>,
timing_rx: crossbeam_channel::Receiver<EncodeThreadTiming>,
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
}
struct WebrtcThread {
@@ -244,14 +245,26 @@ impl StatePortal {
bitrate_rx,
encoder_resolution_rx,
)?;
let duplicate_count = std::sync::Arc::new(
std::sync::atomic::AtomicU64::new(0),
);
let duplicate_count_for_thread = duplicate_count.clone();
let handle = std::thread::Builder::new()
.name("wl-webrtc-encode".into())
.spawn(move || encode_thread_loop(encode, input_rx, timing_tx))?;
.spawn(move || {
encode_thread_loop(
encode,
input_rx,
timing_tx,
duplicate_count_for_thread,
)
})?;
self.enc_import = Some(import);
self.enc_thread = Some(EncodeThread {
handle: Some(handle),
input_tx,
timing_rx,
duplicate_count,
});
let wrtc = self.webrtc.take().ok_or_else(|| {
@@ -331,6 +344,11 @@ impl StatePortal {
timing.output_bytes,
);
}
// Read duplicate counter (delta computed in setter)
let total = enc_thread
.duplicate_count
.load(std::sync::atomic::Ordering::Relaxed);
self.stats.set_duplicate_frames_skipped(total);
}
if let Some(ref webrtc_thread) = self.webrtc_thread {
while let Ok((gap_ms, age_ms)) = webrtc_thread.sent_gap_rx.try_recv() {
@@ -634,6 +652,7 @@ fn encode_thread_loop(
mut encode: SwEncEncode,
input_rx: crossbeam_channel::Receiver<CpuNv12Frame>,
timing_tx: crossbeam_channel::Sender<EncodeThreadTiming>,
duplicate_count: std::sync::Arc<std::sync::atomic::AtomicU64>,
) {
loop {
match input_rx.recv() {
@@ -647,10 +666,11 @@ fn encode_thread_loop(
output_bytes: t.output_bytes,
});
}
Ok(EncodeOutcome::SkippedDuplicate) => {
duplicate_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(_) => {
// SkippedPaused / SkippedDisconnected / SkippedDuplicate
// Do not report timing; do not tick encoded_fps.
// take_timing() intentionally NOT called — last_timing stays default.
// SkippedPaused / SkippedDisconnected — no counter needed
}
Err(e) => {
tracing::error!("Encode thread error: {e}");
+20 -1
View File
@@ -39,6 +39,12 @@ pub struct PipelineStats {
sent_frames: u64,
pipewire_dropped: u64,
over_budget_count: u64,
/// Count of frames dropped by encode thread due to Y-plane hash dedup
/// (EncodeOutcome::SkippedDuplicate). Read from atomic counter set by
/// encode thread, computed as delta since previous snapshot.
duplicate_frames_skipped: u64,
// Running total from the encode-thread atomic; NOT reset between windows.
prev_duplicate_frames_skipped: u64,
// --- queue depth at last observation ---
capture_queue_depth: usize,
@@ -75,6 +81,8 @@ impl PipelineStats {
sent_frames: 0,
pipewire_dropped: 0,
over_budget_count: 0,
duplicate_frames_skipped: 0,
prev_duplicate_frames_skipped: 0,
capture_queue_depth: 0,
encoded_queue_depth: 0,
capture_gaps_ms: Vec::new(),
@@ -186,6 +194,13 @@ impl PipelineStats {
self.pipewire_dropped = total_dropped.saturating_sub(prev_dropped);
}
/// Update duplicate frames skipped counter (absolute value from atomic).
/// Computes delta from previous value, like set_pipewire_dropped.
pub fn set_duplicate_frames_skipped(&mut self, total_skipped: u64) {
self.duplicate_frames_skipped = total_skipped.saturating_sub(self.prev_duplicate_frames_skipped);
self.prev_duplicate_frames_skipped = total_skipped;
}
/// Update queue depth observations.
pub fn set_queue_depths(&mut self, capture: usize, encoded: usize) {
self.capture_queue_depth = capture;
@@ -216,6 +231,7 @@ impl PipelineStats {
sent_frames: self.sent_frames,
pipewire_dropped: self.pipewire_dropped,
over_budget_count: self.over_budget_count,
duplicate_frames_skipped: self.duplicate_frames_skipped,
capture_queue_depth: self.capture_queue_depth,
encoded_queue_depth: self.encoded_queue_depth,
capture_gap_avg_ms: avg_f64(&self.capture_gaps_ms),
@@ -254,6 +270,7 @@ impl PipelineStats {
self.sent_frames = 0;
self.pipewire_dropped = 0;
self.over_budget_count = 0;
self.duplicate_frames_skipped = 0;
self.capture_queue_depth = 0;
self.encoded_queue_depth = 0;
self.capture_gaps_ms.clear();
@@ -288,6 +305,7 @@ pub struct StatsSnapshot {
pub sent_frames: u64,
pub pipewire_dropped: u64,
pub over_budget_count: u64,
pub duplicate_frames_skipped: u64,
// Queue depths
pub capture_queue_depth: usize,
pub encoded_queue_depth: usize,
@@ -331,7 +349,7 @@ impl std::fmt::Display for StatsSnapshot {
write!(
f,
"capture_fps={:.1} encoded_fps={:.1} sent_fps={:.1} \
pw_dropped={} over_budget={} \
pw_dropped={} over_budget={} duplicate_frames_skipped={} \
cap_q={} enc_q={} \
cap_gap_p95={:.1}ms cap_gap_max={:.1}ms \
enc_gap_p95={:.1}ms enc_gap_max={:.1}ms \
@@ -346,6 +364,7 @@ impl std::fmt::Display for StatsSnapshot {
self.sent_fps,
self.pipewire_dropped,
self.over_budget_count,
self.duplicate_frames_skipped,
self.capture_queue_depth,
self.encoded_queue_depth,
self.capture_gap_p95_ms,