콘텐츠로 이동

Rust 성능 기초

Rust는 빠르기로 유명하고, 그럴 만한 이유가 있어요. 하지만 “Rust는 빠르다”가 모든 Rust 프로그램이 빠르다는 뜻은 아니에요. 느린 알고리즘은 어떤 언어로 짜도 느리고, 몇 가지 습관만으로도 모르는 사이에 성능을 크게 잃을 수 있어요.

좋은 소식은, 효과가 큰 방법은 대부분 간단하다는 거예요. 릴리스 모드로 빌드하세요. 바꾸기 전에 먼저 측정하세요. 읽기만 할 데이터는 복사하지 마세요. 알맞은 컬렉션을 고르세요. 이 페이지에서는 하나씩 살펴보고, 도움이 되는 곳에는 실제 측정값도 보여 줄게요. 자전거 손보기와 비슷하다고 생각하면 돼요. 가벼운 부품을 고민하기 전에, 먼저 타이어에 바람이 들어 있는지(릴리스 모드)부터 확인하는 거죠.

무언가를 빠르게 만들기 전에, 지금 얼마나 느린지부터 알아야 해요. std::time::Instant는 표준 라이브러리에 들어 있는 스톱워치예요. 작업 전에 Instant::now()를, 작업 후에 .elapsed()를 호출하면 돼요.

use std::time::Instant;
fn main() {
let start = Instant::now();
// 간단한 xorshift로 유사 난수 500만 개를 만들어요.
let mut x: u64 = 12345;
let mut numbers = Vec::with_capacity(5_000_000);
for _ in 0..5_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
numbers.push(x % 1_000_000);
}
numbers.sort();
let sum: u64 = numbers.iter().take(1000).sum();
println!("가장 작은 1000개의 합: {sum}");
println!("걸린 시간: {:?}", start.elapsed());
}

{:?} 형식은 Duration을 250.271745ms처럼 읽기 좋게 출력해요. 여기에 “Try it” 버튼이 없는 건 일부러예요. 실행 시간은 실행할 때마다, 컴퓨터마다 달라서 정해진 출력과 비교할 수 없거든요. 대신 여러분의 컴퓨터에서 cargo run으로 실행해 보세요.

Rust 성능 팁 중에서 단연 가장 중요한 팁인데, 플래그 하나면 돼요.

Terminal window
cargo run # 디버그 빌드: 컴파일은 빠르고, 실행은 느려요
cargo run --release # 릴리스 빌드: 컴파일은 느리고, 실행은 훨씬 빨라요

디버그 빌드는 빠르게 컴파일하고 디버깅하기 쉽도록 대부분의 최적화를 건너뛰어요. 정수 오버플로도 검사해요. 릴리스 빌드는 최적화를 최대로 켜요.

다음은 이 페이지를 쓸 때 사용한 컴퓨터(Intel Xeon CPU를 쓰는 클라우드 서버)에서 위 프로그램을 각각 세 번씩 실행한 측정 예시예요. 여러분의 숫자는 다를 거예요. 중요한 건 둘 사이의 차이뿐이에요.

디버그 빌드: 5.02s, 4.27s, 4.83s
릴리스 빌드: 250ms, 297ms, 265ms

플래그 하나로 대략 15~20배 빨라졌어요. 차이는 코드에 따라 많이 달라요. 반복문과 반복자가 가장 큰 효과를 봐요. 하지만 무시해도 될 만큼 작은 경우는 없어요.

그래서 규칙은 간단해요. 디버그 빌드로 속도를 판단하지 마세요. 누군가 cargo run으로 시간을 재 보고 “Rust는 느리다”고 말한다면, 거의 항상 이게 원인이에요.

.clone()은 전체를 복사해요. String이나 Vec라면 힙 메모리를 새로 할당하고 모든 바이트를 복사한다는 뜻이에요. 복사가 꼭 필요할 때도 있지만, 데이터를 읽기만 하면 될 때가 많아요. 그럴 때 대여(borrowing)는 비용이 들지 않아요.

가장 흔한 경우는 함수 매개변수예요. 함수가 문자열을 읽기만 한다면 String이 아니라 &str를 받으세요.

