Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 80

Profiling and the Observer Effect

Recommended resources

Hello! Welcome to the final lesson of our "Benchmarking and Performance Verification" module.

In our previous lesson, we meticulously designed a microbenchmark to measure the overhead of our future instrumentation macros. We established a clear methodology: compare a baseline function against the same function with instrumentation enabled, and then verify that the overhead disappears when instrumentation is disabled.

Today, we bring that design to life. Our objective is to execute the benchmark with and without the profiling flag enabled to quantify the 'observer effect'. This isn't just an academic exercise; you will generate the first hard data that proves whether your telemetry engine can live up to its "zero-overhead" promise.

Let's get our hands dirty and see what the numbers say.

Part 1: Setting up Your Benchmark Environment

To run the benchmark we designed, we first need to integrate the Google Benchmark library into our project. The most common and robust way to manage C++ projects and their dependencies is with a build system generator like CMake. While we'll dive deeper into CMake when setting up our GUI in Module 7, for now, we can use a simple configuration to get our benchmark running.

Create a file named CMakeLists.txt in your project's root directory and add the following content:

# Set the minimum required version of CMake and define the project
cmake_minimum_required(VERSION 3.16)
project(TelemetryEngineBench CXX)

# Use the C++17 standard
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# Include the FetchContent module to download external libraries
include(FetchContent)

# Declare the Google Benchmark dependency
FetchContent_Declare(
  GoogleBenchmark
  GIT_REPOSITORY https://github.com/google/benchmark.git
  GIT_TAG        v1.8.3
)

# Make the dependency available to our project
FetchContent_MakeAvailable(GoogleBenchmark)

# Create an executable for our benchmark from a source file
add_executable(OverheadBenchmark benchmark.cpp)

# Link our benchmark executable against Google Benchmark and pthread
target_link_libraries(OverheadBenchmark PRIVATE benchmark pthread)

This file tells CMake to download Google Benchmark and link it against a C++ file we will create called benchmark.cpp. The use of FetchContent makes this process automatic and self-contained.


Part 2: Implementing and Compiling the Benchmark

Now, create the benchmark.cpp file. Copy the full code design from our last lesson into this file. It contains our placeholder macro and the BM_Baseline and BM_Instrumented test cases.

With both CMakeLists.txt and benchmark.cpp in place, open your terminal and follow these steps to compile:

  1. Configure CMake (with profiling enabled): This command tells CMake to set up the build files in a build directory. We use the -DPROFILING_ENABLED=1 flag to enable our instrumentation macro.

    cmake -S . -B build -DCMAKE_CXX_FLAGS="-DPROFILING_ENABLED=1"
    
  2. Build the executable: This command compiles the code.

    cmake --build build
    

If successful, you will have an executable file named OverheadBenchmark inside the build directory.


Part 3: Running the Benchmark and Quantifying the Observer Effect

It's time for the moment of truth. Run the benchmark from your terminal:

./build/OverheadBenchmark

