ข้ามไปยังเนื้อหาหลัก

[LLM 10/10] Deployment: คุณไม่ได้รอการคำนวณ คุณรอน้ำหนักเดินทาง

· อ่าน 8 นาที
Kobkrit Viriyayudhakorn
CEO, iApp Technology

เก้าบทที่ผ่านมาเราใส่ความรู้ สอนรูปแบบ จัด preference กลั่นโมเดล กันโมเดลพัง และวัดผลอย่างซื่อสัตย์ แต่โมเดลที่ดีที่สุดของเราก็ยังเป็นแค่ไฟล์ checkpoint ที่ไม่มีใครเรียกใช้ได้ บทสุดท้ายนี้เอามันขึ้นเสิร์ฟจริง และพิสูจน์ประโยคเดียวที่ควบคุมทุกการตัดสินใจของงานเสิร์ฟ LLM: การ decode ทีละ token ไม่ได้ถูกจำกัดด้วยพลังคำนวณ แต่ถูกจำกัดด้วยแบนด์วิดท์หน่วยความจำ — เราจะคำนวณเพดานความเร็วจาก datasheet ของ GPU ก่อนเขียนโค้ดแม้แต่บรรทัดเดียว แล้วค่อยวัดของจริงมาเทียบ

Open in Colab10_deployment.ipynb

1. ปัญหา (Problem statement)

คุณมีโมเดลที่ผ่านการเทรนและคัดเลือกมาแล้วจาก sweep ในบทที่ 9 คำถามต่อไปไม่ใช่คำถามเชิง machine learning เลย:

