프로그래머가 반드시 알아야 할 메모리 Part 3
Source: Dev.to
목차
1. UMA vs. NUMA: 평등의 종말
To understand why modern servers behave the way they do, we need to look at the evolution of memory architectures.

1.1 UMA (Uniform Memory Access)
The Old Way: In the days of SMP (Symmetric Multi‑Processing) we had a single memory controller and a single system bus. All CPUs were attached to that bus.
옛 방식: SMP(대칭 다중 처리) 시절에는 메모리 컨트롤러와 시스템 버스가 하나뿐이었습니다. 모든 CPU가 그 버스에 연결되었습니다.
What it means: “Uniform” means the cost to access RAM is the same for every core. Accessing address 0x0 takes, say, 100 ns for Core 0 and 100 ns for Core 1.
무엇을 의미하나요: “Uniform”는 모든 코어가 RAM에 접근하는 비용이 동일하다는 뜻입니다. 주소 0x0에 접근하는 데 Core 0은 100 ns, Core 1도 100 ns가 걸린다고 가정합니다.
Why it failed: The shared bus became a bottleneck. As we added more cores (2, 4, 8, …) they all fought for the same bandwidth—like 64 cars trying to use a single‑lane highway.
왜 실패했나요: 공유 버스가 병목이 되었습니다. 코어를 2, 4, 8 … 늘릴수록 모두 같은 대역폭을 두고 경쟁했으며, 마치 64대의 차가 단일 차선 고속도로를 이용하려는 상황과 같습니다.
1.2 NUMA (Non‑Uniform Memory Access)
The New Way: To solve the bottleneck, hardware architects split the memory up.
새로운 방식: 병목을 해결하기 위해 하드웨어 설계자는 메모리를 분할했습니다.
What it means: Instead of one giant bank of RAM, each processor socket gets a dedicated chunk of RAM. A Processor + its Local RAM is called a NUMA node.
무엇을 의미하나요: 거대한 RAM 뱅크 하나 대신, 각 프로세서 소켓에 전용 RAM 조각이 할당됩니다. 프로세서 + 그 로컬 RAM을 NUMA 노드라고 부릅니다.
How it works: Nodes are connected by a high‑speed interconnect (Intel UPI, AMD Infinity Fabric, etc.). If CPU 0 needs data that lives in CPU 1’s memory, it asks CPU 1 to fetch the data and ship it over the interconnect.
동작 방식: 노드들은 고속 인터커넥트(Intel UPI, AMD Infinity Fabric 등)로 연결됩니다. CPU 0이 CPU 1의 메모리에 있는 데이터를 필요로 하면, CPU 1에 데이터를 가져와 인터커넥트를 통해 전달하도록 요청합니다.
This solves the bandwidth problem (multiple highways!) but introduces a new problem: physics.
**이는 대역폭 문제(다중 고속도로!)를 해결하지만 새로운 문제, 즉 물리적 제약을 도입합니다.
2. 원격 접근 비용
메모리가 물리적으로 분산되면서, 거리의 중요성이 커졌다.
CPU가 Node 0에 있고 Node 0의 RAM에 있는 데이터를 필요로 하면, 경로가 짧고 빠릅니다.
CPU가 Node 0에 있고 Node 1의 RAM에 있는 데이터를 필요로 하면, 요청이 인터커넥트를 통해 이동하고 Node 1의 메모리 컨트롤러를 기다린 뒤 데이터를 다시 전달받아야 합니다.
2.1 지연 페널티
우리는 이 비용을 종종 latency factor(지연 계수)로 표현합니다:
| 접근 유형 | 상대 지연 |
|---|---|
| 로컬 | 1.0 × (기준) |
| 원격 | 1.5 × – 2.0 × 느림 |
원격 메모리를 참조하는 모든 캐시 미스는 로컬 미스보다 최대 두 배 정도 더 비쌀 수 있습니다. 고성능 컴퓨팅(HPC)이나 저지연 트레이딩 환경에서는 치명적입니다.
2.2 대역폭 포화: 막힌 파이프
문제는 지연뿐만 아니라 용량도 있습니다. 소켓 간 인터커넥트는 제한된 대역폭을 가집니다.
만약 64코어 전체의 모든 스레드가 Node 0의 메모리를 공격적으로 읽도록 프로그램을 작성한다면, 트래픽 정체가 발생합니다. Node 0에 있는 로컬 코어는 데이터를 잘 받아오지만, 다른 노드에 있는 원격 코어는 인터커넥트의 공간을 놓고 경쟁하면서 대기하게 됩니다.
3. OS 정책: “첫 터치” 함정
그렇다면 OS가 메모리를 어디에 배치할지 어떻게 결정할까요? malloc(1 GB)를 하면 Node 0에 가나요, Node 1에 가나요?
Linux는 First‑Touch Allocation이라는 정책을 사용합니다.
3.1 Linux가 메모리를 할당하는 방법
malloc(1 GB)는 가상 주소 범위를 반환합니다; 아직 물리적 RAM은 할당되지 않았습니다.- 물리 페이지는 프로세스가 해당 페이지를 처음으로 쓸 때(페이지 폴트) 할당됩니다.
- 그 순간 커널은 어떤 CPU가 폴트를 일으켰는지 확인합니다.
- 페이지는 그 CPU와 로컬인 NUMA 노드에 배치됩니다.
즉, 페이지를 처음 건드린 스레드가 해당 페이지의 홈 노드를 결정합니다.
3.2 함정: 메인 스레드 초기화
메인 스레드가 모든 첫 번째 쓰기를 수행하면, 모든 페이지가 메인 스레드가 실행되는 노드에 배치됩니다. 멀티‑소켓 시스템에서는 메모리가 단일 노드에 집중되어, 다른 소켓에 있는 워커 스레드들은 원격 접근을 하게 됩니다.
시나리오
- 메인 스레드(Node 0에서 실행)가 거대한 배열을 할당하고
memset합니다. - 모든 페이지가 Node 0에 할당됩니다.
- 64개의 워커 스레드가 Nodes 0‑3에 걸쳐 생성되어 데이터를 처리합니다.
결과
- Node 0에 있는 스레드들은 로컬 접근을 즐깁니다.
- Node 1‑3에 있는 스레드들은 원격 접근을 하게 되어 인터커넥트가 포화됩니다.
- 코어를 추가할수록 스케일링이 정체되거나 오히려 성능이 저하됩니다.
해결책
병렬 초기화 – 각 워커 스레드가 나중에 처리할 데이터 부분을 직접 초기화하도록 합니다. 이렇게 하면 페이지가 해당 스레드가 실행되는 노드에 할당되어 원격 접근 페널티가 사라집니다.
3.3 “스필오버” 동작 (Zone Reclaim)
노드의 로컬 메모리가 고갈되면, 커널은 원격 노드에서 페이지를 할당할 수 있습니다(Zone Reclaim).
- 이로 인해 예측할 수 없는 지연 시간 스파이크가 발생합니다.
- 애플리케이션은 일정 시간 동안 빠르게 동작하다가, 로컬 노드가 가득 차고 할당이 다른 노드로 “스필오버”되면 급격히 느려집니다.
/sys/devices/system/node/아래의numa_miss카운터를 모니터링하는 것이 이 상황을 감지하는 유일한 신뢰할 만한 방법입니다.
4. 도구
4.1 lscpu 로 분석하기
$ lscpu
lscpu는 CPU 토폴로지를 출력합니다. 여기에는 NUMA 노드 수, 노드당 코어 수, 그리고 인터커넥트 아키텍처가 포함됩니다.
4.2 거리 매트릭스 (numactl)
$ numactl --hardware
전형적인 출력:
available: 2 nodes (0-1)
node 0 cpus: 0-15
node 1 cpus: 16-31
node 0 size: 128 GB
node 1 size: 128 GB
node 0 free: 124 GB
node 1 free: 126 GB
node distances:
node 0 1
0: 10 20
1: 20 10
거리 값은 상대적인 값이며, 숫자가 클수록 지연 시간이 더 높다는 의미입니다.
4.3 numactl 로 정책 제어하기
명시적인 메모리 정책을 지정하여 프로그램을 실행합니다:
# 프로세스를 노드 0에 바인드하고 메모리는 노드 0에서만 할당
numactl --cpunodebind=0 --membind=0 ./my_program
# 노드 전체에 메모리를 인터리브(균등하게 섞음) – 대용량이며 균일하게 접근되는 데이터에 적합
numactl --interleave=all ./my_program
4.4 libnuma 로 프로그래밍하기
#include <numa.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void) {
if (numa_available() == -1) {
fprintf(stderr, "NUMA not supported on this system.\n");
return EXIT_FAILURE;
}
/* Allocate 1
5. 결론
UMA와 NUMA의 차이점, 원격 메모리 접근의 지연 시간 및 대역폭 비용, 그리고 OS의 first‑touch 할당 정책을 이해하는 것은 현대 멀티‑소켓 서버에서 확장 가능한 소프트웨어를 작성하는 데 필수적입니다. 올바른 도구(lscpu, numactl, libnuma)를 사용하고 병렬 초기화 패턴을 채택함으로써 개발자는 숨겨진 성능 함정을 피하고 하드웨어의 기능을 최대한 활용할 수 있습니다.