技術 約12分で読めます

SwiftLMをM1 Max 64GBで動かしてOllama/MLX-lmと比べてみた

いけさん目次

SwiftLMの紹介記事を先に書いたが、あれはソースコードとREADMEから読み解いた理論解説だった。手元のM1 Max 64GBで実際にビルドして、既存のQwen3.6-35B-A3B Ollama検証27B dense vs 35B-A3B MLX比較と並べてみる。

SwiftLMが掲げる2本柱(TurboQuant KVキャッシュ圧縮とNVMe SSDエキスパートストリーミング)が、ターゲットのM5 Proではなく4世代前のM1 Max 64GBでどこまで動くか、というのが主な関心。

環境

項目
マシンM1 Max 64GB(統合メモリ)
OSmacOS (Darwin 25.3.0)
Swift6.2.4 (swift-driver 1.127.15)
Xcode未インストール(Command Line Toolsのみ)
SwiftLMビルドb554 相当(commit実ビルド + 公式tarball併用)
モデルunsloth/Qwen3.6-35B-A3B-UD-MLX-4bit(20GB、HFキャッシュ済み)

Xcode本体を入れていない環境で進めた。前回の紹介記事で書いた通りSwiftLMはxcrun metalcmakeを使ったmetallib自前ビルドを前提にしているが、CLTだけでも公式Release tarballからmlx.metallibだけ拝借するルートで回避できる(後述)。

--product SwiftLM 指定でビルドする

素直にswift build -c releaseを叩くと、SwiftBuddy(同梱のiOSコンパニオンアプリ)で落ちる。

error: missing argument for parameter 'wings' in call
error: referencing initializer 'init(_:content:)' on 'ForEach' requires that 'PalaceWing' conform to 'Identifiable'
error: instance method 'delete' requires that 'PalaceWing' conform to 'PersistentModel'

PalaceWing型のIdentifiable/PersistentModel準拠欠落と、PalaceVisualizerViewwings:引数欠落。この時点でSwiftLM本体のバイナリは生成済みだが、ビルドコマンドは失敗扱いで終わるため気持ち悪い。

回避策は--product SwiftLMでサーバ本体だけに絞ること。

git clone --recursive https://github.com/SharpAI/SwiftLM
cd SwiftLM
swift build -c release --product SwiftLM

キャッシュ有り2回目のビルドは4.25秒。生成バイナリは.build/release/SwiftLMの61MB(Mach-O ARM64)。

metallibは公式Releaseから拝借する

前回の紹介記事で「default.metallibをサブモジュール内からコピー」と書いた手順は、今回のビルドでは効かなかった。実際のSwiftLMが期待するファイル名は**mlx.metallib** で、しかもリポジトリ内に事前にはビルドされていない。

自前ビルドを試すと順番に詰まる。

  1. cmake が無い(Homebrewで入れれば解決)
  2. xcrun metal が CLT 環境では見つからない
  3. DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer で切り替えようとしても、初回は You have not agreed to the Xcode license agreements
  4. sudo xcodebuild -license が対話必須でスクリプトから抜けられない

今回は公式のビルド済みtarballからmlx.metallibだけ取ってきた。

mkdir -p /tmp/swiftlm-prebuilt && cd /tmp/swiftlm-prebuilt
gh release download b554 --repo SharpAI/SwiftLM --pattern "SwiftLM-b554-macos-arm64.tar.gz"
tar -xzf SwiftLM-b554-macos-arm64.tar.gz
cp mlx.metallib ~/projects/SwiftLM/.build/release/mlx.metallib

tarball内のmlx.metallibは120MB。Xcodeライセンスを通していない環境でSwiftLMを動かしたい場合、この「自前バイナリ + 公式metallib」の組み合わせが最小構成になる。

名前をdefault.metallibのまま置いておくと、起動時に以下のエラーで止まる。

MLX error: Failed to load the default metallib. library not found
at /Users/.../mlx-swift/Source/Cmlx/mlx-c/mlx/c/stream.cpp:115

