client: start the GPU device and font stack before the first window #420

Merged
natsukium merged 4 commits from client/overlap-startup into main 2026-09-25 07:46:27 +09:00
Owner

Closes #402.

What changes

A window launch no longer runs its steps strictly in series. felis-client starts the wgpu instance, adapter and device on a helper thread right after loading its config, before it dials the daemon, and starts font discovery on a second thread once the session is attached. resumed still creates the window and the surface on the main thread, then takes both results.

  • Adapter choice is unchanged. The warm adapter is requested without a surface and kept only if adapter.is_surface_supported(&surface). wgpu-core 30's surface-aware request_adapter filters the same enumerated list with the same get_capabilities test and then stable-sorts it by device type, so an accepted warm adapter is the one the surface-aware pick would return. A rejected one falls back to that pick.
  • Failures are unchanged. Any warm step that failed is repeated on the original path, so the errors, their order and the exit codes are those of main. A warm font stack is used only if the font inputs still match at resumed (a config reload can land first). Fonts start after the attach, so a launch whose dial fails logs no font warnings.
  • Early exits stay bounded. A warmup that goes unused (for example, the daemon cannot be reached) waits at most 2 s for the GPU thread and then leaves it. The common case finishes inside that wait, so the driver's exit handlers do not run during a call. A stalled driver no longer holds up the error exit.

Not taken: overlapping the dial with the event loop and window creation. A warm dial plus attach costs 2 ms on both platforms, and a window built before the dial resolves would appear even when the daemon cannot be reached. Instance cost that scales with the number of installed Vulkan ICDs (35-50 ms on this host) belongs to the system loader and is left alone.

No docs under docs/ describe the startup order, so the only doc change is a CHANGELOG entry.

Measurements

Linux (i5-12400F, RTX 3080, niri, NVIDIA ICD pinned through VK_DRIVER_FILES because the userland and kernel module do not match). Startup suite (crossterm.py run startup, with foot as the one comparison leg the harness requires). Base and branch alternate, 10 timed launches after 3 discarded per leg. Adapter confirmed in the log: NVIDIA GeForce RTX 3080.

build rounds load cold, median (IQR) warm, median (IQR)
origin/main 37e99833 4 2.2-3.8 192.9 (175.7-213.8) 149.8 (141.4-161.3)
1f632258 4 2.2-3.8 144.0 (136.7-159.2) 143.5 (136.8-156.9)
origin/main 37e99833 3* 2.4-6.1 201.1 (186.8-213.9) 152.2 (146.4-158.4)
HEAD 621515f3 4 2.4-6.1 152.6 (140.4-161.2) 144.5 (136.2-151.5)

