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

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

· อ่าน 26 นาที
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) แยกกันเสมอ

4. เห็นภาพสมการ (Visualize)

Context คือตัวกินงบ ไม่ใช่โมเดล

กราฟหน่วยความจำรวมตอนเสิร์ฟเทียบกับความยาว context ที่ batch 1, 4, 16 พร้อมเส้นเพดาน 16 GB ของ T4 และเส้นน้ำหนักโมเดล 1.19 GBกราฟหน่วยความจำรวมตอนเสิร์ฟเทียบกับความยาว context ที่ batch 1, 4, 16 พร้อมเส้นเพดาน 16 GB ของ T4 และเส้นน้ำหนักโมเดล 1.19 GB

Figure 10.1งบหน่วยความจำตอนเสิร์ฟจากสมการ 3.1 — คำนวณจากค่าจริงใน config ของ Qwen3-0.6B ทั้งเส้น: KV โต 112 KiB ต่อ token ต่อ sequence และที่ batch 16 การ์ดเต็มตั้งแต่ context ~8,000 token

สังเกตจุดสี่เหลี่ยมขวาล่าง: sequence เดียวที่ context เต็ม 40,960 token ใช้ KV 4.7 GB — เกือบสี่เท่าของน้ำหนักโมเดลเอง และเส้น batch 16 ชนเพดาน 16 GB ตั้งแต่ ~8,070 token นี่คือเหตุผลที่ผู้ให้บริการ LLM คิดเงินตามความยาว context

Batching: การอ่านน้ำหนักหนึ่งรอบ แลกได้หลาย token

กราฟ throughput รวมที่เพิ่มขึ้นตาม batch size ลู่เข้าหาเพดานแบนด์วิดท์ 266 tok/s ขณะที่ latency ต่อ request แย่ลงกราฟ throughput รวมที่เพิ่มขึ้นตาม batch size ลู่เข้าหาเพดานแบนด์วิดท์ 266 tok/s ขณะที่ latency ต่อ request แย่ลง

Figure 10.2เพดาน 266 tok/s จากสมการ 3.2 (เส้นประแดง — คำนวณจาก datasheet จริง) กับแบบจำลองการเฉลี่ยต้นทุนคงที่ของ batching (เส้นทึบ — ภาพประกอบกลไก ตัวเลขจริงมาจากโน้ตบุ๊ก)

อ่านกราฟนี้ให้ครบทั้งสองแกน: เส้นน้ำเงินขึ้น (ดี) แต่เส้นส้มก็ขึ้นด้วย (แย่) batching ไม่ใช่ของฟรี — มันคือการขาย latency ของผู้ใช้แต่ละคน ซื้อ throughput ของระบบ ตำแหน่งที่ควรอยู่บนกราฟนี้เป็นการตัดสินใจทางธุรกิจ ไม่ใช่ทางเทคนิค

คิวระเบิดก่อนเซิร์ฟเวอร์เต็ม

กราฟ latency เปอร์เซ็นไทล์ 50 และ 99 เทียบกับอัตราการมาถึงของ request แสดงการระเบิดของ p99 เมื่อเข้าใกล้จุดอิ่มตัวกราฟ latency เปอร์เซ็นไทล์ 50 และ 99 เทียบกับอัตราการมาถึงของ request แสดงการระเบิดของ p99 เมื่อเข้าใกล้จุดอิ่มตัว

Figure 10.3p50/p99 ของคิวแบบ M/M/1 คำนวณจากสูตร -ln(1-q)/(μ-λ) — เป็นแบบจำลองเชิงคณิตศาสตร์ ไม่ใช่ผลวัด แต่รูปทรง 'หัวเข่า' นี้จะโผล่ในผลวัดจริงของหัวข้อ 8

จุดที่ต้องจำ: ที่ utilisation 80% p99 พุ่งทะลุ 11 วินาทีแล้ว ทั้งที่เซิร์ฟเวอร์ "ยังว่าง" อยู่ 20% ระบบจริงจึงต้องเผื่อ headroom เสมอ — ใครขนาดระบบให้พอดี 100% ของ throughput ที่วัดได้ กำลังออกแบบระบบที่ p99 เป็นอนันต์