コード上はdefault.metallibで探すパスとmlx.metallibで探すパスの両方がありそうだが、少なくとも今回のビルドではmlx.metallibのほうしか認識されなかった。

Qwen3.6-35B-A3B-UD-MLX-4bit で疎通確認

Unsloth版のMLX 4bitモデル(20GB)は既にHFキャッシュに入っていたので、そのまま投げる。SwiftLMのREADMEはmlx-community/名前空間を推奨しているが、unsloth/のMLX版も素直に食った。

cd ~/projects/SwiftLM
nohup .build/release/SwiftLM \
  --model unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit \
  --port 5413 > /tmp/swiftlm.log 2>&1 &

起動時のログ(主要部分)。

[SwiftLM] Loading model: unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit
[SwiftLM] ✅ Memory strategy: FULL GPU (21.6GB model, 64.7GB available)
[SwiftLM] Loading LLM (large language model)...
[SwiftLM] Download: [====>] 100% ⠋ (20652.2 MB / 20652.2 MB) | Speed: 0.0 MB/s
[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":"full_gpu",
 "gpu_layers":40,"cpu_layers":0,"total_layers":40,"model_weight_gb":21.6,
 "kv_cache_gb":0.3,"total_required_gb":26.3,"system_ram_gb":68.7,
 "estimated_tok_s":9.2}}

「Download: 100%」と出るがHFキャッシュからの読み込みでネットI/Oはゼロ。Speedが0.0 MB/sのまま進捗バーが回るので初見だと回線詰まりを疑う。

コールドスタートからReady まで約5秒。estimated_tok_s: 9.2は控えめで、実測(下記)は倍以上出る。

起動メモリは ps のRSSで 19.52 GB、SwiftLMログ側のGPU_MEM19.4 GB。ロード前のvm_stat free pagesが約38GBぶんあったのが、ロード後は約17MB(4,327 pages)まで食われている。40層をフル GPU に載せるストラテジ(cpu_layers:0)がそのまま効いた結果。

curl で /v1/chat/completions を叩く

curl -s http://localhost:5413/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit",
    "stream": false,
    "messages": [{"role":"user","content":"1+1は?"}]
  }'

応答。

{
  "choices":[{
    "finish_reason":"stop","index":0,
    "message":{"content":"1 + 1 = 2 です。","role":"assistant"}
  }],
  "id":"chatcmpl-7615EE98-9392-4832-824F-8DF3D1740376",
  "object":"chat.completion",
  "model":"unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit",
  "usage":{"total_tokens":26,"prompt_tokens":17,"completion_tokens":9}
}

Wall時間4.50秒。サーバログのタイミングを見るとこうなっている。

srv slot update: id 0 | prefill done | n_tokens=17, t=3.51s, 4.8t/s
srv slot done:   id 0 | gen_tokens=9 | OS_RAM=19.5GB | GPU_MEM=19.4GB

初回はMetalパイプラインのJITウォームが入るのでprefillが3.51秒(4.8 tok/s)と遅い。2回目以降は桁が変わる。

ウォーム状態での速度

curl -s http://localhost:5413/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit",
    "stream": false,
    "max_tokens": 200,
    "messages": [{"role":"user","content":"M1 Max搭載のMacBook Proでローカル推論する利点を3つ、簡潔に列挙してください。"}]
  }'

サーバログ。

srv slot update: id 0 | prefill done | n_tokens=38, t=0.95s, 39.9t/s
srv slot done:   id 0 | gen_tokens=199 | OS_RAM=19.6GB | GPU_MEM=19.4GB

prefill 39.9 tok/s、generation 199 tok / 約9.6秒 ≈ 20.7 tok/s

Ollama GGUFで同じ35B-A3Bを動かしたときの27 tok/s、MLX-lmで動かしたときの54 tok/sと比べると、SwiftLMは現時点でOllama GGUFより少し遅く、MLX-lmの半分以下という位置になった。内部はmlx-swift(MLXのSwiftバインディング)を通っているはずなのに、mlx-lm(Python)側と同じ速度が出ていない。SwiftLM側のサンプラー実装かHTTP層のオーバーヘッド、あるいはMLXカーネル呼び出しの粒度差が候補だが、今回の検証ではそこまで切り分けていない。

