Subscribe

파이썬 러스트 바인딩 성능 최적화 (NumPy + Rayon + Release Build)

2026-06-17PyO3, NumPy, Rayon, SIMD를 활용한 파이썬 러스트 바인딩 성능 최적화 실험

파이썬 러스트 바인딩 환경을 구축하고 기본 코드를 돌려보는 테스트를 이전 포스팅에서 알아보았다. 이번에는 파이썬 러스트 바인딩 환경을 통해 어떻게 러스트의 장점인 빠른 연산을 이용하도록 만들어 프로그램의 성능을 극대화할 수 있는지 몇 가지 테스트를 하며 알아본다.

1단계 최적화: 대량 데이터 연산 테스트와 ‘반전’

본격적으로 러스트의 장점인 고속 연산 성능을 테스트해 본다. 100만 개의 정수 리스트를 만들어 제곱의 합을 구하는 코드다.

러스트 코드 수정 (src/lib.rs)

Rust
// pyo3 라이브러리에서 파이썬 연동에 필요한 핵심 기능들을 가져옵니다.
use pyo3::prelude::*;

// #[pyfunction] 어트리뷰트는 이 러스트 함수를 파이썬에서 직접 호출할 수 있도록 변환해 줍니다.
#[pyfunction]
fn calculate_heavy_work(numbers: Vec<i64>) -> PyResult<i64> {
    // 1. 데이터 입력: 파이썬의 리스트(int)가 러스트의 Vec<i64> 형태로 자동 변환되어 들어옵니다.

    // 2. 연산 수행: 리스트의 각 숫자를 제곱(|&x| x * x)한 뒤 모두 더합니다(.sum()).
    let result: i64 = numbers.iter().map(|&x| x * x).sum();
    
    // 3. 데이터 반환 (중요): 
    // 파이썬과 러스트는 서로 다른 언어이므로, 연산이 '성공했는지 에러가 났는지'를 알려주는 
    // 안전한 주머니인 `PyResult` 형태로 감싸서 반환해야 합니다.
    // 성공 시에는 결과값을 `Ok(결과값)` 형태로 포장해서 파이썬으로 보냅니다.
    Ok(result)
}

// #[pymodule]은 파이썬에서 `import py_rust_hybrid`로 불러올 수 있는 하나의 '모듈'을 만듭니다.
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
    // 위에서 만든 `calculate_heavy_work` 함수를 이 파이썬 모듈에 등록합니다.
    m.add_function(wrap_pyfunction!(calculate_heavy_work, m)?)?;
    
    // 모듈 등록이 성공적으로 끝났음을 알리는 빈 포장지(Ok(()))를 반환합니다.
    Ok(())
}

💡 기초 개념 짚고 넘어가기

  • PyResult<i64>란 무엇인가요?
    • 러스트는 안전성을 극단적으로 추구하는 언어다. 파이썬으로 데이터를 돌려줄 때 “여기 데이터 성공적으로 잘 뽑혔어!”라는 것을 보장해 주는 PyResult라는 특수한 포장 상자에 담아서 줘야 한다.
  • 왜 리턴할 때 Ok(result)라고 쓰나요?
    • PyResult라는 상자를 열었을 때 성공(Ok)과 실패(Err) 중 성공에 해당한다는 것을 명시해 주는 러스트의 문법이다. 파이썬은 이 Ok 상자를 받으면 자동으로 포장을 풀어 알맹이인 숫자 결과값만 가져가게 된다.

파이썬 성능 비교 코드 (main.py)

Python
import py_rust_hybrid
import time

# 1. 테스트용 대량 데이터 생성 (1부터 1,000,000까지의 정수 리스트)
data = list(range(1, 1000001))

print("=== 1단계: 퓨어 파이썬(Pure Python) 연산 테스트 ===")
start_time = time.perf_counter()

# 파이썬 자체 기능(리스트 컴프리헨션과 내장 sum 함수)으로 제곱의 합 계산
pure_python_result = sum(x * x for x in data)

end_time = time.perf_counter()
print(f"퓨어 파이썬 연산 결과: {pure_python_result}")
print(f"퓨어 파이썬 소요 시간: {end_time - start_time:.5f}\n")


