Platform

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.

LevelValueAddsCost
TIMING0GPU pass spans and frame boundariesa few kilobytes a frame; runs for many minutes unnoticed
STRUCTURE1the frame graph: passes, resource nodes, edges, scopes, cull decisions, declared resource sizesnearly free on top of TIMING - the topology repeats frame to frame and is stored once
WORKLOAD2dispatch and draw counts, pipeline identities, workgroup sizes, attachment statethe level at which “is this dispatch the right size” becomes answerable
VERBOSE3bind-group contents, resolved indirect counts, per-pass CPU timingroughly 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

MemberNotes
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(): ArrayBufferends 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(): ArrayBufferthe 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: GPUProfileMetanote, engine_version, adapter_vendor / _architecture / _device / _description, features. Populate before start(); it is written at that point and never read again
byte_budgetsee below. Default 512 MiB
level, is_running, frames_recorded, bytes_written, topology_countread-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.

sgpt captureheader - magic, version, flagsmeta - level, note, adapterframesspans - one per GPU passwork - draw and dispatch countstopologies - the frame graphdefects - what could not be read
GPUProfileCapture memberMeaning
headerSGPTHeader, or null when the bytes are not a readable .sgpt
metathe GPUProfileMeta written at start()
framesGPUProfileFrame[] - frame_index, cpu_begin_ms, cpu_submit_ms, gpu_epoch_ns, spans, dropped_pass_count, gpu_duration_ns
topologiesGPUProfileTopology[], 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_countsMap<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
defectsSGPTDefect[] - see below
is_completewhether 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:

DefectMeaning
HEADERnot an .sgpt, or a header too short or too new to read
CHECKSUMa checksum did not match the bytes it covers
FRAMINGa record’s declared length runs past the end of the data
RESYNCbytes between records that are not a record; recovered by scanning
PAYLOADa record’s payload does not decode as its type says it should
DIRECTORYthe directory disagrees with the records actually present
TRUNCATEDthe 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 from flags onward is written twice - zeroed when the header is laid down, patched at stop() - 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_VERSION is 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 at stop()) 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:

ExportModulePurpose
GPUTimerGPUTimer.jsnew GPUTimer(device, name) - one named begin/end pair. resolve(encoder), update(encoder), getResults(), data, stats, buildLogTextAverage()
GPUTimerArrayGPUTimerArray.jsnew GPUTimerArray(device, size = 1024) - a pool of query slots for many passes in one frame
GPUTimerDataGPUTimerData.jsone decoded measurement
GPUTimerStatsGPUTimerStats.jsa 64-record rolling history with average and last
format_nanosecond_time(ns)format_nanosecond_time.jsnanoseconds to a legible string
ShadeGPUContextProfilerShadeGPUContextProfiler.jsbinds 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