DataScience
Published 2023. 5. 29. 09:05
Rust 파이썬 바인딩 Rust
728x90

러스트코드를 파이썬에서 실행하는 방법입니다. 러스트의 높은 성능때문에 많은 파이썬 개발자들이 러스트 코드를 파이썬에서 사용하기 위한 도구를 개발했습니다. 그중에서 가장 널리 사용되는 PyO3 크레이트의 사용법을 알아보겠습니다.

 

파이썬 가상환경 만들기

pipenv

pipenv는 pipenv 명령어 하나로 가상환경의 생성, 삭제, 의존성의 추가, 삭제, 업데이트 등을 모두 할 수 있는 편리한 도구입니다.

pipenv는 프로젝트의 가상 환경을 자동으로 생성 및 관리하고 패키지를 설치/제거할 때 Pipfile에서 패키지를 추가/제거합니다. 또한 패키지 유효성을 검사하는 데 사용되는 매우 중요한 Pipfile.lock을 생성합니다.

Pipfile.lock은 가상 환경에 설치된 각 패키지의 정확한 버전을 기록하는 pipenv에 의해 생성된 파일입니다. 이를 통해 다른 개발자가 동일한 버전의 패키지를 설치하여 동일한 환경을 재현할 수 있습니다.

pipenv를 사용하려면 pip을 이용해 먼저 설치해주어야 합니다.

pip install pipenv

파이썬 버전을 지정해서 가상환경을 생성합니다.

pipenv --python 3.11

생성된 가상환경 셸로 진입하는 방법은 다음과 같습니다.

pipenv shell

가상환경에 새로운 패키지를 설치합니다.

pipenv install requests

만일 개발 단계에서만 사용되는 툴이라면 --dev 플래그를 추가합니다. 예를 들어 black과 같은 포매터 패키지는 실제 소스코드에서는 쓰이지 않고 개발 단계에서만 사용되기 때문에 다음과 같시 설치할 수 있습니다.

pipenv install --dev black

결과적으로 일반 패키지와 개발 패키지가 Pipfile에 구분되어서 추가되는 것을 알 수 있습니다.

[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[packages]
requests = "*"

[dev-packages]
black = "*"

[requires]
python_version = "3.11"

[pipenv]
allow_prereleases = true

 

러스트 프로젝트 생성하기

파이썬 바인딩이란?

파이썬 바인딩은 다른 프로그래밍 언어로 작성된 코드를 파이썬에서 사용하는 것을 의미합니다.

파이썬용 러스트 바인딩을 사용하면 파이썬에서 러스트로 함수를 호출하고 데이터를 전달할 수 있으므로 두 언어의 강점을 모두 활용할 수 있습니다. 이 기능은 테스트를 거쳐 안정적으로 작성된 대규모 라이브러리를 파이썬에서 활용하거나 파이썬 코드의 특정 섹션을 러스트로 변환하여 속도를 높이고자 하는 경우에 유용합니다.

파이썬에서 러스트 바인딩을 생성하는 데 가장 널리 알려진 프로젝트는 PyO3입니다. 이 프로젝트는 러스트로 파이썬 모듈을 작성하거나 파이썬 런타임을 러스트 바이너리에 임베드하는 데 사용할 수 있습니다. PyO3는 파이썬 패키징 및 바인딩이 포함된 러스트 상자를 작성하는 도구인 Maturin이라는 또 다른 프로젝트를 활용합니다.

PyO3

PyO3는 파이썬에서 러스트 코드를 실행할 수 있고, 반대로 러스트에서 파이썬 코드를 실행할 수 있도록 도와주는 크레이트입니다. 우리는 파이썬에서 러스트 코드를 실행하는 방법을 배워 보겠습니다. 파이썬에서 러스트 코드를 호출해 높은 성능 향상을 달성한 다양한 예시가 있습니다. 아래에서 그 중 유명한 몇 가지 패키지를 소개합니다.

  • orjson Fast Python JSON library 10배
  • fastuuid Python bindings to Rust's UUID library
  • cryptography Python cryptography library with some functionality in Rust

maturin

maturin은 최소한의 구성으로 러스트로 작성한 파이썬 패키지를 빌드할 수 있는 도구입니다.

$ pipenv install maturin --dev
$ pipenv shell

maturin으로 pyo3 프로젝트를 시작합니다. -b 옵션을 주면 pyo3를 빌드 시스템으로 해서 프로젝트가 생성됩니다.

$ maturin init -b pyo3
  ✨ Done! New project created string_sum

프로젝트를 생성하면 다음과 같은 폴더 구조가 만들어집니다.

.
├── Cargo.lock
├── Cargo.toml
├── Pipfile
├── pyproject.toml
├── src
    └── lib.rs

Cargo.toml파일에서 패키지와 라이브러리의 이름을 "fibonacci"로 변경합니다.

[package]
name = "fibonacci"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "fibonacci"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.16.5", features = ["extension-module"] }

pyproject.toml 파일에서도 프로젝트 이름을 "fibonacci"로 수정합니다.

[build-system]
requires = ["maturin>=0.13,<0.14"]
build-backend = "maturin"

[project]
name = "fibonacci"
requires-python = ">=3.7"
classifiers = [
    "Programming Language :: Rust",
    "Programming Language :: Python :: Implementation :: CPython",
    "Programming Language :: Python :: Implementation :: PyPy",
]

라이브러리 크레이트 만들기

#[pyfunction]은 PyO3 라이브러리에서 제공하는 어트리뷰트로, 러스트 함수를 파이썬 함수로 정의하는 데 사용할 수 있습니다. 러스트 함수에 #[pyfunction] 어트리뷰트를 추가하면 PyO3는 해당 함수가 일반 파이썬 함수인 것처럼 파이썬에서 호출할 수 있는 코드를 생성합니다.

#[pymodule]은 PyO3 라이브러리에서 제공하는 어트리뷰트로, 러스트 함수를 파이썬 모듈로 정의하는 데 사용할 수 있습니다. 러스트 함수에 #[pymodule] 어트리뷰트를 추가하면 PyO3는 해당 함수를 파이썬 모듈의 초기화 함수로 사용할 수 있는 코드를 생성합니다.

모듈에 함수를 추가하려면 add_function 메서드를 사용합니다. 이렇게 하면 모듈 내에서 함수를 호출 가능한 객체로 사용할 수 있습니다.

use pyo3::prelude::*;

fn _run(n: u64) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        _ => _run(n - 1) + _run(n - 2),
    }
}

