TL;DR
- LLVM은 3단 구조: 프론트엔드(C/C++/Rust → IR) → 옵티마이저(IR → IR) → 백엔드(IR → 머신 코드). IR이 공통 언어.
- LLVM IR은 SSA(Static Single Assignment) 기반이다. 각 변수는 정확히 한 번 정의된다. 덕분에 데이터 흐름 분석이 "이 값은 어디서 왔나?"가 유일한 답을 가진다.
- 옵티마이저는 수백 개의 패스(pass)로 구성.
instcombine,gvn(전역 값 번호 매기기),licm(루프 불변 이동),inliner,dce(죽은 코드 제거),sroa(스칼라 대체) 등이 핵심. - 백엔드는 IR → 머신 코드 선택(SelectionDAG 또는 GlobalISel) → 스케줄링 → 레지스터 할당 → 어셈블리.
- LTO (Link-Time Optimization): 링크 시점에 전체 프로그램 IR을 보고 크로스 파일 인라이닝/최적화.
- PGO (Profile-Guided Optimization): 프로파일 데이터를 기반으로 핫 경로 최적화.
- MLIR: LLVM IR의 한계(너무 저수준)를 해결. Dialect 개념으로 멀티레벨 IR — TensorFlow, Affine, GPU, 도메인 특화 IR을 한 프레임워크에.
- 실전 사용자: Clang/Clang++(C/C++), Rustc, Swift, Julia, Kotlin Native, Zig, Crystal, Mojo, TensorFlow XLA, MLIR 기반 PyTorch, WebAssembly Emscripten, CUDA nvcc.
1. LLVM의 역사와 철학
1.1 기원 — Chris Lattner와 2000년
2000년, University of Illinois의 대학원생 Chris Lattner가 석사 논문 프로젝트로 시작했다. 목표: "모든 언어와 모든 타겟을 연결하는 공통 컴파일러 인프라." 이름은 Low Level Virtual Machine — 처음엔 실제 VM을 의도했지만, 이제는 약어로만 남았다.
당시 컴파일러 세계의 문제:
- GCC: 강력하지만 코드베이스가 단단히 결합됨. 새 언어/새 백엔드 추가가 어렵다.
- 각 언어별 컴파일러: Java, C#, OCaml 등이 각자 컴파일러 인프라를 재발명.
- 재사용 불가능: "GCC의 옵티마이저를 Rust에 쓰고 싶다" → 불가능.
Lattner의 아이디어: 컴파일러를 모듈화하자. 언어, IR, 백엔드를 분리하고 라이브러리로 제공.
1.2 Apple의 입양
2005년, Apple이 Lattner를 고용했다. Apple은 GCC에서 벗어나고 싶었다(GPL 부담). Lattner는 LLVM과 Clang(C/C++ 프론트엔드)을 Apple 내부로 가져갔다.
2007년 Apple이 Clang을 공개했다. 그 후:
- macOS/iOS: 기본 컴파일러가 GCC → Clang.
- Swift (2014): LLVM을 백엔드로. Lattner가 설계 주도.
- WebKit/Safari: LLVM을 JIT으로 활용.
Apple의 투자가 LLVM을 "학술 프로젝트"에서 "프로덕션급 표준"으로 바꿨다.
1.3 현재 (2025)
LLVM은 거의 모든 "근대적" 언어의 기본 선택이다:
- C/C++: Clang (gcc와 양대 산맥).
- Rust: rustc의 기본 백엔드.
- Swift: Apple의 공식 컴파일러.
- Julia: 고성능 과학 계산.
- Kotlin Native: JVM 없이 네이티브 바이너리.
- Zig: 자체 프론트엔드 + LLVM 백엔드.
- Mojo: Python 유사 고성능 언어 (LLVM + MLIR).
- CUDA nvcc: Nvidia의 GPU 컴파일러 (LLVM fork).
- AMDGPU: AMD의 GPU 백엔드.
그리고 MLIR이 새롭게 AI/머신러닝 컴파일러의 표준이 되고 있다 (TensorFlow, PyTorch, Triton).
1.4 왜 LLVM인가
Chris Lattner가 강조한 "3대 이점":
- 모듈성: 프론트엔드만 쓰거나, IR만 쓰거나, 백엔드만 쓸 수 있다.
- 재사용: 한 번 쓴 최적화 패스가 모든 언어에 적용.
- 연구 친화적: 새 최적화 아이디어를 손쉽게 테스트.
대가: 복잡도와 메모리 사용량. LLVM은 GCC보다 빌드가 무겁고 바이너리도 크다. 2025년 LLVM 17 소스는 1GB 넘는다.
2. 3단 아키텍처
2.1 전통적 컴파일러
소스코드 → 토큰 → AST → 어셈블리 → 머신 코드
(렉서) (파서) (코드젠)
하나의 큰 프로그램. 언어와 CPU가 긴밀히 결합.
2.2 LLVM의 3단 구조
┌───────────┐ ┌─────────────┐ ┌──────────┐
│ 프론트엔드 │ → │ 옵티마이저 │ → │ 백엔드 │
│ (Clang) │ │ (IR 변환) │ │ (x86_64) │
└───────────┘ └─────────────┘ └──────────┘
↓ ↓ ↓
C/C++/ LLVM IR 생성 어셈블리
ObjC AST IR 최적화 생성
↓ IR 출력
LLVM IR
**IR이 중간 계약(contract)**이다. 언어는 IR만 알면 되고, 백엔드도 IR만 알면 된다.
2.3 N × M에서 N + M으로
LLVM 이전:
- N개 언어 × M개 타겟 = N × M개 컴파일러가 필요.
- 예: C++/ARM, Rust/x86, Swift/RISC-V 각자 따로.
LLVM 이후:
- N개 프론트엔드 + M개 백엔드 = N + M만 필요.
- 새 언어가 생기면 프론트엔드 하나만 추가 → 모든 타겟 자동 지원.
- 새 CPU가 생기면 백엔드 하나만 추가 → 모든 언어 자동 지원.
이 구조적 승리가 LLVM이 GCC를 조용히 추월한 이유다.
2.4 실제 파이프라인
Clang이 hello.cpp를 컴파일할 때:
1. Clang 프론트엔드
- Lexer: 토큰화
- Parser: AST 생성
- Sema (Semantic Analysis): 타입 체크
- CodeGen: AST → LLVM IR
2. LLVM 옵티마이저 (opt)
- 수십 개의 패스 순차 실행
- 각 패스가 IR을 읽고 변환된 IR을 출력
3. LLVM 백엔드 (llc)
- Instruction Selection: IR → MI (Machine Instruction)
- Scheduling: 명령어 순서 결정
- Register Allocation: 가상 레지스터 → 물리 레지스터
- Peephole: 지역 최적화
- Emission: 어셈블리 출력
4. 어셈블러/링커
- MC 레이어가 어셈블리 → 오브젝트
- 시스템 링커가 실행 파일 생성
clang -O2 hello.cpp -o hello 한 명령이 이 모든 단계를 실행한다.
3. LLVM IR — 공통 언어
3.1 설계 목표
LLVM IR은 세 형식으로 존재한다:
- In-memory: C++ 객체 그래프 (
llvm::Module,llvm::Function,llvm::Instruction). - Bitcode: 직렬화된 바이너리 (
.bc). - Textual: 사람이 읽는 IR (
.ll).
모두 동일한 의미. 개발자는 주로 텍스트 IR을 보고, 옵티마이저는 in-memory를 쓰고, 링크/저장엔 bitcode를 쓴다.
3.2 예제: C → IR
C 코드:
int add(int a, int b) {
return a + b;
}
clang -S -emit-llvm add.c로 IR 생성:
define i32 @add(i32 noundef %a, i32 noundef %b) #0 {
entry:
%a.addr = alloca i32, align 4
%b.addr = alloca i32, align 4
store i32 %a, ptr %a.addr, align 4
store i32 %b, ptr %b.addr, align 4
%0 = load i32, ptr %a.addr, align 4
%1 = load i32, ptr %b.addr, align 4
%add = add nsw i32 %0, %1
ret i32 %add
}
i32: 32-bit 정수.%a,%b: 가상 레지스터 (local value).alloca: 스택 할당.store/load: 메모리 쓰기/읽기.add nsw: 정수 덧셈.nsw= "no signed wrap" (오버플로우 시 UB).ret: 반환.
이것은 최적화 전의 IR이다. -O2 적용 후:
define noundef i32 @add(i32 noundef %a, i32 noundef %b) #0 {
entry:
%add = add nsw i32 %b, %a
ret i32 %add
}
옵티마이저가 alloca/store/load를 모두 제거했다. 이것이 SROA(Scalar Replacement of Aggregates)와 Mem2Reg 패스의 힘.
3.3 SSA — LLVM IR의 핵심
IR의 중요 규칙: 각 가상 레지스터는 정확히 한 번 정의된다.
%x = add i32 1, 2 ; %x 정의 (1번만!)
%y = mul i32 %x, 3 ; %x 사용
다시 쓸 수 없다:
%x = add i32 1, 2
%x = mul i32 %x, 3 ; ERROR: %x already defined
같은 이름을 재사용하려면 PHI 노드가 필요 (아래).
3.4 Phi 노드 — SSA의 동반자
분기 이후 어느 값이 도달할지 결정하는 연산.
int max(int a, int b) {
if (a > b) return a;
else return b;
}
IR:
define i32 @max(i32 %a, i32 %b) {
entry:
%cmp = icmp sgt i32 %a, %b
br i1 %cmp, label %if.then, label %if.else
if.then:
br label %if.end
if.else:
br label %if.end
if.end:
%result = phi i32 [ %a, %if.then ], [ %b, %if.else ]
ret i32 %result
}
phi는 "이전 블록이 if.then이었으면 %a, if.else였으면 %b"를 의미한다. 각 블록이 SSA 규칙(한 번 정의)을 유지하면서 분기를 합칠 수 있다.
SSA 형식에서 대부분의 최적화가 훨씬 쉬워진다. GCC도 결국 GIMPLE/SSA로 이동했고, Rust의 MIR, Swift의 SIL 모두 SSA 기반.
3.5 타입 시스템
LLVM IR은 강타입이다:
- 기본:
i1,i8,i16,i32,i64,i128,float,double. - 포인터:
ptr(Opaque Pointers, LLVM 15+). 이전에는i32*,i8*같이 타입을 명시했지만 이제 구분 없음. - 배열:
[10 x i32](10개의 i32 배열). - 구조체:
{ i32, i8, ptr }. - 벡터:
<4 x float>(4개의 float SIMD 표기). - 함수:
i32 (i32, i32)*.
모든 명령어는 타입을 체크한다. 잘못된 타입은 verifier가 잡아낸다.
3.6 Intrinsic 함수
LLVM이 특별히 아는 함수들:
%r = call i32 @llvm.ctpop.i32(i32 %x) ; population count
%r = call i32 @llvm.ctlz.i32(i32 %x, i1 0) ; count leading zeros
%r = call { i32, i1 } @llvm.sadd.with.overflow.i32(i32 %a, i32 %b)
call void @llvm.memcpy.p0.p0.i64(ptr %dst, ptr %src, i64 100, i1 0)
Intrinsic은:
- 실제 함수가 아님 (백엔드가 직접 기계어 생성).
- 아키텍처 특화 기능 (AVX, ARM NEON)에 접근.
- 옵티마이저가 추가 정보 사용 가능 (오버플로우 플래그 등).
4. 최적화 파이프라인
4.1 Pass Manager
LLVM 옵티마이저는 Pass Manager가 Pass를 순차 실행하는 구조다.
IR → [Pass 1] → IR' → [Pass 2] → IR'' → [Pass 3] → ... → 최종 IR
각 Pass는 작고 전문화된 변환:
- Analysis Pass: IR을 읽고 정보 수집 (변환 안 함).
- Transformation Pass: IR을 변환.
opt -passes='...' 명령으로 수동 실행 가능:
opt -passes='instcombine,gvn,loop-rotate,licm' input.ll -S -o output.ll
4.2 주요 변환 패스
SROA (Scalar Replacement of Aggregates): 구조체/배열의 필드를 스칼라로 분해.
; 변환 전
%s = alloca { i32, i32 }
%p1 = getelementptr { i32, i32 }, ptr %s, i32 0, i32 0
store i32 5, ptr %p1
; 변환 후
%s.0 = alloca i32
store i32 5, ptr %s.0
Mem2Reg:
alloca + load/store를 SSA 레지스터로 변환. 위의 add 예제에서 본 그 변환.
InstCombine (Instruction Combining): 작은 algebraic/peephole 최적화의 거대한 모음. 수백 개의 패턴.
; x + 0 → x
; x * 1 → x
; (a + b) - b → a
; x | 0 → x
; (x << 2) << 3 → x << 5
GVN (Global Value Numbering): 같은 값을 가지는 표현식을 찾아 중복 계산 제거.
%a = add i32 %x, %y
%b = add i32 %x, %y ; %a와 같은 값
%c = mul i32 %a, %b
; → %c = mul i32 %a, %a
LICM (Loop Invariant Code Motion): 루프 안의 불변식을 바깥으로.
for (int i = 0; i < n; i++) {
arr[i] = x * y; // x, y는 루프 안에서 변하지 않음
}
// 변환 후:
int tmp = x * y;
for (int i = 0; i < n; i++) {
arr[i] = tmp;
}
DCE (Dead Code Elimination): 결과가 사용되지 않는 명령어 제거. 간단하지만 여러 최적화 후에 실행하면 많은 코드를 제거.
Inliner: 함수 호출을 호출자의 바디에 인라인. LLVM이 가장 많이 사용하는 최적화 중 하나. 비용 모델로 "이 인라인이 이득인가?" 판단:
- 함수 크기 vs 호출 횟수.
- 인라인 후 상수 전파로 추가 최적화 가능성.
- 재귀, 간접 호출은 어려움.
4.3 Pass 순서가 중요하다
최적화 패스들은 상호작용한다. 예:
InstCombine → 상수 접힘 → DCE → 더 많은 죽은 코드 생성 → DCE가 제거
특정 최적화가 다른 최적화의 전제가 되는 경우가 많다. LLVM의 기본 파이프라인(-O2)은 수년에 걸쳐 튜닝된 순서다. 약 100개의 패스가 순차 실행된다.
4.4 "-O0" vs "-O1" vs "-O2" vs "-O3" vs "-Oz"
- -O0: 최적화 없음. 디버깅용.
- -O1: 기본 최적화. 빠른 빌드.
- -O2: 일반적인 릴리스 빌드. 대부분의 성능 최적화.
- -O3: 공격적 최적화. 벡터화, 더 공격적 인라이닝. 바이너리 크기 증가.
- -Os: 크기 우선 (-O2 기반).
- -Oz: 크기 극단적 우선 (Os보다 더).
-O3가 항상 -O2보다 빠르지는 않다. 가끔 인라인 과다로 캐시 미스 → 더 느려진다.
4.5 최적화 가시화
clang -O2 -mllvm -print-after-all input.c 2>&1 | less
각 패스 후의 IR을 출력. 어느 패스가 어떤 변환을 했는지 정확히 확인 가능.
opt -passes='...' input.ll -debug-pass-manager
패스 실행 순서만 보기.
5. 백엔드 — IR에서 머신 코드까지
5.1 파이프라인
LLVM IR
↓ SelectionDAGISel 또는 GlobalISel
SelectionDAG (DAG-based IR)
↓ Instruction Selection
MachineInstr (MI, 타겟 독립)
↓ Scheduling, Register Allocation
MachineInstr (할당 완료)
↓ MC Layer
Assembly / Object File
5.2 SelectionDAG
전통적 LLVM 백엔드의 중간 단계. IR을 **DAG (Directed Acyclic Graph)**로 변환:
add
/ \
load const
|
ptr
그 후 DAG 노드를 머신 명령어로 매칭. 이것은 트리 패턴 매칭 기반이다.
장점: 로컬 최적화에 좋음. 단점: 전역 최적화 못함, 느림, 디버깅 어려움.
5.3 GlobalISel
새로운 대체재. DAG 대신 MachineInstr 위에서 직접 instruction selection을 한다.
3단계:
- IRTranslator: LLVM IR → MI (generic opcodes).
- Legalizer: 타겟이 지원하지 않는 타입/연산을 지원되는 것으로 변환.
- InstructionSelect: Generic MI → 타겟 특화 MI.
장점:
- 빠름 (-O0 빌드에서 SelectionDAG의 2-4배).
- 전역 분석 가능.
- 디버깅 용이.
2025년 기준 AArch64는 GlobalISel이 기본, x86_64는 여전히 SelectionDAG 기본. 점진적 전환 중.
5.4 Register Allocation
가상 레지스터 → 물리 레지스터 매핑. 가장 복잡한 백엔드 단계.
LLVM의 전략:
- Fast: -O0용. 단순 local allocator. 매우 빠르지만 코드 품질 낮음.
- Basic: Priority-based. 중간 품질.
- Greedy: -O2/-O3 기본. 가장 공격적. 그래프 채색 + priority + splitting.
- PBQP: Partitioned Boolean Quadratic Programming. 실험적, 최고 품질이지만 느림.
Greedy 예시:
- 가상 레지스터들을 살아있는 범위(live range)로 분석.
- 우선순위 큐에 넣음 (더 핫한 것이 먼저).
- 하나씩 꺼내 물리 레지스터 할당.
- 충돌이 있으면 "spill"(메모리로 내보냄) 또는 "split"(범위를 쪼갬).
5.5 Instruction Scheduling
명령어 순서를 재배치해 파이프라인을 더 효율적으로. 두 단계:
- Pre-RA: 레지스터 할당 전. 의존성 기반.
- Post-RA: 레지스터 할당 후. 마이크로아키텍처 특화.
Out-of-order CPU(Intel/AMD)에서는 효과가 덜하지만, in-order(많은 ARM Cortex)에서는 큰 차이. GPU 코드 생성에서 특히 중요.
5.6 MC Layer
최종 출력. MC (Machine Code) 레이어는 어셈블리 파서, 인스트럭션 인코더, 오브젝트 파일 작성자를 포함.
llc -filetype=asm input.ll -o output.s # 어셈블리
llc -filetype=obj input.ll -o output.o # 오브젝트
동일 라이브러리가 어셈블리 파싱, 디스어셈블, JIT 코드 방출에도 쓰인다.
6. Clang — C/C++/Objective-C 프론트엔드
6.1 왜 Clang인가
GCC가 오랫동안 지배했던 C/C++ 컴파일러 세계에 2007년 Clang이 등장한 이유:
- 라이브러리화: GCC는 모놀리식. Clang은 모든 것이 라이브러리 → IDE/분석 도구 재사용.
- 에러 메시지: GCC 대비 훨씬 친절하고 색깔 지원.
- 빠른 컴파일: 파싱이 GCC보다 빠름.
- 정확한 표준 준수: C++ 표준을 엄격히 지킴.
6.2 AST → IR
Clang의 AST는 소스 코드와 거의 1:1 매핑된다. 각 표현식, 선언, 타입이 노드.
int x = 5 + 3;
AST:
VarDecl 'x' 'int'
BinaryOperator '+' 'int'
IntegerLiteral 5
IntegerLiteral 3
CodeGen이 이 AST를 순회하며 IR을 방출:
%x = alloca i32
store i32 8, ptr %x ; 이미 상수 접힘됨
실제로 Clang은 "trivially constant expressions"를 프론트엔드에서 이미 접는다. 옵티마이저에 맡기는 것보다 빠름.
6.3 static analyzer
Clang은 Clang Static Analyzer를 포함한다. Null 역참조, use-after-free, 메모리 리크를 컴파일 타임에 감지.
scan-build clang -c my_file.c
또한 clang-tidy, clang-format 같은 도구도 Clang 인프라 위에서 구현됐다. "라이브러리화"의 결실.
6.4 Language Server — clangd
clangd는 LSP(Language Server Protocol) 구현. VS Code, Vim, Emacs에서 C/C++ 자동완성, go-to-definition 등을 제공. 코드베이스가 Clang과 같음 → 완벽한 정확도.
7. LTO — Link-Time Optimization
7.1 문제
전통 컴파일러는 파일 단위로 최적화한다. 각 .c 파일을 따로 컴파일하고 링커가 이어붙인다. 파일 경계를 넘어서는 최적화 불가:
// foo.c
int foo(int x) { return x * 2; }
// main.c
extern int foo(int);
int main() {
int r = foo(5); // foo 인라인 불가 (정의가 없어서)
return r;
}
컴파일러가 main.c를 컴파일할 때 foo의 정의를 모른다 → 일반 call 명령 방출. 성능 손실.
7.2 LTO의 해결
각 .o가 실제 오브젝트 코드 대신 LLVM IR을 저장한다. 링크 시점에 링커(또는 링커 플러그인)가 모든 IR을 합쳐 한 번에 최적화.
clang -flto -c foo.c -o foo.o # foo.o는 IR bitcode 포함
clang -flto -c main.c -o main.o
clang -flto foo.o main.o -o prog # 링크 시점에 전체 최적화
결과:
- 크로스 파일 인라이닝 → foo가 main 안에 인라인됨.
- 죽은 코드 제거 (절대 호출되지 않는 함수).
- 더 정밀한 상수 전파.
7.3 Full LTO vs ThinLTO
Full LTO: 모든 IR을 한 덩어리로 모아 최적화. 품질 최고, 속도 최악. 대형 프로젝트는 수 시간 걸림.
ThinLTO (2015+): Chris Lattner 팀의 개선. 링크 시점에 모듈별로 병렬 최적화 + 작은 요약(summary)만 교환.
ThinLTO 단계:
1. 각 파일을 IR로 컴파일 + 요약 저장.
2. 링커가 요약을 모아 "call graph"를 만듦.
3. 각 모듈을 독립적으로 최적화 (병렬!).
4. 요약에서 결정한 인라이닝/제거 수행.
성능 이득의 80-90%를 얻으면서 빌드 시간은 훨씬 빠름. 2025년에는 Chrome, Firefox, Rust 모두 ThinLTO를 기본으로 쓴다.
7.4 LTO 예시 성과
실측 데이터:
- Chrome: ThinLTO로 바이너리 크기 7% 감소, 시작 시간 8% 개선.
- Rust: ThinLTO로 벤치마크 5-15% 향상.
- LLVM 자체: 6% 빠른 컴파일.
"공짜 성능"에 가깝다. 단, 빌드가 조금 더 무거워지고 메모리 사용량이 많다.
8. PGO — Profile-Guided Optimization
8.1 아이디어
최적화는 "뜨거운 코드(hot code)"에 집중할 때 가장 효과적이다. 하지만 컴파일러는 런타임 동작을 모른다. 프로파일 정보를 제공하자:
- 프로그램 한 번 빌드 (계측 삽입).
- 대표적 워크로드로 실행 → 프로파일 데이터 수집.
- 다시 빌드, 프로파일을 사용해 최적화.
8.2 사용법
# 1. 계측 빌드
clang -fprofile-generate=./pgo-data -O2 app.c -o app
# 2. 실행 (실제 워크로드)
./app typical_input.txt
# 3. 프로파일 병합
llvm-profdata merge -output=app.profdata ./pgo-data/*.profraw
# 4. PGO 적용 빌드
clang -fprofile-use=app.profdata -O2 app.c -o app.optimized
8.3 PGO가 사용하는 정보
- 핫/콜드 함수: 자주 불리는 함수를 인라인, 콜드는 아님.
- 분기 빈도:
if분기에서 어느 쪽이 더 많이 실행되는가 → branch prediction hint. - 루프 trip count: 루프가 평균 몇 번 도는가 → 언롤 크기 결정.
- 클래스 핫스팟: C++ virtual call을 devirtualize.
8.4 성능 이득
- Chromium: PGO로 6-10% 성능 향상.
- Firefox: 전체 벤치마크 3-6% 향상.
- postgres: 약 10% TPS 개선.
- Clang 자체: PGO 적용하면 Clang이 컴파일하는 속도가 15% 빨라짐.
PGO는 "최고 수준의 최적화"로 간주된다. 서버 소프트웨어에서 특히 효과가 크다.
8.5 AutoFDO
수동 계측은 번거롭다. AutoFDO는 프로덕션에서 perf record로 수집한 샘플링 프로파일을 그대로 사용:
perf record -b -o perf.data ./app
create_llvm_prof --binary=./app --profile=perf.data --out=app.prof
clang -fprofile-sample-use=app.prof -O2 app.c -o app
Google은 이 방식으로 수천 개의 내부 바이너리를 자동 PGO 빌드한다.
9. MLIR — 멀티레벨 IR의 등장
9.1 LLVM IR의 한계
LLVM IR은 "너무 저수준"이다. 문제:
- 도메인 정보 손실: GPU 커널, 텐서 연산, 회로 기술의 고수준 개념을 표현할 수 없음.
- 반복된 바퀴 재발명: TensorFlow, Swift, Rust MIR이 각자 "LLVM IR의 위"에 자기 IR을 만들었다.
- 최적화 기회 놓침: 예를 들어 "행렬 곱 A×B는 tiling이 이득"이라는 정보가 LLVM IR 레벨에서는 이미 날라감.
9.2 MLIR의 해결
MLIR (Multi-Level IR) — 2019년 Chris Lattner가 Google에서 설계.
핵심 아이디어: Dialect. 한 IR 안에 여러 "방언"이 공존할 수 있다.
TensorFlow Dialect:
tf.matmul %A, %B
↓ lowering
Linalg Dialect:
linalg.matmul ins(%A, %B) outs(%C)
↓ lowering
Affine Dialect:
affine.for %i = 0 to %M {
affine.for %j = 0 to %N {
...
}
}
↓ lowering
LLVM Dialect:
llvm.load ...
llvm.add ...
각 Dialect는 자기 연산/타입을 정의. Pass는 한 Dialect 내에서 또는 Dialect 간 변환(lowering)을 한다.
9.3 공식 Dialect들
MLIR 업스트림에 포함된 주요 dialect:
- arith: 기본 산술 (add, sub, mul).
- func: 함수 선언/호출.
- scf: Structured Control Flow (for, if, while).
- cf: Control Flow (branch, conditional_branch).
- memref: 메모리 참조 (N차원 배열).
- linalg: 선형대수 (matmul, dot, generic).
- affine: Polyhedral 최적화용.
- vector: SIMD 벡터 연산.
- gpu: GPU 커널 표현.
- llvm: LLVM IR 직접 매핑.
- spirv: Vulkan/OpenCL용.
외부 프로젝트도 자기 dialect를 추가할 수 있다: torch-mlir, mhlo(XLA), triton, Mojo 등.
9.4 MLIR을 쓰는 실제 프로젝트
- TensorFlow: XLA가 MLIR로 전환 중.
- PyTorch: torch-mlir 프로젝트, 컴파일 타깃.
- Triton: OpenAI의 GPU 커널 컴파일러. Llama 추론 엔진 핵심.
- Mojo: Modular의 Python-유사 언어. 고성능 ML용.
- Jax: XLA/StableHLO 기반, MLIR 위에서.
- Swift for TensorFlow: 폐기됐지만 역사적.
- CIRCT: 하드웨어 설계 (Chisel, FIRRTL).
9.5 LLVM vs MLIR
한 줄 요약:
- LLVM IR: 저수준, 일반 목적. CPU/GPU/WASM 백엔드.
- MLIR: 고수준, 도메인 특화. AI 컴파일러, DSL 구축용.
보통 함께 쓴다: 도메인 IR (예: TF) → MLIR dialect 체인 → LLVM IR → 머신 코드.
10. 실전 활용
10.1 Rustc의 LLVM 통합
Rustc의 전체 파이프라인:
Rust 소스
↓ 파서, type checker
HIR (High-level IR)
↓ desugaring
THIR (Typed HIR)
↓ desugaring, pattern checking
MIR (Mid-level IR) ← borrow checker가 여기서 실행
↓ monomorphization, codegen
LLVM IR
↓ LLVM 옵티마이저
LLVM 백엔드
↓
머신 코드
MIR이 추가된 이유: borrow checker에게 더 적합한 형식. LLVM IR로 바로 내리면 borrow 분석이 어렵다.
Rustc는 LLVM의 헤비 유저다. 복잡한 제네릭 → monomorphization → 거대한 IR → 오래 걸리는 최적화. "Rust가 컴파일이 느린" 이유의 큰 부분.
10.2 Cranelift 대안
Cranelift (Wasmtime에서 본) 는 Rust로 짠 대안 백엔드. 특징:
- LLVM보다 10배 빠른 컴파일 (최적화는 덜함).
- Rust로 작성 → 메모리 안전.
- 주로 JIT과 -O0 빌드용.
rustc_codegen_cranelift 프로젝트가 Rust의 디버그 빌드를 Cranelift로 대체. 디버그 빌드 시간 30-50% 단축.
10.3 Swift
Swift는 처음부터 LLVM을 전제로 설계됐다. 특별한 IR:
- AST → SIL (Swift Intermediate Language): Swift 특화 최적화용.
- SIL → LLVM IR: 표준 LLVM 파이프라인으로.
SIL에서 하는 것:
- Ownership 분석 (ARC 최적화).
- Generics specialization.
- Devirtualization.
SIL은 Rust MIR의 디자인 영감이 되기도 했다.
10.4 Julia JIT
Julia는 모든 함수를 JIT 컴파일한다. 최초 호출에 LLVM이 동작:
- 함수가 호출됨.
- Julia는 현재 인자 타입에 대해 특수화 버전 생성.
- Julia AST → Julia IR → LLVM IR.
- LLVM 옵티마이저 + 백엔드.
- 네이티브 코드 생성, 호출.
첫 호출은 느리지만 그 후는 C 수준. 이것이 Julia의 "type stability가 성능의 핵심"인 이유 — 특수화가 효과적이려면 타입이 명확해야.
10.5 WebAssembly Emscripten
Emscripten은 C/C++ → Wasm 컴파일러:
C/C++ 소스 → Clang → LLVM IR → Wasm backend → .wasm
LLVM은 Wasm 백엔드를 공식 지원한다. LLVM의 IR을 Wasm 바이트코드로 컴파일. 덕분에 Clang의 모든 C++ 기능을 Wasm에서 쓸 수 있다.
11. 디버깅과 튜닝
11.1 IR 덤프
# IR 보기
clang -emit-llvm -S input.c -o output.ll
# 최적화 전
clang -emit-llvm -S -O0 input.c -o unoptimized.ll
# 최적화 후
clang -emit-llvm -S -O2 input.c -o optimized.ll
# 각 패스 후
clang -O2 -mllvm -print-after-all input.c 2>&1 | less
11.2 특정 패스만 실행
opt -passes='instcombine,gvn' input.ll -S -o output.ll
"-O2가 뭘 하는지" 궁금하면:
opt -passes='default<O2>' input.ll -S -o output.ll -debug-pass-manager
11.3 Godbolt Compiler Explorer
가장 좋은 LLVM 학습 도구 중 하나. 브라우저에서 코드를 붙여넣으면 어셈블리 또는 LLVM IR을 즉시 확인.
컴파일러 버전, 최적화 레벨, 타겟을 자유롭게 바꿀 수 있다.
11.4 성능 튜닝
컴파일 시간이 너무 길다:
- LTO 끄기 (-fno-lto).
- ThinLTO 대신.
- 인스턴스화가 많은 템플릿 확인 (C++).
- Precompiled headers 사용.
바이너리가 너무 크다:
- -Os / -Oz.
-fdata-sections -ffunction-sections -Wl,--gc-sections(쓰지 않는 섹션 제거).strip(심볼 제거).
실행 시간 최적화:
- -O3 시도 (단, 항상 이득 아님).
- PGO 적용.
- LTO 적용.
-march=native(특정 CPU 특화 코드).
11.5 흔한 함정
Undefined Behavior: LLVM 옵티마이저는 UB를 "불가능한 일"로 가정한다. UB가 있는 코드는 놀랍게 동작 가능.
int *p = NULL;
if (!p) return 0;
*p = 5; // UB
최적화 후:
int *p = NULL;
*p = 5; // 조건이 불가능하다고 가정 → if 제거
UBSan(-fsanitize=undefined)으로 잡아야 한다.
12. LLVM vs GCC
| 항목 | LLVM/Clang | GCC |
|---|---|---|
| 라이선스 | Apache 2.0 (친기업) | GPL |
| 모듈성 | 높음 (라이브러리) | 낮음 (모놀리식) |
| 에러 메시지 | 좋음 (친절) | 개선됨 |
| 빌드 속도 | 대체로 빠름 | 비슷하거나 조금 느림 |
| 바이너리 품질 | 동등 | 동등 |
| 언어 지원 | C/C++/ObjC, 외부 (Rust, Swift) | 풍부 (Fortran, Go, Ada) |
| 플랫폼 | 모든 주류 | 임베디드 쪽 더 다양 |
| 최적화 | 혁신적 (LTO, PGO 리딩) | 성숙, 안정 |
2025년 기준: 신규 프로젝트는 Clang 기본, 기존 레거시는 GCC. Linux 커널은 여전히 GCC지만 Clang 빌드도 공식 지원.
13. 학습 로드맵
1단계: 사용자 관점
- Godbolt로 C/C++ → 어셈블리 관찰.
- LLVM IR 기초 문법.
clang -O2의 마법 감상.
2단계: IR 심층
- LLVM Language Reference 읽기.
- 작은 프로그램을 -O0와 -O2로 비교.
- 각 최적화 패스의 효과 관찰.
3단계: 내부
- "Engineering a Compiler" (Cooper & Torczon) — 교과서.
- LLVM Tutorial (Kaleidoscope) — 토이 언어를 LLVM으로 구현.
- Chris Lattner의 LLVM 관련 발표들.
4단계: 기여
- LLVM 소스 코드 탐색.
good first issue태그된 버그 해결.- MLIR dialect 개발 실험.
책과 자료:
- "LLVM Essentials" — Suyog Sarda.
- "LLVM Cookbook" — Mayur Pandey.
- LLVM Developer Meeting 발표 영상 (YouTube).
- "Compiler Explorer: https://godbolt.org/"
14. 요약 — 한 장 정리
┌─────────────────────────────────────────────────────┐
│ LLVM Cheat Sheet │
├─────────────────────────────────────────────────────┤
│ 3단 구조: │
│ Frontend → IR → Optimizer → IR → Backend → ASM │
│ │
│ LLVM IR 특징: │
│ - SSA (Static Single Assignment) │
│ - Typed (i32, i64, ptr, float, vector, struct) │
│ - Phi 노드로 분기 합침 │
│ - Intrinsic으로 특수 기능 │
│ │
│ 주요 Optimization Pass: │
│ - SROA, Mem2Reg: alloca → SSA │
│ - InstCombine: 패턴 기반 피프홀 │
│ - GVN: 중복 계산 제거 │
│ - LICM: 루프 불변식 이동 │
│ - Inliner: 함수 인라인 │
│ - DCE: 죽은 코드 제거 │
│ - LoopVectorize: 자동 벡터화 │
│ │
│ Backend: │
│ - SelectionDAG: 전통적, 트리 매칭 │
│ - GlobalISel: 새로운, 빠름 │
│ - Register Allocation: Greedy / Fast / PBQP │
│ - MC Layer: 어셈블리/오브젝트 생성 │
│ │
│ LTO: │
│ - Full LTO: 품질 최고, 느림 │
│ - ThinLTO: 병렬, 빠름 (프로덕션 기본) │
│ │
│ PGO: │
│ - Instrument → Run → Profile → Re-build │
│ - 핫 함수 인라이닝, 분기 예측 │
│ - AutoFDO로 샘플링 프로파일 사용 │
│ │
│ MLIR: │
│ - Dialect 기반 멀티레벨 IR │
│ - TF, PyTorch, Triton, Mojo의 기반 │
│ - Lowering으로 점진적 저수준화 │
│ │
│ 실전 사용자: │
│ - Clang (C/C++/ObjC) │
│ - Rust (LLVM 기본 백엔드) │
│ - Swift (SIL → LLVM) │
│ - Julia (JIT) │
│ - Wasm Emscripten │
│ - CUDA nvcc │
│ │
│ 도구: │
│ - opt: IR 최적화 실행 │
│ - llc: IR → 어셈블리 │
│ - llvm-dis / llvm-as: bitcode 변환 │
│ - Godbolt: 웹 기반 탐색 │
└─────────────────────────────────────────────────────┘
15. 퀴즈
Q1. LLVM IR의 SSA (Static Single Assignment)란?
A. 각 가상 레지스터(예: %x)가 정확히 한 번만 정의되는 형식. 같은 이름의 재할당 불가. 분기가 합쳐지는 지점에서는 phi 노드로 "이전 블록이 A면 x1, B면 x2"를 표현. SSA의 이점은 데이터 흐름 분석이 자명해진다는 것 — 어떤 값이 어디서 왔는지 유일한 답이 있어 GVN, DCE, 상수 전파 등 수많은 최적화가 간단해진다. GCC의 GIMPLE, Swift의 SIL, Rust의 MIR도 모두 SSA.
Q2. LLVM의 "3단 아키텍처"가 가져온 혁신은?
A. N × M 문제를 N + M으로 바꿨다. 이전에는 "N개 언어 × M개 타겟"마다 별도 컴파일러가 필요했다. LLVM은 프론트엔드(언어 → IR)와 백엔드(IR → 머신 코드)를 분리해, 새 언어는 프론트엔드 하나만 추가하면 모든 타겟에서 동작하고, 새 CPU는 백엔드 하나만 추가하면 모든 언어가 지원된다. Rust, Swift, Julia, Kotlin Native 등이 이 덕분에 빠르게 생태계를 구축했다.
Q3. ThinLTO가 Full LTO보다 선호되는 이유는?
A. 병렬 처리 가능성과 빌드 시간. Full LTO는 모든 IR을 한 프로세스가 순차 처리해서 대형 프로젝트에서 수 시간이 걸린다. ThinLTO는 링크 시점에 작은 "summary"만 교환해 call graph를 만들고, 각 모듈을 독립적으로 병렬 최적화한다. Full LTO 이득의 80-90%를 얻으면서 빌드는 훨씬 빠르다. 2015년 Chris Lattner 팀이 설계한 후 Chrome, Firefox, Rust의 기본 선택이 됐다.
Q4. PGO (Profile-Guided Optimization)가 얻는 정보는?
A. 런타임 행동에 대한 4가지 주요 정보: (1) 핫/콜드 함수 — 자주 호출되는 함수를 공격적으로 인라인, (2) 분기 빈도 — if의 어느 쪽이 더 자주 실행되는지로 branch prediction hint, (3) 루프 trip count — 평균 반복 횟수로 언롤링 크기 결정, (4) 클래스 핫스팟 — 어느 virtual call이 자주 불리는지로 devirtualize. 평균 3-10% 성능 향상, 서버 소프트웨어에서 특히 효과적.
Q5. MLIR이 LLVM IR로 부족했던 문제를 어떻게 해결했는가?
A. LLVM IR은 너무 저수준이라 도메인 정보가 손실된다. 예를 들어 "행렬 곱"이라는 고수준 정보는 LLVM IR에서 그냥 중첩 루프와 load/store일 뿐이라 tiling 같은 도메인 특화 최적화가 어렵다. MLIR은 Dialect 개념으로 여러 수준의 IR이 공존하게 한다. TensorFlow Dialect → Linalg → Affine → LLVM 순으로 점진적으로 lowering하면서, 각 수준에서 그 레벨에 맞는 최적화를 적용할 수 있다. TensorFlow, PyTorch, Triton, Mojo가 모두 MLIR 기반이다.
Q6. GlobalISel이 SelectionDAG를 대체하려는 이유는?
A. 컴파일 속도와 전역 분석. SelectionDAG는 각 기본 블록을 DAG로 변환해 트리 패턴 매칭으로 명령어를 선택한다. 로컬 최적화에는 좋지만 느리고(-O0에서 큰 오버헤드) 블록 경계를 넘어선 분석이 어렵다. GlobalISel은 DAG 없이 MachineInstr 위에서 직접 instruction selection을 하며 3단계(IRTranslator, Legalizer, InstructionSelect)로 동작. -O0 빌드에서 2-4배 빠르고 전역 분석이 가능하다. AArch64는 이미 기본, x86_64도 점진 전환 중.
Q7. int *p = NULL; if (!p) return 0; *p = 5; 코드에서 LLVM이 if를 제거할 수 있는 이유는?
A. LLVM 옵티마이저는 UB (Undefined Behavior)를 절대 발생하지 않는 일로 가정한다. *p = 5가 UB인데, 옵티마이저는 "UB가 없으니 이 지점은 도달 가능"이라 추론한다. 그런데 p == NULL이면 UB니까 p != NULL임이 증명되고, if (!p) 조건이 항상 거짓 → if 전체 제거. 이것이 "UB를 이용한 공격적 최적화"의 전형적 예. UBSan(-fsanitize=undefined) 같은 sanitizer로만 잡을 수 있다. C/C++의 악명 높은 함정 중 하나.
이 글이 도움이 됐다면 다음 포스트도 확인해 보세요:
- "JIT Compilation V8 & JVM" — LLVM과 다른 접근의 고성능 JIT.
- "Rust Tokio Async Runtime Deep Dive" — rustc가 LLVM을 어떻게 쓰는지 이해하는 배경.
- "WebAssembly Deep Dive" — LLVM의 Wasm 백엔드가 만든 것.
- "Transformer Architecture" — MLIR이 AI 컴파일러에서 어떻게 쓰이는지의 맥락.
현재 단락 (1/574)
- **LLVM**은 3단 구조: **프론트엔드**(C/C++/Rust → IR) → **옵티마이저**(IR → IR) → **백엔드**(IR → 머신 코드). IR이 공통 언어.