C++ 프로파일링: perf·Tracy·VTune 도구 비교와 병목 찾는 절차
핵심 개념: 추측으로 최적화하지 말고 perf, 프로파일러, chrono로 어디가 병목인지 먼저 측정합니다. Release 빌드에 가깝게 측정하며, 한 번에 한 가지 변경만 비교하면 원인 추적이 쉬워집니다.
들어가며: “어디가 느린지 모르겠어요”
실무에서 겪는 문제 시나리오
실제 겪는 상황:
- "이 함수가 느릴 것 같아서" 3일 최적화했는데, 실제 병목은 파일 I/O였음
- perf report를 봐도 심볼이 ???로 나와서 분석 불가
- gprof로 프로파일했는데 gmon.out이 생성되지 않음
- Valgrind를 돌리니 30배 느려져서 실용성이 없다고 느낌
- API 서버가 CPU 100%인데 어느 핸들러가 문제인지 모름
- 24시간 실행 후 메모리가 2GB→8GB로 증가 (누수 의심)
- O(n) 알고리즘인데 n이 커지면 선형보다 훨씬 느림 (캐시 의심)
이런 상황에서 추측 대신 측정이 핵심입니다. 프로파일러로 병목을 찾고, 화염 그래프로 시각화하고, 상위 20% 함수부터 최적화하면 시간 대비 효과가 큽니다.
추측으로 최적화하다가 시간만 낭비했다
프로그램이 느려서 추측으로 최적화를 시도했습니다. 하지만 실제 병목(bottleneck—전체 성능을 제한하는 가장 느린 지점)은 다른 곳이었다.
잘못된 접근:
// "이 함수가 느릴 것 같아서" 최적화
void processData(std::vector<int>& data) {
// 복잡한 최적화 시도...
}
// 실제로는 이 함수가 병목이었음
void loadData() {
// 파일 I/O가 느림
}
프로파일링 후:
processData: 전체 시간의 5%loadData: 전체 시간의 80% ← 정말 병목
교훈:
- 추측하지 말고 측정하라
- 프로파일러로 병목 찾기
- 가장 느린 부분부터 최적화
프로파일링(실행 중 어느 함수가 시간·메모리를 얼마나 쓰는지 측정하는 것) 없이 “이 부분이 느릴 것 같다”고 손대면, 실제로는 I/O나 다른 모듈이 병목인 경우가 많습니다. CPU 샘플링(perf 등)이나 인스트루멘테이션으로 “어느 함수가 시간을 많이 쓰는지”를 먼저 확인한 뒤, 상위 몇 %부터 최적화하는 것이 시간 대비 효과가 큽니다.
프로파일링의 전체 흐름
flowchart TD
A[프로그램 느림] --> B[측정 없이 추측]
B --> C{병목 맞추기}
C -->|실패| D[시간 낭비]
A --> E[프로파일링 실행]
E --> F[병목 지점 확인]
F --> G[상위 20% 구간 최적화]
G --> H[재측정]
H --> I{목표 달성?}
I -->|아니오| E
I -->|예| J[성능 개선 완료]
perf, gprof, Valgrind, Visual Studio Profiler로 병목을 찾는 방법과 화염 그래프로 결과를 읽는 방법, 최적화 전후를 수치로 비교하는 습관을 다룹니다.
프로파일링이란
성능 측정의 필요성
"추측하지 말고 측정하라"
- 직관은 자주 틀림
- 병목은 예상 밖의 곳에 있음
- 측정 없는 최적화는 시간 낭비
프로파일링 종류
1. CPU 프로파일링
- 어떤 함수가 CPU를 많이 쓰는지
- 함수 호출 횟수와 시간
2. 메모리 프로파일링
- 메모리 사용량
- 할당/해제 횟수
- 메모리 누수
3. 캐시 프로파일링
- 캐시 미스 횟수
- 메모리 접근 패턴
프로파일링 종류별 비교
flowchart LR
subgraph CPU[CPU 프로파일링]
C1[perf]
C2[gprof]
C3[VS Profiler]
end
subgraph MEM[메모리 프로파일링]
M1[Valgrind Memcheck]
M2[AddressSanitizer]
end
subgraph CACHE[캐시 프로파일링]
K1[Valgrind Cachegrind]
K2[perf stat]
end
시간 측정 기초
std::chrono로 측정
C++11부터 제공하는 std::chrono로 구간 시간을 잴 수 있습니다. high_resolution_clock::now()로 시작·종료 시점을 받으며, end - start로 duration을 구한 뒤 duration_cast<std::chrono::milliseconds>로 원하는 단위로 변환합니다. 이렇게 하면 특정 함수나 블록이 실제로 몇 ms 걸리는지 숫자로 확인할 수 있어, “느리다”는 감이 아니라 데이터로 병목를 찾을 수 있습니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o profile_time profile_time.cpp && ./profile_time
#include <chrono>
#include <iostream>
void slowFunction() {
// 무거운 작업...
}
int main() {
auto start = std::chrono::high_resolution_clock::now();
slowFunction();
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "Time: " << duration.count() << " ms\n";
return 0;
}
실행 결과: Time: N ms 형태로 출력됩니다 (환경에 따라 N 값은 다름).
코드 상세 설명:
high_resolution_clock: 시스템에서 사용 가능한 가장 정밀한 시계now(): 현재 시점을time_point로 반환duration_cast: 나노초 단위 duration을 밀리초로 변환count(): 해당 단위의 정수 값 반환
측정 헬퍼 클래스
생성자에서 시각을 기록하고 소멸자에서 경과 시간을 출력하는 RAII 스타일 타이머입니다. { Timer t("slowFunction"); slowFunction(); }처럼 스코프를 두면, 블록을 빠져나갈 때 자동으로 소멸자가 호출되어 해당 구간 시간이 출력됩니다. 예외가 나거나 early return이 있어도 소멸자는 호출되므로, 수동으로 end 시간을 찍는 것보다 누락이 적습니다.
class Timer {
std::chrono::high_resolution_clock::time_point start;
const char* name;
public:
Timer(const char* n) : name(n) {
start = std::chrono::high_resolution_clock::now();
}
~Timer() {
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << name << ": " << duration.count() << " us\n";
}
};
void processData() {
Timer timer("processData");
// 작업...
} // 소멸자에서 자동 출력
주의점: Timer 객체를 스코프 밖으로 두면 측정 구간이 의도와 다를 수 있습니다. { }로 블록을 명확히 구분하세요.
여러 구간 측정
class Profiler {
std::map<std::string, long long> timings;
std::chrono::high_resolution_clock::time_point start;
public:
void startTimer() {
start = std::chrono::high_resolution_clock::now();
}
void record(const std::string& name) {
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
timings[name] += duration.count();
start = end;
}
void report() {
for (const auto& [name, time] : timings) {
std::cout << name << ": " << time << " us\n";
}
}
};
int main() {
Profiler prof;
prof.startTimer();
loadData();
prof.record("loadData");
processData();
prof.record("processData");
saveData();
prof.record("saveData");
prof.report();
}
활용: record() 호출 시점마다 “이전 구간”의 누적 시간이 기록됩니다. start = end로 다음 구간 시작을 갱신하므로, 여러 번 반복하면 전체 합계를 볼 수 있습니다.
프로파일링 도구
도구 선택 가이드
flowchart TD
A[프로파일링 필요] --> B{플랫폼?}
B -->|Linux| C[perf]
B -->|Linux/Mac| D[gprof]
B -->|Linux/Mac| E[Valgrind]
B -->|Windows| F[VS Profiler]
C --> G[CPU 샘플링]
D --> H[인스트루멘테이션]
E --> I[메모리/캐시]
F --> C
perf (Linux)
Linux의 표준 프로파일링 도구입니다. 샘플링 방식으로 CPU가 주기적으로 실행 중인 함수를 기록해, 오버헤드가 적어 프로덕션 환경에서도 사용 가능합니다.
# 프로그램 실행하며 프로파일링
perf record ./myapp
# 결과 보기
perf report
# 함수별 시간
perf stat ./myapp
출력 예시:
50.23% myapp [.] processData
30.45% myapp [.] loadFile
15.32% myapp [.] parseJson
perf report 상세 사용법:
# 호출 그래프 포함 (함수 호출 관계)
perf record -g ./myapp
# 결과를 텍스트로 출력
perf report --stdio
# 특정 함수만 필터
perf report --symbol-filter=processData
perf stat 출력 해석:
Performance counter stats for './myapp':
1,234.56 msec task-clock
42 context-switches
0 cpu-migrations
128 page-faults
3,456,789,012 cycles
2,345,678,901 instructions
task-clock: CPU 사용 시간 (ms)context-switches: 컨텍스트 스위칭 횟수page-faults: 페이지 폴트 횟수cycles: CPU 사이클instructions: 실행된 명령어 수
IPC (Instructions Per Cycle): instructions / cycles가 1에 가까우면 CPU가 효율적으로 동작합니다. 0.5 이하면 메모리 대기나 분기 예측 실패 등이 의심됩니다.
gprof (GNU Profiler)
컴파일 시 -pg 플래그로 코드에 프로파일링 코드를 삽입합니다. 실행 시 gmon.out 파일이 생성되고, 이를 분석해 함수별 호출 횟수와 시간을 보여줍니다.
# -pg 플래그로 컴파일
g++ -pg -O2 main.cpp -o myapp
# 실행 (gmon.out 생성)
./myapp
# 프로파일 보기
gprof myapp gmon.out
gprof 출력 예시:
% cumulative self self total
time seconds seconds calls ms/call ms/call name
80.0 0.80 0.80 1 800.00 800.00 loadFile
15.0 0.95 0.15 100 1.50 1.50 processData
5.0 1.00 0.05 1 50.00 50.00 saveResult
주의: -pg와 -O2를 함께 쓰면 인라인 최적화로 인해 일부 함수가 합쳐져 보일 수 있습니다. 정확한 호출 관계를 보려면 -O0 또는 -O1로 테스트할 수 있습니다.
Valgrind Callgrind
시뮬레이션 방식으로 CPU 명령어를 단계별 실행합니다. 정확한 호출 관계와 캐시 정보를 얻을 수 있지만, 10~50배 느려지므로 짧은 실행에만 적합합니다.
# 프로파일링 실행
valgrind --tool=callgrind ./myapp
# 결과 분석
callgrind_annotate callgrind.out.12345
# GUI 도구
kcachegrind callgrind.out.12345
Callgrind 옵션:
# 캐시 시뮬레이션 포함
valgrind --tool=callgrind --cache-sim=yes ./myapp
# 특정 함수만 프로파일
valgrind --tool=callgrind --toggle-collect=processData ./myapp
Visual Studio Profiler
1. 메뉴: Debug → Performance Profiler
2. CPU Usage 선택
3. Start 클릭
4. 프로그램 실행
5. Hot Path와 함수별 시간 확인
도구 비교표
| 도구 | 플랫폼 | 방식 | 오버헤드 | 프로덕션 |
|---|---|---|---|---|
| perf | Linux | 샘플링 | 낮음 (~5%) | ✅ |
| gprof | Linux/Mac | 인스트루멘테이션 | 중간 (~10%) | △ |
| Valgrind | Linux/Mac | 시뮬레이션 | 매우 높음 (10~50x) | ❌ |
| VS Profiler | Windows | 샘플링 | 낮음 | ✅ |
Flame Graph로 시각화하기
Flame Graph는 프로파일 결과를 가로 막대 그래프로 보여줍니다. 호출 스택이 아래에서 위로 쌓이며, 너비가 CPU 사용 비율을 나타냅니다. 한눈에 병목 함수를 찾을 수 있습니다.
# perf 데이터로 Flame Graph 생성
perf record -F 99 -g ./myapp
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
Flame Graph 읽는 법:
- 가로: 함수가 CPU 사용 시간 중 차지하는 비율
- 세로: 호출 스택 (아래가 호출자, 위가 피호출자)
- 넓은 막대: 가장 많은 시간을 쓰는 호출 경로
예: main → loadFile → readBuffer → memcpy
↑ 이 경로가 가장 넓으면 memcpy가 병목
화염 그래프 완전한 생성 예제
# 1. FlameGraph 스크립트 설치 (한 번만)
git clone --depth 1 https://github.com/brendangregg/FlameGraph
export PATH="$PATH:$(pwd)/FlameGraph"
# 2. perf로 호출 스택 수집 (DWARF로 정확한 스택)
perf record -F 99 -g --call-graph dwarf,8192 ./myapp
# 3. SVG 화염 그래프 생성
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
# 4. 브라우저에서 열기
open flamegraph.svg # macOS
# xdg-open flamegraph.svg # Linux
화염 그래프에서 자주 보는 패턴:
| 패턴 | 의미 | 대응 |
|---|---|---|
memcpy가 넓음 | 메모리 복사 병목 | 버퍼 풀, zero-copy |
malloc/free가 넓음 | 할당/해제 비용 | 객체 풀, arena |
std::sort가 넓음 | 정렬 비용 | 정렬 제거, 부분 정렬 |
pthread_mutex_lock | 락 대기 | 락 최소화, lock-free |
완전한 프로파일링 예제
프로파일링 대상 프로그램
// profile_target.cpp - perf, gprof로 분석할 대상
#include <vector>
#include <algorithm>
#include <random>
#include <chrono>
#include <iostream>
// 의도적으로 비효율적인 함수: 캐시 미스 유발
void processDataCacheUnfriendly(std::vector<int>& data) {
const size_t stride = 16;
for (size_t i = 0; i < data.size(); i += stride) {
data[i] = data[i] * 2 + 1;
}
}
// 캐시 친화적: 연속 접근
void processDataCacheFriendly(std::vector<int>& data) {
for (size_t i = 0; i < data.size(); ++i) {
data[i] = data[i] * 2 + 1;
}
}
// 병목 후보: O(n log n) 정렬
void sortData(std::vector<int>& data) {
std::sort(data.begin(), data.end());
}
// 병목 후보: 난수 생성
void fillRandom(std::vector<int>& data) {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<> dis(1, 1000000);
for (auto& v : data) {
v = dis(gen);
}
}
int main() {
const size_t N = 10'000'000;
std::vector<int> data(N);
fillRandom(data);
sortData(data);
processDataCacheUnfriendly(data);
processDataCacheFriendly(data);
return 0;
}
perf로 완전한 프로파일링 예제
# 1. 디버그 심볼 포함하여 컴파일
g++ -std=c++17 -O2 -g -o profile_target profile_target.cpp
# 2. perf로 호출 그래프 수집 (화염 그래프용)
perf record -F 99 -g --call-graph dwarf,8192 ./profile_target
# 3. perf report로 결과 확인
perf report --stdio
# 4. perf stat로 CPU/캐시 통계
perf stat -e cycles,instructions,cache-references,cache-misses ./profile_target
perf report —stdio 출력 예시:
# Samples: 1,234 of event 'cpu-clock'
# Event count (approx.): 12340000000
#
# Overhead Command Shared Object Symbol
# ......... .............. ................. .......................
# 45.23% profile_target profile_target [.] sortData
# 28.10% profile_target profile_target [.] fillRandom
# 12.30% profile_target profile_target [.] processDataCacheUnfriendly
# 10.00% profile_target profile_target [.] processDataCacheFriendly
핫스팟 해석: sortData가 45%로 가장 큰 병목 → 정렬 알고리즘 개선 또는 정렬 제거 검토.
gprof로 완전한 프로파일링 예제
# 1. -pg 옵션으로 컴파일
g++ -std=c++17 -O2 -pg -g -o profile_target_gprof profile_target.cpp
# 2. 실행 (gmon.out 자동 생성)
./profile_target_gprof
# 3. 플랫 프로파일 (함수별 시간 비율)
gprof -p profile_target_gprof gmon.out
# 4. 호출 그래프만 보기
gprof -q profile_target_gprof gmon.out
# 5. 전체 리포트를 파일로 저장
gprof profile_target_gprof gmon.out > gprof_report.txt
gprof 출력 해석:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
45.23 2.15 2.15 1 2150.00 2150.00 sortData
28.10 3.48 1.33 1 1330.00 1330.00 fillRandom
12.30 4.07 0.59 1 590.00 590.00 processDataCacheUnfriendly
10.00 4.54 0.48 1 480.00 480.00 processDataCacheFriendly
핵심 컬럼: % time(전체 대비 비율), self seconds(해당 함수 자체 시간), calls(호출 횟수).
핫스팟 식별 워크플로우
flowchart TD
A[프로그램 실행] --> B[perf record -g]
B --> C[perf report]
C --> D{상위 3개 함수 확인}
D --> E[넓은 막대 = 병목]
E --> F[Timer로 세부 측정]
F --> G[최적화 대상 구간 확정]
G --> H[최적화 후 재측정]
병목 지점 분석
핫스팟 찾기
// 프로파일 결과:
// 80% - loadFile() ← 병목!
// 15% - processData()
// 5% - saveResult()
// loadFile() 최적화에 집중
void loadFile(const std::string& path) {
Timer timer("loadFile");
// 세부 측정
{
Timer t("open");
file.open(path);
}
{
Timer t("read");
// 읽기... ← 여기가 느림!
}
{
Timer t("parse");
// 파싱...
}
}
호출 횟수 확인
class CallCounter {
static std::map<std::string, int> counts;
std::string name;
public:
CallCounter(const char* n) : name(n) {
counts[name]++;
}
static void report() {
for (const auto& [name, count] : counts) {
std::cout << name << ": " << count << " calls\n";
}
}
};
std::map<std::string, int> CallCounter::counts;
void expensiveFunction() {
CallCounter counter("expensiveFunction");
// ...
}
int main() {
// ...
CallCounter::report();
}
파레토 원칙 (80/20 법칙)
전체 실행 시간의 80% ← 상위 20% 함수만 최적화
나머지 20% ← 나머지 80% 함수는 건드리지 않아도 됨
실전: 프로파일 결과에서 상위 20% 함수만 골라 최적화하면, 전체 성능의 대부분을 개선할 수 있습니다.
실전 최적화 프로세스
1단계: 측정
// 현재 성능 측정
void benchmark() {
Timer timer("Total");
for (int i = 0; i < 1000; ++i) {
processData();
}
}
int main() {
benchmark(); // 기준선 확립
}
2단계: 프로파일링
# CPU 프로파일
perf record -g ./myapp
# 결과 확인
perf report
3단계: 병목 최적화
// 병목: 벡터 재할당
void processData() {
std::vector<int> data; // ❌ 재할당 반복
for (int i = 0; i < 100000; ++i) {
data.push_back(i);
}
}
// 최적화
void processDataOptimized() {
std::vector<int> data;
data.reserve(100000); // ✅ 미리 할당
for (int i = 0; i < 100000; ++i) {
data.push_back(i);
}
}
4단계: 재측정
int main() {
{
Timer t("Before");
processData(); // 출력 예: Before: 50ms
}
{
Timer t("After");
processDataOptimized(); // 출력 예: After: 10ms (수치는 예시, 같은 입력으로 비교)
}
}
5단계: 반복
측정 → 프로파일 → 최적화 → 재측정 → 반복
실전 예시: JSON 파서 최적화
시나리오: 대용량 JSON 파일 파싱이 10초 걸립니다. 어디가 느린지 모릅니다.
1단계: 프로파일링:
perf record -g ./json_parser large_file.json
perf report
결과: parseJson() 80%, loadFile() 15%, validate() 5%
2단계: 파싱 세부 분석:
void parseJson(const std::string& content) {
{
Timer t("tokenize");
auto tokens = tokenize(content); // 60% 여기!
}
{
Timer t("build_tree");
buildTree(tokens); // 20%
}
{
Timer t("validate");
validate();
}
}
3단계: tokenize 최적화 (정규식 → 수동 파싱):
// ❌ 느림: 정규식 매칭
std::regex number_regex(R"(\d+)");
for (auto token : tokens) {
std::regex_match(token, number_regex);
}
// ✅ 빠름: 수동 파싱
std::vector<int> parseNumbers(const std::string& s) {
std::vector<int> result;
size_t i = 0;
while (i < s.size()) {
if (std::isdigit(s[i])) {
int num = 0;
while (i < s.size() && std::isdigit(s[i])) {
num = num * 10 + (s[i++] - '0');
}
result.push_back(num);
} else {
++i;
}
}
return result;
}
4단계: 재측정 → 같은 입력 파일로 다시 perf record를 돌려 tokenize 비중이 줄었는지, 전체 실행 시간이 얼마나 줄었는지 확인합니다. 정규식은 호출마다 매칭 엔진을 돌리고 std::regex 구현이 특히 느린 편이라, 단순한 숫자 추출처럼 규칙이 고정된 부분을 수동 파싱으로 바꾸면 큰 차이가 나는 경우가 많습니다.
벤치마크 시 주의사항
1. 워밍업: 첫 실행은 캐시 미스·JIT 등으로 느릴 수 있습니다.
// 워밍업 후 측정
for (int i = 0; i < 3; ++i) {
processData(); // 캐시 워밍업
}
{
Timer t("benchmark");
for (int i = 0; i < 1000; ++i) {
processData();
}
}
2. 여러 번 실행해 평균: 한 번의 결과는 노이즈에 영향받습니다.
std::vector<long long> times;
for (int run = 0; run < 10; ++run) {
auto start = std::chrono::high_resolution_clock::now();
processData();
auto end = std::chrono::high_resolution_clock::now();
times.push_back(std::chrono::duration_cast<std::chrono::microseconds>(end - start).count());
}
// 중앙값 또는 평균 사용
3. 컴파일 최적화: -O2 또는 -O3로 릴리즈 빌드 후 측정해야 실제 성능을 반영합니다.
# 프로덕션과 동일한 최적화로 벤치마크
g++ -O2 -DNDEBUG main.cpp -o myapp
4. 측정 코드 자체의 비용: std::chrono::steady_clock::now() 한 번은 보통 수십 나노초가 걸립니다. 측정하려는 함수가 수십 나노초 수준으로 짧은데 반복문 안에서 호출마다 now()를 두 번씩 부르면, 결과의 상당 부분이 시계 읽는 비용이 됩니다. 짧은 연산은 반복문 전체를 한 구간으로 재고 반복 횟수로 나누어 평균을 구해야 합니다. 이때 최적화 빌드에서는 결과를 쓰지 않는 계산이 통째로 제거될 수 있으니, 결과를 누적해 출력하거나 Google Benchmark의 benchmark::DoNotOptimize로 컴파일러가 지우지 못하게 해야 합니다.
// ❌ 호출마다 측정: now() 비용이 결과를 왜곡
for (int i = 0; i < 1'000'000; ++i) {
auto t0 = std::chrono::steady_clock::now();
sink += doWork(i);
total += std::chrono::steady_clock::now() - t0;
}
// ✅ 구간 전체를 측정한 뒤 평균
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < 1'000'000; ++i) sink += doWork(i);
auto avg = (std::chrono::steady_clock::now() - t0) / 1'000'000;
메모리 프로파일링 (Valgrind Memcheck)
CPU뿐 아니라 메모리 누수나 잘못된 접근도 프로파일링할 수 있습니다.
# 메모리 누수 검사
valgrind --leak-check=full ./myapp
# 출력 예시:
# ==12345== 40 bytes in 1 blocks are definitely lost in loss record 1 of 2
# ==12345== at 0x4C2AB80: malloc
# ==12345== by 0x400567: loadData() (main.cpp:42)
AddressSanitizer (ASan)는 컴파일 시 삽입되는 방식으로, Valgrind보다 빠릅니다.
# -fsanitize=address로 컴파일
g++ -g -O1 -fsanitize=address -fno-omit-frame-pointer main.cpp -o myapp
./myapp # 힙 버퍼 오버플로우 등 즉시 감지
자주 발생하는 문제
문제 1: perf 권한 오류
증상:
Permission denied (raw syscall access denied)
원인: perf는 커널 레벨 이벤트를 사용하므로 root 권한 또는 perf_event_paranoid 설정이 필요합니다.
해결법:
# 임시로 paranoid 값 낮추기 (재부팅 시 초기화)
sudo sysctl -w kernel.perf_event_paranoid=-1
# 권한 설정으로 재실행
sudo perf record ./myapp
문제 2: gprof에서 gmon.out이 생성되지 않음
원인: -pg 플래그 없이 컴파일했거나, 정상 종료가 아닌 경우(예: Ctrl+C, abort()).
해결법:
# -pg 플래그 확인
g++ -pg -O2 main.cpp -o myapp
# 정상 종료되도록 main에서 return 0
exit(0); // 또는 정상 종료 경로
문제 3: Valgrind가 너무 느림
원인: 시뮬레이션 방식이라 10~50배 느립니다.
해결법:
# 입력 데이터를 작게 줄여서 테스트
./myapp < small_input.txt
# 또는 perf로 대체 (CPU 프로파일링만 필요할 때)
perf record ./myapp
문제 4: 프로파일 결과에 함수명이 없음 (???)
원인: 디버그 심볼이 없거나 릴리즈 빌드에서 strip된 경우.
해결법:
# -g 옵션으로 디버그 심볼 포함
g++ -g -O2 main.cpp -o myapp
# strip하지 않기
# g++ -g -O2 main.cpp -o myapp # -s 옵션 제거
문제 5: 컴파일 최적화로 인라인된 함수
증상: 프로파일에서 main만 보이고 세부 함수가 보이지 않음.
원인: -O2/-O3에서 인라인 최적화로 인해 함수가 합쳐짐.
해결법:
# 프로파일링용 빌드는 -O1 또는 -O0
g++ -g -O1 -pg main.cpp -o myapp
# 또는 __attribute__((noinline))로 특정 함수 보호
void __attribute__((noinline)) criticalFunction() { ... }
체크리스트
프로파일링 전 체크리스트
-
-g플래그로 디버그 심볼 포함 - 최적화 수준 결정 (
-O1권장, 정확한 호출 관계 필요 시-O0) - perf 사용 시
perf_event_paranoid설정 확인 - gprof 사용 시
-pg플래그 추가 - Valgrind 사용 시 입력 데이터 크기 축소
프로파일링 후 체크리스트
- 상위 20% 함수 식별
- 병목 구간 세부 측정 (Timer 등)
- 최적화 전 기준선 기록
- 최적화 후 재측정
- 회귀 테스트 (기능 정상 동작 확인)
최적화 원칙 체크리스트
- 추측하지 말고 측정
- 병목 80%부터 최적화
- 최적화 전후 비교
- 작은 개선보다 큰 병목
- 프로파일러 활용
같이 보면 좋은 글
- C++ 캐시 친화 코드: AoS vs SoA, 데이터 지향 설계, False Sharing
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- PGO·LTO로 C++ 컴파일러 최적화하기: -O2 vs -O3 차이와 측정법
- C++ 프로그램이 느릴 때 병목 찾기: perf·Flame Graph·VS Profiler와 흔한 병목 7가지
- C++ 프로파일러 비교
- C++ STL 알고리즘 기초 | sort·find·count·transform·accumulate 가이드
자주 묻는 질문 (FAQ)
Q. 프로파일 결과에 함수명 대신 ???가 나오거나 main만 보이는 이유는 무엇인가요?
A. 함수명이 ???로 나오는 것은 대개 디버그 심볼이 없거나 릴리스 바이너리를 strip했기 때문이므로, -O2를 유지하더라도 -g를 함께 넣어 빌드합니다. main만 보이고 세부 함수가 사라지는 경우는 -O2/-O3 인라인 최적화로 함수가 합쳐진 것입니다. 이때는 프로파일링용으로 -O1 빌드를 따로 두거나 확인이 필요한 함수에 noinline 속성을 붙여 구분합니다.
Q. perf와 gprof 중 어떤 것을 써야 하나요?
A. Linux에서는 perf를 우선 추천합니다. 샘플링 방식이라 오버헤드가 적고, 프로덕션 환경에서도 사용 가능합니다. gprof는 -pg 플래그로 코드를 수정해야 하므로, 빌드 설정이 다를 수 있습니다. 정확한 호출 그래프가 필요하면 Valgrind Callgrind를 고려하세요.
Q. 프로파일링이 프로그램을 느리게 만들지 않나요?
A. perf는 샘플링 방식이라 오버헤드가 5% 내외로 낮습니다. gprof는 인스트루멘테이션이라 1020% 정도 느려질 수 있지만, 병목 찾기에는 충분합니다. Valgrind는 시뮬레이션이라 1050배 느려지므로, 짧은 실행에만 사용하세요.
프로파일링 도구 상세 비교와 실무 워크플로우
프로파일링 도구 종합 비교
| 도구 | 방식 | 오버헤드 | 환경 | 핵심 기능 |
|---|---|---|---|---|
| perf (Linux) | 하드웨어 카운터 기반 샘플링 | 낮음 (샘플링 주파수에 비례) | 서버·백엔드 | Flame Graph, 캐시 프로파일링, 샘플링 |
| Tracy Profiler | 코드에 심은 존(zone) 계측 | 낮음 (계측한 구간 수에 비례) | 게임·실시간 | 프레임 단위, 락/메모리 추적, 원격 프로파일 |
| Intel VTune | 샘플링 + 하드웨어 이벤트 | 분석 종류에 따라 다름 | Intel CPU | 파이프라인, 메모리 대역폭, 하드웨어 이벤트 |
| Valgrind (Callgrind/Cachegrind/Memcheck) | 명령어 단위 시뮬레이션 | 매우 높음 (수십 배 느려짐) | 단기 테스트 | 메모리 누수, 캐시 미스, 정밀 호출 그래프 |
| gprof | 컴파일 시 계측(-pg) + 샘플링 | 중간 | 레거시 | 플랫 프로필, 호출 그래프 |
오버헤드가 방식에 따라 갈리는 이유는 단순합니다. 샘플링은 일정 간격으로 “지금 어디를 실행 중인가”만 기록하므로 프로그램 자체는 거의 그대로 돌고, 계측은 함수나 구간마다 기록 코드가 끼어들어 호출이 잦을수록 비용이 쌓이며, 시뮬레이션은 모든 명령을 가상 CPU에서 해석하므로 가장 느립니다.
추천 조합:
- Linux 서버:
perf(기본) +Tracy(핫스팟 심층 분석) - 게임/실시간:
Tracy(메인) +perf(캐시 분석) - Intel 전용 최적화:
VTune+perf - 메모리 디버깅:
Valgrind Memcheck(단기 실행만)
Tracy Profiler 실무 패턴
기본 통합
#include <tracy/Tracy.hpp>
void process_frame() {
ZoneScoped; // 자동 프레임 마커
{
ZoneScopedN("Physics");
update_physics();
}
{
ZoneScopedN("Rendering");
render_scene();
}
}
int main() {
while (running) {
FrameMark; // 프레임 경계 표시
process_frame();
}
}
메모리 추적
void* operator new(size_t size) {
void* ptr = malloc(size);
TracyAlloc(ptr, size);
return ptr;
}
void operator delete(void* ptr) noexcept {
TracyFree(ptr);
free(ptr);
}
Lock Profiling
TracyLockable(std::mutex, my_mutex);
void critical_section() {
std::lock_guard lock(my_mutex); // Tracy가 자동 추적
// ...
}
Tracy 장점:
- 정확한 구간 시간: 샘플링은 통계적으로 “어디서 시간을 많이 쓰는가”를 보여 주지만, Tracy는 표시한 구간의 시작·끝을 고해상도 타이머로 직접 기록해 한 프레임 안의 순서와 길이를 그대로 보여 줌
- 낮은 계측 비용: 존마다 타임스탬프를 기록하는 정도라 계측 구간이 적당하면 상시로 켜 두고 쓸 수 있음. 단, 아주 짧은 함수에 존을 달면 계측 비용이 측정값을 왜곡함
- 원격 프로파일링 (모바일/임베디드 지원)
- 프레임 단위 분석: 평균이 아니라 특정 프레임이 왜 튀었는지(스파이크)를 찾을 때 유용
Flame Graph 읽는 법
Flame Graph 구조
넓이 = CPU 시간 비율 (X축은 시간 흐름이 아님!)
높이 = 호출 스택 깊이
[ main() ] ← 가장 넓음 (전체 시간의 100%)
[ func_a() ][ func_b() ] ← 중간 레벨
[ hot_loop ][ io_wait ] ← 리프 노드 (실제 작업)
핵심 읽기 규칙
| 패턴 | 의미 | 조치 |
|---|---|---|
| 넓은 평탄 박스 | ”뜨거운” 함수 (CPU 대부분 소비) | 우선 최적화 대상 |
| 높은 탑 | 깊은 재귀/호출 체인 | 인라인 또는 플래핑 고려 |
| 여러 작은 봉우리 | 분산된 작업 | 개별 최적화보다 아키텍처 재고 |
| IO/sleep 박스 | 대기 시간 | 비동기 전환 또는 배치 처리 |
실무 분석 예시
# 1) perf로 샘플 수집 (60초)
sudo perf record -F 99 -a -g -- sleep 60
# 2) Flame Graph 생성
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
# 3) 브라우저로 확인
firefox flame.svg
핫스팟 찾기:
- 대개 소수의 함수가 전체 시간의 대부분을 차지하므로 가장 넓은 박스부터 봄
- 넓은 박스 클릭 → 코드 라인 확인
- 한 번에 한 가지만 수정 후 재측정
Cache Profiling 실무 패턴
언제 Cache Profiling을 하나?
증상:
- O(n) 알고리즘인데 n 커지면 비선형적으로 느려짐
std::vector가std::list보다 느림 (cache miss 의심)- 코드는 같은데 데이터 크기에 따라 성능 급감
perf로 Cache Miss 측정
# L1/L2/L3 캐시 미스율 측정
perf stat -e cache-references,cache-misses,L1-dcache-load-misses ./my_app
# 결과 예시:
# 1,234,567,890 cache-references
# 45,678,901 cache-misses # 3.7% miss rate
# 123,456,789 L1-dcache-load-misses
해석: 미스율에 절대적인 “양호” 기준은 없습니다. 같은 워크로드에서 변경 전후를 비교하는 것이 핵심이며, 특히 마지막 단계 캐시(LLC) 미스가 많으면 매번 메인 메모리까지 가야 하므로 비용이 큽니다. cache-misses 이벤트가 CPU마다 가리키는 캐시 계층이 다를 수 있으니 perf list로 사용 가능한 이벤트를 확인하세요.
Valgrind Cachegrind 상세 분석
# 캐시 시뮬레이션 (매우 느림, 단기 실행만)
valgrind --tool=cachegrind --branch-sim=yes ./my_app
# 결과 시각화
cg_annotate cachegrind.out.<PID>
# 소스 라인별 캐시 미스 표시
cg_annotate --auto=yes cachegrind.out.<PID> my_source.cpp
최적화 예시:
// ❌ Cache-unfriendly (AoS - Array of Structures)
struct Particle {
float x, y, z; // 위치 (12 bytes)
float vx, vy, vz; // 속도 (12 bytes)
float mass; // 질량 (4 bytes)
float padding[1]; // 정렬 (4 bytes)
}; // 총 32 bytes
std::vector<Particle> particles(1'000'000);
// X 좌표만 업데이트 (다른 필드도 캐시에 로드됨)
for (auto& p : particles) {
p.x += p.vx * dt; // 32바이트 중 x, vx 8바이트만 사용
}
// ✅ Cache-friendly (SoA - Structure of Arrays)
struct ParticleSystem {
std::vector<float> x, y, z; // 위치
std::vector<float> vx, vy, vz; // 속도
std::vector<float> mass; // 질량
};
ParticleSystem particles;
particles.x.resize(1'000'000);
particles.vx.resize(1'000'000);
// X만 순차 접근 (완벽한 캐시 지역성)
for (size_t i = 0; i < particles.x.size(); ++i) {
particles.x[i] += particles.vx[i] * dt; // 읽어 온 캐시 라인을 전부 사용
}
왜 빨라지는가: AoS에서는 x와 vx만 필요해도 파티클 하나의 32바이트가 통째로 캐시에 올라오므로, 64바이트 캐시 라인에서 실제로 쓰는 데이터는 16바이트뿐입니다. 메모리에서 가져온 대역폭의 4분의 3을 버리는 셈입니다. SoA에서는 x와 vx 배열을 각각 연속으로 읽어 캐시 라인을 전부 쓰고, 하드웨어 프리페처도 순차 패턴을 잘 따라가며, 컴파일러가 루프를 SIMD로 벡터화하기도 쉽습니다. 개선 폭은 CPU와 데이터 크기(캐시에 다 들어가는지)에 따라 다르므로 perf stat으로 전후를 직접 비교하세요.
실무 프로파일링 워크플로우
flowchart TD
A[성능 문제 인지] --> B[Release 빌드 + 심볼<br/>-O3 -g -fno-omit-frame-pointer]
B --> C[perf record 전체 샘플링<br/>60초 이상]
C --> D[Flame Graph 생성<br/>핫스팟 확인]
D --> E{상위 20% 함수?}
E -->|Yes| F[Tracy 마커 추가<br/>나노초 단위 분석]
E -->|No| G[아키텍처 재설계 필요]
F --> H[한 가지만 수정]
H --> I[재측정 perf stat]
I --> J{개선?}
J -->|Yes| K[다음 병목으로]
J -->|No| L[변경 롤백]
L --> H
K --> E
핵심 원칙:
- 추측하지 말고 측정하라 (Measure, Don’t Guess)
- Release 빌드로 측정 (
-O3 -g, Debug 빌드는 인라인이 안 되어 병목 위치 자체가 달라짐) - 한 번에 한 가지만 바꾸고 재측정 (A/B 테스트)
- 가장 넓은 박스부터 (소수의 함수가 대부분의 시간을 차지하는 경우가 많음)
- 프로파일러 오버헤드 고려 (계측형 도구는 짧은 함수의 시간을 부풀림)
실무 체크리스트:
-
-O3 -g -fno-omit-frame-pointer플래그 확인 - 60초 이상 샘플링 (짧으면 부정확)
- 실제 워크로드로 측정 (합성 벤치마크 금지)
- 변경 전후 3회 이상 측정 (노이즈 제거)
- Flame Graph에서 넓은 박스 시각적 확인
- 한 번에 한 최적화만 적용
- 개선 없으면 즉시 롤백