Issue #2936: build the GPU kernels on parallel threads

Gpu::new built the 27 compute pipelines one after another. On DX12 each one is compiled by FXC
(issue #471), single-threaded, inside create_compute_pipeline, so every process waited ~2.2 s for
them on an Intel Core Ultra 7 165U iGPU (app.gpu 2.4 s median) while the other cores idled.

build_kernels creates the shader modules and layouts on the calling thread and shares the pipelines
out to worker threads (std::thread::scope, an atomic job counter; the calling thread works too, so
every kernel is built even when no thread can be started). wgpu's error scopes are per thread, so
each worker pushes its own validation and internal scopes: a refused kernel or a panicked worker
still means no GPU, never a half-built one. app.gpu: 2.4 s -> 1.2 s median on that machine, exports
pixel-identical. The gpu: info line now reports the kernel count and build time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018QWU3BMJMENFqFqxDHzV3b
This commit is contained in:
Ingmar Lübbe
2026-10-25 16:38:12 +01:00
co-authored by Claude Opus 5.5
parent 03c88c166c
commit fa4bccff64
2 changed files with 127 additions and 34 deletions
+119 -34
View File
@@ -1,7 +1,7 @@
//! The wgpu device, the compiled kernels, buffers, dispatch and readback.
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use wgpu::util::DeviceExt;
@@ -77,6 +77,101 @@ struct Kernel {
nbuf: usize,
}
/// A module's shader and layouts, shared by the kernels of its entry points.
struct Prepared {
module: wgpu::ShaderModule,
layout: wgpu::BindGroupLayout,
pipeline_layout: wgpu::PipelineLayout,
nbuf: usize,
}
/// Validation and internal error scopes. They belong to the thread that pushed them.
fn push_scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 2] {
[device.push_error_scope(wgpu::ErrorFilter::Validation), device.push_error_scope(wgpu::ErrorFilter::Internal)]
}
/// The error the scopes caught, if any (popped innermost first).
fn pop_scopes(scopes: [wgpu::ErrorScopeGuard; 2]) -> Option<String> {
let [validation, internal] = scopes;
let (internal, validation) = (pollster::block_on(internal.pop()), pollster::block_on(validation.pop()));
internal.or(validation).map(|e| e.to_string())
}
/// Build every kernel. A kernel the driver's compiler refuses is a validation or an internal error,
/// and then there is no GPU, not a half-built one.
///
/// Issue #2936: built one after another, the 27 pipelines took 1.7–7 s on an Intel iGPU under DX12,
/// where FXC (issue #471) compiles each one, single-threaded, inside `create_compute_pipeline`. The
/// device takes pipeline creation from any thread, so the entry points are shared out to worker
/// threads. This thread works too: every kernel is built even when no thread can be started.
fn build_kernels(device: &wgpu::Device) -> Result<HashMap<&'static str, Kernel>, String> {
let scopes = push_scopes(device);
let consts = constants();
let prepared: Vec<Prepared> = MODULES
.iter()
.map(|m| {
let src = module_source(m, &consts);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) });
let mut entries = vec![storage_entry(0, true)];
entries.extend(m.bindings.iter().enumerate().map(|(i, (_, w, _))| storage_entry(i as u32 + 1, !w)));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &entries });
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
Prepared { module, layout, pipeline_layout, nbuf: m.bindings.len() }
})
.collect();
if let Some(e) = pop_scopes(scopes) {
return Err(e);
}
let jobs: Vec<(&Prepared, &'static str)> = MODULES.iter().zip(&prepared).flat_map(|(m, p)| m.entries.iter().map(move |e| (p, *e))).collect();
let next = AtomicUsize::new(0);
let built = std::sync::Mutex::new(HashMap::with_capacity(jobs.len()));
let workers = std::thread::available_parallelism().map_or(1, |n| n.get()).min(jobs.len()).saturating_sub(1);
let errors: Vec<String> = std::thread::scope(|s| {
let work = || build_jobs(device, &jobs, &next, &built);
let threads: Vec<_> =
(0..workers).filter_map(|_| std::thread::Builder::new().name("lc-gpu-kernels".into()).spawn_scoped(s, work).ok()).collect();
let mut errors: Vec<String> = work().into_iter().collect();
for t in threads {
match t.join() {
Ok(e) => errors.extend(e),
Err(_) => errors.push("a kernel build thread panicked".into()),
}
}
errors
});
if let Some(e) = errors.into_iter().next() {
return Err(e);
}
Ok(built.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner))
}
/// One worker of [`build_kernels`]: build kernels until none is left; the first error its scopes caught.
fn build_jobs(
device: &wgpu::Device,
jobs: &[(&Prepared, &'static str)],
next: &AtomicUsize,
built: &std::sync::Mutex<HashMap<&'static str, Kernel>>,
) -> Option<String> {
let scopes = push_scopes(device);
while let Some(&(p, entry)) = jobs.get(next.fetch_add(1, Ordering::Relaxed)) {
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: Some(&p.pipeline_layout),
module: &p.module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
});
let kernel = Kernel { pipeline, layout: p.layout.clone(), nbuf: p.nbuf };
built.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(entry, kernel);
}
pop_scopes(scopes)
}
/// A device buffer of `len` 32-bit values.
///
/// Dropped buffers are recycled: they first wait in a per-thread list (commands recorded on this
@@ -339,39 +434,15 @@ impl Gpu {
log::error!("gpu: device lost ({reason:?}): {msg}");
crate::mark_broken(&format!("device lost ({reason:?}): {msg}"));
});
// a kernel the driver's compiler refuses is a validation or an internal error: no GPU, not a half-built one
let scopes = [device.push_error_scope(wgpu::ErrorFilter::Validation), device.push_error_scope(wgpu::ErrorFilter::Internal)];
let consts = constants();
let mut kernels = HashMap::new();
for m in MODULES {
let src = module_source(m, &consts);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(src.into()) });
let mut entries = vec![storage_entry(0, true)];
entries.extend(m.bindings.iter().enumerate().map(|(i, (_, w, _))| storage_entry(i as u32 + 1, !w)));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: None, entries: &entries });
let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &[Some(&layout)],
immediate_size: 0,
});
for e in m.entries {
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(e),
layout: Some(&pl),
module: &module,
entry_point: Some(e),
compilation_options: Default::default(),
cache: None,
});
kernels.insert(*e, Kernel { pipeline, layout: layout.clone(), nbuf: m.bindings.len() });
let started = std::time::Instant::now();
let kernels = match build_kernels(&device) {
Ok(kernels) => kernels,
Err(e) => {
log::error!("gpu: kernels failed to build on {}: {e}", info.name);
return Err(format!("{}: kernels failed to build ({e})", info.name));
}
}
let [validation, internal] = scopes;
let (internal, validation) = (pollster::block_on(internal.pop()), pollster::block_on(validation.pop()));
if let Some(e) = internal.or(validation) {
log::error!("gpu: kernels failed to build on {}: {e}", info.name);
return Err(format!("{}: kernels failed to build ({e})", info.name));
}
};
let build_ms = started.elapsed().as_millis();
let dummy = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("dummy"),
size: 16,
@@ -379,7 +450,7 @@ impl Gpu {
mapped_at_creation: false,
});
let max_buffer = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size);
log::info!("gpu: {} ({:?}), buffers ≤ {} MiB", info.name, info.backend, max_buffer >> 20);
log::info!("gpu: {} ({:?}), {} kernels in {build_ms} ms, buffers ≤ {} MiB", info.name, info.backend, kernels.len(), max_buffer >> 20);
Ok(Gpu { device, queue, info, kernels, dummy, free: Default::default(), max_buffer })
}
@@ -773,6 +844,20 @@ mod tests {
assert_eq!(super::parse_limits("lots"), None);
}
/// Issue #2936: the kernels are built on several threads; every entry point of every module has one.
#[test]
fn every_entry_point_has_a_kernel() {
let Some(g) = crate::device() else {
lightcraft_testkit::skip!(Gpu, "no GPU device");
return;
};
let entries: Vec<&str> = super::MODULES.iter().flat_map(|m| m.entries.iter().copied()).collect();
assert_eq!(g.kernels.len(), entries.len());
for e in entries {
assert!(g.kernels.contains_key(e), "no kernel for {e}");
}
}
/// Run `child` (an ignored test in this module) in its own process: it loses the device, which
/// no other test running beside it may see. Returns its stdout, or `None` without a GPU.
fn run_child(child: &str) -> Option<String> {
+8
View File
@@ -302,6 +302,14 @@ compilation: ~0.4 s once per process — the desktop app starts it on a backgrou
(`lightcraft_gpu::warm_up`), so the first loupe render doesn't wait for it; other processes create
the device on their first GPU render. `lightcraft_gpu::ready()` asks without blocking.
On Windows / DX12 the kernels cost more: FXC compiles each pipeline single-threaded inside
`create_compute_pipeline` (issue #471). Built one after another, the 27 took ~2.2 s on an Intel Core
Ultra 7 165U iGPU (`app.gpu` 2.4 s median, up to 7 s on the first run after an install), so
`ctx::build_kernels` shares them out to worker threads (issue #2936): ~1.05 s there (`app.gpu` 1.2 s),
now bounded by the largest kernel (`finish.wgsl`). wgpu's error scopes are per thread, so every worker
opens its own; a refused kernel still means no GPU. The `gpu:` info log line gives the kernel count
and build time.
## Opening a photo (M5.4)
- The loupe shows a stand-in at once (`media::QuickJob`): the photo's cached view render for its
current settings, else (raws with their import look) the embedded camera JPEG, else a