
MLIR 입문 — AI 추론 최적화의 메커니즘을 저수준에서 이해하기 (opt / llc로 NEON 명령 생성하기)
요약
MLIR을 사용하여 AI 추론 최적화의 핵심인 저수준 코드 생성 과정을 설명합니다. LLVM의 opt와 llc 도구를 활용해 스칼라 IR을 AArch64의 NEON SIMD 명령으로 변환하는 메커니즘과 최적화 단계를 다룹니다.
핵심 포인트
- opt를 통한 LLVM IR의 벡터화 최적화 과정 이해
- llc를 이용한 타겟 아키텍처별 어셈블리 생성 원리
- 스칼라 IR이 NEON 명령으로 변환되는 2단계 파이프라인
- AI 추론 성능 향상을 위한 SIMD 매핑의 중요성
서론
이전 기사에서는 linalg dialect로 작성한 vec_add를 mlir-opt로 단계적으로 Lowering 하고, mlir-translate를 통해 스칼라 LLVM IR (vec_add.ll)을 생성하는 과정까지 살펴보았습니다.
AI 추론 최적화에서는 텐서 연산을 하드웨어의 SIMD 명령으로 매핑하는 것이 중요합니다. 이 기사에서는 그 후속 단계로, 생성된 스칼라 IR을 NEON 명령 (AArch64의 SIMD 명령)으로 변환하는 프로세스를 확인합니다. 구체적으로는 다음과 같은 흐름을 따릅니다.
opt로 LLVM IR에 vectorize 최적화를 적용한다llc로 어셈블리를 생성한다- 4가지 조합을 비교하여 NEON 명령이 나오는 조건을 확인한다
- 실측을 통해 어느 정도의 속도 차이가 발생하는지 확인한다
사용하는 도구는 opt (LLVM 미들엔드 최적화)와 llc (백엔드 코드 생성) 두 가지입니다.
동작 환경
이 기사의 커맨드는 LLVM/MLIR 17.0.6에서 동작을 확인했습니다. opt와 llc가 수중에 실행 가능한 상태라면, 버전이 다소 다르더라도 기본적인 흐름은 동일합니다. Pi5 실측은 Raspberry Pi 5 (cortex-a76)에서 진행했습니다.
opt / llc 확인
opt --version # LLVM optimizer
llc --version # LLVM static compiler
도구가 PATH에 설정되어 있다면 LLVM version 17.0.6과 같은 출력을 얻을 수 있습니다. MLIR 개발 환경 (예: Docker 컨테이너) 내에는 이것들이 동봉되어 있습니다.
커맨드의 기본 형태는 다음과 같습니다.
# opt: LLVM IR에 최적화를 적용하여 새로운 IR을 출력
opt -O3 -mtriple=aarch64-linux-gnu -mattr=+neon -S input.ll -o output.ll
# ^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^
...
-mtriple은 타겟 아키텍처 지정, -mattr=+neon은 NEON 확장 명령 세트 활성화, -mcpu는 명령 스케줄링에 사용할 CPU 모델 지정입니다.
LLVM의 2단계 파이프라인: opt와 llc의 역할
LLVM의 코드 생성은 크게 2단계로 나뉩니다.
**opt (미들엔드 최적화)**는 LLVM IR을 입력으로 받아 최적화된 IR을 출력합니다. vectorize, 인라인 확장 (Inline Expansion), 상수 폴딩 (Constant Folding) 등이 여기서 수행됩니다. IR은 어디까지나 IR 상태로 유지되며, 타겟 고유의 명령으로 변환되지 않습니다.
**llc (백엔드 코드 생성)**는 IR을 입력으로 받아 타겟 고유의 어셈블리를 출력합니다. 명령 선택 (Instruction Selection), 레지스터 할당 (Register Allocation), 명령 스케줄링 (Instruction Scheduling)이 여기서 수행됩니다.
NEON 명령이 나오기까지의 흐름을 한마디로 표현하면, **"opt가 IR을 벡터 타입으로 만든 결과를 llc가 NEON 명령에 대응시킨다"**가 됩니다. 어느 한쪽만으로는 완결되지 않습니다.
opt를 통한 vectorize
opt -O0와 opt -O3의 IR 비교
opt -O0 (스칼라 상태)의 루프 핵심부는 다음과 같습니다.
; 스칼라 루프
24:
%25 = phi i64 [ %34, %27 ], [ 0, %2 ]
...
opt -O3 (vectorize 후)에서는 타입이 변합니다.
vector.body:
%index = phi i64 [ %index.next, %vector.body ], [ 0, %vector.memcheck ]
%wide.load = load <4 x float>, ptr %11, align 4 ; 4개 요소를 한꺼번에 로드
...
<4 x float>라는 타입이 등장했습니다. 4개 요소를 한 번에 연산하는 벡터 루프와, 나머지 요소를 처리하기 위한 스칼라 에필로그 (Epilogue) 구조로 되어 있습니다. 또한 2개 벡터 분량의 언롤링 (wide.load와 wide.load2)도 포함되어 있어, 1회 루프 반복으로 8개 요소를 처리합니다.
vectorize 가 발생하는 조건
다음의 3가지 조건이 충족되었을 때 vectorize 가 수행됩니다.
- 루프 내에 반복 간 데이터 의존성이 없음 — 어떤 반복의 결과값을 다음 반복에서 참조하지 않는 것.
vec_add는c[i] = a[i] + b[i]이며 각 반복이 독립적이므로 조건을 만족합니다. 반면, 누적 합c[i] = c[i-1] + a[i]와 같이 이전 반복의 결과에 의존하는 루프는 여러 반복을 동시에 계산할 수 없으므로 vectorize 할 수 없습니다. - 포인터 에일리어싱 (Alias)이 없음을 컴파일러가 증명할 수 있음 —
a,b,c가 동일한 메모리 영역을 가리키지 않는 것. 예를 들어c가a와 겹쳐 있는 경우,c[0]에 대한 쓰기가a[0]의 읽기에 영향을 주어 한꺼번에 처리하면 결과가 달라집니다. 이번 IR에서는vector.memcheck블록에서 실행 시 주소 중첩 여부를 확인하며, 중첩될 경우 스칼라 (Scalar) 폴백 (Fallback)으로 전환합니다. - 타겟 속성에서 SIMD가 활성화됨 (
-mtriple=aarch64-linux-gnu -mattr=+neon)
N=1024 고정 길이가 효과적으로 작용하는 이유
루프 경계가 상수이기 때문에, 컴파일러가 "1024 / 8 = 128회의 벡터 루프 + 나머지 0"이라고 정적으로 계산할 수 있습니다. 나머지가 0임이 확정되어 있으므로, 에필로그 (Epilogue)가 실행되지 않는다는 점도 정적으로 알 수 있습니다.
llc를 통한 어셈블리 생성과 opt × llc 조합 실험
4가지 조합을 생성합니다.
opt -O0 -mtriple=aarch64-linux-gnu -mattr=+neon -S build/vec_add.ll -o build/vec_add.opt0.ll
opt -O3 -mtriple=aarch64-linux-gnu -mattr=+neon -S build/vec_add.ll -o build/vec_add.opt3.ll
for opt_level in 0 3; do
...
4가지 결과
먼저 NEON 명령 (fadd v*.4s)이 나오는지 확인합니다:
| opt | llc | NEON fadd v*.4s |
|---|---|---|
| -O0 | -O0 | 나오지 않음 (스칼라) |
| -O0 | -O3 | 나오지 않음 (스칼라) |
| -O3 | -O0 | 나옴 |
| -O3 | -O3 | 나옴 |
NEON vectorize 가 발생하는지는 opt의 레벨에 의해 결정됩니다.
llc는 IR에 작성된 <4 x float>를 NEON 명령에 대응시킬 뿐이며, IR이 스칼라 상태라면 llc -O3를 사용하더라도 NEON 명령은 나오지 않습니다. 다만, 실행 속도에 미치는 영향은 별개의 문제입니다. 다음 섹션의 실측 결과를 먼저 확인해 주세요.
opt3_llc3의 실제 어셈블리 (Pi5 / cortex-a76)
.LBB0_3: // vector loop
add x11, x10, x9
add x12, x19, x9
...
주목할 점을 정리합니다.
ldp q0, q1: 128-bit 레지스터 (q 레지스터) 2개를 한 명령으로 로드 (8개 요소 분량)fadd v0.4s:.4s접미사가 "4 × float32"를 의미합니다. 한 명령으로 4개 요소를 병렬 가산합니다.vector.memcheck: 입출력 버퍼의 주소가 중첩되지 않은 경우에만 vector loop로 진입합니다. 중첩될 경우 스칼라 epilogue로 폴백합니다.
실측: 얼마나 차이가 나는가
벤치마크 코드는 C 드라이버에서 vec_add를 호출하는 단순한 구성입니다.
// warmup
for (int i = 0; i < 1000; i++) { c = vec_add(a, b); free(c); }
// measure
...
M5 Mac에서의 측정 결과 (N=1024, 10000회 평균)
| opt | llc | SIMD화 (opt) | 스케줄링 (llc) | 평균 시간 | 베이스라인 대비 |
|---|---|---|---|---|---|
| -O0 | -O0 | ✗ | ✗ | 2379 ns | 1.0× |
| ... | |||||
| 참고 (clang 직접 컴파일): |
| 평균 시간 |
|---|---|
| clang -O0 | 2173 ns |
| clang -O3 | 93 ns |
Pi5에서의 측정 결과 (N=1024, 10000회 평균)
| opt | llc | SIMD화 (opt) | 스케줄링 (llc) | 평균 시간 | 베이스라인 대비 |
|---|---|---|---|---|---|
| -O0 | -O0 | ✗ | ✗ | 5784 ns | 1.0× |
| ... | |||||
| 참고 (clang 직접 컴파일): |
| 평균 시간 |
|---|---|
| clang -O0 | 4439 ns |
| clang -O3 | 213 ns |
분석
표가 보여주듯이, NEON 명령어가 생성되는지 여부는 opt 단계에서 결정됩니다.
opt -O0 상태 그대로라면 llc -O3를 적용하더라도 NEON은 생성되지 않습니다. IR에 벡터 타입 (<4 x float>)이 나타나야 비로소 llc가 이를 fadd v0.4s에 대응시킬 수 있습니다. 속도 기여도를 정리하면, opt -O3 (NEON 포함)는 llc -O0 상태에서도 M5에서 10.5배의 성능을 보여주었습니다. SIMD 폭 4배 × 언롤링 (Unrolling) 2배의 이론치는 8배이지만, M5에서는 10.5배로 이를 상회했습니다. 한 가지 원인으로, 비교 대상인 분모인 opt -O0 llc -O0가 "순수한 스칼라 연산 (Scalar operation)"보다 느릴 가능성이 있습니다. -O0는 레지스터 할당 (Register allocation)을 수행하지 않고 변수마다 스택에 store/load 하는 코드를 생성하기 때문에, 스칼라 연산 본래의 비용보다 커지게 되어 배율이 부풀려지는 방향으로 작용합니다. Pi5의 8.8배가 이론치에 가까운 이유에 대해서는 확인되지 않았으나, M5와의 미세한 차이는 아키텍처 특성의 차이에 의한 것으로 추측됩니다.
opt -O3의 IR에서는 wide.load와 wide.load2를 통해 2 벡터 분량이 언롤링되어 있어, 1회 반복당 8개 요소를 처리합니다 (어셈블리의 ldp q0, q1 / fadd v0.4s + fadd v1.4s가 이에 대응). 언롤링 배수가 2배에 머무는 이유는 LLVM의 비용 모델 (Cost model)에 따른 판단이며, vec_add와 같은 메모리 바운드 (Memory-bound) 연산에서는 2배만으로도 로드 유닛 (Load unit)을 충분히 채울 수 있고, 그 이상은 코드 크기만 증가시킨다고 추측됩니다.
opt -O0 llc -O3에서도 M5에서 8.5배의 성능이 나왔습니다. llc가 담당하는 명령 스케줄링 (Instruction scheduling)이나 레지스터 할당 최적화가 효과를 발휘하고 있다고 생각되지만, O0 베이스라인의 느린 속도가 배율을 끌어올린 측면도 있으므로, 순수한 llc 최적화 효과를 분리해내려면 별도의 스칼라 기준과의 비교가 필요합니다.
opt -O3 llc -O3는 두 효과가 중첩되어 M5에서 26.8배를 기록했습니다. clang -O3와의 비교에서는 M5와 Pi5 모두 거의 동등한 결과가 나왔습니다. MLIR을 경유하는 파이프라인에서도 clang에 상응하는 성능을 낼 수 있음을 확인할 수 있습니다.
요약
- NEON 벡터화 (Vectorize)는
opt가 담당한다.llc는 IR에<4 x float>가 나타나야 비로소fadd v0.4s를 생성할 수 있다. - 단,
llc또한 독립적인 명령 스케줄링 및 레지스터 할당 최적화를 가지고 있어, 단독으로도 수 배의 가속화에 기여한다. opt와llc의 최적화는 서로 보완하며, 양쪽 모두-O3로 설정해야 최대 효과를 얻을 수 있다.- 벡터화 조건(반복 간 의존성 없음, 에일리어싱 분석 (Alias analysis), 타겟 속성)을 맞추는 것이 빠른 명령어를 생성하기 위한 전제 조건이다.
다음에는 행렬 곱 (matmul)의 타일링 (Tiling)과 최적화를 다룰 예정입니다. 향후에는 ONNX 모델을 MLIR을 통해 로워링 (Lowering) 및 최적화하는 단계까지 목표로 하고 있습니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기