C++ 스톱워치와 벤치마크 | chrono로 실행 시간 측정하기
이 글의 핵심
최적화 전에 한 번 재 보고 결론을 내리면 캐시 상태나 스케줄링 잡음에 속기 쉽습니다. 이 글은 steady_clock을 골라야 하는 이유, 반복 측정 결과의 각 통계 지표가 무엇을 뜻하는지, 최적화로 사라진 코드를 재고 있지 않은지 확인하는 방법을 코드에 바로 넣을 수 있는 작은 계측 도구와 함께 설명합니다.
들어가며
코드를 최적화하기 전에 가장 먼저 해야 할 일은 “지금 얼마나 걸리는지”를 정확히 재는 것입니다. C++에서는 별도 라이브러리 없이도 <chrono> 헤더만으로 충분히 신뢰할 수 있는 시간 측정이 가능합니다. 이 글은 std::chrono를 이용해 재사용 가능한 스톱워치 클래스와 RAII 방식 타이머를 직접 만들고, 결과를 통계적으로 해석하는 방법을 다룹니다.
Google Benchmark 같은 전문 프레임워크의 심화 사용법은 C++ Benchmarking 가이드에서 자세히 다루므로, 이 글에서는 외부 라이브러리 없이 chrono만으로 “당장 코드에 넣어 쓸 수 있는” 가벼운 계측 도구에 집중합니다. duration의 개념이 낯설다면 duration 가이드를, 단위 변환이 궁금하다면 시간 변환 가이드를 먼저 읽어보는 것을 권합니다.
이 글에서 다루는 내용
high_resolution_clock과steady_clock의 차이와 선택 기준- 재사용 가능한
Stopwatch클래스와ScopedTimer(RAII) 구현 - 여러 번 측정한 뒤 평균·중앙값·표준편차로 해석하는 방법
- 컴파일러 최적화로 벤치마크 코드가 사라지는 문제와 방지법
- 실무에서 자주 발생하는 측정 실수와 해결책
간단한 스톱워치 만들기
가장 기본적인 형태는 시작 시점을 저장해두고, 필요할 때 현재 시각과의 차이를 계산하는 클래스입니다.
#include <chrono>
#include <iostream>
class Stopwatch {
using Clock = std::chrono::high_resolution_clock;
Clock::time_point start_;
public:
Stopwatch() : start_(Clock::now()) {}
void reset() { start_ = Clock::now(); }
double elapsed_ms() const {
auto end = Clock::now();
return std::chrono::duration<double, std::milli>(end - start_).count();
}
};
int main() {
Stopwatch sw;
volatile long long x = 0;
for (int i = 0; i < 1000000; ++i) x += i;
std::cout << "elapsed: " << sw.elapsed_ms() << " ms\n";
return 0;
}
생성자에서 Clock::now()를 호출해 시작 시각을 저장하고, elapsed_ms()를 호출할 때마다 현재 시각과의 차이를 밀리초 단위 double로 반환합니다. reset()으로 시작점을 다시 잡을 수 있어서, 여러 구간을 하나의 객체로 반복 측정할 때 유용합니다. duration<double, std::milli>처럼 표현식을 직접 지정하면 정수 나눗셈으로 인한 오차 없이 소수점까지 정확한 경과 시간을 얻을 수 있습니다.
실무 팁: steady_clock을 쓰면 시스템 시계 보정의 영향을 받지 않아 경과 시간만 측정할 때 더 적합합니다. high_resolution_clock은 구현에 따라 steady_clock의 별칭일 수도, 아닐 수도 있으므로 “모노토닉이 꼭 필요하다”면 steady_clock을 명시하는 것이 좋습니다. 실제로 GCC의 libstdc++에서는 high_resolution_clock이 system_clock의 별칭이라, 위 예제처럼 쓰면 NTP 보정의 영향을 그대로 받습니다. 이 글의 이후 코드가 모두 steady_clock을 쓰는 이유입니다.
예제의 누적 변수를 long long으로 둔 데에도 이유가 있습니다. 0부터 999,999까지 더한 값은 약 5천억으로 int 범위(약 21억)를 한참 넘기 때문에, int로 두면 부호 있는 정수 오버플로라는 미정의 동작이 됩니다. 컴파일러는 “오버플로는 일어나지 않는다”고 가정하고 최적화할 수 있으므로, 벤치마크 코드에서 UB가 있으면 측정하려는 코드와 전혀 다른 기계어가 나올 수 있습니다.
now() 호출 자체에도 비용이 있습니다. Linux의 clock_gettime은 커널 진입 없이 vDSO로 처리되어 대개 수십 나노초 수준이지만, 가상 머신이나 일부 클라우드 인스턴스에서는 시계 소스 설정에 따라 훨씬 느려지기도 합니다. 측정 대상이 마이크로초보다 짧다면 한 번의 호출을 now() 두 번으로 감싸기보다, 수천 번 반복한 전체 시간을 재고 반복 횟수로 나누는 편이 정확합니다.
RAII 스타일 구간 측정
생성자에서 시작 시각을 기록하고 소멸자에서 경과 시간을 출력하면, 스코프를 벗어나는 순간 자동으로 측정이 끝나는 타이머를 만들 수 있습니다. 예외가 던져져도 스택 풀기(stack unwinding) 과정에서 소멸자는 반드시 호출되므로, 함수 중간에 return이 여러 곳에 있어도 측정을 놓치지 않습니다.
class ScopedTimer {
using Clock = std::chrono::steady_clock;
Clock::time_point start_;
const char* name_;
public:
explicit ScopedTimer(const char* name = nullptr) : start_(Clock::now()), name_(name) {}
~ScopedTimer() {
auto ms = std::chrono::duration<double, std::milli>(Clock::now() - start_).count();
if (name_) std::cout << "[" << name_ << "] ";
std::cout << ms << " ms\n";
}
};
void process() {
ScopedTimer t("process");
// ... 작업 ...
} // 함수를 벗어나는 순간 소멸자가 호출되어 경과 시간이 출력됨
이 패턴은 함수나 특정 블록의 실행 시간을 로그로 남기고 싶을 때 코드 한 줄만 추가하면 되므로 실무에서 자주 쓰입니다. 다만 소멸자에서 매번 std::cout으로 출력하면 그 자체가 오버헤드가 될 수 있으므로, 프로덕션 코드에서는 로거로 교체하거나 조건부 컴파일(#ifdef PROFILE)로 감싸는 것이 좋습니다.
처음 이 타이머를 쓸 때 가장 흔한 실수는 변수 이름을 빼먹는 것입니다. ScopedTimer("process");처럼 이름 없이 쓰면 그 줄에서 임시 객체가 만들어졌다가 바로 파괴되므로, 항상 0ms 근처가 찍히고 정작 측정하려던 구간은 재지 않습니다. 컴파일 에러도 경고도 나오지 않아서 “이 함수가 이렇게 빨랐나?” 하고 넘어가기 쉽습니다. 생성자에 [[nodiscard]]를 붙이면(C++20) 이런 사용을 컴파일러가 경고해 주고, 매크로로 ScopedTimer _timer_##__LINE__(name)처럼 이름을 자동으로 붙이는 방법도 많이 쓰입니다. 또 name_을 const char*로 저장하므로 임시 std::string의 c_str()을 넘기면 소멸자가 실행될 때 이미 해제된 메모리를 읽게 됩니다. 문자열 리터럴만 넘기거나, 동적 이름이 필요하면 std::string으로 복사해 저장해야 합니다.
여러 번 측정하고 통계로 해석하기
한 번만 측정하면 캐시 상태나 OS 스케줄링에 따라 편차가 큽니다. 같은 코드를 여러 번 실행한 뒤 평균, 중앙값, 표준편차를 함께 보는 것이 신뢰할 수 있는 결론을 내는 방법입니다.
#include <chrono>
#include <vector>
#include <algorithm>
#include <iostream>
#include <numeric>
#include <cmath>
struct BenchmarkResult {
double min, max, mean, median, stddev;
std::vector<double> samples;
};
template<typename F>
BenchmarkResult benchmark(F&& f, int runs = 100) {
std::vector<double> times;
times.reserve(runs);
// 워밍업: 캐시를 데우고 분기 예측을 안정시킴
for (int i = 0; i < 3; ++i) f();
// 실제 측정
for (int i = 0; i < runs; ++i) {
auto start = std::chrono::steady_clock::now();
f();
auto end = std::chrono::steady_clock::now();
times.push_back(std::chrono::duration<double, std::milli>(end - start).count());
}
std::sort(times.begin(), times.end());
BenchmarkResult result;
result.samples = times;
result.min = times.front();
result.max = times.back();
result.median = times[runs / 2];
result.mean = std::accumulate(times.begin(), times.end(), 0.0) / runs;
double variance = 0.0;
for (double t : times) {
variance += (t - result.mean) * (t - result.mean);
}
result.stddev = std::sqrt(variance / runs);
return result;
}
int main() {
volatile long long sink = 0;
auto result = benchmark([&sink]() {
for (int i = 0; i < 1000000; ++i) sink += i;
}, 100);
std::cout << "Min: " << result.min << " ms\n";
std::cout << "Max: " << result.max << " ms\n";
std::cout << "Mean: " << result.mean << " ms\n";
std::cout << "Median: " << result.median << " ms\n";
std::cout << "StdDev: " << result.stddev << " ms\n";
return 0;
}
이 함수는 먼저 워밍업으로 몇 차례 함수를 실행한 뒤, runs번 반복하며 각 실행 시간을 벡터에 저장합니다. 이후 정렬해서 최솟값·최댓값·중앙값을 구하고, std::accumulate로 평균을, 편차 제곱의 평균으로 표준편차를 계산합니다. 람다를 템플릿 인자로 받기 때문에 어떤 호출 가능한 객체든 그대로 넘길 수 있다는 점이 이 함수의 장점입니다.
각 지표가 의미하는 것
- Min: 최상의 경우로, 캐시 히트와 CPU 스케줄링이 가장 유리했던 실행입니다.
- Max: 최악의 경우로, 캐시 미스나 컨텍스트 스위칭이 끼어든 실행입니다.
- Mean: 전체 평균이지만 이상치에 민감합니다.
- Median: 이상치의 영향을 덜 받아 일반적인 성능을 더 잘 나타냅니다.
- StdDev: 값이 클수록 측정 환경이 불안정하다는 뜻입니다.
두 구현을 비교할 때는 Min을 함께 보는 것도 유용합니다. 측정 잡음은 거의 항상 시간을 늘리는 방향으로만 작용하므로(다른 프로세스가 끼어들어 코드가 더 빨라지는 경우는 없습니다), 최솟값은 “방해가 가장 적었을 때 이 코드가 낼 수 있는 속도”에 가깝습니다. 반대로 실행 시간 분포 자체가 중요한 경우(서버 응답 지연)에는 최솟값이 오해를 부르므로 중앙값과 꼬리 백분위를 봐야 합니다. 또 실행 시간 분포는 보통 오른쪽으로 꼬리가 긴 형태라 평균 ± 표준편차가 정규분포처럼 해석되지 않는다는 점도 기억할 만합니다.
실무 권장: 결과를 보고할 때는 평균보다 중앙값을 우선하고, 표준편차가 크면 반복 횟수를 늘리거나 측정 환경(백그라운드 프로세스, CPU 주파수 스케일링)을 먼저 안정화하세요. 필요하다면 아래처럼 백분위수도 함께 확인할 수 있습니다.
// 백분위 계산 (P95, P99)
double percentile(const std::vector<double>& sorted_times, double p) {
int idx = static_cast<int>(sorted_times.size() * p);
return sorted_times[std::min(idx, (int)sorted_times.size() - 1)];
}
auto result = benchmark([]{ /* ... */ }, 100);
std::cout << "P95: " << percentile(result.samples, 0.95) << " ms\n";
std::cout << "P99: " << percentile(result.samples, 0.99) << " ms\n";
지연 시간이 SLA와 직결되는 서버 코드라면 평균보다 P95, P99가 더 실질적인 지표입니다. 상위 5%, 1%의 느린 요청이 실제 사용자 체감 품질을 결정하기 때문입니다.
컴파일러의 최적화 제거 막기
컴파일러가 “이 계산의 결과를 아무도 쓰지 않는다”고 판단하면 루프나 함수 호출 전체를 제거해버릴 수 있습니다. 그러면 벤치마크 시간이 비정상적으로 0에 가깝게 나옵니다. 이를 막으려면 결과를 실제로 “사용”했다고 컴파일러에 알려야 합니다.
// 나쁜 예: x를 아무 곳에도 쓰지 않으므로 루프 전체가 제거될 수 있음
void bad_bench() {
long long x = 0;
for (int i = 0; i < 1000000; ++i) x += i;
}
// 나은 예 1: volatile 변수에 결과를 저장
volatile long long sink = 0;
void better_bench() {
long long x = 0;
for (int i = 0; i < 1000000; ++i) x += i;
sink = x; // volatile 쓰기는 컴파일러가 지울 수 없는 부작용(side effect)
}
// 나은 예 2: 인라인 어셈블리로 컴파일러 장벽을 세움
void best_bench() {
long long x = 0;
for (int i = 0; i < 1000000; ++i) x += i;
asm volatile("" : "+r"(x) : :); // x가 사용되었다고 컴파일러에 알림
}
여기서 흔히 놓치는 함정이 있습니다. “나은 예 1, 2”는 결과가 사용된다는 것만 알려 줄 뿐, 결과를 계산하는 방법까지 강제하지는 않습니다. 이 루프는 입력이 모두 상수(0부터 999,999)라서, GCC와 Clang은 -O2에서 루프를 합 공식이나 미리 계산된 상수 하나로 바꿔 버릴 수 있습니다. 그러면 sink에는 올바른 값이 한 번 기록되지만, 측정된 시간은 루프가 아니라 상수 저장 하나의 시간입니다. 결과가 너무 빠르게 나오면 입력도 불투명하게 만들어야 합니다. 루프 경계 n을 런타임 인자로 받거나, 아래의 DoNotOptimize(n)을 루프 앞에서 호출해 컴파일러가 값을 모르게 하는 것입니다. 가장 확실한 확인 방법은 -O2 -S 출력이나 Compiler Explorer에서 루프가 실제로 남아 있는지 보는 것입니다.
Google Benchmark 같은 라이브러리를 쓰면 benchmark::DoNotOptimize()와 ClobberMemory()가 같은 역할을 대신해줍니다. 자체 유틸리티를 만들 때는 아래처럼 최소한의 래퍼를 두면 됩니다. 이 인라인 어셈블리 문법은 GCC와 Clang 전용이며, x64용 MSVC는 인라인 어셈블리를 지원하지 않으므로 Windows에서는 결과를 volatile 변수에 저장하는 방식이나 Google Benchmark의 MSVC 구현을 쓰는 편이 현실적입니다.
template<typename T>
void DoNotOptimize(T const& value) {
asm volatile("" : : "r,m"(value) : "memory");
}
void ClobberMemory() {
asm volatile("" : : : "memory");
}
주의할 점: volatile은 매번 메모리 접근을 강제하므로 그 자체로 성능 오버헤드가 있습니다. 측정하려는 코드 내부에서 반복적으로 사용하지 말고, 루프가 끝난 뒤 결과를 저장할 때 한 번만 사용하세요.
// 느림: 루프 안에서 매번 메모리에 씀
volatile long long x = 0;
for (int i = 0; i < 1000000; ++i) {
x += i;
}
// 빠름: 레지스터에서 계산하고 마지막에 한 번만 기록
long long x = 0;
for (int i = 0; i < 1000000; ++i) {
x += i;
}
volatile long long sink = x;
실무에서 자주 하는 실수
시계 선택을 잘못 고르는 경우
경과 시간을 재는 목적이라면 system_clock이 아니라 반드시 steady_clock을 써야 합니다. system_clock은 NTP 동기화나 사용자의 시스템 시계 변경으로 값이 앞뒤로 튈 수 있어, 측정 도중 시계가 되감기면 음수 경과 시간이 나올 수도 있습니다.
// 위험: 시스템 시간이 바뀌면 음수가 나올 수 있음
auto start = std::chrono::system_clock::now();
auto elapsed = std::chrono::system_clock::now() - start;
// 안전: 항상 단조 증가함이 보장됨
auto start2 = std::chrono::steady_clock::now();
auto elapsed2 = std::chrono::steady_clock::now() - start2;
정리하면, 벽시계 시각(wall-clock time)이 필요할 때는 system_clock을, 경과 시간만 필요할 때는 steady_clock을 사용합니다. high_resolution_clock은 해상도는 높지만 표준이 모노토닉을 보장하지 않으므로 용도가 명확할 때만 선택하는 것이 안전합니다.
한 번만 측정하고 결론 내리는 경우
첫 실행은 명령어 캐시와 데이터 캐시가 아직 데워지지 않아 느릴 수 있고, 이후 실행은 캐시 히트로 훨씬 빨라질 수 있습니다. 워밍업 없이 단 한 번의 측정값만 보고하면 실제 성능을 과소평가하거나 과대평가하게 됩니다. 앞서 만든 benchmark() 함수처럼 여러 번 실행한 뒤 중앙값과 표준편차를 함께 보고하는 습관을 들이는 것이 좋습니다.
반대로 워밍업이 현실과 다른 결과를 만들 때도 있습니다. 같은 1MB 배열을 100번 처리하면 두 번째부터는 데이터가 전부 캐시에 있어 매우 빠르게 나오지만, 실제 서비스에서는 요청마다 다른 데이터를 다루므로 캐시가 차갑게 시작하는 경우가 많습니다. “무엇을 재고 싶은가”에 따라 반복마다 다른 입력을 쓰거나, 큰 버퍼를 한 번 훑어 캐시를 비운 뒤 측정하는 방식을 택해야 합니다.
디버그 빌드나 불안정한 환경에서 재는 경우
-O0 디버그 빌드에서 잰 결과로 최적화 방향을 정하는 것은 거의 의미가 없습니다. 디버그 빌드에서는 템플릿과 인라인 함수가 전혀 펼쳐지지 않아, std::vector 반복자 접근처럼 릴리스에서는 사라지는 비용이 결과를 지배합니다. Visual Studio 디버그 빌드는 반복자 검사까지 켜져 있어 차이가 더 커집니다. 환경 쪽에서는 노트북의 전원 절약 모드, CPU 터보 부스트와 발열에 따른 클럭 변화가 대표적인 잡음원입니다. 긴 벤치마크의 뒷부분만 느려진다면 코드가 아니라 CPU 온도 때문일 수 있으므로, 비교하려는 두 구현을 번갈아 실행해 이런 시간에 따른 변화가 한쪽에만 몰리지 않게 하는 것이 좋습니다.
권장 반복 횟수
작업이 얼마나 빠른지에 따라 적절한 반복 횟수도 달라집니다.
| 함수 실행 시간 | 권장 반복 횟수 |
|---|---|
| 1ms 미만 (매우 빠름) | 1,000회 이상 |
| 1~100ms (보통) | 100회 |
| 100ms 초과 (느림) | 10~30회 |
측정 대상이 아주 짧다면(수 나노초 수준) 측정 자체의 오버헤드가 실제 실행 시간보다 커질 수 있으므로, 루프로 여러 번 반복한 뒤 총 시간을 나눠서 평균 시간을 구하는 방식을 함께 고려해야 합니다.
직접 만든 스톱워치로 부족해질 때
두 구현 중 어느 쪽이 빠른지 한 번 확인하거나, 서비스 코드의 특정 구간이 얼마나 걸리는지 로그로 남기는 정도라면 이 글의 Stopwatch와 ScopedTimer로 충분합니다. 반면 입력 크기를 바꿔 가며 곡선을 봐야 하거나, 반복 횟수를 측정 안정성에 맞춰 자동으로 정해야 하거나, 최적화 제거를 막는 장치를 매번 손으로 넣기 번거로워졌다면 직접 만든 도구를 키우기보다 C++ Benchmarking 가이드에서 다루는 Google Benchmark로 옮기는 편이 낫습니다. 직접 만든 측정 코드는 워밍업과 통계 처리를 빠뜨려도 숫자는 그럴듯하게 나오기 때문에, 틀렸다는 사실을 알아채기 어렵다는 점이 가장 큰 위험입니다.
같이 보면 좋은 글
- C++ Chrono | 시간 라이브러리 가이드
- C++ Benchmarking | 벤치마킹 가이드
- C++ duration | 시간 간격 가이드
- C++ time_point
- C++ steady_clock