Rust에서 사용자 정의 벤치마킹 하네스를 만드는 방법

Everett Pompeii

Everett Pompeii


벤치마킹이란 무엇인가요?

벤치마킹은 코드의 성능을 테스트하여 코드가 얼마나 빠르게(지연시간) 또는 얼마나 많은 양(처리량)의 작업을 수행할 수 있는지를 확인하는 실습입니다. 이는 소프트웨어 개발에서 종종 간과되지만, 빠르고 성능 좋은 코드를 작성하고 유지하기 위해 매우 중요한 단계입니다. 벤치마킹은 개발자가 다양한 작업 부하와 조건에서 코드가 얼마나 잘 수행되는지를 이해하는 데 필요한 메트릭을 제공합니다. 기능 회귀를 방지하기 위해 단위 테스트와 통합 테스트를 작성하는 것과 같은 이유로, 성능 회귀를 방지하기 위해 벤치마크를 작성해야 합니다. 성능 버그도 버그입니다!

러스트에서의 벤치마킹

러스트에서 벤치마킹을 위한 세 가지 인기 있는 옵션은 다음과 같습니다: libtest bench, Criterion, 그리고 Iai.

libtest는 러스트의 내장 단위 테스트 및 벤치마킹 프레임워크입니다. 러스트 표준 라이브러리의 일부이지만, libtest 벤치는 여전히 불안정한 상태로 간주되어 nightly 컴파일러 릴리스에서만 사용할 수 있습니다. 안정적인 러스트 컴파일러에서 작업하려면, 별도의 벤치마킹 하네스를 사용해야 합니다. 하지만 그 어느 것도 활발히 개발되고 있지는 않습니다.

러스트 생태계 내에서 가장 인기 있는 벤치마킹 하네스는 Criterion입니다. 이것은 안정적인 컴파일러와 nightly 컴파일러에서 모두 작동하며, 러스트 커뮤니티 내에서 사실상의 표준이 되었습니다. Criterion은 libtest 벤치에 비해 훨씬 더 많은 기능을 제공합니다.

Criterion의 실험적 대안은 같은 제작자로부터 나온 Iai입니다. 그러나 이는 월드 시계 시간을 대신하여 명령어 수를 사용합니다: CPU 명령어, L1 접근, L2 접근 및 RAM 접근. 이로 인해 이러한 메트릭은 실행 간에 거의 동일하게 유지되므로 단일 샷 벤치마킹이 가능합니다.

With all that in mind, if you are looking to benchmark your code’s wall clock time then you should probably use Criterion. If you are looking to benchmark your code in CI with shared runners then it may be worth checking out Iai. Note though, Iai hasn’t been update in over 3 years. So you might consider using Iai-Callgrind instead.

But what if you don’t want to benchmark wall clock time or instruction counts? What if you want to track some completely different benchmark‽ Luckily, Rust makes it incredibly easy to create a custom benchmarking harness.

cargo bench 작동 방식

사용자 정의 벤치마킹 하네스를 만들기 전에, 러스트 벤치마크가 어떻게 작동하는지 이해해야 합니다. 대부분의 러스트 개발자에게 이는 cargo bench 명령어를 실행하는 것을 의미합니다. cargo bench 명령어는 벤치마크를 컴파일하고 실행합니다. 기본적으로, cargo bench는 기본적으로 내장되어 있지만 불안정한 libtest 벤치 하네스를 사용하려고 합니다. libtest 벤치 하네스는 코드를 살펴보고 #[bench] 속성이 주석된 모든 함수를 실행합니다. 사용자 정의 벤치마킹 하네스를 사용하려면, cargo bench에 libtest 벤치를 사용하지 않도록 지시해야 합니다.

cargo bench와 함께 사용자 정의 벤치마크 하니스를 사용하기

cargo bench에서 libtest 벤치마크를 사용하지 않도록 하려면, 다음 내용을 Cargo.toml 파일에 추가해야 합니다:

Cargo.toml
[[bench]]
harness = false

