MMAP(Memory-MAPping)

Pt J·2026년 3월 30일
post-thumbnail

MMAP(Memory-MAPping)

가변카피 첫 예제에서 데이터 양이 너무 많아지면
SWAP 공간에 대한 오버헤드가 발생한다는 사실을 확인해 보았다.
파일을 마치 메모리인 것처럼 다루는 MMAP을 사용하면
그런 오버헤드를 완화할 수 있다.

일반적인 파일 읽기는 File -> OS Buffer -> User Buffer 순으로
데이터를 복사하여 사용하는 반면,
MMAP은 파일을 프로세스의 가상 주소 공간에 직접 매핑한다.

데이터를 명시적으로 read() 하지 않아도 되며
RAM 용량보다 훨씬 큰 파일을 읽는 경우에도
파일 크기만큼의 메모리 공간이 있는 것처럼 사용할 수 있다.

작업공간 생성 및 구조 확인

~/workspace$ mkdir memory-mapping && cd memory-mapping
~/workspace/memory-mapping$ python3 -m venv venv
~/workspace/memory-mapping$ source venv/bin/activate
~/workspace/memory-mapping$ # 평소에 설치하던 것 외에 추가된 라이브러리를 놓치지 말자
~/workspace/memory-mapping$ pip install maturin fastapi uvicorn orjson numpy
~/workspace/memory-mapping$ maturin init
~/workspace/memory-mapping$ # 선택지 중 기본값인 PyO3 선택
~/workspace/memory-mapping$ # Cargo.toml과 src/lib.rs가 자동 생성된다
~/workspace/memory-mapping$ # Python 코드는 직접 생성해 주어야 한다
~/workspace/memory-mapping$ mkdir app && touch app/main.py
~/workspace/memory-mapping$ tree -I venv
.
├── app
│   └── main.py
├── Cargo.toml
├── pyproject.toml
└── src
    └── lib.rs

Cargo.toml 파일을 열어 라이브러리 이름을 수정해 주겠다.

MMAP을 위해서는 memmap2 크레이트를 불러와야 한다.

Cargo.toml

[package]
name = "memory-mapping"
version = "0.1.0"
edition = "2024"

[lib]
name = "rust_engine"
crate-type = ["cdylib"]

[dependencies]
pyo3 = "0.28.0"
numpy = "0.28"
rayon = "1.11"
memmap2 = "0.9"

코드 작성

Rust 코드

파일 경로를 받아 그 파일을 MMAP으로 열고,
안에 들어있는 f64 데이터를 제로카피로 읽어 합계를 계산한다.

바이트 데이터를 f64 슬라이스로 변환하여 사용할 것이다.
f64는 8바이트이므로, 전체 바이트를 8로 나눈 만큼의 슬라이스를 만들고
각 슬라이스의 합을 Rayon을 통해 병렬 연산한다.

src/lib.rs

use pyo3::prelude::*;
use memmap2::Mmap;
use std::fs::File;
use rayon::prelude::*;

#[pyfunction]
fn sum_mmap_file(file_path: String) -> PyResult<f64> {
    let file = File::open(file_path)?;

    let mmap = unsafe {
        Mmap::map(&file)?
    };

    let raw_ptr = mmap.as_ptr() as *const f64;
    let len = mmap.len() / 8;

    let slice = unsafe {
        std::slice::from_raw_parts(raw_ptr, len)
    };

    let total_sum = slice.par_iter().sum();

    Ok(total_sum)
}

#[pymodule]
fn rust_engine(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(sum_mmap_file, m)?)?;

    Ok(())
}

Python 코드

MMAP 테스트를 위해 거대한 바이너리 파일을 만드는 함수와
실제 MMAP 연산을 Rust에게 요청하는 함수를 각각 작성한다.

여기서는 전부 1.0 으로 채워진 GB 단위의 바이너리 파일을 사용하겠다.

app/main.py

import numpy as np
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
import rust_engine
import os
import time

class UTF8ORJSONResponse(ORJSONResponse):
    media_type = "application/json; charset=utf-8"

app = FastAPI(default_response_class=UTF8ORJSONResponse)

@app.get("/")
def read_root():
    return {
        "status": "200",
        "info": "서버 가동 중입니다."
    }

