Skip to content

Fix black screen: prewarm pipelines on every backend, not just Vulkan - #555

Open
samoylenkodmitry wants to merge 1 commit into
mainfrom
fix/pipeline-prewarm-every-backend
Open

Fix black screen: prewarm pipelines on every backend, not just Vulkan#555
samoylenkodmitry wants to merge 1 commit into
mainfrom
fix/pipeline-prewarm-every-backend

Conversation

@samoylenkodmitry

@samoylenkodmitry samoylenkodmitry commented Aug 29, 2026

Copy link
Copy Markdown
Owner

What this changes

Cranpose apps could show a black screen for up to 90 seconds at launch — reported on an Android emulator with no Vulkan adapter, where the GL fallback pays 40+ seconds per create_render_pipeline call, and nothing is presented until every pipeline the first frame needs has compiled.

A prior fix (f494e37, "Give every pipeline creation a persistent device pipeline cache") added a background prewarm thread plus a persisted wgpu::PipelineCache, intended to move that compile cost off the render thread. But wgpu-hal only grants Features::PIPELINE_CACHE on the Vulkan backend (confirmed by reading wgpu-hal-29.0.3/src/vulkan/adapter.rs — no GL, Metal, or DX12 backend sets that flag), and the prewarm thread was gated on pipeline_cache.is_some(). It also worked by building a throwaway pipeline and dropping it, hoping the driver's pipeline-cache blob would make the render thread's own later compile fast — a mechanism that only exists on Vulkan. So GL, Metal, DX12 and wasm's WebGL/WebGPU — every backend except Vulkan — got zero mitigation. The exact backend the bug report describes (GL, no Vulkan adapter) was never actually fixed by that prior work.

The fix

