Diagnostics

CNA snapshot 009d40f5  ·  new since alpha.1  ·  opt-in: CNA_DIAGNOSTICS=OFF|STATS|FULL

ℹ

New in the development snapshot. Diagnostics does not exist in the alpha.1 tag. It is opt-in (CNA_DIAGNOSTICS, default OFF), has no XNA counterpart and no C ABI routes, and no CI workflow enables it. Everything on this page is read from the source at snapshot 009d40f5 and its tests; the timing figures quoted in Overhead are CNA’s own, measured on one machine, and were not reproduced by us.

What Diagnostics is

Diagnostics (modules/diagnostics, namespace CNA::Diagnostics) is a renderer-independent, in-process observation layer: counters, gauges, per-frame counters, a rolling frame history, resource metadata, and — in FULL mode — CPU timing zones, markers, an event history and bounded recording with trace export. It depends only on the C++ standard library. It owns no thread, no socket and no renderer dependency: everything it does happens on the calling thread, at a macro call site or at a frame boundary.

Diagnostics isDiagnostics is not
A compile-time-selectable metrics and profiling foundation the engine and your game both write to.A network service. Sending data out of the process is the job of the Inspector, which reads Diagnostics through a pull interface.
Always part of the CNA umbrella target (with OFF it links and does nothing).A GPU profiler. No GPU timings, input metrics or total process/driver memory are published to it at this snapshot.
Bounded: fixed-size rings, a metric-count cap and reader safety limits (see Limits).Part of the C ABI: the C API has no Diagnostics routes.

Modes and how to enable them

Select the maximum level at configure time with the cache option CNA_DIAGNOSTICS:

cmake -S . -B build -DCNA_DIAGNOSTICS=STATS     # OFF (default) | STATS | FULL

The value is case-normalized, and anything other than OFF, STATS or FULL is a configure-time FATAL_ERROR. CMake turns it into the compile definition CNA_DIAGNOSTICS_LEVEL (0, 1 or 2), which is a public compile definition of the CNA build configuration: the engine and your game are compiled with the same level as soon as you link CNA. If a translation unit sees no definition, the header defaults it to 0, and a value outside 0–2 is a #error.

ModeCNA_DIAGNOSTICS_LEVELWhat is compiled in and retained
OFF (default)0Nothing. Every instrumentation macro compiles out, evaluates none of its arguments, and reaches no state object, thread or lock.
STATS1Frame statistics, counters, gauges, per-frame counters, the 240-frame history and resource metadata. The timing-zone and event macros still compile out.
FULL2Everything in STATS plus hierarchical CPU timing zones, instantaneous markers, the process event history and bounded recording with trace export.

Runtime switches

The build level is a ceiling. At run time you may lower the mode, never raise it above what the build compiled:

CallBehaviour
GetBuildMode()constexpr; the mode compiled into this build.
GetRuntimeMode()The active mode. It starts equal to the build mode; in an OFF build it always returns Mode::Off.
SetRuntimeMode(Mode)Returns false when the requested mode is above the build mode (in an OFF build only Off is accepted). A change bumps an internal generation counter so zones that were open across the change are invalidated rather than half-recorded.
ℹ

No environment variables. Diagnostics reads none, and neither does the Inspector agent (only the separate cna-inspector bridge process reads CNA_INSPECTOR_TOKEN). Mode selection is a build option plus SetRuntimeMode, nothing else.

Instrumentation macros

Include CNA/Diagnostics/Instrumentation.hpp. Names are string literals registered once through function-local statics at the macro site, so use stable, slash-separated names ("Physics/Contacts").

