ความแตกต่างระหว่าง Prefill และ Decode ในการรัน LLM
ทำความเข้าใจว่าทำไม Prefill จึงเป็น Compute bound ที่กำหนดค่า Time to first token และ Decode เป็น Memory bandwidth bound ที่กำหนดค่า Tokens per second พร้อมวิธีวัดผลแยกส่วน
ความแตกต่างระหว่าง Prefill และ Decode
ความแตกต่างระหว่าง Prefill และ Decode คือปัจจัยหลักที่อธิบายคำถามส่วนใหญ่เกี่ยวกับความหน่วง (latency) ของการรัน LLM (large language model) ด้วยตนเอง โดยขั้นตอน Prefill จะอ่าน prompt ทั้งหมดในรอบเดียวและถูกจำกัดด้วยพลังการประมวลผล (compute) ส่วนขั้นตอน Decode จะเขียนคำตอบทีละ token และถูกจำกัดด้วยแบนด์วิดท์ของหน่วยความจำ (memory bandwidth) ดังนั้น Time to first token จึงเป็นตัวเลขที่วัดจากช่วง Prefill ในขณะที่ Tokens per second เป็นตัวเลขที่วัดจากช่วง Decode ทั้งสองขั้นตอนทำงานบน GPU (graphics processing unit) เดียวกัน โดยใช้ weight ชุดเดียวกันและอยู่ในกระบวนการเดียวกัน จึงเป็นเรื่องปกติที่จะมองว่าทั้งสองเป็นภาระงาน (workload) เดียวกัน แต่ในความเป็นจริงแล้วทั้งสองมีพฤติกรรมเหมือนโปรแกรมสองโปรแกรมที่ใช้ทรัพยากรบนอุปกรณ์เดียวกัน หากแยกการพิจารณาทั้งสองส่วนออกจากกัน ผลลัพธ์ที่เคยดูสับสนก็จะมีความชัดเจนขึ้นทันที
เหตุใดขั้นตอน prefill จึงถูกจำกัดด้วยความสามารถในการประมวลผล (compute bound)?
ขั้นตอน prefill จะส่ง prompt ทั้งหมดผ่านทุกเลเยอร์หนึ่งครั้ง prompt ขนาด 2,000 token จะทำให้การคูณเมทริกซ์แต่ละครั้งต้องประมวลผลข้อมูลถึง 2,000 แถว ส่งผลให้ GPU ต้องคำนวณทางคณิตศาสตร์จำนวนมากต่อข้อมูลน้ำหนัก (weight) แต่ละไบต์ที่โหลดเข้ามา อัตราส่วนระหว่างการคำนวณต่อไบต์ที่เคลื่อนย้ายนี้เรียกว่า arithmetic intensity ซึ่งขั้นตอน prefill มีค่านี้สูงมาก อุปกรณ์จึงทำงานใกล้ขีดจำกัดด้านการประมวลผลในขณะที่บัสหน่วยความจำยังมีช่องว่างเหลืออยู่
ขั้นตอน prefill สร้างผลลัพธ์สองอย่างคือ KV cache (เทนเซอร์ key และ value) สำหรับทุก token ใน prompt และ token แรกของผลลัพธ์ โดยจะไม่มีข้อมูลใดส่งถึงผู้ใช้จนกว่าการประมวลผลรอบนี้จะเสร็จสิ้น นี่คือเหตุผลว่าทำไมเวลาที่ใช้ในขั้นตอน prefill และเวลาที่ใช้ในการสร้าง token แรก (TTFT) จึงเป็นการวัดผลที่ใกล้เคียงกัน
ต้นทุนของ prefill จะเพิ่มขึ้นตามความยาวของ prompt โดยส่วนที่เป็นเชิงเส้นคือการทำงานของเมทริกซ์ในแต่ละเลเยอร์ ส่วนที่เป็นกำลังสองคือกลไก attention ซึ่งแต่ละ token จะต้องประมวลผลร่วมกับทุก token ก่อนหน้า ซึ่งจะเริ่มส่งผลกระทบอย่างชัดเจนเมื่อใช้ context ที่ยาว ดังนั้นการเพิ่มความยาว prompt เป็นสองเท่าจะทำให้ TTFT เพิ่มขึ้นอย่างน้อยสองเท่าเช่นกัน
คุณสามารถสังเกตปรากฏการณ์นี้ได้ภายในหนึ่งนาที โดยส่ง prompt ขนาด 200 token ไปยังเซิร์ฟเวอร์ของคุณ จากนั้นส่ง prompt ขนาด 2,000 token โดยขอจำนวน token ผลลัพธ์เท่าเดิมในแต่ละครั้ง คุณจะพบว่า TTFT เพิ่มขึ้นอย่างรวดเร็ว ในขณะที่ความเร็วในการสตรีมข้อมูลหลังจาก token แรกแทบจะไม่เปลี่ยนแปลงเลย
เหตุใดการถอดรหัส (decode) จึงถูกจำกัดด้วยแบนด์วิดท์หน่วยความจำ?
กระบวนการถอดรหัสจะสร้างโทเค็นออกมาทีละหนึ่งตัวในแต่ละขั้นตอน เพื่อให้ได้โทเค็นนั้นมา GPU จำเป็นต้องอ่านค่าน้ำหนัก (weight) ทั้งหมดในโมเดลออกมาจากหน่วยความจำ นำค่าน้ำหนักแต่ละตัวไปใช้คำนวณเพียงไม่กี่ครั้ง แล้วจึงทิ้งไป ความเข้มข้นของการคำนวณ (arithmetic intensity) จึงมีค่าใกล้เคียงกับ 1 ทำให้หน่วยประมวลผลส่วนใหญ่ต้องเสียเวลาไปกับการรอคอย
การถอดรหัสทำงานช้าเพราะโทเค็นแต่ละตัวต้องอาศัยการอ่านโมเดลทั้งหมดออกมาจากหน่วยความจำ ดังนั้นบัสหน่วยความจำจึงเป็นตัวกำหนดความเร็ว และทำให้หน่วยประมวลผลต้องว่างงาน
นั่นทำให้ขีดจำกัดความเร็วของการถอดรหัสแบบสตรีมเดี่ยวเป็นเพียงการคำนวณทางคณิตศาสตร์พื้นฐาน โดยให้นำแบนด์วิดท์หน่วยความจำมาหารด้วยจำนวนไบต์ที่ค่าน้ำหนักเหล่านั้นใช้พื้นที่
The data behind this chart
[
{
"device": "CPU, dual channel DDR5-5600",
"mem_bandwidth_gb_s": 90,
"decode_ceiling_tok_s": 6
},
{
"device": "NVIDIA A10G",
"mem_bandwidth_gb_s": 600,
"decode_ceiling_tok_s": 38
},
{
"device": "NVIDIA L40S",
"mem_bandwidth_gb_s": 864,
"decode_ceiling_tok_s": 54
},
{
"device": "NVIDIA RTX 4090",
"mem_bandwidth_gb_s": 1008,
"decode_ceiling_tok_s": 63
},
{
"device": "NVIDIA A100 80GB SXM",
"mem_bandwidth_gb_s": 2039,
"decode_ceiling_tok_s": 127
},
{
"device": "NVIDIA H100 SXM",
"mem_bandwidth_gb_s": 3350,
"decode_ceiling_tok_s": 209
}
]คอลัมน์ bandwidth แสดงค่าสเปกตามที่ผู้ผลิตแต่ละรายประกาศไว้ ส่วนคอลัมน์ ceiling คือค่าดังกล่าวที่หารด้วย 16 GB ซึ่งเป็นขนาดของโมเดลที่มีพารามิเตอร์ 8 พันล้านตัวที่จัดเก็บด้วยความละเอียด 16 บิต นี่เป็นการคำนวณทางคณิตศาสตร์ ไม่ใช่ผลลัพธ์จากการทำ benchmark อัตราที่คุณวัดได้จริงจะต่ำกว่าค่านี้ และการทราบว่าต่ำกว่าอยู่เท่าใดนั้นมีประโยชน์ เพราะจะช่วยให้คุณตัดสินใจได้ว่าควรแก้ไขที่ stack การให้บริการ (serving stack) หรือที่ฮาร์ดแวร์ของคุณ
เมื่ออ่านแถว 6 ในตารางตามลำดับ คุณจะเห็นรูปแบบที่ชัดเจน CPU ที่ใช้ DDR5 แบบ dual channel สามารถรับส่งข้อมูลได้ประมาณ 90 GB/s ซึ่งจำกัดความเร็วการถอดรหัสสำหรับโมเดลนี้ไว้ที่ประมาณ 6 โทเค็นต่อวินาที สำหรับ L40S จะอยู่ที่ประมาณ 54 ส่วน H100 SXM ซึ่งมีแบนด์วิดท์ตามประกาศอยู่ที่ 3350 GB/s จะอยู่ที่ประมาณ 209
นี่คือเหตุผลว่าทำไมการทำ quantization จึงเป็นปัจจัยสำคัญที่สุดที่ส่งผลต่อความเร็วในการถอดรหัส หากคุณจัดเก็บโมเดลเดิมด้วยขนาด 8 บิตแทนที่จะเป็น 16 บิต คุณจะลดจำนวนไบต์ที่ต้องอ่านต่อหนึ่งโทเค็นลงครึ่งหนึ่ง ส่งผลให้ขีดจำกัดความเร็วเพิ่มขึ้นเป็นสองเท่าโดยประมาณ โดยที่คุณไม่ต้องเพิ่มพลังการคำนวณ แต่เป็นการลดภาระการย้ายข้อมูลในหน่วยความจำแทน
ฉันจะวัดแต่ละเฟสบนเซิร์ฟเวอร์ของตัวเองได้อย่างไร
Ollama จะส่งข้อมูลการแบ่งเฟสกลับมาในส่วนของ response body ให้คุณร้องขอการประมวลผลแบบไม่สตรีม (non-streaming completion) แล้วอ่านค่าตัวนับเหล่านั้น
curl -s http://localhost:11434/api/generate -d '{
"model": "llama3.2",
"prompt": "Explain memory bandwidth in two sentences.",
"stream": false
}' | jq '{prompt_eval_count, prompt_eval_duration, eval_count, eval_duration}'ให้ใช้แท็กของโมเดลที่คุณได้ดึง (pull) มาไว้แล้ว ซึ่งคำสั่ง ollama list จะแสดงให้คุณเห็น ค่า prompt_eval_count และ prompt_eval_duration คือส่วนของ prefill ซึ่งประกอบด้วยจำนวนโทเค็นใน prompt และเวลาที่ใช้ในการประมวลผล ส่วน eval_count และ eval_duration คือส่วนของ decode ระยะเวลาจะแสดงเป็นหน่วยนาโนวินาที ดังนั้นความเร็วในการ decode คือ eval_count / eval_duration * 1e9 และความเร็วในการ prefill คือ prompt_eval_count / prompt_eval_duration * 1e9 โดยปกติแล้วอัตราการทำ prefill จะสูงกว่าอัตราการทำ decode ในคำขอเดียวกันอย่างมาก ช่องว่างระหว่างค่าทั้งสองนี้คือสิ่งที่อธิบายไว้ในเนื้อหาทั้งหมดของส่วนนี้
สำหรับเซิร์ฟเวอร์ที่รองรับ OpenAI API เช่น vLLM คุณสามารถใช้ curl เพื่อวัดเวลาจนถึงไบต์แรก (time to first byte) ได้
curl -N -s -o /dev/null \
-w 'pretransfer %{time_pretransfer}s first_byte %{time_starttransfer}s\n' \
http://localhost:8000/v1/completions \
-H 'Content-Type: application/json' \
-d '{"model": "meta-llama/Llama-3.1-8B-Instruct", "prompt": "Explain memory bandwidth.", "max_tokens": 128, "stream": true}'ค่า time_starttransfer คือช่วงเวลาที่ไบต์แรกของ body เดินทางมาถึง ดังนั้นเมื่อรวมกับ "stream": true จะได้ค่า TTFT รวมกับการตั้งค่าการเชื่อมต่อ ให้ลบค่า time_pretransfer ออกเพื่อตัดต้นทุนในการตั้งค่าการเชื่อมต่อออกไป ควรทดสอบซ้ำสองครั้งและใช้ผลลัพธ์ครั้งที่สอง เนื่องจากในการเรียกครั้งแรกอาจรวมเวลาที่ใช้ในการโหลดโมเดลแบบ cold start เข้าไปด้วย
นอกจากนี้ vLLM ยังเผยแพร่ข้อมูลการแบ่งเฟสในรูปแบบ Prometheus metrics ที่ /metrics ให้รันคำสั่ง curl -s http://localhost:8000/metrics | grep -E 'time_to_first_token|inter_token_latency' แล้วคุณจะได้รับฮิสโตแกรม vllm:time_to_first_token_seconds และ vllm:inter_token_latency_seconds ให้เพิ่ม vllm:num_requests_running และ vllm:num_requests_waiting เพื่อดูความลึกของคิว (queue depth) และ vllm:kv_cache_usage_perc สำหรับแรงกดดันต่อแคช (cache pressure) ชื่อทั้งห้านี้คือข้อมูลทั้งหมดที่จำเป็นสำหรับแดชบอร์ด
เมื่ออยู่ภายใต้ภาระงาน (load) เครื่องมือ vllm bench serve --model <name> --num-prompts 200 --request-rate 4 จะทำหน้าที่ควบคุมเซิร์ฟเวอร์ที่กำลังทำงานอยู่และรายงานค่า time to first token รวมถึงค่าความหน่วงต่อโทเค็นขาออก (per output token latency) พร้อมค่าเปอร์เซ็นไทล์ ซึ่งเป็นวิธีเดียวที่จะเห็นการแย่งทรัพยากรกันระหว่างสองเฟสนี้ ก่อนที่คุณจะปรับแต่งค่าใดๆ ให้เก็บค่า baseline ที่สะอาดไว้ก่อน โดยวิธีใน การวัดจำนวนโทเค็นต่อวินาทีบน LLM ในเครื่อง จะช่วยให้คุณได้ค่า baseline ที่ยังคงอยู่แม้หลังจากรีบูตเซิร์ฟเวอร์แล้ว
ทำไม system prompt ที่ยาวถึงทำให้ token แรกแสดงผลช้า แต่ไม่ส่งผลต่อความเร็วในการ streaming?
เพราะ system prompt คือภาระงานในส่วน prefill เพียงอย่างเดียว มันจะถูกประมวลผลเพียงครั้งเดียวในขั้นตอนเดียวกับส่วนที่เหลือของ prompt ก่อนที่ token แรกจะปรากฏขึ้น หลังจากผ่านขั้นตอนนี้ไปแล้ว มันจะคงอยู่ในรูปแบบของ KV cache เท่านั้น และในขั้นตอน decode ระบบจะอ่านข้อมูลเหล่านี้ไปพร้อมกับข้อมูลส่วนอื่น ดังนั้น system prompt ขนาด 3,000 token จึงเพิ่มเวลา TTFT ในทุกคำขอ แต่แทบไม่ส่งผลกระทบต่อจำนวน token ต่อวินาที
แทบไม่ส่งผล แต่ไม่ใช่ว่าไม่มีผลเลย เพราะ KV cache ส่วนเกินเหล่านั้นจะถูกอ่านซ้ำในทุกขั้นตอนการ decode ดังนั้น prompt ที่ยาวมากจึงทำให้การ decode ช้าลงเล็กน้อย ซึ่งส่วนถัดไปจะอธิบายเรื่องนี้
วิธีแก้ไขคือการหยุดคำนวณ prefix เดิมซ้ำ เซิร์ฟเวอร์ที่มีระบบ prefix caching จะเก็บ KV cache ของ prefix ที่ใช้ร่วมกันไว้และนำกลับมาใช้ใหม่ ดังนั้นคำขอที่สองที่มี system prompt เดียวกันจะข้ามขั้นตอน prefill ส่วนนั้นไปโดยสิ้นเชิง vLLM เรียกสิ่งนี้ว่า automatic prefix caching ให้ตรวจสอบ vllm serve --help ในเวอร์ชันที่คุณใช้งาน เนื่องจากค่าเริ่มต้นมีการเปลี่ยนแปลงในแต่ละ release ทั้งนี้ KV cache ที่อยู่ใน GPU นั้นเป็นคนละส่วนกับ prompt cache ที่ผู้ให้บริการ API เรียกเก็บเงินจากคุณ และ ความแตกต่างระหว่าง KV cache และ prompt cache เป็นหัวข้อที่ควรศึกษาให้เข้าใจก่อนที่คุณจะปรับแต่งค่าใดค่าหนึ่ง
เหตุใดการถอดรหัส (decode) จึงช้าลงเมื่อ context เต็ม?
มีสองสาเหตุหลัก ซึ่งทั้งคู่เกี่ยวข้องกับ KV cache
สาเหตุแรกคือแบนด์วิดท์ ในทุกขั้นตอนการถอดรหัส กลไก attention จะอ่านค่า key และ value ของทุกโทเค็นก่อนหน้า น้ำหนัก (weights) เป็นต้นทุนคงที่ต่อโทเค็น แต่ KV cache เป็นต้นทุนที่เพิ่มขึ้นเรื่อยๆ คุณสามารถคำนวณขนาดของมันได้จาก config.json ของโมเดล: จำนวนไบต์ต่อโทเค็นเท่ากับ 2 คูณด้วย num_hidden_layers, คูณด้วย num_key_value_heads, คูณด้วยขนาดของ head (hidden_size หารด้วย num_attention_heads) และคูณด้วยจำนวนไบต์ต่อ element เลข 2 ตัวหน้าคือการนับรวมทั้ง key และ value
สำหรับโครงสร้างโมเดลขนาด 8 พันล้านพารามิเตอร์ทั่วไปที่มี 32 เลเยอร์, 8 key และ value heads ภายใต้ GQA (grouped query attention), ขนาด head 128, ที่ความละเอียด 16 bit จะได้เท่ากับ 2 x 32 x 8 x 128 x 2 = 131,072 ไบต์ หรือประมาณ 128 KiB ต่อโทเค็น ดังนั้นบทสนทนาที่มีความยาว 8,000 โทเค็นจะใช้ KV cache ประมาณ 1 GB ต่อหนึ่งคำขอ
สาเหตุที่สองคือความจุ หน่วยความจำ 1 GB นั้นไม่สามารถนำไปใช้เก็บน้ำหนักหรือ context ของผู้ใช้อื่นได้ เซิร์ฟเวอร์จะกำหนดขนาดของ KV pool ไว้ตั้งแต่ตอนเริ่มต้นระบบ โดยใน vLLM จะกำหนดผ่าน --gpu-memory-utilization และเมื่อ pool เต็ม คำขอใหม่จะต้องรอ การที่ vllm:num_requests_waiting เพิ่มสูงขึ้นในขณะที่ vllm:kv_cache_usage_perc อยู่ใกล้ระดับ 1 คือสัญญาณบ่งชี้ที่ชัดเจนของสภาวะดังกล่าว บาง stack จะใช้วิธีหยุดการทำงานของคำขอที่กำลังประมวลผลอยู่ชั่วคราวแล้วค่อยคำนวณ cache ใหม่ในภายหลังแทนการเข้าคิว ซึ่งผู้ใช้จะรู้สึกเหมือนการตอบกลับหยุดชะงักไปกลางคัน
Context ที่ยาวทำให้คุณต้องเสียต้นทุนสองต่อ: งาน prefill ที่มากขึ้นในช่วงเริ่มต้น และการอ่านหน่วยความจำที่มากขึ้นต่อโทเค็นในส่วนที่เหลือของการตอบกลับ
เหตุใดการทำ batching จึงช่วยเพิ่ม throughput แต่ส่งผลเสียต่อ tail latency
เนื่องจากกระบวนการ decode ถูกจำกัดด้วย bandwidth การเพิ่มคำขอเข้ามาจึงแทบไม่มีต้นทุนทางฝั่งประมวลผล การอ่านค่า weights หนึ่งครั้งสามารถสร้าง token ให้กับทุก sequence ใน batch ได้ ดังนั้น throughput รวมจึงเพิ่มขึ้นเกือบเป็นเส้นตรงตามขนาดของ batch จนกว่า KV pool จะเต็มหรือ batch มีขนาดใหญ่จนกลับไปถูกจำกัดด้วยพลังประมวลผลอีกครั้ง การทำ continuous batching จะสร้าง batch ใหม่ในทุกขั้นตอน ดังนั้นคำขอที่เสร็จสิ้นจะออกไปและคำขอที่รอคิวจะเข้ามาแทนที่โดยไม่ต้องรอคำขออื่นในกลุ่ม
ผลกระทบจะปรากฏให้เห็นในค่า percentiles โดย token ถัดไปของผู้ใช้แต่ละคนจะต้องรอส่วนที่ช้าที่สุดของขั้นตอนที่ใช้ร่วมกัน ส่งผลให้ค่า p50 หรือค่ามัธยฐานยังคงอยู่ในระดับที่ยอมรับได้ ในขณะที่ค่า p99 ซึ่งเป็นคำขอที่ช้าที่สุด 1 รายการจาก 100 รายการจะยืดออกไป ค่า p99 คือสิ่งที่ผู้ใช้สังเกตเห็นได้ชัดเจน เพราะมันคือช่วงจังหวะที่หยุดชะงักกลางประโยค
กระบวนการ prefill ทำให้ปัญหานี้ชัดเจนยิ่งขึ้น เมื่อมี prompt ขนาดใหญ่เข้ามาในระหว่างการ streaming จะทำให้ตัวประมวลผลต้องทำงานในขั้นตอนเดียวนานขึ้น ส่งผลให้ทุกคนที่กำลัง streaming อยู่เห็นช่วงว่าง การทำ chunked prefill ช่วยลดปัญหาส่วนใหญ่ได้โดยการตัด prompt ยาวๆ ออกเป็นส่วนย่อยแล้วผสมแต่ละส่วนเข้ากับ batch ของการ decode ณ เดือนสิงหาคม 2026 เอนจิน vLLM V1 ได้เปิดใช้งานฟีเจอร์นี้เป็นค่าเริ่มต้นและให้ผู้ใช้ปรับสมดุลได้ผ่าน --max-num-batched-tokens เอกสารการปรับแต่ง vLLM ระบุถึงการแลกเปลี่ยนนี้ไว้อย่างชัดเจนว่า ค่าที่น้อยลงประมาณ 2048 จะให้ค่า inter token latency (ITL) ที่ดีกว่าเนื่องจากมีการขัดจังหวะการ decode ด้วย prefill น้อยลง และค่าที่มากขึ้นจะให้ค่า TTFT ที่ดีกว่าเนื่องจากสามารถใส่ token ของ prefill ลงใน batch เดียวได้มากขึ้น แฟล็กตัวเดียวนี้นำเสนอการเลือกระหว่าง prefill กับ decode ในรูปแบบของตัวเลขที่คุณสามารถปรับได้ ส่วนจุดที่ค่า p99 จะเริ่มไม่เป็นที่ยอมรับนั้นเป็นคำถามเรื่องขีดความสามารถของระบบ ซึ่งเนื้อหาเรื่อง จำนวนผู้ใช้พร้อมกันที่ LLM แบบ self-hosted หนึ่งตัวสามารถรองรับได้ ได้อธิบายรายละเอียดผ่านตัวชี้วัดเดียวกันนี้ไว้แล้ว
เหตุใด GPU ที่ใหญ่ขึ้นบางครั้งจึงไม่ช่วยให้ประสิทธิภาพดีขึ้น
เนื่องจากขนาดที่ใหญ่ขึ้นมักหมายถึงพลังในการประมวลผลที่มากขึ้น แต่กระบวนการ decode ไม่ได้ต้องการพลังประมวลผลดังกล่าว
ลองเปรียบเทียบสองแถวจากตารางด้านบน A100 80GB มีแบนด์วิดท์ที่ระบุไว้ที่ 2039 GB/s ในขณะที่ L40S อยู่ที่ 864 GB/s ซึ่งขีดจำกัดของ decode ก็เป็นไปตามนั้นอย่างแม่นยำ คือ 127 tokens ต่อวินาที เทียบกับ 54 ส่วน RTX 4090 เป็นการ์ดที่เร็วมากในเกือบทุกด้าน และด้วยแบนด์วิดท์ 1008 GB/s ทำให้ขีดจำกัดของมันอยู่ที่ 63 ไม่ว่าการ์ดสองใบจะแตกต่างกันในด้านอื่นอย่างไร การ decode แบบ single stream จะแปรผันตรงกับค่าแบนด์วิดท์ที่ระบุไว้ในเอกสารข้อมูลทางเทคนิคเสมอ
ดังนั้น จึงมีสองวิธีในการทำให้ decode เร็วขึ้น ได้แก่ การอ่านจำนวนไบต์ต่อ token ให้น้อยลง (ทำ quantization ให้กับ weight หรือรันโมเดลที่เล็กลง) หรือการซื้อแบนด์วิดท์ที่มากขึ้น ส่วนกระบวนการ prefill นั้นตรงกันข้าม เพราะต้องการพลังประมวลผล ดังนั้นการ์ดที่เร็วกว่าจึงช่วยลดเวลา TTFT สำหรับ prompt ที่ยาวได้อย่างแท้จริง หากปัญหาคือการรอ token แรกนานถึงสี่วินาที ฮาร์ดแวร์ที่ดีขึ้นอาจช่วยแก้ปัญหานี้ได้ แต่หากปัญหาคือข้อความแสดงผลช้า ฮาร์ดแวร์ใหม่มักจะไม่ช่วยอะไร
ควรแยกการทำงานของ prefill และ decode ไว้คนละ worker หรือไม่?
กลุ่มซอฟต์แวร์สำหรับให้บริการขนาดใหญ่ใช้วิธีนี้โดยเฉพาะ ซึ่งเรียกว่าการแยกส่วน prefill และ decode (prefill and decode disaggregation) โดยจะมีกลุ่ม worker ชุดหนึ่งทำหน้าที่ prefill เพียงอย่างเดียว และอีกชุดทำหน้าที่ decode เพียงอย่างเดียว จากนั้น KV cache ที่สร้างขึ้นจากชุดแรกจะถูกส่งต่อไปยังชุดที่สองผ่านเครือข่ายความเร็วสูง วิธีนี้ได้ผลเพราะทั้งสองขั้นตอนต้องการฮาร์ดแวร์และการจัดตารางเวลาที่แตกต่างกัน โดย prefill ต้องการพลังการประมวลผลและ batch ขนาดใหญ่ ส่วน decode ต้องการแบนด์วิดท์และรองรับลำดับงานที่ทำงานพร้อมกันจำนวนมาก การแยกส่วนช่วยให้แต่ละกลุ่มขยายขนาดได้ด้วยตนเอง และป้องกันไม่ให้ prompt ขนาดใหญ่เพียงรายการเดียวทำให้ stream อื่นๆ ทั้งหมดต้องหยุดชะงัก
บน VPS (virtual private server) เครื่องเดียวที่มี GPU เพียงตัวเดียว แทบไม่มีความจำเป็นต้องทำเช่นนี้ เพราะจะเป็นการแบ่งทรัพยากรของอุปกรณ์ชิ้นเดียวให้แย่งกันเอง และจะเปลี่ยนการอ้างอิง pointer ให้กลายเป็นการโอนย้าย cache ขนาดหลาย gigabyte ผ่านเครือข่าย เทคนิคนี้จะคุ้มค่าก็ต่อเมื่อคุณมีตัวเร่งความเร็ว (accelerator) มากพอที่จะอุทิศเครื่องทั้งเครื่องให้กับแต่ละขั้นตอน และมีปริมาณ traffic ที่สม่ำเสมอมากพอที่จะทำให้ทั้งสองกลุ่มทำงานได้ตลอดเวลา หากต่ำกว่าระดับนั้น การใช้ chunked prefill จะให้ผลลัพธ์ด้านการแยกส่วนที่ใกล้เคียงกันมากกว่าด้วยการตั้งค่าเพียง flag เดียว
สิ่งที่ควรปรับเปลี่ยนเมื่อค่าตัวเลขไม่เป็นไปตามเป้าหมาย
เมื่อค่า TTFT สูงเกินไป:
- ลดความยาวของ prompt ลง ค่าใช้จ่ายของ prefill จะนับรวมจำนวน token ของ prompt และ system prompt จะถูกคิดค่าใช้จ่ายในทุกคำขอ
- เปิดใช้งาน prefix caching เพื่อให้ส่วนนำที่ซ้ำกันถูกคำนวณเพียงครั้งเดียวแทนที่จะคำนวณใหม่ทุกครั้ง
- เพิ่มค่า
--max-num-batched-tokensเพื่อให้งานในส่วน prefill ถูกประมวลผลในแต่ละขั้นตอนมากขึ้น - ตรวจสอบคิวงานก่อนที่จะสรุปว่าเป็นปัญหาที่ตัวโมเดล หากค่า
vllm:num_requests_waitingมากกว่าศูนย์ แสดงว่าคำขอยังไม่ได้เริ่มประมวลผล ซึ่งเป็นปัญหาด้านขีดความสามารถในการรองรับ
เมื่อค่า tokens per second ต่ำเกินไป:
- ทำการ quantize น้ำหนักของโมเดล การใช้จำนวนไบต์ต่อหนึ่งน้ำหนักที่น้อยลง จะช่วยลดจำนวนไบต์ที่ต้องอ่านต่อหนึ่ง token
- ตรวจสอบ memory bandwidth ของการ์ดจอตามที่ระบุไว้ในเอกสารเทียบกับแผนภูมิด้านบน เพื่อดูว่าการใช้งานของคุณใกล้ถึงขีดจำกัดเพียงใด
- ลดค่า
--max-num-batched-tokensเพื่อให้การทำ prefill ขัดจังหวะการทำ decode น้อยลง - ตรวจสอบความยาวของ context การสนทนาที่มีความยาวหลายพัน token จะต้องอ่าน KV cache ที่มีขนาดใหญ่ขึ้นในทุกขั้นตอน
Runtime ก็มีผลในส่วนนี้เช่นกัน เนื่องจาก Ollama และ vLLM มีการจัดตารางเวลาสำหรับ prefill และ decode ที่แตกต่างกัน การตั้งค่าที่ช่วยเพิ่มประสิทธิภาพในตัวหนึ่งอาจไม่มีผลกับอีกตัวหนึ่ง ให้ทำการวัดผลก่อนเสมอทั้งในสองระยะ แล้วจึงค่อยปรับเปลี่ยนทีละอย่าง
FAQ
ทำไมโทเค็นแรกถึงใช้เวลาหลายวินาทีกว่าจะแสดง แต่หลังจากนั้นถึงสตรีมได้เร็ว?
การรอคอยนั้นคือขั้นตอน prefill ส่วนการสตรีมคือขั้นตอน decode โดย prefill จะประมวลผล prompt ทั้งหมดในรอบการคำนวณเดียวเพื่อให้ได้ผลลัพธ์แรกออกมา ดังนั้นต้นทุนจึงเพิ่มขึ้นตามความยาวของ prompt จากนั้น decode จะปล่อยโทเค็นออกมาทีละหนึ่งตัวต่อขั้นตอนด้วยความเร็วที่ขึ้นอยู่กับ memory bandwidth ซึ่งแทบไม่ขึ้นอยู่กับความยาวของ prompt เดิม สาเหตุที่พบบ่อยคือการใช้ system prompt ที่ยาวในทุกคำขอ ซึ่งการทำ prefix caching จะช่วยลดต้นทุนในส่วนที่ซ้ำซ้อนนี้ได้
prompt ที่ยาวขึ้นทำให้จำนวนโทเค็นต่อวินาทีช้าลงหรือไม่?
ช้าลงเล็กน้อย แต่ด้วยเหตุผลที่ต่างจาก TTFT ในทุกขั้นตอนของ decode ระบบจะต้องอ่าน keys และ values ของโทเค็นก่อนหน้าทั้งหมด ดังนั้น KV cache ที่ใหญ่ขึ้นจึงหมายถึงจำนวนไบต์ที่ต้องอ่านต่อโทเค็นมากขึ้น สำหรับโครงสร้างโมเดลขนาด 8 พันล้านพารามิเตอร์ทั่วไป cache จะมีขนาดประมาณ 128 KiB ต่อโทเค็น ดังนั้นบริบทขนาด 8,000 โทเค็นจึงเท่ากับประมาณ 1 GB ที่ต้องถูกเข้าถึงในทุกขั้นตอน อย่างไรก็ตาม ผลกระทบที่ชัดเจนกว่าของ prompt ที่ยาวจะยังคงอยู่ที่ TTFT ไม่ใช่ความเร็วในการสตรีม
ข้อมูลจำเพาะของ GPU ใดที่ใช้คาดการณ์ความเร็วในการ decode?
Memory bandwidth ให้คุณนำค่า bandwidth ที่ระบุไว้มาหารด้วยขนาดของ weights ในหน่วยความจำ คุณจะได้เพดานความเร็วสูงสุดทางทฤษฎีสำหรับหนึ่งสตรีม การ์ดที่มีพลังประมวลผลสูงกว่าแต่มี bandwidth เท่าเดิมจะไม่สามารถสตรีมได้เร็วขึ้น นี่คือเหตุผลว่าทำไมการทำ quantization เหลือ 8 bits จึงช่วยเพิ่มความเร็วในการ decode ได้ประมาณสองเท่า เพราะมันช่วยลดจำนวนไบต์ที่ต้องอ่านต่อโทเค็นลงครึ่งหนึ่งโดยไม่ต้องพึ่งพาพลังประมวลผลเพิ่ม
ทำไม throughput ถึงเพิ่มขึ้นเมื่อเพิ่มจำนวนผู้ใช้ แต่ผู้ใช้แต่ละคนกลับรู้สึกว่าช้าลง?
การอ่าน weights หนึ่งครั้งสามารถสร้างโทเค็นให้กับทุก sequence ใน batch ได้ ดังนั้นจำนวนโทเค็นรวมต่อวินาทีจึงเพิ่มขึ้นตามขนาดของ batch แต่ในขณะเดียวกัน โทเค็นของแต่ละคนจะต้องรอขั้นตอนการประมวลผลร่วมกัน ทำให้ latency ต่อผู้ใช้เพิ่มขึ้นตามไปด้วย ให้ตรวจสอบค่า p99 inter token latency แทนการดูตัวเลข throughput รวม และตรวจสอบ vllm:num_requests_waiting เพื่อดูว่าคำขอกำลังเข้าคิวรออยู่แทนที่จะถูกประมวลผลหรือไม่