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

commit005532b14ca0773cd869b4765322928f6bab3207
parentc3b7648eec
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 14:22
perf(vk): a window no longer reserves ~320 MiB of GPU memory up front

Every client held ~355-425 MiB of GPU memory (i915 fdinfo, resident) —
the status bar's clock module as much as a full window. On the iGPU that
is system RAM, ~4 GiB across a session's clients. Two causes:

- gpu-allocator reserved a 256 MiB device block and a 64 MiB host block
  on the first allocation of each, for ~60 MiB of allocations. Its
  0.27.0 `Allocator::new` ignored the sizes passed to it, so 0.28 is
  taken, and blocks now start at 8 / 4 MiB and double as a client needs
  more, up to the old sizes; a larger allocation gets its own block.
- The 3D scene's backdrop and depth targets were made at the surface's
  size in every window (~33 MiB at 2560x1600). They are 1x1 until the
  first stage_scene / stage_rt grows them; a 2D frame's frost samples
  the zeroed backdrop through a clamp-to-edge sampler at normalized
  coordinates, which a zeroed 1x1 reads the same.

probe_native in a scale-2 shadow: 425 -> 156 MiB windowed, 364 -> 96
fullscreen; probe3d_native 425 -> 237 / 364 -> 144. Fullscreen shots of
both pixel-identical to the old build (the 3D one after a resize with
its targets grown). A plain vkcube holds 42 MiB here, the driver floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 Cargo.lock         |  6 +++---
 Cargo.toml         |  2 +-
 src/vk/core.rs     | 17 ++++++++++++++++-
 src/vk/renderer.rs | 23 ++++++++++++++++++++++-
 src/vk/scene.rs    | 23 +++++++++++++++++++++++
 5 files changed, 65 insertions(+), 6 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 54883e1..9a5a950 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -859,14 +859,14 @@ checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee"
 
 [[package]]
 name = "gpu-allocator"
