본문 바로가기

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

명시적 루프와 반복자 기반 검색을 벤치마크하고 단형화·인라이닝·제로 비용 추상화가 성능을 유지하는 이유를 이해합니다.

루프와 반복자 가운데 무엇을 쓸지 정하려면, 어느 구현이 더 빠른지 알아야 합니다. 명시적인 for 루프를 쓴 search 함수와 반복자를 쓴 search 함수 가운데 말입니다. 공식 Book(The Rust Programming Language)의 저자들은 아서 코넌 도일의 『셜록 홈스의 모험』 전체를 String으로 읽어 들이고 그 내용에서 the라는 단어를 찾는 벤치마크를 돌렸습니다. 다음은 for 루프를 쓴 search와 반복자를 쓴 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)

두 구현의 성능은 비슷합니다. 반복자 쪽의 평균이 385,400ns, 약 1.96퍼센트 작지만, 괄호 안의 변동 범위가 서로 겹칩니다. 이 숫자로 성능 결론을 낼 때 무엇을 확인해야 하는지는 아래 그림과 같습니다.

반복자 성능 주장을 검증하는 증거 사다리

같은 작업 계약→입력/빌드 조건 기록→반복 측정→변동성 포함 제한된 결론이라는 증거 수집 절차이다. 과거 숫자에서 모든 실행 우위를 만들지 않는다.

RUST · PERFORMANCE EVIDENCE

성능 결론에는 입력·설정·측정 조건이 필요하다

본문의 역사적 검색 벤치마크와 최적화 설명을 구분합니다. 제시된 숫자의 적용 범위와 실제 비교에 필요한 조건을 함께 읽습니다.

같은 작업부터 측정 결론까지 근거를 잇는다네노드세의존간선. 반환내용/순서등같은작업을확인하고입력/프로필/타깃을기록한뒤반복측정한다. 관찰값과변동성범위를해석해해당조건의결론을낸다. 현재과거보고385400ns차이약1.96퍼센트/겹치는표시범위를모든실행의우위나신뢰구간으로일반화하지않는다. 언롤링과IR/assembly증거는아래별도조건이며실제컴파일실행을이그림으로주장하지않는다.
1 · 같은 작업반환·순서·조기 종료할당·오류 계약
연결 관계1 · 같은 작업 · 반환·순서·조기 종료 · 할당·오류 계약 → 2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃
2 · 조건 기록입력·컴파일러프로필·타깃
연결 관계2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃 → 3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의
3 · 반복 측정보고값 + 변동성측정 범위의 정의
연결 관계3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의 → 4 · 범위 있는 결론 · 385,400 ns / 약 1.96% · 표시 범위는 겹침
4 · 범위 있는 결론385,400 ns / 약 1.96%표시 범위는 겹침
같은 작업부터 측정 결론까지 근거를 잇는다네노드세의존간선. 반환내용/순서등같은작업을확인하고입력/프로필/타깃을기록한뒤반복측정한다. 관찰값과변동성범위를해석해해당조건의결론을낸다. 현재과거보고385400ns차이약1.96퍼센트/겹치는표시범위를모든실행의우위나신뢰구간으로일반화하지않는다. 언롤링과IR/assembly증거는아래별도조건이며실제컴파일실행을이그림으로주장하지않는다.
1 · 같은 작업내용·순서·조기 종료할당·오류 계약
연결 관계1 · 같은 작업 · 내용·순서·조기 종료 · 할당·오류 계약 → 2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃
2 · 조건 기록입력·컴파일러프로필·타깃
연결 관계2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃 → 3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의
3 · 반복 측정보고값 + 변동성측정 범위의 정의
연결 관계3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의 → 4 · 조건 안의 결론 · 385,400 ns 차이 · 약 1.96% / 구간 겹침
4 · 조건 안의 결론385,400 ns 차이약 1.96% / 구간 겹침

1 · 같은 작업 확인

