여러 세대의 Intel CPU에서 시스템 콜과 futex를 이용해 컨텍스트 스위치 비용을 측정하고, CPU 어피니티·캐시 오염·가상화가 성능에 미치는 영향을 정리한다.
그건 제가 시간을 좀 들여볼 만한 흥미로운 질문입니다. StumbleUpon의 누군가가, Nehalem architecture (Intel i7로 마케팅됨)의 여러 개선 덕분에 컨텍스트 스위칭이 훨씬 빨라졌을 것이라는 가설을 제시했습니다. 이 질문에 대한 답을 경험적으로 찾아내기 위한 테스트를 어떻게 고안할 수 있을까요? 컨텍스트 스위치는 어쨌든 얼마나 비쌀까요? (tl;dr 답: 매우 비쌉니다)
2011년 4월 21일 업데이트: "극단적인" Nehalem과 저전력 Westmere를 추가했습니다.
2013년 4월 1일 업데이트: Intel Sandy Bridge E5-2620을 추가했습니다.
테스트를 위해 서로 다른 세대의 CPU 4개를 준비했습니다:
제가 보기에는 모든 CPU가 고정 클럭으로 설정되어 있습니다(터보 부스트나 다른 화려한 기능 없음). 모든 Linux 커널은 Ubuntu가 빌드해 배포한 것입니다.
첫 번째 아이디어는 값싼 시스템 콜을 연속으로 매우 많이 호출해 걸린 시간을 재고, syscall당 평균 시간을 계산하는 것이었습니다. 요즘 Linux에서 가장 싼 시스템 콜은 gettid인 듯합니다. 그런데 이는 순진한 접근이었습니다. 요즘은 시스템 콜이 실제로는 더 이상 완전한 컨텍스트 스위치를 일으키지 않기 때문입니다. 커널은 "mode switch"(유저 모드에서 커널 모드로 갔다가 다시 유저 모드로)만으로도 충분합니다. 그래서 첫 번째 테스트 프로그램을 돌렸을 때 vmstat에서는 컨텍스트 스위치 수가 눈에 띄게 늘지 않았습니다. 하지만 이 테스트도 흥미롭긴 합니다. 다만 애초에 원하던 바는 아니었죠.
소스 코드: timesyscall.c 결과:
좋네요. 더 비싼 CPU가 눈에 띄게 더 잘합니다(다만 Sandy Bridge에서 비용이 약간 증가한 점은 유의). 하지만 이건 우리가 진짜 알고 싶었던 게 아닙니다. 컨텍스트 스위치의 비용을 측정하려면, 커널이 현재 프로세스를 디스케줄하고 대신 다른 프로세스를 스케줄하도록 강제해야 합니다. 그리고 CPU를 벤치마크하려면, 커널이 빡빡한 루프에서 이것 말고는 아무것도 하지 않게 만들어야 합니다. 어떻게 하면 될까요?
futex로제가 한 방식은 futex를 (악)용하는 것입니다(RTFM). futex는 Linux 전용의 저수준 프리미티브로, 대부분의 스레딩 라이브러리가 경쟁이 발생한 뮤텍스에서 기다리기, permit이 바닥난 세마포어, 조건 변수 등과 같은 블로킹 연산을 구현하는 데 사용합니다. 더 알고 싶다면 Ulrich Drepper의 Futexes Are Tricky를 읽어보세요. 어쨌든 futex를 쓰면 프로세스를 중단/재개하는 것이 쉽습니다. 제 테스트는 자식 프로세스를 fork한 뒤, 부모와 자식이 번갈아 futex에서 기다리게 합니다. 부모가 기다리면 자식이 깨워주고 자신은 futex에서 기다립니다. 그러다 부모가 다시 자식을 깨우고 또 기다립니다. 일종의 핑퐁, "내가 너 깨우고, 네가 나 깨우고..."입니다.
소스 코드: timectxsw.c 결과:
참고: 이 결과에는 futex 시스템 콜의 오버헤드가 포함되어 있습니다.
이 결과는 곧이곧대로 믿으면 안 됩니다. 이 마이크로 벤치마크는 컨텍스트 스위치 외에는 아무것도 하지 않습니다. 실제로 컨텍스트 스위치가 비싼 이유는 CPU 캐시(L1, L2, L3가 있다면 L3, 그리고 TLB – TLB를 잊지 마세요!)를 망가뜨리기 때문입니다.
SMP 환경에서는 예측이 더 어렵습니다. 태스크가 한 코어에서 다른 코어로 마이그레이션되는지(특히 물리 CPU를 가로질러 마이그레이션되는지)에 따라 성능이 크게 달라질 수 있기 때문입니다. 벤치마크를 다시 돌리되 이번에는 프로세스/스레드를 단일 코어(또는 "hardware thread")에 고정해(pinning) 실행했습니다. 성능 향상이 극적입니다.
소스 코드: cpubench.sh 결과:
성능 향상: 5150: 66%, E5440: 65-70%, E5520: 50-54%, X5550: 55%, L5630: 45%, E5-2620: 45%.
스레드 스위치와 프로세스 스위치 사이의 성능 차이는 CPU 세대가 새로워질수록 커지는 듯합니다(5150: 7-8%, E5440: 5-15%, E5520: 11-20%, X5550: 15%, L5630: 13%, E5-2620: 19%). 전반적으로 한 태스크에서 다른 태스크로 전환하는 페널티는 여전히 매우 큽니다. 이 인위적인 테스트는 연산을 전혀 하지 않으므로 L1d와 L1i에서 캐시 히트가 100%일 가능성이 큽니다. 현실에서는 두 태스크(스레드든 프로세스든) 사이를 전환할 때 캐시 오염 때문에 보통 훨씬 더 큰 페널티가 발생합니다. 하지만 이건 뒤에서 다시 다루겠습니다.
위 숫자를 만든 뒤 저는 Java 애플리케이션을 재빨리 비판했습니다. Java에서는 엄청나게 많은 스레드를 만드는 일이 꽤 흔하고, 그런 애플리케이션에서는 컨텍스트 스위칭 비용이 높아지기 때문입니다. 그러자 누군가 이렇게 반박했습니다. 맞다, Java는 스레드를 많이 쓰지만 Linux 2.6의 NPTL 덕분에 스레드는 훨씬 빠르고 싸졌다고. 그리고 일반적으로 같은 프로세스의 두 스레드 사이를 전환할 때는 TLB flush가 필요 없다고 했습니다. 이는 사실이며 Linux 커널 소스(mmu_context.h의 switch_mm)를 확인해볼 수 있습니다:
static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk) { unsigned cpu = smp_processor_id();
if (likely(prev != next)) {
_[...]_
load_cr3(next->pgd);
} else {
_[don't typically reload cr3]_
}
}
이 코드에서 커널은 서로 다른 메모리 구조를 가진 태스크들 사이를 전환할 것이라고 예상하며, 그런 경우 CR3 — page table을 가리키는 포인터를 담는 레지스터 — 를 업데이트합니다. x86에서 CR3에 쓰기를 하면 자동으로 TLB flush가 발생합니다.
하지만 실제로는, 기본 커널 스케줄러와 서버형의 바쁜 워크로드에서는 load_cr3 호출을 건너뛰는 코드 경로를 타는 일이 꽤 드뭅니다. 게다가 서로 다른 스레드는 서로 다른 워킹 셋을 갖는 경향이 있어, 이 단계를 건너뛰더라도 결국 L1/L2/L3/TLB 캐시를 오염시키게 됩니다. 위 벤치마크를 2개 프로세스 대신 2개 스레드로 다시 돌려봤지만(소스: timetctxsw.c) 결과는 크게 다르지 않았습니다(스케줄링과 운에 따라 편차가 크지만, 커스텀 CPU 어피니티를 설정하지 않으면 여러 번 돌린 평균에서 스레드 간 전환이 보통 100ns 정도만 더 빠릅니다).
위 결과는 University of Rochester의 연구자들이 발표한 논문 Quantifying The Cost of Context Switch과도 일치합니다. 특정되지 않은 Intel Xeon(논문이 2007년에 쓰였으니 CPU가 너무 오래되지는 않았을 겁니다)에서 평균 3800ns 정도가 나옵니다. 그들은 제가 생각했던 다른 방법도 씁니다. 파이프에 1바이트를 쓰고/읽어 프로세스 몇 개를 블록/언블록하는 방식입니다. 저는 futex를 (악)용하는 게 더 낫다고 생각했는데, futex는 본질적으로 유저랜드에 어떤 스케줄링 인터페이스를 노출하는 것이기 때문입니다.
논문은 이어서 캐시 간섭 때문에 발생하는 컨텍스트 스위치의 간접 비용을 설명합니다. 특정 워킹 셋 크기(그들 벤치마크에서는 L2 캐시 크기의 대략 절반)를 넘어서면 컨텍스트 스위치 비용이 급격히 증가합니다(2자릿수 자릿수 정도).
이쪽이 더 현실적인 기대치라고 생각합니다. 스레드 간에 데이터를 공유하지 않으면 최적의 성능을 얻을 수 있지만, 이는 각 스레드가 자신의 워킹 셋을 가진다는 뜻이기도 하며, 스레드가 한 코어에서 다른 코어로(혹은 더 나쁘게는 물리 CPU를 가로질러) 마이그레이션될 때 캐시 오염 비용이 커질 것입니다. 안타깝게도 애플리케이션이 하드웨어 스레드 수보다 훨씬 더 많은 활성 스레드를 가지고 있으면 이런 일이 계속 일어납니다. 그래서 사용 가능한 하드웨어 스레드 수보다 더 많은 활성 스레드를 만들지 않는 것이 중요합니다. 그래야 Linux 스케줄러가 마지막으로 사용했던 코어에 같은 스레드를 다시 스케줄링("weak affinity")하기가 더 쉽습니다.
그렇다고 해도, 요즘 CPU는 캐시가 훨씬 더 크고 L3 캐시도 있을 수 있습니다.
E5520/X5550/L5630("i7"로 마케팅되는 것들)와 Sandy Bridge E5-2520의 경우, L2 캐시는 작지만 코어당 L2 캐시가 하나씩 있습니다(HT가 활성화되어 있으면 하드웨어 스레드당 128K가 됩니다). L3 캐시는 각 물리 CPU 위의 모든 코어가 공유합니다.
코어가 많아지는 것은 좋지만, 태스크가 다른 코어로 다시 스케줄될 가능성도 커집니다. 코어들은 캐시 라인을 "마이그레이션"해야 하는데, 이는 비용이 큽니다. 이것이 어떻게 동작하고 어떤 성능 페널티가 있는지 더 이해하려면 Ulrich Drepper의 What Every Programmer Should Know About Main Memory(네, 또 그 사람입니다!)를 읽어보길 권합니다.
그렇다면 워킹 셋 크기에 따라 컨텍스트 스위치 비용은 어떻게 증가할까요? 이번에는 다른 마이크로 벤치마크 timectxswws.c를 사용하겠습니다. 이 벤치마크는 워킹 셋으로 사용할 페이지 수를 인자로 받습니다. 이 벤치마크는 앞서 두 프로세스 사이의 컨텍스트 스위치 비용을 측정한 것과 정확히 동일하지만, 이제 각 프로세스가 두 프로세스 사이에서 공유되는 워킹 셋에 대해 memset을 수행한다는 점이 다릅니다. 시작하기 전에, 벤치마크는 요청된 워킹 셋 크기의 모든 페이지를 덮어쓰는 데 걸리는 시간을 먼저 측정합니다. 그런 다음 그 시간을 테스트 전체 시간에서 할인합니다. 이는 컨텍스트 스위치 전후로 페이지를 덮어쓰는 데 드는 _오버헤드_를 추정하려는 시도입니다.
5150의 결과는 다음과 같습니다:
보시다시피, 워킹 셋이 L1d(32K)에 담을 수 있는 크기를 넘어서면 4K 페이지를 쓰는 데 필요한 시간이 두 배 이상이 됩니다. 워킹 셋 크기가 커질수록 컨텍스트 스위치당 시간은 계속 올라가지만, 어느 지점을 지나면 벤치마크는 메모리 접근에 지배되어 더 이상 컨텍스트 스위치 오버헤드를 측정하는 것이 아니라, 단지 메모리 서브시스템의 성능을 테스트하게 됩니다.
같은 테스트를 이번에는 CPU 어피니티를 적용해(두 프로세스를 같은 코어에 고정) 수행했습니다:
와, 이거 보세요! 같은 코어에 두 프로세스를 고정하면 자릿수 단위로 더 빨라집니다! 워킹 셋이 공유되기 때문에 워킹 셋 전체가 4M L2 캐시에 들어가고, 캐시 라인은 코어 간(심지어 2개의 물리 CPU를 가로질러) 이동하는 대신 L2에서 L1d로만 옮겨지면 되기 때문입니다. (이는 같은 CPU 내부에서의 이동보다 훨씬 비쌉니다.)
이제 i7 프로세서의 결과입니다:
이번에는 더 큰 워킹 셋 크기까지 커버했기 때문에 X 축이 로그 스케일인 점에 유의하세요.
즉, i7에서 컨텍스트 스위칭은 더 빠르긴 하지만, 그건 어느 정도까지입니다. 실제 애플리케이션(특히 Java 애플리케이션)은 워킹 셋이 큰 경향이 있어 컨텍스트 스위치를 겪을 때 보통 가장 큰 비용을 치릅니다. i7에 쓰인 Nehalem 아키텍처에 대한 다른 관찰:
TLB 얘기가 나와서 말인데, Nehalem은 흥미로운 아키텍처를 가지고 있습니다. 각 코어는 64 엔트리의 "L1d TLB"("L1i TLB"는 없음)와 통합된 512 엔트리의 "L2TLB"를 갖습니다. 둘 다 두 HyperThreads 사이에 동적으로 할당됩니다.
가상화를 사용할 때 오버헤드가 얼마나 되는지도 궁금했습니다. 듀얼 E5440에서 벤치마크를 한 번은 일반 Linux 설치에서, 또 한 번은 동일한 설치를 VMware ESX Server 안에서 실행한 상태에서 반복했습니다. 결과는 평균적으로 가상화를 사용하면 컨텍스트 스위치가 2.5배에서 3배 더 비싸진다는 것이었습니다. 제 _추측_으로는, 게스트 OS가 페이지 테이블을 직접 업데이트할 수 없어서 변경을 시도할 때 하이퍼바이저가 개입하고, 그 결과 추가로 컨텍스트 스위치 2번(하이퍼바이저로 들어갈 때 1번, 하이퍼바이저에서 나와 게스트 OS로 돌아올 때 1번)이 발생하기 때문입니다.
이 점이 Intel이 Nehalem에서 EPT(Extended Page Table)를 추가한 이유를 설명해주는 듯합니다. EPT는 하이퍼바이저의 도움 없이도 게스트 OS가 자신의 페이지 테이블을 수정할 수 있게 해주고, CPU가 전 과정의 메모리 주소 변환(가상 주소에서 "guest-physical" 주소로, 다시 물리 주소로)을 하드웨어에서 자체적으로 수행할 수 있게 해줍니다.
컨텍스트 스위칭은 비쌉니다. 제 경험칙으로는 CPU 오버헤드가 약 30µs 든다고 봅니다. 이는 괜찮은 최악의 경우 근사치처럼 보입니다. CPU 시간을 두고 끊임없이 경쟁하는 스레드를 너무 많이 만드는 애플리케이션(예: Apache의 HTTPd 또는 많은 Java 애플리케이션)은, 서로 다른 스레드 사이를 왔다 갔다 전환하는 데만 상당한 양의 CPU 사이클을 낭비할 수 있습니다. 최적의 CPU 사용을 위한 스위트 스폿은 하드웨어 스레드 수와 동일한 워커 스레드 수를 두고, 비동기/논블로킹 방식으로 코드를 작성하는 것이라고 생각합니다. 비동기 코드는 CPU bound가 되는 경향이 있는데, 블로킹될 만한 것은 블로킹 연산이 완료될 때까지 단지 나중으로 미뤄지기 때문입니다. 이는 비동기/논블로킹 애플리케이션의 스레드가 커널 스케줄러에 의해 선점되기 전까지 자신의 타임 퀀텀을 끝까지 사용할 가능성이 훨씬 높다는 뜻입니다. 그리고 실행 가능한 스레드 수가 하드웨어 스레드 수와 같다면, 커널은 같은 코어에 스레드를 다시 스케줄할 가능성이 매우 높아지며, 이는 성능에 상당히 도움이 됩니다.
서버형 워크로드에서 성능에 심각한 영향을 주는 또 다른 숨은 비용은, 스위치 아웃된 뒤 프로세스가 다시 runnable이 되더라도, CPU 코어가 उपलब्ध해질 때까지 커널의 run queue에서 기다려야 한다는 점입니다. Linux 커널은 종종 HZ=100으로 컴파일되는데, 이는 프로세스가 10ms의 타임 슬라이스를 받는다는 뜻입니다. 스레드가 스위치 아웃되었다가 거의 즉시 runnable이 되었는데, run queue에서 그 앞에 2개의 다른 스레드가 CPU 시간을 기다리고 있다면, 최악의 시나리오에서 여러분의 스레드는 CPU 시간을 얻기까지 최대 20ms를 기다려야 할 수도 있습니다. 따라서 run queue의 평균 길이(로드 애버리지에 반영됨)와 여러분의 스레드가 다시 스위치 아웃되기 전까지 보통 얼마나 오래 실행되는지에 따라, 이는 성능에 상당한 영향을 줄 수 있습니다.
NPTL이나 Nehalem 아키텍처가 현실의 서버형 워크로드에서 컨텍스트 스위칭을 더 싸게 만들었다고 상상하는 것은 허상입니다. 기본 Linux 커널은, 유휴 머신에서도 CPU 어피니티를 잘 유지하지 못합니다. 대체 스케줄러를 탐색하거나 taskset 또는 cpuset을 사용해 직접 어피니티를 제어해야 합니다. 하나의 서버에서 서로 다른 여러 CPU 집약적 애플리케이션을 함께 실행하고 있다면, 애플리케이션별로 코어를 수동으로 분할하는 것만으로도 매우 큰 성능 향상을 얻을 수 있습니다.