0

Running 100B+ MoE Models on a Single RTX 4090: A Practical Guide to Expert Offloading with llama.cpp

Tuần này trên Hacker News có một bài hơn 600 điểm: chạy một model MoE 125B tham số trên một con RTX 4090 mà vẫn đạt tốc độ sinh token rất đáng nể. Nghe như chuyện đùa, vì 4090 chỉ có 24GB VRAM, trong khi 125B tham số ở mức quantize 4-bit đã chiếm khoảng 70-75GB. Bí quyết không nằm ở phép màu nào cả, mà ở kiến trúc Mixture of Experts (MoE) và một kỹ thuật gọi là expert offloading. Mình đã thử kỹ thuật này với vài model MoE trên máy cá nhân, và bài này tổng hợp lại những gì thực sự có tác dụng, những gì chỉ tốn thời gian, cùng các lệnh bạn có thể copy về chạy luôn.

Tại sao MoE lại "lách" được giới hạn VRAM?

Với model dense (như Llama 3 70B), mỗi token sinh ra phải đi qua toàn bộ tham số. Nếu không nhét vừa VRAM, bạn phải đẩy một phần layer xuống CPU, và tốc độ sẽ rơi thẳng xuống còn vài token/s vì băng thông RAM chậm hơn VRAM cả chục lần.

MoE thì khác. Mỗi layer có một router chọn ra vài expert (thường là 8 trên tổng 128 hoặc 256 expert) để xử lý token hiện tại. Một model 125B tổng tham số có thể chỉ có khoảng 10-15B tham số active cho mỗi token. Phần nặng nhất (các expert FFN) thì to nhưng mỗi lần chỉ dùng một ít; còn phần dùng liên tục (attention, embedding, shared expert, router) lại khá nhỏ.

Ý tưởng chính: để phần dùng liên tục trên GPU, đẩy các expert xuống RAM hệ thống. CPU chỉ phải tính một lượng nhỏ expert được chọn cho mỗi token, nên tốc độ vẫn chấp nhận được.