@app.get("/setup-mmap-file/{gb}")
def setup_file(gb: int):
    file_path = "huge_data.bin"
    size = gb * 1024 * 1024 * 1024
    elements = size // 8

    data = np.ones(elements, dtype=np.float64)
    data.tofile(file_path)

    return {
        "message": f"{gb}GB 파일 생성 완료",
        "path": file_path
    }

@app.get("/sum-mmap")
def sum_mmap():
    file_path = "huge_data.bin"
    if not os.path.exists(file_path):
        return {
            "error": "파일이 없습니다. /setup-mmap-file 라우트를 먼저 호출하세요."
        }

    start = time.perf_counter()
    result = rust_engine.sum_mmap_file(file_path)
    end = time.perf_counter()
  
    rust_duration = end - start

    return {
        "file_size": f"{os.path.getsize(file_path) / (1024**3)} GB",
        "result": result,
        "rust_pure_time": f"Rust 연산에 걸린 시간: {rust_duration:.4f} sec"
    }

빌드 및 실행

Maturin 라이브러리를 통해 Rust 코드를 Python에서 호출 가능한 형태로 컴파일한다.
병렬 처리가 포함된 코드는 성능 최적화를 위해 --release 를 붙여 컴파일한다.
컴파일 후 pip list 명령어를 사용해 보면 Cargo.toml 파일에 작성한 패키지 이름을 확인할 수 있다.

uvicorn 라이브러리를 통해 FastAPI를 실행한다.

~/workspace/memory-mapping$ maturin develop --release
~/workspace/memory-mapping$ uvicorn app.main:app --reload

curl 명령어 또는 브라우저를 통해 다음과 같은 테스트를 해볼 수 있다.

  • http://127.0.0.1:8000/setup-mmap-file/8
  • http://127.0.0.1:8000/sum-mmap
~$ curl -i http://127.0.0.1:8000/setup-mmap-file/8
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:13 GMT
server: uvicorn
content-length: 61
content-type: application/json; charset=utf-8

{"message":"8GB 파일 생성 완료","path":"huge_data.bin"}%

~$ curl -i http://127.0.0.1:8000/sum-mmap         
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:18 GMT
server: uvicorn
content-length: 104
content-type: application/json; charset=utf-8

{"file_size":"8.0 GB","result":1073741824.0,"rust_pure_time":"Rust 연산에 걸린 시간: 0.5097 sec"}%

데이터 크기가 작으면 OS 페이지 캐시의 도움으로 매우 빠르게 완료된다. (약 15.6 GB/s)

~$ curl -i http://127.0.0.1:8000/setup-mmap-file/16
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:29 GMT
server: uvicorn
content-length: 62
content-type: application/json; charset=utf-8

{"message":"16GB 파일 생성 완료","path":"huge_data.bin"}%

~$ curl -i http://127.0.0.1:8000/sum-mmap          
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:19:34 GMT
server: uvicorn
content-length: 105
content-type: application/json; charset=utf-8

{"file_size":"16.0 GB","result":2147483648.0,"rust_pure_time":"Rust 연산에 걸린 시간: 2.8441 sec"}%

캐시의 도움을 받지 못하는 순수 SSD 읽기 속도로 수행된다. (약 5.6 GB/s)

~$ curl -i http://127.0.0.1:8000/setup-mmap-file/80
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:20:02 GMT
server: uvicorn
content-length: 62
content-type: application/json; charset=utf-8

{"message":"80GB 파일 생성 완료","path":"huge_data.bin"}%

~$ curl -i http://127.0.0.1:8000/sum-mmap          
HTTP/1.1 200 OK
date: Mon, 30 Mar 2026 01:20:51 GMT
server: uvicorn
content-length: 107
content-type: application/json; charset=utf-8

{"file_size":"80.0 GB","result":10737418240.0,"rust_pure_time":"Rust 연산에 걸린 시간: 34.3357 sec"}%

RAM(64GB) 크기를 초과하여 시간이 많이 걸린다. (약 2.3 GB/s)
100억 개의 데이터를 약 55초만에 처리했던 것(약 1.44 GB/s)에 비하면 많이 빨라졌지만.

크기가 작은 데이터는 메모리에 먼저 올려 놓고 사용하는
일반적인 제로카피가 더 빠른 연산이 가능하지만
데이터 크기가 커질수록 MMAP을 사용하는 게 이득이다.

profile
Peter J Online Space - since July 2020 | 아무데서나 채용해줬으면 좋겠다

0개의 댓글