Lumen 사이드 3편 – 컴파일러가 코드를 어셈블리로 바꾸는 4단계

Close up of lush green thuja branches creating a t

2편에서 “왜 직접 짰나”를 풀었으니 3편부터는 본격적으로 Lumen 내부로 들어가요. 첫 주제는 컴파일러의 4단계예요. DSL, AST, IR, 코드 생성. 처음 컴파일러 책 펴면 이 용어들이 진짜 추상적으로 느껴지거든요. 저도 작년에 Engineering a Compiler(Cooper & Torczon) 펴서 절반 못 읽고 덮은 기억이 있어요.

근데 막상 Lumen 짜면서 한 단계씩 손으로 만들어 보니까 그게 정확히 건축가가 청사진 만드는 4단계와 같은 흐름이더라구요. 손 스케치 → 구조 평면도 → 시공 도면 → 현장 작업 지시서. 이 비유로 풀면 머리에 진짜 잘 들어와요. 한 번 잡으면 안 잊혀요.

이번 편은 약간 코드가 들어가지만 “이게 어떤 모양인지 한 번 본다” 정도라 부담 갖지 않으셔도 돼요. Rust 문법 몰라도 됩니다. 흐름만 보시면 충분해요.

건축 청사진 위에 놓인 색연필과 자

컴파일러가 하는 일

컴파일러는 한 줄로 정리하면 사람이 쓴 코드를 컴퓨터가 실행 가능한 명령어로 바꿔 주는 프로그램입니다. Rust 컴파일러는 Rust 코드를 받아 기계어를 만들고, Python 인터프리터는 Python 코드를 받아 (인터프리트 방식으로) 실행하고요.

그런데 컴파일러가 “Rust 코드 → 기계어”를 한 번에 통째로 변환하지는 않아요. 중간에 여러 단계를 거칩니다. 각 단계마다 코드가 다른 형태로 표현돼요.

저도 사이드 시작하기 전엔 “컴파일러는 코드를 기계어로 바꾸는 마법의 상자”라고 막연하게 생각했거든요. 직접 짜보니까 그게 마법이 아니고 한 단계씩 차근차근 변환하는 거더라구요. 각 단계가 따로 따로 디버깅 가능해서 의외로 명확해요.

청사진 만드는 4단계 – 그리고 컴파일러의 4단계

새 건물을 짓는다고 합시다. 건축가가 처음부터 끝까지 한 번에 시공 도면을 그리진 않아요. 단계가 있습니다.

  1. 스케치: 건축가가 처음 그리는 손그림. “여기에 거실, 저기에 주방, 발코니는 이쪽”이라는 큰 그림. 디테일 없고 의도만.
  2. 구조 평면도: 스케치를 정식 평면도로 정리. 벽 위치, 방 크기, 문 방향 등이 정확히 표시.
  3. 시공 도면: 평면도를 더 자세한 시공 가능 형태로 변환. “벽돌 N개, 시멘트 M kg, 철근 K m” 같은 구체적 자재 명세 + 시공 순서.
  4. 현장 작업 지시서: 시공 도면을 작업팀별로 쪼개서 “1번 팀은 기초 공사, 2번 팀은 1층 벽돌, 3번 팀은 …” 같은 실제 작업 지시.

같은 건물인데 표현이 4번 바뀌어요. 각 단계마다 표현이 점점 구체적이고 시공 가능해집니다.

컴파일러도 정확히 똑같이 4단계예요.

원본 코드 (사람이 쓴 텍스트)
   ↓ 1. 렉서 (토큰으로 쪼개기)         ← 스케치 → 평면도의 첫 단계
   ↓ 2. 파서 (트리로 구조 정리)         ← 구조 평면도 (AST)
   ↓ 3. IR로 변환 (시공 가능 형태)      ← 시공 도면
   ↓ 4. 코드 생성 (어셈블리)              ← 현장 작업 지시서
실행 가능한 기계어

이 4단계는 모든 컴파일러가 본질적으로 같습니다. Rust, C, Go, Java 다 같은 흐름. Lumen도 마찬가지예요. 그래서 한 번 이 흐름을 잡으면 다른 컴파일러 봐도 익숙합니다.

