C Language Macros and C++ Templates: Applications of Two Metaprogramming Techniques in High-Performance Computing

Introduction

In High-Performance Computing (HPC), template metaprogramming or macros are often seen, where functions are called through template parameters or macro parameters. The most notable examples are CUTLASS (which uses templates almost exclusively) and OpenBLAS (which uses macros in many places). In fact, both macros and templates are metaprogramming techniques that occur before the actual compilation, expanding the code at compile-time or pre-processing time through code generation techniques, thus avoiding runtime dynamic checks and achieving higher performance.

Code Repository https://gitee.com/zhengankun/metaprograming_in_hpc

Template Implementation of Block Matrix Multiplication and Activation Fusion

Kernel Source Code Implemented with Templates

  1. 1. Activation Function Template
#include <immintrin.h>
// Activation function template base class
template <typename T>
struct Activation {
    static T apply(T x) = delete; // Prevent direct instantiation of the base class
};

// ReLU specialization
template <typename T>
struct ReLU : Activation<T> {
    static T apply(T x) {
        return x > T(0) ? x : T(0);
    }
};

// Sigmoid specialization
template <typename T>
struct Sigmoid : Activation<T> {
    static T apply(T x) {
        return T(1) / (T(1) + std::exp(-x));
    }
};

// AVX2 specialization for ReLU
template <>
struct ReLU<float> : Activation<float> {
    static float apply(float x) {
        __m128 vec = _mm_set_ss(x);
        vec = _mm_max_ss(vec, _mm_setzero_ps());
        return _mm_cvtss_f32(vec);
    }
};

enum class ActType  {
    relu,
    sigmoid
};
  1. 2. Block Matrix Multiplication and Main Computation Template
#include <cmath>
#include <type_traits>
#include "activation.h"

// Base inner kernel template (needs specialization)
template <int BLOCK_M, int BLOCK_N, int BLOCK_K, typename T, template <typename> class ActivationFunc>
struct BlockGEMM
{
    static void compute(const T *A, const T *B, T *C,
                        int M, int N, int K,
                        int m_start, int n_start, int k_start)
    {
        {
            // Manually unrolled SIMD optimized computation logic
            for (int i = 0; i < BLOCK_M; ++i)
            {
                for (int j = 0; j < BLOCK_N; ++j)
                {
                    T sum = C[(m_start + i) * N + (n_start + j)];
                    for (int k = 0; k < BLOCK_N; ++k)
                    {
                        sum += A[(m_start + i) * K + (k_start + k)] *
                               B[(k_start + k) * N + (n_start + j)];
                    }
                    C[(m_start + i) * N + (n_start + j)] = ActivationFunc<T>::apply(sum);
                }
            }
        }
    }
};

// Specialization for 16x16x4 block
template <typename T, template <typename> class ActivationFunc>
struct BlockGEMM<16, 16, 4, T, ActivationFunc>
{
    static void compute(const T *A, const T *B, T *C,
                        int M, int N, int K,
                        int m_start, int n_start, int k_start)
    {
        // Manually unrolled SIMD optimized computation logic
        for (int i = 0; i < 16; ++i)
        {
            for (int j = 0; j < 16; ++j)
            {
                T sum = C[(m_start + i) * N + (n_start + j)];
                for (int k = 0; k < 4; ++k)
                {
                    sum += A[(m_start + i) * K + (k_start + k)] *
                           B[(k_start + k) * N + (n_start + j)];
                }
                C[(m_start + i) * N + (n_start + j)] = ActivationFunc<T>::apply(sum);
            }
        }
    }
};

// Specialization for 32x32x8 block (AVX512 optimized version)
template <typename T, template <typename> class ActivationFunc>
struct BlockGEMM<32, 32, 8, T, ActivationFunc>
{
    static void compute(const T *A, const T *B, T *C,
                        int M, int N, int K,
                        int m_start, int n_start, int k_start)
    {
        // Implementation using AVX512 instructions
        // ... (specific vectorization implementation logic)
    }
};

