背景

本番でGPU上でサービングしていたペルソナ逸脱判定モデル(AIキャラクターがペルソナを外れて一般アシスタントのように答えたり、リクエストを拒否したりする状況を捉える二値分類器)を国産NPUに移す実験を行いました。結論から言うと、「公式非対応」という壁の前で原因を一つずつ突き止めた末に、移植に成功しました。オンデバイス推論は9.7ms(固定512トークン、ジッターほぼゼロ) ほどで、既存のクラウドGPUサービングと比べても遜色ないレイテンシと安定性を確認できました。この記事は、その過程で何を試し、どこで詰まり、どう解決したのかを順に記録したものです。

まず、国産NPUにアクセスできた背景から。韓国は政府主導で国産AI半導体を複数の経路で供給しています。マーケティング文句ではなく、実際にスタートアップや研究者がRebellions ATOM系を無償/実証で確保できる窓口が複数存在します。

  • K-Cloudプロジェクト(科学技術情報通信部・NIPA、2023年から3年)— 「AI半導体ファーム構築・実証」事業。クラウド3社(Naver Cloud・KT Cloud・NHN Cloud)とAI半導体3社(Rebellions・Sapeon・FuriosaAI)がコンソーシアムを組み、国産NPUベースの高性能クラウドを構築・検証します。(ZDNetNIPA公告
  • 高性能コンピューティング支援事業(科学技術情報通信部・NIPA、2026年に約167億ウォン規模)— 選定された需要企業に国産NPUサーバを無償提供します。供給企業もKT Cloud、Gabiaなど複数が選ばれ、例えばGabiaはRebellions ATOM-Maxを無償提供しています。(EtodayVentureSquare

つまりCSP(KT/NHN/Naver/Gabia…)×事業(ファーム実証/無償提供)の組み合わせで、ATOM・ATOM+・ATOM-Maxが多様な窓口から供給されています。私たちもこのうち一つの経路で、KT CloudのAI Nexus(内部的にはBackend.AIベース)という形でATOM+ 4枚を確保しました。

この移植で期待した利得は三つでした。① 常時GPUサービングのコスト削減(政府提供ハードウェア)、② より低く安定した推論レイテンシ、③ NVIDIA GPU以外のベンダー選択肢の確保です。

問題はその次でした。

スタックを一望する — ソフトウェアからハードウェアまで

本題に入る前に、この作業がどの階層の上で起きているのかを整理します。テキスト一行が判定結果になるまでには、ソフトウェア(上)からハードウェア(下)へと連なる複数の階層を通過します。(各用語の定義は末尾の用語整理にまとめてあります。)

階層役割GPU(一般的な経路)NPU(ATOM+、私たちの場合)
アプリケーションサービスロジック(プーリング + 分類ヘッド)ペルソナ逸脱判定分類器← 同じ
モデル(バックボーン)テキストをベクトルにエンコードするニューラルネットModernBERT(mmBERT)← 同じ
統合レイヤモデル定義を実行層に接続HuggingFace transformers ライブラリ(直接実行)optimum-rbln
コンパイラモデルを実行形態に変換torch.compile / TensorRT(任意 — なくても即時実行)rebel-compilerによる事前コンパイル必須
ランタイム実際の演算実行PyTorch + CUDArebel.Runtime
ハードウェア物理アクセラレータNVIDIA GPURebellions ATOM+(RBLN-CA22)

肝はコンパイラ層です。GPUではこのスタックがおおむね透過的でモデルをそのまま実行できますが、NPUではコンパイラが関門になります。この記事の大半は、結局この一つの層を通す話です。

ちょっと待った — 「コンパイル」とは何のことか

NPUの話を続けるには、「モデルをコンパイルする」という言葉の意味を押さえておく必要があります。手短に言えば、PyTorchモデルを特定のアクセラレータがそのまま実行できる形態にあらかじめ翻訳しておくことです。GPUの基本であるeager実行(Pythonの一行ごとにカーネルをその場で呼び出す)は柔軟ですがレイテンシがばらつき、コンパイルはグラフ全体を一つの決定的なバイナリに固めて低レイテンシ・低ジッターを得ます。NPUでは事実上このコンパイルが必須であり、後述のreference_compileや動的shape分岐をオフにしなければならなかったのも、すべてこの「グラフをあらかじめ固める」という制約から来ています。

概念そのもの — eager vs コンパイル、グラフキャプチャ・演算融合・カーネルlowering(OpenAI Tritonなど)・静的shape — を図とインタラクティブなデモで一から解き明かした別記事があります → ニューラルネットのコンパイルを、やさしく理解する。ここでは私たちの事例に必要な分だけ押さえて進みます。

問題認識 — NPUは事前にコンパイルしなければならない

GPUはPyTorchモデルをそのまま実行できますが、NPUは違います。「このモデル、この入力サイズ」で事前にコンパイルし、NPU専用バイナリに変換する必要があります。この変換を担うのがRebellionsのコンパイラrebel-compilerで、その上に標準モデル定義をつなぐ統合レイヤoptimum-rblnが載ります。

ところが私たちのバックボーンはmmBERT、つまり**ModernBERTアーキテクチャ**でした。確認してみると、対応リストから外れていました。

  • optimum-rblnが公式対応するエンコーダはbert / distilbert / roberta / xlm-robertaのみ。ModernBERTは非対応。
  • Rebellions 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など)に入っている要素でした。つまりコンパイラのop互換性は事実上検証済みで、実際の違いは**「エンコーダかデコーダか」だけ、という結論に至りました。そしてエンコーダ用のベースクラス(RBLNModelForMaskedLM)はすでに存在していました。結局(2)、つまりラッパーがないだけ**でした。

注意すべき点はattentionの実装でした。ModernBERTはデフォルトがFlash Attention 2(HuggingFaceのflash_attention_2バックエンド)ですが、これはNVIDIA CUDA専用カーネルなので、rebel-compilerがNPU向けにコンパイルできません。幸い、PyTorch標準の抽象APISDPA(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

つまりアーキテクチャ全体が問題なのではなく、attentionカーネル(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関連の引数(indicescu_seqlenssliding_window_maskなど)を明示的に無効化し、動的shapeの分岐を除去。
  3. 入力シグネチャを単純化するinput_ids + attention_maskの二つだけ。

ここまではNPUなしでも検証できます。MacBookでtorch.jit.tracetorch.exportによりグラフを取り出してみると、異なる二種類の入力でeager比の誤差max|diff| = 0.0数値的に完全に同一のグラフが出ました。ここまで確認できれば、移植可能性への確信が持てます。残るは実機での検証だけです。

実機コンパイル — 最初の試みは失敗

ATOM+セッションに接続し(コンテナにrebel-compileroptimum-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+専用カーネルへloweringされ、すべてのテンソル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演算特性)でしたが、確率・判定は小数第4位まで一致しました。予測結果はそのまま再現されました。

ベンチマーク

同一モデル、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・ジッターほぼゼロ)。ベンダー選択肢も「非対応モデルすら移植できる」ことを実証して開けました。コスト削減に向けたサービング構成は現在準備中です。

教訓

  • 「非対応」はたいてい「ラッパーがない」だ。 コンパイラが理解する演算(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文脈) — PyTorchモデルを特定の入力形態に合わせてNPU専用の実行バイナリ(.rbln)に変換する過程。GPUの即時実行と対比される。
  • rebel-compiler / optimum-rbln / rebel.Runtime — それぞれRebellionsのコンパイラ、HuggingFaceモデルをコンパイラに接続する統合レイヤ、コンパイル済みバイナリをNPUで実行するランタイム。
  • SDPA vs Flash Attention 2 — どちらもattention演算の実装。ここで言うFA2はHuggingFaceのflash_attention_2バックエンド(NVIDIA CUDA専用のflash-attnカーネル)で、rebel-compilerがコンパイルできない。SDPA(scaled_dot_product_attention)はPyTorch標準の抽象APIで、各バックエンドが自前のカーネルに翻訳できるため移植性が高い。(参考:RBLNにもデコーダのKVキャッシュ用の独自flash_attnオプションがあるが、これはHFのFA2バックエンドとは別物で、エンコーダには該当しない。)
  • RoPE / GLU・GeGLU — それぞれ位置情報を回転で注入する埋め込み手法、ゲーティングを適用したMLP構造。最新のトランスフォーマーで広く使われており、Rebellionsのコンパイラもすでに対応している。
  • MaskedLM — 文の一部を隠して復元するように学習されたエンコーダの基本形態(バックボーン)。ここにプーリング・分類ヘッドを載せて分類器にする。
  • mean pooling — トークンごとのベクトルを平均して、文一つのベクトルに要約する方式。
  • ATOM+ / RBLN-CA22 — RebellionsのNPUカード(ATOM+)と、その内部ターゲット識別子(RBLN-CA22)。

参考

  • K-Cloudプロジェクト / AI半導体ファーム構築・実証 — ZDNetNIPA統合公告
  • 2026 高性能コンピューティング支援事業(国産NPU無償提供)— EtodayVentureSquare
  • Rebellions optimum-rbln(アダプタが参照した統合レイヤ)— GitHub
  • Rebellions Model Zoo — GitHub
  • Rebellions RBLN SDKドキュメント — docs.rbln.ai