C++ 분기 예측: likely·unlikely 속성과 분기 없는 코드로 성능 개선하기

이 글의 핵심

같은 루프가 정렬된 데이터에서는 빠르고 무작위 데이터에서는 느려지는 현상은 분기 예측으로 설명됩니다. 다만 힌트를 과하게 붙이거나 분기를 없애느라 연산이 늘면 오히려 느려질 수 있고, 결과가 CPU마다 달라지기도 합니다. 패킷 필터링과 이미지 임계값 처리 사례로 언제 최적화할 가치가 있는지 판단하도록 돕습니다.

들어가며

현대 CPU는 파이프라인과 슈퍼스칼라 구조로 여러 명령을 겹쳐 실행합니다. if나 루프 분기에서 다음에 실행할 명령의 주소가 조건에 따라 갈라지면, 조건이 계산되기 전까지는 “어느 쪽이 실행될지”를 모릅니다. 따라서 CPU는 분기 예측기(branch predictor)가 과거 패턴을 바탕으로 한쪽 경로를 추측 실행(speculative execution)합니다. 맞으면 그대로 진행하며, 틀리면 파이프라인 플러시(misprediction penalty)로 수십 사이클을 날립니다.

중요한 점은 이 모든 일이 하드웨어에서 자동으로 일어난다는 것입니다. C++ 코드로 예측기를 직접 조작할 방법은 없고, 개발자가 할 수 있는 일은 세 가지뿐입니다. 예측하기 쉬운 데이터 순서를 만들거나(정렬, 데이터 분리), 분기 자체를 없애거나(branchless, 룩업 테이블), 컴파일러가 코드를 더 좋게 배치하도록 정보를 주는 것([[likely]], PGO)입니다. 아래 기법들은 모두 이 세 범주 중 하나에 속하며, 어느 것이든 “실제로 분기 미스가 병목인가”를 먼저 측정한 뒤에 적용해야 의미가 있습니다.


기본 개념

CPU 분기 예측 원리

  1. 순차 실행 가정: 파이프라인은 보통 “다음 주소”를 연속으로 가져옵니다.
  2. 조건부 분기: 조건이 확정되기 전에 목적지가 두 갈래 이상이면, 예측기가 한쪽을 선택합니다.
  3. 미스 예측: 잘못 고른 경로에서 이미 시작한 작업을 버리고 올바른 주소로 다시 채웁니다. 이 비용이 루프 내부에서 수백만 번 반복되면 체감 성능에 큰 영향을 줍니다.

예측 가능 vs 예측 불가능

// 예측 가능한 분기: 빠름
for (int i = 0; i < n; ++i) {
    if (i < n/2) {  // 항상 같은 패턴
        // ...
    }
}
// 예측 불가능한 분기: 느림
for (int i = 0; i < n; ++i) {
    if (data[i] % 2 == 0) {  // 랜덤
        // ...
    }
}

현대 예측기는 단순히 “지난번에 갔던 쪽”만 기억하지 않고, 최근 수십~수백 개 분기의 결과 이력(global history)과 분기 주소를 조합해 패턴을 학습합니다. 그래서 T, T, F처럼 짧은 주기로 반복되는 패턴도 대부분 맞히고, 루프 탈출 분기처럼 “N번 true 뒤 한 번 false”인 패턴도 N이 작으면 잘 예측합니다. 예측기가 구조적으로 이길 수 없는 것은 진짜 무작위 데이터뿐입니다. 위 두 번째 루프가 느린 이유도 % 2 연산 때문이 아니라 data[i]의 홀짝이 이력으로 예측할 수 없는 값이기 때문입니다.


실전 구현

[[likely]] / [[unlikely]] (C++20)

#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
    if (b == 0) [[unlikely]] {
        throw std::runtime_error("0으로 나눔");
    }
    
    return a / b;
}
void process(int* data, int n) {
    for (int i = 0; i < n; ++i) {
        if (data[i] > 0) [[likely]] {
            // 대부분 양수
            processPositive(data[i]);
        } else {
            processNegative(data[i]);
        }
    }
}
int main() {
    int result = divide(10, 2);
    std::cout << result << std::endl;

    return 0;
}

[[likely]]와 [[unlikely]]가 실제로 바꾸는 것은 CPU의 예측이 아니라 컴파일러의 코드 배치입니다. 컴파일러는 likely 쪽 경로를 분기 명령 바로 뒤에 이어서(fall-through) 배치하고, unlikely 쪽은 함수 끝이나 별도 섹션(.text.unlikely)으로 밀어냅니다. 그러면 자주 실행되는 코드가 명령 캐시에 연속으로 올라가고, taken 분기 수가 줄어 프런트엔드 효율이 좋아집니다. x86에는 과거 분기 힌트 접두사가 있었지만 대부분의 현대 CPU가 이를 무시하므로, 힌트가 예측기 정확도를 직접 올린다고 기대하면 안 됩니다. 효과가 가장 확실한 곳은 위 divide처럼 오류 처리 경로를 핫 루프에서 떼어 내는 경우이고, 50:50에 가까운 분기에 붙이는 것은 대부분 의미가 없습니다.

문법적으로는 속성이 조건이 아니라 문장에 붙는다는 점을 주의해야 합니다. if (b == 0) [[unlikely]] { ... }처럼 조건 괄호 뒤, 문장 앞에 써야 하고, if ([[unlikely]] b == 0)은 컴파일 오류입니다. if와 else 양쪽에 모두 [[likely]]를 붙이면 GCC와 Clang이 경고를 내며 무시합니다. C++20 이전 코드베이스라면 GCC/Clang의 __builtin_expect(cond, 0)이 같은 역할을 하며, 리눅스 커널의 likely()/unlikely() 매크로가 이 내장 함수를 감싼 것입니다.

분기 제거 (Branchless)

조건부 이동 (cmov)

// ❌ 분기
int max(int a, int b) {
    if (a > b) {
        return a;
    } else {
        return b;
    }
}
// ✅ 조건부 이동
int max(int a, int b) {
    return (a > b) ? a : b;
}
// 컴파일러가 cmov 명령 생성

사실 두 max는 -O2 이상에서 대개 같은 기계어로 컴파일됩니다. 삼항 연산자를 썼다고 cmov가 보장되는 것도 아니고, if를 썼다고 분기가 남는 것도 아닙니다. 컴파일러는 양쪽 값이 이미 계산되어 있고 부작용이 없으면 cmov를 선택하고, 한쪽 경로가 무겁거나 메모리 접근을 포함하면(잘못된 주소를 읽을 수 있으므로) 분기를 유지합니다. 그래서 소스 형태보다 Compiler Explorer 같은 도구로 생성된 어셈블리에 cmov가 있는지, jg/jle 같은 조건 점프가 있는지 확인하는 습관이 더 중요합니다. 또 cmov는 결과가 두 입력에 모두 의존하는 데이터 의존성을 만들기 때문에, 루프를 따라 누적되는 의존 체인 위에 있으면 잘 예측되는 분기보다 느려질 수 있습니다.

마스크 사용

#include <vector>
#include <chrono>
#include <iostream>
int main() {
    std::vector<int> data(10000000);
    for (int i = 0; i < data.size(); ++i) {
        data[i] = i % 100 - 50;
    }
    
    // ❌ 분기 많음
    auto start1 = std::chrono::high_resolution_clock::now();
    int sum1 = 0;
    for (int x : data) {
        if (x > 0) {
            sum1 += x;
        }
    }
    auto end1 = std::chrono::high_resolution_clock::now();
    auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
    
    // ✅ 분기 제거
    auto start2 = std::chrono::high_resolution_clock::now();
    int sum2 = 0;
    for (int x : data) {
        int mask = (x > 0) ? 1 : 0;
        sum2 += x * mask;
    }
    auto end2 = std::chrono::high_resolution_clock::now();
    auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
    
    std::cout << "분기: " << time1 << "ms" << std::endl;
    std::cout << "분기 제거: " << time2 << "ms" << std::endl;

    return 0;
}

