vuk (C++)
A rendergraph-based abstraction layer for Vulkan that treats a frame as a lazily-evaluated program: passes are functions, resources are typed Value<T> futures, and an IR compiler derives every barrier, layout transition, queue transfer, and submission from declared accesses.
| Field | Value |
|---|---|
| Language | C++20 (target_compile_features(vuk PUBLIC cxx_std_20) in CMakeLists.txt) |
| License | MIT |
| Repository | martty/vuk |
| Documentation | vuk.readthedocs.io |
| Key Authors | Marcell Kiss (martty) and contributors |
| Category | Render-graph / auto-sync layer (not a binding — sits on raw vulkan.h handles) |
| Sync strategy | Fully automated: per-argument Access declarations compiled by an IR into synchronization2 barriers |
| First release | v0.4 era tags; latest GitHub release is v0.5 (August 13, 2023) |
| Latest tag | v0.7 (tagged December 23, 2025); master reviewed at commit 61abde9 (April 26, 2026) |
NOTE
vuk was rewritten between v0.5 and v0.6: the original RenderGraph+Future API became a value-based eager-DSL/lazy-execution model built on a genuine compiler IR (include/vuk/IR.hpp). This deep-dive covers current master; older articles describing vuk::Future and named-resource strings document the pre-rewrite API.
Overview
What it solves
Raw Vulkan makes the programmer schedule the GPU by hand: pipeline barriers with source/destination stage+access masks, image layout transitions, queue-family ownership transfers, semaphores between queues, and fences back to the host. Getting any of these wrong is undefined behavior that synchronization validation only partially catches. vuk's position — inherited from Themaister's render-graph articles that the project credits as its origin — is that all of this is derivable: if every pass declares how it uses each resource, a compiler can compute the minimal synchronization, place it, and also deduce render passes, framebuffers, image layouts, and multi-queue submission.
Where Daxa's TaskGraph applies the same idea as a record-once/execute-many framework, vuk goes further down the "frame as program" road: passes compose like ordinary C++ function calls over Value<T> futures, nothing executes until a result is observed, and the work list is compiled each submit by a multi-pass IR pipeline (SSA linking, type inference, queue inference, partitioning, sync lowering, linearization — src/IRPasses.cpp).
Design philosophy
From the documentation root (docs/index.rst), verbatim (including the typos):
"Alltogether vuk presents a vision of GPU development that embraces compilation - the idea that knowledge about optimisation of programs can be encoded into to tools (compilers) and this way can be insitutionalised, which allows a broader range of programs and programmers to take advantage of these."
And the execution model, from docs/topics/rendergraph.rst:
"The key difference is that execution is lazy - work is deferred until you explicitly observe a result."
The README frames the feature set as automation with full access retained: "Comes with lots of sugar to simplify common operations, but still exposing the full Vulkan interface" — automatic renderpass/subpass/framebuffer deduction, automatic layout transitions, shader-reflection-driven pipeline and descriptor-set-layout creation, and "Automates resource binding with hashmaps, reducing descriptor set allocations and updates."
How it works
A pass is a named lambda whose parameters after CommandBuffer& are resources annotated with an Access; make_pass turns it into a callable that weaves an IR CALL node. From examples/01_triangle.cpp:
// examples/01_triangle.cpp
auto pass = vuk::make_pass("01_triangle", [](vuk::CommandBuffer& command_buffer, VUK_IA(vuk::eColorWrite) color_rt) {
command_buffer.set_viewport(0, vuk::Rect2D::framebuffer());
command_buffer.set_scissor(0, vuk::Rect2D::framebuffer());
command_buffer
.set_rasterization({}) // Set the default rasterization state
.set_color_blend(color_rt, {}) // Set the default color blend state
.bind_graphics_pipeline("triangle") // Recall pipeline for "triangle" and bind
.draw(3, 1, 0, 0); // Draw 3 vertices
return color_rt;
});
auto drawn = pass(std::move(target));target and drawn are vuk::Value<vuk::ImageAttachment> — futures naming a resource produced by GPU work that has not happened yet. Values enter the graph via declare_ia/declare_buf (vuk allocates), acquire_ia/acquire_buf (import an existing resource with its last-known Access), or discard_* (import, contents dead); they leave via Value::as_released(access, domain), which records the final state a consumer outside the graph will see (include/vuk/RenderGraph.hpp). Calling submit(), wait(), or get() on a Value triggers Compiler::compile + execute over the accumulated IR; per docs/topics/rendergraph.rst, computation happens once — re-observing the same Value does not re-execute it (see Overhead for the mechanism).
The IR (include/vuk/IR.hpp) is a real compiler IR: nodes of kind CONSTRUCT, CALL, SLICE (mip/layer/subrange views), CONVERGE, ACQUIRE, RELEASE, ACQUIRE_NEXT_IMAGE, CAST, MATH_BINARY, … over a structural type system (INTEGER_TY, COMPOSITE_TY, IMBUED_TY — a type annotated with an Access, ALIASED_TY, OPAQUE_FN_TY). Even integers can be Value<uint64_t> with GPU-side arithmetic via MATH_BINARY nodes, so buffer sizes and counts can flow through the graph without host round-trips.
Binding generation & API coverage
vuk is not generated from vk.xml and is not a binding: it includes <vulkan/vulkan.h> directly (include/vuk/Config.hpp) and consumes raw VkInstance/VkDevice/VkQueue handles the application created (the examples use vk-bootstrap). Its Vulkan surface is a hand-curated dispatch table declared as X-macro lists — VkPFNRequired.hpp (101 entries, grouped by core version // 1.0, // 1.1, // 1.2) and VkPFNOptional.hpp (19 entries, including VK_KHR_ray_tracing / acceleration-structure commands):
// include/vuk/runtime/vk/VkPFNRequired.hpp
// REQUIRED
// 1.0
VUK_X(vkCmdBindDescriptorSets)
VUK_X(vkCmdBindIndexBuffer)
VUK_X(vkCmdBindPipeline)The table is filled either by the user or by dynamic loading: FunctionPointers::load_pfns(instance, device, allow_dynamic_loading_of_vk_function_pointers) (VkRuntime.hpp); a missing required pointer is a RequiredPFNMissingException. The platform floor is stated verbatim in src/extra/init/SimpleInit.cpp: "vuk requires at least vulkan 1.2 and the synchronization2 extension". Coverage is therefore curated, not exhaustive: graphics, compute, transfer, ray tracing, swapchain, timestamps — but no video, and new extensions appear only when vuk grows a feature that needs them. No vk.xml metadata (externsync, valid-usage) survives into the API, because none is consumed; the safety story is entirely vuk's own graph compiler. Applications needing full enumerated bindings pair vuk with a binding such as Vulkan-Hpp.
Handle lifetime & ownership model
Resource memory is managed by a chainable allocator hierarchy (include/vuk/runtime/vk/Allocator.hpp): DeviceVkResource (direct Vulkan allocation), DeviceFrameResource (a ring of N frames; everything allocated from frame i is recycled when frame i+N begins), DeviceLinearResource (arena), and DeviceNestedResource for stacking. Long-term handles use the RAII wrapper Unique<T> (move-only, Unique(Unique const&) = delete, frees through its Allocator on destruction). Buffer and ImageAttachment themselves are copyable plain structs carrying raw VkBuffer/VkImage plus metadata — ownership lives in the allocator and in Unique, not in the handle type, so nothing in the type system prevents using a dead handle; the frame ring plus deferred destruction makes the common per-frame case safe by construction.
Graph-side lifetime is reference-counted: a Value<T> holds a std::shared_ptr<ExtNode> keeping its IR subgraph alive, and dead nodes are reclaimed by IRModule::collect_garbage() (include/vuk/IR.hpp). Keeping a Value alive across frames is the supported idiom for persistent GPU state — the executed node degenerates into a cheap ACQUIRE (see Overhead).
Synchronization safety
Synchronization is fully automated from per-argument Access declarations — there is no user-facing barrier API at all. Access (include/vuk/Types.hpp) is a 64-bit flag enum whose values each imply a stage+access+layout triple:
// include/vuk/Types.hpp
/// Written as a framebuffer color attachment
eColorWrite = 1ULL << 2,
/// Sampled in a vertex shader
eVertexSampled = 1ULL << 5,to_use(Access) in include/vuk/SyncLowering.hpp expands an Access into a ResourceUse { PipelineStageFlags stages; AccessFlags access; ImageLayout layout; }. The compiler (src/IRPasses.cpp) then runs, per Compiler::compile: SSA link building over value revisions (build_links), inference reification (unspecified extents/formats/sample counts propagated via same_extent_as-style constraints), chain collection, queue_inference() (forward/backward propagation of DomainFlagBits through the graph), pass_partitioning() into transfer/compute/graphics streams, sync lowering (build_sync, which merges compatible read groups into "a merged layout (TRANSFER_SRC_OPTIMAL / READ_ONLY_OPTIMAL / GENERAL)"), and linearization.
The backend emits VkImageMemoryBarrier2KHR/VkMemoryBarrier2KHR (synchronization2 is mandatory). Queue-family ownership transfer is derived, not declared: a barrier gets real family indices only when the producing and consuming streams sit on executors with different queue families (src/runtime/vk/Backend.cpp):
// src/runtime/vk/Backend.cpp
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
if (src_use.stream && dst_use.stream && src_use.stream != dst_use.stream) { // cross-stream
...
if (src_queue->get_queue_family_index() != dst_queue->get_queue_family_index()) { // cross queue family
barrier.srcQueueFamilyIndex = src_queue->get_queue_family_index();
barrier.dstQueueFamilyIndex = dst_queue->get_queue_family_index();
}
}Buffers sidestep QFOT entirely: when more than one queue family exists they are created with VK_SHARING_MODE_CONCURRENT (src/runtime/vk/DeviceVkResource.cpp). Cross-queue and host-GPU ordering uses one timeline semaphore per QueueExecutor (VK_SEMAPHORE_TYPE_TIMELINE, src/runtime/vk/VkQueueExecutor.cpp); a point in device time is the two-word SyncPoint { Executor* executor; uint64_t visibility; }, whose comment reads verbatim "results are available if waiting for {executor, visibility}" (include/vuk/SyncPoint.hpp). Fences are absent from the API — host waits are timeline-semaphore waits surfaced as Value::wait()/Signal::Status::eHostAvailable. Swapchain images enter as ACQUIRE_NEXT_IMAGE IR nodes so even binary-semaphore present sync is graph-derived. The README's one honest unchecked box: fine-grained VkEvent-based split barriers are not used ("[ ] using fine grained synchronization when possible (events)", README).
Implicit vs. explicit external synchronization (vk.xml externsync) is not modeled in types; instead vuk removes the hazard category — command pools, descriptor pools, and queues are owned by executors/allocators, and Runtime documents which entry points are thread-safe. Multi-threaded graph construction is supported via a thread-local current_module; submission is serialized per executor.
Type-system techniques
Typed futures (phantom-ish wrapper):
Value<T>is a thin typed view over an untyped IR reference (UntypedValue+ExtNode), withT-specific surface grafted on —mip(),layer(),same_extent_as()forImageAttachment;subrange()forBuffer; arithmetic operators forValue<uint64_t>(include/vuk/Value.hpp).Access as a non-type template parameter: pass arguments carry their access in the signature.
VUK_IA(access)expands tovuk::Arg<vuk::ImageAttachment, access, vuk::tag_type<__COUNTER__>>(include/vuk/RenderGraph.hpp), and theArgcarrier (include/vuk/Types.hpp) is:cpp// include/vuk/Types.hpp template<class Type, Access acc, class UniqueT> struct Arg { using type = Type; static constexpr Access access = acc; Type* ptr; ... };make_passreflects the lambda's parameter pack at compile time (the__COUNTER__tag keeps otherwise-identical argument types distinct) and builds the matchingIMBUED_TYIR types — so declaring usage is part of the function type, not a separate registration step, and passing aValue<Buffer>where an image is expected is a compile error.Runtime structural type system: beneath the C++ types sits the IR's own hashed, structural
Typelattice (IMBUED_TY= type + access,ALIASED_TY, composites for images/buffers), which is what inference and sync lowering actually operate on — C++ types are the checked frontend, IR types the semantic truth.No linear/affine ownership and no borrow checking:
Valueis freely copyable (shared graph node); use-after-free of raw handles is prevented operationally (frame rings, deferred destruction), not by types. C++20 offers vuk no equivalent of Vulkano's lifetime-checked references — a relevant delta for a D port, whereDIP1000/scopecould type theCommandBuffer&-scopedArg::ptrborrows.Provenance capture instead of lifetimes: every graph-building API threads
VUK_CALLSTACK(std::source_locationchains) so compiler errors point at the user code that created the offending node.
Overhead & escape hatches
vuk's costs are deliberately runtime, amortized by caching, not compile-time-only:
- Per-submit graph compilation. The IR is rebuilt by running your frame code and recompiled on every
submit()— there is no record-once/execute-many compiled artifact as in Daxa. The mitigations are arena/colony node storage (plf::colony,InlineArena),ShortAlloc/FixedVectorsmall-allocation tooling, and partial evaluation (next point). The reusableCompilerobject retains state acrosscompile()calls but each call re-derives schedule and sync. - Partial evaluation of executed work. After a node executes, the backend rewrites it in place into an
ACQUIREnode carrying the produced values and their last-use sync state — the comment innode_to_acq(src/runtime/vk/Backend.cpp) says verbatim "// morph into acquire". AValuekept across frames therefore costs one constant-like IR node thereafter: the upload chain runs once, and subsequent graphs see only "resource, available at{executor, visibility}, last used as X". This is the current incarnation of the oldFuturecross-frame story. - Hash-based runtime caches everywhere. Pipelines, pipeline layouts, descriptor-set layouts, shader modules, samplers, render passes, framebuffers, image views, and descriptor sets are all
Cache<T>hashmaps keyed on create-info (src/runtime/vk/VkRuntime.cpp,src/runtime/vk/DeviceFrameResource.cpp); descriptor sets are hashed per draw frombind_*calls (the README's "Automates resource binding with hashmaps"), withset_descriptor_set_strategy()andbind_persistent(set, PersistentDescriptorSet&)(include/vuk/runtime/CommandBuffer.hpp) as the opt-outs for bindless/perf-critical paths. - Escape hatches.
CommandBuffer::get_underlying()returns the rawVkCommandBuffer— annotated verbatim "Unsafe: use only when not setting state, eg. tracing. Otherwise use the bind_X_state functions" (include/vuk/runtime/CommandBuffer.hpp).Buffer/ImageAttachmentexpose their rawVkBuffer/VkImage/VkImageView, the device/instance/queues are the application's to begin with, andacquire_*/as_releasedare the sanctioned airlock for resources whose lifetime vuk never sees. There is no zero-overhead mode: you cannot keep the pass API and skip graph compilation.
Error handling & validation integration
All fallible APIs return vuk::Result<T, E> (include/vuk/Result.hpp) — attributed verbatim in-source as "based on https://github.com/kociap/anton_core/blob/master/public/anton/expected.hpp" — a discriminated union whose error arm is a heap-allocated pointer to an Exception-derived object. Its distinctive policy is unobserved-error escalation: destroying a Result holding an unexamined error calls _error->throw_this() when VUK_USE_EXCEPTIONS is set, otherwise std::abort(); VUK_FAIL_FAST asserts at the error's creation site instead. The error taxonomy (include/vuk/Exception.hpp) covers ShaderCompilationException, RenderGraphException (graph validation failures: unattached resources, inference contradictions, illegal access combinations — diagnosed at compile-of-the-graph, before any Vulkan call, with VUK_CALLSTACK source locations in the message), RequiredPFNMissingException, VkException (a VkResult wrapper), and AllocateException.
CommandBuffer recording is monadic-light: bind_*/draw return CommandBuffer& for chaining while a floating Result<void> current_error latches the first failure; the pass framework checks result() after the callback, so a broken bind poisons the pass rather than crashing mid-record (include/vuk/runtime/CommandBuffer.hpp). For debugging, Compiler::dump_graph() emits the IR (src/GraphDumper.cpp), passes/resources get debug names propagated to Vulkan object names (the README: "Helps debugging by naming the internal resources"), and because barriers are machine-derived, synchronization validation findings indicate vuk bugs rather than user bugs. Khronos validation layers remain the backstop for everything below the graph (vuk does not ingest validation output programmatically).
Scheduling model vs Daxa TaskGraph
Both libraries derive barriers from declared per-task accesses, but their compilation economics differ fundamentally:
| Aspect | vuk | Daxa TaskGraph |
|---|---|---|
| Graph construction | Re-built every frame by running C++ code over lazy Values; shape may change freely | Recorded once into a TaskGraph, then complete()d; shape is fixed (escape: permutations) |
| Compilation cost | Compiler::compile per submit; amortized via ACQUIRE morphing + create-info caches | Paid once: "record graph once and execute it many times, significantly reducing CPU overhead" (Daxa README) |
| Dynamism | Free — any frame can build any graph; data-dependent values via Value<uint64_t> math | Conditionals via pre-compiled graph permutations; per-frame variation is otherwise re-recording |
| Use declaration | In the C++ type: Arg<T, Access, tag> via VUK_IA/VUK_BA macros | Runtime attachment lists (TaskAttachmentInfo) on task structs |
| Cross-frame results | A live Value partial-evaluates to ACQUIRE | Persistent TaskBuffer/TaskImage objects track latest access between executions |
| Optimization locus | IR passes: queue inference, partitioning, read-group merging, linearization | Graph-level: task reordering to minimize barriers, transient memory aliasing |
In short, Daxa buys per-frame CPU time with rigidity; vuk buys flexibility (and a compiler-shaped future: the docs say development focuses on a "backend… -agnostic form of representing graphics programs" [sic], docs/index.rst) with a per-frame compile it must keep cheap. See the Daxa deep-dive and the cross-survey comparison.
Strengths
- The most complete auto-sync design surveyed: barriers, layouts, renderpasses, framebuffers, queue routing, QFOT, timeline semaphores, and swapchain sync are all derived from one declaration — the per-argument
Access. - Access in the function type:
Arg<Type, Access, tag>makes resource usage part of a pass's compile-time signature, catching wrong-resource-kind errors and keeping declarations adjacent to use (no separate registration list to drift). - Lazy
Valuecomposition subsumes upload pipelines, cross-frame persistence, GPU readbacks, and multi-queue chains under one future-like abstraction with partial evaluation (node_to_acq) keeping steady-state cost low. - Genuine IR with inference: image parameters propagate through the graph (
same_extent_as), integers can live GPU-side, anddump_graph()gives a real compiler-style debugging artifact. - Graph-time validation with source locations (
VUK_CALLSTACK) reports errors before any Vulkan call, in terms of user code. - Multi-queue for free:
DomainFlagBits::eAnyplusqueue_inference()/pass_partitioning()exploits async compute/transfer without user-visible semaphore code.
Weaknesses
- Per-frame graph compilation is irreducible CPU overhead — the inverse of Daxa's precompilation model; vuk has no record-once mode, and its IR passes run on every submit.
- No type-level lifetime/ownership safety:
Buffer/ImageAttachmentare copyable raw-handle structs; safety against use-after-free is operational (frame rings, RAIIUnique<T>), andAccesscorrectness inside the pass body (does the shader really only sample?) is unchecked. - Curated, lagging API surface: the hand-maintained PFN tables cover what vuk uses (no
vk.xmlgeneration, no video, no mesh-shading-specific sugar at review time); anything else needs the raw escape hatches. - Hash lookups on hot paths: per-draw descriptor-set hashing and create-info cache probes are the price of "binding with hashmaps"; bindless via
bind_persistentis the workaround, not the default. - No
VkEventsplit barriers — the README's own unchecked box; sync is correct-but-coarse pipeline barriers. - Pre-1.0, API in flux ("will change in API and behaviour as we better understand the shape of the problem",
docs/index.rst); thev0.5→v0.6rewrite invalidated most third-party tutorials, and bus factor is essentially one.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
Lazy Value<T> futures + per-submit IR compilation (vs. record-once) | Frames stay ordinary C++ control flow; arbitrary per-frame dynamism; uploads/readbacks compose | Graph build + compile every frame; CPU cost must be re-amortized via caches and ACQUIRE morphing |
Access as non-type template parameter in pass signatures (VUK_IA) | Usage declarations are type-checked, colocated with the parameter, reflectable by make_pass | Macro-based ergonomics; declared access is trusted, not verified against shader behavior |
Layered on raw vulkan.h + curated X-macro PFN tables (no vk.xml codegen) | Zero binding maintenance; interops with any loader/binding; small dispatch surface | API coverage lags the registry; no externsync/valid-usage metadata can inform the type system |
One timeline semaphore per queue executor; SyncPoint{executor, visibility} | Uniform GPU/host sync; no fence pools; trivially expressible cross-queue waits | Requires Vulkan 1.2 + synchronization2; binary semaphores survive only at the swapchain boundary |
QFOT derived from stream partitioning; buffers VK_SHARING_MODE_CONCURRENT | Ownership transfer is invisible to users; buffers skip the dance entirely | Concurrent sharing can cost bandwidth on some hardware; no user control over transfer placement |
Result<T, E> that throws/aborts when an error is dropped unexamined | Errors cannot be silently ignored even without exceptions enabled | Heap-allocated errors; destructor-driven control flow surprises; VUK_USE_EXCEPTIONS config split |
| Descriptor/pipeline/renderpass state via create-info hashmaps | No descriptor lifecycle code for users; deduplication across passes for free | Hash + probe per draw on the default path; bind_persistent needed for bindless-class performance |
Sources
- martty/vuk — GitHub repository · README ·
CMakeLists.txt - vuk documentation (readthedocs) ·
docs/index.rst·docs/topics/rendergraph.rst include/vuk/IR.hpp— IR nodes, structural types,IMBUED_TYinclude/vuk/Value.hpp—Value<T>/UntypedValuefuturesinclude/vuk/RenderGraph.hpp—make_pass,VUK_IA/VUK_BA,declare_*/acquire_*,Compilerinclude/vuk/Types.hpp—Accessenum,Argcarrierinclude/vuk/SyncLowering.hpp—to_use, access classification ·include/vuk/ResourceUse.hppinclude/vuk/SyncPoint.hpp—SyncPoint/Signalsrc/IRPasses.cpp— SSA linking, inference, queue inference, partitioning, linearizationsrc/runtime/vk/Backend.cpp— barrier emission, QFOT derivation,node_to_acqsrc/runtime/vk/VkQueueExecutor.cpp— timeline-semaphore executors ·src/runtime/vk/DeviceVkResource.cpp·src/runtime/vk/DeviceFrameResource.cppinclude/vuk/runtime/CommandBuffer.hpp— binding model, error latch,get_underlying()include/vuk/Result.hpp·include/vuk/Exception.hpp·src/GraphDumper.cppinclude/vuk/runtime/vk/VkPFNRequired.hpp·VkPFNOptional.hpp·VkRuntime.hpp·src/extra/init/SimpleInit.cpp- Themaister: Render graphs and Vulkan — a deep dive
- Ipotrick/Daxa — README (TaskGraph precompilation quote)
- Related: Daxa (C++) · Tephra (C++) · Vulkano (Rust) · Vulkan-Hpp (C++) · Synchronization validation · Comparison · Survey index