SSD Nodes Learn 🎉 VPS من $5.50/شهر
الأدلة Matt Connorبقلم Matt Connor

Prefill أم Decode: لماذا يتأخر الرمز الأول؟

افهم لماذا تعتمد Prefill على قدرة الحوسبة وDecode على عرض نطاق الذاكرة، ولماذا يقيس الرمز الأول زمناً مختلفاً عن عدد الرموز في الثانية.

Prefill مقابل Decode، في فقرة واحدة

يُعدّ الفرق بين Prefill وDecode التمييز الوحيد الذي يفسّر معظم الأسئلة المتعلقة بزمن الاستجابة في نموذج LLM (نموذج لغوي كبير) مستضاف ذاتياً. يقرأ Prefill المطالبة كاملةً في تمريرة واحدة، ويكون محدوداً بقدرة الحوسبة. يكتب Decode الإجابة رمزاً واحداً في كل مرة، ويكون محدوداً بعرض نطاق الذاكرة. زمن ظهور الرمز الأول هو قيمة تخص Prefill. وعدد الرموز في الثانية هو قيمة تخص Decode.

تعمل المرحلتان على GPU (وحدة معالجة الرسومات) نفسها، باستخدام الأوزان نفسها، وضمن العملية نفسها؛ لذلك من الطبيعي التعامل معهما كعبء عمل واحد. لكن سلوكهُما يشبه سلوك برنامجين مختلفين يتشاركان الجهاز نفسه. عند الفصل بينهما، تتوقف قائمة طويلة من النتائج المربكة عن كونها مربكة.

لماذا يكون حساب مرحلة Prefill مقيداً بقدرة المعالجة؟

تمرّر مرحلة Prefill الموجّه بالكامل عبر كل طبقة مرة واحدة. يمنح موجّه يتكوّن من 2,000 رمز كل عملية ضرب مصفوفات 2,000 صف من العمل، لذلك تنفّذ GPU قدراً كبيراً من العمليات الحسابية مقابل كل بايت من الأوزان تحمّله. تُسمّى هذه النسبة، أي عدد العمليات الحسابية لكل بايت منقول، الكثافة الحسابية، وتكون مرتفعة في مرحلة Prefill. يعمل الجهاز قريباً من حد قدرة المعالجة، بينما تبقى سعة ناقل الذاكرة غير مستغلة بالكامل.

تنتج مرحلة Prefill شيئين: ذاكرة KV المؤقتة، أي موترَي المفتاح والقيمة، لكل رمز في الموجّه، ورمز الإخراج الأول. لا يصل أي شيء إلى القارئ قبل انتهاء هذه المرحلة، ولذلك يكون زمن Prefill وزمن الوصول إلى الرمز الأول (TTFT) متقاربين جداً.

تزداد تكلفة Prefill مع طول الموجّه. الجزء الخطي هو عمل ضرب المصفوفات في كل طبقة. أما الجزء التربيعي فينتج عن آلية الانتباه، حيث ينتبه كل رمز إلى كل رمز سابق، ويبدأ تأثيره بالظهور عند استخدام سياق طويل. لذلك تؤدي مضاعفة طول الموجّه إلى مضاعفة TTFT على الأقل.

يمكنك ملاحظة ذلك خلال دقيقة واحدة. أرسل موجّهاً من 200 رمز إلى خادمك، ثم موجّهاً من 2,000 رمز، واطلب العدد نفسه من رموز الإخراج في كل مرة. سيرتفع TTFT بحدة. أما سرعة التدفق بعد الرمز الأول فلن تتغير إلا قليلاً.

لماذا يكون فك الترميز محدوداً بعرض نطاق الذاكرة؟

ينتج فك الترميز رمزاً واحداً في كل خطوة. ولإنتاج هذا الرمز الواحد، يجب على GPU قراءة كل وزن في النموذج من الذاكرة، واستخدام كل وزن في عمليتين تقريباً، ثم التخلّص منه. تكون الكثافة الحسابية قريبة من 1، لذلك تقضي وحدات الحساب معظم وقتها في الانتظار.

يكون فك الترميز بطيئاً لأن كل رمز يتطلب قراءة النموذج بأكمله من الذاكرة، ولذلك يحدد ناقل الذاكرة الوتيرة بينما تبقى وحدات الحساب خاملة.

لذلك يمكن حساب الحد الأقصى لسرعة فك الترميز لمسار واحد حسابياً على الورق. اقسم عرض نطاق الذاكرة على عدد البايتات التي تشغلها الأوزان.

