C++ 프로그램이 느릴 때 병목 찾기: perf·Flame Graph·VS Profiler와 흔한 병목 7가지

이 글의 핵심

알고리즘은 O(n)인데 느리거나 멀티스레드로 바꿨더니 오히려 느려지는 경우, 감으로 코드를 고치면 엉뚱한 곳을 최적화하게 됩니다. 측정하고 분석한 뒤 고치는 순서를 기준으로 성능 저하의 7가지 원인과 그 밖의 6가지 패턴, 플랫폼별 프로파일러 선택 기준을 정리했습니다. JSON 파싱과 이미지 처리 병목을 줄인 사례도 함께 봅니다.

들어가며: “코드는 맞는데 왜 이렇게 느리죠?”

C++로 작성한 프로그램이 예상보다 느릴 때, 원인을 찾기 어렵습니다. “알고리즘은 O(n)인데 왜 느릴까?”, “멀티스레드로 바꿨는데 오히려 느려졌어요”, “최적화 플래그를 켰는데도 개선이 없어요” 같은 상황에서 프로파일링(Profiling—프로그램 실행 중 함수별 시간·메모리 사용량을 측정하는 기법)이 필요합니다.

아래에서는 먼저 흔한 원인을 코드로 보고, 이어서 프로파일러로 그 원인을 찾아내는 방법과 결과를 읽는 법을 다룹니다.


성능 저하의 7가지 주요 원인

원인 1: 잘못된 알고리즘 선택

// ❌ O(n²) - 100만 건이면 1조 번 비교
std::vector<int> data(1000000);
for (size_t i = 0; i < data.size(); ++i) {
    for (size_t j = 0; j < data.size(); ++j) {
        if (data[i] == data[j] && i != j) {
            // 중복 찾기
        }
    }
}
// ✅ O(n) - 100만 번
std::unordered_set<int> seen;
for (int x : data) {
    if (seen.count(x)) {
        // 중복 발견
    }
    seen.insert(x);
}

영향: 복잡도 차이는 입력이 커질수록 벌어집니다. n=100만일 때 O(n²)은 O(n log n)보다 연산 횟수가 수만 배 많아, 작은 테스트 데이터에서는 안 보이던 문제가 운영 데이터에서 갑자기 드러납니다.

원인 2: 불필요한 복사

// ❌ 호출마다 원소 전체를 복사
void process(std::vector<int> data) {  // 값 복사
    // ...
}
std::vector<int> big_data(1000000);
process(big_data);  // 4MB 복사
// ✅ 참조 사용
void process(const std::vector<int>& data) {  // 참조 (8바이트)
    // ...
}

값 전달은 호출마다 새 버퍼를 할당하고 원소 전체를 O(n)으로 복사하므로, 벡터가 클수록 그리고 호출이 잦을수록 비용이 커집니다. const&는 포인터 하나만 넘깁니다. 프로파일러에서는 복사 생성자나 memcpy, malloc이 호출 함수 아래에 크게 잡히는 모습으로 나타납니다.

원인 3: 메모리 할당 과다

// ❌ 루프마다 할당/해제
for (int i = 0; i < 1000000; ++i) {
    std::vector<int> temp(100);  // 매번 힙 할당
    // ...
}
// ✅ 루프 밖에서 한 번만 할당
std::vector<int> temp;
temp.reserve(100);
for (int i = 0; i < 1000000; ++i) {
    temp.clear();
    // ...
}

malloc/free는 빠른 경로에서도 수십 ns 수준이고, 스레드 간 경합이나 큰 블록 할당에서는 훨씬 비싸집니다. 루프마다 새 벡터를 만들면 할당과 해제가 반복마다 일어나지만, 바깥에서 한 번 reserve한 벡터를 clear()로 재사용하면 clear()는 용량을 유지하므로 첫 반복 이후 할당이 사라집니다. 원래 코드처럼 원소 100개가 0으로 채워진 상태가 필요하다면 clear() 대신 assign(100, 0)을 씁니다.

