20211019

Rust + Python PyO3 바인딩 실험

Python으로 돌리는 이미지 해시/유사도 매칭 로직 중 일부가 CPU bound라서 Rust로 포팅 실험. pyo3 0.14 버전, Python 3.9, Rust 1.55. maturin으로 빌드.

프로젝트 구조.

my_hash/
├── Cargo.toml
├── pyproject.toml
└── src/
    └── lib.rs

Cargo.toml

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

[dependencies]
pyo3 = { version = "0.14", features = ["extension-module"] }
image = "0.23"

lib.rs — phash 같은 걸 Rust로 구현.

use pyo3::prelude::*;
use image::imageops::FilterType;
use image::DynamicImage;

#[pyfunction]
fn phash(path: &str) -> PyResult<u64> {
    let img = image::open(path).map_err(|e| PyErr::new::<pyo3::exceptions::PyIOError, _>(e.to_string()))?;
    let small = img.resize_exact(8, 8, FilterType::Lanczos3).to_luma8();
    let mean: u32 = small.pixels().map(|p| p[0] as u32).sum::<u32>() / 64;
    let mut hash: u64 = 0;
    for (i, p) in small.pixels().enumerate() {
        if p[0] as u32 > mean {
            hash |= 1 << i;
        }
    }
    Ok(hash)
}

#[pymodule]
fn my_hash(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(phash, m)?)?;
    Ok(())
}

빌드/설치.

pip install maturin
maturin develop --release

Python에서 그냥 import 해서 씀.

from my_hash import phash
print(phash("sample.jpg"))

벤치

1000장 phash 계산.

  • Pillow + numpy 구현: 11.2초
  • Rust(pyo3) 구현: 2.8초

4배 개선. 이미지 디코드가 병목의 큰 부분이라 이 이상 당기려면 멀티스레드(rayon) 도입해야 함. GIL 풀리는 구간(py.allow_threads)에서 Python쪽 multiprocessing과도 조합 가능.

걸린 점

  • pyo3 메이저 버전별로 API 꽤 바뀐다. 0.13 → 0.14 사이에도 wrap_pyfunction! 사용법 달라짐.
  • 예외 매핑을 Rust Result → PyErr로 직접 해줘야 하는데, 에러 문자열 잘 보존 안 하면 파이썬 쪽 디버깅이 힘들다.
  • 리눅스/맥/윈도 다 서포트하려면 maturin의 manylinux 빌드 세팅 해야 함. 사내 배포만이면 linux wheel 하나로 충분.

결론 — 코어 루프 몇 개만 Rust로 빼는 하이브리드 구조, 충분히 실용적. 단 팀에 Rust 알아야 유지됨.