Hello! Welcome to our next lesson.
In our last session, you successfully implemented a microbenchmark to measure the throughput of your SPSC queue. You used std::thread and std::chrono to create a concurrent test, giving you the first hard performance number for a core component of your telemetry engine. This was an excellent exercise in manual benchmark implementation.
Today, we shift our focus from the data pipeline's performance to the performance of the measurement tool itself. Our goal is to achieve the learning outcome: Design a benchmark to measure the overhead of the instrumentation macros.
This is a critical step. The core promise of your telemetry engine is to be "zero-overhead," meaning it shouldn't slow down the application it's observing. To validate this claim, you first need a reliable way to measure that overhead. This lesson will equip you with the design principles and tools to do just that.
Part 1: What is Instrumentation Overhead?
An "instrumented profiler" works by having the programmer manually add markers to the code. In our project, this will take the form of preprocessor macros that capture data like function names and timestamps.
void MyFunction() {
PROFILE_SCOPE("MyFunction"); // Our future instrumentation macro
// ... rest of the function logic ...
}
The code inside that PROFILE_SCOPE macro—even if it's highly optimized—takes a small amount of time to execute. This time is the instrumentation overhead, also known as the "observer effect."
The article from Riot Games provides a perfect illustration of this concept.
Profiling: Measurement and Analysis
The article 'Profiling: Measurement and Analysis' from Riot Games Technology explains how instrumented profilers work and shows a real-world example of an instrumentation macro.
Read the section 'Instrumented'. Pay close attention to the FT_PROFILE_FN macro example. This is exactly the kind of tool whose performance we need to measure. The text explains that this macro creates an object to time its own scope—that creation, and the subsequent data recording, is the source of overhead.
Our goal is to design a test that can precisely measure the execution time of our PROFILE_SCOPE macro, both when it's active and when it's compiled out.
Part 2: Moving Beyond Manual Benchmarking
While our manual benchmark with std::chrono was effective for a single, long-running test, it's not ideal for measuring the tiny, nanosecond-level cost of a single macro call. Such measurements are highly susceptible to "noise" from the operating system, CPU cache effects, and other processes.
Furthermore, compilers are incredibly aggressive about optimization. If you write a loop that calls an empty function, the compiler will likely remove the entire loop. This makes measuring the difference between an active macro and a disabled one very difficult.
To overcome these challenges, we need a more robust tool. We will use Google Benchmark, a C++ library designed specifically for writing accurate microbenchmarks.
How to use Google Benchmark for C++ programs
The video 'How to use Google Benchmark' explains why a dedicated library is superior to manual timing for microbenchmarks.
Watch from the beginning to 02:02, and then from 03:13 to 05:32. The first part highlights the features of Google Benchmark, and the second explains the complexities (like compiler and CPU optimizations) that make benchmarking so difficult. This will give you a clear motivation for using this library.
Part 3: The Anatomy of a Google Benchmark
Google Benchmark simplifies the process by handling the statistical analysis and providing tools to counteract compiler optimizations. A basic benchmark looks like this:

