C++ 프로파일링: perf·Tracy·VTune 도구 비교와 병목 찾는 절차
들어가며: “어디가 느린지 모르겠어요”
느린 프로그램을 보면 “이 함수가 느릴 것 같다”는 감이 먼저 듭니다. 하지만 감으로 고른 함수를 며칠 다듬었는데 실제 시간은 전혀 다른 곳, 예를 들어 파일 I/O나 생각지 못한 메모리 할당에서 쓰이고 있었다면 그 시간은 그대로 낭비됩니다. 프로파일링(실행 중 어느 함수가 시간과 메모리를 얼마나 쓰는지 측정하는 것)은 이 추측을 데이터로 바꾸는 작업입니다.
이 글은 std::chrono로 구간 시간을 재는 것에서 시작해 perf, gprof, Valgrind, Tracy, VTune으로 핫스팟을 찾고, Flame Graph로 결과를 읽고, 수정 전후를 같은 조건으로 다시 재는 흐름을 다룹니다. 측정은 Release 빌드에 가까운 설정으로 하고, 한 번에 한 가지만 바꿔 비교해야 원인을 추적할 수 있습니다.
프로파일링의 전체 흐름
flowchart TD
A[프로그램 느림] --> B[측정 없이 추측]
B --> C{병목 맞추기}
C -->|실패| D[시간 낭비]
A --> E[프로파일링 실행]
E --> F[병목 지점 확인]
F --> G[가장 큰 핫스팟 최적화]
G --> H[재측정]
H --> I{목표 달성?}
I -->|아니오| E
I -->|예| J[성능 개선 완료]
프로파일링이란
프로파일링 종류
CPU 프로파일링은 어떤 함수가 CPU 시간을 많이 쓰는지, 각 함수가 몇 번 호출되는지를 봅니다. 메모리 프로파일링은 메모리 사용량, 할당·해제 횟수, 누수를 보고, 캐시 프로파일링은 캐시 미스 횟수와 메모리 접근 패턴을 봅니다. 한 가지 주의할 점은 perf 같은 CPU 샘플링 프로파일러는 기본적으로 CPU에서 실행 중인 코드만 기록한다는 것입니다. 디스크나 네트워크를 기다리며 잠들어 있는 시간은 CPU 프로파일에 나타나지 않으므로, I/O 대기가 의심되면 벽시계 시간 측정(std::chrono)이나 off-CPU 분석을 함께 써야 합니다.
프로파일링 종류별 비교
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로 구간 시간을 잴 수 있습니다. steady_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::steady_clock::now();
slowFunction();
auto end = std::chrono::steady_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 값은 다름).
구간 측정에는 steady_clock을 씁니다. high_resolution_clock은 이름과 달리 구현에 따라 system_clock의 별칭일 수 있는데, system_clock은 NTP 동기화나 사용자의 시각 변경으로 거꾸로 갈 수 있어 경과 시간 측정에 맞지 않습니다. steady_clock은 단조 증가가 보장됩니다. duration_cast는 duration을 원하는 단위로 바꾸고(밀리초로 바꾸면 소수점 아래는 버려짐), count()는 그 단위의 정수 값을 돌려줍니다.
측정 헬퍼 클래스
생성자에서 시각을 기록하고 소멸자에서 경과 시간을 출력하는 RAII 스타일 타이머입니다. { Timer t("slowFunction"); slowFunction(); }처럼 스코프를 두면, 블록을 빠져나갈 때 자동으로 소멸자가 호출되어 해당 구간 시간이 출력됩니다. 예외가 나거나 early return이 있어도 소멸자는 호출되므로, 수동으로 end 시간을 찍는 것보다 누락이 적습니다.
class Timer {
std::chrono::steady_clock::time_point start;
const char* name;
public:
Timer(const char* n) : name(n) {
start = std::chrono::steady_clock::now();
}
~Timer() {
auto end = std::chrono::steady_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::steady_clock::time_point start;
public:
void startTimer() {
start = std::chrono::steady_clock::now();
}
void record(const std::string& name) {
auto end = std::chrono::steady_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| D[gprof]
B -->|Linux| E[Valgrind]
B -->|Windows| F[VS Profiler]
C --> G[CPU 샘플링]
D --> H[인스트루멘테이션]
E --> I[메모리/캐시]
F --> G
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입니다. 최신 x86 코어는 한 사이클에 여러 명령을 처리할 수 있어 연산 위주 코드는 IPC가 2~4까지 나오기도 하고, 1보다 한참 낮으면 메모리 대기나 분기 예측 실패로 CPU가 놀고 있을 가능성이 큽니다. 다만 기준값은 CPU와 워크로드마다 다르므로, 같은 머신에서 수정 전후를 비교하는 용도로 쓰는 것이 안전합니다.
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으로 프로파일하면 실제로 배포되는 코드와 병목 위치 자체가 달라질 수 있으므로, 최적화 수준은 유지하고 확인이 필요한 함수에만 -fno-inline이나 noinline 속성을 쓰는 편이 낫습니다. gprof는 사실상 Linux(glibc) 전용이며, macOS의 Clang에서는 -pg가 지원되지 않습니다.
Valgrind Callgrind
가상 CPU에서 명령어를 하나씩 해석해 실행하는 방식입니다. 정확한 호출 횟수와 캐시 시뮬레이션 정보를 얻을 수 있지만 수십 배 이상 느려지므로 짧은 실행에만 적합합니다.
# 프로파일링 실행
valgrind --tool=callgrind ./myapp
# 결과 분석
callgrind_annotate callgrind.out.12345
# GUI 도구
kcachegrind callgrind.out.12345
Callgrind 옵션:
# 캐시 시뮬레이션 포함
valgrind --tool=callgrind --cache-sim=yes ./myapp
# 특정 함수 안에서만 수집 (C++ 이름은 맹글링되므로 와일드카드 사용)
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 | 샘플링 | 낮음 (샘플링 주파수에 비례) | 가능 |
| gprof | Linux | 계측(-pg) + 샘플링 | 중간 (호출 횟수에 비례) | 별도 빌드 필요 |
| Valgrind | Linux (macOS 지원은 제한적) | 시뮬레이션 | 매우 높음 (수십 배) | 불가 |
| 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 |
perf 심화: 화염 그래프와 캐시 프로파일링
perf record 주요 옵션
# 초당 99회 샘플링 (perf record의 기본은 4000Hz)
perf record -F 99 -g ./myapp
# 더 촘촘하게 샘플링 (짧은 실행에서 통계가 안정되지만 오버헤드와 파일 크기 증가)
perf record -F 999 -g ./myapp
# DWARF 기반 스택 언와인딩, 샘플마다 스택 8192바이트 복사 (기본값 8192)
perf record -F 99 --call-graph dwarf,8192 ./myapp
# 특정 이벤트 기준 샘플링: 캐시 미스
perf record -e cache-misses -c 10000 -g ./myapp
# 특정 CPU 코어(0, 1)에서 일어나는 일만 기록
perf record -C 0,1 -F 99 -g
-F 99처럼 100이 아닌 값을 쓰는 이유는 100Hz로 도는 타이머나 주기 작업과 박자가 맞아 떨어져 같은 지점만 반복해서 찍히는 것을 피하기 위해서입니다. -g는 기본적으로 프레임 포인터로 호출 스택을 따라가는데, 대부분의 배포판 바이너리와 -O2 빌드는 프레임 포인터를 생략하므로 스택이 끊겨 보일 수 있습니다. 이때는 -fno-omit-frame-pointer로 다시 빌드하거나 --call-graph dwarf를 씁니다. DWARF 방식은 샘플마다 스택 일부를 복사하므로 perf.data가 훨씬 커지고 후처리도 느려집니다. 숫자 8192는 스택 깊이가 아니라 샘플마다 복사하는 스택 크기(바이트)입니다. 일반 이름 cache-misses는 대개 마지막 단계 캐시(LLC) 미스에 매핑되며, 정확히 무엇을 세는지는 CPU마다 다르므로 perf list로 확인하는 것이 좋습니다.
화염 그래프 생성
# 1. perf 데이터 수집
perf record -F 99 -g -- ./myapp
# 2. FlameGraph 스크립트 받기 (한 번만)
git clone --depth 1 https://github.com/brendangregg/FlameGraph
export PATH="$PATH:$(pwd)/FlameGraph"
# 3. 스택을 접어서 SVG 화염 그래프 생성
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg
# 4. 브라우저에서 열기
xdg-open flamegraph.svg # Linux (macOS는 open)
perf stat: 하드웨어 이벤트 분석
# 기본 통계
perf stat ./myapp
# 캐시 이벤트 상세
perf stat -e cycles,instructions,cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses ./myapp
# 5번 반복 실행해 평균과 편차 보기
perf stat -r 5 ./myapp
출력은 다음과 같은 형태입니다(숫자는 예시).
Performance counter stats for './myapp' (5 runs):
1,234.56 msec task-clock # CPU 시간
42 context-switches
0 cpu-migrations
128 page-faults
3,456,789,012 cycles
2,345,678,901 instructions # 0.68 insn per cycle
123,456,789 cache-references
12,345,678 cache-misses # 10.00 % of all cache refs
IPC(instructions per cycle)는 instructions / cycles이고, 위 예시에서는 약 0.68입니다. 최신 x86 코어는 사이클당 여러 명령을 처리할 수 있으므로 IPC가 1보다 한참 낮다면 메모리 대기나 분기 예측 실패로 파이프라인이 자주 멈춘다는 신호일 수 있습니다. 다만 적절한 IPC 수준은 워크로드와 CPU에 따라 다르므로, 절대값보다 최적화 전후의 변화를 보는 데 쓰는 편이 안전합니다. 캐시 미스율(cache-misses / cache-references)도 마찬가지로, 같은 프로그램의 두 버전을 비교할 때 가장 유용합니다.
perf annotate: 명령어 단위 분석
# 특정 함수의 어셈블리와 소스 줄별 샘플 비율
perf annotate --stdio -s processData
# 또는 perf report의 TUI에서 함수를 고르고 'a' 키로 annotate
perf report
gprof: 호출 횟수와 호출 그래프
gprof 개요
gprof는 GNU binutils에 포함된 프로파일러입니다. -pg로 컴파일하면 컴파일러가 모든 함수 진입부에 계측 코드(mcount)를 넣어 호출 관계와 횟수를 정확히 세고, 실행 시간은 타이머 샘플링으로 추정합니다. 프로그램이 정상 종료할 때 gmon.out을 남기고, gprof가 이를 읽어 함수별 시간 비율(플랫 프로파일)과 호출 그래프를 보여 줍니다. 추가 설치가 필요 없다는 장점이 있지만, 인라인된 함수와 공유 라이브러리 안의 시간은 제대로 나눠 보여 주지 못합니다.
gprof 사용법
# 1. -pg 옵션으로 컴파일·링크 (최적화와 함께 사용 가능)
g++ -std=c++17 -O2 -pg -g -o myapp profile_target.cpp
# 2. 실행 (정상 종료 시 gmon.out 생성)
./myapp
# 3. 플랫 프로파일 + 호출 그래프
gprof myapp gmon.out
# 4. 호출 그래프만
gprof -q myapp gmon.out
# 5. 플랫 프로파일만
gprof -p myapp gmon.out
# 6. 파일로 저장
gprof myapp 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
47.36 2.15 2.15 1 2150.00 2150.00 sortData
29.30 3.48 1.33 1 1330.00 1330.00 fillRandom
13.00 4.07 0.59 1 590.00 590.00 processDataCacheUnfriendly
10.35 4.54 0.47 1 470.00 470.00 processDataCacheFriendly
% time은 전체 샘플 중 그 함수 자체에서 찍힌 비율, self seconds는 그 함수 자체의 시간, calls는 계측으로 센 호출 횟수, total ms/call은 하위 호출까지 포함한 호출당 시간입니다. “Each sample counts as 0.01 seconds”는 샘플 간격이 10ms라는 뜻이라, 실행 시간이 짧은 프로그램에서는 샘플 수가 너무 적어 비율을 믿기 어렵습니다.
호출 그래프는 각 함수가 누구에게서 불렸고 누구를 불렀는지를 보여 줍니다.
index % time self children called name
[1] 100.0 0.00 4.54 main [1]
2.15 0.00 1/1 sortData [2]
1.33 0.00 1/1 fillRandom [3]
[2] 47.4 2.15 0.00 1 sortData [2]
gprof 한계와 대안
| 한계 | 설명 | 대안 |
|---|---|---|
| 인라인 | -O2에서 인라인된 함수의 시간은 호출한 함수에 합산됨 | perf (DWARF 정보로 인라인 프레임까지 표시 가능) |
| 공유 라이브러리 | -pg로 빌드되지 않은 .so 안의 시간은 제대로 나뉘지 않음 | perf, VTune |
| 멀티스레드 | 기본적으로 메인 스레드 위주로 측정되어 다른 스레드 시간이 빠질 수 있음 | perf, VTune Threading |
| 샘플 해상도 | 10ms 간격 고정, 짧은 실행은 샘플 부족 | perf -F로 주파수 조절 |
Valgrind: Callgrind·Cachegrind·Memcheck
Valgrind 개요
Valgrind는 동적 바이너리 계측 프레임워크입니다. 프로그램의 기계어를 실행 시점에 중간 표현으로 바꿔 계측 코드를 끼워 넣은 뒤 실행하므로, 모든 메모리 접근과 명령어를 관찰할 수 있는 대신 실행이 수십 배 느려집니다. 공식 문서도 Memcheck에서 수십 배 수준의 감속을 안내하므로, 짧은 실행이나 단위 테스트에 씁니다.
Valgrind 도구 비교
| 도구 | 용도 | 출력 |
|---|---|---|
| Callgrind | 함수별 실행 명령어 수, 호출 그래프 | callgrind.out.*, KCachegrind로 시각화 |
| Cachegrind | 명령어 수, 선택적으로 캐시·분기 시뮬레이션 | cachegrind.out.*, cg_annotate |
| Memcheck | 메모리 누수, 잘못된 접근, 초기화 안 된 값 사용 | 에러와 누수 리포트 |
Callgrind: 명령어 수 기반 프로파일링
# 1. Callgrind로 실행
valgrind --tool=callgrind ./myapp
# 2. 출력 파일: callgrind.out.<pid>
# 3. GUI로 보기
kcachegrind callgrind.out.12345 # 또는 qcachegrind
# 4. 명령줄 요약 (함수별 Ir = 실행된 명령어 수)
callgrind_annotate callgrind.out.12345
# 5. 하위 호출까지 포함한 비용(inclusive)으로 보기
callgrind_annotate --inclusive=yes callgrind.out.12345 | head -80
Callgrind의 기본 지표는 시간이 아니라 실행된 명령어 수(Ir)입니다. 실행할 때마다 거의 같은 값이 나와서 작은 개선을 비교하기 좋지만, 캐시 미스나 분기 예측 실패로 인한 지연은 반영하지 않으므로 “명령어는 적은데 실제로는 느린” 코드를 놓칠 수 있습니다.
Cachegrind: 캐시 미스 시뮬레이션
# Valgrind 3.21부터 캐시 시뮬레이션은 기본으로 꺼져 있어 명시해야 함
valgrind --tool=cachegrind --cache-sim=yes ./myapp
# 출력 예시:
# ==12345== I1 cache: 32768 B, 64 B, 8-way associative
# ==12345== D1 cache: 32768 B, 64 B, 8-way associative
# ==12345== LL cache: 8388608 B, 64 B, 16-way associative
# ==12345==
# ==12345== D1 misses: 12,345,678 ( 10.2% of all refs)
# ==12345== LL misses: 1,234,567 ( 1.0% of all refs)
cg_annotate cachegrind.out.12345
I1·D1은 1단계 명령어·데이터 캐시, LL은 마지막 단계 캐시(Last Level)를 뜻합니다. Cachegrind는 실제 하드웨어가 아니라 단순화한 캐시 모델을 시뮬레이션하므로, 하드웨어 프리페처의 효과 등은 반영되지 않습니다. 실제 하드웨어의 캐시 미스는 perf stat으로 재고, Cachegrind는 어느 줄에서 미스가 생기는지 짚어 보는 용도로 쓰는 것이 좋습니다.
Memcheck: 메모리 누수·오류 탐지
# 메모리 누수 및 잘못된 접근 검사
valgrind --tool=memcheck --leak-check=full ./myapp
# 로그 파일로 저장
valgrind --tool=memcheck --leak-check=full --log-file=memcheck.log ./myapp
누수 리포트는 definitely lost(가리키는 포인터가 하나도 남지 않은 확실한 누수), indirectly lost(누수된 블록을 통해서만 닿을 수 있는 블록), possibly lost(블록 내부를 가리키는 포인터만 남은 경우), still reachable(종료 시점까지 포인터가 살아 있는 블록)으로 나뉘고, 각각 할당한 위치의 호출 스택이 함께 나옵니다. 먼저 definitely lost부터 고치면 indirectly lost는 대개 함께 사라집니다.
화염 그래프 읽는 법
화염 그래프 구조
flowchart TB
subgraph Flame["화염 그래프 개념도 (너비 = 샘플 비율)"]
direction TB
M[main - 100%]
M --> F[fillRandom]
M --> S[sortData]
M --> P[processData]
S --> S1[std::sort 내부 함수들]
end
화염 그래프에서 막대의 너비는 그 함수가 스택에 있던 샘플의 비율입니다. 아래에서 위로 갈수록 호출자에서 피호출자로 이어지므로, main 위에 sortData, 그 위에 std::sort 내부 함수가 쌓입니다. 가로축은 시간 순서가 아니라 함수 이름 알파벳순이라는 점을 자주 오해합니다. 왼쪽에 있다고 먼저 실행된 것이 아닙니다.
먼저 볼 곳은 위쪽이 평평하게 넓은 막대입니다. 그 함수 자체(self)에서 시간을 많이 쓴다는 뜻이기 때문입니다. 반대로 아래쪽만 넓고 위로 잘게 쪼개지는 막대는 여러 하위 호출에 시간이 분산된 경우라, 하위 함수들을 차례로 살펴봐야 합니다.
화염 그래프에서 자주 보는 패턴
| 패턴 | 의미 | 대응 |
|---|---|---|
memcpy가 넓음 | 메모리 복사 병목 | 불필요한 복사 제거, 이동 의미론, 버퍼 재사용 |
malloc/free가 넓음 | 할당/해제 비용 | 객체 풀, arena, reserve |
std::sort가 넓음 | 정렬 비용 | 정렬 횟수 줄이기, partial_sort·nth_element |
pthread_mutex_lock·futex가 넓음 | 락 경합 | 임계 구역 축소, 데이터 분할 |
예제: 정렬·난수·캐시 접근 프로파일링
프로파일링 대상 프로그램
// profile_target.cpp - perf, gprof로 분석할 대상
#include <vector>
#include <algorithm>
#include <random>
#include <chrono>
#include <iostream>
// 의도적으로 비효율적인 함수: 같은 원소를 모두 처리하지만 16칸씩 건너뛰며 접근
// (int 16개 = 64바이트 = 캐시 라인 하나이므로 매 접근이 다른 캐시 라인)
void processDataCacheUnfriendly(std::vector<int>& data) {
const size_t stride = 16;
for (size_t start = 0; start < stride; ++start) {
for (size_t i = start; 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
위 출력은 형태를 보여 주기 위한 예이며, 실제 비율은 CPU와 컴파일러에 따라 다릅니다. 또 -O2에서는 sortData가 인라인되어 std::__introsort_loop 같은 표준 라이브러리 내부 함수 이름으로 잡히는 경우가 많으므로, perf report -g로 호출자를 따라 올라가 어느 코드에서 온 시간인지 확인합니다. 정렬이 가장 크다면 정렬 자체가 필요한지, 부분 정렬(std::partial_sort, std::nth_element)로 충분한지부터 검토합니다. 두 processData 함수는 처리하는 원소 수가 같은데도 접근 순서만으로 시간 차이가 나며, 데이터(40MB)가 캐시보다 크기 때문에 생기는 차이입니다.
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[최적화 후 재측정]
병목 지점 분석
핫스팟 찾기
// 프로파일에서 loadFile()이 가장 크게 나왔다면, 그 안을 구간별로 나눠 잰다
void loadFile(const std::string& path) {
Timer timer("loadFile");
std::ifstream file;
{
Timer t("open");
file.open(path);
}
{
Timer t("read");
// 읽기...
}
{
Timer t("parse");
// 파싱...
}
}
호출 횟수 확인
#include <iostream>
#include <string>
#include <map>
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();
}
핫스팟에서 자주 보이는 원인과 그 모습
상위 함수를 찾았다면 자체(self) 시간과 전체(total/children) 시간을 나눠 봅니다. 자체 비율이 높으면 그 함수 본문을 고쳐야 하고, 전체 비율은 높은데 자체 비율이 낮으면 하위 호출을 줄이거나 호출 구조를 바꿔야 한다는 신호입니다. 그다음 프로파일에 나타나는 모양으로 흔한 원인을 좁힐 수 있습니다.
malloc·free·memcpy나_M_realloc_insert가 상위에 보임: 값으로 넘긴 큰 컨테이너의 복사, 루프 안에서 매번 만드는 임시vector/string,reserve없는 대량push_back입니다.const&전달, 루프 밖 버퍼를clear()로 재사용(용량 유지),reserve로 대부분 사라집니다.std::basic_regex생성자나 파싱 함수가 루프 안에 잡힘: 정규식을 반복마다 다시 만드는 코드입니다. 패턴 생성은 검색보다 비싼 경우가 많으므로 루프 밖이나static const로 옮깁니다.write시스템 콜이나 스트림 flush가 많음:std::endl이 줄마다 flush하는 경우로,'\n'으로 바꾸면 버퍼에 모았다가 한꺼번에 내보냅니다. 출력이 파일이나 파이프로 갈 때 차이가 가장 큽니다.- IPC가 낮고
perf stat의cache-misses·branch-misses가 수정 전보다 높음: 열 우선 행렬 순회나 쓰지 않는 필드까지 끌고 다니는 AoS 레이아웃, 데이터에 따라 무작위로 갈리는 분기입니다. 이 비율은 CPU마다 세는 대상이 달라 절대값보다 같은 머신에서의 전후 비교로 판단합니다. - 스레드를 늘렸는데
futex나 mutex 관련 함수가 커짐: 반복마다 같은 락을 잡는 락 경합입니다. 스레드 로컬 버퍼에 모았다가 마지막에 한 번만 잠그는 식으로 경합 횟수를 줄입니다.
원인을 고친 뒤에는 반드시 같은 입력과 같은 빌드 설정으로 다시 재서, 개선이 측정 잡음보다 큰지 확인합니다.
실전 최적화 프로세스
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();
}
{
Timer t("After");
processDataOptimized(); // 같은 입력·같은 빌드 설정으로 비교
}
}
5단계: 반복
측정 → 프로파일 → 최적화 → 재측정 → 반복
벤치마크 시 주의사항
- 워밍업: 첫 실행은 캐시가 비어 있고, 메모리 페이지를 처음 건드리는 페이지 폴트와 공유 라이브러리 심볼의 지연 바인딩 비용이 더해져 느릴 수 있습니다.
// 워밍업 후 측정
for (int i = 0; i < 3; ++i) {
processData(); // 캐시 워밍업
}
{
Timer t("benchmark");
for (int i = 0; i < 1000; ++i) {
processData();
}
}
- 여러 번 실행해 중앙값: 한 번의 결과는 다른 프로세스, CPU 주파수 변화 같은 노이즈에 영향받습니다. 이상값에 덜 흔들리는 중앙값이 평균보다 안정적입니다.
std::vector<long long> times;
for (int run = 0; run < 10; ++run) {
auto start = std::chrono::steady_clock::now();
processData();
auto end = std::chrono::steady_clock::now();
times.push_back(std::chrono::duration_cast<std::chrono::microseconds>(end - start).count());
}
// 중앙값 또는 평균 사용
- 컴파일 최적화:
-O2또는-O3로 릴리즈 빌드 후 측정해야 실제 성능을 반영합니다.
# 프로덕션과 동일한 최적화로 벤치마크
g++ -O2 -DNDEBUG main.cpp -o myapp
- 측정 코드 자체의 비용:
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 설정이 필요합니다.
해결법:
# 현재 값 확인 (배포판마다 기본값이 다름)
cat /proc/sys/kernel/perf_event_paranoid
# 임시로 낮추기 (재부팅 시 초기화). 1이면 자기 프로세스의 커널 이벤트까지 측정 가능
sudo sysctl -w kernel.perf_event_paranoid=1
# 또는 root로 실행
sudo perf record ./myapp
문제 2: gprof에서 gmon.out이 생성되지 않음
원인: -pg 플래그 없이 컴파일했거나(링크 단계에도 -pg가 필요), 프로그램이 정상 종료하지 않은 경우입니다. gmon.out은 main이 반환하거나 exit()가 호출될 때 기록되므로, Ctrl+C로 죽이거나 abort(), _exit()로 끝나면 생성되지 않습니다. 서버처럼 끝나지 않는 프로그램은 시그널을 받으면 exit()를 호출하는 종료 경로를 만들어 두어야 합니다.
# 컴파일과 링크 모두 -pg
g++ -pg -O2 main.cpp -o myapp
./myapp # 정상 종료 후 현재 디렉터리에 gmon.out 생성
문제 3: Valgrind가 너무 느림
원인: 모든 명령을 가상 CPU에서 해석하므로 수십 배 느립니다.
해결법:
# 입력 데이터를 작게 줄여서 테스트
./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에서 인라인 최적화로 인해 함수가 합쳐짐.
해결법: 최적화를 끄면 병목 위치 자체가 바뀔 수 있으므로, 최적화 수준은 유지한 채 인라인된 프레임을 보거나 특정 함수만 인라인을 막습니다.
# DWARF 정보로 인라인된 호출까지 펼쳐 보기
g++ -g -O2 main.cpp -o myapp
perf record --call-graph dwarf ./myapp
perf report --inline
# 특정 함수만 인라인 금지
# void __attribute__((noinline)) criticalFunction() { ... }
측정을 믿을 수 있게 만드는 원칙
최적화는 추측이 아니라 측정에서 시작해야 합니다. “이 함수가 느린 것 같다”는 느낌으로 코드를 고치기보다, perf로 상위 함수를 확인하고 실제로 시간을 많이 쓰는 곳부터 손대는 편이 효과가 큽니다. 최적화 전에는 perf stat -r 5 ./myapp 같은 방식으로 기준선을 기록해 두고, 변경 후 같은 조건으로 다시 재서 개선을 수치로 확인합니다. 여러 곳을 한꺼번에 고치면 어떤 변경이 효과가 있었는지, 회귀가 생겼을 때 무엇이 원인인지 알 수 없으므로 한 번에 하나씩 바꾸고 측정합니다.
샘플 수가 충분한지도 확인해야 합니다. 샘플 수는 실행 시간과 주파수의 곱이라, 99Hz로 10초 실행하면 약 990개입니다. 전체의 50%를 차지하는 함수라면 500개 가까이 찍혀 충분하지만, 몇 %짜리 함수는 수십 개에 불과해 순위가 실행마다 바뀔 수 있습니다. 짧은 실행이라면 주파수를 높이거나 워크로드를 반복해 실행 시간을 늘리십시오. gprof는 샘플 간격이 10ms로 고정이라 1초 미만 실행에서는 의미 있는 비율을 얻기 어렵습니다.
측정 환경의 잡음도 줄여야 합니다. CPU 주파수가 부하에 따라 바뀌면 같은 코드도 실행 시간이 흔들리므로, sudo cpupower frequency-set -g performance로 주파수 조절 정책(governor)을 고정하고, 필요하면 터보 부스트도 끄고 측정합니다(터보 설정은 governor와 별개입니다). 네트워크나 디스크 I/O가 섞인 워크로드는 여러 번 반복해 편차를 함께 보십시오.
프로파일링용 빌드는 배포 빌드와 같은 최적화 수준(-O2나 -O3)에 -g만 더하는 것이 원칙입니다. -O0 빌드를 프로파일링하면 배포 환경에서는 사라질 병목을 쫓게 됩니다. -g는 실행 코드에 영향을 주지 않으므로, 배포 바이너리에서 심볼을 떼어 별도 파일로 보관해 두면 프로덕션에서 수집한 프로파일도 해석할 수 있습니다.
참고 자료
같이 보면 좋은 글
- C++ 캐시 친화 코드: AoS vs SoA, 데이터 지향 설계, False Sharing
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- PGO·LTO로 C++ 컴파일러 최적화하기: -O2 vs -O3 차이와 측정법
- C++ STL 알고리즘 기초 | sort·find·count·transform·accumulate 가이드
프로파일링 도구 상세 비교와 실무 워크플로우
프로파일링 도구 종합 비교
| 도구 | 방식 | 오버헤드 | 환경 | 핵심 기능 |
|---|---|---|---|---|
| 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; // 이 함수 전체를 함수 이름의 존(zone)으로 기록
{
ZoneScopedN("Physics");
update_physics();
}
{
ZoneScopedN("Rendering");
render_scene();
}
}
int main() {
while (running) {
FrameMark; // 프레임 경계 표시
process_frame();
}
}
메모리 추적
void* operator new(std::size_t size) {
void* ptr = std::malloc(size);
if (!ptr) throw std::bad_alloc();
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 ] ← 리프 노드 (실제 작업)
핵심 읽기 규칙
| 패턴 | 의미 | 조치 |
|---|---|---|
| 넓고 위가 평평한 박스(plateau) | 그 함수 자체가 CPU를 씀(self 시간) | 함수 본문 최적화 |
| 넓지만 위에 자식 박스가 쌓인 박스 | 하위 호출들이 시간을 씀 | 위쪽 자식 중 넓은 것을 따라 올라감 |
| 높은 탑 | 깊은 호출 체인·재귀 | 높이 자체는 비용이 아님, 너비로 판단 |
| 비슷한 너비의 박스가 넓게 흩어짐 | 특정 핫스팟 없이 시간이 분산 | 알고리즘·데이터 구조 수준에서 재검토 |
perf로 만든 CPU Flame Graph에는 CPU에서 실행 중인 시간만 담기므로, 잠들어 기다린 I/O나 락 대기 시간은 보이지 않습니다. 대기 시간이 의심되면 off-CPU Flame Graph를 따로 만들어야 합니다.
실무 분석 예시
# 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이 커지면 선형보다 훨씬 느려지거나, 코드는 같은데 데이터 크기가 어느 선(대개 캐시 크기)을 넘는 순간 성능이 급감하거나, 같은 연산량인데 접근 순서만 바꿨을 때 시간이 크게 달라진다면 캐시 미스를 의심할 수 있습니다.
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/>실제 워크로드로]
C --> D[Flame Graph 생성<br/>핫스팟 확인]
D --> E{소수 함수에 집중?}
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