ลองเล่นกับงบเสิร์ฟด้วยตัวเอง — สลับไปโหมด Serving แล้วปรับ context กับจำนวน request พร้อมกัน ดูว่าชนเพดาน 16 GB เมื่อไหร่:

596.0M parameters, derived from config.json
Weight dtype
Run mode
Trades about 30% more compute for a large drop in activation memory.
weights: 1.11 GiBactivations: 68.00 KiBkvCache: 112.00 MiB16 GB — Colab T40481216GiB
  • Weights1.11 GiB
  • Gradients
  • Optimizer state
  • Activations68.00 KiB
  • KV cache112.00 MiB
Total VRAM1.22 GiB14.78 GiB to spare
Trainable params00.00%
KV cache per token112 KiB2 x 28 x 8 x 128
Full context KV4.38 GiB41.0K tok

It fits.This run needs 1.22 GiB and leaves 14.78 GiB of headroom on a free Colab T4.

5. เตรียมสภาพแวดล้อม (Environment)

เปิด Colab เลือก Runtime → Change runtime type → T4 GPU (แผนฟรีพอ — เป็นครั้งสุดท้ายของซีรีส์)

คำเตือนประจำซีรีส์ — รอบสุดท้าย และคุณควรทายเนื้อหาได้เองแล้ว

ถ้าตามมาครบเก้าบท คุณคงท่องได้ขึ้นใจ: T4 คือ Turing (SM 7.5) ไม่มี bfloat16 ไม่มี FlashAttention-2 มุกประจำซีรีส์นี้ไม่เคยเป็นแค่มุก — บทนี้มันจะกัดอีกสองครั้ง:

torch_dtype=torch.float16      # ไม่ใช่ bfloat16 — ครั้งสุดท้ายที่คุณจะอ่านบรรทัดนี้จากผม
attn_implementation="sdpa" # ไม่ใช่ flash_attention_2
# และล่วงหน้าไว้เลย: vLLM ต้อง dtype="half" — ถ้าปล่อย auto มันจะเจอ bf16 ใน config แล้วปฏิเสธทันที
cap = torch.cuda.get_device_capability(0)
print("compute capability:", cap) # T4 = (7, 5)
print("native bf16:", cap[0] >= 8) # T4 -> False
print("torch says :", torch.cuda.is_bf16_supported()) # T4 -> True (นับ emulation!)
is_bf16_supported() โกหกคุณบน T4

torch รุ่นใหม่ตอบ True บน T4 เพราะนับ การจำลอง (emulation) ว่ารองรับด้วย ซึ่งช้ากว่า fp16 มาก ให้เช็ค compute capability ≥ 8.0 (Ampere ขึ้นไป) แทน — นี่คือบั๊กจริงที่เจอตอนรันโน้ตบุ๊กบน Colab จริง ๆ

Stage 1 — Merge adapter แล้ว export (~2 นาที)

ตลอดซีรีส์เราเทรนด้วย LoRA ซึ่งตอนเสิร์ฟจะกลายเป็นภาระ: ทุก forward ต้องคูณ BAxBAx เพิ่มอีกหนึ่งทอด ข่าวดีคือ LoRA merge กลับเข้าน้ำหนักฐานได้แบบปิดรูป: W=W+αrBAW' = W + \tfrac{\alpha}{r}BA — เสิร์ฟแล้วต้นทุนเท่าโมเดลฐานเป๊ะ

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")
base = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen3-0.6B",
torch_dtype=torch.float16,
attn_implementation="sdpa",
).cuda()

policy = PeftModel.from_pretrained(base, "qwen3-th-lora-best") # ตัวชนะจาก sweep บทที่ 9
merged = policy.merge_and_unload() # W' = W + (α/r)·B·A
merged.save_pretrained("qwen3-th-serve")
tok.save_pretrained("qwen3-th-serve")

อย่าเชื่อว่า merge แล้วได้โมเดลเดิม — พิสูจน์ ด้วยการเทียบ logits:

x = tok("การไฟฟ้าส่วนภูมิภาคมีหน้าที่อะไร", return_tensors="pt").to("cuda")
with torch.no_grad():
d = (policy(**x).logits - merged(**x).logits).abs().max().item()
print(f"max |Δlogit| = {d:.4f}") # ระดับ ~1e-3 — ใกล้ศูนย์แต่ไม่ใช่ศูนย์เป๊ะ

