이 글은 국산 NPU에 미지원 모델을 이식한 이야기에서 갈라져 나온 개념 설명입니다. 그 글이 “실제로 어떻게 뚫었나"라면, 이 글은 그 밑에 깔린 **“모델을 컴파일한다는 게 대체 무슨 뜻인가”**를 처음부터 풀어봅니다. 딥러닝을 조금 아는 분이라면 무리 없이 읽히도록 썼습니다.
“컴파일"이라는 익숙한 단어부터
프로그래밍에서 컴파일은 사람이 읽는 소스 코드(C, Rust 등)를 CPU가 곧바로 실행하는 기계어로 미리 번역하는 과정입니다. 번역을 미리 해두는 대신, 실행할 때는 빠릅니다.
뉴럴넷 컴파일도 정확히 같은 발상입니다. 파이토치로 짠 모델을, 특정 가속기(GPU·NPU 등)가 곧바로 실행할 수 있는 형태로 미리 번역해 둡니다. 딱 하나 다른 점은 번역의 출발점입니다.
- 일반 컴파일러의 입력 = 텍스트 소스 코드
- 뉴럴넷 컴파일러의 입력 = 연산 그래프(computation graph)
연산 그래프란 “이 텐서에 행렬곱 → 그다음 어텐션 → 그다음 정규화 → …“처럼 텐서 연산들이 이어진 방향성 그래프입니다. 모델의 forward()가 하는 일을 그림으로 펼친 것이라고 보면 됩니다.
다만 “일자로 흐르는 파이프라인"은 교과서 속 단순 모델 이야기고, 실제 모델은 여러 모듈이 갈라지고 합쳐지는 그래프입니다. 예를 들어 이미지 편집 모델(Qwen-Image-Edit류)은 입력 이미지·편집 프롬프트·타임스텝이 각기 다른 인코더를 거쳐 하나의 백본에서 합류하고, 디노이징 루프를 돌며 되먹임됩니다.
flowchart TD
IMG["입력 이미지"] --> VAE["VAE 인코더"]
IMG --> VIT["비전 인코더<br/>Qwen2.5-VL"]
TXT["편집 프롬프트"] --> TENC["텍스트 인코더<br/>LLM"]
TS["timestep t"] --> TEMB["시간 임베딩"]
VAE --> LAT["이미지 잠재 토큰"]
VIT --> COND["의미 조건 토큰"]
TENC --> TTOK["텍스트 토큰"]
LAT --> BB
COND --> BB
TTOK --> BB
TEMB --> BB
subgraph BB["MMDiT 백본 · 멀티모달 조인트 어텐션 × N블록"]
direction TB
JA["조인트 셀프-어텐션<br/>이미지 ↔ 텍스트"] --> FF["MLP"]
FF -. 잔차 .-> JA
end
BB --> EPS["예측 노이즈 ε"]
EPS --> LOOP{"디노이징 루프"}
LOOP -->|"다음 스텝 t−1"| BB
LOOP -->|"완료"| VDEC["VAE 디코더"]
VDEC --> OUT["편집된 이미지"]같은 입력(이미지)이 두 갈래로 갈라져 서로 다른 인코더로 들어가고, 텍스트·타임스텝과 함께 백본에서 합류(merge) 하며, 블록 안에는 잔차 연결이, 바깥에는 디노이징 되먹임 루프가 있습니다. 컴파일러가 실제로 마주하는 그래프는 이렇게 분기·병합·반복이 얽힌 형태입니다.
여기서 중요한 사실 하나. 일반 컴파일러의 앞단인 렉시컬 어날리시스(lexical analysis, 소스 텍스트를 토큰으로 쪼개기)와 파싱(parsing, 토큰을 구문 트리로 만들기)이 뉴럴넷 컴파일에는 없습니다. 입력이 이미 구조화된 그래프(일반 컴파일러로 치면 AST/IR에 해당)로 주어지기 때문입니다. 그래서 뉴럴넷 컴파일은 컴파일러 파이프라인의 앞단을 통째로 건너뛰고, 사실상 IR 최적화와 코드 생성 단계부터 시작합니다.
그래서 왜 컴파일하나? — eager 실행의 대가
파이토치의 기본 실행 방식은 eager 실행입니다. 파이썬 코드가 한 줄 실행될 때마다, 그에 대응하는 연산 커널을 그 자리에서 즉석 호출합니다. 대화하듯 한 문장씩 통역해 주는 통역사에 가깝습니다 — 유연하고, 중간에 값을 찍어보며 디버깅하기도 쉽습니다.
문제는 비용입니다. 연산 하나하나마다 파이썬 해석 → 커널 디스패치 → 실행이 반복되고, 이 오버헤드가 매번 조금씩 다르게 붙습니다(=지연이 튄다, 지터). 범용 GPU는 커널이 워낙 빠르고 유연해 이 방식으로도 충분히 잘 돌지만, NPU 같은 특화 가속기는 무엇을 어떤 순서로 실행할지 미리 알아야 제 성능이 납니다.
컴파일은 이 통역사를 번역서로 바꾸는 일입니다. 실행 전에 그래프 전체를 받아 하나의 결정적 실행 단위로 굳혀두면, 매 요청마다 즉석에서 결정할 게 없어 실행이 튀지 않습니다.
말보다 직접 보는 게 빠릅니다. 아래에서 같은 모델을 eager와 컴파일 두 방식으로 여러 번 돌려, 지연 분포가 어떻게 다른지 비교해 보세요.
eager는 op를 하나씩 디스패치하느라 평균도 높고 분포가 넓게 흩어집니다. 컴파일은 단일 그래프를 결정적으로 실행하니 거의 한 점에 모입니다. 저지연·저지터가 필요한 실서비스에서 컴파일이 갖는 값이 여기서 드러납니다.
컴파일러가 그 사이에 하는 일
model 하나를 넘기면 컴파일러는 대략 다섯 단계를 거칩니다.
flowchart TD
A["① 그래프 캡처<br/>trace / export"] --> B["② 연산 융합·최적화"]
B --> C["③ 하드웨어 커널로 낮추기<br/>lowering"]
C --> D["④ 정적 shape 고정"]
D --> E["⑤ 메모리 계획"]
E --> F(["실행 바이너리"])① 그래프 캡처 — 파이썬을 그래프로
먼저 모델이 실제로 어떤 연산을 하는지 그래프로 뽑아냅니다. 두 가지 방식이 있습니다.
- trace (
torch.jit.trace): 예시 입력을 한 번 실제로 흘려보내며 지나간 연산을 기록. 간단하지만,if로 갈라지는 분기 같은 건 “그때 지나간 길"만 잡힙니다. - export (
torch.export): 코드를 정적으로 분석해 제어 흐름까지 포함한 그래프를 추출. 더 튼튼합니다.
② 연산 융합 — 여러 연산을 하나로
인접한 연산들을 하나로 합칩니다. 예를 들어 행렬곱 → 편향 더하기 → 활성화 함수를 따로 실행하면 중간 결과를 메모리에 세 번 썼다 읽습니다. 이를 하나의 융합 커널로 합치면 메모리 왕복이 줄어 훨씬 빨라집니다.
flowchart LR
subgraph before["융합 전 — 커널 3번, 메모리 왕복 多"]
direction LR
M1["matmul"] --> B1["+bias"] --> A1["gelu"]
end
subgraph after["융합 후 — 커널 1번"]
direction LR
F1["fused_matmul_bias_gelu"]
end
before -.최적화.-> after③ 하드웨어 커널로 낮추기(lowering)
표준 연산(행렬곱, 어텐션…) 하나하나를 그 칩 전용 명령·커널로 번역합니다. GPU 쪽 예를 보면 감이 잡힙니다. 파이토치 torch.compile의 기본 백엔드(TorchInductor)는 연산을 OpenAI Triton으로 낮춥니다. Triton은 GPU 커널을 파이썬 유사 문법으로 기술하는 언어인데, 컴파일러가 융합된 연산에 맞는 커널을 Triton 코드로 즉석 생성한 뒤 이를 다시 GPU 바이너리(PTX/cubin)로 낮춥니다. 벤더가 미리 만든 라이브러리 커널(cuBLAS 등)을 부르는 대신 그래프에 맞춰 커널을 생성하는 것이죠. NPU 컴파일러도 발상은 같습니다 — 코드 생성 백엔드가 Triton이 아니라 그 칩 전용이라는 점만 다릅니다.
④ 정적 shape 고정
입력 크기를 미리 못 박습니다(예: [1, 512]). 크기가 고정되면 실행 시 분기가 사라져 결정적 경로가 만들어지고, 융합·메모리 계획도 더 공격적으로 할 수 있습니다. NPU 관련 글에서 “고정 512토큰"이 계속 등장하는 이유가 이것입니다. 대가는 유연성 — 컴파일 시점에 정한 크기를 벗어나는 입력은 (재컴파일하거나 패딩하지 않으면) 못 넣습니다.
⑤ 메모리 계획
각 연산이 쓸 버퍼의 배치와 재사용을 실행 전에 확정합니다. 실행 중 메모리를 즉석 할당·해제하지 않으니 그만큼 빠르고 일정합니다.
컴파일 결과물은 어떻게 생겼나
결과는 하드웨어가 바로 먹는 바이너리입니다(리벨리온이라면 .rbln). 직렬화된 형태라 그대로 열어볼 순 없지만, 내부 그래프를 개념적으로 펼치면 이런 모습입니다.
# compiled_model — 내부 그래프 (개념적 표현)
target: RBLN-CA22 dtype: fp16
in %input_ids : i64[1, 512]
in %attention_mask : i64[1, 512]
%emb = embedding %input_ids -> f16[1,512,768]
%h0 = layernorm %emb
# × N encoder layers:
%a = sdpa_attention %h{i}, mask=%attention_mask @kernel=attn_fused
%m = geglu_mlp %a @kernel=geglu_fused
...
%pool = mean_pool %enc, mask=%attention_mask -> f16[1,768]
%log = linear %pool, W=const[768,2] -> f16[1,2]
out %log
앞서 본 단계들이 그대로 드러납니다 — 연산이 @kernel=..._fused 형태로 전용 커널에 매핑됐고, 모든 텐서 shape이 [1,512,...]로 정적으로 고정돼 있습니다.
이미 있는 것들 — ONNX와 컴파일 생태계
지금까지 “컴파일러"를 하나로 뭉뚱그렸지만, 실제로는 여러 표준과 도구가 층을 이루고 있습니다. 그 허브에 자주 등장하는 것이 ONNX(Open Neural Network Exchange) 입니다.
ONNX는 컴파일러가 아니라 연산 그래프를 담는 공개 표준 포맷입니다. 파이토치든 텐서플로든 어디서 만든 모델이라도 ONNX로 내보내면(export), 그 그래프를 읽을 수 있는 여러 런타임·컴파일러가 각자의 백엔드로 실행합니다. 모델을 만든 프레임워크와 배포처를 분리(decouple) 하는 공통어인 셈입니다.
flowchart TD
PT["PyTorch"] --> ONNX
TF["TensorFlow"] --> ONNX
JAX["JAX"] --> ONNX
ONNX["ONNX · 공통 그래프 표준"] --> ORT["ONNX Runtime"]
ONNX --> TRT["TensorRT · NVIDIA"]
ONNX --> OV["OpenVINO · Intel"]
ONNX --> NPU["벤더 NPU 컴파일러"]자주 마주치는 도구들을 성격별로 정리하면 이렇습니다.
| 도구 | 성격 | 역할 |
|---|---|---|
| ONNX | 표준 포맷 | 프레임워크 간 그래프 교환 (그 자체는 컴파일러가 아님) |
| ONNX Runtime | 런타임 + 최적화 | 그래프를 최적화한 뒤 여러 실행 백엔드(Execution Provider)로 실행 |
| TensorRT | 컴파일러 + 런타임 | NVIDIA GPU 전용 고성능 추론 |
| Apache TVM | 컴파일러 스택 | CPU/GPU/가속기 등 다양한 백엔드를 겨냥한 오픈 컴파일러 |
| XLA | 컴파일러 | JAX·TensorFlow(및 torch/XLA), TPU 중심 |
| torch.compile | 컴파일러 | PyTorch 내장 — TorchInductor → Triton |
| OpenVINO | 컴파일러 + 런타임 | Intel CPU/iGPU/NPU 대상 |
| optimum-rbln / rebel-compiler | 벤더 스택 | 리벨리온 NPU 대상 (이 시리즈의 사례) |
큰 그림은 대개 ① 프레임워크에서 그래프를 뽑고(캡처/export) → ② ONNX 같은 공통 표현을 거치거나 곧장 → ③ 타깃 백엔드용으로 컴파일하는 흐름입니다. NPU 벤더 대부분도 ONNX 입력을 받습니다. 다만 이번 시리즈에서는 ONNX를 한 겹 거치는 대신 파이토치 네이티브 그래프(torch.export)를 벤더 컴파일러에 직접 넘기는 경로를 택했습니다 — 아키텍처가 미묘하게 어긋나는 모델을 다룰 때는, 포맷 변환을 한 단계 줄이는 편이 디버깅 표면적이 작았기 때문입니다.
트레이드오프 — 무엇을 얻고 무엇을 잃나
| eager (인터프리터) | 컴파일 (번역서) | |
|---|---|---|
| 지연 | 높고 지터 큼 | 낮고 일정 |
| 유연성 | 임의 파이썬·동적 shape OK | 고정 그래프·고정 shape |
| 디버깅 | 쉬움(값 즉시 확인) | 어려움(그래프가 굳음) |
| 준비 비용 | 없음 | 컴파일 시간 필요 |
정리하면 컴파일은 “유연한 인터프리터"를 “결정적 실행 바이너리"로 바꾸는 것입니다. 유연성을 내주고 저지연·저지터·효율을 얻습니다.
그래서 언제 컴파일하나
- 연구·프로토타이핑, 입력 크기가 매번 달라지는 경우 → eager로 충분합니다. 유연성이 값집니다.
- 실서비스 저지연 추론, 엣지·온디바이스, 특화 가속기(NPU) → 컴파일이 이깁니다. 특히 NPU는 사실상 컴파일이 필수입니다.
즉 “컴파일이 항상 정답"은 아닙니다. 유연성이 중요한 국면에선 eager가, 지연·안정성·효율이 중요한 국면에선 컴파일이 맞습니다. 국산 NPU에 실제 모델을 올리며 이 트레이드오프를 어떻게 넘겼는지는 이식 실험기에서 이어집니다.