본문으로 건너뛰기 C++ Profiling 완벽 마스터 | perf·Tracy·VTune 도구 비교·병목 분석

C++ Profiling 완벽 마스터 | perf·Tracy·VTune 도구 비교·병목 분석

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와 함수별 시간 확인

도구 비교표

도구플랫폼방식오버헤드프로덕션
perfLinux샘플링낮음 (~5%)
gprofLinux/Mac인스트루멘테이션중간 (~10%)
ValgrindLinux/Mac시뮬레이션매우 높음 (10~50x)
VS ProfilerWindows샘플링낮음

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++ 프로파일링, perf gprof, Valgrind, 병목 지점, 성능 측정, 최적화 등으로 검색하시면 이 글이 도움이 됩니다.

정리

도구플랫폼용도
perfLinuxCPU 프로파일링
gprofLinux/Mac함수별 시간
ValgrindLinux/Mac메모리, 캐시
VS ProfilerWindowsCPU, 메모리
std::chrono모든 플랫폼수동 측정

핵심 원칙:

  1. 추측 금지, 측정 필수
  2. 병목 80%부터 최적화
  3. 최적화 전후 비교
  4. 작은 개선보다 큰 병목
  5. 프로파일러 활용

초보자를 위한 체크리스트

  • 최적화 전·후 같은 입력으로 시간을 재었는가?
  • 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 Profiler0.1-1%나노초게임·실시간프레임 단위, 락/메모리 추적, 원격 프로파일9.8/10
Intel VTune5-15%마이크로초Intel CPU파이프라인, 메모리 대역폭, 하드웨어 이벤트8.5/10
Valgrind (Callgrind/Cachegrind/Memcheck)10-50×인스트럭션단기 테스트메모리 누수, 캐시 미스, 정밀 호출 그래프7.0/10
gprof5-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

핫스팟 찾기:

  1. 상위 20% 함수가 전체 시간의 80% 차지 (파레토 법칙)
  2. 넓은 박스 클릭 → 코드 라인 확인
  3. 한 번에 한 가지만 수정 후 재측정

5. Cache Profiling 실무 패턴 (2026)

언제 Cache Profiling을 하나?

증상:

  • O(n) 알고리즘인데 n 커지면 비선형적으로 느려짐
  • std::vectorstd::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 파티클):

패턴실행 시간캐시 미스율메모리 대역폭
AoS450ms12%3.2 GB/s
SoA45ms0.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위):

  1. 추측하지 말고 측정하라 (Measure, Don’t Guess)
  2. Release 빌드로 측정 (-O3 -g, Debug는 10-50× 느림)
  3. 한 번에 한 가지만 바꾸고 재측정 (A/B 테스트)
  4. 상위 20% 함수부터 (파레토 법칙)
  5. 프로파일러 오버헤드 고려 (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


관련 글