ash (Rust)
The de-facto raw Vulkan binding for Rust — a deliberately thin, vk.xml-generated FFI layer whose only safety ambitions are strongly typed handles, lifetime-checked p_next chains, and Result-typed returns, and on which the safe layers (vulkano, wgpu) are built.
| Field | Value |
|---|---|
| Language | Rust (MSRV 1.69; no_std-capable with alloc) |
| License | MIT OR Apache-2.0 (dual) |
| Repository | ash-rs/ash |
| Documentation | docs.rs/ash · crates.io |
| Category | Thin / generated binding |
| First release | 0.1.0, December 9, 2016 (Maik Klein) |
| Latest release | 0.38.0+1.3.281, April 1, 2024; master tracks Vulkan-Headers 1.4.x for the unreleased 0.39 |
NOTE
The +1.3.281 build-metadata suffix pins the Vulkan-Headers revision the bindings were generated from. The crate has ~25 million downloads on crates.io and is the Vulkan substrate of both wgpu's wgpu-hal Vulkan backend and vulkano (which replaced its own vk-sys bindings with ash in 2021).
Overview
What it solves
Raw extern "C" Vulkan bindings (what bindgen emits from vulkan.h) give Rust nothing the C API doesn't have: untyped u64/pointer handles, out-parameters, VkResult codes the compiler lets you ignore, hand-rolled function-pointer loading, and void* pNext chains assembled from raw casts. ash's job is to remove exactly that layer of mechanical unsafety — and nothing more. It generates the full API surface from vk.xml, wraps every command in a method that takes slices and references instead of pointer-plus-count pairs, returns Result<T, vk::Result>, loads entry/instance/device function-pointer tables itself (device-level functions through vkGetDeviceProcAddr, skipping the loader's dispatch trampoline), and types the p_next extension chain so that only registry-sanctioned structs can be attached.
What it explicitly does not do is enforce Vulkan's usage rules. Nearly every method on Device/Instance is unsafe fn; there is no synchronization tracking, no lifetime tying a vk::Buffer to its Device, and no host-side validation. Correctness is delegated to the programmer and to the Khronos validation layers — the same contract as C, with the FFI foot-guns removed.
Design philosophy
The README's feature checklist (README.md) is the philosophy, verbatim:
- [x] A true Vulkan API without compromises
- [x] Convenience features without limiting functionality
- [x] Additional type safety
- [x] Device local function pointer loading
- [x] No validation, everything is unsafe
- [x] Lifetime-safety on structs created with the builder pattern
- [x] Generated from
vk.xml
"Without compromises" is load-bearing: wherever ergonomics would limit functionality, ash sides with functionality (e.g. functions return Vec<T> only where that doesn't preclude passing a p_next chain into the output struct, and "Raw function pointers are available, if something hasn't been exposed yet in the higher level API" — README.md). Within this survey, ash is the baseline data point: the maximum type safety attainable while remaining a zero-policy 1:1 mirror of the C API. Compare vulkanalia (the other Rust thin binding, which makes even unsafety more explicit) and vulkano (the safety-first layer built on top of ash).
NOTE
Historical context — the alternatives ash outlived. erupt, the other generated thin Rust binding (built on the same vk-parse), is in maintenance mode since 2022; its author's note conceded the niche to ash: "It is not recommended to use erupt for new projects, use ash instead. There is work underway to rewrite ash using ideas from the erupt project" (erupt README.md). gfx-hal, the gfx-rs Vulkan-shaped portability HAL (on which the rendy frame-graph toolkit was built, now likewise archived), was abandoned by its own flagship consumer: "0.9 is going to be the last release that will use gfx-hal as its hardware abstraction layer. While it has served us well, it has proved to not be at the exact level of abstraction we need" (gfx-rs blog, July 16, 2021) — its successor, wgpu's wgpu-hal, sits directly on ash.
How it works
A program touches three hand-written wrapper types — Entry (loader-level), Instance, and Device — each owning a generated function-pointer table (EntryFnV1_0…, InstanceFnV1_0…, DeviceFnV1_0…DeviceFnV1_3) populated at creation time. Extensions live in per-extension modules (ash::khr::swapchain, ash::ext::debug_utils, …), each exposing its own Instance/Device loader struct so instance-level functions can't accidentally be fetched via vkGetDeviceProcAddr (a deliberate split introduced in 0.38, Changelog.md). All raw types, constants, and bitflags live in the generated ash::vk module.
// README.md — extension loading
use ash::khr;
let swapchain_loader = khr::swapchain::Device::new(&instance, &device);
let swapchain = swapchain_loader.create_swapchain(&swapchain_create_info).unwrap();Binding generation & API coverage
The bindings are produced by an in-repo, unpublished generator crate (generator/Cargo.toml pins its inputs): vk-parse 0.20 (with the vkxml-convert feature) and vkxml 0.3 parse the registry, nom + regex parse the C macro definitions vk.xml embeds (e.g. VK_MAKE_API_VERSION), and proc-macro2/quote/syn emit the ~76k-line definitions.rs plus enums.rs, bitflags.rs, extensions.rs, features.rs, and const_debugs.rs. A pinned bindgen (=0.69.4, "Pin version to have control over how ash/src/vk/native.rs is generated" — generator/Cargo.toml) separately translates the C video-codec headers (vk_video/*.h) into vk::native, which is why the Vulkan Video bindings are declared semver-exempt in the README. Generation is offline: the output is committed, so consumers never run the generator, and a release is regenerated against a specific Vulkan-Headers tag (the +1.3.281 suffix).
Coverage is total — core 1.0–1.4 plus every extension in the registry, including provisional ones behind a provisional cargo feature (the generator wraps those in #[cfg(feature = "provisional")], generator/src/lib.rs). Registry metadata that survives into Rust: structextends → Extends impls; member len attributes → combined slice setters; optional parameters → Option<&T> (lowered via the RawPtr trait, lib.rs); successcodes/errorcodes → Result-shaped wrappers; sType values → TaggedStructure::STRUCTURE_TYPE; deprecated attributes → #[deprecated] with a link to the explanation; handle objtypeenum → Handle::TYPE. Metadata that is dropped: the string externsync does not occur anywhere in generator/src/lib.rs — external-synchronization requirements, like all other valid-usage rules, vanish at generation time (see Synchronization safety).
Handle lifetime & ownership model
Handles are #[repr(transparent)] newtypes with no ownership semantics: dispatchable handles wrap *mut u8, non-dispatchable ones wrap u64 (vk/macros.rs: define_handle! / handle_nondispatchable!). Every handle is Copy + Clone + Eq + Hash + Default (default = null), dispatchable handles are explicitly unsafe impl Send/Sync, and all implement the Handle trait:
// ash/src/vk.rs
pub trait Handle: Sized {
const TYPE: ObjectType;
fn as_raw(self) -> u64;
fn from_raw(_: u64) -> Self;
fn is_null(self) -> bool { self.as_raw() == 0 }
}That is the entire model. A vk::Buffer is not tied to the Device that created it, can be freely copied, used after destroy_buffer, or sent across threads mid-recording — none of which the compiler can see. Drop is implemented nowhere; destruction is an explicit unsafedestroy_* call, and double-destroy/leak prevention is the caller's job. The only typed property a handle carries is Handle::TYPE (its VkObjectType), which is what makes generic debug-marker utilities possible. This is the maximal contrast with vulkano's Arc-tracked ownership and with what D's DIP1000 scope/ownership could express — ash chose Copy handles precisely so that the binding adds zero lifetime policy.
The one place real lifetimes do appear is structs, not handles: since 0.38 every generated struct containing a pointer carries a 'a parameter and a PhantomData<&'a ()> marker (definitions.rs):
// ash/src/vk/definitions.rs (generated)
pub struct DeviceQueueCreateInfo<'a> {
pub s_type: StructureType,
pub p_next: *const c_void,
pub flags: DeviceQueueCreateFlags,
pub queue_family_index: u32,
pub queue_count: u32,
pub p_queue_priorities: *const f32,
pub _marker: PhantomData<&'a ()>,
}The separate vk::XxxBuilder types of 0.37 were deleted; setters now sit directly on the struct and consume self, so "The borrow checker now ensures structs cannot outlive contained references" with no .build() escape hatch that erased the lifetime (Changelog.md). A slice setter writes the pointer and the count in one move (queue_priorities(&'a [f32]) sets both p_queue_priorities and queue_count), eliminating the classic count/pointer mismatch. The guarantee is enforced in CI by a trybuild compile-fail test, tests/fail/long_lived_root_struct_borrow.rs: reading a chained output struct while the root struct still borrows it is a compile error.
Synchronization safety
None — by design, and explicitly. ash exposes vkQueueSubmit, vkCmdPipelineBarrier, fences, binary and timeline semaphores, and queue-family ownership transfer exactly as the C API does, every one an unsafe fn whose contract lives in the linked Vulkan refpage:
// ash/src/device.rs
pub unsafe fn queue_submit(
&self,
queue: vk::Queue,
submits: &[vk::SubmitInfo<'_>],
fence: vk::Fence,
) -> VkResult<()>The contribution is limited to shape: barriers are typed slices (memory_barriers: &[vk::MemoryBarrier<'_>]), stage/access masks are distinct bitflag newtypes (vk::PipelineStageFlags2 vs vk::AccessFlags2 cannot be swapped), and wait/signal arrays are length-checked slices instead of count+pointer pairs. There is no render graph, no hazard tracking, no typestate on command buffers, and — because externsync is dropped at generation time (see above) — no distinction in the signature between externally synchronized and internally synchronized commands: queue_submit (caller must externally synchronize queue and fence) takes the same &self and Copy handles as a thread-safe query. A user must read the spec or run the synchronization validation layer to know the difference. The README's "No validation, everything is unsafe" is the whole synchronization story; this is the gap every higher layer in this survey (vulkano, daxa, vuk) exists to fill.
Type-system techniques
ash concentrates its type-system budget on four mechanisms:
Newtype handles and bitflags. Every handle, enum, and flag type is a distinct newtype with associated constants (
vk::PipelineBindPoint::GRAPHICS,vk::AccessFlags::COLOR_ATTACHMENT_READ), so mixing avk::Bufferinto an image parameter or OR-ing access flags into a stage mask is a type error — the cheapest 80% of FFI safety.Lifetime-parameterized structs (previous section): borrow-checked
p_next/array pointers withPhantomData, replacing the builder-typestate approach of 0.37.Typed
p_nextchains via marker traits. The registry'sstructextendsattribute is compiled into anunsafemarker trait per relationship (vk.rs,generator/src/lib.rs):rust// ash/src/vk.rs /// Implemented for every structure that extends base structure `B`. Concretely that means /// struct `B` is listed in its array of `structextends` in the Vulkan registry. pub unsafe trait Extends<B> {} // generated, e.g.: unsafe impl Extends<DeviceCreateInfo<'_>> for PhysicalDeviceVariablePointerFeatures<'_> {}(master generates 1,217 such impls). The safe chain-builder is constrained by it —
fn push<T: Extends<Self> + TaggedStructure<'_>>(self, next: &'a mut T) -> Self— so attaching a struct to a chain thatvk.xmldoesn't sanction does not compile.pushis safe because itassert!s the pushed struct's ownp_nextis null ("push() expects a struct without an existing p_next pointer chain"); splicing a whole pre-built chain requires theunsafe fn extendintroduced in 0.38 (which must trust every node of the foreign chain).TaggedStructure<'a>is itself anunsafe traitassertingBaseOutStructurelayout compatibility and carrying theSTRUCTURE_TYPEconstant thatDefaultwrites intos_type— the user never touchessTypeat all.Checked downcasting of returned chains. For the inverse problem — decoding a
p_nextchain handed back by the driver or a validation-layer callback — thematch_out_struct!/match_in_struct!macros dispatch on the runtimes_typetag against each arm'sTaggedStructure::STRUCTURE_TYPE, rebinding the pointer at the matched type: a tag-checked union read, the closest a C ABI allows to a safe downcast.
Absent, deliberately: no phantom branding of handles to their parent Device, no linear/affine ownership, no typestate on command buffers or pipeline construction, no const-generic API-version gating. Each was left out because it would either add policy ("compromises") or break the 1:1 C correspondence.
Overhead & escape hatches
The overhead story is effectively zero beyond C:
- Every wrapper method is
#[inline]and expands to a direct call through the cached per-device/per-instance function-pointer table — no global dispatch, no mutex, no ref-counting, no handle table.vk::Result::result()is amatchonSUCCESS. - Structs are
#[repr(C)]with a zero-sizedPhantomDataappended; the lifetime machinery,Extendsimpls, andTaggedStructureconstants are all compile-time-only.pushis a two-pointer write plus one nullassert!. - The only allocations the binding itself performs are the
Vec<T>returns for enumeration commands, implemented byread_into_uninitialized_vector, which loops the count/fill protocol until the driver stops returningvk::Result::INCOMPLETEand writes directly into uninitialized capacity. - The
debug(default) feature generatesDebugimpls that pretty-print flag names; disabling it shrinks the binary.loaded(default) pulls inlibloadingfor runtime discovery;linkedinstead links the loader at build time and exposes the infallibleEntry::linked().
Escape hatches are first-class, in both directions: device.fp_v1_0() exposes the raw function-pointer tables ("if something hasn't been exposed yet in the higher level API" — README.md); Handle::as_raw/from_raw convert any handle to/from u64 for FFI with C/C++ middleware (this is the interop hinge wgpu's wgpu-hal and gpu-allocator rely on); Entry::from_static_fn() lets the application supply its own vkGetInstanceProcAddr; and every struct field is pub, so the setter layer is skippable with plain struct syntax (vk::ApplicationInfo { api_version: …, ..Default::default() }).
Error handling & validation integration
Every fallible command returns pub type VkResult<T> = Result<T, vk::Result> (lib.rs) — success codes other than SUCCESS that carry a payload (e.g. SUBOPTIMAL_KHR from acquire_next_image, which returns VkResult<(u32, bool)>) are mapped case-by-case in the hand-written wrappers. vk::Result itself is the raw VkResult newtype, so no information is lost, and structs/commands are #[must_use] where the registry marks them so. Out-parameters are materialized through MaybeUninit + assume_init_on_success, so a failed creation can never yield a half-initialized handle.
Host-side validation is out of scope ("No validation"): ash integrates with the Khronos validation layers rather than reimplementing them, providing the typed plumbing — ext::debug_utils wrappers, vk::DebugUtilsMessengerCreateInfoEXT (pushable into InstanceCreateInfo via push, validating instance creation itself), and the match_in_struct! macro for decoding callback data. Functions that failed to load are stubbed with a panicking function pointer, so calling a Vulkan 1.1 entry point on a 1.0 instance panics loudly instead of jumping to null.
Strengths
- Zero-overhead by construction:
#[inline]wrappers over cached function-pointer tables; all safety machinery (Extends, lifetimes,TaggedStructure) is compile-time-only. - The
p_nextchain is actually type-checked —structextendsis the one piece of registry safety metadata ash promotes into the type system, and it eliminates a whole class of silent-driver-ignores-your-struct bugs;s_typeis never written by hand. - Lifetime-checked struct graphs without builders (since 0.38): the borrow checker rejects dangling
p_*pointers, proven by an in-tree compile-fail test. - Total, fast-moving coverage: core 1.0–1.4 plus all extensions including video and provisional, regenerated from each Vulkan-Headers tag.
- Honest contract:
unsafe fneverywhere a Vulkan valid-usage rule exists, so the unsafety is visible in the source rather than hidden behind a leaky safe facade. - Proven foundation: wgpu's Vulkan backend and vulkano both build on it;
as_raw/from_rawmake C/C++ interop (VMA, OpenXR) trivial. no_std+allocsupport and a small feature surface (std,debug,loaded,linked,provisional).
Weaknesses
- No synchronization model at all — barriers, semaphores, fences, timeline semaphores, and queue-family transfers are passed through verbatim; correctness depends entirely on the programmer plus the validation layers.
externsyncmetadata is discarded: externally synchronized parameters are indistinguishable from thread-safe ones in the signatures (&self+Copyhandles everywhere), so a data race on aVkQueueorVkCommandPoolcompiles silently.- No handle ownership/lifetime tracking: use-after-destroy, double-destroy, leaks, and cross-device handle mixups are all expressible in (unsafe) safe-looking code.
- Struct lifetimes are uniform
'aborrows, not a model of Vulkan's actual retention rules — they are conservative (occasionally forcing awkward scoping, as thetest_use_struct_after_pointer_chaintest demonstrates) yet say nothing about how long the driver needs the memory. - Breaking releases are routine (0.37 → 0.38 renamed every extension module and deleted all builders); there is no 1.0 and the video bindings are explicitly semver-exempt.
- The
Vec<T>-returning enumeration helpers allocate and loop onINCOMPLETE— convenient, but a hot-path caller must drop to the raw function pointers to control allocation.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
"No validation, everything is unsafe" | 1:1 C semantics; safety layers can be built on top without fighting binding policy | Every call site is unsafe; all valid-usage discipline outsourced to the user + validation layers |
Copy newtype handles, no Drop, no parent tracking | Zero overhead; handles freely shareable/FFI-able; matches Vulkan's own handle semantics | Use-after-destroy, double-free, and wrong-device bugs are uncatchable at compile time |
Lifetimes + PhantomData on structs, builders deleted (0.38) | Borrow checker proves p_* pointers outlive the struct; no .build() lifetime-erasure hole | Generated structs gain a viral 'a parameter; conservative borrows occasionally over-constrain |
structextends → Extends<B> marker traits gating push | Invalid p_next attachment becomes a compile error; s_type auto-set via TaggedStructure | Only registry-declared relationships allowed; chains built elsewhere need unsafe extend |
Drop externsync (and all other valid-usage metadata) | Keeping signatures identical to C; encoding it would impose an ownership/threading policy | Externally synchronized parameters look exactly like thread-safe ones in the types |
Offline generation, committed output, pinned headers + bindgen | Consumers never run the generator; reproducible bindings per Vulkan-Headers tag | New extensions wait for a regeneration + release; ~76k-line generated definitions.rs in-tree |
Per-device function-pointer tables (vkGetDeviceProcAddr) | Skips the loader's per-call dispatch trampoline; missing functions panic loudly | Entry/Instance/Device must outlive their children — documented, not compiler-enforced |
Vec<T> returns with an INCOMPLETE retry loop | Ergonomic enumeration; handles the count-changed-between-calls race correctly | Hidden allocation; no_std still requires alloc; hot paths must use raw pointers instead |
Sources
- ash-rs/ash — GitHub repository
- ash on docs.rs · ash on crates.io
README.md— feature checklist, builder/pushdocs, raw function pointersChangelog.md— 0.38 builder removal,extendsemantics, module reorganizationash/src/vk.rs—Handle,TaggedStructure,Extends,push/extend, chain testsash/src/vk/macros.rs—define_handle!/handle_nondispatchable!ash/src/vk/definitions.rs— generated lifetime-parameterized structsash/src/lib.rs—VkResult,match_out_struct!,read_into_uninitialized_vectorash/src/device.rs— hand-writtenunsafe fncommand wrappersash/tests/fail/long_lived_root_struct_borrow.rs— compile-fail lifetime proofgenerator/Cargo.toml—vk-parse,vkxml, pinnedbindgen·generator/src/lib.rs- vulkano-rs/vulkano#1500 — "Proposal: replace vk-sys with ash"
- Vulkan registry —
vk.xml· Vulkan-Headers · vk-parse crate - Related: vulkanalia (Rust) · vulkano (Rust) · wgpu (Rust) · Vulkan-Hpp (C++) · erupted (D) · sync validation · comparison