Skip to content

Commit 0b5462a

Browse files
authored
local video example render timing improvements (#1276)
- improve the accuracy of the latency measurements on the local_video subscriber example. - measure both frame render request & frame render completion timestamp.
1 parent 7a10c63 commit 0b5462a

3 files changed

Lines changed: 385 additions & 111 deletions

File tree

examples/local_video/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ Subscriber flags (in addition to the common connection flags above):
182182
- `--display-timestamp`: Show detailed frame ID, publisher timestamp, subscriber timing stages, and end-to-end latency in the separate diagnostics window. Timestamp fields require the publisher to use `--attach-timestamp`; frame ID requires `--attach-frame-id`.
183183
- `--e2ee-key <key>`: Enable end-to-end decryption with the given shared key. Must match the key used by the publisher.
184184

185+
The subscriber reports two render boundaries. `frame draw encoded` is the CPU time immediately after the WGPU draw command is recorded; it does not mean that the command has been submitted or executed. `frame GPU complete` is when the subscriber observes completion of the GPU submission containing that draw. This measurement does not include surface presentation, compositor queuing, display scanout, or physical pixel illumination, so use an OS presentation API or optical measurement when those later boundaries matter. Exposure-to-GPU measurements across different publisher and subscriber hosts also require synchronized system clocks (for example, NTP or PTP).
186+
185187
Notes:
186188
- If the active video track is unsubscribed or unpublished, the app clears its state and will automatically attach to the next matching video track when it appears.
187189
- For E2EE to work, both publisher and subscriber must specify the same `--e2ee-key` value. If the keys don't match, the subscriber will not be able to decode the video.

examples/local_video/src/subscriber.rs

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use livekit::prelude::*;
1010
use livekit::webrtc::video_frame::BoxVideoFrame;
1111
use livekit::webrtc::video_stream::native::NativeVideoStream;
1212
use livekit_api::access_token;
13-
use log::{debug, info};
14-
use parking_lot::Mutex;
13+
use log::{debug, info, warn};
14+
use parking_lot::{Condvar, Mutex};
1515
use std::{
1616
collections::{HashMap, VecDeque},
1717
env,
@@ -20,6 +20,7 @@ use std::{
2020
atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering},
2121
Arc,
2222
},
23+
thread::{self, JoinHandle},
2324
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
2425
};
2526

@@ -475,14 +476,136 @@ impl AtomicVideoSize {
475476
}
476477
}
477478

478-
/// Carried from prepare into the WGPU paint callback to stamp the paint boundary.
479+
/// Carried from prepare into the WGPU paint callback to stamp render boundaries.
479480
#[derive(Clone, Copy, Debug)]
480481
struct PendingPaintSample {
481482
frame_id: Option<u32>,
482483
capture_timestamp_us: u64,
483484
prepare_timestamp_us: u64,
484485
}
485486

