GPU profiling
Recording Shade's GPU work to a .sgpt capture - the GPUProfileSession API, the four detail levels and what they cost, reading a capture back, and the lower-level GPUTimer family.
Shade can record what it asked the GPU to do over a run of frames - pass timings, the frame graph’s shape, dispatch and draw counts - into a self-contained .sgpt file. The integration is one nullable field: renderer.profile_session. The engine never constructs a session, and nothing in the engine imports the profiler, so an application that never mentions it does not carry any of the recording code, the write stream or the format codecs. There is no build flag and no dead branch to keep honest.
This is the deep-dive tool. For frame-time numbers you look at every run, use the performance metrics and FPS overlay instead.
Recording a capture
import { GPUProfileSession } from "@woosh/meep-engine/src/shade/device/timing/profile/GPUProfileSession.js";
import { GPUProfileLevel } from "@woosh/meep-engine/src/shade/device/timing/profile/GPUProfileLevel.js";
const renderer = engine.graphics.renderer; // null before Engine.start(), and after stop()
const session = new GPUProfileSession({
level: GPUProfileLevel.STRUCTURE,
frame_limit: 600,
note: "boss fight, 4k, DoF on",
});
renderer.profile_session = session;
session.start();
// ... let frames run ...
const bytes = session.stop(); // ArrayBuffer holding the whole .sgpt file
renderer.profile_session = null;
renderer is GraphicsEngine’s escape hatch onto Shade’s Renderer, and it is null until the engine has started - attach the session after Engine.start(), not during setup.
Assigning the field is not enough on its own: begin_frame returns null while the session is not running, so nothing is recorded until start(). Detaching is the reverse - set the field back to null when you are done, or the next frame opens a recorder for a session that has stopped. A frame whose GPU timings land after stop() is dropped rather than throwing, which matters because a frame is committed when its timestamps have been read back, not when it was submitted.
Detail levels
GPUProfileLevel is cumulative: each level includes everything below it.
| Level | Value | Adds | Cost |
|---|---|---|---|
TIMING | 0 | GPU pass spans and frame boundaries | a few kilobytes a frame; runs for many minutes unnoticed |
STRUCTURE | 1 | the frame graph: passes, resource nodes, edges, scopes, cull decisions, declared resource sizes | nearly free on top of TIMING - the topology repeats frame to frame and is stored once |
WORKLOAD | 2 | dispatch and draw counts, pipeline identities, workgroup sizes, attachment state | the level at which “is this dispatch the right size” becomes answerable |
VERBOSE | 3 | bind-group contents, resolved indirect counts, per-pass CPU timing | roughly an order of magnitude more data than WORKLOAD. Meant for one repro with a frame_limit set, not for leaving running |
gpu_profile_level_name(level) from the same module turns a value back into its name for logging.
STRUCTURE being nearly free is a consequence of content-hashing: an identical frame graph is written once and every later frame refers to it by id. session.topology_count is worth watching - if it climbs with the frame count, the graph is changing shape every frame and any size estimate for a long capture stops holding.
Session API
| Member | Notes |
|---|---|
new GPUProfileSession({ level, frame_limit, note }) | level defaults to TIMING, frame_limit to Infinity, note to "". frame_limit must be a positive integer or Infinity |
start() | begins recording; writes meta into the stream. A stopped session cannot be restarted - construct a new one |
stop(): ArrayBuffer | ends the recording and returns the capture. Idempotent: calling it twice returns the same bytes, and a session that hit its frame_limit has already stopped itself |
snapshot(): ArrayBuffer | the capture as it stands, without ending the session. Reads back as a capture that was cut short, because that is what it is. Recording continues |
done: Promise<ArrayBuffer> | resolves with the capture however the session ended - stop() or the frame limit running out |
meta: GPUProfileMeta | note, engine_version, adapter_vendor / _architecture / _device / _description, features. Populate before start(); it is written at that point and never read again |
byte_budget | see below. Default 512 MiB |
level, is_running, frames_recorded, bytes_written, topology_count | read-only getters |
onBytesWritten: Signal<number> | fires per frame with the running byte total |
onComplete: Signal<ArrayBuffer> | fires once, with the finished capture, however the session ended |
begin_frame(frame_index, cpu_begin_ms), end_frame(recorder, cpu_submit_ms), record_frame(frame) | the renderer’s side of the contract. You do not call these |
byte_budget is a warning, not a cap
Recording is an open-ended forward stream. There is no ring buffer, frame_limit defaults to Infinity, and everything captured is kept - so a per-frame cost is also a per-second one. At byte_budget the session logs one console.warn naming the size, the frame count, the level and the average bytes per frame, then keeps recording. Silently truncating a capture is worse than a large one; the warning exists so an application finds out before the tab dies rather than after.
Bound a long or VERBOSE capture with frame_limit, and use session.done to pick it up when it ends on its own:
const session = new GPUProfileSession({ level: GPUProfileLevel.VERBOSE, frame_limit: 30 });
renderer.profile_session = session;
session.start();
const bytes = await session.done; // resolves when the 30th frame lands
renderer.profile_session = null;
Reading a capture back
import { sgpt_read_capture } from "@woosh/meep-engine/src/shade/device/timing/profile/sgpt_read_capture.js";
const capture = sgpt_read_capture(bytes);
for (const frame of capture.frames) {
const topology = capture.topology_of(frame); // null below STRUCTURE
for (const span of frame.spans) {
console.log(span.label, span.kind, span.duration_ns);
}
}
sgpt_read_capture reads by scanning records, never by consulting the directory, and never throws on damage. A capture comes back whole, carrying whatever decoded plus a list of what did not. That is deliberate: a capture of a crash, a device loss or a hang ends mid-record by definition, and the records before the cut are the evidence.
GPUProfileCapture member | Meaning |
|---|---|
header | SGPTHeader, or null when the bytes are not a readable .sgpt |
meta | the GPUProfileMeta written at start() |
frames | GPUProfileFrame[] - frame_index, cpu_begin_ms, cpu_submit_ms, gpu_epoch_ns, spans, dropped_pass_count, gpu_duration_ns |
topologies | GPUProfileTopology[], indexed by a frame’s topology_id. Empty below STRUCTURE, and far shorter than frames |
topology_of(frame) | the structure behind one frame, or null |
unknown_record_counts | Map<number, number> of record types this build does not understand, and how many of each. Skipping them is correct; staying quiet about it is not |
defects | SGPTDefect[] - see below |
is_complete | whether the writer reached stop() |
A span is { label, kind, t_begin_ns, duration_ns, query_set_id, graph_pass_id, work }, where kind is 'compute' or 'render' and work (present from WORKLOAD up) carries dispatch_count, indirect_dispatch_count, draw_count, indirect_draw_count, vertex_count, index_count, instance_count, pipeline and workgroup_size.
SGPT_DEFECT names what a defect is, and TRUNCATED in particular is the expected outcome of a session that never stopped:
| Defect | Meaning |
|---|---|
HEADER | not an .sgpt, or a header too short or too new to read |
CHECKSUM | a checksum did not match the bytes it covers |
FRAMING | a record’s declared length runs past the end of the data |
RESYNC | bytes between records that are not a record; recovered by scanning |
PAYLOAD | a record’s payload does not decode as its type says it should |
DIRECTORY | the directory disagrees with the records actually present |
TRUNCATED | the stream ends inside a record |
Do not treat defects as an error channel to check before trusting the rest, and do not treat a missing CLOSED flag or a zero directory_offset as damage - both are the normal state of an interrupted session, and such a file is fully readable.
The .sgpt container
Shade GPU Profile Trace, format version 2. The normative spec ships in the package at src/shade/device/timing/profile/SGPT_FORMAT.md, written for somebody outside the project who wants to read or write one without reading the engine’s source.
- 32-byte header at offset 0: magic
0x54504753(SGPT),format_version,min_reader_version,flags,header_checksum,directory_offset,directory_byte_length. Every integer is little-endian and unsigned. Records start at offset 32. - The header checksum covers bytes
[0, 8)only. Everything fromflagsonward is written twice - zeroed when the header is laid down, patched atstop()- and a checksum over those fields would read as corrupt for exactly the capture that never stopped. What stays under it is what a reader must trust before it can do anything: is this an.sgpt, and can this build read it. - Every record opens with the sync word
SREC, declares its type and length, and is checksummed, so a torn stream can be re-entered by scanning. Symbol blocks travel inside the record that first needs them rather than in a table at the tail, so a truncated capture still resolves the names it references. - An unrecognised record type is skipped by length. That is what lets a newer writer add types without a version bump, and an older reader open the result and say honestly what it could not show.
SGPT_MIN_READER_VERSIONis still 1 at format version 2 and is raised only when a change would make an older reader wrong rather than incomplete. - Record types:
META,SYMS,TOPO,FRAM,CNTR,DIRE. The directory (DIRE, written atstop()) is an index over the stream, never a prerequisite for reading it.
A capture holds pass timings, the frame graph’s structure and dependencies, declared resource sizes, and dispatch and draw counts. It deliberately holds no buffer or texture contents and no shader source - nothing that scales with the size of the resources it describes. That exclusion is what keeps a frame record in the kilobytes and makes an uncapped recording practical, and it is also what makes a capture safe to send to somebody else.
No viewer ships
meep 3.21.0 ships the format, the writer and the reader. It does not ship a .sgpt viewer - nothing in the editor or anywhere else opens one. What you get from sgpt_read_capture is a plain object graph; charting it, diffing two captures or building an inspector is yours to write. The format spec is published in the package precisely so that tool does not have to live here.
Without timestamp-query
timestamp-query is an optional WebGPU feature that Shade takes when the adapter offers it and never requires. A browser may withhold it on hardware that is otherwise fine.
When it is absent the timers go quiet rather than failing: no query set is allocated and every timing method returns early. A session still records structure and workload - the frame graph, the dispatch and draw counts - but the spans carry no durations. Check device.features.has('timestamp-query') before promising a user a timing number.
Lower-level timers
Under src/shade/device/timing/, usable without a session at all:
| Export | Module | Purpose |
|---|---|---|
GPUTimer | GPUTimer.js | new GPUTimer(device, name) - one named begin/end pair. resolve(encoder), update(encoder), getResults(), data, stats, buildLogTextAverage() |
GPUTimerArray | GPUTimerArray.js | new GPUTimerArray(device, size = 1024) - a pool of query slots for many passes in one frame |
GPUTimerData | GPUTimerData.js | one decoded measurement |
GPUTimerStats | GPUTimerStats.js | a 64-record rolling history with average and last |
format_nanosecond_time(ns) | format_nanosecond_time.js | nanoseconds to a legible string |
ShadeGPUContextProfiler | ShadeGPUContextProfiler.js | binds a command context’s timer array to a frame recorder. Constructed only by enable_profiling; an unprofiled context holds one null field and allocates no query set |
These are the mechanism GPUProfileSession is built on. Reach for them when you want one number for one pass in your own code; reach for a session when you want the whole frame, over many frames, in a file.
See also
- Rendering overview - the frame Shade draws, and the device floor
- Frame features - the
renderer.feature_*switches whose cost a capture measures - Render extensions - your own passes, which show up in a capture like any other
- Diagnostics - CPU-side frame metrics and the FPS overlay