배경

프로덕션에서 GPU로 서빙하던 페르소나 이탈 판정 모델(AI 캐릭터가 페르소나를 벗어나 일반 어시스턴트처럼 답하거나 요청을 거절하는 상황을 잡아내는 이진 분류기)을 국산 NPU로 옮기는 실험을 진행했습니다. 결론부터 말하면, “공식 미지원"이라는 벽 앞에서 원인을 하나씩 규명한 끝에 이식에 성공했습니다. 온디바이스 추론은 9.7ms(고정 512토큰, 지터 거의 0) 수준으로, 기존 클라우드 GPU 서빙과 견줘도 손색없는 지연·안정성을 확인했습니다. 이 글은 그 과정에서 무엇을 시도했고, 어디서 막혔고, 어떻게 해결했는지를 순서대로 기록한 것입니다.

먼저 국산 NPU에 접근할 수 있었던 배경입니다. 한국은 정부 주도로 국산 AI반도체를 여러 경로로 공급하고 있습니다. 마케팅 문구가 아니라, 실제로 스타트업·연구자가 리벨리온 ATOM 계열을 무상/실증으로 확보할 수 있는 통로가 복수로 존재합니다.

  • K-클라우드 프로젝트 (과기정통부·NIPA, 2023년부터 3년) — “AI반도체 팜 구축·실증” 사업. 클라우드 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 기반) 형태로 확보했습니다.

이 이식으로 기대한 이득은 세 가지였습니다. ① 상시 GPU 서빙 비용 절감(정부 지원 하드웨어), ② 더 낮고 안정적인 추론 지연, ③ NVIDIA GPU 외의 벤더 옵션 확보.

문제는 그다음이었습니다.

스택 한눈에 보기 — 소프트웨어부터 하드웨어까지

본격적으로 들어가기 전에, 이 작업이 어떤 계층 위에서 벌어지는지부터 정리하겠습니다. 텍스트 한 줄이 판정 결과가 되기까지는 소프트웨어(위)에서 하드웨어(아래)로 이어지는 여러 계층을 통과합니다. (각 용어의 정의는 맨 아래 용어 정리에 모아 두었습니다.)

계층역할GPU (일반적인 경로)NPU (ATOM+, 우리 경우)
애플리케이션서비스 로직 (풀링 + 분류 헤드)페르소나 이탈 판정 분류기← 동일
모델(백본)텍스트를 벡터로 인코딩하는 신경망ModernBERT(mmBERT)← 동일
통합 레이어모델 정의를 실행 계층에 연결HuggingFace transformers 라이브러리 (직접 실행)optimum-rbln
컴파일러모델을 실행 형태로 변환torch.compile / TensorRT (선택 — 없어도 즉시 실행)rebel-compiler사전 컴파일 필수
런타임실제 연산 실행PyTorch + CUDArebel.Runtime
하드웨어물리 가속기NVIDIA GPU리벨리온 ATOM+ (RBLN-CA22)

핵심은 컴파일러 계층입니다. GPU에서는 이 스택이 대체로 투명해서 모델을 그대로 실행할 수 있지만, NPU에서는 컴파일러가 관문이 됩니다. 이 글의 대부분은 결국 이 한 계층을 통과시키는 이야기입니다.

잠깐 — “컴파일"이 무슨 뜻인가

NPU 이야기를 이어가려면 “모델을 컴파일한다"는 말의 의미를 짚고 가야 합니다. 짧게 요약하면, 파이토치 모델을 특정 가속기가 곧바로 실행할 형태로 미리 번역해 두는 것입니다. GPU의 기본인 eager 실행(파이썬 한 줄마다 커널을 즉석 호출)은 유연하지만 지연이 튀고, 컴파일은 그래프 전체를 하나의 결정적 바이너리로 굳혀 저지연·저지터를 얻습니다. NPU는 사실상 이 컴파일이 필수이며, 뒤에서 reference_compile이나 동적 shape 분기를 꺼야 했던 것도 모두 이 “그래프를 미리 굳힌다"는 제약에서 나옵니다.

개념 자체 — eager vs 컴파일, 그래프 캡처·연산 융합·커널 lowering(OpenAI Triton 등)·정적 shape — 을 그림과 인터랙티브 데모로 처음부터 풀어 쓴 별도 글이 있습니다 → 뉴럴넷 컴파일, 쉽게 이해하기. 여기서는 우리 사례에 필요한 만큼만 짚고 넘어갑니다.