print("=== 2단계: 파이썬 러스트 하이브리드(PyO3) 연산 테스트 ===")
start_time = time.perf_counter()

# 러스트로 구현한 함수 호출
rust_result = py_rust_hybrid.calculate_heavy_work(data)

end_time = time.perf_counter()
print(f"파이썬 러스트 바인딩 연산 결과: {rust_result}")
print(f"파이썬 러스트 바인딩 소요 시간: {end_time - start_time:.5f}\n")

터미널에서 maturin develop으로 재빌드 후 파이썬 코드를 실행한 결과는 다음과 같다.

  • 퓨어 파이썬 소요 시간: 0.08517초
  • 파이썬 러스트 바인딩 소요 시간: 0.12130초

큰 성능 차이가 날 것으로 예상했지만, 오히려 러스트 바인딩 방식이 더 느린 결과가 나타났다. 이는 러스트의 연산 성능이 부족해서가 아니라, 파이썬과 러스트 사이에서 데이터를 주고받는 과정에서 발생하는 데이터 복사 비용(Overhead) 때문이다.

실제로 러스트의 연산 자체는 매우 빠르게 수행되지만, 파이썬 리스트 객체를 러스트의 Vec<i64> 배열로 변환하고 복사하는 준비 작업에 상당한 시간이 소요된다. 속도를 높이기 위해 러스트를 사용했음에도 오히려 ‘배보다 배꼽이 더 큰’ 상황이 발생한 것이다. 그렇다면 데이터 복사 비용의 영향을 줄이고 연산량을 크게 늘리면 결과는 어떻게 달라질까? 다음 단계에서 확인해 보자.

2단계 최적화: 연산 비중을 늘려 체급 차이 확인

데이터를 복사하는 비용이 문제라면, “주고받는 데이터양은 그대로 두고, 내부 연산 횟수를 크게 늘리면 어떻게 될까?”라는 의문이 생긴다. 이번에는 데이터 크기를 내부 루프를 1000번 반복하여 CPU를 극한으로 쓰도록 설계를 변경한다. 반환 값이 커지므로 타입을 u128로 확장한다.

러스트 코드 수정 (src/lib.rs)

Rust
// pyo3 라이브러리에서 파이썬 연동에 필요한 핵심 기능들을 가져옵니다.
use pyo3::prelude::*;


#[pyfunction]
fn calculate_heavy_work(numbers: Vec<i64>) -> PyResult<u128> { // 반환 타입을 u128로
    // 반복문에서 값을 계속 누적해야 하므로 mut 사용
    let mut result: u128 = 0; // 합계 변수도 u128로
    // 연산을 1000번 반복하여 CPU를 빡세게 돌립니다!
    for _ in 0..1000 {
        // x * x 결과를 u128로 변환해서 더함
        result+= numbers.iter().map(|&x| (x as u128) * (x as u128)).sum::<u128>();
    }

    Ok(result)
}

// #[pymodule]은 파이썬에서 `import py_rust_hybrid`로 불러올 수 있는 하나의 '모듈'을 만듭니다.
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
    // 위에서 만든 `calculate_heavy_work` 함수를 이 파이썬 모듈에 등록합니다.
    m.add_function(wrap_pyfunction!(calculate_heavy_work, m)?)?;

    // 모듈 등록이 성공적으로 끝났음을 알리는 빈 포장지(Ok(()))를 반환합니다.
    Ok(())
}

파이썬 코드 수정 (main.py)

Python
import py_rust_hybrid
import time

# 1. 테스트용 대량 데이터 생성 (1부터 1,000,000까지의 정수 리스트)
data = list(range(1, 1000001))

print("=== 1단계: 퓨어 파이썬(Pure Python) 연산 테스트 ===")
start_time = time.perf_counter()

# 파이썬 자체 기능(리스트 컴프리헨션과 내장 sum 함수)으로 제곱의 합 계산
pure_python_result = 0
for _ in range(1000):
    pure_python_result += sum(x * x for x in data)

end_time = time.perf_counter()
print(f"퓨어 파이썬 연산 결과: {pure_python_result}")
print(f"퓨어 파이썬 소요 시간: {end_time - start_time:.5f}\n")