원인 4: 캐시 미스

// ❌ 캐시 비효율 (열 우선 순회)
int matrix[1000][1000];
for (int col = 0; col < 1000; ++col) {
    for (int row = 0; row < 1000; ++row) {
        sum += matrix[row][col];  // 캐시 미스 다발
    }
}
// ✅ 캐시 효율 (행 우선 순회)
for (int row = 0; row < 1000; ++row) {
    for (int col = 0; col < 1000; ++col) {
        sum += matrix[row][col];  // 연속 접근
    }
}

영향: L1 캐시 적중은 몇 사이클(약 1ns)이지만, 메인 메모리까지 가면 대략 수십~100ns가 걸립니다. 그래서 캐시 미스가 잦은 루프는 계산량이 같아도 몇 배씩 느려질 수 있습니다.

원인 5: 분기 예측 실패

// ❌ 랜덤 분기 (예측 불가)
std::vector<int> data = generateRandomData();
for (int x : data) {
    if (x > 50) {  // 랜덤하게 true/false
        // ...
    }
}
// ✅ 정렬 후 분기 (예측 가능)
std::sort(data.begin(), data.end());
for (int x : data) {
    if (x > 50) {  // 처음에는 false, 나중에는 true
        // ...
    }
}

분기 예측이 빗나가면 파이프라인을 비우고 다시 채우느라 현대 x86 CPU에서 대략 10~20 사이클을 잃습니다. 다만 정렬 자체가 O(n log n)이므로 이 방법은 같은 데이터를 여러 번 순회할 때만 이득입니다. 단순한 조건이라면 컴파일러가 분기 대신 조건부 이동(cmov)이나 SIMD 마스크로 바꿔 예측 실패가 아예 없어지기도 하므로, perf stat -e branch-misses로 실제 문제인지 먼저 확인합니다.

원인 6: 가상 함수 오버헤드

// ❌ 가상 함수 호출 (간접 호출)
class Base {
public:
    virtual void process() = 0;
};
std::vector<std::unique_ptr<Base>> objects;
for (auto& obj : objects) {
    obj->process();  // 가상 함수 호출 (vtable 조회)
}
// ✅ 타입별로 분리 (직접 호출)
std::vector<TypeA> type_a_objects;
std::vector<TypeB> type_b_objects;
for (auto& obj : type_a_objects) {
    obj.process();  // 직접 호출 (인라인 가능)
}

가상 호출 자체는 간접 점프 한 번이라 그렇게 비싸지 않습니다. 더 큰 비용은 컴파일러가 호출 대상을 몰라 인라인할 수 없고, 그 결과 루프 벡터화 같은 후속 최적화도 막힌다는 점입니다(final이나 LTO로 대상이 하나로 확정되면 컴파일러가 탈가상화하기도 합니다). 타입별로 나누면 호출 대상이 컴파일 시점에 정해지고, 같은 타입이 연속으로 처리돼 간접 분기 예측도 쉬워집니다.

원인 7: 문자열 연결 비효율

// ❌ 용량이 찰 때마다 재할당 + 반복마다 임시 문자열 생성
std::string result;
for (int i = 0; i < 10000; ++i) {
    result += std::to_string(i) + ",";
}
// ✅ reserve로 재할당 방지
std::string result;
result.reserve(100000);  // 미리 공간 확보
for (int i = 0; i < 10000; ++i) {
    result += std::to_string(i) + ",";
}
// 대안: ostringstream
std::ostringstream oss;
for (int i = 0; i < 10000; ++i) {
    oss << i << ",";
}
std::string result = oss.str();

