cpp26-reflection · part 24

reflect_tracing: zero-overhead spans on Maciek Gajewski's ring-buffer engine

· upd 2026-10-06· english· audience: working-cpp· AI-generated, reviewed by Filip Sajdak

Status: github.com/wrocpp/reflect_tracing is a placeholder repository: a README with no code, licence or release. This post is a design sketch.

Correction, 6 October 2026. This post said the library was published as v0.1.0 under the MIT licence, and listed what it ships. The repository holds a README and one commit, with no code, licence, tag or release, so there is no library and the “What the library ships” list described features that do not exist: the source-to-source transformer, the OTLP and ftrace output, TRACE_FN, and tracing::start and flush. The most substantive stand-in is the embedded Compiler Explorer demo: about 240 lines in one file, with four of the instrumentation forms, a per-thread buffer of 1024 records, a registry of names where the design uses DWARF, std::chrono::steady_clock where the design reads the TSC, and a Chrome-trace JSON dump. It has no timing, transformer, OTLP output or lifecycle calls, so the #include <reflect_tracing/trace.hpp> and tracing::start code at the top is a sketch, not code that builds. The cost figures (cycles and nanoseconds per span, in the prose, the tables and the benchmark line) were never measured. I removed them, or marked them “not measured”. The “~200 ns per span” attributed to Maciek Gajewski’s 2021 talk is not in the talk: its slides say the enter and exit functions each take 2-3 ns, as the speaker’s estimate based on measurements, and show a plain per-thread buffer with a drop pointer, not a ring buffer. “Six years ago” should have been five. The text said “four” forms in two places and “five” in another; the table has five rows and the demo implements four of them (it has no auto_scope). The scope_guard_named code passed a char const* to std::define_static_string, which takes a range: GCC 16.2.0 rejects it (no matching function for call to 'define_static_string(const char*)'), and the line now passes a std::string_view; the demo’s own source still has the char const* form, which I have not re-run. The co-author credit and the sentence about joint work on the APIs and the OTLP exporter are removed, because they described a library that does not exist. The text below is left as published, apart from those corrections, so you can read it against this note.

Metrics (post 23) answer “how is my service trending?”. Tracing answers “what did this one request actually do?”: a timeline of spans at microsecond resolution, showing which function called which, how long each took, and where time was actually spent.

Chrome’s trace format (and its Perfetto UI successor) is the de-facto standard. Writing a producer is straightforward; making it low-overhead enough for production is the hard part. Five years ago at Wro.cpp #25, Maciek Gajewski showed how: thread-local buffers, TSC timestamps, a code address as the payload, names resolved from DWARF afterwards.

reflect_tracing wraps that engine. Here’s what instrumentation looks like in C++26 today:

#include <reflect_tracing/trace.hpp>

Response handle_request(Request const& req) {
    tracing::scope_guard<^^handle_request> _g;   // one line; reflection captures the function
    auto validated = validate(req);
    auto computed  = compute(validated);
    auto persisted = persist(computed);
    return persisted;
}

