انتقل إلى المحتوى الرئيسي

الوحدة 7 — قائمة الانتظار والحمل المتزامن

الوحدات السابقة بنت عرضًا يعمل ممتازًا لمستخدم واحد. المفاجأة تأتي عند مشاركة الرابط في محادثة عمل: خمسة أشخاص ينقرون في نفس الدقيقة، فتتعطّل الواجهة، وتظهر رسائل خطأ غامضة، ويسقط عرضك من ثقة الفريق. هذه الوحدة تشرح لماذا يحدث ذلك، وكيف تحلّه قائمة الانتظار مع حدود تزامن صريحة.

لماذا ينهار الخادم بلا قائمة انتظار

بدون قائمة انتظار، كلّ طلب يُشغّل الدالّة فورًا. إذا كانت الدالّة تستدعي نموذجًا يستخدم ذاكرة GPU كاملة، فإنّ خمسة طلبات متزامنة تحاول تحميل خمس نُسَخ. النتيجة عمليًّا:

  • نفاد الذاكرة: خطأ CUDA out of memory أو تعطّل النظام.
  • تباطؤ خطّيّ: كلّ طلب يزاحم الآخر على المورد، فيتضاعف زمن كلّ منها.
  • تصرّف غير منتظم: بعض المستخدمين يحصلون على جواب سريع، وآخرون ينتظرون دقيقة كاملة، بلا سبب واضح.

Gradio يُفعّل قائمة انتظار بشكل تلقائيّ منذ الإصدار 3.x، لكنّه لا يعرف حدود مواردك. أنت من يجب أن يُحدّدها.

تفعيل قائمة الانتظار وضبطها

الأساس بسطر واحد:

demo.queue(default_concurrency_limit=2, max_size=20).launch()
  • default_concurrency_limit=2: يعالج طلبَين متزامنَين على الأكثر لكلّ حدث. الطلب الثالث يدخل الطابور.
  • max_size=20: طول الطابور الأقصى. الطلب الحادي والعشرون يُرفَض فورًا برسالة «الخادم مشغول جدًّا».

هذان الرقمان يجب أن يعكسا الحقيقة الفيزيائيّة لموردك: كم طلبًا متزامنًا يستطيع النموذج تنفيذه دون تدهور محسوس؟ للنموذج اللغويّ الكبير على GPU واحد، الجواب غالبًا 1 أو 2، لا أكثر.

concurrency_limit لكلّ حدث

عرض واحد قد يحوي أحداثًا متفاوتة الكلفة: زرّ يُلخّص (خفيف) وزرّ يولّد صورة (ثقيل). Gradio يسمح بتحديد الحدّ لكلّ حدث على حدة:

import gradio as gr

with gr.Blocks() as demo:
prompt = gr.Textbox()
resume = gr.Textbox()
image = gr.Image()
b1 = gr.Button("لخّص")
b2 = gr.Button("ولّد صورة")

def resumer(t): return t[:50]
def imager(t): return None

b1.click(fn=resumer, inputs=prompt, outputs=resume, concurrency_limit=5)
b2.click(fn=imager, inputs=prompt, outputs=image, concurrency_limit=1)

demo.queue(max_size=30).launch()

هنا التلخيص يقبل خمسة طلبات متزامنة (خفيف)، لكن توليد الصورة واحدًا فقط (ثقيل يحتاج GPU كاملًا). هذا التفصيل يمنع طلبات التلخيص من الحبس خلف طلب صورة واحد.

المدّة القصوى للطلب

يُسمَح لطلب أن يستغرق زمنًا معقولًا، لا زمنًا لا نهاية له. Gradio لا يفرض مهلة افتراضيّة صريحة على الجانب الخادم، لكنّه ينشر معلومات الطابور للمستخدم عبر show_progress. لضبط المهلة على مستوى النموذج نفسه:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama", timeout=30.0)

def repondre(m, h):
try:
return client.chat.completions.create(
model="qwen2.5:7b", messages=[{"role": "user", "content": m}], timeout=30.0,
).choices[0].message.content
except Exception as e:
return f"انتهى الوقت أو حصل خطأ: {e}"

المهلة على مستوى العميل تحمي الواجهة من طلب معلَّق يشغل الموارد إلى الأبد. القاعدة العمليّة: مهلة أقصر من الصبر البشريّ. 30 ثانية سقف معقول لجواب مساعد؛ ما زاد يُنتج مستخدمًا يُغلق الصفحة قبل الجواب.

