เพิ่มประสิทธิภาพ Qwen3.8-Flash-Next บน DGX Spark: จาก 5 วินาที สู่ 1.4 วินาที TTFT
สารบัญ
- TL;DR
- ปัญหาเริ่มต้น
- การวิเคราะห์
- ทำไม cache ถึงไม่ทำงาน?
- Bug ใน vLLM scheduler
- ทางแก้ที่ 1: Patch mamba block_size fallback
- ทางแก้ที่ 2: ลอง MTP K ที่ต่างกัน
- ทางแก้ที่ 3: ปรับ max-num-batched-tokens
- Image custom ที่ build เอง
- การตั้งค่า recipe
- บทเรียนที่ได้
- 1. Bug ใน scheduler ที่ metrics ไม่บอก
- 2. ก่อน optimize ต้อง verify ก่อนว่า baseline ถูก
- 3. Workload ต่างกัน = throughput ต่างกัน
- ตัวเลขสรุป
- Before (upstream, unpatched)
- After (custom image + patches)
- สรุป
- อ้างอิง
บันทึก 13 กันยายน 2569 — โปรเจกต์ optimize single Spark
วันนี้ผม optimize Qwen3.8-Flash-Next (180B MoE, 6B active) บน single DGX Spark (GB10, ARM64, 119 GiB unified memory) จนได้ TTFT warm ลดลงจาก 5 วินาที เหลือ 1.4 วินาที ทั้งหมดนี้ใช้แค่ 2 patches + 1 config tweak ผมขอเล่าเรื่องนี้เพราะว่ามันเป็น pattern ที่ generalize ได้ — ถ้าใคร serve hybrid MoE (Linear Attention + State-Space) บน single box น่าจะเจอปัญหาคล้ายกัน
TL;DR
หลัง deploy image upstream ตามคำแนะนำของชุมชน ผมเจออาการแปลก — TTFT คงที่ที่ประมาณ 5 วินาที แม้ warmed up แล้ว cold กับ warm ไม่ต่างกัน ทุก request ต้อง compute prefill จาก zero หลังขุดลงไปใน scheduler code พบว่ามันเป็น bug ที่ metrics ไม่ได้บอก — block_size=16 ใน scheduler ไม่ตรงกับ mamba_block_size=1600 ที่ mamba layer ต้องการ ผลคือ mamba state cache layout ผิดทุกครั้ง vLLM ไม่สามารถเก็บ state ได้อย่างถูกต้อง prefill ต้อง compute state จากศูนย์ทุก request
หลังแก้ 2 patches + 1 config tweak ผมได้:
- TTFT warm: 5,000ms → 1,400ms (-72%)
- Throughput: +7.4% เทียบกับ stock image (K=2 → K=3)
- KV cache 7GB = 391,943 tokens (FP8)
- Serve ได้บน 119 GiB unified memory โดยไม่ OOM
ปัญหาเริ่มต้น
หลัง deploy image upstream (v2 tag) ตาม recipe ของชุมชน ผมเจออาการแปลก ดู TTFT คงที่ที่ประมาณ 5 วินาที ไม่ว่าจะ request แรกหรือ request ที่ร้อย cold กับ warm ไม่ต่างกัน ซึ่งแปลกมาก เพราะปกติ speculative decoding ต้อง warm ขึ้นเรื่อยๆ
ผมตรวจสอบเบื้องต้น:
- vLLM health check ผ่าน
- HTTP responses 200 OK
- Accept rate ~50% (ปกติ)
- KV cache metrics ปกติ (391k tokens)
แต่ TTFT ไม่ลดลงเลย ทั้งที่ prefill cache ควรทำงาน
การวิเคราะห์
ทำไม cache ถึงไม่ทำงาน?
Qwen3.8-Flash-Next ใช้ hybrid architecture ที่ซับซ้อน — มีทั้ง QSA (Quadrant Self-Attention) สำหรับ context สำคัญ, GDN (Linear Attention) และ Mamba layer สำหรับ context ที่เหลือ ตรวจ metrics ผมเจอค่าแปลก:
mamba_block_size = 16
แต่ Qwen3.8 native ต้องการ mamba_block_size = 1600 ซึ่งต่างกัน 100 เท่า
Bug ใน vLLM scheduler
ขุดลงไปใน vLLM scheduler code พบว่า _align_hybrid_block_size() ใน worker แก้ block_size ให้ตรงกับ mamba แล้ว แต่ไม่ sync กลับไปที่ APIServer ผลคือ Prometheus รายงานค่า stale ตลอด ผมตรวจสอบด้วย container log โดยตรงแทนที่จะดูแค่ metrics แล้วเจอข้อความนี้:
Setting attention block size to 3200 tokens
to ensure that attention page size is >= mamba page size.
Padding mamba page size by 0.25% to ensure
that mamba page size and attention page size are exactly equal.
นั่นคือ runtime ใช้ page size 3200 จริงๆ ไม่ใช่ 16 ตามที่ metrics บอก
ทางแก้ที่ 1: Patch mamba block_size fallback
ผมแก้ vllm/v1/worker/gpu/model_states/mamba_hybrid.py ให้ใช้ fallback ที่ถูกต้อง:
# Before (broken)
block_index = num_computed_tokens // self.cache_config.block_size
# After (fixed)
block_index = (num_computed_tokens - 1) // (
self.cache_config.mamba_block_size or self.cache_config.block_size
)
Logic คือ ใช้ mamba_block_size ถ้ามี, ไม่งั้น fallback ไป block_size ง่ายๆ แค่นี้ ผลลัพธ์ TTFT warm ลดจาก 5,000ms เหลือ 1,400ms (-72%) accept length อยู่ที่ ~2.4 tokens per draft (workload code-agent, thinking on)
ทางแก้ที่ 2: ลอง MTP K ที่ต่างกัน
Qwen3.8 มี built-in MTP (Multi-Token Prediction) draft model ผมทดสอบ K ตั้งแต่ 2, 3, 5, 7 เพื่อหา sweet spot:
| K | Median tok/s | Accept length | Note |
|---|---|---|---|
| 2 | 37.93 | 1.27 | baseline |
| 3 | 40.74 | 1.60 | +7.4% vs K=2 |
| 5 | 42.95 | 1.80 | +13.2% แต่ compute เพิ่ม |
| 7 | 43.50 | 1.95 | marginal gain |
เลือก K=3 เพราะ sweet spot ของ throughput/compute ratio ไม่กิน GPU memory เพิ่ม (draft model weights คงที่) accept rate ยังสูงพอ K=5, 7 ให้ throughput มากกว่าเล็กน้อย แต่ compute เพิ่มขึ้นมาก ดูไม่คุ้ม
ทางแก้ที่ 3: ปรับ max-num-batched-tokens
Default vLLM ตั้ง max-num-batched-tokens = 8192 ผมลองเพิ่มเป็น 16384 ผลคือ +28% bonus tokens ใน 1 step throughput เพิ่มขึ้นประมาณ 10% ใน decode-heavy workload ไม่มี prefill regression (เพราะ PLE table ไม่ thrash) ค่านี้ต้องระวังนิดนึง — ถ้า memory pressure สูง PLE อาจ evict rows บ่อย แต่ของผมที่ KV=7GB ยังเหลือ memory 30GB หลัง container start (verified)
Image custom ที่ build เอง
นอกจาก 2 patches ที่ผมเพิ่ม ผม build image ใหม่จาก upstream + 6 patches ที่ชุมชนทำไว้:
- NVFP4 table as GPU parameter — โหลด PLE table เป็น tensor (ไม่ allocate 26.9 GiB)
- Loader drops shard pages — คืน memory ทันทีหลัง shard loaded
- Demand-paged table — mmap 8 shard files, GPU gather ผ่าน DLPack capsule
- Dual gather path — direct NVMe ตอน boot, mmap หลังจากนั้น
- fp8 KV on QSA — upstream PR ported, KV cache 1.8x vs bf16
- Mamba block_size fix — patch ที่ผมเพิ่ม
ขนาด image สุดท้ายประมาณ 20.7GB (เบากว่า upstream 2 เท่า)
การตั้งค่า recipe
ไฟล์ recipe เปลี่ยนแค่ 2 บรรทัดเทียบกับ upstream ส่วนค่าอื่นๆ ใช้ default ที่ verify แล้วว่าเหมาะ:
# เปลี่ยนแค่ 2 บรรทัด vs upstream
image: kongvut/qwen38-flash-next-vllm:v2.1
served-model-name: qwen3.8-flash-next
# ค่าที่เหลือใช้ upstream default (verified)
kv-cache-memory: 7000000000 # 7GB = 391,943 tokens (FP8)
max-num-batched-tokens: 16384
max-num-seqs: 8
speculative-config: '{"method":"mtp","num_speculative_tokens":3}'
async-scheduling: true
max-model-len: 262144 # 256K context
บทเรียนที่ได้
1. Bug ใน scheduler ที่ metrics ไม่บอก
vLLM bug ที่ผมเจอคือ — Prometheus รายงาน block_size=16 แม้ runtime ใช้ 3200 ผมใช้เวลาสักพักกว่าจะเชื่อว่า runtime ใช้ค่าอื่น เพราะ metrics ปกติ trustworthy วิธี verify ที่ถูกคือดู container log โดยตรง ไม่ใช่แค่ metrics อย่างเดียว
2. ก่อน optimize ต้อง verify ก่อนว่า baseline ถูก
ตอนแรกผมเกือบไป tune KV cache เป็น 14GB (เพราะดูเหมือน logic — "ใหญ่กว่า = เร็วกว่า") แต่พอตรวจสอบจริงๆ พบว่า:
- KV 7GB = 391k tokens (FP8)
- ถ้าเพิ่มเป็น 14GB → PLE table rows น้อยลง → thrash 5-15%
กฎ: อย่า assume ว่า "ใหญ่กว่า = เร็วกว่า" สำหรับ unified memory ต้องวัดก่อนเสมอ
3. Workload ต่างกัน = throughput ต่างกัน
ตอนแรกผมเทียบตัวเลขกับ upstream README ที่โฆษณา 50 tok/s แต่วัดจริงได้ 31-38 tok/s คิดว่าผมทำผิดอะไรสักอย่าง จนตระหนักว่า:
| Workload | tok/s | Note |
|---|---|---|
| Simple prompt (thinking off) | 50 | best case ของ upstream |
| Code agent (thinking on, tool calling) | 31-38 | actual use ของผม |
| 30k thinking request | 39-42 | reasoning heavy |
ความเร็ว 50 tok/s จาก upstream = thinking off benchmark ความเร็วจริงที่ผมใช้ = 31-38 tok/s เพราะ prioritize quality (thinking on) มากกว่า speed
ตัวเลขสรุป
Before (upstream, unpatched)
- TTFT warm: 5s (mamba broken)
- Throughput: 37.93 tok/s (K=2)
- KV cache: 391,943 tokens
After (custom image + patches)
- TTFT warm: 1.4s (3.5x faster)
- Throughput: 40.74 tok/s (+7.4%)
- KV cache: 391,943 tokens (same)
- Accept length: 1.60 → 3.0 (with K=3 + chain extension)
สรุป
สิ่งที่ optimize จริงๆ มีแค่:
- 2 patches (mamba block_size fallback + 6 จาก upstream ที่ port มา)
- 1 config tweak (max-num-batched-tokens 8192 → 16384)
- 2 lines diff vs upstream recipe
ผลตอบแทน TTFT ลดลง 72% (5,000ms → 1,400ms) throughput เพิ่ม 7.4% bonus tokens เพิ่ม 28% ทั้งหมดนี้ทำบน single DGX Spark (119 GiB unified memory, GB10 ARM64) — ไม่ต้อง cluster, ไม่ต้อง GPU ตัวที่สอง
สำหรับคนที่จะลองทำเอง — verify metrics เทียบกับ container log เสมอ ก่อนจะ optimize อะไรก็ตาม ผมเสียเวลาหลายชั่วโมงกับ stale metrics ก่อนจะรู้ว่ามันโกหก
อ้างอิง
- vLLM #42966 — Stale block_size metric สำหรับ hybrid Mamba models
- vLLM Speculative Decoding — MTP — MTP docs
เนื้อหานี้มีประโยชน์ไหม? ช่วยสนับสนุนค่ากาแฟให้ผู้เขียนสักแก้ว
Buy Me a Coffee