이 예제는 측정 결과를 해석할 때 두 가지를 알고 있어야 합니다. 먼저 데이터가 i % 100 - 50이라 양수·음수가 100개 주기로 규칙적으로 반복되므로, 분기 버전도 예측기가 상당 부분 맞힐 수 있습니다. 분기 미스의 효과를 보려면 난수로 채운 데이터와 비교해야 합니다. 다음으로, 최적화 빌드에서는 컴파일러가 첫 번째 루프도 SIMD 비교와 마스크로 벡터화하는 경우가 많아서 두 루프의 기계어가 거의 같아질 수 있습니다. x * mask처럼 곱셈을 쓰는 방식보다는 sum += (x > 0) ? x : 0;이나 x & -(x > 0) 같은 형태가 컴파일러에 의도를 더 잘 전달합니다. 합계가 커지는 벤치마크에서는 int 누적이 오버플로(정의되지 않은 동작)를 일으킬 수 있으니 long long을 쓰는 것이 안전합니다.


정렬 효과

#include <algorithm>
#include <vector>
#include <random>
#include <chrono>
#include <iostream>
int main() {
    std::vector<int> data(10000000);
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> dis(0, 10000);
    std::generate(data.begin(), data.end(), [&]() { return dis(gen); });
    
    // ❌ 랜덤 데이터 (예측 불가)
    auto start1 = std::chrono::high_resolution_clock::now();
    long long sum1 = 0;  // int면 1천만 개 합산 시 오버플로
    for (int x : data) {
        if (x > 5000) {
            sum1 += x;
        }
    }
    auto end1 = std::chrono::high_resolution_clock::now();
    auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
    
    // ✅ 정렬 후 (예측 가능)
    std::sort(data.begin(), data.end());
    
    auto start2 = std::chrono::high_resolution_clock::now();
    long long sum2 = 0;
    for (int x : data) {
        if (x > 5000) {
            sum2 += x;
        }
    }
    auto end2 = std::chrono::high_resolution_clock::now();
    auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
    
    std::cout << "랜덤: " << time1 << "ms" << std::endl;
    std::cout << "정렬: " << time2 << "ms" << std::endl;
    
    return 0;
}

이 예제는 Stack Overflow에서 가장 유명한 질문 중 하나(“Why is processing a sorted array faster than processing an unsorted array?”)와 같은 구조입니다. 저도 처음 이 코드를 따라 해 봤을 때 차이가 전혀 나지 않아 한참 헤맸는데, 원인은 -O3 빌드에서 컴파일러가 루프를 벡터화해 분기를 없앤 것이었습니다. 벤치마크 코드가 “분기 예측”을 측정하고 있는지 “컴파일러의 벡터화 능력”을 측정하고 있는지는 어셈블리를 봐야만 알 수 있습니다.

결과 해석: 분기가 실제로 남아 있으면 정렬된 데이터에서는 앞쪽 절반은 계속 false, 뒤쪽 절반은 계속 true라 예측기가 거의 틀리지 않고, 랜덤 데이터에서는 절반 가까이 틀려서 정렬 후 루프가 눈에 띄게 빨라집니다. 다만 -O2/-O3에서는 GCC·Clang이 이 루프를 조건부 이동(cmov)이나 SIMD로 바꿔 분기 자체를 없애는 경우가 많고, 그러면 두 시간이 거의 같게 나옵니다. 차이가 안 보인다면 -O1로 빌드하거나 어셈블리에 조건 분기(jg, jle 등)가 남아 있는지 먼저 확인하세요.


고급 활용

PGO (Profile-Guided Optimization)

프로파일 생성

# GCC/Clang
g++ -O3 -fprofile-generate program.cpp -o program
./program  # 대표 워크로드 실행
g++ -O3 -fprofile-use program.cpp -o program_optimized