MacroAvailable inWhat it does
CNA_DIAGNOSTICS_COUNTER_ADD(name, delta)STATS, FULLAdds to a cumulative counter.
CNA_DIAGNOSTICS_GAUGE_SET(name, value)STATS, FULLReplaces a last-value gauge.
CNA_DIAGNOSTICS_GAUGE_ADD(name, delta)STATS, FULLAdds a signed delta to a gauge.
CNA_DIAGNOSTICS_FRAME_COUNTER_ADD(name, delta)STATS, FULLAdds to a counter that is exchanged to zero at each completed frame; the completed value is kept in the frame history.
CNA_DIAGNOSTICS_FRAME_SCOPE()STATS, FULLMarks one engine frame (a scope guard). Game already does this; you rarely need it.
CNA_PROFILE_SCOPE(name)FULLTimes the enclosing lexical scope as a zone in the Application category.
CNA_PROFILE_SCOPE_CATEGORY(name, category)FULLThe same with an explicit Category (Core, Update, Draw, Graphics, Audio, Content, Application, Gpu).
CNA_DIAGNOSTICS_EVENT(name), CNA_DIAGNOSTICS_EVENT_CATEGORY(name, category)FULLEmits an instantaneous marker.
⚠

Compiled-out arguments are type-checked but never evaluated. In a mode that lacks a macro, it expands to do { (void) sizeof((args)); } while(false). Your arguments must still compile, so a broken call site cannot hide in the default OFF build, but they are not executed: never put a side effect in a diagnostics macro argument. A unit test (DisabledMacrosDoNotEvaluateArguments) pins this.

Instead of the macros you can use the handle classes directly — CounterHandle, GaugeHandle, FrameCounterHandle (register a metric once, then Add/Set), NameHandle with BeginZone/EndZone/ZoneScope, and MarkEvent. Handles are cheap to keep; a registration beyond the metric cap, or a conflicting re-registration (different kind, unit or accuracy under an existing name), yields an inert handle that silently does nothing.

What the engine already measures

With STATS or FULL the engine itself is instrumented at a handful of common points, so a game gets useful numbers without writing any instrumentation. All names below are registered by engine code (not by any single renderer):

NameKindWhere it comes from
Runtime/UpdateCount, Runtime/DrawCountper-frame counterGame tick, incremented per update and per draw.
Graphics/DrawCalls, Graphics/IndexedDrawCalls, Graphics/NonIndexedDrawCallsper-frame counterThe common GraphicsDevice draw entry points.
Graphics/SubmittedPrimitives, Graphics/IndirectDrawCallsper-frame counterGraphicsDevice; indirect draws contribute a primitive count of 0.
Graphics/EffectChanges, Graphics/RenderTargetChangesper-frame counterGraphicsDevice effect and render-target changes.
Graphics/TextureBindingChangesper-frame counterThe texture collection.
Graphics/SpriteSubmissionsper-frame counterOne per SpriteBatch::Draw call, counted separately from the draw-call counters above.
Audio/AllocatedVoicesgaugeBoth mixer engines (CNA’s own mixer and the SDL3 mixer).
Audio/VoiceCreationscounterBoth mixer engines.
Game/Tick, Game/Update, Game/DrawFULL zonesGame (also the Emscripten main-loop callback).

Not published anywhere at this snapshot: GPU timings, input metrics and total process or driver memory. No production code registers an IDiagnosticsSource. (CNA’s changelog mentions Vulkan GPU timing query pools, but they are not wired to Diagnostics.)

The public API

Two public headers: CNA/Diagnostics/Instrumentation.hpp (the macros) and CNA/Diagnostics/Diagnostics.hpp (types and functions).

Type or functionPurpose
Mode, MetricUnit, MetricKind, Accuracy, Category, EventKind, ResourceKindThe enumerations. Units: Count, Bytes, Nanoseconds, PerSecond, BasisPoints. Kinds: Counter, Gauge, FrameCounter. Accuracy: Exact, Estimated, Unavailable.
IDiagnosticsProvider (InterfaceVersion = 1) and GetProvider()The pull interface: CaptureSnapshot(), ReadEvents(afterSequence, maximumEvents) and ResolveName(id). The Inspector agent is built on it.
SnapshotOwned, point-in-time copy: buildMode, runtimeMode, currentFrameNumber, malformed-zone and malformed-frame counts, producerEventsDropped, eventHistoryOverwrites, sourceCollectionFailures, profilerOwnedBytes, registeredResourceBytes, and the vectors metrics, recentFrames and resources.
MetricSample, FrameSampleOne metric value (id, name, value, kind, unit, accuracy) and one completed frame (frame number, start timestamp, duration in nanoseconds, frames per second, its frame-counter values).
EventRecord, EventBatchOne retained FULL event, and a batch with oldestAvailableSequence, newestAvailableSequence, eventsDroppedBeforeStart and producerEventsDropped so a consumer can detect loss.
ResourceDescriptor, ResourceRecord, ResourceHandleResource metadata registration (RAII) and its owned copy in a snapshot.
IDiagnosticsSource, RegisterSource, SourceRegistration, FrameStatisticsSinkOptional frame-boundary sources (see below).
Trace, RecordingSession, StartRecording, StopRecordingBounded recording and export (see below).

