Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

mlir-egglog

MLIR 소개

LLVM은 프로그래밍 언어와 도구를 만드는 방식에 혁신을 가져온 강력한 컴파일러 인프라 프로젝트입니다. 핵심(Core)에서 LLVM은 모든 프로그래밍 언어의 프런트엔드 컴파일러를 개발하고 다양한 타깃 아키텍처용으로 최적화된 코드를 생성하는 데 사용할 수 있는 모듈식 컴파일러 및 툴체인 기술 모음을 제공합니다.

MLIR은 LLVM 생태계 내의 비교적 새로운 프로젝트로, 현대 하드웨어 가속기와 머신러닝 프레임워크가 안고 있는 과제를 해결하기 위해 설계되었습니다. LLVM IR은 강력하지만 비교적 낮은 수준에서 동작합니다. MLIR은 이러한 개념을 확장하여 여러 수준의 추상화를 지원합니다.

안타깝게도 현대 컴파일러 스택은 RFC, 논문, 메일링 리스트를 읽으며 배워야 하는 경우가 많습니다. 이 튜토리얼에서는 현대 컴파일러 스택과 그 안에서 MLIR이 차지하는 위치, 그리고 MLIR을 Python 머신러닝 생태계에 통합하는 방법을 간결하게 개괄하고자 합니다.

이 시리즈 전체에 걸쳐 GPT-2 트랜스포머의 텐서 연산을 MLIR로 컴파일하는 것을 목표로 나아갑니다. IR과 메모리에서 시작하여 Python 특수화와 GPU 컴파일을 살펴본 다음, MLIR의 Python 인터페이스를 사용해 작은 컴파일러를 만듭니다. 완성된 동반 예제는 CPU와 NVIDIA GPU에서 네이티브 추론을 실행하고, 그 출력을 NumPy 모델과 비교하여 검증합니다.

9–14부에서는 tiny-mlir 동반 프로젝트를 사용합니다. 이 프로젝트는 타입이 지정된 텐서 표현식, 구조화된 MLIR 구성, 컴파일러 변환, 사전 학습된 모델의 추론을 다룹니다. 13부에서는 생성된 토큰 ID 10개와 모델의 중간 결과를 모두 원래의 NumPy 구현과 비교하고, 14부에서는 같은 비교를 NVIDIA GPU에서 반복합니다.

이 튜토리얼은 C++와 고급 Python에 대한 어느 정도의 지식, 그리고 NVIDIA CUDA에 대한 기본적인 이해를 전제로 하지만, 그 밖에는 이 튜토리얼만으로 충분히 학습할 수 있도록 구성되어 있습니다.

왜 관심을 가져야 합니까?

아마 그럴 필요는 없을 것입니다. 최적화 컴파일러를 작성하는 것은 매우 틈새 분야의 주제입니다. 다만 이제는 LLVM + MLIR 덕분에 불과 몇 년 전보다도 훨씬 적은 지식만으로 다룰 수 있는 주제가 되었습니다. 그러니 새로운 AI 프레임워크나 실험적인 프로그래밍 언어를 만들고 싶다면, 지금이 좋은 때입니다. 꼭 배워야 하는 것은 아니지만, 사실 인생에서 꼭 필요한 것은 그리 많지 않습니다.

왜 MLIR인가?

MLIR은 특히 최신 하드웨어 가속기와 AI 워크로드를 다룰 때 드러나는 LLVM이나 GCC 같은 전통적인 컴파일러의 한계를 해결하기 위해 설계되었습니다. LLVM은 전통적인 CPU 타깃에는 탁월하지만, AI와 머신러닝 분야에서 등장하고 있는 다양한 특수 아키텍처는 잘 다루지 못합니다.

현대 AI 시스템은 일반적으로 CPU, GPU, TPU, 맞춤형 ASIC이 혼합되어 구성됩니다. MLIR의 유연한 아키텍처는 이러한 다양한 플랫폼 전반에서 작업하기 쉽게 해 주며, 이기종 하드웨어를 견고하게 지원합니다.

MLIR의 다이얼렉트 시스템은 고수준 ML 작업부터 하드웨어별 명령어에 이르기까지 다양한 수준에서 연산을 표현하고 최적화할 수 있습니다. 이는 특정 도메인에 맞춘 최적화를 구현하여 AI 워크로드를 더 효율적으로 실행할 수 있다는 뜻입니다.

