[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) แยกกันเสมอ
4. เห็นภาพสมการ (Visualize)
Context คือตัวกินงบ ไม่ใช่โมเดล
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
Figure 10.2เพดาน 266 tok/s จากสมการ 3.2 (เส้นประแดง — คำนวณจาก datasheet จริง) กับแบบจำลองการเฉลี่ยต้นทุนคงที่ของ batching (เส้นทึบ — ภาพประกอบกลไก ตัวเลขจริงมาจากโน้ตบุ๊ก)
อ่านกราฟนี้ให้ครบทั้งสองแกน: เส้นน้ำเงินขึ้น (ดี) แต่เส้นส้มก็ขึ้นด้วย (แย่) batching ไม่ใช่ของฟรี — มันคือการขาย latency ของผู้ใช้แต่ละคน ซื้อ throughput ของระบบ ตำแหน่งที่ควรอยู่บนกราฟนี้เป็นการตัดสินใจทางธุรกิจ ไม่ใช่ทางเทคนิค
คิวระเบิดก่อนเซิร์ฟเวอร์เต็ม
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 เมื่อไหร่:
- Weights1.11 GiB
- Gradients—
- Optimizer state—
- Activations68.00 KiB
- KV cache112.00 MiB
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() โกหกคุณบน T4torch รุ่นใหม่ตอบ True บน T4 เพราะนับ การจำลอง (emulation) ว่ารองรับด้วย ซึ่งช้ากว่า fp16 มาก
ให้เช็ค compute capability ≥ 8.0 (Ampere ขึ้นไป) แทน — นี่คือบั๊กจริงที่เจอตอนรันโน้ตบุ๊กบน Colab จริง ๆ
Stage 1 — Merge adapter แล้ว export (~2 นาที)
ตลอดซีรีส์เราเทรนด้วย LoRA ซึ่งตอนเสิร์ฟจะกลายเป็นภาระ: ทุก forward ต้องคูณ เพิ่มอีกหนึ่งทอด ข่าวดีคือ LoRA merge กลับเข้าน้ำหนักฐานได้แบบปิดรูป: — เสิร์ฟแล้วต้นทุนเท่าโมเดลฐานเป๊ะ
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 — ใกล้ศูนย์แต่ไม่ใช่ศูนย์เป๊ะ
ค่าไม่เป็นศูนย์เป๊ะเพราะการบวก เข้า ใน 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) # ตารางเวลายิง — ยึดตามนี้เคร่งครัด
ถ้า 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 เท่า" ก็เป็นแค่คำโฆษณา
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 (รอให้ครบ ค่อยเริ่ม แล้วรอให้ตัวที่ยาวที่สุดจบ) ทิ้งที่ว่างมหาศาล 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},
)
Figure 10.4ซ้าย: VRAM ของน้ำหนัก คำนวณจริงจาก config (bitsandbytes เก็บ embedding เป็น fp16 เสมอ) — ขวา: สเกลความเร็วโดยประมาณบน T4 ช่อง ΔTH-KNOW ตั้งใจปล่อยเป็น ? จนกว่าโน้ตบุ๊กจะเติม
ตัวเลข "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 (ทางเลือก และเปราะที่สุดในซีรีส์)
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) | p50 | p99 |
|---|---|---|
| ต่ำ (~30% ของ capacity) | ? | ? |
| กลาง (~60%) | ? | ? |
| ใกล้อิ่มตัว (~90%) | ? | ? — ควรระเบิดแบบหัวเข่าในรูป 10.3 |
และการเช็คคุณภาพด้วยตาที่ตัวเลขทำแทนไม่ได้ — prompt เดียวกัน ตอบด้วย fp16 เทียบ nf4:
Promptอธิบายว่าทำไมท้องฟ้าถึงเป็นสีฟ้า แบบสั้น ๆbase
sft
Showing the built-in sample.
9. เปรียบเทียบ (Comparison)
ตารางสรุปของทั้งบท — ทุกความเร็วที่ได้มา ต้องโชว์ราคาที่จ่ายในแถวเดียวกัน:
| การตั้งค่า | tok/s @B=1 | tok/s @B=16 | p99 | VRAM สูงสุด | concurrency สูงสุด* | TH-KNOW |
|---|---|---|---|---|---|---|
fp16 + naive generate | ? | — | ? | ~1.5 GB | 1 | baseline |
+ static cache + torch.compile | ? | — | ? | ~1.7 GB | 1 | = 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 ผูกทุกอย่างเข้าด้วยกัน: บอกจำนวน 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)
- Kwon et al. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention — PagedAttention: การจัดการ KV cache ที่ vLLM ใช้
- Yu et al. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models (OSDI '22) — continuous batching ต้นฉบับ ที่หัวข้อ 7 เขียนขึ้นเองแบบย่อ
- Dao et al. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness — FlashAttention -- และเหตุผลว่าทำไม T4 ใช้ไม่ได้
- Frantar et al. (2022). GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers — GPTQ: quantization หลังเทรนแบบแม่นยำ
- Dettmers et al. (2022). LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale — LLM.int8(): ที่มาของโหมด 8-bit ใน bitsandbytes
- Pope et al. (2022). Efficiently Scaling Transformer Inference — การวิเคราะห์คอขวดของ inference ในระดับระบบ
- Williams et al. (2009). Roofline: An Insightful Visual Performance Model for Multicore Architectures — โมเดล roofline ที่หัวข้อ 3 ใช้หาเพดาน 266 tok/s
- Pipatanakul et al. (2023). Typhoon: Thai Large Language Models — Typhoon: LLM ภาษาไทยอีกสายหนึ่ง
- Nguyen et al. (2023). SeaLLMs -- Large Language Models for Southeast Asia — SeaLLMs: โมเดลสำหรับภาษาเอเชียตะวันออกเฉียงใต้
- Pairatsuppawat et al. (2025). SiamGPT: Quality-First Fine-Tuning for Stable Thai Text Generation — SiamGPT: fine-tuning ภาษาไทยที่เน้นคุณภาพ
บทความ โค้ด และโน้ตบุ๊กในซีรีส์นี้เผยแพร่ภายใต้สัญญาอนุญาต CC BY-NC-SA 4.0 — นำไปใช้และดัดแปลงต่อได้ โดยอ้างอิงที่มา ไม่ใช้เพื่อการค้า และเผยแพร่ต่อด้วยสัญญาเดียวกัน (โมเดลและชุดข้อมูลของบุคคลที่สามที่อ้างถึง ยังคงใช้สัญญาของเจ้าของเดิม)