print("=== 2단계: 파이썬 러스트 하이브리드(PyO3) 연산 테스트 ===")
start_time = time.perf_counter()

# 러스트로 구현한 함수 호출
rust_result = py_rust_hybrid.calculate_heavy_work(data)

end_time = time.perf_counter()
print(f"파이썬 러스트 바인딩 연산 결과: {rust_result}")
print(f"파이썬 러스트 바인딩 소요 시간: {end_time - start_time:.5f}\n")
  • 퓨어 파이썬 소요 시간: 82.02788초
  • 파이썬 러스트 바인딩 소요 시간: 14.88394초

1000회 반복 테스트에서는 퓨어 파이썬보다 파이썬-러스트 바인딩이 훨씬 빠른 결과를 보여주었다. 연산량이 많아질수록 데이터 복사 비용보다 실제 연산 성능의 영향이 커지기 때문이다. 그렇다면 데이터 복사 비용마저 줄일 수는 없을까?

3단계 최적화: NumPy 도입으로 메모리 직접 공유

Cargo.toml 수정 (의존성 추가)

TOML
[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"] }
numpy = "0.22"
  • [dependencies]에
  • pyo3 = { version = “0.22”, features = [“extension-module”] } #이건 변경
  • numpy = “0.22” 추가

러스트 코드 수정 (src/lib.rs)

Rust
use pyo3::prelude::*;

// 수정된 핵심 로직 (데이터 복사 없이 메모리 주소만 가져옴)
// NumPy 배열을 Rust에서 읽기 전용으로 접근하기 위한 타입
use numpy::PyReadonlyArray1;

#[pyfunction]
fn calculate_heavy_work(numbers: PyReadonlyArray1<i64>) -> PyResult<u128> {
    // NumPy 배열의 메모리 주소를 그대로 참조합니다.
    // Vec<i64>처럼 새로운 배열을 생성하지 않으므로 데이터 복사 비용이 발생하지 않습니다.
    let numbers = numbers.as_slice()?;
    // 반복 계산 결과를 계속 더해야 하므로 mut 사용
    let mut total: u128 = 0;
    // 연산을 1000번 반복하여 CPU 연산 비중을 높입니다.
    for _ in 0..1000 {
        total += numbers.iter().map(|&x| (x as u128) * (x as u128)).sum::<u128>();
    }
    Ok(total)
}

#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(calculate_heavy_work, m)?)?;
    Ok(())
}

파이썬 코드 수정 (main.py)

Python
import py_rust_hybrid
import numpy as np # 넘파이 불러오기
import time

# 100만 개의 데이터를 그냥 리스트가 아니라 np.array로 생성
data = np.arange(1, 1000001, dtype=np.int64)

# 이제 러스트가 복사 없이 이 데이터의 주소만 가져가서 계산합니다!
start = time.time()
result = py_rust_hybrid.calculate_heavy_work(data)
end = time.time()

print(f"속도 결과: {end - start:.5f}초")

터미널에서 maturin develop 빌드 후 pip install numpy 설치하고 실행해 본다..

  • 파이썬 러스트 바인딩 + NumPy 연산: 14.61190초

NumPy 배열을 사용하면 파이썬 리스트를 러스트의 Vec<i64>로 변환하는 과정이 사라지므로 성능 향상을 기대할 수 있다. 하지만 실제 테스트 결과는 예상과 달리 이전 방식과 비교해 큰 차이를 보이지 않았다.

그 이유는 현재 테스트에서 데이터 복사 비용(Overhead)보다 실제 연산 비용의 비중이 훨씬 더 크기 때문이다. 100만 개의 데이터에 대해 동일한 연산을 1000회 반복하고 있어 대부분의 실행 시간이 CPU 연산에 사용된다. 따라서 데이터 복사 과정을 제거하더라도 전체 실행 시간에서 차지하는 비중이 매우 작아 체감할 만한 성능 향상으로 이어지지 않은 것이다.

