Post

(최적화 1) 메모리 얼라인먼트와 병목

메모리 얼라인먼트가 성능에 미치는 영향과 실제 병목이 어디서 발생하는지 알아봅니다.

(최적화 1) 메모리 얼라인먼트와 병목

이전 글: 최적화 목록

이 글은 매매 시스템 시리즈의 최적화 파트 첫 번째 글입니다.

다음 글: 비트 패킹으로 주문 ID 최적화

메모리 얼라인먼트란?

메모리 얼라인먼트(Memory Alignment)는 데이터를 메모리의 특정 바이트 경계에 배치하는 것입니다.

정의

N바이트 데이터가 자연 정렬(Natural Alignment)되었다는 것은 해당 데이터의 시작 주소가 N의 배수라는 뜻입니다.

1
addr % N == 0
데이터 타입크기자연 정렬 조건
u8 / i81바이트모든 주소 (항상 정렬됨)
u16 / i162바이트주소가 2의 배수
u32 / i32 / f324바이트주소가 4의 배수
u64 / i64 / f648바이트주소가 8의 배수

왜 정렬이 필요한가?

CPU는 메모리를 워드(word) 단위로 읽습니다. 64비트 CPU는 한 번에 8바이트를 읽습니다.

정렬된 경우:

1
2
3
4
5
6
7
8
9
Memory Address:  0x00  0x08  0x10  0x18
                  |     |     |     |
                  v     v     v     v
                +-----+-----+-----+-----+
                |  A  |  B  |  C  |  D  |  (each 8 bytes)
                +-----+-----+-----+-----+
                  ^
                  |
              Read A: 1 memory access

비정렬된 경우:

1
2
3
4
5
6
7
8
9
Memory Address:  0x00  0x08  0x10  0x18
                  |     |     |     |
                  v     v     v     v
                +-----+-----+-----+-----+
                |  ???A|AAAA|A???  |     |
                +-----+-----+-----+-----+
                    ^-------^
                        |
              Read A: 2 memory accesses + merge

비정렬 접근 시 CPU는:

  1. 두 개의 워드를 읽고
  2. 필요한 바이트를 추출해서
  3. 하나로 합침

이 과정에서 메모리 접근 횟수가 2배가 됩니다.

아키텍처별 차이

아키텍처비정렬 접근 처리
x86/x86_64하드웨어가 자동 처리 (성능 저하 미미)
ARM (v6 이전)예외 발생 또는 예측 불가 결과
ARM (v6 이후)대부분 허용, 일부 제약
MIPS예외 발생
PowerPC소프트웨어 처리 (4.6배 느림)

현대 x86에서는 비정렬 접근의 성능 페널티가 거의 없습니다. 하지만 캐시 라인 경계를 넘는 경우에는 여전히 느려집니다.

구조체 패딩

컴파일러는 자연 정렬을 보장하기 위해 패딩(padding)을 삽입합니다.

1
2
3
4
5
struct A {
    a: u8,   // 1 byte
    b: u64,  // 8 bytes
    c: u16,  // 2 bytes
}

이 구조체의 메모리 레이아웃을 보면:

1
2
3
4
5
+--------+--------+--------+
|   a    |  pad   |   b    |   c    |  pad   |
| 1 byte | 7 byte | 8 byte | 2 byte | 6 byte |
+--------+--------+--------+--------+--------+
   0-7       8-15      16-23
  • a는 1바이트지만 다음 8바이트 경계까지 7바이트 패딩
  • b는 8바이트 경계에서 시작
  • c는 2바이트지만 전체 정렬을 맞추기 위해 6바이트 패딩

결과: 11바이트 데이터가 24바이트를 차지

1
2
3
4
5
6
7
8
9
Size: 24 bytes
Alignment: 8 bytes

Memory contents:
0000: AA 00 00 00
0004: 00 00 00 00
0008: AA AA AA AA
000C: AA AA AA AA
0010: CC CC ...

필드 순서를 바꾸면?

1
2
3
4
5
struct B {
    b: u64,  // 8 bytes
    c: u16,  // 2 bytes
    a: u8,   // 1 byte
}

큰 타입부터 작은 타입 순으로 정렬하면 16바이트로 줄어듭니다. 구조체 크기가 33% 감소합니다.


Enum의 메모리

Enum은 가장 큰 variant의 크기에 4바이트 태그가 추가됩니다.

