본문 바로가기
programming

가상 메모리와 mmap

by AtoN 2023. 9. 13.

가상 메모리와 mmap

큰 GGUF 파일을 mmap으로 열면 파일 전체를 먼저 사용자 공간의 버퍼로 읽어 복사하지 않고도 파일을 프로세스의 가상 주소 공간에 연결할 수 있습니다. 물리 RAM보다 큰 파일도 주소 공간이 허용한다면 매핑 자체는 가능하지만, 실제 추론이 원활하게 동작하는지는 working set의 크기와 page cache, swap, storage 속도, GPU offload 같은 조건에 달려 있습니다.

이것이 가능한 이유는 운영체제가 프로세스가 보는 가상 주소와 실제 물리 메모리를 분리해서 관리하기 때문입니다. 프로세스에는 큰 연속 주소 공간이 보이지만, 파일-backed mapping의 모든 페이지가 처음부터 RAM에 올라오는 것은 아닙니다. 실제로 접근한 페이지가 필요해지면 page fault를 통해 page cache에 있는 데이터를 연결하거나, 아직 없다면 저장장치에서 읽어 온 뒤 해당 가상 주소에 매핑합니다.

그래서 mmap의 핵심 이점은 파일 전체를 미리 읽어 별도의 사용자 버퍼에 복사할 필요가 없고, 실제로 필요한 페이지를 운영체제의 가상 메모리와 page cache를 통해 접근할 수 있다는 것입니다. 다만 mmap 자체가 디스크에서 읽어야 할 총 데이터 양을 항상 줄이는 것은 아닙니다. 결국 파일 전체를 순차적으로 모두 접근한다면 대부분의 페이지를 읽게 되고, 반대로 일부 영역만 사용한다면 eager full read보다 실제 I/O를 줄일 수 있습니다.

가상 주소가 물리 페이지와 어떻게 연결되는지부터 시작해 page fault와 page cache, swap이 어떤 역할을 하는지 살펴봅니다. 이어서 read()로 파일을 직접 읽는 방식과 mmap이 무엇이 다른지 비교하고, 마지막에는 운영체제의 paging에서 아이디어를 가져와 KV cache를 고정 크기 block으로 관리하는 PagedAttention 같은 LLM serving 기법과도 연결해서 봅니다.


1장. 가상 메모리가 생긴 배경

물리 주소를 직접 쓰던 시절에는 문제가 셋이었습니다.

① 프로그램이 물리 메모리보다 크면 못 돌린다
② 프로그램끼리 서로의 메모리를 밟는다
③ 어디에 로드될지 미리 알 수 없어 주소를 고정할 수 없다

한 겹 속이는 해결책. 프로그램에게는 0부터 시작하는 연속된 주소 공간을 주고, 실제 물리 주소로의 변환은 하드웨어가 몰래 처리합니다.

가상 메모리는 이 셋을 한 번에 풉니다.

가상 메모리가 주는 것 그래서 가능해지는 것
물리 RAM보다 큰 주소 공간을 표현할 수 있다 모든 page를 동시에 RAM에 둘 필요가 없다
프로세스마다 페이지 테이블이 다르다 남의 메모리에 접근할 수 없다
가상 주소와 물리 위치를 분리한다 프로그램이 물리 RAM의 실제 배치를 직접 관리하지 않아도 된다

추상화 한 겹으로 세 문제를 동시에 푼 사례입니다.


2장. 페이징

주소 변환. 주소 공간을 페이지라는 고정 크기 조각으로 나눕니다. Linux x86-64에서 4KB base page가 흔하지만 시스템은 huge page를 쓸 수도 있고, 아키텍처별 page size도 다릅니다.

전통적인 4-level x86-64 예시에서는 canonical virtual address의 하위 48비트를 사용하고 4KB page라면 12비트 offset이 됩니다. 최신 x86-64는 5-level paging(LA57)으로 더 넓은 virtual address를 지원할 수도 있습니다.

4KB page를 쓰는 48-bit 예시

   [ 페이지 번호 36bit ][ 오프셋 12bit ]
        │                    │
        │                    └──►  오프셋은 그대로 사용
        │
        └──►  페이지 테이블 조회  ──►  프레임 번호

   [ 프레임 번호 ][ 오프셋 ]

