C++ 성능 측정: chrono와 Google Benchmark로 신뢰할 수 있는 벤치마크 만들기
이 글의 핵심
벤치마크 결과가 실행할 때마다 다르거나 비현실적으로 빠르게 나온다면 컴파일러가 측정 대상 코드를 제거했거나 캐시 효과가 섞였을 가능성이 큽니다. 측정 오버헤드와 백그라운드 프로세스의 영향, 메모리 사용량 측정 방법을 짚어 최적화 결정을 내리기 전에 수치를 믿을 수 있는지 판단하는 기준을 제공합니다.
들어가며
벤치마킹(Benchmarking) 은 코드의 성능을 정량적으로 측정하는 과정입니다. 단순히 한 번 실행 시간을 재는 것이 아니라, 워밍업, 반복 실행, 통계 분석을 통해 신뢰할 수 있는 수치를 얻어야 합니다.
벤치마크가 어려운 이유는 측정하려는 코드와 측정 결과 사이에 끼어드는 요소가 많기 때문입니다. 컴파일러는 결과를 쓰지 않는 계산을 지워 버리고, CPU는 캐시와 분기 예측기를 데워 가며 점점 빨라지고, 운영체제는 다른 프로세스를 끼워 넣고 클럭 주파수를 바꿉니다. 이 글의 예제들은 대부분 이런 간섭 중 하나를 막기 위한 장치이며, 각 코드 뒤에 “이 코드가 무엇을 막고, 무엇은 여전히 막지 못하는지”를 함께 적었습니다.
기본 개념
벤치마킹 프로세스
graph LR
A[코드 작성] --> B[워밍업]
B --> C[측정 시작]
C --> D[반복 실행]
D --> E[측정 종료]
E --> F[통계 분석]
F --> G{목표 달성?}
G -->|No| H[최적화]
H --> A
G -->|Yes| I[완료]
기본 측정
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::high_resolution_clock::now();
// 코드 실행
std::vector<int> v(1000000);
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(
end - start
);
std::cout << "시간: " << duration.count() << "μs" << std::endl;
return 0;
}
이 코드는 가장 기본 형태지만 두 가지를 바로잡을 필요가 있습니다. 첫째, 시계 선택입니다. high_resolution_clock은 표준상 “가장 짧은 틱을 가진 시계”의 별칭일 뿐이고, 구현에 따라 system_clock의 별칭이라서 NTP 시간 동기화나 사용자의 시계 변경에 따라 값이 뒤로 가거나 건너뛸 수 있습니다. 경과 시간을 잴 때는 단조 증가가 보장되는 std::chrono::steady_clock을 쓰는 것이 원칙이며, cppreference도 같은 권고를 합니다. 둘째, 이 예제는 <vector>를 포함하지 않았고, 만든 벡터를 전혀 사용하지 않습니다. 최적화 빌드에서는 컴파일러가 할당과 해제를 통째로 없앨 수 있어서, 측정 결과가 “아무것도 안 한 시간”이 될 수 있습니다.
실전 구현
기본 벤치마크 함수
#include <chrono>
#include <iostream>
#include <functional>
template<typename Func>
auto benchmark(Func f, int iterations = 1000) {
using namespace std::chrono;
auto start = high_resolution_clock::now();
for (int i = 0; i < iterations; ++i) {
f();
}
auto end = high_resolution_clock::now();
auto total = duration_cast<microseconds>(end - start);
return total.count() / iterations;
}
int main() {
auto avgTime = benchmark([]() {
std::vector<int> v(1000);
});
std::cout << "평균: " << avgTime << "μs" << std::endl;
return 0;
}
total.count() / iterations는 정수 나눗셈이라는 점을 주의해야 합니다. 1000번 반복한 총 시간이 800μs라면 평균은 0.8μs인데 결과는 0이 됩니다. 짧은 작업을 측정한다면 duration<double, std::micro>처럼 부동소수점 duration을 쓰거나 나노초 단위로 계산해야 합니다. 또 이 방식은 반복 전체의 합만 재므로, 중간에 한 번 크게 튄 값(페이지 폴트, 컨텍스트 스위치)이 평균에 섞여도 알아낼 방법이 없습니다. 다음 절의 통계 수집이 필요한 이유입니다.
통계 수집
#include <vector>
#include <algorithm>
#include <numeric>
#include <cmath>
#include <iostream>
class BenchmarkStats {
private:
std::vector<double> samples;
public:
void addSample(double microseconds) {
samples.push_back(microseconds);
}
double mean() const {
if (samples.empty()) return 0.0;
auto sum = std::accumulate(samples.begin(), samples.end(), 0.0);
return sum / samples.size();
}
double median() const {
if (samples.empty()) return 0.0;
auto sorted = samples;
std::sort(sorted.begin(), sorted.end());
return sorted[sorted.size() / 2];
}
double min() const {
return *std::min_element(samples.begin(), samples.end());
}
double max() const {
return *std::max_element(samples.begin(), samples.end());
}
double stddev() const {
if (samples.size() < 2) return 0.0;
double avg = mean();
double variance = 0.0;
for (double s : samples) {
variance += (s - avg) * (s - avg);
}
return std::sqrt(variance / (samples.size() - 1));
}
double percentile(double p) const {
if (samples.empty()) return 0.0;
auto sorted = samples;
std::sort(sorted.begin(), sorted.end());
size_t index = static_cast<size_t>(p * sorted.size());
index = std::min(index, sorted.size() - 1); // p == 1.0일 때 범위 밖 접근 방지
return sorted[index];
}
void printStats() const {
std::cout << "평균: " << mean() << "μs" << std::endl;
std::cout << "중앙값: " << median() << "μs" << std::endl;
std::cout << "최소: " << min() << "μs" << std::endl;
std::cout << "최대: " << max() << "μs" << std::endl;
std::cout << "표준편차: " << stddev() << "μs" << std::endl;
std::cout << "P95: " << percentile(0.95) << "μs" << std::endl;
std::cout << "P99: " << percentile(0.99) << "μs" << std::endl;
}
};
벤치마크 시간의 분포는 정규분포가 아니라 오른쪽으로 꼬리가 긴 모양입니다. 코드가 낼 수 있는 가장 빠른 시간 근처에 대부분의 샘플이 몰려 있고, 인터럽트나 스케줄링 때문에 가끔 몇 배 느린 샘플이 섞입니다. 그래서 평균은 소수의 이상치에 끌려가기 쉽고, 두 구현을 비교할 때는 중앙값이나 최솟값이 더 안정적인 지표가 됩니다. 반대로 서버 응답 시간처럼 “가끔 느린 것” 자체가 문제인 경우에는 P95, P99가 핵심 지표입니다. 표준편차가 평균에 비해 크다면(예: 평균의 10%를 넘는다면) 측정 환경이 불안정하다는 신호이므로, 결과를 해석하기 전에 환경부터 점검하는 편이 낫습니다. 참고로 이 클래스의 median()은 샘플 수가 짝수일 때 가운데 두 값의 평균 대신 위쪽 값을 반환하는 단순화된 구현이고, min()/max()는 샘플이 비어 있으면 정의되지 않은 동작이 됩니다.
워밍업 패턴
#include <chrono>
#include <iostream>
#include <functional>
template<typename Func>
BenchmarkStats benchmarkWithWarmup(Func f, int warmup, int iterations) {
// 워밍업
for (int i = 0; i < warmup; ++i) {
f();
}
// 측정
BenchmarkStats stats;
for (int i = 0; i < iterations; ++i) {
auto start = std::chrono::high_resolution_clock::now();
f();
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(
end - start
);
stats.addSample(duration.count());
}
return stats;
}
int main() {
auto stats = benchmarkWithWarmup([]() {
std::vector<int> v(1000);
}, 10, 100);
stats.printStats();
return 0;
}
반복마다 시계를 읽는 이 방식은 샘플별 분포를 얻을 수 있다는 장점이 있지만, now() 호출 자체에도 비용이 듭니다. 리눅스의 steady_clock::now()는 보통 vDSO를 통해 커널 진입 없이 수십 나노초 안에 끝나지만, 측정 대상이 그보다 짧다면 샘플 대부분이 시계 읽기 비용이 됩니다. 이런 경우에는 작업을 여러 번 묶어 한 샘플로 재야 합니다. 또 여기서 샘플을 microseconds로 잘라 저장하면 1μs 미만 작업은 전부 0으로 기록되므로, 샘플 단위는 나노초나 double로 두는 편이 좋습니다. 워밍업 횟수에 정답은 없으며, 샘플을 순서대로 출력해 보았을 때 앞쪽 값이 뚜렷하게 크다면 그만큼은 버려야 한다고 판단하면 됩니다.
알고리즘 비교
#include <algorithm>
#include <vector>
#include <chrono>
#include <iostream>
#include <random>
void compareSort() {
std::vector<int> data(100000);
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(0, 100000);
std::generate(data.begin(), data.end(), [&]() { return dis(gen); });
// std::sort
auto data1 = data;
auto start1 = std::chrono::high_resolution_clock::now();
std::sort(data1.begin(), data1.end());
auto end1 = std::chrono::high_resolution_clock::now();
auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1);
// std::stable_sort
auto data2 = data;
auto start2 = std::chrono::high_resolution_clock::now();
std::stable_sort(data2.begin(), data2.end());
auto end2 = std::chrono::high_resolution_clock::now();
auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2);
std::cout << "sort: " << time1.count() << "ms" << std::endl;
std::cout << "stable_sort: " << time2.count() << "ms" << std::endl;
}
int main() {
compareSort();
return 0;
}
출력 형식:
sort: <N>ms
stable_sort: <N>ms
이 비교는 “각 알고리즘을 한 번씩” 실행하므로 결과가 실행마다 흔들립니다. data1, data2를 매번 복사해서 같은 입력을 쓰게 한 것은 올바르지만, 두 번째 측정은 첫 번째 측정 때 데워진 캐시와 할당자 상태의 영향을 받을 수 있습니다. 공정하게 비교하려면 순서를 바꿔서도 돌려 보고, 각각 여러 번 반복한 중앙값을 비교해야 합니다. 또 milliseconds 단위는 10만 개 정렬처럼 수 ms 단위 작업에서는 해상도가 너무 거칠어 1ms 차이가 곧 수십 퍼센트 차이로 보입니다.
Google Benchmark
설치
# Linux/macOS
git clone https://github.com/google/benchmark.git
cd benchmark
cmake -E make_directory "build"
cmake -E chdir "build" cmake -DBENCHMARK_DOWNLOAD_DEPENDENCIES=on -DCMAKE_BUILD_TYPE=Release ../
cmake --build "build" --config Release
sudo cmake --build "build" --target install
기본 사용
#include <benchmark/benchmark.h>
#include <vector>
static void BM_VectorPushBack(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> v;
for (int i = 0; i < state.range(0); ++i) {
v.push_back(i);
}
}
}
BENCHMARK(BM_VectorPushBack)->Range(8, 8<<10);
static void BM_VectorReserve(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> v;
v.reserve(state.range(0));
for (int i = 0; i < state.range(0); ++i) {
v.push_back(i);
}
}
}
BENCHMARK(BM_VectorReserve)->Range(8, 8<<10);
BENCHMARK_MAIN();
컴파일 및 실행:
g++ -std=c++17 bench.cpp -lbenchmark -lpthread -o bench
./bench
출력 형식 (수치는 환경마다 다르므로 생략):
-----------------------------------------------------------------
Benchmark Time CPU Iterations
-----------------------------------------------------------------
BM_VectorPushBack/8 <ns> <ns> <반복 수>
BM_VectorPushBack/64 <ns> <ns> <반복 수>
...
BM_VectorReserve/8192 <ns> <ns> <반복 수>
Google Benchmark의 for (auto _ : state) 루프는 반복 횟수를 개발자가 정하지 않는다는 점이 chrono 방식과 가장 큰 차이입니다. 라이브러리가 먼저 적은 횟수로 돌려 보고, 측정 시간이 충분히 길어질 때까지(기본 최소 시간은 약 0.5초) 반복 횟수를 늘려 가며 결과를 안정화합니다. 그래서 출력의 Iterations 열은 작업이 빠를수록 커집니다. Time은 벽시계 시간, CPU는 해당 스레드가 실제로 CPU를 쓴 시간이며, 두 값이 크게 다르면 I/O 대기나 스케줄링 지연이 섞였다는 뜻입니다. Range(8, 8<<10)은 8부터 8192까지 기본 배수 8로 인자를 만들어 크기에 따른 변화를 보여 줍니다.
실행하자마자 ***WARNING*** CPU scaling is enabled, the benchmark real time measurements may be noisy and will incur extra overhead.라는 경고를 보는 경우가 많은데, CPU 주파수가 부하에 따라 바뀌는 절전 설정 때문입니다. 리눅스에서는 cpupower frequency-set --governor performance로 고정할 수 있습니다. ***WARNING*** Library was built as DEBUG. Timings may be affected.가 보이면 라이브러리 자체를 Debug로 빌드한 것이므로, 앞의 설치 명령처럼 -DCMAKE_BUILD_TYPE=Release로 다시 빌드해야 합니다. 벤치마크 대상 코드도 -O2 이상으로 컴파일해야 하는데, 위 컴파일 명령에는 최적화 옵션이 빠져 있으므로 실제로는 g++ -std=c++17 -O2 bench.cpp ...처럼 지정하세요.
고급 활용
최적화 방지
#include <benchmark/benchmark.h>
static void BM_Compute(benchmark::State& state) {
for (auto _ : state) {
int result = 1 + 1;
benchmark::DoNotOptimize(result); // 최적화 방지
}
}
BENCHMARK(BM_Compute);
BENCHMARK_MAIN();
DoNotOptimize는 이름과 달리 최적화를 끄지 않습니다. 인라인 어셈블리 제약을 이용해 “이 값이 어딘가에서 읽힌다”고 컴파일러가 믿게 만들어서, 값을 계산하는 코드가 제거되지 않게 할 뿐입니다. 그래서 위 1 + 1은 여전히 컴파일 시점에 2로 접히고, 측정되는 것은 상수를 레지스터에 넣는 비용뿐입니다. 입력까지 컴파일러가 알 수 없게 하려면 입력 변수에도 DoNotOptimize를 적용하거나 state.range(0)처럼 실행 시점에 정해지는 값을 써야 합니다. 메모리에 쓴 결과가 버려지지 않게 하려면 benchmark::ClobberMemory()를 함께 씁니다.
처리량(bytes/s) 보고
#include <benchmark/benchmark.h>
#include <vector>
static void BM_VectorMemory(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> v(state.range(0));
benchmark::DoNotOptimize(v.data());
}
// 루프가 끝난 뒤 한 번만 설정 (state.iterations()는 최종 반복 수)
state.SetBytesProcessed(state.iterations() * state.range(0) * sizeof(int));
}
BENCHMARK(BM_VectorMemory)->Range(8, 8<<10);
BENCHMARK_MAIN();
사용자 정의 카운터
#include <benchmark/benchmark.h>
#include <vector>
static void BM_CustomCounter(benchmark::State& state) {
int operations = 0;
for (auto _ : state) {
std::vector<int> v(state.range(0));
operations += state.range(0);
}
state.counters["operations"] = operations;
}
BENCHMARK(BM_CustomCounter)->Range(8, 8<<10);
BENCHMARK_MAIN();
SetBytesProcessed는 “메모리를 얼마나 썼는지”가 아니라 “초당 몇 바이트를 처리했는지”를 출력에 bytes_per_second 열로 추가하는 기능입니다. 원래 예제처럼 루프 안에서 호출하면 매 반복 덮어쓰기만 하므로 결과는 같지만 불필요한 비용이 측정에 섞입니다. 실제 할당량이나 최대 메모리 사용량을 재고 싶다면 benchmark::RegisterMemoryManager로 할당 추적기를 연결하거나, Valgrind Massif나 heaptrack 같은 별도 도구를 쓰는 편이 정확합니다.
사용자 정의 카운터도 기본값은 “단순 합계”라서 위 operations는 전체 반복의 누적 값이 그대로 찍힙니다. 반복 수가 매번 달라지므로 이 값끼리 비교해서는 의미가 없고, benchmark::Counter(operations, benchmark::Counter::kIsRate)로 초당 비율로 바꾸거나 kAvgIterations로 반복당 평균을 내야 비교할 수 있습니다. 반복 횟수가 많으면 int 누적이 오버플로할 수 있다는 점도 주의하세요.
성능 비교
정렬 알고리즘 비교
| 알고리즘 | 하는 일 | 시간 복잡도 | 안정성 | 메모리 |
|---|---|---|---|---|
| std::sort | 전체 정렬 | O(N log N) | ❌ | O(log N) |
| std::stable_sort | 전체 정렬 + 같은 키의 순서 유지 | 버퍼 확보 시 O(N log N), 아니면 O(N log² N) | ✅ | O(N) |
| std::partial_sort | 상위 K개만 정렬 | O(N log K) | ❌ | O(1) |
| std::nth_element | K번째 원소만 제자리에 | 평균 O(N) | ❌ | O(1) |
같은 N이라도 “하는 일”이 적은 알고리즘일수록 빠릅니다. 상위 10개만 필요한데 std::sort로 전부 정렬하는 것은 흔한 낭비이고, 중앙값 하나만 필요하면 nth_element로 충분합니다. stable_sort는 보조 버퍼를 할당하므로 메모리가 부족한 환경에서는 더 느린 경로로 떨어질 수 있습니다. 실제 시간 차이는 데이터 분포(이미 정렬됨, 중복 많음)와 원소 크기에 따라 달라지므로, 위 Google Benchmark 템플릿에 각 알고리즘을 넣어 state.range(0)으로 N을 바꿔 가며 비교해 보세요.
컨테이너 삽입 비교
| 방법 | 삽입 시 일어나는 일 |
|---|---|
vector (reserve 없음) | 용량이 찰 때마다 더 큰 버퍼를 할당하고 기존 원소를 전부 이동 |
vector (reserve 있음) | 할당 1회, 이후에는 끝에 쓰기만 함 |
list | 원소마다 노드 할당, 메모리가 흩어져 캐시 효율이 낮음 |
deque | 고정 크기 블록 단위로 할당, 기존 원소 이동 없음 |
결론: 삽입할 개수를 미리 알면 reserve로 재할당과 이동을 없애는 것이 가장 간단한 개선입니다. 위 Google Benchmark 예제의 BM_VectorPushBack과 BM_VectorReserve가 바로 이 차이를 재는 코드이므로, 자기 환경에서 실행해 개선 폭을 확인하세요.
실무 사례
사례 1: 해시 함수 비교
#include <benchmark/benchmark.h>
#include <string>
#include <functional>
static void BM_StdHash(benchmark::State& state) {
std::string str = "hello world";
std::hash<std::string> hasher;
for (auto _ : state) {
size_t hash = hasher(str);
benchmark::DoNotOptimize(hash);
}
}
BENCHMARK(BM_StdHash);
static void BM_CustomHash(benchmark::State& state) {
std::string str = "hello world";
for (auto _ : state) {
size_t hash = 0;
for (char c : str) {
hash = hash * 31 + c;
}
benchmark::DoNotOptimize(hash);
}
}
BENCHMARK(BM_CustomHash);
BENCHMARK_MAIN();
이 비교에는 공정성 문제가 숨어 있습니다. str의 내용이 컴파일 시점에 알려져 있으므로, 인라인되는 BM_CustomHash의 루프는 컴파일러가 해시 값을 미리 계산해 상수로 바꿔 버릴 수 있습니다. 반면 std::hash<std::string>은 보통 라이브러리 내부 함수(libstdc++의 경우 _Hash_bytes)를 호출하므로 실제로 계산됩니다. 결과적으로 직접 만든 해시가 “놀랍게 빠른” 것처럼 보일 수 있으며, 루프 안에서 benchmark::DoNotOptimize(str)을 먼저 호출해 입력을 매번 불투명하게 만들어야 공정합니다. 또 해시 함수는 속도만큼 분포 품질도 중요하므로, 짧은 고정 문자열 하나로 잰 결과로 해시를 바꾸는 결정을 내리면 안 됩니다. 실제 키 분포에서 충돌률과 함께 비교해야 합니다.
사례 2: JSON 파싱 성능
#include <benchmark/benchmark.h>
#include <nlohmann/json.hpp>
#include <string>
static void BM_JsonParse(benchmark::State& state) {
std::string json_str = R"({"name":"John","age":30,"city":"New York"})";
for (auto _ : state) {
auto json = nlohmann::json::parse(json_str);
benchmark::DoNotOptimize(json);
}
}
BENCHMARK(BM_JsonParse);
static void BM_JsonSerialize(benchmark::State& state) {
nlohmann::json json = {{"name", "John"}, {"age", 30}, {"city", "New York"}};
for (auto _ : state) {
std::string str = json.dump();
benchmark::DoNotOptimize(str);
}
}
BENCHMARK(BM_JsonSerialize);
BENCHMARK_MAIN();
사례 3: 메모리 할당 전략
#include <benchmark/benchmark.h>
#include <vector>
#include <memory>
static void BM_RawPointer(benchmark::State& state) {
for (auto _ : state) {
int* ptr = new int[state.range(0)];
benchmark::DoNotOptimize(ptr);
delete[] ptr;
}
}
BENCHMARK(BM_RawPointer)->Range(8, 8<<10);
static void BM_UniquePtr(benchmark::State& state) {
for (auto _ : state) {
auto ptr = std::make_unique<int[]>(state.range(0));
benchmark::DoNotOptimize(ptr.get());
}
}
BENCHMARK(BM_UniquePtr)->Range(8, 8<<10);
static void BM_Vector(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> v(state.range(0));
benchmark::DoNotOptimize(v.data());
}
}
BENCHMARK(BM_Vector)->Range(8, 8<<10);
BENCHMARK_MAIN();
세 벤치마크는 비슷해 보이지만 하는 일이 다릅니다. new int[n]은 원소를 초기화하지 않고, make_unique<int[]>(n)과 vector<int>(n)은 모든 원소를 0으로 초기화합니다. 그래서 크기가 커질수록 앞의 것이 빨라 보이는데, 이는 스마트 포인터나 vector의 “오버헤드”가 아니라 초기화 작업의 차이입니다. 같은 조건으로 비교하려면 C++20의 std::make_unique_for_overwrite<int[]>(n)처럼 초기화하지 않는 버전을 쓰거나 양쪽 모두 초기화하게 맞춰야 합니다. 이처럼 벤치마크 결과를 해석할 때는 “무엇이 더 빠른가”보다 “두 코드가 정말 같은 일을 하는가”를 먼저 확인하는 것이 중요합니다.
트러블슈팅
문제 1: 컴파일러 최적화로 코드 제거
증상: 벤치마크 시간이 0에 가까움
// ❌ 최적화로 제거
static void BM_Bad(benchmark::State& state) {
for (auto _ : state) {
int x = 42; // 컴파일러가 제거
}
}
// ✅ 최적화 방지
static void BM_Good(benchmark::State& state) {
for (auto _ : state) {
int x = 42;
benchmark::DoNotOptimize(x); // 최적화 방지
}
}
문제 2: 캐시 효과
증상: 첫 실행이 느리고 이후 빨라짐
// ❌ 캐시 효과
auto time1 = benchmark(f); // 캐시 미스 (느림)
auto time2 = benchmark(f); // 캐시 히트 (빠름)
// ✅ 워밍업
for (int i = 0; i < 10; ++i) f();
auto time = benchmark(f);
문제 3: 측정 오버헤드
증상: 매우 짧은 작업의 측정 시간이 부정확
// ❌ 측정 오버헤드 > 실제 시간
auto time = benchmark([]() {
int x = 1 + 1; // 너무 짧음
});
// ✅ 여러 번 반복 후 평균
auto time = benchmark([]() {
for (int i = 0; i < 1000; ++i) {
int x = 1 + 1;
}
}, 100) / 1000;
이 “개선” 예제에도 함정이 남아 있습니다. 안쪽 루프의 int x = 1 + 1;은 결과가 쓰이지 않으므로 최적화 빌드에서 루프 전체가 사라지고, 앞서 본 benchmark()는 정수 μs를 반환하므로 / 1000의 결과는 거의 항상 0입니다. 즉 반복 횟수를 늘리는 원칙은 맞지만, 반복되는 본문이 실제로 남아 있는지와 나눗셈 단위를 함께 챙겨야 합니다. 나노초 수준의 작업이라면 직접 루프를 만들기보다 Google Benchmark에 맡기는 편이 이런 실수를 줄입니다.
문제 4: 백그라운드 프로세스
증상: 측정 시간이 불안정
// ❌ 다른 프로세스 실행 중
// 브라우저, IDE, 백그라운드 서비스 등
// ✅ 격리 실행
// 1. 다른 프로세스 종료
// 2. CPU 고정 (Linux)
taskset -c 0 ./bench
// 3. 우선순위 높이기 (음수 nice 값은 root 권한 필요)
nice -n -20 ./bench
저는 노트북에서 돌린 벤치마크 결과를 그대로 믿었다가, 같은 코드를 다시 돌렸을 때 두 구현의 순위가 뒤바뀌는 경험을 한 적이 있습니다. 전원 어댑터 연결 여부, 발열로 인한 클럭 저하(thermal throttling), 터보 부스트가 켜졌다 꺼지는 것만으로도 수십 퍼센트 차이가 생기기 때문입니다. 그 뒤로는 비교 대상을 번갈아 여러 번 실행하고, Google Benchmark의 --benchmark_repetitions=10으로 반복 측정해 _mean, _median, _stddev 집계 행을 함께 보며, 차이가 표준편차보다 충분히 클 때만 결론을 냅니다. 두 결과 파일을 비교할 때는 저장소에 포함된 tools/compare.py가 통계적 유의성 검정까지 해 줍니다.
마무리
C++ 벤치마킹은 성능 최적화의 핵심 도구입니다.
핵심 요약
- 기본 측정
- 경과 시간은
std::chrono::steady_clock - 짧은 작업은 나노초나
double단위로 기록
- 경과 시간은
- 정확한 측정
- 워밍업 (초기 샘플이 안정될 때까지)
- 반복 실행 (측정 시간이 타이머 오버헤드보다 충분히 길도록)
- 통계 분석 (평균, 중앙값, 표준편차)
- 최적화 방지
volatile또는benchmark::DoNotOptimize- 결과 사용
- Google Benchmark
- 전문적인 벤치마크 프레임워크
- 자동 반복, 통계, 비교
벤치마킹 체크리스트
| 항목 | 권장 사항 | 이유 |
|---|---|---|
| 워밍업 | 초기 샘플이 안정될 때까지 | CPU 캐시, 분기 예측 최적화 |
| 반복 횟수 | 측정 시간이 충분히 길어질 때까지 | 통계적 유의성 확보 |
| 최적화 방지 | volatile 또는 DoNotOptimize | 컴파일러 최적화 제거 방지 |
| 격리 실행 | 다른 프로세스 종료 | 노이즈 최소화 |
| CPU 고정 | taskset (Linux) | 코어 이동 방지 |
| 릴리즈 빌드 | -O3 -DNDEBUG | 실제 성능 측정 |
통계 지표
| 지표 | 의미 | 활용 |
|---|---|---|
| 평균 (Mean) | 전체 평균 시간 | 일반적인 성능 |
| 중앙값 (Median) | 중간 값 | 이상치 제거 |
| 최소 (Min) | 최고 성능 | 최적 조건 |
| 최대 (Max) | 최악 성능 | 최악 케이스 |
| 표준편차 (StdDev) | 변동성 | 안정성 평가 |
| 백분위수 (P95, P99) | 상위 5%, 1% | SLA 기준 |
코드 예제 치트시트
// 기본 측정
auto start = std::chrono::high_resolution_clock::now();
// 코드 실행
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
// 워밍업 + 반복
for (int i = 0; i < 10; ++i) f(); // 워밍업
BenchmarkStats stats;
for (int i = 0; i < 100; ++i) {
auto time = benchmark(f);
stats.addSample(time);
}
stats.printStats();
// Google Benchmark
static void BM_Function(benchmark::State& state) {
for (auto _ : state) {
// 코드 실행
}
}
BENCHMARK(BM_Function);
BENCHMARK_MAIN();
다음 단계
- 스톱워치: C++ 스톱워치와 벤치마크
- 성능 최적화: C++ 성능 최적화
- 캐시 최적화: C++ 캐시 최적화
참고 자료
- “Optimized C++” - Kurt Guntheroth
- Google Benchmark: https://github.com/google/benchmark
- cppreference: https://en.cppreference.com/w/cpp/chrono 한 줄 정리: 벤치마킹은 워밍업, 반복 실행, 통계 분석으로 신뢰할 수 있는 성능 수치를 얻으며, Google Benchmark로 전문적인 측정을 자동화합니다.
자주 묻는 질문 (FAQ)
Q. 아주 짧은 함수의 실행 시간을 한 번만 재면 왜 결과를 믿기 어렵나요?
A. 측정 대상이 나노초 단위로 끝나면 chrono로 시간을 읽는 호출 자체의 비용과 타이머 해상도가 실제 작업 시간보다 커져서 숫자가 대부분 측정 오버헤드가 됩니다. 작업을 루프로 수천 번 반복한 전체 시간을 재고 반복 횟수로 나누어야 의미 있는 값이 나옵니다. 이때 루프 안의 계산 결과를 사용하지 않으면 컴파일러가 루프 자체를 없앨 수 있으므로, Google Benchmark의 DoNotOptimize처럼 결과를 소비하는 장치를 함께 써야 합니다.