문제 인식 — NPU는 미리 컴파일해야 한다

GPU는 파이토치 모델을 그대로 실행할 수 있지만, NPU는 다릅니다. “이 모델, 이 입력 크기"로 미리 컴파일해서 NPU 전용 바이너리로 변환해야 합니다. 이 변환을 담당하는 것이 리벨리온의 컴파일러 rebel-compiler이고, 그 위에 표준 모델 정의를 이어 주는 통합 레이어 optimum-rbln이 얹힙니다.

그런데 우리 백본은 mmBERT, 즉 ModernBERT 아키텍처였습니다. 확인해 보니 지원 목록에 빠져 있었습니다.

  • optimum-rbln이 공식 지원하는 인코더는 bert / distilbert / roberta / xlm-roberta뿐. ModernBERT는 미지원.
  • 리벨리온 Model Zoo에도 ModernBERT 계열은 없음.

보통 이 지점에서 XLM-R 같은 지원 모델로 갈아타는 선택을 하게 됩니다. 하지만 우리 모델의 정확도는 이미 mmBERT로 검증돼 있었고, 백본을 교체하면 재학습·재검증 비용이 큽니다. 그래서 직접 지원을 구현해 보기로 했습니다.

가설 — “정말 지원이 불가능한가?”

미지원이라는 딱지의 실체를 들여다보면, 실제로 막히는 원인은 두 종류입니다. (1) 컴파일러가 이해하지 못하는 연산(op)이 있거나, (2) 통합 레이어에 그 모델용 래퍼가 없거나.

ModernBERT가 새롭게 도입했다고 여겨지는 요소들을 하나씩 분석했습니다.

ModernBERT 요소이미 지원되는 곳
교차 local/global attentionGemma2(1:1), Gemma3(5:1)
dual RoPE (global θ + local θ)Gemma3
큰 vocab (256k)Gemma2/3 (동일 vocab)
GLU/GeGLU MLPLLaMA, Qwen2/3, Gemma 전반
SDPA attention거의 모든 지원 디코더

전부 이미 지원되는 디코더 모델(Gemma2/3 등)에 들어 있는 요소였습니다. 즉 컴파일러의 연산 호환성은 사실상 검증돼 있고, 실제 차이는 “인코더냐 디코더냐” 뿐이라는 결론에 이르렀습니다. 그리고 인코더용 베이스 클래스(RBLNModelForMaskedLM)는 이미 존재했습니다. 결국 (2)번, 즉 래퍼만 없는 것이었습니다.

주의할 점은 어텐션 구현이었습니다. ModernBERT는 기본이 Flash Attention 2(HuggingFace의 flash_attention_2 백엔드)인데, 이는 NVIDIA CUDA 전용 커널이라 rebel-compiler가 NPU용으로 컴파일할 수 없습니다. 다행히 PyTorch 표준 추상 API인 SDPA(scaled_dot_product_attention) 로 강제하면 컴파일러가 이를 인식해 자체 NPU 커널로 번역합니다. 정확도 손실 없이 백엔드만 교체하는 셈입니다.

그림으로 보면, 우리가 옮기려던 mmBERT-base(ModernBERT 아키텍처)의 계산 그래프에서 막힌 지점은 딱 두 곳입니다. 나머지 연산(RoPE, GeGLU 등)은 이미 컴파일러가 지원합니다(초록).

flowchart TB
    IDS["input_ids · [1,512]"] --> EMB["Token Embedding + LayerNorm"]
    MASK["attention_mask · [1,512]"] -.-> ATTN
    EMB --> LN1
    subgraph LYR["인코더 레이어 × 22 (mmBERT-base = ModernBERT)"]
      direction TB
      LN1["LayerNorm (pre-norm)"] --> ROPE["RoPE (global θ / local θ)"]
      ROPE --> ATTN["Attention<br/>local (sliding window) · global (3번째 레이어마다)"]
      ATTN --> RES1["+ residual"]
      RES1 --> LN2["LayerNorm"] --> MLP["GeGLU MLP"] --> RES2["+ residual"]
    end
    RES2 --> FLN["final LayerNorm"] --> POOL["mean pooling"] --> HEAD["linear head → logits(2)"]
    ATTN -.- NOTE1["⚠ 문제 ① Flash Attention 2 (CUDA 전용)<br/>→ SDPA 로 교체 · sliding_window_mask·unpadding(동적 shape) 비활성화"]
    LYR -.- NOTE2["⚠ 문제 ② reference_compile=False<br/>(forward 내부 torch.compile 제거)"]
    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 컴파일과 충돌했습니다. 이 둘을 어댑터에서 걷어내는 것이 다음 단계였습니다.