fn count_long_words(text: &str, min_len: usize) -> usize {
text.split_whitespace().filter(|w| w.len() >= min_len).count()
}
fn main() {
let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String은 자동으로 &str가 돼요. 복사가 필요 없어요.
println!("{}", count_long_words(&owned, 5));
// 문자열 리터럴은 원래 &str예요.
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// `owned`를 넘겨준 적이 없으니 계속 쓸 수 있어요.
println!("{owned}");
}

실행 결과:

3
2
the quick brown fox jumps over the lazy dog
▶ 직접 실행해 보기

이 예제의 문장은 영어로 그대로 두었어요. w.len()은 글자 수가 아니라 바이트 수를 세는데, 한글은 한 글자가 3바이트라서 결과가 달라지거든요.

&str 매개변수는 String(&로 대여해서)과 문자열 리터럴을 둘 다 받을 수 있어요. 매개변수가 String이라면 호출하는 쪽은 자기 문자열을 넘겨주거나 clone해야 해요. 리스트에도 같은 생각을 적용할 수 있어요. Vec<T> 대신 &[T]를 받으세요. 자세한 내용은 대여와 슬라이스를 참고하세요.

반복문 안의 clone도 조심하세요. for name in names.clone()은 그냥 보기만 하려고 리스트 전체를 복사해요. for name in &names는 공짜로 대여해요.

할당은 여러 번이 아니라 한 번에

섹션 제목: “할당은 여러 번이 아니라 한 번에”

Vec는 공간이 모자라면 더 큰 메모리 블록을 할당하고 모든 요소를 옮겨요. 크기를 미리 알고 있다면 Vec::with_capacity로 한 번에 확보할 수 있어요. 이 프로그램은 숫자 1000개를 push하는 동안 각 Vec가 몇 번 커져야 했는지 세요.

fn count_growths(mut v: Vec<u32>, n: u32) -> u32 {
let mut growths = 0;
let mut cap = v.capacity();
for i in 0..n {
v.push(i);
if v.capacity() != cap {
growths += 1;
cap = v.capacity();
}
}
growths
}
fn main() {
println!("Vec::new(): {}번 커졌어요", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity(): {}번 커졌어요", count_growths(Vec::with_capacity(1000), 1000));
}

실행 결과:

Vec::new(): 9번 커졌어요
Vec::with_capacity(): 0번 커졌어요
▶ 직접 실행해 보기

아홉 번 다시 커지는 건 큰일이 아니에요. Vec는 매번 두 배로 커지기 때문에 비용이 적게 들어요. 하지만 벡터를 수백만 개 만드는 핫 루프(hot loop)라면 쌓이고 쌓여요. (정확히 어떻게 커지는지는 표준 라이브러리 내부의 세부 사항이라 바뀔 수도 있어요.)

더 쉬운 방법도 있어요. collect()와 join()에게 맡기는 거예요. 이 둘은 최종 크기를 아는 경우가 많아서 한 번만 할당해요.

fn main() {
// collect()는 Vec를 한 번에 만들어요.
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join()은 전체 길이를 먼저 계산한 다음, 한 번만 복사해요.
let colors = ["빨강", "초록", "파랑"];
println!("{}", colors.join(", "));
}

실행 결과:

[1, 4, 9, 16, 25]
빨강, 초록, 파랑
▶ 직접 실행해 보기

.iter().filter().map().sum()은 함수 호출이 잔뜩 있는 것처럼 보여서, 평범한 반복문보다 느리지 않을까 걱정될 수 있어요. 그렇지 않아요. 릴리스 빌드에서 컴파일러는 그 작은 클로저들을 모두 인라인(inline)해서, 손으로 짠 반복문과 똑같이 촘촘한 기계어를 만들어 내요. Rust에서는 이것을 비용 없는 추상화(zero-cost abstraction)라고 불러요. 더 보기 좋은 스타일을 쓴다고 대가를 치르지 않아요.

fn sum_even_squares_loop(nums: &[u64]) -> u64 {
let mut total = 0;
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
total += nums[i] * nums[i];
}
}
total
}
fn sum_even_squares_iter(nums: &[u64]) -> u64 {
nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()
}
fn main() {
let nums: Vec<u64> = (1..=10).collect();
println!("반복문: {}", sum_even_squares_loop(&nums));
println!("반복자: {}", sum_even_squares_iter(&nums));
}