std::string은 용량을 배수로 늘리므로 +=가 매번 재할당하지는 않지만, 커질 때마다 전체를 복사합니다. 최종 크기를 대략 알면 reserve로 재할당을 없앨 수 있습니다. std::to_string(i) + ","는 반복마다 임시 문자열을 두 개 만드는데, 이 비용이 재할당보다 클 수도 있어 result += std::to_string(i); result += ',';로 나누는 것만으로도 줄어듭니다. ostringstream은 로케일·스트림 상태 처리 비용이 있어 reserve한 std::string보다 항상 빠르지는 않으니 두 방식을 측정해 보고 고르세요. C++17의 std::to_chars를 쓰면 임시 문자열 없이 버퍼에 바로 숫자를 쓸 수 있습니다.

프로파일러 선택 가이드

플랫폼별 권장 도구

플랫폼권장 도구특징
Linuxperf재컴파일 불필요, 하드웨어 카운터 지원
macOSInstrumentsXcode 통합, GUI 친화적
WindowsVisual Studio ProfilerIDE 통합, 사용 쉬움
Linux·macOS(일부)Valgrind (callgrind)명령어 단위 시뮬레이션이라 매우 느리지만 결과가 결정적
Intel·AMD CPUIntel VTune마이크로아키텍처 분석이 강함, 현재 무료 배포(oneAPI)

도구별 비교

도구속도 오버헤드재컴파일하드웨어 카운터난이도
perf낮음(샘플링)불필요지원중간
gprof중간(-pg 계측)필요 (-pg)미지원쉬움
Valgrind매우 큼(수십 배 느려짐)불필요미지원(캐시는 시뮬레이션)쉬움
VTune낮음(샘플링)불필요지원어려움

오버헤드는 샘플링 주기, 프로그램 특성, 옵션에 따라 크게 달라지므로 표는 상대적인 크기만 나타냅니다.

perf로 병목 찾기 (Linux)

설치

# Ubuntu/Debian
sudo apt install linux-tools-common linux-tools-generic
# Fedora/RHEL
sudo dnf install perf

기본 사용법

# 1. 프로그램 실행 중 프로파일링
perf record -g ./myapp
# 2. 결과 확인
perf report
# 3. 함수별 시간 확인
perf report --stdio

출력 예시

# Overhead  Command  Shared Object      Symbol
# ......... ........ .................. .............................
#
    45.23%  myapp    myapp              [.] processData
    23.45%  myapp    myapp              [.] calculateSum
    12.34%  myapp    libc-2.31.so       [.] malloc
     8.90%  myapp    myapp              [.] sortArray
     5.67%  myapp    myapp              [.] std::vector<int>::_M_realloc_insert

이 예시라면 processData가 샘플의 45%를 차지하므로 가장 먼저 볼 대상입니다. malloc이 12%이고 벡터 재할당 함수도 보이므로 할당을 줄일 여지가 있습니다. 템플릿 함수는 보통 호출한 바이너리 안에 인스턴스화되므로 std::vector 관련 심볼도 libstdc++가 아니라 myapp에 잡힙니다. 기본 perf report의 Overhead는 그 함수 자신의 시간(self)이 기준이고, -g로 기록하면 하위 호출까지 포함한 Children 열이 함께 나옵니다.

하드웨어 카운터 측정

# 캐시 미스 측정
perf stat -e cache-misses,cache-references ./myapp
# 분기 예측 실패 측정
perf stat -e branch-misses,branches ./myapp
# 전체 통계
perf stat ./myapp

출력 예시:

 Performance counter stats for './myapp':
        1,234,567      cache-misses              #   12.34% of all cache refs
       10,000,000      cache-references
          234,567      branch-misses             #    2.35% of all branches
       10,000,000      branches
       2.345678 seconds time elapsed

이 비율은 단독으로 좋고 나쁨을 판단하기 어렵습니다. cache-references가 어느 캐시 레벨을 세는지는 CPU마다 다르고(보통 마지막 레벨 캐시), 분기 예측 실패율 2%대는 일반적인 프로그램에서 흔한 수준입니다. 같은 프로그램의 수정 전후나 같은 머신의 다른 구현과 비교하고, 명령어 1000개당 미스 수처럼 작업량으로 정규화해 보는 편이 의미가 있습니다.