A frame counter shows up in Snapshot::metrics with the value of the last completed frame; the same counter, per frame, is in each FrameSample::metrics in recentFrames (the rolling history keeps the newest 240 frames, and copies at most 64 frame counters into each).

Resource metadata

The graphics layer registers metadata (never contents) for Texture2D, Texture3D, texture cubes, RenderTarget2D, render-target cubes, VertexBuffer and IndexBuffer, and unregisters it when the object is disposed or dies. Every other GraphicsResource (a state object, an effect and so on) also registers a zero-byte ResourceKind::Unknown record when it is constructed, so Snapshot::resources lists those too. Each record carries a stable id, a ResourceKind, a label, a format string, width, height, depth, mip count, byte size and an Accuracy for that byte size. The format is currently the numeric SurfaceFormat value. Snapshot::registeredResourceBytes is a mixed aggregate of exact and estimated sizes, not a measurement of GPU memory: nothing is read back from the GPU. Your own code can register a ResourceKind::Custom resource with ResourceHandle.

Optional sources

A renderer or subsystem may register an IDiagnosticsSource with RegisterSource. At each frame boundary (from EndFrame, never from a background thread) every registered source’s Collect(FrameStatisticsSink&) is called synchronously. The contract is strict: return promptly and publish only data that is already available; never wait for the GPU or a queue. A source that throws is counted in sourceCollectionFailures, and a source that tries to end a frame from inside Collect is refused and counted as a malformed frame. No production code registers a source at this snapshot; the interface exists so renderers can publish asynchronous GPU statistics later.

Recording and trace export (FULL)

StartRecording(maximumEvents) returns an active RecordingSession only when the build and the current runtime mode are FULL (and maximumEvents is non-zero); otherwise it returns an inactive session, and StopRecording on it returns an empty trace. The event count is clamped to the size of the process history (32,768) and StopRecording copies at most that many of the newest events.

Trace memberPurpose
WriteChromeTrace(std::ostream&)Streams Chrome Trace Event JSON without building a whole document in memory: an object {"traceEvents":[...], "displayTimeUnit":"ns", "cnaDroppedEvents":N} whose zones and frames are complete ("ph":"X") events with microsecond ts/dur and whose markers are instant ("ph":"i") events. The format opens in Chrome’s trace viewer and in Perfetto.
WriteBinary(std::ostream&) / ReadBinary(std::istream&)CNA’s compact, little-endian CNATRACE format: the 8 magic bytes CNATRACE, a 16-bit version (1), 16 reserved bits, a 32-bit name count, a 64-bit event count, a 64-bit dropped count, then the names and the events. The reader rejects a bad magic or version, truncation, invalid enumerators, more than 65,536 names, a name over 1 MiB, more than 16 MiB of names in total, or more than 32,768 events, and throws std::runtime_error.
GetEvents(), GetDroppedEventCount(), ResolveName(id), GetVersion()Read access to a recording (Trace::FormatVersion is 1). The dropped count combines producer, history and recording-capacity losses.

Limits

LimitValueWhat happens at the limit
Registered metrics512Later registrations return an inert handle.
Frame counters copied per historical frame64Extra frame counters are still live but are not kept in that frame’s history entry.
Frame history240 framesThe oldest frame is overwritten.
Zone nesting depth (per thread)64A deeper zone is not recorded and is counted in malformedZoneCount.
Per-thread event ring1,024 eventsA full ring drops new events and counts them (producerEventsDropped).
Process event history32,768 eventsThe oldest events are overwritten and counted (eventHistoryOverwrites).
Trace names65,536 names; 1 MiB per name; 16 MiB totalEnforced when names are registered and by the trace reader.

Overhead