실행 결과:

반복문: 220
반복자: 220
▶ 직접 실행해 보기

반복자 버전이 오히려 조금 더 빠를 수도 있어요. nums[i]를 쓰면 Rust는 접근할 때마다 i가 슬라이스 범위 안에 있는지 검사해요. 반복자는 범위를 벗어날 수 없다는 걸 이미 알기 때문에 그 검사가 사라져요. 더 읽기 좋은 쪽을 쓰세요. 보통은 반복자 쪽이에요.

알맞은 자료 구조를 고르는 것이 아무리 세세하게 튜닝하는 것보다 효과가 클 때가 많아요. 대표적인 예가 “이 항목이 내 리스트에 있나?“를 여러 번 확인하는 경우예요.

  • Vec::contains는 모든 요소를 하나씩 살펴봐요. 리스트가 커질수록 찾는 속도가 느려져요.
  • HashSet::contains(그리고 HashMap::get)는 거의 곧바로 답으로 건너뛰어요. 크기와 상관없이 찾는 속도가 빨라요.
use std::collections::HashSet;
fn main() {
let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect();
let queries: Vec<u32> = (0..20_000).collect();
// 큰 리스트에서는 느려요. contains()가 매번 Vec 전체를 훑어요.
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// 빨라요. HashSet을 한 번 만들고 나면 찾기는 금방이에요.
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec으로 찾은 개수: {hits_vec}, HashSet으로 찾은 개수: {hits_set}");
}

실행 결과:

Vec으로 찾은 개수: 6667, HashSet으로 찾은 개수: 6667
▶ 직접 실행해 보기

답은 같지만 비용은 전혀 달라요. 각 부분에 Instant 타이머를 붙여서 릴리스 빌드로 실행했을 때, 저희 컴퓨터에서 나온 측정 예시는 이래요.

Vec: 6667 hits in 27.97ms
HashSet: 6667 hits in 551.60µs

약 50배 빨라요. 그것도 집합을 만드는 시간까지 포함해서요. 하지만 항목이 10개뿐인 리스트라면 Vec로 충분하고, 오히려 더 빠를 수도 있어요. HashSet과 HashMap 페이지에서 더 알아보세요.

최적화하기 전에 프로파일링하기

섹션 제목: “최적화하기 전에 프로파일링하기”

프로그래머는 시간이 어디서 쓰이는지 추측하는 데 서툴기로 유명해요. 그러니 추측하지 말고 프로파일링(profiling)하세요. 프로파일러는 프로그램이 실행되는 모습을 지켜보고, 어떤 함수가 시간을 가장 많이 쓰는지 알려 줘요. 보통은 한두 군데의 핫스팟(hot spot)이 문제이고, 나머지 코드는 전혀 상관없어요.

Rust와 함께 쓸 수 있는 인기 있는 무료 프로파일러 두 가지예요.

Terminal window
# flamegraph: 시간이 어디에 쓰였는지 그림으로 그려 줘요 (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: 결과를 브라우저의 Firefox Profiler로 열어 줘요
cargo install samply
cargo build --release
samply record ./target/release/my_app

프로파일러가 함수 이름을 보여 주려면 디버그 심볼이 필요해요. 릴리스 빌드에서도 디버그 심볼을 남기도록 Cargo.toml에 다음을 추가하세요(코드가 느려지지는 않고, 파일만 커져요).

[profile.release]
debug = true

Instant로 한 번 재는 건 오차가 커요. 벤치마크(benchmark)는 코드를 여러 번 실행하고 통계를 이용해서 믿을 만한 숫자를 알려 줘요. Rust에 내장된 #[bench]는 nightly에서만 동작해서, 대부분은 criterion (영어) 크레이트를 써요. 이렇게 설정하세요.

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

테스트하고 싶은 코드는 라이브러리(src/lib.rs)에 두고, 벤치마크는 benches/fib_bench.rs에 작성해요.

use criterion::{criterion_group, criterion_main, Criterion};
use my_crate::fibonacci;
use std::hint::black_box;
fn bench_fib(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);

black_box는 컴파일러가 머리를 써서 답을 미리 계산해 버리지 못하게 막아요. 그렇게 되면 벤치마크가 아무 의미가 없어지거든요. 다음 명령으로 실행해요.