PGO는 계측 빌드를 실행해 각 분기가 실제로 몇 번 taken됐는지 기록하고, 그 확률로 코드 배치·인라이닝·루프 최적화를 결정합니다. [[likely]]를 손으로 붙이는 것보다 넓은 범위를 정확하게 다루지만, 결과는 학습에 쓴 워크로드만큼만 좋습니다. 테스트 데이터나 개발용 입력으로 프로파일을 만들면 실제 운영 분포와 다른 방향으로 최적화될 수 있으므로, 운영 트래픽을 재현한 입력을 쓰는 것이 핵심입니다. 소스가 바뀌면 오래된 프로파일과 맞지 않는 함수가 생기고, GCC는 이때 -Wcoverage-mismatch 계열 경고를 냅니다. 계측 빌드는 카운터 갱신 때문에 눈에 띄게 느리므로 운영 환경에 배포하면 안 됩니다. Clang을 쓴다면 계측 대신 perf로 수집한 샘플을 쓰는 AutoFDO 방식도 있습니다.

MSVC

# 프로파일 생성
cl /O2 /GL /LTCG:PGI program.cpp
program.exe  # 대표 워크로드 실행
# 프로파일 사용
cl /O2 /GL /LTCG:PGO program.cpp

룩업 테이블

// ❌ 분기 많음
const char* getDayName(int day) {
    if (day == 0) return "Sunday";
    if (day == 1) return "Monday";
    if (day == 2) return "Tuesday";
    // ...
}
// ✅ 룩업 테이블
const char* DAY_NAMES[] = {
    "Sunday", "Monday", "Tuesday", "Wednesday",
    "Thursday", "Friday", "Saturday"
};
const char* getDayName(int day) {
    return DAY_NAMES[day];  // day는 0~6이어야 함 (범위 밖이면 정의되지 않은 동작)
}

룩업 테이블은 분기를 메모리 읽기로 바꾸는 기법입니다. 테이블이 작아 L1 캐시에 머무르면 매우 빠르지만, 테이블이 커서 캐시 미스가 나면 한 번의 메모리 접근이 분기 미스보다 훨씬 비쌀 수 있습니다. 또 분기 체인에서는 자연스럽던 “범위 밖 값 처리”가 테이블에서는 사라지므로, 입력을 신뢰할 수 없다면 경계 검사를 따로 넣어야 합니다. 이 검사는 거의 항상 통과하는 분기라 예측 비용이 거의 없습니다. 참고로 연속된 정수에 대한 switch는 컴파일러가 이미 점프 테이블이나 값 테이블로 바꾸는 경우가 많습니다.

가상 함수 최적화

// ❌ 가상 함수 (간접 분기)
class Shape {
public:
    virtual double area() const = 0;
};
std::vector<Shape*> shapes;
double total = 0;
for (auto* shape : shapes) {
    total += shape->area();  // 간접 분기
}
// ✅ 타입별 분리
std::vector<Circle> circles;
std::vector<Rectangle> rectangles;
double total = 0;
for (const auto& circle : circles) {
    total += circle.area();  // 직접 호출
}
for (const auto& rect : rectangles) {
    total += rect.area();
}

가상 함수 호출은 간접 분기(indirect branch)라서 “어느 주소로 갈지”를 예측해야 합니다. 현대 CPU의 간접 분기 예측기는 호출 지점마다 대상 주소 이력을 기억하므로, 벡터에 같은 타입이 연속으로 들어 있으면 예측이 잘 맞고 타입이 무작위로 섞여 있을 때 미스가 늘어납니다. 타입별 컨테이너로 나누면 이 문제뿐 아니라 인라이닝과 캐시 지역성까지 개선되는 것이 진짜 이득입니다. 다만 다형성이 주는 확장성을 포기하는 설계 변경이므로, 프로파일에서 해당 루프가 실제 핫스팟으로 확인됐을 때만 고려하는 것이 좋습니다. 클래스를 final로 선언하면 컴파일러가 탈가상화(devirtualization)할 수 있는 경우도 늘어납니다.


성능 비교

분기 예측 실패 비용

