Skip to content
Published on

컴파일러/인터프리터 설계 완전 정복: 파서부터 LLVM, AI 컴파일러(TVM/XLA)까지

Authors

들어가며

소프트웨어 엔지니어가 "컴파일러"를 이해해야 하는 이유는 단순히 언어를 만들기 위해서가 아닙니다. 오늘날 AI/ML 분야에서 torch.compile(), TVM, XLA, MLIR 같은 도구들이 핵심 인프라로 자리 잡았고, 이들의 내부 동작은 모두 컴파일러 이론 위에 세워져 있습니다.

이 글에서는 컴파일러 설계의 전체 파이프라인을 처음부터 끝까지 살펴봅니다.


1. 컴파일러 기초: 전체 파이프라인

컴파일러는 소스 코드를 받아 실행 가능한 코드로 변환하는 프로그램입니다. 이 과정은 여러 단계로 나뉩니다.

소스 코드
    ↓  어휘 분석 (Lexing)
토큰 스트림
    ↓  구문 분석 (Parsing)
AST (추상 구문 트리)
    ↓  의미 분석 (Semantic Analysis)
Annotated AST
IR 생성
중간 표현 (IR)
최적화 (Optimization)
최적화된 IR
    ↓  코드 생성 (Code Generation)
기계어 / 어셈블리

1.1 어휘 분석 (Lexical Analysis / Lexing)

어휘 분석기(Lexer 또는 Tokenizer)는 소스 코드 문자열을 토큰(Token) 의 스트림으로 변환합니다.

토큰은 숫자 리터럴, 식별자, 키워드, 연산자, 구두점 같은 의미 단위입니다.

import re
from enum import Enum, auto
from dataclasses import dataclass
from typing import List, Optional

class TokenType(Enum):
    NUMBER   = auto()
    IDENT    = auto()
    PLUS     = auto()
    MINUS    = auto()
    STAR     = auto()
    SLASH    = auto()
    LPAREN   = auto()
    RPAREN   = auto()
    ASSIGN   = auto()
    SEMICOL  = auto()
    LET      = auto()
    EOF      = auto()

@dataclass
class Token:
    type: TokenType
    value: str
    line: int

KEYWORDS = {'let': TokenType.LET}

TOKEN_PATTERNS = [
    (r'\d+(\.\d+)?',  TokenType.NUMBER),
    (r'[a-zA-Z_]\w*', TokenType.IDENT),
    (r'\+',           TokenType.PLUS),
    (r'-',            TokenType.MINUS),
    (r'\*',           TokenType.STAR),
    (r'/',            TokenType.SLASH),
    (r'\(',           TokenType.LPAREN),
    (r'\)',           TokenType.RPAREN),
    (r'=',            TokenType.ASSIGN),
    (r';',            TokenType.SEMICOL),
]

def tokenize(source: str) -> List[Token]:
    tokens = []
    line = 1
    pos = 0
    while pos < len(source):
        if source[pos] in ' \t\r':
            pos += 1
            continue
        if source[pos] == '\n':
            line += 1
            pos += 1
            continue
        matched = False
        for pattern, ttype in TOKEN_PATTERNS:
            m = re.match(pattern, source[pos:])
            if m:
                value = m.group(0)
                # 키워드 처리
                if ttype == TokenType.IDENT and value in KEYWORDS:
                    ttype = KEYWORDS[value]
                tokens.append(Token(ttype, value, line))
                pos += len(value)
                matched = True
                break
        if not matched:
            raise SyntaxError(f"Unexpected character '{source[pos]}' at line {line}")
    tokens.append(Token(TokenType.EOF, '', line))
    return tokens

# 사용 예
src = "let x = 10 + 20 * 3;"
for tok in tokenize(src):
    print(tok)

1.2 구문 분석 (Parsing) 과 AST

파서는 토큰 스트림을 받아 추상 구문 트리(AST, Abstract Syntax Tree) 를 만듭니다. AST는 프로그램의 계층적 구조를 표현합니다.

from dataclasses import dataclass, field
from typing import Union

# AST 노드 정의
@dataclass
class NumberLit:
    value: float

@dataclass
class Identifier:
    name: str

@dataclass
class BinOp:
    op: str
    left: 'Expr'
    right: 'Expr'

@dataclass
class LetStmt:
    name: str
    value: 'Expr'

Expr = Union[NumberLit, Identifier, BinOp]
Stmt = Union[LetStmt]

# Recursive Descent Parser
class Parser:
    def __init__(self, tokens: List[Token]):
        self.tokens = tokens
        self.pos = 0

    def peek(self) -> Token:
        return self.tokens[self.pos]

    def consume(self, expected: Optional[TokenType] = None) -> Token:
        tok = self.tokens[self.pos]
        if expected and tok.type != expected:
            raise SyntaxError(f"Expected {expected}, got {tok.type} at line {tok.line}")
        self.pos += 1
        return tok

    def parse_stmt(self) -> Stmt:
        if self.peek().type == TokenType.LET:
            return self.parse_let()
        raise SyntaxError("Expected statement")

    def parse_let(self) -> LetStmt:
        self.consume(TokenType.LET)
        name = self.consume(TokenType.IDENT).value
        self.consume(TokenType.ASSIGN)
        expr = self.parse_expr()
        self.consume(TokenType.SEMICOL)
        return LetStmt(name=name, value=expr)

    def parse_expr(self) -> Expr:
        return self.parse_additive()

    def parse_additive(self) -> Expr:
        left = self.parse_multiplicative()
        while self.peek().type in (TokenType.PLUS, TokenType.MINUS):
            op = self.consume().value
            right = self.parse_multiplicative()
            left = BinOp(op=op, left=left, right=right)
        return left

    def parse_multiplicative(self) -> Expr:
        left = self.parse_primary()
        while self.peek().type in (TokenType.STAR, TokenType.SLASH):
            op = self.consume().value
            right = self.parse_primary()
            left = BinOp(op=op, left=left, right=right)
        return left

    def parse_primary(self) -> Expr:
        tok = self.peek()
        if tok.type == TokenType.NUMBER:
            self.consume()
            return NumberLit(value=float(tok.value))
        if tok.type == TokenType.IDENT:
            self.consume()
            return Identifier(name=tok.value)
        if tok.type == TokenType.LPAREN:
            self.consume()
            expr = self.parse_expr()
            self.consume(TokenType.RPAREN)
            return expr
        raise SyntaxError(f"Unexpected token {tok}")

2. 중간 표현 (IR, Intermediate Representation)

AST를 직접 기계어로 변환하는 것은 비효율적입니다. 대신 중간 표현(IR) 을 거치면 언어와 독립적인 최적화가 가능해집니다.

2.1 3-주소 코드 (Three-Address Code)

가장 단순한 IR 형태입니다. 모든 연산은 최대 3개의 피연산자를 가집니다.

# 소스: x = (a + b) * (c - d)
t1 = a + b
t2 = c - d
x  = t1 * t2

2.2 SSA (Static Single Assignment) Form

SSA는 각 변수가 정확히 한 번만 할당되는 IR 형태입니다. 최적화 분석이 크게 단순해집니다.

# 일반 코드
x = 1
x = x + 2   # x가 두 번 할당됨

# SSA 변환
x_1 = 1
x_2 = x_1 + 2

분기가 합류하는 지점에는 Phi 함수를 사용합니다.

if cond:
    x_1 = 10
else:
    x_2 = 20
# 합류 지점
x_3 = phi(x_1, x_2)

2.3 LLVM IR

LLVM IR은 실제 산업용 컴파일러에서 사용하는 강력한 IR입니다.

; 함수 정의: int add(int a, int b)
define i32 @add(i32 %a, i32 %b) {
entry:
  %result = add i32 %a, %b
  ret i32 %result
}

; 더 복잡한 예: 조건 분기 + SSA
define i32 @max(i32 %a, i32 %b) {
entry:
  %cmp = icmp sgt i32 %a, %b     ; a > b ?
  br i1 %cmp, label %then, label %else

then:
  br label %merge

else:
  br label %merge

merge:
  ; phi 함수: 어느 블록에서 왔느냐에 따라 값 선택
  %result = phi i32 [ %a, %then ], [ %b, %else ]
  ret i32 %result
}