ค่าไม่เป็นศูนย์เป๊ะเพราะการบวก αrBA\tfrac{\alpha}{r}BA เข้า WW ใน fp16 มีการปัดเศษ — ถ้าเจอระดับ 1e-3 คือปกติ ถ้าเจอระดับ 1.0 แปลว่าโหลด adapter ผิดตัวหรือ dtype ไม่ตรง

ราคาที่จ่าย: ไฟล์ adapter ~20 MB (10.1M พารามิเตอร์ที่ r = 16) กลายเป็นน้ำหนักเต็ม ~1.2 GB โตขึ้น ~60 เท่า แลกกับการที่ทุกเครื่องมือเสิร์ฟ (รวมถึง vLLM) มองเห็นมันเป็นโมเดลธรรมดาหนึ่งก้อน

6. เตรียมข้อมูล (Data)

"ข้อมูล" ของบทนี้ไม่ใช่ชุดเทรน แต่คือ workload — และ workload ที่วัดผิดวิธีให้ p99 ที่สวยเกินจริงเสมอ

  • พรอมป์ตภาษาไทย 60 ข้อ จากพูลเดียวกับ KobEval-TH — สั้น/กลาง/ยาวคละกัน เพื่อให้ prefill มีความหลากหลายแบบงานจริง
  • ชุดวัดคุณภาพ TH-KNOW จากบทที่ 9 — ใช้ซ้ำกับทุก configuration ที่เราวัดความเร็ว
  • ตัวยิงโหลดแบบ open-loop: เวลามาถึงของ request สุ่มแบบ Poisson แล้วยิงตามนัดไม่ว่าเซิร์ฟเวอร์จะพร้อมหรือไม่
import numpy as np

rng = np.random.default_rng(42)
gaps = rng.exponential(1.0 / LAM, size=N_REQUESTS) # Poisson process: ช่องว่าง ~ Exp(λ)
arrivals = np.cumsum(gaps) # ตารางเวลายิง — ยึดตามนี้เคร่งครัด
ทำไมต้อง open-loop — กับดักชื่อ coordinated omission

ถ้า load generator ยิงทีละคำขอแล้วรอจนได้คำตอบก่อนยิงต่อ (closed-loop) เซิร์ฟเวอร์ที่ช้าจะทำให้คุณยิงช้าลงโดยอัตโนมัติ คิวเลยไม่มีวันสะสม และ p99 ที่วัดได้จะสวยหลอก ๆ เพราะเครื่องมือวัด "เกรงใจ" ระบบที่กำลังวัดอยู่ การยิงตามตารางเวลาที่สุ่มไว้ล่วงหน้า — แม้ request ก่อนหน้ายังไม่เสร็จ — คือวิธีเดียวที่หัวเข่าในรูป 10.3 จะโผล่ให้เห็นจริง

ทุก request บันทึกสามค่า: TTFT, ITL เฉลี่ย, และจำนวน token — แล้วสรุปเป็น p50/p99 ต่อ configuration

7. โค้ดหลัก (Main code)

7.1 Stage 2 — Baseline ที่ตั้งใจให้แย่ (~4 นาที)

from fastapi import FastAPI
import threading, uvicorn

app = FastAPI()

@app.post("/generate")
def generate(body: dict):
ids = tok(body["prompt"], return_tensors="pt").to("cuda")
out = merged.generate(**ids, max_new_tokens=128, do_sample=False)
return {"text": tok.decode(out[0, ids.input_ids.shape[1]:],
skip_special_tokens=True)}

threading.Thread(
target=uvicorn.run, args=(app,),
kwargs=dict(host="127.0.0.1", port=8000, log_level="warning"),
daemon=True,
).start()

เซิร์ฟเวอร์นี้ผิดทุกข้อที่บทนี้สอน: รับทีละ request, ไม่มี batching, KV cache ถูกจองใหม่ทุกครั้ง, Python วนลูปต่อ token — และนั่นคือหน้าที่ของมัน มันคือ baseline ที่ทุกการปรับปรุงจะถูกเทียบกลับมา ถ้าไม่วัดจุดเริ่มต้น คำว่า "เร็วขึ้น 5 เท่า" ก็เป็นแค่คำโฆษณา

Colab ฆ่าเซิร์ฟเวอร์เบื้องหลังเมื่อหลุดการเชื่อมต่อ

