技術 約10分で読めます

SwiftLMで非Qwen系MoEのLing-flash-2.0 MXFP4をM1 Max 64GBで動かす

いけさん目次

SwiftLMをM1 Max 64GBで動かしてOllama/MLX-lmと比べてみたの記録ではQwenしか試していなかった。Qwen3.6-35B-A3BとQwen3.5-122B-A10Bはいずれもmlx-community/から配布されているMLX 4bit版で、mlx-swift-lmのフロントエンドでも確実に動くのがわかっていたモデルだ。

今回はSwiftLMが非Qwen系のMoEをどこまで食うかを見たい。候補に挙がったのはTencent Hy3-previewとAnt Ling-2.6-flashが同週公開のニュースで触れたAnt Groupの新作Ling-2.6-flashの1世代前にあたるLing-flash-2.0。2.6-flash本体は現時点でAPI only・重み未公開だが、2.0は既にMITで公開されていて、アーキテクチャも同じbailing_moe系統。総104B / アクティブ7.4B / コンテキスト32K(YaRNで128K拡張可)のMoEで、mlx-community公式の4bit版は今回の時点で未公開だったが、個人リポジトリexdysa/Ling-flash-2.0-MLX-MXFP4がMXFP4量子化で54.7GBのMLX版を出している。これをSwiftLMに食わせる。

やる前の不安と事前確認

SwiftLMは内部でmlx-swift-lmに丸投げするので、mlx-swift-lm側がbailing_moeMXFP4の両方をサポートしていないと即落ちる。前回のQwen記事でSwiftLMがmlx-swift上に薄く被さっているだけなのは確認済みだが、アーキレベルのサポートはmlx-swift-lm次第だ。

触る前に両方チェックした。幸い揃っていた。

bailing_moe対応

$ ls ~/projects/SwiftLM/mlx-swift-lm/Libraries/MLXLLM/Models/ | grep -i bail
BailingMoe.swift

BailingMoe.swift が364行で存在する。LLMModelFactory.swiftを見るとこう登録されている。

"bailing_moe": create(BailingMoeConfiguration.self, BailingMoeModel.init),

コメントにある通り、mlx-lm Python側のbailing_moe.pyをそのままSwiftに移植したもの。ファイルの中身を確認すると、V2特有の機能も一通り実装されていた。

V2機能HF configキーSwift実装
エキスパートグルーピングn_groupnGroup
ルーターのbias補正moe_router_enable_expert_biasexpertBias as MLXArray
共有エキスパートnum_shared_expertssharedExperts
前段のdense層差替えfirst_k_dense_replacefirstKDenseReplace

Ling-flash-2.0のconfig.jsonarchitecturesBailingMoeV2ForCausalLMだが、model_typebailing_moe。mlx-swift-lmはmodel_typeで分岐するので、V1/V2の差は実装側で吸収されている。

MXFP4対応

$ grep -rE "mxfp4|MXFP4" ~/projects/SwiftLM/mlx-swift/ | head -5
Source/Cmlx/mlx/mlx/backend/metal/kernels/fp_quantized.metal
Source/Cmlx/mlx/mlx/primitives.cpp
Source/python/tests/test_quantized.py

Metalカーネルレベルでfp_quantized.metalがあり、quantize(mode="mxfp4")dequantizeの両方がテスト通過している。またLLMModelFactory.swiftには既にmlx-community/gpt-oss-20b-MXFP4-Q8が登録済みで、別モデルでMXFP4実行実績があるのがわかる。

bailing_moe × MXFP4 の組み合わせだけは実測例を見つけられなかったが、構成要素は揃っている。

モデルのDL

54.7GB・11シャード。

HF_HUB_ENABLE_HF_TRANSFER=1 hf download exdysa/Ling-flash-2.0-MLX-MXFP4

hf_transfer有効で60〜70MB/s、14分ほどで完了した。

config.jsonの主要部分。

{
  "architectures": ["BailingMoeV2ForCausalLM"],
  "model_type": "bailing_moe",
  "num_experts": 256,
  "num_experts_per_tok": 8,
  "num_shared_experts": 1,
  "n_group": 8,
  "first_k_dense_replace": 1,
  "moe_router_enable_expert_bias": true,
  "hidden_size": 4096,
  "num_hidden_layers": 32,
  "num_attention_heads": 32,
  "num_key_value_heads": 4,
  "max_position_embeddings": 32768,
  "quantization": { "group_size": 32, "bits": 4, "mode": "mxfp4" }
}

256エキスパート中top-8 + 共有1。DeepSeek-V3系のn_groupグルーピングルーティングも入っている。Qwen3.6-35B-A3BやQwen3.5-122B-A10Bはもっとラフな8-of-128/top-k=8構成だったので、Lingの256エキスパート + group-8構成はSwiftLMにとって初物。

起動

前回と同じ--product SwiftLMでビルドしたバイナリを使う。--stream-expertsは付けない(54.7GB < 64GBで overcommit 不要と踏んでいた)。

cd ~/projects/SwiftLM
nohup .build/release/SwiftLM \
  --model exdysa/Ling-flash-2.0-MLX-MXFP4 \
  --port 5413 > /tmp/swiftlm-ling.log 2>&1 &

起動ログ(主要部分)。

[SwiftLM] Loading model: exdysa/Ling-flash-2.0-MLX-MXFP4
[SwiftLM] ⚠️ Memory strategy: SWAP-ASSISTED (1.0× overcommit, cache limited to 2MB)
[SwiftLM]    Model exceeds RAM by 1%. macOS swap will be used. Expect 2-4× slowdown.
[SwiftLM] Auto-partitioning: 31/32 layers on GPU
[SwiftLM] Loading LLM (large language model)...
[SwiftLM] Loaded model configuration. Inferred tool call format: nil
[SwiftLM] ⚠️  Model does not support layer partitioning (architecture not yet adapted)
[SwiftLM] Model loaded. Starting HTTP server on 127.0.0.1:5413
[SwiftLM] ✅ Ready. Listening on http://127.0.0.1:5413
{"event":"ready","engine":"mlx","partition":{
 "strategy":"swap_assisted","model_weight_gb":54.7,"kv_cache_gb":0.3,
 "total_required_gb":65.9,"system_ram_gb":68.7,"overcommit_ratio":1.02,
 "gpu_layers":31,"cpu_layers":1,"total_layers":32,
 "estimated_tok_s":3.3}}

起動は2分ほどで通った。モデルは確かに食った。ただし想定していたfull_gpuではなく**swap_assisted**に分類された点が意外だった。総要求は54.7GB + 0.3GB KVで65.9GBと計算されており、系の公称メモリ68.7GBに対してわずかovercommit_ratio: 1.02しかない。ところがSwiftLMは「1%でも超えたらswapに頼る」という保守的な判定を入れている。

さらに重大なのが、警告

⚠️  Model does not support layer partitioning (architecture not yet adapted)

bailing_moeアーキはBailingMoe.swiftでモデル実装自体は存在するが、SwiftLM側のレイヤー分割ストラテジ(層をGPUとCPUに振り分ける仕組み)にはまだ登録されていない。起動後、設定上はgpu_layers: 31 / cpu_layers: 1と書かれているが、これはauto-partitionerの名目上の計算結果で、実際のモデル実行時には効いていない可能性が高い。

加えてssd_stream=disabledturbo_kv=disabled。どちらも--stream-experts--turbo-kvを明示しないとデフォルトでは無効。

疎通確認でいきなりswap満杯になる

起動はしたので1+1は?という最小プロンプトを投げてみる。

curl -s http://localhost:5413/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "exdysa/Ling-flash-2.0-MLX-MXFP4",
    "stream": false,
    "messages": [{"role":"user","content":"1+1は?"}]
  }'

この時点でサーバ側のログ。

srv  slot_launch: id 0 | prompt=23t | thinking=false | prefilling...
srv  slot update: id 0 | prefill done | n_tokens=23, t=74.85s, 0.3t/s |
  OS_RAM=51.1GB | MEM_DEMAND=99.5GB | GPU_MEM=50.9GB

