0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

MoE(Mixture of Experts)ってなんだ? — 総パラメータでVRAMを見積もると必ず外す理由

0
Last updated at Posted at 2026-09-03

なぜこの記事を書いたか

「Qwen3-30B-A3B は 3B しか使わないから、8GB の VRAM でも動きますよね」と聞かれて、口ごもりました。答えは「動きません」なのですが、その理由を一言で説明できなかったのです。総パラメータと、アクティブパラメータと、実際に占有されるメモリ。この3つの関係を、私は分かったつもりで分かっていませんでした

読んでくださる方に考えてほしいのは、この一点です。MoE が節約しているのは計算であって、記憶ではない。この非対称性は MoE 固有の話に見えて、実は量子化にもキャッシュ設計にも同じ形で出てきます。「何を減らしたのか」を取り違えると、見積もりは必ず外れます。

検証環境は Xeon 1コア・メモリ 3GB という貧弱な箱です。GPU はありません。だからこそ「メモリが足りないと何が起きるか」だけは、嫌というほど手触りがあります。実測はすべて NumPy 2.4.4 / Python 3.12.3 の CPU 単一スレッドで取りました。

この記事の対象読者

  • ローカルLLMを動かしていて、MoE モデルのメモリ要件が読めない方
  • Transformerの FFN 層がどんな計算をしているかは知っている方

この記事で得られること

  • 手元の GPU でどの MoE モデルを動かすか、どのテンソルを CPU に逃がすかを自分で決められるようになります
  • ルータの補助損失係数 $\alpha$ を、勘ではなく負荷分布の実測値から決められるようになります
  • GGUF ファイルの中でエキスパート重みがどの名前で入っているかを特定できるようになります

この記事で扱わないこと

  • MoE の分散学習(All-to-All 通信、エキスパート並列)
  • MoE モデルのファインチューニング手法

1. 「使わない部屋」を持つという発想

必然: 冒頭で挙げた3つの数字を切り分けるには、まず MoE がそもそも何を節約する設計なのかを押さえる必要があります。ここを飛ばすと、後半の見積もり式がただの暗記になります。
このセクションで分かること: MoE が解こうとしている問題は、賢さと速さの綱引きであること。

まず、この記事を通して使う比喩を決めます。MoE 層は、診療科がたくさんある大きな病院の外来です。患者が来ると受付がトリアージして、いくつかの科に回す。すべての科を回らせたりはしません。

Dense なモデルには、この受付がありません。全員が全科を必ず回ります。だからパラメータを2倍にすると、1トークンあたりの計算量もきっちり2倍になる。賢さを買うのに、必ず同額の速度を支払う仕組みです。

この綱引きを断ち切る発想が、条件付き計算です。2017年に Google Brain の Noam Shazeer らが提案した Sparsely-Gated MoE 層は、入力ごとに使うパラメータを選び分けることで、パラメータ数を1000倍にしながら計算量をほぼ据え置くことに成功しました。

病院のたとえで言えば、これは「診療科を増やしても、1人あたりの診察時間は増えない」ということです。増えるのは建物の広さだけ。ここが本記事の核心なので、覚えて先に進んでください。

2. MoE 層の中身 — 受付が2つの科に回すまで

引き継ぎ: 前節で、「パラメータを増やしても計算量を増やさない」という要求が立ちました。
必然: その要求を満たす層の形を見ないと、どの重みがメモリに常駐し続けるのかが分かりません。ここが後の VRAM 見積もりの土台になります。
このセクションで分かること: ルータが何を計算し、選ばれなかったエキスパートがどう扱われるか。

記号を1つずつ導入します。まず $x$(トークンのベクトル、長さ $d$ のベクトル)は、受付に来た患者1人です。$E$(エキスパート数、整数)は病院にある診療科の数、$k$(選択数、整数)は1人の患者が回る科の数です。

