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를 쓰면 임시 문자열 없이 버퍼에 바로 숫자를 쓸 수 있습니다.
프로파일러 선택 가이드
플랫폼별 권장 도구
| 플랫폼 | 권장 도구 | 특징 |
|---|---|---|
| Linux | perf | 재컴파일 불필요, 하드웨어 카운터 지원 |
| macOS | Instruments | Xcode 통합, GUI 친화적 |
| Windows | Visual Studio Profiler | IDE 통합, 사용 쉬움 |
| Linux·macOS(일부) | Valgrind (callgrind) | 명령어 단위 시뮬레이션이라 매우 느리지만 결과가 결정적 |
| Intel·AMD CPU | Intel 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이 먼저이고, 비중이 작은 함수는 뒤로 미룹니다.
같이 보면 좋은 글
- C++ 프로파일링
- C++ 성능 최적화 | 알고리즘·메모리·캐시 개선 패턴
- C++ 캐시 친화 코드: AoS vs SoA, 데이터 지향 설계, False Sharing
- C++ 벤치마킹 | 정확한 성능 측정 방법
- C++ 시리즈 전체 보기