TaskGraph
TaskGraph
As Vulkan and Daxa require manual synchronization, using Daxa and Vulkan can become quite complex and error-prone.
A common way to abstract and improve synchronization with low-level APIs is using a RenderGraph. Daxa provides a render graph called TaskGraph.
With TaskGraph, you can create task resource handles and names for the resources you have in your program. You can then list a series of tasks. Each task contains a list of used resources and a callback to the operations the task should perform.
A core idea of TaskGraph (and other render graphs) is that you record a high-level description of a series of operations and execute these operations later. In TaskGraph, you record tasks, “complete” (compile), and later run them. The callbacks in each task are called during execution.
This “two-phase” design allows render graphs to optimize the operations, unlike how a compiler would optimize a program before execution. It also allows render graphs to determine optimal synchronization automatically based on the declared resource used in each task. In addition, task graphs are reusable. You can, for example, record your main render loop as a task graph and let the task graph optimize the tasks only once and then reuse the optimized execution plan every frame. All in all, this allows for automatically optimized, low CPU cost synchronization generation.
Overview of the workflow for the task graph:
- Create tasks
- Create task resources
- Add tasks to graph
- Complete task graph
- Execute task graph
- (optional) Repeatedly reassign resources to task resources
- (optional) Repeatedly execute task graph
Task Resources
When constructing a task graph, it’s essential not to use the real resource IDs used in execution but virtual representatives at record time. This is the simple reason that the task graph is reusable between executions. Making the reusability viable is only possible when resources can change between executions. The graph takes virtual resources, TaskImage and TaskBuffer. ImageId and BufferIds can be assigned to these TaskImages and Taskbuffers and changed between executions of task graphs.
Task Resource Views
Referring to only a part of an image or buffer is convenient. For example, to specify specific mip levels in an image in a mip map generator.
For this purpose, Daxa has TaskImageViews. A TaskImageView, similarly to an ImageView, contains a slice of the TaskImage, specifying the subresource.
All Tasks take in views instead of the resources themselves. Resources implicitly cast to the views but have the explicit conversion function .view()
. Views also have a .view()
function to create a new view from an existing one.
Task
The core part of any render graph is the nodes in the graph. In the case of Daxa, these nodes are called tasks.
A task is a unit of work. It might be a single compute dispatch or multiple dispatches/render passes/raytracing dispatches. What limits the size of a task is resource dependencies that require synchronization.
Synchronization is only inserted between tasks. If dispatch A writes an image and dispatch B needs to read the finished content, both dispatches must be within different tasks, so task graph is able to synchronize.
A Task consists of four parts:
- A description of how graph resources are used, the so called “Attachments”.
- A task resource view for each attachment, telling the graph which resource belongs to which attachment.
- User data, such as a pointer to some context, pipeline pointer and general parameters for the task.
- The callback, describing how the work should be recorded for the task.
Notably, the graph works in two phases: the recording and the execution. The callbacks of tasks are only ever called in the execution of the graph, not the recording.
There are two ways to declare a task. You can declare tasks inline, directly inside the add_task function:
NOTE: There is a third defaulted parameter to inl_attachment, taking in the VIEW_TYPE for the image. When filling this VIEW_TYPE parameter, task graph will create an image view that exactly fits the dimensions of the attachments view slice. When this parameter is defaulted, daxa will fill the image view id with 0. How to access these tg generated image views is shown later.
This is convenient for smaller tasks or quick additions that don’t necessarily need shaders.
The other way to declare tasks (using “task heads”) is shown later.
Task Attachments
Attachments describe a list of used graph resources that might require synchronization between tasks.
Note: Any resource that is readonly for the execution of the task, like textures, do not need to be mentioned in the attachments.
Each attachment consists of:
- a
task resource access
(eitherTaskBufferAccess
orTaskImageAccess
), - a description of how the resource is meant to be used in a shader,
- an attachment index
For persistent tasks this is obvious, take DAXA_TH_IMAGE
as an example:
DAXA_TH_IMAGE(TaskImageAccess, ImageViewType, TaskImageAttachmentIndexName)
.
TaskGraph will use all this information to generate optimal synchronization and ordering of tasks, based on the attachments and assigned resource views.
Inline tasks omit some of these and set them do default values. When listing an inline attachment, one also directly assigns the view to the attachment as well.
TaskInterface
The interface provides functions to query information about the graph, attachments and task itself.
For example to get the runtime information for a given attachment the interface has the get
function.
It takes a resource view or an attachment index directly.
It returns a TaskAttachmentInfo
(TaskBufferAttachmentInfo
for buffers and TaskImageAttachmentInfo
for images), this struct contains all data about the attachment given on construction as well as runtime data used by the graph.
This includes:
- views assigned to attachments
- runtime daxa resource ids
- runtime daxa resource view ids (these are created by the graph based on the attachment view type)
- image layout
Aside from attachment information the interface also provides:
- a command recorder (automatically reused by the graph)
- a transfer memory allocator (super fast per execution linear allocator for mapped gpu memory)
- attachment shader data (generated from the list of attachments, can be send to shader)
TaskHead
When using shader resources like buffers and images, one must transport the image id or buffer pointer to the shader. In traditional apis one would bind buffers and images to an index but in daxa these need to be in a struct that is either stored inside another buffer or directly within a push constant.
This means that in traditional apis you must list the attachments many times:
- once in shaders, either as indices/pointers in a push constant OR direct bindings
- once in the attachments of the task
- when assigning the indices/bindings for the api
- once when assigning task buffer/task image views to the attachments
Daxa can help you a lot here by reducing the reduncancy with task heads. Task heads allow you to declare a struct in shader containing all indices/pointers to resources AS WELL AS the attachments for a task in one go! With task heads you only need to:
- list resource in attachment
- assign view to attachment
Thats it. Daxa will do all the other logic for you.
But how do task heads work?
Essentially task head declarations consist of a set of macros that are valid in shaders as well as c/c++. In each language the macros have different definitions. The task head declaration either describes a struct with indices/pointers in the shader OR a namespace containing constexpr metadata about the attachments and their use in the shader. The metadata is enoug to properly fill the shader struct in the task graph internals.
An example of a task head:
This task head declaration will translate to the following glsl shader struct:
Or the following Slang-HLSL:
In c++ this macro declares a namespace containing a few constexpr static variables. In the following code i omittied some code as it is hard to read/understand on the spot:
Extended example using a task head:
Example usage of the above task:
Alternative Use Of TaskHead
Task heads can also be directly used in inline tasks without having to declare a struct inheriting the task:
TaskInterface and Attachment Information
The ATTACHMENTS or AT constants declared within the task head contain all metadata about the attachments. But they also contain named indices for each attachment!
In the above code these named indices are used to refer to the attachments. You can refer to any attachment with HEAD_NAME::AT.attachment_name
.
Note that all these functions also take views directly instead of attachments indices in order to be compatible with inline tasks.
These indices can also be used to access information of attachments within the task callback:
TaskHead Attachment Declarations
There are multiple ways to declare how a resource is used within the shader:
Note: Some permutations are missing here. BLAS for example has no _ID, _INDEX or _PTR version. This is intentional, as some resources can not be used in certain ways inside shaders.
There are some additional valid usage rules
- A task may use the same image multiple times, as long as the TaskImagView’s slices don’t overlap.
- A task may only ever have one use of a TaskBuffer
- All task uses must have a valid TaskResource or TaskResourceView assigned to them when adding a task.
- All task resources must have valid image and buffer IDs assigned to them on execution.