git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitefb8a1be0e173a6579be6f59c786468930151a7d
parent75d27e1bad
authorLucas Galante <[email protected]>
date2026-07-14 21:00
perf: share the Vulkan instance process-wide and cache WGSL compiles

Instance creation (ICD enumeration + driver init) cost ~70ms warm — and
seconds on a cold cache — and daemon-style consumers (cce-cloud) build a
VkRenderer per popup window. The entry/instance/debug-messenger now live
in a process-wide OnceLock shared by every VkCore; per-core teardown
stops at the device. Surface extensions are enabled when the loader
offers them, so the one instance serves windowed and headless cores.

Also memoize the naga WGSL->SPIR-V compiles of the three always-built UI
shaders, and add vk::prewarm() so a daemon can pay the instance/driver/
shader costs at startup instead of on its first window.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/vk/core.rs     | 150 +++++++++++++++++++++++++++++++++++------------------
 src/vk/image.rs    |   6 +--
 src/vk/mod.rs      |  13 +++++
 src/vk/renderer.rs |  28 +++++++++-
 src/vk/scene.rs    |   6 +--
 src/vk/text.rs     |   6 +--
 6 files changed, 147 insertions(+), 62 deletions(-)

diff --git a/src/vk/core.rs b/src/vk/core.rs
index 3a23ff2..bea6b2f 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -43,8 +43,8 @@ unsafe extern "system" fn debug_callback(
 
 pub struct VkCore {
     // Field order is drop order: allocator and command pool go before the
-    // device, the device before debug/instance; `_entry` (the loaded library)
-    // must outlive everything.
+    // device. The instance (and the loaded library) is process-shared and
+    // never destroyed — see `shared_instance()`.
     pub(crate) allocator: Option<Allocator>,
     pub(crate) command_pool: vk::CommandPool,
     pub(crate) queue: vk::Queue,
@@ -57,53 +57,33 @@ pub struct VkCore {
     pub(crate) device: ash::Device,
     pub(crate) physical_device: vk::PhysicalDevice,
     pub(crate) surface_loader: ash::khr::surface::Instance,
-    pub(crate) debug: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>,
     pub(crate) instance: ash::Instance,
-    pub(crate) _entry: ash::Entry,
     pub(crate) min_uniform_align: vk::DeviceSize,
     /// minAccelerationStructureScratchOffsetAlignment; 1 when no ray-query stack.
     pub(crate) as_scratch_align: vk::DeviceSize,
 }
 
-impl VkCore {
-    /// A core bound to a Wayland surface: the returned `vk::SurfaceKHR` is
-    /// created from the raw pointers and the chosen device supports presenting
-    /// to it. The caller owns the surface handle (destroy it before the core).
-    ///
-    /// # Safety
-    /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
-    /// pointers that outlive the core and everything created from it.
-    pub unsafe fn new_for_wayland_surface(
-        display_ptr: *mut c_void,
-        surface_ptr: *mut c_void,
-    ) -> (Self, vk::SurfaceKHR) {
-        let (core, surface) = Self::new_inner(Some((display_ptr, surface_ptr)));
-        (core, surface.expect("surface requested but not created"))
-    }
+/// The process-wide Vulkan entry + instance every [`VkCore`] hangs off.
+///
+/// Instance creation is the expensive part of bringing up a renderer (ICD
+/// enumeration + driver init, ~70ms warm and much worse on a cold cache), and
+/// popup-style consumers (the cce-cloud daemon) create a renderer per window —
+/// so the instance is created once and intentionally lives for the process.
+struct SharedInstance {
+    entry: ash::Entry,
+    instance: ash::Instance,
+    // Held so the messenger stays alive; never destroyed.
+    _debug: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>,
+    /// VK_KHR_surface + VK_KHR_wayland_surface were available and enabled.
+    has_wayland_surface: bool,
+    api_version: u32,
+}
 
-    /// A windowless core: no surface extensions, any graphics-capable device.
-    /// For offscreen rendering (thumbnails, previews) and compute.
-    pub fn new_headless() -> Self {
-        unsafe { Self::new_inner(None).0 }
-    }
-
-    unsafe fn new_inner(
-        wayland: Option<(*mut c_void, *mut c_void)>,
-    ) -> (Self, Option<vk::SurfaceKHR>) {
-        // CCE_VK_DEVICE: "integrated" (the default), "discrete", or a device
-        // name substring. An explicit request also lifts a session-wide ICD
-        // pin (VK_DRIVER_FILES / VK_ICD_FILENAMES) for THIS process — the
-        // common setup pins Vulkan to the iGPU to keep the dGPU asleep, which
-        // would otherwise make "discrete" unsatisfiable.
-        let device_pref = std::env::var("CCE_VK_DEVICE")
-            .ok()
-            .map(|v| v.to_lowercase())
-            .filter(|v| !v.is_empty());
-        if device_pref.is_some() {
-            std::env::remove_var("VK_DRIVER_FILES");
-            std::env::remove_var("VK_ICD_FILENAMES");
-        }
+static SHARED_INSTANCE: std::sync::OnceLock<SharedInstance> = std::sync::OnceLock::new();
 
+fn shared_instance() -> &'static SharedInstance {
+    SHARED_INSTANCE.get_or_init(|| unsafe {
+        let t = std::time::Instant::now();
         let entry = ash::Entry::load().expect("Failed to load libvulkan");
 
         // Validation when available (debug builds or CCE_VK_VALIDATION=1).
@@ -135,8 +115,21 @@ impl VkCore {
             .engine_name(app_name)
             .api_version(api_version);
 
+        // Surface extensions are enabled whenever the loader offers them, so
+        // the one shared instance serves both windowed and headless cores.
+        let ext_props = entry
+            .enumerate_instance_extension_properties(None)
+            .unwrap_or_default();
+        let has_inst_ext = |name: &CStr| {
+            ext_props
+                .iter()
+                .any(|e| CStr::from_ptr(e.extension_name.as_ptr()) == name)
+        };
+        let has_wayland_surface =
+            has_inst_ext(ash::khr::surface::NAME) && has_inst_ext(ash::khr::wayland_surface::NAME);
+
         let mut extension_names: Vec<*const i8> = Vec::new();
-        if wayland.is_some() {
+        if has_wayland_surface {
             extension_names.push(ash::khr::surface::NAME.as_ptr());
             extension_names.push(ash::khr::wayland_surface::NAME.as_ptr());
         }
@@ -184,11 +177,69 @@ impl VkCore {
             None
         };
 
+        log::debug!("[timing] shared Vulkan instance init: {:?}", t.elapsed());
+        SharedInstance {
+            entry,
+            instance,
+            _debug: debug,
+            has_wayland_surface,
+            api_version,
+        }
+    })
+}
+
+impl VkCore {
+    /// A core bound to a Wayland surface: the returned `vk::SurfaceKHR` is
+    /// created from the raw pointers and the chosen device supports presenting
+    /// to it. The caller owns the surface handle (destroy it before the core).
+    ///
+    /// # Safety
+    /// `display_ptr` and `surface_ptr` must be live `wl_display` / `wl_surface`
+    /// pointers that outlive the core and everything created from it.
+    pub unsafe fn new_for_wayland_surface(
+        display_ptr: *mut c_void,
+        surface_ptr: *mut c_void,
+    ) -> (Self, vk::SurfaceKHR) {
+        let (core, surface) = Self::new_inner(Some((display_ptr, surface_ptr)));
+        (core, surface.expect("surface requested but not created"))
+    }
+
+    /// A windowless core: no surface extensions, any graphics-capable device.
+    /// For offscreen rendering (thumbnails, previews) and compute.
+    pub fn new_headless() -> Self {
+        unsafe { Self::new_inner(None).0 }
+    }
+
+    unsafe fn new_inner(
+        wayland: Option<(*mut c_void, *mut c_void)>,
+    ) -> (Self, Option<vk::SurfaceKHR>) {
+        // CCE_VK_DEVICE: "integrated" (the default), "discrete", or a device
+        // name substring. An explicit request also lifts a session-wide ICD
+        // pin (VK_DRIVER_FILES / VK_ICD_FILENAMES) for THIS process — the
+        // common setup pins Vulkan to the iGPU to keep the dGPU asleep, which
+        // would otherwise make "discrete" unsatisfiable.
+        let device_pref = std::env::var("CCE_VK_DEVICE")
+            .ok()
+            .map(|v| v.to_lowercase())
+            .filter(|v| !v.is_empty());
+        if device_pref.is_some() {
+            std::env::remove_var("VK_DRIVER_FILES");
+            std::env::remove_var("VK_ICD_FILENAMES");
+        }
+
+        let shared = shared_instance();
+        let entry = &shared.entry;
+        let instance = shared.instance.clone();
+        let api_version = shared.api_version;
+        if wayland.is_some() && !shared.has_wayland_surface {
+            panic!("Vulkan loader offers no VK_KHR_wayland_surface but a window was requested");
+        }
+
         // Instance-level loader; only usable when VK_KHR_surface was enabled.
-        let surface_loader = ash::khr::surface::Instance::new(&entry, &instance);
+        let surface_loader = ash::khr::surface::Instance::new(entry, &instance);
 
         let surface = wayland.map(|(display_ptr, surface_ptr)| {
-            let wayland_loader = ash::khr::wayland_surface::Instance::new(&entry, &instance);
+            let wayland_loader = ash::khr::wayland_surface::Instance::new(entry, &instance);
             wayland_loader
                 .create_wayland_surface(
                     &vk::WaylandSurfaceCreateInfoKHR::default()
@@ -327,6 +378,7 @@ impl VkCore {
                 .push_next(&mut rq_features);
             log::info!("Vulkan ray-query stack enabled (RT tier 2 available)");
         }
+        let t = std::time::Instant::now();
         let device = instance
             .create_device(
                 physical_device,
@@ -334,6 +386,7 @@ impl VkCore {
                 None,
             )
             .expect("Failed to create Vulkan device");
+        log::debug!("[timing] vk create_device: {:?}", t.elapsed());
         let queue = device.get_device_queue(queue_family, 0);
         let accel_loader =
             ray_query.then(|| ash::khr::acceleration_structure::Device::new(&instance, &device));
@@ -375,9 +428,7 @@ impl VkCore {
                 device,
                 physical_device,
                 surface_loader,
-                debug,
                 instance,
-                _entry: entry,
                 min_uniform_align,
                 as_scratch_align,
             },
@@ -390,14 +441,11 @@ impl Drop for VkCore {
     fn drop(&mut self) {
         unsafe {
             let _ = self.device.device_wait_idle();
-            // The allocator must go before the device it allocates from.
+            // The allocator must go before the device it allocates from. The
+            // instance is process-shared and intentionally never destroyed.
             drop(self.allocator.take());
             self.device.destroy_command_pool(self.command_pool, None);
             self.device.destroy_device(None);
-            if let Some((loader, messenger)) = self.debug.take() {
-                loader.destroy_debug_utils_messenger(messenger, None);
-            }
-            self.instance.destroy_instance(None);
         }
     }
 }
diff --git a/src/vk/image.rs b/src/vk/image.rs
index 0caf84c..cbb2264 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -17,7 +17,7 @@ use ash::vk;
 use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
 use gpu_allocator::MemoryLocation;
 
-use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 
 /// One image draw in a 2D frame.
 pub struct ImageQuad {
@@ -121,9 +121,9 @@ impl ImageStage {
                 .expect("Failed to create image pipeline layout");
 
             // Same shader as glyphs: sampled texel * vertex color (+ circle clip).
-            let spirv = compile_wgsl(include_str!("glyph.wgsl"));
+            let spirv = super::renderer::glyph_spirv();
             let shader_module = device
-                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
+                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
                 .expect("Failed to create image shader module");
             let stages = [
                 vk::PipelineShaderStageCreateInfo::default()
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 3a9e3cb..0558ccd 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -44,3 +44,16 @@ pub use renderer::{Batch2D, Frame2D, VkRenderer};
 pub use rt::{RtCamera, RtMaterial, RtOffscreen, RtTriangle};
 pub use scene::{MeshId, SceneDraw, Vertex3D};
 pub use text::TextSpan;
+
+/// Pay the process-wide, window-independent renderer costs up front: the
+/// shared Vulkan instance (ICD enumeration + driver init), one throwaway
+/// device (loads the driver's device-level libraries), and the naga WGSL
+/// compiles. For daemon-style processes (cce-cloud) that build a renderer per
+/// window: called at daemon startup, it moves the multi-second cold-cache hit
+/// off the first window's critical path.
+pub fn prewarm() {
+    renderer::shader2d_spirv();
+    renderer::glyph_spirv();
+    renderer::scene3d_spirv();
+    drop(VkCore::new_headless());
+}
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index b6dd56c..216a166 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -187,6 +187,24 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
     naga::back::spv::write_vec(&module, &info, &options, None).expect("SPIR-V write failed")
 }
 
+/// Cached SPIR-V for the always-compiled UI shaders. The daemon-style
+/// consumers (cce-cloud) build a renderer per popup; naga compilation is pure,
+/// so compile each shader once per process.
+pub(crate) fn shader2d_spirv() -> &'static [u32] {
+    static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
+    SPIRV.get_or_init(|| compile_wgsl(include_str!("shader2d.wgsl")))
+}
+
+pub(crate) fn glyph_spirv() -> &'static [u32] {
+    static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
+    SPIRV.get_or_init(|| compile_wgsl(include_str!("glyph.wgsl")))
+}
+
+pub(crate) fn scene3d_spirv() -> &'static [u32] {
+    static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
+    SPIRV.get_or_init(|| compile_wgsl(include_str!("scene3d.wgsl")))
+}
+
 /// Like [`compile_wgsl`], but with naga's RAY_QUERY capability and SPIR-V 1.4
 /// (required by SPV_KHR_ray_query). Only used on devices where the ray-query
 /// device stack was enabled — those are Vulkan 1.2+, which accepts 1.4.
@@ -314,8 +332,11 @@ impl VkRenderer {
         height: u32,
         corner_radius_px: f32,
     ) -> Self {
+        let t_new = std::time::Instant::now();
         let (mut core, surface) =
             super::core::VkCore::new_for_wayland_surface(display_ptr, surface_ptr);
+        log::debug!("[timing] VkCore::new_for_wayland_surface: {:?}", t_new.elapsed());
+        let t_rest = std::time::Instant::now();
         // Locals over the core for the setup below (methods use self.core.*).
         let device = core.device.clone();
         let queue = core.queue;
@@ -437,9 +458,9 @@ impl VkRenderer {
             .expect("Failed to create pipeline layout");
 
         // Pipeline from shader.wgsl (both entry points live in one SPIR-V module).
-        let spirv = compile_wgsl(include_str!("shader2d.wgsl"));
+        let spirv = shader2d_spirv();
         let shader_module = device
-            .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
+            .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
             .expect("Failed to create shader module");
 
         let stages = [
@@ -687,11 +708,14 @@ impl VkRenderer {
             swapchain_dirty: false,
             core,
         };
+        log::debug!("[timing] VkRenderer pipelines/stages: {:?}", t_rest.elapsed());
+        let t_swap = std::time::Instant::now();
         renderer.create_swapchain();
         renderer.write_window_info();
         // The swapchain may have settled on a different extent than requested;
         // keep the backdrop targets in lockstep.
         renderer.sync_backdrop_targets();
+        log::debug!("[timing] swapchain setup: {:?}", t_swap.elapsed());
         renderer
     }
 
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index 615f5ca..115c884 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -14,7 +14,7 @@ use ash::vk;
 use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator};
 use gpu_allocator::MemoryLocation;
 
-use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 
 /// Layout-identical to the app's `geometry::Vertex3D` (bytemuck-castable at cutover).
 #[repr(C)]
@@ -187,9 +187,9 @@ impl SceneStage {
                 )
                 .expect("Failed to create scene pipeline layout");
 
-            let spirv = compile_wgsl(include_str!("scene3d.wgsl"));
+            let spirv = super::renderer::scene3d_spirv();
             let shader_module = device
-                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
+                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
                 .expect("Failed to create 3D shader module");
             let stages = [
                 vk::PipelineShaderStageCreateInfo::default()
diff --git a/src/vk/text.rs b/src/vk/text.rs
index 433555f..8403e24 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -25,7 +25,7 @@ use gpu_allocator::MemoryLocation;
 use glyphon::cosmic_text::{Buffer as TextBuffer, CacheKey, SwashContent};
 use glyphon::{FontSystem, SwashCache};
 
-use super::renderer::{compile_wgsl, create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
+use super::renderer::{create_cpu_buffer, destroy_cpu_buffer, AllocatedBuffer};
 
 const ATLAS_SIZE: u32 = 1024;
 const ATLAS_PAD: u32 = 1;
@@ -170,9 +170,9 @@ impl TextStage {
                 )
                 .expect("Failed to create text pipeline layout");
 
-            let spirv = compile_wgsl(include_str!("glyph.wgsl"));
+            let spirv = super::renderer::glyph_spirv();
             let shader_module = device
-                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(&spirv), None)
+                .create_shader_module(&vk::ShaderModuleCreateInfo::default().code(spirv), None)
                 .expect("Failed to create glyph shader module");
 
             let stages = [