NUMA(비균일 메모리 접근) 시스템의 하드웨어적 특징과 과제, 리눅스 커널의 NUMA 지원, sysfs가 제공하는 토폴로지/거리 정보, 원격 메모리 접근 비용과 그 영향에 대해 다룹니다.
[편집자 주: 울리히 드레퍼(Ulrich Drepper)의 "프로그래머라면 알아야 할 메모리" 4편에 오신 것을 환영합니다. 이 절에서는 NUMA(비균일 메모리 접근) 시스템과 관련된 특별한 과제를 다룹니다. 아직 1편, 2편, 3편을 읽지 않았다면 먼저 읽어 보길 권합니다. 항상 그렇듯, 오탈자 제보 등은 여기 댓글이 아니라 lwn@lwn.net 으로 보내 주세요.]
$ sudo subscribe today 지금 구독하고 LWN 권한을 높여 보세요. 새 글이 게시되는 즉시 LWN의 모든 고품질 콘텐츠에 접근할 수 있으며, 동시에 LWN을 후원하게 됩니다. 지금 신청하시면 무료 체험 구독으로 시작할 수 있습니다.
2절에서 살펴본 것처럼, 어떤 머신에서는 특정 물리 메모리 영역에 대한 접근 비용이 접근이 시작된 위치에 따라 다릅니다. 이런 유형의 하드웨어는 OS와 애플리케이션 모두의 특별한 주의를 필요로 합니다. 여기서는 NUMA 하드웨어의 몇몇 세부사항부터 시작해, 리눅스 커널이 NUMA를 위해 제공하는 지원을 살펴보겠습니다.
비균일 메모리 아키텍처는 점점 더 흔해지고 있습니다. 가장 단순한 형태의 NUMA에서, 프로세서는 로컬 메모리(그림 2.3 참조)를 가지며 이는 다른 프로세서에 로컬한 메모리에 비해 접근 비용이 더 낮습니다. 이런 유형의 NUMA 시스템은 비용 차이가 크지 않아, 즉 NUMA 팩터가 낮습니다.
NUMA는 또한, 특히, 대형 머신에서 사용됩니다. 많은 프로세서가 같은 메모리에 접근할 때의 문제를 앞서 설명했습니다. 범용 하드웨어에서는 모든 프로세서가 같은 노스브리지(Northbridge)를 공유합니다(지금은 AMD Opteron의 NUMA 노드는 논외로 합니다. 그들만의 문제가 있습니다). 이는 모든 메모리 트래픽이 그곳을 통해 라우팅되므로 노스브리지가 심각한 병목이 됩니다. 대형 머신은 물론 노스브리지를 대체하는 커스텀 하드웨어를 사용할 수 있지만, 사용되는 메모리 칩이 멀티포트—즉, 여러 버스에서 사용할 수 있는—가 아닌 한 여전히 병목은 존재합니다. 멀티포트 RAM은 설계와 지원이 복잡하고 비싸서 거의 사용되지 않습니다.
다음 단계의 복잡성은 AMD가 사용하는 모델입니다. 여기서는 인터커넥트 메커니즘(AMD의 경우 하이퍼트랜스포트(HyperTransport), Digital로부터 라이선스한 기술)이 RAM에 직접 연결되지 않은 프로세서에 대한 접근을 제공합니다. 이 방식으로 구성할 수 있는 구조물의 크기는 지름(diameter, 즉 임의의 두 노드 간 최대 거리)을 임의로 키우지 않는 한 제한됩니다.
그림 5.1: 하이퍼큐브
노드의 효율적인 토폴로지는 하이퍼큐브이며, 각 노드가 가진 인터커넥트 인터페이스 수를 C라고 할 때 노드 수를 2의 C제곱(2^C)으로 제한합니다. 하이퍼큐브는 2의 n제곱(2^n)개의 CPU를 갖는 모든 시스템 중 지름이 가장 작습니다. 그림 5.1은 처음 세 가지 하이퍼큐브를 보여줍니다. 각 하이퍼큐브의 지름은 C이며, 이는 절대 최소입니다. AMD 1세대 Opteron 프로세서는 프로세서당 세 개의 하이퍼트랜스포트 링크를 갖습니다. 적어도 한 프로세서는 한 링크에 사우스브리지(Southbridge)를 연결해야 하므로, 현재로서는 C=2인 하이퍼큐브를 직접 효율적으로 구현할 수 있습니다. 다음 세대는 네 개의 링크를 제공할 것으로 발표되었고, 그 시점에는 C=3인 하이퍼큐브가 가능해집니다.
그렇다고 해서 더 큰 규모의 프로세서 집합을 지원할 수 없다는 뜻은 아닙니다. 더 큰 프로세서 집합을 사용할 수 있도록 크로스바를 개발한 회사들(예: Newisys의 Horus)이 있습니다. 하지만 이런 크로스바는 NUMA 팩터를 증가시키며, 프로세서 수가 일정 수준을 넘으면 효과가 사라집니다.
그 다음 단계는 CPU 그룹들을 연결하고 모두에 대해 공유 메모리를 구현하는 것입니다. 이러한 시스템은 모두 특수 하드웨어를 필요로 하며 결코 범용 시스템이 아닙니다. 이런 설계는 다양한 복잡도 수준으로 존재합니다. 여전히 범용 머신에 꽤 가까운 시스템으로는 IBM x445와 유사한 머신이 있습니다. 이들은 x86 및 x86-64 프로세서를 사용한 일반적인 4U, 8웨이 머신으로 구매할 수 있습니다. 이런 머신 두 대(어떤 시점에는 최대 네 대) 를 연결하여 공유 메모리를 갖는 단일 머신처럼 동작하게 할 수 있습니다. 사용된 인터커넥트는 OS와 애플리케이션 모두가 고려해야 하는 상당한 NUMA 팩터를 도입합니다.
스펙트럼의 반대편에는 SGI Altix 같은 머신이 있으며, 이들은 연결을 염두에 두고 설계되었습니다. SGI의 NUMAlink 인터커넥트 패브릭은 매우 빠르고 지연이 낮으며, 이는 고성능 컴퓨팅(HPC)에서 특히 메시지 패싱 인터페이스(MPI)를 사용할 때 필수 요건입니다. 단점은 물론 이러한 정교함과 특수화가 매우 비싸다는 것입니다. 이는 비교적 낮은 NUMA 팩터를 가능하게 하지만, 이 머신들이 가질 수 있는 CPU 수(수천)에 비해 인터커넥트의 용량은 제한적이므로 NUMA 팩터는 실제로 동적이며, 작업 부하에 따라 용납할 수 없는 수준에 도달할 수 있습니다.
더 흔한 방식은 범용 머신 클러스터를 고속 네트워킹으로 연결하는 것입니다. 그러나 이는 NUMA 머신이 아닙니다. 공유 주소 공간을 구현하지 않으므로 여기서 논의하는 범주에 속하지 않습니다.
NUMA 머신을 지원하려면 OS가 메모리의 분산 특성을 고려해야 합니다. 예를 들어, 특정 프로세서에서 프로세스를 실행할 때, 그 프로세스의 주소 공간에 할당되는 물리 RAM은 로컬 메모리에서 나와야 합니다. 그렇지 않으면 각 명령이 코드와 데이터를 위해 원격 메모리에 접근해야 합니다. NUMA 머신에만 존재하는 특별한 경우들도 고려해야 합니다. DSO(동적 공유 객체)의 텍스트 세그먼트는 보통 머신의 물리 RAM에 정확히 한 번만 존재합니다. 하지만 DSO가 모든 CPU의 프로세스와 스레드(예: libc 같은 기본 런타임 라이브러리)에서 사용된다면, 소수의 프로세서를 제외한 모든 프로세서가 원격 접근을 해야 한다는 뜻입니다. 이상적으로는 OS가 그러한 DSO를 각 프로세서의 물리 RAM에 "거울처럼" 복제(mirror)해 로컬 복사본을 사용해야 합니다. 이는 최적화일 뿐 필수는 아니며, 일반적으로 구현이 어렵습니다. 지원하지 않거나 제한적으로만 지원될 수 있습니다.
상황을 악화시키지 않으려면 OS는 프로세스나 스레드를 한 노드에서 다른 노드로 마이그레이션하지 않아야 합니다. OS는 일반적인 멀티프로세서 머신에서도 이미 프로세스 마이그레이션을 피하려고 합니다. 한 프로세서에서 다른 프로세서로 옮기면 캐시 내용이 사라지기 때문입니다. 부하 분산 때문에 프로세스나 스레드를 어떤 프로세서에서 내보내야 한다면, OS는 보통 충분한 여유 용량이 있는 임의의 새 프로세서를 선택할 수 있습니다. NUMA 환경에서는 새 프로세서 선택이 좀 더 제한됩니다. 새로 선택된 프로세서는 그 프로세스가 사용하는 메모리에 대해 이전 프로세서보다 더 높은 접근 비용을 가져서는 안 됩니다. 이것이 대상 목록을 제한합니다. 그 기준에 맞는 유휴 프로세서가 없다면, OS는 메모리 접근이 더 비싼 프로세서로 마이그레이션할 수밖에 없습니다.
이 상황에서 가능한 방법은 두 가지입니다. 첫째, 상황이 일시적일 것이라 기대하고 프로세스를 더 적합한 프로세서로 다시 마이그레이션하는 것입니다. 또는 OS가 프로세스의 메모리를 새로 사용 중인 프로세서에 더 가까운 물리 페이지로 함께 마이그레이션할 수도 있습니다. 이는 꽤 비용이 큰 작업입니다. 잠재적으로 방대한 양의 메모리를 복사해야 할 수 있으며, 반드시 한 번에 처리해야 하는 것은 아닙니다. 이 과정에서 적어도 잠시 동안은 프로세스를 중지하여, 이전 페이지에 대한 수정이 올바르게 함께 마이그레이션되도록 해야 합니다. 페이지 마이그레이션을 효율적이고 빠르게 만들기 위한 요구사항은 아주 많습니다. 요컨대, 정말 필요하지 않다면 OS는 이를 피해야 합니다.
일반적으로, NUMA 머신의 모든 프로세스가 동일한 양의 메모리를 사용한다고 가정할 수 없습니다. 따라서 프로세스를 프로세서에 분산 배치하면 메모리 사용량도 동일하게 분산된다고 기대할 수 없습니다. 사실 머신에서 실행되는 애플리케이션이 매우 특수한 경우(HPC 세계에서는 흔하지만 그 밖에서는 드묾)가 아니라면 메모리 사용은 매우 불균등합니다. 어떤 애플리케이션은 엄청난 양의 메모리를 사용하고, 어떤 것은 거의 사용하지 않습니다. 이렇게 되면 요청이 발생한 프로세서의 로컬에 항상 메모리를 할당한다면, 시간 문제로 대형 프로세스를 실행하는 노드의 로컬 메모리가 결국 고갈되는 문제가 발생합니다.
이 심각한 문제에 대응하여, 기본적으로는 메모리가 오로지 로컬 노드에서만 할당되지는 않습니다. 시스템의 모든 메모리를 활용하기 위해 기본 전략은 메모리를 스트라이핑하는 것입니다. 이는 시스템의 모든 메모리가 고르게 사용되도록 보장합니다. 부수 효과로, 평균적으로 사용된 모든 메모리에 대한 접근 비용이 변하지 않기 때문에 프로세스를 프로세서 간에 자유롭게 마이그레이션할 수 있게 됩니다. NUMA 팩터가 작은 경우 스트라이핑은 받아들일 수 있지만 여전히 최적은 아닙니다(5.4절의 데이터를 보세요).
이는 시스템이 심각한 문제를 피하고 일반적인 동작에서 더 예측 가능하도록 돕는 비최적화(pessimization)입니다. 하지만 전반적인 시스템 성능을 떨어뜨리며, 경우에 따라서는 크게 떨어뜨립니다. 이런 이유로 리눅스는 프로세스별로 메모리 할당 규칙을 선택할 수 있게 합니다. 프로세스는 자신과 자식 프로세스에 대해 다른 전략을 선택할 수 있습니다. 이를 위해 사용할 수 있는 인터페이스는 6절에서 소개합니다.
커널은 sys 의사 파일 시스템(sysfs)을 통해 프로세서 캐시에 관한 정보를 다음 위치에 공개합니다.
/sys/devices/system/cpu/cpu*/cache
6.2.1절에서 다양한 캐시의 크기를 조회할 수 있는 인터페이스를 보겠습니다. 여기서 중요한 것은 캐시의 토폴로지입니다. 위 디렉터리에는 CPU가 가진 각종 캐시에 대한 정보를 나열하는 하위 디렉터리(index*라는 이름)가 있습니다. 토폴로지 관점에서 중요한 파일은 이 디렉터리들에 있는 type, level, shared_cpu_map 파일입니다. Intel Core 2 QX6700의 경우 정보는 표 5.1과 같습니다.
type level shared_cpu_map cpu0 index0 Data 1 index1 Instruction 1 00000001 index2 Unified 2 00000003 cpu1 index0 Data 1 index1 Instruction 1 00000002 index2 Unified 2 00000003 cpu2 index0 Data 1 index1 Instruction 1 00000004 index2 Unified 2 0000000c cpu3 index0 Data 1 index1 Instruction 1 00000008 index2 Unified 2 0000000c 표 5.1: Core 2 CPU 캐시의 sysfs 정보
이 데이터의 의미는 다음과 같습니다.
CPU에 더 많은 캐시 레벨이 있다면 index* 디렉터리가 더 많을 것입니다.
4소켓, 듀얼코어 Opteron 머신의 캐시 정보는 표 5.2와 같습니다.
type level shared_cpu_map cpu0 index0 Data 1 index1 Instruction 1 00000001 index2 Unified 2 00000001 cpu1 index0 Data 1 index1 Instruction 1 00000002 index2 Unified 2 00000002 cpu2 index0 Data 1 index1 Instruction 1 00000004 index2 Unified 2 00000004 cpu3 index0 Data 1 index1 Instruction 1 00000008 index2 Unified 2 00000008 cpu4 index0 Data 1 index1 Instruction 1 00000010 index2 Unified 2 00000010 cpu5 index0 Data 1 index1 Instruction 1 00000020 index2 Unified 2 00000020 cpu6 index0 Data 1 index1 Instruction 1 00000040 index2 Unified 2 00000040 cpu7 index0 Data 1 index1 Instruction 1 00000080 index2 Unified 2 00000080 표 5.2: Opteron CPU 캐시의 sysfs 정보
보듯이 이 프로세서들도 세 개의 캐시(L1i, L1d, L2)를 가집니다. 어떤 코어도 캐시 레벨을 공유하지 않습니다. 이 시스템에서 흥미로운 부분은 프로세서 토폴로지입니다. 이 추가 정보 없이는 캐시 데이터를 해석할 수 없습니다. sys 파일 시스템은 다음 경로 아래의 파일들에서 이 정보를 제공합니다.
/sys/devices/system/cpu/cpu*/topology
SMP Opteron 머신에 대해, 이 계층 구조에서 흥미로운 파일들은 표 5.3과 같습니다.
physical_ package_id core_id core_ siblings thread_ siblings cpu0 0 0 00000003 00000001 cpu1 1 00000003 00000002 cpu2 1 0 0000000c 00000004 cpu3 1 0000000c 00000008 cpu4 2 0 00000030 00000010 cpu5 1 00000030 00000020 cpu6 3 0 000000c0 00000040 cpu7 1 000000c0 00000080 표 5.3: Opteron CPU 토폴로지의 sysfs 정보
표 5.2와 표 5.3을 함께 보면, 어떤 CPU에도 하이퍼스레드가 없고(thread_siblings 비트맵에 한 비트만 설정됨), 시스템에는 실제로 네 개의 프로세서가 있으며(physical_package_id 0~3), 각 프로세서에는 두 개의 코어가 있고, 어떤 코어도 캐시를 공유하지 않는다는 것을 알 수 있습니다. 이는 초기 Opteron과 정확히 일치합니다.
지금까지 제공된 데이터에서 완전히 빠진 것은 이 머신의 NUMA 특성에 관한 정보입니다. 어떤 SMP Opteron 머신이든 NUMA 머신입니다. 이 데이터는 NUMA 머신에 존재하는 sys 파일 시스템의 또 다른 부분, 즉 다음 계층 구조에서 찾아야 합니다.
/sys/devices/system/node
이 디렉터리는 시스템의 각 NUMA 노드마다 하나의 하위 디렉터리를 포함합니다. 노드별 디렉터리 안에는 여러 파일이 있습니다. 앞선 두 표에서 설명한 Opteron 머신에 대해 중요한 파일과 그 내용은 표 5.4에 나와 있습니다.
cpumap distance node0 00000003 10 20 20 20 node1 0000000c 20 10 20 20 node2 00000030 20 20 10 20 node3 000000c0 20 20 20 10 표 5.4: Opteron 노드의 sysfs 정보
이 정보는 나머지 모든 것을 하나로 묶어 줍니다. 이제 머신의 아키텍처에 대한 완전한 그림을 얻었습니다. 머신에 네 개의 프로세서가 있다는 것은 이미 알고 있습니다. 각각의 프로세서는 자신의 노드를 구성하고 있으며, 이는 node* 디렉터리의 cpumap 파일 값에 설정된 비트에서 볼 수 있습니다. 해당 디렉터리의 distance 파일에는 각 노드마다 하나씩, 해당 노드에서의 메모리 접근 비용을 나타내는 값 집합이 들어 있습니다. 이 예에서 모든 로컬 메모리 접근 비용은 10이고, 다른 어떤 노드에 대한 모든 원격 접근은 20입니다. {그런데 이건 사실 정확하지 않습니다. 사용된 ACPI 정보가 잘못된 듯합니다. 프로세서에는 일관성 유지형 하이퍼트랜스포트 링크가 세 개 있지만, 적어도 한 프로세서는 사우스브리지에 연결되어야 합니다. 따라서 적어도 한 쌍의 노드는 더 큰 거리를 가져야 합니다.} 이는 프로세서가 2차원 하이퍼큐브로 구성되어 있어도(그림 5.1 참조), 직접 연결되지 않은 프로세서 간 접근이 더 비싸지 않다는 뜻입니다. 비용의 상대적 값은 실제 접근 시간 차이에 대한 추정치로 사용할 수 있습니다. 이 정보의 정확성은 또 다른 문제입니다.
그래도 distance는 의미가 있습니다. [amdccnuma]에서 AMD는 4소켓 머신의 NUMA 비용을 문서화합니다. 쓰기 작업에 대한 숫자는 그림 5.3에 나와 있습니다.
그림 5.3: 다중 노드에서의 읽기/쓰기 성능
쓰기가 읽기보다 느리다는 것은 놀랍지 않습니다. 흥미로운 부분은 1홉과 2홉의 비용입니다. 두 개의 1홉 경우는 실제로 약간 다른 비용을 갖습니다. 상세한 내용은 [amdccnuma]를 보세요. 이 차트에서 기억해야 할 사실은 2홉 읽기와 쓰기가 각각 0홉 읽기보다 30%, 49% 느리다는 것입니다. 2홉 쓰기는 0홉 쓰기보다 32% 느리고, 1홉 쓰기보다 17% 느립니다. 프로세서와 메모리 노드의 상대적 위치는 큰 차이를 만들어낼 수 있습니다. AMD의 차세대 프로세서는 프로세서당 일관성 유지형 하이퍼트랜스포트 링크를 네 개 제공합니다. 그런 경우 4소켓 머신의 지름은 1이 됩니다. 8소켓에서는 같은 문제가 더 심해져, 8노드 하이퍼큐브의 지름은 3입니다.
이 모든 정보는 이용 가능하지만 사용하기 번거롭습니다. 6.5절에서는 이 정보를 더 쉽게 접근하고 사용할 수 있게 도와주는 인터페이스를 보겠습니다.
시스템이 제공하는 마지막 정보는 프로세스 자체의 상태에 있습니다. 시스템의 메모리 매핑 파일, Copy-On-Write(COW) 페이지, 익명 메모리가 노드들에 어떻게 분포하는지 확인할 수 있습니다. {Copy-On-Write는 OS 구현에서 자주 사용되는 방법으로, 메모리 페이지가 처음에는 한 사용자만 갖고 있다가 독립적 사용자를 허용하기 위해 복사해야 할 때 쓰입니다. 많은 상황에서 복사가 전혀 필요 없거나, 적어도 처음에는 필요 없습니다. 이런 경우 각 사용자가 메모리를 수정할 때만 복사하는 것이 합리적입니다. 운영체제가 쓰기 연산을 가로채어 메모리 페이지를 복제한 뒤, 쓰기 명령을 진행시키는 방식입니다.} 각 프로세스에는 /proc/PID/numa_maps 파일이 있으며, 여기서 PID는 프로세스 ID입니다. 내용은 그림 5.2와 같습니다.
00400000 default file=/bin/cat mapped=3 N3=3 00504000 default file=/bin/cat anon=1 dirty=1 mapped=2 N3=2 00506000 default heap anon=3 dirty=3 active=0 N3=3 38a9000000 default file=/lib64/ld-2.4.so mapped=22 mapmax=47 N1=22 38a9119000 default file=/lib64/ld-2.4.so anon=1 dirty=1 N3=1 38a911a000 default file=/lib64/ld-2.4.so anon=1 dirty=1 N3=1 38a9200000 default file=/lib64/libc-2.4.so mapped=53 mapmax=52 N1=51 N2=2 38a933f000 default file=/lib64/libc-2.4.so 38a943f000 default file=/lib64/libc-2.4.so anon=1 dirty=1 mapped=3 mapmax=32 N1=2 N3=1 38a9443000 default file=/lib64/libc-2.4.so anon=1 dirty=1 N3=1 38a9444000 default anon=4 dirty=4 active=0 N3=4 2b2bbcdce000 default anon=1 dirty=1 N3=1 2b2bbcde4000 default anon=2 dirty=2 N3=2 2b2bbcde6000 default file=/usr/lib/locale/locale-archive mapped=11 mapmax=8 N0=11 7fffedcc7000 default stack anon=2 dirty=2 N3=2
그림 5.2: /proc/PID/numa_maps의 내용
이 파일에서 중요한 정보는 N0부터 N3까지의 값으로, 해당 메모리 영역에 대해 노드 0~3에 할당된 페이지 수를 나타냅니다. 이 프로그램이 노드 3의 코어에서 실행되었다고 추측하는 것이 합리적입니다. 프로그램 자체와 더럽혀진(dirtied) 페이지는 그 노드에 할당됩니다. ld-2.4.so와 libc-2.4.so의 첫 번째 매핑처럼 읽기 전용 매핑과, locale-archive 같은 공유 파일은 다른 노드에 할당됩니다.
그림 5.3에서 보았듯, 노드를 가로지르는 읽기 성능은 1홉과 2홉의 경우 각각 9%와 30% 떨어집니다. 실행을 위해서는 이런 읽기가 필요하며, L2 캐시를 놓치면 각 캐시 라인마다 이 추가 비용이 발생합니다. 캐시 크기를 넘어서는 큰 작업 집합에 대해 측정한 모든 비용은, 메모리가 프로세서에 대해 원격인 경우 9%/30%만큼 증가해야 합니다.
그림 5.4: 원격 메모리에서의 연산
현실 세계의 효과를 보기 위해 3.5.1절과 같은 방식으로 대역폭을 측정하되, 이번에는 메모리가 한 홉 떨어진 원격 노드에 있도록 합니다. 로컬 메모리를 사용한 데이터와 비교한 이 테스트의 결과는 그림 5.4에서 볼 수 있습니다. 수치에는 양방향으로 몇몇 큰 스파이크가 있는데, 이는 멀티스레드 코드를 측정할 때의 문제에서 비롯된 것이니 무시해도 됩니다. 이 그래프에서 중요한 정보는 읽기 연산이 항상 20% 느리다는 것입니다. 이는 그림 5.3의 9%보다 훨씬 큰데, 아마도 그 수치가 끊김 없는 읽기/쓰기 연산에 대한 것이 아니거나 더 오래된 프로세서 리비전에 대한 것일 수 있습니다. AMD만이 정확히 알겠지요.
작업 집합 크기가 캐시에 들어맞는 경우, 쓰기와 복사 연산의 성능도 20% 느려집니다. 작업 집합이 캐시 크기를 초과하는 경우, 쓰기 성능은 로컬 노드에서의 연산과 비교해 측정 가능한 수준으로 더 느려지지 않습니다. 인터커넥트의 속도가 메모리 속도를 따라잡을 만큼 빠르기 때문입니다. 지배적인 요인은 메인 메모리를 기다리는 시간입니다.
| 이 글의 색인 항목 |
|---|
| GuestArticles |