int main() {
    tracing::start("request_trace.json");
    for (int i = 0; i < 10'000; ++i) handle_request(next_request());
    tracing::flush();
}

Open request_trace.json in Perfetto UI: a per-thread timeline of every scope_guard, nested by call depth, with microsecond-accurate timings.

The scope_guard<^^Fn> template takes a reflection of the enclosing function as a non-type template parameter. On construction it records the function’s address; on destruction it completes the span. ^^handle_request is the new piece. We’re not passing a string literal (no chance of mismatch), not relying on a macro (no preprocessor involvement), just a compile-time handle to the function.

“Can I move the scope-guard line into the annotation?”

Natural next question: why write that one line at the top of every traced function? Why not just [[=trace]] on the declaration and let the compiler inject the scope guard?

In C++26, you can’t. Annotations in P2996 are queryable data: [[=expr]] attaches a std::meta::info to the declaration that any library can read via annotation_of_type<T>(^^decl). Reading is all they do. There’s no mechanism for an annotation to execute at declaration time and modify the annotated entity. The only code-generating primitive P2996 ships is std::meta::define_aggregate, which synthesises new class types from a namespace-scope initialiser, not “insert a statement at the start of this function’s body.”

The future picture, in C++29:

// Target syntax once P3157 (Generative Extensions) / P3294 (Token Injection) land:
struct trace_tag {
    consteval void operator()(std::meta::info fn) const {
        std::meta::inject_at_body_start(fn,
            std::meta::token_seq("tracing::scope_guard<", ^^fn, "> _g;"));
    }
};
inline constexpr trace_tag trace{};

[[=trace]]                                  // annotation is callable; injection runs at compile time
Response handle_request(Request const& req) {
    return persist(compute(validate(req))); // no manual scope_guard line
}

That’s what reflect_tracing eventually looks like. The library’s annotation design is forward-compatible. The explicit scope_guard<^^fn> form shipping today becomes the expansion of a future [[=trace]] annotation, and your instrumented source doesn’t change when you upgrade compilers. Your instrumentation source (the one-liner at the top) just goes away.

Three practical options bridge the gap today:

  1. The explicit scope_guard<^^Fn> line above. One line, honest, works now. This is the form the embedded demo implements.
  2. A macro helper. TRACE_FN(handle_request) expands to the scope_guard declaration. Duplicates the function name but saves a few keystrokes. A library header could offer TRACE_FN(name) for teams who want it; the embedded demo does not.
  3. reflect-trace-instrument, a source-to-source transformer. A companion binary (built on clang-p2996-as-library, same pattern as reflect_dx’s reflect-pretty) scans your source for [[=trace]]-annotated functions and emits an instrumented copy at build time. Functionally equivalent to P3157’s future compile-time injection, just running as a Makefile step instead of a language feature. It does not exist; it is a design idea.

The first option is the default. The third is for teams that really don’t want to see the line.

Can we get the function name without typing it?

Natural follow-up: even if [[=trace]] can’t inject code in C++26, can we at least avoid naming the function in the scope_guard<^^…> line? There’s no ^^this_function primitive (reflection always names entities explicitly), but two C++-today mechanisms recover the name automatically, each with a different payload story:

(a) std::source_location::current() default argument

A constructor(std::source_location = std::source_location::current()) captures the caller’s location as a default argument, resolved at each call site:

struct auto_scope {
    std::uint64_t enter_ns_ = now_ns();
    char const* name_;
    auto_scope(std::source_location loc = std::source_location::current())
      : name_{loc.function_name()} {}                    // runtime copy
    ~auto_scope() { push_record({name_, enter_ns_, now_ns()}); }
};

void handle_request() {
    tracing::auto_scope _g;                              // no name typed
    validate(); compute(); persist();
}

name_ is a runtime variable, initialised at each construction by copying a pointer value that is itself a compile-time constant. A pointer copy per span (cost not measured). The pointer lives in .rodata.

This is the cheapest “no-naming” option that uses only standard C++, no extensions or macros.

(b) Compile-time NTTP: name as template parameter

If “runtime MOV of a compile-time value” offends your sense of what ought to be compile-time, the name can be promoted to a non-type template parameter, provided you accept either two lines or a macro:

template <char const* Name>
struct scope_guard_named {
    std::uint64_t enter_ns_ = now_ns();
    ~scope_guard_named() { push_record({Name, enter_ns_, now_ns()}); }
};

void handle_request() {
    static constexpr char const* _fn = std::define_static_string(
        std::string_view{std::source_location::current().function_name()});
    tracing::scope_guard_named<_fn> _g;
    validate(); compute(); persist();
}

std::source_location::current() is consteval; its result is a constant expression at the call site. .function_name() returns a pointer to a string literal, which is not directly usable as an NTTP (the “pointer to subobject of a string literal” rule). Wrap in std::define_static_string (it takes a range, so pass a std::string_view) to promote the value into compile-time-stable storage, which is a valid NTTP. The scope_guard_named<_fn> instantiation is specialised per unique name, typically one per traced function, which is fine.

Cost: not measured. The Name is a template argument, so there is no per-span naming work in the source, but there is one template instantiation per unique name. For a typical service with O(100) traced functions, this is invisible. For O(10k) traced functions, code-size effects may matter; measure before pre-optimising.

The AUTO_SCOPE() macro wraps this in a single line, using __LINE__ to give the static constexpr a unique name within the translation unit.

(c) Maciek’s labels-as-values: no name at all

The “no name anywhere, ever, not even at dump time” option, as originally demonstrated at Wro.cpp #25 in 2021. Takes a per-call-site label address via GCC’s &&label extension:

#define TRACE()                                     \
    _trace_addr_:                                   \
    tracing::scope_guard_from_addr _g_{&&_trace_addr_}

void handle_request() {
    TRACE();                          // no name typed; payload is 8-byte label address
    validate(); compute(); persist();
}

Payload is purely an 8-byte address (same as Form 1) and includes the line number within the function (because the label is at a specific source position). Resolution at dump time via addr2line / dwarf++. This is the form the 2021 talk uses; it was not measured against the others here.

Picking a form

Form Name at call site Hot-path cost Payload Requires
scope_guard<^^Fn> One name, typed not measured 8-byte function address P2996 reflection only
auto_scope (source_location default arg) none not measured 8-byte runtime-copied name pointer Standard C++20
scope_guard_named<_fn> (2 lines) none not measured 8-byte NTTP name pointer define_static_string (C++26)
AUTO_SCOPE() macro none not measured Same as above define_static_string + macro
TRACE() macro (Maciek) none not measured 8-byte label address (per call site) GCC/Clang &&label extension

All five emit a per-call-site register entry so the dump step can map key → display name uniformly; the difference is what the “key” is (a function address vs. a label address vs. a static-string pointer).

For a mixed codebase, the design offers all of them and would document the trade-offs. Default is Form 1 (scope_guard<^^Fn>) because it’s the only form that composes with the reflection-driven filter / arg-capture annotations coming in C++29, but every form produces a valid trace, and picking the right one per function is a legitimate codebase-hygiene decision rather than a library constraint.

The example trace_emit.cpp exercises four of the five forms (all but auto_scope) side-by-side and shows the dump showing each one’s resolved name.

The engine (Maciek’s technique, recapped)

The critical design choice Maciek drove home in the 2021 talk: the hot path records a function address, not a name.

Per thread:

  • A fixed-size ring buffer of span_record entries, typically a few MB, sized once at startup.
  • A write pointer incremented with relaxed atomics (single-writer-per-thread → no contention).

On span enter/exit:

  1. Read TSC (__rdtsc() on x86, CNTVCT_EL0 on arm64).
  2. Write a span_record { void* addr; uint64_t tsc_enter; uint64_t tsc_exit; }: 24 bytes, pure POD, zero string work.
  3. Advance the write pointer.

Cost per span: not measured here. Name resolution, TSC→ns conversion, and thread-ID labelling all happen at dump time, not in the hot path.

Post-processing: address → name via DWARF

The ring-buffer payload is just an 8-byte function address. To turn that into the span name "handle_request" you resolve it against the binary’s debug info. Three tool options:

  • addr2line: lazy option, runs as a subprocess, piping addresses in and names out.
  • dwarf++ / elf++: C++ libraries that parse DWARF / ELF in-process. Faster, no subprocess, no popen.
  • dladdr: libc fallback; gives the immediate symbol but not full source location.

Any of them run after the ring buffer is drained. The hot path is 24 bytes of POD writes; everything else is deferred.

Periodic flush

  • Each thread’s ring buffer is drained into a central std::vector<span_record> (or streamed direct to disk).
  • TSCs are converted to nanoseconds via a calibrated TSC-rate.
  • Addresses are resolved via DWARF lookup.
  • Records are emitted in Chrome-trace JSON with real function names.

Enable / disable

A production tracer needs two kill switches:

Compile-time: -DREFLECT_TRACING_DISABLED=1

#if REFLECT_TRACING_DISABLED
template <std::meta::info Fn>
struct scope_guard { scope_guard() noexcept = default; };   // empty
#else
template <std::meta::info Fn>
struct scope_guard { /* full impl */ };
#endif

With the flag set, scope_guard<^^Fn> is an empty class. The constructor + destructor are trivial no-ops that the optimiser removes entirely. The optimiser should remove it entirely; code size and cycles were not measured.

Runtime: tracing::enabled = false

inline thread_local bool enabled = true;

scope_guard() noexcept : addr_(reinterpret_cast<void*>(&[:Fn:])) {
    if (!enabled) return;   // predictable branch; cost not measured
    enter_ns_ = now_ns();
}

Thread-local so toggling doesn’t cross CPUs. The branch is taken the same way for long stretches, so it should predict well; its cost was not measured.

Typical shape: default off in release builds, flipped on for specific request types (“trace 1% of requests”) or for specific workloads.

Reflection’s role

Note what reflection does not do: it doesn’t make the hot path cheaper. The 24-byte payload is the same with or without reflection: address + timestamps. Reflection’s contribution is the instrumentation layer above that, plus a few quality-of-life wins:

  1. Auto-record the address. &[:Fn:] in the scope_guard constructor splices the function pointer, the same 8 bytes as &handle_request, from the reflection the guard already receives, so the address is not written a second time.
  2. No stale name strings in the binary. Because we record the address and resolve via DWARF at dump time, we don’t need "handle_request" stored somewhere in .rodata. DWARF has it already, for every function, for free.
  3. Compile-time filters become data. An annotation like [[=trace::verbose]] on the declaration gets consumed by if constexpr in the instrumentation layer, compiled out of release builds without macros.
  4. Arguments will be inspectable (today partial, fully in C++29). With std::meta::parameters_of(^^Fn) we can identify specific parameters to capture alongside the span. In C++26 the user has to pass them to a record function explicitly; C++29 injection will let an annotation drive it.

Comparing to the status quo

Approach Source of truth Per-span cost (none measured) Works in release Arg capture
printf / spdlog Manual not measured Variable Manual
TRACE_SCOPE() macro Manual not measured Yes Requires second macro
gperftools Manual not measured No none
Perfetto SDK (Chrome) Manual API not measured Yes Yes
reflect_tracing (C++26) scope_guard<^^Fn> line not measured Yes Explicit param captures
reflect_tracing (C++29, P3157) [[=trace]] annotation not measured Yes Fully automatic

The engine is not new (Maciek shipped this technique in 2021). What C++26 adds is the reflection-driven instrumentation layer: address capture via &[:Fn:], static dispatch on annotations, ^^Fn as a first-class NTTP. What C++29 will add is the final boilerplate collapse, turning the scope_guard line into an annotation. The library stays useful today either way; upgrading to C++29 deletes code from your call sites, not from your library.

A concrete trace

A snapshot of handle_request spans under load:

Thread 3  |━━━ handle_request ━━━━━━━━━━━━━━━━━━━━━━━━━━━|
          |━━ validate ━━|━━━ compute ━━━|━━ persist ━━|
                            |━━━ db_lookup ━━━|

Thread 4  |━━━ handle_request ━━━━━━━━━━━━━━━━|
          |━ validate ━|━━━ compute ━━━|
                          |━ cache_hit ━|

Perfetto’s flamegraph view turns this into a million-span zoomable timeline. Useful for spotting:

  • A specific request path where db_lookup blew up.
  • Rare slow cases (one span 100× slower than the median).
  • Lock contention (threads waiting).
  • Thread-pool imbalances.

What exists today

github.com/wrocpp/reflect_tracing is a placeholder: a README with no code, licence or release. The embedded demo above is the most substantive stand-in. It has:

  • tracing::scope_guard<^^Fn>, scope_guard_named, AUTO_SCOPE() and TRACE(): four of the five forms in the table.
  • tracing::enabled: a thread-local runtime kill switch.
  • -DREFLECT_TRACING_DISABLED=1: a compile-time kill switch that makes the guard empty.
  • A 24-byte span_record (key plus enter and exit timestamps) in a per-thread buffer of 1024 records.
  • A Chrome-trace JSON dump, with names from a registry instead of DWARF, and steady_clock timestamps instead of the TSC.

It does not have any timing, TRACE_FN, a source transformer, tracing::start and flush, OTLP or ftrace output, or [[=trace]] handling.

The technique this post builds on is from Maciek Gajewski’s talk Wro.cpp #25 (2021); the slides are online.

Next in the series

  • Post 25, reflect_soa: AoS → SoA → AoSoA, picked by benchmark.
  • Companion Monday short: “Reading a Perfetto trace for the first time”.