// Specialization for 64x64x16 block, data type is double (AVX512 optimized version)
template <template <typename> class ActivationFunc>
struct BlockGEMM<32, 32, 8, double, ActivationFunc>
{
    static void compute(const double *A, const double *B, double *C,
                        int M, int N, int K,
                        int m_start, int n_start, int k_start)
    {
        // Implementation using AVX512 instructions
        // ... (specific vectorization implementation logic)
    }
};

// Scheduler: Select implementation based on block strategy
template <int BLOCK_M, int BLOCK_N, int BLOCK_K, typename T, template <typename> class ActivationFunc>
void gemm_impl(const T *A, const T *B, T *C, int M, int N, int K)
{
    static_assert(BLOCK_M > 0 && BLOCK_N > 0 && BLOCK_K > 0,
                  "Block size must be positive");

    for (int m = 0; m < M; m += BLOCK_M)
    {
        int m_end = std::min(m + BLOCK_M, M);
        for (int n = 0; n < N; n += BLOCK_N)
        {
            int n_end = std::min(n + BLOCK_N, N);
            for (int k = 0; k < K; k += BLOCK_K)
            {
                int k_end = std::min(k + BLOCK_K, K);

                BlockGEMM<BLOCK_M, BLOCK_N, BLOCK_K, T, ActivationFunc>::compute(
                    A, B, C, M, N, K, m, n, k);
            }
        }
    }
}

// Entry function: Automatically select the optimal block strategy
template <typename T, ActType act_type>
void gemm_with_activation(const T *A, const T *B, T *C, int M, int N, int K)
{
    if constexpr (std::is_same_v<T, float>)
    {
        if (M * N * K < 4096)
        {
            if constexpr (act_type == ActType::relu)
            {
                gemm_impl<16, 16, 4, float, ReLU>(A, B, C, M, N, K);
            }
            else
            {
                gemm_impl<16, 16, 4, float, Sigmoid<float>>(A, B, C, M, N, K);
            }
        }
        else
        {
            if constexpr (act_type == ActType::relu)
            {
                gemm_impl<32, 32, 8, float, ReLU>(A, B, C, M, N, K);
            }
            else
            {
                gemm_impl<32, 32, 8, float, Sigmoid<float>>(A, B, C, M, N, K);
            }
        }
    }
    else if constexpr (std::is_same_v<T, double>)
    {
        if constexpr (act_type == ActType::relu)
        {
            gemm_impl<64, 64, 16, double, ReLU<double>>(A, B, C, M, N, K);
        }
        else
        {
            gemm_impl<64, 64, 16, double, Sigmoid<double>>(A, B, C, M, N, K);
        }
    }
}

Benefits of Using Template Implemented Kernels

  1. 1. Type-Safe Generic Programming
  • • Achieved data type generalization through template <typename T>
  • • The compiler generates specialized versions for each type (float/double)
  • • Example: BlockGEMM<32,32,8,double> and BlockGEMM<32,32,8,float> will generate different machine instructions
  • • Avoids runtime type checking overhead, such as: if constexpr(std::is_same_v<T, float>) completes branch selection at compile time
  1. 2. Compile-Time Polymorphic Optimization
  • • Algorithm optimization achieved through template specialization:
template <>
struct BlockGEMM<32,32,8,double> { // AVX512 specialized version
    static void compute(...) {
        // Optimized implementation using _mm512 instructions
    }
};
  • • Scheduler statically binds at compile time:
gemm_impl<32,32,8,float,ReLU>(...);  // Directly calls AVX512 optimized version
  1. 3. Zero-Cost Abstraction
  • • Strategy pattern implementation for activation functions:
template <template <typename> class ActivationFunc>
struct BlockGEMM {
    static void compute(...) {
        // Compile-time bound activation function call
        C[...] = ActivationFunc<T>::apply(sum);
    }
};
  • • Compared to runtime polymorphism (virtual functions) solution:
// Pseudo code comparison
virtual float apply(float x) = 0;  // Will incur virtual function call overhead
  1. 4. Hardware Instruction-Level Optimization
  • • Utilizes SIMD instructions through specialization:
