Cache-Friendly C++ - Jonathan Müller - CppCon 2025

발표자 소개

Jonathan Müller는 ThinkCell에서 근무하는 C++ 개발자다. ThinkCell은 Microsoft PowerPoint용 플러그인을 개발하는 회사로, 슬라이드 제작을 위한 커스텀 다이어그램 타입과 고급 자동 레이아웃 알고리즘을 제공한다. PowerPoint를 역공학(Reverse Engineering)하여 seamless한 경험을 제공하며, 그 위에 커스텀 표준 라이브러리를 포함한 대량의 C++ 코드를 운용하고 있다.

벤치마크 방법론에 대한 경고

이 강연은 성능에 관한 내용이지만, 올바른 벤치마킹 방법론을 다루지는 않는다. 발표자는 “원하는 결과를 먼저 정하고, 그 결과를 얻을 때까지 벤치마크를 조정했다”고 솔직히 밝힌다. 특정 효과를 시연하기 위해 CPU 아키텍처를 전략적으로 선택했으며(일부는 노트북, 일부는 Apple M1), 제시된 수치는 실제이지만 실제 최적화 결정을 내리기 전에는 반드시 직접 측정해야 한다고 강조한다.

동기부여: Set 구현의 성능 비교

Set을 구현하는 방법으로 다음과 같은 선택지가 있다:

자료구조Lookup 복잡도Insertion 복잡도
std::set (Binary Search Tree)O(log n)O(log n)
std::unordered_set (Hash Table)O(1) amortizedO(1) amortized
std::vector (Unsorted)O(n)O(1)
std::vector (Sorted)O(log n)O(n) 단일 삽입, O(n log n) 배치 삽입

Big-O 복잡도만 본다면 std::unordered_set이 가장 빠를 것으로 예상되지만, 실제 벤치마크 결과는 다르다:

  • 64개 원소일 때: Unsorted std::vector가 모든 컨테이너보다 빠르다
  • 전반적으로: Sorted std::vector가 O(1)인 std::unordered_set을 일관되게 능가한다
  • Unsorted vector는 선형 성장을 명확히 보인다

이러한 현상의 답은 CPU 캐시에 있다.

CPU 캐시의 기본 원리

메모리 접근 속도의 현실

메모리 접근이 단순히 CPU ↔ RAM 사이에서 일어난다면, 컴퓨터는 매우 느릴 것이다. Apple M1을 예로 들면:

  • RAM 접근 속도: ~70 GB/s
  • 연산 성능: 2.6 테라플롭스 = ~10,000 GB/s
  • 성능 차이: 연산 속도가 메모리 접근보다 100배 빠르다

만약 모든 연산마다 메인 메모리에 접근해야 한다면, 실제 성능은 100분의 1로 떨어질 것이다.

캐시 계층 구조

이 문제를 해결하기 위해 CPU는 **캐시 계층(Cache Hierarchy)**을 사용한다. Apple M1의 경우:

캐시 레벨크기접근 시간특징
L1 Cache64-128 KB~3 cycles코어당 독립적
L2 Cache8-12 MB~18 cycles여러 코어가 공유
L3 Cache8 MB~18 cycles + 10-15ns 지연GPU와 공유
Main Memory16-32 GB+100ns 추가 지연

캐시 작동 방식

// 메모리 주소 2 접근
load register, address_2  // L1 miss → 캐시에 로드 → CPU로 전송
 
// 메모리 주소 5 접근
load register, address_5  // L1 miss → 캐시에 로드 → CPU로 전송
 
// 메모리 주소 2 재접근
load register, address_2  // L1 hit! → 메인 메모리 접근 불필요

캐시가 가득 찬 상태에서 새로운 데이터를 로드하면 **eviction(퇴거)**이 발생한다. 가장 최근에 사용하지 않은 데이터를 퇴거시키고 새 데이터를 로드하는데, 이것이 바로 직후에 필요한 데이터일 수 있다는 점이 문제다.

랜덤 메모리 접근 벤치마크 분석