ChartPublished memory bandwidth and the decode ceiling it implies for a 16 GB model
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
  }
]

يحتوي عمود عرض النطاق على قيمة المواصفات المنشورة لكل مورّد. ويحتوي عمود الحد الأقصى على تلك القيمة مقسومة على 16 GB، وهو حجم نموذج يضم 8 مليارات معامل مخزناً بدقة 16 بت. هذه عملية حسابية وليست نتيجة اختبار معياري. سيكون المعدل الذي تقيسه أقل من ذلك، ومعرفة مقدار الفرق مفيدة لأنها توضح ما إذا كان ينبغي إصلاح مكدس تقديم النموذج أو العتاد.

اقرأ الصفوف البالغ عددها 6 بالترتيب، وسيتضح النمط. ينقل CPU بذاكرة DDR5 ثنائية القناة نحو 90 GB/s، ما يحد فك ترميز ذلك النموذج إلى نحو 6 رمزاً في الثانية. ويصل L40S إلى نحو 54. أما H100 SXM، فعند عرض نطاق منشور يبلغ 3350 GB/s، فيقترب من 209.

ولهذا أيضاً تكون quantization أقوى وسيلة منفردة لزيادة سرعة فك الترميز. خزّن النموذج نفسه بدقة 8 بت بدلاً من 16 بت، فتقلل عدد البايتات المقروءة لكل رمز إلى النصف، ويتضاعف الحد الأقصى تقريباً. لم تضف أي عمليات حسابية. بل نقلت كمية أقل من الذاكرة.

كيف أقيس كل مرحلة على خادمي؟

يعرض Ollama التقسيم في نص الاستجابة. اطلب إكمالاً غير متدفق واقرأ العدادات.

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}'

استخدم وسم نموذج سبق أن سحبته فعلياً، وسيعرضه لك ollama list. يمثّل prompt_eval_count وprompt_eval_duration مرحلة prefill: عدد رموز المطالبة والوقت المستغرق فيها. ويمثّل eval_count وeval_duration مرحلة decode. المدد بوحدة النانوثانية، لذلك تكون سرعة decode هي eval_count / eval_duration * 1e9، وسرعة prefill هي prompt_eval_count / prompt_eval_duration * 1e9. توقّع أن تكون قيمة معدل prefill أعلى بكثير من معدل decode في الطلب نفسه. هذا الفرق هو ما تشرحه بقية الفقرات هنا.

بالنسبة إلى خادم متوافق مع OpenAI، مثل vLLM، يمكن لـcurl قياس زمن وصول البايت الأول.

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 لحظة وصول أول بايت من نص الاستجابة، لذلك يكون الزمن مع "stream": true هو TTFT مضافاً إليه زمن إعداد الاتصال. اطرح time_pretransfer لإزالة تكلفة الإعداد. شغّل الأمر مرتين واحتفظ بالنتيجة الثانية، لأن الاستدعاء الأول قد يتضمن تحميل نموذج بارد.

ينشر vLLM أيضاً هذا التقسيم كمقاييس Prometheus على /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 لقياس عمق قائمة الانتظار، وvllm:kv_cache_usage_perc لقياس ضغط ذاكرة التخزين المؤقت. هذه الأسماء الخمسة هي لوحة المعلومات كاملة.

أثناء الحمل، يشغّل vllm bench serve --model <name> --num-prompts 200 --request-rate 4 الخادم قيد التشغيل ويعرض زمن الوصول إلى أول رمز وزمن الوصول لكل رمز ناتج مع النسب المئوية. هذه هي الطريقة الوحيدة لرؤية المرحلتين وهما تتنافسان. قبل ضبط أي شيء، أنشئ خط أساس نظيفاً: تمنحك الطريقة الواردة في قياس الرموز في الثانية على نموذج LLM محلي خط أساس يبقى صالحاً بعد إعادة التشغيل.

لماذا يؤخر prompt النظام الطويل ظهور أول token، لكنه لا يبطئ سرعة البث؟

لأن prompt النظام يتطلب عمل prefill، ولا شيء آخر. يُعالَج مرة واحدة، في المرحلة نفسها التي يُعالَج فيها باقي prompt، وقبل ظهور أول token. بعد انتهاء هذه المرحلة، لا يبقى إلا كإدخالات في KV cache، وتقرأها مرحلة decode مع بقية الإدخالات. لذلك يضيف prompt نظام يتكون من 3,000 token إلى TTFT في كل طلب، مع إبقاء عدد tokenات في الثانية دون تغيير يُذكر.