이 그림 처음 본 게 작년 컴파일러 책 첫 장이었는데, 그땐 “왜 이렇게 많은 단계가 필요하지?” 싶었거든요. 직접 짜보니까 각 단계가 따로 분리돼 있는 게 진짜 도움이 되더라구요. 어디서 버그 났는지 단계별로 추적 가능. 한 단계만 손대도 다른 단계가 안 망가지고요. 이 모듈 분리의 가치는 5절(왜 4단계나?)에서 더 풀어볼게요.

DSL – 우리만의 작은 언어

DSL은 Domain-Specific Language의 약자입니다. “특정 분야 전용 작은 언어”라는 뜻이에요. 일반 프로그래밍 언어(Rust, Python)와 달리 한 분야 일만 잘 표현하도록 설계된 언어입니다.

예를 들면 SQL은 데이터베이스 질의용 DSL이고, regex는 텍스트 패턴용 DSL이고, CSS는 스타일링용 DSL이에요. 일반 언어 안에서 그 일을 할 수도 있지만, DSL을 쓰면 그 분야를 훨씬 간결하게 표현할 수 있어요. SQL이 데이터 질의용으로 압도적으로 편한 거 한 번이라도 써본 분은 아실 거예요.

도시 건축으로 풀면 — 일반 프로그래밍 언어는 모든 종류의 건물을 다 짓는 범용 건축 설계 시스템이에요. 카페부터 공항까지 다 가능. 그만큼 표현이 복잡합니다. 카페만 지으려고 해도 공항 짓는 데 필요한 옵션들이 다 노출돼 있는 시스템이에요.

DSL은 특정 건물 타입 전용 설계 시스템이에요. “이 시스템으로는 LLM 도시만 설계할 수 있다. 대신 단순하고 빠르다.” 그런 거죠.

Lumen은 텐서 연산용 DSL을 만들었습니다. 매트멀, 활성 함수, 정규화 같은 LLM 내부 연산을 간단히 표현하기 위함이에요. 실제 예시:

fn matmul(
    a: tensor<f32, [64, 128]>,
    b: tensor<f32, [128, 32]>,
) -> tensor<f32, [64, 32]> {
    return a @ b;
}

이 코드를 보면 몇 가지 특징이 있어요.

  • 함수 이름이 matmul
  • 입력이 두 개의 텐서. 각각 [64, 128], [128, 32] 모양
  • 출력 텐서 모양 [64, 32]
  • 함수 본문은 한 줄. @ 기호로 매트멀 표현

Rust나 Python이 이걸 표현하려면 라이브러리 임포트하고 dtype 클래스 가져오고 어쩌고 코드가 길어집니다. PyTorch로 쓰면 torch.matmul(a, b)인데 그러려면 import 줄, 텐서 dtype 명시, 형상 검증 코드가 다 따로 필요해요. DSL은 이 한 가지 일(텐서 연산)만 잘 표현하도록 단어와 기호를 단순하게 했어요.

왜 DSL을 직접 만드나

좋은 의문이에요. Rust나 Python을 그대로 쓰면 안 되나요? 가능은 합니다. 그런데 DSL을 만들면 다음 단계가 훨씬 쉬워져요.

  • 타입에 텐서 형상이 들어감: tensor<f32, [64, 128]>. 컴파일 시점에 매트멀의 형상이 맞는지 검증 가능. [64, 128] @ [128, 32]는 OK지만 [64, 128] @ [127, 32]는 컴파일 에러로 거부.
  • 표현이 단순함: 분석할 코드가 단순하니 컴파일러도 단순해짐. Rust 컴파일러는 100만 줄, 우리 DSL 컴파일러는 5천 줄.
  • 다른 백엔드로 쉽게 변환 가능: 같은 DSL 코드를 x86_64 매트멀, ARM64 매트멀, CUDA 매트멀 등 여러 목표로 변환 가능.

DSL의 가장 큰 가치는 표현이 단순하므로 컴파일러도 단순하다는 점입니다. 처음부터 짤 때 Rust 같은 거대 언어를 다루려고 하면 평생 가도 못 끝나요. 작은 DSL 하나로 시작해서 점점 확장하는 게 현실적인 길이에요.

이거 사이드 1주차에 결정한 건데, 그땐 의심도 있었어요. “DSL 직접 만들면 사용성이 떨어지지 않을까?” 싶었거든요. 근데 막상 작은 DSL 짜고 보니 진짜 5천 줄로 매트멀 표현이 가능하더라구요. Rust로 같은 일 하려면 PyTorch 같은 거대 라이브러리 임포트하고 dtype·shape 관리 코드를 또 따로 짜야 해서 오히려 복잡했을 거예요.

