이 글에서는 블록 읽기 ( Win32 기준 블록 읽기 ) 방식과 meory mapped 파일 두 방법의 장단점을 비교하려한다.
메모리 mapped 파일과 연관이 있는 메모리 mapped IO에 대해서 알고 싶다면 이 글을 읽기바란다.
간단하게 이 셋을 요약해보자.
크게 보면 블록 읽기과 File Stream 방식은 매우 비슷한 방법이다..
우선 Win32의 블록 읽기 방식에 대해 알아보자.
블록 읽기 방식으로 파일을 읽으려는 경우 우선 DMA를 통해 디스크의 데이터를 시스템 메모리 ( DRAM )으로 읽어와야한다.
여기서 중요한 것은 이때 유저 영역 ( 프로그램 영역 ) 메모리 공간에 디스크의 파일 데이터를 쓰는 것이 아니라 커널 영역의 메모리 공간에 쓴다. 왜냐하면 유저 영역에 데이터를 써버리면 데이터를 쓰는 동안 프로세스가 그 영역에 다른 데이터틀 동시에 쓸 수도 있기 때문이다. 그래서 우선은 디스크에서 DRAM의 커널 영역 메모리 공간 ( 버퍼 )에 임시로 데이터를 쓰고, 그 후 유저 모드 프로세스가 커널 모드 메모리 영역에 저장되어 있는 데이터를 유저 메모리 영역으로 복사해온다. 디스크에 있는 파일 데이터가 총 두 번의 복사를 걸쳐 유저 메모리 영역에 도착한 것이다. ( 밑에서 말하겠지만 해당 파일 데이터가 페이지 캐시에 있는 경우 디스크에서 데이터를 가져오지 않고 이 페이지 캐시된 데이터를 유저 메모리 영역으로 가져간다. )
여기서 블록 읽기은 파일의 모든 데이터를 유저 모드로 가져온 후 프로세스에 알려준다. ( 동기식이든 비동기식이든 ) ( 소프트웨어 인터럽트든, 콜백함수를 통해서든 ),
BOOL ReadFile(
HANDLE hFile,
LPVOID lpBuffer,
DWORD nNumberOfBytesToRead,
LPDWORD lpNumberOfBytesRead,
LPOVERLAPPED lpOverlapped
);
위의 코드는 블록 읽기 방식의 WIN32 파일 읽기 함수로 유저 메모리 영역에 프로그래머가 임의로 정한 사이즈의 버퍼를 미리 할당해두고 그 사이즈만큼의 데이터만을 파일에서 읽어온다.
참고로 파일 스트림은 블록 읽기 방식의 한 종류로 파일의 데이터를 조금 조금씩 가져온다는 점에서 ReadFile 방식과 차이가 있다. 조금씩 데이터를 가져올 때마다 유저 프로세스에게 알려준다.
반면 Memory mapped 파일 방식은 위의 두 방식과 완전히 다른 방식이다.
메모리 맵 파일은 간단히 말하면 파일의 데이터들을 가상 주소로 페이징해서 페이지 테이블로 관리하는 것이다.
기본적으로는 파일들이 페이지 아웃되어 있고 읽으려고 하면 메모리로 페이지 in해서 사용한다. ( 필요할 때만 메모리로 읽어온다는 것이다! ) 또한 페이지 아웃될 때 페이지가 dirty한 경우 파일에 쓰기 동작을 수행한다.
그리고 페이지 in된 페이지 데이터는 메모리에서 페이지 캐시라는 특별한 영역에 저장된다. 이 페이지 캐시는 OS가 항상 다른 데이터를 할당하지 않고 메모리 맵 파일을 위해 비워두는 영역인데 disk로부터 페이징했던 페이지는 항상 이 곳에 저장된다.
그래서 메모리 맵 파일는 우선 디스크에서 이 페이지 캐시로 데이터를 복사하고 ( 아마 DMA가 할 것이다 ), 그럼 유저 프로세스는 이 페이지 캐시의 Physical 페이지를 자신의 가상 주소 페이지 테이블에 참조(!!!)해 넣으면 된다. ( 여기서 중요한 것은 유저 프로세스가 페이지 캐시에서 Physical 페이지를 자신의 유저 Physical 공간으로 복사해가는게 아니라 그냥 페이지 캐시의 Physical 페이지에 대한 참조를 자신의 페이지 테이블에 추가한다는 것이다. 복사가 아니다. 이 경우 커널 모드로의 전환이 필요 없다!!!! )
( 반면 블록 읽기 방식은 디스크에서 우선 커널 메모리 영역의 버퍼로 한번 복사를 하고, 그 후 유저 프로세스가 다시 이 커널 메모리 영역의 버퍼에서 자신의 유저 메모리 영역으로 복사를 한번 더 해야한다. 두번 복사를 하는 것이다.)
또한 메모리 맵 파일 방식은 파일을 메모리로 보기 때문에 ( CPU 입장에서는 그냥 메모리 맵 파일도 메모리일 뿐이다. ), 컴파일러의 vectorization 최적화나 SIMD, prefetch 등등의 메모리와 똑같이 여러 최적화 기법을 적용할 수 있다.
메모리 맵 파일은 그냥 메모리랑 똑같기 때문에 페이징된 파일의 물리적 주소를 얻는 것을 도와주는 TLB의 성능에 많은 영향을 받는다. TLB가 커야 메모리 맵 파일 접근도 빠르다.
이러한 메모리 맵 파일의 특성 덕분에 만약 엄청나게 큰 파일을 읽을 때도 해당 파일 전체를 한번에 다 메모리로 복사하는 것이 아니라 페이지에 접근할 때마다 해당 페이지만 페이지 인 하면 되기 때문에 처음 셋팅을 하는 시간을 단축할 수 있다. ( 파일 전체 접근하는 경우 결국에는 전부 페이지 인해야하니 결국 처음에 엄청 느리느냐, 느린 동작을 여러 시간에 걸쳐 분배하느냐의 차이인 것 같다. )
그리고 하나의 파일에 대해 여러 프로세스들이 자신의 가상 주소로 페이징을 해서 여러 프로세스가 하나의 파일을 공유해서 사용할 수 있다. 당연히 프로세스간 데이터 동기화도 된다.
메모리 맵 파일는 매번 페이지 in, 페이지 out이 발생할 수 있는데, 페이지 폴트는 블록킹 IO 작업이기 떄문에 해당 스레드의 CPU 점유를 빼앗기게된다.
페이지 폴트 발생시 MMU는 페이지 폴트 트랩 ( 가상 주소의 페이지가 in되어 있지 않았을 때 발생하므로 소프트웨어 exception인 trap이다. 인터럽트는 하드웨어가 발생시키는 것 )을 발생시키고 CPU의 제어권은 OS ( 커널 )로 넘어간다. OS는 페이지 폴트 핸들러를 통해 IO 통신으로 ( DMA를 통한 ) 페이지 IN을 명령하고 CPU를 다른 다른 스레드로 넘긴다.
반면 블록 읽기 방식은 데이터의 버퍼 사이즈마다 시스템 콜 한번이 필요하니 시스템 콜 호출 횟수는 상대적으로 적다. ( 커널 모드쪽 버퍼를 가져오기 위해 시스템 콜로 커널 모드 진입해야함. )
메모리 맵 파일는 우선 맨 처음 호출할 때 IO의 모든 데이터에 대해 프로세스의 가상 주소의 페이지 테이블에 페이징을 연결해주어야함. 초기 비용이 크다는 것이다. 해당 IO 데이터에 대한 매핑을 해제할 때도 페이지 테이블에서 페이징된 데이터를 모두 해제해주어야하니 비용이 크다. 반면 블록 읽기 방식 방식은 프로그램에 할당해둔 버퍼에 대해서만 페이징이 적용된다.
극닥적으로 두 방법의 성능차를 드러내는 예를 들어보자. 4095 사이즈의 레코드 10개를 가진 파일에 대해 랜덤으로 딱 2개의 레코드만을 읽는다고 생각해보자. ( 여기서 주목할 것은 4095라는 사이즈이다!!, 참고로 여기서 페이지의 사이즈는 4096byte(!)로 전제한다. 64KB인 경우도 있다. )
메모리 맵 파일의 경우에는 하나의 4095사이즈의 레코드가 2개의 페이지를 사용한다……. ( 2번째 레코드는 첫번째 레코드의 페이지 딱 1바이트에 걸치기 때문에 결국 2개의 페이지에 걸쳐있는 것이다!…. 이럴수가….. ) 그럼 하나의 레코드에 접근을 할 때마다 두번의 페이지 폴트가 발생한다는 것이다….. 하나의 레코드마다 두번의 페이지 폴트가 생기니 시스템 콜도 2번이다…… 얼마나 비효율적인가…… 또한 가상 주소 영역 페이지에 접근하다보니 TLB도 읽는 파일의 페이지로 덮는다.
반면 블록 읽기 방식 방식의 경우 유저 메모리 영역에 필요한 레코드 개수인 2개의 레코드만큼만 메모리를 서로 다른 페이지에 할당받은 후 ( 페이지 사이즈에 align되게 ) 2개의 레코드를 각각 다른 ReadFile을 통해 파일에서 읽어온다. 최종적으로는 하나의 레코드당 하나의 페이지만을 사용한 것이다. 2개의 레코드를 읽어오는데 오직 2번의 시스템 콜만 사용된 것이다.
일반적으로는 메모리 맵 파일 방식이 더 빠르지만, 경우에 따라 블록 읽기 방식이 더 빠를 수도 있다.
계속해서 파일 데이터에 랜덤하게, 광범위하게 접근하는 경우에도 메모리 맵 파일가 빠르다. ( 접근하는 데이터의 페이지만 콕 찝어 페이지 in이 되니 빠르다. ) 반면 연속적으로 페이지 전체 파일 데이터에 접근하는 경우 블록 읽기 방식이 더 빠르다. 페이지 in하는 것도 결국에는 비용이니 연속해서 파일 전체 데이터에 접근하는 경우 차라리 블록 읽기 방식이 빠르다.
메모리 맵 파일는 페이징되어 접근된 데이터를 캐싱할 수 있다. 그러니 오랫 동안 자주 자주 접근하는 데이터는 당연히 캐싱되어 있을 것이고 빠르게 접근할 수 있다. 그래서 IO 데이터에 여러번 접근할 필요가 있는 경우 메모리 맵 파일가 유리하다.
메모리 맵 파일은 당연히도 프로세스의 가상 주소 영역에 페이지를 페이징하니 가상 주소 영역에서 그 만큼 공간을 차지하게 된다. 예전에는 문제가 됬지만 지금과 같이 64bit 환경에서 가상 주소 공간이 128테라바이트나 되니 가상 주소 공간이 부족할 일은 없으니 걱정할 필요없다.
Memory mapped 파일 방식이 항상 더 빠를 것 같지만 그렇지는 않고 파일의 데이터에 접근하는 패턴에 따라 어떤 방식으로 파일을 읽을지 결정해야한다.( 이 글에서도 Memory mapped 파일 방식이 항상 빠르지만은 않다는 것을 보여준다. )
Linus Torvalds의 글에 Memory mapped 방식의 장단점이 잘 나와있다.
https://stackoverflow.com/questions/57813999/understanding-memory-mapping-conceptually, https://stackoverflow.com/questions/48994329/is-memory-mapped-i-o-worthwhile-for-sequential-processing, https://stackoverflow.com/questions/45972/mmap-vs-reading-blocks, https://marc.info/?l=linux-kernel&m=95496636207616&w=2