#[pyfunction]
fn run(n: u64) -> PyResult<u64> {
    Ok(_run(n))
}

/// A Python module implemented in Rust.
#[pymodule]
fn fibonacci(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(run, m)?)?;
    Ok(())
}

파이썬에서 러스트 코드 실행해 보기

개발 모드로 빌드해보기

maturin develop 명령어를 사용하면, 러스트 패키지를 빌드한 다음 파이썬 가상환경에 패키지를 자동으로 설치해줍니다. 이때 러스트 컴파일 타겟이 [unoptimized + debuginfo]가 되는데, 빠른 개발을 위해 코드 성능보다는 컴파일 속도를 중요하게 생각한 옵션입니다.

$ maturin develop      
🔗 Found pyo3 bindings
🐍 Found CPython 3.8 at /Users/.local/share/virtualenvs/ch14-4UzrGkRt/bin/python
   Compiling pyo3-build-config v0.16.5
   Compiling pyo3-ffi v0.16.5
   Compiling pyo3 v0.16.5
   Compiling fibonacci v0.1.0 (/Users/code/Tutorials/sap_rust_tutorial/ch14)
    Finished dev [unoptimized + debuginfo] target(s) in 12.64s
📦 Built wheel for CPython 3.8 to /var/folders/74/l6jhlmk114g8kx1pzz2s9fm80000gn/T/.tmpBh1Xiw/fibonacci-0.1.0-cp38-cp38-macosx_10_7_x86_64.whl
🛠  Installed fibonacci-0.1.0

만일 가상환경에서 실행하지 않을 경우 에러가 발생하므로 주의하세요!

$ maturin develop
💥 maturin failed
  Caused by: You need to be inside a virtualenv or conda environment to use develop (neither VIRTUAL_ENV nor CONDA_PREFIX are set). See https://virtualenv.pypa.io/en/latest/index.html on how to use virtualenv or use `maturin build` and `pip install ` instead.

main.py 파일을 만들고 다음 코드를 추가합니다. 파이썬으로 피보나치 수열을 구하는 함수 pyrun 을 추가해 러스트 구현체와 성능을 비교해봅니다.

import time

from fibonacci import run


def pyrun(n: int):
    if n < 2:
        return n

    return pyrun(n - 1) + pyrun(n - 2)


N = 35

start = time.time()
result = pyrun(N)
print(f"python: {time.time()-start:.2f}, result: {result}")
start = time.time()
result = run(N)
print(f"rust: {time.time()-start:.2f}, result: {result}")

실행 결과

$ python main.py
python: 3.13, result: 9227465
rust: 0.10, result: 9227465

릴리즈 모드로 빌드해보기

빌드 옵션을 --release 로 주면, 러스트 코드를 최대한 최적화해서 컴파일한 바이너리가 패키지로 만들어지게 됩니다. 컴파일 타겟이 [optimized]인 걸 알 수 있습니다.