flowchart LR
    T[Token input] --> A[Attention + KV cache<br/>GPU VRAM]
        A --> R[Router<br/>GPU]
            R -->|chọn top-k| E1[Expert 3<br/>CPU RAM]
                R -->|chọn top-k| E2[Expert 41<br/>CPU RAM]
                    R --> S[Shared expert<br/>GPU]
                        E1 --> M[Merge output]
                            E2 --> M
                                S --> M
                                    M --> N[Layer tiếp theo]
                                    ```
                                    
                                    Điểm mấu chốt: KV cache và attention là thứ ăn nhiều băng thông nhất khi context dài, nên chúng **phải** nằm trên GPU. Expert thì ngược lại: chiếm nhiều dung lượng nhưng mỗi token chỉ đọc một phần nhỏ.
                                    
                                    ## Chuẩn bị phần cứng và build llama.cpp
                                    
                                    Cấu hình mình dùng để test:
                                    
                                    - GPU: RTX 4090 24GB
                                    - RAM: 128GB DDR5-5600 (2 thanh 64GB, **dual channel**). Tối thiểu nên có 96GB cho model khoảng 120B ở Q4.
                                    - CPU: Ryzen 9 7950X
                                    - NVMe Gen4 để load model nhanh
                                    
                                    Một lưu ý quan trọng: với expert offloading, **băng thông RAM quyết định tốc độ nhiều hơn số core CPU**. Cắm 4 thanh RAM trên nền tảng consumer thường làm bus tụt xuống 4800 hoặc thấp hơn, nên 2 thanh dung lượng lớn sẽ tốt hơn 4 thanh nhỏ.
                                    
                                    Build llama.cpp với CUDA:
                                    
                                    ```bash
                                    git clone https://github.com/ggml-org/llama.cpp
                                    cd llama.cpp
                                    
                                    cmake -B build \
                                      -DGGML_CUDA=ON \
                                        -DGGML_CUDA_F16=ON \
                                          -DCMAKE_CUDA_ARCHITECTURES=89 \
                                            -DCMAKE_BUILD_TYPE=Release
                                            
                                            cmake --build build --config Release -j $(nproc)
                                            
                                            # Kiểm tra GPU được nhận
                                            ./build/bin/llama-cli --list-devices
                                            ```
                                            
                                            `CMAKE_CUDA_ARCHITECTURES=89` là compute capability của dòng Ada (4090). Đặt đúng giá trị này giúp build nhanh hơn và tránh biên dịch kernel cho những kiến trúc bạn không dùng.
                                            
                                            Về file model: tải bản GGUF đã quantize sẵn, ưu tiên các bản quant dạng **dynamic** hoặc **imatrix** (Q4_K_M, IQ4_XS). Với MoE, bản IQ4_XS thường cho chất lượng gần Q4_K_M nhưng nhẹ hơn 10-15%, tức là tiết kiệm thêm RAM.
                                            
                                            ## Lệnh chạy: `--n-cpu-moe` và `-ot`
                                            
                                            Có hai cách để đẩy expert xuống CPU trong llama.cpp.
                                            
                                            **Cách 1: `--n-cpu-moe N`** (đơn giản, nên dùng trước). Flag này giữ expert của N layer đầu tiên trên CPU, phần còn lại lên GPU.
                                            
                                            **Cách 2: `-ot` (override-tensor)** dùng regex để chỉ định chính xác tensor nào nằm ở đâu. Cách này linh hoạt hơn khi bạn muốn tinh chỉnh từng chút.
                                            
                                            ```bash
                                            # Cách 1: đơn giản
                                            ./build/bin/llama-server \
                                              -m ./models/moe-125b-IQ4_XS.gguf \
                                                -ngl 99 \
                                                  --n-cpu-moe 40 \
                                                    -c 32768 \
                                                      -fa on \
                                                        --cache-type-k q8_0 --cache-type-v q8_0 \
                                                          -t 16 \
                                                            --host 0.0.0.0 --port 8080
                                                            
                                                            # Cách 2: toàn bộ expert xuống CPU, rồi kéo dần lên GPU
                                                            ./build/bin/llama-server \
                                                              -m ./models/moe-125b-IQ4_XS.gguf \
                                                                -ngl 99 \
                                                                  -ot "blk\.(4[0-9])\.ffn_.*_exps\.=CUDA0" \
                                                                    -ot "\.ffn_.*_exps\.=CPU" \
                                                                      -c 32768 -fa on \
                                                                        --cache-type-k q8_0 --cache-type-v q8_0
                                                                        ```
                                                                        
                                                                        Giải thích nhanh:
                                                                        
                                                                        - `-ngl 99`: đưa **tất cả** layer lên GPU. Nghe vô lý vì không vừa, nhưng các rule `-ot` / `--n-cpu-moe` sẽ "rút" phần expert ra, chỉ còn attention và shared weights trên GPU.
                                                                        - `-fa on`: bật Flash Attention, giảm đáng kể VRAM cho KV cache.
                                                                        - `--cache-type-k/v q8_0`: quantize KV cache xuống 8-bit, gần như không ảnh hưởng chất lượng nhưng tiết kiệm một nửa VRAM so với f16.
                                                                        - Thứ tự `-ot` quan trọng: rule khớp trước được áp dụng trước, nên đặt rule "kéo lên GPU" lên trên rule "đẩy xuống CPU".
                                                                        - `-t 16`: số thread nên bằng số **physical core**, không phải logical. Tăng quá mức sẽ chậm hơn vì tranh chấp băng thông RAM.
                                                                        
                                                                        Quy trình tune của mình: bắt đầu với `--n-cpu-moe` bằng tổng số layer, xem VRAM còn dư bao nhiêu bằng `nvidia-smi`, rồi giảm dần N cho đến khi VRAM dùng khoảng 22-23GB. Chừa 1-2GB để tránh OOM khi context đầy.
                                                                        
                                                                        ## Đo đạc thay vì đoán
                                                                        
                                                                        Đừng tin con số trên mạng, kể cả con số trong bài HN kia. Tốc độ phụ thuộc rất nhiều vào RAM, độ dài context và loại quant. Dùng `llama-bench` để đo, rồi viết một script nhỏ đo trải nghiệm thật qua API:
                                                                        
                                                                        ```python
                                                                        import time, json, requests
                                                                        
                                                                        URL = "http://localhost:8080/v1/chat/completions"
                                                                        prompt = "Giải thích cơ chế routing trong Mixture of Experts, khoảng 300 từ."
                                                                        
                                                                        def bench(n_runs=3):
                                                                            for i in range(n_runs):
                                                                                    start = time.perf_counter()
                                                                                            first_token_at = None
                                                                                                    n_tokens = 0
                                                                                                            with requests.post(URL, json={
                                                                                                                        "messages": [{"role": "user", "content": prompt}],
                                                                                                                                    "max_tokens": 512,
                                                                                                                                                "stream": True,
                                                                                                                                                        }, stream=True) as r:
                                                                                                                                                                    for line in r.iter_lines():
                                                                                                                                                                                    if not line or not line.startswith(b"data: "):
                                                                                                                                                                                                        continue
                                                                                                                                                                                                                        data = line[6:]
                                                                                                                                                                                                                                        if data == b"[DONE]":
                                                                                                                                                                                                                                                            break
                                                                                                                                                                                                                                                                            delta = json.loads(data)["choices"][0]["delta"]
                                                                                                                                                                                                                                                                                            if delta.get("content"):
                                                                                                                                                                                                                                                                                                                if first_token_at is None:
                                                                                                                                                                                                                                                                                                                                        first_token_at = time.perf_counter()
                                                                                                                                                                                                                                                                                                                                                            n_tokens += 1
                                                                                                                                                                                                                                                                                                                                                                    gen_time = time.perf_counter() - first_token_at
                                                                                                                                                                                                                                                                                                                                                                            print(f"Run {i+1}: TTFT={first_token_at - start:.2f}s, "
                                                                                                                                                                                                                                                                                                                                                                                          f"decode={n_tokens / gen_time:.1f} tok/s")
                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                          bench()
                                                                                                                                                                                                                                                                                                                                                                                          ```
                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                          Hai chỉ số cần nhìn: **TTFT** (time to first token, phản ánh tốc độ prefill) và **decode tok/s**. Với expert offloading, prefill thường là điểm yếu vì phải đẩy cả đoạn prompt dài qua expert trên CPU. Nếu use case của bạn là RAG với prompt dài, hãy tăng `-b` và `-ub` (batch size, ví dụ `-ub 2048`) để llama.cpp chuyển phần tính expert lúc prefill sang GPU hiệu quả hơn.
                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                          ```mermaid
                                                                                                                                                                                                                                                                                                                                                                                          flowchart TD
                                                                                                                                                                                                                                                                                                                                                                                              S[Bắt đầu: n-cpu-moe = tổng số layer] --> C{VRAM còn dư > 2GB?}
                                                                                                                                                                                                                                                                                                                                                                                                  C -->|Có| D[Giảm n-cpu-moe đi 2-4]
                                                                                                                                                                                                                                                                                                                                                                                                      D --> C
                                                                                                                                                                                                                                                                                                                                                                                                          C -->|Không| B[Chạy llama-bench + script đo]
                                                                                                                                                                                                                                                                                                                                                                                                              B --> P{Prefill quá chậm?}
                                                                                                                                                                                                                                                                                                                                                                                                                  P -->|Có| U[Tăng -ub, giảm context]
                                                                                                                                                                                                                                                                                                                                                                                                                      P -->|Không| OK[Chốt cấu hình]
                                                                                                                                                                                                                                                                                                                                                                                                                          U --> B
                                                                                                                                                                                                                                                                                                                                                                                                                          ```
                                                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                                                          Vài cái bẫy mình đã dính:
                                                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                                                          - **Swap/mmap**: nếu RAM không đủ, OS sẽ đọc expert từ ổ đĩa và tốc độ rơi xuống dưới 1 tok/s. Kiểm tra bằng `free -h` trong lúc chạy; nếu thấy swap tăng thì model quá lớn so với RAM.
                                                                                                                                                                                                                                                                                                                                                                                                                          - **Windows + WSL2**: WSL2 mặc định chỉ cấp 50% RAM. Sửa trong `.wslconfig` với `memory=110GB`.
                                                                                                                                                                                                                                                                                                                                                                                                                          - **Context quá dài**: 128K context với KV cache q8_0 vẫn ngốn nhiều GB VRAM. Hãy bắt đầu từ 32K, chỉ tăng khi thực sự cần.
                                                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                                                          ## Kết luận
                                                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                                                          Expert offloading biến bài toán "cần cụm GPU 80GB" thành "cần một GPU tầm trung và nhiều RAM", và RAM DDR5 thì rẻ hơn VRAM rất nhiều. Với dev Việt Nam muốn chạy model lớn local để giữ dữ liệu nhạy cảm (code nội bộ, tài liệu khách hàng) mà không tốn tiền thuê GPU cloud, đây là hướng rất đáng đầu tư.
                                                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                                                          Những việc bạn có thể làm ngay:
                                                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                                                          1. **Kiểm tra RAM trước GPU**: muốn chạy MoE khoảng 100B+ ở Q4 thì cần ít nhất 96GB RAM, ưu tiên 2 thanh dual channel tốc độ cao.
                                                                                                                                                                                                                                                                                                                                                                                                                          2. **Chỉ dùng model MoE** cho kỹ thuật này. Model dense offload xuống CPU sẽ chậm, không có cách nào cứu.
                                                                                                                                                                                                                                                                                                                                                                                                                          3. **Bắt đầu với `--n-cpu-moe`**, chỉ chuyển sang `-ot` khi cần tinh chỉnh sâu.
                                                                                                                                                                                                                                                                                                                                                                                                                          4. **Luôn bật `-fa on` và KV cache q8_0**, đây là hai thay đổi tiết kiệm VRAM nhiều nhất mà gần như không mất chất lượng.
                                                                                                                                                                                                                                                                                                                                                                                                                          5. **Tự đo bằng `llama-bench` và script streaming**, tách riêng prefill và decode, rồi tune theo đúng use case của bạn thay vì chạy theo con số trên HN.
                                                                                                                                                                                                                                                                                                                                                                                                                          
                                                                                                                                                                                                                                                                                                                                                                                                                          Phần cứng consumer năm 2026 đã đủ mạnh để chạy model mà hai năm trước chỉ data center mới chạy nổi. Khác biệt giữa "chạy được" và "chạy tốt" giờ chỉ nằm ở vài flag cấu hình đúng.

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí