Skip to content

Commit df45535

Browse files
authored
Merge pull request #173 from SharpAI/fix/fullgpu-cache-limit
fix(memory): run cleanly on 32 GB Macs + Mac mini M6 benchmarks
2 parents d7443da + a17b094 commit df45535

24 files changed

Lines changed: 1242 additions & 13 deletions

‎README.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,90 @@ Then start the server (models download automatically if not cached):
5151

5252
*(Add `--stream-experts` when running oversized MoE models to bypass macOS virtual memory swapping and stream expert layers directly from NVMe SSD.)*
5353

54+
## 📊 Performance: Mac mini M6 (base, 32 GB)
55+
56+
The first SwiftLM numbers from a **32 GB** Mac. Every other table in this README comes from 64 GB hardware. Running the same context lengths on this M6 exposed memory bugs that 64 GB machines had been hiding, and they are fixed in this release (see [What 32 GB exposed](#what-32-gb-exposed)).
57+
58+
![Gemma-4-26B-A4B streaming at 55 tok/s on a base Mac mini M6 32 GB](docs/profiling/m6/media/m6_gemma4_26b_a4b_stream.gif)
59+
60+
> *Hardware:* Mac mini (Mac18,5), Apple M6, 12-core GPU, 32 GB unified memory (170 GB/s), macOS 27.0. Metal working set 26.8 GB.
61+
> *Method:* [`scripts/profiling/m6_bench.py`](scripts/profiling/m6_bench.py). One warm-up, then the median of 3 runs (1 run at 32K and above), temperature 0. Every prompt starts with a unique nonce, so the prompt cache can't hit, and hides a code word that the answer must return. A memory guard aborts any case whose swap grows by more than 2 GB. Raw results: [`docs/profiling/m6/`](docs/profiling/m6/).
62+
63+
### What runs well on a 32 GB M6
64+
65+
| Model (4-bit unless noted) | Weights | Mode | Decode, short prompt | Longest prompt that passed | Peak GPU |
66+
|---|---|---|---|---|---|
67+
| **`gemma-4-26b-a4b-it-4bit`** (MoE, ~4B active) | 15.3 GB | GPU | **52.2 tok/s** | 80.7K tokens | 19.5 GB |
68+
| **`Qwen3.6-35B-A3B-UD-MLX-4bit`** (MoE, ~3B active) | 21.6 GB | GPU | **46.7 tok/s** | 40.8K tokens | 22.4 GB |
69+
| `Qwen3.6-35B-A3B-UD-MLX-4bit` | 21.6 GB | `--stream-experts` | 13.2 tok/s | 40.8K tokens | 7.7 GB |
70+
| `Qwen3.8-27B-4bit` (dense) | 11.3 GB | GPU | 9.3 tok/s | 40.8K tokens | 18.4 GB |
71+
| `gemma-4-26b-a4b-it-8bit` | ~26 GB | GPU | swaps (+3.1 GB on the first prompt) | — | — |
72+
| `gemma-4-26b-a4b-it-8bit` | ~26 GB | `--stream-experts` | 8.8 tok/s | 9.5K tokens (32K swapped) | 7.6 GB |
73+
74+
- **MoE models are the sweet spot at 32 GB.** Only the active experts are read for each token, so they decode 5–6× faster than a dense 27B. A 4-bit MoE with up to about 22 GB of weights runs entirely on the GPU.
75+
- **Qwen3.6-35B-A3B on a base M6 reaches 76%** of the M1 Ultra 64 GB decode speed below (46.7 vs 61.7 tok/s).
76+
- **Dense 27B decode is bandwidth-bound.** 9.3 tok/s × 11.3 GB is about 105 GB/s, roughly 60% of the M6's rated 170 GB/s.
77+
- **An 8-bit 26 GB model needs SSD streaming** and tops out at about 10K tokens of context.
78+
79+
### Gemma-4-26B-A4B 4-bit — by prompt length
80+
81+
| Prompt tokens | Vanilla prefill / decode (tok/s) | TTFT | `--turbo-kv` prefill / decode (tok/s) | Peak GPU · swap growth |
82+
|---|---|---|---|---|
83+
| ~530 | 733 / **52.2** | 0.8 s | 785 / 53.2 | 14.5 GB · 0 |
84+
| ~2.3K | **963** / 50.2 | 2.5 s | 969 / 50.5 | 15.0 GB · 0 |
85+
| ~9.5K | 959 / 45.1 | 10.1 s | 971 / 45.5 | 15.8 GB · 0 |
86+
| ~39.7K | 757 / 31.0 | 53.3 s | 787 / 31.5 | 17.9 GB · 0 |
87+
| ~80.7K | 622 / 24.3 | 131.3 s | 630 / 24.5 | 19.5 GB · 0 |
88+
89+
Every needle check passed in both modes. `--mtp` with the bf16 assistant (`gemma-4-26B-A4B-it-assistant-bf16`) works but is slower on the M6: 45.2 / 35.6 / 30.4 tok/s decode at ~530 / 2.3K / 9.5K tokens, against 53.0 / 50.8 / 46.0 without it. A 4-bit MoE is compute-bound, so verifying the drafted tokens costs more than it saves (the same finding as the M5 Pro tables below).
90+
91+
### Qwen3.6-35B-A3B 4-bit — GPU vs SSD streaming
92+
93+
| Prompt tokens | GPU prefill / decode (tok/s) | GPU peak | `--stream-experts` prefill / decode (tok/s) | SSD peak |
94+
|---|---|---|---|---|
95+
| ~550 | 808 / 46.7 | 20.4 GB | 321 / 13.2 | 6.0 GB |
96+
| ~2.3K | 969 / 45.5 | 21.0 GB | 402 / 13.1 | 6.2 GB |
97+
| ~9.8K | 849 / 43.4 | 21.1 GB | 403 / 12.9 | 6.6 GB |
98+
| 40.8K | 635 / 35.1 | 22.4 GB | 340 / 11.9 | 7.7 GB |
99+
100+
### Qwen3.8-27B-4bit (dense) — Vanilla vs TurboKV
101+
102+
| Prompt tokens | Vanilla prefill / decode (tok/s) | TurboKV prefill / decode (tok/s) | Peak GPU · swap growth |
103+
|---|---|---|---|
104+
| ~550 | 233 / 9.3 | 231 / 9.3 | 15.2 GB · 0 |
105+
| ~2.3K | 242 / 9.1 | 261 / 9.2 | 16.1 GB · 0 |
106+
| ~9.8K | 238 / 8.9 | 250 / 8.9 | 16.9 GB · 0 |
107+
| ~40.8K | 200 / 7.9 | 202 / 7.9 | 18.4 GB · 0 (TurboKV 16.9 GB) |
108+
109+
TurboKV barely changes speed on this model. Only 16 of its 64 layers use full attention (the other 48 are GatedDeltaNet), so the KV cache is already small. It saves about 1.5 GB at 40K tokens.
110+
111+
### What 32 GB exposed
112+
113+
| 8.5K-token prompt, Qwen3.8-27B-4bit | Before (old pin) | After (this release) |
114+
|---|---|---|
115+
| Prefill | 33.4 tok/s | **~240 tok/s** (≈7×; 238 tok/s measured at 9.8K) |
116+
| Peak memory | 38 GB process footprint | **≤19 GB** process (16.9 GB GPU peak at 9.8K) |
117+
| Swap growth | +15 GB | **0** |
118+
119+
1. **The MLX buffer cache was unbounded on full-GPU loads.** It could grow to the whole 26.8 GB working set. It is now sized from the RAM left after weights and KV.
120+
2. **The KV-cache estimate counted every layer as full attention.** Gemma 4 (25 of 30 layers use a 1,024-token sliding window) was overestimated 10×, and Qwen3.5/3.8 (48 of 64 layers are linear attention) 4×. On 32 GB that pushed Gemma into CPU/GPU layer partitioning, which crashed with a Metal GPU timeout.
121+
3. **An auto-detected VLM that failed to load exited the server.** `Qwen3.6-35B-A3B-UD-MLX-4bit` ships a `preprocessor_config.json` without `image_mean`. SwiftLM now falls back to text-only unless you pass `--vision`.
122+
4. **Vision-capable models skipped chunked prefill.** On the older mlx-swift-lm pin, a text-only prompt on the VLM path ran through the model in a single pass. It's fixed by the mlx-swift-lm bump in #167. Every number in this section was measured on `main` with that bump.
123+
124+
> ⚠️ **Known issues:** `--gpu-layers N` (CPU/GPU layer partitioning) hits a Metal GPU timeout on the first request (repro: `--model mlx-community/gemma-4-26b-a4b-it-4bit --gpu-layers 23`). QAT-quantized Gemma 4 MTP assistants (`…-qat-assistant-4bit`) fail with `unhandledKeys pre_projection/post_projection`; use `gemma-4-26B-A4B-it-assistant-bf16`.
125+
126+
Reproduce:
127+
128+
```bash
129+
./build.sh
130+
.build/release/SwiftLM --model mlx-community/gemma-4-26b-a4b-it-4bit --port 5431 --ctx-size 48000 &
131+
python3 scripts/demo/stream_client.py short
132+
python3 scripts/profiling/m6_bench.py --model mlx-community/gemma-4-26b-a4b-it-4bit \
133+
--config "Vanilla=" --contexts 512,2048,8192,32768,65536 --out docs/profiling/m6/gemma4_26b_a4b_4bit
134+
```
135+
136+
More recordings: [Gemma, 41K-token prompt (4×)](docs/profiling/m6/media/m6_gemma4_26b_a4b_41k_prompt_4x.gif) · [Qwen3.8-27B streaming](docs/profiling/m6/media/m6_qwen38_27b_stream.gif) · [Qwen3.8-27B, 8.6K-token prompt (4×)](docs/profiling/m6/media/m6_qwen38_27b_8k_prompt_4x.gif)
137+
54138
## 📊 Performance: MTP Speculative Decoding — Gemma 4-26B (MacBook Pro M5 Pro 64 GB)
55139

56140
Benchmarked with `gemma-4-26b-a4b-it-4bit` running three configurations across 512 / 40K / 100K token contexts.

‎Sources/SwiftLM/ModelProfiler.swift‎

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ struct ModelProfile: Sendable {
3131
let numActiveExperts: Int?
3232
let weightFileSizeBytes: Int
3333
let modelId: String
34+
/// Attention layout from `layer_types`. When a config has no `layer_types`,
35+
/// every layer counts as full attention, which is the old estimate.
36+
var fullAttentionLayers: Int? = nil
37+
var slidingAttentionLayers: Int = 0
38+
var slidingWindow: Int? = nil
39+
/// Gemma 4 gives its full-attention layers their own KV head count and head dim.
40+
var globalKVHeads: Int? = nil
41+
var globalHeadDim: Int? = nil
3442

3543
/// Estimated total parameters in billions (rough)
3644
var estimatedParamsB: Double {
@@ -55,10 +63,19 @@ struct ModelProfile: Sendable {
5563

5664
/// KV cache memory in GB for a given context length
5765
func kvCacheMemoryGB(contextLength: Int) -> Double {
58-
// KV cache = 2 (K + V) × layers × kv_heads × head_dim × context × 2 bytes (FP16)
66+
// KV cache = 2 (K + V) × layers × kv_heads × head_dim × tokens × 2 bytes (FP16).
67+
// Only attention layers hold a KV cache. Linear-attention layers (GatedDeltaNet)
68+
// keep a fixed-size state instead, and sliding-window layers stop growing at the
69+
// window size. Counting every layer as full attention overestimated Gemma 4 by
70+
// ~10× and Qwen3.5/3.8 by ~4×, which pushed 32 GB machines into CPU
71+
// partitioning they didn't need.
5972
let bytesPerElement = 2 // FP16
60-
let kvBytes = 2 * numLayers * numKVHeads * headDim * contextLength * bytesPerElement
61-
return Double(kvBytes) / 1e9
73+
let fullLayers = fullAttentionLayers ?? numLayers
74+
let fullBytes = 2 * fullLayers * (globalKVHeads ?? numKVHeads) * (globalHeadDim ?? headDim)
75+
* contextLength * bytesPerElement
76+
let slidingTokens = min(contextLength, slidingWindow ?? contextLength)
77+
let slidingBytes = 2 * slidingAttentionLayers * numKVHeads * headDim * slidingTokens * bytesPerElement
78+
return Double(fullBytes + slidingBytes) / 1e9
6279
}
6380

6481
/// Total memory required in GB (weights + KV cache + overhead)
@@ -177,6 +194,10 @@ enum ModelProfiler {
177194
let vocabSize: Int?
178195
let quantizationConfig: QuantConfig?
179196
let textConfig: TextConfig?
197+
let layerTypes: [String]?
198+
let slidingWindow: Int?
199+
let numGlobalKeyValueHeads: Int?
200+
let globalHeadDim: Int?
180201

181202
enum CodingKeys: String, CodingKey {
182203
case modelType = "model_type"
@@ -189,6 +210,10 @@ enum ModelProfiler {
189210
case vocabSize = "vocab_size"
190211
case quantizationConfig = "quantization_config"
191212
case textConfig = "text_config"
213+
case layerTypes = "layer_types"
214+
case slidingWindow = "sliding_window"
215+
case numGlobalKeyValueHeads = "num_global_key_value_heads"
216+
case globalHeadDim = "global_head_dim"
192217
}
193218
}
194219

@@ -200,6 +225,10 @@ enum ModelProfiler {
200225
let headDim: Int?
201226
let intermediateSize: Int?
202227
let vocabSize: Int?
228+
let layerTypes: [String]?
229+
let slidingWindow: Int?
230+
let numGlobalKeyValueHeads: Int?
231+
let globalHeadDim: Int?
203232

204233
enum CodingKeys: String, CodingKey {
205234
case numHiddenLayers = "num_hidden_layers"
@@ -209,6 +238,10 @@ enum ModelProfiler {
209238
case headDim = "head_dim"
210239
case intermediateSize = "intermediate_size"
211240
case vocabSize = "vocab_size"
241+
case layerTypes = "layer_types"
242+
case slidingWindow = "sliding_window"
243+
case numGlobalKeyValueHeads = "num_global_key_value_heads"
244+
case globalHeadDim = "global_head_dim"
212245
}
213246
}
214247

@@ -275,7 +308,8 @@ enum ModelProfiler {
275308
// Measure weight file sizes on disk (only for MoE to avoid slow walks on dense models)
276309
let weightSize = isMoE ? measureWeightFiles(directory: modelDirectory) : 0
277310

278-
return ModelProfile(
311+
let layerTypes = config.layerTypes ?? config.textConfig?.layerTypes
312+
var profile = ModelProfile(
279313
modelType: modelType,
280314
numLayers: numLayers,
281315
hiddenSize: hiddenSize,
@@ -291,6 +325,22 @@ enum ModelProfiler {
291325
weightFileSizeBytes: weightSize,
292326
modelId: modelId
293327
)
328+
if let layerTypes, layerTypes.count == numLayers {
329+
// Anything that isn't sliding or linear attention is treated as full attention.
330+
let sliding = layerTypes.filter { $0 == "sliding_attention" }.count
331+
let linear = layerTypes.filter { $0 == "linear_attention" }.count
332+
profile.slidingAttentionLayers = sliding
333+
profile.fullAttentionLayers = layerTypes.count - sliding - linear
334+
profile.slidingWindow = config.slidingWindow ?? config.textConfig?.slidingWindow
335+
if sliding > 0 && profile.slidingWindow == nil {
336+
// Can't cap a window we don't know, so count those layers as full.
337+
profile.fullAttentionLayers = layerTypes.count - linear
338+
profile.slidingAttentionLayers = 0
339+
}
340+
}
341+
profile.globalKVHeads = config.numGlobalKeyValueHeads ?? config.textConfig?.numGlobalKeyValueHeads
342+
profile.globalHeadDim = config.globalHeadDim ?? config.textConfig?.globalHeadDim
343+
return profile
294344
}
295345

296346
/// Routed-expert count keys, in precedence order within a container.
@@ -505,7 +555,15 @@ enum ModelProfiler {
505555
switch strategy {
506556
case .fullGPU:
507557
memoryLimit = Int(Double(system.recommendedWorkingSetBytes) * 1.5)
508-
cacheLimit = system.recommendedWorkingSetBytes // default
558+
// MLX's default cache limit is the whole working set. Prefill chunk
559+
// buffers change shape as the KV offset grows, so they are rarely
560+
// reused and the cache just keeps growing. On a 32 GB machine that
561+
// pushed macOS into swap by ~8K tokens. Give the cache half of what
562+
// is left after weights, KV and an 8 GB OS reserve, and never more
563+
// than the old default.
564+
let headroomGB = system.totalRAMGB - (weightGB + draftGB) - kvGB - 8.0
565+
let budget = Int(max(1.0, headroomGB / 2) * 1e9)
566+
cacheLimit = min(system.recommendedWorkingSetBytes, budget)
509567
case .swapAssisted:
510568
memoryLimit = 200 * 1024 * 1024 * 1024 // 200 GB sentinel to bypass MLX eval_impl spin loop (let macOS swap handle it)
511569
cacheLimit = 2 * 1024 * 1024 // 2MB — let OS manage caching

‎Sources/SwiftLM/Server.swift‎

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,28 @@ private struct TransformersTokenizerBridge: MLXLMCommon.Tokenizer, Sendable {
205205

206206
/// Returns `nil` when the value must be dropped (JSON `null` / NSNull), otherwise a
207207
/// structure with every nested null removed. See `TransformersTokenizerBridge.applyChatTemplate`.
208+
/// True when a VLM load failed because the checkpoint doesn't match the VLM code:
209+
/// its config doesn't decode, its weights don't line up with the module tree, or the
210+
/// factory doesn't know the model/processor type. Only these justify retrying an
211+
/// auto-detected VLM as a text-only LLM. Anything else (cancellation, download, I/O)
212+
/// would fail the same way again and just hide the real error.
213+
func isVLMCheckpointMismatch(_ error: any Error) -> Bool {
214+
switch error {
215+
case is DecodingError, is UpdateError:
216+
return true
217+
case let factoryError as ModelFactoryError:
218+
switch factoryError {
219+
case .unsupportedModelType, .unsupportedProcessorType, .configurationDecodingError,
220+
.invalidConfiguration:
221+
return true
222+
default:
223+
return false
224+
}
225+
default:
226+
return false
227+
}
228+
}
229+
208230
func sanitizeForJinja(_ value: any Sendable) -> (any Sendable)? {
209231
if value is NSNull { return nil }
210232
let mirror = Mirror(reflecting: value)
@@ -771,7 +793,8 @@ struct MLXServer: AsyncParsableCommand {
771793
// Apply memory strategy
772794
switch plan.strategy {
773795
case .fullGPU:
774-
print("[SwiftLM] \(plan.strategy.emoji) Memory strategy: FULL GPU (\(String(format: "%.1f", plan.weightMemoryGB))GB model, \(String(format: "%.1f", system.availableRAMGB))GB available)")
796+
Memory.cacheLimit = plan.recommendedCacheLimit
797+
print("[SwiftLM] \(plan.strategy.emoji) Memory strategy: FULL GPU (\(String(format: "%.1f", plan.weightMemoryGB))GB model, \(String(format: "%.1f", system.availableRAMGB))GB available, cache limited to \(plan.recommendedCacheLimit / (1024*1024))MB)")
775798
case .swapAssisted:
776799
if self.streamExperts {
777800
// SSD Streaming: expert weights are mmap'd from SSD via the OS page cache.
@@ -863,7 +886,7 @@ struct MLXServer: AsyncParsableCommand {
863886
let speculativeDecodingRequested = self.draftModel != nil || self.dflash || self.mtp
864887
let autoDetectedVision = !self.audio && architecture.supportsVision
865888
&& !speculativeDecodingRequested
866-
let isVision = self.vision || autoDetectedVision
889+
var isVision = self.vision || autoDetectedVision
867890
if architecture.supportsVision, !self.vision, !self.audio, speculativeDecodingRequested {
868891
print(
869892
"[SwiftLM] Note: \(architecture.modelType ?? "unknown") reports vision support, but speculative/MTP decoding was requested; loading as a text-only LLM."
@@ -895,12 +918,29 @@ struct MLXServer: AsyncParsableCommand {
895918
}
896919
} else if isVision {
897920
print("[SwiftLM] Loading VLM (vision-language model)...")
898-
container = try await VLMModelFactory.shared.loadContainer(
899-
from: downloader,
900-
using: TransformersTokenizerLoader(modelId: resolvedModelId),
901-
configuration: modelConfig
902-
) { progress in
903-
tracker.printProgress(progress)
921+
do {
922+
container = try await VLMModelFactory.shared.loadContainer(
923+
from: downloader,
924+
using: TransformersTokenizerLoader(modelId: resolvedModelId),
925+
configuration: modelConfig
926+
) { progress in
927+
tracker.printProgress(progress)
928+
}
929+
} catch where !self.vision && isVLMCheckpointMismatch(error) {
930+
// Vision was only auto-detected, and the vision side of the checkpoint
931+
// doesn't match the VLM code (e.g. a preprocessor_config.json without
932+
// image_mean). The text model can still serve, so fall back rather than
933+
// exit. Cancellation, network and I/O errors still propagate, as does
934+
// any error under an explicit --vision.
935+
print("[SwiftLM] ⚠️ Auto-detected VLM failed to load (\(error)); loading as a text-only LLM. Pass --vision to make this fatal.")
936+
isVision = false
937+
container = try await LLMModelFactory.shared.loadContainer(
938+
from: downloader,
939+
using: TransformersTokenizerLoader(modelId: resolvedModelId),
940+
configuration: modelConfig
941+
) { progress in
942+
tracker.printProgress(progress)
943+
}
904944
}
905945
} else if isAudio {
906946
print("[SwiftLM] Loading ALM (audio-language model)...")

0 commit comments

Comments
 (0)