Flame Graph 생성

# FlameGraph 스크립트 받기
git clone https://github.com/brendangregg/FlameGraph ~/FlameGraph
# 프로그램 디렉터리에서 프로파일링 + Flame Graph 생성
perf record -g ./myapp
perf script | ~/FlameGraph/stackcollapse-perf.pl | ~/FlameGraph/flamegraph.pl > flame.svg
# 브라우저로 열기
firefox flame.svg

함수 이름 대신 주소만 보이거나 호출 스택이 끊길 때

함수 이름이 안 보이면 디버그 심볼 없이(-g 미지정) 빌드했거나 바이너리가 strip된 경우가 대부분입니다. 호출 스택이 끊기는 것은 최적화 빌드에서 프레임 포인터가 생략되어 perf가 스택을 따라가지 못하기 때문이라, -fno-omit-frame-pointer로 빌드하거나 perf record --call-graph dwarf를 사용하면 개선됩니다. 병목 측정은 실제 배포와 같은 최적화 수준에서 해야 하므로, 최적화를 끄기보다 -O2 -g 조합으로 심볼만 추가하는 것이 좋습니다.


Visual Studio Profiler (Windows)

사용법

1. 디버그 → 성능 프로파일러 (Alt+F2)
2. "CPU 사용량" 체크
3. "시작" 클릭
4. 프로그램 실행 후 종료
5. 함수별 시간 확인

핫 패스 (Hot Path) 확인

함수                    전체 %    자체 %
processData             45.2%     12.3%
  ├─ calculateSum       23.4%     23.4%
  └─ sortArray           8.9%      8.9%
malloc                  12.3%     12.3%

전체 %는 이 함수와 하위 함수를 합친 시간이고, 자체 %는 하위 함수를 뺀 이 함수만의 시간입니다. 자체 %가 높은 함수는 그 함수 본문을 고쳐야 하고, 전체 %는 높은데 자체 %가 낮은 함수는 하위 호출을 줄이거나 호출 구조를 바꿔야 한다는 신호입니다.


그 밖에 자주 나오는 성능 문제 패턴

앞의 7가지 원인 외에 프로파일링에서 자주 보이는 패턴입니다.

패턴 1: map 대신 unordered_map

// ❌ 느린 코드 (O(log n) 조회)
std::map<int, std::string> cache;
for (int i = 0; i < 1000000; ++i) {
    cache[i] = "value";
}
for (int i = 0; i < 1000000; ++i) {
    auto it = cache.find(i);  // O(log n)
}
// ✅ 빠른 코드 (O(1) 조회)
std::unordered_map<int, std::string> cache;
for (int i = 0; i < 1000000; ++i) {
    cache[i] = "value";
}
for (int i = 0; i < 1000000; ++i) {
    auto it = cache.find(i);  // O(1) 평균
}

map 조회는 트리를 약 log₂ n 단계 내려가며 단계마다 다른 노드를 읽어 캐시 미스가 쌓이고, unordered_map은 해시로 버킷을 바로 찾습니다. 조회가 많고 순서가 필요 없을 때 이득이 크며, 키 해시가 비싸거나 충돌이 많으면 차이가 줄어듭니다.

패턴 2: 캐시 비효율적 자료구조

// ❌ 느린 코드 (AoS - Array of Structures)
struct Particle {
    float x, y, z;      // 위치
    float vx, vy, vz;   // 속도
    float r, g, b, a;   // 색상
};
std::vector<Particle> particles(1000000);
// 위치만 갱신 (색상은 안 쓰는데 캐시에 올라옴)
for (auto& p : particles) {
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.z += p.vz * dt;
}
// ✅ 빠른 코드 (SoA - Structure of Arrays)
struct Particles {
    std::vector<float> x, y, z;
    std::vector<float> vx, vy, vz;
    std::vector<float> r, g, b, a;
};
Particles particles;
particles.x.resize(1000000);
// ...
for (size_t i = 0; i < particles.x.size(); ++i) {
    particles.x[i] += particles.vx[i] * dt;
    particles.y[i] += particles.vy[i] * dt;
    particles.z[i] += particles.vz[i] * dt;
}

