コーディングLLMを M5 Pro 48GB で実測:「動く」と「使える」を分けるのは context の壁だった

by ZeroZawa

ローカルでコーディング用の大規模言語モデルを動かすとき、最初にぶつかる質問は「自分の Mac で動くのか」です。配布元のモデルカードは「48GB で動作」「256K トークンのコンテキストに対応」といった数字を掲げます。けれど、重みがメモリに載ることと、実際の開発で使えることは別の話です。

手元の MacBook Pro(Apple M5 Pro、ユニファイドメモリ 48GB)で、オープンウェイトのコーディングモデルを 7B から 70B まで並べて測りました。わかったのは、実用上の境界が「重みが載るかどうか」ではなく、二つの壁の位置で決まるということです。

一つはメモリの崖です。コンテキストを伸ばすと KV キャッシュがメモリを食い、ある長さを超えると GPU の割り当て上限を超えて失速、あるいは生成の瞬間にメモリ不足で落ちます。もう一つは prefill の壁です。大きなコードを貼り付けると、最初の一文字が返るまでの待ち時間がコンテキスト長とともに伸びます。

結論を先に書きます。7B はメモリには余裕があるのに、131K のコードを貼ると最初の一文字まで 4 分待ちました。32B は 65K あたりでメモリ上限に達して失速しました。70B は重みは載ったのに、生成の瞬間に GPU メモリが尽きて落ちました。三つとも「動く」とは言えますが、「使える」かは context 長しだいでした。

ローカルコーディングLLMの「メモリ崖」と「prefill 壁」を示す概要図

何を、どう測ったか

検証環境は Apple M5 Pro、ユニファイドメモリ 48GB の MacBook Pro です。推論には Apple Silicon 向けの mlx_lm を使い、モデルはすべて 4bit 量子化版で揃えました。量子化を 4bit に固定し、温度ゼロの貪欲法で、モデルだけを入れ替える対照実験にしています。速度は同じ条件で 3 回実行した中央値です。

長いコンテキストは合成のダミー文字列ではなく、実在するコードを連結して作りました。コーディングで問題になるのは、大きなファイルや複数ファイルの差分を丸ごと食わせる場面だからです。このコンテキストを 4K から 131K トークンまで段階的に伸ばし、各段で次を測りました。最初の一文字までの時間(first-token)、プロンプト処理速度、生成中のトークン毎秒、ピーク時の GPU メモリ1、そして実行前後のスワップ使用量の差です。スワップは sysctl vm.swapusagevm_stat のページアウト数で見ています。

対象は三つです。7B クラスの Qwen2.5-Coder、32B の Qwen3、そして 48GB にぎりぎり載る 70B クラスの Llama-3.3 をストレス役にしました。結果として、7B は全段を完走し、32B は 65K で頭打ちになり、70B はそもそも生成できませんでした。その差が、そのまま二つの壁の話になります。

一つ目の壁、メモリ崖——コンテキストを伸ばすとメモリが尽きる

Apple Silicon のユニファイドメモリは CPU と GPU で共有されますが、GPU が確保できる量には上限があります。今回の 48GB マシンでは、mlx_lm が「推奨される最大サイズは 38,338MB」と警告してきました2。つまり既定で GPU に回せるのはおよそ 38GB です。モデルの重みと、生成中に膨らむ KV キャッシュと、作業領域の合計がこの枠を超えると、メモリは圧縮され、やがてスワップに落ち、最後はメモリ不足で落ちます。

KV キャッシュはコンテキスト長に比例して増えます3。重みが 4bit で軽くても、長いコンテキストではこの KV が効いてきます。三つのモデルでピーク GPU メモリがどう動いたかを並べます。

context7B peak32B peak70B
4K4.88GB19.04GB起動できず(後述)
32K6.31GB25.93GB(pressure: warn)
65K8.12GB34.0GB(swap 開始)
131K11.79GB未到達

7B は 131K でもピークが 11.79GB で、スワップはゼロのままでした。小さいモデルにとって、メモリは問題になりません。

32B は様子が変わります。重みだけで 19GB を使い、32K でメモリ圧(macOS の memory pressure)が warn に変わり4、65K でピークが 34GB に達してスワップが始まりました(162.9MB、ページアウト 911 回)。38GB の枠が近づくにつれて余裕が消えていきます。

70B は最も極端でした。重みのロードには成功しますが、mlx_lm は「37,849MB を必要とし、推奨最大の 38,338MB に近い」と警告し、最初の生成を始めた瞬間に Metal がメモリ不足で異常終了しました(Insufficient Memory で abort)。重みだけで枠をほぼ使い切り、KV キャッシュを置く場所が残っていなかったためです。

ここで分かったのは、崖は「じわじわ遅くなる」ものではないということです。32B の 65K のように、少しスワップしながらも完走できる狭い帯はあります。けれどその先は、遅い生成ではなく、プロセスごとの異常終了になります。70B は 4K ですらその先にいました。48GB の既定設定では、70B クラスは事実上動かせません。

コンテキスト長とピーク GPU メモリ(M5 Pro 48GB / MLX 4bit)

既定 GPU 割り当て上限は約 38GB。70B は重みは載るが生成開始時に OOM で計測不可。

Qwen2.5-Coder-7BQwen3-32B
51015202530↑ ピーク GPU メモリ (GB)20406080100120コンテキスト長 (K tokens) →
32B は 64K で 34GB に達してスワップが始まる。7B は 128K でも 11.8GB に収まり崖に当たらない。

