Skip to content

blade (Rust)

A deliberately minimal, unsafe GPU abstraction by wgpu's original author, built after writing wgpu's per-resource usage tracker — and concluding that for his workloads the tracking was not worth its cost: blade replaces per-resource barriers, image layout transitions, and lifetime tracking with one catch-all global barrier between passes, every image permanently in VK_IMAGE_LAYOUT_GENERAL, and a single timeline semaphore per queue.

FieldValue
LanguageRust (backends: Vulkan via ash, Metal, GLES; one selected per platform at compile time)
LicenseMIT
Repositorykvark/blade
Documentationdocs.rs/blade-graphics · motivation.md · FAQ.md · performance.md
CategoryRender-graph / auto-sync layer (the in-category counterpoint: minimal tracking, no graph, no per-resource sync)
First releaseblade-graphics 0.1.0 — January 26, 2023
Latest releaseblade-graphics 0.8.4 — April 18, 2026

NOTE

blade exists as a counter-experiment to wgpu, by the same primary author (Dzmitry Malyshau, kvark — wgpu's lead from its inception through 2021). Where wgpu spends a measured 5–10 % of CPU time on validation, state tracking, and lifetime tracking, blade spends approximately zero — by declining to track anything per-resource and pushing correctness onto the driver, the Khronos validation layers, and the user. Its production test was the Zed editor's Linux renderer from February 2024 until February 2026, when Zed replaced it with wgpu (see Error handling & validation integration).


Overview

What it solves

blade targets the gap its author saw between engines ("too high level"), raw APIs ("too verbose"), and portability layers ("overly general"). The motivation document positions it against the author's own prior work:

"wgpu provides the most thorough graphics abstraction in Rust ecosystem. … However, it is very restricted (by being a least common denominator of the platforms), fairly verbose … and has overhead (for safety and portability)." (blade-graphics/etc/motivation.md)

and against wgpu-hal, which removes the overhead but keeps the ceremony:

"wgpu-hal expects resource states to be tracked by the user and changed (on a command encoder) explicitly." (motivation.md)

blade's answer is to delete the state machine rather than automate or expose it. Three bookkeeping domains that vulkano tracks at runtime, Daxa/vuk compile from a graph, and wgpu derives per command are simply defined away:

  • Resource states do not exist. No per-resource access tracking, no image layout transitions (everything is GENERAL), no queue-family ownership transfers.
  • Descriptor sets do not exist as a user concept. A plain Rust struct deriving ShaderData is pushed at draw/dispatch time; the Vulkan backend materializes a descriptor set on the fly via VK_KHR_descriptor_update_template.
  • Lifetime tracking does not exist. Resources are Copy structs; destroy_* is explicit and immediate; keeping a resource alive until the GPU is done is the user's job, checked by nobody.

Design philosophy

The motivation document states the inversion of wgpu's priorities outright:

"safety: wgpu places safety first and foremost. Self-sufficient, guarantees no UB. Blade is on the opposite - considers safety to be secondary. Expects users to rely on native API's validation and tooling." (motivation.md)

and is equally direct about why per-resource barrier derivation was abandoned rather than improved:

"barriers: wgpu attempts to always use the optimal image layouts and can set reduced access flags on resources based on use. Placing the barriers optimally is a non-trivial task to solve, no universal solutions. Blade not only ignores this fight by making the user place the barrier, these barriers are only global, and there are no image layout changes - everything is GENERAL." (motivation.md)

The same document frames the whole project as "a bit experiment. It may fail horribly, or it may open up new ideas and perspectives", and the FAQ answers "why invest in this when there is wgpu?" with: "Blade is an attempt to strike where wgpu can't reach, it makes a lot of the opposite design solutions." The January 2023 announcement talk compresses the sync story to three lines: "No per-object state. No image layout transitions. Global barriers between passes."


How it works

blade-graphics is the GPU abstraction (the subject of this page); above it sit blade-render (a hardware-ray-tracing path tracer), blade-egui, blade-asset, and a small blade-engine. The portable surface is defined as traits in blade-graphics/src/traits.rs (ResourceDevice, CommandDevice, TransferEncoder, …) with exactly one backend compiled per platform (src/vulkan/, src/metal/, src/gles/) — there is no runtime backend dispatch and no generic parameter in user code. A frame is: create a CommandEncoder, open named passes, push data structs, submit, and keep the returned SyncPoint:

rust
// kvark/blade — examples follow this shape (see blade-graphics/README.md)
#[derive(blade_macros::ShaderData)]
struct Params {
    input: gpu::BufferPiece,
    output: gpu::TextureView,
}

encoder.start();
if let mut pass = encoder.compute("filter") {          // global barrier inserted here by default
    let mut pc = pass.with(&pipeline);                 // rebinds everything; pipeline-scoped
    pc.bind(0, &Params { input, output });             // descriptor set created on the fly
    pc.dispatch(groups);
}
let sync_point = context.submit(&mut encoder);         // bumps the queue's timeline semaphore
context.wait_for(&sync_point, timeout_ms);             // CPU-GPU sync

The Vulkan backend requires VK_KHR_descriptor_update_template, VK_KHR_timeline_semaphore, and VK_KHR_dynamic_rendering (blade-graphics/README.md) — "the baseline Vulkan hardware with a relatively fresh driver", with ray tracing (AccelerationStructure is a first-class resource type in the portable API) available on the Vulkan backend only.

Binding generation & API coverage

blade generates nothing from vk.xml, and adds no metadata of its own. The Vulkan backend is hand-written over ash (which supplies the generated raw API); the portable surface is a small hand-designed trait set, so — exactly as with Daxa — no registry metadata (externsync, success codes, structure-chain validity) survives to the user, because the Vulkan surface it would annotate is hidden. Coverage is deliberately narrow: compute, raster (dynamic rendering only), ray queries/acceleration structures, transfers, timestamp timings, and external memory import/export (Memory::External with Win32/fd/DMA-BUF sources). Deliberately absent, per motivation.md: multisampling ("too expensive") and — for a long time — vertex buffers ("use storage buffers instead"; a Vertex derive was added later). Shaders are WGSL compiled through naga, with bindings matched by struct-field name rather than by binding decorations — the ShaderData derive and the shader module are reconciled at pipeline creation, a name-keyed cousin of Daxa's TaskHead single-artifact trick.

Handle lifetime & ownership model

The motivation document is one line on this: "Object lifetime is explicit, no automatic tracking is done." Every resource type in traits.rs is constrained Send + Sync + Clone + Copy + Debug + Hash + PartialEq — plain bit-copyable value handles, the opposite pole from vulkano's Arc-everywhere and from wgpu-hal's Clone-only opaque objects:

"object copy: wgpu-hal hides API objects so that they can only be Clone, and some of the backends use Arc and other heap-allocated backing for them. Blade keeps the API for resources to be are light as possible and allows them to be copied freely." (motivation.md)

destroy_buffer/destroy_texture/… free immediately; there is no deferred destruction, no zombie list, no epoch tracking (deferred destruction is simply absent — the user holds SyncPoints and schedules frees themselves). The one concession is CommandEncoderDesc::buffer_count (how many command buffers the encoder rotates internally, e.g. 2 for one-recording-one-executing). Memory is allocated automatically from a few profiles (Memory::Device/Shared/Upload) via gpu-alloc — the user picks a profile, not a heap. Rust's ownership system is not used to enforce any of this: a copied Buffer handle outliving its destroy_buffer call is a use-after-free the type system never sees.

Synchronization safety

blade's model has exactly three moving parts, all coarse:

  • Global barriers between passes. Opening any pass (encoder.compute(label) / .render(…) / .transfer(…)) calls begin_pass, which by default emits one full-pipeline vkCmdPipelineBarrier with a single VkMemoryBarrierMEMORY_WRITEMEMORY_READ | MEMORY_WRITE across ALL_COMMANDSALL_COMMANDS (src/vulkan/command.rs, fn barrier). No resource is named; everything written before the pass is visible to everything after it. CommandEncoderDesc::manual_barriers opts out: "When set, automatic memory barriers between passes are not inserted. The user is responsible for calling barrier() on the encoder where synchronization is needed." (src/lib.rs) — so the sync the user ever writes is at most placement of an opaque full barrier, never stages, access masks, or layouts. A compute pass additionally exposes a within-pass compute-to-compute barrier().
  • No image layouts. init_texture transitions UNDEFINEDGENERAL once at creation; presentation transitions GENERALPRESENT_SRC_KHR; nothing else ever changes layout (src/vulkan/command.rs). Daxa reached the same all-GENERAL position in its release 3.3 (November 27, 2025) — nearly three years after blade shipped it — on the same modern-drivers-don't-care thesis.
  • One timeline semaphore per queue. submit signals the queue's timeline_semaphore and returns a SyncPoint { progress } — a plain cloneable counter value; wait_for(&sync_point, timeout_ms) waits on it (src/vulkan/mod.rs). There are no user-visible fences, binary semaphores (swapchain acquire/present semaphores are managed internally), or events.

What is given up relative to wgpu's tracker is every per-resource guarantee: nothing detects a read of a buffer the GPU is still writing in the same pass, a destroy-while-in-flight, or a missing barrier under manual_barriers — there is no hazard model at all, and the externsync burden is handled only by Rust's ordinary &mut borrows on the encoder plus an internal queue mutex. The compensating bet is stated in motivation.md: "Resource states do not exist. The API is built on an assumption that the driver knows better how to track resource states, and so our API doesn't need to care about this. The only command exposed is a catch-all barrier." The model is sound-by-overshoot between passes (a full barrier is never missing sync, only excess) — the GPU-side cost is serialized passes: no compute/raster overlap across a barrier that a per-resource system would have permitted.

Type-system techniques

blade uses Rust's type system for ergonomics and scoping, not for safety proofs:

  • Scoped pass/pipeline encodersCommandEncoder → pass encoder (compute()/render()/ transfer()) → pipeline encoder (.with(&pipeline)), each holding a &mut borrow of its parent, so passes cannot interleave and commands cannot be recorded outside a pass — a borrow-checker-lite typestate without phantom types.
  • ShaderData derive macro (blade-macros) — a struct of BufferPiece/TextureView/ Sampler/AccelerationStructure/plain-data fields becomes a ShaderDataLayout at compile time; binding is by field name against the naga-reflected WGSL module.
  • Const-generic bindless arraysResourceArray<T, const N> with aliases BufferArray<N>, TextureArray<N>, AccelerationStructureArray<N> (src/lib.rs) give a fixed-capacity bindless table whose size is a type parameter.
  • Compile-time backend selectioncfg-selected backend modules behind shared traits; no trait objects, no generics in user code, so the abstraction is resolved entirely at compile time.

Deliberately absent: no lifetimes tying handles to the Context, no phantom-typed sync scopes, no linear/affine handle ownership (linear types would contradict the Copy-handles goal), no typed structure chains (the portable API has no pNext). The crate root says it plainly: clippy::missing_safety_doc is allowed because "This is the land of unsafe." (src/lib.rs)

Overhead & escape hatches

The CPU overhead story is the inverse of wgpu's: nothing is tracked, so nothing is paid per command — no usage-state CAS, no lifetime refcounts, no barrier derivation. What blade pays instead is at the bind and GPU level: a descriptor set is created on the fly for every bind ("Blade considers it cheap enough to always create on the fly", motivation.md), a pipeline switch rebinds everything ("everything is re-bound on pipeline change" — defended in the FAQ by analogy to D3D12's actual behavior), and global barriers serialize passes on the GPU. The project measures itself honestly in performance.md with the ported wgpu bunnymark ("the worst case of the usage", every draw fully dynamic):

