C++ 메모리 정렬: Alignment·Padding과 False Sharing이 성능에 미치는 영향

이 글의 핵심

구조체 크기를 멤버 크기의 합으로 가정하면 네트워크 패킷이나 파일 포맷을 다룰 때 플랫폼마다 결과가 달라집니다. 정렬되지 않은 접근이 느리거나 오류를 내는 이유, SIMD 연산에 정렬된 메모리가 필요한 경우, 게임 엔진의 데이터 지향 설계 사례를 통해 메모리 배치가 성능에 주는 영향을 설명합니다.

들어가며

메모리 정렬(Alignment) 은 CPU가 메모리를 효율적으로 읽고 쓰기 위해 요구하는 주소 경계입니다. 컴파일러는 구조체 멤버 사이에 패딩(Padding) 을 삽입해 정렬을 맞추며, 이는 메모리 크기와 성능에 직접적인 영향을 미칩니다.

정렬이 필요한 근본 이유는 CPU가 메모리를 바이트 단위가 아니라 정해진 크기의 덩어리로 읽기 때문입니다. 캐시는 64바이트 라인 단위로 메모리를 가져오고, 로드·스토어 장치는 자연 정렬된 값을 한 번의 접근으로 처리하도록 설계되어 있습니다. 4바이트 int가 4의 배수 주소에 있으면 절대로 두 캐시 라인에 걸치지 않으므로 항상 한 번에 읽을 수 있고, 원자적 연산도 보장하기 쉽습니다. 컴파일러가 구조체에 패딩을 넣는 것은 이 보장을 모든 멤버에 대해 지키기 위해서이며, 우리가 할 수 있는 일은 그 비용(늘어난 크기)을 멤버 배치로 줄이거나, 반대로 일부러 늘려(캐시 라인 정렬) 멀티스레드 성능을 얻는 것입니다.


메모리 정렬 기본

정렬이란?

CPU는 타입마다 읽기·쓰기가 허용되는 시작 주소(정렬 경계) 가 정해져 있습니다. 예를 들어, int는 4바이트 경계(주소가 4의 배수)에서 시작해야 효율적이며, 일부 CPU는 정렬되지 않은 접근을 금지하거나 성능 저하를 일으킵니다.

타입별 정렬 요구사항

#include <iostream>
using namespace std;
int main() {
    cout << "char: " << alignof(char) << endl;      // 1
    cout << "short: " << alignof(short) << endl;    // 2
    cout << "int: " << alignof(int) << endl;        // 4
    cout << "long: " << alignof(long) << endl;      // 4 (Windows) / 8 (Linux)
    cout << "double: " << alignof(double) << endl;  // 8
    cout << "int*: " << alignof(int*) << endl;      // 8 (64비트)
    
    return 0;
}

패딩이 생기는 이유

컴파일러는 각 멤버를 정렬 경계에 맞추기 위해 빈 바이트(패딩)를 삽입합니다.

struct Bad {
    char c;    // 주소 0 (1 byte)
    // 7 bytes padding (주소 1~7)
    double d;  // 주소 8 (8 bytes)
    int i;     // 주소 16 (4 bytes)
    // 4 bytes padding (주소 20~23, 구조체 크기를 8의 배수로)
};  // 총 24 bytes

끝부분의 패딩은 처음 보면 낭비처럼 보이지만 배열 때문에 반드시 필요합니다. Bad arr[2]에서 두 번째 원소는 첫 원소 바로 뒤에서 시작하는데, 구조체 크기가 20바이트라면 arr[1].d가 주소 28에 놓여 8의 배수가 아니게 됩니다. 그래서 구조체 크기는 항상 가장 큰 멤버 정렬의 배수로 올림되고, 구조체 자체의 정렬(alignof(Bad))도 가장 엄격한 멤버의 정렬과 같아집니다. 멤버 사이 패딩은 멤버 순서로 줄일 수 있지만 끝 패딩은 남은 바이트를 채울 작은 멤버를 추가하지 않는 한 없앨 수 없습니다. 패딩 바이트의 값은 정해지지 않으므로, 구조체를 memcmp로 비교하거나 해시하면 값이 같아도 다르다고 판정될 수 있다는 점도 기억해 두세요.