二つ目の壁、prefill の遅延——大きなコードを貼ると最初の一文字が遅い

生成が始まる前に、モデルはプロンプト全体を一度処理します(prefill)。この時間はコンテキスト長とともに伸びます。短いプロンプトなら一瞬ですが、数万トークンのコードを貼り付けると、最初のトークンが返るまで目に見えて待たされます。最初の一文字までの実測値(3 回の中央値)を並べます。

context7B TTFT32B TTFT
4K2.3 秒10.9 秒
16K11.0 秒53.7 秒
32K25.8 秒137.8 秒
65K74.6 秒374.1 秒(6.2 分)
131K263.8 秒(4.4 分)未到達

メモリに余裕がある 7B でも、131K のコードを貼ると最初の一文字まで 4 分以上かかりました。生成そのものは 21.7 トークン毎秒と実用的なのに、prefill が待ち時間を支配します。32B は 65K の時点ですでに 6 分です。「256K に対応」という公称値は、その長さでも待てる速度で動くことを意味しません。長いコンテキストを扱う作業では、メモリの前に、この待ち時間が先に効いてくる場面が多いはずです。

コンテキスト長と first-token 時間(prefill の壁)

最初の一文字が返るまでの秒数(同一プロンプト 3 回の中央値)。

Qwen2.5-Coder-7BQwen3-32B
50100150200250300350↑ 最初のトークンまで (秒)20406080100120コンテキスト長 (K tokens) →
7B は 128K で 263.8 秒(4.4 分)、32B は 64K で 374 秒(6.2 分)。生成自体は速くても prefill が待ち時間を支配する。

崖の手前で、コードは劣化するのか

速度とメモリの限界はわかりました。では崖に近づいたとき、出力されるコードは少しずつ壊れていくのでしょうか。今回測った範囲では、そうはなりませんでした。失敗のしかたは「だんだん下手になる」ではなく、「途中で落ちる」でした。70B は劣化した答えを返す前に異常終了し、32B も 65K を超えればおそらく同じ末路です。

実用上はこの違いが大きいところです。出力の質が少し落ちるだけなら使い道はありますが、生成が始まらずプロセスごと落ちるなら、その context 長はそのモデルにとって存在しないのと同じです。なお、メモリに余裕のある 7B について、131K と短いコンテキストでコード正答率が変わるかどうかは別の検証が要ります。本記事の範囲では、速度とメモリの壁を測ることに絞りました。

重みのサイズではなく、コンテキスト長で選ぶ

ここまでの実測から、48GB の Mac でコーディングモデルを選ぶときの目安が出ます。

短いスニペットの補完や、小さな単一ファイルの編集が中心なら、7B で十分です。速く、メモリも余り、待ち時間も短いままです。中規模のファイルやそこそこの context を渡すなら、32B も選べますが、prefill の待ち時間に気をつける必要があります。32K までは現実的で、65K では最初の一文字まで 6 分です。そして、大きなコンテキストや複数ファイルをまとめて渡したい場合、4bit の 48GB では今のところ快適な選択肢がありません。7B は prefill 壁(131K で 4.4 分)、32B はメモリ壁(65K で失速)、70B は起動すらしません。

選ぶ基準は、重みが載るかどうかではありません。自分が普段どれくらいの長さのコンテキストを渡すかです。その長さで、メモリの崖にも prefill の壁にも当たらないモデルが、自分にとって「使える」モデルです。

自分で再現する

計測は使い捨ての小さなハーネスで行いました。実コードを連結してコーパスを作り、コンテキスト長を段階的に伸ばしながら、mlx_lm の生成を回して時間とメモリを記録するだけのものです。

一つ注意点があります。HuggingFace からの未認証ダウンロードはレート制限がかかり、重みの取得が実質的に止まることがあります。本計測の前に無料アカウントの HF_TOKENhf auth login)を設定しておくと安定します。GPU の割り当て上限は sudo sysctl iogpu.wired_limit_mb=<MB> で引き上げられますが、本記事は既定値での挙動を測りました。引き上げれば 70B も短いコンテキストなら動く余地はありますが、KV の置き場所を増やすほど他が削られるため、根本的に楽になるわけではありません。

所要時間も正直に書きます。7B 一本を 4K から 131K まで全段測るだけで、実時間で 25〜30 分かかりました。131K 段の prefill が 264 秒、それを 3 回繰り返す部分が支配的です。ローカルは無料ですが、速くも手軽でもありません。

重みではなく、context 長で決まる

二つの壁を測った結果は単純でした。7B はメモリには困らないが、長いコンテキストでは prefill の待ち時間が壁になる。32B は中程度の context でメモリ上限に達して失速する。70B は重みは載るが生成の瞬間に落ちる。「48GB で動く」は重みが載るかどうかの話にすぎません。実際に使えるかは、自分が渡すコンテキスト長で、どちらの壁にも当たらずに済むかどうかで決まります。

参考文献

Footnotes

  1. MLX Unified Memory(ピークメモリ API・割り当ての考え方). https://ml-explore.github.io/mlx/build/html/usage/unified_memory.html

  2. mlx-lm(generate の verbose 出力・大規模モデルの注意). https://github.com/ml-explore/mlx-lm

  3. KV Cache Memory Calculation for LLMs(キャッシュ量の算式). https://lyceum.technology/magazine/kv-cache-memory-calculation-llm/

  4. memory_pressure(1) man page(メモリ圧の段階). https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man1/memory_pressure.1.html