또한 NumPy를 사용한다고 해서 모든 비용이 완전히 사라지는 것은 아니다. 기존 방식은 러스트가 가장 다루기 편한 Vec<i64> 자료구조를 직접 사용했지만, PyReadonlyArray1<i64> 방식은 내부적으로 NumPy 배열 검증, 데이터 타입(dtype) 확인, 연속 메모리 여부 확인, 슬라이스 생성 등의 작업을 수행해야 한다. 물론 이는 데이터 전체를 복사하는 비용보다 훨씬 작지만, 대가가 완전히 없는 작업은 아니다.

즉, 이번 테스트에서는 데이터 복사 비용을 제거해도 전체 실행 시간 대부분을 차지하는 대규모 반복 연산 비용이 그대로 남아 있었기 때문에 기대했던 만큼의 성능 차이가 나타나지 않았다.

그렇다면 데이터 복사 비용의 영향을 좀 더 명확하게 확인하려면 어떻게 해야 할까? 이번에는 데이터 개수를 100만 개에서 1000만 개로 늘리고, 반복 횟수는 1000회에서 100회로 조정하여 다시 테스트해 보았다. 연산량은 비슷하게 유지하면서 데이터 전달 비용의 비중을 높이면 어떤 결과가 나타나는지 확인해 보자.

3단계 추가 실험: 데이터 규모 확대 후 재측정

Rust
use pyo3::prelude::*;

#[pyfunction]
fn calculate_heavy_work(numbers: PyReadonlyArray1<i64>) -> PyResult<u128> {
    let numbers = numbers.as_slice()?;
    let mut total: u128 = 0;
    for _ in 0..100 {
        total += numbers.iter().map(|&x| (x as u128) * (x as u128)).sum::<u128>();
    }
    Ok(total)
}
Python
import py_rust_hybrid
import numpy as np # 넘파이 불러오기
import time

# 1,000만 개의 데이터를 그냥 리스트가 아니라 np.array로 생성
data = np.arange(1, 10000001, dtype=np.int64)

print("=== 2단계: 파이썬 러스트 하이브리드(PyO3) 연산 테스트 ===")
start_time = time.perf_counter()
rust_result = py_rust_hybrid.calculate_heavy_work(data)
end_time = time.perf_counter()
print(f"파이썬 러스트 바인딩 연산 결과: {rust_result}")
print(f"파이썬 러스트 바인딩 소요 시간: {end_time - start_time:.5f}\n")

파이썬 러스트 바인딩 소요 시간: 13.79904초

3단계 추가 실험: 비교군

조금은 빨라진것 같기는 하지만 역시 비교군이 없다.

넘피를 안쓸때 코드로 다시 만들어서 측정해보자

Rust
use pyo3::prelude::*;

#[pyfunction]
fn calculate_heavy_work(numbers: Vec<i64>) -> PyResult<u128> { // 반환 타입을 u128로
    let mut result: u128 = 0; // 합계 변수도 u128로
    for _ in 0..100 {
        // x * x 결과를 u128로 변환해서 더함
        result+= numbers.iter().map(|&x| (x as u128) * (x as u128)).sum::<u128>();
    }

    Ok(result)
}
Python
import py_rust_hybrid
import time

data = list(range(1, 10000001))

print("=== 1단계: 퓨어 파이썬(Pure Python) 연산 테스트 ===")
start_time = time.perf_counter()

# 파이썬 자체 기능(리스트 컴프리헨션과 내장 sum 함수)으로 제곱의 합 계산
pure_python_result = 0
for _ in range(100):
    pure_python_result += sum(x * x for x in data)

end_time = time.perf_counter()
print(f"퓨어 파이썬 연산 결과: {pure_python_result}")
print(f"퓨어 파이썬 소요 시간: {end_time - start_time:.5f}\n")


print("=== 2단계: 파이썬 러스트 하이브리드(PyO3) 연산 테스트 ===")
start_time = time.perf_counter()

# 러스트로 구현한 함수 호출
rust_result = py_rust_hybrid.calculate_heavy_work(data)

end_time = time.perf_counter()
print(f"파이썬 러스트 바인딩 연산 결과: {rust_result}")
print(f"파이썬 러스트 바인딩 소요 시간: {end_time - start_time:.5f}\n")
  • 퓨어 파이썬 소요 시간: 82.36590초
  • 파이썬 러스트 바인딩 소요 시간: 14.86433초

2번 결과와 비슷했다.

3단계 테스트 결과