루프와 반복자의 반환값, 순서, 조기 종료, 할당 및 오류 처리가 같은지 확인합니다. 다른 일을 하는 코드는 같은 성능 질문에 답하지 않습니다.

2 · 설정과 입력 고정

release 또는 bench 프로필, 컴파일러·타깃·대표 입력을 기록합니다. 결과가 쓰이도록 하고 반복 측정하여 변동성을 살핍니다.

3 · 본문의 보고값 읽기

for : 19,620,300 ns/iter ±915,700
iter: 19,234,900 ns/iter ±657,200

본문의 과거 보고값입니다. 단일 평균 약 2% 차이와 겹치는 변동 범위만으로 보편적인 우위나 완전한 동등성을 증명하지 않습니다.

4 · 필요한 최적화 근거 확인

본문의 12개 계수 zip·map·sum은 언롤링·인라이닝·경계 검사 제거가 가능한 예입니다. 실제 결과는 코드·컴파일러·프로필·타깃에 따라 달라지므로 필요할 때 IR/assembly를 확인합니다.

zero-cost abstraction은 추상화 때문에 피할 수 없는 추가 비용을 만들지 않으려는 목표입니다. 모든 반복자 체인의 총비용이 0이거나 모든 루프보다 빠르다는 보장은 아닙니다. 결론에는 실제 측정 조건을 적습니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명네 노드와 세 의존 간선이다. 반환 내용과 순서 등 같은 작업을 확인하고 입력/프로필/타깃을 기록한 뒤 반복 측정한다. 관찰값과 변동성 범위를 해석해 해당 조건의 결론을 낸다. 과거에 보고된 385400 ns 차이 약 1.96퍼센트와 겹치는 표시 범위를 모든 실행의 우위나 신뢰 구간으로 일반화하지 않는다. 언롤링과 IR/assembly 증거는 아래 별도 조건이며 실제 컴파일 실행을 이 그림으로 주장하지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 계약

비교할 구현이 같은 결과를 내는지 확인한다.

  • 기준: 같은 작업 계약
  • 조건: 입력·빌드 기록
  • 다른 일을 빠르게 수행한 결과는 성능 비교가 아니다.
반복 측정

같은 조건에서 측정과 변동성을 기록한다.

  • 근거: 반복 측정
  • 포함: 변동성
  • 한 번의 우연한 시간으로 보편 우위를 주장하지 않는다.
제한된 결론

관측 범위 안에서만 결과를 해석한다.

  • 범위: 기록한 환경·입력
  • 과거 숫자: 일반적 보장 아님
  • 본문의 역사적 측정은 현재 모든 실행의 순위를 증명하지 않는다.

반복자 성능 주장을 검증하는 증거 사다리

같은 작업 계약→입력/빌드 조건 기록→반복 측정→변동성 포함 제한된 결론이라는 증거 수집 절차이다. 과거 숫자에서 모든 실행 우위를 만들지 않는다.

RUST · PERFORMANCE EVIDENCE

성능 결론에는 입력·설정·측정 조건이 필요하다

본문의 역사적 검색 벤치마크와 최적화 설명을 구분합니다. 제시된 숫자의 적용 범위와 실제 비교에 필요한 조건을 함께 읽습니다.