prefill 0.3 tok/s(23トークンに74.85秒)。前回のQwen3.6-35B-A3Bが約40 tok/sだったので2桁落ちている。MEM_DEMAND=99.5GBが異様で、モデル54.7GB + KV 0.3GB = 55GBで済むはずが、実際には99.5GB要求に膨れている。

vm_statsysctl vm.swapusageで見ると:

vm.swapusage: total = 14336.00M  used = 13176.50M  free = 1159.50M
Pages wired down: 2,628,154 (≈ 42 GB)

swapが満杯。macOSの14GBのswap pool全部を使い切って、それでも足りない状態。M1 Maxのswap上限を超えた瞬間にカーネルパニック予備軍になる。応答は出るが、その先の生成も1トークンごとに秒単位かかる。

MEM_DEMANDが実モデルの倍近くまで膨らむ理由は、MXFP4量子化の逆量子化経路の可能性が高い。MXFP4は4bit格納 + 32要素ごとの共有スケールで、推論時にはfp16相当まで展開される。54.7GBのMXFP4がfp16化されると単純計算で4倍の218GB相当。実態はレイヤーごとに部分展開される想定だが、Model does not support layer partitioning警告の通り、レイヤーごとのストラテジが効かず展開バッファが過剰に取られた、と読むのが自然。

一回目の推論はここでkillした。

--stream-experts で再起動したら全部ひっくり返る

そのままもう一度--stream-expertsフラグを足して起動し直す。

pkill -f "SwiftLM --model"
nohup .build/release/SwiftLM \
  --model exdysa/Ling-flash-2.0-MLX-MXFP4 \
  --stream-experts \
  --port 5413 > /tmp/swiftlm-ling-stream.log 2>&1 &

起動ログ。

[SwiftLM] Enabled Async SSD Streaming on directory: 494c709b...
[SwiftLM] 💾 Memory strategy: SSD STREAMING (page-cache managed, 50GB RAM budget, no swap)
[SwiftLM] SSD Streaming active: Bypassing CPU auto-partitioning (forcing all layers to GPU)
[SwiftLM] Loaded model configuration. Inferred tool call format: nil
[SwiftLM] ⚠️  Model does not support SSD expert streaming
[SwiftLM] 🧠 Auto-calibration (Wisdom) bypassed for SSD Streaming
[SwiftLM] ✅ Ready. Listening on http://127.0.0.1:5413
{"event":"ready","partition":{
 "strategy":"ssd_streaming","model_weight_gb":54.7,"kv_cache_gb":0.3,
 "total_required_gb":65.9,"system_ram_gb":68.7,"overcommit_ratio":1.02,
 "gpu_layers":32,"cpu_layers":0,"ssd_stream":true,
 "estimated_tok_s":3.3}}

ここでも⚠️ Model does not support SSD expert streamingという警告が出る。bailing_moeはlayer partitioningだけでなくSSD expert streamingにも未対応ということになる。それでも実測の挙動は1回目と大きく違う

同じ1+1は?max_tokens=30で投げる。

srv  slot update: id 0 | prefill done | n_tokens=23, t=4.64s, 5.0t/s |
  OS_RAM=6.1GB | MEM_DEMAND=6.1GB | GPU_MEM=5.9GB
srv  slot done: id 0 | gen_tokens=30 | OS_RAM=6.0GB | MEM_DEMAND=6.0GB

勝手にひっくり返っている。

項目--stream-expertsなし--stream-expertsあり
strategyswap_assistedssd_streaming
prefill (cold)0.3 t/s (75秒)5.0 t/s (4.6秒)
OS_RAM51.1 GB6.1 GB
MEM_DEMAND99.5 GB6.1 GB
GPU_MEM50.9 GB5.9 GB

