Transform 방언을 이용해 IR의 특정 연산을 정확히 겨냥하고 변환을 체인으로 연결하는 방법을, 타일링과 퓨전, 실패 전파, 인터프리터 사용, 핸들 무효화와 변경 추적 등 핵심 개념과 예제로 설명합니다.
Transform 방언은 IR에서 특정 연산을 정밀하게 겨냥해 변환을 적용하고, 변환을 체인으로 연결(앞선 변환이 만들어낸 연산에 다시 변환을 적용)할 수 있게 해준다. 이를 위해 변환은 IR 안의 다른 연산들로 표현된다. 이러한 연산들을 담고 있는 IR을 변환 IR(transform IR)이라 부른다. 그리고 실제로 변환의 대상이 되는 IR을 페이로드 IR(payload IR)이라 부른다.
Transform IR 연산은 페이로드 IR의 연산, 값 또는 속성에 연관될 수 있는 값들 위에서 동작한다. 앞의 두 가지 값들을 각각 연산 핸들(operation handle)과 값 핸들(value handle)이라 부른다. 마지막 종류의 값(속성 등으로 전달되는 수치)은 파라미터(parameters)라 부른다.
Transform IR의 적용은 항상 하나의 최상위 연산에서 시작한다. C++ API에서는 이 연산을 applyTransforms 함수에 전달한다. 이 최상위 연산은 다른 변환을 수행해야 하는지, 그리고 어떻게 수행할지를 지정한다. 가장 일반적인 최상위 연산인 transform.named_sequence는, 마치 함수나 매크로처럼, 본문에 나열된 다른 변환 연산을 차례로 적용하기만 한다.
이를 흔한 “fully connected + bias + ReLU” ML 레이어의 간단한 변환 시퀀스로 살펴보자. 이는 행렬 곱을 수행한 뒤 (원소별) 행렬 덧셈을 하고, 0과의 원소별 최댓값(ReLU)을 취하는 것으로 귀결된다. 다음과 같은 IR로 표현할 수 있다.
func.func @fc_relu(%lhs: tensor<512x512xf32>, %rhs: tensor<512x512xf32>,
%bias: tensor<512x512xf32>, %output: tensor<512x512xf32>)
-> tensor<512x512xf32> {
// 행렬-행렬 곱.
%matmul = linalg.matmul ins(%lhs, %rhs: tensor<512x512xf32>, tensor<512x512xf32>)
outs(%output: tensor<512x512xf32>) -> tensor<512x512xf32>
// 원소별 덧셈.
%biased = linalg.elementwise kind=#linalg.elementwise_kind<add>
ins(%matmul, %bias : tensor<512x512xf32>, tensor<512x512xf32>)
outs(%output : tensor<512x512xf32>) -> tensor<512x512xf32>
// 0과의 원소별 max (ReLU).
%c0f = arith.constant 0.0 : f32
%relued = linalg.elementwise kind=#linalg.elementwise_kind<max_signed>
indexing_maps = [affine_map<(d0, d1) -> (d0, d1)>, affine_map<(d0, d1) -> ()>, affine_map<(d0, d1) -> (d0, d1)>]
ins(%biased, %c0f : tensor<512x512xf32>, f32)
outs(%output : tensor<512x512xf32>) -> tensor<512x512xf32>
func.return %relued : tensor<512x512xf32>
}
성능상의 이유로, 우리는 캐시 지역성을 활용하기 위해 이 연산들을 타일링하고 퓨전하고자 한다. 이는 차례로 수행되어야 하는 일련의 변환이므로, 자연스럽게 해당 최상위 transform 연산부터 시작한다.
module attributes {transform.with_named_sequence} {
transform.named_sequence @__transform_main(
%arg0: !transform.any_op,
%arg1: !transform.op<"linalg.matmul">,
%arg2: !transform.op<"linalg.elementwise">):
transform.yield
}
}
이 연산에는 눈여겨볼 점이 몇 가지 있다.
특수한 이름 @__transform_main 과 첫 번째 인자는 인터프리터 패스에 의해 요구된다. 이는 C 프로그램의 진입점이 반드시 main이어야 하고 int (int argc, char** argv) 시그니처를 가질 수 있는 것과 유사하다. 이 인자는 최상위 페이로드 연산에 연관되며, 대개 패스가 적용되는 그 연산이다. applyTransforms 또는 applyNamedSequence를 통해 변환을 프로그램적으로 적용하는 경우에는 이러한 제약이 필요하지 않음을 유의하라.
나머지 엔트리 블록 인자들은 선택 사항이며, 시퀀스에서 유용한 페이로드 속성, 연산 또는 값들과 연관될 수 있다. 이들 역시 applyTransforms를 호출할 때 지정한다. 우리 경우에는 타일링과 퓨전을 수행할 행렬 곱 연산과 원소별 연산들에 관심이 있다.
모든 값 핸들에는 Transform 방언 타입이 있다. 이 타입은 해당 핸들과 연관된 페이로드 IR 엔티티의 특정 속성을 지정한다. 이 예에서 transform.any_op는 핸들이 임의의 페이로드 연산과 연관됨을 나타낸다. 반대로 transform.op<"X">는 핸들이 오직 종류 X의 페이로드 연산과만 연관됨을 나타낸다. 이러한 제약은 핸들과 페이로드의 연관이 생성될 때 검증된다. 최상위 transform 연산의 엔트리 블록 인자에 대해서는 applyTransforms 함수 초기에 이 검증이 수행된다. 제약을 만족하지 못하면 변환 적용은 실패하고 사용자에게 진단 메시지가 출력된다.
마지막으로, 변환이름 시퀀스가 여러 개 존재할 경우 필요한 모든 검증을 트리거하기 위해 transform.with_named_sequence 속성을 가진 모듈로 이 연산을 감싼다.
Transform 방언 인프라는 복구 가능한 오류를 지원하는 독특한 진단 처리 메커니즘을 갖고 있다. 이는 실패 전파 모드를 지정하는 필수 속성을 가진 (이름 없는) 시퀀스 연산을 통해 이해하는 것이 가장 쉽다. 옵션은 두 가지다.
후자의 경우, 시퀀스 내부의 오류가 복구 가능하다고 가정하면 시퀀스를 둘러싼 변환 스크립트가 계속 진행할 수 있다. 지금은 변환 스크립트를 구성하는 단계이므로, 무엇이 적용되지 않았는지 알 수 있도록 실패를 전파하는 편이 바람직하다.
Transform 시퀀스를 점검하거나 디버그하기 위해, transform IR 값에 연관된 다양한 엔티티를 출력할 수 있다. 예를 들어, 핸들과 연관된 연산들을 출력할 수 있다.
transform.sequence failures(propagate) {
^bb0(%arg0: !transform.any_op,
%arg1: !transform.op<"linalg.matmul">,
%arg2: !transform.op<"linalg.elementwise">):
transform.debug.emit_remark_at %arg1, "matmul"
: !transform.op<"linalg.matmul">
transform.debug.emit_remark_at %arg2, "elemwise_binaries"
: !transform.op<"linalg.elementwise">
transform.yield
}
변환을 바꿀 때마다 컴파일러를 다시 빌드하고 싶지 않으므로, Transform 방언 인터프리터 패스를 사용해 이 변환 시퀀스를 페이로드 IR에 적용할 수 있다. 다음 장에서 보겠지만, 사용자 정의 패스를 만들거나 transform 인터프리터를 더 큰 패스에 통합하는 것도 가능하다. 지금은 기존의 테스트 패스를 사용할 수 있다.
$ mlir-opt sequence.mlir --pass-pipeline="
builtin.module(transform-interpreter{
debug-bind-trailing-args=linalg.matmul,linalg.elementwise})"
sequence.mlir 파일에는 페이로드 IR 함수와 transform IR 시퀀스가 동일한 모듈 안에 모두 포함되어 있다. transform 인터프리터 패스는 패스의 앵커 연산에 @__transform_main 이름 시퀀스를 적용한다. 우리 경우, 최상위 시퀀스의 두 개 추가 인자를 각각 모든 linalg.matmul 및 linalg.elementwise 페이로드 연산들과 연관하도록 인터프리터 패스에 옵션도 함께 전달했다. 이 패스를 실행하면 다음과 같은 예상된 remark가 출력된다.
sequence.mlir:5:13: remark: matmul
%matmul = linalg.matmul ins(%lhs, %rhs: tensor<512x512xf32>, tensor<512x512xf32>)
^
sequence.mlir:5:13: note: see current operation: %0 = linalg.matmul ins(%arg0, %arg1 : tensor<512x512xf32>, tensor<512x512xf32>) outs(%arg3 : tensor<512x512xf32>) -> tensor<512x512xf32>
sequence.mlir:9:13: remark: elemwise_binaries
%biased = linalg.elementwise kind=#linalg.elementwise_kind<add>
^
sequence.mlir:9:13: note: see current operation: %1 = linalg.elementwise kind=#linalg.elementwise_kind<add> ins(%0, %arg2 : tensor<512x512xf32>, tensor<512x512xf32>) outs(%arg3 : tensor<512x512xf32>) -> tensor<512x512xf32>
sequence.mlir:15:13: remark: elemwise_binaries
%relued = linalg.elementwise kind=#linalg.elementwise_kind<max_signed>
^
sequence.mlir:15:13: note: see current operation: %2 = linalg.elementwise kind=#linalg.elementwise_kind<max_signed> indexing_maps = [affine_map<(d0, d1) -> (d0, d1)>, affine_map<(d0, d1) -> ()>, affine_map<(d0, d1) -> (d0, d1)>] ins(%1, %cst : tensor<512x512xf32>, f32) outs(%arg3 : tensor<512x512xf32>) -> tensor<512x512xf32>
%arg2는 두 개의 원소별 페이로드 연산 모두와 연관된다는 점에 주의하라. 어떤 핸들이든 엔티티들의 리스트와 연관된다. 개별 변환은 그 리스트의 원소 순서를 신경 쓸 수도 있고, 그렇지 않을 수도 있다.
이제 변환하고자 하는 연산들에 대한 핸들을 얻었으니, 변환을 적용할 준비가 되었다. 먼저 matmul 연산 자체를 타일링해 보자.
module attributes {transform.with_named_sequence} {
transform.named_sequence @__transform_main(
%arg0: !transform.any_op,
%arg1: !transform.op<"linalg.matmul">,
%arg2: !transform.op<"linalg.elementwise">) {
// 실제 타일링 변환은 타일 크기를 속성으로 받는다.
%loop, %tiled = transform.structured.tile_using_forall %arg1
tile_sizes [4, 32]
: (!transform.op<"linalg.matmul">)
-> (!transform.any_op, !transform.any_op)
transform.yield
}
}
이 변환은 문서에 설명된 대로 두 개의 핸들을 반환한다(https://mlir.llvm.org/docs/Dialects/Transform/#transformstructuredtile_using_forall-transformtileusingforallop).
위와 동일한 명령으로 이 변환을 실행하면 기대한 타일링된 코드가 생성된다.
#map = affine_map<(d0) -> (d0 * 4)>
#map1 = affine_map<(d0) -> (d0 * 32)>
#map2 = affine_map<(d0, d1) -> (d0, d1)>
#map3 = affine_map<(d0, d1) -> ()>
func.func @fc_relu(%arg0: tensor<512x512xf32>,
%arg1: tensor<512x512xf32>,
%arg2: tensor<512x512xf32>,
%arg3: tensor<512x512xf32>) -> tensor<512x512xf32> {
%0 = scf.forall (%arg4, %arg5) in (128, 16) shared_outs(%arg6 = %arg3) -> (tensor<512x512xf32>) {
%3 = affine.apply #map(%arg4)
%4 = affine.apply #map1(%arg5)
%extracted_slice = tensor.extract_slice %arg0[%3, 0] [4, 512] [1, 1]
: tensor<512x512xf32> to tensor<4x512xf32>
%extracted_slice_0 = tensor.extract_slice %arg1[0, %4] [512, 32] [1, 1]
: tensor<512x512xf32> to tensor<512x32xf32>
%extracted_slice_1 = tensor.extract_slice %arg6[%3, %4] [4, 32] [1, 1]
: tensor<512x512xf32> to tensor<4x32xf32>
%5 = linalg.matmul
ins(%extracted_slice, %extracted_slice_0
: tensor<4x512xf32>, tensor<512x32xf32>)
outs(%extracted_slice_1 : tensor<4x32xf32>) -> tensor<4x32xf32>
scf.forall.in_parallel {
tensor.parallel_insert_slice %5 into %arg6[%3, %4] [4, 32] [1, 1]
: tensor<4x32xf32> into tensor<512x512xf32>
}
}
%1 = linalg.elementwise kind=#linalg.elementwise_kind<add>
ins(%0, %arg2 : tensor<512x512xf32>, tensor<512x512xf32>)
outs(%arg3 : tensor<512x512xf32>) -> tensor<512x512xf32>
%cst = arith.constant 0.000000e+00 : f32
%2 = linalg.elementwise kind=#linalg.elementwise_kind<max_signed>
indexing_maps = [#map2, #map3, #map2]
ins(%1, %cst : tensor<512x512xf32>, f32)
outs(%arg3 : tensor<512x512xf32>) -> tensor<512x512xf32>
return %2 : tensor<512x512xf32>
}
새 핸들을 생성하는 것 외에, 타일링 transform 연산은 피연산자 핸들을 소비(consumes)한다. 즉, 이 연산 이후 그 핸들은 무효화(invalidated)되어 더는 사용할 수 없다. Transform 연산은 모든 피연산자를 소비(consumed) 또는 읽기 전용(readonly)으로 표시해야 한다. 일반적으로 연산이 피연산자와 연관된 페이로드 연산을 삭제하거나 재생성(삭제 후 유사한 구조로 새로 생성)하는 경우 피연산자를 소비한다. 핸들은 본질적으로 페이로드 연산에 대한 참조이므로, 페이로드가 더 이상 존재하지 않으면 핸들은 덩글링 참조가 되기 때문이다.
정의되지 않은 동작은 발생했을 때 다루기 어렵다. 그래서 Transform 방언 인터프리터는 기본적으로 변환 IR에서 대부분의 정의되지 않은 동작을 감지하는 추가적이고(잠재적으로) 비용이 큰 검사를 수행한다. 예를 들어, 소비된 후의 %arg1 핸들을 사용하려고 하면 정의되지 않은 동작이 발생하는데, 디버그 빌드에서는 어설션으로, 릴리스 빌드에서는 세그멘테이션 폴트로 나타날 수 있다.
module attributes {transform.with_named_sequence} {
transform.named_sequence @__transform_main(
%arg0: !transform.any_op,
%arg1: !transform.op<"linalg.matmul">,
%arg2: !transform.op<"linalg.elementwise">) {
// 실제 타일링 변환은 타일 크기를 속성으로 받는다.
%loop, %tiled = transform.structured.tile_using_forall %arg1 tile_sizes [4, 32]
: (!transform.op<"linalg.matmul">) -> (!transform.any_op, !transform.any_op)
// 무효화된 핸들을 사용하려 하므로 정의되지 않은 동작을 유발한다.
transform.debug.emit_remark_at %arg1, "remark" : !transform.op<"linalg.matmul">
transform.yield
}
}
하지만 인터프리터에서 고비용 검사를 활성화한 경우, 보기 좋은 진단이 출력된다.
sequence.mlir:28:3: error: op uses a handle invalidated by a previously executed transform op
transform.debug.emit_remark_at %mm, "elemwise_binaries" : !transform.any_op
^
sequence.mlir:26:9: note: handle to invalidated ops
%mm = transform.cast %matmul : !transform.op<"linalg.matmul"> to !transform.any_op
^
sequence.mlir:27:19: note: invalidated by this transform op that consumes its operand #0 and invalidates all handles to payload IR entities associated with this operand and entities nested in them
%loop, %tiled = transform.structured.tile_using_forall %mm tile_sizes [4, 32]
컴파일 타임 성능이 걱정되고 변환 시퀀스가 충분히 안정적이라면, 패스에 disable-expensive-checks 옵션을 제공하거나 applyTransforms에 전달하는 TransformOptions에서 해당 플래그를 설정하여 인터프리터의 고비용 검사를 비활성화해 성능을 개선할 수 있다.
transform.cast 같은 일부 연산은 피연산자를 소비하지 않는다(연관된 연산을 삭제하지 않기 때문). 그렇다면 그 피연산자를 대신 사용하려 하면 어떻게 될까?
module attributes {transform.with_named_sequence} {
transform.named_sequence @__transform_main(
%arg0: !transform.any_op,
%arg1: !transform.op<"linalg.matmul">,
%arg2: !transform.op<"linalg.elementwise">) {
// 연산이 두 타입 모두에 대해 호환되기만 하면 한 타입에서 다른 타입으로 캐스팅할 수 있다.
// 이는 "앨리어싱" 핸들을 만든다.
%casted = transform.cast %arg1 : !transform.op<"linalg.matmul">
to !transform.any_op
// 실제 타일링 변환은 타일 크기를 속성으로 받는다.
%loop, %tiled = transform.structured.tile_using_forall %arg1
tile_sizes [4, 32]
: (!transform.op<"linalg.matmul">)
-> (!transform.any_op, !transform.any_op)
// 피연산자를 소비하면, 소비된 핸들과 동일한 페이로드 연산(또는 그 안에
// 중첩된 페이로드 연산)과 연관된 다른 모든 핸들도 무효화된다.
transform.debug.emit_remark_at %casted, "remark"
: !transform.any_op
transform.yield
}
}
%arg1과 %casted는 같은 페이로드 연산을 참조한다. 참조의 비유를 확장하면, 이 참조들은 서로 앨리어싱한다. 자연스럽게, 페이로드 연산이 삭제되면 그에 대한 모든 참조는 덩글링이 된다. 핸들도 마찬가지다. 실제로 피연산자를 소비하면, 피연산자 핸들은 물론 동일한 페이로드 연산 중 어떤 것과도 연관된 모든 다른 핸들이 무효화된다. 페이로드 IR 관점은 재귀적이다. 삭제된 연산 안에 중첩된 페이로드 연산과 연관된 핸들도 무효화된다(연산을 삭제하면 그 영역과 포함된 모든 연산도 함께 삭제되기 때문이다). 고비용 검사 모드 역시 이 경우를 잡아낼 수 있다.
sequence.mlir:28:3: error: op uses a handle invalidated by a previously executed transform op
transform.debug.emit_remark_at %matmul, "elemwise_binaries" : !transform.op<"linalg.matmul">
^
sequence.mlir:21:29: note: handle to invalidated ops
^bb0(%root: !transform.any_op, %matmul: !transform.op<"linalg.matmul">, %elemwise: !transform.op<"linalg.elementwise">):
^
sequence.mlir:27:19: note: invalidated by this transform op that consumes its operand #0 and invalidates all handles to payload IR entities associated with this operand and entities nested in them
%loop, %tiled = transform.structured.tile_using_forall %mm tile_sizes [4, 32]
변환 시퀀스로 돌아와서, 우리는 행렬 곱을 타일링했지만 원소별 연산들도 타일링하고 퓨전하고 싶다. 구조적 연산 패러다임에서 이를 수행하는 전형적인 방법은, 비순환 데이터플로 그래프에서 마지막 연산을 타일링한 후 그 피연산자들을 생성하는 연산들을 점진적으로 퓨전하는 것이다. 이렇게 하면 모든 연산을 명시적으로 타일링할 필요가 없어지고, 퓨전이 크기를 맞추고 필요하면 재계산을 주입할 수 있다. 따라서 matmul 연산을 타일링하는 대신, 체인의 마지막 연산을 타일링하고, 앞선 연산들을 타일링으로 생성된 루프에 퓨전하자.
module attributes {transform.with_named_sequence} {
transform.named_sequence @__transform_main(
%arg0: !transform.any_op,
%arg1: !transform.op<"linalg.matmul">,
%arg2: !transform.op<"linalg.elementwise">) {
// %arg2 핸들은 두 개의 원소별 연산과 연관되어 있으므로,
// 두 개의 핸들로 분할해 두 번째 원소별 연산만을 대상으로 삼는다.
%add, %max = transform.split_handle %arg2
: (!transform.op<"linalg.elementwise">)
-> (!transform.any_op, !transform.any_op)
// 실제 타일링 변환은 타일 크기를 속성으로 받는다.
// 타일링 중 생성된 루프에 대한 핸들을 생산한다.
%tiled_max, %loop =
transform.structured.tile_using_forall %max tile_sizes [8, 32]
: (!transform.any_op) -> (!transform.any_op, !transform.any_op)
// 이제 다른 연산들을 루프에 퓨전할 수 있다. 여기서는 연산들을 하나씩
// 퓨전한다. 퓨전되는 연산이 루프 내에서 사용되는 값을 정의해야 하므로,
// 이러한 퓨전의 순서가 중요하다. 또한 "transform.merge_handles"를 사용해
// 모든 연산에 대한 단일 핸들을 얻고, 이를 fuse_into_containing_op에 전달해
// 이 경우 순서를 알아서 처리하게 할 수도 있다.
%add_fused, %loop_0 =
transform.structured.fuse_into_containing_op %add into %loop
: (!transform.any_op, !transform.any_op)
-> (!transform.any_op, !transform.any_op)
%matmul_fused, %loop_1 =
transform.structured.fuse_into_containing_op %arg1 into %loop_0
: (!transform.op<"linalg.matmul">, !transform.any_op)
-> (!transform.any_op, !transform.any_op)
transform.yield
}
}
이로써 원하는 타일링과 퓨전을 달성했다.
마지막으로, 4x4 행렬 곱에 대해 효율적인 마이크로커널 또는 하드웨어 명령(내장 함수로 표현됨)이 존재한다고 가정하자. 이를 위해서는 퓨전된 연산을 원하는 크기로 타일링한 다음, 틀어내기(아웃라인, outline)해야 한다. 그 결과로 생긴 함수 호출은 마이크로커널 호출로 바꿀 수 있다.
module attributes {transform.with_named_sequence} {
transform.named_sequence @__transform_main(
%arg0: !transform.any_op,
%arg1: !transform.op<"linalg.matmul">,
%arg2: !transform.op<"linalg.elementwise">) {
// %arg2 핸들은 두 개의 원소별 연산과 연관되어 있으므로,
// 두 개의 핸들로 분할해 두 번째 원소별 연산만을 대상으로 삼는다.
%add, %max = transform.split_handle %arg2
: (!transform.op<"linalg.elementwise">)
-> (!transform.any_op, !transform.any_op)
// 실제 타일링 변환은 타일 크기를 속성으로 받는다.
// 타일링 중 생성된 루프에 대한 핸들을 생산한다.
%tiled, %loop = transform.structured.tile_using_forall %max
tile_sizes [8, 32]
: (!transform.any_op) -> (!transform.any_op, !transform.any_op)
// 이제 다른 연산들을 루프에 퓨전할 수 있다. 여기서는 연산들을 하나씩
// 퓨전한다. 퓨전되는 연산이 루프 내에서 사용되는 값을 정의해야 하므로,
// 이러한 퓨전의 순서가 중요하다. 또한 "transform.merge_handles"를 사용해
// 모든 연산에 대한 단일 핸들을 얻고, 이를 fuse_into_containing_op에 전달해
// 이 경우 순서를 알아서 처리하게 할 수도 있다.
%add_fused, %loop_0 =
transform.structured.fuse_into_containing_op %add into %loop
: (!transform.any_op, !transform.any_op)
-> (!transform.any_op, !transform.any_op)
%matmul_fused, %loop_1 =
transform.structured.fuse_into_containing_op %arg1 into %loop_0
: (!transform.op<"linalg.matmul">, !transform.any_op)
-> (!transform.any_op, !transform.any_op)
// 원하는 크기를 얻기 위해 다시 타일링한다. 이번에는 "add" 연산을 타일링하고
// matmul을 루프로 퓨전하지만, "max" 연산에는 영향을 주지 않는다.
// 이는 transform 방언으로 정밀하게 목표를 지정할 수 있음을 보여준다.
// 그렇지 않으면 종류가 같은 "add"와 "max"를 구분하기 어렵다.
%tiled_2, %loop_2 =
transform.structured.tile_using_forall %add_fused tile_sizes [4, 4]
: (!transform.any_op) -> (!transform.any_op, !transform.any_op)
%matmul_fused_2, %loop_3 =
transform.structured.fuse_into_containing_op %matmul_fused into %loop_2
: (!transform.any_op, !transform.any_op)
-> (!transform.any_op, !transform.any_op)
// 현재 아웃라인은 루프 같은 영역 보유 연산에 대해서만 구현되어 있으므로,
// 크기 1로 타일링해 바깥쪽 루프를 구체화하고 이를 아웃라인 대상으로 삼는다.
%_, %outline_target =
transform.structured.tile_using_forall %tiled_2 tile_sizes [1]
: (!transform.any_op) -> (!transform.any_op, !transform.any_op)
transform.structured.fuse_into_containing_op %matmul_fused_2
into %outline_target
: (!transform.any_op, !transform.any_op)
-> (!transform.any_op, !transform.any_op)
%func, %call = transform.loop.outline %outline_target
{func_name = "outlined"}
: (!transform.any_op) -> (!transform.any_op, !transform.op<"func.call">)
transform.yield
}
}
이 추가 변환은 중첩 연산에 대한 핸들 무효화도 보여준다. transform.loop.outline 연산은 루프에 대한 핸들을 소비하며, 그 결과 해당 핸들과 그 안에 중첩된 모든 연산(예: %2)에 대한 모든 핸들이 무효화된다. 이 핸들을 사용하려 하면 정의되지 않은 동작을 유발한다. (이 특정 형태의 아웃라인에서는 구현이 영역을 단순히 "이동"만 하고 연산을 재생성하지 않으므로 피연산자를 반드시 소비할 필요는 없지만, 변환 작성자는 핸들을 무효화하기로 선택했다.)
아웃라인 후 퓨전 결과에 접근하려 하면 다음과 같은 오류가 발생한다.
test/Examples/transform/Ch1/invalidation-2.mlir:109:3: error: op uses a handle invalidated by a previously executed transform op
transform.debug.emit_remark_at %outline_target, "outlined loop" : !transform.any_op
^
test/Examples/transform/Ch1/invalidation-2.mlir:102:25: note: handle to invalidated ops
%outline_target, %_ = transform.structured.tile_using_forall %tiled_2 tile_sizes [1]
^
test/Examples/transform/Ch1/invalidation-2.mlir:106:18: note: invalidated by this transform op that consumes its operand #0 and invalidates all handles to payload IR entities associated with this operand and entities nested in them
%func, %call = transform.loop.outline %outline_target {func_name = "outlined"}
^
test/Examples/transform/Ch1/invalidation-2.mlir:24:13: note: ancestor payload op
%biased = linalg.elementwise kind=#linalg.elementwise_kind<add>
^
test/Examples/transform/Ch1/invalidation-2.mlir:24:13: note: nested payload op
%matmul = linalg.matmul ins(%lhs, %rhs: tensor<512x512xf32>, tensor<512x512xf32>)
“add” 원소별 연산은 타일 루프를 생성하는 데 사용되었고, 그 루프가 해당 위치 정보를 갖고 있으므로 페이로드 상위 연산(ancestor)으로 표시된다.
마지막으로, 아웃라인된 함수 호출을 마이크로커널 호출로 대체하고 싶다. 안타깝게도 Transform 방언은 이 변환에 대한 지원을 제공하지 않는다(그리고 호출이 트리 외부의 사용자 정의 연산으로 재작성된다면 제공할 수도 없다). 따라서 새로운 transform 연산을 정의해야 한다. 다음 장에서 그 방법을 설명한다.
Transform 방언은 transform op의 일부로 이루어진 모든 IR 변경을 자동으로 추적한다(구현은 제공된 rewriter를 사용해 IR을 변경해야 한다). 페이로드 연산이 삭제되면, 현재 그 연산과 연관된 모든 핸들에서 자동으로 제거된다. 페이로드 연산이 치환(replace)되면, Transform 방언은 가능한 경우 대체 연산을 찾아 모든 핸들을 그에 맞게 갱신하려고 시도한다. 다중 결과 연산이 여러 연산이 정의한 값들로 치환되거나, 한 연산이 다른 종류의 연산으로 치환되면 오류가 발생한다. 이는 직접 치환된 것이 원래 연산의 계산을 실제로 표현하는지 불분명하기 때문이다. 이 동작을 사용자 정의하는 방법도 있다. 자세한 내용은 transform::TrackingListener 문서를 참조하라.