MLIR의 다이얼렉트 스택, 점진적 로어링, 재사용 가능한 컴파일러 인프라와 그 한계를 살펴봅니다.
모델을 학습하거나 서빙한다면, MLIR 코드를 한 줄도 작성해 본 적이 없더라도 MLIR에 의존하고 있습니다. XLA는 이를 거쳐 로어링하고, Triton은 이를 기반으로 구축되었으며, Mojo는 MLIR 네이티브이고, Torch-MLIR, IREE, ONNX-MLIR은 각자의 프런트엔드를 이곳으로 흘려보내기 위해 존재합니다. 서로 경쟁하는 스택이 이렇게 많이 하나의 인프라 아래에 놓이게 된 이유는 이해할 가치가 있습니다. 현대 ML 컴파일러가 실제로 어떻게 구축되는지, 그리고 그 경계면이 어디에 있는지를 많이 설명해 주기 때문입니다.
이 글은 MLIR를 둘러봅니다. MLIR가 무엇인지, 이를 다르게 만드는 다이얼렉트라는 개념은 무엇인지, 텐서 연산이 어떻게 점진적으로 기계 코드까지 로어링되는지, 그리고 이 인프라가 제공하는 것과 제공하지 않는 것은 무엇인지 살펴봅니다.
흔한 오해는 MLIR가 “LLVM IR 같은 또 다른 IR”이라는 것입니다. 더 적절하게는 IR _구성 도구 상자_라고 설명할 수 있습니다. LLVM IR는 하나의 고정된 저수준 표현, 즉 대략 SSA 값을 가진 타입 지정 어셈블리입니다. 이는 기계 코드에 이르는 마지막 단계에는 적합한 추상화이지만, 텐서에 대한 행렬 곱에는 부적합합니다. 역사적으로 더 높은 수준의 표현이 필요했던 모든 도메인 컴파일러는 이를 처음부터 직접 만들었습니다. XLA에는 HLO가 있었고, Halide에는 자체 IR가 있었으며, TensorFlow에는 그래프가 있었고, 각각 별도의 패스 관리자, 직렬화 형식, 검증기, 그리고 수많은 버그를 함께 제공했습니다.1
MLIR의 전제는 이러한 표현들이 서로 다른 점보다 공통점이 훨씬 많으며, 공통 부분은 한 번 구축해 공유할 수 있다는 것입니다. SSA, 영역과 블록으로 이루어진 CFG, 패스 인프라, 패턴 재작성기, 위치 추적, 검증이 여기에 해당합니다. 도메인 간 차이는 _다이얼렉트_로 표현됩니다.
MLIR에서 모든 것의 단위는 연산입니다. 연산은 피연산자와 결과(SSA 값), 타입이 지정된 속성 집합(형상이나 스트라이드 같은 컴파일 시간 상수), 그리고 0개 이상의 영역을 가지며, 각 영역은 다시 추가 연산으로 이루어진 블록을 담습니다. 마지막 특성 덕분에 IR는 진정한 다단계 구조가 됩니다. 하나의 연산이 중첩된 전체 계산을 담을 수 있으므로, 고수준 linalg.generic과 저수준 llvm.add는 고도만 다를 뿐 같은 종류의 객체입니다. 모든 연산은 다이얼렉트에 속하며, 다이얼렉트는 관련 연산, 타입, 속성 계열을 위한 네임스페이스일 뿐입니다.
문법으로 표현하면 이 관계는 작고 재귀적입니다. 다이얼렉트는 연산 이름, 타입, 속성이라는 _어휘_를 제공하지만, 연산의 _형태_는 보편적입니다. 아래 형식은 핵심 구조를 보여 주기 위해 MLIR의 텍스트 문법을 단순화한 것이며, 권위 있는 생성 규칙은 언어 참조 문서에 있습니다2:
; A dialect is a namespace that contributes a family of ops, types, and attributes.
dialect ::= (operation-def | type-def | attribute-def)*
; The grammar of an operation is identical across every dialect.
operation ::= [ result ("," result)* "=" ] op-name
"(" [ operand ("," operand)* ] ")"
attr-dict? region*
":" type-signature
op-name ::= dialect-name "." mnemonic ; e.g. linalg.matmul; quoted as a string in the generic form
attr-dict ::= "{" attr-entry ("," attr-entry)* "}" ; compile-time constants
region ::= "{" block+ "}"
block ::= operation+ ; ops hold regions hold ops -> it recurses
result ::= ssa-value ; %C
operand ::= ssa-value ; %A, %B
type ::= "!" dialect-name "." mnemonic | builtin-type ; e.g. !llvm.ptr; builtins: tensor<...>, memref<...>
attr-entry ::= name "=" attribute-value ; e.g. 1 : i64, "foo", #dialect.attr<...>
여기서 두 가지 결과가 나옵니다. 첫째, 연산 이름, 타입, 속성은 모두 다이얼렉트로 네임스페이스화됩니다(linalg.matmul, tensor<128x256xf32>). 따라서 “다이얼렉트를 추가하는 일”은 문법을 건드리지 않고 어휘를 확장하는 일이며, 바로 이것이 주변 인프라가 다이얼렉트에 독립적일 수 있는 이유입니다. 둘째, operation은 region을 포함할 수 있고 region은 추가 operation의 block을 포함하므로 구조는 제한 없이 중첩됩니다. 이 재귀성 덕분에 하나의 연산이 하나의 명령어가 아니라 전체 계산을 담을 수 있습니다.
결정적인 특징은 다이얼렉트가 _공존한다_는 점입니다. 컴파일 중간의 모듈에는 여러 다이얼렉트의 연산이 동시에 존재하는 일이 흔하며, 로어링은 상위 수준 연산을 하위 수준 연산으로 점진적으로 교체하여 마지막에는 대상 다이얼렉트만 남게 하는 과정입니다. ML 파이프라인이 높은 수준에서 낮은 수준으로 거치는 다이얼렉트는 다음과 같습니다.
stablehlo 및 tosa(전체 텐서 연산자 집합), linalg(텐서/버퍼에 대한 구조화된 연산), tensor(값 의미론 텐서 조작).3memref(레이아웃/스트라이드를 갖는 버퍼), affine 및 scf(루프 중첩과 구조화된 제어 흐름), vector(SIMD), arith(스칼라 수학).4llvm(CPU용 LLVM IR로 변환), gpu와 nvvm/rocdl(GPU 대상), spirv(Vulkan/컴퓨트).MLIR가 담고 있는 역량은 한 고도에서 다음 고도로 언제 내려갈지 선택하는 것입니다. 너무 오래 높은 수준에 머물면 하드웨어별 스케줄을 표현할 수 없고, 너무 일찍 낮은 수준으로 내려가면 최적화기에 필요한 구조를 버리게 됩니다.
_공존_이라는 단어는 쉽게 지나치기 쉬우므로, 여기 한 함수에서 네 개의 다이얼렉트를 동시에 사용해 보겠습니다. 아직 로어링된 것은 없습니다. 이 연산들은 하나의 SSA 영역에서 나란히 존재할 뿐이고, 검증기는 이들을 함께 검사합니다.
1
2
3
4
5
6
7
8
9
10
11
12
// One region, four dialects; each line is tagged with the dialect it comes from.
func.func @scale_in_place(%buf: memref<1024xf32>, %a: f32) {
%c0 = arith.constant 0 : index // 1. arith: loop bounds are scalar index constants
%c1 = arith.constant 1 : index // 2. arith
%n = arith.constant 1024 : index // 3. arith
scf.for %i = %c0 to %n step %c1 { // 4. scf: a structured loop that carries a region
%x = memref.load %buf[%i] : memref<1024xf32> // 5. memref: read from an explicit buffer
%y = arith.mulf %x, %a : f32 // 6. arith: the scalar multiply
memref.store %y, %buf[%i] : memref<1024xf32> // 7. memref: write the result back
}
return // 8. func: terminator
}
func, arith, scf, memref라는 네 다이얼렉트가 이질성 문제 없이 하나의 영역에 나타납니다. 이후 패스는 scf.for를 cf 분기로 재작성하거나 본문을 vector 다이얼렉트로 벡터화할 수 있지만, 이 고도에서는 그저 조합될 뿐입니다. 이 조합성이 핵심입니다. 다이얼렉트는 서로 번역하는 별개의 IR가 아니라 하나의 프로그램 안에서 섞어 쓰는 어휘입니다.
graph TD
subgraph FE["Frontends"]
SH["StableHLO / TOSA"]
TM["Torch-MLIR"]
end
SH --> LIN
TM --> LIN
LIN["linalg + tensor<br/>(value semantics)"] -->|bufferize| MR["memref<br/>(explicit buffers)"]
MR --> LOOPS["scf / affine<br/>(loop nests)"]
LOOPS --> VEC["vector<br/>(SIMD)"]
VEC --> TGT{Target}
TGT -->|CPU| LLVMD["llvm dialect → LLVM IR"]
TGT -->|NVIDIA GPU| NVVM["gpu + nvvm → PTX"]
TGT -->|Vulkan| SPV["spirv"]
classDef hi fill:#e1f5ff,stroke:#4a6572,color:#1a1a1a;
classDef mid fill:#fff3cd,stroke:#7a6a00,color:#1a1a1a;
classDef dec fill:#ffe0b2,stroke:#7a5200,color:#1a1a1a;
classDef tgt fill:#f3e5f5,stroke:#6a4a6a,color:#1a1a1a;
class SH,TM,LIN hi;
class MR,LOOPS,VEC mid;
class TGT dec;
class LLVMD,NVVM,SPV tgt;
행렬 곱 하나를 살펴보겠습니다. 맨 위에서는 (값 의미론) 텐서에 대한 하나의 구조화된 연산입니다. 형상은 타입에 포함되어 있고, 메모리나 루프에 대해서는 아직 아무것도 말하지 않습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Shapes are part of the type; this is pure dataflow over tensors.
func.func @matmul(%A: tensor<128x256xf32>,
%B: tensor<256x64xf32>) -> tensor<128x64xf32> {
%zero = arith.constant 0.0 : f32 // 1. scalar identity for the accumulator
%init = tensor.empty() : tensor<128x64xf32> // 2. an uninitialized result value
%acc = linalg.fill ins(%zero : f32)
outs(%init : tensor<128x64xf32>)
-> tensor<128x64xf32> // 3. zero-initialize the accumulator
%C = linalg.matmul // 4. the whole multiply, as one op
ins(%A, %B : tensor<128x256xf32>, tensor<256x64xf32>)
outs(%acc : tensor<128x64xf32>) -> tensor<128x64xf32>
return %C : tensor<128x64xf32>
}
두 가지 변환이 이를 기계 코드에 가까운 형태로 바꿉니다. 첫째, 버퍼화는 값 의미론 tensor를 주소, 레이아웃, 수명을 명시하는 버퍼인 memref로 변환합니다.5 이 시점에 프로그램은 순수 데이터 흐름이기를 멈추고 별칭, 할당을 갖게 되며, 새 값을 반환하는 대신 호출자가 제공한 출력 버퍼에 결과를 써야 합니다. 둘째, 구조화된 linalg.matmul은 명시적인 루프 중첩으로 로어링됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Tensors are now memrefs; the result is written in place into %C.
func.func @matmul(%A: memref<128x256xf32>,
%B: memref<256x64xf32>,
%C: memref<128x64xf32>) {
affine.for %i = 0 to 128 { // 1. the loop nest the matmul implied
affine.for %j = 0 to 64 {
affine.for %k = 0 to 256 {
%a = affine.load %A[%i, %k] : memref<128x256xf32> // 2. explicit loads from buffers
%b = affine.load %B[%k, %j] : memref<256x64xf32>
%c = affine.load %C[%i, %j] : memref<128x64xf32>
%p = arith.mulf %a, %b : f32 // 3. the arithmetic, now scalar
%s = arith.addf %c, %p : f32
affine.store %s, %C[%i, %j] : memref<128x64xf32> // 4. accumulate back into the buffer
}
}
}
return
}
여기서 affine/scf 루프는 타일링되고 vector 다이얼렉트로 벡터화된 뒤, 최종적으로 CPU용 llvm 다이얼렉트 또는 gpu/nvvm 경로로 로어링될 수 있습니다. 본질적으로 동일한 고수준 출발점도 서로 다른 로어링 패스를 선택함으로써 이러한 대상 경로 어디로든 내려보낼 수 있습니다. 하나의 프런트엔드 표현과 다수의 대상 로어링이라는 재사용이야말로 MLIR의 전체 경제적 근거입니다.
이를 실용적으로 만드는 것은 다이얼렉트 자체가 아니라 그 아래의 공유 인프라입니다. MLIR는 다이얼렉트 변환 프레임워크를 제공합니다. 변환 대상(마지막에 어떤 다이얼렉트/연산이 “적법한지”)을 선언하고, 부적법한 연산을 적법한 연산으로 바꾸는 재작성 패턴을 제공하면, 프레임워크가 타입 변환과 피연산자 재매핑을 처리하면서 고정점까지 이를 적용합니다.2 패턴 재작성, 검증기, 위치/디버그 정보 전파, 패스 관리자는 모두 다이얼렉트에 독립적입니다. 대부분의 변환은 수작업 IR 순회가 아니라 국소 재작성 패턴으로 작성됩니다. 여기에는 각 다이얼렉트가 자체 연산을 접고 단순화하기 위해 등록하는 정규화 패턴도 포함되며, 선언적으로(PDL/DRR을 통해) 선언하고 공유 드라이버로 적용할 수 있습니다.6 새 추상화는 연산 정의 집합과 패턴 집합이며, 그 주변의 모든 것은 상속됩니다.
이것이 생태계가 통합된 이유입니다. 각 프로젝트가 맞춤형 컴파일러 중간 단계를 유지하는 대신 하나를 공유합니다.
graph LR
MLIR["MLIR core<br/>ops · dialects · passes · rewriter"]
MLIR --- XLA["XLA / StableHLO"]
MLIR --- TRITON["Triton"]
MLIR --- MOJO["Mojo"]
MLIR --- IREE["IREE"]
MLIR --- TORCH["Torch-MLIR"]
MLIR --- CIRCT["CIRCT (hardware design)"]
classDef core fill:#e1f5ff,stroke:#4a6572,color:#1a1a1a;
classDef leaf fill:#f3e5f5,stroke:#6a4a6a,color:#1a1a1a;
class MLIR core;
class XLA,TRITON,MOJO,IREE,TORCH,CIRCT leaf;
새 하드웨어를 대상으로 삼는 방식도 이와 같습니다. 공급업체는 장치의 연산과 메모리를 모델링하는 다이얼렉트와, 표준 중간 수준 다이얼렉트(linalg, memref, vector)를 그 위로 로어링하는 패스를 추가합니다. 프런트엔드, 최적화기, 도구는 무료로 따라오고, 공급업체는 실리콘에 진정으로 특화된 부분만 작성하면 됩니다.7
MLIR를 생산적으로 만드는 장치에는 분명히 언급할 만한 날카로운 모서리도 있습니다.
올바른 검증을 정의하는 것은 실제 작업입니다. 모든 다이얼렉트는 자신의 연산과 타입에 대한 불변 조건을 강제하는 검증기를 제공하며, 공유 프레임워크는 이들을 모두 함께 실행합니다. 이는 잘못된 형식의 IR를 일찍 잡아내지만, 올바른 검증 규칙을 작성하는 부담은 다이얼렉트를 정의한 사람에게 있습니다. 타입 불변 조건을 잘못 설정하거나 하나를 빠뜨리면, 잘못된 형식의 IR가 검증기를 통과하고 실제로 도입된 곳과 먼 후속 패스에서 충돌로 다시 나타납니다.8
디버깅은 여러 고도에 걸칩니다. 하나의 모듈에 여러 다이얼렉트가 있고 패스 순서가 길면, 잘못된 컴파일이나 성능 저하를 고수준 텐서 연산에서 특정 LLVM 또는 PTX 명령어까지 추적해야 합니다. 위치 추적은 로어링을 거치며 소스 위치를 전파하여 도움을 주지만, 버그는 이를 유발한 패스보다 여러 패스 뒤에서 드러나는 경우가 많습니다. 실용적인 도구는 패스 사이에서 IR를 덤프하는 것(mlir-opt --mlir-print-ir-after-all)과 자동 충돌 재현기입니다. 이들이 있어도 다단계 디버깅은 그 자체로 하나의 역량이며, 이를 개선하는 일은 MLIR 공동체에서 반복적으로 다루는 주제입니다.9
다이얼렉트는 안정적이지만 파이프라인은 그렇지 않습니다. StableHLO는 버전이 지정된 이식성 있는 교환 형식이고, 내장 다이얼렉트도 상당히 안정적입니다. 그러나 변환을 연결하는 _패스 파이프라인_은 그렇지 않습니다. Torch-MLIR에 맞춰 조정된 로어링 파이프라인은 일반적으로 수정 없이 IREE를 통과해 실행되지 않으며, 한 릴리스에서 작동하는 순서가 다음 릴리스에서 깨질 수 있습니다. “MLIR 기반”이라는 말은 다이얼렉트가 조합된다는 뜻이지, 두 MLIR 기반 스택이 처음부터 끝까지 상호 운용된다는 뜻은 아닙니다.10
| 개념 | 무엇인가 | 중요한 이유| 다이얼렉트 | 연산과 타입을 위한 네임스페이스(예: linalg, arith). | 이질성 문제 없이 고수준 및 저수준 연산을 하나의 모듈에서 섞을 수 있게 한다. | | 영역 | 연산은 추가 연산으로 이루어진 중첩 블록을 담을 수 있다. | 하나의 연산이 전체 하위 계산을 담는 다단계 IR를 가능하게 한다(구조화된 연산 안의 루프 본문). | | 버퍼화 | tensor(값 의미론)를 memref(버퍼)로 변환하는 것. | 프로그램이 별칭, 할당, 부수 효과를 갖게 되는 전환 지점이다. | | 다이얼렉트 변환 | 부적법한 연산을 고정점까지 적법한 연산으로 재작성하는 프레임워크. | linalg → vector → llvm 같은 로어링을 구동하는 엔진이다. | |
| --- | --- | --- |
MLIR는 의도적으로 배관입니다. 하드웨어를 _표현_할 장소와 그 위로 규율 있게 _로어링_할 방법을 제공하며, 이는 대단한 일입니다. 하지만 소스 언어는 아니고, 그 자체로 계산을 어디에서 실행할지 결정하거나 버퍼가 연산이 기대하는 메모리 공간에 존재함을 보장하지도 않습니다. 이러한 선택은 그 위에 놓인 프런트엔드 언어, 패스 파이프라인, 비용 모델이 합니다. 오늘날에도 이들은 대부분 타입 시스템 밖에서 표현되며, 확인되더라도 늦게 확인됩니다. MLIR가 이기종 하드웨어 위로 로어링할 장치를 제공하는 것과 그 위의 소스 언어가 배치와 메모리 공간을 대체로 부차적인 문제로 취급하는 것 사이의 이 간극은, 앞으로의 글에서 계속 다룰 반복 주제입니다.
반대편에서도 같은 간극을 볼 수 있습니다. MLIR는 핵심을 의도적으로 작게 유지하므로, 상위 다이얼렉트가 다루지 않는 모든 요구 사항, 프레임워크별 텐서 의미론, 샤딩, 양자화, 대상별 연산은 또 하나의 다이얼렉트가 되는 경향이 있습니다. 공개된 MLIR 사용자 목록은 사실상 트리 내부 및 외부(그리고 흔히 독점적인) 다이얼렉트의 목록처럼 읽히며, 이들 중 다수는 겹치는 문제를 호환되지 않는 방식으로 다시 해결합니다.11 확장성과 파편화는 양쪽에서 본 같은 특성이며, 데이터가 어디에 있고 작업이 어디에서 실행되는지를 포함하여 ML 프로그래밍이 가장 의존하는 추상화는 한 번 표현되기보다 스택마다 재발명되는 것들입니다.
지금으로서는 요점이 더 좁습니다. 이미 사용 중인 스택은 MLIR 스택입니다. 그 고도, 텐서가 버퍼가 되는 곳, 구조가 루프가 되는 곳, 루프가 대상이 되는 곳을 아는 것이 컴파일러가 실제로 모델에 무엇을 하는지 읽는 데 필요한 대부분입니다.
고지: 이 글은 Claude Opus 4.8 모델을 사용하여 생성되었습니다.
MLIR: 도메인 ML을 위한 확장 가능한 컴파일러 인프라 — Lattner, C., Amini, M., Bondhugula, U., Cohen, A., Davis, A., Pienaar, J., Riddle, R., Shpeisman, T., Vasilache, N., Zinenko, O. CGO 2021. (arXiv:2002.11054)↩︎
MLIR 언어 참조 — 연산, 영역, SSA, 다이얼렉트 변환 프레임워크. (mlir.llvm.org/docs/LangRef)↩︎↩︎2
linalg 다이얼렉트 — 텐서와 버퍼에 대한 구조화된 연산 및 그 로어링. (mlir.llvm.org/docs/Dialects/Linalg)↩︎
MLIR 다이얼렉트 — 내장 다이얼렉트(linalg, tensor, memref, affine, scf, vector, arith, gpu, llvm, spirv) 개요. (mlir.llvm.org/docs/Dialects)↩︎
MLIR의 버퍼화 — 값 의미론 텐서를 부수 효과가 있는 memref로 변환. (mlir.llvm.org/docs/Bufferization)↩︎
MLIR의 패턴 기반 IR 재작성 — Springer, M. LLVM 개발자 모임 (2024). 공유 드라이버로 적용되는 정규화 및 선언형(PDL/DRR) 재작성 패턴. (슬라이드 (PDF))↩︎
StableHLO — XLA 계열 컴파일러의 교환 형식으로 사용되는 이식성 있고 버전이 지정된 MLIR 다이얼렉트. (openxla.org/stablehlo)↩︎
다이얼렉트 정의: 연산 — 연산 검증기와 불변 조건을 작성하는 방법 및 후속 패스가 검증된 IR를 가정할 수 있는 이유. 일부 유효하지 않은 경우는 실행 시간에만 드러나므로, 공동체에서는 정적 검증의 한계도 논의해 왔습니다. (연산 정의, RFC: 실행 시간 연산 검증)↩︎
MLIR 디버깅 — 공식 디버깅 안내서(IR를 출력하는 --mlir-print-ir-before/after-all, --mlir-disable-threading, 충돌 재현기)와 패스 간 추적을 개선하려는 지속적인 공동체 노력. (디버깅 안내서, RFC: MLIR 액션, 추적 및 디버깅)↩︎
스택 간 파이프라인 취약성 — 백엔드 경계에서 실패하는 Torch-MLIR 로어링과, 단일 패스를 삽입하거나 재정렬하는 일이 후속 적법화 실패로 연쇄되는 IREE 사례. (torch-mlir#2096, iree#12283)↩︎
MLIR 사용자 — MLIR를 기반으로 구축된 프로젝트의 공개 목록으로, 트리 내부와 외부 다이얼렉트의 폭을 가늠하는 지표입니다. (mlir.llvm.org/users)↩︎