Featured image of post [Compiler 03] CPU 기능별 함수 다중 버전

[Compiler 03] CPU 기능별 함수 다중 버전

런타임 CPU 기능 감지에 따라 최적 구현을 선택하는 함수 멀티버저닝을 다룹니다. GCC/Clang의 target·target_clones 속성 사용법, IFUNC 디스패치 비용, AVX2/AVX-512 등 경로별 빌드·배포 설계와 검증 방법을 정리합니다.

멀티버저닝으로 CPU 기능(AVX2, AVX-512 등)에 맞는 구현을 런타임에 선택할 수 있습니다. 이 챕터에서는 문법과 디스패치 비용을 다룹니다.

멀티버저닝 개념

함수 멀티버저닝(Function Multiversioning)같은 함수에 대해 CPU 기능별로 서로 다른 구현을 두고, 실행 시점에 CPU가 지원하는 기능을 검사해 해당하는 버전을 호출하는 방식입니다. 예를 들어 “기본” 경로는 SSE만 쓰고, “AVX2” 경로는 256비트 SIMD를 쓰고, “AVX-512” 경로는 512비트 SIMD를 쓰도록 만들 수 있습니다. 한 바이너리를 여러 CPU에 배포할 때, 각 CPU에서 가장 적합한 구현이 자동으로 선택되므로, 호환성과 성능을 동시에 잡을 수 있습니다.

GCC / Clang 문법과 코드 예시

멀티버저닝의 핵심은 동일한 함수 이름에 서로 다른 target 속성을 붙인 여러 정의를 두는 것입니다. 컴파일러가 자동으로 디스패치 코드를 생성합니다.

GCC: __attribute__((target(...)))

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// multiversioning_demo.cc — GCC/Clang 공통 예시
#include <immintrin.h>
#include <cstddef>

// 기본 경로: SSE2 이상만 가정 (구형 CPU에서도 동작)
__attribute__((target("default")))
float dot_product(const float* a, const float* b, size_t n) {
    float sum = 0.0f;
    for (size_t i = 0; i < n; ++i)
        sum += a[i] * b[i];
    return sum;
}

// AVX2 경로: 256비트 SIMD로 8개 float 동시 처리
__attribute__((target("avx2")))
float dot_product(const float* a, const float* b, size_t n) {
    __m256 acc = _mm256_setzero_ps();
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        acc = _mm256_fmadd_ps(va, vb, acc);   // FMA: va * vb + acc
    }
    // 수평 합산 + 나머지 스칼라 처리 (생략)
    float buf[8];
    _mm256_storeu_ps(buf, acc);
    float s = buf[0]+buf[1]+buf[2]+buf[3]+buf[4]+buf[5]+buf[6]+buf[7];
    for (; i < n; ++i) s += a[i] * b[i];
    return s;
}

이 코드를 -O2로 빌드하면 컴파일러가 자동으로 CPUID 기반 디스패치 코드를 생성합니다. 프로그램이 AVX2를 지원하는 CPU에서 실행되면 avx2 버전이, 그렇지 않으면 default 버전이 호출됩니다.

GCC: target_clones로 여러 버전 한 번에 생성

1
2
3
4
5
6
// target_clones: 하나의 함수 정의에서 여러 버전 자동 생성
__attribute__((target_clones("avx512f", "avx2", "sse4.2", "default")))
void vector_add(float* dst, const float* src, size_t n) {
    for (size_t i = 0; i < n; ++i)
        dst[i] += src[i];
}

target_clones는 함수 정의를 한 번만 쓰고, 컴파일러가 각 타겟에 맞는 버전을 자동으로 생성합니다. 내부 루프에 SIMD intrinsic을 직접 쓰지 않아도 auto-vectorization이 타겟별로 다르게 적용됩니다.

Clang: __attribute__((target_version(...)))

1
2
3
4
5
6
// Clang에서도 비슷한 방식 — target_version 속성 사용
__attribute__((target_version("default")))
int process(int x) { return x * 2; }

__attribute__((target_version("avx2+fma")))
int process(int x) { return x * 3; }  // AVX2+FMA CPU에서 선택되는 버전

두 컴파일러 모두 CPU 디스패치를 위해 런타임에 CPUID 등을 사용해 “지금 CPU가 어떤 기능을 지원하는지” 확인하고, 그에 맞는 함수 포인터를 선택합니다. 이 선택은 보통 프로그램 시작 시 또는 해당 함수가 처음 호출될 때 한 번 이루어지고, 이후에는 같은 포인터를 사용합니다.

어셈블리에서 디스패치 확인

멀티버저닝을 적용하면 링크된 바이너리에 ifunc resolver 또는 유사한 간접 호출 메커니즘이 나타납니다.

1
2
3
4
5
# objdump로 resolver 함수와 여러 버전 확인
objdump -d ./app | grep -E "(dot_product|resolver)"
# dot_product.default:  -> 기본 경로
# dot_product.avx2:     -> AVX2 경로
# dot_product.ifunc:    -> 디스패치 포인터

