C++ 배열: 메모리 레이아웃, std::array, 캐시 최적화

이 글의 핵심

배열을 함수에 넘긴 뒤 sizeof로 길이를 구하면 포인터 크기가 나오는 문제는 decay 규칙을 알면 바로 이해됩니다. vector<vector>가 연속 메모리가 아니어서 캐시 효율이 떨어지는 이유, 할당·재할당 비용, CPU 캐시 구조와 순회 순서의 관계를 짚어 성능을 고려한 컨테이너 선택 기준을 제공합니다.

들어가며

배열은 C++에서 가장 기본적인 자료구조이고, 같은 타입의 요소를 연속된 메모리에 놓는다는 단순한 성질 덕분에 CPU 캐시와 잘 맞습니다. 하지만 그 단순한 겉모습 아래에는 포인터 쇠퇴(array decay), 다차원 배열의 메모리 레이아웃, C 배열·std::array·std::vector의 차이처럼 실수하기 쉬운 규칙이 있습니다.

이 글은 배열의 타입과 decay 규칙에서 시작해, std::array의 설계, 다차원 배열의 레이아웃, 컨테이너 선택, 캐시와 SIMD를 고려한 최적화 순서로 정리합니다.

배열의 기본 원리

메모리 레이아웃

C++ 배열은 같은 타입의 요소를 연속된 메모리 블록에 순서대로 배치합니다.

int arr[5];  // int 5개가 연속된 메모리에 배치
             // 메모리: [int][int][int][int][int]
             // 주소:   addr   addr+4 addr+8 addr+12 addr+16  (int가 4바이트일 때)

요소 사이의 거리는 sizeof(T)바이트이고, 배열 전체 크기는 N * sizeof(T)바이트입니다. arr[i]의 주소는 시작 주소 + i * sizeof(T)로 바로 계산되므로 인덱스 접근은 O(1)입니다.

배열 타입

C++에서 배열은 요소 타입과 크기를 모두 포함한 고유한 타입을 가집니다.

#include <type_traits>

int a[3];      // 타입: int[3]
int b[5];      // 타입: int[5] (다른 타입)
float c[3];    // 타입: float[3] (다른 타입)

static_assert(!std::is_same_v<decltype(a), decltype(b)>);

크기가 타입의 일부라서, 템플릿이 배열을 참조로 받으면 크기를 템플릿 인자로 추론할 수 있습니다. 이 성질은 아래 decay 절에서 다시 씁니다.

저장 위치와 생명주기

배열이 어디에 놓이는지는 선언 위치에 따라 결정됩니다.

// 1. 자동 저장 기간: 보통 스택
void function() {
    int stack_array[100];
}

// 2. 정적 저장 기간: .data(초기값 있음) 또는 .bss(0 초기화)
static int static_array[100];
int global_array[100];

// 3. 동적 저장 기간: 힙
int* heap_array = new int[100];
delete[] heap_array;

// 4. 멤버 배열: 포함하는 객체와 같은 곳, 같은 생명주기
struct Container {
    int member_array[100];
};

이 규칙은 std::array에도 그대로 적용됩니다. std::array가 항상 스택에 있는 것이 아니라, 지역 변수면 스택, static이면 정적 영역, 클래스 멤버면 그 객체가 있는 곳, new로 만들면 힙에 놓입니다.

성능 측면에서 자동·정적 배열은 할당·해제 비용이 없고, 힙 배열은 new/delete 호출 비용이 듭니다. 하지만 일단 할당된 뒤의 접근 속도는 위치와 무관합니다. 캐시는 주소가 스택이든 힙이든 구분하지 않으며, 중요한 것은 그 메모리를 최근에 썼는지와 순서대로 접근하는지입니다.


포인터 쇠퇴(Array Decay)

배열은 포인터가 아니다

“배열은 포인터”라는 설명은 잘못된 모델입니다. 정확히는 배열 이름이 대부분의 표현식에서 쓰일 때 배열 타입이 첫 번째 요소를 가리키는 포인터로 암묵적으로 변환되며, 이를 배열에서 포인터로의 변환(array-to-pointer decay)이라고 부릅니다.

int arr[5] = {1, 2, 3, 4, 5};