같은 작업부터 측정 결론까지 근거를 잇는다네노드세의존간선. 반환내용/순서등같은작업을확인하고입력/프로필/타깃을기록한뒤반복측정한다. 관찰값과변동성범위를해석해해당조건의결론을낸다. 현재과거보고385400ns차이약1.96퍼센트/겹치는표시범위를모든실행의우위나신뢰구간으로일반화하지않는다. 언롤링과IR/assembly증거는아래별도조건이며실제컴파일실행을이그림으로주장하지않는다.
1 · 같은 작업반환·순서·조기 종료할당·오류 계약
연결 관계1 · 같은 작업 · 반환·순서·조기 종료 · 할당·오류 계약 → 2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃
2 · 조건 기록입력·컴파일러프로필·타깃
연결 관계2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃 → 3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의
3 · 반복 측정보고값 + 변동성측정 범위의 정의
연결 관계3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의 → 4 · 범위 있는 결론 · 385,400 ns / 약 1.96% · 표시 범위는 겹침
4 · 범위 있는 결론385,400 ns / 약 1.96%표시 범위는 겹침
같은 작업부터 측정 결론까지 근거를 잇는다네노드세의존간선. 반환내용/순서등같은작업을확인하고입력/프로필/타깃을기록한뒤반복측정한다. 관찰값과변동성범위를해석해해당조건의결론을낸다. 현재과거보고385400ns차이약1.96퍼센트/겹치는표시범위를모든실행의우위나신뢰구간으로일반화하지않는다. 언롤링과IR/assembly증거는아래별도조건이며실제컴파일실행을이그림으로주장하지않는다.
1 · 같은 작업내용·순서·조기 종료할당·오류 계약
연결 관계1 · 같은 작업 · 내용·순서·조기 종료 · 할당·오류 계약 → 2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃
2 · 조건 기록입력·컴파일러프로필·타깃
연결 관계2 · 조건 기록 · 입력·컴파일러 · 프로필·타깃 → 3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의
3 · 반복 측정보고값 + 변동성측정 범위의 정의
연결 관계3 · 반복 측정 · 보고값 + 변동성 · 측정 범위의 정의 → 4 · 조건 안의 결론 · 385,400 ns 차이 · 약 1.96% / 구간 겹침
4 · 조건 안의 결론385,400 ns 차이약 1.96% / 구간 겹침

1 · 같은 작업 확인

루프와 반복자의 반환값, 순서, 조기 종료, 할당 및 오류 처리가 같은지 확인합니다. 다른 일을 하는 코드는 같은 성능 질문에 답하지 않습니다.

2 · 설정과 입력 고정

release 또는 bench 프로필, 컴파일러·타깃·대표 입력을 기록합니다. 결과가 쓰이도록 하고 반복 측정하여 변동성을 살핍니다.

3 · 본문의 보고값 읽기

for : 19,620,300 ns/iter ±915,700
iter: 19,234,900 ns/iter ±657,200

본문의 과거 보고값입니다. 단일 평균 약 2% 차이와 겹치는 변동 범위만으로 보편적인 우위나 완전한 동등성을 증명하지 않습니다.

4 · 필요한 최적화 근거 확인

본문의 12개 계수 zip·map·sum은 언롤링·인라이닝·경계 검사 제거가 가능한 예입니다. 실제 결과는 코드·컴파일러·프로필·타깃에 따라 달라지므로 필요할 때 IR/assembly를 확인합니다.

zero-cost abstraction은 추상화 때문에 피할 수 없는 추가 비용을 만들지 않으려는 목표입니다. 모든 반복자 체인의 총비용이 0이거나 모든 루프보다 빠르다는 보장은 아닙니다. 결론에는 실제 측정 조건을 적습니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명네 노드와 세 의존 간선이다. 반환 내용과 순서 등 같은 작업을 확인하고 입력/프로필/타깃을 기록한 뒤 반복 측정한다. 관찰값과 변동성 범위를 해석해 해당 조건의 결론을 낸다. 과거에 보고된 385400 ns 차이 약 1.96퍼센트와 겹치는 표시 범위를 모든 실행의 우위나 신뢰 구간으로 일반화하지 않는다. 언롤링과 IR/assembly 증거는 아래 별도 조건이며 실제 컴파일 실행을 이 그림으로 주장하지 않는다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
같은 계약

비교할 구현이 같은 결과를 내는지 확인한다.

  • 기준: 같은 작업 계약
  • 조건: 입력·빌드 기록
  • 다른 일을 빠르게 수행한 결과는 성능 비교가 아니다.