คำถามของคนจ่ายเงินตัวเลขที่ตอบ
ผู้ใช้หนึ่งคนต้องรอนานแค่ไหนp50 / p99 latency
รับผู้ใช้พร้อมกันได้กี่คนconcurrency (Little's law)
ต้องใช้ GPU กี่ตัวthroughput (tok/s)
ให้ context ยาวได้แค่ไหนงบ KV cache

วิธีที่คนส่วนใหญ่ตอบคำถามพวกนี้คือ "ลอง generate ดูแล้วมันก็เร็วดีนะ" ซึ่งไม่ใช่วิศวกรรม และวิธีที่บทความ benchmark ส่วนใหญ่ตอบ ก็มักไร้ความหมายด้วยเหตุผลที่เจาะจงมาก:

  • รายงาน tok/s โดยไม่บอก batch size — 27 tok/s ที่ batch 1 กับ 400 tok/s ที่ batch 32 อาจเป็นเครื่องเดียวกันเป๊ะ
  • เอาความเร็วช่วง prefill (อ่าน prompt) มาเฉลี่ยรวมกับ decode (สร้างคำตอบ) ทั้งที่สองช่วงนี้ชนคอขวดคนละตัว
  • รายงานความเร็วหลัง quantise โดยไม่รายงานคุณภาพ — นี่คือบาปต้นของ genre นี้ และเราจะพูดถึงมันอีกหลายครั้งในบทนี้

บทนี้จะตอบทุกคำถามข้างบนด้วยตัวเลขที่วัดเอง บนเครื่องฟรีเครื่องเดิมที่ใช้มาทั้งซีรีส์

2. เราจะทำอะไร (Solution)

เราจะเริ่มจากข้อเท็จจริงทางกายภาพหนึ่งข้อ แล้วให้ทุกอย่างไหลออกมาจากมัน

แนวคิดหลักของบทนี้

ตอน decode ทีละ token ที่ batch = 1 การสร้าง token หนึ่งตัวต้องอ่านน้ำหนักทุกตัวของโมเดลจาก HBM หนึ่งรอบเต็ม แต่ใช้การคำนวณต่อน้ำหนักแค่ ~2 FLOP — GPU จึงนั่งว่างรอข้อมูลเดินทาง คุณไม่ได้รอการคำนวณ คุณรอน้ำหนักเดินทางจากหน่วยความจำมาถึงชิป

ทุก optimisation ของการเสิร์ฟที่มีความหมาย — batching, quantisation, paged KV cache — คือการโจมตีคอขวดเดียวกันนี้จากคนละมุม: ลดไบต์ที่ต้องเดินทาง หรือใช้การเดินทางหนึ่งรอบให้คุ้มขึ้น

แผนของบทนี้ตรงไปตรงมาและผมคิดว่ามันคือการทดลองที่ "พิสูจน์ตัวเอง" ได้หนักแน่นที่สุดในซีรีส์:

  1. คำนวณเพดาน ความเร็ว decode จาก datasheet ของ T4 — ยังไม่ต้องรันอะไรเลย
  2. วัดของจริง ด้วยเซิร์ฟเวอร์ที่ตั้งใจเขียนให้แย่ก่อน แล้วดูว่าห่างเพดานกี่เท่า
  3. ปิดช่องว่าง ทีละขั้น — static KV cache, torch.compile, continuous batching ที่เขียนเองราว 60 บรรทัด — และวัดใหม่ทุกขั้นเพื่อให้รู้ว่าอะไรช่วยเท่าไหร่
  4. จ่ายด้วยอะไร — quantise เป็น int8 และ nf4 แล้ววัดทั้งความเร็ว และ คุณภาพบน KobEval-TH คู่กันเสมอ

3. สมการ (Equation)

3.1 งบหน่วยความจำตอนเสิร์ฟ

M  =  Pbwweights  +  2LnkvdhsBbkvKV cache  +  MactM \;=\; \underbrace{P\,b_w}_{\text{weights}} \;+\; \underbrace{2\,L\,n_{kv}\,d_h\,s\,B\,b_{kv}}_{\text{KV cache}} \;+\; M_{\text{act}}
  • PP = จำนวนพารามิเตอร์, bwb_w = ไบต์ต่อน้ำหนัก (fp16 = 2)
  • LL = จำนวนชั้น, nkvn_{kv} = จำนวน key-value heads, dhd_h = มิติต่อ head, bkvb_{kv} = ไบต์ต่อค่าใน cache
  • ss = ความยาว context, BB = จำนวน sequence ที่ถืออยู่พร้อมกัน
  • เลข 2 ข้างหน้าคือ K กับ V อย่างละชุด ส่วน MactM_{\text{act}} ตอน inference เล็กมากจนแทบตัดทิ้งได้

แทนค่าจริงจาก config.json ของ Qwen3-0.6B (L=28L=28, nkv=8n_{kv}=8, dh=128d_h=128, fp16):

KV/token  =  2×28×8×128×2  =  114,688 ไบต์  =  112 KiB พอดีเป๊ะ\text{KV/token} \;=\; 2 \times 28 \times 8 \times 128 \times 2 \;=\; 114{,}688 \text{ ไบต์} \;=\; 112\ \text{KiB พอดีเป๊ะ}

ระวังกับดักที่คนพลาดบ่อยที่สุดตรงนี้: Qwen3 ใช้ grouped-query attention ต้องใช้ nkv=8n_{kv}=8 ไม่ใช่จำนวน attention heads (16) — ใช้ผิดตัวเดียว คำตอบคูณสองทันที และเลข 112 KiB นี้ไม่ใช่เลขลอย ๆ ในบทความ มันถูก assert ไว้ใน test suite ของ widget บนเว็บนี้ (memoryMath.test.ts) — โค้ดของซีรีส์กับบทความถูกบังคับให้เห็นตรงกัน

ทีนี้ลองคูณด้วย context เต็มเพดานของโมเดล (s=40,960s = 40{,}960 ตาม max_position_embeddings จริง):

114,688×40,960  =  4,697,620,480 ไบต์    4.7 GB  (4.4 GiB)114{,}688 \times 40{,}960 \;=\; 4{,}697{,}620{,}480 \text{ ไบต์} \;\approx\; 4.7\ \text{GB} \;(\approx 4.4\ \text{GiB})

สำหรับ sequence เดียว — ประมาณ 3.9 เท่าของน้ำหนักโมเดลทั้งก้อน (596M พารามิเตอร์ × 2 ไบต์ ≈ 1.19 GB) นี่คือเหตุผลเชิงเลขคณิตที่ context ยาวแพง: ตัวกินงบไม่ใช่โมเดล แต่คือความจำของบทสนทนา

3.2 สมการที่สำคัญที่สุดในบทนี้ — เพดานของ decode

การสร้าง token หนึ่งตัวต้องอ่านน้ำหนักทุกตัวหนึ่งรอบ บวก KV cache ที่สะสมมา ดังนั้น

ttoken    Mweights+MKVBWtok/s    320 GB/s1.2 GB    266t_{\text{token}} \;\gtrsim\; \frac{M_{\text{weights}} + M_{\text{KV}}}{\text{BW}} \qquad\Longrightarrow\qquad \text{tok/s} \;\lesssim\; \frac{320\ \text{GB/s}}{1.2\ \text{GB}} \;\approx\; 266

320 GB/s คือแบนด์วิดท์ GDDR6 จาก datasheet ของ T4 ตรง ๆ — ~266 tok/s คือเพดานทฤษฎีที่ batch = 1 ไม่มีโค้ดใดบนโลกทำให้ T4 decode โมเดลนี้แบบ single-stream เร็วกว่านี้ได้ เพราะมันคือขีดจำกัดของสายไฟ ไม่ใช่ของซอฟต์แวร์

ลองเช็คว่าคอขวดคือแบนด์วิดท์จริงไหม: ที่ 266 tok/s งานคำนวณคือ 2P1.192P \approx 1.19 GFLOP/token รวมเป็น ~0.32 TFLOPS หรือ ราว 0.5% ของ 65 TFLOPS (fp16) ที่ T4 ทำได้ — ชิปว่างงาน 99.5% โน้ตบุ๊กจะวัดตัวเลขจริง (ซึ่งต่ำกว่าเพดานมาก) แล้วหัวข้อ 8 จะอธิบายและปิดช่องว่างนั้นทีละชั้น

3.3 Little's Law — ขนาดของระบบที่ต้องรองรับ

L  =  λWL \;=\; \lambda\,W

จำนวนงานที่ค้างอยู่ในระบบ (LL) เท่ากับอัตราที่งานเข้า (λ\lambda) คูณเวลาเฉลี่ยต่องาน (WW) — จริงเสมอ ไม่มีสมมติฐานเรื่องการแจกแจง ใช้ขนาดระบบได้ทันที: ถ้าผู้ใช้ยิงมา λ=5\lambda = 5 requests/วินาที และแต่ละคำตอบใช้เวลา W=2W = 2 วินาที ระบบต้องถือ L=10L = 10 requests พร้อมกัน — ที่ context เฉลี่ย 1,024 token นั่นคือ KV cache 10×1,024×112 KiB1.210 \times 1{,}024 \times 112\ \text{KiB} \approx 1.2 GB ที่ต้องจองไว้ตลอดเวลา สมการ 3.1 กับ 3.3 จึงเป็นสมการเดียวกันมองคนละมุม

3.4 INT8 symmetric quantisation

s  =  maxx127,xq  =  round ⁣(xs),x^  =  sxqs \;=\; \frac{\max|x|}{127},\qquad x_q \;=\; \mathrm{round}\!\left(\frac{x}{s}\right),\qquad \hat{x} \;=\; s\,x_q

เก็บน้ำหนักเป็นจำนวนเต็ม 8 บิต (xq[127,127]x_q \in [-127, 127]) พร้อมตัวคูณ ss หนึ่งตัวต่อกลุ่ม แล้วคูณกลับตอนใช้ ความคลาดเคลื่อนต่อค่าไม่เกิน s/2s/2 สิ่งที่ได้คือไบต์ต่อน้ำหนักลดครึ่ง — และเพราะสมการ 3.2 บอกว่าเวลาต่อ token แปรตามไบต์ที่ต้องอ่าน ในทางทฤษฎี decode จึงเร็วขึ้น 2 เท่า ส่วนในทางปฏิบัติ kernel ที่ต้อง dequantise อาจกินกำไรนั้นหมดหรือเกิน (โดยเฉพาะ LLM.int8() ของ bitsandbytes บน T4) — ต้องวัด ห้ามเดา และอย่าลืม: bitsandbytes ทำกับน้ำหนักเท่านั้น KV cache ยังเป็น fp16 ที่ 112 KiB/token เท่าเดิม

3.5 Prefill กับ Decode — สองระบอบที่ห้ามเอามาปนกัน

นิยาม arithmetic intensity II = FLOPs ที่ทำได้ต่อไบต์ที่อ่าน แล้วเทียบกับ "จุดสัน" ของ GPU:

Iridge  =  65 TFLOPS320 GB/s    203 FLOP/byteI_{\text{ridge}} \;=\; \frac{65\ \text{TFLOPS}}{320\ \text{GB/s}} \;\approx\; 203\ \text{FLOP/byte}
  • Decode (batch 1): อ่านน้ำหนัก 2 ไบต์ ใช้ 2 FLOP → I1I \approx 1 — ต่ำกว่าจุดสันราว 200 เท่า → bandwidth-bound
  • Prefill: prompt ยาว ss token ประมวลผลพร้อมกัน น้ำหนักหนึ่งตัวถูกใช้ซ้ำ ss ครั้งต่อการอ่านหนึ่งรอบ → IsI \approx s — prompt ยาวเกิน ~200 token ก็ compute-bound แล้ว

สองช่วงนี้คนละโลกกันโดยสิ้นเชิง: prefill โยน token ได้เป็นพันต่อวินาที decode ได้หลักสิบถึงร้อย ใครเอาสองอย่างนี้เฉลี่ยรวมเป็น "tok/s" ตัวเดียว ตัวเลขนั้นแทบไม่บอกอะไรเลย — โน้ตบุ๊กของเราจึงรายงาน TTFT (time to first token — วัด prefill) กับ ITL (inter-token latency — วัด decode) แยกกันเสมอ

เนื้อหาเต็มอยู่ในคอร์ส

บทความนี้คือประมาณ 30% แรกของบทเรียน — ส่วนที่เหลือ (การเตรียมสภาพแวดล้อม, การเตรียมข้อมูล, โค้ดหลัก, ผลลัพธ์จริง และบทสรุป) อยู่ในคอร์ส LLM Finetuning ซึ่งเรียนฟรี เพียงเข้าสู่ระบบด้วย Google

อ่านเนื้อหาเต็มในคอร์ส →