LLVM IR의 특징:

  • 타입 시스템: i32, i64, float, double, 포인터 등 명확한 타입
  • SSA 기반: 모든 값은 한 번만 정의
  • 무한 가상 레지스터: %0, %1, %2 ... 레지스터 수 제한 없음
  • 언어 독립성: C, C++, Rust, Swift 모두 같은 LLVM IR로 변환됨

3. 최적화 패스 (Optimization Passes)

컴파일러 최적화의 핵심 기법들을 살펴봅니다.

3.1 Constant Folding (상수 접기)

컴파일 시간에 계산 가능한 상수 표현식을 미리 계산합니다.

# 최적화 전
x = 2 * 3 + 4

# 최적화  (컴파일러가 미리 계산)
x = 10
def constant_fold(node):
    """간단한 상수 접기 구현"""
    if isinstance(node, BinOp):
        left = constant_fold(node.left)
        right = constant_fold(node.right)
        if isinstance(left, NumberLit) and isinstance(right, NumberLit):
            ops = {'+': lambda a,b: a+b, '-': lambda a,b: a-b,
                   '*': lambda a,b: a*b, '/': lambda a,b: a/b}
            result = ops[node.op](left.value, right.value)
            return NumberLit(value=result)
        return BinOp(op=node.op, left=left, right=right)
    return node

3.2 Dead Code Elimination (죽은 코드 제거)

실행되지 않거나 결과가 사용되지 않는 코드를 제거합니다.

def is_reachable(block, cfg):
    """제어 흐름 그래프에서 도달 가능한 블록을 BFS로 탐색"""
    visited = set()
    queue = [cfg.entry]
    while queue:
        current = queue.pop(0)
        if current in visited:
            continue
        visited.add(current)
        queue.extend(cfg.successors[current])
    return block in visited

3.3 Loop Unrolling (루프 풀기)

루프 오버헤드를 줄이고 명령어 수준 병렬성(ILP)을 높입니다.

// 원본 (루프 4회)
for (int i = 0; i < 4; i++) {
    a[i] = b[i] + c[i];
}

// 언롤링 후 (루프 제거)
a[0] = b[0] + c[0];
a[1] = b[1] + c[1];
a[2] = b[2] + c[2];
a[3] = b[3] + c[3];

3.4 Inlining (함수 인라이닝)

함수 호출 오버헤드를 없애고 추가 최적화 기회를 만듭니다.

// 원본
inline int square(int x) { return x * x; }
int y = square(5);

// 인라이닝 후
int y = 5 * 5;  // → 상수 접기로 25

4. 코드 생성: x86-64 어셈블리

IR을 실제 기계 코드로 변환하는 과정입니다.

4.1 레지스터 할당

x86-64에는 범용 레지스터가 16개(rax, rbx, rcx, rdx, rsi, rdi, rsp, rbp, r8~r15)입니다. 가상 레지스터를 물리 레지스터에 매핑해야 합니다.

그래프 채색(Graph Coloring) 알고리즘이 가장 유명한 레지스터 할당 방법입니다. 동시에 살아 있는(live) 변수들은 같은 레지스터를 공유할 수 없습니다.

4.2 x86-64 어셈블리 예시

; C 함수: int add(int a, int b) { return a + b; }
; Linux x86-64 System V ABI: 인자는 rdi, rsi 순서

add:
    push    rbp
    mov     rbp, rsp
    mov     DWORD PTR [rbp-4], edi   ; a 저장
    mov     DWORD PTR [rbp-8], esi   ; b 저장
    mov     edx, DWORD PTR [rbp-4]
    mov     eax, DWORD PTR [rbp-8]
    add     eax, edx                  ; a + b
    pop     rbp
    ret                               ; 결과는 eax에

; 최적화된 버전 (-O2)
add:
    lea     eax, [rdi + rsi]          ; 단 1 명령어!
    ret

5. JIT 컴파일: 런타임 최적화

JIT(Just-In-Time) 컴파일은 프로그램 실행 중에 코드를 컴파일합니다. Python의 느린 실행 속도를 극복하는 핵심 기술입니다.

