이 문서는 2판 번역본입니다.

최신 2021 에디션 문서는 https://doc.rust-kr.org 에서 확인하실 수 있습니다.

성능 비교하기: 루프 vs. 반복자

루프와 반복자 중에 어떤것을 사용할지 결정하기 위해, 어떤 버전의 search 함수가 더 빠른지 알 필요가 있습니다: 명시적으로 for 루프를 사용한 버전과 반복자를 사용한 버전.

우리는 아서 코난 도일이 쓴 셜록 홈즈의 모험 의 전체 내용을 로딩하고 내용중에 the 를 찾는 벤치마크를 돌렸습니다. 여기 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)

반복자 버전이 약간더 빠릅니다! 여기서 벤치마크 코드에 대해 설명하진 않을 것 입니 다. 왜냐하면 핵심은 두 버전이 동등하다는 것을 증명하는 것이 아니고, 이 두 구현 방법이 성능 측면에서 어떻게 다른지에 대한 상식적인 이해를 얻는 것이기 때문 입니 다.

더 포괄적인 벤치마크를 위해, 다양한 크기의 다양한 텍스트를 내용 으로 사용하고, 다른 길이의 다른 단어들을 질의어 로 사용해서 모든 종류의 다른 조합을 확인 하는 것이 좋습니다. 핵심은 이렇습니다: 반복자는 비록 고수준의 추상이지만, 컴파일 되면 대략 직접 작성한 저수준의 코드와 같은 코드 수준으로 내려갑니다. 반복자는 러스트의 제로 비용 추상화 중 하나이며, 그 추상을 사용하는 것은 추가 적인 실행시간 오버헤드가 없다는 것을 의미 합니다. 최초의 C++ 디자이너 이자 구현자인 비야네 스트롭스트룹이 “Foundations of C++” (2012) 에서 제로 오버헤드 를 정의한 것과 유사 합니다:

일반적으로, C++ 구현은 제로-오버헤드 원리를 따릅니다: 사용하지 않는 것은, 비용을 지불하지 않습니다. 그리고 더 나아가: 사용하는 것은, 더 나은 코드를 제공할 수 없습니다.

다른 예로, 다음 코드는 오디오 디코더에서 가져왔습니다. 디코딩 알고리즘은 이전 샘플의 선형 함수에 기반해서 미래의 값을 추정하기 위해 선형 예측이라는 수학적 연산을 사용합니다. 이 코드는 반복자 체인을 사용해서 스코프에 있는 세 개의 변수로 수학 연산을 합니다: 데이터의 buffer 슬라이스, 12 개의 coefficients 배열, 그리고 데이터를 쉬프트 하기 위한 qlp_shift 값. 이 예제에서 변수를 선언 했지만 값은 주지 않았습니다; 이 코드는 이 문맥밖에서는 크게 의미가 없지만, 러스트가 어떻게 고수준의 개념을 저수준의 코드로 변환하는지 에 대한 간결하고 실제적인 예제 입니다.

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개의 값을 순회하면서 각각의 계수와 buffer 의 이전 12개의 값의 쌍을 만들기 위해 zip 메서드를 사용 합니다. 그런 다음, 각 쌍에 대해 값들을 모두 곱하고 모든 결과를 더한 후 더한 값을 qlp_shift 비트 만큼 우측으로 쉬프트 합니다.

오디오 디코더와 같은 어플리케이션에서의 계산은 종종 성능에 가장 높은 우선순위를 둡니다. 여기서 우리는 두 개의 어댑터를 사용하는 반복자를 생성하고 값을 소비 했습니다. 이 러스트 코드가 컴파일 되면 어떤 어셈블리 코드가 될 까요? 글쎄요, 이 글을 쓰는 시점에선 그것은 직접 손으로 작성한 것과 같은 어셈블리 코드로 컴파일 됩니다. 거기엔 coefficients 의 값들을 순회하기 위한 어떤 루프도 없습니다: 러스트는 12개의 순회가 있다는 것을 알고 있으며, 루프를 "풀어(unrolls)" 놓습니다. 언롤링(Unrolling) 은 루프 제어 코드의 오버헤드를 제거하고 대신 루프의 각 순회에 해당하는 반복적인 코드를 생성하는 최적화 방법 입니다.

모든 계수들은 레지스터에 저장되는데 값에 대한 접근이 매우 빠르다는 것을 뜻합 니다. 실행시간에 배열 접근에 대한 경계 체크가 없습니다. 러스트가 적용할 수 있는 이런 모든 최적화들은 결과 코드를 아주 효율적으로 만듭니다. 이제 이것을 알게 되었으니, 반복자와 클로저를 공포없이 사용할 수 있습니다! 이것들은 코드를 고수준 으로 보이도록 하지만, 그렇게 하기 위해 실행시간 성능 저하를 만들지 않습니다.

요약

클로저와 반복자는 함수형 프로그래밍 아이디어에서 영감을 받은 러스트의 특징들 입니다. 이것들은 고수준의 개념을 저수준의 성능으로 명확하게 표현할 수 있는 러스트의 능력에 기여하고 있습니다. 클로저와 반복자의 구현들은 런타임 성능에 영향을 미치지 않습니다. 이것은 제로-비용 추상을 제공하기 위해 노력하는 러스트의 목표 중의 일부 입니다.

이제 I/O 프로젝트의 표현력을 개선 했으니, 프로젝트를 세상과 공유하는데 도움을 줄 cargo 의 몇몇 특징들을 살펴 봅시다.