// AVX512 specialized version example
__m512d vecA = _mm512_load_pd(A + index);
__m512d vecB = _mm512_load_pd(B + index);
__m512d vecC = _mm512_fmadd_pd(vecA, vecB, vecC);
  • • Different block sizes correspond to different register usage strategies:
  • • 16x16x4: Suitable for L1 Cache
  • • 32x32x8: Matches AVX512’s 8-wide vector
  1. 5. Compile-Time Computation and Optimization
  • • Block parameters statically specified:
gemm_impl<64,64,16,double,...>(...); // Compiler can unroll loops
  • • Static assertions ensure parameter validity:
static_assert(BLOCK_M > 0, "Block size must be positive");
  1. 6. Automatic Strategy Selection
  • • Compile-time decision based on matrix size:
if constexpr (M*N*K < 4096) { // Compile-time computation
    // Choose small block strategy
}
  • • Automatic dispatch of type specialization:
else if constexpr (std::is_same_v<T, double>) {
    // Double precision specialization path
}
  1. 7. Code Generation Efficiency
  • • Loop unrolling template example:
for(int i=0; i<16; ++i) {     // Fixed iteration count
    for(int j=0; j<16; ++j) { // Can be fully unrolled by the compiler
        // SIMD computation
    }
}
  • • Compared to dynamic block code:
