Skip to content

@typegpu/kitprofiler · dynamicResolution

Profiler

GPU timers for your passes, on one query set, read back without stalling.

kit.profiler

module

Times GPU passes with one shared query set and reads results without stalling.

profiler.create(root, options?) → Profiler

profiler.split(writes) → Split

rootborrows
the root
Profilerreturns
a profiler you create timers on, capture each frame and destroy
writestakes
a timer's writes, or none for untimed passes
Splitreturns
the writes for the first pass and for the last
types
TimestampWritesSplitTimedTimingTimerProfiler
Optionscreate · 5 fields
capacity?

number= 16

Limits the number of timers, with two timestamps each.

samples?

number= 30

Sets the number of samples per median.

skip?

number= 30

Ignores the first this many frames read back while pipelines and caches warm up.

staging?

number= 3

Sets the number of staging buffers.

onSample?

(timing: Timing, ms: number) => void= none

Receives samples in milliseconds after the first skip frames, as they arrive a frame or two later.

records
  • captureframe

capture after the passes it times; a timer's writes in every frame that records its pass.

owns

a query set ·4 buffers

Two timestamps per timer, a buffer they resolve into, and staging buffers mapped in turn, as many as staging.

lifecycle

destroy()[Symbol.dispose]

requires
  • timestamp-queryoptional

    Without it the profiler is inert and its timings stay undefined.

Set overlap to 0 to make frame duration match the sum of pass times. The simulated passes total 13.7 ms.

Timestamp queries measure GPU pass time. A pass records the GPU clock at its beginning and end. The timestamps reach the CPU a frame or two later. A profiler holds the query set and its readback, and makes a timer for each pass you time. It reads back only the passes that ran.

import {
import profiler
profiler
} from '@typegpu/kit';
const
const root: TgpuRoot
root
= await
const tgpu: {
const: typeof import("node_modules/typegpu/core/constant/tgpuConstant").constant;
fn: typeof import("node_modules/typegpu/core/function/tgpuFn").fn;
comptime: typeof import("node_modules/typegpu/core/function/comptime").comptime;
resolve: typeof import("node_modules/typegpu/core/resolve/tgpuResolve").resolve;
resolveWithContext: typeof import("node_modules/typegpu/core/resolve/tgpuResolve").resolveWithContext;
init: typeof import("node_modules/typegpu/core/root/init").init;
initFromDevice: typeof import("node_modules/typegpu/core/root/init").initFromDevice;
slot: typeof import("node_modules/typegpu/core/slot/slot").slot;
lazy: typeof import("node_modules/typegpu/core/slot/lazy").lazy;
... 10 more ...;
'~unstable': typeof import("node_modules/typegpu/tgpuUnstable");
}

@module ― typegpu

tgpu
.
init: (options?: InitOptions) => Promise<TgpuRoot>

Requests a new GPU device and creates a root around it. If a specific device should be used instead, use

@see ― initFromDevice. *

@example

When given no options, the function will ask the browser for a suitable GPU device.

const root = await tgpu.init();

@example

If there are specific options that should be used when requesting a device, you can pass those in.

const adapterOptions: GPURequestAdapterOptions = ...;
const deviceDescriptor: GPUDeviceDescriptor = ...;
const root = await tgpu.init({ adapter: adapterOptions, device: deviceDescriptor });

init
({
device?: (GPUDeviceDescriptor & {
optionalFeatures?: Iterable<GPUFeatureName>;
}) | undefined
device
: {
optionalFeatures?: Iterable<GPUFeatureName> | undefined
optionalFeatures
: ['timestamp-query'] },
});
const
const gpu: profiler.Profiler
gpu
=
import profiler
profiler
.
function create(root: TgpuRoot, options?: profiler.Options): profiler.Profiler

Times GPU passes with one shared query set and reads results without stalling. Throws unless capacity, samples and staging are integers above 0.

@requires ― timestamp-query optional without it the profiler is inert and its timings stay undefined