도시 건축으로 풀면 — DSL은 “이 건축 설계 시스템은 LLM 도시만 그릴 수 있어요. 그 대신 사용법이 30분이면 익혀집니다”라는 시스템. 범용 시스템은 익히는 데만 6개월이고, 다 익혀도 LLM 도시 짓는 데 필요 없는 기능들이 90%예요.

DSL 짜본 1주차 일기

이건 좀 다른 얘긴데, Lumen 1주차에 DSL을 어떻게 짰는지 짧게 풀어볼게요. 처음에 어떻게 시작했나 궁금하실 수도 있으니까.

1일차: Cargo로 빈 crate 생성. `Cargo.toml`에 의존성 0개. `cargo new –lib lumen-dsl`로 시작.

2~3일차: 렉서 짜기. 입력 텍스트를 토큰(`fn`, `matmul`, `(`, `tensor` 등)으로 쪼개는 함수. 간단한 상태 머신으로 짰어요. 핸드코딩으로 약 400줄. 라이브러리(LALRPOP, pest 같은) 안 썼어요. 학습 목적이라 처음부터 직접.

4~5일차: 파서 짜기. 토큰 리스트를 받아서 AST 트리를 만드는 함수. recursive descent parser 패턴. 약 800줄. 첫 번째 작동하는 매트멀 함수 파싱 통과한 순간 진짜 작은 감동이었어요. fn matmul(a: tensor<f32, [4, 8]>, b: tensor<f32, [8, 2]>) -> tensor<f32, [4, 2]> { return a @ b; } 이 한 줄이 처음 트리로 변환된 순간.

6~7일차: 타입 체커 짜기. 매트멀 형상 검증, 변수 스코프 검증 등. 약 600줄. 21개 단위 테스트 통과.

1주차 끝. 약 1800줄 Rust 코드. 매일 1~2시간씩 작업한 결과예요. 이 단계가 결과물로는 “텍스트 파싱” 한 줄이라 외부엔 안 보이는데, 안 짜놓으면 그 다음 단계가 시작이 안 되니까 사이드의 기초공사 단계인 거예요.

잎 없는 나무가지 모노 톤

AST – 코드를 구조 평면도로

DSL 코드가 한 문자열이라고 합시다.

return a @ b;

이 문자열을 컴파일러는 토큰으로 쪼개고, 그걸 다시 트리 모양으로 정리해요. 이 트리가 AST (Abstract Syntax Tree)입니다. 추상 구문 트리.

도시 건축으로 풀면 AST는 구조 평면도예요. 스케치에 표시된 “거실, 주방, 발코니” 같은 요소들이 어떤 관계로 연결돼 있는지 트리 형태로 정리한 거.

return a @ b;의 AST는 이런 모양이에요.

Return
  └── MatMul
        ├── Variable(a)
        └── Variable(b)

읽는 법: 최상위 노드가 “Return”이고, 그 아래 자식이 MatMul. MatMul의 자식은 두 변수 a와 b. 이 트리를 위에서 아래로 따라가면 “a와 b를 매트멀한 결과를 return 한다”가 됩니다.

평면도로 풀면 “출구(Return)는 거실(MatMul)에 연결되고, 거실에는 창문(a)과 문(b)이 있다” 같은 구조 표현이에요.

더 복잡한 코드도 마찬가지로 트리가 됩니다.

return (a @ b) + c;
Return
  └── Add
        ├── MatMul
        │     ├── Variable(a)
        │     └── Variable(b)
        └── Variable(c)

평면도가 한 단계 더 깊어진 거죠. 거실 안에 또 작은 방이 있는 구조.

AST가 왜 필요한가

문자열을 그냥 두면 안 되나요? 두 가지 이유가 있어요.

1. 평면도가 분석하기 쉽다. 문자열에서 “이 코드가 잘못된 부분”을 찾으려면 매번 다시 파싱해야 합니다. 트리는 한 번 만들어 두면 그 위에서 검사하고 변환하기 편해요. 건축으로 풀면 — 스케치(문자열)는 보고 매번 새로 해석해야 하지만, 평면도(트리)는 한 번 그려 두면 그 위에 측정·검토·변경이 다 가능합니다.

