From b10348f9a61d07cc75e627cdec82b5f54f2c543a Mon Sep 17 00:00:00 2001 From: Wraient Date: Tue, 13 Oct 2026 10:36:45 +0000 Subject: [PATCH 1/7] improve: reuse GPU layer pipelines across export preview frames; coalesce export progress updates Preview frames rebuilt RendererLayers (full shader/pipeline compilation, ~0.25-0.5s) on every scrub. Preview now takes pooled layers via the same LAYERS_POOL mechanism the export path uses, keyed per project, returning them after a clean render. The three export progress drain loops issued a full model update + cx.notify() per progress message (~60/s) while only the latest value survives each 50ms wake. They now drain to the latest message and update once per wake: 3x fewer render-tree rebuilds, identical visible values. Benchmarks: crates/export/examples/preview-layers-benchmark.rs, apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs --- .../export-progress-coalesce-benchmark.rs | 128 ++++++++++++++++++ apps/desktop-gpui/src/editor_export.rs | 12 ++ .../examples/preview-layers-benchmark.rs | 73 ++++++++++ crates/export/src/preview.rs | 23 +++- crates/rendering/src/lib.rs | 25 +++- 5 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs create mode 100644 crates/export/examples/preview-layers-benchmark.rs diff --git a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs new file mode 100644 index 00000000000..5e705a96806 --- /dev/null +++ b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs @@ -0,0 +1,128 @@ +//! Export-progress drain benchmark: per-message model updates against +//! drain-to-latest coalescing, on the exact channel pattern used by the +//! export UI in `src/editor_export.rs`. +//! +//! "old" reproduces the previous drain loop: one `update` + `cx.notify()` +//! (a full editor render-tree rebuild) per progress message received. +//! "new" is the replacement: drain to the latest message, single update + +//! notify per 50ms wake. The producer sends at 60 msg/s for 5s in both. +//! +//! The mock update body mirrors the real one (field writes + the +//! `"{} / {} frames"` label); `cx.notify()` itself is not measurable here, +//! so the headline metric is update invocations: every avoided update is one +//! avoided render-tree rebuild while the machine is encode-bound. +//! +//! Run: cargo run -p cap-desktop-gpui --example export-progress-coalesce-benchmark --release + +use std::hint::black_box; +use std::time::{Duration, Instant}; + +const MESSAGE_RATE_PER_SEC: u64 = 60; +const CONSUMER_CADENCE_MS: u64 = 50; +const RUN_SECS: u64 = 5; + +#[derive(Default)] +struct MockExportUi { + rendered: u32, + total_frames: u32, + phase: u8, + label: String, +} + +#[inline(never)] +fn mock_update(ui: &mut MockExportUi, rendered: u32, total: u32) { + ui.rendered = rendered; + ui.total_frames = total; + ui.phase = 1; + ui.label = format!("{rendered} / {total} frames"); + black_box(&ui.label); +} + +fn report(label: &str, samples: &mut [f64]) { + samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN timings")); + let n = samples.len() as f64; + let mean = samples.iter().sum::() / n; + let p50 = samples[(n * 0.50) as usize]; + let p95 = samples[(n * 0.95) as usize]; + println!("{label}: n={} mean={mean:.2}us p50={p50:.2}us p95={p95:.2}us", samples.len()); +} + +fn spawn_producer() -> flume::Receiver<(u32, u32)> { + let (tx, rx) = flume::unbounded::<(u32, u32)>(); + let total = (MESSAGE_RATE_PER_SEC * RUN_SECS) as u32; + std::thread::spawn(move || { + let interval = Duration::from_nanos(1_000_000_000 / MESSAGE_RATE_PER_SEC); + for i in 1..=total { + let _ = tx.send((i.min(total), total)); + std::thread::sleep(interval); + } + }); + rx +} + +fn run_old() -> (u64, Vec, (u32, u32)) { + let rx = spawn_producer(); + let mut ui = MockExportUi::default(); + let mut updates = 0; + let mut costs = Vec::new(); + loop { + while let Ok((rendered, total)) = rx.try_recv() { + let started = Instant::now(); + mock_update(&mut ui, rendered, total); + black_box(&mut ui); + updates += 1; + costs.push(started.elapsed().as_secs_f64() * 1e6); + } + if rx.is_disconnected() { + break; + } + std::thread::sleep(Duration::from_millis(CONSUMER_CADENCE_MS)); + } + (updates, costs, (ui.rendered, ui.total_frames)) +} + +fn run_new() -> (u64, Vec, (u32, u32)) { + let rx = spawn_producer(); + let mut ui = MockExportUi::default(); + let mut updates = 0; + let mut costs = Vec::new(); + loop { + let mut latest: Option<(u32, u32)> = None; + while let Ok((rendered, total)) = rx.try_recv() { + latest = Some((rendered, total)); + } + if let Some((rendered, total)) = latest { + let started = Instant::now(); + mock_update(&mut ui, rendered, total); + black_box(&mut ui); + updates += 1; + costs.push(started.elapsed().as_secs_f64() * 1e6); + } + if rx.is_disconnected() { + break; + } + std::thread::sleep(Duration::from_millis(CONSUMER_CADENCE_MS)); + } + (updates, costs, (ui.rendered, ui.total_frames)) +} + +fn main() { + println!( + "export progress drain: {MESSAGE_RATE_PER_SEC} msg/s for {RUN_SECS}s, consumer every {CONSUMER_CADENCE_MS}ms" + ); + let (old_updates, mut old_costs, old_final) = run_old(); + let (new_updates, mut new_costs, new_final) = run_new(); + + assert_eq!(old_final, new_final, "visible end state must match"); + assert_eq!(old_final.0, old_final.1, "progress must reach 100%"); + + println!("old updates: {old_updates}"); + println!("new updates: {new_updates}"); + println!( + "update reduction: {:.1}x (each avoided update skips a full render-tree rebuild)", + old_updates as f64 / new_updates as f64 + ); + println!("final UI state old={old_final:?} new={new_final:?} (identical)"); + report("old per-update cost", &mut old_costs); + report("new per-update cost", &mut new_costs); +} diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index abbf6d98e15..5b7c3ee9373 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -896,7 +896,11 @@ impl EditorWindow { }); loop { + let mut latest: Option<(u32, u32)> = None; while let Ok((rendered, total)) = progress_rx.try_recv() { + latest = Some((rendered, total)); + } + if let Some((rendered, total)) = latest { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { ui.rendered = rendered; @@ -1174,7 +1178,11 @@ impl EditorWindow { }); loop { + let mut latest: Option<(u32, u32)> = None; while let Ok((rendered, total)) = progress_rx.try_recv() { + latest = Some((rendered, total)); + } + if let Some((rendered, total)) = latest { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { ui.rendered = rendered; @@ -1219,7 +1227,11 @@ impl EditorWindow { }); loop { + let mut latest: Option = None; while let Ok(progress) = upload_rx.try_recv() { + latest = Some(progress); + } + if let Some(progress) = latest { let _ = this.update(cx, |this, cx| { if let Some(ui) = this.export.as_mut() { ui.upload_progress = progress as f32; diff --git a/crates/export/examples/preview-layers-benchmark.rs b/crates/export/examples/preview-layers-benchmark.rs new file mode 100644 index 00000000000..800e3268172 --- /dev/null +++ b/crates/export/examples/preview-layers-benchmark.rs @@ -0,0 +1,73 @@ +//! Cost of acquiring GPU layer pipelines for one export-preview frame. +//! +//! Baseline mirrors what `render_preview_frame` did before pooling: +//! `RendererLayers::new_with_options` on every frame, paying full +//! shader/pipeline compilation each time. Improved measures the +//! `take_reusable_layers` / `return_reusable_layers` round trip used now. +//! +//! Run: `cargo run --release -p cap-export --example preview-layers-benchmark` +//! Needs a wgpu adapter; the software fallback is fine, the cost under test +//! is pipeline compilation, not rasterization. + +use std::time::Instant; + +use cap_rendering::{ + RendererLayers, create_shared_device, return_reusable_layers, take_reusable_layers, +}; + +const ITERATIONS: usize = 5; +const KEY: &str = "preview-layers-benchmark"; + +fn report(label: &str, samples: &mut [f64]) { + samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN timings")); + let n = samples.len() as f64; + let mean = samples.iter().sum::() / n; + let p50 = samples[(n * 0.5) as usize]; + let max = samples[samples.len() - 1]; + println!( + "{label}: n={} mean={mean:.1}ms p50={p50:.1}ms max={max:.1}ms", + samples.len() + ); +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let shared = create_shared_device().await?; + + let mut baseline_ms = Vec::with_capacity(ITERATIONS); + for _ in 0..ITERATIONS { + let started = Instant::now(); + let layers = RendererLayers::new_with_options( + &shared.device, + &shared.queue, + shared.is_software_adapter, + ); + baseline_ms.push(started.elapsed().as_secs_f64() * 1000.0); + drop(layers); + } + + return_reusable_layers( + shared.device.clone(), + KEY.to_string(), + RendererLayers::new_with_options(&shared.device, &shared.queue, shared.is_software_adapter), + ); + + let mut pooled_ms = Vec::with_capacity(ITERATIONS); + for _ in 0..ITERATIONS { + let started = Instant::now(); + let layers = take_reusable_layers(&shared.device, KEY).expect("pool primed above"); + pooled_ms.push(started.elapsed().as_secs_f64() * 1000.0); + return_reusable_layers(shared.device.clone(), KEY.to_string(), layers); + } + + report("baseline build layers per frame", &mut baseline_ms); + report("pooled take layers per frame ", &mut pooled_ms); + let base_mean = baseline_ms.iter().sum::() / baseline_ms.len() as f64; + let pool_mean = pooled_ms.iter().sum::() / pooled_ms.len() as f64; + println!( + "per-frame saving: {saved:.1}ms ({reduction:.1}x)", + saved = base_mean - pool_mean, + reduction = base_mean / pool_mean.max(0.001), + ); + Ok(()) +} diff --git a/crates/export/src/preview.rs b/crates/export/src/preview.rs index 45befeaa688..567ee0e901b 100644 --- a/crates/export/src/preview.rs +++ b/crates/export/src/preview.rs @@ -100,6 +100,7 @@ struct PreviewSource<'a> { render_constants: &'a RenderVideoConstants, segments: Vec>, total_duration: f64, + layers_reuse_key: Option, } struct PreviewSegment<'a> { @@ -130,6 +131,7 @@ async fn render_preview_with_base( }) .collect(), total_duration, + layers_reuse_key: None, }; render_preview_frame(source, frame_time, settings).await } @@ -184,6 +186,7 @@ pub async fn render_preview_with_editor( }) .collect(), total_duration, + layers_reuse_key: Some(format!("preview:{}", editor.project_path.display())), }; render_preview_frame(source, frame_time, settings).await } @@ -219,6 +222,7 @@ async fn render_preview_frame( render_constants, segments, total_duration, + layers_reuse_key, } = source; let transition_mapping = project_config.timeline.as_ref().and_then(|timeline| { if timeline.transitions.is_empty() { @@ -285,11 +289,16 @@ async fn render_preview_frame( ); let mut frame_renderer = FrameRenderer::new(render_constants); - let mut layers = RendererLayers::new_with_options( - &render_constants.device, - &render_constants.queue, - render_constants.is_software_adapter, - ); + let mut layers = layers_reuse_key + .as_deref() + .and_then(|key| cap_rendering::take_reusable_layers(&render_constants.device, key)) + .unwrap_or_else(|| { + RendererLayers::new_with_options( + &render_constants.device, + &render_constants.queue, + render_constants.is_software_adapter, + ) + }); let frame = if let Some((outgoing, kind, progress)) = transition_mapping { let outgoing_media = segments @@ -387,6 +396,10 @@ async fn render_preview_frame( (total_bitrate * encoder_efficiency * duration_seconds) / (8.0 * 1024.0 * 1024.0) }; + if let Some(key) = layers_reuse_key { + cap_rendering::return_reusable_layers(render_constants.device.clone(), key, layers); + } + Ok(ExportPreviewResult { jpeg_base64: STANDARD.encode(&jpeg_buffer), estimated_size_mb, diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 05758d1d9e2..59759c6747d 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1155,6 +1155,29 @@ fn take_pooled_layers(device: &wgpu::Device, key: &str) -> Option Option { + take_pooled_layers(device, key).map(|mut layers| { + layers.reset_frame_state(); + layers + }) +} + +/// Return layer pipelines to the pool so the next render with the same `key` +/// on the same device can skip pipeline compilation. Only call after a clean +/// render: a failed render may leave layer state behind. +pub fn return_reusable_layers(device: wgpu::Device, key: String, layers: RendererLayers) { + return_pooled_layers(device, key, layers); +} + /// Builds a fresh set of layers for the next project's first render, off /// its critical path (an idle engine calls this between tasks). pub fn prebuild_spare_layers( @@ -7085,7 +7108,7 @@ fn join_layer(handle: std::thread::ScopedJoinHandle<'_, T>) -> T { } impl RendererLayers { - fn reset_frame_state(&mut self) { + pub fn reset_frame_state(&mut self) { self.display.reset_frame_state(); self.camera.reset_frame_state(); self.camera_only.reset_frame_state(); From 89a68d68c6c2cc2e3ee788c52bc3746b353efc0b Mon Sep 17 00:00:00 2001 From: Wraient Date: Tue, 13 Oct 2026 10:57:28 +0000 Subject: [PATCH 2/7] improve: clear stale cursor bind group on pooled layers; trim docs, honest benchmark Greptile review follow-ups: - CursorLayer::prepare returned early without clearing bind_group when interpolated_cursor was None or the cursor texture failed to load. Harmless with fresh layers per frame, but pooled layers would draw a stale cursor after scrubbing to a cursorless clip. Now cleared on both paths. - Trimmed take_reusable_layers docs to the non-obvious pool-key invariant per AGENTS.md. - Benchmark now states exactly what it measures (update invocations) vs what it does not (cx.notify render cost). --- .../export-progress-coalesce-benchmark.rs | 17 ++++++++--------- crates/rendering/src/layers/cursor.rs | 2 ++ crates/rendering/src/lib.rs | 16 ++++------------ 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs index 5e705a96806..e78675a2dd7 100644 --- a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs +++ b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs @@ -2,15 +2,14 @@ //! drain-to-latest coalescing, on the exact channel pattern used by the //! export UI in `src/editor_export.rs`. //! -//! "old" reproduces the previous drain loop: one `update` + `cx.notify()` -//! (a full editor render-tree rebuild) per progress message received. -//! "new" is the replacement: drain to the latest message, single update + -//! notify per 50ms wake. The producer sends at 60 msg/s for 5s in both. +//! "old" reproduces the previous drain loop: one model update per progress +//! message received. "new" is the replacement: drain to the latest message, +//! single update per 50ms wake. The producer sends at 60 msg/s for 5s in both. //! -//! The mock update body mirrors the real one (field writes + the -//! `"{} / {} frames"` label); `cx.notify()` itself is not measurable here, -//! so the headline metric is update invocations: every avoided update is one -//! avoided render-tree rebuild while the machine is encode-bound. +//! What is measured: update invocations (exact) and the mock update body +//! cost (field writes + the `"{} / {} frames"` label). What is not measured: +//! `cx.notify()`'s render-tree rebuild, which runs once per update in +//! production and is the reason fewer updates matter. //! //! Run: cargo run -p cap-desktop-gpui --example export-progress-coalesce-benchmark --release @@ -119,7 +118,7 @@ fn main() { println!("old updates: {old_updates}"); println!("new updates: {new_updates}"); println!( - "update reduction: {:.1}x (each avoided update skips a full render-tree rebuild)", + "update reduction: {:.1}x", old_updates as f64 / new_updates as f64 ); println!("final UI state old={old_final:?} new={new_final:?} (identical)"); diff --git a/crates/rendering/src/layers/cursor.rs b/crates/rendering/src/layers/cursor.rs index a2d16dd9910..abfb3ac7025 100644 --- a/crates/rendering/src/layers/cursor.rs +++ b/crates/rendering/src/layers/cursor.rs @@ -453,6 +453,7 @@ impl CursorLayer { let time_s = segment_frames.recording_time; let Some(interpolated_cursor) = &uniforms.interpolated_cursor else { + self.bind_group = None; return; }; @@ -562,6 +563,7 @@ impl CursorLayer { } let Some(tex) = self.cursors.get(&interpolated_cursor.cursor_id) else { error!("Cursor {:?} not found!", interpolated_cursor.cursor_id); + self.bind_group = None; return; }; tex diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 59759c6747d..3341b098df2 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1155,15 +1155,8 @@ fn take_pooled_layers(device: &wgpu::Device, key: &str) -> Option Option { take_pooled_layers(device, key).map(|mut layers| { layers.reset_frame_state(); @@ -1171,9 +1164,8 @@ pub fn take_reusable_layers(device: &wgpu::Device, key: &str) -> Option Date: Wed, 14 Oct 2026 09:00:42 +0000 Subject: [PATCH 3/7] improve: trim benchmark headers to measurement-only per AGENTS.md --- .../examples/export-progress-coalesce-benchmark.rs | 14 +++----------- crates/export/examples/preview-layers-benchmark.rs | 10 ++-------- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs index e78675a2dd7..ff738ecfdce 100644 --- a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs +++ b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs @@ -1,15 +1,7 @@ -//! Export-progress drain benchmark: per-message model updates against -//! drain-to-latest coalescing, on the exact channel pattern used by the -//! export UI in `src/editor_export.rs`. +//! Model-update counts in the export-progress drain loop, at 60 msg/s with a +//! 50 ms consumer over 5 s. //! -//! "old" reproduces the previous drain loop: one model update per progress -//! message received. "new" is the replacement: drain to the latest message, -//! single update per 50ms wake. The producer sends at 60 msg/s for 5s in both. -//! -//! What is measured: update invocations (exact) and the mock update body -//! cost (field writes + the `"{} / {} frames"` label). What is not measured: -//! `cx.notify()`'s render-tree rebuild, which runs once per update in -//! production and is the reason fewer updates matter. +//! Measures update invocations, not `cx.notify()` render cost. //! //! Run: cargo run -p cap-desktop-gpui --example export-progress-coalesce-benchmark --release diff --git a/crates/export/examples/preview-layers-benchmark.rs b/crates/export/examples/preview-layers-benchmark.rs index 800e3268172..1595bfb7427 100644 --- a/crates/export/examples/preview-layers-benchmark.rs +++ b/crates/export/examples/preview-layers-benchmark.rs @@ -1,13 +1,7 @@ -//! Cost of acquiring GPU layer pipelines for one export-preview frame. -//! -//! Baseline mirrors what `render_preview_frame` did before pooling: -//! `RendererLayers::new_with_options` on every frame, paying full -//! shader/pipeline compilation each time. Improved measures the -//! `take_reusable_layers` / `return_reusable_layers` round trip used now. +//! Per-frame cost of acquiring `RendererLayers`: fresh build vs pooled reuse. //! //! Run: `cargo run --release -p cap-export --example preview-layers-benchmark` -//! Needs a wgpu adapter; the software fallback is fine, the cost under test -//! is pipeline compilation, not rasterization. +//! Needs a wgpu adapter (software fallback works). use std::time::Instant; From 90e36b6c6cc259683980c2c12ccf1fdfe0c3ec87 Mon Sep 17 00:00:00 2001 From: Wraient Date: Wed, 21 Oct 2026 04:39:06 +0000 Subject: [PATCH 4/7] improve: keep preview layers on EditorInstance instead of process-wide pool Richie's review: render_preview_frame handed layers to the process-wide LAYERS_POOL keyed by preview:, and nothing removed the entry when the editor closed, leaking a full RendererLayers after the window was gone. Keep the reusable layers on the EditorInstance as Mutex>, taken and put back around the preview render, so they are freed with the editor. The config path (fresh device per call) is unaffected. --- crates/editor/src/editor_instance.rs | 7 +++++-- crates/export/src/preview.rs | 26 +++++++++++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index f670c27a4fb..81f4953cd0c 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -8,8 +8,8 @@ use cap_project::{ }; use cap_rendering::{ PrecomputedCursorTimeline, ProjectRecordingsMeta, ProjectUniforms, RecordingSegmentDecoders, - RenderVideoConstants, SegmentVideoPaths, SharedWgpuDevice, Video, ZoomTransformTimeline, - get_duration, spring_mass_damper::SpringMassDamperSimulationConfig, + RenderVideoConstants, RendererLayers, SegmentVideoPaths, SharedWgpuDevice, Video, + ZoomTransformTimeline, get_duration, spring_mass_damper::SpringMassDamperSimulationConfig, }; use std::{ path::{Path, PathBuf}, @@ -201,6 +201,7 @@ pub struct EditorInstance { meta: RecordingMeta, pub export_preview_active: AtomicBool, pub export_active: AtomicBool, + pub preview_layers: std::sync::Mutex>, runtime_handle: tokio::runtime::Handle, audio_output: Arc, } @@ -600,6 +601,7 @@ impl EditorInstance { playback_active_rx, export_preview_active: AtomicBool::new(false), export_active: AtomicBool::new(false), + preview_layers: std::sync::Mutex::new(None), playback_epoch: AtomicU64::new(0), runtime_handle: tokio::runtime::Handle::current(), audio_output, @@ -726,6 +728,7 @@ impl EditorInstance { meta: self.meta.clone(), export_preview_active: AtomicBool::new(false), export_active: AtomicBool::new(false), + preview_layers: std::sync::Mutex::new(None), runtime_handle: self.runtime_handle.clone(), audio_output: self.audio_output.clone(), }); diff --git a/crates/export/src/preview.rs b/crates/export/src/preview.rs index 567ee0e901b..218f44e33a8 100644 --- a/crates/export/src/preview.rs +++ b/crates/export/src/preview.rs @@ -100,7 +100,7 @@ struct PreviewSource<'a> { render_constants: &'a RenderVideoConstants, segments: Vec>, total_duration: f64, - layers_reuse_key: Option, + layers_cache: Option<&'a std::sync::Mutex>>, } struct PreviewSegment<'a> { @@ -131,7 +131,7 @@ async fn render_preview_with_base( }) .collect(), total_duration, - layers_reuse_key: None, + layers_cache: None, }; render_preview_frame(source, frame_time, settings).await } @@ -186,7 +186,7 @@ pub async fn render_preview_with_editor( }) .collect(), total_duration, - layers_reuse_key: Some(format!("preview:{}", editor.project_path.display())), + layers_cache: Some(&editor.preview_layers), }; render_preview_frame(source, frame_time, settings).await } @@ -222,7 +222,7 @@ async fn render_preview_frame( render_constants, segments, total_duration, - layers_reuse_key, + layers_cache, } = source; let transition_mapping = project_config.timeline.as_ref().and_then(|timeline| { if timeline.transitions.is_empty() { @@ -289,9 +289,15 @@ async fn render_preview_frame( ); let mut frame_renderer = FrameRenderer::new(render_constants); - let mut layers = layers_reuse_key - .as_deref() - .and_then(|key| cap_rendering::take_reusable_layers(&render_constants.device, key)) + let mut layers = layers_cache + .and_then(|cache| { + let mut guard = cache.lock().ok()?; + guard.take() + }) + .map(|mut cached| { + cached.reset_frame_state(); + cached + }) .unwrap_or_else(|| { RendererLayers::new_with_options( &render_constants.device, @@ -396,8 +402,10 @@ async fn render_preview_frame( (total_bitrate * encoder_efficiency * duration_seconds) / (8.0 * 1024.0 * 1024.0) }; - if let Some(key) = layers_reuse_key { - cap_rendering::return_reusable_layers(render_constants.device.clone(), key, layers); + if let Some(cache) = layers_cache { + if let Ok(mut guard) = cache.lock() { + *guard = Some(layers); + } } Ok(ExportPreviewResult { From 81cf678d61f68bc8651a36c6c8771235009dfaf4 Mon Sep 17 00:00:00 2001 From: Rushikesh Gaikwad Date: Thu, 22 Oct 2026 10:06:21 +0000 Subject: [PATCH 5/7] improve: collapse nested if-let into let-chains for layers cache write-back Addresses Greptile P2 on the current head: AGENTS.md requires combining collapsible checks. No behavior change. --- crates/export/src/preview.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/export/src/preview.rs b/crates/export/src/preview.rs index a6edef35b73..09e1172af4f 100644 --- a/crates/export/src/preview.rs +++ b/crates/export/src/preview.rs @@ -407,10 +407,10 @@ async fn render_preview_frame( (total_bitrate * encoder_efficiency * duration_seconds) / (8.0 * 1024.0 * 1024.0) }; - if let Some(cache) = layers_cache { - if let Ok(mut guard) = cache.lock() { - *guard = Some(layers); - } + if let Some(cache) = layers_cache + && let Ok(mut guard) = cache.lock() + { + *guard = Some(layers); } Ok(ExportPreviewResult { From 8865cfc06c0e7edf651e7362840dd26283973bd6 Mon Sep 17 00:00:00 2001 From: Wraient Date: Thu, 22 Oct 2026 13:06:12 +0000 Subject: [PATCH 6/7] fix: rustfmt on export-progress-coalesce benchmark line width --- .../examples/export-progress-coalesce-benchmark.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs index ff738ecfdce..d807ccf3608 100644 --- a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs +++ b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs @@ -35,7 +35,10 @@ fn report(label: &str, samples: &mut [f64]) { let mean = samples.iter().sum::() / n; let p50 = samples[(n * 0.50) as usize]; let p95 = samples[(n * 0.95) as usize]; - println!("{label}: n={} mean={mean:.2}us p50={p50:.2}us p95={p95:.2}us", samples.len()); + println!( + "{label}: n={} mean={mean:.2}us p50={p50:.2}us p95={p95:.2}us", + samples.len() + ); } fn spawn_producer() -> flume::Receiver<(u32, u32)> { From 159ebdcd0bef8311b2f0310abe7e9c146952ea8e Mon Sep 17 00:00:00 2001 From: Wraient Date: Fri, 23 Oct 2026 13:08:00 +0000 Subject: [PATCH 7/7] chore: remove pool-era layer helpers and benchmark examples take_reusable_layers and return_reusable_layers were only used by the preview-layers benchmark, which measured the process-wide pool the editor preview no longer uses. Drop both examples as well. --- .../export-progress-coalesce-benchmark.rs | 122 ------------------ .../examples/preview-layers-benchmark.rs | 67 ---------- crates/rendering/src/lib.rs | 15 --- 3 files changed, 204 deletions(-) delete mode 100644 apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs delete mode 100644 crates/export/examples/preview-layers-benchmark.rs diff --git a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs b/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs deleted file mode 100644 index d807ccf3608..00000000000 --- a/apps/desktop-gpui/examples/export-progress-coalesce-benchmark.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Model-update counts in the export-progress drain loop, at 60 msg/s with a -//! 50 ms consumer over 5 s. -//! -//! Measures update invocations, not `cx.notify()` render cost. -//! -//! Run: cargo run -p cap-desktop-gpui --example export-progress-coalesce-benchmark --release - -use std::hint::black_box; -use std::time::{Duration, Instant}; - -const MESSAGE_RATE_PER_SEC: u64 = 60; -const CONSUMER_CADENCE_MS: u64 = 50; -const RUN_SECS: u64 = 5; - -#[derive(Default)] -struct MockExportUi { - rendered: u32, - total_frames: u32, - phase: u8, - label: String, -} - -#[inline(never)] -fn mock_update(ui: &mut MockExportUi, rendered: u32, total: u32) { - ui.rendered = rendered; - ui.total_frames = total; - ui.phase = 1; - ui.label = format!("{rendered} / {total} frames"); - black_box(&ui.label); -} - -fn report(label: &str, samples: &mut [f64]) { - samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN timings")); - let n = samples.len() as f64; - let mean = samples.iter().sum::() / n; - let p50 = samples[(n * 0.50) as usize]; - let p95 = samples[(n * 0.95) as usize]; - println!( - "{label}: n={} mean={mean:.2}us p50={p50:.2}us p95={p95:.2}us", - samples.len() - ); -} - -fn spawn_producer() -> flume::Receiver<(u32, u32)> { - let (tx, rx) = flume::unbounded::<(u32, u32)>(); - let total = (MESSAGE_RATE_PER_SEC * RUN_SECS) as u32; - std::thread::spawn(move || { - let interval = Duration::from_nanos(1_000_000_000 / MESSAGE_RATE_PER_SEC); - for i in 1..=total { - let _ = tx.send((i.min(total), total)); - std::thread::sleep(interval); - } - }); - rx -} - -fn run_old() -> (u64, Vec, (u32, u32)) { - let rx = spawn_producer(); - let mut ui = MockExportUi::default(); - let mut updates = 0; - let mut costs = Vec::new(); - loop { - while let Ok((rendered, total)) = rx.try_recv() { - let started = Instant::now(); - mock_update(&mut ui, rendered, total); - black_box(&mut ui); - updates += 1; - costs.push(started.elapsed().as_secs_f64() * 1e6); - } - if rx.is_disconnected() { - break; - } - std::thread::sleep(Duration::from_millis(CONSUMER_CADENCE_MS)); - } - (updates, costs, (ui.rendered, ui.total_frames)) -} - -fn run_new() -> (u64, Vec, (u32, u32)) { - let rx = spawn_producer(); - let mut ui = MockExportUi::default(); - let mut updates = 0; - let mut costs = Vec::new(); - loop { - let mut latest: Option<(u32, u32)> = None; - while let Ok((rendered, total)) = rx.try_recv() { - latest = Some((rendered, total)); - } - if let Some((rendered, total)) = latest { - let started = Instant::now(); - mock_update(&mut ui, rendered, total); - black_box(&mut ui); - updates += 1; - costs.push(started.elapsed().as_secs_f64() * 1e6); - } - if rx.is_disconnected() { - break; - } - std::thread::sleep(Duration::from_millis(CONSUMER_CADENCE_MS)); - } - (updates, costs, (ui.rendered, ui.total_frames)) -} - -fn main() { - println!( - "export progress drain: {MESSAGE_RATE_PER_SEC} msg/s for {RUN_SECS}s, consumer every {CONSUMER_CADENCE_MS}ms" - ); - let (old_updates, mut old_costs, old_final) = run_old(); - let (new_updates, mut new_costs, new_final) = run_new(); - - assert_eq!(old_final, new_final, "visible end state must match"); - assert_eq!(old_final.0, old_final.1, "progress must reach 100%"); - - println!("old updates: {old_updates}"); - println!("new updates: {new_updates}"); - println!( - "update reduction: {:.1}x", - old_updates as f64 / new_updates as f64 - ); - println!("final UI state old={old_final:?} new={new_final:?} (identical)"); - report("old per-update cost", &mut old_costs); - report("new per-update cost", &mut new_costs); -} diff --git a/crates/export/examples/preview-layers-benchmark.rs b/crates/export/examples/preview-layers-benchmark.rs deleted file mode 100644 index 1595bfb7427..00000000000 --- a/crates/export/examples/preview-layers-benchmark.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Per-frame cost of acquiring `RendererLayers`: fresh build vs pooled reuse. -//! -//! Run: `cargo run --release -p cap-export --example preview-layers-benchmark` -//! Needs a wgpu adapter (software fallback works). - -use std::time::Instant; - -use cap_rendering::{ - RendererLayers, create_shared_device, return_reusable_layers, take_reusable_layers, -}; - -const ITERATIONS: usize = 5; -const KEY: &str = "preview-layers-benchmark"; - -fn report(label: &str, samples: &mut [f64]) { - samples.sort_by(|a, b| a.partial_cmp(b).expect("no NaN timings")); - let n = samples.len() as f64; - let mean = samples.iter().sum::() / n; - let p50 = samples[(n * 0.5) as usize]; - let max = samples[samples.len() - 1]; - println!( - "{label}: n={} mean={mean:.1}ms p50={p50:.1}ms max={max:.1}ms", - samples.len() - ); -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let shared = create_shared_device().await?; - - let mut baseline_ms = Vec::with_capacity(ITERATIONS); - for _ in 0..ITERATIONS { - let started = Instant::now(); - let layers = RendererLayers::new_with_options( - &shared.device, - &shared.queue, - shared.is_software_adapter, - ); - baseline_ms.push(started.elapsed().as_secs_f64() * 1000.0); - drop(layers); - } - - return_reusable_layers( - shared.device.clone(), - KEY.to_string(), - RendererLayers::new_with_options(&shared.device, &shared.queue, shared.is_software_adapter), - ); - - let mut pooled_ms = Vec::with_capacity(ITERATIONS); - for _ in 0..ITERATIONS { - let started = Instant::now(); - let layers = take_reusable_layers(&shared.device, KEY).expect("pool primed above"); - pooled_ms.push(started.elapsed().as_secs_f64() * 1000.0); - return_reusable_layers(shared.device.clone(), KEY.to_string(), layers); - } - - report("baseline build layers per frame", &mut baseline_ms); - report("pooled take layers per frame ", &mut pooled_ms); - let base_mean = baseline_ms.iter().sum::() / baseline_ms.len() as f64; - let pool_mean = pooled_ms.iter().sum::() / pooled_ms.len() as f64; - println!( - "per-frame saving: {saved:.1}ms ({reduction:.1}x)", - saved = base_mean - pool_mean, - reduction = base_mean / pool_mean.max(0.001), - ); - Ok(()) -} diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index e6dbce722c5..75e6e1c115e 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1352,21 +1352,6 @@ fn take_pooled_layers(device: &wgpu::Device, key: &str) -> Option Option { - take_pooled_layers(device, key).map(|mut layers| { - layers.reset_frame_state(); - layers - }) -} - -/// Only return layers after a clean render; a failed render may leave layer -/// state behind. -pub fn return_reusable_layers(device: wgpu::Device, key: String, layers: RendererLayers) { - return_pooled_layers(device, key, layers); -} - /// Builds a fresh set of layers for the next project's first render, off /// its critical path (an idle engine calls this between tasks). pub fn prebuild_spare_layers(