GPT-OSS Swallow 120B RL v0.1 の MXFP4 版を llama.cpp の llama-server で動かす技術検証で、公式の safetensors から自分で GGUF に変換しようとしたところ、変換器が assert で止まりました。元の gpt-oss(OpenAI の MXFP4 版)が通る形を前提にした変換器なので、同じ系統のモデルでなぜ落ちるのかで迷うところです。

結論

  • 原因は MoE の量子化ブロックの形です。 Swallow 版は、名前が _blocks で終わるテンソルを [専門家, 行, 1440] の3次元で持っています。llama.cpp の変換器(conversion/gpt_oss.py の repack_mxfp4)は OpenAI の元の形 [専門家, 行, 90, 16] を前提にしていて、assert len(blocks.shape) == 4 で落ちます。
  • 並びは同じ(行優先で 1440 = 90 × 16)なので、形を戻すだけで済みます。
  • llama.cpp のソースは書き換えず、GptOssModel.get_tensors を包んで3次元のブロックだけ4次元に reshape するラッパーから変換器を走らせました。4 分 58 秒・RSS 4.9 GiB で通りました。

試した環境

  • モデル: tokyotech-llm/GPT-OSS-Swallow-120B-RL-v0.1-MXFP4(リビジョンを固定して取得。61 GB、取得に 9 分)
  • 変換器: llama.cpp v0.6.0 の convert_hf_to_gguf.py
  • 変換用の venv: llama.cpp の requirements のとおり(torch は CPU 版 2.11.0)
  • 変換したマシン: Ubuntu。変換に GPU は使いません

何が起きたか

変換器をそのまま走らせると、repack_mxfp4 の中の次の行で落ちました。

assert len(blocks.shape) == 4

トレースバックの全文は残していませんが、落ちたのはこの assert で、関数は repack_mxfp4 です。

Swallow 版のブロックは3次元で、最後の次元が 1440 でした。元の gpt-oss は同じブロックを [専門家, 行, 90, 16] の4次元で持っていて、90 × 16 = 1440 です。最後の2つの次元が、1つにまとまっていたわけです。

丸い断面と四角い断面の2本のガラス管を短い継ぎ手でつなぎ、色の付いたビーズが同じ順のまま流れていく様子

なぜ形を戻すだけでよいのか

行優先で並んでいれば、[専門家, 行, 1440] と [専門家, 行, 90, 16] は中身の並びが同じです。最後の 1440 個を 16 個ずつ 90 組に区切り直すだけなので、値を並べ替える必要はありません。

次の図は、ラッパーが重みの読み出しと変換器のあいだに入り、形だけを戻す様子です。

3次元のブロックを4次元に戻してから変換器に渡す

4次元で届けば assert は通り、変換は最後まで進みました。

ラッパー

llama.cpp を clone した場所を LLAMA_CPP に書き、適当な名前(ここでは wrapper.py)で保存します。

import runpy, sys
LLAMA_CPP = "/path/to/llama.cpp"   # llama.cpp を clone した場所
sys.path.insert(0, LLAMA_CPP)
from conversion import gpt_oss
_base = gpt_oss.GptOssModel.get_tensors
def _get_tensors(self):
    for name, t in _base(self):
        if name.endswith("_blocks") and len(t.shape) == 3:
            t = t.reshape(t.shape[0], t.shape[1], t.shape[2] // 16, 16)
        yield name, t
gpt_oss.GptOssModel.get_tensors = _get_tensors
sys.argv = [f"{LLAMA_CPP}/convert_hf_to_gguf.py", *sys.argv[1:]]
runpy.run_path(f"{LLAMA_CPP}/convert_hf_to_gguf.py", run_name="__main__")

やっていることは3つです。

  • llama.cpp の変換器の gpt-oss 用の部分(conversion.gpt_oss)を読み込み、GptOssModel.get_tensors を差し替える
  • 差し替えた get_tensors は、名前が _blocks で終わり、しかも3次元のテンソルだけ、最後の次元を (1440/16, 16) に reshape して渡す。ほかのテンソルには手を付けない
  • 受け取った引数をそのまま convert_hf_to_gguf.py に渡し、runpy でスクリプトとして走らせる

呼び方は、変換器を直に呼ぶときと同じ引数です。

python wrapper.py "$SNAPSHOT" \
  --outtype auto --outfile GPT-OSS-Swallow-120B-RL-v0.1-MXFP4_MOE.gguf

python は変換用の venv の物、$SNAPSHOT はダウンロードした重みの置き場です。変換は 4 分 58 秒・RSS 4.9 GiB で終わり、専門家が MXFP4、ほかが bf16 の 61.3 GB(57 GiB)の GGUF ができました。

確かめ方

できた GGUF を llama-server で --n-cpu-moe 30 にして起こすと、VRAM 20 GB の Radeon RX 7900 XT で、VRAM 15.6 GB・生成 7.9 トークン/秒で動きました。

中身の確かめは1問です。「日本の首都と有名な電波塔を1文で」と聞くと、「日本の首都は東京で、そこにある有名な電波塔は東京タワーです。」と返りました。

確かめていないこと

  • 当時の assert のトレースバックの全文(残っているのは assert の行と関数名だけです)
  • llama.cpp の新しい版で、この3次元の形にそのまま対応したかどうか
  • 長い文章で、元の gpt-oss の GGUF と出力を突き合わせること(確かめたのは上の1問だけです)

おわりに

同じ assert で止まったら、ブロックが3次元で届いていないかを見てください。並びが同じなら、llama.cpp を書き換えずに、読み出しのときに形を戻すだけで変換できました。

※ llama.cpp の変換器の振る舞いとモデルの配布物の形は執筆時点の情報で、いまは変わっている可能性があります。