บทที่ 5 จาก 9 · AI 101 — คู่มือเริ่มต้นสำหรับคนที่จะเอา AI ไปใช้งานจริง
ดูยังไงว่า AI กินทรัพยากรเท่าไร
สูตรคำนวณ VRAM และ KV cache ที่โตตามจำนวนคนใช้พร้อมกัน คำสั่ง nvidia-smi ที่ใช้จริง ตัวเลขที่ต้องดูและวิธีตีความ พร้อมวิธี benchmark ที่ไม่หลอกตัวเอง
คำถามที่ตอบได้ก่อนโหลดโมเดลคือ มันจะลงเครื่องเราไหม ส่วนคำถามที่ตอบได้หลังรันแล้วเท่านั้นคือ มันเร็วพอไหม และคอขวดอยู่ตรงไหน
5.1 VRAM ประกอบด้วยอะไรบ้าง
VRAM ที่ใช้จริง = น้ำหนักโมเดล + KV cache + activation + overhead ของตัวรัน
| ส่วนประกอบ | ลักษณะ |
|---|---|
| น้ำหนักโมเดล | คงที่ คำนวณล่วงหน้าได้ด้วยสูตรในบทที่ 2 |
| KV cache | โตตามความยาว context และจำนวนคนที่ใช้พร้อมกัน — ตัวที่คนลืมบ่อยที่สุด |
| activation | ค่าระหว่างคำนวณ ขึ้นกับ batch size |
| overhead | CUDA context และ buffer ของตัวรัน ราว 0.5–2 GB |
สูตรคำนวณ KV cache
KV cache (ไบต์) = 2 × layers × kv_heads × head_dim × ctx_len × batch × bytes_per_value
| ตัวแปร | ความหมาย |
|---|---|
| 2 | เพราะเก็บทั้ง Key และ Value |
| layers | จำนวนชั้นของโมเดล ดูได้ใน config.json |
| kv_heads | จำนวน KV heads โมเดลใหม่ใช้ GQA ทำให้เลขนี้น้อยกว่า attention heads มาก |
| head_dim | hidden_size ÷ num_attention_heads |
| bytes | 2 สำหรับ FP16, 1 สำหรับ FP8 |
ตัวอย่างจริงกับโมเดล 8B ทั่วไป ที่มี 36 ชั้น, 8 kv_heads, head_dim 128:
| เงื่อนไข | คำนวณ | ได้ | หมายเหตุ |
|---|---|---|---|
| 8K context, 1 คน, FP16 | 2 × 36 × 8 × 128 × 8192 × 1 × 2 | 1.2 GB | |
| 32K context, 1 คน, FP16 | 2 × 36 × 8 × 128 × 32768 × 1 × 2 | 4.8 GB | ใหญ่เกือบเท่าตัวโมเดลที่ Q4 |
| 32K context, 8 คนพร้อมกัน, FP16 | 4.8 × 8 | 38.6 GB | จุดที่ระบบจริงพัง |
| 32K context, 8 คนพร้อมกัน, FP8 | 38.6 ÷ 2 | 19.3 GB | เหตุผลที่ควรเปิด --kv-cache-dtype fp8 |
บทเรียนที่แพงที่สุดของมือใหม่
ทดสอบคนเดียวบนเครื่องแล้วทุกอย่างดี พอเปิดให้คนใช้จริง 10 คนแล้วระบบล่ม เพราะ KV cache โตเป็นเส้นตรงตามจำนวนคนที่ใช้พร้อมกัน คูณกับความยาว context คำนวณ VRAM ที่จำนวนผู้ใช้สูงสุดที่คาดไว้เสมอ ไม่ใช่ที่หนึ่งคน
5.2 เครื่องมือดูการใช้ทรัพยากร
# ภาพรวมการ์ด อัปเดตทุก 1 วินาที
nvidia-smi -l 1
# ดูเฉพาะตัวเลขที่สนใจ ในรูปแบบที่เอาไปทำกราฟต่อได้
nvidia-smi --query-gpu=timestamp,name,memory.used,memory.total,\
utilization.gpu,utilization.memory,temperature.gpu,power.draw,clocks_throttle_reasons.active \
--format=csv -l 2
# บันทึกลงไฟล์ระหว่างรัน benchmark
nvidia-smi --query-gpu=timestamp,memory.used,utilization.gpu,power.draw \
--format=csv -l 1 > gpu_log.csv &
# หน้าจอแบบโต้ตอบ (ติดตั้งด้วย apt install nvtop)
nvtop
# ดูว่ากระบวนการไหนกิน VRAM อยู่
nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv
# CPU / RAM / ดิสก์
htop ; free -h ; df -h
วัดจากในโค้ด:
import torch, time
def report(tag: str):
a = torch.cuda.memory_allocated() / 1024**3 # ที่ tensor ใช้จริง
r = torch.cuda.memory_reserved() / 1024**3 # ที่ PyTorch จองไว้จากการ์ด
p = torch.cuda.max_memory_allocated() / 1024**3
print(f"{tag:24s} allocated={a:6.2f} GB reserved={r:6.2f} GB peak={p:6.2f} GB")
torch.cuda.reset_peak_memory_stats()
report("ก่อนโหลด")
from transformers import AutoModelForCausalLM, AutoTokenizer
mid = "Qwen/Qwen3-8B"
tok = AutoTokenizer.from_pretrained(mid)
model = AutoModelForCausalLM.from_pretrained(
mid, torch_dtype=torch.bfloat16, device_map="cuda",
use_safetensors=True, # บังคับรูปแบบปลอดภัย
)
report("หลังโหลดน้ำหนัก")
ids = tok("อธิบายการทำงานของ KV cache", return_tensors="pt").to("cuda")
t0 = time.perf_counter()
out = model.generate(**ids, max_new_tokens=256, do_sample=False)
dt = time.perf_counter() - t0
report("หลัง generate")
n = out.shape[-1] - ids["input_ids"].shape[-1]
print(f"สร้าง {n} tokens ใน {dt:.2f}s = {n/dt:.1f} tok/s")
print(torch.cuda.memory_summary(abbreviated=True))
5.3 ตัวเลขที่ต้องดูจริง และความหมายของมัน
| ตัวเลข | ดูจากไหน | ตีความอย่างไร |
|---|---|---|
| VRAM used | nvidia-smi | ถ้าเกือบเต็มเกิน 95% เสี่ยง OOM เมื่อ context ยาวขึ้น ให้ลด context หรือ quantize KV cache |
| GPU utilization | nvidia-smi | สูงไม่ได้แปลว่าดี ตอน decode ตัวเลขนี้สูงได้ทั้งที่การ์ดรออ่านหน่วยความจำอยู่ |
| TTFT | วัดเอง | เวลาถึง token แรก สะท้อนความเร็วการอ่าน prompt ผู้ใช้รู้สึกกับตัวเลขนี้มากที่สุด |
| tok/s (decode) | วัดเอง | ความเร็วพิมพ์คำตอบ ถูกจำกัดด้วย bandwidth เป็นหลัก |
| Throughput รวม | เมตริกของตัวรัน | tok/s ของทุกคนรวมกัน ตัวเลขที่สำคัญเมื่อให้บริการหลายคน |
| KV cache usage | vLLM /metrics | ถ้าเกิน 90% บ่อย แปลว่าจะเริ่มปฏิเสธหรือคิวยาว ต้องลด context หรือเพิ่มการ์ด |
| Power draw | nvidia-smi | ต่ำกว่า TDP มากตอนโหลดเต็ม แปลว่าติดคอขวดที่หน่วยความจำ ไม่ใช่ที่การคำนวณ |
| Throttle reasons | nvidia-smi | ถ้าขึ้น SwThermalSlowdown แปลว่าร้อนเกิน แก้ที่การระบายความร้อน ไม่ใช่ที่ซอฟต์แวร์ |
5.4 วิธี benchmark ที่ไม่หลอกตัวเอง
ข้อผิดพลาดที่ทำให้ตัวเลขไม่จริง:
- วัดรอบแรกที่รวมเวลาโหลดโมเดลและคอมไพล์ kernel
- วัดครั้งเดียวแล้วสรุป
- ใช้ prompt สั้น 10 token แล้วบอกว่าเร็ว ทั้งที่งานจริง prompt ยาวกว่านั้นมาก
- วัดที่ผู้ใช้คนเดียวแล้วคูณจำนวนคนเอาเอง
- ไม่แยก TTFT กับ decode ทั้งที่สองอย่างนี้ติดคอขวดคนละที่
วิธีที่ถูก:
- warm-up 3 รอบทิ้ง แล้วค่อยเริ่มวัด
- ใช้ prompt ที่ยาวใกล้เคียงงานจริง เช่น 1,000–4,000 token
- วัดอย่างน้อย 10 รอบ รายงานค่ามัธยฐานและ p95 ไม่ใช่ค่าเฉลี่ย
- ไล่จำนวนผู้ใช้พร้อมกัน 1, 2, 4, 8, 16 แล้วดูว่าจุดไหนที่ latency พุ่ง
- บันทึก VRAM สูงสุดตลอดการทดสอบ ไม่ใช่ค่าตอนเริ่ม
- ทดสอบซ้ำหลังเครื่องร้อนแล้ว 20 นาที เพราะตัวเลขมักตกจากตอนเย็น
สคริปต์วัด TTFT และ decode แยกกัน พร้อมไล่จำนวนผู้ใช้:
import asyncio, statistics, time, httpx
BASE = "http://localhost:8000/v1/chat/completions"
PROMPT = "สรุปประเด็นสำคัญของข้อความต่อไปนี้เป็นข้อ ๆ:\n" + ("ตัวอย่างเนื้อหา " * 400)
async def one(client) -> tuple[float, float, int]:
t0, first, n = time.perf_counter(), None, 0
async with client.stream("POST", BASE, json={
"model": "Qwen/Qwen3-8B",
"messages": [{"role": "user", "content": PROMPT}],
"max_tokens": 256, "temperature": 0, "stream": True,
}, timeout=180) as r:
async for line in r.aiter_lines():
if line.startswith("data: ") and "[DONE]" not in line:
if first is None:
first = time.perf_counter() - t0 # TTFT
n += 1
total = time.perf_counter() - t0
decode = n / (total - first) if first and total > first else 0
return first, decode, n
async def sweep():
async with httpx.AsyncClient() as c:
await asyncio.gather(*[one(c) for _ in range(3)]) # warm-up ทิ้ง
for users in (1, 2, 4, 8, 16):
res = await asyncio.gather(*[one(c) for _ in range(users)])
ttft = [r[0] for r in res]
decode = [r[1] for r in res]
print(f"users={users:2d} "
f"TTFT med={statistics.median(ttft):.2f}s "
f"p95={sorted(ttft)[int(len(ttft)*0.95)-1]:.2f}s "
f"decode med={statistics.median(decode):.1f} tok/s "
f"รวม={sum(decode):.0f} tok/s")
asyncio.run(sweep())
สิ่งที่ผลลัพธ์จะบอกคุณ
เมื่อเพิ่มจำนวนผู้ใช้ ถ้า throughput รวมเพิ่มขึ้นแต่ tok/s ต่อคนลดลง แปลว่าปกติดี ระบบกำลัง batch งานได้ดี แต่ถ้า throughput รวมไม่เพิ่มแล้ว แปลว่าถึงเพดานของการ์ด เพิ่มคนต่อไปมีแต่จะทำให้ทุกคนช้าลง จุดนั้นคือขีดความสามารถจริงของเครื่อง และเป็นตัวเลขที่ควรใช้วางแผนกำลังการผลิต ไม่ใช่ตัวเลขจากโบรชัวร์
สรุปบทนี้
VRAM ไม่ได้มีแค่ตัวโมเดล KV cache โตตามความยาว context คูณจำนวนคนพร้อมกัน และนั่นคือจุดที่ระบบพังเมื่อเปิดใช้จริง วัดด้วย prompt ที่ยาวเหมือนงานจริง แยก TTFT กับ decode รายงานมัธยฐานและ p95 แล้วไล่จำนวนผู้ใช้จนเห็นเพดาน
บทถัดไปว่าด้วยการเลือกโมเดลให้ตรงกับงาน โดยเริ่มจากข้อจำกัดของเรา ไม่ใช่จาก leaderboard