$ maturin build --release
🔗 Found pyo3 bindings
🐍 Found CPython 3.8 at /Users/.local/share/virtualenvs/temp-nO4s4P8m/bin/python3
   Compiling target-lexicon v0.12.4
   Compiling once_cell v1.13.1
   Compiling proc-macro2 v1.0.43
   Compiling libc v0.2.132
   Compiling quote v1.0.21
   Compiling unicode-ident v1.0.3
   Compiling syn v1.0.99
   Compiling autocfg v1.1.0
   Compiling parking_lot_core v0.9.3
   Compiling cfg-if v1.0.0
   Compiling smallvec v1.9.0
   Compiling scopeguard v1.1.0
   Compiling unindent v0.1.10
   Compiling indoc v1.0.7
   Compiling lock_api v0.4.7
   Compiling pyo3-build-config v0.16.5
   Compiling parking_lot v0.12.1
   Compiling pyo3-ffi v0.16.5
   Compiling pyo3 v0.16.5
   Compiling pyo3-macros-backend v0.16.5
   Compiling pyo3-macros v0.16.5
   Compiling fibonacci v0.1.0 (/Users/code/temp)
    Finished release [optimized] target(s) in 20.61s
📦 Built wheel for CPython 3.8 to /Users/code/temp/target/wheels/fibonacci-0.1.0-cp38-cp38-macosx_10_7_x86_64.whl

이제 파이썬 코드를 그대로 실행하면 최적화된 패키지로 실행이 가능합니다.

실행 결과

$ python main.py
python: 3.03, result: 9227465
rust: 0.03, result: 9227465

 

PyO3와 GIL

위에서는 단일 스레드 환경에서 러스트 코드를 실행하는 예제였습니다. 하지만 최종 목표인 파이썬 GIL을 우회하는 러스트 패키지를 만들기 위해서는 멀티스레드 환경에서 러스트 코드를 실행해보아야 합니다. 이를 살펴보기 위해서 새로운 프로젝트 gil을 생성합니다.

maturin init -b pyo3

GIL 획득과 해제

py.allow_threads는 PyO3에서 제공하는 메서드로, 클로저를 실행하는 동안 GIL을 일시적으로 해제할 수 있습니다. 이 메서드는 파이썬 인터프리터와 상호 작용할 필요가 없는 장기 실행 계산이 있고 다른 스레드가 파이썬 코드를 병렬로 실행하도록 허용하려는 경우에 유용할 수 있습니다. py.allow_threads를 사용하면 GIL의 해제 및 획득 시점을 세밀하게 제어할 수 있으므로 일부 상황에서 유용할 수 있습니다.

PyO3에서 GIL을 해제하는 다른 방법으로는 Python::with_gil 메서드를 사용하여 명시적으로 GIL을 획득하고 해제할 수도 있습니다.

일반적으로 파이썬 인터프리터와 상호 작용할 필요가 없는 장기 실행 계산이 있을 때마다 GIL을 해제하는 것이 좋습니다. 이렇게 하면 다른 스레드에서 Python 코드를 병렬로 실행할 수 있으므로 프로그램 성능이 향상될 수 있습니다.

use std::thread;
use std::time::Duration;

use pyo3::prelude::*;
use pyo3::types::PyList;

#[pyfunction]
fn double_list(py: Python<'_>, list: &PyList, result: &PyList, idx: usize) -> PyResult<()> {
    println!("Rust: Enter double_list...");
    py.allow_threads(|| {
        println!("Rust: Release GIL...");
        thread::sleep(Duration::from_secs(1));
    });

    let doubled: Vec<i32> = list.extract::<Vec<i32>>()?.iter().map(|x| x * 2).collect();

    Python::with_gil(|py| {
        println!("Rust: Acquire GIL...");
        thread::sleep(Duration::from_secs(1));
        let py_list = PyList::new(py, &doubled);
        println!("Rust: Exit...");
        result.set_item(idx, py_list)
    })
}

/// A Python module implemented in Rust.
#[pymodule]
fn gil(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(double_list, m)?)?;
    Ok(())
}

파이썬에서 sleep 함수를 사용해 GIL을 해제한 다음 러스트 코드와 번갈아가면서 실행되는 예제입니다.

import time
import threading

from gil import double_list


def double_list_py(list, result, idx):
    print("Py: Enter double_list_py...")
    time.sleep(0.1)
    result[idx] = [x * 2 for x in list]
    print("Py: Exit...")


result = [[], []]
nums = [1, 2, 3]

t1 = threading.Thread(target=double_list_py, args=(nums, result, 0))
t2 = threading.Thread(target=double_list, args=(nums, result, 1))

t1.start()
t2.start()

t1.join()
t2.join()

print(f"Py: {result[0]}")
print(f"Rust: {result[1]}")

실행 결과

Py: Enter double_list_py...
Rust: Enter double_list...
Rust: Release GIL...
Py: Sleep for 1 sec...
Rust: Acquire GIL...
Rust: Exit...
Py: Exit...
Py: [2, 4, 6]
Rust: [2, 4, 6]

'Rust' 카테고리의 다른 글

Rust Generic 데이터 타입  (91) 2023.06.19
Rust testing  (142) 2023.06.15
Rust 비동기 프로그래밍  (36) 2023.05.28
Rust 멀티스레딩  (28) 2023.05.27
Rust 스마트 포인터  (63) 2023.05.14
profile

DataScience

@Ninestar

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!