MLIR은 가속기나 프레임워크 종류마다 별도의 컴파일러를 둘 필요 없이, 다양한 사용 사례에 맞게 확장할 수 있는 통합 인프라를 제공합니다. 전통적인 컴파일러는 AI를 염두에 두고 만들어지지 않았지만, MLIR은 텐서 연산, 신경망, 트랜스포머 및 기타 ML 구성 요소를 일급으로 지원합니다.

MLIR은 특수 머신러닝 가속기를 위한 대표적인 기술이 되었으며, 신호 처리, 양자 컴퓨팅, 동형 암호, FPGA, 맞춤형 실리콘 분야에서도 활용되고 있습니다. 도메인 특화 컴파일러를 만들 수 있는 능력은 전통적인 CPU와 GPU의 틀에 맞지 않는 “특이한 도메인“에서 판도를 바꾸는 요소입니다. 게다가 다른 언어에 쉽게 임베드할 수 있어서 특정 작업에 맞춘 도메인 특화 언어를 만들 수 있습니다. 이 블로그에서 바로 이 부분을 깊이 다룰 것입니다.

현대 컴파일러 스택의 핵심 구성 요소

  1. LLVM: LLVM의 핵심은 중간 표현입니다. 이는 어셈블리와 비슷하지만 중요한 고수준 정보가 보존된 저수준 프로그래밍 언어입니다. 이 IR은 소스 언어와 타깃 아키텍처 사이의 다리 역할을 합니다.

  2. MLIR: MLIR은 새로운 “다층(multi-level)” 중간 표현으로, 표현력이 더 풍부하여 제어 흐름, 데이터 흐름, 병렬성 같은 고수준 개념을 표현할 수 있으며 LLVM IR로 낮출 수 있습니다.

  3. 최적화 패스: MLIR과 LLVM은 모두 IR 수준에서 동작하는 방대한 최적화 패스를 제공하므로, LLVM을 사용하는 모든 언어가 정교한 최적화의 혜택을 누릴 수 있습니다. 대부분은 컴파일러 최적화 분야의 최신 연구를 바탕으로 대학원생과 박사들이 작성한 것입니다. 바로 이것이 LLVM을 그토록 강력한 도구로 만드는 요인입니다.

  4. E-Graphs 동치 포화는 자동 정리 증명 분야에서 유래한 e-그래프를 사용하여 최적화 컴파일러를 만드는 기법입니다. 이 기법은 e-그래프가 포화될 때까지 e-매칭을 사용해 일련의 재작성 규칙을 적용하며, 여러 종료 조건 중 하나가 충족될 때까지 포화 과정을 계속합니다. 재작성 단계가 끝나면 일반적으로 구문 트리의 크기나 성능 고려 사항과 관련된 비용 함수에 따라 e-그래프에서 최적의 항을 추출합니다. 이를 통해 더 넓은 범위의 잠재적 변환을 탐색하고 정의된 기준에 따라 가장 좋은 변환을 선택함으로써 더 효율적인 최적화가 가능해집니다.

그런 다음 현대 컴파일러는 이러한 구성 요소를 엮어 하나의 파이프라인을 구성합니다.

  1. 소스 코드를 파싱하여 표면 언어로 만듭니다.
  2. 표면 언어는 Core 언어로 변환됩니다.
  3. Core 언어는 Core-to-Core 변환으로 최적화할 수 있습니다(선택적으로 e-그래프 같은 도구를 사용할 수 있습니다).
  4. Core 언어는 MLIR로 로워링됩니다.
  5. MLIR이 고수준 최적화를 수행합니다
  6. MLIR은 LLVM IR로 로워링됩니다
  7. LLVM은 저수준 최적화와 코드 생성을 수행합니다

많은 프로그래밍 언어가 소스 언어와 LLVM 사이의 간극을 메우는 방법으로 “중간 수준” IR을 구현합니다. 예를 들어 Rust, Swift, Tensorflow가 모두 이 패턴을 따릅니다. MLIR을 통해 동일한 인프라를 재사용하고 어쩌면 상호 운용성도 더 높아질 수 있는 새로운 세대의 프로그래밍 언어와 프레임워크가 등장할 수 있습니다. 이는 LLVM이 많은 프로그래밍 언어의 사실상 표준 백엔드가 된 것과 거의 같은 방식입니다.

MLIR 설치하기

설치 방법은 Part 0에 있습니다. 여기서 다루는 예제를 실행하려면 mlir-opt, mlir-translate, 그리고 LLVM 도구가 필요합니다. 도구들은 모두 같은 LLVM 릴리스의 것으로 맞추어 사용하십시오. 패스 이름과 연산 문법은 버전마다 바뀔 수 있습니다. 이 장의 원래 예제는 LLVM 21 개발 빌드를 사용했습니다.

