Running 100B+ MoE LLMs on a Single RTX 4090: A Practical Guide to Expert Offloading with llama.cpp
Tuần này trên Hacker News có bài được hơn 300 điểm: chạy model MoE cỡ 125B trên một con RTX 4090, tốc độ khoảng 100 token/s. Nghe khó tin, vì 4090 chỉ có 24GB VRAM, mà 125B params dù quantize 4-bit cũng ngốn khoảng 70GB. Mình đã chạy local LLM được hơn hai năm, từ thời phải "vắt" Llama 2 13B cho vừa VRAM. Mình thấy mẹo này không có gì ma thuật cả: nó dựa vào kiến trúc Mixture of Experts (MoE) và một kỹ thuật tên là expert offloading. Bài này giải thích vì sao nó chạy được, cách tính xem máy bạn có chạy nổi không, và cách setup bằng llama.cpp. Nhờ vậy bạn sẽ tự đánh giá được mấy con số benchmark trên mạng có thực tế với máy mình không.
Vì sao MoE "lách" được giới hạn VRAM
Với model dense (kiểu Llama 3 70B), mỗi token sinh ra phải đi qua toàn bộ weights. Còn với MoE, mỗi layer có nhiều "expert" (thực chất là các khối FFN), và một router chỉ chọn vài expert cho mỗi token. Vì vậy model có thể có 125B params tổng nhưng mỗi token chỉ dùng khoảng 10-15B active params.
Điểm mấu chốt là các phần được dùng ở mọi token (attention, embedding, shared expert, KV cache) thì nhỏ nhưng truy cập liên tục, nên đặt trên GPU. Các routed expert thì to nhưng mỗi lần chỉ đọc một phần nhỏ, nên có thể để trên RAM hệ thống và cho CPU xử lý.
flowchart LR
T[Token input] --> A[Attention layers - GPU]
A --> R{Router}
R -->|chọn top-k| E1[Expert 3 - CPU RAM]
R -->|chọn top-k| E2[Expert 17 - CPU RAM]
R --> S[Shared expert - GPU]
E1 --> M[Combine]
E2 --> M
S --> M
M --> O[Layer tiếp theo]
```
Vì thế khi chạy MoE offload, nút thắt cổ chai nằm ở **băng thông RAM**: DDR5 dual-channel khoảng 80-100 GB/s, so với khoảng 1 TB/s của VRAM trên 4090. Đây là lý do cùng một model mà người này báo 100 T/s, người kia chỉ được 20 T/s. Con số 100 T/s thường đến từ máy có RAM bandwidth cao, model có active params nhỏ, và context ngắn. Đừng mặc định máy mình cũng đạt được như vậy.
## Tính trước khi tải 70GB về
Mình đã mất vài buổi tối tải model về rồi mới biết không chạy nổi. Giờ lần nào mình cũng tính trước bằng một script nhỏ như sau:
```python
# estimate_moe.py - ước lượng bộ nhớ và tốc độ cho MoE offload
BITS_PER_WEIGHT = {"Q4_K_M": 4.85, "Q5_K_M": 5.7, "Q8_0": 8.5, "IQ3_XXS": 3.1}
def estimate(total_b, active_b, shared_b, quant, ram_bw_gbs, ctx=16384,
kv_bytes_per_token=0.1e6):
bpw = BITS_PER_WEIGHT[quant] / 8
total_gb = total_b * bpw
gpu_weights_gb = shared_b * bpw # attention + shared, luôn trên GPU
cpu_experts_gb = total_gb - gpu_weights_gb # routed experts nằm trên RAM
kv_gb = ctx * kv_bytes_per_token / 1e9
# mỗi token phải đọc phần expert active từ RAM
active_expert_gb = (active_b - shared_b) * bpw
max_tps = ram_bw_gbs / active_expert_gb # giới hạn lý thuyết, thực tế ~50-70%
print(f"Tổng model : {total_gb:6.1f} GB")
print(f"VRAM cần : {gpu_weights_gb + kv_gb + 1.5:6.1f} GB (gồm KV + overhead)")
print(f"RAM cần : {cpu_experts_gb + 8:6.1f} GB (gồm OS)")
print(f"Tốc độ trần : {max_tps:6.1f} T/s | thực tế ~{max_tps*0.6:.0f} T/s")
# Ví dụ: MoE 125B, ~12B active, ~5B shared, máy DDR5-6000 dual channel
estimate(total_b=125, active_b=12, shared_b=5, quant="Q4_K_M", ram_bw_gbs=90)
```
Với cấu hình trong ví dụ, script cho ra khoảng 76GB tổng, khoảng 6GB VRAM cho phần GPU và hơn 80GB RAM. Tốc độ trần vào khoảng 20 T/s. Muốn lên cao hơn thì phải có platform nhiều kênh RAM như Threadripper hay EPYC, hoặc giữ bớt expert trên GPU. Kết luận thực tế là **máy 64GB RAM thì quên model 125B đi**, nên nhắm tới MoE cỡ 30B-50B.
## Setup llama.cpp với expert offload
Mình dùng `llama.cpp` vì nó cho kiểm soát chi tiết từng tensor nằm ở đâu. Ollama tiện hơn nhưng giấu mất mấy option quan trọng này.
```bash
# Build với CUDA (cần CUDA Toolkit 12.4+)
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build -j$(nproc)
# Tải bản GGUF đã quantize (chỉ lấy đúng file Q4_K_M)
pip install -U "huggingface_hub[cli]"
huggingface-cli download <repo-gguf> --include "*Q4_K_M*" --local-dir ./models
# Chạy server: toàn bộ layer lên GPU, nhưng ép routed experts về CPU
./build/bin/llama-server \
-m ./models/model-Q4_K_M-00001-of-00002.gguf \
--n-gpu-layers 999 \
--override-tensor "\.ffn_.*_exps\.=CPU" \
--ctx-size 16384 \
--flash-attn on \
--cache-type-k q8_0 --cache-type-v q8_0 \
--threads 16 \
--host 127.0.0.1 --port 8080
```
Một vài lưu ý về các option:
- `--override-tensor` (viết tắt `-ot`) nhận regex khớp với tên tensor. Các tensor routed expert trong GGUF thường có tên `ffn_up_exps`, `ffn_down_exps`, `ffn_gate_exps`. Bản build mới còn có `--n-cpu-moe N`, dùng để đẩy expert của N layer đầu về CPU. Cách này tiện để tinh chỉnh: VRAM còn trống thì giảm N xuống để giữ thêm expert trên GPU.
- `--threads` nên đặt bằng số **physical core**, không phải số thread. Với CPU Intel có P-core và E-core, chỉ đếm P-core thôi, thêm E-core vào thường còn chậm hơn.
- Quantize KV cache bằng `q8_0` gần như không làm giảm chất lượng mà tiết kiệm được một nửa VRAM cho context.
Trong lúc chạy, mở `nvidia-smi -l 1` song song. Nếu VRAM mới dùng khoảng 10GB thì vẫn còn chỗ: giảm `--n-cpu-moe` để kéo thêm expert lên GPU, tốc độ sẽ tăng đáng kể.
## Gọi model và benchmark cho đúng cách
`llama-server` cung cấp API tương thích OpenAI, nên code hiện có hầu như chỉ cần đổi `baseURL`:
```mermaid
sequenceDiagram
participant App as Node.js App
participant S as llama-server :8080
participant G as GPU (attention)
participant C as CPU (experts)
App->>S: POST /v1/chat/completions (stream)
S->>G: prompt processing
loop mỗi token
G->>C: hidden state + router chọn experts
C-->>G: kết quả FFN
S-->>App: SSE chunk
end
```
```javascript
// bench.mjs - đo đúng 2 chỉ số: time-to-first-token và tốc độ generate
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "http://127.0.0.1:8080/v1", apiKey: "local" });
const start = performance.now();
let firstTokenAt = null;
let tokens = 0;
const stream = await client.chat.completions.create({
model: "local",
stream: true,
messages: [{ role: "user", content: "Giải thích cơ chế MVCC trong PostgreSQL, khoảng 400 từ." }],
});
for await (const chunk of stream) {
if (chunk.choices[0]?.delta?.content) {
firstTokenAt ??= performance.now();
tokens++;
}
}
const genSec = (performance.now() - firstTokenAt) / 1000;
console.log(`TTFT: ${(firstTokenAt - start).toFixed(0)} ms`);
console.log(`Generate: ${(tokens / genSec).toFixed(1)} tokens/s (${tokens} chunks)`);
```
Khi đọc benchmark trên mạng, cần tách bạch **prompt processing** (pp) và **token generation** (tg). Nhiều bài khoe con số pp hàng nghìn T/s, nhưng tg mới là tốc độ bạn cảm nhận được khi chat. Ngoài ra, hãy test với context thật của bạn. Ở 32K context, tốc độ thường giảm 20-40% so với lúc prompt ngắn. Nếu muốn có số liệu chuẩn, chạy `./build/bin/llama-bench` với `-p 512 -n 128` để so sánh giữa các cấu hình.
## Kết luận
Chạy model 100B+ trên GPU consumer là làm được thật, với điều kiện bạn hiểu rõ trade-off. Tóm tắt những gì mình rút ra:
1. **Tính trước khi tải.** Lấy tổng params × bits-per-weight để biết dung lượng RAM cần, lấy RAM bandwidth ÷ dung lượng expert active để ước lượng tốc độ trần. Đừng tin con số 100 T/s nếu máy bạn chỉ có DDR4 dual-channel.
2. **Ưu tiên RAM hơn GPU.** Với MoE offload, nâng từ 64GB lên 128GB DDR5 thường đáng tiền hơn nâng GPU.
3. **Dùng `--override-tensor` hoặc `--n-cpu-moe`** để đặt attention lên GPU và expert về CPU. Sau đó tinh chỉnh dần cho tới khi VRAM dùng khoảng 90%.
4. **Đặt `--threads` bằng số physical core** và quantize KV cache bằng `q8_0`. Hai thay đổi nhỏ này cho thêm tốc độ gần như miễn phí.
5. **Benchmark bằng workload thật của bạn**, đo riêng TTFT và tốc độ generate, đừng chỉ nhìn một con số tổng.
Nếu máy bạn có 32-64GB RAM, hãy bắt đầu với một MoE cỡ 30B (active khoảng 3B). Model cỡ này chạy rất mượt trên 4090, thậm chí cả trên 3060 12GB. Khi đã quen với cách tính và tinh chỉnh ở trên, nâng lên model lớn hơn chỉ còn là chuyện phần cứng.
All Rights Reserved