Clang/LLVM 과 Optimization

GCC vs Clang 커널 빌드 차이에서는 LTO를 “Clang 쪽이 ThinLTO를 지원해 더 현실적”이라는 수준으로만 짚고 넘어갔다. 하지만 정작 -O2와 -O3가 실제로 무엇을 다르게 하는지, LTO가 정확히 어떤 상황에서 이득을 주는지는 오브젝트 코드를 직접 들여다보지 않으면 체감하기 어렵다. 이 글에서는 Ubuntu 24.04 + Clang 18 환경에서 최적화 레벨별 코드 크기, 벡터화 여부, LTO의 크로스 파일 인라인 효과를 실제로 컴파일해서 확인한다.

최적화 레벨별 코드 크기

static long fib(int n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);
}

int sum_array(const int *arr, int len) {
    int total = 0;
    for (int i = 0; i < len; i++) {
        total += arr[i];
    }
    return total;
}

int main(void) {
    int data[1000];
    for (int i = 0; i < 1000; i++) data[i] = i;
    printf("sum=%d fib(20)=%ld\n", sum_array(data, 1000), fib(20));
    return 0;
}
for opt in O0 O2 O3 Oz; do
  clang -$opt -c demo.c -o demo_$opt.o
  llvm-size demo_$opt.o
done
-- O0 --   text  data  bss  dec  hex  filename
            464     0    0  464  1d0  demo_O0.o
-- O2 --   text  data  bss  dec  hex  filename
            883     0    0  883  373  demo_O2.o
-- O3 --   text  data  bss  dec  hex  filename
            883     0    0  883  373  demo_O3.o
-- Oz --   text  data  bss  dec  hex  filename
            296     0    0  296  128  demo_Oz.o

이 예제에서는 -O3가 -O2보다 더 크지 않다 — 루프 구조가 단순해 두 레벨이 같은 벡터화 결정을 내렸기 때문이다. -O2가 -O0보다 오히려 커진 이유는 다음 절에서 확인한다. -Oz는 벡터화·언롤링을 포기하고 크기만 줄인 결과다.

-O2부터 시작되는 자동 벡터화

-O0와 -O2로 만든 오브젝트를 llvm-objdump로 역어셈블하면 코드가 커진 이유가 드러난다.

# -O0: 스칼라 루프 그대로
6c: cmpl $0x3e8, -0xfb4(%rbp)
7c: movl -0xfb4(%rbp), %ecx
89: movl %ecx, -0xfb0(%rbp,%rax,4)
9f: jmp  0x6c

# -O2: SSE 레지스터로 4개씩 묶어 처리(paddd/movdqa)
f5:  paddd  %xmm1, %xmm9
fa:  movdqa %xmm0, -0x70(%rsp,%rax,4)
190: paddd  -0x90(%rsp,%rax,4), %xmm0
199: paddd  -0x80(%rsp,%rax,4), %xmm1

data[i] = i 초기화 루프와 sum_array의 합산 루프 모두 paddd(packed add)로 4개 정수를 한 번에 처리한다. 이 SIMD 명령어들을 풀어내는 언롤링 코드 자체가 -O0의 단순 루프보다 바이너리 크기를 키운다.

LTO의 실질적 효과 — 크로스 파일 인라인

LTO의 가치는 파일 하나짜리 예제로는 드러나지 않는다. 서로 다른 번역 단위(translation unit)에 있는 함수를 컴파일러가 얼마나 넘나들며 최적화하는지가 핵심이므로, 파일을 두 개로 나눠 확인한다.

int square(int x) {
    return x * x;
}
#include <stdio.h>
int square(int x);
int main(void) {
    int r = 0;
    for (int i = 0; i < 5; i++) r += square(i);
    printf("r=%d\n", r);
    return 0;
}
# 일반 빌드 (LTO 없음)
clang -O2 -c math_util.c -o math_util.o
clang -O2 -c main.c -o main.o
clang -O2 math_util.o main.o -o app_no_lto

# ThinLTO 빌드
clang -O2 -flto=thin -c math_util.c -o math_util_lto.o
clang -O2 -flto=thin -c main.c -o main_lto.o
clang -O2 -flto=thin -fuse-ld=lld math_util_lto.o main_lto.o -o app_thinlto

두 바이너리의 main을 역어셈블해 square 호출이 남아있는지 비교한다.

$ llvm-objdump -d --disassemble-symbols=main app_no_lto
callq 0x1140 <square>   # i=0
callq 0x1140 <square>   # i=1
callq 0x1140 <square>   # i=2
callq 0x1140 <square>   # i=3
callq 0x1140 <square>   # i=4

$ llvm-objdump -d --disassemble-symbols=main app_thinlto
1720: pushq %rax
1728: movl  $0x1e, %esi     ; 0x1e = 30 = 0+1+4+9+16
172f: callq 0x1790 <printf@plt>
1737: retq

LTO 없이는 square 호출 5번이 그대로 남지만, ThinLTO를 적용하면 다른 파일에 있던 square까지 컴파일러가 들여다보고 루프 전체를 상수 30(0x1e)으로 접어버린다. 단일 파일 컴파일에서는 애초에 컴파일러가 못 보는 범위이므로 LTO 없이는 나올 수 없는 결과다.

ThinLTO vs Full LTO (커널 설정 기준)

항목CONFIG_LTO_CLANG_THINCONFIG_LTO_CLANG_FULL
최적화 범위모듈 단위 요약 정보로 병렬 최적화전체를 하나로 묶어 전역 최적화
런타임 성능Full보다 낮음이론상 최고
빌드 확장성병렬 빌드 가능, 증분 빌드 빠름시간·메모리 모두 스케일 안 됨
커널 기본값둘 다 experimental, CONFIG_LTO_CLANG(choice)는 기본 비활성

커널 Kconfig에서 LTO는 choice 항목으로 정의되어 있고 기본값은 비활성이다. Full LTO가 이론상 가장 최적화된 코드를 만들지만 빌드 시간·메모리가 프로젝트 규모에 비례해 폭증하므로, 커널처럼 큰 프로젝트에서는 ThinLTO가 실사용 가능한 선택지로 취급된다.

주의사항

  • Full LTO는 빌드 시간·메모리 사용량이 프로젝트 크기에 비례해 급격히 늘어나므로 커널 규모에서는 비현실적이고, ThinLTO도 여전히 experimental 취급이다
  • -Oz는 코드 크기를 줄이는 대신 벡터화·언롤링을 포기하므로 성능이 떨어질 수 있다 — 플래시 용량이 빠듯한 임베디드 타깃 한정으로 고려한다
  • 이 글의 데모는 수십 줄짜리 예제 기준이라 벡터화·인라인 효과가 과장되어 보일 수 있다. 실제 커널 규모에서는 코드 구조·의존성이 훨씬 복잡해 체감 차이가 다를 수 있다
  • PGO(Profile-Guided Optimization)는 실행 프로파일 수집이 별도로 필요해 이 글에서는 다루지 않았다

마무리

-O2부터 시작되는 자동 벡터화는 코드 크기 증가로 이어질 수 있고, LTO의 진짜 가치는 파일 하나짜리 예제가 아니라 여러 번역 단위에 걸친 함수를 컴파일러가 함께 보는 순간부터 드러난다. 커널처럼 수천 개 파일로 쪼개진 프로젝트에서 LTO를 켜는 이유도 결국 이 크로스 파일 최적화 범위를 넓히기 위해서고, ThinLTO는 그 이득과 빌드 시간 사이의 현실적 타협점이다.

참고

답글 남기기