성능 비교: 루프 vs 이터레이터
루프와 이터레이터 중 어떤 것을 사용할지 결정하려면, 두 구현 중 어떤 것이 더 빠른지 알아야 한다. 명시적인 for
루프를 사용한 search
함수 버전과 이터레이터를 사용한 버전의 성능을 비교해 보자.
우리는 아서 코난 도일 경의 《셜록 홈즈의 모험》 전체 내용을 String
으로 불러온 후, 그 안에서 단어 _the_를 찾는 벤치마크를 실행했다. for
루프를 사용한 search
버전과 이터레이터를 사용한 버전의 벤치마크 결과는 다음과 같다:
test bench_search_for ... bench: 19,620,300 ns/iter (+/- 915,700)
test bench_search_iter ... bench: 19,234,900 ns/iter (+/- 657,200)
두 구현의 성능이 거의 비슷하다! 여기서 벤치마크 코드를 자세히 설명하지는 않을 것인데, 그 이유는 두 버전이 동등하다는 것을 증명하려는 것이 아니라, 두 구현의 성능이 어떻게 비교되는지 대략적으로 파악하는 것이 목적이기 때문이다.
더 포괄적인 벤치마크를 위해서는 다양한 크기의 텍스트를 contents
로 사용하고, 다양한 단어와 길이의 단어를 query
로 사용하며, 다른 여러 변형을 적용해 보는 것이 좋다. 중요한 점은 이터레이터가 높은 수준의 추상화임에도 불구하고, 직접 저수준 코드를 작성한 것과 거의 동일한 코드로 컴파일된다는 것이다. 이터레이터는 Rust의 제로 코스트 추상화 중 하나로, 이 추상화를 사용해도 런타임 오버헤드가 추가되지 않는다. 이는 C++의 원래 설계자이자 구현자인 비야네 스트롭스트룹이 《Foundations of C++》(2012)에서 정의한 _제로 오버헤드_와 유사하다:
일반적으로 C++ 구현은 제로 오버헤드 원칙을 따른다: 사용하지 않는 것에 대해서는 비용을 지불하지 않는다. 그리고 더 나아가: 사용하는 것에 대해서는 손으로 작성한 코드보다 더 나은 코드를 만들 수 없다.
또 다른 예로, 다음 코드는 오디오 디코더에서 가져온 것이다. 디코딩 알고리즘은 선형 예측 수학 연산을 사용해 이전 샘플의 선형 함수를 기반으로 미래 값을 예측한다. 이 코드는 스코프 내의 세 변수인 데이터의 buffer
슬라이스, 12개의 coefficients
배열, 그리고 qlp_shift
에서 데이터를 이동시킬 양에 대해 수학 연산을 수행하기 위해 이터레이터 체인을 사용한다. 이 예제에서 변수들을 선언했지만 값을 할당하지는 않았다. 이 코드는 컨텍스트 외부에서는 큰 의미가 없지만, Rust가 높은 수준의 아이디어를 저수준 코드로 어떻게 변환하는지에 대한 간결하고 실제적인 예제이다.
let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;
for i in 12..buffer.len() {
let prediction = coefficients.iter()
.zip(&buffer[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;
let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}
prediction
값을 계산하기 위해, 이 코드는 coefficients
의 12개 값을 순회하며 zip
메서드를 사용해 계수 값과 buffer
의 이전 12개 값을 짝지은 다음, 각 쌍의 값을 곱하고 모든 결과를 합산한 후, 그 합을 qlp_shift
비트만큼 오른쪽으로 이동시킨다.
오디오 디코더와 같은 애플리케이션에서의 계산은 종종 성능을 가장 중요시한다. 여기서는 이터레이터를 생성하고, 두 개의 어댑터를 사용한 다음, 값을 소비한다. 이 Rust 코드가 어떤 어셈블리 코드로 컴파일될까? 현재 시점에서는, 이 코드는 직접 작성한 어셈블리 코드와 동일하게 컴파일된다. coefficients
의 값을 순회하는 데 해당하는 루프는 전혀 없다: Rust는 반복 횟수가 12번임을 알고 있기 때문에 루프를 “풀어버린다”. _루프 풀기_는 루프 제어 코드의 오버헤드를 제거하고 대신 각 반복에 대해 반복적인 코드를 생성하는 최적화 기법이다.
모든 계수는 레지스터에 저장되며, 이는 값에 접근하는 속도가 매우 빠르다는 것을 의미한다. 런타임에 배열 접근에 대한 경계 검사도 없다. Rust가 적용할 수 있는 이러한 모든 최적화는 결과 코드를 매우 효율적으로 만든다. 이제 이 사실을 알았으니, 이터레이터와 클로저를 두려움 없이 사용할 수 있다! 이들은 코드를 더 높은 수준으로 보이게 하지만, 그렇게 해도 런타임 성능에 페널티를 주지 않는다.
요약
클로저와 이터레이터는 함수형 프로그래밍 언어에서 아이디어를 얻은 Rust의 기능이다. 이들은 Rust가 높은 수준의 개념을 낮은 수준의 성능으로 명확하게 표현할 수 있도록 돕는다. 클로저와 이터레이터의 구현은 런타임 성능에 영향을 미치지 않도록 설계되었다. 이는 Rust가 추구하는 ‘제로 코스트 추상화’ 목표의 일환이다.
이제 I/O 프로젝트의 표현력을 개선했으니, 프로젝트를 세상과 공유하는 데 도움이 될 cargo
의 몇 가지 기능을 살펴보자.