PassPipeline::new (crates/cranpose-render/wgpu/src/lazy_resource.rs) now returns a SharedPassPipeline (Arc off wasm; Rc on wasm, where wgpu's handles are neither Send nor Sync) instead of a bare value. The prewarm thread (crates/cranpose-render/wgpu/src/render.rs) now races the render thread's own first-use get_or_init on the same OnceLock-backed slot, instead of building a pipeline it immediately drops. Whichever side gets there first wins; the other blocks on OnceLock's own wait instead of compiling a redundant pipeline. This mechanism needs no device-level pipeline cache to pay off, so prewarming now runs unconditionally rather than being gated on pipeline_cache.is_some(). The device pipeline cache and its disk persistence are unchanged and still help Vulkan warm-start across launches.

Verification

Per this repo's debugging discipline: confirmed the cause by reverting to the old gate and rerunning the new test against this machine's real (non-Vulkan) adapter — it timed out waiting for the shared slot, exactly as the bug predicts. Restored the fix and it passed in 0.04s.

New test: render::tests::pipeline_prewarm_fills_the_shared_slot_without_a_device_pipeline_cache. It requests a device with zero features, so Features::PIPELINE_CACHE is absent regardless of what the adapter under the test runner actually supports (Vulkan CI included) — so the test deterministically exercises the previously-broken "no device cache" path on any machine, not just a GL host.

What I could and couldn't verify here

I do not have Android emulator/device access in this environment (and was instructed not to use the shared build host that does), so I could not reproduce the original 90-second GL scenario end-to-end. The fix and its test are backend-agnostic by construction and were validated on this machine's real (Metal, non-Vulkan) adapter, which exercises the same "no PIPELINE_CACHE" code path GL hits.

Residual gap, not fixed here: even with this fix, a first frame still presents nothing until its content pipelines finish compiling. wgpu-hal's GLES backend serializes all GL calls behind one context lock, so moving a compile to a background thread does not shrink GL's own wall-clock compile time — it removes redundant compiles and lets non-GPU startup work overlap, but on a genuinely slow GL driver the app can still show nothing for a while. Fully guaranteeing "never presents nothing for a perceptible period" needs a second piece: presenting an immediate placeholder (a clear-color pass needing zero pipelines) the moment the surface is configured. I've flagged that as separate follow-up work (present-thread startup in crates/cranpose-render/wgpu/src/present_runtime.rs) rather than rushing an untested cross-platform change into this PR.

Checklist

  • just ci passes in full, run locally end to end: fmt-check, typos, versions, clippy (workspace, -D warnings, zero warnings), doc (-D warnings, clean), and test (cargo test --profile ci --workspace) — 0 failures across the whole workspace. Also ran the wasm target (clippy-wasm's exact feature set) and the camera-desktop/robot feature combo (clippy-camera-desktop's exact command) clean. Did not run budgets (dependency/size budgets) locally — nothing in this change adds a dependency or should move the binary-size ceiling; left it to CI.
  • A bug fix starts with a test that fails without the fix — see Verification above.
  • No half-migrated state, deprecation shim, or "legacy" path left behind — PassPipeline::new has exactly one return type everywhere it's constructed.
  • I used AI assistance (Claude) to write this code.

…kend

The black-screen bug this fixes: on an Android emulator with no Vulkan
adapter, the GL fallback pays 40+ seconds per shader-pipeline compile,
and nothing is presented until every pipeline a first frame needs has
compiled. A prior fix (f494e37) added a background prewarm thread and
a persisted wgpu::PipelineCache, but wgpu-hal only grants
Features::PIPELINE_CACHE on Vulkan (confirmed by reading
wgpu-hal's adapter.rs), and the prewarm thread only ran when that
feature was granted -- it built throwaway pipelines that only paid off
through the driver reusing the shared cache's compiled code. GL, Metal,
DX12 and wasm's WebGL/WebGPU got zero mitigation: the exact backend the
bug report describes was never fixed.

PassPipeline::new now returns a SharedPassPipeline (Arc off wasm, Rc on
wasm, where wgpu handles are neither Send nor Sync) instead of a bare
value. The prewarm thread races the render thread's own first-use
get_or_init on the SAME OnceLock-backed slot instead of building a
pipeline it immediately drops: whichever side gets there first wins,
and the other blocks on OnceLock's own wait instead of compiling a
redundant pipeline. This works with or without a device pipeline
cache, so it now runs unconditionally rather than being gated on
`pipeline_cache.is_some()`.

render::tests::pipeline_prewarm_fills_the_shared_slot_without_a_device_pipeline_cache
requests a device with zero features (so PIPELINE_CACHE is absent
regardless of what the adapter under the test runner actually
supports) and asserts prewarm still fills the shared slot. Confirmed
red against the old gate by temporarily reinstating it and rerunning
on this machine's real (non-Vulkan) adapter before writing the fix.

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

Copy link
Copy Markdown
Owner Author

Field evidence: this predicts a real, measured symptom on real hardware

Independent corroboration, from an unrelated app (cranpose-orbit, a WGSL runtime shader showcase) pinned to cranpose 0.1.104 — i.e. pre-this-fix — that measured pipeline compilation on real devices while investigating something else entirely.

On a Pixel 9 Pro with a working Vulkan adapter, it observed two compiles for the one shared shader module, logged as an unexplained framework-internal curiosity. That is exactly the redundant compile this PR describes: the old prewarm thread built and dropped a throwaway pipeline while the render thread separately compiled its own on first use, because the two never shared the same OnceLock-backed slot. This fix makes both race the same slot, so the loser blocks instead of recompiling — a double compile on a pre-fix build is exactly what the old architecture predicts, and it shows up even on Vulkan, where the device pipeline cache was supposed to be covering for it.

Numbers, all real devices with real Vulkan adapters, no emulator:

  • Pixel 9 Pro: ~228ms combined compile on a clean cold run; ~2.66s under concurrent load.
  • Huawei EVR-AL00 (Android 10, 2019-era mid-range): ~497ms cold.
  • Content appeared within about a second of process start on both, dominated by ordinary activity-launch latency rather than shader compilation — verified against timestamped screenshots correlated with logcat, including a pm clear cold-cache run.

This also narrows the original bug report's scope, worth recording here: on real Vulkan hardware there is no 90-second black screen — that figure was the software-GL emulator path (no Vulkan adapter at all, GL fallback, where wgpu-hal never grants Features::PIPELINE_CACHE, which is the exact gap this PR closes). On real Vulkan hardware the redundant-compile symptom this PR fixes is real and measured (the 228ms-2.66s numbers above), but it was never the dominant cost of a black screen there — it's still worth fixing on its own merits (wasted CPU/GPU work, and it's the same gap that leaves GL/Metal/DX12 with zero prewarm coverage).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant