なぜこの記事を書いたか
「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_expert と n_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人が実際に会う医師の数 |
| ルーティング崩壊 | 一部のエキスパートに負荷が集中し、残りが使われなくなる現象 | 特定の科に患者が殺到し、他科が開店休業になる |
| 補助損失 | 負荷を均等化するために主損失へ足す罰則項 | 受付に課す交通整理のルール |
| 共有エキスパート | ルーティングを経ず、全トークンが必ず通るエキスパート | 全員が最初に通る総合内科 |
次に読むとよいもの
- GGUF と Safetensors — テンソルがファイル上でどう並ぶか
- llama.cpp と vLLM — 実際にオフロードを試す
- 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 まわりの実測を投稿しています。