물리 주소

4KB는 2^12 byte이므로 페이지 내부 offset에 12비트가 필요합니다.

다단계 페이지 테이블. 거대한 단일 테이블 대신 계층 구조로 필요한 부분만 할당합니다. 전통적인 x86-64 48-bit 구성은 4단계를, LA57 구성은 5단계를 사용합니다. TLB miss가 나면 page walk 비용이 생기지만 CPU에는 page-walk cache 등 추가 최적화도 있습니다.

TLB. 최근 주소 변환을 캐싱합니다. TLB hit이면 page-table walk를 피할 수 있고, miss이면 하드웨어가 페이지 테이블을 따라가야 합니다. 주소 공간이 바뀌면 TLB locality가 영향을 받지만 PCID/ASID 같은 기능 때문에 컨텍스트 스위칭마다 전체 TLB를 무조건 비우는 것은 아닙니다.

Huge Page. 4KB 대신 2MB나 1GB 페이지를 씁니다. TLB 엔트리 하나가 더 넓은 영역을 덮어 미스가 줄고, 큰 모델 가중치처럼 연속된 대용량 영역을 훑는 워크로드에서 이득이 있습니다. 대신 내부 단편화가 늘고 설정이 필요합니다.


3장. 페이지 폴트

요구 페이징. 프로세스가 시작할 때 모든 메모리를 물리 RAM에 올리지 않고, 접근할 때 올립니다.

페이지 폴트는 두 종류입니다.

종류 상황 비용
Minor fault disk I/O 없이 처리 가능한 fault. page table 갱신이나 이미 cached된 file page 연결 등 상대적으로 저렴
Major fault backing storage에서 page를 읽어와야 하는 fault storage I/O가 포함돼 훨씬 비쌀 수 있음

실제 지연은 storage, page cache, NUMA 상태와 커널에 따라 크게 달라지므로 고정된 마이크로초/밀리초 배수로 외우지 않는 편이 안전합니다.

공유의 이득. 같은 파일을 여러 프로세스가 file-backed mmap하면 clean file page는 page cache를 통해 공유될 수 있습니다. 해당 page가 cache에 남아 있다면 다른 프로세스의 첫 접근은 storage I/O 없이 처리될 수 있지만, cache에서 축출됐다면 다시 major fault가 날 수 있습니다.


4장. mmap

일반적인 파일 읽기는 이렇게 씁니다.

int fd = open("model.gguf", O_RDONLY);
char* buf = malloc(size);
read(fd, buf, size);        // 커널에서 유저 공간으로 전체 복사

두 가지 비용이 발생합니다. 실제로 안 쓰는 부분까지 전체를 읽고, 디스크에서 페이지 캐시로 다시 유저 버퍼로 복사가 일어납니다.

mmap으로 바꾸면 이렇게 됩니다.

int fd = open("model.gguf", O_RDONLY);
void* addr = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
// 보통 주소 공간 매핑을 먼저 만든다. 실제 page는 접근 시 fault로 연결될 수 있다

주소 공간에 매핑만 걸고 끝납니다. 실제 데이터는 그 주소에 접근할 때 페이지 폴트가 나면서 페이지 단위로 올라옵니다.

둘을 나란히 놓으면 차이가 분명합니다.

read() mmap()
초기 비용 이 예제처럼 size 전체를 read하면 전체 복사 mapping metadata 설정. 실제 page I/O는 지연될 수 있음
별도 사용자 버퍼 복사 read()가 page cache의 내용을 user buffer로 복사 file page를 주소 공간에 매핑하므로 별도 full-size user buffer 복사를 피할 수 있음
안 쓰는 부분 요청한 read 범위는 읽음 접근하지 않은 page는 실제로 읽지 않을 수 있음
프로세스 간 공유 각자 복사본 페이지 캐시 공유
랜덤 접근 명시적 pread/버퍼링 전략으로 제어 가능 fault와 readahead 패턴에 따라 유리하거나 불리할 수 있음

자주 쓰는 플래그입니다.