1
2
3
4
5
pub enum MyEnum {
    A(u16),  // 2 bytes
    B(u32),  // 4 bytes
    C(u64),  // 8 bytes
}

메모리 레이아웃:

  • 태그: 4바이트 + 패딩 4바이트
  • 데이터: 8바이트 (가장 큰 C 기준)
  • 총 16바이트

진짜 병목은 어디인가?

여기서 중요한 질문이 있습니다. 메모리 얼라인먼트를 최적화하면 얼마나 빨라질까요? 직접 측정해 봤습니다.

단일 연산 벤치마크

AMD Ryzen 7700에서 1,000개 원소의 곱셈 연산:

타입시간
f32190 ns
f64394 ns
패딩된 구조체937 ns
1
2
3
4
struct A {
    val: f64,
    _pad: u8,
}

패딩된 구조체 A는 f64의 2.4배 느립니다. 16바이트(8 + 패딩)가 8바이트의 두 배 크기이므로, 성능 차이는 거의 메모리 이동 시간에서 발생합니다.

f32 vs f64: 예상과 다른 결과

단일 사칙연산만 보면 f64가 f32보다 미묘하게 빠릅니다. 이건 CPU의 네이티브 word size가 64비트이기 때문입니다.

하지만 Vec<f32> vs Vec<f64> 곱셈을 해보면 결과가 좀 다릅니다:

타입상대 속도
Vec<f32>1x (기준)
Vec<f64>2x 느림

f32가 정확히 두 배 빠릅니다. 조금 의외죠? 왜 그럴까요?


Memory Bound vs Compute Bound

CPU 연산에는 두 가지 병목 유형이 있습니다:

유형병목특징
Compute BoundCPU 연산산술 연산이 많은 경우
Memory Bound메모리 이동데이터 로드/저장이 많은 경우

현대 CPU는 연산 속도가 메모리 속도보다 훨씬 빠르거든요.

캐시 레이턴시 차이

메모리 계층레이턴시
L1 캐시~4 사이클
L2 캐시~12 사이클
L3 캐시~40 사이클
DRAM~300 사이클

L1 캐시와 DRAM의 차이가 75배입니다. 캐시 미스가 발생하면 CPU는 데이터를 기다리며 놀게 됩니다.


캐시 라인

CPU는 메모리를 캐시 라인 단위로 가져옵니다. 대부분의 현대 CPU에서 캐시 라인은 64바이트입니다.

1
2
3
4
+------------------+------------------+------------------+
|   Cache Line 0   |   Cache Line 1   |   Cache Line 2   |
|     64 bytes     |     64 bytes     |     64 bytes     |
+------------------+------------------+------------------+

캐시 라인과 f32/f64

타입크기캐시 라인당 개수
f324바이트16개
f648바이트8개

f32는 같은 캐시 라인에 두 배 많은 데이터를 담을 수 있습니다. 이게 Vec<f32> 곱셈이 두 배 빠른 이유예요. 연산 속도가 아니라 메모리 대역폭이 병목이거든요.


실전 최적화 지침

1. 구조체 필드 순서

큰 타입부터 작은 타입 순으로 정렬:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Bad
struct Bad {
    a: u8,
    b: u64,
    c: u16,
}  // 24 bytes

// Good
struct Good {
    b: u64,
    c: u16,
    a: u8,
}  // 16 bytes

2. AoS vs SoA

Array of Structures보다 Structure of Arrays가 캐시 효율적일 수 있습니다:

1
2
3
4
5
6
7
8
9
10
// AoS (Array of Structures)
struct Particle { x: f64, y: f64, z: f64 }
let particles: Vec<Particle>;

// SoA (Structure of Arrays)
struct Particles {
    x: Vec<f64>,
    y: Vec<f64>,
    z: Vec<f64>,
}

x 좌표만 순회할 때 SoA가 캐시 효율적입니다.


결론

  1. 대부분의 병목은 메모리 이동에서 발생합니다
  2. 단일 연산에서 f64가 f32보다 약간 빠를 수 있지만 (word size)
  3. 벡터 연산에서는 f32가 두 배 빠릅니다 (메모리 대역폭)
  4. 메모리 레이아웃 관리가 정말 중요합니다

매매 시스템에서 나노초를 다툴 때, 알고리즘 최적화 전에 데이터 구조의 메모리 레이아웃부터 점검하세요.

This post is licensed under CC BY 4.0 by the author.