From 9ff6c57e0335f87f5d2b97e3ff3e8a47b4aa28fc Mon Sep 17 00:00:00 2001 From: Jakub <47925045+JameEnder@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:46:16 +0200 Subject: [PATCH] GPU composite: submit every 1000 copies and render passes, not only between page cells (#3844) The compositor submitted only between 2048x2048 page cells, so one cell of a deep stack recorded every pass into a single command buffer: 700 full-size layers make 1403 render passes, and wgpu-core opens two hal command buffers per pass. Metal allows 4096 command buffers per queue, so a few thousand passes in one cell can lose the device. run_cell now counts copies and render passes and, when a refresh may submit in between, submits the batch after FLUSH_OPS (1000) of them, waiting for the previous batch as the page-cell flush does. Chunk textures, uniforms and bind groups outlive the submit and the queue keeps the order, so a chunk continues in the next command buffer. A device lost in such a submit ends the refresh with an error instead of recording more work. Not measurably slower on Vulkan (RTX 3090): perf_scenarios rows are within noise. Test: many_layers_submit_inside_one_chunk (700 noise layers on a 64x48 document) needs flushes > 0 inside one chunk and matches the CPU composite; main records all 1403 passes without a flush. --- crates/gpu/src/lib.rs | 86 ++++++++++++++++++++++++++++---------- crates/gpu/tests/parity.rs | 19 +++++++++ 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 2efd7f6a49..854db1b91d 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -82,6 +82,12 @@ const FLUSH_BYTES: u64 = 512 << 20; /// command buffer holding a whole huge refresh can run for seconds, long enough for the OS to /// reset the GPU. const FLUSH_WORK: u64 = 1 << 30; +/// Copies and render passes [`Compositor::render`] records before submitting. wgpu opens up to +/// two backend command buffers for each (a render pass and its barrier pre-pass), and Metal +/// treats more than 4096 outstanding ones as a lost device: a refresh of a document with a few +/// hundred layers reached that inside a single page cell (P12, "pc_compose_uniforms is +/// invalid"), where the work and byte limits above never trigger. +const FLUSH_OPS: u32 = 1000; /// A finished chunk: straight-alpha RGBA32F pixels of `rect` (document coordinates) at the /// texture's origin. @@ -422,6 +428,8 @@ pub struct Compositor { stamp: u64, /// Bytes staged by uploads since the last submit. staged: u64, + /// Copies and render passes recorded since the last submit. + recorded: u32, acc_format: wgpu::TextureFormat, /// Whether the effect-map pipelines could be built (else documents with layer effects are /// [`Unsupported`] and use the CPU compositor). @@ -617,6 +625,7 @@ impl Compositor { frame: 0, stamp: 0, staged: 0, + recorded: 0, acc_format, effect_maps: map_error.is_none(), cmyk: 0, @@ -907,6 +916,7 @@ impl Compositor { self.frame += 1; self.stamp += 1; self.staged = 0; + self.recorded = 0; // Only render owns submission. A caller may discard encode's command buffer, so it // cannot establish (or consume) a checkpoint whose GPU copy might never execute. @@ -937,9 +947,7 @@ impl Compositor { } let flush_bytes = FLUSH_BYTES.min(self.memory_budget() / 8).max(16 << 20); stats.cells = cells.len(); - // The previous intermediate submit (waited for before the next is queued; the web has - // no blocking wait and relies on the browser's own scheduling). - #[cfg(not(target_arch = "wasm32"))] + // The previous intermediate submit (see `submit_batch`). let mut last_submit: Option = None; // Work recorded and bytes evicted since the last submit. let (mut work, mut freed) = (0u64, 0u64); @@ -958,29 +966,19 @@ impl Compositor { let chunks = grid_rects(part, self.chunk); stats.chunks += chunks.len(); work += part.width() as u64 * part.height() as u64 * plan.passes.len() as u64; - self.run_cell(device, queue, encoder, doc.id, &plan, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink); + let mut batch = flush.then_some(&mut last_submit); + self.run_cell(device, queue, encoder, doc.id, &plan, &paged, cell, &fr, &chunks, checkpoint, &mut stats, sink, &mut batch); + // The device was lost in a submit inside the cell: issue no more work. + if let Some(f) = self.fault() { + return Err(Unsupported(f.to_string())); + } let (evicted, bytes) = self.evict(); stats.evicted += evicted; freed += bytes; - if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes) { - // Submit what's recorded, and wait for the previous submit, so at most two - // batches of uploads and evicted pages are in flight. - let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); - let index = queue.submit([done.finish()]); - #[cfg(not(target_arch = "wasm32"))] - if let Some(prev) = last_submit.replace(index) { - match &self.health { - Some(h) => { - h.wait(device, Some(prev)); - } - None => { - let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None }); - } - } - } - #[cfg(target_arch = "wasm32")] - let _ = index; + if flush && (self.staged >= flush_bytes || work >= FLUSH_WORK || freed >= flush_bytes || self.recorded >= FLUSH_OPS) { + submit_batch(device, queue, encoder, self.health.as_ref(), &mut last_submit); self.staged = 0; + self.recorded = 0; work = 0; freed = 0; stats.flushes += 1; @@ -1135,6 +1133,8 @@ impl Compositor { checkpoint: Option, stats: &mut Stats, sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>), + // The previous intermediate submit, when this refresh may submit in between. + batch: &mut Option<&mut Option>, ) { if chunks.is_empty() { return; @@ -1220,6 +1220,19 @@ impl Compositor { if i < resume { continue; } + // Chunk textures, uniforms and bind groups outlive the submit, and the queue + // keeps the order, so a chunk may continue in the next command buffer. + if self.recorded >= FLUSH_OPS + && let Some(last) = batch.as_deref_mut() + { + submit_batch(device, queue, encoder, self.health.as_ref(), last); + self.staged = 0; + self.recorded = 0; + stats.flushes += 1; + if self.fault().is_some() { + return; + } + } if resume == 0 && let Some(cp) = checkpoint.filter(|cp| cp.pass_end == i) && let Some((src, _)) = self.pool.get(cp.slot as usize) @@ -1257,10 +1270,12 @@ impl Compositor { }, wgpu::Extent3d { width: scissor.width(), height: scissor.height(), depth_or_array_layers: 1 }, ); + self.recorded += 1; continue; } _ => {} } + self.recorded += 1; let target = &self.pool[p.dst as usize].1; let clear = p.kernel == Kernel::Clear; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -1778,6 +1793,33 @@ fn write_tile(queue: &wgpu::Queue, r: &Resident, c: TileCoord, bytes: &[u8]) { ); } +/// Submit what `encoder` recorded and continue in a fresh one, then wait for the previous +/// intermediate submit (`last`), so at most two batches of uploads and evicted pages are in +/// flight. The web has no blocking wait and relies on the browser's own scheduling. +fn submit_batch( + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + health: Option<&DeviceHealth>, + last: &mut Option, +) { + let done = std::mem::replace(encoder, device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pc_compose") })); + let prev = last.replace(queue.submit([done.finish()])); + #[cfg(not(target_arch = "wasm32"))] + if let Some(prev) = prev { + match health { + Some(h) => { + h.wait(device, Some(prev)); + } + None => { + let _ = device.poll(wgpu::PollType::Wait { submission_index: Some(prev), timeout: None }); + } + } + } + #[cfg(target_arch = "wasm32")] + let _ = (prev, health); +} + /// Distinct tiles converted per parallel batch: bounds the converted bytes held at once (64 tiles /// of a 16-bit page are 32 MiB). const CONVERT_BATCH: usize = 64; diff --git a/crates/gpu/tests/parity.rs b/crates/gpu/tests/parity.rs index fca32a02c4..1bdd597a36 100644 --- a/crates/gpu/tests/parity.rs +++ b/crates/gpu/tests/parity.rs @@ -1061,6 +1061,25 @@ fn small_layers_composite_over_their_bounds() { check(&mut g, &big, "small layers across chunks"); } +#[test] +fn many_layers_submit_inside_one_chunk() { + // P12: a refresh recorded every pass of a page cell into one command buffer, and with a few + // hundred layers Metal ran out of command buffers (device lost, "pc_compose_uniforms is + // invalid"). 700 layers need well over 1000 passes in the document's only chunk. + let Some(mut g) = gpu() else { return }; + let mut d = base_doc(64, 48); + let modes: Vec = BlendMode::layer_modes().collect(); + for (i, mode) in modes.iter().copied().cycle().take(700).enumerate() { + let mut l = noise_layer("l", PixelFormat::RGBA8, Rect::from_xywh(0, 0, 64, 48), 500 + i as u32, 0.0); + l.blend = mode; + l.opacity = 0.9; + d.layers.push(l); + } + let stats = diff_rect(&mut g, &d, d.bounds(), "700 layers").unwrap_or_else(|e| panic!("{e}")); + assert_eq!(stats.chunks, 1); + assert!(stats.flushes > 0, "{stats:?}"); +} + fn blob(name: &str, fmt: PixelFormat, cx: f32, cy: f32, r: f32, color: [f32; 3]) -> Layer { let mut l = Layer::raster(name, fmt); let rect = Rect::new((cx - r - 12.0) as i32, (cy - r - 4.0) as i32, (cx + r + 14.0) as i32, (cy + r + 4.0) as i32);