int* p = arr;           // arr이 &arr[0]으로 decay
void process(int* p);
process(arr);           // 마찬가지로 decay

// arr 자체의 타입은 여전히 배열
static_assert(std::is_array_v<decltype(arr)>);
static_assert(!std::is_pointer_v<decltype(arr)>);

decay가 일어나는 경우와 일어나지 않는 경우

포인터에 대입하거나, 포인터 매개변수를 가진 함수에 넘기거나, arr + 2 같은 포인터 산술을 하거나, *arr처럼 역참조하면 decay가 일어납니다.

int arr[5];

int* p = arr;          // 포인터에 대입
void func(int* p);
func(arr);             // 함수 인자
int* third = arr + 2;  // 포인터 산술
int first = *arr;      // decay된 포인터를 역참조

반대로 다음 경우에는 decay가 일어나지 않고 배열 타입이 유지됩니다.

int arr[5];

sizeof(arr);            // 20: 배열 전체 크기 (int 4바이트 기준)
sizeof(int*);           // 8: 64비트 시스템의 포인터 크기

using ArrType = decltype(arr);   // int[5]

int (*ptr)[5] = &arr;   // 배열 전체를 가리키는 포인터

template<typename T, std::size_t N>
void no_decay(T (&a)[N]) {      // 배열 참조로 받으면 크기 보존
    std::cout << N << '\n';
}

for (int x : arr) { }           // 범위 기반 for는 배열 크기를 알고 순회
auto it = std::begin(arr);      // 배열용 오버로드가 크기 정보 사용

함수 매개변수의 함정

// 세 선언은 모두 같은 함수 시그니처입니다
void func1(int arr[]);
void func2(int arr[10]);    // 10은 무시됨
void func3(int* arr);

void func1(int arr[]) {
    sizeof(arr);  // 포인터 크기(64비트에서 8), 배열 크기가 아님
}

매개변수 자리에 배열 문법을 써도 컴파일러는 포인터로 바꿔 버리므로, 함수 안에서 sizeof(arr) / sizeof(arr[0])으로 길이를 구하면 틀린 값이 나옵니다. GCC와 Clang은 이 경우 -Wsizeof-array-argument 경고를 냅니다. 크기를 함께 다루는 방법은 네 가지입니다.

// 1. 크기를 별도로 전달
void process(int* arr, std::size_t size);

// 2. 템플릿으로 크기 보존
template<std::size_t N>
void process(int (&arr)[N]) {
    for (std::size_t i = 0; i < N; ++i) { /* ... */ }
}

// 3. std::array 사용
void process(std::array<int, 10>& arr);

// 4. std::span 사용 (C++20): C 배열, std::array, vector를 모두 받음
void process(std::span<int> arr);

새 코드에서는 길이가 정해지지 않은 연속 데이터를 받을 때 std::span이 가장 무난합니다. 포인터와 길이를 한 객체로 묶어 주므로, 1번 방식의 “길이를 잘못 넘기는” 실수가 줄어듭니다.

2차원 배열의 decay

int matrix[3][4];

// matrix는 int(*)[4], 즉 "int[4] 배열을 가리키는 포인터"로 decay
int (*p)[4] = matrix;  // OK

// int** pp = matrix;  // 컴파일 에러: int[3][4]는 포인터의 배열이 아님

void process(int (*m)[4], std::size_t rows);
process(matrix, 3);

template<std::size_t Rows, std::size_t Cols>
void process(int (&m)[Rows][Cols]);

decay는 가장 바깥 차원 한 번만 일어납니다. int[3][4]는 메모리상 int 12개가 이어진 블록이고, 포인터 배열이 아니므로 int**로 변환할 수 없습니다. 함수에 넘길 때는 안쪽 차원의 크기를 타입에 적어야 합니다.


std::array

설계

C++11의 std::array는 C 배열 하나를 공개 멤버로 가진 집합체입니다. 표준 라이브러리 구현은 대략 다음과 같습니다.

template<typename T, std::size_t N>
struct array {
    T _M_elems[N];  // 실제 이름은 구현마다 다름

    constexpr T& operator[](std::size_t i) noexcept { return _M_elems[i]; }
    constexpr std::size_t size() const noexcept { return N; }
    // begin(), end(), data(), at(), fill(), swap() ...
};