487+
const GPU_POLL_TIMEOUT: Duration = Duration::from_millis(5);
488+
const GPU_POLL_QUEUE_EMPTY_RETRY_DELAY: Duration = Duration::from_micros(100);
489+
490+
#[derive(Default)]
491+
struct GpuCompletionPollState {
492+
pending: usize,
493+
shutdown: bool,
494+
}
495+
496+
#[derive(Default)]
497+
struct GpuCompletionPollShared {
498+
state: Mutex<GpuCompletionPollState>,
499+
wake: Condvar,
500+
}
501+
502+
impl GpuCompletionPollShared {
503+
fn begin_probe(self: &Arc<Self>) -> GpuCompletionProbe {
504+
self.state.lock().pending += 1;
505+
self.wake.notify_one();
506+
GpuCompletionProbe { shared: Some(self.clone()) }
507+
}
508+
509+
fn request_shutdown(&self) {
510+
self.state.lock().shutdown = true;
511+
self.wake.notify_one();
512+
}
513+
}
514+
515+
struct GpuCompletionProbe {
516+
shared: Option<Arc<GpuCompletionPollShared>>,
517+
}
518+
519+
impl GpuCompletionProbe {
520+
fn inactive() -> Self {
521+
Self { shared: None }
522+
}
523+
}
524+
525+
impl Drop for GpuCompletionProbe {
526+
fn drop(&mut self) {
527+
let Some(shared) = self.shared.take() else {
528+
return;
529+
};
530+
531+
let mut state = shared.state.lock();
532+
state.pending = state.pending.saturating_sub(1);
533+
if state.pending == 0 {
534+
shared.wake.notify_one();
535+
}
536+
}
537+
}
538+
539+
struct GpuCompletionPoller {
540+
shared: Arc<GpuCompletionPollShared>,
541+
worker: Option<JoinHandle<()>>,
542+
}
543+
544+
impl GpuCompletionPoller {
545+
fn new(device: wgpu::Device) -> Self {
546+
let shared = Arc::new(GpuCompletionPollShared::default());
547+
let worker_shared = shared.clone();
548+
let worker = thread::Builder::new()
549+
.name("local-video-gpu-completion".to_string())
550+
.spawn(move || poll_gpu_completions(device, worker_shared))
551+
.map_err(|err| warn!("Unable to start GPU-completion polling thread: {err}"))
552+
.ok();
553+
Self { shared, worker }
554+
}
555+
556+
fn begin_probe(&self) -> GpuCompletionProbe {
557+
if self.worker.is_some() {
558+
self.shared.begin_probe()
559+
} else {
560+
GpuCompletionProbe::inactive()
561+
}
562+
}
563+
}
564+
565+
impl Drop for GpuCompletionPoller {
566+
fn drop(&mut self) {
567+
self.shared.request_shutdown();
568+
if let Some(worker) = self.worker.take() {
569+
if worker.join().is_err() {
570+
warn!("GPU-completion polling thread panicked during shutdown");
571+
}
572+
}
573+
}
574+
}
575+
576+
fn poll_gpu_completions(device: wgpu::Device, shared: Arc<GpuCompletionPollShared>) {
577+
let mut poll_error_logged = false;
578+
loop {
579+
{
580+
let mut state = shared.state.lock();
581+
while state.pending == 0 && !state.shutdown {
582+
shared.wake.wait(&mut state);
583+
}
584+
if state.shutdown {
585+
return;
586+
}
587+
}
588+
589+
let result = device
590+
.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(GPU_POLL_TIMEOUT) });
591+
match result {
592+
Ok(status) if status.is_queue_empty() => {
593+
// The paint callback runs just before eframe submits. If the worker wins
594+
// that race, briefly back off and wait for the containing submission.
595+
thread::sleep(GPU_POLL_QUEUE_EMPTY_RETRY_DELAY);
596+
}
597+
Ok(_) | Err(wgpu::PollError::Timeout) => {}
598+
Err(err) => {
599+
if !poll_error_logged {
600+
warn!("Unable to poll GPU completion: {err}");
601+
poll_error_logged = true;
602+
}
603+
thread::sleep(GPU_POLL_TIMEOUT);
604+
}
605+
}
606+
}
607+
}
608+
486609
struct PendingPaintSampleSlot {
487610
capture_timestamp_us: AtomicU64,
488611
prepare_timestamp_us: AtomicU64,
@@ -835,6 +958,22 @@ mod tests {
835958
assert!(low_latency_args.low_latency);
836959
}
837960

961+
#[test]
962+
fn gpu_completion_poll_state_tracks_probes_and_shutdown() {
963+
let shared = Arc::new(GpuCompletionPollShared::default());
964+
let first = shared.begin_probe();
965+
let second = shared.begin_probe();
966+
assert_eq!(shared.state.lock().pending, 2);
967+
968+
drop(first);
969+
assert_eq!(shared.state.lock().pending, 1);
970+
drop(second);
971+
assert_eq!(shared.state.lock().pending, 0);
972+
973+
shared.request_shutdown();
974+
assert!(shared.state.lock().shutdown);
975+
}
976+
838977
#[test]
839978
fn subscriber_diagnostics_show_status_without_timing() {
840979
let shared = Arc::new(Mutex::new(SharedYuv {
@@ -1734,6 +1873,7 @@ struct YuvGpuState {
17341873
dims: (u32, u32),
17351874
yuv_layout: u32,
17361875
pending_paint_sample: PendingPaintSampleSlot,
1876+
gpu_completion_poller: GpuCompletionPoller,
17371877
cpu_upload_logged: bool,
17381878
#[cfg(target_os = "macos")]
17391879
native_resources: Option<macos_native_video::NativeFrameResources>,
@@ -2025,6 +2165,7 @@ impl CallbackTrait for YuvPaintCallback {
20252165
dims: (0, 0),
20262166
yuv_layout: 0,
20272167
pending_paint_sample: PendingPaintSampleSlot::new(),
2168+
gpu_completion_poller: GpuCompletionPoller::new(device.clone()),
20282169
cpu_upload_logged: false,
20292170
#[cfg(target_os = "macos")]
20302171
native_resources: None,
@@ -2271,19 +2412,26 @@ impl CallbackTrait for YuvPaintCallback {
22712412
return;
22722413
}
22732414

2274-
let painted_sample = state.pending_paint_sample.take();
2415+
let draw_sample = state.pending_paint_sample.take();
22752416

22762417
render_pass.set_pipeline(&state.pipeline);
22772418
render_pass.set_bind_group(0, &state.bind_group, &[]);
22782419
render_pass.draw(0..3, 0..1);
22792420

2280-
if let Some(sample) = painted_sample {
2281-
self.subscriber_timing.record_frame_painted(
2421+
if let Some(sample) = draw_sample {
2422+
let completion_token = self.subscriber_timing.record_frame_draw_encoded(
22822423
sample.capture_timestamp_us,
22832424
sample.frame_id,
22842425
sample.prepare_timestamp_us,
22852426
current_timestamp_us(),
22862427
);
2428+
let completion_probe = state.gpu_completion_poller.begin_probe();
2429+
let subscriber_timing = self.subscriber_timing.clone();
2430+
render_pass.on_submitted_work_done(move || {
2431+
subscriber_timing
2432+
.record_frame_gpu_complete(completion_token, current_timestamp_us());
2433+
drop(completion_probe);
2434+
});
22872435
}
22882436
}
22892437
}

0 commit comments

Comments
 (0)