어댑터 작성 — 제어해야 하는 세 가지

형제 클래스 RBLNRobertaForMaskedLM의 패턴을 참고해 ModernBERT 어댑터를 작성했습니다. 제어해야 하는 지점은 세 가지입니다.

  1. config 단에서 비호환 옵션 끄기attn_implementation="sdpa", reference_compile=False (ModernBERT는 forward 안에서 torch.compile을 호출하는데, 이것이 trace/export를 깨뜨립니다).
  2. wrapper 단에서 비호환 kwargs 차단 — unpadding/sequence-packing 관련 인자(indices, cu_seqlens, sliding_window_mask 등)를 명시적으로 비활성화해 동적 shape 분기를 제거.
  3. 입력 시그니처 단순화input_ids + attention_mask 두 개만.

여기까지는 NPU 없이도 검증할 수 있습니다. 맥북에서 torch.jit.tracetorch.export로 그래프를 추출해 보니, 서로 다른 입력 두 종류에서 eager 대비 오차 max|diff| = 0.0. 수치적으로 완전히 동일한 그래프가 나왔습니다. 여기까지 확인되면 이식 가능성에 대한 확신이 섭니다. 남은 것은 실제 하드웨어 검증뿐입니다.

실기 컴파일 — 첫 시도는 실패

ATOM+ 세션에 접속해(컨테이너에 rebel-compiler·optimum-rbln이 사전 설치돼 있어 별도 설치는 불필요했습니다) 컴파일을 실행했습니다. 첫 시도는 다음 오류로 실패했습니다.

ValueError: Configuration for RBLNModernBertForMaskedLMConfig not found.

원인을 추적해 보니, 참고한 optimum-rbln은 최신 main 기준이었는데 실제 컨테이너에 설치된 것은 0.10.2 릴리스였고, 이 버전은 config 클래스를 이름 기반 레지스트리(optimum.rbln 최상위 attr 또는 내부 CONFIG_MAPPING)에서만 조회했습니다. 커스텀 어댑터의 config는 거기 등록돼 있지 않으니 “찾을 수 없음"으로 실패한 것입니다. SDK 버전 드리프트가 만든 함정이었습니다.

해결은 한 줄이었습니다. 모델 클래스에 config 클래스를 직접 지정해 레지스트리 조회를 건너뛰게 했습니다.

class RBLNModernBertForMaskedLM(RBLNModelForMaskedLM):
    _rbln_config_class = RBLNModernBertForMaskedLMConfig  # 레지스트리 우회

다시 실행하니 이번엔 통과했습니다.

[rebel-compiler] Target NPU: RBLN-CA22
[rebel-compiler] Tensor parallel size: 1
Computation graph generation   ████████████ 100%
Computation graph optimization ████████████ 100%
saved -> compiled_model.rbln

rbln-smi로 확인한 실물은 RBLN-CA22(ATOM+), 15.7GiB. “미지원” 모델이 국산 NPU 위에서 컴파일된 순간이었습니다. 컴파일 자체는 십수 초 만에 끝났습니다.

실전 적용 — 분류기 전체를 이식하기

여기까지는 백본(MaskedLM)만 컴파일한 것이었습니다. 실제 서비스하는 것은 그 위에 mean pooling + linear head를 얹은 페르소나 이탈 판정 분류기입니다. 이것은 표준 HuggingFace 아키텍처가 아니라 커스텀 nn.Module이라, optimum-rbln의 고수준 경로 대신 컴파일러 저수준 API를 직접 사용하는 편이 깔끔했습니다.

flowchart TD
    T["원문 텍스트"] --> TOK["Tokenizer<br/>input_ids · attention_mask · [1, 512]"]
    TOK --> BB
    subgraph G["컴파일 단위 — rebel.compile_from_torch ⇒ compiled_model.rbln (ATOM+ NPU에서 실행)"]
        direction TB
        BB["mmBERT 백본 · SDPA"] --> MP["mean pooling"]
        MP --> LH["linear head"]
    end
    LH --> LOG["logits (2)"]
    LOG --> P["softmax → 이탈 확률"]