AoS에서는 위치와 속도만 쓰는데도 한 파티클의 40바이트가 통째로 캐시에 올라오므로, 읽어 오는 바이트의 40%(색상 16바이트)가 쓰지 않는 데이터입니다. SoA는 필요한 배열만 연속으로 읽으므로 캐시 라인을 꽉 채워 쓰고, 컴파일러가 SIMD로 벡터화하기도 쉽습니다.

패턴 3: 불필요한 std::endl

// ❌ 느린 코드
for (int i = 0; i < 1000000; ++i) {
    std::cout << i << std::endl;  // 매번 flush
}
// ✅ 빠른 코드
for (int i = 0; i < 1000000; ++i) {
    std::cout << i << '\n';  // flush 안 함
}

std::endl은 줄바꿈 후 매번 flush를 호출해 버퍼를 비우고, 보통 그때마다 write 시스템 콜이 일어납니다. '\n'은 버퍼에 쌓았다가 한꺼번에 내보내므로 시스템 콜 횟수가 크게 줄어듭니다. 출력이 파일이나 파이프로 리다이렉트될 때 차이가 가장 큽니다.

패턴 4: 정규표현식 매번 컴파일

// ❌ 느린 코드
for (const auto& line : lines) {
    std::regex pattern(R"(\d+)");  // 매번 컴파일
    if (std::regex_search(line, pattern)) {
        // ...
    }
}
// ✅ 빠른 코드
std::regex pattern(R"(\d+)");  // 한 번만 컴파일
for (const auto& line : lines) {
    if (std::regex_search(line, pattern)) {
        // ...
    }
}

std::regex 생성자는 패턴을 파싱해 내부 오토마톤을 만드는 비싼 작업이라, 루프 안에서 매번 만들면 검색 자체보다 생성 비용이 더 커지기 쉽습니다. 패턴은 루프 밖에서 한 번만 만들고(필요하면 static const), 그래도 느리면 std::regex 대신 RE2나 CTRE 같은 라이브러리를 검토하세요.

패턴 5: 멀티스레드 락 경합

// ❌ 느린 코드
std::mutex mtx;
std::vector<int> shared_data;
void worker() {
    for (int i = 0; i < 1000000; ++i) {
        std::lock_guard lock(mtx);  // 매번 락
        shared_data.push_back(i);
    }
}
// 4스레드 실행 시 락 경합으로 느림
// ✅ 빠른 코드 (로컬 버퍼)
std::mutex mtx;
std::vector<int> shared_data;
void worker() {
    std::vector<int> local_buffer;
    local_buffer.reserve(1000000);
    
    for (int i = 0; i < 1000000; ++i) {
        local_buffer.push_back(i);  // 락 없이
    }
    
    std::lock_guard lock(mtx);  // 한 번만 락
    shared_data.insert(shared_data.end(), 
                      local_buffer.begin(), 
                      local_buffer.end());
}

반복마다 락을 잡으면 네 스레드가 같은 mutex를 두고 계속 경쟁해, 대부분의 시간을 대기와 캐시 라인 이동에 씁니다. 로컬 버퍼에 모았다가 마지막에 한 번만 락을 잡으면 경합이 스레드당 한 번으로 줄어듭니다.

패턴 6: 불필요한 동적 할당

// ❌ 느린 코드
std::vector<std::unique_ptr<int>> data;
for (int i = 0; i < 1000000; ++i) {
    data.push_back(std::make_unique<int>(i));  // 100만 번 할당
}
// ✅ 빠른 코드
std::vector<int> data;
data.reserve(1000000);
for (int i = 0; i < 1000000; ++i) {
    data.push_back(i);  // 값 저장 (재할당 없음)
}