집합체이므로 std::array<int, 3> arr = {1, 2, 3};처럼 중괄호로 초기화하고, 포인터로 decay되지 않으며, 복사와 대입이 가능하고, begin(), end(), size()를 제공해 표준 알고리즘과 바로 함께 쓸 수 있습니다. C API에 넘겨야 할 때는 data()로 첫 요소 포인터를 얻습니다.

C 배열과의 성능 비교

constexpr std::size_t N = 1000;

int c_array[N];
for (std::size_t i = 0; i < N; ++i) {
    c_array[i] = static_cast<int>(i * 2);
}

std::array<int, N> std_array;
for (std::size_t i = 0; i < N; ++i) {
    std_array[i] = static_cast<int>(i * 2);
}

최적화 빌드(-O2/-O3)에서는 operator[]가 인라인되어 두 루프가 사실상 같은 기계어로 컴파일됩니다. Compiler Explorer(godbolt.org)에 두 버전을 넣어 보면 직접 확인할 수 있습니다. 차이가 생기는 것은 디버그 빌드입니다. operator[]가 인라인되지 않은 함수 호출로 남고, MSVC 디버그 모드나 libstdc++의 _GLIBCXX_ASSERTIONS처럼 경계 검사를 켜면 그만큼 느려집니다.

std::array로 얻는 것

std::array<int, 5> arr = {1, 2, 3, 4, 5};
constexpr auto n = std::tuple_size_v<decltype(arr)>;  // 컴파일 타임 상수 5

// 크기를 템플릿 인자로 받음
template<typename T, std::size_t N>
void process(std::array<T, N>& a) { /* N 사용 가능 */ }

// 표준 알고리즘과 바로 호환
std::sort(arr.begin(), arr.end());
auto it = std::find(arr.begin(), arr.end(), 3);

// 값으로 복사·대입 가능 (C 배열은 불가)
std::array<int, 5> arr2 = arr;

// at()은 범위를 검사
try {
    int value = arr.at(10);
} catch (const std::out_of_range&) { /* 범위 초과 */ }

C 배열, std::array, vector 고르기

컴파일 타임에 크기가 확정되어 있고 크기가 크지 않다면 std::array가 기본 선택입니다. 크기가 런타임에 정해지거나 늘었다 줄었다 해야 하거나, 데이터가 커서 스택에 두기 부담스럽다면 std::vector를 씁니다. C 배열이 꼭 필요한 경우는 생각보다 적습니다. C API에 넘길 때도 std::array::data()나 std::vector::data()로 충분하므로, C 배열은 C와 공유하는 헤더의 구조체 정의나 문자열 리터럴처럼 언어 차원에서 배열 타입이 필요한 곳에 주로 남습니다.


다차원 배열과 메모리 레이아웃

행 우선(Row-Major) 레이아웃

C++의 다차원 배열은 행 우선(row-major) 순서로 저장됩니다. 즉 마지막 인덱스가 가장 빠르게 변합니다.

int matrix[3][4] = {
    {1,  2,  3,  4},
    {5,  6,  7,  8},
    {9, 10, 11, 12}
};

// 메모리 레이아웃:
// [1][2][3][4][5][6][7][8][9][10][11][12]
//  ←─ row 0 ─→ ←─ row 1 ─→ ←─ row 2  ─→

matrix[i][j]의 위치는 시작 주소 + (i * 열 수 + j) * sizeof(int)입니다.

int* ptr = &matrix[0][0];
int element = ptr[i * 4 + j];  // matrix[i][j]와 같은 위치

엄밀히 말하면 &matrix[0][0]에서 출발해 첫 행의 끝을 넘어 인덱싱하는 것은 표준상 경계 밖 접근이라 논쟁의 여지가 있지만, 모든 주요 컴파일러에서 기대대로 동작하며 실무에서 널리 쓰입니다. 처음부터 1차원으로 할당하고 인덱스를 계산하는 방식(아래 vector 예제)이 더 깔끔합니다.

캐시 친화적인 순회

