cloud storage client
git clone https://git.lucas.co/cce-cloud.git
Upload launcher icons once, not per open
Each launcher open uploaded its 46 row icons again, and the renderer
drains uploads at the first frame with a synchronous GPU copy each, then
drained the previous open's 46 frees with a device-idle wait each: ~90
stalls between the popup being ready and being drawn. Now that the daemon
keeps its renderer (7014b9f), icon_image caches each icon's image id for
the renderer's life (keyed on renderer_epoch, so a new renderer drops
them), the switcher shares it, and the per-popup frees are gone. The
daemon also decodes and queues the icons from a thread at startup, so the
first open after login skips the disk and the rasterizer too.
In a shadow under load average ~20-28, against the previous build:
launcher first open 789 -> 59 ms, median over 60 opens 172 -> 34 ms, worst
789 -> 59 ms. Third-open screenshots are pixel-identical; daemon RSS is
flat over 60 opens.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 17 +++++++------
src/main.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++-------------
2 files changed, 74 insertions(+), 23 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index eb35e92..178be60 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -128,11 +128,13 @@ bound with plain `get_keyboard`, and a held Backspace deleted one character.
an absolute path, missing, or a generic name several apps share — see
`cce-icons/hicolor/README.md`. Overrides named after the `Icon=` value need
nothing here: the user's data dir is the theme search's first base dir.
- Icons are uploaded per popup (`cce_ui::icon::upload_themed` caches the
- decode, not the image id), and the daemon frees them with
- `cce_ui::vk::free_image` when the popup closes: the renderer outlives the
- popup (see "One renderer for every popup" below), so an upload nobody freed
- would stay on the GPU for the daemon's life.
+ Icons are uploaded once per renderer, not per popup: `icon_image` caches
+ the image id by icon name for as long as `cce_ui::vk::renderer_epoch` holds,
+ which in the daemon is its whole life (see "One renderer for every popup"
+ below), and the daemon warms it from a thread at startup. Uploading per
+ popup cost a synchronous GPU copy per icon at the first frame and a
+ device-idle wait per free at the next: ~90 stalls for 46 icons, most of the
+ launcher's time to first frame until 2026-10-05.
Apps is also the one **tabbed** mode: the list carries an *Apps* page and a
*System* page of DE verbs (`SYSTEM_COMMANDS` — window-manager actions through
`ccectl`, plus session/power commands), and **Tab / Shift+Tab step between
@@ -230,8 +232,9 @@ startup on a scratch surface that is never mapped, so the first popup attaches
too. Until 2026-10-05 every popup built a new renderer, and its pipelines were
70-100 ms of a ~100 ms popup on an idle machine and several hundred under
load; a reopen is now one swapchain, and a menu popup is ready in ~2 ms and on
-screen in 15-40 ms. Anything a popup uploads to it (icons) must be freed at
-close for the same reason. Standalone mode still builds its own.
+screen in 15-40 ms, the launcher in 30-60. An image uploaded to it lives as
+long as it does: upload through a cache like `icon_image`, never per popup,
+or free it at close. Standalone mode still builds its own.
Surface choice: an XDG toplevel flagged as popup via the cce window-management
protocol when the compositor global is present and no `-x/-y` was given; otherwise a
diff --git a/src/main.rs b/src/main.rs
index 5abf2b5..b07489b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -925,6 +925,42 @@ pub struct TabPage {
/// keeps from the selection chip's edge.
const ICON_PX: f32 = 26.0;
+/// The pixel size row icons are rasterized at: twice [`ICON_PX`], for a
+/// scale-2 output.
+const ICON_RASTER_PX: u32 = ICON_PX as u32 * 2;
+
+/// A row icon's image, uploaded once per renderer: `(id, width, height)`, or
+/// `None` when the theme has no such icon. The daemon keeps one renderer for
+/// its whole life, so the launcher's icons are uploaded on its first open (or
+/// by the startup warm-up, `run_daemon`) and every later open draws the same
+/// images. Uploading per popup cost a synchronous GPU copy per icon at the
+/// first frame, plus a device-idle wait per free at the next one: ~90 stalls
+/// for 46 icons, most of the launcher's time to first frame.
+///
+/// The cache follows [`cce_ui::vk::renderer_epoch`]: a different renderer
+/// means none of the ids name anything, so they are dropped and uploaded
+/// again. Thread-safe, because the warm-up runs off the main thread.
+fn icon_image(name: &str) -> Option<(u32, u32, u32)> {
+ use std::collections::HashMap;
+ use std::sync::Mutex;
+ type Cache = (u32, HashMap<String, Option<(u32, u32, u32)>>);
+ static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
+
+ let epoch = cce_ui::vk::renderer_epoch();
+ let mut guard = CACHE.lock().unwrap();
+ let (cached_epoch, ids) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
+ if *cached_epoch != epoch {
+ *cached_epoch = epoch;
+ ids.clear();
+ }
+ if let Some(hit) = ids.get(name) {
+ return *hit;
+ }
+ let img = cce_ui::icon::upload_themed(name, ICON_RASTER_PX);
+ ids.insert(name.to_string(), img);
+ img
+}
+
/// The list chrome's metrics. The spacing around them — the inset from the
/// popup edge, the gap under the tab strip and under the search well, the
/// text inset inside a well or a row — is the toolkit's ladder
@@ -1964,17 +2000,14 @@ impl State {
sort_apps_by_history(&mut apps);
let app_names: Vec<String> = apps.iter().map(|app| app.name.clone()).collect();
- // Resolve every entry's Icon= against the icon theme. Uploads are
- // per-popup, and the daemon frees them when the popup closes (the
- // renderer itself is kept). Only the decode is cached (see
- // cce_ui::icon::upload_themed), so the second open of the launcher
- // skips the disk and the rasterizer.
+ // Resolve every entry's Icon= against the icon theme. Each icon is
+ // uploaded once for the daemon's life (see `icon_image`), so after
+ // the first open this is a map lookup per entry.
let t_icons = std::time::Instant::now();
let icons: std::collections::HashMap<String, (u32, u32, u32)> = apps
.iter()
.filter_map(|app| {
- let name = app.icon.as_deref()?;
- let img = cce_ui::icon::upload_themed(name, ICON_PX.ceil() as u32 * 2)?;
+ let img = icon_image(app.icon.as_deref()?)?;
Some((app.name.clone(), img))
})
.collect();
@@ -2151,8 +2184,8 @@ impl State {
/// Give the switcher's window rows their app's icon. Rows stream in over
/// stdin, so this runs per ingest and only resolves the rows that don't
- /// have an icon yet. Uploads are per-popup, like Apps mode's (see
- /// `State::new`). `index` is `State::switcher_icon_index`.
+ /// have an icon yet. Each icon is uploaded once and shared with the
+ /// launcher (`icon_image`). `index` is `State::switcher_icon_index`.
fn resolve_switcher_icons(
fuzzel: &mut FuzzelWidget,
index: &mut Option<std::collections::HashMap<String, String>>,
@@ -2167,7 +2200,7 @@ impl State {
.into_iter()
.filter_map(|item| {
let name = icon_name_for_app_id(index, split_switcher_row(item).1);
- let img = cce_ui::icon::upload_themed(&name, ICON_PX.ceil() as u32 * 2)?;
+ let img = icon_image(&name)?;
Some((item.clone(), img))
})
.collect();
@@ -3948,6 +3981,26 @@ fn run_daemon(socket_path: &str) {
};
log::info!("[timing] daemon renderer prewarm: {:?}", t_renderer.elapsed());
+ // Decode and queue the launcher's icons now, off the main thread, so its
+ // first open does not read and rasterize every one (~350 ms). The uploads
+ // drain into the renderer at the first popup's first frame. An open that
+ // starts before this finishes simply shares the cache with it.
+ if renderer_slot.is_some() {
+ std::thread::spawn(|| {
+ let t = std::time::Instant::now();
+ let apps = scan_apps();
+ let resolved = apps
+ .iter()
+ .filter(|app| app.icon.as_deref().and_then(icon_image).is_some())
+ .count();
+ log::info!(
+ "[timing] launcher icon prewarm: {resolved} of {} in {:?}",
+ apps.len(),
+ t.elapsed()
+ );
+ });
+ }
+
let scale = cce_ui::wayland::detect_scale_factor(&app.output_state);
let xdg_shell_state = smithay_client_toolkit::shell::xdg::XdgShell::bind(&globals, &qh).ok();
@@ -4361,12 +4414,7 @@ fn run_daemon(socket_path: &str) {
fonts_slot = Some((fs, sc));
// Keep the renderer for the next popup, detached now: the State's
// drop below destroys this wl_surface, and the swapchain must not
- // outlive it. Its images outlive the popup too, so free the row
- // icons this popup uploaded (Apps, the switcher) or every open of
- // the launcher would leave another set on the GPU.
- for (_, (id, _, _)) in st.fuzzel.icons.drain() {
- cce_ui::vk::free_image(id);
- }
+ // outlive it. The row icons stay uploaded with it (`icon_image`).
if let Some(mut r) = st.renderer.take() {
r.detach_surface();
renderer_slot = Some(r);