make_unique는 원소마다 힙 할당을 하고, 벡터에는 포인터만 들어가 실제 값이 메모리 곳곳에 흩어집니다. 값을 직접 저장하면 할당이 reserve 한 번으로 줄고, 값이 연속으로 놓여 순회할 때 캐시 효율도 좋아집니다.


실전 최적화 사례

사례 1: JSON 파싱 최적화 (재할당 제거)

Before:

// ❌ 느린 코드
std::string parseJson(const std::string& json) {
    std::string result;
    
    for (char c : json) {
        result += c;  // 용량이 찰 때마다 재할당·복사
        if (c == '{') {
            result += '\n';
        }
    }
    
    return result;
}
// 프로파일에서 string::operator+=와 그 안의 재할당이 상위에 보이는 경우

After:

// ✅ 빠른 코드
std::string parseJson(const std::string& json) {
    std::string result;
    result.reserve(json.size() * 2);  // 미리 할당
    
    for (char c : json) {
        result += c;
        if (c == '{') {
            result += '\n';
        }
    }
    
    return result;
}

결과 문자열이 커질 때마다 일어나던 재할당과 복사가 reserve 한 번으로 사라집니다. std::string은 용량을 배수로 늘리기 때문에 재할당 횟수 자체는 log n 수준이지만, 큰 입력에서는 매번 전체를 복사하는 비용이 쌓입니다. 개선 폭은 입력 크기와 할당기에 따라 다르므로 프로파일러로 operator+= 비중이 실제로 줄었는지 확인하세요.

사례 2: 데이터베이스 쿼리 최적화 (N+1 쿼리 제거)

Before:

// ❌ 느린 코드 (N+1 쿼리)
std::vector<User> users = db.query("SELECT * FROM users");
for (const auto& user : users) {
    auto orders = db.query("SELECT * FROM orders WHERE user_id = " + 
                          std::to_string(user.id));  // 사용자 수만큼 쿼리
    // ...
}
// 실제 코드에서는 문자열 연결 대신 바인딩 파라미터를 써야 SQL 인젝션도 막을 수 있음

After:

// ✅ 빠른 코드 (JOIN 한 번)
auto results = db.query(
    "SELECT u.*, o.* FROM users u "
    "LEFT JOIN orders o ON u.id = o.user_id"
);
// 결과를 메모리에서 그룹화
std::unordered_map<int, std::vector<Order>> user_orders;
for (const auto& row : results) {
    user_orders[row.user_id].push_back(row.order);
}

N+1 쿼리는 사용자 수만큼 DB 왕복이 생기므로, 쿼리 하나가 1ms만 걸려도 사용자 100만 명이면 왕복만 1000초입니다. JOIN으로 바꾸면 왕복이 한 번으로 줄고, DB가 인덱스를 이용해 한꺼번에 조인합니다. 이런 문제는 CPU 프로파일러보다 DB 쿼리 로그에서 같은 쿼리가 반복되는 모습으로 먼저 보입니다.

사례 3: 이미지 처리 최적화 (픽셀 접근 오버헤드 제거)

Before:

// ❌ 느린 코드 (픽셀마다 함수 호출)
void applyFilter(Image& img) {
    for (int y = 0; y < img.height; ++y) {
        for (int x = 0; x < img.width; ++x) {
            Color c = img.getPixel(x, y);  // 가상 함수 호출
            c = processColor(c);
            img.setPixel(x, y, c);  // 가상 함수 호출
        }
    }
}
// 프로파일에서 getPixel/setPixel이 상위에 보이는 경우

After:

// ✅ 빠른 코드 (직접 메모리 접근)
void applyFilter(Image& img) {
    Color* pixels = img.getPixelData();  // 직접 포인터 (행 사이 패딩이 없는 버퍼라고 가정)
    size_t total = static_cast<size_t>(img.width) * img.height;  // int 곱셈 오버플로 방지
    
    for (size_t i = 0; i < total; ++i) {
        pixels[i] = processColor(pixels[i]);
    }
}