// 캐시 친화적: 안쪽 루프가 연속된 메모리를 따라감
for (std::size_t i = 0; i < rows; ++i) {
    for (std::size_t j = 0; j < cols; ++j) {
        process(matrix[i][j]);
    }
}

// 캐시 비친화적: 안쪽 루프가 한 행 크기만큼씩 건너뜀
for (std::size_t j = 0; j < cols; ++j) {
    for (std::size_t i = 0; i < rows; ++i) {
        process(matrix[i][j]);
    }
}

열 우선 순회는 원소 하나를 읽을 때마다 다른 캐시 라인을 건드리므로, 한 번 가져온 64바이트 중 4바이트만 쓰고 나머지는 다시 쓰기 전에 밀려날 수 있습니다. 행렬 전체가 캐시에 들어가는 작은 크기에서는 차이가 작지만, L2/L3를 넘어서는 크기부터는 순차 접근을 돕는 하드웨어 프리페처의 이점까지 잃어 격차가 크게 벌어집니다.

2차원 배열과 vector

// 2차원 배열: 하나의 연속 블록
int matrix[3][4];

// vector<vector>: 행마다 독립된 힙 블록
std::vector<std::vector<int>> vmatrix(3, std::vector<int>(4));
// 바깥 vector: [vector0][vector1][vector2]
//                 ↓        ↓        ↓
//               [row0]   [row1]   [row2]   (각각 따로 할당)

vector<vector>는 행마다 별도 할당이 일어나고, 원소에 접근할 때 바깥 vector에서 행의 데이터 포인터를 읽고 다시 그 포인터로 원소를 읽는 이중 간접 참조가 필요하며, 행들이 메모리상 흩어져 있어 행을 넘어갈 때 연속성이 끊깁니다. 행의 길이가 제각각인 들쭉날쭉한(jagged) 데이터가 아니라면, 1차원 vector에 인덱스를 계산하는 방식이 낫습니다.

class Matrix {
    std::size_t rows_, cols_;
    std::vector<int> data_;
public:
    Matrix(std::size_t r, std::size_t c) : rows_(r), cols_(c), data_(r * c) {}
    int& operator()(std::size_t i, std::size_t j) { return data_[i * cols_ + j]; }
};

std::mdspan (C++23)

C++23의 std::mdspan은 연속 메모리 위에 다차원 인덱싱을 얹는 뷰입니다. 위의 Matrix 같은 래퍼를 직접 만들지 않아도 됩니다.

#include <mdspan>
#include <vector>

std::vector<int> data(3 * 4);
std::mdspan<int, std::extents<std::size_t, 3, 4>> matrix(data.data());

matrix[1, 2] = 42;  // C++23 다중 인자 operator[]

크기를 런타임에 정하려면 std::dextents<std::size_t, 2>를 쓰고 생성자에 행과 열 수를 넘깁니다. 레이아웃 정책(layout_right가 기본인 행 우선, layout_left는 열 우선)을 바꿔 Fortran이나 BLAS 라이브러리의 열 우선 데이터도 감쌀 수 있습니다. 표준 라이브러리 지원은 비교적 최근에 들어왔으므로 사용하는 컴파일러 버전을 확인해야 합니다.


array와 vector의 성능 차이

할당 비용

std::array<int, 1000> arr;   // 지역 변수라면 스택 공간 확보만
std::vector<int> vec(1000);  // operator new로 힙 할당 + 0 초기화

std::array는 지역 변수일 때 스택 포인터 조정만으로 공간이 생기고 해제 비용도 없습니다. std::vector는 생성 시 operator new(보통 malloc)를 호출하고 소멸 시 해제합니다. 루프 안에서 매번 만들고 버리면 이 할당·해제가 반복 비용이 되지만, 한 번 만들어 재사용하면 차이는 사라집니다. 또 std::vector<int> vec(1000)은 원소를 모두 0으로 초기화하는 반면 지역 std::array<int, 1000> arr;은 초기화하지 않으므로, 비교할 때 이 차이도 고려해야 합니다.

요소 접근

arr[i];   // 배열 시작 주소 + i * 4
vec[i];   // vector 객체에서 데이터 포인터를 읽은 뒤 + i * 4

