Cache Locality y False Sharing en C++

La jerarquía de memoria es la frontera entre un código que escala y un código que estrangula el hardware. El procesador no interactúa con la RAM directamente de forma atómica; lo hace a través de cache lines (líneas de caché), que en la arquitectura x86 moderna suelen ser de 64 bytes. Cuando el CPU necesita un solo int, el controlador de memoria descarga los 64 bytes que lo rodean para maximizar la localidad espacial. Si el dato que sigue es el que necesitas, lo tienes casi instantáneamente en L1 (~4 ciclos). Si el siguiente dato está en una dirección lejana, el CPU debe esperar a la RAM (~100ns), un abismo temporal que detiene el pipeline de ejecución.

Esta arquitectura dicta cómo debemos estructurar nuestros datos. El diseño AoS (Array of Structs) es el estándar intuitivo: una lista de objetos donde cada objeto tiene sus campos juntos. Sin embargo, si un kernel solo necesita procesar un campo específico de millones de objetos (por ejemplo, solo la posición x), AoS desperdicia el ancho de banda de la caché cargando y, z y mass innecesariamente. El diseño SoA (Struct of Arrays), al agrupar todos los campos x en un array contiguo y todos los y en otro, permite que el hardware prefetcher detecte un patrón de acceso secuencial (stride-1) y traiga los datos a la caché antes de que los pidas, optimizando el uso del bus.

En entornos multihilo, surge un problema crítico: el false sharing. Ocurre cuando dos hilos modifican variables distintas que, por casualidad, residen en la misma línea de caché. Aunque lógicamente no hay contención, el protocolo de coherencia de caché (como MESI) obliga a invalidar la línea completa en los otros núcleos cada vez que uno escribe. Esto provoca un “cache line bouncing” que destruye el rendimiento. Para evitarlo, utilizamos alignas junto con std::hardware_destructive_interference_size [C++17] para asegurar que las variables críticas ocupen líneas de caché independientes.

#include <iostream>
#include <vector>
#include <thread>
#include <atomic>
#include <chrono>
#include <new> // std::hardware_destructive_interference_size

// Representación AoS (Array of Structs): Intuitiva pero poco eficiente para kernels específicos
struct ParticleAoS {
    float x, y, z;
    int id;
};

// Representación SoA (Struct of Arrays): Excelente para prefetching y SIMD
struct ParticleSoA {
    std::vector<float> x;
    std::vector<float> y;
    std::vector<float> z;
    std::vector<int> id;
};

// --- ESCENARIO DE FALSE SHARING ---

// Caso de error: Las variables están pegadas y probablemente en la misma cache line
struct BadCounter {
    std::atomic<int> thread1_count{0};
    std::atomic<int> thread2_count{0};
};

// Caso optimizado: Alineación para evitar false sharing
struct GoodCounter {
    // std::hardware_destructive_interference_size evita que las variables 
    // compartan la misma línea de caché (típicamente 64 bytes).
    alignas(std::hardware_destructive_interference_size) std::atomic<int> thread1_count{0};
    alignas(std::hardware_destructive_interference_size) std::atomic<int> thread2_count{0};
};

template<typename T>
void work(T& counters, int iterations) {
    // Simulamos carga de trabajo intensiva con atomics
    for (int i = 0; i < iterations; ++i) {
        // En BadCounter, esto causa invalidaciones constantes en otros núcleos
        // En GoodCounter, cada hilo trabaja en su propia línea de caché
    }
}

// Implementación específica para demostrar el efecto
void run_bad_counter(int iterations) {
    BadCounter counters;
    auto f1 = std::jthread([&]() {
        for (int i = 0; i < iterations; ++i) counters.thread1_count.fetch_add(1, std::memory_order_relaxed);
    });
    auto f2 = std::jthread([&]() {
        for (int i = 0; i < iterations; ++i) counters.thread2_count.fetch_add(1, std::memory_order_relaxed);
    });
}

void run_good_counter(int iterations) {
    GoodCounter counters;
    auto f1 = std::jthread([&]() {
        for (int i = 0; i < iterations; ++i) counters.thread1_count.fetch_add(1, std::memory_order_relaxed);
    });
    auto f2 = std::jthread([&]() {
        for (int i = 0; i < iterations; ++i) counters.thread2_count.fetch_add(1, std::memory_order_relaxed);
    });
}

int main() {
    const int iterations = 100'000'000;

    std::cout << "Iniciando benchmarks...\n";

    auto start = std::chrono::high_resolution_clock::now();
    run_bad_counter(iterations);
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> bad_duration = end - start;
    std::cout << "Bad Counter (False Sharing): " << bad_duration.count() << "s\n";

    start = std::chrono::high_resolution_clock::now();
    run_good_counter(iterations);
    end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> good_duration = end - start;
    std::cout << "Good Counter (Aligned):      " << good_duration.count() << "s\n";

    return 0;
}

Para compilar el ejemplo:
g++ -std=c++20 -O3 -Wall -Wextra -Wpedantic -o benchmark example.cpp

Análisis del código

En el ejemplo, BadCounter es el culpable de un cuello de botella invisible. Sus miembros thread1_count y thread2_count son de tipo std::atomic<int>, que ocupan 4 bytes cada uno. Como están declarados uno tras otro, el compilador los coloca en direcciones contiguas. En la mayoría de los sistemas, ambos residirán dentro de los mismos 64 bytes de una misma línea de caché. Cuando el hilo 1 ejecuta fetch_add, el núcleo debe tomar posesión exclusiva de esa línea de caché, invalidando la copia que tiene el núcleo 2. El núcleo 2, al intentar escribir, debe invalidar la del núcleo 1. Este ping-pong de señales de coherencia satura el bus de memoria.

La solución en GoodCounter utiliza alignas con std::hardware_destructive_interference_size [C++17]. Esto le indica al compilador que debe insertar padding (relleno) entre los miembros para asegurar que cada std::atomic comience en una nueva línea de caché. Al hacerlo, el hardware puede tratar cada contador de forma totalmente independiente; el núcleo 1 puede mantener su línea en estado “Modified” sin interferir con el núcleo 2.

En cuanto a la estructura de datos de partículas, si implementáramos una función que calcule la energía cinética usando solo la componente z, el ParticleSoA nos permitiría cargar múltiples valores z en una sola iteración de la caché (aprovechando el prefetcher), mientras que en ParticleAoS, la caché se llenaría con datos de x, y e id que el CPU ignorará, reduciendo la eficiencia efectiva de la memoria.

El error frecuente
El error más sutil en sistemas de alto rendimiento es el false sharing en estructuras de datos globales o en pools de objetos. Si diseñas una estructura de control para un motor de hilos donde cada hilo tiene su propio contador de estadísticas dentro de la misma struct, el rendimiento puede degradarse hasta en un orden de magnitud, incluso si los hilos no compiten por la misma variable. Este error es casi imposible de detectar con un debugger convencional porque el programa es lógicamente correcto y no presenta condiciones de carrera (data races), pero es extremadamente destructivo en sistemas de baja latencia. Si los resultados de tus benchmarks no coinciden con la escala teórica de tus núcleos, revisa la alineación de tus estructuras compartidas con std::hardware_destructive_interference_size.

124

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio