Recursive AI

Measure GPU compute shader time 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.

Measure a compute pass with GPU timestamps. Timestamps work on Vulkan and Metal; OneGpu 1.0.0 does not expose them on WebGPU. Linux and Windows require Vulkan 1.3.

Terminal
dotnet new console
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(
    new GpuDeviceOptions { Adapter = "discrete" });
if (!device.Capabilities.Timestamps)
    throw new NotSupportedException("GPU timestamps are unavailable.");
using var left = device.CreateBuffer<float>(new float[] { 1, 2, 3, 4 });
using var right = device.CreateBuffer<float>(new float[] { 5, 6, 7, 8 });
using var output = device.CreateBuffer<float>(4);
using var gemm = new Gemm(device, GemmElementType.F32);
using var timestamps = device.CreateTimestampSet(2);
var encoder = device.BeginCommands();
encoder.BeginPassTiming(new PassTiming(timestamps, 0, 1));
gemm.Encode(encoder, new GemmProblem(2, 2, 2), left, right, output);
Console.WriteLine($"Timed passes: {encoder.EndPassTiming()}");
device.Submit(encoder);
device.WaitIdle();
var values = new ulong[2];
if (device.TryReadTimestamps(timestamps, 0, values))
    Console.WriteLine($"Compute pass: {values[1] - values[0]} ns");
else
    throw new InvalidOperationException("Timestamps are not ready.");

The snippet at the top supplies both 2 × 2 row-major input matrices inline. It creates two timestamp slots, records a single GEMM (matrix multiplication) pass, submits it, then waits before reading the slots. Kernel creation is outside the timed pass.

If timestamps are unavailable, this program throws NotSupportedException before recording work; invalid matrix shapes throw GpuKernelShapeException.

For your own compute shader, call BeginPassTiming before BeginComputePass, record the pipeline, bindings and dispatch, end the pass, then call EndPassTiming before submitting the same encoder.

Unzip the example, then dotnet run.

Example output from an NVIDIA GeForce RTX 4070 with Vulkan:

Text
Timed passes: 1
Compute pass: 4608 ns

Your duration will vary; this is one measured pass, not a performance comparison.

Variations

Replace the nanosecond output line in the program with this, inside the successful TryReadTimestamps branch:

C#
Console.WriteLine(
    $"Compute pass: {(values[1] - values[0]) / 1_000_000.0:F6} ms");

One run printed:

Text
Timed passes: 1
Compute pass: 0.003328 ms

The short answer

Check device.Capabilities.Timestamps, create a timestamp set, and call BeginPassTiming on the encoder before recording a compute pass. Each timed pass consumes a start slot and an end slot. After submission has completed, TryReadTimestamps returns values in nanoseconds. Subtract the start from the end to obtain the pass duration.

The OneGpu 1.0.0 timestamp documentation defines this as pass timing. A kernel family can encode one or more passes, so count the passes you actually record rather than assuming every C# method call is a single GPU dispatch.

How to test shader performance?

First decide what you need to measure. Device execution, CPU recording, submission, synchronization and readback are different intervals. The program above measures the GPU boundaries of one compute pass. It does not measure the entire console application or include kernel construction as part of that pass.

The encoder’s EndPassTiming() returns the number of passes timed. Here it returns one, matching the single GEMM pass and the two allocated slots. For more passes, reserve two slots for each pass and set MaxPasses accordingly. A pass beyond that maximum is not timed. Read the pair for each pass separately if you need its individual duration.

Use representative inputs when you apply the same pattern to an application. The four-element product here keeps all inputs visible and makes the timestamp API easy to reproduce; its measured duration is not a prediction for a larger matrix or a different GPU. Keep setup outside the timing region if the question is execution time, and explicitly include it in a separate wall-clock measurement if startup is what matters.

How to calculate execution time?

For the first pass, calculate values[1] - values[0]. The API has already converted the backend’s clock values to nanoseconds. Do not multiply the returned values by the Vulkan timestamp period again. Divide by one million for milliseconds, as the variation shows.

Read only after the relevant submission has completed. The sample uses WaitIdle() for a direct, synchronous walkthrough, then checks the Boolean return from TryReadTimestamps rather than treating unread values as a zero-duration pass. BeginPassTiming and EndPassTiming record timing boundaries; they do not themselves submit the encoder or wait for it.

How do Vulkan timestamp queries work?

OneGpu writes a Vulkan timestamp query at each end of the pass. The device’s timestampPeriod supplies the resolution. On Metal, OneGpu samples at compute or render pass boundaries and converts the GPU clock ticks to nanoseconds. Those are backend implementation details behind the same public timestamp API.

WebGPU is different in this release: OneGpu reports no timestamps there. Check the capability instead of inferring support from the graphics card model or a browser’s general WebGPU support. A CPU stopwatch around browser submission would measure a different interval, so it is not a substitute for the pass duration this recipe promises.

All onegpu recipes