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.
This commit is contained in:
Jakub
2026-10-12 08:46:16 -04:00
committed by GitHub
parent 3cc0d48410
commit 9ff6c57e03
2 changed files with 83 additions and 22 deletions
+64 -22
View File
@@ -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<wgpu::SubmissionIndex> = 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<plan::Checkpoint>,
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<wgpu::SubmissionIndex>>,
) {
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<wgpu::SubmissionIndex>,
) {
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;
+19
View File
@@ -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> = 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);