なお応答冒頭に 1 という謎文字が混じるケースがあった(「M1 Max搭載…」のプロンプトに対して応答が "1M1 Max搭載..." で始まる)。Unsloth quant側のクセか、SwiftLMのチャットテンプレート処理のエッジかは今回判別できず。

停止とメモリ解放

pkill -f "SwiftLM"

停止直後にvm_statを取ると free pagesが約20GBぶんに戻った。起動/停止の前後でメモリが綺麗に解放されるので、別モデルに切り替える用途でも引き摺らない。

122B-A10B-4bit を —stream-experts で動かす

SwiftLMの本丸機能、NVMe SSDエキスパートストリーミングを試す。ターゲットハードウェアはM5 Pro 64GBなので、4世代前のM1 Max 64GBでどう挙動するかが焦点。

モデルのDL

mlx-community/Qwen3.5-122B-A10B-4bit をHuggingFaceから取る。14シャード・合計約65GB。

HF_HUB_ENABLE_HF_TRANSFER=1 hf download mlx-community/Qwen3.5-122B-A10B-4bit

hf_transfer を有効にすると約60〜70 MB/sで落ちてくる。無効だと同じ回線で5〜6 MB/sしか出ず、60GBだと2.8時間ほどかかった計算になる。有効化後は実測で 20分程度で完了。hf_transferはRustベースの並列DLライブラリで、pip install hf_transfer で入る。

起動

--stream-experts フラグ付きで起動する。

nohup /Users/hide3tu/projects/SwiftLM/.build/release/SwiftLM \
  --model mlx-community/Qwen3.5-122B-A10B-4bit \
  --stream-experts \
  --port 5413 > /tmp/swiftlm-122b.log 2>&1 &

起動ログ。

[SwiftLM] Enabled Async SSD Streaming on directory: e9c67b08...
[SwiftLM] Memory strategy: SSD STREAMING (page-cache managed, 50GB RAM budget, no swap)
[SwiftLM] SSD Expert Streaming enabled (lazy load + layer-sync)
[SwiftLM] SSD Streaming active: Bypassing CPU auto-partitioning (forcing all layers to GPU)
[SwiftLM] Config: ctx_size=model_default, ..., ssd_stream=enabled, turbo_kv=disabled
[SwiftLM] ✅ Ready. Listening on http://127.0.0.1:5413

Ready構造化JSONで partition を見るとこう。

{
  "strategy":"ssd_streaming",
  "model_weight_gb":69.6,
  "gpu_layers":48,
  "cpu_layers":0,
  "kv_cache_gb":0.4,
  "total_required_gb":83.9,
  "system_ram_gb":68.7,
  "overcommit_ratio":1.3,
  "ssd_stream":true,
  "estimated_tok_s":3.3
}

合計83.9GBの要求に対して統合メモリ68.7GB、overcommit ratio 1.3 の状態で起動を許容する。紹介記事で触れたmacOS Watchdogカーネルパニックのリスクが頭をよぎるが、SwiftLM側は「page-cache managed, 50GB RAM budget, no swap」と宣言してmmap + pagecacheで回す設計を取っている。

起動から Ready まで 約10秒。ダウンロード進捗バーのUIが暴走して分母を1593GB132GBで表示する場面があったが、実害なし(すでにキャッシュ済みでネットI/Oはゼロ)。

メモリ推移

フェーズRSSOS_RAMGPU_MEMfree pages
起動直後 (+10s)216 MB9.8 KB307,696 (~4.8 GB)
1回目推論中〜完了12.7 GB12.0 GB11.8 GB4,552 (~70 MB)
2回目推論完了12.7 GB12.1 GB11.8 GB4,096 (~64 MB)
プロセス停止後839,235 (~13 GB)

モデルウェイト69.6GBに対して実RSS 12.7GB。mmap + lazy loadでアクセスしたページだけメモリに残り、残りはSSDに置きっぱなし。GPU_MEMも11.8GB固定で安定した。

