fix(memory): run cleanly on 32 GB Macs + Mac mini M6 benchmarks - #173
Merged
Merged
Conversation
…-guarded bench harness The full-GPU branch printed the plan but never set Memory.cacheLimit, so MLX kept its default, the whole GPU working set (26.8 GB on a 32 GB M6). Prefill chunk buffers change shape as the KV offset grows, so they are rarely reused, and the cache grew until macOS swapped. With Qwen3.8-27B-4bit (11.3 GB of weights) the process footprint reached 24 GB and swap grew 2.3 GB by 8.5K tokens. The full-GPU plan now gives the cache half of the RAM left after weights, KV and an 8 GB OS reserve, with a 1 GB floor and the old default as a ceiling. Server.swift applies it. On the M6 at 8.5K / 17K / 35.6K prompt tokens: peak footprint 19 / 19 / 21 GB, swap growth 0 / 0 / 0.5 GB, prefill 106.7 / 101.6 / 90.0 tok/s. scripts/profiling/m6_bench.py: one server per config, warm-up plus N runs with medians, a per-run nonce so the prompt cache can't hit, a planted code-word check, and a memory guard (swap growth / free-memory floor) that kills the server and records MEM_ABORT instead of letting a small-RAM machine thrash. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ModelProfile.kvCacheMemoryGB counted every layer as full attention. Gemma 4 has 25 sliding-window layers (capped at 1024 tokens) and 5 full-attention layers with their own KV head count and head dim. Qwen3.5/3.8 has 48 GatedDeltaNet layers, which keep a fixed-size state and no KV cache. At a 64K context the estimate was 16.1 GB for Gemma-4-26B (≈1.6 GB real) and 17.2 GB for Qwen3.8-27B (≈4.4 GB real). On a 32 GB M6, the Gemma overestimate flipped the plan to SWAP-ASSISTED with 7 of 30 layers on the CPU, and that partitioned path crashed with a Metal GPU timeout on the first request (kIOGPUCommandBufferCallbackErrorTimeout, even for a 521-token prompt; the partitioned-path hang itself is a separate issue). With layer_types, sliding_window, num_global_key_value_heads and global_head_dim read from config.json, both models plan FULL GPU at 64K and run cleanly. Configs without layer_types keep the old all-full-attention estimate. m6_bench.py: when a case aborts during its warm-up, report the warm-up row instead of crashing the summary writer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…load Vision mode is auto-enabled for any checkpoint whose config looks like a VLM. unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit ships a preprocessor_config.json without image_mean, so the VLM loader threw a DecodingError and the server exited, although the model serves text fine. When vision was only auto-detected (no --vision flag), catch the VLM load error, log it, and load the model through LLMModelFactory with isVision = false. An explicit --vision still fails hard. Verified on a 32 GB M6: Qwen3.6-35B-A3B loads as text-only, and an 8.5K-token prompt prefills at 535 tok/s with a 22 GB peak footprint and no swap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- README: new "Mac mini M6 (base, 32 GB)" section covering four models (Gemma-4-26B-A4B 4-bit and 8-bit, Qwen3.6-35B-A3B, Qwen3.8-27B), the fixes 32 GB exposed, known issues, and reproduce commands. - docs/profiling/m6/: per-model tables (.md) and raw per-run results (.jsonl) from scripts/profiling/m6_bench.py, plus asciinema recordings (.cast) and GIFs under media/. Server logs are not included; they hold the full test prompts. - scripts/demo/stream_client.py: the streaming client used in the recordings. It prints live prefill progress with the server's memory and swap, then a stats line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review on #173: `catch where !self.vision` caught every error, so a download failure or a CancellationError triggered a second, LLM load attempt and then surfaced a confusing error. The fallback now requires isVLMCheckpointMismatch(error): a DecodingError, an MLXNN UpdateError (unhandledKeys, keyNotFound, mismatchedSize, ...), or a ModelFactoryError for an unsupported model/processor type or an undecodable/invalid config. Every other error propagates unchanged. VLMFallbackTests covers the fallback cases (missing image_mean, unhandled weight keys, unsupported model type) and the pass-through cases (cancellation, URLError, missing config file). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
solderzzc
force-pushed
the
fix/fullgpu-cache-limit
branch
from
September 24, 2026 15:44
79c7a20 to
398252e
Compare
…1) + this PR main now ships chunked VLM prefill (#167), so the earlier Gemma-4-26B-A4B and Qwen3.8-27B runs, which relied on a local backport, are replaced with runs on main 318f712 + this PR. Nothing unshipped is involved any more. - Gemma-4-26B-A4B 4-bit: 52.2 tok/s decode at ~530 tokens; an 80.7K-token prompt at 622 tok/s prefill / 24.3 decode, 19.5 GB peak, 0 swap. --turbo-kv now passes all 13 runs up to 80.9K tokens (the Metal assertion and needle misses are gone). --mtp with the bf16 assistant works but is slower on the M6 (45.2 / 35.6 / 30.4 vs 53.0 / 50.8 / 46.0 tok/s). - Qwen3.8-27B 4-bit: prefill ~235 tok/s at every length (~2.3× the backport), 9.3 tok/s decode, 18.4 GB peak at 40.8K tokens. - Known issues now cover --gpu-layers partitioning (Metal GPU timeout) and QAT MTP assistants (unhandledKeys); the fixed items are removed. - m6_bench.py records the code word and the start of each answer, so a needle miss shows what the model actually said. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
solderzzc
changed the base branch from
claude/mac-mini-m6-benchmarking-b1cae5
to
main
September 24, 2026 16:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #172 (the Xcode 27 build fix). Merge #172 first; this PR then retargets to
main. Rebased onto #172 @8c14389, which already hasmain@318f712merged in (#167: mlx-swift-lm →460ff81).Why
Every published SwiftLM number so far came from a 64 GB Mac. On a base Mac mini M6 with 32 GB (Mac18,5, 12-core GPU, 170 GB/s, macOS 27.0), the same workloads swapped, crashed, or wouldn't load. Each cause below was invisible on 64 GB.
Fixes
ModelProfiler.swift,Server.swift). The.fullGPUbranch printed the plan but never setMemory.cacheLimit, so MLX kept its default: the whole Metal working set (26.8 GB here). Prefill chunk buffers change shape as the KV offset grows, so they're rarely reused, and the cache grows until macOS swaps. The cache now gets half of the RAM left after weights, KV and an 8 GB OS reserve (floor 1 GB, capped at the old default). 64 GB machines see little change.ModelProfiler.swift). It used to count every layer as full attention. It now readslayer_types,sliding_window,num_global_key_value_headsandglobal_head_dim. At 64K context: Gemma-4-26B 16.1 → 1.6 GB, Qwen3.8-27B 17.2 → 4.4 GB. On 32 GB the Gemma overestimate flipped the plan to SWAP-ASSISTED with 7/30 layers on the CPU, and that path crashed withkIOGPUCommandBufferCallbackErrorTimeout, even on a 521-token prompt. Configs withoutlayer_typeskeep the old estimate.Server.swift).unsloth/Qwen3.6-35B-A3B-UD-MLX-4bitships apreprocessor_config.jsonwithoutimage_mean. Vision was auto-enabled, the loader threw, and the server exited. It now logs the error and loads throughLLMModelFactorywithisVision = false. An explicit--visionstill fails hard. Review follow-up: the fallback fires only whenisVLMCheckpointMismatch(error)is true (DecodingError, MLXNN UpdateError, or ModelFactoryError for an unsupported type or a bad config). Cancellation, network and file-I/O errors propagate unchanged.VLMFallbackTestshas 6 tests.Tooling, docs, results
scripts/profiling/m6_bench.py: one server per config, warm-up plus medians of N runs, a per-run nonce (so the prompt cache can't hit), a hidden code-word check, and a memory guard that kills the server and recordsMEM_ABORTwhen swap grows more than 2 GB or free memory drops below 10%.scripts/demo/stream_client.py: the streaming client used for the terminal recordings. It prints live prefill progress with server memory and swap.README.md: new "Mac mini M6 (base, 32 GB)" section (a "what runs well on 32 GB" table, per-model tables, what 32 GB exposed, known issues, reproduce commands) with a GIF.docs/profiling/m6/: per-model tables, raw per-run JSONL, asciinema casts and GIFs. Server logs are left out because they contain the full test prompts.Results (M6 32 GB,
main318f712 + this PR, medians, every needle check passed)--stream-experts--stream-expertsA/B on the same M6: #172 alone vs this PR (Gemma-4-26B-A4B 4-bit)
Chunked VLM prefill: now shipped in main
Earlier versions of this PR disclosed a dependency on a local mlx-swift-lm backport. That's gone. With #167 (mlx-swift-lm
460ff81), chunked text-only prefill on the VLM path ships inmain, and every number in the README section was re-measured onmain+ this PR without any local patch. Upstream's windowed prefill is faster than the backport, too: Qwen3.8-27B prefills at about 235 tok/s at every length (about 2.3× faster).Rechecked on
main+ this PR--turbo-kv+PromptCache.saveMetal assertion: gone. Gemma 4 TurboKV passes 13/13 runs up to 80.9K tokens.n_past/fractionstay 0 (no progress hook in the windowed prefill).--gpu-layers Npartitioning: Metal GPU timeout on the first request. Repro:--model mlx-community/gemma-4-26b-a4b-it-4bit --gpu-layers 23, with FULL GPU memory otherwise.unhandledKeys pre_projection/post_projection. The MLXLLMGemma4AssistantModel.sanitizeonly takes unquantized projection weights, while the MLXVLM drafter handles quantized ones.gemma-4-26B-A4B-it-assistant-bf16works, but MTP is slower than vanilla on the M6 (45.2 / 35.6 / 30.4 vs 53.0 / 50.8 / 46.0 tok/s).--turbo-kvgets the numbered-lines count wrong at ~11K tokens, while the needle test passes. A length sweep is running.Checks
any X?in the Swift diff (Xcode 26.3 CI rejects it).🤖 Generated with Claude Code