가상 주소 공간, 다단계 페이지 테이블, TLB(변환 후방 버퍼) 성능, 그리고 가상화가 메모리 주소 변환에 미치는 영향을 다룬다.
LWN 구독자의 혜택 LWN 구독의 가장 큰 혜택은 우리가 계속해서 기사를 발행할 수 있도록 돕는 것입니다. 그뿐 아니라 구독자는 사이트의 모든 콘텐츠에 즉시 접근할 수 있고 여러 추가 사이트 기능도 이용할 수 있습니다. 지금 가입해 주세요!
[편집자 주: 이는 Ulrich Drepper의 “What every programmer should know about memory(모든 프로그래머가 메모리에 대해 알아야 할 것)” 문서의 세 번째 연재입니다. 이 절은 가상 메모리, 특히 TLB 성능을 다룹니다. 아직 1부와 2부를 읽지 않았다면 지금 읽어보는 것이 좋습니다. 언제나처럼 오탈자 제보 등은 이곳 댓글로 남기지 말고 lwn@lwn.net 으로 보내주세요.]
프로세서의 가상 메모리 서브시스템은 각 프로세스에 제공되는 가상 주소 공간을 구현한다. 덕분에 각 프로세스는 자신만이 시스템을 사용하는 것처럼 느낀다. 가상 메모리의 장점 목록은 다른 곳에서 자세히 설명되어 있으므로 여기서 반복하지 않겠다. 대신 이 절에서는 가상 메모리 서브시스템의 실제 구현 세부사항과 그에 수반되는 비용에 집중한다.
가상 주소 공간은 CPU의 메모리 관리 장치(MMU)에 의해 구현된다. OS는 페이지 테이블 데이터 구조를 채워 넣어야 하지만, 대부분의 CPU는 나머지 작업을 스스로 수행한다. 이는 실제로 꽤 복잡한 메커니즘이며, 이를 이해하는 가장 좋은 방법은 가상 주소 공간을 기술하는 데 쓰이는 데이터 구조들을 소개하는 것이다.
MMU가 수행하는 주소 변환의 입력은 가상 주소다. 보통 그 값에는 (있더라도) 거의 제한이 없다. 가상 주소는 32비트 시스템에서는 32비트 값이고 64비트 시스템에서는 64비트 값이다. 어떤 시스템(예: x86, x86-64)에서는 실제로 사용되는 주소에 한 단계의 간접 참조가 더 있다. 이런 아키텍처는 세그먼트를 사용하며, 이는 모든 논리 주소에 오프셋을 더하는 방식일 뿐이다. 이 부분은 주소 생성에서 무시해도 된다. 단순하고, 메모리 처리 성능과 관련하여 프로그래머가 신경 써야 할 것이 아니기 때문이다. {x86의 세그먼트 한계(segment limit)는 성능과 관련이 있지만, 그건 다른 이야기다.}
흥미로운 부분은 가상 주소를 물리 주소로 변환하는 과정이다. MMU는 페이지 단위로 주소를 재매핑할 수 있다. 캐시 라인을 주소 지정할 때와 마찬가지로, 가상 주소는 구분된 여러 부분으로 나뉜다. 이 부분들은 최종 물리 주소를 구성하는 데 사용되는 여러 테이블을 인덱싱하는 데 쓰인다. 가장 단순한 모델에서는 테이블 레벨이 하나뿐이다.
그림 4.1: 1레벨 주소 변환
그림 4.1은 가상 주소의 서로 다른 부분이 어떻게 사용되는지 보여준다. 상위 부분은 페이지 디렉터리(Page Directory)의 엔트리를 선택하는 데 사용되며, 그 디렉터리의 각 엔트리는 OS가 개별적으로 설정할 수 있다. 페이지 디렉터리 엔트리는 물리 메모리 페이지의 주소를 결정하며, 페이지 디렉터리의 둘 이상의 엔트리가 같은 물리 주소를 가리킬 수도 있다. 메모리 셀의 완전한 물리 주소는 페이지 디렉터리에서 얻은 페이지 주소와 가상 주소의 하위 비트를 결합하여 결정된다. 페이지 디렉터리 엔트리는 접근 권한 같은 페이지에 대한 추가 정보도 포함한다.
페이지 디렉터리를 위한 데이터 구조는 메모리에 저장된다. OS는 연속된 물리 메모리를 할당하고, 이 메모리 영역의 기준 주소(base address)를 특별한 레지스터에 저장해야 한다. 그 다음 가상 주소의 해당 비트들이 페이지 디렉터리의 인덱스로 사용되며, 페이지 디렉터리는 실제로 디렉터리 엔트리 배열이다.
구체적인 예로, 이는 x86 머신에서 4MB 페이지에 사용되는 레이아웃이다. 가상 주소의 오프셋(Offset) 부분은 22비트이며, 4MB 페이지 내의 모든 바이트를 주소 지정할 수 있을 만큼 크다. 나머지 10비트는 페이지 디렉터리의 1024개 엔트리 중 하나를 선택한다. 각 엔트리는 4MB 페이지의 10비트 베이스 주소를 포함하며, 이를 오프셋과 결합해 완전한 32비트 주소를 만든다.
4MB 페이지는 표준이 아니다. OS가 수행해야 하는 많은 연산이 메모리 페이지 정렬을 요구하기 때문에, 4MB 페이지는 많은 메모리를 낭비하게 된다. 4kB 페이지(32비트 머신의 표준이며 64비트 머신에서도 여전히 흔함)를 쓰면 가상 주소의 오프셋 부분은 12비트뿐이다. 그러면 페이지 디렉터리 선택자로 20비트가 남는다. 2^20 엔트리를 가진 테이블은 현실적이지 않다. 각 엔트리가 4바이트뿐이라 해도 테이블 크기는 4MB가 된다. 각 프로세스가 서로 다른 페이지 디렉터리를 가질 수 있으므로, 시스템의 물리 메모리 상당 부분이 이런 페이지 디렉터리로 묶이게 된다.
해결책은 여러 레벨의 페이지 테이블을 사용하는 것이다. 그러면 실제로 사용되지 않는 영역에는 메모리를 할당할 필요가 없는, 성긴(sparse) 거대한 페이지 디렉터리를 표현할 수 있다. 표현이 훨씬 더 컴팩트해져, 많은 프로세스의 페이지 테이블을 메모리에 두더라도 성능에 미치는 영향이 크지 않게 된다.
오늘날 가장 복잡한 페이지 테이블 구조는 4레벨로 구성된다. 그림 4.2는 이런 구현의 개략도를 보여준다.
그림 4.2: 4레벨 주소 변환
이 예에서 가상 주소는 적어도 다섯 부분으로 나뉜다. 그중 네 부분은 각 디렉터리에 대한 인덱스다. 레벨 4 디렉터리는 CPU의 특수 목적 레지스터를 통해 참조된다. 레벨 4에서 레벨 2까지의 디렉터리 내용은 다음 하위 레벨 디렉터리에 대한 참조다. 디렉터리 엔트리가 비어 있다고 표시되면 당연히 어떤 하위 디렉터리도 가리킬 필요가 없다. 이런 방식으로 페이지 테이블 트리는 성기고 컴팩트할 수 있다. 레벨 1 디렉터리의 엔트리는 그림 4.1과 마찬가지로 물리 주소의 일부와 접근 권한 같은 보조 데이터를 포함한다.
가상 주소에 대응하는 물리 주소를 결정하기 위해 프로세서는 먼저 최상위 레벨 디렉터리의 주소를 결정한다. 이 주소는 보통 레지스터에 저장되어 있다. 그런 다음 CPU는 이 디렉터리에 해당하는 가상 주소의 인덱스 부분을 취해 적절한 엔트리를 선택한다. 그 엔트리는 다음 디렉터리의 주소이며, 다음 가상 주소 부분으로 인덱싱된다. 이 과정은 레벨 1 디렉터리에 도달할 때까지 계속되며, 그 시점에서 디렉터리 엔트리 값이 물리 주소의 상위 부분이 된다. 물리 주소는 가상 주소의 페이지 오프셋 비트를 더해 완성된다. 이 과정을 페이지 트리 워킹(page tree walking)이라 한다. 일부 프로세서(x86, x86-64 등)는 이 작업을 하드웨어에서 수행하고, 다른 일부는 OS의 도움이 필요하다.
시스템에서 실행되는 각 프로세스는 자체 페이지 테이블 트리가 필요할 수 있다. 트리를 부분적으로 공유하는 것도 가능하지만 이는 예외적인 경우다. 따라서 페이지 테이블 트리에 필요한 메모리가 가능한 한 작을수록 성능과 확장성에 좋다. 이를 위한 이상적인 경우는 가상 주소 공간에서 사용되는 메모리를 서로 가깝게 배치하는 것이다. 실제로 사용되는 물리 주소는 중요하지 않다. 작은 프로그램은 레벨 2, 3, 4에서 각각 디렉터리 하나만 쓰고 레벨 1 디렉터리를 몇 개만 써도 충분할 수 있다. x86-64에서 4kB 페이지와 디렉터리당 512 엔트리를 사용하면, 레벨별로 디렉터리 1개씩 총 4개 디렉터리로 2MB를 주소 지정할 수 있다. 1GB의 연속 메모리는 레벨 2~4에 디렉터리 하나와 레벨 1에 512개의 디렉터리로 주소 지정할 수 있다.
하지만 모든 메모리가 연속적으로 할당될 수 있다고 가정하는 것은 지나치게 단순하다. 유연성을 위해 프로세스의 스택과 힙 영역은 대부분의 경우 주소 공간의 거의 양 끝에 배치된다. 이는 필요하면 어느 한 영역이 최대한 성장할 수 있도록 하기 위함이다. 즉, 레벨 2 디렉터리가 두 개 필요할 가능성이 크고, 그에 따라 더 하위 레벨 디렉터리도 더 많이 필요하다.
그런데 이것조차 현재 관행과 항상 일치하지는 않는다. 보안상의 이유로 실행 파일의 여러 부분(코드, 데이터, 힙, 스택, DSO 즉 공유 라이브러리)은 무작위화된 주소에 매핑된다[nonselsec]. 무작위화는 각 부분의 상대적 위치에도 확장되며, 이는 프로세스에서 사용 중인 다양한 메모리 영역이 가상 주소 공간 전역에 넓게 퍼지게 됨을 뜻한다. 무작위화되는 주소 비트 수에 제한을 두면 범위를 줄일 수는 있지만, 대부분의 경우 레벨 2와 3에서 디렉터리 1~2개만으로 프로세스를 실행할 수 있게 해주지는 못한다.
성능이 보안보다 정말 훨씬 더 중요하다면 무작위화를 끌 수 있다. 그러면 OS는 보통 최소한 모든 DSO를 가상 메모리에서 연속적으로 로드한다.
페이지 테이블을 위한 모든 데이터 구조는 주 메모리에 유지된다. OS가 테이블을 구성하고 갱신하는 곳이 바로 여기다. 프로세스를 생성하거나 페이지 테이블을 변경하면 CPU에 통지된다. 페이지 테이블은 앞서 설명한 페이지 테이블 워크를 통해, 모든 가상 주소를 물리 주소로 해석하는 데 사용된다. 더 정확히 말하면: 가상 주소 하나를 해석하는 과정에서 각 레벨마다 최소 하나의 디렉터리가 사용된다. 이는 (실행 중인 프로세스의 단일 접근에 대해) 최대 4번의 메모리 접근이 필요하다는 뜻이며 느리다. 이 디렉터리 테이블 엔트리들을 일반 데이터처럼 취급해 L1d, L2 등의 캐시에 넣을 수는 있지만, 그래도 너무 느리다.
가상 메모리 초기부터 CPU 설계자들은 다른 최적화를 사용해왔다. 단순 계산만으로도 디렉터리 테이블 엔트리만 L1d 및 상위 캐시에 유지하는 것은 끔찍한 성능으로 이어짐을 알 수 있다. 절대 주소 계산은 페이지 테이블 깊이에 비례하는 수의 L1d 접근이 필요하다. 이 접근들은 이전 조회 결과에 의존하기 때문에 병렬화할 수 없다. 이것만으로도, 4레벨 페이지 테이블을 가진 머신에서는 최소 12사이클이 필요하다. 여기에 L1d 미스 확률까지 더하면, 이는 명령 파이프라인이 숨길 수 있는 수준이 아니다. 추가 L1d 접근은 캐시에 대한 귀중한 대역폭도 빼앗는다.
그래서 디렉터리 테이블 엔트리만 캐시하는 대신, 물리 페이지 주소 계산 전체를 캐시한다. 코드/데이터 캐시가 동작하는 것과 같은 이유로, 이렇게 캐시된 주소 계산은 효과적이다. 가상 주소의 페이지 오프셋 부분은 물리 페이지 주소 계산에 전혀 관여하지 않으므로, 가상 주소의 나머지 부분만이 캐시의 태그로 사용된다. 페이지 크기에 따라 수백/수천 개의 명령 또는 데이터 객체가 같은 태그(따라서 같은 물리 주소 접두어)를 공유한다.
계산된 값이 저장되는 캐시는 변환 후방 버퍼(Translation Look-Aside Buffer, TLB)라 불린다. 이는 극도로 빨라야 하므로 보통 작은 캐시다. 현대 CPU는 다른 캐시들과 마찬가지로 다단계 TLB 캐시를 제공한다. 상위 레벨 캐시는 더 크고 더 느리다. L1TLB의 작은 크기는 종종 완전 연관(fully associative)과 LRU 축출 정책으로 보완된다. 최근 이 캐시는 크기가 커지고, 그 과정에서 세트 연관(set associative)으로 바뀌었다. 그 결과 새 엔트리를 추가해야 할 때 항상 가장 오래된 엔트리가 축출/대체되는 것은 아닐 수 있다.
앞서 언급했듯 TLB 접근에 쓰이는 태그는 가상 주소의 일부다. 태그가 캐시에서 일치하면, 최종 물리 주소는 가상 주소의 페이지 오프셋을 캐시된 값에 더해 계산한다. 이는 매우 빠른 과정이어야 한다. 절대 주소를 사용하는 모든 명령마다(또는 어떤 경우에는 물리 주소를 키로 쓰는 L2 조회를 위해서도) 물리 주소가 필요하기 때문이다. TLB 조회가 미스하면 프로세서는 페이지 테이블 워크를 수행해야 하며, 이는 비용이 클 수 있다.
소프트웨어 또는 하드웨어를 통한 코드/데이터 프리페치는 주소가 다른 페이지에 있을 경우 암묵적으로 TLB 엔트리도 프리페치할 수 있다. 하지만 하드웨어 프리페치에는 이를 허용할 수 없다. 하드웨어가 유효하지 않은 페이지 테이블 워크를 시작할 수도 있기 때문이다. 따라서 프로그래머는 하드웨어 프리페치가 TLB 엔트리를 프리페치해주리라 기대할 수 없다. 프리페치 명령을 사용해 명시적으로 해야 한다. TLB도 데이터/명령 캐시처럼 여러 레벨로 존재할 수 있다. 데이터 캐시와 마찬가지로, TLB는 보통 두 종류로 나타난다. 명령 TLB(ITLB)와 데이터 TLB(DTLB)다. L2TLB 같은 상위 레벨 TLB는 다른 캐시들과 마찬가지로 보통 통합(unified)되어 있다.
4.3.1 TLB 사용의 주의점
TLB는 프로세서 코어 전역 자원이다. 해당 코어에서 실행되는 모든 스레드와 프로세스는 같은 TLB를 사용한다. 가상→물리 주소 변환은 어떤 페이지 테이블 트리가 설치되어 있는지에 따라 달라지므로, CPU는 페이지 테이블이 바뀌었을 때 캐시된 엔트리를 무작정 재사용할 수 없다. 각 프로세스는 (같은 프로세스의 스레드는 제외하고) 서로 다른 페이지 테이블 트리를 가지며, 커널도, 존재한다면 VMM(하이퍼바이저)도 마찬가지다. 또한 프로세스의 주소 공간 레이아웃이 바뀔 수도 있다. 이를 다루는 방법은 두 가지다.
첫 번째 경우 컨텍스트 스위치가 수행될 때마다 TLB가 플러시된다. 대부분의 OS에서 한 스레드/프로세스에서 다른 스레드/프로세스로 전환하려면 커널 코드를 실행해야 하므로, TLB 플러시는 커널 주소 공간으로 들어가고 나올 때로 제한된다. 가상화된 시스템에서는 커널이 VMM을 호출할 때와 돌아올 때도 발생한다. 커널 및/또는 VMM이 가상 주소를 쓸 필요가 없거나, 시스템/VMM 호출을 한 프로세스 또는 커널과 같은 가상 주소를 재사용할 수 있다면, TLB는 커널이나 VMM을 벗어날 때 프로세서가 다른 프로세스나 커널의 실행을 재개하는 경우에만 플러시하면 된다.
TLB 플러시는 효과적이지만 비싸다. 예를 들어 시스템 콜을 실행할 때 커널 코드는 수천 개의 명령으로 제한될 수 있고, 아마도 소수의 새 페이지(또는 일부 아키텍처에서의 Linux처럼 하나의 거대 페이지)를 건드릴 것이다. 이 작업은 건드린 페이지 수만큼의 TLB 엔트리만 대체할 것이다. Intel Core2 아키텍처의 128 ITLB 및 256 DTLB 엔트리를 예로 들면, 전체 플러시는 각각 100개가 넘는(ITLB) 그리고 200개가 넘는(DTLB) 엔트리가 불필요하게 플러시된다는 뜻이다. 시스템 콜이 같은 프로세스로 돌아오면, 플러시된 TLB 엔트리들은 다시 사용할 수 있었겠지만 사라져 버린다. 자주 쓰이는 커널/VMM 코드도 마찬가지다. 커널에 진입할 때마다 커널과 VMM의 페이지 테이블은 보통 변하지 않으므로 TLB 엔트리는 이론상 오랫동안 보존될 수 있음에도, TLB는 매번 처음부터 채워야 한다. 이것은 또한 오늘날 프로세서에서 TLB 캐시가 더 크지 않은 이유를 설명한다. 프로그램은 아마도 이 모든 엔트리를 다 채울 만큼 오래 실행되지 못할 가능성이 크다.
물론 이 사실은 CPU 설계자들도 놓치지 않았다. 캐시 플러시를 최적화하는 한 가지 방법은 TLB 엔트리를 개별적으로 무효화하는 것이다. 예를 들어 커널 코드와 데이터가 특정 주소 범위에 있다면, 그 범위에 속하는 페이지만 TLB에서 축출하면 된다. 이는 태그 비교만 필요하므로 그리 비싸지 않다. 이 방법은 munmap 호출처럼 주소 공간의 일부가 바뀌는 경우에도 유용하다.
훨씬 더 나은 해결책은 TLB 접근에 쓰이는 태그를 확장하는 것이다. 가상 주소의 일부에 더해 각 페이지 테이블 트리(즉 프로세스의 주소 공간)를 위한 고유 식별자를 추가하면, TLB를 완전히 플러시할 필요가 없다. 커널, VMM, 개별 프로세스는 모두 고유 식별자를 가질 수 있다. 이 방식의 유일한 문제는 TLB 태그에 사용할 수 있는 비트 수가 심각하게 제한되어 있는 반면, 주소 공간의 수는 제한되어 있지 않다는 점이다. 따라서 식별자 재사용이 필요하다. 이 경우 TLB를 부분적으로(가능하다면) 플러시해야 한다. 재사용된 식별자를 가진 모든 엔트리를 플러시해야 하지만, 바라건대 이는 훨씬 더 작은 집합일 것이다.
이 확장된 TLB 태깅은 여러 프로세스가 시스템에서 실행될 때 전반적으로 이점이 있다. 실행 가능(runnable)한 각 프로세스의 메모리 사용(따라서 TLB 엔트리 사용)이 제한되어 있다면, 해당 프로세스가 다시 스케줄될 때 가장 최근에 사용한 TLB 엔트리들이 여전히 TLB에 남아 있을 가능성이 크다. 하지만 추가로 두 가지 장점이 있다.
일부 프로세서는 오래전부터 이런 확장 태그를 구현해왔다. AMD는 Pacifica 가상화 확장에서 1비트 태그 확장을 도입했다. 이 1비트 주소 공간 ID(ASID)는 가상화 맥락에서 VMM의 주소 공간과 게스트 도메인의 주소 공간을 구별하는 데 사용된다. 이를 통해 OS는 VMM에 진입할 때(예: 페이지 폴트를 처리하기 위해)마다 게스트의 TLB 엔트리를 플러시하는 것, 또는 제어가 게스트로 돌아올 때 VMM의 TLB 엔트리를 플러시하는 것을 피할 수 있다. 이 아키텍처는 향후 더 많은 비트를 사용할 수 있게 할 것이다. 다른 주류 프로세서들도 아마 이를 따라 이 기능을 지원할 것이다.
4.3.2 TLB 성능에 영향을 주기
TLB 성능에 영향을 주는 요인이 몇 가지 있다. 첫째는 페이지 크기다. 당연히 페이지가 클수록 그 안에 더 많은 명령이나 데이터 객체가 들어간다. 그래서 더 큰 페이지 크기는 필요한 주소 변환의 총 개수를 줄여, TLB 캐시에 필요한 엔트리 수를 줄인다. 대부분의 아키텍처는 여러 서로 다른 페이지 크기를 허용하며, 일부 크기는 동시에 사용할 수도 있다. 예를 들어 x86/x86-64 프로세서는 일반 페이지 크기가 4kB이지만 각각 4MB 및 2MB 페이지도 사용할 수 있다. IA-64와 PowerPC는 기본 페이지 크기로 64kB 같은 크기도 허용한다.
하지만 큰 페이지 크기를 사용하면 몇 가지 문제가 따른다. 큰 페이지에 사용되는 메모리 영역은 물리 메모리에서 연속적이어야 한다. 물리 메모리 관리의 단위 크기를 가상 메모리 페이지 크기로 올리면, 낭비되는 메모리 양이 증가한다. 실행 파일 로딩 같은 다양한 메모리 연산은 페이지 경계 정렬을 요구한다. 즉 평균적으로 각 매핑은 매핑마다 페이지 크기의 절반만큼 물리 메모리를 낭비한다. 이 낭비는 쉽게 누적되므로, 물리 메모리 할당의 합리적인 단위 크기에 상한을 두게 된다.
x86-64의 큰 페이지를 수용하기 위해 단위 크기를 2MB로 올리는 것은 현실적이지 않다. 너무 크다. 대신 각 큰 페이지는 더 많은 작은 페이지들로 구성되어야 한다. 그리고 이 작은 페이지들은 물리 메모리에서 연속적이어야 한다. 4kB 단위 페이지로 2MB 연속 물리 메모리를 할당하는 것은 어려울 수 있다. 512개의 연속된 페이지를 가진 빈 영역을 찾아야 하기 때문이다. 시스템이 한동안 실행되어 물리 메모리가 단편화되면 이는 매우 어렵거나(혹은 불가능)해질 수 있다.
그래서 Linux에서는 보통 hugetlbfs 특수 파일시스템을 사용해 시스템 시작 시점에 이 큰 페이지들을 미리 할당해야 한다. 일정 개수의 물리 페이지가 큰 가상 페이지로만 독점 사용되도록 예약된다. 이는 항상 사용되지 않을 수도 있는 자원을 묶어 둔다. 또한 제한된 풀이라서, 보통 늘리려면 시스템 재시작이 필요하다. 그럼에도 성능이 최우선이고 자원이 충분하며 번거로운 설정이 큰 장애가 아닐 때, huge page는 좋은 선택이다. 데이터베이스 서버가 그 예다.
(선택적 큰 페이지가 아니라) 최소 가상 페이지 크기를 늘리는 것에도 문제가 있다. 메모리 매핑 연산(예: 애플리케이션 로딩)은 그 페이지 크기를 따라야 한다. 더 작은 매핑은 불가능하다. 대부분의 아키텍처에서 실행 파일의 여러 부분의 위치 관계는 고정되어 있다. 실행 파일 또는 DSO를 빌드할 때 고려한 것보다 페이지 크기가 커지면, 로드 연산을 수행할 수 없다. 이 제한을 염두에 두는 것이 중요하다. 그림 4.3은 ELF 바이너리의 정렬 요구 사항을 결정하는 방법을 보여준다. 이는 ELF 프로그램 헤더에 인코딩되어 있다.
$ eu-readelf -l /bin/ls Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align ... LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x0132ac 0x0132ac R E 0x200000 LOAD 0x0132b0 0x00000000006132b0 0x00000000006132b0 0x001a71 0x001a71 RW 0x200000 ...
그림 4.3: 정렬 요구 사항을 나타내는 ELF 프로그램 헤더
이 예는 x86-64 바이너리이며, 값은 0x200000=2,097,152=2MB로 프로세서가 지원하는 최대 페이지 크기에 해당한다.
더 큰 페이지 크기를 쓰는 두 번째 효과는 페이지 테이블 트리의 레벨 수가 줄어든다는 점이다. 가상 주소에서 페이지 오프셋에 해당하는 부분이 커지므로, 페이지 디렉터리를 통해 처리해야 할 비트가 그만큼 줄어든다. 즉 TLB 미스가 발생했을 때 수행해야 할 작업량이 줄어든다.
큰 페이지 크기를 사용하는 것 외에도, 동시에 사용되는 데이터를 더 적은 페이지로 모아 필요한 TLB 엔트리 수를 줄일 수 있다. 이는 앞서 이야기한 캐시 사용 최적화와 유사하다. 다만 여기서는 요구되는 정렬 단위가 크다. TLB 엔트리 수가 꽤 작다는 점을 고려하면, 이는 중요한 최적화가 될 수 있다.
OS 이미지의 가상화는 점점 더 보편화될 것이다. 이는 메모리 처리 관점에서 또 하나의 계층이 추가된다는 뜻이다. 프로세스 가상화(기본적으로 jail)나 OS 컨테이너는 하나의 OS만 관여하므로 이 범주에 속하지 않는다. Xen이나 KVM 같은 기술은(프로세서의 도움을 받거나 받지 않거나) 독립적인 OS 이미지 실행을 가능하게 한다. 이런 상황에서는 물리 메모리에 대한 접근을 직접 제어하는 소프트웨어가 하나 존재한다.
그림 4.4: Xen 가상화 모델
Xen의 경우(그림 4.4 참조) Xen VMM이 바로 그 소프트웨어다. 하지만 VMM이 다른 하드웨어 제어까지 많이 직접 구현하는 것은 아니다. 다른(더 이른) 시스템의 VMM(그리고 Xen VMM의 첫 릴리스)과 달리, 메모리와 프로세서 밖의 하드웨어는 특권 Dom0 도메인이 제어한다. 현재 이는 기본적으로 비특권 DomU 커널과 같은 커널이며, 메모리 처리 관점에서 둘은 다르지 않다. 중요한 점은 VMM이 Dom0와 DomU 커널에 물리 메모리를 나눠주고, 그 커널들이 스스로 프로세서에서 직접 실행되는 것처럼 일반적인 메모리 처리를 구현한다는 것이다.
가상화를 완전하게 만들기 위해 요구되는 도메인 분리를 구현하려면, Dom0와 DomU 커널의 메모리 처리 코드는 물리 메모리에 대한 무제한 접근 권한을 가지지 않는다. VMM은 개별 물리 페이지를 나눠주고 게스트 OS가 주소 지정을 처리하게 하는 방식으로 메모리를 제공하지 않는다. 그렇게 하면 결함이 있거나 악의적인 게스트 도메인으로부터 보호가 되지 않기 때문이다. 대신 VMM은 각 게스트 도메인에 대해 자체 페이지 테이블 트리를 만들고, 이 데이터 구조를 사용해 메모리를 나눠준다. 좋은 점은 페이지 테이블 트리의 관리 정보에 대한 접근을 제어할 수 있다는 것이다. 코드에 적절한 권한이 없으면 아무것도 할 수 없다.
이 접근 제어는 Xen이 제공하는 가상화(준가상화든 하드웨어(즉 완전) 가상화든)에서 활용된다. 게스트 도메인은 각 프로세스에 대해 자신의 페이지 테이블 트리를 구성하는데, 이는 준가상화와 하드웨어 가상화 모두에서 의도적으로 꽤 비슷하게 만들어져 있다. 게스트 OS가 페이지 테이블을 수정할 때마다 VMM이 호출된다. 그러면 VMM은 게스트 도메인의 갱신된 정보를 사용해 자신의 섀도(shadow) 페이지 테이블을 갱신한다. 하드웨어가 실제로 사용하는 것은 이 페이지 테이블이다. 당연히 이 과정은 매우 비싸다. 페이지 테이블 트리의 각 수정은 VMM 호출을 필요로 한다. 가상화가 없을 때도 메모리 매핑 변경은 싸지 않지만, 이제는 더 비싸진다.
추가 비용은 상당히 커질 수 있는데, 게스트 OS에서 VMM으로 전환했다가 돌아오는 것 자체가 이미 꽤 비싸기 때문이다. 이것이 바로 프로세서들이 섀도 페이지 테이블 생성을 피하기 위한 추가 기능을 갖추기 시작한 이유다. 이는 속도 문제뿐 아니라, VMM의 메모리 소비도 줄인다. Intel은 확장 페이지 테이블(EPT), AMD는 중첩 페이지 테이블(NPT)이라 부른다. 두 기술 모두 게스트 OS의 페이지 테이블이 ‘가상 물리 주소(virtual physical address)’를 생성하도록 한다. 그 주소는 도메인별 EPT/NPT 트리를 사용해 다시 실제 물리 주소로 변환되어야 한다. 이렇게 하면 메모리 처리에서 VMM 진입 대부분이 제거되어, 가상화가 없는 경우에 가까운 속도로 메모리 처리가 가능해진다. 또한 VMM이 유지해야 하는 페이지 테이블 트리가 (프로세스 단위가 아니라 도메인 단위로) 하나만 필요해지므로 VMM의 메모리 사용도 감소한다.
추가 주소 변환 단계의 결과도 TLB에 저장된다. 즉 TLB는 가상 물리 주소를 저장하는 것이 아니라 조회의 완전한 결과를 저장한다. AMD의 Pacifica 확장이 VMM 진입 때마다 TLB 플러시를 피하기 위해 ASID를 도입했다는 점은 이미 설명했다. 초기 프로세서 확장에서 ASID의 비트 수는 1이며, 이는 VMM과 게스트 OS를 구별하기에는 충분하다. Intel은 같은 목적을 위한 가상 프로세서 ID(VPID)를 제공하는데, 이쪽은 더 많은 수를 갖는다. 하지만 VPID는 각 게스트 도메인에 대해 고정되어 있으므로, 개별 프로세스를 표시해 그 레벨에서도 TLB 플러시를 피하는 데는 사용할 수 없다.
가상화된 OS에서 주소 공간 수정마다 필요한 작업량이 많다는 것이 한 가지 문제다. 하지만 VMM 기반 가상화에는 또 다른 고유한 문제가 있다. 두 계층의 메모리 처리를 피할 방법이 없다. 그런데 메모리 처리는 어렵다(특히 NUMA 같은 복잡함을 고려하면 더 그렇다. 5절 참고). Xen처럼 별도의 VMM을 쓰는 접근은 최적(또는 그나마 괜찮은) 처리를 어렵게 한다. 메모리 영역 탐지 같은 “사소한” 것까지 포함해, 메모리 관리 구현의 모든 복잡함이 VMM에 중복 구현되어야 하기 때문이다. OS들은 완전하고 최적화된 구현을 가지고 있으므로, 이를 중복하는 것은 피하고 싶다.
그림 4.5: KVM 가상화 모델
이것이 VMM/Dom0 모델을 끝까지 밀고 나가는 것이 매력적인 대안인 이유다. 그림 4.5는 KVM Linux 커널 확장이 이 문제를 어떻게 해결하려 하는지 보여준다. 하드웨어 위에서 직접 실행되며 모든 게스트를 제어하는 별도의 VMM이 존재하지 않는다. 대신 일반 Linux 커널이 그 기능을 가져간다. 즉 Linux 커널의 완전하고 정교한 메모리 처리 기능이 시스템 메모리 관리에 사용된다. 게스트 도메인은 제작자들이 “게스트 모드”라고 부르는 방식으로 일반 사용자 수준 프로세스들과 나란히 실행된다. 준가상화 또는 완전 가상화 같은 가상화 기능은 또 다른 사용자 수준 프로세스인 KVM VMM에 의해 제어된다. 이는 커널이 구현한 특수 KVM 디바이스를 사용해 게스트 도메인을 제어하는 또 하나의 프로세스일 뿐이다.
이 모델이 Xen 모델의 별도 VMM에 비해 갖는 이점은, 게스트 OS를 사용할 때 여전히 두 개의 메모리 핸들러가 동작하긴 하지만 구현은 하나(즉 하드웨어에서 실행되는 바깥쪽 Linux 커널의 구현)만 필요하다는 점이다. Xen VMM 같은 다른 코드 조각에 같은 기능을 중복 구현할 필요가 없다. 이는 작업량을 줄이고 버그를 줄이며, 두 메모리 핸들러가 맞닿는 부분에서의 마찰도 줄일 수 있다. Linux 게스트의 메모리 핸들러가, 베어메탈에서 동작하는 바깥쪽 Linux 커널의 메모리 핸들러와 같은 가정을 하기 때문이다.
종합하면 프로그래머는 가상화를 사용할 경우 메모리 연산의 비용이 가상화가 없을 때보다 더 높다는 점을 알아야 한다. 이 작업을 줄이는 어떤 최적화도 가상화 환경에서는 더 큰 효과를 낼 것이다. 프로세서 설계자들은 시간이 지나며 EPT와 NPT 같은 기술을 통해 그 차이를 점점 줄이겠지만, 완전히 사라지지는 않을 것이다.
| 이 글의 인덱스 엔트리 |
|---|
| GuestArticles |