⚠️ Model does not support SSD expert streamingとは言いつつ、SSD STREAMING (page-cache managed, 50GB RAM budget, no swap)のヒントを受け取った下位層はちゃんと反応している。つまり--stream-expertsの実際の効果は**「SSD expert streamingを発火させる」ではなく、「mmap + page cacheで制御しろ、全体をRAMにeager-loadするな」というmacOSカーネルへのプレッシャ設定**だと読める。Lingのように「モデルを素直にmmap展開しようとするとMXFP4の逆量子化で破綻する」タイプには、このヒントが不可欠になる。

BST挿入関数で速度を測る

ウォーム状態で前回のQwen記事と同じプロンプトを投げる。

curl -s http://localhost:5413/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model":"exdysa/Ling-flash-2.0-MLX-MXFP4",
    "stream":false,
    "max_tokens":300,
    "messages":[{"role":"user","content":"Pythonで、二分探索木に値を挿入する関数 insert(root, val) を書いて。短く。"}]
  }'

出力(抜粋)。

class Node:
    def __init__(self, val):
        self.val = val
        self.left = None
        self.right = None

def insert(root, val):
    if not root:
        return Node(val)
    if val< root.val:
        root.left = insert(root.left, val)
    else:
        root.right = insert(root.right, val)
    return root

サーバログ。

srv  slot update: id 0 | prefill done | n_tokens=46, t=2.27s, 20.2t/s
srv  slot done: id 0 | gen_tokens=248 | OS_RAM=6.9GB | GPU_MEM=5.9GB
  • 46プロンプトトークン + 248生成トークン、wall 38.0秒
  • prefill 20.2 t/s(ウォーム時)
  • generation 6.95 tok/s(248トークン / 約35.7秒)
  • メモリ使用量 OS_RAM 6.9GB / GPU_MEM 5.9GB で安定

興味深いのは出力コード中のval< root.valのスペース欠落。Qwen3.6-35B-A3BをSwiftLMで回したときと同じ表記揺れが出ていて、これはSwiftLMのサンプラーに起因する癖(mlx-lmでは出にくい)と判断できる。モデルが違っても同じSwiftLMサーバなら再現する、という前回の仮説が1本積まれた。

速度比較

今回の計測を既存計測に重ねる。

モデルランタイムgeneration
Qwen3.6-35B-A3B (UD-MLX-4bit)Ollama GGUF27 tok/s
Qwen3.6-35B-A3B (UD-MLX-4bit)MLX-lm54 tok/s
Qwen3.6-35B-A3B (UD-MLX-4bit)SwiftLM20 tok/s
Qwen3.5-122B-A10B-4bitSwiftLM --stream-experts4.25 tok/s
Ling-flash-2.0 MXFP4 (100B-A6.1B)SwiftLM --stream-experts6.95 tok/s

active 6.1Bの軽さと、総54.7GBでメモリ圧迫が少ない分だけQwen3.5-122B-A10Bよりは速い。ただQwen3.6-35B-A3Bの20 tok/sには及ばず、総パラの差(100B vs 35B)ぶんだけ素直に落ちた感じ。SwiftLMがmlx-lmの半分以下しか出ないというスコアはQwen以外のアーキでも再現した。mlx-swiftとmlx-lmの差はモデル非依存の問題というのが強まる。

/metricsで見る謎のSSD I/Oゼロ

--stream-expertsで起動して BST + ペルソナを流した後の/metrics

swiftlm_tokens_per_second 5.84
swiftlm_memory_active_bytes 6,327,000,048  (6.3 GB)
swiftlm_memory_peak_bytes   7,155,477,540  (7.2 GB)
swiftlm_ssd_throughput_mbps 0.0
swiftlm_ssd_bytes_read_total 0
swiftlm_ssd_chunks_total 0

swiftlm_ssd_bytes_read_total: 0。ここも前回のQwen3.5-122B-A10Bと同じ結論で、ssd_stream=trueの旗は立っているのにSwiftLM独自のSSDチャンク読込パスは最後まで発火しない

ただしLingの場合、その挙動の意味が前回とは違う。

  • 前回のQwen3.5-122B-A10B: --stream-expertsなしでも起動できたが、mmap + page cacheで足りてしまい、SSDストリーミングは「名目上だけ」有効。
  • 今回のLing-flash-2.0: --stream-expertsなしだとswapで死ぬ--stream-expertsを付けると救われるが、実測ではSSDから読んでおらず、挙動はmmap + page cacheのまま。