MLIR 개념

MLIR은 그 자체로 하나의 프로그래밍 언어입니다. 매우 저수준이지만, 다른 언어에서 볼 수 있는 Core 프리미티브를 다수 포함하고 있습니다. 먼저 몇 가지 식별자 규칙을 살펴보겠습니다:

  • % 접두사: SSA 값 (예: %result)
  • @ 접두사: 함수 (예: @fun)
  • ^ 접두사: 기본 블록 (예: ^bb0)
  • # 접두사: 속성 별칭 (예: #map_1d_identity)
  • x 구분자: 형태(shape) 안에서, 그리고 형태와 타입 사이에서 사용됩니다 (예: 10xf32)
  • :와 ->는 연산이나 값의 타입을 나타내는 데 사용됩니다 (예: %result: i32)
  • ! 접두사: 타입 별칭 (예: !avx_m128 = vector<4 x f32>)
  • ()는 인수를 나타내는 데 사용됩니다 (예: (%arg0, %arg1))
  • {}는 리전을 나타내는 데 사용됩니다
  • //는 주석에 사용됩니다
  • <>는 타입 매개변수를 나타내는 데 사용됩니다 (예: tensor<10xf32>)
  1. 모듈: MLIR 연산을 담는 최상위 컨테이너입니다.

    module {
      // Operations
    }
    
  2. 함수: 함수는 특정 순서로 실행되는 연산의 모음입니다.

    func.func @my_function(%arg0: i32, %arg1: i32) -> i32 {
      // Operations
    }
    
  3. 다이얼렉트: MLIR에서 도메인별로 정의된 연산과 타입의 집합입니다.

    우리가 사용할 고수준 다이얼렉트는 다음과 같습니다:

    • tensor: 이 다이얼렉트는 다차원 배열(텐서)을 생성하고 조작하는 연산을 제공하여, 부수 효과 없이 고수준 수학 표현과 변환을 가능하게 합니다.
    • linalg: 선형대수(linalg) 다이얼렉트는 선형대수 계산을 위해 특별히 설계된 연산 집합을 제공하여, 행렬 및 벡터 연산을 효율적으로 구현할 수 있게 합니다.
    • omp: OpenMP(omp) 다이얼렉트는 병렬 프로그래밍 모델을 지원하는 연산을 포함하여, 개발자가 OpenMP 표준과 호환되는 방식으로 병렬성을 표현할 수 있게 합니다.
    • affine: affine 다이얼렉트는 아핀 연산과 분석을 표현하기 위한 프레임워크를 제공하며, 아핀 표현식의 예측 가능한 동작을 활용하는 최적화를 가능하게 합니다.
    • gpu: GPU 다이얼렉트는 GPU 프로그래밍에 특화되어 있으며, 이기종 아키텍처에서의 병렬 실행을 위한 연산을 제공합니다.

    더 저수준의 다이얼렉트는 다음과 같습니다.

    • scf: 구조적 제어 흐름(scf) 다이얼렉트는 루프와 조건문 같은 구조적 제어 흐름 구성 요소를 나타내는 연산을 제공하여, 명확하고 유지보수하기 쉬운 실행 흐름을 보장합니다.
    • func: 이 다이얼렉트는 함수 정의 및 호출과 관련된 연산을 포함하며, 고수준 함수 추상화를 통해 코드의 구성과 모듈화를 용이하게 합니다.
    • memref: memref 다이얼렉트는 메모리 참조 연산을 전담하며, 성능이 중요한 애플리케이션에 필수적인 메모리 버퍼의 효율적인 관리와 조작을 가능하게 합니다.
    • index: index 다이얼렉트는 인덱스 계산을 처리하는 연산에 특화되어 있으며, 이러한 계산은 특히 루프 반복과 데이터 접근 패턴에서 배열과 텐서의 원소를 지정하는 데 매우 중요합니다.
    • arith: 산술(arith) 다이얼렉트는 정수 타입과 부동소수점 타입 모두를 위한 기본적인 수학 연산을 포함하며, 여기에는 기본 산술, 비트 연산, 비교가 포함되고 스칼라, 벡터, 텐서에 적용할 수 있습니다.

    또한 NVidia GPU를 대상으로 하기 위해 몇 가지 하드웨어 특화 다이얼렉트를 사용할 것이지만, 이에 대해서는 나중에 자세히 다루겠습니다.

  4. Operations: MLIR에서 작업의 기본 단위로, LLVM 명령어와 비슷하지만 다이얼렉트 네임스페이스가 추가되고 타입 주석이 붙을 수 있습니다.

    // Example of an MLIR operation
    %0 = "my_dialect.my_operation"(%arg0, %arg1) : (i32, i32) -> i32
    

    이 예제에서 my_dialect.my_operation은 my_dialect 다이얼렉트에 정의된 연산이며, %arg0과 %arg1은 i32 타입의 인자입니다. 이 연산은 변수 %0에 바인딩된 i32 타입의 결과를 반환합니다.

    구체적인 예로는 두 부동소수점 수를 더하는 “arith.addf” 연산을 들 수 있습니다.

    %0 = arith.addf %arg0, %arg1 : f32
    
  5. Basic Blocks: MLIR의 기본 블록은 분기 없이 선형적으로 실행되는 연산들의 시퀀스입니다. 각 기본 블록은 하나의 진입점을 가지며, 일반적으로 분기나 반환 같은 제어 흐름 연산을 통해 여러 개의 출구점을 가질 수 있습니다. 기본 블록은 프로그램의 제어 흐름을 구조화하는 데 필수적이며, 명확하고 유지보수하기 쉬운 코드 구성을 가능하게 합니다. 기본 블록을 통해 컴파일러는 실행 경로를 최적화할 수 있으며, 인라이닝과 루프 언롤링 같은 변환이 용이해집니다. MLIR에서 기본 블록은 함수 내부에 정의되며, 복잡한 제어 흐름 구성 요소를 표현하기 위해 다양한 다이얼렉트를 통해 조작될 수 있습니다.

    ^bb1: // Label for the then block
    %then_result = arith.muli %result, 2 : i32
    return %then_result : i32
    

    LLVM과 달리, 이제 기본 블록도 인자를 받을 수 있으며, 인자는 ^bb1(%result : i32) 구문을 통해 전달됩니다.

  6. Regions: 영역(region)은 연산들을 하나로 묶는 방법입니다. 영역은 루프와 조건문 같은 제어 흐름 구성 요소를 표현하는 데 사용됩니다. 영역은 {} 블록으로 묶이며 인자를 받을 수 있습니다.

    {
      ^bb1(%result : i32):
      %then_result = arith.muli %result, 2 : i32
      return %then_result : i32
    }
    
  7. Types: 타입은 변수가 어떤 종류의 값을 담을 수 있고 그 값에 어떤 연산을 수행할 수 있는지를 지정하는 분류입니다. 타입은 데이터에 제약 조건을 적용하여, 연산이 호환되는 값에 대해 수행되도록 보장하는 데 도움을 줍니다.

    %result = arith.constant 1 : i32
    

    편의를 위해 타입 동의어를 정의할 수도 있습니다.

    !avx_m128 = vector<4 x f32>
    
  8. Passes: MLIR 다이얼렉트에 작용하여 이를 최적화하고 더 단순한 구성 요소로 로어링하는 변환입니다.

    다음은 MLIR을 변환하기 위해 mlir-opt에 전달할 수 있는 인자들로, 가장 일반적인 것들은 다음과 같습니다.

    • convert-func-to-llvm: 함수형 연산을 LLVM 다이얼렉트로 변환합니다
    • convert-math-to-llvm: 수학 연산을 LLVM 다이얼렉트로 변환합니다
    • convert-index-to-llvm: 인덱스 연산을 LLVM 다이얼렉트로 변환합니다
    • convert-scf-to-cf: 구조적 제어 흐름을 CF 다이얼렉트로 변환합니다
    • convert-cf-to-llvm: 제어 흐름을 LLVM 다이얼렉트로 변환합니다
    • convert-arith-to-llvm: 산술 연산을 LLVM 다이얼렉트로 변환합니다
    • reconcile-unrealized-casts: 실현되지 않은 캐스트를 조정합니다
    • convert-memref-to-llvm: memref 연산을 LLVM 다이얼렉트로 변환합니다
    • convert-tensor-to-llvm: 텐서 연산을 LLVM 다이얼렉트로 변환합니다
    • convert-linalg-to-scf: linalg 연산을 scf.for 루프로 변환합니다
    • convert-linalg-to-affine-loops: linalg 연산을 affine.for 루프로 변환합니다
    • convert-omp-to-llvm: OpenMP 연산을 LLVM 다이얼렉트로 변환합니다
    • convert-vector-to-llvm: 벡터 연산을 LLVM 다이얼렉트로 변환합니다

    MLRI에서 LLVM IR로 변환할 수 있는 모든 것을 변환하는 범용 -convert-to-llvm 패스도 있습니다. 실제로는 특정 다이얼렉트를 LLVM으로 변환하기 위해 더 세분화된 패스를 사용합니다.

    패스를 더 세밀하게 제어해야 한다면 --pass-pipeline 문자열에 패스 이름을 쉼표로 구분한 목록으로 지정할 수도 있습니다. 예를 들면 --pass-pipeline="builtin.module(pass1,pass2)"와 같습니다. 패스들은 하나의 그룹에서 순차적으로 실행됩니다.

    또한 패스 변환을 디버깅하는 데 유용한 플래그가 몇 가지 있습니다. --mlir-print-ir-after-all 플래그는 각 패스 이후의 IR을 출력하며, --mlir-print-ir-after-change와 --mlir-print-ir-after-failure는 보다 구체적인 출력을 제공합니다. 터미널 표준 출력을 일일이 살펴보고 싶지 않다면, --mlir-print-ir-tree-dir을 포함하여 이러한 print-ir 플래그 중 하나를 사용할 때 IR이 디렉터리 트리 안의 파일들로 기록됩니다.

    참고: 패스의 순서가 중요할 수 있습니다. 예를 들어 convert-scf-to-cf는 반드시 convert-cf-to-llvm보다 먼저 와야 합니다.