推論中に free pages が70MBまで落ちる瞬間はあるもののスワップは発生せず、停止後は13GB分の free に戻る。overcommit 1.3 の看板どおりmmapが想定通りに動いている。

BST挿入関数の実測

コールドスタート後の1発目。

curl -s http://localhost:5413/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mlx-community/Qwen3.5-122B-A10B-4bit",
    "stream": false,
    "max_tokens": 3000,
    "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 root is None:
        return Node(val)
    if val < root.val:
        root.left = insert(root.left, val)
    else:
        root.right = insert(root.right, val)
    return root

usage: prompt_tokens=37, completion_tokens=93。wall時間32秒。

サーバログから分解すると prefill 5.0 tok/s(7.38秒)、generation 3.78 tok/s(24.6秒で93トークン)。

同じプロンプトで2回目(プロンプトキャッシュが効いたウォーム状態)。

  • prefill 44.0 tok/s(0.84秒)← プロンプトキャッシュヒットで約9倍
  • generation 4.25 tok/s(21.2秒で90トークン)

累積平均は swiftlm_tokens_per_second: 3.40 で、prefillを含めた全体平均。

/metrics エンドポイントで見るSSD I/O

SwiftLMはPrometheus形式の /metrics を持っている。--stream-experts で起動した状態で、2リクエスト投げたあとの主要メトリクス。

swiftlm_requests_total 2
swiftlm_tokens_generated_total 183
swiftlm_tokens_per_second 3.40
swiftlm_memory_active_bytes 12,607,979,844  (12.6 GB)
swiftlm_memory_peak_bytes   12,707,326,186  (12.7 GB)
swiftlm_memory_cache_bytes  184,747,906     (184 MB)
swiftlm_ssd_throughput_mbps 0.0
swiftlm_ssd_bytes_read_total 0
swiftlm_ssd_chunks_total 0
swiftlm_ssd_chunk_latency_ms 0.0

ここが最大の発見で、ssd_stream=true の宣言とは裏腹に、SSD I/Oメトリクスは全てゼロのまま推論が完走している

起動時にmmapで張り付けた領域がOSのpage cacheに載り、BSTプロンプトで活性化するエキスパートが狭い範囲に収まったため、SwiftLM独自のSSDチャンク読込パスは発火しなかった。MoEの疎性(A10B = 総パラメータ1220億中活性10B)と、プロンプトが37トークンと短いこと、出力も90〜93トークンで活性エキスパートが入れ替わるほどではないことが揃うと、overcommit構成でもほぼpage cacheだけで回る。

理論上限との乖離

SwiftLMのソースコードを追うと、SSD Expert StreamingはGCDのdispatch_io + Metal MTL::SharedEventで非同期同期を実現している。dispatch_io_tでPOSIX preadを並列化する素直な実装だ。

M1/M5 Pro世代のNVMe SSD帯域は3.1〜3.5 GB/s。122B-A10Bを4bit量子化しても1トークンあたり約2GB読み出す必要があるとして、理論上限は1.69〜1.84 tok/s(SSD帯域律速)。READMEでM1 Ultra 64GBが5 tok/sを出しているのは、top-k削減などの別最適化経路で活性エキスパートをさらに絞ったケース。

ところが今回の実測はウォーム時 4.25 tok/s、累積平均 3.40 tok/s で、理論上限 1.69〜1.84 tok/s を2倍以上上回った。メトリクスが示す通り「SSDストリーミング」は名目上だけで、実態はmmap + page cache運用、ドメイン狭めのプロンプトでは全層SSD読込が発火しないため理論値を超えられる。

逆に言うと、長尺かつドメインを跨ぐプロンプトを投げると活性エキスパートが入れ替わってSSD I/Oが走り、スループットが1.69〜1.84 tok/sに近づいていくはず。そこは今回のBSTプロンプトでは検証外。

停止

pkill -f "SwiftLM"
sleep 3
pgrep -fl SwiftLM  # 何も出ない
vm_stat | head -10

停止後は free pages が 839,235 (~13 GB) に戻り、メモリは綺麗に解放された。