"Blade starts to slow down after about 23K bunnies … wgpu-hal starts at 60K bunnies … wgpu starts at 15K bunnies" (MacBook Pro 2016 / Metal; on a Ryzen 3500U via Vulkan: blade ≈18K, wgpu-hal ≈60K, wgpu ≈20K) (performance.md)

— i.e. worst-case blade lands on par with full wgpu and well below wgpu-hal, and the FAQ owns it: "Short answer is - yes, it's unlikely going to be faster than wgpu-hal. Long answer is - slow doesn't matter here" — above ~100 unique objects you should be instancing anyway, and blade's target workloads (compute, ray tracing, AZDO-style rendering, a UI like Zed's) issue few, fat commands. The same document counts ergonomics as the real win: the bunnymark example is "335 LOC versus 830 LOC of wgpu-hal".

The ultimate escape hatch is structural rather than an API: "Blade expects to be vendored in and modified according to the needs of a user" and "Blade needs to be transparent, since it assumes modifcation by the user" (motivation.md) — backend internals are reachable, raw external memory can be imported/exported (Memory::External), and manual_barriers removes even the automatic global barriers.

Error handling & validation integration

Initialization is the only recoverable phase: Context::init is an unsafe fn returning Result<Self, NotSupportedError> (src/vulkan/init.rs); after that, "Blade doesn't expect any recovery" (motivation.md) — creation functions are infallible-or-panic, and only wait_for surfaces a DeviceError (DeviceLost/OutOfMemory). In place of its own validation layer, blade integrates the native tooling it tells users to rely on: ContextDesc { validation: true } loads VK_LAYER_KHRONOS_validation, names every object via VK_EXT_debug_utils, labels passes for capture tools, and — notably — arms a built-in GPU crash handler: when VK_AMD_buffer_marker is available under validation, every pass writes a marker into a dedicated buffer and a failed submit is decoded by check_gpu_crash into the name of the pass that hung the device (src/vulkan/mod.rs, CrashHandler). Since all sync between passes is a full barrier, synchronization validation has little to find in default-mode blade code — the hazards it hunts are mostly within a pass or under manual_barriers, where blade offers no help.