2. 변환할 때 정확하다. “a @ b를 4×8 register tile로 변환”하려면 매트멀의 위치를 정확히 알아야 해요. 트리에서 MatMul 노드를 찾는 건 명확한데, 문자열에서 “@”를 검색하면 다른 곳에 있는 @와 헷갈릴 수 있어요. 예를 들어 주석 안에 “@”가 있어도 검색에 걸리겠죠. 트리는 그런 모호함이 없어요.

AST는 컴파일러의 “내부 표현 첫 번째 단계”예요. 인간이 쓴 텍스트를 컴파일러가 다루기 좋은 형태로 첫 정리한 거예요.

IR – 시공 도면으로 펼치기

AST 다음 단계가 IR (Intermediate Representation)입니다. 중간 표현이라는 뜻인데, 조금 더 풀면 “의미만 남기고 표현은 단순화한 형태”예요.

왜 또 다른 표현이 필요할까요? AST는 “사람이 쓴 코드의 구조”를 보존하고 있어요. 함수, return 키워드, 변수 이름 같은 게 다 들어 있습니다. 그런데 컴파일러가 코드를 변환할 때는 이게 다 필요 없어요. 필요한 건 “각 연산이 무엇이고 무슨 데이터를 받아 무슨 데이터를 만드냐”는 것뿐이에요.

이 핵심만 남긴 게 IR입니다.

도시 건축으로 풀면 IR은 시공 도면이에요. 구조 평면도(AST)는 거실, 주방, 발코니 같은 “공간 개념”을 표현하지만 시공 도면(IR)은 “여기에 벽돌 N개, 시멘트 M kg, 철근 K m, 그 다음 미장, 그 다음 도배” 같이 시공 단위로 풀어 놓은 도면이에요.

같은 거실이라도 시공 도면에서는 벽 4면, 바닥, 천장, 조명, 콘센트 같은 시공 단위로 쪼개져 있어요. 거실이라는 개념이 사라지고 시공 단계 리스트만 남는 거예요.

Lumen의 IR 예시

return a @ b;를 IR로 표현하면 이런 모양이에요.

%0 = Param(0)              : tensor<f32, [64, 128]>
%1 = Param(1)              : tensor<f32, [128, 32]>
%2 = MatMul %0, %1         : tensor<f32, [64, 32]>
     Return %2

각 줄은 하나의 연산(시공 단위)을 표시해요.

  • %0, %1, %2값의 이름(SSA 형식이라 함). 한 번 만들어진 값은 다시 변경 안 됨.
  • Param(0), Param(1)은 입력 매개변수.
  • MatMul %0, %1은 두 입력을 매트멀.
  • 각 값의 형상과 타입이 : tensor<f32, [64, 32]>로 명시됨.

이걸 AST와 비교하면 한 가지 큰 차이가 있어요. AST는 트리(중첩 구조)지만 IR은 평탄(flat list)입니다. 모든 중간 결과에 이름이 붙어 있어요. 이 덕분에 “어떤 값이 어디서 사용되는지”를 쉽게 분석할 수 있어요.

건축으로 풀면 평면도는 “거실 안에 식탁이 있다”고 표시되지만 시공 도면은 “1단계: 바닥 시공, 2단계: 벽 4면 시공, 3단계: 식탁 자리에 배선, 4단계: 식탁 배치” 같이 순서가 있는 평탄한 리스트예요. 평면도의 중첩 구조가 시공 단계의 평탄한 리스트로 펼쳐진 거.

SSA 형식을 채택한 이유

SSA(Static Single Assignment)는 “한 번 만들어진 값은 절대 변경 안 됨”이라는 약속이에요. 각 변수가 정확히 한 번만 정의되고, 그 뒤로는 읽기만 가능. 이게 일반 프로그래밍 언어 변수와 다른 점이에요.

이 약속이 왜 좋냐면, 분석이 단순해져요. “이 변수가 언제 변경됐지?”를 추적할 필요가 없거든요. 한 번 정의됐으니 그 값이 끝까지 그대로. 컴파일러 패스(optimization pass)들이 짜기 정말 쉬워져요.

Lumen은 처음부터 SSA로 갔어요. LLVM도 SSA고 MLIR도 SSA고, 거의 모든 현대 컴파일러가 SSA를 표준으로 써요. 사이드라도 표준 따라가는 게 좋다고 판단했어요. 5천 줄 안에 들어가니까.

