(최적화 4) 코어 격리
Core Affinity만으로는 부족합니다. isolcpus로 OS 스케줄러의 간섭을 차단하고 스레드를 전용 코어에서 실행하는 방법을 알아봅니다.
이전 글: 저지연 로깅
이 글은 매매 시스템 시리즈의 최적화 파트 네 번째 글입니다.
Core Affinity
HFT 시스템에서 매매에 관여하는 핵심 스레드는 항상 지정된 CPU 코어 위에서 실행되어야 합니다.
“Affine 되어 있다”는 표현이 컴퓨터 공학 용어라 감이 안 올 수 있습니다. 쉽게 말하면, 애플리케이션에서 스레드를 여러 개 실행했을 때 각 스레드가 코어를 옮겨 다니지 않고 특정 코어에 붙어 있는 상태를 뜻합니다.
스레드가 코어를 옮기면 큰 문제가 생깁니다.
- 기존 코어의 L1, L2 캐시에 올라와 있던 데이터를 새 코어에서 쓸 수 없습니다. 캐시를 처음부터 다시 채워야 합니다 (cold cache).
- 스레드 마이그레이션 자체가 상당한 지연을 만듭니다.
첫 번째 글에서 L1 캐시와 DRAM의 레이턴시 차이가 75배라고 했습니다. 캐시가 날아가면 이 페널티를 고스란히 받습니다. 그래서 core affinity는 HFT에서 빠질 수 없는 설정입니다.
C, Rust, Java에서 각각 다음과 같이 설정합니다.
C
1
2
3
4
5
6
7
#define _GNU_SOURCE
#include <sched.h>
cpu_set_t mask;
CPU_ZERO(&mask);
CPU_SET(2, &mask);
sched_setaffinity(0, sizeof(mask), &mask); // 현재 스레드를 CPU 2에 고정
Rust
1
2
[dependencies]
core_affinity = "0.8"
1
2
let core_id = core_affinity::CoreId { id: 2 };
core_affinity::set_for_current(core_id);
Java
1
2
3
4
5
<dependency>
<groupId>net.openhft</groupId>
<artifactId>affinity</artifactId>
<version>3.23.3</version>
</dependency>
1
2
3
try (AffinityLock lock = AffinityLock.acquireLock(2)) {
// CPU 2에 고정된 상태로 실행
}
Core Affinity의 한계
그런데 문제가 하나 있습니다. Core affinity만으로는 원하는 효과를 100% 얻지 못합니다.
복잡한 얘기를 간단하게 하면, core affinity는 확보가 아니라 운영체제에 하는 부탁에 가깝습니다. sched_setaffinity()의 man page를 보면 이런 내용이 있습니다:
“The system may further restrict the set of CPUs on which the process runs if the “cpuset” mechanism described in cpuset(7) is being used. These restrictions on the actual set of CPUs on which the process will run are silently imposed by the kernel.”
cgroup cpuset이 affinity를 조용히 덮어쓸 수 있고, 커널 스레드(migration, ksoftirqd 등)가 해당 코어를 점유할 수도 있습니다. 내가 코어 2에 스레드를 고정했다고 생각했는데, 실제로는 커널이 다른 결정을 내릴 수 있다는 뜻입니다.
코어 격리
그러면 어떻게 해야 할까요?
Core affinity에 더해서, 이 코어는 다른 프로세스가 침범할 수 없다고 선언해 주면 됩니다. 이것이 코어 격리(CPU Isolation) 입니다.
코어 격리란 OS 스케줄러가 관리하는 프로세스를 해당 코어에서 실행하지 못하도록 스케줄링 대상에서 제외하는 것입니다. 격리된 코어에는 taskset이나 sched_setaffinity()로 명시적으로 할당한 프로세스만 실행됩니다.
정리하면:
- 코어 격리로 OS 프로세스의 간섭을 차단
- 애플리케이션 내부에서 core affinity로 스레드를 해당 코어에 고정
이 둘을 함께 써야 스레드가 안정적으로 전용 코어에서 실행됩니다.
코어 격리는 런타임이 아니라 커널 부트 파라미터로 설정됩니다. 따라서 설정 후 반드시 재부팅이 필요합니다.
격리 상태 확인 (RHEL 9.x)
이제 실제로 코어 격리 상태를 확인하고 설정하는 방법을 알아봅시다. RHEL 9.x 기준입니다.
CPU 토폴로지 확인
1
lscpu | grep -E "^CPU\(s\)|Thread|Core|Socket|NUMA"
1
2
3
4
5
6
CPU(s): 8
Thread(s) per core: 2
Core(s) per socket: 4
Socket(s): 1
NUMA node(s): 1
NUMA node0 CPU(s): 0-7
현재 격리 상태
1
cat /sys/devices/system/cpu/isolated
비어 있으면 격리가 설정되지 않은 상태입니다. 격리된 경우:
1
2-7
커널 부트 파라미터 확인
1
cat /proc/cmdline | tr ' ' '\n' | grep -E "isolcpus|nohz_full|rcu_nocbs"
1
2
3
isolcpus=2-7
nohz_full=2-7
rcu_nocbs=2-7
HyperThread 시블링 확인
격리할 때 HyperThread 시블링 쌍은 반드시 함께 격리해야 합니다. 한쪽만 격리하면 캐시 간섭이 발생합니다.
1
cat /sys/devices/system/cpu/cpu*/topology/thread_siblings_list | sort -u
1
2
3
4
0,4
1,5
2,6
3,7
위 결과에서 CPU 2와 6은 시블링입니다. 코어 2를 격리하려면 6도 함께 격리해야 합니다.
코어 격리 설정
8코어 시스템에서 코어 0-1은 OS용으로 남기고, 코어 2-7을 격리하는 예시입니다.
RHEL 9에서는 grubby를 사용합니다:
1
2
sudo grubby --update-kernel=ALL \
--args="isolcpus=2-7 nohz_full=2-7 rcu_nocbs=2-7"
| 파라미터 | 역할 |
|---|---|
isolcpus | 스케줄러 도메인에서 CPU 제거 |
nohz_full | 격리된 코어에서 타이머 틱 비활성화 |
rcu_nocbs | RCU 콜백 처리를 비격리 코어로 오프로드 |
설정 확인:
1
sudo grubby --info=ALL | grep args
재부팅 적용:
1
sudo reboot
격리 후 검증
재부팅 후 격리 상태를 확인합니다:
1
cat /sys/devices/system/cpu/isolated
1
2-7
격리된 코어에 프로세스가 거의 없어야 정상입니다:
1
ps -eo pid,psr,comm | awk '$2 == 2'
1
2
3
PID PSR COMMAND
31 2 migration/2
32 2 ksoftirqd/2
migration, ksoftirqd 같은 바운드 커널 스레드는 isolcpus만으로 제거되지 않습니다. 추가로 제거하려면:
1
2
sudo dnf install -y tuna
sudo tuna --cpus=2-7 --isolate
격리 해제
1
2
3
sudo grubby --update-kernel=ALL \
--remove-args="isolcpus nohz_full rcu_nocbs"
sudo reboot
주의사항
- 코어 0은 격리하지 마세요. 부트 프로세스와 커널 초기화에 사용됩니다.
- HyperThread 시블링은 반드시 쌍으로 격리하세요. 한쪽만 격리하면 캐시 간섭이 발생합니다.
- OS용 코어를 충분히 남겨야 합니다. 너무 많이 격리하면 OS 자체 성능이 저하됩니다.
- NUMA 토폴로지를 고려하세요. 같은 NUMA 노드의 코어를 격리해야 캐시 공유를 활용할 수 있습니다.
결론
Core affinity만으로는 OS 스케줄러와 커널 스레드의 간섭을 막을 수 없습니다. isolcpus + nohz_full + rcu_nocbs로 코어를 먼저 격리하고, 그 위에서 core affinity로 스레드를 배치해야 합니다.
이전 글에서 로거 스레드를 별도 코어에 고정했습니다. 매매 시스템에서는 이처럼 각 스레드의 역할에 맞게 코어를 배분하고, 격리된 환경에서 실행하는 것이 지연 최소화의 기본입니다.
