울리히 드레퍼의 ‘모든 프로그래머가 알아야 할 메모리’ 6부. 멀티스레드 코드의 최적화(동시성, 원자성, 대역폭)와 CPU 친화도, 메모리 정책, NUMA 프로그래밍 기법을 다룬다.
LWN.net이 여러분을 필요로 합니다! 구독자가 없다면 LWN은 존재할 수 없습니다. LWN의 발행을 계속할 수 있도록 구독을 고려해 주세요: https://lwn.net/Promo/nst-nag2/subscribe
[편집자 주: 이것은 울리히 드레퍼(Ulrich Drepper)의 "What every programmer should know about memory(모든 프로그래머가 알아야 할 메모리)"의 6부입니다. 이 부분은 6장의 후반부로, 멀티스레드 코드의 최적화를 다룹니다. 6장의 전반부는 이미 5부(http://lwn.net/Articles/255364/)에 실렸습니다. 다른 섹션들의 안내는 1부(http://lwn.net/Articles/250967/)를 참조하세요.]
멀티스레딩과 관련해 캐시 사용에서 중요한 측면은 세 가지가 있다.
이 측면들은 멀티 프로세스 상황에도 적용되지만, 여러 프로세스는 (대체로) 독립적이기 때문에 최적화가 쉽지 않다. 가능한 멀티 프로세스 최적화는 멀티 스레드 시나리오에서 가능한 최적화의 부분집합이다. 따라서 여기서는 멀티 스레드만을 다룬다.
이 문맥에서 동시성은 한 프로세스가 동시에 하나 이상의 스레드를 실행할 때 겪는 메모리 효과를 의미한다. 스레드의 특성은 모두 동일한 주소 공간을 공유하며, 따라서 동일한 메모리에 모두 접근할 수 있다는 점이다. 이상적인 경우, 스레드들이 사용하는 메모리 영역이 서로 다르면 입력/출력 공유 정도만 있는 느슨한 결합이 된다. 두 개 이상의 스레드가 동일한 데이터를 사용한다면 조정이 필요하고, 이때 원자성이 개입된다. 마지막으로, 머신 아키텍처에 따라 프로세서가 사용할 수 있는 메모리 및 프로세서 간 버스 대역폭이 제한된다. 다음 절들에서 이 세 가지 측면을 각각 다룰 것이며—물론 서로 밀접하게 연관되어 있긴 하다.
6.4.1 동시성 최적화
이 절에서는 실제로 상충되는 최적화를 요구하는 두 가지 이슈를 논의한다. 멀티스레드 애플리케이션은 일부 스레드에서 공통 데이터를 사용한다. 일반적인 캐시 최적화는 데이터를 모아 애플리케이션의 풋프린트를 작게 유지해, 한 번에 캐시에 들어갈 수 있는 메모리의 양을 최대화하도록 요구한다.
하지만 이 접근에는 문제가 있다. 여러 스레드가 동일한 메모리 위치에 쓰기를 하면, 해당 캐시 라인은 각 코어의 L1d에서 ‘E’(exclusive) 상태여야 한다. 이는 최악의 경우 각 쓰기 접근마다 하나의 RFO 요청이 전송됨을 의미한다. 따라서 보통의 쓰기가 갑자기 매우 비싸진다. 동일한 메모리 위치가 사용된다면 동기화가 필요하다(아마도 다음 절에서 다룰 원자적 연산을 통해). 문제는 모든 스레드가 서로 다른 메모리 위치를 사용하고 겉보기엔 독립적인 경우에도 나타날 수 있다.
그림 6.10: 동시 캐시 라인 접근 오버헤드
그림 6.10은 이러한 “거짓 공유(false sharing)”의 결과를 보여준다. 테스트 프로그램(섹션 9.3 참고)은 여러 스레드를 생성해 메모리 위치를 증가시키는 작업만 수행한다(5억 번). 측정 시간은 프로그램 시작부터 마지막 스레드를 기다린 후 프로그램이 종료될 때까지이다. 스레드들은 개별 프로세서에 고정(pinning)된다. 머신은 P4 프로세서 4개로 구성되어 있다. 파란 막대는 각 스레드에 할당된 메모리 위치가 서로 다른 캐시 라인에 있을 때의 실행 결과다. 빨간 막대는 스레드들의 위치를 동일한 하나의 캐시 라인으로 모았을 때 발생한 패널티다.
파란 측정값(개별 캐시 라인 사용)은 기대와 일치한다. 프로그램은 패널티 없이 많은 스레드로 스케일한다. 각 프로세서는 자신의 L1d에 캐시 라인을 보유하고, 읽어야 할 코드나 데이터가 많지 않기 때문에(실제로는 모두 캐시에 있음) 대역폭 이슈가 없다. 측정된 소폭 상승은 시스템 노이즈와 아마도 프리패칭 효과(스레드가 연속 캐시 라인을 사용) 때문일 것이다.
측정된 오버헤드는 하나의 캐시 라인을 사용할 때 걸린 시간을 각 스레드에 별도 캐시 라인을 사용할 때의 시간으로 나눈 값으로 계산했으며, 각각 390%, 734%, 1,147%다. 숫자가 처음 보면 놀라울 수 있지만, 필요한 캐시 상호작용을 생각해 보면 자명하다. 캐시 라인은 한 프로세서가 해당 캐시 라인에 쓰기를 끝내자마자 다른 프로세서의 캐시에서 끌려온다. 어느 순간 캐시 라인을 가진 프로세서를 제외한 모든 프로세서는 지연되고 아무것도 할 수 없다. 프로세서가 추가될수록 지연만 더 커진다.
이 측정에서 보듯이 이런 상황은 프로그램에서 피해야 한다. 페널티가 엄청나므로 많은 상황에서 이 문제는 뚜렷하게 드러난다(프로파일링을 하면 최소한 코드 위치가 보일 것이다). 하지만 최신 하드웨어에서는 함정이 있다. 그림 6.11은 단일 프로세서 쿼드 코어(인텔 Core 2 QX 6700) 머신에서 동일 코드를 실행했을 때의 측정값이다. 이 프로세서가 L2를 두 개로 분리해 갖고 있음에도, 이 테스트 케이스는 스케일링 문제를 보이지 않는다. 동일 캐시 라인을 여러 번 사용할 때 약간의 오버헤드가 있지만 코어 수에 따라 증가하지 않는다. {모든 네 코어를 사용했을 때 수치가 더 낮은 이유는 설명하지 못하겠지만 재현된다.} 이런 프로세서를 여러 개 사용한다면 당연히 그림 6.10과 유사한 결과를 보게 될 것이다. 멀티코어 프로세서의 사용이 늘고 있지만, 많은 머신은 여전히 멀티 프로세서를 사용할 것이므로 이 시나리오를 제대로 처리하는 것이 중요하다. 실제 SMP 머신에서 코드를 테스트해야 할 수도 있다.
그림 6.11: 오버헤드, 쿼드 코어
이 문제에 대한 매우 간단한 “수정”은 각 변수를 자신의 캐시 라인에 놓는 것이다. 여기서 앞서 언급한 최적화와 충돌이 발생한다. 즉, 애플리케이션의 풋프린트가 크게 증가한다. 이는 받아들일 수 없다. 따라서 더 영리한 해결책이 필요하다.
필요한 것은 어떤 변수가 한 번에 정확히 한 스레드만 사용하는지, 영원히 한 스레드만 사용하는지, 그리고 때때로 경합하는지를 식별하는 일이다. 각 시나리오에 대해 서로 다른 해결책이 가능하고 유용하다. 변수를 구분하는 가장 기본 기준은: 그 변수가 쓰기되는지, 그리고 얼마나 자주 쓰기되는지다.
절대 쓰기되지 않는 변수와 한 번만 초기화되는 변수는 기본적으로 상수다. RFO 요청은 쓰기 연산에서만 필요하므로, 상수는 캐시에서 공유(‘S’ 상태)될 수 있다. 따라서 이런 변수는 특별 취급할 필요가 없다. 묶어서 배치하면 된다. 프로그래머가 변수를 올바르게 const로 표시하면, 툴체인은 변수를 일반 변수와 분리해 .rodata(read-only data) 또는 .data.rel.ro(재배치 이후 read-only) 섹션으로 옮긴다. {ELF 파일에서 이름으로 식별되는 섹션은 코드와 데이터를 담는 원자 단위다.} 추가 조치는 필요 없다. 어떤 이유로 const로 정확히 표시할 수 없다면, 특별한 섹션을 지정해 배치를 조정할 수도 있다.
링커가 최종 바이너리를 구성할 때, 먼저 모든 입력 파일에서 동일한 이름의 섹션들을 이어붙이고, 그런 다음 링커 스크립트가 결정한 순서로 배치한다. 즉, 기본적으로 상수이지만 그렇게 표시되지 않은 변수를 특별 섹션으로 옮기면, 프로그래머는 이런 변수들을 한데 모을 수 있다. 그 사이에 자주 쓰기되는 변수가 끼어들지 않게 된다. 섹션의 첫 변수를 적절히 정렬하면 거짓 공유가 발생하지 않도록 보장할 수 있다. 다음의 작은 예를 보자.
int foo = 1;
int bar __attribute__((section(".data.ro"))) = 2;
int baz = 3;
int xyzzy __attribute__((section(".data.ro"))) = 4;
컴파일하면 이 입력 파일은 4개의 변수를 정의한다. 흥미로운 부분은 foo와 baz, 그리고 bar와 xyzzy가 각각 함께 묶인다는 점이다. 애트리뷰트 정의가 없다면, 컴파일러는 소스 코드에 정의된 순서대로 네 변수를 .data라는 섹션에 할당할 것이다. {이는 ISO C 표준이 보장하는 바는 아니지만 gcc는 이렇게 동작한다.} 현재 코드에서는 bar와 xyzzy가 .data.ro라는 섹션에 배치된다. 섹션 이름 .data.ro는 거의 임의다. .data. 접두사를 사용하면 GNU 링커가 이 섹션을 다른 데이터 섹션들과 함께 배치함을 보장한다.
같은 기법을 대체로 읽기 전용이지만 가끔 쓰기되는 변수들을 분리하는 데도 적용할 수 있다. 단지 다른 섹션 이름을 고르면 된다. 이런 분리는 리눅스 커널처럼 일부 경우에 합리적일 수 있다.
변수가 오직 한 스레드에서만 사용된다면 변수를 지정하는 또 다른 방법이 있다. 이런 경우 스레드 로컬 변수(see [mytls])를 사용하는 것이 가능하고 유용하다. gcc의 C/C++ 언어에서는 __thread 키워드를 사용해 변수를 스레드별로 정의할 수 있다.
int foo = 1;
__thread int bar = 2;
int baz = 3;
__thread int xyzzy = 4;
bar와 xyzzy 변수는 일반 데이터 세그먼트에 할당되지 않는다. 대신 각 스레드는 이런 변수들을 저장하는 자신의 별도 영역을 갖는다. 변수들은 정적 초기값을 가질 수 있다. 모든 스레드는 서로의 스레드 로컬 변수를 주소로 접근할 수 있지만, 한 스레드가 다른 스레드에게 스레드 로컬 변수의 포인터를 넘기지 않는 한 다른 스레드가 그 변수를 찾을 방법은 없다. 변수가 스레드 로컬이므로, 프로그램이 인위적으로 문제를 만들지 않는 한 거짓 공유 문제는 없다. 이 해결책은 설정이 쉽다(컴파일러와 링커가 모든 일을 한다). 하지만 비용이 있다. 스레드가 생성될 때 스레드 로컬 변수를 설정하는 데 시간과 메모리가 든다. 또한 스레드 로컬 변수에 접근하는 것은 보통 전역 변수나 자동 변수 사용보다 더 비싸다(가능하면 비용을 자동으로 최소화하는 방법은 [mytls]를 보라).
스레드 로컬 스토리지(TLS)의 단점 하나는, 변수를 사용하는 주체가 다른 스레드로 바뀌면 이전 스레드의 현재 변수 값이 새 스레드에 없다는 점이다. 각 스레드의 변수 사본은 별개다. 종종 전혀 문제가 안 되지만, 문제가 된다면 새 스레드로의 전환 시점에 조정이 필요하고, 그때 현재 값을 복사할 수 있다.
두 번째, 더 큰 문제는 자원 낭비 가능성이다. 한 번에 오직 하나의 스레드만 변수를 사용한다면 모든 스레드가 메모리 비용을 치러야 한다. 스레드가 TLS 변수를 전혀 사용하지 않으면 TLS 메모리 영역의 지연 할당으로 이 문제가 생기지 않는다(애플리케이션 자체의 TLS는 예외). 스레드가 어떤 DSO에서 단 하나의 TLS 변수만 사용하더라도, 그 객체의 다른 모든 TLS 변수들을 위한 메모리도 함께 할당된다. TLS 변수를 대규모로 사용하면 합이 꽤 커질 수 있다.
일반적으로 줄 수 있는 최선의 조언은 다음과 같다.
int foo = 1;
int baz = 3;
struct {
struct al1 {
int bar;
int xyzzy;
};
char pad[CLSIZE - sizeof(struct al1)];
} rwstruct __attribute__((aligned(CLSIZE))) =
{ { .bar = 2, .xyzzy = 4 } };
약간의 코드 변경이 필요하다(bar 참조를 rwstruct.bar로, xyzzy도 마찬가지) 하지만 그게 전부다. 컴파일러와 링커가 나머지를 모두 처리한다. {이 코드는 커맨드라인에서 -fms-extensions 옵션으로 컴파일해야 한다.}
6.4.2 원자성 최적화
여러 스레드가 동일한 메모리 위치를 동시에 수정하면, 프로세서는 특정 결과를 보장하지 않는다. 이는 전체 케이스의 99.999%에서 불필요한 비용을 피하기 위한 의도적 결정이다. 예를 들어, 메모리 위치가 ‘S’ 상태이고 두 스레드가 동시에 값을 증가시켜야 한다면, 실행 파이프라인은 캐시 라인이 ‘E’ 상태에서 사용 가능해질 때까지 기다렸다가 캐시에서 오래된 값을 읽어 덧셈을 수행할 필요가 없다. 대신 현재 캐시에 있는 값을 읽고, 캐시 라인이 ‘E’ 상태로 사용 가능해지면 새 값을 기록한다. 두 스레드의 캐시 읽기가 동시에 발생하면, 하나의 덧셈이 유실되어 기대한 결과가 나오지 않는다.
이를 방지하기 위해 프로세서는 원자적 연산을 제공한다. 예컨대 이러한 원자 연산은 덧셈이 메모리 위치에 대해 원자적으로 보이도록 수행될 수 있다는 것이 확실해질 때까지 오래된 값을 읽지 않는다. 다른 코어나 프로세서를 기다리는 것 외에도, 일부 프로세서는 특정 주소의 원자 연산을 메인보드 상의 다른 장치에도 신호한다. 이 모든 것이 원자 연산을 더 느리게 만든다.
프로세서 벤더들은 서로 다른 집합의 원자 연산을 제공하기로 결정했다. 초기 RISC 프로세서는 ‘R’(reduced)에 걸맞게 아주 적은 원자 연산만 제공했으며, 때로는 원자적 비트 설정 및 테스트 정도만 제공했다. {HP Parisc는 지금도 더 많이 제공하지 않는다…} 반대편 극단에는 많은 원자 연산을 제공하는 x86과 x86-64가 있다. 일반적으로 제공되는 원자 연산은 네 가지 범주로 나눌 수 있다.
한 아키텍처는 LL/SC 또는 CAS 중 하나를 지원하지 둘 다 지원하지 않는다. 두 접근법은 기본적으로 동등하며, 원자적 산술 연산을 동일하게 구현할 수 있지만 오늘날에는 CAS가 선호되는 듯하다. 다른 모든 연산은 간접적으로 이를 사용해 구현할 수 있다. 예를 들어, 원자적 덧셈은 다음과 같다.
int curval;
int newval;
do {
curval = var;
newval = curval + addend;
} while (CAS(&var, curval, newval));
CAS 호출의 결과는 연산이 성공했는지 여부를 나타낸다. 실패(0이 아닌 값)를 반환하면 루프를 다시 돌고, 덧셈을 수행한 뒤 CAS를 다시 시도한다. 성공할 때까지 반복한다. 주목할 점은 메모리 위치의 주소를 두 개의 별도 명령으로 계산해야 한다는 것이다. {x86 및 x86-64의 CAS 오퍼코드는 두 번째 이후 반복에서 값의 로드를 피할 수 있지만, 이 플랫폼에서는 단일 add 오퍼코드로 더 간단하게 원자적 덧셈을 작성할 수 있다.} LL/SC의 경우 코드는 거의 동일하다.
int curval;
int newval;
do {
curval = LL(var);
newval = curval + addend;
} while (SC(var, newval));
여기서는 특별한 로드 명령(LL)을 사용해야 하며, SC에는 메모리 위치의 현재 값을 전달할 필요가 없다. 프로세서가 그 사이에 메모리 위치가 수정되었는지 안다.
큰 차별점은 x86과 x86-64로, 여기에는 원자 연산이 있으며, 최상의 결과를 얻기 위해 적절한 원자 연산을 선택하는 것이 중요하다. 그림 6.12는 원자적 증가 연산을 구현하는 세 가지 방법을 보여준다.
for (i = 0; i < N; ++i) __sync_add_and_fetch(&var,1);
1. 더하고 결과 읽기(Add and Read Result) for (i = 0; i < N; ++i) __sync_fetch_and_add(&var,1);
2. 더하고 이전 값 반환(Add and Return Old Value) for (i = 0; i < N; ++i) { long v, n; do { v = var; n = v + 1; } while (!__sync_bool_compare_and_swap(&var, v, n)); }
3. 새 값으로 원자 교체(Atomic Replace with New Value)
그림 6.12: 루프에서의 원자적 증가
세 경우 모두 x86과 x86-64에서는 서로 다른 코드를 생성하지만, 다른 아키텍처에서는 동일할 수도 있다. 성능 차이는 매우 크다. 다음 표는 4개의 동시 스레드로 100만 번 증가시켰을 때의 실행 시간을 보여준다. 코드는 gcc의 내장 프리미티브(_sync*)를 사용한다.
1. Exchange Add 2. Add Fetch 3. CAS 0.23s 0.21s 0.73s
첫 두 숫자는 비슷하며, 이전 값을 반환하는 쪽이 약간 더 빠르다. 중요한 정보는 강조된 칸, 즉 CAS를 사용할 때의 비용이다. 예상대로 훨씬 더 비싸다. 이유는 여러 가지다. 1) 메모리 연산이 두 번이고, 2) CAS 자체가 더 복잡하며 조건부 연산까지 필요하고, 3) 두 접근이 동시에 발생해 CAS가 실패할 수 있으므로 전체 연산을 루프에서 수행해야 한다.
그렇다면 독자는 묻고 싶을 것이다. 왜 누군가 CAS를 사용하는 더 복잡하고 긴 코드를 쓰는가? 답은: 복잡성이 보통 숨겨지기 때문이다. 앞서 언급했듯 CAS는 현재 흥미로운 모든 아키텍처 전반에서 통일적인 원자 연산이다. 그래서 모든 원자 연산을 CAS로 정의해도 충분하다고 생각하는 사람들이 있다. 이는 프로그램을 단순하게 만든다. 하지만 숫자가 보여주듯 결과는 최적과 거리가 멀 수 있다. CAS 해법의 메모리 처리 오버헤드는 엄청나다. 다음은 각기 자신만의 코어에서 실행되는 단 두 개의 스레드의 실행을 설명한다.
스레드 #1 스레드 #2 var 캐시 상태 v = var ‘E’ (프로세서 1) n = v + 1 v = var ‘S’ (프로세서 1+2) CAS(var) n = v + 1 ‘E’ (프로세서 1) CAS(var) ‘E’ (프로세서 2)
이 짧은 실행 구간 동안 캐시 라인 상태가 최소 세 번 바뀌며, 그 중 두 번은 RFO다. 게다가 두 번째 CAS는 실패하므로 해당 스레드는 전체 연산을 반복해야 한다. 그 반복 동안에도 동일한 일이 다시 일어날 수 있다.
대조적으로, 원자적 산술 연산을 사용할 때 프로세서는 덧셈(또는 그 밖의 연산)에 필요한 로드와 스토어를 함께 유지할 수 있다. 프로세서는 원자 연산이 끝날 때까지 동시에 발행된 캐시 라인 요청을 차단할 수 있다. 따라서 예제의 각 루프 반복은 최대 한 번의 RFO 캐시 요청만 발생하고, 그 외에는 없다.
이 모든 것이 의미하는 바는, 기계 추상화를 원자적 산술 및 논리 연산을 활용할 수 있는 수준에서 정의하는 것이 매우 중요하다는 점이다. CAS를 보편적 통합 메커니즘으로 사용해서는 안 된다.
대부분의 프로세서에서 원자 연산은 그 자체로 항상 원자적이다. 원자성이 필요하지 않은 경우를 위해 완전히 분리된 코드 경로를 제공하는 것 외에는 피할 수 없다. 이는 더 많은 코드, 조건문, 그리고 적절한 실행 경로로 분기하는 추가 점프를 의미한다.
x86과 x86-64의 상황은 다르다. 동일한 명령을 원자적 또는 비원자적으로 모두 사용할 수 있다. 원자적으로 만들려면 명령에 특별한 접두사, 즉 lock 프리픽스를 사용한다. 이는 필요한 상황이 아니라면 원자 연산의 높은 비용을 피할 여지를 제공한다. 예를 들어 항상 필요할 때 스레드 안전해야 하는 라이브러리의 일반 코드에서 유용하다. 코드를 작성할 때 정보가 없어도 되며, 실행 시점에 결정할 수 있다. 요령은 lock 프리픽스를 건너뛰는 점프를 넣는 것이다. 이 트릭은 x86과 x86-64에서 lock 프리픽스를 허용하는 모든 명령에 적용된다.
cmpl $0, multiple_threads
je 1f
lock
1: add $1, some_var
이 어셈블리 코드가 난해해 보여도 걱정하지 말라. 간단하다. 첫 번째 명령은 변수가 0인지 아닌지 검사한다. 0이 아닌 값은 한 개 이상의 스레드가 실행 중임을 의미한다. 값이 0이면 두 번째 명령이 레이블 1로 점프한다. 그렇지 않으면 다음 명령이 실행된다. 이것이 핵심이다. je 명령이 점프하지 않으면 add 명령은 lock 프리픽스와 함께 실행된다. 점프하면 lock 없이 실행된다.
분기 예측 실패 시 비교적 비싼 조건 분기를 추가하는 것은 역효과처럼 보일 수 있다. 실제로 그런 경우도 있다. 대부분의 시간에 여러 스레드가 실행 중이라면, 성능은 더 떨어지고 특히 분기 예측이 틀리면 더 나빠진다. 하지만 한 스레드만 사용되는 상황이 많다면 코드는 상당히 빨라진다. if-then-else 구성은 두 경우 모두 추가적인 무조건 점프를 도입하여 더 느릴 수 있다. 원자 연산은 대략 200사이클 정도의 비용이 들기 때문에, 이 트릭(또는 if-then-else 블록)을 사용할 교차점은 꽤 낮다. 반드시 기억해야 할 테크닉이다. 안타깝게도 이 기법은 gcc의 _sync* 프리미티브를 사용할 수 없음을 의미한다.
6.4.3 대역폭 고려사항
많은 스레드를 사용하고, 서로 다른 코어에서 동일한 캐시 라인을 사용해 캐시 경합을 일으키지 않더라도 잠재적 문제가 있다. 각 프로세서는 자신에게 속한 모든 코어와 하이퍼스레드가 공유하는 메모리로의 최대 대역폭을 가진다. 머신 아키텍처(예: 그림 2.1)와 달리, 여러 프로세서가 같은 메모리 또는 노스브리지로 가는 버스를 공유할 수도 있다.
프로세서 코어 자체는, 최적 조건에서도, 메모리 연결이 모든 로드/스토어 요청을 대기 없이 처리할 수 없는 주파수에서 동작한다. 여기에 가용 대역폭을 노스브리지로 가는 연결을 공유하는 코어, 하이퍼스레드, 프로세서 수만큼 더 나누면, 병렬성이 큰 문제가 된다. 이론적으로 매우 효율적인 프로그램도 메모리 대역폭에 의해 제한될 수 있다.
그림 3.32에서 보았듯, 프로세서의 FSB 속도를 높이면 큰 도움이 된다. 그래서 프로세서의 코어 수가 증가함에 따라 FSB 속도도 증가할 것이다. 그래도 프로그램이 큰 워킹 셋을 사용하고 충분히 최적화되어 있으면 결코 충분하지 않을 것이다. 프로그래머는 대역폭 제한으로 인한 문제를 인지할 준비가 되어 있어야 한다.
현대 프로세서의 성능 측정 카운터는 FSB 경합을 관찰할 수 있게 해준다. 코어 2 프로세서에서는 NUS_BNR_DRV 이벤트가 코어가 버스 준비가 안 되어 대기해야 했던 사이클 수를 센다. 이는 버스 사용률이 높아 메인 메모리로부터의 로드/스토어가 평소보다 더 오래 걸린다는 것을 보여준다. 코어 2 프로세서는 RFO 같은 특정 버스 동작이나 일반적인 FSB 사용률을 셀 수 있는 더 많은 이벤트를 지원한다. 후자는 개발 중 애플리케이션의 스케일링 가능성을 조사할 때 유용할 수 있다. 버스 사용률이 이미 1.0에 가까우면, 스케일링 기회는 극히 제한적이다.
대역폭 문제가 인지되면, 취할 수 있는 조치가 몇 가지 있다. 때로는 상충적이므로 실험이 필요할 수 있다. 한 가지 해결책은 더 빠른 컴퓨터를 구매하는 것이다. 더 높은 FSB 속도, 더 빠른 RAM 모듈, 프로세서에 가까운 메모리는 도움이 될 수 있다(그리고 아마 그럴 것이다). 하지만 비용이 많이 든다. 문제가 되는 프로그램이 한 대(또는 소수의) 머신에서만 필요하다면, 하드웨어 일회성 지출이 프로그램 재작업보다 적게 들 수 있다. 일반적으로는 프로그램을 개선하는 편이 낫다.
프로그램 자체를 캐시 미스를 피하도록 최적화한 다음, 남은 유일한 선택지는 가용 코어에 스레드를 더 잘 배치해 대역폭 활용을 개선하는 것이다. 기본적으로 커널의 스케줄러는 독자 정책에 따라 스레드를 프로세서에 배정한다. 스레드를 한 코어에서 다른 코어로 옮기는 것은 가능하면 피한다. 하지만 스케줄러는 워크로드에 대한 실제 지식을 갖고 있지 않다. 캐시 미스 등에서 정보를 모을 수 있지만, 많은 상황에서는 큰 도움이 되지 않는다.
그림 6.13: 비효율적 스케줄링
큰 FSB 사용을 유발할 수 있는 한 상황은, 두 스레드가 서로 다른 프로세서(또는 캐시를 공유하지 않는 코어)에 스케줄되고 동일한 데이터 셋을 사용하는 경우다. 그림 6.13은 그런 상황을 보여준다. 코어 1과 3은 동일한 데이터에 접근한다(접근 표시와 메모리 영역의 동일한 색으로 표시). 마찬가지로 코어 2와 4도 동일한 데이터에 접근한다. 하지만 스레드가 서로 다른 프로세서에 스케줄되어 있다. 이는 각 데이터 셋이 메모리에서 두 번 읽혀야 함을 의미한다. 이 상황은 더 나은 방식으로 처리할 수 있다.
그림 6.14: 효율적 스케줄링
그림 6.14는 이상적인 모습을 보여준다. 이제 사용 중인 총 캐시 크기가 줄어든다. 코어 1과 2, 코어 3과 4가 동일한 데이터를 작업하기 때문이다. 데이터 셋은 메모리에서 한 번만 읽히면 된다.
이는 단순한 예지만 확장하면 많은 상황에 적용된다. 앞서 언급했듯 커널의 스케줄러는 데이터 사용에 대한 통찰이 없으므로, 프로그래머가 효율적인 스케줄링을 보장해야 한다. 이 요구를 전달할 수 있는 커널 인터페이스는 많지 않다. 사실상 하나뿐이다. 스레드 친화도(thread affinity)를 정의하는 것이다.
스레드 친화도란 스레드를 하나 이상의 코어에 할당하는 것을 의미한다. 스케줄러는 스레드를 어디서 실행할지 결정할 때 오직 그 코어들 중에서만 선택한다. 다른 코어가 유휴 상태여도 고려되지 않는다. 이는 불리하게 들리지만, 치러야 할 대가다. 너무 많은 스레드가 특정 코어 집합에서만 실행되면 나머지 코어는 대부분 유휴 상태가 될 수 있고, 친화도를 바꾸는 것 말고는 할 수 있는 일이 없다. 기본적으로 스레드는 어떤 코어에서도 실행될 수 있다.
다음은 스레드의 친화도를 조회하고 변경하는 인터페이스들이다.
#define _GNU_SOURCE
#include <sched.h>
int sched_setaffinity(pid_t pid, size_t size, const cpu_set_t *cpuset);
int sched_getaffinity(pid_t pid, size_t size, cpu_set_t *cpuset);
이 두 인터페이스는 단일 스레드 코드에 사용하도록 설계되었다. pid 인자는 어느 프로세스의 친화도를 변경하거나 확인할지 지정한다. 호출자는 당연히 적절한 권한이 필요하다. 두 번째와 세 번째 파라미터는 코어에 대한 비트마스크를 지정한다. 첫 번째 함수는 친화도를 설정하기 위해 채워진 비트마스크를 필요로 한다. 두 번째 함수는 선택된 스레드의 스케줄링 정보를 비트마스크에 채워준다. 인터페이스는 <sched.h>에 선언되어 있다.
cpu_set_t 타입은 이 헤더에 정의되어 있으며, 이 타입의 객체를 조작하고 사용하는 매크로들도 함께 제공된다.
#define _GNU_SOURCE
#include <sched.h>
#define CPU_SETSIZE
#define CPU_SET(cpu, cpusetp)
#define CPU_CLR(cpu, cpusetp)
#define CPU_ZERO(cpusetp)
#define CPU_ISSET(cpu, cpusetp)
#define CPU_COUNT(cpusetp)
CPU_SETSIZE는 이 자료구조로 표현할 수 있는 CPU의 수를 지정한다. 나머지 세 매크로는 cpu_set_t 객체를 조작한다. 객체를 초기화하려면 CPU_ZERO를 사용해야 하며, 나머지 두 매크로는 개별 코어를 선택/해제하는 데 사용된다. CPU_ISSET은 특정 프로세서가 집합에 포함되어 있는지 테스트한다. CPU_COUNT는 집합에서 선택된 코어 수를 반환한다. cpu_set_t 타입은 CPU 수 상한에 합리적인 기본값을 제공한다. 시간이 지나면 이것도 작아질 테고, 그때 타입이 조정될 것이다. 즉, 프로그램은 항상 크기를 염두에 두어야 한다. 위의 편의 매크로들은 cpu_set_t 정의에 따라 암묵적으로 크기를 처리한다. 보다 동적인 크기 처리가 필요하다면 확장된 매크로들을 사용해야 한다.
#define _GNU_SOURCE
#include <sched.h>
#define CPU_SET_S(cpu, setsize, cpusetp)
#define CPU_CLR_S(cpu, setsize, cpusetp)
#define CPU_ZERO_S(setsize, cpusetp)
#define CPU_ISSET_S(cpu, setsize, cpusetp)
#define CPU_COUNT_S(setsize, cpusetp)
이 인터페이스들은 크기 매개변수를 하나 더 받는다. 동적으로 크기가 정해지는 CPU 집합을 할당할 수 있도록 세 가지 매크로가 제공된다.
#define _GNU_SOURCE
#include <sched.h>
#define CPU_ALLOC_SIZE(count)
#define CPU_ALLOC(count)
#define CPU_FREE(cpuset)
CPU_ALLOC_SIZE 매크로는 count개의 CPU를 처리할 수 있는 cpu_set_t 구조체에 대해 할당해야 하는 바이트 수를 반환한다. 이런 블록을 할당하려면 CPU_ALLOC 매크로를 사용할 수 있다. 이렇게 할당한 메모리는 CPU_FREE로 해제해야 한다. 내부적으로 malloc/free를 사용할 가능성이 높지만 반드시 그래야 하는 것은 아니다.
마지막으로 CPU 집합 객체들에 대한 연산들이 정의되어 있다.
#define _GNU_SOURCE
#include <sched.h>
#define CPU_EQUAL(cpuset1, cpuset2)
#define CPU_AND(destset, cpuset1, cpuset2)
#define CPU_OR(destset, cpuset1, cpuset2)
#define CPU_XOR(destset, cpuset1, cpuset2)
#define CPU_EQUAL_S(setsize, cpuset1, cpuset2)
#define CPU_AND_S(setsize, destset, cpuset1, cpuset2)
#define CPU_OR_S(setsize, destset, cpuset1, cpuset2)
#define CPU_XOR_S(setsize, destset, cpuset1, cpuset2)
이 두 묶음의 네 매크로는 두 집합의 동일성 검사와, 집합에 대한 논리 AND, OR, XOR 연산을 수행한다. 이러한 연산은 libNUMA의 일부 함수(섹션 12 참고)를 사용할 때 유용하다.
프로세스는 sched_getcpu 인터페이스를 사용하여 현재 자신이 어느 프로세서에서 실행 중인지 확인할 수 있다.
#define _GNU_SOURCE
#include <sched.h>
int sched_getcpu(void);
결과는 CPU 집합에서의 CPU 인덱스다. 스케줄링의 특성상 이 숫자가 항상 100% 정확할 수는 없다. 결과가 반환된 시점과 스레드가 유저 레벨로 돌아가는 사이에 스레드가 다른 CPU로 옮겨졌을 수 있다. 프로그램은 항상 이 부정확 가능성을 고려해야 한다. 어쨌든 더 중요한 것은 스레드가 실행될 수 있는 CPU의 집합이다. 이 집합은 sched_getaffinity로 가져올 수 있다. 이 집합은 자식 스레드와 프로세스에 상속된다. 스레드는 이 집합이 생애 동안 안정적이라고 가정하면 안 된다. 외부에서 친화도 마스크가 설정될 수 있고(위 프로토타입의 pid 파라미터 참고), 리눅스는 CPU 핫플러깅을 지원하므로 CPU가 시스템에서 사라질 수 있으며, 이는 친화도 CPU 집합에서도 사라짐을 의미한다.
멀티스레드 프로그램에서는 개별 스레드가 POSIX에서 정의한 프로세스 ID를 공식적으로 갖고 있지 않으므로, 위의 두 함수를 사용할 수 없다. 대신 <pthread.h>는 네 가지 인터페이스를 선언한다.
#define _GNU_SOURCE
#include <pthread.h>
int pthread_setaffinity_np(pthread_t th, size_t size,
const cpu_set_t *cpuset);
int pthread_getaffinity_np(pthread_t th, size_t size, cpu_set_t *cpuset);
int pthread_attr_setaffinity_np(pthread_attr_t *at,
size_t size, const cpu_set_t *cpuset);
int pthread_attr_getaffinity_np(pthread_attr_t *at, size_t size,
cpu_set_t *cpuset);
처음 두 인터페이스는 이미 본 두 인터페이스와 기본적으로 동일하지만, 첫 번째 매개변수로 프로세스 ID 대신 스레드 핸들을 받는다. 이는 프로세스 내의 개별 스레드를 지정할 수 있게 한다. 또한 이 인터페이스들은 다른 프로세스에서 사용할 수 없고, 프로세스 내부에서만 사용된다는 뜻이다. 세 번째와 네 번째 인터페이스는 스레드 속성(thread attribute)을 사용한다. 이 속성들은 새 스레드를 생성할 때 사용된다. 속성을 설정하면 스레드는 시작 시점부터 특정 CPU 집합에서 스케줄될 수 있다. 스레드가 이미 시작된 후에 설정하는 대신 이처럼 더 이른 시점에 대상 프로세서를 선택하면 여러 수준에서(특히 메모리 할당; 섹션 6.5의 NUMA 참조) 이점이 있다.
NUMA 얘기가 나왔으니, 친화도 인터페이스는 NUMA 프로그래밍에서도 큰 역할을 한다. 곧 그 경우로 돌아오겠다.
지금까지는 두 스레드의 워킹 셋이 겹쳐 두 스레드를 같은 코어에서 실행하는 것이 합리적인 경우를 이야기했다. 반대의 경우도 있다. 두 스레드가 별도의 데이터 셋에서 작업한다면, 같은 코어에 스케줄되는 것은 문제가 될 수 있다. 두 스레드는 동일한 캐시를 놓고 경쟁하여 서로의 캐시 활용도를 떨어뜨린다. 둘째, 두 데이터 셋이 동일한 캐시에 로드되어야 하므로, 사실상 로드해야 할 데이터 양이 증가하고 그에 따라 가용 대역폭이 절반으로 줄어든다.
이 경우의 해결책은 두 스레드가 같은 코어에 스케줄되지 않도록 스레드의 친화도를 설정하는 것이다. 이는 앞선 상황과 정반대이므로, 변경을 가하기 전에 최적화하려는 상황을 정확히 이해하는 것이 중요하다.
대역폭 최적화를 위한 캐시 공유 최적화는 실제로 다음 절에서 다룰 NUMA 프로그래밍의 한 측면이다. “메모리”의 개념을 캐시로 확장하기만 하면 된다. 캐시 레벨이 늘어날수록 이는 점점 더 중요해질 것이다. 이러한 이유로 멀티코어 스케줄링에 대한 해결책은 NUMA 지원 라이브러리에 있다. 시스템 세부사항을 하드코딩하거나 /sys 파일시스템의 깊이를 파고들지 않고 친화도 마스크를 결정하는 방법은 섹션 12의 코드 샘플을 보라.
NUMA 프로그래밍에서도 지금까지 말한 캐시 최적화는 모두 적용된다. 차이는 그 아래 레벨에서 시작된다. NUMA는 주소 공간의 서로 다른 부분에 접근할 때 다른 비용을 도입한다. UMA(균일 메모리 접근)에서는 페이지 폴트를 최소화하도록 최적화할 수 있지만(섹션 7.5 참고) 그게 전부다. 모든 페이지는 동등하다.
NUMA는 이를 바꾼다. 접근 비용은 접근하는 페이지에 따라 달라질 수 있다. 접근 비용이 다르면 메모리 페이지 지역성 최적화의 중요성도 증가한다. NUMA는 대부분의 SMP 머신에서 불가피하다. 인텔은 CSI(x86, x86-64, IA-64)로, AMD는 Opteron으로 NUMA를 사용한다. 프로세서당 코어 수가 증가함에 따라 SMP 시스템 사용은 급격히 줄어들 가능성이 있다(적어도 데이터센터 밖과 CPU 사용량이 엄청 높은 사람들의 사무실을 제외하면). 대부분의 가정용 머신은 프로세서 하나로 충분할 것이며, 따라서 NUMA 이슈가 없다. 하지만 a) 그렇다고 프로그래머가 NUMA를 무시할 수 있는 것은 아니며, b) 관련 이슈가 없다는 뜻도 아니다.
NUMA에 대한 일반화를 생각해 보면, 개념이 프로세서 캐시에도 확장된다는 것을 금방 알 수 있다. 동일 캐시를 사용하는 코어의 두 스레드는 캐시를 공유하지 않는 코어의 스레드보다 더 빠르게 협업할 수 있다. 이는 꾸며낸 사례가 아니다.
캐시는 자체 계층을 이루며, 코어에서의 스레드 배치가 캐시를 공유(또는 비공유)하는 데 중요해진다. 이는 NUMA가 직면한 문제와 크게 다르지 않으므로 두 개념을 통합할 수 있다. SMP가 아닌 머신에만 관심이 있는 사람이라도 이 절을 읽어야 한다.
섹션 5.3에서 리눅스 커널이 NUMA 프로그래밍에 유용하고 필요한 많은 정보를 제공함을 보았다. 하지만 이 정보를 수집하는 것은 쉽지 않다. 현재 리눅스에서 사용 가능한 NUMA 라이브러리는 이러한 목적에 전혀 부적합하다. 필자가 더 적합한 버전을 만들고 있다.
기존 NUMA 라이브러리 libnuma(numactl 패키지의 일부)는 시스템 아키텍처 정보에 접근을 제공하지 않는다. 제공 가능한 시스템 콜을 감싸고 흔히 쓰는 작업에 대한 편의 인터페이스만 제공한다. 오늘날 리눅스에서 사용 가능한 시스템 콜은 다음과 같다.
이 인터페이스들은 libnuma 라이브러리와 함께 제공되는 <numaif.h>에 선언되어 있다. 더 자세히 들어가기 전에 메모리 정책 개념을 이해해야 한다.
6.5.1 메모리 정책
메모리 정책을 정의하는 아이디어는 기존 코드가 큰 수정 없이도 NUMA 환경에서 그럭저럭 잘 동작하게 하려는 것이다. 정책은 자식 프로세스에 상속되므로 numactl 도구를 사용할 수 있다. 이 도구는 프로그램을 지정한 정책으로 시작하는 등의 일을 할 수 있다.
리눅스 커널은 다음과 같은 정책을 지원한다.
이 목록은 정책을 재귀적으로 정의하는 듯 보인다. 절반은 맞다. 실제로 메모리 정책은 계층을 이룬다(그림 6.15 참조).
그림 6.15: 메모리 정책 계층
주소가 VMA 정책의 적용을 받으면 해당 정책이 사용된다. 공유 메모리 세그먼트에는 특별한 종류의 정책이 사용된다. 특정 주소에 정책이 없으면 태스크의 정책이 사용된다. 이것도 없으면 시스템의 기본 정책이 사용된다.
시스템 기본은 메모리를 요청하는 스레드에 로컬한 노드에서 메모리를 할당하는 것이다. 기본적으로 태스크 및 VMA 정책은 제공되지 않는다. 여러 스레드가 있는 프로세스의 경우 로컬 노드는 “홈” 노드, 즉 프로세스를 최초로 실행한 노드다. 위에서 언급한 시스템 콜로 다른 정책을 선택할 수 있다.
6.5.2 정책 지정
set_mempolicy 호출은 현재 스레드(커널 용어로 태스크)의 태스크 정책을 설정하는 데 사용한다. 전체 프로세스가 아니라 현재 스레드만 영향을 받는다.
#include <numaif.h>
long set_mempolicy(int mode,
unsigned long *nodemask,
unsigned long maxnode);
mode 파라미터는 이전 절에서 소개한 MPOL_* 상수 중 하나여야 한다. nodemask 파라미터는 사용할 메모리 노드를 지정하며, maxnode는 nodemask의 노드(즉 비트) 수다. MPOL_DEFAULT를 사용하면 nodemask는 무시된다. MPOL_PREFERRED에서 nodemask로 null 포인터를 전달하면 로컬 노드가 선택된다. 그 외에는 nodemask에서 비트가 켜진 가장 낮은 노드 번호를 사용한다.
정책을 설정해도 이미 할당된 메모리에는 아무 영향이 없다. 페이지는 자동으로 마이그레이션되지 않으며, 향후 할당만 영향을 받는다. 메모리 할당과 주소 공간 예약의 차이에 유의하라. mmap로 설정된 주소 공간 영역은 보통 자동으로 할당되지 않는다. 메모리 영역에 대한 첫 읽기/쓰기에서 적절한 페이지가 할당된다. 다른 페이지에 접근하는 사이에 정책이 바뀌거나, 정책이 다른 노드의 메모리 할당을 허용하면, 겉보기에는 균일한 주소 공간 영역이 많은 메모리 노드에 흩어질 수 있다.
6.5.3 스와핑과 정책
물리 메모리가 부족하면 시스템은 clean 페이지를 드롭하고 dirty 페이지를 스왑에 저장해야 한다. 리눅스의 스왑 구현은 페이지를 스왑에 기록할 때 노드 정보를 버린다. 즉 페이지가 재사용되어 페이징될 때, 사용될 노드는 처음부터 다시 선택된다. 스레드의 정책은 실행 중인 프로세서에 가까운 노드를 선택하게 만들 가능성이 있지만, 이전과 다른 노드일 수 있다.
이 연관이 바뀔 수 있다는 것은, 프로그램이 페이지의 속성으로 노드 연관을 저장할 수 없다는 뜻이다. 연관은 시간이 지나며 변할 수 있다. 다른 프로세스와 공유된 페이지의 경우에는, 어떤 프로세스의 요청(아래 mbind 논의 참고)으로도 이런 일이 발생할 수 있다. 커널은 한 노드의 공간이 끝나고 다른 노드에 여유 공간이 있을 때 페이지를 마이그레이션할 수도 있다.
따라서 유저 레벨 코드가 알게 되는 어떤 노드 연관도 짧은 시간만 사실일 수 있다. 절대적 정보라기보다 힌트에 가깝다. 정확한 지식이 필요할 때는 항상 get_mempolicy 인터페이스(섹션 6.5.5)를 사용해야 한다.
6.5.4 VMA 정책
주소 범위에 대한 VMA 정책을 설정하려면 다른 인터페이스를 사용해야 한다.
#include <numaif.h>
long mbind(void *start, unsigned long len,
int mode,
unsigned long *nodemask,
unsigned long maxnode,
unsigned flags);
이 인터페이스는 [start, start + len) 주소 범위에 대한 새로운 VMA 정책을 등록한다. 메모리 처리는 페이지 단위로 동작하므로, start 주소는 페이지 정렬되어야 한다. len 값은 다음 페이지 크기로 올림된다.
mode 파라미터는 다시 정책을 지정하며, 값은 섹션 6.5.1의 리스트에서 골라야 한다. set_mempolicy와 마찬가지로 nodemask는 일부 정책에서만 사용되며, 처리 방식은 동일하다.
mbind의 의미는 flags 파라미터의 값에 따라 달라진다. 기본적으로 flags가 0이면, 시스템 콜은 해당 주소 범위의 VMA 정책을 설정한다. 기존 매핑은 영향을 받지 않는다. 이것으로 충분하지 않다면 현재 세 가지 플래그가 이 동작을 수정한다. 개별 또는 조합하여 선택할 수 있다.
MPOL_MF_MOVE 및 MPOL_MF_MOVEALL 지원은 리눅스 2.6.16에서 추가되었음을 주의하라.
flags 없이 mbind를 호출하는 것은 새로 예약된 주소 범위에 대해, 어떠한 페이지도 실제로 할당되기 전에 정책을 지정해야 할 때 가장 유용하다.
void *p = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_ANON, -1, 0);
if (p != MAP_FAILED)
mbind(p, len, mode, nodemask, maxnode, 0);
이 코드 시퀀스는 길이 len의 주소 공간 범위를 예약하고, nodemask에 있는 메모리 노드를 참조하는 정책 mode를 지정한다. mmap에 MAP_POPULATE 플래그를 사용하지 않는 한, mbind 호출 시점에는 아무 메모리도 할당되지 않았으며, 따라서 새 정책은 해당 주소 공간 영역의 모든 페이지에 적용된다.
MPOL_MF_STRICT 플래그만 사용하면, mbind의 start와 len 파라미터가 기술하는 주소 범위에서 nodemask에 지정된 노드 이외에 할당된 페이지가 있는지 여부를 판별하는 데 사용할 수 있다. 할당된 페이지는 변경되지 않는다. 모든 페이지가 지정 노드에 할당되어 있으면, 해당 주소 공간 영역의 VMA 정책은 mode에 따라 변경된다.
때로는 메모리 재균형이 필요하며, 이 경우 특정 노드에 할당된 페이지를 다른 노드로 옮겨야 할 수도 있다. MPOL_MF_MOVE를 설정해 mbind를 호출하면 최선을 다해 이를 수행한다. 프로세스의 페이지 테이블 트리에 의해 독점적으로 참조되는 페이지들만 이동 대상이 된다. 스레드나 해당 페이지 테이블 트리의 일부를 공유하는 다른 프로세스가 다중 사용자로 존재할 수 있다. 동일 데이터를 매핑하는 다른 프로세스에는 영향을 주지 않는다. 이러한 페이지들은 페이지 테이블 엔트리를 공유하지 않는다.
MPOL_MF_STRICT과 MPOL_MF_MOVE를 함께 전달하면 커널은 지정한 노드에 할당되지 않은 모든 페이지를 옮기려 시도한다. 불가능하면 호출이 실패한다. 이는 모든 페이지를 수용할 수 있는 노드(또는 노드 집합)가 있는지 판단하는 데 유용할 수 있다. 적절한 노드를 찾을 때까지 여러 조합을 연속적으로 시도할 수 있다.
MPOL_MF_MOVEALL의 사용은 현재 프로세스 실행이 컴퓨터의 주목적일 때가 아니면 정당화하기 어렵다. 이유는, 여러 페이지 테이블에 나타나는 페이지까지 이동시키기 때문이다. 이는 다른 프로세스에 쉽게 부정적 영향을 줄 수 있다. 이 작업은 주의해서 사용해야 한다.
6.5.5 노드 정보 조회
get_mempolicy 인터페이스는 주어진 주소에 대한 NUMA 상태와 관련된 다양한 사실을 조회하는 데 사용된다.
#include <numaif.h>
long get_mempolicy(int *policy,
const unsigned long *nmask,
unsigned long maxnode,
void *addr, int flags);
flags에 플래그 없이 get_mempolicy를 호출하면, addr에 대한 정책 정보가 policy가 가리키는 워드와 nmask가 가리키는 노드 비트마스크에 저장된다. addr이 VMA 정책이 지정된 주소 공간 영역에 속하면 해당 정책에 대한 정보가 반환된다. 그렇지 않으면 태스크 정책 또는 필요 시 시스템 기본 정책에 대한 정보가 반환된다.
flags에 MPOL_F_NODE 플래그가 설정되어 있고 addr을 지배하는 정책이 MPOL_INTERLEAVE라면, policy가 가리키는 워드에 저장되는 값은 다음 할당이 일어날 노드의 인덱스다. 이 정보는 새로 할당된 메모리에서 작업할 스레드의 친화도를 설정하는 데 사용할 수 있다. 특히 스레드가 아직 생성되지 않았다면, 근접성을 달성하는 비용이 더 낮을 수 있다.
MPOL_F_ADDR 플래그를 사용하면 완전히 다른 데이터 항목을 가져올 수 있다. 이 플래그를 사용하면, policy가 가리키는 워드에 저장되는 값은 addr이 포함된 페이지의 메모리가 할당되어 있는 메모리 노드의 인덱스다. 이 정보는 가능한 페이지 마이그레이션에 대한 결정을 내리고, 어떤 스레드가 해당 메모리 위치에서 가장 효율적으로 작업할 수 있을지 결정하는 등 다양한 데 사용할 수 있다.
스레드가 사용하는 CPU—따라서 메모리 노드—는 그 스레드의 메모리 할당보다 훨씬 더 불안정하다. 명시적 요청이 없다면 메모리 페이지는 극단적 상황에서만 이동된다. 스레드는 CPU 부하 재균형의 결과로 다른 CPU에 할당될 수 있다. 현재 CPU 및 노드에 대한 정보는 따라서 수명이 짧다. 스케줄러는 차가운 캐시로 인한 성능 손실을 최소화하기 위해 스레드를 동일한 CPU, 가능하면 같은 코어에 유지하려고 한다. 따라서 현재 CPU 및 노드 정보를 확인하는 것은 유용하지만, 연관이 변하지 않을 것이라고 가정해서는 안 된다.
libNUMA는 주어진 가상 주소 공간 범위에 대한 노드 정보를 조회하는 두 가지 인터페이스를 제공한다.
#include <libNUMA.h>
int NUMA_mem_get_node_idx(void *addr);
int NUMA_mem_get_node_mask(void *addr,
size_t size,
size_t __destsize,
memnode_set_t *dest);
NUMA_mem_get_node_mask는 dest에, [addr, addr+size) 범위의 페이지들이(또는 그럴 예정인) 할당된 모든 메모리 노드의 비트를, 지배 정책에 따라 설정한다. NUMA_mem_get_node는 addr 하나만 보고, 이 주소가(또는 그럴 예정인) 할당된 메모리 노드의 인덱스를 반환한다. 이 인터페이스들은 get_mempolicy보다 사용이 간단하며 보통 선호된다.
스레드가 현재 사용 중인 CPU는 sched_getcpu(섹션 6.4.3 참고)로 조회할 수 있다. 이 정보를 사용해, 프로그램은 libNUMA의 NUMA_cpu_to_memnode 인터페이스를 통해 CPU에 로컬한 메모리 노드들을 결정할 수 있다.
#include <libNUMA.h>
int NUMA_cpu_to_memnode(size_t cpusetsize,
const cpu_set_t *cpuset,
size_t memnodesize,
memnode_set_t *
memnodeset);
이 함수를 호출하면 네 번째 파라미터가 가리키는 메모리 노드 집합에, 두 번째 파라미터가 가리키는 집합에 있는 임의의 CPU에 로컬한 모든 메모리 노드의 비트가 설정된다. CPU 정보 자체와 마찬가지로, 이 정보는 머신의 구성(예컨대 CPU 제거/추가)이 바뀔 때까지 유효하다.
memnode_set_t 객체에 설정된 비트는 get_mempolicy 같은 저수준 함수 호출에 사용할 수 있다. 하지만 libNUMA의 다른 함수를 사용하는 것이 더 편리하다. 역방향 매핑은 다음을 통해 사용할 수 있다.
#include <libNUMA.h>
int NUMA_memnode_to_cpu(size_t memnodesize,
const memnode_set_t *
memnodeset,
size_t cpusetsize,
cpu_set_t *cpuset);
결과 cpuset에 설정되는 비트는 memnodeset에서 비트가 설정된 임의의 메모리 노드에 로컬한 CPU들의 것이다. 두 인터페이스 모두에서, 정보는 시간이 지나며 변할 수 있음을(특히 CPU 핫플러깅) 프로그래머가 인지해야 한다. 많은 상황에서 입력 비트셋에는 한 개의 비트만 설정되지만, 예를 들어 NUMA_cpu_to_memnode에 sched_getaffinity 호출로 얻은 전체 CPU 집합을 전달해, 스레드가 직접 접근할 수 있는 메모리 노드들을 알아내는 것도 의미가 있다.
6.5.6 CPU 및 노드 집합
지금까지 본 인터페이스를 사용하도록 코드를 수정해 SMP 및 NUMA 환경에 맞추는 것은 비용이 너무 크거나(또는 소스가 없어) 불가능할 수 있다. 또한 시스템 관리자는 사용자 및/또는 프로세스가 사용할 수 있는 자원에 제한을 가하고 싶을 수 있다. 이런 상황을 위해 리눅스 커널은 이른바 CPU 집합(CPU sets)을 지원한다. 이름이 다소 오해의 소지가 있는데, 메모리 노드도 포함한다. 또한 cpu_set_t 데이터 타입과는 무관하다.
CPU 집합에 대한 인터페이스는 현재로서는 특별한 파일시스템이다. 보통은 마운트되어 있지 않다(지금까지는). 다음으로 바꿀 수 있다.
mount -t cpuset none /dev/cpuset
물론 /dev/cpuset 마운트 포인트는 존재해야 한다. 이 디렉터리의 내용은 기본(root) CPU 집합의 설명이다. 최초에는 모든 CPU와 모든 메모리 노드를 포함한다. 디렉터리의 cpus 파일은 CPU 집합에 있는 CPU를, mems 파일은 메모리 노드를, tasks 파일은 프로세스를 보여준다.
새 CPU 집합을 만들려면, 계층 어딘가에 새 디렉터리를 하나 만들면 된다. 새 CPU 집합은 부모로부터 모든 설정을 상속한다. 그런 다음 새 디렉터리의 cpus 및 mems 가상 파일에 값을 기록해 새 CPU 집합의 CPU와 메모리 노드를 변경할 수 있다.
프로세스가 CPU 집합에 속하면, CPU 및 메모리 노드 설정은 친화도 및 메모리 정책 비트마스크에 대한 마스크로 사용된다. 즉, 프로그램은 자신이 속한 CPU 집합의 cpus 파일에 없는 CPU를 친화도 마스크에 선택할 수 없다(즉 tasks 파일에 나열된 그 CPU 집합). 메모리 정책의 노드 마스크와 mems 파일도 마찬가지다.
마스킹 후 비트마스크가 비어 있지 않은 한, 프로그램은 어떠한 오류도 경험하지 않으므로 CPU 집합은 프로그램 실행을 제어하는 거의 보이지 않는 수단이다. 이 방법은 많은 CPU 및/또는 메모리 노드를 가진 대형 머신에서 특히 효율적이다. 프로세스를 새 CPU 집합으로 옮기는 것은 적절한 CPU 집합의 tasks 파일에 프로세스 ID를 쓰는 것만큼 쉽다.
CPU 집합 디렉터리에는 메모리 압박 시 동작, CPU 및 메모리 노드에 대한 배타적 접근 같은 세부 사항을 지정하는 다른 파일들이 있다. 관심 있는 독자는 커널 소스 트리의 Documentation/cpusets.txt 파일을 참조하라.
6.5.7 명시적 NUMA 최적화
모든 로컬 메모리 및 친화도 규칙도, 모든 노드의 모든 스레드가 동일한 메모리 영역에 접근해야 한다면 도움이 되지 않는다. 물론 해당 메모리 노드에 직접 연결된 프로세서가 지원하는 스레드 수로 스레드 수를 제한하는 것은 가능하다. 하지만 이는 SMP NUMA 머신의 장점을 살리지 못하므로 현실적인 선택이 아니다.
문제의 데이터가 읽기 전용이라면 간단한 해결책이 있다. 복제(replication)다. 각 노드는 자체 사본을 가져서 노드 간 접근이 필요 없게 만든다. 이를 위한 코드는 다음과 같을 수 있다.
void *local_data(void) {
static void *data[NNODES];
int node =
NUMA_memnode_self_current_idx();
if (node == -1)
/* Cannot get node, pick one. */
node = 0;
if (data[node] == NULL)
data[node] = allocate_data();
return data[node];
}
void worker(void) {
void *data = local_data();
for (...)
compute using data
}
이 코드에서 worker 함수는 local_data 호출로 로컬 데이터 사본의 포인터를 가져오는 준비를 한다. 그러고 나서 이 포인터를 사용하는 루프를 진행한다. local_data 함수는 이미 할당된 데이터 사본들의 목록을 유지한다. 각 시스템은 제한된 수의 메모리 노드를 가지므로, 노드별 메모리 사본 포인터를 담는 배열의 크기도 제한된다. 시스템의 노드 수는 libNUMA의 NUMA_memnode_system_count 함수가 반환한다. NUMA_memnode_self_current_idx로 결정된 현재 노드에 대한 포인터가 아직 알려지지 않으면 새 사본을 할당한다.
스레드가 sched_getcpu 시스템 콜 이후 다른 메모리 노드에 연결된 CPU로 스케줄되어도, 끔찍한 일은 일어나지 않는다는 점을 이해하는 것이 중요하다. 단지 worker에서 data 변수를 사용한 접근이 다른 메모리 노드의 메모리를 접근하게 된다는 뜻이다. data가 새로 계산될 때까지 프로그램이 느려지기는 하지만, 그게 전부다. 커널은 CPU별 런 큐의 불필요한 재균형을 항상 피하려고 한다. 그런 이동이 일어나면 보통은 타당한 이유가 있으며 당분간 다시 일어나지 않을 것이다.
문제의 메모리 영역이 쓰기 가능하면 상황은 더 복잡하다. 이 경우 단순 복제는 작동하지 않는다. 정확한 상황에 따라 가능한 해결책이 여러 가지 있을 수 있다.
예를 들어, 쓰기 가능한 메모리 영역이 결과를 누적하는 데 사용된다면, 먼저 각 메모리 노드에 결과를 누적할 별도 영역을 만들 수 있다. 그런 다음 작업이 끝나면 모든 노드별 메모리 영역을 결합해 전체 결과를 얻는다. 이 기법은 작업이 완전히 멈추지 않더라도 중간 결과가 필요하다면 작동할 수 있다. 이 접근의 요구 조건은 결과 누적이 상태가 없어야(stateless) 한다는 것이다. 즉, 이전에 수집한 결과에 의존하지 않아야 한다.
그래도 쓰기 가능한 메모리 영역에 직접 접근하는 것이 항상 더 낫다. 해당 메모리 영역에 대한 접근 횟수가 상당하다면, 커널이 문제의 메모리 페이지를 로컬 노드로 마이그레이션하도록 강제하는 것이 좋은 아이디어일 수 있다. 접근 횟수가 정말 많고, 다른 노드에서의 쓰기가 동시에 발생하지 않는다면 도움이 될 수 있다. 하지만 커널이 기적을 행할 수는 없다는 점을 명심하라. 페이지 마이그레이션은 복사 작업이며, 따라서 싸지 않다. 이 비용은 상쇄되어야 한다.
6.5.8 모든 대역폭 활용하기
그림 5.4의 숫자는 캐시가 비효율적일 때 원격 메모리 접근이 로컬 메모리 접근보다 측정 가능한 수준으로 느리지 않음을 보여준다. 이는 프로그램이 다시 읽을 필요가 없는 데이터를 다른 프로세서에 연결된 메모리에 기록함으로써 로컬 메모리에 대한 대역폭을 절약할 수 있음을 의미한다. DRAM 모듈로의 연결 대역폭과 인터커넥트의 대역폭은 대부분 독립적이므로, 병렬 사용으로 전체 성능을 개선할 수 있다.
이것이 실제로 가능한지는 여러 요인에 달려 있다. 캐시가 비효율적이라는 것을 정말 확실하게 알아야 한다. 그렇지 않으면 원격 접근과 관련된 느려짐이 측정 가능하다. 또 하나의 큰 문제는 원격 노드가 자신의 메모리 대역폭을 필요로 하는지 여부다. 이 가능성을 접근을 취하기 전에 자세히 검토해야 한다. 이론적으로 한 프로세서에 가용한 모든 대역폭을 사용하는 것은 긍정적인 효과를 가져올 수 있다. 패밀리 10h Opteron 프로세서는 최대 4개의 다른 프로세서에 직접 연결될 수 있다. 적절한 프리패치(특히 prefetchw)와 결합해 그 모든 추가 대역폭을 활용하면 시스템의 나머지가 뒷받침해 준다면 개선으로 이어질 수 있다.
| 이 기사에 대한 색인 항목 |
|---|
| GuestArticles |