You will see output similar to this (the exact numbers will vary depending on your computer's hardware):

Run on (12 X 4.50 GHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x6)
  L1 Instruction 32 KiB (x6)
  L2 1024 KiB (x6)
  L3 12288 KiB (x1)
-------------------------------------------------------------------
Benchmark                   Time             CPU         Iterations
-------------------------------------------------------------------
BM_Baseline              151 ns          151 ns            4621981
BM_Instrumented          167 ns          167 ns            4189196

Let's break this down:

  • The Time column shows the average wall-clock time it took for one iteration of the for (auto _ : state) loop to complete.
  • BM_Baseline took 151 nanoseconds. This is the cost of our DoWork(1000) function call.
  • BM_Instrumented took 167 nanoseconds. This is the cost of DoWork(1000) plus the cost of our PROFILE_SCOPE macro.

The overhead—the observer effect—is the difference between these two measurements:

Overhead = Time(BM_Instrumented) - Time(BM_Baseline) = 167 ns - 151 ns = 16 ns

This number is the performance cost of a single instrumentation point when profiling is active. But is 16 ns good? Let's consult a real-world, industry-leading profiler.

README.md - Tracy Profiler

The documentation for Tracy, one of the high-performance profilers that inspired your project, provides its own overhead measurements. This gives us a fantastic real-world target to compare against.

Read the 'Performance impact' section. Notice their methodology: they compare a 'Clean run' vs. a 'Profiling run' and calculate the difference. They measure their per-zone cost to be around 15 ns. The fact that our simple placeholder is already in the same ballpark is an excellent sign!

Our measurement of 16 ns is remarkably close to Tracy's, confirming that our measurement technique is sound and our initial design is on the right track for high performance.


Part 4: Verifying the "Zero-Overhead" Disabled Case

A key feature of a professional profiler is that it adds zero cost when disabled in a release build. Let's verify our design achieves this.

Re-compile the code, but this time, set the PROFILING_ENABLED flag to 0:

# 1. Re-configure CMake with the flag disabled
cmake -S . -B build -DCMAKE_CXX_FLAGS="-DPROFILING_ENABLED=0"

# 2. Re-build the executable
cmake --build build

Now, run the benchmark executable again:

./build/OverheadBenchmark

The output should now look something like this:

-------------------------------------------------------------------
Benchmark                   Time             CPU         Iterations
-------------------------------------------------------------------
BM_Baseline              151 ns          151 ns            4628205
BM_Instrumented          151 ns          151 ns            4621981

Success! The execution times for BM_Baseline and BM_Instrumented are now identical (or within a negligible margin of system noise). This proves that when our flag is off, the #if PROFILING_ENABLED preprocessor directive works as expected, and the PROFILE_SCOPE macro expands to absolutely nothing, incurring zero performance cost.

This technique is standard practice in high-performance C++.

VISUAL BENCHMARKING in C++ (how to measure performance visually)

The pattern of using a macro that can be compiled out is a cornerstone of performant C++ instrumentation. Let's revisit this quick video to see it in another practical example.

Watch from 09:46 to 11:20. The presenter creates a PROFILE_SCOPE macro and wraps it in an #if block controlled by a profiling flag. When the flag is 0, the macro is defined as empty, stripping all timing code from the build. This is precisely the mechanism we've just benchmarked and verified.

Test your understanding!

Question: Imagine that after running the benchmark with PROFILING_ENABLED=0, you got the following result:
BM_Baseline: 151 ns
BM_Instrumented: 154 ns

What would this imply about your #else case in the macro definition, and how would you fix it?

Show answer

This result would imply that your macro is not compiling away to true zero. Even when profiling is disabled, some residual code is being generated that costs ~3 ns. A common mistake is to define the disabled macro as an empty function call, like do_nothing(). Even though the function is empty, the call itself has a small overhead.

The fix would be to ensure the macro expands to absolutely nothing, as in our design:
#define PROFILE_SCOPE(name)
This ensures the preprocessor removes the entire line from the code before the compiler even sees it, guaranteeing zero overhead.



Conclusion

Congratulations! You have successfully executed a professional-grade microbenchmark and, most importantly, interpreted the results. You've now quantified the observer effect for your placeholder instrumentation and empirically proven that it can be completely removed from a build. This is a crucial milestone for your telemetry engine project.

Key Takeaways:

  • You can now use CMake to configure, build, and link a project with an external dependency like Google Benchmark.
  • The observer effect is not just a theoretical concept; you measured it to be approximately 16 ns, a figure competitive with industry-standard tools.
  • You verified that using preprocessor flags is an effective strategy to achieve true zero-overhead instrumentation when profiling is disabled.
  • The process of benchmarking is iterative: compile, run, analyze, and then repeat for a different configuration.

What's Next?

This lesson concludes our deep dive into benchmarking and performance verification. You've built and tested the core data structures and now have a solid methodology for measuring their performance.

We are now ready to begin the final module of the course: Integrating and Polishing the Final Application. In our next lesson, we will begin connecting the pieces. The learning outcome will be: Connect the telemetry capture pipeline's consumer thread to the plotter's data storage. We will finally take the data from our lock-free SPSC queues and feed it into the data structures needed by our ImGui plotter, taking the first step toward visualizing our captured events.