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

الوحدة 10 — تقديم نموذج ONNX للخدمة خلف FastAPI

كلّ ما بنيناه من الوحدة 1 إلى 9 يخدم غرضًا وحيدًا: أن يصير النموذج متاحًا لاستدعاء من عميل حقيقيّ. رسم ONNX مُحسَّن ومكمَّم ومقيَّد الدقّة، بلا واجهة استدعاء، هو ملفّ على القرص، لا نظام إنتاج. هذه الوحدة تُغلق الحلقة: FastAPI أمام ONNX Runtime، بأنماط تصمد في الطلبات الحقيقيّة، مع نظرة على تقديم النموذج نفسه في المتصفّح.

المعادلة: جلسة واحدة، عدّة طلبات

الخطأ الأشيع للمبتدئ هو إنشاء InferenceSession جديدة في كلّ طلب. تكلفة الإنشاء ثقيلة: تحميل الملفّ، بناء الرسم، اختيار الخوارزميّات، حجز الذاكرة. القياس النموذجيّ على ResNet18: 200 مِلّي ثانية لإنشاء الجلسة مقابل 4 مِلّي ثانية للاستدلال الفعليّ. عمليًّا، الإنشاء المتكرّر يقتل الأداء ويجعل الخدمة عاجزة عن تحمّل حمل بسيط.

القاعدة الذهبيّة: أنشئ الجلسة مرّة واحدة عند إقلاع الخدمة، شاركها بين كلّ الطلبات. ONNX Runtime مصمَّم لهذا: session.run() آمن للاستخدام المتزامن من عدّة خيوط، لأنّ الأوزان للقراءة فقط، والحالة المؤقّتة تُخصَّص لكلّ استدعاء.

import onnxruntime as ort

# مرّة واحدة عند إقلاع التطبيق
_session_globale = None

