Diagnostics and profiler internals

CNA snapshot 009d40f5  ·  Development › Module internals  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the TARGET sources and tests; no build or test was executed, and no CI lane builds STATS or FULL. Sizes quoted are computed from declared capacities, not measured. Renderer-specific diagnostics sources do not exist yet, and consumer UIs (the Inspector's browser page) remain unverified.

modules/diagnostics/ is a bounded, process-wide measurement layer: compile-time instrumentation, counters, gauges, frame samples, CPU zones, markers, resource metadata, bounded recording and a pull-only provider. It does not own Game, render a profiler UI, start a thread or open a socket. This page is for maintainers who change the instrumentation, the frame path, the ring buffers or the trace formats, and for anyone who must tell “the code ran”, “the work was counted” and “a trace captured it” apart. The user-level guide, with the macro table, the built-in metric names and the limits table, is Diagnostics; this page does not repeat those tables but explains the machinery behind them.

Three build modes, then a runtime ceiling

The root CMakeLists.txt defines the cache variable CNA_DIAGNOSTICS, default OFF. It is upper-cased, anything other than OFF, STATS or FULL is a FATAL_ERROR, and the value is mapped to CNA_DIAGNOSTICS_LEVEL 0, 1 or 2. modules/CMakeLists.txt then puts CNA_DIAGNOSTICS_LEVEL=${CNA_DIAGNOSTICS_LEVEL} on the cna_build_config interface target, which cna_add_module links PUBLIC into every module and which reaches every consumer through the CNA umbrella. The level is therefore a build-configuration fact, not a per-target switch. Diagnostics.hpp defaults a missing definition to 0 and turns a value outside 0–2 into #error.

The level must be identical in every translation unit for a reason stronger than macro consistency: it changes class layout. GraphicsResource.hpp declares the member CNA::Diagnostics::ResourceHandle diagnosticResource_ and the method UpdateDiagnosticResourceEXT only when the level is at least 1. A consumer compiled at a different level from the graphics library sees a different GraphicsResource, which is an ODR violation, not a missing feature.

What each level removes is decided in two places:

  • Macros. Instrumentation.hpp defines the counter, gauge, frame-counter and frame-scope macros at level 1 and above, and the zone and event macros only at level 2. A disabled macro expands to CNA_DIAGNOSTICS_DETAIL_CHECK, that is do { (void) sizeof((args)); } while (false): the arguments are type-checked but never evaluated, so an argument with a side effect silently disappears in an OFF build. The disabled CNA_DIAGNOSTICS_FRAME_SCOPE() is an empty statement. Enabled macros declare a function-local static const handle whose name is made unique with __COUNTER__ (or __LINE__), so registration happens once per call site under C++ thread-safe static initialisation.
  • Implementation. In Diagnostics.cpp the whole State, every registry and the frame logic sit under #if CNA_DIAGNOSTICS_LEVEL >= 1; the name registry, thread contexts and event history sit under >= 2. At level 0 the handle constructors are empty, GetRuntimeMode returns Mode::Off, and GetProvider().CaptureSnapshot() fills only buildMode and runtimeMode. The library is still linked; it simply holds no state.

At run time SetRuntimeMode refuses any mode above the constexpr GetBuildMode(). When it accepts, it exchanges the atomic mode and, if the value actually changed, increments modeGeneration. Every change bumps the generation, not only a change out of Off: a Full→Stats→Full cycle also invalidates open zones (see zones). A runtime Off in a STATS or FULL build keeps all storage allocated; it makes metric updates, resource registration, frame begins and event emission return early. It cannot create FULL events in a binary compiled at a lower level.

Physically, modules/diagnostics/CMakeLists.txt globs src/*.cpp into the static cna_diagnostics (CNA::Diagnostics), which links only cna_build_config: standard library, no UI, renderer or platform dependency. It is linked PUBLIC by the four modules that publish into it or read it: cna_audio (voice metrics), cna_graphics_core (draw metrics and resource metadata), cna_runtime (the frame) and cna_inspector (the consumer), and it is listed in the CNA umbrella's runtime parts. A benchmark executable is added only when CNA_BUILD_BENCHMARKS is on. See the module index for the module's place in the tree.

One measured Game frame

Game::Tick()                       (native loop, RunOneFrame; Emscripten: EmscriptenMainLoopCallback)
  FrameScope ctor -> TryBeginFrame      [STATS+]  frameMutex; nested begin -> malformedFrames++
  ZoneScope "Game/Tick" (Core)          [FULL]
    fixed-step sleep / yield, PollEvents
    { ZoneScope "Game/Update" } Update  -> Runtime/UpdateCount   (once per fixed step)
    { ZoneScope "Game/Draw"   } Draw + EndDraw (Present) -> Runtime/DrawCount
  ~ZoneScope "Game/Tick"               zone event pushed to this thread's ring
  ~FrameScope -> FinishFrame(false)
     thread_local completingFrame guard      re-entry -> refused
     lock frameCompletionMutex               (held to the end)
       frameMutex: frame still active?       no -> return
       CollectDiagnosticsSources             copy shared_ptrs under sourceMutex, Collect() unlocked
       frameMutex + metricMutex:             duration = now - start; frameActive = false
          every FrameCounter: exchange(0) -> lastFrameValue, first 64 into the 240-slot frame ring
       [FULL] "Frame" event (correlation = frame number), CollectEvents()
consumers, any thread: GetProvider().CaptureSnapshot() / ReadEvents(after, max)

Game.cpp declares CNA_DIAGNOSTICS_FRAME_SCOPE() first and the Game/Tick zone second in both Game::Tick and Game::EmscriptenMainLoopCallback. Destruction runs in reverse, so the Game/Tick zone is already in the thread ring when frame completion drains it. RunOneFrame reaches the same scopes through Tick; a suspended mobile game in WaitWhileSuspended publishes no frames at all.

Two timing consequences follow from where the scope sits, and both matter when a number looks wrong:

  • On native builds the frame covers the fixed-step pacing wait, PollEvents and the Present inside EndDraw. A fixed-step game's FrameSample::durationNs therefore sits near the target step even when the work is small; the Game/Update and Game/Draw zones isolate the work.
  • On Emscripten, Game::RunLoop calls CNA_WaitForAnimationFrame outside the callback, so the frame measures the callback's work only. framesPerSecond, computed as 109/durationNs of that single frame (pinned by FrameHistoryIsBoundedOrderedAndReportsExactFps), is a work rate there, not the display rate. This is read from the loop structure; it was not observed in a browser.

TryBeginFrame returns false in runtime Off; otherwise, under frameMutex, it refuses a second begin while a frame is active (counted in malformedFrameCount), records the start timestamp and increments currentFrameNumber. Events therefore carry the number of the frame in which they were emitted, and the frame number advances at begin, not at end. The measured duration ends after the sources have been collected, so a slow source lengthens the frame it reports on.

The frame is one process-wide state, not per Game or per thread. EndFrame passes reportMalformed = true, while the FrameScope destructor passes false. Read together, this means application code that adds its own BeginFrame/EndFrame inside Update gets its begin counted as malformed, and its EndFrame completes Game's frame early; Game's own scope then finds no active frame and ends silently. No test covers this interaction; it follows from reading FinishFrame.

The engine's other publishing sites are ordinary macro or handle calls, all renderer-neutral: the draw entry points of GraphicsDevice.cpp call RecordGraphicsDraw after the renderer call returns, so a rejected or throwing draw is not counted, and the same file counts effect and render-target changes; TextureCollection.cpp counts texture-slot changes; SpriteBatch.cpp counts sprite submissions; both mixer engines, CnaMixer/MixerEngine.cpp and Sdl3Mixer/MixerEngine.cpp, maintain the voice gauge and creation counter. The names are listed in What the engine already measures. For the surrounding frame, see the one-frame source trace.

Ownership and concurrency of records

GetState() allocates one State with new on first use and never deletes it: thread-local context destructors can publish their final events after ordinary namespace statics would already have been destroyed. Its fixed arrays dominate its size; computed from the declared capacities (not measured), that is roughly 0.3 MiB in STATS, mostly the 240-frame ring with room for 64 metric values per frame, and about 2.5 MiB in FULL, where the 32,768-entry event history of 72-byte records is added. Each producer thread in FULL adds a context of roughly 74 KiB. Snapshot::profilerOwnedBytes reports exactly sizeof(State) plus one sizeof(ThreadContext) per live context, so it is a lower bound that ignores heap held by names, maps and resource records.

Lock and thread map

LockGuardsNesting observed in the source
metricMutexmetric slots' names and signatures, the name-to-id map, the metric countinnermost; taken inside frameMutex by frame completion and by the snapshot's frame copy
frameCompletionMutexserialises whole frame completionsoutermost; held while sources run and, in FULL, while threadMutex/historyMutex are taken to drain
frameMutexframeActive, start timestamp, the 240-slot frame ringinside frameCompletionMutex; outside metricMutex
sourceMutexthe source-id → shared_ptr mapnever held while a source runs
resourceMutexresource records and the saturating byte totalalone; resource events are emitted after it is released
nameMutex (FULL)event-name table, capped at 65,536 entries and 16 MiBalone
threadMutex (FULL)the list of live thread contextsoutside historyMutex (drain)
historyMutex (FULL)the 32,768-event process ring, sequence counter, overwrite countinnermost of the event path

Metric updates take no lock: a handle stores a slot id, and AddMetric/SetMetric do one relaxed load of the runtime mode and one relaxed atomic on the slot. Registration takes metricMutex, keys the slot by the UTF-8-sanitised name, and returns the existing id only if kind, unit and accuracy match; a conflicting signature, an empty or oversized name, a registration beyond the 512-metric cap or an exception all yield id 0, an inert handle. Ids are slot index + 1 and are never reused. See the thread and callback map for how these locks sit beside the rest of CNA's threads.

In FULL, each producer thread lazily creates a ThreadContext on its first event or zone and registers it under threadMutex. The context is a single-producer/single-consumer ring of 1,024 events: the owning thread writes a slot only while writeIndex - readIndex < 1024 and publishes with a release store of publishedWriteIndex; any thread that holds threadMutex may drain it, copying events into the process history under historyMutex and advancing readIndex with a release store. The zone stack (64 entries), the correlation counter and the context's mode generation belong to the producer alone. Thread ids come from an atomic counter starting at 1 and are never reused; exhausting the 64-bit space would count a drop rather than wrap. A thread_local holder drains and deletes the context when its thread exits.

Draining happens only at frame completion, in CaptureSnapshot and ReadEvents, in StartRecording/StopRecording and at thread exit. A worker thread that emits more than 1,024 events between two of those points drops the newest ones (producerEventsDropped), while the process history overwrites its oldest entries (eventHistoryOverwrites). The drainer is often not the producer: the Inspector agent's worker thread calls the provider from Agent.cpp, so a FULL snapshot there drains the game thread's ring, and a long history copy under historyMutex can delay the game thread's frame completion. That is why ReadEvents starts at the first unseen sequence instead of scanning the ring.

Sources are owned by the registry: RegisterSource stores a shared_ptr<IDiagnosticsSource> under a fresh id, and the move-only SourceRegistration erases it on Reset or destruction. Completion copies the owners first, so a source may unregister itself from Collect without deadlock and stays alive while it runs. The corollary is that Reset does not wait: a source can be called once more, on the frame-ending thread, after its registration was reset elsewhere. A throwing Collect increments sourceCollectionFailures. No production code registers a source at the snapshot, so this contract is exercised only by tests.

ResourceHandle owns a metadata id, not the resource. Ids are monotonically increasing and never reused; registration returns 0 in runtime Off and on exhaustion; Update replaces the record in place; move construction and assignment transfer the one live id; the byte total saturates at UINT64_MAX and is recomputed from the records once an update or removal happens while saturated. GraphicsResource.cpp registers every GraphicsResource at construction with an empty descriptor (ResourceKind::Unknown, 0 bytes); textures, render targets and buffers then call UpdateDiagnosticResourceEXT with typed metadata built by DiagnosticResource.hpp (estimated payload for textures, exact logical capacity for buffers). State objects and effects therefore appear as Unknown zero-byte records; an assignment that shares a resource identity drops the alias's registration so one identity has one record; Dispose resets the handle. A resource created during a runtime Off window is never registered, even after the mode returns. None of this is a GPU allocation census: compare it with real GraphicsResource and backend lifetime, as described in the ownership master map.

CaptureSnapshot returns owned copies but is not one atomic transaction: in FULL it first drains all rings (a side effect), then copies metrics, frames, resources and counters under separate locks, one after another. Two sections of one snapshot can reflect different instants.

⚠

Gauges maintained by deltas drift across a runtime Off window. AddMetric and SetMetric discard updates while the runtime mode is Off, and nothing resynchronises a gauge afterwards. Audio/AllocatedVoices is kept with +1/-1 deltas, so voices created or destroyed while diagnostics were lowered to Off leave it wrong (possibly negative) until mixer-engine shutdown sets it back to 0. OffStatsAndFullTransitionsPreserveMetricSemantics covers a cumulative counter only. Derived from reading the source; not executed.

Zones, recording and export failures

BeginZone requires runtime Full and a registered name (id ≠ 0). It resets the thread's zone stack if the global mode generation has moved on, refuses a 65th nested zone (counted as malformed, inactive token), and pushes an entry whose correlation id is derived from the thread id and a per-thread counter. Correlation ids are unique within a thread; a consumer that joins parent and child should key on the pair of thread id and correlation id. The returned ZoneToken carries correlation, thread id, generation and depth. ZoneScope is the RAII route and the one the macros use. EndZone distinguishes these cases:

SituationResult
inactive token, or runtime mode not Fullreturns true; nothing recorded
token from an older mode generationreturns true; the zone is silently discarded and the stack reset, with no malformed count
other thread, empty stack, or correlation not on the stack (e.g. a double end)returns false; malformedZoneCount++ and a Diagnostics/MalformedZone event
token found on the stack, but not as the top entry at its recorded depthreturns false; the stack is unwound: inner zones are emitted as Zone events ending now, the token's own zone as EventKind::Malformed, and the count is incremented
token is the top entry at its recorded depthreturns true; a Zone event with start timestamp and duration

Zone events are appended when the zone ends, so in sequence order an inner zone precedes its parent. Markers from MarkEvent, the Frame event (correlation = frame number) and the Diagnostics/ResourceCreated/ResourceDestroyed events (value = resource id) go through EmitRawEvent, which adds the thread id and current frame number and counts any exception as a producer drop.

StartRecording(maximumEvents) returns an inactive session unless the runtime mode is Full and the request is non-zero. It is a cursor, not a writer: it drains, stores the last assigned sequence, clamps the capacity to 32,768 and remembers the producer-drop count. Sessions are move-only and independent; two may be open at once. StopRecording drains again, counts the sequences that the history already overwrote since the start, keeps only the newest maximumEvents of the rest (the older excess is also counted), adds the process-wide producer drops since the start, and copies every registered event name into the trace. A mode change during a recording does not end it; events are simply not produced while the mode is below Full (RuntimeModesBeforeAndDuringRecordingFailSafely).

The CNATRACE binary layout is in the user guide. The details that matter when changing it: ReadBinary rejects bad magic, a version other than Trace::FormatVersion (1), truncation, name or event counts above the live bounds, oversized names and out-of-range EventKind/Category bytes, each with std::runtime_error; it re-sanitises every name to valid UTF-8, because a trace file need not come from this process. WriteChromeTrace streams without building a document; it writes a constant "cat":"cna" and "pid":1, uses CNA's diagnostic thread id (not the OS id) as tid, exports zones and frames as "X" and everything else as thread-scoped "i", puts value and frame in args, and truncates ts and dur to whole microseconds. The Category and the correlation ids are not exported to JSON (the viewer nests by time on one tid), and a sub-microsecond zone exports with dur 0 although the binary trace keeps nanoseconds. Both writers return the stream state rather than throwing.

The failure model is deliberately quiet. Handles, BeginZone, EndZone, MarkEvent, BeginFrame/EndFrame, RegisterSource, StartRecording and GetProvider are noexcept and return an invalid id, an inactive token or an empty registration on exhaustion or allocation failure. The provider calls, StopRecording and the Trace members allocate and are not noexcept; ReadBinary throws by design. A missing metric is therefore not proof of a missing code path, and a trace with missing events is not proof that the operations never happened.

A human investigation recipe

  1. Record the exact CNA build and configure -DCNA_DIAGNOSTICS=STATS for frame and counter questions or FULL for zone and event ordering. The level is a compile definition, so switching needs a reconfigure and a rebuild; a runtime switch cannot upgrade an OFF binary.
  2. Start from the existing Game/Tick, Game/Update and Game/Draw zones. Remember that Game/Tick includes pacing waits and event polling, and that on the web the frame excludes the wait for the next animation frame. For a renderer question, place a name at the neutral-to-native boundary and compare the same build on a second renderer. Keep every instrumentation argument free of side effects.
  3. Take GetProvider().CaptureSnapshot() for metrics, the recent frames and resource metadata; consume FULL events incrementally with ReadEvents(lastSequence, max). Read producerEventsDropped, eventHistoryOverwrites, eventsDroppedBeforeStart, malformedZoneCount, malformedFrameCount and sourceCollectionFailures alongside the value being diagnosed. Do not add a private BeginFrame/EndFrame pair inside a running Game.
  4. For a suspected leak, correlate resource metadata with explicit construct and dispose cycles and with native backend evidence; an Unknown zero-byte record is usually a state object or effect, and a diagnostics id is not proof of native bytes. For timing, the source contract says a GPU source may only publish results that are already available, so any future GPU metric may describe an earlier frame; Collect must never stall to make it current.
  5. To look at the same data from outside the process, use the Inspector, which reads this provider on its own thread; its transport is described in Inspector transport internals. General tactics are in the debugging cookbook.
  6. After adding an instrumented call site, build the OFF, STATS and FULL variants at least once, then run the diagnostics tests plus the changed subsystem's tests. OFF must still compile and must not evaluate the expression; FULL must keep zone nesting and the bounded history behaviour.

What the diagnostics corpus proves

Three test files cover the module and its producers. Most of each file is behind a level gate, so what a build compiles depends on CNA_DIAGNOSTICS:

FileOFFSTATSFULLTest group (focused target)
DiagnosticsTests.cpp21640diagnostics (CnaDiagnosticsTests)
GraphicsDiagnosticsTests.cpp066graphics (CnaGraphicsTests)
AudioDiagnosticsTests.cpp011audio (CnaAudioTests); also needs SOUND_ENABLED

The counts are static TEST definitions per level, counted from the preprocessor gates; none was executed for this page. What they pin, by suite:

  • DiagnosticsBuildModeTest (every level): the published build mode equals the compile definition, the provider interface version is 1, a mode above the build is refused, and DisabledMacrosDoNotEvaluateArguments (0 evaluations at OFF, 3 at STATS and FULL).
  • DiagnosticsMetricsTest, DiagnosticsFrameTest, DiagnosticsResourceTest, DiagnosticsSourceTest, DiagnosticsRuntimeModeTest (STATS+): counter versus gauge semantics and signature conflicts, frame-counter reset, malformed frame transitions, the bounded ordered frame history and its exact FPS, one stable resource id across update and move, byte-total saturation and recovery, snapshots concurrent with a registering thread, sources collected only at valid frame ends, ASourceEndingAFrameFromCollectIsRefusedNotDeadlocked, invalid UTF-8 names stored as U+FFFD.
  • DiagnosticsZonesTest, DiagnosticsEventsTest, DiagnosticsRecordingTest, DiagnosticsTraceTest (FULL): parent/child correlation, malformed manual ends, mode-generation invalidation (including a worker waiting across five mode changes), 256 sequential and 64 concurrent threads with distinct ids, context release at thread exit, per-thread overflow and history overwrite, cursor exactness without phantom gaps, bounded recordings that drop the oldest excess, move-only sessions, binary and Chrome round trips, JSON escaping, rejected malformed input, and 3,000 deterministically mutated binary traces, each of which must be rejected or read back into valid UTF-8.
  • Graphics: estimated texture bytes and unregistration, the byte-estimate formula and its saturation, one record per shared state identity, no transient registrations from SpriteBatch::Begin, exact draw, effect, texture-binding, render-target and sprite counters. Audio: the voice gauge and creation counter across two mixer tracks.

Registration: cmake/UnitTests.cmake globs every module's tests/ into CnaTests and groups sources by module. CnaDiagnosticsTests is a focused developer executable, EXCLUDE_FROM_ALL, linking only cna_diagnostics (plus the test build configuration and gtest_main); it is not a CTest registration. CTest discovers the same tests from CnaTests with gtest_discover_tests(... DISCOVERY_MODE PRE_TEST), which runs each discovered test in its own process. Running a focused executable directly runs them all in one process against the one global State, which is why the tests measure against captured baselines and restore the runtime mode with a RuntimeModeGuard; a new test must do the same rather than rely on case order. No CI workflow and no configure preset sets CNA_DIAGNOSTICS, so a CI build compiles only the two level-independent DiagnosticsTests and none of the graphics or audio diagnostics tests; see Tests and CI.

With CNA_BUILD_BENCHMARKS=ON, DiagnosticsBenchmark.cpp builds cna_diagnostics_benchmark. It drains the event history every 512 zones so that the 1,024-event ring never overflows, which is itself a reminder of the draining rule. CNA's recorded results are in docs/diagnostics-benchmark.md; they measure instrumentation primitives on one machine, not a Game frame or GPU work.

Not proven by this corpus: any renderer-specific source (none exists at the snapshot, and every renderer's future diagnostics source remains unverified), the behaviour of a consumer UI such as the Inspector's browser page, and mixed-level builds, which are unsupported rather than tested.

Curated source route

  1. CMakeLists.txt (the CNA_DIAGNOSTICS option), modules/CMakeLists.txt (cna_build_config) and Instrumentation.hpp: separate compile-time absence from runtime mode before interpreting a missing metric or event.
  2. Game.cpp (Tick, EmscriptenMainLoopCallback, RunLoop) and Diagnostics.cpp (TryBeginFrame, FinishFrame, CollectDiagnosticsSources): follow one frame through source collection and publication.
  3. Diagnostics.hpp for the handles and the provider, then State, ThreadContext, DrainContextLocked and ProcessProvider in Diagnostics.cpp: identify which ids and owners live for the process, a registration, a resource or one thread, and under which lock.
  4. GraphicsResource.cpp and DiagnosticResource.hpp: how engine objects become resource records and why the level changes class layout.
  5. DiagnosticsTests.cpp: check the capacity and failure contracts before changing ring sizes, trace encoding or lock order, and compare each test's #if CNA_DIAGNOSTICS_LEVEL gate with the build being run.
  6. docs/diagnostics.md: CNA's own design note, including the rules it sets for an Inspector-style consumer.

Related: the Maintainer Handbook for task-oriented change routes and test architecture for how groups and focused targets are assembled.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.