لكن التغيير ليس معدوماً تماماً. تُقرأ إدخالات KV الإضافية هذه مرة أخرى في كل خطوة من خطوات decode، لذلك يبطئ prompt الطويل جداً عملية decode قليلاً. يشرح القسم التالي ذلك.

الحل هو إيقاف إعادة حساب البادئة نفسها. يحتفظ الخادم الذي يستخدم prefix caching بـKV cache لبادئة مشتركة ويعيد استخدامها، لذلك يتجاوز الطلب الثاني الذي يحمل prompt النظام نفسه هذا الجزء من prefill بالكامل. يطلق vLLM على ذلك اسم automatic prefix caching؛ تحقّق من vllm serve --help في إصدارك، لأن الإعداد الافتراضي تغيّر بين الإصدارات. تختلف KV cache الموجودة داخل GPU عن prompt cache التي يفرض مزود API رسوماً عليها، ومن المفيد قراءة الفرق بين KV cache وprompt cache قبل ضبط أيٍّ منهما.

لماذا يتباطأ decode مع امتلاء السياق؟

هناك سببان، وكلاهما يتعلق بـKV cache.

السبب الأول هو عرض النطاق الترددي. في كل خطوة من خطوات decode، يقرأ نظام attention المفاتيح والقيم لكل token سابق. أما الأوزان فتمثل تكلفة ثابتة لكل token. بينما ينمو حجم KV cache مع السياق. يمكنك حساب حجمه من config.json للنموذج: يساوي عدد البايتات لكل token حاصل ضرب 2 في num_hidden_layers، ثم في num_key_value_heads، ثم في بُعد الرأس (hidden_size مقسوماً على num_attention_heads)، ثم في عدد البايتات لكل عنصر. ويمثل العدد 2 في البداية مفتاحاً واحداً وقيمة واحدة.

في تخطيط شائع لنموذج يحوي 8 billion parameters، مع 32 طبقة و8 رؤوس للمفاتيح والقيم ضمن GQA (grouped query attention)، وبُعد رأس يساوي 128، ودقة 16 bit، تكون العملية 2 x 32 x 8 x 128 x 2 = 131,072 bytes، أي نحو 128 KiB لكل token. لذلك تحمل محادثة تضم 8,000 token نحو 1 GB من KV cache لكل طلب.

السبب الثاني هو السعة. تمثل تلك الـ1 GB ذاكرة لا يمكنها استيعاب الأوزان أو سياق مستخدم آخر. يحدد الخادم حجم تجمع KV مرة واحدة عند بدء التشغيل، ويُضبط ذلك في vLLM عبر --gpu-memory-utilization. وعندما يمتلئ التجمع، تنتظر الطلبات الجديدة. ويمثل ارتفاع vllm:num_requests_waiting مع بقاء vllm:kv_cache_usage_perc قريباً من 1 الإشارة الدقيقة إلى هذه الحالة. تقوم بعض الحزم بإيقاف طلب قيد التنفيذ مؤقتاً وإعادة حساب cache الخاص به لاحقاً بدلاً من وضعه في قائمة انتظار، وهو ما يختبره المستخدم كتوقف في منتصف التدفق.

يفرض السياق الطويل تكلفة مضاعفة: عمل prefill أكبر في البداية، وقراءة ذاكرة أكبر لكل token طوال بقية الإجابة.

لماذا يساعد التجميع في زيادة الإنتاجية ويؤثر سلباً في زمن الاستجابة الطرفي؟

لأن مرحلة decode مقيّدة بعرض النطاق، تكون الطلبات الإضافية شبه مجانية من ناحية الحوسبة. يمكن لقراءة واحدة للأوزان أن تنتج token واحداً لكل sequence في batch، لذلك ترتفع الإنتاجية الإجمالية تقريباً بصورة خطية مع حجم batch، إلى أن تنفد مساحة KV أو يكبر batch بما يكفي ليصبح مقيّداً بالحوسبة مرة أخرى. يعيد Continuous batching بناء batch في كل خطوة، لذلك يغادر الطلب المكتمل وينضم طلب موجود في قائمة الانتظار من دون انتظار الطلبات المجاورة.

تظهر الكلفة في النسب المئوية. ينتظر next token لكل مستخدم الآن الجزء الأبطأ من خطوة مشتركة، لذلك يبقى p50، أي الوسيط، مقبولاً، بينما يزداد p99، أي زمن الاستجابة لأبطأ 1 طلب من كل 100 طلب. يلاحظ الناس p99 لأنه يمثل التوقف الواقع في منتصف الجملة.