5.1 Numba JIT

Numba는 Python/NumPy 코드를 LLVM을 통해 기계어로 컴파일합니다.

import numpy as np
from numba import njit, prange
import time

# 순수 Python 버전
def matmul_python(A, B):
    N = A.shape[0]
    C = np.zeros((N, N))
    for i in range(N):
        for j in range(N):
            for k in range(N):
                C[i, j] += A[i, k] * B[k, j]
    return C

# Numba JIT 버전 (병렬화 포함)
@njit(parallel=True)
def matmul_numba(A, B):
    N = A.shape[0]
    C = np.zeros((N, N))
    for i in prange(N):       # 병렬 루프
        for j in range(N):
            for k in range(N):
                C[i, j] += A[i, k] * B[k, j]
    return C

N = 256
A = np.random.rand(N, N).astype(np.float32)
B = np.random.rand(N, N).astype(np.float32)

# 첫 호출에서 컴파일 발생 (웜업)
_ = matmul_numba(A, B)

t0 = time.perf_counter()
C_python = matmul_python(A, B)
print(f"Python: {time.perf_counter()-t0:.3f}s")

t0 = time.perf_counter()
C_numba = matmul_numba(A, B)
print(f"Numba JIT: {time.perf_counter()-t0:.4f}s")
# 결과: Python ~30s vs Numba ~0.01s (수백 배 빠름)

Numba의 내부 동작:

  1. Python 바이트코드를 Numba IR로 변환
  2. 타입 추론 (type inference)으로 정적 타입 확정
  3. LLVM IR 생성
  4. LLVM 백엔드가 기계어로 컴파일
  5. 컴파일된 코드를 캐시에 저장

6. AI/ML 컴파일러 생태계

6.1 torch.compile() 내부 동작

PyTorch 2.0의 핵심 기능인 torch.compile()은 세 가지 컴포넌트로 구성됩니다.

import torch
import torch.nn as nn

class SimpleNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear1 = nn.Linear(128, 256)
        self.linear2 = nn.Linear(256, 10)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.relu(self.linear1(x))
        return self.linear2(x)

model = SimpleNet().cuda()
x = torch.randn(32, 128).cuda()

# torch.compile 적용
compiled_model = torch.compile(model, mode='max-autotune')

# 첫 호출: 컴파일 발생 (수 초 소요)
out = compiled_model(x)

# 이후 호출: 컴파일된 커널 재사용 (빠름)
for _ in range(100):
    out = compiled_model(x)

컴파일 스택 3단계:

1단계 - TorchDynamo (그래프 캡처) Python 바이트코드를 가로채서 torch.fx.Graph 로 변환합니다.

import torch._dynamo as dynamo

def my_func(x, y):
    return torch.sin(x) + torch.cos(y)

# FX 그래프 확인
explanation = dynamo.explain(my_func)(torch.randn(4), torch.randn(4))
print(explanation.graphs[0].print_tabular())
# opcode  | name   | target        | args
# --------|--------|---------------|----------
# call_fn | sin    | torch.sin     | (x,)
# call_fn | cos    | torch.cos     | (y,)
# call_fn | add    | operator.add  | (sin, cos)
# output  | output | output        | (add,)

2단계 - AOTAutograd (자동 미분 사전 컴파일) 순전파(forward)와 역전파(backward) 그래프를 미리 생성합니다.

3단계 - TorchInductor (커널 생성) FX 그래프를 Triton(GPU) 또는 C++(CPU) 커널로 변환합니다.

6.2 TVM: 딥러닝 컴파일러

Apache TVM은 딥러닝 모델을 다양한 하드웨어(GPU, CPU, FPGA, NPU)에 최적화합니다.

import tvm
from tvm import relay
import tvm.relay.testing
import numpy as np

# PyTorch 모델을 Relay IR로 변환
import torch
import torchvision

model = torchvision.models.resnet18(pretrained=False).eval()
input_shape = [1, 3, 224, 224]
input_data = torch.randn(input_shape)

# TorchScript를 거쳐 TVM Relay로
scripted_model = torch.jit.trace(model, input_data)
shape_list = [("input0", input_shape)]
mod, params = relay.frontend.from_pytorch(scripted_model, shape_list)