LLVM 모듈 생성 및 사용

종료 코드로 42를 반환하는 간단한 LLVM 모듈을 만들고, 이를 공유 라이브러리로 컴파일한 뒤 Python에서 사용해 봅시다. 이 예제는 LLVM IR에서 사용 가능한 코드에 이르는 전체 파이프라인을 보여 줍니다.

1. LLVM IR 작성하기

먼저 다음 LLVM IR로 simple.ll이라는 파일을 만드십시오:

define i32 @main() {
    ret i32 42
}

비유하자면 이 프로그램은 다음과 같습니다:

int main() {
    return 42;
}

2. 공유 객체로 컴파일하기

clang을 사용하여 이 LLVM IR을 공유 객체로 컴파일할 수 있습니다:

llc -filetype=obj --relocation-model=pic simple.ll -o simple.o
clang -shared -fPIC simple.o -o libsimple.so
clang simple.o -o simple # optionally create an executable

이제 실행 파일을 실행하면 출력 42가 프로세스의 종료 코드로 반환되는 것을 확인할 수 있습니다.

$ ./simple
$ echo $?
42

3. Python에서 사용하기

이제 ctypes를 사용하여 Python에서 이 공유 라이브러리 libsimple.so를 사용할 수 있습니다:

import ctypes

module = ctypes.CDLL("./libsimple.so")