실전 구현

구조체 패딩 최적화

비효율적 배치

#include <iostream>
using namespace std;
struct Bad {
    char c;    // 1 byte
    // 7 bytes padding
    double d;  // 8 bytes
    int i;     // 4 bytes
    // 4 bytes padding
};  // 총 24 bytes
int main() {
    cout << "Bad: " << sizeof(Bad) << endl;  // 24
    
    return 0;
}

최적화 배치

struct Good {
    double d;  // 8 bytes
    int i;     // 4 bytes
    char c;    // 1 byte
    // 3 bytes padding
};  // 총 16 bytes
int main() {
    cout << "Good: " << sizeof(Good) << endl;  // 16
    
    return 0;
}

최적화 원칙

  1. 큰 타입을 먼저 배치 (double → int → char)
  2. 같은 크기 타입을 그룹화
  3. 패딩을 최소화
struct Best {
    double d1;  // 8 bytes
    double d2;  // 8 bytes
    int i1;     // 4 bytes
    int i2;     // 4 bytes
    char c1;    // 1 byte
    char c2;    // 1 byte
    char c3;    // 1 byte
    char c4;    // 1 byte
};  // 총 32 bytes (패딩 없음)
int main() {
    cout << "Best: " << sizeof(Best) << endl;  // 32

    return 0;
}

“큰 타입부터”라는 규칙은 크기를 줄이는 가장 단순한 방법이지만, 유일한 기준은 아닙니다. 함께 자주 읽히는 멤버를 가까이 두는 것(같은 캐시 라인에 들어가게)이 크기를 몇 바이트 줄이는 것보다 성능에 더 중요할 때가 있고, 공개 헤더의 구조체라면 순서를 바꾸는 것 자체가 ABI 변경이 됩니다. 현재 배치를 눈으로 계산하기보다 도구로 확인하는 편이 정확합니다. 리눅스에서는 pahole 도구가 디버그 정보를 읽어 멤버별 오프셋과 구멍(hole)을 보여 주고, Clang은 -Xclang -fdump-record-layouts로, GCC와 Clang은 -Wpadded로 패딩이 생기는 위치를 경고로 알려 줍니다.


alignas - 정렬 지정

시그니처:

alignas(alignment) type name;

구조체 정렬

#include <iostream>
using namespace std;
struct alignas(16) Aligned {
    int x;
    int y;
};
int main() {
    cout << "정렬: " << alignof(Aligned) << endl;  // 16
    cout << "크기: " << sizeof(Aligned) << endl;   // 16

    return 0;
}

alignas는 정렬을 늘릴 수만 있고 줄일 수는 없습니다. alignas(1) int x;처럼 타입의 기본 정렬보다 작은 값을 주면 컴파일 에러가 나며, 정렬을 줄이려면 비표준인 #pragma pack이나 __attribute__((packed))를 써야 합니다. 정렬을 늘린 구조체는 크기도 정렬의 배수로 커진다는 점에 주의하세요. 위 Aligned는 멤버가 8바이트뿐인데 크기가 16바이트가 됩니다. 이런 타입을 new로 할당할 때, C++17 이전에는 new가 기본 정렬(보통 16바이트)까지만 보장해서 alignas(64) 타입을 힙에 만들면 정렬이 맞지 않을 수 있었습니다. C++17부터는 과정렬(over-aligned) 타입에 대해 정렬 인자를 받는 operator new가 자동으로 호출되므로 new와 std::make_unique, std::vector가 정렬을 지켜 줍니다.

변수 정렬

#include <iostream>
int main() {
    alignas(64) int cacheLine[16];  // 64바이트 정렬
    
    cout << "주소: " << (uintptr_t)cacheLine << endl;
    // 64의 배수
    
    return 0;
}

패딩 제거 (pragma pack)

주의: 성능 저하, undefined behavior 가능

