페이지 폴트는 오류가 아니지만 프레임은 흔든다
Esc

    페이지 폴트는 오류가 아니지만 프레임은 흔든다

    –OS메모리

    프로그램이 손에 쥐는 주소는 전부 가짜입니다. 포인터를 찍어 보면 나오는 그 값은 램의 어느 자리도 가리키지 않습니다. CPU 가 그 값을 실제 주소로 번역한 다음에야 메모리에 닿습니다. 번역표가 프로세스마다 따로라서, 프로세스 A 의 0x1000 과 B 의 0x1000 은 서로 다른 자리로 갑니다.

    여기까지가 가상 메모리입니다. 이런 층이 왜 생겼는지는 가상 메모리는 램이 모자라서 나왔지만 남은 이유는 따로 있다에 적었습니다. 이 글은 그 번역이 실제로 어떻게 돌고, 빗나가면 무슨 일이 생기는지를 봅니다.

    주소는 어떻게 번역되나

    바이트마다 번역표를 만들면 표가 메모리보다 커집니다. 그래서 덩어리로 묶어서 번역합니다. 여기 나오는 이름들을 먼저 맞춰 두겠습니다.

    용어 뜻
    페이지 가상 주소 공간을 자른 덩어리입니다. 보통 4KB 이고, 애플 실리콘처럼 16KB 를 쓰는 것도 있습니다
    프레임 물리 메모리를 같은 크기로 자른 칸입니다. 페이지 하나가 프레임 하나에 들어갑니다
    페이지 테이블 페이지 번호를 넣으면 프레임 번호가 나오는 번역표입니다. 프로세스마다 따로 있고 커널이 만듭니다
    MMU 번역을 실제로 하는 CPU 안의 회로입니다
    TLB 최근 번역 결과를 담아 두는 CPU 안의 작은 캐시입니다

    페이지와 프레임은 같은 크기의 칸을 가상 쪽에서 부르는 이름과 물리 쪽에서 부르는 이름입니다.

    주소는 두 토막으로 갈립니다. 위쪽이 페이지 번호, 아래쪽이 페이지 안에서 몇 번째 바이트인지를 적은 오프셋입니다. 4KB 면 오프셋이 12비트라서, 16진수로 쓰면 아래 세 자리가 통째로 오프셋입니다.

    번역은 앞자리만 갈아 끼웁니다. 오프셋은 손대지 않고 그대로 따라갑니다.

    0x2A40          가상 주소
      │ └┬─┘
      │  └──────────  오프셋 0xA40   그대로 따라갑니다
      └─────────────  페이지 2       표에서 찾습니다
    
    페이지 2 → 프레임 3
    
    0x3A40          물리 주소

    이 번역을 CPU 안의 MMU 라는 회로가 합니다. 소프트웨어가 하는 일이 아닙니다.

    다만 번역할 때마다 페이지 테이블을 읽으면 메모리를 한 번 쓰려고 메모리를 여러 번 읽는 꼴이 됩니다. 그래서 CPU 안에 최근 번역 결과를 담아 두는 작은 캐시가 따로 있습니다. TLB 입니다. TLB 에 있으면 번역은 사실상 공짜고, 없으면 페이지 테이블을 단계별로 걸어가야 합니다.

    여기까지가 번역이 성공한 경우입니다. 값이 비싸지는 쪽은 빗나갔을 때입니다.

    번역이 빗나가면

    페이지 테이블 칸에는 프레임 번호만 있는 것이 아니라 유효 비트가 같이 있습니다. “이 페이지는 지금 물리 메모리에 올라가 있다” 를 나타내는 표시입니다.

    그 표시가 꺼져 있는 페이지를 건드리면 CPU 가 번역을 못 합니다. 그 자리에서 예외를 일으켜 OS 에게 넘깁니다. 이것이 페이지 폴트입니다.

    이름이 험해서 오해받지만 오류가 아닙니다. 주소는 멀쩡하고, 내용이 아직 물리 메모리에 없을 뿐입니다. 매핑 자체가 없는 주소를 건드리는 것은 다른 이야기이고, 그쪽이 세그멘테이션 폴트입니다.

    재미있는 것은 CPU 가 둘을 구분하지 못한다는 점입니다. 표에 프레임이 없으면 똑같이 예외를 던지고 손을 뗍니다. 불려 온 커널이 자기 기록을 보고 판단합니다. 매핑된 범위 안이면 채워 주고, 범위 밖이면 프로세스를 죽입니다.

    폴트는 CPU 가 던지는 사건이고, 처리는 커널이 합니다. 순서는 이렇습니다.

    1. 그 스레드를 멈춥니다. 멈추는 것은 그 스레드 하나뿐이고 다른 스레드는 계속 돕니다
    2. 커널이 빈 프레임 목록에서 하나 꺼냅니다. 물리 메모리를 뒤지는 것이 아닙니다. 목록이 비었으면 올라가 있는 페이지를 골라 내보내 자리를 만듭니다. 그 페이지가 수정된 상태면 먼저 저장 장치에 기록해야 합니다
    3. 그 프레임에 들어갈 내용을 채웁니다. 여기가 시간을 먹는 자리입니다
    4. 페이지 테이블의 그 줄을 고칩니다. 표 전체를 다시 쓰는 것이 아닙니다
    5. 멈췄던 그 명령부터 다시 실행합니다

    3번에서 채울 내용은 셋 중 하나입니다.

    어떤 페이지인가 무엇을 채우나
    새로 잡은 메모리 0 으로 밀어 버립니다. 램 안에서 끝납니다
    실행 코드나 에셋 파일에서 읽어 옵니다. 파일 캐시에 남아 있으면 연결만 하면 됩니다
    밀어냈던 페이지 스왑에서 읽어 옵니다

    제일 흔한 것이 첫째 줄입니다. 메모리를 할당받아도 그 순간에는 표에 줄만 생기고 프레임은 안 붙습니다. 처음 만지는 순간에 폴트가 나고, 그때 커널이 빈 프레임을 0 으로 밀어서 붙입니다. 이것을 요구 페이징이라고 합니다.

    5번이 이 구조의 핵심입니다. 프로그램은 자기가 멈췄다는 것을 모릅니다. 코드 어디에도 “메모리에 없으면 읽어 온다” 는 줄이 없는데 그 일이 일어납니다. 편한 대신, 어느 줄에서 멈출지가 코드에 안 적혀 있습니다.

    폴트는 비용이 전부 다릅니다.

    무엇 언제 비용
    마이너 폴트 내용이 이미 메모리 어딘가에 있습니다. 파일 캐시에 남아 있거나, 처음 쓰는 자리라 0 으로 채우면 끝나는 경우입니다. 매핑만 이어 주면 됩니다 커널을 한 번 다녀오는 값. 대개 1마이크로초 아래
    메이저 폴트 저장 장치에서 실제로 읽어 와야 합니다 NVMe 로도 수십에서 수백 마이크로초
    잘못된 접근 매핑 자체가 없는 주소입니다 폴트 처리가 아니라 프로세스 종료

    프로그램을 실행할 때 실행 파일 전체를 메모리에 올리지도 않습니다. 처음 건드리는 순간에 그 페이지만 올라옵니다. 이것을 요구 페이징이라고 합니다. 앱을 켠 직후나 씬에 처음 들어간 직후에 폴트가 몰리는 이유가 이것입니다.

    프레임이 모자라면

    빈 프레임 목록이 바닥나면 이야기가 하나 더 붙습니다. 붙여 줄 자리를 먼저 만들어야 합니다.

    커널은 올라가 있는 페이지 중 하나를 골라 내보냅니다. 고르는 기준은 대체로 오래 안 쓴 것입니다. 그리고 내보내는 비용이 페이지마다 다릅니다.

    • 내용이 안 바뀐 페이지는 그냥 버립니다. 원본이 파일에 있으니 나중에 다시 읽으면 됩니다
    • 내용이 바뀐 페이지는 저장 장치에 먼저 기록해야 버릴 수 있습니다

    그래서 폴트 하나가 저장 장치를 두 번 다녀오기도 합니다. 내보낼 페이지를 기록하고, 필요한 페이지를 읽어 오는 것입니다.

    내보낸 페이지를 곧바로 다시 만지면 또 폴트가 납니다. 그 상태가 이어지면 프로그램이 일은 안 하고 페이지만 들락날락시킵니다. 이것을 스래싱이라고 합니다.

    번역이 끝난 뒤 닿는 곳은 언제나 물리 메모리다

    여기서 헷갈리기 쉬운 자리를 하나 짚고 가겠습니다. 가상 메모리를 쓴다고 해서 프로그램이 저장 장치를 읽고 쓰는 것이 아닙니다.

    가상 주소 → (MMU 번역) → 물리 주소 → 램의 그 자리를 읽고 쓴다

    이 흐름에 저장 장치는 없습니다. 스왑은 램이 모자랄 때만 끼어드는 예외 경로입니다. 지금 안 쓰는 페이지를 저장 장치로 잠깐 밀어내 두었다가, 그 페이지를 다시 만지는 순간 폴트가 나서 램으로 도로 올려놓은 다음에야 프로그램이 그 값을 봅니다. 저장 장치의 값을 직접 쓰는 일은 없습니다.

    그래서 이런 오해도 같이 풀립니다. 주소 공간이 크다고 쓸 수 있는 메모리가 큰 것이 아닙니다. 64비트면 주소 공간은 사실상 무한하지만, 동시에 올려 둘 수 있는 양은 램 크기까지입니다.

    할당한 양과 실제로 쓰고 있는 양도 다릅니다. 큰 배열을 하나 잡아도 그 순간에는 표에 줄만 생기고 프레임은 만지는 페이지마다 붙습니다. 그래서 프로세스 메모리를 보면 숫자가 둘 나옵니다. 주소 공간에 잡아 둔 전체와, 실제로 램을 먹고 있는 상주 크기입니다. 앞의 것이 몇 배 큰 것이 정상입니다. 게임에서 의미가 있는 쪽은 뒤엣것입니다.

    그럼 20GB 짜리 게임이 8GB 기기에서 어떻게 도는가. 셋이 같이 일합니다.

    하나. 한 시점에 실제로 필요한 것은 전체의 일부입니다. 지금 이 방을 그리는 데 필요한 메시와 텍스처는 몇백 MB 입니다. 지금 실제로 만지는 페이지들을 워킹 셋이라고 부르고, 이것만 램에 들어가면 게임은 돕니다.

    둘. 에셋은 파일에 있고 만지는 페이지만 올라옵니다. 실행 파일과 메모리 매핑한 에셋은 원본이 저장 장치에 있으니, 커널은 안 쓰는 페이지를 그냥 버리면 됩니다. 필요해지면 다시 읽습니다. 밀어낼 자리를 따로 마련할 필요가 없어서 스왑이 없는 기기에서도 이건 됩니다.

    셋. 엔진이 직접 올리고 내립니다. 이게 제일 큽니다. 씬과 에셋 번들 로드, 텍스처 밉 스트리밍, 월드를 타일로 잘라 가까운 것만 올리기가 전부 이 일입니다. OS 의 페이징에 맡기면 언제 튈지 모르니 폴트가 나기 전에 엔진이 미리 올려 둡니다. 로딩 화면과 좁은 복도와 엘리베이터 구간이 대체로 이 시간을 버는 장치입니다.

    즉 페이지 단위의 교체는 기계가 알아서 하고, 에셋 단위의 교체는 여전히 사람이 정합니다.

    그래서 남는 질문은 하나입니다. 기계가 알아서 하는 그 교체가 프레임 한복판에서 일어나면 얼마나 비싼가.

    프레임 예산에서 보면

    60프레임으로 돌리면 한 프레임에 쓸 수 있는 시간이 대략 16.6밀리초입니다.

    • 메인 메모리를 한 번 읽는 데 100나노초 남짓
    • 메이저 폴트 한 번에 100마이크로초쯤

    천 배 차이입니다. 한 번 나는 것은 티도 안 납니다. 문제는 한 번만 나지 않는다는 것입니다. 100마이크로초짜리가 166번이면 한 프레임이 통째로 없어집니다. 씬을 처음 열거나 새 에셋을 만지기 시작하면 이 정도는 쉽게 납니다.

    평균 프레임 타임에는 거의 안 잡힙니다. 대부분의 프레임이 멀쩡하기 때문입니다. 흔들리는 것은 평균이 아니라 최악값이고, 그것이 언제 올지 모른다는 쪽이 더 나쁩니다.

    멈추는 것은 폴트가 난 스레드 하나뿐입니다. 커널은 그 스레드를 대기로 돌려놓고 다른 스레드를 CPU 에 올립니다. 서버라면 그동안 다른 요청을 처리하면 되니 괜찮습니다. 게임에서 나쁜 이유는 멈춘 것이 메인 스레드일 때입니다. 이번 프레임을 끝내야 하는 것이 그 스레드인데, 다른 스레드가 대신 돌아 봐야 그 프레임은 이미 늦습니다.

    게임에서 이게 실제로 몰리는 자리는 대체로 정해져 있습니다.

    • 앱을 켜고 처음 몇 초. 실행 코드도 에셋도 아직 하나도 안 올라와 있어서 요구 페이징이 그 구간에 전부 일어납니다
    • 씬에 처음 들어갈 때. 그 씬에서만 지나가는 코드 경로와 그 씬에서만 쓰는 에셋을 처음 만집니다
    • 처음 재생되는 이펙트와 사운드. 두 번째부터는 멀쩡한데 첫 번째만 걸리는 것이 대체로 이것입니다. 셰이더 변형을 처음 만드는 비용과 겹쳐 있어서 둘을 헷갈리기 쉽습니다
    • 백그라운드에 뒀다가 돌아왔을 때. 그동안 커널이 깨끗한 페이지부터 버렸기 때문에, 복귀 직후 한동안 다시 읽어 옵니다

    대응은 전부 한 문장으로 모입니다. 프레임 중에 처음 만지는 것을 없애면 됩니다. 로딩 화면에서 미리 한 번 만져 두고, 첫 이펙트를 안 보이는 데서 한 번 재생해 두고, 풀에 미리 만들어 둡니다. 비용을 없애는 것이 아니라 비용이 나는 시점을 사람이 정하는 것입니다.

    모바일에는 밀려날 곳이 없다

    데스크톱은 물리 메모리가 모자라면 안 쓰는 페이지를 저장 장치로 밀어내고 버팁니다. 느려질 뿐 프로그램은 삽니다. 모바일은 그 보험이 거의 없습니다.

    • 안드로이드는 저장 장치로 스왑하지 않고 압축 스왑(zram) 을 씁니다. 메모리가 모자라면 커널이 먼저 깨끗한 파일 페이지를 버리고 나머지를 압축해 밀어냅니다. 그래도 모자라면 lmkd 가 프로세스를 죽입니다
    • iOS 는 앱 메모리를 저장 장치로 스왑하지 않습니다. 압축해서 버티다가 한도를 넘으면 jetsam 이 앱을 종료합니다

    여기서 게임에 중요한 것은 깨끗한 파일 페이지가 제일 먼저 버려진다는 점입니다. 실행 코드와 메모리 매핑해 둔 에셋이 그쪽입니다. 저장 장치에서 다시 읽으면 되니까 커널 입장에서는 버리기 제일 쉬운 것들입니다.

    그래서 스왑이 없는 기기에서도 메이저 폴트는 납니다. 버려진 코드 페이지를 다시 만지는 순간 다시 읽어 옵니다. 백그라운드에 뒀다 돌아온 직후에 프레임이 흔들리는 것이 이것입니다.

    결국 손에 남는 것은 워킹 셋을 줄이는 것 하나입니다. 상주 메모리가 작으면 커널이 버릴 이유가 없고, 버릴 것이 없으면 다시 읽어 올 일도 없습니다.

    정리

    • 번역은 페이지 단위로 합니다. 페이지 번호만 프레임 번호로 바뀌고 오프셋은 그대로 따라갑니다
    • 페이지 폴트는 오류가 아닙니다. 주소는 멀쩡한데 내용이 물리 메모리에 없는 상태이고, OS 가 채워 넣은 뒤 멈췄던 명령부터 다시 실행합니다
    • 폴트가 나면 커널이 빈 프레임 목록에서 하나 꺼내 내용을 채웁니다. 시간을 먹는 것은 프레임을 찾는 일이 아니라 내용을 채우는 일입니다
    • 마이너 폴트와 메이저 폴트는 비용이 두 자릿수 넘게 차이 납니다. 프레임을 흔드는 쪽은 메이저입니다
    • 번역이 끝난 뒤 닿는 곳은 언제나 램입니다. 주소 공간이 크다고 쓸 수 있는 메모리가 커지지 않습니다
    • 큰 게임이 도는 것은 워킹 셋이 램에 들어가기 때문이고, 그 워킹 셋을 관리하는 것은 OS 가 아니라 엔진입니다
    • 모바일은 밀려날 곳이 없어서 커널이 깨끗한 파일 페이지부터 버리고, 그래도 모자라면 앱을 종료합니다. 스왑이 없어도 메이저 폴트는 납니다
    • 폴트를 없앨 수는 없습니다. 나는 시점을 프레임 밖으로 옮기는 것이 할 수 있는 전부입니다