IR이 빛나는 순간 – 패턴 매칭과 융합

IR이 진짜로 빛나는 순간은 “여러 연산을 한 덩어리로 합치는 최적화”를 할 때예요. 예를 들면 LLM에서는 이런 패턴이 자주 나옵니다.

%0 = Param(0)              : tensor<q8_0, [...]>     ← Q8 양자화 weight
%1 = Param(1)              : tensor<f32, [...]>      ← F32 activation
%2 = Dequantize %0         : tensor<f32, [...]>      ← Q8 → F32 변환
%3 = MatMul %2, %1         : tensor<f32, [...]>      ← 매트멀
     Return %3

이걸 그대로 실행하면 Q8 → F32 변환 한 번 하고 F32 매트멀 한 번 하는 두 단계가 돼요. 그런데 패턴을 인식해서 두 단계를 한 단계로 합치는 게 가능합니다. “Q8 weight × F32 activation = F32 output”이라는 융합 매트멀 커널을 만들면 변환을 따로 하지 않고 매트멀 안에서 한 번에 처리해요.

이게 6편 양자화에서 자세히 다룰 Fused Quantized MatMul이에요. 이론적으로 4배 가속이고, 실제로 측정해 보니 정확히 4배 가속이었어요. 매번 dequantize 단계를 거치는 비용이 그만큼 컸던 거예요. 7편에서도 직관이 맞은 케이스로 등장.

건축으로 풀면 — 시공 도면에 “1단계: 벽돌 운반, 2단계: 시멘트 운반, 3단계: 벽 쌓기”가 있다고 합시다. 패턴 매칭으로 “벽돌과 시멘트를 같은 트럭에 실어서 1단계만에 운반 + 쌓기”로 합치는 거예요. 같은 결과인데 시간 절약.

Lumen은 이런 패턴 매칭으로 매트멀 자동 융합을 합니다(5편에서 자세히). 같은 일을 더 빠르게 하는 핵심 기술이에요. 이걸 AST로 하면 어려워요. AST는 트리 구조라 “Dequantize 다음에 MatMul”이라는 패턴을 찾기가 복잡합니다. IR은 평탄 구조라 “%2의 op이 Dequantize이고 %2가 MatMul의 입력”이라는 조건을 단순한 코드로 검사할 수 있어요.

실제로 Lumen의 패턴 매칭 코드는 약 200줄 정도예요. 5천 줄짜리 컴파일러에서 패턴 매칭만 200줄. 이 200줄이 4배 가속을 만들었어요. 코드 양 대비 가성비가 진짜 좋아요.

단계별로 한 번에 다시 보기

지금까지 본 세 단계(+코드 생성)를 한 코드의 흐름으로 정리할게요.

원본 DSL 코드 (스케치):

fn matmul(
    a: tensor<f32, [64, 128]>,
    b: tensor<f32, [128, 32]>,
) -> tensor<f32, [64, 32]> {
    return a @ b;
}

렉서가 토큰으로 쪼갬 (스케치 → 평면도 첫 단계):

[fn] [matmul] [(] [a] [:] [tensor] [<] [f32] [,] [[] [64] [,] [128] []]
[>] [,] [b] [:] [tensor] [<] [f32] [,] [[] [128] [,] [32] []] [>] [)]
[->] [tensor] [<] [f32] [,] [[] [64] [,] [32] []] [>] [{] [return]
[a] [@] [b] [;] [}]

파서가 AST를 만듦 (구조 평면도):

Function "matmul"
  ├── Params
  │     ├── Param a: tensor<f32, [64, 128]>
  │     └── Param b: tensor<f32, [128, 32]>
  ├── ReturnType tensor<f32, [64, 32]>
  └── Body
        └── Return
              └── MatMul
                    ├── Variable a
                    └── Variable b

IR로 lower (시공 도면):

Function "matmul"(
    %0 : tensor<f32, [64, 128]>,
    %1 : tensor<f32, [128, 32]>,
) -> tensor<f32, [64, 32]>:
    %2 = MatMul %0, %1 : tensor<f32, [64, 32]>
    Return %2

Codegen이 x86_64 어셈블리로 변환 (현장 작업 지시서, 실제로는 더 복잡):