تجعل مرحلة prefill الفرق أوضح. يشغل prompt كبير يصل أثناء تدفق التوليد الجهاز في خطوة طويلة واحدة، ويرى كل من يولّد حالياً فجوة في التدفق. يزيل Chunked prefill معظم هذا الأثر عبر تقسيم prompt الطويل إلى أجزاء ودمج كل جزء في batches الخاصة بمرحلة decode. اعتباراً من August 2026، يطبّق محرك vLLM V1 هذا السلوك افتراضياً، ويتيح ضبط التوازن عبر --max-num-batched-tokens. توضّح وثائق ضبط vLLM المفاضلة مباشرة: القيم الأصغر، مثل 2048، توفر inter token latency (ITL) أفضل لأن عدداً أقل من عمليات prefill يقطع عمليات decode، بينما توفر القيم الأكبر TTFT أفضل لأن عدداً أكبر من tokens الخاصة بـ prefill يدخل في batch واحد. يعرّض ذلك الخيار الواحد المفاضلة بين prefill وdecode في صورة رقم يمكن تغييره. يعتمد تحديد النقطة التي يتوقف عندها p99 عن كونه مقبولاً على السعة، وتتناول عدد المستخدمين المتزامنين الذين يمكن لـ LLM مستضاف ذاتياً خدمتهم ذلك باستخدام المقاييس نفسها.

لماذا لا تغيّر وحدة GPU الأكبر شيئاً أحياناً؟

لأن الحجم الأكبر يعني عادةً قدرة حسابية أكبر، بينما لا تحتاج مرحلة decode إلى قدرة حسابية.

قارن بين صفين من المخطط أعلاه. تبلغ الذاكرة ذات النطاق الترددي المعلن لوحدة A100 80GB مقدار 2039 GB/s، مقابل 864 GB/s لوحدة L40S، ويتبع حد decode ذلك تماماً: 127 token في الثانية، مقابل 54. تُعد RTX 4090 بطاقة سريعة جداً وفق معظم المقاييس، ويضعها معدل 1008 GB/s عند حد مقداره 63. وبغض النظر عن الاختلافات الأخرى بين بطاقتين، يتبع decode في مسار واحد خط النطاق الترددي الوارد في ورقة المواصفات.

لذلك توجد طريقتان لتسريع decode: قراءة عدد أقل من البايتات لكل token، وذلك عبر تكميم الأوزان أو تشغيل نموذج أصغر، أو شراء بطاقة ذات نطاق ترددي أكبر. أما prefill فهي الحالة المعاكسة. فهي تحتاج إلى القدرة الحسابية، ولذلك تقلل البطاقة الأسرع فعلاً زمن TTFT مع المطالبات الطويلة. إذا كانت المشكلة أن ظهور أول token يستغرق أربع ثوانٍ، فقد تصلح العتاد الأفضل المشكلة. أما إذا كانت المشكلة أن النص يظهر ببطء، فمن المرجح ألا يفعل ذلك.

هل ينبغي تشغيل مرحلتي prefill وdecode على خادمين منفصلين؟

تطبّق حزم الخدمة الكبيرة ذلك تماماً، وتُسمّى هذه التقنية فصل prefill عن decode. يشغّل أحد تجمعات الخوادم prefill فقط، بينما يشغّل تجمع ثانٍ decode فقط، ثم تُنقل ذاكرة KV cache التي أنشأها التجمع الأول إلى التجمع الثاني عبر وصلة ربط سريعة. تنجح هذه الطريقة لأن المرحلتين تحتاجان إلى عتاد مختلف وآلية جدولة مختلفة. تحتاج prefill إلى قدرة حوسبة وإلى دفعات كبيرة من الرموز. أما decode فتحتاج إلى عرض نطاق ترددي وإلى عدد كبير من التسلسلات المتزامنة. يتيح فصلهما لكل تجمع أن يتوسع بشكل مستقل، ويمنع prompt واحداً ضخماً من تعطيل كل stream نشط.

على VPS واحد، مع GPU واحد، لا تستحق هذه الطريقة العناء في الغالب. ستقسّم جهازاً واحداً على نفسه، وستحوّل تمرير مؤشر إلى نقل شبكي لعدة GB من ذاكرة التخزين المؤقت. تصبح هذه التقنية مفيدة عندما يتوفر لديك عدد كافٍ من المسرّعات لتخصيص أجهزة كاملة لكل مرحلة، وحركة مرور مستقرة تكفي لإبقاء التجمعين مشغّلين. قبل الوصول إلى ذلك، يوفّر prefill المقسّم معظم العزل نفسه باستخدام flag واحد.