#include <iostream>
using namespace std;
#pragma pack(push, 1)
struct Packed {
    char c;    // 1 byte
    int i;     // 4 bytes
    double d;  // 8 bytes
};  // 총 13 bytes (패딩 없음)
#pragma pack(pop)
int main() {
    cout << "Packed: " << sizeof(Packed) << endl;  // 13
    
    Packed p;
    p.i = 10;  // 컴파일러가 비정렬 접근 코드를 생성 (플랫폼에 따라 느릴 수 있음)
    // int* ptr = &p.i;  // 위험: 포인터로 꺼내면 정렬 정보가 사라짐 (-Waddress-of-packed-member)
    
    return 0;
}

사용 시나리오:

  • 네트워크 프로토콜 (패킷 구조)
  • 파일 포맷 (바이너리 직렬화)
  • 하드웨어 인터페이스 (레지스터 맵)

#pragma pack의 위험은 멤버를 직접 쓸 때보다 포인터나 참조로 꺼낼 때 드러납니다. p.i처럼 구조체를 통해 접근하면 컴파일러는 이 멤버가 비정렬이라는 것을 알고 안전한 코드를 만들지만, int* ptr = &p.i;로 포인터를 만들면 그 포인터 타입에는 “비정렬일 수 있다”는 정보가 없어서 이후의 *ptr 접근은 정렬된 int로 가정하고 생성됩니다. 엄격한 정렬을 요구하는 CPU에서는 여기서 크래시가 나고, x86에서도 원자적 연산이나 SIMD로 최적화되면 문제가 됩니다. GCC 9 이후 -Waddress-of-packed-member 경고가 이 경우를 잡아 줍니다. 또 패킹된 구조체의 멤버를 std::atomic으로 만들거나 비상수 참조로 함수에 넘기는 것도 같은 이유로 피해야 합니다.


고급 활용

False Sharing 방지

False Sharing: 여러 스레드가 같은 캐시 라인의 다른 변수를 수정하여 성능 저하

문제 코드

#include <atomic>
#include <thread>
#include <vector>
#include <chrono>
#include <iostream>
struct Counters {
    std::atomic<int> counter1;  // 0-3 bytes
    std::atomic<int> counter2;  // 4-7 bytes
};  // 같은 캐시 라인 (64 bytes)
int main() {
    Counters counters;
    
    auto start = std::chrono::high_resolution_clock::now();
    
    std::thread t1([&]() {
        for (int i = 0; i < 10000000; ++i) {
            counters.counter1++;
        }
    });
    
    std::thread t2([&]() {
        for (int i = 0; i < 10000000; ++i) {
            counters.counter2++;
        }
    });
    
    t1.join();
    t2.join();
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    
    std::cout << "False Sharing: " << duration << "ms" << std::endl;
    // 수치는 CPU·코어 배치에 따라 다름
    
    return 0;
}

해결 코드

struct CountersAligned {
    alignas(64) std::atomic<int> counter1;
    alignas(64) std::atomic<int> counter2;
};  // 각각 다른 캐시 라인
int main() {
    CountersAligned counters;
    
    auto start = std::chrono::high_resolution_clock::now();
    
    std::thread t1([&]() {
        for (int i = 0; i < 10000000; ++i) {
            counters.counter1++;
        }
    });
    
    std::thread t2([&]() {
        for (int i = 0; i < 10000000; ++i) {
            counters.counter2++;
        }
    });
    
    t1.join();
    t2.join();
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    
    std::cout << "No False Sharing: " << duration << "ms" << std::endl;
    // 보통 False Sharing 버전보다 확연히 빠름 (개선 폭은 환경마다 다름)
    
    return 0;
}

