Recursive AI

Read a GPU buffer back to the CPU 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.

Readback copies values from a GPU buffer into a C# array. This example uses Vulkan on Windows or Linux, with a Vulkan 1.3 driver.

On Apple silicon Macs, GpuBackends selects Metal automatically; the buffer and readback calls stay the same.

Terminal
dotnet new console
dotnet add package RecursiveAI.OneGpu.Backends

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

C#
using RecursiveAI.OneGpu;
 
using var device = GpuBackends.CreateDevice();
using var buffer = device.CreateBuffer<int>(4);
buffer.Write([10, 20, 30, 40]);
device.Submit(device.BeginCommands());
var values = new int[4];
await buffer.ReadAsync(values);
Console.WriteLine(string.Join(", ", values));

Prints the uploaded integers after readback:

Text
10, 20, 30, 40

Write queues an upload; the empty submission sends it to the GPU before the readback. A read extending beyond the buffer throws ArgumentOutOfRangeException.

Unzip the example, then dotnet run.

Variations

With the same submitted buffer and destination, replace the await with a blocking read:

C#
buffer.Read(values);
Text
10, 20, 30, 40

To read only the middle two elements, replace destination creation and the await:

C#
var values = new int[2];
await buffer.ReadAsync(values, elementOffset: 1);
Text
20, 30

The short answer

Use await buffer.ReadAsync(destination) to copy a typed GPU buffer into a C# array without blocking the calling thread while the device finishes the submitted work. Use buffer.Read(destination) when a blocking call is appropriate. In both cases the destination length determines how many elements are copied.

The sample deliberately uploads known integers and reads them back without a shader. That isolates the transfer and its ordering from any calculation. In a compute application, submit the pass that writes the buffer in place of the empty command submission. The same readback call then retrieves that pass’s output.

How do you await GPU readback?

Create the destination before starting the readback, pass it to ReadAsync, and await the returned ValueTask before inspecting or reusing it. The typed buffer API says the destination “must stay valid and unused until the task completes.” An array satisfies the lifetime requirement as long as your application does not modify it concurrently.

On Vulkan, readback records a copy into host-visible memory after the work submitted so far. A helper thread waits for that submission, rather than waiting for all future work. The calling thread and device can continue working; later submissions are not delayed by the wait. The task completes when the bytes have reached the supplied destination. The readback documentation also specifies completion in submission order and that device disposal finishes pending readbacks first.

Keep the device and buffer alive through the await. Do not start a readback, leave their using scope and then try to reuse either resource. If other application work must run before awaiting, it must not touch the destination. This API is asynchronous data transfer, not permission to share one destination between unfinished operations.

For a partial read, elementOffset selects the first source element and the destination length selects the count. The variation reads two integers starting at index one: twenty and thirty. There is no need to allocate a full-size destination and discard unwanted elements afterward.

Why is GPU readback not working?

First check submission order. Write queues an upload to run before the next submitted commands; it does not mean that a GPU calculation has already run. The sample explicitly submits commands after Write and before ReadAsync. A compute dispatch must likewise be submitted before requesting its result.

Next check the buffer’s usage flags. Readback needs BufferUsage.CopySource, while uploads need BufferUsage.CopyDestination. The default typed compute buffers used here include both. If you replace the default usage with a custom set, keep the flags needed by your transfers and shader accesses.

Finally, check the range and the element layout. Offsets on GpuBuffer<T> count elements, not bytes. A request extending past the buffer throws ArgumentOutOfRangeException before the copy. T must be unmanaged and its layout must match what the shader writes. A completed copy cannot repair a mismatched shader struct or turn float bit patterns into integer values.

Read is the synchronous alternative: it waits for all submitted work and copies into a span. The docs recommend ReadAsync in a render loop or a browser. Choose the method based on where waiting is acceptable, not on whether the GPU is discrete or integrated.

All onegpu recipes