From fa4bccff64ef7674346ca5ae77c0b2a056cbcf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ingmar=20L=C3=BCbbe?= Date: Sun, 25 Oct 2026 16:38:12 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_018QWU3BMJMENFqFqxDHzV3b --- crates/gpu/src/ctx.rs | 153 ++++++++++++++++++++++++++++++++---------- docs/gpu-pipeline.md | 8 +++ 2 files changed, 127 insertions(+), 34 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 33e02fbe4..2c523539f 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -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 { + 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, String> { + let scopes = push_scopes(device); + let consts = constants(); + let prepared: Vec = 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 = 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 = 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>, +) -> Option { + 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 { diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index f88f81c39..ecf52603f 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -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