MLIR의 핵심 개념과 텍스트 문법, 연산/블록/영역, 타입/속성/프로퍼티, 다이얼렉트, 그리고 IR 버전 관리까지 다루는 언어 레퍼런스입니다.
MLIR(멀티 레벨 IR, Multi-Level IR)는 전통적인 3-주소 SSA 표현(예: LLVM IR 또는 SIL)과 유사하지만, 다면체(polyhedral) 루프 최적화의 개념을 일급 개념으로 도입한 컴파일러 중간 표현입니다. 이 하이브리드 설계는 고수준 데이터플로 그래프와 고성능 데이터 병렬 시스템을 위한 타깃 특화 코드 모두를 표현·분석·변환하는 데 최적화되어 있습니다. 표현 능력을 넘어, 단일하고 연속적인 설계는 데이터플로 그래프에서 고성능 타깃 특화 코드로의 단계적 낮추기(lowering)를 위한 프레임워크를 제공합니다.
이 문서는 MLIR의 핵심 개념을 정의하고 설명하는 건조한 레퍼런스 문서이며, 설계 근거 문서, 용어집, 기타 내용은 별도의 문서로 제공됩니다.
MLIR은 디버깅에 적합한 사람이 읽을 수 있는 텍스트 형태, 프로그램적 변환과 분석에 적합한 메모리 내 형태, 저장과 전송에 적합한 압축된 직렬화 형태의 세 가지 형태로 사용되도록 설계되었습니다. 서로 다른 형태는 모두 동일한 의미적 내용을 기술합니다. 이 문서는 사람이 읽을 수 있는 텍스트 형태를 설명합니다.
MLIR은 노드인 ‘연산(Operation)’과 엣지인 ‘값(Value)’로 이루어진 그래프 유사 데이터 구조를 근본으로 합니다. 각 값은 정확히 하나의 연산 또는 블록 인자의 결과이며, 타입 시스템에 의해 정의되는 ‘값 타입(Value Type)’을 가집니다. 연산은 블록에 포함되고, 블록은 영역(Region)에 포함됩니다. 연산은 포함하는 블록 안에서 순서가 있고, 블록은 포함하는 영역 안에서 순서가 있지만, 이 순서가 주어진 영역 종류)에 따라 의미론적으로 의미가 있을 수도 없을 수도 있습니다. 연산은 영역을 포함할 수도 있어, 계층적 구조를 표현할 수 있습니다.
연산은 함수 정의, 함수 호출, 버퍼 할당, 버퍼의 뷰/슬라이스, 프로세스 생성 같은 고수준 개념부터, 타깃 독립 산술, 타깃 특화 명령, 구성 레지스터, 논리 게이트 같은 저수준 개념에 이르기까지 다양한 개념을 표현할 수 있습니다. 이러한 서로 다른 개념은 MLIR에서 서로 다른 연산으로 표현되며, MLIR에서 사용할 수 있는 연산 집합은 임의로 확장할 수 있습니다.
MLIR은 또한 익숙한 컴파일러 패스 개념을 이용해 연산에 대한 변환을 위한 확장 가능한 프레임워크를 제공합니다. 임의의 연산 집합에 임의의 패스 집합을 가능하게 하면 각 변환이 잠재적으로 모든 연산의 의미를 고려해야 하므로 심각한 확장성 문제가 발생합니다. MLIR은 Trait과 Interface를 사용해 연산의 의미를 추상적으로 기술할 수 있도록 하여, 변환이 보다 일반적으로 연산에 작동할 수 있게 함으로써 이 복잡성을 해결합니다. Trait은 종종 유효한 IR에 대한 검증 제약을 설명하여 복잡한 불변식을 캡처하고 검사할 수 있게 합니다. (참고: Op vs Operation)
MLIR의 한 가지 명백한 용도는 LLVM 코어 IR과 같은 SSA 기반 IR을 표현하는 것으로, 모듈, 함수, 분기, 메모리 할당을 정의하는 적절한 연산 타입과 SSA 지배(dominance) 성질을 보장하는 검증 제약을 선택하는 것입니다. MLIR은 바로 이러한 구조를 정의하는 다이얼렉트 모음을 포함합니다. 그러나 MLIR은 언어 프런트엔드의 추상 구문 트리, 타깃 특화 백엔드에서 생성된 명령, HLS 도구의 회로 등 다른 컴파일러 유사 데이터 구조를 표현할 수 있을 만큼 충분히 일반적이도록 설계되었습니다.
다음은 MLIR 모듈의 예시입니다:
// multiply 커널 구현을 사용해 A*B를 계산하고,
// TensorFlow 연산으로 결과를 출력한다. A와 B의 차원은 부분적으로만
// 알려져 있으며, 두 텐서의 shape은 일치한다고 가정한다.
func.func @mul(%A: tensor<100x?xf32>, %B: tensor<?x50xf32>) -> (tensor<100x50xf32>) {
// dim 연산을 사용해 %A의 안쪽 차원을 계산한다.
%n = memref.dim %A, 1 : tensor<100x?xf32>
// 주소 지정 가능한 "버퍼"를 할당하고 텐서 %A와 %B를 그 안에 복사한다.
%A_m = memref.alloc(%n) : memref<100x?xf32>
bufferization.materialize_in_destination %A in writable %A_m
: (tensor<100x?xf32>, memref<100x?xf32>) -> ()
%B_m = memref.alloc(%n) : memref<?x50xf32>
bufferization.materialize_in_destination %B in writable %B_m
: (tensor<?x50xf32>, memref<?x50xf32>) -> ()
// memref를 인자로 전달하여 @multiply 함수를 호출하고,
// 곱셈 결과를 반환받는다.
%C_m = call @multiply(%A_m, %B_m)
: (memref<100x?xf32>, memref<?x50xf32>) -> (memref<100x50xf32>)
memref.dealloc %A_m : memref<100x?xf32>
memref.dealloc %B_m : memref<?x50xf32>
// 버퍼 데이터를 더 높은 수준의 "tensor" 값으로 적재한다.
%C = memref.tensor_load %C_m : memref<100x50xf32>
memref.dealloc %C_m : memref<100x50xf32>
// TensorFlow 내장 함수를 호출해 결과 텐서를 출력한다.
"tf.Print"(%C){message: "mul result"} : (tensor<100x50xf32>) -> (tensor<100x50xf32>)
return %C : tensor<100x50xf32>
}
// 두 memref를 곱해 결과를 반환하는 함수.
func.func @multiply(%A: memref<100x?xf32>, %B: memref<?x50xf32>)
-> (memref<100x50xf32>) {
// %A의 안쪽 차원을 계산한다.
%n = memref.dim %A, 1 : memref<100x?xf32>
// 곱셈 결과를 위한 메모리를 할당한다.
%C = memref.alloc() : memref<100x50xf32>
// 곱셈 루프 중첩.
affine.for %i = 0 to 100 {
affine.for %j = 0 to 50 {
memref.store 0 to %C[%i, %j] : memref<100x50xf32>
affine.for %k = 0 to %n {
%a_v = memref.load %A[%i, %k] : memref<100x?xf32>
%b_v = memref.load %B[%k, %j] : memref<?x50xf32>
%prod = arith.mulf %a_v, %b_v : f32
%c_v = memref.load %C[%i, %j] : memref<100x50xf32>
%sum = arith.addf %c_v, %prod : f32
memref.store %sum, %C[%i, %j] : memref<100x50xf32>
}
}
}
return %C : memref<100x50xf32>
}
MLIR은 간단하고 모호하지 않은 문법을 가지며, 텍스트 형태로 안정적으로 라운드트립할 수 있습니다. 이는 컴파일러 개발에 중요합니다. 예를 들어 코드가 변환되는 동안의 상태를 이해하고 테스트 케이스를 작성하는 데 필요합니다.
이 문서는 EBNF(Extended Backus-Naur Form)로 문법을 설명합니다.
이 문서에서 사용하는 EBNF 문법은 다음과 같으며, 노란색 상자에 제시합니다.
alternation ::= expr0 | expr1 | expr2 // expr0 또는 expr1 또는 expr2 중 하나.
sequence ::= expr0 expr1 expr2 // expr0 expr1 expr2의 시퀀스.
repetition0 ::= expr* // 0회 이상 반복.
repetition1 ::= expr+ // 1회 이상 반복.
optionality ::= expr? // 0회 또는 1회.
grouping ::= (expr) // 괄호 안의 모든 것은 하나로 그룹화됨.
literal ::= `abcd` // 리터럴 `abcd`와 일치.
코드 예시는 파란색 상자에 제시합니다.
// 위 문법의 예시 사용:
// 다음과 같은 것과 일치: ba, bana, boma, banana, banoma, bomana...
example ::= `b` (`an` | `om`)* `a`
아래의 핵심 문법 생성 규칙이 이 문서에서 사용됩니다:
// TODO: 토큰화(렉싱)와 구문 분석(파싱)의 경계를 더 명확히 하자.
digit ::= [0-9]
hex_digit ::= [0-9a-fA-F]
letter ::= [a-zA-Z]
id-punct ::= [$._-]
integer-literal ::= decimal-literal | hexadecimal-literal
decimal-literal ::= digit+
hexadecimal-literal ::= `0x` hex_digit+
float-literal ::= [-+]?[0-9]+[.][0-9]*([eE][-+]?[0-9]+)?
string-literal ::= `"` [^"\n\f\v\r]* `"` TODO: 이스케이프 규칙 정의 필요
여기에 명시하지 않았지만, MLIR은 주석을 지원합니다. 표준 BCPL 문법을 사용하며, //로 시작해 줄 끝까지가 주석입니다.
// 최상위 생성 규칙
toplevel := (operation | attribute-alias-def | type-alias-def)*
생성 규칙 toplevel은 MLIR 문법을 소비하는 어떤 파서라도 파싱하는 최상위 생성 규칙입니다. 연산, 속성 별칭, 타입 별칭은 최상위에서 선언될 수 있습니다.
문법:
// 식별자
bare-id ::= (letter|[_]) (letter|digit|[_$.])*
bare-id-list ::= bare-id (`,` bare-id)*
value-id ::= `%` suffix-id
alias-name :: = bare-id
suffix-id ::= (digit+ | ((letter|id-punct) (letter|id-punct|digit)*))
symbol-ref-id ::= `@` (suffix-id | string-literal) (`::` symbol-ref-id)?
value-id-list ::= value-id (`,` value-id)*
// 값의 사용, 예: 연산의 피연산자 목록에서.
value-use ::= value-id (`#` decimal-literal)?
value-use-list ::= value-use (`,` value-use)*
식별자는 값, 타입, 함수와 같은 엔터티의 이름이며, MLIR 코드를 작성하는 사람이 정합니다. 식별자는 설명적일 수도(예: %batch_size, @matmul), 자동 생성될 때 비설명적일 수도 있습니다(예: %23, @func42). 값의 식별자 이름은 MLIR 텍스트 파일에 사용할 수 있지만 IR의 일부로 영속되지는 않습니다. 프린터는 이를 %42와 같은 익명 이름으로 출력합니다.
MLIR은 식별자에 시길(예: %, #, @, ^, !)을 접두로 붙여 키워드와 충돌하지 않음을 보장합니다. 어떤 명백한 문맥(예: affine 식)에서는 간결함을 위해 식별자에 접두를 붙이지 않습니다. 새 키워드는 MLIR의 미래 버전에 추가될 수 있으며, 기존 식별자와 충돌 위험이 없습니다.
값 식별자는 자신이 정의된 (중첩된) 영역에 대해서만 스코프 안에 있으며, 그 영역 밖에서는 접근하거나 참조할 수 없습니다. 매핑 함수의 인자 식별자는 매핑 본문에 대해 스코프 안에 있습니다. 특정 연산은 자신의 영역에서 어떤 식별자가 스코프 안에 있는지를 추가로 제한할 수 있습니다. 예를 들어, SSA 제어 흐름 의미가 있는 영역에서 값의 스코프는 SSA 지배(dominance)의 표준 정의에 의해 제한됩니다. 또 다른 예로 IsolatedFromAbove trait는 포함하는 영역에서 정의된 값을 직접 접근하는 것을 제한합니다.
함수 식별자와 매핑 식별자는 심볼과 연관되며 심볼 속성에 따라 스코프 규칙이 달라집니다.
다이얼렉트는 MLIR 생태계와 상호작용하고 확장하는 메커니즘입니다. 다이얼렉트는 새로운 연산, 속성, 타입을 정의할 수 있습니다. 각 다이얼렉트에는 정의된 속성/연산/타입 앞에 붙는 고유한 namespace가 주어집니다. 예를 들어, Affine 다이얼렉트의 네임스페이스는 affine입니다.
MLIR은 메인 저장소 밖의 것들을 포함해 여러 다이얼렉트가 하나의 모듈 내에서 함께 공존하도록 허용합니다. 다이얼렉트는 특정 패스에 의해 생성되고 소비됩니다. MLIR은 서로 다른 다이얼렉트 간, 그리고 동일 다이얼렉트 내에서 변환할 수 있는 프레임워크를 제공합니다.
MLIR이 지원하는 몇 가지 다이얼렉트:
다이얼렉트는 타깃이 타깃 특화 연산을 MLIR에 직접 노출할 수 있는 모듈식 방법을 제공합니다. 예를 들어, 일부 타깃은 LLVM을 경유합니다. LLVM은 특정 타깃 독립 연산(예: 오버플로 검사 포함 덧셈)에 대한 풍부한 내장 함수 집합을 가지며, 지원하는 타깃에 대해서는 타깃 특화 연산(예: 벡터 치환 연산)에 대한 접근을 제공합니다. MLIR에서 LLVM의 intrinsic은 “llvm.”로 시작하는 이름의 연산으로 표현됩니다.
예시:
// LLVM: %x = call {i16, i1} @llvm.sadd.with.overflow.i16(i16 %a, i16 %b)
%x:2 = "llvm.sadd.with.overflow.i16"(%a, %b) : (i16, i16) -> (i16, i1)
이러한 연산은 LLVM을 백엔드로 할 때만 동작하며(예: CPU와 GPU), LLVM에서 정의한 해당 intrinsic의 정의와 일치해야 합니다.
문법:
operation ::= op-result-list? (generic-operation | custom-operation)
trailing-location?
generic-operation ::= string-literal `(` value-use-list? `)` successor-list?
dictionary-properties? region-list? dictionary-attribute?
`:` function-type
custom-operation ::= bare-id custom-operation-format
op-result-list ::= op-result (`,` op-result)* `=`
op-result ::= value-id (`:` integer-literal)?
successor-list ::= `[` successor (`,` successor)* `]`
successor ::= caret-id (`:` block-arg-list)?
dictionary-properties ::= `<` dictionary-attribute `>`
region-list ::= `(` region (`,` region)* `)`
dictionary-attribute ::= `{` (attribute-entry (`,` attribute-entry)*)? `}`
trailing-location ::= `loc` `(` location `)`
MLIR은 다양한 추상화 수준과 계산을 기술할 수 있도록 ‘연산(operation)’이라는 통일된 개념을 도입합니다. MLIR의 연산은 완전히 확장 가능하며(고정된 연산 목록이 없습니다) 응용에 특화된 의미를 가집니다. 예를 들어, MLIR은 타깃 독립 연산, Affine 연산, 타깃 특화 기계 연산을 지원합니다.
연산의 내부 표현은 간단합니다. 연산은 고유한 문자열(예: dim, tf.Conv2d, x86.repmovsb, ppc.eieio 등)로 식별되며, 0개 이상의 결과를 반환하고, 0개 이상의 피연산자를 취하며, 프로퍼티 저장소를 가지고, 속성(Attribute) 딕셔너리를 가지며, 0개 이상의 successor, 0개 이상의 포함된 영역(Region)을 가집니다. 제네릭 출력 형식은 이 모든 요소를 글자 그대로 포함하고, 결과와 피연산자의 타입을 나타내기 위해 함수 타입을 포함합니다.
예시:
// 두 개의 결과를 생성하는 연산.
// %result의 결과들은 <이름> `#` <opNo> 구문으로 접근할 수 있다.
%result:2 = "foo_div"() : () -> (f32, i32)
// 각 결과에 고유한 이름을 부여하는 보기 좋은 형식.
%foo, %bar = "foo_div"() : () -> (f32, i32)
// 두 입력과 프로퍼티에 저장된 "fruit" 속성을 사용하여
// tf.scramble이라는 TensorFlow 함수를 호출한다.
%2 = "tf.scramble"(%result#0, %bar) <{fruit = "banana"}> : (f32, i32) -> f32
// 일부 폐기 가능한(discardable) 속성이 있는 연산을 호출한다.
%foo, %bar = "foo_div"() {some_attr = "value", other_attr = 42 : i64} : () -> (f32, i32)
위의 기본 문법에 더해, 다이얼렉트는 알려진 연산을 등록할 수 있습니다. 이를 통해 해당 다이얼렉트는 연산을 파싱·출력하기 위한 ‘커스텀 어셈블리 형식’을 지원할 수 있습니다. 아래의 연산 집합에서는 두 형식을 모두 보여줍니다.
builtin 다이얼렉트는 MLIR 다이얼렉트에서 폭넓게 적용 가능한 소수의 연산을 정의합니다. 예를 들어, 다이얼렉트 간/내 변환을 단순화하는 보편적 변환 캐스트 연산이 있습니다. 이 다이얼렉트는 또한 유용한 IR 컨테이너를 나타내는 최상위 module 연산을 정의합니다.
문법:
block ::= block-label operation+
block-label ::= block-id block-arg-list? `:`
block-id ::= caret-id
caret-id ::= `^` suffix-id
value-id-and-type ::= value-id `:` type
// 공백이 아닌 이름·타입 목록.
value-id-and-type-list ::= value-id-and-type (`,` value-id-and-type)*
block-arg-list ::= `(` value-id-and-type-list? `)`
블록(Block)은 연산의 리스트입니다. SSACFG 영역에서는 각 블록이 컴파일러의 기본 블록을 나타내며, 블록 내부의 명령은 순서대로 실행되고 종결자(terminator) 연산이 기본 블록 간 제어 흐름 분기를 구현합니다.
블록의 마지막 연산은 반드시 종결자 연산이어야 합니다. 단일 블록을 가진 영역은 둘러싼 연산에 NoTerminator를 부착하여 이 요구 사항을 선택적으로 제거할 수 있습니다. 최상위 ModuleOp는 이러한 Trait를 정의하며 블록 본문에 종결자가 없는 연산의 예시입니다.
MLIR의 블록은 함수와 유사한 방식으로 표기되는 블록 인자 목록을 받습니다. 블록 인자는 개별 연산의 의미에 의해 지정된 값에 바인딩됩니다. 영역의 진입 블록의 블록 인자는 영역의 인자이기도 하며, 해당 인자에 바인딩되는 값은 포함하는 연산의 의미에 의해 결정됩니다. 다른 블록의 블록 인자는 분기와 같이 블록을 successor로 갖는 종결자 연산의 의미에 의해 결정됩니다. 제어 흐름이 있는 영역에서는, MLIR이 이 구조를 활용하여 전통적인 SSA 표현의 PHI 노드가 가지는 복잡한 미묘함 없이 제어 흐름 의존 값을 암묵적으로 전달합니다. 제어 흐름에 의존하지 않는 값은 직접 참조할 수 있으며 블록 인자를 통해 전달할 필요가 없다는 점에 유의하세요.
다음은 분기, 반환, 블록 인자를 보여주는 간단한 함수 예시입니다:
func.func @simple(i64, i1) -> i64 {
^bb0(%a: i64, %cond: i1): // ^bb0에 의해 지배되는 코드에서 %a를 참조할 수 있음
cf.cond_br %cond, ^bb1, ^bb2
^bb1:
cf.br ^bb3(%a: i64) // 분기가 %a를 인자로 전달
^bb2:
%b = arith.addi %a, %a : i64
cf.br ^bb3(%b: i64) // 분기가 %b를 인자로 전달
// ^bb3는 predecessor로부터 %c라는 인자를 받고
// %a와 함께 bb4로 전달한다. %a는 자신의 정의 연산에서
// 직접 참조되며 ^bb3의 인자로 전달되지 않는다.
^bb3(%c: i64):
cf.br ^bb4(%c, %a : i64, i64)
^bb4(%d : i64, %e : i64):
%0 = arith.addi %d, %e : i64
return %0 : i64 // return 역시 종결자.
}
컨텍스트: “블록 인자” 표현은 전통적인 “PHI 노드가 연산인” SSA IR(예: LLVM)에 비해 IR에서 여러 특수 케이스를 제거합니다. 예를 들어 SSA의 병렬 복사 의미가 즉시 드러나며, 함수 인자는 더 이상 특수 케이스가 아닙니다. 진입 블록의 인자가 되기 때문입니다(추가 근거). 또한 블록은 연산으로 표현될 수 없는 근본 개념입니다. 연산에서 정의된 값은 그 연산 바깥에서 접근할 수 없기 때문입니다.
영역은 정렬된 MLIR 블록 목록입니다. 영역 내부의 의미는 IR이 강제하지 않습니다. 대신, 영역을 포함하는 연산이 자신이 포함한 영역의 의미를 정의합니다. MLIR은 현재 두 가지 종류의 영역을 정의합니다. 블록 간 제어 흐름을 기술하는 SSACFG 영역과, 블록 간 제어 흐름을 요구하지 않는 그래프 영역입니다. 연산 내의 영역 종류는 RegionKindInterface를 사용해 기술됩니다.
영역은 이름이나 주소가 없으며, 오직 영역이 포함하는 블록만이 이를 가집니다. 영역은 연산 내에 포함되어야 하며, 타입이나 속성이 없습니다. 영역의 첫 번째 블록은 ‘진입 블록(entry block)’이라고 하는 특수한 블록입니다. 진입 블록의 인자는 영역 자체의 인자이기도 합니다. 진입 블록은 어떤 다른 블록의 successor로 나열될 수 없습니다. 영역의 문법은 다음과 같습니다:
region ::= `{` entry-block? block* `}`
entry-block ::= operation+
함수 본문은 영역의 예시입니다. 블록의 CFG로 구성되며, 다른 유형의 영역에는 없을 수 있는 추가 의미 제약을 가집니다. 예를 들어 함수 본문에서는 블록 종결자가 다른 블록으로 분기하거나 함수에서 반환해야 하며, return 인자의 타입은 함수 시그니처의 결과 타입과 일치해야 합니다. 마찬가지로 함수 인자는 영역 인자의 타입과 개수와 일치해야 합니다. 일반적으로 영역을 가진 연산은 이러한 대응 관계를 임의로 정의할 수 있습니다.
진입 블록(entry block)은 라벨과 인자가 없으며 영역의 시작에 올 수 있는 블록입니다. 이는 영역을 사용해 새로운 스코프를 여는 일반적인 패턴을 가능하게 합니다.
영역은 프로그램의 계층적 캡슐화를 제공합니다. 즉, 참조의 근원이 되는 종결자 연산과 동일한 영역에 있지 않은 블록으로는 참조(예: 분기)할 수 없습니다. 마찬가지로, 영역은 값 가시성에 대한 자연스러운 스코프를 제공합니다. 영역에서 정의된 값은(있다면) 둘러싼 영역으로 빠져나가지 않습니다. 기본적으로 영역 내부의 연산은, 둘러싼 연산의 피연산자가 그 값을 참조하는 것이 합법적이었다면, 영역 밖에서 정의된 값을 참조할 수 있지만, OpTrait::IsolatedFromAbove와 같은 Trait 또는 사용자 정의 검증기를 사용해 이를 제한할 수 있습니다.
예시:
"any_op"(%a) ({ // 만약 %a가 포함하는 영역의 스코프 안에 있다면...
// 여기에서도 %a는 스코프 안에 있다.
%new_value = "another_op"(%a) : (i64) -> (i64)
}) : (i64) -> (i64)
MLIR은 계층을 가로지르는 일반화된 ‘계층적 지배(hierarchical dominance)’ 개념을 정의하며, 이는 어떤 값이 ‘스코프 안’에 있고 특정 연산에서 사용될 수 있는지를 결정합니다. 동일 영역 내에서 한 값이 다른 연산에서 사용될 수 있는지는 영역 종류에 의해 정의됩니다. 한 영역에서 정의된 값은, 동일한 영역 내에서 부모를 가진 연산에서 (그 부모가 그 값을 사용할 수 있는 경우에 한해) 사용할 수 있습니다. 영역의 인자로 정의된 값은 영역에 깊게 포함된 어떤 연산에서도 항상 사용할 수 있습니다. 한 영역에서 정의된 값은 그 영역 밖에서는 절대 사용할 수 없습니다.
MLIR에서는 영역의 제어 흐름 의미가 RegionKind::SSACFG로 표시됩니다. 비공식적으로, 이러한 영역은 영역의 연산이 ‘순차적으로 실행’되는 의미를 지원합니다. 연산이 실행되기 전에, 그 피연산자는 잘 정의된 값을 가집니다. 연산이 실행된 후, 피연산자의 값은 동일하게 유지되며 결과 또한 잘 정의된 값을 가집니다. 연산이 실행된 후, 블록의 종결자 연산에 도달할 때까지 다음 연산이 실행되며, 그 경우 어떤 다른 연산이 실행됩니다. 다음에 실행할 명령을 결정하는 것이 ‘제어 흐름의 전달’입니다.
일반적으로 연산으로 제어 흐름이 전달되면, MLIR은 제어 흐름이 그 연산이 포함하는 영역으로 언제 들어가거나 나올지를 제한하지 않습니다. 그러나 제어 흐름이 영역으로 들어갈 때에는 항상 영역의 첫 번째 블록인 진입 블록에서 시작합니다. 각 블록을 끝내는 종결자 연산은 블록의 successor 블록을 명시적으로 지정함으로써 제어 흐름을 표현합니다. 제어 흐름은 branch 연산처럼 지정된 successor 블록 중 하나로만 전달되거나, return 연산처럼 둘러싼 연산으로 되돌아갈 수 있습니다. successor가 없는 종결자 연산은 둘러싼 연산으로만 제어를 돌려줄 수 있습니다. 이러한 제약 내에서, 종결자 연산의 구체적 의미는 해당 다이얼렉트 연산이 결정합니다. (진입 블록을 제외한) 블록이 어떤 종결자 연산의 successor로 나열되지 않았다면, 해당 블록은 도달 불가능하다고 정의되며 둘러싼 연산의 의미에 영향을 주지 않고 제거될 수 있습니다.
제어 흐름은 항상 진입 블록을 통해 영역으로 들어가지만, 적절한 종결자를 가진 어떤 블록을 통해서든 영역을 빠져나갈 수 있습니다. 표준 다이얼렉트는 이 기능을 활용해 단일 진입·다중 종료(SEME) 영역을 가진 연산을 정의합니다. 이는 영역의 서로 다른 블록을 통과할 수 있으며, return 연산이 있는 어떤 블록을 통해서든 종료할 수 있습니다. 이 동작은 대부분의 프로그래밍 언어에서 함수 본문이 가지는 동작과 유사합니다. 또한, 함수 호출이 반환하지 않는 경우처럼, 제어 흐름이 블록이나 영역의 끝에 도달하지 않을 수도 있습니다.
예시:
func.func @accelerator_compute(i64, i1) -> i64 { // SSACFG 영역
^bb0(%a: i64, %cond: i1): // ^bb0에 의해 지배되는 코드에서 %a를 참조할 수 있음
cf.cond_br %cond, ^bb1, ^bb2
^bb1:
// %value의 정의는 ^bb2를 지배하지 않음
%value = "op.convert"(%a) : (i64) -> i64
cf.br ^bb3(%a: i64) // 분기가 %a를 인자로 전달
^bb2:
accelerator.launch() { // SSACFG 영역
^bb0:
// "accelerator.launch" 아래에 중첩된 코드의 영역으로, %a는 참조할 수 있지만
// %value는 참조할 수 없다.
%new_value = "accelerator.do_something"(%a) : (i64) -> ()
}
// %new_value는 영역 밖에서 참조할 수 없다
^bb3:
...
}
다중 영역을 포함하는 연산은 해당 영역들의 의미도 완전히 결정합니다. 특히, 연산으로 제어 흐름이 전달되면, 포함된 어떤 영역으로든 제어 흐름을 전달할 수 있습니다. 영역에서 제어 흐름이 종료되어 포함하는 연산으로 돌아오면, 포함하는 연산은 동일 연산 내의 어떤 영역으로든 제어 흐름을 전달할 수 있습니다. 연산은 또한 여러 포함된 영역으로 제어 흐름을 동시에 전달할 수 있습니다. 또한 연산은 호출 연산처럼, 주어진 연산이 사용하는 값이나 심볼을 정의한 다른 연산에서 지정된 영역으로 제어 흐름을 전달할 수도 있습니다. 이 제어 전달은 일반적으로 포함하는 영역의 기본 블록을 통한 제어 흐름의 전달과는 독립적입니다.
영역은 예를 들어 영역의 본문을 자신이 생성하는 값으로 “박싱”하여, 클로저를 생성하는 연산을 정의할 수 있게 합니다. 의미는 연산이 정의합니다. 만약 연산이 영역의 비동기 실행을 트리거한다면, 연산 호출자는 직접 사용되는 값이 라이브로 유지되도록 영역 실행을 기다릴 책임이 있습니다.
MLIR에서는 영역의 그래프 유사 의미가 RegionKind::Graph로 표시됩니다. 그래프 영역은 제어 흐름 없이 동시성 의미를 표현하거나, 일반적인 유향 그래프 데이터 구조를 모델링하는 데 적합합니다. 그래프 영역은 근본적인 순서가 없는 결합된 값 간의 순환 관계를 표현하는 데 적합합니다. 예를 들어 그래프 영역의 연산은 값이 데이터 스트림을 나타내는 독립적인 제어 스레드를 표현할 수 있습니다. MLIR에서와 같이, 영역의 구체적 의미는 포함하는 연산이 완전히 결정합니다. 그래프 영역은 기본 블록을 하나만(진입 블록) 포함할 수 있습니다.
근거: 현재 그래프 영역은 임의로 단일 기본 블록으로 제한되어 있지만, 이 제한에 대한 특별한 의미상의 이유는 없습니다. 이 제한은 패스 인프라와 그래프 영역을 처리하는 데 일반적으로 사용되는 패스가 피드백 루프를 적절히 다루기 쉽게 안정화되도록 하기 위해 추가되었습니다. 필요 사례가 생기면 향후 다중 블록 영역을 허용할 수도 있습니다.
그래프 영역에서는 MLIR 연산이 자연스럽게 노드를 나타내고, 각 MLIR 값은 단일 소스 노드와 다수의 목적지 노드를 연결하는 다중 엣지를 나타냅니다. 영역에서 연산의 결과로 정의된 모든 값은 영역 내에서 스코프 안에 있으며, 영역의 다른 어떤 연산도 이를 접근할 수 있습니다. 그래프 영역에서는 블록 내 연산의 순서와 영역 내 블록의 순서가 의미론적으로 의미가 없으며, 비-종결자 연산은 예를 들어 정준화(canonicalization)에 의해 자유롭게 재배치될 수 있습니다. 다중 소스 노드와 다중 목적지 노드를 가진 그래프 같은 다른 종류의 그래프도 그래프 엣지를 MLIR 연산으로 표현하여 나타낼 수 있습니다.
사이클은 그래프 영역의 단일 블록 안에서 발생할 수도 있고 기본 블록 간에 발생할 수도 있습니다.
"test.graph_region"() ({ // 그래프 영역
%1 = "op1"(%1, %3) : (i32, i32) -> (i32) // 허용: 여기서 %1, %3 사용 가능
%2 = "test.ssacfg_region"() ({
%5 = "op2"(%1, %2, %3, %4) : (i32, i32, i32, i32) -> (i32) // 허용: %1, %2, %3, %4 모두 포함 영역에서 정의됨
}) : () -> (i32)
%3 = "op2"(%1, %4) : (i32, i32) -> (i32) // 허용: 여기서 %4 사용 가능
%4 = "op3"(%1) : (i32) -> (i32)
}) : () -> ()
영역의 첫 번째 블록의 인자는 영역의 인자로 취급됩니다. 이러한 인자의 근원은 부모 연산의 의미에 의해 정의됩니다. 이들은 연산 자체가 사용하는 값 중 일부에 대응할 수 있습니다.
영역은 (비어 있을 수도 있는) 값 목록을 생성합니다. 영역 결과와 연산 결과 간의 관계는 연산 의미가 정의합니다.
MLIR의 각 값은 타입 시스템에 의해 정의되는 타입을 가집니다. MLIR은 오픈 타입 시스템(즉, 고정된 타입 목록이 없음)을 가지며, 타입은 응용에 특화된 의미를 가질 수 있습니다. MLIR 다이얼렉트는 표현하는 추상화에 제약 없이 임의의 개수의 타입을 정의할 수 있습니다.
type ::= type-alias | dialect-type | builtin-type
type-list-no-parens ::= type (`,` type)*
type-list-parens ::= `(` `)`
| `(` type-list-no-parens `)`
// 지정된 타입을 가진 값을 참조하는 일반적인 방법.
ssa-use-and-type ::= ssa-use `:` type
ssa-use ::= value-use
// 공백이 아닌 이름·타입 목록.
ssa-use-and-type-list ::= ssa-use-and-type (`,` ssa-use-and-type)*
function-type ::= (type | type-list-parens) `->` (type | type-list-parens)
type-alias-def ::= `!` alias-name `=` type
type-alias ::= `!` alias-name
MLIR은 타입에 대한 명명된 별칭을 정의하는 것을 지원합니다. 타입 별칭은 자신이 정의하는 타입 대신 사용할 수 있는 식별자입니다. 이러한 별칭은 사용 전에 반드시 정의되어야 합니다. 별칭 이름에는 ‘.’을 포함할 수 없습니다. 해당 이름은 다이얼렉트 타입을 위해 예약되어 있기 때문입니다.
예시:
!avx_m128 = vector<4 x f32>
// 원래 타입을 사용.
"foo"(%x) : vector<4 x f32> -> ()
// 타입 별칭을 사용.
"foo"(%x) : !avx_m128 -> ()
연산과 마찬가지로, 다이얼렉트는 타입 시스템에 대한 사용자 정의 확장을 정의할 수 있습니다.
dialect-namespace ::= bare-id
dialect-type ::= `!` (opaque-dialect-type | pretty-dialect-type)
opaque-dialect-type ::= dialect-namespace dialect-type-body
pretty-dialect-type ::= dialect-namespace `.` pretty-dialect-type-lead-ident
dialect-type-body?
pretty-dialect-type-lead-ident ::= `[A-Za-z][A-Za-z0-9._]*`
dialect-type-body ::= `<` dialect-type-contents+ `>`
dialect-type-contents ::= dialect-type-body
| `(` dialect-type-contents+ `)`
| `[` dialect-type-contents+ `]`
| `{` dialect-type-contents+ `}`
| [^\[<({\]>)}\0]+
다이얼렉트 타입은 일반적으로 불투명 형식으로 지정되며, 타입의 내용은 다이얼렉트 네임스페이스와 <>로 감싼 본문 안에서 정의됩니다. 다음 예를 보세요:
// TensorFlow 문자열 타입.
!tf<string>
// 복잡한 구성 요소를 가진 타입.
!foo<something<abcd>>
// 더 복잡한 타입.
!foo<"a123^^^" + bar>
충분히 단순한 다이얼렉트 타입은 일부 구문을 풀어내어 동등하지만 더 가벼운 형식의 더 보기 좋은(pretty) 포맷을 사용할 수 있습니다:
// TensorFlow 문자열 타입.
!tf.string
// 복잡한 구성 요소를 가진 타입.
!foo.something<abcd>
다이얼렉트 타입 정의 방법은 여기를 참고하세요.
builtin 다이얼렉트는 MLIR의 다른 어떤 다이얼렉트에서도 직접 사용할 수 있는 타입 집합을 정의합니다. 이 타입들은 기본 정수 및 부동소수점 타입, 함수 타입 등을 포함합니다.
프로퍼티는 연산(Operation) 클래스에 직접 저장되는 추가 데이터 멤버입니다. 이는 고유(inherent) 속성과 기타 임의의 데이터를 저장하는 방법을 제공합니다. 데이터의 의미는 특정 연산에 특화되어 있으며, Interface의 접근자나 기타 메서드를 통해 노출될 수 있습니다. 프로퍼티는 항상 Attribute로 직렬화할 수 있어 제네릭 방식으로 출력할 수 있습니다.
문법:
attribute-entry ::= (bare-id | string-literal) `=` attribute-value
attribute-value ::= attribute-alias | dialect-attribute | builtin-attribute
속성은 변수 사용이 허용되지 않는 위치에서 연산에 상수 데이터를 지정하는 메커니즘입니다. 예를 들어, cmpi 연산의 비교 프레디킷 등이 있습니다. 각 연산은 속성 딕셔너리를 가지며, 속성 이름 집합을 속성 값에 연결합니다. MLIR의 builtin 다이얼렉트는 (배열, 딕셔너리, 문자열 등과 같은) 풍부한 내장 속성 값을 기본으로 제공합니다. 추가로, 다이얼렉트는 자신의 다이얼렉트 속성 값을 정의할 수 있습니다.
아직 프로퍼티를 채택하지 않은 다이얼렉트의 경우, 연산에 부착된 최상위 속성 딕셔너리는 특별한 의미를 가집니다. 딕셔너리 키에 다이얼렉트 접두사가 있는지 여부에 따라 속성 엔트리는 두 종류로 간주됩니다:
_고유(inherent) 속성_은 연산 의미 정의에 고유합니다. 연산 자체가 이러한 속성의 일관성을 검증해야 합니다. 예시는 arith.cmpi op의 predicate 속성입니다. 이러한 속성의 이름은 다이얼렉트 접두사로 시작하지 않아야 합니다.
_폐기 가능한(discardable) 속성_은 연산 자체 외부에서 의미가 정의되지만 연산의 의미와 호환되어야 합니다. 이러한 속성의 이름은 다이얼렉트 접두사로 시작해야 합니다. 다이얼렉트 접두사가 나타내는 다이얼렉트가 이 속성을 검증해야 합니다. 예시는 gpu.container_module 속성입니다.
속성 값 자체가 딕셔너리 속성일 수 있지만, 이러한 분류는 연산에 부착된 최상위 딕셔너리 속성에만 적용됩니다.
프로퍼티가 채택되면, 최상위 딕셔너리에는 폐기 가능한 속성만 저장되고, 고유 속성은 프로퍼티 저장소에 저장됩니다.
attribute-alias-def ::= `#` alias-name `=` attribute-value
attribute-alias ::= `#` alias-name
MLIR은 속성 값에 대한 명명된 별칭을 정의하는 것을 지원합니다. 속성 별칭은 자신이 정의하는 속성 대신 사용할 수 있는 식별자입니다. 이러한 별칭은 사용 전에 반드시 정의되어야 합니다. 별칭 이름에는 ‘.’을 포함할 수 없습니다. 해당 이름은 다이얼렉트 속성을 위해 예약되어 있기 때문입니다.
예시:
#map = affine_map<(d0) -> (d0 + 10)>
// 원래 속성을 사용.
%b = affine.apply affine_map<(d0) -> (d0 + 10)> (%a)
// 속성 별칭을 사용.
%b = affine.apply #map(%a)
연산과 마찬가지로, 다이얼렉트는 사용자 정의 속성 값을 정의할 수 있습니다.
dialect-namespace ::= bare-id
dialect-attribute ::= `#` (opaque-dialect-attribute | pretty-dialect-attribute)
opaque-dialect-attribute ::= dialect-namespace dialect-attribute-body
pretty-dialect-attribute ::= dialect-namespace `.` pretty-dialect-attribute-lead-ident
dialect-attribute-body?
pretty-dialect-attribute-lead-ident ::= `[A-Za-z][A-Za-z0-9._]*`
dialect-attribute-body ::= `<` dialect-attribute-contents+ `>`
dialect-attribute-contents ::= dialect-attribute-body
| `(` dialect-attribute-contents+ `)`
| `[` dialect-attribute-contents+ `]`
| `{` dialect-attribute-contents+ `}`
| [^\[<({\]>)}\0]+
다이얼렉트 속성은 일반적으로 불투명 형식으로 지정되며, 속성의 내용은 다이얼렉트 네임스페이스와 <>로 감싼 본문 안에서 정의됩니다. 다음 예를 보세요:
// 문자열 속성.
#foo<string<"">>
// 복잡한 속성.
#foo<"a123^^^" + bar>
충분히 단순한 다이얼렉트 속성은 일부 구문을 풀어내어 동등하지만 더 가벼운 형식의 더 보기 좋은(pretty) 포맷을 사용할 수 있습니다:
// 문자열 속성.
#foo.string<"">
다이얼렉트 속성 값을 정의하는 방법은 여기를 참고하세요.
builtin 다이얼렉트는 MLIR의 다른 어떤 다이얼렉트에서도 직접 사용할 수 있는 속성 값 집합을 정의합니다. 이 타입들은 기본 정수 및 부동소수점 값, 속성 딕셔너리, 밀집 다차원 배열 등을 포함합니다.
다이얼렉트는 BytecodeDialectInterface를 통해 버전 관리를 옵트인할 수 있습니다. 바이트코드 파일에 인코딩된 버전을 관리할 수 있도록 다이얼렉트에 몇 가지 훅이 제공됩니다. 버전은 지연 로드되며 입력 IR을 파싱하는 동안 버전 정보를 조회할 수 있고, 버전이 존재하는 각 다이얼렉트가 upgradeFromVersion 메서드를 통해 파싱 이후 IR 업그레이드를 수행할 기회를 제공합니다. 사용자 정의 Attribute와 Type 인코딩 또한 다이얼렉트 버전에 따라 readAttribute와 readType 메서드를 사용해 업그레이드할 수 있습니다.
다이얼렉트가 자체 버전 관리를 모델링하기 위해 인코딩할 수 있는 정보의 종류에는 제한이 없습니다. 현재 버전 관리는 바이트코드 형식에 대해서만 지원됩니다.