@owns ― a query set, 4 buffers: two timestamps per timer, a buffer they resolve into, and staging buffers mapped in turn, as many as staging

@records ― capture after the passes it times; a timer's writes in every frame that records its pass

@returns ― a profiler you create timers on, capture each frame and destroy

create
(
const root: TgpuRoot
root
);
const
const shadow: profiler.Timer
shadow
=
const gpu: profiler.Profiler
gpu
.
Profiler.timer(name: string): profiler.Timer

Creates a timer for one pass during setup. Throws when the timer count exceeds capacity.

timer
('shadow');
import frames
frames
.
function create(root: TgpuRoot, record: Recorder): frames.Frames

Records a frame into one encoder and submits once, with a clock. Borrows the root. The synchronous recorder borrows each frame's encoder.

@submits ― always once a frame after your recorder returns, and again at each frame.submit()

@param ― record your frame, recorded into each frame's encoder

@returns ― a loop you run with each frame's time and destroy

create
(
const root: TgpuRoot
root
, (
frame: frames.Frame
frame
) => {
const
const pass: TgpuComputePass
pass
=
frame: frames.Frame
frame
.
Frame.encoder: TgpuCommandEncoder

Records commands for submission after the recorder returns. submit() replaces this encoder. Read it at each call instead of keeping it, and let the frame submit it.

encoder
.
TgpuCommandEncoder.beginComputePass(descriptor?: TgpuComputePassDescriptor): TgpuComputePass

Begins recording a compute pass

beginComputePass
({
TgpuComputePassDescriptor.timestampWrites?: TgpuPassTimestampWrites | undefined
timestampWrites
:
const shadow: profiler.Timer
shadow
.
Timer.writes(): profiler.TimestampWrites | undefined

Marks the pass for timing in this frame and returns timestamp writes, or undefined without timestamp-query. Only marked passes are read back, because skipped passes keep stale timestamps. Call in every frame that records the pass, as you record it: a timing starts no earlier than the one marked before it. Throws after destroy, even without timestamp support.

writes
(),
});
// ..
const pass: TgpuComputePass
pass
.
TgpuComputePass.end(): void

Completes the recording of this compute pass

end
();
const gpu: profiler.Profiler
gpu
.
Profiler.capture(frame: frames.Frame): void

Resolves the frame's timestamps and maps them after the submit. Call it after the timed passes. A second call in the same frame records nothing.

capture
(
frame: frames.Frame
frame
); // after the timed passes
});
const shadow: profiler.Timer
shadow
.ms;
Timing.ms: number | undefined

Reports the median GPU duration in milliseconds from the last samples samples, or undefined before any samples. A duration runs from the pass's first timestamp to its last, so it includes time spent waiting on earlier passes and time shared with passes running beside it. It starts no earlier than the pass timed before it in the frame, since some drivers write a first timestamp before the pass can run. Passes overlap on the GPU, so their durations do not sum to the frame duration.

A timer is a handle you keep and pass around; its name is only a label. Make timers at setup, up to capacity, 16 by default. timers lists them in order, for a readout.

A CPU clock read around queue.onSubmittedWorkDone() times when the queue drains, which includes waiting for earlier frames and for other programs using the GPU, and can’t split a frame into passes. To time every pass of a page without changing its code, use the GPU debug panel.

A timing spans a pass’s first and last timestamps. Passes can overlap. Tile-based GPUs, including those in Apple devices and most phones, can start the next pass’s vertex work while the previous pass’s fragments finish. Independent passes can run side by side. Compare passes individually, but don’t add their times to measure a frame.

A pass’s timing also counts time it spends waiting. The canvas pass often starts early and then waits for the passes whose results it reads, so a cheap tone map can read as the most expensive pass of the frame. Some drivers write a pass’s first timestamp before it can run at all. The profiler starts each timing no earlier than the pass timed before it, so call writes() as you record each pass.

