느린 곳을 짐작으로 고치면 대개 엉뚱한 곳을 고친다. 그래서 먼저 재고, 가장 오래 걸리는 곳 하나만 고치고, 다시 잰다. Rust에서는 빌드 설정만 바꿔도 열 배가 빨라지기도 하고, 반대로 컴파일러가 잴 코드를 통째로 지워 0초가 찍히기도 한다. 재는 방법이 틀리면 숫자는 거짓말을 한다.
예시로 보기
“왜 내 Rust 코드가 파이썬보다 느리지?”라는 질문의 절반은 cargo run으로 돌린 결과다. cargo run과 cargo build는 기본으로 개발용 설정(dev 프로필)을 쓴다. 이 설정은 최적화를 끈다(opt-level = 0)1. 배포용은 --release를 붙여야 켜진다(opt-level = 3)1.
같은 프로그램을 두 설정으로 컴파일해 돌려 보았다. u64 3천만 개를 섞어 더하는 반복문이다.
| 빌드 | 걸린 시간(세 번 중 최소) |
|---|---|
opt-level=0 (dev와 같다) |
약 140ms |
opt-level=3 (release와 같다) |
약 14ms |
열 배 차이다. 이 숫자는 이 기계(aarch64 macOS)에서 잰 한 번의 값이고, 다른 기계에서는 다르다. Rust Performance Book도 release 빌드가 dev보다 10~100배 빠른 일이 흔하다고 적는다2.
rustc -C opt-level=3으로 따로 컴파일해 얻었다.재는 법
마이크로 벤치마크와 black_box
함수 하나를 여러 번 돌려 시간을 재는 것을 마이크로 벤치마크라 부른다. 표준 라이브러리만으로는 Instant와 black_box 두 가지면 된다.
Instant::now()와elapsed()는 뒤로 가지 않는 시계로 걸린 시간을 잰다. 다만 눈금 간격이 늘 같다는 보장은 없다3.std::hint::black_box(x)는 하는 일 없이x를 돌려준다. 컴파일러에게는 “이 값이 어디에 어떻게 쓰일지 모른다”고 알려서, 잴 계산을 지우거나 미리 계산해 두지 못하게 한다4.
let t = Instant::now();
black_box(push_n(black_box(100_000))); // 입력과 결과 양쪽을 감싼다
println!("{:?}", t.elapsed());
왜 양쪽을 감싸나? black_box는 들어가는 값을 어떻게 다룰지가 아니라 나오는 값만 막는다. black_box(5 * 10)은 곱셈이 미리 접힐 수 있으니 black_box(5) * black_box(10)처럼 재료를 감싸라고 문서가 적는다4. 이것은 “최선을 다한다” 수준의 힌트다. 벤치마크에는 믿고 써도 되지만, 프로그램의 정확성이나 보안을 여기에 기대면 안 된다4.
한 번 잰 시간은 흔들린다. 메모리 배치가 조금만 바뀌어도 일시적인 큰 변동이 생긴다5. 검증 코드는 여러 번 돌려 가장 짧은 값을 골랐다. 다른 일이 끼어든 회차는 길어지기만 하기 때문이다.
Criterion: 표준 도구
손으로 짠 반복문 대신 실무에서는 Criterion을 많이 쓴다. 통계로 여러 번 재고, 지난 실행과 비교해 “빨라졌다/느려졌다”를 유의확률과 함께 알려 준다6. 지금 버전은 0.8.2다7. 이 과목의 실행 환경에는 설치되어 있지 않아 아래 설정은 실행하지 않았다.
[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "my_benchmark"
harness = false # 기본 벤치 실행기 대신 Criterion의 main을 쓴다
// benches/my_benchmark.rs
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;
fn bench(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench);
criterion_main!(benches);
cargo bench로 돌린다6. Criterion 0.8의 criterion::black_box는 더 쓰지 말라고(deprecated) 표시되어 있어서 std::hint::black_box를 쓴다7. 같은 일을 하는 다른 도구로 Divan이 있고, 프로그램 전체를 명령줄에서 재려면 Hyperfine을 쓴다5.
프로파일러: 어디가 느린지 찾기
벤치마크는 “얼마나”를 재고, 프로파일러는 “어디서”를 찾는다. Linux에서는 perf, macOS에서는 Instruments, 여러 운영체제에서는 samply를 쓰고, 결과를 불꽃 그래프(flamegraph)로 본다. 할당이 많은 곳은 DHAT으로 찾는다8. release 빌드를 프로파일링할 때는 줄 번호 정보를 남겨야 결과가 읽힌다8.
[profile.release]
debug = "line-tables-only"
고치는 법
할당 줄이기
Vec은 꽉 차면 더 큰 자리를 새로 잡고 옮긴다. Vec<u64>에 하나씩 넣으며 용량을 찍어 보면 0, 4, 8, 16, 32, 64, 128로 두 배씩 자랐다. 성능 책도 같은 수열을 적는다9.
| 상황 | 할당 횟수(실행 결과) |
|---|---|
Vec::new()에 1,000개 push |
9번 |
Vec::with_capacity(1000)에 1,000개 push |
1번 |
1,000줄을 줄마다 format! |
1,900번 |
String 하나를 바깥에 두고 clear() 후 writeln! |
1번 |
할당 횟수는 시간과 달리 몇 번을 돌려도 같다. 검증 코드는 전역 할당자를 감싸 횟수를 셌다. format!은 줄마다 새 String을 만든다9. 1,900번인 까닭은 번호가 100 이상인 900줄에서 처음 잡은 자리가 모자라 한 번 더 잡았기 때문이다. 버퍼를 반복문 바깥에 두고 clear()하면 내용만 비우고 자리는 남는다. 그래서 할당이 처음 한 번뿐이다9.
크기를 미리 알면 with_capacity나 reserve로 한 번에 잡는다9. clone()은 힙을 가진 값을 통째로 복사한다. Rc·Arc만 참조 수를 하나 올릴 뿐이다9.
빌드 설정
Cargo.toml의 [profile.release]에서 바꾼다. 기본값과 효과는 이렇다.
| 설정 | release 기본값 | 바꾸면 | 대가 |
|---|---|---|---|
opt-level |
31 |
"s"·"z"는 크기를 줄인다1 |
느려질 수 있다 |
lto |
false(자기 크레이트 안에서만 thin LTO)1 |
"thin", "fat"(= true)은 크레이트를 넘어 최적화한다. 10~20% 이상 빨라지기도 한다2 |
컴파일이 오래 걸린다 |
codegen-units |
16(dev는 256)1 |
1이면 최적화 기회가 늘고 파일이 작아진다2 |
컴파일이 병렬로 덜 돈다 |
panic |
"unwind"1 |
"abort"면 파일이 조금 작고 빨라질 수 있다2 |
panic을 잡아 회복할 수 없다. 테스트에는 적용되지 않는다1 |
debug |
false1 |
"line-tables-only"는 프로파일링용8 |
파일이 커진다 |
LTO(link-time optimization)는 링크할 때 크레이트 경계를 넘어 함수를 합치고 지우는 최적화다. 이 밖에 -C target-cpu=native(이 CPU 전용 명령을 쓴다, 다른 CPU에서 안 돌 수 있다), 프로필 기반 최적화(PGO, 10% 이상), jemalloc·mimalloc 같은 다른 할당자가 있다2. 성능 책은 바꿀 때마다 하나씩 재라고 권한다2.
활용
- 순서. 프로파일러로 오래 걸리는 곳을 찾고 → Criterion으로 그 함수의 기준 시간을 잡고 → 하나만 고치고 → 다시 잰다. 고친 뒤 결과가 같은지는 테스트가 지킨다.
- 흔한 실수.
- clippy가 잡아 주는 것. 기본 묶음(
clippy::all)의vec_init_then_push(만들자마자 push),large_enum_variant(한 변형만 큰 열거형),manual_memcpy(손으로 짠 복사 반복문). 기본에서 꺼진 nursery 묶음의redundant_clone,needless_collect, pedantic 묶음의format_push_string(s += &format!(..))도 있다. 묶음은 이 기계의 clippy 0.1.99로 직접 돌려 확인했다. - 비동기 서비스. 요청 하나가 느린 이유는 대개 계산이 아니라 기다림(DB, 네트워크)이다. 이때는 tracing의 구간 시간을 먼저 본다.
연결
- 선수: 컬렉션, 테스트 전략
- 컴파일러가 하는 최적화의 예: 지역 최적화 기법
- 메모리 접근이 시간을 좌우하는 까닭: 캐시 성능과 평균 접근 시간
- 빌드 설정을 배포에 쓰는 법: 빌드와 배포
자주 하는 오해
틀렸다. 같은 Rust 코드도 빌드 설정에 따라 열 배가 달랐다. 할당을 어떻게 하느냐에 따라 1번과 1,900번이 갈렸다. 언어가 빠르다는 말은 “빠르게 짤 수 있다”는 뜻이지 “아무렇게나 짜도 빠르다”는 뜻이 아니다. release로 빌드하고, black_box로 지워지지 않게 하고, 고치기 전후를 재서 확인한다.
확인 문제
C1 for _ in 0..1_000_000 { fib(20); }를 --release로 재니 1나노초도 안 걸렸다. 왜인가? 어떻게 고치나?
답: fib(20)의 결과를 아무도 쓰지 않고 입력도 상수라서, 최적화가 계산을 미리 하거나 통째로 지웠다. black_box(fib(black_box(20)))처럼 입력과 결과를 감싼다.
흔한 오답: “black_box(fib(20))이면 충분하다.” 결과만 감싸면 fib(20) 자체는 상수로 미리 접힐 수 있다. black_box는 나오는 값만 막는다.
C2 let mut v: Vec<u64> = Vec::new();에 값을 하나씩 10개 넣는다. 용량이 바뀔 때마다 적으면?
답: 0 → 4 → 8 → 16.
이유: 처음에는 자리가 없다. 첫 push에 4칸을 잡고, 다섯째에 8칸, 아홉째에 16칸으로 두 배씩 늘린다. 실행해서 찍은 값이다.
C3 서비스 실행 파일을 더 빠르게 하려고 한다. (가) codegen-units = 1, (나) panic = "abort", (다) lto = "fat" 가운데 “panic이 나도 그 요청만 실패시키고 서버는 계속 돈다”는 동작을 깨뜨리는 것은?
답: (나). abort면 panic이 나는 순간 프로세스가 끝난다. 스택을 풀며 잡을 기회가 없다.
이유: (가)와 (다)는 컴파일 시간만 늘리고 동작은 그대로다.
-
The Cargo Book, Profiles — https://doc.rust-lang.org/cargo/reference/profiles.html (
[profile.dev]·[profile.release]기본값 표,opt-level값의 뜻,lto의false·"thin"·"fat"·"off",codegen-units는 증분 빌드 256·아니면 16,panic은 테스트·벤치·빌드 스크립트·proc macro에 적용되지 않는다) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 -
The Rust Performance Book, Build Configuration — https://nnethercote.github.io/perf-book/build-configuration.html (release는 dev보다 10~100배 빠른 일이 흔하다, LTO 10~20% 이상,
codegen-units = 1,panic = "abort", 다른 할당자,target-cpu=native, PGO 10% 이상, 한 번에 하나씩 재라) ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
std 문서,
std::time::Instant— https://doc.rust-lang.org/std/time/struct.Instant.html (단조 비감소 시계, 안정적(steady)이라는 보장은 없다, 벤치마크에 쓴다) ↩ -
std 문서,
std::hint::black_box— https://doc.rust-lang.org/std/hint/fn.black_box.html (최선을 다하는 힌트, 정확성·보안에 기대지 말 것, 벤치마크에는 써야 한다, 입력이 아니라 출력만 막는다,black_box(5) * black_box(10)예) ↩ ↩2 ↩3 -
The Rust Performance Book, Benchmarking — https://nnethercote.github.io/perf-book/benchmarking.html (Criterion·Divan, Hyperfine, 벽시계 시간은 변동이 크고 메모리 배치의 작은 변화로도 흔들린다) ↩ ↩2
-
Criterion.rs 사용자 안내서, Getting Started — https://bheisler.github.io/criterion.rs/book/getting_started.html (
bench표와harness = false,criterion_group!·criterion_main!·bench_function,cargo bench, 이전 실행과의 비교와 p 값). 안내서의 예는criterion = "0.3"이다. ↩ ↩2 -
docs.rs, criterion 0.8.2 — https://docs.rs/criterion/latest/criterion/ (통계 기반 마이크로 벤치마크 라이브러리,
black_box는 Deprecated) ↩ ↩2 -
The Rust Performance Book, Profiling — https://nnethercote.github.io/perf-book/profiling.html (perf, Instruments, samply, flamegraph, DHAT, release 프로파일링에는
debug = "line-tables-only") ↩ ↩2 ↩3 -
The Rust Performance Book, Heap Allocations — https://nnethercote.github.io/perf-book/heap-allocations.html (
Vec용량 0, 4, 8, 16, 32, 64,with_capacity·reserve, 컬렉션을clear로 다시 쓰기,format!은String을 할당한다,clone과Rc·Arc) ↩ ↩2 ↩3 ↩4 ↩5