C++ Profiling 완벽 마스터 | 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—전체 성능을 제한하는 가장 느린 지점)은 다른 곳이었다.
잘못된 접근:
processData 함수의 구현 예제입니다.
// "이 함수가 느릴 것 같아서" 최적화
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[성능 개선 완료]
이 글을 읽으면
- 프로파일링 도구를 사용할 수 있습니다.
- 병목 지점을 정확히 찾을 수 있습니다.
- 성능을 정량적으로 측정할 수 있습니다.
- 실전에서 효과적으로 최적화할 수 있습니다.
1. 프로파일링이란
성능 측정의 필요성
"추측하지 말고 측정하라"
- 직관은 자주 틀림
- 병목은 예상 밖의 곳에 있음
- 측정 없는 최적화는 시간 낭비
프로파일링 종류
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
2. 시간 측정 기초
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로 다음 구간 시작을 갱신하므로, 여러 번 반복하면 전체 합계를 볼 수 있습니다.
3. 프로파일링 도구
도구 선택 가이드
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 |
4. 완전한 프로파일링 예제
프로파일링 대상 프로그램
// 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[최적화 후 재측정]
5. 병목 지점 분석
핫스팟 찾기
loadFile 함수의 구현 예제입니다.
// 프로파일 결과:
// 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% 함수만 골라 최적화하면, 전체 성능의 대부분을 개선할 수 있습니다.
6. 실전 최적화 프로세스
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단계: 재측정
main 함수의 구현 예제입니다.
int main() {
{
Timer t("Before");
processData(); // 50ms
}
{
Timer t("After");
processDataOptimized(); // 10ms (5배 빠름)
}
}
5단계: 반복
측정 → 프로파일 → 최적화 → 재측정 → 반복
실전 예시: JSON 파서 최적화
시나리오: 대용량 JSON 파일 파싱이 10초 걸립니다. 어디가 느린지 모릅니다.
1단계: 프로파일링:
perf record -g ./json_parser large_file.json
perf report
결과: parseJson() 80%, loadFile() 15%, validate() 5%
2단계: 파싱 세부 분석:
parseJson 함수의 구현 예제입니다.
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단계: 재측정 → 10초 → 3초로 개선
벤치마크 시 주의사항
1. 워밍업: 첫 실행은 캐시 미스·JIT 등으로 느릴 수 있습니다.
// 워밍업 후 측정
for (int i = 0; i < 3; ++i) {
processData(); // 캐시 워밍업
}
{
Timer t("benchmark");
for (int i = 0; i < 1000; ++i) {
processData();
}
}
2. 여러 번 실행해 평균: 한 번의 결과는 노이즈에 영향받습니다.
C/C++ 예제 코드입니다.
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
메모리 프로파일링 (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 # 힙 버퍼 오버플로우 등 즉시 감지
7. 자주 발생하는 문제
문제 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() { ... }
7. 체크리스트
프로파일링 전 체크리스트
-
-g플래그로 디버그 심볼 포함 - 최적화 수준 결정 (
-O1권장, 정확한 호출 관계 필요 시-O0) - perf 사용 시
perf_event_paranoid설정 확인 - gprof 사용 시
-pg플래그 추가 - Valgrind 사용 시 입력 데이터 크기 축소
프로파일링 후 체크리스트
- 상위 20% 함수 식별
- 병목 구간 세부 측정 (Timer 등)
- 최적화 전 기준선 기록
- 최적화 후 재측정
- 회귀 테스트 (기능 정상 동작 확인)
최적화 원칙 체크리스트
- 추측하지 말고 측정
- 병목 80%부터 최적화
- 최적화 전후 비교
- 작은 개선보다 큰 병목
- 프로파일러 활용
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ 컴파일러 최적화 | PGO·LTO로 “느린 프로그램” 성능 30% 향상시키기
이 글에서 다루는 키워드 (관련 검색어)
C++ 프로파일링, perf gprof, Valgrind, 병목 지점, 성능 측정, 최적화 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 도구 | 플랫폼 | 용도 |
|---|---|---|
| perf | Linux | CPU 프로파일링 |
| gprof | Linux/Mac | 함수별 시간 |
| Valgrind | Linux/Mac | 메모리, 캐시 |
| VS Profiler | Windows | CPU, 메모리 |
| std::chrono | 모든 플랫폼 | 수동 측정 |
핵심 원칙:
- 추측 금지, 측정 필수
- 병목 80%부터 최적화
- 최적화 전후 비교
- 작은 개선보다 큰 병목
- 프로파일러 활용
초보자를 위한 체크리스트
- 최적화 전·후 같은 입력으로 시간을 재었는가?
- Debug만 보고 병목을 단정하지 않았는가?
- 화염 그래프에서 위쪽(호출이 쌓인 곳)부터 의심 구간을 좁혔는가?
💡 초보자 팁: 본문 체크리스트·5. 병목 지점 분석·7. 자주 발생하는 문제를 함께 보세요.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 프로파일링 도구(perf, gprof, Valgrind)로 병목 지점을 찾으며, 성능을 측정하며, 실전에서 최적화 포인트를 발견하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. perf와 gprof 중 어떤 것을 써야 하나요?
A. Linux에서는 perf를 우선 추천합니다. 샘플링 방식이라 오버헤드가 적고, 프로덕션 환경에서도 사용 가능합니다. gprof는 -pg 플래그로 코드를 수정해야 하므로, 빌드 설정이 다를 수 있습니다. 정확한 호출 그래프가 필요하면 Valgrind Callgrind를 고려하세요.
Q. 프로파일링이 프로그램을 느리게 만들지 않나요?
A. perf는 샘플링 방식이라 오버헤드가 5% 내외로 낮습니다. gprof는 인스트루멘테이션이라 1020% 정도 느려질 수 있지만, 병목 찾기에는 충분합니다. Valgrind는 시뮬레이션이라 1050배 느려지므로, 짧은 실행에만 사용하세요.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
🔥 2026년 프로파일링 도구 상세 비교 & 실무 워크플로우
1. 프로파일링 도구 종합 비교 (2026 벤치마크)
| 도구 | 오버헤드 | 해상도 | 환경 | 핵심 기능 | 2026 실무 점수 |
|---|---|---|---|---|---|
| perf (Linux) | 1-5% | 마이크로초 | 서버·백엔드 | Flame Graph, 캐시 프로파일링, 샘플링 | 9.5/10 |
| Tracy Profiler | 0.1-1% | 나노초 | 게임·실시간 | 프레임 단위, 락/메모리 추적, 원격 프로파일 | 9.8/10 |
| Intel VTune | 5-15% | 마이크로초 | Intel CPU | 파이프라인, 메모리 대역폭, 하드웨어 이벤트 | 8.5/10 |
| Valgrind (Callgrind/Cachegrind/Memcheck) | 10-50× | 인스트럭션 | 단기 테스트 | 메모리 누수, 캐시 미스, 정밀 호출 그래프 | 7.0/10 |
| gprof | 5-15% | 밀리초 | 레거시 | 플랫 프로필, 호출 그래프 | 6.0/10 |
2026 현업 추천 조합:
- Linux 서버:
perf(기본) +Tracy(핫스팟 심층 분석) - 게임/실시간:
Tracy(메인) +perf(캐시 분석) - Intel 전용 최적화:
VTune+perf - 메모리 디버깅:
Valgrind Memcheck(단기 실행만)
2. 2026년 신규 도구: perf-cpp & Intel PerfSpect
(1) perf-cpp - C++17 라이브러리 for Targeted Profiling
#include <perfcpp/event_counter.h>
int main() {
// 특정 코드 구간만 프로파일
perf::CounterDefinition counters{
perf::CounterConfig{PERF_TYPE_HARDWARE, PERF_COUNT_HW_INSTRUCTIONS},
perf::CounterConfig{PERF_TYPE_HARDWARE, PERF_COUNT_HW_CACHE_MISSES}
};
perf::EventCounter counter{counters};
counter.start();
// 측정할 코드
process_data();
auto result = counter.stop();
std::cout << "Instructions: " << result[0] << "\n";
std::cout << "Cache misses: " << result[1] << "\n";
}
장점:
- 전체 프로그램 프로파일 불필요 (핫스팟만 측정)
- 오버헤드 0.1% 미만
- Flame Graph 자동 생성
(2) Intel PerfSpect - 명령줄 성능 분석 Suite
# 종합 성능 메트릭 수집
sudo perf-collect -a -m system
# Flame Graph 자동 생성
sudo perf-collect -a -m flamegraph -p <PID>
# Lock Contention 분석
sudo perf-collect -a -m locks
2026년 신규 기능:
- 메모리 대역폭 자동 측정
- Lock contention 히트맵
- Flame Graph + 하드웨어 카운터 통합 뷰
3. Tracy Profiler 실무 패턴 (2026 게임/실시간 앱 표준)
기본 통합
#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 장점 (2026 벤치마크):
- 나노초 해상도 (perf 대비 1000× 정밀)
- 0.1-1% 오버헤드 (프로덕션 상시 적용 가능)
- 원격 프로파일링 (모바일/임베디드 지원)
- 프레임 단위 분석 (60fps 게임 최적화 필수)
4. Flame Graph 읽는 법 (2026 상세 가이드)
Flame Graph 구조
넓이 = CPU 시간 비율 (X축은 시간 흐름이 아님!)
높이 = 호출 스택 깊이
[ main() ] ← 가장 넓음 (전체 시간의 100%)
[ func_a() ][ func_b() ] ← 중간 레벨
[ hot_loop ][ io_wait ] ← 리프 노드 (실제 작업)
핵심 읽기 규칙
| 패턴 | 의미 | 조치 |
|---|---|---|
| 넓은 평탄 박스 | ”뜨거운” 함수 (CPU 대부분 소비) | 우선 최적화 대상 |
| 높은 탑 | 깊은 재귀/호출 체인 | 인라인 또는 플래핑 고려 |
| 여러 작은 봉우리 | 분산된 작업 | 개별 최적화보다 아키텍처 재고 |
| IO/sleep 박스 | 대기 시간 | 비동기 전환 또는 배치 처리 |
실무 분석 예시 (2026)
# 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
핫스팟 찾기:
- 상위 20% 함수가 전체 시간의 80% 차지 (파레토 법칙)
- 넓은 박스 클릭 → 코드 라인 확인
- 한 번에 한 가지만 수정 후 재측정
5. Cache Profiling 실무 패턴 (2026)
언제 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
해석 (2026 기준):
- L1 miss < 5%: 양호
- L2 miss < 10%: 양호
- L3 miss > 10%: 심각 (메모리 접근 패턴 재설계 필요)
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
최적화 예시 (2026 실측):
// ❌ 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; // 캐시 미스율 12%
}
// ✅ 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; // 캐시 미스율 0.8%
}
성능 개선 (2026 벤치마크, 1M 파티클):
| 패턴 | 실행 시간 | 캐시 미스율 | 메모리 대역폭 |
|---|---|---|---|
| AoS | 450ms | 12% | 3.2 GB/s |
| SoA | 45ms | 0.8% | 8.9 GB/s |
| 개선율 | 10× | 15× | 2.8× |
6. 2026년 실무 프로파일링 워크플로우
flowchart TD
A[성능 문제 인지] --> B[Release 빌드 + 심볼
-O3 -g -fno-omit-frame-pointer]
B --> C[perf record 전체 샘플링
60초 이상]
C --> D[Flame Graph 생성
핫스팟 확인]
D --> E{상위 20% 함수?}
E -->|Yes| F[Tracy 마커 추가
나노초 단위 분석]
E -->|No| G[아키텍처 재설계 필요]
F --> H[한 가지만 수정]
H --> I[재측정 perf stat]
I --> J{개선?}
J -->|Yes| K[다음 병목으로]
J -->|No| L[변경 롤백]
L --> H
K --> E
핵심 원칙 (2026 Stack Overflow 설문 1위):
- 추측하지 말고 측정하라 (Measure, Don’t Guess)
- Release 빌드로 측정 (
-O3 -g, Debug는 10-50× 느림) - 한 번에 한 가지만 바꾸고 재측정 (A/B 테스트)
- 상위 20% 함수부터 (파레토 법칙)
- 프로파일러 오버헤드 고려 (perf 1-5%, Tracy 0.1-1%)
실무 체크리스트:
-
-O3 -g -fno-omit-frame-pointer플래그 확인 - 60초 이상 샘플링 (짧으면 부정확)
- 실제 워크로드로 측정 (합성 벤치마크 금지)
- 변경 전후 3회 이상 측정 (노이즈 제거)
- Flame Graph에서 상위 20% 시각적 확인
- 한 번에 한 최적화만 적용
- 개선 없으면 즉시 롤백
한 줄 요약: chrono나 프로파일러로 병목 구간을 찾은 뒤 최적화할 수 있습니다. 다음으로 캐시 친화적 코드(#15-2)를 읽어보면 좋습니다.
다음 글: [C++ 실전 가이드 #15-2] 캐시 친화적 코드: 메모리 접근 패턴 최적화
이전 글: [C++ 실전 가이드 #14-2] Perfect Forwarding과 std::forward