std::array는 배열이 객체 안에 있으므로 주소 계산 한 번이면 되고, std::vector는 먼저 내부 데이터 포인터를 읽어야 합니다. 하지만 루프 안에서는 컴파일러가 이 포인터 읽기를 루프 밖으로 끌어내므로, 최적화 빌드에서 순회 성능은 사실상 같습니다. 순차 순회의 캐시 동작도 둘 다 연속 메모리라서 같습니다.

큰 배열과 스택 크기

100만 개 int(4MB)를 지역 std::array나 C 배열로 두면 기본 스택 크기(Windows 1MB, Linux는 보통 8MB)를 넘거나 위협합니다. 스택 오버플로는 대개 즉시 크래시로 끝나고 원인이 바로 드러나지 않으므로, 수십 KB를 넘는 데이터는 std::vector처럼 힙에 두는 것이 안전합니다.

void risky_function() {
    int huge_array[1'000'000];  // 4MB를 스택에 요구
}

void safe_function() {
    std::vector<int> huge_array(1'000'000);
}

재할당

std::array는 크기가 고정이라 재할당이 없습니다. std::vector는 push_back이 capacity를 넘으면 더 큰 버퍼를 새로 할당하고 기존 원소를 옮기므로 그 한 번은 O(n)이고, 이때 기존 포인터·참조·반복자가 모두 무효화됩니다. 성장 배율이 일정하므로 n번 push_back하는 동안 재할당은 O(log n)번 일어나고, 원소당 평균 비용은 O(1)입니다. 최종 크기를 알고 있다면 reserve(N)으로 재할당을 없앨 수 있습니다.

std::vector<int> vec;
vec.reserve(1000);
for (int i = 0; i < 1000; ++i) {
    vec.push_back(i);  // 재할당 없음
}

캐시 최적화 기법

CPU 캐시 계층

CPU 레지스터
    ↓ ~1 cycle
L1 캐시 (코어당 32-64KB)
    ↓ ~4 cycles
L2 캐시 (코어당 256KB-2MB)
    ↓ ~12 cycles
L3 캐시 (수 MB-수십 MB, 코어 간 공유)
    ↓ ~40 cycles
메인 메모리 (GB)
    ↓ 수백 cycles

위 사이클 수는 CPU 세대마다 다른 대략적인 크기 비교용 값입니다. 모든 캐시를 놓친 접근은 L1 적중보다 수십 배 이상 비쌉니다. 다만 CPU는 여러 미스를 동시에 처리하고 순차 접근은 프리페처가 미리 가져오므로, 실제 프로그램이 그만큼 느려지는지는 접근 패턴에 달려 있습니다.

캐시 라인과 공간 지역성

대부분의 x86-64 CPU에서 캐시 라인은 64바이트입니다. 메모리에서 데이터를 가져올 때는 필요한 바이트가 포함된 64바이트 라인 전체를 가져옵니다.

alignas(64) int arr[16];  // 16 * 4 = 64바이트, 64바이트 경계에 정렬해 정확히 1라인

arr[0] = 1;   // 라인이 캐시에 없으면 미스, 64바이트 전체를 가져옴
arr[1] = 2;   // 같은 라인이라 적중
arr[15] = 3;  // 여전히 같은 라인

alignas(64)가 없으면 배열이 라인 경계에 걸쳐 두 라인에 나뉠 수 있습니다. 순차 접근은 가져온 라인의 모든 바이트를 쓰므로 라인을 최대한 활용하고, 함께 쓰는 데이터를 가까이 두면 한 번 가져온 라인에서 더 많은 것을 얻습니다.

Array of Structures(AoS)와 Structure of Arrays(SoA)

전통적인 AoS 방식은 객체 하나의 필드를 묶어서 둡니다.

struct Particle {
    float x, y, z;     // 위치 (12바이트)
    float vx, vy, vz;  // 속도 (12바이트)
    float mass;        // 질량 (4바이트)
};  // float만 있으므로 패딩 없이 sizeof == 28

std::array<Particle, 1000> particles;

for (auto& p : particles) {
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.z += p.vz * dt;   // mass는 쓰지 않지만 같은 라인에 실려 옴
}

