Skip to main content

เพิ่มประสิทธิภาพ Qwen3.8-Flash-Next บน DGX Spark: จาก 5 วินาที สู่ 1.4 วินาที TTFT

· 7 min read

บันทึก 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:

KMedian tok/sAccept lengthNote
237.931.27baseline
340.741.60+7.4% vs K=2
542.951.80+13.2% แต่ compute เพิ่ม
743.501.95marginal 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 ที่ชุมชนทำไว้:

  1. NVFP4 table as GPU parameter — โหลด PLE table เป็น tensor (ไม่ allocate 26.9 GiB)
  2. Loader drops shard pages — คืน memory ทันทีหลัง shard loaded
  3. Demand-paged table — mmap 8 shard files, GPU gather ผ่าน DLPack capsule
  4. Dual gather path — direct NVMe ตอน boot, mmap หลังจากนั้น
  5. fp8 KV on QSA — upstream PR ported, KV cache 1.8x vs bf16
  6. 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 คิดว่าผมทำผิดอะไรสักอย่าง จนตระหนักว่า:

Workloadtok/sNote
Simple prompt (thinking off)50best case ของ upstream
Code agent (thinking on, tool calling)31-38actual use ของผม
30k thinking request39-42reasoning 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 ก่อนจะรู้ว่ามันโกหก

อ้างอิง​

แชร์บทความ
☕

เนื้อหานี้มีประโยชน์ไหม? ช่วยสนับสนุนค่ากาแฟให้ผู้เขียนสักแก้ว

Buy Me a Coffee
Loading...