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

LangBackend Tutorial

MLIR 기반 FunLang 컴파일러 백엔드 튜토리얼입니다.

FunLang의 Typed AST를 MLIR을 거쳐 네이티브 바이너리로 컴파일하는 과정을 단계별로 다룹니다. 각 챕터마다 그때까지 다룬 기능이 모두 동작하는 컴파일러를 만들 수 있습니다.

시작하기

Chapter 00: 사전 준비부터 시작하세요.

Chapter 00: 사전 준비

소개

LangBackend 튜토리얼 시리즈에 오신 것을 환영한다. 여러분은 LangTutorial을 완료하고 완전히 동작하는 FunLang 인터프리터를 구축했기 때문에 이 튜토리얼을 시작하게 되었을 것이다. 이미 파서, Hindley-Milner 타입 추론을 갖춘 타입 체커, 그리고 트리 워킹 평가기를 갖추고 있다. 이제 FunLang을 다음 단계로 끌어올릴 차례다: 네이티브 머신 코드로 컴파일하는 것이다.

이 튜토리얼 시리즈에서는 타입이 지정된 FunLang AST를 실행 가능한 바이너리로 변환하는 MLIR 기반 컴파일러 백엔드를 구축하는 방법을 배운다. MLIR(Multi-Level Intermediate Representation)은 LLVM 프로젝트에서 제공하는 현대적인 컴파일러 프레임워크로, 구조화된 IR 연산, 타입 안전성, 플러그인 가능한 dialect, 그리고 고수준 의미론에서 머신 코드까지의 점진적 lowering 등 필요한 인프라를 제공한다.

이 장에서는 필수 사전 준비 설정을 다룬다: C API를 활성화하여 LLVM/MLIR을 소스에서 빌드하고, F# 개발을 위한 .NET SDK를 설치하며, 두 시스템이 통신할 수 있도록 환경을 구성하는 것이다. 이러한 기초가 없으면 나머지 튜토리얼을 진행할 수 없다.

시스템 요구 사항

시작하기 전에 시스템이 다음 요구 사항을 충족하는지 확인한다:

  • 디스크 공간: ~30 GB (LLVM 소스 + 빌드 산출물 + 설치)
  • RAM: 16 GB 권장 (빌드 병렬 처리를 줄이면 최소 8 GB)
  • 빌드 시간: 최신 하드웨어 기준 30-60분 (4코어 이상, SSD)
  • 지원 플랫폼:
    • Linux (Ubuntu 22.04+, Fedora 38+ 또는 이에 상응하는 배포판)
    • macOS (13 Ventura 이상, Intel 및 Apple Silicon 모두 지원)
    • Windows (Ubuntu 22.04+가 설치된 WSL2 권장; 네이티브 MSVC 빌드도 가능하지만 이 튜토리얼에서는 다루지 않는다)

C API를 포함한 LLVM/MLIR 빌드

MLIR은 LLVM 프로젝트의 일부이다. MLIR 팀은 F#과 같은 비-C++ 언어가 MLIR 인프라와 상호작용할 수 있도록 안정적인 C API를 제공한다. 이 C API는 기본적으로 빌드되지 않으므로 CMake 구성 단계에서 명시적으로 활성화해야 한다.

빌드 의존성 설치

Linux (Ubuntu/Debian)

sudo apt update
sudo apt install -y \
  build-essential \
  cmake \
  ninja-build \
  clang \
  lld \
  python3 \
  git

macOS

먼저 Xcode Command Line Tools가 설치되어 있지 않다면 설치한다:

xcode-select --install

그런 다음 Homebrew를 통해 CMake와 Ninja를 설치한다:

brew install cmake ninja

macOS에는 이미 Clang이 포함되어 있으므로 빌드할 준비가 된 것이다.

Windows (WSL2)

Ubuntu 22.04가 설치된 Windows Subsystem for Linux 2 (WSL2)를 사용하는 것을 권장한다. WSL2 설치 가이드를 따른 후, 위의 Linux (Ubuntu) 의존성 설치 단계를 사용한다.

참고: MSVC를 사용한 네이티브 Windows 빌드도 가능하지만 다른 CMake 구성이 필요하며 이 튜토리얼의 범위를 벗어난다. WSL2는 Windows에서 일관된 Linux 환경을 제공한다.

LLVM 클론

LLVM monorepo를 LLVM 19.x 안정 릴리스 브랜치에서 클론한다. --depth 1을 사용하면 최신 커밋만 가져와 디스크 공간과 다운로드 시간을 절약할 수 있다:

cd $HOME
git clone --depth 1 --branch release/19.x https://github.com/llvm/llvm-project.git
cd llvm-project

shallow clone 후 저장소 크기는 약 2 GB이다.

빌드 구성

CMake 구성 단계는 매우 중요하다. 각 플래그는 특정 목적을 가지고 있다:

cmake -S llvm -B build -G Ninja \
  -DCMAKE_BUILD_TYPE=Release \
  -DLLVM_ENABLE_PROJECTS=mlir \
  -DMLIR_BUILD_MLIR_C_DYLIB=ON \
  -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \
  -DCMAKE_INSTALL_PREFIX=$HOME/mlir-install

플래그 설명:

  • -S llvm: 소스 디렉터리 (저장소 내의 llvm 하위 디렉터리)
  • -B build: 빌드 디렉터리 (out-of-tree 빌드 권장)
  • -G Ninja: Ninja 빌드 시스템 사용 (Make보다 빠름)
  • -DCMAKE_BUILD_TYPE=Release: 디버그 심볼 없이 최적화된 빌드 (크기가 훨씬 작고 빠름)
  • -DLLVM_ENABLE_PROJECTS=mlir: LLVM과 함께 MLIR 빌드 (MLIR은 LLVM에 의존)
  • -DMLIR_BUILD_MLIR_C_DYLIB=ON: 핵심 플래그 — MLIR C API를 노출하는 libMLIR-C 공유 라이브러리를 빌드한다
  • -DLLVM_TARGETS_TO_BUILD="X86;AArch64": x86-64 및 ARM64 백엔드만 빌드 (빌드 시간 단축; 필요시 다른 타겟 추가)
  • -DCMAKE_INSTALL_PREFIX=$HOME/mlir-install: 설치 위치 (쓰기 가능한 디렉터리 사용)

CMake 구성은 1-2분 내에 완료된다. 다음과 같은 출력이 표시된다:

-- The C compiler identification is GNU 11.4.0
-- The CXX compiler identification is GNU 11.4.0
...
-- Build files have been written to: /home/user/llvm-project/build

빌드 및 설치

사용 가능한 모든 CPU 코어를 활용하여 MLIR을 빌드한다 (Ninja는 자동으로 병렬 처리를 사용한다):

cmake --build build --target install

이 단계는 하드웨어에 따라 30-60분이 소요된다. 수천 줄의 컴파일 로그가 스크롤된다. 빌드 중 메모리가 부족해지면 (시스템이 응답하지 않는 경우), 빌드를 중지하고 (Ctrl+C) 병렬 처리를 줄여 다시 시작한다:

cmake --build build --target install -- -j2

-j2 플래그는 Ninja의 병렬 컴파일 작업을 2개로 제한하여, 빌드 시간이 느려지는 대신 최대 메모리 사용량을 줄인다.

빌드가 완료되면 다음과 같이 표시된다:

[100%] Built target install

설치 확인

MLIR C API 공유 라이브러리가 설치되었는지 확인한다:

ls -lh $HOME/mlir-install/lib/libMLIR-C*

예상 출력:

  • Linux: libMLIR-C.solibMLIR-C.so.19 (버전이 지정된 라이브러리에 대한 심볼릭 링크)
  • macOS: libMLIR-C.19.dyliblibMLIR-C.dylib (심볼릭 링크)
  • Windows (WSL): Linux와 동일

No such file or directory가 표시되면 CMake 구성에 -DMLIR_BUILD_MLIR_C_DYLIB=ON이 포함되어 있는지 확인하고 빌드 단계를 다시 실행한다.

mlir-opt 도구도 설치되어 있어야 한다:

$HOME/mlir-install/bin/mlir-opt --version

예상 출력: MLIR (http://mlir.llvm.org) version 19.1.x

.NET SDK 설치

FunLang의 컴파일러 백엔드는 F#으로 구현된다. F# 프로그램을 컴파일하고 실행하려면 .NET SDK가 필요하다.

Linux (Ubuntu/Debian)

.NET 8.0 SDK (2026년 11월까지 지원되는 LTS 릴리스)를 설치한다:

wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel 8.0

스크립트는 .NET을 $HOME/.dotnet에 설치한다. PATH에 추가한다:

echo 'export PATH="$HOME/.dotnet:$PATH"' >> ~/.bashrc
source ~/.bashrc

macOS

https://dotnet.microsoft.com/download/dotnet/8.0에서 .NET 8.0 SDK 설치 프로그램을 다운로드하여 설치하거나, Homebrew를 사용한다:

brew install --cask dotnet-sdk

Windows (WSL2)

WSL2 Ubuntu 환경에서 위의 Linux 설치 단계를 따른다.

.NET 설치 확인

.NET 버전을 확인한다:

dotnet --version

예상 출력: 8.0.x

F# 컴파일러가 사용 가능한지 확인한다:

dotnet fsi --version

예상 출력: Microsoft (R) F# Interactive version 12.8.x.0

모든 것이 정상적으로 작동하는지 확인하기 위해 테스트 F# 프로젝트를 생성한다:

dotnet new console -lang F# -o test-fsharp
cd test-fsharp
dotnet run

다음과 같이 출력되어야 한다:

Hello from F#

라이브러리 검색 경로 설정

F# 프로그램이 P/Invoke를 통해 MLIR C API 함수를 호출할 때, .NET 런타임은 런타임에 libMLIR-C 공유 라이브러리를 찾을 수 있어야 한다. 표준적인 방법은 MLIR 설치 라이브러리 디렉터리를 시스템의 라이브러리 검색 경로에 추가하는 것이다.

Linux

MLIR 라이브러리 디렉터리를 LD_LIBRARY_PATH에 추가한다:

echo 'export LD_LIBRARY_PATH="$HOME/mlir-install/lib:$LD_LIBRARY_PATH"' >> ~/.bashrc
source ~/.bashrc

라이브러리가 검색 가능한지 확인한다:

ldconfig -p | grep MLIR

libMLIR-C.so에 대한 항목이 표시되어야 한다.

macOS

MLIR 라이브러리 디렉터리를 DYLD_LIBRARY_PATH에 추가한다:

echo 'export DYLD_LIBRARY_PATH="$HOME/mlir-install/lib:$DYLD_LIBRARY_PATH"' >> ~/.zshrc
source ~/.zshrc

참고: macOS Catalina 이후 macOS는 기본적으로 zsh를 사용한다. bash를 사용하고 있다면 ~/.bashrc를 수정한다.

라이브러리가 존재하는지 확인한다:

ls -l $HOME/mlir-install/lib/libMLIR-C.dylib

Windows (WSL2)

WSL2에서 위의 Linux 지침을 따른다.

대안: 프로젝트별 구성

전역 환경 변수를 설정하는 대신, F# 애플리케이션을 실행할 때 라이브러리 경로를 지정할 수 있다:

LD_LIBRARY_PATH=$HOME/mlir-install/lib dotnet run

이 방법은 셸 프로파일을 수정하지 않고 테스트할 때 유용하다.

자주 발생하는 문제 해결

빌드 중 메모리 부족

증상: MLIR 빌드 중 시스템이 응답하지 않음; 스왑 사용량이 100%.

해결 방법: 빌드 병렬 처리를 줄인다:

cmake --build build --target install -- -j2

RAM이 8 GB인 시스템에서는 -j1이 필요할 수 있다.

“MLIR-C library not found” 런타임 오류

증상: F# 프로그램이 DllNotFoundException: Unable to load shared library 'MLIR-C'로 실패한다.

해결 방법: 라이브러리 검색 경로가 구성되어 있는지 확인한다:

# Linux
echo $LD_LIBRARY_PATH
# $HOME/mlir-install/lib이 포함되어 있어야 합니다

# macOS
echo $DYLD_LIBRARY_PATH

라이브러리 파일이 존재하는지 확인한다:

ls $HOME/mlir-install/lib/libMLIR-C*

파일이 없다면 -DMLIR_BUILD_MLIR_C_DYLIB=ON으로 다시 빌드한다.

CMake 버전이 너무 오래됨

증상: CMake 구성이 CMake 3.20 or higher is required로 실패한다.

해결 방법: 최신 CMake를 설치한다:

# Linux: 최신 CMake 바이너리 다운로드
wget https://github.com/Kitware/CMake/releases/download/v3.28.0/cmake-3.28.0-linux-x86_64.sh
sudo sh cmake-3.28.0-linux-x86_64.sh --prefix=/usr/local --skip-license

# macOS
brew upgrade cmake

Ninja 빌드 시스템 누락

증상: CMake 구성이 Could not find Ninja로 실패한다.

해결 방법: Ninja를 설치하거나 (위의 “빌드 의존성 설치” 참조), 대신 Unix Makefiles를 사용한다 (더 느림):

cmake -S llvm -B build -G "Unix Makefiles" \
  -DCMAKE_BUILD_TYPE=Release \
  -DLLVM_ENABLE_PROJECTS=mlir \
  -DMLIR_BUILD_MLIR_C_DYLIB=ON \
  -DLLVM_TARGETS_TO_BUILD="X86;AArch64" \
  -DCMAKE_INSTALL_PREFIX=$HOME/mlir-install

make -C build install -j$(nproc)

디스크 공간 부족

증상: 빌드가 No space left on device로 실패한다.

해결 방법: LLVM 빌드에는 ~30 GB가 필요하다. 공간을 확보하거나 다른 파티션에서 빌드한다. 설치 후 build 디렉터리를 삭제하면 ~20 GB를 회수할 수 있다:

rm -rf $HOME/llvm-project/build

이 장에서 완료한 것

이 시점에서 다음 항목이 준비되어 있다:

  1. LLVM/MLIR 설치 완료$HOME/mlir-install에 C API 공유 라이브러리(libMLIR-C.so, libMLIR-C.dylib, 또는 MLIR-C.dll) 포함
  2. .NET 8.0 SDK — F# 컴파일러 및 런타임과 함께 설치 완료
  3. 라이브러리 검색 경로 구성 완료 — .NET이 런타임에 MLIR을 찾을 수 있도록 설정
  4. 빌드 도구 검증 완료 — 개발 준비 완료 (mlir-opt, dotnet)

이제 MLIR과 상호작용하는 F# 코드를 작성할 준비가 되었다. 다음 장에서는 코드를 작성하기 전에 이해해야 할 핵심 MLIR 개념들을 살펴본다: dialect, operation, region, block, 그리고 SSA 형식이다.

다음 장

Chapter 01: MLIR 입문으로 이동하여 MLIR IR의 기본 개념을 학습한다.

Chapter 01: MLIR 입문

소개

이전 챕터에서 LLVM/MLIR을 소스에서 빌드하고 .NET SDK를 설정했다. 필요한 도구는 모두 설치되었다. 하지만 MLIR을 생성하는 F# 코드를 작성하기 전에, MLIR이 무엇이고 프로그램을 어떻게 표현하는지 이해해야 한다.

MLIR은 전통적인 중간 표현(intermediate representation)과 다르다. 단순히 “하나의 IR“이 아니라, 서로 상호 운용할 수 있는 여러 IR(dialect이라고 부른다)을 구축하기 위한 프레임워크이다. 이 다단계(multi-level) 철학이 MLIR을 컴파일러 개발에 강력하게 만드는 핵심이다. 고수준 함수형 언어를 매우 저수준인 LLVM IR로 직접 변환하도록 강제하는 대신, MLIR은 언어의 의미론(semantics)을 필요한 만큼 보존하는 중간 표현을 정의한 다음, 단계적으로 점진적 하강(progressive lowering)할 수 있게 해준다.

FunLang의 컴파일 파이프라인은 다음과 같다:

FunLang Typed AST
    ↓
High-Level MLIR (arith, func, scf dialects)
    ↓
Low-Level MLIR (LLVM dialect)
    ↓
LLVM IR
    ↓
Native Machine Code

이 챕터에서는 MLIR IR을 이해하기 위한 멘탈 모델을 제공한다. 다섯 가지 핵심 개념 — dialect, operation, region, block, 그리고 SSA form — 을 구체적인 예제를 통해 배운다. 챕터를 마치면 MLIR 텍스트 IR을 읽고, FunLang 프로그램이 MLIR 구조에 어떻게 매핑되는지 이해할 수 있을 것이다.

MLIR IR 구조

완전한 MLIR 프로그램을 보면서 각 부분을 분석해 본다. 다음은 두 개의 32비트 정수를 더하는 간단한 함수이다:

module {
  func.func @add(%arg0: i32, %arg1: i32) -> i32 {
    %result = arith.addi %arg0, %arg1 : i32
    return %result : i32
  }
}

한 줄씩 분석해 본다:

  • module { ... }: 모든 MLIR 프로그램은 module에 포함된다. module은 모든 코드를 담는 최상위 컨테이너로, C의 컴파일 단위(compilation unit)나 .NET의 어셈블리와 유사하다.

  • func.func @add(...) -> i32 { ... }: func dialect의 operation으로, @add라는 이름의 함수를 정의한다. @ 접두사는 심볼(함수 이름)을 나타낸다. 이 함수는 두 개의 인자를 받아 i32(32비트 정수)를 반환한다.

  • %arg0: i32, %arg1: i32: 함수 매개변수이다. 각 매개변수는 타입 어노테이션(: i32)을 가진 SSA 값(%로 시작)이다. 이것이 함수의 입력이다.

  • %result = arith.addi %arg0, %arg1 : i32: arith dialect의 산술 덧셈 operation이다. 두 피연산자(%arg0%arg1)를 받아 더한 후, 새로운 SSA 값 %result를 생성한다. : i32 접미사는 결과 타입을 지정한다.

  • return %result : i32: 함수의 return operation이다. %result 값을 호출자에게 반환한다. : i32 타입 어노테이션은 타입 안전성을 보장한다.

MLIR의 모든 요소에는 목적과 타입이 있다. 암시적 변환이나 정의되지 않은 동작(undefined behavior)은 없다. 이러한 엄격함이 MLIR이 공격적인 최적화와 검증을 수행할 수 있게 해주는 것이다.

Dialect

Dialect은 관련된 operation, 타입, attribute를 그룹화하는 네임스페이스이다. Dialect은 MLIR의 확장성 메커니즘이다 — 모든 가능한 operation을 하나의 거대한 IR에 넣는 대신, MLIR은 도메인에 맞는 커스텀 dialect을 정의할 수 있게 해준다.

사용할 내장 Dialect

FunLang 컴파일러에서는 주로 다음 표준 dialect들을 사용한다:

  1. arith — 산술 연산

    • arith.addi, arith.subi, arith.muli, arith.divsi (부호 있는 정수 산술)
    • arith.cmpi (정수 비교: <, >, == 등)
    • arith.constant (정수 및 부동소수점 상수)
  2. func — 함수 정의 및 호출

    • func.func (함수 정의)
    • func.call (함수 호출)
    • func.return (함수에서 반환)
  3. scf — 구조적 제어 흐름(Structured Control Flow)

    • scf.if (조건부 실행)
    • scf.for (카운트 루프)
    • scf.while (조건 루프)
  4. llvm — LLVM dialect (lowering 대상)

    • llvm.func, llvm.call, llvm.add
    • 이 dialect은 LLVM IR 구성 요소와 1:1로 매핑된다

커스텀 Dialect

이 튜토리얼 시리즈의 후반부(Chapter 10-11)에서는 다음과 같은 operation을 가진 FunLang dialect을 정의하게 된다:

  • funlang.closure (클로저 생성)
  • funlang.apply (클로저에 인자를 적용)
  • funlang.match (패턴 매칭)

커스텀 dialect을 사용하면 컴파일 과정에서 고수준 의미론을 보존할 수 있다. FunLang 클로저를 즉시 저수준 구조체 할당과 함수 포인터로 변환하는 대신, 고수준 funlang.closure operation으로 표현한다. 이렇게 하면 최적화를 작성하고 이해하기가 더 쉬워진다.

Dialect 명명 규칙

Operation은 항상 자신이 속한 dialect 이름을 접두사로 가지며, 점(.)으로 구분된다:

arith.addi   // "arith" dialect의 "addi" operation
func.call    // "func" dialect의 "call" operation
llvm.load    // "llvm" dialect의 "load" operation

이를 통해 이름 충돌을 방지한다. arith dialect의 addi는 가상의 mydialect.addi와 구별된다.

Operation

Operation은 MLIR IR의 기본 단위이다. MLIR에서는 함수 정의, 산술 명령어, 제어 흐름 등 모든 것이 operation으로 표현된다. 심지어 타입과 attribute도 operation에 첨부된다.

Operation의 구조

텍스트 형식에서 operation은 다음과 같은 구조를 가진다:

%results = dialect.opname(%operands) {attributes} : (types) -> result_type

덧셈 예제에서 각 구성 요소를 살펴본다:

%result = arith.addi %arg0, %arg1 : i32
  • %result: 이 operation이 생성하는 SSA 값이다. 이 값은 이후 operation에서 사용할 수 있다. % 접두사는 SSA 값을 심볼(@function_name)과 구별한다.

  • arith.addi: operation 이름(dialect + opname)이다.

  • %arg0, %arg1: 피연산자(operation의 입력)이다. 이전에 정의된 SSA 값(이 경우 함수 인자)이다.

  • : i32: 타입 제약 조건이다. 이 operation은 32비트 정수에 대해 동작한다.

모든 operation이 결과를 생성하는 것은 아니다. 예를 들어, return은 함수를 종료하는 operation이지만 이후에 사용할 값을 생성하지는 않는다:

return %result : i32

복수 결과를 가진 Operation

일부 operation은 여러 값을 생성한다. 예를 들어, 몫과 나머지를 모두 반환하는 나눗셈 operation이 있다:

%quot, %rem = arith.divrem %dividend, %divisor : i32

이제 %quot%rem 모두 사용 가능한 SSA 값이다.

Attribute를 가진 Operation

Attribute는 컴파일 타임 상수 메타데이터를 제공한다. 예를 들어, 정수 상수는 다음과 같다:

%zero = arith.constant 0 : i32

0은 attribute(상수 값)이고, i32는 타입이다. Attribute는 런타임 값이 아니라 컴파일 타임에 IR에 내장되는 것이다.

Region과 Block

MLIR operation은 region을 포함할 수 있고, region은 block을 포함한다. 이것이 MLIR이 중첩된 스코프와 제어 흐름을 표현하는 방식이다.

Region

Region은 block의 목록이다. 함수 본문은 region이다. scf.if와 같은 제어 흐름 operation에는 “then“과 “else” 분기를 위한 region이 있다.

다음은 하나의 region에 하나의 block을 포함하는 함수이다:

func.func @example() -> i32 {
  %one = arith.constant 1 : i32
  return %one : i32
}

중괄호 { ... }가 함수의 region을 구분한다. region 내부에는 두 개의 operation(상수와 return)을 가진 하나의 block이 있다.

Block

Block은 선형적으로 실행되는 operation의 시퀀스이다. 모든 block은 terminator operation — 제어를 다른 곳으로 이전하는 operation(return, branch 등) — 으로 끝나야 한다. Block을 “통과(fall through)“할 수 없다.

제어 흐름이 있을 때 block이 필수적이 된다. 다음은 두 개의 block을 가진 함수이다:

func.func @conditional(%cond: i1, %a: i32, %b: i32) -> i32 {
  cf.cond_br %cond, ^then_block, ^else_block

^then_block:
  return %a : i32

^else_block:
  return %b : i32
}

분석해 본다:

  • cf.cond_br %cond, ^then_block, ^else_block: 조건 분기 operation(cf control-flow dialect)이다. %cond가 참이면 ^then_block으로, 그렇지 않으면 ^else_block으로 점프한다. 이것이 entry block의 terminator이다.

  • ^then_block:: block 레이블이다. ^ 접두사는 block을 나타낸다. block 이름은 함수 내에서 로컬이다.

  • return %a : i32: ^then_block의 terminator이다. %a를 호출자에게 반환한다.

  • ^else_block:: 또 다른 block 레이블이다.

  • return %b : i32: ^else_block의 terminator이다. %b를 반환한다.

Block 인자 (MLIR의 Phi Node 처리 방식)

MLIR은 LLVM의 phi node 대신 block 인자를 사용한다. LLVM IR에서는 여러 선행 block의 값을 병합하기 위해 phi node를 사용한다. MLIR에서는 block으로 분기할 때 값을 인자로 전달한다.

다음은 두 값을 병합하는 예제이다:

func.func @merge_example(%cond: i1, %a: i32, %b: i32) -> i32 {
  cf.cond_br %cond, ^merge(%a : i32), ^merge(%b : i32)

^merge(%result: i32):
  return %result : i32
}

무슨 일이 일어나는지 살펴본다:

  • cf.cond_br %cond, ^merge(%a : i32), ^merge(%b : i32): ^merge block으로 분기하되, 조건이 참이면 %a를, 거짓이면 %b를 전달한다.

  • ^merge(%result: i32):: ^merge block은 i32 타입의 인자 하나를 기대한다고 선언한다. 어느 분기가 선택되든, 전달된 값이 이 block 내에서 %result가 된다.

이 방식은 LLVM의 phi node보다 깔끔하다. 데이터 흐름이 분기 지점에서 명시적으로 표현되며, 사후에 재구성할 필요가 없기 때문이다.

SSA Form (Static Single Assignment)

MLIR은 SSA form을 사용한다. 즉, 모든 값은 정확히 한 번만 정의되고 절대 변경되지 않는다. %x를 정의하면 다시 할당할 수 없다. 이 속성 덕분에 “지금 어떤 버전의 변수를 보고 있는 것인가?“를 추적할 필요가 없어 최적화가 단순해진다.

SSA 동작 예시

다음 FunLang 코드를 살펴본다:

let x = 5
let y = x + 3
let z = y * 2
z

MLIR SSA form에서 각 let 바인딩은 새로운 SSA 값이 된다:

func.func @example() -> i32 {
  %x = arith.constant 5 : i32
  %three = arith.constant 3 : i32
  %y = arith.addi %x, %three : i32
  %two = arith.constant 2 : i32
  %z = arith.muli %y, %two : i32
  return %z : i32
}

주목할 점:

  • let 바인딩은 새로운 SSA 값(%x, %y, %z)이 된다.
  • 상수는 값을 생성하는 operation(arith.constant)이다.
  • 어떤 값도 재할당되지 않는다.

SSA와 가변성(Mutability)

FunLang은 불변(immutable)이므로 SSA와 자연스럽게 매핑된다. 하지만 변이(mutation)가 있는 명령형 코드는 어떨까? 다음을 살펴본다:

int x = 1;
x = x + 1;
return x;

SSA에서는 x를 변경할 수 없다. 대신, 새로운 버전을 생성한다:

%x0 = arith.constant 1 : i32
%one = arith.constant 1 : i32
%x1 = arith.addi %x0, %one : i32
return %x1 : i32

각 “변이“는 새로운 SSA 값(%x0, %x1 등)을 생성한다. 이 변환을 SSA conversion이라고 하며, 명령형 언어의 컴파일러에서 자동으로 처리된다.

FunLang은 함수형이므로 이 작업은 필요하지 않다 — 모든 let 바인딩이 이미 새로운 이름을 도입하기 때문이다.

핵심 통찰: SSA는 최적화를 가능하게 한다

SSA form은 많은 컴파일러 최적화를 간단하게 만들어 준다. 예를 들어:

  • Dead code elimination(죽은 코드 제거): SSA 값이 정의되었지만 사용되지 않으면, 해당 값을 정의하는 operation을 삭제한다.
  • Constant propagation(상수 전파): %xarith.constant 5로 정의되었다면, %x의 모든 사용을 5로 대체한다.
  • Common subexpression elimination(공통 하위 표현식 제거): 두 operation이 같은 값을 계산하면, 하나를 재사용하고 다른 하나를 삭제한다.

이 모든 최적화는 값이 정의 후 절대 변경되지 않는다는 보장에 의존한다.

MLIR의 타입

MLIR은 강타입(strongly typed)이다. 모든 SSA 값, operation, 함수에는 타입이 있다. 타입 시스템은 확장 가능하며(dialect이 커스텀 타입을 정의할 수 있음), 다음은 사용하게 될 내장 타입이다:

정수 타입

  • i1 — 1비트 정수 (boolean)
  • i32 — 32비트 부호 있는 정수
  • i64 — 64비트 부호 있는 정수
  • i8, i16, i128 등 — 임의 비트 너비 정수

부동소수점 타입

  • f32 — 32비트 IEEE 754 float
  • f64 — 64비트 IEEE 754 double

Index 타입

  • index — 배열 인덱싱을 위한 플랫폼 의존 정수 (대상 아키텍처에 따라 일반적으로 32비트 또는 64비트)

메모리 타입

  • memref<4xi32> — 메모리상의 4개 i32 값 배열에 대한 참조
  • memref<*xf64>f64 값에 대한 unranked(동적) 메모리 참조

함수 타입

  • (i32, i32) -> i32 — 두 개의 i32 인자를 받아 i32를 반환하는 함수

FunLang 타입 매핑

FunLang 타입이 MLIR 타입에 어떻게 매핑되는지 정리하면 다음과 같다:

FunLang 타입MLIR 타입비고
Inti64FunLang 정수는 인터프리터에서 임의 정밀도이지만, 64비트로 컴파일한다
Booli1True = 1, False = 0
String!llvm.ptr (LLVM dialect 포인터)문자열은 힙에 할당된 null 종료 C 문자열이다
Floatf64배정밀도 부동소수점
List<'a>!llvm.ptr리스트는 힙에 할당된 연결 구조이다
Tuple<'a, 'b, ...>!llvm.struct<...>튜플은 LLVM struct로 컴파일된다

! 접두사는 dialect에서 정의된 타입을 나타낸다 (예: !llvm.ptr는 LLVM dialect의 포인터 타입).

Progressive Lowering

MLIR의 강력함은 progressive lowering에 있다: 한 번에 크게 변환하는 대신, 고수준 operation을 여러 단계에 걸쳐 저수준 operation으로 변환하는 방식이다.

FunLang 컴파일 파이프라인

이 튜토리얼에서 구축할 파이프라인은 다음과 같다:

Stage 1: AST → High-Level MLIR
    FunLang AST (타입 검사기에서 전달)
    ↓
    arith, func, scf dialect을 사용하여 MLIR로 변환
    예: `let x = 1 + 2`는 `%x = arith.addi ...`가 됩니다

Stage 2: High-Level MLIR → LLVM Dialect
    `arith.addi` 같은 operation이 `llvm.add`로 lowering됩니다
    구조적 제어 흐름(`scf.if`)은 basic block과 branch로 lowering됩니다

Stage 3: LLVM Dialect → LLVM IR
    MLIR의 LLVM dialect이 텍스트 LLVM IR로 변환됩니다

Stage 4: LLVM IR → Native Code
    LLVM 백엔드(llc)가 대상 플랫폼의 머신 코드로 컴파일합니다

각 lowering 단계는 pass — IR을 재작성하는 변환 — 이다. MLIR은 pass 정의, 패턴 기반 재작성, 각 단계 후 검증을 위한 인프라를 제공한다.

Progressive Lowering이 중요한 이유

FunLang의 패턴 매칭을 컴파일하는 경우를 생각해 보자. LLVM IR로 직접 lowering해야 한다면, 즉시 basic block, phi node, 메모리 로드로 이루어진 복잡한 결정 트리로 확장해야 한다. 하지만 progressive lowering을 사용하면:

  1. 고수준: 패턴 매칭을 구조를 보존하는 funlang.match operation으로 표현한다.
  2. 중간 수준: funlang.matchscf.ifscf.while(구조적 제어 흐름)로 lowering한다.
  3. 저수준: scf.if를 LLVM basic block과 branch로 lowering한다.

각 단계에서 해당 추상화 수준에 맞는 최적화를 수행할 수 있다. 패턴 매칭 최적화(중복 검사 제거)는 고수준에서 이루어지고, LLVM 수준 최적화(레지스터 할당, 명령어 스케줄링)는 저수준에서 이루어진다.

종합 예제

여러 개념을 함께 사용하는 좀 더 현실적인 MLIR 예제를 살펴본다:

module {
  func.func @factorial(%n: i64) -> i64 {
    %c0 = arith.constant 0 : i64
    %c1 = arith.constant 1 : i64
    %is_zero = arith.cmpi eq, %n, %c0 : i64
    cf.cond_br %is_zero, ^base_case, ^recursive_case

  ^base_case:
    return %c1 : i64

  ^recursive_case:
    %n_minus_1 = arith.subi %n, %c1 : i64
    %rec_result = func.call @factorial(%n_minus_1) : (i64) -> i64
    %result = arith.muli %n, %rec_result : i64
    return %result : i64
  }
}

이 코드를 추적해 본다:

  1. func.func @factorial(%n: i64) -> i64: 하나의 64비트 정수를 받아 64비트 정수를 반환하는 @factorial 함수를 정의한다.

  2. %c0 = arith.constant 0 : i64: 상수 0을 생성한다.

  3. %c1 = arith.constant 1 : i64: 상수 1을 생성한다.

  4. %is_zero = arith.cmpi eq, %n, %c0 : i64: %n0을 동등성 비교한다. 결과는 i1(boolean)이다.

  5. cf.cond_br %is_zero, ^base_case, ^recursive_case: 참이면 ^base_case로, 아니면 ^recursive_case로 분기한다.

  6. ^base_case:: n == 0이면 1을 반환한다.

  7. ^recursive_case:: n > 0이면 n * factorial(n - 1)을 계산한다:

    • %n_minus_1 = arith.subi %n, %c1: n - 1을 계산한다.
    • %rec_result = func.call @factorial(%n_minus_1): 재귀 호출이다.
    • %result = arith.muli %n, %rec_result: n과 재귀 결과를 곱한다.
    • return %result: 결과를 반환한다.

이 예제는 다음을 보여준다:

  • SSA form: 모든 값(%c0, %n_minus_1 등)이 한 번만 정의된다.
  • Operation: 상수, 비교, 산술, 함수 호출.
  • Region과 block: 함수 본문은 세 개의 block(entry, ^base_case, ^recursive_case)을 가진 region이다.
  • Terminator: 모든 block이 terminator(cf.cond_br 또는 return)로 끝난다.
  • Dialect: arith, func, cf dialect을 사용한다.

학습 내용 정리

이제 MLIR의 다섯 가지 핵심 개념을 이해하게 되었다:

  1. Dialect: operation, 타입, attribute의 네임스페이스 (예: arith, func, llvm).
  2. Operation: MLIR IR의 기본 단위 (예: arith.addi, func.call).
  3. Region: block의 목록 (예: 함수 본문).
  4. Block: terminator로 끝나는 operation 시퀀스 (예: 제어 흐름의 basic block).
  5. SSA form: 모든 값이 정확히 한 번만 정의되며 불변.

구체적인 예제(산술, 제어 흐름, 재귀)를 통해 이 개념들이 어떻게 함께 작동하는지 살펴보았다. 또한 progressive lowering — IR을 한 번에 큰 점프가 아닌 단계적으로 변환하는 철학 — 을 이해하게 되었다.

다음 단계

다음 챕터에서는 MLIR IR을 생성하는 첫 번째 F# 프로그램을 작성한다. P/Invoke를 사용하여 MLIR의 C API를 호출하고, 컴파일러의 “Hello, World“인 상수 정수를 반환하는 프로그램을 생성할 것이다.

**Chapter 02: Hello MLIR from F#**로 계속된다 (작성 예정).

참고 자료

챕터 02: F#에서 Hello MLIR

소개

챕터 00에서는 MLIR을 소스에서 빌드하고 .NET SDK를 설치했다. 챕터 01에서는 MLIR의 핵심 개념인 dialect, operation, region, block, SSA 형태에 대해 배웠다. 이제 코드를 작성할 차례다.

이 챕터는 처음으로 “동작한다!“를 경험하는 순간이다. F# 스크립트를 작성하여 P/Invoke를 통해 MLIR C API를 호출하고, MLIR context와 module을 생성하며, 산술 연산이 포함된 간단한 함수를 구성한 뒤, 결과 IR을 콘솔에 출력할 것이다. 이 챕터를 마치면 F#이 MLIR과 상호운용될 수 있다는 것을 증명하는 동작하는 프로토타입을 갖게 된다.

이 챕터의 코드는 의도적으로 즉흥적이고 탐색적이다. P/Invoke 바인딩을 인라인으로 정의하고 우선 동작하는 것에 집중한다. 챕터 03에서 이 바인딩들을 적절한 재사용 가능한 모듈로 구성할 것이다.

만들어 볼 것

첫 번째 MLIR 프로그램은 상수 정수를 반환하는 함수다. MLIR 텍스트 형식으로는 다음과 같다:

module {
  func.func @return_forty_two() -> i32 {
    %c42 = arith.constant 42 : i32
    return %c42 : i32
  }
}

이것은 가장 간단한 MLIR 프로그램이다:

  • @return_forty_two라는 이름의 함수 하나
  • 매개변수 없음
  • i32 (32비트 정수) 반환
  • 본문에서 상수 42를 생성하고 반환

이것을 MLIR의 C API를 사용하여 F#에서 프로그래밍 방식으로 구성할 것이다.

P/Invoke 이해하기

P/Invoke (Platform Invoke)는 .NET의 외부 함수 인터페이스(FFI) 메커니즘이다. 관리 코드(F#, C# 등)에서 공유 라이브러리(Linux의 .so, macOS의 .dylib, Windows의 .dll)에 있는 비관리 네이티브 함수를 호출할 수 있게 해준다.

DllImport 속성

네이티브 함수를 호출하려면 [<DllImport>] 속성을 사용하여 함수 시그니처를 선언한다. 패턴은 다음과 같다:

[<DllImport("library-name", CallingConvention = CallingConvention.Cdecl)>]
extern ReturnType functionName(ParamType1 param1, ParamType2 param2)

하나씩 살펴본다:

  • [<DllImport("library-name")>]: 함수가 포함된 공유 라이브러리를 지정한다. MLIR의 경우 "MLIR-C"이다(파일 확장자 없이 – .NET이 플랫폼에 따라 자동으로 .so, .dylib, .dll을 추가한다).

  • CallingConvention = CallingConvention.Cdecl: 인수 전달 및 스택 관리 방식을 지정한다. MLIR C API는 C 라이브러리의 표준인 C 호출 규약(Cdecl)을 사용한다.

  • extern: 네이티브 코드에 정의된 외부 함수임을 표시한다.

  • 반환 타입과 매개변수: C 함수 시그니처와 정확히 일치해야 한다. MLIR은 불투명 구조체 핸들(내부 데이터 구조에 대한 포인터)을 사용하며, F#에서는 이를 nativeint로 표현한다.

MLIR 핸들 타입

MLIR C API는 모든 IR 엔티티에 불투명 구조체 타입을 사용한다:

// MLIR-C API (C header)
typedef struct MlirContext { void *ptr; } MlirContext;
typedef struct MlirModule { void *ptr; } MlirModule;
typedef struct MlirOperation { void *ptr; } MlirOperation;
// ... and many more

각 구조체는 포인터를 감싸는 래퍼다. F#의 관점에서는 내부 구조에 관심이 없고, MLIR 함수 간에 이 핸들들을 전달하기만 하면 된다. 단일 nativeint 필드를 가진 F# 구조체로 표현한다:

[<Struct>]
type MlirContext =
    val Handle: nativeint
    new(handle) = { Handle = handle }

이는 C 메모리 레이아웃(단일 포인터)과 일치하며, P/Invoke 경계를 넘어 안전하게 전달할 수 있다.

F# 스크립트 생성

코드를 작성해 본다. 작업 디렉터리에 HelloMlir.fsx라는 새 파일을 생성한다:

cd $HOME
mkdir -p mlir-fsharp-tutorial
cd mlir-fsharp-tutorial
touch HelloMlir.fsx

텍스트 편집기에서 HelloMlir.fsx를 열고 필요한 import부터 시작한다:

open System
open System.Runtime.InteropServices
  • System: .NET 핵심 타입
  • System.Runtime.InteropServices: DllImport, CallingConvention, 마샬링 속성 포함

핸들 타입 정의

먼저 필요한 MLIR 핸들 타입을 정의한다. 이 간단한 예제에서는 다음이 필요하다:

  • MlirContext: MLIR 루트 context (메모리, dialect 등을 관리)
  • MlirModule: module (함수의 최상위 컨테이너)
  • MlirLocation: 소스 위치 정보 (operation 생성에 필요)
  • MlirType: 타입 시스템 (i32 사용 예정)
  • MlirBlock: 기본 블록
  • MlirRegion: 블록을 포함하는 region
  • MlirOperation: operation (함수나 산술 연산 생성 결과)
  • MlirValue: SSA 값 (operation의 결과)

스크립트에 다음 타입 정의를 추가한다:

[<Struct>]
type MlirContext =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirModule =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirLocation =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirType =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirBlock =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirRegion =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirOperation =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirValue =
    val Handle: nativeint
    new(handle) = { Handle = handle }

각 핸들은 네이티브 포인터를 감싸는 얇은 래퍼다. [<Struct>] 속성은 이들이 힙에 할당되는 클래스가 아닌 스택에 할당되는 값 타입임을 보장하며, 작은 래퍼에 대해 더 효율적이다.

문자열 마샬링: MlirStringRef

MLIR의 C API는 소유권 문제 없이 문자열을 전달하기 위해 MlirStringRef라는 사용자 정의 문자열 구조체를 사용한다. C에서는 다음과 같이 정의되어 있다:

typedef struct MlirStringRef {
    const char *data;
    size_t length;
} MlirStringRef;

이 레이아웃을 F#에서 맞춰야 한다:

[<Struct; StructLayout(LayoutKind.Sequential)>]
type MlirStringRef =
    val Data: nativeint  // const char*
    val Length: nativeint  // size_t

    new(data, length) = { Data = data; Length = length }

    static member FromString(s: string) =
        let bytes = System.Text.Encoding.UTF8.GetBytes(s)
        let ptr = Marshal.AllocHGlobal(bytes.Length)
        Marshal.Copy(bytes, 0, ptr, bytes.Length)
        MlirStringRef(ptr, nativeint bytes.Length)

    member this.Free() =
        if this.Data <> nativeint 0 then
            Marshal.FreeHGlobal(this.Data)

세부 사항을 살펴본다:

  • [<StructLayout(LayoutKind.Sequential)>]: 필드가 선언된 순서대로 메모리에 배치되도록 보장한다 (C 구조체와 일치).

  • FromString(s: string): F# 문자열을 MlirStringRef로 변환하는 헬퍼다. 비관리 메모리를 할당하고, UTF-8 바이트를 복사한 후, 해당 메모리를 가리키는 MlirStringRef를 반환한다.

  • Free(): 비관리 메모리를 해제한다. 문자열을 MLIR에 전달한 후 반드시 호출해야 하며, 그렇지 않으면 메모리 누수가 발생한다.

P/Invoke 함수 선언

이제 P/Invoke 선언을 작성한다. 이 예제에 필요한 함수만 선언한다. 스크립트에 다음을 추가한다:

module MlirNative =
    // Context management
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirContext mlirContextCreate()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirContextDestroy(MlirContext ctx)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__func__()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__arith__()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirDialectHandleRegisterDialect(MlirDialectHandle handle, MlirContext ctx)

    // Module management
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirModule mlirModuleCreateEmpty(MlirLocation loc)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirModuleGetOperation(MlirModule m)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirModuleDestroy(MlirModule m)

    // Location
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirLocation mlirLocationUnknownGet(MlirContext ctx)

    // Types
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirIntegerTypeGet(MlirContext ctx, uint32 bitwidth)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunctionTypeGet(MlirContext ctx, nativeint numInputs, MlirType& inputs, nativeint numResults, MlirType& results)

    // Operation building
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirOperationCreate(MlirOperationState& state)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirOperationGetRegion(MlirOperation op, nativeint pos)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirRegionAppendOwnedBlock(MlirRegion region, MlirBlock block)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirBlock mlirBlockCreate(nativeint numArgs, MlirType& argTypes, MlirLocation& argLocs)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirBlockInsertOwnedOperation(MlirBlock block, nativeint pos, MlirOperation op)

    // Printing
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationPrint(MlirOperation op, MlirStringCallback callback, nativeint userData)

함수 시그니처에 등장한 추가 핸들 타입도 필요하다:

[<Struct>]
type MlirDialectHandle =
    val Handle: nativeint
    new(handle) = { Handle = handle }

[<Struct>]
type MlirOperationState =
    val Name: MlirStringRef
    val Location: MlirLocation
    val NumResults: nativeint
    val Results: nativeint  // Pointer to MlirType array
    val NumOperands: nativeint
    val Operands: nativeint  // Pointer to MlirValue array
    val NumRegions: nativeint
    val Regions: nativeint  // Pointer to MlirRegion array
    val NumSuccessors: nativeint
    val Successors: nativeint  // Pointer to MlirBlock array
    val NumAttributes: nativeint
    val Attributes: nativeint  // Pointer to MlirNamedAttribute array
    val EnableResultTypeInference: bool

그리고 출력을 위한 콜백 delegate도 필요하다:

[<UnmanagedFunctionPointer(CallingConvention.Cdecl)>]
type MlirStringCallback = delegate of MlirStringRef * nativeint -> unit

이 delegate는 IR 출력 시 MLIR이 F# 코드를 콜백할 수 있게 해준다. MLIR은 출력의 각 청크마다 콜백을 호출한다.

MLIR Module 구성하기

이제 MLIR module을 생성하는 로직을 작성한다. 스크립트에 다음 함수를 추가한다:

let buildHelloMlir() =
    // Step 1: Create MLIR context
    let ctx = MlirNative.mlirContextCreate()
    printfn "Created MLIR context"

    // Step 2: Load required dialects (func and arith)
    let funcDialect = MlirNative.mlirGetDialectHandle__func__()
    MlirNative.mlirDialectHandleRegisterDialect(funcDialect, ctx)
    let arithDialect = MlirNative.mlirGetDialectHandle__arith__()
    MlirNative.mlirDialectHandleRegisterDialect(arithDialect, ctx)
    printfn "Registered func and arith dialects"

    // Step 3: Create an empty module
    let loc = MlirNative.mlirLocationUnknownGet(ctx)
    let mlirModule = MlirNative.mlirModuleCreateEmpty(loc)
    printfn "Created empty module"

    // Step 4: Create the function type () -> i32
    let i32Type = MlirNative.mlirIntegerTypeGet(ctx, 32u)
    let mutable resultType = i32Type
    let funcType = MlirNative.mlirFunctionTypeGet(ctx, nativeint 0, &i32Type, nativeint 1, &resultType)
    printfn "Created function type () -> i32"

    // Step 5: Create func.func operation
    let funcName = MlirStringRef.FromString("func.func")
    let mutable funcState =
        { MlirOperationState.Name = funcName
          Location = loc
          NumResults = nativeint 0
          Results = nativeint 0
          NumOperands = nativeint 0
          Operands = nativeint 0
          NumRegions = nativeint 1  // Function body is a region
          Regions = nativeint 0
          NumSuccessors = nativeint 0
          Successors = nativeint 0
          NumAttributes = nativeint 0
          Attributes = nativeint 0
          EnableResultTypeInference = false }

    let funcOp = MlirNative.mlirOperationCreate(&funcState)
    funcName.Free()
    printfn "Created func.func operation"

    // Step 6: Create a block for the function body
    let funcRegion = MlirNative.mlirOperationGetRegion(funcOp, nativeint 0)
    let block = MlirNative.mlirBlockCreate(nativeint 0, &i32Type, &loc)
    MlirNative.mlirRegionAppendOwnedBlock(funcRegion, block)
    printfn "Created function body block"

    // Step 7: Create arith.constant 42 : i32
    let constantName = MlirStringRef.FromString("arith.constant")
    let mutable constantState =
        { MlirOperationState.Name = constantName
          Location = loc
          NumResults = nativeint 1
          Results = Marshal.AllocHGlobal(sizeof<nativeint>)
          NumOperands = nativeint 0
          Operands = nativeint 0
          NumRegions = nativeint 0
          Regions = nativeint 0
          NumSuccessors = nativeint 0
          Successors = nativeint 0
          NumAttributes = nativeint 0
          Attributes = nativeint 0
          EnableResultTypeInference = false }
    Marshal.StructureToPtr(i32Type, constantState.Results, false)

    let constantOp = MlirNative.mlirOperationCreate(&constantState)
    constantName.Free()
    Marshal.FreeHGlobal(constantState.Results)
    printfn "Created arith.constant operation"

    // Step 8: Create return operation
    let returnName = MlirStringRef.FromString("func.return")
    let mutable returnState =
        { MlirOperationState.Name = returnName
          Location = loc
          NumResults = nativeint 0
          Results = nativeint 0
          NumOperands = nativeint 1
          Operands = nativeint 0  // Should point to constant's result
          NumRegions = nativeint 0
          Regions = nativeint 0
          NumSuccessors = nativeint 0
          Successors = nativeint 0
          NumAttributes = nativeint 0
          Attributes = nativeint 0
          EnableResultTypeInference = false }

    let returnOp = MlirNative.mlirOperationCreate(&returnState)
    returnName.Free()
    printfn "Created func.return operation"

    // Step 9: Insert operations into the block
    MlirNative.mlirBlockInsertOwnedOperation(block, nativeint 0, constantOp)
    MlirNative.mlirBlockInsertOwnedOperation(block, nativeint 1, returnOp)
    printfn "Inserted operations into block"

    // Step 10: Get module operation and print
    let moduleOp = MlirNative.mlirModuleGetOperation(mlirModule)
    printfn "\n--- Generated MLIR IR ---"

    let mutable output = ""
    let callback = MlirStringCallback(fun strRef _ ->
        let length = int strRef.Length
        let bytes = Array.zeroCreate<byte> length
        Marshal.Copy(strRef.Data, bytes, 0, length)
        let text = System.Text.Encoding.UTF8.GetString(bytes)
        output <- output + text
    )

    MlirNative.mlirOperationPrint(moduleOp, callback, nativeint 0)
    printfn "%s" output
    printfn "--- End of IR ---\n"

    // Cleanup
    MlirNative.mlirModuleDestroy(mlirModule)
    MlirNative.mlirContextDestroy(ctx)
    printfn "Cleaned up MLIR context and module"

이 함수에는 많은 내용이 있으므로 단계별로 살펴본다.

단계별 분석

1단계: MLIR Context 생성

let ctx = MlirNative.mlirContextCreate()

MLIR context는 등록된 dialect, 타입 고유화, 메모리 관리 등 모든 MLIR 상태를 관리하는 루트 객체다. 다른 작업을 하기 전에 반드시 context를 생성해야 한다.

2단계: Dialect 로드

let funcDialect = MlirNative.mlirGetDialectHandle__func__()
MlirNative.mlirDialectHandleRegisterDialect(funcDialect, ctx)
let arithDialect = MlirNative.mlirGetDialectHandle__arith__()
MlirNative.mlirDialectHandleRegisterDialect(arithDialect, ctx)

MLIR dialect은 요청 시 로드된다. 함수 정의를 위한 func dialect과 상수 및 산술 연산을 위한 arith dialect이 필요하다. 각 dialect에는 getter 함수(mlirGetDialectHandle__<dialect>__)가 있으며, 이를 context에 등록한다.

3단계: 빈 Module 생성

let loc = MlirNative.mlirLocationUnknownGet(ctx)
let mlirModule = MlirNative.mlirModuleCreateEmpty(loc)

모든 MLIR operation에는 소스 위치가 필요하다. 생성된 코드의 경우 “unknown” 위치를 사용한다. 그런 다음 빈 module을 생성한다.

4단계: 함수 타입 생성

let i32Type = MlirNative.mlirIntegerTypeGet(ctx, 32u)
let mutable resultType = i32Type
let funcType = MlirNative.mlirFunctionTypeGet(ctx, nativeint 0, &i32Type, nativeint 1, &resultType)

함수 시그니처를 정의한다: 입력 없음(nativeint 0), 출력 하나(i32). mlirFunctionTypeGet 함수는 타입 배열에 대한 포인터를 받으므로 &를 사용하여 참조로 전달한다.

5-6단계: 함수 Operation 및 본문 Block 생성

MLIR에서 operation을 생성하려면 MlirOperationState를 구성하고 mlirOperationCreate를 호출해야 한다. 이것이 모든 operation 생성의 일반적인 패턴이다:

  1. operation 이름, 위치, 피연산자, 결과, region 등을 포함하는 MlirOperationState 생성
  2. mlirOperationCreate(&state) 호출
  3. 할당된 메모리(operation 이름 문자열 등) 해제

함수의 경우 region(함수 본문)과 그 안의 block도 생성한다.

7-8단계: 함수 내부 Operation 생성

두 개의 operation을 생성한다:

  1. arith.constant 42 : i32: 상수 operation이다. 하나의 결과(값 42)를 가진다.
  2. func.return %result: 반환 operation이다. 하나의 피연산자(상수의 결과)를 가진다.

각 operation은 동일한 패턴을 따른다: MlirOperationState 생성, mlirOperationCreate 호출, 정리.

9단계: Operation을 Block에 삽입

MlirNative.mlirBlockInsertOwnedOperation(block, nativeint 0, constantOp)
MlirNative.mlirBlockInsertOwnedOperation(block, nativeint 1, returnOp)

Operation은 실행 순서대로 block에 삽입해야 한다. 상수가 먼저(위치 0), 그다음 반환(위치 1)이다.

10단계: IR 출력

let callback = MlirStringCallback(fun strRef _ ->
    // MlirStringRef를 F# 문자열로 변환
    // output 변수에 누적
)
MlirNative.mlirOperationPrint(moduleOp, callback, nativeint 0)

MLIR의 출력 함수는 콜백을 사용한다. 콜백은 출력의 청크마다 여러 번 호출된다. 이 청크들을 하나의 문자열로 누적하여 출력한다.

정리

MlirNative.mlirModuleDestroy(mlirModule)
MlirNative.mlirContextDestroy(ctx)

메모리 누수를 방지하기 위해 항상 module과 context를 파괴해야 한다.

스크립트 실행

HelloMlir.fsx 파일 끝에 다음을 추가한다:

[<EntryPoint>]
let main argv =
    buildHelloMlir()
    0

이제 F# Interactive로 스크립트를 실행한다:

LD_LIBRARY_PATH=$HOME/mlir-install/lib dotnet fsi HelloMlir.fsx

예상 출력:

Created MLIR context
Registered func and arith dialects
Created empty module
Created function type () -> i32
Created func.func operation
Created function body block
Created arith.constant operation
Created func.return operation
Inserted operations into block

--- Generated MLIR IR ---
module {
  func.func @return_forty_two() -> i32 {
    %c42 = arith.constant 42 : i32
    return %c42 : i32
  }
}
--- End of IR ---

Cleaned up MLIR context and module

이 출력이 보인다면 성공이다! F#에서 MLIR을 호출하고 프로그래밍 방식으로 IR을 생성하는 데 성공한 것이다.

문제 해결

DllNotFoundException: Unable to load shared library ‘MLIR-C’

원인: .NET 런타임이 MLIR-C 공유 라이브러리를 찾을 수 없다.

해결 방법: LD_LIBRARY_PATH (Linux) 또는 DYLD_LIBRARY_PATH (macOS)에 $HOME/mlir-install/lib이 포함되어 있는지 확인한다:

export LD_LIBRARY_PATH=$HOME/mlir-install/lib:$LD_LIBRARY_PATH
dotnet fsi HelloMlir.fsx

또는 환경 변수를 인라인으로 지정하여 실행한다:

LD_LIBRARY_PATH=$HOME/mlir-install/lib dotnet fsi HelloMlir.fsx

AccessViolationException 또는 Segmentation Fault

원인: 잘못된 P/Invoke 시그니처 (잘못된 매개변수 타입, byref 매개변수에 & 누락 등).

해결 방법: DllImport 선언이 MLIR-C API 헤더 파일과 정확히 일치하는지 확인한다. MLIR-C API 문서$HOME/mlir-install/include/mlir-c/의 헤더 파일을 참고한다.

비어있거나 잘못된 형식의 IR 출력

원인: Operation이 block에 제대로 삽입되지 않았거나, region이 operation에 제대로 연결되지 않았다.

해결 방법: 연산 순서를 확인한다: operation 생성 -> region 가져오기 -> block 생성 -> block에 operation 삽입.

배운 내용

이 챕터에서 다음을 배웠다:

  1. MLIR 핸들 타입 정의 - 네이티브 포인터를 감싸는 F# 구조체로 정의했다.
  2. [<DllImport>] 사용 - 외부 MLIR-C API 함수를 선언했다.
  3. 문자열 마샬링 - MlirStringRef와 수동 메모리 관리를 사용했다.
  4. MLIR context와 module 생성 - 처음부터 생성했다.
  5. 프로그래밍 방식으로 operation 구성 - MlirOperationState를 사용했다.
  6. MLIR IR 출력 - 콜백을 사용했다.
  7. 메모리 관리 - 완료 후 context와 module을 파괴했다.

이제 F#이 MLIR과 상호운용될 수 있다는 것이 증명되었다. 하지만 이 코드는 정돈되지 않았다 – 타입과 P/Invoke 함수를 스크립트에 인라인으로 정의하고 있다. 실제 컴파일러에서는 이 바인딩들이 재사용 가능한 모듈로 구성되어야 한다.

다음 챕터

챕터 03: P/Invoke 바인딩으로 이어서 이 바인딩들을 깔끔한 API와 MLIR-C API의 포괄적인 커버리지를 갖춘 적절한 F# 모듈로 구성하는 방법을 배운다.

추가 참고 자료

Chapter 03: P/Invoke 바인딩

소개

Chapter 02에서는 MLIR IR을 생성하는 첫 번째 F# 프로그램을 작성했다. 핸들 타입을 정의하고, DllImport 선언을 작성하며, MLIR C API를 성공적으로 호출하여 간단한 함수를 만들었다. 하지만 그 코드는 탐색적이고 임시방편적이었다 – 모든 바인딩이 스크립트 내에 인라인으로 정의되어 있었다.

실제 컴파일러에는 체계적이고 재사용 가능한 바인딩이 필요하다. 이 장에서는 Chapter 02에서 배운 모든 것을 가져와 적절한 F# 모듈인 MlirBindings.fs로 체계화한다. 이 모듈은 이후 모든 장의 기반이 된다. 이 장에서 배울 내용은 다음과 같다:

  • 기능 영역별(context, module, type, operation 등)로 MLIR C API 바인딩을 구성하는 방법
  • 문자열 마샬링을 올바르고 안전하게 처리하는 방법
  • IR 출력을 위한 콜백 처리 방법
  • 크로스 플랫폼 고려 사항 (Linux, macOS, Windows)

이 장을 마치면 MLIR C API에 대한 완전하고 프로덕션에 사용할 수 있는 바인딩 레이어를 갖추게 된다.

설계 철학

바인딩 레이어는 다음 원칙을 따른다:

  1. 얇은 래퍼: C API 위에 최소한의 추상화만 적용한다. 각 F# 함수는 C 함수에 직접 대응된다.
  2. 타입 안전성: MLIR 핸들에 F# struct 타입을 사용하여 컴파일 시점에 타입 오류를 잡는다.
  3. 메모리 안전성: 안전한 문자열 마샬링과 정리를 위한 유틸리티를 제공하되, destroy 함수를 호출해야 하는 필요성을 숨기지 않는다.
  4. 완전성: 컴파일러에 필요한 모든 MLIR C API 함수를 다룬다 (context, module, type, operation, region, block, location, attribute, value).
  5. 문서화: 모든 함수에 목적과 MLIR C API 대응 관계를 설명하는 주석이 있다.

프로젝트 구조

코드를 작성하기 전에 적절한 F# 프로젝트를 설정한다. Chapter 02에서는 스크립트(.fsx)를 사용했지만, 이제 라이브러리 프로젝트를 만든다:

cd $HOME/mlir-fsharp-tutorial
dotnet new classlib -lang F# -o MlirBindings
cd MlirBindings

이렇게 하면 다음과 같은 구조의 새 F# 라이브러리 프로젝트가 생성된다:

MlirBindings/
├── MlirBindings.fsproj
└── Library.fs

기본 Library.fs를 삭제한다:

rm Library.fs

MlirBindings.fs를 처음부터 새로 만든다.

모듈 구성

바인딩 모듈은 다음과 같은 논리적 섹션으로 구성된다:

  1. 핸들 타입: MLIR 불투명 타입을 나타내는 F# struct
  2. 문자열 마샬링: MlirStringRef와 헬퍼 함수
  3. 콜백 델리게이트: MLIR 콜백을 위한 함수 포인터 타입
  4. Context 관리: Context 생성, 소멸, dialect 로딩
  5. Module 관리: Module 생성, 연산, 출력
  6. Location: 소스 위치 유틸리티
  7. 타입 시스템: 정수 타입, 함수 타입, LLVM 타입
  8. Operation 빌딩: Operation state 생성 및 조립
  9. Region과 Block: Region 및 Block 생성과 관리
  10. Value와 Attribute: SSA value 및 attribute 처리

단계별로 구축해 본다.

핸들 타입

MlirBindings 디렉토리에 새 파일 MlirBindings.fs를 생성한다:

touch MlirBindings.fs

프로젝트 파일 MlirBindings.fsproj를 편집하여 파일을 추가한다. 내용을 다음으로 교체한다:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="MlirBindings.fs" />
  </ItemGroup>

</Project>

이제 MlirBindings.fs를 열고 namespace와 import부터 시작한다:

namespace MlirBindings

open System
open System.Runtime.InteropServices

필요한 모든 핸들 타입을 정의한다. 이것들은 MLIR 내부 구조체에 대한 불투명 포인터이다:

/// MLIR context - manages dialects, types, and global state
[<Struct>]
type MlirContext =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR module - top-level container for functions and global data
[<Struct>]
type MlirModule =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR operation - fundamental IR unit (instructions, functions, etc.)
[<Struct>]
type MlirOperation =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR type - represents value types (i32, f64, pointers, etc.)
[<Struct>]
type MlirType =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR location - source code location for diagnostics
[<Struct>]
type MlirLocation =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR region - contains a list of blocks
[<Struct>]
type MlirRegion =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR block - basic block containing a sequence of operations
[<Struct>]
type MlirBlock =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR value - SSA value produced by an operation
[<Struct>]
type MlirValue =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR attribute - compile-time constant metadata
[<Struct>]
type MlirAttribute =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR named attribute - key-value pair (name: attribute)
[<Struct; StructLayout(LayoutKind.Sequential)>]
type MlirNamedAttribute =
    val Name: MlirStringRef
    val Attribute: MlirAttribute

/// MLIR dialect handle - opaque handle to a registered dialect
[<Struct>]
type MlirDialectHandle =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR identifier - interned string for operation names, attribute keys, etc.
[<Struct>]
type MlirIdentifier =
    val Handle: nativeint
    new(handle) = { Handle = handle }

각 핸들 타입에는 목적을 설명하는 문서 주석이 포함되어 있다. [<Struct>] 어트리뷰트는 이들이 스택에 할당되는 값 타입임을 보장한다.

문자열 마샬링

MLIR은 소유권 의미 없이 문자열을 전달하기 위해 MlirStringRef를 사용한다. 헬퍼 유틸리티와 함께 정의한다:

/// MLIR string reference - non-owning pointer to string data
[<Struct; StructLayout(LayoutKind.Sequential)>]
type MlirStringRef =
    val Data: nativeint  // const char*
    val Length: nativeint  // size_t

    new(data, length) = { Data = data; Length = length }

    /// Convert F# string to MlirStringRef (allocates unmanaged memory)
    static member FromString(s: string) =
        if String.IsNullOrEmpty(s) then
            MlirStringRef(nativeint 0, nativeint 0)
        else
            let bytes = System.Text.Encoding.UTF8.GetBytes(s)
            let ptr = Marshal.AllocHGlobal(bytes.Length)
            Marshal.Copy(bytes, 0, ptr, bytes.Length)
            MlirStringRef(ptr, nativeint bytes.Length)

    /// Convert MlirStringRef to F# string
    member this.ToString() =
        if this.Data = nativeint 0 || this.Length = nativeint 0 then
            String.Empty
        else
            let length = int this.Length
            let bytes = Array.zeroCreate<byte> length
            Marshal.Copy(this.Data, bytes, 0, length)
            System.Text.Encoding.UTF8.GetString(bytes)

    /// Free unmanaged memory (call after passing to MLIR)
    member this.Free() =
        if this.Data <> nativeint 0 then
            Marshal.FreeHGlobal(this.Data)

    /// Create from string, use it, and automatically free
    static member WithString(s: string, f: MlirStringRef -> 'a) =
        let strRef = MlirStringRef.FromString(s)
        try
            f strRef
        finally
            strRef.Free()

WithString 헬퍼는 특히 유용하다 – 할당과 정리를 자동으로 처리한다:

// 이렇게 하는 대신:
let strRef = MlirStringRef.FromString("func.func")
let op = createOp strRef
strRef.Free()

// 다음과 같이 작성할 수 있습니다:
MlirStringRef.WithString "func.func" (fun strRef ->
    createOp strRef
)

콜백 델리게이트

MLIR은 출력과 문자열 처리를 위해 콜백을 사용한다. 델리게이트 타입을 정의한다:

/// Callback for MLIR IR printing (invoked with chunks of output)
[<UnmanagedFunctionPointer(CallingConvention.Cdecl)>]
type MlirStringCallback = delegate of MlirStringRef * nativeint -> unit

/// Callback for diagnostic handlers
[<UnmanagedFunctionPointer(CallingConvention.Cdecl)>]
type MlirDiagnosticCallback = delegate of MlirDiagnostic * nativeint -> MlirLogicalResult

/// MLIR diagnostic handle
[<Struct>]
type MlirDiagnostic =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR logical result (success/failure)
[<Struct>]
type MlirLogicalResult =
    val Value: int8
    new(value) = { Value = value }
    member this.IsSuccess = this.Value <> 0y
    member this.IsFailure = this.Value = 0y

Operation State

MlirOperationState struct는 operation을 빌드하는 데 사용된다. 배열에 대한 포인터를 포함하기 때문에 복잡하다:

/// MLIR operation state - used to construct operations
[<Struct; StructLayout(LayoutKind.Sequential)>]
type MlirOperationState =
    val mutable Name: MlirStringRef
    val mutable Location: MlirLocation
    val mutable NumResults: nativeint
    val mutable Results: nativeint  // Pointer to MlirType array
    val mutable NumOperands: nativeint
    val mutable Operands: nativeint  // Pointer to MlirValue array
    val mutable NumRegions: nativeint
    val mutable Regions: nativeint  // Pointer to MlirRegion array
    val mutable NumSuccessors: nativeint
    val mutable Successors: nativeint  // Pointer to MlirBlock array
    val mutable NumAttributes: nativeint
    val mutable Attributes: nativeint  // Pointer to MlirNamedAttribute array
    val mutable EnableResultTypeInference: bool

참고: mlirOperationCreate에 전달하기 전에 수정해야 하므로 모든 필드가 mutable이다.

P/Invoke 선언

이제 핵심 부분이다: MLIR C API에 대한 P/Invoke 선언이다. 모듈로 구성한다:

module MlirNative =

    //==========================================================================
    // Context 관리
    //==========================================================================

    /// Create an MLIR context
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirContext mlirContextCreate()

    /// Destroy an MLIR context (frees all owned IR)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirContextDestroy(MlirContext ctx)

    /// Check if two contexts are equal
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern bool mlirContextEqual(MlirContext ctx1, MlirContext ctx2)

    /// Get dialect handle for the 'func' dialect
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__func__()

    /// Get dialect handle for the 'arith' dialect
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__arith__()

    /// Get dialect handle for the 'scf' (structured control flow) dialect
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__scf__()

    /// Get dialect handle for the 'cf' (control flow) dialect
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__cf__()

    /// Get dialect handle for the 'llvm' dialect
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__llvm__()

    /// Register a dialect with a context
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirDialectHandleRegisterDialect(MlirDialectHandle handle, MlirContext ctx)

    //==========================================================================
    // Module 관리
    //==========================================================================

    /// Create an empty MLIR module
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirModule mlirModuleCreateEmpty(MlirLocation loc)

    /// Create an MLIR module from parsing a string
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirModule mlirModuleCreateParse(MlirContext ctx, MlirStringRef mlir)

    /// Get the top-level operation of a module
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirModuleGetOperation(MlirModule m)

    /// Get the body (region) of a module
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirModuleGetBody(MlirModule m)

    /// Destroy a module (frees all owned IR)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirModuleDestroy(MlirModule m)

    //==========================================================================
    // Location
    //==========================================================================

    /// Create an unknown location (for generated code)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirLocation mlirLocationUnknownGet(MlirContext ctx)

    /// Create a file-line-column location
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirLocation mlirLocationFileLineColGet(MlirContext ctx, MlirStringRef filename, uint32 line, uint32 col)

    /// Create a fused location (combination of multiple locations)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirLocation mlirLocationFusedGet(MlirContext ctx, nativeint numLocs, MlirLocation& locs, MlirAttribute metadata)

    //==========================================================================
    // 타입 시스템
    //==========================================================================

    /// Create an integer type with specified bit width
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirIntegerTypeGet(MlirContext ctx, uint32 bitwidth)

    /// Create a signed integer type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirIntegerTypeSignedGet(MlirContext ctx, uint32 bitwidth)

    /// Create an unsigned integer type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirIntegerTypeUnsignedGet(MlirContext ctx, uint32 bitwidth)

    /// Create a floating-point type (f32, f64, etc.)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirF32TypeGet(MlirContext ctx)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirF64TypeGet(MlirContext ctx)

    /// Create the index type (platform-dependent integer for indexing)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirIndexTypeGet(MlirContext ctx)

    /// Create a function type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunctionTypeGet(MlirContext ctx, nativeint numInputs, MlirType& inputs, nativeint numResults, MlirType& results)

    /// Get the number of inputs for a function type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirFunctionTypeGetNumInputs(MlirType funcType)

    /// Get the number of results for a function type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirFunctionTypeGetNumResults(MlirType funcType)

    /// Create an LLVM pointer type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirLLVMPointerTypeGet(MlirContext ctx, uint32 addressSpace)

    /// Create an LLVM void type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirLLVMVoidTypeGet(MlirContext ctx)

    /// Create an LLVM struct type
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirLLVMStructTypeLiteralGet(MlirContext ctx, nativeint numFieldTypes, MlirType& fieldTypes, bool isPacked)

    //==========================================================================
    // Attribute 시스템
    //==========================================================================

    /// Create an integer attribute
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirIntegerAttrGet(MlirType typ, int64 value)

    /// Create a float attribute
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirFloatAttrDoubleGet(MlirContext ctx, MlirType typ, float64 value)

    /// Create a string attribute
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirStringAttrGet(MlirContext ctx, MlirStringRef str)

    /// Create a type attribute
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirTypeAttrGet(MlirType typ)

    /// Create a symbol reference attribute
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirFlatSymbolRefAttrGet(MlirContext ctx, MlirStringRef symbol)

    /// Create an array attribute
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirArrayAttrGet(MlirContext ctx, nativeint numElements, MlirAttribute& elements)

    /// Get an identifier from a string
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirIdentifier mlirIdentifierGet(MlirContext ctx, MlirStringRef str)

    /// Create a named attribute
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirNamedAttribute mlirNamedAttributeGet(MlirIdentifier name, MlirAttribute attr)

    //==========================================================================
    // Operation 빌딩
    //==========================================================================

    /// Create an operation state
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperationState mlirOperationStateGet(MlirStringRef name, MlirLocation loc)

    /// Create an operation from an operation state
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirOperationCreate(MlirOperationState& state)

    /// Destroy an operation (if not owned by a block)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationDestroy(MlirOperation op)

    /// Get the name of an operation
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirIdentifier mlirOperationGetName(MlirOperation op)

    /// Get the number of regions in an operation
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirOperationGetNumRegions(MlirOperation op)

    /// Get a region from an operation by index
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirOperationGetRegion(MlirOperation op, nativeint pos)

    /// Get the number of results an operation produces
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirOperationGetNumResults(MlirOperation op)

    /// Get a result value from an operation by index
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirOperationGetResult(MlirOperation op, nativeint pos)

    /// Get the number of operands an operation takes
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirOperationGetNumOperands(MlirOperation op)

    /// Get an operand value from an operation by index
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirOperationGetOperand(MlirOperation op, nativeint pos)

    /// Set an operand of an operation
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationSetOperand(MlirOperation op, nativeint pos, MlirValue value)

    /// Print an operation to a callback
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationPrint(MlirOperation op, MlirStringCallback callback, nativeint userData)

    /// Verify an operation (check IR well-formedness)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern bool mlirOperationVerify(MlirOperation op)

    //==========================================================================
    // Region 관리
    //==========================================================================

    /// Create a new region
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirRegionCreate()

    /// Destroy a region (if not owned by an operation)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirRegionDestroy(MlirRegion region)

    /// Append a block to a region (region takes ownership)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirRegionAppendOwnedBlock(MlirRegion region, MlirBlock block)

    /// Insert a block into a region at position (region takes ownership)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirRegionInsertOwnedBlock(MlirRegion region, nativeint pos, MlirBlock block)

    /// Get the first block in a region
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirBlock mlirRegionGetFirstBlock(MlirRegion region)

    //==========================================================================
    // Block 관리
    //==========================================================================

    /// Create a new block with arguments
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirBlock mlirBlockCreate(nativeint numArgs, MlirType& argTypes, MlirLocation& argLocs)

    /// Destroy a block (if not owned by a region)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirBlockDestroy(MlirBlock block)

    /// Get the number of arguments a block has
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirBlockGetNumArguments(MlirBlock block)

    /// Get a block argument by index
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirBlockGetArgument(MlirBlock block, nativeint pos)

    /// Append an operation to a block (block takes ownership)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirBlockAppendOwnedOperation(MlirBlock block, MlirOperation op)

    /// Insert an operation into a block at position (block takes ownership)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirBlockInsertOwnedOperation(MlirBlock block, nativeint pos, MlirOperation op)

    /// Get the first operation in a block
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirBlockGetFirstOperation(MlirBlock block)

    //==========================================================================
    // Value
    //==========================================================================

    /// Get the type of a value
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirValueGetType(MlirValue value)

    /// Print a value
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirValuePrint(MlirValue value, MlirStringCallback callback, nativeint userData)

이것은 컴파일러 구축에 필요한 모든 MLIR C API 함수를 다루는 포괄적인 바인딩 레이어이다. 각 함수에는 목적을 설명하는 문서가 포함되어 있다.

크로스 플랫폼 라이브러리 로딩

중요한 세부 사항이 하나 있다: 라이브러리 이름 "MLIR-C"는 .NET이 자동으로 올바른 확장자를 추가하기 때문에 플랫폼 간에 동작한다:

  • Linux: libMLIR-C.so
  • macOS: libMLIR-C.dylib
  • Windows: MLIR-C.dll

그러나 .NET은 런타임에 라이브러리를 어디서 찾을 수 있는지 알아야 한다. 이 내용은 Chapter 00에서 다루었다 (LD_LIBRARY_PATH 또는 DYLD_LIBRARY_PATH 설정). 프로덕션 애플리케이션의 경우 여러 가지 옵션이 있다:

옵션 1: 환경 변수 (개발 시)

실행 전에 라이브러리 경로를 설정한다:

LD_LIBRARY_PATH=$HOME/mlir-install/lib dotnet run

옵션 2: NativeLibrary.SetDllImportResolver (런타임)

.NET의 NativeLibrary API를 사용하여 커스텀 검색 경로를 지정한다:

open System.Runtime.InteropServices

module LibraryLoader =
    let initialize() =
        NativeLibrary.SetDllImportResolver(
            typeof<MlirContext>.Assembly,
            fun libraryName assemblyPath searchPath ->
                if libraryName = "MLIR-C" then
                    let customPath = Environment.GetEnvironmentVariable("MLIR_INSTALL_PATH")
                    if not (String.IsNullOrEmpty(customPath)) then
                        let libPath =
                            if RuntimeInformation.IsOSPlatform(OSPlatform.Linux) then
                                System.IO.Path.Combine(customPath, "lib", "libMLIR-C.so")
                            elif RuntimeInformation.IsOSPlatform(OSPlatform.OSX) then
                                System.IO.Path.Combine(customPath, "lib", "libMLIR-C.dylib")
                            else
                                System.IO.Path.Combine(customPath, "bin", "MLIR-C.dll")
                        NativeLibrary.Load(libPath)
                    else
                        nativeint 0
                else
                    nativeint 0
        )

MLIR 함수를 호출하기 전에 LibraryLoader.initialize()를 호출한다.

옵션 3: rpath (Linux/macOS 바이너리)

컴파일된 바이너리의 경우, rpath를 사용하여 실행 파일에 라이브러리 검색 경로를 내장한다. 이 방법은 이 튜토리얼의 범위를 벗어나지만, 배포 애플리케이션의 표준 솔루션이다.

헬퍼 유틸리티

자주 사용되는 패턴을 위한 고수준 헬퍼 함수를 추가한다:

module MlirHelpers =
    /// Print an operation to a string
    let operationToString (op: MlirOperation) : string =
        let mutable output = ""
        let callback = MlirStringCallback(fun strRef _ ->
            output <- output + strRef.ToString()
        )
        MlirNative.mlirOperationPrint(op, callback, nativeint 0)
        output

    /// Print a module to a string
    let moduleToString (m: MlirModule) : string =
        let op = MlirNative.mlirModuleGetOperation(m)
        operationToString op

    /// Print a value to a string
    let valueToString (v: MlirValue) : string =
        let mutable output = ""
        let callback = MlirStringCallback(fun strRef _ ->
            output <- output + strRef.ToString()
        )
        MlirNative.mlirValuePrint(v, callback, nativeint 0)
        output

    /// Create a context with common dialects registered
    let createContextWithDialects() : MlirContext =
        let ctx = MlirNative.mlirContextCreate()
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__func__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__arith__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__scf__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__cf__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__llvm__(), ctx)
        ctx

    /// Create a block with no arguments
    let createEmptyBlock(ctx: MlirContext) : MlirBlock =
        let loc = MlirNative.mlirLocationUnknownGet(ctx)
        let mutable dummyType = MlirType()
        let mutable dummyLoc = loc
        MlirNative.mlirBlockCreate(nativeint 0, &dummyType, &dummyLoc)

이 유틸리티들은 일반적인 작업을 래핑하여 사용자 코드에서 보일러플레이트를 줄여 준다.

전체 MlirBindings.fs 목록

다음은 모든 섹션이 통합된 완전한 MlirBindings.fs 파일이다:

namespace MlirBindings

open System
open System.Runtime.InteropServices

//=============================================================================
// Handle Types
//=============================================================================

/// MLIR context - manages dialects, types, and global state
[<Struct>]
type MlirContext =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR module - top-level container for functions and global data
[<Struct>]
type MlirModule =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR operation - fundamental IR unit (instructions, functions, etc.)
[<Struct>]
type MlirOperation =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR type - represents value types (i32, f64, pointers, etc.)
[<Struct>]
type MlirType =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR location - source code location for diagnostics
[<Struct>]
type MlirLocation =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR region - contains a list of blocks
[<Struct>]
type MlirRegion =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR block - basic block containing a sequence of operations
[<Struct>]
type MlirBlock =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR value - SSA value produced by an operation
[<Struct>]
type MlirValue =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR attribute - compile-time constant metadata
[<Struct>]
type MlirAttribute =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR dialect handle - opaque handle to a registered dialect
[<Struct>]
type MlirDialectHandle =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR identifier - interned string for operation names, attribute keys, etc.
[<Struct>]
type MlirIdentifier =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR diagnostic handle
[<Struct>]
type MlirDiagnostic =
    val Handle: nativeint
    new(handle) = { Handle = handle }

/// MLIR logical result (success/failure)
[<Struct>]
type MlirLogicalResult =
    val Value: int8
    new(value) = { Value = value }
    member this.IsSuccess = this.Value <> 0y
    member this.IsFailure = this.Value = 0y

//=============================================================================
// String Marshalling
//=============================================================================

/// MLIR string reference - non-owning pointer to string data
[<Struct; StructLayout(LayoutKind.Sequential)>]
type MlirStringRef =
    val Data: nativeint
    val Length: nativeint

    new(data, length) = { Data = data; Length = length }

    static member FromString(s: string) =
        if String.IsNullOrEmpty(s) then
            MlirStringRef(nativeint 0, nativeint 0)
        else
            let bytes = System.Text.Encoding.UTF8.GetBytes(s)
            let ptr = Marshal.AllocHGlobal(bytes.Length)
            Marshal.Copy(bytes, 0, ptr, bytes.Length)
            MlirStringRef(ptr, nativeint bytes.Length)

    member this.ToString() =
        if this.Data = nativeint 0 || this.Length = nativeint 0 then
            String.Empty
        else
            let length = int this.Length
            let bytes = Array.zeroCreate<byte> length
            Marshal.Copy(this.Data, bytes, 0, length)
            System.Text.Encoding.UTF8.GetString(bytes)

    member this.Free() =
        if this.Data <> nativeint 0 then
            Marshal.FreeHGlobal(this.Data)

    static member WithString(s: string, f: MlirStringRef -> 'a) =
        let strRef = MlirStringRef.FromString(s)
        try
            f strRef
        finally
            strRef.Free()

/// MLIR named attribute - key-value pair
[<Struct; StructLayout(LayoutKind.Sequential)>]
type MlirNamedAttribute =
    val Name: MlirStringRef
    val Attribute: MlirAttribute

//=============================================================================
// Callback Delegates
//=============================================================================

/// Callback for MLIR IR printing
[<UnmanagedFunctionPointer(CallingConvention.Cdecl)>]
type MlirStringCallback = delegate of MlirStringRef * nativeint -> unit

/// Callback for diagnostic handlers
[<UnmanagedFunctionPointer(CallingConvention.Cdecl)>]
type MlirDiagnosticCallback = delegate of MlirDiagnostic * nativeint -> MlirLogicalResult

//=============================================================================
// Operation State
//=============================================================================

/// MLIR operation state - used to construct operations
[<Struct; StructLayout(LayoutKind.Sequential)>]
type MlirOperationState =
    val mutable Name: MlirStringRef
    val mutable Location: MlirLocation
    val mutable NumResults: nativeint
    val mutable Results: nativeint
    val mutable NumOperands: nativeint
    val mutable Operands: nativeint
    val mutable NumRegions: nativeint
    val mutable Regions: nativeint
    val mutable NumSuccessors: nativeint
    val mutable Successors: nativeint
    val mutable NumAttributes: nativeint
    val mutable Attributes: nativeint
    val mutable EnableResultTypeInference: bool

//=============================================================================
// P/Invoke Declarations
//=============================================================================

module MlirNative =

    // Context Management
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirContext mlirContextCreate()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirContextDestroy(MlirContext ctx)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__func__()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__arith__()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__scf__()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__cf__()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirDialectHandle mlirGetDialectHandle__llvm__()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirDialectHandleRegisterDialect(MlirDialectHandle handle, MlirContext ctx)

    // Module Management
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirModule mlirModuleCreateEmpty(MlirLocation loc)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirModule mlirModuleCreateParse(MlirContext ctx, MlirStringRef mlir)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirModuleGetOperation(MlirModule m)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirModuleGetBody(MlirModule m)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirModuleDestroy(MlirModule m)

    // Location
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirLocation mlirLocationUnknownGet(MlirContext ctx)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirLocation mlirLocationFileLineColGet(MlirContext ctx, MlirStringRef filename, uint32 line, uint32 col)

    // Type System
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirIntegerTypeGet(MlirContext ctx, uint32 bitwidth)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirF32TypeGet(MlirContext ctx)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirF64TypeGet(MlirContext ctx)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirIndexTypeGet(MlirContext ctx)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunctionTypeGet(MlirContext ctx, nativeint numInputs, MlirType& inputs, nativeint numResults, MlirType& results)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirLLVMPointerTypeGet(MlirContext ctx, uint32 addressSpace)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirLLVMVoidTypeGet(MlirContext ctx)

    // Attributes
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirIntegerAttrGet(MlirType typ, int64 value)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirStringAttrGet(MlirContext ctx, MlirStringRef str)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirTypeAttrGet(MlirType typ)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirIdentifier mlirIdentifierGet(MlirContext ctx, MlirStringRef str)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirNamedAttribute mlirNamedAttributeGet(MlirIdentifier name, MlirAttribute attr)

    // Operation Building
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperationState mlirOperationStateGet(MlirStringRef name, MlirLocation loc)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirOperationCreate(MlirOperationState& state)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationDestroy(MlirOperation op)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirOperationGetRegion(MlirOperation op, nativeint pos)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirOperationGetNumResults(MlirOperation op)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirOperationGetResult(MlirOperation op, nativeint pos)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationSetOperand(MlirOperation op, nativeint pos, MlirValue value)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationPrint(MlirOperation op, MlirStringCallback callback, nativeint userData)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern bool mlirOperationVerify(MlirOperation op)

    // Region Management
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirRegionCreate()

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirRegionAppendOwnedBlock(MlirRegion region, MlirBlock block)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirBlock mlirRegionGetFirstBlock(MlirRegion region)

    // Block Management
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirBlock mlirBlockCreate(nativeint numArgs, MlirType& argTypes, MlirLocation& argLocs)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirBlockGetNumArguments(MlirBlock block)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirBlockGetArgument(MlirBlock block, nativeint pos)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirBlockAppendOwnedOperation(MlirBlock block, MlirOperation op)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirBlockInsertOwnedOperation(MlirBlock block, nativeint pos, MlirOperation op)

    // Value
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirValueGetType(MlirValue value)

    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirValuePrint(MlirValue value, MlirStringCallback callback, nativeint userData)

//=============================================================================
// Helper Utilities
//=============================================================================

module MlirHelpers =
    let operationToString (op: MlirOperation) : string =
        let mutable output = ""
        let callback = MlirStringCallback(fun strRef _ ->
            output <- output + strRef.ToString()
        )
        MlirNative.mlirOperationPrint(op, callback, nativeint 0)
        output

    let moduleToString (m: MlirModule) : string =
        let op = MlirNative.mlirModuleGetOperation(m)
        operationToString op

    let valueToString (v: MlirValue) : string =
        let mutable output = ""
        let callback = MlirStringCallback(fun strRef _ ->
            output <- output + strRef.ToString()
        )
        MlirNative.mlirValuePrint(v, callback, nativeint 0)
        output

    let createContextWithDialects() : MlirContext =
        let ctx = MlirNative.mlirContextCreate()
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__func__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__arith__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__scf__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__cf__(), ctx)
        MlirNative.mlirDialectHandleRegisterDialect(MlirNative.mlirGetDialectHandle__llvm__(), ctx)
        ctx

이것이 완전하고 프로덕션에 사용할 수 있는 MLIR 바인딩 레이어이다.

라이브러리 빌드

라이브러리 프로젝트를 빌드한다:

cd $HOME/mlir-fsharp-tutorial/MlirBindings
dotnet build

예상 출력:

Build succeeded.
    0 Warning(s)
    0 Error(s)

컴파일된 라이브러리는 bin/Debug/net8.0/MlirBindings.dll에 위치한다.

바인딩 사용하기

새 바인딩을 사용하여 Chapter 02의 hello-world 예제를 다시 작성해 본다. 새 콘솔 프로젝트를 생성한다:

cd $HOME/mlir-fsharp-tutorial
dotnet new console -lang F# -o HelloMlirWithBindings
cd HelloMlirWithBindings
dotnet add reference ../MlirBindings/MlirBindings.fsproj

Program.fs의 내용을 다음으로 교체한다:

open System
open MlirBindings

[<EntryPoint>]
let main argv =
    // Create context with dialects
    let ctx = MlirHelpers.createContextWithDialects()
    printfn "Created MLIR context with dialects loaded"

    // Create empty module
    let loc = MlirNative.mlirLocationUnknownGet(ctx)
    let mlirModule = MlirNative.mlirModuleCreateEmpty(loc)
    printfn "Created empty module"

    // Print the module
    printfn "\nGenerated MLIR IR:"
    printfn "%s" (MlirHelpers.moduleToString mlirModule)

    // Cleanup
    MlirNative.mlirModuleDestroy(mlirModule)
    MlirNative.mlirContextDestroy(ctx)
    printfn "\nCleaned up"

    0

실행한다:

LD_LIBRARY_PATH=$HOME/mlir-install/lib dotnet run

예상 출력:

Created MLIR context with dialects loaded
Created empty module

Generated MLIR IR:
module {
}

Cleaned up

Chapter 02보다 훨씬 깔끔하다! 바인딩 모듈이 모든 마샬링과 보일러플레이트를 처리한다.

이 장에서 배운 내용

이 장에서는 다음을 수행했다:

  1. MLIR 바인딩을 구성하여 논리적 섹션으로 나뉜 재사용 가능한 F# 라이브러리 모듈을 만들었다.
  2. 포괄적인 핸들 타입을 정의하여 모든 MLIR 엔티티(context, module, operation, type, region, block, value, attribute)를 다루었다.
  3. 안전한 문자열 마샬링을 구현하여 MlirStringRef와 헬퍼 유틸리티를 만들었다.
  4. P/Invoke 바인딩을 선언하여 컴파일에 필요한 MLIR C API의 전체 표면적을 다루었다.
  5. 헬퍼 유틸리티를 생성하여 보일러플레이트를 줄였다 (출력, context 생성).
  6. 크로스 플랫폼 고려 사항을 이해하여 라이브러리 로딩을 다루었다.
  7. 바인딩 라이브러리를 빌드하고 사용하여 별도의 프로젝트에서 활용했다.

이제 MLIR에 대한 완전하고 프로덕션에 사용할 수 있는 바인딩 레이어를 갖추었다. 이 MlirBindings 모듈은 FunLang 컴파일러를 구축하는 이후 모든 장의 기반이 된다.

다음 장

다음 장에서는 FunLang 컴파일러 백엔드 구축을 시작한다. 타입이 지정된 FunLang AST를 F#에서 표현하기 위한 데이터 구조를 정의하고, 여기서 만든 바인딩을 사용하여 FunLang 표현식을 MLIR operation으로 변환하는 코드 생성 로직을 작성하기 시작한다.

Chapter 04: FunLang AST에서 MLIR로 (작성 예정)로 이어진다.

참고 자료

Chapter 04: F# 래퍼 레이어

소개

Chapter 03에서는 MLIR C API에 대한 완전한 P/Invoke 바인딩 모듈인 MlirBindings.fs를 구축했다. 이제 Context를 생성하고, Module을 만들며, Operation을 구성하는 등 MLIR C API의 모든 기능을 F#에서 호출할 수 있다.

하지만 Chapter 02와 03의 코드를 살펴보면 몇 가지 문제점이 드러난다:

문제 1: 리소스 누수 위험

let ctx = MlirNative.mlirContextCreate()
let loc = MlirNative.mlirLocationUnknownGet(ctx)
let mlirMod = MlirNative.mlirModuleCreateEmpty(loc)

// ... IR 구축 ...

// 정리를 잊어버리면 메모리 누수 발생
MlirNative.mlirModuleDestroy(mlirMod)
MlirNative.mlirContextDestroy(ctx)

수동으로 Destroy 함수를 호출해야 한다. 예외가 발생하거나 조기 반환이 있으면 리소스가 누수된다.

문제 2: 장황함

let state = MlirNative.mlirOperationStateGet(
    MlirStringRef.FromString("arith.constant"),
    location)
MlirNative.mlirOperationStateAddResults(&state, 1, &intType)
// ... 더 많은 state 조작 ...
let op = MlirNative.mlirOperationCreate(&state)

Operation 하나를 만드는데 5-10줄의 코드가 필요하다. 반복적이고 오류가 발생하기 쉽다.

문제 3: 타입 안전성 부족

let ctx = MlirNative.mlirContextCreate()
MlirNative.mlirContextDestroy(ctx)
// ctx는 이제 무효하지만, 타입 시스템이 이를 막지 못한다
let loc = MlirNative.mlirLocationUnknownGet(ctx) // 버그!

핸들을 해제한 후에도 여전히 사용할 수 있다. C API는 이를 막지 못한다.

이 장에서는 이러한 문제들을 해결하는 래퍼 레이어를 구축한다. 이 레이어는 원시 P/Invoke 바인딩을 관용적인 F# API로 감싸서 다음을 제공한다:

  • 자동 리소스 관리: IDisposableuse 키워드
  • 간결한 API: OpBuilder.CreateConstant(42) 같은 유창한 빌더
  • 생명주기 안전성: 부모 객체가 자식보다 먼저 파괴되는 것을 방지

이 장을 마치면 튜토리얼의 나머지 부분에서 사용할 깔끔하고 안전한 MLIR API를 갖게 된다.

소유권 문제

MLIR은 엄격한 소유권 계층 구조를 갖는다:

Context (root)
  └─ Module
       └─ Operation
            └─ Region
                 └─ Block
                      └─ Operation

각 객체는 부모에 속한다:

  • ModuleContext가 소유한다
  • OperationBlock이 소유한다
  • BlockRegion이 소유한다
  • RegionOperation이 소유한다

C++에서는 이 소유권이 자동으로 관리된다 (RAII와 unique_ptr). 부모가 파괴되면 자식도 자동으로 파괴된다.

P/Invoke에서는 이 소유권을 수동으로 관리해야 한다. 문제는 부모를 먼저 파괴하면 자식 핸들이 무효가 된다는 것이다:

// 버그가 있는 코드
let ctx = MlirNative.mlirContextCreate()
let loc = MlirNative.mlirLocationUnknownGet(ctx)
let mlirMod = MlirNative.mlirModuleCreateEmpty(loc)

// Context를 먼저 파괴
MlirNative.mlirContextDestroy(ctx)

// Module 핸들이 이제 무효 - 위험한 포인터!
MlirNative.mlirModuleGetOperation(mlirMod) // 세그멘테이션 폴트

F#의 가비지 컬렉터는 MLIR의 소유권 규칙을 알지 못한다. 따라서 우리가 강제해야 한다.

해결책: F# 래퍼는 부모 객체에 대한 참조를 저장한다. 자식이 살아있는 한 부모는 가비지 컬렉트되지 않는다.

type Module(context: Context, location: Location) =
    let handle = MlirNative.mlirModuleCreateEmpty(location.Handle)
    let contextRef = context  // 부모 참조 유지 - Context가 먼저 GC되는 것을 방지

    member _.Handle = handle

    interface IDisposable with
        member _.Dispose() =
            MlirNative.mlirModuleDestroy(handle)

Context 래퍼

MLIR의 최상위 객체인 Context부터 시작한다. 새 파일 MlirWrapper.fs를 만든다:

namespace MlirWrapper

open System
open MlirBindings

/// MLIR Context를 나타낸다. 모든 MLIR 객체의 소유자이며 메모리 관리를 담당한다.
/// Context는 dialect와 type을 등록하고 IR 구성을 위한 전역 환경을 제공한다.
type Context() =
    let mutable handle = MlirNative.mlirContextCreate()
    let mutable disposed = false

    /// 기본 MLIR context 핸들
    member _.Handle = handle

    /// 이 context에 dialect를 로드한다.
    /// dialect: 로드할 dialect의 이름 (예: "arith", "func", "llvm")
    member _.LoadDialect(dialect: string) =
        if disposed then
            raise (ObjectDisposedException("Context"))

        MlirStringRef.WithString dialect (fun nameRef ->
            MlirNative.mlirContextGetOrLoadDialect(handle, nameRef)
            |> ignore)

    interface IDisposable with
        member this.Dispose() =
            this.Dispose(true)
            GC.SuppressFinalize(this)

    member private _.Dispose(disposing: bool) =
        if not disposed then
            if disposing then
                // 관리 리소스 정리 (이 경우 없음)
                ()

            // 비관리 리소스 정리
            MlirNative.mlirContextDestroy(handle)
            handle <- Unchecked.defaultof<_>
            disposed <- true

설계 결정: disposed 플래그는 이중 해제를 방지한다. 동일한 Context에서 Dispose()를 두 번 호출하는 것은 안전하다 (두 번째 호출은 아무 작업도 하지 않는다).

사용 예:

let example () =
    use ctx = new Context()          // Context 생성
    ctx.LoadDialect("arith")         // Arithmetic dialect 로드
    ctx.LoadDialect("func")          // Function dialect 로드

    // ctx 사용...
    printfn "Context created: %A" ctx.Handle

    // 스코프가 끝나면 자동으로 Dispose 호출됨 - mlirContextDestroy 호출

F#의 use 키워드는 C#의 using과 동일하다. 스코프가 끝나면 자동으로 Dispose()를 호출한다. 예외가 발생해도 정리가 보장된다.

Location 래퍼

Location은 MLIR의 가벼운 값 타입이다. 리소스를 소유하지 않으므로 IDisposable이 필요하지 않다:

/// MLIR IR에서 소스 위치를 나타낸다. 컴파일 오류 보고에 사용된다.
type Location =
    | Unknown of Context
    | FileLineCol of Context * filename: string * line: int * col: int

    /// 기본 MLIR location 핸들을 반환한다
    member this.Handle =
        match this with
        | Unknown ctx ->
            MlirNative.mlirLocationUnknownGet(ctx.Handle)

        | FileLineCol (ctx, filename, line, col) ->
            MlirStringRef.WithString filename (fun filenameRef ->
                MlirNative.mlirLocationFileLineColGet(
                    ctx.Handle,
                    filenameRef,
                    uint32 line,
                    uint32 col))

설계 결정: 모든 MLIR 타입이 IDisposable을 필요로 하는 것은 아니다. Location, Type, Attribute는 값 타입이며 Context가 소유한다. 명시적 정리가 필요 없다.

사용 예:

use ctx = new Context()

let loc1 = Location.Unknown(ctx)
let loc2 = Location.FileLineCol(ctx, "example.fun", 10, 5)

printfn "Unknown location: %A" loc1.Handle
printfn "File location: %A" loc2.Handle

Module 래퍼

Module은 MLIR IR의 최상위 컨테이너다. 여러 함수와 전역 선언을 포함한다:

/// MLIR Module - 최상위 IR 컨테이너. 함수와 전역 선언을 포함한다.
type Module(context: Context, location: Location) =
    let handle = MlirNative.mlirModuleCreateEmpty(location.Handle)
    let contextRef = context  // Context 참조 유지 - 조기 GC 방지
    let mutable disposed = false

    /// 기본 MLIR module 핸들
    member _.Handle = handle

    /// 이 module이 속한 context
    member _.Context = contextRef

    /// 이 module의 body block을 반환한다 (최상위 operation들을 포함)
    member _.Body =
        let op = MlirNative.mlirModuleGetOperation(handle)
        let region = MlirNative.mlirOperationGetRegion(op, 0n)
        MlirNative.mlirRegionGetFirstBlock(region)

    /// MLIR IR을 검증한다. 모든 operation이 올바른 형식인지 확인한다.
    member _.Verify() =
        let op = MlirNative.mlirModuleGetOperation(handle)
        MlirNative.mlirOperationVerify(op)

    /// MLIR IR을 문자열로 출력한다
    member _.Print() =
        let op = MlirNative.mlirModuleGetOperation(handle)
        MlirHelpers.operationToString(op)

    interface IDisposable with
        member this.Dispose() =
            this.Dispose(true)
            GC.SuppressFinalize(this)

    member private _.Dispose(disposing: bool) =
        if not disposed then
            if disposing then
                ()

            MlirNative.mlirModuleDestroy(handle)
            disposed <- true

설계 결정: contextRef 필드는 Module이 존재하는 한 Context가 가비지 컬렉트되지 않도록 보장한다. 이는 소유권 안전성의 핵심이다.

사용 예:

use ctx = new Context()
ctx.LoadDialect("func")

let loc = Location.Unknown(ctx)
use mlirMod = new Module(ctx, loc)

// IR 구축...

if mlirMod.Verify() then
    printfn "Module IR:\n%s" (mlirMod.Print())
else
    failwith "IR verification failed"

OpBuilder: IR 구축을 위한 유창한 API

Operation을 만드는 것은 MLIR에서 가장 복잡한 작업이다. 원시 C API는 다음과 같다:

// 원시 P/Invoke - 15줄
let mutable state = MlirNative.mlirOperationStateGet(
    MlirStringRef.FromString("arith.constant"), location)

let mutable intType = MlirNative.mlirIntegerTypeGet(ctx, 32u)
MlirNative.mlirOperationStateAddResults(&state, 1, &intType)

let value = 42
let mutable attr = MlirNative.mlirIntegerAttrGet(intType, int64 value)
let mutable attrName = MlirStringRef.FromString("value")
MlirNative.mlirOperationStateAddAttributes(&state, 1, &attrName, &attr)

let op = MlirNative.mlirOperationCreate(&state)

이것을 한 줄로 줄이고 싶다:

let op = builder.CreateConstant(42, intType, location)

OpBuilder 클래스가 이를 가능하게 한다:

/// MLIR operation을 구축하기 위한 유창한 빌더 API.
/// 원시 operation state 조작을 숨기고 일반적인 operation에 대한 고수준 메서드를 제공한다.
type OpBuilder(context: Context) =
    let contextRef = context

    /// i32 타입을 반환한다
    member _.I32Type() =
        MlirNative.mlirIntegerTypeGet(contextRef.Handle, 32u)

    /// i64 타입을 반환한다
    member _.I64Type() =
        MlirNative.mlirIntegerTypeGet(contextRef.Handle, 64u)

    /// 함수 타입을 생성한다 (inputs -> results)
    member _.FunctionType(inputs: MlirType[], results: MlirType[]) =
        let mutable inputsArray = inputs
        let mutable resultsArray = results
        MlirNative.mlirFunctionTypeGet(
            contextRef.Handle,
            unativeint inputs.Length,
            &&inputsArray.[0],
            unativeint results.Length,
            &&resultsArray.[0])

    /// 정수 상수 operation을 생성한다: arith.constant
    member _.CreateConstant(value: int, typ: MlirType, location: Location) =
        let mutable state = MlirNative.mlirOperationStateGet(
            MlirStringRef.FromString("arith.constant"),
            location.Handle)

        // 결과 타입 추가
        let mutable resultType = typ
        MlirNative.mlirOperationStateAddResults(&state, 1n, &&resultType)

        // value attribute 추가
        let mutable attr = MlirNative.mlirIntegerAttrGet(typ, int64 value)
        let mutable attrName = MlirStringRef.FromString("value")
        MlirNative.mlirOperationStateAddAttributes(&state, 1n, &&attrName, &&attr)

        MlirNative.mlirOperationCreate(&state)

    /// 함수 operation을 생성한다: func.func
    member _.CreateFunction(name: string, funcType: MlirType, location: Location) =
        let mutable state = MlirNative.mlirOperationStateGet(
            MlirStringRef.FromString("func.func"),
            location.Handle)

        // sym_name attribute 추가 (함수 이름)
        MlirStringRef.WithString name (fun nameRef ->
            let mutable attr = MlirNative.mlirStringAttrGet(contextRef.Handle, nameRef)
            let mutable attrName = MlirStringRef.FromString("sym_name")
            MlirNative.mlirOperationStateAddAttributes(&state, 1n, &&attrName, &&attr))

        // function_type attribute 추가
        let mutable funcTypeAttr = MlirNative.mlirTypeAttrGet(funcType)
        let mutable funcTypeAttrName = MlirStringRef.FromString("function_type")
        MlirNative.mlirOperationStateAddAttributes(&state, 1n, &&funcTypeAttrName, &&funcTypeAttr)

        // body region 추가
        let mutable numRegions = 1n
        MlirNative.mlirOperationStateAddOwnedRegions(&state, numRegions)

        MlirNative.mlirOperationCreate(&state)

    /// return operation을 생성한다: func.return
    member _.CreateReturn(values: MlirValue[], location: Location) =
        let mutable state = MlirNative.mlirOperationStateGet(
            MlirStringRef.FromString("func.return"),
            location.Handle)

        // operand 추가
        if values.Length > 0 then
            let mutable operands = values
            MlirNative.mlirOperationStateAddOperands(&state, unativeint values.Length, &&operands.[0])

        MlirNative.mlirOperationCreate(&state)

    /// Block에서 operation의 결과 value를 가져온다
    member _.GetResult(op: MlirOperation, index: int) =
        MlirNative.mlirOperationGetResult(op, unativeint index)

설계 결정: OpBuilder는 MLIR의 복잡성 대부분을 숨긴다. 일반적인 operation (constant, function, return)에 대해 고수준 메서드를 제공한다. 드물게 사용되는 operation은 직접 원시 API를 사용할 수 있다.

타입 헬퍼

타입 생성을 더 편리하게 만드는 모듈:

/// MLIR 타입 생성을 위한 헬퍼 함수들
module MLIRType =
    /// i32 타입을 반환한다
    let i32 (ctx: Context) =
        MlirNative.mlirIntegerTypeGet(ctx.Handle, 32u)

    /// i64 타입을 반환한다
    let i64 (ctx: Context) =
        MlirNative.mlirIntegerTypeGet(ctx.Handle, 64u)

    /// 함수 타입을 생성한다
    let func (ctx: Context) (inputs: MlirType[]) (results: MlirType[]) =
        let mutable inputsArray = inputs
        let mutable resultsArray = results
        MlirNative.mlirFunctionTypeGet(
            ctx.Handle,
            unativeint inputs.Length,
            (if inputs.Length > 0 then &&inputsArray.[0] else nativeint 0),
            unativeint results.Length,
            (if results.Length > 0 then &&resultsArray.[0] else nativeint 0))

모두 함께 사용하기

이제 래퍼를 사용하여 Chapter 02의 “hello-mlir” 예제를 다시 작성해 본다. 비교를 위해 두 버전을 나란히 보자:

원시 P/Invoke 버전 (Chapter 02):

// 35+ 줄, 수동 정리, 장황함
let ctx = MlirNative.mlirContextCreate()

MlirStringRef.WithString "arith" (fun dialectName ->
    MlirNative.mlirContextGetOrLoadDialect(ctx, dialectName) |> ignore)

MlirStringRef.WithString "func" (fun dialectName ->
    MlirNative.mlirContextGetOrLoadDialect(ctx, dialectName) |> ignore)

let loc = MlirNative.mlirLocationUnknownGet(ctx)
let mlirMod = MlirNative.mlirModuleCreateEmpty(loc)

// ... 더 많은 장황한 코드 ...

MlirNative.mlirModuleDestroy(mlirMod)
MlirNative.mlirContextDestroy(ctx)

래퍼 버전 (Chapter 04):

// 20줄, 자동 정리, 간결함
open MlirWrapper

let buildHelloMlir () =
    use ctx = new Context()
    ctx.LoadDialect("arith")
    ctx.LoadDialect("func")

    let loc = Location.Unknown(ctx)
    use mlirMod = new Module(ctx, loc)

    let builder = OpBuilder(ctx)
    let i32Type = builder.I32Type()

    // 함수 타입 생성: () -> i32
    let funcType = builder.FunctionType([||], [| i32Type |])

    // 함수 operation 생성
    let funcOp = builder.CreateFunction("return_forty_two", funcType, loc)

    // 함수 body의 첫 번째 region과 block 가져오기
    let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
    let entryBlock = MlirNative.mlirRegionGetFirstBlock(bodyRegion)

    // entry block이 비어있는지 확인하고, 비어있으면 새로 생성
    let block =
        if MlirNative.mlirBlockIsNull(entryBlock) then
            let newBlock = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
            MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, newBlock)
            newBlock
        else
            entryBlock

    // 상수 operation 생성: %c42 = arith.constant 42 : i32
    let constOp = builder.CreateConstant(42, i32Type, loc)
    MlirNative.mlirBlockAppendOwnedOperation(block, constOp)

    // 상수의 결과 value 가져오기
    let constValue = builder.GetResult(constOp, 0)

    // return operation 생성: return %c42 : i32
    let returnOp = builder.CreateReturn([| constValue |], loc)
    MlirNative.mlirBlockAppendOwnedOperation(block, returnOp)

    // 함수를 module에 추가
    MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)

    // 검증 및 출력
    if mlirMod.Verify() then
        printfn "Generated MLIR:\n%s" (mlirMod.Print())
    else
        failwith "Module verification failed"

    // use가 자동으로 정리 처리

개선 사항:

  1. 자동 정리: use 키워드가 Dispose()를 자동으로 호출한다
  2. 간결성: builder.CreateConstant(42, i32Type, loc) vs. 15줄의 state 조작
  3. 타입 안전성: Context 참조가 Module이 살아있는 동안 유지됨을 보장
  4. 가독성: 의도가 명확하고 보일러플레이트가 적음

완전한 래퍼 모듈 리스팅

다음은 완전한 MlirWrapper.fs 파일이다:

namespace MlirWrapper

open System
open MlirBindings

/// MLIR Context - 모든 MLIR 객체의 소유자
type Context() =
    let mutable handle = MlirNative.mlirContextCreate()
    let mutable disposed = false

    member _.Handle = handle

    member _.LoadDialect(dialect: string) =
        if disposed then
            raise (ObjectDisposedException("Context"))

        MlirStringRef.WithString dialect (fun nameRef ->
            MlirNative.mlirContextGetOrLoadDialect(handle, nameRef)
            |> ignore)

    interface IDisposable with
        member this.Dispose() =
            this.Dispose(true)
            GC.SuppressFinalize(this)

    member private _.Dispose(disposing: bool) =
        if not disposed then
            if disposing then
                ()
            MlirNative.mlirContextDestroy(handle)
            handle <- Unchecked.defaultof<_>
            disposed <- true

/// MLIR Location - 소스 위치 정보
type Location =
    | Unknown of Context
    | FileLineCol of Context * filename: string * line: int * col: int

    member this.Handle =
        match this with
        | Unknown ctx ->
            MlirNative.mlirLocationUnknownGet(ctx.Handle)
        | FileLineCol (ctx, filename, line, col) ->
            MlirStringRef.WithString filename (fun filenameRef ->
                MlirNative.mlirLocationFileLineColGet(
                    ctx.Handle, filenameRef, uint32 line, uint32 col))

/// MLIR Module - 최상위 IR 컨테이너
type Module(context: Context, location: Location) =
    let handle = MlirNative.mlirModuleCreateEmpty(location.Handle)
    let contextRef = context
    let mutable disposed = false

    member _.Handle = handle
    member _.Context = contextRef

    member _.Body =
        let op = MlirNative.mlirModuleGetOperation(handle)
        let region = MlirNative.mlirOperationGetRegion(op, 0n)
        MlirNative.mlirRegionGetFirstBlock(region)

    member _.Verify() =
        let op = MlirNative.mlirModuleGetOperation(handle)
        MlirNative.mlirOperationVerify(op)

    member _.Print() =
        let op = MlirNative.mlirModuleGetOperation(handle)
        MlirHelpers.operationToString(op)

    interface IDisposable with
        member this.Dispose() =
            this.Dispose(true)
            GC.SuppressFinalize(this)

    member private _.Dispose(disposing: bool) =
        if not disposed then
            if disposing then
                ()
            MlirNative.mlirModuleDestroy(handle)
            disposed <- true

/// Operation 빌더 - 유창한 IR 구축 API
type OpBuilder(context: Context) =
    let contextRef = context

    member _.I32Type() =
        MlirNative.mlirIntegerTypeGet(contextRef.Handle, 32u)

    member _.I64Type() =
        MlirNative.mlirIntegerTypeGet(contextRef.Handle, 64u)

    member _.FunctionType(inputs: MlirType[], results: MlirType[]) =
        let mutable inputsArray = inputs
        let mutable resultsArray = results
        MlirNative.mlirFunctionTypeGet(
            contextRef.Handle,
            unativeint inputs.Length,
            (if inputs.Length > 0 then &&inputsArray.[0] else nativeint 0),
            unativeint results.Length,
            (if results.Length > 0 then &&resultsArray.[0] else nativeint 0))

    member _.CreateConstant(value: int, typ: MlirType, location: Location) =
        let mutable state = MlirNative.mlirOperationStateGet(
            MlirStringRef.FromString("arith.constant"), location.Handle)

        let mutable resultType = typ
        MlirNative.mlirOperationStateAddResults(&state, 1n, &&resultType)

        let mutable attr = MlirNative.mlirIntegerAttrGet(typ, int64 value)
        let mutable attrName = MlirStringRef.FromString("value")
        MlirNative.mlirOperationStateAddAttributes(&state, 1n, &&attrName, &&attr)

        MlirNative.mlirOperationCreate(&state)

    member _.CreateFunction(name: string, funcType: MlirType, location: Location) =
        let mutable state = MlirNative.mlirOperationStateGet(
            MlirStringRef.FromString("func.func"), location.Handle)

        MlirStringRef.WithString name (fun nameRef ->
            let mutable attr = MlirNative.mlirStringAttrGet(contextRef.Handle, nameRef)
            let mutable attrName = MlirStringRef.FromString("sym_name")
            MlirNative.mlirOperationStateAddAttributes(&state, 1n, &&attrName, &&attr))

        let mutable funcTypeAttr = MlirNative.mlirTypeAttrGet(funcType)
        let mutable funcTypeAttrName = MlirStringRef.FromString("function_type")
        MlirNative.mlirOperationStateAddAttributes(&state, 1n, &&funcTypeAttrName, &&funcTypeAttr)

        let mutable numRegions = 1n
        MlirNative.mlirOperationStateAddOwnedRegions(&state, numRegions)

        MlirNative.mlirOperationCreate(&state)

    member _.CreateReturn(values: MlirValue[], location: Location) =
        let mutable state = MlirNative.mlirOperationStateGet(
            MlirStringRef.FromString("func.return"), location.Handle)

        if values.Length > 0 then
            let mutable operands = values
            MlirNative.mlirOperationStateAddOperands(&state, unativeint values.Length, &&operands.[0])

        MlirNative.mlirOperationCreate(&state)

    member _.GetResult(op: MlirOperation, index: int) =
        MlirNative.mlirOperationGetResult(op, unativeint index)

/// 타입 생성 헬퍼
module MLIRType =
    let i32 (ctx: Context) =
        MlirNative.mlirIntegerTypeGet(ctx.Handle, 32u)

    let i64 (ctx: Context) =
        MlirNative.mlirIntegerTypeGet(ctx.Handle, 64u)

    let func (ctx: Context) (inputs: MlirType[]) (results: MlirType[]) =
        let mutable inputsArray = inputs
        let mutable resultsArray = results
        MlirNative.mlirFunctionTypeGet(
            ctx.Handle,
            unativeint inputs.Length,
            (if inputs.Length > 0 then &&inputsArray.[0] else nativeint 0),
            unativeint results.Length,
            (if results.Length > 0 then &&resultsArray.[0] else nativeint 0))

배운 것

이 장에서 다음을 배웠다:

  1. 소유권 관리: MLIR의 계층적 소유권과 F#에서 부모 참조로 이를 강제하는 방법
  2. IDisposable 패턴: 자동 리소스 정리를 위한 use 키워드
  3. 빌더 패턴: 복잡한 API를 간단한 메서드 호출로 감싸는 OpBuilder
  4. 타입 안전성: 장황함 없이 컴파일 시점 타입 검사를 제공하는 F# 래퍼

다음 단계

Chapter 05에서는 이 래퍼 레이어를 사용하여 완전한 컴파일러를 구축한다. 정수 리터럴을 갖는 간단한 FunLang 프로그램을 파싱하고, MLIR IR로 변환하며, LLVM dialect로 낮추고, 네이티브 바이너리로 컴파일하여 실행할 것이다.

이것이 Phase 1의 정점이다 – 실제 코드를 실행하는 것이다!

Chapter 05: 산술 컴파일러 - 첫 번째 네이티브 바이너리

소개

지금까지의 여정:

  • Chapter 00: LLVM/MLIR을 빌드하고 .NET SDK를 설치했다
  • Chapter 01: MLIR 개념 (dialect, operation, region, block, SSA)을 배웠다
  • Chapter 02: F#에서 처음으로 MLIR IR을 생성했다
  • Chapter 03: 완전한 P/Invoke 바인딩 모듈을 구축했다
  • Chapter 04: 안전하고 관용적인 F# 래퍼 레이어를 만들었다

이제 보상을 받을 시간이다.

이 장에서는 실제 컴파일러를 구축한다. 소스 코드를 입력으로 받아 실행 가능한 네이티브 바이너리를 출력하는 컴파일러다. 단순화를 위해 FunLang의 매우 작은 부분집합, 즉 정수 리터럴만 다룬다. 이것이 사소해 보일 수 있지만 전체 컴파일 파이프라인을 보여준다:

Source code → AST → MLIR IR → Lowering → LLVM IR → Object file → Native binary

이 장을 마치면 42를 네이티브 실행 파일로 컴파일하고 실행하여 프로그램 종료 코드로 42를 볼 수 있다.

마일스톤: 이것은 Phase 1의 정점이다. 이 장 이후에는 실제 코드를 컴파일하고 실행하는 작동하는 컴파일러를 갖게 된다!

FunLang 부분집합

지금은 단 하나의 구문만 지원한다:

program ::= <integer>

예시:

  • 42
  • 0
  • 1337

이 프로그램은 정수를 종료 코드로 반환한다. Unix에서는 $?로 확인할 수 있다:

./program
echo $?  # 42 출력

단순해 보이지만 이것은 다음을 포함한 완전한 컴파일 파이프라인을 요구한다:

  1. 소스를 AST로 파싱
  2. AST를 MLIR IR로 변환
  3. MLIR IR 검증
  4. LLVM dialect로 낮추기
  5. LLVM IR로 변환
  6. 오브젝트 파일 생성
  7. 실행 파일로 링크

컴파일러 파이프라인 개요

전체 파이프라인을 시각화해 본다:

┌─────────────┐
│   42        │  소스 코드 (문자열)
└──────┬──────┘
       │ parse
       ▼
┌─────────────┐
│ IntLiteral  │  타입 있는 AST
│   value=42  │
└──────┬──────┘
       │ translateToMlir
       ▼
┌──────────────────────────────┐
│ func.func @main() -> i32 {   │  MLIR IR (high-level)
│   %c = arith.constant 42     │
│   return %c                  │
│ }                            │
└──────┬───────────────────────┘
       │ mlirPassManagerRun
       │ (convert-to-llvm)
       ▼
┌──────────────────────────────┐
│ llvm.func @main() -> i32 {   │  MLIR IR (LLVM dialect)
│   %c = llvm.mlir.constant 42 │
│   llvm.return %c             │
│ }                            │
└──────┬───────────────────────┘
       │ mlirTranslateModuleToLLVMIR
       ▼
┌──────────────────────────────┐
│ define i32 @main() {         │  LLVM IR
│   ret i32 42                 │
│ }                            │
└──────┬───────────────────────┘
       │ llc -filetype=obj
       ▼
┌─────────────┐
│ program.o   │  오브젝트 파일 (ELF/Mach-O)
└──────┬──────┘
       │ cc -o program
       ▼
┌─────────────┐
│ ./program   │  네이티브 실행 파일
└─────────────┘

각 단계를 하나씩 구현해 본다.

1단계: AST 정의와 파싱

먼저 FunLang AST의 부분집합을 정의한다. 새 파일 Ast.fs를 만든다:

namespace FunLangCompiler

/// FunLang 표현식 AST
type Expr =
    | IntLiteral of int

/// 최상위 프로그램
type Program =
    { expr: Expr }

극도로 단순하다. 프로그램은 하나의 표현식이고, 표현식은 정수 리터럴이다.

이제 파서를 작성한다. 실제 프로젝트에서는 LangTutorial의 파서를 재사용할 것이다. 여기서는 단순성을 위해 int.Parse를 사용한다:

/// 간단한 파서 - 문자열을 정수로 파싱
module Parser =
    open System

    let parse (source: string) : Program =
        let trimmed = source.Trim()
        match Int32.TryParse(trimmed) with
        | (true, value) ->
            { expr = IntLiteral value }
        | (false, _) ->
            failwithf "Parse error: expected integer, got '%s'" trimmed

테스트:

let program = Parser.parse "42"
// { expr = IntLiteral 42 }

2단계: AST를 MLIR로 변환

이제 핵심 컴파일 단계다. AST를 MLIR IR로 변환한다. 목표는 다음 IR을 생성하는 것이다:

module {
  func.func @main() -> i32 {
    %c42 = arith.constant 42 : i32
    return %c42 : i32
  }
}

새 파일 CodeGen.fs를 만든다:

namespace FunLangCompiler

open System
open MlirWrapper
open MlirBindings

/// AST를 MLIR IR로 변환
module CodeGen =

    /// 표현식을 MLIR value로 컴파일
    let rec compileExpr
        (builder: OpBuilder)
        (block: MlirBlock)
        (location: Location)
        (expr: Expr)
        : MlirValue =

        match expr with
        | IntLiteral value ->
            // arith.constant operation 생성
            let i32Type = builder.I32Type()
            let constOp = builder.CreateConstant(value, i32Type, location)

            // block에 operation 추가
            MlirNative.mlirBlockAppendOwnedOperation(block, constOp)

            // 결과 value 반환
            builder.GetResult(constOp, 0)

    /// 프로그램을 MLIR module로 컴파일
    let translateToMlir (program: Program) : Module =
        let ctx = new Context()
        ctx.LoadDialect("arith")
        ctx.LoadDialect("func")

        let loc = Location.Unknown(ctx)
        let mlirMod = new Module(ctx, loc)

        let builder = OpBuilder(ctx)
        let i32Type = builder.I32Type()

        // main 함수 생성: () -> i32
        let funcType = builder.FunctionType([||], [| i32Type |])
        let funcOp = builder.CreateFunction("main", funcType, loc)

        // 함수 body에 entry block 생성
        let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
        let entryBlock = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
        MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)

        // 표현식 컴파일 (상수 생성)
        let resultValue = compileExpr builder entryBlock loc program.expr

        // return operation 생성
        let returnOp = builder.CreateReturn([| resultValue |], loc)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, returnOp)

        // 함수를 module에 추가
        MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)

        mlirMod

설계 결정: compileExpr은 재귀적이다. 현재는 IntLiteral만 처리하지만, 나중 장에서 더 많은 케이스 (BinaryOp, IfThenElse, FunctionCall 등)를 추가할 것이다.

테스트:

let program = Parser.parse "42"
let mlirMod = CodeGen.translateToMlir program
printfn "%s" (mlirMod.Print())

출력:

module {
  func.func @main() -> i32 {
    %0 = arith.constant 42 : i32
    return %0 : i32
  }
}

3단계: MLIR 검증

MLIR은 강력한 검증 인프라를 제공한다. 모든 operation이 올바른 형식인지 확인한다:

  • 모든 block이 terminator (return, branch 등)로 끝나는가?
  • SSA dominance 규칙이 존중되는가?
  • 타입이 일치하는가?

CodeGen.fs에 검증 단계를 추가한다:

    /// MLIR module을 검증. 실패 시 예외 발생.
    let verify (mlirMod: Module) =
        if not (mlirMod.Verify()) then
            eprintfn "MLIR verification failed:"
            eprintfn "%s" (mlirMod.Print())
            failwith "MLIR IR is invalid"

사용:

let mlirMod = CodeGen.translateToMlir program
CodeGen.verify mlirMod  // 실패 시 예외 발생

마일스톤: 이 시점에서 올바른 MLIR IR을 생성할 수 있다. 다음 단계는 LLVM으로 낮추는 것이다.

4단계: LLVM Dialect로 낮추기

MLIR IR은 계층적이다. 고수준 dialect (arith, func)에서 시작하여 LLVM dialect로 점진적으로 낮춘다. 이를 progressive lowering이라고 한다 (Chapter 01 참조).

MLIR의 pass manager를 사용하여 변환을 수행한다:

namespace FunLangCompiler

open MlirBindings

/// MLIR lowering pass
module Lowering =

    /// arith와 func dialect를 LLVM dialect로 낮춘다
    let lowerToLLVMDialect (mlirMod: Module) =
        let ctx = mlirMod.Context

        // Pass manager 생성
        let pm = MlirNative.mlirPassManagerCreate(ctx.Handle)

        // convert-func-to-llvm pass 추가
        MlirStringRef.WithString "convert-func-to-llvm" (fun passName ->
            let pass = MlirNative.mlirCreateConversionPass(passName)
            MlirNative.mlirPassManagerAddOwnedPass(pm, pass))

        // convert-arith-to-llvm pass 추가
        MlirStringRef.WithString "convert-arith-to-llvm" (fun passName ->
            let pass = MlirNative.mlirCreateConversionPass(passName)
            MlirNative.mlirPassManagerAddOwnedPass(pm, pass))

        // Pass 실행
        let moduleOp = MlirNative.mlirModuleGetOperation(mlirMod.Handle)
        let success = MlirNative.mlirPassManagerRunOnOp(pm, moduleOp)

        if not success then
            failwith "MLIR lowering failed"

        // Pass manager 정리
        MlirNative.mlirPassManagerDestroy(pm)

아키텍처 노트: Pass는 MLIR의 강력한 기능이다. 각 pass는 IR을 변환한다 (최적화, 낮추기, 분석). 여러 pass를 체인으로 연결하여 복잡한 변환을 구성할 수 있다.

변환 전 (high-level):

func.func @main() -> i32 {
  %c42 = arith.constant 42 : i32
  return %c42 : i32
}

변환 후 (LLVM dialect):

llvm.func @main() -> i32 {
  %c42 = llvm.mlir.constant(42 : i32) : i32
  llvm.return %c42 : i32
}

차이를 주목한다:

  • func.funcllvm.func
  • arith.constantllvm.mlir.constant
  • returnllvm.return

이제 IR이 LLVM IR로 변환할 준비가 되었다.

5단계: LLVM IR 변환

MLIR은 LLVM IR로 변환하는 빌트인 변환기를 제공한다. Lowering.fs에 추가한다:

    open System.Runtime.InteropServices

    /// MLIR module (LLVM dialect)을 LLVM IR 문자열로 변환
    let translateToLLVMIR (mlirMod: Module) : string =
        let ctx = mlirMod.Context
        let moduleOp = MlirNative.mlirModuleGetOperation(mlirMod.Handle)

        // LLVM context 생성
        let llvmCtx = MlirNative.llvmContextCreate()

        // MLIR을 LLVM IR로 변환
        let llvmModule = MlirNative.mlirTranslateModuleToLLVMIR(
            moduleOp,
            llvmCtx)

        if llvmModule = nativeint 0 then
            failwith "Failed to translate MLIR to LLVM IR"

        // LLVM IR을 문자열로 출력
        let irString = MlirNative.llvmPrintModuleToString(llvmModule)

        // 정리
        MlirNative.llvmDisposeModule(llvmModule)
        MlirNative.llvmContextDispose(llvmCtx)

        Marshal.PtrToStringAnsi(irString)

구현 참고: MLIR C API는 LLVM IR로 변환하는 mlirTranslateModuleToLLVMIR을 제공한다. 그런 다음 LLVM C API (llvmPrintModuleToString)를 사용하여 문자열화한다.

출력 (LLVM IR):

define i32 @main() {
  ret i32 42
}

완벽하다! 이것은 순수한 LLVM IR이다. MLIR 개념이 전혀 없다.

6단계: 오브젝트 파일 생성

이제 LLVM IR을 네이티브 머신 코드로 컴파일해야 한다. LLVM의 llc 도구를 사용한다:

namespace FunLangCompiler

open System
open System.IO
open System.Diagnostics

/// 네이티브 코드 생성
module NativeCodeGen =

    /// LLVM IR을 오브젝트 파일로 컴파일 (llc 사용)
    let emitObjectFile (llvmIR: string) (outputPath: string) =
        // 임시 .ll 파일에 LLVM IR 쓰기
        let llFile = Path.GetTempFileName() + ".ll"
        File.WriteAllText(llFile, llvmIR)

        try
            // llc 실행: .ll → .o
            let psi = ProcessStartInfo()
            psi.FileName <- "llc"
            psi.Arguments <- sprintf "-filetype=obj -o %s %s" outputPath llFile
            psi.RedirectStandardOutput <- true
            psi.RedirectStandardError <- true
            psi.UseShellExecute <- false

            let proc = Process.Start(psi)
            proc.WaitForExit()

            if proc.ExitCode <> 0 then
                let stderr = proc.StandardError.ReadToEnd()
                failwithf "llc failed:\n%s" stderr

            printfn "Generated object file: %s" outputPath

        finally
            // 임시 파일 정리
            File.Delete(llFile)

도구 요구사항: llc는 LLVM 도구체인의 일부다. Chapter 00에서 LLVM을 빌드했다면 $HOME/mlir-install/bin/llc에 있다. PATH에 있는지 확인한다.

사용:

let llvmIR = Lowering.translateToLLVMIR mlirMod
NativeCodeGen.emitObjectFile llvmIR "program.o"

이제 program.o가 있다 – ELF 오브젝트 파일 (Linux) 또는 Mach-O (macOS).

7단계: 실행 파일로 링크

마지막 단계는 오브젝트 파일을 실행 파일로 링크하는 것이다. 시스템 링커 (cc 또는 clang)를 사용한다:

    /// 오브젝트 파일을 실행 파일로 링크 (cc 사용)
    let linkExecutable (objectPath: string) (outputPath: string) =
        let psi = ProcessStartInfo()
        psi.FileName <- "cc"  // 또는 "clang"
        psi.Arguments <- sprintf "-o %s %s" outputPath objectPath
        psi.RedirectStandardOutput <- true
        psi.RedirectStandardError <- true
        psi.UseShellExecute <- false

        let proc = Process.Start(psi)
        proc.WaitForExit()

        if proc.ExitCode <> 0 then
            let stderr = proc.StandardError.ReadToEnd()
            failwithf "Linking failed:\n%s" stderr

        printfn "Generated executable: %s" outputPath

사용:

NativeCodeGen.linkExecutable "program.o" "program"

완료! ./program 실행 파일이 생성되었다.

완전한 컴파일러 드라이버

모든 것을 Compiler.fs에 하나로 모은다:

namespace FunLangCompiler

open System
open System.IO

/// 메인 컴파일러 드라이버
module Compiler =

    /// 소스 파일을 네이티브 실행 파일로 컴파일
    let compile (sourceFile: string) (outputFile: string) =
        printfn "=== FunLang Compiler ==="
        printfn "Source: %s" sourceFile
        printfn "Output: %s" outputFile
        printfn ""

        // 1단계: 파싱
        printfn "[1/7] Parsing..."
        let source = File.ReadAllText(sourceFile)
        let program = Parser.parse source
        printfn "  AST: %A" program

        // 2단계: MLIR로 변환
        printfn "[2/7] Translating to MLIR..."
        let mlirMod = CodeGen.translateToMlir program
        printfn "  MLIR (high-level):"
        printfn "%s" (mlirMod.Print())

        // 3단계: 검증
        printfn "[3/7] Verifying MLIR..."
        CodeGen.verify mlirMod
        printfn "  ✓ Verification passed"

        // 4단계: LLVM dialect로 낮추기
        printfn "[4/7] Lowering to LLVM dialect..."
        Lowering.lowerToLLVMDialect mlirMod
        printfn "  MLIR (LLVM dialect):"
        printfn "%s" (mlirMod.Print())

        // 5단계: LLVM IR로 변환
        printfn "[5/7] Translating to LLVM IR..."
        let llvmIR = Lowering.translateToLLVMIR mlirMod
        printfn "  LLVM IR:"
        printfn "%s" llvmIR

        // 6단계: 오브젝트 파일 생성
        printfn "[6/7] Emitting object file..."
        let objectFile = outputFile + ".o"
        NativeCodeGen.emitObjectFile llvmIR objectFile

        // 7단계: 링크
        printfn "[7/7] Linking executable..."
        NativeCodeGen.linkExecutable objectFile outputFile

        // 정리
        mlirMod.Dispose()

        printfn ""
        printfn "=== Compilation successful ==="
        printfn "Run: ./%s" outputFile

실행해 보기

테스트 프로그램을 작성한다:

echo "42" > test.fun

컴파일한다:

dotnet fsi Compiler.fs -- test.fun program

출력:

=== FunLang Compiler ===
Source: test.fun
Output: program

[1/7] Parsing...
  AST: { expr = IntLiteral 42 }
[2/7] Translating to MLIR...
  MLIR (high-level):
module {
  func.func @main() -> i32 {
    %0 = arith.constant 42 : i32
    return %0 : i32
  }
}
[3/7] Verifying MLIR...
  ✓ Verification passed
[4/7] Lowering to LLVM dialect...
  MLIR (LLVM dialect):
module {
  llvm.func @main() -> i32 {
    %0 = llvm.mlir.constant(42 : i32) : i32
    llvm.return %0 : i32
  }
}
[5/7] Translating to LLVM IR...
  LLVM IR:
define i32 @main() {
  ret i32 42
}
[6/7] Emitting object file...
Generated object file: program.o
[7/7] Linking executable...
Generated executable: program

=== Compilation successful ===
Run: ./program

실행한다:

./program
echo $?

출력:

42

마일스톤: 축하한다! 실제 코드를 컴파일하고 실행했다! 🎉

구축한 것

이 장에서 다음을 성취했다:

  1. 완전한 컴파일 파이프라인:

    • 소스 → AST (파싱)
    • AST → MLIR IR (코드 생성)
    • MLIR 검증
    • High-level dialect → LLVM dialect (progressive lowering)
    • MLIR → LLVM IR (변환)
    • LLVM IR → 오브젝트 파일 (llc)
    • 오브젝트 파일 → 실행 파일 (링커)
  2. 실제 컴파일러: 단순하지만 이것은 실제 컴파일러다. 텍스트를 받아 네이티브 머신 코드를 생성한다.

  3. 확장 가능한 아키텍처: compileExpr은 재귀적이다. 나중 장에서 더 많은 표현식 타입을 추가할 것이다:

    • Chapter 06: 이진 연산 (+, -, *, /)
    • Chapter 07: Let 바인딩과 변수
    • Chapter 08: If/then/else
    • Chapter 09: 함수와 재귀
    • Chapter 10+: 클로저, 패턴 매칭, 리스트

다음 단계

Phase 1 완료! 다음 phase에서는:

  • Phase 2: 산술 연산자, let 바인딩, if/else
  • Phase 3: 함수와 재귀
  • Phase 4: 클로저와 고차 함수
  • Phase 5: 커스텀 MLIR dialect (Appendix 참조)
  • Phase 6: 패턴 매칭과 데이터 구조
  • Phase 7: 최적화와 마무리

Appendix를 읽는 것을 잊지 마라: 커스텀 MLIR dialect를 C++에서 정의하고 F#에서 사용하는 방법을 다룬다. 이것은 Phase 5의 기초가 된다.


Phase 1의 정점에 도달했다. 실제 컴파일러를 구축했다!

Chapter 06: 산술 표현식 - 연산자와 비교

소개

Chapter 05에서 정수 리터럴 하나만 컴파일하는 최소한의 컴파일러를 구축했다. 42를 입력으로 받아 네이티브 바이너리로 출력하는 전체 파이프라인이 작동한다. 하지만 실제 프로그램을 작성하려면 산술 연산자가 필요하다.

이 장에서는 다음을 추가한다:

  • 이진 연산자: +, -, *, / (정수 산술)
  • 비교 연산자: <, >, <=, >=, =, <> (i1 boolean 반환)
  • 단항 연산자: - (부정)
  • 출력 기능: print 함수로 결과를 stdout에 출력

이 장을 마치면 10 + 3 * 4와 같은 표현식을 컴파일하고, 비교를 수행하고, 결과를 화면에 출력하는 완전한 계산기 컴파일러를 갖게 된다.

중요: 산술 연산은 MLIR의 arith dialect를 사용한다 (Chapter 01의 primer에서 배웠다). 이 dialect는 SSA 형태의 연산을 제공하며 LLVM dialect로 깔끔하게 낮춰진다.

확장된 AST 정의

Chapter 05의 AST는 IntLiteral 하나만 가졌다. 이제 표현식을 확장한다:

namespace FunLangCompiler

/// 이진 연산자
type Operator =
    | Add       // +
    | Subtract  // -
    | Multiply  // *
    | Divide    // /

/// 비교 연산자
type CompareOp =
    | LessThan       // <
    | GreaterThan    // >
    | LessEqual      // <=
    | GreaterEqual   // >=
    | Equal          // =
    | NotEqual       // <>

/// 단항 연산자
type UnaryOp =
    | Negate  // -

/// FunLang 표현식 AST
type Expr =
    | IntLiteral of int
    | BinaryOp of Operator * Expr * Expr       // 예: Add(IntLiteral 10, IntLiteral 20)
    | UnaryOp of UnaryOp * Expr                // 예: Negate(IntLiteral 42)
    | Comparison of CompareOp * Expr * Expr    // 예: LessThan(IntLiteral 5, IntLiteral 10)

/// 최상위 프로그램
type Program =
    { expr: Expr }

설계 결정:

  • Operator와 CompareOp 분리: 산술 연산은 i32를 반환하지만, 비교는 i1 (boolean)을 반환한다. 타입 시스템이 다르므로 별도의 타입으로 구분한다.
  • UnaryOp은 확장 가능: 지금은 Negate만 있지만 나중에 논리 부정 (not) 등을 추가할 수 있다.

AST 예시:

// Source: 10 + 3 * 4
BinaryOp(Add,
  IntLiteral 10,
  BinaryOp(Multiply,
    IntLiteral 3,
    IntLiteral 4))

// Source: -(5 + 10)
UnaryOp(Negate,
  BinaryOp(Add,
    IntLiteral 5,
    IntLiteral 10))

// Source: 5 < 10
Comparison(LessThan,
  IntLiteral 5,
  IntLiteral 10)

파서 노트: 실제 파서는 연산자 우선순위를 처리해야 한다 (*+보다 높음). 이 장에서는 코드 생성에 집중하므로 파서 구현은 생략한다. LangTutorial의 파서를 재사용하거나 간단한 재귀 하강 파서를 작성하면 된다.

arith Dialect 연산 생성

Chapter 03-04에서 구축한 OpBuilder.CreateOperation 패턴을 사용하여 arith dialect 연산을 생성한다. 개별 P/Invoke 대신 generic operation builder를 사용하는 것이 더 유연하고 유지보수가 쉽다.

CodeGen.fs에서 연산 생성 헬퍼:

/// Create operation, append to block, return result value
let private emitOp (ctx: CompileContext) name resultTypes operands attrs regions =
    let op = ctx.Builder.CreateOperation(name, ctx.Location, resultTypes, operands, attrs, regions)
    ctx.Builder.AppendOperationToBlock(ctx.Block, op)
    op

산술 연산 생성 예시:

// arith.addi: 정수 덧셈
| Add(left, right, _) ->
    let leftVal = compileExpr ctx left
    let rightVal = compileExpr ctx right
    let i32Type = builder.I32Type()
    let op = emitOp ctx "arith.addi" [| i32Type |] [| leftVal; rightVal |] [||] [||]
    builder.GetResult(op, 0)

// arith.subi: 정수 뺄셈
| Subtract(left, right, _) ->
    let leftVal = compileExpr ctx left
    let rightVal = compileExpr ctx right
    let op = emitOp ctx "arith.subi" [| i32Type |] [| leftVal; rightVal |] [||] [||]
    builder.GetResult(op, 0)

// arith.muli: 정수 곱셈
| Multiply(left, right, _) ->
    let leftVal = compileExpr ctx left
    let rightVal = compileExpr ctx right
    let op = emitOp ctx "arith.muli" [| i32Type |] [| leftVal; rightVal |] [||] [||]
    builder.GetResult(op, 0)

// arith.divsi: 부호 있는 정수 나눗셈
| Divide(left, right, _) ->
    let leftVal = compileExpr ctx left
    let rightVal = compileExpr ctx right
    let op = emitOp ctx "arith.divsi" [| i32Type |] [| leftVal; rightVal |] [||] [||]
    builder.GetResult(op, 0)

비교 연산 - arith.cmpi:

비교 연산은 predicate 속성이 필요하다. 중요: predicate는 반드시 i64 타입의 IntegerAttr로 전달해야 한다:

// arith.cmpi predicate 값:
//   0 = eq (equal)
//   1 = ne (not equal)
//   2 = slt (signed less than)
//   3 = sle (signed less or equal)
//   4 = sgt (signed greater than)
//   5 = sge (signed greater or equal)

| Equal(left, right, _) ->
    let leftVal = compileExpr ctx left
    let rightVal = compileExpr ctx right
    let i64Type = builder.I64Type()  // 주의: i64 타입!
    let predicateAttr = builder.IntegerAttr(0L, i64Type)  // eq = 0
    let i1Type = builder.I1Type()  // 결과는 i1 (boolean)
    let op = emitOp ctx "arith.cmpi" [| i1Type |]
                [| leftVal; rightVal |]
                [| builder.NamedAttr("predicate", predicateAttr) |]
                [||]
    builder.GetResult(op, 0)

핵심 발견: MLIR의 ArithOps.td 정의에 따르면 predicate 속성은 i64 타입이어야 한다. i32를 사용하면 “attribute ‘predicate’ expected integer type of width 64” 에러가 발생한다.

연산자 매핑 표:

FunLang OperatorMLIR Operation타입 시그니처
+arith.addi(i32, i32) -> i32
-arith.subi(i32, i32) -> i32
*arith.muli(i32, i32) -> i32
/arith.divsi(i32, i32) -> i32 (부호 있는 나눗셈)
<arith.cmpi slt(i32, i32) -> i1
>arith.cmpi sgt(i32, i32) -> i1
<=arith.cmpi sle(i32, i32) -> i1
>=arith.cmpi sge(i32, i32) -> i1
=arith.cmpi eq(i32, i32) -> i1
<>arith.cmpi ne(i32, i32) -> i1

C API 노트: MLIR C API는 mlir-c/Dialect/Arith.h에서 arith dialect 연산을 노출한다. 실제 함수 이름은 위와 다를 수 있다 (예: mlirArithAddiOpCreate vs mlirArithAddiCreate). MLIR 설치의 헤더 파일을 확인하여 정확한 시그니처를 사용한다.

arith.cmpi predicate 값:

/// arith.cmpi predicate enum
module ArithCmpIPredicate =
    let eq = 0    // equal
    let ne = 1    // not equal
    let slt = 2   // signed less than
    let sle = 3   // signed less or equal
    let sgt = 4   // signed greater than
    let sge = 5   // signed greater or equal
    let ult = 6   // unsigned less than (나중에 사용)
    let ule = 7   // unsigned less or equal
    let ugt = 8   // unsigned greater than
    let uge = 9   // unsigned greater or equal

Boolean 리터럴과 논리 연산자

비교 연산 외에도 boolean 리터럴 (true, false)과 논리 연산자 (&&, ||)를 지원해야 한다.

Boolean 리터럴 컴파일

Boolean 값은 i1 타입 (1-bit integer)으로 표현된다:

| Bool(b, _) ->
    let i1Type = builder.I1Type()
    let value = if b then 1L else 0L
    let valueAttr = builder.IntegerAttr(value, i1Type)
    let op = emitOp ctx "arith.constant" [| i1Type |] [||]
                [| builder.NamedAttr("value", valueAttr) |] [||]
    builder.GetResult(op, 0)

생성된 MLIR IR:

%true = arith.constant true    // 또는 arith.constant 1 : i1
%false = arith.constant false  // 또는 arith.constant 0 : i1

논리 AND/OR 연산자

논리 연산자는 arith.andiarith.ori를 사용한다:

| And(left, right, _) ->
    let leftVal = compileExpr ctx left
    let rightVal = compileExpr ctx right
    let i1Type = builder.I1Type()
    let op = emitOp ctx "arith.andi" [| i1Type |] [| leftVal; rightVal |] [||] [||]
    builder.GetResult(op, 0)

| Or(left, right, _) ->
    let leftVal = compileExpr ctx left
    let rightVal = compileExpr ctx right
    let i1Type = builder.I1Type()
    let op = emitOp ctx "arith.ori" [| i1Type |] [| leftVal; rightVal |] [||] [||]
    builder.GetResult(op, 0)

주의: 이 구현은 **비단락 평가 (non-short-circuit evaluation)**이다. 양쪽 피연산자가 항상 평가된다. 진정한 단락 평가를 위해서는 scf.if를 사용해야 한다 (Chapter 08 참조).

생성된 MLIR IR:

// true && false
%a = arith.constant true
%b = arith.constant false
%result = arith.andi %a, %b : i1  // 결과: false

// true || false
%result = arith.ori %a, %b : i1   // 결과: true

코드 생성 패턴

실제 구현에서는 개별 P/Invoke 대신 generic CreateOperation 패턴을 사용한다. 이것이 더 유지보수하기 쉽고 확장성이 좋다.

설계 결정:

  • Generic 패턴: CreateOperation(name, resultTypes, operands, attrs, regions) 형식으로 모든 연산을 생성할 수 있다
  • emitOp 헬퍼: CompileContext를 받아 operation 생성, block에 추가, operation 반환을 하나로 묶는다
  • 부정 구현: -expr0 - expr로 변환한다. 별도의 arith.negate 연산이 없으므로 이것이 표준 방법이다
  • 타입 일관성: 모든 정수는 i32, 모든 boolean은 i1로 컴파일한다

공통 에러 (1부)

에러 1: 잘못된 정수 타입 사용 (i64 vs i32)

증상:

MLIR verification failed:
  Type mismatch: expected i32, got i64

원인: MLIR은 타입이 엄격하다. 상수를 i64로 생성했지만 함수 시그니처는 i32를 요구하는 경우.

해결:

// WRONG: i64 타입 사용
let i64Type = builder.Context.GetIntegerType(64)
let attr = builder.Context.GetIntegerAttr(i64Type, 42L)

// CORRECT: i32 타입 사용
let i32Type = builder.Context.GetIntegerType(32)
let attr = builder.Context.GetIntegerAttr(i32Type, 42L)

규칙: 모든 FunLang 정수는 i32로 컴파일한다. 타입을 일관되게 유지한다.

에러 2: 연산자 우선순위를 파서에서 처리하지 않음

증상:

Source: 10 + 3 * 4
Expected: 22
Actual: 52  (잘못된 결과)

원인: 파서가 우선순위를 무시하고 왼쪽에서 오른쪽으로 파싱하여 (10 + 3) * 4 = 52가 됨.

해결: 파서에서 연산자 우선순위를 구현한다:

  • 곱셈/나눗셈 (*, /)이 덧셈/뺄셈 (+, -)보다 우선순위가 높다.
  • 비교 연산자는 산술 연산보다 우선순위가 낮다.

재귀 하강 파서 예시:

// Precedence climbing algorithm
// additive := multiplicative (('+' | '-') multiplicative)*
// multiplicative := primary (('*' | '/') primary)*
// primary := number | '(' additive ')'

파서 구현은 이 장의 범위를 벗어난다. LangTutorial의 기존 파서를 사용하거나 FParsec 같은 파서 라이브러리를 사용한다.

산술 표현식을 위한 코드 생성

이제 Chapter 05의 compileExpr을 확장하여 모든 산술 표현식을 처리한다.

CodeGen.fs 수정:

namespace FunLangCompiler

open System
open MlirWrapper
open MlirBindings

module CodeGen =

    /// 표현식을 MLIR value로 컴파일 (재귀적)
    let rec compileExpr
        (builder: OpBuilder)
        (block: MlirBlock)
        (location: Location)
        (expr: Expr)
        : MlirValue =

        match expr with
        | IntLiteral value ->
            // arith.constant operation 생성
            let i32Type = builder.I32Type()
            let attr = builder.Context.GetIntegerAttr(i32Type, int64 value)
            let constOp = builder.CreateConstant(attr, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, constOp)
            builder.GetResult(constOp, 0)

        | BinaryOp(op, lhs, rhs) ->
            // 왼쪽 피연산자 컴파일 (재귀)
            let lhsVal = compileExpr builder block location lhs

            // 오른쪽 피연산자 컴파일 (재귀)
            let rhsVal = compileExpr builder block location rhs

            // 이진 연산 생성
            let binOp = builder.CreateArithBinaryOp(op, lhsVal, rhsVal, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, binOp)
            builder.GetResult(binOp, 0)

        | UnaryOp(Negate, expr) ->
            // 피연산자 컴파일
            let val = compileExpr builder block location expr

            // 부정 연산 생성 (0 - val)
            let negOp = builder.CreateArithNegate(val, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, negOp)
            builder.GetResult(negOp, 0)

        | Comparison(compareOp, lhs, rhs) ->
            // 피연산자 컴파일
            let lhsVal = compileExpr builder block location lhs
            let rhsVal = compileExpr builder block location rhs

            // 비교 연산 생성 (i1 반환)
            let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
            builder.GetResult(cmpOp, 0)

    /// 프로그램을 MLIR module로 컴파일
    let translateToMlir (program: Program) : Module =
        let ctx = new Context()
        ctx.LoadDialect("arith")
        ctx.LoadDialect("func")

        let loc = Location.Unknown(ctx)
        let mlirMod = new Module(ctx, loc)

        let builder = OpBuilder(ctx)
        let i32Type = builder.I32Type()

        // main 함수 생성: () -> i32
        let funcType = builder.FunctionType([||], [| i32Type |])
        let funcOp = builder.CreateFunction("main", funcType, loc)

        // 함수 body에 entry block 생성
        let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
        let entryBlock = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
        MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)

        // 표현식 컴파일 (재귀적으로 모든 연산 처리)
        let resultValue = compileExpr builder entryBlock loc program.expr

        // return operation 생성
        let returnOp = builder.CreateReturn([| resultValue |], loc)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, returnOp)

        // 함수를 module에 추가
        MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)

        mlirMod

    /// MLIR module을 검증
    let verify (mlirMod: Module) =
        if not (mlirMod.Verify()) then
            eprintfn "MLIR verification failed:"
            eprintfn "%s" (mlirMod.Print())
            failwith "MLIR IR is invalid"

SSA 형태 유지:

재귀 호출이 SSA 형태를 자연스럽게 유지한다는 것을 주목한다:

  • compileExpr 호출은 새로운 SSA value를 반환한다.
  • 중복 계산이 없다 (각 표현식은 정확히 한 번만 평가된다).
  • 지배 관계가 자동으로 유지된다 (하위 표현식이 먼저 평가된다).

예시: 복잡한 표현식 컴파일

// Source: 10 + 3 * 4
let ast = BinaryOp(Add,
            IntLiteral 10,
            BinaryOp(Multiply,
              IntLiteral 3,
              IntLiteral 4))

let mlirMod = CodeGen.translateToMlir { expr = ast }
printfn "%s" (mlirMod.Print())

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %c10 = arith.constant 10 : i32
    %c3 = arith.constant 3 : i32
    %c4 = arith.constant 4 : i32
    %0 = arith.muli %c3, %c4 : i32     // 3 * 4 = 12
    %1 = arith.addi %c10, %0 : i32     // 10 + 12 = 22
    func.return %1 : i32
  }
}

동작 분석:

  1. 상수 10, 3, 4가 생성된다 (arith.constant)
  2. 먼저 곱셈 계산: %0 = 3 * 4 (하위 표현식이 먼저)
  3. 그 다음 덧셈: %1 = 10 + %0
  4. 결과 반환: return %1

중요: 연산 순서는 AST 구조가 결정한다. 파서가 올바른 우선순위로 AST를 구축하면 코드 생성이 자동으로 올바른 평가 순서를 생성한다.

비교 예시:

// Source: 5 < 10
let ast = Comparison(LessThan, IntLiteral 5, IntLiteral 10)

let mlirMod = CodeGen.translateToMlir { expr = ast }
printfn "%s" (mlirMod.Print())

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32
    %0 = arith.cmpi slt, %c5, %c10 : i32  // returns i1
    // 문제: %0은 i1이지만 함수는 i32를 반환해야 함!
    func.return %0 : i32  // TYPE ERROR!
  }
}

타입 불일치 문제: 비교는 i1 (boolean)을 반환하지만 main 함수는 i32를 기대한다. 이를 해결하려면 boolean을 정수로 확장해야 한다:

// compileExpr 수정 (Comparison 케이스)
| Comparison(compareOp, lhs, rhs) ->
    let lhsVal = compileExpr builder block location lhs
    let rhsVal = compileExpr builder block location rhs

    // 비교 연산 (i1 반환)
    let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
    let cmpVal = builder.GetResult(cmpOp, 0)

    // i1 -> i32 확장 (zero extend)
    let i32Type = builder.I32Type()
    let extOp = builder.CreateArithExtUI(cmpVal, i32Type, location)  // unsigned extend
    MlirNative.mlirBlockAppendOwnedOperation(block, extOp)
    builder.GetResult(extOp, 0)

생성된 MLIR IR (수정 후):

module {
  func.func @main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32
    %0 = arith.cmpi slt, %c5, %c10 : i32   // returns i1
    %1 = arith.extui %0 : i1 to i32        // i1 -> i32 (0 or 1)
    func.return %1 : i32
  }
}

이제 비교 결과가 정수로 반환된다 (true = 1, false = 0).

단항 부정 예시:

// Source: -(10 + 5)
let ast = UnaryOp(Negate, BinaryOp(Add, IntLiteral 10, IntLiteral 5))

let mlirMod = CodeGen.translateToMlir { expr = ast }
printfn "%s" (mlirMod.Print())

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %c10 = arith.constant 10 : i32
    %c5 = arith.constant 5 : i32
    %0 = arith.addi %c10, %c5 : i32     // 10 + 5 = 15
    %c0 = arith.constant 0 : i32
    %1 = arith.subi %c0, %0 : i32       // 0 - 15 = -15
    func.return %1 : i32
  }
}

출력 기능 추가: printf로 결과 출력

지금까지 프로그램은 결과를 종료 코드로만 반환했다. 이제 printf를 사용하여 stdout에 출력하는 기능을 추가한다.

llvm.call 연산을 위한 P/Invoke 바인딩

LLVM dialect는 외부 함수를 호출하는 llvm.call 연산을 제공한다.

MlirBindings.fs에 추가:

    // ===== LLVM dialect operations =====

    /// llvm.call: 외부 함수 호출
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirLLVMCallCreate(
        MlirContext context,
        MlirLocation location,
        MlirValue callee,
        MlirValue[] args,
        int numArgs)

    /// llvm.mlir.global: 전역 문자열 상수
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirLLVMGlobalCreate(
        MlirContext context,
        MlirLocation location,
        MlirType type,
        MlirAttribute initializer,
        MlirStringRef name)

    /// llvm.mlir.addressof: 전역 변수의 주소 가져오기
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirLLVMAddressOfCreate(
        MlirContext context,
        MlirLocation location,
        MlirStringRef globalName)

C API 경고: 실제 MLIR C API는 LLVM dialect에 대한 직접 지원이 제한적일 수 있다. 필요한 경우 Chapter 05의 Appendix 패턴 (C++ wrapper)을 사용한다.

printf 함수 선언 생성

printf를 호출하려면 먼저 함수 선언과 전역 포맷 문자열이 필요하다.

CodeGen.fs에 헬퍼 함수 추가:

    /// printf 함수 선언 생성 (module 레벨)
    let createPrintfDeclaration (builder: OpBuilder) (mlirMod: Module) (location: Location) =
        // printf 시그니처: (i8*, ...) -> i32
        let i8Type = builder.Context.GetIntegerType(8)
        let i8PtrType = builder.Context.GetPointerType(i8Type)
        let i32Type = builder.I32Type()

        // func.func @printf(%fmt: !llvm.ptr<i8>, ...) -> i32 attributes { sym_visibility = "private" }
        let printfType = builder.FunctionType([| i8PtrType |], [| i32Type |])
        let printfOp = builder.CreateFunction("printf", printfType, location)

        // 가변 인자 속성 추가 (실제 구현에서는 속성 API 필요)
        // 여기서는 단순화를 위해 생략

        MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, printfOp)

    /// 전역 포맷 문자열 생성: "%d\n\0"
    let createFormatString (builder: OpBuilder) (mlirMod: Module) (location: Location) : string =
        let formatStrName = ".str.fmt"
        let formatStrValue = "%d\n\0"

        // LLVM global 생성
        let i8Type = builder.Context.GetIntegerType(8)
        let arrayType = builder.Context.GetArrayType(i8Type, formatStrValue.Length)
        let strAttr = builder.Context.GetStringAttr(formatStrValue)

        let globalOp = builder.CreateLLVMGlobal(arrayType, strAttr, formatStrName, location)
        MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, globalOp)

        formatStrName

    /// print_int 헬퍼 함수 생성: 정수를 출력
    let createPrintIntHelper
        (builder: OpBuilder)
        (mlirMod: Module)
        (location: Location)
        (formatStrName: string)
        =

        // func.func @print_int(%arg: i32) -> i32
        let i32Type = builder.I32Type()
        let funcType = builder.FunctionType([| i32Type |], [| i32Type |])
        let funcOp = builder.CreateFunction("print_int", funcType, location)

        // 함수 body
        let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
        let entryBlock = MlirNative.mlirBlockCreate(1n, &i32Type, nativeint 0)  // 1 argument
        MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)

        // 인자 가져오기
        let arg = MlirNative.mlirBlockGetArgument(entryBlock, 0n)

        // 포맷 문자열 주소 가져오기
        let formatStrOp = builder.CreateLLVMAddressOf(formatStrName, location)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, formatStrOp)
        let formatStrPtr = builder.GetResult(formatStrOp, 0)

        // printf 호출
        let printfCallOp = builder.CreateLLVMCall("printf", [| formatStrPtr; arg |], location)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, printfCallOp)

        // 인자를 그대로 반환 (print는 부수 효과)
        let returnOp = builder.CreateReturn([| arg |], location)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, returnOp)

        MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)

main 함수에서 print_int 호출

이제 main 함수를 수정하여 결과를 출력하도록 한다:

    /// 프로그램을 MLIR module로 컴파일 (print 지원)
    let translateToMlirWithPrint (program: Program) : Module =
        let ctx = new Context()
        ctx.LoadDialect("arith")
        ctx.LoadDialect("func")
        ctx.LoadDialect("llvm")

        let loc = Location.Unknown(ctx)
        let mlirMod = new Module(ctx, loc)

        let builder = OpBuilder(ctx)
        let i32Type = builder.I32Type()

        // printf 선언과 print_int 헬퍼 생성
        createPrintfDeclaration builder mlirMod loc
        let formatStrName = createFormatString builder mlirMod loc
        createPrintIntHelper builder mlirMod loc formatStrName

        // main 함수 생성
        let funcType = builder.FunctionType([||], [| i32Type |])
        let funcOp = builder.CreateFunction("main", funcType, loc)

        let bodyRegion = MlirNative.mlirOperationGetRegion(funcOp, 0n)
        let entryBlock = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
        MlirNative.mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)

        // 표현식 컴파일
        let resultValue = compileExpr builder entryBlock loc program.expr

        // print_int 호출
        let printOp = builder.CreateFunctionCall("print_int", [| resultValue |], loc)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, printOp)
        let printedVal = builder.GetResult(printOp, 0)

        // 결과 반환
        let returnOp = builder.CreateReturn([| printedVal |], loc)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, returnOp)

        MlirNative.mlirBlockAppendOwnedOperation(mlirMod.Body, funcOp)

        mlirMod

생성된 MLIR IR (전체):

module {
  // printf 선언
  func.func private @printf(!llvm.ptr<i8>, ...) -> i32

  // 포맷 문자열
  llvm.mlir.global private constant @.str.fmt("%d\n\00")

  // print_int 헬퍼
  func.func @print_int(%arg0: i32) -> i32 {
    %fmt = llvm.mlir.addressof @.str.fmt : !llvm.ptr<array<4 x i8>>
    %fmt_ptr = llvm.bitcast %fmt : !llvm.ptr<array<4 x i8>> to !llvm.ptr<i8>
    %result = llvm.call @printf(%fmt_ptr, %arg0) : (!llvm.ptr<i8>, i32) -> i32
    func.return %arg0 : i32
  }

  // main 함수
  func.func @main() -> i32 {
    %c10 = arith.constant 10 : i32
    %c3 = arith.constant 3 : i32
    %c4 = arith.constant 4 : i32
    %0 = arith.muli %c3, %c4 : i32
    %1 = arith.addi %c10, %0 : i32
    %2 = func.call @print_int(%1) : (i32) -> i32
    func.return %2 : i32
  }
}

실행 결과:

$ ./program
22
$ echo $?
22

결과가 stdout에 출력되고 종료 코드로도 반환된다!

완전한 컴파일러 드라이버

Chapter 05의 컴파일러 드라이버를 업데이트하여 새로운 기능을 지원한다:

Compiler.fs 업데이트:

namespace FunLangCompiler

open System
open System.IO

module Compiler =

    /// 소스 파일을 네이티브 실행 파일로 컴파일
    let compile (sourceFile: string) (outputFile: string) (withPrint: bool) =
        printfn "=== FunLang Compiler ==="
        printfn "Source: %s" sourceFile
        printfn "Output: %s" outputFile
        printfn ""

        // 1단계: 파싱
        printfn "[1/7] Parsing..."
        let source = File.ReadAllText(sourceFile)
        let program = Parser.parse source  // 실제 파서 사용 (LangTutorial 재사용)
        printfn "  AST: %A" program

        // 2단계: MLIR로 변환
        printfn "[2/7] Translating to MLIR..."
        let mlirMod =
            if withPrint then
                CodeGen.translateToMlirWithPrint program
            else
                CodeGen.translateToMlir program
        printfn "  MLIR (high-level):"
        printfn "%s" (mlirMod.Print())

        // 3단계: 검증
        printfn "[3/7] Verifying MLIR..."
        CodeGen.verify mlirMod
        printfn "  ✓ Verification passed"

        // 4-7단계: Lowering, LLVM IR, object file, linking (Chapter 05와 동일)
        Lowering.lowerToLLVMDialect mlirMod
        let llvmIR = Lowering.translateToLLVMIR mlirMod
        let objectFile = outputFile + ".o"
        NativeCodeGen.emitObjectFile llvmIR objectFile
        NativeCodeGen.linkExecutable objectFile outputFile

        mlirMod.Dispose()

        printfn ""
        printfn "=== Compilation successful ==="
        printfn "Run: ./%s" outputFile

[<EntryPoint>]
let main args =
    if args.Length < 2 then
        eprintfn "Usage: compiler <source.fun> <output> [--print]"
        exit 1

    let sourceFile = args.[0]
    let outputFile = args.[1]
    let withPrint = args.Length > 2 && args.[2] = "--print"

    Compiler.compile sourceFile outputFile withPrint
    0

사용 예시:

# 결과를 출력하지 않음 (종료 코드만)
$ dotnet run test.fun program

# 결과를 출력함 (stdout + 종료 코드)
$ dotnet run test.fun program --print
$ ./program
22

공통 에러 (2부)

에러 3: 비교가 i1을 반환하지만 i32가 필요한 곳에서 사용

증상:

MLIR verification failed:
  Type mismatch in func.return: expected i32, got i1

원인: 비교 연산은 i1 (boolean)을 반환하지만 main 함수는 i32를 반환해야 한다.

해결: i1을 i32로 확장한다:

// arith.extui 사용 (zero extend)
let extOp = builder.CreateArithExtUI(cmpVal, i32Type, location)

또는 main 함수가 i1을 반환하도록 변경 (덜 일반적):

// main 함수 시그니처를 i1으로 변경 (비권장)
let funcType = builder.FunctionType([||], [| builder.Context.GetIntegerType(1) |])

권장 방법: 항상 i32로 확장한다. Unix 종료 코드는 8비트 정수이므로 boolean을 정수로 표현하는 것이 자연스럽다.

에러 4: 0으로 나누기 (런타임 vs 컴파일 타임)

증상:

$ ./program
Floating point exception (core dumped)

원인: 10 / 0과 같은 표현식이 런타임에 0으로 나누기를 시도한다.

컴파일 타임 해결: AST를 분석하여 상수 0으로 나누기를 감지한다:

| BinaryOp(Divide, lhs, IntLiteral 0) ->
    failwith "Compile error: division by zero"

런타임 해결 (더 일반적): 동적 검사 코드를 삽입한다:

| BinaryOp(Divide, lhs, rhs) ->
    let lhsVal = compileExpr builder block location lhs
    let rhsVal = compileExpr builder block location rhs

    // rhsVal == 0 검사
    let zero = builder.CreateConstant(0, builder.I32Type(), location)
    let isZero = builder.CreateArithCmpi(ArithCmpIPredicate.eq, rhsVal, zero, location)

    // if (rhsVal == 0) abort() else lhs / rhs
    // scf.if 사용 (Chapter 08에서 다룸)
    // 지금은 단순화를 위해 생략

실용적 접근: 대부분의 컴파일러는 0으로 나누기를 런타임 에러로 남긴다. 프로그램이 SIGFPE로 종료되는 것이 예상 동작이다.

에러 5: printf 포맷 문자열에 null terminator 누락

증상:

$ ./program
22ݠ�(garbage characters)

원인: C 문자열은 null terminator (\0)가 필요하다. "%d\n" 대신 "%d\n\0"를 사용해야 한다.

해결:

// WRONG: null terminator 없음
let formatStrValue = "%d\n"

// CORRECT: null terminator 포함
let formatStrValue = "%d\n\0"

MLIR IR:

// CORRECT
llvm.mlir.global private constant @.str.fmt("%d\0A\00") : !llvm.array<4 x i8>

에러 6: arith 연산 후 LLVM dialect로 낮추기 잊음

증상:

Translation error: Unhandled operation 'arith.addi'

원인: arith dialect를 LLVM IR로 변환하려면 먼저 LLVM dialect로 낮춰야 한다.

해결: Lowering 단계에서 convert-arith-to-llvm pass를 실행한다:

// Pass manager에 추가
MlirStringRef.WithString "convert-arith-to-llvm" (fun passName ->
    let pass = MlirNative.mlirCreateConversionPass(passName)
    MlirNative.mlirPassManagerAddOwnedPass(pm, pass))

Pass 순서:

  1. convert-func-to-llvm
  2. convert-arith-to-llvm
  3. reconcile-unrealized-casts
  4. 그 다음 mlirTranslateModuleToLLVMIR

구현 시 주의사항 (Common Pitfalls)

실제 구현에서 발견된 중요한 주의사항들:

1. arith.cmpi predicate는 i64 타입이어야 한다

// WRONG: i32 타입 predicate
let predicateAttr = builder.IntegerAttr(0L, builder.I32Type())

// CORRECT: i64 타입 predicate
let predicateAttr = builder.IntegerAttr(0L, builder.I64Type())

MLIR ArithOps.td 정의에서 predicate는 64비트 정수 속성으로 정의되어 있다. i32를 사용하면 검증 에러가 발생한다.

2. 비교 연산 결과는 i1, 정수 연산 결과는 i32

// 비교 연산: i1 결과
let op = emitOp ctx "arith.cmpi" [| builder.I1Type() |] ...

// 산술 연산: i32 결과
let op = emitOp ctx "arith.addi" [| builder.I32Type() |] ...

3. Boolean 리터럴은 i1 타입의 arith.constant

// Boolean true/false
let i1Type = builder.I1Type()
let value = if b then 1L else 0L
let valueAttr = builder.IntegerAttr(value, i1Type)  // i1 타입으로 생성

4. 비단락 평가에 주의

arith.andiarith.ori는 양쪽 피연산자를 모두 평가한다. 부수 효과가 있는 표현식에서 문제가 될 수 있다. 진정한 단락 평가가 필요하면 scf.if를 사용한다.

장 요약

이 장에서 다음을 성취했다:

  1. 확장된 AST: 이진 연산자, 비교, 단항 부정, boolean 리터럴, 논리 연산자를 지원하는 표현식 타입
  2. Generic 연산 생성: CreateOperation 패턴으로 arith dialect 연산 생성
  3. 비교 연산: arith.cmpi와 i64 predicate 속성
  4. Boolean 지원: i1 타입, arith.andi/ori 논리 연산자
  5. 재귀 코드 생성: SSA 형태를 유지하며 복잡한 표현식 컴파일
  6. 출력 기능: printf를 통한 결과 출력
  7. 완전한 예제: MLIR IR 출력을 보여주는 실행 가능한 코드

독자가 할 수 있는 것:

  • 10 + 3 * 4 컴파일 → 네이티브 바이너리 → 실행 → 결과: 22 ✓
  • 5 < 10 컴파일 → boolean 반환 (1 = true) ✓
  • -42 컴파일 → 부정 연산 ✓
  • print(10 + 20) 컴파일 → stdout 출력: 30 ✓

다음 장 미리보기:

Chapter 07에서는 let 바인딩을 추가한다:

let x = 10 in
let y = 20 in
x + y

이것은 다음을 도입한다:

  • 변수 이름과 SSA value 간의 환경 (symbol table)
  • 중첩된 스코프 (nested scopes)
  • 변수 섀도잉 (shadowing) vs 뮤테이션 (mutation)

Phase 2는 계속된다!


이제 독자는 산술 표현식을 컴파일하고 결과를 출력할 수 있다!

Chapter 07: Let 바인딩과 SSA 형태

소개

프로그래밍에서 변수는 필수적이다. 값을 이름에 바인딩하고, 나중에 그 이름을 참조하여 값을 재사용한다. Chapter 06까지는 표현식을 직접 계산했지만, 실제 프로그램을 작성하려면 중간 결과를 저장하고 참조할 수 있어야 한다.

이 장에서는 let 바인딩을 추가한다:

let x = 10 in
let y = 20 in
x + y

함수형 언어의 let 바인딩은 명령형 언어의 변수 할당과 다르다:

  • 명령형: x = 5; x = 10; (뮤테이션 - 값이 변경됨)
  • 함수형: let x = 5 in let x = 10 in x (섀도잉 - 새로운 바인딩 생성, 뮤테이션 아님)

핵심 통찰력: let 바인딩은 **불변(immutable)**이다. 이것이 MLIR의 SSA (Static Single Assignment) 형태와 완벽하게 일치한다. 함수형 프로그래밍은 SSA를 자연스럽게 표현한다!

이 장을 마치면:

  • let 바인딩을 컴파일하여 네이티브 바이너리로 만들 수 있다
  • 중첩된 바인딩과 스코프를 이해한다
  • SSA 형태가 무엇이고 왜 중요한지 안다
  • 환경 전달(environment passing)로 변수를 관리하는 방법을 안다

중요: 이 장은 SSA 개념을 소개한다. SSA는 현대 컴파일러의 핵심 기술이며, MLIR은 SSA를 기본으로 한다.

SSA 형태 설명

SSA란 무엇인가?

**SSA (Static Single Assignment)**는 중간 표현(IR)의 속성이다:

정의: 각 변수는 프로그램에서 정확히 한 번만 할당된다.

예시:

// SSA가 아님 (명령형):
x = 5
x = 10  // x가 두 번 할당됨!

// SSA (함수형):
let x1 = 5 in    // x1은 한 번만 할당
let x2 = 10 in   // x2는 한 번만 할당
x2

MLIR IR에서 SSA value는 % 기호로 표시된다:

%x = arith.constant 5 : i32      // %x 정의 (한 번만)
%y = arith.addi %x, %x : i32     // %x 사용 (여러 번 가능)
%z = arith.muli %y, %x : i32     // %x, %y 사용

각 SSA value (%x, %y, %z)는 정확히 한 번만 정의된다. 사용은 여러 번 가능하다.

왜 SSA가 중요한가?

SSA 형태는 컴파일러 최적화를 극적으로 단순화한다:

1. 상수 전파 (Constant Propagation)

// SSA 형태
%c5 = arith.constant 5 : i32
%result = arith.addi %c5, %c5 : i32

// 최적화: %c5가 상수임을 알고 있으므로
%c10 = arith.constant 10 : i32  // 컴파일 타임에 계산

SSA value는 한 번만 정의되므로, 정의를 추적하여 상수 값을 전파할 수 있다.

2. 죽은 코드 제거 (Dead Code Elimination)

// SSA 형태
%unused = arith.constant 42 : i32  // 정의되지만 사용되지 않음
%result = arith.constant 10 : i32
func.return %result : i32

// 최적화: %unused는 사용되지 않으므로 제거 가능

SSA value가 사용되지 않으면 정의도 불필요하다. 쉽게 감지하고 제거할 수 있다.

3. 레지스터 할당 (Register Allocation)

%x = arith.constant 5 : i32       // %x의 수명 시작
%y = arith.constant 10 : i32      // %y의 수명 시작
%z = arith.addi %x, %y : i32      // %x, %y 사용 (%x, %y 수명 끝)
func.return %z : i32               // %z 사용 (%z 수명 끝)

SSA value의 수명(lifetime)이 명확하다:

  • 정의 지점에서 시작
  • 마지막 사용 지점에서 끝

레지스터 할당기가 수명 분석을 쉽게 수행하여 레지스터를 효율적으로 재사용할 수 있다.

Let 바인딩은 자연스럽게 SSA다

함수형 언어의 let 바인딩은 불변이므로, 변환 없이 SSA로 직접 매핑된다:

// FunLang 소스
let x = 5 in
x + x

MLIR IR로 변환:

func.func @main() -> i32 {
  %x = arith.constant 5 : i32      // let x = 5
  %result = arith.addi %x, %x : i32  // x + x
  func.return %result : i32
}

let x = 5가 SSA value %x단일 정의가 된다. 추가 작업이 필요 없다!

명령형 언어와의 대비

명령형 언어는 변수 뮤테이션을 허용하므로 SSA 변환이 필요하다:

// C 코드 (SSA 아님)
int x = 5;
int y = x + x;
x = 10;       // 뮤테이션!
int z = x + y;

SSA로 변환 (컴파일러가 수행):

%x_0 = arith.constant 5 : i32       // x = 5
%y = arith.addi %x_0, %x_0 : i32    // y = x + x
%x_1 = arith.constant 10 : i32      // x = 10 (새로운 SSA value)
%z = arith.addi %x_1, %y : i32      // z = x + y

각 “할당“이 새로운 SSA value를 생성한다 (%x_0, %x_1). 이것이 SSA 변환(SSA conversion)이다.

함수형 언어의 이점: 뮤테이션이 없으므로 SSA 변환이 불필요하다. let 바인딩이 이미 SSA다!

섀도잉: 새로운 값, 뮤테이션 아님

함수형 언어에서 같은 이름을 다시 바인딩하면 어떻게 될까?

let x = 5 in
let x = 10 in
x

이것은 **섀도잉(shadowing)**이다:

func.func @main() -> i32 {
  %x = arith.constant 5 : i32      // 첫 번째 x 바인딩
  %x_0 = arith.constant 10 : i32   // 두 번째 x 바인딩 (새로운 값)
  func.return %x_0 : i32            // 내부 x 사용
}

핵심: MLIR은 자동으로 고유한 이름을 생성한다 (%x, %x_0, %x_1, …). 섀도잉은 새로운 SSA value를 만들 뿐, 기존 값을 변경하지 않는다.

외부 %x는 여전히 존재하지만 내부 스코프에서는 가려진다 (shadowed). 스코프가 끝나면 외부 %x가 다시 보인다.

SSA의 제약

SSA에서 제어 흐름(control flow) 합류 지점에서는 어떻게 될까?

let x = if condition then 10 else 20 in
x + x

if 표현식이 두 가지 다른 값 (10 또는 20)을 생성할 수 있다. 어떤 SSA value를 x에 바인딩해야 할까?

해답: MLIR은 block arguments를 사용한다. Chapter 08 (제어 흐름)에서 자세히 다룰 것이다. 지금은 let 바인딩이 단순한 값 바인딩이며 조건부 바인딩이 없다는 점만 기억하자.

SSA 요약

SSA 형태:

  • 각 value는 정확히 한 번만 정의된다
  • 사용은 여러 번 가능하다
  • 컴파일러 최적화를 단순화한다
  • MLIR은 SSA를 기본으로 한다

Let 바인딩과 SSA:

  • 함수형 언어의 let 바인딩은 불변이다
  • 불변 = 자연스러운 SSA 형태
  • 섀도잉은 새로운 SSA value를 생성한다
  • 뮤테이션이 없으므로 SSA 변환이 불필요하다

명심: SSA는 이론이 아니라 실용이다. 모든 현대 컴파일러 (LLVM, GCC, MLIR)는 SSA를 사용한다. 함수형 언어는 SSA를 “무료로” 얻는다!

확장된 AST: Let과 Var

이제 AST에 let 바인딩과 변수 참조를 추가한다.

Ast.fs 수정:

namespace FunLangCompiler

/// 이진 연산자 (Chapter 06)
type Operator =
    | Add
    | Subtract
    | Multiply
    | Divide

/// 비교 연산자 (Chapter 06)
type CompareOp =
    | LessThan
    | GreaterThan
    | LessEqual
    | GreaterEqual
    | Equal
    | NotEqual

/// 단항 연산자 (Chapter 06)
type UnaryOp =
    | Negate

/// FunLang 표현식 AST
type Expr =
    | IntLiteral of int
    | BinaryOp of Operator * Expr * Expr
    | UnaryOp of UnaryOp * Expr
    | Comparison of CompareOp * Expr * Expr
    // NEW: let 바인딩과 변수 참조
    | Let of name: string * binding: Expr * body: Expr
    | Var of name: string

/// 최상위 프로그램
type Program =
    { expr: Expr }

새로운 케이스 설명:

Let of name * binding * body

| Let of name: string * binding: Expr * body: Expr

의미: let {name} = {binding} in {body}

필드:

  • name: 바인딩할 변수 이름 (예: “x”)
  • binding: 변수에 바인딩할 표현식 (예: IntLiteral 10)
  • body: 바인딩이 유효한 스코프 (예: BinaryOp(Add, Var "x", Var "x"))

예시:

// FunLang: let x = 10 in x + x
Let("x",
  IntLiteral 10,
  BinaryOp(Add, Var "x", Var "x"))

스코프: body 표현식 내에서만 name이 유효하다. 스코프 밖에서는 변수가 존재하지 않는다.

Var of name

| Var of name: string

의미: 변수 참조 - 이전에 바인딩된 변수의 값을 사용한다.

필드:

  • name: 참조할 변수 이름 (예: “x”)

예시:

// FunLang: x
Var "x"

바인딩 필요: Var "x"를 사용하려면 스코프에서 x가 바인딩되어 있어야 한다. 바인딩되지 않은 변수를 참조하면 컴파일 에러다.

중첩된 Let 바인딩

// FunLang:
// let x = 10 in
// let y = 20 in
// x + y

Let("x",
  IntLiteral 10,
  Let("y",
    IntLiteral 20,
    BinaryOp(Add, Var "x", Var "y")))

스코프 중첩:

  • 외부 let (x)의 body는 내부 let (y)이다
  • 내부 let의 body에서 xy 모두 보인다
  • 내부 스코프는 외부 스코프를 “확장“한다

섀도잉 예시

// FunLang:
// let x = 5 in
// let x = x + 1 in
// x

Let("x",
  IntLiteral 5,
  Let("x",
    BinaryOp(Add, Var "x", IntLiteral 1),  // 외부 x 사용
    Var "x"))  // 내부 x 반환

섀도잉 동작:

  • 두 번째 Let("x", ...): 새로운 x 바인딩
  • BinaryOp(Add, Var "x", ...): 여기서 Var "x"외부 x (값 5)를 참조한다
  • bodyVar "x": 여기서 Var "x"내부 x (값 6)를 참조한다

결과: 6을 반환한다.

AST 완전한 예시

// FunLang:
// let x = 10 in
// let y = 20 in
// let z = x + y in
// z * 2

Let("x",
  IntLiteral 10,
  Let("y",
    IntLiteral 20,
    Let("z",
      BinaryOp(Add, Var "x", Var "y"),
      BinaryOp(Multiply, Var "z", IntLiteral 2))))

예상 결과:

  • x = 10
  • y = 20
  • z = x + y = 30
  • z * 2 = 60

이 AST를 컴파일하면 60을 반환하는 네이티브 바이너리가 생성된다.

환경 개념 (Environment)

변수를 컴파일하려면 **환경(environment)**이 필요하다.

환경이란?

정의: 환경은 변수 이름을 SSA value에 매핑하는 자료구조다.

타입 정의:

/// 변수 이름 -> MLIR SSA value 매핑
type Env = Map<string, MlirValue>

F#의 Map 타입은 불변 딕셔너리다. 키-값 쌍을 저장하며, 함수형 방식으로 확장할 수 있다.

환경 연산

1. 빈 환경 생성

let emptyEnv : Env = Map.empty

프로그램 시작 시 환경은 비어 있다. 아직 변수가 바인딩되지 않았다.

2. 환경 확장 (바인딩 추가)

// x를 %c5 SSA value에 바인딩
let env = Map.empty
let env' = env.Add("x", someValue)

env.Add(name, value)는 새로운 환경을 반환한다. 기존 환경 env는 변경되지 않는다 (불변성).

3. 변수 조회

// x의 SSA value 찾기
match env.TryFind("x") with
| Some(value) -> value  // x가 바인딩되어 있음
| None -> failwith "Unbound variable: x"  // x가 바인딩되지 않음

TryFindOption 타입을 반환한다:

  • Some(value): 변수가 환경에 존재
  • None: 변수가 존재하지 않음 (컴파일 에러)

환경과 스코프

스코프는 환경을 통해 구현된다:

// let x = 10 in let y = 20 in x + y
// 각 let이 환경을 확장한다

let env0 = Map.empty             // 초기 환경 (비어 있음)

// let x = 10
let env1 = env0.Add("x", %c10)   // env1 = { x -> %c10 }

// let y = 20
let env2 = env1.Add("y", %c20)   // env2 = { x -> %c10, y -> %c20 }

// x + y (env2에서 x와 y 조회)
// x = %c10, y = %c20

환경 스택 다이어그램:

let x = 5 in       env = { x -> %c5 }
  let y = 10 in    env = { x -> %c5, y -> %c10 }
    x + y          lookup x, lookup y -> arith.addi %c5, %c10

각 let 바인딩이 환경에 새로운 항목을 추가한다. 내부 스코프의 환경은 외부 스코프의 모든 바인딩을 포함한다.

섀도잉과 환경

같은 이름을 다시 바인딩하면?

// let x = 5 in let x = 10 in x
let env0 = Map.empty
let env1 = env0.Add("x", %c5)   // env1 = { x -> %c5 }
let env2 = env1.Add("x", %c10)  // env2 = { x -> %c10 }

// env2에서 x 조회 -> %c10 (새로운 바인딩)

Map.Add는 기존 키가 있으면 값을 덮어쓴다. 하지만 env1은 변경되지 않는다 (불변):

// env1에서 x 조회 -> 여전히 %c5
// env2에서 x 조회 -> %c10

이것이 스코프 기반 섀도잉이다. 내부 스코프가 끝나면 외부 바인딩이 다시 보인다:

// let x = 5 in (let x = 10 in x) + x
//               ^^^^^^^^^^^^^   ^^^
//               내부 x = 10     외부 x = 5

let env0 = Map.empty
let env1 = env0.Add("x", %c5)

// 내부 스코프
let env2 = env1.Add("x", %c10)
// 내부 body에서 x 조회 -> %c10

// 외부 스코프로 돌아옴 (env1 사용)
// 외부 body에서 x 조회 -> %c5

결과: 10 + 5 = 15

환경 전달 패턴

실제 구현에서는 환경을 CompileContext 레코드의 필드로 관리한다:

/// Compilation context - 모든 컴파일 상태를 하나로 묶음
type CompileContext = {
    Context: Context
    Builder: OpBuilder
    Location: Location
    Block: MlirBlock  // Current block to append operations to
    Env: Map<string, MlirValue>  // Variable name -> SSA value mapping
}

let rec compileExpr (ctx: CompileContext) (expr: Expr) : MlirValue =
  let builder = ctx.Builder
  match expr with
  | IntLiteral n -> ...  // ctx.Env 사용 안 함
  | Var(name, _) ->
      // ctx.Env에서 변수 조회
      match ctx.Env.TryFind(name) with
      | Some value -> value
      | None -> failwithf "Unbound variable: %s" name
  | Let(name, binding, body, _) ->
      // 1. binding 표현식 컴파일 (현재 ctx 사용)
      let bindVal = compileExpr ctx binding
      // 2. ctx 확장 (immutable update)
      let extendedEnv = ctx.Env.Add(name, bindVal)
      let ctx' = { ctx with Env = extendedEnv }
      // 3. body 표현식 컴파일 (확장된 ctx' 사용)
      compileExpr ctx' body
  | BinaryOp(op, lhs, rhs) ->
      // 재귀 호출에 ctx 전달
      let lhsVal = compileExpr ctx lhs
      let rhsVal = compileExpr ctx rhs
      ...

핵심: { ctx with Env = extendedEnv } 패턴으로 불변 레코드를 업데이트한다. F#의 record update syntax는 새로운 레코드를 생성하므로 기존 ctx는 변경되지 않는다.

핵심 패턴:

  • compileExprenv 파라미터를 받는다
  • 모든 재귀 호출에서 env를 전달한다
  • Let 케이스에서 env를 확장하고 body에 전달한다
  • Var 케이스에서 env를 조회한다

이것이 **환경 전달(environment passing)**이다. 함수형 프로그래밍에서 흔한 패턴이다.

환경 요약

환경:

  • 변수 이름 -> SSA value 매핑
  • F# Map<string, MlirValue> 타입
  • 불변 자료구조

연산:

  • Map.empty: 빈 환경
  • env.Add(name, value): 바인딩 추가 (새 환경 반환)
  • env.TryFind(name): 변수 조회 (Option 반환)

스코프:

  • 각 let 바인딩이 환경을 확장한다
  • 내부 스코프는 외부 바인딩을 모두 포함한다
  • 섀도잉은 Map.Add로 구현된다

환경 전달:

  • compileExprenv 파라미터 추가
  • 재귀 호출에서 env 전달
  • Let 케이스에서 env 확장

다음 섹션: 환경을 사용하여 let 바인딩을 MLIR IR로 컴파일하는 코드를 작성한다!

환경을 사용한 코드 생성

이제 Chapter 06의 compileExpr을 확장하여 let 바인딩과 변수를 처리한다.

compileExpr 시그니처 변경

먼저 환경 파라미터를 추가한다:

// 기존 (Chapter 06):
let rec compileExpr
    (builder: OpBuilder)
    (block: MlirBlock)
    (location: Location)
    (expr: Expr)
    : MlirValue = ...

// 새로운 (Chapter 07):
let rec compileExpr
    (builder: OpBuilder)
    (block: MlirBlock)
    (location: Location)
    (expr: Expr)
    (env: Env)        // 환경 추가!
    : MlirValue = ...

환경 타입 정의:

/// 변수 이름 -> MLIR SSA value 매핑
type Env = Map<string, MlirValue>

Let 케이스 구현

| Let(name, binding, body) ->
    // 1. binding 표현식 컴파일 (현재 환경 사용)
    let bindVal = compileExpr builder block location binding env

    // 2. 환경 확장: name -> bindVal 매핑 추가
    let env' = env.Add(name, bindVal)

    // 3. body 표현식 컴파일 (확장된 환경 사용)
    compileExpr builder block location body env'

동작 설명:

  1. binding 표현식을 먼저 컴파일한다. 이것이 변수에 바인딩될 값이다.
  2. 현재 환경 env를 확장하여 namebindVal에 매핑한다. 새로운 환경 env'가 생성된다.
  3. body 표현식을 컴파일할 때 확장된 환경 env'를 사용한다. body 내에서 name을 참조할 수 있다.

핵심: let 바인딩은 MLIR IR에 새로운 연산을 생성하지 않는다. 단지 환경을 확장하고 body를 컴파일할 뿐이다. SSA value는 binding 표현식에서 이미 생성되었다.

Var 케이스 구현

| Var(name) ->
    // 환경에서 변수 조회
    match env.TryFind(name) with
    | Some(value) -> value  // 바인딩된 SSA value 반환
    | None -> failwithf "Unbound variable: %s" name  // 컴파일 에러

동작 설명:

  • env.TryFind(name)으로 변수를 조회한다.
  • 바인딩되어 있으면 (Some(value)) 해당 SSA value를 반환한다.
  • 바인딩되지 않았으면 (None) 에러를 발생시킨다.

중요: 변수 참조는 MLIR IR에 새로운 연산을 생성하지 않는다. 단지 기존 SSA value를 반환할 뿐이다. 이것이 SSA의 핵심이다!

기존 케이스 업데이트

모든 기존 케이스에서 재귀 호출에 env를 전달해야 한다:

| IntLiteral value ->
    // 환경 사용 안 함 (리터럴은 변수를 참조하지 않음)
    let i32Type = builder.I32Type()
    let attr = builder.Context.GetIntegerAttr(i32Type, int64 value)
    let constOp = builder.CreateConstant(attr, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, constOp)
    builder.GetResult(constOp, 0)

| BinaryOp(op, lhs, rhs) ->
    // 재귀 호출에 env 전달
    let lhsVal = compileExpr builder block location lhs env
    let rhsVal = compileExpr builder block location rhs env
    let binOp = builder.CreateArithBinaryOp(op, lhsVal, rhsVal, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, binOp)
    builder.GetResult(binOp, 0)

| UnaryOp(Negate, expr) ->
    // 재귀 호출에 env 전달
    let val = compileExpr builder block location expr env
    let negOp = builder.CreateArithNegate(val, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, negOp)
    builder.GetResult(negOp, 0)

| Comparison(compareOp, lhs, rhs) ->
    // 재귀 호출에 env 전달
    let lhsVal = compileExpr builder block location lhs env
    let rhsVal = compileExpr builder block location rhs env
    let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
    let cmpVal = builder.GetResult(cmpOp, 0)
    // i1 -> i32 확장 (Chapter 06과 동일)
    let i32Type = builder.I32Type()
    let extOp = builder.CreateArithExtUI(cmpVal, i32Type, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, extOp)
    builder.GetResult(extOp, 0)

패턴: 모든 재귀 호출에서 현재 환경 env를 그대로 전달한다. Let 케이스만 환경을 확장한다.

완전한 CodeGen.fs 리스팅

CodeGen.fs (환경 지원 버전):

namespace FunLangCompiler

open System
open MlirWrapper
open MlirBindings

module CodeGen =

    /// 변수 이름 -> MLIR SSA value 매핑
    type Env = Map<string, MlirValue>

    /// 표현식을 MLIR value로 컴파일 (재귀적, 환경 전달)
    let rec compileExpr
        (builder: OpBuilder)
        (block: MlirBlock)
        (location: Location)
        (expr: Expr)
        (env: Env)
        : MlirValue =

        match expr with
        | IntLiteral value ->
            let i32Type = builder.I32Type()
            let attr = builder.Context.GetIntegerAttr(i32Type, int64 value)
            let constOp = builder.CreateConstant(attr, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, constOp)
            builder.GetResult(constOp, 0)

        | Var(name) ->
            match env.TryFind(name) with
            | Some(value) -> value
            | None -> failwithf "Unbound variable: %s" name

        | Let(name, binding, body) ->
            let bindVal = compileExpr builder block location binding env
            let env' = env.Add(name, bindVal)
            compileExpr builder block location body env'

        | BinaryOp(op, lhs, rhs) ->
            let lhsVal = compileExpr builder block location lhs env
            let rhsVal = compileExpr builder block location rhs env
            let binOp = builder.CreateArithBinaryOp(op, lhsVal, rhsVal, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, binOp)
            builder.GetResult(binOp, 0)

        | UnaryOp(Negate, expr) ->
            let val = compileExpr builder block location expr env
            let negOp = builder.CreateArithNegate(val, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, negOp)
            builder.GetResult(negOp, 0)

        | Comparison(compareOp, lhs, rhs) ->
            let lhsVal = compileExpr builder block location lhs env
            let rhsVal = compileExpr builder block location rhs env
            let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
            let cmpVal = builder.GetResult(cmpOp, 0)
            let i32Type = builder.I32Type()
            let extOp = builder.CreateArithExtUI(cmpVal, i32Type, location)
            MlirNative.mlirBlockAppendOwnedOperation(block, extOp)
            builder.GetResult(extOp, 0)

    /// Compile a FunLang expression into a function that returns i32
    let compileToFunction (ctx: Context) (funcName: string) (expr: Expr) : Module =
        let loc = Location.Unknown ctx
        let mlirMod = new Module(ctx, loc)
        let builder = OpBuilder(ctx)

        let i32Type = builder.I32Type()
        let funcType = builder.FunctionType([||], [| i32Type |])

        // Create function body
        let region = builder.CreateRegion()
        let entryBlock = builder.CreateBlock([||], loc)
        builder.AppendBlockToRegion(region, entryBlock)

        // Compile expression into the entry block
        let compileCtx = {
            Context = ctx
            Builder = builder
            Location = loc
            Block = entryBlock
            Env = Map.empty  // 빈 환경에서 시작
        }
        let resultVal = compileExpr compileCtx expr

        // Return the result
        let returnOp = builder.CreateOperation(
            "func.return", loc,
            [||], [| resultVal |], [||], [||])
        builder.AppendOperationToBlock(entryBlock, returnOp)

        // Create func.func with C interface for JIT
        let unitAttr = MlirNative.mlirUnitAttrGet(ctx.Handle)
        let funcOp = builder.CreateOperation(
            "func.func", loc,
            [||], [||],
            [| builder.NamedAttr("sym_name", builder.StringAttr(funcName))
               builder.NamedAttr("function_type", builder.TypeAttr(funcType))
               builder.NamedAttr("llvm.emit_c_interface", unitAttr) |],
            [| region |])
        builder.AppendOperationToBlock(mlirMod.Body, funcOp)

        mlirMod

    /// MLIR module 검증
    let verify (mlirMod: Module) =
        if not (mlirMod.Verify()) then
            eprintfn "MLIR verification failed:"
            eprintfn "%s" (mlirMod.Print())
            failwith "MLIR IR is invalid"

주목할 점:

  • compileExprenv 파라미터를 받는다
  • translateToMlir에서 빈 환경 (Map.empty)으로 시작한다
  • 모든 재귀 호출에서 env를 전달한다

중첩된 Let 바인딩

중첩된 let 바인딩이 어떻게 컴파일되는지 보자.

FunLang 소스:

let x = 10 in
let y = 20 in
let z = x + y in
z * 2

AST:

Let("x",
  IntLiteral 10,
  Let("y",
    IntLiteral 20,
    Let("z",
      BinaryOp(Add, Var "x", Var "y"),
      BinaryOp(Multiply, Var "z", IntLiteral 2))))

컴파일 과정:

  1. Let(“x”, IntLiteral 10, …)

    • binding 컴파일: %c10 = arith.constant 10 : i32
    • env0 = {}
    • env1 = env0.Add("x", %c10) = { x -> %c10 }
    • body 컴파일 (env1 사용)
  2. Let(“y”, IntLiteral 20, …) (env1에서)

    • binding 컴파일: %c20 = arith.constant 20 : i32
    • env2 = env1.Add("y", %c20) = { x -> %c10, y -> %c20 }
    • body 컴파일 (env2 사용)
  3. Let(“z”, BinaryOp(Add, Var “x”, Var “y”), …) (env2에서)

    • binding 컴파일:
      • Var "x": env2에서 조회 → %c10
      • Var "y": env2에서 조회 → %c20
      • %z = arith.addi %c10, %c20 : i32
    • env3 = env2.Add("z", %z) = { x -> %c10, y -> %c20, z -> %z }
    • body 컴파일 (env3 사용)
  4. BinaryOp(Multiply, Var “z”, IntLiteral 2) (env3에서)

    • Var "z": env3에서 조회 → %z
    • IntLiteral 2: %c2 = arith.constant 2 : i32
    • %result = arith.muli %z, %c2 : i32

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %c10 = arith.constant 10 : i32      // let x = 10
    %c20 = arith.constant 20 : i32      // let y = 20
    %z = arith.addi %c10, %c20 : i32    // let z = x + y
    %c2 = arith.constant 2 : i32
    %result = arith.muli %z, %c2 : i32  // z * 2
    func.return %result : i32
  }
}

분석:

  • 각 let 바인딩이 SSA value를 생성한다
  • 변수 참조는 기존 SSA value를 재사용한다
  • 명시적인 저장/로드 연산이 없다 (모든 것이 레지스터에 있다)
  • SSA value가 자유롭게 흐른다 (%c10과 %c20이 %z에서 사용됨)

실행:

$ ./program
$ echo $?
60

예상대로 60을 반환한다!

변수 섀도잉

섀도잉이 어떻게 작동하는지 보자.

FunLang 소스:

let x = 5 in
let x = x + 1 in
x

AST:

Let("x",
  IntLiteral 5,
  Let("x",
    BinaryOp(Add, Var "x", IntLiteral 1),
    Var "x"))

컴파일 과정:

  1. 첫 번째 Let(“x”, IntLiteral 5, …)

    • binding: %x = arith.constant 5 : i32
    • env1 = { x -> %x }
  2. 두 번째 Let(“x”, BinaryOp(Add, Var “x”, IntLiteral 1), …) (env1에서)

    • binding:
      • Var "x": env1에서 조회 → %x (값 5)
      • IntLiteral 1: %c1 = arith.constant 1 : i32
      • %x_0 = arith.addi %x, %c1 : i32
    • env2 = env1.Add("x", %x_0) = { x -> %x_0 } ← 섀도잉!
  3. Var “x” (env2에서)

    • env2에서 조회 → %x_0 (값 6)

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %x = arith.constant 5 : i32        // 외부 x
    %c1 = arith.constant 1 : i32
    %x_0 = arith.addi %x, %c1 : i32    // 내부 x = 외부 x + 1
    func.return %x_0 : i32              // 내부 x 반환
  }
}

핵심 통찰력:

  • MLIR은 자동으로 고유한 이름을 생성한다 (%x, %x_0)
  • 두 번째 Let("x", ...)에서 binding 표현식은 외부 x를 참조한다 (env1에서 컴파일)
  • body 표현식은 내부 x를 참조한다 (env2에서 컴파일)
  • 섀도잉은 새로운 SSA value를 생성하지, 기존 value를 변경하지 않는다

실행:

$ ./program
$ echo $?
6

예상대로 6을 반환한다!

완전한 예시와 드라이버

이제 완전한 컴파일러 드라이버를 작성하자.

Main.fs 예시:

namespace FunLangCompiler

open System

module Main =

    [<EntryPoint>]
    let main args =
        printfn "=== FunLang Compiler with Let Bindings ==="

        // 예시: let x = 10 in let y = 20 in x + y
        let ast =
            Let("x",
              IntLiteral 10,
              Let("y",
                IntLiteral 20,
                BinaryOp(Add, Var "x", Var "y")))

        let program = { expr = ast }

        printfn "AST: %A" ast
        printfn ""

        // MLIR로 컴파일
        printfn "Compiling to MLIR..."
        let mlirMod = CodeGen.translateToMlir program
        printfn "%s" (mlirMod.Print())

        // 검증
        printfn "Verifying..."
        CodeGen.verify mlirMod
        printfn "✓ Verification passed"

        // Lowering과 네이티브 코드 생성 (Chapter 05와 동일)
        Lowering.lowerToLLVMDialect mlirMod
        let llvmIR = Lowering.translateToLLVMIR mlirMod
        NativeCodeGen.emitObjectFile llvmIR "program.o"
        NativeCodeGen.linkExecutable "program.o" "program"

        mlirMod.Dispose()

        printfn ""
        printfn "=== Compilation successful ==="
        printfn "Run: ./program"
        printfn "Expected output (exit code): 30"

        0

컴파일과 실행:

$ dotnet run
=== FunLang Compiler with Let Bindings ===
AST: Let ("x", IntLiteral 10, Let ("y", IntLiteral 20, BinaryOp (Add, Var "x", Var "y")))

Compiling to MLIR...
module {
  func.func @main() -> i32 {
    %c10 = arith.constant 10 : i32
    %c20 = arith.constant 20 : i32
    %0 = arith.addi %c10, %c20 : i32
    func.return %0 : i32
  }
}
Verifying...
✓ Verification passed
[... lowering과 linking ...]

=== Compilation successful ===
Run: ./program
Expected output (exit code): 30

$ ./program
$ echo $?
30

완벽하다!

공통 에러

에러 1: 바인딩되지 않은 변수 참조

증상:

Exception: Unbound variable: y

원인:

변수가 스코프에 없는데 참조하려고 했다.

예시:

// WRONG: y가 바인딩되지 않음
let x = 10 in
y + x

해결:

변수를 사용하기 전에 let 바인딩으로 정의한다:

// CORRECT
let x = 10 in
let y = 20 in
y + x

에러 2: 스코프 밖에서 변수 사용

증상:

Exception: Unbound variable: x

원인:

변수가 스코프 밖에서 사용되었다.

예시:

// WRONG: 두 번째 x는 스코프 밖
(let x = 10 in x + x) + x
//                      ^ x는 여기서 바인딩되지 않음

let 바인딩의 스코프는 body 표현식까지만이다. 밖에서는 보이지 않는다.

해결:

필요한 스코프 전체를 감싸거나, 바인딩을 외부로 이동한다:

// CORRECT: x를 외부에서 바인딩
let x = 10 in
(x + x) + x

에러 3: 환경을 재귀 호출에 전달하지 않음

증상:

Compilation error: 'env' is not defined

원인:

compileExpr 재귀 호출에서 env 파라미터를 빠뜨렸다.

예시:

// WRONG: env 파라미터 누락
| BinaryOp(op, lhs, rhs) ->
    let lhsVal = compileExpr builder block location lhs  // env 없음!
    ...

해결:

모든 compileExpr 호출에 env를 전달한다:

// CORRECT
| BinaryOp(op, lhs, rhs) ->
    let lhsVal = compileExpr builder block location lhs env
    let rhsVal = compileExpr builder block location rhs env
    ...

패턴: 각 케이스를 추가할 때마다 재귀 호출에 env를 전달하는지 확인한다.

에러 4: Let 바인딩에서 환경 확장 잊음

증상:

변수가 body에서 보이지 않는다.

원인:

Let 케이스에서 env.Add를 호출했지만 확장된 환경을 body에 전달하지 않았다.

예시:

// WRONG: 확장된 환경을 사용하지 않음
| Let(name, binding, body) ->
    let bindVal = compileExpr builder block location binding env
    let env' = env.Add(name, bindVal)
    compileExpr builder block location body env  // env' 대신 env 사용!

해결:

확장된 환경 env'를 body에 전달한다:

// CORRECT
| Let(name, binding, body) ->
    let bindVal = compileExpr builder block location binding env
    let env' = env.Add(name, bindVal)
    compileExpr builder block location body env'  // env' 사용!

에러 5: 섀도잉을 뮤테이션으로 착각

개념 오류:

// 이것은 뮤테이션이 아니다!
let x = 5 in
let x = 10 in
x

설명:

이것은 변수 “x“를 덮어쓰는 것이 아니다. 새로운 바인딩을 만드는 것이다:

  • 외부 x는 값 5를 가진 SSA value %x
  • 내부 x는 값 10을 가진 SSA value %x_0
  • 두 value 모두 존재한다 (외부 x는 변경되지 않음)

MLIR IR 확인:

%x = arith.constant 5 : i32    // 외부 x (여전히 존재)
%x_0 = arith.constant 10 : i32  // 내부 x (새로운 value)
func.return %x_0 : i32

장 요약

이 장에서 다음을 성취했다:

  1. SSA 형태 이해: 각 value는 한 번만 정의되며, 이것이 컴파일러 최적화를 단순화한다
  2. Let 바인딩 추가: 함수형 언어의 불변 바인딩이 SSA와 자연스럽게 일치한다
  3. 환경 구현: Map<string, MlirValue>로 변수 스코프 관리
  4. 환경 전달 패턴: 재귀 함수에 환경을 전달하여 중첩 스코프 구현
  5. 섀도잉 vs 뮤테이션: 섀도잉은 새로운 SSA value를 생성하지, 기존 value를 변경하지 않는다
  6. 완전한 예제: 중첩된 let 바인딩이 올바른 MLIR IR로 컴파일된다

독자가 할 수 있는 것:

  • let x = 5 in x + x 컴파일 → 네이티브 바이너리 → 결과: 10 ✓
  • let x = 10 in let y = 20 in x + y 컴파일 → 결과: 30 ✓
  • 섀도잉 이해: let x = 5 in let x = 10 in x → 결과: 10 ✓
  • 환경 전달로 스코프 관리 ✓
  • 스코프 에러 디버깅 (바인딩되지 않은 변수) ✓

핵심 개념:

  • SSA 형태: 각 value는 한 번만 정의된다
  • Let 바인딩 = SSA value: 불변 바인딩이 SSA를 자연스럽게 표현한다
  • 환경 = 변수 스코프: Map으로 변수 이름을 SSA value에 매핑한다
  • 환경 전달 = 스코프 중첩: 재귀 호출로 스코프를 확장한다
  • 섀도잉 ≠ 뮤테이션: 새로운 value 생성, 기존 value 변경 아님

다음 장 미리보기:

Chapter 08에서는 **제어 흐름 (if/else)**을 추가한다:

let x = if 5 < 10 then 42 else 0 in
x + x

이것은 다음을 도입한다:

  • scf.if 연산: 구조화된 제어 흐름
  • Block arguments: MLIR의 PHI 노드 대안
  • scf.yield: 분기에서 값 반환
  • SSA at control flow merges: 조건부 값을 어떻게 SSA로 표현하는가

Phase 2는 계속된다!


이제 독자는 let 바인딩과 변수를 컴파일하고, SSA 형태를 이해한다!

Chapter 08: 제어 흐름과 Block Arguments

소개

프로그래밍에서 조건부 실행은 필수다. 조건에 따라 다른 코드 경로를 실행하는 능력은 모든 실용적인 프로그램의 핵심이다.

함수형 언어에서 **if/then/else는 표현식(expression)**이다. 명령형 언어의 문(statement)이 아니라, 값을 생성하는 표현식이다:

// 함수형 스타일 - if는 값을 반환한다
let result = if condition then 42 else 0

// 명령형 스타일과 대비
int result;
if (condition) {
    result = 42;
} else {
    result = 0;
}

함수형 스타일에서 if 표현식은 값을 생성한다. 두 분기(then/else) 중 하나가 실행되고, 그 결과가 if 표현식의 값이 된다.

컴파일 도전과제: SSA 형태에서 두 분기가 어떻게 하나의 값으로 합쳐지는가?

let x = if condition then 10 else 20 in
x + x

조건이 true면 x = 10, false면 x = 20이다. 하지만 SSA 형태에서 x는 단일 SSA value여야 한다. 두 분기의 값을 어떻게 합칠까?

MLIR의 우아한 해답: Block Arguments

전통적인 SSA는 PHI 노드를 사용하지만, MLIR은 더 깔끔한 방식을 제공한다. 이 장에서 MLIR의 block arguments와 scf.if 연산을 배운다.

이 장을 마치면:

  • if/then/else 표현식을 네이티브 바이너리로 컴파일할 수 있다
  • Block arguments와 PHI 노드의 차이를 이해한다
  • MLIR의 scf.if 연산과 scf.yield 종결자를 사용할 수 있다
  • 제어 흐름 합류 지점에서 SSA 값이 어떻게 병합되는지 안다

중요: Block arguments는 MLIR의 핵심 혁신이다. PHI 노드의 복잡성을 제거하고 SSA 형태를 더 명확하게 만든다.

PHI 노드 문제

전통적인 SSA: PHI 노드

LLVM IR과 전통적인 SSA 형태는 PHI 노드를 사용하여 제어 흐름 합류 지점에서 값을 병합한다.

LLVM IR 예시:

define i32 @example(i1 %cond) {
entry:
  br i1 %cond, label %then, label %else

then:
  %a = add i32 10, 1
  br label %merge

else:
  %b = add i32 20, 1
  br label %merge

merge:
  %result = phi i32 [ %a, %then ], [ %b, %else ]
  ret i32 %result
}

동작 설명:

  1. entry 블록에서 조건 분기 (br i1 %cond)
  2. then 블록: %a = 11 계산 후 merge로 이동
  3. else 블록: %b = 21 계산 후 merge로 이동
  4. merge 블록: PHI 노드가 선택
    • %then 블록에서 왔으면 %a 사용
    • %else 블록에서 왔으면 %b 사용

PHI 노드는 “어느 블록에서 왔는가“에 따라 값을 선택한다. 표기법: phi type [ value1, pred1 ], [ value2, pred2 ]

PHI 노드의 문제점

1. 블록 시작 위치 제약

PHI 노드는 반드시 블록의 시작에 있어야 한다:

merge:
  %result = phi i32 [ %a, %then ], [ %b, %else ]  ; PHI는 여기!
  %x = add i32 %result, 1                          ; 일반 연산은 PHI 뒤
  ; PHI를 여기에 추가할 수 없다 - 순서 규칙 위반

이 제약은 코드 생성을 복잡하게 만든다. PHI 노드를 먼저 모으고, 일반 연산을 뒤에 배치해야 한다.

2. Lost Copy Problem

PHI 노드의 의미는 “블록 진입 시” 값을 선택하는 것이다. 하지만 실제 구현에서는 선행 블록의 끝에서 값을 복사한다:

then:
  %a = add i32 10, 1
  ; 실제로는 여기서 %a를 %result로 복사
  br label %merge

merge:
  %result = phi i32 [ %a, %then ], [ %b, %else ]
  ; %result는 이미 복사된 값을 가진다

이것이 lost copy problem이다:

  • PHI 노드는 “merge 블록 진입 시” 선택하는 것처럼 보인다
  • 실제 구현은 “선행 블록 종료 시” 복사한다
  • 의미론과 구현의 불일치

3. Dominance Frontier 계산

PHI 노드를 올바르게 배치하려면 dominance frontier 알고리즘이 필요하다:

// 어디에 PHI 노드를 삽입해야 할까?
// 복잡한 제어 흐름에서는 자명하지 않다
if (cond1) {
  x = 10;
} else if (cond2) {
  x = 20;
} else {
  x = 30;
}
// 여기서 x에 PHI 노드가 필요하다
// 하지만 몇 개의 선행 블록이 있는가?

Dominance frontier는 “변수가 재정의되는 모든 블록의 지배 경계“를 계산한다. 알고리즘이 복잡하고 구현이 어렵다.

4. 가독성 문제

PHI 노드는 직관적이지 않다:

%result = phi i32 [ %a, %then ], [ %b, %else ]
; 이것이 무엇을 의미하는가?
; "then에서 왔으면 %a, else에서 왔으면 %b"
; 함수 호출처럼 보이지만 실제로는 특별한 의미를 가진다

초보자가 PHI 노드를 이해하기 어렵다. 특별한 규칙(블록 시작, 순서 지정, edge 의미론)을 배워야 한다.

PHI 노드 요약

PHI 노드의 특징:

  • 제어 흐름 합류 지점에서 값을 병합한다
  • 블록 시작에 위치해야 한다 (특별한 위치 규칙)
  • Lost copy problem - 의미론과 구현의 불일치
  • Dominance frontier 계산 필요
  • 가독성이 낮다

MLIR의 해답: Block Arguments - PHI 노드를 대체하는 더 깔끔한 방식

Block Arguments in MLIR

MLIR은 PHI 노드 대신 block arguments를 사용한다.

Block Arguments 개념

핵심 아이디어: 기본 블록(basic block)도 함수처럼 파라미터를 받을 수 있다.

함수는 인자를 받는다:

let add(x: int, y: int) = x + y

MLIR에서는 블록도 인자를 받는다:

^myblock(%arg0: i32, %arg1: i32):
  %sum = arith.addi %arg0, %arg1 : i32
  ...

^myblock은 두 개의 i32 인자를 받는다. 블록으로 분기할 때 값을 전달한다:

cf.br ^myblock(%value1, %value2 : i32, i32)

이것은 함수 호출과 유사하다: myblock(value1, value2)

Block Arguments vs PHI Nodes

같은 예시를 block arguments로 작성하면:

MLIR with Block Arguments:

func.func @example(%cond: i1) -> i32 {
  cf.cond_br %cond, ^then, ^else

^then:
  %a = arith.constant 11 : i32
  cf.br ^merge(%a : i32)

^else:
  %b = arith.constant 21 : i32
  cf.br ^merge(%b : i32)

^merge(%result: i32):
  func.return %result : i32
}

차이점 분석:

측면PHI 노드 (LLVM)Block Arguments (MLIR)
값 전달phi i32 [ %a, %then ], [ %b, %else ]cf.br ^merge(%a : i32)
의미론“어느 블록에서 왔는가”“블록 호출 시 인자 전달”
위치 제약블록 시작에만 가능블록 인자로 선언 (일반 파라미터)
가독성특별한 문법, edge 리스트함수 호출과 유사

핵심 통찰력:

  • PHI 노드: “merge 블록이 선행 블록을 검사하여 값을 선택”
  • Block Arguments: “선행 블록이 merge 블록에 값을 전달” (함수 호출처럼)

Block arguments는 제어의 역전(inversion of control)이다:

  • PHI: pull 방식 (merge 블록이 값을 가져온다)
  • Block Arguments: push 방식 (선행 블록이 값을 전달한다)

Block Arguments의 장점

1. 통일된 의미론

함수 인자와 블록 인자가 같은 개념이다:

// 함수 인자
func.func @foo(%arg: i32) -> i32 {
  ...
}

// 블록 인자 (동일한 문법!)
^myblock(%arg: i32):
  ...

배울 것이 하나다. 함수를 이해하면 블록도 이해한다.

2. Lost Copy Problem 해결

Block arguments는 의미론과 구현이 일치한다:

^then:
  %a = arith.constant 11 : i32
  cf.br ^merge(%a : i32)  ; 명시적으로 %a 전달

“분기할 때 값을 전달한다“는 의미가 명확하다. Lost copy problem이 없다.

3. 위치 제약 없음

Block arguments는 블록 파라미터다. 블록 내 어디서든 일반 value처럼 사용할 수 있다:

^merge(%result: i32):
  %x = arith.constant 1 : i32
  %y = arith.addi %result, %x : i32  ; %result 사용
  func.return %y : i32

특별한 위치 규칙이 없다. 블록 파라미터는 블록 내 모든 곳에서 유효하다.

4. 가독성

코드가 더 명확하다:

cf.br ^merge(%a : i32)  ; "merge 블록을 %a와 함께 호출"
^merge(%result: i32):   ; "merge 블록은 %result 파라미터를 받는다"

함수 호출 비유가 자연스럽다. 초보자가 쉽게 이해한다.

Block Arguments 예시

복잡한 제어 흐름:

func.func @complex(%x: i32) -> i32 {
  %c0 = arith.constant 0 : i32
  %c10 = arith.constant 10 : i32

  %cond1 = arith.cmpi slt, %x, %c0 : i32
  cf.cond_br %cond1, ^negative, ^nonnegative

^negative:
  %neg = arith.constant -1 : i32
  cf.br ^merge(%neg : i32)

^nonnegative:
  %cond2 = arith.cmpi sgt, %x, %c10 : i32
  cf.cond_br %cond2, ^large, ^small

^large:
  %l = arith.constant 1 : i32
  cf.br ^merge(%l : i32)

^small:
  cf.br ^merge(%c0 : i32)

^merge(%result: i32):
  func.return %result : i32
}

동작:

  • x < 0: ^negative → ^merge(-1)
  • x > 10: ^nonnegative → ^large → ^merge(1)
  • 0 ≤ x ≤ 10: ^nonnegative → ^small → ^merge(0)

^merge 블록은 세 곳에서 호출된다. 각 선행 블록이 값을 전달한다. Block argument %result가 전달된 값을 받는다.

PHI 노드로 작성했다면:

merge:
  %result = phi i32 [ %neg, %negative ], [ %l, %large ], [ %c0, %small ]

어느 쪽이 더 명확한가? Block arguments가 push 방식으로 값을 전달하므로 추적하기 쉽다.

Block Arguments 요약

Block Arguments:

  • 기본 블록이 함수처럼 파라미터를 받는다
  • 분기 시 값을 전달: cf.br ^block(%value : type)
  • 블록 선언에서 파라미터 정의: ^block(%arg: type):

장점:

  • 함수 인자와 통일된 의미론
  • Lost copy problem 해결
  • 위치 제약 없음
  • 가독성 향상

PHI 노드 대비:

  • PHI는 pull (merge가 선택), Block Arguments는 push (선행이 전달)
  • PHI는 특별한 규칙, Block Arguments는 일반 파라미터

다음 섹션: MLIR의 고수준 제어 흐름인 scf.if 연산을 배운다!

scf.if: 고수준 제어 흐름

Block arguments를 직접 사용하는 것은 저수준(low-level) 방식이다. MLIR은 **구조화된 제어 흐름(Structured Control Flow)**을 위한 scf dialect를 제공한다.

scf Dialect 소개

scf (Structured Control Flow) dialect:

  • 고수준 제어 흐름 연산 제공
  • scf.if, scf.for, scf.while
  • 구조화된 방식으로 제어 흐름 표현
  • 나중에 저수준 cf dialect로 lowering된다

Progressive Lowering 철학:

scf.if (high-level)
  ↓ lowering pass
cf.cond_br (low-level branches)
  ↓ lowering pass
llvm.cond_br (LLVM IR)

사용자는 고수준 scf.if를 사용한다. 컴파일러가 자동으로 저수준 분기로 변환한다.

scf.if 문법

기본 형태:

%result = scf.if %condition -> (result_type) {
  // then region
  scf.yield %then_value : result_type
} else {
  // else region
  scf.yield %else_value : result_type
}

구성 요소:

  1. %condition: i1 타입의 boolean 값
  2. -> (result_type): 반환할 타입 선언
  3. then region: 조건이 true일 때 실행
  4. else region: 조건이 false일 때 실행
  5. scf.yield: 각 region의 종결자, 값을 반환

중요: 양쪽 region이 같은 타입을 yield해야 한다!

scf.if 예시

간단한 예시:

func.func @example(%cond: i1) -> i32 {
  %result = scf.if %cond -> (i32) {
    %c42 = arith.constant 42 : i32
    scf.yield %c42 : i32
  } else {
    %c0 = arith.constant 0 : i32
    scf.yield %c0 : i32
  }
  func.return %result : i32
}

동작:

  • %cond가 true: then region 실행 → %c42 yield → %result = 42
  • %cond가 false: else region 실행 → %c0 yield → %result = 0

핵심: scf.if표현식이다. 값을 반환한다 (%result). if/then/else의 함수형 의미론!

scf.yield 종결자

scf.yield의 역할:

scf.yield %value : type
  • Region의 **종결자(terminator)**다
  • Region을 종료하고 값을 반환한다
  • 함수의 return과 유사하지만, region에서 사용한다

중요 규칙:

  1. 모든 region은 종결자가 필요하다

    scf.if %cond -> (i32) {
      %c42 = arith.constant 42 : i32
      // 에러! scf.yield 누락
    }
    
  2. yield 타입이 일치해야 한다

    // 에러! then은 i32, else는 i1
    scf.if %cond -> (i32) {
      %c42 = arith.constant 42 : i32
      scf.yield %c42 : i32
    } else {
      %true = arith.constant 1 : i1
      scf.yield %true : i1  // 타입 불일치!
    }
    
  3. 선언된 결과 타입과 일치해야 한다

    // 에러! -> (i32) 선언했지만 i64 yield
    %result = scf.if %cond -> (i32) {
      %c42 = arith.constant 42 : i64
      scf.yield %c42 : i64  // 타입 불일치!
    }
    

scf.if의 장점

1. 타입 안전성

결과 타입을 미리 선언한다 (-> (i32)). 컴파일러가 양쪽 region을 검증한다.

%result = scf.if %cond -> (i32) {
  scf.yield %then_val : i32
} else {
  scf.yield %else_val : i32
}
// 컴파일러: "양쪽 모두 i32를 yield하는가?" ✓

2. 구조화된 형태

scf.if는 블록 구조가 명확하다:

  • then region
  • else region
  • 둘 다 명확한 시작과 끝

저수준 분기(cf.cond_br)는 임의의 블록으로 점프할 수 있다 (덜 구조화됨).

3. 변환 용이성

고수준 구조는 최적화와 분석이 쉽다:

  • Dead branch elimination
  • Condition hoisting
  • Pattern matching

저수준 분기는 제어 흐름 그래프(CFG) 분석이 필요하다.

scf.if에서 cf.cond_br로 Lowering

scf.if는 나중에 cf.cond_br와 block arguments로 변환된다.

High-level (scf.if):

%result = scf.if %cond -> (i32) {
  %c42 = arith.constant 42 : i32
  scf.yield %c42 : i32
} else {
  %c0 = arith.constant 0 : i32
  scf.yield %c0 : i32
}
func.return %result : i32

Lowering 후 (cf.cond_br + block arguments):

cf.cond_br %cond, ^then, ^else

^then:
  %c42 = arith.constant 42 : i32
  cf.br ^merge(%c42 : i32)

^else:
  %c0 = arith.constant 0 : i32
  cf.br ^merge(%c0 : i32)

^merge(%result: i32):
  func.return %result : i32

변환 과정:

  1. scf.if의 then region → ^then 블록
  2. scf.if의 else region → ^else 블록
  3. scf.yieldcf.br ^merge(value)
  4. scf.if의 결과 → ^merge 블록의 block argument

자동 변환: --convert-scf-to-cf pass가 이 변환을 수행한다. 사용자는 신경 쓰지 않아도 된다!

Multiple Results

scf.if는 여러 값을 반환할 수 있다:

%x, %y = scf.if %cond -> (i32, i32) {
  %a = arith.constant 10 : i32
  %b = arith.constant 20 : i32
  scf.yield %a, %b : i32, i32
} else {
  %c = arith.constant 30 : i32
  %d = arith.constant 40 : i32
  scf.yield %c, %d : i32, i32
}
// %x, %y는 (10, 20) 또는 (30, 40)

Lowering 후:

^merge(%x: i32, %y: i32):
  // %x, %y는 block arguments

Block arguments도 여러 개 가질 수 있다. scf.if의 유연성이 그대로 lowering된다.

scf.if 요약

scf.if 연산:

  • 고수준 구조화된 제어 흐름
  • 결과 타입 선언: -> (type)
  • 양쪽 region이 같은 타입 yield
  • scf.yield 종결자로 값 반환

장점:

  • 타입 안전성
  • 구조화된 형태
  • 최적화 용이성
  • Progressive lowering: scf → cf → llvm

다음: F# P/Invoke 바인딩을 추가하여 scf.ifscf.yield를 생성한다!

P/Invoke 바인딩: SCF Dialect

이제 F#에서 SCF dialect 연산을 사용할 수 있도록 P/Invoke 바인딩을 추가한다.

MLIR C API for SCF

MLIR C API는 mlir-c/Dialect/SCF.h 헤더에서 SCF dialect 지원을 제공한다.

주요 함수:

// mlir-c/Dialect/SCF.h

// scf.if operation 생성
MlirOperation mlirSCFIfCreate(
    MlirLocation location,
    MlirValue condition,
    bool hasElse
);

// scf.yield operation 생성
MlirOperation mlirSCFYieldCreate(
    MlirLocation location,
    intptr_t nResults,
    MlirValue const *results
);

// scf.if의 then/else region 접근
MlirRegion mlirSCFIfGetThenRegion(MlirOperation ifOp);
MlirRegion mlirSCFIfGetElseRegion(MlirOperation ifOp);

Note: 실제 MLIR C API에서 SCF dialect 지원은 제한적일 수 있다. 필요한 함수가 없으면 C++ shim을 작성한다 (Appendix 참조).

F# P/Invoke 바인딩

MlirBindings.fs에 추가:

namespace MlirBindings

open System
open System.Runtime.InteropServices

module MlirNative =
    // ... 기존 바인딩 ...

    // ===== SCF Dialect Operations =====

    /// scf.if operation 생성
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirSCFIfCreate(
        MlirLocation location,
        MlirValue condition,
        bool hasElse
    )

    /// scf.yield operation 생성
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirSCFYieldCreate(
        MlirLocation location,
        nativeint nResults,
        MlirValue[] results
    )

    /// scf.if의 then region 가져오기
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirSCFIfGetThenRegion(MlirOperation ifOp)

    /// scf.if의 else region 가져오기
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirRegion mlirSCFIfGetElseRegion(MlirOperation ifOp)

    /// operation의 결과 개수 설정 (scf.if 결과 타입용)
    [<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
    extern void mlirOperationSetResultTypes(
        MlirOperation operation,
        nativeint nTypes,
        MlirType[] types
    )

바인딩 설명:

  1. mlirSCFIfCreate: scf.if operation 생성

    • location: operation 위치
    • condition: i1 타입 boolean 값
    • hasElse: else region 포함 여부 (true면 then/else, false면 then만)
  2. mlirSCFYieldCreate: scf.yield operation 생성

    • nResults: yield할 값 개수
    • results: yield할 값 배열
  3. mlirSCFIfGetThenRegion/ElseRegion: region 접근

    • scf.if는 내부에 then/else region을 가진다
    • Region에 블록을 추가하고 연산을 작성한다

C API 제약과 대안

MLIR C API의 SCF dialect 지원은 완전하지 않을 수 있다. 특히:

  • scf.if 결과 타입 설정 API가 명확하지 않을 수 있다
  • Region builder API가 제한적일 수 있다

대안 1: Operation State Builder 사용

MLIR C API의 일반 operation builder를 사용:

let createScfIf (builder: OpBuilder) (condition: MlirValue) (resultTypes: MlirType[]) (location: MlirLocation) =
    let opName = MlirHelpers.fromString("scf.if")
    let state = MlirNative.mlirOperationStateGet(opName, location)

    // operand 추가 (condition)
    MlirNative.mlirOperationStateAddOperands(state, 1n, [| condition |])

    // 결과 타입 추가
    MlirNative.mlirOperationStateAddResults(state, nativeint resultTypes.Length, resultTypes)

    // region 추가 (then, else)
    MlirNative.mlirOperationStateAddOwnedRegions(state, 2n, [| thenRegion; elseRegion |])

    // operation 생성
    MlirNative.mlirOperationCreate(state)

대안 2: C++ Shim 작성

Appendix (Chapter 01-03에서 다룬 C++ dialect wrapper 패턴)에 따라 C++ shim을 작성:

// mlir_scf_wrapper.cpp
extern "C" {

MlirOperation mlirCreateSCFIf(
    MlirLocation location,
    MlirValue condition,
    MlirType* resultTypes,
    intptr_t numResults,
    bool hasElse
) {
    // C++ MLIR API 사용
    mlir::OpBuilder builder(...);
    auto ifOp = builder.create<mlir::scf::IfOp>(
        unwrap(location),
        llvm::ArrayRef<mlir::Type>(...),
        unwrap(condition),
        hasElse
    );
    return wrap(ifOp.getOperation());
}

} // extern "C"

이 shim을 컴파일하여 F#에서 호출한다.

권장 방안: 먼저 C API를 시도하고, 부족하면 C++ shim을 작성한다. Chapter 01 Appendix가 이미 패턴을 확립했다.

OpBuilder 헬퍼 메서드

고수준 래퍼를 OpBuilder 클래스에 추가한다:

MlirWrapper.fs에 추가:

type OpBuilder(context: Context) =
    // ... 기존 메서드 ...

    /// scf.if operation 생성
    member this.CreateScfIf(condition: MlirValue, resultTypes: MlirType[], location: MlirLocation) : MlirOperation =
        let ifOp = MlirNative.mlirSCFIfCreate(location, condition, true)

        // 결과 타입 설정 (C API 함수 사용)
        MlirNative.mlirOperationSetResultTypes(ifOp, nativeint resultTypes.Length, resultTypes)

        ifOp

    /// scf.if의 then region에 블록 추가
    member this.GetThenBlock(ifOp: MlirOperation) : MlirBlock =
        let thenRegion = MlirNative.mlirSCFIfGetThenRegion(ifOp)
        let block = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
        MlirNative.mlirRegionAppendOwnedBlock(thenRegion, block)
        block

    /// scf.if의 else region에 블록 추가
    member this.GetElseBlock(ifOp: MlirOperation) : MlirBlock =
        let elseRegion = MlirNative.mlirSCFIfGetElseRegion(ifOp)
        let block = MlirNative.mlirBlockCreate(0n, nativeint 0, nativeint 0)
        MlirNative.mlirRegionAppendOwnedBlock(elseRegion, block)
        block

    /// scf.yield operation 생성
    member this.CreateScfYield(results: MlirValue[], location: MlirLocation) : MlirOperation =
        MlirNative.mlirSCFYieldCreate(location, nativeint results.Length, results)

사용 예시:

// scf.if operation 생성
let i32Type = builder.I32Type()
let ifOp = builder.CreateScfIf(condition, [| i32Type |], location)

// then region 작성
let thenBlock = builder.GetThenBlock(ifOp)
// ... thenBlock에 연산 추가 ...
let thenYield = builder.CreateScfYield([| thenValue |], location)
MlirNative.mlirBlockAppendOwnedOperation(thenBlock, thenYield)

// else region 작성
let elseBlock = builder.GetElseBlock(ifOp)
// ... elseBlock에 연산 추가 ...
let elseYield = builder.CreateScfYield([| elseValue |], location)
MlirNative.mlirBlockAppendOwnedOperation(elseBlock, elseYield)

Dialect 로딩

SCF dialect를 사용하려면 context에 로드해야 한다:

let ctx = new Context()
ctx.LoadDialect("arith")
ctx.LoadDialect("func")
ctx.LoadDialect("scf")  // SCF dialect 로드!

이것으로 scf.ifscf.yield 연산을 사용할 준비가 완료되었다!

P/Invoke 바인딩 요약

추가한 바인딩:

  • mlirSCFIfCreate: scf.if operation 생성
  • mlirSCFYieldCreate: scf.yield operation 생성
  • mlirSCFIfGetThenRegion/ElseRegion: region 접근

OpBuilder 헬퍼:

  • CreateScfIf: scf.if 생성 + 결과 타입 설정
  • GetThenBlock/GetElseBlock: region에 블록 추가
  • CreateScfYield: scf.yield 생성

C API 제약:

  • C API가 불완전하면 C++ shim 작성 (Appendix 패턴 따름)
  • Operation State Builder를 일반 대안으로 사용

다음 섹션: AST에 If 케이스를 추가하고, 코드 생성을 구현한다!

AST 확장: If 표현식과 Boolean 리터럴

이제 AST에 if 표현식과 boolean 리터럴을 추가한다.

Expr 타입 확장

Ast.fs 수정:

namespace FunLangCompiler

/// 이진 연산자 (Chapter 06)
type Operator =
    | Add
    | Subtract
    | Multiply
    | Divide

/// 비교 연산자 (Chapter 06)
type CompareOp =
    | LessThan
    | GreaterThan
    | LessEqual
    | GreaterEqual
    | Equal
    | NotEqual

/// 단항 연산자 (Chapter 06)
type UnaryOp =
    | Negate

/// FunLang 표현식 AST
type Expr =
    | IntLiteral of int
    | BinaryOp of Operator * Expr * Expr
    | UnaryOp of UnaryOp * Expr
    | Comparison of CompareOp * Expr * Expr
    | Let of name: string * binding: Expr * body: Expr
    | Var of name: string
    // NEW: If 표현식과 Boolean 리터럴
    | If of condition: Expr * thenBranch: Expr * elseBranch: Expr
    | Bool of bool

/// 최상위 프로그램
type Program =
    { expr: Expr }

새로운 케이스 설명:

If of condition * thenBranch * elseBranch

| If of condition: Expr * thenBranch: Expr * elseBranch: Expr

의미: if {condition} then {thenBranch} else {elseBranch}

필드:

  • condition: 조건 표현식 (i1 boolean 값을 생성해야 함)
  • thenBranch: 조건이 true일 때 실행하는 표현식
  • elseBranch: 조건이 false일 때 실행하는 표현식

타입 제약:

  • condition은 i1 타입을 생성해야 한다
  • thenBranchelseBranch같은 타입을 생성해야 한다

예시:

// FunLang: if 5 < 10 then 42 else 0
If(
  Comparison(LessThan, IntLiteral 5, IntLiteral 10),
  IntLiteral 42,
  IntLiteral 0
)

Bool of bool

| Bool of bool

의미: Boolean 리터럴 - true 또는 false

필드:

  • bool: F# boolean 값 (true 또는 false)

예시:

// FunLang: if true then 1 else 0
If(
  Bool true,
  IntLiteral 1,
  IntLiteral 0
)

MLIR로 컴파일: Bool truearith.constant 1 : i1, Bool falsearith.constant 0 : i1

AST 예시

간단한 if:

// FunLang: if true then 42 else 0
If(Bool true, IntLiteral 42, IntLiteral 0)

비교 조건:

// FunLang: if 5 < 10 then 1 else 0
If(
  Comparison(LessThan, IntLiteral 5, IntLiteral 10),
  IntLiteral 1,
  IntLiteral 0
)

let 바인딩과 결합:

// FunLang: let x = 5 in if x > 0 then x * 2 else 0
Let("x",
  IntLiteral 5,
  If(
    Comparison(GreaterThan, Var "x", IntLiteral 0),
    BinaryOp(Multiply, Var "x", IntLiteral 2),
    IntLiteral 0
  )
)

Boolean 표현식

Boolean 값은 MLIR에서 i1 타입 (1-bit integer)으로 표현된다.

Boolean 타입: i1

MLIR은 boolean을 위한 전용 타입이 없다. 대신 1-bit integer (i1)를 사용한다:

%true = arith.constant 1 : i1    // Boolean true
%false = arith.constant 0 : i1   // Boolean false

i1의 값:

  • 1: true
  • 0: false

Boolean 리터럴 컴파일

Bool 케이스를 i1 상수로 컴파일한다:

| Bool(value) ->
    let i1Type = builder.Context.GetIntegerType(1)  // 1-bit integer
    let intValue = if value then 1L else 0L
    let attr = builder.Context.GetIntegerAttr(i1Type, intValue)
    let constOp = builder.CreateConstant(attr, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, constOp)
    builder.GetResult(constOp, 0)

생성된 MLIR IR:

// Bool true
%true = arith.constant 1 : i1

// Bool false
%false = arith.constant 0 : i1

비교 연산은 이미 i1을 반환한다

Chapter 06에서 구현한 비교 연산 (arith.cmpi)은 i1을 반환한다:

%c5 = arith.constant 5 : i32
%c10 = arith.constant 10 : i32
%cond = arith.cmpi slt, %c5, %c10 : i32  // 결과는 i1

중요: if 조건으로 비교 연산을 사용할 때, i1 → i32 확장(arith.extui)을 제거해야 한다!

Chapter 06에서는 main 함수 반환을 위해 i1을 i32로 확장했다:

// Chapter 06 코드 (비교 결과를 i32로 확장)
| Comparison(compareOp, lhs, rhs) ->
    let lhsVal = compileExpr builder block location lhs env
    let rhsVal = compileExpr builder block location rhs env
    let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
    let cmpVal = builder.GetResult(cmpOp, 0)  // i1 값
    // i1 -> i32 확장
    let i32Type = builder.I32Type()
    let extOp = builder.CreateArithExtUI(cmpVal, i32Type, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, extOp)
    builder.GetResult(extOp, 0)  // i32 반환

문제: if 조건은 i1이 필요한데, 위 코드는 i32를 반환한다!

해결 방안: 컨텍스트에 따라 확장 여부를 결정한다:

  • if 조건: i1 그대로 사용
  • main 함수 반환: i32로 확장

간단한 접근: Comparison 케이스가 i1을 반환하도록 하고, main 함수에서만 확장한다.

수정된 Comparison 케이스:

| Comparison(compareOp, lhs, rhs) ->
    let lhsVal = compileExpr builder block location lhs env
    let rhsVal = compileExpr builder block location rhs env
    let cmpOp = builder.CreateArithCompare(compareOp, lhsVal, rhsVal, location)
    MlirNative.mlirBlockAppendOwnedOperation(block, cmpOp)
    builder.GetResult(cmpOp, 0)  // i1 반환 (확장 안 함)

main 함수에서 확장:

let resultValue = compileExpr builder entryBlock loc program.expr env

// 결과가 i1이면 i32로 확장 (main 함수 반환용)
let resultType = MlirNative.mlirValueGetType(resultValue)
let finalResult =
    if MlirNative.mlirTypeIsI1(resultType) then
        let i32Type = builder.I32Type()
        let extOp = builder.CreateArithExtUI(resultValue, i32Type, loc)
        MlirNative.mlirBlockAppendOwnedOperation(entryBlock, extOp)
        builder.GetResult(extOp, 0)
    else
        resultValue

Boolean 연산 (선택 사항)

Boolean 값에 논리 연산을 적용할 수 있다:

AND:

%a = arith.constant 1 : i1
%b = arith.constant 0 : i1
%result = arith.andi %a, %b : i1  // 결과: 0 (false)

OR:

%result = arith.ori %a, %b : i1  // 결과: 1 (true)

NOT (XOR with 1):

%c1 = arith.constant 1 : i1
%result = arith.xori %a, %c1 : i1  // a의 반대

AST 추가 (나중에):

Phase 2에서는 boolean 연산을 추가하지 않는다. if/then/else만으로 충분하다. 필요하면 나중에 추가한다.

If/Then/Else 코드 생성

이제 If 케이스를 scf.if로 컴파일한다.

If 케이스 구현

실제 구현에서는 CreateOperation과 Region 생성 패턴을 사용한다:

CodeGen.fs에 추가:

| If(cond, thenExpr, elseExpr, _) ->
    // 1. Compile condition (must be i1 type)
    let condVal = compileExpr ctx cond

    // 2. Determine result type (assume i32 for now - FunLang is well-typed)
    let resultType = i32Type

    // 3. Create THEN region
    let thenRegion = builder.CreateRegion()
    let thenBlock = builder.CreateBlock([||], ctx.Location)
    builder.AppendBlockToRegion(thenRegion, thenBlock)

    // Compile then expression in new block context
    let thenCtx = { ctx with Block = thenBlock }
    let thenVal = compileExpr thenCtx thenExpr

    // Add scf.yield terminator to then block
    let thenYieldOp = builder.CreateOperation(
        "scf.yield", ctx.Location,
        [||], [| thenVal |], [||], [||])
    builder.AppendOperationToBlock(thenBlock, thenYieldOp)

    // 4. Create ELSE region
    let elseRegion = builder.CreateRegion()
    let elseBlock = builder.CreateBlock([||], ctx.Location)
    builder.AppendBlockToRegion(elseRegion, elseBlock)

    // Compile else expression in new block context
    let elseCtx = { ctx with Block = elseBlock }
    let elseVal = compileExpr elseCtx elseExpr

    // Add scf.yield terminator to else block
    let elseYieldOp = builder.CreateOperation(
        "scf.yield", ctx.Location,
        [||], [| elseVal |], [||], [||])
    builder.AppendOperationToBlock(elseBlock, elseYieldOp)

    // 5. Create scf.if operation
    let ifOp = builder.CreateOperation(
        "scf.if", ctx.Location,
        [| resultType |],              // result types
        [| condVal |],                 // operands (condition only)
        [||],                          // no attributes
        [| thenRegion; elseRegion |])  // regions: then, else

    builder.AppendOperationToBlock(ctx.Block, ifOp)
    builder.GetResult(ifOp, 0)

핵심 패턴:

  • Region 생성: builder.CreateRegion()builder.CreateBlock([||], loc)builder.AppendBlockToRegion
  • Context 전환: 각 region에서 { ctx with Block = thenBlock }로 새 컨텍스트 생성
  • scf.yield 종결자: 반드시 각 region의 끝에 추가해야 함
  • Region 순서: [| thenRegion; elseRegion |] - then이 첫 번째, else가 두 번째

동작 설명:

  1. 조건 컴파일: condition 표현식을 컴파일하여 i1 값을 얻는다
  2. 결과 타입: if 표현식의 결과 타입 (여기서는 i32로 가정)
  3. scf.if 생성: CreateScfIf로 operation 생성
  4. Then region: thenBranch 컴파일 → scf.yield로 값 반환
  5. Else region: elseBranch 컴파일 → scf.yield로 값 반환
  6. Operation 추가: scf.if를 부모 블록에 추가
  7. 결과 사용: scf.if의 결과 (SSA value)를 반환

핵심: 각 region에서 compileExpr를 호출할 때 해당 region의 블록을 전달한다. 이렇게 하면 연산이 올바른 region에 추가된다.

예시: if true then 42 else 0

AST:

If(Bool true, IntLiteral 42, IntLiteral 0)

컴파일 과정:

  1. Bool true 컴파일: %true = arith.constant 1 : i1
  2. scf.if 생성
  3. Then region:
    • IntLiteral 42 컴파일: %c42 = arith.constant 42 : i32
    • scf.yield %c42
  4. Else region:
    • IntLiteral 0 컴파일: %c0 = arith.constant 0 : i32
    • scf.yield %c0
  5. scf.if 결과: %result

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %true = arith.constant 1 : i1
    %result = scf.if %true -> (i32) {
      %c42 = arith.constant 42 : i32
      scf.yield %c42 : i32
    } else {
      %c0 = arith.constant 0 : i32
      scf.yield %c0 : i32
    }
    func.return %result : i32
  }
}

실행:

$ ./program
$ echo $?
42

조건이 true이므로 42를 반환한다!

예시: if 5 < 10 then 1 else 0

AST:

If(
  Comparison(LessThan, IntLiteral 5, IntLiteral 10),
  IntLiteral 1,
  IntLiteral 0
)

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32
    %cond = arith.cmpi slt, %c5, %c10 : i32  // i1 결과
    %result = scf.if %cond -> (i32) {
      %c1 = arith.constant 1 : i32
      scf.yield %c1 : i32
    } else {
      %c0 = arith.constant 0 : i32
      scf.yield %c0 : i32
    }
    func.return %result : i32
  }
}

실행:

$ ./program
$ echo $?
1

5 < 10이 true이므로 1을 반환한다!

Lowering Pass 업데이트

SCF dialect를 사용하므로 lowering pass에 --convert-scf-to-cf를 추가해야 한다.

Pass Pipeline

실제 구현에서는 PassManager.AddPipeline을 사용하여 단일 문자열로 pass pipeline을 지정한다:

CodeGen.fs의 compileAndRun 함수:

/// Compile, lower to LLVM, and JIT execute an expression
let compileAndRun (source: string) : int32 =
    use ctx = new Context()
    ctx.LoadStandardDialects()
    MlirNative.mlirRegisterAllLLVMTranslations(ctx.Handle)

    let expr = parse source "<string>"
    use mlirMod = compileToFunction ctx "main" expr

    // Lower to LLVM
    // Conversion order:
    // 1. convert-scf-to-cf - Convert scf.if to cf.br/cf.cond_br
    // 2. convert-arith-to-llvm - Convert arith ops to LLVM dialect
    // 3. convert-cf-to-llvm - Convert cf branches to LLVM dialect
    // 4. convert-func-to-llvm - Convert func dialect to LLVM dialect
    // 5. reconcile-unrealized-casts - Clean up any unrealized casts
    use pm = new PassManager(ctx)
    pm.AddPipeline("builtin.module(convert-scf-to-cf,convert-arith-to-llvm,convert-cf-to-llvm,convert-func-to-llvm,reconcile-unrealized-casts)")
    if not (pm.Run(mlirMod)) then
        failwith "Pass pipeline failed"

    // JIT execute
    use ee = new ExecutionEngine(mlirMod, 0)
    // ... JIT 실행 코드 ...

Pass 순서가 중요하다:

  1. convert-scf-to-cf: scf.ifcf.cond_br + block arguments
  2. convert-arith-to-llvm: arith.constant, arith.addi → LLVM dialect
  3. convert-cf-to-llvm: cf.br, cf.cond_br → LLVM dialect branches
  4. convert-func-to-llvm: func.func, func.return → LLVM dialect
  5. reconcile-unrealized-casts: 중간 cast 연산 정리

주의: cf dialect도 로드해야 한다. LoadStandardDialects()에 포함되어 있다.

MlirBindings.fs에 Pass 추가

MlirBindings.fs에 추가:

/// SCF to CF 변환 pass 생성
[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirPass mlirCreateConversionConvertSCFToCFPass()

Note: 함수 이름은 MLIR C API 버전에 따라 다를 수 있다. mlir-c/Conversion/Passes.h 헤더를 확인한다.

Lowering 후 MLIR IR

scf.if lowering 전:

func.func @main() -> i32 {
  %c5 = arith.constant 5 : i32
  %c10 = arith.constant 10 : i32
  %cond = arith.cmpi slt, %c5, %c10 : i32
  %result = scf.if %cond -> (i32) {
    %c1 = arith.constant 1 : i32
    scf.yield %c1 : i32
  } else {
    %c0 = arith.constant 0 : i32
    scf.yield %c0 : i32
  }
  func.return %result : i32
}

scf.if lowering 후 (cf dialect):

func.func @main() -> i32 {
  %c5 = arith.constant 5 : i32
  %c10 = arith.constant 10 : i32
  %cond = arith.cmpi slt, %c5, %c10 : i32
  cf.cond_br %cond, ^then, ^else

^then:
  %c1 = arith.constant 1 : i32
  cf.br ^merge(%c1 : i32)

^else:
  %c0 = arith.constant 0 : i32
  cf.br ^merge(%c0 : i32)

^merge(%result: i32):
  func.return %result : i32
}

핵심:

  • scf.ifcf.cond_br + 블록
  • scf.yieldcf.br ^merge(value)
  • Block argument %result가 PHI 역할

Let 바인딩과 If 결합

Let 바인딩과 if 표현식을 결합한 예시를 보자.

FunLang 소스:

let x = 5 in
if x > 0 then x * 2 else 0

AST:

Let("x",
  IntLiteral 5,
  If(
    Comparison(GreaterThan, Var "x", IntLiteral 0),
    BinaryOp(Multiply, Var "x", IntLiteral 2),
    IntLiteral 0
  )
)

컴파일 과정:

  1. Let("x", IntLiteral 5, ...)

    • IntLiteral 5 컴파일: %c5 = arith.constant 5 : i32
    • env' = env.Add("x", %c5)
    • Body 컴파일 (env’ 사용)
  2. If(...) (env’에서)

    • Condition: Comparison(GreaterThan, Var "x", IntLiteral 0)
      • Var "x": env’에서 조회 → %c5
      • IntLiteral 0: %c0 = arith.constant 0 : i32
      • %cond = arith.cmpi sgt, %c5, %c0 : i32
    • Then: BinaryOp(Multiply, Var "x", IntLiteral 2)
      • Var "x": env’에서 조회 → %c5
      • IntLiteral 2: %c2 = arith.constant 2 : i32
      • %then_val = arith.muli %c5, %c2 : i32
    • Else: IntLiteral 0
      • %else_val = arith.constant 0 : i32

생성된 MLIR IR:

module {
  func.func @main() -> i32 {
    %c5 = arith.constant 5 : i32          // let x = 5
    %c0 = arith.constant 0 : i32
    %cond = arith.cmpi sgt, %c5, %c0 : i32  // x > 0
    %result = scf.if %cond -> (i32) {
      %c2 = arith.constant 2 : i32
      %then_val = arith.muli %c5, %c2 : i32  // x * 2
      scf.yield %then_val : i32
    } else {
      %else_val = arith.constant 0 : i32
      scf.yield %else_val : i32
    }
    func.return %result : i32
  }
}

실행:

$ ./program
$ echo $?
10

x = 5, x > 0이 true, x * 2 = 10!

중첩된 If

if 안에 if를 넣을 수도 있다:

// FunLang: if x > 0 then (if x < 10 then 1 else 2) else 0
If(
  Comparison(GreaterThan, Var "x", IntLiteral 0),
  If(
    Comparison(LessThan, Var "x", IntLiteral 10),
    IntLiteral 1,
    IntLiteral 2
  ),
  IntLiteral 0
)

생성된 MLIR IR:

%outer_cond = arith.cmpi sgt, %x, %c0 : i32
%result = scf.if %outer_cond -> (i32) {
  %inner_cond = arith.cmpi slt, %x, %c10 : i32
  %inner_result = scf.if %inner_cond -> (i32) {
    %c1 = arith.constant 1 : i32
    scf.yield %c1 : i32
  } else {
    %c2 = arith.constant 2 : i32
    scf.yield %c2 : i32
  }
  scf.yield %inner_result : i32
} else {
  %c0 = arith.constant 0 : i32
  scf.yield %c0 : i32
}

중첩된 scf.if가 올바르게 생성된다!

공통 에러

에러 1: 조건이 i32인데 i1이 필요

증상:

MLIR verification failed:
'scf.if' op operand #0 must be 1-bit signless integer, but got 'i32'

원인:

if 조건에 i32 값을 전달했다.

해결:

조건은 반드시 i1 타입이어야 한다:

  • Boolean 리터럴: Bool truearith.constant 1 : i1
  • 비교 연산: arith.cmpi → i1 결과
  • i32를 i1로 변환하지 말고, 비교 연산을 사용한다
// WRONG: i32를 조건으로 사용
let x = IntLiteral 5
If(x, ..., ...)  // 에러! x는 i32

// CORRECT: 비교 연산 사용
If(Comparison(GreaterThan, x, IntLiteral 0), ..., ...)

에러 2: scf.yield 타입 불일치

증상:

MLIR verification failed:
'scf.yield' op types mismatch between then and else regions

원인:

then region과 else region이 다른 타입을 yield했다.

해결:

양쪽 region이 같은 타입을 yield해야 한다:

// WRONG: then은 i32, else는 i1
If(cond,
  IntLiteral 42,        // i32
  Bool true)            // i1 - 타입 불일치!

// CORRECT: 둘 다 i32
If(cond,
  IntLiteral 42,        // i32
  IntLiteral 0)         // i32

에러 3: scf.yield 누락

증상:

MLIR verification failed:
Region does not have a terminator

원인:

then 또는 else region에 scf.yield를 추가하지 않았다.

해결:

모든 region은 종결자가 필요하다. 코드 생성 시 항상 scf.yield를 추가한다:

// 올바른 코드 생성 패턴
let thenBlock = builder.GetThenBlock(ifOp)
let thenVal = compileExpr builder thenBlock location thenExpr env
let thenYield = builder.CreateScfYield([| thenVal |], location)
MlirNative.mlirBlockAppendOwnedOperation(thenBlock, thenYield)  // 필수!

에러 4: –convert-scf-to-cf pass 누락

증상:

Failed to translate MLIR to LLVM IR:
Unhandled operation: scf.if

원인:

Lowering pass에서 SCF → CF 변환을 실행하지 않았다.

해결:

Pass manager에 --convert-scf-to-cf를 추가한다:

let scfToCfPass = MlirNative.mlirCreateConversionConvertSCFToCFPass()
MlirNative.mlirPassManagerAddOwnedPass(pm, scfToCfPass)

Pass 순서: SCF → CF → Arith → Func → Reconcile

구현 시 주의사항 (Common Pitfalls)

실제 구현에서 발견된 중요한 주의사항들:

1. Region 내부의 Block Context

각 region에서 표현식을 컴파일할 때 해당 region의 블록을 컨텍스트에 전달해야 한다:

// CORRECT: region별로 새 컨텍스트 생성
let thenCtx = { ctx with Block = thenBlock }
let thenVal = compileExpr thenCtx thenExpr

// WRONG: 부모 블록 사용하면 연산이 잘못된 위치에 생성됨
let thenVal = compileExpr ctx thenExpr  // ctx.Block은 부모 블록!

2. scf.yield 종결자 필수

모든 region은 반드시 종결자로 끝나야 한다. scf.yield가 없으면 MLIR 검증이 실패한다:

// 반드시 yield 추가
let thenYieldOp = builder.CreateOperation(
    "scf.yield", ctx.Location,
    [||], [| thenVal |], [||], [||])
builder.AppendOperationToBlock(thenBlock, thenYieldOp)

3. if 결과 타입 고정

현재 구현에서는 if 결과 타입을 i32로 고정했다. FunLang은 well-typed 언어이므로 양쪽 branch가 같은 타입을 반환한다고 가정한다:

// 결과 타입 고정 (실제로는 타입 추론 필요할 수 있음)
let resultType = i32Type

4. Pass Pipeline 순서

scf → cf → llvm 순서로 lowering해야 한다. scf.if를 직접 LLVM으로 변환할 수 없다:

// CORRECT: scf.if → cf.cond_br → llvm branches
"builtin.module(convert-scf-to-cf,convert-arith-to-llvm,convert-cf-to-llvm,convert-func-to-llvm,reconcile-unrealized-casts)"

5. cf dialect 로드

scf-to-cf 변환을 사용하면 cf dialect가 필요하다. LoadStandardDialects()에 포함시켜야 한다.

장 요약

이 장에서 다음을 성취했다:

  1. PHI 노드 문제 이해: 위치 제약, lost copy problem, dominance frontier 계산
  2. Block Arguments 학습: MLIR의 우아한 대안, 함수 인자와 통일된 의미론
  3. scf.if 연산 사용: 고수준 구조화된 제어 흐름, scf.yield 종결자
  4. Region 생성 패턴: CreateRegion → CreateBlock → AppendBlockToRegion
  5. AST 확장: If 표현식과 Bool 리터럴 추가
  6. Boolean 타입: i1 (1-bit integer), true = 1, false = 0
  7. 코드 생성 구현: If 케이스를 scf.if + regions로 컴파일
  8. Lowering pass 업데이트: scf→cf→llvm 순서 pipeline
  9. 완전한 예제: if/then/else와 let 바인딩 결합

독자가 할 수 있는 것:

  • if true then 42 else 0 컴파일 → 네이티브 바이너리 → 결과: 42 ✓
  • if 5 < 10 then 1 else 0 컴파일 → 결과: 1 ✓
  • let x = 5 in if x > 0 then x * 2 else 0 컴파일 → 결과: 10 ✓
  • Block arguments vs PHI 노드 차이 이해 ✓
  • scf.if lowering 과정 이해 ✓
  • Boolean 타입 (i1) 사용 ✓
  • 타입 불일치 에러 디버깅 ✓

핵심 개념:

  • Block Arguments > PHI 노드: 깔끔한 의미론, push vs pull
  • scf.if = 표현식: 값을 반환, 함수형 의미론
  • scf.yield = 종결자: Region에서 값 반환, return과 유사
  • i1 타입 = Boolean: 1 = true, 0 = false
  • Progressive Lowering: scf → cf → llvm

다음 장 미리보기:

Chapter 09에서는 메모리 관리를 다룬다:

  • Stack vs Heap 할당
  • memref.alloca (stack allocation)
  • memref.alloc (heap allocation)
  • Boehm GC 통합 (garbage collection)

Phase 2의 마지막 장이다. Phase 3에서는 함수와 클로저를 구현할 것이다!


이제 독자는 if/then/else 제어 흐름을 컴파일하고, block arguments와 scf.if를 이해한다!

Chapter 09: 메모리 관리와 Boehm GC

소개

지금까지 FunLang 컴파일러는 모든 값을 SSA 레지스터로 처리했다. 정수, boolean, 심지어 let 바인딩도 메모리 연산 없이 SSA value로만 표현했다.

func.func @main() -> i32 {
  %c5 = arith.constant 5 : i32       // SSA value (레지스터)
  %c10 = arith.constant 10 : i32     // SSA value (레지스터)
  %sum = arith.addi %c5, %c10 : i32  // SSA value (레지스터)
  func.return %sum : i32
}

이 접근 방식은 단순한 표현식에서는 완벽하게 작동한다. 하지만 앞으로 구현할 기능은 메모리 할당이 필요하다:

  • 클로저(Closures): 외부 스코프의 변수를 캡처하는 함수
  • 데이터 구조: 리스트, 튜플, 문자열 등 동적 크기 데이터
  • 함수에서 반환되는 값: 함수 스코프를 벗어나 생존하는 값

이 장에서는 메모리 관리 전략을 학습한다:

  • Stack vs Heap 할당 전략
  • MLIR의 memref dialect
  • Boehm GC 통합 (자동 메모리 회수)

중요한 관점: Phase 2 프로그램은 아직 메모리 할당이 필요하지 않다. 하지만 Phase 3 (함수와 클로저)에 들어가기 전에 GC 인프라를 미리 준비한다. “필요하기 전에 왜 GC가 필요한지“를 이해하는 것이 목표다.

이 장을 마치면:

  • Stack과 heap의 차이를 이해한다
  • 어떤 값이 stack에, 어떤 값이 heap에 가는지 안다
  • MLIR의 memref 연산을 사용할 수 있다
  • Boehm GC를 빌드하고 링킹할 수 있다
  • 왜 클로저에 GC가 필요한지 이해한다

Preview: Phase 3에서 클로저를 구현할 때, 이 장에서 준비한 GC가 바로 사용된다!

메모리 관리 전략

프로그램이 실행될 때 두 종류의 메모리 영역을 사용한다: StackHeap.

Stack 할당

**Stack (스택)**은 함수 호출 시 자동으로 관리되는 메모리 영역이다.

Stack에 저장되는 것:

  • 함수 파라미터
  • 지역 변수
  • 임시 계산 값
  • 함수 반환 주소

Stack의 특징:

  1. 자동 할당 및 해제

    int foo() {
        int x = 5;    // Stack에 할당
        int y = 10;   // Stack에 할당
        return x + y;
        // 함수 종료 시 x, y 자동 해제
    }
    
  2. 빠른 할당

    • Stack pointer만 이동 (포인터 연산 한 번)
    • 별도의 할당자(allocator) 불필요
  3. LIFO (Last-In-First-Out) 구조

    foo() 호출:
    ┌──────────────┐
    │ foo의 지역변수│ ← stack top
    ├──────────────┤
    │ main의 지역변수│
    ├──────────────┤
    │    ...       │
    └──────────────┘
    
    foo() 종료:
    ┌──────────────┐
    │ main의 지역변수│ ← stack top (foo의 프레임 제거됨)
    ├──────────────┤
    │    ...       │
    └──────────────┘
    
  4. 크기 제한

    • Stack 크기는 고정 (보통 1-8MB)
    • 너무 많은 지역 변수나 깊은 재귀는 stack overflow 유발

언제 stack을 사용하는가:

  • 함수 내부에서만 사용되는 값
  • 크기가 컴파일 타임에 결정되는 값
  • 함수 종료 시 사라져도 되는 값

Heap 할당

**Heap (힙)**은 명시적으로 할당하고 해제하는 메모리 영역이다.

Heap에 저장되는 것:

  • 함수 스코프를 벗어나 생존하는 값
  • 동적 크기 데이터 (런타임에 크기 결정)
  • 여러 함수/클로저가 공유하는 값

Heap의 특징:

  1. 명시적 할당

    void* ptr = malloc(100);  // Heap에 100바이트 할당
    // ... ptr 사용 ...
    free(ptr);                // 명시적 해제 필요
    
  2. 느린 할당

    • 할당자가 적절한 메모리 블록을 찾아야 함
    • Fragmentation (단편화) 관리 필요
  3. 유연한 생명주기

    int* create_value() {
        int* p = malloc(sizeof(int));
        *p = 42;
        return p;  // 함수 종료 후에도 값이 살아있다
    }
    
  4. 크기 제한이 크다

    • Heap은 시스템 전체 가용 메모리를 사용할 수 있다
    • Stack보다 훨씬 큰 데이터 구조 가능

언제 heap을 사용하는가:

  • 함수에서 반환되는 값
  • 동적 크기 데이터 (리스트 길이를 런타임에 결정)
  • 여러 클로저가 공유하는 환경

FunLang의 메모리 전략

Phase 2 (현재):

  • 모든 값이 SSA 레지스터
  • 정수와 boolean만 존재
  • 메모리 할당이 전혀 없다
// Phase 2: 모든 것이 SSA value
func.func @main() -> i32 {
  %c5 = arith.constant 5 : i32      // 레지스터
  %c10 = arith.constant 10 : i32    // 레지스터
  %sum = arith.addi %c5, %c10 : i32 // 레지스터
  func.return %sum : i32
}

Phase 3 (클로저):

  • 클로저가 환경을 캡처
  • 캡처된 환경은 heap에 할당 (함수를 벗어나 생존)
  • GC가 자동으로 회수
// Phase 3 예시 (preview):
// let x = 5 in (fun y -> x + y)  // 클로저가 x를 캡처
func.func @main() -> !closure {
  %c5 = arith.constant 5 : i32

  // 클로저 환경을 heap에 할당
  %env_size = arith.constant 8 : i64  // x를 저장할 공간
  %env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

  // x를 환경에 저장
  llvm.store %c5, %env : !llvm.ptr

  // 클로저 생성 (함수 포인터 + 환경 포인터)
  %closure = funlang.create_closure @lambda, %env
  func.return %closure : !closure
}

Phase 6 (데이터 구조):

  • 리스트, 튜플, 문자열
  • 모두 heap에 할당
  • GC가 관리

Stack vs Heap 다이어그램

함수 호출 스택                      Heap (GC 관리)
┌─────────────────────┐            ┌─────────────────────┐
│ main() 프레임       │            │ 클로저 환경 #1      │
│ - return addr       │     ┌─────>│ - x = 5            │
│ - local: result     │─────┘      │ - y = 10           │
│ - temp: %c5, %c10   │            ├─────────────────────┤
├─────────────────────┤            │ 리스트 노드         │
│ foo() 프레임        │            │ - head = 1         │
│ - return addr       │            │ - tail = ...       │
│ - param: x          │            └─────────────────────┘
│ - local: y          │                     ↑
└─────────────────────┘                     │
   (함수 종료 시 자동 해제)           (GC가 회수)

핵심 차이:

  • Stack: 함수 스코프에 묶임, 자동 해제, 빠름
  • Heap: 스코프 독립, 명시적 할당/해제, 유연함

왜 FunLang은 Heap이 필요한가?

클로저가 핵심 이유다:

// FunLang 예시
let makeAdder = fun x ->
    fun y -> x + y

let add5 = makeAdder 5   // 클로저: x=5를 캡처
let add10 = makeAdder 10 // 클로저: x=10을 캡처

add5 3    // 결과: 8  (x=5 사용)
add10 3   // 결과: 13 (x=10 사용)

문제:

  • makeAdder 5가 반환될 때, x=5는 어디에 저장되는가?
  • makeAdder 함수는 이미 종료되었다 (stack 프레임 해제됨)
  • 하지만 add5를 호출할 때 x=5가 필요하다!

해답: x=5heap에 할당한다. 클로저는 heap 포인터를 가진다.

makeAdder(5) 실행:
1. Heap에 환경 할당: { x: 5 }
2. 클로저 생성: (function_ptr, env_ptr)
3. makeAdder 종료 (stack 해제)
4. 클로저 반환 (env_ptr는 여전히 유효)

add5(3) 호출:
1. env_ptr에서 x 로드: x = 5
2. y = 3 (파라미터)
3. x + y = 8 반환

GC 없이는?

  • 수동으로 free(env_ptr) 호출 필요
  • 언제 해제? add5가 더 이상 사용되지 않을 때
  • 하지만 add5가 다른 변수에 할당되었다면?
  • 복잡성 폭발! → Garbage Collection 필요

MLIR memref Dialect 개요

MLIR은 메모리 연산을 위해 memref (memory reference) dialect를 제공한다.

memref 타입

memref는 “메모리 영역에 대한 참조“를 나타낸다:

memref<10xi32>           // 10개의 i32 배열
memref<1xi32>            // 단일 i32 (크기 1 배열)
memref<5x5xf32>          // 5×5 float 행렬
memref<*xi32>            // 동적 크기 i32 배열

구성:

  • memref<shape x type>: shape은 차원, type은 요소 타입
  • memref<1xi32>: 하나의 i32를 저장하는 메모리 영역

Stack 할당: memref.alloca

Stack에 메모리를 할당하는 연산:

func.func @stack_example() -> i32 {
  // Stack에 i32 하나 할당
  %stack = memref.alloca() : memref<1xi32>

  %c0 = arith.constant 0 : index      // 인덱스 0
  %c42 = arith.constant 42 : i32      // 값 42

  // Stack에 값 저장
  memref.store %c42, %stack[%c0] : memref<1xi32>

  // Stack에서 값 로드
  %loaded = memref.load %stack[%c0] : memref<1xi32>

  func.return %loaded : i32
  // 함수 종료 시 stack 자동 해제
}

동작:

  1. memref.alloca: Stack에 공간 할당
  2. memref.store: 메모리에 값 쓰기
  3. memref.load: 메모리에서 값 읽기
  4. 함수 종료: Stack 자동 해제

인덱스 타입:

  • index: MLIR의 배열 인덱스 전용 타입
  • 플랫폼에 따라 i32 또는 i64로 lowering됨

LLVM IR로 lowering:

define i32 @stack_example() {
  %stack = alloca i32, i32 1         ; Stack 할당
  store i32 42, i32* %stack          ; 저장
  %loaded = load i32, i32* %stack    ; 로드
  ret i32 %loaded
}

Heap 할당: memref.alloc

Heap에 메모리를 할당하는 연산:

func.func @heap_example() -> memref<10xi32> {
  // Heap에 i32 배열 10개 할당
  %heap = memref.alloc() : memref<10xi32>

  // ... heap 사용 ...

  // 명시적 해제 (수동 메모리 관리)
  // memref.dealloc %heap : memref<10xi32>

  func.return %heap : memref<10xi32>
  // heap은 함수 종료 후에도 유효
}

동작:

  1. memref.alloc: Heap에 메모리 할당 (malloc과 유사)
  2. 메모리 사용
  3. memref.dealloc: 명시적 해제 (free와 유사)
    • 주의: 수동 해제는 에러 유발 (use-after-free, double-free)
    • FunLang은 GC를 사용하므로 dealloc을 호출하지 않는다!

LLVM IR로 lowering:

define ptr @heap_example() {
  ; malloc 호출
  %size = mul i64 10, 4                    ; 10 * sizeof(i32)
  %heap = call ptr @malloc(i64 %size)

  ; ... heap 사용 ...

  ret ptr %heap
}

memref.load와 memref.store

메모리 읽기/쓰기:

// 쓰기
memref.store %value, %memref[%index] : memref<10xi32>

// 읽기
%loaded = memref.load %memref[%index] : memref<10xi32>

다차원 배열:

// 5×5 행렬
%matrix = memref.alloc() : memref<5x5xi32>
%c1 = arith.constant 1 : index
%c2 = arith.constant 2 : index
%c42 = arith.constant 42 : i32

// matrix[1][2] = 42
memref.store %c42, %matrix[%c1, %c2] : memref<5x5xi32>

// value = matrix[1][2]
%value = memref.load %matrix[%c1, %c2] : memref<5x5xi32>

Phase 2에서 memref를 사용하지 않는 이유

Phase 2 프로그램은 SSA 레지스터만으로 충분하다:

// Phase 2 스타일 (SSA only)
func.func @main() -> i32 {
  %x = arith.constant 5 : i32      // SSA value
  %y = arith.constant 10 : i32     // SSA value
  %sum = arith.addi %x, %y : i32   // SSA value
  func.return %sum : i32
}

// memref 스타일로 작성하면? (불필요하게 복잡)
func.func @main() -> i32 {
  %x_mem = memref.alloca() : memref<1xi32>
  %c0 = arith.constant 0 : index
  %c5 = arith.constant 5 : i32
  memref.store %c5, %x_mem[%c0] : memref<1xi32>

  %y_mem = memref.alloca() : memref<1xi32>
  %c10 = arith.constant 10 : i32
  memref.store %c10, %y_mem[%c0] : memref<1xi32>

  %x = memref.load %x_mem[%c0] : memref<1xi32>
  %y = memref.load %y_mem[%c0] : memref<1xi32>
  %sum = arith.addi %x, %y : i32
  func.return %sum : i32
}

첫 번째 버전이 훨씬 간단하다! SSA 레지스터만으로 충분하면 memref를 사용할 필요가 없다.

memref가 필요한 경우:

  • 값이 함수 스코프를 벗어나야 할 때 (클로저 환경)
  • 포인터가 필요할 때 (데이터 구조 간 참조)
  • Mutation이 필요할 때 (SSA는 immutable)

memref 요약

memref dialect:

  • MLIR의 메모리 연산 추상화
  • memref.alloca: Stack 할당 (자동 해제)
  • memref.alloc: Heap 할당 (수동 해제 또는 GC)
  • memref.load/store: 메모리 읽기/쓰기

Phase 2 vs Phase 3:

  • Phase 2: SSA 레지스터만 사용 (memref 불필요)
  • Phase 3: 클로저 환경 → heap 할당 → memref 필요

다음 섹션: 왜 Garbage Collection이 필요한가?

왜 Garbage Collection이 필요한가

Heap 메모리는 명시적으로 할당하고 해제해야 한다. 하지만 수동 메모리 관리는 매우 어렵고 에러가 많다.

수동 메모리 관리의 문제

1. Use-After-Free

freed 메모리에 접근:

int* ptr = malloc(sizeof(int));
*ptr = 42;
free(ptr);        // 메모리 해제
printf("%d\n", *ptr);  // 에러! freed 메모리 접근

결과:

  • Undefined behavior (프로그램 crash 또는 잘못된 값)
  • 보안 취약점 (공격자가 freed 메모리를 재사용)

2. Double-Free

같은 메모리를 두 번 해제:

int* ptr = malloc(sizeof(int));
free(ptr);
free(ptr);  // 에러! 이미 freed된 메모리

결과:

  • Heap 메타데이터 손상
  • 프로그램 crash

3. Memory Leak

메모리 해제를 잊음:

void leak() {
    int* ptr = malloc(sizeof(int));
    *ptr = 42;
    return;  // ptr을 free하지 않음!
}

// leak()을 1000번 호출하면?
for (int i = 0; i < 1000; i++) {
    leak();  // 메모리 누수: 1000 * sizeof(int) 바이트
}

결과:

  • 메모리 사용량 계속 증가
  • Out-of-memory 에러

클로저가 수동 메모리 관리를 어렵게 만드는 이유

문제: 언제 클로저 환경을 해제하는가?

// FunLang 예시
let makeAdder x = fun y -> x + y

let add5 = makeAdder 5   // 클로저 1: env = { x: 5 }
let add10 = makeAdder 10 // 클로저 2: env = { x: 10 }

// Q: env { x: 5 }를 언제 해제하는가?
// A: add5가 더 이상 사용되지 않을 때

// 하지만 이것이 언제인가?
let adders = [add5; add10]  // add5를 리스트에 저장
// 여기서 add5를 해제할 수 있는가? No! 리스트가 참조 중

let result = List.head adders 3  // add5 사용
// 이제 해제? 아직 adders가 add5를 가리킨다

// ... 프로그램 계속 실행 ...

복잡성:

  • add5가 언제 “더 이상 사용되지 않는가“를 결정하기 어렵다
  • 여러 변수가 같은 클로저를 참조할 수 있다
  • 클로저가 다른 클로저를 캡처할 수 있다 (환경이 중첩)

수동 관리 시도:

// 명시적 free 추가?
let add5 = makeAdder 5
// ... add5 사용 ...
free(add5)  // 하지만 다른 변수가 add5를 참조하면?

let alias = add5
free(add5)  // alias는 이제 invalid pointer!

불가능한 이유:

  • 참조 추적이 필요 (누가 클로저를 가리키는가?)
  • 런타임 추적 메커니즘 필요
  • 이미 Garbage Collector를 구현하는 것과 같다!

클로저 생명주기 예시

복잡한 시나리오:

let outer x =
    let inner y =
        fun z -> x + y + z  // x와 y를 모두 캡처
    inner

let f = outer 5 10   // f는 클로저, env = { x: 5, y: 10 }

// outer 함수는 종료됨 (stack 해제)
// 하지만 env { x: 5, y: 10 }은 heap에 살아있어야 함

let result = f 3     // x=5, y=10, z=3 → 18

// 언제 env를 해제?
// f가 더 이상 참조되지 않을 때

Garbage Collector의 역할:

  • 런타임에 객체 참조를 추적한다
  • 더 이상 참조되지 않는 객체를 찾는다
  • 자동으로 메모리를 회수한다

Garbage Collection의 이점

1. 안전성

  • Use-after-free: 불가능 (GC가 사용 중인 객체를 해제하지 않음)
  • Double-free: 불가능 (GC가 한 번만 해제)
  • Memory leak: 최소화 (접근 불가능한 객체는 자동 회수)

2. 생산성

  • 프로그래머가 메모리 관리를 신경 쓰지 않아도 됨
  • 버그가 적다
  • 코드가 간결해진다

3. 클로저 지원

  • 클로저 환경의 생명주기를 자동 관리
  • 복잡한 참조 그래프도 처리

트레이드오프:

  • 성능: GC가 주기적으로 실행됨 (pause time)
  • 메모리: GC는 약간의 메모리 오버헤드 존재
  • FunLang의 선택: 클로저 지원을 위해 GC는 필수

GC 없이 클로저를 구현한다면?

대안들:

  1. Reference Counting

    • 각 객체의 참조 카운트 추적
    • 카운트가 0이 되면 해제
    • 문제: 순환 참조 처리 불가
      let rec loop x = fun y -> loop y x  // 순환 참조!
      
  2. Arena Allocation

    • 모든 객체를 arena에 할당
    • Arena 전체를 한 번에 해제
    • 문제: 클로저가 서로 다른 생명주기를 가질 때 비효율
  3. Ownership System (Rust 스타일)

    • 컴파일 타임에 생명주기 추적
    • 런타임 오버헤드 없음
    • 문제: FunLang은 타입 추론 언어 (ownership 추가는 언어 복잡성 증가)

결론: Garbage Collection이 가장 적합한 선택이다!

왜 GC가 필요한가 요약

문제:

  • 클로저 환경은 heap에 할당해야 한다 (함수 스코프를 벗어남)
  • 환경의 생명주기는 복잡하다 (여러 참조, 중첩, 순환)
  • 수동 메모리 관리는 에러가 많다 (use-after-free, leak)

해답: Garbage Collection

  • 런타임에 객체 참조를 추적한다
  • 접근 불가능한 객체를 자동으로 회수한다
  • 프로그래머가 메모리 관리를 신경 쓰지 않아도 된다

다음 섹션: Boehm GC 소개 - FunLang이 사용할 GC!

Boehm GC 소개

FunLang은 Boehm-Demers-Weiser Garbage Collector (줄여서 Boehm GC 또는 bdwgc)를 사용한다.

Boehm GC란?

Boehm GC는 C와 C++을 위한 보수적(conservative) 가비지 컬렉터다.

핵심 특징:

  1. Conservative Collection

    • “보수적“이란 정확한 타입 정보 없이 동작한다는 의미
    • Stack과 heap을 스캔하여 “포인터처럼 보이는 값“을 찾는다
    • 값이 유효한 heap 주소 범위에 있으면 포인터로 간주한다
  2. Drop-in Replacement for malloc/free

    // 기존 코드
    int* ptr = malloc(sizeof(int) * 10);
    // ... 사용 ...
    free(ptr);
    
    // Boehm GC 사용
    int* ptr = GC_malloc(sizeof(int) * 10);
    // ... 사용 ...
    // free 불필요! GC가 자동으로 회수
    
  3. Battle-Tested

    • 1988년부터 개발됨 (30년 이상 역사)
    • 많은 프로그래밍 언어 구현에서 사용:
      • GNU Guile (Scheme)
      • Mono (.NET on Linux)
      • W3m (텍스트 브라우저)
    • 안정성이 검증됨
  4. Thread-Safe

    • 멀티스레드 환경에서 안전
    • 적절한 초기화 필요 (GC_INIT())

왜 Boehm GC를 선택했는가?

장점:

  1. 컴파일러 변경 최소화

    • Stack map 불필요 (conservative 스캔)
    • Write barrier 불필요
    • GC를 위한 특별한 코드 생성 불필요
  2. 간단한 통합

    • C 라이브러리로 제공
    • GC_malloc 호출만으로 사용 가능
    • 기존 C runtime과 함께 링킹
  3. 안정성

    • 오래 사용됨, 버그가 적다
    • 다양한 플랫폼 지원 (Linux, macOS, Windows)

단점:

  1. 보수적 수집

    • False positive: 포인터가 아닌 값을 포인터로 오인
    • 결과: 일부 객체가 회수되지 않을 수 있음 (메모리 누수)
    • 실제로는 드물고, 대부분의 프로그램에서 문제없음
  2. Stop-the-world GC

    • GC 실행 중 프로그램 전체가 일시 중지
    • Latency-critical 애플리케이션에는 부적합
    • FunLang은 교육용이므로 문제없음

대안과 비교

1. Reference Counting

  • 장점: 즉시 회수, 예측 가능
  • 단점: 순환 참조 처리 불가, 성능 오버헤드 (카운트 업데이트)
  • FunLang: 클로저는 순환 참조 가능 → 부적합

2. LLVM Statepoints (Precise GC)

  • 장점: 정확한 수집 (false positive 없음)
  • 단점: 복잡한 컴파일러 지원 필요 (safepoint 삽입, stack map 생성)
  • FunLang: 교육용으로는 너무 복잡 → 부적합

3. Custom Mark-Sweep GC

  • 장점: 완전한 제어
  • 단점: 구현이 어렵고 버그가 많음
  • FunLang: Boehm GC가 이미 잘 동작 → 불필요

결론: Boehm GC가 FunLang에 가장 적합하다!

Boehm GC 핵심 함수

1. GC_INIT()

GC_INIT();  // 프로그램 시작 시 한 번 호출
  • GC를 초기화한다
  • 반드시 main() 시작 부분이나 첫 GC_malloc 전에 호출
  • Thread-local storage 설정, heap 초기화

2. GC_malloc(size)

void* ptr = GC_malloc(100);  // 100바이트 할당
  • Heap에 메모리 할당
  • malloc과 동일하게 사용
  • GC가 자동으로 회수 (free 불필요)

3. GC_malloc_atomic(size)

void* ptr = GC_malloc_atomic(100);  // 포인터 없는 데이터
  • 포인터를 포함하지 않는 데이터용 할당
  • 예: 문자열, 정수 배열
  • GC가 스캔하지 않음 (성능 향상)

4. GC_free(ptr) (선택 사항)

GC_free(ptr);  // 명시적 해제 (힌트)
  • GC에게 “이 메모리를 즉시 회수해도 됨“을 알림
  • 필수는 아님 (GC가 나중에 자동 회수)
  • 성능 최적화용

Conservative GC 동작 원리

1. Heap 스캔:

Heap:
┌────────────────┐ 0x1000
│ Object A       │
├────────────────┤ 0x1010
│ Object B       │
├────────────────┤ 0x1020
│ Free space     │
└────────────────┘

2. Stack 스캔:

Stack:
┌────────────────┐
│ var1 = 0x1000  │ ← 포인터처럼 보임 (Object A 가리킴)
├────────────────┤
│ var2 = 42      │ ← 포인터 아님 (heap 범위 밖)
├────────────────┤
│ var3 = 0x1010  │ ← 포인터처럼 보임 (Object B 가리킴)
└────────────────┘

3. Mark Phase:

  • Stack에서 0x1000, 0x1010 발견
  • Object A와 Object B를 “live“로 표시

4. Sweep Phase:

  • Heap 전체를 스캔
  • “live” 표시 없는 객체 회수

False Positive 예시:

int x = 0x1000;  // 우연히 heap 주소와 같은 정수
// GC는 x를 포인터로 오인할 수 있음
// 결과: 0x1000의 객체가 회수되지 않음 (누수)

실제로는 드물고, 대부분의 프로그램에서 문제없음.

Boehm GC 빌드 및 설치

Boehm GC를 소스에서 빌드하거나 패키지 매니저로 설치할 수 있다.

소스에서 빌드

1. 저장소 클론:

# Boehm GC 저장소
git clone https://github.com/ivmai/bdwgc
cd bdwgc

# Atomic operations 라이브러리 (의존성)
git clone https://github.com/ivmai/libatomic_ops

2. libatomic_ops 링크:

# bdwgc가 libatomic_ops를 찾을 수 있도록 심볼릭 링크 생성
ln -s $(pwd)/libatomic_ops $(pwd)/libatomic_ops

또는:

cd bdwgc
ln -s ../libatomic_ops libatomic_ops

3. Build 설정:

cd bdwgc
autoreconf -vif        # autoconf 파일 생성
automake --add-missing # 누락된 파일 추가
./configure --prefix=$HOME/boehm-gc --enable-threads=posix

configure 옵션:

  • --prefix=$HOME/boehm-gc: 설치 경로 (홈 디렉토리)
  • --enable-threads=posix: 멀티스레드 지원 (POSIX threads)

4. 빌드 및 설치:

make -j$(nproc)        # 병렬 빌드 (CPU 코어 수만큼)
make check             # 테스트 실행 (선택 사항)
make install           # $HOME/boehm-gc에 설치

5. 환경 변수 설정:

# 라이브러리 경로 추가
export LD_LIBRARY_PATH=$HOME/boehm-gc/lib:$LD_LIBRARY_PATH

# 헤더 경로 추가
export C_INCLUDE_PATH=$HOME/boehm-gc/include:$C_INCLUDE_PATH

# bashrc에 추가하여 영구 적용
echo 'export LD_LIBRARY_PATH=$HOME/boehm-gc/lib:$LD_LIBRARY_PATH' >> ~/.bashrc
echo 'export C_INCLUDE_PATH=$HOME/boehm-gc/include:$C_INCLUDE_PATH' >> ~/.bashrc

패키지 매니저로 설치

Ubuntu/Debian:

sudo apt update
sudo apt install libgc-dev

macOS (Homebrew):

brew install bdw-gc

Fedora/RHEL:

sudo dnf install gc-devel

Arch Linux:

sudo pacman -S gc

패키지 매니저로 설치하면 환경 변수 설정이 자동으로 처리된다.

설치 확인

테스트 프로그램 작성:

// test_gc.c
#include <stdio.h>
#include <gc.h>

int main() {
    GC_INIT();

    void* ptr = GC_malloc(100);
    if (ptr == NULL) {
        printf("GC_malloc failed\n");
        return 1;
    }

    printf("GC_malloc succeeded: %p\n", ptr);
    // GC_free 불필요 - GC가 자동 회수

    return 0;
}

컴파일 및 실행:

# 소스 빌드한 경우
gcc test_gc.c -o test_gc -I$HOME/boehm-gc/include -L$HOME/boehm-gc/lib -lgc
./test_gc

# 패키지 매니저로 설치한 경우
gcc test_gc.c -o test_gc -lgc
./test_gc

예상 출력:

GC_malloc succeeded: 0x7f1234567890

성공! Boehm GC가 올바르게 설치되었다.

FunLang Runtime 통합

이제 FunLang 컴파일러가 생성하는 바이너리와 Boehm GC를 연결한다.

C Runtime 작성

runtime.c - FunLang 실행 환경:

// runtime.c - FunLang runtime with Boehm GC
#include <stdio.h>
#include <gc.h>

/**
 * GC 초기화
 * 프로그램 시작 시 한 번 호출
 */
void funlang_init() {
    GC_INIT();
}

/**
 * GC-managed 메모리 할당
 *
 * @param size 할당할 바이트 수
 * @return 할당된 메모리 포인터
 */
void* funlang_alloc(size_t size) {
    return GC_malloc(size);
}

/**
 * Atomic 메모리 할당 (포인터 없는 데이터용)
 *
 * @param size 할당할 바이트 수
 * @return 할당된 메모리 포인터
 */
void* funlang_alloc_atomic(size_t size) {
    return GC_malloc_atomic(size);
}

/**
 * 정수 출력 (Chapter 06에서 구현)
 *
 * @param value 출력할 정수 값
 */
void print_int(int value) {
    printf("%d\n", value);
}

/**
 * MLIR 컴파일된 main 함수
 * F# 컴파일러가 생성한 LLVM IR에서 정의됨
 */
extern int funlang_main();

/**
 * C 프로그램 진입점
 * GC 초기화 후 funlang_main 호출
 */
int main(int argc, char** argv) {
    funlang_init();
    int result = funlang_main();
    return result;
}

Runtime 구조:

  1. funlang_init(): GC 초기화
  2. funlang_alloc(): Heap 할당 (Phase 3+에서 사용)
  3. print_int(): 정수 출력 (Phase 2에서 이미 사용 중)
  4. main(): GC 초기화 → funlang_main 호출

Runtime 컴파일

# 소스 빌드한 경우
gcc -c runtime.c -o runtime.o -I$HOME/boehm-gc/include

# 패키지 매니저로 설치한 경우
gcc -c runtime.c -o runtime.o

결과: runtime.o 오브젝트 파일 생성

MLIR에서 GC_malloc 호출

Phase 3에서 클로저 환경을 heap에 할당할 때 사용할 패턴 (미리보기):

1. GC_malloc 선언 (MLIR):

// External function 선언
llvm.func @GC_malloc(i64) -> !llvm.ptr attributes {
    sym_visibility = "private"
}

2. Heap 할당 호출:

func.func @allocate_closure_env() -> !llvm.ptr {
    // 클로저 환경 크기 (예: 2개의 i64 값)
    %size = arith.constant 16 : i64  // 2 * 8 bytes

    // GC_malloc 호출
    %env = llvm.call @GC_malloc(%size) : (i64) -> !llvm.ptr

    // env에 캡처된 값 저장
    // (Phase 3에서 구현)

    func.return %env : !llvm.ptr
}

3. F# 코드 생성 패턴:

// MlirWrapper.fs에 추가할 헬퍼 메서드 (Phase 3)
type OpBuilder(context: Context) =
    // ... 기존 메서드 ...

    /// GC_malloc external function 선언
    member this.DeclareGCMalloc() : MlirOperation =
        let ptrType = this.LLVMPointerType()
        let i64Type = builder.Context.GetIntegerType(64)
        let funcType = MlirNative.mlirFunctionTypeGet(
            ctx.Handle,
            1n, [| i64Type |],
            1n, [| ptrType |]
        )

        let name = MlirHelpers.fromString("GC_malloc")
        let funcOp = MlirNative.mlirLLVMFuncCreate(location, name, funcType)

        // 가시성 속성 설정
        // ...

        funcOp

    /// GC_malloc 호출하여 메모리 할당
    member this.CallGCMalloc(size: MlirValue, location: MlirLocation) : MlirValue =
        let gcMalloc = // ... GC_malloc 함수 참조 ...
        let callOp = MlirNative.mlirLLVMCallCreate(
            location, gcMalloc, 1n, [| size |]
        )
        MlirNative.mlirOperationGetResult(callOp, 0)

Phase 2에서는 사용하지 않지만, runtime.c에 funlang_alloc을 미리 정의하여 Phase 3에서 바로 사용할 수 있다.

빌드 파이프라인 업데이트

Boehm GC를 포함한 완전한 빌드 파이프라인:

단계별 빌드 과정

1. FunLang 소스 → LLVM IR:

# F# 컴파일러 실행
dotnet run "let x = 5 in if x > 0 then x * 2 else 0"

# 출력: output.ll (LLVM IR 파일)

2. LLVM IR → Object 파일:

llc -filetype=obj output.ll -o output.o

3. Runtime 컴파일:

# 소스 빌드한 경우
gcc -c runtime.c -o runtime.o -I$HOME/boehm-gc/include

# 패키지 매니저로 설치한 경우
gcc -c runtime.c -o runtime.o

4. 링킹 (Boehm GC 포함):

# 소스 빌드한 경우
gcc output.o runtime.o -o program \
    -L$HOME/boehm-gc/lib -lgc \
    -Wl,-rpath,$HOME/boehm-gc/lib

# 패키지 매니저로 설치한 경우
gcc output.o runtime.o -o program -lgc

링커 옵션 설명:

  • -L$HOME/boehm-gc/lib: 라이브러리 검색 경로
  • -lgc: Boehm GC 라이브러리 링크
  • -Wl,-rpath,$HOME/boehm-gc/lib: 실행 시 라이브러리 경로 (RPATH)

5. 실행:

./program
echo $?   # Exit code 확인

자동화된 빌드 스크립트

build.sh:

#!/bin/bash
# FunLang 빌드 스크립트

set -e  # 에러 시 중단

FUNLANG_SRC="$1"
OUTPUT="program"

# 1. FunLang → LLVM IR
echo "Compiling FunLang to LLVM IR..."
dotnet run "$FUNLANG_SRC" > output.ll

# 2. LLVM IR → Object
echo "Compiling LLVM IR to object file..."
llc -filetype=obj output.ll -o output.o

# 3. Runtime 컴파일 (필요 시)
if [ ! -f runtime.o ]; then
    echo "Compiling runtime..."
    gcc -c runtime.c -o runtime.o
fi

# 4. 링킹
echo "Linking with Boehm GC..."
if [ -d "$HOME/boehm-gc" ]; then
    # 소스 빌드
    gcc output.o runtime.o -o "$OUTPUT" \
        -L$HOME/boehm-gc/lib -lgc \
        -Wl,-rpath,$HOME/boehm-gc/lib
else
    # 패키지 매니저
    gcc output.o runtime.o -o "$OUTPUT" -lgc
fi

echo "Build complete: $OUTPUT"

사용:

chmod +x build.sh
./build.sh "let x = 5 in x + x"
./program

F# 통합

Compiler.fs에 추가:

module Compiler =

    /// LLVM IR을 object 파일로 컴파일
    let compileToObject (llvmIR: string) (outputPath: string) =
        // LLVM IR을 파일에 쓰기
        let llPath = Path.ChangeExtension(outputPath, ".ll")
        File.WriteAllText(llPath, llvmIR)

        // llc 호출
        let llcArgs = sprintf "-filetype=obj %s -o %s" llPath outputPath
        let result = Process.Start("llc", llcArgs)
        result.WaitForExit()

        if result.ExitCode <> 0 then
            failwith "llc compilation failed"

    /// Object 파일과 runtime을 링킹
    let linkWithGC (objPath: string) (exePath: string) =
        let runtimePath = "runtime.o"

        // Boehm GC 경로 확인
        let gcPath = Environment.GetEnvironmentVariable("HOME") + "/boehm-gc"
        let hasSourceBuild = Directory.Exists(gcPath)

        let gccArgs =
            if hasSourceBuild then
                sprintf "%s %s -o %s -L%s/lib -lgc -Wl,-rpath,%s/lib"
                    objPath runtimePath exePath gcPath gcPath
            else
                sprintf "%s %s -o %s -lgc"
                    objPath runtimePath exePath

        let result = Process.Start("gcc", gccArgs)
        result.WaitForExit()

        if result.ExitCode <> 0 then
            failwith "gcc linking failed"

    /// 전체 컴파일 파이프라인
    let compileProgram (source: string) (outputExe: string) =
        // 1. Parse
        let ast = Parser.parse source

        // 2. MLIR IR 생성
        let mlirModule = CodeGen.compile ast

        // 3. Lowering
        Lowering.lowerToLLVMDialect mlirModule

        // 4. LLVM IR 변환
        let llvmIR = Lowering.translateToLLVMIR mlirModule

        // 5. Object 컴파일
        let objPath = Path.ChangeExtension(outputExe, ".o")
        compileToObject llvmIR objPath

        // 6. 링킹
        linkWithGC objPath outputExe

        printfn "Compilation successful: %s" outputExe

사용:

// Program.fs
[<EntryPoint>]
let main argv =
    if argv.Length < 1 then
        printfn "Usage: dotnet run <source> [output]"
        1
    else
        let source = argv.[0]
        let output = if argv.Length > 1 then argv.[1] else "program"

        Compiler.compileProgram source output
        0

Phase 2 vs Phase 3+ 메모리 사용

FunLang의 메모리 사용 패턴은 단계별로 진화한다.

Phase 2 (현재)

특징:

  • 모든 값이 SSA 레지스터
  • 메모리 할당 없음
  • GC 초기화되지만 사용되지 않음

생성되는 MLIR IR:

module {
  func.func @funlang_main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32
    %sum = arith.addi %c5, %c10 : i32
    func.return %sum : i32
  }
}

GC 호출: 없음 (funlang_alloc 호출 0회)

Phase 3 (함수와 클로저)

특징:

  • 클로저가 환경을 캡처
  • 환경은 heap에 할당 (GC_malloc)
  • GC가 죽은 클로저 회수

예시: 클로저 환경 할당

// let makeAdder x = fun y -> x + y
func.func @makeAdder(%x: i32) -> !llvm.ptr {
    // 클로저 환경 할당 (x를 저장)
    %size = arith.constant 8 : i64
    %env = llvm.call @GC_malloc(%size) : (i64) -> !llvm.ptr

    // x를 환경에 저장
    %x_i64 = arith.extsi %x : i32 to i64
    llvm.store %x_i64, %env : !llvm.ptr

    // 클로저 생성 (function pointer + env pointer)
    %closure = funlang.make_closure @lambda, %env

    func.return %closure : !llvm.ptr
}

// fun y -> x + y
func.func private @lambda(%env: !llvm.ptr, %y: i32) -> i32 {
    // 환경에서 x 로드
    %x_i64 = llvm.load %env : !llvm.ptr -> i64
    %x = arith.trunci %x_i64 : i64 to i32

    // x + y
    %result = arith.addi %x, %y : i32
    func.return %result : i32
}

GC 호출: makeAdder 호출마다 1회

Phase 6 (데이터 구조)

특징:

  • 리스트, 튜플, 문자열 모두 heap 할당
  • 재귀적 데이터 구조 (리스트의 tail)
  • GC가 복잡한 참조 그래프 처리

예시: 리스트 cons

// cons(1, cons(2, nil))
func.func @build_list() -> !llvm.ptr {
    // nil
    %nil = llvm.mlir.null : !llvm.ptr

    // cons(2, nil)
    %size = arith.constant 16 : i64  // head + tail
    %cons2 = llvm.call @GC_malloc(%size) : (i64) -> !llvm.ptr
    %c2 = arith.constant 2 : i64
    llvm.store %c2, %cons2 : !llvm.ptr
    %tail_ptr = llvm.getelementptr %cons2[8] : !llvm.ptr
    llvm.store %nil, %tail_ptr : !llvm.ptr

    // cons(1, cons2)
    %cons1 = llvm.call @GC_malloc(%size) : (i64) -> !llvm.ptr
    %c1 = arith.constant 1 : i64
    llvm.store %c1, %cons1 : !llvm.ptr
    %tail_ptr1 = llvm.getelementptr %cons1[8] : !llvm.ptr
    llvm.store %cons2, %tail_ptr1 : !llvm.ptr

    func.return %cons1 : !llvm.ptr
}

GC 호출: cons 노드마다 1회

메모리 그래프:

%cons1 ─→ [ head: 1 | tail: ─→ %cons2 ─→ [ head: 2 | tail: nil ] ]

GC는 %cons1이 접근 불가능해지면 전체 체인을 회수한다.

메모리 사용 비교

Phase할당 위치GC 사용복잡도
Phase 2SSA 레지스터만초기화만 (호출 0회)낮음
Phase 3클로저 환경 → Heap클로저 생성 시중간
Phase 6모든 데이터 구조 → Heap거의 모든 연산높음

핵심: Phase 2는 GC 인프라를 준비하지만, 실제 사용은 Phase 3부터다.

공통 에러 및 해결

GC 통합 시 자주 발생하는 에러와 해결 방법:

에러 1: GC_malloc 호출 시 Segfault

증상:

Segmentation fault (core dumped)

원인: GC_INIT()을 호출하지 않고 GC_malloc을 사용했다.

해결: main() 시작 부분에서 GC_INIT() 호출:

int main() {
    GC_INIT();  // 필수!
    // ... 나머지 코드 ...
}

FunLang runtime.c:

void funlang_init() {
    GC_INIT();
}

int main(int argc, char** argv) {
    funlang_init();  // 첫 번째 호출
    // ...
}

에러 2: Linker Error - Undefined Reference to GC_malloc

증상:

undefined reference to `GC_malloc'
collect2: error: ld returned 1 exit status

원인: Boehm GC 라이브러리를 링킹하지 않았다.

해결: 링킹 시 -lgc 옵션 추가:

gcc output.o runtime.o -o program -lgc

또는 라이브러리 경로 지정:

gcc output.o runtime.o -o program -L$HOME/boehm-gc/lib -lgc

에러 3: 실행 시 Library Not Found

증상:

error while loading shared libraries: libgc.so.1: cannot open shared object file

원인: 실행 시 libgc.so를 찾을 수 없다.

해결:

옵션 1: LD_LIBRARY_PATH 설정

export LD_LIBRARY_PATH=$HOME/boehm-gc/lib:$LD_LIBRARY_PATH
./program

옵션 2: RPATH 사용 (권장)

gcc output.o runtime.o -o program \
    -L$HOME/boehm-gc/lib -lgc \
    -Wl,-rpath,$HOME/boehm-gc/lib

RPATH는 바이너리에 라이브러리 경로를 포함시킨다. LD_LIBRARY_PATH 설정 불필요.

에러 4: GC가 메모리를 회수하지 않음

증상: 프로그램 메모리 사용량이 계속 증가한다.

원인: Boehm GC는 보수적(conservative)이므로 일부 객체를 회수하지 못할 수 있다.

확인 방법:

#include <gc.h>

int main() {
    GC_INIT();

    for (int i = 0; i < 1000000; i++) {
        void* ptr = GC_malloc(100);
        // ptr을 더 이상 사용하지 않음
    }

    // GC 통계 출력
    GC_gcollect();  // 강제 수집
    printf("Heap size: %zu\n", GC_get_heap_size());
    printf("Free bytes: %zu\n", GC_get_free_bytes());

    return 0;
}

일반적인 경우:

  • Phase 2-3 프로그램에서는 문제없음
  • Conservative GC의 false positive는 드물다
  • 메모리 누수가 심각하면 정확한(precise) GC 고려

에러 5: Multi-threading 관련 Crash

증상: 멀티스레드 프로그램에서 random crash.

원인: GC를 멀티스레드 모드로 초기화하지 않았다.

해결:

Phase 2-5: 싱글스레드만 사용하므로 문제없음.

Phase 6+ (Future): 스레드 생성 시 GC-aware 함수 사용:

#include <gc.h>
#include <pthread.h>

void* thread_func(void* arg) {
    GC_pthread_create(...);  // GC-aware thread creation
    // ...
}

또는 빌드 시 thread-safe 옵션:

./configure --enable-threads=posix

장 요약

이 장에서 메모리 관리의 기초와 Boehm GC 통합을 완료했다.

주요 성취

  1. Stack vs Heap 이해

    • Stack: 함수 스코프, 자동 관리, LIFO
    • Heap: 유연한 생명주기, 명시적 할당/해제
  2. FunLang 메모리 전략

    • Phase 2: SSA 레지스터만 사용
    • Phase 3+: 클로저 환경 → heap 할당
  3. MLIR memref Dialect

    • memref.alloca: Stack 할당
    • memref.alloc: Heap 할당
    • memref.load/store: 메모리 읽기/쓰기
  4. GC 필요성 이해

    • 수동 메모리 관리의 문제: use-after-free, leak, double-free
    • 클로저가 복잡한 생명주기를 가진다
    • GC가 자동으로 회수한다
  5. Boehm GC 통합

    • Conservative GC: 타입 정보 불필요
    • GC_INIT(), GC_malloc() 사용
    • 빌드 및 설치 완료
  6. Runtime 작성

    • runtime.c: GC 초기화, 메모리 할당 wrapper
    • funlang_main() 호출 전에 funlang_init()
  7. 빌드 파이프라인

    • FunLang → LLVM IR → Object → 링킹 (+ Boehm GC)
    • 자동화 스크립트 및 F# 통합
  8. 에러 처리

    • GC_INIT 누락, 링킹 오류, 라이브러리 경로 문제 해결

독자가 할 수 있는 것

  • Stack과 heap의 차이를 설명할 수 있다 ✓
  • 언제 heap 할당이 필요한지 안다 (클로저, 데이터 구조) ✓
  • Boehm GC를 빌드하고 설치할 수 있다 ✓
  • runtime.c를 작성하여 GC를 초기화할 수 있다 ✓
  • FunLang 컴파일러 출력을 Boehm GC와 링킹할 수 있다 ✓
  • GC 관련 에러를 디버깅할 수 있다 ✓
  • 왜 클로저가 GC를 필요로 하는지 이해한다 ✓

Phase 2 완료!

Chapter 06: 산술 표현식 (+, -, *, /, 비교, 부정, print) Chapter 07: Let 바인딩과 SSA 환경 전달 Chapter 08: 제어 흐름 (scf.if, block arguments, boolean) Chapter 09: 메모리 관리 (stack/heap 전략, Boehm GC 통합)

독자가 컴파일할 수 있는 프로그램:

// 복잡한 예시
let x = 5 in
let y = 10 in
if x > 0 then
    if y < 20 then
        x * y
    else
        x + y
else
    0

생성되는 바이너리:

  • MLIR로 컴파일
  • LLVM IR로 lowering
  • Native object 생성
  • Boehm GC와 링킹
  • 실행 가능한 바이너리!
$ ./program
$ echo $?
50

Phase 3 Preview: 함수와 클로저

다음 Phase에서 다룰 내용:

함수 정의:

let add = fun x -> fun y -> x + y

클로저 캡처:

let makeAdder x = fun y -> x + y
let add5 = makeAdder 5  // x=5를 캡처

메모리 할당:

  • 클로저 환경을 heap에 할당 (GC_malloc)
  • 함수 포인터 + 환경 포인터 구조
  • GC가 죽은 클로저 회수

MLIR 연산:

  • llvm.call @GC_malloc: Heap 할당
  • llvm.store, llvm.load: 환경 읽기/쓰기
  • Function 타입과 호출 규약

이 장에서 준비한 GC 인프라가 바로 사용된다!


독자는 이제 메모리 관리를 이해하고, Boehm GC를 통합했다. Phase 3로 가자!

Chapter 10: 함수와 func 다이얼렉트

소개

지금까지 FunLang 컴파일러는 **표현식(expression)**만 처리했다. Chapter 06부터 09까지 산술, 비교, let 바인딩, if 표현식을 컴파일하는 방법을 배웠다. 모든 것이 하나의 표현식이었고, 그 결과가 프로그램의 최종 값이었다.

// 지금까지의 FunLang - 단일 표현식
let x = 10 in
let y = 20 in
if x > y then x else y

이것은 단순한 스크립트에서는 작동하지만, 실제 프로그램은 재사용 가능한 코드 단위가 필요하다. 계산을 이름에 바인딩하고, 여러 곳에서 호출할 수 있어야 한다. 바로 **함수(function)**다.

이 장에서는 **최상위 명명된 함수(top-level named functions)**를 추가한다:

// 함수 정의
let add x y = x + y

// 함수 호출
add 10 20   // 결과: 30

중요한 범위 구분: 이 장은 Phase 3의 첫 단계로, 간단한 함수만 다룬다:

  • 최상위 함수 정의 (module-level functions)
  • 함수 파라미터 (고정된 개수)
  • 함수 호출 (call-by-value)
  • 함수 반환값

제외되는 것 (Phase 4에서 다룸):

  • 클로저(Closures): 외부 변수를 캡처하는 함수
  • 고차 함수(Higher-order functions): 함수를 인자로 받거나 반환하는 함수
  • 익명 함수(Lambda expressions): fun x -> x + 1

왜 Phase 3과 Phase 4로 나누는가?

  • Phase 3: 함수의 정적 측면 (함수 정의, 호출, 재귀)
  • Phase 4: 함수의 동적 측면 (클로저, 환경 캡처, 함수 값)

Phase 3 함수는 C나 Java의 static method와 유사하다: 이름으로 호출하고, 외부 상태를 캡처하지 않는다. Phase 4에서 환경 캡처를 추가하면 진정한 함수형 언어가 된다.

학습 목표:

  • MLIR func 다이얼렉트의 연산들 (func.func, func.call, func.return)
  • 함수 파라미터를 block arguments로 표현하는 방법
  • 함수 호출과 반환 값 처리
  • LLVM 호출 규약(calling convention)의 기초
  • 재귀 함수의 작동 원리 (Chapter 11 preview)

이 장을 마치면:

  • 다중 함수 정의를 포함한 FunLang 프로그램을 컴파일할 수 있다
  • 함수가 MLIR IR로, 그리고 네이티브 코드로 변환되는 과정을 이해한다
  • 함수 파라미터가 SSA value로 처리되는 원리를 안다
  • 모듈 레벨 심볼 테이블이 어떻게 재귀를 가능하게 하는지 안다

Preview: Chapter 11에서는 재귀와 상호 재귀를 다룬다. Chapter 10은 함수의 기초를 확립한다.

MLIR func 다이얼렉트

MLIR은 함수를 표현하기 위한 전용 다이얼렉트를 제공한다: func 다이얼렉트.

func 다이얼렉트 개요

func 다이얼렉트는 함수 정의와 호출을 표현하는 고수준 추상화다. C, C++, Rust 같은 언어의 함수와 동일한 개념이다.

핵심 연산:

연산목적예시
func.func함수 정의func.func @add(%arg0: i32, %arg1: i32) -> i32
func.call함수 호출%result = func.call @add(%x, %y) : (i32, i32) -> i32
func.return함수에서 값 반환func.return %result : i32

func 다이얼렉트의 위치 (다이얼렉트 스택):

High-level:  func 다이얼렉트 (함수 추상화)
             scf 다이얼렉트 (제어 흐름)
             arith 다이얼렉트 (산술)
             ↓ (lowering passes)
Middle:      LLVM 다이얼렉트 (LLVM IR 추상화)
             ↓ (mlir-translate)
Low-level:   LLVM IR (define, call, ret)
             ↓ (llc)
Native:      Machine code (x86-64, ARM, etc.)

func 다이얼렉트는 고수준 추상화다. 플랫폼 독립적으로 함수를 정의하고, 나중에 LLVM 다이얼렉트로 내려가면서 호출 규약, 레지스터 할당, 스택 프레임 관리가 추가된다.

func.func 연산: 함수 정의

func.func 연산은 함수를 정의한다. C의 function definition, Java의 method declaration과 동일한 개념이다.

Syntax:

func.func @function_name(%arg0: type0, %arg1: type1, ...) -> return_type {
  // function body
  func.return %result : return_type
}

구성 요소:

  1. Symbol name (@function_name): 함수의 이름. @ 기호는 모듈 레벨 심볼을 나타낸다.
  2. Parameters (%arg0, %arg1): 함수의 파라미터. Block arguments로 표현된다.
  3. Function type ((type0, type1) -> return_type): 파라미터 타입과 반환 타입.
  4. Function body: 함수 본체. Region (영역) 내부에 블록을 포함한다.
  5. Terminator (func.return): 함수 종료. 반환 값을 지정한다.

예시 1: 단순한 함수 (두 정수 더하기)

func.func @add(%arg0: i32, %arg1: i32) -> i32 {
  %result = arith.addi %arg0, %arg1 : i32
  func.return %result : i32
}

해석:

  • 함수 이름: @add
  • 파라미터: %arg0 (i32), %arg1 (i32)
  • 반환 타입: i32
  • 본체: %arg0 + %arg1 계산
  • 반환: %result 값 반환

이것은 C의 int add(int arg0, int arg1) { return arg0 + arg1; }와 동일하다.

예시 2: 파라미터 없는 함수

func.func @get_constant() -> i32 {
  %c42 = arith.constant 42 : i32
  func.return %c42 : i32
}

파라미터가 없으면 괄호 내부가 비어있다: ().

예시 3: 다중 연산을 포함하는 함수

func.func @compute(%x: i32) -> i32 {
  %c2 = arith.constant 2 : i32
  %doubled = arith.muli %x, %c2 : i32
  %c10 = arith.constant 10 : i32
  %result = arith.addi %doubled, %c10 : i32
  func.return %result : i32
}

해석:

  • x * 2 + 10 계산
  • 중간 계산 (%doubled) 저장
  • 최종 결과 반환

func.call 연산: 함수 호출

func.call 연산은 함수를 호출한다. 함수 이름을 심볼 참조로 지정하고, 인자를 전달하고, 결과를 받는다.

Syntax:

%result = func.call @function_name(%arg0, %arg1, ...) : (type0, type1, ...) -> return_type

구성 요소:

  1. Callee (@function_name): 호출할 함수의 심볼 참조.
  2. Arguments (%arg0, %arg1): 함수에 전달할 인자 (SSA values).
  3. Function type annotation: 함수의 시그니처 (파라미터 타입과 반환 타입).
  4. Result (%result): 함수 호출의 결과 (SSA value).

예시 1: add 함수 호출

func.func @main() -> i32 {
  %c10 = arith.constant 10 : i32
  %c20 = arith.constant 20 : i32
  %sum = func.call @add(%c10, %c20) : (i32, i32) -> i32
  func.return %sum : i32
}

func.func @add(%arg0: i32, %arg1: i32) -> i32 {
  %result = arith.addi %arg0, %arg1 : i32
  func.return %result : i32
}

실행 흐름:

  1. @main 함수 시작
  2. %c10 = 10, %c20 = 20 생성
  3. @add 함수 호출 (인자: 10, 20)
  4. @add 내부: %arg0 = 10, %arg1 = 20
  5. %result = 10 + 20 = 30 계산
  6. @add 반환: 30
  7. @main에서 %sum = 30 저장
  8. @main 반환: 30

예시 2: 중첩 호출 (함수 결과를 다른 함수의 인자로 사용)

func.func @main() -> i32 {
  %c5 = arith.constant 5 : i32
  %doubled = func.call @double(%c5) : (i32) -> i32
  %result = func.call @double(%doubled) : (i32) -> i32
  func.return %result : i32
}

func.func @double(%x: i32) -> i32 {
  %c2 = arith.constant 2 : i32
  %result = arith.muli %x, %c2 : i32
  func.return %result : i32
}

실행:

  • double(5) → 10
  • double(10) → 20
  • 최종 결과: 20

func.return 연산: 함수 종료

func.return 연산은 함수를 종료하고 값을 반환한다. C의 return 문과 동일하다.

Syntax:

func.return %value : type

중요한 규칙:

  1. 모든 함수는 func.return으로 끝나야 한다: func.return은 terminator operation이다. 함수 본체의 마지막 연산이어야 한다.

  2. 반환 타입 일치: 반환 값의 타입은 함수 시그니처의 반환 타입과 일치해야 한다.

    // 올바름
    func.func @example() -> i32 {
      %c42 = arith.constant 42 : i32
      func.return %c42 : i32  // i32 반환 (시그니처와 일치)
    }
    
    // 오류: 타입 불일치
    func.func @wrong() -> i32 {
      %c1 = arith.constant 1 : i1  // i1 타입
      func.return %c1 : i1  // 오류! i32를 반환해야 함
    }
    
  3. Multiple returns (여러 반환 지점): 함수는 여러 반환 지점을 가질 수 있다 (조건부).

    func.func @abs(%x: i32) -> i32 {
      %c0 = arith.constant 0 : i32
      %is_negative = arith.cmpi slt, %x, %c0 : i32
      %result = scf.if %is_negative -> (i32) {
        %neg = arith.subi %c0, %x : i32
        scf.yield %neg : i32
      } else {
        scf.yield %x : i32
      }
      func.return %result : i32
    }
    

함수 가시성 (Visibility)

함수는 가시성(visibility) 속성을 가질 수 있다:

가시성의미사용 예
public (기본값)모듈 외부에서 접근 가능func.func @main() -> i32
private모듈 내부에서만 접근 가능func.func private @helper() -> i32
nested부모 함수 내부에서만 접근 가능 (Phase 4에서 다룸)

예시: private 함수 (헬퍼 함수)

// Public 함수 - 외부에서 호출 가능
func.func @main() -> i32 {
  %result = func.call @helper() : () -> i32
  func.return %result : i32
}

// Private 함수 - main에서만 호출 가능
func.func private @helper() -> i32 {
  %c42 = arith.constant 42 : i32
  func.return %c42 : i32
}

Phase 3에서는 모든 함수가 public이다 (기본값). 가시성을 명시할 필요가 없다.

함수와 심볼 테이블

MLIR 모듈은 **심볼 테이블(symbol table)**을 유지한다. 모든 func.func 연산은 모듈 레벨 심볼로 등록된다.

핵심 특성:

  1. Flat namespace (평면 네임스페이스): 모든 함수가 동일한 네임스페이스에 있다. 함수 정의 순서는 중요하지 않다.

  2. Forward references (전방 참조): 함수를 정의하기 전에 호출할 수 있다.

    // foo는 아직 정의되지 않았지만 호출 가능
    func.func @main() -> i32 {
      %result = func.call @foo() : () -> i32
      func.return %result : i32
    }
    
    // 나중에 정의됨
    func.func @foo() -> i32 {
      %c42 = arith.constant 42 : i32
      func.return %c42 : i32
    }
    
  3. 재귀 가능: 함수가 자기 자신을 호출할 수 있다 (심볼이 모듈에 등록되므로).

    func.func @factorial(%n: i32) -> i32 {
      %c1 = arith.constant 1 : i32
      %is_one = arith.cmpi sle, %n, %c1 : i32
      %result = scf.if %is_one -> (i32) {
        scf.yield %c1 : i32
      } else {
        %n_minus_1 = arith.subi %n, %c1 : i32
        %rec = func.call @factorial(%n_minus_1) : (i32) -> i32  // 재귀 호출
        %product = arith.muli %n, %rec : i32
        scf.yield %product : i32
      }
      func.return %result : i32
    }
    
  4. 상호 재귀 가능: 두 함수가 서로를 호출할 수 있다.

    func.func @is_even(%n: i32) -> i1 {
      // ... calls @is_odd ...
    }
    
    func.func @is_odd(%n: i32) -> i1 {
      // ... calls @is_even ...
    }
    

심볼 테이블 덕분에 함수 정의 순서나 전방 선언을 걱정할 필요가 없다. 모든 함수가 모듈 로드 시 등록된다.

Phase 2와의 비교: 함수 vs 표현식

Phase 2에서는 모든 것이 단일 표현식이었다:

// Phase 2 스타일 - 단일 main 함수
func.func @main() -> i32 {
  %c10 = arith.constant 10 : i32
  %c20 = arith.constant 20 : i32
  %sum = arith.addi %c10, %c20 : i32
  func.return %sum : i32
}

Phase 3에서는 재사용 가능한 함수를 정의한다:

// Phase 3 스타일 - 여러 함수
func.func @add(%a: i32, %b: i32) -> i32 {
  %result = arith.addi %a, %b : i32
  func.return %result : i32
}

func.func @main() -> i32 {
  %c10 = arith.constant 10 : i32
  %c20 = arith.constant 20 : i32
  %sum = func.call @add(%c10, %c20) : (i32, i32) -> i32
  func.return %sum : i32
}

차이점:

측면Phase 2 (표현식)Phase 3 (함수)
코드 조직단일 main 함수여러 함수 정의
재사용불가능 (중복 코드)가능 (함수 호출)
추상화없음함수 이름으로 추상화
모듈성낮음높음 (함수 단위)
컴파일 결과단일 함수여러 함수 심볼

함수는 코드를 모듈화하고 재사용 가능하게 만든다. Phase 2의 표현식 컴파일러를 함수 본체 내부에서 재사용한다!

AST 확장: FunDef와 App

FunLang에 함수를 추가하려면 AST를 확장해야 한다. 두 가지 새로운 노드가 필요하다:

  1. FunDef: 함수 정의 (let f x y = ...)
  2. App: 함수 적용 (호출) (f 10 20)

FunDef: 함수 정의

FunDef는 최상위 함수 정의를 표현한다.

F# AST 정의:

type Expr =
    | Int of int
    | Bool of bool
    | Var of string
    | BinOp of Expr * Operator * Expr
    | UnaryOp of UnaryOperator * Expr
    | Compare of Expr * CompareOp * Expr
    | Let of string * Expr * Expr
    | If of Expr * Expr * Expr
    | App of string * Expr list              // NEW: 함수 호출
    // ... Lambda는 Phase 4에서 추가 ...

type FunDef = {                               // NEW: 함수 정의
    name: string                              // 함수 이름
    parameters: string list                   // 파라미터 이름 리스트
    body: Expr                                // 함수 본체 (표현식)
}

type Program = {                              // NEW: 프로그램 구조
    functions: FunDef list                    // 함수 정의 리스트
    main: Expr                                // Main 표현식
}

예시: let add x y = x + y

let addFunction = {
    name = "add"
    parameters = ["x"; "y"]
    body = BinOp(Var "x", Add, Var "y")
}

구성 요소:

  • name: 함수 이름 ("add")
  • parameters: 파라미터 이름 리스트 (["x"; "y"])
  • body: 함수 본체 (x + y 표현식)

예시: let square x = x * x

let squareFunction = {
    name = "square"
    parameters = ["x"]
    body = BinOp(Var "x", Mul, Var "x")
}

예시: 파라미터가 없는 함수 let getConstant = 42

let constantFunction = {
    name = "getConstant"
    parameters = []                           // 빈 리스트
    body = Int 42
}

App: 함수 적용 (호출)

App는 함수 호출을 표현한다. 함수 이름과 인자 리스트를 포함한다.

F# AST 정의:

type Expr =
    | ...
    | App of string * Expr list               // 함수 이름, 인자 리스트

예시: add 10 20

let callExpr = App("add", [Int 10; Int 20])

구성 요소:

  • 함수 이름: "add"
  • 인자 리스트: [Int 10; Int 20]

예시: square 5

let squareCall = App("square", [Int 5])

예시: 중첩 호출 add (square 3) (square 4)

let nestedCall =
    App("add", [
        App("square", [Int 3]);
        App("square", [Int 4])
    ])

해석:

  • square 3 → 9
  • square 4 → 16
  • add 9 16 → 25

Program: 프로그램 구조

지금까지는 FunLang 프로그램이 단일 표현식이었다. 이제 여러 함수 정의 + main 표현식으로 구성된다.

F# 정의:

type Program = {
    functions: FunDef list                    // 함수 정의 리스트
    main: Expr                                // Main 표현식
}

예시 프로그램:

// FunLang 소스:
// let add x y = x + y
// let square x = x * x
// square (add 3 4)

let program = {
    functions = [
        { name = "add"
          parameters = ["x"; "y"]
          body = BinOp(Var "x", Add, Var "y") };
        { name = "square"
          parameters = ["x"]
          body = BinOp(Var "x", Mul, Var "x") }
    ]
    main = App("square", [App("add", [Int 3; Int 4])])
}

실행:

  1. add 3 4 → 7
  2. square 7 → 49
  3. 최종 결과: 49

프로그램 구조 시각화:

Program
├── functions
│   ├── FunDef("add", ["x", "y"], x + y)
│   └── FunDef("square", ["x"], x * x)
└── main
    └── App("square", [App("add", [3, 4])])

Lambda는 어디에?

함수형 언어의 핵심 기능인 **lambda (익명 함수)**는 어디에 있는가?

Phase 3 범위: 최상위 명명된 함수만

  • let f x = ... (함수 정의)
  • f 10 (함수 호출)

Phase 4에서 추가: Lambda와 클로저

  • fun x -> x + 1 (익명 함수)
  • let makeAdder n = fun x -> x + n (클로저, 외부 변수 캡처)
  • 함수를 값으로 전달 (고차 함수)

Phase 3 함수는 정적이다:

  • 컴파일 타임에 모든 함수가 알려진다
  • 함수 이름은 고정된 심볼이다
  • 외부 환경을 캡처하지 않는다

Phase 4 클로저는 동적이다:

  • 런타임에 클로저가 생성된다
  • 클로저는 값처럼 전달된다
  • 외부 환경을 캡처하고 유지한다

Phase 3은 함수의 기초를 다진다. Phase 4는 그 위에 클로저를 추가한다.

P/Invoke 바인딩: func 다이얼렉트

MLIR의 func 다이얼렉트 연산을 사용하려면 C API 바인딩이 필요하다. 이미 Phase 1에서 기본 바인딩을 작성했으므로, func 관련 함수를 추가한다.

Function Type API

MLIR에서 함수는 function type을 가진다. Function type은 파라미터 타입과 반환 타입을 표현한다.

Function type 생성:

// C API
MlirType mlirFunctionTypeGet(
    MlirContext ctx,
    intptr_t numInputs,
    MlirType const *inputs,
    intptr_t numResults,
    MlirType const *results
);

파라미터:

  • ctx: MLIR context
  • numInputs: 파라미터 개수
  • inputs: 파라미터 타입 배열
  • numResults: 반환 값 개수 (보통 0 또는 1)
  • results: 반환 타입 배열

예시: (i32, i32) -> i32 타입

MlirType i32Type = mlirIntegerTypeGet(ctx, 32);
MlirType paramTypes[] = { i32Type, i32Type };  // 두 개의 i32 파라미터
MlirType resultTypes[] = { i32Type };          // 하나의 i32 반환값

MlirType funcType = mlirFunctionTypeGet(
    ctx,
    2, paramTypes,   // 2개 파라미터
    1, resultTypes   // 1개 반환값
);

Function type 쿼리:

// 파라미터 개수 가져오기
intptr_t mlirFunctionTypeGetNumInputs(MlirType type);

// 반환 값 개수 가져오기
intptr_t mlirFunctionTypeGetNumResults(MlirType type);

// N번째 파라미터 타입 가져오기
MlirType mlirFunctionTypeGetInput(MlirType type, intptr_t pos);

// N번째 반환 타입 가져오기
MlirType mlirFunctionTypeGetResult(MlirType type, intptr_t pos);

F# P/Invoke 바인딩:

// MlirBindings.fs에 추가

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType mlirFunctionTypeGet(
    MlirContext ctx,
    nativeint numInputs,
    [<MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1s)>] MlirType[] inputs,
    nativeint numResults,
    [<MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3s)>] MlirType[] results
)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern nativeint mlirFunctionTypeGetNumInputs(MlirType funcType)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern nativeint mlirFunctionTypeGetNumResults(MlirType funcType)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType mlirFunctionTypeGetInput(MlirType funcType, nativeint pos)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType mlirFunctionTypeGetResult(MlirType funcType, nativeint pos)

사용 예시:

// (i32, i32) -> i32 타입 생성
let i32Type = mlirIntegerTypeGet(ctx, 32u)
let paramTypes = [| i32Type; i32Type |]
let resultTypes = [| i32Type |]

let funcType = mlirFunctionTypeGet(
    ctx,
    2n, paramTypes,
    1n, resultTypes
)

// 타입 쿼리
let numParams = mlirFunctionTypeGetNumInputs(funcType)  // 2
let param0Type = mlirFunctionTypeGetInput(funcType, 0n)  // i32

Symbol Reference Attribute

함수 호출 시 symbol reference가 필요하다. 심볼 참조는 @function_name 형태로, attribute로 표현된다.

C API:

// Flat symbol reference (단일 심볼)
MlirAttribute mlirFlatSymbolRefAttrGet(
    MlirContext ctx,
    MlirStringRef symbol
);

F# P/Invoke 바인딩:

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirAttribute mlirFlatSymbolRefAttrGet(
    MlirContext ctx,
    MlirStringRef symbol
)

사용 예시:

// @add 심볼 참조 생성
let addSymbol = MlirStringRef.FromString("add")
let addSymbolAttr = mlirFlatSymbolRefAttrGet(ctx, addSymbol)

Generic Operation Creation for func.func

MLIR C API는 func.func 전용 생성 함수를 제공하지 않는다. 대신 generic operation creation을 사용한다.

func.func 연산 생성 단계:

  1. Operation state 초기화

    MlirOperationState state = mlirOperationStateGet(
        mlirStringRefCreateFromCString("func.func"),
        location
    );
    
  2. Attributes 추가 (sym_name, function_type)

    // sym_name: 함수 이름
    MlirAttribute nameAttr = mlirStringAttrGet(ctx, nameStringRef);
    MlirNamedAttribute symNameAttr = {
        mlirIdentifierGet(ctx, mlirStringRefCreateFromCString("sym_name")),
        nameAttr
    };
    
    // function_type: 함수 타입
    MlirAttribute typeAttr = mlirTypeAttrGet(functionType);
    MlirNamedAttribute funcTypeAttr = {
        mlirIdentifierGet(ctx, mlirStringRefCreateFromCString("function_type")),
        typeAttr
    };
    
    MlirNamedAttribute attrs[] = { symNameAttr, funcTypeAttr };
    mlirOperationStateAddAttributes(&state, 2, attrs);
    
  3. Region 추가 (함수 본체)

    MlirRegion bodyRegion = mlirRegionCreate();
    MlirBlock entryBlock = mlirBlockCreate(numParams, paramTypes, NULL);
    mlirRegionAppendOwnedBlock(bodyRegion, entryBlock);
    mlirOperationStateAddOwnedRegions(&state, 1, &bodyRegion);
    
  4. Operation 생성

    MlirOperation funcOp = mlirOperationCreate(&state);
    

F# 헬퍼 함수 (OpBuilder에 추가 예정):

// OpBuilder.fs에 추가할 메서드 (다음 섹션에서 구현)
member this.CreateFuncOp(name: string, paramTypes: MlirType[], resultType: MlirType) =
    // ... implementation ...

Generic Operation Creation for func.call

func.call 연산 생성 단계:

  1. Operation state 초기화

    MlirOperationState state = mlirOperationStateGet(
        mlirStringRefCreateFromCString("func.call"),
        location
    );
    
  2. Callee attribute 추가

    MlirAttribute calleeAttr = mlirFlatSymbolRefAttrGet(ctx, calleeSymbol);
    MlirNamedAttribute attr = {
        mlirIdentifierGet(ctx, mlirStringRefCreateFromCString("callee")),
        calleeAttr
    };
    mlirOperationStateAddAttributes(&state, 1, &attr);
    
  3. Operands 추가 (인자)

    mlirOperationStateAddOperands(&state, numArgs, argValues);
    
  4. Result types 추가

    mlirOperationStateAddResults(&state, 1, &resultType);
    
  5. Operation 생성

    MlirOperation callOp = mlirOperationCreate(&state);
    

F# 헬퍼 함수:

member this.CreateFuncCall(calleeName: string, args: MlirValue[], resultType: MlirType) =
    // ... implementation ...

Complete MlirBindings.fs Additions

전체 추가 코드 (MlirBindings.fs):

// ============================================================
// Function Type API
// ============================================================

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType mlirFunctionTypeGet(
    MlirContext ctx,
    nativeint numInputs,
    [<MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1s)>] MlirType[] inputs,
    nativeint numResults,
    [<MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3s)>] MlirType[] results
)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern nativeint mlirFunctionTypeGetNumInputs(MlirType funcType)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern nativeint mlirFunctionTypeGetNumResults(MlirType funcType)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType mlirFunctionTypeGetInput(MlirType funcType, nativeint pos)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType mlirFunctionTypeGetResult(MlirType funcType, nativeint pos)

// ============================================================
// Symbol Reference Attribute
// ============================================================

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirAttribute mlirFlatSymbolRefAttrGet(
    MlirContext ctx,
    MlirStringRef symbol
)

// ============================================================
// Block Arguments (for function parameters)
// ============================================================

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirValue mlirBlockGetArgument(
    MlirBlock block,
    nativeint pos
)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern nativeint mlirBlockGetNumArguments(MlirBlock block)

설명:

  • mlirFunctionTypeGet: 함수 타입 생성
  • mlirFunctionTypeGetInput/GetResult: 함수 타입 쿼리
  • mlirFlatSymbolRefAttrGet: 심볼 참조 attribute 생성
  • mlirBlockGetArgument: 블록의 N번째 argument 가져오기 (함수 파라미터)
  • mlirBlockGetNumArguments: 블록의 argument 개수 (파라미터 개수)

이 바인딩으로 func 다이얼렉트의 모든 연산을 생성할 수 있다!

OpBuilder 확장: func 연산 헬퍼

P/Invoke 바인딩은 저수준 API다. 사용하기 편리한 F# 헬퍼 메서드를 OpBuilder 클래스에 추가한다.

CreateFuncOp: 함수 생성

목적: func.func 연산을 생성한다. 함수 이름, 파라미터 타입, 반환 타입을 받아 함수 operation을 반환한다.

시그니처:

member this.CreateFuncOp(
    name: string,
    paramTypes: MlirType[],
    resultType: MlirType
) : MlirOperation

구현:

member this.CreateFuncOp(name: string, paramTypes: MlirType[], resultType: MlirType) =
    let loc = this.UnknownLoc()

    // 1. Function type 생성
    let resultTypes = [| resultType |]
    let funcType = mlirFunctionTypeGet(
        this.Context,
        nativeint paramTypes.Length, paramTypes,
        1n, resultTypes
    )

    // 2. Operation state 초기화
    let opName = MlirStringRef.FromString("func.func")
    let mutable state = mlirOperationStateGet(opName, loc)

    // 3. sym_name attribute 추가
    let nameStr = MlirStringRef.FromString(name)
    let nameAttr = mlirStringAttrGet(this.Context, nameStr)
    let symNameId = mlirIdentifierGet(this.Context, MlirStringRef.FromString("sym_name"))
    let mutable symNameAttr = MlirNamedAttribute(symNameId, nameAttr)

    // 4. function_type attribute 추가
    let typeAttr = mlirTypeAttrGet(funcType)
    let funcTypeId = mlirIdentifierGet(this.Context, MlirStringRef.FromString("function_type"))
    let mutable funcTypeAttr = MlirNamedAttribute(funcTypeId, typeAttr)

    // 5. Attributes 추가
    let attrs = [| symNameAttr; funcTypeAttr |]
    mlirOperationStateAddAttributes(&state, 2n, attrs)

    // 6. Body region 생성 (entry block with parameters)
    let bodyRegion = mlirRegionCreate()
    let entryBlock = mlirBlockCreate(
        nativeint paramTypes.Length,
        paramTypes,
        Array.zeroCreate paramTypes.Length  // Location array (null array)
    )
    mlirRegionAppendOwnedBlock(bodyRegion, entryBlock)

    let regions = [| bodyRegion |]
    mlirOperationStateAddOwnedRegions(&state, 1n, regions)

    // 7. Operation 생성
    let funcOp = mlirOperationCreate(&state)
    funcOp

사용 예시:

let builder = new OpBuilder(ctx, module)

// func.func @add(%arg0: i32, %arg1: i32) -> i32
let funcOp = builder.CreateFuncOp(
    "add",
    [| i32Type; i32Type |],
    i32Type
)

// 이제 funcOp 내부에 body를 추가해야 한다

핵심 포인트:

  • paramTypes는 블록 arguments의 타입이 된다
  • Entry block이 자동으로 생성되고 region에 추가된다
  • 반환된 MlirOperation은 아직 비어있는 함수 (body를 채워야 함)

GetFunctionEntryBlock: entry block 가져오기

함수 본체를 작성하려면 entry block을 가져와야 한다.

시그니처:

member this.GetFunctionEntryBlock(funcOp: MlirOperation) : MlirBlock

구현:

member this.GetFunctionEntryBlock(funcOp: MlirOperation) =
    // func.func operation은 region을 하나 가진다
    let bodyRegion = mlirOperationGetRegion(funcOp, 0n)
    // Region의 첫 번째 block이 entry block
    mlirRegionGetFirstBlock(bodyRegion)

사용 예시:

let funcOp = builder.CreateFuncOp("add", [| i32Type; i32Type |], i32Type)
let entryBlock = builder.GetFunctionEntryBlock(funcOp)

// 이제 entryBlock에 연산을 추가할 수 있다
builder.SetInsertionPointToEnd(entryBlock)

GetFunctionBlockArg: 파라미터 가져오기

함수 파라미터는 entry block의 block arguments로 표현된다. 파라미터를 사용하려면 block argument를 가져와야 한다.

시그니처:

member this.GetFunctionBlockArg(block: MlirBlock, index: int) : MlirValue

구현:

member this.GetFunctionBlockArg(block: MlirBlock, index: int) =
    mlirBlockGetArgument(block, nativeint index)

사용 예시:

let funcOp = builder.CreateFuncOp("add", [| i32Type; i32Type |], i32Type)
let entryBlock = builder.GetFunctionEntryBlock(funcOp)

// 파라미터 가져오기
let arg0 = builder.GetFunctionBlockArg(entryBlock, 0)  // %arg0
let arg1 = builder.GetFunctionBlockArg(entryBlock, 1)  // %arg1

// 파라미터를 사용하여 연산 수행
builder.SetInsertionPointToEnd(entryBlock)
let sum = builder.CreateArithBinaryOp(ArithOp.Addi, arg0, arg1, i32Type)

CreateFuncCall: 함수 호출 생성

시그니처:

member this.CreateFuncCall(
    calleeName: string,
    args: MlirValue[],
    resultType: MlirType
) : MlirValue

구현:

member this.CreateFuncCall(calleeName: string, args: MlirValue[], resultType: MlirType) =
    let loc = this.UnknownLoc()

    // 1. Operation state 초기화
    let opName = MlirStringRef.FromString("func.call")
    let mutable state = mlirOperationStateGet(opName, loc)

    // 2. callee attribute 추가
    let calleeSymbol = MlirStringRef.FromString(calleeName)
    let calleeAttr = mlirFlatSymbolRefAttrGet(this.Context, calleeSymbol)
    let calleeId = mlirIdentifierGet(this.Context, MlirStringRef.FromString("callee"))
    let mutable calleeNamedAttr = MlirNamedAttribute(calleeId, calleeAttr)

    mlirOperationStateAddAttributes(&state, 1n, [| calleeNamedAttr |])

    // 3. Operands 추가
    mlirOperationStateAddOperands(&state, nativeint args.Length, args)

    // 4. Result type 추가
    mlirOperationStateAddResults(&state, 1n, [| resultType |])

    // 5. Operation 생성
    let callOp = mlirOperationCreate(&state)

    // 6. 현재 insertion point에 추가
    mlirBlockAppendOwnedOperation(this.currentBlock, callOp)

    // 7. Result value 반환
    mlirOperationGetResult(callOp, 0n)

사용 예시:

builder.SetInsertionPointToEnd(mainBlock)

// func.call @add(%c10, %c20) : (i32, i32) -> i32
let c10 = builder.CreateConstant(10, i32Type)
let c20 = builder.CreateConstant(20, i32Type)
let result = builder.CreateFuncCall("add", [| c10; c20 |], i32Type)

CreateFuncReturn: 함수 반환

시그니처:

member this.CreateFuncReturn(value: MlirValue) : unit

구현:

member this.CreateFuncReturn(value: MlirValue) =
    let loc = this.UnknownLoc()

    // 1. Operation state 초기화
    let opName = MlirStringRef.FromString("func.return")
    let mutable state = mlirOperationStateGet(opName, loc)

    // 2. Operand 추가 (반환 값)
    mlirOperationStateAddOperands(&state, 1n, [| value |])

    // 3. Operation 생성
    let returnOp = mlirOperationCreate(&state)

    // 4. 현재 insertion point에 추가
    mlirBlockAppendOwnedOperation(this.currentBlock, returnOp)

사용 예시:

builder.SetInsertionPointToEnd(entryBlock)
let sum = builder.CreateArithBinaryOp(ArithOp.Addi, arg0, arg1, i32Type)
builder.CreateFuncReturn(sum)

완전한 함수 생성 예시

전체 흐름 (add 함수 생성):

let builder = new OpBuilder(ctx, module)
let i32Type = builder.I32Type()

// 1. 함수 operation 생성
let funcOp = builder.CreateFuncOp("add", [| i32Type; i32Type |], i32Type)

// 2. Entry block 가져오기
let entryBlock = builder.GetFunctionEntryBlock(funcOp)

// 3. 파라미터 가져오기
let arg0 = builder.GetFunctionBlockArg(entryBlock, 0)
let arg1 = builder.GetFunctionBlockArg(entryBlock, 1)

// 4. Insertion point 설정
builder.SetInsertionPointToEnd(entryBlock)

// 5. 함수 본체 작성
let sum = builder.CreateArithBinaryOp(ArithOp.Addi, arg0, arg1, i32Type)

// 6. 반환
builder.CreateFuncReturn(sum)

// 7. 모듈에 함수 추가
builder.AddOperationToModule(funcOp)

생성된 MLIR IR:

func.func @add(%arg0: i32, %arg1: i32) -> i32 {
  %0 = arith.addi %arg0, %arg1 : i32
  func.return %0 : i32
}

이 헬퍼 메서드들로 func 다이얼렉트 연산을 쉽게 생성할 수 있다!

함수 파라미터와 Block Arguments

함수 파라미터는 MLIR에서 block arguments로 표현된다. 이것은 MLIR의 핵심 설계 원칙이며, Chapter 08에서 배운 block arguments 개념의 확장이다.

파라미터는 변수가 아니다

전통적인 프로그래밍 언어에서 함수 파라미터는 “변수“처럼 보인다:

// C 함수
int add(int x, int y) {
    return x + y;
}

하지만 MLIR에서 파라미터는 block arguments다:

func.func @add(%arg0: i32, %arg1: i32) -> i32 {
  %result = arith.addi %arg0, %arg1 : i32
  func.return %result : i32
}

차이점:

관점변수 (C/Java)Block Arguments (MLIR)
저장 위치스택 메모리 (또는 레지스터)SSA value (레지스터 직접 사용)
초기화함수 진입 시 스택에 복사블록 진입 시 이미 존재
뮤테이션가능 (재할당 가능)불가능 (SSA, 한 번만 정의)
주소주소 가져오기 가능 (&x)주소 없음 (값 자체)

MLIR에서 파라미터는 이미 존재하는 SSA value다. 함수가 호출되면, 인자 값들이 entry block의 arguments로 전달된다.

Block Arguments 복습 (Chapter 08 연결)

Chapter 08에서 scf.if의 block arguments를 배웠다:

%result = scf.if %condition -> (i32) {
  %c10 = arith.constant 10 : i32
  scf.yield %c10 : i32
} else {
  %c20 = arith.constant 20 : i32
  scf.yield %c20 : i32
}
// %result는 block argument (scf.if의 결과)

함수 파라미터도 동일한 메커니즘이다:

func.func @example(%arg0: i32) -> i32 {
  // %arg0는 entry block의 argument
  func.return %arg0 : i32
}

공통점:

  • 둘 다 block arguments
  • 둘 다 SSA values다
  • 둘 다 블록 진입 시 이미 정의되어 있다

차이점:

  • scf.if block arguments: 분기의 결과 값 (yield로 전달)
  • 함수 block arguments: 함수의 입력 값 (호출자가 전달)

Entry Block과 파라미터

함수의 entry block은 함수 정의 시 자동으로 생성된다. 파라미터 개수만큼 block arguments를 가진다.

예시: 파라미터가 3개인 함수

func.func @sum3(%arg0: i32, %arg1: i32, %arg2: i32) -> i32 {
  // Entry block은 3개의 arguments를 가진다:
  // - %arg0 (첫 번째 파라미터)
  // - %arg1 (두 번째 파라미터)
  // - %arg2 (세 번째 파라미터)

  %sum01 = arith.addi %arg0, %arg1 : i32
  %sum012 = arith.addi %sum01, %arg2 : i32
  func.return %sum012 : i32
}

MLIR IR 구조 시각화:

func.func @sum3(...) {
^entry(%arg0: i32, %arg1: i32, %arg2: i32):
    // %arg0, %arg1, %arg2는 block arguments
    %sum01 = arith.addi %arg0, %arg1
    %sum012 = arith.addi %sum01, %arg2
    func.return %sum012
}

Entry block의 arguments는 함수 시그니처의 파라미터와 1:1 대응된다.

파라미터와 환경 (Environment)

Chapter 07에서 let 바인딩을 위한 **환경(environment)**을 구현했다:

type Environment = Map<string, MlirValue>

함수 파라미터도 환경에 추가해야 한다. 하지만 let 바인딩과는 다른 방식으로 처리한다:

Let 바인딩:

  • 표현식을 컴파일하여 SSA value 생성
  • 환경에 추가
  • 본체 표현식 컴파일

함수 파라미터:

  • Block arguments로 이미 존재
  • 환경에 추가 (이름 → block argument 매핑)
  • 본체 표현식 컴파일

코드 비교:

// Let 바인딩 (Phase 2)
| Let(name, valueExpr, bodyExpr) ->
    let value = compileExpr builder env valueExpr  // 표현식 컴파일
    let newEnv = Map.add name value env            // 환경 확장
    compileExpr builder newEnv bodyExpr

// 함수 파라미터 (Phase 3)
let compileFuncDef builder (funcDef: FunDef) =
    // ...
    let entryBlock = builder.GetFunctionEntryBlock(funcOp)

    // 파라미터를 환경에 추가
    let initialEnv =
        funcDef.parameters
        |> List.mapi (fun i name ->
            let arg = builder.GetFunctionBlockArg(entryBlock, i)
            (name, arg)
        )
        |> Map.ofList

    // 본체 컴파일 (환경 전달)
    let bodyValue = compileExpr builder initialEnv funcDef.body
    builder.CreateFuncReturn(bodyValue)

핵심 차이:

  • Let 바인딩: compileExpr로 value 생성
  • 함수 파라미터: GetFunctionBlockArg로 기존 value 가져오기

예시: 함수 본체에서 파라미터 사용

FunLang 소스:

let double x = x + x

AST:

{
    name = "double"
    parameters = ["x"]
    body = BinOp(Var "x", Add, Var "x")
}

컴파일 과정:

  1. 함수 operation 생성

    let funcOp = builder.CreateFuncOp("double", [| i32Type |], i32Type)
    
  2. Entry block 가져오기

    let entryBlock = builder.GetFunctionEntryBlock(funcOp)
    
  3. 파라미터를 환경에 추가

    let arg0 = builder.GetFunctionBlockArg(entryBlock, 0)  // %arg0
    let env = Map.ofList [("x", arg0)]
    
  4. 본체 컴파일 (x + x)

    builder.SetInsertionPointToEnd(entryBlock)
    
    // BinOp(Var "x", Add, Var "x")
    // Var "x" → 환경에서 조회 → %arg0
    let lhs = env.["x"]  // %arg0
    let rhs = env.["x"]  // %arg0
    let sum = builder.CreateArithBinaryOp(ArithOp.Addi, lhs, rhs, i32Type)
    
  5. 반환

    builder.CreateFuncReturn(sum)
    

생성된 MLIR IR:

func.func @double(%arg0: i32) -> i32 {
  %0 = arith.addi %arg0, %arg0 : i32
  func.return %0 : i32
}

Let 바인딩 vs 함수 파라미터 구분

함수 본체 내부에서 let 바인딩과 파라미터를 모두 사용할 수 있다:

FunLang 소스:

let compute x y =
    let doubled = x + x in
    doubled + y

환경 변화 추적:

// 1. 초기 환경 (파라미터만)
env = { "x" -> %arg0, "y" -> %arg1 }

// 2. Let 바인딩 처리
// let doubled = x + x
let doubledValue = arith.addi %arg0, %arg0
env = { "x" -> %arg0, "y" -> %arg1, "doubled" -> %0 }

// 3. 본체 표현식 (doubled + y)
let result = arith.addi %0, %arg1

생성된 MLIR IR:

func.func @compute(%arg0: i32, %arg1: i32) -> i32 {
  %doubled = arith.addi %arg0, %arg0 : i32   // let doubled = x + x
  %result = arith.addi %doubled, %arg1 : i32 // doubled + y
  func.return %result : i32
}

결론: 파라미터와 let 바인딩 모두 환경을 통해 관리된다. 차이점은 value의 출처뿐이다 (block argument vs 컴파일된 표현식).

코드 생성: 함수 정의

이제 FunLang 함수 정의 (FunDef)를 MLIR func.func 연산으로 컴파일하는 compileFuncDef 함수를 작성한다.

compileFuncDef 시그니처

let compileFuncDef (builder: OpBuilder) (funcDef: FunDef) : unit =
    // ...

입력:

  • builder: OpBuilder (MLIR IR 생성 도구)
  • funcDef: FunDef (FunLang 함수 정의)

출력:

  • unit (모듈에 함수를 추가하는 부수 효과)

단계별 구현

Step 1: 타입 준비

파라미터 타입과 반환 타입을 준비한다. Phase 3에서는 모든 값이 i32다.

let i32Type = builder.I32Type()
let paramTypes = Array.create funcDef.parameters.Length i32Type
let resultType = i32Type

Step 2: 함수 operation 생성

let funcOp = builder.CreateFuncOp(funcDef.name, paramTypes, resultType)

Step 3: Entry block 가져오기

let entryBlock = builder.GetFunctionEntryBlock(funcOp)

Step 4: 초기 환경 구축 (파라미터 → block arguments)

let initialEnv =
    funcDef.parameters
    |> List.mapi (fun i paramName ->
        let arg = builder.GetFunctionBlockArg(entryBlock, i)
        (paramName, arg)
    )
    |> Map.ofList

Step 5: Insertion point 설정

builder.SetInsertionPointToEnd(entryBlock)

Step 6: 본체 표현식 컴파일

let bodyValue = compileExpr builder initialEnv funcDef.body

compileExpr는 Phase 2에서 작성한 함수다. 환경을 받아서 표현식을 컴파일한다.

Step 7: func.return 삽입

builder.CreateFuncReturn(bodyValue)

Step 8: 모듈에 함수 추가

builder.AddOperationToModule(funcOp)

완전한 compileFuncDef 구현

let compileFuncDef (builder: OpBuilder) (funcDef: FunDef) : unit =
    // 1. 타입 준비
    let i32Type = builder.I32Type()
    let paramTypes = Array.create funcDef.parameters.Length i32Type
    let resultType = i32Type

    // 2. 함수 operation 생성
    let funcOp = builder.CreateFuncOp(funcDef.name, paramTypes, resultType)

    // 3. Entry block 가져오기
    let entryBlock = builder.GetFunctionEntryBlock(funcOp)

    // 4. 초기 환경 구축 (파라미터 → block arguments)
    let initialEnv =
        funcDef.parameters
        |> List.mapi (fun i paramName ->
            let arg = builder.GetFunctionBlockArg(entryBlock, i)
            (paramName, arg)
        )
        |> Map.ofList

    // 5. Insertion point 설정
    builder.SetInsertionPointToEnd(entryBlock)

    // 6. 본체 표현식 컴파일
    let bodyValue = compileExpr builder initialEnv funcDef.body

    // 7. func.return 삽입
    builder.CreateFuncReturn(bodyValue)

    // 8. 모듈에 함수 추가
    builder.AddOperationToModule(funcOp)

예시: let double x = x + x

FunDef:

{
    name = "double"
    parameters = ["x"]
    body = BinOp(Var "x", Add, Var "x")
}

compileFuncDef 실행 과정:

  1. paramTypes = [| i32Type |], resultType = i32Type
  2. funcOp = CreateFuncOp("double", [| i32 |], i32)
  3. entryBlock = GetFunctionEntryBlock(funcOp)
  4. arg0 = GetFunctionBlockArg(entryBlock, 0), env = { "x" -> %arg0 }
  5. SetInsertionPointToEnd(entryBlock)
  6. bodyValue = compileExpr builder env (BinOp(Var "x", Add, Var "x"))
    • Var "x"env.["x"]%arg0
    • arith.addi %arg0, %arg0
  7. CreateFuncReturn(bodyValue)
  8. AddOperationToModule(funcOp)

생성된 MLIR IR:

func.func @double(%arg0: i32) -> i32 {
  %0 = arith.addi %arg0, %arg0 : i32
  func.return %0 : i32
}

예시: let add x y = x + y

FunDef:

{
    name = "add"
    parameters = ["x"; "y"]
    body = BinOp(Var "x", Add, Var "y")
}

생성된 MLIR IR:

func.func @add(%arg0: i32, %arg1: i32) -> i32 {
  %0 = arith.addi %arg0, %arg1 : i32
  func.return %0 : i32
}

복잡한 예시: let compute x y = (x + x) + y

FunDef:

{
    name = "compute"
    parameters = ["x"; "y"]
    body = BinOp(
        BinOp(Var "x", Add, Var "x"),
        Add,
        Var "y"
    )
}

생성된 MLIR IR:

func.func @compute(%arg0: i32, %arg1: i32) -> i32 {
  %0 = arith.addi %arg0, %arg0 : i32      // x + x
  %1 = arith.addi %0, %arg1 : i32         // (x + x) + y
  func.return %1 : i32
}

compileExpr가 재귀적으로 호출되어 중첩된 연산을 처리한다!

코드 생성: 함수 호출

함수를 정의했으니 이제 호출할 수 있어야 한다. 함수 호출은 App 노드로 표현되며, compileExpr에 새로운 case를 추가한다.

App case 추가

compileExpr 확장:

let rec compileExpr (builder: OpBuilder) (env: Environment) (expr: Expr) : MlirValue =
    match expr with
    | Int n -> builder.CreateConstant(n, builder.I32Type())
    | Bool b -> builder.CreateConstant((if b then 1 else 0), builder.I1Type())
    | Var name ->
        match Map.tryFind name env with
        | Some value -> value
        | None -> failwithf "Unbound variable: %s" name
    | BinOp(lhs, op, rhs) ->
        let lhsValue = compileExpr builder env lhs
        let rhsValue = compileExpr builder env rhs
        builder.CreateArithBinaryOp(op, lhsValue, rhsValue, builder.I32Type())
    | Compare(lhs, op, rhs) ->
        let lhsValue = compileExpr builder env lhs
        let rhsValue = compileExpr builder env rhs
        builder.CreateArithCompare(op, lhsValue, rhsValue)
    | Let(name, valueExpr, bodyExpr) ->
        let value = compileExpr builder env valueExpr
        let newEnv = Map.add name value env
        compileExpr builder newEnv bodyExpr
    | If(condition, thenExpr, elseExpr) ->
        let condValue = compileExpr builder env condition
        compileIfExpr builder env condValue thenExpr elseExpr
    | App(calleeName, argExprs) ->                  // NEW: 함수 호출
        // Step 1: 인자 표현식들을 컴파일
        let argValues =
            argExprs
            |> List.map (compileExpr builder env)
            |> List.toArray

        // Step 2: 함수 호출 생성
        let resultType = builder.I32Type()
        builder.CreateFuncCall(calleeName, argValues, resultType)

App case 설명

Step 1: 인자 컴파일

함수 호출 전에 모든 인자 표현식을 먼저 컴파일한다 (call-by-value 의미론).

let argValues =
    argExprs
    |> List.map (compileExpr builder env)
    |> List.toArray

예시:

// add (5 + 3) (10 * 2)
App("add", [
    BinOp(Int 5, Add, Int 3);
    BinOp(Int 10, Mul, Int 2)
])

인자 컴파일 결과:

  • 5 + 3%0 = arith.addi ... (8)
  • 10 * 2%1 = arith.muli ... (20)
  • argValues = [| %0; %1 |]

Step 2: 함수 호출 생성

let resultType = builder.I32Type()
builder.CreateFuncCall(calleeName, argValues, resultType)

CreateFuncCallfunc.call 연산을 생성하고 결과 SSA value를 반환한다.

예시: double 5

FunLang 표현식:

App("double", [Int 5])

컴파일 과정:

  1. 인자 컴파일: Int 5%c5 = arith.constant 5 : i32
  2. 함수 호출: CreateFuncCall("double", [| %c5 |], i32Type)

생성된 MLIR IR:

%c5 = arith.constant 5 : i32
%0 = func.call @double(%c5) : (i32) -> i32

예시: add 10 20

FunLang 표현식:

App("add", [Int 10; Int 20])

생성된 MLIR IR:

%c10 = arith.constant 10 : i32
%c20 = arith.constant 20 : i32
%0 = func.call @add(%c10, %c20) : (i32, i32) -> i32

중첩 호출 예시: double (add 3 4)

FunLang 표현식:

App("double", [
    App("add", [Int 3; Int 4])
])

컴파일 과정:

  1. 외부 호출의 인자 컴파일: App("add", [Int 3; Int 4])
    • 내부 호출의 인자 컴파일: Int 3%c3, Int 4%c4
    • 내부 호출: %inner = func.call @add(%c3, %c4)
  2. 외부 호출: %result = func.call @double(%inner)

생성된 MLIR IR:

%c3 = arith.constant 3 : i32
%c4 = arith.constant 4 : i32
%inner = func.call @add(%c3, %c4) : (i32, i32) -> i32
%result = func.call @double(%inner) : (i32) -> i32

중첩 호출이 자연스럽게 처리된다!

코드 생성: Program 컴파일

이제 전체 프로그램을 컴파일하는 compileProgram 함수를 작성한다. Program은 여러 함수 정의와 main 표현식으로 구성된다.

compileProgram 시그니처

let compileProgram (builder: OpBuilder) (program: Program) : unit =
    // ...

입력:

  • builder: OpBuilder
  • program: Program (함수 정의 리스트 + main 표현식)

출력:

  • unit (모듈에 함수들과 main을 추가)

단계별 구현

Step 1: 모든 함수 정의 컴파일

// 함수 정의들을 모듈에 추가
program.functions
|> List.iter (compileFuncDef builder)

각 FunDef를 compileFuncDef로 컴파일하여 모듈에 추가한다.

Step 2: Main 함수 생성

Main 표현식을 @funlang_main 함수로 컴파일한다. 이 함수가 프로그램의 진입점이 된다.

// Main 함수 생성
let i32Type = builder.I32Type()
let mainFuncOp = builder.CreateFuncOp("funlang_main", [||], i32Type)
let mainBlock = builder.GetFunctionEntryBlock(mainFuncOp)
builder.SetInsertionPointToEnd(mainBlock)

// Main 표현식 컴파일 (빈 환경)
let resultValue = compileExpr builder Map.empty program.main

// Main 반환
builder.CreateFuncReturn(resultValue)
builder.AddOperationToModule(mainFuncOp)

Step 3 (선택적): C main 함수 생성

실행 가능한 바이너리를 만들려면 C의 main 함수가 필요하다. runtime.c에서 제공한다 (Chapter 09 참조).

// runtime.c
int funlang_main();

int main() {
    return funlang_main();
}

완전한 compileProgram 구현

let compileProgram (builder: OpBuilder) (program: Program) : unit =
    // 1. 모든 함수 정의 컴파일
    program.functions
    |> List.iter (compileFuncDef builder)

    // 2. Main 함수 생성 (프로그램 진입점)
    let i32Type = builder.I32Type()
    let mainFuncOp = builder.CreateFuncOp("funlang_main", [||], i32Type)
    let mainBlock = builder.GetFunctionEntryBlock(mainFuncOp)
    builder.SetInsertionPointToEnd(mainBlock)

    // 3. Main 표현식 컴파일 (빈 환경 - 함수 파라미터 없음)
    let resultValue = compileExpr builder Map.empty program.main

    // 4. Main 반환
    builder.CreateFuncReturn(resultValue)
    builder.AddOperationToModule(mainFuncOp)

함수 정의 순서와 심볼 테이블

중요한 특성: 함수 정의 순서는 중요하지 않다!

MLIR 모듈의 심볼 테이블은 flat namespace다. 모든 func.func 연산이 모듈 로드 시 등록되므로, 정의 순서와 무관하게 호출할 수 있다.

예시:

// 함수 정의 순서
let program = {
    functions = [
        { name = "bar"; parameters = []; body = App("foo", []) };  // foo를 호출
        { name = "foo"; parameters = []; body = Int 42 }           // foo 정의
    ]
    main = App("bar", [])
}

barfoo를 호출하지만, foo는 나중에 정의된다. MLIR에서는 문제없다:

func.func @bar() -> i32 {
  %0 = func.call @foo() : () -> i32  // 전방 참조
  func.return %0 : i32
}

func.func @foo() -> i32 {
  %c42 = arith.constant 42 : i32
  func.return %c42 : i32
}

모든 함수가 모든 함수를 볼 수 있다

Flat namespace 덕분에 상호 재귀도 가능하다 (Chapter 11에서 자세히 다룸).

func.func @is_even(%n: i32) -> i1 {
  // ... calls @is_odd ...
}

func.func @is_odd(%n: i32) -> i1 {
  // ... calls @is_even ...
}

정의 순서와 무관하게 모든 함수가 서로를 참조할 수 있다.

완전한 예시: 여러 함수와 Main

이제 완전한 프로그램 예시를 보자.

FunLang 소스

let square x = x * x
let sumSquares a b = square a + square b
sumSquares 3 4

의미:

  • square 3 → 9
  • square 4 → 16
  • 9 + 16 → 25

AST 표현

let program = {
    functions = [
        { name = "square"
          parameters = ["x"]
          body = BinOp(Var "x", Mul, Var "x") };

        { name = "sumSquares"
          parameters = ["a"; "b"]
          body = BinOp(
              App("square", [Var "a"]),
              Add,
              App("square", [Var "b"])
          ) }
    ]
    main = App("sumSquares", [Int 3; Int 4])
}

컴파일 과정

1. square 함수 컴파일

compileFuncDef builder { name = "square"; parameters = ["x"]; body = ... }

생성된 MLIR IR:

func.func @square(%arg0: i32) -> i32 {
  %0 = arith.muli %arg0, %arg0 : i32
  func.return %0 : i32
}

2. sumSquares 함수 컴파일

compileFuncDef builder { name = "sumSquares"; parameters = ["a"; "b"]; body = ... }

본체 컴파일:

  • App("square", [Var "a"])%0 = func.call @square(%arg0)
  • App("square", [Var "b"])%1 = func.call @square(%arg1)
  • BinOp(..., Add, ...)%2 = arith.addi %0, %1

생성된 MLIR IR:

func.func @sumSquares(%arg0: i32, %arg1: i32) -> i32 {
  %0 = func.call @square(%arg0) : (i32) -> i32
  %1 = func.call @square(%arg1) : (i32) -> i32
  %2 = arith.addi %0, %1 : i32
  func.return %2 : i32
}

3. Main 함수 컴파일

// main = App("sumSquares", [Int 3; Int 4])

생성된 MLIR IR:

func.func @funlang_main() -> i32 {
  %c3 = arith.constant 3 : i32
  %c4 = arith.constant 4 : i32
  %0 = func.call @sumSquares(%c3, %c4) : (i32, i32) -> i32
  func.return %0 : i32
}

완전한 MLIR 모듈

module {
  func.func @square(%arg0: i32) -> i32 {
    %0 = arith.muli %arg0, %arg0 : i32
    func.return %0 : i32
  }

  func.func @sumSquares(%arg0: i32, %arg1: i32) -> i32 {
    %0 = func.call @square(%arg0) : (i32) -> i32
    %1 = func.call @square(%arg1) : (i32) -> i32
    %2 = arith.addi %0, %1 : i32
    func.return %2 : i32
  }

  func.func @funlang_main() -> i32 {
    %c3 = arith.constant 3 : i32
    %c4 = arith.constant 4 : i32
    %0 = func.call @sumSquares(%c3, %c4) : (i32, i32) -> i32
    func.return %0 : i32
  }
}

Lowering to LLVM Dialect

MLIR의 --convert-func-to-llvm 패스를 적용하면 LLVM 다이얼렉트로 변환된다:

mlir-opt --convert-func-to-llvm \
         --convert-arith-to-llvm \
         --convert-scf-to-cf \
         --convert-cf-to-llvm \
         input.mlir -o lowered.mlir

Lowered MLIR (LLVM dialect):

module {
  llvm.func @square(%arg0: i32) -> i32 {
    %0 = llvm.mul %arg0, %arg0 : i32
    llvm.return %0 : i32
  }

  llvm.func @sumSquares(%arg0: i32, %arg1: i32) -> i32 {
    %0 = llvm.call @square(%arg0) : (i32) -> i32
    %1 = llvm.call @square(%arg1) : (i32) -> i32
    %2 = llvm.add %0, %1 : i32
    llvm.return %2 : i32
  }

  llvm.func @funlang_main() -> i32 {
    %c3 = llvm.mlir.constant(3 : i32) : i32
    %c4 = llvm.mlir.constant(4 : i32) : i32
    %0 = llvm.call @sumSquares(%c3, %c4) : (i32, i32) -> i32
    llvm.return %0 : i32
  }
}

func.* 연산이 llvm.* 연산으로 변환되었다!

LLVM IR 변환

mlir-translate --mlir-to-llvmir lowered.mlir -o output.ll

LLVM IR:

define i32 @square(i32 %0) {
  %2 = mul i32 %0, %0
  ret i32 %2
}

define i32 @sumSquares(i32 %0, i32 %1) {
  %3 = call i32 @square(i32 %0)
  %4 = call i32 @square(i32 %1)
  %5 = add i32 %3, %4
  ret i32 %5
}

define i32 @funlang_main() {
  %1 = call i32 @sumSquares(i32 3, i32 4)
  ret i32 %1
}

컴파일과 실행

# LLVM IR을 object file로 컴파일
llc -filetype=obj output.ll -o funlang.o

# runtime.c와 링크
cc runtime.c funlang.o -o program

# 실행
./program
echo $?  # 25 (3*3 + 4*4)

프로그램이 실행되어 25를 반환한다!

호출 규약 (Calling Convention)

함수 호출이 실제로 어떻게 동작하는지 이해하려면 **호출 규약(calling convention)**을 알아야 한다.

호출 규약이란?

호출 규약은 함수 호출 시 인자, 반환 값, 레지스터, 스택이 어떻게 관리되는지 정의하는 규칙이다.

규약에 포함되는 내용:

  1. 인자 전달 방법: 레지스터? 스택? 어떤 순서?
  2. 반환 값 위치: 어느 레지스터에 반환 값을 넣는가?
  3. 레지스터 보존: 어떤 레지스터는 호출 전후에 보존되어야 하는가?
  4. 스택 프레임: 스택을 어떻게 정리하는가?

C 호출 규약 (x86-64 System V ABI)

MLIR/LLVM은 기본적으로 C 호출 규약을 사용한다. x86-64 Linux에서는 System V ABI다.

인자 전달 (x86-64 System V ABI):

인자 순서정수/포인터부동소수점
1번째RDIXMM0
2번째RSIXMM1
3번째RDXXMM2
4번째RCXXMM3
5번째R8XMM4
6번째R9XMM5
7번째 이상스택스택

예시: add(10, 20) 호출

mov edi, 10      ; 첫 번째 인자 (RDI의 하위 32비트)
mov esi, 20      ; 두 번째 인자 (RSI의 하위 32비트)
call add         ; 함수 호출
; 반환 값은 EAX (RAX의 하위 32비트)에 저장됨

반환 값:

  • 정수/포인터: RAX (32비트 정수는 EAX)
  • 부동소수점: XMM0

예시: add 함수 반환

add:
    mov eax, edi
    add eax, esi   ; eax = edi + esi
    ret            ; eax에 반환 값

LLVM이 호출 규약을 처리한다

핵심 통찰력: 우리는 호출 규약을 직접 구현하지 않는다. LLVM이 자동으로 처리한다!

MLIR func 다이얼렉트 코드:

func.func @add(%arg0: i32, %arg1: i32) -> i32 {
  %0 = arith.addi %arg0, %arg1 : i32
  func.return %0 : i32
}

LLVM이 생성하는 네이티브 코드 (x86-64):

add:
    ; 프롤로그 (스택 프레임 설정) - 단순 함수는 생략 가능
    lea eax, [rdi + rsi]  ; eax = edi + esi (최적화됨)
    ret

LLVM이 자동으로:

  1. 파라미터를 적절한 레지스터에 배치 (EDI, ESI)
  2. 반환 값을 EAX에 배치
  3. 최적화 적용 (lea 사용)
  4. 에필로그 생략 (단순 함수)

플랫폼별 차이

호출 규약은 플랫폼마다 다르다:

플랫폼호출 규약인자 전달
Linux x86-64System V ABIRDI, RSI, RDX, RCX, R8, R9, 스택
Windows x86-64Microsoft x64RCX, RDX, R8, R9, 스택
ARM64AAPCS64X0-X7, 스택
x86-32cdecl스택 (오른쪽부터)

LLVM의 역할: 동일한 LLVM IR을 각 플랫폼에 맞게 변환한다. 우리는 신경 쓸 필요 없다!

왜 C 호출 규약을 사용하는가?

장점:

  1. C 라이브러리와 상호 운용: printf, malloc 같은 C 함수를 호출할 수 있다.
  2. 시스템 콜 호환성: OS 시스템 콜이 C 규약을 따른다.
  3. 디버거 지원: GDB 같은 디버거가 C 호출 규약을 이해한다.
  4. ABI 안정성: 표준 ABI로 다른 언어와 링크 가능.

단점 (Phase 3에서는 해당 없음):

  • Tail call optimization이 보장되지 않음 (Chapter 11에서 다룸)
  • 클로저 전달이 비효율적일 수 있음 (Phase 4에서 다룸)

Phase 3에서는 C 호출 규약이 완벽하게 작동한다. 단순한 값 전달과 반환만 있기 때문이다.

스택 프레임 관리

함수 호출 시 **스택 프레임(stack frame)**이 생성된다.

스택 프레임 구조 (x86-64):

High address
┌─────────────────┐
│ 인자 7, 8, ...  │  (레지스터에 들어가지 않는 인자들)
├─────────────────┤
│ 반환 주소       │  (call 명령어가 push)
├─────────────────┤
│ 이전 RBP        │  (함수 프롤로그가 push)
├─────────────────┤  ← RBP (base pointer)
│ 지역 변수       │
├─────────────────┤
│ 임시 값         │
└─────────────────┘  ← RSP (stack pointer)
Low address

함수 프롤로그 (진입 시):

push rbp           ; 이전 프레임 포인터 저장
mov rbp, rsp       ; 새 프레임 포인터 설정
sub rsp, 32        ; 지역 변수를 위한 공간 할당

함수 에필로그 (종료 시):

mov rsp, rbp       ; 스택 포인터 복원
pop rbp            ; 이전 프레임 포인터 복원
ret                ; 반환

LLVM의 역할: 이 모든 것을 자동으로 생성한다. 우리는 func.funcfunc.return만 작성하면 된다!

Tail Call Optimization (미리보기)

Tail call은 함수의 마지막 연산이 다른 함수 호출인 경우다:

let factorial_tail n acc =
    if n <= 1 then acc
    else factorial_tail (n - 1) (n * acc)  // Tail call!

일반 호출과 tail call의 차이:

일반 호출 (스택 프레임 누적):

factorial_tail(5, 1)
  → factorial_tail(4, 5)
    → factorial_tail(3, 20)
      → factorial_tail(2, 60)
        → factorial_tail(1, 120)
          → 120

5개의 스택 프레임이 누적된다.

Tail call optimization (스택 프레임 재사용):

factorial_tail(5, 1)
→ factorial_tail(4, 5)  (같은 프레임 재사용)
→ factorial_tail(3, 20)
→ factorial_tail(2, 60)
→ factorial_tail(1, 120)
→ 120

1개의 스택 프레임만 사용한다!

Chapter 11에서 자세히 다룬다. Tail call optimization은 재귀 함수를 효율적으로 만드는 핵심 기술이다.

일반적인 오류

함수를 처음 구현할 때 흔히 겪는 오류들을 살펴본다.

오류 1: 함수를 찾을 수 없음

증상:

error: 'func.call' op symbol reference '@foo' not found in symbol table

원인:

  • 함수 이름 오타
  • 함수가 정의되지 않음
  • 함수를 모듈에 추가하지 않음

예시 (잘못된 코드):

// 'add' 함수를 정의했지만 'addd'로 호출
let program = {
    functions = [ { name = "add"; parameters = ["x"; "y"]; body = ... } ]
    main = App("addd", [Int 10; Int 20])  // 오타!
}

해결 방법:

  1. 함수 이름 확인: 정의와 호출 시 이름이 일치하는가?
  2. 함수 정의 확인: compileFuncDef가 호출되었는가?
  3. 모듈 추가 확인: AddOperationToModule이 호출되었는가?
// 올바른 코드
let program = {
    functions = [ { name = "add"; parameters = ["x"; "y"]; body = ... } ]
    main = App("add", [Int 10; Int 20])  // 일치!
}

오류 2: 인자 개수 불일치

증상:

error: 'func.call' op incorrect number of operands: expected 2 but got 1

원인:

  • 함수 호출 시 인자 개수가 정의와 다름

예시 (잘못된 코드):

// add는 2개의 파라미터를 받는다
let addDef = { name = "add"; parameters = ["x"; "y"]; body = ... }

// 하지만 1개만 전달
let call = App("add", [Int 10])  // 오류!

해결 방법:

함수 정의의 파라미터 개수와 호출 시 인자 개수를 일치시킨다.

// 올바른 코드
let call = App("add", [Int 10; Int 20])  // 2개 인자

디버깅 팁:

함수 시그니처를 확인하는 유틸리티를 추가한다:

let checkFunctionArity (funcDef: FunDef) (argCount: int) =
    if argCount <> funcDef.parameters.Length then
        failwithf "Function %s expects %d arguments but got %d"
            funcDef.name
            funcDef.parameters.Length
            argCount

오류 3: 타입 불일치

증상:

error: 'func.call' op operand type mismatch: expected 'i32' but got 'i1'

원인:

  • 함수 파라미터 타입과 인자 타입이 다름
  • Phase 3에서는 모든 값이 i32이므로 비교 결과(i1)를 함수에 전달할 때 발생

예시 (잘못된 코드):

// compute는 i32를 받는다
let computeDef = { name = "compute"; parameters = ["x"]; body = ... }

// 하지만 i1 (비교 결과)를 전달
let cond = Compare(Int 10, Gt, Int 5)  // i1 타입
let call = App("compute", [cond])      // 타입 불일치!

해결 방법:

Phase 3에서는 모든 함수 파라미터가 i32다. 비교 결과를 전달하려면 i1을 i32로 확장한다:

// 비교 결과를 i32로 확장
let cond = Compare(Int 10, Gt, Int 5)  // i1
let condExtended = If(cond, Int 1, Int 0)  // i32
let call = App("compute", [condExtended])

또는 컴파일러가 자동으로 확장하도록 구현:

let rec compileExpr builder env expr =
    match expr with
    | App(name, args) ->
        let argValues =
            args
            |> List.map (fun argExpr ->
                let value = compileExpr builder env argExpr
                // i1 타입이면 i32로 확장
                if mlirTypeEqual (mlirValueGetType value) (builder.I1Type()) then
                    builder.CreateArithExtension(value, builder.I32Type())
                else
                    value
            )
            |> List.toArray
        builder.CreateFuncCall(name, argValues, builder.I32Type())

오류 4: func.return 누락

증상:

error: 'func.func' op block must be terminated with a func.return operation

원인:

  • 함수 본체가 종결자(terminator) 없이 끝남
  • func.return을 추가하지 않음

예시 (잘못된 코드):

let compileFuncDef builder funcDef =
    let funcOp = builder.CreateFuncOp(...)
    let entryBlock = builder.GetFunctionEntryBlock(funcOp)
    builder.SetInsertionPointToEnd(entryBlock)

    // 본체 컴파일
    let bodyValue = compileExpr builder env funcDef.body

    // func.return 누락!
    builder.AddOperationToModule(funcOp)

해결 방법:

항상 func.return을 추가한다:

let compileFuncDef builder funcDef =
    // ...
    let bodyValue = compileExpr builder env funcDef.body
    builder.CreateFuncReturn(bodyValue)  // 추가!
    builder.AddOperationToModule(funcOp)

오류 5: 파라미터와 let 바인딩 혼동

증상:

error: use of value '%arg0' requires an operation that dominates it

원인:

  • 파라미터를 일반 변수처럼 처리함
  • 환경에 파라미터를 추가하지 않음

예시 (잘못된 코드):

let compileFuncDef builder funcDef =
    // ...
    let entryBlock = builder.GetFunctionEntryBlock(funcOp)
    builder.SetInsertionPointToEnd(entryBlock)

    // 파라미터를 환경에 추가하지 않음!
    let env = Map.empty
    let bodyValue = compileExpr builder env funcDef.body  // Var "x"를 찾지 못함

해결 방법:

파라미터를 환경에 추가한다:

let compileFuncDef builder funcDef =
    // ...
    let entryBlock = builder.GetFunctionEntryBlock(funcOp)

    // 파라미터를 환경에 추가
    let initialEnv =
        funcDef.parameters
        |> List.mapi (fun i name ->
            let arg = builder.GetFunctionBlockArg(entryBlock, i)
            (name, arg)
        )
        |> Map.ofList

    builder.SetInsertionPointToEnd(entryBlock)
    let bodyValue = compileExpr builder initialEnv funcDef.body
    builder.CreateFuncReturn(bodyValue)

핵심 원칙: 파라미터는 block arguments다. 환경에 추가하여 이름으로 참조할 수 있게 한다.

장 요약

이 장에서 FunLang에 함수를 추가했다.

배운 내용:

  1. MLIR func 다이얼렉트

    • func.func: 함수 정의
    • func.call: 함수 호출
    • func.return: 함수 반환
    • 모듈 레벨 심볼 테이블
  2. AST 확장

    • FunDef: 함수 정의 (이름, 파라미터, 본체)
    • App: 함수 호출 (함수 이름, 인자 리스트)
    • Program: 함수 정의 리스트 + main 표현식
  3. P/Invoke 바인딩

    • Function type API (mlirFunctionTypeGet)
    • Symbol reference (mlirFlatSymbolRefAttrGet)
    • Block arguments (mlirBlockGetArgument)
  4. OpBuilder 확장

    • CreateFuncOp: 함수 생성
    • GetFunctionEntryBlock: entry block 가져오기
    • GetFunctionBlockArg: 파라미터 가져오기
    • CreateFuncCall: 함수 호출
    • CreateFuncReturn: 함수 반환
  5. 함수 파라미터와 Block Arguments

    • 파라미터는 block arguments로 표현
    • Entry block의 arguments로 자동 생성
    • 환경에 추가하여 이름으로 참조
  6. 코드 생성

    • compileFuncDef: 함수 정의 컴파일
    • compileExprApp case: 함수 호출 컴파일
    • compileProgram: 전체 프로그램 컴파일
  7. 호출 규약 (Calling Convention)

    • C 호출 규약 (System V ABI)
    • 인자 전달: 레지스터 → 스택
    • 반환 값: RAX 레지스터
    • LLVM이 자동 처리

독자가 할 수 있는 것:

  • 다중 함수 정의를 포함한 FunLang 프로그램 작성
  • 함수 호출과 중첩 호출 컴파일
  • 생성된 MLIR IR 확인
  • 네이티브 바이너리로 컴파일 및 실행

다음 단계 (Chapter 11):

  • 재귀(Recursion): 함수가 자기 자신을 호출
  • 상호 재귀(Mutual Recursion): 두 함수가 서로를 호출
  • Tail Call Optimization: 재귀를 효율적으로 만들기

함수는 코드 재사용과 모듈화의 핵심이다. Phase 3은 함수의 기초를 확립했다. 다음 장에서는 재귀로 함수의 표현력을 극대화한다!

Chapter 11: 재귀 (Recursion)

소개

함수형 프로그래밍에서 **재귀(recursion)**는 단순한 기법이 아니라 필수 도구다. 명령형 언어가 loop을 쓰는 곳에 함수형 언어는 재귀를 쓴다.

// 명령형 스타일 (loop)
let sum_to n =
    let mutable result = 0
    for i in 1 to n do
        result <- result + i
    result

// 함수형 스타일 (recursion)
let rec sum_to n =
    if n <= 0 then 0
    else n + sum_to (n - 1)

왜 재귀인가?

순수 함수형 언어에는 mutable 변수가 없다. 값은 불변이고, 상태는 함수 파라미터를 통해 전달된다. Loop은 카운터 변수를 변경하는데, 이것은 mutation이다. 재귀는 mutation 없이 반복을 표현할 수 있다.

FunLang은 순수 함수형 언어다. Loop 구문이 없다. 모든 반복은 재귀로 표현된다.

재귀의 본질: 자기 참조(Self-reference)

재귀 함수는 자기 자신을 호출한다:

let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)
           // ↑ 자기 자신을 호출!

factorial 함수가 본체 내부에서 factorial을 호출한다. 이것이 가능하려면:

  • 함수 이름이 본체에서 보여야 한다 (scope 문제)
  • 무한 재귀를 방지할 기저 사례(base case)가 필요하다

Chapter 11의 범위:

이 장에서 다루는 것:

  1. 재귀 함수 (Recursive functions): 자기 자신을 호출하는 함수 (factorial, fibonacci)
  2. 상호 재귀 (Mutual recursion): 두 함수가 서로를 호출 (is_even, is_odd)
  3. 스택 프레임 (Stack frames): 재귀 호출이 스택 메모리를 어떻게 사용하는가
  4. 꼬리 호출 최적화 (Tail call optimization): 스택 오버플로우를 방지하는 기법

이 장을 마치면:

  • factorial, fibonacci 같은 재귀 함수를 컴파일할 수 있다
  • 상호 재귀가 모듈 레벨 심볼 테이블을 통해 작동하는 원리를 안다
  • 스택 프레임이 어떻게 생성되고 소멸되는지 이해한다
  • 꼬리 호출 최적화가 무엇이고 왜 중요한지 안다

Preview: Phase 3 (Chapter 10-11)은 최상위 명명된 함수를 다룬다. Phase 4에서 클로저와 고차 함수를 추가할 것이다.

재귀가 MLIR에서 작동하는 원리

모듈 레벨 심볼 테이블

Chapter 10에서 배운 것: MLIR 모듈은 flat symbol table을 가진다. 모든 func.func 연산이 모듈 레벨 심볼로 등록된다.

module {
  func.func @factorial(%n: i32) -> i32 {
    // ...
  }

  func.func @fibonacci(%n: i32) -> i32 {
    // ...
  }

  func.func @main() -> i32 {
    // ...
  }
}

핵심: 모든 함수가 서로에게 보인다. 정의 순서는 중요하지 않다.

  • @factorial@fibonacci를 호출할 수 있다
  • @fibonacci@factorial을 호출할 수 있다
  • @factorial자기 자신을 호출할 수 있다!

자기 참조 (Self-reference):

func.func @factorial(%n: i32) -> i32 {
  // ...
  %rec = func.call @factorial(%n_minus_1) : (i32) -> i32
  //                 ↑ 자기 자신을 호출!
  // ...
}

@factorial 함수가 내부에서 func.call @factorial을 실행한다. 이것은 **심볼 참조(symbol reference)**다:

  • @factorial이라는 심볼이 모듈에 존재하는가? (자기 자신)
  • 타입이 (i32) -> i32가 맞는가?
  • 호출 가능한가?

MLIR verifier는 심볼 존재를 확인하지만, “자기 자신 호출“을 금지하지 않는다. 재귀가 자연스럽게 작동한다.

Interpreter vs Compiler의 차이

Interpreter에서 재귀 (LangTutorial FunLang):

// AST
LetRec("factorial",
       Lambda(["n"],
              If(BinOp(Var "n", Le, Num 1),
                 Num 1,
                 BinOp(Var "n",
                       Mul,
                       App(Var "factorial", [BinOp(Var "n", Sub, Num 1)])))))

// Interpreter evaluation
let rec eval env ast =
    match ast with
    | LetRec(name, Lambda(params, body), rest) ->
        // 1. 재귀 환경 생성: env에 함수 자신을 추가
        let rec_env = env.Add(name, RecursiveClosure(params, body, rec_env))
        // 2. 본체 평가
        eval rec_env body

Interpreter는 **환경(environment)**에 함수를 바인딩한다. LetRec은 “재귀 바인딩“을 만든다 - 함수 본체가 평가되기 전에 환경에 자기 자신이 포함된다.

Compiler에서 재귀 (FunLang MLIR):

// 컴파일
let compileFuncDef builder moduleDef (FunDef(name, params, body)) =
    // 1. 함수 생성 (func.func @name)
    let funcOp = builder.CreateFuncOp(name, paramTypes, returnType)

    // 2. 본체 컴파일
    let bodyValue = compileExpr builder env body

    // 3. 반환
    builder.CreateFuncReturn(bodyValue)

    // 4. 모듈에 추가
    moduleDef.AddFunction(funcOp)

Compiler는 심볼 테이블을 사용한다:

  • 함수가 func.func 연산으로 모듈에 추가되면, 심볼 @name이 등록된다
  • 본체를 컴파일할 때 func.call @name을 만나면, 심볼 테이블에서 @name을 찾는다
  • 심볼이 존재하므로 (자기 자신) 호출이 성공한다

차이점:

측면InterpreterCompiler
함수 저장환경 (Map<string, Value>)모듈 심볼 테이블
재귀 메커니즘재귀 클로저 (self-reference in closure)심볼 참조 (symbol reference)
평가 시점런타임 (함수 호출할 때마다 환경 검색)컴파일 타임 (심볼 확인) + 런타임 (call instruction)
Forward declaration불필요 (LetRec이 재귀 환경 생성)불필요 (모듈 레벨 심볼은 정의 순서 무관)

핵심: Interpreter는 환경을 사용하고, Compiler는 심볼을 사용한다. 둘 다 재귀를 지원하지만, 메커니즘이 다르다.

컴파일 타임 심볼 확인

MLIR은 static symbol resolution을 수행한다:

// 잘못된 IR - verifier가 거부
func.func @foo(%n: i32) -> i32 {
  %result = func.call @bar(%n) : (i32) -> i32
  //                     ↑ @bar가 모듈에 없음!
  func.return %result : i32
}

MLIR verifier (mlirOperationVerify)는 심볼 참조를 검증한다:

  • @bar 심볼이 모듈에 존재하는가?
  • 타입이 (i32) -> i32와 호환되는가?

검증 실패 시 에러:

error: 'func.call' op 'bar' does not reference a valid function

재귀 함수는 자연스럽게 통과:

func.func @factorial(%n: i32) -> i32 {
  // ...
  %rec = func.call @factorial(%n_minus_1) : (i32) -> i32
  // ✓ @factorial은 모듈에 존재 (자기 자신)
  // ✓ 타입 (i32) -> i32 일치
  // ...
}

Verifier는 심볼 존재만 확인한다. “자기 자신 호출“을 특별히 처리하지 않는다.

MLIR IR 예시: Factorial 자기 참조

module {
  func.func @factorial(%arg0: i32) -> i32 {
    %c1 = arith.constant 1 : i32
    %cmp = arith.cmpi sle, %arg0, %c1 : i32
    %result = scf.if %cmp -> (i32) {
      scf.yield %c1 : i32
    } else {
      %n_minus_1 = arith.subi %arg0, %c1 : i32
      %rec = func.call @factorial(%n_minus_1) : (i32) -> i32
      //                ↑ 자기 자신 호출
      %product = arith.muli %arg0, %rec : i32
      scf.yield %product : i32
    }
    func.return %result : i32
  }
}

실행 시퀀스 (factorial 5):

  1. @factorial이 5로 호출됨
  2. 조건 확인: 5 <= 1? → false
  3. else 블록 실행:
    • n_minus_1 = 5 - 1 = 4
    • rec = func.call @factorial(4)재귀 호출
  4. 이제 새로운 스택 프레임에서 @factorial(4) 실행
  5. 조건 확인: 4 <= 1? → false
  6. else 블록 실행:
    • n_minus_1 = 4 - 1 = 3
    • rec = func.call @factorial(3)재귀 호출
  7. … (계속)

재귀 호출마다 새로운 스택 프레임이 생성된다. 각 프레임은 독립적인 %arg0, %n_minus_1, %rec 값을 가진다.

핵심: 심볼 참조 @factorial은 컴파일 타임에 확인되고, 런타임에 call instruction으로 실행된다. LLVM이 스택 프레임 관리를 처리한다.

재귀 함수: Factorial

Factorial 정의

수학적 정의:

factorial(n) = n! = n × (n-1) × (n-2) × ... × 2 × 1

예시:
  5! = 5 × 4 × 3 × 2 × 1 = 120
  3! = 3 × 2 × 1 = 6
  1! = 1
  0! = 1 (정의에 의해)

재귀적 정의:

factorial(n) = {
  1                        if n <= 1  (base case)
  n × factorial(n - 1)     if n > 1   (recursive case)
}

기저 사례(base case): n <= 1일 때 1 반환. 재귀 종료 조건. 재귀 사례(recursive case): n × factorial(n - 1). 자기 자신을 더 작은 입력으로 호출.

FunLang 소스:

let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)

AST 표현

Chapter 10에서 정의한 AST:

type Expr =
    | Num of int
    | Var of string
    | BinOp of Expr * Operator * Expr
    | If of Expr * Expr * Expr
    | Let of string * Expr * Expr
    | App of string * Expr list    // 함수 호출

type FunDef =
    | FunDef of string * string list * Expr

type Program =
    | Program of FunDef list * Expr

factorial의 AST:

FunDef("factorial",
       ["n"],
       If(BinOp(Var "n", Le, Num 1),
          Num 1,
          BinOp(Var "n",
                Mul,
                App("factorial", [BinOp(Var "n", Sub, Num 1)]))))

주목할 점:

  • App("factorial", ...): 함수 호출. 자기 자신을 호출한다.
  • 기존 AST로 충분하다. LetRec 같은 새로운 AST 노드가 필요 없다.
  • FunDef는 이미 모듈 레벨 함수를 표현한다. 이름으로 자기 참조가 가능하다.

컴파일 전략

Chapter 10의 compileFuncDef 재사용:

let compileFuncDef (builder: OpBuilder) (moduleDef: ModuleOp) (FunDef(name, params, body)) =
    // 1. 함수 타입 생성
    let paramTypes = List.replicate params.Length builder.GetI32Type()
    let returnType = builder.GetI32Type()
    let funcType = builder.GetFunctionType(paramTypes, returnType)

    // 2. func.func 생성
    let funcOp = builder.CreateFuncOp(name, funcType)

    // 3. Entry block 생성 및 파라미터 가져오기
    let entryBlock = funcOp.GetEntryBlock()
    builder.SetInsertionPointToEnd(entryBlock)

    // 4. 환경 구축: 파라미터를 환경에 추가
    let env =
        params
        |> List.mapi (fun i paramName ->
            let argValue = entryBlock.GetArgument(i)
            (paramName, argValue))
        |> Map.ofList

    // 5. 본체 컴파일
    let bodyValue = compileExpr builder env body

    // 6. 반환
    builder.CreateFuncReturn(bodyValue)

    // 7. 모듈에 추가
    moduleDef.AddFunction(funcOp)

재귀 호출 처리 (compileExpr의 App case):

let rec compileExpr (builder: OpBuilder) (env: Map<string, MlirValue>) (expr: Expr) =
    match expr with
    | App(funcName, args) ->
        // 1. 인자 컴파일
        let argValues = args |> List.map (compileExpr builder env)

        // 2. 함수 호출
        builder.CreateFuncCall(funcName, argValues)
    // ... other cases

핵심:

  • App("factorial", [arg])를 만나면 CreateFuncCall("factorial", [argValue])
  • CreateFuncCallfunc.call @factorial(%arg) : (i32) -> i32 생성
  • 심볼 @factorial이 모듈에 존재 (자기 자신)
  • 재귀 호출 완료!

재귀 함수 컴파일에 특별한 처리가 필요 없다. 일반 함수 호출과 동일하게 처리된다.

완전한 MLIR IR 출력

module {
  func.func @factorial(%arg0: i32) -> i32 {
    // if n <= 1
    %c1 = arith.constant 1 : i32
    %cmp = arith.cmpi sle, %arg0, %c1 : i32

    // scf.if with two branches
    %result = scf.if %cmp -> (i32) {
      // then: return 1
      scf.yield %c1 : i32
    } else {
      // else: return n * factorial(n - 1)

      // n - 1
      %n_minus_1 = arith.subi %arg0, %c1 : i32

      // factorial(n - 1) - 재귀 호출!
      %rec = func.call @factorial(%n_minus_1) : (i32) -> i32

      // n * factorial(n - 1)
      %product = arith.muli %arg0, %rec : i32

      scf.yield %product : i32
    }

    func.return %result : i32
  }
}

구조:

  1. 조건 평가: %cmp = arith.cmpi sle, %arg0, %c1 (n <= 1?)
  2. scf.if 분기:
    • then 블록: scf.yield %c1 (기저 사례: 1 반환)
    • else 블록:
      • %n_minus_1 = arith.subi %arg0, %c1 (n - 1 계산)
      • %rec = func.call @factorial(%n_minus_1) (재귀 호출)
      • %product = arith.muli %arg0, %rec (n * 재귀 결과)
      • scf.yield %product (재귀 사례: n * factorial(n-1))
  3. 반환: func.return %result

단계별 실행 추적

factorial 5 실행 과정:

1. factorial(5) 호출
   ├─ 조건: 5 <= 1? → false
   ├─ else 블록 진입
   ├─ n_minus_1 = 5 - 1 = 4
   ├─ factorial(4) 호출 ← 재귀
   │  ├─ 조건: 4 <= 1? → false
   │  ├─ else 블록 진입
   │  ├─ n_minus_1 = 4 - 1 = 3
   │  ├─ factorial(3) 호출 ← 재귀
   │  │  ├─ 조건: 3 <= 1? → false
   │  │  ├─ else 블록 진입
   │  │  ├─ n_minus_1 = 3 - 1 = 2
   │  │  ├─ factorial(2) 호출 ← 재귀
   │  │  │  ├─ 조건: 2 <= 1? → false
   │  │  │  ├─ else 블록 진입
   │  │  │  ├─ n_minus_1 = 2 - 1 = 1
   │  │  │  ├─ factorial(1) 호출 ← 재귀
   │  │  │  │  ├─ 조건: 1 <= 1? → true
   │  │  │  │  └─ then 블록: return 1 ← 기저 사례!
   │  │  │  ├─ rec = 1
   │  │  │  ├─ product = 2 * 1 = 2
   │  │  │  └─ return 2
   │  │  ├─ rec = 2
   │  │  ├─ product = 3 * 2 = 6
   │  │  └─ return 6
   │  ├─ rec = 6
   │  ├─ product = 4 * 6 = 24
   │  └─ return 24
   ├─ rec = 24
   ├─ product = 5 * 24 = 120
   └─ return 120

최종 결과: 120

호출 깊이 (Call depth): 5

각 재귀 호출은 새로운 스택 프레임을 생성한다. factorial(5)는 5개의 스택 프레임을 사용한다.

Lowered LLVM IR

MLIR IR을 LLVM IR로 변환하면 (mlir-opt --convert-scf-to-cf --convert-func-to-llvm --convert-arith-to-llvm):

define i32 @factorial(i32 %0) {
entry:
  %1 = icmp sle i32 %0, 1
  br i1 %1, label %then, label %else

then:
  br label %merge

else:
  %2 = sub i32 %0, 1
  %3 = call i32 @factorial(i32 %2)  ; 재귀 호출 (call instruction)
  %4 = mul i32 %0, %3
  br label %merge

merge:
  %5 = phi i32 [ 1, %then ], [ %4, %else ]
  ret i32 %5
}

주목할 점:

  • call i32 @factorial(i32 %2): LLVM IR의 재귀 호출
  • 각 호출은 스택 프레임을 생성한다 (LLVM runtime이 처리)
  • PHI 노드 (phi i32 [ 1, %then ], [ %4, %else ])는 scf.if의 lowering 결과

Native 코드로 컴파일:

mlir-translate --mlir-to-llvmir factorial.mlir > factorial.ll
llc -filetype=obj factorial.ll -o factorial.o
gcc -o factorial factorial.o runtime.o -lgc
./factorial

재귀 함수: Fibonacci

Fibonacci 정의

수학적 정의:

fibonacci(n) = {
  n                                if n <= 1  (base case)
  fibonacci(n-1) + fibonacci(n-2)  if n > 1   (recursive case)
}

수열:
  fib(0) = 0
  fib(1) = 1
  fib(2) = fib(1) + fib(0) = 1 + 0 = 1
  fib(3) = fib(2) + fib(1) = 1 + 1 = 2
  fib(4) = fib(3) + fib(2) = 2 + 1 = 3
  fib(5) = fib(4) + fib(3) = 3 + 2 = 5
  fib(6) = fib(5) + fib(4) = 5 + 3 = 8

FunLang 소스:

let rec fib n =
    if n <= 1 then n
    else fib (n - 1) + fib (n - 2)

Double Recursion 패턴

Factorial은 단일 재귀(single recursion): 한 번만 자기 자신을 호출. Fibonacci는 이중 재귀(double recursion): 두 번 자기 자신을 호출.

fib (n - 1) + fib (n - 2)
//  ↑             ↑
// 첫 번째 호출   두 번째 호출

함의:

  • 각 재귀 호출이 또 다른 두 개의 호출을 만든다
  • 호출 트리가 지수적으로 증가한다

fib(5)의 호출 트리:

                    fib(5)
                   /      \
              fib(4)      fib(3)
             /     \      /     \
        fib(3)   fib(2) fib(2) fib(1)
        /   \    /   \  /   \
    fib(2) fib(1) fib(1) fib(0) fib(1) fib(0)
    /   \
fib(1) fib(0)

호출 횟수: fib(5)를 계산하기 위해 15번의 함수 호출이 발생한다.

시간 복잡도: O(2^n) - 지수 시간. fib(30) ≈ 20억 번 호출!

컴파일: 두 개의 func.call

func.func @fib(%arg0: i32) -> i32 {
  // if n <= 1
  %c1 = arith.constant 1 : i32
  %cmp = arith.cmpi sle, %arg0, %c1 : i32

  %result = scf.if %cmp -> (i32) {
    // then: return n
    scf.yield %arg0 : i32
  } else {
    // else: return fib(n-1) + fib(n-2)

    // n - 1
    %n_minus_1 = arith.subi %arg0, %c1 : i32

    // fib(n - 1) - 첫 번째 재귀 호출
    %fib_n_1 = func.call @fib(%n_minus_1) : (i32) -> i32

    // n - 2
    %c2 = arith.constant 2 : i32
    %n_minus_2 = arith.subi %arg0, %c2 : i32

    // fib(n - 2) - 두 번째 재귀 호출
    %fib_n_2 = func.call @fib(%n_minus_2) : (i32) -> i32

    // fib(n-1) + fib(n-2)
    %sum = arith.addi %fib_n_1, %fib_n_2 : i32

    scf.yield %sum : i32
  }

  func.return %result : i32
}

구조:

  • else 블록에서 두 번의 func.call:
    • %fib_n_1 = func.call @fib(%n_minus_1)
    • %fib_n_2 = func.call @fib(%n_minus_2)
  • 각 호출은 독립적: %fib_n_1이 완료된 후 %fib_n_2 실행
  • 결과를 더함: %sum = arith.addi %fib_n_1, %fib_n_2

실행 순서 (eager evaluation):

  1. %n_minus_1 계산
  2. func.call @fib(%n_minus_1) 실행 → 결과를 %fib_n_1에 저장
  3. %n_minus_2 계산
  4. func.call @fib(%n_minus_2) 실행 → 결과를 %fib_n_2에 저장
  5. %sum = %fib_n_1 + %fib_n_2 계산

성능 문제

지수 시간 복잡도:

fib(10) ≈ 177 호출
fib(20) ≈ 21,891 호출
fib(30) ≈ 2,692,537 호출
fib(40) ≈ 331,160,281 호출 (3억 번!)

왜 느린가?

중복 계산이 많다. fib(5)를 계산할 때 fib(3)을 두 번 계산하고, fib(2)를 세 번 계산한다.

fib(5)
├─ fib(4)
│  ├─ fib(3) ← 첫 번째 fib(3)
│  └─ fib(2)
└─ fib(3) ← 두 번째 fib(3) (중복!)
   ├─ fib(2) ← 중복!
   └─ fib(1)

최적화 방법 (Phase 3 범위 밖):

  • Memoization: 이미 계산한 값을 저장 (hashtable 사용)
  • Dynamic Programming: Bottom-up 방식으로 계산
  • Tail recursion: 꼬리 재귀로 변환 (accumulator 사용)

이 장에서는 순진한 재귀 구현만 다룬다. 최적화는 나중 단계에서 배운다.

교훈: 재귀는 우아하지만, 항상 효율적이지는 않다. 알고리즘 선택이 중요하다.

스택 프레임 관리

스택 프레임이란?

스택 프레임(stack frame) (또는 activation record)은 함수 호출에 필요한 정보를 저장하는 메모리 영역이다.

스택 프레임에 포함되는 것:

  1. 반환 주소(return address): 함수가 끝나면 돌아갈 위치
  2. 함수 파라미터: 호출자가 전달한 인자
  3. 지역 변수: 함수 내부에서 선언된 변수
  4. 저장된 레지스터: 호출 전 레지스터 상태 (ABI가 요구)
  5. 임시 값: 중간 계산 결과 (SSA values)

함수 호출 시 스택 프레임 생성:

main()
  |
  ├─ factorial(5) 호출
  │    ├─ 스택 프레임 생성
  │    │    - return address: main의 다음 instruction
  │    │    - arg0 = 5
  │    │    - 지역 변수 공간
  │    ├─ factorial(4) 호출
  │    │    ├─ 새로운 스택 프레임 생성
  │    │    │    - return address: factorial(5)의 다음 instruction
  │    │    │    - arg0 = 4
  │    │    ├─ factorial(3) 호출
  │    │    │    └─ 또 다른 스택 프레임...

스택 성장 방향:

대부분의 플랫폼에서 스택은 아래로 성장한다 (높은 주소 → 낮은 주소):

높은 주소
   ↓
 [main의 스택 프레임]
 [factorial(5)의 스택 프레임]  ← SP (Stack Pointer) 이동
 [factorial(4)의 스택 프레임]  ← SP 이동
 [factorial(3)의 스택 프레임]  ← SP 이동
 [factorial(2)의 스택 프레임]
 [factorial(1)의 스택 프레임]  ← SP (현재 위치)
   ↓
낮은 주소

Stack Pointer (SP): 스택의 현재 끝을 가리키는 레지스터. 함수 호출 시 SP가 아래로 이동.

재귀 호출과 스택 깊이

재귀 호출마다 새로운 스택 프레임:

factorial(5)
  ├─ 스택 프레임 1: arg0=5, return_addr=main
  ├─ factorial(4) 호출
  │  ├─ 스택 프레임 2: arg0=4, return_addr=factorial(5)
  │  ├─ factorial(3) 호출
  │  │  ├─ 스택 프레임 3: arg0=3, return_addr=factorial(4)
  │  │  ├─ factorial(2) 호출
  │  │  │  ├─ 스택 프레임 4: arg0=2, return_addr=factorial(3)
  │  │  │  ├─ factorial(1) 호출
  │  │  │  │  └─ 스택 프레임 5: arg0=1, return_addr=factorial(2)
  │  │  │  │     ├─ 기저 사례: return 1
  │  │  │  │     └─ 스택 프레임 5 소멸
  │  │  │  ├─ 반환값 1 받음, 2*1=2 계산, return 2
  │  │  │  └─ 스택 프레임 4 소멸
  │  │  ├─ 반환값 2 받음, 3*2=6 계산, return 6
  │  │  └─ 스택 프레임 3 소멸
  │  ├─ 반환값 6 받음, 4*6=24 계산, return 24
  │  └─ 스택 프레임 2 소멸
  ├─ 반환값 24 받음, 5*24=120 계산, return 120
  └─ 스택 프레임 1 소멸

최대 스택 깊이: factorial(5)는 5개의 스택 프레임이 동시에 존재한다 (기저 사례에 도달했을 때).

일반화: factorial(n)의 최대 스택 깊이는 n.

스택 크기 제한

운영체제는 스택 크기를 제한한다:

플랫폼기본 스택 크기
Linux (x86-64)8 MB
macOS8 MB
Windows1 MB

왜 제한이 필요한가?

  • 무한 재귀를 방지
  • 메모리 보호 (스택이 다른 메모리 영역을 침범하지 않도록)

스택 오버플로우(Stack Overflow):

재귀 깊이가 너무 크면 스택 크기 한계에 도달한다:

factorial(100000)
  ├─ 100,000개의 스택 프레임 필요
  ├─ 각 프레임이 ~64 bytes라고 가정
  ├─ 총 스택 사용: 100,000 * 64 = 6.4 MB
  └─ Linux에서는 OK (8MB 한계), Windows에서는 실패 (1MB 한계)

스택 오버플로우 에러:

./factorial
Segmentation fault (core dumped)
# 또는
Stack overflow error

해결책:

  1. 재귀 깊이 제한: 입력 크기를 제한
  2. 꼬리 호출 최적화(Tail Call Optimization): 스택 프레임 재사용
  3. 반복(Iteration)으로 변환: Loop 사용 (함수형 언어에서는 덜 선호)
  4. Trampoline 기법: 재귀를 CPS(Continuation-Passing Style)로 변환

이 장 후반부에서 꼬리 호출 최적화를 다룬다.

LLVM의 스택 프레임 관리

LLVM은 스택 프레임을 자동으로 관리한다:

  1. 함수 프롤로그(prologue):

    • 스택 포인터(SP) 감소 (스택 공간 할당)
    • 프레임 포인터(FP) 저장
    • 필요한 레지스터 저장 (callee-saved registers)
  2. 함수 에필로그(epilogue):

    • 저장된 레지스터 복원
    • 프레임 포인터 복원
    • 스택 포인터 증가 (스택 공간 해제)
    • 반환 (ret instruction)

예시 (x86-64 어셈블리):

factorial:
  ; Prologue
  push    rbp              ; 이전 프레임 포인터 저장
  mov     rbp, rsp         ; 새로운 프레임 포인터 설정
  sub     rsp, 16          ; 지역 변수를 위한 스택 공간 할당

  ; Function body
  ; ... (factorial 계산)

  ; Epilogue
  add     rsp, 16          ; 스택 공간 해제
  pop     rbp              ; 이전 프레임 포인터 복원
  ret                      ; 반환 주소로 점프

FunLang 컴파일러는 스택 관리를 직접 하지 않는다:

  • MLIR func 다이얼렉트로 함수 정의
  • LLVM이 lowering 과정에서 프롤로그/에필로그 생성
  • 플랫폼별 calling convention 자동 적용 (System V ABI for Linux, Microsoft x64 for Windows)

이점:

  • 플랫폼 독립적인 코드
  • ABI 호환성 자동 보장
  • 최적화 (tail call elimination, frame pointer omission)

Visualization: factorial 5의 스택

시간별 스택 상태:

시간 T1: main에서 factorial(5) 호출
┌──────────────────────┐
│ factorial(5)         │ ← SP
│  - arg0 = 5          │
│  - ret_addr = main+X │
├──────────────────────┤
│ main                 │
└──────────────────────┘

시간 T2: factorial(5)에서 factorial(4) 호출
┌──────────────────────┐
│ factorial(4)         │ ← SP
│  - arg0 = 4          │
│  - ret_addr = f(5)+Y │
├──────────────────────┤
│ factorial(5)         │
│  - arg0 = 5          │
├──────────────────────┤
│ main                 │
└──────────────────────┘

시간 T3: factorial(1) 도달 (최대 깊이)
┌──────────────────────┐
│ factorial(1)         │ ← SP (최대 깊이)
│  - arg0 = 1          │
├──────────────────────┤
│ factorial(2)         │
│  - arg0 = 2          │
├──────────────────────┤
│ factorial(3)         │
│  - arg0 = 3          │
├──────────────────────┤
│ factorial(4)         │
│  - arg0 = 4          │
├──────────────────────┤
│ factorial(5)         │
│  - arg0 = 5          │
├──────────────────────┤
│ main                 │
└──────────────────────┘

시간 T4: factorial(1) 반환 후 (1 반환)
┌──────────────────────┐
│ factorial(2)         │ ← SP
│  - arg0 = 2          │
│  - rec = 1           │
├──────────────────────┤
│ factorial(3)         │
├──────────────────────┤
│ factorial(4)         │
├──────────────────────┤
│ factorial(5)         │
├──────────────────────┤
│ main                 │
└──────────────────────┘

...

시간 T_final: 모든 호출 반환 완료
┌──────────────────────┐
│ main                 │ ← SP
│  - result = 120      │
└──────────────────────┘

핵심:

  • 재귀 호출마다 스택이 성장한다
  • 기저 사례에 도달하면 스택이 수축하기 시작한다
  • 각 반환은 이전 스택 프레임을 복원한다

스택 vs 힙

Phase 2에서 배운 것:

  • 스택(Stack): 함수 로컬 값, LIFO, 자동 해제
  • 힙(Heap): 탈출하는 값(closures, data structures), 수동/GC 해제

Phase 3에서 함수는 스택만 사용:

  • 파라미터: 스택 또는 레지스터 (calling convention)
  • 반환 값: 레지스터 (작은 값) 또는 스택 (큰 구조체)
  • 지역 변수: SSA values (레지스터 또는 스택 스필링)

Phase 4에서 클로저는 힙 사용:

  • 클로저 환경: 힙에 할당 (GC_malloc)
  • 클로저 포인터: 스택에 저장

연결:

  • Chapter 9 (Boehm GC)는 Phase 4를 위한 준비였다
  • Phase 3 함수는 GC를 사용하지 않는다 (메모리 할당 없음)
  • Phase 4 클로저에서 GC가 활성화된다

왜 스택 오버플로우가 발생하는가

깊은 재귀의 위험

문제:

factorial(100000)

이 호출은 100,000개의 스택 프레임을 생성한다. 각 프레임이 64 bytes라면:

100,000 frames × 64 bytes/frame = 6,400,000 bytes = 6.4 MB

Linux 기본 스택 크기가 8 MB이므로 아슬아슬하게 성공할 수 있다. Windows (1 MB)에서는 확실히 실패한다.

실제 테스트:

# factorial 100000 컴파일 및 실행
./factorial 100000
Segmentation fault

왜 Segmentation fault?

스택 포인터(SP)가 스택 크기 한계를 넘어서 guard page에 도달한다. Guard page는 스택 오버플로우 감지를 위한 특수 메모리 페이지로, 접근 시 segfault를 발생시킨다.

최적화 없는 재귀

일반 재귀 (Non-tail recursion):

let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)
         // ↑ 재귀 호출 후 곱셈이 남아있음

재귀 호출 후에 추가 작업(곱셈)이 있으므로:

  • 재귀 호출이 반환될 때까지 현재 스택 프레임을 유지해야 한다
  • 반환 값을 받아서 n과 곱해야 한다
  • 따라서 스택 프레임을 재사용할 수 없다

스택 프레임 누적:

factorial(5) 스택 프레임 유지 (n=5 저장 필요)
  factorial(4) 스택 프레임 유지 (n=4 저장 필요)
    factorial(3) 스택 프레임 유지 (n=3 저장 필요)
      factorial(2) 스택 프레임 유지 (n=2 저장 필요)
        factorial(1) 스택 프레임 생성
          return 1
        return 2 (= 2 * 1)
      return 6 (= 3 * 2)
    return 24 (= 4 * 6)
  return 120 (= 5 * 24)

모든 프레임이 동시에 존재해야 한다.

결론: 일반 재귀는 스택 크기에 제한받는다.

예시: factorial 100000은 왜 실패하는가

스택 크기: 8 MB = 8,388,608 bytes
필요한 스택: 100,000 frames × 64 bytes = 6,400,000 bytes

6,400,000 < 8,388,608 → 이론적으로 가능

하지만 실제로는:

  • 다른 함수 프레임: main, runtime initialization
  • 스택 정렬 (alignment): 16-byte 정렬 요구사항
  • 추가 오버헤드: 레지스터 저장, guard page

실제 사용 가능한 스택이 줄어든다. 그래서 6.4 MB도 실패할 수 있다.

안전한 한계:

대부분의 시스템에서 ~5,000 - 10,000 깊이가 안전하다. 그 이상은 스택 오버플로우 위험.

교훈: 깊은 재귀는 위험하다. 꼬리 호출 최적화가 필요하다.

상호 재귀 (Mutual Recursion)

상호 재귀란?

**상호 재귀(mutual recursion)**는 두 개 이상의 함수가 서로를 호출하는 패턴이다.

// 함수 A가 함수 B를 호출하고,
// 함수 B가 함수 A를 호출한다.

let rec is_even n =
    if n = 0 then true
    else is_odd (n - 1)

let rec is_odd n =
    if n = 0 then false
    else is_even (n - 1)

차이점:

  • 단순 재귀: 함수가 자기 자신을 호출 (factorialfactorial)
  • 상호 재귀: 함수 A가 함수 B를 호출, 함수 B가 함수 A를 호출 (is_evenis_odd)

왜 필요한가?

어떤 문제는 자연스럽게 상호 재귀로 표현된다:

  • 짝수/홀수 판정
  • 문법 파서 (expression → term → factor → expression)
  • 상태 기계 (state A → state B → state A)

예시: is_even과 is_odd

수학적 정의:

is_even(n) = {
  true                 if n = 0
  is_odd(n - 1)        if n > 0
}

is_odd(n) = {
  false                if n = 0
  is_even(n - 1)       if n > 0
}

직관:

  • 0은 짝수
  • n이 짝수인지 확인하려면: n-1이 홀수인지 확인
  • n이 홀수인지 확인하려면: n-1이 짝수인지 확인

FunLang 소스:

let rec is_even n =
    if n = 0 then true
    else is_odd (n - 1)

let rec is_odd n =
    if n = 0 then false
    else is_even (n - 1)

실행 예시 (is_even 4):

is_even(4)
  ├─ 4 = 0? → false
  ├─ is_odd(3) 호출
  │  ├─ 3 = 0? → false
  │  ├─ is_even(2) 호출
  │  │  ├─ 2 = 0? → false
  │  │  ├─ is_odd(1) 호출
  │  │  │  ├─ 1 = 0? → false
  │  │  │  ├─ is_even(0) 호출
  │  │  │  │  ├─ 0 = 0? → true
  │  │  │  │  └─ return true
  │  │  │  └─ return true (is_even(0) = true)
  │  │  └─ return true (is_odd(1) = true)
  │  └─ return true (is_even(2) = true)
  └─ return true (is_odd(3) = true)

최종 결과: true (4는 짝수)

호출 시퀀스: is_even → is_odd → is_even → is_odd → is_even

모듈 레벨 심볼 테이블의 역할

핵심: MLIR 모듈은 flat symbol namespace를 가진다. 모든 함수가 동시에 보인다.

module {
  func.func @is_even(%n: i32) -> i1 { ... }
  func.func @is_odd(%n: i32) -> i1 { ... }
}

중요한 점:

  • 정의 순서는 무관: is_even이 먼저 정의되든, is_odd가 먼저 정의되든 상관없다.
  • Forward declaration 불필요: C에서는 forward declaration이 필요하지만, MLIR에서는 필요 없다.
  • 모든 함수가 서로에게 보임: is_even 본체에서 is_odd를 참조할 수 있고, is_odd 본체에서 is_even을 참조할 수 있다.

C와 비교:

// C에서는 forward declaration 필요
int is_odd(int n);  // forward declaration

int is_even(int n) {
    if (n == 0) return 1;
    else return is_odd(n - 1);
}

int is_odd(int n) {
    if (n == 0) return 0;
    else return is_even(n - 1);
}

MLIR/FunLang에서는 불필요:

// 정의 순서 무관 - 둘 다 작동
module {
  func.func @is_even(%n: i32) -> i1 { ... func.call @is_odd ... }
  func.func @is_odd(%n: i32) -> i1 { ... func.call @is_even ... }
}

컴파일: 크로스 참조 처리

상호 재귀 함수 컴파일:

let compileProgram (builder: OpBuilder) (moduleDef: ModuleOp) (Program(funcs, mainExpr)) =
    // 1. 모든 함수 정의를 모듈에 추가
    funcs |> List.iter (compileFuncDef builder moduleDef)

    // 2. Main 표현식 컴파일
    let mainValue = compileExpr builder Map.empty mainExpr
    ...

핵심 아이디어:

  1. 모든 함수를 먼저 컴파일: 모듈에 func.func 연산 추가
  2. 심볼 등록 자동: MLIR이 각 함수를 심볼 테이블에 등록
  3. 본체 컴파일 시 심볼 참조: func.call @is_odd → 심볼 테이블에서 찾기

두 가지 접근법:

접근법 1: 순차 컴파일 (FunLang 사용)

// 함수를 하나씩 컴파일
funcs |> List.iter (fun funcDef ->
    compileFuncDef builder moduleDef funcDef
)
  • is_even 컴파일 시 본체에서 func.call @is_odd 생성
  • @is_odd 심볼이 아직 등록 안 됨 → 문제 없음!
  • MLIR verifier는 모든 함수가 컴파일된 후 실행됨
  • Verifier가 실행될 때는 @is_odd도 이미 등록되어 있음

접근법 2: 스텁 먼저 생성 (대안)

// 1단계: 모든 함수 헤더만 생성 (body 없음)
funcs |> List.iter (fun (FunDef(name, params, _)) ->
    let funcOp = builder.CreateFuncStub(name, paramTypes, returnType)
    moduleDef.AddFunction(funcOp)
)

// 2단계: 모든 함수 본체 채우기
funcs |> List.iter (fun (FunDef(name, params, body)) ->
    let funcOp = moduleDef.GetFunction(name)
    compileFuncBody builder funcOp params body
)
  • 더 명시적이지만 복잡함
  • FunLang은 접근법 1 사용 (더 간단)

왜 작동하는가?

MLIR의 lazy verification:

  • 함수를 컴파일하는 동안 심볼 참조는 검증하지 않음
  • 모듈이 완성된 후 mlirOperationVerify()를 호출
  • 그때 모든 심볼 참조 확인

완전한 MLIR IR 출력

module {
  // is_even 함수
  func.func @is_even(%arg0: i32) -> i1 {
    %c0 = arith.constant 0 : i32
    %is_zero = arith.cmpi eq, %arg0, %c0 : i32

    %result = scf.if %is_zero -> (i1) {
      // then: return true
      %true = arith.constant 1 : i1
      scf.yield %true : i1
    } else {
      // else: return is_odd(n - 1)
      %c1 = arith.constant 1 : i32
      %n_minus_1 = arith.subi %arg0, %c1 : i32

      // is_odd 호출 (상호 재귀!)
      %odd_result = func.call @is_odd(%n_minus_1) : (i32) -> i1

      scf.yield %odd_result : i1
    }

    func.return %result : i1
  }

  // is_odd 함수
  func.func @is_odd(%arg0: i32) -> i1 {
    %c0 = arith.constant 0 : i32
    %is_zero = arith.cmpi eq, %arg0, %c0 : i32

    %result = scf.if %is_zero -> (i1) {
      // then: return false
      %false = arith.constant 0 : i1
      scf.yield %false : i1
    } else {
      // else: return is_even(n - 1)
      %c1 = arith.constant 1 : i32
      %n_minus_1 = arith.subi %arg0, %c1 : i32

      // is_even 호출 (상호 재귀!)
      %even_result = func.call @is_even(%n_minus_1) : (i32) -> i1

      scf.yield %even_result : i1
    }

    func.return %result : i1
  }

  // Main 함수
  func.func @funlang_main() -> i32 {
    %c4 = arith.constant 4 : i32
    %result_i1 = func.call @is_even(%c4) : (i32) -> i1

    // i1 → i32 확장 (main 반환용)
    %result_i32 = arith.extui %result_i1 : i1 to i32

    func.return %result_i32 : i32
  }
}

주목할 점:

  • @is_evenfunc.call @is_odd 사용
  • @is_oddfunc.call @is_even 사용
  • 순환 참조(cyclic call graph) 형성
  • MLIR verifier가 허용 (심볼이 모두 존재)

실행 추적

is_even(4) 호출:

is_even(4)
  ├─ 4 = 0? → false
  ├─ else 블록: is_odd(4 - 1) = is_odd(3)
  │  ├─ 3 = 0? → false
  │  ├─ else 블록: is_even(3 - 1) = is_even(2)
  │  │  ├─ 2 = 0? → false
  │  │  ├─ else 블록: is_odd(2 - 1) = is_odd(1)
  │  │  │  ├─ 1 = 0? → false
  │  │  │  ├─ else 블록: is_even(1 - 1) = is_even(0)
  │  │  │  │  ├─ 0 = 0? → true
  │  │  │  │  └─ then 블록: return true
  │  │  │  ├─ odd_result = true
  │  │  │  └─ return true
  │  │  ├─ even_result = true
  │  │  └─ return true
  │  ├─ odd_result = true
  │  └─ return true
  ├─ even_result = true
  └─ return true (i1), 확장하여 1 (i32) 반환

호출 스택 깊이: 5 (is_even → is_odd → is_even → is_odd → is_even)

상호 재귀의 스택 프레임:

┌──────────────────────┐
│ is_even(0)           │ ← 최대 깊이 (기저 사례)
├──────────────────────┤
│ is_odd(1)            │
├──────────────────────┤
│ is_even(2)           │
├──────────────────────┤
│ is_odd(3)            │
├──────────────────────┤
│ is_even(4)           │ ← 최초 호출
├──────────────────────┤
│ funlang_main         │
└──────────────────────┘

스택 프레임이 번갈아가며 생성된다: is_even → is_odd → is_even → …

Verifier의 심볼 검증

MLIR verifier는 모듈 완성 후 실행:

// 컴파일러 코드
let compileProgram moduleDef funcs mainExpr =
    // 1. 모든 함수 컴파일
    funcs |> List.iter (compileFuncDef builder moduleDef)

    // 2. Main 컴파일
    let mainFunc = compileMain builder mainExpr
    moduleDef.AddFunction(mainFunc)

    // 3. Verify (모든 함수 추가 후)
    if not (mlirOperationVerify(moduleDef.GetOperation())) then
        failwith "Module verification failed"

Verification 과정:

  1. 심볼 수집: 모듈의 모든 func.func 연산에서 심볼 추출 (@is_even, @is_odd)
  2. 심볼 참조 확인:func.call 연산의 callee 확인
    • func.call @is_odd@is_odd 심볼이 존재하는가?
    • func.call @is_even@is_even 심볼이 존재하는가?
  3. 타입 검증: 호출 타입과 함수 타입 일치 확인
    • @is_even: (i32) -> i1
    • func.call @is_even(%n_minus_1) : (i32) -> i1일치

실패 케이스 (존재하지 않는 함수 호출):

func.func @foo(%n: i32) -> i1 {
  %result = func.call @nonexistent(%n) : (i32) -> i1
  //                    ↑ 모듈에 없음
  func.return %result : i1
}

Verifier 에러:

error: 'func.call' op 'nonexistent' does not reference a valid function

상호 재귀는 통과: 모든 심볼이 존재하므로 검증 성공.

FunLang Interpreter와의 차이

Interpreter에서 상호 재귀:

// FunLang interpreter (LangTutorial)
let rec eval env ast =
    match ast with
    | LetRec(funcs, body) ->
        // 재귀 환경 생성: 모든 함수를 env에 추가
        let rec_env =
            funcs |> List.fold (fun e (name, func) ->
                e.Add(name, RecursiveClosure(func, rec_env))
            ) env
        eval rec_env body

문제:

  • 환경이 재귀적으로 정의됨 (rec_env가 자기 자신을 참조)
  • F#의 let rec 또는 명시적인 mutation 필요

Compiler는 더 간단:

  • 모듈 심볼 테이블이 자연스럽게 flat namespace 제공
  • 순환 참조를 허용
  • Lazy verification으로 정의 순서 무관

꼬리 재귀와 꼬리 호출 최적화

꼬리 위치 (Tail Position)

**꼬리 위치(tail position)**는 함수에서 마지막으로 실행되는 표현식의 위치다.

let rec factorial n =
    if n <= 1 then
        1           // ← 꼬리 위치 (then 분기의 마지막)
    else
        n * factorial (n - 1)
        //  ↑ factorial 호출은 꼬리 위치가 아님!
        //    호출 후 곱셈이 남아있음

꼬리 위치 판단:

  • then 분기의 1: 꼬리 위치 (분기의 마지막 값)
  • factorial (n - 1) 호출: 꼬리 위치 아님 (호출 후 n * 곱셈이 실행됨)
  • n * factorial(...) 전체: 꼬리 위치 (else 분기의 마지막 값)

일반 규칙:

함수 본체에서:

  • if then/else 각 분기의 마지막 표현식: 꼬리 위치
  • let x = ... in <expr>: <expr>이 꼬리 위치
  • 함수의 최상위 표현식: 꼬리 위치

꼬리 호출(tail call): 꼬리 위치에 있는 함수 호출.

let rec countdown n =
    if n <= 0 then
        0           // ← 꼬리 위치, 값 (호출 아님)
    else
        countdown (n - 1)
        // ↑ 꼬리 위치에 있는 호출 → 꼬리 호출!

countdown (n - 1)은 else 분기의 마지막이고, 호출 후 추가 작업이 없다. 꼬리 호출이다.

꼬리 호출 최적화 (Tail Call Optimization)

**꼬리 호출 최적화(TCO, Tail Call Optimization)**는 꼬리 호출을 **점프(jump)**로 변환하여 스택 프레임을 재사용하는 최적화다.

일반 재귀 (TCO 없음):

factorial(5)
  ├─ 스택 프레임 1 생성
  ├─ factorial(4) 호출
  │  ├─ 스택 프레임 2 생성
  │  ├─ factorial(3) 호출
  │  │  └─ ... (스택 누적)
  │  ├─ 반환 후 n * result 계산 ← 추가 작업
  │  └─ 스택 프레임 2 해제
  ├─ 반환 후 n * result 계산
  └─ 스택 프레임 1 해제

꼬리 재귀 (TCO 사용):

countdown(5)
  ├─ 스택 프레임 1 생성
  ├─ countdown(4) 호출 → 점프로 변환!
  │    (스택 프레임 1 재사용, n 값만 업데이트)
  ├─ countdown(3) 호출 → 점프
  ├─ countdown(2) 호출 → 점프
  ├─ countdown(1) 호출 → 점프
  ├─ countdown(0) 호출 → 점프
  └─ 기저 사례: return 0
     (스택 프레임 1 해제)

핵심 차이:

  • 일반 재귀: 각 호출마다 스택 프레임 생성. 깊이 N → N개 프레임.
  • 꼬리 재귀 + TCO: 단일 스택 프레임 재사용. 깊이 N → 1개 프레임.

왜 가능한가?

꼬리 호출은 “호출 후 돌아올 필요가 없다”:

  • 현재 함수는 호출 결과를 그대로 반환
  • 현재 스택 프레임에 남은 작업이 없음
  • 따라서 현재 프레임을 버리고, 새 프레임으로 점프할 수 있음

꼬리 재귀 변환: Factorial

일반 재귀 factorial (non-tail):

let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)
         // ↑ 호출 후 곱셈 → 꼬리 호출 아님

꼬리 재귀 factorial:

let rec factorial_tail n acc =
    if n <= 1 then acc
    else factorial_tail (n - 1) (n * acc)
         // ↑ 호출이 마지막 → 꼬리 호출!

차이점:

측면일반 재귀꼬리 재귀
Accumulator없음acc 파라미터
곱셈 위치호출 (n * result)호출 (n * acc)
반환값재귀 호출 결과를 변환재귀 호출 결과 그대로
꼬리 호출아님맞음

Accumulator 패턴:

꼬리 재귀는 accumulator를 사용하여 중간 결과를 전달한다:

factorial_tail(5, 1)
  → factorial_tail(4, 5*1=5)
    → factorial_tail(3, 4*5=20)
      → factorial_tail(2, 3*20=60)
        → factorial_tail(1, 2*60=120)
          → return 120

Wrapper 함수:

사용자는 accumulator를 모르므로, wrapper 함수 제공:

let factorial n =
    factorial_tail n 1

MLIR/LLVM에서 TCO

LLVM의 꼬리 호출 최적화:

LLVM은 특정 조건에서 꼬리 호출을 최적화할 수 있다:

  1. 함수 속성 (function attribute): "tailcc" calling convention
  2. 최적화 플래그: -tailcallopt
  3. 타겟 지원: 플랫폼이 TCO를 지원해야 함 (대부분의 x86-64, ARM은 지원)

MLIR IR에서 꼬리 호출 표시:

MLIR func 다이얼렉트는 TCO를 명시적으로 표시하는 속성이 없다. 대신:

  • LLVM dialect로 낮춘 후 tail 속성 추가
  • 또는 LLVM 최적화 패스에 의존

Lowered LLVM IR (꼬리 호출 속성):

define i32 @factorial_tail(i32 %n, i32 %acc) {
entry:
  %cmp = icmp sle i32 %n, 1
  br i1 %cmp, label %base, label %rec

base:
  ret i32 %acc

rec:
  %n_minus_1 = sub i32 %n, 1
  %new_acc = mul i32 %n, %acc
  ; tail 키워드 → TCO 힌트
  %result = tail call i32 @factorial_tail(i32 %n_minus_1, i32 %new_acc)
  ret i32 %result
}

tail call의 의미:

  • “이 호출은 꼬리 호출입니다”
  • LLVM 최적화 패스가 이를 점프로 변환 가능
  • -tailcallopt 플래그와 함께 사용

FunLang Phase 3에서 TCO:

Phase 3에서는 TCO를 보장하지 않는다:

  • 교육 목적: 재귀의 기본 개념 먼저 이해
  • LLVM이 자동으로 최적화할 있지만, 보장되지 않음
  • Phase 7 (최적화)에서 명시적 TCO 지원 추가 예정

현재 동작:

// FunLang 소스
let rec factorial_tail n acc =
    if n <= 1 then acc
    else factorial_tail (n - 1) (n * acc)

// MLIR IR
func.func @factorial_tail(%arg0: i32, %arg1: i32) -> i32 {
  // ... if 조건
  %result = scf.if %cmp -> (i32) {
    scf.yield %arg1 : i32
  } else {
    %n_minus_1 = arith.subi %arg0, %c1 : i32
    %new_acc = arith.muli %arg0, %arg1 : i32
    %rec = func.call @factorial_tail(%n_minus_1, %new_acc) : (i32, i32) -> i32
    // ↑ 일반 func.call (tail 속성 없음)
    scf.yield %rec : i32
  }
  func.return %result : i32
}

LLVM이 최적화할 수 있음 (보장 안 됨):

  • LLVM -O2 또는 -O3 최적화 레벨
  • 일부 경우 자동으로 TCO 적용
  • 하지만 C calling convention에서는 보장되지 않음

TCO 활성화 방법 (Preview)

Phase 7에서 다룰 내용 (Preview):

  1. tailcc calling convention 사용:
define tailcc i32 @factorial_tail(i32 %n, i32 %acc) {
  ; tailcc = 꼬리 호출 최적화에 특화된 calling convention
  ...
  %result = tail call tailcc i32 @factorial_tail(i32 %n_minus_1, i32 %new_acc)
  ret i32 %result
}
  1. Compiler 플래그:
llc -tailcallopt factorial.ll -o factorial.s
  1. 함수 속성:

MLIR에서 LLVM dialect로 낮출 때 함수 속성 추가:

  • llvm.func @factorial_tail ... attributes { tail = true }

현재 (Phase 3):

  • 꼬리 재귀 패턴을 이해
  • accumulator 사용법 배우기
  • LLVM의 자동 최적화에 의존
  • Phase 7에서 명시적 제어 추가

코드 생성 업데이트

compileFuncDef 재사용

좋은 소식: 재귀 함수를 위한 특별한 코드 생성이 필요 없다.

Chapter 10의 compileFuncDef를 그대로 사용:

let compileFuncDef (builder: OpBuilder) (moduleDef: ModuleOp) (FunDef(name, params, body)) =
    // 1. 함수 타입 생성
    let paramTypes = List.replicate params.Length builder.GetI32Type()
    let returnType = builder.GetI32Type()
    let funcType = builder.GetFunctionType(paramTypes, returnType)

    // 2. func.func 생성
    let funcOp = builder.CreateFuncOp(name, funcType)

    // 3. Entry block에서 파라미터 가져오기
    let entryBlock = funcOp.GetEntryBlock()
    builder.SetInsertionPointToEnd(entryBlock)

    let env =
        params
        |> List.mapi (fun i paramName ->
            let argValue = entryBlock.GetArgument(i)
            (paramName, argValue))
        |> Map.ofList

    // 4. 본체 컴파일
    let bodyValue = compileExpr builder env body

    // 5. 반환
    builder.CreateFuncReturn(bodyValue)

    // 6. 모듈에 추가
    moduleDef.AddFunction(funcOp)

재귀 호출은 자동으로 처리:

compileExprApp case:

| App(funcName, args) ->
    let argValues = args |> List.map (compileExpr builder env)
    builder.CreateFuncCall(funcName, argValues)
  • App("factorial", [Num 5])func.call @factorial(%c5) : (i32) -> i32
  • App("factorial", [BinOp(...)])func.call @factorial(%n_minus_1) : (i32) -> i32

자기 참조가 자연스럽게 작동:

  • @factorial 심볼이 모듈에 이미 존재 (본체 컴파일 중이지만 함수 자체는 이미 추가됨)
  • CreateFuncCall이 심볼 참조 생성
  • Verifier가 나중에 확인

상호 재귀 처리

상호 재귀도 특별한 처리 불필요:

let compileProgram (builder: OpBuilder) (moduleDef: ModuleOp) (Program(funcs, mainExpr)) =
    // 모든 함수 컴파일
    funcs |> List.iter (compileFuncDef builder moduleDef)

    // Main 표현식 컴파일
    // ...

순서:

  1. is_even 컴파일:

    • func.func @is_even 생성, 모듈에 추가
    • 본체에서 func.call @is_odd 생성 (아직 @is_odd 없음 - OK!)
  2. is_odd 컴파일:

    • func.func @is_odd 생성, 모듈에 추가
    • 본체에서 func.call @is_even 생성 (@is_even 이미 존재)
  3. Verification:

    • @is_evenfunc.call @is_odd@is_odd 존재 확인 ✓
    • @is_oddfunc.call @is_even@is_even 존재 확인 ✓

핵심: MLIR의 lazy verification 덕분에 순서 무관.

compileProgram 전체 구조

다중 함수 + Main 표현식:

let compileProgram (builder: OpBuilder) (moduleDef: ModuleOp) (Program(funcs, mainExpr)) =
    // 1. 모든 함수 정의 컴파일
    funcs |> List.iter (fun funcDef ->
        compileFuncDef builder moduleDef funcDef
    )

    // 2. Main 함수 생성
    let mainFuncType = builder.GetFunctionType([], builder.GetI32Type())
    let mainFunc = builder.CreateFuncOp("funlang_main", mainFuncType)

    let mainBlock = mainFunc.GetEntryBlock()
    builder.SetInsertionPointToEnd(mainBlock)

    // 3. Main 표현식 컴파일
    let mainValue = compileExpr builder Map.empty mainExpr

    // 4. Main 반환
    builder.CreateFuncReturn(mainValue)

    moduleDef.AddFunction(mainFunc)

    // 5. Verification
    if not (mlirOperationVerify(moduleDef.GetOperation())) then
        failwith "Module verification failed"

    moduleDef

프로그램 구조:

Program([
    FunDef("factorial", ["n"], <body>),
    FunDef("fibonacci", ["n"], <body>),
    FunDef("is_even", ["n"], <body>),
    FunDef("is_odd", ["n"], <body>)
], App("factorial", [Num 5]))

생성된 MLIR:

module {
  func.func @factorial(%arg0: i32) -> i32 { ... }
  func.func @fibonacci(%arg0: i32) -> i32 { ... }
  func.func @is_even(%arg0: i32) -> i1 { ... }
  func.func @is_odd(%arg0: i32) -> i1 { ... }

  func.func @funlang_main() -> i32 {
    %c5 = arith.constant 5 : i32
    %result = func.call @factorial(%c5) : (i32) -> i32
    func.return %result : i32
  }
}

완전한 예시: 여러 재귀 함수

프로그램 소스

// 함수 정의들
let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)

let rec fibonacci n =
    if n <= 1 then n
    else fibonacci (n - 1) + fibonacci (n - 2)

let rec is_even n =
    if n = 0 then true
    else is_odd (n - 1)

let rec is_odd n =
    if n = 0 then false
    else is_even (n - 1)

// Main 표현식
let result_fact = factorial 5 in
let result_fib = fibonacci 6 in
let result_even = is_even 4 in
result_fact + result_fib + result_even

AST 표현 (간략)

Program([
    FunDef("factorial", ["n"], <factorial_body>),
    FunDef("fibonacci", ["n"], <fibonacci_body>),
    FunDef("is_even", ["n"], <is_even_body>),
    FunDef("is_odd", ["n"], <is_odd_body>)
],
Let("result_fact", App("factorial", [Num 5]),
Let("result_fib", App("fibonacci", [Num 6]),
Let("result_even", App("is_even", [Num 4]),
BinOp(
    BinOp(Var "result_fact", Add, Var "result_fib"),
    Add,
    Var "result_even"
)))))

생성된 MLIR IR (전체)

module {
  // factorial 함수
  func.func @factorial(%arg0: i32) -> i32 {
    %c1 = arith.constant 1 : i32
    %cmp = arith.cmpi sle, %arg0, %c1 : i32
    %result = scf.if %cmp -> (i32) {
      scf.yield %c1 : i32
    } else {
      %n_minus_1 = arith.subi %arg0, %c1 : i32
      %rec = func.call @factorial(%n_minus_1) : (i32) -> i32
      %product = arith.muli %arg0, %rec : i32
      scf.yield %product : i32
    }
    func.return %result : i32
  }

  // fibonacci 함수
  func.func @fibonacci(%arg0: i32) -> i32 {
    %c1 = arith.constant 1 : i32
    %cmp = arith.cmpi sle, %arg0, %c1 : i32
    %result = scf.if %cmp -> (i32) {
      scf.yield %arg0 : i32
    } else {
      %n_minus_1 = arith.subi %arg0, %c1 : i32
      %fib_n_1 = func.call @fibonacci(%n_minus_1) : (i32) -> i32
      %c2 = arith.constant 2 : i32
      %n_minus_2 = arith.subi %arg0, %c2 : i32
      %fib_n_2 = func.call @fibonacci(%n_minus_2) : (i32) -> i32
      %sum = arith.addi %fib_n_1, %fib_n_2 : i32
      scf.yield %sum : i32
    }
    func.return %result : i32
  }

  // is_even 함수
  func.func @is_even(%arg0: i32) -> i1 {
    %c0 = arith.constant 0 : i32
    %is_zero = arith.cmpi eq, %arg0, %c0 : i32
    %result = scf.if %is_zero -> (i1) {
      %true = arith.constant 1 : i1
      scf.yield %true : i1
    } else {
      %c1 = arith.constant 1 : i32
      %n_minus_1 = arith.subi %arg0, %c1 : i32
      %odd_result = func.call @is_odd(%n_minus_1) : (i32) -> i1
      scf.yield %odd_result : i1
    }
    func.return %result : i1
  }

  // is_odd 함수
  func.func @is_odd(%arg0: i32) -> i1 {
    %c0 = arith.constant 0 : i32
    %is_zero = arith.cmpi eq, %arg0, %c0 : i32
    %result = scf.if %is_zero -> (i1) {
      %false = arith.constant 0 : i1
      scf.yield %false : i1
    } else {
      %c1 = arith.constant 1 : i32
      %n_minus_1 = arith.subi %arg0, %c1 : i32
      %even_result = func.call @is_even(%n_minus_1) : (i32) -> i1
      scf.yield %even_result : i1
    }
    func.return %result : i1
  }

  // Main 함수
  func.func @funlang_main() -> i32 {
    // result_fact = factorial(5)
    %c5 = arith.constant 5 : i32
    %result_fact = func.call @factorial(%c5) : (i32) -> i32

    // result_fib = fibonacci(6)
    %c6 = arith.constant 6 : i32
    %result_fib = func.call @fibonacci(%c6) : (i32) -> i32

    // result_even = is_even(4)
    %c4 = arith.constant 4 : i32
    %result_even_i1 = func.call @is_even(%c4) : (i32) -> i1
    %result_even = arith.extui %result_even_i1 : i1 to i32

    // result_fact + result_fib + result_even
    %sum1 = arith.addi %result_fact, %result_fib : i32
    %sum2 = arith.addi %sum1, %result_even : i32

    func.return %sum2 : i32
  }
}

컴파일 및 실행

# 1. MLIR 파일 저장
echo "<위 MLIR IR>" > recursion_example.mlir

# 2. Lowering passes 적용
mlir-opt \
  --convert-scf-to-cf \
  --convert-func-to-llvm \
  --convert-arith-to-llvm \
  recursion_example.mlir \
  -o lowered.mlir

# 3. LLVM IR로 변환
mlir-translate --mlir-to-llvmir lowered.mlir -o recursion.ll

# 4. Object file 생성
llc -filetype=obj recursion.ll -o recursion.o

# 5. Runtime과 링크
gcc -o recursion recursion.o runtime.o -lgc

# 6. 실행
./recursion
# 출력: 129
# (factorial(5)=120, fibonacci(6)=8, is_even(4)=1, 120+8+1=129)

실행 결과 분석

계산 과정:

  1. factorial(5) = 120
  2. fibonacci(6) = 8
  3. is_even(4) = true = 1 (i32로 확장)
  4. 120 + 8 + 1 = 129

스택 사용:

  • factorial(5): 최대 5개 스택 프레임
  • fibonacci(6): 최대 6개 스택 프레임 (하지만 호출 트리가 넓음)
  • is_even(4): 최대 5개 스택 프레임 (is_even/is_odd 번갈아가며)

총 호출 횟수:

  • factorial(5): 5번
  • fibonacci(6): 25번 (지수 복잡도!)
  • is_even(4) + is_odd: 5번

성능 고려사항

재귀 vs 반복 성능

재귀의 오버헤드:

  1. 함수 호출 비용:

    • 스택 프레임 생성/소멸
    • 레지스터 저장/복원
    • 점프 instruction (call/ret)
  2. 스택 메모리 사용:

    • 깊이 N → N개 스택 프레임
    • 각 프레임 ~64-128 bytes
    • 캐시 미스 가능성
  3. 분기 예측:

    • 재귀 호출은 간접 분기
    • CPU 분기 예측기가 학습하기 어려움

반복(Loop)의 이점:

  1. 함수 호출 없음:

    • 단일 스택 프레임
    • 레지스터 할당 효율적
  2. 명령어 수 감소:

    • 직접 점프 (conditional branch)
    • 예측 가능한 패턴
  3. 메모리 효율:

    • 스택 사용 최소

언제 재귀가 괜찮은가?

  1. 얕은 재귀 (shallow recursion):

    • 깊이 < 100: 성능 차이 미미
    • 예: 균형 트리 탐색 (깊이 ~log N)
  2. 꼬리 재귀 + TCO:

    • 컴파일러가 loop으로 변환
    • 성능이 반복과 동일
  3. 알고리즘이 본질적으로 재귀적:

    • 트리 순회, 퀵소트, 병합정렬
    • 재귀로 작성하는 것이 자연스럽고 명확

언제 재귀를 피해야 하는가?

  1. 깊은 재귀 (deep recursion):

    • 깊이 > 10,000: 스택 오버플로우 위험
    • 예: naive fibonacci
  2. 중복 계산:

    • Fibonacci 같은 지수 복잡도
    • Memoization 또는 DP로 해결
  3. 성능이 중요한 경우:

    • 내부 루프 (hot path)
    • 반복으로 작성 또는 TCO 보장

스택 프레임 오버헤드

스택 프레임 구조 (x86-64):

┌──────────────────────┐
│ Return address       │ 8 bytes
├──────────────────────┤
│ Saved rbp (frame ptr)│ 8 bytes
├──────────────────────┤
│ Local variables      │ Variable
├──────────────────────┤
│ Saved registers      │ Variable (callee-saved)
├──────────────────────┤
│ Padding (alignment)  │ 0-15 bytes (16-byte align)
└──────────────────────┘

최소 크기: ~16 bytes (return address + rbp) 일반적 크기: 64-128 bytes (지역 변수, 레지스터 저장 포함)

호출 비용:

  • call instruction: ~1-2 CPU cycles (분기 예측 성공 시)
  • 스택 프레임 setup: ~5-10 instructions (push rbp, mov, sub)
  • 스택 프레임 teardown: ~5-10 instructions (mov, pop, ret)
  • 총: ~20-30 instructions per call

비교 (factorial 1000):

  • 재귀: 1,000 함수 호출 × 30 instructions = 30,000 instructions
  • 반복: ~5 instructions per iteration × 1,000 = 5,000 instructions

6배 차이! 하지만 절대 시간은 여전히 작음 (~수 마이크로초).

LLVM 최적화 기회

LLVM이 재귀에 적용하는 최적화:

  1. Tail Call Elimination (TCO):

    • 꼬리 재귀 → loop 변환
    • 스택 사용 O(1)
  2. Inlining:

    • 작은 재귀 함수를 호출 사이트에 인라인
    • 함수 호출 오버헤드 제거
  3. Constant Folding:

    • 컴파일 타임에 계산 가능한 재귀 (예: factorial(5)) → 상수 120
  4. Loop Optimization:

    • 재귀를 loop으로 변환 후 loop unrolling, vectorization 적용

예시 (LLVM -O3):

// 소스
let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)

let result = factorial 5

LLVM -O3 최적화 후:

define i32 @funlang_main() {
  ret i32 120  ; 컴파일 타임에 계산됨!
}

변수 입력 (factorial n, n이 런타임 값):

LLVM은 재귀를 그대로 유지하지만, 레지스터 할당과 분기 예측을 최적화.

Phase 7 최적화 Preview

Phase 7에서 다룰 내용:

  1. 명시적 TCO 지원:

    • tailcc calling convention
    • 꼬리 재귀 자동 감지 및 변환
  2. Inlining 제어:

    • 작은 함수 자동 인라인
    • inline 힌트
  3. Memoization:

    • 함수 결과 캐싱 (fibonacci 최적화)
  4. Loop 변환:

    • 재귀 → 반복 자동 변환 (특정 패턴)

현재 (Phase 3):

  • 재귀의 기본 개념과 제약 이해
  • 성능 트레이드오프 인지
  • LLVM의 기본 최적화에 의존

일반적인 오류

Error 1: 무한 재귀 (기저 사례 누락)

문제:

let rec infinite_loop n =
    infinite_loop (n - 1)
    // 기저 사례가 없음!

증상:

./program
Segmentation fault (core dumped)

원인:

  • 재귀 종료 조건이 없음
  • 스택이 무한히 성장
  • 스택 오버플로우

해결:

let rec countdown n =
    if n <= 0 then 0  // ← 기저 사례 추가
    else countdown (n - 1)

디버깅 팁:

  • 모든 재귀 함수에 기저 사례가 있는지 확인
  • “언제 재귀가 멈추는가?” 질문

Error 2: 스택 오버플로우 (깊은 재귀)

문제:

let rec sum_to n =
    if n <= 0 then 0
    else n + sum_to (n - 1)

let result = sum_to 100000  // 깊이 100,000!

증상:

./program
Segmentation fault (core dumped)

원인:

  • 재귀 깊이 > 스택 크기
  • 100,000 프레임 × 64 bytes = 6.4 MB > 일부 플랫폼 한계

해결:

  1. 꼬리 재귀로 변환:
let rec sum_to_tail n acc =
    if n <= 0 then acc
    else sum_to_tail (n - 1) (n + acc)

let sum_to n = sum_to_tail n 0
  1. 입력 크기 제한:
if n > 10000 then
    failwith "Input too large"
else
    sum_to n
  1. 반복으로 변환:
// FunLang은 loop 없지만, LLVM이 TCO로 변환 가능

Error 3: 심볼을 찾을 수 없음 (타이포)

문제:

let rec factorial n =
    if n <= 1 then 1
    else n * factorail (n - 1)  // typo: factorail

증상 (MLIR verification):

error: 'func.call' op 'factorail' does not reference a valid function

원인:

  • 함수 이름 오타
  • 심볼 @factorail이 모듈에 없음

해결:

  • 함수 이름 철자 확인
  • IDE의 자동완성 사용

Error 4: 인자 순서 오류 (상호 재귀)

문제:

let rec is_even n =
    if n = 0 then true
    else is_odd n  // ← (n - 1) 빠뜨림!

let rec is_odd n =
    if n = 0 then false
    else is_even n  // ← 똑같은 오류

증상:

./program
Segmentation fault (core dumped)

원인:

  • 무한 재귀: is_even(4) → is_odd(4) → is_even(4) → …
  • 인자가 감소하지 않음

해결:

let rec is_even n =
    if n = 0 then true
    else is_odd (n - 1)  // ← (n - 1) 추가

let rec is_odd n =
    if n = 0 then false
    else is_even (n - 1)

Error 5: 꼬리 위치가 아닌 곳에서 TCO 기대

문제:

let rec factorial n =
    if n <= 1 then 1
    else n * factorial (n - 1)
    //       ↑ 꼬리 위치 아님 (곱셈 후 실행)

// TCO가 적용될 것으로 기대하지만, 실제로는 안 됨

증상:

  • 깊은 재귀에서 스택 오버플로우
  • TCO가 적용되지 않음

원인:

  • 재귀 호출 후 추가 작업 (n *)
  • 꼬리 호출이 아님

해결:

// accumulator 패턴으로 변환
let rec factorial_tail n acc =
    if n <= 1 then acc
    else factorial_tail (n - 1) (n * acc)
    //   ↑ 꼬리 위치! (호출이 마지막)

디버깅 팁

  1. Print 디버깅:
let rec factorial n =
    // 디버깅: 함수 호출 출력
    print_int n;
    if n <= 1 then 1
    else n * factorial (n - 1)
  1. 기저 사례 먼저 확인:

재귀 함수를 작성할 때:

  • 먼저 기저 사례 작성
  • 그 다음 재귀 사례 작성
  1. 작은 입력으로 테스트:
// factorial 100000 전에 factorial 5 먼저 테스트
  1. 스택 크기 늘리기 (임시 해결):
# Linux에서 스택 크기 늘리기
ulimit -s 16384  # 16 MB
./program
  1. MLIR IR 검증:
mlir-opt --verify-diagnostics program.mlir

요약 및 Phase 3 완료

Chapter 11 요약

배운 내용:

  1. 재귀의 기본:

    • 자기 자신을 호출하는 함수
    • 기저 사례 + 재귀 사례
    • 예시: factorial, fibonacci
  2. MLIR에서 재귀:

    • 모듈 레벨 심볼 테이블
    • 자기 참조 (func.call @factorial inside @factorial)
    • 심볼 확인은 컴파일 타임, 호출은 런타임
  3. 상호 재귀:

    • 두 함수가 서로 호출 (is_even, is_odd)
    • Forward declaration 불필요
    • Flat symbol namespace 덕분에 자연스럽게 작동
  4. 스택 프레임:

    • 각 재귀 호출마다 스택 프레임 생성
    • 깊이 N → N개 프레임
    • 스택 크기 제한 (8 MB Linux, 1 MB Windows)
  5. 꼬리 호출 최적화:

    • 꼬리 위치 = 함수의 마지막 표현식
    • 꼬리 호출 = 꼬리 위치의 함수 호출
    • TCO = 꼬리 호출을 점프로 변환, 스택 재사용
    • Accumulator 패턴으로 꼬리 재귀 변환
  6. 성능:

    • 재귀는 오버헤드 있음 (함수 호출, 스택)
    • 얕은 재귀는 괜찮음
    • 깊은 재귀는 TCO 필요
    • LLVM 최적화 활용
  7. 일반적인 오류:

    • 무한 재귀 (기저 사례 누락)
    • 스택 오버플로우 (깊은 재귀)
    • 타이포 (심볼 참조 실패)
    • 인자 오류 (상호 재귀)
    • 꼬리 위치 오해

Phase 3 완료!

Phase 3 목표:

  • 최상위 명명된 함수 (Chapter 10)
  • 함수 파라미터와 호출 (Chapter 10)
  • 재귀 함수 (Chapter 11)
  • 상호 재귀 (Chapter 11)
  • 스택 프레임 관리 (Chapter 11)
  • 꼬리 호출 최적화 개념 (Chapter 11)

Phase 3에서 구축한 것:

  1. func 다이얼렉트 통합:

    • func.func, func.call, func.return 연산
    • P/Invoke 바인딩 및 OpBuilder 메서드
  2. 함수 컴파일 인프라:

    • compileFuncDef: AST → func.func
    • compileProgram: 다중 함수 + main
    • 환경 관리 (파라미터를 block arguments로)
  3. 재귀 지원:

    • 자기 참조 (심볼 테이블)
    • 상호 재귀 (lazy verification)
    • 스택 기반 실행 모델
  4. Calling convention:

    • C calling convention (System V ABI)
    • LLVM의 자동 프롤로그/에필로그 생성

Phase 3에서 제외된 것 (Phase 4로 연기):

  • 클로저: 환경을 캡처하는 함수
  • 고차 함수: 함수를 인자로 받거나 반환
  • 익명 함수: Lambda 표현식
  • 힙 할당: 클로저 환경 (GC_malloc 사용)

Phase 4 Preview

Phase 4: 클로저와 고차 함수

목표:

  1. Lambda 표현식:

    let add_n n = fun x -> x + n
    
  2. 환경 캡처:

    let make_counter () =
        let count = ref 0 in
        fun () -> (count := !count + 1; !count)
    
  3. 고차 함수:

    let map f list = ...
    let result = map (fun x -> x * 2) [1; 2; 3]
    
  4. 클로저 변환:

    • 자유 변수 분석
    • 환경을 힙에 할당 (GC_malloc)
    • 클로저 = (function pointer, environment pointer)
  5. Heap 사용:

    • Chapter 9 (Boehm GC) 활성화
    • memref 다이얼렉트 (alloc, load, store)

연결:

  • Phase 3: 스택 기반 함수 (파라미터만 사용)
  • Phase 4: 힙 기반 클로저 (파라미터 + 캡처된 환경)

다음 단계

완성된 컴파일러 능력:

Phase 3 완료 후 FunLang 컴파일러는 다음을 지원한다:

  • 산술 및 비교 연산 (Chapter 06)
  • Let 바인딩과 변수 (Chapter 07)
  • If/then/else 제어 흐름 (Chapter 08)
  • 메모리 관리 (Boehm GC 통합, Chapter 09)
  • 함수 정의 및 호출 (Chapter 10)
  • 재귀 및 상호 재귀 (Chapter 11)

아직 지원하지 않는 것:

  • 클로저 및 lambda
  • 고차 함수
  • 패턴 매칭
  • 대수적 데이터 타입 (ADT)
  • 리스트, 튜플 등 데이터 구조
  • 타입 시스템 (현재 모두 i32)

학습 경로:

Phase 1 (Foundation): MLIR 기초, P/Invoke
  ↓
Phase 2 (Core Language): 표현식, 제어 흐름, 메모리
  ↓
Phase 3 (Functions): 함수, 재귀, 스택 ← 현재 위치
  ↓
Phase 4 (Closures): 클로저, 고차 함수, 힙
  ↓
Phase 5 (Data Structures): 리스트, 튜플, ADT
  ↓
Phase 6 (Type System): 타입 추론, 다형성
  ↓
Phase 7 (Optimization): 인라인, TCO, 최적화 패스

축하합니다! Phase 3를 완료했습니다. FunLang 컴파일러는 이제 재귀 함수를 포함한 완전한 프로그램을 네이티브 코드로 컴파일할 수 있습니다.

다음 장 (Phase 4)에서: 클로저와 환경 캡처를 추가하여 진정한 함수형 프로그래밍 기능을 구현할 것입니다.

Chapter 12: 클로저 (Closures)

소개

**클로저(closure)**는 함수형 프로그래밍의 핵심 기능이다. 클로저는 단순한 함수가 아니라, **함수 + 환경(environment)**의 조합이다.

// Phase 3 함수 - 외부 변수 사용 불가
let add x y = x + y

// Phase 4 클로저 - 외부 변수 캡처 가능
let make_adder n =
    fun x -> x + n   // n을 캡처!

fun x -> x + n클로저다:

  • x파라미터 (bound variable)
  • n캡처된 변수 (free variable, captured from environment)

클로저가 생성될 때, n의 값이 **환경(environment)**에 저장된다. 나중에 클로저가 호출되면, 저장된 n 값을 사용한다.

왜 클로저가 중요한가?

  1. 고차 함수(Higher-order functions)의 기초: 함수를 반환하거나 인자로 전달하려면 클로저가 필요하다
  2. 상태 캡처: 함수 생성 시점의 환경을 저장할 수 있다
  3. 추상화: 공통 패턴을 클로저로 추상화할 수 있다 (map, filter, fold)

클로저 vs Phase 3 함수:

Phase 3 함수Phase 4 클로저
이름이 있다 (named)익명 가능 (anonymous)
외부 변수 사용 불가외부 변수 캡처 가능
func.func 연산함수 포인터 + 환경
정적 바인딩환경 저장 필요

Chapter 12의 범위:

이 장에서 다루는 것:

  1. 클로저 이론: 자유 변수(free variables), 바운드 변수(bound variables)
  2. 자유 변수 분석: 어떤 변수를 캡처해야 하는지 계산
  3. 클로저 변환(Closure conversion): 암묵적 캡처를 명시적으로 만들기
  4. 환경 구조체: 캡처된 변수를 저장하는 힙 객체
  5. 클로저 생성 코드: GC_malloc으로 환경 할당하기

이 장을 마치면:

  • 클로저가 무엇이고 왜 필요한지 이해한다
  • 자유 변수 분석 알고리즘을 구현할 수 있다
  • 클로저를 (함수 포인터, 환경 포인터) 쌍으로 표현할 수 있다
  • 환경을 힙에 할당하고 변수를 저장/로드할 수 있다
  • GC_malloc을 사용해 환경을 생성할 수 있다

Preview: Chapter 13에서는 고차 함수 (map, filter)를 추가한다. Chapter 12는 클로저의 기초를 확립한다.

클로저 이론

Lexical Scoping vs Dynamic Scoping

클로저를 이해하려면 먼저 스코핑(scoping) 개념을 알아야 한다. 변수의 값이 어떻게 결정되는가?

Lexical scoping (정적 스코핑):

변수는 코드 작성 시점의 위치로 결정된다.

let x = 10 in
let f = fun y -> x + y in
let x = 20 in
f 5   // 결과: 15 (x = 10 사용)

fun y -> x + y에서 x정의 시점의 x (10)을 참조한다. 나중에 x를 20으로 재바인딩해도 영향 없다.

Dynamic scoping (동적 스코핑):

변수는 호출 시점의 환경에서 찾는다.

// 동적 스코핑 가상 예시 (FunLang은 지원 안 함)
let x = 10 in
let f = fun y -> x + y in
let x = 20 in
f 5   // 결과: 25 (x = 20 사용)

f를 호출할 때, x호출 시점의 환경에서 찾는다 (20).

FunLang은 lexical scoping을 사용한다. 대부분의 현대 언어가 그렇다 (F#, JavaScript, Python, Rust, etc.). Dynamic scoping은 혼란스럽고 디버깅이 어렵다.

Lexical scoping의 의미:

  • 함수가 정의될 때, 그 시점의 환경을 기억해야 한다
  • 함수가 호출될 때, 저장된 환경을 사용해야 한다
  • 이것이 클로저다: function + environment

Free Variables vs Bound Variables

변수는 두 가지로 분류된다:

Bound variable (바운드 변수):

함수의 파라미터이거나, let 바인딩으로 정의된 변수.

fun x -> x + 1
//  ↑   ↑
//  바인딩  사용

xbound variable이다. fun xx를 바인딩한다.

Free variable (자유 변수):

함수 내부에서 사용되지만, 그 함수에서 바인딩되지 않은 변수.

fun x -> x + y
//           ↑
//       자유 변수!

yfree variable이다. fun xy를 바인딩하지 않는다. y외부 환경에서 와야 한다.

예시 1: 자유 변수 없음

fun x -> x + 1
  • Bound: {x}
  • Free: {} (empty)

예시 2: 자유 변수 하나

fun x -> x + y
  • Bound: {x}
  • Free: {y}

예시 3: 중첩된 람다

fun x -> fun y -> x + y + z

내부 람다 fun y -> x + y + z를 보면:

  • Bound: {y}
  • Free: {x, z}

외부 람다 fun x -> ...를 보면:

  • Bound: {x}
  • Free: {z}

전체 표현식의 자유 변수: {z}

예시 4: Let 바인딩

let a = 10 in
fun x -> a + x

fun x -> a + x:

  • Bound: {x}
  • Free: {a}

하지만 전체 표현식은 let a = 10a를 바인딩하므로:

  • 전체 자유 변수: {} (empty)

환경 캡처 (Environment Capture)

자유 변수가 있으면, 그 값을 어디선가 가져와야 한다. 클로저는 **환경(environment)**을 저장해서 해결한다.

환경이란?

환경은 변수 이름 → 값의 매핑이다.

let x = 10 in
let y = 20 in
fun z -> x + y + z

fun z -> x + y + z가 생성될 때:

  • 자유 변수: {x, y}
  • 환경에서 찾기: x = 10, y = 20
  • 환경 캡처: {x: 10, y: 20}을 저장

클로저는 (함수 포인터, 환경 포인터) 쌍이 된다:

Closure {
    fn_ptr: @lambda_123,
    env: { x: 10, y: 20 }
}

나중에 클로저를 호출할 때:

  1. 함수 포인터를 찾는다 (@lambda_123)
  2. 환경을 함수에 전달한다 ({x: 10, y: 20})
  3. 함수는 환경에서 x, y 값을 로드한다
  4. 계산: 10 + 20 + z

Value capture vs Reference capture:

FunLang은 value capture를 사용한다. 변수의 현재 값을 복사해서 저장한다.

let x = 10 in
let f = fun y -> x + y in
let x = 20 in   // x 재바인딩
f 5   // 결과: 15 (캡처된 x = 10 사용)

클로저가 생성될 때 x = 10이 환경에 복사된다. 나중에 x가 재바인딩되어도 영향 없다.

(참조 캡처는 C++의 [&x] 같은 개념인데, FunLang은 순수 함수형이므로 지원 안 함)

클로저의 구조

클로저는 두 개의 포인터로 표현된다:

// C 스타일 표현
struct Closure {
    void* fn_ptr;      // 함수 코드 포인터
    void* env_ptr;     // 환경 데이터 포인터
};

1. 함수 포인터 (fn_ptr):

실행할 코드의 주소. MLIR에서는 @lambda_N 심볼.

2. 환경 포인터 (env_ptr):

캡처된 변수들을 저장한 힙 객체. 구조체의 주소.

시각적 다이어그램:

클로저 생성:
  let x = 10 in
  let y = 20 in
  fun z -> x + y + z

메모리 레이아웃:
┌─────────────────────┐
│ Closure (스택/레지스터) │
├─────────────────────┤
│ fn_ptr: @lambda_0   │───┐
│ env_ptr: 0x1a3b5c8  │───┼───────┐
└─────────────────────┘   │       │
                          │       │
                          │       v
                          │  ┌──────────────┐
                          │  │ Environment  │
                          │  │ (힙 할당)     │
                          │  ├──────────────┤
                          │  │ x: 10        │
                          │  │ y: 20        │
                          │  └──────────────┘
                          │
                          v
                    @lambda_0 코드:
                      ; env를 파라미터로 받음
                      ; env[0]에서 x 로드
                      ; env[1]에서 y 로드
                      ; x + y + z 계산

핵심:

  • 클로저는 작은 객체 (포인터 2개)
  • 환경은 힙에 할당 (크기는 캡처된 변수 개수에 따라 다름)
  • 함수는 환경을 첫 번째 파라미터로 받음

자유 변수 분석 (Free Variable Analysis)

클로저를 컴파일하려면, 어떤 변수를 캡처해야 하는지 알아야 한다. 이것이 **자유 변수 분석(free variable analysis)**이다.

분석 알고리즘

자유 변수를 찾는 알고리즘은 set-based traversal이다:

  1. AST를 재귀적으로 순회
  2. 각 표현식에서 자유 변수 set을 계산
  3. 바운드 변수는 자유 변수 set에서 제거

정의:

FV(expr) = 표현식 expr의 자유 변수 집합
BV(expr) = 표현식 expr에서 바인딩되는 변수 집합

규칙:

ExpressionFree VariablesBound Variables
Var(x){x}{}
Num(n){}{}
Add(e1, e2)FV(e1) ∪ FV(e2){}
Let(x, e1, e2)FV(e1) ∪ (FV(e2) - {x}){x}
Lambda(x, body)FV(body) - {x}{x}
App(f, arg)FV(f) ∪ FV(arg){}
If(cond, t, f)FV(cond) ∪ FV(t) ∪ FV(f){}

핵심 규칙 설명:

1. Var(x):

변수 사용은 자유 변수다 (아직 바인딩 확인 안 함).

FV(x) = {x}

2. Lambda(x, body):

람다가 x를 바인딩하므로, body의 자유 변수에서 x를 제거.

FV(fun x -> body) = FV(body) - {x}

3. Let(x, e1, e2):

e1의 자유 변수 + (e2의 자유 변수 - {x})

FV(let x = e1 in e2) = FV(e1) ∪ (FV(e2) - {x})

4. 기타 연산:

자식 표현식들의 자유 변수를 합집합.

FV(e1 + e2) = FV(e1) ∪ FV(e2)

F# 구현

// AST 정의 (간략화)
type Expr =
    | Var of string
    | Num of int
    | Add of Expr * Expr
    | Sub of Expr * Expr
    | Let of string * Expr * Expr
    | Lambda of string * Expr
    | App of Expr * Expr
    | If of Expr * Expr * Expr

// 자유 변수 분석
let rec freeVars (expr: Expr) : Set<string> =
    match expr with
    | Var(x) ->
        // 변수 사용 = 자유 변수 후보
        Set.singleton x

    | Num(_) ->
        // 리터럴 = 자유 변수 없음
        Set.empty

    | Add(e1, e2)
    | Sub(e1, e2) ->
        // 이항 연산 = 양쪽의 자유 변수 합
        Set.union (freeVars e1) (freeVars e2)

    | Let(x, e1, e2) ->
        // let x = e1 in e2
        // e1의 자유 변수 + (e2의 자유 변수 - {x})
        let fv1 = freeVars e1
        let fv2 = freeVars e2
        Set.union fv1 (Set.remove x fv2)

    | Lambda(param, body) ->
        // fun param -> body
        // body의 자유 변수 - {param}
        let fvBody = freeVars body
        Set.remove param fvBody

    | App(func, arg) ->
        // f arg
        // f의 자유 변수 + arg의 자유 변수
        Set.union (freeVars func) (freeVars arg)

    | If(cond, thenExpr, elseExpr) ->
        // if cond then thenExpr else elseExpr
        // 세 부분의 자유 변수 합
        freeVars cond
        |> Set.union (freeVars thenExpr)
        |> Set.union (freeVars elseExpr)

예시 분석

예시 1: 단순 람다

fun x -> x + 1

분석:

FV(fun x -> x + 1)
= FV(x + 1) - {x}
= (FV(x) ∪ FV(1)) - {x}
= ({x} ∪ {}) - {x}
= {} (empty)

결과: 자유 변수 없음

예시 2: 하나의 자유 변수

fun x -> x + y

분석:

FV(fun x -> x + y)
= FV(x + y) - {x}
= (FV(x) ∪ FV(y)) - {x}
= ({x} ∪ {y}) - {x}
= {y}

결과: 자유 변수 = {y}

예시 3: 중첩 람다

fun x -> fun y -> x + y + z

분석:

내부: FV(fun y -> x + y + z)
    = FV(x + y + z) - {y}
    = ({x} ∪ {y} ∪ {z}) - {y}
    = {x, z}

외부: FV(fun x -> (fun y -> x + y + z))
    = FV(fun y -> ...) - {x}
    = {x, z} - {x}
    = {z}

결과: 자유 변수 = {z}

예시 4: Let 바인딩

let a = 10 in
let b = a + 5 in
fun x -> a + b + x

분석:

1. FV(fun x -> a + b + x) = {a, b}

2. FV(let b = a + 5 in (fun x -> ...))
   = FV(a + 5) ∪ (FV(fun x -> ...) - {b})
   = {a} ∪ ({a, b} - {b})
   = {a} ∪ {a}
   = {a}

3. FV(let a = 10 in (let b = ...))
   = FV(10) ∪ (FV(let b = ...) - {a})
   = {} ∪ ({a} - {a})
   = {} (empty)

결과: 전체 표현식의 자유 변수 없음 (모든 변수가 바인딩됨)

하지만 fun x -> a + b + x 자체는 {a, b}를 캡처해야 한다.

스코프와 섀도잉 (Shadowing)

섀도잉은 같은 이름의 변수를 재바인딩하는 것이다.

let x = 10 in
let f = fun y -> x + y in
let x = 20 in
f 5

분석:

1. 내부 x: let x = 10 에서 바인딩
2. fun y -> x + y: x는 첫 번째 x (10) 참조
3. 외부 x: let x = 20 에서 재바인딩 (다른 x)
4. f 5: f는 x = 10을 캡처한 클로저

중요: 자유 변수 분석은 lexical scope을 따른다. 변수는 가장 가까운 바인딩 지점을 참조한다.

F# 구현에서 Set.remove가 이것을 처리한다:

  • Let(x, e1, e2)에서 Set.remove x fv2
  • Lambda(x, body)에서 Set.remove x fvBody

예시: 중첩된 섀도잉

let x = 1 in
let f = fun y ->
    let x = 2 in
    fun z -> x + y + z
in
f 10 100

내부 람다 fun z -> x + y + z:

  • x: let x = 2 참조 (가장 가까운 바인딩)
  • y: fun y 참조
  • 자유 변수: {x (inner), y}

외부 람다 fun y -> let x = 2 in ...:

  • 자유 변수: {x (outer)}

핵심: 각 바인딩 지점이 새로운 스코프를 생성한다.

클로저 변환 (Closure Conversion)

자유 변수를 분석했으면, 이제 **클로저 변환(closure conversion)**을 적용한다. 클로저 변환은 암묵적 환경 캡처를 명시적으로 만드는 변환이다.

변환 개념

변환 전 (source code):

let x = 10 in
fun y -> x + y

x가 암묵적으로 캡처된다.

변환 후 (closure-converted code):

// 의사 코드
let x = 10 in
let env = { x: x } in
let closure = { fn: lambda_0, env: env } in
closure

// lambda_0 정의:
fun lambda_0 (env, y) =
    let x = env.x in
    x + y

변화:

  1. 환경 생성: env = { x: 10 }
  2. 클로저 생성: closure = { fn: lambda_0, env: env }
  3. 함수 수정: 환경을 첫 번째 파라미터로 받음
  4. 자유 변수 접근: 환경에서 로드 (env.x)

Before/After 예시

예시 1: 단순 클로저

Before:

let make_adder n =
    fun x -> x + n

After:

// make_adder 함수
func.func @make_adder(%n: i32) -> !llvm.ptr {
    // 1. 환경 할당 (1개 변수)
    %env_size = arith.constant 16 : i64  // 8 (fn ptr) + 8 (n)
    %env_ptr = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // 2. 함수 포인터 저장 (env[0])
    %fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %fn_slot = llvm.getelementptr %env_ptr[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr

    // 3. 캡처된 변수 저장 (env[1])
    %n_slot = llvm.getelementptr %env_ptr[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr

    // 4. 환경 포인터 반환 (클로저)
    func.return %env_ptr : !llvm.ptr
}

// lambda_adder 함수 (환경 파라미터 추가)
func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // 1. 환경에서 n 로드
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32

    // 2. x + n 계산
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

핵심 변환:

  • fun x -> x + nfunc.func @lambda_adder(%env, %x)
  • n 접근 → llvm.load from env[1]
  • 클로저 생성 → GC_malloc + store fn_ptr + store n

예시 2: 여러 변수 캡처

Before:

let x = 10 in
let y = 20 in
let z = 30 in
fun a -> x + y + z + a

After (환경 구조):

// 환경 레이아웃
struct env {
    void* fn_ptr;   // [0] 함수 포인터
    i32 x;          // [1] 캡처된 x
    i32 y;          // [2] 캡처된 y
    i32 z;          // [3] 캡처된 z
};

// 환경 크기 = 8 + 4 + 4 + 4 = 20 바이트
func.func @lambda_xyz(%env: !llvm.ptr, %a: i32) -> i32 {
    // x 로드
    %x_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %x = llvm.load %x_slot : !llvm.ptr -> i32

    // y 로드
    %y_slot = llvm.getelementptr %env[2] : (!llvm.ptr) -> !llvm.ptr
    %y = llvm.load %y_slot : !llvm.ptr -> i32

    // z 로드
    %z_slot = llvm.getelementptr %env[3] : (!llvm.ptr) -> !llvm.ptr
    %z = llvm.load %z_slot : !llvm.ptr -> i32

    // x + y + z + a
    %t1 = arith.addi %x, %y : i32
    %t2 = arith.addi %t1, %z : i32
    %result = arith.addi %t2, %a : i32
    func.return %result : i32
}

환경 파라미터

클로저 변환 후, 모든 람다 함수는 환경을 첫 번째 파라미터로 받는다.

일반 함수 (Phase 3):

func.func @add(%x: i32, %y: i32) -> i32 {
    %result = arith.addi %x, %y : i32
    func.return %result : i32
}

클로저 함수 (Phase 4):

func.func @lambda_closure(%env: !llvm.ptr, %x: i32, %y: i32) -> i32 {
    // 환경에서 캡처된 변수 로드
    // ...
    func.return %result : i32
}

차이:

  • 일반 함수: 파라미터만
  • 클로저 함수: %env: !llvm.ptr + 파라미터

환경 타입:

환경은 opaque pointer로 표현된다: !llvm.ptr

LLVM은 포인터 타입이 단순화되어, 모든 포인터가 !llvm.ptr이다. 내부 구조는 getelementptr의 인덱스로 관리한다.

Flat Environment vs Linked Environment

환경을 표현하는 방법은 두 가지다:

1. Flat environment (FunLang 선택):

모든 캡처된 변수를 하나의 배열에 저장.

struct env {
    void* fn_ptr;
    int var1;
    int var2;
    int var3;
};

장점:

  • O(1) 접근: env[index]로 직접 접근
  • 메모리 효율: 하나의 할당
  • 간단한 구현

단점:

  • 중첩 클로저가 부모 환경을 통째로 복사해야 함

2. Linked environment (일부 컴파일러):

환경을 체인으로 연결.

struct env {
    void* fn_ptr;
    struct env* parent;  // 부모 환경 포인터
    int var1;
};

장점:

  • 중첩 클로저가 부모 환경을 공유할 수 있음

단점:

  • O(depth) 접근: 체인을 따라 탐색
  • 메모리 간접 참조 증가

FunLang 선택: Flat environment

이유:

  1. 단순성: 구현이 간단함
  2. 성능: O(1) 접근이 빠름
  3. 교육 목적: 개념을 명확히 이해할 수 있음

중첩 클로저는 드물고, 복사 오버헤드가 크지 않다.

클로저 변환 요약

클로저 변환은 다음 단계를 수행한다:

  1. 자유 변수 분석: freeVars(lambda){x, y, z}
  2. 환경 크기 계산: size = 8 (fn ptr) + 4*n (captured vars)
  3. 환경 할당: GC_malloc(size) → heap 객체
  4. 함수 포인터 저장: env[0] = @lambda_N
  5. 변수 저장: env[1] = x, env[2] = y, …
  6. 함수 정의 수정: lambda_N(env, params...) 형태로 변환
  7. 변수 접근 수정: xload from env[1]

결과: 암묵적 캡처가 명시적 환경 조작으로 변환된다.

AST 확장: Lambda 표현식

클로저를 컴파일하려면, AST에 Lambda 케이스를 추가해야 한다.

Expr 타입 확장

// Phase 3 AST (Chapter 10-11)
type Expr =
    | Var of string
    | Num of int
    | Add of Expr * Expr
    | Sub of Expr * Expr
    | Mul of Expr * Expr
    | Div of Expr * Expr
    | Eq of Expr * Expr
    | Lt of Expr * Expr
    | Let of string * Expr * Expr
    | If of Expr * Expr * Expr
    | App of string * Expr list  // 함수 호출: f(arg1, arg2, ...)

// Phase 4 AST (Chapter 12+)
type Expr =
    | Var of string
    | Num of int
    | Add of Expr * Expr
    | Sub of Expr * Expr
    | Mul of Expr * Expr
    | Div of Expr * Expr
    | Eq of Expr * Expr
    | Lt of Expr * Expr
    | Let of string * Expr * Expr
    | If of Expr * Expr * Expr
    | Lambda of string * Expr        // NEW: 람다 표현식
    | App of Expr * Expr             // CHANGED: 일반 함수 적용

변경사항:

  1. Lambda 추가: Lambda(param, body)

    • param: 파라미터 이름 (단일 파라미터, 다중 파라미터는 currying으로 표현)
    • body: 함수 본체
  2. App 변경: App(Expr, Expr) (함수 표현식 + 인자 표현식)

    • Phase 3: App(string, Expr list) - 이름으로 함수 호출
    • Phase 4: App(Expr, Expr) - 표현식이 함수가 될 수 있음 (클로저 호출)

Lambda 예시

예시 1: 단순 람다

fun x -> x + 1

AST:

Lambda("x", Add(Var "x", Num 1))

예시 2: 클로저

let y = 10 in
fun x -> x + y

AST:

Let("y", Num 10,
    Lambda("x", Add(Var "x", Var "y")))

예시 3: 고차 함수

fun f -> fun x -> f x

AST:

Lambda("f",
    Lambda("x",
        App(Var "f", Var "x")))

Currying으로 다중 파라미터 표현

FunLang은 단일 파라미터 람다만 지원한다. 다중 파라미터는 currying으로 표현한다.

// 다중 파라미터 (syntax sugar)
fun x y -> x + y

// Currying (desugared)
fun x -> fun y -> x + y

AST:

Lambda("x",
    Lambda("y",
        Add(Var "x", Var "y")))

이것이 표준 함수형 언어 패턴이다 (Haskell, OCaml, F#).

Parser 업데이트 (개념)

Lambda를 파싱하려면, fun 키워드를 추가해야 한다.

// LangTutorial의 parser.fsy에서
// (독자는 LangTutorial을 참고하여 자신의 parser를 업데이트)

Expr:
    | FUN ID ARROW Expr    { Lambda($2, $4) }
    | ...

토큰:

  • FUN: “fun” 키워드
  • ID: 식별자
  • ARROW: “->” 화살표
  • Expr: 본체 표현식

결합 순서:

  • fun x -> fun y -> x + y: 오른쪽 결합
  • f x y: 왼쪽 결합 (App는 왼쪽 결합)

MLIR 환경 구조체

클로저의 핵심은 환경(environment) 구조체다. 환경은 캡처된 변수들을 저장하는 힙 객체다.

환경 레이아웃

환경은 **헤테로지니어스 배열(heterogeneous array)**이다:

// C 스타일 표현
struct closure_env {
    void* fn_ptr;   // [0] 함수 포인터
    int var1;       // [1] 첫 번째 캡처된 변수
    int var2;       // [2] 두 번째 캡처된 변수
    // ...
};

인덱스 규칙:

IndexContentTypeSize
0함수 포인터!llvm.ptr8 bytes
1첫 번째 변수i324 bytes
2두 번째 변수i324 bytes

상수 정의:

// F# 컴파일러에서
let ENV_FN_PTR = 0         // 함수 포인터 인덱스
let ENV_FIRST_VAR = 1      // 첫 번째 변수 인덱스

LLVM Struct Type

MLIR에서 환경은 !llvm.struct 타입으로 표현할 수도 있지만, opaque pointer 방식이 더 간단하다.

Opaque pointer 방식 (FunLang 선택):

// 환경은 !llvm.ptr로 표현
// 내부 구조는 getelementptr 인덱스로 관리

%env_ptr = llvm.call @GC_malloc(%size) : (i64) -> !llvm.ptr
%slot = llvm.getelementptr %env_ptr[index] : (!llvm.ptr) -> !llvm.ptr

장점:

  • 타입 시스템이 간단함
  • 동적 크기 환경 가능
  • getelementptr가 바이트 오프셋 자동 계산

Struct type 방식 (대안):

// 환경 타입 정의
!env_type = !llvm.struct<(ptr, i32, i32)>

// 사용
%env = llvm.alloca : !llvm.ptr
%slot = llvm.getelementptr %env[0, 1] : (!llvm.ptr) -> !llvm.ptr

단점:

  • 각 클로저마다 다른 타입 필요
  • 타입 정의가 복잡함

FunLang 선택: Opaque pointer 방식

getelementptr로 슬롯 접근

llvm.getelementptr는 포인터 산술 연산이다. 배열 인덱스를 받아서 해당 위치의 포인터를 계산한다.

Syntax:

%slot_ptr = llvm.getelementptr %base_ptr[index] : (!llvm.ptr) -> !llvm.ptr

예시:

// 환경 포인터: %env
// 인덱스 1번 슬롯 접근 (첫 번째 변수)

%slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
%value = llvm.load %slot : !llvm.ptr -> i32

중요: getelementptr는 포인터만 계산한다. 실제 로드는 llvm.load로 수행한다.

메모리 레이아웃 예시:

환경 메모리 (3개 변수 캡처):
Address     Content
0x1000      @lambda_N (fn ptr, 8 bytes)
0x1008      10 (var1, 4 bytes)
0x100C      20 (var2, 4 bytes)
0x1010      30 (var3, 4 bytes)

getelementptr %env[0]: 0x1000
getelementptr %env[1]: 0x1008
getelementptr %env[2]: 0x100C
getelementptr %env[3]: 0x1010

바이트 정렬: LLVM이 자동으로 적절한 정렬을 수행한다.

Helper 함수: CreateClosureEnv

환경 생성을 간단하게 만드는 helper 함수:

// F# 컴파일러에서
let createClosureEnv (builder: OpBuilder) (fnAddr: MlirValue) (capturedVars: MlirValue list) : MlirValue =
    // 1. 환경 크기 계산
    let fnPtrSize = 8L  // 포인터 크기
    let varSize = 4L    // i32 크기
    let totalSize = fnPtrSize + (int64 capturedVars.Length) * varSize
    let sizeConst = builder.CreateI64Const(totalSize)

    // 2. GC_malloc 호출
    let envPtr = builder.CreateCall("GC_malloc", [sizeConst])

    // 3. 함수 포인터 저장
    let fnSlot = builder.CreateGEP(envPtr, 0)
    builder.CreateStore(fnAddr, fnSlot)

    // 4. 캡처된 변수들 저장
    capturedVars |> List.iteri (fun i var ->
        let slot = builder.CreateGEP(envPtr, i + 1)
        builder.CreateStore(var, slot)
    )

    // 5. 환경 포인터 반환
    envPtr

Helper 함수: GetEnvSlot

환경에서 변수 로드를 간단하게:

let getEnvSlot (builder: OpBuilder) (envPtr: MlirValue) (index: int) : MlirValue =
    // getelementptr + load
    let slot = builder.CreateGEP(envPtr, index)
    builder.CreateLoad(slot, "i32")

사용 예시:

// 환경에서 첫 번째 변수 로드
let var1 = getEnvSlot builder envPtr ENV_FIRST_VAR

클로저 생성 코드 (Closure Creation)

클로저 생성은 환경 할당 + 변수 저장 + 환경 포인터 반환이다.

compileLambda 함수

// Lambda 표현식 컴파일
let compileLambda (builder: OpBuilder) (env: Environment) (param: string) (body: Expr) : MlirValue =
    // 1. 자유 변수 분석
    let freeVarSet = freeVars (Lambda(param, body))
    let freeVarList = Set.toList freeVarSet

    // 2. 캡처된 변수들의 SSA 값 가져오기
    let capturedValues =
        freeVarList |> List.map (fun varName ->
            match env.TryFind(varName) with
            | Some(value) -> value
            | None -> failwithf "Undefined variable: %s" varName
        )

    // 3. 람다 함수 정의 생성
    let lambdaName = generateLambdaName()  // @lambda_0, @lambda_1, ...
    let lambdaFunc = createLambdaFunction builder lambdaName param body freeVarList env

    // 4. 함수 포인터 얻기
    let fnAddr = builder.CreateAddressOf(lambdaName)

    // 5. 환경 생성 및 변수 저장
    let envPtr = createClosureEnv builder fnAddr capturedValues

    // 6. 환경 포인터 반환 (이것이 클로저)
    envPtr

핵심 단계:

  1. 자유 변수 분석: freeVars로 캡처할 변수 찾기
  2. 값 가져오기: 환경에서 SSA 값 로드
  3. 람다 함수 정의: 별도 함수로 생성 (환경 파라미터 포함)
  4. 함수 포인터: llvm.mlir.addressof로 주소 얻기
  5. 환경 할당: GC_malloc + 변수 저장
  6. 반환: 환경 포인터 (클로저 값)

createLambdaFunction

람다 함수를 별도 func.func로 정의:

let createLambdaFunction (builder: OpBuilder) (name: string) (param: string) (body: Expr) (freeVars: string list) (outerEnv: Environment) : unit =
    // 1. 함수 시그니처: (%env: !llvm.ptr, %param: i32) -> i32
    let paramTypes = [builder.GetPtrType(); builder.GetI32Type()]
    let returnType = builder.GetI32Type()

    // 2. 함수 생성
    let funcOp = builder.CreateFuncOp(name, paramTypes, returnType)
    let entryBlock = builder.GetEntryBlock(funcOp)
    builder.SetInsertionPoint(entryBlock)

    // 3. Block arguments 얻기
    let envArg = builder.GetBlockArg(entryBlock, 0)   // %env
    let paramArg = builder.GetBlockArg(entryBlock, 1)  // %param

    // 4. 환경 구축: 파라미터 + 캡처된 변수들
    let mutable lambdaEnv = Map.empty
    lambdaEnv <- lambdaEnv.Add(param, paramArg)

    // 캡처된 변수들을 환경에서 로드
    freeVars |> List.iteri (fun i varName ->
        let value = getEnvSlot builder envArg (ENV_FIRST_VAR + i)
        lambdaEnv <- lambdaEnv.Add(varName, value)
    )

    // 5. 본체 컴파일
    let bodyValue = compileExpr builder lambdaEnv body

    // 6. 반환
    builder.CreateFuncReturn(bodyValue)

핵심:

  • 환경 파라미터 %env: !llvm.ptr가 첫 번째
  • 실제 파라미터 %param: i32가 두 번째
  • 캡처된 변수들을 환경에서 로드하여 lambda 환경에 추가
  • 본체 컴파일은 일반 표현식과 동일

전체 예시: make_adder 컴파일

Source code:

let make_adder n =
    fun x -> x + n

AST:

Let("make_adder",
    Lambda("n",
        Lambda("x", Add(Var "x", Var "n"))),
    ...)

Generated MLIR IR:

// make_adder 함수
func.func @make_adder(%n: i32) -> !llvm.ptr {
    // 내부 람다: fun x -> x + n
    // 자유 변수: {n}

    // 1. 환경 크기 계산: 8 (fn ptr) + 4 (n) = 12 bytes
    %env_size = arith.constant 12 : i64

    // 2. GC_malloc 호출
    %env_ptr = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // 3. 함수 포인터 저장
    %fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %fn_slot = llvm.getelementptr %env_ptr[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr

    // 4. 캡처된 변수 n 저장
    %n_slot = llvm.getelementptr %env_ptr[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr

    // 5. 환경 포인터 반환 (클로저)
    func.return %env_ptr : !llvm.ptr
}

// lambda_adder 함수
func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // 1. 환경에서 n 로드
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32

    // 2. x + n 계산
    %result = arith.addi %x, %n : i32

    // 3. 반환
    func.return %result : i32
}

코드 흐름:

  1. make_adder 호출: make_adder(5)
  2. 환경 할당: env = GC_malloc(12)
  3. 함수 포인터 저장: env[0] = @lambda_adder
  4. n 저장: env[1] = 5
  5. 클로저 반환: env (포인터)
  6. 나중에 클로저 호출: closure(10)
  7. lambda_adder 호출: @lambda_adder(env, 10)
  8. n 로드: env[1]5
  9. 계산: 10 + 515

클로저 본체 컴파일 (Closure Body)

클로저 본체는 lifted function으로 컴파일된다. Lifted function은 최상위 함수로 추출된 람다 함수다.

Lifting 개념

Before lifting (nested lambda):

let make_adder n =
    fun x -> x + n

After lifting (top-level functions):

// Lifted lambda
let lambda_adder (env, x) =
    let n = env[1] in
    x + n

// make_adder는 클로저 생성기
let make_adder n =
    let env = allocate_env(@lambda_adder, n) in
    env

모든 람다 함수가 최상위로 lift된다. 중첩된 함수가 flat structure로 변환된다.

환경 파라미터 타입

Lifted function의 시그니처:

func.func @lambda_N(%env: !llvm.ptr, %param1: i32, %param2: i32, ...) -> i32

첫 번째 파라미터:

  • 이름: %env
  • 타입: !llvm.ptr (opaque pointer)
  • 목적: 캡처된 변수 접근

나머지 파라미터:

  • 람다의 실제 파라미터들

환경에서 변수 로드

캡처된 변수를 사용하려면, 환경에서 로드해야 한다:

// 첫 번째 캡처된 변수 로드
%var1_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
%var1 = llvm.load %var1_slot : !llvm.ptr -> i32

// 두 번째 캡처된 변수 로드
%var2_slot = llvm.getelementptr %env[2] : (!llvm.ptr) -> !llvm.ptr
%var2 = llvm.load %var2_slot : !llvm.ptr -> i32

패턴:

  1. getelementptr로 슬롯 포인터 계산
  2. llvm.load로 값 로드
  3. SSA 값으로 사용

전체 예시: 중첩 클로저

Source code:

let x = 10 in
let y = 20 in
fun z -> x + y + z

Lifted function:

func.func @lambda_xyz(%env: !llvm.ptr, %z: i32) -> i32 {
    // 1. x 로드 (env[1])
    %x_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %x = llvm.load %x_slot : !llvm.ptr -> i32

    // 2. y 로드 (env[2])
    %y_slot = llvm.getelementptr %env[2] : (!llvm.ptr) -> !llvm.ptr
    %y = llvm.load %y_slot : !llvm.ptr -> i32

    // 3. x + y 계산
    %t1 = arith.addi %x, %y : i32

    // 4. t1 + z 계산
    %result = arith.addi %t1, %z : i32

    // 5. 반환
    func.return %result : i32
}

클로저 생성 부분:

func.func @main() -> i32 {
    // 1. x, y 정의
    %x = arith.constant 10 : i32
    %y = arith.constant 20 : i32

    // 2. 환경 크기: 8 (fn ptr) + 4 (x) + 4 (y) = 16 bytes
    %env_size = arith.constant 16 : i64

    // 3. 환경 할당
    %env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // 4. 함수 포인터 저장
    %fn = llvm.mlir.addressof @lambda_xyz : !llvm.ptr
    %fn_slot = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn, %fn_slot : !llvm.ptr, !llvm.ptr

    // 5. x 저장
    %x_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %x, %x_slot : i32, !llvm.ptr

    // 6. y 저장
    %y_slot = llvm.getelementptr %env[2] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %y, %y_slot : i32, !llvm.ptr

    // 7. 클로저 호출 (나중에 Chapter 13에서)
    // %result = call_closure %env (%z)

    func.return %0 : i32
}

함수 명명 규칙

Lifted function의 이름은 자동 생성된다:

let mutable lambdaCounter = 0

let generateLambdaName() =
    let name = sprintf "lambda_%d" lambdaCounter
    lambdaCounter <- lambdaCounter + 1
    name

예시:

  • 첫 번째 람다: @lambda_0
  • 두 번째 람다: @lambda_1

중요: 이름은 unique해야 한다. 같은 이름의 함수가 여러 개 있으면 링커 오류가 발생한다.

공통 오류 (Common Errors)

클로저 컴파일에서 자주 발생하는 오류들:

Error 1: 환경 인덱스 off-by-one

증상:

// 잘못된 코드 - 함수 포인터를 변수로 로드
%var1_slot = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
%var1 = llvm.load %var1_slot : !llvm.ptr -> i32  // ERROR: 타입 불일치!

원인:

환경 레이아웃을 잊음:

  • env[0]: 함수 포인터 (!llvm.ptr)
  • env[1]: 첫 번째 변수 (i32)
  • env[2]: 두 번째 변수 (i32)

해결:

// 올바른 코드 - 첫 번째 변수는 env[1]
%var1_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
%var1 = llvm.load %var1_slot : !llvm.ptr -> i32  // OK

팁: ENV_FN_PTR = 0, ENV_FIRST_VAR = 1 상수 사용하기.

Error 2: 환경 파라미터 누락

증상:

// 잘못된 코드 - 환경 파라미터 없음
func.func @lambda_adder(%x: i32) -> i32 {
    %n = ??? // n을 어디서 가져오나?
}

원인:

Lifted function에 환경 파라미터를 추가하지 않음.

해결:

// 올바른 코드 - 환경 파라미터 추가
func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    // ...
}

팁: 모든 람다 함수는 첫 번째 파라미터로 %env: !llvm.ptr를 받는다.

Error 3: 스택 vs 힙 할당

증상:

// 잘못된 코드 - 스택 할당
%env = llvm.alloca 16, i8 : (i32, i8) -> !llvm.ptr
// ...
func.return %env : !llvm.ptr  // ERROR: 스택 메모리를 반환!

원인:

환경을 스택에 할당했는데, 함수 반환 후 사라진다.

해결:

// 올바른 코드 - 힙 할당
%env_size = arith.constant 16 : i64
%env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
func.return %env : !llvm.ptr  // OK: 힙 메모리는 살아있음

원칙:

  • 스택 할당 (llvm.alloca): 함수 로컬 변수 (현재 스택 프레임에서만 유효)
  • 힙 할당 (GC_malloc): 탈출하는 값 (함수 반환 후에도 유효)

클로저는 항상 힙 할당해야 한다. 클로저가 생성된 함수가 반환된 후에도 사용되기 때문이다.

Error 4: 타입 불일치

증상:

// 잘못된 코드
%fn_addr = llvm.mlir.addressof @lambda_0 : !llvm.ptr
%fn_slot = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %fn_addr, %fn_slot : i32, !llvm.ptr  // ERROR: 타입 불일치!

원인:

함수 포인터 타입을 i32로 잘못 지정.

해결:

// 올바른 코드
%fn_addr = llvm.mlir.addressof @lambda_0 : !llvm.ptr
%fn_slot = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr  // OK

타입 체크:

  • 함수 포인터: !llvm.ptr
  • i32 변수: i32
  • llvm.store 시그니처: llvm.store %value, %ptr : value_type, !llvm.ptr

요약

이 장에서 배운 것:

  1. 클로저 이론

    • Lexical scoping: 정의 시점의 환경을 기억
    • Free variables: 람다에서 바인딩되지 않은 변수
    • Bound variables: 람다 파라미터로 바인딩된 변수
    • 환경 캡처: 자유 변수의 값을 저장
  2. 자유 변수 분석

    • Set-based traversal 알고리즘
    • FV(Lambda(x, body)) = FV(body) - {x}
    • F# 구현: freeVars 재귀 함수
  3. 클로저 변환

    • 암묵적 캡처 → 명시적 환경 조작
    • Flat environment: 모든 변수를 배열에 저장
    • Lifted functions: 람다를 최상위 함수로 추출
  4. 환경 구조체

    • 레이아웃: [fn_ptr, var1, var2, ...]
    • env[0]: 함수 포인터
    • env[1+]: 캡처된 변수들
    • getelementptr로 슬롯 접근
  5. 클로저 생성 코드

    • GC_malloc로 환경 힙 할당
    • 함수 포인터 저장 (llvm.mlir.addressof)
    • 캡처된 변수들 저장 (llvm.store)
    • 환경 포인터 반환 (클로저 값)
  6. 클로저 본체 컴파일

    • Lifted function: @lambda_N(%env, %params...)
    • 환경 파라미터를 첫 번째로 받음
    • getelementptr + llvm.load로 변수 접근

다음 장 (Chapter 13):

  • 고차 함수 (Higher-order functions): 함수를 인자로 받거나 반환
  • 클로저 호출: 환경 포인터에서 함수 포인터 추출 + 간접 호출
  • Map/Filter/Fold: 표준 고차 함수 구현
  • Function 타입: 함수를 first-class value로 취급

클로저는 함수형 프로그래밍의 핵심이다. 이 장에서 확립한 환경 캡처 메커니즘이 고차 함수의 기초가 된다.

Chapter 13: 고차 함수 (Higher-Order Functions)

소개

**고차 함수(higher-order function, HOF)**는 함수를 **일급 값(first-class value)**으로 다루는 함수다:

  1. 함수를 인자로 받는 함수: apply f x = f x
  2. 함수를 반환하는 함수: makeAdder n = fun x -> x + n
// 고차 함수 예시
let apply f x = f x           // 함수를 인자로 받는다
let twice f x = f (f x)       // 함수를 두 번 적용한다
let compose f g x = f (g x)   // 함수 합성

let inc x = x + 1
let result = twice inc 10     // 결과: 12

왜 고차 함수가 중요한가?

  1. 추상화(Abstraction): 공통 패턴을 재사용 가능하게 만든다 (map, filter, fold)
  2. 합성(Composition): 작은 함수를 조합해 복잡한 동작을 만든다
  3. 지연 평가(Lazy evaluation): 계산을 함수로 감싸서 나중에 실행할 수 있다
  4. 콜백 패턴: 비동기 작업, 이벤트 처리에 필수

고차 함수 vs 일반 함수:

일반 함수 (Phase 3)고차 함수 (Phase 4)
데이터를 인자로 받는다함수를 인자로 받는다
데이터를 반환한다함수를 반환할 수 있다
직접 호출 (func.call @symbol)간접 호출 (function pointer)
타입: int -> int타입: (int -> int) -> int

Chapter 13의 목표:

이 장을 마치면 다음을 컴파일할 수 있다:

// 함수를 인자로 받기
let apply f x = f x
let result1 = apply inc 42   // 43

// 함수를 반환하기
let makeAdder n = fun x -> x + n
let add5 = makeAdder 5
let result2 = add5 10        // 15

// 함수 합성
let compose f g x = f (g x)
let inc x = x + 1
let double x = x * 2
let incThenDouble = compose double inc
let result3 = incThenDouble 5   // 12

Chapter 13의 범위:

  1. 함수를 일급 값으로 다루기: 클로저가 함수의 런타임 표현이다
  2. 간접 호출(Indirect call) 패턴: llvm.call로 함수 포인터를 호출한다
  3. Apply 함수: 가장 단순한 고차 함수
  4. Compose 함수: 여러 함수 인자를 다루기
  5. 함수를 반환하기: makeAdder 패턴, upward funarg problem
  6. 커링(Currying): 다중 인자 함수를 중첩 클로저로 표현
  7. 메모리 관리: GC가 클로저 생명주기를 처리한다
  8. Complete 예시: map 함수 (개념적, Phase 6에서 완전 구현)

Prerequisites:

  • Chapter 12 (Closures): 클로저 표현, 환경 구조, 자유 변수 분석
  • Phase 3 함수 (이름 있는 함수, func.call)
  • Phase 2 메모리 관리 (GC_malloc, 힙 할당)

이 장은 클로저 + 고차 함수 = 함수형 프로그래밍 핵심을 완성한다.

함수를 일급 값으로 다루기

First-Class Functions

**일급 값(first-class value)**이란:

  1. 변수에 할당할 수 있다
  2. 함수 인자로 전달할 수 있다
  3. 함수 반환값으로 반환할 수 있다
  4. 데이터 구조에 저장할 수 있다

FunLang에서 함수는 일급 값이다:

// 1. 변수에 할당
let f = fun x -> x + 1

// 2. 함수 인자로 전달
let apply g x = g x
let result = apply f 10   // 11

// 3. 함수 반환값으로 반환
let makeAdder n = fun x -> x + n
let add5 = makeAdder 5

// 4. 데이터 구조에 저장 (Phase 6에서 리스트 구현 후)
// let funcs = [inc; double; square]

일급 함수의 런타임 표현:

Chapter 12에서 배운 클로저가 바로 함수의 런타임 표현이다:

Closure = (function_pointer, environment_pointer)
  • function_pointer: 실행할 코드 (lifted function의 주소)
  • environment_pointer: 캡처된 변수들 (힙에 할당된 환경)

모든 함수가 클로저인가?

논리적으로는 YES. 실제로는 최적화로 구분된다:

함수 종류환경표현예시
Top-level named비어있음함수 포인터만let add x y = x + y
Lambda (no capture)비어있음함수 포인터만fun x -> x + 1
Lambda (capture)변수 캡처클로저 (ptr, env)fun x -> x + n

Uniform representation:

컴파일러 구현을 단순화하기 위해, 모든 함수를 클로저로 표현할 수 있다:

  • Top-level 함수: 환경이 null이거나 빈 환경
  • 캡처 없는 람다: 환경이 빈 환경
  • 캡처 있는 람다: 환경에 변수 저장

이 장에서는 uniform representation을 사용한다. 모든 함수는 (fn_ptr, env_ptr) 쌍이다.

Named Functions vs Anonymous Lambdas

Named function (Phase 3 스타일):

let inc x = x + 1

컴파일 결과:

func.func @inc(%x: i32) -> i32 {
    %c1 = arith.constant 1 : i32
    %result = arith.addi %x, %c1 : i32
    func.return %result : i32
}
  • MLIR 심볼 @inc로 직접 참조 가능
  • func.call @inc(%arg) 직접 호출

Anonymous lambda (Chapter 12 스타일):

fun x -> x + 1

컴파일 결과:

// Lifted function
func.func @lambda_0(%env: !llvm.ptr, %x: i32) -> i32 {
    %c1 = arith.constant 1 : i32
    %result = arith.addi %x, %c1 : i32
    func.return %result : i32
}

// Closure 생성 (호출 지점에서)
%c0 = arith.constant 0 : i64
%env_size = arith.constant 8 : i64  // 환경 없음, fn_ptr만
%env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
%fn_ptr = llvm.mlir.addressof @lambda_0 : !llvm.ptr
llvm.store %fn_ptr, %env : !llvm.ptr, !llvm.ptr
// %env가 클로저다

Named function을 클로저로 wrapping:

Named function도 고차 함수에 전달하려면 클로저로 감싸야 한다:

let inc x = x + 1        // Named function
let apply f x = f x      // HOF
let result = apply inc 42   // inc를 클로저로 wrap

컴파일:

// Named function (그대로)
func.func @inc(%x: i32) -> i32 { ... }

// inc를 클로저로 wrap
%env_size = arith.constant 8 : i64
%env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
%fn_ptr = llvm.mlir.addressof @inc : !llvm.ptr
llvm.store %fn_ptr, %env : !llvm.ptr, !llvm.ptr
%closure_inc = %env : !llvm.ptr

// apply에 전달
%result = func.call @apply(%closure_inc, %c42) : (!llvm.ptr, i32) -> i32

요약:

  • Named function: MLIR 심볼로 정의, 직접 호출 가능
  • Anonymous lambda: 항상 클로저로 표현
  • Named function을 HOF에 전달: 클로저로 wrapping 필요
  • Uniform representation: 모두 !llvm.ptr (클로저 포인터)로 표현

클로저 호출: 간접 호출 패턴

Direct Call vs Indirect Call

Direct call (Phase 3):

%result = func.call @inc(%x) : (i32) -> i32
  • 호출 대상이 컴파일 타임에 결정됨 (@inc 심볼)
  • 최적화 가능 (인라이닝, 특수화)

Indirect call (Phase 4):

%fn_ptr = /* 클로저에서 추출 */
%result = llvm.call %fn_ptr(%closure, %x) : !llvm.ptr, (i32) -> i32
  • 호출 대상이 런타임에 결정됨 (함수 포인터)
  • 최적화 어려움 (가상 함수처럼 동작)

왜 간접 호출이 필요한가?

고차 함수는 어떤 함수가 전달될지 컴파일 타임에 모른다:

let apply f x = f x   // f는 런타임에 결정된다

apply inc 10      // f = inc
apply double 10   // f = double

컴파일러는 f가 무엇인지 모르므로, 간접 호출을 생성해야 한다.

간접 호출 패턴 (Indirect Call Pattern)

클로저를 호출하는 3단계:

1. 함수 포인터 추출:

환경의 slot 0에서 함수 포인터를 로드한다:

// %closure: !llvm.ptr (클로저 포인터)
%c0 = arith.constant 0 : i64
%fn_ptr_addr = llvm.getelementptr %closure[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
%fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr

2. 인자 준비:

  • 첫 번째 인자: 클로저 자신 (환경 포인터)
  • 나머지 인자: 원래 함수 파라미터
%env = %closure : !llvm.ptr   // 클로저 = 환경
%arg1 = %x : i32               // 실제 인자

3. 간접 호출:

함수 포인터를 통해 호출한다:

%result = llvm.call %fn_ptr(%env, %arg1) : !llvm.ptr, (i32) -> i32

완전한 예시:

// 클로저 %closure를 호출: closure(42)
%c0 = arith.constant 0 : i64
%c42 = arith.constant 42 : i32

// Step 1: 함수 포인터 추출
%fn_ptr_addr = llvm.getelementptr %closure[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
%fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr

// Step 2: 인자 준비
%env = %closure : !llvm.ptr

// Step 3: 간접 호출
%result = llvm.call %fn_ptr(%env, %c42) : !llvm.ptr, (i32) -> i32

F# Helper: CallClosure

반복되는 패턴을 헬퍼 함수로 추출한다:

type OpBuilder with
    /// 클로저를 간접 호출한다
    /// closure: !llvm.ptr (클로저 포인터)
    /// args: 함수 인자들 (환경 제외)
    /// Returns: 함수 호출 결과
    member this.CallClosure(closure: MlirValue, args: MlirValue list, resultType: MlirType) : MlirValue =
        // 1. 함수 포인터 추출
        let c0 = this.ConstantInt(0L, 64)
        let fnPtrAddr = this.CreateGEP(closure, [c0])
        let fnPtr = this.CreateLoad(this.PtrType(), fnPtrAddr)

        // 2. 인자 리스트 구성 (환경 + 원래 인자)
        let allArgs = closure :: args

        // 3. 간접 호출
        this.CreateLLVMCall(fnPtr, allArgs, resultType)

사용 예시:

// compileExpr에서 클로저 호출
| App(funcExpr, argExpr) ->
    let funcVal = compileExpr builder env funcExpr
    let argVal = compileExpr builder env argExpr

    // funcVal은 클로저 (!llvm.ptr)
    // argVal은 인자 (i32)
    builder.CallClosure(funcVal, [argVal], builder.IntType(32))

간접 호출의 비용:

  • 성능: 직접 호출보다 느리다 (포인터 로드, 인라이닝 불가)
  • 유연성: 런타임에 함수 선택 가능 (고차 함수의 핵심)

최적화 가능성:

  • 인라이닝: 클로저가 상수라면 특수화 가능
  • Devirtualization: 타입 분석으로 호출 대상 추론
  • Phase 4는 최적화 없이 단순 구현만 한다

Apply 함수

Apply의 의미

Apply 함수는 가장 단순한 고차 함수다:

let apply f x = f x
  • 타입: (a -> b) -> a -> b
  • 의미: 함수 f를 인자 x에 적용한다

왜 apply가 유용한가?

일견 쓸모없어 보인다 (f xapply f x는 같다). 하지만:

  1. HOF 테스트: 가장 단순한 고차 함수로 컴파일러 검증
  2. 파이프라인: x |> apply f 스타일 (Phase 7 파이프 연산자)
  3. 교육적: 간접 호출 패턴을 명확히 보여줌

Apply 예시:

let inc x = x + 1
let double x = x * 2

let result1 = apply inc 42      // 43
let result2 = apply double 10   // 20

Apply 컴파일: F# 구현

AST 표현:

// apply f x = f x
Let("apply",
    Lambda("f",
        Lambda("x",
            App(Var "f", Var "x"))),
    ...)

컴파일 단계:

  1. 외부 람다: fun f -> ... (f를 캡처)
  2. 내부 람다: fun x -> f x (f 사용)
  3. App: f x (간접 호출)

F# 컴파일 함수:

let rec compileExpr (builder: OpBuilder) (env: Map<string, MlirValue>) (expr: Expr) : MlirValue =
    match expr with
    // ... (기존 케이스들)

    | App(funcExpr, argExpr) ->
        // funcExpr를 평가 -> 클로저
        let closureVal = compileExpr builder env funcExpr

        // argExpr를 평가 -> 인자
        let argVal = compileExpr builder env argExpr

        // 클로저 간접 호출
        builder.CallClosure(closureVal, [argVal], builder.IntType(32))

Apply 전체 컴파일:

// let apply f x = f x
let compileApply (builder: OpBuilder) : MlirValue =
    // Lifted inner function: fun(env, x) -> env[1](x)
    //   env[0] = fn_ptr (inner)
    //   env[1] = f (captured)
    let innerFunc = builder.CreateFunction("apply_inner",
        [builder.PtrType(); builder.IntType(32)],
        builder.IntType(32))

    // Inner function body
    builder.WithInsertionPoint(innerFunc, fun () ->
        let env = innerFunc.GetArgument(0)
        let x = innerFunc.GetArgument(1)

        // Load captured f from env[1]
        let c1 = builder.ConstantInt(1L, 64)
        let fAddr = builder.CreateGEP(env, [c1])
        let f = builder.CreateLoad(builder.PtrType(), fAddr)

        // Call f(x) indirectly
        let result = builder.CallClosure(f, [x], builder.IntType(32))
        builder.CreateReturn(result)
    )

    // Lifted outer function: fun(env_outer, f) -> closure(inner, [f])
    let outerFunc = builder.CreateFunction("apply_outer",
        [builder.PtrType(); builder.PtrType()],
        builder.PtrType())

    // Outer function body
    builder.WithInsertionPoint(outerFunc, fun () ->
        let envOuter = outerFunc.GetArgument(0)
        let f = outerFunc.GetArgument(1)

        // Allocate environment for inner closure
        let envSize = builder.ConstantInt(16L, 64)  // 2 slots
        let envInner = builder.CreateGCMalloc(envSize)

        // env[0] = fn_ptr(inner)
        let c0 = builder.ConstantInt(0L, 64)
        let fnPtrInner = builder.CreateAddressOf(innerFunc)
        let slot0 = builder.CreateGEP(envInner, [c0])
        builder.CreateStore(fnPtrInner, slot0)

        // env[1] = f (captured)
        let c1 = builder.ConstantInt(1L, 64)
        let slot1 = builder.CreateGEP(envInner, [c1])
        builder.CreateStore(f, slot1)

        // Return closure
        builder.CreateReturn(envInner)
    )

    // Return outer closure (no captures, empty env)
    let envOuter = builder.CreateEmptyClosure(outerFunc)
    envOuter

Apply MLIR IR

예상 MLIR 출력:

// Inner lifted function
func.func @apply_inner(%env: !llvm.ptr, %x: i32) -> i32 {
    // Load captured f from env[1]
    %c1 = arith.constant 1 : i64
    %f_addr = llvm.getelementptr %env[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %f = llvm.load %f_addr : !llvm.ptr -> !llvm.ptr

    // Extract f's function pointer
    %c0 = arith.constant 0 : i64
    %fn_ptr_addr = llvm.getelementptr %f[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr

    // Call f(x) - indirect call
    %result = llvm.call %fn_ptr(%f, %x) : (!llvm.ptr, i32) -> i32
    func.return %result : i32
}

// Outer lifted function
func.func @apply_outer(%env_outer: !llvm.ptr, %f: !llvm.ptr) -> !llvm.ptr {
    // Allocate environment for inner closure (2 slots)
    %env_size = arith.constant 16 : i64
    %env_inner = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // env[0] = fn_ptr(inner)
    %c0 = arith.constant 0 : i64
    %fn_ptr_inner = llvm.mlir.addressof @apply_inner : !llvm.ptr
    %slot0 = llvm.getelementptr %env_inner[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr_inner, %slot0 : !llvm.ptr, !llvm.ptr

    // env[1] = f (captured)
    %c1 = arith.constant 1 : i64
    %slot1 = llvm.getelementptr %env_inner[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %f, %slot1 : !llvm.ptr, !llvm.ptr

    // Return inner closure
    func.return %env_inner : !llvm.ptr
}

사용 예시 MLIR:

// let inc x = x + 1
func.func @inc(%x: i32) -> i32 {
    %c1 = arith.constant 1 : i32
    %result = arith.addi %x, %c1 : i32
    func.return %result : i32
}

// let result = apply inc 42
func.func @main() -> i32 {
    // Wrap inc as closure
    %c8 = arith.constant 8 : i64
    %env_inc = llvm.call @GC_malloc(%c8) : (i64) -> !llvm.ptr
    %fn_ptr_inc = llvm.mlir.addressof @inc : !llvm.ptr
    %c0 = arith.constant 0 : i64
    %slot0 = llvm.getelementptr %env_inc[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr_inc, %slot0 : !llvm.ptr, !llvm.ptr
    %closure_inc = %env_inc : !llvm.ptr

    // Create apply closure
    %env_apply_outer = llvm.call @GC_malloc(%c8) : (i64) -> !llvm.ptr
    %fn_ptr_apply = llvm.mlir.addressof @apply_outer : !llvm.ptr
    %slot0_apply = llvm.getelementptr %env_apply_outer[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr_apply, %slot0_apply : !llvm.ptr, !llvm.ptr
    %closure_apply = %env_apply_outer : !llvm.ptr

    // Call apply(inc)
    %fn_ptr_apply_outer = llvm.load %slot0_apply : !llvm.ptr -> !llvm.ptr
    %closure_partial = llvm.call %fn_ptr_apply_outer(%closure_apply, %closure_inc)
        : (!llvm.ptr, !llvm.ptr) -> !llvm.ptr

    // Call (apply inc)(42)
    %c42 = arith.constant 42 : i32
    %fn_ptr_partial = llvm.getelementptr %closure_partial[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_partial : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr(%closure_partial, %c42) : (!llvm.ptr, i32) -> i32

    func.return %result : i32
}

테스트:

$ ./funlang apply_test.fun
43

여러 함수 인자 받기

Compose 함수

Compose는 두 함수를 합성한다:

let compose f g x = f (g x)
  • 타입: (b -> c) -> (a -> b) -> a -> c
  • 의미: g를 먼저 적용하고, 그 결과에 f를 적용한다

Compose 예시:

let inc x = x + 1
let double x = x * 2

let incThenDouble = compose double inc
let result = incThenDouble 5   // double(inc(5)) = double(6) = 12

왜 compose가 유용한가?

  1. 함수 조합: 작은 함수를 연결해 복잡한 동작 만들기
  2. 파이프라인: f << g << h 스타일 (Phase 7)
  3. 포인트-프리 스타일: let process = compose validate transform

Compose 컴파일

AST:

// compose f g x = f (g x)
Let("compose",
    Lambda("f",
        Lambda("g",
            Lambda("x",
                App(Var "f", App(Var "g", Var "x"))))),
    ...)

중첩 람다:

  • 외부: fun f -> ... (f 캡처)
  • 중간: fun g -> ... (f, g 캡처)
  • 내부: fun x -> f (g x) (f, g 사용)

Lifted functions:

  1. Innermost: compose_inner(env, x) - env에 f, g 저장
  2. Middle: compose_middle(env, g) - env에 f 저장, g와 f로 inner closure 생성
  3. Outermost: compose_outer(env, f) - f로 middle closure 생성

MLIR IR (간략):

// Innermost: fun x -> f (g x)
func.func @compose_inner(%env: !llvm.ptr, %x: i32) -> i32 {
    // Load g from env[1]
    %c1 = arith.constant 1 : i64
    %g_addr = llvm.getelementptr %env[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %g = llvm.load %g_addr : !llvm.ptr -> !llvm.ptr

    // Call g(x)
    %gx = /* CallClosure(g, x) */ : i32

    // Load f from env[2]
    %c2 = arith.constant 2 : i64
    %f_addr = llvm.getelementptr %env[0, %c2] : (!llvm.ptr, i64) -> !llvm.ptr
    %f = llvm.load %f_addr : !llvm.ptr -> !llvm.ptr

    // Call f(g(x))
    %result = /* CallClosure(f, gx) */ : i32
    func.return %result : i32
}

// Middle: fun g -> <inner closure with f, g>
func.func @compose_middle(%env: !llvm.ptr, %g: !llvm.ptr) -> !llvm.ptr {
    // Load f from env[1]
    %c1 = arith.constant 1 : i64
    %f_addr = llvm.getelementptr %env[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %f = llvm.load %f_addr : !llvm.ptr -> !llvm.ptr

    // Allocate environment for inner (3 slots: fn_ptr, g, f)
    %c24 = arith.constant 24 : i64
    %env_inner = llvm.call @GC_malloc(%c24) : (i64) -> !llvm.ptr

    // env[0] = fn_ptr(inner)
    %c0 = arith.constant 0 : i64
    %fn_ptr_inner = llvm.mlir.addressof @compose_inner : !llvm.ptr
    %slot0 = llvm.getelementptr %env_inner[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr_inner, %slot0 : !llvm.ptr, !llvm.ptr

    // env[1] = g
    %slot1 = llvm.getelementptr %env_inner[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %g, %slot1 : !llvm.ptr, !llvm.ptr

    // env[2] = f
    %c2 = arith.constant 2 : i64
    %slot2 = llvm.getelementptr %env_inner[0, %c2] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %f, %slot2 : !llvm.ptr, !llvm.ptr

    func.return %env_inner : !llvm.ptr
}

// Outermost: fun f -> <middle closure with f>
func.func @compose_outer(%env: !llvm.ptr, %f: !llvm.ptr) -> !llvm.ptr {
    // Allocate environment for middle (2 slots: fn_ptr, f)
    %c16 = arith.constant 16 : i64
    %env_middle = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr

    // env[0] = fn_ptr(middle)
    %c0 = arith.constant 0 : i64
    %fn_ptr_middle = llvm.mlir.addressof @compose_middle : !llvm.ptr
    %slot0 = llvm.getelementptr %env_middle[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr_middle, %slot0 : !llvm.ptr, !llvm.ptr

    // env[1] = f
    %c1 = arith.constant 1 : i64
    %slot1 = llvm.getelementptr %env_middle[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %f, %slot1 : !llvm.ptr, !llvm.ptr

    func.return %env_middle : !llvm.ptr
}

여러 클로저 호출 체이닝

Compose 사용:

let inc x = x + 1
let double x = x * 2
let incThenDouble = compose double inc
let result = incThenDouble 5

컴파일 과정:

  1. compose double inc → 중간 클로저 반환 (middle closure with f=double, g=inc)
  2. (compose double inc) 5 → 내부 클로저 호출 (inner with f, g, x=5)
  3. 내부에서 g(5) → 6
  4. 내부에서 f(6) → 12

MLIR 호출 체인:

// 1. Wrap double as closure
%closure_double = /* ... */

// 2. Wrap inc as closure
%closure_inc = /* ... */

// 3. Create compose closure
%closure_compose = /* compose_outer의 empty closure */

// 4. Call compose(double)
%closure_partial1 = llvm.call %fn_ptr_compose(%closure_compose, %closure_double)
    : (!llvm.ptr, !llvm.ptr) -> !llvm.ptr

// 5. Call (compose double)(inc)
%closure_partial2 = llvm.call %fn_ptr_partial1(%closure_partial1, %closure_inc)
    : (!llvm.ptr, !llvm.ptr) -> !llvm.ptr

// 6. Call (compose double inc)(5)
%c5 = arith.constant 5 : i32
%result = llvm.call %fn_ptr_partial2(%closure_partial2, %c5)
    : (!llvm.ptr, i32) -> i32

간접 호출의 연쇄:

  • compose 호출 → 클로저 반환
  • (compose double) 호출 → 클로저 반환
  • (compose double inc) 호출 → 클로저 반환
  • (compose double inc 5) 호출 → 값 반환 (12)

모든 중간 단계가 간접 호출을 사용한다.

함수를 반환하기

Upward Funarg Problem

함수를 반환하는 함수는 특별한 문제를 야기한다:

let makeAdder n =
    fun x -> x + n   // 이 클로저가 함수를 벗어나 반환된다
  • makeAdder가 호출되면, 내부 람다 fun x -> x + n이 생성된다
  • 이 람다는 n캡처한다
  • 람다가 makeAdder를 벗어나 반환된다
  • 반환된 후에도 n에 접근할 수 있어야 한다!

Upward funarg problem:

함수가 생성된 스코프를 벗어나 반환될 때, 캡처된 변수들이 어떻게 유지되는가?

잘못된 해결책: 스택 할당

// 안 되는 C 코드
typedef int (*func_ptr)(int);

func_ptr makeAdder(int n) {
    int captured_n = n;   // 스택 변수
    return &inner_func;    // inner_func이 captured_n을 참조
}   // 여기서 captured_n이 소멸! Dangling pointer!

함수가 반환되면 스택 프레임이 소멸되므로, captured_n에 접근하면 undefined behavior다.

올바른 해결책: 힙 할당

환경을 **힙(heap)**에 할당하면, 함수가 반환되어도 환경이 유지된다:

func.func @makeAdder(%n: i32) -> !llvm.ptr {
    // Allocate environment on heap (NOT stack!)
    %env_size = arith.constant 16 : i64
    %env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // env[0] = fn_ptr
    %fn_ptr = llvm.mlir.addressof @makeAdder_inner : !llvm.ptr
    // ... store fn_ptr ...

    // env[1] = n (captured)
    // ... store n ...

    func.return %env : !llvm.ptr   // 환경이 함수를 벗어나 반환
}

GC의 역할:

  • 힙에 할당된 환경은 GC가 관리한다
  • 클로저가 살아있는 동안 환경도 유지된다
  • 클로저가 더 이상 사용되지 않으면 환경도 해제된다

Chapter 12 설계의 정당성:

Chapter 12에서 모든 클로저를 힙에 할당한 이유가 바로 이것이다. 클로저가 생성 스코프를 벗어날 수 있으므로, 항상 힙에 할당해야 안전하다.

MakeAdder 구현

MakeAdder 함수:

let makeAdder n =
    fun x -> x + n
  • 타입: int -> (int -> int)
  • 의미: n을 받아서, “n을 더하는 함수“를 반환한다

사용 예시:

let add5 = makeAdder 5
let result1 = add5 10   // 15

let add10 = makeAdder 10
let result2 = add10 20  // 30

AST:

Let("makeAdder",
    Lambda("n",
        Lambda("x",
            Add(Var "x", Var "n"))),
    ...)

Closure conversion:

  1. 내부 람다: fun x -> x + n (n 캡처)
    • Lifted: makeAdder_inner(env, x) = x + env[1]
  2. 외부 람다: fun n -> <inner closure>
    • Lifted: makeAdder_outer(env, n) = create_closure(makeAdder_inner, [n])

F# 컴파일 (간략):

let compileMakeAdder (builder: OpBuilder) : unit =
    // Inner function: fun x -> x + n
    let innerFunc = builder.CreateFunction("makeAdder_inner",
        [builder.PtrType(); builder.IntType(32)],
        builder.IntType(32))

    builder.WithInsertionPoint(innerFunc, fun () ->
        let env = innerFunc.GetArgument(0)
        let x = innerFunc.GetArgument(1)

        // Load n from env[1]
        let c1 = builder.ConstantInt(1L, 64)
        let nAddr = builder.CreateGEP(env, [c1])
        let n = builder.CreateLoad(builder.IntType(32), nAddr)

        // Compute x + n
        let result = builder.CreateAdd(x, n)
        builder.CreateReturn(result)
    )

    // Outer function: fun n -> <inner closure>
    let outerFunc = builder.CreateFunction("makeAdder_outer",
        [builder.PtrType(); builder.IntType(32)],
        builder.PtrType())

    builder.WithInsertionPoint(outerFunc, fun () ->
        let envOuter = outerFunc.GetArgument(0)
        let n = outerFunc.GetArgument(1)

        // Allocate environment for inner closure (2 slots)
        let envSize = builder.ConstantInt(16L, 64)
        let envInner = builder.CreateGCMalloc(envSize)

        // env[0] = fn_ptr(inner)
        let c0 = builder.ConstantInt(0L, 64)
        let fnPtrInner = builder.CreateAddressOf(innerFunc)
        let slot0 = builder.CreateGEP(envInner, [c0])
        builder.CreateStore(fnPtrInner, slot0)

        // env[1] = n (captured)
        let c1 = builder.ConstantInt(1L, 64)
        let slot1 = builder.CreateGEP(envInner, [c1])
        builder.CreateStore(n, slot1)

        // Return inner closure (escapes function!)
        builder.CreateReturn(envInner)
    )

완전한 MLIR IR:

// Inner function
func.func @makeAdder_inner(%env: !llvm.ptr, %x: i32) -> i32 {
    // Load n from env[1]
    %c1 = arith.constant 1 : i64
    %n_addr = llvm.getelementptr %env[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %n = llvm.load %n_addr : !llvm.ptr -> i32

    // x + n
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

// Outer function
func.func @makeAdder_outer(%env_outer: !llvm.ptr, %n: i32) -> !llvm.ptr {
    // Allocate environment for inner closure
    %c16 = arith.constant 16 : i64
    %env_inner = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr

    // env[0] = fn_ptr(inner)
    %c0 = arith.constant 0 : i64
    %fn_ptr_inner = llvm.mlir.addressof @makeAdder_inner : !llvm.ptr
    %slot0 = llvm.getelementptr %env_inner[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr_inner, %slot0 : !llvm.ptr, !llvm.ptr

    // env[1] = n
    %c1 = arith.constant 1 : i64
    %slot1 = llvm.getelementptr %env_inner[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %n, %slot1 : !llvm.ptr, !llvm.ptr

    // Return closure (environment escapes!)
    func.return %env_inner : !llvm.ptr
}

테스트 코드:

func.func @main() -> i32 {
    // Create makeAdder closure (empty env)
    %c8 = arith.constant 8 : i64
    %env_makeAdder = llvm.call @GC_malloc(%c8) : (i64) -> !llvm.ptr
    %fn_ptr_makeAdder = llvm.mlir.addressof @makeAdder_outer : !llvm.ptr
    %c0 = arith.constant 0 : i64
    %slot0 = llvm.getelementptr %env_makeAdder[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr_makeAdder, %slot0 : !llvm.ptr, !llvm.ptr

    // Call makeAdder(5)
    %c5 = arith.constant 5 : i32
    %fn_ptr = llvm.load %slot0 : !llvm.ptr -> !llvm.ptr
    %add5 = llvm.call %fn_ptr(%env_makeAdder, %c5) : (!llvm.ptr, i32) -> !llvm.ptr
    // %add5 is a closure (inner function with n=5)

    // Call add5(10)
    %c10 = arith.constant 10 : i32
    %fn_ptr_inner = llvm.getelementptr %add5[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr_loaded = llvm.load %fn_ptr_inner : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr_loaded(%add5, %c10) : (!llvm.ptr, i32) -> i32
    // %result = 15

    func.return %result : i32
}

실행:

$ ./funlang makeAdder_test.fun
15

반환된 클로저의 생명주기

환경은 언제 해제되는가?

let add5 = makeAdder 5
// add5가 살아있는 동안, makeAdder의 환경도 유지된다
let result = add5 10   // OK
// add5가 스코프를 벗어나면, 환경도 GC에 의해 해제된다

GC의 추적:

  • add5 (클로저 포인터)가 살아있으면 → 환경 유지
  • add5가 더 이상 참조되지 않으면 → GC가 환경 수거

여러 클로저가 같은 환경을 공유하지 않는다:

let add5 = makeAdder 5
let add10 = makeAdder 10
  • add5add10서로 다른 환경을 가진다
  • makeAdder 호출이 새로운 환경을 힙에 할당한다

메모리 누수 없음:

GC가 자동으로 관리하므로, 프로그래머가 free를 호출할 필요 없다.

커링 패턴

다중 인자 함수를 클로저 체인으로 표현

**커링(Currying)**은 다중 인자 함수를 중첩된 단일 인자 함수로 변환하는 것이다:

// 다중 인자 함수 (Phase 3 스타일)
let add x y = x + y

// 커리된 함수 (Phase 4 스타일)
let add = fun x -> fun y -> x + y
  • add의 타입: int -> (int -> int)
  • add는 함수를 반환하는 함수다

커링의 장점:

  1. 부분 적용(Partial application): let add5 = add 5
  2. 합성 용이: 커리된 함수는 파이프라인에 쉽게 통합됨
  3. 일관된 타입 시스템: 모든 함수가 단일 인자

커링 예시:

let add x y = x + y     // 실제로는 fun x -> fun y -> x + y

let add5 = add 5        // 부분 적용
let result = add5 10    // 15

커리된 함수의 컴파일

AST:

Let("add",
    Lambda("x",
        Lambda("y",
            Add(Var "x", Var "y"))),
    ...)

Closure conversion:

  1. 내부 람다: fun y -> x + y (x 캡처)
    • Lifted: add_inner(env, y) = env[1] + y
  2. 외부 람다: fun x -> <inner closure>
    • Lifted: add_outer(env, x) = create_closure(add_inner, [x])

MLIR IR:

// Inner: fun y -> x + y
func.func @add_inner(%env: !llvm.ptr, %y: i32) -> i32 {
    // Load x from env[1]
    %c1 = arith.constant 1 : i64
    %x_addr = llvm.getelementptr %env[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %x = llvm.load %x_addr : !llvm.ptr -> i32

    // x + y
    %result = arith.addi %x, %y : i32
    func.return %result : i32
}

// Outer: fun x -> <inner closure>
func.func @add_outer(%env_outer: !llvm.ptr, %x: i32) -> !llvm.ptr {
    // Allocate environment for inner
    %c16 = arith.constant 16 : i64
    %env_inner = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr

    // env[0] = fn_ptr
    %c0 = arith.constant 0 : i64
    %fn_ptr = llvm.mlir.addressof @add_inner : !llvm.ptr
    %slot0 = llvm.getelementptr %env_inner[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr

    // env[1] = x
    %c1 = arith.constant 1 : i64
    %slot1 = llvm.getelementptr %env_inner[0, %c1] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %x, %slot1 : !llvm.ptr, !llvm.ptr

    func.return %env_inner : !llvm.ptr
}

부분 적용:

let add5 = add 5
// Call add(5) -> returns closure with x=5
%c5 = arith.constant 5 : i32
%closure_add = /* ... */
%fn_ptr_add = /* load from closure_add[0] */
%add5 = llvm.call %fn_ptr_add(%closure_add, %c5) : (!llvm.ptr, i32) -> !llvm.ptr
// %add5 is inner closure with x=5 captured

완전 적용:

let result = add5 10
// Call add5(10)
%c10 = arith.constant 10 : i32
%fn_ptr_inner = /* load from %add5[0] */
%result = llvm.call %fn_ptr_inner(%add5, %c10) : (!llvm.ptr, i32) -> i32
// %result = 15

커링과 makeAdder의 유사성:

  • makeAdder는 명시적 함수 반환
  • 커링은 암묵적 함수 반환 (다중 인자를 중첩 람다로 변환)
  • 둘 다 upward funarg problem 해결 필요 (힙 할당)

3개 이상의 인자

let add3 x y z = x + y + z
// = fun x -> fun y -> fun z -> x + y + z

중첩 구조:

  • 외부: fun x -> ... (비어있음)
  • 중간: fun y -> ... (x 캡처)
  • 내부: fun z -> x + y + z (x, y 캡처)

MLIR에서 3단계 중첩:

각 단계가 새로운 클로저를 생성하고, 이전 환경을 캡처한다. 복잡하지만 패턴은 동일하다.

메모리 관리와 클로저

GC가 클로저 생명주기를 관리한다

핵심 원칙:

  • 모든 클로저 환경은 힙에 할당된다 (GC_malloc)
  • GC가 자동으로 추적하여, 사용되지 않으면 해제한다
  • 프로그래머는 메모리 관리를 신경 쓸 필요 없다

생명주기 예시:

let createAdders () =
    let add5 = makeAdder 5
    let add10 = makeAdder 10
    add5    // add5만 반환, add10은 버려진다

let adder = createAdders()
let result = adder 20   // 25

메모리 추적:

  1. makeAdder 5 호출 → 환경1 할당 (n=5)
  2. makeAdder 10 호출 → 환경2 할당 (n=10)
  3. add5 반환 → 환경1은 유지
  4. add10은 스코프 벗어남 → 환경2는 GC 수거 대상
  5. adder 사용 → 환경1 유지
  6. adder 스코프 벗어남 → 환경1도 GC 수거

Dangling pointer 없음:

C/C++에서는 스택 포인터 반환이 위험하지만, GC 덕분에 FunLang은 안전하다:

let unsafeInC () =
    let local = 42
    fun () -> local   // C에서는 dangling pointer, FunLang에서는 OK

FunLang 컴파일러는 local을 환경에 캡처하고 힙에 할당하므로 안전하다.

순환 참조와 GC

Cyclic closures (순환 클로저):

let rec isEven n =
    if n = 0 then true
    else isOdd (n - 1)
and isOdd n =
    if n = 0 then false
    else isEven (n - 1)
  • isEven 클로저가 isOdd를 캡처
  • isOdd 클로저가 isEven을 캡처
  • 순환 참조!

GC의 처리:

Boehm GC는 tracing GC이므로, 순환 참조를 정확히 감지하고 해제한다:

  • 루트(스택, 전역)에서 도달 가능한 객체만 유지
  • 순환 참조가 루트에서 도달 불가능하면 → 수거

Reference counting과의 차이:

  • Reference counting: 순환 참조를 해제하지 못함 (메모리 누수)
  • Tracing GC: 순환 참조도 정확히 처리

Phase 2에서 Boehm GC를 선택한 이유가 이것이다.

클로저 생성 비용

힙 할당 비용:

  • 클로저 생성 = GC_malloc 호출
  • 스택 할당보다 느리지만, 안전성 보장

최적화 가능성 (Phase 7):

  • Escape analysis: 클로저가 함수를 벗어나지 않으면 스택 할당 가능
  • Closure inlining: 클로저가 즉시 호출되면 인라이닝 가능
  • Phase 4는 최적화 없이 항상 힙 할당

GC 오버헤드:

  • 주기적인 GC 실행 (pause time)
  • 메모리 오버헤드 (fragmentation)
  • 하지만 프로그래머 생산성은 크게 향상

Complete 예시: Map 함수

Map의 개념

Map 함수는 리스트의 각 원소에 함수를 적용한다:

// 개념적 정의 (Phase 6에서 완전 구현)
let rec map f list =
    match list with
    | [] -> []
    | head :: tail -> (f head) :: (map f tail)
  • 타입: (a -> b) -> list a -> list b
  • 의미: f를 각 원소에 적용해 새 리스트 생성

Map 예시:

let inc x = x + 1
let numbers = [1; 2; 3; 4]
let incremented = map inc numbers   // [2; 3; 4; 5]

let double x = x * 2
let doubled = map double numbers    // [2; 4; 6; 8]

Phase 4의 Map (단순화 버전)

Phase 4에는 리스트가 없으므로, 개념적 설명만 한다. 핵심은 HOF 패턴이다:

// 단순화: 두 원소 "리스트"만 처리
let map2 f x y =
    let fx = f x
    let fy = f y
    (fx, fy)   // Phase 6에서는 실제 리스트 반환

컴파일:

func.func @map2(%f: !llvm.ptr, %x: i32, %y: i32) -> (i32, i32) {
    // Call f(x)
    %c0 = arith.constant 0 : i64
    %fn_ptr_addr = llvm.getelementptr %f[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
    %fx = llvm.call %fn_ptr(%f, %x) : (!llvm.ptr, i32) -> i32

    // Call f(y)
    %fy = llvm.call %fn_ptr(%f, %y) : (!llvm.ptr, i32) -> i32

    // Return pair (fx, fy) - Phase 6에서는 리스트
    // ... (tuple 구현 생략)
}

Map의 핵심:

  • 함수 f인자로 받는다 (고차 함수)
  • f여러 번 호출한다 (간접 호출)
  • 각 호출마다 f의 환경을 전달한다

Map + Closure:

let addN n = fun x -> x + n
let add5 = addN 5
let result = map2 add5 10 20   // (15, 25)
  • add5는 클로저 (n=5 캡처)
  • map2add5를 받아서 두 번 호출
  • 각 호출마다 캡처된 n 사용

이것이 클로저 + 고차 함수 = 함수형 프로그래밍 핵심 조합이다.

자주 하는 실수 (Common Errors)

Error 1: 클로저를 첫 인자로 전달하지 않음

문제:

// 잘못된 호출 - 환경 누락
%result = llvm.call %fn_ptr(%arg) : (i32) -> i32

Lifted function은 첫 번째 파라미터로 환경을 받는다:

func.func @lifted(%env: !llvm.ptr, %arg: i32) -> i32

환경 없이 호출하면 타입 미스매치 또는 segfault:

ERROR: Call argument count mismatch (expected 2, got 1)

해결:

// 올바른 호출 - 클로저(환경)를 첫 인자로
%result = llvm.call %fn_ptr(%closure, %arg) : (!llvm.ptr, i32) -> i32

F# 헬퍼 사용:

// 자동으로 클로저를 첫 인자로 전달
builder.CallClosure(closure, [arg], resultType)

Error 2: 클로저 본체를 직접 호출

문제:

// 잘못된 호출 - lifted function을 직접 호출
%result = func.call @lifted_func(%env, %arg) : (!llvm.ptr, i32) -> i32

Lifted function은 내부 함수이고, 직접 호출하면 환경이 잘못 전달될 수 있다.

올바른 방법:

  1. 클로저에서 함수 포인터 추출
  2. 간접 호출 (llvm.call)
// 올바른 호출 - 클로저를 통해 간접 호출
%fn_ptr_addr = llvm.getelementptr %closure[0, 0] : (!llvm.ptr, i64) -> !llvm.ptr
%fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
%result = llvm.call %fn_ptr(%closure, %arg) : (!llvm.ptr, i32) -> i32

예외:

테스트 목적으로 직접 호출할 수는 있지만, 일반적인 패턴은 아니다.

Error 3: 스택에 반환 클로저의 환경 할당

문제:

func.func @makeAdder_wrong(%n: i32) -> !llvm.ptr {
    // 잘못됨 - 스택 할당!
    %c1 = arith.constant 1 : index
    %env = memref.alloca(%c1) : memref<?xi64>
    // ... store function pointer and n ...
    %ptr = memref.extract_aligned_pointer_as_index %env : memref<?xi64> -> !llvm.ptr
    func.return %ptr : !llvm.ptr
}   // 함수 종료 시 %env 소멸! Dangling pointer!

함수가 반환되면 스택 프레임이 소멸되므로, 환경 접근 시 undefined behavior.

해결:

func.func @makeAdder_correct(%n: i32) -> !llvm.ptr {
    // 올바름 - 힙 할당
    %env_size = arith.constant 16 : i64
    %env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
    // ... store function pointer and n ...
    func.return %env : !llvm.ptr
}   // %env는 힙에 있으므로 안전

원칙:

  • 반환되는 클로저: 항상 GC_malloc 사용
  • 로컬 클로저 (스코프 벗어나지 않음): 스택 가능 (Phase 7 최적화)

Error 4: 간접 호출 시 타입 미스매치

문제:

// Lifted function: (%env: !llvm.ptr, %x: i32) -> i32
func.func @lifted(%env: !llvm.ptr, %x: i32) -> i32

// 잘못된 호출 - 타입 불일치
%result = llvm.call %fn_ptr(%closure) : (!llvm.ptr) -> i32   // 인자 누락

LLVM IR에서 타입 불일치는 검증 실패 또는 런타임 크래시:

ERROR: Function signature mismatch in indirect call

해결:

간접 호출 시 정확한 시그니처 명시:

// 올바른 호출 - 모든 인자와 정확한 타입
%result = llvm.call %fn_ptr(%closure, %x) : (!llvm.ptr, i32) -> i32

타입 시그니처 유지:

  • Lifted function 정의와 간접 호출 타입이 정확히 일치해야 함
  • 컴파일러가 자동으로 추론하도록 구현

Error 5: 클로저 동일성 혼동

문제:

let f = fun x -> x + 1
let g = fun x -> x + 1
// f와 g는 같은가?

답: 아니다!

  • fg서로 다른 클로저
  • 각각 다른 환경 포인터를 가진다 (빈 환경이더라도)
  • 포인터 비교: f != g (주소가 다름)

의미적 동등성 vs 포인터 동등성:

  • 의미적 동등성: 같은 동작 (extensional equality)
  • 포인터 동등성: 같은 객체 (intensional equality)

FunLang은 포인터 동등성만 지원한다 (대부분의 언어와 동일).

예시:

// 두 클로저 생성
%closure1 = /* fun x -> x + 1 */
%closure2 = /* fun x -> x + 1 */

// 포인터 비교
%same = llvm.icmp "eq" %closure1, %closure2 : !llvm.ptr
// %same = false (주소가 다름)

함수 메모이제이션:

의미적 동등성이 필요하면 명시적 비교 로직 구현 필요 (Phase 7).

Phase 4 완료 요약

무엇을 구현했는가

Phase 4 - Closures & Higher-Order Functions:

  1. Chapter 12 - Closures:

    • 클로저 이론 (lexical scoping, free/bound variables)
    • 자유 변수 분석 알고리즘
    • 클로저 변환 (closure conversion)
    • 환경 구조체 (힙 할당)
    • GC_malloc으로 클로저 생성
  2. Chapter 13 - Higher-Order Functions:

    • 함수를 일급 값으로 다루기
    • 간접 호출 패턴 (llvm.call with function pointer)
    • Apply 함수 (함수를 인자로 받기)
    • Compose 함수 (여러 함수 인자)
    • 함수를 반환하기 (makeAdder, upward funarg problem)
    • 커링 패턴 (다중 인자 → 중첩 람다)
    • 메모리 관리 (GC가 클로저 생명주기 관리)
    • 자주 하는 실수 5가지

핵심 구현 항목:

항목설명MLIR 패턴
클로저 표현(fn_ptr, env) 쌍!llvm.ptr
환경 할당힙에 GC_mallocllvm.call @GC_malloc
간접 호출함수 포인터 로드 후 호출llvm.call %fn_ptr(...)
환경 접근GEP + loadllvm.getelementptr + llvm.load
클로저 생성환경 할당 + 변수 저장GC_malloc + store
함수 반환클로저 반환 (escaping)func.return %env

타입 시스템:

  • 모든 함수/클로저: !llvm.ptr (opaque pointer)
  • 함수 타입 (개념적): a -> b = (!llvm.ptr, a) -> b (lifted)

Phase 4가 가능하게 한 것

이제 컴파일할 수 있는 것:

// 1. 클로저 생성
let makeAdder n = fun x -> x + n

// 2. 고차 함수
let apply f x = f x
let compose f g x = f (g x)

// 3. 부분 적용
let add5 = makeAdder 5
let result = add5 10   // 15

// 4. 함수 합성
let inc x = x + 1
let double x = x * 2
let incThenDouble = compose double inc
let result2 = incThenDouble 5   // 12

// 5. 콜백 패턴
let processWithCallback callback data =
    let result = compute data
    callback result

// 6. 커링
let add x y = x + y   // = fun x -> fun y -> x + y
let add5 = add 5

함수형 프로그래밍의 핵심:

  • ✅ 클로저 (환경 캡처)
  • ✅ 고차 함수 (함수 인자/반환)
  • ✅ 부분 적용
  • ✅ 함수 합성
  • ⏸️ Map, filter, fold (Phase 6에서 리스트 추가 후)

다음 단계: Phase 5 - Custom MLIR Dialect

Phase 5 목표:

FunLang 전용 MLIR dialect 설계 및 구현:

  1. FunLang Dialect 정의:

    • funlang.closure 연산 (클로저 생성 추상화)
    • funlang.closure_call 연산 (간접 호출 추상화)
    • funlang.capture 연산 (환경 저장 추상화)
  2. Lowering passes:

    • FunLang dialect → Func/LLVM dialect
    • 고수준 의미론 → 저수준 MLIR
  3. 이점:

    • 컴파일러 코드 단순화 (고수준 연산 사용)
    • 최적화 pass 추가 용이 (dialect-specific 변환)
    • 타입 안전성 향상 (dialect 타입 시스템)

Phase 4 vs Phase 5:

Phase 4Phase 5
저수준 LLVM dialect 직접 생성고수준 FunLang dialect 생성
GEP, load, store 수동 관리추상화된 연산 사용
최적화 어려움Dialect 최적화 pass

Preview:

// Phase 4 (저수준)
%env = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr
%fn_ptr = llvm.mlir.addressof @func : !llvm.ptr
%slot0 = llvm.getelementptr %env[0, 0] : (!llvm.ptr, i64) -> !llvm.ptr
llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr
// ... (환경 저장)

// Phase 5 (고수준)
%closure = funlang.closure @func, %captured_vars : (i32) -> i32

Phase 5는 컴파일러 품질을 향상시킨다. Phase 4는 기능 완성이다.


Congratulations! Phase 4 완료. 이제 FunLang은 완전한 함수형 언어의 핵심 기능을 지원한다.

Chapter 14: 커스텀 Dialect 설계 (Custom Dialect Design)

소개

커스텀 MLIR dialect는 도메인별 언어(DSL)를 높은 수준에서 표현하는 방법이다. FunLang 컴파일러는 Phase 4까지 빌트인 dialect만 사용했다:

  • arith: 산술 연산
  • func: 함수 정의와 호출
  • scf: 구조적 제어 흐름 (if/while)
  • llvm: LLVM IR 타입과 operation

이 dialect들은 강력하고 범용적이지만, FunLang의 고수준 의미론을 직접 표현하지 못한다. Phase 4에서 클로저를 구현할 때 복잡성이 드러났다.

Phase 4의 문제점: 저수준 LLVM Dialect의 복잡성

Chapter 12에서 클로저를 구현할 때, 단순한 개념을 복잡한 저수준 코드로 표현해야 했다:

// 개념적으로 단순한 코드
let make_adder n =
    fun x -> x + n

생성된 MLIR (Phase 4 방식):

func.func @make_adder(%n: i32) -> !llvm.ptr {
    // 1. 환경 크기 계산: 8 (fn ptr) + 4 (n) = 12 bytes
    %env_size = arith.constant 12 : i64

    // 2. GC_malloc 호출
    %env_ptr = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // 3. 함수 포인터 저장 (env[0])
    %fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %fn_slot = llvm.getelementptr %env_ptr[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr

    // 4. 캡처된 변수 n 저장 (env[1])
    %n_slot = llvm.getelementptr %env_ptr[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr

    // 5. 환경 포인터 반환 (클로저)
    func.return %env_ptr : !llvm.ptr
}

// lambda_adder 함수 (환경 파라미터 추가)
func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // 1. 환경에서 n 로드
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32

    // 2. x + n 계산
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

문제점:

  1. GEP (getelementptr) 패턴의 반복

    • 환경 슬롯 접근할 때마다 getelementptr + load 패턴 필요
    • 인덱스 관리 오류 발생 가능 (env[0] vs env[1])
    • 코드 가독성 저하
  2. 저수준 메모리 관리 노출

    • GC_malloc 크기 계산 (8 + 4 bytes?)
    • 포인터 산술 명시적 작성
    • 타입 불일치 가능성 (i32 vs !llvm.ptr)
  3. 도메인 의미론 상실

    • “클로저“라는 개념이 안 보인다
    • “환경 포인터” = !llvm.ptr (opaque, 타입 안전성 없음)
    • 최적화 pass 작성 어려움 (어떤 포인터가 클로저인지?)
  4. 컴파일러 코드 복잡성 폭발

    • F# 컴파일러 코드가 저수준 세부사항 처리
    • 변수 1개 추가할 때마다 GEP 인덱스 계산
    • 에러 가능성 증가

실제 컴파일러 코드 (Phase 4):

// Lambda 컴파일 (Phase 4 버전)
let compileLambda (builder: OpBuilder) (param: string) (body: Expr) (capturedVars: (string * MlirValue) list) =
    // 1. 환경 크기 계산 (수동!)
    let fnPtrSize = 8L
    let varSize = 4L  // i32 가정
    let totalSize = fnPtrSize + (int64 capturedVars.Length) * varSize
    let sizeConst = builder.CreateI64Const(totalSize)

    // 2. GC_malloc 호출
    let envPtr = builder.CreateCall("GC_malloc", [sizeConst])

    // 3. 함수 포인터 저장 (getelementptr 0)
    let fnSlot = builder.CreateGEP(envPtr, 0)
    builder.CreateStore(fnAddr, fnSlot)

    // 4. 캡처된 변수들 저장 (getelementptr 1, 2, 3...)
    capturedVars |> List.iteri (fun i (name, value) ->
        let slot = builder.CreateGEP(envPtr, i + 1)
        builder.CreateStore(value, slot)
    )

    envPtr

크기 계산, GEP 인덱스 관리, 타입 추론 등 저수준 세부사항이 컴파일러 로직에 섞여있다.

Custom Dialect의 이점

커스텀 dialect를 사용하면 높은 수준에서 의미론을 표현할 수 있다. 같은 코드를 FunLang dialect로 표현한다면:

func.func @make_adder(%n: i32) -> !funlang.closure {
    // 클로저 생성 - 고수준 operation
    %closure = funlang.make_closure @lambda_adder(%n) : !funlang.closure
    func.return %closure : !funlang.closure
}

func.func @lambda_adder(%x: i32, %n: i32) -> i32 {
    // 캡처된 변수는 파라미터로 전달 (환경 명시적 관리 불필요)
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

변화:

  1. 도메인 의미론 보존

    • !funlang.closure 타입: 클로저임을 명시
    • funlang.make_closure: 클로저 생성의 의도가 명확
    • GEP, malloc 등 구현 세부사항 숨김
  2. 컴파일러 코드 단순화

// Lambda 컴파일 (Phase 5 버전 - 커스텀 dialect 사용)
let compileLambda (builder: OpBuilder) (param: string) (body: Expr) (capturedVars: (string * MlirValue) list) =
    // 간단! dialect operation 호출
    let capturedValues = capturedVars |> List.map snd
    builder.CreateFunLangClosure(lambdaFuncName, capturedValues)

환경 크기, GEP 인덱스, 메모리 레이아웃 등이 dialect operation 구현 안으로 캡슐화된다.

  1. 타입 안전성 향상

    • !llvm.ptr (모든 포인터) → !funlang.closure (클로저 전용)
    • 타입 체커가 클로저 오용 방지 가능
    • 예: 정수 포인터를 클로저로 사용하려는 시도 방지
  2. 최적화 기회 증가

    • Dialect-specific optimization pass 작성 가능
    • 예: 환경에 변수 1개만 있을 때 inline 최적화
    • 예: 탈출하지 않는 클로저는 stack 할당
  3. 디버깅 용이성

    • 높은 수준 IR을 먼저 검증 가능
    • 에러 메시지가 도메인 용어 사용 (“closure type mismatch” vs “pointer type mismatch”)

Progressive Lowering: 왜 점진적으로 낮추는가?

**Progressive lowering (점진적 하강)**은 높은 수준 표현을 여러 단계로 낮추는 전략이다:

FunLang Dialect (highest level, domain-specific)
    ↓ (FunLangToStandard lowering pass)
Func + SCF + MemRef (mid-level, general purpose)
    ↓ (StandardToLLVM lowering pass)
LLVM Dialect (low-level, machine-oriented)
    ↓ (MLIR-to-LLVM translation)
LLVM IR → Machine Code

Before/After 비교:

Phase 4 (Direct lowering)Phase 5 (Progressive lowering)
FunLang AST → LLVM DialectFunLang AST → FunLang Dialect
단일 거대 변환→ Func/SCF/MemRef Dialect
의미론 상실 즉시→ LLVM Dialect
최적화 불가각 단계에서 최적화 가능
디버깅 어려움각 단계 독립 검증 가능

Chapter 14의 목표

이 장에서 다루는 것:

  1. MLIR Dialect 아키텍처: Operation, Type, Attribute의 역할
  2. Progressive Lowering 철학: 왜 여러 단계로 낮추는가?
  3. TableGen ODS: MLIR operation 정의 DSL
  4. C API Shim 패턴: C++ dialect를 F#에 연결
  5. FunLang Dialect 설계: 어떤 operation을 만들 것인가?

이 장을 마치면:

  • 커스텀 dialect가 왜 필요한지 이해한다
  • TableGen ODS 문법을 읽고 쓸 수 있다
  • C API shim 패턴으로 F# interop 할 수 있다
  • FunLang dialect의 operation과 type을 설계할 수 있다
  • Progressive lowering 경로를 계획할 수 있다

Preview: Chapter 15에서는 실제로 FunLang dialect를 구현하고 lowering pass를 작성한다. Chapter 14는 이론적 기초를 확립한다.

MLIR Dialect 아키텍처

MLIR의 핵심 강점은 **extensibility (확장성)**다. 새 dialect를 정의해서 도메인별 개념을 표현할 수 있다.

Dialect Hierarchy 개념

MLIR 프로그램은 여러 dialect의 operation이 섞여있다:

func.func @example(%arg: i32) -> i32 {
    // arith dialect
    %c1 = arith.constant 1 : i32
    %sum = arith.addi %arg, %c1 : i32

    // scf dialect
    %result = scf.if %cond -> i32 {
        scf.yield %sum : i32
    } else {
        scf.yield %arg : i32
    }

    // func dialect
    func.return %result : i32
}

각 operation은 dialect.operation 형식으로 네임스페이스를 가진다:

  • arith.constant: arith dialect의 constant operation
  • scf.if: scf dialect의 if operation
  • func.return: func dialect의 return operation

Dialect hierarchy (계층 구조):

┌────────────────────────────────────────┐
│  FunLang Dialect (highest level)      │
│  - funlang.closure                     │
│  - funlang.apply                       │
│  - funlang.match (Phase 6)             │
└──────────────┬─────────────────────────┘
               │ (lowering pass)
               ↓
┌────────────────────────────────────────┐
│  Standard Dialects (mid-level)         │
│  - func.func, func.call                │
│  - scf.if, scf.while                   │
│  - memref.alloc, memref.load           │
└──────────────┬─────────────────────────┘
               │ (lowering pass)
               ↓
┌────────────────────────────────────────┐
│  LLVM Dialect (low-level)              │
│  - llvm.getelementptr                  │
│  - llvm.load, llvm.store               │
│  - llvm.call                           │
└──────────────┬─────────────────────────┘
               │ (translation)
               ↓
┌────────────────────────────────────────┐
│  LLVM IR                               │
└────────────────────────────────────────┘

높은 수준일수록:

  • 도메인 개념 명확 (funlang.closure vs !llvm.ptr)
  • 최적화 기회 많음 (의미론 활용 가능)
  • 플랫폼 독립적

낮은 수준일수록:

  • 기계 모델에 가까움 (레지스터, 메모리, 포인터)
  • 구현 세부사항 노출
  • 플랫폼 특화

Operation, Type, Attribute의 역할

MLIR dialect는 세 가지 확장 포인트를 제공한다:

1. Operation (연산)

Operation은 계산 단위다. FunLang dialect operation 예시:

// funlang.make_closure operation
%closure = funlang.make_closure @lambda_func(%n, %m) : !funlang.closure

// funlang.apply operation
%result = funlang.apply %closure(%x) : (i32) -> i32

Operation 구성 요소:

  • Name: funlang.make_closure (dialect.operation 형식)
  • Operands: @lambda_func, %n, %m (입력 값)
  • Results: %closure (출력 값)
  • Types: !funlang.closure, i32 (타입 정보)
  • Attributes: @lambda_func (컴파일 타임 상수)
  • Regions: 중첩 코드 블록 (예: scf.if의 then/else 블록)

Operation의 역할:

  • 도메인별 계산 표현 (클로저 생성, 패턴 매칭 등)
  • Verifier로 정적 검증 (타입 체크, 불변식)
  • Lowering 대상 (다른 dialect operation으로 변환)

2. Type (타입)

Type은 값의 종류를 표현한다. FunLang dialect type 예시:

// funlang.closure 타입
%closure : !funlang.closure

// funlang.list 타입 (Phase 6)
%list : !funlang.list<i32>

빌트인 타입 vs 커스텀 타입:

빌트인 타입커스텀 타입
i32, i64, f32!funlang.closure
!llvm.ptr!funlang.list<i32>
tensor<10xf32>!funlang.record<{x:i32, y:i32}>
범용적도메인 특화

타입의 역할:

  • 값의 의미론 표현 (closure vs raw pointer)
  • 타입 체커가 오용 방지
  • 최적화 hint (closure는 함수 포인터 + 환경)

3. Attribute (속성)

Attribute는 컴파일 타임 상수 값이다:

// IntegerAttr
%c1 = arith.constant 1 : i32

// SymbolRefAttr (함수 이름)
%fn = func.call @my_function(%arg) : (i32) -> i32

// StringAttr
%str = llvm.mlir.global "hello"

// ArrayAttr
#array = [1, 2, 3, 4]

FunLang dialect에서 attribute 사용:

// 클로저가 참조하는 함수 (SymbolRefAttr)
%closure = funlang.make_closure @lambda_func(%n) : !funlang.closure

// 패턴 매칭 케이스 (ArrayAttr)
%result = funlang.match %value {
    #funlang.pattern<constructor="Nil"> -> { ... }
    #funlang.pattern<constructor="Cons"> -> { ... }
}

Attribute의 역할:

  • 컴파일 타임 정보 저장 (함수 이름, 상수 등)
  • Serialization (MLIR IR을 파일에 저장)
  • Lowering 힌트

Region과 Block (Phase 1 복습)

Chapter 01에서 배운 개념 다시 보기:

Region: operation 내부의 코드 영역

scf.if %cond -> i32 {
    // ↑ Region 1 (then block)
    %result = arith.addi %a, %b : i32
    scf.yield %result : i32
} else {
    // ↑ Region 2 (else block)
    %result = arith.subi %a, %b : i32
    scf.yield %result : i32
}

Block: region 내부의 명령어 시퀀스

func.func @example(%arg: i32) -> i32 {
^entry:  // ↑ Block label
    %c1 = arith.constant 1 : i32
    %sum = arith.addi %arg, %c1 : i32
    func.return %sum : i32
}

FunLang dialect에서 region 사용 가능?

가능하다. 예를 들어 funlang.match operation은 패턴별 region을 가질 수 있다:

%result = funlang.match %list : !funlang.list<i32> -> i32 {
    // Nil case
    ^nil_case:
        %zero = arith.constant 0 : i32
        funlang.yield %zero : i32

    // Cons case
    ^cons_case(%head: i32, %tail: !funlang.list<i32>):
        %sum = funlang.apply %f(%head) : (i32) -> i32
        funlang.yield %sum : i32
}

각 케이스가 별도 block을 가진다. 이렇게 structured control flow를 dialect operation으로 표현할 수 있다.

Symbol Table과 함수 참조

MLIR은 symbol table을 사용해 함수, 전역 변수 등을 참조한다.

Symbol (심볼):

// 함수 정의 - symbol
func.func @my_function(%arg: i32) -> i32 {
    func.return %arg : i32
}

// 함수 참조 - SymbolRefAttr
%result = func.call @my_function(%x) : (i32) -> i32

@my_functionSymbolRefAttr이다:

  • 컴파일 타임에 해석됨
  • 타입 체커가 함수 시그니처 검증
  • Linker가 심볼 해석

FunLang dialect에서 symbol 사용:

// 람다 함수 정의 (lifted)
func.func private @lambda_adder(%env: !funlang.env, %x: i32) -> i32 {
    // ...
}

// 클로저 생성 - 함수 심볼 참조
%closure = funlang.make_closure @lambda_adder(%n) : !funlang.closure

@lambda_adder가 심볼이다. 클로저는 이 심볼을 참조해서 함수 포인터를 얻는다.

Symbol vs SSA Value:

SymbolSSA Value
@func_name%result
컴파일 타임 상수런타임 값
전역 참조 가능로컬 스코프만
함수, 전역 변수operation 결과

Phase 4에서 사용한 llvm.mlir.addressof @lambda_func도 심볼을 사용한다:

// 함수 심볼 주소 얻기
%fn_addr = llvm.mlir.addressof @lambda_func : !llvm.ptr

DialectRegistry와 의존성 선언

DialectRegistry는 context에 dialect를 등록하는 메커니즘이다.

Phase 1-4 코드 (빌트인 dialect 등록):

// MlirHelpers.fs
let createContextWithDialects() =
    let ctx = MlirContext.Create()

    // 빌트인 dialect 등록
    let arithHandle = Mlir.mlirGetDialectHandle__arith__()
    Mlir.mlirDialectHandleRegisterDialect(arithHandle, ctx.Handle)

    let funcHandle = Mlir.mlirGetDialectHandle__func__()
    Mlir.mlirDialectHandleRegisterDialect(funcHandle, ctx.Handle)

    // ... scf, llvm 등

    ctx

Phase 5 코드 (커스텀 dialect 추가):

// FunLang dialect 등록
let ctx = createContextWithDialects()

// C API shim 호출
FunLangDialect.RegisterDialect(ctx)

의존성 선언:

FunLang dialect는 다른 dialect를 사용할 수 있다:

// FunLang dialect 정의 (C++)
class FunLangDialect : public Dialect {
public:
    FunLangDialect(MLIRContext *context) : ... {
        // 의존성 선언
        addDependentDialect<func::FuncDialect>();
        addDependentDialect<arith::ArithDialect>();
        addDependentDialect<LLVM::LLVMDialect>();
    }
};

이렇게 하면:

  • FunLang operation이 func, arith operation을 사용 가능
  • Lowering pass에서 func.call, arith.addi 생성 가능
  • Context가 필요한 dialect 자동 로드

FunLang Dialect 계층 구조 다이어그램

┌─────────────────────────────────────────────────────────────────┐
│                    MLIR Context                                 │
│  (모든 dialect의 컨테이너)                                        │
└────────────────────────────┬────────────────────────────────────┘
                             │
        ┌────────────────────┼────────────────────┐
        │                    │                    │
        ▼                    ▼                    ▼
┌───────────────┐   ┌───────────────┐   ┌───────────────┐
│ FunLang       │   │ BuiltIn       │   │ LLVM          │
│ Dialect       │   │ Dialect       │   │ Dialect       │
│               │   │ (func, scf,   │   │               │
│ - closure     │   │  arith)       │   │ - ptr         │
│ - apply       │   │               │   │ - call        │
│ - match       │   │ - func.func   │   │ - gep         │
└───────┬───────┘   │ - scf.if      │   │ - load/store  │
        │           │ - arith.addi  │   └───────────────┘
        │           └───────────────┘
        │
        │  (의존성)
        └──────────────────┐
                           │
                ┌──────────┴──────────┐
                │                     │
                ▼                     ▼
        ┌───────────────┐     ┌───────────────┐
        │ Types         │     │ Operations    │
        │               │     │               │
        │ - closure     │     │ - make_closure│
        │ - list<T>     │     │ - apply       │
        │ - record<...> │     │ - match       │
        └───────────────┘     └───────────────┘

Dialect 간 관계:

  1. FunLang Dialect: 최상위, 도메인 특화

    • 의존: func, scf, arith, llvm dialect
    • 제공: funlang.* operation/type
  2. BuiltIn Dialects: 중간 수준, 범용

    • 의존: 최소 (arith는 독립적)
    • 제공: func., scf., arith.* operation
  3. LLVM Dialect: 최하위, 기계 지향

    • 의존: 없음 (target-independent LLVM IR)
    • 제공: llvm.* operation

Lowering 경로:

funlang.make_closure
    ↓ (FunLangToFunc lowering)
func.func + memref.alloc + func.call
    ↓ (FuncToLLVM lowering)
llvm.call + llvm.getelementptr + llvm.store
    ↓ (MLIR-to-LLVM translation)
LLVM IR: call, getelementptr, store

Progressive Lowering 철학

Why Not Direct FunLang → LLVM Lowering?

컴파일러를 설계할 때 유혹이 있다: “FunLang AST를 바로 LLVM dialect로 낮추면 빠르지 않을까?”

직접 lowering의 문제점:

1. 최적화 기회 상실

예시: 클로저 inlining

// FunLang 코드
let apply f x = f x

let result = apply (fun y -> y + 1) 42

Direct lowering (FunLang → LLVM):

// 클로저 생성 (즉시 LLVM dialect)
%env = llvm.call @GC_malloc(...) : (i64) -> !llvm.ptr
%fn_ptr = llvm.mlir.addressof @lambda_0 : !llvm.ptr
%fn_slot = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %fn_ptr, %fn_slot : !llvm.ptr, !llvm.ptr
// ... (환경 저장)

// 클로저 호출 (간접 호출)
%fn_ptr_loaded = llvm.load %fn_slot : !llvm.ptr -> !llvm.ptr
%result = llvm.call %fn_ptr_loaded(%env, %x) : (!llvm.ptr, i32) -> i32

문제: LLVM 수준에서는 이것이 즉시 사용되는 클로저인지 알 수 없다. 최적화 pass가 malloc, store, load, call 패턴을 분석해야 하는데, 이미 의미론이 상실됨.

Progressive lowering (FunLang → Func → LLVM):

// Step 1: FunLang dialect (high-level)
%closure = funlang.make_closure @lambda_0() : !funlang.closure
%result = funlang.apply %closure(%x) : (i32) -> i32

// Optimization pass: closure inlining (FunLang dialect level)
// "이 클로저는 즉시 사용되고 탈출하지 않는다" → inline!
%result = func.call @lambda_0(%x) : (i32) -> i32

// Step 2: Lower to LLVM (이미 최적화됨)
%result = llvm.call @lambda_0(%x) : (i32) -> i32

높은 수준에서 최적화하면:

  • 의미론이 명확 (closure + apply = inline candidate)
  • 패턴 매칭 쉬움 (GEP + load 추적 불필요)
  • 변환이 안전함 (타입 체커가 검증)

2. 코드 복잡성 폭발

Direct lowering 컴파일러 코드:

// compileLambda: FunLang AST → LLVM dialect
let rec compileLambda (builder: OpBuilder) (lambda: Expr) =
    match lambda with
    | Lambda(param, body) ->
        // 1. 자유 변수 분석
        let freeVars = analyzeFreeVars lambda

        // 2. 환경 크기 계산 (수동!)
        let envSize = 8L + (int64 freeVars.Length) * 4L
        let sizeConst = builder.CreateI64Const(envSize)

        // 3. GC_malloc 호출
        let malloc = builder.CreateCall("GC_malloc", [sizeConst])

        // 4. 함수 포인터 저장 (GEP 0)
        let fnAddr = builder.CreateAddressOf(lambdaName)
        let fnSlot = builder.CreateGEP(malloc, 0)
        builder.CreateStore(fnAddr, fnSlot)

        // 5. 변수 저장 (GEP 1, 2, 3...)
        freeVars |> List.iteri (fun i var ->
            let value = compileExpr builder var
            let slot = builder.CreateGEP(malloc, i + 1)
            builder.CreateStore(value, slot)
        )

        // 6. 람다 함수 정의 (별도 함수)
        let lambdaFunc = builder.CreateFunction(lambdaName)
        // ... (환경 파라미터, body 컴파일, GEP + load for captures)

        malloc

모든 세부사항이 한 함수에 섞여있다:

  • 메모리 레이아웃 계산
  • GEP 인덱스 관리
  • 타입 변환
  • 함수 생성

Progressive lowering 컴파일러 코드:

// Step 1: FunLang AST → FunLang dialect
let rec compileLambda (builder: OpBuilder) (lambda: Expr) =
    match lambda with
    | Lambda(param, body) ->
        let freeVars = analyzeFreeVars lambda
        let capturedValues = freeVars |> List.map (compileExpr builder)

        // 간단! dialect operation 호출
        builder.CreateFunLangClosure(lambdaName, capturedValues)

// Step 2: FunLang dialect → Func dialect (별도 lowering pass)
// 이 pass에서 malloc, GEP, store 처리
class FunLangToFuncLowering : public RewritePattern {
    LogicalResult matchAndRewrite(MakeClosureOp op, ...) {
        // 여기서 환경 할당, 함수 포인터 저장 등 처리
        // 재사용 가능한 로직, 독립적 테스트 가능
    }
};

코드가 계층화된다:

  • AST → Dialect: 의미론 변환 (단순)
  • Dialect → Dialect: 구현 세부사항 (재사용 가능)
  • Dialect → LLVM: 기계 코드 생성 (표준 패턴)

3. 디버깅 어려움

Direct lowering:

FunLang AST → [Giant Black Box] → LLVM Dialect

에러가 발생하면:

  • LLVM IR에서 segfault 발견
  • 원인 추적 어려움 (GEP 인덱스? 타입? 메모리?)
  • AST와 LLVM IR 사이 gap이 크다

Progressive lowering:

FunLang AST → FunLang Dialect → Func Dialect → LLVM Dialect
               ↑ verify       ↑ verify      ↑ verify

각 단계에서 검증 가능:

  1. FunLang Dialect: 타입 체크 (!funlang.closure vs i32)
  2. Func Dialect: 함수 시그니처, region 구조
  3. LLVM Dialect: 포인터 연산, 메모리 안전성

에러 메시지 비교:

Direct lowering:

error: 'llvm.load' op requires result type '!llvm.ptr' but found 'i32'
  %value = llvm.load %slot : !llvm.ptr -> i32

“어디서 잘못됐지? GEP 인덱스? 타입 계산?”

Progressive lowering:

error: 'funlang.apply' op operand type mismatch
  expected: !funlang.closure
  found: i32
  %result = funlang.apply %x(%y) : (i32) -> i32

“아, 클로저가 아니라 정수를 apply하려고 했구나!”

Progressive Lowering 단계 설계

FunLang 컴파일러의 lowering 경로:

┌─────────────────────────────────────────┐
│  FunLang AST (F# data structures)       │
│  - Lambda(param, body)                  │
│  - Apply(fn, arg)                       │
│  - Let(name, value, body)               │
└───────────────┬─────────────────────────┘
                │ (AST → Dialect)
                ↓
┌─────────────────────────────────────────┐
│  FunLang Dialect (MLIR IR)              │
│  - funlang.make_closure                 │
│  - funlang.apply                        │
│  - funlang.match                        │
│                                         │
│  Optimization:                          │
│  - Closure inlining                     │
│  - Dead closure elimination             │
│  - Escape analysis                      │
└───────────────┬─────────────────────────┘
                │ (FunLangToFunc lowering pass)
                ↓
┌─────────────────────────────────────────┐
│  Func + SCF + MemRef (MLIR IR)          │
│  - func.func, func.call                 │
│  - scf.if, scf.while                    │
│  - memref.alloc, memref.load/store      │
│                                         │
│  Optimization:                          │
│  - Inlining                             │
│  - Dead code elimination                │
│  - Loop optimization                    │
└───────────────┬─────────────────────────┘
                │ (FuncToLLVM lowering pass)
                ↓
┌─────────────────────────────────────────┐
│  LLVM Dialect (MLIR IR)                 │
│  - llvm.call                            │
│  - llvm.getelementptr                   │
│  - llvm.load, llvm.store                │
│                                         │
│  Optimization:                          │
│  - (LLVM's own optimization passes)     │
└───────────────┬─────────────────────────┘
                │ (MLIR → LLVM IR translation)
                ↓
┌─────────────────────────────────────────┐
│  LLVM IR                                │
│  - call, getelementptr, load, store     │
└───────────────┬─────────────────────────┘
                │ (LLVM backend)
                ↓
┌─────────────────────────────────────────┐
│  Machine Code (x86, ARM, etc.)          │
└─────────────────────────────────────────┘

각 단계의 역할

Stage 1: FunLang Dialect

표현: 도메인 의미론 (클로저, 패턴 매칭, 리스트)

Example:

func.func @make_adder(%n: i32) -> !funlang.closure {
    %closure = funlang.make_closure @lambda_adder(%n) : !funlang.closure
    func.return %closure : !funlang.closure
}

func.func private @lambda_adder(%x: i32, %n: i32) -> i32 {
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

특징:

  • !funlang.closure 타입 사용
  • 구현 세부사항 숨김 (malloc, GEP 없음)
  • 최적화 가능 (클로저 inlining, escape analysis)

최적화 예시:

// Before optimization
%closure = funlang.make_closure @lambda_inc() : !funlang.closure
%result = funlang.apply %closure(%x) : (i32) -> i32

// After closure inlining (FunLang dialect pass)
%result = func.call @lambda_inc(%x) : (i32) -> i32

Stage 2: Func + SCF + MemRef Dialect

표현: 범용 추상화 (함수, 제어 흐름, 메모리)

Example (Stage 1 lowering 후):

func.func @make_adder(%n: i32) -> !llvm.ptr {
    // 환경 할당 (memref.alloc)
    %c2 = arith.constant 2 : index
    %env = memref.alloc(%c2) : memref<?xi32>

    // 함수 포인터 저장 (conceptual, 실제는 다름)
    // ... (이 단계에서 여전히 추상적)

    // 캡처된 변수 저장
    %c1 = arith.constant 1 : index
    memref.store %n, %env[%c1] : memref<?xi32>

    // 포인터 반환
    %ptr = memref.cast %env : memref<?xi32> to !llvm.ptr
    func.return %ptr : !llvm.ptr
}

특징:

  • 여전히 플랫폼 독립적
  • 메모리 연산이 추상적 (memref vs raw pointer)
  • 구조적 제어 흐름 (scf.if vs cf.br)

최적화 예시:

// Inlining (func dialect level)
%result = func.call @small_function(%x) : (i32) -> i32

// After inlining
// (함수 본체 inline됨)
%result = arith.addi %x, %c1 : i32

Stage 3: LLVM Dialect

표현: 기계 모델 (포인터, 레지스터, 메모리)

Example (Stage 2 lowering 후):

llvm.func @make_adder(%n: i32) -> !llvm.ptr {
    // GC_malloc 호출
    %c12 = llvm.mlir.constant(12 : i64) : i64
    %env = llvm.call @GC_malloc(%c12) : (i64) -> !llvm.ptr

    // 함수 포인터 저장
    %fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %fn_slot = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr

    // 캡처된 변수 저장
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr

    llvm.return %env : !llvm.ptr
}

특징:

  • 구현 세부사항 완전 노출 (GEP, malloc, store)
  • LLVM IR과 1:1 대응
  • 플랫폼 특화 최적화 가능 (LLVM backend)

ConversionTarget과 Legal/Illegal Dialects

Lowering pass는 특정 dialect operation을 다른 dialect operation으로 변환한다. MLIR은 ConversionTarget으로 이를 제어한다.

ConversionTarget 개념:

“이 pass 이후 어떤 operation이 허용되는가?”

// FunLangToFunc lowering pass
class FunLangToFuncLowering : public Pass {
    void runOnOperation() override {
        ConversionTarget target(getContext());

        // FunLang dialect operation은 불법 (lowering 대상)
        target.addIllegalDialect<FunLangDialect>();

        // Func, SCF, Arith dialect operation은 합법
        target.addLegalDialect<func::FuncDialect>();
        target.addLegalDialect<scf::SCFDialect>();
        target.addLegalDialect<arith::ArithDialect>();

        // Lowering 수행
        if (failed(applyPartialConversion(module, target, patterns)))
            signalPassFailure();
    }
};

Legal vs Illegal:

Legal OperationsIllegal Operations
Pass 후 존재 가능Pass 후 제거되어야 함
변환 불필요변환 패턴 필요
예: func.call예: funlang.make_closure

예시: FunLangToFunc lowering

Before (FunLang dialect):

%closure = funlang.make_closure @lambda_func(%n) : !funlang.closure

After (Func + MemRef dialect):

%env = memref.alloc(...) : memref<?xi32>
memref.store %n, %env[%c1] : memref<?xi32>
%ptr = memref.cast %env : memref<?xi32> to !llvm.ptr

ConversionTarget이 보장:

  • funlang.make_closure는 pass 후 존재하지 않음
  • memref.alloc, memref.store는 합법

RewritePatternSet 개념

RewritePattern은 operation 변환 규칙이다.

구조:

struct MakeClosureOpLowering : public OpRewritePattern<MakeClosureOp> {
    using OpRewritePattern<MakeClosureOp>::OpRewritePattern;

    LogicalResult matchAndRewrite(MakeClosureOp op,
                                   PatternRewriter &rewriter) const override {
        // 1. Match: 이 operation을 변환할 수 있는가?
        // (OpRewritePattern이 자동으로 매칭)

        // 2. Rewrite: 어떻게 변환하는가?

        // 환경 할당
        Value envSize = rewriter.create<arith::ConstantOp>(...);
        Value env = rewriter.create<memref::AllocOp>(...);

        // 캡처된 변수 저장
        for (auto [idx, captured] : enumerate(op.getCapturedValues())) {
            Value index = rewriter.create<arith::ConstantIndexOp>(idx);
            rewriter.create<memref::StoreOp>(captured, env, index);
        }

        // 원래 operation 교체
        rewriter.replaceOp(op, env);
        return success();
    }
};

RewritePatternSet 사용:

void FunLangToFuncPass::runOnOperation() {
    RewritePatternSet patterns(&getContext());

    // 변환 패턴 등록
    patterns.add<MakeClosureOpLowering>(&getContext());
    patterns.add<ApplyOpLowering>(&getContext());
    patterns.add<MatchOpLowering>(&getContext());

    // Conversion target 설정
    ConversionTarget target(getContext());
    target.addIllegalDialect<FunLangDialect>();
    target.addLegalDialect<func::FuncDialect, memref::MemRefDialect, arith::ArithDialect>();

    // 변환 적용
    if (failed(applyPartialConversion(getOperation(), target, patterns)))
        signalPassFailure();
}

각 pattern이 처리:

  • MakeClosureOpLowering: funlang.make_closurememref.alloc + stores
  • ApplyOpLowering: funlang.applyfunc.call (indirect)
  • MatchOpLowering: funlang.matchscf.if cascade

실제 Lowering Pass 구조 미리보기

FunLangToFunc.cpp 구조:

// 1. Pattern 정의들
namespace {

struct MakeClosureOpLowering : public OpRewritePattern<MakeClosureOp> {
    LogicalResult matchAndRewrite(...) const override {
        // funlang.make_closure → memref operations
    }
};

struct ApplyOpLowering : public OpRewritePattern<ApplyOp> {
    LogicalResult matchAndRewrite(...) const override {
        // funlang.apply → func.call (indirect)
    }
};

} // namespace

// 2. Pass 정의
struct FunLangToFuncPass : public PassWrapper<FunLangToFuncPass, OperationPass<ModuleOp>> {
    void getDependentDialects(DialectRegistry &registry) const override {
        registry.insert<func::FuncDialect, memref::MemRefDialect, arith::ArithDialect>();
    }

    void runOnOperation() override {
        // Pattern set 구성
        RewritePatternSet patterns(&getContext());
        patterns.add<MakeClosureOpLowering, ApplyOpLowering>(&getContext());

        // Target 설정
        ConversionTarget target(getContext());
        target.addIllegalDialect<FunLangDialect>();
        target.addLegalDialect<func::FuncDialect, memref::MemRefDialect, arith::ArithDialect>();

        // 변환 실행
        if (failed(applyPartialConversion(getOperation(), target, patterns)))
            signalPassFailure();
    }
};

// 3. Pass 등록
std::unique_ptr<Pass> createFunLangToFuncPass() {
    return std::make_unique<FunLangToFuncPass>();
}

Pass 실행 순서 (Compiler.fs):

// MLIR pass pipeline
let runLoweringPasses (module: MlirModule) =
    let pm = PassManager.Create(module.Context)

    // 1. FunLang dialect → Func/MemRef dialect
    pm.AddPass(FunLangPasses.CreateFunLangToFuncPass())

    // 2. SCF → CF (structured control flow → control flow)
    pm.AddPass(Passes.CreateSCFToCFPass())

    // 3. Func/MemRef/Arith → LLVM dialect
    pm.AddPass(Passes.CreateFuncToLLVMPass())
    pm.AddPass(Passes.CreateMemRefToLLVMPass())
    pm.AddPass(Passes.CreateArithToLLVMPass())

    pm.Run(module)

요약

Chapter 14에서 배운 것:

  1. Phase 4의 문제점: 저수준 LLVM dialect 직접 사용 시 GEP 패턴 반복, 도메인 의미론 상실, 컴파일러 코드 복잡도 증가

  2. Custom Dialect의 이점: 도메인 의미론 보존, 컴파일러 코드 단순화, 타입 안전성 향상, 최적화 기회 증가

  3. MLIR Dialect 아키텍처: Operation (계산), Type (값 종류), Attribute (컴파일 타임 상수), Region/Block (중첩 코드), Symbol Table (전역 참조)

  4. Progressive Lowering 철학:

    • 직접 lowering의 문제 (최적화 상실, 복잡도 폭발, 디버깅 어려움)
    • 단계적 lowering의 이점 (각 단계 최적화, 독립 검증, 명확한 책임)
    • FunLang → Func/MemRef → LLVM 경로
  5. ConversionTarget과 RewritePattern: Legal/Illegal dialect 정의, 변환 규칙 작성, pass 구조

TableGen ODS (Operation Definition Specification) 기초

TableGen이란?

TableGen은 LLVM 프로젝트의 **DSL (Domain-Specific Language)**이다. 코드 생성(code generation)을 위한 선언적 언어다.

Why TableGen?

MLIR operation을 C++로 직접 정의하면:

// C++ 직접 정의 (verbose!)
class MakeClosureOp : public Op<MakeClosureOp, OpTrait::OneResult, OpTrait::ZeroRegions> {
public:
    static StringRef getOperationName() { return "funlang.make_closure"; }

    static void build(OpBuilder &builder, OperationState &state,
                      FlatSymbolRefAttr funcName, ValueRange capturedValues) {
        // 복잡한 builder 로직...
    }

    LogicalResult verify() {
        // 복잡한 verification 로직...
    }

    // parser, printer, folders, canonicalizers...
    // 100+ lines of boilerplate!
};

문제점:

  • Boilerplate 코드 많음 (parser, printer, builder)
  • 타입 안전성 수동 관리
  • 일관성 유지 어려움 (operation마다 다른 스타일)

TableGen 사용:

// TableGen 정의 (concise!)
def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  let summary = "Creates a closure value";
  let description = [{
    Creates a closure by capturing values into an environment.
  }];

  let arguments = (ins FlatSymbolRefAttr:$funcName,
                       Variadic<AnyType>:$capturedValues);
  let results = (outs FunLang_ClosureType:$result);

  let assemblyFormat = "$funcName `(` $capturedValues `)` attr-dict `:` type($result)";
}

장점:

  • 선언적 (what, not how)
  • 코드 자동 생성 (parser, printer, builder, verifier)
  • 타입 안전성 자동 보장
  • 일관된 스타일

TableGen 빌드 프로세스:

FunLangOps.td (TableGen source)
    ↓ (mlir-tblgen tool)
FunLangOps.h.inc (Generated C++ header)
FunLangOps.cpp.inc (Generated C++ implementation)
    ↓ (C++ compiler)
libMLIRFunLangDialect.so (Shared library)

FunLang Dialect 정의

FunLangDialect.td:

// FunLang dialect 정의
def FunLang_Dialect : Dialect {
  // Dialect 이름 (operation prefix)
  let name = "funlang";

  // C++ namespace
  let cppNamespace = "::mlir::funlang";

  // 의존성 선언
  let dependentDialects = [
    "func::FuncDialect",
    "arith::ArithDialect",
    "LLVM::LLVMDialect"
  ];

  // Description (documentation)
  let description = [{
    The FunLang dialect represents high-level functional programming constructs
    for the FunLang compiler. It provides operations for closures, pattern matching,
    and other domain-specific features.
  }];

  // Extra class declarations (C++ 코드 삽입)
  let extraClassDeclaration = [{
    // Custom dialect methods (optional)
    void registerTypes();
    void registerOperations();
  }];
}

각 필드 의미:

  1. name: Dialect 네임스페이스

    • Operation: funlang.make_closure
    • Type: !funlang.closure
  2. cppNamespace: 생성되는 C++ 코드의 네임스페이스

    • mlir::funlang::MakeClosureOp
    • mlir::funlang::ClosureType
  3. dependentDialects: 이 dialect가 사용하는 다른 dialect

    • FunLang operation이 func.func, arith.addi 등 사용 가능
    • Context에 자동 로드됨
  4. description: Documentation (mlir-doc tool이 사용)

  5. extraClassDeclaration: 추가 C++ 메서드 선언

Operation 정의 구조

Base class 정의:

// FunLang operation base class
class FunLang_Op<string mnemonic, list<Trait> traits = []>
    : Op<FunLang_Dialect, mnemonic, traits>;

모든 FunLang operation이 이 base class를 상속한다.

Operation 정의 예시: make_closure

def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  // 한 줄 요약
  let summary = "Creates a closure value";

  // 상세 설명 (multi-line string)
  let description = [{
    The `funlang.make_closure` operation creates a closure by capturing
    values into an environment. The closure can later be invoked using
    `funlang.apply`.

    Example:
    ```mlir
    %closure = funlang.make_closure @my_lambda(%x, %y) : !funlang.closure
    ```
  }];

  // 입력 인자 (arguments)
  let arguments = (ins
    FlatSymbolRefAttr:$funcName,        // 함수 심볼 (@lambda_0)
    Variadic<AnyType>:$capturedValues   // 캡처된 값들 (%x, %y, ...)
  );

  // 출력 결과 (results)
  let results = (outs
    FunLang_ClosureType:$result         // 클로저 값
  );

  // Assembly format (parser/printer)
  let assemblyFormat = [{
    $funcName `(` $capturedValues `)` attr-dict `:` type($result)
  }];

  // Traits (operation 특성)
  // [Pure]: no side effects, result depends only on operands
}

Arguments (ins):

TypeNameMeaning
FlatSymbolRefAttrfuncName함수 이름 attribute (@lambda_0)
Variadic<AnyType>capturedValues가변 길이 값 목록 (captured variables)

Results (outs):

TypeNameMeaning
FunLang_ClosureTyperesult클로저 타입 값

Assembly Format:

  • $funcName: @lambda_func 출력
  • `(`: 리터럴 ( 문자
  • $capturedValues: 캡처된 값들 출력 (%x, %y)
  • `)`: 리터럴 ) 문자
  • attr-dict: attribute dictionary (선택적)
  • `:`: 리터럴 : 문자
  • type($result): 결과 타입 출력 (!funlang.closure)

생성되는 IR:

%closure = funlang.make_closure @lambda_func(%x, %y) : !funlang.closure

Operation Traits

Trait는 operation의 특성을 선언한다. MLIR이 최적화/검증에 사용한다.

Pure trait:

def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  // ...
}

Pure = 순수 함수 (no side effects, deterministic)

  • 같은 입력 → 같은 출력
  • 메모리 쓰기 없음, I/O 없음
  • 최적화 가능: 중복 제거, 재배치

MemoryEffects trait:

def FunLang_AllocClosureOp : FunLang_Op<"alloc_closure",
    [MemoryEffects<[MemAlloc]>]> {
  // Memory allocation operation
}

MemoryEffects<[MemAlloc]> = 메모리 할당만 함 (읽기/쓰기 없음)

다른 유용한 traits:

TraitMeaningExample
NoSideEffect부작용 없음 (Pure와 비슷)산술 연산
TerminatorBasic block 종료 operationfunc.return
IsolatedFromAbove외부 값 참조 불가func.func
SameOperandsAndResultType입력과 출력 타입 동일arith.addi

hasVerifier 속성

Custom verification 로직이 필요하면:

def FunLang_ApplyOp : FunLang_Op<"apply"> {
  let arguments = (ins FunLang_ClosureType:$closure,
                       Variadic<AnyType>:$arguments);
  let results = (outs AnyType:$result);

  // Custom verifier 필요
  let hasVerifier = 1;
}

생성된 C++ 코드에 verify() 메서드 선언:

// FunLangOps.h.inc에 생성됨
class ApplyOp : public ... {
public:
    LogicalResult verify();  // Custom implementation 필요
};

Verifier 구현 (FunLangOps.cpp):

LogicalResult ApplyOp::verify() {
    // 클로저 타입 체크
    if (!getClosure().getType().isa<ClosureType>())
        return emitError("operand must be a closure type");

    // 인자 개수 체크 (optional, 런타임 체크 가능)
    // ...

    return success();
}

Type 정의 (TypeDef)

FunLang Closure 타입:

def FunLang_ClosureType : TypeDef<FunLang_Dialect, "Closure"> {
  let mnemonic = "closure";

  let summary = "FunLang closure type";

  let description = [{
    Represents a closure value (function pointer + captured environment).
  }];

  // Parameters (타입 파라미터)
  // Closure는 파라미터 없음 (단순 타입)
  let parameters = (ins);

  // Assembly format
  let assemblyFormat = "";
}

생성되는 C++ 코드:

// FunLangTypes.h.inc
class ClosureType : public Type::TypeBase<ClosureType, Type, TypeStorage> {
public:
    static constexpr StringLiteral getMnemonic() { return "closure"; }
    // ...
};

사용 예:

// MLIR IR
%closure : !funlang.closure

// F# 코드
let closureType = FunLangType.GetClosure(ctx)

FunLang 타입 설계

1. ClosureType (클로저)

def FunLang_ClosureType : TypeDef<FunLang_Dialect, "Closure"> {
  let mnemonic = "closure";
  let summary = "Function closure (function pointer + environment)";
  let parameters = (ins);
  let assemblyFormat = "";
}

용도: 클로저 값 표현

%closure = funlang.make_closure @lambda_func(%x) : !funlang.closure

2. ListType (리스트, Phase 6 preview)

def FunLang_ListType : TypeDef<FunLang_Dialect, "List"> {
  let mnemonic = "list";
  let summary = "Immutable list of elements";

  // 파라미터: element type
  let parameters = (ins "Type":$elementType);

  // Assembly format: list<i32>
  let assemblyFormat = "`<` $elementType `>`";
}

파라미터화된 타입:

  • !funlang.list<i32>: 정수 리스트
  • !funlang.list<!funlang.closure>: 클로저 리스트

생성된 C++ 코드:

class ListType : public Type::TypeBase<...> {
public:
    static ListType get(Type elementType);
    Type getElementType() const;
};

사용 예:

// 빈 리스트
%nil = funlang.nil : !funlang.list<i32>

// Cons (head::tail)
%list = funlang.cons %head, %tail : (i32, !funlang.list<i32>) -> !funlang.list<i32>

3. RecordType (레코드, Phase 7 preview)

def FunLang_RecordType : TypeDef<FunLang_Dialect, "Record"> {
  let mnemonic = "record";
  let summary = "Record with named fields";

  // 파라미터: field names + types
  let parameters = (ins
    ArrayRefParameter<"StringAttr">:$fieldNames,
    ArrayRefParameter<"Type">:$fieldTypes
  );

  let assemblyFormat = "`<` `{` $fieldNames `:` $fieldTypes `}` `>`";
}

사용 예:

// {x: i32, y: i32}
%point : !funlang.record<{x: i32, y: i32}>

FunLang Operations 정의 예시

funlang.make_closure

TableGen 정의:

def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  let summary = "Creates a closure value";

  let arguments = (ins
    FlatSymbolRefAttr:$funcName,
    Variadic<AnyType>:$capturedValues
  );

  let results = (outs FunLang_ClosureType:$result);

  let assemblyFormat = "$funcName `(` $capturedValues `)` attr-dict `:` type($result)";

  let builders = [
    OpBuilder<(ins "FlatSymbolRefAttr":$funcName,
                   "ValueRange":$capturedValues), [{
      build($_builder, $_state, ClosureType::get($_builder.getContext()),
            funcName, capturedValues);
    }]>
  ];
}

생성된 C++ API:

// FunLangOps.h.inc
class MakeClosureOp : public Op<...> {
public:
    static MakeClosureOp create(OpBuilder &builder, Location loc,
                                FlatSymbolRefAttr funcName,
                                ValueRange capturedValues);

    FlatSymbolRefAttr getFuncName();
    OperandRange getCapturedValues();
    Value getResult();
};

funlang.apply

TableGen 정의:

def FunLang_ApplyOp : FunLang_Op<"apply"> {
  let summary = "Applies a closure to arguments";

  let arguments = (ins
    FunLang_ClosureType:$closure,
    Variadic<AnyType>:$arguments
  );

  let results = (outs AnyType:$result);

  let assemblyFormat = [{
    $closure `(` $arguments `)` attr-dict `:` functional-type($arguments, $result)
  }];

  let hasVerifier = 1;
}

사용 예:

%result = funlang.apply %closure(%x, %y) : (i32, i32) -> i32

Verifier (FunLangOps.cpp):

LogicalResult ApplyOp::verify() {
    if (!getClosure().getType().isa<ClosureType>())
        return emitError("first operand must be a closure");
    return success();
}

생성되는 C++ 코드 설명

mlir-tblgen 실행:

mlir-tblgen -gen-op-decls FunLangOps.td > FunLangOps.h.inc
mlir-tblgen -gen-op-defs FunLangOps.td > FunLangOps.cpp.inc
mlir-tblgen -gen-typedef-decls FunLangTypes.td > FunLangTypes.h.inc
mlir-tblgen -gen-typedef-defs FunLangTypes.td > FunLangTypes.cpp.inc

FunLangOps.h.inc (생성된 헤더):

class MakeClosureOp : public Op<MakeClosureOp, OpTrait::ZeroRegions,
                                OpTrait::OneResult, OpTrait::Pure> {
public:
    static constexpr StringLiteral getOperationName() {
        return StringLiteral("funlang.make_closure");
    }

    // Accessors
    FlatSymbolRefAttr getFuncName();
    OperandRange getCapturedValues();
    Value getResult();

    // Builder
    static void build(OpBuilder &builder, OperationState &state, ...);

    // Parser/Printer (assemblyFormat에서 생성)
    static ParseResult parse(OpAsmParser &parser, OperationState &result);
    void print(OpAsmPrinter &p);

    // Verifier (기본 타입 체크)
    LogicalResult verify();
};

사용 (C++ dialect code):

// Operation 생성
auto closureOp = builder.create<MakeClosureOp>(
    loc,
    funcNameAttr,
    capturedValues
);

// Accessors 사용
FlatSymbolRefAttr funcName = closureOp.getFuncName();
Value result = closureOp.getResult();

FunLangTypes.h.inc:

class ClosureType : public Type::TypeBase<ClosureType, Type, TypeStorage> {
public:
    static constexpr StringLiteral getMnemonic() { return "closure"; }

    static ClosureType get(MLIRContext *ctx) {
        return Base::get(ctx);
    }

    // Parser/Printer
    static ParseResult parse(AsmParser &parser);
    void print(AsmPrinter &printer) const;
};

사용:

// 타입 생성
ClosureType closureType = ClosureType::get(ctx);

// 타입 체크
if (auto ct = value.getType().dyn_cast<ClosureType>()) {
    // This is a closure!
}

C API Shim 패턴 (F# Interop)

문제: TableGen은 C++ 코드 생성, F#은 C API 필요

상황:

  1. TableGen → C++ 코드 생성

    • MakeClosureOp 클래스 (C++)
    • ClosureType::get() 메서드 (C++)
  2. F#은 C API만 호출 가능

    • P/Invoke는 extern "C" 함수만 지원
    • C++ 클래스 직접 호출 불가

문제:

// 이런 코드를 쓰고 싶지만...
let closure = MakeClosureOp.Create(builder, funcName, capturedValues)  // ERROR: C++ class!

해결책: extern “C” Wrapper Functions

아키텍처:

┌─────────────────────────────────────────┐
│ F# Code (Compiler.fs)                   │
│                                         │
│ let closure = FunLang.CreateClosure(...) │
└────────────────┬────────────────────────┘
                 │ P/Invoke
                 ▼
┌─────────────────────────────────────────┐
│ C API Shim (FunLangCAPI.h/.cpp)         │
│                                         │
│ extern "C" {                            │
│   MlirOperation mlirFunLangClosure...() │
│ }                                       │
└────────────────┬────────────────────────┘
                 │ Call C++ API
                 ▼
┌─────────────────────────────────────────┐
│ C++ Dialect (FunLangOps.h/.cpp)         │
│                                         │
│ class MakeClosureOp { ... }             │
│ (TableGen generated)                    │
└─────────────────────────────────────────┘

FunLangCAPI.h 구조

헤더 파일:

// FunLangCAPI.h - C API for FunLang Dialect
#ifndef FUNLANG_C_API_H
#define FUNLANG_C_API_H

#include "mlir-c/IR.h"

#ifdef __cplusplus
extern "C" {
#endif

//===----------------------------------------------------------------------===//
// Dialect Registration
//===----------------------------------------------------------------------===//

/// Register FunLang dialect in the given context
MLIR_CAPI_EXPORTED void mlirContextRegisterFunLangDialect(MlirContext ctx);

/// Load FunLang dialect into the given context
MLIR_CAPI_EXPORTED MlirDialect mlirContextLoadFunLangDialect(MlirContext ctx);

//===----------------------------------------------------------------------===//
// Types
//===----------------------------------------------------------------------===//

/// Returns true if the given type is a FunLang closure type
MLIR_CAPI_EXPORTED bool mlirTypeIsAFunLangClosure(MlirType type);

/// Creates a FunLang closure type
MLIR_CAPI_EXPORTED MlirType mlirFunLangClosureTypeGet(MlirContext ctx);

//===----------------------------------------------------------------------===//
// Operations
//===----------------------------------------------------------------------===//

/// Creates a funlang.make_closure operation
MLIR_CAPI_EXPORTED MlirOperation mlirFunLangMakeClosureOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirAttribute funcName,       // FlatSymbolRefAttr
    intptr_t numCaptured,
    MlirValue *capturedValues     // Array of values
);

/// Creates a funlang.apply operation
MLIR_CAPI_EXPORTED MlirOperation mlirFunLangApplyOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirValue closure,
    intptr_t numArgs,
    MlirValue *arguments,
    MlirType resultType
);

#ifdef __cplusplus
}
#endif

#endif // FUNLANG_C_API_H

핵심 패턴:

  1. extern "C": C linkage (name mangling 없음)
  2. MLIR C API 타입 사용: MlirContext, MlirOperation, MlirValue
  3. 배열 전달: intptr_t num + MlirValue *array 패턴

FunLangCAPI.cpp 구현 패턴

구현 파일:

// FunLangCAPI.cpp
#include "FunLangCAPI.h"
#include "mlir/CAPI/IR.h"
#include "mlir/CAPI/Support.h"
#include "FunLang/IR/FunLangDialect.h"
#include "FunLang/IR/FunLangOps.h"
#include "FunLang/IR/FunLangTypes.h"

using namespace mlir;
using namespace mlir::funlang;

//===----------------------------------------------------------------------===//
// Dialect Registration
//===----------------------------------------------------------------------===//

void mlirContextRegisterFunLangDialect(MlirContext ctx) {
    // unwrap: C handle → C++ pointer
    MLIRContext *context = unwrap(ctx);

    // Register dialect
    DialectRegistry registry;
    registry.insert<FunLangDialect>();
    context->appendDialectRegistry(registry);
}

MlirDialect mlirContextLoadFunLangDialect(MlirContext ctx) {
    MLIRContext *context = unwrap(ctx);
    Dialect *dialect = context->loadDialect<FunLangDialect>();

    // wrap: C++ pointer → C handle
    return wrap(dialect);
}

//===----------------------------------------------------------------------===//
// Types
//===----------------------------------------------------------------------===//

bool mlirTypeIsAFunLangClosure(MlirType type) {
    return unwrap(type).isa<ClosureType>();
}

MlirType mlirFunLangClosureTypeGet(MlirContext ctx) {
    MLIRContext *context = unwrap(ctx);
    Type closureType = ClosureType::get(context);
    return wrap(closureType);
}

//===----------------------------------------------------------------------===//
// Operations
//===----------------------------------------------------------------------===//

MlirOperation mlirFunLangMakeClosureOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirAttribute funcName,
    intptr_t numCaptured,
    MlirValue *capturedValues)
{
    // Unwrap C handles
    MLIRContext *context = unwrap(ctx);
    Location location = unwrap(loc);
    FlatSymbolRefAttr funcNameAttr = unwrap(funcName).cast<FlatSymbolRefAttr>();

    // Convert array to ValueRange
    SmallVector<Value, 4> captured;
    for (intptr_t i = 0; i < numCaptured; ++i) {
        captured.push_back(unwrap(capturedValues[i]));
    }

    // Create operation using OpBuilder
    OpBuilder builder(context);
    auto op = builder.create<MakeClosureOp>(location, funcNameAttr, captured);

    // Wrap and return
    return wrap(op.getOperation());
}

MlirOperation mlirFunLangApplyOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirValue closure,
    intptr_t numArgs,
    MlirValue *arguments,
    MlirType resultType)
{
    MLIRContext *context = unwrap(ctx);
    Location location = unwrap(loc);
    Value closureValue = unwrap(closure);
    Type resType = unwrap(resultType);

    SmallVector<Value, 4> args;
    for (intptr_t i = 0; i < numArgs; ++i) {
        args.push_back(unwrap(arguments[i]));
    }

    OpBuilder builder(context);
    auto op = builder.create<ApplyOp>(location, resType, closureValue, args);

    return wrap(op.getOperation());
}

wrap/unwrap 헬퍼 사용

MLIR C API convention:

  • unwrap(): C handle → C++ pointer
  • wrap(): C++ pointer → C handle
// C handle types (opaque)
typedef struct MlirContext { void *ptr; } MlirContext;
typedef struct MlirType { void *ptr; } MlirType;
typedef struct MlirValue { void *ptr; } MlirValue;

// Unwrap/Wrap (MLIR/CAPI/Support.h)
inline MLIRContext *unwrap(MlirContext ctx) {
    return static_cast<MLIRContext *>(ctx.ptr);
}

inline MlirContext wrap(MLIRContext *ctx) {
    return MlirContext{static_cast<void *>(ctx)};
}

사용 패턴:

// C API function signature (C handles)
MlirType mlirFunLangClosureTypeGet(MlirContext ctx);

// Implementation (unwrap → use C++ API → wrap)
MlirType mlirFunLangClosureTypeGet(MlirContext ctx) {
    MLIRContext *context = unwrap(ctx);           // C → C++
    Type closureType = ClosureType::get(context); // C++ API
    return wrap(closureType);                      // C++ → C
}

OpBuilder 활용

OpBuilder는 MLIR operation 생성 헬퍼다:

OpBuilder builder(context);

// Operation 생성
auto op = builder.create<MakeClosureOp>(
    location,       // Location (source info)
    funcNameAttr,   // Symbol reference
    capturedValues  // Operands
);

// Block에 삽입
builder.setInsertionPointToEnd(block);
auto op2 = builder.create<ApplyOp>(...);

C API shim에서:

MlirOperation mlirFunLangMakeClosureOpCreate(...) {
    OpBuilder builder(context);
    auto op = builder.create<MakeClosureOp>(...);
    return wrap(op.getOperation());  // Operation* → MlirOperation
}

타입 생성 및 검증

타입 생성:

MlirType mlirFunLangClosureTypeGet(MlirContext ctx) {
    MLIRContext *context = unwrap(ctx);
    Type closureType = ClosureType::get(context);
    return wrap(closureType);
}

타입 검증:

bool mlirTypeIsAFunLangClosure(MlirType type) {
    Type t = unwrap(type);
    return t.isa<ClosureType>();  // C++ RTTI
}

F#에서 사용:

// 타입 생성
let closureType = FunLang.GetClosureType(ctx)

// 타입 체크
if FunLang.IsClosureType(value.Type) then
    printfn "This is a closure!"

CMakeLists.txt 빌드 설정

FunLang dialect CMake:

# CMakeLists.txt
add_mlir_dialect_library(MLIRFunLangDialect
  # TableGen sources
  FunLangDialect.cpp
  FunLangOps.cpp
  FunLangTypes.cpp

  ADDITIONAL_HEADER_DIRS
  ${PROJECT_SOURCE_DIR}/include/FunLang

  DEPENDS
  MLIRFunLangOpsIncGen        # TableGen generated files
  MLIRFunLangTypesIncGen

  LINK_LIBS PUBLIC
  MLIRIR
  MLIRFuncDialect
  MLIRLLVMDialect
)

# C API shim
add_mlir_public_c_api_library(MLIRFunLangCAPI
  FunLangCAPI.cpp

  ADDITIONAL_HEADER_DIRS
  ${PROJECT_SOURCE_DIR}/include/FunLang-c

  LINK_LIBS PUBLIC
  MLIRCAPIIR
  MLIRFunLangDialect
)

빌드 출력:

  • libMLIRFunLangDialect.so: C++ dialect library
  • libMLIRFunLangCAPI.so: C API shim library

F#은 MLIRFunLangCAPI.so를 로드한다.

F# P/Invoke 바인딩 (Mlir.FunLang 모듈)

FunLangBindings.fs:

module Mlir.FunLang

open System
open System.Runtime.InteropServices

// P/Invoke declarations
[<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
extern void mlirContextRegisterFunLangDialect(MlirContext ctx)

[<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
extern MlirDialect mlirContextLoadFunLangDialect(MlirContext ctx)

[<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
extern bool mlirTypeIsAFunLangClosure(MlirType ty)

[<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType mlirFunLangClosureTypeGet(MlirContext ctx)

[<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
extern MlirOperation mlirFunLangMakeClosureOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirAttribute funcName,
    nativeint numCaptured,
    MlirValue[] capturedValues
)

[<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
extern MlirOperation mlirFunLangApplyOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirValue closure,
    nativeint numArgs,
    MlirValue[] arguments,
    MlirType resultType
)

// High-level F# API
type FunLangDialect =
    static member Register(ctx: MlirContext) =
        mlirContextRegisterFunLangDialect(ctx)

    static member Load(ctx: MlirContext) : MlirDialect =
        mlirContextLoadFunLangDialect(ctx)

type FunLangType =
    static member GetClosure(ctx: MlirContext) : MlirType =
        mlirFunLangClosureTypeGet(ctx)

    static member IsClosure(ty: MlirType) : bool =
        mlirTypeIsAFunLangClosure(ty)

type FunLangOps =
    static member CreateMakeClosure(ctx: MlirContext, loc: MlirLocation,
                                     funcName: MlirAttribute,
                                     capturedValues: MlirValue[]) : MlirOperation =
        mlirFunLangMakeClosureOpCreate(ctx, loc, funcName, nativeint capturedValues.Length, capturedValues)

    static member CreateApply(ctx: MlirContext, loc: MlirLocation,
                               closure: MlirValue, arguments: MlirValue[],
                               resultType: MlirType) : MlirOperation =
        mlirFunLangApplyOpCreate(ctx, loc, closure, nativeint arguments.Length, arguments, resultType)

사용 예 (Compiler.fs):

// Dialect 등록
let ctx = MlirContext.Create()
FunLangDialect.Register(ctx)
FunLangDialect.Load(ctx)

// 클로저 타입 얻기
let closureType = FunLangType.GetClosure(ctx)

// make_closure operation 생성
let funcNameAttr = ... // SymbolRefAttr
let capturedValues = [| %x; %y |]
let makeClosureOp = FunLangOps.CreateMakeClosure(ctx, loc, funcNameAttr, capturedValues)

// apply operation 생성
let closureValue = ... // %closure
let arguments = [| %arg1; %arg2 |]
let resultType = ... // i32
let applyOp = FunLangOps.CreateApply(ctx, loc, closureValue, arguments, resultType)

전체 아키텍처 다이어그램

┌──────────────────────────────────────────────────────────────────┐
│                        F# Compiler                               │
│                                                                  │
│  let closure = FunLangOps.CreateMakeClosure(...)                │
│  let result = FunLangOps.CreateApply(...)                       │
└─────────────────────────┬────────────────────────────────────────┘
                          │ P/Invoke
                          │ (CallingConvention.Cdecl)
                          ↓
┌──────────────────────────────────────────────────────────────────┐
│              C API Shim (FunLangCAPI.h/.cpp)                     │
│                                                                  │
│  extern "C" {                                                    │
│    MlirOperation mlirFunLangMakeClosureOpCreate(...) {          │
│      MLIRContext *ctx = unwrap(ctxHandle);                      │
│      OpBuilder builder(ctx);                                     │
│      auto op = builder.create<MakeClosureOp>(...);              │
│      return wrap(op.getOperation());                             │
│    }                                                             │
│  }                                                               │
└─────────────────────────┬────────────────────────────────────────┘
                          │ Call C++ API
                          ↓
┌──────────────────────────────────────────────────────────────────┐
│         C++ Dialect (FunLangOps.h/.cpp, TableGen generated)      │
│                                                                  │
│  class MakeClosureOp : public Op<...> {                         │
│    // Generated by TableGen                                      │
│    static void build(OpBuilder &, OperationState &, ...);       │
│    LogicalResult verify();                                       │
│  };                                                              │
└─────────────────────────┬────────────────────────────────────────┘
                          │ Uses MLIR Core API
                          ↓
┌──────────────────────────────────────────────────────────────────┐
│                      MLIR Core (C++)                             │
│                                                                  │
│  - Operation, Type, Attribute classes                            │
│  - OpBuilder, PatternRewriter                                    │
│  - Dialect, DialectRegistry                                      │
└──────────────────────────────────────────────────────────────────┘

데이터 흐름:

  1. F# → C API: P/Invoke로 C 함수 호출

    • MlirContext, MlirValue 등 opaque handle 전달
    • 배열은 nativeint len + array 패턴
  2. C API → C++: unwrap으로 handle → pointer 변환

    • unwrap(MlirContext)MLIRContext*
    • OpBuilder.create<Op>(...) 호출
  3. C++ → MLIR Core: TableGen 생성 코드 사용

    • MakeClosureOp::build() 호출
    • Operation 생성, 타입 체크
  4. C++ → C API: wrap으로 pointer → handle 변환

    • wrap(Operation*)MlirOperation
    • F#에 반환

FunLang Dialect Operations Preview

Phase 5-6에서 구현할 operations 목록:

1. funlang.make_closure

의미: 클로저 생성 (함수 포인터 + 캡처된 변수)

시그니처:

def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  let arguments = (ins FlatSymbolRefAttr:$funcName,
                       Variadic<AnyType>:$capturedValues);
  let results = (outs FunLang_ClosureType:$result);
}

사용 예:

%closure = funlang.make_closure @lambda_adder(%n, %m) : !funlang.closure

Lowering (Phase 5):

// FunLang dialect
%closure = funlang.make_closure @lambda_adder(%n, %m) : !funlang.closure

// ↓ Lower to Func + MemRef

// 환경 할당
%c3 = arith.constant 3 : index
%env = memref.alloc(%c3) : memref<?xi32>

// 함수 포인터 저장 (slot 0)
// ... (conceptual)

// 변수 저장 (slot 1, 2)
%c1 = arith.constant 1 : index
memref.store %n, %env[%c1] : memref<?xi32>
%c2 = arith.constant 2 : index
memref.store %m, %env[%c2] : memref<?xi32>

// 포인터 반환
%closure_ptr = memref.cast %env : memref<?xi32> to !llvm.ptr

2. funlang.apply

의미: 클로저 호출 (간접 함수 호출)

시그니처:

def FunLang_ApplyOp : FunLang_Op<"apply"> {
  let arguments = (ins FunLang_ClosureType:$closure,
                       Variadic<AnyType>:$arguments);
  let results = (outs AnyType:$result);
}

사용 예:

%result = funlang.apply %closure(%x, %y) : (i32, i32) -> i32

Lowering:

// FunLang dialect
%result = funlang.apply %closure(%x, %y) : (i32, i32) -> i32

// ↓ Lower to Func + LLVM

// 환경에서 함수 포인터 로드
%fn_slot = llvm.getelementptr %closure[0] : (!llvm.ptr) -> !llvm.ptr
%fn_ptr = llvm.load %fn_slot : !llvm.ptr -> !llvm.ptr

// 간접 호출 (환경 + 인자들)
%result = llvm.call %fn_ptr(%closure, %x, %y) : (!llvm.ptr, i32, i32) -> i32

3. funlang.match (Phase 6)

의미: 패턴 매칭 (리스트, ADT)

시그니처:

def FunLang_MatchOp : FunLang_Op<"match", [RecursiveSideEffect]> {
  let arguments = (ins AnyType:$scrutinee);
  let results = (outs AnyType:$result);
  let regions = (region VariadicRegion<AnyRegion>:$cases);
}

사용 예:

%result = funlang.match %list : !funlang.list<i32> -> i32 {
^nil_case:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32

^cons_case(%head: i32, %tail: !funlang.list<i32>):
    // ... (재귀 호출)
    funlang.yield %sum : i32
}

Lowering:

// FunLang dialect
%result = funlang.match %list { ... }

// ↓ Lower to SCF (structured control flow)

// 리스트 태그 확인
%tag = llvm.load %list[0] : !llvm.ptr -> i32

// if (tag == NIL)
%is_nil = arith.cmpi eq, %tag, %c0 : i32
%result = scf.if %is_nil -> i32 {
    // Nil case
    %zero = arith.constant 0 : i32
    scf.yield %zero : i32
} else {
    // Cons case - head/tail 추출
    %head = llvm.load %list[1] : !llvm.ptr -> i32
    %tail = llvm.load %list[2] : !llvm.ptr -> !llvm.ptr
    // ... (body)
    scf.yield %sum : i32
}

4. funlang.nil / funlang.cons (Phase 6)

리스트 생성:

def FunLang_NilOp : FunLang_Op<"nil", [Pure]> {
  let arguments = (ins);
  let results = (outs FunLang_ListType:$result);
}

def FunLang_ConsOp : FunLang_Op<"cons", [Pure]> {
  let arguments = (ins AnyType:$head, FunLang_ListType:$tail);
  let results = (outs FunLang_ListType:$result);
}

사용 예:

%nil = funlang.nil : !funlang.list<i32>
%list1 = funlang.cons %c1, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>
%list2 = funlang.cons %c2, %list1 : (i32, !funlang.list<i32>) -> !funlang.list<i32>
// list2 = [2, 1]

Chapter 15에서 구현할 내용

Phase 5 (Chapter 15-16):

  1. TableGen 정의

    • FunLangDialect.td
    • FunLangOps.td (make_closure, apply)
    • FunLangTypes.td (closure)
  2. C API Shim

    • FunLangCAPI.h
    • FunLangCAPI.cpp
  3. F# Bindings

    • FunLangBindings.fs
  4. Lowering Pass

    • FunLangToFunc.cpp (make_closure → memref.alloc)
    • Pattern: MakeClosureOpLowering, ApplyOpLowering
  5. 컴파일러 통합

    • Compiler.fs 수정: FunLang dialect operations 생성
    • Pass pipeline: FunLangToFunc → FuncToLLVM

Phase 6 (Chapter 17-18):

  • funlang.match, funlang.nil, funlang.cons
  • ListType 구현
  • Pattern matching lowering

Common Pitfalls (흔한 실수들)

Pitfall 1: 불완전한 타입 시스템 (AnyType 남용)

문제:

// 잘못된 설계 - 모든 것이 AnyType
def FunLang_MakeClosureOp : FunLang_Op<"make_closure"> {
  let arguments = (ins AnyType:$func, Variadic<AnyType>:$captured);
  let results = (outs AnyType:$result);  // ERROR: 타입 안전성 없음!
}

왜 문제인가?

  • AnyType은 컴파일 타임 체크 불가
  • 정수를 클로저로 사용 가능 (버그!)
  • 최적화 pass가 타입 정보 활용 불가

해결:

// 올바른 설계 - 명확한 타입
def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  let arguments = (ins FlatSymbolRefAttr:$funcName,  // 함수 심볼
                       Variadic<AnyType>:$captured);  // 캡처된 값 (다양한 타입)
  let results = (outs FunLang_ClosureType:$result);  // GOOD: 명확한 타입!
}

원칙:

  • 도메인 타입 (closure, list)은 커스텀 타입 사용
  • 범용 값 (캡처된 변수)은 AnyType 허용

Pitfall 2: Missing Operation Traits (Pure, MemoryEffects)

문제:

// Trait 없는 operation
def FunLang_MakeClosureOp : FunLang_Op<"make_closure"> {
  // No traits specified!
}

왜 문제인가?

  • MLIR이 side effect 가정 (보수적 최적화)
  • CSE (Common Subexpression Elimination) 불가
  • Dead code elimination 불가

예시:

// 중복 클로저 생성
%closure1 = funlang.make_closure @lambda(%x) : !funlang.closure
%closure2 = funlang.make_closure @lambda(%x) : !funlang.closure
// Trait 없으면: 둘 다 유지 (side effect 가능성 가정)
// Pure trait 있으면: %closure2 = %closure1 (CSE 적용)

해결:

// 올바른 설계 - Trait 명시
def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  // Pure = no side effects, deterministic
}

def FunLang_AllocEnvOp : FunLang_Op<"alloc_env", [MemoryEffects<[MemAlloc]>]> {
  // MemAlloc = allocates memory (but no read/write side effects)
}

자주 사용하는 traits:

Trait의미예시
Pure부작용 없음arith.addi, funlang.make_closure
MemoryEffects<[MemRead]>메모리 읽기만memref.load
MemoryEffects<[MemWrite]>메모리 쓰기만memref.store
MemoryEffects<[MemAlloc]>메모리 할당만memref.alloc

Pitfall 3: Symbol Table 미사용 (String 함수 참조)

문제:

// 잘못된 설계 - 함수 이름을 문자열로
def FunLang_MakeClosureOp : FunLang_Op<"make_closure"> {
  let arguments = (ins StrAttr:$funcName);  // ERROR: 타입 체크 불가!
}

왜 문제인가?

  • 함수 존재 여부 체크 불가 (컴파일 타임)
  • 함수 시그니처 검증 불가
  • Linker가 심볼 해석 불가

예시:

// 문자열 사용 - 에러 발견 안 됨!
%closure = funlang.make_closure "typo_func"  // 함수 없어도 pass!

해결:

// 올바른 설계 - SymbolRefAttr 사용
def FunLang_MakeClosureOp : FunLang_Op<"make_closure", [Pure]> {
  let arguments = (ins FlatSymbolRefAttr:$funcName);  // GOOD: 심볼 참조
}

사용:

// 심볼 참조 - 컴파일 타임 체크!
%closure = funlang.make_closure @lambda_func  // 함수 없으면 에러!

// 함수 정의 필요
func.func private @lambda_func(%env: !llvm.ptr, %x: i32) -> i32 {
  // ...
}

SymbolRefAttr의 이점:

  • 컴파일 타임 심볼 해석
  • 함수 시그니처 체크 가능
  • IDE 지원 (jump to definition)

Pitfall 4: C API 메모리 관리 혼동

문제:

// 잘못된 C API - 포인터 반환
extern "C" {
    MlirValue* mlirFunLangGetCapturedValues(MlirOperation op) {
        auto makeClosureOp = cast<MakeClosureOp>(unwrap(op));
        auto captured = makeClosureOp.getCapturedValues();

        // ERROR: SmallVector 로컬 변수!
        SmallVector<MlirValue, 4> result;
        for (Value v : captured) {
            result.push_back(wrap(v));
        }

        // DANGER: 댕글링 포인터! (result는 스택)
        return result.data();
    }
}

왜 문제인가?

  • C API는 ownership 명확히 해야 함
  • 스택 메모리 반환 → use-after-free
  • F#은 언제 메모리 해제할지 모름

해결 1: 호출자가 버퍼 제공

extern "C" {
    intptr_t mlirFunLangGetCapturedValuesInto(MlirOperation op,
                                               MlirValue *buffer,
                                               intptr_t bufferSize) {
        auto makeClosureOp = cast<MakeClosureOp>(unwrap(op));
        auto captured = makeClosureOp.getCapturedValues();

        intptr_t numCaptured = captured.size();
        if (numCaptured > bufferSize)
            return -1;  // Buffer too small

        for (intptr_t i = 0; i < numCaptured; ++i) {
            buffer[i] = wrap(captured[i]);
        }

        return numCaptured;
    }
}

F#에서:

let buffer = Array.zeroCreate<MlirValue> 10
let count = mlirFunLangGetCapturedValuesInto(op, buffer, 10n)
let capturedValues = buffer.[0..int count - 1]

해결 2: Iterator 패턴

extern "C" {
    void mlirFunLangMakeClosureForEachCaptured(MlirOperation op,
                                                 void (*callback)(MlirValue, void*),
                                                 void *userData) {
        auto makeClosureOp = cast<MakeClosureOp>(unwrap(op));
        for (Value v : makeClosureOp.getCapturedValues()) {
            callback(wrap(v), userData);
        }
    }
}

원칙:

  • C API는 ownership 명확히 (caller owns? callee owns?)
  • 배열 반환: caller-provided buffer 또는 callback
  • 문서화: “caller must free” vs “MLIR owns”

요약

Chapter 14에서 배운 것:

  1. Progressive Lowering의 필요성: Phase 4 직접 lowering의 문제 (복잡도, 최적화 상실, 디버깅 어려움)

  2. MLIR Dialect 아키텍처: Operation (계산), Type (값), Attribute (상수), Region/Block (제어 흐름), Symbol Table (전역 참조)

  3. TableGen ODS 기초:

    • Dialect 정의 (FunLang_Dialect)
    • Operation 정의 (arguments, results, traits, assemblyFormat)
    • Type 정의 (ClosureType, ListType)
    • 생성된 C++ 코드 (parser, printer, builder, verifier)
  4. C API Shim 패턴:

    • 문제: TableGen → C++, F# → C API
    • 해결: extern "C" wrapper (FunLangCAPI.h/.cpp)
    • wrap/unwrap helpers (C ↔ C++ 변환)
    • OpBuilder 활용 (operation 생성)
    • F# P/Invoke bindings
  5. FunLang Operations 설계:

    • funlang.make_closure: 클로저 생성
    • funlang.apply: 클로저 호출
    • funlang.match: 패턴 매칭 (Phase 6)
    • Lowering 전략 (FunLang → Func/MemRef → LLVM)
  6. Common Pitfalls:

    • AnyType 남용 → 커스텀 타입 사용
    • Trait 누락 → Pure, MemoryEffects 명시
    • 문자열 함수 참조 → SymbolRefAttr 사용
    • C API 메모리 관리 → ownership 명확히

다음 장 (Chapter 15) Preview:

Chapter 15에서는:

  • FunLang dialect 실제 구현 (C++ 코드 작성)
  • TableGen 파일 작성 (FunLangOps.td, FunLangTypes.td)
  • C API shim 구현 (FunLangCAPI.cpp)
  • F# bindings 작성 (FunLangBindings.fs)
  • Lowering pass 구현 (FunLangToFunc.cpp)
  • 컴파일러 통합 (Compiler.fs 수정)
  • 전체 빌드 시스템 (CMakeLists.txt)

이론적 기초를 확립했으므로, 실제 구현으로 넘어갈 준비가 됐다.

Chapter 15: 커스텀 Operations (Custom Operations)

소개

Chapter 14에서는 커스텀 MLIR dialect의 이론을 다뤘다:

  • Progressive lowering 철학
  • TableGen ODS 문법
  • C API shim 패턴
  • FunLang dialect 설계 방향

Chapter 15에서는 실제 구현을 진행한다. FunLang dialect의 핵심 operations를 정의하고 F#에서 사용할 수 있게 만든다.

Chapter 15의 목표

  1. funlang.closure Operation: Chapter 12의 12줄 클로저 생성 코드를 1줄로 압축
  2. funlang.apply Operation: Chapter 13의 8줄 간접 호출 코드를 1줄로 압축
  3. funlang.match Operation (Preview): Phase 6 패턴 매칭을 위한 준비
  4. FunLang Custom Types: !funlang.closure, !funlang.list 타입 정의
  5. Complete F# Integration: C API shim부터 F# wrapper까지 전체 스택 구축

Before vs After: 코드 압축의 위력

Before (Phase 4 - Chapter 12):

// 클로저 생성: 12줄
func.func @make_adder(%n: i32) -> !llvm.ptr {
    %env_size = arith.constant 16 : i64
    %env_ptr = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
    %fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %fn_slot = llvm.getelementptr %env_ptr[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr
    %n_slot = llvm.getelementptr %env_ptr[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr
    func.return %env_ptr : !llvm.ptr
}

// 클로저 호출: 8줄
func.func @apply(%f: !llvm.ptr, %x: i32) -> i32 {
    %c0 = arith.constant 0 : i64
    %fn_ptr_addr = llvm.getelementptr %f[0, %c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr(%f, %x) : (!llvm.ptr, i32) -> i32
    func.return %result : i32
}

After (Phase 5 - Chapter 15):

// 클로저 생성: 1줄!
func.func @make_adder(%n: i32) -> !funlang.closure {
    %closure = funlang.closure @lambda_adder, %n : !funlang.closure
    func.return %closure : !funlang.closure
}

// 클로저 호출: 1줄!
func.func @apply(%f: !funlang.closure, %x: i32) -> i32 {
    %result = funlang.apply %f(%x) : (i32) -> i32
    func.return %result : i32
}

개선 효과:

  • 코드 줄 수: 20줄 → 4줄 (80% 감소!)
  • 가독성: GEP/store 패턴 제거, 의도 명확
  • 타입 안전성: !llvm.ptr!funlang.closure (타입 시스템 활용)
  • 최적화 가능성: 클로저 인라이닝, escape analysis 등

Chapter 14 복습

커스텀 dialect를 만드는 3가지 핵심 요소:

1. TableGen ODS (Operation Definition Specification)

  • 선언적으로 operation 정의 (파서/프린터/빌더 자동 생성)
  • .td 파일로 작성

2. C++ Dialect 구현

  • TableGen이 생성한 클래스를 활용
  • Verifier, lowering pass 구현

3. C API Shim

  • extern "C" wrapper로 F# P/Invoke 연결
  • wrap/unwrap 헬퍼로 C handle ↔ C++ pointer 변환

이 장에서는 이 세 요소를 모두 구현한다.

구현할 Operations

OperationPurposePhase
funlang.closure클로저 생성 (GC_malloc + store 추상화)5
funlang.apply클로저 호출 (GEP + load + llvm.call 추상화)5
funlang.match패턴 매칭 (region-based control flow)6 preview

구현할 Types

TypePurposePhase
!funlang.closure클로저 값 (opaque type)5
!funlang.list<T>불변 리스트 (parameterized type)6 preview

Chapter 15 성공 기준

이 장을 완료하면:

  • funlang.closure operation을 TableGen으로 정의할 수 있다
  • C API shim 함수를 작성해 F#에서 호출할 수 있다
  • F# P/Invoke 바인딩을 작성할 수 있다
  • Chapter 12-13의 compileExpr 코드를 리팩토링할 수 있다
  • Phase 4 대비 코드 줄 수가 60% 이상 감소한다
  • Region-based operation (funlang.match)의 구조를 이해한다

Preview: Chapter 16에서는 FunLang dialect을 LLVM dialect으로 lowering하는 pass를 구현한다.


Part 1: funlang.closure Operation

Phase 4 패턴 분석: 무엇을 추상화하는가?

Chapter 12에서 클로저를 생성할 때, 12줄의 LLVM dialect 코드가 필요했다:

func.func @make_adder(%n: i32) -> !llvm.ptr {
    // Step 1: 환경 크기 계산
    // 함수 포인터 (8 bytes) + 캡처된 변수 (4 bytes * count)
    %env_size = arith.constant 16 : i64

    // Step 2: GC_malloc 호출로 환경 할당
    %env_ptr = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // Step 3: 함수 포인터 저장 (env[0])
    %fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %fn_slot = llvm.getelementptr %env_ptr[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr

    // Step 4: 캡처된 변수 n 저장 (env[1])
    %n_slot = llvm.getelementptr %env_ptr[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr

    // Step 5: 환경 포인터 반환 (클로저 값)
    func.return %env_ptr : !llvm.ptr
}

패턴 분석:

  1. 환경 크기 계산: 8 (fn ptr) + 4 * n (captured vars)

    • 컴파일 타임에 결정 가능
    • 하지만 컴파일러 코드에서 수동 계산 필요
  2. GC_malloc 호출: 힙 할당

    • 모든 클로저에 공통
    • 크기만 다름
  3. 함수 포인터 저장: env[0] 슬롯에 @lambda_N 주소

    • 모든 클로저에 공통
    • 슬롯 인덱스는 항상 0
  4. 변수 저장: env[1..n] 슬롯에 캡처된 변수들

    • 변수 개수만 다름
    • GEP + store 패턴 반복
  5. 타입: !llvm.ptr (opaque)

    • 타입 안전성 없음
    • 클로저인지 일반 포인터인지 구별 불가

문제점:

  • 반복 코드: 모든 람다마다 동일한 패턴 12줄
  • 인덱스 오류 가능성: env[0] vs env[1] 수동 관리
  • 타입 안전성 부족: 모든 포인터가 !llvm.ptr
  • 최적화 어려움: 클로저인지 알 수 없음
  • 가독성 저하: 저수준 메모리 조작 노출

해결책: funlang.closure Operation

이 패턴을 단일 operation으로 추상화한다:

// Before: 12 lines
%env_size = arith.constant 16 : i64
%env_ptr = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
%fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
%fn_slot = llvm.getelementptr %env_ptr[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr
%n_slot = llvm.getelementptr %env_ptr[1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %n, %n_slot : i32, !llvm.ptr

// After: 1 line!
%closure = funlang.closure @lambda_adder, %n : !funlang.closure

이득:

  • 간결성: 12줄 → 1줄
  • 타입 안전성: !funlang.closure (dedicated type)
  • 의도 명확: “클로저를 만든다“라는 의미가 즉시 보임
  • 컴파일러 단순화: GEP 인덱스 계산 불필요
  • 최적화 가능: 클로저 특화 pass 작성 가능 (escape analysis, inlining)

TableGen 정의: FunLang_ClosureOp

FunLangOps.td 파일에 다음과 같이 정의한다:

//===- FunLangOps.td - FunLang dialect operations ---------*- tablegen -*-===//
//
// FunLang Dialect Operations
//
//===----------------------------------------------------------------------===//

#ifndef FUNLANG_OPS
#define FUNLANG_OPS

include "mlir/IR/OpBase.td"
include "mlir/Interfaces/SideEffectInterfaces.td"
include "mlir/Interfaces/CallInterfaces.td"
include "FunLangDialect.td"
include "FunLangTypes.td"

//===----------------------------------------------------------------------===//
// ClosureOp
//===----------------------------------------------------------------------===//

def FunLang_ClosureOp : FunLang_Op<"closure", [Pure]> {
  let summary = "Create a closure with captured environment";

  let description = [{
    Creates a closure by combining a function reference with captured values.

    Syntax:
    ```
    %closure = funlang.closure @func_name, %arg1, %arg2, ... : !funlang.closure
    ```

    This operation abstracts the low-level closure creation pattern:
    - Allocate environment (GC_malloc)
    - Store function pointer (env[0])
    - Store captured values (env[1..n])

    Example:
    ```
    // Create closure: fun x -> x + n
    %closure = funlang.closure @lambda_adder, %n : !funlang.closure
    ```

    Lowering to LLVM dialect:
    - Compute environment size: 8 (fn ptr) + sizeof(captured values)
    - Call GC_malloc
    - Store function pointer at slot 0
    - Store captured values at slots 1..n
    - Return environment pointer
  }];

  let arguments = (ins
    FlatSymbolRefAttr:$callee,
    Variadic<AnyType>:$capturedValues
  );

  let results = (outs FunLang_ClosureType:$result);

  let assemblyFormat = [{
    $callee (`,` $capturedValues^)? attr-dict `:` type($result)
  }];

  let builders = [
    OpBuilder<(ins "mlir::FlatSymbolRefAttr":$callee,
                   "mlir::ValueRange":$capturedValues), [{
      build($_builder, $_state,
            FunLangClosureType::get($_builder.getContext()),
            callee, capturedValues);
    }]>
  ];
}

#endif // FUNLANG_OPS

TableGen 상세 설명

1. Operation 이름과 Traits

def FunLang_ClosureOp : FunLang_Op<"closure", [Pure]> {

구성 요소:

  • FunLang_ClosureOp: C++ 클래스 이름 (ClosureOp 생성)
  • "closure": MLIR assembly에서의 operation 이름 (funlang.closure)
  • [Pure]: Operation traits 리스트

Pure Trait:

Pure trait는 operation이 side-effect free임을 선언한다:

// Pure operation의 의미:
// 1. 같은 입력 → 항상 같은 출력
// 2. 메모리 읽기/쓰기 없음 (pure function)
// 3. 외부 상태에 영향 없음

왜 funlang.closure가 Pure인가?

“GC_malloc을 호출하는데 Pure라고?“라는 의문이 들 수 있다. 여기서 Pure는 FunLang dialect 수준에서의 의미다:

  • FunLang 수준: 클로저 생성은 pure (같은 인자 → 같은 클로저 값)
  • Lowering 후: GC_malloc 호출 (side effect 있음)

Progressive lowering의 핵심: 각 dialect 수준에서 독립적인 의미론을 가진다.

Pure trait의 이점:

// CSE (Common Subexpression Elimination) 가능
%c1 = funlang.closure @lambda_add, %n : !funlang.closure
%c2 = funlang.closure @lambda_add, %n : !funlang.closure
// CSE pass가 %c2를 %c1로 대체 가능 (Pure이므로)

2. Summary와 Description

let summary = "Create a closure with captured environment";
  • summary: 한 줄 설명 (IDE tooltip, 문서 생성에 사용)
  • description: 상세 설명 (Markdown 포맷 지원)

Description에 포함할 내용:

  1. Syntax: 사용 방법
  2. Semantics: 의미론 (무엇을 하는가)
  3. Example: 구체적 예시
  4. Lowering: LLVM dialect으로의 변환 방법

3. Arguments (입력)

let arguments = (ins
  FlatSymbolRefAttr:$callee,
  Variadic<AnyType>:$capturedValues
);

FlatSymbolRefAttr:$callee

  • 타입: Symbol reference (함수 이름)
  • 이름: callee (호출할 함수)
  • FlatSymbolRefAttr: 같은 모듈 내 심볼 참조
// FlatSymbolRefAttr 예시
funlang.closure @lambda_adder, %n  // @lambda_adder가 FlatSymbolRefAttr

왜 StrAttr이 아니라 FlatSymbolRefAttr인가?

StrAttrFlatSymbolRefAttr
단순 문자열심볼 테이블 참조
검증 없음컴파일 타임 검증 (심볼 존재 여부)
최적화 불가최적화 가능 (인라이닝, DCE)
타입 정보 없음타입 정보 있음 (함수 시그니처)
// 잘못된 정의
let arguments = (ins StrAttr:$callee, ...);
// 문제: "@lambda_adder"가 존재하는지 검증 불가

// 올바른 정의
let arguments = (ins FlatSymbolRefAttr:$callee, ...);
// MLIR이 심볼 테이블에서 @lambda_adder 검증

Variadic:$capturedValues

  • Variadic: 가변 길이 인자 (0개 이상)
  • AnyType: 어떤 타입이든 허용
  • 이름: capturedValues
// 캡처 변수 0개
%closure0 = funlang.closure @const_fn : !funlang.closure

// 캡처 변수 1개
%closure1 = funlang.closure @add_n, %n : !funlang.closure

// 캡처 변수 3개
%closure3 = funlang.closure @lambda_xyz, %x, %y, %z : !funlang.closure

AnyType의 Trade-off:

장점:

  • 유연성: i32, f64, !llvm.ptr 등 모든 타입 허용
  • 간단한 정의

단점:

  • 타입 안전성 감소
  • Verifier에서 추가 검증 필요

Alternative (더 엄격한 타입):

// 특정 타입만 허용
let arguments = (ins
  FlatSymbolRefAttr:$callee,
  Variadic<AnyTypeOf<[I32, F64, LLVM_AnyPointer]>>:$capturedValues
);

Phase 5에서는 단순성을 위해 AnyType을 사용한다.

4. Results (출력)

let results = (outs FunLang_ClosureType:$result);
  • outs: 출력 값들
  • FunLang_ClosureType: 커스텀 타입 (FunLangTypes.td에 정의)
  • $result: 결과 값 이름

단일 결과 operation이므로 outs 안에 하나만 선언한다.

FunLang_ClosureType은 어디서 정의되는가?

FunLangTypes.td 파일에 다음과 같이 정의한다:

//===- FunLangTypes.td - FunLang dialect types ------------*- tablegen -*-===//

#ifndef FUNLANG_TYPES
#define FUNLANG_TYPES

include "mlir/IR/AttrTypeBase.td"
include "FunLangDialect.td"

//===----------------------------------------------------------------------===//
// FunLang Type Definitions
//===----------------------------------------------------------------------===//

class FunLang_Type<string name, string typeMnemonic>
    : TypeDef<FunLang_Dialect, name> {
  let mnemonic = typeMnemonic;
}

def FunLang_ClosureType : FunLang_Type<"Closure", "closure"> {
  let summary = "FunLang closure type";
  let description = [{
    Represents a closure value (function + captured environment).

    Syntax: `!funlang.closure`

    Opaque type (no type parameters).
    Lowering: !funlang.closure -> !llvm.ptr
  }];
}

#endif // FUNLANG_TYPES

5. Assembly Format (Parser/Printer)

let assemblyFormat = [{
  $callee (`,` $capturedValues^)? attr-dict `:` type($result)
}];

구문 분석:

  • $callee: 심볼 참조 (필수)
  • (, $capturedValues^)?: 캡처 변수들 (선택, 쉼표로 구분)
    • ^: anchor (variadic의 첫 요소에만 , 붙음)
    • ?: 선택 (캡처 변수 없으면 생략)
  • attr-dict: 추가 속성들 (location 등)
  • :: 타입 구분자
  • type($result): 결과 타입 (:!funlang.closure)

생성되는 Assembly:

// 캡처 변수 없음
%c0 = funlang.closure @const_fn : !funlang.closure

// 캡처 변수 1개
%c1 = funlang.closure @add_n, %n : !funlang.closure

// 캡처 변수 3개
%c3 = funlang.closure @lambda_xyz, %x, %y, %z : !funlang.closure

TableGen이 자동 생성:

  • Parser: assembly → C++ operation
  • Printer: C++ operation → assembly

수동 구현과 비교:

// 수동 구현 (100+ lines)
class ClosureOp : public Op<...> {
  static ParseResult parse(OpAsmParser &parser, OperationState &result);
  void print(OpAsmPrinter &p);
};

// TableGen 자동 생성 (1 line in .td)
let assemblyFormat = [{...}];

6. Builders (생성자)

let builders = [
  OpBuilder<(ins "mlir::FlatSymbolRefAttr":$callee,
                 "mlir::ValueRange":$capturedValues), [{
    build($_builder, $_state,
          FunLangClosureType::get($_builder.getContext()),
          callee, capturedValues);
  }]>
];

Builder의 역할:

C++ 코드에서 operation을 생성할 때 사용하는 헬퍼 함수:

// C++ 코드에서 사용
auto calleeAttr = mlir::FlatSymbolRefAttr::get(context, "lambda_adder");
SmallVector<mlir::Value> captured = {nValue};
auto closure = builder.create<FunLang::ClosureOp>(loc, calleeAttr, captured);

Builder 파라미터:

  • $_builder: OpBuilder 인스턴스
  • $_state: OperationState (operation 생성 중간 상태)
  • callee: 함수 심볼
  • capturedValues: 캡처된 변수들

자동 타입 추론:

Builder 내부에서 결과 타입을 자동으로 설정한다:

FunLangClosureType::get($_builder.getContext())
// 항상 !funlang.closure 타입

생성되는 C++ 클래스

TableGen은 FunLangOps.td를 읽고 다음 C++ 코드를 생성한다:

Generated: FunLangOps.h.inc

namespace mlir {
namespace funlang {

class ClosureOp : public Op<ClosureOp,
                             OpTrait::ZeroRegions,
                             OpTrait::OneResult,
                             OpTrait::Pure> {
public:
  using Op::Op;

  static StringRef getOperationName() {
    return "funlang.closure";
  }

  // Accessors
  FlatSymbolRefAttr getCalleeAttr() { return /*...*/ ; }
  StringRef getCallee() { return getCalleeAttr().getValue(); }

  OperandRange getCapturedValues() { return /*...*/ ; }

  FunLangClosureType getType() { return /*...*/ ; }

  // Builder
  static void build(OpBuilder &builder, OperationState &state,
                    FlatSymbolRefAttr callee,
                    ValueRange capturedValues);

  // Parser/Printer
  static ParseResult parse(OpAsmParser &parser, OperationState &result);
  void print(OpAsmPrinter &p);

  // Verifier (default)
  LogicalResult verify();
};

} // namespace funlang
} // namespace mlir

자동 생성되는 기능:

  1. Accessors: getCallee(), getCapturedValues() (argument 접근)
  2. Builder: create<ClosureOp>(...) (operation 생성)
  3. Parser: assembly → operation (assemblyFormat 기반)
  4. Printer: operation → assembly (assemblyFormat 기반)
  5. Verifier: 기본 검증 (타입 일치, operand 개수)

C API Shim 구현

F#에서 ClosureOp를 생성하려면 C API shim이 필요하다.

FunLangCAPI.h:

//===- FunLangCAPI.h - C API for FunLang dialect --------------------------===//

#ifndef FUNLANG_CAPI_H
#define FUNLANG_CAPI_H

#include "mlir-c/IR.h"

#ifdef __cplusplus
extern "C" {
#endif

//===----------------------------------------------------------------------===//
// FunLang Types
//===----------------------------------------------------------------------===//

/// Create a FunLang closure type.
MLIR_CAPI_EXPORTED MlirType mlirFunLangClosureTypeGet(MlirContext ctx);

/// Check if a type is a FunLang closure type.
MLIR_CAPI_EXPORTED bool mlirTypeIsAFunLangClosureType(MlirType type);

//===----------------------------------------------------------------------===//
// FunLang Operations
//===----------------------------------------------------------------------===//

/// Create a funlang.closure operation.
///
/// Arguments:
///   ctx: MLIR context
///   loc: Source location
///   callee: Symbol reference to the function (FlatSymbolRefAttr)
///   numCaptured: Number of captured values
///   capturedValues: Array of captured SSA values
///
/// Returns: The created operation (as MlirOperation)
MLIR_CAPI_EXPORTED MlirOperation mlirFunLangClosureOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirAttribute callee,
    intptr_t numCaptured,
    MlirValue *capturedValues);

/// Get the callee attribute from a funlang.closure operation.
MLIR_CAPI_EXPORTED MlirAttribute mlirFunLangClosureOpGetCallee(MlirOperation op);

/// Get the number of captured values from a funlang.closure operation.
MLIR_CAPI_EXPORTED intptr_t mlirFunLangClosureOpGetNumCapturedValues(MlirOperation op);

/// Get a captured value by index from a funlang.closure operation.
MLIR_CAPI_EXPORTED MlirValue mlirFunLangClosureOpGetCapturedValue(
    MlirOperation op, intptr_t index);

#ifdef __cplusplus
}
#endif

#endif // FUNLANG_CAPI_H

FunLangCAPI.cpp:

//===- FunLangCAPI.cpp - C API for FunLang dialect ------------------------===//

#include "FunLangCAPI.h"
#include "FunLang/FunLangDialect.h"
#include "FunLang/FunLangOps.h"
#include "FunLang/FunLangTypes.h"
#include "mlir/CAPI/IR.h"
#include "mlir/CAPI/Support.h"

using namespace mlir;
using namespace mlir::funlang;

//===----------------------------------------------------------------------===//
// Type API
//===----------------------------------------------------------------------===//

MlirType mlirFunLangClosureTypeGet(MlirContext ctx) {
  return wrap(FunLangClosureType::get(unwrap(ctx)));
}

bool mlirTypeIsAFunLangClosureType(MlirType type) {
  return unwrap(type).isa<FunLangClosureType>();
}

//===----------------------------------------------------------------------===//
// Operation API
//===----------------------------------------------------------------------===//

MlirOperation mlirFunLangClosureOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirAttribute callee,
    intptr_t numCaptured,
    MlirValue *capturedValues) {

  MLIRContext *context = unwrap(ctx);
  Location location = unwrap(loc);

  // Verify callee is a FlatSymbolRefAttr
  auto calleeAttr = unwrap(callee).dyn_cast<FlatSymbolRefAttr>();
  assert(calleeAttr && "callee must be a FlatSymbolRefAttr");

  // Build captured values range
  SmallVector<Value, 4> captured;
  for (intptr_t i = 0; i < numCaptured; ++i) {
    captured.push_back(unwrap(capturedValues[i]));
  }

  // Create operation using OpBuilder
  OpBuilder builder(context);
  auto op = builder.create<ClosureOp>(location, calleeAttr, captured);

  return wrap(op.getOperation());
}

MlirAttribute mlirFunLangClosureOpGetCallee(MlirOperation op) {
  auto closureOp = llvm::cast<ClosureOp>(unwrap(op));
  return wrap(closureOp.getCalleeAttr());
}

intptr_t mlirFunLangClosureOpGetNumCapturedValues(MlirOperation op) {
  auto closureOp = llvm::cast<ClosureOp>(unwrap(op));
  return closureOp.getCapturedValues().size();
}

MlirValue mlirFunLangClosureOpGetCapturedValue(MlirOperation op, intptr_t index) {
  auto closureOp = llvm::cast<ClosureOp>(unwrap(op));
  return wrap(closureOp.getCapturedValues()[index]);
}

wrap/unwrap Pattern:

MLIR C API의 핵심 패턴:

DirectionFunctionPurpose
C → C++unwrap(MlirX)C handle을 C++ pointer로 변환
C++ → Cwrap(X*)C++ pointer를 C handle로 변환
// unwrap: C handle -> C++ pointer
MLIRContext *context = unwrap(ctx);          // MlirContext -> MLIRContext*
Location location = unwrap(loc);             // MlirLocation -> Location
Value value = unwrap(capturedValues[i]);     // MlirValue -> Value

// wrap: C++ pointer -> C handle
MlirOperation result = wrap(op.getOperation());  // Operation* -> MlirOperation
MlirType resultType = wrap(closure_type);         // Type -> MlirType

F# P/Invoke 바인딩

FunLangBindings.fs:

namespace Mlir.FunLang

open System.Runtime.InteropServices
open Mlir.Core

/// FunLang dialect P/Invoke bindings
module FunLangBindings =

    //==========================================================================
    // Types
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangClosureTypeGet(MlirContext ctx)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern bool mlirTypeIsAFunLangClosureType(MlirType ty)

    //==========================================================================
    // Operations
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirFunLangClosureOpCreate(
        MlirContext ctx,
        MlirLocation loc,
        MlirAttribute callee,
        nativeint numCaptured,
        MlirValue[] capturedValues)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirFunLangClosureOpGetCallee(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirFunLangClosureOpGetNumCapturedValues(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirFunLangClosureOpGetCapturedValue(
        MlirOperation op,
        nativeint index)

/// High-level F# wrappers for FunLang operations
type FunLangOps =

    /// Create !funlang.closure type
    static member ClosureType(context: MlirContext) : MlirType =
        FunLangBindings.mlirFunLangClosureTypeGet(context)

    /// Check if type is !funlang.closure
    static member IsClosureType(ty: MlirType) : bool =
        FunLangBindings.mlirTypeIsAFunLangClosureType(ty)

    /// Create funlang.closure operation
    static member CreateClosure(
        context: MlirContext,
        location: MlirLocation,
        callee: string,
        capturedValues: MlirValue list) : MlirOperation =

        // Convert function name to FlatSymbolRefAttr
        use calleeStrRef = MlirStringRef.FromString(callee)
        let calleeAttr =
            mlirFlatSymbolRefAttrGet(context, calleeStrRef)

        // Convert F# list to array
        let capturedArray = List.toArray capturedValues
        let numCaptured = nativeint capturedArray.Length

        // Call C API
        FunLangBindings.mlirFunLangClosureOpCreate(
            context, location, calleeAttr, numCaptured, capturedArray)

    /// Get callee name from funlang.closure operation
    static member GetClosureCallee(op: MlirOperation) : string =
        let attr = FunLangBindings.mlirFunLangClosureOpGetCallee(op)
        let strRef = mlirFlatSymbolRefAttrGetValue(attr)
        MlirStringRef.ToString(strRef)

    /// Get captured values from funlang.closure operation
    static member GetClosureCapturedValues(op: MlirOperation) : MlirValue list =
        let count = FunLangBindings.mlirFunLangClosureOpGetNumCapturedValues(op)
        [ for i in 0n .. (count - 1n) do
            yield FunLangBindings.mlirFunLangClosureOpGetCapturedValue(op, i) ]

F# Wrapper 설계 패턴:

  1. Low-level bindings: FunLangBindings 모듈에 extern 선언
  2. High-level wrappers: FunLangOps 타입에 static member
  3. 타입 변환: F# list ↔ C array, string ↔ MlirStringRef
  4. Resource 관리: use 키워드로 자동 해제

사용 예시: F#에서 funlang.closure 생성

Before (Phase 4): Low-level LLVM Operations

// Phase 4: 12줄의 LLVM dialect 코드
let compileLambda (builder: OpBuilder) (param: string) (body: Expr) (freeVars: (string * MlirValue) list) =
    let context = builder.Context
    let loc = builder.Location

    // 1. 환경 크기 계산
    let fnPtrSize = 8L
    let varSize = 4L
    let totalSize = fnPtrSize + (int64 freeVars.Length) * varSize
    let sizeConst = builder.CreateI64Const(totalSize)

    // 2. GC_malloc 호출
    let envPtr = builder.CreateCall("GC_malloc", [sizeConst])

    // 3. 함수 포인터 저장
    let lambdaName = freshLambdaName()
    let fnAddr = builder.CreateAddressOf(lambdaName)
    let fnSlot = builder.CreateGEP(envPtr, 0)
    builder.CreateStore(fnAddr, fnSlot)

    // 4. 캡처된 변수들 저장
    freeVars |> List.iteri (fun i (name, value) ->
        let slot = builder.CreateGEP(envPtr, i + 1)
        builder.CreateStore(value, slot)
    )

    // 5. 환경 포인터 반환
    envPtr

After (Phase 5): FunLang Dialect

// Phase 5: 1줄!
let compileLambda (builder: OpBuilder) (param: string) (body: Expr) (freeVars: (string * MlirValue) list) =
    let context = builder.Context
    let loc = builder.Location

    // 1. 람다 함수 생성 (lifted function)
    let lambdaName = freshLambdaName()
    createLiftedFunction builder lambdaName param body freeVars

    // 2. 캡처된 변수 값들 추출
    let capturedValues = freeVars |> List.map snd

    // 3. funlang.closure 생성 (1 line!)
    let closureOp = FunLangOps.CreateClosure(context, loc, lambdaName, capturedValues)
    let closureValue = mlirOperationGetResult(closureOp, 0)
    closureValue

코드 비교:

AspectPhase 4Phase 5Improvement
줄 수~20 lines~10 lines50% 감소
GEP 패턴수동 (인덱스 관리)없음오류 가능성 제거
타입!llvm.ptr!funlang.closure타입 안전성 향상
가독성저수준 메모리 조작고수준 의미 표현명확성 향상

Phase 4 vs Phase 5 코드 비교: 완전한 예시

테스트 프로그램:

// FunLang source
let make_adder n =
    fun x -> x + n

let add5 = make_adder 5
let result = add5 10
// result = 15

Phase 4 Generated MLIR (LLVM Dialect):

module {
  // GC_malloc 선언
  llvm.func @GC_malloc(i64) -> !llvm.ptr

  // lambda_adder lifted function
  func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // n 로드 (env[1])
    %c1 = arith.constant 1 : i64
    %n_slot = llvm.getelementptr %env[%c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32

    // x + n
    %result = arith.addi %x, %n : i32
    func.return %result : i32
  }

  // make_adder 함수
  func.func @make_adder(%n: i32) -> !llvm.ptr {
    // 환경 크기: 8 (fn ptr) + 4 (n) = 12 bytes
    %c12 = arith.constant 12 : i64
    %env = llvm.call @GC_malloc(%c12) : (i64) -> !llvm.ptr

    // 함수 포인터 저장 (env[0])
    %fn_addr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %c0 = arith.constant 0 : i64
    %fn_slot = llvm.getelementptr %env[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr

    // n 저장 (env[1])
    %c1 = arith.constant 1 : i64
    %n_slot = llvm.getelementptr %env[%c1] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr

    func.return %env : !llvm.ptr
  }

  // main 함수
  func.func @main() -> i32 {
    // add5 = make_adder 5
    %c5 = arith.constant 5 : i32
    %add5 = func.call @make_adder(%c5) : (i32) -> !llvm.ptr

    // result = add5 10 (간접 호출)
    %c10 = arith.constant 10 : i32
    %c0 = arith.constant 0 : i64
    %fn_ptr_addr = llvm.getelementptr %add5[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr(%add5, %c10) : (!llvm.ptr, i32) -> i32

    func.return %result : i32
  }
}

Phase 5 Generated MLIR (FunLang Dialect):

module {
  // lambda_adder lifted function (동일)
  func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    %c1 = arith.constant 1 : i64
    %n_slot = llvm.getelementptr %env[%c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    %result = arith.addi %x, %n : i32
    func.return %result : i32
  }

  // make_adder 함수 (funlang.closure 사용!)
  func.func @make_adder(%n: i32) -> !funlang.closure {
    // 클로저 생성: 1줄!
    %closure = funlang.closure @lambda_adder, %n : !funlang.closure
    func.return %closure : !funlang.closure
  }

  // main 함수 (funlang.apply는 다음 섹션에서)
  func.func @main() -> i32 {
    %c5 = arith.constant 5 : i32
    %add5 = func.call @make_adder(%c5) : (i32) -> !funlang.closure

    // 간접 호출 (Chapter 15 Part 2에서 funlang.apply로 대체)
    %c10 = arith.constant 10 : i32
    // ... (임시로 Phase 4 패턴 유지)

    func.return %result : i32
  }
}

줄 수 비교 (make_adder 함수만):

  • Phase 4: 12 lines (GC_malloc + store 패턴)
  • Phase 5: 2 lines (funlang.closure)
  • Reduction: 83%

Part 2: funlang.apply Operation

Phase 4 간접 호출 패턴 분석

Chapter 13에서 클로저를 호출할 때, 8줄의 LLVM dialect 코드가 필요했다:

func.func @apply(%f: !llvm.ptr, %x: i32) -> i32 {
    // Step 1: 함수 포인터 추출 (env[0])
    %c0 = arith.constant 0 : i64
    %fn_ptr_addr = llvm.getelementptr %f[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr

    // Step 2: 간접 호출 (환경 + 인자)
    %result = llvm.call %fn_ptr(%f, %x) : (!llvm.ptr, i32) -> i32

    // Step 3: 결과 반환
    func.return %result : i32
}

패턴 분석:

  1. 상수 0 생성: 함수 포인터 슬롯 인덱스
  2. GEP: 환경 포인터의 0번 슬롯 주소 계산
  3. Load: 함수 포인터 로드
  4. 간접 호출: llvm.call %fn_ptr(...)
    • 첫 인자: 환경 포인터 (클로저 자체)
    • 나머지 인자: 실제 함수 인자들
  5. 타입 시그니처: 수동 지정 필요

문제점:

  • 반복 코드: 모든 클로저 호출마다 동일한 패턴
  • 인덱스 하드코딩: %c0 (함수 포인터는 항상 슬롯 0)
  • 타입 안전성 부족: 간접 호출 시그니처 수동 관리
  • 환경 전달 실수: llvm.call %fn_ptr(%x) (환경 누락 버그)

해결책: funlang.apply Operation

// Before: 8 lines
%c0 = arith.constant 0 : i64
%fn_ptr_addr = llvm.getelementptr %f[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
%fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
%result = llvm.call %fn_ptr(%f, %x) : (!llvm.ptr, i32) -> i32

// After: 1 line!
%result = funlang.apply %f(%x) : (i32) -> i32

TableGen 정의: FunLang_ApplyOp

FunLangOps.td에 추가:

//===----------------------------------------------------------------------===//
// ApplyOp
//===----------------------------------------------------------------------===//

def FunLang_ApplyOp : FunLang_Op<"apply", []> {
  let summary = "Apply a closure to arguments";

  let description = [{
    Applies a closure (function + environment) to arguments via indirect call.

    Syntax:
    ```
    %result = funlang.apply %closure(%arg1, %arg2, ...) : (T1, T2, ...) -> Tresult
    ```

    This operation abstracts the indirect call pattern:
    - Load function pointer from closure (env[0])
    - Call function pointer with environment + args

    Example:
    ```
    // Call closure: %f(10)
    %result = funlang.apply %f(%c10) : (i32) -> i32
    ```

    Lowering to LLVM dialect:
    - %fn_ptr_addr = llvm.getelementptr %closure[0]
    - %fn_ptr = llvm.load %fn_ptr_addr
    - %result = llvm.call %fn_ptr(%closure, %args...)
  }];

  let arguments = (ins
    FunLang_ClosureType:$closure,
    Variadic<AnyType>:$args
  );

  let results = (outs AnyType:$result);

  let assemblyFormat = [{
    $closure `(` $args `)` attr-dict `:` functional-type($args, $result)
  }];

  let builders = [
    OpBuilder<(ins "mlir::Value":$closure,
                   "mlir::ValueRange":$args,
                   "mlir::Type":$resultType), [{
      build($_builder, $_state, resultType, closure, args);
    }]>
  ];
}

TableGen 상세 설명

1. Operation 이름과 Traits

def FunLang_ApplyOp : FunLang_Op<"apply", []> {

Traits가 비어있는 이유:

funlang.applyside-effect가 있다 (간접 호출):

  • 호출되는 함수가 무엇을 할지 모름 (메모리 쓰기, I/O 등)
  • Pure trait 불가
  • 최적화 제한 (CSE 불가, DCE 불가)

Alternative: MemoryEffects Trait

Phase 6 이후에는 더 정밀한 trait를 추가할 수 있다:

def FunLang_ApplyOp : FunLang_Op<"apply", [
    DeclareOpInterfaceMethods<MemoryEffectsOpInterface>
]> {
  // ...
}

이를 통해 “메모리 읽기만 한다” 등의 정보를 제공할 수 있다.

2. Arguments

let arguments = (ins
  FunLang_ClosureType:$closure,
  Variadic<AnyType>:$args
);

FunLang_ClosureType:$closure

  • 타입: !funlang.closure (커스텀 타입)
  • 이름: closure
  • 필수: 단일 값 (variadic 아님)

ClosureOp와의 차이:

ClosureOpApplyOp
FlatSymbolRefAttr:$calleeFunLang_ClosureType:$closure
심볼 (함수 이름)SSA 값 (클로저)
컴파일 타임 해석런타임 값
// ClosureOp: callee는 심볼
%c = funlang.closure @lambda_add, %n : !funlang.closure

// ApplyOp: closure는 SSA 값
%result = funlang.apply %c(%x) : (i32) -> i32

Variadic:$args

  • 가변 길이 인자: 0개 이상
  • AnyType: 타입 제약 없음
// 인자 0개
%result0 = funlang.apply %const_fn() : () -> i32

// 인자 1개
%result1 = funlang.apply %add_n(%x) : (i32) -> i32

// 인자 2개
%result2 = funlang.apply %mul(%x, %y) : (i32, i32) -> i32

3. Results

let results = (outs AnyType:$result);

AnyType을 사용하는 이유:

클로저가 반환하는 타입은 런타임에 결정된다:

// 클로저 A: i32 반환
%r1 = funlang.apply %closure_a(%x) : (i32) -> i32

// 클로저 B: f64 반환
%r2 = funlang.apply %closure_b(%y) : (f64) -> f64

// 클로저 C: 클로저 반환 (HOF)
%r3 = funlang.apply %closure_c(%z) : (i32) -> !funlang.closure

타입 추론:

Verifier에서 다음을 검증해야 한다:

  • 호출 시그니처 ((T1, ...) -> Tresult)
  • 클로저의 실제 타입과 일치하는지

Phase 5에서는 단순화를 위해 AnyType을 사용하고, 기본 검증만 수행한다.

4. Assembly Format

let assemblyFormat = [{
  $closure `(` $args `)` attr-dict `:` functional-type($args, $result)
}];

구문 분석:

  • $closure: 클로저 값 (필수)
  • ( ): 괄호 (인자 구분)
  • $args: 인자들 (쉼표로 자동 구분, 0개 가능)
  • :: 타입 구분자
  • functional-type($args, $result): 함수 타입 (T1, ...) -> Tresult

functional-type이란?

함수 시그니처 표기법:

// functional-type 예시
(i32) -> i32              // 1 arg, 1 result
(i32, i32) -> i32         // 2 args, 1 result
() -> i32                 // 0 args, 1 result
(i32) -> !funlang.closure // HOF (클로저 반환)

생성되는 Assembly:

// 다양한 호출 예시
%r1 = funlang.apply %f() : () -> i32
%r2 = funlang.apply %f(%x) : (i32) -> i32
%r3 = funlang.apply %f(%x, %y) : (i32, i32) -> i32
%r4 = funlang.apply %compose(%f, %g) : (!funlang.closure, !funlang.closure) -> !funlang.closure

5. Builders

let builders = [
  OpBuilder<(ins "mlir::Value":$closure,
                 "mlir::ValueRange":$args,
                 "mlir::Type":$resultType), [{
    build($_builder, $_state, resultType, closure, args);
  }]>
];

Builder 파라미터:

  • closure: 클로저 SSA 값
  • args: 인자들 (가변 길이)
  • resultType: 결과 타입 (명시적 지정 필요)

C++ 사용 예시:

// C++ code
Value closureVal = /*...*/;
SmallVector<Value> args = {xValue};
Type resultType = builder.getI32Type();

auto applyOp = builder.create<FunLang::ApplyOp>(
    loc, closureVal, args, resultType);
Value result = applyOp.getResult();

C API Shim 구현

FunLangCAPI.h에 추가:

//===----------------------------------------------------------------------===//
// ApplyOp
//===----------------------------------------------------------------------===//

/// Create a funlang.apply operation.
///
/// Arguments:
///   ctx: MLIR context
///   loc: Source location
///   closure: Closure value to apply
///   numArgs: Number of arguments
///   args: Array of argument SSA values
///   resultType: Type of the result
///
/// Returns: The created operation (as MlirOperation)
MLIR_CAPI_EXPORTED MlirOperation mlirFunLangApplyOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirValue closure,
    intptr_t numArgs,
    MlirValue *args,
    MlirType resultType);

/// Get the closure value from a funlang.apply operation.
MLIR_CAPI_EXPORTED MlirValue mlirFunLangApplyOpGetClosure(MlirOperation op);

/// Get the number of arguments from a funlang.apply operation.
MLIR_CAPI_EXPORTED intptr_t mlirFunLangApplyOpGetNumArgs(MlirOperation op);

/// Get an argument by index from a funlang.apply operation.
MLIR_CAPI_EXPORTED MlirValue mlirFunLangApplyOpGetArg(
    MlirOperation op, intptr_t index);

/// Get the result type from a funlang.apply operation.
MLIR_CAPI_EXPORTED MlirType mlirFunLangApplyOpGetResultType(MlirOperation op);

FunLangCAPI.cpp에 추가:

MlirOperation mlirFunLangApplyOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirValue closure,
    intptr_t numArgs,
    MlirValue *args,
    MlirType resultType) {

  MLIRContext *context = unwrap(ctx);
  Location location = unwrap(loc);
  Value closureVal = unwrap(closure);
  Type resType = unwrap(resultType);

  // Build args range
  SmallVector<Value, 4> argVals;
  for (intptr_t i = 0; i < numArgs; ++i) {
    argVals.push_back(unwrap(args[i]));
  }

  // Create operation
  OpBuilder builder(context);
  auto op = builder.create<ApplyOp>(location, closureVal, argVals, resType);

  return wrap(op.getOperation());
}

MlirValue mlirFunLangApplyOpGetClosure(MlirOperation op) {
  auto applyOp = llvm::cast<ApplyOp>(unwrap(op));
  return wrap(applyOp.getClosure());
}

intptr_t mlirFunLangApplyOpGetNumArgs(MlirOperation op) {
  auto applyOp = llvm::cast<ApplyOp>(unwrap(op));
  return applyOp.getArgs().size();
}

MlirValue mlirFunLangApplyOpGetArg(MlirOperation op, intptr_t index) {
  auto applyOp = llvm::cast<ApplyOp>(unwrap(op));
  return wrap(applyOp.getArgs()[index]);
}

MlirType mlirFunLangApplyOpGetResultType(MlirOperation op) {
  auto applyOp = llvm::cast<ApplyOp>(unwrap(op));
  return wrap(applyOp.getResult().getType());
}

F# P/Invoke 바인딩

FunLangBindings.fs에 추가:

module FunLangBindings =
    // (이전 ClosureOp 바인딩...)

    //==========================================================================
    // ApplyOp
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirFunLangApplyOpCreate(
        MlirContext ctx,
        MlirLocation loc,
        MlirValue closure,
        nativeint numArgs,
        MlirValue[] args,
        MlirType resultType)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirFunLangApplyOpGetClosure(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirFunLangApplyOpGetNumArgs(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirFunLangApplyOpGetArg(MlirOperation op, nativeint index)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangApplyOpGetResultType(MlirOperation op)

type FunLangOps =
    // (이전 ClosureType, CreateClosure...)

    /// Create funlang.apply operation
    static member CreateApply(
        context: MlirContext,
        location: MlirLocation,
        closure: MlirValue,
        args: MlirValue list,
        resultType: MlirType) : MlirValue =

        // Convert F# list to array
        let argsArray = List.toArray args
        let numArgs = nativeint argsArray.Length

        // Call C API
        let op = FunLangBindings.mlirFunLangApplyOpCreate(
            context, location, closure, numArgs, argsArray, resultType)

        // Extract result SSA value
        mlirOperationGetResult(op, 0)

    /// Get closure from funlang.apply operation
    static member GetApplyClosure(op: MlirOperation) : MlirValue =
        FunLangBindings.mlirFunLangApplyOpGetClosure(op)

    /// Get arguments from funlang.apply operation
    static member GetApplyArgs(op: MlirOperation) : MlirValue list =
        let count = FunLangBindings.mlirFunLangApplyOpGetNumArgs(op)
        [ for i in 0n .. (count - 1n) do
            yield FunLangBindings.mlirFunLangApplyOpGetArg(op, i) ]

Closure + Apply 조합 예시

완전한 makeAdder 예시:

module {
  // Lifted function
  func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // (환경에서 n 로드 - Phase 5에서도 여전히 저수준)
    %c1 = arith.constant 1 : i64
    %n_slot = llvm.getelementptr %env[%c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32

    // x + n 계산
    %result = arith.addi %x, %n : i32
    func.return %result : i32
  }

  // make_adder: funlang.closure 사용
  func.func @make_adder(%n: i32) -> !funlang.closure {
    %closure = funlang.closure @lambda_adder, %n : !funlang.closure
    func.return %closure : !funlang.closure
  }

  // apply: funlang.apply 사용
  func.func @apply(%f: !funlang.closure, %x: i32) -> i32 {
    %result = funlang.apply %f(%x) : (i32) -> i32
    func.return %result : i32
  }

  // main: 전체 조합
  func.func @main() -> i32 {
    // add5 = make_adder 5
    %c5 = arith.constant 5 : i32
    %add5 = func.call @make_adder(%c5) : (i32) -> !funlang.closure

    // result = apply add5 10
    %c10 = arith.constant 10 : i32
    %result = func.call @apply(%add5, %c10) : (!funlang.closure, i32) -> i32

    func.return %result : i32
  }
}

Phase 4 vs Phase 5 비교 (main 함수):

OperationPhase 4Phase 5
클로저 생성func.call @make_adder!llvm.ptrfunc.call @make_adder!funlang.closure
클로저 호출GEP + load + llvm.call (8 lines)funlang.apply (1 line)
타입!llvm.ptr (opaque)!funlang.closure (typed)

apply 함수 비교:

// Phase 4: 8 lines
func.func @apply(%f: !llvm.ptr, %x: i32) -> i32 {
    %c0 = arith.constant 0 : i64
    %fn_ptr_addr = llvm.getelementptr %f[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr(%f, %x) : (!llvm.ptr, i32) -> i32
    func.return %result : i32
}

// Phase 5: 2 lines
func.func @apply(%f: !funlang.closure, %x: i32) -> i32 {
    %result = funlang.apply %f(%x) : (i32) -> i32
    func.return %result : i32
}

Reduction: 75% (8 lines → 2 lines)


Part 3: funlang.match Operation (Phase 6 Preview)

패턴 매칭 개념

Pattern matching은 함수형 언어의 핵심 기능이다:

// FunLang pattern matching (Phase 6)
let rec sum_list lst =
    match lst with
    | [] -> 0
    | head :: tail -> head + sum_list tail

두 가지 구성 요소:

  1. Scrutinee: 패턴을 검사할 값 (lst)
  2. Cases: 각 패턴과 해당 동작
    • []: nil case (빈 리스트)
    • head :: tail: cons case (head와 tail로 분해)

Why Region-Based Operation?

나쁜 설계: Block-based (scf.if 스타일)

// 가상의 잘못된 설계
%result = funlang.match %list
    then ^nil_block
    else ^cons_block

^nil_block:
    %zero = arith.constant 0 : i32
    br ^merge_block(%zero : i32)

^cons_block:
    // ... head/tail 분해 ...
    br ^merge_block(%sum : i32)

^merge_block(%result: i32):
    func.return %result : i32

문제점:

  • 블록들이 함수 레벨: 다른 operation과 섞임
  • 결과 타입 검증 어려움: 각 블록이 독립적
  • 가독성 저하: 패턴과 동작이 분리됨

좋은 설계: Region-based

%result = funlang.match %list : !funlang.list<i32> -> i32 {
  ^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32
  ^cons(%head: i32, %tail: !funlang.list<i32>):
    %sum_tail = /* recursive call */
    %sum = arith.addi %head, %sum_tail : i32
    funlang.yield %sum : i32
}

장점:

  • 각 case가 별도 region: operation 내부에 encapsulated
  • Block arguments: 패턴 변수를 직접 표현 (head, tail)
  • Unified terminator: 모든 case가 funlang.yield로 종료
  • 타입 검증 간단: 모든 yield가 같은 타입 반환해야 함

TableGen 정의: FunLang_MatchOp

//===----------------------------------------------------------------------===//
// MatchOp
//===----------------------------------------------------------------------===//

def FunLang_MatchOp : FunLang_Op<"match", [
    RecursiveSideEffects,
    SingleBlockImplicitTerminator<"YieldOp">
]> {
  let summary = "Pattern matching expression";

  let description = [{
    Pattern matches on a value (scrutinee) with multiple cases.
    Each case is a separate region with optional block arguments.

    Syntax:
    ```
    %result = funlang.match %scrutinee : Tin -> Tout {
      ^case1:
        funlang.yield %val1 : Tout
      ^case2(%arg: T):
        funlang.yield %val2 : Tout
    }
    ```

    Example (list pattern matching):
    ```
    %sum = funlang.match %list : !funlang.list<i32> -> i32 {
      ^nil:
        %zero = arith.constant 0 : i32
        funlang.yield %zero : i32
      ^cons(%head: i32, %tail: !funlang.list<i32>):
        // ... compute sum ...
        funlang.yield %sum : i32
    }
    ```

    Constraints:
    - Each region must have exactly one block
    - Each region must end with funlang.yield
    - All yields must have the same result type

    Lowering (Phase 6):
    - Scrutinee tag check
    - Branch to corresponding case
    - Extract pattern variables (block arguments)
    - Execute case body
  }];

  let arguments = (ins AnyType:$scrutinee);
  let results = (outs AnyType:$result);
  let regions = (region VariadicRegion<SizedRegion<1>>:$cases);

  let hasVerifier = 1;
  let hasCustomAssemblyFormat = 1;
}

Region-Based Operation 설명

1. Traits

def FunLang_MatchOp : FunLang_Op<"match", [
    RecursiveSideEffects,
    SingleBlockImplicitTerminator<"YieldOp">
]> {

RecursiveSideEffects:

  • Match operation의 side effect는 각 case에 의존한다
  • Case body가 Pure면 match도 Pure
  • Case body가 side effect 있으면 match도 side effect 있음
// Pure match
%result = funlang.match %x : i32 -> i32 {
  ^case1:
    %c1 = arith.constant 1 : i32
    funlang.yield %c1 : i32  // Pure
  ^case2:
    %c2 = arith.constant 2 : i32
    funlang.yield %c2 : i32  // Pure
}
// 전체 match가 Pure

// Side effect match
%result = funlang.match %x : i32 -> i32 {
  ^case1:
    func.call @print(%c1) : (i32) -> ()  // Side effect!
    funlang.yield %c1 : i32
  ^case2:
    funlang.yield %c2 : i32
}
// 전체 match가 side effect 있음

SingleBlockImplicitTerminator<“YieldOp”>:

  • 각 region이 정확히 하나의 block을 가짐
  • 각 block이 YieldOp로 종료됨 (implicit terminator)
  • Parser가 자동으로 검증
// 올바른 match
%r = funlang.match %x : i32 -> i32 {
  ^case1:
    %val = arith.constant 42 : i32
    funlang.yield %val : i32  // OK: YieldOp terminator
}

// 잘못된 match
%r = funlang.match %x : i32 -> i32 {
  ^case1:
    %val = arith.constant 42 : i32
    func.return %val : i32  // ERROR: Wrong terminator
}

2. Regions

let regions = (region VariadicRegion<SizedRegion<1>>:$cases);

VariadicRegion:

  • 가변 개수의 region (case 개수에 따라)
  • 최소 1개 이상

SizedRegion<1>:

  • 각 region이 정확히 1개의 block을 가짐
  • 다중 block 불가 (control flow는 block 내에서만)
// 2개 case
%r = funlang.match %x : i32 -> i32 {
  ^case1: funlang.yield %c1 : i32
  ^case2: funlang.yield %c2 : i32
}

// 3개 case
%r = funlang.match %x : i32 -> i32 {
  ^case1: funlang.yield %c1 : i32
  ^case2: funlang.yield %c2 : i32
  ^case3: funlang.yield %c3 : i32
}

Region vs Block:

ConceptDefinitionExample
RegionOperation의 내부 범위scf.if의 then/else
BlockRegion 내의 명령 시퀀스기본 블록 (CFG 노드)
// scf.if: 2 regions, 각 region은 1+ blocks
scf.if %cond {
  // Then region
  %val = arith.constant 1 : i32
  scf.yield %val : i32
} else {
  // Else region
  %val = arith.constant 2 : i32
  scf.yield %val : i32
}

// funlang.match: N regions, 각 region은 정확히 1 block
funlang.match %x : i32 -> i32 {
  // Case 1 region (1 block)
  ^case1:
    funlang.yield %c1 : i32
  // Case 2 region (1 block)
  ^case2:
    funlang.yield %c2 : i32
}

3. 각 Case가 별도 Region인 이유

이유 1: 독립적인 스코프

각 case는 독립적인 변수 바인딩을 가진다:

%result = funlang.match %list : !funlang.list<i32> -> i32 {
  ^nil:
    // 이 region에는 변수 없음
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32

  ^cons(%head: i32, %tail: !funlang.list<i32>):
    // 이 region에는 head, tail 변수 있음
    // %head, %tail은 block arguments
    funlang.yield %head : i32
}

이유 2: 타입 안전성

모든 case의 yield 타입을 검증할 수 있다:

// 올바른 match (모든 yield가 i32)
%r = funlang.match %x : i32 -> i32 {
  ^case1: funlang.yield %c1 : i32  // OK
  ^case2: funlang.yield %c2 : i32  // OK
}

// 잘못된 match (타입 불일치)
%r = funlang.match %x : i32 -> i32 {
  ^case1: funlang.yield %c1 : i32       // OK
  ^case2: funlang.yield %f : f64        // ERROR: Type mismatch
}

이유 3: Lowering 간소화

각 region을 독립적인 블록으로 lowering:

// Before lowering
%r = funlang.match %list : !funlang.list<i32> -> i32 {
  ^nil: funlang.yield %zero : i32
  ^cons(%h, %t): funlang.yield %h : i32
}

// After lowering (pseudo-code)
%tag = funlang.list_tag %list : i32  // 0 = nil, 1 = cons
cf.switch %tag [
  case 0: ^nil_block
  case 1: ^cons_block
]

^nil_block:
  %zero = arith.constant 0 : i32
  cf.br ^merge(%zero : i32)

^cons_block:
  %h = funlang.list_head %list : i32
  %t = funlang.list_tail %list : !funlang.list<i32>
  cf.br ^merge(%h : i32)

^merge(%result: i32):
  // ...

4. Verifier 필요성

let hasVerifier = 1;

TableGen 기본 검증만으로는 부족하다. 추가 검증 필요:

검증 사항:

  1. 모든 yield 타입 일치: 각 case의 yield 타입 == match 결과 타입
  2. Case 개수 검증: 최소 1개 이상
  3. Block arguments 타입 검증: Pattern 변수 타입이 valid한지
  4. Terminator 검증: 모든 block이 YieldOp로 종료

C++ Verifier 구현 (Phase 6):

LogicalResult MatchOp::verify() {
  auto resultType = getResult().getType();

  // Check all cases
  for (auto &region : getCases()) {
    Block &block = region.front();

    // Check terminator
    auto yieldOp = dyn_cast<YieldOp>(block.getTerminator());
    if (!yieldOp)
      return emitOpError("case must end with funlang.yield");

    // Check yield type
    auto yieldType = yieldOp.getValue().getType();
    if (yieldType != resultType)
      return emitOpError("yield type mismatch: expected ")
             << resultType << ", got " << yieldType;
  }

  return success();
}

C API Shim 구현 패턴 (Region 생성 포함)

Region-based operation의 C API는 복잡하다. Phase 6에서 완전 구현하지만, 패턴을 미리 소개한다.

FunLangCAPI.h (Preview):

//===----------------------------------------------------------------------===//
// MatchOp (Phase 6 Preview)
//===----------------------------------------------------------------------===//

/// Create a funlang.match operation.
///
/// Arguments:
///   ctx: MLIR context
///   loc: Source location
///   scrutinee: Value to pattern match on
///   numCases: Number of cases
///   resultType: Type of the result
///
/// Returns: The created operation (caller must build case regions)
MLIR_CAPI_EXPORTED MlirOperation mlirFunLangMatchOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirValue scrutinee,
    intptr_t numCases,
    MlirType resultType);

/// Get a case region by index from a funlang.match operation.
MLIR_CAPI_EXPORTED MlirRegion mlirFunLangMatchOpGetCaseRegion(
    MlirOperation op, intptr_t index);

/// Create a block in a region with block arguments.
MLIR_CAPI_EXPORTED MlirBlock mlirRegionAppendBlockWithArgs(
    MlirRegion region,
    intptr_t numArgs,
    MlirType *argTypes);

/// Create a funlang.yield operation.
MLIR_CAPI_EXPORTED MlirOperation mlirFunLangYieldOpCreate(
    MlirContext ctx,
    MlirLocation loc,
    MlirValue value);

사용 패턴 (F# pseudo-code):

// 1. MatchOp 생성 (빈 regions)
let matchOp = FunLangBindings.mlirFunLangMatchOpCreate(
    context, loc, scrutinee, 2, resultType)

// 2. 각 case region 가져오기
let nilRegion = FunLangBindings.mlirFunLangMatchOpGetCaseRegion(matchOp, 0)
let consRegion = FunLangBindings.mlirFunLangMatchOpGetCaseRegion(matchOp, 1)

// 3. Nil case 구축
let nilBlock = FunLangBindings.mlirRegionAppendBlockWithArgs(
    nilRegion, 0, [||])  // No block arguments
builder.SetInsertionPointToEnd(nilBlock)
let zero = builder.CreateI32Const(0)
FunLangBindings.mlirFunLangYieldOpCreate(context, loc, zero)

// 4. Cons case 구축
let consBlock = FunLangBindings.mlirRegionAppendBlockWithArgs(
    consRegion, 2, [| i32Type; listType |])  // head, tail
builder.SetInsertionPointToEnd(consBlock)
let head = mlirBlockGetArgument(consBlock, 0)
let tail = mlirBlockGetArgument(consBlock, 1)
// ... compute with head, tail ...
FunLangBindings.mlirFunLangYieldOpCreate(context, loc, result)

Phase 6에서 완전 구현한다. Phase 5에서는 MatchOp 정의만 포함한다.

Phase 6에서의 사용 예시

FunLang source:

// Phase 6: List pattern matching
let rec length lst =
    match lst with
    | [] -> 0
    | head :: tail -> 1 + length tail

let test = length [1; 2; 3]
// test = 3

Generated MLIR (Phase 6):

module {
  // length 함수
  func.func @length(%lst: !funlang.list<i32>) -> i32 {
    %result = funlang.match %lst : !funlang.list<i32> -> i32 {
      // Nil case
      ^nil:
        %zero = arith.constant 0 : i32
        funlang.yield %zero : i32

      // Cons case
      ^cons(%head: i32, %tail: !funlang.list<i32>):
        // 1 + length tail
        %one = arith.constant 1 : i32
        %tail_length = func.call @length(%tail) : (!funlang.list<i32>) -> i32
        %result = arith.addi %one, %tail_length : i32
        funlang.yield %result : i32
    }
    func.return %result : i32
  }

  // test = length [1, 2, 3]
  func.func @test() -> i32 {
    // Build list [1, 2, 3]
    %nil = funlang.nil : !funlang.list<i32>
    %c3 = arith.constant 3 : i32
    %lst1 = funlang.cons %c3, %nil : !funlang.list<i32>
    %c2 = arith.constant 2 : i32
    %lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>
    %c1 = arith.constant 1 : i32
    %lst3 = funlang.cons %c1, %lst2 : !funlang.list<i32>

    // Call length
    %len = func.call @length(%lst3) : (!funlang.list<i32>) -> i32
    func.return %len : i32
  }
}

Chapter 15에서는 MatchOp의 정의와 구조만 다룬다. 실제 구현과 사용은 Chapter 17 (Phase 6)에서 완성한다.


Part 4: FunLang Custom Types

FunLang_ClosureType 상세

Chapter 15 Part 1에서 !funlang.closure 타입을 간단히 소개했다. 이제 상세히 다룬다.

FunLangTypes.td:

//===- FunLangTypes.td - FunLang dialect types ------------*- tablegen -*-===//

#ifndef FUNLANG_TYPES
#define FUNLANG_TYPES

include "mlir/IR/AttrTypeBase.td"
include "FunLangDialect.td"

//===----------------------------------------------------------------------===//
// FunLang Type Definitions
//===----------------------------------------------------------------------===//

class FunLang_Type<string name, string typeMnemonic>
    : TypeDef<FunLang_Dialect, name> {
  let mnemonic = typeMnemonic;
}

//===----------------------------------------------------------------------===//
// ClosureType
//===----------------------------------------------------------------------===//

def FunLang_ClosureType : FunLang_Type<"Closure", "closure"> {
  let summary = "FunLang closure type";

  let description = [{
    Represents a closure value: a combination of function pointer and
    captured environment.

    Syntax: `!funlang.closure`

    This is an opaque type (no type parameters). The internal representation
    is hidden from the FunLang dialect level.

    Lowering:
    - FunLang dialect: !funlang.closure
    - LLVM dialect: !llvm.ptr

    The lowering pass converts !funlang.closure to !llvm.ptr, exposing the
    internal representation (function pointer + environment data).
  }];

  let extraClassDeclaration = [{
    // No extra methods needed for opaque type
  }];
}

#endif // FUNLANG_TYPES

Opaque Type vs Parameterized Type

Opaque Type (Phase 5 선택):

def FunLang_ClosureType : FunLang_Type<"Closure", "closure"> {
  // No parameters
}

MLIR Assembly:

%closure = funlang.closure @lambda_add, %n : !funlang.closure
// 타입 파라미터 없음

장점:

  • 단순성: 정의와 사용이 간단
  • 구현 숨김: 내부 표현을 dialect 레벨에서 감춤
  • Lowering 유연성: 표현 방식을 나중에 변경 가능

단점:

  • 타입 정보 부족: 함수 시그니처를 타입에서 알 수 없음
  • 검증 제한: 타입 레벨에서 인자/결과 타입 검증 불가

Parameterized Type (Alternative):

def FunLang_ClosureType : FunLang_Type<"Closure", "closure"> {
  let parameters = (ins "FunctionType":$funcType);
  let assemblyFormat = "`<` $funcType `>`";
}

MLIR Assembly:

// 파라미터화된 타입
%closure = funlang.closure @lambda_add, %n : !funlang.closure<(i32) -> i32>
//                                          함수 시그니처 ^^^^^^^^^^^

장점:

  • 타입 안전성 향상: 함수 시그니처가 타입에 포함됨
  • 검증 가능: apply operation에서 인자 타입 검증 가능
  • 문서화: 타입만 봐도 클로저 시그니처 알 수 있음

단점:

  • 복잡성 증가: 타입 파라미터 관리 필요
  • Lowering 복잡도: 타입 변환 시 파라미터 제거 필요

Phase 5 설계 결정:

Opaque type을 사용한다:

  1. 단순성 우선: Phase 5는 dialect 도입이 목표
  2. Phase 6 고려: 리스트 타입은 parameterized (필수)
  3. 점진적 복잡도: 나중에 파라미터 추가 가능

FunLang_ListType (Phase 6 Preview)

Phase 6에서는 리스트를 위한 parameterized type이 필요하다:

//===----------------------------------------------------------------------===//
// ListType (Phase 6)
//===----------------------------------------------------------------------===//

def FunLang_ListType : FunLang_Type<"List", "list"> {
  let summary = "FunLang immutable list type";

  let description = [{
    Represents an immutable linked list.

    Syntax: `!funlang.list<T>`

    Type parameter:
    - T: Element type (any MLIR type)

    Examples:
    - !funlang.list<i32>: List of integers
    - !funlang.list<f64>: List of floats
    - !funlang.list<!funlang.closure>: List of closures

    Lowering:
    - FunLang dialect: !funlang.list<T>
    - LLVM dialect: !llvm.ptr (cons cell pointer)

    Internal representation (after lowering):
    - Nil: nullptr
    - Cons: struct { T head; !llvm.ptr tail }
  }];

  let parameters = (ins "Type":$elementType);
  let assemblyFormat = "`<` $elementType `>`";

  let extraClassDeclaration = [{
    // Get element type
    Type getElementType() { return getImpl()->elementType; }
  }];
}

Parameterized Type의 필요성:

리스트는 다양한 원소 타입을 지원해야 한다:

// 정수 리스트
%int_list = funlang.nil : !funlang.list<i32>
%int_list2 = funlang.cons %x, %int_list : !funlang.list<i32>

// 클로저 리스트
%closure_list = funlang.nil : !funlang.list<!funlang.closure>
%closure_list2 = funlang.cons %f, %closure_list : !funlang.list<!funlang.closure>

타입 파라미터 없이는 타입 안전성을 보장할 수 없다:

// 잘못된 설계 (opaque list type)
%list = funlang.nil : !funlang.list  // 어떤 타입의 리스트?
%list2 = funlang.cons %x, %list : !funlang.list  // i32? f64?

// 타입 체커가 다음을 검증할 수 없음:
// - cons의 head 타입이 list의 원소 타입과 일치하는지
// - match에서 추출한 head의 타입이 무엇인지

타입의 LLVM Lowering

Progressive lowering에서 타입도 변환된다:

FunLang Dialect → LLVM Dialect:

FunLang TypeLLVM TypeInternal Representation
!funlang.closure!llvm.ptrstruct { fn_ptr, var1, var2, ... }
!funlang.list<T>!llvm.ptrstruct { T head; ptr tail } or nullptr

Lowering Pass (Phase 6):

// FunLangToLLVM type converter
class FunLangTypeConverter : public TypeConverter {
public:
  FunLangTypeConverter() {
    // !funlang.closure -> !llvm.ptr
    addConversion([](FunLangClosureType type) {
      return LLVM::LLVMPointerType::get(type.getContext());
    });

    // !funlang.list<T> -> !llvm.ptr
    addConversion([](FunLangListType type) {
      return LLVM::LLVMPointerType::get(type.getContext());
    });

    // Pass through other types (i32, f64, etc.)
    addConversion([](Type type) { return type; });
  }
};

Lowering 예시:

// Before lowering (FunLang dialect)
func.func @make_adder(%n: i32) -> !funlang.closure {
  %closure = funlang.closure @lambda_add, %n : !funlang.closure
  func.return %closure : !funlang.closure
}

// After lowering (LLVM dialect)
func.func @make_adder(%n: i32) -> !llvm.ptr {
  %env_size = arith.constant 16 : i64
  %env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
  %fn_addr = llvm.mlir.addressof @lambda_add : !llvm.ptr
  %fn_slot = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr
  %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %n, %n_slot : i32, !llvm.ptr
  func.return %env : !llvm.ptr
}

타입 변환과 operation 변환의 관계:

  • Operation 변환: funlang.closureGC_malloc + store 패턴
  • Type 변환: !funlang.closure!llvm.ptr
  • 동시 적용: Lowering pass가 두 변환을 함께 수행

C++ Type 클래스 (Generated)

TableGen이 생성하는 C++ 코드:

Generated: FunLangTypes.h.inc

namespace mlir {
namespace funlang {

class FunLangClosureType : public Type::TypeBase<
    FunLangClosureType,
    Type,
    detail::FunLangClosureTypeStorage> {
public:
  using Base::Base;

  static FunLangClosureType get(MLIRContext *context);

  static constexpr StringLiteral name = "funlang.closure";
};

class FunLangListType : public Type::TypeBase<
    FunLangListType,
    Type,
    detail::FunLangListTypeStorage,
    TypeTrait::HasTypeParameter> {
public:
  using Base::Base;

  static FunLangListType get(Type elementType);

  Type getElementType() const;

  static constexpr StringLiteral name = "funlang.list";
};

} // namespace funlang
} // namespace mlir

사용 예시 (C++):

MLIRContext *context = /*...*/;

// Create !funlang.closure type
auto closureType = FunLangClosureType::get(context);

// Create !funlang.list<i32> type
auto i32Type = IntegerType::get(context, 32);
auto listType = FunLangListType::get(i32Type);

// Get element type
Type elemType = listType.getElementType();
// elemType == i32Type

Part 5: Complete F# Integration Module

이제 모든 요소를 통합해 완전한 F# 래퍼를 작성한다.

Mlir.FunLang.fs 모듈 전체 구조

namespace Mlir.FunLang

open System
open System.Runtime.InteropServices
open Mlir.Core

//==============================================================================
// Low-level P/Invoke Bindings
//==============================================================================

module FunLangBindings =

    //==========================================================================
    // Types
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangClosureTypeGet(MlirContext ctx)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern bool mlirTypeIsAFunLangClosureType(MlirType ty)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangListTypeGet(MlirContext ctx, MlirType elementType)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern bool mlirTypeIsAFunLangListType(MlirType ty)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangListTypeGetElementType(MlirType ty)

    //==========================================================================
    // Operations - ClosureOp
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirFunLangClosureOpCreate(
        MlirContext ctx,
        MlirLocation loc,
        MlirAttribute callee,
        nativeint numCaptured,
        MlirValue[] capturedValues)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirAttribute mlirFunLangClosureOpGetCallee(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirFunLangClosureOpGetNumCapturedValues(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirFunLangClosureOpGetCapturedValue(
        MlirOperation op,
        nativeint index)

    //==========================================================================
    // Operations - ApplyOp
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirFunLangApplyOpCreate(
        MlirContext ctx,
        MlirLocation loc,
        MlirValue closure,
        nativeint numArgs,
        MlirValue[] args,
        MlirType resultType)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirFunLangApplyOpGetClosure(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern nativeint mlirFunLangApplyOpGetNumArgs(MlirOperation op)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirValue mlirFunLangApplyOpGetArg(MlirOperation op, nativeint index)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangApplyOpGetResultType(MlirOperation op)

//==============================================================================
// High-level F# Wrappers
//==============================================================================

/// FunLang dialect operations wrapper
type FunLangDialect(context: MlirContext) =

    /// MLIR context
    member val Context = context

    //==========================================================================
    // Type Creation
    //==========================================================================

    /// Create !funlang.closure type
    member this.ClosureType() : MlirType =
        FunLangBindings.mlirFunLangClosureTypeGet(this.Context)

    /// Check if type is !funlang.closure
    member this.IsClosureType(ty: MlirType) : bool =
        FunLangBindings.mlirTypeIsAFunLangClosureType(ty)

    /// Create !funlang.list<T> type
    member this.ListType(elementType: MlirType) : MlirType =
        FunLangBindings.mlirFunLangListTypeGet(this.Context, elementType)

    /// Check if type is !funlang.list
    member this.IsListType(ty: MlirType) : bool =
        FunLangBindings.mlirTypeIsAFunLangListType(ty)

    /// Get element type from !funlang.list<T>
    member this.ListElementType(ty: MlirType) : MlirType =
        if not (this.IsListType(ty)) then
            invalidArg "ty" "Expected !funlang.list type"
        FunLangBindings.mlirFunLangListTypeGetElementType(ty)

    //==========================================================================
    // Operation Creation
    //==========================================================================

    /// Create funlang.closure operation
    ///
    /// Returns the operation (caller extracts result value via getResult(0))
    member this.CreateClosureOp(
        location: MlirLocation,
        callee: string,
        capturedValues: MlirValue list) : MlirOperation =

        // Convert function name to FlatSymbolRefAttr
        use calleeStrRef = MlirStringRef.FromString(callee)
        let calleeAttr = mlirFlatSymbolRefAttrGet(this.Context, calleeStrRef)

        // Convert F# list to array
        let capturedArray = List.toArray capturedValues
        let numCaptured = nativeint capturedArray.Length

        // Call C API
        FunLangBindings.mlirFunLangClosureOpCreate(
            this.Context, location, calleeAttr, numCaptured, capturedArray)

    /// Create funlang.closure operation and return result value
    member this.CreateClosure(
        location: MlirLocation,
        callee: string,
        capturedValues: MlirValue list) : MlirValue =

        let op = this.CreateClosureOp(location, callee, capturedValues)
        mlirOperationGetResult(op, 0)

    /// Create funlang.apply operation
    ///
    /// Returns the operation (caller extracts result value via getResult(0))
    member this.CreateApplyOp(
        location: MlirLocation,
        closure: MlirValue,
        args: MlirValue list,
        resultType: MlirType) : MlirOperation =

        // Convert F# list to array
        let argsArray = List.toArray args
        let numArgs = nativeint argsArray.Length

        // Call C API
        FunLangBindings.mlirFunLangApplyOpCreate(
            this.Context, location, closure, numArgs, argsArray, resultType)

    /// Create funlang.apply operation and return result value
    member this.CreateApply(
        location: MlirLocation,
        closure: MlirValue,
        args: MlirValue list,
        resultType: MlirType) : MlirValue =

        let op = this.CreateApplyOp(location, closure, args, resultType)
        mlirOperationGetResult(op, 0)

//==============================================================================
// OpBuilder Extension Methods
//==============================================================================

/// Extension methods for OpBuilder to work with FunLang dialect
[<AutoOpen>]
module OpBuilderExtensions =

    type OpBuilder with

        /// Create funlang.closure operation
        member this.CreateFunLangClosure(
            callee: string,
            capturedValues: MlirValue list) : MlirValue =

            let funlang = FunLangDialect(this.Context)
            funlang.CreateClosure(this.Location, callee, capturedValues)

        /// Create funlang.apply operation
        member this.CreateFunLangApply(
            closure: MlirValue,
            args: MlirValue list,
            resultType: MlirType) : MlirValue =

            let funlang = FunLangDialect(this.Context)
            funlang.CreateApply(this.Location, closure, args, resultType)

        /// Create !funlang.closure type
        member this.FunLangClosureType() : MlirType =
            let funlang = FunLangDialect(this.Context)
            funlang.ClosureType()

        /// Create !funlang.list<T> type
        member this.FunLangListType(elementType: MlirType) : MlirType =
            let funlang = FunLangDialect(this.Context)
            funlang.ListType(elementType)

F# Wrapper 클래스 설계

설계 원칙:

  1. Low-level과 High-level 분리

    • FunLangBindings 모듈: extern 선언 (P/Invoke)
    • FunLangDialect 클래스: 타입 안전 래퍼
  2. Builder 패턴

    • CreateClosureOp: MlirOperation 반환 (유연성)
    • CreateClosure: MlirValue 반환 (편의성)
  3. OpBuilder Extension

    • this.CreateFunLangClosure(...): 간결한 사용
    • Context와 Location 자동 전달
  4. 타입 안전성

    • F# 타입 시스템 활용 (list, string)
    • Runtime 검증 (IsClosureType, IsListType)

Builder 패턴으로 Operation 생성

패턴 1: Direct Operation Creation

// 명시적 operation 생성
let funlang = FunLangDialect(context)
let op = funlang.CreateClosureOp(location, "lambda_add", [nValue])
let closure = mlirOperationGetResult(op, 0)

// Use cases:
// - Operation에 추가 속성 설정
// - Operation을 블록에 수동 삽입

패턴 2: Direct Value Creation

// 결과 값만 필요
let funlang = FunLangDialect(context)
let closure = funlang.CreateClosure(location, "lambda_add", [nValue])

// Use cases:
// - 대부분의 일반적인 사용
// - Operation 자체에는 관심 없음

패턴 3: OpBuilder Extension

// OpBuilder를 통한 생성 (가장 간결)
let closure = builder.CreateFunLangClosure("lambda_add", [nValue])

// Use cases:
// - Compiler.fs에서 compileExpr 내부
// - Location과 Context 자동 전달
// - 코드 가독성 최대화

타입 안전성 보장

컴파일 타임 안전성:

F# 타입 시스템이 다음을 보장:

// 올바른 사용
let values: MlirValue list = [v1; v2; v3]
let closure = builder.CreateFunLangClosure("lambda", values)

// 컴파일 에러
let wrong: int list = [1; 2; 3]
let closure = builder.CreateFunLangClosure("lambda", wrong)
// ERROR: Expected MlirValue list, got int list

런타임 안전성:

추가 검증 함수 제공:

// 타입 검증
let ty = mlirValueGetType(someValue)
if funlang.IsClosureType(ty) then
    // someValue는 !funlang.closure 타입
    let result = funlang.CreateApply(location, someValue, [arg], i32Type)
else
    failwith "Expected closure type"

사용 예시: makeAdder를 FunLang Dialect로 컴파일

Phase 4 Compiler.fs (Before):

let rec compileExpr (builder: OpBuilder) (env: Map<string, MlirValue>) (expr: Expr) : MlirValue =
    match expr with
    | Lambda(param, body) ->
        // Free variables analysis
        let freeVars = Set.difference (freeVarsExpr body) (Set.singleton param)
        let freeVarList = Set.toList freeVars

        // Create lifted function
        let lambdaName = freshLambdaName()
        createLiftedFunction builder lambdaName param body freeVarList env

        // Environment size: 8 (fn ptr) + 4 * |freeVars|
        let fnPtrSize = 8L
        let varSize = 4L
        let totalSize = fnPtrSize + (int64 freeVarList.Length) * varSize
        let sizeConst = builder.CreateI64Const(totalSize)

        // GC_malloc
        let envPtr = builder.CreateCall("GC_malloc", [sizeConst])

        // Store function pointer at env[0]
        let fnAddr = builder.CreateAddressOf(lambdaName)
        let fnSlot = builder.CreateGEP(envPtr, 0L)
        builder.CreateStore(fnAddr, fnSlot)

        // Store captured values at env[1..n]
        freeVarList |> List.iteri (fun i varName ->
            let value = env.[varName]
            let slot = builder.CreateGEP(envPtr, int64 (i + 1))
            builder.CreateStore(value, slot)
        )

        envPtr  // Return closure (environment pointer)

    | App(funcExpr, argExpr) ->
        // Compile function and argument
        let closureVal = compileExpr builder env funcExpr
        let argVal = compileExpr builder env argExpr

        // Indirect call: GEP + load + llvm.call
        let c0 = builder.CreateI64Const(0L)
        let fnPtrAddr = builder.CreateGEP(closureVal, 0L)
        let fnPtr = builder.CreateLoad(fnPtrAddr, builder.PtrType())
        let result = builder.CreateLLVMCall(fnPtr, [closureVal; argVal], builder.IntType(32))
        result

    // ... other cases ...

Phase 5 Compiler.fs (After):

let rec compileExpr (builder: OpBuilder) (env: Map<string, MlirValue>) (expr: Expr) : MlirValue =
    match expr with
    | Lambda(param, body) ->
        // Free variables analysis (same)
        let freeVars = Set.difference (freeVarsExpr body) (Set.singleton param)
        let freeVarList = Set.toList freeVars

        // Create lifted function (same)
        let lambdaName = freshLambdaName()
        createLiftedFunction builder lambdaName param body freeVarList env

        // Create closure with FunLang dialect (1 line!)
        let capturedValues = freeVarList |> List.map (fun v -> env.[v])
        builder.CreateFunLangClosure(lambdaName, capturedValues)

    | App(funcExpr, argExpr) ->
        // Compile function and argument (same)
        let closureVal = compileExpr builder env funcExpr
        let argVal = compileExpr builder env argExpr

        // Apply closure with FunLang dialect (1 line!)
        let resultType = builder.IntType(32)  // Assume i32 for now
        builder.CreateFunLangApply(closureVal, [argVal], resultType)

    // ... other cases ...

코드 비교:

AspectPhase 4Phase 5Improvement
Lambda body~15 lines~5 lines67% 감소
GC_malloc + GEP명시적숨김추상화
App body~5 lines~3 lines40% 감소
타입!llvm.ptr!funlang.closure타입 안전성
가독성저수준고수준의도 명확

Part 6: Refactoring Chapter 12-13 with Custom Dialect

Phase 4 코드를 Phase 5 코드로 리팩토링하는 구체적인 예시를 제공한다.

Before: Chapter 12 Phase 4 구현

Compiler.fs (Phase 4):

module Compiler

open Mlir.Core
open AST

// Counter for fresh lambda names
let mutable lambdaCounter = 0
let freshLambdaName() =
    lambdaCounter <- lambdaCounter + 1
    sprintf "lambda_%d" lambdaCounter

// Free variables analysis
let rec freeVarsExpr (expr: Expr) : Set<string> =
    match expr with
    | Int _ -> Set.empty
    | Var x -> Set.singleton x
    | Add(e1, e2) -> Set.union (freeVarsExpr e1) (freeVarsExpr e2)
    | Lambda(param, body) -> Set.remove param (freeVarsExpr body)
    | App(e1, e2) -> Set.union (freeVarsExpr e1) (freeVarsExpr e2)

// Create lifted function
let createLiftedFunction
    (builder: OpBuilder)
    (name: string)
    (param: string)
    (body: Expr)
    (freeVars: string list)
    (outerEnv: Map<string, MlirValue>) : unit =

    // Function type: (!llvm.ptr, i32) -> i32
    let envType = builder.PtrType()
    let paramType = builder.IntType(32)
    let resultType = builder.IntType(32)
    let funcType = builder.FunctionType([envType; paramType], [resultType])

    // Create function
    let func = builder.CreateFunction(name, funcType)

    // Build function body
    let entryBlock = builder.GetFunctionEntryBlock(func)
    builder.SetInsertionPointToEnd(entryBlock)

    let envParam = mlirBlockGetArgument(entryBlock, 0)
    let xParam = mlirBlockGetArgument(entryBlock, 1)

    // Build environment for body: {param -> xParam, freeVars -> loads}
    let mutable innerEnv = Map.ofList [(param, xParam)]

    freeVars |> List.iteri (fun i varName ->
        // Load from env[i+1]
        let idx = int64 (i + 1)
        let slot = builder.CreateGEP(envParam, idx)
        let value = builder.CreateLoad(slot, paramType)
        innerEnv <- Map.add varName value innerEnv
    )

    // Compile body
    let resultVal = compileExpr builder innerEnv body
    builder.CreateReturn(resultVal)

// Compile expression
and compileExpr (builder: OpBuilder) (env: Map<string, MlirValue>) (expr: Expr) : MlirValue =
    match expr with
    | Int n ->
        builder.CreateI32Const(n)

    | Var x ->
        env.[x]

    | Add(e1, e2) ->
        let v1 = compileExpr builder env e1
        let v2 = compileExpr builder env e2
        builder.CreateArithBinaryOp(ArithOp.Addi, v1, v2)

    | Lambda(param, body) ->
        // Phase 4: 12+ lines of low-level code
        let freeVars = freeVarsExpr body |> Set.toList

        let lambdaName = freshLambdaName()
        createLiftedFunction builder lambdaName param body freeVars env

        // Calculate environment size
        let fnPtrSize = 8L
        let varSize = 4L
        let totalSize = fnPtrSize + (int64 freeVars.Length) * varSize
        let sizeConst = builder.CreateI64Const(totalSize)

        // Allocate environment
        let envPtr = builder.CreateCall("GC_malloc", [sizeConst])

        // Store function pointer at env[0]
        let fnAddr = builder.CreateAddressOf(lambdaName)
        let fnSlot = builder.CreateGEP(envPtr, 0L)
        builder.CreateStore(fnAddr, fnSlot)

        // Store captured variables at env[1..n]
        freeVars |> List.iteri (fun i varName ->
            let value = env.[varName]
            let slot = builder.CreateGEP(envPtr, int64 (i + 1))
            builder.CreateStore(value, slot)
        )

        envPtr

    | App(funcExpr, argExpr) ->
        // Phase 4: 8+ lines of indirect call
        let closureVal = compileExpr builder env funcExpr
        let argVal = compileExpr builder env argExpr

        // Load function pointer from closure[0]
        let c0 = builder.CreateI64Const(0L)
        let fnPtrAddr = builder.CreateGEP(closureVal, 0L)
        let fnPtr = builder.CreateLoad(fnPtrAddr, builder.PtrType())

        // Indirect call: fn_ptr(closure, arg)
        let resultType = builder.IntType(32)
        builder.CreateLLVMCall(fnPtr, [closureVal; argVal], resultType)

// Main compile function
let compile (expr: Expr) : MlirModule =
    use context = new MlirContext()
    context.LoadDialect("builtin")
    context.LoadDialect("func")
    context.LoadDialect("arith")
    context.LoadDialect("llvm")

    use mlirModule = MlirModule.Create(context, "main_module")
    use builder = new OpBuilder(context)
    builder.SetInsertionPointToEnd(mlirModule.Body)

    // Declare GC_malloc
    let i64Type = builder.IntType(64)
    let ptrType = builder.PtrType()
    let gcMallocType = builder.FunctionType([i64Type], [ptrType])
    builder.CreateFunctionDecl("GC_malloc", gcMallocType)

    // Compile main function
    let i32Type = builder.IntType(32)
    let mainType = builder.FunctionType([], [i32Type])
    let mainFunc = builder.CreateFunction("main", mainType)

    let entryBlock = builder.GetFunctionEntryBlock(mainFunc)
    builder.SetInsertionPointToEnd(entryBlock)

    let resultVal = compileExpr builder Map.empty expr
    builder.CreateReturn(resultVal)

    mlirModule

After: Chapter 15 Phase 5 구현

Compiler.fs (Phase 5):

module Compiler

open Mlir.Core
open Mlir.FunLang  // Add FunLang dialect
open AST

// (freshLambdaName, freeVarsExpr - same as Phase 4)

// Create lifted function (same as Phase 4)
let createLiftedFunction
    (builder: OpBuilder)
    (name: string)
    (param: string)
    (body: Expr)
    (freeVars: string list)
    (outerEnv: Map<string, MlirValue>) : unit =
    // ... (same implementation) ...

// Compile expression
and compileExpr (builder: OpBuilder) (env: Map<string, MlirValue>) (expr: Expr) : MlirValue =
    match expr with
    | Int n -> builder.CreateI32Const(n)
    | Var x -> env.[x]
    | Add(e1, e2) ->
        let v1 = compileExpr builder env e1
        let v2 = compileExpr builder env e2
        builder.CreateArithBinaryOp(ArithOp.Addi, v1, v2)

    | Lambda(param, body) ->
        // Phase 5: 5 lines with FunLang dialect!
        let freeVars = freeVarsExpr body |> Set.toList

        let lambdaName = freshLambdaName()
        createLiftedFunction builder lambdaName param body freeVars env

        // Create closure (1 line!)
        let capturedValues = freeVars |> List.map (fun v -> env.[v])
        builder.CreateFunLangClosure(lambdaName, capturedValues)

    | App(funcExpr, argExpr) ->
        // Phase 5: 3 lines with FunLang dialect!
        let closureVal = compileExpr builder env funcExpr
        let argVal = compileExpr builder env argExpr

        // Apply closure (1 line!)
        let resultType = builder.IntType(32)
        builder.CreateFunLangApply(closureVal, [argVal], resultType)

// Main compile function
let compile (expr: Expr) : MlirModule =
    use context = new MlirContext()
    context.LoadDialect("builtin")
    context.LoadDialect("func")
    context.LoadDialect("arith")
    context.LoadDialect("llvm")
    context.LoadDialect("funlang")  // Add FunLang dialect!

    use mlirModule = MlirModule.Create(context, "main_module")
    use builder = new OpBuilder(context)
    builder.SetInsertionPointToEnd(mlirModule.Body)

    // Declare GC_malloc (same)
    let i64Type = builder.IntType(64)
    let ptrType = builder.PtrType()
    let gcMallocType = builder.FunctionType([i64Type], [ptrType])
    builder.CreateFunctionDecl("GC_malloc", gcMallocType)

    // Compile main function (same)
    let i32Type = builder.IntType(32)
    let mainType = builder.FunctionType([], [i32Type])
    let mainFunc = builder.CreateFunction("main", mainType)

    let entryBlock = builder.GetFunctionEntryBlock(mainFunc)
    builder.SetInsertionPointToEnd(entryBlock)

    let resultVal = compileExpr builder Map.empty expr
    builder.CreateReturn(resultVal)

    mlirModule

코드 줄 수 비교

Lambda case:

VersionLinesKey Operations
Phase 4~20Size calculation, GC_malloc, GEP loop, stores
Phase 5~5CreateFunLangClosure
Reduction75%15 lines eliminated

App case:

VersionLinesKey Operations
Phase 4~8GEP, load, llvm.call
Phase 5~3CreateFunLangApply
Reduction63%5 lines eliminated

Overall (compileExpr function):

VersionTotal LinesLambda LinesApp Lines
Phase 4~50~20~8
Phase 5~25~5~3
Reduction50%75%63%

compileExpr 함수 변경점 요약

추가된 import:

open Mlir.FunLang  // FunLang dialect wrapper

변경된 dialect 로딩:

context.LoadDialect("funlang")  // FunLang dialect 추가

Lambda case 변경:

// Before: 12+ lines (GC_malloc + GEP loop)
let totalSize = ...
let envPtr = builder.CreateCall("GC_malloc", [sizeConst])
// ... GEP loop ...

// After: 1 line
let capturedValues = freeVars |> List.map (fun v -> env.[v])
builder.CreateFunLangClosure(lambdaName, capturedValues)

App case 변경:

// Before: 5+ lines (GEP + load + llvm.call)
let fnPtrAddr = builder.CreateGEP(closureVal, 0L)
let fnPtr = builder.CreateLoad(fnPtrAddr, ...)
builder.CreateLLVMCall(fnPtr, [closureVal; argVal], ...)

// After: 1 line
builder.CreateFunLangApply(closureVal, [argVal], resultType)

Generated MLIR 비교

Test program:

// FunLang AST
let test =
    Let("make_adder",
        Lambda("n",
            Lambda("x",
                Add(Var "x", Var "n"))),
        App(App(Var "make_adder", Int 5), Int 10))

Phase 4 Generated MLIR:

module {
  llvm.func @GC_malloc(i64) -> !llvm.ptr

  func.func @lambda_1(%env: !llvm.ptr, %x: i32) -> i32 {
    %c1 = arith.constant 1 : i64
    %n_slot = llvm.getelementptr %env[%c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    %result = arith.addi %x, %n : i32
    func.return %result : i32
  }

  func.func @lambda_0(%env: !llvm.ptr, %n: i32) -> !llvm.ptr {
    %c12 = arith.constant 12 : i64
    %inner_env = llvm.call @GC_malloc(%c12) : (i64) -> !llvm.ptr
    %fn_addr = llvm.mlir.addressof @lambda_1 : !llvm.ptr
    %c0 = arith.constant 0 : i64
    %fn_slot = llvm.getelementptr %inner_env[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr
    %c1 = arith.constant 1 : i64
    %n_slot = llvm.getelementptr %inner_env[%c1] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %n, %n_slot : i32, !llvm.ptr
    func.return %inner_env : !llvm.ptr
  }

  func.func @main() -> i32 {
    %c12 = arith.constant 12 : i64
    %outer_env = llvm.call @GC_malloc(%c12) : (i64) -> !llvm.ptr
    %fn_addr = llvm.mlir.addressof @lambda_0 : !llvm.ptr
    %c0 = arith.constant 0 : i64
    %fn_slot = llvm.getelementptr %outer_env[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    llvm.store %fn_addr, %fn_slot : !llvm.ptr, !llvm.ptr

    %c5 = arith.constant 5 : i32
    %fn_ptr_addr = llvm.getelementptr %outer_env[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
    %add5 = llvm.call %fn_ptr(%outer_env, %c5) : (!llvm.ptr, i32) -> !llvm.ptr

    %c10 = arith.constant 10 : i32
    %fn_ptr_addr2 = llvm.getelementptr %add5[%c0] : (!llvm.ptr, i64) -> !llvm.ptr
    %fn_ptr2 = llvm.load %fn_ptr_addr2 : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr2(%add5, %c10) : (!llvm.ptr, i32) -> i32

    func.return %result : i32
  }
}

Phase 5 Generated MLIR:

module {
  llvm.func @GC_malloc(i64) -> !llvm.ptr

  func.func @lambda_1(%env: !llvm.ptr, %x: i32) -> i32 {
    %c1 = arith.constant 1 : i64
    %n_slot = llvm.getelementptr %env[%c1] : (!llvm.ptr, i64) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    %result = arith.addi %x, %n : i32
    func.return %result : i32
  }

  func.func @lambda_0(%env: !llvm.ptr, %n: i32) -> !funlang.closure {
    // Closure creation: 1 line!
    %inner_closure = funlang.closure @lambda_1, %n : !funlang.closure
    func.return %inner_closure : !funlang.closure
  }

  func.func @main() -> i32 {
    // Outer closure
    %make_adder = funlang.closure @lambda_0 : !funlang.closure

    // Apply make_adder 5
    %c5 = arith.constant 5 : i32
    %add5 = funlang.apply %make_adder(%c5) : (i32) -> !funlang.closure

    // Apply add5 10
    %c10 = arith.constant 10 : i32
    %result = funlang.apply %add5(%c10) : (i32) -> i32

    func.return %result : i32
  }
}

MLIR Line Count:

FunctionPhase 4Phase 5Reduction
lambda_011 lines3 lines73%
main14 lines8 lines43%
Total~35 lines~18 lines49%

Part 7: Common Errors

FunLang dialect 사용 시 흔히 발생하는 오류들과 해결 방법을 다룬다.

Error 1: Missing Dialect Registration

증상:

ERROR: Dialect 'funlang' not found in context

원인:

FunLang dialect을 context에 로드하지 않았다.

잘못된 코드:

use context = new MlirContext()
context.LoadDialect("builtin")
context.LoadDialect("func")
// funlang dialect 누락!

let builder = new OpBuilder(context)
let closure = builder.CreateFunLangClosure("lambda", [])
// ERROR: funlang dialect not registered

올바른 코드:

use context = new MlirContext()
context.LoadDialect("builtin")
context.LoadDialect("func")
context.LoadDialect("funlang")  // FunLang dialect 로드!

let builder = new OpBuilder(context)
let closure = builder.CreateFunLangClosure("lambda", [])
// OK

체크리스트:

  • context.LoadDialect("funlang") 호출했는가?
  • FunLang dialect 라이브러리를 링크했는가? (-lMLIR-FunLang-CAPI)
  • Dialect 초기화 함수를 호출했는가? (C++ 프로젝트에서만 필요)

Error 2: Wrong Attribute Type for Callee

증상:

ERROR: Expected FlatSymbolRefAttr, got StringAttr

원인:

함수 이름을 일반 문자열 대신 SymbolRefAttr로 전달하지 않았다.

잘못된 코드:

// F# string을 직접 전달 (wrong!)
let nameAttr = mlirStringAttrGet(context, MlirStringRef.FromString("lambda"))
let op = FunLangBindings.mlirFunLangClosureOpCreate(
    context, loc, nameAttr, 0n, [||])
// ERROR: StringAttr is not FlatSymbolRefAttr

올바른 코드:

// FlatSymbolRefAttr로 변환
use nameStrRef = MlirStringRef.FromString("lambda")
let calleeAttr = mlirFlatSymbolRefAttrGet(context, nameStrRef)
let op = FunLangBindings.mlirFunLangClosureOpCreate(
    context, loc, calleeAttr, 0n, [||])
// OK

또는 High-level wrapper 사용:

// FunLangDialect wrapper가 변환 처리
let funlang = FunLangDialect(context)
let closure = funlang.CreateClosure(loc, "lambda", [])
// OK: "lambda" string is converted to FlatSymbolRefAttr internally

Why FlatSymbolRefAttr?

  • Symbol table 검증: MLIR이 @lambda 함수 존재 여부 확인
  • 최적화 지원: Inlining, DCE 등에서 심볼 참조 추적
  • 타입 정보: 함수 시그니처 접근 가능

Error 3: Type Mismatch in Variadic Arguments

증상:

ERROR: funlang.closure expects all captured values to be SSA values

원인:

캡처된 변수 배열에 잘못된 값을 전달했다 (예: null, 초기화되지 않은 값).

잘못된 코드:

// 빈 MlirValue 배열 생성 (uninitialized)
let capturedArray : MlirValue[] = Array.zeroCreate 3
// capturedArray[0..2] are default (uninitialized)

let op = FunLangBindings.mlirFunLangClosureOpCreate(
    context, loc, calleeAttr, 3n, capturedArray)
// ERROR: Invalid MlirValue

올바른 코드:

// F# list에서 변환
let capturedList = [v1; v2; v3]
let capturedArray = List.toArray capturedList

let op = FunLangBindings.mlirFunLangClosureOpCreate(
    context, loc, calleeAttr, nativeint capturedArray.Length, capturedArray)
// OK: All values are valid SSA values

또는 High-level wrapper 사용:

// FunLangDialect wrapper가 변환 처리
let funlang = FunLangDialect(context)
let closure = funlang.CreateClosure(loc, "lambda", [v1; v2; v3])
// OK: F# list is safely converted to array

디버깅 팁:

MlirValue의 유효성을 검증:

// MlirValue가 유효한지 확인
let isValidValue (v: MlirValue) : bool =
    v.ptr <> 0n  // nativeint 0은 null pointer

// 사용 전 검증
if not (isValidValue v1) then
    failwith "v1 is invalid MlirValue"

Error 4: Forgetting to Declare Dependent Dialects

증상:

ERROR: Operation 'func.call' not found
ERROR: Operation 'arith.addi' not found

원인:

FunLang dialect은 다른 dialect (func, arith, llvm)에 의존한다. 이들을 로드하지 않으면 lifted function 내부에서 오류 발생.

잘못된 코드:

use context = new MlirContext()
context.LoadDialect("funlang")  // FunLang만 로드

let builder = new OpBuilder(context)
let closure = builder.CreateFunLangClosure("lambda", [])
// ERROR: lifted function uses arith.addi, but arith dialect not loaded

올바른 코드:

use context = new MlirContext()
context.LoadDialect("builtin")   // Module, FuncOp
context.LoadDialect("func")      // func.func, func.call, func.return
context.LoadDialect("arith")     // arith.constant, arith.addi
context.LoadDialect("llvm")      // llvm.ptr, llvm.getelementptr
context.LoadDialect("funlang")   // funlang.closure, funlang.apply

// 이제 모든 operations 사용 가능

Dialect 의존성 체인:

FunLang dialect
  ├── depends on Func dialect (func.func, func.return)
  ├── depends on Arith dialect (arith.constant, arith.addi)
  └── depends on LLVM dialect (!llvm.ptr, llvm.getelementptr)

TableGen 선언 (FunLangDialect.td):

def FunLang_Dialect : Dialect {
  let name = "funlang";
  let summary = "FunLang functional language dialect";
  let description = [{...}];
  let cppNamespace = "::mlir::funlang";

  // Dependent dialects
  let dependentDialects = [
    "mlir::func::FuncDialect",
    "mlir::arith::ArithDialect",
    "mlir::LLVM::LLVMDialect"
  ];
}

Error 5: Incorrect Result Type in funlang.apply

증상:

ERROR: funlang.apply result type does not match function signature

원인:

funlang.apply에 지정한 결과 타입이 실제 클로저 함수의 반환 타입과 다르다.

잘못된 코드:

// lambda_add 함수: (i32) -> i32
%closure = funlang.closure @lambda_add, %n : !funlang.closure

// 잘못된 결과 타입 (f64)
%result = funlang.apply %closure(%x) : (i32) -> f64
// ERROR: lambda_add returns i32, not f64

올바른 코드:

// lambda_add 함수: (i32) -> i32
%closure = funlang.closure @lambda_add, %n : !funlang.closure

// 올바른 결과 타입 (i32)
%result = funlang.apply %closure(%x) : (i32) -> i32
// OK

F# 컴파일러에서의 해결:

타입 추론을 통해 자동으로 올바른 타입 지정:

// 컴파일러가 resultType를 추론
let resultType =
    match exprType funcExpr with
    | FunctionType(argTypes, retType) -> retType
    | _ -> failwith "Expected function type"

builder.CreateFunLangApply(closureVal, [argVal], resultType)

Error 6: Using funlang.closure with Non-Existent Function

증상:

ERROR: Symbol '@lambda_99' not found in module

원인:

funlang.closure @lambda_99를 생성했지만, @lambda_99 함수를 정의하지 않았다.

잘못된 코드:

// 클로저 생성
let closure = builder.CreateFunLangClosure("lambda_99", [])

// 하지만 lambda_99 함수는 정의되지 않음!
// ERROR: Symbol not found

올바른 코드:

// 1. 먼저 lifted function 생성
createLiftedFunction builder "lambda_99" "x" bodyExpr [] env

// 2. 그 다음 클로저 생성
let closure = builder.CreateFunLangClosure("lambda_99", [])
// OK: lambda_99 exists

순서 보장:

// Lambda case in compileExpr
| Lambda(param, body) ->
    let lambdaName = freshLambdaName()

    // Step 1: Create lifted function FIRST
    createLiftedFunction builder lambdaName param body freeVars env

    // Step 2: Create closure AFTER function exists
    let capturedValues = freeVars |> List.map (fun v -> env.[v])
    builder.CreateFunLangClosure(lambdaName, capturedValues)

Summary

Chapter 15에서 배운 것

1. funlang.closure Operation

  • Phase 4의 12줄 클로저 생성 코드를 1줄로 압축
  • TableGen ODS로 선언적 정의
  • Pure trait로 최적화 가능
  • FlatSymbolRefAttr로 타입 안전 함수 참조
  • C API shim으로 F# 통합

2. funlang.apply Operation

  • Phase 4의 8줄 간접 호출 코드를 1줄로 압축
  • 클로저 타입을 인자로 받음 (!funlang.closure)
  • Side effect 고려 (trait 없음)
  • Functional-type syntax로 명확한 시그니처

3. funlang.match Operation (Phase 6 Preview)

  • Region-based operation 구조
  • VariadicRegion<SizedRegion<1>>로 각 case 독립
  • SingleBlockImplicitTerminator<“YieldOp”>로 통일된 종료
  • Verifier로 타입 안전성 보장
  • Block arguments로 패턴 변수 표현

4. FunLang Custom Types

  • !funlang.closure: Opaque type (단순성 우선)
  • !funlang.list: Parameterized type (타입 안전성 필수)
  • Lowering: FunLang types → !llvm.ptr

5. Complete F# Integration

  • Low-level bindings (FunLangBindings 모듈)
  • High-level wrappers (FunLangDialect 클래스)
  • OpBuilder extensions (CreateFunLangClosure/Apply)
  • Type-safe API (F# list, string 자동 변환)

6. Code Reduction

  • Lambda: 20 lines → 5 lines (75% 감소)
  • App: 8 lines → 3 lines (63% 감소)
  • Overall: 50% 코드 감소
  • 타입 안전성 향상 (!llvm.ptr → !funlang.closure)

핵심 패턴

TableGen ODS:

def FunLang_ClosureOp : FunLang_Op<"closure", [Pure]> {
  let arguments = (ins FlatSymbolRefAttr:$callee,
                       Variadic<AnyType>:$capturedValues);
  let results = (outs FunLang_ClosureType:$result);
  let assemblyFormat = [...];
}

C API Shim:

MlirOperation mlirFunLangClosureOpCreate(...) {
  MLIRContext *ctx = unwrap(mlirCtx);
  OpBuilder builder(ctx);
  auto op = builder.create<ClosureOp>(...);
  return wrap(op.getOperation());
}

F# High-level Wrapper:

type FunLangDialect(context: MlirContext) =
    member this.CreateClosure(loc, callee, captured) =
        // Handle string → FlatSymbolRefAttr conversion
        // Handle F# list → C array conversion
        // Call C API
        // Return MlirValue

Chapter 16 Preview

Chapter 16: Lowering Passes

다음 장에서는 FunLang dialect을 LLVM dialect으로 lowering하는 pass를 구현한다:

  1. FunLangToLLVM Lowering Pass

    • funlang.closure → GC_malloc + store 패턴
    • funlang.apply → GEP + load + llvm.call 패턴
    • !funlang.closure → !llvm.ptr 타입 변환
  2. Pass Infrastructure

    • Pass registration (PassManager)
    • ConversionTarget 설정
    • TypeConverter 구현
    • RewritePattern 작성
  3. Testing

    • FileCheck 테스트 작성
    • Before/After IR 비교
    • 실행 테스트 (JIT)
  4. Optimization Opportunities

    • Closure inlining
    • Escape analysis
    • Dead closure elimination

Progressive Lowering 완성:

FunLang AST
  ↓ (Compiler.fs)
FunLang Dialect (funlang.closure, funlang.apply)
  ↓ (Chapter 16: FunLangToLLVM pass)
LLVM Dialect (llvm.call @GC_malloc, llvm.getelementptr)
  ↓ (MLIR built-in passes)
LLVM IR
  ↓ (LLVM backend)
Native Code

Phase 5의 목표 달성:

  • Custom dialect 정의 (Chapter 14 theory, Chapter 15 implementation)
  • Operations 구현 (closure, apply, match preview)
  • Types 구현 (closure, list preview)
  • F# 통합 (C API shim + bindings)
  • Compiler 리팩토링 (Phase 4 코드 50% 감소)
  • Lowering pass 구현 (Chapter 16)
  • 테스트와 검증 (Chapter 16)

다음: Chapter 16 - Lowering Passes로 Phase 5를 완성한다!

Chapter 16: Lowering Passes (Lowering Passes)

소개

Phase 5의 여정이 완성된다. Chapter 14에서 커스텀 dialect의 이론을 다뤘고, Chapter 15에서 FunLang operations를 정의했다. 이제 마지막 퍼즐 조각: lowering이다.

Chapter 14-15 복습

Chapter 14: Custom Dialect Design

  • Progressive lowering 철학 (FunLang → Func/SCF → LLVM)
  • TableGen ODS로 operation 정의
  • C API shim pattern으로 F# 연결
  • FunLang dialect 설계 방향

Chapter 15: Custom Operations

  • funlang.closure operation: 클로저 생성 추상화
  • funlang.apply operation: 클로저 호출 추상화
  • !funlang.closure custom type: 타입 안전성
  • F# integration: C API → P/Invoke → OpBuilder extensions

현재 상태:

// Phase 5 FunLang dialect (Chapter 15)
func.func @make_adder(%n: i32) -> !funlang.closure {
    %closure = funlang.closure @lambda_adder, %n : !funlang.closure
    func.return %closure : !funlang.closure
}

func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // 환경에서 n 로드
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

문제: funlang.closure는 high-level operation이다. LLVM backend는 이걸 이해 못한다. Lowering pass가 필요하다.

Lowering Pass란?

Lowering pass는 high-level operation을 low-level operation으로 변환하는 MLIR transformation이다.

FunLang의 Progressive Lowering:

1. FunLang dialect (Chapter 15)
   funlang.closure, funlang.apply
   ↓
2. Func + SCF + MemRef (중간 추상화)
   func.func, scf.if, memref.alloca
   ↓
3. LLVM dialect (Chapter 12-13 패턴)
   llvm.call, llvm.getelementptr, llvm.store
   ↓
4. LLVM IR (MLIR → LLVM translation)
   call @GC_malloc, getelementptr, store

Chapter 16의 scope: FunLang dialect → LLVM dialect (Step 1 → 3)

왜 직접 LLVM dialect로?

Phase 5에서는 간단한 클로저만 다룬다. 중간 dialect(SCF, MemRef)를 거칠 필요가 없다. 직접 lowering이 효율적이다.

Phase 6 preview: 패턴 매칭 (funlang.match)은 복잡한 제어 흐름을 포함한다. 그때는 SCF dialect를 거쳐서 lowering한다.

Lowering 목표

Before lowering (FunLang dialect):

%closure = funlang.closure @lambda, %n : !funlang.closure
%result = funlang.apply %closure(%x) : (i32) -> i32

After lowering (LLVM dialect):

// funlang.closure → GC_malloc + getelementptr + store
%env_size = arith.constant 16 : i64
%env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
%fn_ptr = llvm.mlir.addressof @lambda : !llvm.ptr
%slot0 = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr
%slot1 = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %n, %slot1 : i32, !llvm.ptr

// funlang.apply → getelementptr + load + llvm.call
%fn_ptr_addr = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
%fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
%result = llvm.call %fn_ptr(%env, %x) : (!llvm.ptr, i32) -> i32

Lowering은 Chapter 12-13의 패턴을 재사용한다. 수동으로 작성하던 코드를, 이제 compiler pass가 자동으로 생성한다.

Chapter 16 목표

이 장을 마치면:

  1. DialectConversion framework 이해

    • ConversionTarget: 어떤 dialect가 합법적인가?
    • RewritePatternSet: 어떻게 변환하는가?
    • TypeConverter: 타입은 어떻게 변환하는가?
  2. ConversionPattern 작성 능력

    • ClosureOpLowering: funlang.closure → LLVM operations
    • ApplyOpLowering: funlang.apply → LLVM operations
  3. DRR (Declarative Rewrite Rules) 이해

    • TableGen 기반 패턴 매칭
    • 최적화 패턴 작성 (empty closure, known closure inlining)
  4. Complete lowering pass 구현

    • Pass 등록 및 실행
    • C API shim 작성
    • F#에서 pass 호출
  5. End-to-end 이해

    • FunLang source → LLVM IR → executable
    • 전체 컴파일 파이프라인

성공 기준:

// F# source
let makeAdder n = fun x -> x + n
let add5 = makeAdder 5
let result = add5 10   // 15

// Compile and run
let mlir = compileFunLang source
let mlir' = lowerFunLangToLLVM mlir  // <- Chapter 16!
let llvmir = translateToLLVMIR mlir'
let executable = compileAndLink llvmir
runExecutable executable  // Prints: 15

Chapter 16 roadmap:

  1. DialectConversion Framework (350+ lines)
  2. ClosureOp Lowering Pattern (450+ lines)
  3. ApplyOp Lowering Pattern (350+ lines)
  4. TypeConverter for FunLang Types (250+ lines)
  5. Declarative Rewrite Rules (DRR) (300+ lines)
  6. Complete Lowering Pass (250+ lines)
  7. End-to-End Example (200+ lines)
  8. Common Errors (100+ lines)
  9. Summary (50+ lines)

DialectConversion Framework

MLIR의 DialectConversion framework는 dialect 간 변환을 위한 인프라다. 핵심 개념 3가지:

  1. ConversionTarget: 변환 후 허용되는 operations
  2. RewritePatternSet: 변환 규칙 집합
  3. TypeConverter: 타입 변환 규칙

ConversionTarget은 “변환 후 어떤 operations가 남아도 되는가?“를 정의한다.

ConversionTarget target(getContext());

// Legal: 이 dialects의 operations는 변환 후에도 OK
target.addLegalDialect<LLVM::LLVMDialect>();
target.addLegalDialect<func::FuncDialect>();
target.addLegalDialect<arith::ArithDialect>();

// Illegal: 이 dialects의 operations는 반드시 변환되어야 함
target.addIllegalDialect<funlang::FunLangDialect>();

의미:

  • Legal dialect: 최종 IR에 존재해도 된다
  • Illegal dialect: 최종 IR에 존재하면 안 된다 (변환 필수)

예시: FunLangToLLVM pass

ConversionTarget target(getContext());

// Legal: LLVM operations는 OK (최종 목표)
target.addLegalDialect<LLVM::LLVMDialect>();

// Legal: func operations는 OK (func.func, func.return 필요)
target.addLegalDialect<func::FuncDialect>();

// Legal: arith operations는 OK (상수, 산술 연산)
target.addLegalDialect<arith::ArithDialect>();

// Illegal: FunLang operations는 반드시 lowering되어야 함
target.addIllegalDialect<funlang::FunLangDialect>();

변환 후:

// OK - func.func (legal)
func.func @foo() {
    // OK - arith.constant (legal)
    %c = arith.constant 10 : i32

    // OK - llvm.call (legal)
    %ptr = llvm.call @GC_malloc(...) : (...) -> !llvm.ptr

    // ERROR - funlang.closure (illegal!)
    %closure = funlang.closure @bar : !funlang.closure
}

funlang.closure가 남아있으면 conversion failure다.

addLegalOp vs addIllegalOp: Fine-grained Control

Dialect 전체가 아니라 특정 operation만 제어할 수도 있다.

// FuncDialect 전체가 아니라 특정 operations만 legal
target.addLegalOp<func::FuncOp, func::ReturnOp>();

// 특정 operation만 illegal
target.addIllegalOp<funlang::ClosureOp, funlang::ApplyOp>();

사용 사례: Partial lowering (일부만 변환)

// SCF dialect 중 일부는 legal (scf.while은 그대로 둠)
target.addLegalDialect<scf::SCFDialect>();
target.addIllegalOp<scf::IfOp>();  // scf.if만 lowering

addDynamicallyLegalOp: Conditional Legality

Dynamic legality: 런타임에 판단한다.

target.addDynamicallyLegalOp<func::CallOp>(
    [](func::CallOp op) {
        // FunLang 타입을 사용하는 call은 illegal (변환 필요)
        return !llvm::any_of(op.getOperandTypes(), [](Type type) {
            return type.isa<funlang::ClosureType>();
        });
    }
);

의미: func.call!funlang.closure 타입을 사용하면 illegal (lowering 필요). 그렇지 않으면 legal (그대로 둠).

사용 사례: 타입 의존적 변환

// Legal (i32 타입만 사용)
%result = func.call @add(%x, %y) : (i32, i32) -> i32

// Illegal (funlang.closure 타입 사용)
%result = func.call @apply(%closure, %x) : (!funlang.closure, i32) -> i32

RewritePatternSet: 변환 규칙 집합

RewritePatternSet은 “어떻게 변환하는가?“를 정의한다.

RewritePatternSet patterns(&getContext());

// ConversionPattern 추가
patterns.add<ClosureOpLowering>(&getContext());
patterns.add<ApplyOpLowering>(&getContext());

// 여러 patterns를 한 번에 추가
patterns.add<ClosureOpLowering, ApplyOpLowering, MatchOpLowering>(&getContext());

Pattern의 역할:

  • 특정 operation을 매치한다 (funlang.closure)
  • 새로운 operations로 교체한다 (LLVM operations)

applyPartialConversion vs applyFullConversion

변환을 실행하는 방법 2가지:

1. applyPartialConversion: 부분 변환

if (failed(applyPartialConversion(moduleOp, target, std::move(patterns)))) {
    signalPassFailure();
}
  • 일부 illegal operations가 남아도 OK (변환 패턴이 없으면 그냥 둠)
  • 사용 사례: Multi-stage lowering (여러 pass로 나눔)

2. applyFullConversion: 완전 변환

if (failed(applyFullConversion(moduleOp, target, std::move(patterns)))) {
    signalPassFailure();
}
  • 모든 illegal operations를 변환해야 함 (하나라도 남으면 failure)
  • 사용 사례: Final lowering pass (더 이상 illegal operations 없어야 함)

FunLangToLLVM pass: Partial conversion 사용

// Partial conversion: 다른 dialect의 operations는 나중에 lowering
if (failed(applyPartialConversion(getOperation(), target, std::move(patterns)))) {
    signalPassFailure();
}

왜 Partial?

  • arith operations는 나중에 별도 pass로 lowering (--convert-arith-to-llvm)
  • func operations도 별도 pass로 lowering (--convert-func-to-llvm)
  • FunLang operations만 먼저 lowering

TypeConverter: 타입 변환

TypeConverter는 “타입을 어떻게 변환하는가?“를 정의한다.

TypeConverter typeConverter;

// FunLang 타입 → LLVM 타입
typeConverter.addConversion([](funlang::ClosureType type) {
    return LLVM::LLVMPointerType::get(type.getContext());
});

typeConverter.addConversion([](funlang::ListType type) {
    return LLVM::LLVMPointerType::get(type.getContext());
});

// 기본 타입은 그대로
typeConverter.addConversion([](Type type) {
    return type;  // i32, i64 etc.
});

변환 예시:

// Before
%closure : !funlang.closure

// After
%closure : !llvm.ptr

TypeConverter의 역할:

  1. Operation result types 변환

    Type resultType = typeConverter.convertType(op.getResult().getType());
    
  2. Function signatures 변환

    // Before
    func.func @apply(%f: !funlang.closure) -> i32
    
    // After
    func.func @apply(%f: !llvm.ptr) -> i32
    
  3. Block arguments 변환 (region 내부 타입)

Conversion patterns에서 TypeConverter 사용:

struct ApplyOpLowering : public OpConversionPattern<funlang::ApplyOp> {
  using OpConversionPattern<funlang::ApplyOp>::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::ApplyOp op, OpAdaptor adaptor,
      ConversionPatternRewriter &rewriter) const override {

    // TypeConverter를 통해 result type 변환
    Type resultType = getTypeConverter()->convertType(op.getResult().getType());

    // ...
  }
};

ConversionPattern에 TypeConverter 전달:

RewritePatternSet patterns(&getContext());
patterns.add<ApplyOpLowering>(&getContext(), typeConverter);
//                                           ^^^^^^^^^^^^^^
//                                           TypeConverter 전달

변환 실패 처리

변환이 실패하면 pass가 실패를 알려야 한다.

void runOnOperation() override {
    // ...

    if (failed(applyPartialConversion(getOperation(), target, std::move(patterns)))) {
        // 변환 실패 시그널
        signalPassFailure();
        return;
    }
}

실패 원인:

  1. Illegal operation이 남음: Pattern이 없거나 매치 실패
  2. 타입 변환 실패: TypeConverter에 규칙 없음
  3. Pattern이 failure 반환: matchAndRewrite에서 failure() 리턴

디버깅:

# Verbose mode로 실행
mlir-opt --funlang-to-llvm --debug input.mlir

# 에러 메시지 예시:
# error: failed to legalize operation 'funlang.closure'
# note: see current operation: %0 = "funlang.closure"() ...

DialectConversion 전체 흐름

1. Target 정의:

ConversionTarget target(getContext());
target.addLegalDialect<LLVM::LLVMDialect>();
target.addIllegalDialect<funlang::FunLangDialect>();

2. TypeConverter 설정:

TypeConverter typeConverter;
typeConverter.addConversion([](funlang::ClosureType type) {
    return LLVM::LLVMPointerType::get(type.getContext());
});

3. Patterns 구성:

RewritePatternSet patterns(&getContext());
patterns.add<ClosureOpLowering, ApplyOpLowering>(&getContext(), typeConverter);

4. 변환 실행:

if (failed(applyPartialConversion(getOperation(), target, std::move(patterns)))) {
    signalPassFailure();
}

5. 검증:

변환 후 IR에 illegal operations가 없는지 확인.

// 변환 전
%closure = funlang.closure @foo : !funlang.closure

// 변환 후
%env = llvm.call @GC_malloc(...) : (...) -> !llvm.ptr
// ... (LLVM operations only)

ClosureOp Lowering Pattern

funlang.closure를 LLVM dialect로 lowering한다. Chapter 12의 클로저 생성 패턴을 재사용한다.

Chapter 12 복습: 클로저 생성 패턴

Closure 구조 (Chapter 12):

Environment layout (heap-allocated):
+--------+----------+----------+-----+
| fn_ptr | var1     | var2     | ... |
+--------+----------+----------+-----+
  slot 0   slot 1     slot 2
  8 bytes  variable   variable

클로저 생성 MLIR (Chapter 12):

func.func @make_adder(%n: i32) -> !llvm.ptr {
    // 1. 환경 크기 계산: 8 (fn_ptr) + 8 (n)
    %env_size = arith.constant 16 : i64

    // 2. GC_malloc 호출
    %env = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

    // 3. 함수 포인터 저장 (slot 0)
    %fn_ptr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %slot0 = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr

    // 4. 캡처된 변수 n 저장 (slot 1)
    %slot1 = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %slot1 : i32, !llvm.ptr

    // 5. 환경 포인터 반환
    func.return %env : !llvm.ptr
}

Lowering 목표: funlang.closure를 위 패턴으로 확장한다.

funlang.closure Operation (Chapter 15 복습)

ODS 정의:

def FunLang_ClosureOp : FunLang_Op<"closure", [Pure]> {
  let summary = "Create a closure";

  let arguments = (ins
    FlatSymbolRefAttr:$callee,
    Variadic<AnyType>:$captured
  );

  let results = (outs FunLang_ClosureType:$result);

  let assemblyFormat = "$callee `,` $captured attr-dict `:` type($result)";
}

사용 예시:

// 캡처 변수 없음
%closure = funlang.closure @foo : !funlang.closure

// 캡처 변수 1개
%closure = funlang.closure @bar, %n : !funlang.closure

// 캡처 변수 여러 개
%closure = funlang.closure @baz, %x, %y, %z : !funlang.closure

ClosureOp Lowering 전략

입력: funlang.closure @callee, %captured... : !funlang.closure

출력: LLVM dialect operations

  1. 환경 크기 계산: 8 + (captured 개수 * 8) bytes
  2. GC_malloc 호출: 환경 힙 할당
  3. 함수 포인터 저장: env[0] = @callee
  4. 캡처 변수들 저장: env[1] = captured[0], env[2] = captured[1], …
  5. 환경 포인터 반환: !llvm.ptr

ConversionPattern 구조

OpConversionPattern 템플릿:

struct ClosureOpLowering : public OpConversionPattern<funlang::ClosureOp> {
  using OpConversionPattern<funlang::ClosureOp>::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::ClosureOp op,           // 원본 operation
      OpAdaptor adaptor,                // 변환된 operands
      ConversionPatternRewriter &rewriter  // IR 수정 도구
  ) const override {

    // Lowering 로직 구현

    return success();  // 또는 failure()
  }
};

핵심 파라미터:

  1. op: 원본 funlang.closure operation

    • op.getLoc(): source location
    • op.getCalleeAttr(): 함수 심볼 (@callee)
    • op.getResult(): result value
  2. adaptor: 변환된 operands

    • adaptor.getCaptured(): 캡처된 변수들 (타입 이미 변환됨)
  3. rewriter: IR 빌더

    • rewriter.create<...>(): 새 operation 생성
    • rewriter.replaceOp(): 원본 operation 교체

ClosureOpLowering 구현 (Complete)

struct ClosureOpLowering : public OpConversionPattern<funlang::ClosureOp> {
  using OpConversionPattern<funlang::ClosureOp>::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::ClosureOp op, OpAdaptor adaptor,
      ConversionPatternRewriter &rewriter) const override {

    auto loc = op.getLoc();
    auto ctx = rewriter.getContext();

    // ==============================
    // 1. 환경 크기 계산
    // ==============================
    size_t numCaptured = adaptor.getCaptured().size();

    // fn_ptr (8 bytes) + captured vars (8 bytes each)
    // 단순화: 모든 변수를 8 bytes로 가정 (포인터 크기)
    size_t envSize = 8 + numCaptured * 8;

    auto i64Type = rewriter.getI64Type();
    auto envSizeConst = rewriter.create<arith::ConstantOp>(
        loc, i64Type, rewriter.getI64IntegerAttr(envSize));

    // ==============================
    // 2. GC_malloc 호출
    // ==============================
    auto ptrType = LLVM::LLVMPointerType::get(ctx);
    auto gcMalloc = rewriter.create<LLVM::CallOp>(
        loc, ptrType, "GC_malloc", ValueRange{envSizeConst});
    Value envPtr = gcMalloc.getResult(0);

    // ==============================
    // 3. 함수 포인터 저장 (env[0])
    // ==============================
    auto fnPtrAddr = rewriter.create<LLVM::AddressOfOp>(
        loc, ptrType, op.getCalleeAttr());

    auto slot0 = rewriter.create<LLVM::GEPOp>(
        loc, ptrType, ptrType, envPtr,
        ArrayRef<LLVM::GEPArg>{0});

    rewriter.create<LLVM::StoreOp>(loc, fnPtrAddr, slot0);

    // ==============================
    // 4. 캡처된 변수들 저장 (env[1..])
    // ==============================
    for (auto [idx, val] : llvm::enumerate(adaptor.getCaptured())) {
      auto slot = rewriter.create<LLVM::GEPOp>(
          loc, ptrType, ptrType, envPtr,
          ArrayRef<LLVM::GEPArg>{static_cast<int32_t>(idx + 1)});

      rewriter.create<LLVM::StoreOp>(loc, val, slot);
    }

    // ==============================
    // 5. 원본 operation 교체
    // ==============================
    rewriter.replaceOp(op, envPtr);
    return success();
  }
};

코드 상세 설명

1. 환경 크기 계산

size_t numCaptured = adaptor.getCaptured().size();
size_t envSize = 8 + numCaptured * 8;
  • adaptor.getCaptured(): 캡처된 변수들 (ValueRange)
  • 환경 레이아웃: [fn_ptr(8), var1(8), var2(8), ...]
  • 단순화: 모든 변수를 8 bytes로 가정 (실제로는 타입별 크기 계산 필요)

arith.constant 생성:

auto envSizeConst = rewriter.create<arith::ConstantOp>(
    loc, i64Type, rewriter.getI64IntegerAttr(envSize));
  • arith.constant 16 : i64 생성 (캡처 변수 1개일 때)
  • GC_malloc에 전달할 인자

2. GC_malloc 호출

auto ptrType = LLVM::LLVMPointerType::get(ctx);
auto gcMalloc = rewriter.create<LLVM::CallOp>(
    loc, ptrType, "GC_malloc", ValueRange{envSizeConst});
Value envPtr = gcMalloc.getResult(0);
  • LLVM::CallOp: llvm.call operation 생성
  • 함수 이름: "GC_malloc" (string, external function)
  • 인자: ValueRange{envSizeConst} (환경 크기)
  • 반환 타입: !llvm.ptr

생성된 MLIR:

%0 = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr

3. 함수 포인터 저장

auto fnPtrAddr = rewriter.create<LLVM::AddressOfOp>(
    loc, ptrType, op.getCalleeAttr());
  • LLVM::AddressOfOp: llvm.mlir.addressof operation
  • 심볼: op.getCalleeAttr() (예: @lambda_adder)
  • 타입: !llvm.ptr

생성된 MLIR:

%fn_ptr = llvm.mlir.addressof @lambda_adder : !llvm.ptr

GEPOp으로 slot 0 주소 계산:

auto slot0 = rewriter.create<LLVM::GEPOp>(
    loc, ptrType, ptrType, envPtr,
    ArrayRef<LLVM::GEPArg>{0});
  • LLVM::GEPOp: llvm.getelementptr operation
  • 베이스 포인터: envPtr
  • 인덱스: {0} (첫 번째 슬롯)
  • 타입: !llvm.ptr (opaque pointer, LLVM 15+)

생성된 MLIR:

%slot0 = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr

함수 포인터 저장:

rewriter.create<LLVM::StoreOp>(loc, fnPtrAddr, slot0);

생성된 MLIR:

llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr

4. 캡처된 변수들 저장

for (auto [idx, val] : llvm::enumerate(adaptor.getCaptured())) {
  auto slot = rewriter.create<LLVM::GEPOp>(
      loc, ptrType, ptrType, envPtr,
      ArrayRef<LLVM::GEPArg>{static_cast<int32_t>(idx + 1)});

  rewriter.create<LLVM::StoreOp>(loc, val, slot);
}
  • llvm::enumerate: (index, value) 쌍으로 순회
  • 인덱스: idx + 1 (slot 0은 함수 포인터, slot 1부터 변수)
  • 각 변수를 GEP + store

캡처 변수 2개일 때 생성된 MLIR:

// 첫 번째 변수 (%n)
%slot1 = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %n, %slot1 : i32, !llvm.ptr

// 두 번째 변수 (%m)
%slot2 = llvm.getelementptr %env[2] : (!llvm.ptr) -> !llvm.ptr
llvm.store %m, %slot2 : i32, !llvm.ptr

5. 원본 operation 교체

rewriter.replaceOp(op, envPtr);
return success();
  • rewriter.replaceOp(op, envPtr): funlang.closureenvPtr로 교체
  • SSA value 대체: %closure를 사용하던 곳이 이제 %envPtr 사용
  • return success(): 변환 성공

Before:

%closure = funlang.closure @lambda, %n : !funlang.closure
%result = funlang.apply %closure(%x) : (i32) -> i32

After:

%env_size = arith.constant 16 : i64
%envPtr = llvm.call @GC_malloc(%env_size) : (i64) -> !llvm.ptr
%fn_ptr = llvm.mlir.addressof @lambda : !llvm.ptr
%slot0 = llvm.getelementptr %envPtr[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr
%slot1 = llvm.getelementptr %envPtr[1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %n, %slot1 : i32, !llvm.ptr

// %closure가 %envPtr로 교체됨
%result = funlang.apply %envPtr(%x) : (i32) -> i32

OpAdaptor의 역할

**OpAdaptor**는 변환된 operands를 제공한다.

왜 필요한가?

Conversion이 여러 단계로 이뤄질 때, operands의 타입이 이미 변환됐을 수 있다.

예시:

// Before
%captured = funlang.some_op : !funlang.closure
%closure = funlang.closure @foo, %captured : !funlang.closure

// After first pattern
%captured_lowered = ... : !llvm.ptr  // 이미 lowering됨!
%closure = funlang.closure @foo, %captured_lowered : !funlang.closure

ClosureOpLowering이 실행될 때:

  • op.getCaptured()[0]는 원본 타입 (!funlang.closure)
  • adaptor.getCaptured()[0]는 변환된 타입 (!llvm.ptr)

ConversionPattern에서는 항상 adaptor 사용:

// 잘못됨!
for (Value val : op.getCaptured()) { ... }  // 원본 타입

// 올바름
for (Value val : adaptor.getCaptured()) { ... }  // 변환된 타입

ConversionPatternRewriter의 역할

**ConversionPatternRewriter**는 IR 수정 인터페이스다.

주요 메서드:

// Operation 생성
auto newOp = rewriter.create<SomeOp>(loc, ...);

// Operation 교체
rewriter.replaceOp(oldOp, newValue);

// Operation 삭제
rewriter.eraseOp(op);

// 타입 변환
Type newType = rewriter.getTypeConverter()->convertType(oldType);

// 상수 생성 헬퍼
auto i32Type = rewriter.getI32Type();
auto attr = rewriter.getI32IntegerAttr(42);

왜 일반 OpBuilder가 아닌가?

Conversion framework는 transactional semantics를 제공한다:

  • 변환 실패 시 모든 변경 롤백
  • Operand mapping 자동 처리
  • Type conversion tracking

일반 rewriter 사용 금지:

// 잘못됨!
OpBuilder builder(op.getContext());
builder.setInsertionPoint(op);
builder.create<...>(...);

// 올바름
rewriter.setInsertionPoint(op);
rewriter.create<...>(...);

ClosureOpLowering 테스트

입력 MLIR:

func.func @test(%n: i32) -> !funlang.closure {
    %closure = funlang.closure @lambda, %n : !funlang.closure
    func.return %closure : !funlang.closure
}

Lowering pass 실행:

mlir-opt --funlang-to-llvm test.mlir

출력 MLIR:

func.func @test(%n: i32) -> !llvm.ptr {
    %c16 = arith.constant 16 : i64
    %0 = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr
    %1 = llvm.mlir.addressof @lambda : !llvm.ptr
    %2 = llvm.getelementptr %0[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %1, %2 : !llvm.ptr, !llvm.ptr
    %3 = llvm.getelementptr %0[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %3 : i32, !llvm.ptr
    func.return %0 : !llvm.ptr
}

검증:

  1. funlang.closure 사라짐 ✓
  2. GC_malloc 호출 있음 ✓
  3. 함수 포인터 저장 있음 ✓
  4. 캡처 변수 저장 있음 ✓
  5. 반환 타입 !llvm.ptr

C API Shim (Preview)

Lowering pass를 F#에서 사용하려면 C API shim이 필요하다.

C++ Pass 등록:

// FunLangPasses.cpp
void registerFunLangToLLVMPass() {
  PassRegistration<FunLangToLLVMPass>(
      "funlang-to-llvm",
      "Lower FunLang dialect to LLVM dialect");
}

// C API shim
extern "C" void mlirFunLangRegisterToLLVMPass() {
  registerFunLangToLLVMPass();
}

extern "C" void mlirFunLangRunToLLVMPass(MlirModule module) {
  ModuleOp moduleOp = unwrap(module);
  PassManager pm(moduleOp.getContext());
  pm.addPass(std::make_unique<FunLangToLLVMPass>());
  if (failed(pm.run(moduleOp))) {
    llvm::errs() << "FunLangToLLVM pass failed\n";
  }
}

F# P/Invoke:

[<DllImport("funlang-dialect", CallingConvention = CallingConvention.Cdecl)>]
extern void mlirFunLangRegisterToLLVMPass()

[<DllImport("funlang-dialect", CallingConvention = CallingConvention.Cdecl)>]
extern void mlirFunLangRunToLLVMPass(MlirModule module)

// 사용
let lowerToLLVM (module_: MlirModule) =
    mlirFunLangRunToLLVMPass(module_)

전체 pass pipeline 구성:

let compileToLLVM (mlir: MlirModule) =
    // 1. FunLang → LLVM
    mlirFunLangRunToLLVMPass(mlir)

    // 2. Arith → LLVM
    mlirRunArithToLLVMPass(mlir)

    // 3. Func → LLVM
    mlirRunFuncToLLVMPass(mlir)

    // 4. LLVM dialect → LLVM IR
    mlirTranslateToLLVMIR(mlir)

Section 2와 3 요약:

  • DialectConversion framework: Target + Patterns + TypeConverter
  • ClosureOpLowering: funlang.closure → GC_malloc + GEP + store 패턴
  • OpAdaptor: 변환된 operands 제공
  • ConversionPatternRewriter: IR 수정 인터페이스
  • C API shim: F#에서 pass 실행

다음 Section: funlang.apply lowering pattern 구현


ApplyOp Lowering Pattern

funlang.apply를 LLVM dialect로 lowering한다. Chapter 13의 간접 호출 패턴을 재사용한다.

Chapter 13 복습: 간접 호출 패턴

Closure application (Chapter 13):

func.func @apply(%f: !llvm.ptr, %x: i32) -> i32 {
    // 1. 환경에서 함수 포인터 로드 (env[0])
    %fn_ptr_addr = llvm.getelementptr %f[0] : (!llvm.ptr) -> !llvm.ptr
    %fn_ptr = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr

    // 2. 간접 호출 (fn_ptr를 통해 호출)
    // 첫 번째 인자: 환경 포인터 (%f)
    // 나머지 인자: 실제 인자 (%x)
    %result = llvm.call %fn_ptr(%f, %x) : (!llvm.ptr, i32) -> i32

    func.return %result : i32
}

핵심 단계:

  1. 함수 포인터 추출: env[0]에서 로드
  2. 인자 구성: [환경 포인터, 실제 인자들]
  3. 간접 호출: llvm.call %fn_ptr(...)

funlang.apply Operation (Chapter 15 복습)

ODS 정의:

def FunLang_ApplyOp : FunLang_Op<"apply"> {
  let summary = "Apply a closure to arguments";

  let arguments = (ins
    FunLang_ClosureType:$closure,
    Variadic<AnyType>:$args
  );

  let results = (outs AnyType:$result);

  let assemblyFormat = [{
    $closure `(` $args `)` attr-dict `:` functional-type($args, $result)
  }];
}

사용 예시:

// 인자 1개
%result = funlang.apply %closure(%x) : (i32) -> i32

// 인자 여러 개
%result = funlang.apply %closure(%x, %y) : (i32, i32) -> i32

// 인자 없음 (thunk)
%result = funlang.apply %closure() : () -> i32

ApplyOp Lowering 전략

입력: %result = funlang.apply %closure(%args...) : (...) -> result_type

출력: LLVM dialect operations

  1. 함수 포인터 추출: env[0]에서 로드
  2. 인자 리스트 구성: [closure, args...]
  3. 간접 호출: llvm.call %fn_ptr(...)
  4. 결과 반환: result_type로 변환

ApplyOpLowering 구현 (Complete)

struct ApplyOpLowering : public OpConversionPattern<funlang::ApplyOp> {
  using OpConversionPattern<funlang::ApplyOp>::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::ApplyOp op, OpAdaptor adaptor,
      ConversionPatternRewriter &rewriter) const override {

    auto loc = op.getLoc();
    auto ctx = rewriter.getContext();
    auto ptrType = LLVM::LLVMPointerType::get(ctx);

    // ==============================
    // 1. 함수 포인터 추출 (env[0])
    // ==============================
    Value closure = adaptor.getClosure();

    auto slot0 = rewriter.create<LLVM::GEPOp>(
        loc, ptrType, ptrType, closure,
        ArrayRef<LLVM::GEPArg>{0});

    auto fnPtr = rewriter.create<LLVM::LoadOp>(loc, ptrType, slot0);

    // ==============================
    // 2. 인자 리스트 구성
    // ==============================
    SmallVector<Value> callArgs;

    // 첫 번째 인자: 환경 포인터 (클로저 자체)
    callArgs.push_back(closure);

    // 나머지 인자: 실제 인자들
    callArgs.append(adaptor.getArgs().begin(), adaptor.getArgs().end());

    // ==============================
    // 3. 결과 타입 변환
    // ==============================
    Type resultType = getTypeConverter()->convertType(op.getResult().getType());

    // ==============================
    // 4. 간접 호출
    // ==============================
    auto call = rewriter.create<LLVM::CallOp>(
        loc, resultType, fnPtr, callArgs);

    // ==============================
    // 5. 원본 operation 교체
    // ==============================
    rewriter.replaceOp(op, call.getResult(0));
    return success();
  }
};

코드 상세 설명

1. 함수 포인터 추출

Value closure = adaptor.getClosure();

auto slot0 = rewriter.create<LLVM::GEPOp>(
    loc, ptrType, ptrType, closure,
    ArrayRef<LLVM::GEPArg>{0});

auto fnPtr = rewriter.create<LLVM::LoadOp>(loc, ptrType, slot0);
  • adaptor.getClosure(): 클로저 포인터 (이미 !llvm.ptr로 변환됨)
  • GEP: closure[0] 주소 계산 (함수 포인터 슬롯)
  • Load: 함수 포인터 로드

생성된 MLIR:

%slot0 = llvm.getelementptr %closure[0] : (!llvm.ptr) -> !llvm.ptr
%fn_ptr = llvm.load %slot0 : !llvm.ptr -> !llvm.ptr

2. 인자 리스트 구성

SmallVector<Value> callArgs;
callArgs.push_back(closure);  // 환경 포인터
callArgs.append(adaptor.getArgs().begin(), adaptor.getArgs().end());
  • 첫 번째 인자: 클로저 자체 (환경 포인터)
  • 나머지 인자: 실제 application 인자들

예시:

// funlang.apply %closure(%x, %y)
// callArgs = [%closure, %x, %y]

왜 closure를 첫 번째 인자로?

Lifted function은 환경 포인터를 첫 번째 파라미터로 받는다:

func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // %env에서 캡처된 변수 접근
    ...
}

3. 결과 타입 변환

Type resultType = getTypeConverter()->convertType(op.getResult().getType());
  • getTypeConverter(): Pattern에 연결된 TypeConverter
  • convertType(): FunLang 타입 → LLVM 타입

변환 예시:

// funlang.closure → !llvm.ptr
!funlang.closure  ->  !llvm.ptr

// 기본 타입은 그대로
i32  ->  i32
i64  ->  i64

왜 필요한가?

함수가 클로저를 반환할 수 있다:

// funlang.apply의 결과가 또 다른 클로저
%closure2 = funlang.apply %closure(%x) : (i32) -> !funlang.closure

// Lowering 후
%closure2 = llvm.call %fn_ptr(%closure, %x) : (!llvm.ptr, i32) -> !llvm.ptr

4. 간접 호출

auto call = rewriter.create<LLVM::CallOp>(
    loc, resultType, fnPtr, callArgs);
  • LLVM::CallOp: llvm.call operation
  • Callee: fnPtr (함수 포인터, Value)
  • 인자: callArgs (환경 + 실제 인자)
  • 반환 타입: resultType

일반 호출 vs 간접 호출:

// 일반 호출 (direct call)
%result = llvm.call @foo(%x) : (i32) -> i32

// 간접 호출 (indirect call)
%result = llvm.call %fn_ptr(%x) : (i32) -> i32

생성된 MLIR:

%result = llvm.call %fn_ptr(%closure, %x) : (!llvm.ptr, i32) -> i32

5. 원본 operation 교체

rewriter.replaceOp(op, call.getResult(0));
return success();
  • call.getResult(0): llvm.call의 반환 값
  • 교체: funlang.apply 결과를 llvm.call 결과로 대체

ApplyOpLowering 테스트

입력 MLIR:

func.func @test(%closure: !funlang.closure, %x: i32) -> i32 {
    %result = funlang.apply %closure(%x) : (i32) -> i32
    func.return %result : i32
}

Lowering pass 실행:

mlir-opt --funlang-to-llvm test.mlir

출력 MLIR:

func.func @test(%closure: !llvm.ptr, %x: i32) -> i32 {
    %0 = llvm.getelementptr %closure[0] : (!llvm.ptr) -> !llvm.ptr
    %1 = llvm.load %0 : !llvm.ptr -> !llvm.ptr
    %2 = llvm.call %1(%closure, %x) : (!llvm.ptr, i32) -> i32
    func.return %2 : i32
}

검증:

  1. funlang.apply 사라짐 ✓
  2. GEP + load로 함수 포인터 추출 ✓
  3. 간접 호출 (llvm.call %fn_ptr) ✓
  4. 인자 리스트 올바름 (%closure, %x) ✓

End-to-End 예시: makeAdder

Phase 5 FunLang dialect:

func.func @make_adder(%n: i32) -> !funlang.closure {
    %closure = funlang.closure @lambda_adder, %n : !funlang.closure
    func.return %closure : !funlang.closure
}

func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

func.func @main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32

    // makeAdder 5
    %add5 = funlang.closure @lambda_adder, %c5 : !funlang.closure

    // add5 10
    %result = funlang.apply %add5(%c10) : (i32) -> i32

    func.return %result : i32
}

After FunLangToLLVM pass:

func.func @make_adder(%n: i32) -> !llvm.ptr {
    // ClosureOpLowering
    %c16 = arith.constant 16 : i64
    %env = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr
    %fn_ptr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %slot0 = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr
    %slot1 = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %slot1 : i32, !llvm.ptr
    func.return %env : !llvm.ptr
}

func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    %result = arith.addi %x, %n : i32
    func.return %result : i32
}

func.func @main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32

    // ClosureOpLowering
    %c16 = arith.constant 16 : i64
    %add5 = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr
    %fn_ptr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %slot0 = llvm.getelementptr %add5[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr
    %slot1 = llvm.getelementptr %add5[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %c5, %slot1 : i32, !llvm.ptr

    // ApplyOpLowering
    %fn_ptr_addr = llvm.getelementptr %add5[0] : (!llvm.ptr) -> !llvm.ptr
    %fn_ptr_loaded = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr_loaded(%add5, %c10) : (!llvm.ptr, i32) -> i32

    func.return %result : i32
}

실행 흐름 추적:

  1. %c5, %c10 상수 생성
  2. Closure 생성 (ClosureOpLowering):
    • GC_malloc(16)%add5 (환경 포인터)
    • %add5[0] = @lambda_adder (함수 포인터)
    • %add5[1] = 5 (캡처된 n)
  3. Closure 호출 (ApplyOpLowering):
    • %add5[0] 로드 → %fn_ptr_loaded (함수 포인터)
    • llvm.call %fn_ptr_loaded(%add5, 10)
  4. lambda_adder 실행:
    • %env[1] 로드 → %n = 5
    • 10 + 5 = 15
    • 반환: 15

TypeConverter for FunLang Types

TypeConverter는 FunLang 타입을 LLVM 타입으로 변환한다.

FunLang Custom Types (Chapter 15)

1. funlang.closure:

def FunLang_ClosureType : FunLang_Type<"Closure"> {
  let mnemonic = "closure";
  let description = "FunLang closure type (function pointer + environment)";
}

MLIR 표기: !funlang.closure

2. funlang.list (Phase 6 preview):

def FunLang_ListType : FunLang_Type<"List"> {
  let mnemonic = "list";
  let parameters = (ins "Type":$elementType);
  let assemblyFormat = "`<` $elementType `>`";
}

MLIR 표기: !funlang.list<i32>, !funlang.list<!funlang.closure>

TypeConverter 구성

TypeConverter typeConverter;

// ==============================
// 1. FunLang 타입 변환
// ==============================

// funlang.closure → !llvm.ptr
typeConverter.addConversion([&](funlang::ClosureType type) {
    return LLVM::LLVMPointerType::get(type.getContext());
});

// funlang.list<T> → !llvm.ptr
typeConverter.addConversion([&](funlang::ListType type) {
    return LLVM::LLVMPointerType::get(type.getContext());
});

// ==============================
// 2. 기본 타입은 그대로
// ==============================
typeConverter.addConversion([](Type type) {
    // i32, i64, i1 등은 변환하지 않음
    return type;
});

변환 예시:

BeforeAfter
!funlang.closure!llvm.ptr
!funlang.list<i32>!llvm.ptr
i32i32
i64i64

Function Signature 변환

TypeConverter는 자동으로 function signatures를 변환한다.

Before:

func.func @apply(%f: !funlang.closure, %x: i32) -> i32 {
    %result = funlang.apply %f(%x) : (i32) -> i32
    func.return %result : i32
}

After:

func.func @apply(%f: !llvm.ptr, %x: i32) -> i32 {
    %0 = llvm.getelementptr %f[0] : (!llvm.ptr) -> !llvm.ptr
    %1 = llvm.load %0 : !llvm.ptr -> !llvm.ptr
    %2 = llvm.call %1(%f, %x) : (!llvm.ptr, i32) -> i32
    func.return %2 : i32
}

변환 지점:

  • 파라미터 타입: %f: !funlang.closure%f: !llvm.ptr
  • 반환 타입: 여기서는 i32 (변환 없음)
  • Operation result 타입: funlang.apply 결과 타입 변환

Materialization: 타입 변환 보조

Materialization은 타입 변환 중간에 필요한 “접착제” operations을 삽입한다.

사용 사례: Conversion이 여러 단계로 나뉠 때, 중간 타입 불일치 해결.

Source Materialization

typeConverter.addSourceMaterialization(
    [](OpBuilder &builder, Type resultType, ValueRange inputs, Location loc) -> Value {
      // 원본 타입 (FunLang) → 중간 타입 변환
      if (resultType.isa<funlang::ClosureType>()) {
        return builder.create<UnrealizedConversionCastOp>(loc, resultType, inputs).getResult(0);
      }
      return nullptr;
    });

Target Materialization

typeConverter.addTargetMaterialization(
    [](OpBuilder &builder, Type resultType, ValueRange inputs, Location loc) -> Value {
      // 중간 타입 → 대상 타입 (LLVM) 변환
      if (resultType.isa<LLVM::LLVMPointerType>()) {
        return builder.create<UnrealizedConversionCastOp>(loc, resultType, inputs).getResult(0);
      }
      return nullptr;
    });

unrealized_conversion_cast

Materialization이 생성하는 operation:

%cast = builtin.unrealized_conversion_cast %input : !funlang.closure to !llvm.ptr

의미: “이 타입 변환은 아직 완료되지 않았다”

최종 lowering 후:

  • 모든 unrealized_conversion_cast는 사라져야 한다
  • 남아있으면 conversion failure

Phase 5에서는 단순한 변환이므로 materialization 불필요:

  • funlang.closure!llvm.ptr (direct mapping)
  • 중간 타입 없음

타입 변환 체인

Multi-stage lowering에서 타입 변환 체인:

Phase 5 FunLang dialect:
  !funlang.closure

Phase 5a (optional): High-level abstractions
  !funlang.env_ptr  (환경 포인터 전용 타입)

Phase 5b (final): LLVM dialect
  !llvm.ptr

현재 Phase 5 (단순 버전):

!funlang.closure  →  !llvm.ptr  (direct)

TypeConverter 체인 예시 (multi-stage):

// Stage 1: FunLang → HighLevel
TypeConverter highLevelConverter;
highLevelConverter.addConversion([](funlang::ClosureType type) {
    return funlang::EnvPtrType::get(type.getContext());
});

// Stage 2: HighLevel → LLVM
TypeConverter llvmConverter;
llvmConverter.addConversion([](funlang::EnvPtrType type) {
    return LLVM::LLVMPointerType::get(type.getContext());
});

Declarative Rewrite Rules (DRR)

**DRR (Declarative Rewrite Rules)**은 TableGen 기반 패턴 매칭 시스템이다. C++ ConversionPattern보다 간단한 변환을 선언적으로 작성할 수 있다.

DRR이란?

DRR은 MLIR의 패턴 매칭 DSL이다:

  • 입력: .td 파일에 패턴 작성
  • 출력: C++ 코드 자동 생성 (mlir-tblgen)
  • 용도: 최적화, 정규화, 간단한 lowering

DRR vs C++ ConversionPattern:

AspectDRRC++ ConversionPattern
문법선언적 (TableGen)명령형 (C++)
복잡도간단한 패턴복잡한 로직 가능
제어 흐름없음 (순수 매칭)if/for/while 가능
타입 안전성컴파일 타임런타임 검증
디버깅어려움쉬움 (breakpoint)

언제 DRR을 사용하는가?

  • ✓ 1:1 operation 변환 (A → B)
  • ✓ 간단한 패턴 매칭 (조건 1-2개)
  • ✓ 최적화 패턴 (constant folding, peephole)

언제 C++를 사용하는가?

  • ✓ 복잡한 변환 로직 (ClosureOpLowering처럼 여러 ops 생성)
  • ✓ 동적 계산 (환경 크기 계산)
  • ✓ 제어 흐름 (for loop으로 캡처 변수 처리)

DRR 문법 기초

Pat (Pattern) 정의:

def PatternName : Pat<
  (SourcePattern),   // 매치할 패턴
  (TargetPattern),   // 교체할 패턴
  [(Constraint)]     // 추가 제약 (optional)
>;

예시: 상수 폴딩

def AddZero : Pat<
  (Arith_AddIOp $x, (Arith_ConstantOp ConstantAttr<I32Attr, "0">)),
  (replaceWithValue $x)
>;

의미: x + 0x

DRR 예시 1: Empty Closure 최적화

최적화 목표:

캡처 변수가 없는 클로저는 함수 포인터만 필요하다. 환경 할당 불필요.

Before:

// 캡처 없음
%closure = funlang.closure @foo : !funlang.closure

// Lowering 후 (불필요한 GC_malloc!)
%env = llvm.call @GC_malloc(%c8) : (i64) -> !llvm.ptr
%fn_ptr = llvm.mlir.addressof @foo : !llvm.ptr
llvm.store %fn_ptr, %env[0] : !llvm.ptr

After (최적화):

// 함수 포인터만 사용
%fn_ptr = llvm.mlir.addressof @foo : !llvm.ptr

// apply에서 직접 함수 포인터 사용
%result = llvm.call @foo(%null_env, %x) : (!llvm.ptr, i32) -> i32

DRR 패턴:

def SimplifyEmptyClosure : Pat<
  // Match: funlang.closure with no captured variables
  (FunLang_ClosureOp:$result $callee, (variadic)),

  // Replace: function reference (Phase 6에 FuncRefOp 추가 필요)
  (FunLang_FuncRefOp $callee),

  // Constraint: captured variables must be empty
  [(Constraint<CPred<"$0.empty()">, "$result.getCaptured()">)]
>;

설명:

  • (variadic): 가변 인자 (0개 이상)
  • CPred<"$0.empty()">: C++ predicate - 첫 번째 인자가 비어있는가?
  • FuncRefOp: 함수 참조만 담는 operation (Phase 6에서 추가 예정)

DRR 예시 2: Known Closure Inlining

최적화 목표:

클로저 생성 직후 호출하면 인라인 가능.

Before:

// 클로저 생성 후 즉시 호출
%closure = funlang.closure @lambda, %n : !funlang.closure
%result = funlang.apply %closure(%x) : (i32) -> i32

After (최적화):

// 직접 호출 (환경 할당 불필요)
%result = func.call @lambda(%n, %x) : (i32, i32) -> i32

DRR 패턴:

def InlineKnownApply : Pat<
  // Match: apply (closure @callee, $captures) ($args)
  (FunLang_ApplyOp
    (FunLang_ClosureOp:$closure $callee, $captures),
    $args),

  // Replace: direct call @callee (concat $captures and $args)
  (Func_CallOp $callee, (ConcatValues $captures, $args))
>;

설명:

  • $captures: 캡처된 변수들 (variadic)
  • $args: apply 인자들 (variadic)
  • ConcatValues: 두 리스트 합치기 (DRR helper)
  • Func_CallOp: 직접 호출 operation

제약:

이 패턴은 클로저가 escape하지 않을 때만 안전하다:

// OK: 즉시 호출
%result = funlang.apply (funlang.closure @f, %n) (%x)

// NOT OK: 클로저가 반환됨 (인라인 불가!)
func.func @make_adder(%n: i32) -> !funlang.closure {
    %closure = funlang.closure @f, %n : !funlang.closure
    func.return %closure  // Escape!
}

DRR로 escape 검사 불가 → C++ ConversionPattern 필요

DRR 예시 3: Constant Propagation

최적화 목표:

클로저가 상수만 캡처하면 compile-time에 처리 가능.

Before:

%c5 = arith.constant 5 : i32
%closure = funlang.closure @lambda, %c5 : !funlang.closure

After (최적화):

// lambda 함수 내부에서 %c5를 직접 사용하도록 인라인
// (복잡한 변환이므로 DRR보다 C++가 적합)

DRR 한계:

  • 함수 본문 수정 필요 (DRR은 local pattern만 매칭)
  • Whole-program analysis 필요 (DRR은 single operation 매칭)

결론: 이런 최적화는 C++ pass로 구현해야 함.

mlir-tblgen으로 DRR 컴파일

1. DRR 패턴 작성:

// FunLangPatterns.td
include "mlir/IR/PatternBase.td"
include "FunLangOps.td"

def SimplifyEmptyClosure : Pat<
  (FunLang_ClosureOp:$result $callee, (variadic)),
  (FunLang_FuncRefOp $callee),
  [(Constraint<CPred<"$0.empty()">, "$result.getCaptured()">)]
>;

2. mlir-tblgen 실행:

mlir-tblgen -gen-rewriters FunLangPatterns.td -o FunLangPatterns.cpp.inc

3. 생성된 C++ 코드:

// FunLangPatterns.cpp.inc
struct SimplifyEmptyClosure : public RewritePattern {
  SimplifyEmptyClosure(MLIRContext *context)
      : RewritePattern(ClosureOp::getOperationName(), 1, context) {}

  LogicalResult matchAndRewrite(Operation *op, PatternRewriter &rewriter) const override {
    auto closureOp = cast<ClosureOp>(op);

    // Constraint: captured variables empty
    if (!closureOp.getCaptured().empty())
      return failure();

    // Rewrite: create FuncRefOp
    rewriter.replaceOpWithNewOp<FuncRefOp>(op, closureOp.getCalleeAttr());
    return success();
  }
};

4. Pass에 등록:

void populateFunLangOptimizationPatterns(RewritePatternSet &patterns) {
  patterns.add<SimplifyEmptyClosure>(patterns.getContext());
  // ... other patterns
}

DRR vs C++ ConversionPattern 비교 요약

ClosureOpLowering을 DRR로 작성하면?

// 불가능! DRR로는 표현 못함
def LowerClosure : Pat<
  (FunLang_ClosureOp $callee, $captured),
  (??? 어떻게 for loop을 표현?)  // 캡처 변수 개수만큼 GEP + store
>;

왜 불가능?

  • DRR은 fixed-size patterns만 매칭
  • 가변 개수의 operations 생성 불가 (for loop 없음)
  • 동적 계산 불가 (환경 크기 계산)

결론:

  • DRR: 간단한 최적화 패턴 (peephole, constant folding)
  • C++ ConversionPattern: 복잡한 lowering (ClosureOp, ApplyOp)

Complete Lowering Pass

FunLangToLLVMPass는 FunLang dialect를 LLVM dialect로 lowering하는 완전한 pass다.

Pass 정의

// FunLangToLLVMPass.cpp
#include "mlir/Conversion/LLVMCommon/Pattern.h"
#include "mlir/Dialect/LLVMIR/LLVMDialect.h"
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Pass/Pass.h"
#include "mlir/Transforms/DialectConversion.h"
#include "FunLang/FunLangDialect.h"
#include "FunLang/FunLangOps.h"

namespace {

struct FunLangToLLVMPass
    : public PassWrapper<FunLangToLLVMPass, OperationPass<ModuleOp>> {

  // ==============================
  // Pass metadata
  // ==============================
  StringRef getArgument() const final {
    return "funlang-to-llvm";
  }

  StringRef getDescription() const final {
    return "Lower FunLang dialect to LLVM dialect";
  }

  // ==============================
  // Dependent dialects
  // ==============================
  void getDependentDialects(DialectRegistry &registry) const override {
    registry.insert<LLVM::LLVMDialect>();
    registry.insert<func::FuncDialect>();
    registry.insert<arith::ArithDialect>();
  }

  // ==============================
  // Pass execution
  // ==============================
  void runOnOperation() override {
    // Get module operation
    ModuleOp module = getOperation();
    MLIRContext *ctx = &getContext();

    // ------------------------------
    // 1. Setup ConversionTarget
    // ------------------------------
    ConversionTarget target(*ctx);

    // Legal: LLVM, func, arith dialects
    target.addLegalDialect<LLVM::LLVMDialect>();
    target.addLegalDialect<func::FuncDialect>();
    target.addLegalDialect<arith::ArithDialect>();

    // Illegal: FunLang dialect (must be lowered)
    target.addIllegalDialect<funlang::FunLangDialect>();

    // ------------------------------
    // 2. Setup TypeConverter
    // ------------------------------
    TypeConverter typeConverter;

    // FunLang types → LLVM types
    typeConverter.addConversion([&](funlang::ClosureType type) {
      return LLVM::LLVMPointerType::get(ctx);
    });

    typeConverter.addConversion([&](funlang::ListType type) {
      return LLVM::LLVMPointerType::get(ctx);
    });

    // Default: keep type as-is (i32, i64, etc.)
    typeConverter.addConversion([](Type type) {
      return type;
    });

    // ------------------------------
    // 3. Setup RewritePatternSet
    // ------------------------------
    RewritePatternSet patterns(ctx);

    // Add lowering patterns
    patterns.add<ClosureOpLowering>(ctx, typeConverter);
    patterns.add<ApplyOpLowering>(ctx, typeConverter);

    // ------------------------------
    // 4. Apply conversion
    // ------------------------------
    if (failed(applyPartialConversion(module, target, std::move(patterns)))) {
      signalPassFailure();
      return;
    }
  }
};

} // namespace

// ==============================
// Pass registration
// ==============================
void registerFunLangToLLVMPass() {
  PassRegistration<FunLangToLLVMPass>();
}

Pass 구성 요소 설명

1. PassWrapper 템플릿

struct FunLangToLLVMPass
    : public PassWrapper<FunLangToLLVMPass, OperationPass<ModuleOp>> {
  • PassWrapper<Self, Base>: CRTP 패턴
  • OperationPass<ModuleOp>: Module 레벨 pass (전체 IR 처리)

다른 pass 레벨:

  • OperationPass<func::FuncOp>: Function 레벨 (함수별 처리)
  • OperationPass<>: 모든 operation에 대해

2. getDependentDialects

void getDependentDialects(DialectRegistry &registry) const override {
  registry.insert<LLVM::LLVMDialect>();
  registry.insert<func::FuncDialect>();
  registry.insert<arith::ArithDialect>();
}

역할: Pass가 사용할 dialects를 등록한다.

왜 필요?

  • MLIR은 lazy dialect loading 사용
  • Pass가 LLVM::CallOp을 생성하려면 LLVMDialect 로드 필요
  • 명시적 등록으로 dependency 보장

3. runOnOperation

void runOnOperation() override {
  ModuleOp module = getOperation();
  // ... conversion logic
}

역할: Pass의 핵심 로직.

실행 흐름:

  1. Target 설정 (legal/illegal dialects)
  2. TypeConverter 설정 (타입 변환 규칙)
  3. Patterns 구성 (lowering patterns)
  4. Conversion 실행 (applyPartialConversion)
  5. 실패 시 signalPassFailure()

Pass 등록

void registerFunLangToLLVMPass() {
  PassRegistration<FunLangToLLVMPass>();
}

// 초기화 함수에서 호출
void registerFunLangPasses() {
  registerFunLangToLLVMPass();
  // ... other passes
}

등록 후 사용:

mlir-opt --funlang-to-llvm input.mlir -o output.mlir

C API Shim

C++ pass를 F#에서 사용하려면 C API가 필요하다.

// FunLangCAPI.cpp
#include "mlir-c/IR.h"
#include "mlir/CAPI/Wrap.h"
#include "mlir/Pass/PassManager.h"
#include "FunLangPasses.h"

extern "C" {

// Pass 등록
MLIR_CAPI_EXPORTED void mlirFunLangRegisterToLLVMPass() {
  registerFunLangToLLVMPass();
}

// Pass 실행
MLIR_CAPI_EXPORTED void mlirFunLangRunToLLVMPass(MlirModule module) {
  ModuleOp moduleOp = unwrap(module);
  MLIRContext *ctx = moduleOp.getContext();

  PassManager pm(ctx);
  pm.addPass(std::make_unique<FunLangToLLVMPass>());

  if (failed(pm.run(moduleOp))) {
    llvm::errs() << "FunLangToLLVMPass failed\n";
  }
}

} // extern "C"

헬퍼 함수:

// Wrap/unwrap helpers (MLIR-C API)
static inline ModuleOp unwrap(MlirModule module) {
  return ::mlir::unwrap(module);
}

static inline MlirModule wrap(ModuleOp module) {
  return ::mlir::wrap(module);
}

F# P/Invoke

// Mlir.FunLang.fs
module Mlir.FunLang

open System.Runtime.InteropServices

// ==============================
// P/Invoke declarations
// ==============================

[<DllImport("funlang-dialect", CallingConvention = CallingConvention.Cdecl)>]
extern void mlirFunLangRegisterToLLVMPass()

[<DllImport("funlang-dialect", CallingConvention = CallingConvention.Cdecl)>]
extern void mlirFunLangRunToLLVMPass(MlirModule module)

// ==============================
// F# wrapper functions
// ==============================

/// Initialize FunLang passes (call once at startup)
let initializePasses () =
    mlirFunLangRegisterToLLVMPass()

/// Lower FunLang dialect to LLVM dialect
let lowerToLLVM (module_: MlirModule) =
    mlirFunLangRunToLLVMPass(module_)

F#에서 Pass 사용

// CompilerPipeline.fs
open Mlir
open Mlir.FunLang

// 초기화 (프로그램 시작 시 1회)
FunLang.initializePasses()

// 컴파일 파이프라인
let compileToExecutable (source: string) =
    // 1. Parse & build AST
    let ast = Parser.parse source

    // 2. Generate FunLang dialect MLIR
    use ctx = Mlir.createContext()
    use module_ = Mlir.createModule(ctx)
    use builder = Mlir.createOpBuilder(ctx)

    // ... code generation (Chapter 15)

    // 3. Lower FunLang → LLVM
    FunLang.lowerToLLVM(module_)

    // 4. Lower other dialects → LLVM
    Mlir.runPass(module_, "convert-arith-to-llvm")
    Mlir.runPass(module_, "convert-func-to-llvm")

    // 5. Translate LLVM dialect → LLVM IR
    let llvmIR = Mlir.translateToLLVMIR(module_)

    // 6. Compile & link
    let objFile = LLVMCompiler.compile(llvmIR)
    let executable = Linker.link([objFile; "runtime.o"], "gc")

    executable

End-to-End Example

makeAdder 함수를 전체 파이프라인으로 추적한다.

Source Code

// FunLang source
let makeAdder n =
    fun x -> x + n

let add5 = makeAdder 5
let result = add5 10

Stage 1: AST

type Expr =
    | Let of string * Expr * Expr
    | Lambda of string * Expr
    | App of Expr * Expr
    | BinOp of Operator * Expr * Expr
    | Var of string
    | Const of int

// makeAdder AST
Let ("makeAdder",
     Lambda ("n", Lambda ("x", BinOp (Add, Var "x", Var "n"))),
     Let ("add5",
          App (Var "makeAdder", Const 5),
          Let ("result",
               App (Var "add5", Const 10),
               Var "result")))

Stage 2: FunLang Dialect MLIR (Chapter 15)

module {
  // lifted lambda: fun x -> x + n
  func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    // Load captured n from env[1]
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32

    // x + n
    %result = arith.addi %x, %n : i32
    func.return %result : i32
  }

  // makeAdder function
  func.func @makeAdder(%n: i32) -> !funlang.closure {
    %closure = funlang.closure @lambda_adder, %n : !funlang.closure
    func.return %closure : !funlang.closure
  }

  // main function
  func.func @funlang_main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32

    // makeAdder 5
    %add5 = funlang.closure @lambda_adder, %c5 : !funlang.closure

    // add5 10
    %result = funlang.apply %add5(%c10) : (i32) -> i32

    func.return %result : i32
  }
}

Stage 3: After FunLangToLLVM Pass (Chapter 16)

module {
  // lambda_adder (unchanged)
  func.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
    %n_slot = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    %n = llvm.load %n_slot : !llvm.ptr -> i32
    %result = arith.addi %x, %n : i32
    func.return %result : i32
  }

  // makeAdder (funlang.closure lowered)
  func.func @makeAdder(%n: i32) -> !llvm.ptr {
    %c16 = arith.constant 16 : i64
    %env = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr
    %fn_ptr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %slot0 = llvm.getelementptr %env[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr
    %slot1 = llvm.getelementptr %env[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %n, %slot1 : i32, !llvm.ptr
    func.return %env : !llvm.ptr
  }

  // main (both funlang operations lowered)
  func.func @funlang_main() -> i32 {
    %c5 = arith.constant 5 : i32
    %c10 = arith.constant 10 : i32

    // ClosureOpLowering
    %c16 = arith.constant 16 : i64
    %add5 = llvm.call @GC_malloc(%c16) : (i64) -> !llvm.ptr
    %fn_ptr = llvm.mlir.addressof @lambda_adder : !llvm.ptr
    %slot0 = llvm.getelementptr %add5[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %fn_ptr, %slot0 : !llvm.ptr, !llvm.ptr
    %slot1 = llvm.getelementptr %add5[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %c5, %slot1 : i32, !llvm.ptr

    // ApplyOpLowering
    %fn_ptr_addr = llvm.getelementptr %add5[0] : (!llvm.ptr) -> !llvm.ptr
    %fn_ptr_loaded = llvm.load %fn_ptr_addr : !llvm.ptr -> !llvm.ptr
    %result = llvm.call %fn_ptr_loaded(%add5, %c10) : (!llvm.ptr, i32) -> i32

    func.return %result : i32
  }
}

Stage 4: After convert-arith-to-llvm

// arith.constant → llvm.mlir.constant
%c5 = llvm.mlir.constant(5 : i32) : i32
%c10 = llvm.mlir.constant(10 : i32) : i32
%c16 = llvm.mlir.constant(16 : i64) : i64

// arith.addi → llvm.add
%result = llvm.add %x, %n : i32

Stage 5: After convert-func-to-llvm

// func.func → llvm.func
llvm.func @lambda_adder(%env: !llvm.ptr, %x: i32) -> i32 {
  ...
  llvm.return %result : i32
}

// func.call → llvm.call (already indirect, no change)
%result = llvm.call %fn_ptr_loaded(%add5, %c10) : (!llvm.ptr, i32) -> i32

Stage 6: LLVM IR (mlir-translate –mlir-to-llvmir)

define i32 @lambda_adder(ptr %env, i32 %x) {
  %n_slot = getelementptr ptr, ptr %env, i32 1
  %n = load i32, ptr %n_slot
  %result = add i32 %x, %n
  ret i32 %result
}

define ptr @makeAdder(i32 %n) {
  %env = call ptr @GC_malloc(i64 16)
  %fn_ptr = ptrtoint ptr @lambda_adder to i64
  %slot0 = getelementptr ptr, ptr %env, i32 0
  store i64 %fn_ptr, ptr %slot0
  %slot1 = getelementptr ptr, ptr %env, i32 1
  store i32 %n, ptr %slot1
  ret ptr %env
}

define i32 @funlang_main() {
  %c5 = 5
  %c10 = 10

  ; Closure creation
  %add5 = call ptr @GC_malloc(i64 16)
  %fn_ptr = ptrtoint ptr @lambda_adder to i64
  %slot0 = getelementptr ptr, ptr %add5, i32 0
  store i64 %fn_ptr, ptr %slot0
  %slot1 = getelementptr ptr, ptr %add5, i32 1
  store i32 %c5, ptr %slot1

  ; Closure application
  %fn_ptr_addr = getelementptr ptr, ptr %add5, i32 0
  %fn_ptr_loaded = load ptr, ptr %fn_ptr_addr
  %result = call i32 %fn_ptr_loaded(ptr %add5, i32 %c10)

  ret i32 %result
}

Stage 7: Native Code (llc → object file → executable)

# LLVM IR → object file
llc output.ll -o output.o -filetype=obj

# Link with runtime
clang output.o runtime.o -lgc -o program

# Run
./program
# Output: 15

컴파일 파이프라인 다이어그램

┌─────────────────┐
│  FunLang Source │  let makeAdder n = fun x -> x + n
└────────┬────────┘
         │ Parser
         ▼
┌─────────────────┐
│       AST       │  Lambda, App, BinOp nodes
└────────┬────────┘
         │ CodeGen (Chapter 15)
         ▼
┌─────────────────┐
│ FunLang Dialect │  funlang.closure, funlang.apply
│      MLIR       │
└────────┬────────┘
         │ FunLangToLLVM Pass (Chapter 16) ★
         ▼
┌─────────────────┐
│  LLVM Dialect   │  llvm.call, llvm.getelementptr, llvm.store
│      MLIR       │
└────────┬────────┘
         │ convert-arith-to-llvm
         │ convert-func-to-llvm
         ▼
┌─────────────────┐
│  LLVM Dialect   │  All dialects → LLVM dialect
│ (fully lowered) │
└────────┬────────┘
         │ mlir-translate --mlir-to-llvmir
         ▼
┌─────────────────┐
│    LLVM IR      │  %.1 = call ptr @GC_malloc(i64 16)
└────────┬────────┘
         │ llc
         ▼
┌─────────────────┐
│  Object File    │  .o binary
└────────┬────────┘
         │ clang (link)
         ▼
┌─────────────────┐
│   Executable    │  ./program
└─────────────────┘

Common Errors

Lowering pass 구현 중 자주 발생하는 에러와 해결 방법.

Error 1: Illegal Operation Remaining

증상:

error: failed to legalize operation 'funlang.closure' that was explicitly marked illegal
note: see current operation: %0 = "funlang.closure"() {callee = @foo, ...}

원인:

  • Pattern이 등록되지 않음
  • Pattern이 매치 실패 (matchAndRewrite에서 failure() 리턴)
  • Target에 illegal로 설정했지만 pattern 없음

해결:

  1. Pattern 등록 확인:
RewritePatternSet patterns(ctx);
patterns.add<ClosureOpLowering>(ctx, typeConverter);  // 추가했는가?
  1. Pattern 매치 조건 확인:
LogicalResult matchAndRewrite(...) const override {
  // 디버그 출력
  llvm::errs() << "ClosureOpLowering matched\n";

  // ... lowering logic

  return success();  // failure() 리턴하지 않았는가?
}
  1. Target 설정 확인:
target.addIllegalDialect<funlang::FunLangDialect>();  // Illegal로 설정

Error 2: Type Conversion Failure

증상:

error: type conversion failed for block argument #0
note: see current operation: func.func @foo(%arg0: !funlang.closure) -> i32

원인:

  • TypeConverter에 변환 규칙 없음
  • 변환 규칙이 nullptr 리턴

해결:

  1. TypeConverter에 규칙 추가:
typeConverter.addConversion([&](funlang::ClosureType type) {
    return LLVM::LLVMPointerType::get(ctx);
});
  1. 변환 실패 체크:
typeConverter.addConversion([&](funlang::ClosureType type) -> std::optional<Type> {
    if (!isConvertible(type))
        return std::nullopt;  // 변환 불가

    return LLVM::LLVMPointerType::get(ctx);
});

Error 3: Wrong Operand Types

증상:

error: 'llvm.store' op types mismatch between stored value and pointee type
note: stored value: i32, pointee type: !llvm.ptr

원인:

  • Store operation에 타입 불일치
  • GEP 결과를 잘못 사용

해결:

  1. Store 타입 확인:
// 잘못됨: i32를 !llvm.ptr 슬롯에 저장
rewriter.create<LLVM::StoreOp>(loc, i32Value, ptrSlot);

// 올바름: 타입 일치
rewriter.create<LLVM::StoreOp>(loc, i32Value, i32Slot);
  1. GEP 사용 확인:
// 올바른 GEP 패턴
%slot = llvm.getelementptr %ptr[1] : (!llvm.ptr) -> !llvm.ptr

Error 4: Pass Not Registered

증상:

$ mlir-opt --funlang-to-llvm test.mlir
error: unknown command line flag '--funlang-to-llvm'

원인:

  • Pass 등록 함수가 호출되지 않음

해결:

  1. Pass 등록 확인:
// 초기화 코드에서 호출
void initializeMLIR() {
  registerFunLangDialect();
  registerFunLangToLLVMPass();  // 등록 함수 호출
}
  1. C API shim 확인:
extern "C" void mlirFunLangRegisterToLLVMPass() {
  registerFunLangToLLVMPass();
}
  1. F# 초기화 확인:
// 프로그램 시작 시 호출
FunLang.initializePasses()

Error 5: Segmentation Fault in Pattern

증상:

Segmentation fault (core dumped)

원인:

  • rewriter 대신 일반 builder 사용
  • Null pointer dereference
  • Use-after-free (op 삭제 후 접근)

해결:

  1. 항상 rewriter 사용:
// 잘못됨!
OpBuilder builder(ctx);
builder.create<...>();

// 올바름
rewriter.create<...>();
  1. Op 삭제 후 접근 금지:
// 잘못됨!
rewriter.replaceOp(op, newValue);
auto attr = op.getAttr("foo");  // Use-after-free!

// 올바름
auto attr = op.getAttr("foo");  // 먼저 읽기
rewriter.replaceOp(op, newValue);
  1. Null 체크:
Value closure = adaptor.getClosure();
if (!closure) {
  return failure();
}

Summary

Chapter 16에서 배운 것:

1. DialectConversion Framework

  • ConversionTarget: Legal/illegal operations 정의
  • RewritePatternSet: 변환 규칙 집합
  • TypeConverter: 타입 변환 규칙
  • applyPartialConversion: 부분 변환 실행

2. ClosureOp Lowering Pattern

  • funlang.closure → GC_malloc + GEP + store
  • Chapter 12 클로저 생성 패턴 재사용
  • OpAdaptor로 변환된 operands 접근
  • ConversionPatternRewriter로 IR 수정

3. ApplyOp Lowering Pattern

  • funlang.apply → GEP + load + llvm.call (indirect)
  • Chapter 13 간접 호출 패턴 재사용
  • 인자 리스트 구성 (환경 포인터 + 실제 인자)
  • TypeConverter로 결과 타입 변환

4. TypeConverter for FunLang Types

  • !funlang.closure!llvm.ptr
  • !funlang.list<T>!llvm.ptr
  • Function signatures 자동 변환
  • Materialization (optional)

5. Declarative Rewrite Rules (DRR)

  • TableGen 기반 패턴 매칭
  • 간단한 최적화 패턴 (empty closure, known closure inlining)
  • DRR vs C++ ConversionPattern 비교
  • mlir-tblgen으로 C++ 코드 생성

6. Complete Lowering Pass

  • FunLangToLLVMPass 구현
  • Pass 등록 및 실행
  • C API shim for F# integration
  • F# wrapper functions

7. End-to-End Example

  • makeAdder: FunLang source → LLVM IR → executable
  • 전체 컴파일 파이프라인 추적
  • 각 단계별 IR 확인

8. Common Errors

  • Illegal operation remaining
  • Type conversion failure
  • Wrong operand types
  • Pass not registered
  • Segmentation fault

Phase 5 완료!

  • Chapter 14: Custom dialect design theory
  • Chapter 15: Custom operations implementation (funlang.closure, funlang.apply)
  • Chapter 16: Lowering passes (FunLangToLLVM)

코드 압축 효과:

AspectBefore (Phase 4)After (Phase 5)
Closure creation12 lines1 line
Closure application8 lines1 line
Compiler code~200 lines~100 lines
타입 안전성!llvm.ptr (opaque)!funlang.closure (typed)
최적화 가능성어려움쉬움 (DRR patterns)

Phase 6 Preview: Pattern Matching

다음 Phase에서는 패턴 매칭을 추가한다:

// List operations
let rec length list =
    match list with
    | [] -> 0
    | head :: tail -> 1 + length tail

새로운 operations:

  • funlang.match: 패턴 매칭
  • funlang.nil: 빈 리스트
  • funlang.cons: 리스트 생성
  • funlang.list_head, funlang.list_tail: 리스트 접근

새로운 lowering patterns:

  • funlang.matchscf.if + llvm.switch (복잡한 제어 흐름)
  • SCF dialect를 거친 multi-stage lowering

Phase 5와 Phase 6의 차이:

  • Phase 5: FunLang → LLVM (direct lowering)
  • Phase 6: FunLang → SCF → LLVM (multi-stage lowering)

Chapter 16 완료! 이제 custom dialect를 설계하고, operations를 정의하고, lowering passes를 구현할 수 있다. FunLang 컴파일러는 high-level 추상화와 low-level 성능을 모두 제공한다.

Chapter 17: Pattern Matching Theory (Pattern Matching Theory)

소개

Phase 6이 시작된다. Phase 5에서 커스텀 MLIR dialect를 구축했다. funlang.closurefunlang.apply로 클로저를 추상화했고, lowering pass로 LLVM dialect로 변환했다. 이제 함수형 언어의 핵심 기능을 추가할 시간이다: **패턴 매칭(pattern matching)**과 데이터 구조(data structures).

Phase 6 로드맵

Phase 6: Pattern Matching & Data Structures

이번 phase에서 구현할 내용:

  1. Chapter 17 (현재): Pattern matching 이론 - Decision tree 알고리즘
  2. Chapter 18: List operations - funlang.nil, funlang.cons 구현
  3. Chapter 19: Match compilation - funlang.match operation과 lowering
  4. Chapter 20: Functional programs - 실전 예제 (map, filter, fold)

왜 이 순서인가?

  • 이론 먼저: Decision tree 알고리즘을 이해해야 MLIR 구현이 명확해진다
  • 데이터 구조 다음: List operations가 있어야 패턴 매칭할 대상이 생긴다
  • 매칭 구현: funlang.match operation으로 decision tree를 MLIR로 표현한다
  • 실전 활용: 지금까지 배운 모든 기능을 종합해서 함수형 프로그램을 작성한다

Phase 5 복습: 왜 패턴 매칭이 필요한가?

Phase 5까지 우리는 이런 코드를 작성할 수 있게 되었다:

// F# compiler input
let make_adder n =
    fun x -> x + n

let add_5 = make_adder 5
let result = add_5 10  // 15
// Phase 5 MLIR output (FunLang dialect)
%closure = funlang.closure @lambda_adder, %n : !funlang.closure
%result = funlang.apply %closure(%x) : (i32) -> i32

하지만 함수형 언어의 진짜 힘은 데이터 구조와 패턴 매칭의 조합이다:

// F#에서 list 패턴 매칭
let rec sum_list lst =
    match lst with
    | [] -> 0
    | head :: tail -> head + sum_list tail

sum_list [1; 2; 3]  // 6
(* OCaml에서 패턴 매칭 *)
let rec length = function
  | [] -> 0
  | _ :: tail -> 1 + length tail

패턴 매칭이 제공하는 것:

  1. 구조적 분해(structural decomposition): 데이터를 한 번에 분해하고 변수에 바인딩
  2. Exhaustiveness checking: 컴파일러가 모든 경우를 다뤘는지 검증
  3. 효율적인 분기: 각 subterm을 최대 한 번만 테스트하는 코드 생성
  4. 가독성: if-else 체인보다 선언적이고 명확한 코드

Pattern Matching Compilation의 도전

Naive한 접근:

// 잘못된 방법: if-else 체인으로 번역
%is_nil = // list가 nil인지 테스트
scf.if %is_nil {
    %zero = arith.constant 0 : i32
    scf.yield %zero : i32
} else {
    %is_cons = // list가 cons인지 테스트 (중복!)
    scf.if %is_cons {
        %head = // head 추출
        %tail = // tail 추출
        %sum_tail = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
        %result = arith.addi %head, %sum_tail : i32
        scf.yield %result : i32
    }
}

문제점:

  1. 중복 테스트: Nil 테스트 실패 후 Cons 테스트는 중복이다 (list는 Nil 아니면 Cons)
  2. 비효율적 코드: Nested patterns에서 exponential blowup 발생
  3. Exhaustiveness 검증 어려움: 모든 case를 다뤘는지 확인이 복잡하다

올바른 접근: Decision Tree Compilation

Luc Maranget의 decision tree 알고리즘 (2008)을 사용하면:

  • 각 subterm을 최대 한 번만 테스트
  • Pattern matrix representation으로 체계적 변환
  • Exhaustiveness checking이 자연스럽게 통합됨
  • 최적화된 분기 코드 생성

Chapter 17 목표

이 장을 마치면:

  • Pattern matrix 표현법을 이해한다
  • Decision tree 알고리즘의 동작 원리를 안다
  • Specializationdefaulting 연산을 설명할 수 있다
  • Exhaustiveness checking이 어떻게 동작하는지 안다
  • Chapter 18-19에서 MLIR 구현을 시작할 준비가 된다

이론 중심 장(theory-focused chapter):

이 장은 구현 코드가 없다. 알고리즘 설명과 예제에 집중한다. 왜냐하면:

  1. Decision tree 알고리즘은 MLIR과 독립적이다 (OCaml, Haskell, Rust 등 모든 함수형 언어에서 사용)
  2. 알고리즘을 먼저 이해하면 MLIR lowering 구현이 명확해진다
  3. Pattern matrix 표기법은 Chapter 19의 funlang.match operation 설계 기반이 된다

성공 기준

이 장을 이해했다면:

  1. Pattern matrix에서 rows/columns가 무엇을 의미하는지 설명할 수 있다
  2. Specialization 연산이 pattern을 어떻게 분해하는지 예시를 들 수 있다
  3. Default 연산이 wildcard rows를 어떻게 처리하는지 설명할 수 있다
  4. Empty pattern matrix가 왜 non-exhaustive match를 의미하는지 안다
  5. Decision tree가 if-else chain보다 효율적인 이유를 설명할 수 있다

Let’s begin.


Pattern Matching 문제 정의

패턴 매칭 컴파일의 핵심 문제를 정의하자.

ML 계열 언어의 패턴 매칭

OCaml/F# syntax:

(* OCaml *)
match expr with
| pattern1 -> action1
| pattern2 -> action2
| pattern3 -> action3
// F#
match expr with
| pattern1 -> action1
| pattern2 -> action2
| pattern3 -> action3

Example: List length function

let rec length lst =
  match lst with
  | [] -> 0
  | _ :: tail -> 1 + length tail

구성 요소:

  1. Scrutinee (lst): 매칭 대상 expression
  2. Patterns ([], _ :: tail): 구조 템플릿
  3. Actions (0, 1 + length tail): 패턴이 매칭되면 실행할 코드
  4. Pattern variables (tail): 패턴 내부에서 값을 바인딩

FunLang 패턴 매칭 구문

FunLang의 제안 syntax (Phase 6 구현 목표):

// match expression
match list with
| Nil -> 0
| Cons(head, tail) -> head + sum tail

Pattern types:

  1. Wildcard pattern (_): 모든 값과 매칭, 변수 바인딩 없음
  2. Variable pattern (x, tail): 모든 값과 매칭, 변수에 바인딩
  3. Constructor pattern (Nil, Cons(x, xs)): 특정 constructor와 매칭
  4. Literal pattern (0, true): 특정 상수 값과 매칭

Constructor patterns의 subpatterns:

// Nested constructor patterns
match list with
| Nil -> "empty"
| Cons(x, Nil) -> "singleton"  // tail is Nil
| Cons(x, Cons(y, rest)) -> "at least two elements"

Cons(x, Nil)에서:

  • Cons는 constructor
  • x는 head subpattern (variable)
  • Nil은 tail subpattern (constructor)

컴파일 문제: Patterns → Efficient Branching Code

Input: Pattern clauses (scrutinee, patterns, actions)

match list with
| Nil -> 0
| Cons(head, tail) -> head + sum tail

Output: Efficient branching code (MLIR IR)

%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%is_nil = arith.cmpi eq, %tag, %c0 : i32

%result = scf.if %is_nil -> (i32) {
    %zero = arith.constant 0 : i32
    scf.yield %zero : i32
} else {
    // Cons case: extract head and tail
    %data = llvm.extractvalue %list[1] : !llvm.struct<(i32, ptr)>
    %head = llvm.load %data[0] : !llvm.ptr -> i32
    %tail = llvm.load %data[1] : !llvm.ptr -> !llvm.struct<(i32, ptr)>

    %sum_tail = func.call @sum(%tail) : (!funlang.list<i32>) -> i32
    %result_val = arith.addi %head, %sum_tail : i32
    scf.yield %result_val : i32
}

핵심 요구사항:

  1. Correctness: 패턴 순서를 존중 (첫 번째 매칭 패턴이 선택됨)
  2. Efficiency: 각 subterm을 최대 한 번만 테스트
  3. Exhaustiveness: 모든 가능한 값이 처리되는지 검증
  4. Optimization: 불필요한 테스트 제거 (Nil 아니면 자동으로 Cons)

Naive 컴파일의 문제점

If-else chain으로 직접 번역하면?

// Pattern 1: Nil
%is_nil = // test if tag == 0
scf.if %is_nil {
    scf.yield %zero : i32
} else {
    // Pattern 2: Cons(head, tail)
    %is_cons = // test if tag == 1 (redundant!)
    scf.if %is_cons {
        // extract head, tail, compute result
    } else {
        // No more patterns -> error!
    }
}

문제 1: 중복 테스트

Nil 테스트가 false면 자동으로 Cons다 (list는 Nil 또는 Cons만 존재). 하지만 naive 번역은 다시 Cons를 테스트한다.

문제 2: Nested patterns의 exponential blowup

match (list1, list2) with
| (Nil, Nil) -> 0
| (Nil, Cons(_, _)) -> 1
| (Cons(_, _), Nil) -> 2
| (Cons(x, _), Cons(y, _)) -> x + y

두 개의 scrutinee를 독립적으로 테스트하면:

list1 test -> list2 test (중복!)
            -> list2 test (중복!)
-> list1 test (중복!)
            -> list2 test (중복!)
            -> list2 test (중복!)

4개의 패턴이 8번의 테스트를 발생시킨다. Patterns이 늘어나면 2^n 테스트가 필요하다.

문제 3: Exhaustiveness 검증 복잡도

If-else tree를 분석해서 모든 경로가 종료되는지 확인해야 한다. 복잡한 중첩 패턴에서는 거의 불가능하다.

해결책: Decision Tree Compilation

Key insight (Maranget 2008):

“패턴 매칭은 search problem이다. Pattern clauses를 structured representation (pattern matrix)로 변환하면, systematic하게 optimal decision tree를 구성할 수 있다.”

Decision tree 특징:

  1. 각 internal node는 하나의 test (constructor tag, literal value)
  2. 각 edge는 test outcome (Nil vs Cons, 0 vs 1 vs 2)
  3. 각 leaf는 action (실행할 코드)
  4. Root에서 leaf까지 경로는 unique test sequence

장점:

  • 각 subterm을 최대 한 번만 테스트 (no redundancy)
  • Test 순서를 최적화 가능 (heuristic으로 선택)
  • Exhaustiveness checking이 자연스러움 (leaf가 없는 경로 = missing pattern)

다음 섹션에서: Pattern matrix 표기법과 decision tree 구성 알고리즘을 자세히 살펴본다.


Pattern Matrix 표현법

Decision tree 알고리즘의 핵심은 pattern matrix라는 structured representation이다.

Pattern Matrix 정의

Pattern matrix는 2차원 테이블이다:

  • Rows: Pattern clauses (각 row는 하나의 pattern -> action)
  • Columns: Scrutinees (매칭 대상 values)
  • Cells: Patterns (wildcard, constructor, literal)

Notation:

P = | p11  p12  ...  p1m  →  a1
    | p21  p22  ...  p2m  →  a2
    | ...
    | pn1  pn2  ...  pnm  →  an
  • P: Pattern matrix (n rows × m columns)
  • pij: Row i, column j의 pattern
  • ai: Row i의 action
  • m: Scrutinee 개수
  • n: Pattern clause 개수

Example 1: 단일 Scrutinee (List Length)

FunLang code:

match list with
| Nil -> 0
| Cons(head, tail) -> 1 + length tail

Pattern matrix:

Scrutinee: [list]

Matrix:
| Nil             →  0
| Cons(head, tail) →  1 + length tail

설명:

  • 1개의 scrutinee column: list
  • 2개의 pattern rows:
    • Row 1: Nil pattern → action은 0
    • Row 2: Cons(head, tail) pattern → action은 1 + length tail

Constructor patterns의 subpatterns:

Cons(head, tail)은 2개의 subpatterns를 가진다:

  • head: variable pattern (head 값에 바인딩)
  • tail: variable pattern (tail 값에 바인딩)

나중에 이 subpatterns가 새로운 columns로 확장된다 (specialization).

Example 2: 다중 Scrutinee (Pair Matching)

FunLang code:

match (list1, list2) with
| (Nil, Nil) -> 0
| (Nil, Cons(x, _)) -> 1
| (Cons(_, _), Nil) -> 2
| (Cons(x, _), Cons(y, _)) -> x + y

Pattern matrix:

Scrutinee: [list1, list2]

Matrix:
| Nil         Nil          →  0
| Nil         Cons(x, _)   →  1
| Cons(_, _)  Nil          →  2
| Cons(x, _)  Cons(y, _)   →  x + y

설명:

  • 2개의 scrutinee columns: list1, list2
  • 4개의 pattern rows
  • 각 cell은 해당 scrutinee의 pattern

Wildcard pattern _:

값을 바인딩하지 않는 pattern. 모든 값과 매칭된다.

Variable pattern x, y:

값을 변수에 바인딩하는 pattern. 모든 값과 매칭되지만 이름을 부여한다.

Wildcard vs Variable: Semantically 둘 다 모든 값과 매칭된다. Variable은 추가로 바인딩을 생성한다. Pattern matrix 관점에서는 동일하게 취급된다 (irrefutable pattern).

Example 3: Nested Pattern (List Prefix)

FunLang code:

match list with
| Nil -> "empty"
| Cons(x, Nil) -> "singleton"
| Cons(x, Cons(y, rest)) -> "at least two"

Initial pattern matrix:

Scrutinee: [list]

Matrix:
| Nil                   →  "empty"
| Cons(x, Nil)          →  "singleton"
| Cons(x, Cons(y, rest)) →  "at least two"

Nested constructor Cons(y, rest):

Row 3의 tail subpattern Cons(y, rest)는 또 다른 constructor pattern이다. 이게 nested pattern이다.

Compilation strategy:

  1. 먼저 list의 constructor (Nil vs Cons) 테스트
  2. Cons인 경우, head와 tail 추출
  3. 이제 tail에 대해 다시 pattern matching (Nil vs Cons)

Specialization 후 matrix는 확장된다 (나중에 자세히 설명).

Occurrence Vectors

Pattern matrix와 함께 occurrence vectors를 유지한다.

Occurrence vector (π):

Scrutinee values에 어떻게 접근하는지 나타내는 경로(path) 목록.

Initial occurrences:

π = [o1, o2, ..., om]
  • o1: First scrutinee (예: list)
  • o2: Second scrutinee (예: list2)

Example: Single scrutinee

π = [list]

Example: Pair of scrutinees

π = [list1, list2]

Specialization 시 occurrences 확장:

Constructor pattern Cons(x, xs)를 specialize하면:

π = [list]
  → [list.head, list.tail]

list.headlist.tailsubterm access path를 의미한다 (MLIR에서는 llvm.extractvalue operations).

왜 occurrence vectors가 필요한가?

Decision tree를 생성할 때, 각 test가 어느 값을 검사하는지 알아야 한다.

  • Initial: list 자체를 테스트
  • After specialization: list.head, list.tail을 테스트

Occurrence vectors는 code generation의 기반이다.

Pattern Matrix Properties

Irrefutable row:

Row의 모든 patterns가 wildcard 또는 variable이면 irrefutable이다 (항상 매칭).

| _  _  _  →  action  // Irrefutable

Exhaustive matrix:

Matrix가 exhaustive하면 모든 가능한 input values가 어떤 row와 매칭된다.

Non-exhaustive matrix:

어떤 input value도 매칭되지 않는 경우가 있으면 non-exhaustive.

Empty matrix (P = ∅):

Row가 하나도 없는 matrix. 항상 non-exhaustive다.

Example: Non-exhaustive pattern

match list with
| Nil -> 0
// Missing: Cons case!

Matrix:

| Nil  →  0

Input Cons(1, Nil)은 어떤 row와도 매칭 안 됨 → non-exhaustive.

Pattern Matrix Compilation Goal

Compilation algorithm의 목표:

Pattern matrix P와 occurrence vector π를 입력받아서:

  1. Decision tree를 생성한다 (efficient branching code)
  2. Exhaustiveness를 검증한다 (empty matrix 체크)
  3. Optimal test sequence를 선택한다 (heuristic)

Next section: Decision tree의 구조와 pattern matrix의 관계를 살펴본다.


Decision Tree 개념

Pattern matrix를 compile하면 decision tree가 생성된다. 이 섹션에서 decision tree의 구조와 특징을 이해한다.

Decision Tree 구조

Decision tree는 다음 요소로 구성된다:

  1. Internal nodes (decision nodes): Test operations
    • Constructor test: “Is this value Nil or Cons?”
    • Literal test: “Is this value 0 or 1 or 2?”
  2. Edges: Test outcomes (branches)
    • Constructor edges: Nil branch, Cons branch
    • Literal edges: 0 branch, 1 branch, default branch
  3. Leaf nodes: Actions
    • Success leaf: Execute action (return value)
    • Failure leaf: Match failure (non-exhaustive error)

Tree traversal:

  • Root에서 시작
  • 각 internal node에서 test 실행
  • Test outcome에 따라 edge 선택
  • Leaf에 도달하면 종료 (action 실행 또는 failure)

Example: List Length Decision Tree

Pattern matrix:

| Nil             →  a1 (return 0)
| Cons(head, tail) →  a2 (return 1 + length tail)

Decision tree:

       [list]
         |
    Test: constructor
       /   \
     Nil   Cons
     /       \
   Leaf     [head, tail]
   a1          |
             Leaf
              a2

Tree 설명:

  1. Root node: list의 constructor 테스트
  2. Nil edge: Nil constructor → Leaf (action a1)
  3. Cons edge: Cons constructor → Intermediate node (head, tail 추출)
  4. Cons leaf: Action a2 실행

왜 [head, tail] node가 필요한가?

Cons pattern Cons(head, tail)은 subpatterns를 가진다. Cons case에서:

  • head 값을 추출해서 변수 head에 바인딩
  • tail 값을 추출해서 변수 tail에 바인딩

이 바인딩들이 action a2에서 사용된다.

Simplified view (bindings 생략):

       [list]
         |
    Test: constructor
       /   \
     Nil   Cons
     /       \
   a1        a2

구현에서는 Cons branch에서 head/tail 추출 코드를 삽입한다.

Example: Nested Pattern Decision Tree

Pattern matrix:

| Nil                   →  a1 ("empty")
| Cons(x, Nil)          →  a2 ("singleton")
| Cons(x, Cons(y, rest)) →  a3 ("at least two")

Decision tree:

          [list]
            |
       Test: constructor
         /   \
       Nil   Cons
       /       \
     a1      [head, tail]
                |
          Test: tail constructor
              /   \
            Nil   Cons
            /       \
          a2      [y, rest]
                     |
                    a3

Tree traversal example:

Input: Cons(1, Cons(2, Nil))

  1. Root: Test list constructor → Cons
  2. Extract head = 1, tail = Cons(2, Nil)
  3. Test tail constructor → Cons
  4. Extract y = 2, rest = Nil
  5. Leaf a3 (“at least two”)

Key property: 각 subterm을 한 번만 테스트

  • list constructor: 1번 테스트
  • tail constructor: 1번 테스트

Naive if-else chain은 list constructor를 여러 번 테스트할 수 있다.

Comparison: Decision Tree vs If-Else Chain

If-Else chain (naive compilation):

// Pattern 1: Nil
%is_nil = arith.cmpi eq, %tag, %c0 : i32
scf.if %is_nil {
    scf.yield %a1 : i32
} else {
    // Pattern 2: Cons(x, Nil)
    %is_cons = arith.cmpi eq, %tag, %c1 : i32  // Redundant test!
    scf.if %is_cons {
        %tail = // extract tail
        %tail_tag = llvm.extractvalue %tail[0] : !llvm.struct<(i32, ptr)>
        %tail_is_nil = arith.cmpi eq, %tail_tag, %c0 : i32
        scf.if %tail_is_nil {
            scf.yield %a2 : i32
        } else {
            // Pattern 3: Cons(x, Cons(y, rest))
            // ... (more tests)
        }
    }
}

문제:

  1. %is_cons test는 중복 (Nil이 아니면 자동으로 Cons)
  2. Nested if-else는 depth가 깊어진다
  3. 각 level에서 동일한 값을 반복 테스트

Decision tree (optimal compilation):

// Test list constructor once
%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%result = scf.index_switch %tag : i32 -> i32
case 0 {  // Nil
    scf.yield %a1 : i32
}
case 1 {  // Cons
    %data = llvm.extractvalue %list[1] : !llvm.struct<(i32, ptr)>
    %head = llvm.load %data[0] : !llvm.ptr -> i32
    %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
    %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>

    // Test tail constructor once
    %tail_tag = llvm.extractvalue %tail[0] : !llvm.struct<(i32, ptr)>
    %tail_result = scf.index_switch %tail_tag : i32 -> i32
    case 0 {  // Nil
        scf.yield %a2 : i32
    }
    case 1 {  // Cons
        %tail_data = llvm.extractvalue %tail[1] : !llvm.struct<(i32, ptr)>
        %y = llvm.load %tail_data[0] : !llvm.ptr -> i32
        %rest_ptr = llvm.getelementptr %tail_data[1] : (!llvm.ptr) -> !llvm.ptr
        %rest = llvm.load %rest_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
        scf.yield %a3 : i32
    }
    scf.yield %tail_result : i32
}

장점:

  1. 각 constructor tag를 정확히 한 번만 테스트 (scf.index_switch)
  2. 불필요한 비교 연산 제거
  3. Structured control flow (SCF dialect)로 최적화 기회 제공

Decision Tree Benefits

1. Efficiency: O(d) tests (d = pattern depth)

Nested pattern의 depth가 d면, 최대 d번의 test만 필요하다.

  • Flat pattern (Nil, Cons(_, _)): 1번 test
  • Nested pattern (Cons(_, Cons(_, _))): 2번 test (outer, inner)

If-else chain은 worst case O(n × d) tests (n = pattern 개수).

2. Exhaustiveness checking: Leaf coverage

모든 가능한 input이 어떤 leaf에 도달하면 exhaustive.

Leaf에 도달하지 않는 경로가 있으면 non-exhaustive.

Example: Non-exhaustive detection

Pattern matrix:
| Nil  →  a1
// Missing Cons case

Decision tree:

    [list]
      |
  Test: constructor
    /   \
  Nil   Cons
  /       \
a1      FAILURE  // No action for Cons

Cons branch가 Failure leaf로 이어진다 → Compile error: “non-exhaustive match”

3. Optimization opportunities

Decision tree는 structured representation이라서:

  • Common subexpression elimination (같은 test를 여러 번 안 함)
  • Dead code elimination (도달 불가능한 patterns 제거)
  • Branch prediction hints (frequent cases 먼저 테스트)

Relationship: Pattern Matrix → Decision Tree

Compilation function:

compile : PatternMatrix × OccurrenceVector → DecisionTree

Input:

  • Pattern matrix P (n rows × m columns)
  • Occurrence vector π (m elements)

Output:

  • Decision tree T

Recursive algorithm:

function compile(P, π):
    if P is empty:
        return Failure  // Non-exhaustive

    if first row is irrefutable:
        return Success(action)  // Found match

    column = select_column(P)
    constructors = get_constructors(P, column)

    branches = {}
    for each constructor c:
        P_c = specialize(P, column, c)
        π_c = specialize_occurrences(π, column, c)
        branches[c] = compile(P_c, π_c)

    P_default = default(P, column)
    π_default = default_occurrences(π, column)
    default_branch = compile(P_default, π_default)

    return Switch(π[column], branches, default_branch)

핵심 operations:

  1. select_column: 어느 column을 먼저 테스트할지 선택 (heuristic)
  2. specialize: Constructor와 매칭되는 rows만 남기고, subpatterns 확장
  3. default: Wildcard rows만 남기고, 테스트한 column 제거

Next sections: Specialization과 defaulting을 자세히 설명한다.


Specialization 연산

Specialization은 decision tree 알고리즘의 핵심 operation이다. Constructor test가 성공했을 때 pattern matrix를 어떻게 변환하는지 정의한다.

Specialization 정의

Specialization (S):

S(c, i, P) = Specialized pattern matrix

Parameters:

  • c: Constructor (예: Cons, Nil)
  • i: Column index (어느 scrutinee를 테스트하는가)
  • P: Original pattern matrix

Operation:

  1. Column i의 pattern이 constructor c호환되는 rows만 유지
  2. 호환되는 patterns를 subpatterns로 확장 (constructor decomposition)
  3. Column i를 제거하고 subpattern columns를 삽입

Example 1: Simple List Specialization (Cons)

Original pattern matrix:

Column: [list]

| Nil             →  a1
| Cons(head, tail) →  a2
| _               →  a3

Specialize on column 0, constructor Cons:

S(Cons, 0, P):

Step 1: Filter compatible rows

  • Row 1 (Nil): Incompatible with Cons → 제거
  • Row 2 (Cons(head, tail)): Compatible → 유지
  • Row 3 (_): Wildcard, compatible → 유지

Step 2: Decompose patterns

  • Row 2: Cons(head, tail) → expand to [head, tail]
  • Row 3: _ → expand to [_, _] (wildcard for each subpattern)

Step 3: Replace column 0 with subpattern columns

Columns: [head, tail]

| head  tail  →  a2
| _     _     →  a3

Occurrence vector update:

Before: π = [list]
After:  π = [list.head, list.tail]

Example 2: Specialization on Nil

Original pattern matrix:

Column: [list]

| Nil             →  a1
| Cons(head, tail) →  a2
| _               →  a3

Specialize on column 0, constructor Nil:

S(Nil, 0, P):

Step 1: Filter compatible rows

  • Row 1 (Nil): Compatible → 유지
  • Row 2 (Cons(head, tail)): Incompatible with Nil → 제거
  • Row 3 (_): Wildcard, compatible → 유지

Step 2: Decompose patterns

Nil constructor는 subpatterns가 없다 (nullary constructor).

  • Row 1: Nil → no subpatterns
  • Row 3: _ → no subpatterns

Step 3: Remove column 0 (no subpatterns to add)

Columns: [] (empty)

| →  a1
| →  a3

Occurrence vector update:

Before: π = [list]
After:  π = [] (empty)

Empty occurrence vector는 모든 tests가 완료되었음을 의미. 이제 첫 번째 row의 action을 선택한다.

Example 3: Nested Pattern Specialization

Original pattern matrix:

Column: [list]

| Cons(x, Nil)          →  a1
| Cons(x, Cons(y, rest)) →  a2

Specialize on column 0, constructor Cons:

S(Cons, 0, P):

Step 1: Filter compatible rows

Both rows have Cons → 둘 다 유지

Step 2: Decompose patterns

  • Row 1: Cons(x, Nil) → subpatterns [x, Nil]
  • Row 2: Cons(x, Cons(y, rest)) → subpatterns [x, Cons(y, rest)]

Step 3: Replace column 0 with subpattern columns

Columns: [head, tail]

| x  Nil              →  a1
| x  Cons(y, rest)    →  a2

Occurrence vector update:

Before: π = [list]
After:  π = [list.head, list.tail]

이제 column 1 (tail)에 대해 다시 specialization:

Matrix after first specialization:

| x  Nil              →  a1
| x  Cons(y, rest)    →  a2

Specialize on column 1, constructor Nil:

Columns: [head]

| x  →  a1

Specialize on column 1, constructor Cons:

Columns: [head, y, rest]

| x  y  rest  →  a2

Nested patterns는 여러 번의 specialization으로 처리된다.

Wildcard Expansion Rule

Wildcard pattern _의 specialization:

Constructor c가 arity n (subpatterns 개수)를 가지면:

_ → [_, _, ..., _]  (n개의 wildcards)

Example: Cons constructor (arity 2)

_ → [_, _]  // head wildcard, tail wildcard

Example: Nil constructor (arity 0)

_ → []  // No subpatterns

왜 wildcard를 확장하는가?

Wildcard는 “모든 값과 매칭“을 의미한다. Constructor c와 매칭되면, c의 모든 subpatterns도 wildcard로 매칭된다.

// Original pattern
| _ -> action

// After specialization on Cons
// Equivalent to:
| Cons(_, _) -> action

Variable Pattern Specialization

Variable pattern x의 specialization:

Variable은 wildcard와 동일하게 확장되지만, binding name을 유지한다.

x → [_, _, ..., _]  // Subpatterns, 하지만 x는 여전히 전체 값에 바인딩됨

Example:

match list with
| xs -> length xs  // xs는 전체 list에 바인딩

Specialize on Cons:

Columns: [head, tail]

| _  _  →  length (Cons head tail)

xs 바인딩은 original occurrence에 남는다. Specialization 후에도 xs는 사용 가능하다.

Implementation note: Variable bindings는 pattern matrix에 직접 저장되지 않고, occurrence vector와 함께 관리된다. Action에서 variable을 사용할 때 occurrence path로 접근한다.

Specialization Pseudocode

Algorithm: specialize(P, column, constructor)

def specialize(P, column, constructor):
    """
    P: Pattern matrix (n rows × m columns)
    column: Column index to specialize
    constructor: Constructor to match (e.g., Cons, Nil)

    Returns: Specialized matrix
    """
    result_rows = []
    arity = get_arity(constructor)  // Subpattern 개수

    for row in P:
        pattern = row[column]

        if matches_constructor(pattern, constructor):
            # Compatible pattern
            if pattern.is_constructor and pattern.name == constructor:
                # Extract subpatterns
                subpatterns = pattern.subpatterns  // e.g., [head, tail]
            elif pattern.is_wildcard or pattern.is_variable:
                # Expand to wildcard subpatterns
                subpatterns = [Wildcard] * arity  // e.g., [_, _]
            else:
                # Incompatible (different constructor)
                continue  # Skip this row

            # Build new row: columns before + subpatterns + columns after
            new_row = (
                row[:column] +
                subpatterns +
                row[column+1:]
            )
            result_rows.append((new_row, row.action))

    return PatternMatrix(result_rows)

def matches_constructor(pattern, constructor):
    """Check if pattern is compatible with constructor"""
    if pattern.is_wildcard or pattern.is_variable:
        return True  # Wildcard matches everything
    if pattern.is_constructor and pattern.name == constructor:
        return True  # Same constructor
    return False  # Different constructor

Visual Example: Specialization Flow

Original:

   [list]
     |
| Nil        →  a1
| Cons(x, y) →  a2
| _          →  a3

After S(Cons, 0, P):

   [x, y]  (head, tail)
     |
| x  y  →  a2  (from Cons(x, y))
| _  _  →  a3  (from _)

Row 1 (Nil) 제거됨 (incompatible).

After S(Nil, 0, P) on original:

   []  (no occurrences)
    |
| →  a1  (from Nil)
| →  a3  (from _)

Rows 2 (Cons) 제거됨 (incompatible).

Key Insight: Specialization = Assumption + Decomposition

Specialization의 의미:

“Column i의 constructor가 c라고 가정하면, pattern matrix는 어떻게 변하는가?”

Assumption:

  • Constructor test가 성공했다 (e.g., listCons)
  • 이제 c의 subpatterns에 접근 가능 (e.g., head, tail)

Decomposition:

  • 호환되지 않는 rows 제거 (Nil patterns)
  • 호환되는 rows의 patterns를 subpatterns로 확장

Next: Defaulting 연산은 반대 상황을 다룬다 (constructor test 실패).


Defaulting 연산

Defaulting은 specialization의 complement다. Constructor test가 실패했을 때 (또는 테스트하지 않고 default case로 가려 할 때) pattern matrix를 어떻게 변환하는지 정의한다.

Defaulting 정의

Defaulting (D):

D(i, P) = Default pattern matrix

Parameters:

  • i: Column index
  • P: Original pattern matrix

Operation:

  1. Column i의 pattern이 wildcard 또는 variable인 rows만 유지
  2. Column i제거 (더 이상 테스트 안 함)
  3. 나머지 columns는 유지

의미:

“Column i에 대한 모든 constructor tests가 실패했다. Wildcard rows만 남는다.”

Example 1: Simple List Defaulting

Original pattern matrix:

Column: [list]

| Nil             →  a1
| Cons(head, tail) →  a2
| _               →  a3

Default on column 0:

D(0, P):

Step 1: Filter wildcard rows

  • Row 1 (Nil): Constructor pattern → 제거
  • Row 2 (Cons(head, tail)): Constructor pattern → 제거
  • Row 3 (_): Wildcard → 유지

Step 2: Remove column 0

Columns: [] (empty)

| →  a3

Occurrence vector update:

Before: π = [list]
After:  π = [] (empty)

Empty matrix with one row → Irrefutable → Select action a3.

Example 2: Empty Default Matrix

Original pattern matrix:

Column: [list]

| Nil             →  a1
| Cons(head, tail) →  a2

Default on column 0:

D(0, P):

Step 1: Filter wildcard rows

  • Row 1 (Nil): Constructor pattern → 제거
  • Row 2 (Cons(head, tail)): Constructor pattern → 제거

Result: Empty matrix

Columns: []

(no rows)

의미: Non-exhaustive match!

모든 rows가 constructor patterns이면, defaulting은 empty matrix를 생성한다. 즉, wildcard case가 없다 → Non-exhaustive.

Compiler action:

Empty default matrix는 compile error를 발생시킨다:

Error: Non-exhaustive pattern match
Missing case: (other constructors or wildcard)

Example 3: Multiple Columns Defaulting

Original pattern matrix:

Columns: [list1, list2]

| Nil         Nil          →  a1
| Nil         Cons(x, _)   →  a2
| Cons(_, _)  Nil          →  a3
| Cons(x, _)  Cons(y, _)   →  a4
| _           _            →  a5

Default on column 0:

D(0, P):

Step 1: Filter wildcard rows on column 0

  • Row 1 (Nil): Constructor → 제거
  • Row 2 (Nil): Constructor → 제거
  • Row 3 (Cons(_, _)): Constructor → 제거
  • Row 4 (Cons(x, _)): Constructor → 제거
  • Row 5 (_): Wildcard → 유지

Step 2: Remove column 0

Columns: [list2]

| _  →  a5

Occurrence vector update:

Before: π = [list1, list2]
After:  π = [list2]

이제 column 0 (이전 list2)에 대해 specialization 또는 defaulting을 계속할 수 있다.

Defaulting vs Specialization: When to Use

Specialization:

Constructor test가 성공했을 때.

if (tag == CONS) {
    // Specialize on Cons
    S(Cons, 0, P)
}

Defaulting:

모든 constructor tests가 실패했을 때.

if (tag == NIL) {
    S(Nil, 0, P)
} else if (tag == CONS) {
    S(Cons, 0, P)
} else {
    // Default case
    D(0, P)
}

하지만 list는 Nil 또는 Cons만 존재한다!

완전한 constructor set (Nil, Cons)을 모두 테스트하면 default case는 unreachable이다.

Defaulting이 필요한 경우:

  1. Extensible constructors: Open constructor sets (예: integers)
  2. Incomplete specialization: 일부 constructors만 테스트
  3. Wildcard-only rows: 모든 constructors 후 남은 wildcard 처리

List의 경우 (closed constructor set):

if (tag == NIL) {
    S(Nil, 0, P)
} else {
    // Must be CONS (only two constructors)
    S(Cons, 0, P)
}

Default branch는 필요 없다. 하지만 algorithm에서는 여전히 defaulting을 계산해서 exhaustiveness를 체크한다.

Defaulting Empty Matrix Detection

Defaulting의 중요한 역할: Exhaustiveness checking

Case 1: Non-empty default matrix

Pattern matrix:
| Cons(x, xs)  →  a1
| _            →  a2  // Wildcard exists

Default on column 0:

| →  a2  // Non-empty

Result: Exhaustive (wildcard catches everything)

Case 2: Empty default matrix

Pattern matrix:
| Cons(x, xs)  →  a1
// No wildcard

Default on column 0:

(empty matrix)

Result: Non-exhaustive (missing Nil case and wildcard)

Compiler error:

Error: Non-exhaustive pattern match
Missing case: Nil

Defaulting Pseudocode

Algorithm: default(P, column)

def default(P, column):
    """
    P: Pattern matrix (n rows × m columns)
    column: Column index to default

    Returns: Default matrix (wildcard rows only, column removed)
    """
    result_rows = []

    for row in P:
        pattern = row[column]

        if pattern.is_wildcard or pattern.is_variable:
            # Wildcard row: keep it, remove column
            new_row = row[:column] + row[column+1:]
            result_rows.append((new_row, row.action))
        else:
            # Constructor pattern: remove this row
            continue

    return PatternMatrix(result_rows)

Simplicity:

Defaulting은 specialization보다 간단하다:

  • No subpattern expansion
  • Just filter wildcard rows and remove column

Visual Example: Defaulting Flow

Original:

   [list]
     |
| Nil        →  a1
| Cons(x, y) →  a2
| _          →  a3

After D(0, P):

   []  (no occurrences)
    |
| →  a3  (from _)

Rows 1 (Nil) and 2 (Cons) 제거됨 (constructor patterns).

Empty default example:

   [list]
     |
| Nil        →  a1
| Cons(x, y) →  a2

After D(0, P):

   []
    |
(empty - no wildcard rows)

Compiler: “Error: Non-exhaustive match”

Key Insight: Defaulting = Catch-All Case

Defaulting의 의미:

“모든 명시적 constructor tests가 실패했다. 남은 rows는 wildcard만 있다. Wildcard는 catch-all이다.”

Properties:

  1. Default matrix는 항상 wildcards만 포함 (constructors 제거됨)
  2. Empty default matrix = non-exhaustive (catch-all 없음)
  3. Default 후 irrefutable row가 남으면 항상 매칭 (first wildcard row 선택)

Next: Specialization과 defaulting을 결합해서 complete compilation algorithm을 만든다.


Complete Compilation Algorithm

이제 specialization과 defaulting을 결합해서 complete decision tree compilation algorithm을 구성한다.

Algorithm Overview

Recursive function:

compile : PatternMatrix × OccurrenceVector → DecisionTree

Input:

  • Pattern matrix P (n rows × m columns)
  • Occurrence vector π (m elements, scrutinee access paths)

Output:

  • Decision tree T

Strategy:

  1. Base cases: Empty matrix, irrefutable first row
  2. Recursive case: Select column, specialize on constructors, recurse
  3. Default case: Default on column, recurse

Base Case 1: Empty Matrix

Condition:

if P.is_empty():

Meaning:

No pattern rows remain. 어떤 pattern도 매칭되지 않는다.

Action:

return FailureLeaf()

MLIR equivalent:

// Non-exhaustive match error
llvm.call @match_failure() : () -> ()
llvm.unreachable

Example:

Pattern matrix:
(empty)

Input: Cons(1, Nil)

No patterns → match failure

Base Case 2: Irrefutable First Row

Condition:

if all(p.is_wildcard or p.is_variable for p in P[0]):

Meaning:

첫 번째 row의 모든 patterns가 wildcard 또는 variable이다. 이 row는 항상 매칭된다.

Action:

return SuccessLeaf(P[0].action)

Example:

Pattern matrix:
| _  _  →  a1
| ... (more rows, but unreachable)

Any input → select action a1

Unreachable rows:

첫 번째 irrefutable row 이후의 rows는 절대 실행 안 됨.

match list with
| _ -> 0
| Nil -> 1  // Warning: Unreachable pattern

Compiler warning: “Unreachable pattern (row 2)”

Recursive Case: Constructor Test

Condition:

if P is not empty and first row has constructors:

Steps:

  1. Select column: 어느 occurrence를 테스트할지 선택
  2. Get constructors: 그 column에 등장하는 constructors 수집
  3. Specialize: 각 constructor에 대해 specialized matrix 생성, recurse
  4. Default: Wildcard rows로 default matrix 생성, recurse

Pseudocode:

def compile(P, π):
    # Base case 1: Empty matrix
    if not P:
        return Failure()

    # Base case 2: Irrefutable first row
    if is_irrefutable(P[0]):
        return Success(P[0].action)

    # Recursive case: Constructor test
    column = select_column(P, π)
    constructors = get_constructors(P, column)

    # Build switch node
    branches = {}
    for c in constructors:
        # Specialize on constructor c
        P_c = specialize(P, column, c)
        π_c = specialize_occurrences(π, column, c)
        branches[c] = compile(P_c, π_c)

    # Default branch (wildcard rows)
    P_default = default(P, column)
    π_default = default_occurrences(π, column)
    default_branch = compile(P_default, π_default)

    return Switch(π[column], branches, default_branch)

Column Selection Heuristic

문제: 여러 columns가 있을 때, 어느 column을 먼저 테스트하는가?

Heuristic 1: Left-to-right (simple)

def select_column(P, π):
    return 0  # Always test first column

장점: 간단, 예측 가능 단점: 비효율적일 수 있음 (redundant tests)

Heuristic 2: Needed by most rows (Maranget)

def select_column(P, π):
    """Select column needed by most rows (first constructor pattern)"""
    for col in range(len(π)):
        needed_count = sum(1 for row in P if not row[col].is_wildcard)
        if needed_count > 0:
            return col
    return 0  # All wildcards, any column works

의미:

  • Constructor pattern이 가장 많은 column 선택
  • Wildcards는 어떤 constructor도 요구 안 함 (skip 가능)

Example:

Columns: [c1, c2]

| _     Cons(x, _)  →  a1  // c1 not needed, c2 needed
| Nil   _           →  a2  // c1 needed, c2 not needed
| _     _           →  a3  // neither needed
  • c1 needed by: 1 row
  • c2 needed by: 1 row
  • Tie → select c1 (left-to-right tie-breaker)

Heuristic 3: Minimize combined row count (optimal)

def select_column(P, π):
    """Select column that minimizes total specialized matrix sizes"""
    best_column = 0
    min_cost = float('inf')

    for col in range(len(π)):
        constructors = get_constructors(P, col)
        cost = sum(len(specialize(P, col, c)) for c in constructors)
        if cost < min_cost:
            min_cost = cost
            best_column = col

    return best_column

의미: Specialized matrices의 크기 합이 최소인 column 선택

Tradeoff: 계산 비용이 높음 (모든 columns에 대해 specialize 시뮬레이션)

FunLang Phase 6 choice: Heuristic 1 (left-to-right)

간단하고 예측 가능. 대부분의 FunLang patterns는 단순해서 heuristic 차이가 크지 않다.

Occurrence Specialization

Specialization 후 occurrence vector 업데이트:

Example: Cons specialization

Before: π = [list]
Constructor: Cons (arity 2)

After: π = [list.head, list.tail]

Pseudocode:

def specialize_occurrences(π, column, constructor):
    """Expand occurrence at column into suboccurrences"""
    arity = get_arity(constructor)
    suboccurrences = [
        Occurrence(π[column].path + f".{i}")
        for i in range(arity)
    ]

    # Replace column with suboccurrences
    return π[:column] + suboccurrences + π[column+1:]

Occurrence paths:

  • listlist.0 (head), list.1 (tail)
  • list.taillist.1.0 (tail’s head), list.1.1 (tail’s tail)

MLIR code generation:

// π = [list]
%list = ...

// π = [list.0, list.1]
%head = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%tail_ptr = llvm.getelementptr %list[1] : (!llvm.ptr) -> !llvm.ptr
%tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>

Occurrence paths는 extraction code를 생성하는 template이다.

Occurrence Defaulting

Defaulting 후 occurrence vector 업데이트:

Example:

Before: π = [list, other]
Default on column 0:

After: π = [other]

Pseudocode:

def default_occurrences(π, column):
    """Remove occurrence at column"""
    return π[:column] + π[column+1:]

Defaulting은 column을 제거한다 (더 이상 테스트 안 함).

Complete Example: List Length Compilation

Pattern matrix:

π = [list]

| Nil             →  0
| Cons(head, tail) →  1 + length tail

Step 1: compile(P, [list])

  • Not empty
  • First row not irrefutable (Nil is constructor)
  • Select column 0

Step 2: Get constructors

constructors = [Nil, Cons]

Step 3: Specialize on Nil

P_nil = specialize(P, 0, Nil)
π_nil = [list] → []

Result:

π = []

| →  0

Irrefutable → Success(0)

Step 4: Specialize on Cons

P_cons = specialize(P, 0, Cons)
π_cons = [list] → [list.head, list.tail]

Result:

π = [list.head, list.tail]

| head  tail  →  1 + length tail

Irrefutable → Success(1 + length tail)

Step 5: Default

P_default = default(P, 0)

Result: Empty (no wildcard rows)

compile(P_default, []) = Failure()

하지만 Nil + Cons가 complete constructor set이므로 default branch는 unreachable.

Generated decision tree:

Switch(list, {
    Nil: Success(0),
    Cons: Success(1 + length tail)
}, Failure())

MLIR output:

%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%result = scf.index_switch %tag : i32 -> i32
case 0 {  // Nil
    %zero = arith.constant 0 : i32
    scf.yield %zero : i32
}
case 1 {  // Cons
    %data = llvm.extractvalue %list[1] : !llvm.struct<(i32, ptr)>
    %head = llvm.load %data[0] : !llvm.ptr -> i32
    %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
    %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>

    %one = arith.constant 1 : i32
    %len_tail = func.call @length(%tail) : (!funlang.list<i32>) -> i32
    %result_val = arith.addi %one, %len_tail : i32
    scf.yield %result_val : i32
}
default {
    llvm.unreachable  // Should never reach (exhaustive)
}

Example 2: Nested Pattern Compilation

Pattern matrix:

π = [list]

| Cons(x, Nil)          →  "singleton"
| Cons(x, Cons(y, rest)) →  "at least two"
| _                     →  "other"

Step 1: compile(P, [list])

Select column 0, constructors = [Cons]

Step 2: Specialize on Cons

P_cons = specialize(P, 0, Cons)
π_cons = [list.head, list.tail]

Result:

π = [list.head, list.tail]

| x  Nil              →  "singleton"
| x  Cons(y, rest)    →  "at least two"
| _  _                →  "other"

Step 3: compile(P_cons, [list.head, list.tail])

First row not irrefutable (column 1 has Nil constructor).

Select column 1 (tail), constructors = [Nil, Cons]

Step 4: Specialize on Nil (column 1)

P_cons_nil = specialize(P_cons, 1, Nil)
π_cons_nil = [list.head]

Result:

π = [list.head]

| x  →  "singleton"
| _  →  "other"

First row irrefutable → Success("singleton")

Step 5: Specialize on Cons (column 1)

P_cons_cons = specialize(P_cons, 1, Cons)
π_cons_cons = [list.head, list.tail.head, list.tail.tail]

Result:

π = [list.head, list.tail.head, list.tail.tail]

| x  y  rest  →  "at least two"
| _  _  _     →  "other"

First row irrefutable → Success("at least two")

Step 6: Default (column 1)

P_cons_default = default(P_cons, 1)
π_cons_default = [list.head]

Result:

π = [list.head]

| _  →  "other"

Irrefutable → Success("other")

Step 7: Default on column 0 (original)

P_default = default(P, 0)
π_default = []

Result:

π = []

| →  "other"

Irrefutable → Success("other")

Generated decision tree:

Switch(list, {
    Cons: Switch(list.tail, {
        Nil: Success("singleton"),
        Cons: Success("at least two")
    }, Success("other"))
}, Success("other"))

Nested structure: Cons branch 안에 또 다른 switch (tail test).


Exhaustiveness Checking

Exhaustiveness checking은 decision tree 알고리즘에 자연스럽게 통합된다.

Exhaustiveness 정의

Exhaustive pattern match:

모든 가능한 input values가 어떤 pattern과 매칭된다.

Non-exhaustive pattern match:

어떤 input value는 어떤 pattern과도 매칭되지 않는다.

Example: Exhaustive

match list with
| Nil -> 0
| Cons(_, _) -> 1

모든 list는 Nil 또는 Cons다 → Exhaustive.

Example: Non-exhaustive

match list with
| Nil -> 0
// Missing: Cons case

Input Cons(1, Nil)은 매칭 안 됨 → Non-exhaustive.

Empty Matrix = Non-Exhaustive

Key insight:

Empty pattern matrix는 어떤 input도 매칭 안 됨을 의미한다.

Compilation algorithm:

def compile(P, π):
    if not P:
        return Failure()  # Non-exhaustive!

Detection points:

  1. Initial matrix empty: 아예 patterns가 없음
  2. Specialization 후 empty: 특정 constructor case가 없음
  3. Default 후 empty: Wildcard case가 없음

Example 1: Missing Constructor Case

Pattern matrix:

π = [list]

| Nil  →  0

Compile:

  • Specialize on Nil: Success(0)
  • Specialize on Cons: specialize(P, 0, Cons)empty matrix
    • No Cons patterns in original matrix
    • Result: Failure()

Decision tree:

Switch(list, {
    Nil: Success(0),
    Cons: Failure()  // Non-exhaustive!
}, Failure())

Compiler error:

Error: Non-exhaustive pattern match
Location: match list with ...
Missing case: Cons(_, _)

Example 2: Missing Wildcard

Pattern matrix:

π = [list]

| Nil         →  0
| Cons(x, xs) →  1

Compile:

  • Specialize on Nil: Success(0)
  • Specialize on Cons: Success(1)
  • Default: default(P, 0)empty matrix
    • No wildcard rows
    • Result: Failure()

하지만 이 경우는 실제로 exhaustive다!

Nil + Cons가 complete constructor set이므로 default branch는 unreachable.

Optimization:

Complete constructor set일 때 default branch를 생략할 수 있다.

def compile(P, π):
    # ...
    constructors = get_constructors(P, column)
    if is_complete_set(constructors):
        # No default branch needed
        return Switch(π[column], branches, None)
    else:
        # Default branch for incomplete sets
        default_branch = compile(default(P, column), ...)
        return Switch(π[column], branches, default_branch)

Complete constructor sets:

  • List: {Nil, Cons}
  • Bool: {True, False}
  • Option: {None, Some}

Example 3: Nested Non-Exhaustiveness

Pattern matrix:

π = [list]

| Cons(x, Nil)  →  "singleton"
// Missing: Cons(x, Cons(y, rest))
// Missing: Nil

Compile:

  1. Specialize on Cons:

    π = [list.head, list.tail]
    
    | x  Nil  →  "singleton"
    
  2. Specialize on Nil (column 1):

    π = [list.head]
    
    | x  →  "singleton"
    

    Result: Success("singleton")

  3. Specialize on Cons (column 1):

    (empty matrix)  // No Cons(x, Cons(...)) pattern
    

    Result: Failure()Non-exhaustive

  4. Default on column 0:

    (empty matrix)  // No wildcard or Nil pattern
    

    Result: Failure()Non-exhaustive

Decision tree:

Switch(list, {
    Cons: Switch(list.tail, {
        Nil: Success("singleton"),
        Cons: Failure()  // Missing!
    }, Failure()),
}, Failure())  // Missing Nil!

Compiler error:

Error: Non-exhaustive pattern match
Missing cases:
  - Nil
  - Cons(_, Cons(_, _))

Exhaustiveness Error Reporting

Basic approach: Failure leaf

Compile 중 empty matrix 발견 시 error 발생:

def compile(P, π):
    if not P:
        raise CompileError("Non-exhaustive pattern match")

Advanced approach: Missing pattern reconstruction

Empty matrix가 발생한 경로를 추적해서 missing pattern 생성:

def compile(P, π, path=[]):
    if not P:
        missing = reconstruct_pattern(path)
        raise CompileError(f"Missing case: {missing}")

    # ...
    for c in constructors:
        P_c = specialize(P, column, c)
        compile(P_c, π_c, path + [(column, c)])

Example path:

path = [(0, Cons), (1, Cons)]
→ Missing pattern: Cons(_, Cons(_, _))

FunLang Phase 6 approach:

간단한 error message만 제공:

Error: Non-exhaustive pattern match at line X
Consider adding a wildcard pattern: | _ -> ...

자세한 missing case 분석은 나중 phase 또는 bonus 섹션에서 다룬다.

Exhaustiveness Check가 자연스러운 이유

Decision tree 알고리즘의 장점:

“Exhaustiveness checking은 별도의 분석 pass가 아니다. Compilation 과정에서 자동으로 발견된다.”

왜 자연스러운가?

  1. Empty matrix는 명확한 신호: No patterns left = no matches possible
  2. Recursive structure: 각 specialization/default 단계에서 independently 체크
  3. Complete constructor sets: 간단한 rule로 false positives 제거 가능

Contrast with if-else chain analysis:

If-else tree를 분석하려면:

  • 모든 경로를 traverse
  • 각 경로가 종료되는지 확인
  • Missing 경로를 역으로 추론

Decision tree는 construction 과정에서 바로 확인된다.


리터럴 패턴과 와일드카드 최적화

지금까지 constructor patterns (Nil, Cons)를 중심으로 설명했다. 하지만 실제 프로그래밍에서는 리터럴 패턴와일드카드 패턴도 매우 중요하다.

리터럴 패턴 컴파일 (Literal Pattern Compilation)

리터럴 패턴이란?

리터럴 패턴은 특정 상수 값과 매칭되는 패턴이다:

// 정수 리터럴 패턴
match x with
| 0 -> "zero"
| 1 -> "one"
| 2 -> "two"
| _ -> "other"

// 문자열 리터럴 패턴 (문자열 타입이 있다면)
match color with
| "red" -> 0xFF0000
| "green" -> 0x00FF00
| "blue" -> 0x0000FF
| _ -> 0x000000

Constructor patterns와의 차이:

특성Constructor PatternLiteral Pattern
예제Nil, Cons(x, xs)0, 1, 42
분해Subpatterns 있음Subpatterns 없음
값의 개수유한 (finite set)무한 (infinite set)
테스트 방법Tag switchEquality comparison
MLIR operationscf.index_switcharith.cmpi + scf.if

리터럴 패턴의 특징:

  1. 분해되지 않음: Cons(x, xs)xxs로 분해되지만, 42는 그 자체로 atomic하다
  2. 무한 가능성: 정수는 무한히 많으므로 모든 case를 나열할 수 없다
  3. 등호 테스트: Constructor tag가 아니라 값 자체를 비교해야 한다

리터럴 패턴의 Pattern Matrix

Example: Modulo 3 classification

let classify_mod3 n =
    match n % 3 with
    | 0 -> "divisible by 3"
    | 1 -> "remainder 1"
    | 2 -> "remainder 2"
    | _ -> "unexpected"  // 논리적으로 unreachable

Pattern matrix:

Scrutinee: [x]  (where x = n % 3)

Matrix:
| 0   →  "divisible by 3"
| 1   →  "remainder 1"
| 2   →  "remainder 2"
| _   →  "unexpected"

리터럴 패턴의 specialization:

리터럴 lit에 대한 specialization S(lit, 0, P):

S(0, 0, P):
| →  "divisible by 3"   (from 0 pattern)
| →  "unexpected"       (from _ pattern)

리터럴 0과 호환되는 rows만 남는다:

  • Row 1 (0): 리터럴 0과 일치 → 유지
  • Row 2 (1): 리터럴 1 ≠ 0 → 제거
  • Row 3 (2): 리터럴 2 ≠ 0 → 제거
  • Row 4 (_): Wildcard는 모든 값과 호환 → 유지

리터럴 specialization 후에는 column이 사라진다 (리터럴은 subpatterns가 없음).

리터럴 패턴 vs Constructor 패턴 컴파일

Constructor patterns (유한 set):

// List patterns: {Nil, Cons} = complete set
%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%result = scf.index_switch %tag : i32 -> i32
case 0 { /* Nil */ }
case 1 { /* Cons */ }
default { /* unreachable */ }

O(1) dispatch: Tag 값으로 바로 jump.

Literal patterns (무한 set):

// Integer patterns: 0, 1, 2, ... = infinite set
%is_zero = arith.cmpi eq, %x, %c0 : i32
%result = scf.if %is_zero -> i32 {
    scf.yield %zero_result : i32
} else {
    %is_one = arith.cmpi eq, %x, %c1 : i32
    %result1 = scf.if %is_one -> i32 {
        scf.yield %one_result : i32
    } else {
        %is_two = arith.cmpi eq, %x, %c2 : i32
        %result2 = scf.if %is_two -> i32 {
            scf.yield %two_result : i32
        } else {
            scf.yield %default_result : i32
        }
        scf.yield %result2 : i32
    }
    scf.yield %result1 : i32
}

O(n) sequential tests: 각 리터럴을 순서대로 비교.

Decision Tree for Literal Patterns

Example: FizzBuzz remainder check

match (n % 3, n % 5) with
| (0, 0) -> "FizzBuzz"
| (0, _) -> "Fizz"
| (_, 0) -> "Buzz"
| (_, _) -> string_of_int n

Decision tree:

       [n % 3]
          |
    Test: == 0?
      /      \
   Yes        No
   /            \
 [n % 5]      [n % 5]
   |            |
 == 0?        == 0?
  / \          / \
Yes  No      Yes  No
 |    |       |    |
FB   Fizz   Buzz  n

생성된 코드:

%mod3 = arith.remsi %n, %c3 : i32
%mod5 = arith.remsi %n, %c5 : i32

%is_div3 = arith.cmpi eq, %mod3, %c0 : i32
%result = scf.if %is_div3 -> !llvm.ptr<i8> {
    // First column is 0
    %is_div5 = arith.cmpi eq, %mod5, %c0 : i32
    %inner = scf.if %is_div5 -> !llvm.ptr<i8> {
        scf.yield %fizzbuzz : !llvm.ptr<i8>
    } else {
        scf.yield %fizz : !llvm.ptr<i8>
    }
    scf.yield %inner : !llvm.ptr<i8>
} else {
    // First column is not 0
    %is_div5_2 = arith.cmpi eq, %mod5, %c0 : i32
    %inner2 = scf.if %is_div5_2 -> !llvm.ptr<i8> {
        scf.yield %buzz : !llvm.ptr<i8>
    } else {
        %str = func.call @int_to_string(%n) : (i32) -> !llvm.ptr<i8>
        scf.yield %str : !llvm.ptr<i8>
    }
    scf.yield %inner2 : !llvm.ptr<i8>
}

와일드카드 최적화 (Wildcard Optimization)

와일드카드 패턴 _의 핵심 특성:

Wildcard는 어떤 런타임 테스트도 생성하지 않는다.

Example 1: Wildcard in constructor pattern

match list with
| Cons(_, tail) -> length tail + 1
| Nil -> 0

_ vs named variable:

// Case A: Wildcard (no extraction)
| Cons(_, tail) -> ...

// Case B: Named variable (extraction needed)
| Cons(head, tail) -> ...

생성된 코드 비교:

// Case A: Wildcard - head 추출 안 함
case 1 {  // Cons
    // %head = 추출 안 함! (unused)
    %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
    %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
    // ...
}

// Case B: Named variable - head 추출 필요
case 1 {  // Cons
    %head = llvm.load %data : !llvm.ptr -> i32  // 추출함
    %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
    %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
    // ...
}

Wildcard 최적화 효과:

  • 메모리 접근 감소: 불필요한 load 제거
  • 레지스터 절약: 사용하지 않는 값을 저장 안 함
  • Dead code elimination 촉진: 컴파일러가 더 쉽게 최적화

Example 2: Wildcard as default case

match color_code with
| 0 -> "black"
| 1 -> "white"
| _ -> "unknown"

Wildcard default는 테스트를 생성하지 않는다:

%is_black = arith.cmpi eq, %color, %c0 : i32
%result = scf.if %is_black -> !llvm.ptr<i8> {
    scf.yield %black_str : !llvm.ptr<i8>
} else {
    %is_white = arith.cmpi eq, %color, %c1 : i32
    %result1 = scf.if %is_white -> !llvm.ptr<i8> {
        scf.yield %white_str : !llvm.ptr<i8>
    } else {
        // _ case: NO TEST, just yield
        scf.yield %unknown_str : !llvm.ptr<i8>
    }
    scf.yield %result1 : !llvm.ptr<i8>
}

Default branch에서는 equality test가 없다!

이전 tests가 모두 실패했으면 자동으로 default case가 실행된다.

생성 코드 비교 (Generated Code Comparison)

패턴 종류별 MLIR operation mapping:

Pattern TypeTest OperationDispatch MethodBranch Count
Constructor (closed)llvm.extractvalue (tag)scf.index_switchO(1)
Constructor (open)llvm.extractvalue (tag)scf.index_switch + defaultO(1)
Literalarith.cmpi eqscf.if chainO(n) sequential
WildcardNoneFallthrough0 (no test)
VariableNoneBinding only0 (no test)

Complete example: Mixed patterns

match (list, n) with
| (Nil, _) -> 0
| (Cons(x, _), 0) -> x
| (Cons(x, xs), n) -> x + process xs (n - 1)

Decision tree structure:

        [list]
           |
      Constructor test
        /        \
      Nil       Cons
       |          |
    yield 0    [n]
              Literal test
                /    \
            n==0    n!=0
              |        |
          yield x  yield x + ...

생성된 MLIR (simplified):

// Step 1: Constructor test on list
%list_tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%tag_index = arith.index_cast %list_tag : i32 to index

%result = scf.index_switch %tag_index : index -> i32
case 0 {  // Nil
    // Wildcard _ on n: NO TEST
    %zero = arith.constant 0 : i32
    scf.yield %zero : i32
}
case 1 {  // Cons
    %data = llvm.extractvalue %list[1] : !llvm.struct<(i32, ptr)>
    %x = llvm.load %data : !llvm.ptr -> i32

    // Step 2: Literal test on n
    %is_zero = arith.cmpi eq, %n, %c0 : i32
    %inner = scf.if %is_zero -> i32 {
        // Literal 0 matched, wildcard _ on tail: NO extraction
        scf.yield %x : i32
    } else {
        // Default case, extract tail for recursion
        %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
        %xs = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
        %n_minus_1 = arith.subi %n, %c1 : i32
        %rest = func.call @process(%xs, %n_minus_1) : (...) -> i32
        %sum = arith.addi %x, %rest : i32
        scf.yield %sum : i32
    }
    scf.yield %inner : i32
}

리터럴 패턴 최적화 기회

1. Jump table for dense ranges

리터럴이 0, 1, 2, …와 같이 연속적일 때:

// Before: Sequential tests
%is_0 = arith.cmpi eq, %x, %c0
scf.if %is_0 { ... } else {
    %is_1 = arith.cmpi eq, %x, %c1
    scf.if %is_1 { ... } else { ... }
}

// After: Range check + index_switch
%in_range = arith.cmpi ult, %x, %c3 : i32
scf.if %in_range {
    %idx = arith.index_cast %x : i32 to index
    scf.index_switch %idx {
        case 0 { ... }
        case 1 { ... }
        case 2 { ... }
    }
} else {
    // default
}

2. LLVM switch optimization

LLVM backend는 sequential comparisons를 자동으로 switch instruction으로 변환할 수 있다:

; Input: sequential icmp + br
%cmp0 = icmp eq i32 %x, 0
br i1 %cmp0, label %case0, label %check1
check1:
%cmp1 = icmp eq i32 %x, 1
br i1 %cmp1, label %case1, label %default

; Optimized: switch instruction
switch i32 %x, label %default [
    i32 0, label %case0
    i32 1, label %case1
]

3. Guard patterns (future)

리터럴 테스트와 predicate guard를 결합:

match x with
| n when n > 0 && n < 100 -> "small positive"
| n when n >= 100 -> "large positive"
| _ -> "non-positive"

이런 guard patterns는 Phase 7 이후에 다룰 수 있다.

Wildcard Specialization 규칙 상세

Wildcard expansion for different constructor arities:

ConstructorArityWildcard Expansion
Nil0[] (empty)
Cons2[_, _]
Some1[_]
Pair2[_, _]
Triple3[_, _, _]

Example: Option type

type 'a option = None | Some of 'a

match opt with
| Some x -> x + 1
| _ -> 0

Specialization of _ on Some:

Original:
| Some x  →  x + 1
| _       →  0

S(Some, 0, P):
| x  →  x + 1    (from Some x)
| _  →  0        (from _, expanded to Some _)

Wildcard는 어떤 constructor와도 호환된다!

Key Takeaways

  1. 리터럴 패턴은 equality tests를 생성한다 (arith.cmpi eq)
  2. Constructor 패턴은 tag switch를 생성한다 (scf.index_switch)
  3. Wildcard는 테스트를 생성하지 않는다 (fallthrough/binding only)
  4. 리터럴 set이 연속적이면 switch 최적화 가능
  5. Wildcard default는 마지막 else branch로 컴파일
  6. Named variables와 wildcards는 semantics만 다름 (binding vs no binding)

Summary and Next Steps

Chapter 17 핵심 개념 정리

1. Pattern Matrix Representation

  • Rows = pattern clauses
  • Columns = scrutinees
  • Cells = patterns (wildcard, constructor, literal)
  • Occurrence vectors = access paths to values

2. Decision Tree Structure

  • Internal nodes = tests (constructor, literal)
  • Edges = outcomes (Nil, Cons, default)
  • Leaves = actions (success, failure)
  • Property: Each subterm tested at most once

3. Specialization Operation

  • Filters rows compatible with constructor
  • Expands constructor patterns to subpatterns
  • Updates occurrence vector with subpaths
  • Formula: S(c, i, P) = specialized matrix

4. Defaulting Operation

  • Keeps only wildcard rows
  • Removes tested column
  • Detects non-exhaustiveness (empty result)
  • Formula: D(i, P) = default matrix

5. Compilation Algorithm

  • Recursive function: compile(P, π) = DecisionTree
  • Base cases: empty (failure), irrefutable (success)
  • Recursive case: select column, specialize, default
  • Heuristics: column selection strategy

6. Exhaustiveness Checking

  • Empty matrix = non-exhaustive match
  • Complete constructor sets = no default needed
  • Natural integration with compilation
  • Error reporting from failure leaves

Decision Tree Algorithm의 장점 요약

Efficiency:

  • O(d) tests (d = pattern depth), not O(n × d)
  • Each subterm tested exactly once
  • No redundant comparisons

Correctness:

  • Respects pattern order (first-match semantics)
  • Handles nested patterns systematically
  • Works with any constructor arity

Verification:

  • Exhaustiveness checking built-in
  • Detects missing cases at compile time
  • Identifies unreachable patterns

Optimization:

  • Structured representation enables optimizations
  • Column selection heuristics improve code quality
  • Complete constructor sets eliminate default branches

Pattern Matrix Workflow

전체 과정 요약:

1. FunLang match expression
   ↓
2. Pattern matrix + occurrence vector
   ↓
3. Recursive compilation algorithm
   ├─ Specialization (constructor tests)
   ├─ Defaulting (wildcard cases)
   └─ Column selection (heuristic)
   ↓
4. Decision tree
   ↓
5. MLIR IR (scf.index_switch, scf.if)
   ↓
6. LLVM IR (switch, br)

Connection to MLIR Lowering

Chapter 17 (theory) → Chapter 19 (implementation):

Pattern matrix → funlang.match operation:

%result = funlang.match %list : !funlang.list<i32> -> i32 {
^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32
^cons(%head: i32, %tail: !funlang.list<i32>):
    %one = arith.constant 1 : i32
    %len_tail = func.call @length(%tail) : (!funlang.list<i32>) -> i32
    %result = arith.addi %one, %len_tail : i32
    funlang.yield %result : i32
}

Decision tree → SCF dialect:

%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%result = scf.index_switch %tag : i32 -> i32
case 0 { ... }  // Nil branch
case 1 { ... }  // Cons branch

Specialization → Region block arguments:

^cons(%head: i32, %tail: !funlang.list<i32>):
// Block arguments = pattern variables from specialization

Exhaustiveness → Verification:

LogicalResult FunLang::MatchOp::verify() {
    // Check all constructor cases are present or wildcard exists
    if (!isExhaustive()) {
        return emitOpError("non-exhaustive pattern match");
    }
    return success();
}

Chapter 18-20 Preview

Chapter 18: List Operations

  • funlang.nil operation: Create empty list
  • funlang.cons operation: Prepend element to list
  • !funlang.list<T> type: Parameterized list type
  • LLVM representation: !llvm.struct<(i32, ptr)> with tag
  • Heap allocation with GC_malloc

Chapter 19: Match Compilation

  • funlang.match operation: Region-based pattern matching
  • Region block arguments for pattern variables
  • Lowering to SCF dialect (scf.index_switch)
  • OpConversionPattern with region handling
  • Type conversion for !funlang.list<T>

Chapter 20: Functional Programs

  • Complete examples: map, filter, fold
  • List manipulation functions
  • Recursive list traversal
  • Higher-order functions on lists
  • Performance analysis

Practice Questions

이 장을 이해했는지 확인하는 질문들:

Q1: Pattern matrix의 각 요소는 무엇을 의미하는가?

Answer
  • Rows: Pattern clauses (하나의 pattern -> action)
  • Columns: Scrutinees (매칭 대상 values)
  • Cells: Patterns (wildcard, constructor, literal)
  • Actions: 각 row가 매칭되면 실행할 코드

Q2: Specialization이 Cons(x, Nil) pattern을 어떻게 변환하는가?

Answer

Cons(x, Nil) → 두 개의 subpattern columns [x, Nil]

  • x: variable pattern (head)
  • Nil: constructor pattern (tail)

Occurrence vector도 확장:

  • [list][list.head, list.tail]

Q3: Defaulting 후 empty matrix는 무엇을 의미하는가?

Answer

Non-exhaustive pattern match.

  • 모든 rows가 constructor patterns → wildcard 없음
  • Default case가 없음 → 일부 values가 매칭 안 됨
  • Compiler error 발생

Q4: Decision tree가 if-else chain보다 효율적인 이유는?

Answer
  1. 각 subterm을 최대 한 번만 테스트 (no redundancy)
  2. Complete constructor sets에서 불필요한 비교 제거
  3. Structured representation으로 optimization 가능
  4. O(d) tests (d = depth), not O(n × d) (n = patterns)

Q5: Column selection heuristic이 왜 필요한가?

Answer

여러 scrutinees가 있을 때 테스트 순서가 효율성에 영향을 준다.

  • 좋은 순서: Constructor patterns가 많은 column 먼저
  • 나쁜 순서: Wildcard가 많은 column 먼저 (별 정보 없음)

Heuristic으로 optimal에 가까운 decision tree 생성.

성공 기준 달성 확인

이 장의 목표를 모두 달성했는가?

  • Pattern matrix 표현법을 이해한다

    • Rows, columns, occurrences 개념 설명
    • Example matrices with nested patterns
  • Decision tree 알고리즘의 동작 원리를 안다

    • Recursive compilation function
    • Base cases (empty, irrefutable)
    • Recursive case (specialize, default)
  • Specialization과 defaulting 연산을 설명할 수 있다

    • Specialization: constructor assumption + decomposition
    • Defaulting: wildcard filtering + column removal
    • Occurrence vector updates
  • Exhaustiveness checking이 어떻게 동작하는지 안다

    • Empty matrix detection
    • Complete constructor sets
    • Error reporting strategies
  • Chapter 18-19에서 MLIR 구현을 시작할 준비가 된다

    • Pattern matrix → funlang.match operation mapping
    • Decision tree → SCF dialect lowering
    • Specialization → Region block arguments

Next chapter: Let’s build the foundation for pattern matching—list data structures!

Chapter 18: List Operations (List Operations)

소개

Chapter 17에서는 패턴 매칭 컴파일의 이론적 기반을 다뤘다:

  • Decision tree 알고리즘 (Maranget 2008)
  • Pattern matrix 표현법
  • Specialization과 defaulting 연산
  • Exhaustiveness checking

Chapter 18에서는 패턴 매칭이 작동할 데이터 구조를 구현한다. FunLang dialect에 list operations를 추가하여 불변 리스트를 만들고 조작할 수 있게 한다.

Chapter 17 복습: 왜 List Operations가 먼저인가?

Chapter 17에서 우리는 decision tree 알고리즘을 배웠다:

// F# 패턴 매칭 예제
let rec sum_list lst =
    match lst with
    | [] -> 0                           // Nil pattern
    | head :: tail -> head + sum_list tail  // Cons pattern

sum_list [1; 2; 3]  // 6

Decision tree 컴파일 과정:

  1. Pattern matrix 구성: [[]; [Cons(head, tail)]]
  2. Specialization: Nil case, Cons case 분리
  3. Code generation: 각 case에 대한 MLIR 코드 생성

하지만 MLIR로 변환하려면 무엇이 필요한가?

// 목표: 이런 MLIR을 생성하고 싶다
%result = funlang.match %list : !funlang.list<i32> -> i32 {
  ^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32
  ^cons(%head: i32, %tail: !funlang.list<i32>):
    // ... recursive call ...
    funlang.yield %sum : i32
}

필요한 요소들:

  1. List data structure: !funlang.list<T> 타입으로 리스트 표현
  2. List construction: funlang.nil, funlang.cons로 리스트 생성
  3. Pattern matching: funlang.match로 리스트 분해 (Chapter 19)

왜 이 순서인가?

  • 데이터 구조 없이는 패턴 매칭할 대상이 없다
  • funlang.match!funlang.list 타입을 입력으로 받는다
  • List operations를 먼저 구현하면 Chapter 19에서 funlang.match만 집중할 수 있다

Chapter 18의 목표

이 장에서 구현할 것:

  1. List Representation Design

    • Tagged union으로 Nil/Cons 구분
    • GC-allocated cons cells
    • Immutable shared structure
  2. FunLang List Type

    • !funlang.list<T> parameterized type
    • TableGen 정의, C API shim, F# bindings
  3. funlang.nil Operation

    • Empty list 생성
    • Constant representation (no allocation)
  4. funlang.cons Operation

    • Cons cell 생성 (head :: tail)
    • GC allocation for cell
  5. TypeConverter for Lists

    • !funlang.list<T>!llvm.struct<(i32, ptr)> 변환
    • Extending FunLangTypeConverter from Chapter 16
  6. Lowering Patterns

    • NilOpLowering: struct construction
    • ConsOpLowering: GC_malloc + store operations

Before vs After: List Operations의 위력

Before (만약 list operations 없이 직접 구현한다면):

// Empty list: 수동으로 struct 구성
%tag_zero = arith.constant 0 : i32
%null_ptr = llvm.mlir.zero : !llvm.ptr
%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%s1 = llvm.insertvalue %tag_zero, %undef[0] : !llvm.struct<(i32, ptr)>
%empty = llvm.insertvalue %null_ptr, %s1[1] : !llvm.struct<(i32, ptr)>

// Cons cell: 8줄 이상의 GC_malloc + store 패턴
%cell_size = arith.constant 16 : i64
%cell_ptr = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr
%head_ptr = llvm.getelementptr %cell_ptr[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %head_val, %head_ptr : i32, !llvm.ptr
%tail_ptr = llvm.getelementptr %cell_ptr[1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %tail_val, %tail_ptr : !llvm.ptr, !llvm.ptr
%tag_one = arith.constant 1 : i32
%s1 = llvm.insertvalue %tag_one, %undef[0] : !llvm.struct<(i32, ptr)>
%list = llvm.insertvalue %cell_ptr, %s1[1] : !llvm.struct<(i32, ptr)>

After (Chapter 18 구현 후):

// Empty list: 1줄!
%empty = funlang.nil : !funlang.list<i32>

// Cons cell: 1줄!
%list = funlang.cons %head, %tail : !funlang.list<i32>

// Building [1, 2, 3]: 4줄
%nil = funlang.nil : !funlang.list<i32>
%lst1 = funlang.cons %c3, %nil : !funlang.list<i32>
%lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>
%lst3 = funlang.cons %c1, %lst2 : !funlang.list<i32>

개선 효과:

  • 코드 줄 수: 15+ 줄 → 1-2줄 (90%+ 감소!)
  • 가독성: 저수준 struct 조작 제거, 의도 명확
  • 타입 안전성: !funlang.list<T> parameterized type으로 element type 검증
  • 최적화 가능성: Empty list sharing, cons cell inlining

Chapter 15 복습: Custom Operations 패턴

Chapter 15에서 우리는 funlang.closurefunlang.apply를 구현하며 custom operations 패턴을 배웠다:

1. TableGen ODS 정의

def FunLang_ClosureOp : FunLang_Op<"closure", [Pure]> {
  let summary = "Create closure";
  let arguments = (ins FlatSymbolRefAttr:$fn, Variadic<AnyType>:$captures);
  let results = (outs FunLang_ClosureType:$result);
  let assemblyFormat = "$fn `,` $captures attr-dict `:` type($result)";
}

2. C API Shim

extern "C" MlirOperation mlirFunLangClosureOpCreate(
    MlirLocation loc, MlirAttribute fn, MlirValue *captures, intptr_t nCaptures) {
  return wrap(builder.create<funlang::ClosureOp>(loc, fn, ValueRange));
}

3. F# Bindings

member this.CreateClosure(fn: string, captures: MlirValue list) : MlirValue =
    let op = funlang.CreateClosureOp(loc, fn, captures)
    GetOperationResult(op, 0)

Chapter 18에서도 동일한 패턴을 적용한다:

  • funlang.nil ← TableGen → C API → F# bindings
  • funlang.cons ← TableGen → C API → F# bindings
  • !funlang.list<T> ← TableGen → C API → F# bindings

Chapter 18 로드맵

Part 1 (현재 섹션):

  • List representation design
  • !funlang.list<T> parameterized type
  • funlang.nil operation
  • funlang.cons operation

Part 2 (다음 섹션):

  • TypeConverter for !funlang.list<T>
  • NilOpLowering pattern
  • ConsOpLowering pattern
  • Complete lowering pass update

성공 기준

이 장을 완료하면:

  • List의 메모리 표현(tagged union)을 이해한다
  • !funlang.list<T> 타입을 TableGen으로 정의할 수 있다
  • funlang.nilfunlang.cons의 동작 원리를 안다
  • TypeConverter로 FunLang → LLVM 타입 변환을 구현할 수 있다
  • Lowering pattern으로 operation을 LLVM dialect로 변환할 수 있다
  • Chapter 19에서 funlang.match 구현을 시작할 준비가 된다

Let’s build the foundation for pattern matching—list data structures!


List Representation Design

함수형 언어에서 리스트는 가장 기본적인 데이터 구조다. Immutable linked list는 다음 특징을 가진다:

  • Immutability: 한번 생성되면 변경 불가 (functional purity)
  • Structural sharing: 서브리스트를 공유하여 메모리 효율적
  • Recursive structure: Nil (empty) 또는 Cons (head, tail)

List는 Algebraic Data Type이다

함수형 언어에서 리스트는 sum type (tagged union)으로 정의된다:

// F#
type List<'T> =
    | Nil
    | Cons of 'T * List<'T>

// 예제
let empty = Nil
let one = Cons(1, Nil)               // [1]
let three = Cons(1, Cons(2, Cons(3, Nil)))  // [1; 2; 3]
(* OCaml *)
type 'a list =
  | []
  | (::) of 'a * 'a list

(* 예제 *)
let empty = []
let one = 1 :: []
let three = 1 :: 2 :: 3 :: []
-- Haskell
data List a = Nil | Cons a (List a)

-- 예제
empty = Nil
one = Cons 1 Nil
three = Cons 1 (Cons 2 (Cons 3 Nil))

공통 패턴:

  1. Two constructors: Nil (empty), Cons (non-empty)
  2. Type parameter: 'T, 'a, a (element type)
  3. Recursive definition: Cons의 tail은 List 자체

Tagged Union Representation

LLVM에서 sum type을 표현하는 일반적인 방법:

Discriminator tag + Data pointer

struct TaggedUnion {
    i32 tag;        // 0 = Nil, 1 = Cons, 2 = OtherVariant, ...
    ptr data;       // variant-specific data
}

List의 경우:

!llvm.struct<(i32, ptr)>

- tag = 0: Nil (data = null)
- tag = 1: Cons (data = pointer to {head, tail})

메모리 레이아웃:

Nil representation:
┌─────┬──────┐
│  0  │ null │
└─────┴──────┘
  tag   data

Cons representation:
┌─────┬──────┐        ┌────────┬──────────┐
│  1  │ ptr  │───────>│  head  │   tail   │
└─────┴──────┘        └────────┴──────────┘
  tag   data            element   ptr/struct

Cons Cell Memory Layout

Cons cell은 heap에 할당되는 구조체다:

Cons Cell = struct {
    element: T,           // head value
    tail: !llvm.struct<(i32, ptr)>  // tail as tagged union
}

예제: 리스트 [1, 2, 3]의 메모리 구조

%lst3 = Cons(1, Cons(2, Cons(3, Nil)))

Stack (list values as tagged unions):
%lst3: {1, ptr_to_cell1}
%lst2: {1, ptr_to_cell2}
%lst1: {1, ptr_to_cell3}
%nil:  {0, null}

Heap (cons cells):
cell1: {1, %lst2}
       ↑   ↓
     head  tail

cell2: {2, %lst1}
       ↑   ↓
     head  tail

cell3: {3, %nil}
       ↑   ↓
     head  tail (= {0, null})

Visual representation:

%lst3               cell1              %lst2              cell2              %lst1              cell3              %nil
┌───┬────┐          ┌───┬──────┐       ┌───┬────┐         ┌───┬──────┐       ┌───┬────┐         ┌───┬──────┐       ┌───┬──────┐
│ 1 │ ●──┼─────────>│ 1 │ ●────┼──────>│ 1 │ ●──┼────────>│ 2 │ ●────┼──────>│ 1 │ ●──┼────────>│ 3 │ ●────┼──────>│ 0 │ null │
└───┴────┘          └───┴──────┘       └───┴────┘         └───┴──────┘       └───┴────┘         └───┴──────┘       └───┴──────┘

GC Allocation for Cons Cells

Cons cell은 항상 heap에 할당된다:

이유:

  1. Escape analysis: 리스트는 함수 반환값으로 사용됨 (upward funarg)
  2. Sharing: 여러 리스트가 같은 tail을 공유할 수 있음
  3. Lifetime: 리스트의 lifetime은 생성 함수보다 길 수 있음

Allocation strategy:

// funlang.cons %head, %tail

// Lowering:
%cell_size = arith.constant 16 : i64  // sizeof(element) + sizeof(ptr)
%cell_ptr = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr

// Store head
%head_offset = llvm.getelementptr %cell_ptr[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %head, %head_offset : i32, !llvm.ptr

// Store tail
%tail_offset = llvm.getelementptr %cell_ptr[1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %tail, %tail_offset : !llvm.struct<(i32, ptr)>, !llvm.ptr

// Build tagged union
%tag = arith.constant 1 : i32
%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
%result = llvm.insertvalue %cell_ptr, %s1[1] : !llvm.struct<(i32, ptr)>

GC의 역할:

  • Cons cells는 명시적으로 free하지 않는다
  • Boehm GC가 reachability를 추적하여 자동으로 수집
  • Chapter 9에서 설정한 GC infrastructure 활용

Immutability와 Structural Sharing

Immutability:

// 리스트 생성
%lst1 = funlang.cons %x, %nil : !funlang.list<i32>

// "수정" 불가능 (새 리스트 생성)
%lst2 = funlang.cons %y, %lst1 : !funlang.list<i32>
// %lst1은 변경되지 않음!

Structural sharing:

%nil = funlang.nil : !funlang.list<i32>
%lst1 = funlang.cons %c1, %nil : !funlang.list<i32>  // [1]
%lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>  // [2, 1]
%lst3 = funlang.cons %c3, %lst1 : !funlang.list<i32>  // [3, 1]

// %lst2와 %lst3는 %lst1을 tail로 공유!

메모리 효율:

Without sharing (mutable arrays):
[2, 1]: 2개 원소 저장
[3, 1]: 2개 원소 저장
Total: 4개 원소

With sharing (immutable lists):
[2, 1]: cell(2) → cell(1) → Nil
[3, 1]: cell(3) ──┘
Total: 3개 cons cells (원소 중복 없음)

장점:

  1. 메모리 효율: 공통 sublist를 재사용
  2. 안전성: Aliasing bugs 없음 (immutable)
  3. 병렬성: Race conditions 없음
  4. Persistent data structures: 이전 버전 유지 가능

Element Type Considerations

리스트는 parameterized type이어야 한다:

타입 안전성:

// 올바른 타입: !funlang.list<i32>
%int_list = funlang.nil : !funlang.list<i32>
%int_cons = funlang.cons %x, %int_list : !funlang.list<i32>
// Type checker verifies: %x must be i32

// 잘못된 타입: !funlang.list (opaque - no element type)
%list = funlang.nil : !funlang.list
%cons = funlang.cons %x, %list : !funlang.list
// Type checker CANNOT verify: %x type unknown

Cons cell storage:

  • Element type은 cons cell에 저장됨 (not in list struct)
  • List struct는 tag + pointer만 포함
  • Element type은 컴파일 타임 정보 (type safety)

Type parameter in lowering:

!funlang.list<i32> → !llvm.struct<(i32, ptr)>
!funlang.list<f64> → !llvm.struct<(i32, ptr)>
!funlang.list<!funlang.closure> → !llvm.struct<(i32, ptr)>

// 런타임 표현은 동일! (opaque pointer)
// 컴파일 타임에만 element type 검증

List Representation vs Array Representation

왜 linked list인가? 배열보다 나은가?

AspectLinked ListArray
Random accessO(n)O(1)
Prepend (cons)O(1)O(n) - copy
AppendO(n)O(1) or O(n)
Structural sharingO(1)Impossible (mutable)
Pattern matchingNatural (Nil/Cons)Complex (length check + index)
MemoryPointer overheadContiguous, cache-friendly

함수형 언어에서 linked list를 선호하는 이유:

  1. Immutability: Sharing이 메모리 효율적
  2. Pattern matching: Constructor-based decomposition 자연스러움
  3. Recursion: Recursive structure와 recursive functions 매칭
  4. Prepend: 대부분의 list operations는 prepend 중심 (cons, map, filter)

Array가 더 나은 경우:

  • Random access가 주요 operation
  • Numeric computing (SIMD, vectorization)
  • Cache locality가 중요한 tight loop

FunLang의 선택:

  • Phase 6는 linked list로 구현 (함수형 언어 교육 목적)
  • Phase 7에서 array/vector 추가 가능 (performance-critical code)

Comparison with Other Implementations

OCaml list representation:

// OCaml runtime
typedef uintnat value;

#define Val_int(x) ((value)((x) << 1) + 1)
#define Int_val(x) ((long)(x) >> 1)

// List: []
#define Val_emptylist Val_int(0)

// List: head :: tail
struct list_cell {
    value header;  // GC header
    value head;
    value tail;
};

Haskell list representation (GHC):

// Haskell runtime
typedef struct {
    StgHeader header;
    StgClosure *head;
    StgClosure *tail;
} StgCons;

// [] is a special constructor (static object)

FunLang’s simpler approach:

  • No GC header (Boehm GC handles this internally)
  • Tagged union explicit (tag + data)
  • Uniform representation (LLVM struct)

Summary: List Representation Design

핵심 결정사항:

  1. Tagged union: !llvm.struct<(i32, ptr)> for Nil/Cons discrimination
  2. Cons cells: Heap-allocated {element, tail} structs via GC_malloc
  3. Immutability: 리스트는 생성 후 변경 불가
  4. Structural sharing: 여러 리스트가 tail을 공유 가능
  5. Parameterized type: !funlang.list<T> for type safety

다음 섹션에서:

  • !funlang.list<T> TableGen 정의
  • funlang.nil operation 구현
  • funlang.cons operation 구현

FunLang List Type

이제 list를 표현할 MLIR type을 정의한다. Chapter 15에서 배운 parameterized type 패턴을 적용한다.

Parameterized Type의 필요성

!funlang.list가 아니라 !funlang.list<T>인가?

// 잘못된 설계: Opaque list type
def FunLang_ListType : FunLang_Type<"List", "list"> {
  // No type parameters!
}

// 사용 예
%list1 = funlang.nil : !funlang.list  // 어떤 타입의 원소?
%list2 = funlang.cons %x, %list1 : !funlang.list  // %x의 타입은?

// 문제점:
// 1. Type checker가 element type을 검증할 수 없음
// 2. funlang.cons의 head 타입이 tail의 element type과 일치하는지 확인 불가
// 3. funlang.match의 cons region에서 head의 타입을 추론할 수 없음

올바른 설계: Parameterized type

def FunLang_ListType : FunLang_Type<"List", "list", [
    TypeParameter<"Type", "elementType">
]> {
  // Type parameter: T
}

// 사용 예
%int_list = funlang.nil : !funlang.list<i32>
%float_list = funlang.nil : !funlang.list<f64>
%closure_list = funlang.nil : !funlang.list<!funlang.closure>

// 장점:
// 1. Type checker가 element type 검증
// 2. funlang.cons %x, %tail에서 %x : T (T는 tail의 element type)
// 3. funlang.match의 ^cons region에서 head : T

TableGen Type Definition

파일: mlir/include/mlir/Dialect/FunLang/FunLangOps.td

//===----------------------------------------------------------------------===//
// FunLang Types
//===----------------------------------------------------------------------===//

// ClosureType (Chapter 15)
def FunLang_ClosureType : FunLang_Type<"Closure", "closure"> {
  let summary = "FunLang closure type (opaque)";

  let description = [{
    Represents a closure (function + captured environment).

    Syntax: `!funlang.closure`

    Lowering:
    - FunLang dialect: !funlang.closure
    - LLVM dialect: !llvm.ptr

    Internal representation (after lowering):
    ```
    struct {
        ptr fn_ptr;      // function pointer
        T1 capture1;     // captured variable 1
        T2 capture2;     // captured variable 2
        ...
    }
    ```
  }];
}

// ListType (Chapter 18)
def FunLang_ListType : FunLang_Type<"List", "list", [
    TypeParameter<"Type", "elementType">
]> {
  let summary = "FunLang immutable list type";

  let description = [{
    Represents an immutable linked list with type parameter.

    Syntax: `!funlang.list<T>`

    Type parameter:
    - T: Element type (any MLIR type)

    Examples:
    ```
    !funlang.list<i32>          // List of integers
    !funlang.list<f64>          // List of floats
    !funlang.list<!funlang.closure>  // List of closures
    !funlang.list<!funlang.list<i32>>  // List of lists (nested)
    ```

    Lowering:
    - FunLang dialect: !funlang.list<T>
    - LLVM dialect: !llvm.struct<(i32, ptr)>

    Internal representation (after lowering):
    ```
    struct TaggedUnion {
        i32 tag;        // 0 = Nil, 1 = Cons
        ptr data;       // nullptr for Nil, cons cell pointer for Cons
    }

    struct ConsCell {
        T element;      // head element
        TaggedUnion tail;  // tail list
    }
    ```

    Note: Element type T is compile-time information only.
          Runtime representation is uniform (opaque pointer).
  }];

  let parameters = (ins "Type":$elementType);

  let assemblyFormat = "`<` $elementType `>`";

  let builders = [
    TypeBuilder<(ins "Type":$elementType), [{
      return Base::get($_ctxt, elementType);
    }]>
  ];
}

핵심 요소:

  1. Type parameter: TypeParameter<"Type", "elementType">

    • C++ 클래스에서 Type getElementType() const 메서드 생성
    • Assembly format에서 !funlang.list<i32> 형태로 출력
  2. Assembly format: "`<` $elementType `>`"

    • <T> syntax for parameterized type
    • TableGen이 parser/printer 자동 생성
  3. Builder: 편의를 위한 생성자

    • FunLangListType::get(context, elementType)

Generated C++ Interface

TableGen이 생성하는 C++ 코드:

// mlir/include/mlir/Dialect/FunLang/FunLangTypes.h

namespace mlir {
namespace funlang {

class FunLangListType : public Type::TypeBase<
    FunLangListType,
    Type,
    detail::FunLangListTypeStorage,   // Storage for type parameters
    TypeTrait::HasTypeParameter> {    // Trait for parameterized types
public:
  using Base::Base;

  /// Create !funlang.list<elementType>
  static FunLangListType get(MLIRContext *context, Type elementType);

  /// Get element type from !funlang.list<T>
  Type getElementType() const;

  /// Parse !funlang.list<T> from assembly
  static Type parse(AsmParser &parser);

  /// Print !funlang.list<T> to assembly
  void print(AsmPrinter &printer) const;

  /// Verify type parameter is valid
  static LogicalResult verify(
      function_ref<InFlightDiagnostic()> emitError,
      Type elementType);
};

} // namespace funlang
} // namespace mlir

Storage implementation (TableGen이 생성):

namespace mlir {
namespace funlang {
namespace detail {

struct FunLangListTypeStorage : public TypeStorage {
  using KeyTy = Type;  // elementType is the key

  FunLangListTypeStorage(Type elementType) : elementType(elementType) {}

  bool operator==(const KeyTy &key) const {
    return elementType == key;
  }

  static FunLangListTypeStorage *construct(
      TypeStorageAllocator &allocator, const KeyTy &key) {
    return new (allocator.allocate<FunLangListTypeStorage>())
        FunLangListTypeStorage(key);
  }

  Type elementType;
};

} // namespace detail
} // namespace funlang
} // namespace mlir

Type Uniquing

MLIR은 type uniquing을 자동으로 수행한다:

// Same element type → same type instance
auto ctx = /* context */;
auto i32Ty = IntegerType::get(ctx, 32);

auto listTy1 = FunLangListType::get(ctx, i32Ty);
auto listTy2 = FunLangListType::get(ctx, i32Ty);

assert(listTy1 == listTy2);  // Same pointer!

장점:

  • Type comparison은 pointer equality (==)
  • Type hashing 효율적
  • Memory 효율적 (각 unique type은 한 번만 저장)

C API Shim

F#에서 사용하기 위한 C API:

파일: mlir/lib/CAPI/Dialect/FunLang.cpp

//===----------------------------------------------------------------------===//
// ListType
//===----------------------------------------------------------------------===//

/// Create !funlang.list<elementType>
MlirType mlirFunLangListTypeGet(MlirContext ctx, MlirType elementType) {
  return wrap(funlang::FunLangListType::get(
      unwrap(ctx), unwrap(elementType)));
}

/// Check if type is !funlang.list
bool mlirTypeIsAFunLangListType(MlirType ty) {
  return unwrap(ty).isa<funlang::FunLangListType>();
}

/// Get element type from !funlang.list<T>
MlirType mlirFunLangListTypeGetElementType(MlirType ty) {
  auto listTy = unwrap(ty).cast<funlang::FunLangListType>();
  return wrap(listTy.getElementType());
}

헤더 파일: mlir/include/mlir-c/Dialect/FunLang.h

#ifndef MLIR_C_DIALECT_FUNLANG_H
#define MLIR_C_DIALECT_FUNLANG_H

#include "mlir-c/IR.h"

#ifdef __cplusplus
extern "C" {
#endif

//===----------------------------------------------------------------------===//
// ListType
//===----------------------------------------------------------------------===//

/// Create !funlang.list<elementType> type
MLIR_CAPI_EXPORTED MlirType
mlirFunLangListTypeGet(MlirContext ctx, MlirType elementType);

/// Check if type is !funlang.list
MLIR_CAPI_EXPORTED bool
mlirTypeIsAFunLangListType(MlirType ty);

/// Get element type from !funlang.list<T>
MLIR_CAPI_EXPORTED MlirType
mlirFunLangListTypeGetElementType(MlirType ty);

#ifdef __cplusplus
}
#endif

#endif // MLIR_C_DIALECT_FUNLANG_H

F# Bindings

파일: FunLang.Compiler/MlirBindings.fs

module FunLangBindings =
    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangListTypeGet(MlirContext ctx, MlirType elementType)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern bool mlirTypeIsAFunLangListType(MlirType ty)

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirType mlirFunLangListTypeGetElementType(MlirType ty)

FunLangDialect wrapper:

type FunLangDialect(ctx: MlirContext) =
    member this.Context = ctx

    //==========================================================================
    // Types
    //==========================================================================

    /// Create !funlang.closure type
    member this.ClosureType() : MlirType =
        FunLangBindings.mlirFunLangClosureTypeGet(this.Context)

    /// Check if type is !funlang.closure
    member this.IsClosureType(ty: MlirType) : bool =
        FunLangBindings.mlirTypeIsAFunLangClosureType(ty)

    /// Create !funlang.list<T> type
    member this.ListType(elementType: MlirType) : MlirType =
        FunLangBindings.mlirFunLangListTypeGet(this.Context, elementType)

    /// Check if type is !funlang.list
    member this.IsListType(ty: MlirType) : bool =
        FunLangBindings.mlirTypeIsAFunLangListType(ty)

    /// Get element type from !funlang.list<T>
    member this.ListElementType(ty: MlirType) : MlirType =
        if not (this.IsListType(ty)) then
            invalidArg "ty" "Expected !funlang.list type"
        FunLangBindings.mlirFunLangListTypeGetElementType(ty)

OpBuilder extension:

type OpBuilder with
    /// Create !funlang.list<T> type
    member this.FunLangListType(elementType: MlirType) : MlirType =
        let funlang = FunLangDialect(this.Context)
        funlang.ListType(elementType)

F# Usage Examples

// F# compiler code
let compileListExpr (builder: OpBuilder) =
    // Create type: !funlang.list<i32>
    let i32Type = builder.IntegerType(32)
    let listType = builder.FunLangListType(i32Type)

    // Create empty list
    let nil = builder.CreateNil(listType)

    // Create cons cell
    let head = (* some i32 value *)
    let cons = builder.CreateCons(head, nil)

    cons

// Check if type is list type
let isListType (ty: MlirType) =
    let funlang = FunLangDialect(ctx)
    funlang.IsListType(ty)

// Get element type
let getElementType (listTy: MlirType) =
    let funlang = FunLangDialect(ctx)
    if funlang.IsListType(listTy) then
        Some (funlang.ListElementType(listTy))
    else
        None

Nested List Types

Parameterized type이므로 중첩 가능:

// List of lists
!funlang.list<!funlang.list<i32>>

// Example: [[1, 2], [3, 4]]
%inner_nil = funlang.nil : !funlang.list<i32>
%inner1 = funlang.cons %c2, %inner_nil : !funlang.list<i32>
%inner1 = funlang.cons %c1, %inner1 : !funlang.list<i32>  // [1, 2]

%inner2 = funlang.cons %c4, %inner_nil : !funlang.list<i32>
%inner2 = funlang.cons %c3, %inner2 : !funlang.list<i32>  // [3, 4]

%outer_nil = funlang.nil : !funlang.list<!funlang.list<i32>>
%outer = funlang.cons %inner2, %outer_nil : !funlang.list<!funlang.list<i32>>
%outer = funlang.cons %inner1, %outer : !funlang.list<!funlang.list<i32>>
// [[1, 2], [3, 4]]

Lowering:

!funlang.list<!funlang.list<i32>> → !llvm.struct<(i32, ptr)>

// 동일한 표현! Element type은 컴파일 타임 정보만

Type Verification

TableGen이 자동으로 verification 생성하지만, 추가 검증 가능:

LogicalResult FunLangListType::verify(
    function_ref<InFlightDiagnostic()> emitError,
    Type elementType) {
  // Element type must be non-null
  if (!elementType)
    return emitError() << "list element type cannot be null";

  // Additional constraints (if needed)
  // e.g., element type must be first-class (no void, etc.)

  return success();
}

Summary: FunLang List Type

구현 완료:

  • !funlang.list<T> parameterized type in TableGen
  • C++ interface with getElementType() method
  • C API shim: mlirFunLangListTypeGet, mlirTypeIsAFunLangListType, mlirFunLangListTypeGetElementType
  • F# bindings in FunLangDialect class
  • OpBuilder extension for convenient usage

다음 섹션:

  • funlang.nil operation으로 empty list 생성
  • funlang.cons operation으로 cons cell 생성

funlang.nil Operation

Empty list를 생성하는 operation을 구현한다.

Purpose and Semantics

funlang.nil의 역할:

  • Empty list (빈 리스트) 생성
  • 리스트의 base case (재귀의 종료 조건)
  • Runtime allocation 불필요 (constant representation)

예제:

// Create empty list of integers
%nil = funlang.nil : !funlang.list<i32>

// Create empty list of floats
%nil = funlang.nil : !funlang.list<f64>

// Create empty list of closures
%nil = funlang.nil : !funlang.list<!funlang.closure>

의미:

funlang.nil : !funlang.list<T>

// Equivalent to (after lowering):
{tag: 0, data: null}

TableGen ODS Definition

파일: mlir/include/mlir/Dialect/FunLang/FunLangOps.td

//===----------------------------------------------------------------------===//
// List Operations
//===----------------------------------------------------------------------===//

def FunLang_NilOp : FunLang_Op<"nil", [Pure]> {
  let summary = "Create empty list";

  let description = [{
    Creates an empty list (Nil constructor).

    Syntax:
    ```
    %nil = funlang.nil : !funlang.list<T>
    ```

    The result type specifies the element type of the list.

    Examples:
    ```
    // Empty list of integers
    %nil_int = funlang.nil : !funlang.list<i32>

    // Empty list of closures
    %nil_closure = funlang.nil : !funlang.list<!funlang.closure>
    ```

    Lowering:
    ```
    %nil = funlang.nil : !funlang.list<i32>

    // Lowers to:
    %tag = arith.constant 0 : i32
    %null = llvm.mlir.zero : !llvm.ptr
    %undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
    %nil = llvm.insertvalue %null, %s1[1] : !llvm.struct<(i32, ptr)>
    ```

    Traits: Pure (no side effects, no memory allocation)
  }];

  let arguments = (ins);

  let results = (outs FunLang_ListType:$result);

  let assemblyFormat = "attr-dict `:` type($result)";

  let builders = [
    OpBuilder<(ins "Type":$elementType), [{
      auto listType = funlang::FunLangListType::get($_builder.getContext(), elementType);
      $_state.addTypes(listType);
    }]>
  ];
}

핵심 요소:

  1. Pure trait: No side effects, 메모리 할당 없음

    • CSE (Common Subexpression Elimination) 가능
    • 같은 element type의 nil은 한 번만 생성 가능
  2. No arguments: Empty list는 인자 불필요

  3. Result type: !funlang.list<T> (element type 명시 필요)

  4. Assembly format: funlang.nil : !funlang.list<i32>

    • Type suffix로 element type 지정
  5. Builder: Element type만으로 NilOp 생성 가능

Generated C++ Interface

TableGen이 생성하는 C++ 코드:

// mlir/include/mlir/Dialect/FunLang/FunLangOps.h

namespace mlir {
namespace funlang {

class NilOp : public Op<
    NilOp,
    OpTrait::ZeroOperands,
    OpTrait::OneResult,
    OpTrait::Pure> {
public:
  using Op::Op;

  static StringRef getOperationName() { return "funlang.nil"; }

  /// Get result type (!funlang.list<T>)
  FunLangListType getType() {
    return getResult().getType().cast<FunLangListType>();
  }

  /// Get element type (T from !funlang.list<T>)
  Type getElementType() {
    return getType().getElementType();
  }

  /// Build NilOp with element type
  static void build(
      OpBuilder &builder,
      OperationState &state,
      Type elementType);

  /// Verify operation
  LogicalResult verify();

  /// Parse from assembly
  static ParseResult parse(OpAsmParser &parser, OperationState &result);

  /// Print to assembly
  void print(OpAsmPrinter &p);
};

} // namespace funlang
} // namespace mlir

Verification

LogicalResult NilOp::verify() {
  // Result must be !funlang.list<T>
  auto resultTy = getResult().getType();
  if (!resultTy.isa<FunLangListType>()) {
    return emitOpError("result must be !funlang.list type");
  }

  // Element type must be valid (checked by FunLangListType::verify)
  return success();
}

C API Shim

파일: mlir/lib/CAPI/Dialect/FunLang.cpp

//===----------------------------------------------------------------------===//
// NilOp
//===----------------------------------------------------------------------===//

MlirOperation mlirFunLangNilOpCreate(
    MlirLocation loc,
    MlirType elementType) {
  mlir::OpBuilder builder(unwrap(loc)->getContext());
  builder.setInsertionPointToStart(/* appropriate block */);

  auto listType = funlang::FunLangListType::get(
      unwrap(loc)->getContext(), unwrap(elementType));

  auto op = builder.create<funlang::NilOp>(
      unwrap(loc), listType);

  return wrap(op.getOperation());
}

헤더 파일: mlir/include/mlir-c/Dialect/FunLang.h

//===----------------------------------------------------------------------===//
// NilOp
//===----------------------------------------------------------------------===//

/// Create funlang.nil operation
/// Returns MlirOperation (not MlirValue - use mlirOperationGetResult)
MLIR_CAPI_EXPORTED MlirOperation
mlirFunLangNilOpCreate(MlirLocation loc, MlirType elementType);

F# Bindings

파일: FunLang.Compiler/MlirBindings.fs

module FunLangBindings =
    // ... (previous bindings) ...

    //==========================================================================
    // Operations - NilOp
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirFunLangNilOpCreate(MlirLocation loc, MlirType elementType)

FunLangDialect wrapper:

type FunLangDialect(ctx: MlirContext) =
    // ... (previous members) ...

    //==========================================================================
    // Operation Creation
    //==========================================================================

    /// Create funlang.nil operation
    member this.CreateNilOp(loc: MlirLocation, elementType: MlirType) : MlirOperation =
        FunLangBindings.mlirFunLangNilOpCreate(loc, elementType)

    /// Create funlang.nil and return the result value
    member this.CreateNil(loc: MlirLocation, elementType: MlirType) : MlirValue =
        let op = this.CreateNilOp(loc, elementType)
        MlirHelpers.GetOperationResult(op, 0)

OpBuilder extension:

type OpBuilder with
    // ... (previous members) ...

    /// Create funlang.nil operation
    member this.CreateNilOp(elementType: MlirType) : MlirOperation =
        let funlang = FunLangDialect(this.Context)
        funlang.CreateNilOp(this.UnknownLoc, elementType)

    /// Create funlang.nil and return result value
    member this.CreateNil(elementType: MlirType) : MlirValue =
        let funlang = FunLangDialect(this.Context)
        funlang.CreateNil(this.UnknownLoc, elementType)

F# Usage Examples

// Example 1: Basic usage
let builder = OpBuilder(ctx)

let i32Type = builder.IntegerType(32)
let nilValue = builder.CreateNil(i32Type)
// %nil = funlang.nil : !funlang.list<i32>

// Example 2: Building list [1, 2, 3] (forward)
let nil = builder.CreateNil(i32Type)
let c1 = builder.CreateConstantInt(1, 32)
let c2 = builder.CreateConstantInt(2, 32)
let c3 = builder.CreateConstantInt(3, 32)

// Build from right to left: 3 → 2 → 1 → nil
let lst1 = builder.CreateCons(c3, nil)    // [3]
let lst2 = builder.CreateCons(c2, lst1)   // [2, 3]
let lst3 = builder.CreateCons(c1, lst2)   // [1, 2, 3]

// Example 3: Empty list of different types
let floatType = builder.FloatType(64)
let nilFloat = builder.CreateNil(floatType)
// %nil = funlang.nil : !funlang.list<f64>

let closureType = builder.FunLangClosureType()
let nilClosure = builder.CreateNil(closureType)
// %nil = funlang.nil : !funlang.list<!funlang.closure>

No Runtime Allocation Needed

중요한 최적화 기회:

// Multiple nil operations
%nil1 = funlang.nil : !funlang.list<i32>
%nil2 = funlang.nil : !funlang.list<i32>
%nil3 = funlang.nil : !funlang.list<i32>

// Pure trait enables CSE:
// → All replaced with single %nil!

// Lowering (only once):
%tag = arith.constant 0 : i32
%null = llvm.mlir.zero : !llvm.ptr
%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
%nil = llvm.insertvalue %null, %s1[1] : !llvm.struct<(i32, ptr)>

// No GC_malloc call! (constant struct)

Static empty list (advanced optimization - Phase 7):

// Could use global constant for empty list
static const struct { int tag; void* data; } EMPTY_LIST = {0, NULL};

// All funlang.nil → load from EMPTY_LIST address

Summary: funlang.nil Operation

구현 완료:

  • TableGen ODS definition with Pure trait
  • No arguments, result type is !funlang.list<T>
  • C API shim: mlirFunLangNilOpCreate
  • F# bindings: CreateNilOp, CreateNil
  • OpBuilder extension for convenient usage

특징:

  • Pure operation (CSE 가능)
  • No runtime allocation
  • Result type으로 element type 지정

다음 섹션:

  • funlang.cons operation으로 cons cell 생성

funlang.cons Operation

Cons cell을 생성하는 operation을 구현한다. 리스트의 핵심 생성자다.

Purpose and Semantics

funlang.cons의 역할:

  • Non-empty list 생성 (head :: tail)
  • 리스트의 recursive case
  • GC를 통한 heap allocation

예제:

// Prepend element to list
%lst = funlang.cons %head, %tail : !funlang.list<i32>

// Build list [1, 2, 3]
%nil = funlang.nil : !funlang.list<i32>
%c3 = arith.constant 3 : i32
%lst1 = funlang.cons %c3, %nil : !funlang.list<i32>    // [3]
%c2 = arith.constant 2 : i32
%lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>   // [2, 3]
%c1 = arith.constant 1 : i32
%lst3 = funlang.cons %c1, %lst2 : !funlang.list<i32>   // [1, 2, 3]

의미:

funlang.cons %head, %tail : !funlang.list<T>

// Equivalent to (after lowering):
cell = GC_malloc(sizeof(T) + sizeof(ptr))
cell->head = %head
cell->tail = %tail
result = {tag: 1, data: cell}

TableGen ODS Definition

파일: mlir/include/mlir/Dialect/FunLang/FunLangOps.td

def FunLang_ConsOp : FunLang_Op<"cons", []> {
  let summary = "Create cons cell (non-empty list)";

  let description = [{
    Creates a cons cell by prepending an element to a list.

    Syntax:
    ```
    %result = funlang.cons %head, %tail : !funlang.list<T>
    ```

    Arguments:
    - `head`: Element to prepend (type T)
    - `tail`: Existing list (type !funlang.list<T>)

    Result:
    - New list with `head` prepended to `tail` (type !funlang.list<T>)

    Type constraints:
    - `head` type must match element type of `tail` list
    - Result type is same as `tail` type

    Examples:
    ```
    // Create [1]
    %nil = funlang.nil : !funlang.list<i32>
    %c1 = arith.constant 1 : i32
    %lst = funlang.cons %c1, %nil : !funlang.list<i32>

    // Create [1, 2, 3]
    %c3 = arith.constant 3 : i32
    %lst1 = funlang.cons %c3, %nil : !funlang.list<i32>
    %c2 = arith.constant 2 : i32
    %lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>
    %lst3 = funlang.cons %c1, %lst2 : !funlang.list<i32>
    ```

    Lowering:
    ```
    %lst = funlang.cons %head, %tail : !funlang.list<i32>

    // Lowers to:
    // 1. Allocate cons cell
    %size = arith.constant 16 : i64  // sizeof(i32) + sizeof(struct)
    %cell = llvm.call @GC_malloc(%size) : (i64) -> !llvm.ptr

    // 2. Store head
    %head_ptr = llvm.getelementptr %cell[0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %head, %head_ptr : i32, !llvm.ptr

    // 3. Store tail
    %tail_ptr = llvm.getelementptr %cell[1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %tail, %tail_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

    // 4. Build tagged union
    %tag = arith.constant 1 : i32
    %undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
    %lst = llvm.insertvalue %cell, %s1[1] : !llvm.struct<(i32, ptr)>
    ```

    Note: No Pure trait (allocates memory via GC_malloc)
  }];

  let arguments = (ins AnyType:$head, FunLang_ListType:$tail);

  let results = (outs FunLang_ListType:$result);

  let assemblyFormat = "$head `,` $tail attr-dict `:` type($result)";

  let builders = [
    OpBuilder<(ins "Value":$head, "Value":$tail), [{
      auto tailType = tail.getType().cast<funlang::FunLangListType>();
      $_state.addOperands({head, tail});
      $_state.addTypes(tailType);
    }]>
  ];

  let extraClassDeclaration = [{
    /// Get element type (T from !funlang.list<T>)
    Type getElementType() {
      return getResult().getType().cast<FunLangListType>().getElementType();
    }
  }];
}

핵심 요소:

  1. No Pure trait: GC_malloc 호출로 side effect 발생

    • CSE 불가능 (각 cons는 새로운 cell 할당)
    • Dead code elimination 신중하게 (allocation 유지 필요할 수도)
  2. Arguments: head (element), tail (list)

    • head: AnyType (element type은 tail과 검증)
    • tail: FunLang_ListType
  3. Result type: Same as tail type

    • Builder가 자동으로 tail의 타입을 result에 사용
  4. Assembly format: funlang.cons %head, %tail : !funlang.list<i32>

  5. extraClassDeclaration: getElementType() 헬퍼 메서드

Type Constraints and Verification

LogicalResult ConsOp::verify() {
  // Tail must be !funlang.list<T>
  auto tailType = getTail().getType().dyn_cast<FunLangListType>();
  if (!tailType) {
    return emitOpError("tail must be !funlang.list type");
  }

  // Result must be same type as tail
  auto resultType = getResult().getType().dyn_cast<FunLangListType>();
  if (!resultType || resultType != tailType) {
    return emitOpError("result type must match tail type");
  }

  // Head type must match element type of list
  Type headType = getHead().getType();
  Type elemType = tailType.getElementType();
  if (headType != elemType) {
    return emitOpError("head type (")
        << headType << ") must match list element type (" << elemType << ")";
  }

  return success();
}

검증하는 제약조건:

  1. Tail은 !funlang.list<T> 타입이어야 함
  2. Result 타입은 tail 타입과 동일해야 함
  3. Head 타입은 list의 element 타입과 일치해야 함

예제: Type errors

// Error: head type mismatch
%nil = funlang.nil : !funlang.list<i32>
%f = arith.constant 3.14 : f64
%bad = funlang.cons %f, %nil : !funlang.list<i32>
// Error: head type (f64) must match list element type (i32)

// Error: tail not a list
%x = arith.constant 42 : i32
%bad = funlang.cons %x, %x : !funlang.list<i32>
// Error: tail must be !funlang.list type

// Error: result type mismatch
%nil_int = funlang.nil : !funlang.list<i32>
%x = arith.constant 42 : i32
%bad = funlang.cons %x, %nil_int : !funlang.list<f64>
// Error: result type must match tail type

C API Shim

파일: mlir/lib/CAPI/Dialect/FunLang.cpp

//===----------------------------------------------------------------------===//
// ConsOp
//===----------------------------------------------------------------------===//

MlirOperation mlirFunLangConsOpCreate(
    MlirLocation loc,
    MlirValue head,
    MlirValue tail) {
  mlir::OpBuilder builder(unwrap(loc)->getContext());
  builder.setInsertionPointToStart(/* appropriate block */);

  auto op = builder.create<funlang::ConsOp>(
      unwrap(loc),
      unwrap(head),
      unwrap(tail));

  return wrap(op.getOperation());
}

헤더 파일: mlir/include/mlir-c/Dialect/FunLang.h

//===----------------------------------------------------------------------===//
// ConsOp
//===----------------------------------------------------------------------===//

/// Create funlang.cons operation
/// Arguments:
///   - head: Element to prepend
///   - tail: Existing list
/// Returns MlirOperation (use mlirOperationGetResult to get value)
MLIR_CAPI_EXPORTED MlirOperation
mlirFunLangConsOpCreate(
    MlirLocation loc,
    MlirValue head,
    MlirValue tail);

F# Bindings

파일: FunLang.Compiler/MlirBindings.fs

module FunLangBindings =
    // ... (previous bindings) ...

    //==========================================================================
    // Operations - ConsOp
    //==========================================================================

    [<DllImport("MLIR-FunLang-CAPI", CallingConvention = CallingConvention.Cdecl)>]
    extern MlirOperation mlirFunLangConsOpCreate(
        MlirLocation loc,
        MlirValue head,
        MlirValue tail)

FunLangDialect wrapper:

type FunLangDialect(ctx: MlirContext) =
    // ... (previous members) ...

    /// Create funlang.cons operation
    member this.CreateConsOp(loc: MlirLocation, head: MlirValue, tail: MlirValue) : MlirOperation =
        FunLangBindings.mlirFunLangConsOpCreate(loc, head, tail)

    /// Create funlang.cons and return the result value
    member this.CreateCons(loc: MlirLocation, head: MlirValue, tail: MlirValue) : MlirValue =
        let op = this.CreateConsOp(loc, head, tail)
        MlirHelpers.GetOperationResult(op, 0)

OpBuilder extension:

type OpBuilder with
    // ... (previous members) ...

    /// Create funlang.cons operation
    member this.CreateConsOp(head: MlirValue, tail: MlirValue) : MlirOperation =
        let funlang = FunLangDialect(this.Context)
        funlang.CreateConsOp(this.UnknownLoc, head, tail)

    /// Create funlang.cons and return result value
    member this.CreateCons(head: MlirValue, tail: MlirValue) : MlirValue =
        let funlang = FunLangDialect(this.Context)
        funlang.CreateCons(this.UnknownLoc, head, tail)

F# Usage Examples

// Example 1: Build single-element list [42]
let builder = OpBuilder(ctx)
let i32Type = builder.IntegerType(32)

let nil = builder.CreateNil(i32Type)
let c42 = builder.CreateConstantInt(42, 32)
let lst = builder.CreateCons(c42, nil)
// %lst = funlang.cons %c42, %nil : !funlang.list<i32>

// Example 2: Build list [1, 2, 3]
let nil = builder.CreateNil(i32Type)
let c1 = builder.CreateConstantInt(1, 32)
let c2 = builder.CreateConstantInt(2, 32)
let c3 = builder.CreateConstantInt(3, 32)

// Build from right to left
let lst1 = builder.CreateCons(c3, nil)    // [3]
let lst2 = builder.CreateCons(c2, lst1)   // [2, 3]
let lst3 = builder.CreateCons(c1, lst2)   // [1, 2, 3]

// Example 3: Build list from F# list
let buildList (builder: OpBuilder) (elements: MlirValue list) (elemType: MlirType) =
    let nil = builder.CreateNil(elemType)
    List.foldBack (fun elem acc ->
        builder.CreateCons(elem, acc)
    ) elements nil

let values = [c1; c2; c3]
let lst = buildList builder values i32Type
// funlang.cons %c1, (funlang.cons %c2, (funlang.cons %c3, %nil))

// Example 4: Type inference from tail
let tail = (* existing !funlang.list<i32> *)
let head = builder.CreateConstantInt(99, 32)
let extended = builder.CreateCons(head, tail)
// Result type inferred from tail type

Memory Allocation Details

Cons cell size calculation:

ConsCell<T> = struct {
    T element;
    TaggedUnion tail;  // struct { i32 tag; ptr data }
}

Size = sizeof(T) + sizeof(i32) + sizeof(ptr)

Examples:
- i32: 4 + 4 + 8 = 16 bytes
- f64: 8 + 4 + 8 = 20 bytes (alignment → 24 bytes)
- !funlang.closure (ptr): 8 + 4 + 8 = 20 bytes (alignment → 24 bytes)

Lowering에서 size 계산:

// ConsOpLowering::matchAndRewrite
Value ConsOpLowering::calculateCellSize(
    OpBuilder &builder, Location loc, Type elementType) {
  auto &dataLayout = getDataLayout();

  // Get element size
  uint64_t elemSize = dataLayout.getTypeSize(elementType);

  // TaggedUnion size: i32 (4 bytes) + ptr (8 bytes) = 12 bytes
  // But alignment: struct<(i32, ptr)> → 16 bytes on 64-bit
  uint64_t tailSize = 16;  // Hardcoded for simplicity

  uint64_t totalSize = elemSize + tailSize;

  // Align to 8 bytes
  totalSize = (totalSize + 7) & ~7;

  return builder.create<arith::ConstantIntOp>(
      loc, totalSize, builder.getI64Type());
}

List Construction Patterns

Pattern 1: Build from literal

// F# source: [1; 2; 3]
let lst = [1; 2; 3]

// MLIR output:
%nil = funlang.nil : !funlang.list<i32>
%c3 = arith.constant 3 : i32
%lst1 = funlang.cons %c3, %nil : !funlang.list<i32>
%c2 = arith.constant 2 : i32
%lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>
%c1 = arith.constant 1 : i32
%lst3 = funlang.cons %c1, %lst2 : !funlang.list<i32>

Pattern 2: Recursive construction

// F# source
let rec range n =
    if n <= 0 then []
    else n :: range (n - 1)

// MLIR output (simplified):
func.func @range(%n: i32) -> !funlang.list<i32> {
    %zero = arith.constant 0 : i32
    %cond = arith.cmpi sle, %n, %zero : i32
    %result = scf.if %cond -> !funlang.list<i32> {
        %nil = funlang.nil : !funlang.list<i32>
        scf.yield %nil : !funlang.list<i32>
    } else {
        %one = arith.constant 1 : i32
        %n_minus_1 = arith.subi %n, %one : i32
        %tail = func.call @range(%n_minus_1) : (i32) -> !funlang.list<i32>
        %cons = funlang.cons %n, %tail : !funlang.list<i32>
        scf.yield %cons : !funlang.list<i32>
    }
    func.return %result : !funlang.list<i32>
}

Pattern 3: List transformation (map)

// F# source
let rec map f lst =
    match lst with
    | [] -> []
    | head :: tail -> f head :: map f tail

// MLIR output (with funlang.match - Chapter 19):
func.func @map(
    %f: !funlang.closure,
    %lst: !funlang.list<i32>
) -> !funlang.list<i32> {
    %result = funlang.match %lst : !funlang.list<i32> -> !funlang.list<i32> {
      ^nil:
        %nil = funlang.nil : !funlang.list<i32>
        funlang.yield %nil : !funlang.list<i32>
      ^cons(%head: i32, %tail: !funlang.list<i32>):
        %new_head = funlang.apply %f(%head) : (i32) -> i32
        %new_tail = func.call @map(%f, %tail)
            : (!funlang.closure, !funlang.list<i32>) -> !funlang.list<i32>
        %new_cons = funlang.cons %new_head, %new_tail : !funlang.list<i32>
        funlang.yield %new_cons : !funlang.list<i32>
    }
    func.return %result : !funlang.list<i32>
}

Summary: funlang.cons Operation

구현 완료:

  • TableGen ODS definition (no Pure trait)
  • Arguments: head (element), tail (list)
  • Type verification: head type matches element type
  • C API shim: mlirFunLangConsOpCreate
  • F# bindings: CreateConsOp, CreateCons
  • OpBuilder extension for convenient usage

특징:

  • GC allocation for cons cells
  • Type-safe: head type must match list element type
  • Result type inferred from tail type

다음 Part:

  • TypeConverter for !funlang.list<T>
  • NilOpLowering pattern
  • ConsOpLowering pattern
  • Complete lowering pass integration

튜플 타입과 연산 (Tuple Type and Operations)

리스트와 함께 함수형 프로그래밍에서 필수적인 또 다른 데이터 구조가 있다: **튜플(tuple)**이다. 리스트가 같은 타입의 여러 원소를 가변 개수로 담는다면, 튜플은 서로 다른 타입의 원소들을 고정된 개수로 묶는다.

튜플 vs 리스트: 근본적인 차이

List:

  • 가변 개수 (0개부터 N개까지)
  • 동질적 (모든 원소가 같은 타입)
  • 런타임에 태그로 Nil/Cons 구분 필요
  • 패턴 매칭에서 여러 case 필요
// 리스트: 가변 길이, 같은 타입
let numbers: int list = [1; 2; 3; 4; 5]
let empty: int list = []
let singleton: int list = [42]

Tuple:

  • 고정 개수 (컴파일 타임에 결정)
  • 이질적 (원소마다 다른 타입 가능)
  • 태그 불필요 (항상 같은 구조)
  • 패턴 매칭에서 단일 case (항상 매칭)
// 튜플: 고정 길이, 다른 타입 가능
let pair: int * string = (42, "hello")
let triple: int * float * bool = (1, 3.14, true)
let person: string * int = ("Alice", 30)

메모리 표현의 차이:

List [1, 2, 3] (가변, 태그 필요):
┌─────────┬─────────┐     ┌─────────┬─────────┐     ┌─────────┬─────────┐     ┌─────────┬─────────┐
│ tag=1   │ ptr  ───────► │ head=1  │ tail ────────► │ head=2  │ tail ────────► │ head=3  │ tail=NULL │
│ (Cons)  │         │     │         │         │     │         │         │     │         │         │
└─────────┴─────────┘     └─────────┴─────────┘     └─────────┴─────────┘     └─────────┴─────────┘

Tuple (1, "hello") (고정, 태그 불필요):
┌─────────┬─────────┐
│  int=1  │ ptr ────────► "hello"
│ (slot0) │ (slot1) │
└─────────┴─────────┘

튜플 타입 설계 (Tuple Type Design)

FunLang에서 튜플 타입의 문법:

// 2-tuple (pair)
!funlang.tuple<i32, f64>

// 3-tuple (triple)
!funlang.tuple<i32, string, bool>

// Nested tuple
!funlang.tuple<!funlang.tuple<i32, i32>, f64>

// Tuple of lists
!funlang.tuple<!funlang.list<i32>, !funlang.list<f64>>

타입 시스템에서의 특징:

  1. Arity가 타입에 인코딩: !funlang.tuple<i32> (1-tuple)과 !funlang.tuple<i32, i32> (2-tuple)은 다른 타입
  2. 원소 타입 순서가 중요: !funlang.tuple<i32, f64>!funlang.tuple<f64, i32>
  3. Unit type: 0-tuple !funlang.tuple<>은 unit type으로 사용 가능

LLVM으로의 lowering:

// FunLang tuple type
!funlang.tuple<i32, f64>

// LLVM struct type (no tag needed!)
!llvm.struct<(i32, f64)>

리스트와 달리:

  • 태그 필요 없음: 튜플은 항상 같은 구조
  • 포인터 indirection 없음: 값 자체를 struct에 저장 (작은 튜플의 경우)
  • 스택 할당 가능: escape하지 않으면 힙 할당 불필요

TableGen 정의 (TableGen Definition)

파일: mlir/include/Dialect/FunLang/FunLangTypes.td

//===----------------------------------------------------------------------===//
// Tuple Type
//===----------------------------------------------------------------------===//

def FunLang_TupleType : FunLang_Type<"Tuple", "tuple"> {
  let summary = "FunLang tuple type";
  let description = [{
    A fixed-size product type with heterogeneous elements.
    Unlike lists, tuples have a known arity at compile time.

    Examples:
    - `!funlang.tuple<i32, f64>` is a pair of integer and float
    - `!funlang.tuple<i32, i32, i32>` is a triple of integers
    - `!funlang.tuple<>` is the unit type (empty tuple)

    Tuples are lowered to LLVM structs directly, without tags,
    because they always have the same structure (no variants).
  }];

  let parameters = (ins
    ArrayRefParameter<"mlir::Type", "element types">:$elementTypes
  );

  let assemblyFormat = "`<` $elementTypes `>`";

  let extraClassDeclaration = [{
    /// Get the number of elements in this tuple
    size_t getNumElements() const { return getElementTypes().size(); }

    /// Get the element type at the given index
    mlir::Type getElementType(size_t index) const {
      return getElementTypes()[index];
    }

    /// Check if this is a pair (2-tuple)
    bool isPair() const { return getNumElements() == 2; }

    /// Check if this is a unit type (0-tuple)
    bool isUnit() const { return getNumElements() == 0; }
  }];
}

핵심 요소 분석:

  1. ArrayRefParameter: 가변 개수의 타입 파라미터

    • Variadic<Type>이 아닌 ArrayRefParameter<"mlir::Type">
    • TableGen이 자동으로 storage와 accessor 생성
  2. assemblyFormat: < 원소타입들 >

    • !funlang.tuple<i32, f64> 형태로 파싱/프린팅
  3. extraClassDeclaration: 유틸리티 메서드

    • getNumElements(), getElementType(index)

생성되는 C++ 코드:

// Auto-generated from TableGen
class TupleType : public mlir::Type::TypeBase<TupleType,
                                               mlir::Type,
                                               detail::TupleTypeStorage> {
public:
  using Base::Base;

  static TupleType get(mlir::MLIRContext *context,
                       llvm::ArrayRef<mlir::Type> elementTypes);

  llvm::ArrayRef<mlir::Type> getElementTypes() const;
  size_t getNumElements() const { return getElementTypes().size(); }
  mlir::Type getElementType(size_t index) const {
    return getElementTypes()[index];
  }
  bool isPair() const { return getNumElements() == 2; }
  bool isUnit() const { return getNumElements() == 0; }
};

funlang.make_tuple 연산 (make_tuple Operation)

파일: mlir/include/Dialect/FunLang/FunLangOps.td

//===----------------------------------------------------------------------===//
// make_tuple Operation
//===----------------------------------------------------------------------===//

def FunLang_MakeTupleOp : FunLang_Op<"make_tuple", [Pure]> {
  let summary = "Create a tuple from values";
  let description = [{
    Constructs a tuple from the given element values.
    The result type must match the types of the input elements.

    Example:
    ```mlir
    %c1 = arith.constant 1 : i32
    %c2 = arith.constant 3.14 : f64
    %pair = funlang.make_tuple(%c1, %c2) : !funlang.tuple<i32, f64>
    ```

    The operation is marked Pure because it has no side effects.
    This enables CSE (Common Subexpression Elimination) optimization.
  }];

  let arguments = (ins
    Variadic<AnyType>:$elements
  );

  let results = (outs
    FunLang_TupleType:$result
  );

  let assemblyFormat = [{
    `(` $elements `)` attr-dict `:` type($result)
  }];

  let builders = [
    OpBuilder<(ins "mlir::ValueRange":$elements), [{
      // Infer result type from element types
      llvm::SmallVector<mlir::Type> elemTypes;
      for (auto elem : elements)
        elemTypes.push_back(elem.getType());

      auto tupleType = TupleType::get($_builder.getContext(), elemTypes);
      build($_builder, $_state, tupleType, elements);
    }]>
  ];

  let hasVerifier = 1;
}

핵심 요소 분석:

  1. Variadic<AnyType>: 0개 이상의 임의 타입 operands

    • make_tuple() (unit), make_tuple(%a) (singleton), make_tuple(%a, %b) (pair) 모두 가능
  2. Pure trait: 순수 함수

    • 부작용 없음, 같은 입력 → 같은 출력
    • CSE 최적화 가능: 동일한 make_tuple 호출 합치기
  3. Custom builder: 타입 추론

    • element 타입들로부터 결과 tuple 타입 자동 추론
    • 사용자가 명시적으로 타입을 지정할 필요 없음
  4. Verifier: 타입 일관성 검증

    • element 개수와 tuple 타입의 arity 일치
    • 각 element 타입과 tuple의 대응 위치 타입 일치

Verifier 구현:

// FunLangOps.cpp
LogicalResult MakeTupleOp::verify() {
  auto tupleType = getType().cast<TupleType>();
  auto elements = getElements();

  // Check element count matches tuple arity
  if (elements.size() != tupleType.getNumElements()) {
    return emitOpError() << "expected " << tupleType.getNumElements()
                         << " elements but got " << elements.size();
  }

  // Check each element type matches
  for (size_t i = 0; i < elements.size(); ++i) {
    Type expectedType = tupleType.getElementType(i);
    Type actualType = elements[i].getType();
    if (expectedType != actualType) {
      return emitOpError() << "element " << i << " type mismatch: expected "
                           << expectedType << " but got " << actualType;
    }
  }

  return success();
}

사용 예제:

// Empty tuple (unit)
%unit = funlang.make_tuple() : !funlang.tuple<>

// Pair of int and float
%c1 = arith.constant 42 : i32
%c2 = arith.constant 3.14 : f64
%pair = funlang.make_tuple(%c1, %c2) : !funlang.tuple<i32, f64>

// Triple of ints
%a = arith.constant 1 : i32
%b = arith.constant 2 : i32
%c = arith.constant 3 : i32
%triple = funlang.make_tuple(%a, %b, %c) : !funlang.tuple<i32, i32, i32>

// Nested tuple
%inner = funlang.make_tuple(%a, %b) : !funlang.tuple<i32, i32>
%outer = funlang.make_tuple(%inner, %c2) : !funlang.tuple<!funlang.tuple<i32, i32>, f64>

// Tuple containing list
%list = funlang.cons %c1, %nil : !funlang.list<i32>
%mixed = funlang.make_tuple(%list, %c2) : !funlang.tuple<!funlang.list<i32>, f64>

튜플 로우어링 (Tuple Lowering)

튜플의 lowering은 리스트보다 훨씬 간단하다. 태그 없이 직접 LLVM struct로 변환한다.

TypeConverter 확장:

// FunLangTypeConverter에 추가
addConversion([](funlang::TupleType type) {
  auto ctx = type.getContext();

  // Convert each element type
  llvm::SmallVector<mlir::Type> llvmTypes;
  for (auto elemType : type.getElementTypes()) {
    // Recursively convert element types
    // (handles nested tuples, lists, etc.)
    auto convertedType = convertType(elemType);
    llvmTypes.push_back(convertedType);
  }

  // Create LLVM struct type
  return LLVM::LLVMStructType::getLiteral(ctx, llvmTypes);
});

변환 예제:

// Before: FunLang types
!funlang.tuple<i32, f64>
!funlang.tuple<i32, i32, i32>
!funlang.tuple<!funlang.list<i32>, f64>

// After: LLVM types
!llvm.struct<(i32, f64)>
!llvm.struct<(i32, i32, i32)>
!llvm.struct<(!llvm.struct<(i32, ptr)>, f64)>  // list becomes tagged union struct

MakeTupleOpLowering 패턴:

class MakeTupleOpLowering : public OpConversionPattern<funlang::MakeTupleOp> {
public:
  using OpConversionPattern::OpConversionPattern;

  LogicalResult matchAndRewrite(funlang::MakeTupleOp op,
                                 OpAdaptor adaptor,
                                 ConversionPatternRewriter &rewriter) const override {
    Location loc = op.getLoc();
    auto elements = adaptor.getElements();  // Already converted by TypeConverter

    // Get the converted result type (LLVM struct)
    auto resultType = getTypeConverter()->convertType(op.getType());
    auto structType = resultType.cast<LLVM::LLVMStructType>();

    // Start with undef struct
    Value structVal = rewriter.create<LLVM::UndefOp>(loc, structType);

    // Insert each element at its position
    for (size_t i = 0; i < elements.size(); ++i) {
      structVal = rewriter.create<LLVM::InsertValueOp>(
          loc, structVal, elements[i], i);
    }

    // Replace make_tuple with the constructed struct
    rewriter.replaceOp(op, structVal);
    return success();
  }
};

Lowering 과정 시각화:

// Before lowering
%c1 = arith.constant 42 : i32
%c2 = arith.constant 3.14 : f64
%pair = funlang.make_tuple(%c1, %c2) : !funlang.tuple<i32, f64>

// After lowering
%c1 = arith.constant 42 : i32
%c2 = arith.constant 3.14 : f64
%0 = llvm.mlir.undef : !llvm.struct<(i32, f64)>
%1 = llvm.insertvalue %c1, %0[0] : !llvm.struct<(i32, f64)>
%pair = llvm.insertvalue %c2, %1[1] : !llvm.struct<(i32, f64)>

리스트 vs 튜플 lowering 비교:

구분ListTuple
태그필요 (Nil=0, Cons=1)불필요
힙 할당필요 (GC_malloc)불필요 (값 의미론)
간접 참조있음 (ptr → data)없음 (직접 저장)
Lowering 복잡도높음낮음

C API 및 F# 바인딩 (C API and F# Bindings)

C API Shim:

// mlir/lib/Dialect/FunLang/CAPI/FunLangCAPI.cpp

//===----------------------------------------------------------------------===//
// Tuple Type
//===----------------------------------------------------------------------===//

extern "C" MlirType funlangTupleTypeGet(MlirContext ctx,
                                        MlirType *elementTypes,
                                        intptr_t numElements) {
  llvm::SmallVector<mlir::Type> types;
  for (intptr_t i = 0; i < numElements; ++i) {
    types.push_back(unwrap(elementTypes[i]));
  }
  return wrap(funlang::TupleType::get(unwrap(ctx), types));
}

extern "C" intptr_t funlangTupleTypeGetNumElements(MlirType type) {
  return unwrap(type).cast<funlang::TupleType>().getNumElements();
}

extern "C" MlirType funlangTupleTypeGetElementType(MlirType type, intptr_t index) {
  return wrap(unwrap(type).cast<funlang::TupleType>().getElementType(index));
}

extern "C" bool funlangTypeIsATupleType(MlirType type) {
  return unwrap(type).isa<funlang::TupleType>();
}

//===----------------------------------------------------------------------===//
// make_tuple Operation
//===----------------------------------------------------------------------===//

extern "C" MlirOperation funlangMakeTupleOpCreate(MlirLocation loc,
                                                   MlirType resultType,
                                                   MlirValue *elements,
                                                   intptr_t numElements,
                                                   MlirBlock block) {
  OpBuilder builder(unwrap(block)->getParent());
  builder.setInsertionPointToEnd(unwrap(block));

  llvm::SmallVector<mlir::Value> values;
  for (intptr_t i = 0; i < numElements; ++i) {
    values.push_back(unwrap(elements[i]));
  }

  auto tupleType = unwrap(resultType).cast<funlang::TupleType>();
  auto op = builder.create<funlang::MakeTupleOp>(
      unwrap(loc), tupleType, values);
  return wrap(op.getOperation());
}

F# Bindings:

// FunLang.Bindings/FunLangTypes.fs

module FunLangTypes

open System.Runtime.InteropServices

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType funlangTupleTypeGet(MlirContext ctx, MlirType[] elementTypes, nativeint numElements)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern nativeint funlangTupleTypeGetNumElements(MlirType type_)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirType funlangTupleTypeGetElementType(MlirType type_, nativeint index)

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern bool funlangTypeIsATupleType(MlirType type_)

type MLIRTypeExtensions =
    /// Create a tuple type with the given element types
    static member CreateTupleType(ctx: MlirContext, elementTypes: MlirType list) : MlirType =
        let typesArray = elementTypes |> List.toArray
        funlangTupleTypeGet(ctx, typesArray, nativeint typesArray.Length)

    /// Check if a type is a tuple type
    static member IsTupleType(t: MlirType) : bool =
        funlangTypeIsATupleType(t)

    /// Get the number of elements in a tuple type
    static member GetTupleNumElements(t: MlirType) : int =
        int (funlangTupleTypeGetNumElements(t))

    /// Get an element type from a tuple type
    static member GetTupleElementType(t: MlirType, index: int) : MlirType =
        funlangTupleTypeGetElementType(t, nativeint index)
// FunLang.Bindings/FunLangOps.fs

module FunLangOps

[<DllImport("MLIR-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirOperation funlangMakeTupleOpCreate(
    MlirLocation loc,
    MlirType resultType,
    MlirValue[] elements,
    nativeint numElements,
    MlirBlock block)

type OpBuilderExtensions =
    /// Create a make_tuple operation
    member this.CreateMakeTupleOp(loc: MlirLocation, elements: MlirValue list) : MlirOperation =
        // Infer tuple type from elements
        let elementTypes = elements |> List.map (fun e -> e.GetType())
        let tupleType = MLIRTypeExtensions.CreateTupleType(this.Context, elementTypes)
        let elemArray = elements |> List.toArray
        funlangMakeTupleOpCreate(loc, tupleType, elemArray, nativeint elemArray.Length, this.CurrentBlock)

    /// Create a tuple and return its value
    member this.CreateMakeTuple(loc: MlirLocation, elements: MlirValue list) : MlirValue =
        let op = this.CreateMakeTupleOp(loc, elements)
        op.GetResult(0)

    /// Create a pair (2-tuple)
    member this.CreatePair(loc: MlirLocation, first: MlirValue, second: MlirValue) : MlirValue =
        this.CreateMakeTuple(loc, [first; second])

사용 예제:

// F# code using the bindings
let createPointTuple (builder: OpBuilder) (x: MlirValue) (y: MlirValue) =
    let loc = builder.GetUnknownLoc()

    // Create pair using convenience method
    let point = builder.CreatePair(loc, x, y)

    // Or explicitly with CreateMakeTuple
    let point' = builder.CreateMakeTuple(loc, [x; y])

    point

let createMixedTuple (builder: OpBuilder) (intVal: MlirValue) (floatVal: MlirValue) (listVal: MlirValue) =
    let loc = builder.GetUnknownLoc()

    // 3-tuple with mixed types
    let mixed = builder.CreateMakeTuple(loc, [intVal; floatVal; listVal])

    // Check the type
    let tupleType = mixed.GetType()
    assert (MLIRTypeExtensions.IsTupleType(tupleType))
    assert (MLIRTypeExtensions.GetTupleNumElements(tupleType) = 3)

    mixed

Summary: 튜플 타입과 연산

구현 완료:

  • !funlang.tuple<T1, T2, ...> 타입 정의 (TableGen)
  • ArrayRefParameter로 가변 개수 타입 파라미터
  • funlang.make_tuple 연산 정의
  • Pure trait (CSE 최적화 가능)
  • TypeConverter에 튜플 → LLVM struct 변환 추가
  • MakeTupleOpLowering 패턴
  • C API shim 함수
  • F# bindings

튜플의 특징:

특성리스트튜플
Arity가변고정
원소 타입동질적 (T)이질적 (T1, T2, …)
런타임 태그필요불필요
메모리 할당힙 (GC)스택/인라인 가능
패턴 매칭 case다중 (Nil/Cons)단일 (항상 매칭)
Lowering 대상!llvm.struct<(i32, ptr)>!llvm.struct<(T1, T2, ...)>

다음:

  • Chapter 19에서 튜플 패턴 매칭 구현
  • extractvalue로 튜플 원소 추출
  • 중첩 패턴 (튜플 + 리스트 조합)

TypeConverter for List Types

Chapter 16에서 우리는 TypeConverter를 배웠다. FunLang types를 LLVM types로 변환하는 규칙을 정의한다.

Chapter 16 복습: TypeConverter란?

TypeConverter의 역할:

// Type conversion rules
!funlang.closure → !llvm.ptr
!funlang.list<T> → !llvm.struct<(i32, ptr)>
i32 → i32 (identity)

왜 필요한가?

  • Operations를 lowering할 때 operand/result types도 변환해야 함
  • Type consistency 유지 필요
  • DialectConversion framework가 자동으로 type materialization 수행

FunLangTypeConverter 확장

Chapter 16에서 closure type 변환만 구현했다. 이제 list type 변환을 추가한다.

파일: mlir/lib/Dialect/FunLang/Transforms/FunLangToLLVM.cpp

class FunLangTypeConverter : public TypeConverter {
public:
  FunLangTypeConverter(MLIRContext *ctx) {
    // Identity conversion for built-in types
    addConversion([](Type type) { return type; });

    // !funlang.closure → !llvm.ptr (Chapter 16)
    addConversion([](funlang::FunLangClosureType type) {
      return LLVM::LLVMPointerType::get(type.getContext());
    });

    // !funlang.list<T> → !llvm.struct<(i32, ptr)> (Chapter 18)
    addConversion([](funlang::FunLangListType type) {
      auto ctx = type.getContext();
      auto i32Type = IntegerType::get(ctx, 32);
      auto ptrType = LLVM::LLVMPointerType::get(ctx);
      return LLVM::LLVMStructType::getLiteral(ctx, {i32Type, ptrType});
    });

    // Materialization for unconverted types
    addSourceMaterialization([&](OpBuilder &builder, Type type,
                                  ValueRange inputs, Location loc) -> Value {
      if (inputs.size() != 1)
        return nullptr;
      return inputs[0];
    });

    addTargetMaterialization([&](OpBuilder &builder, Type type,
                                  ValueRange inputs, Location loc) -> Value {
      if (inputs.size() != 1)
        return nullptr;
      return builder.create<UnrealizedConversionCastOp>(loc, type, inputs)
          .getResult(0);
    });
  }
};

핵심 포인트:

  1. List type conversion:

    !funlang.list<T> → !llvm.struct<(i32, ptr)>
    
    • Element type T는 버려짐 (runtime representation에 불필요)
    • Tagged union: tag (i32) + data (ptr)
  2. Type parameter 무시:

    !funlang.list<i32> → !llvm.struct<(i32, ptr)>
    !funlang.list<f64> → !llvm.struct<(i32, ptr)>
    !funlang.list<!funlang.closure> → !llvm.struct<(i32, ptr)>
    // 모두 동일한 LLVM type!
    
  3. Opaque pointer:

    • Cons cell은 !llvm.ptr로 표현 (opaque)
    • Element type 정보는 컴파일 타임에만 존재

Element Type은 어디로?

질문: Element type T를 버려도 괜찮은가?

답: 네, 컴파일 타임에만 필요하기 때문입니다.

Element type의 용도:

  1. Type checking (compile time):

    %cons = funlang.cons %head, %tail : !funlang.list<i32>
    // Verifier checks: %head must be i32
    
  2. Pattern matching (compile time):

    %result = funlang.match %list : !funlang.list<i32> -> i32 {
      ^cons(%head: i32, %tail: !funlang.list<i32>):
        // %head type inferred from list element type
    }
    
  3. Lowering (code generation):

    // ConsOpLowering::matchAndRewrite
    Type elemType = consOp.getElementType();  // Get T from !funlang.list<T>
    uint64_t elemSize = dataLayout.getTypeSize(elemType);  // Calculate cell size
    

Runtime에는 불필요:

  • Runtime에는 tag만 확인 (0=Nil, 1=Cons)
  • Cons cell에서 데이터 로드할 때 타입 정보 불필요 (opaque pointer)
  • GC가 타입 정보 없이도 메모리 관리 가능

비유:

// C++ template (compile time)
template<typename T>
struct List {
    int tag;
    void* data;
};

List<int> intList;      // Compile time: T = int
List<double> doubleList;  // Compile time: T = double

// Runtime: sizeof(List<int>) == sizeof(List<double>)
// Runtime에는 T 정보 사라짐 (type erasure)

Recursive List Types

중첩 리스트:

!funlang.list<!funlang.list<i32>>

TypeConverter가 자동으로 처리:

// Step 1: Convert inner list
!funlang.list<i32> → !llvm.struct<(i32, ptr)>

// Step 2: Convert outer list (element type = inner list)
!funlang.list<!funlang.list<i32>>
  → !funlang.list<!llvm.struct<(i32, ptr)>>  // Inner converted
  → !llvm.struct<(i32, ptr)>                 // Outer converted

// Result: Same as flat list!

이것도 type erasure:

  • Cons cell에는 element가 !llvm.struct<(i32, ptr)>로 저장됨
  • 하지만 outer list의 표현은 여전히 !llvm.struct<(i32, ptr)>

Type Materialization

Materialization이란?

Type conversion 중 intermediate values가 필요할 때 자동으로 생성되는 operations.

예제:

// Before lowering
func.func @foo(%lst: !funlang.list<i32>) -> i32 {
    // %lst uses: !funlang.list<i32>
}

// After lowering
func.func @foo(%arg: !llvm.struct<(i32, ptr)>) -> i32 {
    // But some operations might still reference old type temporarily
    // Materialization creates cast operations
}

FunLangTypeConverter에서:

// Source materialization: LLVM type → FunLang type (usually no-op)
addSourceMaterialization([&](OpBuilder &builder, Type type,
                              ValueRange inputs, Location loc) -> Value {
  if (inputs.size() != 1)
    return nullptr;
  return inputs[0];  // Identity cast
});

// Target materialization: FunLang type → LLVM type
addTargetMaterialization([&](OpBuilder &builder, Type type,
                              ValueRange inputs, Location loc) -> Value {
  if (inputs.size() != 1)
    return nullptr;
  return builder.create<UnrealizedConversionCastOp>(loc, type, inputs)
      .getResult(0);
});

UnrealizedConversionCastOp:

  • Temporary operation for type conversion
  • Should be removed by complete conversion
  • If it remains after pass, conversion failed (verification error)

Complete FunLangTypeConverter

전체 TypeConverter (Closure + List):

// mlir/lib/Dialect/FunLang/Transforms/FunLangToLLVM.cpp

class FunLangTypeConverter : public TypeConverter {
public:
  FunLangTypeConverter(MLIRContext *ctx, const DataLayout &dataLayout)
      : dataLayout(dataLayout) {
    // Keep identity conversions (i32, f64, etc.)
    addConversion([](Type type) { return type; });

    // Closure type conversion (Phase 5)
    addConversion([](funlang::FunLangClosureType type) {
      return LLVM::LLVMPointerType::get(type.getContext());
    });

    // List type conversion (Phase 6)
    addConversion([](funlang::FunLangListType type) {
      auto ctx = type.getContext();
      auto i32Type = IntegerType::get(ctx, 32);
      auto ptrType = LLVM::LLVMPointerType::get(ctx);
      // Tagged union: {i32 tag, ptr data}
      return LLVM::LLVMStructType::getLiteral(ctx, {i32Type, ptrType});
    });

    // Function type conversion
    addConversion([this](FunctionType type) {
      return convertFunctionType(type);
    });

    // Materialization hooks
    addSourceMaterialization(materializeSource);
    addTargetMaterialization(materializeTarget);
    addArgumentMaterialization(materializeSource);
  }

  // Get element type from list type (helper for lowering patterns)
  Type getListElementType(funlang::FunLangListType listType) const {
    return listType.getElementType();
  }

  // Calculate cons cell size for element type
  uint64_t getConsCellSize(Type elementType) const {
    uint64_t elemSize = dataLayout.getTypeSize(elementType);
    uint64_t tailSize = 16;  // sizeof(struct<(i32, ptr)>) with alignment
    uint64_t totalSize = elemSize + tailSize;
    // Align to 8 bytes
    return (totalSize + 7) & ~7;
  }

private:
  const DataLayout &dataLayout;

  FunctionType convertFunctionType(FunctionType type) {
    SmallVector<Type> inputs;
    SmallVector<Type> results;

    if (failed(convertTypes(type.getInputs(), inputs)) ||
        failed(convertTypes(type.getResults(), results)))
      return nullptr;

    return FunctionType::get(type.getContext(), inputs, results);
  }

  static Value materializeSource(OpBuilder &builder, Type type,
                                   ValueRange inputs, Location loc) {
    if (inputs.size() != 1)
      return nullptr;
    return inputs[0];
  }

  static Value materializeTarget(OpBuilder &builder, Type type,
                                   ValueRange inputs, Location loc) {
    if (inputs.size() != 1)
      return nullptr;
    return builder.create<UnrealizedConversionCastOp>(loc, type, inputs)
        .getResult(0);
  }
};

TypeConverter in Lowering Pass

Pass에서 TypeConverter 사용:

struct FunLangToLLVMPass : public PassWrapper<FunLangToLLVMPass, OperationPass<ModuleOp>> {
  void runOnOperation() override {
    auto module = getOperation();
    auto *ctx = &getContext();

    // Get data layout from module
    auto dataLayout = DataLayout(module);

    // Create type converter
    FunLangTypeConverter typeConverter(ctx, dataLayout);

    // Setup conversion target
    ConversionTarget target(*ctx);
    target.addLegalDialect<LLVM::LLVMDialect, arith::ArithDialect>();
    target.addIllegalDialect<funlang::FunLangDialect>();

    // Populate rewrite patterns
    RewritePatternSet patterns(ctx);
    patterns.add<ClosureOpLowering>(typeConverter, ctx);
    patterns.add<ApplyOpLowering>(typeConverter, ctx);
    patterns.add<NilOpLowering>(typeConverter, ctx);     // New!
    patterns.add<ConsOpLowering>(typeConverter, ctx);    // New!

    // Run conversion
    if (failed(applyPartialConversion(module, target, std::move(patterns)))) {
      signalPassFailure();
    }
  }
};

ConversionPattern에서 typeConverter 사용:

class NilOpLowering : public OpConversionPattern<funlang::NilOp> {
public:
  using OpConversionPattern::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::NilOp op,
      OpAdaptor adaptor,
      ConversionPatternRewriter &rewriter) const override {

    auto loc = op.getLoc();

    // Get converted result type: !llvm.struct<(i32, ptr)>
    Type convertedType = typeConverter->convertType(op.getType());

    // Build Nil representation: {0, null}
    // ...
  }
};

Summary: TypeConverter for List Types

구현 완료:

  • !funlang.list<T>!llvm.struct<(i32, ptr)> conversion
  • Element type handling (compile-time only)
  • Recursive list types (automatic handling)
  • Type materialization hooks
  • Helper methods for lowering patterns (getConsCellSize)

핵심 통찰:

  • Element type은 컴파일 타임 정보만
  • Runtime representation은 모든 list types에 대해 uniform
  • Type erasure로 효율적인 메모리 사용

다음 섹션:

  • NilOpLowering pattern으로 empty list 생성

NilOp Lowering Pattern

이제 funlang.nil을 LLVM dialect로 lowering하는 pattern을 작성한다.

Lowering Strategy

Before:

%nil = funlang.nil : !funlang.list<i32>

After:

// Build struct {0, null}
%tag = arith.constant 0 : i32
%null = llvm.mlir.zero : !llvm.ptr
%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
%nil = llvm.insertvalue %null, %s1[1] : !llvm.struct<(i32, ptr)>

핵심 LLVM operations:

  1. arith.constant: Create tag value (0 for Nil)
  2. llvm.mlir.zero: Create null pointer
  3. llvm.mlir.undef: Create undefined struct (placeholder)
  4. llvm.insertvalue: Insert values into struct fields

ConversionPattern Structure

class NilOpLowering : public OpConversionPattern<funlang::NilOp> {
public:
  using OpConversionPattern::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::NilOp op,
      OpAdaptor adaptor,
      ConversionPatternRewriter &rewriter) const override;
};

OpConversionPattern vs OpRewritePattern:

AspectOpConversionPatternOpRewritePattern
FrameworkDialectConversionGreedy rewriter
Type conversionAutomatic (TypeConverter)Manual
AdaptorYes (adaptor.getOperands())No (op.getOperands())
Use caseDialect loweringOptimization

OpAdaptor:

  • Provides converted operands (types already converted by TypeConverter)
  • Example: adaptor.getTail() returns tail with LLVM type, not FunLang type

Implementation

파일: mlir/lib/Dialect/FunLang/Transforms/FunLangToLLVM.cpp

//===----------------------------------------------------------------------===//
// NilOpLowering
//===----------------------------------------------------------------------===//

class NilOpLowering : public OpConversionPattern<funlang::NilOp> {
public:
  using OpConversionPattern::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::NilOp op,
      OpAdaptor adaptor,
      ConversionPatternRewriter &rewriter) const override {

    auto loc = op.getLoc();
    auto ctx = op.getContext();

    // Get converted result type: !llvm.struct<(i32, ptr)>
    Type convertedType = typeConverter->convertType(op.getType());
    auto structType = convertedType.cast<LLVM::LLVMStructType>();

    // Step 1: Create tag value (0 for Nil)
    auto i32Type = IntegerType::get(ctx, 32);
    auto tagValue = rewriter.create<arith::ConstantIntOp>(loc, 0, i32Type);

    // Step 2: Create null pointer
    auto ptrType = LLVM::LLVMPointerType::get(ctx);
    auto nullPtr = rewriter.create<LLVM::ZeroOp>(loc, ptrType);

    // Step 3: Create undefined struct (placeholder)
    auto undefStruct = rewriter.create<LLVM::UndefOp>(loc, structType);

    // Step 4: Insert tag into struct at index 0
    auto withTag = rewriter.create<LLVM::InsertValueOp>(
        loc, undefStruct, tagValue, ArrayRef<int64_t>{0});

    // Step 5: Insert null pointer into struct at index 1
    auto nilValue = rewriter.create<LLVM::InsertValueOp>(
        loc, withTag, nullPtr, ArrayRef<int64_t>{1});

    // Replace funlang.nil with constructed struct
    rewriter.replaceOp(op, nilValue.getResult());

    return success();
  }
};

Step-by-Step Explanation

Step 1: Tag value (0)

auto tagValue = rewriter.create<arith::ConstantIntOp>(loc, 0, i32Type);

생성되는 MLIR:

%tag = arith.constant 0 : i32

Step 2: Null pointer

auto nullPtr = rewriter.create<LLVM::ZeroOp>(loc, ptrType);

생성되는 MLIR:

%null = llvm.mlir.zero : !llvm.ptr

llvm.mlir.zero vs llvm.null:

  • llvm.mlir.zero: MLIR의 zero initializer (opaque pointers)
  • Old LLVM: llvm.null (deprecated with opaque pointers)

Step 3: Undefined struct

auto undefStruct = rewriter.create<LLVM::UndefOp>(loc, structType);

생성되는 MLIR:

%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>

왜 undef부터 시작?

  • LLVM structs는 immutable (SSA form)
  • insertvalue로 필드를 하나씩 채워 나감
  • 초기값은 undefined (나중에 덮어씀)

Step 4-5: Insert values

auto withTag = rewriter.create<LLVM::InsertValueOp>(
    loc, undefStruct, tagValue, ArrayRef<int64_t>{0});
auto nilValue = rewriter.create<LLVM::InsertValueOp>(
    loc, withTag, nullPtr, ArrayRef<int64_t>{1});

생성되는 MLIR:

%s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
%nil = llvm.insertvalue %null, %s1[1] : !llvm.struct<(i32, ptr)>

InsertValueOp syntax:

  • llvm.insertvalue %value, %struct[index]
  • index: struct field index (0 = tag, 1 = data)
  • Returns new struct with field updated

Step 6: Replace operation

rewriter.replaceOp(op, nilValue.getResult());
  • Remove original funlang.nil operation
  • Replace all uses with new struct value
  • nilValue.getResult(): Extract Value from Operation

No Memory Allocation

중요한 최적화:

  • NilOp lowering은 pure computation (no side effects)
  • Stack-only operations (constant, undef, insertvalue)
  • No GC_malloc call (unlike ConsOp)

LLVM optimization 기회:

// Multiple nil operations
%nil1 = funlang.nil : !funlang.list<i32>
%nil2 = funlang.nil : !funlang.list<i32>

// After lowering:
%tag = arith.constant 0 : i32
%null = llvm.mlir.zero : !llvm.ptr
%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
%nil = llvm.insertvalue %null, %s1[1] : !llvm.struct<(i32, ptr)>
// LLVM CSE: %nil1 and %nil2 → same %nil!

Advanced optimization (Phase 7):

  • Global constant for empty list
  • All nil operations → load from constant
  • Zero runtime cost

C API Shim (if needed)

NilOpLowering은 C++에서만 사용되므로 C API shim 불필요. 하지만 testing을 위해 제공 가능:

// For testing lowering pass from F#
void mlirFunLangRegisterNilOpLowering(MlirRewritePatternSet patterns) {
  auto *ctx = unwrap(patterns)->getContext();
  FunLangTypeConverter typeConverter(ctx, /* dataLayout */);
  unwrap(patterns)->add<NilOpLowering>(typeConverter, ctx);
}

Complete Example

Input MLIR (FunLang dialect):

func.func @test_nil() -> !funlang.list<i32> {
    %nil = funlang.nil : !funlang.list<i32>
    func.return %nil : !funlang.list<i32>
}

After NilOpLowering (LLVM dialect):

func.func @test_nil() -> !llvm.struct<(i32, ptr)> {
    %c0 = arith.constant 0 : i32
    %null = llvm.mlir.zero : !llvm.ptr
    %0 = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %1 = llvm.insertvalue %c0, %0[0] : !llvm.struct<(i32, ptr)>
    %nil = llvm.insertvalue %null, %1[1] : !llvm.struct<(i32, ptr)>
    func.return %nil : !llvm.struct<(i32, ptr)>
}

After LLVM optimization:

define { i32, ptr } @test_nil() {
  ; Constant struct {0, null} directly
  ret { i32, ptr } { i32 0, ptr null }
}

Summary: NilOp Lowering Pattern

구현 완료:

  • OpConversionPattern for funlang.nil
  • Tagged union construction: {tag: 0, data: null}
  • No memory allocation (pure computation)
  • LLVM optimization friendly

핵심 패턴:

  1. Undefined struct as starting point
  2. InsertValueOp for field-by-field construction
  3. replaceOp to complete rewriting

다음 섹션:

  • ConsOpLowering pattern으로 cons cell allocation

ConsOp Lowering Pattern

이제 funlang.cons를 LLVM dialect로 lowering한다. NilOp보다 복잡하다 - memory allocation이 필요하기 때문이다.

Lowering Strategy

Before:

%lst = funlang.cons %head, %tail : !funlang.list<i32>

After:

// 1. Allocate cons cell
%cell_size = arith.constant 16 : i64
%cell_ptr = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr

// 2. Store head element
%head_ptr = llvm.getelementptr %cell_ptr[0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %head, %head_ptr : i32, !llvm.ptr

// 3. Store tail list
%tail_ptr = llvm.getelementptr %cell_ptr[1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %tail, %tail_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

// 4. Build tagged union {1, cell_ptr}
%tag = arith.constant 1 : i32
%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
%lst = llvm.insertvalue %cell_ptr, %s1[1] : !llvm.struct<(i32, ptr)>

핵심 작업:

  1. GC_malloc: Heap에 cons cell 할당
  2. GEP (GetElementPtr): Struct field 주소 계산
  3. Store: Head와 tail을 cell에 저장
  4. InsertValue: Tagged union 구성

Memory Layout Recap

Cons cell structure:

struct ConsCell {
    T element;                    // Offset 0
    TaggedUnion tail;             // Offset sizeof(T)
}

TaggedUnion = struct {
    i32 tag;                      // 4 bytes
    ptr data;                     // 8 bytes
}

예제: !funlang.list<i32>

ConsCell<i32> = {
    i32 element;        // 4 bytes at offset 0
    struct {            // 16 bytes at offset 4 (aligned to 8)
        i32 tag;        // 4 bytes
        ptr data;       // 8 bytes
    } tail;
}

Total size: 4 + 16 = 20 bytes → aligned to 24 bytes

Implementation

파일: mlir/lib/Dialect/FunLang/Transforms/FunLangToLLVM.cpp

//===----------------------------------------------------------------------===//
// ConsOpLowering
//===----------------------------------------------------------------------===//

class ConsOpLowering : public OpConversionPattern<funlang::ConsOp> {
public:
  using OpConversionPattern::OpConversionPattern;

  LogicalResult matchAndRewrite(
      funlang::ConsOp op,
      OpAdaptor adaptor,
      ConversionPatternRewriter &rewriter) const override {

    auto loc = op.getLoc();
    auto ctx = op.getContext();

    // Get converted types
    Type convertedResultType = typeConverter->convertType(op.getType());
    auto structType = convertedResultType.cast<LLVM::LLVMStructType>();

    // Get element type (from original FunLang type)
    Type elementType = op.getElementType();

    // Get converted operands (TypeConverter already converted them)
    Value headValue = adaptor.getHead();
    Value tailValue = adaptor.getTail();

    // Step 1: Calculate cons cell size
    auto cellSize = calculateCellSize(rewriter, loc, elementType);

    // Step 2: Allocate cons cell via GC_malloc
    auto cellPtr = allocateConsCell(rewriter, loc, cellSize);

    // Step 3: Store head element
    storeHead(rewriter, loc, cellPtr, headValue, elementType);

    // Step 4: Store tail list
    storeTail(rewriter, loc, cellPtr, tailValue, elementType);

    // Step 5: Build tagged union {1, cellPtr}
    auto consValue = buildTaggedUnion(rewriter, loc, structType, cellPtr);

    // Replace funlang.cons with constructed value
    rewriter.replaceOp(op, consValue);

    return success();
  }

private:
  // Calculate cons cell size: sizeof(element) + sizeof(TaggedUnion)
  Value calculateCellSize(
      OpBuilder &builder, Location loc, Type elementType) const {

    auto *typeConverter = getTypeConverter();
    auto dataLayout = DataLayout::closest(loc.getParentModule());

    // Get element size
    uint64_t elemSize = dataLayout.getTypeSize(elementType);

    // TaggedUnion size: struct<(i32, ptr)> = 4 + 8 = 12, aligned to 16
    uint64_t tailSize = 16;

    uint64_t totalSize = elemSize + tailSize;

    // Align to 8 bytes
    totalSize = (totalSize + 7) & ~7;

    auto i64Type = builder.getI64Type();
    return builder.create<arith::ConstantIntOp>(loc, totalSize, i64Type);
  }

  // Allocate cons cell via GC_malloc
  Value allocateConsCell(
      OpBuilder &builder, Location loc, Value size) const {

    auto ctx = builder.getContext();
    auto ptrType = LLVM::LLVMPointerType::get(ctx);
    auto i64Type = builder.getI64Type();

    // Get or declare GC_malloc
    auto module = loc->getParentOfType<ModuleOp>();
    auto gcMalloc = module.lookupSymbol<LLVM::LLVMFuncOp>("GC_malloc");
    if (!gcMalloc) {
      OpBuilder::InsertionGuard guard(builder);
      builder.setInsertionPointToStart(module.getBody());

      auto funcType = LLVM::LLVMFunctionType::get(ptrType, {i64Type});
      gcMalloc = builder.create<LLVM::LLVMFuncOp>(
          loc, "GC_malloc", funcType);
    }

    // Call GC_malloc
    auto callOp = builder.create<LLVM::CallOp>(
        loc, gcMalloc, ValueRange{size});

    return callOp.getResult();
  }

  // Store head element at offset 0
  void storeHead(
      OpBuilder &builder, Location loc, Value cellPtr,
      Value headValue, Type elementType) const {

    // GEP to head field: cell[0]
    auto headPtr = builder.create<LLVM::GEPOp>(
        loc, cellPtr.getType(), cellPtr,
        ArrayRef<LLVM::GEPArg>{0},
        elementType);

    // Store head
    builder.create<LLVM::StoreOp>(loc, headValue, headPtr);
  }

  // Store tail list at offset sizeof(element)
  void storeTail(
      OpBuilder &builder, Location loc, Value cellPtr,
      Value tailValue, Type elementType) const {

    auto ctx = builder.getContext();
    auto dataLayout = DataLayout::closest(loc.getParentModule());

    // Calculate tail offset
    uint64_t elemSize = dataLayout.getTypeSize(elementType);
    uint64_t tailOffset = (elemSize + 7) & ~7;  // Align to 8 bytes

    // GEP to tail field: cell + tailOffset bytes
    auto tailPtr = builder.create<LLVM::GEPOp>(
        loc, cellPtr.getType(), cellPtr,
        ArrayRef<LLVM::GEPArg>{static_cast<int32_t>(tailOffset)},
        builder.getI8Type());

    // Store tail
    builder.create<LLVM::StoreOp>(loc, tailValue, tailPtr);
  }

  // Build tagged union: {tag: 1, data: cellPtr}
  Value buildTaggedUnion(
      OpBuilder &builder, Location loc,
      LLVM::LLVMStructType structType, Value cellPtr) const {

    auto ctx = builder.getContext();
    auto i32Type = builder.getI32Type();

    // Tag = 1 (Cons)
    auto tagValue = builder.create<arith::ConstantIntOp>(loc, 1, i32Type);

    // Start with undefined struct
    auto undefStruct = builder.create<LLVM::UndefOp>(loc, structType);

    // Insert tag
    auto withTag = builder.create<LLVM::InsertValueOp>(
        loc, undefStruct, tagValue, ArrayRef<int64_t>{0});

    // Insert cell pointer
    auto withData = builder.create<LLVM::InsertValueOp>(
        loc, withTag, cellPtr, ArrayRef<int64_t>{1});

    return withData.getResult();
  }
};

Detailed Breakdown

Step 1: Cell size calculation

uint64_t elemSize = dataLayout.getTypeSize(elementType);
uint64_t tailSize = 16;  // struct<(i32, ptr)> aligned
uint64_t totalSize = elemSize + tailSize;
totalSize = (totalSize + 7) & ~7;  // Align to 8 bytes

Examples:

i32: 4 + 16 = 20 → 24 bytes
f64: 8 + 16 = 24 → 24 bytes
!funlang.closure (ptr): 8 + 16 = 24 → 24 bytes

Step 2: GC_malloc call

auto gcMalloc = module.lookupSymbol<LLVM::LLVMFuncOp>("GC_malloc");
if (!gcMalloc) {
  // Declare if not exists
  auto funcType = LLVM::LLVMFunctionType::get(ptrType, {i64Type});
  gcMalloc = builder.create<LLVM::LLVMFuncOp>(loc, "GC_malloc", funcType);
}
auto callOp = builder.create<LLVM::CallOp>(loc, gcMalloc, ValueRange{size});

생성되는 MLIR:

llvm.func @GC_malloc(i64) -> !llvm.ptr
%cell_ptr = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr

Step 3: Store head

auto headPtr = builder.create<LLVM::GEPOp>(
    loc, cellPtr.getType(), cellPtr,
    ArrayRef<LLVM::GEPArg>{0}, elementType);
builder.create<LLVM::StoreOp>(loc, headValue, headPtr);

생성되는 MLIR:

%head_ptr = llvm.getelementptr %cell_ptr[0] : (!llvm.ptr) -> !llvm.ptr, i32
llvm.store %head, %head_ptr : i32, !llvm.ptr

GEPOp (GetElementPtr):

  • Opaque pointers 시대의 GEP
  • Type hint: elementType (i32, f64, etc.)
  • Offset: [0] (first field)

Step 4: Store tail

uint64_t tailOffset = (elemSize + 7) & ~7;  // Aligned offset
auto tailPtr = builder.create<LLVM::GEPOp>(
    loc, cellPtr.getType(), cellPtr,
    ArrayRef<LLVM::GEPArg>{static_cast<int32_t>(tailOffset)},
    builder.getI8Type());
builder.create<LLVM::StoreOp>(loc, tailValue, tailPtr);

생성되는 MLIR:

%tail_ptr = llvm.getelementptr %cell_ptr[8] : (!llvm.ptr) -> !llvm.ptr, i8
llvm.store %tail, %tail_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

Byte-offset GEP:

  • Type hint: i8 (byte-addressable)
  • Offset: [8] (after 4-byte i32, aligned to 8)

Step 5: Tagged union

auto tagValue = builder.create<arith::ConstantIntOp>(loc, 1, i32Type);
auto undefStruct = builder.create<LLVM::UndefOp>(loc, structType);
auto withTag = builder.create<LLVM::InsertValueOp>(
    loc, undefStruct, tagValue, ArrayRef<int64_t>{0});
auto withData = builder.create<LLVM::InsertValueOp>(
    loc, withTag, cellPtr, ArrayRef<int64_t>{1});

생성되는 MLIR:

%tag = arith.constant 1 : i32
%undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%s1 = llvm.insertvalue %tag, %undef[0] : !llvm.struct<(i32, ptr)>
%cons = llvm.insertvalue %cell_ptr, %s1[1] : !llvm.struct<(i32, ptr)>

OpAdaptor Usage

OpAdaptor가 중요한 이유:

Value headValue = adaptor.getHead();  // Converted type!
Value tailValue = adaptor.getTail();  // Converted type!

Type conversion 자동 처리:

// Before lowering
%cons = funlang.cons %head, %tail : !funlang.list<i32>
// %head: i32
// %tail: !funlang.list<i32>

// During lowering (via OpAdaptor)
// adaptor.getHead(): i32 (unchanged)
// adaptor.getTail(): !llvm.struct<(i32, ptr)> (converted!)

이미 TypeConverter가 처리함:

  • OpAdaptor는 TypeConverter가 변환한 operands 제공
  • Pattern 코드는 converted types로 작업
  • 수동 type conversion 불필요

Complete Example

Input MLIR (FunLang dialect):

func.func @build_list() -> !funlang.list<i32> {
    %c1 = arith.constant 1 : i32
    %c2 = arith.constant 2 : i32
    %c3 = arith.constant 3 : i32

    %nil = funlang.nil : !funlang.list<i32>
    %lst1 = funlang.cons %c3, %nil : !funlang.list<i32>
    %lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>
    %lst3 = funlang.cons %c1, %lst2 : !funlang.list<i32>

    func.return %lst3 : !funlang.list<i32>
}

After lowering (LLVM dialect):

func.func @build_list() -> !llvm.struct<(i32, ptr)> {
    %c1 = arith.constant 1 : i32
    %c2 = arith.constant 2 : i32
    %c3 = arith.constant 3 : i32

    // Nil
    %c0_tag = arith.constant 0 : i32
    %null = llvm.mlir.zero : !llvm.ptr
    %nil_undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %nil_1 = llvm.insertvalue %c0_tag, %nil_undef[0] : !llvm.struct<(i32, ptr)>
    %nil = llvm.insertvalue %null, %nil_1[1] : !llvm.struct<(i32, ptr)>

    // Cons %c3, %nil
    %size1 = arith.constant 24 : i64
    %cell1 = llvm.call @GC_malloc(%size1) : (i64) -> !llvm.ptr
    %head1_ptr = llvm.getelementptr %cell1[0] : (!llvm.ptr) -> !llvm.ptr, i32
    llvm.store %c3, %head1_ptr : i32, !llvm.ptr
    %tail1_ptr = llvm.getelementptr %cell1[8] : (!llvm.ptr) -> !llvm.ptr, i8
    llvm.store %nil, %tail1_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr
    %c1_tag = arith.constant 1 : i32
    %lst1_undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %lst1_1 = llvm.insertvalue %c1_tag, %lst1_undef[0] : !llvm.struct<(i32, ptr)>
    %lst1 = llvm.insertvalue %cell1, %lst1_1[1] : !llvm.struct<(i32, ptr)>

    // Cons %c2, %lst1
    %size2 = arith.constant 24 : i64
    %cell2 = llvm.call @GC_malloc(%size2) : (i64) -> !llvm.ptr
    %head2_ptr = llvm.getelementptr %cell2[0] : (!llvm.ptr) -> !llvm.ptr, i32
    llvm.store %c2, %head2_ptr : i32, !llvm.ptr
    %tail2_ptr = llvm.getelementptr %cell2[8] : (!llvm.ptr) -> !llvm.ptr, i8
    llvm.store %lst1, %tail2_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr
    %lst2_1 = llvm.insertvalue %c1_tag, %lst1_undef[0] : !llvm.struct<(i32, ptr)>
    %lst2 = llvm.insertvalue %cell2, %lst2_1[1] : !llvm.struct<(i32, ptr)>

    // Cons %c1, %lst2
    %size3 = arith.constant 24 : i64
    %cell3 = llvm.call @GC_malloc(%size3) : (i64) -> !llvm.ptr
    %head3_ptr = llvm.getelementptr %cell3[0] : (!llvm.ptr) -> !llvm.ptr, i32
    llvm.store %c1, %head3_ptr : i32, !llvm.ptr
    %tail3_ptr = llvm.getelementptr %cell3[8] : (!llvm.ptr) -> !llvm.ptr, i8
    llvm.store %lst2, %tail3_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr
    %lst3_1 = llvm.insertvalue %c1_tag, %lst1_undef[0] : !llvm.struct<(i32, ptr)>
    %lst3 = llvm.insertvalue %cell3, %lst3_1[1] : !llvm.struct<(i32, ptr)>

    func.return %lst3 : !llvm.struct<(i32, ptr)>
}

Summary: ConsOp Lowering Pattern

구현 완료:

  • OpConversionPattern for funlang.cons
  • GC_malloc call for cons cell allocation
  • GEP + Store for head and tail
  • Tagged union construction with tag=1
  • OpAdaptor for converted operands

핵심 패턴:

  1. Calculate cell size from element type
  2. Allocate via GC_malloc
  3. Store head and tail with GEP
  4. Build tagged union with InsertValueOp

다음 섹션:

  • Complete lowering pass integration
  • Common errors and debugging

Complete Lowering Pass Update

이제 NilOpLowering과 ConsOpLowering을 FunLangToLLVM pass에 등록한다.

FunLangToLLVM Pass Structure

파일: mlir/lib/Dialect/FunLang/Transforms/FunLangToLLVM.cpp

//===----------------------------------------------------------------------===//
// FunLangToLLVM Pass
//===----------------------------------------------------------------------===//

struct FunLangToLLVMPass
    : public PassWrapper<FunLangToLLVMPass, OperationPass<ModuleOp>> {

  MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(FunLangToLLVMPass)

  StringRef getArgument() const final { return "convert-funlang-to-llvm"; }
  StringRef getDescription() const final {
    return "Convert FunLang dialect to LLVM dialect";
  }

  void runOnOperation() override {
    auto module = getOperation();
    auto *ctx = &getContext();

    // Get data layout from module
    auto dataLayout = DataLayout::closest(module);

    // Create type converter
    FunLangTypeConverter typeConverter(ctx, dataLayout);

    // Setup conversion target
    ConversionTarget target(*ctx);

    // Legal dialects (after conversion)
    target.addLegalDialect<LLVM::LLVMDialect>();
    target.addLegalDialect<arith::ArithDialect>();
    target.addLegalDialect<func::FuncDialect>();

    // Illegal dialects (must be converted)
    target.addIllegalDialect<funlang::FunLangDialect>();

    // Function signatures must be converted
    target.addDynamicallyLegalOp<func::FuncOp>([&](func::FuncOp op) {
      return typeConverter.isSignatureLegal(op.getFunctionType());
    });

    // Populate rewrite patterns
    RewritePatternSet patterns(ctx);

    // Phase 5 patterns (Chapter 16)
    patterns.add<ClosureOpLowering>(typeConverter, ctx);
    patterns.add<ApplyOpLowering>(typeConverter, ctx);

    // Phase 6 patterns (Chapter 18)
    patterns.add<NilOpLowering>(typeConverter, ctx);
    patterns.add<ConsOpLowering>(typeConverter, ctx);

    // Function signature conversion
    populateFunctionOpInterfaceTypeConversionPattern<func::FuncOp>(
        patterns, typeConverter);

    // Run partial conversion
    if (failed(applyPartialConversion(module, target, std::move(patterns)))) {
      signalPassFailure();
    }
  }
};

// Register pass
void registerFunLangToLLVMPass() {
  PassRegistration<FunLangToLLVMPass>();
}

Pattern Registration Order

순서가 중요한가?

일반적으로 순서 무관하다. DialectConversion framework가 모든 patterns를 시도한다.

하지만 성능 최적화를 위해:

  • 자주 매칭되는 patterns를 먼저 등록
  • 복잡한 patterns를 나중에 등록 (matching cost 고려)

FunLang의 경우:

// Frequency: ClosureOp > ApplyOp > ConsOp > NilOp (typical functional code)
patterns.add<ClosureOpLowering>(typeConverter, ctx);    // Most frequent
patterns.add<ApplyOpLowering>(typeConverter, ctx);
patterns.add<ConsOpLowering>(typeConverter, ctx);
patterns.add<NilOpLowering>(typeConverter, ctx);        // Least frequent

하지만 실용적으로는 로직 순서로 배치:

// Logical grouping
// Phase 5 operations
patterns.add<ClosureOpLowering>(typeConverter, ctx);
patterns.add<ApplyOpLowering>(typeConverter, ctx);

// Phase 6 operations
patterns.add<NilOpLowering>(typeConverter, ctx);
patterns.add<ConsOpLowering>(typeConverter, ctx);

Pass Manager Integration

F# compiler pipeline:

// FunLang.Compiler/Compiler.fs
let lowerToLLVM (mlirModule: MlirModule) =
    let pm = PassManager(mlirModule.Context)

    // Phase 5-6: FunLang → LLVM
    pm.AddPass("convert-funlang-to-llvm")

    // Standard MLIR lowering
    pm.AddPass("convert-func-to-llvm")
    pm.AddPass("convert-arith-to-llvm")
    pm.AddPass("reconcile-unrealized-casts")

    pm.Run(mlirModule)

Pass order:

  1. convert-funlang-to-llvm: FunLang ops → LLVM ops
  2. convert-func-to-llvm: func.func → llvm.func
  3. convert-arith-to-llvm: arith ops → llvm ops
  4. reconcile-unrealized-casts: Remove UnrealizedConversionCastOps

Testing List Construction

Test case:

// F# source
let test_list = [1; 2; 3]

Compiled MLIR (FunLang dialect):

module {
  func.func @test_list() -> !funlang.list<i32> {
    %c1 = arith.constant 1 : i32
    %c2 = arith.constant 2 : i32
    %c3 = arith.constant 3 : i32

    %nil = funlang.nil : !funlang.list<i32>
    %lst1 = funlang.cons %c3, %nil : !funlang.list<i32>
    %lst2 = funlang.cons %c2, %lst1 : !funlang.list<i32>
    %lst3 = funlang.cons %c1, %lst2 : !funlang.list<i32>

    func.return %lst3 : !funlang.list<i32>
  }
}

After lowering:

mlir-opt test.mlir \
  --convert-funlang-to-llvm \
  --convert-func-to-llvm \
  --convert-arith-to-llvm \
  --reconcile-unrealized-casts

Result (LLVM dialect):

module {
  llvm.func @GC_malloc(i64) -> !llvm.ptr

  llvm.func @test_list() -> !llvm.struct<(i32, ptr)> {
    %c1 = llvm.mlir.constant(1 : i32) : i32
    %c2 = llvm.mlir.constant(2 : i32) : i32
    %c3 = llvm.mlir.constant(3 : i32) : i32

    // Nil
    %c0 = llvm.mlir.constant(0 : i32) : i32
    %null = llvm.mlir.zero : !llvm.ptr
    %nil_undef = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %nil_1 = llvm.insertvalue %c0, %nil_undef[0] : !llvm.struct<(i32, ptr)>
    %nil = llvm.insertvalue %null, %nil_1[1] : !llvm.struct<(i32, ptr)>

    // Cons cells (similar to previous example)
    // ...

    llvm.return %lst3 : !llvm.struct<(i32, ptr)>
  }
}

End-to-End Example

Complete workflow:

// 1. F# AST → FunLang MLIR
let ast = parseExpression "[1; 2; 3]"
let mlirModule = compileToFunLang ast

// 2. FunLang MLIR → LLVM MLIR
lowerToLLVM mlirModule

// 3. LLVM MLIR → LLVM IR
let llvmIR = translateToLLVMIR mlirModule

// 4. LLVM IR → Object file
let objFile = compileLLVMIR llvmIR

// 5. Link with runtime
let executable = linkWithRuntime objFile

// 6. Run!
runExecutable executable

Memory diagram at runtime:

Stack:
  %lst3: {1, 0x1000}

Heap (GC-managed):
  0x1000: ConsCell { head: 1, tail: {1, 0x2000} }
  0x2000: ConsCell { head: 2, tail: {1, 0x3000} }
  0x3000: ConsCell { head: 3, tail: {0, null} }

Summary: Complete Lowering Pass

구현 완료:

  • FunLangToLLVMPass with all patterns
  • Pattern registration (Closure, Apply, Nil, Cons)
  • Pass manager integration
  • End-to-end list construction

Pass pipeline:

  1. convert-funlang-to-llvm
  2. convert-func-to-llvm
  3. convert-arith-to-llvm
  4. reconcile-unrealized-casts

다음 섹션:

  • Common errors and debugging strategies

Common Errors

Lowering pass 구현 시 흔히 발생하는 오류와 해결 방법.

Error 1: Wrong Cons Cell Size

증상:

Runtime segfault when accessing tail

원인:

// 잘못된 코드
uint64_t totalSize = elemSize + 12;  // struct<(i32, ptr)> = 12 bytes?
// 실제: struct는 alignment 때문에 16 bytes!

해결:

// 올바른 코드
uint64_t tailSize = 16;  // Aligned struct size
uint64_t totalSize = elemSize + tailSize;
totalSize = (totalSize + 7) & ~7;  // Align total to 8 bytes

디버깅:

// Print sizes in lowering pass
llvm::errs() << "Element size: " << elemSize << "\n";
llvm::errs() << "Total cell size: " << totalSize << "\n";

Error 2: Type Mismatch in Store Operations

증상:

error: 'llvm.store' op operand #0 type 'i32' does not match
  destination pointer element type '!llvm.struct<(i32, ptr)>'

원인:

// 잘못된 GEP - head pointer로 tail을 store
llvm.store %tail, %head_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

해결:

// 올바른 GEP offsets
auto headPtr = builder.create<LLVM::GEPOp>(
    loc, cellPtr.getType(), cellPtr, ArrayRef<LLVM::GEPArg>{0}, elementType);
    // ^^^^^^^^ offset 0 for head

auto tailPtr = builder.create<LLVM::GEPOp>(
    loc, cellPtr.getType(), cellPtr,
    ArrayRef<LLVM::GEPArg>{tailOffset}, builder.getI8Type());
    // ^^^^^^^^^^^^^^^^ byte offset for tail

Error 3: Missing TypeConverter Rule

증상:

error: failed to legalize operation 'funlang.cons'
  operand #1 type '!funlang.list<i32>' is not legal

원인:

TypeConverter에 list type 변환 규칙 없음.

해결:

// TypeConverter에 추가
addConversion([](funlang::FunLangListType type) {
  auto ctx = type.getContext();
  auto i32Type = IntegerType::get(ctx, 32);
  auto ptrType = LLVM::LLVMPointerType::get(ctx);
  return LLVM::LLVMStructType::getLiteral(ctx, {i32Type, ptrType});
});

Error 4: GEP Index Confusion

증상:

Runtime crash: accessing wrong memory offset

원인:

// Element index vs byte offset 혼동
auto tailPtr = builder.create<LLVM::GEPOp>(
    loc, cellPtr.getType(), cellPtr,
    ArrayRef<LLVM::GEPArg>{1},  // Element index 1? No!
    structType);

해결:

// Byte offset 사용
uint64_t tailOffset = (elemSize + 7) & ~7;
auto tailPtr = builder.create<LLVM::GEPOp>(
    loc, cellPtr.getType(), cellPtr,
    ArrayRef<LLVM::GEPArg>{static_cast<int32_t>(tailOffset)},
    builder.getI8Type());  // i8 for byte-addressable

GEP modes:

  • Type-based: GEP ptr, [index] with element type → element index
  • Byte-based: GEP ptr, [offset] with i8 type → byte offset

Debugging Strategies

Strategy 1: Print intermediate MLIR

mlir-opt input.mlir \
  --convert-funlang-to-llvm \
  --print-ir-after-all \
  -o output.mlir

Strategy 2: Use mlir-opt with debug flags

mlir-opt input.mlir \
  --convert-funlang-to-llvm \
  --debug-only=dialect-conversion \
  --mlir-print-debuginfo

Strategy 3: Add assertions in lowering patterns

LogicalResult matchAndRewrite(...) const override {
  // Check preconditions
  assert(adaptor.getTail().getType().isa<LLVM::LLVMStructType>() &&
         "Tail must be converted to struct type");

  // Pattern logic...
}

Strategy 4: Test incrementally

// Test NilOp alone first
func.func @test_nil() -> !funlang.list<i32> {
    %nil = funlang.nil : !funlang.list<i32>
    func.return %nil : !funlang.list<i32>
}

// Then ConsOp with nil
func.func @test_cons_nil() -> !funlang.list<i32> {
    %nil = funlang.nil : !funlang.list<i32>
    %c1 = arith.constant 1 : i32
    %cons = funlang.cons %c1, %nil : !funlang.list<i32>
    func.return %cons : !funlang.list<i32>
}

// Then multiple cons
// ...

Summary: Common Errors

주요 실수:

  1. Cons cell size 계산 오류 (alignment 무시)
  2. GEP offset 혼동 (element index vs byte offset)
  3. TypeConverter 규칙 누락
  4. Store type mismatch

디버깅 도구:

  • mlir-opt --print-ir-after-all
  • --debug-only=dialect-conversion
  • Assertions in pattern code
  • Incremental testing

다음 섹션:

  • Chapter 18 summary and Chapter 19 preview

Summary and Chapter 19 Preview

Chapter 18 복습

이 장에서 구현한 것:

  1. List Representation Design

    • Tagged union: !llvm.struct<(i32, ptr)>
    • Cons cells: Heap-allocated {element, tail} structs
    • Immutability and structural sharing
  2. FunLang List Type

    • !funlang.list<T> parameterized type
    • TableGen definition with type parameter
    • C API shim and F# bindings
  3. funlang.nil Operation

    • Empty list constructor
    • Pure trait (no allocation)
    • Lowering: constant struct {0, null}
  4. funlang.cons Operation

    • Cons cell constructor
    • Type-safe head/tail constraints
    • Lowering: GC_malloc + GEP + store
  5. TypeConverter Extension

    • !funlang.list<T>!llvm.struct<(i32, ptr)>
    • Element type erasure at runtime
    • Integration with FunLangTypeConverter
  6. Lowering Patterns

    • NilOpLowering: InsertValueOp for struct construction
    • ConsOpLowering: GC_malloc + GEP + store + InsertValueOp
    • Complete pass integration

List Operations의 의의

Before Chapter 18:

// 리스트 표현 불가
// 패턴 매칭 불가

After Chapter 18:

// 리스트 생성 가능
%nil = funlang.nil : !funlang.list<i32>
%lst = funlang.cons %head, %tail : !funlang.list<i32>

// Chapter 19에서 패턴 매칭 추가:
%result = funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil: ...
  ^cons(%h, %t): ...
}

성공 기준 달성 확인

  • List의 메모리 표현(tagged union)을 이해한다
  • !funlang.list<T> 타입을 TableGen으로 정의할 수 있다
  • funlang.nilfunlang.cons의 동작 원리를 안다
  • TypeConverter로 FunLang → LLVM 타입 변환을 구현할 수 있다
  • Lowering pattern으로 operation을 LLVM dialect로 변환할 수 있다
  • Chapter 19에서 funlang.match 구현을 시작할 준비가 된다

Chapter 19 Preview: Match Compilation

Chapter 19의 목표:

funlang.match operation으로 패턴 매칭을 MLIR로 표현하고, decision tree 알고리즘을 lowering으로 구현한다.

funlang.match operation (preview):

%sum = funlang.match %list : !funlang.list<i32> -> i32 {
  ^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32

  ^cons(%head: i32, %tail: !funlang.list<i32>):
    %sum_tail = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
    %result = arith.addi %head, %sum_tail : i32
    funlang.yield %result : i32
}

Lowering strategy:

// funlang.match lowering → scf.if + tag dispatch

// Extract tag
%tag_ptr = llvm.getelementptr %list[0] : ...
%tag = llvm.load %tag_ptr : ...

// Dispatch
%is_nil = arith.cmpi eq, %tag, %c0 : i32
%result = scf.if %is_nil -> i32 {
  // Nil case
  %zero = arith.constant 0 : i32
  scf.yield %zero : i32
} else {
  // Cons case: extract head and tail
  %data_ptr = llvm.getelementptr %list[1] : ...
  %cell = llvm.load %data_ptr : ...
  %head = llvm.load %head_ptr : ...
  %tail = llvm.load %tail_ptr : ...

  // Execute cons body
  %sum_tail = func.call @sum_list(%tail) : ...
  %result = arith.addi %head, %sum_tail : i32
  scf.yield %result : i32
}

Chapter 19 구조:

  1. funlang.match Operation: Region-based pattern matching
  2. MatchOp Lowering: Decision tree → scf.if/cf.br
  3. Pattern Decomposition: Tag dispatch + field extraction
  4. Exhaustiveness Checking: Verification at operation level
  5. End-to-End Examples: sum_list, length, map, filter

Phase 6 Progress

Completed:

  • Chapter 17: Pattern Matching Theory (decision tree algorithm)
  • Chapter 18: List Operations (nil, cons, lowering)

Remaining:

  • Chapter 19: Match Compilation (funlang.match operation and lowering)
  • Chapter 20: Functional Programs (실전 예제: map, filter, fold)

Phase 6이 완료되면:

  • 완전한 함수형 언어 (closures + pattern matching + data structures)
  • Real-world functional programs 작성 가능
  • Phase 7 (optimizations)의 기반 완성

마무리

Chapter 18에서 배운 핵심 개념:

  1. Parameterized types: !funlang.list<T> for type safety
  2. Tagged unions: Runtime representation of sum types
  3. GC allocation: Heap-allocated cons cells
  4. Type erasure: Element type as compile-time information
  5. ConversionPattern: OpConversionPattern + TypeConverter + OpAdaptor

Next chapter: Let’s implement pattern matching with funlang.match!

Chapter 19: Match Compilation (Match Compilation)

소개

Chapter 17에서는 패턴 매칭의 이론적 기반을 다뤘다:

  • Decision tree 알고리즘 (Maranget 2008)
  • Pattern matrix와 specialization/defaulting 연산
  • Exhaustiveness checking과 unreachable case detection

Chapter 18에서는 패턴 매칭이 작동할 데이터 구조를 구현했다:

  • !funlang.list<T> parameterized type
  • funlang.nilfunlang.cons operations
  • TypeConverter로 tagged union 변환
  • NilOpLowering과 ConsOpLowering patterns

Chapter 19에서는 모든 것을 종합하여 패턴 매칭 컴파일을 완성한다. funlang.match operation을 정의하고 SCF dialect로 lowering하여 실행 가능한 코드를 생성한다.

두 장의 복습: 왜 Match Operation이 필요한가?

Chapter 17에서 우리는 decision tree 알고리즘을 배웠다:

// F# 패턴 매칭 예제
let rec sum_list lst =
    match lst with
    | [] -> 0
    | head :: tail -> head + sum_list tail

sum_list [1; 2; 3]  // 6

Decision tree 컴파일 결과:

Switch on lst:
  Case Nil -> return 0
  Case Cons(head, tail) -> return head + sum_list tail

Chapter 18에서 우리는 리스트 데이터 구조를 구현했다:

// Empty list
%empty = funlang.nil : !funlang.list<i32>

// List construction: [1, 2, 3]
%three = arith.constant 3 : i32
%t3 = funlang.nil : !funlang.list<i32>
%l3 = funlang.cons %three, %t3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

%two = arith.constant 2 : i32
%l2 = funlang.cons %two, %l3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

%one = arith.constant 1 : i32
%l1 = funlang.cons %one, %l2 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

이제 이 두 가지를 연결할 방법이 필요하다:

// 목표: sum_list를 MLIR로 표현
func.func @sum_list(%lst: !funlang.list<i32>) -> i32 {
  %result = funlang.match %lst : !funlang.list<i32> -> i32 {
    ^nil:
      %zero = arith.constant 0 : i32
      funlang.yield %zero : i32
    ^cons(%head: i32, %tail: !funlang.list<i32>):
      %tail_sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
      %sum = arith.addi %head, %tail_sum : i32
      funlang.yield %sum : i32
  }
  return %result : i32
}

funlang.match: The Most Complex Operation

funlang.match가 가장 복잡한가?

지금까지 우리가 구현한 FunLang operations:

OperationComplexityWhy
funlang.nilSimpleZero arguments, constant value
funlang.consModerateTwo operands, GC allocation
funlang.closureModerateFunction ref + captures, GC allocation
funlang.applyModerateIndirect call, block arguments
funlang.matchComplexMultiple regions, block arguments, type conversion

funlang.match의 복잡성:

  1. Region-based structure: 각 case가 별도의 region (not just basic block)
  2. Variable number of cases: Nil/Cons 2개부터 임의의 pattern 개수까지
  3. Block arguments per case: Cons case는 (%head, %tail) 같은 바인딩 필요
  4. Type conversion in regions: 각 region 내부의 operations도 lowering 필요
  5. Multi-stage lowering: FunLang → SCF → CF → LLVM

Chapter 15 Preview 복습:

Chapter 15에서 우리는 funlang.match를 미리 살짝 봤다:

// Chapter 15의 preview (간략 버전)
def FunLang_MatchOp : FunLang_Op<"match"> {
  let summary = "Pattern matching operation";
  let arguments = (ins AnyType:$input);
  let results = (outs AnyType:$result);
  let regions = (region VariadicRegion<SizedRegion<1>>:$cases);
}

Chapter 19에서는 완전한 버전을 구현한다:

  • Full TableGen definition with verification
  • Custom assembly format (parser/printer)
  • C API shim for region-based operation
  • F# bindings with builder callback
  • Lowering pattern to SCF dialect

Multi-Stage Lowering: FunLang → SCF → LLVM

왜 SCF dialect를 거치는가?

Phase 5에서 우리는 FunLang operations를 직접 LLVM dialect로 lowering했다:

funlang.closure → llvm.alloca + llvm.store  (direct lowering)
funlang.apply   → llvm.load + llvm.call     (direct lowering)

하지만 funlang.match는 다르다:

funlang.match → scf.index_switch → cf.switch → llvm.switch
              (structured)        (CFG)       (machine)

이유:

  1. Structured control flow preservation: SCF는 high-level structure 유지
  2. Optimization opportunities: SCF level에서 최적화 가능 (dead case elimination, etc.)
  3. Debugging: SCF IR이 source 구조를 반영하여 디버깅 쉬움
  4. Separation of concerns: Pattern matching logic과 low-level branching 분리

SCF Dialect란?

SCF = Structured Control Flow

MLIR의 standard dialect 중 하나로, high-level control flow operations 제공:

// scf.if: two-way branching (Chapter 8에서 사용)
%result = scf.if %cond : i1 -> i32 {
  %x = arith.constant 42 : i32
  scf.yield %x : i32
} else {
  %y = arith.constant 0 : i32
  scf.yield %y : i32
}

// scf.index_switch: multi-way branching (Chapter 19에서 사용)
%result = scf.index_switch %tag : index -> i32
case 0 {
  %zero = arith.constant 0 : i32
  scf.yield %zero : i32
}
case 1 {
  %one = arith.constant 1 : i32
  scf.yield %one : i32
}
default {
  %minus = arith.constant -1 : i32
  scf.yield %minus : i32
}

SCF vs CF (Control Flow) dialect:

DialectLevelStructureWhen
SCFHigh-levelStructured (nested regions)Pattern matching, loops
CFLow-levelUnstructured (goto-like)After SCF lowering

Complete lowering pipeline:

FunLang Dialect
    ↓ (FunLangToSCFPass)
SCF + FunLang (partially lowered)
    ↓ (FunLangToLLVMPass - for nil/cons/closure/apply)
SCF + LLVM
    ↓ (SCFToControlFlowPass)
CF + LLVM
    ↓ (ControlFlowToLLVMPass)
LLVM Dialect only
    ↓ (LLVMToObjectPass)
Machine code

Chapter 19 Goals

이 장에서 배울 것:

  1. Match Operation Definition (Part 1)

    • Region-based operation structure
    • TableGen definition with VariadicRegion
    • Custom verifier for region semantics
    • YieldOp terminator for match results
    • C API shim for region-based operations
    • F# bindings with builder callback pattern
  2. SCF Lowering (Part 2)

    • SCF dialect overview and scf.index_switch
    • MatchOpLowering pattern implementation
    • Region cloning and type conversion
    • Block argument remapping
    • Common errors and debugging strategies
  3. End-to-End Example

    • length function: complete compilation pipeline
    • Stage-by-stage IR transformation
    • Performance comparison vs naive approach

Success criteria:

  • funlang.match operation defined and verified
  • ✅ Lowering to scf.index_switch working
  • ✅ Pattern variables bound via block arguments
  • ✅ End-to-end compilation of recursive list functions

Let’s begin!


Part 1: Match Operation Definition

Region-Based Operations: The Foundation

Region이란 무엇인가?

MLIR에서 regionbasic blocks의 container다.

Region
  ├─ Block 1 (entry block)
  │   ├─ Operation 1
  │   ├─ Operation 2
  │   └─ Terminator
  ├─ Block 2
  │   └─ ...
  └─ Block N

우리가 이미 본 region-based operations:

Chapter 8에서 scf.if:

scf.if %cond : i1 -> i32 {
  // "then" region (1 block)
  %x = arith.constant 42 : i32
  scf.yield %x : i32
} else {
  // "else" region (1 block)
  %y = arith.constant 0 : i32
  scf.yield %y : i32
}
  • scf.if는 2개의 regions (then, else)
  • 각 region은 exactly 1 block
  • 각 block은 scf.yield terminator로 끝남

Chapter 10에서 func.func:

func.func @my_function(%arg: i32) -> i32 {
  // function body region (1 or more blocks)
  %result = arith.addi %arg, %arg : i32
  return %result : i32
}
  • func.func는 1개의 region (body)
  • Region은 1개 이상의 blocks (control flow로 여러 block 가능)
  • Entry block은 function arguments as block arguments

왜 basic blocks이 아니라 regions인가?

Scenario: match expression with 3 cases

// F# code
match shape with
| Circle r -> compute_circle_area r
| Rectangle (w, h) -> compute_rectangle_area w h
| Triangle (a, b, c) -> compute_triangle_area a b c

Option 1: Basic blocks (NOT what we do)

// 잘못된 접근: basic blocks only
func.func @match_shape(%shape: !funlang.shape) -> f32 {
  // ... tag extraction ...
  cf.br ^dispatch

^dispatch:
  cf.switch %tag [
    ^circle,
    ^rectangle,
    ^triangle
  ]

^circle:
  // Circle case logic
  cf.br ^exit

^rectangle:
  // Rectangle case logic
  cf.br ^exit

^triangle:
  // Triangle case logic
  cf.br ^exit

^exit(%result: f32):
  return %result : f32
}

문제점:

  1. All blocks in same scope: ^circle, ^rectangle, ^triangle은 모두 같은 function body region
  2. No encapsulation: Case logic이 function CFG에 섞임
  3. Hard to verify: “각 case가 정확히 1개의 yield를 가지는가?” 검증 어려움
  4. Type conversion complexity: Lowering pass가 case blocks을 구분하기 어려움

Option 2: Regions (What we do)

// 올바른 접근: regions
func.func @match_shape(%shape: !funlang.shape) -> f32 {
  %result = funlang.match %shape : !funlang.shape -> f32 {
    ^circle(%r: f32):
      %area = call @compute_circle_area(%r) : (f32) -> f32
      funlang.yield %area : f32
    ^rectangle(%w: f32, %h: f32):
      %area = call @compute_rectangle_area(%w, %h) : (f32, f32) -> f32
      funlang.yield %area : f32
    ^triangle(%a: f32, %b: f32, %c: f32):
      %area = call @compute_triangle_area(%a, %b, %c) : (f32, f32, f32) -> f32
      funlang.yield %area : f32
  }
  return %result : f32
}

장점:

  1. Encapsulation: 각 case가 자신만의 region (isolated scope)
  2. Clear structure: match operation이 모든 cases를 소유
  3. Easy verification: 각 region은 정확히 1 block, 1 terminator
  4. Lowering-friendly: Region 단위로 type conversion 수행 가능

Region vs Block vs Operation:

Operation: funlang.match
  ↓ has
Regions: [case 1 region, case 2 region, ...]
  ↓ each contains
Blocks: [entry block]
  ↓ contains
Operations: [arith.constant, func.call, funlang.yield, ...]

Match Operation Semantics

funlang.match의 의미론:

%result = funlang.match %input : InputType -> ResultType {
  ^case1(...pattern_vars1...):
    // case 1 logic
    funlang.yield %value1 : ResultType
  ^case2(...pattern_vars2...):
    // case 2 logic
    funlang.yield %value2 : ResultType
  ...
}

Execution semantics:

  1. Input evaluation: %input 값을 runtime에 evaluate
  2. Tag extraction: Tagged union에서 tag value 추출
  3. Case selection: Tag에 따라 해당 region 선택
  4. Pattern variable binding: Region의 block arguments에 values 바인딩
  5. Case execution: 선택된 region 실행
  6. Result yielding: Region의 funlang.yield%result에 값 전달

Example: sum_list

func.func @sum_list(%lst: !funlang.list<i32>) -> i32 {
  %result = funlang.match %lst : !funlang.list<i32> -> i32 {
    ^nil:
      %zero = arith.constant 0 : i32
      funlang.yield %zero : i32
    ^cons(%head: i32, %tail: !funlang.list<i32>):
      %tail_sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
      %sum = arith.addi %head, %tail_sum : i32
      funlang.yield %sum : i32
  }
  return %result : i32
}

Runtime execution: sum_list([1, 2])

  1. Call: @sum_list([1, 2])
  2. Tag extraction: Tag = 1 (Cons)
  3. Case selection: ^cons region
  4. Variable binding: %head = 1, %tail = [2]
  5. Recursive call: @sum_list([2])
    • Tag = 1 (Cons)
    • %head = 2, %tail = []
    • Recursive call: @sum_list([])
      • Tag = 0 (Nil)
      • Return 0
    • %sum = 2 + 0 = 2
    • Return 2
  6. Final sum: 1 + 2 = 3
  7. Return: 3

Block Arguments for Pattern Variables

패턴 변수는 어떻게 바인딩되는가?

Chapter 2에서 우리는 block arguments를 배웠다:

^entry_block(%arg0: i32, %arg1: i32):
  %sum = arith.addi %arg0, %arg1 : i32

Block arguments는 PHI nodes의 structured 대안이다.

Match operation에서 block arguments 활용:

funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil:
    // Nil case: 패턴 변수 없음 → block arguments 없음
    funlang.yield %zero : i32

  ^cons(%head: i32, %tail: !funlang.list<i32>):
    // Cons case: 2개 패턴 변수 → 2개 block arguments
    // %head: i32          → cons cell의 head field
    // %tail: !funlang.list<i32> → cons cell의 tail field
    funlang.yield %sum : i32
}

Lowering이 block arguments를 채우는 방법:

// funlang.match lowering 후 (pseudo-code)
%tag = // extract tag from %lst
scf.index_switch %tag {
  case 0 {  // Nil case
    // No data to extract, no arguments
    %zero = arith.constant 0 : i32
    scf.yield %zero : i32
  }
  case 1 {  // Cons case
    // Extract head and tail from cons cell
    %head = // extract field 0 from data pointer
    %tail = // extract field 1 from data pointer
    // Now pass to the ^cons block's body (with arguments bound)
    ^cons(%head, %tail):
      // User code here
  }
}

실제로는 region을 clone하고 IRMapping으로 arguments를 remap한다 (Part 2에서 자세히)

Block arguments vs Let bindings:

// Option 1: Block arguments (what we do)
^cons(%head: i32, %tail: !funlang.list<i32>):
  %sum = arith.addi %head, ... : i32

// Option 2: Let-style extraction (what we DON'T do)
^cons:
  %head = funlang.extract_head %lst : !funlang.list<i32> -> i32
  %tail = funlang.extract_tail %lst : !funlang.list<i32> -> !funlang.list<i32>
  %sum = arith.addi %head, ... : i32

Block arguments가 더 나은 이유:

  1. Declarative: Pattern structure가 arguments에 직접 반영
  2. SSA-friendly: Block entry에서 values가 이미 available
  3. No redundant ops: extract operations 불필요
  4. Verification: Argument types로 pattern structure 검증 가능

TableGen Definition: MatchOp

이제 funlang.match operation의 TableGen 정의를 작성한다.

File: FunLang/FunLangOps.td (conceptual, 실제로는 C++ codebase)

def FunLang_MatchOp : FunLang_Op<"match", [
    RecursiveSideEffect,
    SingleBlockImplicitTerminator<"YieldOp">
  ]> {
  let summary = "Pattern matching operation";
  let description = [{
    The `funlang.match` operation performs pattern matching on a value.
    Each case is represented as a separate region with exactly one block.

    The entry block of each region may have arguments corresponding to
    pattern variables. For example, a Cons case has two arguments:
    the head element and the tail list.

    Each region must terminate with a `funlang.yield` operation that
    returns a value of the result type.

    Example:
    ```mlir
    %result = funlang.match %lst : !funlang.list<i32> -> i32 {
      ^nil:
        %zero = arith.constant 0 : i32
        funlang.yield %zero : i32
      ^cons(%head: i32, %tail: !funlang.list<i32>):
        %sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
        %result = arith.addi %head, %sum : i32
        funlang.yield %result : i32
    }
    ```
  }];

  let arguments = (ins AnyType:$input);
  let results = (outs AnyType:$result);
  let regions = (region VariadicRegion<SizedRegion<1>>:$cases);

  let hasCustomAssemblyFormat = 1;
  let hasVerifier = 1;
}

핵심 요소 설명:

1. Traits: RecursiveSideEffect

RecursiveSideEffect

의미: 이 operation의 side effects는 내부 regions의 operations에 의존한다.

왜 필요한가?

MLIR optimizer는 side effects를 분석하여 dead code elimination, common subexpression elimination 등을 수행한다.

  • funlang.nilPure trait → no side effects
  • funlang.cons는 side effects 있음 (GC allocation)

Match operation은?

%result = funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil:
    %x = funlang.nil : !funlang.list<i32>  // Pure
    funlang.yield %zero : i32
  ^cons(%h, %t):
    %y = funlang.cons %h, %t : ...  // Side effect!
    funlang.yield %sum : i32
}
  • Nil case: no side effects
  • Cons case: side effect (funlang.cons)

RecursiveSideEffect trait는 MLIR에게 말한다:

“이 operation의 side effects는 내부 regions을 재귀적으로 분석해서 결정해라”

없으면 어떻게 되나?

  • Conservative assumption: match는 항상 side effects 있음
  • Optimizer가 legitimate optimizations를 못함
  • 예: dead match elimination 불가

2. Traits: SingleBlockImplicitTerminator

SingleBlockImplicitTerminator<"YieldOp">

의미: 각 region은 정확히 1개의 block을 가지며, 그 block은 YieldOp로 끝나야 한다.

검증 자동화:

이 trait가 있으면 MLIR이 자동으로 검증:

  1. 각 region이 정확히 1 block인가?
  2. 그 block이 funlang.yield로 끝나는가?

없으면 어떻게 되나?

Custom verifier에서 수동 검증 필요:

// Without the trait (manual verification)
LogicalResult MatchOp::verify() {
  for (Region& region : getCases()) {
    if (!region.hasOneBlock()) {
      return emitError("each case must have exactly one block");
    }
    Block& block = region.front();
    if (!isa<YieldOp>(block.getTerminator())) {
      return emitError("each case must terminate with funlang.yield");
    }
  }
  return success();
}

Trait가 이 boilerplate를 제거한다!

3. Regions: VariadicRegion<SizedRegion<1>>

let regions = (region VariadicRegion<SizedRegion<1>>:$cases);

분해:

  • VariadicRegion: 가변 개수의 regions (Nil/Cons = 2개, 더 많은 patterns = N개)
  • SizedRegion<1>: 각 region은 정확히 1개의 block
  • :$cases: C++ accessor name → getCases() method

대안들과 비교:

DeclarationMeaning
region AnyRegion:$bodyExactly 1 region, any number of blocks
region SizedRegion<1>:$bodyExactly 1 region, exactly 1 block
region VariadicRegion<AnyRegion>:$casesN regions, each with any blocks
region VariadicRegion<SizedRegion<1>>:$casesN regions, each with 1 block ✅

scf.if와 비교:

// scf.if (exactly 2 regions)
def SCF_IfOp : ... {
  let regions = (region SizedRegion<1>:$thenRegion,
                        SizedRegion<1>:$elseRegion);
}

// funlang.match (variable number of regions)
def FunLang_MatchOp : ... {
  let regions = (region VariadicRegion<SizedRegion<1>>:$cases);
}

4. Custom Assembly Format

let hasCustomAssemblyFormat = 1;

이유: Generic format은 readable하지 않다.

Generic format (자동 생성):

%result = "funlang.match"(%lst) ({
  ^bb0:
    %zero = arith.constant 0 : i32
    "funlang.yield"(%zero) : (i32) -> ()
}, {
  ^bb0(%head: i32, %tail: !funlang.list<i32>):
    %sum = arith.addi %head, %tail_sum : i32
    "funlang.yield"(%sum) : (i32) -> ()
}) : (!funlang.list<i32>) -> i32

Custom format (우리가 작성):

%result = funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32
  ^cons(%head: i32, %tail: !funlang.list<i32>):
    %sum = arith.addi %head, %tail_sum : i32
    funlang.yield %sum : i32
}

Custom parser/printer 구현 필요 (C++ code):

// File: FunLangOps.cpp

void MatchOp::print(OpAsmPrinter& p) {
  p << " " << getInput() << " : " << getInput().getType()
    << " -> " << getResult().getType() << " ";

  p.printRegion(getCases(), /*printEntryBlockArgs=*/true);
}

ParseResult MatchOp::parse(OpAsmParser& parser, OperationState& result) {
  OpAsmParser::UnresolvedOperand input;
  Type inputType, resultType;
  Region* casesRegion = result.addRegion();

  if (parser.parseOperand(input) ||
      parser.parseColon() ||
      parser.parseType(inputType) ||
      parser.parseArrow() ||
      parser.parseType(resultType) ||
      parser.parseRegion(*casesRegion, /*arguments=*/{}, /*argTypes=*/{}))
    return failure();

  result.addTypes(resultType);
  return success();
}

실제 구현은 더 복잡하지만, F# tutorial에서는 C API로 추상화됨

5. Custom Verifier

let hasVerifier = 1;

검증할 내용:

  1. ✅ Region count > 0
  2. ✅ 각 region의 block arguments types 검증
  3. ✅ 각 region의 yield type이 result type과 일치
  4. ✅ Input type이 matchable type (현재는 !funlang.list)

C++ verifier implementation:

// File: FunLangOps.cpp

LogicalResult MatchOp::verify() {
  // Check: at least one case
  if (getCases().empty()) {
    return emitError("match must have at least one case");
  }

  Type resultType = getResult().getType();

  // Check each case region
  for (Region& region : getCases()) {
    if (region.empty())
      return emitError("case region cannot be empty");

    Block& block = region.front();

    // Verify terminator (already checked by SingleBlockImplicitTerminator)
    auto yieldOp = dyn_cast<YieldOp>(block.getTerminator());
    if (!yieldOp)
      return emitError("case must terminate with funlang.yield");

    // Verify yield type matches result type
    if (yieldOp.getValue().getType() != resultType) {
      return emitError("yield type ")
             << yieldOp.getValue().getType()
             << " does not match result type " << resultType;
    }
  }

  return success();
}

실전 예제:

// ERROR: No cases
%result = funlang.match %lst : !funlang.list<i32> -> i32 {
}
// Error: match must have at least one case

// ERROR: Type mismatch
%result = funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil:
    %x = arith.constant 3.14 : f32  // Wrong type!
    funlang.yield %x : f32
}
// Error: yield type f32 does not match result type i32

YieldOp: Match Result Terminator

각 match case는 funlang.yield로 끝나야 한다.

TableGen definition:

def FunLang_YieldOp : FunLang_Op<"yield", [
    Terminator,
    HasParent<"MatchOp">
  ]> {
  let summary = "Yield a value from a match case";
  let description = [{
    The `funlang.yield` operation terminates a match case region and
    returns a value to the parent `funlang.match` operation.

    Example:
    ```mlir
    funlang.match %lst : !funlang.list<i32> -> i32 {
      ^nil:
        %zero = arith.constant 0 : i32
        funlang.yield %zero : i32  // Yield from nil case
      ^cons(%h, %t):
        %sum = arith.addi %h, ... : i32
        funlang.yield %sum : i32   // Yield from cons case
    }
    ```
  }];

  let arguments = (ins AnyType:$value);
  let results = (outs);

  let assemblyFormat = "$value attr-dict `:` type($value)";
}

핵심 요소:

1. Trait: Terminator

Terminator

의미: 이 operation은 basic block을 종료한다.

Block의 terminator 규칙:

  • 모든 block은 정확히 1개의 terminator로 끝나야 함
  • Terminator는 block의 마지막 operation이어야 함
  • Terminator 예: func.return, cf.br, scf.yield, funlang.yield

2. Trait: HasParent<“MatchOp”>

HasParent<"MatchOp">

의미: 이 operation은 MatchOp의 region 내에서만 사용 가능.

검증 자동화:

// OK: inside funlang.match
funlang.match %lst {
  ^nil:
    funlang.yield %zero : i32  // ✅
}

// ERROR: outside match
func.func @wrong() -> i32 {
  %x = arith.constant 42 : i32
  funlang.yield %x : i32  // ❌ Error: funlang.yield must be inside MatchOp
}

3. Assembly Format

let assemblyFormat = "$value attr-dict `:` type($value)";

생성되는 format:

funlang.yield %sum : i32

Generic format과 비교:

// Generic (verbose)
"funlang.yield"(%sum) : (i32) -> ()

// Custom (readable)
funlang.yield %sum : i32

scf.yield와 비교

MLIR에는 여러 yield operations이 있다:

OperationParentPurpose
scf.yieldscf.if, scf.for, scf.whileSCF control flow
funlang.yieldfunlang.matchFunLang pattern matching
affine.yieldaffine.for, affine.ifAffine loops

scf.yield를 재사용하지 않는가?

Option 1: 재사용 (하지 않음)

funlang.match %lst {
  ^nil:
    scf.yield %zero : i32  // Reuse scf.yield?
}

문제:

  1. Trait conflict: scf.yieldHasParent<"IfOp", "ForOp", ...>

    • MatchOp이 parent list에 없으면 verifier 실패
    • SCF dialect 수정 필요 (bad coupling)
  2. Semantic confusion: scf.yield는 SCF dialect semantics

    • Lowering pass에서 scf.yield 처리 시 match context 고려해야 함
    • Separation of concerns 위반

Option 2: 전용 operation (우리가 하는 것)

funlang.match %lst {
  ^nil:
    funlang.yield %zero : i32  // FunLang-specific yield
}

장점:

  1. Clear ownership: FunLang dialect이 자신의 terminators 소유
  2. Lowering flexibility: funlang.yieldscf.yield 변환을 명시적으로 제어
  3. Future extensions: 나중에 funlang.yield에 attributes 추가 가능

C API and F# Integration

Region-based operations는 C API 설계가 복잡하다.

문제: Regions를 어떻게 F#에서 구축하는가?

Simple operations (Chapter 15):

// funlang.cons: no regions, straightforward
let cons =
    FunLangOps.CreateConsOp(builder, head, tail, listType)

Region-based operations (Chapter 19):

// funlang.match: multiple regions, complex
let matchOp = FunLangOps.CreateMatchOp(builder, input, resultType, [
    // How to build regions here???
    nilRegion;
    consRegion
])

Challenge:

  • Region 구축은 F# side에서 일어나야 함 (pattern cases logic)
  • 하지만 MLIR C++ API를 직접 호출할 수 없음 (C API만 가능)
  • Builder callback pattern 필요!

C API Shim: Builder Callback Pattern

Pattern: C API가 F# callback을 받아서 region을 채움

C API shim (C wrapper):

// File: FunLang-C/FunLangOps.h

typedef void (*FunLangMatchCaseBuilder)(
    MlirOpBuilder builder,
    MlirBlock block,
    void* userData
);

typedef struct {
    FunLangMatchCaseBuilder builder;
    void* userData;
} FunLangMatchCase;

MLIR_CAPI_EXPORTED MlirOperation funlangMatchOpCreate(
    MlirOpBuilder builder,
    MlirLocation loc,
    MlirValue input,
    MlirType resultType,
    FunLangMatchCase* cases,
    intptr_t numCases
);

Implementation (C++):

// File: FunLang-C/FunLangOps.cpp

MlirOperation funlangMatchOpCreate(
    MlirOpBuilder builder,
    MlirLocation loc,
    MlirValue input,
    MlirType resultType,
    FunLangMatchCase* cases,
    intptr_t numCases
) {
  OpBuilder& cppBuilder = unwrap(builder);
  Location cppLoc = unwrap(loc);
  Value cppInput = unwrap(input);
  Type cppResultType = unwrap(resultType);

  // Create match operation
  auto matchOp = cppBuilder.create<MatchOp>(
      cppLoc, cppResultType, cppInput, numCases);

  // Build each case region
  for (intptr_t i = 0; i < numCases; ++i) {
    Region& region = matchOp.getCases()[i];
    Block* block = cppBuilder.createBlock(&region);

    // Invoke F# callback to populate the block
    MlirBlock wrappedBlock = wrap(block);
    cases[i].builder(builder, wrappedBlock, cases[i].userData);
  }

  return wrap(matchOp.getOperation());
}

핵심 아이디어:

  1. C API가 empty regions를 가진 MatchOp 생성
  2. 각 region에 대해 F# callback 호출
  3. F# callback이 region의 block을 채움 (operations + yield)

F# Bindings

Low-level binding:

// File: FunLang.Interop/FunLangOps.fs

type MatchCaseBuilder =
    MlirOpBuilder -> MlirBlock -> nativeint -> unit

[<Struct>]
type MatchCase =
    val Builder: MatchCaseBuilder
    val UserData: nativeint

    new(builder, userData) =
        { Builder = builder; UserData = userData }

[<DllImport("FunLang-C", CallingConvention = CallingConvention.Cdecl)>]
extern MlirOperation funlangMatchOpCreate(
    MlirOpBuilder builder,
    MlirLocation loc,
    MlirValue input,
    MlirType resultType,
    MatchCase[] cases,
    nativeint numCases
)

High-level wrapper:

// File: FunLang.Compiler/OpBuilder.fs

type OpBuilder with
    member this.CreateMatchOp(
        input: MlirValue,
        resultType: MlirType,
        buildCases: (OpBuilder -> Block -> unit) list
    ) : MlirOperation =

        // Convert F# functions to C callbacks
        let cases =
            buildCases
            |> List.map (fun buildCase ->
                let callback builder block userData =
                    let opBuilder = new OpBuilder(builder)
                    let mlirBlock = new Block(block)
                    buildCase opBuilder mlirBlock

                MatchCase(callback, 0n)
            )
            |> List.toArray

        let numCases = nativeint cases.Length
        let loc = this.UnknownLoc()

        funlangMatchOpCreate(
            this.Handle,
            loc,
            input,
            resultType,
            cases,
            numCases
        )

사용 예제 (F# compiler code):

// File: FunLang.Compiler/Codegen.fs

let compileMatch (builder: OpBuilder) (scrutinee: MlirValue) (cases: MatchCase list) =
    let resultType = // infer from cases

    let buildCases =
        cases |> List.map (fun case ->
            fun (builder: OpBuilder) (block: Block) ->
                match case with
                | NilCase expr ->
                    // Build nil case body
                    let value = compileExpr builder env expr
                    builder.CreateYieldOp(value) |> ignore

                | ConsCase (headVar, tailVar, expr) ->
                    // Add block arguments for head and tail
                    let headType = builder.GetIntegerType(32)
                    let tailType = builder.GetFunLangListType(headType)
                    block.AddArgument(headType) |> ignore
                    block.AddArgument(tailType) |> ignore

                    // Build cons case body with extended environment
                    let env' =
                        env
                        |> Map.add headVar (block.GetArgument(0))
                        |> Map.add tailVar (block.GetArgument(1))

                    let value = compileExpr builder env' expr
                    builder.CreateYieldOp(value) |> ignore
        )

    builder.CreateMatchOp(scrutinee, resultType, buildCases)

Generated MLIR:

%result = funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32
  ^cons(%head: i32, %tail: !funlang.list<i32>):
    %tail_sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
    %sum = arith.addi %head, %tail_sum : i32
    funlang.yield %sum : i32
}

Builder callback pattern의 장점:

  1. Flexibility: F# code가 region 내용을 완전히 제어
  2. Type safety: F# compiler가 callback signature 검증
  3. Composability: Nested match expressions 지원 (callback 안에서 또 match 생성)

Block Arguments in Builder Callback

위 코드에서 중요한 부분:

| ConsCase (headVar, tailVar, expr) ->
    // Add block arguments for pattern variables
    block.AddArgument(headType) |> ignore
    block.AddArgument(tailType) |> ignore

    // Use block arguments in environment
    let env' =
        env
        |> Map.add headVar (block.GetArgument(0))
        |> Map.add tailVar (block.GetArgument(1))

F# callback이 하는 일:

  1. Pattern structure 분석: ConsCase는 2개 변수 (head, tail)
  2. Block arguments 추가: Cons case block에 2개 arguments
  3. Environment extension: Pattern variables를 block arguments로 바인딩
  4. Body compilation: Extended environment로 case expression 컴파일

Lowering pass의 책임:

Lowering pass는 이 block arguments를 실제 데이터로 채운다:

// MatchOpLowering (Part 2에서 자세히)
// 1. Extract head and tail from cons cell
Value head = extractHead(builder, consCellPtr);
Value tail = extractTail(builder, consCellPtr);

// 2. Clone cons region
IRMapping mapper;
mapper.map(consBlock->getArgument(0), head);  // Map %head
mapper.map(consBlock->getArgument(1), tail);  // Map %tail

// 3. Clone operations with mapped values
for (Operation& op : consBlock->getOperations()) {
    builder.clone(op, mapper);
}

결과: Block arguments가 실제 values로 대체됨


중간 정리: Part 1 완료

Part 1에서 다룬 내용:

Region-based operation structure

  • Regions vs basic blocks
  • Encapsulation과 verification 장점

Match operation semantics

  • Runtime execution model
  • Tag extraction → case selection → variable binding → yield

TableGen definition

  • Traits: RecursiveSideEffect, SingleBlockImplicitTerminator
  • VariadicRegion<SizedRegion<1>> for variable cases
  • Custom assembly format과 verifier

YieldOp terminator

  • Terminator trait
  • HasParent<“MatchOp”> constraint
  • Comparison with scf.yield

C API and F# integration

  • Builder callback pattern
  • High-level wrapper for match construction
  • Block arguments for pattern variables

다음 Part 2에서:

  • SCF dialect 상세 설명
  • MatchOpLowering pattern 완전 구현
  • Region cloning과 IRMapping
  • 전체 pipeline 예제 (sum_list)
  • Common errors와 debugging

Part 2: SCF Lowering and Pipeline

SCF Dialect: Structured Control Flow

SCF = Structured Control Flow

Chapter 8에서 우리는 scf.if를 사용했다:

%result = scf.if %cond : i1 -> i32 {
  %then_val = arith.constant 42 : i32
  scf.yield %then_val : i32
} else {
  %else_val = arith.constant 0 : i32
  scf.yield %else_val : i32
}

SCF dialect의 핵심 operations:

OperationPurposeRegions
scf.ifTwo-way branch2 (then, else)
scf.index_switchMulti-way branchN (cases) + default
scf.forCounted loop1 (body)
scf.whileConditional loop2 (before, after)

Chapter 19에서는 scf.index_switch를 사용한다.

scf.index_switch: Multi-Way Branching

Syntax:

%result = scf.index_switch %selector : index -> ResultType
case 0 {
  // Case 0 operations
  scf.yield %value0 : ResultType
}
case 1 {
  // Case 1 operations
  scf.yield %value1 : ResultType
}
default {
  // Default case (optional)
  scf.yield %default_val : ResultType
}

Semantics:

  1. Selector evaluation: %selector 값을 runtime에 evaluate (index type)
  2. Case selection: Selector 값에 해당하는 case region 선택
  3. Fallback: 해당하는 case가 없으면 default region (있다면)
  4. Result yielding: 선택된 region의 scf.yield가 결과 전달

Example: Tag dispatch for list

// %lst: !funlang.list<i32>
// Tag extraction
%struct = // convert %lst to !llvm.struct<(i32, ptr)>
%tag = llvm.extractvalue %struct[0] : !llvm.struct<(i32, ptr)>
%tag_index = arith.index_cast %tag : i32 to index

// Dispatch on tag
%result = scf.index_switch %tag_index : index -> i32
case 0 {  // Nil case (tag = 0)
  %zero = arith.constant 0 : i32
  scf.yield %zero : i32
}
case 1 {  // Cons case (tag = 1)
  %ptr = llvm.extractvalue %struct[1] : !llvm.struct<(i32, ptr)>
  %head = llvm.load %ptr : !llvm.ptr -> i32
  // ... compute with head ...
  scf.yield %sum : i32
}
default {
  // Unreachable for {Nil, Cons} (complete constructor set)
  %minus = arith.constant -1 : i32
  scf.yield %minus : i32
}

Why SCF Before LLVM?

Option 1: Direct lowering funlang.match → LLVM (what we DON’T do)

// Directly to LLVM dialect
%tag = llvm.extractvalue ...
llvm.switch %tag [
  0: ^nil_block,
  1: ^cons_block
]

^nil_block:
  // ... operations ...
  llvm.br ^merge_block(%zero)

^cons_block:
  // ... operations ...
  llvm.br ^merge_block(%sum)

^merge_block(%result: i32):
  llvm.return %result

문제점:

  1. Lost structure: CFG는 원래 match의 case structure를 상실
  2. Harder optimization: Which blocks belong to which case? 불명확
  3. Debugging: LLVM IR에서 source pattern matching 추적 어려움
  4. Lowering complexity: funlang.match → LLVM을 한 번에 구현해야 함

Option 2: Progressive lowering funlang.match → SCF → CF → LLVM (what we do)

// Stage 1: FunLang
%result = funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil: funlang.yield %zero : i32
  ^cons(%h, %t): funlang.yield %sum : i32
}

// Stage 2: SCF (structured, high-level)
%tag_index = // extract tag and cast to index
%result = scf.index_switch %tag_index : index -> i32
case 0 { scf.yield %zero : i32 }
case 1 { scf.yield %sum : i32 }

// Stage 3: CF (goto-style, low-level)
cf.switch %tag_index [
  0: ^block_0,
  1: ^block_1
]
^block_0: cf.br ^merge(%zero)
^block_1: cf.br ^merge(%sum)
^merge(%result: i32): ...

// Stage 4: LLVM (machine-level)
llvm.switch %tag_i8 [
  0: ^llvm_0,
  1: ^llvm_1
]
// ... LLVM blocks ...

장점:

  1. Separation of concerns: 각 lowering pass는 하나의 변환만 책임
  2. Optimization hooks: SCF level에서 pattern-specific optimizations
  3. Incremental verification: 각 stage마다 IR 검증 가능
  4. Easier debugging: 문제 발생 시 어느 stage에서 일어났는지 명확

Comparison: SCF vs CF

AspectSCFCF
StructureNested regionsFlat blocks
Control flowImplicit (yield returns)Explicit (br/switch)
Source mappingPreserves match structureLost
OptimizationHigh-level (dead case elimination)Low-level (block merging)
ReadabilityHigh (similar to source)Low (machine-like)

Example: Dead case elimination at SCF level

// Input: match on statically-known value
%nil = funlang.nil : !funlang.list<i32>
%result = funlang.match %nil : !funlang.list<i32> -> i32 {
  ^nil: funlang.yield %zero : i32
  ^cons(%h, %t): funlang.yield %sum : i32  // Dead!
}

// After lowering to SCF
%tag_index = arith.constant 0 : index  // Statically known!
%result = scf.index_switch %tag_index : index -> i32
case 0 { scf.yield %zero : i32 }
case 1 { scf.yield %sum : i32 }  // Dead case!

// SCF optimizer can eliminate case 1
%result = scf.index_switch %tag_index : index -> i32
case 0 { scf.yield %zero : i32 }
// case 1 removed

// Further optimization: constant folding
%result = %zero  // Direct replacement!

이런 최적화는 CF level에서는 훨씬 어렵다.

MatchOp Lowering Strategy

Goal: funlang.matchscf.index_switch 변환

Input (FunLang):

%result = funlang.match %lst : !funlang.list<i32> -> i32 {
  ^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32
  ^cons(%head: i32, %tail: !funlang.list<i32>):
    %tail_sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
    %sum = arith.addi %head, %tail_sum : i32
    funlang.yield %sum : i32
}

Output (SCF + LLVM):

// 1. Convert list type to struct
%struct = builtin.unrealized_conversion_cast %lst
    : !funlang.list<i32> to !llvm.struct<(i32, ptr)>

// 2. Extract tag
%tag_i32 = llvm.extractvalue %struct[0] : !llvm.struct<(i32, ptr)>
%tag_index = arith.index_cast %tag_i32 : i32 to index

// 3. Extract data pointer (for cons case)
%data_ptr = llvm.extractvalue %struct[1] : !llvm.struct<(i32, ptr)>

// 4. Multi-way switch
%result = scf.index_switch %tag_index : index -> i32
case 0 {
  // Nil case: no data to extract
  %zero = arith.constant 0 : i32
  scf.yield %zero : i32
}
case 1 {
  // Cons case: extract head and tail
  %head_ptr = %data_ptr  // Points to [head, tail] array
  %head = llvm.load %head_ptr : !llvm.ptr -> i32

  %tail_ptr = llvm.getelementptr %data_ptr[1] : (!llvm.ptr) -> !llvm.ptr
  %tail_struct_ptr = llvm.load %tail_ptr : !llvm.ptr -> !llvm.ptr
  %tail_struct = llvm.load %tail_struct_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
  %tail = builtin.unrealized_conversion_cast %tail_struct
      : !llvm.struct<(i32, ptr)> to !funlang.list<i32>

  // Cons case body (converted)
  %tail_sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
  %sum = arith.addi %head, %tail_sum : i32
  scf.yield %sum : i32
}
default {
  // Unreachable for {Nil, Cons}
  %minus = arith.constant -1 : i32
  scf.yield %minus : i32
}

Lowering steps:

  1. Type conversion: !funlang.list<T>!llvm.struct<(i32, ptr)>
  2. Tag extraction: llvm.extractvalue to get tag field
  3. Index casting: arith.index_cast for scf.index_switch selector
  4. Case region cloning: 각 funlang.match case를 scf.index_switch case로 복사
  5. Block argument mapping: Pattern variables를 extracted values로 대체
  6. Terminator conversion: funlang.yieldscf.yield

Tag Value Mapping

Chapter 18 recap: List representation

// NilOpLowering
Value tag = builder.create<arith::ConstantIntOp>(loc, 0, builder.getI32Type());

// ConsOpLowering
Value tag = builder.create<arith::ConstantIntOp>(loc, 1, builder.getI32Type());

Tag mapping:

ConstructorTag Value
Nil0
Cons1

MatchOpLowering은 이 mapping을 알아야 한다:

// In MatchOpLowering::matchAndRewrite
// Case 0 → Nil pattern
// Case 1 → Cons pattern
for (auto [index, region] : llvm::enumerate(matchOp.getCases())) {
  // index = 0 → Nil
  // index = 1 → Cons
  builder.create<scf::IndexSwitchCaseOp>(loc, index);
  // ... clone region ...
}

Future extension: 임의의 ADT

지금은 hardcoded mapping (Nil=0, Cons=1)이지만, 나중에는:

// Extensible ADT definition
def Shape : FunLang_ADT<"shape"> {
  let constructors = [
    Constructor<"circle", [F32]>,           // tag = 0
    Constructor<"rectangle", [F32, F32]>,   // tag = 1
    Constructor<"triangle", [F32, F32, F32]>  // tag = 2
  ];
}

Compiler가 자동으로 tag 할당.

Pattern Variable Binding

Cons case의 challenge: block arguments를 어떻게 채우는가?

Source (FunLang):

^cons(%head: i32, %tail: !funlang.list<i32>):
  // %head와 %tail이 어디서 오는가?
  funlang.yield %sum : i32

Lowering 후 (SCF):

case 1 {
  // 여기서 %head와 %tail을 extract해야 함
  %head = llvm.load %data_ptr : !llvm.ptr -> i32
  %tail = // ... complex extraction ...

  // 이제 body를 clone하면서 block arguments를 이 values로 map
  // (IRMapping 사용)
}

IRMapping: SSA Value Remapping

MLIR의 IRMapping class는 “old value → new value” mapping을 저장한다.

IRMapping mapper;
mapper.map(oldValue1, newValue1);
mapper.map(oldValue2, newValue2);

// Clone operation with mapped values
Operation* newOp = builder.clone(*oldOp, mapper);
// oldOp의 operands가 oldValue1, oldValue2였다면
// newOp의 operands는 newValue1, newValue2로 대체됨

MatchOpLowering에서 IRMapping 사용:

// Cons case region
Region& consRegion = matchOp.getCases()[1];
Block* consBlock = &consRegion.front();

// consBlock의 block arguments:
// consBlock->getArgument(0) = %head (i32)
// consBlock->getArgument(1) = %tail (!funlang.list<i32>)

// Extract actual values
Value actualHead = extractHead(builder, dataPtrConverted);
Value actualTail = extractTail(builder, dataPtrConverted, typeConverter);

// Map block arguments to extracted values
IRMapping mapper;
mapper.map(consBlock->getArgument(0), actualHead);
mapper.map(consBlock->getArgument(1), actualTail);

// Clone operations in consBlock with mapping
for (Operation& op : consBlock->getOperations()) {
  if (isa<YieldOp>(op)) {
    // Convert funlang.yield → scf.yield
    builder.create<scf::YieldOp>(op.getLoc(),
                                  mapper.lookupOrDefault(op.getOperand(0)));
  } else {
    // Clone other operations with mapped operands
    builder.clone(op, mapper);
  }
}

Result: Block arguments가 사라지고 extracted values로 대체됨

// Before (funlang.match case)
^cons(%head: i32, %tail: !funlang.list<i32>):
  %sum = arith.addi %head, %tail_sum : i32
  funlang.yield %sum : i32

// After (scf.index_switch case)
case 1 {
  %head = llvm.load ...  // Extracted value
  %tail = ...            // Extracted value
  %sum = arith.addi %head, %tail_sum : i32  // %head mapped
  scf.yield %sum : i32
}

MatchOpLowering Pattern: Complete Implementation

이제 전체 lowering pattern을 구현한다.

File: FunLang/Transforms/FunLangToSCF.cpp (conceptual C++ code)

#include "mlir/Conversion/SCFToControlFlow/SCFToControlFlow.h"
#include "mlir/Dialect/Arith/IR/Arith.h"
#include "mlir/Dialect/Func/IR/FuncOps.h"
#include "mlir/Dialect/LLVM/LLVMDialect.h"
#include "mlir/Dialect/SCF/IR/SCF.h"
#include "mlir/IR/IRMapping.h"
#include "mlir/Transforms/DialectConversion.h"
#include "FunLang/IR/FunLangOps.h"
#include "FunLang/Transforms/TypeConverter.h"

using namespace mlir;
using namespace mlir::funlang;

namespace {

// Helper: Extract head from cons cell
// Input: %data_ptr points to [head, tail] array
// Output: %head value
Value extractHead(OpBuilder& builder, Location loc,
                  Value dataPtrConverted, Type headType) {
  // %data_ptr already points to cons cell array
  // Load first element (head)
  Value head = builder.create<LLVM::LoadOp>(loc, headType, dataPtrConverted);
  return head;
}

// Helper: Extract tail from cons cell
// Input: %data_ptr points to [head, tail] array
// Output: %tail value (converted back to !funlang.list<T>)
Value extractTail(OpBuilder& builder, Location loc,
                  Value dataPtrConverted,
                  FunLangTypeConverter* typeConverter,
                  Type tailFunLangType) {
  // GEP to second element (tail)
  Value one = builder.create<arith::ConstantIntOp>(loc, 1, builder.getI32Type());
  Value tailPtr = builder.create<LLVM::GEPOp>(
      loc, LLVM::LLVMPointerType::get(builder.getContext()),
      dataPtrConverted, ValueRange{one});

  // Load tail pointer
  Value tailStructPtr = builder.create<LLVM::LoadOp>(
      loc, LLVM::LLVMPointerType::get(builder.getContext()), tailPtr);

  // Load tail struct
  Type tailStructType = typeConverter->convertType(tailFunLangType);
  Value tailStruct = builder.create<LLVM::LoadOp>(
      loc, tailStructType, tailStructPtr);

  // Convert back to FunLang type (for remaining funlang operations in body)
  Value tail = builder.create<UnrealizedConversionCastOp>(
      loc, tailFunLangType, tailStruct).getResult(0);

  return tail;
}

// Helper: Convert funlang.yield to scf.yield in region
void convertYieldOps(Region& region, OpBuilder& builder, IRMapping& mapper) {
  for (Block& block : region) {
    for (Operation& op : llvm::make_early_inc_range(block)) {
      if (auto yieldOp = dyn_cast<YieldOp>(&op)) {
        builder.setInsertionPoint(yieldOp);
        Value yieldValue = mapper.lookupOrDefault(yieldOp.getValue());
        builder.create<scf::YieldOp>(yieldOp.getLoc(), yieldValue);
        yieldOp.erase();
      }
    }
  }
}

// Main lowering pattern
class MatchOpLowering : public OpConversionPattern<MatchOp> {
public:
  using OpConversionPattern<MatchOp>::OpConversionPattern;

  LogicalResult matchAndRewrite(
      MatchOp matchOp,
      OpAdaptor adaptor,
      ConversionPatternRewriter& rewriter) const override {

    Location loc = matchOp.getLoc();
    Value input = adaptor.getInput();
    Type resultType = matchOp.getResult().getType();

    auto* typeConverter = getTypeConverter<FunLangTypeConverter>();

    // 1. Convert input to LLVM struct
    // input: !funlang.list<T> → !llvm.struct<(i32, ptr)>
    Type structType = typeConverter->convertType(input.getType());
    Value structVal = rewriter.create<UnrealizedConversionCastOp>(
        loc, structType, input).getResult(0);

    // 2. Extract tag field
    Value tag = rewriter.create<LLVM::ExtractValueOp>(loc, structVal, 0);

    // 3. Cast tag to index (for scf.index_switch)
    Value tagIndex = rewriter.create<arith::IndexCastOp>(
        loc, rewriter.getIndexType(), tag);

    // 4. Extract data pointer (needed for cons case)
    Value dataPtr = rewriter.create<LLVM::ExtractValueOp>(loc, structVal, 1);

    // 5. Create scf.index_switch
    auto indexSwitchOp = rewriter.create<scf::IndexSwitchOp>(
        loc, resultType, tagIndex, matchOp.getCases().size());

    // 6. Process each case region
    for (auto [caseIndex, caseRegion] :
         llvm::enumerate(matchOp.getCases())) {

      Block* originalBlock = &caseRegion.front();
      Region& switchCaseRegion = indexSwitchOp.getCaseRegions()[caseIndex];
      Block* caseBlock = rewriter.createBlock(&switchCaseRegion);

      rewriter.setInsertionPointToStart(caseBlock);

      IRMapping mapper;

      // Handle block arguments (pattern variables)
      if (caseIndex == 1) {  // Cons case
        // originalBlock has 2 arguments: %head, %tail

        // Extract head
        Type headFunLangType = originalBlock->getArgument(0).getType();
        Type headLLVMType = typeConverter->convertType(headFunLangType);
        Value head = extractHead(rewriter, loc, dataPtr, headLLVMType);

        // Convert head to FunLang type if needed
        Value headFunLang = head;
        if (headFunLangType != headLLVMType) {
          headFunLang = rewriter.create<UnrealizedConversionCastOp>(
              loc, headFunLangType, head).getResult(0);
        }

        // Extract tail
        Type tailFunLangType = originalBlock->getArgument(1).getType();
        Value tail = extractTail(rewriter, loc, dataPtr,
                                  typeConverter, tailFunLangType);

        // Map block arguments to extracted values
        mapper.map(originalBlock->getArgument(0), headFunLang);
        mapper.map(originalBlock->getArgument(1), tail);
      }
      // Nil case (caseIndex == 0): no block arguments, no extraction

      // Clone operations from original region
      for (Operation& op : originalBlock->getOperations()) {
        if (auto yieldOp = dyn_cast<YieldOp>(&op)) {
          // Convert funlang.yield → scf.yield
          Value yieldValue = mapper.lookupOrDefault(yieldOp.getValue());
          rewriter.create<scf::YieldOp>(loc, yieldValue);
        } else {
          // Clone operation with mapped operands
          rewriter.clone(op, mapper);
        }
      }
    }

    // 7. Add default region (unreachable for complete constructor sets)
    {
      Region& defaultRegion = indexSwitchOp.getDefaultRegion();
      Block* defaultBlock = rewriter.createBlock(&defaultRegion);
      rewriter.setInsertionPointToStart(defaultBlock);

      // Emit error value (this should never execute)
      Value errorVal;
      if (resultType.isIntOrIndex()) {
        errorVal = rewriter.create<arith::ConstantIntOp>(loc, -1, resultType);
      } else {
        // For other types, emit unreachable or null
        errorVal = rewriter.create<LLVM::ZeroOp>(loc, resultType);
      }

      rewriter.create<scf::YieldOp>(loc, errorVal);
    }

    // 8. Replace match operation with index_switch result
    rewriter.replaceOp(matchOp, indexSwitchOp.getResult(0));

    return success();
  }
};

} // namespace

// Pass definition
struct FunLangToSCFPass
    : public PassWrapper<FunLangToSCFPass, OperationPass<ModuleOp>> {

  void getDependentDialects(DialectRegistry& registry) const override {
    registry.insert<arith::ArithDialect,
                    scf::SCFDialect,
                    LLVM::LLVMDialect>();
  }

  void runOnOperation() override {
    auto module = getOperation();
    auto* context = &getContext();

    FunLangTypeConverter typeConverter;
    ConversionTarget target(*context);

    // Mark funlang.match as illegal (must be lowered)
    target.addIllegalOp<MatchOp>();

    // Mark SCF operations as legal
    target.addLegalDialect<scf::SCFDialect>();
    target.addLegalDialect<arith::ArithDialect>();
    target.addLegalDialect<LLVM::LLVMDialect>();
    target.addLegalDialect<func::FuncDialect>();

    // Keep other FunLang ops legal (lowered in FunLangToLLVM pass)
    target.addLegalOp<NilOp, ConsOp, ClosureOp, ApplyOp>();

    RewritePatternSet patterns(context);
    patterns.add<MatchOpLowering>(typeConverter, context);

    if (failed(applyPartialConversion(module, target, std::move(patterns)))) {
      signalPassFailure();
    }
  }
};

std::unique_ptr<Pass> createFunLangToSCFPass() {
  return std::make_unique<FunLangToSCFPass>();
}

핵심 로직 분석:

1. Type conversion (lines ~95-100)

Type structType = typeConverter->convertType(input.getType());
Value structVal = rewriter.create<UnrealizedConversionCastOp>(
    loc, structType, input).getResult(0);

!funlang.list<i32>!llvm.struct<(i32, ptr)> 변환.

UnrealizedConversionCastOp는 type conversion의 placeholder다. 나중에 다른 pass가 이를 실제 operations로 대체하거나 제거한다.

2. Tag extraction (lines ~103-108)

Value tag = rewriter.create<LLVM::ExtractValueOp>(loc, structVal, 0);
Value tagIndex = rewriter.create<arith::IndexCastOp>(
    loc, rewriter.getIndexType(), tag);

Struct의 첫 번째 field (tag)를 추출하고 index type으로 cast.

3. scf.index_switch creation (lines ~113-115)

auto indexSwitchOp = rewriter.create<scf::IndexSwitchOp>(
    loc, resultType, tagIndex, matchOp.getCases().size());

N개의 cases를 가진 index_switch 생성.

4. Region cloning (lines ~118-160)

각 case region을 iterate하며:

  • Nil case (caseIndex == 0): Block arguments 없음, 그냥 clone
  • Cons case (caseIndex == 1): Block arguments 있음, extract + map

5. IRMapping for block arguments (lines ~130-148)

mapper.map(originalBlock->getArgument(0), headFunLang);
mapper.map(originalBlock->getArgument(1), tail);

Original block의 arguments를 extracted values로 mapping.

6. Operation cloning (lines ~152-159)

for (Operation& op : originalBlock->getOperations()) {
  if (auto yieldOp = dyn_cast<YieldOp>(&op)) {
    Value yieldValue = mapper.lookupOrDefault(yieldOp.getValue());
    rewriter.create<scf::YieldOp>(loc, yieldValue);
  } else {
    rewriter.clone(op, mapper);
  }
}
  • funlang.yieldscf.yield 변환
  • 다른 operations는 mapper와 함께 clone

7. Default region (lines ~163-176)

Unreachable case를 위한 default region 생성.

Complete Pipeline: Pass Registration

전체 lowering pipeline:

FunLang Dialect (with match, nil, cons, closure, apply)
    ↓
[FunLangToSCFPass]
    ↓
FunLang (without match) + SCF
    ↓
[FunLangToLLVMPass] (lowers nil, cons, closure, apply)
    ↓
LLVM + SCF
    ↓
[SCFToControlFlowPass]
    ↓
LLVM + CF
    ↓
[ConvertControlFlowToLLVMPass]
    ↓
LLVM Dialect only
    ↓
[LLVMToObjectPass]
    ↓
Object file

Pass manager setup (F# code):

// File: FunLang.Compiler/Pipeline.fs

let lowerToLLVM (module_: Module) =
    let pm = PassManager.Create(module_.Context)

    // 1. FunLang → SCF (lower match operation)
    pm.AddPass(FunLangPasses.CreateFunLangToSCFPass())

    // 2. FunLang → LLVM (lower nil, cons, closure, apply)
    pm.AddPass(FunLangPasses.CreateFunLangToLLVMPass())

    // 3. SCF → CF
    pm.AddPass(SCFPasses.CreateSCFToControlFlowPass())

    // 4. CF → LLVM
    pm.AddPass(ConversionPasses.CreateConvertControlFlowToLLVMPass())

    // 5. Func → LLVM
    pm.AddPass(ConversionPasses.CreateConvertFuncToLLVMPass())

    // 6. Arith → LLVM
    pm.AddPass(ConversionPasses.CreateConvertArithToLLVMPass())

    pm.Run(module_) |> ignore

Pass dependencies:

  • FunLangToSCFPass must run before FunLangToLLVMPass

    • Reason: MatchOp의 regions에 다른 FunLang ops (nil, cons, etc.) 포함
    • SCF로 변환 후 남은 FunLang ops를 LLVM으로 변환
  • SCFToControlFlowPass must run after all FunLang lowering

    • Reason: SCF ops는 다른 dialects가 모두 LLVM으로 변환된 후 lower
  • ConvertFuncToLLVMPass must run after SCF/CF conversion

    • Reason: Function signatures에 FunLang types가 남아있으면 안 됨

End-to-End Example: sum_list Function

F# source code:

// FunLang source
let rec sum_list lst =
    match lst with
    | [] -> 0
    | head :: tail -> head + sum_list tail

let main () =
    let my_list = [1; 2; 3]
    sum_list my_list

Stage 1: FunLang Dialect (after F# compiler)

module {
  func.func @sum_list(%lst: !funlang.list<i32>) -> i32 {
    %result = funlang.match %lst : !funlang.list<i32> -> i32 {
      ^nil:
        %zero = arith.constant 0 : i32
        funlang.yield %zero : i32
      ^cons(%head: i32, %tail: !funlang.list<i32>):
        %tail_sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32
        %sum = arith.addi %head, %tail_sum : i32
        funlang.yield %sum : i32
    }
    return %result : i32
  }

  func.func @main() -> i32 {
    // Build list [1, 2, 3]
    %nil = funlang.nil : !funlang.list<i32>

    %c3 = arith.constant 3 : i32
    %l3 = funlang.cons %c3, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>

    %c2 = arith.constant 2 : i32
    %l2 = funlang.cons %c2, %l3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

    %c1 = arith.constant 1 : i32
    %l1 = funlang.cons %c1, %l2 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

    // Call sum_list
    %sum = func.call @sum_list(%l1) : (!funlang.list<i32>) -> i32
    return %sum : i32
  }
}

Stage 2: After FunLangToSCFPass

module {
  func.func @sum_list(%lst: !funlang.list<i32>) -> i32 {
    // Type conversion
    %struct = builtin.unrealized_conversion_cast %lst
        : !funlang.list<i32> to !llvm.struct<(i32, ptr)>

    // Tag extraction
    %tag = llvm.extractvalue %struct[0] : !llvm.struct<(i32, ptr)>
    %tag_index = arith.index_cast %tag : i32 to index

    // Data pointer
    %data_ptr = llvm.extractvalue %struct[1] : !llvm.struct<(i32, ptr)>

    // Index switch
    %result = scf.index_switch %tag_index : index -> i32
    case 0 {
      %zero = arith.constant 0 : i32
      scf.yield %zero : i32
    }
    case 1 {
      // Extract head
      %head = llvm.load %data_ptr : !llvm.ptr -> i32

      // Extract tail
      %one = arith.constant 1 : i32
      %tail_ptr = llvm.getelementptr %data_ptr[%one] : (!llvm.ptr, i32) -> !llvm.ptr
      %tail_struct_ptr = llvm.load %tail_ptr : !llvm.ptr -> !llvm.ptr
      %tail_struct = llvm.load %tail_struct_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
      %tail = builtin.unrealized_conversion_cast %tail_struct
          : !llvm.struct<(i32, ptr)> to !funlang.list<i32>

      // Recursive call
      %tail_sum = func.call @sum_list(%tail) : (!funlang.list<i32>) -> i32

      // Sum
      %sum = arith.addi %head, %tail_sum : i32
      scf.yield %sum : i32
    }
    default {
      %error = arith.constant -1 : i32
      scf.yield %error : i32
    }

    return %result : i32
  }

  func.func @main() -> i32 {
    // Still has funlang.nil and funlang.cons (not lowered yet)
    %nil = funlang.nil : !funlang.list<i32>
    %c3 = arith.constant 3 : i32
    %l3 = funlang.cons %c3, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>
    // ...
    %sum = func.call @sum_list(%l1) : (!funlang.list<i32>) -> i32
    return %sum : i32
  }
}

Stage 3: After FunLangToLLVMPass

module {
  func.func @sum_list(%lst: !llvm.struct<(i32, ptr)>) -> i32 {
    // ... same as Stage 2 but types converted ...
    %tag = llvm.extractvalue %lst[0] : !llvm.struct<(i32, ptr)>
    %tag_index = arith.index_cast %tag : i32 to index
    %data_ptr = llvm.extractvalue %lst[1] : !llvm.struct<(i32, ptr)>

    %result = scf.index_switch %tag_index : index -> i32
    case 0 { /* ... */ }
    case 1 { /* ... */ }
    default { /* ... */ }

    return %result : i32
  }

  func.func @main() -> i32 {
    // funlang.nil and funlang.cons lowered to LLVM
    %c0 = arith.constant 0 : i32
    %null = llvm.mlir.zero : !llvm.ptr
    %undef_nil = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %s1_nil = llvm.insertvalue %c0, %undef_nil[0] : !llvm.struct<(i32, ptr)>
    %nil = llvm.insertvalue %null, %s1_nil[1] : !llvm.struct<(i32, ptr)>

    %c3 = arith.constant 3 : i32
    %c1_tag = arith.constant 1 : i32
    %size = arith.constant 16 : i64  // sizeof(cons cell)
    %ptr = llvm.call @GC_malloc(%size) : (i64) -> !llvm.ptr
    llvm.store %c3, %ptr : i32, !llvm.ptr
    %tail_ptr = llvm.getelementptr %ptr[1] : (!llvm.ptr) -> !llvm.ptr
    // ... store tail ...
    %undef_cons = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %s1_cons = llvm.insertvalue %c1_tag, %undef_cons[0] : !llvm.struct<(i32, ptr)>
    %l3 = llvm.insertvalue %ptr, %s1_cons[1] : !llvm.struct<(i32, ptr)>

    // ...
    %sum = func.call @sum_list(%l1) : (!llvm.struct<(i32, ptr)>) -> i32
    return %sum : i32
  }
}

Stage 4: After SCFToControlFlowPass

module {
  func.func @sum_list(%lst: !llvm.struct<(i32, ptr)>) -> i32 {
    %tag = llvm.extractvalue %lst[0] : !llvm.struct<(i32, ptr)>
    %tag_index = arith.index_cast %tag : i32 to index
    %data_ptr = llvm.extractvalue %lst[1] : !llvm.struct<(i32, ptr)>

    // scf.index_switch → cf.switch
    cf.switch %tag_index : index, [
      default: ^default,
      0: ^case_0,
      1: ^case_1
    ]

  ^case_0:
    %zero = arith.constant 0 : i32
    cf.br ^merge(%zero : i32)

  ^case_1:
    %head = llvm.load %data_ptr : !llvm.ptr -> i32
    // ... extract tail ...
    %tail_sum = func.call @sum_list(%tail) : (!llvm.struct<(i32, ptr)>) -> i32
    %sum = arith.addi %head, %tail_sum : i32
    cf.br ^merge(%sum : i32)

  ^default:
    %error = arith.constant -1 : i32
    cf.br ^merge(%error : i32)

  ^merge(%result: i32):
    return %result : i32
  }

  func.func @main() -> i32 {
    // ... LLVM code for list construction ...
    %sum = func.call @sum_list(%l1) : (!llvm.struct<(i32, ptr)>) -> i32
    return %sum : i32
  }
}

Stage 5: After ConvertControlFlowToLLVMPass + ConvertFuncToLLVMPass

llvm.func @sum_list(%arg0: !llvm.struct<(i32, ptr)>) -> i32 {
  %0 = llvm.extractvalue %arg0[0] : !llvm.struct<(i32, ptr)>
  %1 = llvm.sext %0 : i32 to i64  // index cast
  %2 = llvm.extractvalue %arg0[1] : !llvm.struct<(i32, ptr)>

  llvm.switch %1 : i64, ^default [
    0: ^case_0,
    1: ^case_1
  ]

^case_0:
  %c0 = llvm.mlir.constant(0 : i32) : i32
  llvm.br ^merge(%c0 : i32)

^case_1:
  %head = llvm.load %2 : !llvm.ptr -> i32
  // ... tail extraction ...
  %tail_sum = llvm.call @sum_list(%tail) : (!llvm.struct<(i32, ptr)>) -> i32
  %sum = llvm.add %head, %tail_sum : i32
  llvm.br ^merge(%sum : i32)

^default:
  %error = llvm.mlir.constant(-1 : i32) : i32
  llvm.br ^merge(%error : i32)

^merge(%result: i32):
  llvm.return %result : i32
}

llvm.func @main() -> i32 {
  // ... LLVM code ...
  %sum = llvm.call @sum_list(%l1) : (!llvm.struct<(i32, ptr)>) -> i32
  llvm.return %sum : i32
}

Stage 6: Native code (after llc + linking)

$ ./funlang_program
6

Pipeline verification at each stage:

# After each pass, verify IR
$ mlir-opt --funlang-to-scf --verify-diagnostics input.mlir
$ mlir-opt --funlang-to-llvm --verify-diagnostics input.mlir
$ mlir-opt --convert-scf-to-cf --verify-diagnostics input.mlir

Common Errors and Debugging

Error 1: Block argument count mismatch

Symptom:

error: 'scf.yield' op result type mismatch

Cause:

Cons case region의 block arguments 개수가 틀림.

// Wrong: forgot to map tail argument
mapper.map(originalBlock->getArgument(0), headFunLang);
// Missing: mapper.map(originalBlock->getArgument(1), tail);

Fix:

모든 block arguments를 map해야 함.

mapper.map(originalBlock->getArgument(0), headFunLang);
mapper.map(originalBlock->getArgument(1), tail);  // ✅

Error 2: Type mismatch after region cloning

Symptom:

error: 'func.call' op operand type mismatch: expected '!llvm.struct<...>', got '!funlang.list<...>'

Cause:

Region 내부의 operations가 아직 type conversion 안 됨.

Why:

FunLangToSCFPass는 partial conversion이다. Match operation만 lower하고 나머지 FunLang ops는 그대로 둔다.

Fix:

Region cloning 후 남은 FunLang operations는 다음 pass (FunLangToLLVMPass)에서 처리됨.

Temporary workaround: UnrealizedConversionCastOp 사용.

Value tail = extractTail(...);  // Returns LLVM struct
// Cast back to FunLang type for func.call
Value tailFunLang = rewriter.create<UnrealizedConversionCastOp>(
    loc, tailFunLangType, tail).getResult(0);

Error 3: Missing scf.yield in converted regions

Symptom:

error: block must terminate with scf.yield

Cause:

funlang.yieldscf.yield로 변환하는 걸 까먹음.

// Wrong: just clone YieldOp as-is
for (Operation& op : originalBlock->getOperations()) {
  rewriter.clone(op, mapper);  // funlang.yield gets cloned!
}

Fix:

YieldOp를 특별히 처리해서 변환.

for (Operation& op : originalBlock->getOperations()) {
  if (auto yieldOp = dyn_cast<YieldOp>(&op)) {
    Value yieldValue = mapper.lookupOrDefault(yieldOp.getValue());
    rewriter.create<scf::YieldOp>(loc, yieldValue);  // ✅ Convert
  } else {
    rewriter.clone(op, mapper);
  }
}

Error 4: Wrong tag values (0 vs 1 confusion)

Symptom:

런타임에 엉뚱한 case가 실행됨. 예: Nil list인데 Cons case 실행.

Cause:

Tag mapping이 틀림.

// Wrong: reversed mapping
// case 0 → Cons (wrong!)
// case 1 → Nil (wrong!)

Fix:

Chapter 18의 tag values와 일치시켜야 함:

// Correct mapping
// case 0 → Nil  (tag = 0)
// case 1 → Cons (tag = 1)
for (auto [caseIndex, caseRegion] : llvm::enumerate(matchOp.getCases())) {
  // caseIndex = 0 → Nil region (first in match)
  // caseIndex = 1 → Cons region (second in match)
}

F# compiler는 pattern 순서를 보장해야 함:

// F# compiler must emit cases in this order:
// Case 0: Nil
// Case 1: Cons
match lst with
| [] -> ...        // Must be first case
| head :: tail -> ... // Must be second case

Error 5: Incorrect data extraction from cons cell

Symptom:

런타임 segfault or garbage values.

Cause:

GEP indices 틀림.

// Wrong: GEP from struct pointer
Value tailPtr = builder.create<LLVM::GEPOp>(
    loc, ptrType, structVal, ValueRange{one});  // ❌ structVal is value not pointer

Fix:

Data pointer는 이미 cons cell array를 가리킴.

// Correct: dataPtr already points to [head, tail] array
Value headPtr = dataPtr;  // Points to head
Value head = builder.create<LLVM::LoadOp>(loc, headType, headPtr);

Value one = builder.create<arith::ConstantIntOp>(loc, 1, i32Type);
Value tailPtr = builder.create<LLVM::GEPOp>(
    loc, ptrType, dataPtr, ValueRange{one});  // ✅ GEP from array pointer

Debugging strategies:

  1. Print IR after each pass:
$ mlir-opt --funlang-to-scf --print-ir-after-all input.mlir
  1. Use verifier:
$ mlir-opt --funlang-to-scf --verify-diagnostics input.mlir
  1. Dump operations in lowering code:
matchOp.dump();  // Before lowering
indexSwitchOp.dump();  // After lowering
  1. Check IRMapping:
for (auto [caseIndex, region] : enumerate(matchOp.getCases())) {
  Block* block = &region.front();
  llvm::errs() << "Case " << caseIndex << ":\n";
  for (BlockArgument arg : block->getArguments()) {
    llvm::errs() << "  Arg: " << arg << " → "
                 << mapper.lookupOrDefault(arg) << "\n";
  }
}

리터럴 패턴 로우어링

지금까지 constructor patterns (Nil, Cons)를 위한 lowering을 설명했다. 이제 리터럴 패턴을 위한 lowering 전략을 다룬다.

Constructor vs Literal Dispatch

Constructor 패턴과 리터럴 패턴의 핵심 차이:

특성ConstructorLiteral
값의 범위유한 (finite)무한 (infinite)
테스트Tag extractionValue comparison
MLIR dispatchscf.index_switcharith.cmpi + scf.if
ComplexityO(1)O(n) sequential

Constructor patterns use scf.index_switch:

Tag는 유한한 범위 (예: 0 = Nil, 1 = Cons)이므로 jump table이 가능하다.

// Constructor dispatch: O(1)
%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%tag_index = arith.index_cast %tag : i32 to index

%result = scf.index_switch %tag_index : index -> i32
case 0 { /* Nil case */ scf.yield ... }
case 1 { /* Cons case */ scf.yield ... }
default { scf.yield %unreachable : i32 }

Literal patterns use arith.cmpi + scf.if chain:

정수 리터럴은 무한하므로 순차적 비교가 필요하다.

// Literal dispatch: O(n)
%is_zero = arith.cmpi eq, %x, %c0 : i32
%result = scf.if %is_zero -> i32 {
    scf.yield %zero_result : i32
} else {
    %is_one = arith.cmpi eq, %x, %c1 : i32
    %inner = scf.if %is_one -> i32 {
        scf.yield %one_result : i32
    } else {
        scf.yield %default_result : i32
    }
    scf.yield %inner : i32
}

리터럴 패턴 Lowering 구현

리터럴 매칭을 위한 C++ lowering pattern:

// LiteralMatchLowering.cpp

class LiteralMatchOpLowering : public OpConversionPattern<LiteralMatchOp> {
public:
  using OpConversionPattern<LiteralMatchOp>::OpConversionPattern;

  LogicalResult matchAndRewrite(
      LiteralMatchOp matchOp,
      OpAdaptor adaptor,
      ConversionPatternRewriter& rewriter) const override {

    Location loc = matchOp.getLoc();
    Value scrutinee = adaptor.getScrutinee();
    Type resultType = matchOp.getResult().getType();

    // Collect all cases: (literal_value, region)
    auto cases = matchOp.getCases();
    Region* defaultRegion = matchOp.getDefaultRegion();

    // Build nested scf.if chain from bottom up
    Value result = buildIfChain(rewriter, loc, scrutinee,
                                 cases, defaultRegion, resultType);

    rewriter.replaceOp(matchOp, result);
    return success();
  }

private:
  Value buildIfChain(
      ConversionPatternRewriter& rewriter,
      Location loc,
      Value scrutinee,
      ArrayRef<std::pair<int64_t, Region*>> cases,
      Region* defaultRegion,
      Type resultType) const {

    // Base case: no more cases, use default
    if (cases.empty()) {
      return cloneRegionAndGetResult(rewriter, loc, defaultRegion, resultType);
    }

    // Current case
    auto [literalValue, caseRegion] = cases.front();
    auto remainingCases = cases.drop_front();

    // Create comparison: scrutinee == literal
    Value literalConst = rewriter.create<arith::ConstantIntOp>(
        loc, literalValue, scrutinee.getType());
    Value isMatch = rewriter.create<arith::CmpIOp>(
        loc, arith::CmpIPredicate::eq, scrutinee, literalConst);

    // Create scf.if
    auto ifOp = rewriter.create<scf::IfOp>(
        loc, resultType, isMatch,
        /*thenBuilder=*/[&](OpBuilder& b, Location loc) {
          Value result = cloneRegionAndGetResult(b, loc, caseRegion, resultType);
          b.create<scf::YieldOp>(loc, result);
        },
        /*elseBuilder=*/[&](OpBuilder& b, Location loc) {
          Value result = buildIfChain(
              rewriter, loc, scrutinee, remainingCases, defaultRegion, resultType);
          b.create<scf::YieldOp>(loc, result);
        });

    return ifOp.getResult(0);
  }

  Value cloneRegionAndGetResult(
      OpBuilder& builder,
      Location loc,
      Region* region,
      Type resultType) const {
    // Clone operations from region
    IRMapping mapper;
    for (Operation& op : region->front()) {
      if (auto yieldOp = dyn_cast<YieldOp>(&op)) {
        return mapper.lookupOrDefault(yieldOp.getValue());
      } else {
        builder.clone(op, mapper);
      }
    }
    llvm_unreachable("Region must end with yield");
  }
};

생성된 IR 예제:

// FunLang source
match x with
| 0 -> "zero"
| 1 -> "one"
| _ -> "other"
// After lowering: nested scf.if chain
%c0 = arith.constant 0 : i32
%c1 = arith.constant 1 : i32

%is_zero = arith.cmpi eq, %x, %c0 : i32
%result = scf.if %is_zero -> !llvm.ptr<i8> {
    scf.yield %zero_str : !llvm.ptr<i8>
} else {
    %is_one = arith.cmpi eq, %x, %c1 : i32
    %inner = scf.if %is_one -> !llvm.ptr<i8> {
        scf.yield %one_str : !llvm.ptr<i8>
    } else {
        // Default case: no comparison
        scf.yield %other_str : !llvm.ptr<i8>
    }
    scf.yield %inner : !llvm.ptr<i8>
}

최적화 기회 (Optimization Opportunities)

1. Dense Range Detection

리터럴이 0, 1, 2, … 연속일 때 scf.index_switch로 변환 가능:

// Before: sequential comparisons
%is_0 = arith.cmpi eq, %x, %c0
scf.if %is_0 { ... } else {
    %is_1 = arith.cmpi eq, %x, %c1
    scf.if %is_1 { ... } else {
        %is_2 = arith.cmpi eq, %x, %c2
        // ...
    }
}

// After: range check + index_switch
%in_range = arith.cmpi ult, %x, %c3 : i32
scf.if %in_range {
    %idx = arith.index_cast %x : i32 to index
    scf.index_switch %idx : index -> i32
    case 0 { /* case 0 */ }
    case 1 { /* case 1 */ }
    case 2 { /* case 2 */ }
} else {
    // default
}

Dense range detection algorithm:

bool isDenseRange(ArrayRef<int64_t> literals) {
  if (literals.empty()) return false;

  // Sort literals
  SmallVector<int64_t> sorted(literals.begin(), literals.end());
  llvm::sort(sorted);

  // Check if consecutive
  for (size_t i = 1; i < sorted.size(); ++i) {
    if (sorted[i] != sorted[i-1] + 1)
      return false;
  }

  // Starts from 0 or 1 (common case)
  return sorted[0] == 0 || sorted[0] == 1;
}

2. Sparse Set Optimization

리터럴이 sparse할 때 (예: 0, 10, 100) binary search 가능:

// O(log n) with binary search
%mid = arith.constant 10 : i32
%less_than_mid = arith.cmpi slt, %x, %mid : i32
scf.if %less_than_mid {
    // Check 0
    %is_0 = arith.cmpi eq, %x, %c0
    scf.if %is_0 { ... } else { /* default */ }
} else {
    // Check 10, 100
    %is_10 = arith.cmpi eq, %x, %c10
    scf.if %is_10 { ... } else {
        %is_100 = arith.cmpi eq, %x, %c100
        scf.if %is_100 { ... } else { /* default */ }
    }
}

이 최적화는 MLIR transformation pass로 구현 가능 (Phase 7).

3. LLVM Backend Optimization

SCF → CF → LLVM pipeline 후 LLVM backend가 추가 최적화:

; LLVM will recognize this pattern
%cmp0 = icmp eq i32 %x, 0
br i1 %cmp0, label %case0, label %check1
check1:
%cmp1 = icmp eq i32 %x, 1
br i1 %cmp1, label %case1, label %default

; And optimize to switch:
switch i32 %x, label %default [
    i32 0, label %case0
    i32 1, label %case1
]

LLVM switch lowering:

  • Dense: jump table (O(1))
  • Sparse: binary search tree (O(log n))
  • Very sparse: linear search (O(n))

Mixed Patterns: Constructor + Literal

실제 코드는 constructor와 literal을 섞어 쓴다:

match (list, n) with
| (Nil, _) -> 0
| (Cons(x, _), 0) -> x
| (Cons(x, xs), n) -> x + process xs (n - 1)

Lowering 전략:

  1. First column (list): Constructor pattern → scf.index_switch
  2. Second column (n): Literal pattern → arith.cmpi + scf.if
// Step 1: Constructor dispatch on list
%list_tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%tag_index = arith.index_cast %list_tag : i32 to index

%result = scf.index_switch %tag_index : index -> i32
case 0 {
    // Nil case: wildcard on n (no test)
    %zero = arith.constant 0 : i32
    scf.yield %zero : i32
}
case 1 {
    // Cons case: extract data
    %data = llvm.extractvalue %list[1] : !llvm.struct<(i32, ptr)>
    %x = llvm.load %data : !llvm.ptr -> i32

    // Step 2: Literal dispatch on n
    %is_zero = arith.cmpi eq, %n, %c0 : i32
    %inner = scf.if %is_zero -> i32 {
        // Case: Cons(x, _), 0 → x
        scf.yield %x : i32
    } else {
        // Case: Cons(x, xs), n → x + process xs (n-1)
        %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
        %xs = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
        %n_minus_1 = arith.subi %n, %c1 : i32
        %rest = func.call @process(%xs, %n_minus_1) : (...) -> i32
        %sum = arith.addi %x, %rest : i32
        scf.yield %sum : i32
    }
    scf.yield %inner : i32
}

핵심 원칙:

  • Constructor column: scf.index_switch로 O(1) dispatch
  • Literal column: scf.if chain으로 O(n) dispatch
  • Wildcard: test 없음 (fallthrough 또는 skip)

와일드카드 Default Case 처리

Wildcard (_) pattern의 lowering:

Wildcard는 어떤 테스트도 생성하지 않는다.

match x with
| 0 -> "zero"
| 1 -> "one"
| _ -> "other"  // Wildcard: no test
%is_zero = arith.cmpi eq, %x, %c0 : i32
scf.if %is_zero {
    scf.yield %zero_str : !llvm.ptr<i8>
} else {
    %is_one = arith.cmpi eq, %x, %c1 : i32
    scf.if %is_one {
        scf.yield %one_str : !llvm.ptr<i8>
    } else {
        // _ case: NO comparison, just yield
        scf.yield %other_str : !llvm.ptr<i8>
    }
}

Wildcard optimization in subpatterns:

match list with
| Cons(_, tail) -> length tail + 1  // Don't extract head
| Nil -> 0
case 1 {  // Cons
    // Wildcard _: Skip head extraction
    // %head = llvm.load %data  -- NOT generated!

    %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
    %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>
    // ...
}

Wildcard 최적화의 효과:

  1. 메모리 접근 감소: 불필요한 load 제거
  2. 레지스터 절약: unused 값을 저장 안 함
  3. DCE 촉진: Dead code elimination이 더 쉬워짐

Type Dispatch Pattern

타입 기반 dispatch (future extension):

일부 언어는 runtime type으로 dispatch한다:

// Hypothetical type dispatch
match value with
| :? int as n -> n + 1
| :? string as s -> String.length s
| _ -> 0

이는 다음으로 lowering 가능:

// Type tag dispatch (similar to ADT constructor)
%type_tag = llvm.extractvalue %boxed_value[0] : !llvm.struct<(i32, ptr)>
%tag_index = arith.index_cast %type_tag : i32 to index

scf.index_switch %tag_index : index -> i32
case 0 { /* int case */ }
case 1 { /* string case */ }
default { scf.yield %zero : i32 }

현재 FunLang은 ADT constructor만 지원하지만, 동일한 패턴이 적용된다.


Summary and Chapter 20 Preview

Chapter 19 Recap

이 장에서 배운 것:

Part 1: Match Operation Definition

  1. Region-based operations

    • Regions vs basic blocks: encapsulation, verification 장점
    • funlang.match는 multiple regions (variadic, each with 1 block)
  2. Match operation semantics

    • Runtime execution: tag extraction → case selection → variable binding → yield
    • Block arguments for pattern variables
  3. TableGen definition

    • Traits: RecursiveSideEffect, SingleBlockImplicitTerminator<"YieldOp">
    • VariadicRegion<SizedRegion<1>> for flexible case count
    • Custom assembly format, verifier
  4. YieldOp terminator

    • Terminator trait, HasParent<“MatchOp”> constraint
    • Dedicated operation (not reusing scf.yield)
  5. C API and F# integration

    • Builder callback pattern for region construction
    • High-level wrapper: CreateMatchOp(scrutinee, resultType, buildCases)
    • Block arguments added in F# callback, mapped in lowering pass

Part 2: SCF Lowering and Pipeline

  1. SCF dialect overview

    • Structured control flow (regions, not goto)
    • scf.index_switch for multi-way branching
    • Why SCF before LLVM: structure preservation, optimization, debugging
  2. MatchOpLowering pattern

    • Tag extraction and index casting
    • Data extraction for pattern variables
    • IRMapping for block argument remapping
    • Region cloning with mapped values
    • funlang.yieldscf.yield conversion
  3. Complete pipeline

    • FunLangToSCFPass → FunLangToLLVMPass → SCFToControlFlowPass → …
    • Pass dependencies and ordering
    • End-to-end example: sum_list
  4. Common errors

    • Block argument count mismatch
    • Type mismatch in regions
    • Missing scf.yield
    • Wrong tag values
    • Incorrect data extraction

Pattern Matching Pipeline: Complete

Phase 6 journey:

Chapter 17: Theory
  ↓
  Decision tree algorithm
  Pattern matrix, specialization/defaulting
  Exhaustiveness checking

Chapter 18: Data Structures
  ↓
  !funlang.list<T> type
  funlang.nil, funlang.cons operations
  TypeConverter, lowering patterns

Chapter 19: Match Compilation (현재)
  ↓
  funlang.match operation
  Region-based structure
  MatchOpLowering to scf.index_switch
  Complete pipeline

Chapter 20: Functional Programs (next)
  ↓
  Realistic examples: map, filter, fold
  Performance analysis
  Debugging functional code

지금까지의 성과:

FeatureChaptersOperationsStatus
Arithmetic5-6arith.*✅ Phase 2
Let bindings7SSA values✅ Phase 2
Control flow8scf.if✅ Phase 2
Functions10func.func, func.call✅ Phase 3
Recursion11func.call @self✅ Phase 3
Closures12funlang.closure✅ Phase 5
Higher-order13funlang.apply✅ Phase 5
Custom dialect14-16Lowering passes✅ Phase 5
Pattern matching17-19funlang.match✅ Phase 6 (현재)
Data structures17-19funlang.nil, funlang.cons✅ Phase 6 (현재)

다음: Realistic functional programs

Chapter 20 Preview: Functional Programs

Chapter 20에서 할 것:

  1. Classic list functions

    • length, map, filter, fold_left, fold_right
    • Pattern matching + recursion 결합
  2. Composed functions

    • sum = fold_left (+) 0
    • product = fold_left (*) 1
    • Higher-order functions로 추상화
  3. Performance analysis

    • Tail recursion vs non-tail recursion
    • Closure allocation overhead
    • GC pressure measurement
  4. Debugging techniques

    • IR dumping at each stage
    • printf debugging in functional code
    • Stack trace interpretation
  5. Complete FunLang compiler

    • All features integrated
    • End-to-end compilation
    • Real-world program examples

Chapter 20 목표:

지금까지 배운 모든 기능을 종합하여 실용적인 함수형 프로그램을 작성하고 컴파일한다.

Example program (Chapter 20):

// Functional list library
let rec map f lst =
    match lst with
    | [] -> []
    | head :: tail -> f head :: map f tail

let rec filter pred lst =
    match lst with
    | [] -> []
    | head :: tail ->
        if pred head then
            head :: filter pred tail
        else
            filter pred tail

let rec fold_left f acc lst =
    match lst with
    | [] -> acc
    | head :: tail -> fold_left f (f acc head) tail

// Usage
let double x = x * 2
let is_even x = x % 2 = 0

let main () =
    let numbers = [1; 2; 3; 4; 5; 6]
    let doubled = map double numbers         // [2; 4; 6; 8; 10; 12]
    let evens = filter is_even doubled       // [2; 4; 6; 8; 10; 12]
    let sum = fold_left (+) 0 evens          // 42
    sum

Generated MLIR (high-level view):

module {
  func.func @map(%f: !funlang.closure, %lst: !funlang.list<i32>)
      -> !funlang.list<i32> {
    %result = funlang.match %lst {
      ^nil: ...
      ^cons(%h, %t): ...
    }
    return %result
  }

  func.func @filter(%pred: !funlang.closure, %lst: !funlang.list<i32>)
      -> !funlang.list<i32> { ... }

  func.func @fold_left(%f: !funlang.closure, %acc: i32, %lst: !funlang.list<i32>)
      -> i32 { ... }

  func.func @main() -> i32 {
    // Build list [1; 2; 3; 4; 5; 6]
    %numbers = ...

    // map double numbers
    %double = funlang.closure @double, () : !funlang.closure
    %doubled = func.call @map(%double, %numbers) : ...

    // filter is_even doubled
    %is_even = funlang.closure @is_even, () : !funlang.closure
    %evens = func.call @filter(%is_even, %doubled) : ...

    // fold_left (+) 0 evens
    %plus = funlang.closure @plus, () : !funlang.closure
    %zero = arith.constant 0 : i32
    %sum = func.call @fold_left(%plus, %zero, %evens) : ...

    return %sum : i32
  }
}

Chapter 20 will show:

  • Complete compilation to native code
  • Performance benchmarks
  • Comparison with imperative equivalents
  • Debugging workflow for functional programs

튜플 패턴 매칭 (Tuple Pattern Matching)

Chapter 18에서 우리는 !funlang.tuple<T1, T2, ...> 타입과 funlang.make_tuple 연산을 구현했다. 이제 튜플에 대한 패턴 매칭을 구현하자.

튜플 패턴의 특성 (Tuple Pattern Characteristics)

튜플 패턴은 리스트 패턴과 근본적으로 다르다:

특성리스트 패턴튜플 패턴
태그 검사필요 (Nil/Cons 구분)불필요
패턴 case 수최소 2개 (Nil, Cons)항상 1개
매칭 실패 가능성있음없음 (항상 매칭)
Lowering 대상scf.index_switch직접 extractvalue
제어 흐름조건부 분기선형

핵심 통찰:

튜플 패턴은 본질적으로 **구조 분해(destructuring)**다. 항상 매칭이 성공하므로 조건 분기가 필요 없다.

// 리스트: 두 가지 가능성, 조건 분기 필요
match list with
| [] -> expr1       // Nil case
| x :: xs -> expr2  // Cons case

// 튜플: 한 가지 가능성, 조건 분기 불필요
match pair with
| (x, y) -> x + y   // 항상 이 case로

funlang.match 튜플 지원 (Tuple Support in funlang.match)

튜플 패턴 매칭의 MLIR 표현:

// 소스 코드: let (x, y) = pair in x + y
%pair = funlang.make_tuple(%a, %b) : !funlang.tuple<i32, i32>

%sum = funlang.match %pair : !funlang.tuple<i32, i32> -> i32 {
  ^case(%x: i32, %y: i32):
    %result = arith.addi %x, %y : i32
    funlang.yield %result : i32
}

리스트 패턴과 비교:

// 리스트 패턴: 두 case
%result = funlang.match %list : !funlang.list<i32> -> i32 {
  ^nil:
    %zero = arith.constant 0 : i32
    funlang.yield %zero : i32
  ^cons(%head: i32, %tail: !funlang.list<i32>):
    // ...
    funlang.yield %sum : i32
}

// 튜플 패턴: 한 case만
%result = funlang.match %tuple : !funlang.tuple<i32, i32> -> i32 {
  ^case(%x: i32, %y: i32):
    // 항상 이 case 실행
    funlang.yield %result : i32
}

튜플 패턴의 핵심:

  1. 단일 case: 분기 불필요
  2. block arguments = 구조 분해된 원소: (%x, %y)^case(%x: i32, %y: i32)
  3. 원소 개수 = block argument 개수: 타입의 arity와 일치

튜플 로우어링 구현 (Tuple Lowering Implementation)

핵심 차이점:

리스트 패턴 lowering:

  1. 태그 추출 (extractvalue [0])
  2. scf.index_switch로 분기
  3. 각 case에서 데이터 추출

튜플 패턴 lowering:

  1. 각 원소 추출 (extractvalue [i])
  2. 원래 block의 operation들을 inline
  3. 분기 없음!

TupleMatchLowering 패턴:

// 튜플 패턴 매칭의 lowering은 특별 처리가 필요
// MatchOpLowering 내부에서 튜플 타입 감지 시

LogicalResult matchTuplePattern(MatchOp op, OpAdaptor adaptor,
                                 ConversionPatternRewriter &rewriter) {
  Location loc = op.getLoc();
  Value input = adaptor.getInput();

  // 튜플은 단일 case만 가짐
  assert(op.getCases().size() == 1 && "Tuple match must have exactly one case");

  Region& caseRegion = op.getCases().front();
  Block& caseBlock = caseRegion.front();

  // 구조체에서 각 원소 추출
  auto structType = input.getType().cast<LLVM::LLVMStructType>();
  IRMapping mapper;

  for (size_t i = 0; i < caseBlock.getNumArguments(); ++i) {
    Value extracted = rewriter.create<LLVM::ExtractValueOp>(
        loc, structType.getBody()[i], input, i);
    mapper.map(caseBlock.getArgument(i), extracted);
  }

  // 원래 block의 operations를 현재 위치에 inline
  for (Operation& op : caseBlock.getOperations()) {
    if (auto yieldOp = dyn_cast<YieldOp>(&op)) {
      // yield의 값으로 match 결과를 대체
      Value yieldValue = mapper.lookupOrDefault(yieldOp.getValue());
      rewriter.replaceOp(op, yieldValue);
    } else {
      rewriter.clone(op, mapper);
    }
  }

  return success();
}

Lowering 결과 비교:

// Before lowering (FunLang)
%sum = funlang.match %pair : !funlang.tuple<i32, i32> -> i32 {
  ^case(%x: i32, %y: i32):
    %result = arith.addi %x, %y : i32
    funlang.yield %result : i32
}

// After lowering (LLVM dialect) - 분기 없음!
%x = llvm.extractvalue %pair[0] : !llvm.struct<(i32, i32)>
%y = llvm.extractvalue %pair[1] : !llvm.struct<(i32, i32)>
%sum = arith.addi %x, %y : i32

리스트 패턴 lowering과 비교:

// 리스트: scf.index_switch 필요
%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%tagIndex = arith.index_cast %tag : i32 to index
%result = scf.index_switch %tagIndex : index -> i32
case 0 {  // Nil
  %zero = arith.constant 0 : i32
  scf.yield %zero : i32
}
case 1 {  // Cons
  %ptr = llvm.extractvalue %list[1] : !llvm.struct<(i32, ptr)>
  %head = llvm.load %ptr : !llvm.ptr -> i32
  // ...
  scf.yield %sum : i32
}
default {
  llvm.unreachable
}

// 튜플: extractvalue만으로 충분
%x = llvm.extractvalue %pair[0] : !llvm.struct<(i32, i32)>
%y = llvm.extractvalue %pair[1] : !llvm.struct<(i32, i32)>
%result = arith.addi %x, %y : i32

중첩 패턴 (Nested Patterns)

튜플 안에 리스트가 있는 경우:

// 두 리스트를 튜플로 묶어서 동시에 패턴 매칭
let rec zip xs ys =
  match (xs, ys) with
  | ([], _) -> []
  | (_, []) -> []
  | (x :: xs', y :: ys') -> (x, y) :: zip xs' ys'

MLIR 표현:

// 1단계: 튜플 구조 분해
%tuple = funlang.make_tuple(%xs, %ys) : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>

// 2단계: 튜플에서 두 리스트 추출
%xs_extracted = ... extractvalue [0] ...
%ys_extracted = ... extractvalue [1] ...

// 3단계: 첫 번째 리스트에 대해 패턴 매칭
%result = funlang.match %xs_extracted : !funlang.list<i32> -> ... {
  ^nil:
    // 빈 리스트 반환
  ^cons(%x: i32, %xs_tail: !funlang.list<i32>):
    // 4단계: 두 번째 리스트에 대해 중첩 패턴 매칭
    %inner = funlang.match %ys_extracted : !funlang.list<i32> -> ... {
      ^nil:
        // 빈 리스트 반환
      ^cons(%y: i32, %ys_tail: !funlang.list<i32>):
        // (x, y) :: zip xs_tail ys_tail
        %pair = funlang.make_tuple(%x, %y) : !funlang.tuple<i32, i32>
        %rest = func.call @zip(%xs_tail, %ys_tail) : ...
        %result = funlang.cons %pair, %rest : ...
        funlang.yield %result
    }
    funlang.yield %inner
}

중첩 패턴 lowering 전략:

  1. 외부에서 내부로: 가장 바깥 패턴부터 lowering
  2. 튜플 먼저: 튜플 분해는 조건 없이 extractvalue
  3. 리스트는 분기: 각 리스트 패턴은 scf.index_switch 필요
  4. 깊이 우선: 내부 패턴이 완전히 lowering된 후 외부로

MatchOpLowering 확장: 튜플 지원

기존 MatchOpLowering에 튜플 분기 추가:

class MatchOpLowering : public OpConversionPattern<MatchOp> {
public:
  LogicalResult matchAndRewrite(MatchOp op, OpAdaptor adaptor,
                                 ConversionPatternRewriter &rewriter) const override {
    Location loc = op.getLoc();
    Value input = adaptor.getInput();
    Type inputType = op.getInput().getType();

    // 튜플인지 확인
    if (auto tupleType = inputType.dyn_cast<funlang::TupleType>()) {
      return matchTuplePattern(op, adaptor, rewriter, tupleType);
    }

    // 리스트인 경우 기존 로직 사용
    if (auto listType = inputType.dyn_cast<funlang::ListType>()) {
      return matchListPattern(op, adaptor, rewriter, listType);
    }

    return op.emitError("unsupported match input type");
  }

private:
  LogicalResult matchTuplePattern(MatchOp op, OpAdaptor adaptor,
                                   ConversionPatternRewriter &rewriter,
                                   funlang::TupleType tupleType) const {
    Location loc = op.getLoc();
    Value input = adaptor.getInput();

    // 튜플은 단일 case만 허용
    if (op.getCases().size() != 1) {
      return op.emitError("tuple match must have exactly one case");
    }

    Region& caseRegion = op.getCases().front();
    Block& caseBlock = caseRegion.front();

    // block argument 개수 검증
    if (caseBlock.getNumArguments() != tupleType.getNumElements()) {
      return op.emitError() << "tuple arity mismatch: type has "
                            << tupleType.getNumElements() << " elements but pattern has "
                            << caseBlock.getNumArguments();
    }

    // 각 원소 추출 및 매핑
    auto structType = getTypeConverter()->convertType(tupleType);
    IRMapping mapper;

    for (size_t i = 0; i < caseBlock.getNumArguments(); ++i) {
      auto elemType = structType.cast<LLVM::LLVMStructType>().getBody()[i];
      Value extracted = rewriter.create<LLVM::ExtractValueOp>(
          loc, elemType, input, i);
      mapper.map(caseBlock.getArgument(i), extracted);
    }

    // 현재 위치에 operations inline
    Value resultValue;
    for (Operation& caseOp : caseBlock.getOperations()) {
      if (auto yieldOp = dyn_cast<YieldOp>(&caseOp)) {
        resultValue = mapper.lookupOrDefault(yieldOp.getValue());
      } else {
        rewriter.clone(caseOp, mapper);
      }
    }

    rewriter.replaceOp(op, resultValue);
    return success();
  }

  LogicalResult matchListPattern(MatchOp op, OpAdaptor adaptor,
                                  ConversionPatternRewriter &rewriter,
                                  funlang::ListType listType) const {
    // 기존 리스트 패턴 매칭 로직...
    // (scf.index_switch 사용)
  }
};

리스트 vs 튜플 패턴 매칭 종합 비교

구분리스트 패턴튜플 패턴
타입!funlang.list<T>!funlang.tuple<T1, T2, ...>
case 수2개 이상 (Nil, Cons, …)정확히 1개
태그 검사필요 (extractvalue [0])불필요
분기scf.index_switch없음
데이터 추출ptr load 필요extractvalue만
패턴 변수 바인딩조건부 (case 안에서)무조건
default case있음 (unreachable)없음
lowering 복잡도높음낮음
최종 코드 크기크다 (분기 포함)작다 (선형)

생성되는 코드 비교:

// 리스트 패턴 매칭 결과 (복잡)
%tag = llvm.extractvalue %list[0] : !llvm.struct<(i32, ptr)>
%idx = arith.index_cast %tag : i32 to index
%result = scf.index_switch %idx : index -> i32
case 0 {  // ~10 lines
  ...
  scf.yield %val0 : i32
}
case 1 {  // ~15 lines
  ...
  scf.yield %val1 : i32
}
default {  // ~3 lines
  llvm.unreachable
}

// 튜플 패턴 매칭 결과 (단순)
%x = llvm.extractvalue %pair[0] : !llvm.struct<(i32, i32)>
%y = llvm.extractvalue %pair[1] : !llvm.struct<(i32, i32)>
// ... operations inline ...

튜플 패턴의 최적화 기회

튜플 패턴 매칭은 이미 최적화되어 있다:

  1. 분기 제거: 조건문 없이 바로 연산
  2. Inlining 자동: 별도 함수 호출 없음
  3. 레지스터 친화적: 작은 튜플은 레지스터에 유지

추가 최적화 가능:

// Before: 사용하지 않는 원소도 추출
%pair = funlang.make_tuple(%a, %b) : !funlang.tuple<i32, i32>
%result = funlang.match %pair {
  ^case(%x: i32, %y: i32):  // %y 사용 안 함
    funlang.yield %x : i32
}

// After: Dead code elimination으로 %y 추출 제거
%x = llvm.extractvalue %pair[0] : !llvm.struct<(i32, i32)>
// %y 추출 생략됨
%result = %x

와일드카드 패턴 (Wildcard Pattern)

사용하지 않는 원소는 와일드카드로:

// 첫 번째 원소만 사용
let fst pair = match pair with (x, _) -> x

// 두 번째 원소만 사용
let snd pair = match pair with (_, y) -> y

MLIR에서 와일드카드:

// 와일드카드는 block argument가 없음
%fst = funlang.match %pair : !funlang.tuple<i32, i32> -> i32 {
  ^case(%x: i32):  // %y 자리에 block argument 없음 (또는 unused)
    funlang.yield %x : i32
}

Lowering 시 최적화:

// 와일드카드 패턴 처리
for (size_t i = 0; i < tupleType.getNumElements(); ++i) {
  BlockArgument arg = caseBlock.getArgument(i);
  if (!arg.use_empty()) {  // 사용되는 경우에만 추출
    Value extracted = rewriter.create<LLVM::ExtractValueOp>(...);
    mapper.map(arg, extracted);
  }
  // 와일드카드 (미사용)면 extractvalue 생략
}

Summary: 튜플 패턴 매칭

구현 완료:

  • 튜플 패턴의 특성 이해 (단일 case, 분기 없음)
  • funlang.match에서 튜플 타입 지원
  • MatchOpLowering에서 튜플/리스트 분기 처리
  • extractvalue 체인으로 원소 추출
  • 분기 없이 inline lowering
  • 중첩 패턴 (튜플 + 리스트) 처리 전략
  • 와일드카드 패턴과 dead code elimination

튜플 패턴 vs 리스트 패턴:

측면리스트튜플
패턴 case다중단일
제어 흐름scf.index_switch선형
태그 검사필요불필요
Lowering복잡단순
생성 코드분기 포함extractvalue만

다음:

  • Chapter 20에서 튜플을 활용한 실제 프로그램 (zip, fst/snd)
  • 중첩 튜플과 포인트 예제

Conclusion

Chapter 19 완료!

우리는 funlang.match operation을 정의하고 SCF dialect로 lowering하여 패턴 매칭 컴파일 파이프라인을 완성했다.

핵심 개념:

  1. Region-based operations: Encapsulation과 verification을 위한 구조
  2. Multi-stage lowering: FunLang → SCF → CF → LLVM (progressive refinement)
  3. IRMapping: Block arguments를 실제 values로 remapping
  4. Builder callback pattern: F#에서 regions를 구축하는 방법
  5. 튜플 패턴 매칭: 분기 없는 extractvalue 기반 lowering

Phase 6 진행 상황:

  • ✅ Chapter 17: Pattern matching theory (Decision tree algorithm)
  • ✅ Chapter 18: List operations (funlang.nil, funlang.cons, funlang.tuple, funlang.make_tuple)
  • ✅ Chapter 19: Match compilation (funlang.match, 리스트/튜플 패턴 lowering)
  • ⏭️ Chapter 20: Functional programs (map, filter, fold, zip - realistic examples)

다음 장에서 만나요!

Chapter 20: Functional Programs (Functional Programs)

소개

Phase 6의 여정을 복습하자:

Chapter 17: Pattern Matching Theory에서는 패턴 매칭의 이론적 기초를 다뤘다:

  • Decision tree 알고리즘 (Maranget 2008)
  • Pattern matrix와 specialization/defaulting 연산
  • Exhaustiveness checking과 unreachable case detection
  • 컴파일 시간에 패턴을 분석하여 최적의 decision tree 생성

Chapter 18: List Operations에서는 패턴 매칭이 작동할 데이터 구조를 구현했다:

  • !funlang.list<T> parameterized type으로 타입 안전한 리스트 표현
  • funlang.nilfunlang.cons operations으로 리스트 생성
  • TypeConverter로 tagged union !llvm.struct<(i32, ptr)> 변환
  • NilOpLowering과 ConsOpLowering patterns로 LLVM dialect 생성

Chapter 19: Match Compilation에서는 모든 것을 종합했다:

  • funlang.match operation으로 패턴 매칭 표현
  • Multi-stage lowering: FunLang → SCF → CF → LLVM
  • IRMapping으로 block argument remapping
  • 실행 가능한 코드 생성

Chapter 20에서는 이 모든 것을 사용하여 실제 함수형 프로그램을 작성한다.

Phase 6의 최종 목표: 완전한 함수형 프로그래밍

Phase 4에서 우리는 클로저를 구현했다:

// Phase 4: 클로저
let makeAdder n = fun x -> x + n
let add5 = makeAdder 5
let result = add5 10  // 15

Phase 5에서 우리는 커스텀 FunLang dialect를 만들었다:

// Phase 5: FunLang operations
%closure = funlang.closure @add_impl(%n) : (i32) -> ((i32) -> i32)
%result = funlang.apply %closure(%x) : ((i32) -> i32, i32) -> i32

Phase 6에서 우리는 리스트와 패턴 매칭을 구현했다:

// Phase 6: Lists and pattern matching
%list = funlang.cons %head, %tail : (i32, !funlang.list<i32>) -> !funlang.list<i32>
%result = funlang.match %list : !funlang.list<i32> -> i32 {
  ^nil:
    funlang.yield %zero : i32
  ^cons(%h: i32, %t: !funlang.list<i32>):
    funlang.yield %h : i32
}

이제 이 세 가지를 조합하면 강력한 함수형 프로그래밍이 가능하다:

// Phase 6 Complete: 클로저 + 리스트 + 패턴 매칭
let map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (map f tail)

let double x = x * 2
let result = map double [1, 2, 3]  // [2, 4, 6]

Chapter 20의 목표: 실전 함수형 프로그램

이 장을 마치면 다음과 같은 실제 함수형 프로그램을 컴파일하고 실행할 수 있다:

1. map: 리스트의 각 원소에 함수를 적용

let map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (map f tail)

map (fun x -> x * 2) [1, 2, 3]  // [2, 4, 6]

2. filter: 조건을 만족하는 원소만 남기기

let filter pred lst =
  match lst with
  | [] -> []
  | head :: tail ->
      if pred head then
        head :: filter pred tail
      else
        filter pred tail

filter (fun x -> x > 2) [1, 2, 3, 4]  // [3, 4]

3. fold: 리스트를 하나의 값으로 축약

let fold f acc lst =
  match lst with
  | [] -> acc
  | head :: tail -> fold f (f acc head) tail

fold (+) 0 [1, 2, 3, 4, 5]  // 15

4. 조합: 복잡한 프로그램

// 제곱의 합: [1, 2, 3] -> 14
let sum_of_squares lst =
  fold (+) 0 (map (fun x -> x * x) lst)

sum_of_squares [1, 2, 3]  // 1 + 4 + 9 = 14

성공 기준: 완전한 컴파일 파이프라인

각 함수형 프로그램에 대해 다음을 보여준다:

  1. FunLang 소스 코드: F# 스타일의 함수형 문법
  2. FunLang dialect MLIR: 커스텀 operations 사용
  3. SCF dialect MLIR: 제어 흐름으로 변환
  4. LLVM dialect MLIR: 최종 lowering
  5. 실행 결과: JIT으로 실행하여 결과 확인

이것이 바로 “실전 컴파일러“다:

  • 교과서의 toy 예제가 아니라 실제 사용 가능한 프로그램
  • 모든 단계를 추적 가능하고 검증 가능
  • Phase 7 (최적화)로 이어지는 기반

Chapter 20의 구성

Part 1: Map and Filter (이번 섹션)

  1. FunLang에서 리스트 구축하기
  2. map 함수: 소스, 컴파일, 실행
  3. filter 함수: 중첩 제어 흐름
  4. Helper 함수: length, append

Part 2: Fold and Complete Pipeline

  1. fold 함수: 일반적인 리스트 combinator
  2. 완전한 예제: sum_of_squares
  3. 성능 고려사항
  4. 완전한 컴파일러 통합
  5. Phase 6 요약과 Phase 7 미리보기

이 장을 마치면 Phase 6가 완료되며, Phase 7 (최적화)로 넘어갈 준비가 된다.

FunLang에서 리스트 구축하기

FunLang AST 확장: List Expressions

지금까지 우리는 MLIR operations로 리스트를 직접 구축했다:

// 직접 MLIR 작성
%nil = funlang.nil : !funlang.list<i32>
%three = arith.constant 3 : i32
%l1 = funlang.cons %three, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>

하지만 사용자는 FunLang 언어로 작성하고 싶어한다:

// 사용자가 원하는 문법
let empty = []
let list = [1, 2, 3]
let consed = 1 :: [2, 3]

AST 확장이 필요하다.

FunLang AST Type Definition

Ast.fs에 리스트 표현식을 추가한다:

// Ast.fs
type Expr =
    | Int of int
    | Var of string
    | Add of Expr * Expr
    | Let of string * Expr * Expr
    | If of Expr * Expr * Expr
    | Fun of string * Expr              // Phase 4: lambda
    | App of Expr * Expr                // Phase 4: application

    // Phase 6: List expressions
    | Nil                                // []
    | Cons of Expr * Expr                // head :: tail
    | List of Expr list                  // [1, 2, 3] - syntactic sugar
    | Match of Expr * (Pattern * Expr) list  // match expr with cases

and Pattern =
    | PVar of string                     // x (bind any value)
    | PNil                               // [] (empty list)
    | PCons of Pattern * Pattern         // head :: tail
    | PWild                              // _ (wildcard)

설계 결정:

  1. Nil: Empty list []는 zero-argument constructor
  2. Cons: Binary operator :: (head와 tail)
  3. List: List literal [1, 2, 3]는 syntactic sugar (연속된 Cons로 desugaring)
  4. Match: Pattern matching expression

List Literal Desugaring

List literal은 syntactic sugar다:

// 사용자 작성
[1, 2, 3]

// Desugaring
1 :: (2 :: (3 :: []))

// AST 표현
Cons(Int 1, Cons(Int 2, Cons(Int 3, Nil)))

Desugaring 함수:

// Parser.fs or Desugar.fs
let rec desugarList (exprs: Expr list) : Expr =
    match exprs with
    | [] -> Nil
    | head :: tail -> Cons(head, desugarList tail)

// Usage
let ast = List [Int 1; Int 2; Int 3]
let desugared = desugarList [Int 1; Int 2; Int 3]
// Result: Cons(Int 1, Cons(Int 2, Cons(Int 3, Nil)))

왜 desugaring인가?

  1. 간단한 컴파일: 컴파일러는 ConsNil만 처리하면 된다
  2. 중복 제거: List literal과 Cons operator가 같은 representation을 공유
  3. 확장성: 새로운 syntactic sugar 추가 시 desugaring만 변경

컴파일러 통합: compileExpr 확장

Compiler.fscompileExpr 함수를 확장하여 리스트를 처리한다:

// Compiler.fs
let rec compileExpr (builder: OpBuilder) (expr: Expr) (symbolTable: Map<string, Value>) : Value =
    match expr with
    | Int n ->
        let ty = builder.GetI32Type()
        builder.CreateConstantInt(ty, n)

    | Var name ->
        symbolTable.[name]

    | Add (left, right) ->
        let lhs = compileExpr builder left symbolTable
        let rhs = compileExpr builder right symbolTable
        builder.CreateAddI(lhs, rhs)

    // ... (Phase 3-4 cases)

    // Phase 6: Nil case
    | Nil ->
        // funlang.nil : !funlang.list<T>
        // Type inference: 주변 context에서 element type 결정
        let elemTy = inferElementType expr  // e.g., i32
        let listTy = builder.GetListType(elemTy)
        builder.CreateNil(listTy)

    // Phase 6: Cons case
    | Cons (head, tail) ->
        // funlang.cons %head, %tail : (T, !funlang.list<T>) -> !funlang.list<T>
        let headVal = compileExpr builder head symbolTable
        let tailVal = compileExpr builder tail symbolTable
        let headTy = headVal.GetType()
        let listTy = builder.GetListType(headTy)
        builder.CreateCons(headVal, tailVal, listTy)

    // Phase 6: Match case (covered later in this chapter)
    | Match (scrutinee, cases) ->
        compileMatch builder scrutinee cases symbolTable

Type inference 예제:

// FunLang source
let list = 1 :: 2 :: []

// Type inference
// - 1 is i32, so head is i32
// - Cons expects (i32, !funlang.list<i32>)
// - [] must be !funlang.list<i32>

// Compiled MLIR
%c1 = arith.constant 1 : i32
%c2 = arith.constant 2 : i32
%nil = funlang.nil : !funlang.list<i32>
%tail = funlang.cons %c2, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>
%list = funlang.cons %c1, %tail : (i32, !funlang.list<i32>) -> !funlang.list<i32>

예제: 리스트 컴파일

Example 1: Empty list

// FunLang
let empty = []

Compiled MLIR:

func.func @example1() -> !funlang.list<i32> {
  %empty = funlang.nil : !funlang.list<i32>
  return %empty : !funlang.list<i32>
}

Example 2: Single element

// FunLang
let single = [42]

// Desugared
let single = 42 :: []

Compiled MLIR:

func.func @example2() -> !funlang.list<i32> {
  %c42 = arith.constant 42 : i32
  %nil = funlang.nil : !funlang.list<i32>
  %single = funlang.cons %c42, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  return %single : !funlang.list<i32>
}

Example 3: Multiple elements

// FunLang
let list = [1, 2, 3]

// Desugared
let list = 1 :: (2 :: (3 :: []))

Compiled MLIR:

func.func @example3() -> !funlang.list<i32> {
  // Build from inside out: 3 :: []
  %c3 = arith.constant 3 : i32
  %nil = funlang.nil : !funlang.list<i32>
  %l3 = funlang.cons %c3, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  // 2 :: [3]
  %c2 = arith.constant 2 : i32
  %l2 = funlang.cons %c2, %l3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  // 1 :: [2, 3]
  %c1 = arith.constant 1 : i32
  %l1 = funlang.cons %c1, %l2 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  return %l1 : !funlang.list<i32>
}

Example 4: Cons operator

// FunLang
let list = 1 :: 2 :: 3 :: []

Compiled MLIR (same as Example 3):

func.func @example4() -> !funlang.list<i32> {
  %c3 = arith.constant 3 : i32
  %nil = funlang.nil : !funlang.list<i32>
  %l3 = funlang.cons %c3, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  %c2 = arith.constant 2 : i32
  %l2 = funlang.cons %c2, %l3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  %c1 = arith.constant 1 : i32
  %l1 = funlang.cons %c1, %l2 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  return %l1 : !funlang.list<i32>
}

Type safety:

FunLang의 타입 시스템은 heterogeneous list를 방지한다:

// Type error: element type mismatch
let bad = [1, "hello", 3]
// Error: Expected i32, found string

MLIR type은 element type을 명시한다:

  • !funlang.list<i32>: 32비트 정수 리스트
  • !funlang.list<f64>: 64비트 부동소수점 리스트
  • !funlang.list<!funlang.closure<(i32) -> i32>>: 클로저 리스트 (고차 함수)

이제 우리는 리스트를 구축할 수 있다. 다음은 리스트를 조작하는 함수를 작성할 차례다.

map 함수: 리스트 변환

map의 개념

map은 함수형 프로그래밍의 가장 기본적인 고차 함수다:

// map의 타입
map : (a -> b) -> [a] -> [b]

// map의 의미
map f [x1, x2, ..., xn] = [f x1, f x2, ..., f xn]

예제:

let double x = x * 2
map double [1, 2, 3]  // [2, 4, 6]

let inc x = x + 1
map inc [10, 20, 30]  // [11, 21, 31]

map (fun x -> x * x) [1, 2, 3, 4]  // [1, 4, 9, 16]

FunLang 소스 코드

map 함수를 FunLang으로 작성한다:

let rec map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (map f tail)

동작 원리:

  1. Base case: Empty list → return empty list
  2. Recursive case:
    • Apply f to head → transformed head
    • Recursively map over tail
    • Cons the results

실행 trace:

map double [1, 2, 3]
→ double 1 :: map double [2, 3]
→ 2 :: (double 2 :: map double [3])
→ 2 :: (4 :: (double 3 :: map double []))
→ 2 :: (4 :: (6 :: []))
→ [2, 4, 6]

FunLang AST 표현

FunLang AST로 표현하면:

// let rec map f lst = ...
Let("map",
    Fun("f",
        Fun("lst",
            Match(Var "lst",
                [ (PNil, Nil)
                ; (PCons(PVar "head", PVar "tail"),
                   Cons(App(Var "f", Var "head"),
                        App(App(Var "map", Var "f"), Var "tail")))
                ]))),
    // ... body that uses map ...
)

구조 분석:

  1. Outer Let: map 정의를 scope에 바인딩
  2. Curried function: flst 두 개의 중첩 lambda
  3. Match expression: lst에 대한 패턴 매칭
  4. Patterns: [] (PNil)과 head :: tail (PCons)
  5. Recursive call: map f tail에서 map 자기 자신 호출

컴파일된 MLIR: FunLang Dialect

compileExpr가 위 AST를 컴파일하면 다음 MLIR이 생성된다:

// map : (T -> U) -> !funlang.list<T> -> !funlang.list<U>
func.func @map(%f: !funlang.closure<(i32) -> i32>,
               %lst: !funlang.list<i32>) -> !funlang.list<i32> {
  // match lst with ...
  %result = funlang.match %lst : !funlang.list<i32> -> !funlang.list<i32> {
    // Case 1: [] -> []
    ^nil:
      %empty = funlang.nil : !funlang.list<i32>
      funlang.yield %empty : !funlang.list<i32>

    // Case 2: head :: tail -> (f head) :: (map f tail)
    ^cons(%head: i32, %tail: !funlang.list<i32>):
      // f head
      %transformed = funlang.apply %f(%head) : (!funlang.closure<(i32) -> i32>, i32) -> i32

      // map f tail (recursive call)
      %mapped_tail = func.call @map(%f, %tail)
        : (!funlang.closure<(i32) -> i32>, !funlang.list<i32>) -> !funlang.list<i32>

      // transformed :: mapped_tail
      %new_list = funlang.cons %transformed, %mapped_tail
        : (i32, !funlang.list<i32>) -> !funlang.list<i32>

      funlang.yield %new_list : !funlang.list<i32>
  }

  return %result : !funlang.list<i32>
}

핵심 포인트:

  1. funlang.match: 리스트를 검사하는 control flow
  2. funlang.apply: 클로저 간접 호출 (f head)
  3. func.call @map: 재귀 호출 (named function)
  4. funlang.cons: 결과 리스트 구축
  5. Type safety: 모든 operations가 타입 정보를 유지

Lowering Stage 1: FunLang → SCF

FunLangToSCFPass가 실행되면 funlang.matchscf.if로 lowering된다:

func.func @map(%f: !funlang.closure<(i32) -> i32>,
               %lst: !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)> {
  // Extract tag: lst->tag
  %tag_ptr = llvm.getelementptr %lst[0, 0] : (!llvm.struct<(i32, ptr)>) -> !llvm.ptr
  %tag = llvm.load %tag_ptr : !llvm.ptr -> i32

  // Check if tag == 0 (Nil)
  %c0 = arith.constant 0 : i32
  %is_nil = arith.cmpi eq, %tag, %c0 : i32

  // if (is_nil) then ... else ...
  %result = scf.if %is_nil -> !llvm.struct<(i32, ptr)> {
    // Nil case: return empty list
    %nil_tag = arith.constant 0 : i32
    %null_ptr = llvm.mlir.null : !llvm.ptr
    %empty = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %empty1 = llvm.insertvalue %nil_tag, %empty[0] : !llvm.struct<(i32, ptr)>
    %empty2 = llvm.insertvalue %null_ptr, %empty1[1] : !llvm.struct<(i32, ptr)>
    scf.yield %empty2 : !llvm.struct<(i32, ptr)>
  } else {
    // Cons case: extract head and tail
    %cons_tag = arith.constant 1 : i32
    %payload_ptr = llvm.getelementptr %lst[0, 1] : (!llvm.struct<(i32, ptr)>) -> !llvm.ptr
    %payload = llvm.load %payload_ptr : !llvm.ptr -> !llvm.ptr

    // Cast payload to ConsCell: struct { head: i32, tail: list }
    %head_ptr = llvm.getelementptr %payload[0, 0] : (!llvm.ptr) -> !llvm.ptr
    %head = llvm.load %head_ptr : !llvm.ptr -> i32

    %tail_ptr = llvm.getelementptr %payload[0, 1] : (!llvm.ptr) -> !llvm.ptr
    %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>

    // Apply closure: f head
    %transformed = funlang.apply %f(%head) : (!funlang.closure<(i32) -> i32>, i32) -> i32

    // Recursive call: map f tail
    %mapped_tail = func.call @map(%f, %tail)
      : (!funlang.closure<(i32) -> i32>, !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)>

    // Build cons cell: transformed :: mapped_tail
    %cell_size = llvm.mlir.constant(16 : i64) : i64  // sizeof(ConsCell)
    %cell = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr

    %cell_head_ptr = llvm.getelementptr %cell[0, 0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %transformed, %cell_head_ptr : i32, !llvm.ptr

    %cell_tail_ptr = llvm.getelementptr %cell[0, 1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %mapped_tail, %cell_tail_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

    // Build list struct: {tag=1, payload=cell}
    %cons_tag_val = arith.constant 1 : i32
    %new_list = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %new_list1 = llvm.insertvalue %cons_tag_val, %new_list[0] : !llvm.struct<(i32, ptr)>
    %new_list2 = llvm.insertvalue %cell, %new_list1[1] : !llvm.struct<(i32, ptr)>

    scf.yield %new_list2 : !llvm.struct<(i32, ptr)>
  }

  return %result : !llvm.struct<(i32, ptr)>
}

변환 내용:

  1. funlang.matchscf.if: Binary choice (Nil vs Cons)
  2. Tag extraction: llvm.getelementptr + llvm.load로 tag field 읽기
  3. Comparison: arith.cmpi eq로 tag 검사
  4. Block arguments → loads: Cons case의 %head, %tail을 payload에서 추출
  5. GC allocation: GC_malloc으로 새 cons cell 할당

Lowering Stage 2: SCF → CF + LLVM

SCFToControlFlowPass가 실행되면 scf.ifcf.br, cf.cond_br로 lowering된다:

func.func @map(%f: !funlang.closure<(i32) -> i32>,
               %lst: !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)> {
^entry:
  // Extract tag
  %tag_ptr = llvm.getelementptr %lst[0, 0] : (!llvm.struct<(i32, ptr)>) -> !llvm.ptr
  %tag = llvm.load %tag_ptr : !llvm.ptr -> i32

  %c0 = arith.constant 0 : i32
  %is_nil = arith.cmpi eq, %tag, %c0 : i32

  // Conditional branch
  cf.cond_br %is_nil, ^nil_case, ^cons_case

^nil_case:
  // Return empty list
  %nil_tag = arith.constant 0 : i32
  %null_ptr = llvm.mlir.null : !llvm.ptr
  %empty = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
  %empty1 = llvm.insertvalue %nil_tag, %empty[0] : !llvm.struct<(i32, ptr)>
  %empty2 = llvm.insertvalue %null_ptr, %empty1[1] : !llvm.struct<(i32, ptr)>
  cf.br ^exit(%empty2 : !llvm.struct<(i32, ptr)>)

^cons_case:
  // Extract head and tail
  %payload_ptr = llvm.getelementptr %lst[0, 1] : (!llvm.struct<(i32, ptr)>) -> !llvm.ptr
  %payload = llvm.load %payload_ptr : !llvm.ptr -> !llvm.ptr

  %head_ptr = llvm.getelementptr %payload[0, 0] : (!llvm.ptr) -> !llvm.ptr
  %head = llvm.load %head_ptr : !llvm.ptr -> i32

  %tail_ptr = llvm.getelementptr %payload[0, 1] : (!llvm.ptr) -> !llvm.ptr
  %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>

  // Apply closure
  %transformed = funlang.apply %f(%head) : (!funlang.closure<(i32) -> i32>, i32) -> i32

  // Recursive call
  %mapped_tail = func.call @map(%f, %tail)
    : (!funlang.closure<(i32) -> i32>, !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)>

  // Allocate cons cell
  %cell_size = llvm.mlir.constant(16 : i64) : i64
  %cell = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr

  %cell_head_ptr = llvm.getelementptr %cell[0, 0] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %transformed, %cell_head_ptr : i32, !llvm.ptr

  %cell_tail_ptr = llvm.getelementptr %cell[0, 1] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %mapped_tail, %cell_tail_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

  // Build result
  %cons_tag = arith.constant 1 : i32
  %new_list = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
  %new_list1 = llvm.insertvalue %cons_tag, %new_list[0] : !llvm.struct<(i32, ptr)>
  %new_list2 = llvm.insertvalue %cell, %new_list1[1] : !llvm.struct<(i32, ptr)>

  cf.br ^exit(%new_list2 : !llvm.struct<(i32, ptr)>)

^exit(%result: !llvm.struct<(i32, ptr)>):
  return %result : !llvm.struct<(i32, ptr)>
}

CFG 구조:

       [entry]
          |
       (is_nil?)
        /    \
    [nil]  [cons]
       \    /
       [exit]

테스트 프로그램: map (fun x -> x * 2) [1, 2, 3]

완전한 프로그램을 컴파일하고 실행해보자:

// FunLang source
let double = fun x -> x * 2

let rec map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (map f tail)

let result = map double [1, 2, 3]
// Expected: [2, 4, 6]

Compiled MLIR (simplified):

module {
  // Helper: double function as closure implementation
  func.func @double_impl(%x: i32) -> i32 {
    %c2 = arith.constant 2 : i32
    %result = arith.muli %x, %c2 : i32
    return %result : i32
  }

  // map function (as defined above)
  func.func @map(%f: !funlang.closure<(i32) -> i32>,
                 %lst: !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)> {
    // ... (as shown in previous section)
  }

  // Main entry point
  func.func @main() -> !llvm.struct<(i32, ptr)> {
    // Create closure: double
    %double_fn = llvm.mlir.addressof @double_impl : !llvm.ptr
    %null_env = llvm.mlir.null : !llvm.ptr  // no captures
    %closure_size = llvm.mlir.constant(16 : i64) : i64
    %closure_mem = llvm.call @GC_malloc(%closure_size) : (i64) -> !llvm.ptr

    %fn_ptr_field = llvm.getelementptr %closure_mem[0, 0] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %double_fn, %fn_ptr_field : !llvm.ptr, !llvm.ptr

    %env_ptr_field = llvm.getelementptr %closure_mem[0, 1] : (!llvm.ptr) -> !llvm.ptr
    llvm.store %null_env, %env_ptr_field : !llvm.ptr, !llvm.ptr

    %double = llvm.load %closure_mem : !llvm.ptr -> !funlang.closure<(i32) -> i32>

    // Create list: [1, 2, 3]
    %c1 = arith.constant 1 : i32
    %c2 = arith.constant 2 : i32
    %c3 = arith.constant 3 : i32

    %nil = funlang.nil : !funlang.list<i32>
    %l3 = funlang.cons %c3, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>
    %l2 = funlang.cons %c2, %l3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>
    %l1 = funlang.cons %c1, %l2 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

    // Call map
    %result = func.call @map(%double, %l1)
      : (!funlang.closure<(i32) -> i32>, !funlang.list<i32>) -> !funlang.list<i32>

    return %result : !llvm.struct<(i32, ptr)>
  }
}

실행 trace:

map double [1, 2, 3]
→ double 1 :: map double [2, 3]
→ 2 :: (double 2 :: map double [3])
→ 2 :: (4 :: (double 3 :: map double []))
→ 2 :: (4 :: (6 :: []))
→ [2, 4, 6]

Memory layout (heap):

Closure (double):
  +0: fn_ptr    -> @double_impl
  +8: env_ptr   -> NULL

List [2, 4, 6]:
  +0: tag=1, payload -> ConsCell1

  ConsCell1:
    +0: head=2
    +8: tail -> {tag=1, payload -> ConsCell2}

  ConsCell2:
    +0: head=4
    +8: tail -> {tag=1, payload -> ConsCell3}

  ConsCell3:
    +0: head=6
    +8: tail -> {tag=0, payload=NULL}  // Nil

검증: JIT 실행

// Compiler.fs
let testMapDouble() =
    let ctx = MLIRContext.Create()
    let module = compileProgram ctx mapDoubleSource

    // Apply lowering passes
    let pm = PassManager.Create(ctx)
    pm.AddPass("convert-funlang-to-scf")
    pm.AddPass("convert-scf-to-cf")
    pm.AddPass("convert-funlang-to-llvm")
    pm.Run(module)

    // JIT execute
    let engine = ExecutionEngine.Create(module)
    let result = engine.Invoke("main", [||])

    // Verify result: [2, 4, 6]
    let list = result :?> ListValue
    assert (list.Count = 3)
    assert (list.[0] = 2)
    assert (list.[1] = 4)
    assert (list.[2] = 6)

    printfn "map double [1, 2, 3] = [2, 4, 6] ✓"

Output:

map double [1, 2, 3] = [2, 4, 6] ✓

성공! map 함수가 완전히 작동한다.

filter 함수: 조건부 리스트 필터링

filter의 개념

filter는 조건을 만족하는 원소만 남긴다:

// filter의 타입
filter : (a -> bool) -> [a] -> [a]

// filter의 의미
filter pred [x1, x2, ..., xn] = [xi | pred xi = true]

예제:

let is_positive x = x > 0
filter is_positive [-2, -1, 0, 1, 2]  // [1, 2]

let is_even x = x % 2 == 0
filter is_even [1, 2, 3, 4, 5, 6]  // [2, 4, 6]

filter (fun x -> x > 2) [1, 2, 3, 4]  // [3, 4]

FunLang 소스 코드

filter 함수를 FunLang으로 작성한다:

let rec filter pred lst =
  match lst with
  | [] -> []
  | head :: tail ->
      if pred head then
        head :: filter pred tail
      else
        filter pred tail

동작 원리:

  1. Base case: Empty list → return empty list
  2. Recursive case:
    • 조건 검사: pred head
    • True이면: head를 결과에 포함
    • False이면: head를 건너뛰고 tail만 재귀 처리

실행 trace:

filter (fun x -> x > 2) [1, 2, 3, 4]
→ (1 > 2)? No → filter pred [2, 3, 4]
→ (2 > 2)? No → filter pred [3, 4]
→ (3 > 2)? Yes → 3 :: filter pred [4]
→ (4 > 2)? Yes → 3 :: (4 :: filter pred [])
→ 3 :: (4 :: [])
→ [3, 4]

map vs filter 비교

특성mapfilter
타입(a -> b) -> [a] -> [b](a -> bool) -> [a] -> [a]
결과 크기Input과 동일Input 이하
조건 분기없음 (항상 변환)있음 (if-else)
원소 변환있음 (f x)없음 (원소 그대로)
MLIR 복잡도ModerateHigher (nested control flow)

컴파일된 MLIR: FunLang Dialect

// filter : (T -> i1) -> !funlang.list<T> -> !funlang.list<T>
func.func @filter(%pred: !funlang.closure<(i32) -> i1>,
                  %lst: !funlang.list<i32>) -> !funlang.list<i32> {
  // match lst with ...
  %result = funlang.match %lst : !funlang.list<i32> -> !funlang.list<i32> {
    // Case 1: [] -> []
    ^nil:
      %empty = funlang.nil : !funlang.list<i32>
      funlang.yield %empty : !funlang.list<i32>

    // Case 2: head :: tail -> if (pred head) then ... else ...
    ^cons(%head: i32, %tail: !funlang.list<i32>):
      // pred head
      %should_keep = funlang.apply %pred(%head)
        : (!funlang.closure<(i32) -> i1>, i32) -> i1

      // Recursive call (always needed)
      %filtered_tail = func.call @filter(%pred, %tail)
        : (!funlang.closure<(i32) -> i1>, !funlang.list<i32>) -> !funlang.list<i32>

      // if should_keep then head :: filtered_tail else filtered_tail
      %new_list = scf.if %should_keep -> !funlang.list<i32> {
        // Keep head
        %kept = funlang.cons %head, %filtered_tail
          : (i32, !funlang.list<i32>) -> !funlang.list<i32>
        scf.yield %kept : !funlang.list<i32>
      } else {
        // Skip head
        scf.yield %filtered_tail : !funlang.list<i32>
      }

      funlang.yield %new_list : !funlang.list<i32>
  }

  return %result : !funlang.list<i32>
}

핵심 포인트:

  1. Nested control flow: funlang.match 안에 scf.if
  2. Predicate 호출: funlang.apply %pred(%head)는 boolean 반환
  3. Conditional cons: True일 때만 funlang.cons
  4. Recursive call position: if 밖에서 호출 (항상 필요)

Nested Control Flow 분석

filter는 두 단계의 제어 흐름을 가진다:

Level 1: Pattern matching

match lst:
  Nil  → []
  Cons → [Level 2]

Level 2: Conditional inclusion

if pred head:
  True  → head :: filtered_tail
  False → filtered_tail

Combined CFG:

        [entry]
           |
       (is_nil?)
        /    \
    [nil]  [cons]
       |      |
       |   (pred head?)
       |    /      \
       | [keep]  [skip]
       |    \      /
       |   [merge]
        \    /
        [exit]

Lowering Stage 1: FunLang → SCF

func.func @filter(%pred: !funlang.closure<(i32) -> i1>,
                  %lst: !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)> {
  // Extract tag
  %tag_ptr = llvm.getelementptr %lst[0, 0] : (!llvm.struct<(i32, ptr)>) -> !llvm.ptr
  %tag = llvm.load %tag_ptr : !llvm.ptr -> i32

  %c0 = arith.constant 0 : i32
  %is_nil = arith.cmpi eq, %tag, %c0 : i32

  // Level 1: match
  %result = scf.if %is_nil -> !llvm.struct<(i32, ptr)> {
    // Nil case
    %nil_tag = arith.constant 0 : i32
    %null_ptr = llvm.mlir.null : !llvm.ptr
    %empty = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
    %empty1 = llvm.insertvalue %nil_tag, %empty[0] : !llvm.struct<(i32, ptr)>
    %empty2 = llvm.insertvalue %null_ptr, %empty1[1] : !llvm.struct<(i32, ptr)>
    scf.yield %empty2 : !llvm.struct<(i32, ptr)>
  } else {
    // Cons case: extract head and tail
    %payload_ptr = llvm.getelementptr %lst[0, 1] : (!llvm.struct<(i32, ptr)>) -> !llvm.ptr
    %payload = llvm.load %payload_ptr : !llvm.ptr -> !llvm.ptr

    %head_ptr = llvm.getelementptr %payload[0, 0] : (!llvm.ptr) -> !llvm.ptr
    %head = llvm.load %head_ptr : !llvm.ptr -> i32

    %tail_ptr = llvm.getelementptr %payload[0, 1] : (!llvm.ptr) -> !llvm.ptr
    %tail = llvm.load %tail_ptr : !llvm.ptr -> !llvm.struct<(i32, ptr)>

    // Apply predicate
    %should_keep = funlang.apply %pred(%head)
      : (!funlang.closure<(i32) -> i1>, i32) -> i1

    // Recursive call
    %filtered_tail = func.call @filter(%pred, %tail)
      : (!funlang.closure<(i32) -> i1>, !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)>

    // Level 2: if pred
    %new_list = scf.if %should_keep -> !llvm.struct<(i32, ptr)> {
      // Keep: allocate cons cell
      %cell_size = llvm.mlir.constant(16 : i64) : i64
      %cell = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr

      %cell_head_ptr = llvm.getelementptr %cell[0, 0] : (!llvm.ptr) -> !llvm.ptr
      llvm.store %head, %cell_head_ptr : i32, !llvm.ptr

      %cell_tail_ptr = llvm.getelementptr %cell[0, 1] : (!llvm.ptr) -> !llvm.ptr
      llvm.store %filtered_tail, %cell_tail_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

      %cons_tag = arith.constant 1 : i32
      %kept = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
      %kept1 = llvm.insertvalue %cons_tag, %kept[0] : !llvm.struct<(i32, ptr)>
      %kept2 = llvm.insertvalue %cell, %kept1[1] : !llvm.struct<(i32, ptr)>

      scf.yield %kept2 : !llvm.struct<(i32, ptr)>
    } else {
      // Skip: return filtered_tail directly
      scf.yield %filtered_tail : !llvm.struct<(i32, ptr)>
    }

    scf.yield %new_list : !llvm.struct<(i32, ptr)>
  }

  return %result : !llvm.struct<(i32, ptr)>
}

Nested scf.if analysis:

  1. Outer if: 리스트가 empty인지 검사
  2. Inner if: Head를 keep할지 skip할지 결정
  3. Region nesting: Inner if는 outer if의 else branch 안에 있다
  4. Type consistency: 모든 branch가 같은 타입 반환

테스트 프로그램: filter (fun x -> x > 2) [1, 2, 3, 4]

// FunLang source
let is_greater_than_2 = fun x -> x > 2

let rec filter pred lst =
  match lst with
  | [] -> []
  | head :: tail ->
      if pred head then
        head :: filter pred tail
      else
        filter pred tail

let result = filter is_greater_than_2 [1, 2, 3, 4]
// Expected: [3, 4]

Compiled MLIR (main function):

func.func @main() -> !llvm.struct<(i32, ptr)> {
  // Create predicate closure: fun x -> x > 2
  %pred_fn = llvm.mlir.addressof @is_greater_than_2_impl : !llvm.ptr
  %null_env = llvm.mlir.null : !llvm.ptr
  %closure_size = llvm.mlir.constant(16 : i64) : i64
  %closure_mem = llvm.call @GC_malloc(%closure_size) : (i64) -> !llvm.ptr

  %fn_ptr_field = llvm.getelementptr %closure_mem[0, 0] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %pred_fn, %fn_ptr_field : !llvm.ptr, !llvm.ptr

  %env_ptr_field = llvm.getelementptr %closure_mem[0, 1] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %null_env, %env_ptr_field : !llvm.ptr, !llvm.ptr

  %pred = llvm.load %closure_mem : !llvm.ptr -> !funlang.closure<(i32) -> i1>

  // Create list: [1, 2, 3, 4]
  %c1 = arith.constant 1 : i32
  %c2 = arith.constant 2 : i32
  %c3 = arith.constant 3 : i32
  %c4 = arith.constant 4 : i32

  %nil = funlang.nil : !funlang.list<i32>
  %l4 = funlang.cons %c4, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  %l3 = funlang.cons %c3, %l4 : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  %l2 = funlang.cons %c2, %l3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  %l1 = funlang.cons %c1, %l2 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  // Call filter
  %result = func.call @filter(%pred, %l1)
    : (!funlang.closure<(i32) -> i1>, !funlang.list<i32>) -> !funlang.list<i32>

  return %result : !llvm.struct<(i32, ptr)>
}

// Predicate implementation
func.func @is_greater_than_2_impl(%x: i32) -> i1 {
  %c2 = arith.constant 2 : i32
  %result = arith.cmpi sgt, %x, %c2 : i32
  return %result : i1
}

실행 trace:

filter pred [1, 2, 3, 4]
→ (1 > 2)? No → filter pred [2, 3, 4]
→ (2 > 2)? No → filter pred [3, 4]
→ (3 > 2)? Yes → 3 :: filter pred [4]
→ (4 > 2)? Yes → 3 :: (4 :: filter pred [])
→ 3 :: (4 :: [])
→ [3, 4]

검증:

let testFilterGreaterThan2() =
    let ctx = MLIRContext.Create()
    let module = compileProgram ctx filterSource

    let pm = PassManager.Create(ctx)
    pm.AddPass("convert-funlang-to-scf")
    pm.AddPass("convert-scf-to-cf")
    pm.AddPass("convert-funlang-to-llvm")
    pm.Run(module)

    let engine = ExecutionEngine.Create(module)
    let result = engine.Invoke("main", [||])

    let list = result :?> ListValue
    assert (list.Count = 2)
    assert (list.[0] = 3)
    assert (list.[1] = 4)

    printfn "filter (fun x -> x > 2) [1, 2, 3, 4] = [3, 4] ✓"

Output:

filter (fun x -> x > 2) [1, 2, 3, 4] = [3, 4] ✓

성공! filter 함수도 완전히 작동한다.

Helper 함수: length와 append

mapfilter 외에도 유용한 리스트 함수가 많다. 두 가지 기본 helper를 구현한다.

length 함수

FunLang 소스:

let rec length lst =
  match lst with
  | [] -> 0
  | head :: tail -> 1 + length tail

타입: [a] -> int

예제:

length []           // 0
length [1]          // 1
length [1, 2, 3]    // 3

Compiled MLIR:

func.func @length(%lst: !funlang.list<i32>) -> i32 {
  %result = funlang.match %lst : !funlang.list<i32> -> i32 {
    ^nil:
      %zero = arith.constant 0 : i32
      funlang.yield %zero : i32

    ^cons(%head: i32, %tail: !funlang.list<i32>):
      %tail_len = func.call @length(%tail) : (!funlang.list<i32>) -> i32
      %one = arith.constant 1 : i32
      %len = arith.addi %one, %tail_len : i32
      funlang.yield %len : i32
  }
  return %result : i32
}

특징:

  • head 값은 무시 (타입만 필요)
  • 재귀 호출로 tail length 계산
  • 결과: 1 + tail_length

append 함수

FunLang 소스:

let rec append xs ys =
  match xs with
  | [] -> ys
  | head :: tail -> head :: (append tail ys)

타입: [a] -> [a] -> [a]

예제:

append [] [1, 2]         // [1, 2]
append [1, 2] []         // [1, 2]
append [1, 2] [3, 4]     // [1, 2, 3, 4]

Compiled MLIR:

func.func @append(%xs: !funlang.list<i32>,
                  %ys: !funlang.list<i32>) -> !funlang.list<i32> {
  %result = funlang.match %xs : !funlang.list<i32> -> !funlang.list<i32> {
    ^nil:
      // Base case: [] ++ ys = ys
      funlang.yield %ys : !funlang.list<i32>

    ^cons(%head: i32, %tail: !funlang.list<i32>):
      // Recursive case: (h :: t) ++ ys = h :: (t ++ ys)
      %appended = func.call @append(%tail, %ys)
        : (!funlang.list<i32>, !funlang.list<i32>) -> !funlang.list<i32>
      %new_list = funlang.cons %head, %appended
        : (i32, !funlang.list<i32>) -> !funlang.list<i32>
      funlang.yield %new_list : !funlang.list<i32>
  }
  return %result : !funlang.list<i32>
}

특징:

  • Base case: 첫 번째 리스트가 empty이면 두 번째 리스트 반환
  • Recursive case: 첫 번째 리스트의 head를 보존하고 tail 재귀 처리
  • 시간 복잡도: O(|xs|) - 첫 번째 리스트 길이에 비례

실행 trace:

append [1, 2] [3, 4]
→ 1 :: append [2] [3, 4]
→ 1 :: (2 :: append [] [3, 4])
→ 1 :: (2 :: [3, 4])
→ [1, 2, 3, 4]

테스트: Helper 함수

let testHelpers() =
    // Test length
    let len1 = length []            // 0
    let len2 = length [1]           // 1
    let len3 = length [1, 2, 3]     // 3

    assert (len1 = 0)
    assert (len2 = 1)
    assert (len3 = 3)
    printfn "length tests passed ✓"

    // Test append
    let app1 = append [] [1, 2]         // [1, 2]
    let app2 = append [1, 2] []         // [1, 2]
    let app3 = append [1, 2] [3, 4]     // [1, 2, 3, 4]

    assert (listEqual app1 [1, 2])
    assert (listEqual app2 [1, 2])
    assert (listEqual app3 [1, 2, 3, 4])
    printfn "append tests passed ✓"

Output:

length tests passed ✓
append tests passed ✓

이제 우리는 기본 함수형 프로그래밍 toolkit을 갖췄다:

  • map: 변환
  • filter: 필터링
  • length: 크기 측정
  • append: 결합

다음 섹션에서는 가장 강력한 combinator인 **fold**를 구현한다.

fold 함수: 일반적인 리스트 Combinator

fold의 개념

fold (또는 reduce)는 리스트를 하나의 값으로 축약하는 가장 일반적인 combinator다:

// fold의 타입
fold : (acc -> a -> acc) -> acc -> [a] -> acc

// fold의 의미
fold f acc [x1, x2, ..., xn] = f (... (f (f acc x1) x2) ...) xn

fold는 모든 리스트 연산의 기초다:

// sum: 모든 원소의 합
let sum lst = fold (+) 0 lst
sum [1, 2, 3, 4, 5]  // 15

// product: 모든 원소의 곱
let product lst = fold (*) 1 lst
product [1, 2, 3, 4]  // 24

// length: map과 filter도 fold로 구현 가능
let length lst = fold (fun acc _ -> acc + 1) 0 lst
length [1, 2, 3]  // 3

왜 fold가 가장 강력한가?

함수fold로 구현 가능?예제
sumfold (+) 0
productfold (*) 1
lengthfold (fun acc _ -> acc + 1) 0
mapfold (fun acc x -> acc ++ [f x]) []
filterfold (fun acc x -> if p x then acc ++ [x] else acc) []
reversefold (fun acc x -> x :: acc) []

fold는 universal list combinator다. 다른 모든 리스트 함수를 fold로 표현할 수 있다.

FunLang 소스 코드

fold 함수를 FunLang으로 작성한다:

let rec fold f acc lst =
  match lst with
  | [] -> acc
  | head :: tail -> fold f (f acc head) tail

동작 원리:

  1. Base case: Empty list → return accumulator (결과)
  2. Recursive case:
    • Apply f to acc and head → new accumulator
    • Recursively fold over tail with new accumulator

실행 trace:

fold (+) 0 [1, 2, 3, 4, 5]
→ fold (+) (0 + 1) [2, 3, 4, 5]
→ fold (+) 1 [2, 3, 4, 5]
→ fold (+) (1 + 2) [3, 4, 5]
→ fold (+) 3 [3, 4, 5]
→ fold (+) (3 + 3) [4, 5]
→ fold (+) 6 [4, 5]
→ fold (+) (6 + 4) [5]
→ fold (+) 10 [5]
→ fold (+) (10 + 5) []
→ fold (+) 15 []
→ 15

Accumulator 패턴:

Accumulator는 중간 결과를 저장하는 변수다:

  • 초기값: acc = 0 (sum의 경우)
  • 갱신: acc = f acc head (각 원소마다 업데이트)
  • 최종값: 리스트가 empty일 때 accumulator 반환

fold vs map/filter 비교

특성mapfilterfold
타입(a -> b) -> [a] -> [b](a -> bool) -> [a] -> [a](acc -> a -> acc) -> acc -> [a] -> acc
입력리스트리스트리스트 + 초기값
출력리스트리스트단일 값
함수 인자1개 (원소)1개 (원소)2개 (누적값, 원소)
일반성특수특수일반 (map/filter 구현 가능)

컴파일된 MLIR: FunLang Dialect

// fold : (acc -> T -> acc) -> acc -> !funlang.list<T> -> acc
func.func @fold(%f: !funlang.closure<(i32, i32) -> i32>,
                %acc: i32,
                %lst: !funlang.list<i32>) -> i32 {
  // match lst with ...
  %result = funlang.match %lst : !funlang.list<i32> -> i32 {
    // Case 1: [] -> acc
    ^nil:
      funlang.yield %acc : i32

    // Case 2: head :: tail -> fold f (f acc head) tail
    ^cons(%head: i32, %tail: !funlang.list<i32>):
      // f acc head
      %new_acc = funlang.apply %f(%acc, %head)
        : (!funlang.closure<(i32, i32) -> i32>, i32, i32) -> i32

      // fold f new_acc tail (tail recursion!)
      %final = func.call @fold(%f, %new_acc, %tail)
        : (!funlang.closure<(i32, i32) -> i32>, i32, !funlang.list<i32>) -> i32

      funlang.yield %final : i32
  }

  return %result : i32
}

핵심 포인트:

  1. Three arguments: 클로저 f, 누적값 acc, 리스트 lst
  2. Binary closure: f는 두 인자 (acc, head)를 받는다
  3. Tail recursion: 재귀 호출이 함수의 마지막 operation (최적화 가능!)
  4. Accumulator threading: accnew_accfinal로 흐름

Tail Recursion 분석

foldtail recursive다:

// Tail recursive (good)
let rec fold f acc lst =
  match lst with
  | [] -> acc
  | head :: tail -> fold f (f acc head) tail
  // ^^^ Recursive call is the LAST operation

// NOT tail recursive (map, filter)
let rec map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (map f tail)
  // ^^^ Recursive call is NOT the last (cons follows)

Tail recursion의 장점:

  1. Stack frame 재사용: 각 재귀 호출이 새 stack frame을 생성하지 않음
  2. 메모리 효율: O(1) stack space (vs O(n) for non-tail)
  3. 컴파일러 최적화: Loop로 변환 가능

LLVM optimization pass가 tail call을 감지하면:

// Before optimization (recursive)
%result = func.call @fold(%f, %new_acc, %tail) : (...) -> i32

// After optimization (loop)
// Stack frame 재사용, jump로 변환

Common Fold Patterns

1. Sum (합계)

let sum lst = fold (fun acc x -> acc + x) 0 lst
// Or simply: fold (+) 0 lst

sum [1, 2, 3, 4, 5]  // 15

Compiled MLIR:

func.func @sum(%lst: !funlang.list<i32>) -> i32 {
  // Create add closure
  %add = funlang.closure @add_impl() : () -> ((i32, i32) -> i32)

  // Initial accumulator
  %zero = arith.constant 0 : i32

  // Call fold
  %result = func.call @fold(%add, %zero, %lst)
    : (!funlang.closure<(i32, i32) -> i32>, i32, !funlang.list<i32>) -> i32

  return %result : i32
}

func.func @add_impl(%acc: i32, %x: i32) -> i32 {
  %result = arith.addi %acc, %x : i32
  return %result : i32
}

2. Product (곱셈)

let product lst = fold (*) 1 lst

product [1, 2, 3, 4]  // 24

3. Length (길이)

let length lst = fold (fun acc _ -> acc + 1) 0 lst

length [1, 2, 3]  // 3

이전에 재귀로 구현한 length와 같은 결과지만, fold를 사용하면 더 일반적이다.

4. Reverse (역순)

let reverse lst = fold (fun acc x -> x :: acc) [] lst

reverse [1, 2, 3]  // [3, 2, 1]

Trace:

fold cons [] [1, 2, 3]
→ fold cons (1 :: []) [2, 3]
→ fold cons [1] [2, 3]
→ fold cons (2 :: [1]) [3]
→ fold cons [2, 1] [3]
→ fold cons (3 :: [2, 1]) []
→ fold cons [3, 2, 1] []
→ [3, 2, 1]

5. Maximum (최댓값)

let max_list lst =
  match lst with
  | [] -> error "empty list"
  | head :: tail -> fold (fun acc x -> if x > acc then x else acc) head tail

max_list [3, 1, 4, 1, 5, 9, 2]  // 9

테스트 프로그램: fold (+) 0 [1, 2, 3, 4, 5]

// FunLang source
let add = fun acc x -> acc + x

let rec fold f acc lst =
  match lst with
  | [] -> acc
  | head :: tail -> fold f (f acc head) tail

let result = fold add 0 [1, 2, 3, 4, 5]
// Expected: 15

Compiled MLIR (main function):

func.func @main() -> i32 {
  // Create add closure
  %add_fn = llvm.mlir.addressof @add_impl : !llvm.ptr
  %null_env = llvm.mlir.null : !llvm.ptr
  %closure_size = llvm.mlir.constant(16 : i64) : i64
  %closure_mem = llvm.call @GC_malloc(%closure_size) : (i64) -> !llvm.ptr

  %fn_ptr_field = llvm.getelementptr %closure_mem[0, 0] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %add_fn, %fn_ptr_field : !llvm.ptr, !llvm.ptr

  %env_ptr_field = llvm.getelementptr %closure_mem[0, 1] : (!llvm.ptr) -> !llvm.ptr
  llvm.store %null_env, %env_ptr_field : !llvm.ptr, !llvm.ptr

  %add = llvm.load %closure_mem : !llvm.ptr -> !funlang.closure<(i32, i32) -> i32>

  // Initial accumulator
  %zero = arith.constant 0 : i32

  // Create list: [1, 2, 3, 4, 5]
  %c1 = arith.constant 1 : i32
  %c2 = arith.constant 2 : i32
  %c3 = arith.constant 3 : i32
  %c4 = arith.constant 4 : i32
  %c5 = arith.constant 5 : i32

  %nil = funlang.nil : !funlang.list<i32>
  %l5 = funlang.cons %c5, %nil : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  %l4 = funlang.cons %c4, %l5 : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  %l3 = funlang.cons %c3, %l4 : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  %l2 = funlang.cons %c2, %l3 : (i32, !funlang.list<i32>) -> !funlang.list<i32>
  %l1 = funlang.cons %c1, %l2 : (i32, !funlang.list<i32>) -> !funlang.list<i32>

  // Call fold
  %result = func.call @fold(%add, %zero, %l1)
    : (!funlang.closure<(i32, i32) -> i32>, i32, !funlang.list<i32>) -> i32

  return %result : i32
}

func.func @add_impl(%acc: i32, %x: i32) -> i32 {
  %result = arith.addi %acc, %x : i32
  return %result : i32
}

검증:

let testFoldSum() =
    let ctx = MLIRContext.Create()
    let module = compileProgram ctx foldSumSource

    let pm = PassManager.Create(ctx)
    pm.AddPass("convert-funlang-to-scf")
    pm.AddPass("convert-scf-to-cf")
    pm.AddPass("convert-funlang-to-llvm")
    pm.Run(module)

    let engine = ExecutionEngine.Create(module)
    let result = engine.Invoke("main", [||])

    assert (result = 15)
    printfn "fold (+) 0 [1, 2, 3, 4, 5] = 15 ✓"

Output:

fold (+) 0 [1, 2, 3, 4, 5] = 15 ✓

성공! fold 함수도 완전히 작동한다.

완전한 예제: Sum of Squares

이제 모든 것을 조합하여 실전 함수형 프로그램을 작성한다.

문제 정의

주어진 숫자 리스트의 제곱의 합을 계산한다:

sum_of_squares [1, 2, 3] = 1² + 2² + 3² = 1 + 4 + 9 = 14

FunLang 소스 코드

// Helper: square function
let square = fun x -> x * x

// Helper: add function
let add = fun acc x -> acc + x

// map: transform each element
let rec map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (map f tail)

// fold: reduce to single value
let rec fold f acc lst =
  match lst with
  | [] -> acc
  | head :: tail -> fold f (f acc head) tail

// Composition: sum of squares
let sum_of_squares lst =
  fold add 0 (map square lst)

// Test
let result = sum_of_squares [1, 2, 3]
// Expected: 14

함수 조합 분석:

[1, 2, 3]
  ↓ map square
[1, 4, 9]
  ↓ fold add 0
14

이것이 바로 함수형 프로그래밍의 핵심이다:

  • 작은 함수들 (square, add, map, fold)
  • 조합하여 복잡한 동작 (sum_of_squares)
  • 선언적 스타일: “무엇을” 계산할지 명확

전체 컴파일 파이프라인 (9 단계)

이 프로그램을 end-to-end로 컴파일하는 과정을 모두 추적한다.

Stage 1: FunLang Source (사용자 작성)

let sum_of_squares lst =
  fold add 0 (map square lst)

Stage 2: FunLang AST (Parser 출력)

Let("sum_of_squares",
    Fun("lst",
        App(App(App(Var "fold", Var "add"),
                Int 0),
            App(App(Var "map", Var "square"),
                Var "lst"))),
    ...)

Stage 3: FunLang MLIR (Compiler.fs 출력)

func.func @sum_of_squares(%lst: !funlang.list<i32>) -> i32 {
  // square closure (defined elsewhere)
  %square = ... : !funlang.closure<(i32) -> i32>

  // add closure (defined elsewhere)
  %add = ... : !funlang.closure<(i32, i32) -> i32>

  // map square lst
  %squared_list = func.call @map(%square, %lst)
    : (!funlang.closure<(i32) -> i32>, !funlang.list<i32>) -> !funlang.list<i32>

  // fold add 0 squared_list
  %zero = arith.constant 0 : i32
  %result = func.call @fold(%add, %zero, %squared_list)
    : (!funlang.closure<(i32, i32) -> i32>, i32, !funlang.list<i32>) -> i32

  return %result : i32
}

Stage 4: FunLang → SCF Lowering (FunLangToSCFPass)

funlang.match operations이 scf.if로 변환된다:

// @map function (simplified)
func.func @map(...) -> ... {
  %is_nil = ... : i1
  %result = scf.if %is_nil -> ... {
    // Nil case
    scf.yield %empty : ...
  } else {
    // Cons case
    %transformed = funlang.apply %f(%head) : ...
    %mapped_tail = func.call @map(...) : ...
    %new_list = funlang.cons %transformed, %mapped_tail : ...
    scf.yield %new_list : ...
  }
  return %result : ...
}

Stage 5: FunLang Ops → LLVM (FunLangToLLVMPass)

funlang.cons, funlang.nil, funlang.apply 등이 LLVM operations로 변환:

// funlang.cons lowering
%cell_size = llvm.mlir.constant(16 : i64) : i64
%cell = llvm.call @GC_malloc(%cell_size) : (i64) -> !llvm.ptr
%head_ptr = llvm.getelementptr %cell[0, 0] : (!llvm.ptr) -> !llvm.ptr
llvm.store %head, %head_ptr : i32, !llvm.ptr
%tail_ptr = llvm.getelementptr %cell[0, 1] : (!llvm.ptr) -> !llvm.ptr
llvm.store %tail, %tail_ptr : !llvm.struct<(i32, ptr)>, !llvm.ptr

%cons_tag = arith.constant 1 : i32
%list = llvm.mlir.undef : !llvm.struct<(i32, ptr)>
%list1 = llvm.insertvalue %cons_tag, %list[0] : !llvm.struct<(i32, ptr)>
%list2 = llvm.insertvalue %cell, %list1[1] : !llvm.struct<(i32, ptr)>

Stage 6: SCF → CF Lowering (SCFToControlFlowPass)

scf.ifcf.cond_br, cf.br:

func.func @map(...) -> ... {
^entry:
  %is_nil = ... : i1
  cf.cond_br %is_nil, ^nil_case, ^cons_case

^nil_case:
  %empty = ...
  cf.br ^exit(%empty : ...)

^cons_case:
  %transformed = ...
  %mapped_tail = func.call @map(...) : ...
  %new_list = ...
  cf.br ^exit(%new_list : ...)

^exit(%result: ...):
  return %result : ...
}

Stage 7: Func → LLVM (ConvertFuncToLLVMPass)

func.funcllvm.func, func.callllvm.call:

llvm.func @map(%f: !llvm.ptr, %lst: !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)> {
  ...
  %result = llvm.call @map(%f, %tail) : (!llvm.ptr, !llvm.struct<(i32, ptr)>) -> !llvm.struct<(i32, ptr)>
  ...
}

Stage 8: LLVM Dialect → LLVM IR (Translate to LLVM IR)

MLIR LLVM dialect를 실제 LLVM IR로 변환:

define { i32, i8* } @map({ i8*, i8* }* %f, { i32, i8* } %lst) {
entry:
  %0 = extractvalue { i32, i8* } %lst, 0
  %1 = icmp eq i32 %0, 0
  br i1 %1, label %nil_case, label %cons_case

nil_case:
  %2 = insertvalue { i32, i8* } undef, i32 0, 0
  %3 = insertvalue { i32, i8* } %2, i8* null, 1
  br label %exit

cons_case:
  %4 = extractvalue { i32, i8* } %lst, 1
  %5 = bitcast i8* %4 to { i32, { i32, i8* } }*
  %6 = getelementptr { i32, { i32, i8* } }, { i32, { i32, i8* } }* %5, i32 0, i32 0
  %7 = load i32, i32* %6
  %8 = getelementptr { i32, { i32, i8* } }, { i32, { i32, i8* } }* %5, i32 0, i32 1
  %9 = load { i32, i8* }, { i32, i8* }* %8
  ; ... (apply closure, recursive call, cons)
  br label %exit

exit:
  %result = phi { i32, i8* } [ %3, %nil_case ], [ %new_list, %cons_case ]
  ret { i32, i8* } %result
}

Stage 9: LLVM IR → Machine Code (JIT 또는 AOT)

LLVM backend가 target architecture의 machine code 생성:

; x86-64 assembly (simplified)
map:
    push    rbp
    mov     rbp, rsp
    ; Extract tag
    mov     eax, dword ptr [rsi]
    test    eax, eax
    je      .LBB0_1        ; Nil case
    ; Cons case
    mov     rdi, qword ptr [rsi + 8]
    mov     ecx, dword ptr [rdi]     ; head
    mov     rsi, qword ptr [rdi + 8]  ; tail
    ; ... (apply f, recursive call)
    jmp     .LBB0_2
.LBB0_1:
    ; Return empty list
    xor     eax, eax
    xor     edx, edx
.LBB0_2:
    pop     rbp
    ret

실행 및 검증

let testSumOfSquares() =
    let ctx = MLIRContext.Create()
    let module = compileProgram ctx sumOfSquaresSource

    // Apply all passes
    let pm = PassManager.Create(ctx)
    pm.AddPass("convert-funlang-to-scf")
    pm.AddPass("convert-scf-to-cf")
    pm.AddPass("convert-funlang-to-llvm")
    pm.AddPass("convert-func-to-llvm")
    pm.Run(module)

    // JIT compile and execute
    let engine = ExecutionEngine.Create(module)
    let result = engine.Invoke("main", [||])

    // Verify
    assert (result = 14)
    printfn "sum_of_squares [1, 2, 3] = 14 ✓"

    // Detailed trace
    printfn "Pipeline trace:"
    printfn "  [1, 2, 3]"
    printfn "  → map square"
    printfn "  [1, 4, 9]"
    printfn "  → fold add 0"
    printfn "  14 ✓"

Output:

sum_of_squares [1, 2, 3] = 14 ✓
Pipeline trace:
  [1, 2, 3]
  → map square
  [1, 4, 9]
  → fold add 0
  14 ✓

완전한 컴파일러가 작동한다!

9단계의 변환을 거쳐 FunLang 소스 코드가 실행 가능한 machine code가 되었다.

성능 고려사항

Stack Usage in Recursive List Functions

리스트 함수는 재귀적이므로 stack 사용량이 중요하다.

Stack depth by function:

함수Stack depth이유
mapO(n)Non-tail recursive (cons 후에 return)
filterO(n)Non-tail recursive (cons 후에 return)
foldO(1)Tail recursive (최적화 가능)
lengthO(n)Non-tail recursive
appendO(n)Non-tail recursive

Non-tail recursion example (map):

let rec map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (map f tail)
  // ^^^ Cons operation AFTER recursive call
  // Stack frame must be preserved until map returns

Call stack for map square [1, 2, 3]:

map [1, 2, 3]
  map [2, 3]
    map [3]
      map []
      return []
    cons 9 []
    return [9]
  cons 4 [9]
  return [4, 9]
cons 1 [4, 9]
return [1, 4, 9]

각 frame은 다음을 저장해야 한다:

  • Return address
  • head value (cons를 위해)
  • tail pointer

Tail recursion example (fold):

let rec fold f acc lst =
  match lst with
  | [] -> acc
  | head :: tail -> fold f (f acc head) tail
  // ^^^ Recursive call is LAST operation
  // Stack frame can be REUSED

Call stack for fold add 0 [1, 2, 3]:

fold 0 [1, 2, 3]
fold 1 [2, 3]      // Same stack frame, acc updated
fold 3 [3]         // Same stack frame, acc updated
fold 6 []          // Same stack frame, acc updated
return 6

Only ONE stack frame!

Tail Call Optimization (TCO)

LLVM은 tail call을 감지하여 최적화할 수 있다.

Before TCO:

define i32 @fold(...) {
  ; ...
  %new_acc = add i32 %acc, %head
  %result = call i32 @fold(..., %new_acc, %tail)
  ret i32 %result
}

After TCO:

define i32 @fold(...) {
entry:
  br label %loop

loop:
  ; ...
  %new_acc = add i32 %acc, %head
  ; Update arguments and jump (no new stack frame)
  br label %loop
}

TCO 활성화:

// PassManager.fs
let pm = PassManager.Create(ctx)

// Add standard LLVM optimization passes
pm.AddPass("inline")              // Inline small functions
pm.AddPass("simplifycfg")         // Simplify control flow
pm.AddPass("tailcallelim")        // Tail call elimination
pm.AddPass("mem2reg")             // Promote memory to registers
pm.Run(module)

결과:

  • fold는 loop로 변환되어 O(1) stack 사용
  • 큰 리스트 (100,000+ elements)도 stack overflow 없이 처리 가능

GC Pressure

리스트 연산은 많은 메모리를 할당한다.

Allocation counts:

// Create list [1, 2, 3]
// - 3 cons cells = 3 * 16 bytes = 48 bytes

// map square [1, 2, 3]
// - Input: 3 cells (48 bytes)
// - Output: 3 NEW cells (48 bytes)
// - Total alive: 96 bytes (both lists live)

// fold add 0 (map square [1, 2, 3])
// - Input: 3 cells (48 bytes) from map
// - Output: i32 (4 bytes) - no new list!
// - GC can collect input list after fold

Allocation pattern by function:

함수할당량설명
mapO(n) cons cells새 리스트 생성
filterO(k) cons cells (k ≤ n)조건 만족하는 원소만
foldO(1)단일 값만 반환
appendO(n) cons cells첫 번째 리스트 복사

GC optimization:

// BAD: 중간 리스트가 메모리에 남는다
let result1 = map f1 lst
let result2 = map f2 result1
let result3 = map f3 result2
// result1, result2, result3 모두 메모리에 존재

// GOOD: Fusion으로 중간 리스트 제거 (Phase 7에서 다룸)
let result = map (f3 << f2 << f1) lst
// 단일 pass, 중간 리스트 없음

Phase 7 Preview: Optimization Opportunities

Phase 7에서 다룰 최적화:

1. List Fusion

// Before: 두 번 순회
map f (map g lst)

// After fusion: 한 번만 순회
map (f << g) lst

2. Deforestation

// Before: 중간 리스트 생성
fold h z (map f lst)

// After deforestation: 직접 계산
fold (fun acc x -> h acc (f x)) z lst

3. Tail Recursion Modulo Cons

// map을 tail recursive로 변환
let map f lst =
  let rec loop acc lst =
    match lst with
    | [] -> reverse acc
    | head :: tail -> loop ((f head) :: acc) tail
  loop [] lst

4. Parallel Map

큰 리스트에 대해 map을 병렬화:

// Sequential
%result = scf.for %i = 0 to %n step 1 iter_args(%acc = %init) -> ... {
  %elem = load %lst[%i]
  %transformed = apply %f(%elem)
  ...
}

// Parallel (MLIR scf.parallel)
scf.parallel (%i) = (0) to (%n) step (1) {
  %elem = load %lst[%i]
  %transformed = apply %f(%elem)
  store %transformed, %result[%i]
}

이러한 최적화는 Phase 7에서 MLIR transformation passes로 구현할 것이다.

완전한 컴파일러 통합

이제 모든 것을 통합하여 완전한 FunLang 컴파일러를 구축한다.

FunLang AST Type Extensions

최종 AST 정의:

// Ast.fs
module Ast

type Expr =
    // Phase 1-2: Basics
    | Int of int
    | Float of float
    | Bool of bool
    | Var of string
    | Add of Expr * Expr
    | Sub of Expr * Expr
    | Mul of Expr * Expr
    | Div of Expr * Expr
    | Lt of Expr * Expr
    | Gt of Expr * Expr
    | Eq of Expr * Expr

    // Phase 3: Control flow and functions
    | Let of string * Expr * Expr
    | If of Expr * Expr * Expr
    | LetRec of string * Expr * Expr

    // Phase 4: Closures and higher-order functions
    | Fun of string * Expr              // lambda
    | App of Expr * Expr                // application

    // Phase 6: Lists and pattern matching
    | Nil                                // []
    | Cons of Expr * Expr                // head :: tail
    | List of Expr list                  // [1, 2, 3] (syntactic sugar)
    | Match of Expr * (Pattern * Expr) list

and Pattern =
    | PVar of string                     // x (variable binding)
    | PNil                               // [] (empty list)
    | PCons of Pattern * Pattern         // head :: tail (cons pattern)
    | PWild                              // _ (wildcard)
    | PInt of int                        // 42 (literal match)
    | PBool of bool                      // true/false

type Program = Expr

Compiler.fs: compileExpr Complete Implementation

// Compiler.fs
module Compiler

open MLIR
open Ast

let rec compileExpr (builder: OpBuilder) (expr: Expr) (symbolTable: Map<string, Value>) : Value =
    match expr with
    // Phase 1-2: Arithmetic
    | Int n ->
        let ty = builder.GetI32Type()
        builder.CreateConstantInt(ty, n)

    | Float f ->
        let ty = builder.GetF64Type()
        builder.CreateConstantFloat(ty, f)

    | Bool b ->
        let ty = builder.GetI1Type()
        builder.CreateConstantBool(ty, b)

    | Var name ->
        symbolTable.[name]

    | Add (left, right) ->
        let lhs = compileExpr builder left symbolTable
        let rhs = compileExpr builder right symbolTable
        builder.CreateAddI(lhs, rhs)

    | Mul (left, right) ->
        let lhs = compileExpr builder left symbolTable
        let rhs = compileExpr builder right symbolTable
        builder.CreateMulI(lhs, rhs)

    // ... (other arithmetic ops)

    // Phase 3: Let and If
    | Let (name, value, body) ->
        let val_result = compileExpr builder value symbolTable
        let newSymbolTable = symbolTable.Add(name, val_result)
        compileExpr builder body newSymbolTable

    | If (cond, thenExpr, elseExpr) ->
        let condVal = compileExpr builder cond symbolTable
        let resultTy = inferType thenExpr symbolTable
        builder.CreateScfIf(condVal, resultTy, fun thenBuilder ->
            let thenResult = compileExpr thenBuilder thenExpr symbolTable
            thenBuilder.CreateScfYield(thenResult)
        , fun elseBuilder ->
            let elseResult = compileExpr elseBuilder elseExpr symbolTable
            elseBuilder.CreateScfYield(elseResult)
        )

    | LetRec (name, func, body) ->
        // Create named function for recursion
        let funcName = sprintf "_%s" name
        let funcOp = compileFunctionDefinition builder funcName func symbolTable
        let funcRef = builder.CreateFuncRef(funcOp)
        let newSymbolTable = symbolTable.Add(name, funcRef)
        compileExpr builder body newSymbolTable

    // Phase 4: Closures
    | Fun (param, body) ->
        // Analyze free variables
        let freeVars = analyzeFreeVars (Fun(param, body)) symbolTable

        // Create closure implementation function
        let implName = sprintf "_lambda_%d" (freshId())
        let implFunc = createClosureImpl builder implName param body freeVars symbolTable

        // Capture environment
        let captures = freeVars |> List.map (fun v -> symbolTable.[v])

        // Create closure object
        builder.CreateClosure(implFunc, captures)

    | App (func, arg) ->
        let funcVal = compileExpr builder func symbolTable
        let argVal = compileExpr builder arg symbolTable
        builder.CreateApply(funcVal, argVal)

    // Phase 6: Lists
    | Nil ->
        let elemTy = inferElementType expr symbolTable
        let listTy = builder.GetListType(elemTy)
        builder.CreateNil(listTy)

    | Cons (head, tail) ->
        let headVal = compileExpr builder head symbolTable
        let tailVal = compileExpr builder tail symbolTable
        let headTy = headVal.GetType()
        let listTy = builder.GetListType(headTy)
        builder.CreateCons(headVal, tailVal, listTy)

    | List exprs ->
        // Desugar to nested Cons
        let desugared = desugarList exprs
        compileExpr builder desugared symbolTable

    | Match (scrutinee, cases) ->
        compileMatch builder scrutinee cases symbolTable

and compileMatch (builder: OpBuilder) (scrutinee: Expr) (cases: (Pattern * Expr) list) (symbolTable: Map<string, Value>) : Value =
    let scrutineeVal = compileExpr builder scrutinee symbolTable
    let resultTy = inferType (snd cases.[0]) symbolTable

    // Create funlang.match operation
    builder.CreateMatch(scrutineeVal, resultTy, fun matchBuilder ->
        cases |> List.map (fun (pattern, body) ->
            match pattern with
            | PNil ->
                // Nil case: no block arguments
                matchBuilder.CreateNilCase(fun caseBuilder ->
                    let result = compileExpr caseBuilder body symbolTable
                    caseBuilder.CreateYield(result)
                )

            | PCons (PVar headName, PVar tailName) ->
                // Cons case: bind head and tail
                let headTy = inferPatternType pattern symbolTable
                let listTy = builder.GetListType(headTy)
                matchBuilder.CreateConsCase(headTy, listTy, fun caseBuilder headArg tailArg ->
                    let newSymbolTable =
                        symbolTable
                            .Add(headName, headArg)
                            .Add(tailName, tailArg)
                    let result = compileExpr caseBuilder body newSymbolTable
                    caseBuilder.CreateYield(result)
                )

            | _ -> failwith "Unsupported pattern"
        )
    )

and desugarList (exprs: Expr list) : Expr =
    match exprs with
    | [] -> Nil
    | head :: tail -> Cons(head, desugarList tail)

Type Inference for List Types

리스트 타입 추론:

// TypeInfer.fs
let rec inferType (expr: Expr) (symbolTable: Map<string, Value>) : MLIRType =
    match expr with
    | Int _ -> builder.GetI32Type()
    | Float _ -> builder.GetF64Type()
    | Bool _ -> builder.GetI1Type()

    | Var name ->
        let value = symbolTable.[name]
        value.GetType()

    | Nil ->
        // Need context to infer element type
        // If context is unavailable, default to i32
        builder.GetListType(builder.GetI32Type())

    | Cons (head, tail) ->
        let headTy = inferType head symbolTable
        builder.GetListType(headTy)

    | List (head :: _) ->
        let headTy = inferType head symbolTable
        builder.GetListType(headTy)

    | Match (scrutinee, cases) ->
        // Result type is the type of first case body
        inferType (snd cases.[0]) symbolTable

    | Fun (param, body) ->
        // Function type: paramTy -> returnTy
        // Need type annotation or inference
        let paramTy = inferParamType param
        let returnTy = inferType body symbolTable
        builder.GetFunctionType(paramTy, returnTy)

    | _ -> failwith "Type inference not implemented"

End-to-End Compilation Function

// Pipeline.fs
let compileProgram (source: string) : MLIRModule =
    // 1. Parse
    let ast = Parser.parse source

    // 2. Desugar
    let desugared = Desugar.desugar ast

    // 3. Type check
    TypeChecker.check desugared

    // 4. Compile to MLIR
    let ctx = MLIRContext.Create()
    let module = MLIRModule.Create(ctx)
    let builder = OpBuilder.Create(ctx)

    let mainFunc = builder.CreateFunc("main", [], inferType desugared Map.empty, fun funcBuilder ->
        let result = Compiler.compileExpr funcBuilder desugared Map.empty
        funcBuilder.CreateReturn(result)
    )

    module.AddFunction(mainFunc)

    // 5. Apply lowering passes
    let pm = PassManager.Create(ctx)
    pm.AddPass("convert-funlang-to-scf")
    pm.AddPass("convert-scf-to-cf")
    pm.AddPass("convert-funlang-to-llvm")
    pm.AddPass("convert-func-to-llvm")
    pm.Run(module)

    module

// Execute
let execute (module: MLIRModule) : obj =
    let engine = ExecutionEngine.Create(module)
    engine.Invoke("main", [||])

// Complete pipeline
let run (source: string) : obj =
    let module = compileProgram source
    execute module

Example Usage

// Main.fs
[<EntryPoint>]
let main argv =
    let source = """
        let square = fun x -> x * x
        let add = fun acc x -> acc + x

        let rec map f lst =
          match lst with
          | [] -> []
          | head :: tail -> (f head) :: (map f tail)

        let rec fold f acc lst =
          match lst with
          | [] -> acc
          | head :: tail -> fold f (f acc head) tail

        let sum_of_squares lst =
          fold add 0 (map square lst)

        sum_of_squares [1, 2, 3]
    """

    let result = Pipeline.run source
    printfn "Result: %A" result  // Result: 14

    0

Output:

Result: 14

완전한 컴파일러가 작동한다!

Common Errors and Debugging

함수형 프로그램 작성 시 자주 발생하는 오류와 해결 방법.

1. Infinite Recursion

오류:

let rec bad_map f lst =
  match lst with
  | [] -> []
  | head :: tail -> (f head) :: (bad_map f lst)  // BUG: lst instead of tail

증상:

Stack overflow
Segmentation fault
Infinite loop

해결:

  • 재귀 호출이 “smaller” input을 사용하는지 확인
  • Base case가 반드시 도달 가능한지 확인
// Correct
| head :: tail -> (f head) :: (map f tail)  // ✓ tail is smaller

2. Type Mismatch

오류:

let bad_fold f acc lst =
  match lst with
  | [] -> 0  // BUG: should return acc, not 0
  | head :: tail -> fold f (f acc head) tail

증상:

Type error: Expected i32, found i64
Type mismatch in match branches

해결:

  • 모든 match branch가 같은 타입 반환하는지 확인
  • Accumulator 타입이 일관되는지 확인
// Correct
| [] -> acc  // ✓ Same type as recursive case

3. Wrong Accumulator Type

오류:

// Want to reverse a list
let reverse lst = fold (fun acc x -> acc :: x) [] lst  // BUG: wrong cons order

증상:

Type error: Cannot cons list to element
Expected: element :: list
Found: list :: element

해결:

  • Cons operator는 element :: list 순서
  • Accumulator 타입 확인
// Correct
let reverse lst = fold (fun acc x -> x :: acc) [] lst  // ✓ x :: acc

4. Stack Overflow

오류:

// Large list
let big_list = [1..100000]
let result = map square big_list  // Stack overflow!

증상:

Segmentation fault (core dumped)
Stack overflow at recursion depth 100000

해결:

  • Tail recursive 버전 사용
  • TCO 활성화
  • Iteration으로 변환 (Phase 7)
// Tail recursive version
let map_tailrec f lst =
  let rec loop acc lst =
    match lst with
    | [] -> reverse acc
    | head :: tail -> loop ((f head) :: acc) tail
  loop [] lst

5. Debugging Strategies

전략 1: Trace execution

let rec map f lst =
  printfn "map called with list of length %d" (length lst)
  match lst with
  | [] ->
      printfn "  -> returning []"
      []
  | head :: tail ->
      printfn "  -> transforming %A" head
      let transformed = f head
      printfn "  -> recursing on tail"
      let mapped_tail = map f tail
      printfn "  -> cons %A onto result" transformed
      transformed :: mapped_tail

전략 2: Unit tests

let test_map() =
    assert (map square [] = [])
    assert (map square [1] = [1])
    assert (map square [1, 2] = [1, 4])
    assert (map square [1, 2, 3] = [1, 4, 9])
    printfn "map tests passed ✓"

전략 3: MLIR inspection

let module = compileProgram source
printfn "%s" (module.ToString())  // Print MLIR before lowering

let pm = PassManager.Create(ctx)
pm.EnableIRPrinting()  // Print after each pass
pm.AddPass("convert-funlang-to-scf")
pm.Run(module)

전략 4: GDB debugging

# Compile with debug info
mlir-opt --debug-only=funlang-to-scf input.mlir

# Run under GDB
gdb --args mlir-opt ...
(gdb) break FunLangToSCFPass::runOnOperation
(gdb) run

리터럴 패턴 예제: fizzbuzz

지금까지 리스트에 대한 constructor pattern (Nil, Cons)을 다뤘다. 이제 리터럴 패턴을 사용하는 실전 예제를 살펴본다.

FizzBuzz 문제

FizzBuzz 규칙:

  • 3의 배수: “Fizz”
  • 5의 배수: “Buzz”
  • 15의 배수: “FizzBuzz”
  • 그 외: 숫자 그대로

FunLang 구현:

let fizzbuzz n =
    match (n % 3, n % 5) with
    | (0, 0) -> "FizzBuzz"
    | (0, _) -> "Fizz"
    | (_, 0) -> "Buzz"
    | (_, _) -> string_of_int n

패턴 분석:

Rown % 3n % 5Result
100“FizzBuzz”
20_“Fizz”
3_0“Buzz”
4__n

컴파일된 MLIR: 리터럴 패턴

func.func @fizzbuzz(%n: i32) -> !llvm.ptr<i8> {
  // Compute remainders
  %c3 = arith.constant 3 : i32
  %c5 = arith.constant 5 : i32
  %c0 = arith.constant 0 : i32

  %mod3 = arith.remsi %n, %c3 : i32
  %mod5 = arith.remsi %n, %c5 : i32

  // Pattern matching: sequential arith.cmpi chain
  %is_div3 = arith.cmpi eq, %mod3, %c0 : i32
  %result = scf.if %is_div3 -> !llvm.ptr<i8> {
    // First column is 0 (n % 3 == 0)
    %is_div5 = arith.cmpi eq, %mod5, %c0 : i32
    %inner = scf.if %is_div5 -> !llvm.ptr<i8> {
      // Case (0, 0): FizzBuzz
      scf.yield %fizzbuzz_str : !llvm.ptr<i8>
    } else {
      // Case (0, _): Fizz
      scf.yield %fizz_str : !llvm.ptr<i8>
    }
    scf.yield %inner : !llvm.ptr<i8>
  } else {
    // First column is not 0 (n % 3 != 0)
    %is_div5_2 = arith.cmpi eq, %mod5, %c0 : i32
    %inner2 = scf.if %is_div5_2 -> !llvm.ptr<i8> {
      // Case (_, 0): Buzz
      scf.yield %buzz_str : !llvm.ptr<i8>
    } else {
      // Case (_, _): n as string
      %str = func.call @int_to_string(%n) : (i32) -> !llvm.ptr<i8>
      scf.yield %str : !llvm.ptr<i8>
    }
    scf.yield %inner2 : !llvm.ptr<i8>
  }

  return %result : !llvm.ptr<i8>
}

핵심 관찰:

  1. arith.cmpi eq: 리터럴 0과의 비교
  2. Nested scf.if: Decision tree 구조
  3. Wildcard _: else branch로 fallthrough (테스트 없음)

classify 함수: 숫자 분류

숫자를 여러 카테고리로 분류하는 예제:

let classify n =
    match n with
    | 0 -> "zero"
    | 1 -> "one"
    | 2 -> "two"
    | _ -> if n < 0 then "negative" else "many"

컴파일된 MLIR:

func.func @classify(%n: i32) -> !llvm.ptr<i8> {
  %c0 = arith.constant 0 : i32
  %c1 = arith.constant 1 : i32
  %c2 = arith.constant 2 : i32

  // Sequential literal comparisons
  %is_zero = arith.cmpi eq, %n, %c0 : i32
  %result = scf.if %is_zero -> !llvm.ptr<i8> {
    scf.yield %zero_str : !llvm.ptr<i8>
  } else {
    %is_one = arith.cmpi eq, %n, %c1 : i32
    %r1 = scf.if %is_one -> !llvm.ptr<i8> {
      scf.yield %one_str : !llvm.ptr<i8>
    } else {
      %is_two = arith.cmpi eq, %n, %c2 : i32
      %r2 = scf.if %is_two -> !llvm.ptr<i8> {
        scf.yield %two_str : !llvm.ptr<i8>
      } else {
        // Default case with guard
        %is_neg = arith.cmpi slt, %n, %c0 : i32
        %r3 = scf.if %is_neg -> !llvm.ptr<i8> {
          scf.yield %negative_str : !llvm.ptr<i8>
        } else {
          scf.yield %many_str : !llvm.ptr<i8>
        }
        scf.yield %r3 : !llvm.ptr<i8>
      }
      scf.yield %r2 : !llvm.ptr<i8>
    }
    scf.yield %r1 : !llvm.ptr<i8>
  }

  return %result : !llvm.ptr<i8>
}

최적화: Dense Range Switch

리터럴이 0, 1, 2 연속일 때 scf.index_switch 최적화 가능:

// Optimized: range check + index_switch
%in_range = arith.cmpi ult, %n, %c3 : i32
%result = scf.if %in_range -> !llvm.ptr<i8> {
  %idx = arith.index_cast %n : i32 to index
  %r = scf.index_switch %idx : index -> !llvm.ptr<i8>
  case 0 { scf.yield %zero_str : !llvm.ptr<i8> }
  case 1 { scf.yield %one_str : !llvm.ptr<i8> }
  case 2 { scf.yield %two_str : !llvm.ptr<i8> }
  default { scf.yield %unreachable : !llvm.ptr<i8> }
  scf.yield %r : !llvm.ptr<i8>
} else {
  // n >= 3: check if negative
  %is_neg = arith.cmpi slt, %n, %c0 : i32
  %r2 = scf.if %is_neg -> !llvm.ptr<i8> {
    scf.yield %negative_str : !llvm.ptr<i8>
  } else {
    scf.yield %many_str : !llvm.ptr<i8>
  }
  scf.yield %r2 : !llvm.ptr<i8>
}

최적화 효과:

  • Before: O(n) sequential comparisons
  • After: O(1) jump table for dense range

Wildcard Default Case 최적화

Wildcard _는 테스트를 생성하지 않는다:

match x with
| 0 -> handle_zero()
| 1 -> handle_one()
| _ -> handle_default()  // No comparison needed!
%is_zero = arith.cmpi eq, %x, %c0 : i32
scf.if %is_zero {
  // case 0
} else {
  %is_one = arith.cmpi eq, %x, %c1 : i32
  scf.if %is_one {
    // case 1
  } else {
    // _ case: NO arith.cmpi, just fallthrough
    // All other cases exhausted, this is the default
  }
}

핵심 원칙:

  • 마지막 else branch는 이전 모든 테스트가 실패한 경우
  • 추가 비교 없이 바로 default 코드 실행
  • 이것이 wildcard의 zero-cost abstraction

리터럴 + Constructor 혼합 예제

리스트와 숫자를 함께 매칭:

let take_first_n lst n =
    match (lst, n) with
    | (_, 0) -> []
    | ([], _) -> []
    | (head :: tail, n) -> head :: take_first_n tail (n - 1)

컴파일된 MLIR:

func.func @take_first_n(%lst: !funlang.list<i32>, %n: i32) -> !funlang.list<i32> {
  %c0 = arith.constant 0 : i32
  %c1 = arith.constant 1 : i32

  // Check n == 0 first (literal pattern)
  %is_n_zero = arith.cmpi eq, %n, %c0 : i32
  %result = scf.if %is_n_zero -> !funlang.list<i32> {
    // Case (_, 0): return empty
    %empty = funlang.nil : !funlang.list<i32>
    scf.yield %empty : !funlang.list<i32>
  } else {
    // Check list constructor (constructor pattern)
    %struct = builtin.unrealized_conversion_cast %lst : ... to !llvm.struct<(i32, ptr)>
    %tag = llvm.extractvalue %struct[0] : !llvm.struct<(i32, ptr)>
    %tag_index = arith.index_cast %tag : i32 to index

    %inner = scf.index_switch %tag_index : index -> !funlang.list<i32>
    case 0 {
      // Case ([], _): return empty
      %empty = funlang.nil : !funlang.list<i32>
      scf.yield %empty : !funlang.list<i32>
    }
    case 1 {
      // Case (head :: tail, n): recursive
      %data = llvm.extractvalue %struct[1] : !llvm.struct<(i32, ptr)>
      %head = llvm.load %data : !llvm.ptr -> i32
      %tail_ptr = llvm.getelementptr %data[1] : (!llvm.ptr) -> !llvm.ptr
      %tail = llvm.load %tail_ptr : !llvm.ptr -> !funlang.list<i32>

      %n_minus_1 = arith.subi %n, %c1 : i32
      %rest = func.call @take_first_n(%tail, %n_minus_1) : (...) -> !funlang.list<i32>
      %new_list = funlang.cons %head, %rest : ...
      scf.yield %new_list : !funlang.list<i32>
    }
    default { scf.yield %unreachable : !funlang.list<i32> }

    scf.yield %inner : !funlang.list<i32>
  }

  return %result : !funlang.list<i32>
}

혼합 패턴 lowering 전략:

  1. Literal column first: arith.cmpi + scf.if
  2. Constructor column inside: scf.index_switch
  3. Wildcard: test 없이 fallthrough

검증 및 테스트

let testFizzBuzz() =
    // Test fizzbuzz
    assert (fizzbuzz 3 = "Fizz")
    assert (fizzbuzz 5 = "Buzz")
    assert (fizzbuzz 15 = "FizzBuzz")
    assert (fizzbuzz 7 = "7")
    printfn "fizzbuzz tests passed"

    // Test classify
    assert (classify 0 = "zero")
    assert (classify 1 = "one")
    assert (classify 2 = "two")
    assert (classify 42 = "many")
    assert (classify (-5) = "negative")
    printfn "classify tests passed"

    // Test take_first_n
    assert (take_first_n [1, 2, 3, 4, 5] 3 = [1, 2, 3])
    assert (take_first_n [1, 2, 3] 0 = [])
    assert (take_first_n [] 5 = [])
    printfn "take_first_n tests passed"

Output:

fizzbuzz tests passed
classify tests passed
take_first_n tests passed

Key Takeaways

  1. 리터럴 패턴: arith.cmpi eq + scf.if chain
  2. Constructor 패턴: scf.index_switch로 O(1) dispatch
  3. Wildcard: else branch로 fallthrough (테스트 없음)
  4. Dense range: scf.index_switch로 최적화 가능
  5. 혼합 패턴: 각 column의 패턴 타입에 맞는 dispatch 사용

튜플 예제: zip과 unzip (Tuple Examples: zip and unzip)

Chapter 18에서 !funlang.tuple<T1, T2, ...> 타입과 funlang.make_tuple 연산을, Chapter 19에서 튜플 패턴 매칭을 구현했다. 이제 튜플을 활용하는 실제 프로그램을 작성하고 컴파일해보자.

zip 함수: 두 리스트를 쌍의 리스트로

zip의 개념:

두 리스트를 받아 각 위치의 원소들을 튜플로 묶은 리스트를 반환한다.

// zip의 타입
zip : [a] -> [b] -> [(a, b)]

// zip의 동작
zip [1, 2, 3] ["a", "b", "c"] = [(1, "a"), (2, "b"), (3, "c")]

// 길이가 다르면 짧은 쪽에 맞춤
zip [1, 2] ["a", "b", "c"] = [(1, "a"), (2, "b")]

FunLang 구현:

let rec zip xs ys =
  match xs with
  | [] -> []
  | x :: xs' ->
      match ys with
      | [] -> []
      | y :: ys' -> make_tuple(x, y) :: zip xs' ys'

동작 원리:

  1. 첫 번째 리스트가 비어있으면 빈 리스트 반환
  2. 두 번째 리스트가 비어있으면 빈 리스트 반환
  3. 둘 다 원소가 있으면:
    • 각 head로 튜플 생성: make_tuple(x, y)
    • tail들로 재귀 호출: zip xs' ys'
    • 결과를 cons: pair :: rest

zip 함수 컴파일: FunLang MLIR

// zip : !funlang.list<i32> -> !funlang.list<f64> -> !funlang.list<!funlang.tuple<i32, f64>>
func.func @zip(%xs: !funlang.list<i32>, %ys: !funlang.list<f64>)
    -> !funlang.list<!funlang.tuple<i32, f64>> {

  // 첫 번째 리스트 패턴 매칭
  %result = funlang.match %xs : !funlang.list<i32>
      -> !funlang.list<!funlang.tuple<i32, f64>> {

    ^nil:
      // xs가 비어있으면 빈 리스트 반환
      %empty = funlang.nil : !funlang.list<!funlang.tuple<i32, f64>>
      funlang.yield %empty : !funlang.list<!funlang.tuple<i32, f64>>

    ^cons(%x: i32, %xs_tail: !funlang.list<i32>):
      // xs = x :: xs_tail, 이제 ys 패턴 매칭
      %inner = funlang.match %ys : !funlang.list<f64>
          -> !funlang.list<!funlang.tuple<i32, f64>> {

        ^nil:
          // ys가 비어있으면 빈 리스트 반환
          %empty2 = funlang.nil : !funlang.list<!funlang.tuple<i32, f64>>
          funlang.yield %empty2 : !funlang.list<!funlang.tuple<i32, f64>>

        ^cons(%y: f64, %ys_tail: !funlang.list<f64>):
          // ys = y :: ys_tail
          // 튜플 생성: (x, y)
          %pair = funlang.make_tuple(%x, %y) : !funlang.tuple<i32, f64>

          // 재귀 호출: zip xs_tail ys_tail
          %rest = func.call @zip(%xs_tail, %ys_tail)
              : (!funlang.list<i32>, !funlang.list<f64>)
              -> !funlang.list<!funlang.tuple<i32, f64>>

          // cons: pair :: rest
          %cons_result = funlang.cons %pair, %rest
              : !funlang.list<!funlang.tuple<i32, f64>>

          funlang.yield %cons_result : !funlang.list<!funlang.tuple<i32, f64>>
      }
      funlang.yield %inner : !funlang.list<!funlang.tuple<i32, f64>>
  }

  return %result : !funlang.list<!funlang.tuple<i32, f64>>
}

핵심 포인트:

  1. 중첩 패턴 매칭: 먼저 xs를 매칭하고, Cons case 안에서 ys를 매칭
  2. make_tuple 사용: funlang.make_tuple(%x, %y) 로 쌍 생성
  3. 결과 타입: !funlang.list<!funlang.tuple<i32, f64>> - 튜플의 리스트

fst와 snd 함수: 튜플 원소 추출

fst와 snd의 정의:

// 첫 번째 원소 추출
let fst pair = match pair with (x, _) -> x

// 두 번째 원소 추출
let snd pair = match pair with (_, y) -> y

MLIR 구현:

// fst : !funlang.tuple<i32, f64> -> i32
func.func @fst(%pair: !funlang.tuple<i32, f64>) -> i32 {
  %result = funlang.match %pair : !funlang.tuple<i32, f64> -> i32 {
    ^case(%x: i32, %y: f64):
      funlang.yield %x : i32
  }
  return %result : i32
}

// snd : !funlang.tuple<i32, f64> -> f64
func.func @snd(%pair: !funlang.tuple<i32, f64>) -> f64 {
  %result = funlang.match %pair : !funlang.tuple<i32, f64> -> f64 {
    ^case(%x: i32, %y: f64):
      funlang.yield %y : f64
  }
  return %result : f64
}

Lowering 결과:

// fst after lowering - 분기 없이 직접 추출
func.func @fst(%pair: !llvm.struct<(i32, f64)>) -> i32 {
  %x = llvm.extractvalue %pair[0] : !llvm.struct<(i32, f64)>
  return %x : i32
}

// snd after lowering
func.func @snd(%pair: !llvm.struct<(i32, f64)>) -> f64 {
  %y = llvm.extractvalue %pair[1] : !llvm.struct<(i32, f64)>
  return %y : f64
}

핵심:

  • 튜플 패턴 매칭은 scf.index_switch 없이 바로 extractvalue로 lowering
  • 와일드카드 _는 해당 위치의 extractvalue를 생략 (dead code elimination)

unzip 함수: 쌍의 리스트를 두 리스트로

unzip의 개념:

zip의 역연산. 튜플 리스트를 두 개의 리스트로 분리한다.

// unzip의 타입
unzip : [(a, b)] -> ([a], [b])

// unzip의 동작
unzip [(1, "a"), (2, "b")] = ([1, 2], ["a", "b"])

FunLang 구현:

let rec unzip pairs =
  match pairs with
  | [] -> ([], [])
  | p :: ps ->
      let (x, y) = p in
      let (xs, ys) = unzip ps in
      (x :: xs, y :: ys)

MLIR 구현:

// unzip : !funlang.list<!funlang.tuple<i32, f64>>
//       -> !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>>
func.func @unzip(%pairs: !funlang.list<!funlang.tuple<i32, f64>>)
    -> !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>> {

  %result = funlang.match %pairs
      : !funlang.list<!funlang.tuple<i32, f64>>
      -> !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>> {

    ^nil:
      // 빈 리스트 → ([], [])
      %empty_ints = funlang.nil : !funlang.list<i32>
      %empty_floats = funlang.nil : !funlang.list<f64>
      %empty_pair = funlang.make_tuple(%empty_ints, %empty_floats)
          : !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>>
      funlang.yield %empty_pair
          : !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>>

    ^cons(%p: !funlang.tuple<i32, f64>, %ps: !funlang.list<!funlang.tuple<i32, f64>>):
      // p = (x, y), 튜플 분해
      %xy = funlang.match %p : !funlang.tuple<i32, f64>
          -> !funlang.tuple<i32, f64> {
        ^case(%x: i32, %y: f64):
          funlang.yield %p : !funlang.tuple<i32, f64>
      }
      // 실제로는 직접 extractvalue 사용
      %x = ... extractvalue [0] ...
      %y = ... extractvalue [1] ...

      // 재귀: unzip ps
      %rest = func.call @unzip(%ps) : ...
      %xs = ... fst rest ...
      %ys = ... snd rest ...

      // 결과: (x :: xs, y :: ys)
      %new_xs = funlang.cons %x, %xs : !funlang.list<i32>
      %new_ys = funlang.cons %y, %ys : !funlang.list<f64>
      %result_pair = funlang.make_tuple(%new_xs, %new_ys)
          : !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>>
      funlang.yield %result_pair
          : !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>>
  }

  return %result : !funlang.tuple<!funlang.list<i32>, !funlang.list<f64>>
}

Point 조작 예제: 2D 좌표

Point 타입:

// Point = (int, int) 튜플
type point = int * int

let origin = (0, 0)
let p1 = (3, 4)

기본 연산들:

// 오른쪽으로 이동
let move_right pt =
  match pt with (x, y) -> (x + 1, y)

// 위로 이동
let move_up pt =
  match pt with (x, y) -> (x, y + 1)

// 두 점 사이의 거리 (맨해튼)
let manhattan_distance p1 p2 =
  match (p1, p2) with ((x1, y1), (x2, y2)) ->
    abs(x2 - x1) + abs(y2 - y1)

// 점 리스트의 중심점
let centroid points =
  let sum_pts = fold (fun (sx, sy) (x, y) -> (sx + x, sy + y)) (0, 0) points
  let n = length points
  match sum_pts with (sx, sy) -> (sx / n, sy / n)

MLIR 구현 - move_right:

// move_right : !funlang.tuple<i32, i32> -> !funlang.tuple<i32, i32>
func.func @move_right(%pt: !funlang.tuple<i32, i32>) -> !funlang.tuple<i32, i32> {
  %result = funlang.match %pt : !funlang.tuple<i32, i32> -> !funlang.tuple<i32, i32> {
    ^case(%x: i32, %y: i32):
      %c1 = arith.constant 1 : i32
      %new_x = arith.addi %x, %c1 : i32
      %new_pt = funlang.make_tuple(%new_x, %y) : !funlang.tuple<i32, i32>
      funlang.yield %new_pt : !funlang.tuple<i32, i32>
  }
  return %result : !funlang.tuple<i32, i32>
}

Lowering 결과:

func.func @move_right(%pt: !llvm.struct<(i32, i32)>) -> !llvm.struct<(i32, i32)> {
  %x = llvm.extractvalue %pt[0] : !llvm.struct<(i32, i32)>
  %y = llvm.extractvalue %pt[1] : !llvm.struct<(i32, i32)>
  %c1 = arith.constant 1 : i32
  %new_x = arith.addi %x, %c1 : i32
  %0 = llvm.mlir.undef : !llvm.struct<(i32, i32)>
  %1 = llvm.insertvalue %new_x, %0[0] : !llvm.struct<(i32, i32)>
  %result = llvm.insertvalue %y, %1[1] : !llvm.struct<(i32, i32)>
  return %result : !llvm.struct<(i32, i32)>
}

중첩 튜플 - manhattan_distance:

// manhattan_distance : !funlang.tuple<i32, i32> -> !funlang.tuple<i32, i32> -> i32
func.func @manhattan_distance(%p1: !funlang.tuple<i32, i32>, %p2: !funlang.tuple<i32, i32>) -> i32 {
  // 두 점을 하나의 튜플로 묶어서 패턴 매칭
  %combined = funlang.make_tuple(%p1, %p2)
      : !funlang.tuple<!funlang.tuple<i32, i32>, !funlang.tuple<i32, i32>>

  // 중첩 튜플 분해
  %result = funlang.match %combined
      : !funlang.tuple<!funlang.tuple<i32, i32>, !funlang.tuple<i32, i32>> -> i32 {

    ^case(%pt1: !funlang.tuple<i32, i32>, %pt2: !funlang.tuple<i32, i32>):
      // 첫 번째 점 분해
      %xy1 = funlang.match %pt1 : !funlang.tuple<i32, i32> -> !funlang.tuple<i32, i32> {
        ^case(%x1: i32, %y1: i32):
          funlang.yield %pt1 : !funlang.tuple<i32, i32>
      }
      // 실제로는 extractvalue 연쇄
      // %x1 = extractvalue %pt1[0]
      // %y1 = extractvalue %pt1[1]
      // %x2 = extractvalue %pt2[0]
      // %y2 = extractvalue %pt2[1]

      // 거리 계산
      // %dx = abs(x2 - x1)
      // %dy = abs(y2 - y1)
      // %result = dx + dy
      ...
      funlang.yield %distance : i32
  }

  return %result : i32
}

튜플 + 고차 함수 결합

튜플을 사용한 map_with_index:

// 리스트의 각 원소에 인덱스와 함께 함수 적용
let map_with_index f lst =
  let indexed = zip [0..length lst - 1] lst
  map (fun (i, x) -> f i x) indexed

enumerate 함수:

// 리스트에 인덱스를 붙여서 튜플 리스트로
let rec enumerate_from n lst =
  match lst with
  | [] -> []
  | x :: xs -> (n, x) :: enumerate_from (n + 1) xs

let enumerate = enumerate_from 0

// 사용 예
enumerate ["a", "b", "c"]  // [(0, "a"), (1, "b"), (2, "c")]

partition 함수 (튜플 반환):

// 리스트를 조건에 따라 두 리스트로 분리
let rec partition pred lst =
  match lst with
  | [] -> ([], [])
  | x :: xs ->
      let (yes, no) = partition pred xs
      if pred x then
        (x :: yes, no)
      else
        (yes, x :: no)

// 사용 예
partition (fun x -> x > 0) [-1, 2, -3, 4]  // ([2, 4], [-1, -3])

MLIR 구현 - partition:

func.func @partition(%pred: !funlang.closure<(i32) -> i1>,
                      %lst: !funlang.list<i32>)
    -> !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>> {

  %result = funlang.match %lst : !funlang.list<i32>
      -> !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>> {

    ^nil:
      %empty1 = funlang.nil : !funlang.list<i32>
      %empty2 = funlang.nil : !funlang.list<i32>
      %pair = funlang.make_tuple(%empty1, %empty2)
          : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>
      funlang.yield %pair : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>

    ^cons(%x: i32, %xs: !funlang.list<i32>):
      // 재귀: partition pred xs
      %rest = func.call @partition(%pred, %xs) : ...
      %yes = ... fst rest ...
      %no = ... snd rest ...

      // pred x 평가
      %test = funlang.apply %pred(%x) : (i32) -> i1

      // 조건부 cons
      %new_pair = scf.if %test -> !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>> {
        %new_yes = funlang.cons %x, %yes : !funlang.list<i32>
        %pair = funlang.make_tuple(%new_yes, %no)
            : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>
        scf.yield %pair : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>
      } else {
        %new_no = funlang.cons %x, %no : !funlang.list<i32>
        %pair = funlang.make_tuple(%yes, %new_no)
            : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>
        scf.yield %pair : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>
      }

      funlang.yield %new_pair : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>
  }

  return %result : !funlang.tuple<!funlang.list<i32>, !funlang.list<i32>>
}

Summary: 튜플 예제

구현한 함수들:

함수타입설명
zip[a] -> [b] -> [(a,b)]두 리스트를 쌍으로 묶기
fst(a,b) -> a첫 번째 원소 추출
snd(a,b) -> b두 번째 원소 추출
unzip[(a,b)] -> ([a], [b])쌍 리스트를 두 리스트로 분리
move_rightpoint -> point좌표 변환
manhattan_distancepoint -> point -> int두 점 사이 거리
enumerate[a] -> [(int, a)]인덱스 붙이기
partition(a -> bool) -> [a] -> ([a], [a])조건에 따라 분리

핵심 패턴:

  1. make_tuple로 튜플 생성: funlang.make_tuple(%a, %b)
  2. 패턴 매칭으로 분해: ^case(%x, %y): 또는 extractvalue 직접 사용
  3. 중첩 가능: 튜플 안에 리스트, 리스트 안에 튜플
  4. 다중 반환값: 함수에서 튜플 반환하여 여러 값 리턴
  5. 고차 함수와 결합: map, fold 등과 함께 사용

Lowering 특성:

  • 튜플 패턴: 분기 없이 extractvalue 체인
  • 리스트 패턴: scf.index_switch 사용
  • 중첩: 외부에서 내부로 순차 처리

Phase 6 Complete Summary

축하한다! Phase 6를 완료했다.

Chapter 17-20 복습

Chapter 17: Pattern Matching Theory

  • Decision tree 알고리즘으로 패턴 매칭을 효율적으로 컴파일
  • Exhaustiveness checking으로 빠진 case 감지
  • Unreachable case detection으로 중복 제거

Chapter 18: List Operations

  • !funlang.list<T> parameterized type
  • Tagged union representation: !llvm.struct<(i32, ptr)>
  • funlang.nilfunlang.cons operations
  • TypeConverter와 lowering patterns

Chapter 19: Match Compilation

  • funlang.match operation 정의
  • Multi-stage lowering: FunLang → SCF → CF → LLVM
  • IRMapping으로 block argument remapping
  • Region-based IR structure

Chapter 20: Functional Programs (this chapter)

  • FunLang AST extensions for lists
  • Compiler integration (compileExpr, type inference)
  • Core list functions: map, filter, fold, length, append
  • Complete example: sum_of_squares
  • End-to-end compilation pipeline (9 stages)
  • Performance analysis and optimization preview

What You Can Now Compile

Phase 6 종료 시점에 컴파일 가능한 프로그램:

// 1. List construction
let list = [1, 2, 3, 4, 5]

// 2. Pattern matching
let rec sum lst =
  match lst with
  | [] -> 0
  | head :: tail -> head + sum tail

// 3. Higher-order functions
let map f lst = ...
let filter pred lst = ...
let fold combiner acc lst = ...

// 4. Function composition
let sum_of_squares lst =
  fold (+) 0 (map (fun x -> x * x) lst)

// 5. Complex functional programs
let process data =
  data
  |> filter is_valid
  |> map transform
  |> fold aggregate initial

// 6. Nested data structures
let nested = [[1, 2], [3, 4], [5, 6]]
let flattened = fold append [] nested

이것은 실제 함수형 언어와 동등한 표현력이다!

Technical Achievements

Phase 6에서 구현한 기술:

  1. Parameterized types: !funlang.list<T> with element type parameter
  2. Tagged unions: Efficient runtime representation of ADTs
  3. Pattern matching: Decision tree compilation for performance
  4. Multi-stage lowering: Progressive refinement through dialects
  5. Type conversion: Consistent type mapping across lowering stages
  6. Region-based IR: Structured control flow with scoped bindings
  7. Tail recursion: Optimization opportunity for fold
  8. GC integration: Automatic memory management for lists
  9. Complete pipeline: Source → AST → MLIR → LLVM IR → Machine code

Phase 7 Preview: Optimization

Phase 7에서 다룰 내용:

1. List Fusion

중간 리스트 제거:

// Before
map f (map g lst)  // Two passes, intermediate list

// After fusion
map (f << g) lst   // One pass, no intermediate

2. Deforestation

Tree 구조 중간 생성 제거:

// Before
fold h z (map f lst)  // Creates intermediate list

// After deforestation
fold (fun acc x -> h acc (f x)) z lst  // Direct

3. Inlining

Small 함수 inline:

// Before
%result = func.call @square(%x) : (i32) -> i32

// After inlining
%result = arith.muli %x, %x : i32

4. Loop Unrolling

재귀를 explicit loop로 변환:

// Before (recursive)
func.func @map(...) {
  %result = funlang.match %lst : ... {
    ^nil: ...
    ^cons(...): %mapped = func.call @map(...) ...
  }
}

// After (loop)
func.func @map(...) {
  scf.for %i = 0 to %n step 1 iter_args(%acc = %init) -> ... {
    %elem = load %lst[%i]
    %transformed = apply %f(%elem)
    ...
  }
}

5. Parallel Map

데이터 병렬성 활용:

scf.parallel (%i) = (0) to (%n) step (1) {
  %elem = load %lst[%i]
  %result = apply %f(%elem)
  store %result, %output[%i]
}

6. Constant Folding

컴파일 시간에 계산:

// Before
let result = sum [1, 2, 3, 4, 5]

// After constant folding
let result = 15  // Computed at compile time

이러한 최적화는 MLIR의 transformation passes로 구현되며, Phase 7에서 자세히 다룬다.

Congratulations!

Phase 6 완료를 축하한다!

이제 여러분은:

  • ✓ 완전한 함수형 프로그래밍 언어를 컴파일할 수 있다
  • ✓ 리스트, 패턴 매칭, 고차 함수를 지원한다
  • ✓ Multi-stage lowering pipeline을 이해한다
  • ✓ End-to-end 컴파일 (source to machine code)을 할 수 있다
  • ✓ 성능 특성과 최적화 기회를 안다

다음 단계: Phase 7 (Optimization)에서 더 빠르고 효율적인 코드 생성을 배운다.

Happy functional programming! 🎉

Appendix: 커스텀 MLIR Dialect 등록

소개

Chapter 01-05에서는 MLIR의 빌트인 dialect를 사용했다:

  • arith: 산술 연산
  • func: 함수 정의와 호출
  • scf: 구조적 제어 흐름 (if/while)
  • llvm: LLVM IR 타입과 operation

이 dialect들은 강력하지만 범용적이다. FunLang과 같은 도메인별 언어의 경우 언어의 의미를 직접 표현하는 커스텀 dialect가 유용하다.

예를 들어 FunLang 클로저를 고려해 본다:

let make_adder x =
    fun y -> x + y

빌트인 dialect만 사용하면 클로저를 즉시 struct, 함수 포인터, 환경 캡처로 낮춰야 한다. 하지만 커스텀 dialect를 사용하면 이렇게 표현할 수 있다:

%closure = funlang.make_closure @lambda_body, %x : (!funlang.closure)
%result = funlang.apply %closure, %y : (i32)

높은 수준에서 의미가 명확하다. 그런 다음 낮추기 pass에서 구현 세부사항 (struct 레이아웃, malloc 호출 등)으로 점진적으로 변환한다.

이 appendix는 다음을 다룬다:

  1. 커스텀 dialect를 C++에서 정의하는 방법
  2. C API shim으로 F#에 노출하는 방법
  3. Phase 5에서 사용할 아키텍처

아키텍처 노트: 커스텀 dialect 등록은 Phase 5의 주제다. 이 appendix는 미리 보기와 기술적 기초를 제공한다.

C API가 커스텀 Dialect를 등록할 수 없는 이유

MLIR C API (mlir-c/IR.h)는 빌트인 dialect를 로드하는 함수를 제공한다:

// C API에 있음 - 빌트인 dialect 로드
MlirDialectHandle mlirGetDialectHandle__arith__();
void mlirDialectHandleRegisterDialect(MlirDialectHandle handle, MlirContext ctx);

하지만 dialect를 정의하는 함수는 없다. 커스텀 dialect 정의는 C++ 코드를 요구한다:

// C++만 가능 - 새 dialect 정의
class FunLangDialect : public mlir::Dialect {
public:
  FunLangDialect(mlir::MLIRContext *context);
  static constexpr llvm::StringLiteral getDialectNamespace() {
    return llvm::StringLiteral("funlang");
  }
  // ... operation, type, attribute 정의 ...
};

왜 C API에 없나?

Dialect 정의는 C++ 클래스 상속, 템플릿, TableGen 생성 코드를 사용한다. 이것들은 C FFI 경계를 넘을 수 없다. C API는 이미 정의된 dialect의 핸들만 다룰 수 있다.

해결책: C++에서 dialect를 정의하고 등록을 위한 C API shim을 작성한다. F#은 이 shim을 P/Invoke로 호출한다.

C++ 래퍼 접근법

아키텍처:

┌─────────────────────────────────────────┐
│ F# Compiler (Compiler.fs)              │
│                                         │
│ ctx.LoadCustomDialect("funlang")        │
└────────────────┬────────────────────────┘
                 │ P/Invoke
                 ▼
┌─────────────────────────────────────────┐
│ C API Shim (funlang_dialect.c)         │
│                                         │
│ void funlangRegisterDialect(MlirContext)│
└────────────────┬────────────────────────┘
                 │ Call C++ API
                 ▼
┌─────────────────────────────────────────┐
│ C++ Dialect (FunLangDialect.cpp)       │
│                                         │
│ class FunLangDialect : public Dialect { │
│   // operation, type 정의               │
│ }                                       │
└─────────────────────────────────────────┘

C++ dialect을 공유 라이브러리 (libFunLangDialect.so)로 컴파일하고 F#이 로드한다.

최소 커스텀 Dialect in C++

C++ 파일 funlang_dialect.cpp를 만든다:

// funlang_dialect.cpp - 최소 FunLang MLIR Dialect
#include "mlir/IR/Dialect.h"
#include "mlir/IR/MLIRContext.h"
#include "mlir/IR/Builders.h"
#include "mlir/IR/DialectRegistry.h"
#include "mlir-c/IR.h"

namespace mlir {
namespace funlang {

/// FunLang Dialect 정의
class FunLangDialect : public Dialect {
public:
  /// Context에 FunLang dialect 등록
  explicit FunLangDialect(MLIRContext *context)
      : Dialect(getDialectNamespace(), context,
                mlir::TypeID::get<FunLangDialect>()) {
    // 여기서 operation, type, attribute를 등록할 것
    // Phase 5에서 구현
  }

  /// Dialect 네임스페이스 반환 ("funlang")
  static constexpr llvm::StringLiteral getDialectNamespace() {
    return llvm::StringLiteral("funlang");
  }
};

} // namespace funlang
} // namespace mlir

// C API shim - F#에서 호출 가능
extern "C" {

/// FunLang dialect를 MLIR context에 등록
void funlangRegisterDialect(MlirContext ctx) {
  mlir::MLIRContext *context = unwrap(ctx);
  mlir::DialectRegistry registry;
  registry.insert<mlir::funlang::FunLangDialect>();
  context->appendDialectRegistry(registry);
  context->loadDialect<mlir::funlang::FunLangDialect>();
}

} // extern "C"

Line-by-line 설명:

  1. #include "mlir/IR/Dialect.h": MLIR dialect 기본 클래스
  2. namespace mlir::funlang: 네임스페이스 충돌 방지
  3. class FunLangDialect : public Dialect: 커스텀 dialect 정의. Dialect는 MLIR 기본 클래스
  4. explicit FunLangDialect(MLIRContext *context): 생성자. Context에 dialect 등록
  5. getDialectNamespace(): Dialect 이름 반환. MLIR IR에서 funlang.operation_name으로 사용됨
  6. extern "C" { ... }: C linkage - name mangling 방지, F# P/Invoke 가능
  7. void funlangRegisterDialect(MlirContext ctx): C API shim. F#이 호출할 함수
  8. unwrap(ctx): MLIR C API 유틸리티 - MlirContext (불투명 핸들)을 C++ MLIRContext*로 변환
  9. registry.insert<FunLangDialect>(): Registry에 dialect 추가
  10. context->appendDialectRegistry(registry): Context에 registry 추가
  11. context->loadDialect<FunLangDialect>(): Dialect 즉시 로드 (lazy loading 아님)

설계 결정: 이 dialect는 아직 operation이나 type을 정의하지 않는다. Phase 5에서 funlang.closure, funlang.apply 같은 operation을 추가할 것이다.

C++ 라이브러리 빌드

CMakeLists.txt를 작성한다:

# CMakeLists.txt - FunLang Dialect 빌드
cmake_minimum_required(VERSION 3.20)
project(FunLangDialect)

# LLVM/MLIR 찾기
find_package(MLIR REQUIRED CONFIG)
list(APPEND CMAKE_MODULE_PATH "${MLIR_CMAKE_DIR}")
include(AddLLVM)
include(AddMLIR)

# Include 디렉토리
include_directories(${MLIR_INCLUDE_DIRS})

# FunLangDialect 공유 라이브러리
add_library(FunLangDialect SHARED
  funlang_dialect.cpp
)

# MLIR 라이브러리 링크
target_link_libraries(FunLangDialect
  PRIVATE
    MLIRIR
    MLIRDialect
)

# 설치
install(TARGETS FunLangDialect
  LIBRARY DESTINATION lib
)

빌드:

# CMake 설정
cmake -S . -B build \
  -DMLIR_DIR=$HOME/mlir-install/lib/cmake/mlir \
  -DCMAKE_BUILD_TYPE=Release

# 빌드
cmake --build build

# 설치
cmake --build build --target install

이렇게 하면 libFunLangDialect.so (Linux), libFunLangDialect.dylib (macOS), 또는 FunLangDialect.dll (Windows)가 생성된다.

F#에서 사용

MlirBindings.fs에 P/Invoke 선언 추가:

// MlirBindings.fs에 추가

module MlirNative =
    // ... 기존 바인딩 ...

    /// FunLang 커스텀 dialect 등록 (C++ shim 호출)
    [<DllImport("FunLangDialect", CallingConvention = CallingConvention.Cdecl)>]
    extern void funlangRegisterDialect(MlirContext ctx)

MlirWrapper.fsContext 클래스에 메서드 추가:

type Context() =
    let mutable handle = MlirNative.mlirContextCreate()
    let mutable disposed = false

    member _.Handle = handle

    /// 빌트인 dialect 로드
    member _.LoadDialect(dialect: string) =
        if disposed then
            raise (ObjectDisposedException("Context"))

        MlirStringRef.WithString dialect (fun nameRef ->
            MlirNative.mlirContextGetOrLoadDialect(handle, nameRef)
            |> ignore)

    /// 커스텀 FunLang dialect 로드
    member _.LoadFunLangDialect() =
        if disposed then
            raise (ObjectDisposedException("Context"))

        MlirNative.funlangRegisterDialect(handle)

    // ... IDisposable 구현 ...

사용:

use ctx = new Context()
ctx.LoadDialect("arith")
ctx.LoadDialect("func")
ctx.LoadFunLangDialect()  // 커스텀 dialect 로드

// 이제 funlang.* operation 사용 가능 (Phase 5에서 정의)

커스텀 Operation 추가 (미리 보기)

Phase 5에서 FunLang dialect에 operation을 추가한다. 미리 보기:

TableGen 정의 (FunLangOps.td):

// FunLangOps.td - FunLang operation 정의 (TableGen)
include "mlir/IR/OpBase.td"

def FunLang_Dialect : Dialect {
  let name = "funlang";
  let cppNamespace = "::mlir::funlang";
}

class FunLang_Op<string mnemonic, list<Trait> traits = []>
    : Op<FunLang_Dialect, mnemonic, traits>;

// funlang.make_closure operation
def FunLang_MakeClosureOp : FunLang_Op<"make_closure"> {
  let summary = "Create a closure capturing environment";
  let arguments = (ins
    FlatSymbolRefAttr:$callee,
    Variadic<AnyType>:$captured
  );
  let results = (outs AnyType:$result);
}

// funlang.apply operation
def FunLang_ApplyOp : FunLang_Op<"apply"> {
  let summary = "Apply a closure to arguments";
  let arguments = (ins
    AnyType:$closure,
    Variadic<AnyType>:$args
  );
  let results = (outs AnyType:$result);
}

생성된 C++ 코드:

TableGen은 위 정의에서 C++ 클래스를 생성한다:

// 생성됨: FunLangOps.h.inc
class MakeClosureOp : public Op<MakeClosureOp, /* traits */> {
public:
  static StringRef getOperationName() { return "funlang.make_closure"; }
  // ... getter/setter, verifier ...
};

class ApplyOp : public Op<ApplyOp, /* traits */> {
public:
  static StringRef getOperationName() { return "funlang.apply"; }
  // ... getter/setter, verifier ...
};

Dialect에 등록:

// funlang_dialect.cpp 업데이트
FunLangDialect::FunLangDialect(MLIRContext *context)
    : Dialect(/*...*/) {
  // Operation 등록
  addOperations<
    MakeClosureOp,
    ApplyOp
  >();
}

F#에서 사용:

// 커스텀 operation 생성 (Phase 5에서 OpBuilder 확장)
let closureOp = builder.CreateMakeClosure("lambda_body", [| xValue |], loc)
let resultOp = builder.CreateApply(closureOp, [| yValue |], loc)

생성된 MLIR IR:

%closure = funlang.make_closure @lambda_body, %x : (!funlang.closure)
%result = funlang.apply %closure, %y : (i32)

Phase 5에서 이 operation들을 scf, memref, llvm dialect로 낮추는 pass를 작성할 것이다.

커스텀 Dialect를 사용할 때 vs. 빌트인 사용

커스텀 dialect를 사용해야 하는 경우:

  1. 도메인별 의미: FunLang 클로저, 패턴 매칭, 리스트 cons는 커스텀 operation으로 더 명확하다
  2. 점진적 낮추기: 높은 수준에서 시작하여 여러 pass를 통해 낮춘다
  3. 최적화 기회: 커스텀 operation의 패턴 매칭 최적화 작성 가능
  4. 가독성: funlang.make_closure가 15줄의 llvm.call, memref.alloc, memref.store보다 이해하기 쉽다

빌트인 dialect를 사용해야 하는 경우:

  1. 단순한 언어: 산술과 함수만 있으면 arith + func로 충분하다
  2. 빠른 프로토타이핑: 커스텀 dialect는 C++ 빌드 시스템이 필요하다
  3. MLIR 학습: 빌트인 dialect로 시작하면 개념을 빠르게 배울 수 있다

FunLang의 경우: Phase 1-4는 빌트인 dialect를 사용한다. Phase 5는 클로저와 고급 기능을 위해 커스텀 dialect를 도입한다.

요약

이 appendix에서 다음을 배웠다:

  1. C API 제한: MLIR C API는 커스텀 dialect 정의를 지원하지 않는다 - C++ 필요
  2. C++ 래퍼 패턴: C++에서 dialect를 정의하고 extern "C" shim으로 노출
  3. F# 통합: P/Invoke로 shim 호출, 빌트인 dialect처럼 사용
  4. TableGen: Operation 정의를 위한 MLIR의 코드 생성 도구
  5. 점진적 낮추기: 커스텀 operation → 표준 dialect → LLVM

Phase 5 미리 보기:

  • FunLang dialect 정의 (funlang.closure, funlang.apply, funlang.match)
  • TableGen으로 operation 생성
  • 낮추기 pass 작성 (pattern rewrite 사용)
  • 이전 chapter들을 커스텀 dialect 사용으로 리팩터링

리소스:


이것으로 Phase 1이 완료되었다! Chapter 00-05와 이 appendix를 통해 MLIR 기반 컴파일러 구축을 위한 완전한 기초를 갖추었다. Phase 2에서 FunLang의 더 많은 기능을 컴파일하기 시작할 것이다.