-version = "0.27.0"
+version = "0.28.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
 dependencies = [
  "ash",
  "log",
  "presser",
- "thiserror 1.0.69",
+ "thiserror 2.0.19",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index acb51d7..f31c838 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,7 +56,7 @@ web-time = "1"
 raw-window-handle = "0.6"
 # Raw-Vulkan backend (versions match what wgpu 24 already pulled in).
 ash = "0.38"
-gpu-allocator = { version = "0.27", default-features = false, features = ["vulkan"] }
+gpu-allocator = { version = "0.28", default-features = false, features = ["vulkan", "std"] }
 naga = { version = "24", features = ["wgsl-in", "spv-out"] }
 libc = "0.2"
 rfd = "0.15"
diff --git a/src/vk/core.rs b/src/vk/core.rs
index dba60a3..8eb9fd2 100644
--- a/src/vk/core.rs
+++ b/src/vk/core.rs
@@ -14,6 +14,21 @@ use std::ffi::{c_void, CStr, CString};
 use ash::vk;
 use gpu_allocator::vulkan::{Allocator, AllocatorCreateDesc};
 
+/// Memory blocks start small and double as a client needs more, up to the
+/// allocator's old fixed sizes. Those defaults (a 256 MiB device block and a
+/// 64 MiB host one, reserved on the first allocation of each) held ~320 MiB
+/// for every client, against ~60 MiB of allocations in a typical window — a
+/// status-bar module included. On the iGPU that is system RAM. Until
+/// gpu-allocator 0.28 the sizes passed here were ignored (0.27.0's
+/// `Allocator::new` stored the defaults whatever the descriptor said).
+/// An allocation larger than the current block size gets one of its own.
+fn allocation_sizes() -> gpu_allocator::AllocationSizes {
+    const MIB: u64 = 1024 * 1024;
+    gpu_allocator::AllocationSizes::new(8 * MIB, 4 * MIB)
+        .with_max_device_memblock_size(256 * MIB)
+        .with_max_host_memblock_size(64 * MIB)
+}
+
 const VALIDATION_LAYER: &CStr = c"VK_LAYER_KHRONOS_validation";
 
 unsafe extern "system" fn debug_callback(
@@ -653,7 +668,7 @@ impl VkCore {
             physical_device,
             debug_settings: Default::default(),
             buffer_device_address: ray_query,
-            allocation_sizes: Default::default(),
+            allocation_sizes: allocation_sizes(),
         })
         .expect("Failed to create GPU allocator");
 
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index ae68dfd..5ab486a 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1415,10 +1415,11 @@ impl VkRenderer {
     /// a compositor on the integrated one — its swapchain is rebuilt at the
     /// same size a few frames in (`CCE_PRESENT_DEBUG` logs every rebuild).
     fn sync_backdrop_targets(&mut self) {
+        let extent = self.scene.target_extent(self.extent);
         let recreated = self.scene.resize(
             &self.core.device,
             self.core.allocator.as_mut().unwrap(),
-            self.extent,
+            extent,
         );
         if recreated {
             clear_image_to_shader_read(
@@ -1553,9 +1554,27 @@ impl VkRenderer {
     /// with no staged scene reuse the previous backdrop — the ash equivalent of
     /// the app's viewport-changed cache.
     pub fn stage_scene(&mut self, scissor: (u32, u32, u32, u32), draws: Vec<SceneDraw>) {
+        self.want_scene_targets();
         self.scene.stage(scissor, draws);
     }
 
+    /// Grow the backdrop and depth targets to the surface the first time a
+    /// scene is staged; until then they are 1×1 (`Scene::target_extent`).
+    /// Runs between frames, the device idle, as a resize does.
+    fn want_scene_targets(&mut self) {
+        if self.scene.wanted {
+            return;
+        }
+        self.scene.wanted = true;
+        if self.surface == vk::SurfaceKHR::null() {
+            return; // sized with the swapchain when one exists
+        }
+        unsafe {
+            let _ = self.core.device.device_wait_idle();
+        }
+        self.sync_backdrop_targets();
+    }
+
     /// Add textured quads to the scene staged by the last [`stage_scene`] —
     /// user images (ids from `upload_rgba`) standing in the 3D world, depth
     /// tested against the meshes. Call it AFTER `stage_scene`, which starts
@@ -1650,6 +1669,8 @@ impl VkRenderer {
     /// RT mode is on: each frame adds a sample; a camera/pane/scene change
     /// restarts the accumulation. No-op until `set_rt_scene` has run.
     pub fn stage_rt(&mut self, pane: (u32, u32, u32, u32), camera: RtCamera) {
+        // The tracer blits into the backdrop at the surface's size.
+        self.want_scene_targets();
         if let Some(rt) = self.rt.as_mut() {
             rt.set_background(self.rt_background);
             rt.set_environment(self.rt_environment);
diff --git a/src/vk/scene.rs b/src/vk/scene.rs
index 4e3c072..ac41f32 100644
--- a/src/vk/scene.rs
+++ b/src/vk/scene.rs
@@ -81,6 +81,10 @@ pub(crate) struct SceneStage {
     staged: Option<StagedScene>,
     /// True once the backdrop holds rendered content worth copying to screen.
     pub(crate) backdrop_valid: bool,
+    /// Whether the app has ever staged a scene (or a traced one). Until it
+    /// has, the backdrop and depth targets are 1×1 — see
+    /// [`Scene::target_extent`].
+    pub(crate) wanted: bool,
     /// Toward the flat shading's light, unit length — see
     /// `VkRenderer::set_scene_light`.
     pub(crate) light: [f32; 3],
@@ -549,8 +553,10 @@ impl SceneStage {
                 frames,
                 staged: None,
                 backdrop_valid: false,
+                wanted: false,
                 light: glam::Vec3::from_array(DEFAULT_SCENE_LIGHT).normalize().to_array(),
             };
+            let extent = stage.target_extent(extent);
             stage.resize(device, allocator, extent);
             stage
         }
@@ -573,6 +579,23 @@ impl SceneStage {
         }
     }
 
+    /// The size the backdrop and depth targets are made at for a surface of
+    /// `surface`: the surface's, once a scene has been staged; 1×1 before.
+    ///
+    /// Most windows never draw a 3D scene, and full-surface targets cost
+    /// ~33 MiB a window at 2560×1600 (D32 depth plus the RGBA backdrop). A
+    /// 2D frame still samples the backdrop — the frosted root plate reads it,
+    /// zeroed — and a zeroed 1×1 image reads the same under the UI's
+    /// clamp-to-edge sampler at normalized coordinates. The first
+    /// `stage_scene` / `stage_rt` grows them (`VkRenderer::want_scene_targets`).
+    pub(crate) fn target_extent(&self, surface: vk::Extent2D) -> vk::Extent2D {
+        if self.wanted {
+            surface
+        } else {
+            vk::Extent2D { width: 1, height: 1 }
+        }
+    }
+
     fn destroy_targets(&mut self, device: &ash::Device, allocator: &mut Allocator) {
         unsafe {
             if self.framebuffer != vk::Framebuffer::null() {