Recursive AI

Measure CPU and GPU frame time in C#

onegame is a free, open-source game engine for .NET from Recursive AI. MIT or Apache-2.0, no editor required.

Frame time measures rendering work in milliseconds. This example needs Vulkan (Linux or Windows) and GPU timestamp queries.

Terminal
dotnet add package RecursiveAI.OneGame.Render3D
dotnet add package RecursiveAI.OneGpu.Backends

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

C#
using RecursiveAI.OneGpu;
using OneGame.Gpu.Graph;
using OneGame.Render3D;
 
using var device = GpuBackends.CreateDevice();
using var renderer = new Renderer3D(device);
using var target = new Offscreen3D(renderer, 640, 360);
var scene = new Scene3D();
for (int i = 0; i < 12; i++) target.Render(scene);
var stats = renderer.LastStats;
Console.WriteLine($"CPU: {stats.CpuFrameMs:F3} ms");
Console.WriteLine(stats.GpuFrameMs is double gpu
    ? $"GPU: {gpu:F3} ms (frame {stats.GpuFrameIndex})"
    : "GPU: unavailable");

Renders an empty 640 × 360 scene and prints CPU and GPU rendering time. One run on an NVIDIA RTX 4070 with Vulkan printed:

Text
CPU: 0.200 ms
GPU: 0.093 ms (frame 12)

Unzip the example, then dotnet run.

Full program

C#
using RecursiveAI.OneGpu;
using OneGame.Gpu.Graph;
using OneGame.Render3D;
 
using var device = GpuBackends.CreateDevice();
using var renderer = new Renderer3D(device);
using var target = new Offscreen3D(renderer, 640, 360);
var scene = new Scene3D();
for (int i = 0; i < 12; i++) target.Render(scene);
var stats = renderer.LastStats;
Console.WriteLine($"CPU: {stats.CpuFrameMs:F3} ms");
Console.WriteLine(stats.GpuFrameMs is double gpu
    ? $"GPU: {gpu:F3} ms (frame {stats.GpuFrameIndex})"
    : "GPU: unavailable");
Console.WriteLine($"Adapter: {device.Adapter.Name}");
Console.WriteLine($"Submitted frame: {target.Graph.Frame}");
var passes = new List<PassTime>();
int frame = renderer.ReadGpuPassTimes(passes);
Console.WriteLine($"Pass frame: {frame}");
foreach (var pass in passes)
    Console.WriteLine($"{pass.Name}: {pass.Microseconds:F3} us");

The snippet at the top and the full program were run separately, so their CPU times (0.200 and 0.100 ms) differ. The download also prints the adapter and the GPU pass breakdown. Its scene has no models or input files. These are individual measurements, not expected values for your game.

Text
CPU: 0.100 ms
GPU: 0.094 ms (frame 12)
Adapter: NVIDIA GeForce RTX 4070
Submitted frame: 12
Pass frame: 12
forward: 13.312 us
bloom_down_0: 4.096 us
bloom_down_1: 2.048 us
bloom_down_2: 1.024 us
bloom_down_3: 1.024 us
bloom_down_4: 1.024 us
bloom_up_4: 1.024 us
bloom_up_3: 1.024 us
bloom_up_2: 2.048 us
bloom_up_1: 3.072 us
tonemap: 4.096 us

Without finished GPU timestamps the program prints GPU: unavailable; a missing graphics backend can throw GpuUnsupportedException when creating the device.

The short answer

Read Renderer3D.LastStats after rendering and executing the graph. CpuFrameMs gives CPU rendering time in milliseconds. GpuFrameMs gives a nullable GPU duration; use GpuFrameIndex to identify its frame. For a pass breakdown, call ReadGpuPassTimes with a reusable list.

The CPU measurement belongs to the rendering work just submitted, while the GPU result describes the newest finished graph frame. The pinned frame-time documentation says GPU results are usually one or two frames behind on a real GPU. Do not assume that the two numbers printed together describe the same frame.

How do you measure frame time rather than FPS?

Use the renderer’s measured durations, not the interval between calls to your update loop. This program renders offscreen: it does not need a window, a swapchain or a displayed FPS counter. It submits twelve frames, then reads the stats. The scene is deliberately empty, so the output demonstrates the measurement API rather than a particular game workload.

In a windowed game, read the stats each frame after executing the graph that contains renderer.Render(...). Here, graph and renderer are the game’s existing render graph and 3D renderer:

C#
// Inside the windowed render loop, after renderer.Render(...):
graph.Execute();
var stats = renderer.LastStats;

CpuFrameMs measures the Renderer3D.Render call plus the following RenderGraph.Execute. That includes scene preparation, uploads, pass declaration, command recording and submission. Before graph execution it contains only the graph-building part. It is not your whole game-loop duration: simulation, input processing and application work outside those calls are not included in that definition.

GpuFrameMs spans the first timed pass’s start through the last timed pass’s end. Gaps between passes count. If 2D or UI work shares the graph, it is included in that GPU interval too. These scopes come from the documentation linked above; neither field is a presentation-latency measurement.

How do I read CPU versus GPU frame time?

Read the units and frame identity before interpreting the values. The frame-level fields use milliseconds. The per-pass records use microseconds. In the output above, Submitted frame identifies the graph submission and Pass frame identifies the finished GPU work returned by the timing read.

The read does not wait or submit more GPU work. A frame that is still running is skipped for an older finished frame. The timing ring is four frames deep, and the documented lag is at most three frames. An older valid result is not an error, and a matching index in this particular offscreen run is not a promise that indices always match.

How can I see GPU frame time when no result is available yet?

Continue rendering and check the nullable field again on later frames. Do not turn null into zero: zero would imply a measured duration, while null says that there is no available measurement.

There are three documented reasons: the device lacks timestamp-query support, graph timing is disabled, or no timed frame has finished. Check device.Capabilities.Timestamps when selecting a profiling path. At this release, Metal and WebGPU do not provide these timestamps. Disabling timing with RenderGraphOptions.Timing = false also leaves GPU frame time unavailable. In those cases the GPU index is zero and ReadGpuPassTimes returns zero; CPU rendering time remains available after rendering.

The pass breakdown uses existing graph timestamps. Its returned integer identifies the frame that supplied the list. An empty or unavailable breakdown is not evidence that your scene performed no rendering.

All onegame recipes