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

الوحدة 1 — صيغة ONNX: الرسم والعمليات والإصدارات

قبل أن نُصدّر شيئًا، علينا فهم ما نُصدِّره إليه. ONNX ليس محرّكًا للتنفيذ، بل هو صيغة ملفّ تصف رسمًا حسابيًّا لا يعتمد على أيّ إطار. تعبيرًا صادقًا: هو «PDF للنموذج». يُنتج PyTorch الصيغة، ويستهلكها ONNX Runtime أو TensorRT أو OpenVINO، وكلٌّ يفهمها بلغته. هذه الوحدة تفكّك المفهوم قبل أن نستعمله.

لماذا صيغة تبادل من الأساس؟

في العالم قبل ONNX، كان النموذج المُدرَّب في PyTorch سجينًا لبيئة PyTorch. لتقديمه على مخدّم بدون بايثون، أو على هاتف، أو على بطاقة تسريع نيّة العتاد، كان يجب إمّا إعادة كتابة النموذج بلغة العتاد المستهدف، وإمّا سحب مكتبة التدريب كاملةً إلى الإنتاج. الأوّل مُكلف ومصدر لعيوب صامتة، والثاني يُثقل الحاويات بمئات الميغابايتات ويُبطئ الإقلاع.

الفكرة التي طرحتها Microsoft وFacebook في 2017 بسيطة: نتّفق على تمثيل مشترك للرسم. يعرف كلّ إطار كيف يُنتج هذا التمثيل من نماذجه، ويعرف كلّ محرّك كيف يقرأه ويُشغِّله. النتيجة: نموذج مُدرَّب في PyTorch يعمل على أي منصّة يدعمها ONNX Runtime، من كمبيوتر محمول إلى خادم GPU إلى متصفّح ويب.

الرسم الحسابي: العُقد والحوافّ

ملفّ ONNX هو رسم موجّه لا دَوريّ. كلّ عقدة تُمثّل عمليّة (MatMul، Conv، Relu، Softmax...)، وكلّ حافّة تُمثّل تنسورًا يتدفّق بين العُقد. المُدخَلات تدخل من طرف والمُخرَجات تخرج من الطرف الآخر، والوسط سلسلة من التحويلات.

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

يمكنك رؤية هذا كلّه ببرنامج مجّاني اسمه Netron: افتح ملفّ .onnx، وسيرسم لك الرسم كاملًا، عقدة عقدة، مع أشكال التنسورات وقيم الأوزان. هذه الأداة لا تُقدَّر بثمن للتشخيص: عندما لا يعمل نموذج مُصدَّر، فتحه في Netron هو أوّل ردّ فعل مُتدرَّب عليه.

مجموعة العمليّات (opset)

كلّ عملية في ONNX تنتمي إلى مجموعة عمليّات ذات إصدار (opset version). المجموعة القياسيّة تُسمّى ai.onnx، وهي تتطوّر بمرور الوقت: opset 13 أضاف عمليّة Trilu، وopset 17 جدّد LayerNormalization رسميًّا، وopset 20 أضاف عمليّات للاهتمام. الإصدار الحاليّ عند كتابة هذه الدورة هو 21.

هذا الترقيم ليس تفصيلًا شكليًّا: هو العقد بين المُصدِّر (PyTorch مثلًا) والمُستهلك (ONNX Runtime). عندما تُصدّر بـopset_version=17، فأنت تُخبر ONNX Runtime أنّ الرسم يحتوي عمليّات تُعرَّف كما هي في الإصدار 17. إذا كان ONNX Runtime المُثبَّت لديك أقدم من ذلك، سيرفض الملفّ. وإذا اخترت إصدارًا قديمًا جدًّا، فبعض العمليّات الحديثة قد لا تكون موجودة، وسيفشل التصدير بخطأ صريح.

القاعدة العمليّة: اختر أحدث opset يدعمه محرّك الاستدلال المستهدف. لا معنى لاستخدام opset 11 إذا كان مخدّم الإنتاج يشغّل ONNX Runtime 1.17 الذي يدعم opset 20. الإصدارات الحديثة تجلب عمليّات مدمجة أكثر، وتحسينات أفضل.

Protobuf: الحاوية

ملفّ .onnx هو ملفّ Protocol Buffers (بروتوكول التسلسل من Google). البنية معرَّفة في onnx.proto وتحتوي أساسًا على:

  • graph: العُقد، مُدخَلات، مُخرَجات، initializers
  • opset_import: قائمة مجموعات العمليّات المستخدَمة وإصداراتها
  • ir_version: إصدار صيغة ONNX نفسها
  • producer_name وproducer_version: مَن أنتج الملفّ (pytorch 2.4.0 مثلًا)
  • metadata_props: خصائص حرّة (تاريخ التدريب، اسم البيانات...)

هذا التصميم يعني أنّ الملفّ مكتفٍ بذاته: كلّ ما يحتاجه المحرّك موجود بداخله. لا يوجد ملفّ Python مصاحب ولا تعريف لصنف يجب توفيره. هذا فرق جوهريّ مع torch.save(model.state_dict()) الذي رأيناه في دورة PyTorch: هناك، يجب امتلاك تعريف الصنف قبل التحميل. هنا لا شيء من ذلك.

تفتيش نموذج بـPython

قبل الاعتماد على Netron كأداة رسوميّة، من المفيد معرفة كيف نقرأ الرسم برمجيًّا. سنبني نموذجًا بسيطًا في PyTorch ونصدّره، ثم نُفتّشه:

import torch
import torch.nn as nn
import onnx

# نموذج صغير للتوضيح
class Reseau(nn.Module):
def __init__(self):
super().__init__()
self.lin = nn.Linear(10, 5)
self.relu = nn.ReLU()
self.tete = nn.Linear(5, 2)

def forward(self, x):
return self.tete(self.relu(self.lin(x)))

modele = Reseau().eval()
entree = torch.randn(1, 10)

torch.onnx.export(
modele,
entree,
"reseau.onnx",
input_names=["entree"],
output_names=["sortie"],
opset_version=17,
)

# قراءة الرسم برمجيّا
graphe = onnx.load("reseau.onnx")
onnx.checker.check_model(graphe) # يرفع استثناء إن كان الرسم غير صالح

print("إصدار opset :", graphe.opset_import[0].version)
print("عدد العقد :", len(graphe.graph.node))
for n in graphe.graph.node:
print(f" {n.op_type:15s} {list(n.input)} -> {list(n.output)}")

المُخرَج يعرض ثلاث عُقد (Gemm، Relu، Gemm — لاحظ أنّ ONNX يستخدم Gemm لضرب المصفوفات وليس MatMul). هذا هو ما سيراه ONNX Runtime.

الوجه المُظلم: ما لا يوصفه الرسم

الرسم يصف الحساب، لا التدريب. لا يوجد في ملفّ ONNX شيء عن دالّة الخسارة، ولا عن المُستمثِل، ولا عن معدّل التعلّم. تصدير النموذج هو تجميد نسخة استدلال: انتهت مرحلة التعلّم. هذا مقصود ومنطقيّ، فالإنتاج لا يُدرِّب.

كما أنّ الرسم ثابت في شكله: العُقد ومُخرَجاتها معروفة مسبقًا. الشروط في forward والحلقات الديناميكيّة يجب أن تُحلّ عند التصدير، إمّا بتتبّع مسار واحد (torch.onnx.export التقليديّ)، أو بتحويل الشيفرة إلى رسم عبر torch.export وdynamo (سنراه في الوحدة التالية). لهذا نتحدّث عن تصدير، لا عن حفظ بسيط.

إصدارات مضاعَفة، تُخلط بسهولة

هناك ثلاثة إصدارات مختلفة في المشهد، ولا يُخفَى الالتباس بينها. ir_version هو إصدار صيغة الملفّ نفسها (كيف يُنظَّم protobuf). opset_version هو إصدار مجموعة العمليّات المستخدَمة داخل الرسم. وأخيرًا، إصدار مكتبة onnx الذي تُثبِّته في بيئتك (pip install onnx==1.16.0) يحدّد أيّ ir_version وopset_version تُدعَم للقراءة والكتابة. عند الخطأ، تحقّق من الثلاثة، بهذا الترتيب.

الخلاصة

  • ONNX صيغة تبادل، ليست محرّكًا: يُنتجها PyTorch/TensorFlow، ويستهلكها ONNX Runtime وTensorRT وغيرها.
  • الملفّ هو رسم موجّه لا دَوريّ من عُقد (عمليّات) وحوافّ (تنسورات)، مع الأوزان مدمجة بداخله بصيغة protobuf.
  • مجموعة العمليّات (opset) هي عقد بين المُصدِّر والمُستهلك: اختر أحدث إصدار يدعمه محرّك الإنتاج.
  • استعمل Netron للعرض البصريّ وonnx.checker.check_model للفحص البرمجيّ؛ هذه أدوات التشخيص الأولى.

الوحدة التالية: نُصدّر ResNet18 من الخيط الأحمر بـtorch.onnx.export، مع محاور ديناميكيّة وأسماء واضحة.