안타깝게도, 사용자 정의 벤치마크 하니스에서는 #[bench] 속성을 사용할 수 없습니다. 언젠가는 가능할지도 모릅니다, 하지만 오늘은 아닙니다. 대신, 벤치마크를 보관할 별도의 benches 디렉토리를 생성해야 합니다. benches 디렉토리는 통합 테스트용 tests 디렉토리와 같은 역할을 합니다. benches 디렉토리 내의 각 파일은 개별 크레이트로 취급됩니다. 따라서 벤치마크 대상 크레이트는 라이브러리 크레이트여야 합니다. 즉, lib.rs 파일이 있어야 합니다.

예를 들어, game이라는 기본 라이브러리 크레이트가 있다면, benches 디렉토리에 play_game이라는 사용자 정의 벤치마크 파일을 추가할 수 있습니다. 디렉토리 구조는 다음과 같습니다:

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

다음으로, cargo bench에 사용자 정의 벤치마크 크레이트 play_game을 알려야 합니다. 이를 위해 Cargo.toml 파일을 업데이트합니다:

Cargo.toml
[[bench]]
name = "play_game"
harness = false

벤치마크를 위한 코드 작성

성능 테스트를 작성하기 전에 벤치마크할 라이브러리 코드를 준비해야 합니다. 우리의 예제에서는 FizzBuzzFibonacci 게임을 플레이할 것입니다.

FizzBuzzFibonacci의 규칙은 다음과 같습니다:

1부터 100까지의 정수를 출력하는 프로그램을 작성하십시오 (포함):

  • 3의 배수일 경우, Fizz 출력
  • 5의 배수일 경우, Buzz 출력
  • 3과 5 모두의 배수일 경우, FizzBuzz 출력
  • 피보나치 수열에 속하는 숫자인 경우, Fibonacci만 출력
  • 그 외의 경우, 숫자를 출력

다음은 src/lib.rs에 있는 우리의 구현입니다:

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

사용자 정의 벤치마킹 하네스 만들기

benches/play_game.rs 내에 사용자 정의 벤치마킹 하네스를 만들겠습니다. 이 사용자 정의 벤치마킹 하네스는 the dhat-rs crate을 사용하여 힙 할당을 측정할 것입니다. dhat-rs는 Rust 프로그램에서 힙 할당을 추적하는 데 탁월한 도구로, Rust 성능 전문가 Nicholas Nethercote가 만들었습니다. 벤치마크 함수를 관리하는 데 도움이 되도록 놀랍도록 다작하는 David Tolnay가 만든 the inventory crate를 사용할 것입니다.

Cargo.toml 파일에 dhat-rsinventorydev-dependencies로 추가합시다:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

사용자 정의 할당자 생성

우리의 커스텀 벤치마킹 하네스가 힙 할당을 측정할 것이므로, 커스텀 힙 할당자를 사용해야 합니다. Rust는 #[global_allocator] 애트리뷰트를 사용하여 사용자 정의 전역 힙 할당자를 설정할 수 있게 합니다. benches/play_game.rs 파일의 맨 위에 다음을 추가하세요:

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

이 코드는 Rust에게 dhat::Alloc을 전역 힙 할당자로 사용하라고 지시합니다.

🐰 하나의 전역 힙 할당자만 한 번에 설정할 수 있습니다. 여러 전역 할당자 간 전환을 원한다면, Rust 기능을 통해 조건부 컴파일로 관리해야 합니다.

커스텀 벤치마크 수집기 만들기

커스텀 벤치마킹 하네스를 만들기 위해서는 벤치마크 함수를 식별하고 저장할 수 있는 방법이 필요합니다. 이를 위해 CustomBenchmark라는 구조체를 사용하여 각 벤치마크 함수를 캡슐화하겠습니다.

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

CustomBenchmark는 이름과 dhat::HeapStats를 출력으로 반환하는 벤치마크 함수를 가집니다.

그런 다음 inventory 크레이트를 사용하여 모든 CustomBenchmark의 컬렉션을 생성합니다:

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

벤치마크 함수 생성

이제 FizzBuzzFibonacci 게임을 실행하는 벤치마크 함수를 만들 수 있습니다:

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