이 루프는 입자 하나당 28바이트 중 24바이트를 쓰므로 낭비가 크지 않습니다. 문제는 구조체에 이 루프가 쓰지 않는 필드(색상, 수명, 이름 등)가 많을 때입니다. 그 바이트들도 캐시 라인에 함께 실려 와 캐시 공간과 메모리 대역폭을 차지합니다.

SoA 방식은 필드마다 별도 배열을 둡니다.

struct ParticleSystem {
    std::array<float, 1000> x, y, z;
    std::array<float, 1000> vx, vy, vz;
    std::array<float, 1000> mass;
};

ParticleSystem ps;

for (std::size_t i = 0; i < 1000; ++i) {
    ps.x[i] += ps.vx[i] * dt;
    ps.y[i] += ps.vy[i] * dt;
    ps.z[i] += ps.vz[i] * dt;
}

SoA에서는 루프가 실제로 쓰는 배열만 읽으므로, 구조체에 필드가 많고 루프가 그중 일부만 쓸수록 이득이 커집니다. 같은 타입의 값이 연속으로 놓이므로 컴파일러가 SIMD로 자동 벡터화하기도 쉽습니다. 반면 입자 하나의 모든 필드를 한꺼번에 다루는 코드는 여러 배열을 오가야 해서 읽기 어려워지고, 객체 단위로 넘기거나 정렬하는 API를 만들기도 번거롭습니다. 모든 필드를 함께 쓰는 루프라면 이득이 거의 없으므로, 대량의 데이터에서 특정 필드만 반복해 처리하는 경로에 한해 고려하는 것이 좋습니다.

행렬 곱셈과 전치

// 기본 구현
void matrix_multiply_naive(const std::vector<float>& A, const std::vector<float>& B,
                           std::vector<float>& C, std::size_t N) {
    for (std::size_t i = 0; i < N; ++i) {
        for (std::size_t j = 0; j < N; ++j) {
            float sum = 0.0f;
            for (std::size_t k = 0; k < N; ++k) {
                sum += A[i * N + k] * B[k * N + j];  // B는 N칸씩 건너뛰며 읽음
            }
            C[i * N + j] = sum;
        }
    }
}

// B를 전치해 두 행렬 모두 순차로 읽기
void matrix_multiply_transposed(const std::vector<float>& A, const std::vector<float>& B,
                                std::vector<float>& C, std::size_t N) {
    std::vector<float> B_T(N * N);
    for (std::size_t i = 0; i < N; ++i)
        for (std::size_t j = 0; j < N; ++j)
            B_T[j * N + i] = B[i * N + j];

    for (std::size_t i = 0; i < N; ++i) {
        for (std::size_t j = 0; j < N; ++j) {
            float sum = 0.0f;
            for (std::size_t k = 0; k < N; ++k) {
                sum += A[i * N + k] * B_T[j * N + k];
            }
            C[i * N + j] = sum;
        }
    }
}

원래 코드에서 B[k * N + j]는 k가 늘 때마다 N개씩 건너뛰며 읽으므로 매번 새 캐시 라인을 가져옵니다. 전치 후에는 두 행렬 모두 순차로 읽으므로, 행렬이 캐시보다 커지는 크기(예: 1024×1024 float은 4MB)에서 차이가 뚜렷해집니다. 전치 자체의 O(N²) 비용은 O(N³) 곱셈에 비해 작습니다. 루프 순서를 i-k-j로 바꿔 가장 안쪽 루프가 B와 C의 행을 따라가게 하는 방법도 전치 없이 같은 효과를 냅니다. 실제 고성능 구현은 여기에 캐시 크기에 맞춘 블록 분할(tiling)과 SIMD를 더하므로, 프로덕션에서는 BLAS 라이브러리를 쓰는 것이 일반적입니다.

False sharing 방지

// 문제가 있는 코드: 8개 스레드가 각각 counters[t]만 수정
std::array<int, 8> counters{};
// 서로 다른 원소지만 32바이트가 한 캐시 라인에 들어가
// 한 스레드의 쓰기가 다른 코어가 가진 라인 사본을 무효화

// 해결: 원소마다 캐시 라인 하나씩
struct alignas(64) PaddedCounter {
    int value = 0;
};  // alignas 덕분에 sizeof == 64

std::array<PaddedCounter, 8> padded_counters;