비교군과 3단계 테스트 결과를 비교하면

numpy를 적용하자 파이썬 러스트 바인딩 소요 시간:13.79904초로 약간은 줄었다는 것을 확인할 수 있었다.

테스트 결과 기존 Vec 방식은 약 14.86초, NumPy 메모리 공유 방식은 약 13.80초가 측정되었다. 수치상으로는 약 1초 정도 단축된 것으로, 극적인 차이는 아니지만 약간의 성능 향상을 확인할 수 있었다.

결국 NumPy의 핵심 장점은 연산 자체를 빠르게 만드는 것이 아니라 파이썬과 러스트 사이에서 발생하는 데이터 복사 비용(Overhead)을 줄이는 데 있다. 따라서 대량의 데이터를 자주 주고받는 환경이라면 NumPy 기반 메모리 공유 방식이 의미 있는 최적화 방법이 될 수 있다.

하지만 여기서도 한계는 존재한다. 데이터 복사 비용을 줄여도 실제 연산 자체는 여전히 단일 CPU 코어에서 수행되고 있기 때문이다. 그렇다면 여러 CPU 코어를 동시에 활용하는 병렬 처리까지 적용하면 성능은 얼마나 더 향상될까? 다음 단계에서는 Rayon을 이용한 멀티코어 병렬 최적화를 진행해 보자.

4단계 최적화: Rayon을 활용한 멀티코어 병렬 처리

이번에는 러스트의 대표적인 데이터 병렬 처리 라이브러리인 Rayon을 도입해 여러 CPU 코어를 동시에 활용해 보자. 데이터 전달 과정은 이미 최적화되었으므로, 이제는 연산 자체를 병렬화하여 성능을 더욱 끌어올리는 단계다.

Cargo.toml 수정

TOML
[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"] }
numpy = "0.22"
rayon = "1.10"

러스트 코드 수정 (src/lib.rs)

핵심은 into_par_iter()이다. 기존에는 100번의 반복 연산이 하나의 코어에서 순차적으로 실행되었지만, 이를 사용하면 Rayon이 이 작업들을 여러 스레드에 자동으로 분배한다. 개발자는 복잡한 스레드 생성이나 동기화 코드를 직접 작성할 필요 없이 병렬 처리를 적용할 수 있다.

Rust
use pyo3::prelude::*;
use numpy::PyReadonlyArray1;
use rayon::prelude::*; // 병렬 처리 크레이트 추가

#[pyfunction]
fn calculate_heavy_work(numbers: PyReadonlyArray1<i64>) -> PyResult<u128> {
    let numbers = numbers.as_slice()?; 
    
    // 100번의 루프를 병렬(Multi-threading)로 처리
    let total: u128 = (0..100)
        .into_par_iter() // 여기서부터 병렬 실행
        .map(|_| {
            numbers.iter().map(|&x| (x as u128) * (x as u128)).sum::<u128>()
        })
        .sum();
    
    Ok(total)
}
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(calculate_heavy_work, m)?)?;
    Ok(())
}

터미널에서 cargo add rayonmaturin develop을 실행한 뒤 측정한다.

  • 병렬 처리 테스트 결과: 3.88341초

연산 속도가 14초에서 약 4초의 속도로 대폭 단축된다. 약 3~4배 수준의 성능 향상이 발생한 셈이다. 기존에는 하나의 코어가 순차적으로 처리하던 작업을 여러 코어가 동시에 수행하면서 실행 시간이 크게 감소했다.

이는 데이터 전달 비용을 줄이는 것만으로는 해결할 수 없었던 연산 병목을 병렬 처리로 해소했기 때문이다. 기존에는 하나의 코어가 모든 계산을 담당했지만, 이제는 여러 코어가 동시에 연산을 수행하면서 전체 실행 시간이 크게 단축되었다.

5단계 최적화: SIMD (Single Instruction, Multiple Data) 최적화

SIMD란?

SIMD는 CPU가 하나의 명령어로 여러 데이터를 동시에 처리하는 기술이다. Rust에서는 이를 자동 벡터화(Auto-vectorization)라고 한다.

예를 들어 일반적인 방식은 다음과 같이 숫자를 하나씩 계산한다.

1² → 2² → 3² → 4²

