Tutorial 155: Read Frame Statistics with Diagnostics
What you’ll learn: Turn on CNA Diagnostics with CNA_DIAGNOSTICS, add counters, gauges and timing zones to a game, read frame statistics from the running process through the provider, and export a FULL-mode Chrome trace.
Development snapshot only. Diagnostics does not exist in the v0.1.0-alpha.1 tag. Everything here targets CNA snapshot 009d40f5 on the next branch (which still reports 0.1.0-alpha.1); a plain git clone of CNA gives you alpha.1, so clone with -b next and give sharp-runtime its next branch too. The program below was checked to compile against that snapshot’s headers at all three Diagnostics levels; we did not build and run the whole project, so treat the printed numbers as what the code is designed to show, not as a recorded run.
What you need
A checkout layout as in Tutorial 03: your project next to cna/ and sharp-runtime/ (plus easy-gl/ and meta-gl/ for the default Linux renderer):
my-cna-workspace/
├── cna/ # git clone -b next https://github.com/libcna/cna.git
├── sharp-runtime/ # git clone -b next https://github.com/libcna/sharp-runtime.git
├── easy-gl/ meta-gl/ # default branches
└── stats-game/ # <-- create this
├── CMakeLists.txt
└── main.cpp
Diagnostics is part of the CNA target you already link. The only switch is a configure option, CNA_DIAGNOSTICS, whose default is OFF.
CMakeLists.txt
Nothing Diagnostics-specific goes in the project file: CNA_DIAGNOSTICS_LEVEL is a public compile definition of the CNA build configuration, so linking CNA gives your code the same level the engine was built with.
cmake_minimum_required(VERSION 3.20)
project(StatsGame LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# CNA is a sibling checkout, added as a subdirectory (the C++ framework has no install package).
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../cna ${CMAKE_BINARY_DIR}/cna)
add_executable(StatsGame main.cpp)
target_link_libraries(StatsGame PRIVATE CNA)
main.cpp
The program is a small game that draws 48 sprites per frame for 120 frames and then exits. It uses two of Diagnostics’ macro families in its own code and, just before it exits, reads everything Diagnostics collected through the pull provider. In a FULL build it also records the events and writes a Chrome trace.
// StatsGame.cpp -- a tiny game that draws sprites, then prints what Diagnostics measured.
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Graphics/GraphicsDevice.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/Texture2D.hpp"
#include "CNA/Diagnostics/Instrumentation.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <fstream>
#include <map>
#include <memory>
#include <string>
#include <vector>
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Graphics;
namespace
{
constexpr int SpriteCount = 48;
constexpr int FramesToRun = 120;
const char* ModeName(CNA::Diagnostics::Mode mode)
{
switch (mode)
{
case CNA::Diagnostics::Mode::Off: return "OFF";
case CNA::Diagnostics::Mode::Stats: return "STATS";
case CNA::Diagnostics::Mode::Full: return "FULL";
}
return "?";
}
const char* KindName(CNA::Diagnostics::MetricKind kind)
{
switch (kind)
{
case CNA::Diagnostics::MetricKind::Counter: return "counter";
case CNA::Diagnostics::MetricKind::Gauge: return "gauge";
case CNA::Diagnostics::MetricKind::FrameCounter: return "per-frame";
}
return "?";
}
void PrintReport()
{
namespace Diag = CNA::Diagnostics;
const Diag::Snapshot snapshot = Diag::GetProvider().CaptureSnapshot();
std::printf("build mode: %s, runtime mode: %s, current frame number: %llu\n",
ModeName(snapshot.buildMode), ModeName(snapshot.runtimeMode),
static_cast<unsigned long long>(snapshot.currentFrameNumber));
// Counters and gauges as of now. A per-frame counter shows the last completed frame.
std::printf("\n%-32s %-10s %s\n", "metric", "kind", "value");
for (const Diag::MetricSample& metric : snapshot.metrics)
{
std::printf("%-32s %-10s %lld\n", metric.name.c_str(), KindName(metric.kind),
static_cast<long long>(metric.value));
}
// Per-frame counters aggregated over the retained frame history (up to 240 frames).
struct Aggregate { long long total = 0; long long peak = 0; };
std::map<std::string, Aggregate> perFrame;
std::uint64_t worst = 0;
std::uint64_t sum = 0;
for (const Diag::FrameSample& frame : snapshot.recentFrames)
{
worst = std::max(worst, frame.durationNs);
sum += frame.durationNs;
for (const Diag::MetricSample& metric : frame.metrics)
{
Aggregate& entry = perFrame[metric.name];
entry.total += metric.value;
entry.peak = std::max<long long>(entry.peak, metric.value);
}
}
std::printf("\n%-32s %12s %10s\n", "per-frame counter", "total", "peak");
for (const auto& [name, entry] : perFrame)
std::printf("%-32s %12lld %10lld\n", name.c_str(), entry.total, entry.peak);
if (!snapshot.recentFrames.empty())
{
std::printf("\nlast %zu frames: average %.3f ms, worst %.3f ms\n",
snapshot.recentFrames.size(),
static_cast<double>(sum) / static_cast<double>(snapshot.recentFrames.size()) / 1e6,
static_cast<double>(worst) / 1e6);
}
std::printf("resources registered: %zu (%llu declared bytes)\n", snapshot.resources.size(),
static_cast<unsigned long long>(snapshot.registeredResourceBytes));
}
class StatsGame final : public Game
{
public:
StatsGame()
{
graphics_ = std::make_unique<GraphicsDeviceManager>(this);
graphics_->setPreferredBackBufferWidthProperty(640);
graphics_->setPreferredBackBufferHeightProperty(360);
}
protected:
void LoadContent() override
{
auto& device = getGraphicsDeviceProperty();
spriteBatch_ = std::make_unique<SpriteBatch>(device);
sprite_ = std::make_unique<Texture2D>(device, 32, 32);
std::vector<Color> pixels(32 * 32, Color::White);
sprite_->SetData(pixels.data(), static_cast<int>(pixels.size()));
}
void UnloadContent() override
{
sprite_.reset();
spriteBatch_.reset();
}
void Update(GameTime& gameTime) override
{
CNA_PROFILE_SCOPE("Stats/Update"); // FULL only
CNA_DIAGNOSTICS_GAUGE_SET("Stats/Sprites", SpriteCount); // STATS and FULL
if (++frame_ >= FramesToRun)
{
PrintReport(); // resources are still alive here, so they are still registered
Exit();
return;
}
Game::Update(gameTime);
}
void Draw(const GameTime& gameTime) override
{
CNA_PROFILE_SCOPE_CATEGORY("Stats/Draw", ::CNA::Diagnostics::Category::Draw);
auto& device = getGraphicsDeviceProperty();
device.Clear(Color::CornflowerBlue);
spriteBatch_->Begin();
for (int index = 0; index < SpriteCount; ++index)
{
spriteBatch_->Draw(*sprite_, Rectangle(10 + index * 12, 40 + (index % 6) * 30, 12, 12),
Color::Yellow);
}
spriteBatch_->End();
Game::Draw(gameTime);
}
private:
int frame_ = 0;
std::unique_ptr<GraphicsDeviceManager> graphics_;
std::unique_ptr<SpriteBatch> spriteBatch_;
std::unique_ptr<Texture2D> sprite_;
};
}
int main()
{
#if CNA_DIAGNOSTICS_LEVEL >= 2
// FULL only: a bounded recording of the newest 4096 events (an inactive session otherwise).
CNA::Diagnostics::RecordingSession recording = CNA::Diagnostics::StartRecording(4096);
#endif
{
StatsGame game;
game.Run();
}
#if CNA_DIAGNOSTICS_LEVEL >= 2
const CNA::Diagnostics::Trace trace = CNA::Diagnostics::StopRecording(recording);
std::ofstream out("trace.json");
if (trace.WriteChromeTrace(out))
{
std::printf("\nwrote trace.json: %zu events, %llu dropped\n", trace.GetEvents().size(),
static_cast<unsigned long long>(trace.GetDroppedEventCount()));
}
#endif
return 0;
}
What each part does
| Code | What it demonstrates |
|---|---|
CNA_DIAGNOSTICS_GAUGE_SET("Stats/Sprites", SpriteCount) | A last-value gauge registered on first use. Available in STATS and FULL; compiled out (arguments type-checked, never evaluated) in OFF. |
CNA_PROFILE_SCOPE("Stats/Update"), CNA_PROFILE_SCOPE_CATEGORY("Stats/Draw", ...) | CPU timing zones for the enclosing scope. FULL only; in STATS they compile out. The category (Update, Draw, …) only labels the zone. |
| (engine code you did not write) | While the game runs, Game, GraphicsDevice, SpriteBatch and the audio mixer add their own metrics: Runtime/UpdateCount, Runtime/DrawCount, Graphics/DrawCalls and friends, Graphics/SpriteSubmissions, plus the Game/Tick, Game/Update and Game/Draw zones in FULL. The full list is on the Diagnostics page. |
GetProvider().CaptureSnapshot() | Returns an owned copy of the current metrics, the last 240 frames and the registered resources. It can be called from your own code at any time; here it runs from Update on the last frame, while the textures are still alive and registered. |
snapshot.metrics versus frame.metrics | The snapshot lists every metric once; a per-frame counter shows the value of the last completed frame. Each historical FrameSample carries the per-frame counters for that frame, which is what the aggregation loop sums. |
StartRecording(4096) / StopRecording | FULL only: a bounded recording of the newest 4,096 events, exported with WriteChromeTrace. In a STATS or OFF build the calls are compiled out with the #if. |
Build and run in STATS mode
cd stats-game
cmake -S . -B build -DCNA_DIAGNOSTICS=STATS -DCMAKE_BUILD_TYPE=Release
cmake --build build --target StatsGame -j"$(nproc)"
./build/StatsGame
(On a machine with no display add -DCNA_PLATFORM=HEADLESS -DCNA_GRAPHICS_RENDERER=HEADLESS -DCNA_AUDIO_PLATFORM=NULL to the configure line; sprite submissions are counted before any renderer is involved.) The window closes by itself after 120 updates, and the report prints from the last one. Its shape is:
build mode: STATS, runtime mode: STATS, current frame number: <about 120>
metric kind value
Runtime/UpdateCount per-frame <last frame>
Graphics/SpriteSubmissions per-frame <last frame>
Stats/Sprites gauge 48
...
per-frame counter total peak
Graphics/SpriteSubmissions <48 x frames that drew> 48
Runtime/DrawCount ... ...
...
last <n> frames: average <x> ms, worst <y> ms
resources registered: <n> (<bytes> declared bytes)
How to read it:
- The gauge
Stats/Spritesreads48: you set it every update and a gauge keeps its last value. - The per-frame table is where the drawing statistics live.
Graphics/SpriteSubmissionshas a peak of exactly 48 — one submission perSpriteBatch::Drawcall, and you make 48 in every frame that draws — and a total that is 48 times the number of frames that drew. TheGraphics/DrawCallsfamily counts draws made throughGraphicsDevice’s draw entry points; sprites are counted separately (above), and CNA’s own notes saySpriteBatchdoes not go through those draw counters, so do not expect the two families to move together. - The frame line is computed from
FrameSample::durationNs, the duration of each completed engine frame (theGametick betweenBeginFrameandEndFrame) over the retained history. - Resources lists the textures, buffers and render targets the graphics layer registered; you should see at least the 32×32 texture you created. Resources unregister when their objects are destroyed, which is why the report runs before the game shuts down. The byte count is a declared size, a mixed exact/estimated aggregate, not GPU memory.
Reconfigure for FULL: zones and a trace
The level is baked into every translation unit, so changing it reconfigures and rebuilds the engine. Reuse the same build directory rather than creating another one:
cmake -S . -B build -DCNA_DIAGNOSTICS=FULL
cmake --build build --target StatsGame -j"$(nproc)"
./build/StatsGame
The report is the same and one line is added at the end, wrote trace.json: N events, D dropped. The file is Chrome Trace Event JSON: every zone and frame is a complete event and every marker an instant event, with microsecond timestamps. Open it in the trace viewer of a Chromium browser (chrome://tracing) or drop it on the Perfetto UI. You should see nested Game/Tick → Game/Update and Game/Draw zones from the engine, and your Stats/Update and Stats/Draw zones inside them. A non-zero D (dropped) means events were lost: the per-thread ring (1,024 events) filled, the process history (32,768) overwrote older events, or more events were produced than the 4,096 you asked to keep. Shorten the run or ask StartRecording for more (it is clamped to 32,768).
Lowering the mode at run time
A FULL build can behave as STATS, or as OFF, without rebuilding:
namespace Diag = CNA::Diagnostics;
if (!Diag::SetRuntimeMode(Diag::Mode::Stats)) {
// the build did not compile the requested mode (a mode above the build's level is refused)
}
// Diag::GetRuntimeMode() now reports Stats; zones and markers stop recording.
Raising the mode above what the build compiled is refused with false. Changing the mode invalidates zones that were open at that moment instead of recording half of them.
Shipping without it
Leave CNA_DIAGNOSTICS at OFF for the build you ship. Your macro call sites then compile to a type check that evaluates nothing (so never put a side effect in a macro argument), the engine’s own instrumentation disappears from its object files, and no state, thread or lock exists. Code that reads Diagnostics, like PrintReport() above, still compiles at level 0 and simply sees empty results; wrap it in #if CNA_DIAGNOSTICS_LEVEL >= 1 if you would rather not carry it in a shipping build.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
build mode: OFF and empty tables | Configured without -DCNA_DIAGNOSTICS=STATS (or FULL). The option lives in the CNA subproject’s cache, so pass it on your project’s configure line. |
No Stats/Update or Stats/Draw in the trace, and no trace.json | A STATS build compiles zones out, and the recording block in main is behind #if CNA_DIAGNOSTICS_LEVEL >= 2. Use FULL. |
Configure fails on CNA_DIAGNOSTICS | The value must be exactly OFF, STATS or FULL (case-insensitive). |
| Metrics you registered are missing | Only 512 metrics can be registered; later registrations return an inert handle. A conflicting re-registration (same name, different kind or unit) does too. |
Next steps
- Tutorial 156: Watch a running game live in the Inspector — see the same metrics in a browser.
- Diagnostics reference — every macro, built-in metric, limit and the trace format.
- Tutorial 73: Profiling — frame-time thinking that Diagnostics now automates.