A timer’s share splits the frame instead. Each stretch of GPU time goes to the earliest recorded pass running in it, since later passes usually wait on it. The canvas pass’s share is its tail after the rest finish, the time it adds to the frame. A pass that runs entirely beside an earlier one, such as a light pass beside a trace, gets none of it, and the earlier pass’s share includes both. Shares add up to the time any timed pass was running. To measure what one pass costs, leave it out and see how much shorter the frame gets.

gpu.frame.ms measures the interval from the earliest timed pass’s beginning to the latest timed pass’s end. It reports the median of those frame intervals.

  • Time the frame’s first and last passes, and frame is the whole frame’s GPU time, gaps between passes included. Passes between needn’t be timed.
  • The last pass is usually the canvas’s, which reads what the frame drew, so it ends last. A last pass that shares nothing with the rest, such as a compute pass after the canvas’s, can run beside them and end early.

A GPU that finishes a frame early lowers its clock until the next one, and the next frame’s passes run slower. Timings show how long passes took at the clock the GPU chose. To compare passes or measure a change, also measure without vsync, where the GPU runs at full clock. The GPU debug panel’s Measure runs frames back to back at full clock.

  • writes() returns what a pass descriptor’s timestampWrites takes, or pipeline.withTimestampWrites, which shares the compiled pipeline.
  • writes() also marks the pass, and only marked passes are read back, since a skipped pass leaves old timestamps in the query set. Call it in every frame that records the pass.
  • Call capture(frame) after the timed passes. It maps timestamps after submit through staging buffers, 3 by default, as a readback does. If all buffers are mapping, it skips timing that frame. Passes marked after the frame’s first capture also go untimed.
  • ms and share are medians of the last samples valid timings, or undefined before any arrive. The profiler drops the first skip frames it reads back, for warm-up, and any sample whose end isn’t after its beginning. A pass that runs only now and then, such as a level load, reports once it runs after warm-up. samples and skip both default to 30.
  • onSample(timing, ms) gets every ms sample after the first skip frames, a timer’s or gpu.frame’s, as it comes back. Keep a maximum or a percentile there, or plot it.
  • Keep a pass descriptor and assign scenePass.timestampWrites = sceneTime.writes() before each begin, so no frame allocates one.

A kit module that opens passes takes the writes in its last argument, a profiler.Timed:

scene.begin(frame, { timestampWrites: sceneTime.writes() });
scene.record(frame, drawn, { timestampWrites: sceneTime.writes() });
glow.record(frame, { timestampWrites: bloom.writes() });
light.shadow.record(frame, casters, { timestampWrites: shadow.writes() });

After destroy(), timer and writes throw The profiler was destroyed., with or without timestamp-query, and capture records nothing.

Without timestamp-query, available is false. writes() returns undefined, which timestampWrites accepts. capture does nothing and every ms stays undefined, so the same code still runs. Optional features missing from the adapter are omitted without an error. Check available and fall back to frame times.

kit.dynamicResolution

cpu

Picks a resolution scale that keeps a GPU timing under a budget.

dynamicResolution.create(timing, { budget, … }) → DynamicResolution

Optionscreate · 2 fields
budget

number | ((interval: number) => number)

Sets the milliseconds the timing should stay under, such as 6 for a frame's GPU time, or a function of the display's frame interval in milliseconds, called at every judgement: (interval) => 0.75 * interval.

range?

readonly [number, number]= [0.5, 1]

Bounds the scale, lowest first, at most 1.

kind
cpu
records
updateframe
types
DynamicResolution

A frame over its GPU budget misses the display’s refresh, and one well under it could draw more pixels. dynamicResolution.create(timing, { budget }) turns a timing into a resolution scale. It lowers the scale as soon as the timing runs over budget and raises it when there is room. A target set’s resolution reads the scale and changes without allocating:

import {
import dynamicResolution
dynamicResolution
} from '@typegpu/kit';
const
const gpu: profiler.Profiler
gpu
=
import profiler
profiler
.
function create(root: TgpuRoot, options?: profiler.Options): profiler.Profiler

