프로세서의 가상 메모리 서브시스템이 어떻게 가상 주소를 물리 주소로 변환하는지, 다단계 페이지 테이블과 TLB의 동작 및 성능, 페이지 크기의 영향, 그리고 Xen/KVM과 같은 가상화가 메모리 성능과 설계에 미치는 영향을 설명한다.
[편집자 주: 이것은 Ulrich Drepper의 "모든 프로그래머가 메모리에 대해 알아야 할 것" 문서의 세 번째 연재분입니다. 이 섹션은 특히 가상 메모리와 TLB 성능에 대해 다룹니다. 아직 1부와 2부를 읽지 않았다면 지금 읽어보시기 바랍니다. 언제나 그렇듯, 오탈자 보고 등은 여기 댓글 대신 lwn@lwn.net 으로 보내 주십시오.]
$ sudo 오늘 구독하기 오늘 구독하고 LWN 권한을 높이세요. 게시와 동시에 LWN의 고품질 아티클 전부에 접근할 수 있고, 동시에 LWN을 지원하게 됩니다. 지금 신청하시면 무료 체험 구독으로 시작할 수 있습니다.
프로세서의 가상 메모리 서브시스템은 각 프로세스에 제공되는 가상 주소 공간을 구현한다. 이를 통해 각 프로세스는 자신이 시스템에서 유일한 프로세스라고 "생각"하게 된다. 가상 메모리의 장점 목록은 다른 곳에서 자세히 설명되어 있으므로 여기서 반복하지는 않는다. 대신 이 절에서는 가상 메모리 서브시스템의 실제 구현 세부사항과 그에 따른 비용에 집중한다.
가상 주소 공간은 CPU의 MMU(메모리 관리 장치, Memory Management Unit)에 의해 구현된다. 운영체제는 페이지 테이블 데이터 구조를 채워야 하지만, 대부분의 CPU는 나머지 작업을 스스로 처리한다. 이는 꽤 복잡한 메커니즘이며, 이를 이해하는 가장 좋은 방법은 가상 주소 공간을 설명하는 데 사용되는 데이터 구조를 소개하는 것이다.
MMU가 수행하는 주소 변환의 입력은 가상 주소이다. 그 값에는 일반적으로 거의—있다면—제한이 없다. 가상 주소는 32비트 시스템에서는 32비트, 64비트 시스템에서는 64비트 값이다. 일부 시스템(예: x86 및 x86-64)에서는 실제로 주소가 또 다른 간접화 단계(세그먼트)를 포함한다. 이 아키텍처들은 세그먼트를 사용하며, 이는 단순히 모든 논리 주소에 오프셋을 더하는 역할을 한다. 주소 생성의 이 부분은 무시해도 된다. 사소하고, 메모리 처리 성능과 관련하여 프로그래머가 신경 쓸 일이 아니기 때문이다. {x86의 세그먼트 한계는 성능과 관련이 있지만 이는 다른 이야기다.}
흥미로운 부분은 가상 주소를 물리 주소로 변환하는 과정이다. MMU는 페이지 단위로 주소를 재매핑할 수 있다. 캐시 라인을 다룰 때와 마찬가지로, 가상 주소는 구분된 여러 부분으로 나뉜다. 이 부분들은 최종 물리 주소를 구성하는 데 사용되는 다양한 테이블을 인덱싱하는 데 쓰인다. 가장 단순한 모델에서는 테이블이 한 단계만 있다.
그림 4.1: 1단계 주소 변환
그림 4.1은 가상 주소의 서로 다른 부분이 어떻게 사용되는지를 보여준다. 최상위 부분은 페이지 디렉터리의 항목을 선택하는 데 사용된다. 디렉터리의 각 항목은 OS가 개별적으로 설정할 수 있다. 페이지 디렉터리 항목은 물리 메모리 페이지의 주소를 결정한다. 페이지 디렉터리의 둘 이상의 항목이 동일한 물리 주소를 가리킬 수 있다. 메모리 셀의 완전한 물리 주소는 페이지 디렉터리에서 가져온 페이지 주소에 가상 주소의 하위 비트를 결합하여 결정된다. 페이지 디렉터리 항목에는 접근 권한과 같은 페이지에 대한 추가 정보도 포함된다.
페이지 디렉터리에 대한 데이터 구조는 메모리에 저장된다. OS는 연속된 물리 메모리를 할당하고 이 메모리 영역의 베이스 주소를 특수 레지스터에 저장해야 한다. 그런 다음 가상 주소의 해당 비트들이 페이지 디렉터리(실제로는 디렉터리 엔트리의 배열)에 대한 인덱스로 사용된다.
구체적인 예로 x86에서 4MB 페이지에 사용되는 레이아웃이 있다. 가상 주소의 오프셋 부분은 22비트 크기이며, 4MB 페이지의 모든 바이트를 주소 지정하기에 충분하다. 가상 주소의 나머지 10비트는 페이지 디렉터리의 1024개 항목 중 하나를 선택한다. 각 항목에는 4MB 페이지의 10비트 베이스 주소가 들어 있으며, 이것이 오프셋과 결합되어 완전한 32비트 주소를 형성한다.
4MB 페이지는 일반적이지 않다. OS가 수행해야 하는 많은 작업이 메모리 페이지 경계에 정렬을 요구하기 때문에 메모리를 많이 낭비하기 때문이다. 4kB 페이지(32비트 머신의 표준이며 64비트 머신에서도 여전히 흔함)를 사용하면, 가상 주소의 오프셋 부분은 12비트만 된다. 그러면 페이지 디렉터리 선택에 20비트가 남는다. 항목이 2^20개인 테이블은 실용적이지 않다. 각 항목이 4바이트에 불과하더라도 테이블은 4MB 크기가 된다. 각 프로세스가 잠재적으로 고유한 페이지 디렉터리를 가진다면, 시스템의 물리 메모리 상당 부분이 이러한 페이지 디렉터리에 묶이게 된다.
해결책은 여러 단계의 페이지 테이블을 사용하는 것이다. 이렇게 하면 실제로 사용되지 않는 영역에 대해 메모리를 할당할 필요가 없는, 희소한 거대 페이지 디렉터리를 표현할 수 있다. 표현이 훨씬 더 compact해져 많은 프로세스의 페이지 테이블을 메모리에 두더라도 성능에 큰 영향을 주지 않게 된다.
오늘날 가장 복잡한 페이지 테이블 구조는 최대 4단계로 구성된다. 그림 4.2는 그러한 구현의 개략도를 보여준다.
그림 4.2: 4단계 주소 변환
이 예에서 가상 주소는 최소 다섯 부분으로 나뉜다. 이 중 네 부분은 각 단계의 디렉터리에 대한 인덱스이다. 4단계 디렉터리는 CPU의 특수 목적 레지스터를 통해 참조된다. 4단계부터 2단계까지 디렉터리의 내용은 다음 낮은 단계 디렉터리에 대한 참조다. 디렉터리 엔트리가 비어 있음으로 표시된 경우 당연히 하위 디렉터리를 가리킬 필요가 없다. 이렇게 해서 페이지 테이블 트리는 희소하고 compact할 수 있다. 1단계 디렉터리의 엔트리는 그림 4.1과 마찬가지로, 물리 주소의 상위 부분과 접근 권한 같은 보조 데이터를 담는다.
가상 주소에 대응하는 물리 주소를 결정하기 위해 프로세서는 먼저 최상위 단계 디렉터리의 주소를 알아낸다. 이 주소는 보통 레지스터에 저장되어 있다. 그런 다음 CPU는 가상 주소에서 해당 디렉터리에 해당하는 인덱스 부분을 가져와 그 인덱스를 사용해 적절한 항목을 선택한다. 이 항목은 다음 디렉터리의 주소이며, 가상 주소의 다음 부분을 사용해 인덱싱한다. 이 과정은 1단계 디렉터리에 도달할 때까지 계속되며, 이때 디렉터리 엔트리의 값이 물리 주소의 상위 부분이 된다. 물리 주소는 가상 주소의 페이지 오프셋 비트를 더해 완성된다. 이 과정을 페이지 트리 워킹(page tree walking)이라 한다. 일부 프로세서(x86 및 x86-64 등)는 이 작업을 하드웨어로 수행하고, 다른 프로세서는 OS의 도움이 필요하다.
시스템에서 실행 중인 각 프로세스는 자체 페이지 테이블 트리를 필요로 할 수 있다. 트리를 부분적으로 공유하는 것도 가능하지만 이는 예외적이다. 따라서 페이지 테이블 트리에 필요한 메모리가 가능한 한 작을수록 성능과 확장성에 좋다. 이상적인 경우는 사용 중인 메모리를 가상 주소 공간에서 서로 가깝게 배치하는 것이다. 실제 물리 주소는 중요하지 않다. 작은 프로그램은 각 2, 3, 4단계에서 하나의 디렉터리와 몇 개의 1단계 디렉터리만으로 충분할 수 있다. x86-64에서 4kB 페이지와 디렉터리당 512개 엔트리를 가정하면, 총 4개의 디렉터리(각 단계별 1개)로 2MB를 주소 지정할 수 있다. 연속된 1GB 메모리는 2~4단계에 각 1개 디렉터리와 1단계에 512개 디렉터리로 주소 지정할 수 있다.
하지만 모든 메모리가 연속적으로 할당될 수 있다고 가정하는 것은 너무 단순하다. 유연성을 위해, 대부분의 경우 프로세스의 스택과 힙 영역은 주소 공간의 거의 반대쪽 끝에 할당된다. 이렇게 하면 필요할 경우 어느 쪽이든 최대한 성장할 수 있다. 이는 2단계 디렉터리가 아마도 두 개 필요하고 그에 상응하여 더 많은 하위 단계 디렉터리가 필요함을 의미한다.
그러나 이것마저도 요즘의 관행과 항상 맞지 않는다. 보안상의 이유로 실행 파일의 다양한 부분(코드, 데이터, 힙, 스택, DSO, 즉 공유 라이브러리)은 무작위화된 주소에 매핑된다 [nonselsec]. 무작위화는 각 부분의 상대적 위치까지 확장된다. 즉, 프로세스에서 사용 중인 다양한 메모리 영역이 가상 주소 공간 전역에 폭넓게 분포한다는 뜻이다. 무작위화에 사용하는 주소 비트 수에 제한을 두어 범위를 좁힐 수는 있지만, 대부분의 경우 2단계와 3단계에 대해 한두 개 디렉터리만으로 프로세스를 실행하는 것은 허용되지 않을 것이다.
성능이 보안보다 훨씬 더 중요하다면 무작위화를 끌 수 있다. 그러면 OS는 보통 최소한 모든 DSO를 가상 메모리에서 연속적으로 적재한다.
페이지 테이블의 모든 데이터 구조는 주 메모리에 보관된다. OS가 테이블을 구성하고 갱신하는 곳이기도 하다. 프로세스를 생성하거나 페이지 테이블이 변경되면 CPU에 이를 알린다. 페이지 테이블은 위에서 설명한 페이지 테이블 워크를 통해 모든 가상 주소를 물리 주소로 해석하는 데 사용된다. 더 구체적으로 말하면, 가상 주소를 해석하는 과정에서 각 단계마다 최소 한 개의 디렉터리가 사용된다. 이는 최대 네 번의 메모리 접근(실행 중인 프로세스의 단일 접근에 대해)을 요구하므로 느리다. 이러한 디렉터리 테이블 엔트리를 일반 데이터처럼 취급해 L1d, L2 등의 캐시에 넣을 수는 있지만, 그래도 여전히 너무 느리다.
가상 메모리의 초기부터 CPU 설계자들은 다른 최적화를 사용해왔다. 간단한 계산만으로도 디렉터리 테이블 엔트리를 L1d 및 상위 캐시에만 넣어두면 끔찍한 성능이 나온다는 것을 알 수 있다. 절대 주소 계산마다 페이지 테이블 깊이에 해당하는 수의 L1d 접근이 필요하다. 이 접근들은 이전 조회 결과에 의존하므로 병렬화할 수 없다. 이 사실만으로도, 페이지 테이블이 4단계인 머신에서는 최소한 12 사이클이 필요하다. 여기에 L1d 미스 확률까지 더해지면 명령 파이프라인으로 숨길 수 있는 수준이 아니다. 추가적인 L1d 접근은 캐시 대역폭도 빼앗는다.
그래서 디렉터리 테이블 엔트리만 캐시하는 대신, 물리 페이지 주소 계산 전체의 결과를 캐시한다. 코드와 데이터 캐시가 효과적인 것과 같은 이유로, 이렇게 주소 계산을 캐시하는 것이 효과적이다. 가상 주소의 페이지 오프셋 부분은 물리 페이지 주소 계산에 관여하지 않으므로, 캐시의 태그로는 가상 주소의 나머지 부분만 사용한다. 페이지 크기에 따라 수백 또는 수천 개의 명령이나 데이터 객체가 동일한 태그(따라서 동일한 물리 주소 접두부)를 공유한다.
이렇게 계산된 값을 저장하는 캐시를 TLB(Translation Look-Aside Buffer)라 한다. 이는 매우 빠르게 동작해야 하므로 보통 작은 캐시다. 현대 CPU는 다른 캐시와 마찬가지로 다단계 TLB 캐시를 제공한다. 상위 레벨 캐시는 더 크고 느리다. L1 TLB의 작은 크기는 캐시를 완전 연관(fully associative)으로 만들고 LRU 제거 정책을 적용함으로써 보완되는 경우가 많다. 최근에는 이 캐시의 크기가 커지면서 세트 연관(set associative)으로 변경되기도 했다. 그 결과, 새 항목을 추가해야 할 때 항상 가장 오래된 항목이 제거되는 것은 아니다.
앞서 언급했듯이, TLB에 접근할 때 사용하는 태그는 가상 주소의 일부다. 태그가 캐시에 일치하면, 최종 물리 주소는 가상 주소의 페이지 오프셋을 캐시된 값에 더해 계산된다. 이는 매우 빠른 과정이어야 한다. 모든 절대 주소를 사용하는 명령마다, 그리고 경우에 따라 물리 주소를 키로 사용하는 L2 조회에도 물리 주소가 필요하기 때문이다. TLB 조회가 실패하면 프로세서는 페이지 테이블 워크를 수행해야 하며, 이는 상당히 비용이 클 수 있다.
소프트웨어나 하드웨어를 통한 코드/데이터 프리페치는, 주소가 다른 페이지에 있는 경우 TLB 항목들을 묵시적으로 프리페치할 수도 있다. 그러나 하드웨어 프리페치에서는 이를 허용할 수 없다. 하드웨어가 유효하지 않은 페이지 테이블 워크를 시작할 수 있기 때문이다. 따라서 프로그래머는 하드웨어 프리페치가 TLB 엔트리를 프리페치해줄 것이라고 기대할 수 없다. 이는 프리페치 명령을 사용해 명시적으로 해야 한다. 데이터/명령 캐시와 마찬가지로 TLB도 여러 레벨로 나타날 수 있다. 또한 데이터 캐시와 마찬가지로, TLB는 보통 ITLB(Instruction TLB)와 DTLB(Data TLB)라는 두 가지 형태로 제공된다. L2 TLB와 같은 상위 레벨 TLB는 보통 통합(unified)되어 있으며, 다른 캐시들과 동일하다.
4.3.1 TLB 사용 시 주의점
TLB는 프로세서 코어의 전역 자원이다. 프로세서 코어에서 실행되는 모든 스레드와 프로세스는 동일한 TLB를 사용한다. 가상 주소를 물리 주소로 변환하는 것은 어떤 페이지 테이블 트리가 설치되어 있는지에 의존하므로, 페이지 테이블이 바뀌면 CPU는 캐시된 엔트리를 무턱대고 재사용할 수 없다. 각 프로세스(같은 프로세스의 스레드는 제외)는 서로 다른 페이지 테이블 트리를 가지며, 커널과 VMM(하이퍼바이저)도 존재한다면 마찬가지다. 또한 프로세스의 주소 공간 레이아웃이 변경될 수도 있다. 이 문제를 해결하는 방법은 두 가지다.
첫 번째 경우, 컨텍스트 스위치가 수행될 때마다 TLB가 플러시된다. 대부분의 OS에서 한 스레드/프로세스에서 다른 스레드/프로세스로 전환하려면 커널 코드를 실행해야 하므로, TLB 플러시는 커널 주소 공간으로 들어가고 나올 때로 제한된다. 가상화된 시스템에서는 커널이 VMM을 호출할 때와 돌아올 때도 발생한다. 커널 및/또는 VMM이 가상 주소를 사용하지 않거나, 시스템/VMM 호출을 수행한 프로세스나 커널과 동일한 가상 주소를 재사용할 수 있다면, 커널 또는 VMM을 떠난 뒤 프로세서가 다른 프로세스나 커널을 재개할 때만 TLB를 플러시하면 된다.
TLB 플러시는 효과적이지만 비용이 크다. 예를 들어 시스템 콜을 실행할 때, 커널 코드는 수천 개의 명령으로 제한될 수 있고, 어쩌면 몇 개의 새로운 페이지만(또는 일부 아키텍처에서의 리눅스처럼 하나의 큰 페이지) 건드릴 수 있다. 이 작업은 접근한 페이지 수만큼의 TLB 엔트리를 대체할 뿐이다. Intel Core2 아키텍처에서는 ITLB 128개, DTLB 256개 엔트리가 있는데, 전체 플러시는 각각 100개와 200개가 넘는 엔트리를 불필요하게 비워버린다. 시스템 콜이 같은 프로세스로 돌아오면, 플러시된 TLB 엔트리는 다시 사용할 수 있었겠지만 이미 사라진 상태다. 커널이나 VMM에서 자주 사용되는 코드에 대해서도 마찬가지다. 커널에 들어갈 때마다 TLB를 처음부터 채워야 하는데, 커널과 VMM의 페이지 테이블은 보통 바뀌지 않으므로 이론적으로는 TLB 엔트리를 매우 오래 보존할 수 있다. 이것은 또한 오늘날의 프로세서에서 TLB 캐시가 더 커지지 않는 이유를 설명해준다. 프로그램은 대부분 모든 엔트리를 가득 채울 만큼 오래 같은 주소 공간을 달리지 않기 때문이다.
물론 CPU 아키텍트들이 이 사실을 놓칠 리 없다. 캐시 플러시를 최적화하는 한 가지 가능성은 TLB 엔트리를 개별적으로 무효화하는 것이다. 예를 들어, 커널 코드와 데이터가 특정 주소 범위에 속한다면, 그 범위에 속하는 페이지만 TLB에서 퇴출(evict)하면 된다. 이는 태그 비교만 필요하므로 그리 비싸지 않다. 이 방법은 주소 공간의 일부가 변경되는 경우(예: munmap 호출)에도 유용하다.
훨씬 더 나은 해결책은 TLB 접근에 사용하는 태그를 확장하는 것이다. 가상 주소의 일부에 더해, 각 페이지 테이블 트리(즉, 프로세스의 주소 공간)에 대한 고유 식별자를 추가하면, TLB를 전혀 완전히 플러시할 필요가 없다. 커널, VMM, 개별 프로세스는 모두 고유 식별자를 가질 수 있다. 이 방식의 유일한 문제는 TLB 태그에 사용할 수 있는 비트 수가 심각하게 제한되어 있는 반면, 주소 공간의 수는 그렇지 않다는 점이다. 즉, 식별자 재사용이 필요하다. 이런 일이 발생하면 TLB를 부분적으로 플러시(가능한 경우)해야 한다. 재사용되는 식별자를 가진 모든 엔트리를 플러시해야 하지만, 희망컨대 이는 훨씬 작은 집합일 것이다.
이 확장된 TLB 태깅은 시스템에서 여러 프로세스가 동시에 실행될 때 일반적으로 이점이 있다. 실행 가능한 각 프로세스의 메모리 사용(따라서 TLB 엔트리 사용)이 제한되어 있다면, 해당 프로세스가 다시 스케줄될 때 최근에 사용한 TLB 엔트리가 여전히 TLB에 남아 있을 가능성이 높다. 하지만 추가적인 이점이 두 가지 더 있다.
일부 프로세서는 오래전부터 이러한 확장 태그를 구현해 왔다. AMD는 Pacifica 가상화 확장과 함께 1비트 태그 확장을 도입했다. 이 1비트 ASID(Address Space ID)는 가상화 맥락에서 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개의 연속된 빈 페이지가 있는 영역을 찾아야 하기 때문이다. 시스템이 한동안 실행되어 물리 메모리가 단편화된 후에는 이는 극히 어렵거나 불가능할 수 있다.
리눅스에서는 따라서 시스템 시작 시점에 특별한 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이 제공하는 가상화에서, 준가상화(para-virtualization)든 하드웨어(=완전) 가상화든 관계없이 활용된다. 게스트 도메인은 각 프로세스에 대한 페이지 테이블 트리를 구성하는데, 이는 의도적으로 준가상화와 하드웨어 가상화 모두와 상당히 유사하다. 게스트 OS가 페이지 테이블을 수정할 때마다 VMM이 호출된다. 그러면 VMM은 게스트 도메인의 갱신된 정보를 사용해 자신의 섀도우 페이지 테이블을 갱신한다. 이 섀도우 페이지 테이블이 실제로 하드웨어에 의해 사용되는 테이블이다. 분명히, 이 과정은 꽤 비싸다. 페이지 테이블 트리의 각 수정이 VMM 호출을 요구하기 때문이다. 가상화 없이도 메모리 매핑 변경은 저렴하지 않은데, 이제는 더 비싸진다.
게다가 게스트 OS에서 VMM으로, 다시 되돌아오는 전환 자체도 꽤 비싸다는 점을 고려하면 추가 비용은 정말로 클 수 있다. 그래서 프로세서들이 섀도우 페이지 테이블 생성 자체를 피할 수 있도록 추가 기능을 갖추기 시작했다. 이는 속도 문제뿐 아니라 VMM의 메모리 소비를 줄이는 데에도 유리하다. Intel은 EPT(Extended Page Tables), AMD는 NPT(Nested Page Tables)라고 부른다. 기본적으로 두 기술 모두 게스트 OS의 페이지 테이블이 "가상 물리 주소"를 생성하게 한다. 그런 다음 이 주소는 도메인별 EPT/NPT 트리를 사용해 실제 물리 주소로 추가 변환되어야 한다. 이를 통해 메모리 처리는 대부분의 VMM 진입이 제거되므로 가상화가 없는 경우에 가까운 속도로 이루어질 수 있다. 또한 이제 각 도메인당(프로세스당이 아니라) 하나의 페이지 테이블 트리만 유지하면 되므로 VMM의 메모리 사용량도 줄어든다.
추가적인 주소 변환 단계의 결과도 TLB에 저장된다. 즉, TLB는 가상 물리 주소가 아니라 조회의 완전한 결과를 저장한다는 뜻이다. AMD의 Pacifica 확장이 각 진입마다 발생하는 TLB 플러시를 피하기 위해 ASID를 도입했다는 것을 이미 설명했다. 초기 릴리스에서 ASID 비트 수는 1비트이며, 이는 VMM과 게스트 OS를 구분하기에는 충분하다. Intel에는 같은 목적을 위한 VPID(virtual processor ID)가 있으며, 가능한 값이 더 많다. 하지만 VPID는 각 게스트 도메인에 고정되어 있으므로, 별도의 프로세스를 표시하고 그 수준에서도 TLB 플러시를 피하는 데에는 사용할 수 없다.
주소 공간 수정마다 필요한 작업량은 가상화된 OS의 한 가지 문제다. 그러나 VMM 기반 가상화에는 다른 고유의 문제가 있다. 메모리 처리는 두 개의 레이어를 가질 수밖에 없다는 점이다. 그런데 메모리 처리는 어렵다(특히 5장에서 다룰 NUMA 같은 복잡성을 고려하면 더욱). 별도의 VMM을 사용하는 Xen 접근법은 최적의(혹은 심지어 좋은) 처리가 어렵다. 메모리 영역 발견 같은 "사소한" 일들을 포함해, 메모리 관리 구현의 모든 복잡함을 VMM에서 복제해야 하기 때문이다. OS는 완성도 높고 최적화된 구현을 가지고 있는데, 이를 중복하고 싶지는 않을 것이다.
그림 4.5: KVM 가상화 모델
이 때문에 VMM/Dom0 모델을 그 결론까지 밀어붙이는 것이 매력적인 대안이 된다. 그림 4.5는 KVM 리눅스 커널 확장이 이 문제를 어떻게 해결하려 하는지 보여준다. 하드웨어에서 직접 실행되며 모든 게스트를 제어하는 별도의 VMM은 없다. 대신 일반 리눅스 커널이 이 기능을 맡는다. 이는 시스템 메모리 관리를 위해 리눅스 커널의 완전하고 정교한 메모리 처리 기능을 그대로 사용할 수 있음을 의미한다. 게스트 도메인은 생성자들이 "게스트 모드"라고 부르는 형태로 일반 사용자 레벨 프로세스와 나란히 실행된다. 가상화 기능(준가상화 또는 완전 가상화)은 또 다른 사용자 레벨 프로세스인 KVM VMM이 제어한다. 이는 커널이 구현한 특수 KVM 디바이스를 사용해 게스트 도메인을 제어하는, 또 하나의 프로세스일 뿐이다.
이 모델이 Xen의 별도 VMM 모델에 비해 갖는 장점은, 게스트 OS가 사용될 때 여전히 두 개의 메모리 핸들러가 동작하더라도, 구현이 리눅스 커널 하나로만 충분하다는 점이다. Xen VMM 같은 또 다른 코드에 동일한 기능을 복제할 필요가 없다. 이는 작업량을 줄이고, 버그를 줄이며, 두 메모리 핸들러가 맞닿는 지점에서의 마찰도 줄일 수 있다. 베어 하드웨어에서 동작하는 외부 리눅스 커널의 메모리 핸들러와 리눅스 게스트의 메모리 핸들러가 동일한 가정 위에서 동작하기 때문이다.
전반적으로, 프로그래머는 가상화를 사용할 때 메모리 작업의 비용이 가상화를 사용하지 않을 때보다 더 높다는 사실을 인지해야 한다. 이 작업을 줄이는 모든 최적화는 가상화된 환경에서 더 큰 효과를 낼 것이다. 프로세서 설계자들은 시간이 지나면서 EPT와 NPT 같은 기술을 통해 차이를 점점 줄여나가겠지만, 완전히 사라지지는 않을 것이다.
| 이 글의 인덱스 항목 |
|---|
| GuestArticles |