분기 패턴예측기 동작비용
항상 true / 항상 false몇 번 만에 패턴을 학습, 거의 틀리지 않음분기당 1사이클 안팎
규칙적 반복 (예: T,T,F 반복)최근 이력을 이용해 대부분 맞힘작음
랜덤약 절반을 틀림틀릴 때마다 파이프라인을 비우고 다시 채움

예측이 틀리면 CPU는 추측으로 미리 실행한 명령을 버리고 올바른 경로에서 다시 시작해야 합니다. 이 페널티는 최근 x86 CPU 기준으로 대략 십수~수십 사이클이며(Agner Fog의 마이크로아키텍처 문서 참고), 루프 본문이 짧을수록 전체 시간에서 차지하는 비중이 커집니다.

분기 제거 효과

분기 제거(마스크·cmov)는 예측 실패 페널티를 없애는 대신 조건과 상관없이 양쪽 계산을 항상 수행합니다. 그래서 분기가 랜덤할 때는 이득이지만, 분기가 거의 항상 한쪽으로 가는 경우에는 원래 코드가 더 빠를 수 있습니다. 아래 문제 2: 분기 제거 비용도 같은 이유입니다.

정렬 효과

정렬은 랜덤 분기를 “앞은 전부 false, 뒤는 전부 true”인 예측 가능한 패턴으로 바꿔 줍니다. 하지만 정렬 자체가 O(N log N)이므로, 같은 데이터를 여러 번 순회할 때만 정렬 비용을 회수할 수 있습니다. 또 앞에서 설명했듯 컴파일러가 분기를 없애 버리면 정렬 효과는 사라지므로, 이 기법을 적용하기 전에 perf stat -e branch-misses로 실제 분기 미스가 많은지 먼저 확인하세요.


실무 사례

사례 1: 패킷 필터링

#include <vector>
#include <chrono>
#include <iostream>
struct Packet {
    int type;
    int size;
};
void filterPackets(const std::vector<Packet>& packets) {
    int count = 0;
    
    for (const auto& packet : packets) {
        if (packet.type == 1) [[likely]] {
            // 대부분 타입 1
            count++;
        }
    }
    
    std::cout << "타입 1 패킷: " << count << std::endl;
}
int main() {
    std::vector<Packet> packets(10000000);
    for (auto& p : packets) {
        p.type = (rand() % 100 < 90) ? 1 : 2;  // 90% 타입 1
        p.size = 1500;
    }
    
    auto start = std::chrono::high_resolution_clock::now();
    filterPackets(packets);
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    
    std::cout << "시간: " << duration << "ms" << std::endl;

    return 0;
}

이 사례에서 90%라는 분포는 예측기 입장에서 “대체로 한쪽”일 뿐 규칙적인 패턴은 아닙니다. rand()로 섞인 10%의 타입 2 패킷은 예측기가 맞힐 수 없으므로, 대략 10% 안팎의 분기 미스가 생깁니다. [[likely]]는 이 미스율을 낮추지 못하고, 타입 1 처리 코드를 fall-through 경로에 배치하는 데만 기여합니다. 실제 패킷 처리처럼 분포가 편향된 워크로드에서는 오히려 count += (packet.type == 1);처럼 비교 결과를 바로 더하는 방식이 분기를 완전히 없애 더 안정적인 경우가 많습니다.

사례 2: 이미지 처리 - 임계값 필터

#include <vector>
#include <algorithm>
#include <chrono>
#include <iostream>
void applyThreshold(std::vector<uint8_t>& image, uint8_t threshold) {
    // ❌ 분기 많음
    auto start1 = std::chrono::high_resolution_clock::now();
    for (auto& pixel : image) {
        if (pixel > threshold) {
            pixel = 255;
        } else {
            pixel = 0;
        }
    }
    auto end1 = std::chrono::high_resolution_clock::now();
    auto time1 = std::chrono::duration_cast<std::chrono::milliseconds>(end1 - start1).count();
    
    std::cout << "분기: " << time1 << "ms" << std::endl;
}
void applyThresholdBranchless(std::vector<uint8_t>& image, uint8_t threshold) {
    // ✅ 분기 제거
    auto start2 = std::chrono::high_resolution_clock::now();
    for (auto& pixel : image) {
        int mask = (pixel > threshold) ? 0xFF : 0x00;
        pixel = mask;
    }
    auto end2 = std::chrono::high_resolution_clock::now();
    auto time2 = std::chrono::duration_cast<std::chrono::milliseconds>(end2 - start2).count();
    
    std::cout << "분기 제거: " << time2 << "ms" << std::endl;
}
int main() {
    std::vector<uint8_t> image(10000000);
    std::generate(image.begin(), image.end(), []() { return rand() % 256; });
    
    applyThreshold(image, 128);
    applyThresholdBranchless(image, 128);

    return 0;
}