반면 SIMD를 사용하면 CPU 내부에서 여러 값을 한 번에 계산할 수 있다.

1², 2², 3², 4²

러스트 컴파일러는 특정 코드 패턴을 만나면 SIMD 명령어를 자동으로 활용할 수 있는데, fold() 형태의 누적 연산이 대표적인 예다.

SIMD 사용해 성능 개선 테스트

러스트 코드 수정 (SIMD 자동 벡터화 유도)

Rust
use pyo3::prelude::*;
use numpy::PyReadonlyArray1;
use rayon::prelude::*; // 병렬 처리를 위해 추가

#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn calculate_heavy_work(numbers: PyReadonlyArray1<i64>) -> PyResult<u128> {
    let numbers = numbers.as_slice()?; 
    
    let total: u128 = (0..100)
        .into_par_iter()
        .map(|_| {
            // fold를 사용하여 컴파일러의 SIMD 자동 벡터화를 유도
            numbers.iter()
                   .fold(0u128, |acc, &x| acc + (x as u128 * x as u128))
        })
        .sum();
    
    Ok(total)
}
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(calculate_heavy_work, m)?)?;
    Ok(())
}

기존 sum() 대신 fold()를 사용하여 컴파일러가 벡터화(Vectorization)를 수행하기 쉽도록 힌트를 제공했다.

다만 중요한 점은 이 코드만으로 SIMD가 반드시 적용된다고 보장되는 것은 아니라는 것이다. 실제 SIMD 활용 여부는 CPU 종류와 컴파일러 최적화 옵션에 따라 달라진다.

터미널에서 maturin develop을 실행한 후 파이썬 코드 실행.

파이썬 러스트 바인딩 소요 시간: 2.41212초

테스트 결과 실행 시간은 3.88초에서 2.41초로 감소했다. 이미 NumPy와 Rayon을 통해 상당 부분 최적화된 상태였음에도 추가적인 성능 향상이 나타났으며, SIMD 힌트가 실제 연산 처리 효율 개선에 도움이 되는 것을 확인할 수 있었다.

6단계 최적화: 릴리즈(–release) 빌드

지금까지 데이터 복사 제거, NumPy 메모리 공유, Rayon 병렬 처리, SIMD 힌트 적용까지 다양한 최적화를 진행했다.

하지만 한 가지 중요한 사실이 남아 있었다.

기본적으로 maturin develop디버그(Debug) 모드로 빌드된다는 점이다. 디버그 모드는 개발 과정에서 오류를 쉽게 찾기 위한 용도이므로 성능 최적화를 거의 수행하지 않는다.

Rust의 실제 성능을 확인하려면 반드시 릴리즈(Release) 모드로 빌드해야 한다.

Cargo.toml 최적화 프로필 추가

컴파일러에게 최고 수준의 최적화를 수행하도록 설정한다.

TOML
[profile.release]
opt-level = 3
lto = "fat"            # 링크 타임 최적화 (코드 전체를 하나처럼 보고 최적화)
codegen-units = 1      # 더 정밀한 최적화 수행
panic = "abort"        # 패닉 시 안전 장치를 제거하여 속도 향상

터미널에서 maturin develop –release 명령어 실행 후 빌드가 완료되면 기존과 동일한 파이썬 코드를 실행한다.

파이썬 러스트 바인딩 소요 시간: 0.32811초

이전 단계에서 약 2.41초가 소요되었던 코드가 0.33초 수준까지 감소했다.

큰 성능 향상이 나타난 순간이었다.

사실 대부분의 Rust 성능 벤치마크는 Release 모드를 기준으로 측정된다. 이번 실험에서도 가장 큰 성능 향상은 코드 수정이 아니라 컴파일 최적화 옵션에서 발생했다.

💡 –release왜 이렇게 큰 차이가 발생할까?

Rust는 기본적으로 디버그 모드로 빌드된다.

  • 디버그(Debug) : 오류를 쉽게 찾기 위해 최적화를 거의 수행하지 않음
  • 릴리즈(Release) : 실행 속도를 위해 적극적으로 최적화를 수행함