قياس السلوك تحت الحمل

القياس أفضل من التخمين. سكربت بسيط بـasyncio وaiohttp` يحاكي عدّة مستخدمين متزامنين:

import asyncio, aiohttp, time

URL = "http://127.0.0.1:7860/api/predict"

async def envoyer(session, i):
debut = time.time()
async with session.post(URL, json={"data": [f"سؤال رقم {i}"]}) as r:
await r.json()
return time.time() - debut

async def main(n):
async with aiohttp.ClientSession() as s:
durees = await asyncio.gather(*[envoyer(s, i) for i in range(n)])
print(f"عدد المستخدمين: {n}")
print(f"الأدنى: {min(durees):.2f} ث · الأعلى: {max(durees):.2f} ث · المتوسّط: {sum(durees)/len(durees):.2f} ث")

asyncio.run(main(5))
asyncio.run(main(10))
asyncio.run(main(20))

قراءة النتائج تخبرك مباشرة: هل قيمة concurrency_limit مناسبة، أم أنّ حالة عشرة مستخدمين تُدهور الزمن ثلاث مرّات؟ الأمر ليس افتراضًا، بل رقم يظهر أمامك.

قراءة تقدّم الطابور للمستخدم

Gradio يعرض تلقائيًّا للمستخدم موقعه في الطابور عبر شارة صغيرة. لكن التخصيص ممكن بـgr.Progress():

def repondre(m, historique, progress=gr.Progress()):
progress(0, desc="بدء المعالجة")
for i in range(10):
progress((i + 1) / 10, desc=f"الخطوة {i + 1} من 10")
time.sleep(0.5)
return "انتهى"

عرض تقدّم صريح يُقلّل الإحساس بالانتظار كثيرًا، حتّى لو لم يُقصّر الزمن الفعليّ. هذه استفادة أخرى من مبدأ «الإحساس بالسرعة» الذي رأيناه في الوحدة 5.

تخفيف الحمل قبل النموذج

قبل زيادة المورد، قلّص الحاجة:

  • التخزين المؤقّت: functools.lru_cache على مدخلات متكرّرة (أسئلة شائعة).
  • cache_examples="lazy" يوفّر النموذج على الأمثلة الشائعة.
  • قيود مبكّرة: max_length على الحقل، حجم أقصى للصوت، الرفض المبكّر للمدخلات الفارغة.
  • حزم الطلبات إن أمكن (batching): تجميع عدّة أسئلة في نداء واحد للنموذج.

كلّ طلب تمنعه من الوصول إلى النموذج يوفّر ثانية GPU. حساب سنويّ يُظهر أنّ عرضًا نشطًا يوفّر مبالغ كبيرة بـ«USD» أو «دولار» بمجرّد إضافة تخزين مؤقّت بسيط.

share=True والحمل

share=True في الوحدة القادمة يفتح رابطًا عامًّا. إذا شاركته على وسائل التواصل ولم تُفعّل قائمة انتظار مع حدود، فمائة مستخدم متزامن يُطفئون جهازك. القاعدة قبل المشاركة العلنيّة: حدّد الحدود أوّلًا، ثمّ شارك.

قياس آخر: عدد الطلبات في الدقيقة

بجانب زمن الاستجابة، RPM (طلبات في الدقيقة) قياس مفيد. عرض يستقبل 100 طلب في الدقيقة بمعدّل 3 ثوانٍ يعالج طلبًا كلّ 0.6 ثانية، ما يتطلّب concurrency_limit=5 على الأقلّ. الرياضيّة بسيطة: concurrency ≈ RPM × zaman_الاستجابة / 60. هذا يُغني عن التجريب الأعمى.

الخلاصة

  • قائمة الانتظار مُفعّلة تلقائيًّا، لكن حدودها مسؤوليّتك: default_concurrency_limit وmax_size.
  • concurrency_limit لكلّ حدث يمنع الحدث الثقيل من حبس الطابور أمام الأحداث الخفيفة.
  • قياس الحمل بـasyncio أفضل من الحدس؛ 20 طلبًا متزامنًا يكشف السقف الحقيقيّ لعرضك.
  • gr.Progress يُحسّن الإحساس بالانتظار حتّى بلا تسريع فعليّ.

الوحدة التالية: share=True والرابط المؤقّت، ما يعرضه بالضبط، ومتى لا نستعمله.