本記事は temi × ローカル LLM シリーズの2本目です。
ロボット temi に載せるローカル LLM 環境を、M3・16GB の Apple Silicon Mac で構築したときの記録です。
temi に話しかけてから返事が返るまで十数秒かかっていました。最初は「Docker 上の Ollama が CPU 実行になっているから遅い」と考え、Ollama を macOS ネイティブに移しましたが速くなりませんでした。調べると、Ollama が GPU を使っていませんでした。num_gpu を明示すると生成速度は約1.35倍になりましたが、実運用の応答時間はそれでも十数秒のままでした。次に MLX にすれば速くなるはずと考えましたが、実測すると GGUF との差は数%でした。
生成速度を下げていた直接の原因は、GPU を使っていなかったことでした。一方、実際の応答時間を長くしていた最大の原因は、manual.txt を含む長いプロンプトを毎回送っていたことでした。 前者はこの記事で扱い、後者は次回の記事で扱います。
結論を先に書きます。
| 問題 | 実測で分かったこと |
|---|---|
| Docker かどうか | CPU 実行同士では大差なし |
| GPU 未使用 | 約1.3〜1.45倍の差が出た |
| 長いプロンプト | 実運用の応答時間に大きく影響した(詳細は次回) |
| MLX か GGUF か | 当環境では数%差で、ほぼ同等 |
第1部 Docker から macOS ネイティブへ
最初は Ollama も backend も全部 Docker Compose で立てていました。その後 Ollama だけ macOS 本体ネイティブに移す構成に変更しています。
移行の理由は、「Docker 上の Ollama は macOS では CPU 実行になり、それが遅さの原因だ」と当時仮定したためです。しかし後の実測で、この仮定は正しくないことが分かりました。速度に大きく影響していたのは、Docker の有無ではなく GPU の使用状態でした。後述します。
1. 最初の構成:全部 Docker Compose
docker compose up --build だけで全部立ち上がる構成にしていました。サービスは 3 つです。
| サービス名 | 役割 |
|---|---|
ollama-service |
Ollama 本体をコンテナで起動する。LLM を実行するサービス |
ollama-model-init |
初回起動時にモデルを pull する補助コンテナ |
python-backend |
FastAPI バックエンドを起動する。temi と Ollama の間に入るオペレーター役 |
ポートは FastAPI が 8000、Ollama が 11434。モデルは Docker named volume ollama_data に保存され、docker compose down(-v を付けない場合)では消えません。ollama-model-init は起動時に ollama show で存在確認してから pull する使い捨てコンテナで、2 回目以降は何もしません。
2. 応答が遅く、原因を Docker にあると考えた
実際に temi に質問すると、応答まで十数秒待たされました。当時のランブックには、遅い理由として次の4点を挙げていました。
- Docker 上の Ollama が macOS で CPU 実行になっている
-
qwen2.5:7bが約 4.7 GB と比較的大きい - 起動直後の 1 問目はモデルロード時間が乗る
-
manual.txtの内容を毎回プロンプトに含めるため、入力トークン量が増える
実際には、4番目の影響が最も大きいものでした。 モデルを小さくする(4B は 7B の 1.21 倍速くなる程度)は十数秒の待ち時間を説明できる大きさではなく、「Ollama を macOS ネイティブへ移行する」に至っては、後述のとおり理由付けそのものが間違っていました。当時はそれが分かっていなかったので、まずここから手をつけました。
理屈としては筋が通っていました。macOS の Docker は Linux VM 上で動くので、Metal GPU に触れません。
ただしこれは「Docker だと CPU 実行になる」が正しいという話で、「CPU 実行だから大幅に遅くなる」とは別の話でした。 後者は誤りだったと、後で実測して初めて分かります。
3. Ollama だけを Mac 本体で動かす
brew install ollama && brew services start ollama で localhost:11434 を待ち受けさせ、backend は Docker のまま残しました(backend はコード変更のたびに動かすため、依存関係がコンテナに閉じている方が扱いやすかったためです)。docker-compose.yml からは ollama-service と ollama-model-init を削除し、サービスは python-backend 1 つだけになりました。
4. コンテナから本体の Ollama に接続する
Docker Desktop なら host.docker.internal でコンテナから本体に到達できますが、Rancher Desktop では届きませんでした。
host.docker.internal が docker ブリッジのゲートウェイアドレスに解決されてしまうためです。Rancher Desktop は Lima VM の上で動いているため、コンテナから見た「ホスト」が Docker Desktop とは違います。
今回の Rancher Desktop 環境では、Lima VM から見た Mac 本体のアドレスは、プライベート IP アドレス帯に属する固有の値でした。このアドレスは環境によって変わるため、本記事では具体的な値を伏せています。自分の環境で確認して読み替えてください。
さらに、brew services start ollama の既定は自分自身にのみ応答するループバックの待受なので、コンテナからのアクセスを受け付けません。
launchctl setenv OLLAMA_HOST=<全インターフェース待受アドレス>:11434
brew services restart ollama
lsof -iTCP:11434 # *:11434 になっていれば OK
全インターフェースで待ち受けると、Mac 自身だけでなく同一 LAN 上の他端末からも Ollama に接続できる状態になります。検証環境のネットワーク構成やファイアウォール設定を踏まえた上で設定してください。
まとめると、Rancher Desktop でコンテナ → 本体 Ollama を繋ぐには、コンテナ側の向き先を Lima VM から見た Mac 本体のアドレスにすることと、本体 Ollama を全インターフェースで待ち受けさせることの2箇所が必要でした。
環境変数はこの2つが要点です(完成版の差分のみ抜粋。<mac-host> は自分の環境の値に読み替えてください)。
environment:
OLLAMA_HOST: ${OLLAMA_HOST:-http://<mac-host>:11434}
LLM_PROVIDER: ${LLM_PROVIDER:-ollama} # MLX 検証時のみ openai + OPENAI_BASE_URL を上書き
ollama_data volume が不要になったぶん、ディスクの管理も楽になりました。モデルは ~/.ollama に置かれます。
5. ネイティブ移行による速度変化を測定する
ネイティブに移しただけでは体感が変わりませんでした。調べると、Ollama が GPU を使っていませんでした。Metal は検出されているのに、モデルの層が 1 つも GPU に載っていなかったのです。原因と対処は第2部で扱います。
さらに、ollama/ollama コンテナに本体のモデルディレクトリをマウントして、Docker CPU 実行の速度を測り直しました(Apple M3 / 16GB、qwen2.5:7b、warm、num_predict=200)。
| 実行 | 生成速度 | プレフィル |
|---|---|---|
native GPU(Metal, num_gpu=99) |
19.3 tok/s | 137 tok/s |
| Docker コンテナ CPU | 13.3 tok/s | 102.6 tok/s |
native CPU(num_gpu=0) |
13.7 tok/s | — |
今回の環境・設定では、Docker CPU 13.3 tok/s ≒ native CPU 13.7 tok/s でした。速度を分けていたのは「Docker か否か」ではなく「GPU か CPU か」で、その差は約 1.45 倍です。「数倍」ではありません。 コンテナは同じ物理 CPU コアをそのまま使うため、CPU の計算に仮想化のオーバーヘッドは乗らず、Linux VM 越しに触れないのは Metal GPU だけだったためです。
当初の期待は「Docker をやめれば速くなる」でしたが、実測のとおり Docker CPU と native CPU の速度はほぼ同じで、外れていました。ネイティブ化の本当の価値は、Metal GPU が使えるようになったことです。引き換えに、本体で Ollama が起動していることが前提になりました。
第2部 GPU が使われていない問題と、MLX は速いのか
ここからは、その num_gpu の話と、「じゃあ MLX にすれば速いのでは」という次の仮説を実測で検証していきます。
計測環境
すべて同一マシン(MacBook Pro / Apple M3、ユニファイドメモリ 16GB、macOS Darwin 25.5.0)での実測です。Ollama 0.30.11(Homebrew ネイティブ)、mlx-lm 0.31.3、MLX サーバは OpenAI 互換の mlx_lm.server を全インターフェース待受のポート 8080 で起動しています。チャットモデルは qwen2.5:7b(GGUF Q4_K_M)と mlx-community/Qwen2.5-7B-Instruct-4bit(MLX 4bit)、追加検証に qwen3.5:4b の GGUF/MLX 版、埋め込みは常に nomic-embed-text です。
6. Ollama が GPU を使っていなかった
macOS 本体ネイティブに Ollama を入れて動かしたのに、体感が変わりませんでした。ログを見ると、Metal デバイス自体は検出されているのに GPU へのオフロードが 0 層で、ollama ps の PROCESSOR も 100% CPU でした。
16GB の統合メモリでは、Ollama の自動 VRAM 見積りが保守的で、既定(auto、num_gpu = -1)だと 0 層に落ちます。Apple Silicon は CPU と GPU が同じ物理メモリを共有しますが、Ollama は「GPU 側に割ける量」を内部で見積もっており、16GB では 7B モデルを載せる余裕がないと判断していたようです。
対処は options.num_gpu を明示するだけです。
| 実行 | 生成速度 |
ollama ps の表示 |
オフロード |
|---|---|---|---|
CPU 強制 (num_gpu:0) |
約 13.7 tok/s | 100% CPU | 0/29 層 |
GPU/Metal 強制 (num_gpu:99) |
約 18.4 tok/s | 100% GPU | 29/29 層 |
デフォルト (num_gpu:-1=auto) |
約 13.6 tok/s | 100% CPU | 0/29 層 |
99 は「載るだけ全層 GPU に載せる」という慣用値です。backend 側では ollama_num_gpu: int = 99 を明示し、非ストリーム・ストリームどちらの生成リクエストでも options に注入しています。ネイティブ化だけでは GPU が利用されているとは限らないため、ollama ps などで実行状態を確認する必要があります。
差は「約1.3〜1.45倍」で、数倍ではありません。Apple Silicon は CPU 実行でもそこそこ速いためです(理由は後述)。GPU 化は確かに速くなりますが、「桁が変わる」ような話ではありませんでした。
7. MLX による速度向上を検証する
GPU を有効にしても、実運用のプロンプトでは応答に十数秒かかりました。GPU の有効化だけでは待ち時間を十分に短縮できなかったため、次に Apple Silicon 向けの MLX を使えば、さらに改善できるのではないかと考えました。
Ollama の MLX 対応について、公式ブログが「ユニファイドメモリ 32GB 超」に言及しています。これを必須要件と誤読し、「16GB の M3 では Ollama の MLX は使えない」と判断しました。この誤読の結果、Ollama を MLX に切り替えるのではなく、OpenAI 互換サーバの mlx_lm.server を本体に別建てして、backend のチャット呼び出しを LLM_PROVIDER=openai でそちらに向けました。
なお 今回使用したmlx_lm.server には /v1/embeddings がなく、Ollama の MLX runner も "embeddings not supported" を返すため、埋め込みは常に Ollama の nomic-embed-text を使い続ける構成にしています。
実測(7B, warm)はこうなりました。
| 指標 | GGUF (Ollama/Metal) | MLX (mlx_lm.server, 4bit) |
|---|---|---|
| 生成速度 (warm, max_tokens=200) | 約 18.4 tok/s | 約 18 tok/s(16.6 / 18.1 / 19.2) |
| backend 1ターン warm | 約 5 秒 | 約 4.3〜4.8 秒 |
| backend 1ターン cold | 約 20 秒 | 約 31.7 秒(MLX の方が遅い) |
| 喋り出し TTFT(stream, warm) | 約 0.95 秒 | 約 0.80 秒 |
生成スループットはほぼ同等でした。 warm の体感は MLX が僅かに良く、cold 初回は MLX の方が遅い、という結果です。
8. 16GB 環境で Ollama 内蔵 MLX を検証する
ollama バイナリの文字列を調べたところ、OLLAMA_USE_MLX のような環境変数は存在せず、--mlx-engine フラグと starting mlx runner 等のログ文言のみが見つかりました。つまり MLX の選択はモデル形式依存で自動で、MLX 形式のモデルを pull して実行すれば Ollama が勝手に MLX runner を起動します。
4.0GB の MLX 形式モデル qwen3.5:4b-mlx を ollama pull して実行したところ、16GB でも ollama ps が 100% GPU を示し、MLX runner が動作しました。
16GB でも動きました。「32GB 超」は必須要件ではなく、必須だと思い込んでいたのは当時の誤読でした。
速度は生成 24.3 tok/s(warm)、プレフィル 194.9 tok/s、初回ロード(cold)約 47.8 秒でした。ただし、空きメモリが乏しい状態(free=2.6 GiB)で試したときはスワップが発生し、180 秒の curl タイムアウトに到達しています。不要プロセスを止めて free+inactive 約8GB を確保したら安定しました。16GB で MLX は動きますが、空きメモリの確保が前提です。
9. エンジンとモデルサイズを変えて比較する
ここまでの計測を一枚にまとめます。すべて warm、num_predict=200、エンジンに直接リクエストした値です。
| モデル | 形式 / 実行 | 生成速度 | プレフィル | 初回ロード(cold) |
|---|---|---|---|---|
| qwen2.5:7b | GGUF / native GPU(Metal, num_gpu=99) |
19.3 tok/s | 137 tok/s | 5.2 s |
| qwen2.5:7b | GGUF / Docker コンテナ CPU | 13.3 tok/s | 102.6 tok/s | 59.7 s |
| qwen2.5:7b | GGUF / native CPU(num_gpu=0) |
13.7 tok/s | — | — |
| Qwen2.5-7B-Instruct-4bit | MLX / mlx_lm.server
|
約18 tok/s | — | — |
| qwen3.5:4b | GGUF / native GPU | 23.4 tok/s | 76.2 tok/s | 8.0 s |
| qwen3.5:4b-mlx | MLX / Ollama 内蔵 | 24.3 tok/s | 194.9 tok/s | 47.8 s |
同一モデル・同一サイズで比べると、4B は MLX が +3.8%、7B は GGUF が +7% と、速い方が一定しませんでした。差は数%にとどまり、今回の測定条件では実用上ほぼ同等でした。 「MLX に変えたから速くなった」は実測で否定されました。
10. GGUF と MLX は比較する軸が違う
「GGUF と MLX、どちらが速いか」という比較だけでは、比較対象の層が異なるため、正確な評価になりません。 最も混乱したのはこの点でした。
| 項目 | GGUF | MLX |
|---|---|---|
| 何か | llama.cpp 系で使うモデルファイル形式。量子化済みの重みを1ファイルに格納 | Apple が Apple Silicon 向けに作ったML フレームワーク=実行エンジン側 |
| レイヤー | データ形式 | 実行ランタイム |
| 実行エンジン | llama.cpp (ggml) が読み込み、Metal GPU か CPU で実行 | MLX が統一メモリ前提でネイティブ Metal 実行 |
| どこでも動くか | 汎用、Win/Linux/Mac・CPU/各種 GPU | Apple Silicon 向け |
正確な対比軸は 「GGUF → llama.cpp 経路」 vs 「MLX 形式 → MLX 経路」 です。両方とも最終的には Metal GPU +統一メモリで動くため、経路差は数%に収まりました。Ollama はモデルの形式を見てランナーを切り替えます。.gguf なら llama.cpp、MLX 形式なら内蔵 mlx runner です。
この違いの背景には、Apple Silicon の統一メモリアーキテクチャと、Ollama の Apple Silicon 向け推論バックエンドの変遷があります。以下は Apple / Ollama の公式資料に基づく一般的な設計思想の整理であり、当環境での実測ではありません(実測は本記事の各数値のとおりです)。
従来の discrete GPU は CPU メモリと GPU メモリ(VRAM)が物理的に分かれており、PCIe 越しのデータ転送がボトルネックになっていました。Apple Silicon は CPU/GPU/ANE が同一の物理メモリを共有するため、MLX は CPU/GPU 間のゼロコピー tensor 操作ができるとされています。
従来 (discrete GPU) Apple Silicon (Unified Memory)
┌─────────┐ PCIe転送 ┌─────────┐ ┌──────────────────────────────┐
│ CPU │ ◄─────────► │ GPU │ │ CPU GPU ANE │
│ ┌─────┐ │ (ボトル │ ┌─────┐ │ │ └───────┼───────┘ │
│ │CPU │ │ ネック) │ │VRAM │ │ │ ▼ 共有 │
│ │ RAM │ │ │ └─────┘ │ │ ┌──────────────────────┐ │
│ └─────┘ │ └─────────┘ │ │ Unified Memory │ │
└─────────┘ │ │ (1つの物理メモリ) │ │
*データを跨ぐたびにコピーが必要 │ └──────────────────────┘ │
│ *コピー不要 = ゼロコピー │
└──────────────────────────────┘
また、Ollama on Mac は 0.19 で Apple Silicon 向けの推論バックエンドが変わりました。従来は llama.cpp が Metal API 経由で GPU を利用していたのに対し、0.19 以降は Apple 製の MLX がその役割を担うようになっています。Metal API はもともとグラフィックス描画・GPGPU 向けに設計されたもので、Metal 4(2025年発表)で TensorOps や Neural Accelerators など ML 向け機能が追加されましたが、活用には開発者側の個別対応が必要です。MLX はこれらの機能をフレームワークレベルで抽象化し、統一メモリの特性をより簡潔に活かせるようにしているとされています。
llama.cpp 経路 MLX 経路
┌──────────────────────┐ ┌────────────────────────────┐
│ llama.cpp (ggml) │ │ MLX framework │
│ GGUF を実行 │ │ safetensors/MLX 形式を実行 │
└──────────┬───────────┘ └─────────────┬──────────────┘
│ Metal API │ Metal 4
│ (元はグラフィクス/GPGPU向け、 │ (TensorOps /
│ ML機能は開発者が個別対応) │ Neural Accelerators を
▼ ▼ framework が抽象化)
┌───────────────────────────────────────────────────────────────┐
│ Apple Silicon GPU (Metal) + 統一メモリ │
└───────────────────────────────────────────────────────────────┘
この設計思想がどれだけ効くかはモデルサイズ・世代・メモリ量に依存するため、当環境(M3/16GB)での実際の結果は本記事の実測値を優先してください。
なお、今回計測した「MLX」には mlx_lm.server(別建て、32GB 必須の誤認で採用したが結果的に不要だった)と、Ollama 内蔵 MLX(16GB で動作を実証した主な構成)の2経路があり、土台が違います。同一モデルでの厳密な比較は 4B のみ実施済みで、GGUF 23.4 vs MLX 24.3 と「同一スタックでも MLX ≒ GGUF」までは言えます。
11. 背景と限界
CPU 実行でも 13 tok/s 台が出るのは、llama.cpp の CPU_REPACK(ARM SIMD 向けの重み再配置)が働くためです。MLX が統一メモリを前提にゼロコピーで動作する設計上の優位性はありますが、llama.cpp も同じ Metal API・統一メモリの恩恵を受けているため、今回のような4〜7Bモデルでは GPU 本体の演算能力がボトルネックになり、その差は誤差の範囲に埋もれます。
Ollama 公式ブログでは M5 系・35B クラスの大型モデル・32GB 超という条件で MLX が prefill 1.6倍/decode 2倍という数値を示していますが、これは M3・16GB・4〜7B という今回の条件とは前提が異なります。矛盾ではなく、条件が違うだけです。
12. 結論 ― 適用条件つき
- 今回検証したmlx-lm は Apple Silicon 専用です。Mac 以外の環境では使えません
- 当環境の実測では、速度を分けていたのはエンジンではなく、① GPU を使うこと(約1.4倍) ② モデルサイズ、4B/7B で約1.2倍 ③ プロンプトキャッシュでした
- 当環境では「GGUF を GPU で動かし、モデルを適切に選べば MLX を採用しなくても要件を満たせた」という結果です。 ただし一般化はしません
- チャットは GGUF を GPU 実行する Ollama で足りました。
mlx_lm.serverの別建ては、当環境では構成が増えただけでした
13. 未検証事項
- 同一 7B を同一スタックでの MLX vs GGUF 比較。4B は A/B 済み
- 3B モデルの速度、未計測
- 32GB / M5 実機での MLX の優位
- メモリ不足のしきい値。どこからスワップするか
- コールドのばらつき。MLX cold 47.8s / Docker CPU cold 59.7s はいずれも単発値
また、qwen3.5:4b-mlx など MLX 形式の thinking 系モデルは、JSON 出力を要求するプロンプトに対して空応答になる事象を確認しています。本番は非 thinking の qwen2.5 系を使って回避しました。MLX 形式のモデルを本番採用するなら、thinking 系かどうかと、JSON 出力を要求したときの挙動を先に確認する必要があります。
14. 出典・参考
- Apple MLX: https://github.com/ml-explore/mlx
- Ollama MLX 発表: https://ollama.com/blog/mlx
- Ollama で Llama-3 を動かしてみる: https://bkbkjo.work/impression/2024/04/29/ollama%E3%81%A7llama-3%E3%82%92%E5%8B%95%E3%81%8B%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B/
- schroneko さんの記事: https://note.com/schroneko/n/n8b1a5bbc740b
Docker で Ollama を動かす構成の記事は、Linux + NVIDIA 前提のものが多いです。NVIDIA なら gpus: all でコンテナから GPU を借りられますが、Mac の Docker からは Metal にアクセスできません。同じ手順でも Mac では結論が逆になります。第1部で扱ったとおりです。
おわりに
ここまでで「GPU では動かせた」「エンジンは変えても意味がなかった」まで来ました。それでも実運用のプロンプトでは十数秒かかっていました。
実運用の待ち時間を最大化していたのは、モデルやエンジンそのものではなく、プロンプトの順序によるプレフィルの再計算でした。ただし、GPU を使っていなかったことも、生成速度を下げる別の原因でした。 詳細は次回の記事で扱います。
次回予告
さて、次回の記事は?
「manual が原因だった」
「並べ替えたら5倍」
「A/B のやり方、間違ってました」の3本です。
次回もまた、読んでくださいね。
じゃんけん…ポン!
✌️
チョキでした。