픽셀마다 가상 함수 두 번을 호출하면 인라인이 막히고, 좌표 → 오프셋 계산도 매번 반복됩니다. 연속된 픽셀 버퍼를 직접 순회하면 호출 오버헤드가 사라지고 컴파일러가 루프를 SIMD로 벡터화할 수 있습니다. 이미지 버퍼가 행마다 정렬용 패딩(stride)을 갖는 형식이라면 행 단위로 포인터를 옮기며 순회해야 합니다.


병목 찾는 5단계 프로세스

1단계: 측정 (Measure)

# 전체 실행 시간 측정
time ./myapp
# 프로파일러 실행
perf record -g ./myapp

2단계: 분석 (Analyze)

# 함수별 시간 확인
perf report
# 상위 함수만 텍스트로 보기
perf report --stdio | head -20

어떤 함수가 시간을 가장 많이 쓰는지, 그것이 예상과 일치하는지를 먼저 봅니다. 예상과 다르다면 그 차이가 곧 가장 값진 정보입니다.

3단계: 가설 (Hypothesize)

"processData 함수가 45%를 차지한다"
→ 가설: 내부에서 불필요한 복사가 일어나는가?
→ 가설: 알고리즘이 비효율적인가?
→ 가설: 캐시 미스가 많은가?

4단계: 최적화 (Optimize)

// 가설 검증: 복사 제거
// Before
void processData(std::vector<int> data) { ... }
// After
void processData(const std::vector<int>& data) { ... }

5단계: 재측정 (Re-measure)

# 최적화 후 다시 측정 (기존 perf.data는 perf.data.old로 이름이 바뀜)
perf record -g ./myapp_optimized
# 개선 확인
perf diff perf.data.old perf.data

목표 성능에 도달할 때까지 2~5단계를 반복합니다. 한 번에 한 가지만 바꿔야 어떤 변경이 효과를 냈는지 알 수 있습니다.


프로파일링 결과 읽는 법

Flat Profile vs Call Graph

Flat Profile (함수별 시간):

  %   cumulative   self              self     total
 time   seconds   seconds    calls  ms/call  ms/call  name
 45.23      1.23     1.23  1000000     0.00     0.00  processData
 23.45      1.87     0.64   500000     0.00     0.00  calculateSum
  8.90      2.11     0.24      100     2.40     2.40  sortArray

Call Graph (호출 관계):

index % time    self  children    called     name
                1.23    0.64  1000000/1000000     main [3]
[1]     68.5    1.23    0.64  1000000         processData [1]
                0.64    0.00   500000/500000      calculateSum [2]
-----------------------------------------------
                0.64    0.00   500000/500000      processData [1]
[2]     23.5    0.64    0.00   500000         calculateSum [2]

gprof의 Call Graph에서 대괄호 번호가 붙은 줄이 그 항목의 함수이고, 그 위의 줄은 호출한 쪽(부모), 아래 줄은 호출된 쪽(자식)입니다. self는 함수 자체의 시간, children은 하위 함수에서 쓴 시간, called는 호출 횟수입니다. gprof는 -pg로 계측된 코드만 정확히 세므로, 계측되지 않은 libc의 malloc 같은 함수는 제대로 나오지 않는 경우가 많습니다.

핫스팟 (Hotspot) 찾기

최적화 대상은 시간 비중이 큰 순서로 고릅니다. 어떤 함수를 무한히 빠르게 만들어도 전체 시간은 그 함수의 비중만큼만 줄기 때문에(암달의 법칙), 5%를 차지하는 함수는 아무리 고쳐도 최대 5%밖에 줄일 수 없습니다. 위 예시라면 processData의 자체 시간과 그 안에서 부르는 calculateSum이 먼저이고, 비중이 작은 함수는 뒤로 미룹니다.

같이 보면 좋은 글