module.main.argtypes = []
module.main.restype = ctypes.c_int

print(module.main())

이를 실행하면 Python 로직이 공유 라이브러리를 성공적으로 호출하고 결과를 출력하는 것을 확인할 수 있습니다.

$ python simple.py
42

MLIR 유니버스

42를 반환하는 함수 정도라면 LLVM으로 작성하기가 충분히 간단합니다. 하지만 LLVM으로 작성하는 간단하지 않은 프로그램은 그리 단순하지 않으며, 실제로 많은 애플리케이션에서 지나치게 “저수준“인 경우가 많기 때문에 수년에 걸쳐 많은 사람이 LLVM 위에 더 높은 수준의 추상화를 구축해 왔습니다.

예를 들어 LLVM으로 루프를 작성하는 일은 사실 꽤 번거로운데, 기본적인 루프 하나를 위해서도 블록과 phi 노드를 모두 직접 처리해야 하기 때문입니다. MLIR을 사용하면 고수준 구조를 직접 작성하고 이를 단일 패스로 LLVM까지 로어링할 수 있습니다.

4. MLIR 작성하기

먼저 다음 MLIR로 simple.mlir이라는 파일을 만드십시오.

func.func @loop_add() -> (index) {
    %init = index.constant 0
    %lb = index.constant 0
    %ub = index.constant 10
    %step = index.constant 1

    %sum = scf.for %iv = %lb to %ub step %step iter_args(%acc = %init) -> (index) {
        %sum_next = arith.addi %acc, %iv : index
        scf.yield %sum_next : index
    }

    return %sum : index
}