두 코드의 유일한 차이는 alignas(64)입니다. 앞 코드에서 counter1과 counter2는 연속된 8바이트 안에 있어 같은 캐시 라인을 공유합니다. 한 코어가 counter1을 증가시키려면 그 라인을 배타적(Modified) 상태로 가져와야 하고, 그 순간 다른 코어가 가진 같은 라인의 사본은 무효화됩니다. 두 스레드가 번갈아 쓰면 라인이 코어 사이를 계속 오가므로, 논리적으로는 아무것도 공유하지 않는데 성능은 하나의 변수를 두고 경쟁하는 것처럼 나빠집니다. 64라는 값은 x86 대부분의 캐시 라인 크기이지만 Apple M 시리즈 같은 일부 ARM CPU는 128바이트 단위로 동작하는 부분이 있고, 인텔 CPU도 인접 라인을 함께 가져오는 프리페처 때문에 128바이트 간격이 더 안전한 경우가 있습니다. C++17의 std::hardware_destructive_interference_size가 이 값을 표준 방식으로 제공하지만, 컴파일러 버전에 따라 지원 여부와 값이 다르고 GCC는 ABI 안정성 경고를 내므로 64나 128을 직접 쓰는 코드가 여전히 많습니다.

SIMD 정렬

SSE/AVX는 16/32바이트 정렬 필요

#include <immintrin.h>
#include <iostream>
int main() {
    // ❌ 정렬 안 됨
    float data1[8];
    // __m256 a = _mm256_load_ps(data1);  // 크래시 가능
    
    // ✅ 32바이트 정렬
    alignas(32) float data2[8] = {1, 2, 3, 4, 5, 6, 7, 8};
    __m256 a = _mm256_load_ps(data2);  // 안전
    
    // 연산
    __m256 b = _mm256_set1_ps(2.0f);
    __m256 c = _mm256_mul_ps(a, b);
    
    // 결과 저장
    alignas(32) float result[8];
    _mm256_store_ps(result, c);
    
    for (float x : result) {
        std::cout << x << " ";  // 2 4 6 8 10 12 14 16
    }

    return 0;
}

AVX 내장 함수를 쓰는 코드는 컴파일 옵션과 실행 CPU 두 가지를 모두 맞춰야 합니다. GCC/Clang에서 -mavx(또는 -march=native) 없이 컴파일하면 inlining failed in call to 'always_inline' '_mm256_load_ps': target specific option mismatch 같은 오류가 나고, 옵션을 켜고 빌드한 바이너리를 AVX를 지원하지 않는 CPU에서 실행하면 Illegal instruction으로 종료됩니다. 배포용 바이너리라면 __builtin_cpu_supports("avx")로 실행 시점에 확인하고 경로를 나누는 방식이 필요합니다. _mm256_load_ps는 주소가 32바이트 정렬이 아니면 일반 보호 예외로 크래시하는데, 이것은 성능 문제가 아니라 명령 자체의 요구 사항이라는 점이 앞서 본 일반 정수 접근과 다릅니다.

정렬된 메모리 할당

#include <cstdlib>
#include <iostream>
template<typename T, size_t Alignment = alignof(T)>
class AlignedAllocator {
public:
    using value_type = T;
    
    T* allocate(size_t n) {
        void* ptr = nullptr;
        
        #ifdef _WIN32
            ptr = _aligned_malloc(n * sizeof(T), Alignment);
        #else
            if (posix_memalign(&ptr, Alignment, n * sizeof(T)) != 0) {
                ptr = nullptr;
            }
        #endif
        
        if (!ptr) {
            throw std::bad_alloc();
        }
        
        return static_cast<T*>(ptr);
    }
    
    void deallocate(T* ptr, size_t) noexcept {
        #ifdef _WIN32
            _aligned_free(ptr);
        #else
            free(ptr);
        #endif
    }
};
int main() {
    AlignedAllocator<double, 64> allocator;
    
    double* data = allocator.allocate(100);
    
    std::cout << "주소: " << (uintptr_t)data << std::endl;
    // 64의 배수
    
    allocator.deallocate(data, 100);

    return 0;
}