false sharing이 있으면 쓰기마다 캐시 라인 소유권이 코어 사이를 오가므로, 스레드 수와 쓰기 빈도가 높을수록 손해가 커집니다. 카운터를 자주 갱신하는 루프에서는 정렬만으로 확연히 빨라지는 경우가 흔하지만, 쓰기가 드물면 차이가 없습니다. C++17의 std::hardware_destructive_interference_size를 쓰면 64를 하드코딩하지 않아도 되지만, 컴파일 옵션에 따라 값이 달라질 수 있어 GCC는 ABI 경계에서 사용하면 경고를 냅니다.


SIMD와 멀티스레딩

SIMD

SIMD(Single Instruction, Multiple Data)는 명령 하나로 여러 데이터를 동시에 처리합니다. 연속 배열은 SIMD 레지스터로 한 번에 읽어 들이기 좋은 형태입니다.

#include <immintrin.h>  // AVX intrinsics

void add_scalar(float* a, const float* b, const float* c, std::size_t n) {
    for (std::size_t i = 0; i < n; ++i) {
        a[i] = b[i] + c[i];
    }
}

// AVX: float 8개를 한 번에 처리 (-mavx 또는 -march=native 필요)
void add_simd(float* a, const float* b, const float* c, std::size_t n) {
    std::size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 vb = _mm256_loadu_ps(&b[i]);
        __m256 vc = _mm256_loadu_ps(&c[i]);
        __m256 va = _mm256_add_ps(vb, vc);
        _mm256_storeu_ps(&a[i], va);
    }
    for (; i < n; ++i) {   // 8로 나누어떨어지지 않는 나머지
        a[i] = b[i] + c[i];
    }
}

AVX 레지스터(256비트)는 float 8개를 한 번에 처리하므로 연산 처리량의 이론적 상한은 8배입니다. 하지만 이 덧셈 루프는 연산보다 메모리 읽기·쓰기가 병목이라, 배열이 캐시를 넘어가면 메모리 대역폭에 막혀 실제 향상은 이보다 훨씬 작습니다.

이런 단순한 루프는 직접 intrinsics를 쓰지 않아도 컴파일러가 자동으로 벡터화합니다.

void add_auto(float* __restrict a,
              const float* __restrict b,
              const float* __restrict c,
              std::size_t n) {
    for (std::size_t i = 0; i < n; ++i) {
        a[i] = b[i] + c[i];
    }
}
// GCC/Clang: -O3 -march=native (GCC 12부터는 -O2에서도 기본 벡터화)

자동 벡터화가 되려면 접근이 연속적이어야 하고, 반복 사이에 데이터 의존성이 없어야 하며, 컴파일러가 포인터 별칭(aliasing)이 없다고 확신할 수 있어야 합니다. __restrict는 표준이 아닌 컴파일러 확장으로, “이 포인터들이 가리키는 메모리는 겹치지 않는다”는 약속을 컴파일러에 알려 줍니다. 벡터화 여부는 GCC의 -fopt-info-vec이나 Clang의 -Rpass=loop-vectorize로 확인할 수 있습니다.

메모리 정렬

alignas(32) float aligned[100];   // 정적·자동 배열: 32바이트 정렬

// 동적 할당 + 정렬 (C++17)
#include <cstdlib>
// aligned_alloc의 크기는 정렬값의 배수여야 함: 100 floats = 400바이트 → 416으로 올림
void* ptr = std::aligned_alloc(32, 416);
float* aligned_ptr = static_cast<float*>(ptr);
std::free(ptr);

std::aligned_alloc은 MSVC에서 지원되지 않으므로 Windows에서는 _aligned_malloc/_aligned_free를 쓰거나, C++17의 정렬 인지 new(alignas(32)가 붙은 타입을 new로 할당하면 자동으로 정렬)를 쓰는 편이 이식성이 좋습니다.

최근 x86 CPU에서는 정렬되지 않은 로드(_mm256_loadu_ps)도 캐시 라인 경계를 넘지 않으면 정렬된 로드와 거의 같은 속도입니다. 비용이 생기는 것은 로드가 캐시 라인(또는 페이지) 경계에 걸칠 때이므로, 정렬의 효과는 CPU 세대와 접근 패턴에 따라 없음부터 눈에 띄는 수준까지 다양합니다. _mm256_load_ps처럼 정렬을 요구하는 명령에 정렬되지 않은 주소를 넘기면 크래시가 난다는 점이 정렬을 신경 써야 하는 더 중요한 이유입니다.

