C++ 프로파일링 | "어디가 느린지 모르겠어요" perf·gprof로 병목 찾기
이 글의 핵심
C++ 프로파일링에 대한 실전 가이드입니다.
들어가며: “어디가 느린지 모르겠어요”
실무에서 겪는 문제 시나리오
실제 겪는 상황:
- "이 함수가 느릴 것 같아서" 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[성능 개선 완료]
이 글을 읽으면:
- 프로파일링 도구를 사용할 수 있습니다.
- 병목 지점을 정확히 찾을 수 있습니다.
- 성능을 정량적으로 측정할 수 있습니다.
- 실전에서 효과적으로 최적화할 수 있습니다.
목차
- 프로파일링이란
- 시간 측정 기초
- 프로파일링 도구
- 완전한 프로파일링 예제
- 병목 지점 분석
- 실전 최적화 프로세스
- 자주 발생하는 문제
- 프로파일링 모범 사례
- 프로덕션 프로파일링 패턴
- 체크리스트
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. 병목 지점 분석
핫스팟 찾기
// 프로파일 결과:
// 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단계: 재측정
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단계: 파싱 세부 분석:
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. 여러 번 실행해 평균: 한 번의 결과는 노이즈에 영향받습니다.
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%부터 최적화
- 최적화 전후 비교
- 작은 개선보다 큰 병목
- 프로파일러 활용
자주 묻는 질문 (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와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: chrono나 프로파일러로 병목 구간을 찾은 뒤 최적화할 수 있습니다. 다음으로 캐시 친화적 코드(#15-2)를 읽어보면 좋습니다.
다음 글: [C++ 실전 가이드 #15-2] 캐시 친화적 코드: 메모리 접근 패턴 최적화
이전 글: [C++ 실전 가이드 #14-2] Perfect Forwarding과 std::forward
관련 글
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
- C++ 프로그램 느림 원인 찾기 | 프로파일링으로 병목 5분 만에 찾는 법
- C++ 고급 프로파일링 완벽 가이드 | perf·gprof
- C++ STL 알고리즘 기초 | sort·find·count·transform·accumulate 가이드