줄별로 설명:

  • CustomBenchmark에서 사용하는 서명과 일치하는 벤치마크 함수를 만듭니다.
  • dhat::Profiler를 테스트 모드로 생성하여, dhat::Alloc 커스텀 전역 할당자에서 결과를 수집합니다.
  • 컴파일러가 우리 코드를 최적화하지 않도록 “블랙 박스” 안에서 play_game 함수를 실행합니다.
  • 1부터 100까지 포함해서 반복합니다.
  • 각 숫자에 대해, printfalse로 설정하여 play_game을 호출합니다.
  • 힙 할당 통계를 dhat::HeapStats로 반환합니다.

🐰 play_game 함수의 printfalse로 설정합니다. 이렇게 하면 play_game이 표준 출력에 인쇄되지 않습니다. 라이브러리 함수를 이렇게 매개변수화하면 벤치마킹하기에 더 적합할 수 있습니다. 그러나 이는 우리가 실제로 라이브러리를 사용하는 방식과 정확히 동일한 방식으로 벤치마크를 하지 않을 수도 있음을 의미합니다.

이 경우, 우리는 스스로에게 물어봐야 합니다:

  1. 표준 출력에 인쇄하는 데 필요한 리소스가 우리가 신경 써야 할 것인가?
  2. 표준 출력에 인쇄하는 것이 잡음의 가능성이 있는가?

우리의 예제에서, 우리는 다음과 같습니다:

  1. 아니요, 표준 출력에 인쇄하는 것에 대해 신경 쓰지 않습니다.
  2. 네, 이것은 매우 가능성이 있는 잡음의 원천입니다.

따라서 이 벤치마크의 일부로 표준 출력에 인쇄하는 것을 생략했습니다. 벤치마킹은 어렵고, 이러한 질문에 대한 올바른 답은 종종 하나만 있는 것이 아닙니다. 상황에 따라 다릅니다.

벤치마크 함수 등록하기

벤치마크 함수를 작성했으므로, 이제 CustomBenchmark를 만들고 inventory를 사용하여 벤치마크 컬렉션에 등록해야 합니다.

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

여러 개의 벤치마크가 있다면, 동일한 과정을 반복합니다:

  1. 벤치마크 함수를 작성합니다.
  2. 벤치마크 함수에 대한 CustomBenchmark를 만듭니다.
  3. CustomBenchmarkinventory 컬렉션에 등록합니다.

사용자 정의 벤치마크 실행기 생성

마지막으로, 사용자 정의 벤치마크 하네스를 위한 실행기를 생성해야 합니다. 사용자 정의 벤치마크 하네스는 우리가 작성한 모든 벤치마크를 실행하고 결과를 보고하는 바이너리일 뿐입니다. 벤치마크 실행기는 이 모든 것을 조정하는 역할을 합니다.

결과를 Bencher Metric Format (BMF) JSON 형식으로 출력하고자 합니다. 이를 위해 마지막으로 하나의 종속성을 추가해야 합니다, 다름 아닌 David Tolnay가 작성한 serde_json 크레이트입니다!

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

다음으로, CustomBenchmark가 벤치마크 함수를 실행하고 그 결과를 BMF JSON으로 반환하는 메서드를 구현하겠습니다.

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

BMF JSON 결과는 각 벤치마크에 대해 여섯 가지 측정치를 포함합니다:

  • Final Blocks: 벤치마크가 완료될 때 할당된 최종 블록 수.
  • Final Bytes: 벤치마크가 완료될 때 할당된 최종 바이트 수.
  • Max Blocks: 벤치마크 실행 중 한 번에 할당된 최대 블록 수.
  • Max Bytes: 벤치마크 실행 중 한 번에 할당된 최대 바이트 수.
  • Total Blocks: 벤치마크 실행 중 할당된 총 블록 수.
  • Total Bytes: 벤치마크 실행 중 할당된 총 바이트 수.

마지막으로, inventory 컬렉션에 있는 모든 벤치마크를 실행하고 결과를 BMF JSON으로 출력하는 main 함수를 작성할 수 있습니다.

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

사용자 정의 벤치마크 하니스 실행

모든 준비가 완료되었습니다. 이제 사용자 정의 벤치마크 하니스를 실행할 수 있습니다.

Terminal window
cargo bench

표준 출력과 results.json이라는 파일에 다음과 같은 출력이 나와야 합니다:

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