func.func @main() -> i32 {
    %out = call @loop_add() : () -> index
    %out_i32 = arith.index_cast %out : index to i32
    func.return %out_i32 : i32
}

이는 C로 다음과 같습니다.

int loop_add(int lb, int ub, int step) {
  int sum_0 = 0;
  int sum = sum_0;
  for (int iv = lb; iv < ub; iv += step) {
    sum_next = sum + iv;
    sum = sum_next;
  }
  return sum;
}

int main() {
  int out = loop_add(0, 10, 1);
  return out;
}

4. LLVM 다이얼렉트로 로어링하기

LLVM IR은 정적 단일 할당(Static Single Assignment)에 기반하는데, 이는 모든 변수가 정확히 한 번만 할당되며 변수의 모든 사용은 그 정의에 의해 지배(dominate)되어야 함을 의미합니다. SSA에서는 변수가 여러 번 재할당되는 대신, 새로운 “할당“마다 변수의 새 버전이 생성되며, 이는 보통 번호가 붙은 접미사(%1, %2 등)로 표기됩니다. 이 속성은 정의와 사용 사이의 관계를 명시적이고 모호하지 않게 만들어 주므로 많은 컴파일러 최적화를 더 단순하고 효과적으로 만듭니다.

예를 들어 LLVM IR에서는 다음과 같은 코드를 볼 수 있습니다.

%1 = add i32 %a, %b
%2 = mul i32 %1, %c

여기서 %1과 %2는 결코 재할당될 수 없는 SSA 값입니다. 제어 흐름상 거쳐 온 경로에 따라 변수가 서로 다른 값을 가져야 하는 경우(예: if 문 이후), LLVM은 특수한 “phi” 노드를 사용하여 가능한 여러 값을 병합함으로써 동적인 프로그램 동작을 처리하면서도 SSA 속성을 유지합니다.

LLVM에서 기본 블록(basic block)은 제어 흐름의 기본 단위이며, 순차적으로 실행되는 명령어의 나열입니다. 기본 블록에는 진입점과 출구점이 각각 하나씩 있습니다.

phi 노드는 SSA 기반 중간 표현에서 서로 다른 제어 흐름 경로에서 오는 여러 잠재적 값을 하나의 값으로 결정하는 특수한 명령어입니다. 본질적으로 phi 노드는 프로그램이 해당 지점에 도달하기까지 어떤 경로를 거쳤는지에 따라 적절한 값을 선택하는, 제어 흐름을 인식하는 멀티플렉서입니다.

entry:
  br i1 %cond, label %then, label %else

then:
  %x.1 = add i32 1, 2
  br label %merge

else:
  %x.2 = mul i32 3, 4
  br label %merge

merge:
  %x.3 = phi i32 [ %x.1, %then ], [ %x.2, %else ]
  ret i32 %x.3

이 예제에서 phi 노드 %x.3은 제어 흐름이 then 블록에서 왔다면 %x.1의 값(3)을, else 블록에서 왔다면 %x.2의 값(12)을 갖게 됩니다. [ value, predecessor_block ] 구문은 이전에 어떤 블록이 실행되었는지에 따라 어떤 값을 사용할지 지정합니다.

mlir-opt를 사용하여 고수준 MLIR을 LLVM 다이얼렉트로 로어링할 수 있습니다. 본질적으로 루프 구조를 블록 간 분기 문으로 축소하는 것입니다.

mlir-opt example.mlir \
  --convert-func-to-llvm \
  --convert-math-to-llvm \
  --convert-index-to-llvm \
  --convert-scf-to-cf \
  --convert-cf-to-llvm \
  --convert-arith-to-llvm \
  --reconcile-unrealized-casts \
  -o example_opt.mlir

그러면 다음과 같은 MLIR이 생성됩니다.

module {
  llvm.func @loop_add() -> i64 {
    %0 = llvm.mlir.constant(0 : i64) : i64
    %1 = llvm.mlir.constant(0 : i64) : i64
    %2 = llvm.mlir.constant(10 : i64) : i64
    %3 = llvm.mlir.constant(1 : i64) : i64
    llvm.br ^bb1(%1, %0 : i64, i64)
  ^bb1(%4: i64, %5: i64):  // 2 preds: ^bb0, ^bb2
    %6 = llvm.icmp "slt" %4, %2 : i64
    llvm.cond_br %6, ^bb2, ^bb3
  ^bb2:  // pred: ^bb1
    %7 = llvm.add %5, %4 : i64
    %8 = llvm.add %4, %3 : i64
    llvm.br ^bb1(%8, %7 : i64, i64)
  ^bb3:  // pred: ^bb1
    llvm.return %5 : i64
  }
  llvm.func @main() -> i32 {
    %0 = llvm.call @loop_add() : () -> i64
    %1 = llvm.trunc %0 : i64 to i32
    llvm.return %1 : i32
  }
}