matmul:
    push rbp
    mov rbp, rsp
    # ... 매트멀 loop ...
    mov rax, [rdi]          # a 메모리 시작 주소
    mov rbx, [rsi]          # b 메모리 시작 주소
    # AVX2 SIMD 명령으로 8개 float 동시 처리
    vmovups ymm0, [rax]
    vfmadd231ps ymm1, ymm0, [rbx]
    # ...
    pop rbp
    ret

각 단계마다 표현이 점점 컴퓨터에 가까워지고 추상이 줄어듭니다. 사람 입장에서는 첫 단계(DSL)가 읽기 쉽고, 컴퓨터 입장에서는 마지막 단계(어셈블리)가 실행 가능해요. 컴파일러는 이 두 세계를 잇는 다리예요.

왜 4단계나 거치나 – 한 번에 안 되나

자연스러운 의문입니다. DSL을 곧바로 어셈블리로 바꾸면 안 되나요? 단계가 많을수록 코드가 복잡해질 텐데요.

이걸 한 단계로 만들 수는 있어요. 그런데 두 가지 큰 문제가 생깁니다.

1. 백엔드별 분기가 어려워진다. x86_64, ARM64, CUDA 세 종류 백엔드를 지원한다고 합시다. DSL을 직접 변환하면 각 백엔드마다 DSL 파싱부터 어셈블리 출력까지 전부 다시 짜야 해요. 3배 작업.

중간 IR을 거치면 DSL → IR은 한 번만 짜고 IR → 어셈블리 변환만 백엔드별로 짭니다. 공통 부분이 분리돼서 작업량이 훨씬 적어요. 건축으로 풀면 — 같은 시공 도면을 한국식 공사팀에게도, 일본식 공사팀에게도, 독일식 공사팀에게도 줄 수 있어요. 시공 도면이 공통 표준이니까. 스케치를 바로 각 나라 공사팀 작업 지시서로 바꾸려고 하면 3배 다시 짜야 해요.

Lumen도 이 패턴 그대로 가요. 현재는 x86_64만 지원하지만 ARM64 백엔드 추가하려면 lumen-codegen만 손대면 돼요. DSL과 IR은 그대로. 6주 사이드에서 ARM64까진 못 갔지만 구조는 잡혀 있어서 다음 cycle에 가능해요.

2. 최적화가 어려워진다. 위에서 본 “Dequantize + MatMul → 융합 매트멀” 같은 최적화는 의미만 남은 IR에서 하기 쉬워요. DSL 단계에서 하려면 문법 디테일에 파묻혀 패턴 매칭이 복잡해집니다.

그래서 단계가 많은 건 코드를 더 복잡하게 만드는 게 아니라 모듈 분리해서 관리하기 쉽게 만드는 거예요. 처음 컴파일러 책을 펴면 단계가 너무 많아 헷갈리는데, 실제로 짜보면 “각 단계가 하는 일이 명확해서 디버깅이 편하다”는 걸 느낍니다.

LLVM, MLIR과 비교

현대 컴파일러 인프라 중에 LLVM과 MLIR이 있어요. 두 가지 다 IR을 핵심으로 한 컴파일러 인프라예요.

  • LLVM: C/C++/Rust 등 거의 모든 시스템 언어가 의존하는 컴파일러 백엔드. 한 종류의 IR(LLVM IR)을 사용.
  • MLIR: LLVM 위에 만들어진 멀티 레벨 IR 프레임워크. 도메인별로 IR을 여러 단계 둠. 텐서, 매트멀 같은 고수준 표현부터 LLVM IR까지 점진적 lowering.

Lumen은 이 둘 다 안 써요. 직접 짜기 위해서. 만약 production 도구를 만들려면 MLIR 기반으로 짜는 게 훨씬 효율적이에요. tinygrad 같은 도구도 이 방향으로 가고 있고요. Lumen은 학습 목적이라 처음부터 손으로 짰어요.

Lumen 코드의 실제 모양

실제 Lumen 저장소를 보면 다음 crate로 나뉘어 있어요.

