Skip to content

Commit 1233435

Browse files
committed
feat: complete extreme context profiling & fix prompt cache for TurboQuant
- Fix: Prevent prompt cache from decoding TurboQuant compressed polar buffers back to fp16, saving ~19GB GPU allocation at 100K context. - Feat: Add GPU allocation tracking via ioreg to capture true memory demand including swap memory. - Docs: Update README with benchmark summary and multi-device profiling results structure. - Add run-benchmark workflow skill. - Add total memory and active GPU memory monitoring in MemoryUtils.
1 parent 170501a commit 1233435

7 files changed

Lines changed: 499 additions & 41 deletions

File tree

‎.agents/workflows/run-benchmark.md‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
description: Run the extreme context benchmark suite for SwiftLM profiling
3+
---
4+
5+
# Run Extreme Context Benchmark
6+
7+
This workflow runs the full 12-test profiling matrix (4 configurations × 3 context depths) for a given model and captures performance + memory metrics.
8+
9+
## Prerequisites
10+
11+
// turbo
12+
1. Ensure the SwiftLM binary is built:
13+
```bash
14+
swift build -c release
15+
```
16+
17+
## Run the Benchmark
18+
19+
2. Kill any existing SwiftLM server:
20+
```bash
21+
killall SwiftLM 2>/dev/null; sleep 2
22+
```
23+
24+
// turbo
25+
3. Run the profiling suite with the target model:
26+
```bash
27+
python3 -u scripts/profiling/profile_runner.py \
28+
--model gemma-4-26b-a4b-it-4bit \
29+
--contexts "512,40000,100000" \
30+
--out ./profiling_results_$(hostname -s).md
31+
```
32+
33+
The profiler will:
34+
- Start SwiftLM with each configuration (`Dense/Vanilla`, `SSD Stream`, `TurboQuant`, `SSD + TurboQuant`)
35+
- Send requests at each context depth (512, 40K, 100K tokens)
36+
- Measure TTFT, TPS, Active RAM, and GPU Alloc (via `ioreg AGXAccelerator`)
37+
- Save results to the output markdown file
38+
39+
## Expected Runtime
40+
41+
| Context Size | Approximate Time per Config |
42+
|---|---|
43+
| 512 | ~10 seconds |
44+
| 40,000 | ~40 seconds |
45+
| 100,000 | ~120 seconds |
46+
47+
**Total**: ~12 minutes for the full 12-test matrix (4 configs × 3 contexts)
48+
49+
## Customizing
50+
51+
- **Different model**: Replace `gemma-4-26b-a4b-it-4bit` with any MLX model ID
52+
- **Different contexts**: Change `--contexts` (comma-separated list of token counts)
53+
- **Output file**: Change `--out` path
54+
55+
## After the Benchmark
56+
57+
4. Review the generated markdown file and check for any `FAILED / OOM` entries.
58+
59+
5. If contributing results back to the project, append the device section to `profiling_results.md`:
60+
- Add a `## <Device Name> — <RAM> GB Unified Memory` section
61+
- Include chip, RAM, macOS version, SwiftLM version
62+
- Submit a PR with title: `bench: <device-name> results for <model-id>`
63+
64+
## Troubleshooting
65+
66+
- **Server fails to start**: Check that the model exists at `~/.aegis-ai/models/mlx_models/mlx-community/<model-id>` or use a full path
67+
- **Python crashes silently**: Run with `python3 -u` for unbuffered output
68+
- **100K tests OOM on <32 GB**: Use `--contexts "512,40000"` to skip 100K
69+
- **Stale results**: The profiler kills any existing SwiftLM process before each config run

‎README.md‎

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,39 @@ No Python runtime, no Global Interpreter Lock (GIL), no unnecessary memory copie
88
<img src="docs/demo.gif" width="320" alt="SwiftLM Chat iOS demo" />
99
</p>
1010