여러분이 보는 정확한 숫자는 컴퓨터의 아키텍처에 따라 약간 다를 수 있습니다. 하지만 중요한 것은 마지막 네 가지 메트릭에 대한 값이 최소한 일부 있어야 한다는 점입니다.

사용자 정의 벤치마크 결과 추적

대부분의 벤치마크 결과는 휘발성입니다. 터미널의 스크롤백 제한에 도달하면 사라집니다. 일부 벤치마크 하네스는 결과를 캐시할 수 있게 해주지만, 구현하는 데 많은 작업이 필요합니다. 그리고 그렇게 하더라도 결과를 로컬에만 저장할 수 있습니다. 다행히도, 우리의 사용자 정의 벤치마크 하네스는 Bencher와 함께 작동합니다! Bencher는 지속적인 벤치마킹 도구 모음으로 벤치마크 결과를 시간이 지남에 따라 추적하고 성능 회귀를 프로덕션에 도달하기 전에 잡아낼 수 있게 해줍니다.

Bencher Cloud 또는 Bencher Self-Hosted를 사용하여 모든 준비가 완료되면, 다음 명령을 실행하여 사용자 정의 벤치마크 하네스의 결과를 추적할 수 있습니다:

Terminal window
bencher run --file results.json "cargo bench"

또한, Bencher로 사용자 정의 벤치마크를 추적하는 방법JSON 벤치마크 어댑터에 대해 더 읽어볼 수 있습니다.

마무리

이 게시물은 Rust 생태계에서 가장 인기 있는 벤치마킹 도구 세 가지, 즉 libtest bench, Criterion, 그리고 Iai를 살펴보는 것으로 시작했습니다. 이 도구들이 대부분의 사용 사례를 다루고 있음에도 불구하고, 때로는 벽 시계 시간이나 명령어 수 이외의 다른 것을 측정해야 할 수도 있습니다. 이로 인해 커스텀 벤치마킹 도구를 만드는 길로 접어들게 되었습니다.

우리의 커스텀 벤치마킹 도구는 dhat-rs를 사용하여 힙 할당을 측정합니다. 벤치마크 함수는 inventory를 사용하여 수집되었습니다. 실행되면 벤치마크는 Bencher Metric Format (BMF) JSON으로 결과를 출력합니다. 그런 다음 Bencher를 사용하여 시간 경과에 따른 커스텀 벤치마크 결과를 추적하고 CI에서 성능 회귀를 잡아낼 수 있습니다.

이 가이드의 모든 소스 코드는 GitHub에서 제공됩니다.

Bencher: 지속적인 벤치마킹

🐰 Bencher

Bencher는 지속적인 벤치마킹 도구 모음입니다. 성능 회귀가 사용자에게 영향을 미친 경험이 있나요? Bencher가 그런 일이 일어나는 것을 막을 수 있었습니다. Bencher를 이용하면 성능 회귀를 상용 환경으로 이동하기 전에 탐지하고 예방할 수 있습니다.

  • 실행: 기존 벤치마킹 도구를 사용하여 로컬 또는 CI에서 벤치마크를 실행합니다. bencher CLI는 기존 벤치마킹 하네스를 감싸고 결과를 저장합니다.
  • 추적: 벤치마크 결과를 시간이 지남에 따라 추적합니다. 소스 브랜치, 테스트 베드, 측정 기반의 Bencher 웹 콘솔을 사용하여 결과를 모니터링, 쿼리, 그래프로 만듭니다.
  • 캐치: CI에서 성능 회귀를 잡아냅니다. Bencher는 최첨단, 사용자 정의 가능한 분석을 사용하여 상용 환경으로 가기 전에 성능 회귀를 탐지합니다.

단위 테스트가 CI에서 기능 회귀를 방지하기 위해 실행되는 것처럼, 벤치마크는 Bencher와 함께 CI에서 실행되어 성능 회귀를 방지해야 합니다. 성능 버그도 버그입니다!

CI에서 성능 회귀를 잡아내기 시작하세요 - Bencher Cloud를 무료로 시도해보세요.

🤖 이 문서는 OpenAI GPT-4에 의해 자동으로 생성되었습니다. 정확하지 않을 수도 있고 오류가 있을 수도 있습니다. 오류를 발견하면 GitHub에서 문제를 열어주세요.