플래그 의미
MAP_PRIVATE 쓰기가 원본 파일에 반영되지 않는다 (copy-on-write)
MAP_SHARED 쓰기가 파일에 반영되고 다른 프로세스에도 보인다
MAP_POPULATE Linux에서 mapping 시 page-table population/read-ahead를 적극 수행하는 힌트. 모든 page resident를 영구 보장하는 것은 아님
MAP_ANONYMOUS 파일 없이 메모리만 매핑한다

모델 가중치는 읽기만 하므로 PROT_READ 와 MAP_PRIVATE 조합이 흔합니다.


5장. 모델 로딩이 빨라지는 이유

로딩 경로의 변화. 수백 MB 가중치 파일을 전부 읽어 버퍼에 담는 대신 매핑만 하고 실제 추론에서 건드리는 부분만 올라옵니다. 앱 시작 시점의 체감 로딩 시간이 크게 줄고, 로컬 추론 엔진들이 가중치 파일을 mmap으로 여는 이유이기도 합니다.

정확히 말해야 할 것. 파일 전체를 결국 접근하면 storage에서 읽어야 하는 데이터 양은 비슷해질 수 있습니다. 반대로 일부 page만 쓰면 demand paging 덕분에 실제 I/O 자체가 줄 수도 있습니다. mmap이 주로 줄이거나 뒤로 미룰 수 있는 비용은 다음과 같습니다.

① 첫 응답까지의 시간
② 중복 복사 비용
③ 여러 프로세스가 같은 모델을 쓸 때의 물리 메모리

MoE에서 기대할 수 있는 이점. Expert weight가 파일에서 분리된 page 범위에 놓이고 엔진이 필요 expert만 실제 접근한다면, MoE의 sparse activation은 일부 expert page를 늦게 fault-in하게 만들 수 있습니다. Ling-3.0-tiny는 128개 routed expert 중 토큰당 8개를 선택합니다. 다만 tensor layout, kernel의 prefetch/packing, GPU offload 방식에 따라 전체 weight를 미리 읽는 구현도 있으므로 mmap만으로 자동 보장되는 성질은 아닙니다.

반대로 불리한 경우. 랜덤 접근이 심하고 페이지 캐시가 부족하면 major fault가 계속 나서 오히려 느려집니다. 그리고 페이지를 그때 올리기 때문에 첫 추론이 느립니다. MAP_POPULATE 로 미리 올리거나 워밍업 추론을 한 번 돌려 대응합니다.


6장. LLM 서빙으로 옮겨진 발상

PagedAttention. vLLM 논문의 문장입니다.

운영체제의 고전적 가상 메모리와 페이징 기법에서 영감을 받은 어텐션 알고리즘

OS의 개념이 그대로 대응됩니다.

OS vLLM
프로세스 요청
가상 주소 공간 논리 KV 캐시
페이지 KV 블록
페이지 테이블 블록 테이블
물리 프레임 GPU 메모리 블록

같은 문제, 같은 해법. OS는 프로세스마다 연속된 물리 메모리를 요구하면 단편화로 메모리를 못 쓰는 문제를 안고 있었고, vLLM은 요청마다 연속된 KV 공간을 잡으면 단편화로 배치 크기가 제한되는 문제를 안고 있었습니다. 해법도 같습니다. 고정 크기 블록으로 쪼개고 간접 매핑을 둡니다.

공유 방식의 대응. OS가 같은 파일을 여러 프로세스에 페이지 캐시로 공유하듯, vLLM은 같은 프롬프트 앞부분을 여러 요청에 KV 블록으로 공유합니다. prefix caching이 바로 이것입니다.


7장. 실무에서 보는 것

페이지 폴트는 이렇게 확인합니다.

/usr/bin/time -v ./my_program
Major (requiring I/O) page faults
Minor (reclaiming a frame) page faults

major fault가 많다는 것은 backing storage에서 page를 가져오는 fault가 자주 발생한다는 신호입니다. 로컬 디스크뿐 아니라 파일 시스템과 storage 계층 전체의 I/O 상태를 함께 봐야 합니다.

프로세스 메모리는 이렇게 봅니다.

cat /proc/<pid>/status     # VmRSS, VmSize
cat /proc/<pid>/smaps      # 매핑별 상세