def initialiser():
global _session_globale
opts = ort.SessionOptions()
opts.intra_op_num_threads = 4
opts.inter_op_num_threads = 1
_session_globale = ort.InferenceSession(
"resnet18.onnx",
sess_options=opts,
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
# فحص مزوّد التنفيذ (وحدة 7)
print("مزوّدون :", _session_globale.get_providers())

def predire(image_np):
return _session_globale.run(None, {"image": image_np})[0]

intra_op_num_threads يضبط الخيوط داخل عمليّة واحدة، وinter_op_num_threads بين عمليّات متوازية. في خدمة بـ8 خيوط CPU، الاختيار الشائع: intra=4 وinter=1 لطلب واحد كبير، أو intra=1 وinter=1 لعدّة طلبات متوازية يديرها إطار الخدمة نفسه (uvicorn workers مثلًا). القاعدة: لا تجعل الرقم الإجماليّ يتجاوز عدد الأنوية الحقيقيّة، وإلّا تتنافس الخيوط.

المعالجة القبلية: مطابقة التدريب حرفيًّا

هذا هو مصدر 80% من فوارق الأداء بين تدريب مثاليّ وإنتاج مخيّب. النموذج تعلّم على تنسورات معالَجة بطريقة محدّدة: تغيير الحجم بخوارزميّة معيّنة، تسوية بمتوسّطات وانحرافات ImageNet، ترتيب قنوات معيّن. أيّ فارق في الإنتاج يُنتج نتائج خاطئة بصمت.

القاعدة الصارمة: احفظ المعالجة القبلية جنبًا إلى جنب مع النموذج، ضِمن نفس الملفّ إن أمكن (ONNX يدعم عمليّات معالجة الصور مثل Resize وNormalize مباشرة في الرسم)، أو في نصّ بايثون خاصّ بالخدمة يُنسَخ مع النموذج:

from PIL import Image
import numpy as np

# نفس المعاملات المستعملة في التدريب (ImageNet)
MOYENNE = np.array([0.485, 0.456, 0.406], dtype=np.float32)
ECART = np.array([0.229, 0.224, 0.225], dtype=np.float32)

def preparer_image(fichier):
img = Image.open(fichier).convert("RGB")
img = img.resize((224, 224), Image.BILINEAR) # نفس التطعيم
arr = np.asarray(img, dtype=np.float32) / 255.0
arr = (arr - MOYENNE) / ECART
arr = arr.transpose(2, 0, 1) # HWC إلى CHW
return arr[np.newaxis, ...].astype(np.float32) # حزمة 1

كلّ سطر يعكس قرارًا اتُّخِذ عند التدريب. Image.BILINEAR مقابل Image.LANCZOS يُنتج بيكسلات مختلفة، وهذا يُغيّر ناتج CNN بشكل ملحوظ. حافظ على بذرة السجلّ: كتابة قرار المعالجة القبلية في وثيقة النموذج (شكل، متوسّط، انحراف، خوارزميّة تغيير الحجم).

FastAPI أدنى، إنتاجيّ فعلًا

الآن نجمع كلّ شيء في تطبيق FastAPI. lifespan يضمن أنّ الجلسة تُنشأ مرّة عند الإقلاع وتُحرَّر عند الإطفاء:

from contextlib import asynccontextmanager
from fastapi import FastAPI, UploadFile, File, HTTPException
import onnxruntime as ort
import numpy as np
import io

session = None
etiquettes = ["classe_0", "classe_1", "..."] # 1000 صنف لـImageNet

@asynccontextmanager
async def lifespan(app: FastAPI):
global session
opts = ort.SessionOptions()
opts.intra_op_num_threads = 4
session = ort.InferenceSession(
"resnet18.onnx",
sess_options=opts,
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
if "CUDAExecutionProvider" not in session.get_providers():
raise RuntimeError("CUDA غير متاح: افحص التثبيت")
yield
# عند الإطفاء، لا حاجة لتحرير صريح: بايثون يتكفّل

app = FastAPI(lifespan=lifespan)

@app.post("/predire")
async def predire(image: UploadFile = File(...)):
try:
octets = await image.read()
x = preparer_image(io.BytesIO(octets))
logits = session.run(None, {"image": x})[0][0]
idx = int(np.argmax(logits))
proba = float(np.exp(logits[idx]) / np.exp(logits).sum())
return {"classe": etiquettes[idx], "probabilite": proba}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

@app.get("/sante")
async def sante():
return {"pret": session is not None,
"providers": session.get_providers() if session else []}

النقاط الجوهريّة:

  • lifespan بدل @app.on_event("startup") (الأخير مُهمَل في إصدارات FastAPI الحديثة).
  • فحص مزوّد التنفيذ عند الإقلاع؛ رفض الإقلاع إذا لم يكن GPU متاحًا كما نتوقّع.
  • نقطة نهاية /sante ضروريّة لمنسّق الحاويات (Kubernetes، ECS) ليعرف متى الخدمة جاهزة.
  • معالجة الأخطاء صريحة: خطأ في المعالجة القبلية (صورة فاسدة) يعطي 400، لا 500.

معالجة الحزم لتحسين الإنتاجيّة

طلب واحد بحزمة 1 يستهلك GPU بشكل ضعيف. إذا استقبلت الخدمة عدّة طلبات في نفس اللحظة، جمعها في حزمة واحدة يُحسِّن الإنتاجيّة بشكل درامي:

import asyncio
from collections import deque

_file = deque()
_verrou = asyncio.Lock()

async def traiter_lot():
while True:
await asyncio.sleep(0.005) # نافذة 5 مِلّي ثانية
async with _verrou:
if not _file:
continue
lot = list(_file)
_file.clear()
# تجميع في حزمة واحدة
entree = np.stack([x for x, _ in lot])
sorties = session.run(None, {"image": entree})[0]
# إرسال كلّ سطر إلى صاحبه
for (_, futur), sortie in zip(lot, sorties):
futur.set_result(sortie)

هذا النمط، المسمّى الحزمة الديناميكيّة، يُستعمل في خدمات الاستدلال الجدّية (TensorFlow Serving، NVIDIA Triton). أدواته موجودة، لكنّ كتابته يدويًّا مفيدة للفهم. الرافعة: نافذة 5 مِلّي ثانية تُضيف تأخيرًا صغيرًا لكن تُضاعف الإنتاجيّة ×3 إلى ×10 حسب معدّل الوصول.

Kubernetes، عمّال متعدّدون، توسّع تلقائيّ

في الإنتاج الحقيقيّ، لا تخدم من عمليّة بايثون واحدة. تُشغّل عدّة عمّال uvicorn وراء منسّق:

gunicorn --workers 4 --worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 main:app

كلّ عامل ينشئ جلسته الخاصّة (الأوزان تُنسَخ 4 مرّات في الذاكرة). للنماذج الكبيرة (LLM بحجم عدّة غيغابايت)، هذا مكلف؛ استعمل عاملًا واحدًا لكلّ حاوية وتوسّع أفقيًّا بمنسّق. للنماذج الصغيرة (ResNet18، 45 ميغابايت)، 4 عمّال في حاوية واحدة اختيار جيّد.

التوسّع التلقائيّ يعتمد على مقاييس: زمن الطلب (p95)، استعمال GPU، طول قائمة الانتظار. Kubernetes HPA على مقياس مخصّص من Prometheus هو النمط القياسيّ. اضبط عتبة p95 > 200 ms لإضافة نسخة، عتبة p95 < 80 ms لإزالة نسخة. تحاشَ التذبذب بإدخال «تهدئة» (5 دقائق بين كلّ تعديل).

ONNX Runtime Web: النموذج نفسه في المتصفّح

فتحة مثيرة: نفس ملفّ resnet18.onnx يمكن أن يعمل مباشرةً في المتصفّح عبر ONNX Runtime Web، بدون خادم. النموذج يُحمَّل عبر HTTP، ويُنفَّذ محلّيًّا بـWebAssembly أو WebGPU:

<script type="module">
import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js";

const session = await ort.InferenceSession.create(
"resnet18.onnx",
{ executionProviders: ["webgpu", "wasm"] },
);

async function predire(imageBitmap) {
const tenseur = await ort.Tensor.fromImage(imageBitmap, {
width: 224, height: 224, means: [0.485, 0.456, 0.406],
stds: [0.229, 0.224, 0.225],
});
const sortie = await session.run({ image: tenseur });
return sortie.logits.data;
}
</script>

المكاسب هائلة: صفر تكلفة خادم، صفر تأخير شبكة، خصوصيّة تامّة (الصورة لا تغادر جهاز المستخدم). القيود: النموذج يجب أن يكون صغيرًا (ميغابايتات، لا غيغابايتات)، وWebGPU لا يزال حديثًا (متاح على Chrome 113+ وSafari 17). المُرمِّز النصّيّ من الخيط الأحمر، بحجم 2.5 ميغابايت بعد التكميم، مرشّح مثاليّ للتشغيل في المتصفّح.

الملاحظة الأمنيّة: النموذج مكشوف

عند تقديم النموذج في المتصفّح، ملفّ .onnx يُنزَّل إلى جهاز المستخدم. من يمتلك الملفّ يمتلك الأوزان ويمكنه استعماله كما شاء. إذا كان النموذج ذا قيمة تجاريّة (مُدرَّب على بيانات خاصّة، خوارزميّة مميّزة)، هذا يفتح باب التسريب. في هذه الحالة، أَبقِ النموذج على الخادم. ONNX Runtime Web ممتاز للنماذج المفتوحة أو نماذج شخصيّة على جهاز المستخدم، لكنّه ليس مناسبًا لكلّ سيناريو.

قائمة تحقّق قبل النشر

قبل أن تعتبر خدمتك جاهزة للإنتاج:

  • الجلسة تُنشأ مرّة واحدة عند الإقلاع، تُشارَك بين الطلبات.
  • مزوّدو التنفيذ يُفحَصون فور الإنشاء؛ الخدمة ترفض الإقلاع إذا كان GPU مفقودًا (متى كان متوقّعًا).
  • المعالجة القبلية مطابقة حرفيًّا لتلك المستعملة في التدريب؛ موثَّقة في المستودع.
  • نقطة نهاية /sante تُرجع حالة تشغيل، ونقطة /metrics تعرض مقاييس (Prometheus format).
  • سجلّ منظّم (JSON) على كلّ طلب مع زمن الاستدلال؛ تنبيه على p95 > عتبة.
  • تحقّق عدديّ نهائيّ في CI: نفس المدخلات، نفس المخرجات كما في اختبار التدريب.

الخلاصة

  • جلسة مشتركة: أنشئها مرّة، شاركها بين الطلبات؛ الإنشاء المتكرّر يقتل الأداء.
  • المعالجة القبلية مطابقة للتدريب حرفيًّا؛ أدرجها في الرسم أو احفظها بجانب النموذج.
  • FastAPI مع lifespan يوفّر إطارًا نظيفًا؛ فحص مزوّدي التنفيذ وحصة الأمان /sante واجبان.
  • ONNX Runtime Web يفتح تشغيل النموذج في المتصفّح للنماذج الصغيرة، بمكاسب خصوصيّة وتكلفة.

الوحدة التالية: مراجعة كاملة للدورة، قائمة تحقّق التصدير، وإعلان الاختبار النهائيّ.