랜덤 데이터에 대해 랜덤 수정을 수행하는 벤치마크에서:

  • 파란색 영역 (~64KB까지): 모든 데이터가 L1 캐시에 fit → 일정한 속도 유지
  • 녹색 영역 (~8-12MB까지): L2 캐시 범위. L1 miss 확률이 증가하면서 속도 저하
  • 주황색 영역 (그 이상): L2도 초과. L2 miss도 증가하면서 급격한 속도 저하
  • 최종 단계: 거의 모든 접근이 메인 메모리로 가면서 성능이 평탄화

Write-Through vs Write-Back 캐시

캐시된 주소에 쓰기 작업을 할 때:

  • Write-Through: 캐시와 메인 메모리 모두 업데이트 (간단하지만 느림)
  • Write-Back: 캐시만 업데이트하고, 퇴거될 때 메인 메모리 업데이트 (효율적)

CPU 캐시는 일반적으로 Write-Back 방식을 사용하며, 이는 멀티스레딩에서 중요한 영향을 미친다.

캐시 효율성을 위한 타입 크기 최적화

기본 원칙: 작은 타입 사용

나이를 저장하고 평균을 계산하는 예시:

// int 사용 (4바이트)
std::vector<int> ages;
for (auto age : ages) total += age;
auto average = total / ages.size();
 
// short 사용 (2바이트) - 더 빠름
std::vector<short> ages;
 
// uint8_t 사용 (1바이트) - short보다는 느리지만 int보다는 빠름
std::vector<uint8_t> ages;

벤치마크 결과: short > uint8_t > int

uint8_t가 short보다 더 많은 데이터를 캐시에 넣을 수 있음에도 느린 이유는 해당 CPU에서 uint8_t 산술 연산이 short보다 느리기 때문이다. 이것이 바로 항상 측정해야 하는 이유다.

Primitive Type 최적화 가이드라인

// ✓ 작은 primitive 타입 사용
signed char, short  // int 대신
float              // double 대신
 
// ✓ Enum의 underlying type 명시
enum class Status : uint8_t {  // 기본값 int(4바이트) 대신
    Active,
    Inactive,
    Pending
};

1바이트 정수 타입의 함정

C++에는 5가지 1바이트 정수 타입이 있다:

signed char      // int8_t
unsigned char    // uint8_t
char            // 구현 정의된 signedness, 별도 타입
char8_t         // UTF-8 전용
std::byte       // 메모리 접근 전용 enum class

벤치마크 결과의 이상 현상: char8_t가 다른 모든 타입보다 빠르다!

Strict Aliasing Rule의 영향

이 성능 차이는 CPU 명령어나 캐시가 아닌 옵티마이저 때문이다.

Strict Aliasing Rule: 어떤 타입의 객체는 호환 타입의 포인터로만 접근 가능하다. 단, char*, unsigned char*, std::byte*는 어떤 타입이든 가리킬 수 있다 (aliasing type).

// Aliasing type (char, signed char, unsigned char, std::byte)
void process(std::vector<T>& data) {
    for (size_t i = 0; i < data.size(); ++i) {  // ❌ data.size()가 루프마다 재평가
        data[i]++;  // 이 할당이 vector의 size를 변경할 수도 있다고 가정
    }
}
 
// Non-aliasing type (char8_t)
void process(std::vector<char8_t>& data) {
    for (size_t i = 0; i < data.size(); ++i) {  // ✓ data.size()가 hoist됨
        data[i]++;  // vector에 영향 없음을 optimizer가 인지
    }
}

해결책:

// 방법 1: 명시적으로 hoist
auto size = data.size();
for (size_t i = 0; i < size; ++i) {
    data[i]++;
}
 
// 방법 2: Range-based for loop
for (auto& element : data) {
    element++;
}

Bit Fields로 1바이트 미만 표현

// 일반 struct: 3바이트
struct Widget {
    bool enabled;     // 1바이트
    bool visible;     // 1바이트
    State state;      // 1바이트 (enum)
};  // sizeof(Widget) == 3
 
// Bit fields 사용: 1바이트
struct Widget {
    bool enabled : 1;  // 1비트
    bool visible : 1;  // 1비트
    State state : 2;   // 2비트
    // 4비트 여유 공간
};  // sizeof(Widget) == 1

주의사항: 접근 시 마스킹(masking) 오버헤드가 발생하므로, 캐시 효율 증가분이 접근 오버헤드를 상쇄하는지 벤치마크해야 한다.

