Recursive AI

Chain dependent GPU compute passes in C#

OneGpu is a free, open-source GPU compute and rendering library for .NET from Recursive AI, on Vulkan, Metal and WebGPU. MIT or Apache-2.0, no bundled native binaries.

Feed one GPU matrix product into another without intermediate readback. Requires Vulkan 1.3 on Windows or Linux.

Terminal
dotnet add package \
    RecursiveAI.OneGpu.Backends
dotnet add package \
    RecursiveAI.OneGpu.Kernels

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using RecursiveAI.OneGpu;
using RecursiveAI.OneGpu.Kernels;
 
using var device =
    GpuBackends.CreateDevice();
using var a = device.CreateBuffer<float>(
    [1, 2, 3, 4]);
using var b = device.CreateBuffer<float>(
    [5, 6, 7, 8]);
using var intermediate = device.CreateBuffer<float>(4);
using var result = device.CreateBuffer<float>(4);
using var gemm =
    new Gemm(device, GemmElementType.F32);
var commands = device.BeginCommands();
var shape = new GemmProblem(2, 2, 2);
gemm.Encode(commands, shape, a, b, intermediate);
gemm.Encode(commands, shape, intermediate, b, result);
Console.WriteLine(commands.ComputePassCount);
Console.WriteLine(commands.DispatchCount);
device.Submit(commands);
var values = new float[4];
await result.ReadAsync(values);
Console.WriteLine(string.Join(", ", values));

Computes (A × B) × B; this run prints:

Text
2
2
249, 290, 565, 658

Unzip the example, then dotnet run.

The full program

C#
using RecursiveAI.OneGpu;
using RecursiveAI.OneGpu.Kernels;
 
using var device = GpuBackends.CreateDevice(new() { Adapter = "discrete" });
using var a = device.CreateBuffer<float>([1, 2, 3, 4]);
using var b = device.CreateBuffer<float>([5, 6, 7, 8]);
using var intermediate = device.CreateBuffer<float>(4);
using var output = device.CreateBuffer<float>(4);
using var gemm = new Gemm(device, GemmElementType.F32);
var commands = device.BeginCommands();
gemm.Encode(commands, new GemmProblem(2, 2, 2), a, b, intermediate);
gemm.Encode(commands, new GemmProblem(2, 2, 2), intermediate, b, output);
Console.WriteLine($"Passes: {commands.ComputePassCount}, dispatches: {commands.DispatchCount}");
device.Submit(commands);
var values = new float[4];
await output.ReadAsync(values);
Console.WriteLine(string.Join(", ", values));
if (!values.SequenceEqual(new float[] { 249, 290, 565, 658 }))
    throw new InvalidOperationException("Unexpected matrix product");

Output on Linux with an NVIDIA GeForce RTX 4070 via Vulkan:

Text
Passes: 2, dispatches: 2
249, 290, 565, 658

Adapter = "discrete" selects the first matching discrete GPU; if none is available, device creation throws GpuUnsupportedException.

GemmProblem(M, N, K) takes output rows, output columns, then the shared inner dimension: A is M × K, B is K × N, and the result is M × N.

The full program checks every final value and throws InvalidOperationException if the product differs. A missing capability or exceeded device limit raises GpuUnsupportedException.

Variations

For your own shaders, use the pipeline and bind-group setup in Run a compute shader in C#, then record the second compute pass in the same encoder with the first pass’s output buffer bound as its input.

To compute (A × B) × A instead, replace the second Encode call in the full program:

C#
gemm.Encode(commands, new GemmProblem(2, 2, 2), intermediate, a, output);

Update its expected-value array to [85, 126, 193, 286]. This run prints:

Text
Passes: 2, dispatches: 2
85, 126, 193, 286

The short answer

Record both operations in one GpuCommandEncoder, with the first operation’s output buffer bound as an input to the second. Submit that encoder once. OneGpu’s ordering contract says a compute pass sees every write from the passes before it in the same encoder. You do not need to read the intermediate buffer back to C# between the two passes.

The example computes two ordinary matrix products with the released kernel package. A is [[1, 2], [3, 4]] and B is [[5, 6], [7, 8]]. The intermediate result is [[19, 22], [43, 50]]; the final result is [[249, 290], [565, 658]].

How do you chain GPU compute passes in C#?

Allocate the intermediate and final buffers before recording. They have different roles: the intermediate holds the first product, and the final buffer receives the second. Keep both alive through submission and readback. In this example, each contains four float32 elements because both products have a two-by-two output shape.

Gemm.Encode records work into the encoder you pass it; it does not submit that encoder. This lets the caller build a dependency chain without a separate submission between operations. The second call receives intermediate where the first call received a. Both calls use b as the right-hand matrix. Swapping that argument is a mathematical change, not a synchronization setting.

The passes and ordering documentation defines the relevant guarantee: passes recorded one after another run in that order, each sees the earlier writes, and a later write to a buffer wins. On Vulkan, the backend records the passes into the same command buffer and separates them with memory barriers. The library handles that barrier boundary; this recipe does not expose raw Vulkan barrier calls.

That guarantee is about successive passes. It is not permission to introduce a race inside one shader dispatch, or to alias inputs and outputs of an operation that does not support in-place execution. Separate intermediate and output buffers make the dependency explicit. If the algorithm needs a global stage boundary between workgroups, keep the stages as separate passes rather than assuming all invocations of one dispatch synchronize with each other.

Read ComputePassCount and DispatchCount before calling Submit. They count recorded compute passes and recorded Dispatch or DispatchIndirect calls, respectively. In this run the values are two and two. They are not GPU execution times or a count of completed operations. The documented counters even include an empty grid, although that grid runs no work.

Finally, read back only the final buffer with ReadAsync. The awaited readback produces the CPU array that is printed and compared. A chain that only records commands but never submits them has not performed these products. Likewise, counters alone cannot establish that the data dependency or the matrix dimensions were correct; compare the resulting values against the calculation you intended.

All onegpu recipes