What the code guarantees, independent of any benchmark:

  • OFF: macros compile out and evaluate nothing; no diagnostics state, thread or lock is reachable from them. CNA’s benchmark note reports that a symbol check of the objects for Game.cpp, GraphicsDevice.cpp, SpriteBatch.cpp and TextureCollection.cpp finds no CNA::Diagnostics reference.
  • STATS: a counter or gauge update is one relaxed atomic operation after a relaxed load of the runtime mode. Frame completion iterates only the registered metrics and calls registered sources synchronously. No mode creates a profiler thread or does periodic work.
  • FULL: a zone or marker writes into a thread-local ring; the first event on a thread allocates that thread’s context; merging into the process history happens at frame boundaries or on an explicit provider read.
ℹ

CNA’s recorded numbers — one machine, not reproduced by us. CNA’s own benchmark record (2026-09-17; AMD Ryzen 7 PRO 7840U, GCC 14.2, Release, pinned to one logical CPU, median of seven five-million-operation runs) reports the OFF build within noise of an empty loop, a STATS frame-counter update at about 1.6 ns, and a FULL completed scoped zone at about 64 ns. They measure the instrumentation primitives only, not a game. To measure your own hardware, configure once per mode with -DCNA_BUILD_BENCHMARKS=ON and run cna_diagnostics_benchmark.

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCNA_DIAGNOSTICS=STATS -DCNA_BUILD_BENCHMARKS=ON
cmake --build build --target cna_diagnostics_benchmark
build/modules/diagnostics/cna_diagnostics_benchmark

Repeat for OFF and FULL (a fresh build directory or a reconfigure each time, because the level is a compile-time definition), and compare medians of several runs.

Tests and CI

DiagnosticsTests.cpp holds 40 test definitions; the graphics module adds 6 (GraphicsDiagnosticsTests.cpp) and the audio module 1 (AudioDiagnosticsTests.cpp). They are part of the ordinary CnaTests glob, with a focused target CnaDiagnosticsTests; the counts are static TEST definitions, not pass results. No CI workflow configures CNA_DIAGNOSTICS above OFF, so the STATS and FULL paths are exercised only where a developer builds them.

End-to-end recipe

Every call in the following was checked to compile against the snapshot’s headers at all three levels (the second half needs to sit inside a function body); the CMake step is the option the build defines. Tutorial 155 walks through the same idea in a complete project. First configure, then instrument, then read.

cmake -S . -B build -DCNA_DIAGNOSTICS=FULL          # or STATS
#include "CNA/Diagnostics/Instrumentation.hpp"
#include <fstream>

void Simulate() {
    CNA_PROFILE_SCOPE_CATEGORY("Physics/Simulate", CNA::Diagnostics::Category::Update);  // FULL
    CNA_DIAGNOSTICS_FRAME_COUNTER_ADD("Physics/Contacts", 12);                            // STATS and FULL
    CNA_DIAGNOSTICS_GAUGE_SET("Physics/ActiveBodies", 340);
}

// After some frames have run:
auto snapshot = CNA::Diagnostics::GetProvider().CaptureSnapshot();   // metrics, recentFrames, resources
auto recording = CNA::Diagnostics::StartRecording(4096);             // FULL only; otherwise inactive
/* ... run frames ... */
auto trace = CNA::Diagnostics::StopRecording(recording);
std::ofstream out("trace.json");
(void) trace.WriteChromeTrace(out);                                   // open in a Chrome/Perfetto trace viewer

Your game gets the level for free by linking CNA, which carries CNA_DIAGNOSTICS_LEVEL. To look at the same data from a browser while the game runs, add the Inspector.

Known limitations

  • No GPU timings, input metrics or process/driver memory totals are published; resource byte counts are declared, partly estimated sizes, not measurements.
  • No renderer or subsystem registers an IDiagnosticsSource yet.
  • The level is a compile-time property of every target that links CNA; mixing translation units built with different levels is not supported.
  • There is no C ABI and no environment-variable control.
  • No CI lane builds or runs STATS or FULL; the timing figures are CNA’s own, from one machine.
  • A macro site registers its name the first time it runs, so pass a string literal, not a runtime string: a later, different value at the same site is ignored.