Diagnostics
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 is | Diagnostics 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.
| Mode | CNA_DIAGNOSTICS_LEVEL | What is compiled in and retained |
|---|---|---|
OFF (default) | 0 | Nothing. Every instrumentation macro compiles out, evaluates none of its arguments, and reaches no state object, thread or lock. |
STATS | 1 | Frame statistics, counters, gauges, per-frame counters, the 240-frame history and resource metadata. The timing-zone and event macros still compile out. |
FULL | 2 | Everything 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:
| Call | Behaviour |
|---|---|
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").
| Macro | Available in | What it does |
|---|---|---|
CNA_DIAGNOSTICS_COUNTER_ADD(name, delta) | STATS, FULL | Adds to a cumulative counter. |
CNA_DIAGNOSTICS_GAUGE_SET(name, value) | STATS, FULL | Replaces a last-value gauge. |
CNA_DIAGNOSTICS_GAUGE_ADD(name, delta) | STATS, FULL | Adds a signed delta to a gauge. |
CNA_DIAGNOSTICS_FRAME_COUNTER_ADD(name, delta) | STATS, FULL | Adds 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, FULL | Marks one engine frame (a scope guard). Game already does this; you rarely need it. |
CNA_PROFILE_SCOPE(name) | FULL | Times the enclosing lexical scope as a zone in the Application category. |
CNA_PROFILE_SCOPE_CATEGORY(name, category) | FULL | The same with an explicit Category (Core, Update, Draw, Graphics, Audio, Content, Application, Gpu). |
CNA_DIAGNOSTICS_EVENT(name), CNA_DIAGNOSTICS_EVENT_CATEGORY(name, category) | FULL | Emits 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):
| Name | Kind | Where it comes from |
|---|---|---|
Runtime/UpdateCount, Runtime/DrawCount | per-frame counter | Game tick, incremented per update and per draw. |
Graphics/DrawCalls, Graphics/IndexedDrawCalls, Graphics/NonIndexedDrawCalls | per-frame counter | The common GraphicsDevice draw entry points. |
Graphics/SubmittedPrimitives, Graphics/IndirectDrawCalls | per-frame counter | GraphicsDevice; indirect draws contribute a primitive count of 0. |
Graphics/EffectChanges, Graphics/RenderTargetChanges | per-frame counter | GraphicsDevice effect and render-target changes. |
Graphics/TextureBindingChanges | per-frame counter | The texture collection. |
Graphics/SpriteSubmissions | per-frame counter | One per SpriteBatch::Draw call, counted separately from the draw-call counters above. |
Audio/AllocatedVoices | gauge | Both mixer engines (CNA’s own mixer and the SDL3 mixer). |
Audio/VoiceCreations | counter | Both mixer engines. |
Game/Tick, Game/Update, Game/Draw | FULL zones | Game (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 function | Purpose |
|---|---|
Mode, MetricUnit, MetricKind, Accuracy, Category, EventKind, ResourceKind | The 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. |
Snapshot | Owned, 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, FrameSample | One 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, EventBatch | One retained FULL event, and a batch with oldestAvailableSequence, newestAvailableSequence, eventsDroppedBeforeStart and producerEventsDropped so a consumer can detect loss. |
ResourceDescriptor, ResourceRecord, ResourceHandle | Resource metadata registration (RAII) and its owned copy in a snapshot. |
IDiagnosticsSource, RegisterSource, SourceRegistration, FrameStatisticsSink | Optional frame-boundary sources (see below). |
Trace, RecordingSession, StartRecording, StopRecording | Bounded 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 member | Purpose |
|---|---|
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
| Limit | Value | What happens at the limit |
|---|---|---|
| Registered metrics | 512 | Later registrations return an inert handle. |
| Frame counters copied per historical frame | 64 | Extra frame counters are still live but are not kept in that frame’s history entry. |
| Frame history | 240 frames | The oldest frame is overwritten. |
| Zone nesting depth (per thread) | 64 | A deeper zone is not recorded and is counted in malformedZoneCount. |
| Per-thread event ring | 1,024 events | A full ring drops new events and counts them (producerEventsDropped). |
| Process event history | 32,768 events | The oldest events are overwritten and counted (eventHistoryOverwrites). |
| Trace names | 65,536 names; 1 MiB per name; 16 MiB total | Enforced 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.cppandTextureCollection.cppfinds noCNA::Diagnosticsreference. - 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
IDiagnosticsSourceyet. - 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.
Deep dives on this topic
Long-form pages that explain the exact semantics, invariants and evidence behind this subject.
- Verification tiers: evidence forms, oracle authority and CI reporting — A decoding key for CNA verification claims: claim labels, what each evidence form establishes, which authority decides which question, what CI runs at 009d40f5 and how to report it.
Known issues in this area
Current defects, gaps and limitations at this snapshot that touch this subject.
- CNA-BUG-207: Delta-maintained diagnostics gauges such as Audio/AllocatedVoices drift after a runtime OFF window — GaugeHandle::Add discards deltas while the runtime mode is OFF, so a gauge kept with GAUGE_ADD(+1/-1), such as Audio/AllocatedVoices, stays wrong (too high, or negative) after statistics are re-enabled, until mixer shutd
- CNA-BUG-208: On Emscripten a diagnostics frame excludes the requestAnimationFrame wait, so FrameSample.framesPerSecond is a work rate there — The web loop opens the frame scope inside EmscriptenMainLoopCallback and waits for the next animation frame after it returns, so web frame durations and FPS exclude pacing, unlike the native Tick the documentation descri
- CNA-GAP-060: No renderer or subsystem registers an IDiagnosticsSource, and nothing registers ResourceKind::AudioVoice resources — The diagnostics source seam for GPU timings and renderer statistics has no production implementation and no engine code registers audio-voice resource metadata, so those diagnostics and Inspector views stay empty at this
- CNA-VGAP-051: No CI configuration builds diagnostics at STATS or FULL or enables CNA_BUILD_INSPECTOR, so most Diagnostics tests and every Inspector test are never compiled in CI — CNA_DIAGNOSTICS and CNA_BUILD_INSPECTOR default to OFF and no workflow or preset changes either, so 38 of 40 DiagnosticsTests, the seven audio and graphics diagnostics tests and all 27 Inspector tests run only in local b