성능 프로파일링 도구 소개
CPU 시간·메모리·호출 빈도를 측정하는 프로파일러와 수동 계측을 활용해 추측이 아닌 데이터로 병목을 찾습니다.
C++은 성능이 중요한 게임, 시스템 프로그램, 계산 프로그램에 많이 쓰입니다. 그런데 프로그램이 느릴 때 어디가 느린지 짐작으로 고치면, 실제로는 시간이 거의 들지 않는 곳을 복잡하게 만들고 정작 느린 곳은 그대로 두는 일이 흔합니다. 프로파일링(profiling)은 프로그램이 실행되는 동안 어디에 시간과 메모리를 쓰는지 측정해, 고칠 곳을 데이터로 찾는 일입니다.
이 장에서는 성능을 측정하고 개선하는 방법과 디버깅 기법을 다룹니다. 이 글은 무엇을 측정할지 정하는 방법, 플랫폼별 프로파일러와 CPU 프로파일링의 순서, std::chrono로 구간의 시간을 직접 재는 방법, 측정 결과를 믿을 수 있게 비교하는 방법을 다룹니다. 아래 그림은 기준을 고정하고, 측정하고, 하나만 바꾸고, 다시 측정하는 반복을 보여 줍니다.
프로파일링 기록은 한 변경과 재측정마다 갱신된다
입력과 환경 고정→기준 측정→병목 가설→한 가지 변경→동일 조건 재측정의 작업 상태가 실제로 바뀐다.
C++ · MEASURE AND VERIFY
측정 조건과 결과를 기록하며 한 변경씩 재검증한다
같은 입력·빌드·환경에서 비용을 측정하고 가설 하나를 바꾼다. 단계마다 조건과 결과를 기록하여 다음 측정과 비교한다.
기준선 고정
입력 · 빌드 · 환경
지표 측정
CPU · 메모리 · 대기
hotspot 선택
가장 큰 비용 · 원인 가설
한 변경
선택한 가설 하나만
재검증
정확성 + 동일 조건 측정
최적화와 디버그 심볼을 포함한 빌드로 대표 작업을 충분히 측정한다. 한 번의 숫자 대신 반복 분포와 정답을 비교하며 기록을 갱신한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 같은 입력·빌드·환경에서 비용을 측정하고 가설 하나에 따른 변경을 적용한다. 단계마다 조건과 결과를 기록하여 다음 측정과 비교한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기준 측정 같은 입력·환경으로 기준 실행을 기록한다. |
|
|
| 한 가설·한 변경 프로파일의 병목 근거에 따라 하나만 바꾼다. |
|
|
| 재측정 같은 조건에서 정확성과 성능을 다시 확인한다. |
|
|
프로파일링 기록은 한 변경과 재측정마다 갱신된다
입력과 환경 고정→기준 측정→병목 가설→한 가지 변경→동일 조건 재측정의 작업 상태가 실제로 바뀐다.
C++ · MEASURE AND VERIFY
측정 조건과 결과를 기록하며 한 변경씩 재검증한다
같은 입력·빌드·환경에서 비용을 측정하고 가설 하나를 바꾼다. 단계마다 조건과 결과를 기록하여 다음 측정과 비교한다.
기준선 고정
입력 · 빌드 · 환경
지표 측정
CPU · 메모리 · 대기
hotspot 선택
가장 큰 비용 · 원인 가설
한 변경
선택한 가설 하나만
재검증
정확성 + 동일 조건 측정
최적화와 디버그 심볼을 포함한 빌드로 대표 작업을 충분히 측정한다. 한 번의 숫자 대신 반복 분포와 정답을 비교하며 기록을 갱신한다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 같은 입력·빌드·환경에서 비용을 측정하고 가설 하나에 따른 변경을 적용한다. 단계마다 조건과 결과를 기록하여 다음 측정과 비교한다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기준 측정 같은 입력·환경으로 기준 실행을 기록한다. |
|
|
| 한 가설·한 변경 프로파일의 병목 근거에 따라 하나만 바꾼다. |
|
|
| 재측정 같은 조건에서 정확성과 성능을 다시 확인한다. |
|
|
측정부터 하는 이유
그림처럼 성능 개선은 같은 입력, 같은 빌드 옵션, 같은 환경에서 기준 실행을 먼저 측정하는 데서 시작합니다. 그다음 가장 큰 비용을 쓰는 곳, 즉 핫스팟(hotspot)을 골라 원인에 대한 가설을 세우고, 그 가설에 맞는 변경 하나만 적용해 같은 조건에서 다시 측정합니다. 결과가 정확한지도 함께 확인하고, 각 단계의 조건과 결과를 기록해 다음 비교에 씁니다.
한 번에 여러 곳을 바꾸면 무엇이 빨라지게 했는지, 혹은 무엇이 느려지게 했는지 알 수 없습니다. 또 전체 실행 시간의 1%를 쓰는 함수를 두 배 빠르게 만들어도 전체는 0.5%만 빨라지므로, 많은 시간을 쓰는 곳부터 고쳐야 노력에 비해 효과가 큽니다. 이 계산을 일반화한 식은 컴퓨터구조 교재의 성능 계산과 종합 추적에서 다루는 Amdahl의 법칙입니다.
무엇을 측정할까
느리다는 증상에도 종류가 있으며, 종류에 따라 볼 지표가 다릅니다. 아래 그림은 증상에서 지표와 도구의 종류를 고르는 기준입니다.
C++ · PROFILING QUESTIONS
증상에 맞는 지표로 성능 가설을 검증한다
도구 이름을 고르기 전에 무엇이 느린지 정한다. 다음 네 관점은 함께 나타날 수 있으므로 측정 결과로 가설을 좁힌다.
CPU가 바쁨 → 실행 비용
샘플과 호출 그래프로 시간을 많이 쓰는 함수와 호출 경로를 찾는다.
호출 횟수와 한 번의 비용을 구분한다. CPU 사용량만으로 원인 함수를 단정하지 않는다.
메모리가 증가 → 할당과 수명
할당 위치·빈도·최대 사용량과 살아 있는 객체의 수명을 확인한다.
사용량 증가 자체가 누수의 증명은 아니다. 정상 캐시·버퍼 보유와 잃어버린 해제 책임을 구별한다.
CPU는 남는데 느림 → 대기
I/O·큐·잠금 대기 시간과 실행 타임라인을 확인한다.
CPU hotspot만 보면 대기 병목을 놓칠 수 있다. 요청의 전체 경과 시간과 실제 실행 시간을 비교한다.
작은 구간 차이 → 반복 측정
같은 작업의 실행 시간을 반복 수집해 분포와 편차를 비교한다.
const auto start =
std::chrono::steady_clock::now();
// 관찰할 작업
const auto end =
std::chrono::steady_clock::now();본문 chrono 예제는 결과 checksum을 사용한다. 초기화·출력 비용과 측정할 범위를 함께 확인한다.
profiler는 비용 측정, sanitizer는 오류 탐지를 주로 맡는다. 도구의 계측 비용·플랫폼·빌드 조건을 기록하고 같은 조건에서 해석한다.
| 증상 | 볼 지표 | 도구의 종류 |
|---|---|---|
| CPU 사용률이 높음 | 함수별 실행 시간, 호출 횟수, 호출 경로 | CPU 프로파일러 |
| 메모리 사용량이 계속 늘어남 | 할당 위치와 횟수, 최대 사용량, 살아 있는 객체의 수명 | 메모리 프로파일러, 누수 탐지기 |
| CPU는 한가한데 느림 | 입출력, 잠금, 큐에서 기다린 시간 | 대기 시간 분석, 추적 도구 |
| 특정 작업만 느림 | 그 구간의 실행 시간 | 직접 계측 |
여러 증상이 함께 나타날 수도 있으므로, 측정 결과를 보고 가설을 좁혀 갑니다. 예를 들어 메모리 사용량이 늘어난다고 바로 누수라고 볼 수는 없으며, 일부러 들고 있는 캐시나 버퍼일 수도 있습니다. 메모리 누수는 메모리 누수 탐지에서 따로 다룹니다.
프로파일러
CPU 프로파일러는 크게 두 방식으로 동작합니다. 표본 추출(sampling) 방식은 일정한 간격으로 프로그램이 지금 어느 함수를 실행하는지 기록해, 많이 기록된 함수를 시간을 많이 쓰는 함수로 봅니다. 계측(instrumentation) 방식은 함수의 시작과 끝에 측정 코드를 넣어 호출 횟수와 시간을 정확히 세지만, 그 코드 때문에 프로그램이 느려지고 측정값이 실제와 달라질 수 있습니다.
| 플랫폼 | 대표 도구 |
|---|---|
| Linux | perf, gprof, Valgrind의 Callgrind |
| Windows | Visual Studio 프로파일러, Windows Performance Analyzer, Intel VTune |
| macOS | Xcode의 Instruments |
| 여러 플랫폼 | gprof, Intel VTune, AMD uProf, Tracy 같은 프레임 프로파일러 |
이 글에서는 특정 도구의 사용법보다 프로파일링의 순서와 해석에 집중하며, 각 도구의 자세한 사용법은 그 도구의 공식 문서를 참고합니다. 도구마다 지원하는 컴파일러와 디버그 정보 형식이 다르므로, MSYS2의 GCC로 만든 프로그램인지 MSVC로 만든 프로그램인지에 따라 쓸 수 있는 도구를 확인합니다.
CPU 프로파일링의 순서
첫째, 실제로 배포할 때와 같은 최적화 옵션으로 빌드하면서 디버그 정보를 함께 넣습니다. GCC와 Clang에서는 -O2 -g를 주며, 최적화를 끈 빌드로 측정하면 배포할 프로그램과 병목의 위치가 달라질 수 있습니다. 디버그 정보는 프로파일러가 실행 주소를 함수 이름과 줄 번호로 바꾸는 데 쓰입니다.
둘째, 프로파일러로 프로그램을 실행해 데이터를 모읍니다. gprof는 -pg로 빌드한 프로그램을 실행하면 gmon.out 파일이 만들어지고, Linux의 perf는 빌드는 그대로 두고 perf record로 실행합니다. perf record가 표본을 모으는 방식과 perf report의 비율을 읽는 법은 운영체제 교재의 시스템 모니터링과 디버깅에서 자세히 다룹니다.
g++ -std=c++20 -O2 -g -pg program.cpp -o program
./program
gprof program gmon.out > analysis.txtperf record -g ./program
perf report셋째, 결과에서 시간을 가장 많이 쓴 함수를 찾고, 호출 그래프(call graph)로 그 함수를 누가 얼마나 불렀는지 확인합니다. 함수 자기 안에서 쓴 시간(self time)과 그 함수가 부른 함수들까지 포함한 시간(total time)을 구별해야, 느린 것이 그 함수 자체인지 그 아래의 호출인지 알 수 있습니다. 실행 시간이 너무 짧으면 초기화 과정이 결과를 덮으므로, 실제와 비슷한 입력으로 충분히 오래 실행합니다.
넷째, 찾은 병목에 맞는 변경 하나를 적용하고 같은 조건으로 다시 측정합니다. 어떤 변경을 적용할지는 다음 글 코드 최적화 기법에서 다룹니다.
std::chrono로 직접 재기
특정 구간이 얼마나 걸리는지만 알고 싶다면 <chrono>로 직접 잴 수 있습니다. 경과 시간을 잴 때는 시스템 시각이 바뀌어도 거꾸로 가지 않는 std::chrono::steady_clock을 씁니다.
#include <algorithm>
#include <chrono>
#include <iostream>
#include <vector>
int heavyCalculation() {
std::vector<int> data(1000000);
for (int i = 0; i < 1000000; ++i) {
data[i] = (i * 13) % 1000;
}
std::sort(data.begin(), data.end());
return data.front() + data[data.size() / 2] + data.back();
}
int main() {
auto start = std::chrono::steady_clock::now();
const int checksum = heavyCalculation();
auto end = std::chrono::steady_clock::now();
std::chrono::duration<double, std::milli> elapsed = end - start;
std::cout << "checksum " << checksum << '\n';
std::cout << "elapsed " << elapsed.count() << " ms\n";
}13과 1000은 공약수가 1뿐이므로, 연속한 1000개의 i마다 (i * 13) % 1000은 0부터 999까지를 한 번씩 만듭니다. 그래서 백만 개의 원소에는 0부터 999까지가 1000개씩 들어 있고, 정렬하면 처음, 가운데, 마지막 값이 0, 500, 999가 되어 첫 줄은 checksum 1499입니다. 둘째 줄의 시간은 컴퓨터와 실행할 때마다 달라지므로 정해진 값이 없습니다.
측정한 계산의 결과를 이처럼 출력해 쓰는 것도 중요합니다. 결과를 전혀 쓰지 않으면 최적화기가 그 계산을 지워 버려 시간이 0에 가깝게 나올 수 있기 때문입니다. 다만 결과를 쓴다고 모든 최적화가 막히는 것은 아니므로, 결과가 이상하게 짧다면 어셈블리나 프로파일러로 확인합니다. duration<double, std::milli>처럼 실수로 받으면 1밀리초보다 짧은 시간도 0으로 잘리지 않습니다.
더 깊이: high_resolution_clock 대신 steady_clock을 쓰는 이유
<chrono>에는 이름만 보면 시간 측정에 가장 알맞아 보이는 std::chrono::high_resolution_clock도 있지만, 이 시계는 구현에 따라 다른 시계의 별칭입니다. cppreference의 설명에 따르면 2023년 기준으로 libstdc++는 이를 system_clock의 별칭으로, MSVC는 steady_clock의 별칭으로 두며, libc++는 단조 시계를 쓸 수 있으면 steady_clock, 아니면 system_clock으로 둡니다. system_clock의 별칭이라면 시스템 시각이 바뀔 때 값이 거꾸로 갈 수도 있으므로, 이 기준이라면 libstdc++를 쓰는 MSYS2의 GCC에서 이 시계로 잰 경과 시간은 시각 조정에 영향을 받습니다.
steady_clock은 물리적인 시간이 흐르는 동안 값이 줄어들지 않고 눈금 사이의 간격이 일정한 시계입니다. 값 자체는 재부팅한 뒤의 시간처럼 달력과 상관없을 수 있지만, 구간을 재는 데 가장 알맞은 시계로 정해져 있습니다. 직접 고른 시계가 이 성질을 가지는지는 static_assert(Clock::is_steady)처럼 컴파일할 때 확인할 수 있습니다.
측정 결과 비교하기
같은 프로그램도 실행할 때마다 시간이 조금씩 다르므로, 한 번의 측정으로 비교하면 안 됩니다. 같은 조건으로 여러 번 실행해 그 값들을 모두 기록하고, 가운데 값인 중앙값(median)과 흩어진 정도를 함께 봅니다.
예를 들어 기존 버전 A가 100, 101, 99, 100, 150밀리초, 새 버전 B가 98, 102, 100, 99, 101밀리초였다면, 두 버전의 중앙값은 모두 100밀리초입니다. B의 가장 빠른 98만 골라 2% 빨라졌다고 하는 것은 나머지 값을 무시한 결론이고, A의 150은 왜 생겼는지 따로 살펴봐야 할 이상값입니다. 또 빨라진 버전도 결과가 원래와 같은지 먼저 확인해야 하며, 결과가 틀린 버전은 아무리 빨라도 쓸 수 없습니다.
정리
- 성능 개선은 기준 실행을 측정하고, 가장 큰 비용을 쓰는 곳에 변경 하나를 적용한 뒤, 같은 조건으로 다시 측정하는 반복입니다.
- 느린 증상의 종류에 따라 CPU 시간, 메모리 할당, 대기 시간 가운데 볼 지표와 도구를 고릅니다.
- 프로파일러는 표본 추출이나 계측으로 함수별 시간을 모으며, 플랫폼과 컴파일러에 맞는 도구를 씁니다.
- CPU 프로파일링은 배포와 같은 최적화에 디버그 정보를 넣어 빌드하고, 자기 시간과 전체 시간을 구별해 읽습니다.
- 구간의 시간은
std::chrono::steady_clock으로 재고, 계산 결과를 써서 최적화로 지워지지 않게 합니다. - 측정은 여러 번 반복해 중앙값과 흩어짐을 보고, 빨라진 버전의 결과가 정확한지도 확인합니다.
연습 문제
1. 시계 고르기
구간의 실행 시간을 잴 때 std::chrono::system_clock 대신 std::chrono::steady_clock을 쓰는 이유는 무엇인가요?
해설 보기
system_clock은 실제 날짜와 시각을 나타내므로, 측정 도중에 시간 동기화나 사용자가 시각을 바꾸면 값이 뛰거나 거꾸로 갈 수 있어 경과 시간이 음수나 엉뚱한 값이 될 수 있습니다. steady_clock은 거꾸로 가지 않도록 보장되어 경과 시간을 재는 데 알맞습니다. 해상도와 실제 정밀도는 구현마다 다르므로, 아주 짧은 구간은 여러 번 반복해 잽니다.
2. 다른 입력의 결과
chrono_timing.cpp에서 원소를 만드는 식을 (i * 7) % 100으로 바꾸면 첫 줄은 무엇이 되나요?
해설 보기
7과 100은 공약수가 1뿐이므로 연속한 100개의 i마다 0부터 99까지가 한 번씩 나오고, 백만 개의 원소에는 각 값이 10000개씩 들어 있습니다. 정렬하면 처음은 0, 인덱스 500000의 가운데 값은 50, 마지막은 99이므로 checksum 149입니다.
3. 빨라졌지만 결과가 다른 버전
기존 버전의 중앙값이 100밀리초이고 결과가 checksum 1499일 때, 새 버전 C가 80, 81, 79, 80, 80밀리초에 checksum 1498을 출력했습니다. C를 채택해도 되나요?
해설 보기
C의 중앙값은 80밀리초로 20% 빠르지만, 결과가 1499가 아니라 1498이어서 정확하지 않으므로 채택하지 않습니다. 성능 비교는 결과가 같다는 것을 먼저 확인한 뒤에만 의미가 있습니다. 결과가 같았다면 입력, 빌드 옵션, 환경, 각 측정값을 기록하고 다른 입력에서도 결과와 성능을 확인한 뒤 채택합니다.
다음 글 코드 최적화 기법에서는 프로파일링으로 찾은 병목에 적용할 수 있는 컴파일러 최적화, 알고리즘 선택, 캐시 친화적인 코드, 불필요한 복사 줄이기 같은 기법을 다룹니다.