반복 측정

같은 조건에서 측정과 변동성을 기록한다.

  • 근거: 반복 측정
  • 포함: 변동성
  • 한 번의 우연한 시간으로 보편 우위를 주장하지 않는다.
제한된 결론

관측 범위 안에서만 결과를 해석한다.

  • 범위: 기록한 환경·입력
  • 과거 숫자: 일반적 보장 아님
  • 본문의 역사적 측정은 현재 모든 실행의 순위를 증명하지 않는다.

먼저 비교하는 두 구현이 같은 일을 하는지 확인합니다. 돌려주는 값과 순서, 일찍 멈추는지, 할당과 오류 처리가 같아야 하며, 다른 일을 하는 코드는 같은 성능 질문에 답하지 않습니다. 그다음 release나 bench 프로필, 컴파일러, 타깃, 대표 입력을 기록하고, 결과가 실제로 쓰이게 한 채 반복 측정해 변동성을 봅니다. 위의 숫자는 과거에 보고된 값이며, 한 번의 평균에서 난 약 2퍼센트 차이와 겹치는 변동 범위만으로는 어느 한쪽이 언제나 빠르다고도, 완전히 같다고도 말할 수 없습니다. 결론은 측정한 조건 안에서만 냅니다.

이 벤치마크 코드는 설명하지 않습니다. 여기서 말하려는 것은 두 구현이 똑같다는 것을 증명하는 것이 아니라, 두 구현의 성능을 대략 비교해 보는 것이기 때문입니다. 더 종합적인 벤치마크를 하려면 크기가 다른 여러 텍스트를 contents로, 길이가 다른 여러 단어를 query로 쓰는 등 여러 조합을 확인해야 합니다.

요점은 이렇습니다. 반복자는 고수준의 추상화이지만, 컴파일하면 직접 쓴 저수준의 코드와 대략 같은 코드가 됩니다. 반복자는 러스트의 비용 없는 추상화(zero-cost abstraction) 가운데 하나로, 추상화를 쓴다고 해서 런타임에 추가 비용이 생기지 않는다는 뜻입니다. 이것은 C++의 처음 설계자이자 구현자인 비야네 스트롭스트룹(Bjarne Stroustrup)이 말한 제로 오버헤드(zero-overhead) 원칙과 비슷합니다. 쓰지 않는 기능에는 비용을 치르지 않고, 쓰는 기능은 손으로 직접 짜도 그보다 낫게 만들 수 없어야 한다는 원칙입니다.

다른 예로, 다음 코드는 오디오 디코더에서 가져온 것입니다. 디코딩 알고리즘은 선형 예측(linear prediction)이라는 수학 연산으로, 이전 샘플들의 선형 함수에 기반해 앞으로의 값을 추정합니다. 이 코드는 반복자 체인으로 스코프에 있는 세 변수에 수학 연산을 합니다. 데이터의 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개 값을 하나씩 돌며 zip 메서드로 계수 값과 buffer의 바로 앞 12개 값을 짝짓습니다. 그다음 각 쌍의 값을 곱하고, 결과를 모두 더한 뒤, 합계의 비트를 오른쪽으로 qlp_shift만큼 시프트합니다.

오디오 디코더 같은 애플리케이션의 계산에서는 성능을 가장 중요하게 여기는 경우가 많습니다. 여기서는 반복자를 만들고, 어댑터 두 개를 쓰고, 값을 소비합니다. 이 러스트 코드는 어떤 어셈블리 코드로 컴파일될까요? 이 글을 쓰는 시점에는, 손으로 직접 쓸 어셈블리와 같은 코드로 컴파일됩니다. coefficients의 값들을 도는 반복에 해당하는 루프는 전혀 없습니다. 러스트는 반복이 12번이라는 것을 알므로 루프를 펼칩니다(unroll). 펼치기(unrolling)는 루프를 제어하는 코드의 부담을 없애고, 루프를 매번 반복하는 코드를 되풀이해 늘어놓는 최적화입니다.