이 할당자는 단독으로 쓰기에는 동작하지만, std::vector<double, AlignedAllocator<double, 64>>처럼 표준 컨테이너에 넘기려면 보완할 부분이 있습니다. 컨테이너는 내부 노드 타입으로 할당자를 바꿔 쓰는 rebind를 하는데, std::allocator_traits의 자동 rebind는 템플릿 인자가 모두 타입일 때만 동작하므로 size_t Alignment 같은 비타입 인자가 있으면 template <class U> struct rebind { using other = AlignedAllocator<U, Alignment>; };를 직접 정의해야 합니다. 두 할당자가 서로 해제할 수 있는지를 알려 주는 operator==/!=도 필요합니다. 또 posix_memalign은 정렬 값이 sizeof(void*)의 배수인 2의 거듭제곱이어야 해서, 기본값 alignof(T)가 1이나 4인 타입에서는 EINVAL로 실패합니다. C++17 이후라면 std::aligned_alloc(크기가 정렬의 배수여야 함, MSVC 미지원)이나 과정렬 타입의 new를 쓰는 편이 간단하며, Windows의 _aligned_malloc으로 받은 메모리는 반드시 _aligned_free로 해제해야 한다는 점도 짝을 맞춰야 합니다.


성능 비교

정렬된 접근 vs 정렬되지 않은 접근

정렬되지 않은 접근의 비용은 CPU마다 크게 다릅니다. 최근 x86-64 CPU는 값이 한 캐시 라인 안에 들어 있으면 정렬되지 않은 int 읽기도 정렬된 읽기와 거의 차이가 없습니다. 비용이 커지는 건 값이 캐시 라인(64바이트) 경계나 페이지 경계에 걸칠 때로, 이때는 두 라인을 읽어 합쳐야 해서 느려집니다. 일부 ARM·임베디드 CPU는 정렬되지 않은 접근 자체를 하드웨어 예외로 처리하거나 여러 번의 읽기로 쪼개기 때문에 훨씬 불리합니다. 또 std::atomic 연산은 정렬이 맞지 않으면 원자성이 보장되지 않습니다. 그래서 성능보다는 이식성과 정확성 때문에 정렬을 지키는 것이 기본이며, 패킹된 네트워크 버퍼처럼 정렬을 보장할 수 없는 데이터는 memcpy로 정렬된 변수에 복사해서 읽는 것이 안전합니다.

False Sharing 비교

두 스레드가 서로 다른 변수를 갱신해도 두 변수가 같은 64바이트 캐시 라인에 있으면, 한쪽이 쓸 때마다 캐시 일관성 프로토콜(MESI)이 상대 코어의 라인을 무효화합니다. 결과적으로 라인이 코어 사이를 계속 오가며 멀티스레드인데도 단일 스레드보다 느려지는 일이 생깁니다. alignas(64)로 변수를 각자의 캐시 라인에 두면 이 왕복이 사라집니다. 개선 폭은 코어 배치(같은 소켓인지), 갱신 빈도에 따라 다르므로 아래 실무 사례 1의 코드를 두 방식으로 빌드해 직접 비교해 보는 것이 좋습니다.

구조체 크기 비교

struct Bad {
    char c;    // 1 + 7 padding
    double d;  // 8
    int i;     // 4 + 4 padding
};  // 24 bytes
struct Good {
    double d;  // 8
    int i;     // 4
    char c;    // 1 + 3 padding
};  // 16 bytes

결론: 큰 타입부터 배치하면 24바이트가 16바이트로 줄어듭니다(sizeof로 확인 가능, 일반적인 64비트 ABI 기준). 구조체 배열이 클수록 캐시에 들어가는 원소 수가 늘어 효과가 커집니다.


실무 사례

사례 1: 멀티스레드 카운터 - False Sharing 방지

#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
struct alignas(64) AlignedCounter {
    std::atomic<int> counter;
    char padding[60];  // 64바이트 채우기 (alignas(64)만으로도 크기가 64로 올림되므로 사실 중복)
};
int main() {
    AlignedCounter counters[4];
    
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([&, i]() {
            for (int j = 0; j < 1000000; ++j) {
                counters[i].counter++;
            }
        });
    }
    
    for (auto& t : threads) {
        t.join();
    }
    
    for (int i = 0; i < 4; ++i) {
        std::cout << "Counter " << i << ": " << counters[i].counter << std::endl;
    }

    return 0;
}