Terminal window
cargo bench

저희 컴퓨터에서 나온 결과 예시예요(criterion은 낮은 추정치, 가장 그럴듯한 추정치, 높은 추정치를 보여 줘요).

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

코드를 바꾼 뒤 다시 실행하면, criterion이 코드가 빨라졌는지 느려졌는지, 그리고 그 차이가 진짜인지 단순한 오차인지 알려 줘요.

기본 릴리스 설정은 충분히 좋아요. 조금 더 필요할 때 Cargo.toml에서 흔히 만지는 설정은 이런 것들이에요.

[profile.release]
opt-level = 3 # 릴리스의 기본값. "s"나 "z"는 크기를 줄이는 쪽으로 최적화해요
lto = true # 링크 타임 최적화: 모든 크레이트를 아울러 최적화해요 (빌드가 느려져요)
codegen-units = 1 # 단위가 하나면 최적화 기회가 늘지만, 컴파일이 느려져요
panic = "abort" # 되감기(unwinding) 코드를 빼요. 바이너리는 작아지지만 패닉을 잡을 수 없어요

이런 설정은 보통 몇 퍼센트 정도의 효과를 줄 뿐, 기적을 일으키지는 않아요. 그리고 빌드는 느려져요. 하나씩 적용해 보고, 매번 측정하세요. lto = "thin"은 lto = true보다 빌드가 빨라서 많이 쓰는 절충안이에요.

디버그 빌드로 시간 재기. 앞에서 이미 다뤘지만, 가장 흔한 실수예요. 항상 --release로 측정하세요.

컴파일러를 달래려고 clone하기. 어떤 함수가 String을 받는데, 대여한 값으로 호출했다고 해 볼게요.

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

컴파일러는 이렇게 말해요.

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

String을 기대했는데 &String이 들어왔다는 뜻이에요. 이럴 때 shout(name.clone())이라고 쓰고 싶어지죠. 동작은 하지만, 호출할 때마다 문자열을 복사해요. 더 좋은 해결책은 매개변수를 text: &str로 바꾸는 거예요. 그러면 shout(&name)이 컴파일되고, 아무것도 복사되지 않아요.

핫 루프 안의 println!. println!은 호출할 때마다 표준 출력(stdout)을 잠그고 그 줄을 바로 써요. 몇 줄이라면 괜찮아요. 백만 줄이라면 느려요. 표준 출력을 BufWriter로 감싸서 큰 덩어리로 쓰게 하세요.

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "{i}번째 줄").unwrap();
}
}

실행 결과:

1번째 줄
2번째 줄
3번째 줄
4번째 줄
5번째 줄
▶ 직접 실행해 보기

저희 측정 예시에서는 파일에 백만 줄을 출력할 때 println!은 약 570670ms, BufWriter는 약 4060ms가 걸렸어요(릴리스 빌드).

  • 속도는 항상 cargo run --release로 측정하세요. 디버그 빌드는 몇 배나 느릴 수 있어요.
  • 간단히 시간을 잴 때는 Instant::now()와 .elapsed()를, 믿을 만한 벤치마크에는 criterion을 쓰세요.
  • 데이터를 읽기만 한다면 clone하지 말고 대여하세요(&str, &[T]).
  • 한 번에 할당하세요. Vec::with_capacity, collect(), join()은 반복적인 재할당을 피해 줘요.
  • 반복자는 비용이 없어요. 찾기를 많이 한다면 HashSet/HashMap을 고르세요.
  • 먼저 프로파일링하고, 프로파일러가 보여 주는 핫스팟을 최적화하세요.

이해도 점검

1. cargo run으로 실행한 프로그램이 느리게 느껴져요. 가장 먼저 무엇을 해 봐야 할까요?

2. 함수가 문자열을 읽기만 해요. 쓸데없는 복사를 피하려면 매개변수 타입을 무엇으로 해야 할까요?

3. ID 50,000개가 금지된 ID 50,000개 목록에 있는지 확인해야 해요. 금지 목록으로 무엇을 쓰는 게 좋을까요?

4. "반복자는 비용이 없다(zero-cost)"는 무슨 뜻일까요?

5. criterion 벤치마크에서 black_box를 쓰는 이유는 무엇일까요?

점수: 0 / 5