[{"content":"배경 프로덕션에서 GPU로 서빙하던 페르소나 이탈 판정 모델(AI 캐릭터가 페르소나를 벗어나 일반 어시스턴트처럼 답하거나 요청을 거절하는 상황을 잡아내는 이진 분류기)을 국산 NPU로 옮기는 실험을 진행했습니다. 결론부터 말하면, \u0026ldquo;공식 미지원\u0026quot;이라는 벽 앞에서 원인을 하나씩 규명한 끝에 이식에 성공했습니다. 온디바이스 추론은 9.7ms(고정 512토큰, 지터 거의 0) 수준으로, 기존 클라우드 GPU 서빙과 견줘도 손색없는 지연·안정성을 확인했습니다. 이 글은 그 과정에서 무엇을 시도했고, 어디서 막혔고, 어떻게 해결했는지를 순서대로 기록한 것입니다.\n먼저 국산 NPU에 접근할 수 있었던 배경입니다. 한국은 정부 주도로 국산 AI반도체를 여러 경로로 공급하고 있습니다. 마케팅 문구가 아니라, 실제로 스타트업·연구자가 리벨리온 ATOM 계열을 무상/실증으로 확보할 수 있는 통로가 복수로 존재합니다.\nK-클라우드 프로젝트 (과기정통부·NIPA, 2023년부터 3년) — \u0026ldquo;AI반도체 팜 구축·실증\u0026rdquo; 사업. 클라우드 3사(네이버클라우드·KT클라우드·NHN클라우드)와 AI반도체 3사(리벨리온·사피온·퓨리오사AI)가 컨소시엄으로 묶여 국산 NPU 기반 고성능 클라우드를 구축·검증합니다. (ZDNet, NIPA 공고) 고성능컴퓨팅 지원사업 (과기정통부·NIPA, 2026년 167억 원 규모) — 선정된 수요기업에 국산 NPU 서버를 무상 제공합니다. 공급기업도 KT클라우드, 가비아 등 여러 곳이 선정됐고, 예컨대 가비아는 리벨리온 ATOM-Max를 무상 지원합니다. (이투데이, 벤처스퀘어) 즉 CSP(KT/NHN/네이버/가비아…) × 사업(팜 실증 / 무상 지원)의 조합으로 ATOM·ATOM+·ATOM-Max가 다양한 창구로 공급되고 있습니다. 우리도 이 중 한 경로로 ATOM+ 4장을 KT클라우드의 AI Nexus(내부적으로 Backend.AI 기반) 형태로 확보했습니다.\n이 이식으로 기대한 이득은 세 가지였습니다. ① 상시 GPU 서빙 비용 절감(정부 지원 하드웨어), ② 더 낮고 안정적인 추론 지연, ③ NVIDIA GPU 외의 벤더 옵션 확보.\n문제는 그다음이었습니다.\n스택 한눈에 보기 — 소프트웨어부터 하드웨어까지 본격적으로 들어가기 전에, 이 작업이 어떤 계층 위에서 벌어지는지부터 정리하겠습니다. 텍스트 한 줄이 판정 결과가 되기까지는 소프트웨어(위)에서 하드웨어(아래)로 이어지는 여러 계층을 통과합니다. (각 용어의 정의는 맨 아래 용어 정리에 모아 두었습니다.)\n계층 역할 GPU (일반적인 경로) NPU (ATOM+, 우리 경우) 애플리케이션 서비스 로직 (풀링 + 분류 헤드) 페르소나 이탈 판정 분류기 ← 동일 모델(백본) 텍스트를 벡터로 인코딩하는 신경망 ModernBERT(mmBERT) ← 동일 통합 레이어 모델 정의를 실행 계층에 연결 HuggingFace transformers 라이브러리 (직접 실행) optimum-rbln 컴파일러 모델을 실행 형태로 변환 torch.compile / TensorRT (선택 — 없어도 즉시 실행) rebel-compiler로 사전 컴파일 필수 런타임 실제 연산 실행 PyTorch + CUDA rebel.Runtime 하드웨어 물리 가속기 NVIDIA GPU 리벨리온 ATOM+ (RBLN-CA22) 핵심은 컴파일러 계층입니다. GPU에서는 이 스택이 대체로 투명해서 모델을 그대로 실행할 수 있지만, NPU에서는 컴파일러가 관문이 됩니다. 이 글의 대부분은 결국 이 한 계층을 통과시키는 이야기입니다.\n잠깐 — \u0026ldquo;컴파일\u0026quot;이 무슨 뜻인가 NPU 이야기를 이어가려면 \u0026ldquo;모델을 컴파일한다\u0026quot;는 말의 의미를 짚고 가야 합니다. 짧게 요약하면, 파이토치 모델을 특정 가속기가 곧바로 실행할 형태로 미리 번역해 두는 것입니다. GPU의 기본인 eager 실행(파이썬 한 줄마다 커널을 즉석 호출)은 유연하지만 지연이 튀고, 컴파일은 그래프 전체를 하나의 결정적 바이너리로 굳혀 저지연·저지터를 얻습니다. NPU는 사실상 이 컴파일이 필수이며, 뒤에서 reference_compile이나 동적 shape 분기를 꺼야 했던 것도 모두 이 \u0026ldquo;그래프를 미리 굳힌다\u0026quot;는 제약에서 나옵니다.\n개념 자체 — eager vs 컴파일, 그래프 캡처·연산 융합·커널 lowering(OpenAI Triton 등)·정적 shape — 을 그림과 인터랙티브 데모로 처음부터 풀어 쓴 별도 글이 있습니다 → 뉴럴넷 컴파일, 쉽게 이해하기. 여기서는 우리 사례에 필요한 만큼만 짚고 넘어갑니다.\n문제 인식 — NPU는 미리 컴파일해야 한다 GPU는 파이토치 모델을 그대로 실행할 수 있지만, NPU는 다릅니다. \u0026ldquo;이 모델, 이 입력 크기\u0026quot;로 미리 컴파일해서 NPU 전용 바이너리로 변환해야 합니다. 이 변환을 담당하는 것이 리벨리온의 컴파일러 rebel-compiler이고, 그 위에 표준 모델 정의를 이어 주는 통합 레이어 optimum-rbln이 얹힙니다.\n그런데 우리 백본은 mmBERT, 즉 ModernBERT 아키텍처였습니다. 확인해 보니 지원 목록에 빠져 있었습니다.\noptimum-rbln이 공식 지원하는 인코더는 bert / distilbert / roberta / xlm-roberta뿐. ModernBERT는 미지원. 리벨리온 Model Zoo에도 ModernBERT 계열은 없음. 보통 이 지점에서 XLM-R 같은 지원 모델로 갈아타는 선택을 하게 됩니다. 하지만 우리 모델의 정확도는 이미 mmBERT로 검증돼 있었고, 백본을 교체하면 재학습·재검증 비용이 큽니다. 그래서 직접 지원을 구현해 보기로 했습니다.\n가설 — \u0026ldquo;정말 지원이 불가능한가?\u0026rdquo; 미지원이라는 딱지의 실체를 들여다보면, 실제로 막히는 원인은 두 종류입니다. (1) 컴파일러가 이해하지 못하는 연산(op)이 있거나, (2) 통합 레이어에 그 모델용 래퍼가 없거나.\nModernBERT가 새롭게 도입했다고 여겨지는 요소들을 하나씩 분석했습니다.\nModernBERT 요소 이미 지원되는 곳 교차 local/global attention Gemma2(1:1), Gemma3(5:1) dual RoPE (global θ + local θ) Gemma3 큰 vocab (256k) Gemma2/3 (동일 vocab) GLU/GeGLU MLP LLaMA, Qwen2/3, Gemma 전반 SDPA attention 거의 모든 지원 디코더 전부 이미 지원되는 디코더 모델(Gemma2/3 등)에 들어 있는 요소였습니다. 즉 컴파일러의 연산 호환성은 사실상 검증돼 있고, 실제 차이는 \u0026ldquo;인코더냐 디코더냐\u0026rdquo; 뿐이라는 결론에 이르렀습니다. 그리고 인코더용 베이스 클래스(RBLNModelForMaskedLM)는 이미 존재했습니다. 결국 (2)번, 즉 래퍼만 없는 것이었습니다.\n주의할 점은 어텐션 구현이었습니다. ModernBERT는 기본이 Flash Attention 2(HuggingFace의 flash_attention_2 백엔드)인데, 이는 NVIDIA CUDA 전용 커널이라 rebel-compiler가 NPU용으로 컴파일할 수 없습니다. 다행히 PyTorch 표준 추상 API인 SDPA(scaled_dot_product_attention) 로 강제하면 컴파일러가 이를 인식해 자체 NPU 커널로 번역합니다. 정확도 손실 없이 백엔드만 교체하는 셈입니다.\n그림으로 보면, 우리가 옮기려던 mmBERT-base(ModernBERT 아키텍처)의 계산 그래프에서 막힌 지점은 딱 두 곳입니다. 나머지 연산(RoPE, GeGLU 등)은 이미 컴파일러가 지원합니다(초록).\nflowchart TB IDS[\u0026#34;input_ids · [1,512]\u0026#34;] --\u0026gt; EMB[\u0026#34;Token Embedding + LayerNorm\u0026#34;] MASK[\u0026#34;attention_mask · [1,512]\u0026#34;] -.-\u0026gt; ATTN EMB --\u0026gt; LN1 subgraph LYR[\u0026#34;인코더 레이어 × 22 (mmBERT-base = ModernBERT)\u0026#34;] direction TB LN1[\u0026#34;LayerNorm (pre-norm)\u0026#34;] --\u0026gt; ROPE[\u0026#34;RoPE (global θ / local θ)\u0026#34;] ROPE --\u0026gt; ATTN[\u0026#34;Attention\u0026lt;br/\u0026gt;local (sliding window) · global (3번째 레이어마다)\u0026#34;] ATTN --\u0026gt; RES1[\u0026#34;＋ residual\u0026#34;] RES1 --\u0026gt; LN2[\u0026#34;LayerNorm\u0026#34;] --\u0026gt; MLP[\u0026#34;GeGLU MLP\u0026#34;] --\u0026gt; RES2[\u0026#34;＋ residual\u0026#34;] end RES2 --\u0026gt; FLN[\u0026#34;final LayerNorm\u0026#34;] --\u0026gt; POOL[\u0026#34;mean pooling\u0026#34;] --\u0026gt; HEAD[\u0026#34;linear head → logits(2)\u0026#34;] ATTN -.- NOTE1[\u0026#34;⚠ 문제 ① Flash Attention 2 (CUDA 전용)\u0026lt;br/\u0026gt;→ SDPA 로 교체 · sliding_window_mask·unpadding(동적 shape) 비활성화\u0026#34;] LYR -.- NOTE2[\u0026#34;⚠ 문제 ② reference_compile=False\u0026lt;br/\u0026gt;(forward 내부 torch.compile 제거)\u0026#34;] class ATTN problem class ROPE,MLP okc class NOTE1,NOTE2 note classDef problem fill:#7f1d1d,stroke:#f87171,stroke-width:3px,color:#fff classDef okc fill:#14532d,stroke:#4ade80,color:#fff classDef note fill:#3a2f0b,stroke:#e0a020,color:#fbe8a6즉 아키텍처 전체가 문제가 아니라, 어텐션 커널(FA2) 과 forward 안의 torch.compile(reference_compile) 두 지점만 NPU 컴파일과 충돌했습니다. 이 둘을 어댑터에서 걷어내는 것이 다음 단계였습니다.\n어댑터 작성 — 제어해야 하는 세 가지 형제 클래스 RBLNRobertaForMaskedLM의 패턴을 참고해 ModernBERT 어댑터를 작성했습니다. 제어해야 하는 지점은 세 가지입니다.\nconfig 단에서 비호환 옵션 끄기 — attn_implementation=\u0026quot;sdpa\u0026quot;, reference_compile=False (ModernBERT는 forward 안에서 torch.compile을 호출하는데, 이것이 trace/export를 깨뜨립니다). wrapper 단에서 비호환 kwargs 차단 — unpadding/sequence-packing 관련 인자(indices, cu_seqlens, sliding_window_mask 등)를 명시적으로 비활성화해 동적 shape 분기를 제거. 입력 시그니처 단순화 — input_ids + attention_mask 두 개만. 여기까지는 NPU 없이도 검증할 수 있습니다. 맥북에서 torch.jit.trace와 torch.export로 그래프를 추출해 보니, 서로 다른 입력 두 종류에서 eager 대비 오차 max|diff| = 0.0. 수치적으로 완전히 동일한 그래프가 나왔습니다. 여기까지 확인되면 이식 가능성에 대한 확신이 섭니다. 남은 것은 실제 하드웨어 검증뿐입니다.\n실기 컴파일 — 첫 시도는 실패 ATOM+ 세션에 접속해(컨테이너에 rebel-compiler·optimum-rbln이 사전 설치돼 있어 별도 설치는 불필요했습니다) 컴파일을 실행했습니다. 첫 시도는 다음 오류로 실패했습니다.\nValueError: Configuration for RBLNModernBertForMaskedLMConfig not found. 원인을 추적해 보니, 참고한 optimum-rbln은 최신 main 기준이었는데 실제 컨테이너에 설치된 것은 0.10.2 릴리스였고, 이 버전은 config 클래스를 이름 기반 레지스트리(optimum.rbln 최상위 attr 또는 내부 CONFIG_MAPPING)에서만 조회했습니다. 커스텀 어댑터의 config는 거기 등록돼 있지 않으니 \u0026ldquo;찾을 수 없음\u0026quot;으로 실패한 것입니다. SDK 버전 드리프트가 만든 함정이었습니다.\n해결은 한 줄이었습니다. 모델 클래스에 config 클래스를 직접 지정해 레지스트리 조회를 건너뛰게 했습니다.\nclass RBLNModernBertForMaskedLM(RBLNModelForMaskedLM): _rbln_config_class = RBLNModernBertForMaskedLMConfig # 레지스트리 우회 다시 실행하니 이번엔 통과했습니다.\n[rebel-compiler] Target NPU: RBLN-CA22 [rebel-compiler] Tensor parallel size: 1 Computation graph generation ████████████ 100% Computation graph optimization ████████████ 100% saved -\u0026gt; compiled_model.rbln rbln-smi로 확인한 실물은 RBLN-CA22(ATOM+), 15.7GiB. \u0026ldquo;미지원\u0026rdquo; 모델이 국산 NPU 위에서 컴파일된 순간이었습니다. 컴파일 자체는 십수 초 만에 끝났습니다.\n실전 적용 — 분류기 전체를 이식하기 여기까지는 백본(MaskedLM)만 컴파일한 것이었습니다. 실제 서비스하는 것은 그 위에 mean pooling + linear head를 얹은 페르소나 이탈 판정 분류기입니다. 이것은 표준 HuggingFace 아키텍처가 아니라 커스텀 nn.Module이라, optimum-rbln의 고수준 경로 대신 컴파일러 저수준 API를 직접 사용하는 편이 깔끔했습니다.\nflowchart TD T[\u0026#34;원문 텍스트\u0026#34;] --\u0026gt; TOK[\u0026#34;Tokenizer\u0026lt;br/\u0026gt;input_ids · attention_mask · [1, 512]\u0026#34;] TOK --\u0026gt; BB subgraph G[\u0026#34;컴파일 단위 — rebel.compile_from_torch ⇒ compiled_model.rbln (ATOM+ NPU에서 실행)\u0026#34;] direction TB BB[\u0026#34;mmBERT 백본 · SDPA\u0026#34;] --\u0026gt; MP[\u0026#34;mean pooling\u0026#34;] MP --\u0026gt; LH[\u0026#34;linear head\u0026#34;] end LH --\u0026gt; LOG[\u0026#34;logits (2)\u0026#34;] LOG --\u0026gt; P[\u0026#34;softmax → 이탈 확률\u0026#34;]Tokenizer는 CPU에서 돌고, 그 아래 백본+풀링+헤드가 하나의 컴파일 단위로 묶여 NPU에서 실행됩니다. rebel.compile_from_torch(model, input_info=[(\u0026quot;input_ids\u0026quot;,[1,512],\u0026quot;int64\u0026quot;), (\u0026quot;attention_mask\u0026quot;,[1,512],\u0026quot;int64\u0026quot;)]) 한 번의 호출로 이 셋을 단일 그래프로 컴파일했습니다.\ncompiled_model.rbln은 직렬화된 바이너리라 그대로 열어 볼 수는 없지만, 컴파일러가 내부적으로 다루는 그래프를 펼치면 대략 이런 형태입니다(개념적 표현).\n# compiled_model.rbln — 내부 그래프 (개념적 표현) target: RBLN-CA22 (ATOM+) tensor_parallel: 1 dtype: fp16 in %input_ids : i64[1, 512] in %attention_mask : i64[1, 512] %emb = rbln.embedding %input_ids -\u0026gt; f16[1,512,768] %h0 = rbln.layernorm %emb # × N encoder layers (attention → mlp): %a = rbln.sdpa_attention %h{i}, mask=%attention_mask @kernel=attn_fused_ca22 %m = rbln.geglu_mlp %a @kernel=geglu_fused_ca22 ... %enc = rbln.layernorm %hN -\u0026gt; f16[1,512,768] %pool = rbln.mean_pool %enc, mask=%attention_mask -\u0026gt; f16[1,768] %log = rbln.linear %pool, W=const[768,2] -\u0026gt; f16[1,2] out %log 앞서 설명한 컴파일 단계들이 그대로 드러납니다 — 연산이 @kernel=..._ca22 형태의 ATOM+ 전용 커널로 낮춰졌고, 모든 텐서 shape이 [1,512,...]로 정적으로 고정돼 있습니다. 그리고 가장 중요한 검증 — CPU와 NPU의 예측이 일치하는가?\n입력 유형 CPU 이탈확률 NPU 이탈확률 이탈 발화 1 1.0000 1.0000 정상 발화 1 0.0007 0.0007 이탈 발화 2 (한국어) 1.0000 1.0000 정상 발화 2 (한국어) 0.0002 0.0002 logits 수준의 수치 오차는 최대 ~4e-2(NPU FP16 연산 특성)였지만, 확률·판정은 소수점 넷째 자리까지 동일했습니다. 예측 결과가 그대로 재현됐습니다.\n벤치마크 동일 모델, batch=1, 고정 512토큰 기준.\n지표 ATOM+ (온디바이스) 평균 지연 9.67 ms p50 / p99 9.66 / 9.73 ms max 10.6 ms 단일 스트림 처리량 103.5 inf/s 숫자만큼이나 인상적이었던 것은 분포의 안정성입니다. p50 9.66ms / p99 9.73ms / max 10.6ms로 사실상 지터가 없습니다. 컴파일된 고정 그래프를 결정적으로 실행하므로 지연이 튀지 않고 일정하게 유지됩니다. 앞서 사용하던 클라우드 GPU 서빙과 견줘도 충분히 경쟁력 있는 수준이었습니다. 소비전력도 카드당 85W급입니다.\n기대는 맞았나 정리하면, 셋 중 저지연·안정성은 기대 이상으로 확인됐습니다(9.67ms·지터 거의 0). 벤더 옵션도 \u0026ldquo;미지원 모델조차 이식 가능\u0026quot;함을 실증하며 열렸습니다. 비용 절감을 위한 서빙 구성은 현재 준비 중입니다.\n교훈 \u0026ldquo;미지원\u0026quot;은 대개 \u0026ldquo;래퍼가 없음\u0026quot;이다. 컴파일러가 이해하는 연산(op)들의 조합으로 이뤄진 모델이라면, 얇은 어댑터로 이식할 수 있습니다. SDPA 표준화의 힘. 벤더 종속 커널(FA2) 대신 PyTorch 표준 추상 API로 내려두면, 백엔드(GPU든 NPU든)가 자기 커널로 번역합니다. 이식성의 핵심입니다. 로컬에서 최대한 검증하고 하드웨어로. trace/export로 그래프 동등성(오차 0)을 로컬에서 확인해 두면, 실기에서 디버깅할 표면적이 크게 줄어듭니다. SDK 버전 드리프트를 조심하라. 문서/최신 main과 실제 배포 릴리스의 내부 API가 다를 수 있습니다. 이번엔 config 레지스트리 방식 차이 한 줄이 첫 컴파일을 막았습니다. 국산 NPU를 \u0026ldquo;받았지만 사용하지 못하는\u0026rdquo; 상태로 두는 팀이 적지 않을 것으로 보입니다. 시행착오가 없진 않았지만, 적어도 인코더 계열 모델이라면 분명히 이식 가능합니다. 정부가 여러 경로로 하드웨어를 공급해 둔 지금, 한 번쯤 진지하게 시도해 볼 만합니다.\n용어 정리 NPU (Neural Processing Unit) — 신경망 연산에 특화된 반도체. 범용 GPU와 달리, 실행할 모델과 입력 크기를 미리 정해 컴파일한 뒤 전용 바이너리로 돌린다. 인코더 모델 / ModernBERT / mmBERT — 텍스트를 벡터 표현으로 변환하는(생성이 아닌 이해 중심) 신경망. BERT 계열의 최신 아키텍처가 ModernBERT이고, 그 다국어 변형이 mmBERT다. 컴파일(NPU 맥락) — 파이토치 모델을 특정 입력 형태에 맞춰 NPU 전용 실행 바이너리(.rbln)로 변환하는 과정. GPU의 즉시 실행과 대비된다. rebel-compiler / optimum-rbln / rebel.Runtime — 각각 리벨리온의 컴파일러, HuggingFace 모델을 컴파일러에 연결하는 통합 레이어, 컴파일된 바이너리를 NPU에서 실행하는 런타임. SDPA vs Flash Attention 2 — 둘 다 어텐션 연산 구현. 여기서 말하는 FA2는 HuggingFace의 flash_attention_2 백엔드(NVIDIA CUDA 전용 flash-attn 커널)로, rebel-compiler가 컴파일할 수 없다. SDPA(scaled_dot_product_attention)는 PyTorch 표준 추상 API로, 각 백엔드가 자기 커널로 번역할 수 있어 이식성이 높다. (참고: RBLN에도 디코더 KV-cache용 자체 flash_attn 옵션이 있지만, 이는 HF의 FA2 백엔드와 다르며 인코더에는 해당하지 않는다.) RoPE / GLU·GeGLU — 각각 위치 정보를 회전으로 주입하는 임베딩 기법, 게이팅을 적용한 MLP 구조. 최신 트랜스포머에 널리 쓰이며 리벨리온 컴파일러도 이미 지원한다. MaskedLM — 문장의 일부를 가리고 복원하도록 학습된 인코더의 기본 형태(백본). 여기에 풀링·분류 헤드를 얹어 분류기로 만든다. mean pooling — 토큰별 벡터를 평균 내 문장 하나의 벡터로 요약하는 방식. ATOM+ / RBLN-CA22 — 리벨리온의 NPU 카드(ATOM+)와 그 내부 타깃 식별자(RBLN-CA22). 참고 K-클라우드 프로젝트 / AI반도체 팜 구축·실증 — ZDNet, NIPA 통합공고 2026 고성능컴퓨팅 지원사업(국산 NPU 무상 제공) — 이투데이, 벤처스퀘어 리벨리온 optimum-rbln (어댑터가 참고한 통합 레이어) — GitHub 리벨리온 Model Zoo — GitHub 리벨리온 RBLN SDK 문서 — docs.rbln.ai ","permalink":"http://blog.caveduck.io/ko/posts/modernbert-on-rebellions-npu/","summary":"프로덕션에서 GPU로 서빙하던 페르소나 이탈 판정 모델(mmBERT 기반 분류기)을 국산 NPU(리벨리온 ATOM+)로 옮기기까지 — 문제 인식부터 어댑터 작성, 실기 컴파일, 벤치마크까지의 여정.","title":"정부가 공짜로 주는 국산 NPU, 진짜 쓸 수 있나? — 미지원 모델 이식 실험기"},{"content":" 이 글은 국산 NPU에 미지원 모델을 이식한 이야기에서 갈라져 나온 개념 설명입니다. 그 글이 \u0026ldquo;실제로 어떻게 뚫었나\u0026quot;라면, 이 글은 그 밑에 깔린 **\u0026ldquo;모델을 컴파일한다는 게 대체 무슨 뜻인가\u0026rdquo;**를 처음부터 풀어봅니다. 딥러닝을 조금 아는 분이라면 무리 없이 읽히도록 썼습니다.\n\u0026ldquo;컴파일\u0026quot;이라는 익숙한 단어부터 프로그래밍에서 컴파일은 사람이 읽는 소스 코드(C, Rust 등)를 CPU가 곧바로 실행하는 기계어로 미리 번역하는 과정입니다. 번역을 미리 해두는 대신, 실행할 때는 빠릅니다.\n뉴럴넷 컴파일도 정확히 같은 발상입니다. 파이토치로 짠 모델을, 특정 가속기(GPU·NPU 등)가 곧바로 실행할 수 있는 형태로 미리 번역해 둡니다. 딱 하나 다른 점은 번역의 출발점입니다.\n일반 컴파일러의 입력 = 텍스트 소스 코드 뉴럴넷 컴파일러의 입력 = 연산 그래프(computation graph) 연산 그래프란 \u0026ldquo;이 텐서에 행렬곱 → 그다음 어텐션 → 그다음 정규화 → …\u0026ldquo;처럼 텐서 연산들이 이어진 방향성 그래프입니다. 모델의 forward()가 하는 일을 그림으로 펼친 것이라고 보면 됩니다.\n다만 \u0026ldquo;일자로 흐르는 파이프라인\u0026quot;은 교과서 속 단순 모델 이야기고, 실제 모델은 여러 모듈이 갈라지고 합쳐지는 그래프입니다. 예를 들어 이미지 편집 모델(Qwen-Image-Edit류)은 입력 이미지·편집 프롬프트·타임스텝이 각기 다른 인코더를 거쳐 하나의 백본에서 합류하고, 디노이징 루프를 돌며 되먹임됩니다.\nflowchart TD IMG[\u0026#34;입력 이미지\u0026#34;] --\u0026gt; VAE[\u0026#34;VAE 인코더\u0026#34;] IMG --\u0026gt; VIT[\u0026#34;비전 인코더\u0026lt;br/\u0026gt;Qwen2.5-VL\u0026#34;] TXT[\u0026#34;편집 프롬프트\u0026#34;] --\u0026gt; TENC[\u0026#34;텍스트 인코더\u0026lt;br/\u0026gt;LLM\u0026#34;] TS[\u0026#34;timestep t\u0026#34;] --\u0026gt; TEMB[\u0026#34;시간 임베딩\u0026#34;] VAE --\u0026gt; LAT[\u0026#34;이미지 잠재 토큰\u0026#34;] VIT --\u0026gt; COND[\u0026#34;의미 조건 토큰\u0026#34;] TENC --\u0026gt; TTOK[\u0026#34;텍스트 토큰\u0026#34;] LAT --\u0026gt; BB COND --\u0026gt; BB TTOK --\u0026gt; BB TEMB --\u0026gt; BB subgraph BB[\u0026#34;MMDiT 백본 · 멀티모달 조인트 어텐션 × N블록\u0026#34;] direction TB JA[\u0026#34;조인트 셀프-어텐션\u0026lt;br/\u0026gt;이미지 ↔ 텍스트\u0026#34;] --\u0026gt; FF[\u0026#34;MLP\u0026#34;] FF -. 잔차 .-\u0026gt; JA end BB --\u0026gt; EPS[\u0026#34;예측 노이즈 ε\u0026#34;] EPS --\u0026gt; LOOP{\u0026#34;디노이징 루프\u0026#34;} LOOP --\u0026gt;|\u0026#34;다음 스텝 t−1\u0026#34;| BB LOOP --\u0026gt;|\u0026#34;완료\u0026#34;| VDEC[\u0026#34;VAE 디코더\u0026#34;] VDEC --\u0026gt; OUT[\u0026#34;편집된 이미지\u0026#34;]같은 입력(이미지)이 두 갈래로 갈라져 서로 다른 인코더로 들어가고, 텍스트·타임스텝과 함께 백본에서 합류(merge) 하며, 블록 안에는 잔차 연결이, 바깥에는 디노이징 되먹임 루프가 있습니다. 컴파일러가 실제로 마주하는 그래프는 이렇게 분기·병합·반복이 얽힌 형태입니다.\n여기서 중요한 사실 하나. 일반 컴파일러의 앞단인 렉시컬 어날리시스(lexical analysis, 소스 텍스트를 토큰으로 쪼개기)와 파싱(parsing, 토큰을 구문 트리로 만들기)이 뉴럴넷 컴파일에는 없습니다. 입력이 이미 구조화된 그래프(일반 컴파일러로 치면 AST/IR에 해당)로 주어지기 때문입니다. 그래서 뉴럴넷 컴파일은 컴파일러 파이프라인의 앞단을 통째로 건너뛰고, 사실상 IR 최적화와 코드 생성 단계부터 시작합니다.\n그래서 왜 컴파일하나? — eager 실행의 대가 파이토치의 기본 실행 방식은 eager 실행입니다. 파이썬 코드가 한 줄 실행될 때마다, 그에 대응하는 연산 커널을 그 자리에서 즉석 호출합니다. 대화하듯 한 문장씩 통역해 주는 통역사에 가깝습니다 — 유연하고, 중간에 값을 찍어보며 디버깅하기도 쉽습니다.\n문제는 비용입니다. 연산 하나하나마다 파이썬 해석 → 커널 디스패치 → 실행이 반복되고, 이 오버헤드가 매번 조금씩 다르게 붙습니다(=지연이 튄다, 지터). 범용 GPU는 커널이 워낙 빠르고 유연해 이 방식으로도 충분히 잘 돌지만, NPU 같은 특화 가속기는 무엇을 어떤 순서로 실행할지 미리 알아야 제 성능이 납니다.\n컴파일은 이 통역사를 번역서로 바꾸는 일입니다. 실행 전에 그래프 전체를 받아 하나의 결정적 실행 단위로 굳혀두면, 매 요청마다 즉석에서 결정할 게 없어 실행이 튀지 않습니다.\n말보다 직접 보는 게 빠릅니다. 아래에서 같은 모델을 eager와 컴파일 두 방식으로 여러 번 돌려, 지연 분포가 어떻게 다른지 비교해 보세요.\neager는 op를 하나씩 디스패치하느라 평균도 높고 분포가 넓게 흩어집니다. 컴파일은 단일 그래프를 결정적으로 실행하니 거의 한 점에 모입니다. 저지연·저지터가 필요한 실서비스에서 컴파일이 갖는 값이 여기서 드러납니다.\n컴파일러가 그 사이에 하는 일 model 하나를 넘기면 컴파일러는 대략 다섯 단계를 거칩니다.\nflowchart TD A[\u0026#34;① 그래프 캡처\u0026lt;br/\u0026gt;trace / export\u0026#34;] --\u0026gt; B[\u0026#34;② 연산 융합·최적화\u0026#34;] B --\u0026gt; C[\u0026#34;③ 하드웨어 커널로 낮추기\u0026lt;br/\u0026gt;lowering\u0026#34;] C --\u0026gt; D[\u0026#34;④ 정적 shape 고정\u0026#34;] D --\u0026gt; E[\u0026#34;⑤ 메모리 계획\u0026#34;] E --\u0026gt; F([\u0026#34;실행 바이너리\u0026#34;])① 그래프 캡처 — 파이썬을 그래프로 먼저 모델이 실제로 어떤 연산을 하는지 그래프로 뽑아냅니다. 두 가지 방식이 있습니다.\ntrace (torch.jit.trace): 예시 입력을 한 번 실제로 흘려보내며 지나간 연산을 기록. 간단하지만, if로 갈라지는 분기 같은 건 \u0026ldquo;그때 지나간 길\u0026quot;만 잡힙니다. export (torch.export): 코드를 정적으로 분석해 제어 흐름까지 포함한 그래프를 추출. 더 튼튼합니다. ② 연산 융합 — 여러 연산을 하나로 인접한 연산들을 하나로 합칩니다. 예를 들어 행렬곱 → 편향 더하기 → 활성화 함수를 따로 실행하면 중간 결과를 메모리에 세 번 썼다 읽습니다. 이를 하나의 융합 커널로 합치면 메모리 왕복이 줄어 훨씬 빨라집니다.\nflowchart LR subgraph before[\u0026#34;융합 전 — 커널 3번, 메모리 왕복 多\u0026#34;] direction LR M1[\u0026#34;matmul\u0026#34;] --\u0026gt; B1[\u0026#34;+bias\u0026#34;] --\u0026gt; A1[\u0026#34;gelu\u0026#34;] end subgraph after[\u0026#34;융합 후 — 커널 1번\u0026#34;] direction LR F1[\u0026#34;fused_matmul_bias_gelu\u0026#34;] end before -.최적화.-\u0026gt; after③ 하드웨어 커널로 낮추기(lowering) 표준 연산(행렬곱, 어텐션…) 하나하나를 그 칩 전용 명령·커널로 번역합니다. GPU 쪽 예를 보면 감이 잡힙니다. 파이토치 torch.compile의 기본 백엔드(TorchInductor)는 연산을 OpenAI Triton으로 낮춥니다. Triton은 GPU 커널을 파이썬 유사 문법으로 기술하는 언어인데, 컴파일러가 융합된 연산에 맞는 커널을 Triton 코드로 즉석 생성한 뒤 이를 다시 GPU 바이너리(PTX/cubin)로 낮춥니다. 벤더가 미리 만든 라이브러리 커널(cuBLAS 등)을 부르는 대신 그래프에 맞춰 커널을 생성하는 것이죠. NPU 컴파일러도 발상은 같습니다 — 코드 생성 백엔드가 Triton이 아니라 그 칩 전용이라는 점만 다릅니다.\n④ 정적 shape 고정 입력 크기를 미리 못 박습니다(예: [1, 512]). 크기가 고정되면 실행 시 분기가 사라져 결정적 경로가 만들어지고, 융합·메모리 계획도 더 공격적으로 할 수 있습니다. NPU 관련 글에서 \u0026ldquo;고정 512토큰\u0026quot;이 계속 등장하는 이유가 이것입니다. 대가는 유연성 — 컴파일 시점에 정한 크기를 벗어나는 입력은 (재컴파일하거나 패딩하지 않으면) 못 넣습니다.\n⑤ 메모리 계획 각 연산이 쓸 버퍼의 배치와 재사용을 실행 전에 확정합니다. 실행 중 메모리를 즉석 할당·해제하지 않으니 그만큼 빠르고 일정합니다.\n컴파일 결과물은 어떻게 생겼나 결과는 하드웨어가 바로 먹는 바이너리입니다(리벨리온이라면 .rbln). 직렬화된 형태라 그대로 열어볼 순 없지만, 내부 그래프를 개념적으로 펼치면 이런 모습입니다.\n# compiled_model — 내부 그래프 (개념적 표현) target: RBLN-CA22 dtype: fp16 in %input_ids : i64[1, 512] in %attention_mask : i64[1, 512] %emb = embedding %input_ids -\u0026gt; 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 -\u0026gt; f16[1,768] %log = linear %pool, W=const[768,2] -\u0026gt; f16[1,2] out %log 앞서 본 단계들이 그대로 드러납니다 — 연산이 @kernel=..._fused 형태로 전용 커널에 매핑됐고, 모든 텐서 shape이 [1,512,...]로 정적으로 고정돼 있습니다.\n이미 있는 것들 — ONNX와 컴파일 생태계 지금까지 \u0026ldquo;컴파일러\u0026quot;를 하나로 뭉뚱그렸지만, 실제로는 여러 표준과 도구가 층을 이루고 있습니다. 그 허브에 자주 등장하는 것이 ONNX(Open Neural Network Exchange) 입니다.\nONNX는 컴파일러가 아니라 연산 그래프를 담는 공개 표준 포맷입니다. 파이토치든 텐서플로든 어디서 만든 모델이라도 ONNX로 내보내면(export), 그 그래프를 읽을 수 있는 여러 런타임·컴파일러가 각자의 백엔드로 실행합니다. 모델을 만든 프레임워크와 배포처를 분리(decouple) 하는 공통어인 셈입니다.\nflowchart TD PT[\u0026#34;PyTorch\u0026#34;] --\u0026gt; ONNX TF[\u0026#34;TensorFlow\u0026#34;] --\u0026gt; ONNX JAX[\u0026#34;JAX\u0026#34;] --\u0026gt; ONNX ONNX[\u0026#34;ONNX · 공통 그래프 표준\u0026#34;] --\u0026gt; ORT[\u0026#34;ONNX Runtime\u0026#34;] ONNX --\u0026gt; TRT[\u0026#34;TensorRT · NVIDIA\u0026#34;] ONNX --\u0026gt; OV[\u0026#34;OpenVINO · Intel\u0026#34;] ONNX --\u0026gt; NPU[\u0026#34;벤더 NPU 컴파일러\u0026#34;]자주 마주치는 도구들을 성격별로 정리하면 이렇습니다.\n도구 성격 역할 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)를 벤더 컴파일러에 직접 넘기는 경로를 택했습니다 — 아키텍처가 미묘하게 어긋나는 모델을 다룰 때는, 포맷 변환을 한 단계 줄이는 편이 디버깅 표면적이 작았기 때문입니다.\n트레이드오프 — 무엇을 얻고 무엇을 잃나 eager (인터프리터) 컴파일 (번역서) 지연 높고 지터 큼 낮고 일정 유연성 임의 파이썬·동적 shape OK 고정 그래프·고정 shape 디버깅 쉬움(값 즉시 확인) 어려움(그래프가 굳음) 준비 비용 없음 컴파일 시간 필요 정리하면 컴파일은 \u0026ldquo;유연한 인터프리터\u0026quot;를 \u0026ldquo;결정적 실행 바이너리\u0026quot;로 바꾸는 것입니다. 유연성을 내주고 저지연·저지터·효율을 얻습니다.\n그래서 언제 컴파일하나 연구·프로토타이핑, 입력 크기가 매번 달라지는 경우 → eager로 충분합니다. 유연성이 값집니다. 실서비스 저지연 추론, 엣지·온디바이스, 특화 가속기(NPU) → 컴파일이 이깁니다. 특히 NPU는 사실상 컴파일이 필수입니다. 즉 \u0026ldquo;컴파일이 항상 정답\u0026quot;은 아닙니다. 유연성이 중요한 국면에선 eager가, 지연·안정성·효율이 중요한 국면에선 컴파일이 맞습니다. 국산 NPU에 실제 모델을 올리며 이 트레이드오프를 어떻게 넘겼는지는 이식 실험기에서 이어집니다.\n","permalink":"http://blog.caveduck.io/ko/posts/neural-net-compilation/","summary":"\u0026lsquo;모델을 컴파일한다\u0026rsquo;는 게 정확히 무슨 뜻일까? eager 실행과의 차이부터 그래프 캡처·연산 융합·커널 lowering·정적 shape·메모리 계획까지, 그림과 인터랙티브 데모로 차근차근 풀어봅니다.","title":"뉴럴넷 컴파일, 쉽게 이해하기 — 파이토치 모델이 하드웨어 언어가 되기까지"},{"content":" 이 글은 경량화 시리즈의 세 번째(#3)입니다. 데이터타입 → 양자화가 \u0026ldquo;숫자를 더 적은 비트로\u0026rdquo; 였다면, 이번엔 아예 연결을 잘라내는 프루닝입니다. 코드는 github.com/warpspaceinc/efficient-ml-practice의 pruning.ipynb 노트북에 있고, 아래 모든 plot은 그 코드를 Colab(A100)에서 직접 돌려 측정한 우리 숫자입니다.\n1989년의 \u0026ldquo;뇌 손상\u0026rdquo; 프루닝을 이야기할 때 빼놓을 수 없는 논문이 하나 있습니다. 제목이 살벌합니다 — Optimal Brain Damage(최적 뇌 손상). 저자는 Yann LeCun, John Denker, Sara Solla, AT\u0026amp;T Bell Labs, 발표는 NeurIPS 1989입니다.\n잠깐 멈춰서 생각해보면 놀랍습니다. 1989년. 지금으로부터 36년 전입니다. 월드와이드웹이 세상에 나오기도 전이고, LeCun이 CNN(LeNet)으로 유명해지기도 전이며, \u0026ldquo;딥러닝\u0026quot;이라는 말조차 없던 시절입니다. 그 LeCun은 지금 Meta의 수석 AI 과학자이자 튜링상 수상자입니다. \u0026ldquo;신경망에서 덜 중요한 연결을 잘라내면 더 작고, 더 잘 일반화되는 망을 얻는다\u0026rdquo; 는 프루닝의 핵심 아이디어가, 그렇게 오래전에 이미 정식화되어 있었습니다.\n논문의 발상은 생물학적 은유와도 맞닿아 있습니다. 사람의 뇌도 시냅스를 무작정 늘리지 않습니다. 뉴런당 시냅스 수는 유아기에 약 2,500개 → 2~4세 무렵 15,000개까지 폭증했다가 → 성인이 되면 약 7,000개로 다시 줄어듭니다. 쓸모없는 연결을 쳐내는 이 \u0026ldquo;가지치기(pruning)\u0026ldquo;가 오히려 효율적인 뇌를 만든다는 것이죠.\n이 글은 그 36년의 통찰이 오늘날 NVIDIA GPU의 2:4 sparsity로 어떻게 현실이 되는지를, 세 가지 질문으로 따라갑니다.\n무엇을 자를 것인가 (criterion) 얼마나 자를 수 있는가 (재학습과 압축의 놀라움) 어떻게 잘라야 하드웨어가 빨라지는가 (granularity와 2:4) 프루닝이란 \u0026amp; 5단계 프레임워크 프루닝은 가중치를 0으로 만들어 연결을 삭제하는 것입니다. dense한 망을 sparse하게 만들어 저장·연산·전력을 아낍니다. 형식적으로는 \u0026ldquo;0이 아닌 가중치 수를 $N$개 이하로 제한하면서 손실을 최소화\u0026quot;하는 문제입니다.\n$$\\arg\\min_{W_P} L(x; W_P) \\quad \\text{s.t.} \\quad \\lVert W_P\\rVert_0 \u003c N$$Han et al.(NeurIPS 2015) 이후 프루닝은 보통 5단계로 정리됩니다: ① 학습 → ② granularity(어떻게) → ③ criterion(무엇을) → ④ ratio(얼마나) → ⑤ fine-tune(회복). 이 글은 그중 핵심인 criterion → ratio/회복 → granularity 순으로 봅니다.\n동기는 양자화 편과 같습니다. 가중치는 대개 0 근처에 몰려 있고, 메모리 접근은 연산보다 수백 배 비쌉니다(Horowitz: DRAM 640pJ vs int ADD 0.1pJ). 그러니 \u0026ldquo;많은 가중치가 거의 0인데, 정말 다 필요할까?\u0026ldquo;라는 질문이 자연스럽습니다.\n1. 무엇을 자를까 — Criterion 가장 먼저 답할 질문은 \u0026ldquo;어떤 가중치를 지울 것인가\u0026quot;입니다. 후보는 세 가지입니다.\nMagnitude(크기): 중요도 $= |w|$. 작으면 지운다. 싸고 단순. OBD saliency(2차/헤시안): OBD의 답입니다. 각 가중치를 지웠을 때 손실이 얼마나 늘어나는지를 테일러 전개로 근사합니다. Random: 무작위. 비교용 하한선. OBD의 유도는 이렇습니다. 손실 $L$을 가중치 변화 $\\delta w$에 대해 2차까지 전개하면:\n$$\\delta L = \\sum_i g_i\\,\\delta w_i + \\frac{1}{2}\\sum_i h_{ii}\\,\\delta w_i^2 + \\frac{1}{2}\\sum_{i\\neq j} h_{ij}\\,\\delta w_i \\delta w_j + O(\\lVert\\delta w\\rVert^3)$$여기서 세 가지 가정을 넣습니다 — 수렴 완료(1차 항 $g_i \\approx 0$), 대각 근사(교차항 $h_{ij}$ 무시), 2차 근사(3차 이상 무시). 그러면 가중치 하나를 0으로 만들 때($\\delta w_i = -w_i$)의 손실 증가, 즉 saliency가 깔끔하게 남습니다.\n$$\\text{saliency}_{w_i} = \\frac{1}{2}\\, h_{ii}\\, w_i^2$$크기가 큰 가중치($w_i^2$)일수록, 그리고 손실 곡률이 가파른 곳($h_{ii}$)일수록 중요하다는 뜻입니다. 문제는 헤시안 $h_{ii}$가 비싸다는 것 — 그래서 실무에서는 대각 헤시안을 empirical Fisher(기울기 제곱의 평균) 로 근사합니다. 우리 실험도 그렇게 했습니다.\n직접 측정: 세 기준 비교 MNIST MLP(784→512→512→10)를 학습한 뒤, 세 기준으로 각각 sparsity를 올려가며 (재학습 없이) 정확도를 쟀습니다.\n읽어야 할 핵심:\nrandom은 50%만 넘어도 무너집니다. 무엇을 자르는지가 결정적이라는 증거입니다. magnitude와 OBD는 거의 겹칩니다. 둘 다 90%까지 정확도를 지킵니다. 극단(98%)에서는 OBD의 2차 통찰이 조금 앞서지만(44.6% vs 29.3%), 그 앞까지는 magnitude가 오히려 근소하게 낫습니다. 아래에서 직접 만져보세요. 슬라이더로 sparsity를 올리면 잘린 시냅스가 오른쪽 신경망에서 사라지고, 연결이 하나도 안 남은 뉴런은 제거됩니다. 동시에 연산량(MAC·FLOPs)이 줄어드는 것도 보입니다 — 단, 이 절감은 하드웨어가 0을 실제로 건너뛸 때만 실현됩니다. 기준을 magnitude ↔ random으로 바꾸면 같은 sparsity(같은 MAC)라도 어떤 연결·뉴런이 먼저 사라지는지가 달라집니다.\n이 결과가 바로 \u0026ldquo;왜 실무는 OBD 대신 magnitude를 쓰나\u0026rdquo; 에 대한 답입니다. OBD는 이론적으로 우아하지만 헤시안이 비쌉니다. 반면 magnitude는 공짜인데 이만큼 강력합니다. 그래서 36년이 지난 지금도 대부분의 프루닝은 magnitude 기반입니다. OBD가 연 문(\u0026ldquo;무엇을 자를지 원리적으로 고르자\u0026rdquo;)을, 정작 가장 단순한 기준이 실용적으로 통과한 셈입니다.\n2. 얼마나 자를 수 있나 — 재학습과 압축의 놀라움 한 번에 많이 자르면 당연히 정확도가 떨어집니다. 하지만 자르고 → 재학습(fine-tune) 을 반복하면 이야기가 완전히 달라집니다. 재학습 학습률은 보통 원래의 1/10~1/100로 낮게 씁니다.\nprune only(회색): 재학습 없이 자르면 95%에서 88%로 떨어집니다. iterative prune+retrain(초록): 조금씩 자르며 매번 재학습하면, 가중치를 95% 잘라내도(=20배 적은 파라미터) dense와 사실상 같은 정확도(97.3% vs 97.6%) 를 유지합니다. 가중치의 20분의 1만 남겨도 성능이 그대로라는 게, 프루닝이 지금도 연구되는 이유입니다. 프루닝 후 가중치 분포를 보면 무슨 일이 일어났는지 한눈에 보입니다 — 정확히 0에 거대한 스파이크가 생깁니다.\n이 \u0026ldquo;잘라내고 다시 학습\u0026rdquo; 아이디어를 극단으로 민 것이 Deep Compression(Han et al., 2016)입니다. iterative pruning으로 AlexNet의 프루닝 한계를 5배에서 9배까지 끌어올렸고, 양자화·허프만 코딩까지 얹어 최대 수십 배를 달성했습니다.\n3. 어떻게 잘라야 빨라지나 — Granularity와 하드웨어 여기서 반드시 짚어야 할 함정이 있습니다. 프루닝으로 sparsity를 얻었다고 저절로 빨라지지 않습니다.\n곰곰이 생각해보면, 가중치를 \u0026ldquo;자른다\u0026quot;는 건 사실 그 자리에 0을 넣는 것과 다를 게 없습니다. 그리고 0을 그냥 곱셈-누산기에 넣어 계산하면 — $0 \\times a = 0$ — 연산 횟수는 하나도 줄지 않습니다. 위에서 정확도를 잰 실험도 실은 가중치를 0으로 만든 뒤 그대로 dense 커널로 돌린 것이라, 정확도는 측정됐지만 저장도 속도도 전혀 줄지 않았습니다(양자화 편의 \u0026ldquo;fake quantization\u0026quot;과 똑같은 상황입니다). 0을 곱하나 0.3을 곱하나 곱셈기 입장에선 같은 한 번의 곱셈입니다.\n이득을 보려면 0을 아예 저장하지도, 곱하지도 않고 건너뛰어야 합니다. 그런데 그게 어렵습니다. 지금까지의 프루닝은 0이 아무 데나 흩어지는 unstructured(비정형) 방식이라, 압축률은 최고지만 GPU 입장에서는 0의 위치가 불규칙해서 건너뛸 수가 없습니다. \u0026ldquo;다음 0이 어디 있나\u0026quot;를 매번 찾는 비용이 곱셈 한 번 아끼는 것보다 비싸서, 결국 dense로 돌리는 게 낫습니다.\n그래서 프루닝에는 이 원칙이 따라붙습니다:\n프루닝은 하드웨어가 그 sparsity 패턴을 지원해야만 실이득이 난다. 쓸 하드웨어가 무엇을 가속하는지 알고 잘라야 한다.\n그동안 sparse 연산을 위한 전용 하드웨어(EIE, ISCA 2016 등)가 연구됐고, 오늘날 가장 널리 쓰이는 답이 NVIDIA의 2:4 structured sparsity입니다.\n2:4 프루닝 규칙은 단순합니다. 연속된 4개 가중치마다 정확히 2개를 0으로 만듭니다(50% sparsity). 0의 위치가 \u0026ldquo;4개 중 2개\u0026quot;로 규칙적이기 때문에, NVIDIA Ampere(A100) 이후의 Sparse Tensor Core가 이 패턴을 하드웨어로 가속합니다. 저장도 효율적입니다 — 비영값 절반 + 각 값의 위치를 가리키는 2-bit 인덱스만 있으면 됩니다.\nmagnitude로 4열마다 작은 2개를 자르는 마스크는 이렇게 만듭니다.\ndef two_four_mask(weight): \u0026#34;\u0026#34;\u0026#34;4열 단위로 |w|가 큰 2개만 남기는 2:4 마스크.\u0026#34;\u0026#34;\u0026#34; w = weight.detach().abs(); R, C = w.shape mask = torch.zeros_like(weight) for j in range(0, C - C % 4, 4): idx = w[:, j:j+4].topk(2, dim=1).indices # 각 4개 중 큰 2개 mask[:, j:j+4].scatter_(1, idx, 1.0) return mask 우리 MLP에 2:4를 적용한 결과: dense 97.6% → 2:4(재학습 없이) 96.8% → 2:4 + 재학습 98.2%. 절반을 규칙적으로 잘라도 재학습이면 완전히 회복됩니다.\n파이썬에서 2:4 활용하기 PyTorch는 2:4 패턴을 압축 저장하고 Sparse Tensor Core로 곱하는 API를 제공합니다(fp16, Ampere+ 필요).\nfrom torch.sparse import to_sparse_semi_structured mask = torch.Tensor([0, 0, 1, 1]).tile((64, 16)).bool() W = (torch.rand(64, 64) * mask).half().cuda() Ws = to_sparse_semi_structured(W) # 비영값 + 2-bit 인덱스로 압축 x = torch.rand(64, 64).half().cuda() torch.mm(Ws, x) # Sparse Tensor Core 사용 정말 빨라지나 — 직접 벤치마크 A100에서 dense vs 2:4 sparse 행렬곱 속도를 크기별로 쟀습니다. 결과가 정직하고 교훈적입니다.\n작은 행렬(N \u0026lt; 4096)에서는 2:4가 오히려 느립니다. 압축·인덱싱 오버헤드가 이득보다 큽니다. 큰 행렬에서만 이득이 납니다. N=8192에서 약 1.72배 빨라집니다. 이것이 \u0026ldquo;하드웨어와 워크로드를 알아야 한다\u0026quot;의 실체입니다. 같은 2:4 프루닝도 Ampere 이상 GPU에서, 충분히 큰 행렬에서만 빨라집니다. Turing(T4)이나 작은 행렬에서는 이득이 없거나 오히려 손해입니다. 프루닝은 알고리즘만의 문제가 아니라, 처음부터 끝까지 하드웨어와 함께 설계해야 하는 문제입니다.\n4. 프루닝 × 양자화는 직교한다 — Deep Compression 여기까지 프루닝을 봤고, 앞선 양자화 편에서는 비트를 줄였습니다. 핵심은 이 둘이 서로 직교(orthogonal) 한다는 것입니다. 프루닝은 \u0026ldquo;어떤 가중치를 지울지\u0026ldquo;를, 양자화는 \u0026ldquo;남은 가중치를 몇 비트로 표현할지\u0026ldquo;를 정합니다. 축이 다르니 함께 쓰면 이득이 곱해집니다.\n이를 가장 잘 보여준 것이 Deep Compression(Han et al., 2016)의 3단계 파이프라인입니다.\n프루닝 + 재학습 — 약한 연결을 지우고 다시 학습해 회복. 양자화 + 재학습 — 살아남은 가중치를 K-Means 코드북 몇 개로 묶고(코드북 재학습), 인덱스만 저장. 허프만 코딩 — 인덱스 분포가 한쪽으로 쏠려 있으니 무손실로 한 번 더 압축. 우리 MLP로 직접 돌려봤습니다. 각 단계에서 fc2 가중치 분포가 어떻게 변하는지 보면, 프루닝과 양자화가 분포의 서로 다른 부분을 건드린다는 게 한눈에 보입니다. (0이 된 가중치는 숨기고 살아남은 것만 그렸습니다.)\n① dense — 익숙한 종 모양 정규분포. ② 프루닝(80%) — 0 근처의 작은 가중치가 통째로 사라집니다. 가운데가 날아가고 양쪽 꼬리만 남습니다. ③ 재학습 — 살아남은 가중치가 데이터에 다시 맞춰지며 뚜렷한 2-모드(bimodal) 분포가 됩니다. ④ 양자화(4-bit) — 그 연속 분포가 16개의 이산 레벨로 접힙니다 (여기가 [양자화 편]의 K-Means가 하는 일). 프루닝은 가로축 가운데(작은 값)를 비우고, 양자화는 남은 값들을 세로선 몇 개로 이산화합니다. 손대는 곳이 다르니 겹치지 않고 쌓입니다. 그 결과 모델 크기는 단계마다 곱으로 줄어듭니다.\ndense 2,612 KB → 프루닝 604 KB (4.3×) → +양자화 147 KB (17.8×) → +허프만 141 KB (18.6×) 그동안 정확도는 오히려 97.5% → 98.3% 로 유지·개선됐습니다(재학습 덕분). 즉 프루닝만으로 4.3×, 양자화를 얹어 17.8×, 허프만까지 18.6×. 각 기법의 이득이 더해지는 게 아니라 곱해집니다. 원 논문은 이 파이프라인으로 AlexNet을 35×, VGG-16을 49× 줄였습니다(정확도 손실 없이). 데이터타입 → 양자화 → 프루닝, 이 시리즈가 다룬 세 축은 결국 하나의 모델에 동시에 얹을 수 있는 직교한 도구들입니다.\n직접 돌려보기 위 모든 plot은 아래 노트북을 Colab에서 실행해 나온 것입니다. 런타임 → 모두 실행이면 됩니다(2:4 속도 측정은 A100 등 Ampere GPU 선택).\n📓 pruning.ipynb — OBD vs magnitude, 재학습 압축, 2:4 sparsity 저장소: github.com/warpspaceinc/efficient-ml-practice 정리 무엇을: random은 금방 무너진다. magnitude(|w|)는 싸고 강력해 OBD의 2차 saliency와 맞먹는다 — 그래서 실무 표준이 됐다. 얼마나: prune→retrain 반복이면 95%(20배)를 잘라도 정확도가 유지된다. 어떻게: unstructured는 압축률만 높다. 2:4 structured라야 Ampere Sparse Tensor Core가 실제로 가속한다 — 단, 큰 행렬에서만. 함께: 프루닝과 양자화는 직교한다. Deep Compression처럼 쌓으면 이득이 곱해진다(우리 실측 18.6×, 정확도 유지). Optimal Brain Damage가 1989년에 심은 통찰은 지금도 유효합니다. 다만 그것이 \u0026ldquo;실이득\u0026quot;이 되려면, 36년 뒤의 하드웨어가 그 sparsity를 받아줘야 했습니다. 프루닝의 역사는 곧 알고리즘과 하드웨어가 서로를 기다려온 역사이기도 합니다.\n다음 편에서는 sparsity를 층마다 자동으로 정하는 방법(sensitivity analysis · AMC · NetAdapt)을 다뤄보겠습니다.\nReferences Y. LeCun, J. S. Denker, S. A. Solla. Optimal Brain Damage. Advances in Neural Information Processing Systems (NeurIPS), 1989. S. Han, J. Pool, J. Tran, W. J. Dally. Learning both Weights and Connections for Efficient Neural Networks. NeurIPS, 2015. S. Han, H. Mao, W. J. Dally. Deep Compression: Compressing Deep Neural Networks with Pruning, Trained Quantization and Huffman Coding. ICLR, 2016. A. Mishra et al. Accelerating Sparse Deep Neural Networks (2:4 Structured Sparsity). arXiv:2104.08378, 2021. S. Han et al. EIE: Efficient Inference Engine on Compressed Deep Neural Network. ISCA, 2016. M. Horowitz. Computing\u0026rsquo;s Energy Problem (and what we can do about it). ISSCC, 2014. 강의 참고: MIT 6.5940 TinyML and Efficient Deep Learning Computing (Song Han), Lec 3–4 Pruning \u0026amp; Sparsity. ","permalink":"http://blog.caveduck.io/ko/posts/neural-net-pruning/","summary":"1989년 LeCun의 Optimal Brain Damage에서 시작해, 가중치를 잘라내(pruning) 모델을 줄이는 이야기. 무엇을·어떻게·얼마나 자르나, 재학습으로 95%까지 잘라도 버티는 압축의 놀라움, 그리고 하드웨어(NVIDIA 2:4)가 받쳐줘야 실이득이라는 것까지 — MNIST MLP로 직접 측정한 plot과 함께.","title":"경량화 시리즈 #3 — 뉴럴넷 프루닝"},{"content":" 이 글은 딥러닝 시대의 데이터타입 편의 후속입니다. 앞 글에서 INT/FP 데이터타입이 비트를 어떻게 숫자로 해석하는지 봤다면, 이 글에서는 그 저비트 타입으로 이미 학습된 모델을 실제로 줄여보고 결과를 측정합니다. 코드는 전부 github.com/warpspaceinc/efficient-ml-practice의 노트북에서 가져왔고, 아래 plot은 그 코드를 Colab(T4)에서 그대로 돌려 뽑은 것입니다.\n32비트짜리 숫자가 정말 다 필요할까 지난 글 끝에서 관찰한 게 하나 있었습니다. 신경망의 가중치는 대개 0 근처에 종 모양으로 몰려 있고 꼬리가 깁니다. 실제로 MNIST용 작은 MLP(784→256→128→10)를 2 에폭만 학습시킨 뒤 첫 번째 층 가중치를 히스토그램으로 그려보면 정확히 그렇습니다.\n값이 이렇게 좁은 범위에 몰려 있는데, 정말 하나하나를 32비트 실수로 따로 저장하고 32비트로 곱해야 할까요? 양자화(quantization) 는 \u0026ldquo;아니오\u0026quot;라고 답합니다. 이 글에서는 대표적인 두 갈래를 코드로 직접 돌려봅니다.\nK-Means 양자화 — 비슷한 값을 대표값으로 묶어 저장을 줄인다 (비균등). Linear 양자화 — 정수 격자에 매핑해 저장도 연산도 정수로 만든다 (균등). 두 방식 모두 학습이 끝난 모델에 사후 적용하는 PTQ(post-training quantization) 이고, 여기서는 가중치만 양자화합니다.\n1. K-Means 양자화 — 대표값 공유로 저장 줄이기 첫 번째 아이디어는 2016년 Deep Compression 논문1에서 왔습니다. 비슷한 값의 가중치를 한 덩어리로 묶어 대표값 하나로 퉁치자는 것입니다. 예를 들어 2.09, 1.92, 1.87은 전부 \u0026ldquo;대략 2.0\u0026quot;이니, 이 값들을 2.00 하나로 대체하고 각 자리에는 \u0026ldquo;몇 번 대표값을 쓸지\u0026rdquo; 인덱스만 적어둡니다.\n절차는 세 단계입니다.\n클러스터링 — 모든 가중치를 $k = 2^N$개 그룹으로 K-Means 클러스터링. 코드북(codebook) — 각 그룹의 중심값(centroid)을 대표값으로 저장. 이 목록이 코드북. 인덱스 — 각 자리엔 32비트 실수 대신 \u0026ldquo;몇 번 클러스터인지\u0026rdquo; 가리키는 $N$비트 정수만 저장. 코드로는 scikit-learn의 KMeans를 그대로 씁니다. 지역 최적해를 피하려고 min~max를 균등 분할한 값으로 초기화합니다.\nfrom sklearn.cluster import KMeans import numpy as np def kmeans_quantize(w, n_bits, fit_sample=20000): \u0026#34;\u0026#34;\u0026#34;가중치를 K-Means로 양자화. 복원값, 코드북, 인덱스를 반환.\u0026#34;\u0026#34;\u0026#34; shape = w.shape flat = w.reshape(-1, 1) k = 2 ** n_bits lo, hi = float(flat.min()), float(flat.max()) init = np.linspace(lo, hi, k).reshape(-1, 1) # 선형 초기화 idx = np.random.choice(len(flat), min(fit_sample, len(flat)), replace=False) km = KMeans(n_clusters=k, init=init, n_init=1, max_iter=50).fit(flat[idx]) codebook = km.cluster_centers_.reshape(-1) # 대표값 목록 indices = km.predict(flat) # 각 가중치 → 클러스터 인덱스 recon = codebook[indices].reshape(shape) # 복원 return recon, codebook, indices 2비트($k=4$)로 돌린 결과를 원본 분포 위에 겹쳐 그리면, 대표값(세로선)이 값이 몰린 0 근처에 촘촘히 놓이는 걸 볼 수 있습니다. 이게 \u0026ldquo;비균등(non-uniform)\u0026ldquo;의 의미입니다.\n얼마나 줄어드나 압축비는 비트 회계로 계산합니다. 파라미터 $M$개, $N$비트 양자화면:\n$$\\text{원본} = 32M \\text{ bit}, \\qquad \\text{압축} = \\underbrace{N \\cdot M}_{\\text{인덱스}} + \\underbrace{32 \\cdot 2^N}_{\\text{코드북}} \\text{ bit}$$num = w.size; k = 2 ** N_BITS orig_bits = 32 * num comp_bits = N_BITS * num + 32 * k # 인덱스 + 코드북 print(f\u0026#34;compression: {orig_bits/comp_bits:.2f}x\u0026#34;) $M \\gg 2^N$이면 코드북 항은 무시할 만해지고 압축비는 $32M/NM = \\mathbf{32/N}$배로 수렴합니다. 2비트면 약 16배, 4비트면 8배입니다. (첫 번째 층 fc1 기준 실측 압축비: 15.99×)\n중요한 한계: 저장만 줄인다 여기서 반드시 짚어야 합니다. K-Means 양자화가 줄이는 건 디스크·메모리에 저장되는 크기뿐입니다. 추론할 때는 인덱스를 코드북으로 디코드해서 원래 실수 가중치를 복원한 뒤 곱해야 합니다.\n저장: 정수 인덱스 (작다) ✓ 연산: 복원된 32비트 실수로 여전히 floating-point 연산 ✗ 메모리 대역폭은 아꼈지만 곱셈기(MAC)는 여전히 float를 돌립니다. 진짜로 정수 연산까지 빠르게 하려면 다음 방식이 필요합니다.\n2. Linear 양자화 — 정수 연산만으로 추론하기 Linear(선형) 양자화는 정수와 실수를 아핀(affine) 매핑 한 줄로 잇습니다. 2018년 Jacob et al.2이 제안했고, TensorFlow Lite의 INT8 양자화가 바로 이 방식입니다.\n$$r = S \\cdot (q - Z)$$ $r$ — 원래의 실수(real) 값 $q$ — 양자화된 정수 $Z$ — zero point. 실수 $0$에 정확히 대응하는 정수. ReLU 뒤의 0, 패딩처럼 흔한 값을 오차 없이 표현하기 위한 장치. $S$ — scale. 정수 한 칸이 실수로 얼마인지 (floating-point). $S$와 $Z$는 실수 범위 $[r_{\\min}, r_{\\max}]$의 양 끝을 정수 범위 $[q_{\\min}, q_{\\max}]$의 양 끝에 맞추는 데서 나옵니다.\n$$S = \\frac{r_{\\max} - r_{\\min}}{q_{\\max} - q_{\\min}}, \\qquad Z = \\text{round}\\left(q_{\\min} - \\frac{r_{\\min}}{S}\\right)$$코드에서는 이 $S$, $Z$만 계산해주면 양자화(round·clamp)와 복원(dequant)은 PyTorch 내장 fake_quantize_per_tensor_affine이 처리합니다. 임의 비트 폭은 quant_min/quant_max로 지정합니다.\nimport torch def linear_quantize(w, n_bits, symmetric=False): \u0026#34;\u0026#34;\u0026#34;PyTorch 내장 fake_quantize로 선형(아핀) 양자화. 복원값과 S, Z를 반환.\u0026#34;\u0026#34;\u0026#34; t = torch.as_tensor(w, dtype=torch.float32) if symmetric: # Z=0 고정 (연산이 단순) qmax = 2 ** (n_bits - 1) - 1; qmin = -qmax S = float(t.abs().max()) / qmax; Z = 0 else: # asymmetric: 범위를 정확히 맞춤 qmin = -2 ** (n_bits - 1); qmax = 2 ** (n_bits - 1) - 1 S = (float(t.max()) - float(t.min())) / (qmax - qmin) Z = int(round(qmin - float(t.min()) / S)) recon = torch.fake_quantize_per_tensor_affine(t, S, Z, qmin, qmax) return recon.numpy(), S, Z 2비트로 돌리면 대표값(세로선)이 일정한 간격 $S$로 균등하게 놓입니다. K-Means와 정확히 대비되는 지점입니다 — 이쪽은 데이터가 어디 몰렸든 격자가 균일합니다.\n왜 \u0026ldquo;정수 연산까지\u0026rdquo; 가능한가 Linear 양자화의 진짜 값어치는 저장이 아니라 연산에 있습니다. 행렬곱 $Y = WX$의 각 값을 아핀 매핑으로 바꾸면:\n$$q_Y = \\frac{S_W S_X}{S_Y}\\big(q_W q_X - Z_W q_X - Z_X q_W + Z_W Z_X\\big) + Z_Y$$괄호 안은 $q_W q_X$를 포함해 전부 정수 곱·합이고, $Z_W Z_X$처럼 입력과 무관한 항은 미리 계산해둘 수 있습니다. 앞의 스케일 비 $\\frac{S_W S_X}{S_Y}$만 실수인데, 이 값은 경험적으로 항상 $(0,1)$ 구간이라 $2^{-n} M_0$ 꼴의 고정소수점 곱 + 비트 시프트로 처리됩니다. 결국 floating-point 유닛이 전혀 필요 없습니다 — 1편의 에너지 표에서 본 \u0026ldquo;정수 연산이 float보다 수십 배 싸다\u0026quot;가 여기서 현실이 됩니다.\n가중치는 대개 0 대칭이라 실전에서는 $Z_W = 0$인 대칭(symmetric) 양자화를 자주 씁니다. 그러면 $Z_W$ 관련 항이 통째로 사라져 식이 더 깔끔해집니다.\n3. 그래서, 얼마나 잃나 — 직접 측정 말로만 하면 감이 안 오니, 세 방식(Linear asymmetric / Linear symmetric / K-Means)을 비트 폭을 바꿔가며 MNIST 테스트 정확도로 비교했습니다. 모델의 세 Linear 층 가중치를 전부 양자화한 뒤(복원값으로 교체) 정확도를 잰 것입니다.\ndef eval_linear(model, n_bits, symmetric): m = copy.deepcopy(model) with torch.no_grad(): for layer in [m.fc1, m.fc2, m.fc3]: wl = layer.weight.detach().cpu().numpy() recon, *_ = linear_quantize(wl, n_bits, symmetric=symmetric) layer.weight.copy_(torch.tensor(recon, dtype=torch.float32, device=device)) return accuracy(m) bits_list = [2, 3, 4, 8] acc_asym = [eval_linear(model, b, False) for b in bits_list] acc_sym = [eval_linear(model, b, True) for b in bits_list] # K-Means 도 동일 구조의 eval_kmeans(model, b) 로 비교 bits Linear (asym) Linear (sym) K-Means float32 8 97.2% 97.2% 97.2% 97.2% 4 97.1% 97.0% 97.1% 97.2% 3 96.7% 94.4% 96.8% 97.2% 2 58.1% 18.8% 93.9% 97.2% 읽어야 할 큰 그림은 이렇습니다.\n8비트면 사실상 공짜. 세 방식 모두 float32와 구분이 안 될 만큼 정확도가 유지됩니다. 낮은 비트로 갈수록 K-Means가 유리. 대표값을 데이터 분포에 맞춰(비균등) 놓기 때문에, 같은 2비트라도 균등 격자인 Linear보다 오차가 작습니다. 아래 MSE 곡선에서 더 뚜렷합니다. 하지만 정확도가 전부는 아닙니다. K-Means가 2비트에서 오차가 더 작아도, 추론 연산은 여전히 float입니다. Linear는 오차를 조금 더 감수하는 대신 연산까지 정수로 만들어 실제 하드웨어에서 빨라집니다. 두 방식은 경쟁이 아니라 목적이 다른 도구입니다.\n방식 저장 연산 격자 원본 FP 가중치 FP 산술 — K-Means 양자화 정수 인덱스 + FP 코드북 FP 산술 비균등 Linear 양자화 정수 가중치 정수 산술 균등 직접 돌려보기 위 plot과 표는 전부 아래 두 노트북을 Colab에서 실행해 나온 것입니다. 런타임 → 모두 실행이면 끝나고, 자기 모델의 .safetensors를 올려 경로만 바꾸면 그대로 적용됩니다.\n📓 linear-quantization.ipynb — 아핀 매핑, symmetric/asymmetric 📓 kmeans-quantization.ipynb — 코드북/인덱스, 압축비 정리 양자화 = 표현 가능한 값의 개수를 줄이는 것. 가중치가 0 근처에 몰려 있으니, 32비트를 다 쓰는 건 낭비입니다. K-Means(비균등) 는 대표값을 분포에 맞춰 놓아 저장을 줄이지만 연산은 float. 낮은 비트에서 오차가 작습니다. Linear(균등) 는 아핀 매핑 $r=S(q-Z)$로 저장도 연산도 정수화. 하드웨어 가속의 기반입니다. 8비트는 거의 공짜, 2비트까지 밀면 방식 선택이 정확도를 가릅니다. 다음 실험 노트에서는 여기서 다루지 못한 활성값(activation) 양자화와 QAT(양자화 인지 학습) 로, 정수 추론 파이프라인을 끝까지 세워보겠습니다.\nHan, Mao, Dally. Deep Compression: Compressing Deep Neural Networks with Pruning, Trained Quantization and Huffman Coding. ICLR 2016.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\nJacob et al. Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference. CVPR 2018.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"http://blog.caveduck.io/ko/posts/neural-net-quantization/","summary":"학습된 32비트 신경망 가중치를 K-Means(비균등)와 Linear(균등, 정수 연산) 두 방식으로 2~8비트까지 줄여봅니다. 아핀 매핑 r=S(q−Z)부터 압축비·정확도 트레이드오프까지, MNIST MLP로 직접 코드를 돌려 측정한 plot과 함께.","title":"경량화 시리즈 #2 — 뉴럴넷 양자화"},{"content":" Warpspace는 Caveduck.io를 온디바이스·저비용으로 굴리기 위해 모델을 작게 만드는 여러 실험을 합니다. 이 글은 그 경량화 시리즈의 첫 편(#1 데이터타입)입니다. 이어지는 #2 양자화·#3 프루닝 편에서는 여기서 다룬 데이터타입으로 실제 모델을 줄이고 그 결과를 직접 측정합니다.\n데이터타입이 왜 이렇게 많아졌나 몇 년 전만 해도 딥러닝의 숫자는 그냥 float32 하나면 충분했습니다. 그런데 요즘 논문이나 모델 카드를 보면 FP16, BF16, FP8 (E4M3/E5M2), INT8, INT4, FP4, NF4… 온갖 데이터타입이 쏟아집니다. 이름만 봐서는 뭐가 뭔지, 왜 이렇게 여러 개인지 헷갈립니다.\n이유는 단순합니다. 저비트 연산은 그냥 싸기 때문입니다. 45nm 공정 기준으로 연산 하나의 에너지 비용은 대략 이렇습니다.\n연산 에너지 (pJ) 8-bit int ADD 0.03 32-bit int ADD 0.1 32-bit float ADD 0.9 8-bit int MULT 0.2 32-bit float MULT 3.7 8비트 정수 곱셈은 32비트 float 곱셈보다 약 18배 싸고, 덧셈은 30배 쌉니다. 게다가 메모리에서 값 하나를 읽어오는 비용은 곱셈·덧셈 한 번보다 수백 배 비쌉니다. 그러니 같은 모델이라도 숫자를 더 적은 비트에 담을수록 전력도, 메모리도, 대역폭도 아낄 수 있습니다. 위의 온갖 데이터타입들은 결국 \u0026ldquo;몇 비트를, 어디에 쓸 것인가\u0026quot;를 저마다 다르게 답한 결과물입니다.\n문제는 비트를 줄이면 표현할 수 있는 숫자의 종류가 줄고, 그만큼 정밀도나 범위를 잃는다는 것입니다. 그래서 포맷마다 부호·지수·가수에 비트를 어떻게 배분하느냐로 성격이 갈립니다. 이 글은 그 배분 규칙을 — 비트가 실제로 어떤 숫자로 해석되는지 — 하나씩 뜯어봅니다.\n각 데이터타입마다 비트를 직접 클릭할 수 있는 위젯을 넣어뒀습니다. 0과 1을 눌러 뒤집으면 계산식과 결과값이 실시간으로 바뀝니다. 설명을 눈으로 읽는 것보다, 직접 비트를 만져보는 편이 훨씬 빨리 감이 옵니다.\n1. 정수 (Integer) 부호 없는 정수 (Unsigned Integer) 가장 단순합니다. $n$개의 비트 각각이 $2^k$ 자리값을 가지고, 켜진 비트($=1$)의 자리값을 모두 더합니다.\n$$\\text{value} = \\sum_{i=0}^{n-1} b_i \\cdot 2^i$$표현 범위는 $[0,\\ 2^n - 1]$. 8비트면 0부터 255까지입니다.\n아래 위젯에서 비트를 눌러보세요. 기본값 00110001은 $2^5 + 2^4 + 2^0 = 49$ 입니다.\n부호 있는 정수 (Signed Integer) 음수를 표현하려면 부호가 필요합니다. 두 가지 방식이 있습니다.\n부호-크기 (Sign-Magnitude) — 맨 앞 비트를 부호로 쓰고($0$=양수, $1$=음수), 나머지로 크기를 표현합니다. 직관적이지만 치명적인 단점이 있습니다. 00000000과 10000000이 둘 다 0입니다 (+0과 −0). 0이 두 개라 하드웨어가 번거로워집니다. 범위는 $[-2^{n-1}+1,\\ 2^{n-1}-1]$.\n2의 보수 (Two\u0026rsquo;s Complement) — 현대 컴퓨터가 실제로 쓰는 방식입니다. 아이디어는 간단합니다. 맨 앞 비트의 자리값을 음수로 만듭니다. 즉 MSB의 가중치가 $+2^{n-1}$이 아니라 $-2^{n-1}$입니다.\n$$\\text{value} = -b_{n-1}\\cdot 2^{n-1} + \\sum_{i=0}^{n-2} b_i \\cdot 2^i$$이러면 0은 00000000 하나뿐이고, 범위는 $[-2^{n-1},\\ 2^{n-1}-1]$로 음수 쪽이 하나 더 넓습니다. 아래 11001111은 $-2^7 + 2^6 + 2^3 + 2^2 + 2^1 + 2^0 = -49$ 입니다. MSB를 끄면 어떻게 바뀌는지 눌러보세요.\n2. 고정소수점 (Fixed-Point) 정수만으로는 소수를 못 담습니다. 가장 쉬운 확장은 소수점의 위치를 고정해두는 것입니다. 비트열을 그냥 2의 보수 정수로 읽은 다음, 정해진 만큼 $2^{-f}$로 스케일합니다.\n$$\\text{value} = (\\text{정수로 읽은 값}) \\times 2^{-f}$$아래는 8비트를 정수부 4비트 · 소수부 4비트($f=4$)로 나눈 것입니다. 각 비트의 가중치가 $2^3, 2^2, \\dots, 2^0, 2^{-1}, \\dots, 2^{-4}$로 이어집니다. 00110001은 정수로 읽으면 49이고, $49 \\times 2^{-4} = 3.0625$ 입니다.\n고정소수점은 단순하지만 한계가 뚜렷합니다. 소수점 위치가 고정이라 표현할 수 있는 크기의 폭(range)이 좁습니다. 아주 큰 수와 아주 작은 수를 동시에 다루기 어렵습니다. 이 문제를 푸는 것이 다음 주인공, 부동소수점입니다.\n3. 부동소수점 (Floating-Point) — IEEE 754 부동소수점은 이름 그대로 소수점이 떠다닙니다. 과학적 표기법($1.5 \\times 10^3$)의 2진수 버전이라고 보면 됩니다. 비트를 세 부분으로 나눕니다.\nSign (부호) — 1비트 Exponent (지수) — 크기의 규모를 정한다 → 표현 범위(range) 를 결정 Fraction (가수부) — 세부 값을 정한다 → 정밀도(precision) 를 결정 일반적인(normal) 수의 해석 공식은 이렇습니다.\n$$\\text{value} = (-1)^{\\text{sign}} \\times (1 + \\text{Fraction}) \\times 2^{\\text{Exponent} - \\text{bias}}$$여기서 bias는 지수를 음수까지 표현하기 위한 오프셋으로 $2^{e-1}-1$ ($e$=지수 비트 수)입니다. 그리고 가수 앞의 $(1 + \\cdots)$ 에 주목하세요. 정규 수는 항상 맨 앞에 암묵적인 1이 붙습니다 (이걸 \u0026ldquo;implicit leading 1\u0026quot;이라 부릅니다).\n잠깐, 용어 정리 — 가수 / Fraction / Mantissa / Significand\n이 부분의 용어는 문헌마다 조금씩 섞여 쓰여서 헷갈리기 쉽습니다. 위 공식의 $1.\\text{Fraction}$ 전체, 즉 유효숫자 부분을 가리키는 정식 명칭은 Significand(유효숫자) 입니다.\nFraction (가수부) — 비트에 실제로 저장되는 소수 부분만 가리킵니다. 위젯에서 노란색으로 칠해진 비트가 바로 이것. 소수점 아래 값 $0.\\text{b}_1\\text{b}_2\\cdots$ 입니다. Significand (유효숫자) — 암묵적 1까지 포함한 $1.\\text{Fraction}$ 전체. 실제로 곱해지는 유효숫자입니다. Mantissa (가수) — 역사적으로 로그표의 소수 부분을 부르던 말인데, 부동소수점에서는 보통 Fraction과 같은 뜻으로 느슨하게 씁니다. IEEE 754 표준 자체는 \u0026ldquo;Significand\u0026quot;를 정식 용어로 쓰고 \u0026ldquo;mantissa\u0026quot;는 권장하지 않지만, 현장에서는 여전히 \u0026ldquo;가수/mantissa\u0026quot;가 흔합니다. 정리하면 Significand = 1 + Fraction, 그리고 일상적으로 \u0026ldquo;가수(mantissa)\u0026ldquo;라고 하면 대개 저장되는 Fraction을 가리킵니다. 이 글에서는 비트에 담기는 필드를 Fraction(가수부) 으로 부릅니다.\n아래는 32비트 단정밀도(FP32)입니다. 부호 1 + 지수 8 + 가수 23 = 32비트, bias는 127. 기본값은 $0.265625 = (1 + 0.0625) \\times 2^{125-127}$를 표현합니다. 지수 비트를 하나씩 눌러 값이 2배씩 뛰는 걸 확인해보세요.\n특별한 값들: 0, 무한대, NaN, 그리고 subnormal 공식을 보면 이상한 점이 하나 있습니다. $(1 + \\text{Fraction})$ 때문에 정규 수로는 0을 표현할 수 없습니다. 그래서 IEEE 754는 지수 필드를 특별한 신호로 씁니다.\n지수(Exponent) Fraction = 0 Fraction ≠ 0 해석 00…0 (=0) $\\pm 0$ subnormal $(-1)^s \\times \\text{Fraction} \\times 2^{1-\\text{bias}}$ 00…1 ~ 11…0 normal normal $(-1)^s \\times (1+\\text{Fraction}) \\times 2^{\\text{Exp}-\\text{bias}}$ 11…1 (=max) $\\pm\\infty$ NaN — 핵심은 지수가 전부 0일 때입니다. 이때는 암묵적 1을 떼고($1+\\text{Fraction} \\to \\text{Fraction}$), 지수를 $2^{1-\\text{bias}}$로 고정합니다. 이렇게 만들어지는 게 subnormal(비정규) 수로, 0 근처의 아주 작은 값들을 촘촘히 메워줍니다. 반대로 지수가 전부 1일 때는 무한대(가수 0)와 NaN(가수 ≠ 0)이 됩니다.\n위의 FP32 위젯에서 직접 만들어보세요.\n지수를 전부 1로 (0 11111111 0…0) → +∞ 거기서 가수 아무 비트나 켜면 → NaN 전부 0으로 → 0 지수만 0으로 두고 가수를 조금 켜면 → 아주 작은 subnormal 값 지수 폭이 넓을수록 표현 범위가 넓어지고, 가수 폭이 넓을수록 정밀도가 올라갑니다. Exponent → Range, Fraction → Precision. 이 한 줄이 이후 모든 저정밀도 포맷 설계의 핵심 트레이드오프입니다.\n4. 절반 크기로: FP16과 BF16 FP32는 정확하지만 32비트나 먹습니다. 딥러닝은 그렇게까지 정밀할 필요가 없는 경우가 많아서, 16비트 포맷을 씁니다. 같은 16비트인데 비트를 어디에 배분하느냐가 갈립니다.\nFP16 (IEEE 754 Half Precision) 지수 5 + 가수 10. bias는 15. 정밀도(가수)에 더 투자한 배분입니다. 아래는 $1\\,10001\\,1100000000$, 즉 $-(1+0.75)\\times 2^{17-15} = -7.0$ 입니다.\nBF16 (Google Brain Float) 지수 8 + 가수 7. bias는 127. FP16과 총 비트 수는 같지만 지수를 FP32와 똑같이 8비트로 유지합니다. 즉 표현 범위는 FP32와 동일하고, 대신 정밀도를 희생했습니다. 학습 중 그래디언트처럼 값의 규모(scale)가 크게 요동치는 상황에서 오버플로 걱정이 없어 인기가 많습니다.\n아래는 $2.5 = (1 + 0.25)\\times 2^{1}$을 BF16으로 표현한 것입니다($0\\,10000000\\,0100000$).\n두 위젯에서 지수 비트 개수 차이를 느껴보세요. BF16은 지수 셀이 8칸이라 아주 큰/작은 수까지 닿지만, 가수가 7칸뿐이라 값이 듬성듬성합니다. FP16은 그 반대입니다.\n5. 더 아래로: FP8과 FP4 FP8 (E4M3 / E5M2) 8비트 부동소수점은 최신 하드웨어(예: Nvidia Hopper/Blackwell)가 지원합니다. 두 가지 배분이 표준처럼 쓰입니다.\nE4M3 — 지수 4 + 가수 3. 정밀도 우선. 순전파(forward)의 가중치·활성값에 주로 씁니다. INF가 없고 S.1111.111만 NaN으로 씁니다. 표현 가능한 최대 정규값은 $448$. E5M2 — 지수 5 + 가수 2. 범위 우선. 역전파(backward)의 그래디언트처럼 규모가 큰 값에 씁니다. IEEE처럼 INF와 NaN을 갖습니다. E4M3부터. bias는 7입니다. 기본값 0 0111 000은 $ (1+0)\\times 2^{7-7} = 1.0$ 입니다.\nE5M2. bias 15. 지수 5칸으로 훨씬 넓은 범위를 담지만, 가수가 2칸뿐이라 값 간격이 성깁니다.\nINT4와 FP4 가장 극단입니다. 4비트로 표현 가능한 값은 단 16개뿐입니다. 이 16개를 어떻게 배치하느냐가 포맷마다 다릅니다.\nINT4 — 2의 보수 정수. $-8$부터 $7$까지 균등한 간격으로 놓입니다.\nFP4는 지수/가수 배분에 따라 값의 분포가 달라집니다. 지수 비트를 늘릴수록 0 근처는 촘촘하고 바깥쪽은 성기게, 비균등(non-uniform) 하게 퍼집니다.\nE1M2 — 지수 1 + 가수 2. 정수에 가장 가깝습니다 (bias 0). 0111 = $(1+0.75)\\times 2^{1-0} = 3.5$. E2M1 — 지수 2 + 가수 1 (bias 1). 0111 = $(1+0.5)\\times 2^{3-1} = 6$. E3M0 — 지수 3 + 가수 0 (bias 3). 가수가 없어서 사실상 2의 거듭제곱만 표현합니다. 0111 = $(1+0)\\times 2^{7-3} = 16$. random 버튼을 눌러 값들이 $\\dots, 4, 8, 16$처럼 지수적으로 벌어지는 걸 확인해보세요. 이 네 포맷의 \u0026ldquo;표현 가능한 16개 값\u0026quot;을 수직선 위에 한번에 찍어 보면 성격 차이가 한눈에 들어옵니다. INT4는 일정 간격, FP4는 지수 비트를 늘릴수록 0 근처가 촘촘해지고 바깥이 벌어집니다.\n여기서 재미있는 관찰이 하나 나옵니다. 신경망의 가중치 분포는 대개 0 근처에 몰려 있고 꼬리가 깁니다. 그렇다면 값을 균등하게 늘어놓는 INT보다, 0 근처를 촘촘하게 메우는 FP 계열이 같은 4비트로도 실제 값들을 더 잘 맞출 수 있습니다. 어떤 분포에는 어떤 포맷이 어울리는가 — 데이터타입 선택이 곧 정확도로 이어지는 이유입니다. (이 관찰을 실제 모델로 측정하는 건 양자화 편에서 다룹니다.)\n마무리 여기까지가 딥러닝에서 마주치는 데이터타입들이 실제로 무슨 뜻인지에 대한 이야기입니다. 정리하면,\n정수 / 고정소수점 — 값이 균등한 간격. 단순하지만 범위가 좁다. 부동소수점 — 지수로 범위를, 가수로 정밀도를 나눠 갖는다. 0 근처는 촘촘하고 바깥은 성기다. 비트를 줄인다는 것 = 표현 가능한 값의 개수를 줄인다는 것. FP32의 약 43억 개에서 FP4의 16개까지. 이제 FP16, BF16, FP8 E4M3, INT4 같은 이름을 보면 머릿속에 비트 배치가 그려질 것입니다. 딥러닝에서 \u0026ldquo;어떤 데이터타입을 쓸까?\u0026ldquo;는 더 이상 사소한 구현 디테일이 아니라, 모델을 얼마나 작고 빠르게, 그러면서도 정확하게 굴릴 수 있느냐를 가르는 설계 선택이 됐습니다.\n다음 글에서는 이 데이터타입들을 무기 삼아, 이미 학습된 32비트 모델을 실제로 2~8비트로 줄여 보고 — 정확도가 얼마나 유지되는지, 크기가 얼마나 주는지 — 직접 코드를 돌려 측정합니다.\n👉 뉴럴넷 양자화 편: 32비트 모델을 2비트까지 줄여보기\n","permalink":"http://blog.caveduck.io/ko/posts/deep-learning-data-types/","summary":"INT8, FP16, BF16, FP8(E4M3/E5M2), FP4 — 딥러닝 모델 카드에 쏟아지는 데이터타입들이 실제로 무슨 뜻인지, 비트가 어떻게 숫자로 해석되는지 하나씩 뜯어봅니다. 비트를 직접 클릭하면 계산식과 값이 실시간으로 바뀌는 위젯과 함께.","title":"경량화 시리즈 #1 — 딥러닝 데이터타입"},{"content":"안녕하세요! 워프스페이스 블로그에 오신 것을 환영합니다! AI 컴패니언 서비스 Caveduck.io를 만들어가는 여정과 인사이트, 경험을 공유하는 이 공간을 열게 되어 기쁩니다.\n블로그에서 만나볼 내용 기술 심층 분석: Caveduck을 구동하는 기술의 비하인드 스토리 제품 업데이트: 새로운 기능, 개선 사항, 로드맵 소식 AI \u0026amp; 기술 트렌드: 진화하는 AI 분야에 대한 우리의 시각 팀 이야기: AI 컴패니언의 미래를 만들어가는 사람들 소통해요 소셜 미디어를 팔로우하고 RSS 피드를 구독해서 최신 글 소식을 받아보세요.\n여러분과 함께할 수 있어 정말 기쁩니다. 함께 미래를 만들어가요!\n- Warpspace 팀\n","permalink":"http://blog.caveduck.io/ko/posts/welcome/","summary":"워프스페이스 블로그를 소개합니다 - Caveduck.io를 만들어가는 여정을 공유합니다","title":"워프스페이스 블로그에 오신 것을 환영합니다"},{"content":"Warpspace Inc. 소개 Warpspace Inc.는 의미 있고 몰입감 있는 상호작용을 제공하는 혁신적인 AI 컴패니언 서비스 Caveduck.io를 만드는 회사입니다.\n우리의 미션 우리는 도움이 되고, 안전하며, 진정으로 즐거운 AI 컴패니언을 만드는 것을 믿습니다. 최고 수준의 품질과 사용자 경험을 유지하면서 AI-인간 상호작용의 가능성을 넓혀가는 것이 우리의 목표입니다.\nCaveduck.io Caveduck.io는 우리의 대표 서비스입니다 - 개인화되고 몰입감 있는 대화를 제공하는 AI 컴패니언 서비스입니다. 창작 파트너, 학습 동반자, 또는 단순히 대화 상대를 찾고 있다면, Caveduck이 함께합니다.\n연락처 이메일: contact@caveduck.io 웹사이트: caveduck.io 회사 홈페이지: warpspace.caveduck.io GitHub: github.com/warpspaceinc ","permalink":"http://blog.caveduck.io/ko/page/about/","summary":"Warpspace Inc.와 Caveduck.io 소개","title":"소개"}]