# 타겟 설정 및 최적화
target = tvm.target.Target("cuda")

# AutoTuning: 최적 커널 파라미터 탐색
from tvm import auto_scheduler
tasks, task_weights = auto_scheduler.extract_tasks(mod["main"], params, target)

tuner = auto_scheduler.TaskScheduler(tasks, task_weights)
tune_option = auto_scheduler.TuningOptions(
    num_measure_trials=200,         # 측정 횟수
    runner=auto_scheduler.LocalRunner(repeat=10),
    measure_callbacks=[auto_scheduler.RecordToFile("tuning_log.json")],
)
tuner.tune(tune_option)

# 튜닝 결과로 컴파일
with auto_scheduler.ApplyHistoryBest("tuning_log.json"):
    with tvm.transform.PassContext(opt_level=3):
        lib = relay.build(mod, target=target, params=params)

print("TVM 컴파일 완료!")

6.3 Operator Fusion (연산자 퓨전)

GPU에서 가장 중요한 최적화입니다. 여러 커널 호출을 하나로 합칩니다.

# 퓨전 전: 3번의 GPU 커널 호출
y1 = conv2d(x)        # 커널 1 (메모리 R/W)
y2 = batch_norm(y1)   # 커널 2 (메모리 R/W)
y3 = relu(y2)         # 커널 3 (메모리 R/W)

# 퓨전 후: 1번의 GPU 커널 호출
y3 = conv_bn_relu(x)  # 단일 퓨즈드 커널
                      # 중간 결과를 L1/L2 캐시에 유지
                      # 메모리 대역폭 절약 = 성능 향상

XLA(Accelerated Linear Algebra)는 TensorFlow/JAX의 컴파일러로, HLO(High Level Operations) IR을 사용합니다.

import jax
import jax.numpy as jnp

# JAX + XLA: jit 데코레이터로 XLA 컴파일 활성화
@jax.jit
def compute(x, W, b):
    # XLA가 자동으로 matmul + add + relu를 퓨즈
    return jax.nn.relu(x @ W + b)

x = jnp.ones((32, 128))
W = jnp.ones((128, 256))
b = jnp.zeros(256)

# 첫 호출: XLA 컴파일
result = compute(x, W, b)
print(result.shape)  # (32, 256)

7. MLIR: 차세대 컴파일러 인프라

MLIR(Multi-Level Intermediate Representation)은 구글이 설계한 컴파일러 프레임워크로, 여러 레벨의 IR을 하나의 인프라로 통합합니다.

TensorFlow Graph
      (TF Dialect)
MLIR HLO Dialect
      (Linalg Dialect)
Loop Nest
      (Affine Dialect)
Affine Loops
      (LLVM Dialect)
LLVM IR
기계어

각 Dialect는 특정 수준의 추상화를 표현하며, 점진적으로 낮은 수준으로 변환(lowering)합니다.


8. 퀴즈: 이해도 점검

Q1. LL(1) 파서와 LR(1) 파서의 파싱 능력 차이는?

정답: LR(1) 파서가 더 넓은 범위의 문법을 처리할 수 있습니다.

설명: LL(1)은 Left-to-right, Leftmost derivation, 1 lookahead를 의미하며 하향식(top-down) 파서입니다. 좌재귀(left-recursive) 문법을 처리하지 못하고, 첫 번째 토큰만 보고 어떤 규칙을 적용할지 결정해야 합니다. LR(1)은 Left-to-right, Rightmost derivation, 1 lookahead를 의미하며 상향식(bottom-up) 파서입니다. LALR(1)은 LR(1)의 메모리 효율적인 변형으로, GCC, Bison 등 실제 컴파일러에서 널리 사용됩니다. LR 파서는 LL 파서보다 훨씬 넓은 문법 클래스를 처리하며, 대부분의 프로그래밍 언어 문법이 LALR(1)로 표현 가능합니다.

Q2. SSA form이 최적화에 유리한 이유는?

정답: 변수의 데이터 흐름 분석이 극도로 단순해지기 때문입니다.

