GSoC Week 4: Best Practices for Integrating Optional Third-Party Libraries in C++: A Case Study with the Tracy Profiler

4 minute read

Published:

As part of my work on the SU2 CFD software suite for Google Summer of Code, I have been focused on performance analysis and optimization. A critical aspect of this work is the integration of profiling tools to identify performance bottlenecks. This article continues from the previous post about integrating the Tracy profiler into a large, mature C++ codebase, highlighting the importance of using software engineering best practices for maintainability and robustness.

The Challenge: Integrating an Optional Dependency

Tracy is a powerful, frame-by-frame profiler that requires code to be manually annotated (or “instrumented”) to measure the execution time of specific scopes. The most direct method of integration is to include the main Tracy header and use its macros within the functions of interest.

For example, to profile the Predict_MLP function in CDataDrivenFluid.cpp, one could do the following:

  1. Add an include directive for the Tracy header:
    #include <tracy/Tracy.hpp>
    
  2. Insert the annotation macro at the beginning of the target function:
    void CDataDrivenFluid::Predict_MLP(...) {
        ZoneScopedN("Predict_MLP"); // Tracy annotation
        // ... function logic ...
    }
    

While this approach is functionally correct, it presents significant challenges in the context of a large, collaborative project like SU2. The primary issue is that Tracy is an optional dependency. Not all developers will have it installed, and it should not be required for a standard build. This direct integration method would cause compilation to fail for any user who has not explicitly set up the Tracy library.

This necessitates a more robust solution that allows for the feature to be enabled or disabled at compile time.

The Role of the C++ Preprocessor

The C++ preprocessor is a tool that parses source code and processes instructions (known as directives) before the code is passed to the compiler. While commonly used for including headers (#include), it also provides powerful capabilities for conditional compilation.

Three directives are key to our solution:

  1. #define: This directive is used to define a macro, which is essentially a named fragment of code. The preprocessor will substitute the macro’s name with its defined value throughout the code.

  2. #ifdef <macro>: This checks if a given macro has been defined. This is typically done via a compiler flag (e.g., g++ -D<macro>).

  3. #else and #endif: These work with #ifdef to create conditional blocks, allowing the preprocessor to include or ignore sections of code based on the defined macros.

By combining these directives, we can instruct the preprocessor to alter the source code based on the build configuration, effectively including or removing the Tracy annotations before the compiler even sees them.

The Solution: A Centralized Wrapper Header

To manage the optional integration cleanly, we can create a single, centralized header file—a “wrapper”—that contains all the logic for the Tracy integration. This practice isolates the third-party dependency and provides a consistent interface for the rest of the project.

We created the file tracy_structure.hpp with the following content:

// /home/divyaprakash/SU2/Common/include/tracy_structure.hpp

#pragma once

// Check if the TRACY_ENABLE macro has been defined by the build system.
#ifdef TRACY_ENABLE
  // If defined, the project is being compiled with profiling enabled.

  // 1. Include the actual Tracy header to make its functionality available.
#  include "tracy/Tracy.hpp"

  // 2. Define project-specific macros as aliases for the real Tracy macros.
#  define SU2_ZONE_SCOPED ZoneScoped
#  define SU2_ZONE_SCOPED_N(name) ZoneScopedN(name)

#else
  // If not defined, the project is being compiled without profiling.

  // 1. Do NOT include the Tracy header.

  // 2. Define the project-specific macros as empty statements.
#  define SU2_ZONE_SCOPED
#  define SU2_ZONE_SCOPED_N(name)

#endif // End of the conditional compilation block.

This approach has several key advantages:

  • Modularity: All logic related to Tracy is contained within this single file.
  • Maintainability: If the project ever moves to a different profiler, only this wrapper file needs to be significantly modified.
  • Robustness: The code now compiles successfully for all users, regardless of whether they have Tracy enabled. When disabled, the macros compile to nothing, incurring zero performance overhead.

Final Implementation

With the wrapper in place, the modification to the target source file, CDataDrivenFluid.cpp, becomes clean and maintainable.

Before:

#include <tracy/Tracy.hpp>
// ...
void CDataDrivenFluid::Predict_MLP(...) {
    ZoneScopedN("Predict_MLP");
    // ...
}

After:

#include "../../../Common/include/tracy_structure.hpp" // Include the wrapper
// ...
void CDataDrivenFluid::Predict_MLP(...) {
    SU2_ZONE_SCOPED_N("Predict_MLP"); // Use the project-specific macro
    // ...
}

This implementation successfully integrates the Tracy profiler while adhering to best practices for software engineering in a large-scale C++ project. By leveraging the preprocessor to create a centralized wrapper, we ensure that the addition of this optional feature does not compromise the stability or cleanliness of the core codebase.

Leave a Comment