thread ที่รัน uvicorn อยู่ได้เท่าที่ session ยังอยู่ — ปิดแท็บ พักหน้าจอนาน หรือโดนเก็บ runtime เมื่อไหร่ เซิร์ฟเวอร์หายเงียบ ๆ โน้ตบุ๊กจึงวัดให้จบเป็นช่วงสั้น ๆ แล้วเขียนผลลง results.json ทันทีทุกช่วง อย่าออกแบบการวัดที่ต้องรันข้ามชั่วโมงบน Colab ฟรี

7.2 Stage 3a — Static KV cache + torch.compile

generate ปกติขยาย KV cache ทีละ token ทำให้ shape เปลี่ยนตลอดและ compile ไม่ได้ จอง cache เต็มก้อนล่วงหน้า (static) แล้ว shape จะนิ่งพอให้ torch.compile จับทั้งกราฟลง CUDA graph — กำจัด overhead ตัวใหญ่ที่สุดของ baseline คือการปล่อย kernel ทีละตัวจาก Python

merged.generation_config.cache_implementation = "static"
fast = torch.compile(merged, mode="reduce-overhead")

warm = tok("อุ่นเครื่อง", return_tensors="pt").to("cuda")
for _ in range(3): # อุ่นเครื่องก่อนจับเวลาเสมอ
fast.generate(**warm, max_new_tokens=8)
ไม่อุ่นเครื่องก่อนวัด = ตัวเลขทั้งชุดเป็นขยะ

การเรียก torch.compile ครั้งแรกใช้เวลาหลายสิบวินาทีถึงระดับนาทีในการ trace และ compile ถ้าเวลานั้นปนเข้าไปในการจับเวลา คุณจะสรุปว่า compile "ทำให้ช้าลง" ซึ่งกลับด้านกับความจริง กติกาของโน้ตบุ๊ก: รันทิ้งอย่างน้อย 3 รอบก่อนเริ่มนาฬิกา ทุก configuration ไม่มีข้อยกเว้น

7.3 Stage 3b — Continuous batching เขียนเองราว 60 บรรทัด

สมการ 3.2 บอกว่าการอ่านน้ำหนักหนึ่งรอบคือต้นทุนก้อนใหญ่ — batching คือการหาร token หลายตัวด้วยต้นทุนก้อนเดียวกัน แต่ static batching (รอให้ครบ BB ค่อยเริ่ม แล้วรอให้ตัวที่ยาวที่สุดจบ) ทิ้งที่ว่างมหาศาล continuous batching จึงรับ request ใหม่เข้า batch ทันทีที่มีที่ว่าง — หัวใจอยู่ในลูปนี้:

from collections import deque
queue, running, MAX_BATCH = deque(), [], 16

while queue or running:
while queue and len(running) < MAX_BATCH:
running.append(Sequence(queue.popleft())) # รับเข้าระหว่างทาง ไม่รอ batch เดิมจบ

step(running) # forward หนึ่งก้าวให้ทุก sequence — อ่านน้ำหนัก 1 รอบ ได้ B token

running = [s for s in running if not s.done] # ตัวที่จบออกทันที คืนที่ให้คิว

ฉบับเต็ม (~60 บรรทัด รวมการจัด position id และ mask ของแต่ละ sequence) อยู่ในโน้ตบุ๊ก มันไม่ใช่ vLLM — ไม่มี paged memory, ไม่มี prefix cache — แต่มันพิสูจน์กลไกด้วยโค้ดที่อ่านจบในหนึ่งหน้าจอ และการปรับปรุงที่วัดได้ของมันอธิบายที่มาได้ทุกเปอร์เซ็นต์ ซึ่งสำคัญกว่าความหรูหราในบทเรียนนี้

7.4 Quantisation — int8 กับ nf4 และราคาที่แท้จริง

from transformers import BitsAndBytesConfig

int8 = AutoModelForCausalLM.from_pretrained(
"qwen3-th-serve",
quantization_config=BitsAndBytesConfig(load_in_8bit=True),
device_map={"": 0},
)

