wgpu (Rust)
A safe, cross-platform GPU API for Rust that implements WebGPU semantics over Vulkan (and Metal/D3D12/OpenGL ES): instead of typing Vulkan's rules, it makes them disappear behind a runtime layer that auto-derives every barrier — the most-studied production auto-barrier implementation, shipped in Firefox.
| Field | Value |
|---|---|
| Language | Rust (MSRV policy: stable minus a few releases; v28 required 1.92) |
| License | MIT OR Apache-2.0 (dual) |
| Repository | gfx-rs/wgpu |
| Documentation | wgpu.rs · docs.rs/wgpu · docs.rs/wgpu-hal |
| Category | Render-graph / auto-sync layer (runtime usage-tracker, not a graph the user declares) |
| First release | 0.1 (2019, as wgpu-rs over wgpu-core); lineage from gfx-rs/gfx-hal |
| Latest release | v29.0.3 (May 2026); major releases roughly quarterly (v25 → v29 between April 2025 and May 2026) |
NOTE
wgpu is not a Vulkan binding — it sits two layers above one. The Vulkan backend is written against ash (see the ash deep-dive). It is in this survey as the reference point for the opposite design pole from typed bindings: full runtime tracking with zero type-level Vulkan exposure, and a measurable CPU bill for it.
Overview
What it solves
Raw Vulkan demands that the programmer place every vkCmdPipelineBarrier, manage every VkFence/VkSemaphore, and respect every externally-synchronized handle — and rewards mistakes with undefined behavior. wgpu removes the entire class: the user records passes against a WebGPU-shaped API (RenderPass, ComputePass, Queue::submit), and the implementation derives the required Vulkan barriers, layout transitions, and queue synchronization at submit time by tracking the state of every buffer and texture subresource. Invalid usage is caught by wgpu's own validation (a reimplementation of the WebGPU validation rules in wgpu-core) and surfaced as Rust errors or panics — never as UB.
The stack is three crates, each a distinct point on the safety/overhead curve:
| Crate | Role | Safety contract |
|---|---|---|
wgpu | Idiomatic Rust-flavoured WebGPU API; also targets the browser via JS | Safe; all types Send + Sync |
wgpu-core | Validation, usage tracking, barrier generation, lifetime management | Safe interface over unsafe internals |
wgpu-hal | Thin unsafe portability layer; Vulkan backend over ash | unsafe traits, "minimal validation, if any" |
Design philosophy
The split is stated bluntly in the wgpu-hal crate docs:
"Our traits' contracts are unsafe: implementations perform minimal validation, if any, and incorrect use will often cause undefined behavior. … Validation is the calling code's responsibility, not
wgpu-hal's."
So all safety lives in wgpu-core, whose tracking layer describes its own job (wgpu-core/src/track/mod.rs, verbatim):
"These structures are responsible for keeping track of resource state, generating barriers where needednd [sic] making sure resources are kept alive until the trackers die."
The philosophy is therefore the inverse of vulkano's type-driven approach and of Daxa/vuk's user-declared task graphs: no Vulkan concept escapes into the user-facing type system at all. Synchronization, layouts, queue ownership, and external synchronization are implementation details, paid for at runtime and amortized by careful data-structure engineering ("metadata SOA style, one vector per type of metadata", per the same module docs).
How it works
What the user writes is pure WebGPU shape — record a pass, submit; no barrier, layout, semaphore, or fence appears anywhere (the docs.rs examples follow this pattern):
// Idiomatic wgpu frame: everything sync-related is derived behind this API.
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());
{
let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame_view, // swapchain acquire/present semaphores: internal
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), // layout transition: derived
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
rpass.set_pipeline(&pipeline);
rpass.draw(0..3, 0..1); // usage tracking merges per-draw resource states here
}
queue.submit([encoder.finish()]); // barriers diffed & emitted; fence = timeline semaphoreEverything the rest of this page describes — trackers, snatch lock, deferred destruction — happens behind those few calls.
Binding generation & API coverage
wgpu is not generated from vk.xml — this dimension applies only indirectly, and the indirection is itself the finding. The layering is:
- Vulkan entry points come from ash, which is generated from
vk.xml(see rust-ash);wgpu-hal/src/vulkan/is a hand-written backend (~15 kLoC) that calls ash and is where allvk.xmlknowledge (extension gating,pNextchains, workarounds) is encoded manually. - The public API surface mirrors the WebGPU specification and its
webgpu.idl; the types inwgpu-typesare hand-maintained Rust structs/bitflags kept in sync with the spec by review, not codegen. Shaders are WGSL, translated to SPIR-V by naga (in-tree).
Consequently no registry safety metadata survives to the user: externsync annotations, handle parentage, and valid-usage rules from vk.xml are all replaced wholesale by WebGPU's own (stricter, smaller) validation rules implemented by hand in wgpu-core. Coverage is deliberately the WebGPU subset plus native-only extensions (wgpu::Features, e.g. push constants, ray-tracing acceleration structures, 64-bit atomics) — far below raw Vulkan (no multi-queue, bindless only partially, no user-visible render-pass control).
Handle lifetime & ownership model
All user-facing handles (Buffer, Texture, BindGroup, …) are opaque, clonable, reference-counted objects. The internals went through a famous redesign — "arcanization" (blog, November 24, 2023; PR #3626) — that moved every wgpu-core resource behind Arc<T>:
- Before: resources lived in contiguous arrays inside a global "Hub"; every access took a
RwLockon the storage, and dependencies were tracked by error-prone manual refcounts. Lock contention made multithreaded recording barely faster than single-threaded. - After (v0.19, January 2024): the Hub stores
Arcs; locks are held "in a lot of cases only while cloning the arc" (arcanization post). Bevy's testing showed parallel shadow-pass encoding give a "45% frame time reduction on a test scene … compared to their single threaded configuration" (arcanization post).
Two further mechanisms complete the model:
- The snatch lock (
wgpu-core/src/snatch.rs) reconcilesArc-based liveness with WebGPU's explicitdestroy(): aSnatchable<T>is "a value that is mostly immutable but can be 'snatched' if we need to destroy it early", guarded by one device-wideSnatchLocktaken (read) on hot paths. It has caused real deadlocks (#6378) and contention (#5525). - Deferred destruction: dropping a handle never destroys GPU memory immediately; the device tracks per-submission "active" resource lists and frees them when the fence (see below) passes the submission index — the classic frames-in-flight problem solved centrally, invisible to the user.
Because everything is internally locked, every wgpu type is Send + Sync — Vulkan's externally-synchronized handles (vk.xml externsync, e.g. VkQueue, command pools) are wrapped in Mutexes inside the backend, distinguished nowhere in user-facing types or docs. After repeated deadlock regressions, lock ordering is enforced by a hand-maintained static lock-rank graph (wgpu-core/src/lock/rank.rs, reinstated via #5204) checked at runtime in debug builds.
Synchronization safety
This is wgpu's center of gravity: a fully automated, runtime-tracked model with no user-visible barrier, semaphore, layout, or queue-ownership concept.
The usage tracker (wgpu-core/src/track/) maintains, per buffer and per texture subresource (mip level × array layer, via TextureSelector), the current internal usage state (BufferUses/TextureUses — a superset of WebGPU usages that includes layout-relevant states). Three tracker flavours compose:
- Bind-group trackers — precomputed lists of (resource, usage) for each bind group, so per-draw merging is a replay, not a re-derivation.
- Usage-scope trackers — implement WebGPU's usage scope rule: within one scope (one render pass, or one dispatch), usages of a resource are merged, and a merge that combines a writable usage with any other usage is a validation error (WebGPU's exclusive-writer rule). This is where races inside a pass are rejected rather than synchronized.
- Full (device/command-buffer) trackers — hold before/after states; the barrier operation diffs the command buffer's first-use states against the device tracker's current states and emits the minimal transitions, which the Vulkan backend lowers to
vkCmdPipelineBarrierwith image-layout transitions (wgpu-hal'sCommandEncoder::transition_buffers/transition_textures— the hal docs require the caller to "record explicit barriers between different usages of a resource").
The module docs are explicit that this hot path is engineered, not naive: state lives in flat vectors indexed by re-used ID indices ("they will always be as low as reasonably possible"), presence is a bit vector permitting "bailing out of whole blocks of 32-64 resources with a single usize comparison", and the pervasive unsafe indexing is mirrored by debug asserts (track/mod.rs).
Fences and semaphores: the user sees neither. wgpu-hal's Fence is a monotonically increasing timeline; the Vulkan backend (wgpu-hal/src/vulkan/mod.rs) implements it as a VkSemaphoreTypeKHR timeline semaphore when available — "timeline semaphores work exactly the way wgpu_hal::Api::Fence is specified to work" — and otherwise as a pool of binary VkFences tracking the highest signalled value. Swapchain acquire/present semaphores and inter-submission ordering (RelaySemaphores, including a two-semaphore alternation working around a Mesa hang) are likewise internal.
Queue-family ownership transfer does not exist in wgpu: the WebGPU model has a single queue, the Vulkan backend opens one queue, and all resources stay in one family. The absence is load-bearing — it removes an entire axis of Vulkan synchronization (and a long-standing feature request: multi-queue is among the missing features users cite for staying on raw Vulkan).
IMPORTANT
The contrast with Daxa/vuk matters for a future sparkles:vulkan: wgpu derives barriers while recording, eagerly, per command, with no whole-frame view — so it cannot reorder passes or batch barriers globally the way a declared task graph can, and it pays the tracking cost on every encoder operation whether or not the frame's structure changed since last frame.
Comparison: Dawn, the other production tracker
Dawn (Google's WebGPU implementation, shipped in Chrome; C++, calling Vulkan directly) solves the identical problem and is the natural cross-check on wgpu's design. Dawn's frontend records commands into an intermediate linear-allocated command stream (src/dawn/native/CommandAllocator.h: "To avoid doing an allocation per command or to avoid copying commands when reallocing, we use a linear allocator in a growing set of large memory blocks") while a SyncScopeUsageTracker accumulates each pass's merged resource usages — per the header, it "returns the per-pass usage for use by backends for APIs with explicit barriers". Only at submit does the Vulkan backend (src/dawn/native/vulkan/CommandBufferVk.cpp) replay the stream and call PrepareResourcesForSyncScope — once per render pass, and once per dispatch in compute passes (WebGPU's per-dispatch usage-scope rule, same as wgpu) — diffing against per-resource last-sync state (SubresourceStorage<TextureSyncInfo> mSubresourceLastSyncInfos in TextureVk.cpp, stored on each resource rather than in wgpu's central index-vector device tracker). Where the designs agree: usage-scope merging with exclusive-writer validation, per-subresource granularity, barriers batched at sync-scope boundaries, read-only-reuse skipping (CanReuseWithoutBarrier). Where they diverge: Dawn additionally splits merged barriers by destination stage —
"Separate barriers with vertex stages in destination stages from all other barriers. This avoids creating unnecessary fragment->vertex dependencies when merging barriers." (
CommandBufferVk.cpp)
— a pessimization-avoidance pass wgpu's eager per-encoder emission has no equivalent of; and Dawn's barrier behavior is tuned per device via toggles (e.g. vulkan_split_command_buffer_on_compute_pass_after_render_pass, default-on for Qualcomm) rather than wgpu's compile-time/feature gating. Notably, Dawn has published no overhead numbers comparable to wgpu's #2080 — the wgpu discussion remains the only quantified cost estimate for this architecture, which is itself why this page leans on it.
Type-system techniques
Almost deliberately none — absence is the finding here. wgpu's safety is dynamic:
- No phantom-typed handles, no typestate builders, no lifetime-encoded scoping. The one notable lifetime —
RenderPass<'encoder>borrowing itsCommandEncoder— was removed (made'staticvia internalArcs, wgpu v22) because it blocked users, the opposite direction from vulkano/ash-style typed designs. - The light typing that exists:
wgpu-core'sid::Id<T>carries aPhantomDatamarker so buffer/texture IDs don't cross-assign (an internal, not user-facing, mechanism);wgpu-typesbitflags (BufferUsages,TextureUsages) are validated at runtime, not by type; backend selection inwgpu-halis static dispatch over anApitrait (generics, not trait objects) so the Vulkan path monomorphizes. pNextchains never reach the user; the hal Vulkan backend builds them by hand, and the documented interop hookwgpu_hal::vulkan::Adapter::open_with_callbackexists precisely so embedders can "modify the pnext chains and extension lists before creating a vulkan device" (#965 lineage).- Capability typing is runtime too:
FeaturesandLimitsare checked when a device is requested and re-checked per call during validation.
The Rust type system is used for memory safety of the implementation (and API misuse like use-after-drop simply being impossible with Arc handles), not for encoding Vulkan's rules.
Overhead & escape hatches
wgpu is the best-documented data point for what automatic barriers + validation cost on the CPU:
- Headline estimate: in the long-running performance discussion #2080, maintainer kvark put the expected overhead over raw hal at 5–10 % in a real app, with a measured worst case of ~2× CPU time comparing
halmark(raw hal) tobunnymark(full wgpu) — the gap being "validation, state tracking, and lifetime tracking". (kvark's own post-wgpu answer to this cost is blade, which deletes the tracker entirely — the zero-tracking counterpoint whose benchmark numbers are quoted against these.) - Multithreading: pre-arcanization, parallel encoding was effectively serialized by Hub locks; post-arcanization it scales (the 45 % Bevy number above), but contention remains real: #5525 (May 2024) profiles a production app dropping from 60 FPS to ~10 FPS during concurrent asset upload, with Tracy traces pointing at the registry
data.write()inassign(),device.trackers.lock(), the snatchable read lock, andtexture.views.lock(). Maintainers' replies acknowledge arcanization replaced "a global lock" with "finer-grained locks" rather than eliminating locking; follow-up work (#5121) targets removing the registries entirely. #2710 ("Remove Locking From Hot Paths") tracks the broader goal. - Per-command cost that typed/manual bindings don't pay: usage-state merge per resource per draw/dispatch, validation of every call against WebGPU rules, and (for
DrawIndirectwith bounds checking) injected GPU-side validation work. - Escape hatches are real and layered.
as_halreturns a guard dereferencing to thewgpu-haltype, from which raw ash/Vulkan handles (vk::Buffer,vk::Device, queue) are reachable;from_hal/texture_from_raw/Device::create_buffer_from_halimport externally created Vulkan objects (with an explicitdrop_guardownership story, #6142);CommandEncoder::transition_resourceslets interop code force states so the tracker's assumptions stay true;vulkan::Queue::add_wait_semaphorelets CUDA/GL producers be awaited without CPU blocking. One can also skipwgpu/wgpu-coreentirely and programwgpu-haldirectly — Vulkan-shaped, portable, unsafe, and validation-free, "1:1 with Vulkan" enough that its overhead over raw calls is negligible.
Error handling & validation integration
wgpu replaces, rather than integrates, Vulkan's validation layers: wgpu-core implements the WebGPU validation algorithms itself, so a correct wgpu program should never trip VK_LAYER_KHRONOS_validation (wgpu CI still runs the layers to catch wgpu's own backend bugs; cf. the sync-validation survey).
- Following WebGPU's error model, errors are deferred and contagious: object creation always returns a handle; if creation failed the handle is internally invalid and later uses propagate the error. On native Rust, an unhandled validation error panics by default via the uncaptured-error handler;
Device::on_uncaptured_errorandpush_error_scope/pop_error_scopegive the WebGPU-style programmatic capture path. Device loss is a separate callback, mirroringVK_ERROR_DEVICE_LOST. - Usage-scope conflicts (write/write or read/write on one subresource within a pass) surface as descriptive validation errors at encode time — the safety property that typed systems try to prove statically, here checked dynamically with full runtime information (hence zero false positives, at runtime cost).
wgpu-halreturns errors only for "cases the user can't anticipate, like running out of memory or losing the device" (hal docs) — everything anticipatable iswgpu-core's job, keeping the hal hot path branch-light.
Strengths
- The strongest safety guarantee in the survey: no unsafe code, no UB, no sync hazards expressible in the safe API — races inside a pass are validation errors, races across passes are auto-barriered.
- Production-proven at scale: ships in Firefox as its WebGPU implementation and underpins Bevy; the auto-barrier engine has years of fuzzing/CTS coverage no hobby layer matches.
- Cache-conscious tracker engineering (SOA metadata, bit-vector presence, index-reusing IDs) shows auto-sync need not mean hash maps everywhere — directly transferable design material.
- Portability for free (Vulkan/Metal/D3D12/GLES/WebGPU-in-browser) with one shader language via naga.
- Graduated escape hatches —
as_hal/from_hal/rawwgpu-hal— let hot paths or interop drop to raw Vulkan without abandoning the stack. - Timeline-semaphore-first fence model with transparent fallback is a clean pattern for D.
Weaknesses
- Permanent CPU tax: ~5–10 % typical, up to ~2× worst case versus raw hal (#2080); per-draw tracking work scales with scene complexity and cannot be opted out of short of leaving
wgpu-core. - Lock architecture is still a liability: post-arcanization contention on registries, device trackers, and the snatch lock measurably degrades concurrent upload + render workloads (#5525); the snatch lock can deadlock (#6378); lock order needs a hand-maintained rank table (#5204).
- Eager per-command barrier derivation can't reorder or globally optimize like a declared task graph (Daxa, vuk) and re-pays the cost every frame even for static scenes.
- API ceiling is WebGPU: single queue (no async compute/transfer queues, no queue-ownership control), no user render-graph hooks, extension access only insofar as wgpu chose to expose a
Feature. - Nothing for the type-system column: a D library hoping to encode sync rules statically gets anti-lessons (the removed
RenderPasslifetime) rather than techniques. - Rapid major-version cadence (quarterly breaking releases) is a churn cost for dependents.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| WebGPU semantics instead of Vulkan semantics | A spec'd, fuzzable, portable safety model; browser + native from one codebase | Single queue, reduced feature surface, no user control over passes/barriers |
| Runtime usage tracking, not types or user graphs | Zero user burden; zero false positives; works for fully dynamic workloads | 5–10 % (worst ~2×) CPU overhead; eager per-command derivation; no whole-frame barrier optimization |
Safety concentrated in wgpu-core; wgpu-hal unsafe + unvalidated | Pay for validation exactly once, at one layer; hal stays portable and near-zero-cost | Two APIs to maintain; hal misuse is instant UB; safe API can't expose what core didn't model |
Arcanization: Arc-per-resource over Hub arrays | Cut lock hold times; enable real multithreaded encoding (45 % frame-time win) | Per-resource refcount traffic; many fine-grained locks needing a static rank graph |
Snatch lock for explicit destroy() under Arc liveness | WebGPU requires early destroy; one device-wide RwLock keeps reads cheap | A global read-lock on hot paths; documented contention and a recursive-lock deadlock class |
Hand-written backends over ash, no vk.xml codegen of the API | The API is webgpu.idl-shaped; backend code is where workarounds and pNext wiring must live | externsync/valid-usage metadata from the registry is discarded; backend upkeep is manual |
hal Fence = timeline semaphore (pool-of-VkFence fallback) | Timeline semantics match the abstract model 1:1; one concept for all backends | Fallback path complexity; binary-semaphore relay dance (incl. Mesa workaround) stays internal |
| Errors deferred + contagious, panic-by-default on native | Matches the WebGPU spec; keeps creation calls infallible-shaped | Failure surfaces later than the offending call; panic default surprises library users |
Sources
- gfx-rs/wgpu — GitHub repository · wgpu.rs · docs.rs/wgpu
wgpu-core/src/track/mod.rs— tracker design doc (SOA, bit vectors, insert/merge/barrier/update)wgpu-halcrate docs — unsafe contract, explicit barriers, error scope ·wgpu-hal/README.mdwgpu-hal/src/vulkan/mod.rs— timeline-semaphoreFence,RelaySemaphores, barrier loweringwgpu-core/src/snatch.rs—Snatchable/SnatchLock·wgpu-core/src/lock/rank.rs— static lock ranks- Arcanization lands on trunk — gfx-rs blog, November 24, 2023 · PR #3626
- Discussion #2080 — "RE: performance" (5–10 % / 2× numbers)
- Discussion #5525 — "Major performance problems with multithreading"
- Issue #2710 — Remove locking from hot paths · Issue #5121 — remove registries · Issue #5204 — static lock order · Issue #6378 — recursive snatch-lock deadlock
- Issue #965 — interop with the underlying graphics API · Issue #6142 —
drop_guardsemantics for raw imports - google/dawn:
PassResourceUsageTracker.h·vulkan/CommandBufferVk.cpp—PrepareResourcesForSyncScope, vertex-stage barrier split ·vulkan/TextureVk.cpp—mSubresourceLastSyncInfos·Toggles.cpp·CommandAllocator.h - WebGPU specification · WebGPU error handling · naga
- Related: ash · vulkano · blade · Daxa · vuk · sync-validation · concepts · comparison · Survey index