릴리즈 빌드에서는 다음과 같은 최적화가 적용된다.

  • 불필요한 코드 제거
  • 함수 인라인(Inlining)
  • 루프 최적화
  • SIMD 벡터화
  • CPU 명령어 재배치
  • 링크 타임 최적화(LTO)

즉, 지금까지 측정했던 2~4초대의 결과는 최적화가 충분히 적용되지 않은 상태였고, 릴리즈 빌드에서는 Rust 컴파일러가 CPU 성능을 최대한 활용할 수 있도록 코드를 재구성한 것이다.

그 결과 실행 시간이 0.3초 수준까지 감소했다.

더 많은 최적화 방법

이번 실험에서는 PyO3, NumPy, Rayon, SIMD 자동 벡터화 힌트, Release 빌드까지 적용하며 상당한 성능 향상을 확인할 수 있었다.

특히 NumPy를 이용한 메모리 공유, Rayon을 이용한 멀티코어 병렬 처리, 그리고 Release 빌드를 통한 컴파일러 최적화는 체감할 수 있을 정도의 성능 개선을 보여주었다.

하지만 여기서 끝이 아니다. CPU 성능을 더욱 끌어내기 위한 다양한 저수준(Low-Level) 최적화 기법들이 존재한다.

1. 데이터 레이아웃 최적화 (Memory Layout)

CPU는 데이터를 하나씩 가져오지 않는다. 일반적으로 64바이트 단위의 캐시 라인(Cache Line)으로 묶어서 한 번에 읽어온다.

따라서 데이터가 메모리에 연속적으로 배치되어 있을수록 CPU는 훨씬 효율적으로 데이터를 처리할 수 있다.

다행히 이번 실험에서 사용한 NumPy 배열은 기본적으로 연속 메모리(Contiguous Memory) 구조를 사용하며, Rust에서도 as_slice()를 통해 해당 메모리를 그대로 참조한다.

즉, 데이터 복사 비용을 제거했을 뿐만 아니라 CPU가 효율적으로 접근할 수 있는 데이터 레이아웃도 이미 확보한 상태다.

2. 메모리 정렬 (Memory Alignment)

연속 메모리와 함께 중요한 것이 메모리 정렬(Memory Alignment)이다.

CPU는 특정 경계(16바이트, 32바이트, 64바이트 등)에 맞춰 정렬된 데이터를 더 효율적으로 처리할 수 있다.

특히 SIMD 명령어를 사용할 때는 메모리 정렬 상태가 성능에 직접적인 영향을 줄 수 있다.

이번 실험에서는 NumPy가 이미 상당히 좋은 메모리 배치를 제공하기 때문에 별도의 Alignment 최적화를 수행하지 않았지만, 초대형 데이터 처리 환경에서는 추가적인 성능 향상을 기대할 수 있다.

3. 캐시 친화적 데이터 구조 (Cache-Friendly Data Layout)

같은 데이터를 저장하더라도 구조에 따라 성능 차이가 발생할 수 있다.

CPU는 가까운 위치의 데이터를 한 번에 읽어오기 때문에 자주 함께 사용하는 데이터를 인접하게 배치하면 캐시 적중률(Cache Hit Rate)을 높일 수 있다.

실제 고성능 시스템에서는 단순히 알고리즘만 최적화하는 것이 아니라 CPU 캐시 구조를 고려하여 데이터 구조 자체를 설계하기도 한다.

4. SIMD 자동 벡터화 (Auto Vectorization)

이번 실험에서는 이미 SIMD 자동 벡터화를 유도하는 코드를 사용했다.

numbers.iter()
.fold(0u128, |acc, &x| acc + (x as u128 * x as u128))

이 방식은 컴파일러가 코드를 분석한 뒤 SIMD 명령어를 사용할 수 있다고 판단하면 자동으로 벡터화(Vectorization)를 수행한다.

다만 이는 어디까지나 컴파일러에게 힌트를 제공한 것이며, 실제 SIMD 사용 여부는 CPU와 컴파일러가 결정한다.

5. 명시적 SIMD (Explicit SIMD)

자동 벡터화보다 한 단계 더 나아간 방법이다.

Rust의 std::simd 또는 CPU 전용 Intrinsics를 사용하면 개발자가 SIMD 명령어 사용을 직접 제어할 수 있다.