이 비교에는 측정상의 함정이 있습니다. applyThreshold가 이미지를 0과 255로 덮어쓴 뒤에 applyThresholdBranchless가 같은 벡터를 처리하므로, 두 번째 함수는 무작위 데이터가 아니라 이미 이진화된 데이터를 받습니다. 공정하게 비교하려면 각 함수에 원본의 복사본을 넘겨야 합니다. 또 이런 픽셀 루프는 컴파일러가 SIMD로 벡터화하기 가장 좋은 형태라서, 최적화 빌드에서는 두 버전 모두 분기가 없는 비교 명령으로 바뀌는 경우가 많습니다. 실무 이미지 처리에서는 분기 형태를 고민하기보다 OpenCV의 cv::threshold처럼 이미 SIMD로 최적화된 구현을 쓰는 편이 현실적입니다.

사례 3: 금융 - 조건부 수수료

#include <vector>
#include <chrono>
#include <iostream>
struct Transaction {
    double amount;
    int type;
};
double calculateFees(const std::vector<Transaction>& transactions) {
    double total_fee = 0.0;
    
    for (const auto& tx : transactions) {
        if (tx.type == 1) [[likely]] {
            // 일반 거래 (90%)
            total_fee += tx.amount * 0.001;
        } else if (tx.type == 2) {
            // 특수 거래 (9%)
            total_fee += tx.amount * 0.002;
        } else [[unlikely]] {
            // 예외 거래 (1%)
            total_fee += tx.amount * 0.005;
        }
    }
    
    return total_fee;
}
int main() {
    std::vector<Transaction> transactions(10000000);
    for (auto& tx : transactions) {
        int r = rand() % 100;
        tx.type = (r < 90) ? 1 : (r < 99) ? 2 : 3;
        tx.amount = 1000.0;
    }
    
    auto start = std::chrono::high_resolution_clock::now();
    double fee = calculateFees(transactions);
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    
    std::cout << "수수료: " << fee << std::endl;
    std::cout << "시간: " << duration << "ms" << std::endl;
    
    return 0;
}

트러블슈팅

문제 1: 과도한 힌트

증상: 잘못된 힌트로 성능 저하

// ❌ 잘못된 힌트
if (condition) [[unlikely]] {
    // 실제로는 자주 실행 (50%)
    // 성능 저하
}
// ✅ 프로파일링 후 적용
// perf stat -e branch-misses ./program

문제 2: 분기 제거 비용

증상: 분기 제거가 오히려 느림

// 간단한 분기 (예측 가능)
if (x > 0) {
    y = x;
} else {
    y = 0;
}
// 분기 제거 (항상 빠른 것은 아님)
y = (x > 0) ? x : 0;
// ✅ 컴파일러가 최적화
// 측정 후 선택

문제 3: 정렬 비용 vs 이득

증상: 정렬 비용이 분기 예측 이득보다 큼

std::vector<int> data = generateData(1000);
// 한 번만 순회: 정렬 안 함
for (int x : data) {
    if (x > threshold) { /* ... */ }
}
// 여러 번 순회: 정렬
std::sort(data.begin(), data.end());
for (int i = 0; i < 100; ++i) {
    for (int x : data) {
        if (x > threshold) { /* ... */ }
    }
}

기준: 정렬 비용(N log N 비교)과 순회당 절약되는 분기 미스 비용을 같은 데이터로 측정해 비교하세요. 손익분기점은 데이터 크기, 분기 본문, CPU에 따라 달라지므로 고정된 횟수 기준은 없습니다.