메모리 정렬(Alignment)과 패딩(Padding)

Alignment의 기본 원리

타입의 alignment는 메모리 주소에 대한 제약이다. 주소는 alignment의 배수여야 한다:

// 1-byte aligned: 모든 주소 가능
uint8_t value;  
 
// 4-byte aligned: 주소가 4의 배수여야 함
uint32_t value;  
 
// 8-byte aligned: 주소가 8의 배수여야 함
uint64_t* pointer;

Struct의 Padding

struct Message {
    uint8_t type;        // 1바이트, offset 0
    uint32_t length;     // 4바이트, offset 4 (3바이트 padding 필요)
    unsigned char* data; // 8바이트, offset 8
    uint16_t checksum;   // 2바이트, offset 16
};
// 논리적 크기: 15바이트
// 실제 크기: 24바이트 (alignment 8)

메모리 레이아웃:

[type:1][PAD:3][length:4][data:8][checksum:2][PAD:6]
 0       1      4         8        16          18    24

최적화된 순서 (큰 alignment → 작은 alignment):

struct Message {
    unsigned char* data; // 8바이트, offset 0
    uint32_t length;     // 4바이트, offset 8
    uint16_t checksum;   // 2바이트, offset 12
    uint8_t type;        // 1바이트, offset 14
};
// 실제 크기: 16바이트 (padding 1바이트만 필요)

메모리 레이아웃:

[data:8][length:4][checksum:2][type:1][PAD:1]
 0       8         12          14       15    16

Pointer의 Lower Bits 활용

Alignment 때문에 포인터의 하위 비트는 항상 0이다. 이를 활용할 수 있다:

// 비효율적: 16바이트
struct Node {
    bool is_text;    // 8바이트 (padding 포함)
    void* pointer;   // 8바이트
};
 
// 효율적: 8바이트
class Node {
    uintptr_t impl;  // pointer + 1 bit flag
 
public:
    bool is_text() const {
        return impl & 1;  // 최하위 비트 확인
    }
    
    Container* as_container() const {
        return reinterpret_cast<Container*>(impl);  // 이미 0
    }
    
    Text* as_text() const {
        return reinterpret_cast<Text*>(impl & ~1);  // 최하위 비트 제거
    }
};

전제 조건: Container와 Text의 alignment가 최소 2 이상이어야 함.

Cache Line과 Prefetching

Cache Line의 개념

메인 메모리 접근은 느리므로, 1바이트만 가져오는 것은 비효율적이다. 대신 cache line 단위(보통 64 또는 128바이트)로 연속된 데이터를 가져온다.

// Cache line 크기 = 2바이트라고 가정
 
// 주소 2 로드 → [2, 3] 전체를 캐시에 로드
load address_2
 
// 주소 5 로드 → [4, 5] 전체를 캐시에 로드
load address_5
 
// 주소 3 접근 → 이미 캐시에 있음! (주소 2 로드 시 함께 들어옴)
load address_3  // Cache hit

Chunk Access 벤치마크

랜덤 청크를 선택하고, 청크 내에서 랜덤하게 접근하는 벤치마크:

  • 청크 크기를 키울수록 성능 향상
  • 하지만 diminishing returns 발생 (400바이트 이후 효과 감소)
  • 이유: Cache line을 더 효율적으로 활용하기 때문

Hardware Prefetcher

CPU는 메모리 접근 패턴을 예측하고 미리 로드한다:

// Sequential forward access
for (size_t i = 0; i < data.size(); ++i) {
    process(data[i]);  // Prefetcher가 data[i+1], data[i+2] 등을 미리 로드
}
 
// Random access
size_t idx = random();
process(data[idx]);  // Prefetcher가 예측 불가능 → cache miss

벤치마크 결과:

  • Sequential access (forward/backward): 데이터 크기와 무관하게 일정한 성능
  • Random access: 데이터 크기 증가 시 성능 급감

Cache-Friendly Containers

설계 원칙

  1. 예측 가능한 메모리 접근 패턴: Prefetcher가 도움을 줄 수 있도록
  2. Pointer Chasing 회피: 포인터 값을 알기 전까지 prefetch 불가능
  3. 타입 크기 최소화: Cache line에 더 많은 데이터 fit
  4. 불필요한 데이터 분리: 사용하지 않는 데이터는 cache line을 낭비함

