ทำไม LLM ที่โฮสต์เองถึงทำงานช้าเมื่อมีผู้ใช้พร้อมกัน
พบสาเหตุที่ LLM ของคุณทำงานช้าเมื่อมีผู้ใช้ 5 คนพร้อมกัน เรียนรู้เรื่องการจัดการ Batching, KV Cache และค่า OLLAMA_NUM_PARALLEL ที่ส่งผลต่อคิวการประมวลผลโทเค็นของคุณ
ทำไม LLM ที่โฮสต์เองถึงทำงานช้าลงเมื่อมีผู้ใช้เพิ่มขึ้น
LLM ที่โฮสต์เองจะเกิดอาการค้างเมื่อมีผู้ใช้พร้อมกัน 5 คน เนื่องจากเซิร์ฟเวอร์ยังคงสร้างคำตอบได้ทีละหนึ่งรายการเท่านั้น ส่วนอีกสี่รายการที่เหลือจะต้องเข้าคิวรอ เอกสารของ Ollama ระบุไว้อย่างชัดเจนเกี่ยวกับค่าเริ่มต้นว่า OLLAMA_NUM_PARALLEL คือ "จำนวนคำขอสูงสุดที่แต่ละโมเดลจะประมวลผลพร้อมกัน โดยมีค่าเริ่มต้นเป็น 1" ระบบไม่ได้เสียหายแต่อย่างใด เพียงแต่ผู้ใช้สี่ในห้าคนของคุณกำลังรอคิวอยู่เท่านั้น
การแก้ไขปัญหานี้มักไม่ใช่การอัปเกรดฮาร์ดแวร์ให้ใหญ่ขึ้น แต่เป็นการใช้ serving engine ที่สามารถส่งคำขอจำนวนมากผ่านโมเดลในการประมวลผล (forward pass) เดียวกัน พร้อมกับต้องมีหน่วยความจำสำรองเพียงพอที่จะเก็บข้อมูลการสนทนาของทุกคนในระหว่างนั้น ทั้งสองส่วนมีความสำคัญเท่าเทียมกัน และส่วนที่สองคือสิ่งที่กำหนดขีดจำกัดสูงสุดของระบบคุณอย่างแท้จริง
สองขั้นตอนที่ทุกคำขอต้องผ่าน
Prefill จะอ่าน prompt ทั้งหมดในคราวเดียวและสร้าง attention cache สำหรับ prompt นั้น โทเค็นทุกตัวใน prompt จะผ่านโมเดลไปพร้อมกัน ดังนั้น prefill จึงเป็นการคูณเมทริกซ์ขนาดใหญ่หนึ่งครั้ง ซึ่งถูกจำกัดด้วยความเร็วในการคำนวณ (arithmetic throughput) จากนั้น Decode จะเขียนคำตอบออกมาทีละหนึ่งโทเค็น แต่ละโทเค็นจำเป็นต้องอ่าน weight ทั้งหมดของโมเดลออกจากหน่วยความจำใหม่อีกครั้ง ในขณะที่การคำนวณสำหรับโทเค็นเดียวนั้นมีขนาดเล็กมาก Decode จึงถูกจำกัดด้วยแบนด์วิดท์ของหน่วยความจำ (memory bandwidth)
ความไม่สมมาตรนี้คือเหตุผลทั้งหมดที่การทำ batching ได้ผล การถอดรหัส (decode) สำหรับผู้ใช้หนึ่งรายต้องอ่าน weight ประมาณ 5 GB ต่อโทเค็น และปล่อยให้หน่วยประมวลผลส่วนใหญ่ว่างงาน หากเพิ่มคำขอที่สองเข้ามา ระบบจะอ่าน 5 GB เดิมเพียงครั้งเดียว แล้วคำนวณโทเค็นสำหรับทั้งสองคำขอพร้อมกัน ผู้ใช้คนที่สองจึงแทบไม่เสียเวลาเพิ่มขึ้นเลย การให้บริการคำขอแบบเรียงลำดับทีละรายการอย่างเคร่งครัดจะทำให้สูญเสียประสิทธิภาพส่วนนี้ไป
ตัวเลขสองค่าที่อธิบายประสบการณ์ของผู้ใช้คือ TTFT (time to first token) ซึ่งประกอบด้วยเวลารอในคิวรวมกับเวลา prefill และ ITL (inter-token latency) ซึ่งเป็นช่วงว่างระหว่างโทเค็นที่สตรีมออกมา โดยถูกกำหนดโดยขั้นตอน decode เซิร์ฟเวอร์ที่ทำงานช้ามักจะมีปัญหาในค่าใดค่าหนึ่งนี้ และวิธีการแก้ไขสำหรับทั้งสองกรณีนั้นไม่เหมือนกัน
Static batching ทำให้ทุกคนต้องรอการตอบกลับที่ช้าที่สุด
Static batching เป็นวิธีแบบพื้นฐาน ซึ่งเกิดขึ้นเมื่อคุณจัดกลุ่มคำขอด้วยตนเองในโค้ดของแอปพลิเคชัน ตัวเอนจินจะรวบรวมคำขอจำนวน N รายการเข้าด้วยกัน แล้วประมวลผลพร้อมกัน โดยจะกักทุกสล็อตไว้จนกว่าการสร้างข้อความที่ยาวที่สุดในกลุ่มจะเสร็จสิ้น
ผู้ใช้รายหนึ่งที่ขอสรุปความยาว 1,200 token จะทำให้คำตอบแบบบรรทัดเดียวอีก 4 รายการถูกล็อกไว้ใน batch เนื่องจาก batch จะไม่ปล่อยสล็อตใดๆ จนกว่าสมาชิกที่ช้าที่สุดจะทำงานเสร็จ
ผลที่ตามมามี 2 ประการ ประการแรก ลำดับที่เสร็จสิ้นแล้วยังคงครอบครองสล็อตโดยไม่ได้ประมวลผลงานที่มีประโยชน์ ส่งผลให้ throughput โดยรวมลดลงเมื่อความยาวของผลลัพธ์มีความแตกต่างกัน ซึ่งความยาวของผลลัพธ์ในการแชทนั้นมีความผันผวนสูง ประการที่สอง คำขอที่เข้ามาหลังจาก batch ถูกสร้างขึ้นแล้วหนึ่งขั้นตอน จะต้องรอให้ batch ทั้งหมดทำงานจนเสร็จก่อนที่จะเริ่มขั้นตอน prefill ซึ่งหมายความว่าค่า TTFT ของคำขอนั้นจะถูกกำหนดโดยความยาวของงานที่ผู้อื่นส่งเข้ามา
Continuous batching จะรับและปลดคำขอออกในทุกๆ token
Continuous batching จะทำการจัดตารางเวลาในระดับขั้นตอนการถอดรหัส (decoding step) ทีละขั้นตอน หลังจากจบแต่ละขั้นตอน ตัวจัดตารางเวลาจะคัดลำดับที่เพิ่งส่ง stop token ออกไป จากนั้นจึงรับคำขอที่รออยู่ในคิวเข้ามาแทนที่ในช่องว่างที่ว่างลง คำตอบที่สิ้นสุดในขั้นตอนที่ 40 จะทำให้ช่องว่างนั้นว่างลงในขั้นตอนที่ 40 ทันที ไม่ใช่รอจนจบทั้ง batch
เรื่องนี้ไม่ใช่เรื่องแปลกใหม่ llama-server ระบุเอกสารของ -cb, --cont-batching ไว้ว่า "เลือกว่าจะเปิดใช้งาน continuous batching (หรือที่เรียกว่า dynamic batching) หรือไม่ (ค่าเริ่มต้น: เปิดใช้งาน)" ซึ่ง vLLM ก็ถูกสร้างขึ้นโดยใช้แนวคิดนี้เป็นหลัก ส่วน Ollama เองก็ให้บริการคำขอแบบขนานได้เช่นกัน ค่าเริ่มต้นเพียงแค่จำกัดจำนวนไว้ที่ 1 เท่านั้น ซึ่งเป็นเหตุผลว่าทำไมหลายคนจึงสรุปว่าฮาร์ดแวร์ของตนไม่สามารถรองรับการทำงานพร้อมกันได้ ทั้งที่จริงแล้วเป็นเพราะการตั้งค่าที่ระบุไว้ว่าไม่ให้ทำ
ผลลัพธ์ของ continuous batching ที่เผยแพร่มักวัดผลบนการ์ดระดับ datacenter ซึ่งมีทั้งพลังประมวลผลเหลือเฟือและหน่วยความจำสำหรับ cache ขนาดหลายสิบกิกะไบต์ รูปแบบของผลลัพธ์เหล่านั้นสามารถนำมาประยุกต์ใช้กับเครื่องของคุณได้ แต่ขนาดของมันนั้นไม่สามารถนำมาเทียบกันได้ ซึ่งส่วนของหน่วยความจำด้านล่างนี้คือเหตุผลว่าทำไม
การทำ Prefill แย่งทรัพยากรประมวลผลกับขั้นตอน Decode
เมื่อมีคำขอใหม่เข้ามาในขณะที่กำลังสตรีมคำตอบอยู่ 4 รายการ ระบบจะต้องทำ prefill สำหรับ prompt นั้นก่อน ซึ่ง prefill เป็นงานที่ใช้ทรัพยากรประมวลผลสูง หากตัวจัดตารางเวลา (scheduler) ให้ขั้นตอน prefill นี้ทำงานแยกเป็นเอกเทศ ผู้ใช้งานทั้ง 4 รายที่กำลังสตรีมอยู่จะไม่ได้รับ token ใดๆ ในระหว่างนั้น สำหรับ prompt ที่มีความยาว การหยุดชะงักนี้จะสังเกตเห็นได้ชัดเจนในทุกหน้าต่างที่เปิดอยู่ นี่คืออาการกระตุกที่ผู้ใช้มักกล่าวถึงเมื่อบอกว่าเซิร์ฟเวอร์มีอาการสะดุดทุกครั้งที่มีคนอื่นกดส่งคำขอ
Chunked prefill จะแบ่ง prompt ที่ยาวออกเป็นส่วนย่อยๆ และแทรกแต่ละส่วนเข้าไปในขั้นตอนเดียวกับการทำ decode ที่กำลังทำงานอยู่ คู่มือการปรับแต่งของ vLLM ได้ระบุถึงข้อแลกเปลี่ยนนี้ไว้อย่างชัดเจนว่า: การกำหนด chunk budget ที่เล็กลง "จะช่วยให้ค่า ITL ดีขึ้นเนื่องจากมีการทำ prefill ที่ขัดจังหวะการ decode น้อยลง" ในขณะที่ค่าที่สูงกว่า "จะช่วยให้ค่า time to first token (TTFT) ดีขึ้น เนื่องจากสามารถประมวลผล token ของ prefill ได้มากขึ้นในหนึ่ง batch" คุณกำลังเลือกว่าจะปกป้องประสบการณ์ของผู้ใช้งานกลุ่มใด ระหว่างคนที่กำลังรอให้คำตอบเริ่มแสดงผล หรือคนที่กำลังเฝ้าดูข้อความที่กำลังสตรีมอยู่
ความยาวของ prompt เป็นตัวกำหนดว่าปัญหานี้จะส่งผลกระทบมากน้อยเพียงใด prompt ขนาด 6,000 token ที่มีคำตอบ 200 token จะเท่ากับงาน prefill 6,000 token เทียบกับขั้นตอน decode 200 ขั้น การแชทที่ใช้ Retrieval-augmented generation และ system prompt ที่ยาวล้วนผลักดันให้คุณเข้าสู่สภาวะนี้ ดังนั้น prefill จึงไม่ใช่แค่ส่วนเกินที่ตัดทิ้งได้ แต่กลายเป็นสิ่งที่ผู้ใช้ต้องรอคอย การทำ Prefix caching จะช่วยได้เมื่อส่วนที่ยาวนั้นมีการใช้งานซ้ำ: vLLM มีฟีเจอร์ --enable-prefix-caching ซึ่งจะนำ cache ของส่วน prefix ของ prompt ที่ใช้ร่วมกันกลับมาใช้ใหม่ แทนที่จะต้องคำนวณใหม่สำหรับทุกคำขอ
หน่วยความจำที่หมดก่อนใครคือ KV cache
ทุกโทเค็นในทุกการสนทนาที่กำลังดำเนินอยู่จะทิ้ง key vector และ value vector ไว้ในทุกเลเยอร์ของโมเดล สิ่งนี้คือ KV cache (key/value cache) ซึ่งช่วยให้ขั้นตอนการถอดรหัส (decode) ไม่ต้องคำนวณ prompt ใหม่ทั้งหมดสำหรับทุกโทเค็นที่สร้างขึ้น ขนาดของ cache ต่อโทเค็นถูกกำหนดไว้ตายตัวตามโครงสร้างของโมเดล คือ 2 (หนึ่ง key, หนึ่ง value) คูณด้วยจำนวนเลเยอร์ คูณด้วยจำนวน key/value heads คูณด้วยมิติของ head และคูณด้วยจำนวนไบต์ต่อค่าหนึ่งค่า คุณสามารถอ่านตัวเลขเหล่านี้ได้จาก config.json ของโมเดล
เมื่อคำนวณดูสักครั้ง ขีดจำกัดของหน่วยความจำก็จะไม่ใช่เรื่องลึกลับอีกต่อไป โมเดลขนาด 8B ทั่วไปที่มี 36 เลเยอร์, 8 key/value heads และมิติของ head เท่ากับ 128 โดยเก็บ cache ในรูปแบบ 16-bit จะใช้หน่วยความจำ 2 36 8 128 2 ไบต์ต่อโทเค็น ซึ่งเท่ากับ 147,456 ไบต์ หรือประมาณ 144 KiB ดังนั้นการสนทนาที่มีความยาว 8,192 โทเค็นจึงต้องการ cache ประมาณ 1.2 GB หากมี 5 การสนทนาพร้อมกันก็จะต้องการ cache ประมาณ 6 GB นอกเหนือจากขนาดของ weight ซึ่งนี่คือคำตอบที่แท้จริงว่าระบบรองรับผู้ใช้งานได้กี่คน
การทำงานพร้อมกัน (concurrency) จะทวีคูณขนาดของบริบท (context) และเครื่องมือต่างๆ ก็ระบุเรื่องนี้ไว้อย่างชัดเจน ใน FAQ ของ Ollama ระบุว่า: "การประมวลผลคำขอแบบขนานสำหรับโมเดลหนึ่งๆ จะส่งผลให้ขนาดของบริบทเพิ่มขึ้นตามจำนวนคำขอที่ทำพร้อมกัน ตัวอย่างเช่น บริบทขนาด 2K ที่มี 4 คำขอพร้อมกัน จะส่งผลให้เกิดบริบทขนาด 8K และมีการจัดสรรหน่วยความจำเพิ่มเติม" RAM ที่ต้องใช้จะแปรผันตาม OLLAMA_NUM_PARALLEL คูณด้วย OLLAMA_CONTEXT_LENGTH ใน llama-server ขนาดบริบทที่คุณกำหนดด้วย -c จะถูกแบ่งเฉลี่ยไปยัง -np slots ดังนั้นการเพิ่มจำนวน slot เพียงอย่างเดียวจะทำให้ความจุของแต่ละคำขอลดลง ควรตรวจสอบขนาดบริบทต่อ slot จาก log ตอนเริ่มต้นระบบแทนการคาดเดา
vLLM ใช้วิธีการจัดสรรหน่วยความจำล่วงหน้า (preallocate) แทน โดย --gpu-memory-utilization (ค่าเริ่มต้นคือ 0.92) คือ "สัดส่วนของหน่วยความจำ GPU ที่จะใช้สำหรับตัวประมวลผลโมเดล" พื้นที่ที่เหลือหลังจากโหลด weight แล้วจะถูกใช้เป็น paged KV pool และเมื่อ pool ดังกล่าวไม่เพียงพอ ตัวจัดการคำขอ (scheduler) จะทำการย้ายคำขอออก (evict) แทนที่จะปล่อยให้ระบบล้มเหลว:
WARNING 05-09 00:49:33 scheduler.py:1057] Sequence group 0 is preempted by PreemptionMode.RECOMPUTE mode because there is not enough KV cache space.ใน engine V1 ของ vLLM โหมดการย้ายคำขอออกเริ่มต้นคือ RECOMPUTE ดังนั้นคำขอที่ถูกย้ายออกจะทิ้ง cache ของตนเองและทำการ prefill ใหม่เมื่อได้รับอนุญาตให้กลับเข้ามาประมวลผลอีกครั้ง งานส่วนนี้จึงถูกทำซ้ำสองรอบ เอกสารประกอบเตือนว่า "การย้ายคำขอออกและการคำนวณใหม่ (preemption and recomputation) อาจส่งผลเสียต่อ latency โดยรวม" และบรรทัด log นี้คือคำอธิบายที่ดีที่สุดว่าทำไมผู้ใช้งานที่โชคร้ายคนหนึ่งถึงต้องรอนานกว่าคนอื่นมาก ในขณะที่ค่าเฉลี่ยของระบบยังดูปกติ คุณสามารถตั้งค่า disable_log_stats=False เพื่อบันทึกจำนวนสะสม หรืออ่านค่าตัวนับการย้ายคำขอ (preemption counter) ได้จาก Prometheus metrics ที่ vLLM เปิดให้เข้าถึง
การเปลี่ยนแปลงที่จำนวนผู้ใช้งานพร้อมกัน 2, 5 และ 20 ราย
ผู้ใช้งาน 2 ราย แทบไม่ส่งผลกระทบต่อ GPU ที่มีแคชเหลือเฟือ เนื่องจากสตรีมการถอดรหัสที่สองจะทำงานไปพร้อมกับสตรีมแรกโดยใช้เวลาเพิ่มขึ้นเพียงเล็กน้อย แต่สำหรับ VPS ที่ใช้ CPU เพียงอย่างเดียวและมี RAM 4 ถึง 8 GB นั้นไม่ใช่เรื่องฟรี ทั้งสองสตรีมต้องแชร์ vCPU จำนวนจำกัดและแบนด์วิดท์ RAM ร่วมกัน ส่งผลให้ผู้ใช้แต่ละรายได้รับจำนวนโทเค็นต่อวินาทีลดลงประมาณครึ่งหนึ่ง และความต้องการแคชจะเพิ่มขึ้นเป็นสองเท่าภายใต้งบประมาณทรัพยากรที่จำกัดกว่ามาก
ผู้ใช้งาน 5 ราย นี่คือจุดที่ค่าเริ่มต้นไม่เพียงพออีกต่อไป และปัญหาจะเริ่มจากการเป็นปัญหาคิวงาน หากตั้งค่า OLLAMA_NUM_PARALLEL ไว้ที่ 1 ผู้ใช้งานสี่คนจะต้องรอให้คนที่ขอคำตอบยาวๆ เสร็จสิ้นก่อน โดยแต่ละคนจะได้รับความเร็วปกติทันทีที่ถึงคิวของตนเอง หากเพิ่มจำนวนการประมวลผลแบบขนาน ปัญหาจะเปลี่ยนรูปแบบไป: การมี 5 สล็อตที่บริบทละ 8K หมายถึงต้องหาพื้นที่แคชสำหรับ 40K โทเค็น หากพื้นที่นี้ไม่พอใน VRAM เอนจินจะย้ายเลเยอร์ไปไว้ใน RAM ของระบบ และหากใน RAM ยังไม่พอ เครื่องจะทำการ swap และจำนวนโทเค็นต่อวินาทีจะลดลงอย่างรวดเร็ว
ผู้ใช้งาน 20 ราย โดยปกติแล้วมนุษย์ 20 คนใน UI แชทไม่ได้ส่งคำขอพร้อมกันทั้ง 20 รายการ และนี่คือสิ่งที่สำคัญที่สุดที่ควรเข้าใจก่อนตัดสินใจซื้อฮาร์ดแวร์ ผู้ใช้งานจะอ่านคำตอบและใช้เวลาคิด 20 ถึง 60 วินาทีก่อนจะส่งคำถามถัดไป ดังนั้นเซสชันส่วนใหญ่จึงอยู่ในสถานะว่าง แต่หากเป็นเอเจนต์ 20 ตัว หรือการสรุปเอกสาร 20 งาน นั่นหมายถึง 20 สตรีมที่ทำงานจริงโดยไม่มีเวลาว่างเลย ซึ่งต้องใช้เครื่องที่มีสเปกแตกต่างออกไปอย่างสิ้นเชิง
ผู้ใช้งานของคุณใช้งานพร้อมกัน หรือเพียงแค่ล็อกอินค้างไว้?
คำนวณจำนวนคำขอที่กำลังประมวลผล (requests in flight) ก่อนที่จะกำหนดขนาดของระบบ การคำนวณเป็นเลขคณิตพื้นฐาน: จำนวนคำขอที่กำลังประมวลผล เท่ากับ จำนวนผู้ใช้ คูณด้วยจำนวนวินาทีที่ใช้สร้างข้อความต่อหนึ่งรอบ หารด้วยจำนวนวินาทีระหว่างรอบ
- วัดความเร็วในการประมวลผลแบบ single-stream ของคุณเองก่อน โดยต้องรวมทั้งขั้นตอน prefill และ decode อย่าหยิบตัวเลขจากฮาร์ดแวร์ของผู้อื่นมาใช้: วัดจำนวน tokens per second บนเครื่องของคุณเอง แล้วใช้ค่าที่คุณวัดได้จริง
- ประเมิน duty cycle ตัวอย่างเช่น ผู้ใช้แชท 20 คน ใช้เวลาสร้างข้อความ 12 วินาทีต่อรอบ และมีการโต้ตอบทุก 90 วินาที จะได้ 20 * 12 / 90 ซึ่งเท่ากับประมาณ 2.7 คำขอที่กำลังประมวลผล
- กำหนดจำนวน slot ให้สูงกว่าค่าที่คำนวณได้เล็กน้อย จากนั้นตรวจสอบกับหน่วยความจำ: จำนวน slot คูณด้วย context ต่อคำขอ ต้องมีขนาดไม่เกินจำนวน cache tokens ที่คุณมีจริง
- รักษาความยาวของคิวให้สั้น เพื่อให้กรณีที่เกิด overflow ระบบจะแจ้งเตือนความล้มเหลวได้อย่างรวดเร็วและชัดเจน
จำนวน cache tokens ที่ใช้งานได้ คือหน่วยความจำที่เหลือหลังจากโหลด weights แล้ว หารด้วยต้นทุนต่อ token จากหัวข้อก่อนหน้า การ์ดขนาด 24 GB ที่รันโมเดล 8B แบบ 16-bit จะใช้หน่วยความจำประมาณ 16 GB สำหรับ weights และเหลือ cache ที่ใช้งานได้ประมาณ 6 GB ที่ระดับการใช้งานเริ่มต้น ซึ่งรองรับการสนทนาขนาด 8K ได้ประมาณ 5 รายการ หากต้องการรองรับมากขึ้น ให้ลดขนาด context ต่อคำขอ หรือจัดเก็บ cache แบบ 8-bit (llama-server ใช้ --cache-type-k q8_0) ทั้งสองวิธีช่วยเพิ่ม concurrency โดยต้องแลกกับประสิทธิภาพบางส่วน ซึ่งควรศึกษาความคุ้มค่าของการแลกเปลี่ยนนี้ก่อนตัดสินใจลงทุนในฮาร์ดแวร์: จุดคุ้มทุนระหว่าง GPU VPS กับการใช้ API tokens
เมื่อค่าเริ่มต้นของ Ollama ไม่เพียงพออีกต่อไป
ให้เพิ่มจำนวนการประมวลผลแบบขนาน (parallel count) ผ่าน service unit เนื่องจากคำสั่ง export ใน shell จะไม่มีผลกับ daemon ที่จัดการโดย systemd
sudo systemctl edit ollama.service[Service]
Environment="OLLAMA_NUM_PARALLEL=4"
Environment="OLLAMA_CONTEXT_LENGTH=8192"
Environment="OLLAMA_MAX_QUEUE=64"sudo systemctl daemon-reload
sudo systemctl restart ollama
systemctl show ollama --property=Environment
ollama pssystemctl show ควรแสดงตัวแปรทั้งสามที่คุณเพิ่งตั้งค่า หากไม่แสดงผล แสดงว่า drop-in file ไม่ถูกบันทึก และสิ่งอื่นที่คุณทำหลังจากนี้จะไม่มีผลใดๆ จากนั้น ollama ps จะแสดงรายการโมเดลที่โหลดอยู่พร้อมขนาดที่ใหญ่กว่าน้ำหนักของโมเดลเพียงอย่างเดียว เนื่องจาก 4 slots ที่ 8,192 tokens จะจองพื้นที่ cache เพิ่มอีก 32,768 tokens ไว้ข้างๆ หากคอลัมน์ PROCESSOR แสดงว่าโมเดลบางส่วนทำงานบน CPU ในขณะที่คุณคาดหวังให้ทำงานบน GPU ทั้งหมด นั่นหมายความว่าคุณกำหนดขนาด cache มากกว่าที่การ์ดจอจะเหลือพื้นที่ให้ ให้ลดตัวเลขตัวใดตัวหนึ่งลง
ค่าเริ่มต้นของคิว (queue) เป็นสิ่งที่ควรพิจารณาใหม่ Ollama รองรับคิวได้สูงสุด OLLAMA_MAX_QUEUE คำขอ และ "ค่าเริ่มต้นคือ 512" หากเกินกว่านั้น ระบบจะตอบกลับ "ด้วยข้อผิดพลาด 503 ซึ่งระบุว่าเซิร์ฟเวอร์ทำงานหนักเกินไป" การตั้งคิวไว้ที่ 512 บนเครื่องที่รองรับการทำงานพร้อมกันได้เพียง 4 คำขอ เป็นสัญญาที่คุณไม่สามารถทำตามได้จริง เพราะไคลเอนต์ที่อยู่ในลำดับที่ 300 จะหมดเวลา (timeout) ไปนานก่อนที่จะถึงคิวของตนเอง การตั้งคิวให้สั้นจะทำให้ระบบส่งข้อผิดพลาดกลับมา ซึ่งแอปพลิเคชันของคุณสามารถลองใหม่หรือรายงานผลได้ดีกว่าการปล่อยให้หน้าจอค้างโดยไม่มีการตอบสนอง
ทดสอบการใช้งานจริง ส่งคำขอสองรายการพร้อมกันจากสอง terminal แล้วสังเกตผลลัพธ์ หากคำขอที่สองไม่แสดงผลใดๆ จนกว่าคำขอแรกจะเสร็จสิ้น แสดงว่าการตั้งค่าแบบขนานไม่มีผลใช้งาน
เมื่อเอนจินสำหรับให้บริการเริ่มสร้างรายได้ด้วยตัวเอง
vLLM จะคุ้มค่ากับการตั้งค่าเพิ่มเติมเมื่อคุณมี GPU ที่มีทรัพยากรเหลือเฟือและมีคำขอที่กำลังประมวลผลอยู่จริงมากกว่า 4 รายการ ตัวจัดตารางเวลาของมันทำงานแบบรายโทเค็น แคชจะถูกแบ่งเป็นหน้าเพื่อให้สามารถนำส่วนที่ว่างกลับมาใช้ใหม่ได้ และมันจะเปลี่ยน VRAM ที่เหลืออยู่ให้กลายเป็นความสามารถในการประมวลผลพร้อมกันแทนที่จะปล่อยให้ว่างเปล่า ณ เดือนสิงหาคม 2026 การติดตั้งและเปิดใช้งานตามเอกสารประกอบมีเพียงสองคำสั่ง:
uv pip install vllm --torch-backend=auto
vllm serve Qwen/Qwen2.5-1.5B-Instructcurl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen2.5-1.5B-Instruct",
"messages": [{"role": "user", "content": "Say hello."}]
}'การตอบกลับที่มีอาร์เรย์ choices หมายความว่าเซิร์ฟเวอร์พร้อมทำงานและโมเดลถูกโหลดเรียบร้อยแล้ว ภายใต้ภาระงานหนัก ปุ่มปรับค่าสองอย่างที่มีความสำคัญคือ --max-num-seqs ซึ่งคือ "จำนวนลำดับสูงสุดที่จะประมวลผลในหนึ่งรอบการทำงาน" และ --max-num-batched-tokens ซึ่งคือ "จำนวนโทเค็นสูงสุดที่สามารถประมวลผลได้ในหนึ่งรอบการทำงาน" ค่าแรกจะเป็นตัวจำกัดความสามารถในการประมวลผลพร้อมกัน ส่วนค่าที่สองคืองบประมาณสำหรับการทำ chunked prefill ที่ได้อธิบายไปก่อนหน้านี้
หากมีคำขอที่กำลังประมวลผลอยู่น้อยกว่าสี่รายการ หรือบนเครื่องที่ไม่มี GPU ที่รองรับ vLLM จะเพิ่มความซับซ้อนโดยให้ผลตอบแทนน้อย มันต้องการการ์ดระดับ CUDA และจะจองหน่วยความจำส่วนใหญ่ไว้ตั้งแต่เริ่มทำงาน ซึ่งเป็นการแลกเปลี่ยนที่ไม่คุ้มค่าบน VPS ที่มีแรม 4 ถึง 8 GB ในกรณีนั้น คำตอบคือการใช้โมเดลที่เล็กลงพร้อมบริบทที่สั้นกว่าและคิวที่คุณควบคุมเอง ความแตกต่างระหว่าง Ollama และ vLLM ในฐานะเอนจินสำหรับให้บริการ ครอบคลุมการตัดสินใจเลือกอย่างละเอียด และ การรัน Qwen 3 8B บน VPS แสดงให้เห็นว่าโมเดลขนาดกลางต้องการทรัพยากรเท่าใดก่อนที่คุณจะเพิ่มผู้ใช้งานแม้เพียงคนเดียว
ข้อแลกเปลี่ยนที่ความเชื่อทั่วไปไม่ได้บอกไว้
Continuous batching ช่วยเพิ่ม throughput รวม และมักจะช่วยปรับปรุง median latency ให้ดีขึ้นด้วย เนื่องจากคำขอที่อยู่ในคิวจะเริ่มประมวลผลได้เร็วขึ้น อย่างไรก็ตาม tail latency จะได้รับผลกระทบในทางตรงกันข้าม ซึ่งเป็นประเด็นที่แทบไม่มีการกล่าวถึง
ทุก sequence ที่เพิ่มเข้ามาในแต่ละ step จะเพิ่มภาระงานเล็กน้อย ส่งผลให้ ITL ของทุกคนสูงขึ้นเมื่อ batch เริ่มเต็ม การ prefill ของคำขอใหม่จะเข้ามาแย่งทรัพยากรใน step ที่ควรจะเป็นของผู้ใช้งานที่กำลัง streaming อยู่ หากเกิดสภาวะ cache pressure ตัว scheduler จะทำการ preempt ซึ่งส่งผลให้คำขอที่กำลังสร้างอยู่ครึ่งทางต้องกลับไปเริ่มต้น prefill ใหม่
UI ของแชทแสดงผลที่ค่า tail ไม่ใช่ค่าเฉลี่ย การที่ stream หยุดชะงักไปสองวินาทีระหว่างประโยคจะทำให้ผู้ใช้รู้สึกว่าระบบมีปัญหา แม้ว่าเวลาโดยรวมจนเสร็จสิ้นจะอยู่ในเกณฑ์ดีก็ตาม ให้วัดค่า p95 TTFT และ p95 ITL ภายใต้โหลดที่คุณคาดการณ์ไว้ และให้ถือว่าค่า mean tokens per second เป็นเพียงตัวเลขบอกความจุ ไม่ใช่ตัวบ่งชี้ประสบการณ์การใช้งานจริง
แนวทางปฏิบัติที่เหมาะสมจึงสรุปได้ดังนี้ ให้จำกัด concurrency ไว้ต่ำกว่าขีดจำกัดของหน่วยความจำเล็กน้อย เพื่อให้ engine ไม่จำเป็นต้องทำการ preempt การมีคิวที่สั้นและคาดการณ์ได้นั้นดีกว่าการมี batch ขนาดใหญ่ที่ทำให้ระบบทำงานหนักเกินไป (thrashing) เพราะผู้ใช้ที่รอสี่วินาทีแล้วได้รับข้อมูลที่ไหลลื่นย่อมพึงพอใจมากกว่าผู้ใช้ที่เริ่มได้ทันทีแต่ต้องหยุดชะงักถึงสองครั้ง
สิ่งที่ควรตรวจสอบเมื่อระบบทำงานช้า
ผู้ใช้แต่ละรายใช้งานได้ปกติ แต่ต้องรอนาน นี่คือปัญหาคิว ไม่ใช่ปัญหาความเร็ว ให้ตรวจสอบการตั้งค่า parallel ก่อน โมเดลกำลังให้บริการอย่างถูกต้องโดยประมวลผลทีละคำขอ
เกิดข้อผิดพลาด HTTP 503 จาก Ollama คิวเต็ม อาจเป็นไปได้ว่าเครื่องทำงานเต็มขีดความสามารถแล้ว หรือมีการตั้งค่า OLLAMA_MAX_QUEUE ไว้ต่ำโดยเจตนาเพื่อลดภาระงาน ซึ่งเป็นพฤติกรรมที่ควรจะเป็นเมื่อระบบรับภาระหนัก
จำนวน Token ต่อวินาทีลดลงอย่างมากเมื่อมีโหลดบนเครื่องที่ใช้ CPU ให้รันคำสั่ง vmstat 1 ในขณะที่เกิดปัญหา หากคอลัมน์ si และ so มีค่าที่ไม่ใช่ศูนย์ แสดงว่าเครื่องกำลังทำ swapping ซึ่งหมายความว่าน้ำหนักของโมเดล (weights) กำลังถูกอ่านจากดิสก์ในทุกๆ token ที่สร้างขึ้น ไม่มีการตั้งค่าใดที่จะแก้ไขปัญหานี้ได้นอกจากต้องลดขนาดโมเดลหรือลดจำนวน slot ลง
ผู้ใช้หนึ่งในสิบต้องรอนานกว่าผู้ใช้อื่นอย่างเห็นได้ชัด ให้ค้นหาคำว่า preempted ใน log ของ vLLM สาเหตุส่วนใหญ่มักเกิดจากการทำ preemption และการคำนวณซ้ำ ซึ่งหมายความว่า cache ถูกใช้งานเกินขีดความสามารถสำหรับความยาวของบริบท (context length) ที่คุณกำหนดไว้
ค่า TTFT แย่แม้ในขณะที่เซิร์ฟเวอร์ว่างงาน นี่เป็นปัญหาที่ขั้นตอน prefill ไม่ใช่ concurrency การใช้ prompt ที่ยาวต้องใช้เวลาประมวลผลจริงก่อนที่ token แรกจะปรากฏ ดังนั้นให้ตรวจสอบขนาดของ prompt และการทำ prefix caching ก่อนที่จะตรวจสอบฮาร์ดแวร์
FAQ
ทำไม LLM ที่ผมโฮสต์เองถึงทำงานช้าลงเมื่อมีคนที่สองเข้ามาใช้งาน?
ส่วนใหญ่มันไม่ได้ช้าลง แต่เป็นการเข้าคิวรอ Ollama ตั้งค่า OLLAMA_NUM_PARALLEL ไว้ที่ 1 ทำให้คำขอที่สองต้องรอจนกว่าคำขอแรกจะส่งโทเค็นสุดท้ายออกมา คุณสามารถแยกสองกรณีนี้ได้โดยการจับเวลาการสตรีมของผู้ใช้คนแรกในขณะที่อีกคนกำลังรอ หากความเร็วโทเค็นต่อวินาทีของพวกเขาเป็นปกติหลังจากเริ่มทำงาน แสดงว่าคุณกำลังเจอปัญหาการเข้าคิว ซึ่งการเพิ่มจำนวน parallel จะช่วยแก้ปัญหานี้ได้ แต่ถ้าทั้งสองสตรีมทำงานด้วยความเร็วเพียงครึ่งเดียว แสดงว่าคุณกำลังแบ่งปันแบนด์วิดท์หน่วยความจำกันจริงๆ ซึ่งนั่นเป็นข้อจำกัดทางฮาร์ดแวร์
GPU ขนาดเล็กหนึ่งตัวสามารถรองรับผู้ใช้พร้อมกันได้กี่คน?
ให้คำนวณจากหน่วยความจำ ไม่ใช่จำนวนผู้ใช้ เริ่มจากน้ำหนักของโมเดล (weights) ตามด้วย KV cache ซึ่งมีค่าเท่ากับ 2 คูณจำนวนเลเยอร์ คูณจำนวน key/value heads คูณขนาดของ head และคูณจำนวนไบต์ ต่อหนึ่งโทเค็น ต่อหนึ่งการสนทนาที่กำลังทำงานอยู่ โมเดล 8B ทั่วไปที่มี 36 เลเยอร์, 8 key/value heads และขนาด head 128 จะใช้หน่วยความจำประมาณ 144 KiB ต่อโทเค็นที่ความละเอียด 16-bit ดังนั้นการสนทนาที่ความยาว 8,192 โทเค็นจะใช้พื้นที่ประมาณ 1.2 GB การ์ดจอขนาด 24 GB ที่โหลดโมเดลนี้ไว้ที่ 16-bit จะเหลือพื้นที่สำหรับ cache ประมาณ 6 GB ซึ่งรองรับได้ประมาณ 5 การสนทนาที่ context เต็ม หรือมากกว่านั้นหากคุณลดความยาว context ลง
การทำ continuous batching ทำให้การตอบกลับของผู้ใช้แต่ละคนช้าลงหรือไม่?
โดยปกติค่ามัธยฐานของ latency จะดีขึ้น เพราะคำขอไม่ต้องรอให้ทั้ง batch ทำงานจนเสร็จ แต่ค่า tail latency จะแย่ลง ลำดับงานที่เพิ่มเข้ามาแต่ละรายการจะเพิ่มภาระในทุกขั้นตอนการถอดรหัส (decoding) การทำ prefill ของคำขอใหม่จะแย่งเวลาบางส่วนไปจากผู้ใช้ที่กำลังสตรีมอยู่ และคำขอที่ถูกขัดจังหวะจะต้องทำ prefill สองครั้ง ให้วัดค่า p95 inter-token latency แทนค่าเฉลี่ย เพราะหน้าต่างแชทจะทำให้เห็นการหยุดชะงักได้ชัดเจนในแบบที่ค่าเฉลี่ยซ่อนไว้
ผมควรเพิ่ม OLLAMA_NUM_PARALLEL หรือย้ายไปใช้ vLLM?
ให้เพิ่มจำนวน parallel ก่อน เพราะทำได้ฟรีและใช้เพียงไฟล์ drop-in ไฟล์เดียว และมันช่วยแก้ปัญหาทั่วไปที่คนสี่คนต้องรอคิวต่อจากคำตอบยาวๆ ของคนแรกได้ หน่วยความจำคือข้อจำกัด: คำขอที่ทำงานพร้อมกันจะเพิ่มปริมาณ context ที่คุณต้องเก็บรักษา ดังนั้นให้คอยสังเกตว่ามีเลเยอร์ใดถูกผลักไปใช้ CPU หรือไม่ ให้ย้ายไปใช้ vLLM เมื่อคุณมี GPU ที่มี VRAM เหลือเฟือและมีคำขอที่กำลังทำงานอยู่จริงๆ มากกว่าสี่รายการขึ้นไป เพราะนั่นคือจุดที่ paged cache และการจัดตารางเวลาแบบ per-token จะให้ผลลัพธ์ที่คุ้มค่ากว่าต้นทุนที่เสียไป
การเพิ่มจำนวน CPU cores จะช่วยแก้ปัญหาเซิร์ฟเวอร์ LLM ช้าได้หรือไม่?
ไม่ได้ช่วยในส่วนที่ผู้ใช้สังเกตเห็นได้มากที่สุด การถอดรหัส (decode) จะอ่านโมเดลทั้งหมดจากหน่วยความจำสำหรับทุกๆ โทเค็น ดังนั้นมันจึงถูกจำกัดด้วยแบนด์วิดท์ของ RAM และจำนวนคอร์ที่เพิ่มขึ้นจะไม่ช่วยอะไรเมื่อแบนด์วิดท์อิ่มตัวแล้ว การทำ prefill จะปรับขนาดตามจำนวนคอร์ได้ ดังนั้นการเพิ่มคอร์จะช่วยลดเวลาในการแสดงโทเค็นแรกสำหรับ prompt ที่ยาวๆ ได้ สำหรับ VPS ขนาด 4 ถึง 8 GB ข้อจำกัดหลักมักจะเป็นความจุของหน่วยความจำ และวิธีแก้ปัญหาที่มีประสิทธิภาพคือการใช้โมเดลที่เล็กลงหรือลดความยาว context ลง แทนที่จะเพิ่มจำนวน vCPU