Ir al contenido

Alineacion, Padding y Lineas de Cache

Comprende los requisitos de alineacion de tipos, el padding de structs, las lineas de cache, el false sharing (comparticion falsa), los bit fields y [[no_unique_address]].

Requisitos de Alineacion

Cada tipo en C++ tiene un requisito de alineacion — la direccion donde un objeto de ese tipo debe ser colocado debe ser un multiplo de su alineacion. Este es un requisito de hardware: la mayoria de las CPUs no pueden acceder eficientemente a datos desalineados, y algunas arquitecturas (ARM, SPARC antiguo) fallaran con acceso desalineado.

Alineaciones comunes en x86-64:
- char: 1 byte
- short: 2 bytes
- int, float: 4 bytes
- double, long long, punteros: 8 bytes
- long double: 16 bytes (en algunas plataformas)

La alineacion de un struct es la mayor alineacion de cualquiera de sus miembros. El compilador inserta bytes de padding entre miembros y al final del struct para satisfacer los requisitos de alineacion.

Padding de Structs en la Practica

El compilador inserta padding invisible para alinear cada miembro. El orden de los miembros puede afectar dramaticamente el tamano del struct.

struct_padding.cpp
#include <iostream>
#include <cstddef>  // offsetof

// BAD: wasteful ordering — 24 bytes
struct BadLayout {
    char a;       // offset 0, size 1
    // 7 bytes padding (align double to 8)
    double b;     // offset 8, size 8
    char c;       // offset 16, size 1
    // 3 bytes padding (align int to 4)
    int d;        // offset 20, size 4
    // 0 bytes tail padding
};  // total: 24 bytes

// GOOD: optimized ordering — 16 bytes
struct GoodLayout {
    double b;     // offset 0, size 8
    int d;        // offset 8, size 4
    char a;       // offset 12, size 1
    char c;       // offset 13, size 1
    // 2 bytes tail padding (struct alignment = 8)
};  // total: 16 bytes

int main() {
    std::cout << "sizeof(BadLayout):  " << sizeof(BadLayout)  << '\n'; // 24
    std::cout << "sizeof(GoodLayout): " << sizeof(GoodLayout) << '\n'; // 16

    // Verify offsets
    std::cout << "BadLayout::a offset: " << offsetof(BadLayout, a) << '\n';  // 0
    std::cout << "BadLayout::b offset: " << offsetof(BadLayout, b) << '\n';  // 8
    std::cout << "BadLayout::c offset: " << offsetof(BadLayout, c) << '\n';  // 16
    std::cout << "BadLayout::d offset: " << offsetof(BadLayout, d) << '\n';  // 20

    // Savings: 33% smaller per object
    // With 1 million objects: 8 MB saved
}

alignas y alignof

alignof(T) consulta la alineacion de un tipo. alignas(N) solicita una alineacion especifica para una variable o tipo. Estos son esenciales para SIMD, buffers DMA y alineacion a linea de cache.

alignas_alignof.cpp
#include <iostream>
#include <new>  // std::hardware_destructive_interference_size

// Query alignment
static_assert(alignof(int) == 4);
static_assert(alignof(double) == 8);

// Force alignment
struct alignas(64) CacheAligned {
    int data[16];  // 64 bytes of data on a 64-byte boundary
};

static_assert(alignof(CacheAligned) == 64);
static_assert(sizeof(CacheAligned) == 64);

// Align a variable
alignas(256) char simd_buffer[256];

// Over-aligned allocation (C++17)
struct alignas(128) BigAlign {
    double values[8];
};

int main() {
    // C++17: hardware_destructive_interference_size
    // Minimum offset to avoid false sharing (typically 64 on x86)
    // NOTE: not available on all compilers
    // std::cout << std::hardware_destructive_interference_size << '\n';

    auto* p = new CacheAligned;  // C++17 guarantees aligned allocation
    std::cout << "alignof(CacheAligned): " << alignof(CacheAligned) << '\n';
    std::cout << "address: " << p << '\n';
    std::cout << "aligned? " << (reinterpret_cast<std::uintptr_t>(p) % 64 == 0
                                  ? "yes" : "no") << '\n';
    delete p;
}

Lineas de Cache y False Sharing

Las CPUs modernas no acceden a la memoria byte por byte — cargan lineas de cache completas (tipicamente 64 bytes en x86). Cuando dos hilos modifican variables que resultan estar en la misma linea de cache, el hardware debe constantemente invalidar y recargar esa linea entre nucleos. Esto se llama false sharing (comparticion falsa), y puede reducir el rendimiento multi-hilo de 10 a 100 veces.

El false sharing ocurre cuando:
- Dos hilos escriben en diferentes variables
- Esas variables estan en la misma linea de cache de 64 bytes
- El protocolo de coherencia de cache de la CPU (MESI) fuerza el constante rebote de lineas de cache

La solucion: aplica padding o alinea las variables calientes a lineas de cache separadas. Usa alignas(64) o std::hardware_destructive_interference_size (C++17, pero no soportado en todos los compiladores).

Inversamente, true sharing (comparticion verdadera) es cuando multiples variables relacionadas se acceden juntas. Colocarlas en la misma linea de cache mejora el rendimiento — esto es std::hardware_constructive_interference_size.