즉,

“컴파일러가 알아서 SIMD를 써주길 기대하는 방식”

이 아니라

“SIMD를 반드시 사용하도록 직접 작성하는 방식”

이다.

구현 난이도는 높아지지만 특정 연산에서는 자동 벡터화보다 더 높은 성능을 얻을 수 있다.

6. AVX2 / AVX-512

최신 CPU는 SIMD보다 더 강력한 벡터 명령어 집합을 지원한다.

  • AVX2 (256-bit)
  • AVX-512 (512-bit)

이러한 명령어를 활용하면 한 번의 명령어로 처리할 수 있는 데이터 양이 크게 증가한다.

수치 계산, 이미지 처리, 머신러닝 추론과 같은 분야에서 매우 강력한 성능 향상을 기대할 수 있다.

다만 CPU 지원 여부에 따라 사용 가능 여부가 달라진다.

7. CPU 전용 최적화 (target-cpu=native)

러스트는 기본적으로 다양한 CPU에서 동작할 수 있도록 보수적으로 컴파일된다.

하지만 아래 옵션을 사용하면 현재 사용 중인 CPU가 제공하는 기능을 적극적으로 활용할 수 있다.

RUSTFLAGS="-C target-cpu=native"

또는

[target]
rustflags = ["-C", "target-cpu=native"]

이를 통해 AVX2, FMA, SSE4.2 등의 기능을 활용할 수 있으며 경우에 따라 추가적인 성능 향상을 얻을 수 있다.

마무리

이번 실험에서는 단순히 “Rust가 Python보다 빠르다”는 결론을 확인하는 것이 목적이 아니었다.

오히려 Python과 Rust를 함께 사용할 때 어떤 부분이 병목이 되는지, 그리고 어떤 방식으로 성능을 개선할 수 있는지를 단계적으로 확인하는 과정에 가까웠다.

이번 테스트의 과정을 정리하면 다음과 같다.

1000만회 데이터 전달 러스트 100회 연산 기준 결과이다

단계핵심 내용소요 시간
1단계 순수 파이썬기본 반복문 연산82.37초
2단계 (Vec)기본 바인딩 (데이터 복사 비용 발생)14.86초
3단계 (NumPy)메모리 주소 직접 공유 (복사 오버헤드 제거)13.80초
4단계 (Rayon)멀티코어 병렬 처리 적용3.88초
5단계 (SIMD).fold() 패턴으로 CPU 벡터 연산 유도2.41초
6단계 (Release)--release 컴파일러 최적화 풀가동0.33초

이번 실험을 통해 PyO3 기반의 Python-Rust 연동 환경에서 데이터 복사 비용(Overhead), NumPy를 이용한 메모리 공유(Memory Sharing), Rayon 기반의 병렬 처리(Parallel Processing), SIMD(Vectorization), 그리고 Release Build 최적화가 실제 성능에 어떤 영향을 주는지 확인할 수 있었다.

특히 성능 최적화는 단순히 언어를 Rust로 바꾸는 것만으로 해결되는 문제가 아니라 데이터 전달 방식(Data Transfer), 메모리 구조(Memory Layout), CPU 활용도(CPU Utilization), 그리고 컴파일러 최적화(Compiler Optimization)가 함께 작용하는 과정이라는 점도 확인할 수 있었다.

다음에는 여기서 한 걸음 더 나아가 실제 서비스 환경에서 자주 사용하는 데이터 구조(Data Structure), 메모리 정렬(Memory Alignment), 캐시 친화적인 데이터 레이아웃(Cache-Friendly Data Layout), 명시적 SIMD(Explicit SIMD), AVX2, AVX-512 같은 CPU 명령어 집합(Instruction Set)을 적용했을 때 성능이 어떻게 달라지는지 실험해 볼 예정이다.

과연 현재의 0.3초 수준에서 성능을 얼마나 더 끌어올릴 수 있을지, 그리고 CPU가 데이터를 처리하는 방식이 실제 애플리케이션 성능에 얼마나 큰 영향을 주는지 직접 확인해 보자.

Python + Rust 바인딩 시리즈

Related posts

Determined woman throws darts at target for concept of business success and achieving set goals

댓글 남기기