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_THIN | CONFIG_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는 그 이득과 빌드 시간 사이의 현실적 타협점이다.