Initial: llama.cpp router mode optimization guide for Apple Silicon
Full documentation of optimizing two LLMs on a single M5 Max GPU: - KV cache quantization (Q4_0) - Flash attention and batch tuning - Router mode with --models-max 1 - Per-model thread optimization via INI presets - Before/after benchmarks (12→48 t/s on 27B, 23→132 t/s on 35B)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# The Problem: Two Models, One GPU
|
||||
|
||||
## Why Two Models?
|
||||
|
||||
Different model architectures excel at different tasks:
|
||||
|
||||
- **MoE (Mixture of Experts):** Fast generation (~3B active params), good for chat. Example: Qwen3.6-35B-A3B
|
||||
- **Dense:** Better reasoning on coding tasks, all parameters active. Example: Qwen3.6-27B
|
||||
|
||||
Running both gives you the best of both worlds — but on a single GPU, they compete.
|
||||
|
||||
## The Naive Approach
|
||||
|
||||
The obvious solution: run two `llama-server` processes on different ports.
|
||||
|
||||
```bash
|
||||
# Server 1: 35B chat model on :8085
|
||||
llama-server -m qwen35b.gguf --port 8085 -ngl 99
|
||||
|
||||
# Server 2: 27B coding model on :8080
|
||||
llama-server -m qwen27b.gguf --port 8080 -ngl 99
|
||||
```
|
||||
|
||||
**Result:** Both models stay permanently loaded in GPU memory, permanently competing for memory bandwidth.
|
||||
|
||||
## Why It's Slow
|
||||
|
||||
On Apple Silicon's Unified Memory architecture, GPU and CPU share the same memory pool. When two processes both use Metal GPU acceleration:
|
||||
|
||||
1. **Memory bandwidth is shared** — Both models' weights (75 GB combined) compete for the ~614 GB/s memory bus
|
||||
2. **GPU scheduler splits time** — macOS Metal driver context-switches between processes
|
||||
3. **KV cache doubles the tax** — Both models maintain large KV caches for their context windows
|
||||
|
||||
The result: each model gets roughly **half** the GPU bandwidth it could achieve alone.
|
||||
|
||||
## Measured Impact
|
||||
|
||||
| Model | Solo Speed | With Other Loaded | Penalty |
|
||||
|-------|-----------|-------------------|---------|
|
||||
| 35B MoE | 94 t/s | 23 t/s | **-75%** |
|
||||
| 27B dense | 12 t/s | 12 t/s | **~0%** (already bandwidth-saturated) |
|
||||
|
||||
The 27B barely notices because it's already reading 27 GB of weights per token — its 12 t/s saturates the available bandwidth regardless. The 35B MoE (3B active params) has huge headroom but loses most of it to contention.
|
||||
|
||||
## The Goal
|
||||
|
||||
Find a way to give each model **full GPU bandwidth** without having to manually kill and restart servers.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Step 1: Two Separate Servers
|
||||
|
||||
The initial setup for running both model servers as persistent macOS services via `launchd`.
|
||||
|
||||
## Launchd Plist for 35B Chat Model
|
||||
|
||||
```xml
|
||||
<!-- ~/Library/LaunchAgents/com.jimmyg.llama-server-qwen35b.plist -->
|
||||
<plist>
|
||||
<dict>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/llama-server</string>
|
||||
<string>-m</string>
|
||||
<string>/Users/jimmyg/models/Qwen3.6-35B-A3B-Q8_0.gguf</string>
|
||||
<string>--host</string><string>0.0.0.0</string>
|
||||
<string>--port</string><string>8085</string>
|
||||
<string>-ngl</string><string>99</string>
|
||||
<string>-c</string><string>262144</string>
|
||||
<string>--mlock</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/jimmyg/.hermes/logs/llama-server-qwen35b.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/jimmyg/.hermes/logs/llama-server-qwen35b.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
## Launchd Plist for 27B Coding Model
|
||||
|
||||
```xml
|
||||
<!-- ~/Library/LaunchAgents/com.jimmyg.llama-server.plist -->
|
||||
<plist>
|
||||
<dict>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/llama-server</string>
|
||||
<string>-m</string>
|
||||
<string>/Users/jimmyg/models/Qwen3.6-27B-Q8_0.gguf</string>
|
||||
<string>--host</string><string>0.0.0.0</string>
|
||||
<string>--port</string><string>8080</string>
|
||||
<string>-ngl</string><string>99</string>
|
||||
<string>-c</string><string>131072</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
## Initial Flags Explained
|
||||
|
||||
| Flag | Value | Purpose |
|
||||
|------|-------|---------|
|
||||
| `-ngl 99` | All layers on GPU | Full Metal acceleration |
|
||||
| `-c` | 262K / 131K | Context window size |
|
||||
| `--mlock` | - | Prevent model from being swapped |
|
||||
| `--spec-type draft-mtp` | - | Multi-Token Prediction speculation |
|
||||
|
||||
## Loading the Services
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.jimmyg.llama-server-qwen35b.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.jimmyg.llama-server.plist
|
||||
```
|
||||
|
||||
## Hermes Agent Integration
|
||||
|
||||
Two custom providers in `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
custom_providers:
|
||||
- base_url: http://localhost:8085/v1
|
||||
model: /Users/jimmyg/models/Qwen3.6-35B-A3B-Q8_0.gguf
|
||||
name: qwen35b
|
||||
- base_url: http://localhost:8080/v1
|
||||
model: Qwen3.6-27B-Q8_0
|
||||
name: qwen27b
|
||||
```
|
||||
|
||||
## Baseline Performance
|
||||
|
||||
At this point with default settings:
|
||||
- 35B MoE: ~20 t/s
|
||||
- 27B dense: ~12 t/s
|
||||
|
||||
Plenty of room for optimization.
|
||||
@@ -0,0 +1,99 @@
|
||||
# The Optimization Journey
|
||||
|
||||
## Overview
|
||||
|
||||
Starting from the baseline (two servers, ~20 t/s on 35B, ~12 t/s on 27B), each optimization improved speed by addressing specific bottlenecks.
|
||||
|
||||
## Optimization 1: KV Cache Quantization
|
||||
|
||||
**Problem:** Full-precision (f16) KV cache consumes massive RAM. For 128K context on the 35B model, the KV cache alone uses ~15 GB.
|
||||
|
||||
**Fix:** Switch to Q4_0 KV cache.
|
||||
|
||||
```bash
|
||||
--cache-type-k q4_0 --cache-type-v q4_0
|
||||
```
|
||||
|
||||
**Impact:** -50% KV cache memory. Negligible quality loss — KV cache is a caching layer, not model weights. The precision of cached attention states has minimal effect on output quality.
|
||||
|
||||
## Optimization 2: Context Window Tuning
|
||||
|
||||
**Problem:** The 35B used 262K context (way too much for chat). The 27B used 131K. Combined KV caches consumed ~32 GB.
|
||||
|
||||
**Fix:** Both models at 128K. Sufficient for agent sessions (typical usage tops out at ~65K tokens).
|
||||
|
||||
```bash
|
||||
-c 131072
|
||||
```
|
||||
|
||||
**Impact:** Saved ~16 GB RAM. Freed headroom for the rest of the system.
|
||||
|
||||
## Optimization 3: Flash Attention
|
||||
|
||||
**Problem:** Without flash attention, generation speed degrades as KV cache grows (O(n²) attention cost).
|
||||
|
||||
**Fix:** Enable flash attention.
|
||||
|
||||
```bash
|
||||
--flash-attn on
|
||||
```
|
||||
|
||||
**Impact:** Maintains generation speed even with large contexts. With FA, generation stays at ~20 t/s regardless of context size. Without it, speed drops to ~10 t/s when context reaches 20K+ tokens.
|
||||
|
||||
## Optimization 4: Batch Size Tuning
|
||||
|
||||
**Problem:** Default batch sizes (2048/512) are optimized for server throughput (many concurrent requests), not single-user generation latency.
|
||||
|
||||
**Fix:** Smaller batch sizes for single-user use.
|
||||
|
||||
```bash
|
||||
--batch-size 512 --ubatch-size 128
|
||||
```
|
||||
|
||||
**Impact:** +25-50% generation speed for dense models. The 27B jumped from 12 to 16 t/s. The 35B MoE was less affected (3B active params already efficient).
|
||||
|
||||
**Why:** During generation (1 token at a time), the batch size affects how the GPU schedules compute. Smaller batches reduce latency per decode step.
|
||||
|
||||
## Optimization 5: Thread Count Tuning
|
||||
|
||||
**Problem:** Auto-detected thread count uses all CPU cores (18), causing dispatch overhead without benefit.
|
||||
|
||||
**Fix:** Explicit thread count optimized per architecture.
|
||||
|
||||
```bash
|
||||
-t 14 # General fallback
|
||||
```
|
||||
|
||||
**Impact:** MoE models benefit from more threads (attention compute-heavy). Dense models benefit from fewer threads (bandwidth-bound, less dispatch overhead).
|
||||
|
||||
| Model | Optimal Threads | Speed |
|
||||
|-------|----------------|-------|
|
||||
| 35B MoE (3B active) | 14 | 132 t/s |
|
||||
| 27B dense (27B active) | 10 | 48 t/s |
|
||||
|
||||
## Optimization 6: Speculative Decoding (MTP)
|
||||
|
||||
**Fix:** Increase speculation from 1 to 3.
|
||||
|
||||
```bash
|
||||
--spec-draft-n-max 3
|
||||
```
|
||||
|
||||
**Impact:** With 82-93% MTP acceptance rate, each forward pass produces ~2.5-3 tokens instead of 1. This effectively doubles generation speed for free.
|
||||
|
||||
**Key insight:** Qwen models have built-in Multi-Token Prediction heads. The `--spec-type draft-mtp` flag uses these native heads rather than a separate draft model, adding negligible overhead.
|
||||
|
||||
## The Big One: Router Mode
|
||||
|
||||
The above optimizations improved speed, but the fundamental problem remained: **two processes competing for GPU bandwidth.**
|
||||
|
||||
**Fix:** Replace two `llama-server` processes with one router-mode server.
|
||||
|
||||
```bash
|
||||
llama-server \
|
||||
--models-dir ~/.hermes/models-router \
|
||||
--models-max 1 \
|
||||
--models-preset ~/.hermes/llama-models.ini
|
||||
```
|
||||
|
||||
**Impact:** +5.7x on 35B, +3.0x on 27B. See `06-router-mode.md` for full details.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Router Mode Setup
|
||||
|
||||
## Architecture
|
||||
|
||||
Instead of running two separate `llama-server` processes, the router mode runs a single server that:
|
||||
|
||||
1. **Discovers models** in a directory (symlinks to actual GGUF files)
|
||||
2. **Loads on demand** — only loads a model when a request comes in
|
||||
3. **Evicts by LRU** — when `--models-max 1`, switches models atomically
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐
|
||||
│ Chat Request │────▶│ llama-server │
|
||||
│ 35B MoE │ │ Router Mode │
|
||||
└─────────────────┘ │ :8085 │
|
||||
│ │
|
||||
┌─────────────────┐ │ ┌────────────┐ │
|
||||
│ Coding Subagent │────▶│ │ 35B MoE │ │
|
||||
│ 27B dense │ │ │ (loaded) │ │
|
||||
└─────────────────┘ │ └────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────┐ │
|
||||
│ │ 27B dense │ │
|
||||
│ │ (evicted) │ │
|
||||
│ └────────────┘ │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Models Directory
|
||||
|
||||
Create a directory with symlinks to your GGUF files:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/models-router
|
||||
ln -sf /path/to/Qwen3.6-35B-A3B-Q8_0.gguf ~/.hermes/models-router/
|
||||
ln -sf /path/to/Qwen3.6-27B-Q8_0.gguf ~/.hermes/models-router/
|
||||
```
|
||||
|
||||
The server uses the GGUF filename (without extension) as the model ID.
|
||||
|
||||
## Launch Command
|
||||
|
||||
```bash
|
||||
llama-server \
|
||||
--models-dir ~/.hermes/models-router \
|
||||
--models-max 1 \
|
||||
--models-preset ~/.hermes/llama-models.ini \
|
||||
--host 0.0.0.0 --port 8085 \
|
||||
-ngl 99 -c 131072 --mlock \
|
||||
--spec-type draft-mtp --spec-draft-n-max 3 \
|
||||
--batch-size 512 --ubatch-size 128 \
|
||||
--cache-type-k q4_0 --cache-type-v q4_0 \
|
||||
--flash-attn on \
|
||||
--metrics
|
||||
```
|
||||
|
||||
## Per-Model INI Preset
|
||||
|
||||
Model-specific settings (like thread count) go in the INI file:
|
||||
|
||||
```ini
|
||||
[*]
|
||||
flash-attn = on
|
||||
cache-type-k = q4_0
|
||||
cache-type-v = q4_0
|
||||
batch-size = 512
|
||||
ubatch-size = 128
|
||||
spec-type = draft-mtp
|
||||
spec-draft-n-max = 3
|
||||
|
||||
[model.Qwen3.6-35B-A3B-Q8_0]
|
||||
threads = 14
|
||||
|
||||
[model.Qwen3.6-27B-Q8_0]
|
||||
threads = 10
|
||||
```
|
||||
|
||||
The `[*]` section applies to all models. Model-specific sections override for individual models.
|
||||
|
||||
## Launchd Service (Persistent)
|
||||
|
||||
Create a launchd plist for automatic startup:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>Label</key><string>com.jimmyg.llama-server-router</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/llama-server</string>
|
||||
<string>--models-dir</string>
|
||||
<string>/Users/jimmyg/.hermes/models-router</string>
|
||||
<string>--models-max</string><string>1</string>
|
||||
<string>--models-preset</string>
|
||||
<string>/Users/jimmyg/.hermes/llama-models.ini</string>
|
||||
<string>--host</string><string>0.0.0.0</string>
|
||||
<string>--port</string><string>8085</string>
|
||||
<string>-ngl</string><string>99</string>
|
||||
<string>-c</string><string>131072</string>
|
||||
<string>--mlock</string>
|
||||
<string>--spec-type</string><string>draft-mtp</string>
|
||||
<string>--spec-draft-n-max</string><string>3</string>
|
||||
<string>--batch-size</string><string>512</string>
|
||||
<string>--ubatch-size</string><string>128</string>
|
||||
<string>--cache-type-k</string><string>q4_0</string>
|
||||
<string>--cache-type-v</string><string>q4_0</string>
|
||||
<string>--flash-attn</string><string>on</string>
|
||||
<string>--metrics</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>/Users/jimmyg/.hermes/logs/llama-server-router.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>/Users/jimmyg/.hermes/logs/llama-server-router.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
Load it:
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.jimmyg.llama-server-router.plist
|
||||
```
|
||||
|
||||
## Hermes Agent Config
|
||||
|
||||
Both providers now point to the same server:
|
||||
|
||||
```yaml
|
||||
custom_providers:
|
||||
- base_url: http://localhost:8085/v1
|
||||
model: Qwen3.6-35B-A3B-Q8_0
|
||||
name: qwen35b
|
||||
- base_url: http://localhost:8085/v1
|
||||
model: Qwen3.6-27B-Q8_0
|
||||
name: qwen27b
|
||||
```
|
||||
|
||||
## API Usage
|
||||
|
||||
Send requests with the model name (GGUF filename without extension):
|
||||
|
||||
```bash
|
||||
# 35B chat
|
||||
curl -X POST http://localhost:8085/v1/completions \
|
||||
-d '{"model":"Qwen3.6-35B-A3B-Q8_0","prompt":"Hello"}'
|
||||
|
||||
# 27B coding
|
||||
curl -X POST http://localhost:8085/v1/completions \
|
||||
-d '{"model":"Qwen3.6-27B-Q8_0","prompt":"def quicksort:"}'
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
The `--metrics` flag exposes Prometheus-formatted metrics:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8085/metrics | grep predicted_tokens_seconds
|
||||
```
|
||||
|
||||
## Known Limitation
|
||||
|
||||
`--slot-save-path` (KV cache persistence) does not work with router mode in current versions. Child processes are killed on eviction, so KV cache is lost. This is a feature gap, not a bug — router mode wasn't designed for context persistence across model switches.
|
||||
Reference in New Issue
Block a user