nf4 = AutoModelForCausalLM.from_pretrained(
"qwen3-th-serve",
quantization_config=BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
),
device_map={"": 0},
)
แผนภูมิแท่งเปรียบเทียบ VRAM และความเร็ว decode ของ fp16, int8, nf4 พร้อมช่องว่างสำหรับผลต่างความแม่น TH-KNOWแผนภูมิแท่งเปรียบเทียบ VRAM และความเร็ว decode ของ fp16, int8, nf4 พร้อมช่องว่างสำหรับผลต่างความแม่น TH-KNOW

Figure 10.4ซ้าย: VRAM ของน้ำหนัก คำนวณจริงจาก config (bitsandbytes เก็บ embedding เป็น fp16 เสมอ) — ขวา: สเกลความเร็วโดยประมาณบน T4 ช่อง ΔTH-KNOW ตั้งใจปล่อยเป็น ? จนกว่าโน้ตบุ๊กจะเติม

บาปต้นของ genre นี้: รายงานความเร็วโดยไม่รายงานคุณภาพ

ตัวเลข "nf4 ประหยัด VRAM 2.2 เท่า!" ที่ไม่มีคะแนนคุณภาพแนบมา ไม่ใช่ผลการทดลอง มันคือโฆษณา เพราะการบีบน้ำหนักเหลือ 4 บิตย่อมจ่ายด้วยอะไรบางอย่างเสมอ คำถามเดียวที่มีความหมายคือ "เท่าไหร่" โน้ตบุ๊กของเราจึงรัน TH-KNOW บน KobEval-TH ซ้ำกับทุก configuration ที่มีแถวในตารางหัวข้อ 9 — fp16, int8, nf4 ใช้ข้อสอบชุดเดียวกัน พร้อม Wilson CI ตามธรรมเนียมของซีรีส์

และคาดการณ์ล่วงหน้าอย่างตรงไปตรงมา: บน T4 int8 ของ bitsandbytes มักจะช้ากว่า fp16 — LLM.int8() แยก outlier ไปคูณใน fp16 ทำให้จ่าย overhead สองทาง มันคือเครื่องมือประหยัด VRAM ไม่ใช่เครื่องมือเร่งความเร็ว ถ้าผลวัดของคุณออกมาแบบนั้น นั่นไม่ใช่บั๊กของคุณ นั่นคือความจริงที่บทความรีวิวไม่ค่อยพิมพ์

7.5 Stage 4 — vLLM (ทางเลือก และเปราะที่สุดในซีรีส์)

เซลล์นี้คือเซลล์ที่มีโอกาสพังสูงที่สุดในทั้ง 10 โน้ตบุ๊ก — ด้วยเหตุผลที่อธิบายได้

vLLM รองรับ SM 7.5 ก็จริง แต่บน T4 ฟรีของ Colab มีเงื่อนไขซ้อนกันสามชั้น: (1) ต้องระบุ dtype="half" — เจอ bf16 ใน config แล้วมันปฏิเสธทันที (ถึงตรงนี้คุณควรทายได้ก่อนอ่าน) (2) หลายเวอร์ชันต้อง enforce_eager=True เพราะเส้นทาง CUDA graph มีปัญหาบนการ์ดเก่า (3) บางเวอร์ชันล่าสุดตัด/พังการ build สำหรับ sm_75 ไปเลย — โน้ตบุ๊กจึง pin เวอร์ชันตายตัว ไว้ อย่าอัปเป็น latest

try:
from vllm import LLM, SamplingParams # เวอร์ชันถูก pin ในเซลล์ติดตั้งของโน้ตบุ๊ก
llm = LLM(
model="qwen3-th-serve",
dtype="half", # T4 ไม่มี bf16 — บังคับระบุ
enforce_eager=True, # ข้าม CUDA graph ที่งอแงบน sm_75
gpu_memory_utilization=0.85,
max_model_len=4096,
)
VLLM_OK = True
except Exception as e:
VLLM_OK = False
print("vLLM ใช้ไม่ได้บนรันไทม์นี้ — ข้ามได้เลย:", e)

โครงสร้างนี้จงใจ: ถ้า vLLM ติดตั้งไม่ผ่านหรือ crash ตอน init ทุกอย่างใน stage 1–3 ยังสมบูรณ์ ข้อสรุปหลักของบทนี้ไม่ได้พึ่ง vLLM เลย — มันเป็นแค่หลักฐานว่า paged KV + kernel ที่ fuse มาดีทำอะไรได้อีก เมื่อเทียบกับ scheduler 60 บรรทัดของเรา

