더 많은 최적화 방법
이번 실험에서는 PyO3, NumPy, Rayon, SIMD 자동 벡터화 힌트, Release 빌드까지 적용하며 상당한 성능 향상을 확인할 수 있었다.
특히 NumPy를 이용한 메모리 공유, Rayon을 이용한 멀티코어 병렬 처리, 그리고 Release 빌드를 통한 컴파일러 최적화는 체감할 수 있을 정도의 성능 개선을 보여주었다.
하지만 여기서 끝이 아니다. CPU 성능을 더욱 끌어내기 위한 다양한 저수준(Low-Level) 최적화 기법들이 존재한다.
1. 데이터 레이아웃 최적화 (Memory Layout)
데이터 레이아웃 최적화는 단순히 많은 데이터를 처리하는 것이 아니라, CPU가 데이터를 얼마나 효율적으로 읽어올 수 있는지를 개선하는 기법이다.
현대 CPU는 메모리에서 데이터를 하나씩 가져오지 않는다. 일반적으로 64바이트 크기의 캐시 라인(Cache Line) 단위로 데이터를 읽어온다. 따라서 데이터가 메모리에 연속적으로 배치되어 있을수록 CPU는 캐시를 더 효율적으로 활용할 수 있고, 메모리 접근 비용도 줄어든다.
반대로 데이터가 비연속적으로 배치되어 있으면 CPU는 필요한 데이터를 찾기 위해 더 많은 메모리 접근을 수행해야 하며, 캐시 적중률(Cache Hit Rate)도 낮아질 수 있다. 결과적으로 동일한 연산을 수행하더라도 성능이 저하될 가능성이 있다.
이번 실험에서는 이러한 차이를 확인하기 위해 NumPy 배열을 일부러 비연속 메모리 구조로 만든 뒤, 다시 연속 메모리 구조로 복구하여 Rust 함수에 전달하는 테스트를 진행했다.
실험 1 – 일부러 비연속 메모리 만들기
#main.py
import py_rust_hybrid
import time
import numpy as np
def run_test(title, data):
print(f"\n=== {title} ===")
print("C_CONTIGUOUS:", data.flags["C_CONTIGUOUS"])
start_time = time.perf_counter()
try:
rust_result = py_rust_hybrid.calculate_heavy_work(data)
print(f"연산 결과: {rust_result}")
except Exception as e:
print("에러 발생:")
print(e)
end_time = time.perf_counter()
print(f"소요 시간: {end_time - start_time:.5f}초")
# 원본 배열 생성
data = np.arange(1, 10000001, dtype=np.int64)
print("원본 배열")
print("C_CONTIGUOUS:", data.flags["C_CONTIGUOUS"])
# 비연속 메모리 생성
data = data[::2]
run_test("비연속 메모리 테스트", data)
# 연속 메모리로 복구
data = np.ascontiguousarray(data)
run_test("연속 메모리 테스트", data)//lib.rs
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> {
println!("Length: {}", numbers.len()?);
let total = match numbers.as_slice() {
Ok(slice) => {
println!("Fast Path (Contiguous)");
(0..100)
.into_par_iter()
.map(|_| slice.iter().fold(0u128, |acc, &x| acc + (x as u128 * x as u128)))
.sum()
}
Err(_) => {
println!("Fallback Path (Non-Contiguous)");
let array = numbers.as_array();
(0..100)
.into_par_iter()
.map(|_| array.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(())
}원본 배열
C_CONTIGUOUS: True
=== 비연속 메모리 테스트 ===
C_CONTIGUOUS: False
Length: 5000000
Fallback Path (Non-Contiguous)
연산 결과: 16666666666666500000000
소요 시간: 0.31174초
=== 연속 메모리 테스트 ===
C_CONTIGUOUS: True
Length: 5000000
Fast Path (Contiguous)
연산 결과: 16666666666666500000000
소요 시간: 0.17893초
이번 실험에서는 동일한 데이터를 비연속 메모리와 연속 메모리 상태에서 각각 Rust 함수에 전달하여 성능 차이를 비교했다.
비연속 메모리 배열은 Fallback Path를 통해 처리되었고, 연속 메모리 배열은 as_slice() 기반의 Fast Path를 통해 처리되었다.
결과 분석
실험 결과 비연속 메모리는 약 0.38초, 연속 메모리는 약 0.17초가 소요되어 연속 메모리 구조가 약 2배 이상 빠른 성능을 보였다.
이러한 차이가 발생한 이유는 CPU 캐시 활용 방식 때문이다. 연속 메모리는 데이터가 순차적으로 배치되어 있어 CPU가 캐시 라인 단위로 데이터를 효율적으로 가져올 수 있다. 반면 비연속 메모리는 접근 시 추가적인 stride 계산이 필요하며, 캐시 적중률도 상대적으로 낮아질 수 있다.
또한 연속 메모리의 경우 Rust가 as_slice()를 통해 NumPy 버퍼를 직접 참조할 수 있기 때문에 추가적인 오버헤드 없이 최적화된 경로를 사용할 수 있다.
정리하면, NumPy와 Rust를 함께 사용할 때는 가능한 한 연속 메모리 구조를 유지하는 것이 CPU 캐시 활용과 메모리 접근 효율 측면에서 유리하다.
2. 메모리 정렬 (Memory Alignment)
연속 메모리와 함께 중요한 개념 중 하나가 메모리 정렬(Memory Alignment)이다.
CPU는 데이터를 메모리에서 읽어올 때 특정 경계(8B, 16B, 32B, 64B 등)에 맞춰 정렬된 데이터를 더 효율적으로 처리할 수 있다. 특히 SIMD(Vector) 명령어를 사용하는 경우 정렬 상태가 성능에 영향을 줄 수 있으며, 일부 아키텍처에서는 정렬된 데이터 접근이 더 적은 명령어로 처리되기도 한다.
이번 실험에서는 NumPy 배열의 시작 주소를 확인하고, 일부러 시작 위치를 한 칸 이동시켜 정렬 상태가 어떻게 달라지는지 관찰했다.
import py_rust_hybrid
import numpy as np
import time
def run_test(title, data):
print(title)
print("Address :", hex(data.ctypes.data))
print("Alignment :", data.ctypes.data % 64)
start = time.perf_counter()
result = py_rust_hybrid.calculate_heavy_work(data)
end = time.perf_counter()
print(f"Result : {result}")
print(f"Time : {end - start:.5f}초")
aligned = np.arange(
1,
10_000_001,
dtype=np.int64
)
misaligned = aligned[1:]
run_test("=======Aligned Array=======", aligned)
run_test("=======Misaligned Array=======", misaligned)//lib.rs
use pyo3::prelude::*;
use numpy::PyReadonlyArray1;
use rayon::prelude::*;
// 제곱합 계산 함수
fn square_sum(numbers: &[i64]) -> u128 {
numbers
.iter()
.fold(0u128, |acc, &x| acc + (x as u128 * x as u128))
}
#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn calculate_heavy_work(numbers: PyReadonlyArray1<i64>) -> PyResult<u128> {
let ptr = numbers.as_ptr();
println!("Memory Address: {:p}", ptr);
println!("Alignment(64B): {}", (ptr as usize) % 64);
let numbers = numbers.as_slice()?;
// Rayon을 이용하여 100번의 반복 연산을 병렬 처리
let total: u128 = (0..100)
.into_par_iter()
.map(|_| square_sum(numbers))
.sum();
Ok(total)
}
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(calculate_heavy_work, m)?)?;
Ok(())
}=======Aligned Array=======
Address : 0x16fec734040
Alignment : 0
Memory Address: 0x16fe3faec70
Alignment(64B): 48
Result : 33333338333333500000000
Time : 0.41090초
=======Misaligned Array=======
Address : 0x16fec734048
Alignment : 8
Memory Address: 0x16fe41f0570
Alignment(64B): 48
Result : 33333338333333499999900
Time : 0.36865초
원본 배열은 64바이트 경계에 맞춰 정렬되어 있었지만, aligned[1:] 슬라이싱 이후에는 시작 주소가 8바이트 이동하면서 정렬 상태가 달라졌다.
즉 NumPy 배열은 슬라이싱 시 데이터를 복사하지 않고 동일한 버퍼를 참조하기 때문에, 시작 위치에 따라 메모리 정렬 상태가 달라질 수 있다.
결과 분석
다만 이번 실험에서는 정렬 상태가 달라졌음에도 성능 차이가 뚜렷하게 나타나지는 않았다.
그 이유는 현대 CPU가 비정렬(Unaligned) 메모리 접근도 상당히 효율적으로 처리할 수 있기 때문이다. 또한 현재 연산은 단순한 순차 접근 위주의 작업이기 때문에 메모리 정렬 효과가 크게 드러나지 않았다.
실제로 메모리 정렬의 영향은 대규모 SIMD 연산이나 초고속 수치 계산 환경에서 더 크게 나타나는 경우가 많다.
따라서 이번 실험의 결론은 “정렬되지 않은 메모리가 반드시 느리다”가 아니라, NumPy 배열의 시작 주소에 따라 정렬 상태가 달라질 수 있으며, 특정 최적화 기법에서는 이러한 정렬 상태가 성능에 영향을 줄 수 있다는 점을 확인한 것이다.
3. 캐시 친화적 데이터 구조 (Cache-Friendly Data Layout)
같은 데이터를 사용하더라도 접근 방식에 따라 성능 차이가 크게 발생할 수 있다.
CPU는 메모리에서 데이터를 하나씩 가져오지 않는다. 일반적으로 캐시 라인(Cache Line) 단위로 데이터를 읽어오며, 한 번 읽어온 주변 데이터도 함께 캐시에 저장한다. 따라서 데이터를 순차적으로 접근하면 이미 캐시에 올라온 데이터를 재사용할 수 있어 매우 효율적이다.
반대로 메모리 곳곳을 무작위로 접근하면 CPU는 필요한 데이터를 계속 새로 가져와야 하며, 캐시 적중률(Cache Hit Rate)이 크게 떨어진다. 이러한 현상을 확인하기 위해 동일한 데이터에 대해 두 가지 접근 방식을 비교했다.
cache_friendly()함수는 배열을 처음부터 끝까지 순차적으로 순회한다.- 반면
cache_unfriendly()함수는 일정한 간격으로 인덱스를 건너뛰며 접근한다. - 즉 두 함수는 동일한 계산을 수행하지만, 데이터에 접근하는 방식만 다르다.
#main.py
import py_rust_hybrid
import numpy as np
import time
data = np.arange( 1, 10_000_001, dtype=np.int64)
print("\n=== Cache Friendly Test ===")
start = time.perf_counter()
result = py_rust_hybrid.cache_friendly(data)
end = time.perf_counter()
print(f"Result : {result}")
print(f"Time : {end - start:.5f}초")
print("\n=== Cache Unfriendly Test ===")
start = time.perf_counter()
result = py_rust_hybrid.cache_unfriendly(data)
end = time.perf_counter()
print(f"Result : {result}")
print(f"Time : {end - start:.5f}초")//lib.rs
use pyo3::prelude::*;
use numpy::PyReadonlyArray1;
use rayon::prelude::*;
fn cache_friendly_sum(numbers: &[i64]) -> u128 {
numbers
.iter()
.fold(0u128, |acc, &x| acc + (x as u128 * x as u128))
}
fn cache_unfriendly_sum(numbers: &[i64]) -> u128 {
let len = numbers.len();
(0..len).fold(0u128, |acc, i| {
let idx = (i * 1024) % len;
let x = numbers[idx];
acc + (x as u128 * x as u128)
})
}
#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn cache_friendly(numbers: PyReadonlyArray1<i64>) -> PyResult<u128> {
let ptr = numbers.as_ptr();
println!("=== Cache Friendly ===");
println!("Memory Address : {:p}", ptr);
println!("Alignment(64B) : {}", (ptr as usize) % 64);
let numbers = numbers.as_slice()?;
let total: u128 = (0..100)
.into_par_iter()
.map(|_| cache_friendly_sum(numbers))
.sum();
Ok(total)
}
#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn cache_unfriendly(numbers: PyReadonlyArray1<i64>) -> PyResult<u128> {
let ptr = numbers.as_ptr();
println!("=== Cache Unfriendly ===");
println!("Memory Address : {:p}", ptr);
println!("Alignment(64B) : {}", (ptr as usize) % 64);
let numbers = numbers.as_slice()?;
let total: u128 = (0..100)
.into_par_iter()
.map(|_| cache_unfriendly_sum(numbers))
.sum();
Ok(total)
}
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(cache_friendly, m)?)?;
m.add_function(wrap_pyfunction!(cache_unfriendly, m)?)?;
Ok(())
}=== Cache Friendly Test ===
=== Cache Friendly ===
Memory Address : 0x191b10dec70
Alignment(64B) : 48
Result : 33333338333333500000000
Time : 0.23266초
=== Cache Unfriendly Test ===
=== Cache Unfriendly ===
Memory Address : 0x191b10dec70
Alignment(64B) : 48
Result : 33332703335937000000000
Time : 4.54197초
캐시 친화적인 접근 방식은 약 0.23초 만에 연산을 완료했지만, 캐시 비친화적인 접근 방식은 약 4.54초가 소요되었다.
약 19배 이상의 성능 차이가 발생한 것이다.
결과 분석
이러한 차이는 CPU 캐시의 동작 방식에서 비롯된다.
순차 접근의 경우 CPU는 다음에 사용할 데이터를 미리 예측하여 캐시에 적재(Prefetch)할 수 있다. 또한 한 번 가져온 캐시 라인 내부의 데이터를 연속적으로 재사용할 수 있기 때문에 메모리 접근 비용이 크게 감소한다.
반면 비순차 접근은 CPU의 예측이 어려워지고, 이미 캐시에 올라온 데이터를 재사용할 가능성도 낮아진다. 결국 CPU는 메인 메모리에서 데이터를 반복적으로 가져와야 하며, 이 과정에서 상당한 지연이 발생한다.
실제로 이번 실험에서는 동일한 데이터와 동일한 연산을 수행했음에도 불구하고, 접근 방식만 변경했을 뿐인데 약 19배의 성능 차이가 발생했다.
이는 CPU 성능이 단순히 연산 속도에 의해 결정되는 것이 아니라, 데이터를 얼마나 캐시 친화적으로 배치하고 접근하느냐에도 크게 영향을 받는다는 사실을 보여준다.
따라서 고성능 시스템에서는 알고리즘 자체뿐만 아니라 데이터 구조와 메모리 접근 패턴 역시 중요한 최적화 요소로 취급된다. 좋은 알고리즘만큼이나 좋은 데이터 구조가 중요한 이유가 바로 여기에 있다.
4. SIMD 자동 벡터화 (Auto Vectorization)
이 실험은 이전 글에서 설명했으므로 이전 최적화 글을 참고하면 된다. 링크는 마무리 아래 부분에 첨부.
5. 명시적 SIMD (Explicit SIMD)
앞서 살펴본 자동 벡터화는 컴파일러가 SIMD 사용 여부를 결정하는 방식이었다. 이번에는 개발자가 SIMD 사용을 직접 제어하는 명시적 SIMD를 적용해 보았다.
자동 벡터화(Auto Vectorization)보다 한 단계 더 나아간 방법이 바로 명시적 SIMD(Explicit SIMD)이다.
자동 벡터화는 컴파일러가 코드를 분석한 뒤 SIMD 명령어를 사용할 수 있다고 판단할 경우에만 최적화를 수행한다. 반면 명시적 SIMD는 개발자가 직접 SIMD 벡터 타입과 연산을 사용하여 병렬 계산을 구현하는 방식이다.
이번 실험에서는 wide 크레이트의 i64x4 타입을 사용하여 4개의 정수를 하나의 벡터로 묶고, 제곱 연산을 동시에 수행하도록 구현했다.
[dependencies]
pyo3 = { version = "0.22", features = ["extension-module"] }
numpy = "0.22"
rayon = "1.12.0"
wide = "0.7"import py_rust_hybrid
import numpy as np
import time
data = np.arange(
1,
10_000_001,
dtype=np.int64
)
for name, func in [
("Normal", py_rust_hybrid.normal),
("Explicit SIMD", py_rust_hybrid.explicit_simd),
]:
print("\n" + "=" * 50)
print(name)
start = time.perf_counter()
result = func(data)
end = time.perf_counter()
print(f"Result : {result}")
print(f"Time : {end - start:.5f}초")//lib.rs
use numpy::PyReadonlyArray1;
use pyo3::prelude::*;
use rayon::prelude::*;
use wide::i64x4;
// 일반 버전
fn normal_sum(numbers: &[i64]) -> i64 {
numbers.iter().fold(0, |acc, &x| acc + x * x)
}
// Explicit SIMD 버전
fn simd_sum(numbers: &[i64]) -> i64 {
let mut total = i64x4::ZERO;
let chunks = numbers.chunks_exact(4);
for chunk in chunks.clone() {
let v = i64x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]);
total = total + (v * v);
}
let simd_total = total.as_array_ref().iter().sum::<i64>();
let remain_total = chunks.remainder().iter().map(|&x| x * x).sum::<i64>();
simd_total + remain_total
}
#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn normal(numbers: PyReadonlyArray1<i64>) -> PyResult<i64> {
let numbers = numbers.as_slice()?;
Ok(
(0..100)
.into_par_iter()
.map(|_| normal_sum(numbers))
.sum(),
)
}
#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn explicit_simd(numbers: PyReadonlyArray1<i64>) -> PyResult<i64> {
let numbers = numbers.as_slice()?;
Ok(
(0..100)
.into_par_iter()
.map(|_| simd_sum(numbers))
.sum(),
)
}
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(normal, m)?)?;
m.add_function(wrap_pyfunction!(explicit_simd, m)?)?;
Ok(())
}즉 기존 방식이 정수 하나씩 계산했다면, SIMD 버전은 하나의 명령으로 여러 개의 데이터를 동시에 처리할 수 있도록 작성한 것이다.
실험 결과는 다음과 같았다.
==============================
Normal
Result : 71792140340229888
Time : 0.37916초
==============================
Explicit SIMD
Result : 71792140340229888
Time : 0.27794초
결과 분석
동일한 데이터와 동일한 연산을 수행했음에도 명시적 SIMD 버전이 약 27% 빠른 성능을 보였다.
이러한 차이가 발생한 이유는 CPU 내부의 SIMD 연산 유닛을 보다 적극적으로 활용할 수 있었기 때문이다. SIMD는 하나의 명령으로 여러 데이터를 동시에 처리하는 구조이므로 반복적인 수치 계산에서 특히 효과적이다.
다만 SIMD가 항상 큰 성능 향상을 보장하는 것은 아니다. 현대 컴파일러는 이미 상당히 뛰어난 자동 벡터화 기능을 제공하며, 단순한 루프의 경우 컴파일러가 자동으로 SIMD 명령어를 생성하는 경우도 많다.
그럼에도 명시적 SIMD는 개발자가 벡터 연산을 직접 제어할 수 있기 때문에 자동 벡터화가 적용되지 않는 복잡한 연산이나 성능이 중요한 구간에서는 추가적인 최적화 수단으로 활용할 수 있다.
이번 실험은 컴파일러의 자동 최적화에 의존하는 것보다, SIMD를 명시적으로 사용했을 때 실제 성능 향상을 얻을 수 있다는 점을 확인한 사례라고 볼 수 있다.
SIMD 구현 방법
Rust에서 SIMD를 구현하는 방법은 크게 세 가지가 있다.
컴파일러 자동 벡터화 (Auto Vectorization)
가장 간단한 방법은 SIMD 코드를 직접 작성하지 않고 컴파일러 최적화에 맡기는 것이다.
이건 명시적 simd는 아니다. 본문의 4단계를 간단히 설명하는 내용이다.
numbers.iter().fold(0, |acc, &x| acc + x * x)이처럼 단순한 반복문은 컴파일러가 SIMD 적용이 가능하다고 판단하면 자동으로 벡터 명령어로 변환할 수 있다.
다만 이는 컴파일러의 판단에 의존하기 때문에 항상 SIMD가 적용되는 것은 아니다.
1. wide 크레이트 사용
이번 실험에서 사용한 방법이다.
use wide::i64x4;wide는 Stable Rust 환경에서 사용할 수 있는 SIMD 라이브러리로, 별도의 Nightly 버전이나 복잡한 설정 없이 SIMD 코드를 작성할 수 있다.
let v = i64x4::new([1, 2, 3, 4]);
let result = v * v;학습용이나 일반적인 성능 최적화에서는 가장 접근하기 쉬운 방법이다.
2. portable_simd (Nightly)
Rust에서는 표준 SIMD API인 portable_simd도 제공하고 있다.
#![feature(portable_simd)]
use std::simd::i64x4;다만 현재는 Nightly Rust가 필요하다.
rustup toolchain install nightly
cargo +nightly buildportable_simd는 Rust 표준 라이브러리와 통합되는 공식 SIMD API이며, 향후 Rust SIMD의 중심이 될 것으로 예상된다.
3. CPU Intrinsics 직접 사용
가장 저수준 방식이다.
use std::arch::x86_64::*;이후 AVX2, AVX-512, SSE 등의 CPU 명령어를 직접 호출할 수 있다.
unsafe {
let v = _mm256_loadu_si256(...);
}가장 높은 제어권을 제공하지만 코드가 복잡해지고 CPU 아키텍처 의존성이 커진다.
실제 고성능 라이브러리나 게임 엔진, 수치 연산 라이브러리 등에서 주로 사용된다.
이번 실험에서 wide를 선택한 이유
원래는 Rust의 표준 SIMD API인 std::simd(portable_simd)를 사용하여 명시적 SIMD를 구현하려고 했다. 하지만 portable_simd는 현재 Nightly Rust 환경이 필요하기 때문에 추가적인 설정 과정이 필요하다.
이번 실험에서는 별도의 Nightly 환경 구성 없이 Stable Rust에서 바로 사용할 수 있는 wide 크레이트를 선택했다.
wide는 SIMD 벡터 타입과 연산을 간편하게 제공하며, 기본적인 수치 연산에서는 portable_simd와 유사한 성능을 기대할 수 있다. 또한 코드가 비교적 단순하여 SIMD의 동작 원리를 학습하거나 성능 최적화 효과를 확인하기에도 적합하다.
따라서 이번 실험에서는 개발 환경의 복잡성을 줄이면서도 명시적 SIMD의 효과를 확인할 수 있는 wide를 사용하여 구현을 진행했다.
6. AVX2 / AVX-512
AVX(Advanced Vector Extensions)는 CPU가 여러 데이터를 동시에 처리할 수 있도록 제공하는 SIMD 명령어 집합이다. 대규모 수치 계산이나 과학 연산, 이미지 처리와 같은 작업에서 높은 성능 향상을 기대할 수 있다.
이번 실험에서 사용한 AVX2는 256비트 벡터 레지스터를 사용하며, i64 기준으로 한 번에 4개의 값을 동시에 처리할 수 있다. 현재 대부분의 x86-64 CPU에서 널리 지원되는 SIMD 기술 중 하나이다.
AVX-512는 AVX2의 확장 버전으로, 512비트 벡터 레지스터를 사용하여 이론적으로는 두 배 더 많은 데이터를 동시에 처리할 수 있다. 다만 AVX-512는 지원하는 CPU가 제한적이며, 실제 성능 향상 폭은 메모리 대역폭이나 캐시 구조 등의 영향을 받기 때문에 항상 2배의 성능 향상이 발생하는 것은 아니다.
이번 실험에서는 사용 중인 CPU가 AVX-512를 지원하지 않아 AVX2 기반 구현만 진행하였으며, AVX2 적용 시 어떤 성능 변화가 나타나는지 확인해 보았다.
#main.py
import numpy as np
import py_rust_hybrid
import time
a = np.arange( 1, 100_001, dtype=np.int32)
b = np.arange( 1, 100_001, dtype=np.int32)
for name, func in [
("Normal", py_rust_hybrid.normal),
("AVX2", py_rust_hybrid.avx2),
]:
start = time.perf_counter()
result = func(a, b)
end = time.perf_counter()
print(name)
print(result)
print(f"{end-start:.7f}초\n")//lib.rs
use numpy::PyReadonlyArray1;
use pyo3::prelude::*;
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
fn normal_sum(a: &[i32], b: &[i32]) -> i64 {
a.iter()
.zip(b.iter())
.map(|(&x, &y)| (x + y) as i64)
.sum()
}
#[target_feature(enable = "avx2")]
unsafe fn avx2_sum(a: &[i32], b: &[i32]) -> i64 {
let mut acc = _mm256_setzero_si256();
let chunks = a.len() / 8;
for i in 0..chunks {
let idx = i * 8;
let va = unsafe {
_mm256_loadu_si256(
a[idx..].as_ptr() as *const __m256i
)
};
let vb = unsafe {
_mm256_loadu_si256(
b[idx..].as_ptr() as *const __m256i
)
};
let sum = _mm256_add_epi32(va, vb);
acc = _mm256_add_epi32(acc, sum);
}
let mut tmp = [0i32; 8];
unsafe {
_mm256_storeu_si256(
tmp.as_mut_ptr() as *mut __m256i,
acc,
);
}
let mut total =
tmp.iter().map(|&x| x as i64).sum::<i64>();
for i in chunks * 8..a.len() {
total += (a[i] + b[i]) as i64;
}
total
}
#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn normal(
a: PyReadonlyArray1<i32>,
b: PyReadonlyArray1<i32>,
) -> PyResult<i64> {
let a = a.as_slice()?;
let b = b.as_slice()?;
Ok(normal_sum(a, b))
}
#[pyfunction]
#[allow(unsafe_op_in_unsafe_fn)]
fn avx2(
a: PyReadonlyArray1<i32>,
b: PyReadonlyArray1<i32>,
) -> PyResult<i64> {
let a = a.as_slice()?;
let b = b.as_slice()?;
Ok(unsafe { avx2_sum(a, b) })
}
#[pymodule]
fn py_rust_hybrid(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(normal, m)?)?;
m.add_function(wrap_pyfunction!(avx2, m)?)?;
Ok(())
}Windows
set RUSTFLAGS=-C target-cpu=native
maturin develop --releaseLinux
export RUSTFLAGS="-C target-cpu=native"
maturin develop --releaseNormal
10000100000
0.0003202초
AVX2
10000100000
0.0000311초
본 실험은 AVX2의 _mm256_add_epi32() 명령을 사용하여 배열 덧셈 연산을 SIMD 방식으로 수행하였다. 결과의 정확성을 유지하기 위해 입력 데이터는 i32 범위 내에서 안전하게 처리될 수 있는 크기로 설정하였다.
결과 분석
이번 실험에서는 일반 구현이 약 0.000320초, AVX2 구현이 약 0.000031초로 측정되어 약 10배 빠른 처리 속도 향상을 확인할 수 있었다.
이전 실험에서는 SIMD 레지스터를 일부 활용하더라도 실제 연산의 상당 부분을 일반 Rust 코드가 수행했지만, 이번 구현은 AVX2 Intrinsics를 사용하여 배열 덧셈 연산을 벡터 레지스터 내부에서 직접 수행하였다.
AVX2는 256비트 벡터 레지스터를 사용하므로 i32 기준으로 한 번에 8개의 값을 동시에 처리할 수 있다. 따라서 동일한 연산을 수행하더라도 일반 반복문 방식보다 더 많은 데이터를 한 번에 계산할 수 있다.
이번 성능 향상은 다음과 같은 요인에 의해 발생한 것으로 볼 수 있다.
- 명시적 SIMD 적용
_mm256_add_epi32()를 사용하여 벡터 레지스터 내부에서 직접 연산을 수행하였다.
- AVX2 병렬 처리
- 하나의 명령어로 8개의 i32 데이터를 동시에 처리할 수 있다.
- Release 빌드 최적화
- 컴파일러의 루프 최적화와 불필요한 연산 제거가 함께 적용되었다.
- target-cpu=native 적용
- CPU가 지원하는 AVX2 명령어를 최대한 활용할 수 있도록 컴파일되었다.
다만 이번 실험은 비교적 단순한 배열 덧셈 연산을 대상으로 수행되었기 때문에 SIMD의 잠재적인 성능을 모두 활용한 사례라고 보기는 어렵다. 실제 수치 계산, 행렬 연산, 이미지 처리와 같이 연산량이 많은 작업에서는 SIMD의 효과가 더욱 크게 나타날 수 있다.
이번 실험을 통해 AVX2를 이용한 명시적 SIMD 구현이 실제 성능 향상에 효과적이라는 점을 확인할 수 있었다. 또한 SIMD 명령어를 직접 제어함으로써 컴파일러의 자동 벡터화에 의존하지 않고 벡터 연산을 수행할 수 있다는 점도 확인할 수 있었다.
다만 현재 구현은 SIMD 활용의 기초적인 형태에 가깝다. 실제 고성능 SIMD 구현에서는 수평 합산(Horizontal Reduction), 데이터 정렬(Alignment), FMA(Fused Multiply Add) 등의 최적화 기법을 적용하여 성능을 더욱 향상시킬 수 있다. 이후에는 이러한 기법들을 활용한 보다 발전된 SIMD 구현도 살펴볼 예정이다.
7. CPU 전용 최적화 (target-cpu=native)
빌드 옵션
AVX2 명령어를 최대한 활용하기 위해 Rust 컴파일 시 target-cpu=native 옵션을 적용하였다. 이 옵션은 현재 사용 중인 CPU의 명령어 집합과 하드웨어 특성을 기준으로 코드를 최적화하여 컴파일한다.
이를 통해 AVX2뿐만 아니라 CPU가 지원하는 추가적인 최적화 기능(FMA 등)도 함께 활용할 수 있다.
Windows
set RUSTFLAGS=-C target-cpu=native
maturin develop --releaseLinux
export RUSTFLAGS="-C target-cpu=native"
maturin develop --release마무리
이번 글에서는 Rust에서 SIMD의 개념을 살펴보고, Explicit SIMD와 AVX2 Intrinsics를 활용하여 실제 성능 변화를 측정해 보았다.
실험 결과, 동일한 작업이라도 SIMD를 적용하면 여러 데이터를 동시에 처리할 수 있기 때문에 상당한 성능 향상을 얻을 수 있음을 확인할 수 있었다. 특히 AVX2를 직접 사용한 구현에서는 벡터 레지스터 내부에서 연산이 수행되면서 일반 반복문 기반 구현보다 훨씬 빠른 실행 속도를 보여주었다.
물론 이번 예제는 SIMD의 기본적인 활용 방법을 확인하기 위한 단순한 실험에 가깝다. 실제 고성능 애플리케이션에서는 데이터 정렬(Alignment), 수평 합산(Horizontal Reduction), FMA(Fused Multiply Add) 등의 추가 최적화 기법이 함께 사용되며, 더욱 큰 성능 향상을 기대할 수 있다.
다음 글에서는 이러한 고급 SIMD 최적화 기법들을 적용하면서 Rust에서 AVX2를 보다 효율적으로 활용하는 방법을 살펴보고자 한다. 이를 통해 단순한 벡터 연산을 넘어 실제 고성능 수치 계산에 가까운 SIMD 구현을 진행해 볼 예정이다.
Python + Rust 바인딩 시리즈



