mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 18:52:15 +01:00
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:
+64
-22
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user