8. ผลลัพธ์ (Results)

โน้ตบุ๊กเขียนทุกตัวเลขลง results.json — โครงคือ ทฤษฎีจาก datasheet เทียบกับของจริงที่วัดได้:

ปริมาณทฤษฎี (หัวข้อ 3)วัดจริง (โน้ตบุ๊ก)
decode เดี่ยว, naive generateเพดาน ≤ 266 tok/s?
decode เดี่ยว, static cache + compileเพดาน ≤ 266 tok/s?
aggregate ที่ batch 16 (continuous batching)สูงกว่า batch 1 หลายเท่า?
TTFT (prompt ~512 token)หลักสิบ ms (compute-bound)?

สิ่งที่ควรคาดหวังและวิธีอ่านมัน: ตัวเลข naive จะต่ำกว่าเพดานราว 10 เท่า — อย่าตกใจ และอย่าโทษ T4 ช่องว่างนั้นมีที่มาที่ไล่ได้เป็นชั้น: Python วนลูปต่อ token, การปล่อย kernel นับสิบครั้งต่อ step, การจอง KV แบบ dynamic, การ sync กลับ CPU ตอน sampling — stage 3 กำจัดชั้นเหล่านี้ทีละชั้นและวัดใหม่ทุกครั้ง ผลรวมที่เข้าใกล้เพดานขึ้นเรื่อย ๆ (แต่ไม่มีวันแตะ เพราะเพดานไม่รวม KV, activation และ overhead ที่เหลือ) คือหลักฐานเชิงประจักษ์ว่าสมการ 3.2 อธิบายเครื่องจริงได้ — นี่แหละคือเหตุผลที่ผมเรียกมันว่าการทดลองที่หนักแน่นที่สุดของซีรีส์

ภายใต้โหลด ตารางที่สองจับรูปทรงของรูป 10.3:

λ (req/s)p50p99
ต่ำ (~30% ของ capacity)??
กลาง (~60%)??
ใกล้อิ่มตัว (~90%)?? — ควรระเบิดแบบหัวเข่าในรูป 10.3

และการเช็คคุณภาพด้วยตาที่ตัวเลขทำแทนไม่ได้ — prompt เดียวกัน ตอบด้วย fp16 เทียบ nf4:

Prompt
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆ

base

Thai 18%41 tokens
The sky appears blue because of Rayleigh scattering. ท้องฟ้า is blue เพราะ light scatter ครับ. Shorter wavelengths scatter more than longer ones.

sft

Thai 99%78 tokens
ท้องฟ้าเป็นสีฟ้าเพราะแสงอาทิตย์กระทบกับโมเลกุลของอากาศแล้วเกิดการกระเจิงแบบเรย์ลี ซึ่งแสงสีน้ำเงินที่มีความยาวคลื่นสั้นกว่าจะกระเจิงได้มากกว่าแสงสีแดง เราจึงมองเห็นท้องฟ้าเป็นสีฟ้าครับ

Showing the built-in sample.

9. เปรียบเทียบ (Comparison)

ตารางสรุปของทั้งบท — ทุกความเร็วที่ได้มา ต้องโชว์ราคาที่จ่ายในแถวเดียวกัน:

การตั้งค่าtok/s @B=1tok/s @B=16p99VRAM สูงสุดconcurrency สูงสุด*TH-KNOW
fp16 + naive generate??~1.5 GB1baseline
+ static cache + torch.compile??~1.7 GB1= baseline (น้ำหนักเดิมเป๊ะ)
+ continuous batching????16 (ตาม MAX_BATCH)= baseline
int8 (bitsandbytes)???~0.9 GB??
nf4 (bitsandbytes)???~0.7 GB??
vLLM dtype="half" (ถ้ารันได้)???ตาม gpu_memory_utilization?= fp16

* ที่ context 1,024 token งบ KV ตามสมการ 3.1 รองรับได้เป็นร้อย sequence (~14.8 GB ÷ 112 MiB ≈ 125) — ตัวจำกัดจริงคือ scheduler และ compute ของ prefill ไม่ใช่ VRAM ซึ่งเป็นบทเรียนในตัวมันเอง

