(최적화 1) 메모리 얼라인먼트와 병목
메모리 얼라인먼트가 성능에 미치는 영향과 실제 병목이 어디서 발생하는지 알아봅니다.
이전 글: 최적화 목록
이 글은 매매 시스템 시리즈의 최적화 파트 첫 번째 글입니다.
다음 글: 비트 패킹으로 주문 ID 최적화
메모리 얼라인먼트란?
메모리 얼라인먼트(Memory Alignment)는 데이터를 메모리의 특정 바이트 경계에 배치하는 것입니다.
정의
N바이트 데이터가 자연 정렬(Natural Alignment)되었다는 것은 해당 데이터의 시작 주소가 N의 배수라는 뜻입니다.
1
addr % N == 0
| 데이터 타입 | 크기 | 자연 정렬 조건 |
|---|---|---|
| u8 / i8 | 1바이트 | 모든 주소 (항상 정렬됨) |
| u16 / i16 | 2바이트 | 주소가 2의 배수 |
| u32 / i32 / f32 | 4바이트 | 주소가 4의 배수 |
| u64 / i64 / f64 | 8바이트 | 주소가 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는:
- 두 개의 워드를 읽고
- 필요한 바이트를 추출해서
- 하나로 합침
이 과정에서 메모리 접근 횟수가 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개 원소의 곱셈 연산:
| 타입 | 시간 |
|---|---|
| f32 | 190 ns |
| f64 | 394 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 Bound | CPU 연산 | 산술 연산이 많은 경우 |
| 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
| 타입 | 크기 | 캐시 라인당 개수 |
|---|---|---|
| f32 | 4바이트 | 16개 |
| f64 | 8바이트 | 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가 캐시 효율적입니다.
결론
- 대부분의 병목은 메모리 이동에서 발생합니다
- 단일 연산에서 f64가 f32보다 약간 빠를 수 있지만 (word size)
- 벡터 연산에서는 f32가 두 배 빠릅니다 (메모리 대역폭)
- 메모리 레이아웃 관리가 정말 중요합니다
매매 시스템에서 나노초를 다툴 때, 알고리즘 최적화 전에 데이터 구조의 메모리 레이아웃부터 점검하세요.
