GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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 = [