이 패턴은 스레드별 통계나 작업 큐의 인덱스처럼 각 스레드가 자기 슬롯만 쓰는 구조에서 자주 쓰입니다. 정렬 대신 더 근본적인 해결책도 있습니다. 각 스레드가 루프 안에서는 지역 변수에 누적하고 끝날 때 한 번만 공유 카운터에 더하면, 캐시 라인 경합 자체가 사라지고 atomic 연산도 거의 필요 없어집니다. 제가 멀티스레드 카운터가 코어 수를 늘려도 빨라지지 않는 문제를 볼 때 먼저 하는 일도 “이 값을 매번 공유 메모리에 써야 하는가”를 확인하는 것입니다. perf c2c는 여러 코어가 같은 캐시 라인을 두고 다투는 지점(HITM 이벤트)을 보여 주므로, False Sharing을 추측이 아니라 측정으로 확인할 수 있는 도구입니다.

사례 2: SIMD 벡터 연산

#include <immintrin.h>
#include <iostream>
void vectorAdd(const float* a, const float* b, float* c, size_t n) {
    // 전제: n이 8의 배수이고 a, b, c가 모두 32바이트 정렬 (아니면 범위 밖 접근·크래시)
    for (size_t i = 0; i < n; i += 8) {
        __m256 va = _mm256_load_ps(a + i);
        __m256 vb = _mm256_load_ps(b + i);
        __m256 vc = _mm256_add_ps(va, vb);
        _mm256_store_ps(c + i, vc);
    }
}
int main() {
    alignas(32) float a[8] = {1, 2, 3, 4, 5, 6, 7, 8};
    alignas(32) float b[8] = {8, 7, 6, 5, 4, 3, 2, 1};
    alignas(32) float c[8];
    
    vectorAdd(a, b, c, 8);
    
    for (float x : c) {
        std::cout << x << " ";  // 9 9 9 9 9 9 9 9
    }

    return 0;
}

실제 입력은 길이가 8의 배수라는 보장이 없으므로, 보통 8개씩 처리하는 본 루프 뒤에 남은 원소를 스칼라로 처리하는 꼬리(tail) 루프를 붙입니다. 이런 단순한 덧셈 루프라면 사실 직접 내장 함수를 쓰지 않아도 최적화 컴파일러가 for (size_t i = 0; i < n; ++i) c[i] = a[i] + b[i];를 자동으로 벡터화합니다. 내장 함수는 컴파일러가 벡터화하지 못하는 복잡한 패턴이나 특정 명령이 꼭 필요할 때 쓰고, 그 전에 컴파일러가 이미 무엇을 하고 있는지 -fopt-info-vec(GCC)이나 -Rpass=loop-vectorize(Clang)로 확인해 보는 것이 좋습니다.

사례 3: 네트워크 프로토콜 - 패딩 제거

#include <cstdint>
#include <iostream>
#pragma pack(push, 1)
struct PacketHeader {
    uint8_t version;     // 1 byte
    uint16_t length;     // 2 bytes
    uint32_t sequence;   // 4 bytes
    uint64_t timestamp;  // 8 bytes
};  // 총 15 bytes (패딩 없음)
#pragma pack(pop)
int main() {
    std::cout << "PacketHeader: " << sizeof(PacketHeader) << std::endl;  // 15
    
    PacketHeader header;
    header.version = 1;
    header.length = 100;
    header.sequence = 12345;
    header.timestamp = 1234567890;
    
    // 네트워크로 전송
    // send(socket, &header, sizeof(header), 0);

    return 0;
}

패킹은 오프셋 문제만 해결한다는 점을 잊기 쉽습니다. 구조체를 그대로 send하면 멤버 값은 호스트의 바이트 순서로 전송되므로, x86(리틀 엔디언)에서 보낸 length = 100을 빅 엔디언 규약의 프로토콜 파서가 읽으면 25600이 됩니다. 네트워크 바이트 순서가 필요한 필드는 htons, htonl(64비트는 플랫폼별 함수나 C++23 std::byteswap)로 변환해 넣어야 하고, 받는 쪽도 반대로 변환해야 합니다. 또 수신 버퍼를 reinterpret_cast<PacketHeader*>(buf)로 바로 해석하는 방식은 엄격한 앨리어싱 규칙에도 걸리므로, 필드별로 memcpy해서 읽는 직렬화 함수를 두는 편이 이식성과 정확성 면에서 안전합니다. 저는 프로토콜 헤더 구조체를 쓸 때 static_assert(sizeof(PacketHeader) == 15)를 함께 두어, 누군가 멤버를 추가하거나 pack 지시를 빠뜨렸을 때 컴파일 단계에서 바로 드러나게 합니다.

