بهینهسازی استنتاج مدلهای زبانی بزرگ (LLM Serving) با vLLM و PagedAttention
کالبدشکافی گلوگاه پهنای باند حافظه در تولید خودرگرسیو توکنها، مدیریت حافظه مجازی KV-Cache با الگوریتم PagedAttention، اشتراک پیشوند (Prefix Caching) و سرویسدهی همزمان به صدها کاربر.
چرا استنتاج مدلهای زبانی بزرگ (LLM Inference) محدود به حافظه است نه پردازنده؟
برخلاف تصور رایج، تولید توکن در مدلهای زبانی بزرگ دارای دو فاز کاملاً متفاوت با ویژگیهای سختافزاری متضاد است:
- ۱. فاز Prefill (پردازش موازی پرامپت ورودی): تمام توکنهای ورودی کاربر در یک گام به صورت ماتریس در ماتریس ضرب میشوند. این فاز Compute-Bound (محدود به توان محاسباتی Tensor Coreهای کارت گرافیک) است و معیار کلیدی آن TTFT (Time To First Token) میباشد.
- ۲. فاز Autoregressive Decode (تولید توکن به توکن): مدل برای تولید هر توکن جدید، باید به بردارهای کلید () و مقدار () تمام توکنهای قبلی توجه (Attention) کند. برای جلوگیری از محاسبه مجدد توکنهای قبلی، این تانسورها در حافظه VRAM کارت گرافیک تحت عنوان KV-Cache ذخیره میشوند. در این فاز، سرعت تولید هر توکن (ITL - Inter-Token Latency) کاملاً محدود به پهنای باند حافظه HBM کارت گرافیک (Memory-Bandwidth Bound) و ظرفیت خالی VRAM برای نگهداری KV-Cache کاربران همزمان است.
محاسبه ریاضی حجم حافظه KV-Cache
برای یک مدل با لایه، سر توجه (در معماری GQA)، بُعد هر سر و دقت ۱۶ بیتی (FP16 / ۲ بایت)، حجم حافظه KV-Cache به ازای هر توکن برابر است با:
در فریمورکهای سنتی (مانند HuggingFace Transformers معمولی)، چون طول خروجی مدل از قبل مشخص نیست، سیستم مجبور است به ازای هر درخواست، یک بلوک حافظه پیوسته (Contiguous) به اندازه حداکثر طول زمینه (مثلاً 8192 توکن) از پیش رزرو کند. این کار باعث هدررفت ۶۰ تا ۸۰ درصد حافظه VRAM به دلیل Internal Fragmentation (خالی ماندن فضای رزروشده) و External Fragmentation میشود و اندازه Batch را به شدت محدود میسازد.
انقلاب PagedAttention و Continuous Batching در vLLM
موتور vLLM با الهام مستقیم از مدیریت حافظه مجازی و صفحهبندی (Virtual Memory & Paging) در هسته سیستمعامل لینوکس، الگوریتم PagedAttention را معرفی کرد:
- حذف نیاز به حافظه پیوسته: فضای KV-Cache به بلوکهای فیزیکی کوچک با اندازه ثابت (مثلاً هر بلوک شامل ۱۶ توکن) تقسیم میشود که میتوانند در هر جای حافظه VRAM به صورت ناپیوسته قرار گیرند.
- تخصیص پویا در لحظه (On-Demand Block Allocation): یک جدول صفحه (Block Table) بلوکهای منطقی توکنهای هر کاربر را به بلوکهای فیزیکی VRAM نگاشت میکند. حافظه جدید تنها زمانی تخصیص مییابد که ۱۶ توکن قبلی پر شده باشند؛ در نتیجه هدررفت حافظه به زیر ۴ درصد میرسد!
- اشتراک پیشوند خودکار (Automatic Prefix Caching / Copy-on-Write): اگر صدها کاربر همزمان یک System Prompt طولانی یا سند مرجع مشترک داشته باشند، بلوکهای KV-Cache آن پیشوند تنها یک بار محاسبه شده و بین تمام درخواستها در سطح اشارهگرهای حافظه به اشتراک گذاشته میشود.
- دستهبندی پیوسته در سطح تکرار (Continuous Iteration-Level Batching): برخلاف Static Batching که تمام درخواستهای کوتاه باید منتظر بمانند تا طولانیترین درخواستِ داخل دسته تمام شود، در vLLM به محض تولید توکنِ پایان (
EOS) برای یک کاربر، درخواست او در همان گام از دسته خارج شده و بلافاصله درخواست جدیدی از صف جایگزین آن میگردد.
پیادهسازی سرور استنتاج غیرهمگام و پرظرفیت با AsyncLLMEngine در vLLM
در کد زیر، یک موتور سرویسدهی سطح پروداکشن مبتنی بر AsyncLLMEngine پیادهسازی شده است که قابلیتهای PagedAttention، Automatic Prefix Caching، کوانتیزاسیون FP8 KV-Cache و تولید خروجی ساختاریافته (Guided JSON Decoding) را برای پاسخگویی همزمان به صدها درخواست به کار میگیرد:
import asyncio
import time
import uuid
from typing import AsyncGenerator, Dict, Any
from vllm import AsyncEngineArgs, AsyncLLMEngine, SamplingParams
from vllm.sampling_params import GuidedDecodingParams
class HighThroughputLLMService:
"""
Production vLLM Serving Engine utilizing PagedAttention,
Automatic Prefix Caching, and Continuous Iteration-Level Batching.
"""
def __init__(self, model_id: str = 'Qwen/Qwen2.5-14B-Instruct', tp_size: int = 1):
engine_args = AsyncEngineArgs(
model=model_id,
tensor_parallel_size=tp_size,
gpu_memory_utilization=0.92,
max_model_len=16384,
block_size=16,
enable_prefix_caching=True,
enable_chunked_prefill=True,
max_num_batched_tokens=4096,
max_num_seqs=256,
kv_cache_dtype='fp8',
trust_remote_code=True
)
self.engine = AsyncLLMEngine.from_engine_args(engine_args)
async def stream_inference(
self,
system_prompt: str,
user_query: str,
json_schema: Dict[str, Any] = None
) -> AsyncGenerator[Dict[str, Any], None]:
request_id = f'req-{uuid.uuid4().hex[:12]}'
full_prompt = f'<|im_start|>system\n{system_prompt}<|im_end|>\n<|im_start|>user\n{user_query}<|im_end|>\n<|im_start|>assistant\n'
guided_params = GuidedDecodingParams(json=json_schema) if json_schema else None
sampling_params = SamplingParams(
temperature=0.2,
top_p=0.9,
max_tokens=1024,
guided_decoding=guided_params
)
start_ts = time.perf_counter()
first_token_ts = None
prev_text_len = 0
async for request_output in self.engine.generate(full_prompt, sampling_params, request_id):
now = time.perf_counter()
if first_token_ts is None:
first_token_ts = now
generated_text = request_output.outputs[0].text
delta_token = generated_text[prev_text_len:]
prev_text_len = len(generated_text)
yield {
'request_id': request_id,
'delta': delta_token,
'ttft_ms': round((first_token_ts - start_ts) * 1000, 2),
'finished': request_output.finished
}
async def benchmark_concurrent_requests():
service = HighThroughputLLMService()
shared_system_context = 'You are an expert distributed systems architect. ' * 150
async def single_client(idx: int):
async for chunk in service.stream_inference(shared_system_context, f'Explain Raft term election step {idx}'):
if chunk['finished']:
print(f"Client {idx} completed | TTFT: {chunk['ttft_ms']} ms")
await asyncio.gather(*(single_client(i) for i in range(50)))نکته کلیدی مهندسی: در بارهای کاری که ترکیبی از پرامپتهای بسیار طولانی (RAG) و گفتگوهای کوتاه هستند، حتماً پارامتر enable_chunked_prefill=True را در vLLM فعال کنید. بدون این قابلیت، ورود یک پرامپت ۱۰ هزار توکنی باعث میشود فاز Prefill آن، تولید توکن برای دهها کاربر دیگر را به مدت چندصد میلیثانیه متوقف کند (پدیده Head-of-Line Blocking). با فعالسازی Chunked Prefill، پرامپت بزرگ به قطعات کوچک شکسته شده و در کنار گامهای Decode سایر کاربران به صورت نرم و بدون پرش تاخیر (ITL Spike) پردازش میشود.