BST挿入関数で速度を測る

122Bを停止してからもう一度35B-A3Bに戻り、既存のQwen3.6-35B-A3B Ollama記事27B dense vs 35B-A3B比較記事と同じプロンプトで速度を並べる。

curl -s http://localhost:5413/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model":"unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit",
    "stream":false,
    "max_tokens":3000,
    "messages":[{"role":"user","content":"Pythonで、二分探索木に値を挿入する関数 insert(root, val) を書いて。短く。"}]
  }'

出力。

def insert(root, val):
    if not root: return TreeNode(val)
    if val< root.val: root.left = insert(root.left, val)
    else: root.right = insert(root.right, val)
    return root
  • prompt_tokens=37, completion_tokens=54, wall 3.59秒
  • prefill 40.6 tok/s(37トークン / 0.91秒)
  • generation 約20 tok/s(54トークン / 2.68秒)

val< とスペース欠落する書き方は元記事のOllama版と同じ表記揺れで実害なし。

3ランタイムの速度比較

同じ35B-A3Bの同じBSTプロンプトで、今回のSwiftLMと既存計測値を並べるとこうなる。

ランタイムgenerationprefill(ウォーム)備考
Ollama GGUF (Q4_K_M)27 tok/s既存計測
MLX-lm (UD-MLX-4bit)54 tok/s既存計測
SwiftLM (UD-MLX-4bit)約20 tok/s33〜67 tok/s今回計測

SwiftLMが同じMLX 4bitモデルを食っているのに、mlx-lm(Python)の半分以下、Ollama GGUF比でも7割程度に落ちる。前回の記事を書いたときは「mlx-swiftで直接叩いているから同程度に出るだろう」と想像していたが、実際はMoEのルーティング/エキスパート選択パスがmlx-lmほど最適化されていないらしい。

prefillとプロンプトキャッシュ側は優秀で、後述のペルソナ3ターンでTurn 3のprefillが 392 tok/s まで跳ねる。単発の長い生成よりもマルチターン対話のほうがSwiftLMの体感は良くなる。

簡易BBSで意図汲み取りを見る

「仕様書にないXSS対策を勝手に入れてくるか」「勝手に機能追加するか」を測るため、LLM-jp-4ベンチ記事Qwen3.6比較記事と同じ日本語箇条書きプロンプトを投げる。

簡易BBS、投稿だけ、localStorage、日本語UI、単一HTMLファイル

SwiftLM経由の結果。

  • prompt_tokens=29, completion_tokens=1935, wall 95.9秒
  • prefill 33.9 tok/s、generation 約20.4 tok/s

実装された内容を既存記事の挙動と並べる。