사례 4: 게임 엔진 - 데이터 지향 설계

#include <vector>
#include <iostream>
// ❌ AoS (Array of Structures) - 캐시 미스 많음
struct EntityAoS {
    float x, y, z;      // 위치
    float vx, vy, vz;   // 속도
    int health;
    int id;
};
std::vector<EntityAoS> entitiesAoS(10000);
// ✅ SoA (Structure of Arrays) - 캐시 친화적
struct EntitiesSoA {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<int> health;
    std::vector<int> id;
};
void updatePositions(EntitiesSoA& entities, float dt) {
    for (size_t i = 0; i < entities.x.size(); ++i) {
        entities.x[i] += entities.vx[i] * dt;
        entities.y[i] += entities.vy[i] * dt;
        entities.z[i] += entities.vz[i] * dt;
    }
}
int main() {
    EntitiesSoA entities;
    entities.x.resize(10000);
    entities.y.resize(10000);
    entities.z.resize(10000);
    entities.vx.resize(10000, 1.0f);
    entities.vy.resize(10000, 1.0f);
    entities.vz.resize(10000, 1.0f);
    
    updatePositions(entities, 0.016f);

    std::cout << "위치 업데이트 완료" << std::endl;

    return 0;
}

AoS의 EntityAoS는 32바이트이고, 위치 갱신에 필요한 것은 그중 24바이트(위치와 속도)뿐입니다. 64바이트 캐시 라인에 엔티티가 두 개 들어가지만 health와 id까지 함께 가져오므로 가져온 데이터의 일부는 쓰지 않습니다. SoA에서는 x 배열의 캐시 라인 하나에 16개 엔티티의 x좌표가 빽빽하게 들어 있고, 필요한 배열만 순서대로 읽으므로 캐시와 프리페처 효율이 높아지고 컴파일러가 SIMD로 벡터화하기도 쉬워집니다. 대신 엔티티 하나의 모든 속성을 함께 다루는 코드(생성, 삭제, 디버그 출력)는 여러 배열을 동시에 관리해야 해서 번거로워지고, 배열 길이가 어긋나는 버그가 생기기 쉽습니다. 그래서 실무에서는 함께 쓰이는 필드끼리 묶는 하이브리드 구조(AoSoA)나, 핫 필드와 콜드 필드를 나누는 정도로 타협하는 경우가 많습니다.


트러블슈팅

문제 1: 정렬되지 않은 접근

증상: 크래시 또는 성능 저하

// ❌ 정렬 안 됨
char buffer[100];
int* ptr = reinterpret_cast<int*>(buffer + 1);
*ptr = 10;  // 정렬 안 됨 (느림 또는 크래시)
// ✅ 정렬 보장
alignas(int) char buffer[100];
int* ptr = reinterpret_cast<int*>(buffer);
*ptr = 10;
// 더 엄밀하게는 new (buffer) int(10)처럼 placement new로 객체를 만들거나
// std::memcpy로 값을 복사하는 것이 객체 수명·앨리어싱 규칙에 맞는 방법

정렬을 맞추는 것만으로 모든 문제가 해결되지는 않습니다. char 배열에 int 객체를 만든 적이 없는데 int*로 접근하는 것은 표준상 객체 수명 규칙에 어긋나고, 컴파일러의 앨리어싱 최적화와 충돌할 수 있습니다. 실무 코드에서 이 패턴이 대체로 동작하는 것은 주요 컴파일러가 관대하게 처리하기 때문이며, 원시 버퍼에서 값을 꺼내는 경우라면 std::memcpy가 가장 확실하고 최적화 컴파일러는 이를 단일 로드 명령으로 바꿔 줍니다. C++23의 std::start_lifetime_as는 이런 경우를 표준 방식으로 표현하기 위해 추가되었습니다.