멀티스레드 배열 처리

#include <thread>
#include <vector>

void parallel_process(std::vector<int>& data, std::size_t num_threads) {
    const std::size_t chunk_size = data.size() / num_threads;
    std::vector<std::thread> threads;

    for (std::size_t t = 0; t < num_threads; ++t) {
        std::size_t start = t * chunk_size;
        std::size_t end = (t == num_threads - 1) ? data.size() : start + chunk_size;

        threads.emplace_back([&data, start, end]() {
            for (std::size_t i = start; i < end; ++i) {
                data[i] = expensive_computation(data[i]);
            }
        });
    }
    for (auto& thread : threads) {
        thread.join();
    }
}

배열을 연속된 덩어리로 나누면 각 스레드가 서로 다른 메모리 영역을 다루므로 동기화가 필요 없고, 캐시 라인을 공유하는 것은 덩어리 경계의 한 라인뿐이라 false sharing도 거의 없습니다. 반대로 i % num_threads처럼 원소를 번갈아 나눠 주면 모든 라인을 여러 스레드가 함께 쓰게 되어 false sharing이 심해집니다. 데이터가 작거나 원소당 계산이 가벼우면 스레드 생성 비용이 이득보다 커지므로, C++17의 병렬 알고리즘(std::transform(std::execution::par, ...))이나 스레드 풀을 쓰는 편이 낫습니다.


피해야 할 패턴

VLA(가변 길이 배열)

void bad_function(std::size_t n) {
    int arr[n];  // C99 기능, C++ 표준 아님 (GCC/Clang 확장)
}

void good_function(std::size_t n) {
    std::vector<int> arr(n);
    // 또는
    auto arr2 = std::make_unique<int[]>(n);
}

VLA는 GCC와 Clang에서 확장으로 컴파일되지만 MSVC에서는 에러이고, n이 크면 스택 오버플로를 일으키며, 크기를 검사할 방법도 없습니다. -Wvla 경고로 코드베이스에서 찾아낼 수 있습니다.

원시 포인터로 배열 관리

int* arr = new int[100];
// ... 예외가 나거나 일찍 return하면 delete[]가 실행되지 않음
delete[] arr;

std::vector<int> arr2(100);                 // RAII
auto arr3 = std::make_unique<int[]>(100);   // RAII

new[]로 할당한 메모리를 delete(대괄호 없이)로 해제하는 것도 미정의 동작이므로, 소유권은 std::vector나 std::unique_ptr<T[]>에 맡기는 것이 안전합니다.

큰 std::array의 값 전달

void process(std::array<int, 1000> arr);          // 호출마다 4KB 복사
void process(const std::array<int, 1000>& arr);   // 복사 없음
void process(std::span<const int> arr);           // 크기와 무관하게 받기 (C++20)

std::array는 C 배열과 달리 값으로 복사되므로, 큰 배열을 값으로 받으면 호출할 때마다 전체가 복사됩니다.


컨테이너 선택 기준

요구사항권장이유
컴파일 타임 고정 크기, 작은 데이터std::array할당 없음, 크기가 타입에 포함
런타임에 결정되는 크기std::vector동적 크기
큰 데이터std::vector스택 오버플로 방지
C API 연동.data()연속 메모리 포인터 제공
고정 크기 2D 데이터std::array<std::array<T, M>, N>연속 메모리
가변 크기 2D 데이터std::vector<T> + 인덱스 계산 또는 std::mdspan연속 메모리, 캐시 효율
대량 데이터에서 일부 필드만 반복 처리SoA 레이아웃필요한 필드만 읽음, 벡터화 용이

std::array<std::array<T, M>, N>이 연속 메모리라는 것은 표준이 명시적으로 보장하지는 않지만, 패딩이 없는 주요 구현에서는 사실상 C의 2차원 배열과 같은 레이아웃입니다.


참고 자료

같이 보면 좋은 글