Container별 분석

✓ Cache-Friendly: std::vector<T>

std::vector<int> data;
// 메모리: [v0][v1][v2][v3][v4]...
// ✓ 연속된 메모리
// ✓ 메타데이터 없음
// ✓ Sequential iteration → prefetcher 활용

✗ Cache-Unfriendly: std::list<T>

std::list<int> data;
// 메모리: [prev*|v0|next*] → [prev*|v1|next*] → ...
// ✗ 포인터가 cache line 낭비
// ✗ 노드가 메모리 곳곳에 흩어짐
// ✗ Iteration 시 pointer chasing

△ 중간: std::deque<T>

std::deque<int> data;
// 메모리: vector of pointers to arrays
// [ptr0] → [v0][v1][v2]...
// [ptr1] → [v100][v101][v102]...
// △ 블록 내에서는 연속적
// △ 블록 전환 시에만 pointer chasing
// ⚠ 블록 크기가 충분히 커야 효과적

주의: 일부 표준 라이브러리 구현에서 블록 크기가 너무 작아 실질적으로 linked list와 다를 바 없음.

✗ Cache-Unfriendly: std::set, std::map

std::set<int> data;
// Binary Search Tree
// 메모리: [left*|value|right*] 노드들이 흩어짐
// ✗ Linked list와 동일한 문제
// ✗ Pointer chasing
// ✗ 메타데이터 낭비

✗ Cache-Unfriendly: std::unordered_set, std::unordered_map

std::unordered_map<K, V> data;
// Closed addressing (separate chaining)
// 메모리: vector of bucket pointers
// [bucket0*] → [next*|k0|v0] → [next*|k1|v1]...
// [bucket1*] → ...
// ✗ Bucket 내에서 pointer chasing
// ✗ 메타데이터 낭비

더 나은 대안: Open addressing hash table

// Open addressing
// 메모리: [k0|v0][k1|v1][empty][k2|v2]...
// ✓ 평평한(flat) 메모리 레이아웃
// ✓ 충돌 시 인접 슬롯 확인 → cache-friendly
// ⚠ 표준 라이브러리는 제공하지 않음

권장사항

거의 모든 경우: std::vector 사용

추가 고려:

  • Pointer stability 필요 시: 큰 블록 크기의 std::deque 또는 custom container
  • Hash table 필요 시: Open addressing 구현 사용

Data-Oriented Design (DoD)

Object-Oriented Programming (OOP) vs Data-Oriented Design (DoD)

측면OOPDoD
초점객체 (도메인별 추상화)알고리즘 (데이터 변환)
구조클래스 = 데이터 + 동작알고리즘이 데이터에 작용
다형성상속, virtual functions여러 동질적(homogeneous) 컬렉션
메모리Array of Structures (AoS)Structure of Arrays (SoA)

예시 1: People 데이터

OOP 방식

class Person {
    std::string name;
    int age;
public:
    const std::string& get_name() const;
    int get_age() const;
};
 
std::vector<Person> people;
 
// 평균 나이 계산
int average_age(const std::vector<Person>& people) {
    int total = 0;
    for (const auto& person : people) {
        total += person.get_age();  // name도 cache line에 로드됨 (낭비)
    }
    return total / people.size();
}
 
// 최고령자 이름
std::string oldest_name(const std::vector<Person>& people) {
    // ...
}

메모리 레이아웃 (Array of Structures):

[name0|age0][name1|age1][name2|age2]...

문제점: average_age는 age만 필요한데, name도 cache line을 차지함.

DoD 방식

// 알고리즘부터 설계
int average_age(const std::vector<int>& ages) {  // age만 전달
    int total = 0;
    for (int age : ages) {
        total += age;
    }
    return total / ages.size();
}
 
size_t oldest_person_index(const std::vector<int>& ages) {
    return std::max_element(ages.begin(), ages.end()) - ages.begin();
}
 
std::string oldest_name(
    const std::vector<std::string>& names,
    const std::vector<int>& ages
) {
    return names[oldest_person_index(ages)];
}
 
// 데이터 구조
struct People {
    std::vector<std::string> names;
    std::vector<int> ages;
};