ما الذي يجب تغييره عندما يكون الرقم سيئاً

عندما يكون TTFT مرتفعاً جداً:

  • قصّر الموجّه. ترتبط تكلفة prefill بعدد رموز الموجّه، وتُحتسب تكلفة موجّه النظام في كل طلب.
  • فعّل التخزين المؤقت للبادئة، بحيث تُحسب البادئة المتكررة مرة واحدة بدلاً من حسابها في كل مرة.
  • ارفع --max-num-batched-tokens، لكي تُنفَّذ أعمال prefill أكثر في كل خطوة.
  • افحص قائمة الانتظار قبل إلقاء اللوم على النموذج. تعني قيمة vllm:num_requests_waiting الأكبر من الصفر أن الطلب لم يبدأ بعد، وهذه مشكلة سعة.

عندما يكون عدد الرموز في الثانية منخفضاً جداً:

  • كمِّم الأوزان. كلما قل عدد البايتات لكل وزن، قل عدد البايتات المقروءة لكل رمز.
  • قارن عرض النطاق الترددي للذاكرة المنشور لبطاقتك بالمخطط أعلاه، وتحقق من مدى اقترابك من الحد الأقصى.
  • اخفض --max-num-batched-tokens، لكي تقاطع عمليات prefill عملية decode بمعدل أقل.
  • افحص طول السياق. عندما تنمو المحادثة إلى آلاف الرموز، تُقرأ ذاكرة KV cache أكبر بكثير في كل خطوة.

يؤثر runtime هنا أيضاً، لأن يجدول Ollama وvLLM عمليتي prefill وdecode بطرق مختلفة، وقد لا يفيد إعداد معيّناً أحدهما على الإطلاق. أجرِ القياس أولاً في المرحلتين، ثم غيّر شيئاً واحداً.

FAQ

لماذا يستغرق ظهور أول token ثواني، بينما تُبث بقية tokens بسرعة؟

هذا الانتظار هو مرحلة prefill، أما البث فهو مرحلة decode. تعالج مرحلة prefill كامل prompt في تمريرة واحدة مقيّدة بقدرة المعالجة قبل إنشاء أي مخرجات، لذلك تزداد كلفتها مع زيادة طول prompt. بعد ذلك تُخرج مرحلة decode token واحداً في كل خطوة، وبمعدل تحدده bandwidth الذاكرة، ويكون هذا المعدل مستقلاً تقريباً عن طول prompt. السبب المعتاد هو وجود system prompt طويل في كل طلب. يزيل prefix caching الجزء المتكرر من هذه الكلفة.

هل يؤدي prompt الأطول إلى خفض عدد tokens في الثانية؟

نعم، قليلاً، ولكن لسبب مختلف عن TTFT. في كل خطوة decode، تُقرأ keys وvalues لجميع tokens السابقة، لذلك يعني ازدياد حجم KV cache قراءة عدد أكبر من البايتات لكل token. في تخطيط شائع يضم 8 billion parameter، يبلغ حجم cache نحو 128 KiB لكل token، ولذلك يحتاج سياق من 8,000 token إلى قراءة نحو 1 GB منه في كل خطوة. مع ذلك، يبقى التأثير الأكبر لـprompt الطويل على TTFT، وليس على سرعة البث.

ما مواصفة GPU التي تتنبأ بسرعة decode؟

Bandwidth الذاكرة. اقسم bandwidth المعلن على حجم weights الموجودة في الذاكرة، لتحصل على الحد الحسابي الأقصى لمسار واحد. لن تبث البطاقة ذات قدرة المعالجة الأكبر tokens بسرعة أعلى إذا كانت تملك bandwidth نفسها. ولهذا يؤدي quantizing إلى 8 bits إلى مضاعفة سرعة decode تقريباً أيضاً: فهو يقلل إلى النصف عدد البايتات المقروءة لكل token، من دون تغيير قدرة المعالجة.

لماذا يرتفع throughput عند إضافة مستخدمين، بينما يشعر كل مستخدم بأن الأداء أصبح أبطأ؟

تخدم قراءة واحدة لـweights token واحداً لكل sequence في batch، لذلك يرتفع إجمالي عدد tokens في الثانية مع زيادة حجم batch. لكن كل token فردي ينتظر الآن خطوة مشتركة، فتزداد latency لكل مستخدم في الوقت نفسه. راقب p99 inter token latency، وليس رقم throughput الإجمالي، وتحقق من vllm:num_requests_waiting لمعرفة ما إذا كانت الطلبات تنتظر في queue بدلاً من تنفيذها.