Ejemplo y Solucion de False Sharing

Este ejemplo demuestra false sharing: dos contadores en la misma linea de cache causan una desaceleracion masiva cuando son incrementados por diferentes hilos.

false_sharing.cpp
#include <atomic>
#include <thread>
#include <chrono>
#include <iostream>

constexpr int ITERATIONS = 100'000'000;

// BAD: counters on the same cache line — false sharing
struct BadCounters {
    std::atomic<int> counter1{0};
    std::atomic<int> counter2{0};  // likely on same 64-byte cache line
};

// GOOD: each counter on its own cache line
struct GoodCounters {
    alignas(64) std::atomic<int> counter1{0};
    alignas(64) std::atomic<int> counter2{0};  // guaranteed separate cache lines
};

template <typename Counters>
void benchmark(const char* label) {
    Counters c;
    auto start = std::chrono::high_resolution_clock::now();

    std::thread t1([&] { for (int i = 0; i < ITERATIONS; ++i) ++c.counter1; });
    std::thread t2([&] { for (int i = 0; i < ITERATIONS; ++i) ++c.counter2; });
    t1.join();
    t2.join();

    auto elapsed = std::chrono::high_resolution_clock::now() - start;
    std::cout << label << ": "
              << std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count()
              << " ms\n";
}

int main() {
    std::cout << "sizeof(BadCounters):  " << sizeof(BadCounters) << '\n';   // 8
    std::cout << "sizeof(GoodCounters): " << sizeof(GoodCounters) << '\n';  // 128
    benchmark<BadCounters>("False sharing  ");
    benchmark<GoodCounters>("No false sharing");
}

Bit Fields y [[no_unique_address]] (C++20)

Los bit fields (campos de bits) especifican el numero exacto de bits que un miembro ocupa — util para registros de hardware y headers de protocolos, pero su disposicion esta definida por la implementacion. [[no_unique_address]] (C++20) permite que miembros vacios (deleters sin estado, asignadores) ocupen cero bytes solapandose con otros miembros, reemplazando el patron antiguo de Optimizacion de Base Vacia (Empty Base Optimization).

bit_fields_no_unique_address.cpp
#include <iostream>
#include <cstdint>

// Bit fields: pack flags tightly
struct TCPFlags {
    uint8_t fin : 1;
    uint8_t syn : 1;
    uint8_t rst : 1;
    uint8_t psh : 1;
    uint8_t ack : 1;
    uint8_t urg : 1;
    uint8_t ece : 1;
    uint8_t cwr : 1;
};
static_assert(sizeof(TCPFlags) == 1);  // 8 flags in 1 byte

// [[no_unique_address]]: eliminate empty member overhead
struct Empty {};
struct Before { int x; Empty e; };           // sizeof == 8 (padding)
struct After  { int x; [[no_unique_address]] Empty e; }; // sizeof == 4
static_assert(sizeof(Before) == 8);
static_assert(sizeof(After) == 4);

int main() {
    TCPFlags tcp{};
    tcp.syn = 1;
    tcp.ack = 1;
    std::cout << "SYN: " << tcp.syn << ", ACK: " << tcp.ack << '\n';
    // Cannot take address of bit field: &tcp.syn is a compiler error

    std::cout << "Before: " << sizeof(Before) << '\n';  // 8
    std::cout << "After:  " << sizeof(After)  << '\n';  // 4
}
Error común

1. Nunca uses memcpy para serializar structs entre plataformas — el padding y la alineacion de miembros difieren entre compiladores y arquitecturas.

2. Portabilidad de bit fields: El orden de los bits dentro de un byte y el padding entre bit fields estan definidos por la implementacion. No uses bit fields para formatos de cable entre plataformas — usa manipulacion explicita de bits.

3. #pragma pack no es estandar: Empaquetar structs para eliminar el padding puede causar acceso desalineado (perdida de rendimiento o fallos en arquitecturas de alineacion estricta como ARM).

4. Ordena los miembros de mayor a menor: double, punteros (8 bytes), luego int (4 bytes), luego short (2 bytes), luego char (1 byte). Esto minimiza el padding interno. Para structs calientes, usa static_assert(sizeof(MyStruct) <= 64) para forzar tamanos compatibles con lineas de cache.

Puntos clave
  • Cada tipo tiene un requisito de alineacion — el compilador inserta padding para satisfacerlo
  • Ordena los miembros de mayor a menor alineacion para minimizar el padding
  • Las lineas de cache son de 64 bytes en x86 — el false sharing entre hilos es una trampa de rendimiento importante
  • Usa alignas(64) para colocar datos por hilo en lineas de cache separadas
  • [[no_unique_address]] (C++20) elimina la sobrecarga de miembros vacios
  • Los bit fields empaquetan datos compactamente pero tienen disposicion definida por la implementacion — no los uses para formatos de cable

Quiz — Pon a prueba tus conocimientos

(15 XP)

1. Dado `struct S { char a; double b; char c; };`, cual es el sizeof(S) probable en una plataforma de 64 bits?

2. Que es false sharing?

3. Que hace `[[no_unique_address]]` (C++20)?