Times GPU passes with one shared query set and reads results without stalling. Throws unless capacity, samples and staging are integers above 0.

@requires ― timestamp-query optional without it the profiler is inert and its timings stay undefined

@owns ― a query set, 4 buffers: two timestamps per timer, a buffer they resolve into, and staging buffers mapped in turn, as many as staging

@records ― capture after the passes it times; a timer's writes in every frame that records its pass

@returns ― a profiler you create timers on, capture each frame and destroy

create
(
const root: TgpuRoot
root
);
const
const sceneTime: profiler.Timer
sceneTime
=
const gpu: profiler.Profiler
gpu
.
Profiler.timer(name: string): profiler.Timer

Creates a timer for one pass during setup. Throws when the timer count exceeds capacity.

timer
('scene');
const
const outputTime: profiler.Timer
outputTime
=
const gpu: profiler.Profiler
gpu
.
Profiler.timer(name: string): profiler.Timer

Creates a timer for one pass during setup. Throws when the timer count exceeds capacity.

timer
('output');
const
const detail: dynamicResolution.DynamicResolution
detail
=
import dynamicResolution
dynamicResolution
.
function create(timing: profiler.Timing, options: dynamicResolution.Options): dynamicResolution.DynamicResolution

Picks a resolution scale that keeps timing under budget. It assumes time grows with pixels, the square of the scale, to step close to the budget in one move: down as soon as a median of recent samples runs over, up after a second at one scale with room to spare, at least one step while the median is under 85% of the budget. It judges only samples of frames recorded at the current scale. Without samples, as without timestamp-query, the scale stays where it is: set it after create to start lower. Throws unless budget is finite and above 0, when created or computed, and range runs from above 0 up to at most 1.

@param ― timing what to keep under budget, such as a profiler's frame or a pass's timer

@returns ― a resolution controller you update every frame and read the scale of

create
(
const gpu: profiler.Profiler
gpu
.
Profiler.frame: profiler.Timing

Reports the duration from the earliest timed pass start to the latest timed pass end in each frame. Covers the frame's GPU work when the first and last passes are timed, including gaps between passes. The final pass, such as the canvas pass, waits for work it draws.

frame
, {
Options.budget: number | ((interval: number) => number)

Sets the milliseconds the timing should stay under, such as 6 for a frame's GPU time, or a function of the display's frame interval in milliseconds, called at every judgement: (interval) => 0.75 * interval.

budget
: (
interval: number
interval
) => 0.75 *
interval: number
interval
});
const
const hdr: {
readonly format: "rgba16float";
readonly sampled: true;
}
hdr
= {
format: "rgba16float"
format
: 'rgba16float',
sampled: true
sampled
: true } as
type const = {
readonly format: "rgba16float";
readonly sampled: true;
}
const
;
const
const screen: targets.Targets<{
readonly hdr: {
readonly format: "rgba16float";
readonly sampled: true;
};
}>
screen
=
import targets
targets
.
function create<{
readonly hdr: {
readonly format: "rgba16float";
readonly sampled: true;
};
}>(root: TgpuRoot, source: targets.SizeSource, specs: {
readonly hdr: {
readonly format: "rgba16float";
readonly sampled: true;
};
}, options?: targets.Options): targets.Targets<{
readonly hdr: {
readonly format: "rgba16float";
readonly sampled: true;
};
}>

Creates render targets that follow a size. Replaces the whole set when the size changes. Put targets needed only sometimes in their own set and create that set when needed. Throws for transient targets with sampling, storage or mip levels, for multisampled targets with storage or mip levels, and for layered targets that are multisampled, mipped or have more layers than the device allows.

@param ― source the size it follows, such as a surface

@param ― specs a format and options per target, by name

@param ― options.resolution the fraction of each side drawn, which changes without allocating

@returns ― a set of targets you fit every frame and destroy