계수는 모두 레지스터에 저장되므로 값에 아주 빨리 접근할 수 있습니다. 런타임에 배열 접근의 범위를 검사하는 일도 없습니다. 러스트가 할 수 있는 이 모든 최적화 덕분에 결과 코드는 아주 효율적입니다. 이제 이것을 알았으니 반복자와 클로저를 두려움 없이 쓸 수 있습니다. 코드가 더 고수준으로 보이게 해 주면서도 그렇게 해서 런타임 성능을 잃지 않기 때문입니다.

다만 그림의 마지막 칸처럼, 이 예의 12개 계수에 쓰인 zip, map, sum은 펼치기와 인라이닝(inlining), 범위 검사 제거가 가능한 경우의 예입니다. 실제 결과는 코드와 컴파일러, 프로필, 타깃에 따라 달라지므로, 필요하다면 만들어진 IR이나 어셈블리를 직접 확인합니다. 비용 없는 추상화는 추상화 때문에 피할 수 없는 추가 비용을 만들지 않으려는 목표이며, 모든 반복자 체인의 비용이 0이라거나 모든 루프보다 빠르다는 보장은 아닙니다.

정리

  • 반복자를 쓴 search와 for 루프를 쓴 search는 보고된 벤치마크에서 성능이 비슷했으며, 약 2퍼센트의 평균 차이는 변동 범위 안에 있었습니다.
  • 성능을 비교할 때는 두 구현이 같은 일을 하는지 확인하고, 프로필과 컴파일러, 타깃, 입력을 기록한 채 반복 측정하며, 결론은 그 조건 안에서만 냅니다.
  • 반복자와 클로저는 러스트의 비용 없는 추상화로, 컴파일하면 직접 쓴 저수준 코드와 대략 같은 코드가 됩니다.
  • 반복 횟수가 정해진 반복자 체인은 루프 펼치기와 범위 검사 제거 같은 최적화를 받을 수 있지만, 실제로 그렇게 되는지는 상황에 따라 다릅니다.

연습 문제

1. 공정한 비교일까

한 사람이 for 루프로 일치하는 줄을 모두 Vec에 모으는 search와, lines().find(|line| line.contains(query))로 첫 줄만 찾는 반복자 버전을 비교해 반복자가 훨씬 빠르다고 결론 내렸습니다. 무엇이 문제인가요?

해설 보기

두 구현이 같은 일을 하지 않습니다. find는 첫 번째로 일치하는 줄을 찾으면 멈추고 벡터도 만들지 않지만, 루프 버전은 끝까지 돌며 일치하는 줄을 모두 모읍니다. 그래서 이 차이는 루프와 반복자의 차이가 아니라 하는 일의 차이입니다. 공정하게 비교하려면 두 쪽 모두 일치하는 줄을 모두 모으거나, 두 쪽 모두 첫 줄에서 멈추도록 맞춘 뒤 같은 조건에서 측정합니다.

2. 어떤 조건을 적어야 할까

반복자 버전이 3퍼센트 빨랐다는 측정 결과를 팀에 공유하려 합니다. 결과와 함께 무엇을 적어야 하나요?

해설 보기

release 같은 빌드 프로필, 컴파일러 버전, 타깃 플랫폼, 입력의 종류와 크기를 적고, 몇 번 반복 측정했는지와 변동 범위도 함께 적습니다. 변동 범위가 3퍼센트보다 크게 겹친다면 차이가 있다고 말하기 어렵습니다. 결론도 측정한 입력과 조건 안에서의 결과라고 적어, 모든 경우에 반복자가 빠르다는 뜻으로 읽히지 않게 합니다.

이것으로 12장을 마칩니다. 다음 장의 릴리즈 프로필 설정부터는 Cargo와 crates.io의 기능을 더 살펴봅니다.