리눅스에서 프로그램의 캐시·메모리 동작을 이해하고 성능을 최적화하기 위한 다양한 도구와 기법(하드웨어 성능 카운터, 시뮬레이션, 메모리 사용 측정, 분기 예측, 페이지 폴트 최적화, 대용량 페이지 활용 등)을 소개한다.
Did you know...? LWN.net은 구독자 후원으로 운영되는 매체입니다. LWN 전체 운영은 구독자 여러분의 지원에 달려 있습니다. 구독을 구매해 주시고 LWN이 계속 온라인에 머물 수 있도록 도와주세요.
[편집자 주: Ulrich Drepper의 “프로그래머라면 알아야 할 메모리 상식” 7부에 오신 것을 환영합니다. 이 절에서는 코드의 메모리 성능을 최적화하는 데 도움이 되는 도구들을 다룹니다. 이전 부분을 보지 못한 분들은 1부부터 보실 수 있습니다.]
프로그램의 캐시 및 메모리 사용을 이해하는 데 도움을 주는 도구는 매우 다양합니다. 현대 프로세서에는 활용 가능한 성능 모니터링 하드웨어가 들어 있습니다. 일부 이벤트는 정확히 측정하기 어렵기 때문에 시뮬레이션의 여지도 있습니다. 좀 더 고수준 기능을 위해서는 프로세스 실행을 모니터링하는 특수 도구도 있습니다. 이 글에서는 대부분의 리눅스 시스템에서 사용할 수 있는, 흔히 쓰이는 도구들을 소개합니다.
메모리 연산을 프로파일링하려면 하드웨어의 협력이 필요합니다. 순수 소프트웨어만으로도 어느 정도 정보를 수집할 수 있지만, 이는 대개 조밀하지 않거나 단순한 시뮬레이션에 그칩니다. 시뮬레이션의 예는 7.2절과 7.5절에서 보겠습니다. 여기서는 측정 가능한 메모리 효과에 집중합니다.
리눅스에서 성능 모니터링 하드웨어에 접근하는 수단은 oprofile입니다. oprofile은 [continuous]에서 처음 설명된 연속 프로파일링 기능을 제공합니다. 시스템 전반에 걸친 통계적 프로파일링을 쉬운 인터페이스로 수행합니다. oprofile이 프로세서의 성능 측정 기능을 사용하는 유일한 방법은 아닙니다. 리눅스 개발자들은 pfmon도 작업 중이며, 언젠가 충분히 널리 배포되면 이 글에서도 다룰 수 있을 것입니다.
oprofile이 제공하는 인터페이스는 단순하고 최소하지만(선택적 GUI를 쓰더라도) 꽤 저수준입니다. 사용자는 프로세서가 기록할 수 있는 이벤트들 가운데에서 선택해야 합니다. 프로세서 아키텍처 매뉴얼에는 이벤트가 설명되어 있지만, 수집된 데이터를 해석하려면 흔히 프로세서 자체에 대한 폭넓은 지식이 필요합니다. 또 다른 문제는 수집 데이터의 해석입니다. 성능 측정 카운터는 절대값이며 임의로 커질 수 있습니다. 특정 카운터가 얼마나 높으면 ‘너무 높은’ 걸까요?
이 문제에 대한 부분적 답은 절대값을 보지 말고, 대신 여러 카운터를 서로 연관 지어 보는 것입니다. 프로세서는 한 번에 하나 이상의 이벤트를 모니터링할 수 있으므로, 수집된 절대값의 비율을 살필 수 있습니다. 이렇게 하면 보기 좋고 비교 가능한 결과를 얻습니다. 종종 분모로 처리 시간을 나타내는 지표(클록 사이클 수나 명령어 수)를 사용합니다. 첫 감으로 프로그램 성능을 볼 때는 이 두 숫자만 서로 비교해 봐도 유용합니다.
그림 7.1: 명령어당 사이클 수(CPI, Follow Random)
그림 7.1은 단순한 랜덤 “Follow” 테스트 케이스에 대해 다양한 워킹셋 크기에서의 CPI(Cycles Per Instruction)를 보여줍니다. 대부분의 Intel 프로세서에서 이 정보를 모으는 이벤트 이름은 CPU_CLK_UNHALTED와 INST_RETIRED입니다. 이름에서 알 수 있듯 전자는 CPU의 클록 사이클을, 후자는 명령어 수를 셉니다. 앞서 리스트 원소당 사이클 측정에서 봤던 것과 유사한 그림을 볼 수 있습니다. 작은 워킹셋 크기에서는 비율이 1.0 혹은 그보다 낮습니다. 이 측정은 멀티스칼라 구조로 여러 명령어를 한꺼번에 처리할 수 있는 Intel Core 2 프로세서에서 수행했습니다. 메모리 대역폭에 제한받지 않는 프로그램이라면 이 비율은 1.0보다 상당히 낮을 수 있지만, 이 경우 1.0이면 꽤 좋은 편입니다.
L1d가 워킹셋을 담기에 충분하지 않게 되면 CPI는 3.0 바로 아래로 점프합니다. CPI 비율은 메모리 명령어뿐만 아니라 모든 명령어에 대해 L2 접근 패널티를 평균한다는 점에 유의하십시오. 리스트 원소 데이터에 대한 사이클 값을 사용하면 리스트 원소당 얼마나 많은 명령이 필요한지도 계산할 수 있습니다. L2 캐시마저 충분하지 않으면 CPI 비율은 20을 넘게 뛰어오릅니다. 예상 가능한 결과입니다.
하지만 성능 측정 카운터는 프로세서 내부에서 무슨 일이 일어나는지 더 많은 통찰을 제공해야 합니다. 이를 위해서는 프로세서 구현을 생각해 봐야 합니다. 이 문서는 캐시 처리 세부 사항에 관심이 있으므로 캐시 관련 이벤트를 살펴야 합니다. 이 이벤트들과 그 이름, 카운트 대상은 프로세서별로 다릅니다. 바로 여기서, 간단한 사용자 인터페이스에도 불구하고, oprofile을 쓰기 어려워집니다. 사용자 자신이 성능 카운터의 세부를 파악해야 하기 때문입니다. 10장에서 몇몇 프로세서에 대한 세부 사항을 보겠습니다.
Core 2 프로세서에서 볼 이벤트는 L1D_REPL, DTLB_MISSES, L2_LINES_IN입니다. 마지막 것은 모든 미스를 측정할 수도 있고, 하드웨어 프리페치가 아닌 명령으로 인한 미스만 측정할 수도 있습니다. 랜덤 “Follow” 테스트의 결과는 그림 7.2에서 볼 수 있습니다.
그림 7.2: 측정된 캐시 미스(Follow Random)
모든 비율은 리타이어된 명령어 수(INST_RETIRED)를 분모로 계산했습니다. 즉, 메모리를 건드리지 않는 명령어도 포함되므로, 실제로 메모리를 건드리고 캐시 미스를 겪는 명령어의 비율은 그래프에 나타난 것보다 더 높습니다.
L1d 미스가 모든 것 위로 치솟는데, 이는 Intel 프로세서에서 포함형(inclusive) 캐시를 쓰기 때문에 L2 미스는 L1d 미스를 내포하기 때문입니다. 프로세서의 L1d는 32k이고, 기대한 대로 워킹셋 크기가 그 정도에 이르면 L1d 미스율이 0에서 올라가기 시작합니다(리스트 자료구조 이외에도 캐시를 쓰는 부분이 있으므로 16k와 32k 사이에서 증가가 시작됩니다). 흥미로운 점은 하드웨어 프리페처가 64k까지(포함) 미스율을 1%로 유지할 수 있다는 것입니다. 그 이후에는 L1d 미스율이 급등합니다.
L2 미스율은 L2가 바닥나기 전까지 0을 유지합니다. L2의 다른 용도로 인한 소수의 미스는 숫자에 큰 영향을 주지 않습니다. L2의 크기(2^21 바이트)를 초과하면 미스율이 상승합니다. L2의 디맨드 미스율이 0이 아니라는 점이 중요합니다. 이는 하드웨어 프리페처가 이후 명령들이 필요로 하는 모든 캐시 라인을 불러오지 못한다는 뜻입니다. 예상 가능한 일로, 접근의 랜덤성이 완벽한 프리페치를 방해합니다. 그림 7.3의 순차 읽기 데이터를 비교해 보십시오.
그림 7.3: 측정된 캐시 미스(Follow Sequential)
이 그래프에서는 L2 디맨드 미스율이 사실상 0입니다(이 그래프의 스케일은 그림 7.2와 다릅니다). 순차 접근의 경우 하드웨어 프리페처가 완벽하게 동작합니다. 거의 모든 L2 캐시 미스가 프리페처에 의해 발생합니다. L1d와 L2 미스율이 동일하다는 사실은 모든 L1d 캐시 미스가 추가 지연 없이 L2 캐시에서 처리된다는 것을 보여줍니다. 이는 모든 프로그램에 대한 이상적인 경우지만, 물론 거의 달성되지 않습니다.
두 그래프의 네 번째 선은 DTLB 미스율입니다(Intel은 코드와 데이터에 대해 분리된 TLB를 가지며, DTLB는 데이터 TLB를 뜻합니다). 랜덤 접근의 경우 DTLB 미스율은 무시할 수 없으며 지연에 기여합니다. 흥미로운 점은 L2 미스가 나타나기 전에 DTLB 페널티가 발생하기 시작한다는 것입니다. 순차 접근의 경우 DTLB 비용은 사실상 0입니다.
6.2.1절의 행렬 곱셈 예제와 9.1절의 예제 코드를 다시 보면, 카운터를 세 가지 더 활용할 수 있습니다. SSE_PRE_MISS, SSE_PRE_EXEC, LOAD_HIT_PRE 카운터는 소프트웨어 프리페칭이 얼마나 효과적인지 파악하는 데 쓸 수 있습니다. 9.1절의 코드를 실행하면 다음과 같은 결과를 얻습니다:
설명 비율 유용한 NTA 프리페치 2.84% 늦은 NTA 프리페치 2.65%
유용한 NTA(non-temporal aligned) 프리페치 비율이 낮다는 것은, 이미 로드된 캐시 라인에 대해 많은 프리페치 명령이 실행되고 있음을 뜻합니다. 즉 할 일이 없습니다. 이는 프로세서가 프리페치 명령을 디코드하고 캐시를 조회하는 데 시간을 낭비한다는 의미입니다. 그렇다고 코드를 너무 가혹하게 평가할 수는 없습니다. 사용된 프로세서의 캐시 크기에 따라 달라지고, 하드웨어 프리페처도 역할을 합니다.
늦은 NTA 프리페치 비율이 낮다는 사실은 오해의 소지가 있습니다. 이 비율은 전체 프리페치 명령의 2.65%가 너무 늦게 발행되었다는 뜻입니다. 필요한 데이터를 요구하는 명령이, 데이터가 캐시에 프리페치되기 전에 실행되어 버렸습니다. 유념할 점은 유용한 프리페치가 전체의 2.84%+2.65%=5.5%에 불과하다는 것입니다. 유용한 NTA 프리페치 명령들 가운데 48%가 제시간에 완료되지 않았습니다. 따라서 코드는 더 최적화될 수 있습니다:
가용 하드웨어에 최적의 해법을 찾는 일은 독자에게 연습문제로 남겨 둡니다. 정확한 하드웨어 사양이 큰 역할을 합니다. Core 2 프로세서에서 SSE 산술 연산의 레이턴시는 1 사이클입니다. 더 오래된 버전은 2 사이클의 레이턴시를 가졌고, 이는 하드웨어 프리페처와 프리페치 명령이 데이터를 가져올 시간이 더 많았음을 의미합니다.
어디에서 프리페치가 필요하거나 불필요한지 판단하려면 opannotate 프로그램을 사용할 수 있습니다. 이 프로그램은 프로그램의 소스나 어셈블리 코드를 나열하고, 이벤트가 관측된 명령어를 표시합니다. 모호함의 원인은 두 가지가 있습니다:
주석이 달린 목록은 프리페칭 정보 파악을 넘어 다양한 용도로 유용합니다. 모든 이벤트는 명령 포인터와 함께 기록되므로, 프로그램의 다른 핫스팟을 정확히 짚어낼 수도 있습니다. INST_RETIRED 이벤트가 많이 보고되는 위치는 자주 실행되므로 튜닝할 가치가 있습니다. 캐시 미스가 많이 보고되는 위치는 프리페치 명령을 넣어 캐시 미스를 피할 여지가 있습니다.
하드웨어 지원 없이 측정할 수 있는 이벤트 한 가지는 페이지 폴트입니다. OS는 페이지 폴트 해결을 책임지고, 그 과정에서 개수를 셉니다. OS는 두 가지 종류의 페이지 폴트를 구분합니다:
명백히, 메이저 페이지 폴트는 마이너 페이지 폴트보다 훨씬 비쌉니다. 하지만 후자도 싸지 않습니다. 어느 경우든 커널 진입이 필요하고, 새 페이지를 찾아 적절한 데이터로 초기화(또는 클리어)한 뒤, 페이지 테이블 트리를 그에 맞게 수정해야 합니다. 마지막 단계에서는 페이지 테이블 트리를 읽거나 수정하는 다른 태스크들과 동기화가 필요하므로 추가 지연이 발생할 수 있습니다.
페이지 폴트 수에 대한 정보를 얻는 가장 쉬운 방법은 time 도구를 사용하는 것입니다. 참고: 셸 내장 명령이 아닌 실제 도구를 사용하십시오. 출력은 그림 7.4와 같습니다. {앞의 역슬래시는 셸 내장 명령을 사용하지 않도록 합니다.}
$ \time ls /etc [...] 0.00user 0.00system 0:00.02elapsed 17%CPU (0avgtext+0avgdata 0maxresident)k 0inputs+0outputs (1major+335minor)pagefaults 0swaps
그림 7.4: time 유틸리티의 출력
여기서 흥미로운 부분은 마지막 줄입니다. time 도구는 메이저 1개, 마이너 335개의 페이지 폴트를 보고합니다. 정확한 숫자는 달라질 수 있으며, 특히 바로 반복 실행하면 이번에는 메이저 페이지 폴트가 0개로 나올 가능성이 높습니다. 프로그램이 동일한 동작을 수행하고 환경이 변하지 않는다면, 전체 페이지 폴트 수는 안정적일 것입니다.
페이지 폴트에 특히 민감한 단계는 프로그램 시작입니다. 사용되는 각 페이지는 페이지 폴트를 발생시키며(특히 GUI 애플리케이션에서) 더 많은 페이지가 사용될수록 프로그램이 동작을 시작하기까지 더 오래 걸립니다. 7.5절에서 이 효과를 구체적으로 측정하는 도구를 보겠습니다.
내부적으로 time 도구는 rusage 기능을 사용합니다. 부모가 자식의 종료를 기다리는 동안(wait4 시스템 콜) 커널은 struct rusage 객체를 채워 주는데, time 도구에 딱 맞습니다. 하지만 프로세스는 자신의 리소스 사용량(이 기능의 이름 rusage의 유래)이나 종료된 자식들의 리소스 사용량을 요청할 수도 있습니다.
#include <sys/resource.h> int getrusage(__rusage_who_t who, struct rusage *usage)
who 매개변수는 정보를 요청할 프로세스를 지정합니다. 현재 RUSAGE_SELF와 RUSAGE_CHILDREN이 정의되어 있습니다. 자식 프로세스의 리소스 사용량은 각 자식이 종료될 때 누적됩니다. 이는 특정 자식의 사용량이 아닌 총량입니다. 스레드별 정보를 요청할 수 있게 하자는 제안이 있어, 가까운 미래에 RUSAGE_THREAD도 볼 수 있을 가능성이 큽니다. rusage 구조체에는 실행 시간, 전송된 IPC 메시지 수와 사용된 메모리, 페이지 폴트 수 등 여러 지표가 정의되어 있습니다. 마지막 정보는 구조체의 ru_minflt와 ru_majflt 멤버에 담깁니다.
프로그램이 페이지 폴트로 인해 성능을 잃는 지점을 찾고자 한다면, 주기적으로 정보를 요청하고 이전 결과와 비교하면 됩니다.
외부에서도, 필요한 권한이 있다면 정보를 볼 수 있습니다. /proc/<PID>/stat라는 의사 파일에는 관심 있는 프로세스의 페이지 폴트 숫자가 10번째부터 14번째 필드에 들어 있습니다. 이는 각각, 프로세스 자신과 그 자식들의 누적 마이너/메이저 페이지 폴트 쌍입니다.
캐시의 동작 방식에 대한 기술적 설명은 비교적 이해하기 쉽지만, 실제 프로그램이 캐시에 대해 어떻게 행동하는지는 보기 쉽지 않습니다. 프로그래머는 절대 주소든 상대 주소든 주소 값 자체에 직접 신경 쓰지 않습니다. 주소는 일부는 링커가, 일부는 런타임에 동적 링커와 커널이 결정합니다. 생성된 어셈블리 코드는 모든 가능한 주소에서 동작해야 하며, 소스 언어 수준에는 절대 주소에 대한 흔적조차 남지 않습니다. 그래서 프로그램이 메모리를 어떻게 사용하는지 감을 잡기 상당히 어렵습니다. {하드웨어에 밀착해 프로그래밍할 때는 다를 수 있지만, 일반적인 프로그래밍에서는 고려 대상이 아니고, 그마저도 메모리 맵 장치처럼 특수한 주소에 한정됩니다.}
oprofile(7.1절) 같은 CPU 수준의 프로파일링 도구는 캐시 사용을 이해하는 데 도움을 줄 수 있습니다. 결과 데이터는 실제 하드웨어에 대응하고, 세밀한 수집이 필요하지 않다면 비교적 빠르게 수집할 수 있습니다. 더 세밀한 데이터가 필요해지는 순간 oprofile은 더 이상 쓸 수 없습니다. 스레드를 너무 자주 중단해야 하기 때문입니다. 게다가 프로그램의 메모리 동작을 다른 프로세서에서 보고 싶다면 해당 머신을 실제로 확보해 프로그램을 실행해야 합니다. 이는 가끔(자주) 불가능합니다. 예를 들어 그림 3.8의 데이터를 oprofile로 수집하려면 24가지 서로 다른 머신이 필요한데, 그 중 상당수는 존재하지 않습니다.
그 그래프의 데이터는 캐시 시뮬레이터를 사용해 수집했습니다. 이 프로그램 cachegrind는 애초에 프로그램의 메모리 처리 관련 문제를 검사하기 위해 개발된 valgrind 프레임워크를 사용합니다. valgrind는 프로그램 실행을 시뮬레이션하며, 그 과정에서 cachegrind 같은 확장을 실행 프레임워크에 걸어(hook) 연결할 수 있습니다. cachegrind는 모든 메모리 주소 사용을 가로채고, 지정된 크기, 캐시 라인 크기, 연관도(associativity)를 가진 L1i, L1d, L2 캐시의 동작을 시뮬레이션합니다.
도구를 사용하려면 프로그램을 valgrind로 감싸 실행하면 됩니다:
valgrind --tool=cachegrind command arg
가장 단순한 형태로, 프로그램 command를 매개변수 arg와 함께 실행하면서, 실행 중인 프로세서의 캐시 크기와 연관도에 맞춘 세 가지 캐시를 시뮬레이션합니다. 출력의 일부는 프로그램 실행 시 표준 오류로 인쇄되며, 그림 7.5에서 볼 수 있듯 전체 캐시 사용 통계를 포함합니다.
==19645== I refs: 152,653,497 ==19645== I1 misses: 25,833 ==19645== L2i misses: 2,475 ==19645== I1 miss rate: 0.01% ==19645== L2i miss rate: 0.00% ==19645== ==19645== D refs: 56,857,129 (35,838,721 rd + 21,018,408 wr) ==19645== D1 misses: 14,187 ( 12,451 rd + 1,736 wr) ==19645== L2d misses: 7,701 ( 6,325 rd + 1,376 wr) ==19645== D1 miss rate: 0.0% ( 0.0% + 0.0% ) ==19645== L2d miss rate: 0.0% ( 0.0% + 0.0% ) ==19645== ==19645== L2 refs: 40,020 ( 38,284 rd + 1,736 wr) ==19645== L2 misses: 10,176 ( 8,800 rd + 1,376 wr) ==19645== L2 miss rate: 0.0% ( 0.0% + 0.0% )
그림 7.5: Cachegrind 요약 출력
총 명령어 수와 메모리 참조 수, 이들이 L1i/L1d 및 L2 캐시에 유발한 미스 수, 미스율 등이 제공됩니다. 이 도구는 L2 접근을 명령과 데이터 접근으로 분리해서 보여줄 수 있고, 모든 데이터 캐시 사용을 읽기와 쓰기로 나누어 보여줍니다.
시뮬레이션 캐시의 세부를 바꿔 결과를 비교하면 더 흥미롭습니다. —I1, —D1, —L2 매개변수를 사용하면, cachegrind에 프로세서의 캐시 구성을 무시하고 명령행에서 지정한 구성을 사용하도록 지시할 수 있습니다. 예를 들어:
valgrind --tool=cachegrind --L2=8388608,8,64 command arg
위 명령은 8MB L2 캐시, 8-웨이 세트 연관도, 64바이트 캐시 라인을 시뮬레이션합니다. —L2 옵션은 시뮬레이션할 프로그램 이름보다 앞에 와야 합니다.
cachegrind는 이뿐만이 아닙니다. 프로세스가 종료되기 전에 cachegrind는 cachegrind.out.XXXXX라는 파일을 쓰는데, XXXXX는 프로세스의 PID입니다. 이 파일에는 요약 정보와, 각 함수 및 소스 파일별 캐시 사용 상세 정보가 들어 있습니다. 데이터는 cg_annotate 프로그램으로 볼 수 있습니다.
이 프로그램이 출력하는 내용은 프로세스 종료 시 인쇄된 캐시 사용 요약과 함께, 프로그램의 각 함수에서의 캐시 라인 사용에 대한 상세 요약을 포함합니다. 함수별 데이터를 생성하려면 cg_annotate가 주소를 함수와 매칭할 수 있어야 합니다. 즉, 최선의 결과를 얻으려면 디버그 정보가 있어야 합니다. 그렇지 못한 경우 ELF 심볼 테이블이 약간 도움을 주지만, 내부 심볼은 동적 심볼 테이블에 나오지 않으므로 결과가 완전하지 않습니다. 그림 7.6은 그림 7.5와 같은 프로그램 실행에 대한 출력의 일부입니다.
Ir I1mr I2mr Dr D1mr D2mr Dw D1mw D2mw file:function
53,684,905 9 8 9,589,531 13 3 5,820,373 14 0 ???:_IO_file_xsputn@@GLIBC_2.2.5 36,925,729 6,267 114 11,205,241 74 18 7,123,370 22 0 ???:vfprintf 11,845,373 22 2 3,126,914 46 22 1,563,457 0 0 ???:__find_specmb 6,004,482 40 10 697,872 1,744 484 0 0 0 ???:strlen 5,008,448 3 2 1,450,093 370 118 0 0 0 ???:strcmp 3,316,589 24 4 757,523 0 0 540,952 0 0 ???:_IO_padn 2,825,541 3 3 290,222 5 1 216,403 0 0 ???:_itoa_word 2,628,466 9 6 730,059 0 0 358,215 0 0 ???:_IO_file_overflow@@GLIBC_2.2.5 2,504,211 4 4 762,151 2 0 598,833 3 0 ???:_IO_do_write@@GLIBC_2.2.5 2,296,142 32 7 616,490 88 0 321,848 0 0 dwarf_child.c:__libdw_find_attr 2,184,153 2,876 20 503,805 67 0 435,562 0 0 ???:__dcigettext 2,014,243 3 3 435,512 1 1 272,195 4 0 ???:_IO_file_write@@GLIBC_2.2.5 1,988,697 2,804 4 656,112 380 0 47,847 1 1 ???:getenv 1,973,463 27 6 597,768 15 0 420,805 0 0 dwarf_getattrs.c:dwarf_getattrs
그림 7.6: cg_annotate 출력
Ir, Dr, Dw 열은 캐시 미스가 아니라 총 캐시 사용을 보여줍니다. 미스는 그 다음 두 열에 나옵니다. 이 데이터는 가장 많은 캐시 미스를 유발하는 코드를 식별하는 데 사용할 수 있습니다. 보통은 L2 캐시 미스부터 집중해서 보고, 그다음 L1i/L1d 캐시 미스를 최적화해 나갑니다.
cg_annotate는 데이터를 더 자세히 제공할 수 있습니다. 소스 파일 이름을 주면, 소스 파일의 각 줄에 해당 줄에서 발생한 캐시 히트/미스 수를 주석(그래서 프로그램 이름이 annotate)으로 달아 줍니다. 이 정보로 프로그래머는 캐시 미스가 문제인 정확한 줄까지 파고들 수 있습니다. 다만 인터페이스는 다소 거칩니다. 이 글을 쓰는 시점에 cachegrind 데이터 파일과 소스 파일은 같은 디렉터리에 있어야 합니다.
이 시점에서 다시 강조합니다. cachegrind는 프로세서의 측정을 쓰지 않는 시뮬레이터입니다. 실제 프로세서의 캐시 구현은 꽤 다를 수 있습니다. cachegrind는 LRU(Least Recently Used) 방식을 모사하는데, 연관도가 큰 캐시에는 비용이 너무 클 수 있습니다. 또한 시뮬레이션은 컨텍스트 스위치와 시스템 콜을 고려하지 않는데, 이들은 L2의 큰 부분을 망가뜨릴 수 있고 L1i/L1d는 반드시 플러시됩니다. 그 결과 전체 캐시 미스 수는 실제보다 낮게 나옵니다. 그럼에도 cachegrind는 프로그램의 메모리 사용과 그 문제점을 배우기에 훌륭한 도구입니다.
프로그램이 얼마나 메모리를 할당하는지, 가능하면 어디에서 할당이 이뤄지는지 아는 것은 메모리 사용을 최적화하는 첫걸음입니다. 다행히, 프로그램을 다시 컴파일하거나 특별히 수정하지 않아도 되는 손쉬운 도구가 몇 가지 있습니다.
첫 번째 도구는 massif로, 컴파일러가 자동으로 생성할 수 있는 디버그 정보를 제거(strip)하지 않기만 하면 됩니다. 시간 경과에 따른 누적 메모리 사용 개요를 제공합니다. 그림 7.7은 생성된 출력의 예입니다.
그림 7.7: Massif 출력
cachegrind(7.2절)처럼, massif도 valgrind 인프라를 사용합니다. 다음과 같이 실행합니다.
valgrind --tool=massif command arg
여기서 command arg는 관찰할 프로그램과 그 인자입니다. 프로그램은 시뮬레이션되며, 메모리 할당 함수로의 모든 호출이 인식됩니다. 호출 지점은 타임스탬프와 함께 기록되며, 새 할당 크기는 전체 프로그램 합계와 특정 호출 지점 합계에 더해집니다. 메모리를 해제하는 함수 역시 마찬가지이며, 이때는 해제된 블록 크기를 해당 합계에서 빼게 됩니다. 이 정보로 프로그램의 생애 동안 메모리 사용을 시각화하는 그래프를 만들 수 있으며, 각 시점의 값은 할당 요청 위치별로 분할됩니다. 프로세스 종료 전에 massif는 두 개의 파일을 생성합니다: massif.XXXXX.txt와 massif.XXXXX.ps(둘 다 XXXXX는 PID). .txt 파일은 모든 호출 지점에 대한 메모리 사용 요약이고, .ps 파일이 그림 7.7에 보이는 것입니다.
Massif는 프로그램의 스택 사용량도 기록할 수 있는데, 애플리케이션의 전체 메모리 풋프린트를 파악하는 데 유용합니다. 하지만 항상 가능한 것은 아닙니다. 어떤 상황(일부 스레드 스택이나 sigaltstack 사용 등)에서는 valgrind 런타임이 스택의 한계를 알 수 없습니다. 이런 상황에서는 해당 스택들의 크기를 합계에 더하는 것이 큰 의미가 없습니다. 다른 상황에서도 의미가 없을 때가 있습니다. 프로그램이 이런 영향권에 있다면, massif를 —stacks=no 옵션을 추가해 시작해야 합니다. 이 옵션은 valgrind 옵션이므로 관찰할 프로그램 이름보다 앞에 와야 합니다.
일부 프로그램은 자체 메모리 할당 함수나, 시스템 할당 함수 주변의 래퍼 함수를 제공합니다. 첫 번째 경우 할당은 보통 놓치게 되고, 두 번째 경우 기록된 호출 지점은 래퍼 함수 안의 호출 주소만 기록되어 정보를 감춥니다. 이 때문에 추가 함수를 ‘할당 함수’ 목록에 넣을 수 있습니다. —alloc-fn=xmalloc 매개변수로 xmalloc 함수도 할당 함수임을 지정할 수 있는데, 이는 GNU 프로그램에서 흔합니다. xmalloc 호출은 기록되지만, xmalloc 내부에서 이뤄지는 실제 할당 호출은 기록되지 않습니다.
두 번째 도구 memusage는 GNU C 라이브러리의 일부입니다. 이는 massif의 단순화 버전(그러나 massif보다 훨씬 이전부터 존재)입니다. 힙(—m 옵션을 주면 mmap 등을 통한 호출 포함)의 전체 메모리 사용과, 선택적으로 스택만 기록합니다. 결과는 시간에 따른 전체 메모리 사용 그래프 또는, 대안으로, 할당 함수 호출 순서에 따라 선형적으로 그려 보여줄 수 있습니다. 그래프는 별도의 memusage 스크립트가 생성하며, valgrind와 마찬가지로 이 스크립트를 통해 애플리케이션을 시작해야 합니다:
memusage command arg
-p IMGFILE 옵션을 사용해 IMGFILE에 그래프를 생성하도록 지정해야 하며, 이 파일은 PNG가 됩니다. 데이터 수집 코드는 실제 프로그램 안에서 실행되며, valgrind처럼 시뮬레이션이 아닙니다. 따라서 memusage는 massif보다 훨씬 빠르고, massif가 유용하지 않은 상황에서도 쓸 수 있습니다. 전체 메모리 소비 외에, memusage는 할당 크기도 기록하고 프로그램 종료 시 사용된 할당 크기의 히스토그램을 표준 오류로 출력합니다.
직접 호출하기 어렵거나(혹은 비현실적)한 프로그램도 있습니다. 예를 들어 gcc의 컴파일러 단계는 gcc 드라이버 프로그램이 실행합니다. 이 경우 관찰할 프로그램의 이름을 -n NAME 매개변수로 memusage 스크립트에 제공해야 합니다. 관찰 대상 프로그램이 다른 프로그램들을 시작하는 경우에도 유용합니다. 프로그램 이름을 지정하지 않으면 시작된 모든 프로그램이 프로파일링됩니다.
massif와 memusage 두 프로그램 모두 추가 옵션이 있습니다. 더 많은 기능이 필요해진 프로그래머라면, 먼저 매뉴얼이나 도움말을 살펴 이미 구현된 추가 기능이 없는지 확인해야 합니다.
이제 메모리 할당 데이터를 어떻게 캡처하는지 알았으니, 메모리와 캐시 사용의 맥락에서 이 데이터를 어떻게 해석할지 논의해야 합니다. 효율적인 동적 메모리 할당의 주요 측면은 선형적 할당과 사용 영역의 조밀함(compactness)입니다. 이는 프리페칭 효율을 높이고 캐시 미스를 줄이려는 데서 비롯됩니다.
후처리를 위해 임의의 양의 데이터를 읽어들여야 하는 프로그램이 있다고 합시다. 리스트를 만들어 각 리스트 원소에 새 데이터 항목을 넣어도 됩니다. 이 할당 방법의 오버헤드는(단일 연결 리스트라면 포인터 하나) 최소일 수 있지만, 데이터를 사용할 때의 캐시 효과가 성능을 극적으로 떨어뜨릴 수 있습니다.
예를 들어 연속적으로 할당된 메모리가 실제로 메모리 상에서 연속 배치된다는 보장이 없습니다. 가능한 이유는 많습니다:
후처리를 위해 데이터를 미리 할당해야 한다면, 연결 리스트 접근법은 분명 좋지 않은 생각입니다. 리스트의 연속 원소가 메모리에서 연속 배치된다는 보장(혹은 가능성)도 없습니다. 연속 할당을 보장하려면 메모리를 작은 덩어리로 나눠 할당해서는 안 됩니다. 메모리 처리를 위한 또 다른 레이어가 필요하며, 이는 프로그래머가 쉽게 구현할 수 있습니다. 대안으로 GNU C 라이브러리에 있는 obstack 구현을 사용할 수 있습니다. 이 할당자는 시스템 할당자로부터 큰 블록을 요청하고, 그 안에서 임의로 크거나 작은 블록을 잘라 반환합니다. 이런 할당은 큰 블록이 바닥나기 전까지는 항상 순차적입니다. 요청된 할당 크기에 따라 다르지만, 보통은 드물게 바닥납니다. obstack은 완전한 메모리 할당자 대체재는 아닙니다. 객체 해제 능력이 제한적입니다. 자세한 내용은 GNU C 라이브러리 매뉴얼을 보십시오.
그렇다면 어떤 상황에서 obstack(또는 유사 기법)의 사용이 바람직한지 그래프에서 어떻게 알아볼 수 있을까요? 소스를 보지 않고는 변경 후보를 정확히 특정할 수 없지만, 그래프는 탐색의 출발점을 제공할 수 있습니다. 같은 위치에서 많은 할당이 이뤄진다면, 벌크 할당이 도움이 될 수 있다는 신호일 수 있습니다. 그림 7.7에서 0x4c0e7d5 주소의 할당이 그런 후보처럼 보입니다. 실행 800ms 즈음부터 1,800ms 즈음까지(맨 위의 녹색 영역 제외) 이 영역만 커지고 있습니다. 게다가 기울기가 완만한데, 이는 상대적으로 작은 할당이 아주 많음을 뜻합니다. 실제로 이 경우는 obstack이나 유사 기법을 쓸 후보입니다.
그래프가 보여 줄 또 다른 문제는 전체 할당 횟수가 많은 경우입니다. 이는 그래프가 시간에 선형으로 그려진 것이 아니라, 호출 횟수에 선형으로 그려진 경우(기본값은 memusage) 특히 보기 쉽습니다. 그 경우 그래프의 완만한 기울기는 작은 할당이 많음을 의미합니다. memusage는 할당이 어디에서 일어났는지는 알려주지 않지만, massif의 출력과 비교하면 알 수 있고, 프로그래머가 바로 알아볼 수도 있습니다. 작은 할당이 많다면 선형 메모리 사용을 달성하기 위해 통합해야 합니다.
하지만 이 후자 부류의 경우에 동등하게 중요한 측면이 하나 더 있습니다. 할당이 많다는 것은 관리 데이터의 오버헤드도 커진다는 뜻입니다. 이것만으로 큰 문제가 아닐 수도 있습니다. massif 그래프의 “heap-admin”이라는 빨간 영역이 이 오버헤드를 나타내며 꽤 작습니다. 하지만 malloc 구현에 따라 이 관리 데이터는 데이터 블록과 함께 같은 메모리에 할당됩니다. 현재 GNU C 라이브러리의 malloc 구현이 그렇습니다. 각 할당 블록은 적어도 2워드 헤더(32비트 플랫폼은 8바이트, 64비트는 16바이트)를 가집니다. 게다가 메모리 관리 방식(블록 크기를 특정 배수로 반올림)에 따라 블록 크기는 필요한 것보다 종종 조금 큽니다.
이는 프로그램이 사용하는 메모리에, 할당자만이 사용하는 관리용 메모리가 끼어든다는 뜻입니다. 다음과 같은 모습을 볼 수 있습니다:
각 블록은 메모리 워드 하나를 나타내며, 이 작은 메모리 영역에는 네 개의 할당 블록이 있습니다. 블록 헤더와 패딩으로 인한 오버헤드는 50%입니다. 헤더의 배치 때문에, 이는 자동으로 프로세서의 효과적 프리페치율이 최대 50% 낮아짐을 의미합니다. 블록을 순차적으로 처리한다면(프리페칭의 최대 활용을 위해), 프로세서는 애플리케이션이 읽거나 쓰지 않을 헤더와 패딩 워드까지 캐시에 읽어들이게 됩니다. 런타임만 헤더 워드를 사용하며, 런타임은 블록이 해제될 때만 관여합니다.
이제 구현을 바꿔 관리 데이터를 다른 곳에 두어야 한다고 주장할 수 있습니다. 일부 구현은 실제로 그렇게 하며, 좋은 아이디어일 수도 있습니다. 고려할 측면이 많으며, 보안도 그중 적지 않은 이유입니다. 미래에 변화가 있더라도, 패딩 문제는 사라지지 않습니다(헤더를 무시하면 예제에서 데이터의 16%). 프로그래머가 직접 할당을 제어할 때만 이를 피할 수 있습니다. 정렬 요구 사항이 개입하면 여전히 빈 공간이 생길 수 있지만, 이것 역시 프로그래머가 통제할 수 있는 부분입니다.
6.2.2절에서는 분기 예측과 블록 재배치를 통해 L1i 사용을 개선하는 두 가지 방법을 언급했습니다. 정적 예측(__builtin_expect)과 프로파일 기반 최적화(PGO)입니다. 올바른 분기 예측은 성능에 영향을 주지만, 여기서는 메모리 사용 개선에 관심이 있습니다.
__builtin_expect(혹은 더 나은 매크로 likely와 unlikely)의 사용은 간단합니다. 정의를 중앙 헤더에 두면 컴파일러가 나머지를 처리합니다. 그런데 작은 문제가 있습니다. 프로그래머가 실제로는 unlikely여야 할 곳에 likely를 쓰거나 그 반대를 쓰는 일이 의외로 쉽습니다. 누군가 oprofile 같은 도구로 잘못된 분기 예측과 L1i 미스를 측정하더라도 이런 문제는 잡아내기 어렵습니다.
하지만 쉬운 방법이 하나 있습니다. 9.2절의 코드는 likely/unlikely 매크로의 대안 정의를 보여 주는데, 런타임에 정적 예측이 옳았는지를 적극적으로 측정합니다. 결과를 프로그래머나 테스터가 검토해 조정할 수 있습니다. 이 측정은 프로그램의 실제 성능을 고려하지 않고, 단지 프로그래머가 한 정적 가정을 검증합니다. 더 자세한 내용과 코드는 위에서 참조한 절에서 볼 수 있습니다.
요즘 gcc에서 PGO를 사용하는 일은 꽤 쉽습니다. 다만 세 단계 과정이고, 몇 가지 요구 사항을 충족해야 합니다. 먼저 모든 소스 파일을 -fprofile-generate 옵션을 추가해 컴파일해야 합니다. 이 옵션은 모든 컴파일과 링크 명령에 전달되어야 합니다. 이 옵션이 없는 오브젝트 파일과 섞을 수는 있지만, PGO는 해당 파일에는 아무 효과가 없습니다.
컴파일러는 정상처럼 동작하되 훨씬 크고 느린 바이너리를 생성합니다. 분기가 어떻게 수행됐는지에 관한 온갖 정보를 기록(그리고 내보내기)하기 때문입니다. 컴파일러는 각 입력 파일에 .gcno 확장자의 파일도 생성합니다. 이 파일은 코드의 분기 관련 정보를 담습니다. 나중을 위해 보존해야 합니다.
프로그램 바이너리가 준비되면 대표적인 작업 부하로 실행해야 합니다. 어떤 작업 부하를 사용하든, 최종 바이너리는 그 작업을 잘하도록 최적화될 것입니다. 프로그램을 여러 번 실행해도 되고, 일반적으로 필요합니다. 모든 실행이 동일한 출력 파일에 기여합니다. 프로그램이 종료되기 전에, 실행 동안 수집된 데이터가 .gcda 확장자 파일에 기록됩니다. 이 파일은 소스 파일이 있는 디렉터리에 생성됩니다. 프로그램은 어떤 디렉터리에서 실행해도 되고, 바이너리를 복사해도 되지만, 소스가 있는 디렉터리는 접근 가능하고 쓰기 가능해야 합니다. 역시 각 입력 소스 파일에 대해 출력 파일이 하나씩 생성됩니다. 프로그램을 여러 번 실행한다면, 이전 실행의 .gcda 파일이 소스 디렉토리에 있어야 실행들의 데이터가 한 파일로 누적될 수 있습니다.
대표적인 테스트 세트를 실행했다면, 이제 애플리케이션을 재컴파일할 차례입니다. 컴파일러는 소스 파일이 있는 같은 디렉터리에서 .gcda 파일을 찾을 수 있어야 합니다. 파일을 옮기면 컴파일러가 찾지 못할 수 있고, 파일에 포함된 체크섬이 더는 맞지 않게 됩니다. 재컴파일에서는 -fprofile-generate를 -fprofile-use로 바꾸십시오. 소스가 생성 코드를 바꿀 방식으로는 변경되지 않는 것이 중요합니다. 즉 공백 변경이나 주석 편집은 괜찮지만, 분기나 기본 블록을 추가하면 수집 데이터가 무효화되어 컴파일이 실패합니다.
프로그래머가 해야 할 일은 이게 전부입니다. 꽤 단순한 과정입니다. 가장 중요한 것은 측정을 수행할 대표 테스트를 제대로 고르는 것입니다. 테스트 작업 부하가 프로그램의 실제 사용 방식과 맞지 않으면, 수행된 최적화가 오히려 해가 될 수 있습니다. 이런 이유로 라이브러리에 PGO를 적용하는 일은 종종 어렵습니다. 라이브러리는 매우—때로는 크게—다른 시나리오에서 사용될 수 있습니다. 사용 사례가 정말 비슷하지 않다면, 보통은 __builtin_expect을 통한 정적 분기 예측만에 의존하는 편이 낫습니다.
.gcno와 .gcda 파일에 대해 몇 마디 더 하자면, 이들은 바로 검사할 수 있는 바이너리 파일이 아닙니다. 하지만 gcc 패키지의 일부인 gcov 도구를 사용해 검사할 수 있습니다. 이 도구는 주로 커버리지 분석(이름이 여기서 옴)에 쓰이지만, 파일 포맷은 PGO와 같습니다. gcov는 실행된 코드가 있는 각 소스 파일(시스템 헤더 포함 가능)에 대해 .gcov 확장자 파일을 생성합니다. 이 파일은 주어진 매개변수에 따라 분기 카운터, 확률 등으로 주석이 달린 소스 목록입니다.
리눅스 같은 온디맨드 페이징 운영체제에서, mmap 호출은 페이지 테이블만 수정합니다. 파일로 뒷받침되는 페이지의 경우 기저 데이터를 찾을 수 있도록 하고, 익명 메모리의 경우 접근 시 0으로 초기화된 페이지가 제공되도록 합니다. mmap 호출 시점에는 실제 메모리 할당이 일어나지 않습니다. {“아니야!”라고 말하고 싶다면 잠시만요. 곧 예외가 있음을 보완 설명합니다.}
할당은 메모리 페이지가 처음 접근될 때(읽기/쓰기 또는 코드 실행) 발생합니다. 뒤따르는 페이지 폴트에 대응해 커널이 제어를 가져오고, 페이지 테이블 트리를 사용해 페이지에 있어야 할 데이터를 결정합니다. 페이지 폴트 해결은 싸지 않지만, 프로세스가 사용하는 모든 페이지마다 발생합니다.
페이지 폴트 비용을 최소화하려면, 사용되는 전체 페이지 수를 줄여야 합니다. 코드 크기를 줄이는 것은 도움이 됩니다. 특정 코드 경로(예: 시작 코드)의 비용을 줄이려면, 그 코드 경로에서 접근되는 페이지 수가 최소화되도록 코드를 재배치하는 것도 가능합니다. 그러나 올바른 순서를 정하는 일은 쉽지 않습니다.
저자는 valgrind 도구셋을 기반으로 한, 페이지 폴트가 발생하는 이유를 실시간으로 측정하는 도구를 작성했습니다. 폴트 수가 아니라, 왜 발생했는지를 측정합니다. pagein 도구는 페이지 폴트의 순서와 타이밍에 대한 정보를 출력합니다. pagein.<PID>라는 파일로 쓰이며, 그림 7.8과 같습니다.
0 0x3000000000 C 0 0x3000000B50: (within /lib64/ld-2.5.so) 1 0x 7FF000000 D 3320 0x3000000B53: (within /lib64/ld-2.5.so) 2 0x3000001000 C 58270 0x3000001080: _dl_start (in /lib64/ld-2.5.so) 3 0x3000219000 D 128020 0x30000010AE: _dl_start (in /lib64/ld-2.5.so) 4 0x300021A000 D 132170 0x30000010B5: _dl_start (in /lib64/ld-2.5.so) 5 0x3000008000 C 10489930 0x3000008B20: _dl_setup_hash (in /lib64/ld-2.5.so) 6 0x3000012000 C 13880830 0x3000012CC0: _dl_sysdep_start (in /lib64/ld-2.5.so) 7 0x3000013000 C 18091130 0x3000013440: brk (in /lib64/ld-2.5.so) 8 0x3000014000 C 19123850 0x3000014020: strlen (in /lib64/ld-2.5.so) 9 0x3000002000 C 23772480 0x3000002450: dl_main (in /lib64/ld-2.5.so)
그림 7.8: pagein 도구의 출력
두 번째 열은 페이지 인된 페이지의 주소를 나타냅니다. 코드 페이지인지 데이터 페이지인지는 세 번째 열의 C(코드)나 D(데이터)로 표시됩니다. 네 번째 열은 첫 페이지 폴트 이후 경과한 사이클 수를 나타냅니다. 줄의 나머지는 해당 페이지 폴트를 야기한 주소에 대해 valgrind가 이름을 찾으려 한 결과입니다. 주소 값 자체는 정확하지만, 디버그 정보가 없으면 이름은 항상 정확하지 않을 수 있습니다.
그림 7.8의 예에서 실행은 0x3000000B50 주소에서 시작하며, 이로 인해 0x3000000000 주소의 페이지가 페이지 인됩니다. 그 직후 바로 다음 페이지도 들어오며, 그 페이지에서 호출된 함수는 _dl_start입니다. 초기 코드는 0x7FF000000 페이지의 변수를 접근합니다. 이는 첫 페이지 폴트로부터 3,320 사이클 후에 발생하며, 프로그램의 두 번째 명령(첫 명령의 바로 셋째 바이트)일 가능성이 큽니다. 프로그램을 보면 이 메모리 접근에는 특이한 점이 있습니다. 문제의 명령은 call이고, 이는 명시적으로 데이터를 로드하거나 저장하지 않습니다. 하지만 복귀 주소를 스택에 저장합니다. 바로 여기서 그렇게 일어난 것입니다. 하지만 이는 프로세스의 공식 스택이 아니라, valgrind가 애플리케이션을 위해 내부적으로 사용하는 스택입니다. 따라서 pagein 결과를 해석할 때는 valgrind가 일부 인공물을 도입한다는 점을 염두에 둬야 합니다.
pagein의 출력은 어떤 코드 시퀀스들이 프로그램 코드에서 이상적으로 인접해야 하는지 판단하는 데 사용할 수 있습니다. /lib64/ld-2.5.so 코드를 빠르게 보면, 첫 명령들이 바로 _dl_start를 호출하며 이 둘이 서로 다른 페이지에 있음을 알 수 있습니다. 코드 시퀀스를 같은 페이지로 옮기도록 재배치하면 페이지 폴트를 피하거나 적어도 지연시킬 수 있습니다. 최적의 코드 배치를 결정하는 과정은 아직까지 번거롭습니다. 설계상, 페이지의 두 번째 사용은 기록되지 않으므로, 변경의 효과를 보려면 시행착오가 필요합니다. 호출 그래프 분석을 사용하면 가능한 호출 시퀀스를 추정할 수 있어, 함수와 변수를 정렬하는 과정을 빠르게 할 수 있습니다.
아주 거친 수준에서는, 실행 파일이나 DSO를 구성하는 오브젝트 파일을 보면 호출 시퀀스를 알 수 있습니다. 하나 이상의 진입점(함수 이름)에서 시작해 의존 관계 사슬을 계산할 수 있습니다. 큰 노력 없이 오브젝트 파일 수준에서 잘 동작합니다. 각 라운드에서 필요한 함수와 변수를 포함하는 오브젝트 파일을 결정합니다. 시드 집합은 명시적으로 지정해야 합니다. 그런 다음 이 오브젝트 파일들의 미해결 심볼을 모두 찾아 필요한 심볼 집합에 추가합니다. 집합이 안정화될 때까지 반복합니다.
두 번째 단계는 순서를 정하는 것입니다. 가능한 한 적은 페이지를 채우도록 여러 오브젝트 파일을 묶어야 합니다. 보너스로, 어떤 함수도 페이지 경계를 넘지 않도록 해야 합니다. 이를 최적으로 배치하려면 링커가 훗날 무엇을 할지 알아야 한다는 점이 복잡함입니다. 중요한 사실은 링커가 입력 파일(예: 아카이브)과 명령행에 나타난 순서 그대로 오브젝트 파일을 실행 파일이나 DSO에 배치한다는 것입니다. 이는 프로그래머에게 충분한 제어권을 줍니다.
좀 더 시간을 들일 의향이 있다면, gcc를 -finstrument-functions 옵션으로 빌드할 때 삽입되는 __cyg_profile_func_enter 및 __cyg_profile_func_exit 훅을 이용해 자동 호출 추적을 통해 재배치를 시도한 성공 사례가 있습니다[oooreorder]. 이 __cyg_* 인터페이스에 대해서는 gcc 매뉴얼을 참조하십시오. 프로그램 실행의 트레이스를 생성함으로써, 호출 체인을 더 정확히 파악할 수 있습니다. [oooreorder]의 결과는 단지 함수 재배치만으로 시작 비용이 5% 감소했습니다. 주된 이점은 페이지 폴트의 감소이지만, TLB 캐시도 역할을 합니다. 특히 가상화된 환경에서는 TLB 미스 비용이 크게 증가하므로 점점 더 중요해집니다.
pagein 도구의 분석과 호출 시퀀스 정보를 결합하면, 시작 같은 특정 프로그램 단계에서 페이지 폴트의 수를 최소화하도록 최적화할 수 있을 것입니다.
리눅스 커널은 페이지 폴트를 피하기 위한 두 가지 추가 메커니즘을 제공합니다. 첫 번째는 mmap의 플래그로, 커널에게 페이지 테이블만 수정하는 것이 아니라 매핑된 영역의 모든 페이지를 미리 폴트(pre-fault)하라고 지시합니다. 이는 mmap 호출의 네 번째 매개변수에 MAP_POPULATE 플래그를 추가하면 됩니다. 이로 인해 mmap 호출은 상당히 비싸지지만, 호출로 매핑된 모든 페이지를 곧바로 사용할 경우 그 이점은 큽니다. 여러 번의 페이지 폴트 대신, 동기화 요구 등으로 인해 하나하나가 꽤 비싼데, 더 비싼 mmap 호출 한 번으로 대체됩니다. 그러나 매핑된 페이지의 큰 부분이 곧바로(혹은 전혀) 사용되지 않는다면 이 플래그 사용은 단점이 됩니다. 매핑되고 사용되지 않는 페이지는 시간과 메모리의 분명한 낭비입니다. 즉시 미리 폴트되었지만 훨씬 나중에 사용되는 페이지는 시스템을 막힐 수도 있습니다. 메모리가 사용되기 전에 할당되어 그 사이 메모리 부족으로 이어질 수 있습니다. 한편 최악의 경우 페이지가 새 용도로 재사용될 수 있습니다(아직 수정되지 않았기 때문). 이는 그리 비싸지는 않지만, 할당과 더불어 어느 정도 비용을 추가합니다.
MAP_POPULATE의 단위는 너무 큽니다. 또 다른 문제 가능성도 있습니다. 이는 최적화일 뿐이므로, 모든 페이지가 실제로 매핑되는 것이 필수는 아닙니다. 시스템이 너무 바빠 작업을 수행할 수 없다면 미리 폴트는 건너뛰어질 수 있습니다. 나중에 페이지가 실제로 사용되면 프로그램은 페이지 폴트를 겪지만, 인위적으로 리소스 부족을 만들기보다 나을 것입니다. 대안은 posix_madvise 함수의 POSIX_MADV_WILLNEED 조언(advice)을 사용하는 것입니다. 이는 가까운 미래에 해당 페이지가 필요해질 것이라는 힌트를 운영체제에 주는 것입니다. 커널은 조언을 무시할 수도 있지만, 미리 폴트를 수행할 수도 있습니다. 이점은 단위가 더 미세하다는 것입니다. 매핑된 어느 주소 공간 영역이든 개별 페이지나 페이지 범위를 미리 폴트할 수 있습니다. 런타임에 사용되지 않는 데이터가 많은 파일 매핑의 경우, 이는 MAP_POPULATE를 사용하는 것보다 큰 이점을 가질 수 있습니다.
이런 적극적 접근 외에도, 하드웨어 설계자들이 선호하는 보다 수동적 접근도 있습니다. DSO는 주소 공간에서 인접한 페이지들을 차지하는데, 코드와 데이터 각각에 대해 한 범위를 가집니다. 페이지 크기가 작을수록 DSO를 담기 위해 필요한 페이지 수가 늘고, 결과적으로 페이지 폴트 수도 늘어납니다. 중요한 점은 그 반대도 참이라는 것입니다. 페이지 크기가 커지면 매핑(또는 익명 메모리)에 필요한 페이지 수가 줄고, 그에 따라 페이지 폴트 수도 줄어듭니다.
대부분의 아키텍처는 4k 페이지 크기를 지원합니다. IA-64와 PPC64에서는 64k 페이지 크기도 흔합니다. 이는 메모리가 최소 64k 단위로만 할당됨을 뜻합니다. 이 값은 커널 컴파일 시 지정하며(최소 현재로서는) 동적으로 바꿀 수 없습니다. 다중 페이지 크기 아키텍처의 ABI는 어느 페이지 크기에서든 애플리케이션이 실행될 수 있도록 설계되어 있습니다. 런타임이 필요한 조정을 수행하며, 올바르게 작성된 프로그램은 아무 것도 알아차리지 못합니다. 페이지 크기가 크면 부분적으로 사용된 페이지로 인한 낭비가 증가하지만, 상황에 따라 괜찮을 수 있습니다.
대부분의 아키텍처는 1MB 이상의 매우 큰 페이지 크기도 지원합니다. 이러한 페이지는 어떤 상황에서 유용하지만, 모든 메모리를 그렇게 큰 단위로 나눠주는 것은 말이 되지 않습니다. 물리적 RAM 낭비가 너무 큽니다. 하지만 매우 큰 페이지는 장점이 있습니다. 거대한 데이터 집합을 사용할 때 x86-64에서 2MB 페이지로 저장하면, 같은 양의 메모리를 4k 페이지로 사용할 때보다(큰 페이지마다) 511개의 페이지 폴트를 덜 겪게 됩니다. 이는 큰 차이를 만듭니다. 해결책은 선택적으로, 특정 주소 범위에 대해서만 큰 페이지를 사용해 메모리 할당을 요청하고, 같은 프로세스의 다른 매핑에는 일반 페이지 크기를 사용하는 것입니다.
하지만 큰 페이지에는 대가가 따릅니다. 큰 페이지에 사용되는 물리 메모리는 연속적이어야 하므로, 시간이 지나면서 메모리 단편화 때문에 이런 페이지를 할당할 수 없게 될 수 있습니다. 이를 막기 위해 사람들은 메모리 디프래그 및 단편화 회피를 연구하고 있지만 매우 복잡합니다. 2MB 같은 큰 페이지의 경우, 필요한 512개의 연속 페이지는 시스템이 부팅할 때를 제외하고는 언제나 구하기 어렵습니다. 그래서 현재 큰 페이지 솔루션은 특수 파일 시스템 hugetlbfs를 사용하도록 요구합니다. 이 의사 파일 시스템은 시스템 관리자가 예약하고자 하는 큰 페이지 수를 다음에 기록하는 방식으로 요청 시 할당됩니다.
/proc/sys/vm/nr_hugepages
이 작업은 연속적인 메모리를 충분히 찾지 못하면 실패할 수 있습니다. 가상화를 사용하는 경우 상황은 특히 흥미로워집니다. VMM 모델로 가상화된 시스템은 물리 메모리에 직접 접근하지 않으므로, 자체적으로 hugetlbfs를 할당할 수 없습니다. VMM에 의존해야 하며, 이 기능이 지원된다는 보장도 없습니다. KVM 모델의 경우, KVM 모듈을 로드한 리눅스 커널이 hugetlbfs 할당을 수행하고, 그중 일부를 게스트 도메인에 전달할 수 있습니다.
나중에 프로그램이 큰 페이지를 필요로 하면 다음과 같은 방법이 있습니다:
첫 번째 경우 hugetlbfs를 마운트할 필요가 없습니다. 큰 페이지 하나 이상을 요청하는 코드는 다음과 같을 수 있습니다:
key_t k = ftok("/some/key/file", 42); int id = shmget(k, LENGTH, SHM_HUGETLB|IPC_CREAT|SHM_R|SHM_W); void *a = shmat(id, NULL, 0);
이 코드에서 중요한 부분은 SHM_HUGETLB 플래그의 사용과 LENGTH의 올바른 값 선택입니다. LENGTH는 시스템의 큰 페이지 크기의 배수여야 합니다. 아키텍처마다 값이 다릅니다. System V 공유 메모리 인터페이스는 키 인자를 통해 매핑을 구분(또는 공유)하는 데 의존한다는 불쾌한 문제가 있습니다. ftok 인터페이스는 충돌을 일으키기 쉬우므로, 가능하다면 다른 메커니즘을 사용하는 것이 더 낫습니다.
hugetlbfs 파일 시스템을 마운트하는 요구사항이 문제가 아니라면, System V 공유 메모리 대신 이를 사용하는 것이 낫습니다. 특수 파일 시스템을 사용하기 위한 실제 문제는 커널이 이를 지원해야 한다는 점과, 아직 표준화된 마운트 지점이 없다는 점뿐입니다. 파일 시스템이 /dev/hugetlb 같은 곳에 마운트되면, 프로그램은 다음처럼 쉽게 사용할 수 있습니다:
int fd = open("/dev/hugetlb/file1", O_RDWR|O_CREAT, 0700); void *a = mmap(NULL, LENGTH, PROT_READ|PROT_WRITE, fd, 0);
open 호출에서 같은 파일 이름을 사용하면 여러 프로세스가 같은 큰 페이지를 공유해 협업할 수 있습니다. 페이지를 실행 가능하게 만드는 것도 가능하며, 그 경우 mmap 호출에서 PROT_EXEC 플래그도 설정해야 합니다. System V 공유 메모리 예제와 마찬가지로, LENGTH의 값은 시스템의 큰 페이지 크기의 배수여야 합니다.
방어적으로 작성된 프로그램(모든 프로그램이 그래야 합니다)은 다음과 같은 함수를 사용해 런타임에 마운트 지점을 알아낼 수 있습니다:
char *hugetlbfs_mntpoint(void) { char *result = NULL; FILE *fp = setmntent(_PATH_MOUNTED, "r"); if (fp != NULL) { struct mntent *m; while ((m = getmntent(fp)) != NULL) if (strcmp(m->mnt_fsname, "hugetlbfs") == 0) { result = strdup(m->mnt_dir); break; } endmntent(fp); } return result; }
두 경우 모두에 대한 자세한 정보는 커널 소스 트리의 hugetlbpage.txt 파일에서 찾을 수 있습니다. 이 파일은 IA-64에 필요한 특별한 처리도 설명합니다.
그림 7.9: Huge Page 사용 시 Follow, NPAD=0
큰 페이지의 장점을 보여 주기 위해, 그림 7.9는 NPAD=0에 대해 랜덤 Follow 테스트를 실행한 결과를 보여 줍니다. 이는 그림 3.15와 동일한 데이터이지만, 이번에는 메모리를 큰 페이지로 할당한 경우도 함께 측정했습니다. 보시다시피 성능 이점이 매우 클 수 있습니다. 2^20 바이트에서는 큰 페이지를 사용한 테스트가 57% 더 빠릅니다. 이 크기는 하나의 2MB 페이지에 완전히 들어가므로 DTLB 미스가 발생하지 않기 때문입니다.
이 지점 이후로는 이득이 처음에는 작아지지만, 워킹셋 크기가 커질수록 다시 증가합니다. 512MB 워킹셋에서는 큰 페이지 테스트가 38% 더 빠릅니다. 큰 페이지 테스트 곡선은 약 250 사이클 근처에서 고원을 이룹니다. 워킹셋이 2^27 바이트를 넘어가면 숫자가 다시 크게 오릅니다. 고원의 이유는 2MB 페이지에 대해 64개의 TLB 엔트리가 2^27 바이트를 커버하기 때문입니다.
이 숫자들은 큰 워킹셋 크기를 사용할 때 드는 비용의 큰 부분이 TLB 미스에서 온다는 것을 보여 줍니다. 이 절에서 설명한 인터페이스를 사용하면 큰 폭의 이득을 얻을 수 있습니다. 그래프의 숫자는 아마도 상한선일 가능성이 크지만, 실제 프로그램에서도 상당한 속도 향상을 보입니다. 데이터베이스는 많은 데이터를 사용하므로 오늘날 큰 페이지를 사용하는 프로그램 가운데 하나입니다.
현재로서는 큰 페이지를 파일로 뒷받침된 데이터 매핑에 사용하는 방법이 없습니다. 이 기능을 구현하려는 관심은 있지만, 지금까지 제안된 방법은 모두 큰 페이지를 명시적으로 사용하고 hugetlbfs 파일 시스템에 의존합니다. 이는 받아들일 수 없습니다. 이 경우 큰 페이지 사용은 투명해야 합니다. 커널은 매핑이 큰지 쉽게 판단할 수 있고, 자동으로 큰 페이지를 사용할 수 있습니다. 큰 문제는 커널이 항상 사용 패턴을 알지 못한다는 점입니다. 큰 페이지로 매핑될 수 있는 메모리가 나중에 4k 페이지 단위를 필요로 하게 되면(예를 들어 mprotect로 메모리 범위 일부의 보호가 바뀌는 경우) 선형 물리 메모리를 포함한 귀중한 리소스가 많이 낭비됩니다. 따라서 이런 접근이 성공적으로 구현되려면 아직 시간이 더 걸릴 것입니다.
| 이 글의 색인 항목 |
|---|
| GuestArticles |