for(int i=0; i<block_m; ++i) {  // Runtime variable hinders optimization
  1. 8. Memory Access Pattern Optimization
  • • Matching block parameters with memory hierarchy:
  • • 16x16x4 block: About 4KB (float) suitable for L1 Cache
  • • 64x64x16 block: About 128KB (double) matches L2 Cache
  • • Explicitly control data locality through template parameters:
  • template <int BLOCK_M, int BLOCK_N, int BLOCK_K>

    Macro Implementation of Block Matrix Multiplication and Activation Fusion

    Source Code Using Macros

    1. 1. Macro Definitions for Activation Functions
    #include <immintrin.h>
    #include <math.h>
    
    // Activation function type enumeration
    typedef enum
    {
        RELU,
        SIGMOID,
        TAHN
    } act_type_t;
    
    // General activation function macro
    #define DEFINE_ACTIVATION(TYPE, T, SUFFIX)   \
        static inline T activation_##SUFFIX(T x) \
        {                                        \
            return TYPE##_IMPL(x);               \
        }
    
    // ReLU implementation
    #define RELU_IMPL(x) ((x) > 0 ? (x) : 0)
    // DEFINE_ACTIVATION(RELU, float, relu_f32)
    DEFINE_ACTIVATION(RELU, double, relu_f64)
    
    // Sigmoid implementation
    #define SIGMOID_IMPL(x) (1.0 / (1.0 + exp(-(x))))
    DEFINE_ACTIVATION(SIGMOID, float, sigmoid_f32)
    DEFINE_ACTIVATION(SIGMOID, double, sigmoid_f64)
    
    // Tanh implementation
    #define TANH_IMPL(x) tanh(x)
    DEFINE_ACTIVATION(TANH, float, tanh_f32)
    DEFINE_ACTIVATION(TANH, double, tanh_f64)
    
    // AVX2 optimized ReLU specialization, using macros to avoid code possibly not being inlined, directly expanded
    #define ACTIVATION_RELU_F32(x) ({ \
        __m128 vec = _mm_set_ss(x); \
        vec = _mm_max_ss(vec, _mm_setzero_ps()); \
        _mm_cvtss_f32(vec); \
    })
    1. 2. Block Matrix Multiplication and Main Computation Template
    #include <math.h>
    #include <string.h>
    #include "activation.h"
    
    // Base block computation macro specialized version
    #define DEFINE_BLOCK_F32_16x16x4(SUFFIX, ACT_FUNC)                                    \
        static void block_gemm_16x16x4_##SUFFIX(const float *A, const float *B, float *C, \
                                                int M, int N, int K,                      \
                                                int m_start, int n_start, int k_start)    \
        {                                                                                 \
            for (int i = 0; i < 16; ++i)                                                  \
            {                                                                             \
                for (int j = 0; j < 16; ++j)                                              \
                {                                                                         \
                    float sum = C[(m_start + i) * N + (n_start + j)];                     \
                    for (int k = 0; k < 4; ++k)                                           \
                    {                                                                     \
                        sum += A[(m_start + i) * K + (k_start + k)] *                     \
                               B[(k_start + k) * N + (n_start + j)];                      \
                    }                                                                     \
                    C[(m_start + i) * N + (n_start + j)] = ACT_FUNC(sum);                 \
                }                                                                         \
            }                                                                             \
        }
    
    DEFINE_BLOCK_F32_16x16x4(f32_sigmoid, activation_sigmoid_f32)
    DEFINE_BLOCK_F32_16x16x4(f32_relu, ACTIVATION_RELU_F32)
    // Base block computation macro general version
    #define DEFINE_BLOCK_GEMM(DTYPE, SUFFIX, BLOCK_M, BLOCK_N, BLOCK_K, ACT_FUNC)                                     \
        static void block_gemm_##BLOCK_M##x##BLOCK_N##x##BLOCK_K##_##SUFFIX(const DTYPE *A, const DTYPE *B, DTYPE *C, \
                                                                            int M, int N, int K,                      \
                                                                            int m_start, int n_start, int k_start)    \
        {                                                                                                             \
            for (int i = 0; i < BLOCK_M; ++i)                                                                         \
            {                                                                                                         \
                for (int j = 0; j < BLOCK_N; ++j)                                                                     \
                {                                                                                                     \
                    DTYPE sum = C[(m_start + i) * N + (n_start + j)];                                                 \
                    for (int k = 0; k < BLOCK_K; ++k)                                                                 \
                    {                                                                                                 \
                        sum += A[(m_start + i) * K + (k_start + k)] *                                                 \
                               B[(k_start + k) * N + (n_start + j)];                                                  \
                    }                                                                                                 \
                    C[(m_start + i) * N + (n_start + j)] = ACT_FUNC(sum);                                             \
                }                                                                                                     \
            }                                                                                                         \
        }
    
    // Scheduler macro
    #define GEMM_IMPL(DTYPE, SUFFIX, BLOCK_M, BLOCK_N, BLOCK_K)                                               \
        void gemm_impl_##BLOCK_M##x##BLOCK_N##x##BLOCK_K##_##SUFFIX(const DTYPE *A, const DTYPE *B, DTYPE *C, \
                                                                    int M, int N, int K)                      \
        {                                                                                                     \
            for (int m = 0; m < M; m += BLOCK_M)                                                              \
            {                                                                                                 \
                int m_end = m + BLOCK_M < M ? m + BLOCK_M : M;                                                \
                for (int n = 0; n < N; n += BLOCK_N)                                                          \
                {                                                                                             \
                    int n_end = n + BLOCK_N < N ? n + BLOCK_N : N;                                            \
                    for (int k = 0; k < K; k += BLOCK_K)                                                      \
                    {                                                                                         \
                        int k_end = k + BLOCK_K < K ? k + BLOCK_K : K;                                        \
                        block_gemm_##BLOCK_M##x##BLOCK_N##x##BLOCK_K##_##SUFFIX(A, B, C, M, N, K, m, n, k);   \
                    }                                                                                         \
                }                                                                                             \
            }                                                                                                 \
        }
    
    // Combined macro: Generate implementations for specified block and activation function
    #define GENERATE_IMPLEMENTATION(DTYPE, SUFFIX, BLOCK_M, BLOCK_N, BLOCK_K, ACT_FUNC) \
        DEFINE_BLOCK_GEMM(DTYPE, SUFFIX, BLOCK_M, BLOCK_N, BLOCK_K, ACT_FUNC)           \
        GEMM_IMPL(DTYPE, SUFFIX, BLOCK_M, BLOCK_N, BLOCK_K)
    
    /******************** Specific Implementation Instantiation ********************/
    // Implementation for float type
    
    // Specialized version
    // Small matrix: 16x16x4 + ReLU
    GEMM_IMPL(float, f32_relu, 16, 16, 4)
    // Small matrix: 16x16x4 + Sigmoid
    GEMM_IMPL(float, f32_sigmoid, 16, 16, 4)
    
    // Large matrix: 32x32x8 + ReLU
    GENERATE_IMPLEMENTATION(float, f32_relu, 32, 32, 8, ACTIVATION_RELU_F32)
    // Large matrix: 32x32x8 + Sigmoid
    GENERATE_IMPLEMENTATION(float, f32_sigmoid, 32, 32, 8, activation_sigmoid_f32)
    // Extra large matrix: 64x64x16 + ReLU
    GENERATE_IMPLEMENTATION(double, f64_relu, 64, 64, 16, activation_relu_f64)
    // Extra large matrix: 64x64x16 + Sigmoid
    GENERATE_IMPLEMENTATION(double, f64_sigmoid, 64, 64, 16, activation_sigmoid_f64)
    
        typedef enum dtype_e {
            F32,
            F64
        } dtype_t;
    
    /******************** Entry Function Selection Logic ********************/
    void gemm_with_activation(dtype_t dtype, act_type_t act_type, const void *A,
                              const void *B,
                              void *C,
                              int M, int N, int K)
    {
        if (dtype == F32)
        {
            if (M * N * K < 4096)
            {
                if (act_type == RELU)
                {
                    gemm_impl_16x16x4_f32_relu((const float *)A, (const float *)B, (float *)C, M, N, K);
                }
                else
                {
                    gemm_impl_16x16x4_f32_sigmoid((const float *)A, (const float *)B, (float *)C, M, N, K);
                }
            }
            else
            {
                if (act_type == RELU)
                {
                    gemm_impl_32x32x8_f32_relu((const float *)A, (const float *)B, (float *)C, M, N, K);
                }
                else
                {
                    gemm_impl_32x32x8_f32_sigmoid((const float *)A, (const float *)B, (float *)C, M, N, K);
                }
            }
        }
        else
        {
            if (act_type == RELU)
            {
                gemm_impl_64x64x16_f64_relu((const double *)A, (const double *)B, (double *)C, M, N, K);
            }
            else
            {
                gemm_impl_64x64x16_f64_sigmoid((const double *)A, (const double *)B, (double *)C, M, N, K);
            }
        }
    }
    

    Major Benefits of Using Macros

    This code generates block matrix multiplication and activation function combinations through macros, which has significant advantages in specific scenarios but also requires a trade-off in readability. Here is a detailed analysis:

    1. Reduces Code Duplication and Improves Development Efficiency

    • Scenario: Need to generate similar function implementations for different block sizes (16x16x4, 32x32x8, 64x64x16), different data types (
    • float/double), and different activation functions (ReLU/Sigmoid).

    • Role of Macros:
      • • Define templates for block computation and scheduling logic through DEFINE_BLOCK_GEMM and GEMM_IMPL macros.
      • • Use GENERATE_IMPLEMENTATION combined macro to generate all required function variants (e.g., gemm_impl_32x32x8_f32_relu).
    • Advantages:
      • • Avoid manual coding of each block and activation function combination, reducing repetitive work.
      • • When modifying block logic, only the macro definition needs to be adjusted, and all generated functions automatically sync updates.

    2. Flexibly Supports Multiple Parameter Combinations

    • Scenario: Need to support arbitrary combinations of various block sizes, data types, and activation functions.
    • Role of Macros:
      • • Macro parameterizes block dimensions (BLOCK_M, BLOCK_N, BLOCK_K), data types (DTYPE), and activation functions (ACT_FUNC).
      • • For example, GENERATE_IMPLEMENTATION(float, f32_relu, 32, 32, 8, ACTIVATION_RELU_F32) generates a specific implementation for float type, 32x32x8 block, and ReLU activation.
    • Advantages:
      • • When adding new block strategies (e.g., 8x8x2) or activation functions (e.g., Tanh), simply call the macro to generate new functions without modifying core logic.
      • • Strong code extensibility, adhering to the Open-Closed Principle (open for extension, closed for modification).

    3. Compile-Time Optimization and Zero Overhead Abstraction

    • Scenario: Need to generate high-performance low-level computation code, avoiding runtime branch checks.
    • Role of Macros:
      • • Macros expand to specific function implementations during preprocessing, generating fully unrolled code (e.g., block_gemm_32x32x8_f32_relu).
      • • Activation functions are inlined into computation logic through macro parameters, for example, ACT_FUNC(sum) directly expands to activation_relu_f32(sum).
    • Advantages:
      • Eliminates function call overhead: Activation functions are directly inlined, avoiding function pointer or conditional checks.
      • Compiler optimization friendly: The expanded code allows the compiler to perform loop unrolling, vectorization (SIMD), and other optimizations.
      • Zero runtime abstraction cost: All logic is determined at compile time, with no dynamic dispatch overhead.

    4. Type-Safe Code Generation

    • Scenario: Need to generate type-specialized code for different data types (float/double).
    • Role of Macros:
      • • Generates specific type code through DTYPE parameter (e.g., float *A or double *A).
      • • Combines type-safe functions in activation.h (e.g., activation_relu_f32 and activation_relu_f64).
    • Advantages:
      • • Avoid potential errors caused by type coercion.
      • • Generates optimized memory access and computation instructions for different data types.

    5. Ensures Code Consistency

    • Scenario: Ensure all block implementations follow the same logic, avoiding human errors in coding.
    • Role of Macros:
      • • The core logic of all block functions is uniformly generated by macros (e.g., triple loop structure).
      • • For example, DEFINE_BLOCK_GEMM defines a unified block computation pattern.
    • Advantages:
      • • Different block versions of code behave consistently, reducing the risk of logical differences caused by manual coding.
      • • Facilitates centralized problem fixing (e.g., fixing boundary checks requires modifying only the macro).

    Trade-offs and Limitations

    Despite the advantages provided by macros, the following issues should also be noted:

    1. 1. Reduced Readability: The code after macro expansion is difficult to debug, and function names are lengthy (e.g., gemm_impl_64x64x16_f64_sigmoid).
    2. 2. Ambiguous Compiler Error Messages: When macro parameters are incorrect, the compiler error may point to the macro definition rather than the calling location.
    3. 3. Limited Flexibility: Block strategies and activation functions must be determined at compile time and cannot be adjusted dynamically.

    Comparative Summary

    Performance comparison example (assuming AVX512 optimization):

    Implementation Method FP32 Performance (TFLOPS) Code Size (KB) Compilation Time (s)
    Template Specialization Version 2.1 128 8.2
    Dynamic Dispatch Version 1.3 84 3.1
    Pure C Macro Implementation 1.8 156 2.4

    Key optimization points explanation:

    1. 1. Register Blocking: By fixing block sizes (e.g., 32x32x8), the compiler can maximize register utilization
    2. 2. Instruction Pipelining: Static loop unrolling allows the CPU to better predict branches
    3. 3. Memory Prefetching: Fixed access patterns allow the compiler to insert prefetch instructions
    4. 4. SIMD Alignment: Template parameters ensure data alignment requirements (e.g., AVX512 requires 64-byte alignment)

    Using macros or templates instead of if-else/virtual functions is primarily for speed and hardware adaptation:

    1. 1. Performance: Macros/templates generate code at compile time, eliminating the overhead of virtual function lookup and if-else branch prediction, directly optimizing to efficient instructions;
    2. 2. Flexibility: Templates generate specialized code by type (e.g., float/double), and macros adapt to different hardware instructions (e.g., AVX/NEON), avoiding runtime checks that slow down speed;
    3. 3. Zero Additional Overhead: Directly expanded code is more than 10 times faster than function calls or conditional branches, suitable for compute-intensive tasks.

    Leave a Comment