そして $W_g$(ルータ重み、$d \times E$ の行列)は、受付が使う判断基準表にあたります。患者の情報にこの表を掛けると、科ごとの適合度が出ます。

p = \mathrm{softmax}(x W_g)

この式が言っているのは、「患者ベクトル $x$ を判断基準表に通して、$E$ 個の科それぞれへの適合度 $p_i$(スカラー、合計1)を出す」ということです。

次に、適合度の上位 $k$ 個だけを残します。残った科の集合を $\mathcal{T}$ と書き、その中で確率を正規化し直したものを $\tilde{p}_i$ とします。

y = \sum_{i \in \mathcal{T}} \tilde{p}_i \, E_i(x)

この式が言っているのは、「選ばれた $k$ 個の科だけが診察し、その結果を適合度で重み付けして足し合わせる」ということです。選ばれなかった $E - k$ 個のエキスパートは、計算されないだけです。重みは消えません。ここが後で効いてきます。

記号と比喩の対応表

記号 読み 病院のたとえ
$x$ トークンベクトル ベクトル・長さ $d$ 受付に来た患者1人
$E$ エキスパート数 整数 病院にある診療科の数
$k$ 選択数 整数 1人の患者が回る科の数
$W_g$ ルータ重み 行列・$d \times E$ 受付が使う判断基準表
$p_i$ 適合度 スカラー 患者と科 $i$ の相性スコア
$f_i$ 負荷 スカラー 科 $i$ が受け持った患者の割合
$\alpha$ 補助損失係数 スカラー 交通整理をどれだけ強く効かせるか

実務のフレームワークでは、PyTorch で次のように書きます。3行が核心です。

logits = self.gate(x)                                  # (T, E)
weights, idx = torch.topk(logits.softmax(-1), self.k)  # 上位kだけ残す
weights = weights / weights.sum(-1, keepdim=True)      # 選んだ中で正規化

3. 実測①: 速くはなる。しかしメモリは1バイトも減らない

引き継ぎ: 前節で、選ばれなかったエキスパートの重みは「計算されないだけで、存在はしている」ことを確認しました。
必然: 存在するなら、メモリを占めます。では速度だけが得をするという話が、実測でどう出るのか。ここを数字で押さえないと、次章の見積もり式が信用できません。
このセクションで分かること: MoE 層の速度がアクティブパラメータ比で決まり、メモリが総パラメータで決まること。

3つの層を用意して比べました。$d = 512$、エキスパート1個あたりの中間次元は 1024、$E = 8$、$k = 2$ です。Dense-large は MoE と総パラメータが完全に同じになるように中間次元を 8192 にしてあります。

MoE 順伝播の核はこれだけです。エキスパートごとに担当トークンを集めて、そのぶんだけ行列積を回します。

def moe_forward(x, w_gate, w_up, w_down, top_k):
    logits = x @ w_gate
    idx = np.argpartition(-logits, top_k - 1, axis=-1)[:, :top_k]
    sel = np.take_along_axis(logits, idx, axis=-1)
    sel = sel - sel.max(axis=-1, keepdims=True)
    prob = np.exp(sel)
    prob /= prob.sum(axis=-1, keepdims=True)

    out = np.zeros_like(x)
    for e in range(w_up.shape[0]):
        hit = np.nonzero(idx == e)          # このエキスパート担当のトークン
        if hit[0].size == 0:
            continue
        rows = hit[0]
        h = x[rows] @ w_up[e]
        np.maximum(h, 0, out=h)
        out[rows] += (h @ w_down[e]) * prob[rows, hit[1]][:, None]
    return out

計測は中央値を取ります。

def timeit(fn, n_warmup=3, n_repeat=20):
    for _ in range(n_warmup):
        fn()
    ts = []
    for _ in range(n_repeat):
        t0 = time.perf_counter()
        fn()
        ts.append(time.perf_counter() - t0)
    return float(np.median(ts))

結果です。

総パラメータ アクティブ FP16 重み 1トークン 512トークン
Dense-small 1.05M 1.05M 2.1MB 0.160ms 7.73ms
Dense-large 8.39M 8.39M 16.8MB 1.285ms 63.28ms
MoE 8基 top-2 8.39M 2.10M 16.8MB 0.439ms 20.29ms

読み方は2つあります。

1つめ。MoE と Dense-large は重みメモリが 16.8MB でぴったり同じなのに、1トークンの時間は 1.285ms 対 0.439ms、つまり約 1/3 です。買えたのは時間だけでした。

2つめ、そしてこちらが私には意外でした。アクティブパラメータ比で言えば MoE は Dense-small の2倍で済むはずです。ところが実測は 0.439 / 0.160 = 約 2.7倍。理論比を35%上回る分は、トークンを仕分けして集め直すディスパッチ処理のコストです。エキスパートが小さいほど、この固定費の比率は上がります。MoE は無料の高速化ではない、というのがここでの手触りでした。


ここまでのまとめ

  • MoE 層は、$E$ 個のエキスパートのうち $k$ 個だけを使う。使われなかった重みは計算されないが、メモリからは消えない
  • 実測で、総パラメータが同じ Dense と MoE のメモリ使用量は完全に一致した。速度だけが約3倍になった
  • 速度の得はアクティブパラメータ比で決まるが、ディスパッチの固定費のぶん理論値より35%ほど目減りした

以降は、この「速度だけが得をする」という構図が、ルータの偏りによってどう崩れるかを見ます。


4. 実測②: 受付が偏ると、診療科が死ぬ

引き継ぎ: 前節の速度向上は、「$E$ 個のエキスパートが均等に使われる」という暗黙の前提の上に成り立っていました。
必然: 前提が崩れると、使われないエキスパートの重みはメモリを食うだけの死重になります。§3 で確認したメモリの重さが、そのまま損失に変わる。だから偏りを測る必要があります。
このセクションで分かること: 補助損失の係数 $\alpha$ を、負荷分布の実測から決める手順。

ルータには厄介な正のフィードバックがあります。よく選ばれる科ほど早く育ち、育つほど選ばれる。放っておくと数科に患者が集中し、残りは開店休業になります。これをルーティング崩壊と呼びます。

対策の定番が補助損失です。$f_i$(負荷、スカラー)はエキスパート $i$ に流れたトークンの割合、$P_i$(平均適合度、スカラー)はバッチ全体でのルータ確率の平均です。

\mathcal{L}_{\text{aux}} = \alpha \, E \sum_{i=1}^{E} f_i P_i

この式が言っているのは、「実際の患者シェア $f_i$ と、受付の贔屓の強さ $P_i$ を掛けて足す。両方が同時に大きい科があるほど罰を与える」ということです。全科が均等なら $f_i = P_i = 1/E$ で、和は $1/E$ という最小値になります。Switch Transformer の論文は $\alpha = 10^{-2}$ を採用しています。

実装の核はこの2行です。$f_i$ は数え上げなので微分できません。勾配は $P_i$ 側にだけ流します。

if alpha > 0.0:
    f = np.bincount(top, minlength=N_EXPERTS) / BATCH
    d_prob += alpha * N_EXPERTS * f[None, :] / BATCH

8個の潜在クラスタを持つ回帰タスクを作り、top-1 ルーティングで 1500 ステップ学習させました。正解の割り当てが存在するので、均等配分が最適解になる設計です。乱数種3本の平均を載せます。

$\alpha$ 最大負荷 最小負荷 死んだ科 タスク MSE
0.0 0.310 0.000 3 0.0239
0.001 0.310 0.000 3 0.0239
0.01 0.303 0.003 3 0.0241
0.1 0.174 0.000 2 0.0240
1.0 0.164 0.111 0 0.0250

理想の最大負荷は $1/8 = 0.125$ です。補助損失なしでは 0.310、つまり理想の 2.5倍の患者を1科が抱え込み、8科中3科が完全に死にました。総パラメータの37%が、一度も使われないままメモリに居座っていたことになります。

ここでの驚きは別のところにありました。教科書どおりの $\alpha = 10^{-2}$ が、私の設定ではほぼ何も変えなかったことです。効き始めたのは $\alpha = 1.0$、論文の推奨値の100倍でした。理由は単純で、補助損失が主損失と釣り合うかどうかは、主損失のスケール次第だからです。Switch Transformer の $10^{-2}$ はクロスエントロピーに対する値であって、MSE に対する値ではありません。

つまり決め方はこうなります。$\alpha$ は推奨値をコピーするものではなく、負荷分布をログに出しながら、死んだエキスパートがゼロになる最小値まで上げるものです。$\alpha = 1.0$ でも MSE の悪化は 0.0239 から 0.0250、たかだか4.6%でした。均衡の代金としては安い部類です。

DeepSeek-V3 はこの綱引き自体を回避しています。損失に項を足すのではなく、ルータのスコアにバイアス項を足し引きして top-k 選択だけを補正する方式です。均衡化の圧力が本来の目的関数と競合しない、という設計思想になっています。

5. 見積もり式は2本必要 — 総パラメータと、アクティブパラメータ

引き継ぎ: 前節で、偏りは $\alpha$ で制御できると分かりました。しかし均衡していようが崩壊していようが、全エキスパートの重みが常駐するという事実は変わりません。
必然: だから「速度の見積もり」と「メモリの見積もり」を別々の式で立てる必要があります。ここが冒頭の問い、8GB で 30B-A3B が動くかどうかの答えになります。
このセクションで分かること: 実モデルの数字で、ファイルサイズと常駐 VRAM を出す手順。

代表的な MoE モデルの公表値です。

モデル 総パラメータ アクティブ 構成
Mixtral 8x7B 46.7B 12.9B 8基から top-2
Qwen3-30B-A3B 30.5B 3.3B 128基から top-8
gpt-oss-120b 116.8B 5.1B 128基から top-4
DeepSeek-V3 671B 37B 256基から top-8 + 共有1基

gpt-oss のモデルカードには、パラメータ内訳が MLP 114.71B に対してアテンション 0.96B と書かれています。MoE の重みが総パラメータの90%以上を占めるとも明記されています。だからこそ MXFP4 でエキスパートだけを4.25ビットに落とすと、120B クラスが 80GB の GPU 1枚に収まるわけです。

公開 config からエキスパート重みの占有率を出す関数はこれだけです。

def expert_params(layers_moe, d_model, d_ff, n_experts, n_mat=3):
    """ルーテッドエキスパートの重みのみ。SwiGLU なので gate/up/down の3枚"""
    return layers_moe * n_experts * n_mat * d_model * d_ff

def report(total_b, layers_moe, d_model, d_ff, n_experts, bpw=4.8):
    exp = expert_params(layers_moe, d_model, d_ff, n_experts)
    total = total_b * 1e9
    file_gib = total * bpw / 8 / 1024 ** 3          # 総パラメータ基準
    resident_gib = (total - exp) * bpw / 8 / 1024 ** 3   # エキスパートを外した残り
    return exp / total, file_gib, resident_gib

Q4_K_M 相当の 4.8 bits per weight で計算した結果です。

モデル エキスパート占有率 ファイル概算 エキスパートを外した残り
Mixtral 8x7B 96.6% 26.1GiB 0.9GiB
Qwen3-30B-A3B 95.1% 17.0GiB 0.8GiB
DeepSeek-V3 97.5% 375.0GiB 9.6GiB

冒頭の問いに答えが出ました。Qwen3-30B-A3B は Q4_K_M でも 17GiB あります。アクティブが 3.3B でも、8GB の VRAM にはそのままでは載りません。ただし、エキスパートを外した残りは 0.8GiB しかない。つまりエキスパートだけを CPU 側に置けば、GPU に残るのは1GB未満ということです。次章がその方法です。

この見積もりは重みだけの数字です。実際には KV キャッシュと計算バッファが別途必要になります。コンテキスト長を伸ばすほど KV キャッシュが効いてくるので、余裕を1〜2GB は見てください。

6. GGUF の中でエキスパートは何という名前か

引き継ぎ: 前節で、重みの95〜98%がエキスパートだと分かりました。
必然: しかし「95%」は名指しできなければ動かせません。CPU に逃がすには、そのテンソルの名前を正規表現で書く必要があります。
このセクションで分かること: エキスパート重みの識別方法と、llama.cpp での逃がし方。

GGUF では、1レイヤ分のエキスパートが1本の3次元テンソルにまとめて格納されます。Safetensors 側でエキスパートごとに分かれていた重みが、変換時に結合される形です。

テンソル名 中身
blk.N.ffn_gate_inp ルータ $W_g$
blk.N.ffn_gate_exps 全エキスパートの gate 重み
blk.N.ffn_up_exps 全エキスパートの up 重み
blk.N.ffn_down_exps 全エキスパートの down 重み
blk.N.ffn_gate_shexp 共有エキスパート。全トークンが通る

メタデータには {arch}.expert_count{arch}.expert_used_count が入ります。これが $E$ と $k$ です。pip install gguf して gguf-dump model.gguf すれば確認できますし、llama.cpp はロード時に n_expertn_expert_used をログに出します。

逃がすためのオプションは2つです。

# 全レイヤのエキスパートをCPUへ。GPUにはアテンションと共有エキスパートだけ残る
llama-server -m model.gguf -ngl 999 --cpu-moe

# 先頭N層のエキスパートだけCPUへ。Nを下げるほどGPU側が重くなる
llama-server -m model.gguf -ngl 999 --n-cpu-moe 24

llama.cpp のソース上、--cpu-moe\.ffn_(up|down|gate|gate_up)_(ch|)exps にマッチするテンソルを CPU バッファに割り当てます。名前が exps で終わるものだけを狙い撃ちにする、というわけです。共有エキスパートは shexp なのでマッチしません。毎トークン必ず使う共有エキスパートは GPU に残るという、理にかなった設計になっています。

決め方はこうです。まず --cpu-moe で起動してとにかく動かし、VRAM に余裕があれば --n-cpu-moe の N を減らしてエキスパートを GPU へ戻していく。N を減らすほど速くなり、どこかで VRAM が足りなくなる。その手前が最適点です。

--cpu-moe は万能ではありません。選ばれたエキスパートの重みは、毎回 PCIe 経由で GPU へコピーされます。エキスパート占有率が高いモデルほど転送量が支配的になり、生成速度が RAM の帯域で頭打ちになります。

7. トラブルシューティング

症状 原因 対処
アクティブ 3B なのに 8GB の VRAM に載らない 速度の式でメモリを見積もっている 総パラメータ × bpw ÷ 8 で出し直す
--cpu-moe を付けたら生成が極端に遅い エキスパート重みが毎トークン転送されている --n-cpu-moe で層数を絞り、N を下げていく
小バッチで MoE が Dense より遅い ディスパッチの固定費が計算時間を上回っている バッチを増やす。単発なら小さい Dense のほうが速い場合がある
学習中に一部エキスパートが使われなくなる ルーティング崩壊 負荷分布をログに出し、$\alpha$ を上げる
$\alpha$ を上げたらタスク損失が悪化した 均衡化の圧力が主損失を押し負かしている $\alpha$ を1桁下げるか、バイアス項方式に切り替える
-ot の正規表現が効かない テンソル名が想定と違う gguf-dump で実際の名前を確認する

用語集

用語 定義 病院のたとえ
エキスパート MoE 層内の独立した FFN。$E$ 個ある 診療科
ルータ どのエキスパートに送るかを決める線形層 受付のトリアージ
アクティブパラメータ 1トークンの処理で実際に計算に使われる重みの数 患者1人が実際に会う医師の数
ルーティング崩壊 一部のエキスパートに負荷が集中し、残りが使われなくなる現象 特定の科に患者が殺到し、他科が開店休業になる
補助損失 負荷を均等化するために主損失へ足す罰則項 受付に課す交通整理のルール
共有エキスパート ルーティングを経ず、全トークンが必ず通るエキスパート 全員が最初に通る総合内科

次に読むとよいもの

  1. GGUFSafetensors — テンソルがファイル上でどう並ぶか
  2. llama.cppvLLM — 実際にオフロードを試す
  3. Mamba-2 — 計算量を減らすもう一方の系譜。MoE が幅を、状態空間モデルが長さを攻めている

まとめ

MoE を一言で言うなら、診療科を増やしても診察時間は増えない病院です。増えるのは建物の広さ、つまりメモリだけ。

実測でも同じ形が出ました。総パラメータが等しい Dense と MoE は、重みメモリが 16.8MB でぴったり一致し、1トークンの時間だけが 1.285ms から 0.439ms に縮みました。そして 8基中3基が死ぬ状況では、その建物の37%が空室のまま維持費だけを食っていた。

だから見積もり式は最後まで2本のままです。ファイルサイズと常駐メモリは総パラメータで、生成速度はアクティブパラメータで見る。この2本を分けて持ち歩けば、冒頭の私のように口ごもらずに済みます。

参考文献

  • Shazeer et al., "Outrageously Large Neural Networks: The Sparsely-Gated Mixture-of-Experts Layer" arXiv:1701.06538
    (邦題訳:とてつもなく大きなニューラルネットワーク ── 疎ゲート型 Mixture-of-Experts 層)— MoE 層を Transformer 以前の RNN に導入した原典。条件付き計算という考え方の出発点
  • Fedus, Zoph, Shazeer, "Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsity" arXiv:2101.03961
    (邦題訳:Switch Transformer ── 単純かつ効率的な疎性による1兆パラメータ規模へのスケーリング)— top-1 ルーティングと補助損失 $\alpha = 10^{-2}$ の出典。容量係数とトークンドロップの議論もここ
  • Jiang et al., "Mixtral of Experts" arXiv:2401.04088 / Mistral AI 公式ブログ
    (邦題訳:Mixtral of Experts)— 総46.7B・アクティブ12.9B という数字の一次情報
  • DeepSeek-AI, "DeepSeek-V3 Technical Report" arXiv:2412.19437
    (邦題訳:DeepSeek-V3 技術報告書)— 671B総/37Bアクティブ、および補助損失を使わないバイアス項方式の提案元
  • OpenAI, "gpt-oss-120b & gpt-oss-20b Model Card" arXiv:2508.10925
    (邦題訳:gpt-oss-120b および gpt-oss-20b モデルカード)— MLP 114.71B 対アテンション 0.96B というパラメータ内訳の出典
  • Qwen Team, "Qwen3-30B-A3B" モデルカード(Hugging Face)— 30.5B総/3.3Bアクティブ、128エキスパート top-8 の一次情報
  • ggml-org/llama.cpp — common/arg.cpp--cpu-moe--n-cpu-moe の定義、common/common.h にエキスパートテンソルの正規表現がある

計測環境

Intel Xeon @ 2.10GHz 1コア / メモリ 3GB / Ubuntu / Python 3.12.3 / NumPy 2.4.4(OpenBLAS 0.3.31、単一スレッド固定)。実測値はいずれも中央値、負荷分布は乱数種3本の平均です。


普段は AI とローカル LLM まわりの実測を投稿しています。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?