메모리 레이아웃 (Structure of Arrays):

names: [name0][name1][name2]...
ages:  [age0][age1][age2]...

장점: average_age는 ages만 순회 → cache line 낭비 없음.

예시 2: Shapes

OOP 방식

class Shape {
public:
    virtual double area() const = 0;
};
 
class Circle : public Shape {
    double radius;
public:
    double area() const override { return M_PI * radius * radius; }
};
 
class Square : public Shape {
    double side;
public:
    double area() const override { return side * side; }
};
 
std::vector<std::unique_ptr<Shape>> shapes;
 
double total_area(const std::vector<std::unique_ptr<Shape>>& shapes) {
    double total = 0;
    for (const auto& shape : shapes) {
        total += shape->area();  // Virtual function call, pointer chasing
    }
    return total;
}

메모리 레이아웃:

Vector: [ptr0][ptr1][ptr2]...
         ↓     ↓     ↓
Heap:   Circle Square Circle...

문제점:

  • Pointer chasing → prefetcher 무력화
  • Virtual function call → 간접 점프
  • Pointers가 cache line 낭비

DoD 방식 1: std::variant

struct Circle { double radius; };
struct Square { double side; };
 
using Shape = std::variant<Circle, Square>;
std::vector<Shape> shapes;
 
double total_area(const std::vector<Shape>& shapes) {
    double total = 0;
    for (const auto& shape : shapes) {
        total += std::visit([](auto&& s) {
            using T = std::decay_t<decltype(s)>;
            if constexpr (std::is_same_v<T, Circle>)
                return M_PI * s.radius * s.radius;
            else
                return s.side * s.side;
        }, shape);
    }
    return total;
}

메모리 레이아웃:

[tag|Circle][tag|Square][tag|Circle]...

개선점: Inline storage, 하지만 여전히 tag가 공간 낭비 (alignment로 인해 8바이트).

DoD 방식 2: 분리된 벡터 (최적)

struct Circle { double radius; };
struct Square { double side; };
 
struct Shapes {
    std::vector<Circle> circles;
    std::vector<Square> squares;
};
 
double total_area(const Shapes& shapes) {
    double total = 0;
    
    // Circles 처리 - 브랜치 없음, 정적으로 알려진 공식
    for (const auto& circle : shapes.circles) {
        total += M_PI * circle.radius * circle.radius;
    }
    
    // Squares 처리 - 브랜치 없음, 정적으로 알려진 공식
    for (const auto& square : shapes.squares) {
        total += square.side * square.side;
    }
    
    return total;
}

메모리 레이아웃:

circles: [radius0][radius1][radius2]...
squares: [side0][side1][side2]...

장점:

  • Tag 불필요 (어느 벡터인지로 타입 결정)
  • 각 루프에서 브랜치 없음 (타입이 정적으로 결정됨)
  • 완벽한 vectorization 가능
  • Prefetcher 최대 활용

벤치마크 결과: 분리된 벡터 > variant > unique_ptr

DoD 핵심 원칙

// ✓ 알고리즘부터 설계
// ✓ N개 요소에 대해 작동하는 함수 작성 (단일 요소 아님)
// ✓ 필요한 데이터만 배열에 포함
// ✓ variant/bool 기반 분기 → 여러 동질적 배열로 분리
 
// ✗ 객체 먼저 설계
// ✗ 단일 요소 함수 → 루프에서 호출
// ✗ 사용하지 않는 데이터도 함께 저장
// ✗ 런타임 분기

멀티스레딩과 False Sharing

False Sharing의 개념

멀티코어 환경에서 각 코어는 자체 L1 캐시를 가진다. 한 코어가 메모리를 수정하면 다른 코어의 캐시를 **무효화(invalidate)**해야 한다. 하지만 CPU는 cache line 단위로 무효화한다.

// 병렬 fold 패턴
template<typename T>
T parallel_fold(ThreadPool& pool, std::vector<T> work) {
    std::vector<T> local_results(pool.thread_count());
    
    pool.execute([&](size_t thread_idx) {
        local_results[thread_idx] = 0;  // ❌ False sharing!
        
        for (auto& item : get_work_slice(work, thread_idx)) {
            local_results[thread_idx] += process(item);
        }
    });
    
    // Final reduction
    return std::accumulate(local_results.begin(), local_results.end(), T{});
}

문제 시나리오:

Cache line: [local_results[0]][local_results[1]]
            
Thread 0: local_results[0] = ... → cache line 무효화
Thread 1: local_results[1] 읽기 → cache miss! (수정되지 않았는데도)

벤치마크 결과: 스레드 수를 늘려도 성능이 향상되지 않고, 오히려 감소함.

False Sharing 해결책

방법 1: Cache Line Padding

// Cache line 크기 얻기
constexpr size_t cache_line_size = 
    std::hardware_destructive_interference_size;  // 보통 64바이트
 
// Aligned storage
alignas(cache_line_size) std::array<T, N * (cache_line_size / sizeof(T))> 
    local_results;
 
pool.execute([&](size_t thread_idx) {
    size_t idx = thread_idx * (cache_line_size / sizeof(T));
    local_results[idx] = 0;  // ✓ 각자의 cache line
    
    for (auto& item : get_work_slice(work, thread_idx)) {
        local_results[idx] += process(item);
    }
});

메모리 레이아웃:

[result0][padding...][result1][padding...][result2][padding...]
 ← 64 bytes →         ← 64 bytes →         ← 64 bytes →

벤치마크 결과: 스레드 수에 비례하여 성능 향상!

방법 2: 로컬 변수 사용 (더 간단)

pool.execute([&](size_t thread_idx) {
    T local = 0;  // ✓ 스택 변수
    
    for (auto& item : get_work_slice(work, thread_idx)) {
        local += process(item);
    }
    
    local_results[thread_idx] = local;  // 마지막에 한 번만 쓰기
});

효과: 마지막 쓰기에서만 false sharing 발생 → 무시할 만한 오버헤드.

멀티스레딩 메모리 원칙

// ✓ 서로 다른 스레드가 동시 사용하는 데이터 → 다른 cache line에 배치
// ✓ 같은 스레드가 사용하는 데이터 → 같은 cache line에 배치
 
// Race condition이 아니어도 False sharing은 성능을 파괴한다!

코드도 데이터다 (Code is Data)

Instruction Cache

CPU 명령어도 메모리에 저장되고, 접근 시 캐시를 사용한다:

  • L1 I-Cache: Data cache와 분리
  • L2 Cache: 명령어와 데이터 통합(unified)

실제 측정된 L2 캐시 크기가 사양보다 작게 나타나는 이유: 프로그램 코드가 일부를 차지하기 때문.

기괴한 코드 레이아웃 효과

// 버전 1: sum 먼저
for (auto value : data) {
    sum += value;
    if (value == 0) {  // 매우 드물게 true
        // 64번의 증분 연산
        repeat_64(counter++);
    }
}
 
// 버전 2: 조건 먼저
for (auto value : data) {
    if (value == 0) {
        repeat_64(counter++);
    }
    sum += value;
}

Apple M1 고성능 코어 벤치마크:

  • 버전 1: ~2 GB/s
  • 버전 2: ~8 GB/s

4배 차이! 로직은 동일한데 코드 배치만 다름.

원인 추정: Assembly 명령어가 메모리에 배치되는 방식, cache line alignment 등이 미묘하게 달라져 instruction cache 성능에 영향.

Cache-Friendly 코드 작성

데이터 최적화코드 최적화
순차적 접근순차적 실행 (적은 브랜치)
Pointer chasing 회피간접 점프 회피 (virtual, function pointer)
타입 크기 최소화루프 본문 최소화
// ✗ 루프 내 함수 호출
for (auto& item : items) {
    process(item);  // 함수가 멀리 있으면 I-cache miss
}
 
// ✓ 인라인 또는 루프 내 직접 작성
for (auto& item : items) {
    // Inline logic
    item.value *= 2;
}
 
// ✗ Virtual function (간접 점프)
for (auto& shape : shapes) {
    total += shape->area();  // Prefetcher가 예측 불가
}
 
// ✓ 정적 디스패치
for (auto& circle : circles) {
    total += M_PI * circle.radius * circle.radius;  // 컴파일 타임에 결정
}

핵심 정리