문제 4: 플랫폼 의존성

증상: 한 CPU에서는 빠르지만 다른 CPU에서는 느림

# 타깃 CPU에서 측정
perf stat -e branch-misses,branches ./program
# 출력 형식 예:
#   <횟수>  branches
#   <횟수>  branch-misses   #  X.XX% of all branches

해결: 타깃 CPU 클래스에서 벤치마크

perf stat의 미스율은 프로그램 전체 합계라서, 어느 분기가 문제인지는 알려 주지 않습니다. 특정 분기를 찾으려면 perf record -e branch-misses ./program 후 perf annotate로 명령 단위 샘플을 확인합니다. 가상 머신이나 일부 클라우드 인스턴스에서는 하드웨어 성능 카운터가 노출되지 않아 <not supported>로 표시될 수 있으며, 이때는 베어메탈이나 카운터를 지원하는 인스턴스에서 측정해야 합니다. Intel과 AMD, 그리고 ARM 서버 CPU는 예측기 구조가 달라서 한쪽에서 효과가 컸던 branchless 변경이 다른 쪽에서는 차이가 없을 수도 있습니다.


마무리

C++ 분기 예측은 성능 최적화의 핵심 요소입니다.

핵심 요약

  1. 분기 예측
    • CPU가 분기 결과를 추측 실행
    • 미스 예측 시 수십 사이클 손실
  2. [[likely]] / [[unlikely]]
    • C++20 표준 속성
    • 컴파일러에 힌트
  3. 분기 제거
    • 조건부 이동 (cmov)
    • 마스크 사용
  4. 정렬 효과
    • 예측 가능한 패턴
    • 정렬 비용을 회수할 만큼 반복 순회할 때만 이득
  5. PGO
    • 실제 워크로드 기반
    • 자동 최적화

최적화 기법

기법효과가 큰 경우난이도
[[likely]]/[[unlikely]]드문 에러 경로를 코드 배치에서 밀어낼 때 (예측기 자체를 바꾸지는 않음)낮음
분기 제거조건이 랜덤하고 양쪽 계산이 가벼울 때중간
정렬같은 데이터를 여러 번 순회할 때낮음
PGO실제 워크로드의 분기 확률로 코드 배치·인라이닝을 정할 때중간

코드 예제 치트시트

// likely/unlikely
if (rare) [[unlikely]] { /* ... */ }
// 분기 제거
result = (condition) ? a : b;
// 마스크
int mask = (x > 0) ? 1 : 0;
sum += x * mask;
// 정렬
std::sort(data.begin(), data.end());
// 룩업 테이블
result = table[index];

다음 단계

참고 자료

  • “Computer Architecture: A Quantitative Approach” - Hennessy, Patterson
  • “Optimized C++” - Kurt Guntheroth
  • “Agner Fog’s Optimization Manuals” 한 줄 정리: 분기 예측은 예측 가능한 패턴에서 빠르며, [[likely]]/[[unlikely]], 분기 제거, 정렬, PGO로 분기 미스를 줄일 수 있지만, 효과는 perf로 분기 미스를 측정한 뒤에 판단해야 합니다.

자주 묻는 질문 (FAQ)

Q. 분기를 없앤 branchless 코드가 오히려 느려지는 경우도 있나요?

A. 있습니다. 데이터 패턴이 일정해서 분기가 거의 항상 같은 방향으로 가면 CPU의 분기 예측이 잘 맞기 때문에, 분기를 없애려고 양쪽 계산을 모두 수행하는 코드가 더 많은 일을 하게 됩니다. 또한 y = (x > 0) ? x : 0 같은 단순한 형태는 컴파일러가 이미 조건부 이동 명령으로 바꿔 주는 경우가 많습니다. 따라서 branchless로 고치기 전에 실제 데이터로 분기 예측 실패가 병목인지 측정하고, 변경 후에도 다시 측정해 비교해야 합니다.


같이 보면 좋은 글