11+
---
12+
13+
## 📊 Performance: Gemma 4-26B on Apple Silicon
14+
15+
Benchmark results for `gemma-4-26b-a4b-it-4bit` (26B MoE, 4-bit) on M5 Pro 64 GB. Full results and methodology at **[profiling_results.md](profiling_results.md)**.
16+
17+
### Headline Numbers
18+
19+
| Configuration | 512 ctx | 40K ctx | 100K ctx |
20+
|---|---|---|---|
21+
| **Dense/Vanilla** | 34 tok/s · 18.8 GB | 17 tok/s · 52.6 GB | 16 tok/s · 52.1 GB |
22+
| **SSD Stream** | 4.5 tok/s · **7.7 GB** | 4.2 tok/s · 52.1 GB | 3.5 tok/s · 52.1 GB |
23+
| **TurboQuant** | 34 tok/s · 18.6 GB | 7.0 tok/s · **35.0 GB** | 4.1 tok/s · **46.7 GB** |
24+
| **SSD + TurboQuant** | 4.7 tok/s · **7.7 GB** | 2.1 tok/s · **22.7 GB** | 1.4 tok/s · **33.3 GB** |
25+
26+
> Values shown as `generation speed · GPU memory allocated`
27+
28+
**Key takeaways:**
29+
- 🖥️ **8 GB Mac Mini**: SSD Stream runs a 26B model at **4.6 GB Active RAM**
30+
- 📄 **40K context on 24 GB MacBook Pro**: SSD + TurboQuant fits in **22.7 GB**
31+
- 📚 **100K context on 32 GB Mac Studio**: SSD + TurboQuant fits in **33.3 GB** — previously required 64 GB
32+
33+
Run the benchmark on your device:
34+
```bash
35+
python3 scripts/profiling/profile_runner.py \
36+
--model gemma-4-26b-a4b-it-4bit \
37+
--contexts "512,40000,100000"
38+
```
39+
40+
> We welcome PRs with results from other Apple Silicon devices! See [profiling_results.md](profiling_results.md#contributing-your-results) for details.
41+
42+
---
43+
1144
## 🚀 Features
1245

1346
- 🍎 **100% Native Apple Silicon**: Powered natively by Metal and Swift.
@@ -47,16 +80,7 @@ Reference implementations: [`turboquant-mlx`](https://github.com/sharpner/turboq
4780

4881
---
4982

50-
## 💻 Tested Hardware & Benchmarks
51-
52-
SwiftLM is designed to leverage Apple Silicon Unified Memory limits to their absolute maximum. All first-party benchmarks are generated on an **Apple M5 Pro (64 GB Unified Memory)**.
53-
54-
Since we have limited access to the full spectrum of Apple hardware, **we welcome Pull Requests with benchmark results** from other devices (especially base M1/M2/M3 chips and Mac Studios).
55-
56-
To run the extreme context benchmark suite on your device, execute:
57-
```bash
58-
bash tests/run_extreme_context.sh <model-id>
59-
```
83+
## 💻 Additional Benchmarks
6084

6185
### Prompt Cache & Sliding Window Regression Test
6286
To verify the stability of the prompt cache when interleaving long contexts with sliding window attention (e.g. Gemma 4/Mistral 3), run this extreme test sequence:
@@ -73,15 +97,6 @@ echo "=== ALL 4 PASSED ==="
7397
```
7498
If you see `ALL 4 PASSED` without `SIGTRAP` or `curl: (52) Empty reply from server`, the memory bounds are stable.
7599

76-
### Extreme Context Performance (100K Tokens)
77-
Tested on M5 Pro (64GB) handling a monolithic **100,000 token** system prompt with **TurboKV Acceleration** enabled.
78-
79-
| Model | Configuration | Time To First Token (TTFT) | Peak GPU Memory (w/ TurboKV) |
80-
|---|---|---|---|
81-
| `gemma-4-e4b-it-8bit` | Dense (4B) | 60.92s | 11.83 GB |
82-
| `gemma-4-26b-a4b-it-4bit` | MoE (26B) | 66.99s | 16.86 GB |
83-
| `gemma-4-31b-it-4bit` | MoE (31B) | 533.37s | 29.23 GB |
84-
85100
### Throughput & Inference Memory Profile
86101
Tested by rendering exactly 20 tokens under standard conversational evaluation (`--prefill-size 512`) to capture precise Token Generation (TPS) and Apple Metal memory footprint limits:
87102

@@ -217,6 +232,7 @@ curl http://localhost:5413/v1/chat/completions \
217232
| `--prefill-size`| `512` | Prompt prefill chunk size (micro-batching for long contexts) |
218233
| `--gpu-layers` | `model_default`| Restrict the amount of layers allocated to GPU hardware |
219234
| `--stream-experts` | `false` | Enable experimental SSD streaming for MoE model expert matrices |
235+
| `--turbo-kv` | `false` | Enable TurboQuant 3-bit KV cache compression |
220236

221237
## 📦 Requirements
222238

‎Sources/SwiftLM/MemoryUtils.swift‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Foundation
2+
import Darwin
3+
import MLX
4+
5+
public struct MemoryUtils {
6+
/// OS-level physical memory footprint (what Activity Monitor shows).
7+
/// Capped by available physical RAM — when this hits the ceiling,
8+
/// macOS begins swapping to SSD, which degrades performance.
9+
public static func getOSPhysFootprintGB() -> Double {
10+
var info = task_vm_info_data_t()
11+
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size) / 4
12+
13+
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
14+
$0.withMemoryRebound(to: integer_t.self, capacity: 1) {
15+
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
16+
}
17+
}
18+
19+
if kerr == KERN_SUCCESS {
20+
return Double(info.phys_footprint) / (1024.0 * 1024.0 * 1024.0)
21+
} else {
22+
return 0.0
23+
}
24+
}
25+
26+
/// Total memory the model has requested (physical + swapped to SSD).
27+
/// This is the TRUE memory demand — it exceeds phys_footprint when macOS
28+
/// is swapping. Comparing this between Vanilla and TurboQuant shows the
29+
/// actual compression benefit even at extreme context lengths.
30+
public static func getTotalMemoryDemandGB() -> Double {
31+
var info = task_vm_info_data_t()
32+
var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size) / 4
33+
34+
let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) {
35+
$0.withMemoryRebound(to: integer_t.self, capacity: 1) {
36+
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
37+
}
38+
}
39+
40+
if kerr == KERN_SUCCESS {
41+
// internal = private pages (includes compressed + swapped)
42+
// phys_footprint is capped by RAM. internal is not.
43+
let internalBytes = Int(info.internal)
44+
let compressedBytes = Int(info.compressed)
45+
// Total demand = what's physically wired + what got compressed/swapped
46+
let total = Int(info.phys_footprint) + compressedBytes
47+
return Double(total) / (1024.0 * 1024.0 * 1024.0)
48+
} else {
49+
return 0.0
50+
}
51+
}
52+
53+
/// MLX GPU buffer allocation (what MLX's allocator explicitly tracks).
54+
public static func getGPUActiveMemoryGB() -> Double {
55+
return Double(Memory.activeMemory) / (1024.0 * 1024.0 * 1024.0)
56+
}
57+
58+
/// Combined snapshot: (OS physical GB, total demand GB, GPU active GB)
59+
public static func snapshot() -> (os: Double, demand: Double, gpu: Double) {
60+
(getOSPhysFootprintGB(), getTotalMemoryDemandGB(), getGPUActiveMemoryGB())
61+
}
62+
}

‎Sources/SwiftLM/Server.swift‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,8 +1090,26 @@ func handleChatCompletion(
10901090

10911091
// Return a closure that will save the cache state synchronously AFTER
10921092
// the generator stream has evaluated the prefill (on its very first token).
1093+
//
1094+
// ⚠️ TurboQuant guard: when TurboQuant has compressed data, cache.state
1095+
// decodes ALL polar buffers back to full fp16 to create a restorable snapshot.
1096+
// At 100K context this creates a ~37 GB allocation that completely negates
1097+
// the compression savings (52 GB → should be ~20 GB).
1098+
// Skip prompt cache save when any layer has active TurboQuant compression.
1099+
// Short contexts (< turboMinActivationTokens) are unaffected — TurboQuant
1100+
// hasn't compressed anything yet so the state getter is a zero-copy view.
1101+
let turboHasCompressed = cache.contains { layer in
1102+
if let simple = layer as? KVCacheSimple {
1103+
return simple.turboQuantEnabled && simple.compressedOffset > 0
1104+
}
1105+
return false
1106+
}
10931107
let onPrefillDone: (() async -> Void)? = {
1094-
await promptCache.save(tokens: promptTokens, cache: cache)
1108+
if turboHasCompressed {
1109+
print("[SwiftLM] 🧠 Skipping prompt cache save — TurboQuant has compressed \(cache.compactMap { ($0 as? KVCacheSimple)?.compressedOffset }.max() ?? 0) tokens. Saving would decode ~37 GB back to fp16.")
1110+
} else {
1111+
await promptCache.save(tokens: promptTokens, cache: cache)
1112+
}
10951113
}
10961114
return (stream, onPrefillDone)
10971115
}
@@ -1259,7 +1277,8 @@ func handleChatStreaming(
12591277
await prefillState.finish()
12601278
let prefillDur = Date().timeIntervalSince(prefillStart)
12611279
let prefillTokPerSec = prefillDur > 0 ? Double(promptTokenCount) / prefillDur : 0
1262-
print("srv slot update: id 0 | prefill done | n_tokens=\(promptTokenCount), t=\(String(format: "%.2f", prefillDur))s, \(String(format: "%.1f", prefillTokPerSec))t/s")
1280+
let memSnap = MemoryUtils.snapshot()
1281+
print("srv slot update: id 0 | prefill done | n_tokens=\(promptTokenCount), t=\(String(format: "%.2f", prefillDur))s, \(String(format: "%.1f", prefillTokPerSec))t/s | OS_RAM=\(String(format: "%.1f", memSnap.os))GB | MEM_DEMAND=\(String(format: "%.1f", memSnap.demand))GB | GPU_MEM=\(String(format: "%.1f", memSnap.gpu))GB")
12631282
print("srv generate: id 0 | ", terminator: "")
12641283
if let onPrefillDone { await onPrefillDone() }
12651284
firstToken = false
@@ -1329,6 +1348,8 @@ func handleChatStreaming(
13291348
cont.finish()
13301349
// llama-server style: print newline then full response JSON
13311350
print("") // end the real-time token stream line
1351+
let postMemSnap = MemoryUtils.snapshot()
1352+
print("srv slot done: id 0 | gen_tokens=\(completionTokenCount) | OS_RAM=\(String(format: "%.1f", postMemSnap.os))GB | MEM_DEMAND=\(String(format: "%.1f", postMemSnap.demand))GB | GPU_MEM=\(String(format: "%.1f", postMemSnap.gpu))GB")
13321353
let dur = Date().timeIntervalSince(genStart)
13331354
let tokPerSec = dur > 0 ? Double(completionTokenCount) / dur : 0
13341355
let logContent: Any = hasToolCalls ? NSNull() : fullText
@@ -1399,7 +1420,8 @@ func handleChatNonStreaming(
13991420
if firstToken {
14001421
let prefillDur = Date().timeIntervalSince(prefillStart)
14011422
let prefillTokPerSec = prefillDur > 0 ? Double(promptTokenCount) / prefillDur : 0
1402-
print("srv slot update: id 0 | prefill done | n_tokens=\(promptTokenCount), t=\(String(format: "%.2f", prefillDur))s, \(String(format: "%.1f", prefillTokPerSec))t/s")
1423+
let memSnap = MemoryUtils.snapshot()
1424+
print("srv slot update: id 0 | prefill done | n_tokens=\(promptTokenCount), t=\(String(format: "%.2f", prefillDur))s, \(String(format: "%.1f", prefillTokPerSec))t/s | OS_RAM=\(String(format: "%.1f", memSnap.os))GB | MEM_DEMAND=\(String(format: "%.1f", memSnap.demand))GB | GPU_MEM=\(String(format: "%.1f", memSnap.gpu))GB")
14031425
print("srv generate: id 0 | ", terminator: "")
14041426
if let onPrefillDone { await onPrefillDone() }
14051427
firstToken = false
@@ -1419,6 +1441,8 @@ func handleChatNonStreaming(
14191441
}
14201442
}
14211443
print("") // end the real-time token stream line
1444+
let postMemSnap = MemoryUtils.snapshot()
1445+
print("srv slot done: id 0 | gen_tokens=\(completionTokenCount) | OS_RAM=\(String(format: "%.1f", postMemSnap.os))GB | MEM_DEMAND=\(String(format: "%.1f", postMemSnap.demand))GB | GPU_MEM=\(String(format: "%.1f", postMemSnap.gpu))GB")
14221446
let duration = Date().timeIntervalSince(genStart)
14231447
await stats.requestFinished(tokens: completionTokenCount, duration: duration)
14241448
await semaphore.signal()

‎journal.md‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,63 @@ You maintain a strict budget of RAM (e.g., 2 GB for active experts). The experts
137137
#### Summary
138138
What you are poking at is exactly the future of local LLMs. Models are getting too big for VRAM, but SSDs are getting fast enough to bridge the gap if the model architecture cooperates. By changing the training objective to favor temporal blocks and expert knockout, you are effectively "hardware-aware" training the model to be friendly to the SSD PCIe lane.
139139
It completely shifts the bottleneck from the hardware (bus speed) to the algorithm (routing predictability).
140+
141+
---
142+
143+
## 🔬 Phase 3: Extreme Context Profiling & The Prompt Cache Discovery (April 5, 2026)
144+
145+
### 10. Building the Profiling Framework
146+
147+
With Gemma 4-26B stable and generating, we needed to answer the real deployment question: *How does this model behave at extreme context lengths across different memory configurations?* We built `scripts/profiling/profile_runner.py` — an automated profiling framework that:
148+
- Iterates through 4 configurations: Dense/Vanilla, SSD Stream, TurboQuant, SSD+TurboQuant
149+
- Tests across 3 context depths: 512, 40K, and 100K tokens
150+
- Captures both **Active RAM** (OS physical footprint via `mach_task_basic_info`) and **GPU Memory Allocated** (Apple GPU driver allocation via `ioreg AGXAccelerator`)
151+
152+
### 11. The `ioreg` Breakthrough
153+
154+
The initial profiling used only `phys_footprint` — the OS physical memory metric. But at 100K context, both Dense/Vanilla (49.3 GB) and TurboQuant (49.3 GB) showed identical numbers. This made no sense — TurboQuant was clearly compressing the KV cache.
155+
156+
The problem: `phys_footprint` is **capped by available physical RAM**. On a 64 GB machine, it tops out at ~49 GB regardless of actual demand. We needed to see the *total GPU allocation* including memory swapped to SSD.
157+
158+
Following the same pattern used by the Aegis-AI `HardwareDetector`, we queried Apple's `AGXAccelerator` GPU driver via `ioreg` for the `"Alloc system memory"` counter. This metric **CAN exceed physical RAM** — revealing the true memory demand:
159+
160+
| Configuration | Active RAM (capped) | GPU Alloc (true demand) |
161+
|---|---|---|
162+
| Dense/Vanilla @ 40K | 49.4 GB | **52.6 GB** |
163+
| TurboQuant @ 40K | 32.4 GB | **35.0 GB** |
164+
165+
The 52.6 GB vs 35.0 GB difference was invisible in the OS metric but clearly visible via `ioreg`.
166+
167+
### 12. 🐛 The Prompt Cache Bug
168+
169+
At 100K context, even with the `ioreg` metric, TurboQuant (52.5 GB) and Dense/Vanilla (52.1 GB) were nearly identical. Tracing through the code revealed the root cause:
170+
171+
1. During prefill, the full 100K fp16 KV cache is built (~37 GB)
172+
2. After the first generation token, TurboQuant compresses it to ~3 GB of polar buffers ✅
173+
3. But then `onPrefillDone` fires → the prompt cache calls `cache.state`
174+
4. The `state` getter **decodes ALL compressed polar buffers back to full fp16** to create a restorable snapshot
175+
5. The `eval()` call materializes this decoded copy — a fresh **~37 GB allocation**
176+
6. Net result: compression savings completely negated
177+
178+
The key insight: for Dense/Vanilla, `cache.state` returns **views** (zero-copy references) of existing buffers. For TurboQuant, it creates **new arrays** via `turboDecodeK/V` — an O(N) memory allocation the size of the entire context.
179+
180+
### 13. 🔧 The Fix
181+
182+
One targeted change: skip prompt cache save when TurboQuant has actively compressed data.
183+
184+
**Results at 100K context (SSD + TurboQuant):**
185+
186+
| Metric | Before Fix | After Fix |
187+
|---|---|---|
188+
| GPU Memory Allocated | 52.2 GB | **33.3 GB** (-36%) |
189+
| Active RAM | 49.1 GB | **29.6 GB** (-40%) |
190+
191+
**29.6 GB Active RAM for a 26B model at 100K tokens.** This fits in a 32 GB Mac Studio — previously required 64 GB.
192+
193+
### 📋 Files Changed
194+
- `Sources/SwiftLM/MemoryUtils.swift` — Added GPU active memory and total demand metrics
195+
- `Sources/SwiftLM/Server.swift` — OS_RAM + MEM_DEMAND + GPU_MEM logging at prefill and post-generation; prompt cache TurboQuant guard
196+
- `scripts/profiling/profile_runner.py` — Full profiling framework with `ioreg` GPU allocation tracking
197+
198+
### 🎯 Key Lesson: Measure What Matters
199+
The OS `phys_footprint` metric is what Activity Monitor shows — but it lies by omission. It's capped by physical RAM and doesn't reveal how much memory the GPU driver has actually allocated (including SSD-swapped pages). For memory-constrained deployment, the `ioreg AGXAccelerator "Alloc system memory"` counter is the ground truth.

0 commit comments

Comments
 (0)