crates/
├── lumen-dsl/        ← 렉서, 파서, AST, 타입 검사 (스케치 → 평면도)
├── lumen-ir/         ← SSA IR 정의, IR 변환 패스 (평면도 → 시공 도면)
├── lumen-codegen/    ← x86_64, ARM64 코드 생성 (시공 도면 → 작업 지시서)
├── lumen-jit/        ← 런타임 컴파일, 코드 캐시 (4편에서 다룸)
├── lumen-runtime/    ← 텐서, 메모리 풀, 디스패치
├── lumen-model/      ← GGUF 로더, 토크나이저, transformer
└── lumen-cli/        ← `lumen` 명령줄 도구

이 시리즈의 1~3편이 끝난 시점에는 처음 세 개(dsl, ir, codegen)가 다 만들어졌다고 생각하면 됩니다. 다음 4편에서 lumen-jit이 등장해요. 5편에서 lumen-codegen의 실제 어셈블리 출력 디테일을 더 보고, 6편 양자화는 lumen-runtime/lumen-model에 들어 있어요.

각 crate가 한 책임을 가지고 분리되어 있어서 한 분야씩 따로 이해할 수 있어요. “내가 이번 주는 IR 패스만 본다”든가 “이번 주는 매트멀 codegen만 본다”든가가 가능합니다. 처음부터 끝까지 한 번에 다 이해하려고 하면 막막한데, 한 번에 한 crate씩 보면 관리 가능한 크기예요. 도시 시공팀도 기초·벽돌·전기·인테리어 팀이 나뉘어 있는 것과 같은 원리.

제 사이드 작업 호흡도 이렇게 갔어요. 1주차는 lumen-dsl만, 2주차는 lumen-ir만, 3주차는 lumen-codegen만. 한 주에 한 crate를 집중해서 그게 작동하는 걸 확인하고 다음 crate로. 이 호흡이 일과 후 1~2시간 짜는 사이드에 잘 맞았어요.

3편 정리

  1. 컴파일러는 코드를 4단계로 변환한다: 토큰(렉서) → AST(파서) → IR(중간 표현) → 어셈블리(codegen). 건축으로 풀면 스케치 → 구조 평면도 → 시공 도면 → 현장 작업 지시서.
  2. DSL은 한 분야에 특화된 작은 언어. 매트멀, 양자화 같은 텐서 연산만 표현하므로 단순. Rust 같은 거대 언어를 다루지 않아 컴파일러도 단순. LLM 도시만 짓는 전용 설계 시스템.
  3. AST는 트리(평면도), IR은 평탄(시공 도면). AST는 사람이 쓴 코드의 구조를 보존하고, IR은 의미만 남기고 분석·변환하기 좋은 형태로 단순화.
  4. IR의 진짜 가치는 패턴 매칭과 최적화. “Dequantize + MatMul → 융합 매트멀” 같은 변환을 IR에서 짧은 코드로 표현 가능. 4배 가속을 200줄로.
  5. 단계가 많은 이유는 모듈 분리. 백엔드별로 따로 짜기 쉽고, 한 분야씩 관리 가능한 크기로 쪼개기 위함. 시공팀 분리와 같은 원리.

컴파일러가 추상적이라 막연하게 느껴졌던 분이라면 이번 편에서 “아, 이게 그런 흐름이구나” 정도 잡히셨으면 좋겠어요. 4단계 흐름만 잡으면 어떤 컴파일러 봐도 비슷한 구조라 이해가 빨라져요.

다음 편 미리보기

4편은 JIT (Just-In-Time 컴파일)이에요. 일반 컴파일러는 시공 도면을 미리 만들어서 파일로 저장해 두는데, JIT은 공사가 시작되고 나서 그 자리에서 시공 도면을 만들어 즉시 시공합니다. 사전 계획 vs 현장 시공의 차이.

이게 진짜 신기한 영역이에요. 컴퓨터가 실행 중에 자기 자신을 위한 어셈블리 코드를 만들어서 즉시 실행하는 거니까요. “메모리에 코드를 쓰고 그걸 함수처럼 호출”이 어떻게 가능한지를 풀어볼게요. LLM 추론에 왜 JIT이 잘 맞는지도 같이.

이 시리즈 본문 전체는 GitHub에도 정리해 뒀어요. github.com/redchupa/lumen/docs/tutorial에서 원문 격으로 보실 수 있어요.

그럼 다음 편에서 뵐게요. 컴파일러 시작해 보고 싶은데 막막하신 분이 있다면 댓글 남겨주세요. 어떤 자료부터 보면 좋을지 같이 정리해 봐요.

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