The production verdict on this safety posture is mixed. Zed adopted blade for its Linux port (kvark's own PR #7343, merged February 7, 2024; the Zed blog introduced it as a "lean low-level GPU abstraction focused at ergonomics and fun") and shipped on it for two years — then removed it (PR #46758, opened January 14, 2026, merged February 13, 2026), citing NVIDIA freezes and Wayland-compositor crashes: "The blade graphics library is a mess and causes several issues for both Zed users as well as other 3rd party apps using GPUI" — replacing it with wgpu, the abstraction blade was designed in reaction to.


Strengths

  • The cleanest existing answer to "what if we just don't track?" — a real, shipping system where per-resource sync, layouts, and lifetime tracking are absent by design, giving an empirical baseline for what that buys (≈zero CPU sync overhead, ~2.5× less user code than wgpu-hal) and costs.
  • Worst-case performance parity with wgpu at a fraction of the implementation — bunnymark's fully-dynamic draws land where wgpu's tracked draws do (performance.md), and blade's target workloads (compute/RT/batched draws) avoid that worst case entirely.
  • Sound-by-overshoot inter-pass sync — the catch-all barrier can be wasteful but never wrong; whole bug classes (wrong stage masks, missed layout transitions) cannot be expressed.
  • All-GENERAL layouts pioneered in 2023 — independently validated when Daxa adopted the same model in late 2025.
  • First-class ray tracing and external memory in a tiny portable API; blade-render demonstrates the RT-first intent end to end.
  • Honest self-assessment culture — motivation/FAQ/performance docs state the trade-offs, the benchmark losses, and the experimental status in the project's own voice.

Weaknesses

  • Safety is genuinely gone, not relocated: use-after-destroy, in-pass hazards, and manual_barriers mistakes are silent UB; the only nets are the Khronos layers and the AMD buffer-marker crash decoder. Rust's unsafe shows up at Context::init and then largely disappears from signatures that can still cause UB.
  • GPU-side cost of global barriers is unmeasured — passes serialize fully; workloads that need async-compute-style overlap have no path to it (no per-resource barriers, no multi-queue API).
  • Per-bind descriptor allocation and rebind-all-on-pipeline-change put a ceiling (~18–23K dynamic draws) well under wgpu-hal's (~60K) (performance.md); blade is the wrong tool past ~10K unique draws by its own FAQ.
  • Production reliability did not hold up at Zed scale — driver/compositor interaction bugs (NVIDIA freezes, Smithay crashes) drove the one major adopter back to wgpu in February 2026 (PR #46758).
  • "Vendor and modify" as the extension model limits it as a dependency: the API is small, young (0.x), sparsely documented (≈38 % docs coverage on docs.rs), and effectively single-author.
  • No web/D3D12 story beyond a basic GLES path; requires fresh Vulkan drivers.

Key design decisions and trade-offs

DecisionRationaleTrade-off
No per-resource state; one catch-all global barrier between passes"the driver knows better how to track resource states"; optimal barrier placement has no universal solutionGPU passes fully serialize; no async overlap; in-pass hazards are invisible
All images permanently in GENERAL layoutEliminates the largest sync-bug class and all transition bookkeepingForfeits layout-specific compression/bandwidth wins on some hardware
Safety secondary; rely on native validation and toolingZero tracking overhead; radically smaller implementation to maintain and vendorUB is reachable from safe-looking code; adopters inherit driver-bug surface (Zed's experience)
Resources are bit-Copy value handles, destroyed explicitly"as light as possible"; no Arc/refcount/registry cost per handleUse-after-destroy unchecked; user must schedule frees against SyncPoints themselves
Descriptor sets created on the fly per bind; rebind on pipeline changeRemoves layout/pool/caching machinery; matches D3D12's actual rebind semantics~3× lower max dynamic draw rate than wgpu-hal; unsuitable above ~10K unique draws
One timeline semaphore per queue; SyncPoint = counter valueCPU-GPU sync collapses to compare-and-wait; no fence poolsNo fine-grained GPU-GPU dependencies; single-queue model
WGSL + name-based binding via naga reflectionNo binding decorations to keep in sync between shader and host structTies shaders to naga's WGSL dialect; renames break bindings at pipeline creation
Vendored-in, transparent codebase over stable library APIUsers with niche needs patch the backend directlyWeak compatibility guarantees; ecosystem reuse (GPUI's plugin authors) suffered in practice

Sources