How to Handle Resource Lifetimes to Avoid Dangling References in Async Command Submission in Luisa Compute
Use CommandList::commit() to transfer ownership to a Commit object, capture it by move in a luisa::fiber::async lambda, and dispatch via Stream::dispatch() to ensure resources live until GPU completion.
Luisa Compute separates command creation from command submission to enable high-performance GPU programming. When submitting commands asynchronously, you must guarantee that every buffer, texture, and command object referenced by a CommandList remains valid until the GPU finishes execution. The library provides specific ownership mechanisms in include/luisa/runtime/command_list.h and include/luisa/core/fiber.h to make this safe.
Understanding the Async Submission Model
Command Creation vs. Command Dispatch
In Luisa Compute, you build a CommandList on the CPU side without immediately sending it to the GPU. This list holds luisa::unique_ptr<Command> objects that own both the command data and any embedded resource handles. Only when you call Stream::dispatch() does the runtime take control of the list and submit it to the device driver.
The Role of Stream and CommandList
The Stream class defined in include/luisa/runtime/stream.h acts as the submission queue. It accepts either a CommandList directly or a Delegate lambda that produces one. When operating asynchronously, you move the CommandList into a Commit object, which the Stream holds until the GPU signals completion.
Ownership Mechanisms for Safe Resource Management
CommandList Ownership
Every CommandList stores commands as luisa::unique_ptr<Command> instances. This design ensures that the list exclusively owns its commands and any resource handles they contain. The class is non-copyable (enforcing concepts::Noncopyable), preventing accidental duplication that could lead to double-free or use-after-move errors.
Transferring Ownership with CommandList::Commit
The CommandList::commit() method, defined in include/luisa/runtime/command_list.h, moves the entire command list into a Commit object. This transfer shifts ownership from your local scope to the runtime. You can return this Commit from a lambda or pass it directly to Stream::dispatch(), ensuring the list survives until the GPU consumes it.
Async Execution via luisa::fiber::async
The luisa::fiber::async function in include/luisa/core/fiber.h launches a lambda on a worker thread and returns a lightweight event or future<T>. When you capture the Commit object by move inside this lambda, the fiber system guarantees the object stays alive until the lambda finishes execution. This bridges the gap between CPU command building and GPU submission without blocking the main thread.
Complete Async Submission Workflow
The following pattern demonstrates safe asynchronous command submission using the ownership transfer mechanisms:
using namespace luisa::compute;
// 1. Build and submit commands asynchronously
auto async_fence = luisa::fiber::async([device = dev, stream = stream] {
// a) Create a command list with reserved capacity
auto cmdlist = CommandList::create(/*reserve*/ 64, /*callback*/ 0);
// b) Fill it with commands referencing resources
auto buffer = device->create_buffer<float>(1024);
cmdlist << buffer.copy_from(host_data);
// c) Transfer ownership to a Commit object
auto commit = cmdlist.commit();
// d) Dispatch on the stream; move semantics keep it alive
stream->dispatch(std::move(commit));
});
// 2. Synchronize later when results are needed
async_fence.wait();
In this example, buffer is created inside the lambda and captured by the command list. The Commit object owns the list, and the fiber captures the commit by move. Until async_fence.wait() returns, the runtime guarantees that buffer and all commands remain valid in GPU memory.
Why This Prevents Dangling References
Three specific design choices eliminate dangling references during async submission:
-
Exclusive Ownership Transfer:
CommandList::commit()moves the entire command storage into aCommit. No raw pointers to the command data remain in user code, preventing use-after-free when the original scope exits. -
Move Capture in Fibers:
luisa::fiber::asyncrequires capturing theCommitby value (which invokes the move constructor). The fiber runtime holds the lambda until execution completes, extending the lifetime of all captured resources. -
Runtime-Managed GPU Submission:
Stream::dispatchaccepts theCommitby rvalue reference and forwards it to the device backend (implemented insrc/runtime/stream.cppand backend-specificsrc/backends/*/stream.h). The runtime retains ownership until the GPU signals command completion, ensuring resources outlive GPU execution.
Handling Resources Across Multiple Submissions
When a resource must survive across several asynchronous command batches, use luisa::shared_ptr to maintain reference counting:
// Create a shared buffer that outlives individual submissions
auto shared_buffer = luisa::make_shared<Buffer<float>>(device->create_buffer<float>(4096));
// First async submission
auto fence1 = luisa::fiber::async([stream, shared_buffer] {
auto cmd = CommandList::create();
cmd << shared_buffer->copy_from(data1);
stream->dispatch(cmd.commit());
});
// Second async submission can reuse the same buffer
auto fence2 = luisa::fiber::async([stream, shared_buffer] {
auto cmd = CommandList::create();
cmd << shared_buffer->copy_from(data2);
stream->dispatch(cmd.commit());
});
// Wait for both; buffer stays alive until both lambdas complete
fence1.wait();
fence2.wait();
The shared_ptr ensures the Buffer remains allocated until the last referencing lambda finishes, preventing premature destruction between submissions.
Common Pitfalls and Solutions
| Pitfall | Symptom | Solution |
|---|---|---|
| Capturing CommandList by reference | Use-after-free or double-free when the stack frame exits before the lambda runs. | Capture the list by move inside the fiber lambda, or convert it to a Commit first. |
| Storing raw pointers to Buffers | GPU accesses freed memory, causing crashes or data corruption. | Use luisa::shared_ptr<Buffer> and capture the shared pointer in the lambda. |
| Modifying CommandList after creating Commit | Race conditions or assertion failures due to non-copyable semantics. | Build the list completely before calling commit(); the list is non-copyable (concepts::Noncopyable). |
| Ignoring the async event | Host code proceeds before GPU finishes, reading invalid data. | Always call event.wait() or future<T>.wait() at synchronization points. |
Key Source Files Reference
Understanding the implementation details helps verify lifetime guarantees:
include/luisa/runtime/command_list.h: DefinesCommandListwithunique_ptrstorage and thecommit()method that produces aCommitobject.include/luisa/runtime/stream.h: DeclaresStream::dispatch()which acceptsCommitobjects by rvalue reference.include/luisa/core/fiber.h: Implementsluisa::fiber::async,event, andfuture<T>for lightweight thread scheduling.src/runtime/stream.cpp: Contains_dispatchimplementation that forwards the command list to device backends while maintaining ownership.src/backends/*/stream.h: Backend-specific implementations (Vulkan, DirectX, Metal) that handle the actual GPU submission and signal completion.
Summary
- Transfer ownership explicitly: Use
CommandList::commit()to move command ownership into aCommitobject before async submission. - Capture by move: Always capture
CommitorCommandListby value (move) insideluisa::fiber::asynclambdas to extend lifetime until execution completes. - Use smart pointers for resources: Wrap persistent
BufferorTextureobjects inluisa::shared_ptrwhen referencing them across multiple async submissions. - Synchronize properly: Wait on the
eventorfuture<T>returned byluisa::fiber::asyncto ensure GPU completion before accessing results.
Frequently Asked Questions
What happens if I capture a CommandList by reference in an async lambda?
Capturing by reference creates a dangling reference risk. The CommandList is typically a stack variable in the calling function; if the calling scope exits before the worker thread executes the lambda, the reference becomes invalid. This leads to undefined behavior, including use-after-free or double-free errors when the lambda tries to commit or destroy the list. Always capture by value to trigger the move constructor.
How do I keep a Buffer alive across multiple async submissions?
Store the Buffer in a luisa::shared_ptr<Buffer> and capture this shared pointer by value in each luisa::fiber::async lambda. The reference count increments for each lambda capture and decrements when each lambda completes. The underlying GPU buffer remains allocated until the final reference is released, preventing premature destruction between submissions.
When should I use luisa::shared_ptr versus unique ownership?
Use luisa::unique_ptr (or the move-only CommandList/Commit pattern) when a resource has a single, linear lifetime tied to one async operation. Use luisa::shared_ptr when multiple async submissions, host threads, or GPU operations need concurrent access to the same buffer or texture. Shared pointers add minimal overhead via atomic reference counting but provide the flexibility needed for complex dependency graphs.
How does Stream::dispatch ensure the CommandList stays alive until GPU completion?
Stream::dispatch accepts a Commit object by rvalue reference (Commit&&), transferring ownership from the caller into the runtime. The implementation in src/runtime/stream.cpp forwards this to the device backend (e.g., Vulkan, DirectX, or Metal backends in src/backends/*/stream.h). The backend retains the Commit until the GPU signals command completion via a fence or semaphore. Only then does the destructor of Commit (and the contained CommandList) run, releasing all referenced resources safely.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →