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

الوحدة 2 — التصدير من PyTorch إلى ONNX

نحن جاهزون لتصدير أوّل نموذج من الخيط الأحمر: ResNet18 المستعمل في دورة PyTorch. الأداة هي torch.onnx.export، ولها ثلاث أو أربع مقابض دقيقة تُقرِّر بين نموذج قابل للنشر ونموذج «يعمل على المطوّر ولا يعمل على المخدّم».

التتبّع: كيف يعرف PyTorch شكل الرسم؟

PyTorch رسم ديناميكيّ: عند كلّ استدعاء لـforward، يُبنى الرسم من جديد أثناء التنفيذ. ONNX، على العكس، رسم ثابت يجب أن يكون معروفًا بالكامل قبل الاستدلال. لسدّ الفجوة، torch.onnx.export يعتمد على تقنية اسمها التتبّع (tracing): تُقدّم له إدخالًا نموذجيًّا، يُشغّل النموذج على هذا الإدخال، ويُسجّل كلّ عمليّة PyTorch تحدث في الطريق. النتيجة رسم ONNX يُعيد إنتاج نفس التسلسل بالضبط على أيّ إدخال بنفس الشكل.

هذه النقطة الأخيرة حرجة. إذا كان في forward شرطٌ من نوع if x.mean() > 0، فسيتبع التتبّع فرعًا واحدًا فقط: الفرع الذي حدث عند الإدخال المرجعيّ. الفرع الآخر يختفي من الرسم. لهذا نقول إنّ التتبّع «يُثبّت اللحظة»: الشكل الديناميكيّ يتحوّل إلى شكل ثابت.

أوّل تصدير: ResNet18

نبدأ بأبسط استدعاء ممكن، ثم نُحسّنه تدريجيًّا:

import torch
import torchvision.models as models

modele = models.resnet18(weights=models.ResNet18_Weights.DEFAULT).eval()

# صورة مرجعيّة بحجم ImageNet المعتاد
entree_ref = torch.randn(1, 3, 224, 224)

torch.onnx.export(
modele,
entree_ref,
"resnet18.onnx",
opset_version=17,
input_names=["image"],
output_names=["logits"],
do_constant_folding=True,
)

eval() واجب: يُبدّل سلوك Dropout وBatchNorm إلى وضع الاستدلال. من دونه، تُصدَّر إحصائيّات الحزمة الحاليّة كما هي، وتفشل كلّ الاستدلالات اللاحقة عندما تختلف الحزمة.

do_constant_folding=True يطلب من ONNX تسبيق حساب كلّ ما يمكن حسابه مسبقًا من الرسم (طيّ الثوابت). هذا لا يُغيّر النتيجة، لكنّه يُقلّص الرسم ويُحسّن الاستدلال.

المحاور الديناميكيّة: لماذا لا نُثبّت حجم الحزمة

المشكلة في تصديرنا الأوّل: شكل الإدخال (1, 3, 224, 224) صار ثابتًا في الرسم. إذا طلبت من ONNX Runtime تشغيل حزمة من 8 صور، سيرفض بخطأ صريح: «الشكل غير مطابق». الحلّ هو الإعلان الصريح عن المحاور التي يجب أن تبقى ديناميكيّة:

torch.onnx.export(
modele,
entree_ref,
"resnet18.onnx",
opset_version=17,
input_names=["image"],
output_names=["logits"],
dynamic_axes={
"image": {0: "batch"}, # المحور 0 (حجم الحزمة) ديناميكيّ
"logits": {0: "batch"},
},
do_constant_folding=True,
)

لاحظ أنّه لا يجب إعلان كلّ المحاور ديناميكيّة إذا لم يكن ذلك ضروريًّا. عرض الصورة وارتفاعها يبقيان ثابتَين هنا لأنّ ResNet18 لا يقبل صورًا بأحجام مختلفة دون تعديل. الحفاظ على أكبر عدد من الأبعاد ثابتًا يُسهِّل تحسينات المحرّك، خصوصًا TensorRT.

أسماء الإدخال والإخراج: تفصيل يُنقذ الإنتاج

إذا لم تحدّد input_names وoutput_names، سيُنتج ONNX أسماء عامّة مثل input.1 و334. هذه الأسماء تظهر في واجهة الاستدلال:

import onnxruntime as ort
session = ort.InferenceSession("resnet18.onnx")
resultat = session.run(None, {"input.1": image_np}) # اسم غامض

بأسماء واضحة، الشيفرة تصبح موثِّقةً لذاتها، وتغيير النموذج لا يكسر شيفرة الاستدعاء ما دامت الأسماء مُحفوظة. اجعل هذا عادة صارمة.

التحقّق الفوريّ بعد التصدير

ثلاثة اختبارات يجب إجراؤها فور التصدير، قبل حتى الانتقال إلى بقيّة سلسلة العمل:

import onnx
import onnxruntime as ort
import numpy as np

# 1. صحّة بنية الملفّ
modele_onnx = onnx.load("resnet18.onnx")
onnx.checker.check_model(modele_onnx)

# 2. جلسة ONNX Runtime تُنشأ دون خطأ
session = ort.InferenceSession(
"resnet18.onnx", providers=["CPUExecutionProvider"]
)
print("مُدخَلات :", [i.name for i in session.get_inputs()])
print("مُخرَجات :", [o.name for o in session.get_outputs()])

# 3. تكافؤ عدديّ سريع مع PyTorch
image_np = entree_ref.numpy()
sortie_torch = modele(entree_ref).detach().numpy()
sortie_onnx = session.run(None, {"image": image_np})[0]
print("أقصى فارق :", np.abs(sortie_torch - sortie_onnx).max())

الفارق المتوقّع من مرتبة 1e-5 إلى 1e-6 بسبب اختلافات جمع النقاط العائمة. أيّ شيء أكبر يستحقّ التحقيق. سنعود إلى الفحص العدديّ بجدّية في الوحدة الرابعة.

المُصدِّر الجديد: dynamo

منذ PyTorch 2.1، هناك مُصدِّر بديل يعتمد على torch.export وdynamo، يُفعَّل بـdynamo=True:

torch.onnx.export(
modele,
entree_ref,
"resnet18-dynamo.onnx",
input_names=["image"],
output_names=["logits"],
dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}},
dynamo=True,
)

الفارق الجوهريّ: هذا المُصدِّر يُحلّل شيفرة forward رمزيًّا بدل تتبّع تنفيذ واحد. النتيجة: الشروط والحلقات تُحفَظ عبر عمليّات ONNX الديناميكيّة (If، Loop، Scan)، مع رسم أدقّ في حالات كثيرة. الجانب المضادّ: بعض العمليّات المخصّصة أو الشيفرة التي تعتمد على Python كثيرًا قد لا تنجح.

القاعدة العمليّة اليوم: جرِّب dynamo=True أوّلًا. إذا فشل بخطأ، ارجع إلى التتبّع الكلاسيكيّ. ستتحوّل النسبة لصالح dynamo مع كلّ إصدار جديد.

مُرمِّز النصّ: الجانب الآخر من الخيط الأحمر

نطبّق نفس النمط على المُرمِّز الصغير لتصنيف الجمل. هنا الإدخال أعداد صحيحة (رموز)، وطوله متغيّر:

class Codeur(torch.nn.Module):
def __init__(self, vocab=30000, d=64, classes=5):
super().__init__()
self.emb = torch.nn.Embedding(vocab, d)
self.rnn = torch.nn.GRU(d, d, batch_first=True)
self.tete = torch.nn.Linear(d, classes)

def forward(self, ids):
h = self.emb(ids)
_, dernier = self.rnn(h)
return self.tete(dernier.squeeze(0))

codeur = Codeur().eval()
ids_ref = torch.randint(0, 30000, (2, 32)) # 2 جمل، طول 32

torch.onnx.export(
codeur,
ids_ref,
"codeur.onnx",
opset_version=17,
input_names=["ids"],
output_names=["logits"],
dynamic_axes={
"ids": {0: "batch", 1: "seq"}, # الطول متغيّر
"logits": {0: "batch"},
},
)

نلاحظ أنّ محورَين ديناميكيّان هنا: حجم الحزمة وطول التسلسل. في الوحدة التاسعة، سنرى أنّ بعض عمليّات RNN تحتاج معالجة خاصّة عند التصدير.

قائمة تحقّق قبل التصدير

قبل أيّ torch.onnx.export جدّي، اطرح على نفسك هذه الأسئلة الخمسة. هل النموذج في eval()؟ هل الإدخال المرجعيّ يمثّل حقًّا استخدامًا نموذجيًّا؟ هل أعلنتُ عن كلّ المحاور الديناميكيّة الحقيقيّة، وفقط تلك؟ هل اخترتُ opset_version مدعومًا في الإنتاج؟ هل الأسماء التي أعطيتها لن تتغيّر بين إصدارات النموذج؟ هذه الأسئلة الخمسة تُنقذ من 90% من الأخطاء اللاحقة.

الخلاصة

  • التصدير هو تتبّع تنفيذ على إدخال مرجعيّ؛ الشروط الديناميكيّة تختفي إلّا مع dynamo.
  • dynamic_axes يُبقي المحاور المتغيّرة (حزمة، طول تسلسل) مرنة؛ الأبعاد المُثبَّتة تُسرِّع.
  • input_names وoutput_names واجبان لواجهة استدلال صالحة للإنتاج.
  • ثلاثة اختبارات فوريّة بعد التصدير: check_model، إنشاء الجلسة، والتكافؤ العدديّ.

الوحدة التالية: التصدير من TensorFlow، مع تعقيدات NHWC مقابل NCHW.