디스패치 비용과 사용 시점

  • 비용: 디스패치는 보통 한 번만 이루어지므로, 함수가 매우 많이 호출되는 핫 경로에서는 디스패치 비용은 무시할 수 있습니다. 다만 디스패치 결과(함수 포인터)가 간접 호출로 이어지므로, 인라인은 되지 않고 호출 비용은 남습니다. 그래서 멀티버저닝된 함수 자체가 작고 루프 안에서 반복 호출되면, 그 호출 비용이 누적될 수 있습니다.
  • 사용 시점: 대량의 수치 연산·SIMD로 이득이 큰 루프를 멀티버저닝하는 경우가 많습니다. 한 번 호출에 오래 걸리는 “무거운” 함수면 디스패치·간접 호출 비용은 상대적으로 작습니다. 가벼운 함수를 멀티버저닝하면, “버전 선택 + 간접 호출” 비용이 이득을 깎아먹을 수 있으므로, 벤치마크로 확인하는 것이 좋습니다.

공급 경로별 설계와 검증

  • 설계: “default”, “avx2”, “avx512” 등 공급 경로(타겟)별로 동일 시맨틱의 구현을 작성합니다. AVX2 버전에서는 4-wide float/double, AVX-512에서는 8-wide 등으로 루프를 구성합니다. 메모리 정렬·테일 처리(tail)를 각 타겟에 맞게 작성해야 합니다.
  • 검증: 각 타겟에서 실제로 해당 버전이 선택되는지(로깅 또는 CPUID 확인), 그리고 정확성이 기본 경로와 동일한지(같은 입력에 같은 출력) 단위 테스트로 확인합니다. 성능은 해당 CPU가 있는 머신에서 벤치마크해, default 대비 AVX2/AVX-512 버전이 기대만큼 빨라지는지 봅니다. 구형 CPU에서는 default만 타는지도 확인하면 안전합니다.

용어 정리

용어설명
target_clonesGCC에서 여러 CPU 타겟에 대해 각각 함수 버전을 생성하도록 지정하는 속성
CPUIDCPU가 지원하는 기능(SSE, AVX2 등)을 런타임에 조회하는 명령; 디스패치 시 사용

판단 기준: 언제 멀티버저닝을 쓸지 / 피할지

상황권장비권장
대량 수치·SIMD 이득 큰 루프멀티버저닝으로 AVX2/AVX-512 경로 추가가벼운 함수만 멀티버저닝
다양한 CPU에 배포default + AVX2 등으로 호환·성능 동시 확보단일 최신 CPU만 가정
디스패치·간접 호출 비용무거운 함수·적은 호출 빈도에 사용핫 루프 내부 가벼운 함수 다중 버전

자주 하는 실수

  • 가벼운 함수를 과하게 멀티버저닝: 작은 함수를 AVX2/AVX-512 버전으로 나누면 디스패치·간접 호출 비용이 이득을 상쇄할 수 있다. 무거운 루프나 호출 빈도가 낮은 함수에 적용하고, 벤치로 이득이 나는지 확인한다.
  • 구형 CPU에서 선택·정확성 미검증: default 경로만 타는지, 해당 CPU에서 정확히 같은 결과가 나오는지 테스트하지 않으면 배포 후 크래시나 잘못된 결과가 나올 수 있다. 각 타겟에서 선택·정확성·성능을 검증한다.

학습 성과 목표

  • 함수 멀티버저닝 개념과 GCC/Clang target 속성 사용법을 설명할 수 있다.
  • 디스패치 비용과 언제 멀티버저닝이 이득인지를 설명할 수 있다.
  • 공급 경로별 구현·검증(선택·정확성·성능) 절차를 적용할 수 있다.

비판적 시각: 한계와 트레이드오프

멀티버저닝은 SIMD 이득이 큰 무거운 경로에만 가치가 있다. 가벼운 함수를 여러 버전으로 나누면 코드 복잡도와 유지보수 비용이 늘어나는 반면, 디스패치·간접 호출로 인한 손해가 이득을 넘을 수 있다. “AVX-512가 있으면 무조건 쓰자"가 아니라, 벤치마크로 해당 워크로드에서 실제로 빨라지는지 확인한 뒤 도입하는 것이 전문가다운 선택이다.

핵심 요약

항목요약
멀티버저닝CPU 기능별 구현을 런타임에 선택; 호환성·성능 동시 확보
비용디스패치 1회·간접 호출; 무거운/적은 호출에 유리
검증타겟별 선택·정확성·성능 벤치 확인

다음 장에서는

컴파일러 내장 함수(intrinsics) 카탈로그, SIMD·원자성 등 사용 시 주의와 대안을 다룹니다.

컴파일러 내장 함수 (챕터 08)