The key components are:
- A static function that takes a
benchmark::State¶meter. - The measurement loop:
for (auto _ : state) { ... }. Google Benchmark runs this loop repeatedly until it gathers enough data for a stable measurement. You place the code you want to measure inside this loop. - Registration: The
BENCHMARK(FunctionName);macro makes your function visible to the benchmark runner. - Main function: The
BENCHMARK_MAIN();macro generates themainfunction for you.
Let's see how this works in practice.
How to use Google Benchmark for C++ programs
Let's continue with the 'How to use Google Benchmark' video to see how to write and run a simple benchmark.
Watch from 05:32 to 08:34. This section demonstrates how to set up the files, write a wrapper function, register it with the BENCHMARK macro, and run it. Don't worry about the Bazel build system details; focus on the C++ code structure.
Part 4: Designing Our Overhead Benchmark
To measure the overhead of our macro, we will design three distinct scenarios:
- The Baseline: We'll benchmark a simple function that does a small amount of work, with no instrumentation. This gives us our baseline performance.
- The Instrumented Case: We'll benchmark the exact same function, but with our
PROFILE_SCOPEmacro added. The difference in time between this and the baseline is our macro's overhead. - The Disabled Case: We'll benchmark the instrumented function again, but this time with our profiling flag (e.g.,
PROFILING_ENABLED) turned off. The macro should expand to nothing. The execution time should be identical to the baseline, proving we have achieved zero overhead when profiling is disabled.
Fighting the Compiler's Optimizations
The biggest challenge in this design is ensuring the compiler doesn't outsmart us. If our test function's work has no observable effect, the compiler will remove it.
Google Benchmark provides two essential tools to prevent this:
benchmark::DoNotOptimize(variable): Tells the compiler thatvariableis being "used," preventing the calculations that produce it from being optimized away. It creates a memory barrier.benchmark::ClobberMemory(): Tells the compiler that some memory may have been changed, forcing it to write out any pending stores.
These tools are our primary weapons for ensuring our measurements are valid.
The official Google Benchmark documentation provides the definitive explanation for these anti-optimization tools.
Read the section titled 'Preventing Optimization'. This section explains what DoNotOptimize and ClobberMemory do and provides examples of how to use them correctly. This is a critical concept for our design.
Part 5: The Final Benchmark Design
Now, let's assemble these concepts into a complete design. Below is the C++ code structure for the benchmark. This isn't the final implementation of your macro, but a placeholder that allows us to design and test the measurement methodology itself.
#include <benchmark/benchmark.h>
#include <cstdint>
#include <string_view>
// --- 1. Placeholder Instrumentation Macro ---
// This will be replaced by your real macro from Module 6.
// Define a build flag to control profiling.
// In a real build system, this would be -DPROFILING_ENABLED=1
#define PROFILING_ENABLED 1
// A dummy class to simulate the RAII timer object in a real profiler.
struct ScopedTimer {
// The constructor would record a start time.
explicit ScopedTimer(std::string_view name) {
// We make it do something non-trivial to simulate work.
// Volatile is used to ensure this isn't optimized away.
volatile std::string_view copy = name;
}
// The destructor would record an end time and store the event.
~ScopedTimer() {}
};
#if PROFILING_ENABLED
#define PROFILE_SCOPE(name) ScopedTimer timer##__LINE__(name)
#else
#define PROFILE_SCOPE(name)
#endif
// --- 2. The Workload to be Measured ---
// A function that does some simple, measurable work.
// It returns a value to ensure the compiler can't optimize it away entirely.
// `__attribute__((noinline))` prevents the compiler from inlining this function,
// which could interfere with the measurement.
__attribute__((noinline))
uint64_t DoWork(uint64_t iterations) {
uint64_t result = 0;
for (uint64_t i = 0; i < iterations; ++i) {
result += i % 123; // Some arbitrary work
}
return result;
}
// --- 3. The Benchmark Scenarios ---
// Scenario 1: Baseline measurement without any instrumentation.
static void BM_Baseline(benchmark::State& state) {
for (auto _ : state) {
// We pass the result to DoNotOptimize to ensure DoWork is called.
benchmark::DoNotOptimize(DoWork(1000));
}
}
BENCHMARK(BM_Baseline);
// Scenario 2: Measurement with the instrumentation macro enabled.
static void BM_Instrumented(benchmark::State& state) {
for (auto _ : state) {
// The only difference is the PROFILE_SCOPE macro inside the loop.
PROFILE_SCOPE("WorkLoop");
benchmark::DoNotOptimize(DoWork(1000));
}
}
BENCHMARK(BM_Instrumented);
// --- 4. Main Entry Point ---
BENCHMARK_MAIN();
Test your understanding!
Question: In the BM_Instrumented benchmark, the PROFILE_SCOPE macro is placed inside the for (auto _ : state) loop. Why is this correct? What would happen if it were placed outside the loop?
Show answer
Placing it inside the loop is correct because the Google Benchmark framework measures the total time of everything within the loop. By putting the macro inside, we ensure its construction and destruction happen on every single iteration that is being timed. This allows us to measure its average cost per call.
If we placed it outside the loop, its constructor would be called only once before the timing loop begins, and its destructor would be called only once after it ends. The timing mechanism would not capture the cost of the macro itself, only the DoWork function, making the benchmark identical to the baseline and defeating the entire purpose of the measurement.
This design gives you two key measurements:
- Average time of
BM_Baseline: The cost ofDoWork(1000). - Average time of
BM_Instrumented: The cost ofDoWork(1000)plus the cost of onePROFILE_SCOPEcall.
The overhead is simply Time(BM_Instrumented) - Time(BM_Baseline). To test the disabled case, you would recompile the same code with -DPROFILING_ENABLED=0 and run the benchmarks again. In that case, Time(BM_Instrumented) should be virtually identical to Time(BM_Baseline).
Conclusion
You have now designed a robust and professional benchmark for measuring the overhead of your future instrumentation macros. You've moved beyond simple manual timing and have a plan that leverages an industry-standard library to account for statistical noise and aggressive compiler optimizations.
Key Takeaways:
- Instrumentation overhead is the time cost of the measurement code itself, and minimizing it is key to an effective profiler.
- Google Benchmark is the right tool for microbenchmarking, as it handles statistical stability and provides tools to combat overzealous compilers.
- The benchmark design relies on comparing a baseline measurement against an instrumented one.
- Using
benchmark::DoNotOptimizeis non-negotiable to ensure the work you're measuring is actually performed. - A complete test involves measuring the macro's overhead when it's enabled and verifying it has zero overhead when it's disabled via a compile-time flag.
What's Next?
With this solid design in hand, the next step is to put it into practice. In the following lesson, we will focus on the learning outcome: Execute the benchmark with and without the profiling flag enabled to quantify the 'observer effect'. You will set up Google Benchmark in your project, run these tests, and analyze the output to get your first concrete data on your profiler's performance footprint.