General-purpose computing on the GPU¶
Besides rendering, Orka can also be used for general purpose computing on the GPU (GPGPU) by using compute shaders. With compute shaders, a framebuffer is not needed; compute shaders can read from and write to buffers and/or images (textures) that have been binded.
A shader program that uses a vertex and fragment shader requires a framebuffer. The default framebuffer is not available unless a window has been created. If the default framebuffer is not to be used, a new framebuffer object must be created and binded instead. This is useful, for example, to attach a texture to the framebuffer object and then later save the texture in a KTX file.
Surfaceless context¶
To run compute shaders, a context needs to be created first. If no default framebuffer is needed because there is nothing that needs to be displayed in a window, a surfaceless context can be created instead. This avoids needing a connection to a windowing system such as a Wayland compositor.
To create a surfaceless context, create a context via EGL using the 'device'
platform by calling the function Create_Context
in package
Orka.Contexts.EGL
.
The function has a parameter Device
that can be used to specify the device
that should be used in case your system has multiple GPUs.
See EGL for more information.
Buffers¶
Compute shaders often read from or write to buffers. To make a buffer available, bind it as an SSBO to the index of a binding point. See SSBO on how to bind a buffer.
Work groups¶
When running a compute shader on the GPU, work is divided over multiple threads. The threads are grouped in what is called a workgroup. Both the threads inside a workgroup and the workgroups themselves can be placed on one to three axes. The number of axes is arbitrary and depends on what is considered useful. For example, a compute shader that applies element-wise transformations to a tensor may use a single axis for both the threads and the workgroups, while a shader which applies a matrix multiplication on two tensors may use two axes for both the threads and the workgroups.
A shader can define a fixed number of threads per axis for a workgroups as following:
layout(local_size_x = 256) in;
This should be a power of two, such as 64 or 256.
The reason for this is that threads inside a workgroup are grouped in smaller
groups called subgroups. These threads are executed in lock step.
If the local size is not a multiple of the subgroup size, some threads will be
inactive, decreasing the occupancy of the shader.
Most discrete GPUs have a subgroup size of 32 threads.
The constant gl_WorkGroupSize
contains a vector of the number of threads on each axis.
The variable gl_WorkGroupID
contains a vector with the indices of a workgroup (one for each axis).
The identifier of a thread inside a workgroup is given by gl_LocalInvocationID
and the identifier of a thread among all workgroups is given by gl_GlobalInvocationID
(both are uvec3
).
If a video driver supports the extension ARB_compute_variable_group_size
then
variable sized compute shaders may be enabled by declaring in the shader:
#extension GL_ARB_compute_variable_group_size : require
If a compute shader has a variable size, it must be dispatched in a different way than fixed size compute shaders (explained below) and it must declare the layout differently:
layout(local_size_variable) in;
For a variable sized compute shader, the variable gl_LocalGroupSizeARB
must be used
instead of the constant gl_WorkGroupSize
.
Shared data¶
The threads inside a single workgroup can communicate with each other using shared data.
Threads from different workgroups cannot communicate with each other because work
groups are scheduled independently. In worst case a GPU may process the workgroups
one workgroup at a time. The only way for the threads from different workgroups to
communicate is to write data to a buffer, insert a memory barrier after the program
has been completed, and then launch the program again so that the threads can read
the data from the previous launch. If multiple threads wish to write to the same offset
in a buffer or a shared variable, atomic functions like atomicAdd
should be used.
To create shared data in a shader, use the keyword shared
:
shared uint data[gl_WorkGroupSize.x];
When the threads have written their data to a shared variable, they all must execute a memory and computation barrier:
memoryBarrierShared();
barrier();
After all threads have passed the barriers, they can read the shared data.
This process can be repeated using a for
loop.
The function groupMemoryBarrier
can be used for memory transactions involving
all types of memory, including buffers, images, and shared variables.
All threads of a workgroup must execute the barriers
It is important to note that all threads of a workgroup must execute a barrier,
otherwise the compute shader will hang. Thus the barriers must not be placed
inside an if
block if the control flow is not uniform.
Voting¶
Threads inside a single subgroup operate in lock step and can also exchange information
by voting. This requires the extension ARB_shader_group_vote
.
The function anyInvocationARB
returns true if and only if at
least one thread in the subgroup used true
as the input to the function.
The function allInvocationsARB
returns true if all threads
used the value true
.
The function allInvocationsEqualARB
returns true if all threads agreed
on the boolean value of the first parameter.
However, for these functions to be used effectively it is often important to know
the size of the subgroup. The size of a subgroup can be retrieved from the constant
gl_SubGroupSizeARB
(a uint
) from the extension ARB_shader_ballot
.
If this extension is not present, the previously mentioned functions may be useful only to choose between executing a fast algorithm (which works only if certain conditions are met) and a slower one (which works in all cases).
A second way to vote is the function ballotARB
from the extension
ARB_shader_ballot
.
It returns a bitfield of the type uint64_t
with a bit set for each
thread that provided the value true
as the input to the function.
This function is an effective way to exchange a small amount of information
among threads of a subgroup.
Note
See Mesamatrix for a list of extensions supported by each video driver.
Broadcasting¶
The extension ARB_shader_ballot
provides two more functions that can be used
to broadcast a value to all threads inside a subgroup.
The function readFirstInvocationARB
broadcasts and returns the given value
of the first active thread to all other active threads in the subgroup.
The function readInvocationARB
has a second parameter containing the index of
the thread in the subgroup whose value must be broadcasted to all other threads.
Limits¶
The hardware places several limits on the number of threads and workgroups.
A workgroup often has a maximum of no less than 1024 threads.
The exact number can be queried with the function Compute_Work_Group_Size
of a
Shader
object. It returns a Dimension_Size_Array
, which is an array containing
three values for the axes X
, Y
, and Z
.
A workgroup containing a large number of threads, decreases the amount of shared data available per thread.
Launching compute shaders¶
To launch a compute shader, first bind the necessary buffers and images, then
bind the program containing a compute shader with the procedure Bind_Shaders
of a Context
object
(See Using a program), and then dispatch it.
To dispatch a fixed size compute shader, execute the procedure Dispatch_Compute
from the package GL.Compute
:
declare
function Groups (Elements, Group_Size : Unsigned_32) return Unsigned_32 is
(Elements / Group_Size
+ (if Elements mod Group_Size = 0 then 0 else 1));
use all type Orka.Index_3D;
Group_Size : Dimension_Size_Array := Pipeline_1 (Compute_Shader).Value.Compute_Work_Group_Size;
Size_X : Unsigned_32 := Unsigned_32 (Group_Size (X));
begin
GL.Compute.Dispatch_Compute
(X => Groups (Elements => Elements, Group_Size => Size_X));
end;
To dispatch a variable sized compute shader, run procedure Dispatch_Compute_Group_Size
:
GL.Compute.Dispatch_Compute_Group_Size
(Group_Size => (Integer_32 (Size_X), 1, 1),
X => Groups (Elements => Elements, Group_Size => Size_X));
Insert a barrier barrier if needed
If a buffer was previously modified in any way, make sure
to insert a Shader_Storage
memory barrier before launching a compute shader.