* One base round was dropped because another agent opened a felis window during it (foot's leg rose from 21 to 34 ms).

A cold launch is now about as fast as a warm one: the 30-40 ms daemon hand-off runs while the Vulkan device is being created.

macOS (M4 Max). The console was at the login window, with no user session and the display asleep, so no window could reach the screen and CGWindowList first-window timing was not possible. Instead I timed spawn → window + surface ready from the client's own log, the end of the first resumed after which AppKit orders the window in. Base and branch alternate, 10 timed launches after 3 discarded, cold with no daemon, warm with a daemon started outside the launch tree (alive before 10/10 launches). Load 2.1-2.7:

build cold, median (IQR) warm, median (IQR)
origin/main 37e99833 142.4 (127.8-143.9) 127.4 (118.3-138.5)
1f632258 105.8 (105.1-110.1) 103.2 (101.4-117.3)

HEAD against 1f632258 on the Mac (4 rounds, load 1.9-2.7): no difference. Both builds split into the same 105/121 ms bands, and so does process start: config loads at 19 or 25 ms.

The macOS cold/warm inversion in the published figures is a harness artifact (#408) and is out of scope here.

Review

pi (sol), two rounds:

  1. BLOCKED. (a) Blocker: the unconditional join on an unused warmup could hang an unreachable-daemon exit on a stalled driver. Accepted and fixed in 621515f3: the drop now waits at most 2 s. (b) Font warnings could appear before the attach and on launches that never reach a window. Accepted: fonts now start after the attach.
  2. PASS, with one non-blocking finding: font warnings can still appear when window or surface creation fails after the attach, or from a scan discarded by a config reload before resumed. Rejected: both cases need a failure that ends the launch anyway, or a reload inside the roughly 100 ms before the first resumed. The warning text still describes the configured font correctly. Holding the warnings back would need a tracing capture layer in the renderer.
Closes #402. ## What changes A window launch no longer runs its steps strictly in series. `felis-client` starts the wgpu instance, adapter and device on a helper thread right after loading its config, before it dials the daemon, and starts font discovery on a second thread once the session is attached. `resumed` still creates the window and the surface on the main thread, then takes both results. - **Adapter choice is unchanged.** The warm adapter is requested without a surface and kept only if `adapter.is_surface_supported(&surface)`. wgpu-core 30's surface-aware `request_adapter` filters the same enumerated list with the same `get_capabilities` test and then stable-sorts it by device type, so an accepted warm adapter is the one the surface-aware pick would return. A rejected one falls back to that pick. - **Failures are unchanged.** Any warm step that failed is repeated on the original path, so the errors, their order and the exit codes are those of `main`. A warm font stack is used only if the font inputs still match at `resumed` (a config reload can land first). Fonts start after the attach, so a launch whose dial fails logs no font warnings. - **Early exits stay bounded.** A warmup that goes unused (for example, the daemon cannot be reached) waits at most 2 s for the GPU thread and then leaves it. The common case finishes inside that wait, so the driver's exit handlers do not run during a call. A stalled driver no longer holds up the error exit. Not taken: overlapping the dial with the event loop and window creation. A warm dial plus attach costs 2 ms on both platforms, and a window built before the dial resolves would appear even when the daemon cannot be reached. Instance cost that scales with the number of installed Vulkan ICDs (35-50 ms on this host) belongs to the system loader and is left alone. No docs under `docs/` describe the startup order, so the only doc change is a CHANGELOG entry. ## Measurements **Linux** (i5-12400F, RTX 3080, niri, NVIDIA ICD pinned through `VK_DRIVER_FILES` because the userland and kernel module do not match). Startup suite (`crossterm.py run startup`, with foot as the one comparison leg the harness requires). Base and branch alternate, 10 timed launches after 3 discarded per leg. Adapter confirmed in the log: `NVIDIA GeForce RTX 3080`. | build | rounds | load | cold, median (IQR) | warm, median (IQR) | |---|---|---|---|---| | origin/main 37e99833 | 4 | 2.2-3.8 | 192.9 (175.7-213.8) | 149.8 (141.4-161.3) | | 1f632258 | 4 | 2.2-3.8 | 144.0 (136.7-159.2) | 143.5 (136.8-156.9) | | origin/main 37e99833 | 3* | 2.4-6.1 | 201.1 (186.8-213.9) | 152.2 (146.4-158.4) | | HEAD 621515f3 | 4 | 2.4-6.1 | 152.6 (140.4-161.2) | 144.5 (136.2-151.5) | \* One base round was dropped because another agent opened a felis window during it (foot's leg rose from 21 to 34 ms). A cold launch is now about as fast as a warm one: the 30-40 ms daemon hand-off runs while the Vulkan device is being created. **macOS** (M4 Max). The console was at the login window, with no user session and the display asleep, so no window could reach the screen and CGWindowList first-window timing was not possible. Instead I timed spawn → `window + surface ready` from the client's own log, the end of the first `resumed` after which AppKit orders the window in. Base and branch alternate, 10 timed launches after 3 discarded, cold with no daemon, warm with a daemon started outside the launch tree (alive before 10/10 launches). Load 2.1-2.7: | build | cold, median (IQR) | warm, median (IQR) | |---|---|---| | origin/main 37e99833 | 142.4 (127.8-143.9) | 127.4 (118.3-138.5) | | 1f632258 | 105.8 (105.1-110.1) | 103.2 (101.4-117.3) | HEAD against 1f632258 on the Mac (4 rounds, load 1.9-2.7): no difference. Both builds split into the same 105/121 ms bands, and so does process start: config loads at 19 or 25 ms. The macOS cold/warm inversion in the published figures is a harness artifact (#408) and is out of scope here. ## Review pi (sol), two rounds: 1. BLOCKED. (a) Blocker: the unconditional join on an unused warmup could hang an unreachable-daemon exit on a stalled driver. Accepted and fixed in 621515f3: the drop now waits at most 2 s. (b) Font warnings could appear before the attach and on launches that never reach a window. Accepted: fonts now start after the attach. 2. PASS, with one non-blocking finding: font warnings can still appear when window or surface creation fails after the attach, or from a scan discarded by a config reload before `resumed`. Rejected: both cases need a failure that ends the launch anyway, or a reload inside the roughly 100 ms before the first `resumed`. The warning text still describes the configured font correctly. Holding the warnings back would need a tracing capture layer in the renderer.
Every step of a window launch ran in series: dial and attach, then the
event loop and the window, and only inside `resumed` the wgpu instance,
adapter, device and font discovery. On Linux with NVIDIA the instance
(34-113 ms, by installed ICD count) and the device (62-94 ms) are most
of the path, and a cold launch waited 29-38 ms for the daemon hand-off
before any of it began. None of that work needs the window or the
session; only the surface does.

The client now starts the instance, adapter and device on one thread
and font discovery on another right after loading its config, and
`resumed` joins them. The adapter is picked without the surface and
kept only if `is_surface_supported` accepts it: wgpu's surface-aware
pick filters the same stably sorted list, so an accepted warm adapter
is the one it would have returned, and a rejected one falls back to the
original pick with the surface. A warm step that failed is repeated on
the original path, so the errors, their order and the exit codes stay
those of the serial path. A warm font stack is used only if the font
inputs still match (a reload can land before the first `resumed`). An
unused warmup joins its GPU thread on drop, so an early exit (an
unreachable daemon) never returns from `main` while a driver call is in
flight.

Time to the first window, Linux (i5-12400F, RTX 3080, niri; startup
suite, 4 interleaved rounds of 10 launches, NVIDIA ICD only, load
2.2-3.8), median of 40:

  cold  192.9 -> 144.0 ms
  warm  149.8 -> 143.5 ms

Cold now matches warm: the dial hides behind the device request.
Overlapping the dial with the event loop and window as well was not
taken: warm dial and attach cost 2 ms on both platforms, and a window
built before the dial resolved would show for a daemon that cannot be
reached.

Assisted-by: Claude Code
The startup overlap changes what a user sees on every launch: the first
window appears sooner, most of all when the launch has to start the
daemon. The figures are the Linux startup-suite medians the change
was measured with; macOS could only be timed to the renderer being
ready (the console had no user session), which fell from 142 to 106 ms
cold and from 127 to 103 ms warm.

Assisted-by: Claude Code
client: bound the wait on an unused GPU warmup and start fonts after attach
Some checks failed
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Has been cancelled
darwin / detect relevant changes (pull_request) Has been cancelled
darwin / build felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / publish felis (aarch64-darwin) (pull_request) Has been cancelled
darwin / darwin docs-only no-op (pull_request) Has been cancelled
fuzz / cargo fuzz smoke (per target) (pull_request) Has been cancelled
pr / nix flake check (pull_request) Has been cancelled
pr / cargo build / clippy / test / deny (pull_request) Has been cancelled
pr / wire schema is compatible with the base (pull_request) Has been cancelled
pr / frontend smoke (x86_64-linux) (pull_request) Has been cancelled
pr / publish felis (x86_64-linux) (pull_request) Has been cancelled
windows / detect relevant changes (pull_request) Has been cancelled
windows / cargo clippy (Windows cross) (pull_request) Has been cancelled
windows / cargo nextest (Windows) (pull_request) Has been cancelled
windows / frontend smoke (Windows) (pull_request) Has been cancelled
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been cancelled
windows / windows docs-only no-op (pull_request) Has been cancelled
621515f39f
An unused warmup joined its GPU thread unboundedly on drop, so a driver
that stalls in instance or device creation would keep a launch whose
daemon cannot be reached from ever reporting that and exiting; that
path never touched the GPU before. It now waits at most 2 s, long
enough for the 100-200 ms these calls take on NVIDIA/Vulkan, so the
common early exit still does not run the driver's exit handlers under
a call in flight, and a stalled one no longer hangs it.

Font discovery started before the dial as well, so a launch that never
reached a window could log font warnings (a missing family) it never
logged before. It now starts once the session is attached, which keeps
it off the window's path: on macOS the 12 ms it takes still runs beside
the event loop, the wait for resumed and the window creation.

Assisted-by: Claude Code
Merge remote-tracking branch 'origin/main' into client/overlap-startup
All checks were successful
bench / Criterion full-suite snapshot (pull_request) Has been skipped
fuzz / cargo fuzz nightly long-run (pull_request) Has been skipped
bench / Criterion regression gate (pull_request) Successful in 8m6s
darwin / darwin docs-only no-op (pull_request) Has been skipped
pr / nix flake check (pull_request) Successful in 6m24s
pr / cargo build / clippy / test / deny (pull_request) Successful in 4m16s
pr / wire schema is compatible with the base (pull_request) Successful in 1m5s
windows / windows docs-only no-op (pull_request) Has been skipped
windows / cargo nextest (Windows) (pull_request) Successful in 3m34s
windows / frontend smoke (Windows) (pull_request) Successful in 1m43s
darwin / detect relevant changes (pull_request) Successful in 3s
darwin / build felis (aarch64-darwin) (pull_request) Successful in 54s
darwin / publish felis (aarch64-darwin) (pull_request) Has been skipped
fuzz / cargo fuzz smoke (per target) (pull_request) Successful in 3m8s
pr / frontend smoke (x86_64-linux) (pull_request) Successful in 3m36s
pr / publish felis (x86_64-linux) (pull_request) Has been skipped
windows / detect relevant changes (pull_request) Successful in 3s
windows / package felis (x86_64-pc-windows-msvc) (pull_request) Has been skipped
windows / cargo clippy (Windows cross) (pull_request) Successful in 13s
84067358fd
natsukium deleted branch client/overlap-startup 2026-09-25 07:46:27 +09:00
Commenting is not possible because the repository is archived.
No description provided.