CPU 캐시 기본 사항

  1. 캐시: 작고 빠른 메모리로 느린 메인 메모리 접근 회피
  2. Cache Line: 메모리와 캐시 간 전송의 최소 단위 (64-128 바이트)
  3. Prefetcher: 메모리 접근 패턴을 학습하여 미리 로드
  4. False Sharing: 멀티스레딩에서 무관한 데이터의 cache line 공유로 인한 무효화

Cache-Friendly 데이터 설계

// ✓ 타입 크기 최소화
short, uint8_t, float  // int, double 대신
enum class E : uint8_t  // 기본 int 대신
 
// ✓ Struct 멤버 재정렬 (큰 alignment → 작은 alignment)
struct Optimized {
    void* ptr;      // 8 bytes
    uint32_t val;   // 4 bytes
    uint16_t id;    // 2 bytes
    uint8_t flag;   // 1 byte
};  // 16 bytes (padding 1)
 
// ✓ Pointer bits 활용
uintptr_t tagged_ptr = ptr | flag;  // 최하위 비트에 플래그 저장
 
// ✓ Cache-friendly containers
std::vector  // ✓✓✓
std::deque   // ✓ (큰 블록 크기 전제)
Open addressing hash table  // ✓✓
 
// ✗ Cache-unfriendly containers
std::list, std::set, std::map, std::unordered_map  // ✗✗✗

Cache-Friendly 코드 설계

// ✓ 순차 접근
for (size_t i = 0; i < n; ++i) {
    process(data[i]);
}
 
// ✗ 랜덤 접근
for (size_t i = 0; i < n; ++i) {
    process(data[random()]);
}
 
// ✓ 필요한 데이터만 순회
double average_age(const std::vector<int>& ages);  // names 불필요
 
// ✗ 전체 객체 순회
double average_age(const std::vector<Person>& people);  // names도 로드됨
 
// ✓ 분리된 동질적 컬렉션
for (auto& circle : circles) area += compute(circle);
for (auto& square : squares) area += compute(square);
 
// ✗ Variant/virtual 기반 분기
for (auto& shape : shapes) {
    if (shape.type == Circle) ...
    else if (shape.type == Square) ...
}

Data-Oriented Design 원칙

// 1. 알고리즘(데이터 변환) 먼저 설계
ReturnType algorithm(RequiredData data);
 
// 2. N개 요소에 작동하는 함수 작성
double total_area(const std::vector<Circle>& circles);
 
// 3. Structure of Arrays (SoA)
struct People {
    std::vector<std::string> names;  // 필요할 때만 접근
    std::vector<int> ages;           // 자주 접근
};
 
// 4. 여러 동질적 컬렉션
struct Shapes {
    std::vector<Circle> circles;
    std::vector<Square> squares;
    // std::variant<Circle, Square> 대신
};

멀티스레딩 고려사항

// ✓ Cache line padding
alignas(std::hardware_destructive_interference_size)
std::array<Result, N> results;
 
// ✓ 로컬 변수 활용
T local = 0;
for (...) local += ...;
shared[idx] = local;  // 마지막에 한 번만
 
// ✗ 공유 cache line에 빈번한 쓰기
for (...) shared[idx] += ...;  // False sharing!

측정의 중요성

"항상 측정하라!"
- 최적화 전후 벤치마크 필수
- CPU 아키텍처마다 다른 결과
- 한 지표 개선이 다른 지표를 악화시킬 수 있음

참고 자료

(강연 내용과 별개)

강연에서는 기본 원리와 실용적 기법에 집중했지만, 더 깊은 이해를 위해서는 다음을 참고할 수 있다:

  • CPU 아키텍처 문서 (Intel, AMD, ARM 등의 최적화 매뉴얼)
  • Hardware performance counters (perf, VTune)
  • Compiler Explorer (godbolt.org)로 assembly 확인
  • std::hardware_destructive_interference_size 등 C++17 이후 표준 기능
  • Cache oblivious algorithms 연구

발표자는 특정 효과의 정확한 원인을 완전히 파악하지 못한 경우도 있었으며(예: 코드 배치에 따른 4배 성능 차이), 이는 현대 CPU의 복잡성을 보여준다. 실무에서는 측정 → 프로파일링 → 가설 → 검증 사이클이 필수적이다.