성능을 개선하려고 할 때 가장 먼저 떠올리는 방법은 자원을 늘리는 것입니다. CPU 코어를 추가하고 메모리를 증설하고 더 많은 작업을 병렬로 처리합니다.
멀티코어 환경에서는 자원의 양만큼 작업과 데이터가 어디에 놓여 있는지도 성능을 좌우합니다. 필요한 데이터를 가까운 캐시에서 바로 쓸 수 있는지, 다른 코어와 같은 캐시 라인을 두고 경쟁하는지, 멀리 있는 메모리에서 가져와야 하는지에 따라 같은 연산의 비용이 달라집니다. 참고: Intel, Memory Performance in a Nutshell.
Cache Affinity에서 출발해 동시성 큐의 CachePadded, Seastar의 shard-per-core 구조, NUMA 기반 메모리 배치까지 따라가면 모두 같은 질문으로 이어집니다.
누가 이 데이터를 사용하고, 누가 수정하며, 그 데이터는 어디에 있어야 할까?
이 글은 공식 문서와 공개된 사례를 바탕으로 정리했으며 직접 실행한 실험은 포함하지 않았습니다.
1. 실행하던 코어에서 다시 실행하면 왜 유리할까요?
CPU가 연산을 하려면 먼저 필요한 데이터를 확보해야 합니다. 매번 DRAM에서 가져오는 대신 CPU 가까이에 있는 캐시를 활용합니다.
일반적인 메모리 계층은 레지스터, L1·L2·L3 캐시, DRAM으로 이어집니다. 구체적인 구성과 지연 시간은 하드웨어마다 다르지만, 가까운 계층일수록 작고 빠르며 멀어질수록 접근 비용이 커집니다.
여기서 말하는 비용은 연산에 필요한 데이터를 가져오는 시간입니다.
long result = values[index] + 1;
1을 더하기 전에 values[index]를 읽어야 합니다. 이 값이 가까운 캐시에 있다면 연산을 그만큼 빨리 이어갈 수 있습니다.
이전에 쓰던 데이터가 캐시에 남아 있다면
스레드 A가 Core 0에서 사용자 데이터를 처리했다고 가정하겠습니다. 실행이 끝난 뒤에도 그 데이터 일부는 Core 0의 캐시에 남아 있을 수 있습니다.
잠시 후 A가 같은 코어에서 다시 실행되면 남아 있는 데이터를 재사용할 가능성이 있습니다. 다른 코어에서 실행되면 그 코어에 없는 데이터를 다시 가져와야 할 수 있습니다.
작업을 이전 실행 위치 가까이에 두면 캐시를 다시 쓰기 쉬운 성질을 Cache Affinity라고 합니다. Linux의 CPU affinity는 스레드가 실행될 수 있는 CPU 집합을 제한하는 기능으로, 이런 배치에 활용할 수 있습니다. 참고: sched_setaffinity(2).
실행 위치를 고정해도 캐시 재사용이 보장되지는 않습니다. 필요한 데이터가 다른 데이터에 밀려났을 수도 있고 다른 코어가 수정하면서 복사본이 무효화되었을 수도 있습니다. 반대로 다른 코어로 옮겨졌다고 반드시 DRAM까지 가는 것도 아닙니다. 공유 캐시나 다른 코어의 캐시에서 데이터를 받아 오는 경로도 있습니다.
그렇다면 여러 코어가 같은 데이터의 복사본을 가진 상태에서 누군가 그 데이터를 수정하면 어떻게 될까요?
2. 캐시에 있는 데이터는 마음대로 수정할 수 있을까요?
Core 0과 Core 1이 모두 x = 10을 읽었다고 가정하겠습니다. 두 코어의 캐시에는 같은 메모리 위치의 복사본이 있을 수 있습니다.
이때 Core 0이 x를 11로 바꾸면, Core 1이 이전 복사본을 계속 유효한 값으로 쓰지 않도록 관리해야 합니다. 이 복사본들의 상태를 관리하는 하드웨어 규칙이 Cache Coherence(캐시 일관성)입니다. 대표적인 MESI 계열 프로토콜은 캐시 라인마다 다음 네 상태 중 하나를 둡니다. 참고: Arm, Cache Coherency Fundamentals.
| 상태 | 의미 |
|---|---|
| M (Modified) | 이 캐시만 갖고 있고, 메모리와 다르게 수정됨 |
| E (Exclusive) | 이 캐시만 갖고 있고, 메모리와 같음 |
| S (Shared) | 여러 캐시가 같은 값을 읽기용으로 가짐 |
| I (Invalid) | 더 이상 쓸 수 없는 복사본 |
쓰려면 그 라인의 독점 권한이 필요합니다
다른 캐시에도 복사본이 있는 상황에서 한 코어가 데이터를 수정하려면 쓰기 권한을 먼저 얻어야 합니다. 무효화 기반 프로토콜에서는 이때 다른 캐시에 있는 해당 라인을 무효화합니다.
무효화는 특정 캐시 라인의 복사본을 I 상태로 바꿔 더 이상 쓰지 못하게 하는 동작입니다. 캐시 전체를 비우거나 값을 0으로 만들지는 않습니다. 다른 캐시에 복사본이 있는지는 snoop 요청이나 복사본의 위치를 추적하는 directory 같은 하드웨어 장치로 확인합니다. 참고: Arm, Cache Coherency and Heterogeneous Compute.
쓰기 권한이 코어 사이를 계속 오가면
한 코어가 쓰기 권한을 유지한 채 계속 수정한다면 매번 다른 코어에 권한을 요청할 필요가 없습니다.
문제는 여러 코어가 같은 라인을 번갈아 수정할 때입니다. 쓰기 권한을 요청하고 다른 복사본을 무효화하고 데이터를 넘겨받는 과정이 수정할 때마다 반복됩니다. 이렇게 캐시 라인이 코어 사이를 계속 옮겨 다니는 현상을 Cache Line Bouncing이라고 부릅니다. 짧은 계산 하나를 할 때마다 코어 간 조율 비용을 치르게 됩니다.
이 비용이 얼마나 되는지는 하드웨어마다 다릅니다. Intel이 2소켓 Xeon(Sapphire Rapids)에서 한 코어가 쓴 값을 다른 코어가 받아 보는 데 걸린 시간을 재 보니, 같은 코어의 다른 하이퍼스레드끼리는 약 16.5ns, 같은 소켓의 다른 코어끼리는 약 61.5ns, 다른 소켓의 코어끼리는 약 150ns였습니다. 참고: Intel, Faster Core-to-Core Communications.
이 비용은 여러 코어가 같은 변수를 수정할 때만 생기는 것은 아닙니다.
3. 서로 다른 변수를 수정하는데 왜 서로 방해할까요?
캐시는 변수 하나하나를 따로 관리하지 않습니다. 일정한 크기의 캐시 라인 단위로 데이터를 관리합니다.
캐시 라인이 64바이트인 시스템을 가정하겠습니다. 서로 다른 변수 a와 b가 같은 라인에 놓여 있다면, Core 0이 a를 수정하고 Core 1이 b를 수정해도 두 코어는 같은 라인의 쓰기 권한을 두고 경쟁합니다. 프로그램에서는 독립적인 데이터인데 하드웨어에서는 같은 관리 단위에 속하기 때문입니다. 이를 False Sharing이라고 합니다. 한쪽은 쓰고 다른 쪽은 읽기만 하는 경우에도 생길 수 있습니다. 참고: Linux Kernel, False Sharing.
동시성 큐의 head와 tail
이 문제를 실제 코드에서 확인하기 좋은 예가 동시성 큐입니다.
배열 기반 큐는 보통 head로 다음에 꺼낼 위치를, tail로 다음에 넣을 위치를 관리합니다. Consumer는 pop에서 head를, Producer는 push에서 tail을 주로 갱신합니다. Crossbeam의 ArrayQueue도 같은 역할을 나눠 갖습니다. 정확히는 단순한 포인터 대신 버퍼 위치와 순환 횟수를 함께 담은 정수 상태를 씁니다. 참고: crossbeam-queue ArrayQueue 소스.
Producer는 head를 바꿀 생각이 없고 Consumer도 tail을 바꿀 생각이 없습니다. 그런데도 두 값이 같은 캐시 라인에 놓여 있으면 다음 순서로 서로의 쓰기에 영향을 줍니다.
- Producer가
tail을 쓰려고 라인의 쓰기 권한을 가져갑니다. - Consumer 캐시에 있던 같은 라인이 무효가 됩니다.
- Consumer가
head를 쓰려면 라인을 다시 받아 와야 하고 이번에는 Producer 쪽 복사본이 무효가 됩니다.
CachePadded로 두 상태를 떼어 놓습니다
Crossbeam의 ArrayQueue는 두 필드를 각각 CachePadded로 감쌉니다. 참고: crossbeam ArrayQueue 코드.
// ArrayQueue의 필드 선언 일부
head: CachePadded<AtomicIndex>,
tail: CachePadded<AtomicIndex>,
AtomicIndex는 플랫폼에 따라 AtomicU64나 AtomicUsize의 별칭이고, CachePadded는 값의 크기와 alignment를 조정해 주변 데이터와 같은 캐시 라인에 묶이지 않게 합니다. 큐의 head와 tail을 떼어 놓는 것은 CachePadded 문서가 직접 드는 사용 예이기도 합니다. 참고: crossbeam-utils CachePadded.
padding은 데이터 사이에 공간을 두는 것이고 alignment는 정해진 단위의 주소에 맞춰 배치하는 것입니다. 둘 다 독립적으로 자주 바뀌는 상태들이 같은 캐시 라인에 묶이지 않게 하려는 장치입니다.
CachePadded의 크기가 항상 64바이트인 것도 아닙니다. x86-64에서는 인접한 캐시 라인을 함께 가져오는 프리페처까지 고려해 128바이트 단위로 맞춥니다. 패딩 단위와 실제 하드웨어의 캐시 라인 크기는 구분해서 봐야 합니다. 참고: crossbeam-utils CachePadded.
떼어 놓아도 남는 경쟁
ArrayQueue는 여러 Producer와 여러 Consumer를 지원합니다. head와 tail을 분리해도 여러 Producer가 같은 tail을 갱신하는 경쟁은 그대로 남습니다. 큐가 가득 찼는지 비었는지 확인하려고 반대쪽 인덱스를 읽는 경로도 있습니다. 참고: crossbeam-queue ArrayQueue 소스.
두 경쟁을 나눠 부르면 다음과 같습니다.
- False Sharing: 서로 다른 상태가 같은 라인에 놓여 생기는 불필요한 간섭. 패딩으로 줄일 수 있습니다.
- True Sharing: 실제로 같은 상태를 함께 수정하면서 생기는 경쟁. 패딩과 관계없이 남습니다.
아래 데모에서 세 가지 배치를 차례로 눌러 보면 같은 쓰기 여섯 번에서 라인이 몇 번 옮겨 가는지 비교할 수 있습니다.
다른 변수를 고치는데 라인이 왜 옮겨 갈까요?
Consumer는 head만, Producer는 tail만 고칩니다. 배치를 고르면 같은 순서로 쓰기 여섯 번을 실행하고, 캐시 라인이 몇 번 옮겨 가는지 셉니다.
- 쓰기 권한이 있는 복사본
- 무효가 된 복사본
- 패딩
무효화 기반 캐시 일관성을 단순화한 모형입니다. 쓰기 권한이 있는 복사본과 무효가 된 복사본만 그렸고, 읽기로 생기는 공유 상태와 큐가 반대쪽 인덱스를 읽는 경로는 생략했습니다.
실제 ArrayQueue의 인덱스에는 순환 횟수 정보가 함께 들어 있고, CachePadded는 x86-64에서 128바이트 단위로 맞춥니다. 횟수는 이 순서의 쓰기에서 나온 값이며 측정값이 아닙니다.
4. 락 없이 CAS를 쓰면 공유 비용도 사라질까요?
동시성 자료구조에서는 CAS(Compare-And-Swap)를 자주 씁니다. 현재 값이 예상한 값과 같을 때만 새 값으로 바꾸는 연산입니다. Java의 AtomicInteger에서는 다음처럼 씁니다.
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger value = new AtomicInteger(10);
boolean first = value.compareAndSet(10, 11); // 성공
boolean second = value.compareAndSet(10, 12); // 실패: 현재 값은 11
비교와 갱신이 원자적으로 수행되므로, 다른 스레드가 값을 바꿨는데 이전 값을 전제로 덮어쓰는 일을 막을 수 있습니다. 참고: Java 17 AtomicInteger.
CAS를 쓴다고 알고리즘 전체가 Lock-free가 되지는 않습니다. Lock-free는 알고리즘의 진행 보장에 관한 성질입니다. 어느 시점에서든 적어도 한 스레드는 유한한 단계 안에 연산을 끝낼 수 있어야 하고, 일부 스레드가 멈춰도 이 조건은 유지되어야 합니다. 개별 스레드의 완료까지 보장하지는 않으므로 경쟁 과정에서 특정 스레드가 계속 실패할 수도 있습니다. 참고: 1024cores, Introduction to Lock-free Algorithms.
CAS로 갱신을 올바르게 처리해도 여러 코어가 같은 메모리 위치를 수정한다는 사실은 그대로입니다. CAS도 결국 그 라인의 쓰기 권한을 얻어야 하므로 2장에서 본 조율 비용이 똑같이 생깁니다. 경쟁이 심하면 실패한 CAS를 다시 시도하는 비용까지 더해집니다.
Java 표준 라이브러리에는 이 비용을 줄이는 예가 있습니다. LongAdder는 여러 스레드가 하나의 합계를 갱신할 때 경쟁이 생기면 값을 여러 셀로 나눠 각자 다른 셀을 갱신하게 하고, 합계는 읽을 때 더합니다. 그래서 경쟁이 심한 통계 카운터에서는 AtomicLong보다 처리량이 훨씬 높고 대신 메모리를 더 씁니다. 나뉜 셀들은 @Contended로 패딩해 서로 같은 캐시 라인에 묶이지 않게 합니다. 같은 변수 경쟁(True Sharing)은 셀을 나눠 줄이고, 나눈 셀 사이의 False Sharing은 패딩으로 막는 구조입니다. 참고: Java 17 LongAdder, OpenJDK Striped64.java.
그러니 경쟁이 심한 통계 카운터라면 AtomicLong 대신 LongAdder를 먼저 검토할 수 있습니다. 반면 @Contended를 직접 쓰기는 어렵습니다. 이 annotation은 JDK 내부 패키지(jdk.internal.vm.annotation)에 있고, HotSpot은 기본값(RestrictContended=true)에서 신뢰된 클래스에만 이 annotation을 적용합니다. 일반 애플리케이션 코드에서 쓰려면 내부 패키지 접근 설정과 -XX:-RestrictContended 옵션이 필요하고, 객체마다 공간도 크게 늘어납니다. 참고: OpenJDK Contended.java, HotSpot globals.hpp.
5. 여러 코어가 같은 데이터를 고치지 않게 설계할 수는 없을까요?
여기까지는 여러 코어가 공유 자료구조에 접근할 때 생기는 비용을 살펴봤습니다. 다른 방향도 있습니다. 처음부터 여러 코어가 같은 데이터를 반복해서 수정하지 않도록 구조를 나누는 것입니다.
ScyllaDB가 사용하는 C++ 비동기 프레임워크 Seastar가 이 방향을 택했습니다. 실행과 상태를 shard별로 나누고 다른 shard와는 명시적인 메시지 전달로만 상호작용합니다. 참고: Seastar Tutorial.
데이터마다 담당 shard를 정합니다
사용자 데이터를 두 shard로 나눴다고 가정하겠습니다.
| Shard | 담당 데이터 |
|---|---|
| Shard 0 | 사용자 A, C |
| Shard 1 | 사용자 B, D |
사용자 B의 데이터를 수정해야 하면, 요청을 어디서 받았든 담당인 Shard 1에서 처리합니다. 다른 shard는 같은 객체를 직접 고치지 않고 Shard 1에 작업을 요청합니다. Seastar에서는 smp::submit_to()로 다른 shard에 작업을 넘깁니다.
이렇게 나누면 같은 데이터를 여러 실행 주체가 번갈아 수정하는 상황 자체가 줄어듭니다.
각 shard는 자기 이벤트 루프로 작업을 진행합니다
Seastar 프로그램은 CPU마다 스레드를 하나씩 두고 각 스레드가 자신의 이벤트 루프를 돌립니다. 스레드 수는 하드웨어 스레드 수를 넘을 수 없으므로, SMT가 켜진 서버에서는 물리 코어보다 스레드가 많을 수 있습니다. I/O 결과를 기다리는 동안에는 준비된 다른 작업을 진행합니다. 어떤 데이터를 누가 처리할지 정하고 그 실행 위치를 바꾸지 않으니, 1장의 Cache Affinity도 자연스럽게 따라옵니다.
shard 사이의 메시지 전달도 결국 공유 메모리 하드웨어 위에서 일어납니다. 그러니 남아 있는 통신 경로에서는 원자적 연산과 캐시 라인 배치가 여전히 성능에 영향을 줄 것입니다. 참고: Seastar Tutorial.
구조를 나누면서 생기는 과제도 있습니다. 데이터를 고르지 않게 나누면 일부 shard에 일이 몰려 코어 사이에 부하 불균형이 생깁니다. 참고: Seastar SMP. 또 한 요청이 여러 shard를 거치면 비동기 작업의 순서와 상태를 따로 맞춰야 합니다.
6. 캐시에 없어서 DRAM에서 가져온다면, 어느 DRAM일까요?
지금까지는 어느 코어가 데이터를 쓰는지를 봤습니다. 이번에는 주기억장치까지 범위를 넓혀 보겠습니다.
NUMA(Non-Uniform Memory Access)는 CPU와 메모리의 위치 관계에 따라 접근 비용이 달라지는 구조입니다. 다른 노드의 메모리에도 접근할 수 있지만 로컬 메모리와 지연 시간이나 대역폭이 같지 않습니다. 참고: Linux Kernel, What is NUMA?.
같은 서버 안에도 가까운 메모리와 먼 메모리가 있습니다
CPU 소켓이 두 개이고 각 소켓이 하나의 NUMA 노드인 구성을 가정하겠습니다.
| 실행 위치 | 접근하는 메모리 | 접근 형태 |
|---|---|---|
| 소켓 0의 CPU | 소켓 0에 연결된 메모리 | local access |
| 소켓 0의 CPU | 소켓 1에 연결된 메모리 | remote access |
두 번째 경우는 다른 노드로 연결되는 interconnect를 거쳐야 합니다. 같은 서버 안에서 같은 크기의 데이터를 읽어도 위치에 따라 비용이 달라집니다. 실제 하드웨어에서는 소켓 하나에 NUMA 노드가 여러 개인 구성도 있습니다.
NUMA 노드는 Kubernetes의 워커 노드와는 관계가 없습니다. 서버 한 대 안의 CPU와 메모리 배치를 설명하는 단위입니다.
CPU affinity만으로는 부족합니다
Linux의 기본 메모리 정책은 local allocation입니다. 페이지를 할당하는 순간 실행 중이던 CPU의 노드에 먼저 할당합니다. 그래서 스레드가 node 1에서 실행되며 데이터를 처음 만들었다면 그 페이지는 node 1에 생깁니다. 참고: Linux Kernel, NUMA Memory Policy.
이 상태에서 스레드를 node 0의 CPU에서만 실행하도록 제한하면, 실행 위치는 node 0으로 옮겨져도 페이지는 node 1에 남습니다. 그래서 원격 메모리 접근이 생깁니다.
CPU affinity가 정하는 것은 실행 위치뿐입니다. 메모리 페이지를 어느 노드에 할당할지는 Linux의 NUMA memory policy가 따로 정합니다. 참고: sched_setaffinity(2), Linux Kernel, NUMA Memory Policy.
| 설정 | 다루는 것 |
|---|---|
| CPU affinity | 어디에서 실행할 것인가 |
| NUMA memory policy | 어디에 메모리를 할당할 것인가 |
memory policy를 바꿔도 이미 할당된 페이지는 옮겨지지 않습니다. task의 memory policy는 정책을 바꾼 뒤에 할당되는 페이지에만 적용됩니다. 기존 페이지를 옮기려면 mbind()의 페이지 이동 옵션이나 migrate_pages(2) 같은 별도 호출이 필요합니다. 참고: Linux Kernel, NUMA Memory Policy.
한 가지 예외가 있습니다. 자동 NUMA 밸런싱(kernel.numa_balancing)이 켜져 있으면 커널이 주기적으로 페이지 접근을 표본으로 잡아 자주 접근하는 노드 쪽으로 페이지를 차츰 옮깁니다. 이 기능이 꺼져 있거나 아직 따라잡지 못한 동안에는 원격 접근이 그대로 남습니다. 참고: Linux Kernel, numa_balancing.
아래 데모에서 CPU affinity부터 눌러 보세요.
실행 위치를 옮기면 데이터도 따라올까요?
스레드가 node 1에서 실행되며 node 1 메모리에 페이지 네 개를 할당한 상태입니다. CPU affinity부터 눌러 보고, 설정을 하나씩 바꿀 때마다 읽기가 노드 사이 연결을 건너는지 확인하세요.
스레드 하나와 페이지 네 개로 단순화한 모형입니다. 처음 페이지는 기본 정책(local allocation)에 따라 스레드가 실행되던 node 1에 할당됐다고 보고, 자동 NUMA 밸런싱은 꺼져 있다고 가정했습니다. 기존 페이지를 옮기는 작업이 policy 변경과 별개라는 점만 보여 주며, 실제로 페이지를 옮기는 방법과 비용은 그리지 않았습니다. 읽기가 이동하는 시간은 경로 길이에 맞춰 그렸을 뿐 실제 지연 비율이 아닙니다.
패딩으로 해결되지 않는 비용
False Sharing은 독립적인 데이터가 같은 캐시 라인에 묶여 생기는 간섭입니다. NUMA의 원격 접근은 실행 CPU와 데이터의 물리적 위치가 떨어져 있어서 생기는 비용입니다.
그래서 패딩으로 False Sharing을 없애도 원격 메모리 접근은 남을 수 있습니다. 반대로 CPU와 메모리를 같은 NUMA 노드에 모아도, 그 노드 안의 코어들 사이에서 False Sharing은 생길 수 있습니다. 참고: Linux Kernel, False Sharing, Linux Kernel, What is NUMA?.
앞에서 본 Seastar는 이 부분까지 고려합니다. 시작할 때 각 실행 스레드에 같은 NUMA 노드의 메모리를 미리 할당하고 그 스레드는 이 영역에서만 메모리를 할당받습니다. 작업의 담당자와 메모리 위치를 함께 정하는 구조입니다. 참고: Seastar Tutorial.
7. 같은 자원에서 배치만 바꾸면 얼마나 달라질까요?
이 관점을 실제 성능 개선으로 연결한 사례가 토스증권의 Milvus 최적화입니다. 벡터 검색을 수행하는 QueryNode의 실행 CPU와 인덱스 메모리를 같은 NUMA 노드에 배치했고, CPU·메모리 할당량과 검색 조건은 그대로 두었습니다. 참고: Toss Tech, Milvus 최적화와 사이징.
세 가지 kubelet 정책
토스증권이 바꾼 핵심 kubelet 설정은 다음과 같습니다.
cpuManagerPolicy: static
memoryManagerPolicy: Static
topologyManagerPolicy: single-numa-node
각 정책이 맡는 역할을 나눠 보면 설정의 의도가 보입니다.
- CPU Manager
static: 조건을 충족하는 컨테이너에 전용 CPU 집합을 할당합니다. Guaranteed QoS Pod에 속하고 CPU 요청이 정수인 컨테이너가 대상입니다. 전용 CPU 집합을 받는다고 각 스레드가 특정 CPU 하나에 고정되지는 않습니다. 참고: Kubernetes, CPU Management Policies. - Memory Manager
Static: 메모리의 NUMA 배치를 관리합니다. Linux에서는cpuset.mems로 컨테이너가 쓸 메모리 노드를 제한합니다. 참고: Kubernetes, Memory Manager. - Topology Manager
single-numa-node: 관련 자원을 하나의 NUMA 노드에 맞춰 할당할 수 있는지 확인합니다. 그럴 수 없으면 그 워커 노드의 kubelet이 Pod를 거부하고 Pod는TopologyAffinityError로 실패합니다. 스케줄러가 다른 워커 노드로 다시 배치해 주지는 않습니다. 참고: Kubernetes, Topology Manager.
위 YAML은 핵심 정책만 뽑은 것입니다. 실제로 적용하려면 CPU·메모리 예약 같은 추가 설정이 필요하고, 특히 Memory Manager의 Static 정책은 예약 메모리 설정 없이는 동작하지 않습니다.
적용 범위도 좁습니다. 전용 CPU를 받으려면 Pod가 Guaranteed QoS이고 CPU 요청이 정수여야 하고, single-numa-node는 Pod의 자원이 NUMA 노드 하나에 들어가야 받아들여집니다. 그래서 모든 서비스에 켤 설정은 아니고 토스 사례처럼 메모리 접근이 많고 Pod를 크게 잡아 쓰는 워크로드에서 검토할 만합니다.
처리량이 약 41% 늘었습니다
주요 결과는 다음과 같습니다.
| 지표 | 변경 전 | 변경 후 |
|---|---|---|
| Remote DRAM 비율 | 약 50% | 0.8% |
| IPC | 0.37 | 0.55 |
| 처리량 | 405 QPS | 572 QPS |
| recall@10 | 0.9541 | 0.9541 |
Remote DRAM 비율은 검색 부하 중 perf로 local·remote DRAM 읽기를 구분하는 PMU 이벤트를 측정한 값입니다. 처리량은 서버 3대 합산, 동시성 90, HNSW_SQ8·ef48 조건에서 비교한 값입니다. QPS는 초당 처리한 질의 벡터 수이고, 검색 품질(recall@10)은 그대로인 채 처리량이 약 41% 늘었습니다.
이 숫자를 NUMA 설정만 바꾸면 항상 41% 빨라진다는 뜻으로 일반화할 수는 없습니다. 이 사례에서 확인할 수 있는 것은 같은 자원이라도 실행 위치와 데이터 배치를 맞추면 활용 효율이 달라질 수 있다는 점입니다.
8. 내 서비스에서는 어디부터 확인해야 할까요?
이 글에서 다룬 비용은 메모리 접근과 코어 간 조율 자체가 병목일 때 드러납니다. 요청 시간 대부분을 DB나 외부 API 응답을 기다리는 데 쓰는 API 서버라면, 먼저 볼 병목이 따로 있을 가능성이 큽니다. 아래 신호 중 하나라도 해당할 때 이 글의 내용을 꺼내 보면 됩니다.
| 신호 | 확인 방법 | 대응 |
|---|---|---|
| 여러 스레드가 같은 카운터·큐·통계 값을 아주 자주 갱신한다 | perf c2c로 경쟁이 심한 캐시 라인을 찾는다 | 같은 변수 경쟁이면 값을 나누고(LongAdder), 다른 필드가 우연히 묶였으면 떼어 놓는다 |
| 서버에 NUMA 노드가 둘 이상 있다 | lscpu의 NUMA node(s) 항목이나 numactl --hardware | 노드가 하나라면 6~7장은 해당하지 않는다 |
| 큰 인덱스나 캐시를 메모리에서 읽는 계산이 CPU를 많이 쓴다 | numastat으로 배치를, perf로 원격 읽기를 확인한다 | 실행 CPU와 메모리를 같은 노드에 맞춘다 |
참고: perf-c2c(1), lscpu(1), numactl(8).
경쟁은 공유인지 우연인지 먼저 구분합니다
perf c2c에서 경쟁이 심한 라인을 찾았다고 바로 False Sharing으로 결론 내리면 안 됩니다. 같은 변수를 실제로 공유하는 경우(True Sharing)라면 패딩은 효과가 없고 값을 나누거나 4장의 LongAdder처럼 갱신을 분산해야 합니다. 서로 다른 필드가 우연히 같은 라인에 놓인 경우라면 그때 떼어 놓습니다. 어느 쪽인지는 접근 주소와 자료구조 배치를 보고 확인합니다.
떼어 놓을 때도 범위를 좁힙니다. 같은 실행 주체가 함께 쓰는 데이터는 가까이 두는 편이 유리하고 서로 다른 코어가 각자 자주 수정하는 데이터만 분리합니다. Linux 커널도 읽기 위주의 필드는 함께 묶고, 서로 간섭하는 쓰기 필드만 분리합니다. 패딩은 그만큼 메모리와 캐시 공간을 더 쓰므로, 자주 실행되지 않는 경로까지 일괄 적용하지 않습니다. 참고: Linux Kernel, False Sharing.
NUMA는 배치와 실제 접근을 따로 봅니다
numastat -p <PID>로 프로세스 메모리가 NUMA 노드별로 얼마나 있는지 확인할 수 있습니다. 다만 페이지가 어디에 있는지와 실행 중 원격 메모리를 얼마나 자주 읽는지는 다른 정보입니다. numastat의 기본 출력에 나오는 numa_miss는 메모리 할당에 관한 통계이므로 원격 DRAM 읽기 횟수로 읽으면 안 됩니다. 실제 원격 읽기 비율은 7장의 토스 사례처럼 perf로 local·remote DRAM 읽기를 구분하는 PMU 이벤트를 측정해야 알 수 있고, 이벤트 이름은 CPU마다 다릅니다. 참고: numastat(8).
원격 접근이 많을 때 모든 것을 한 노드에 모으는 방법에도 비용이 있습니다. 한 노드에 묶으면 그 노드의 메모리 용량과 대역폭만 쓰게 됩니다. NUMA는 여러 노드의 용량과 대역폭을 함께 쓰기 위한 구조이기도 하므로, 작업과 데이터를 나눠 여러 노드를 활용할지 한 노드 안에 모을지는 워크로드를 보고 정합니다. 참고: Linux Kernel, What is NUMA?.
9. 처음 질문으로 돌아가면
누가 이 데이터를 사용하고, 누가 수정하며, 그 데이터는 어디에 있어야 할까?
Cache Affinity, False Sharing, NUMA는 처음에는 서로 다른 주제로 보입니다. 하나는 스케줄링, 하나는 자료구조의 메모리 배치, 하나는 서버의 하드웨어 구성에 가깝습니다. 이 글에서 본 사례들은 각자 이 질문의 한 부분에 답합니다.
| 방법 | 정하는 것 | 줄이려는 비용 |
|---|---|---|
| Cache Affinity | 실행 위치 | 다른 코어로 옮겨 가 캐시를 다시 채우는 비용 |
CachePadded | 캐시 라인 배치 | 독립적인 상태 사이의 False Sharing |
| Seastar shard-per-core | 데이터의 담당자 | 여러 코어가 같은 데이터를 수정하는 경쟁 |
| NUMA 정책 | 물리 메모리 위치 | 원격 메모리 접근 |
동시성 코드를 볼 때 “동시에 접근해도 올바른가?”는 반드시 확인해야 하는 질문입니다. 성능까지 보려면 이 데이터를 누가 주로 읽고 수정하는지, 그 실행 주체와 데이터가 가까이 있는지, 독립적인 변경이 하드웨어에서 하나로 묶여 있지는 않은지도 함께 확인해야 합니다.
참고 자료:
- CPU 캐시와 False Sharing: Memory Performance in a Nutshell, Faster Core-to-Core Communications, Arm Cache Coherency Fundamentals, Arm Cache Coherency and Heterogeneous Compute, sched_setaffinity(2), Linux Kernel False Sharing
- 동시성 큐와 CAS: crossbeam ArrayQueue 코드, crossbeam-utils CachePadded, Java 17 AtomicInteger, Java 17 LongAdder, Introduction to Lock-free Algorithms
- Seastar와 NUMA: Seastar Tutorial, Seastar SMP, What is NUMA?, NUMA Memory Policy, numa_balancing, migrate_pages(2)
- Java: OpenJDK Striped64.java, OpenJDK Contended.java, HotSpot globals.hpp
- Milvus 사례와 측정 도구: Milvus 최적화와 사이징, CPU Management Policies, Memory Manager, Topology Manager, numastat(8), perf-c2c(1), lscpu(1), numactl(8)