설명: SSA에서 각 변수는 정확히 한 번만 정의되므로, 변수의 정의(def)와 사용(use) 관계가 명확합니다. 이를 통해 (1) 상수 전파: 정의 지점에서 값이 상수면 사용 지점에서 즉시 대체 가능, (2) Dead code elimination: use가 없는 def를 즉시 제거 가능, (3) 레지스터 할당: liveness analysis가 단순해짐, (4) 루프 불변 코드 이동: 의존성 분석이 쉬워짐. LLVM, GCC의 middle-end 최적화 패스들이 모두 SSA 기반으로 동작합니다.

Q3. LLVM IR의 특징과 언어 독립적 최적화가 가능한 이유는?

정답: LLVM IR이 특정 언어나 아키텍처에 종속되지 않은 범용 저수준 IR이기 때문입니다.

설명: LLVM IR의 핵심 특징은 (1) 강타입 시스템으로 타입 안전성 보장, (2) SSA 기반으로 분석/최적화 용이, (3) 무한 가상 레지스터 (물리 레지스터 할당은 코드 생성 시), (4) 명시적인 메모리 모델과 정렬 정보. 언어 독립성은 프론트엔드(C→IR, Rust→IR, Swift→IR)와 백엔드(IR→x86, IR→ARM, IR→RISC-V)를 분리했기 때문입니다. 중간의 최적화 패스는 IR만 다루므로 모든 언어-아키텍처 조합에 적용됩니다.

Q4. TVM에서 operator fusion이 GPU 성능을 향상시키는 원리는?

정답: GPU 메모리 대역폭 병목을 줄이고 캐시 효율을 극대화하기 때문입니다.

설명: GPU 연산에서 실제 병목은 연산(FLOPS) 자체가 아니라 메모리 대역폭(HBM ↔ SRAM)인 경우가 많습니다. 퓨전 없이 연산할 때는 각 연산마다 중간 결과를 HBM(전역 메모리)에 쓰고 다음 커널이 다시 읽어야 합니다. 퓨전 후에는 중간 결과가 L1/L2 캐시나 레지스터에 머물러 HBM 접근 횟수가 크게 줄어듭니다. 특히 elementwise 연산들(ReLU, BatchNorm 등)은 메모리 바운드이므로 퓨전 효과가 극적입니다. TVM의 Relay→TIR 변환 과정에서 퓨전 가능한 연산 그룹을 자동으로 탐지합니다.

Q5. torch.compile()이 내부적으로 사용하는 컴파일 스택은?

정답: TorchDynamo → AOTAutograd → TorchInductor의 3단계 스택입니다.

설명: (1) TorchDynamo: Python 바이트코드 수준에서 동작하며, Python 코드를 실행하면서 PyTorch 연산 부분을 FX Graph로 캡처합니다. Python의 동적 특성(제어 흐름, 데이터 의존적 분기)을 처리하기 위해 guard 기반 특수화를 사용합니다. (2) AOTAutograd: 순전파 FX Graph를 받아 역전파 그래프까지 미리(ahead-of-time) 생성합니다. (3) TorchInductor: FX Graph를 실제 GPU 커널 코드(Triton) 또는 CPU 코드(C++)로 변환합니다. operator fusion, tiling, vectorization 등의 최적화가 여기서 적용됩니다. mode 옵션으로 'default', 'reduce-overhead', 'max-autotune' 중 선택 가능합니다.


마치며

컴파일러 설계는 단순한 이론이 아닙니다. 오늘날 AI/ML 인프라의 핵심인 torch.compile(), TVM, XLA, MLIR은 모두 수십 년의 컴파일러 연구 위에 세워져 있습니다.

Lexer → Parser → AST → IR → Optimization → Code Gen 파이프라인을 이해하면, 왜 GPU 프로그래밍에서 커널 퓨전이 중요한지, 왜 LLVM이 이렇게 많은 언어의 백엔드로 사용되는지, 왜 SSA form이 최적화의 기반이 되는지를 자연스럽게 이해할 수 있습니다.

다음 단계로는 LLVM 튜토리얼(Kaleidoscope), TVM 공식 문서, 그리고 PyTorch 2.0 논문을 읽어보시길 추천합니다.