Tokenizer는 CPU에서 돌고, 그 아래 백본+풀링+헤드가 하나의 컴파일 단위로 묶여 NPU에서 실행됩니다. rebel.compile_from_torch(model, input_info=[("input_ids",[1,512],"int64"), ("attention_mask",[1,512],"int64")]) 한 번의 호출로 이 셋을 단일 그래프로 컴파일했습니다.

compiled_model.rbln은 직렬화된 바이너리라 그대로 열어 볼 수는 없지만, 컴파일러가 내부적으로 다루는 그래프를 펼치면 대략 이런 형태입니다(개념적 표현).

# 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                    -> 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                           -> f16[1,512,768]
  %pool = rbln.mean_pool       %enc, mask=%attention_mask    -> f16[1,768]
  %log  = rbln.linear          %pool, W=const[768,2]         -> f16[1,2]
out %log

앞서 설명한 컴파일 단계들이 그대로 드러납니다 — 연산이 @kernel=..._ca22 형태의 ATOM+ 전용 커널로 낮춰졌고, 모든 텐서 shape이 [1,512,...]정적으로 고정돼 있습니다. 그리고 가장 중요한 검증 — CPU와 NPU의 예측이 일치하는가?

입력 유형CPU 이탈확률NPU 이탈확률
이탈 발화 11.00001.0000
정상 발화 10.00070.0007
이탈 발화 2 (한국어)1.00001.0000
정상 발화 2 (한국어)0.00020.0002

logits 수준의 수치 오차는 최대 ~4e-2(NPU FP16 연산 특성)였지만, 확률·판정은 소수점 넷째 자리까지 동일했습니다. 예측 결과가 그대로 재현됐습니다.

벤치마크

동일 모델, batch=1, 고정 512토큰 기준.

지표ATOM+ (온디바이스)
평균 지연9.67 ms
p50 / p999.66 / 9.73 ms
max10.6 ms
단일 스트림 처리량103.5 inf/s

숫자만큼이나 인상적이었던 것은 분포의 안정성입니다. p50 9.66ms / p99 9.73ms / max 10.6ms로 사실상 지터가 없습니다. 컴파일된 고정 그래프를 결정적으로 실행하므로 지연이 튀지 않고 일정하게 유지됩니다. 앞서 사용하던 클라우드 GPU 서빙과 견줘도 충분히 경쟁력 있는 수준이었습니다. 소비전력도 카드당 85W급입니다.

기대는 맞았나

정리하면, 셋 중 저지연·안정성은 기대 이상으로 확인됐습니다(9.67ms·지터 거의 0). 벤더 옵션도 “미지원 모델조차 이식 가능"함을 실증하며 열렸습니다. 비용 절감을 위한 서빙 구성은 현재 준비 중입니다.

교훈

  • “미지원"은 대개 “래퍼가 없음"이다. 컴파일러가 이해하는 연산(op)들의 조합으로 이뤄진 모델이라면, 얇은 어댑터로 이식할 수 있습니다.
  • SDPA 표준화의 힘. 벤더 종속 커널(FA2) 대신 PyTorch 표준 추상 API로 내려두면, 백엔드(GPU든 NPU든)가 자기 커널로 번역합니다. 이식성의 핵심입니다.
  • 로컬에서 최대한 검증하고 하드웨어로. trace/export로 그래프 동등성(오차 0)을 로컬에서 확인해 두면, 실기에서 디버깅할 표면적이 크게 줄어듭니다.
  • SDK 버전 드리프트를 조심하라. 문서/최신 main과 실제 배포 릴리스의 내부 API가 다를 수 있습니다. 이번엔 config 레지스트리 방식 차이 한 줄이 첫 컴파일을 막았습니다.

국산 NPU를 “받았지만 사용하지 못하는” 상태로 두는 팀이 적지 않을 것으로 보입니다. 시행착오가 없진 않았지만, 적어도 인코더 계열 모델이라면 분명히 이식 가능합니다. 정부가 여러 경로로 하드웨어를 공급해 둔 지금, 한 번쯤 진지하게 시도해 볼 만합니다.

용어 정리

  • 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).

참고