문제 2: 구조체 크기 가정

증상: 직렬화 오류, 메모리 계산 오류

struct Data {
    char c;
    int i;
};
// ❌ 잘못된 가정
// sizeof(Data) == 5라고 가정 (실제는 8)
// ✅ sizeof 사용
size_t size = sizeof(Data);  // 8
// ✅ static_assert로 검증
static_assert(sizeof(Data) == 8, "Data size mismatch");

문제 3: 플랫폼별 차이

증상: Windows와 Linux에서 다른 크기

struct Data {
    long l;
    int i;
};
// Windows 64비트: sizeof(long) == 4
// Linux 64비트: sizeof(long) == 8
// ✅ 고정 크기 타입 사용
#include <cstdint>
struct DataFixed {
    int64_t l;  // 항상 8 bytes
    int32_t i;  // 항상 4 bytes
};

문제 4: SIMD 정렬 오류

증상: _mm256_load_ps 크래시

// ❌ 정렬 안 됨
float data[8];
__m256 a = _mm256_load_ps(data);  // 크래시!
// ✅ 32바이트 정렬
alignas(32) float data[8];
__m256 a = _mm256_load_ps(data);  // 안전
// 또는 정렬되지 않은 로드 사용
__m256 a = _mm256_loadu_ps(data);  // 정렬 요구 없음 (최근 CPU에서는 정렬된 주소라면 속도 차이도 거의 없음)

마무리

C++ 메모리 정렬은 성능과 메모리 효율성에 직접적인 영향을 미칩니다.

핵심 요약

  1. 정렬 기본
    • CPU는 타입별 정렬 경계 요구
    • 컴파일러는 패딩을 삽입해 정렬 맞춤
    • alignof로 확인, alignas로 제어
  2. 구조체 최적화
    • 큰 타입을 먼저 배치
    • 같은 크기 타입을 그룹화
    • 패딩을 최소화
  3. False Sharing 방지
    • 캐시 라인(64 bytes) 분리
    • alignas(64) 사용
    • 코어 간 캐시 라인 왕복 제거
  4. SIMD 최적화
    • SSE: 16바이트 정렬
    • AVX: 32바이트 정렬
    • _mm256_load_ps vs _mm256_loadu_ps

선택 가이드

상황방법
구조체 크기 줄이기큰 타입 먼저 배치
멀티스레드 카운터alignas(64)
SIMD 연산alignas(32)
네트워크 프로토콜#pragma pack(1)

코드 예제 치트시트

// 정렬 확인
cout << alignof(int) << endl;
// 크기 확인
cout << sizeof(MyStruct) << endl;
// 정렬 지정
alignas(64) int cacheLine[16];
// 구조체 정렬
struct alignas(16) Aligned { int x, y; };
// 패딩 제거 (주의!)
#pragma pack(push, 1)
struct Packed { char c; int i; };
#pragma pack(pop)
// SIMD 정렬
alignas(32) float data[8];
__m256 a = _mm256_load_ps(data);

다음 단계

참고 자료


자주 묻는 질문 (FAQ)

Q. _mm256_load_ps에서 크래시가 나는 이유와 해결 방법은 무엇인가요?

A. _mm256_load_ps는 32바이트 경계에 정렬된 주소를 전제로 하는 정렬 로드 명령이라, 일반 배열처럼 정렬이 보장되지 않은 주소를 넘기면 크래시가 날 수 있습니다. 데이터를 alignas(32)로 선언하거나 정렬 할당 함수로 버퍼를 만들어 주소를 맞추는 것이 기본 해결책입니다. 정렬을 보장하기 어려운 입력이라면 _mm256_loadu_ps 같은 비정렬 로드를 쓰면 되지만, 환경에 따라 정렬 로드보다 느릴 수 있습니다.


같이 보면 좋은 글