項目Ollama版 (既存記事)SwiftLM版 (今回)
投稿フォーム
localStorage永続化
XSSサニタイズ○(&amp; < > " ' をエスケープ)
削除ボタン○(confirm付き)
検索ボックス×
ソート切り替え×
投稿カウンター×
空状態プレースホルダ×
日時表示○(toLocaleString('ja-JP')
改行保持(white-space: pre-wrap)

SwiftLM版は「投稿だけ」の指示に対してOllama版より忠実。削除ボタンとXSS escapeだけは先回りで入れてくるが、検索・ソート・カウンタ等の「あったら嬉しい機能」を勝手には付けてこない。

サンプリング温度はSwiftLMのデフォルト0.6、Ollama側も同等なので、温度の違いでは説明しきれない。mlx-swiftとllama.cppでロジット処理の順序が微妙に違うあたりが効いているのかもしれない。

気になったのは 生成HTMLに3箇所ほどクォート抜けのシンタックスエラーが混入していたこと。

// 正しくは '<div class="empty-msg">...</div>' だが、開始の「'」が抜けている
postList.innerHTML = <div class="empty-msg">まだ投稿がありません</div>';

// escapeHtml の map 内でも同様。'<' の開始クォートが抜けている
<': '&lt;',

Ollama経由の同モデルでは出なかった癖で、SwiftLMのサンプラーで低確率トークンが通りやすくなっている可能性がある。このまま保存してブラウザで開いても動かない。試す場合は手で直す必要あり。

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

既存記事と完全に同じシステムプロンプトで3ターン投げる。

あなたは『かなちゃん』というキャラ。中性的でやや女性寄り、一人称は『わたし』。
口調はやわらかく、語尾に『〜だよ』『〜かな』を時々使う。おたくでゲームとAIが好き。

自己紹介(Turn 1)

やっほ〜!はじめまして、わたしはかなちゃんだよ!

ちょっと中性的で、でもちょっとだけ女性寄りな感じ。一人称は「わたし」で、話すときは柔らかく、語尾に「〜だよ」や「〜かな」を付けたりしてね。

趣味はゲームAIの話!AIの進化の速さとか、ゲームのストーリーやシステムについて語るとつい熱くなっちゃうおたく系かな。

もしよければ、一緒にゲームの話でもしようか?それとも、AIの最新事情とか、雑談でも何でもいいよ〜!

prompt 75 / completion 127、wall 7.27秒(prefill 67.9 tok/s、gen 約20.6 tok/s)。

好きなゲーム3つ(Turn 2)

  1. elden ring(エルデンリング): 広大なオープンワールドで…
  2. Stardew Valley(スターデューバレー): のんびり農業して…
  3. Portal 2(ポータル2): 物理法則を使ったパズルゲームの傑作…

prompt 219 / completion 241、wall 12.83秒(prefill 198.6 tok/s)。

気になる点が2つ。

  • Elden Ring が小文字 elden ring で出てくる(固有名詞の表記揺れ)
  • 「たまりません」「尊敬してる一枚です」のように、です・ます調が混入してかなちゃんの口調から一瞬ずれる

SwiftLMのサンプリングが低確率トークンを通しやすい仮説と整合する。既存記事でOllama版は崩れなかった箇所。

「2つ目のタイトルだけ教えて」(Turn 3)

Stardew Valley

prompt 477 / completion 4、wall 1.50秒。prefill 392.8 tok/s

ここはSwiftLMの見せ場で、Turn 1〜2がプロンプトキャッシュに入っているおかげでprefillが爆速。応答を「Stardew Valley」一語で返してくる指示忠実度の高さも光る。前置きの「はい、2つ目は〜」みたいな言い回しを付けないのは、Ollama版より整っている感触。

参照解決は完全に正しい(Turn 2の2番目 = Stardew Valley)。

OpenAI互換APIの互換性

/v1/chat/completions は基本OpenAI SDKでdrop-inで叩ける。ただしレスポンスJSONに生の改行文字(\nではなく本物の改行)が混入するケースがあり、jq で素直にパースできない応答が時々出た。標準のJSONパーサは通るので実害は軽微だが、厳密な互換性を謳うなら改行はエスケープしてほしい。

Prometheus形式の /metrics は運用観点で実用的で、swiftlm_tokens_per_secondswiftlm_memory_peak_bytesswiftlm_ssd_bytes_read_totalあたりをそのままダッシュボードに流せる。


まじかよ、結構普通に動くんだな、というのが素直な感想。
「M5 Pro 64GB前提」と書かれていたREADMEに対してM1 Max 64GBでも122B-A10B-4bitが--stream-expertsで立ち上がり、overcommit 1.3の看板通りスワップせず4.25 tok/sで返ってくる。しかも実際はSSD I/Oメトリクスが最後までゼロのまま完走していて、ssd_stream=true の旗は立つがmmap + page cacheだけで足りてしまう。この挙動の乖離が今回の一番の発見。ドメインを跨ぐ長尺プロンプトでどこからSSDが発火するかは別途試したい。

35B-A3Bでの3ランタイム比較は、SwiftLMがOllama GGUFの7割・MLX-lmの半分以下という結果。Python抜きで単一バイナリ + OpenAI互換 + Prometheus metrics、という運用面の良さはあるが、MoE推論の速度だけはmlx-lm(Python側)が明確に速い。この先の最適化次第では並ぶ可能性があるので、タグだけでも追いかけておくつもり。