つまり--stream-experts本当の効果はSSDから読むことではなく、SwiftLMのメモリ割り付け戦略をssd_streaming(50GB RAM budget + no swap + page cache管理)に切り替えること。Lingはそのヒントを必要とし、Qwenは要らなかった、という違いがある。

かなちゃんペルソナ2ターン

前回と同じシステムプロンプトで2ターン投げる(Turn 3の省略応答は今回割愛)。

Turn 1: 自己紹介

はいっ!私はかなちゃんよ~!いつも元気にしてるかな?
最近はAIの仕組みを色々と調べてたり、ゲームの企画を考えたりしてるの~!特に「AIとゲームを融合させた面白いやつ」作りたかったんだよね~💡

(語尾に「〜だよ」「〜かな」を自然に使ってみました~!)

prompt 89 / completion 142、wall 22.18秒、prefill 42.3 t/s、generation 約7.1 t/s。

気になる点が3つ。

  • 一人称が「私は」で入り、指示の「わたし」から外れる
  • 指示にない絵文字(💡、😊)が付く
  • 末尾に「(語尾に「〜だよ」「〜かな」を自然に使ってみました~!)」と、指示を達成したことのメタコメントが付く

Qwen3.6-35B-A3BでSwiftLMを経由した時には一人称「わたし」は守れていたので、これはLing-flash-2.0側の日本語指示追従の弱さ(もしくはMXFP4量子化の影響)と見るのが自然。

Turn 2: 好きなゲーム3つ

わたしの好きなゲーム3つを紹介するね!

  1. 「Stardew Valley」——田舎でんぼん稼ぎと冒険、とても癒やしいよ〜!
  2. 「AI: The Somnium Files」——未来とAIが絡んだ謎解きADV、考えさせられるんだよ〜
  3. 「Undertale」——感動的なストーリーと面白い戦い方、何度でも遊べちゃうの♪

prompt 137 / completion 130、wall 18.36秒、prefill 101.6 t/s、generation 約7.6 t/s。

  • 一人称は「わたし」に戻っている
  • 固有名詞(Stardew Valley / AI: The Somnium Files / Undertale)の表記は崩れない。Qwenで起きたElden Ringelden ringの表記揺れは出ていない
  • でんぼん稼ぎ」という謎の日本語が紛れ込む(おそらく「でん農稼ぎ」か「田んぼ稼ぎ」の変換ミス的な誤生成)

prefillはプロンプトキャッシュヒットで101.6 t/sまで跳ねる。SwiftLMの強みであるキャッシュ系はLingでもそのまま効いた。


動かす前はSwiftLMのBailingMoe.swiftが存在することだけを頼りに「まあ動くだろう」と踏んでいたが、実際には⚠️ architecture not yet adaptedの警告が2回出て、--stream-expertsなしだとswapで死ぬ、という想定外の挙動になった。警告は嘘で実態は動く、逆に警告が緩く見えるフラグが実はメモリ割り付けを救っている、という二重反転がLingの検証では面白かった。--stream-expertsの本当の効果がSSDストリーミングではなく、SwiftLMのメモリモデル切り替えヒントだった、というのが今回の一番の発見。Qwen以外のMoEを食わせてみて初めて見える挙動だった。

速度面ではgeneration 6.95 tok/sで、35B-A3BがSwiftLM上で20 tok/sだったのに比べると明確に落ちる。総パラ100B分だけ素直に重くなった印象で、SwiftLM経由でmlx-lm並の速度を出すのはLingでも難しい。日本語追従の弱さ(一人称揺れ、謎日本語、メタコメント)はLing-flash-2.0側の性質なので、SwiftLMとは独立の話。

2.6-flash側がウェイト公開されたときにまた食わせてみたい。2.6ではhybrid linear attentionが入っているはずで、そっちはbailing_moeアーキでカバーされているかが次の論点になる。