diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index cc861c85b..1f04fa5b1 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -24,9 +24,10 @@ const ENTRIES: &[&str] = &[ "fill", ]; -/// Effect entry points in `kernels.wgsl`. Like the `fx_*` kernels, one that fails to build -/// (a driver's shader compiler rejecting it, #613) leaves its effects to the CPU instead of -/// turning the whole compositor off. +/// Effect entry points in `kernels.wgsl`. Like the `fx_*` kernels, they are built on first use +/// (building all ~200 up front kept Chrome's GPU process busy past its watchdog, #1590, and +/// FXC took over a minute, #613), and one that fails to build (a driver's shader compiler +/// rejecting it) leaves its effects to the CPU instead of turning the whole compositor off. const EFFECT_ENTRIES: &[&str] = &["box_h", "box_v", "directional", "glow_bright", "glow_combine", "shadow_make", "shadow_combine", "pointwise"]; /// Entry points that also bind group 1 (four read-only storage buffers; see @@ -34,6 +35,79 @@ const EFFECT_ENTRIES: &[&str] = &["box_h", "box_v", "directional", "glow_bright" /// can't unroll `classic3d`'s loops, #613) leaves its work to the CPU. const EXT_ENTRIES: &[&str] = &["classic3d"]; +/// What every kernel module starts with: the shared helpers and bindings, and `kernels.wgsl` +/// (the compositor's kernels, which declare `out` and helpers the effect files use). +const BASE_WGSL: [&str; 2] = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl")]; + +/// The family whose module also gets the adapter's median implementation +/// ([`crate::fx_noise::MedianKernel::source`]), chosen when the context is created. +const MEDIAN_FAMILY: &str = "fx_noise"; + +/// Kernel families: each is compiled as a module of its own, `BASE_WGSL` plus its file and the +/// files it borrows functions from, built the first time one of its kernels is. A pipeline then +/// compiles at most ~120K characters of WGSL instead of all ~460K: on WebGPU each pipeline +/// compiles its module again, and the page's and its workers' devices doing that for every +/// kernel kept Chrome's GPU process busy past its watchdog (#1590). Family 0 is the base module. +const FAMILIES: &[(&str, &[&str], &[&str])] = &[ + ("kernels", &[], EFFECT_ENTRIES), + ("fx_color", &[include_str!("shaders/fx_lut.wgsl"), include_str!("shaders/fx_color.wgsl")], crate::fx_color::KERNELS), + ("fx_distort", &[include_str!("shaders/fx_distort.wgsl")], crate::fx_distort::KERNELS), + ("fx_generate", &[include_str!("shaders/fx_generate.wgsl")], crate::fx_generate::KERNELS), + ("fx_key", &[include_str!("shaders/fx_distort.wgsl"), include_str!("shaders/fx_key.wgsl")], crate::fx_key::KERNELS), + ("fx_stylize", &[include_str!("shaders/fx_stylize.wgsl")], crate::fx_stylize::KERNELS), + (MEDIAN_FAMILY, &[include_str!("shaders/fx_noise.wgsl")], crate::fx_noise::KERNELS), + ("fx_tone", &[include_str!("shaders/fx_tone.wgsl")], crate::fx_tone::KERNELS), + ("fx_warp", &[include_str!("shaders/fx_warp.wgsl")], crate::fx_warp::KERNELS), + ("fx_extra", &[include_str!("shaders/fx_generate.wgsl"), include_str!("shaders/fx_extra.wgsl")], crate::fx_extra::KERNELS), + ("fx_depth", &[include_str!("shaders/fx_depth.wgsl")], crate::fx_depth::KERNELS), + ("fx_lut", &[include_str!("shaders/fx_lut.wgsl")], crate::fx_lut::KERNELS), + ("fx_sim", &[include_str!("shaders/fx_sim.wgsl")], crate::fx_sim::KERNELS), + ( + "fx_particles", + &[include_str!("shaders/fx_stylize.wgsl"), include_str!("shaders/fx_sim.wgsl"), include_str!("shaders/fx_particles.wgsl")], + crate::fx_particles::KERNELS, + ), + ("fx_vr", &[include_str!("shaders/fx_vr.wgsl")], crate::fx_vr::KERNELS), + ("fx_light", &[include_str!("shaders/fx_light.wgsl")], crate::fx_light::KERNELS), + ("fx_transition", &[include_str!("shaders/fx_transition.wgsl")], crate::fx_transition::KERNELS), + ("fx_text", &[include_str!("shaders/fx_text.wgsl")], crate::fx_text::KERNELS), + ("fx_time", &[include_str!("shaders/fx_time.wgsl")], crate::fx_time::KERNELS), + ("fx_pixel2", &[include_str!("shaders/fx_stylize.wgsl"), include_str!("shaders/fx_pixel2.wgsl")], crate::fx_pixel2::KERNELS), + ("fx_gen2", &[include_str!("shaders/fx_gen2.wgsl")], crate::fx_gen2::KERNELS), + ("inpaint", &[include_str!("shaders/inpaint.wgsl")], crate::inpaint::KERNELS), +]; + +/// Module `label`: `BASE_WGSL` followed by `files`. +fn kernel_module(device: &wgpu::Device, label: &str, files: &[&str]) -> Result { + let src: String = BASE_WGSL.iter().chain(files).copied().collect(); + init_resource(device, &format!("{label} shader module"), || { + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some(label), source: wgpu::ShaderSource::Wgsl(src.into()) }) + }) +} + +/// Compute pipeline `entry` of `module`; a shader compiler rejecting it (FXC, #613) is an `Err`. +fn build_pipeline( + device: &wgpu::Device, + module: &wgpu::ShaderModule, + layout: &wgpu::PipelineLayout, + entry: &'static str, +) -> Result { + init_resource(device, entry, || { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(layout), + module, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) + }) +} + +/// Effect kernels by entry point: their family (index in `FAMILIES`) and, once asked for, the +/// built pipeline or why it didn't build. +type EffectPipelines = HashMap<&'static str, (usize, std::sync::OnceLock>)>; + /// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom; /// 8/16 bpc are emulated by clamping and quantising, as on the CPU). pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float; @@ -601,9 +675,16 @@ pub struct GpuContext { pub(crate) median_kernel: crate::fx_noise::MedianKernel, bgl: wgpu::BindGroupLayout, bgl_ext: wgpu::BindGroupLayout, + /// Each of `FAMILIES`' modules, built with its first kernel; and their group 0 layout. + modules: Vec>>, + layout: wgpu::PipelineLayout, + /// The compositor's own kernels and Classic 3D (built up front). pipelines: HashMap<&'static str, wgpu::ComputePipeline>, - /// Effect kernels that failed to build, and why (their effects render on the CPU). + /// Kernels built up front that failed to build, and why (Classic 3D renders on the CPU). unavailable: Vec<(&'static str, String)>, + /// Effect kernels, built on first dispatch (`GpuContext::pipeline`); one that doesn't build + /// leaves its effects to the CPU. + effect_pipelines: EffectPipelines, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, /// The display texture's next mip level from the one above (`display.wgsl` `mip`). @@ -778,40 +859,7 @@ impl GpuContext { if let Some(why) = &adv3d_unsupported { log::info!("gpu {name}: Advanced 3D renders on the CPU ({why})"); } - let src = [ - include_str!("shaders/common.wgsl"), - include_str!("shaders/kernels.wgsl"), - include_str!("shaders/classic3d.wgsl"), - include_str!("shaders/fx_color.wgsl"), - include_str!("shaders/fx_distort.wgsl"), - include_str!("shaders/fx_generate.wgsl"), - include_str!("shaders/fx_key.wgsl"), - include_str!("shaders/fx_stylize.wgsl"), - include_str!("shaders/fx_noise.wgsl"), - median_kernel.source(), - include_str!("shaders/fx_tone.wgsl"), - include_str!("shaders/fx_warp.wgsl"), - include_str!("shaders/fx_extra.wgsl"), - include_str!("shaders/fx_depth.wgsl"), - include_str!("shaders/fx_lut.wgsl"), - include_str!("shaders/fx_sim.wgsl"), - include_str!("shaders/fx_particles.wgsl"), - include_str!("shaders/fx_vr.wgsl"), - include_str!("shaders/fx_light.wgsl"), - include_str!("shaders/fx_transition.wgsl"), - include_str!("shaders/fx_text.wgsl"), - include_str!("shaders/fx_time.wgsl"), - include_str!("shaders/fx_pixel2.wgsl"), - include_str!("shaders/fx_gen2.wgsl"), - include_str!("shaders/inpaint.wgsl"), - include_str!("shaders/sky.wgsl"), - ] - .concat(); - #[cfg(test)] - let src = src + crate::tests::MATH_PROBE_SHADER; - let module = init_resource(&device, "kernels shader module", || { - device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) }) - })?; + let module = kernel_module(&device, "effectcraft kernels", &[])?; let bgl = init_resource(&device, "kernels bind-group layout", || { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("effectcraft kernels"), @@ -864,25 +912,21 @@ impl GpuContext { immediate_size: 0, }) })?; - let build = |e: &'static str, layout: &wgpu::PipelineLayout| { - init_resource(&device, e, || { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some(e), - layout: Some(layout), - module: &module, - entry_point: Some(e), - compilation_options: Default::default(), - cache: None, - }) - }) - }; - // The compositor's own kernels are required; an effect kernel that doesn't build is - // left out, and its effects render on the CPU (see `Enc::take_missing`). So is a - // missing Classic 3D kernel: its 3D runs render on the CPU. - let mut pipelines = ENTRIES.iter().chain(crate::adv3d::SKY_KERNELS).map(|e| Ok((*e, build(e, &layout)?))).collect::, String>>()?; + // The compositor's own kernels are required and built now. A Classic 3D kernel that + // doesn't build is left out (its 3D runs render on the CPU), and effect kernels are built + // on first use (`GpuContext::pipeline`); one that doesn't build leaves its effects to the + // CPU (see `Enc::take_missing`). + let sky = kernel_module(&device, "effectcraft sky", &[include_str!("shaders/sky.wgsl")])?; + let mut pipelines = ENTRIES + .iter() + .map(|e| (e, &module)) + .chain(crate::adv3d::SKY_KERNELS.iter().map(|e| (e, &sky))) + .map(|(e, module)| Ok((*e, build_pipeline(&device, module, &layout, e)?))) + .collect::, String>>()?; let mut unavailable = vec![]; + let classic3d = kernel_module(&device, "effectcraft classic3d", &[include_str!("shaders/classic3d.wgsl")]); for e in EXT_ENTRIES { - match build(e, &layout_ext) { + match classic3d.as_ref().map_err(Clone::clone).and_then(|m| build_pipeline(&device, m, &layout_ext, e)) { Ok(p) => { pipelines.insert(*e, p); } @@ -893,41 +937,14 @@ impl GpuContext { } } #[cfg(test)] - pipelines.insert("math_probe", build("math_probe", &layout)?); - for e in EFFECT_ENTRIES - .iter() - .chain(crate::fx_color::KERNELS) - .chain(crate::fx_distort::KERNELS) - .chain(crate::fx_generate::KERNELS) - .chain(crate::fx_key::KERNELS) - .chain(crate::fx_stylize::KERNELS) - .chain(crate::fx_noise::KERNELS) - .chain(crate::fx_tone::KERNELS) - .chain(crate::fx_warp::KERNELS) - .chain(crate::fx_extra::KERNELS) - .chain(crate::fx_depth::KERNELS) - .chain(crate::fx_lut::KERNELS) - .chain(crate::fx_sim::KERNELS) - .chain(crate::fx_particles::KERNELS) - .chain(crate::fx_vr::KERNELS) - .chain(crate::fx_light::KERNELS) - .chain(crate::fx_transition::KERNELS) - .chain(crate::fx_text::KERNELS) - .chain(crate::fx_time::KERNELS) - .chain(crate::fx_pixel2::KERNELS) - .chain(crate::fx_gen2::KERNELS) - .chain(crate::inpaint::KERNELS) { - match build(e, &layout) { - Ok(p) => { - pipelines.insert(*e, p); - } - Err(why) => { - log::warn!("gpu {name}: kernel {e} unavailable, its effects render on the CPU: {why}"); - unavailable.push((*e, why)); - } - } + // The numerical probe (tests) calls `fx_stylize.wgsl` helpers. + let probe = kernel_module(&device, "effectcraft math probe", &[include_str!("shaders/fx_stylize.wgsl"), crate::tests::MATH_PROBE_SHADER])?; + pipelines.insert("math_probe", build_pipeline(&device, &probe, &layout, "math_probe")?); } + let effect_pipelines = + FAMILIES.iter().enumerate().flat_map(|(family, (_, _, kernels))| kernels.iter().map(move |e| (*e, (family, std::sync::OnceLock::new())))).collect(); + let modules = std::iter::once(std::sync::OnceLock::from(Ok(module))).chain(FAMILIES.iter().skip(1).map(|_| std::sync::OnceLock::new())).collect(); let dmodule = init_resource(&device, "display shader module", || { device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft display"), @@ -988,8 +1005,11 @@ impl GpuContext { median_kernel, bgl, bgl_ext, + modules, + layout, pipelines, unavailable, + effect_pipelines, display_bgl, display, display_mip, @@ -1300,21 +1320,101 @@ impl GpuContext { /// Readbacks (GPU → CPU) are possible: waited for natively, or deferred (a browser /// worker, [`crate::deferred`]). Impossible on the browser's main thread. - /// Effect kernels the device couldn't build, with why: their effects render on the CPU. - pub fn unavailable_kernels(&self) -> &[(&'static str, String)] { - &self.unavailable + /// Kernels the device couldn't build so far, with why: their effects render on the CPU. + /// Effect kernels are built on first use, so one not used yet isn't listed. + pub fn unavailable_kernels(&self) -> Vec<(&'static str, String)> { + let mut out: Vec<_> = self + .unavailable + .iter() + .cloned() + .chain(self.effect_pipelines.iter().filter_map(|(e, (_, cell))| match cell.get() { + Some(Err(why)) => Some((*e, why.clone())), + _ => None, + })) + .collect(); + out.sort_unstable_by_key(|(e, _)| *e); + out } - /// Whether kernel `entry` was built (an effect or Classic 3D kernel can be missing, #613). + /// Whether kernel `entry` is available (an effect or Classic 3D kernel can be missing, + /// #613); an effect kernel not built yet is built now. pub(crate) fn has_kernel(&self, entry: &str) -> bool { - self.pipelines.contains_key(entry) + self.pipeline(entry).is_some() + } + + /// Kernel `entry`'s pipeline: one built up front, or an effect kernel, built the first time + /// it is asked for. `None` when the device doesn't have it. + fn pipeline(&self, entry: &str) -> Option<&wgpu::ComputePipeline> { + if let Some(p) = self.pipelines.get(entry) { + return Some(p); + } + let Some((e, (family, cell))) = self.effect_pipelines.get_key_value(entry) else { + if !self.unavailable.iter().any(|(e, _)| *e == entry) { + log::error!("gpu: no kernel {entry}"); + } + return None; + }; + let built = cell.get_or_init(|| { + let built = self.family_module(*family).and_then(|module| build_pipeline(&self.device, module, &self.layout, e)); + if let Err(why) = &built { + log::warn!("gpu {}: kernel {e} unavailable, its effects render on the CPU: {why}", self.name); + } + built + }); + built.as_ref().ok() + } + + /// Family `family`'s module (`FAMILIES`), built the first time it is asked for. + fn family_module(&self, family: usize) -> Result<&wgpu::ShaderModule, String> { + let (Some((label, files, _)), Some(cell)) = (FAMILIES.get(family), self.modules.get(family)) else { + return Err(format!("no kernel family {family}")); + }; + cell.get_or_init(|| { + let median = [self.median_kernel.source()]; + let extra: &[&str] = if *label == MEDIAN_FAMILY { &median } else { &[] }; + let files: Vec<&str> = files.iter().chain(extra).copied().collect(); + kernel_module(&self.device, label, &files) + }) + .as_ref() + .map_err(Clone::clone) + } + + /// Build every effect kernel not built yet (tests; and a host that would rather pay the + /// compile time up front than on first use). + pub fn build_all_kernels(&self) { + for e in self.effect_pipelines.keys() { + let _ = self.pipeline(e); + } + } + + /// How many effect kernels have been built (tests). + #[cfg(test)] + pub(crate) fn effect_pipelines_built(&self) -> usize { + self.effect_pipelines.values().filter(|(_, cell)| matches!(cell.get(), Some(Ok(_)))).count() + } + + /// Leave kernel `entry` out of `pipelines`/`effect_pipelines`, as if it had failed to build. + #[cfg(test)] + fn drop_from( + pipelines: &mut HashMap<&'static str, wgpu::ComputePipeline>, + unavailable: &mut Vec<(&'static str, String)>, + effects: &mut EffectPipelines, + entry: &'static str, + ) { + let why = "dropped by a test".to_string(); + pipelines.remove(entry); + match effects.get(entry).map(|(f, _)| *f) { + Some(family) => { + effects.insert(entry, (family, std::sync::OnceLock::from(Err(why)))); + } + None => unavailable.push((entry, why)), + } } /// Leave kernel `entry` out, as if it had failed to build (tests of the CPU fallback). #[cfg(test)] pub(crate) fn drop_kernel(&mut self, entry: &'static str) { - self.pipelines.remove(entry); - self.unavailable.push((entry, "dropped by a test".into())); + Self::drop_from(&mut self.pipelines, &mut self.unavailable, &mut self.effect_pipelines, entry); } /// A copy of this context sharing the same [`wgpu::Device`] and [`wgpu::Queue`] with @@ -1323,9 +1423,9 @@ impl GpuContext { #[cfg(test)] pub(crate) fn without_kernel(&self, entry: &'static str) -> GpuContext { let mut pipelines = self.pipelines.clone(); - pipelines.remove(entry); let mut unavailable = self.unavailable.clone(); - unavailable.push((entry, "dropped by a test".into())); + let mut effect_pipelines = self.effect_pipelines.clone(); + Self::drop_from(&mut pipelines, &mut unavailable, &mut effect_pipelines, entry); GpuContext { device: self.device.clone(), queue: self.queue.clone(), @@ -1334,8 +1434,11 @@ impl GpuContext { median_kernel: self.median_kernel, bgl: self.bgl.clone(), bgl_ext: self.bgl_ext.clone(), + modules: self.modules.clone(), + layout: self.layout.clone(), pipelines, unavailable, + effect_pipelines, display_bgl: self.display_bgl.clone(), display: self.display.clone(), display_mip: self.display_mip.clone(), @@ -1766,10 +1869,7 @@ impl<'g> Enc<'g> { ) { let KernelImages { src, aux, out } = images; let g = self.g; - let Some(pipe) = g.pipelines.get(entry) else { - if !g.unavailable.iter().any(|(e, _)| *e == entry) { - log::error!("gpu: no kernel {entry}"); - } + let Some(pipe) = g.pipeline(entry) else { self.missing = true; return; }; diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 0844a4335..3ede5c6b9 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -422,7 +422,7 @@ impl Accelerator for Gpu { // Readback is synchronous-only, and a kernel that failed to build would make every stage // fall back to the CPU *after* paying the upload and a failed dispatch (and the fill // would still report the GPU path). Neither is a backend at all. - if !self.ctx.can_wait() || self.ctx.unavailable_kernels().iter().any(|(e, _)| crate::inpaint::KERNELS.contains(e)) { + if !self.ctx.can_wait() || !crate::inpaint::KERNELS.iter().all(|e| self.ctx.has_kernel(e)) { return None; } Some(self as &dyn effectcraft_raster::inpaint::FillBackend) diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 35ad402ba..96e49dd7d 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -137,7 +137,7 @@ fn numerical_effects_remain_resident_on_vulkan() { return; } for entry in ["fxs_point", "fxt_point", "ftr_grid", "fp2_hextile"] { - assert!(!g.ctx.unavailable_kernels().iter().any(|(name, _)| *name == entry), "{entry}: {:?}", g.ctx.unavailable_kernels()); + assert!(g.ctx.has_kernel(entry), "{entry}: {:?}", g.ctx.unavailable_kernels()); } for (id, vals) in [ ("ec.stylize.posterize", vec![("level", n(4.0))]), @@ -1107,7 +1107,7 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { ctx.drop_kernel("pointwise"); ctx.drop_kernel("box_h"); let names: Vec<&str> = ctx.unavailable_kernels().iter().map(|(e, _)| *e).collect(); - assert_eq!(names, ["pointwise", "box_h"]); + assert_eq!(names, ["box_h", "pointwise"]); let g = Gpu::from_context(ctx); for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { let mut s = Scene::new(depth); @@ -1127,6 +1127,24 @@ fn effects_whose_kernels_failed_render_on_the_cpu() { } } +/// #1590: effect kernels are built on first use, not with the context (building all of them +/// up front kept Chrome's GPU process busy past its watchdog). Once asked for, every one of +/// them builds on this device: a kernel no test dispatches still can't break unnoticed. +#[test] +fn effect_kernels_build_on_first_use() { + if gpu().is_none() { + return; + } + let Ok(ctx) = pollster::block_on(crate::context::GpuContext::request()) else { return }; + let built = |ctx: &crate::context::GpuContext| ctx.effect_pipelines_built(); + assert_eq!(built(&ctx), 0, "no effect kernel is built with the context"); + let started = std::time::Instant::now(); + ctx.build_all_kernels(); + eprintln!("{}: built {} effect kernels in {:.2?}", ctx.name, built(&ctx), started.elapsed()); + assert_eq!(ctx.unavailable_kernels(), vec![], "every effect kernel builds on {}", ctx.name); + assert!(ctx.has_kernel("classic3d") && ctx.has_kernel("math_probe"), "the up-front kernels build on {}", ctx.name); +} + /// WGSL cannot read the CPU's constants, so Glow's bright pass repeats them as literals; this /// ties them to `effects::GLOW_GAIN` / `GLOW_KNEE` (runs without a GPU). #[test] diff --git a/crates/gpu/src/tests/inpaint.rs b/crates/gpu/src/tests/inpaint.rs index e00d3cc18..7d6f43acd 100644 --- a/crates/gpu/src/tests/inpaint.rs +++ b/crates/gpu/src/tests/inpaint.rs @@ -44,7 +44,7 @@ fn rect_hole(w: u32, h: u32, x0: u32, y0: u32, s: u32) -> Vec { #[test] fn the_inpaint_kernels_are_available() { let Some(g) = gpu() else { return }; - let bad: Vec<&str> = g.ctx.unavailable_kernels().iter().filter(|(e, _)| crate::inpaint::KERNELS.contains(e)).map(|(e, _)| *e).collect(); + let bad: Vec<&str> = crate::inpaint::KERNELS.iter().copied().filter(|e| !g.ctx.has_kernel(e)).collect(); assert!(bad.is_empty(), "inpaint kernels did not build on {}: {bad:?}", g.ctx.name); }