mlir-cpu-runner를 사용하면 MLIR 코드에서 main 함수를 직접 실행할 수 있습니다.

mlir-cpu-runner -e main -entry-point-result=i32 simple_opt.mlir

# To use the runner utils (e.g. for debug printing)
# mlir-cpu-runner -e main -entry-point-result=i32 -shared-libs=/opt/homebrew/opt/llvm/lib/libmlir_runner_utils.dylib simple_opt.mlir

5. 공유 객체로 컴파일하기

여기서부터는 mlir-translate를 사용하여 MLIR을 LLVM IR로 직접 변환합니다. mlir-translate를 사용하여 이 MLIR을 공유 객체로 컴파일할 수 있습니다.

mlir-translate simple_opt.mlir -mlir-to-llvmir -o simple.ll
llc -filetype=obj --relocation-model=pic simple.ll -o simple.o
clang -shared -fPIC simple.o -o libsimple.so

그런 다음 LLVM이 이를 어셈블리(여기서는 ARM64 어셈블리)로 컴파일합니다.

_loop_add:
sub     sp, sp, #0x30
mov     x8, #0x0
mov     x9, x8
str     x9, [sp, #0x20]
str     x8, [sp, #0x28]
b       0x100003f10
ldr     x9, [sp, #0x20]
ldr     x8, [sp, #0x28]
str     x8, [sp, #0x8]
str     x9, [sp, #0x10]
subs    x9, x9, #0xa
str     x8, [sp, #0x18]
b.ge    0x100003f4c
b       0x100003f30
ldr     x9, [sp, #0x10]
ldr     x8, [sp, #0x8]
add     x8, x8, x9
add     x9, x9, #0x1
str     x9, [sp, #0x20]
str     x8, [sp, #0x28]
b       0x100003f10
ldr     x0, [sp, #0x18]
add     sp, sp, #0x30
ret

표준 MLIR 다이얼렉트

llvm

llvm 다이얼렉트는 LLVM IR을 표현하는 다이얼렉트입니다. 이 다이얼렉트는 MLIR 계층 구조에서 가장 낮은 수준의 다이얼렉트로, LLVM IR로 직접 전달됩니다.

scf와 cf

상위 수준의 모든 ‘scf’ 구조는 ‘cf’ 구성 요소로 컴파일됩니다.

  • cf.br - 기본 블록으로의 분기
  • cf.cond_br - 기본 블록으로의 조건부 분기
  • cf.switch - 기본 블록으로의 스위치

무조건 분기는 항상 같은 기본 블록으로 이동하는 분기입니다.

// A basic block
^bb0:
  cf.br ^bb1

조건부 분기는 조건 변수(여기서는 %c)에 따라 서로 다른 기본 블록으로 이동하는 분기입니다.

// A conditional branch to a basic block
^bb0:
  cf.cond_br %c, ^bb1, ^bb2

스위치는 스위치 변수(여기서는 %i)에 따라 서로 다른 기본 블록으로 이동하는 분기입니다.

// A switch to a basic block
^bb0:
  cf.switch %i, ^bb1, ^bb2, ^bb3

예를 들어 이러한 구성 요소를 조합하면, 불리언 플래그에 따라 두 값 중 하나를 선택하여 주어진 인자 중 하나를 반환하는 함수를 만들 수 있습니다.

func.func @select(%a: i32, %b: i32, %flag: i1) -> i32 {
  cf.cond_br %flag, ^bb1(%a : i32), ^bb1(%b : i32)

^bb1(%x : i32) :
  return %x : i32
}

가장 기본적인 구조적 제어 흐름 구성 요소는 scf.if 구성 요소입니다.

scf.if %b  {
  // true region
} else {
  // false region
}

위에서 살펴본 것처럼 for 루프도 구조적 제어 흐름 구성 요소이며, 하나의 진입점과 하나의 출구점을 가집니다.

%lb = index.constant 0
%ub = index.constant 10
%step = index.constant 1

scf.for %iv = %lb to %ub step %step {
  // loop region
}

이는 convert-scf-to-cf 패스를 통해 cf.br 및 cf.cond_br 연산의 시퀀스로 낮춰집니다.

arith와 math

arith 다이얼렉트에는 다음과 같은 기본 연산이 포함됩니다.

  • 정수 산술: addi, subi, muli, divsi(부호 있음), divui(부호 없음)
  • 부동소수점 산술: addf, subf, mulf, divf
  • 비교: cmpi(정수), cmpf(부동소수점)
  • 변환: extsi(부호 확장), extui(0 확장), trunci, fptoui, fptosi, uitofp, sitofp
  • 비트 연산: andi, ori, xori

산술 연산의 예시:

func.func @arithmetic_example(%a: i32, %b: f32) -> i32 {
    // Integer addition
    %1 = arith.constant 42 : i32
    %2 = arith.addi %a, %1 : i32

    // Float to integer conversion
    %3 = arith.fptosi %b : f32 to i32

    // Final addition
    %4 = arith.addi %2, %3 : i32
    return %4 : i32
}

math 다이얼렉트는 더 복잡한 수학 연산을 제공합니다.

  • 삼각 함수: sin, cos, tan
  • 지수 함수: exp, exp2, log, log2, log10
  • 거듭제곱 함수: pow, sqrt
  • 특수 함수: erf, atan2
  • 상수: constant

math 연산을 사용하는 예시:

func.func @math_example(%x: f32) -> f32 {
    // Calculate sin(x) * sqrt(x)
    %1 = math.sin %x : f32
    %2 = math.sqrt %x : f32
    %3 = arith.mulf %1, %2 : f32
    return %3 : f32
}

-convert-math-to-llvm 패스와 -convert-arith-to-llvm 패스는 수학 연산과 산술 연산을 그에 대응하는 기본 LLVM 연산으로 변환합니다.

index

index 다이얼렉트는 인덱스 계산 처리에 특화되어 있으며, 배열/텐서 연산에서 특히 중요합니다. MLIR의 인덱스 타입은 플랫폼에 따라 크기가 정해지는 정수로, 주소 지정과 루프 유도 변수에 사용됩니다.

주요 인덱스 연산은 다음과 같습니다.

  • index.constant: 인덱스 상수를 생성합니다
  • index.add: 두 인덱스를 더합니다
  • index.sub: 두 인덱스를 뺍니다
  • index.mul: 두 인덱스를 곱합니다
  • index.cmp: 두 인덱스를 비교합니다
  • index.divs: 인덱스의 부호 있는 나눗셈입니다
  • index.rems: 인덱스의 부호 있는 나머지입니다

인덱스 연산은 배열에서 원소의 오프셋을 계산하는 데 사용됩니다. 예를 들어, 다음 함수는 2차원 배열에서 원소의 오프셋을 계산합니다. 인덱스는 항상 음이 아니므로, 이 연산들을 자연수 연산으로 취급할 수 있습니다.

func.func @compute_offset(%i: index, %j: index, %stride: index) -> index {
    // Compute row-major array offset: i * stride + j
    %1 = index.mul %i, %stride
    %2 = index.add %1, %j
    return %2 : index
}

-convert-index-to-llvm 패스는 index 다이얼렉트를 LLVM 다이얼렉트로 변환합니다.

인덱스 타입은 배열 인덱스와 차원을 자연스럽게 표현하는 방법을 제공하므로, 텐서와 메모리 레이아웃을 다룰 때 특히 유용합니다. 인덱스 타입은 배열 접근 패턴을 위해 memref 및 tensor 다이얼렉트와 함께 사용됩니다. 이에 대해서는 다음에 다룹니다.

외부 자료

이전: 0부 - MLIR 설치하기 · 시리즈 목차 · 다음: 2부 - MLIR의 메모리


원문 © Stephen Diehl, stephendiehl.com · 글 CC BY-SA 4.0, 코드 MIT · Stephen Diehl은 런던의 소프트웨어 엔지니어이자 작가입니다(저자가 정한 소개 문구). 이 글은 저자가 개인 자격으로 쓴 것이며 어떤 조직과의 연관도 밝히거나 암시해서는 안 됩니다 (라이선스와 고지).
이 페이지는 원문을 한국어로 옮기며 변경한 비공식 번역이며 같은 CC BY-SA 4.0으로 제공합니다 · yeokja 한국어 번역 · Anthropic 사의 claude-opus-5-5 모델을 활용하여 번역되었으며 학습을 모두 비허용한 상태로 작업하였습니다.