create
(
const root: TgpuRoot
root
,
const surface: web.Surface
surface
, {
hdr: {
readonly format: "rgba16float";
readonly sampled: true;
}
hdr
}, {
Options.resolution?: number | (() => number) | undefined

Sets the fraction of each side to draw, capped at 1 and read at each fit(). Textures keep the source size, so changing resolution allocates nothing. Passes use viewports for the drawn regions, and sampling reads only those regions. Default 1.

resolution
: () =>
const detail: dynamicResolution.DynamicResolution
detail
.
DynamicResolution.scale: number

Reports the scale for a target set's resolution, the top of range until a move or a set. Moves keep it on steps of 0.05 within range. Setting it clamps to range, ignores samples of earlier frames and throws a RangeError unless the value is finite.

scale
});
import frames
frames
.
function create(root: TgpuRoot, record: Recorder): frames.Frames

Records a frame into one encoder and submits once, with a clock. Borrows the root. The synchronous recorder borrows each frame's encoder.

@submits ― always once a frame after your recorder returns, and again at each frame.submit()

@param ― record your frame, recorded into each frame's encoder

@returns ― a loop you run with each frame's time and destroy

create
(
const root: TgpuRoot
root
, (
frame: frames.Frame
frame
) => {
const detail: dynamicResolution.DynamicResolution
detail
.
DynamicResolution.update(frame: frames.Frame): void

Reads the timing's newest sample, if one arrived, and moves scale when the samples call for it. Call once a frame, before the targets that read the scale fit.

update
(
frame: frames.Frame
frame
); // before fit, which reads the scale
const screen: targets.Targets<{
readonly hdr: {
readonly format: "rgba16float";
readonly sampled: true;
};
}>
screen
.
Targets<{ readonly hdr: { readonly format: "rgba16float"; readonly sampled: true; }; }>.fit(): boolean

Rebuilds all textures if the source size changed, destroying the old textures. Updates regions for the current resolution. Returns true after a rebuild so callers can update bind groups that hold old textures. Call before recording passes that use the targets. Throws after destroy.

fit
();
// the scene's pass with sceneTime.writes(), the output's with outputTime.writes()
const gpu: profiler.Profiler
gpu
.
Profiler.capture(frame: frames.Frame): void

Resolves the frame's timestamps and maps them after the submit. Call it after the timed passes. A second call in the same frame records nothing.

capture
(
frame: frames.Frame
frame
);
});
  • The budget applies to the timing you pass: gpu.frame for the frame’s GPU time, or one timer, such as a ray march’s.
  • A function as budget takes the display’s frame interval in milliseconds. Three quarters of it allows 12.5 ms at 60 Hz and 6.25 ms at 120 Hz.
  • Time that doesn’t scale with pixels, such as a final pass at full size, makes a step down too small, and the next median steps again.
  • A scale that ran over budget stays out of reach of steps up for 10 seconds. Running over again within twice that doubles it, up to 40 seconds.
  • Setting scale overrides it, as a detail slider does, and the next update measures from there. Skip update to hold the scale.
  • Without timestamp-query, no samples arrive and the scale stays at the top of range. Set scale after create to a scale a slow device can draw.

The GPU debug panel times every pass and dispatch without changing the program, but skips passes that write their own timestamps, like the profiler’s. TypeGPU’s withPerformanceCallback times one pipeline and reports only its last run in an encoder.

  • A pass recorded without its timer’s writes() that frame isn’t read back, and nothing warns.
  • Omitting the frame’s first or last pass from timing understates frame, without a warning.
  • Shares count only timed passes. An untimed pass is shared out to the timed passes running beside it, or left in the gaps.
  • Dawn on Metal writes no timestamps for an empty pass, so an empty marker pass can’t open the frame’s timing.
  • A median of 30 frames trails by half a second at 60 Hz and hides spikes; onSample sees them.
  • A GPU that slows its clock under light load, as phones and Apple GPUs do, reports longer times at a low scale, so a resolution controller can settle below the scale the device could draw.