[LLM 10/10] Deployment: คุณไม่ได้รอการคำนวณ คุณรอน้ำหนักเดินทาง
เก้าบทที่ผ่านมาเราใส่ความรู้ สอนรูปแบบ จัด 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 — คือการโจมตีคอขวดเดียวกันนี้จากคนละมุม: ลดไบต์ที่ต้องเดินทาง หรือใช้การเดินทางหนึ่งรอบให้คุ้มขึ้น
แผนของบทนี้ตรงไปตรงมาและผมคิดว่ามันคือการทดลองที่ "พิสูจน์ตัวเอง" ได้หนักแน่นที่สุดในซีรีส์:
- คำนวณเพดาน ความเร็ว decode จาก datasheet ของ T4 — ยังไม่ต้องรันอะไรเลย
- วัดของจริง ด้วยเซิร์ฟเวอร์ที่ตั้งใจเขียนให้แย่ก่อน แล้วดูว่าห่างเพดานกี่เท่า
- ปิดช่องว่าง ทีละขั้น — static KV cache,
torch.compile, continuous batching ที่เขียนเองราว 60 บรรทัด — และวัดใหม่ทุกขั้นเพื่อให้รู้ว่าอะไรช่วยเท่าไหร่ - จ่ายด้วยอะไร — quantise เป็น int8 และ nf4 แล้ววัดทั้งความเร็ว และ คุณภาพบน KobEval-TH คู่กันเสมอ
3. สมการ (Equation)
3.1 งบหน่วยความจำตอนเสิร์ฟ
- = จำนวนพารามิเตอร์, = ไบต์ต่อน้ำหนัก (fp16 = 2)
- = จำนวนชั้น, = จำนวน key-value heads, = มิติต่อ head, = ไบต์ต่อค่าใน cache
- = ความยาว context, = จำนวน sequence ที่ถืออยู่พร้อมกัน
- เลข 2 ข้างหน้าคือ K กับ V อย่างละชุด ส่วน ตอน inference เล็กมากจนแทบตัดทิ้งได้
แทนค่าจริงจาก config.json ของ Qwen3-0.6B (, , , fp16):
ระวังกับดักที่คนพลาดบ่อยที่สุดตรงนี้: Qwen3 ใช้ grouped-query attention ต้องใช้
ไม่ใช่จำนวน attention heads (16) — ใช้ผิดตัวเดียว คำตอบคูณสองทันที
และเลข 112 KiB นี้ไม่ใช่เลขลอย ๆ ในบทความ มันถูก assert ไว้ใน test suite ของ widget บนเว็บนี้
(memoryMath.test.ts) — โค้ดของซีรีส์กับบทความถูกบังคับให้เห็นตรงกัน
ทีนี้ลองคูณด้วย context เต็มเพดานของโมเดล ( ตาม max_position_embeddings จริง):
สำหรับ sequence เดียว — ประมาณ 3.9 เท่าของน้ำหนักโมเดลทั้งก้อน (596M พารามิเตอร์ × 2 ไบต์ ≈ 1.19 GB) นี่คือเหตุผลเชิงเลขคณิตที่ context ยาวแพง: ตัวกินงบไม่ใช่โมเดล แต่คือความจำของบทสนทนา
3.2 สมการที่สำคัญที่สุดในบทนี้ — เพดานของ decode
การสร้าง token หนึ่งตัวต้องอ่านน้ำหนักทุกตัวหนึ่งรอบ บวก KV cache ที่สะสมมา ดังนั้น
320 GB/s คือแบนด์วิดท์ GDDR6 จาก datasheet ของ T4 ตรง ๆ — ~266 tok/s คือเพดานทฤษฎีที่ batch = 1 ไม่มีโค้ดใดบนโลกทำให้ T4 decode โมเดลนี้แบบ single-stream เร็วกว่านี้ได้ เพราะมันคือขีดจำกัดของสายไฟ ไม่ใช่ของซอฟต์แวร์
ลองเช็คว่าคอขวดคือแบนด์วิดท์จริงไหม: ที่ 266 tok/s งานคำนวณคือ GFLOP/token รวมเป็น ~0.32 TFLOPS หรือ ราว 0.5% ของ 65 TFLOPS (fp16) ที่ T4 ทำได้ — ชิปว่างงาน 99.5% โน้ตบุ๊กจะวัดตัวเลขจริง (ซึ่งต่ำกว่าเพดานมาก) แล้วหัวข้อ 8 จะอธิบายและปิดช่องว่างนั้นทีละชั้น
3.3 Little's Law — ขนาดของระบบที่ต้องรองรับ
จำนวนงานที่ค้างอยู่ในระบบ () เท่ากับอัตราที่งานเข้า () คูณเวลาเฉลี่ยต่องาน () — จริงเสมอ ไม่มีสมมติฐานเรื่องการแจกแจง ใช้ขนาดระบบได้ทันที: ถ้าผู้ใช้ยิงมา requests/วินาที และแต่ละคำตอบใช้เวลา วินาที ระบบต้องถือ requests พร้อมกัน — ที่ context เฉลี่ย 1,024 token นั่นคือ KV cache GB ที่ต้องจองไว้ตลอดเวลา สมการ 3.1 กับ 3.3 จึงเป็นสมการเดียวกันมองคนละมุม
3.4 INT8 symmetric quantisation
เก็บน้ำหนักเป็นจำนวนเต็ม 8 บิต () พร้อมตัวคูณ หนึ่งตัวต่อกลุ่ม แล้วคูณกลับตอนใช้ ความคลาดเคลื่อนต่อค่าไม่เกิน สิ่งที่ได้คือไบต์ต่อน้ำหนักลดครึ่ง — และเพราะสมการ 3.2 บอกว่าเวลาต่อ token แปรตามไบต์ที่ต้องอ่าน ในทางทฤษฎี decode จึงเร็วขึ้น 2 เท่า ส่วนในทางปฏิบัติ kernel ที่ต้อง dequantise อาจกินกำไรนั้นหมดหรือเกิน (โดยเฉพาะ LLM.int8() ของ bitsandbytes บน T4) — ต้องวัด ห้ามเดา และอย่าลืม: bitsandbytes ทำกับน้ำหนักเท่านั้น KV cache ยังเป็น fp16 ที่ 112 KiB/token เท่าเดิม
3.5 Prefill กับ Decode — สองระบอบที่ห้ามเอามาปนกัน
นิยาม arithmetic intensity = FLOPs ที่ทำได้ต่อไบต์ที่อ่าน แล้วเทียบกับ "จุดสัน" ของ GPU:
- Decode (batch 1): อ่านน้ำหนัก 2 ไบต์ ใช้ 2 FLOP → — ต่ำกว่าจุดสันราว 200 เท่า → bandwidth-bound
- Prefill: prompt ยาว token ประมวลผลพร้อมกัน น้ำหนักหนึ่งตัวถูกใช้ซ้ำ ครั้งต่อการอ่านหนึ่งรอบ → — 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
อ่านเนื้อหาเต็มในคอร์ส →