รูปแบบที่คุณควรจะเห็น:

  • compile ช่วย batch 1 มากที่สุด (มันฆ่า overhead ต่อ step ซึ่งเป็นคอขวดของ single-stream)
  • batching แทบไม่ช่วย tok/s ต่อ request แต่คูณ aggregate — และทำ p99 แย่ลงเมื่อโหลดสูง
  • int8 ลด VRAM แต่ช้าลงบน T4, nf4 ลด VRAM มากกว่าและเร็วกว่า int8 — คุณภาพต้องไปดูคอลัมน์ขวาสุดเอง ห้ามสรุปจากคอลัมน์ความเร็ว
  • vLLM ถ้ารอด ควรชนะ scheduler มือเขียนของเราชัดเจนที่ concurrency สูง — ถ้าไม่ชนะ แปลว่า enforce_eager กำลังกินกำไรของมัน

กับดักที่ต้องระวัง

1. เซิร์ฟเวอร์หายเงียบเพราะ Colab ตัดการเชื่อมต่อ — วัดเป็นช่วงสั้น เขียนผลทันที (หัวข้อ 7.1) อย่าวางแผนการวัดที่ยาวกว่าอายุ session

2. bf16 บน T4 — ถึงบทที่สิบแล้ว คุณควรทายได้ก่อนที่ error จะขึ้น: torch_dtype=torch.float16 ใน transformers และ dtype="half" ใน vLLM มุกประจำซีรีส์จบที่บทนี้ แต่การ์ด Turing ในโลกยังอยู่ต่อ

3. ไม่อุ่นเครื่องก่อนวัด — compile ครั้งแรกกินเวลาระดับนาที ถ้าปนเข้าการจับเวลา ข้อสรุปจะกลับด้านทันที (หัวข้อ 7.2)

4. รายงาน tok/s โดยไม่บอก batch size — ตัวเลขเดียวกันอาจหมายถึงระบบที่ผู้ใช้รอ 40 ms หรือ 500 ms ต่อ token ทุกตัวเลขในบทนี้จึงพก @B ติดตัวเสมอ

5. เอา prefill ปน decode — prompt ยาว ๆ ทำให้ "tok/s เฉลี่ย" สูงหลอกเพราะ prefill เป็น compute-bound ที่กิน token ได้เป็นพันต่อวินาที (หัวข้อ 3.5) — รายงาน TTFT กับ ITL แยกกันเท่านั้น

10. สรุป (Summary)

  • decode ที่ batch 1 คือการรอน้ำหนักเดินทาง ไม่ใช่รอการคำนวณ — ชิปว่าง ~99.5% ขณะทำงาน "เต็มที่"
  • เพดานความเร็วคำนวณได้จาก datasheet: 320 GB/s ÷ 1.2 GB ≈ 266 tok/s ก่อนรันโค้ดแม้แต่บรรทัดเดียว
  • KV cache 112 KiB/token (เลขเดียวกับที่ test suite ของเว็บนี้ assert) — ที่ context เต็ม 40,960 token มันคือ ~4.7 GB ต่อ sequence เดียว เกือบสี่เท่าของน้ำหนักโมเดล — context คือตัวกินงบ
  • batching = ขาย latency ซื้อ throughput และ continuous batching คือวิธีขายที่ขาดทุนน้อยที่สุด
  • quantisation ลดไบต์ที่ต้องเดินทาง — ในทางทฤษฎีเร็วขึ้นเท่าตัว ในทางปฏิบัติต้องวัด และต้องวัดคุณภาพเคียงกันเสมอ
  • Little's law ผูกทุกอย่างเข้าด้วยกัน: L=λWL = \lambda W บอกจำนวน sequence ที่ต้องถือ ซึ่งย้อนกลับไปเป็นงบ KV
  • p99 ระเบิดก่อนเซิร์ฟเวอร์เต็ม — ระบบที่ไม่มี headroom คือระบบที่ออกแบบมาให้พังตอนคนใช้เยอะที่สุด
ข้อจำกัดของการทดลองนี้

T4 เป็นการ์ดปี 2018 แบนด์วิดท์ 320 GB/s เทียบกับ ~3.35 TB/s ของ H100 — ต่างกันเป็นสิบเท่า ตัวเลขสัมบูรณ์ทุกตัวในบทนี้จึงไม่โอนไปเครื่องอื่น สิ่งที่โอนได้คืออัตราส่วนและวิธีคิด: สมการ 3.1–3.5 ใช้ได้กับทุกการ์ด แค่เปลี่ยนค่าคงที่จาก datasheet ใบใหม่