VmSize는 프로세스의 가상 주소 공간 크기이고, VmRSS는 현재 resident한 page를 프로세스 관점에서 집계한 값입니다. File mapping을 만들면 VmSize는 크게 늘 수 있지만 모든 page가 즉시 resident해지는 것은 아닙니다. 또 shared page는 여러 프로세스의 RSS에 각각 보일 수 있으므로 실제 물리 메모리 분담을 보려면 PSS도 함께 확인하는 편이 정확합니다.

여러 워커를 띄울 때. 같은 모델 파일의 clean file-backed page는 여러 프로세스가 page cache에서 공유할 수 있어 물리 메모리 중복을 크게 줄일 수 있습니다. 하지만 각 프로세스의 page table, private heap, dirty/COW page는 별도이고 RSS는 공유 page를 각 프로세스에 중복 계상할 수 있습니다. 실제 공유량을 보려면 PSS가 더 유용합니다.

컨테이너에서. cgroup v2의 memory.current는 anonymous memory뿐 아니라 해당 cgroup에 charge된 page cache 등도 포함합니다. 따라서 “file-backed mmap은 RSS가 작으니 container limit과 무관하다”고 보면 위험합니다. memory.stat의 anon, file 항목과 PSS/RSS를 함께 보는 편이 좋습니다.


8장. 정리

가상 메모리는 프로세스에 큰 가상 주소 공간을 제공하고, 필요한 페이지를 실제 물리 메모리에 매핑합니다. mmap은 파일을 주소 공간에 연결해 필요할 때 page cache를 통해 가져오므로 초기 로딩과 중복 복사를 줄이고 여러 프로세스의 파일 페이지 공유를 가능하게 합니다. 같은 페이징 발상은 PagedAttention의 KV cache block 관리에도 영향을 주었습니다.

mmap이 항상 빠른 것은 아닙니다. 랜덤 접근이 심하고 working set이 RAM을 넘으면 major fault와 page eviction 때문에 느려질 수 있습니다. VmSize가 크다고 곧 물리 메모리를 많이 쓰는 것도 아니므로 VmRSS뿐 아니라 공유 페이지를 고려한 PSS와 cgroup memory accounting도 함께 보는 편이 정확합니다. 모델 로딩이 빨라졌다는 말 역시 주로 초기 materialization을 미룬다는 의미입니다.


용어 정리

용어 한 줄 뜻
가상 메모리 프로세스에 연속된 가상 주소 공간을 주고 물리 주소로 변환하는 구조
페이지 주소 공간을 나눈 고정 크기 조각. 리눅스 x86-64 기본 4KB
페이지 테이블 가상 페이지를 물리 프레임에 매핑하는 자료구조
TLB 최근 주소 변환 결과를 캐싱하는 하드웨어
Huge Page 2MB나 1GB 크기의 큰 페이지. TLB 미스를 줄임
요구 페이징 접근할 때 비로소 페이지를 올리는 방식
Minor fault backing storage I/O 없이 처리할 수 있는 page fault
Major fault backing storage에서 page를 읽어와야 하는 page fault
페이지 캐시 커널이 파일 내용을 캐싱하는 메모리 영역
mmap 파일을 읽지 않고 주소 공간에 매핑하는 시스템 콜
MAP_PRIVATE 쓰기가 원본에 반영되지 않는 copy-on-write 매핑
MAP_POPULATE Linux에서 mapping 시 page-table population과 read-ahead를 적극 수행하는 옵션
VmSize / VmRSS 가상 주소 공간 크기 / 현재 resident page를 프로세스 관점에서 집계한 값
PagedAttention KV 캐시에 페이징 발상을 적용한 어텐션 메모리 관리
블록 테이블 논리 KV 블록을 물리 GPU 블록에 매핑하는 표

참고자료

'programming' 카테고리의 다른 글

프로세스와 스레드 그리고 GIL  (0) 2023.05.17
HTTP와 스트리밍 (SSE)  (0) 2023.05.10
트랜잭션과 격리 수준  (0) 2023.05.09
IO 모델과 비동기  (0) 2023.03.09
[PY] line_profiler (@profile)  (0) 2023.03.04

댓글