และการเสิร์ฟจริงระดับ production ต้องการอีกหลายชั้นที่บทนี้ไม่ได้แตะเลย: autoscaling, health check และ readiness probe, observability (metrics/logging/tracing), multi-tenancy และการแยกผู้ใช้, rate limiting, การยืนยันตัวตน, การทำบัญชีต้นทุนต่อ request, การจัดการเวอร์ชันโมเดลและ rollback — การไม่พูดถึงไม่ได้แปลว่าไม่สำคัญ มันแปลว่าบทความเดียวพูดได้ไม่หมด และเราเลือกพูดเรื่องที่เป็นรากของทุกเรื่อง: ฟิสิกส์ของคอขวด

ปิดซีรีส์

สิบบทที่ผ่านมาสร้างทางเดินเส้นเดียว: ใส่ความรู้ (บท 1) → สอนรูปแบบ (บท 2) → จัด preference สามวิธี (บท 3–5) → กลั่นให้เล็กลง (บท 6–7) → กันพัง (บท 8) → วัดอย่างซื่อสัตย์ (บท 9) → เอาขึ้นเสิร์ฟแล้ววัดกับเพดานที่ฟิสิกส์กำหนด (บทนี้) แต่สิ่งที่ผมอยากให้ติดตัวคุณไปจริง ๆ ไม่ใช่เทคนิคใดเทคนิคหนึ่ง มันคือนิสัยที่ทุกบทย้ำซ้ำ ๆ: ตั้งสมการก่อนเขียนโค้ด, วัดทุกอย่างพร้อม confidence interval, และตีพิมพ์ข้อจำกัดของตัวเองไว้ในกล่องเหลืองท้ายบทเสมอ โมเดลจะเปลี่ยน ไลบรารีจะเปลี่ยน การ์ดจะเร็วขึ้นสิบเท่า — นิสัยสามข้อนี้จะยังใช้ได้วันที่ทุกอย่างในซีรีส์นี้ล้าสมัยไปแล้ว

โน้ตบุ๊กทั้งสิบตัวรันจบได้บน Colab ฟรี — อย่าเพิ่งเชื่อผม ไปรันเอง แล้วดูว่าตัวเลขของคุณต่างจากของผมตรงไหน ถ้าคุณหลงเข้ามาที่บทนี้เป็นบทแรก: เริ่มต้นที่บทที่ 1 — Continue Pretraining แล้วเดินมาตามทางจนถึงตรงนี้ เจอกันครับ

อ้างอิง (References)

  1. Kwon et al. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention — PagedAttention: การจัดการ KV cache ที่ vLLM ใช้
  2. Yu et al. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI '22) — continuous batching ต้นฉบับ ที่หัวข้อ 7 เขียนขึ้นเองแบบย่อ
  3. Dao et al. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness — FlashAttention -- และเหตุผลว่าทำไม T4 ใช้ไม่ได้
  4. Frantar et al. (2022). GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers — GPTQ: quantization หลังเทรนแบบแม่นยำ
  5. Dettmers et al. (2022). LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale — LLM.int8(): ที่มาของโหมด 8-bit ใน bitsandbytes
  6. Pope et al. (2022). Efficiently Scaling Transformer Inference — การวิเคราะห์คอขวดของ inference ในระดับระบบ
  7. Williams et al. (2009). Roofline: An Insightful Visual Performance Model for Multicore Architectures — โมเดล roofline ที่หัวข้อ 3 ใช้หาเพดาน 266 tok/s
  8. Pipatanakul et al. (2023). Typhoon: Thai Large Language Models — Typhoon: LLM ภาษาไทยอีกสายหนึ่ง
  9. Nguyen et al. (2023). SeaLLMs -- Large Language Models for Southeast Asia — SeaLLMs: โมเดลสำหรับภาษาเอเชียตะวันออกเฉียงใต้
  10. Pairatsuppawat et al. (2025). SiamGPT: Quality-First Fine-Tuning for Stable Thai Text Generation — SiamGPT: fine-tuning ภาษาไทยที่เน้นคุณภาพ

บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)