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

الوحدة 9 — العمليّات غير المدعومة والحلول البديلة

في الوحدات السابقة تعاملنا مع نماذج «مُهيَّأة»: ResNet18 هو من الأصناف الأولى التي دُعِمت في ONNX منذ يومه الأوّل، وTensorFlow يُقدّم دعمًا شاملًا لطبقات Keras القياسيّة. الواقع في الإنتاج مختلف. كلّ فريق يبني في وقت ما طبقة مخصّصة أو يستعمل عمليّة PyTorch حديثة أو مُرمِّزًا نصيًّا يحوي شيئًا لا يعرفه المُصدِّر. النتيجة رسالة خطأ في وقت التصدير، وأحيانًا رسالة أخبث في وقت الاستدلال. هذه الوحدة تُقدّم منهجيّة عمليّة لمعالجة الحالات، وتُطبِّقها على مُرمِّز النصّ من الخيط الأحمر.

قراءة رسالة الخطأ أوّلًا

الخطأ الأشيع يظهر في torch.onnx.export بصيغة قريبة من:

RuntimeError: Exporting the operator aten::my_op to ONNX opset version 17
is not supported. Please feel free to request support or submit a pull
request on PyTorch GitHub.

هذه الرسالة تخبرك بثلاثة أشياء ثمينة: اسم العمليّة الدقيق (aten::my_opإصدار opset الذي جرّبت (17)، وأنّها مسألة دعم، لا مسألة نموذج معطوب. الخطأ المضادّ الذي يجب تفاديه: تعديل النموذج بشكل عشوائيّ قبل قراءة الرسالة بعناية. اقرأها كاملة، خذ نسخة، وافتح صفحة torch.onnx.symbolic_opsets في مستودع PyTorch لتفهم أيّ إصدارات تدعم my_op.

الحالة الأخرى تظهر أثناء الاستدلال في ONNX Runtime:

InvalidGraph: [ONNXRuntimeError] Invalid input name: 'foo'
InvalidArgument: Node has invalid type: 'CustomOp' at opset ai.onnx: 17

هنا التصدير نجح، لكنّ المحرّك لا يعرف كيف يُنفّذ عقدة معيّنة. غالبًا لأنّ التصدير أنتج عمليّة من مجال (domain) لا يدعمه ONNX Runtime المُثبَّت، أو لأنّ إصدار opset في الملفّ أعلى من إصدار المحرّك.

الحلّ الأوّل: ترقية إصدار opset

نصف الأخطاء يُعالجها تحديث الإصدار. عمليّة مثل torch.nn.functional.scaled_dot_product_attention صارت مدعومة من opset 14 بشكل مباشر، وطبقة LayerNorm من opset 17. إذا كان تصديرك يستهدف opset 13، ستفشل هذه العمليّات. جرّب أوّلًا:

torch.onnx.export(
modele,
entree_ref,
"modele.onnx",
opset_version=20, # اختر أحدث ما يدعمه ONNX Runtime في الإنتاج
...
)

قبل الرفع، تحقّق أنّ ONNX Runtime المُثبَّت في الإنتاج يدعم opset 20. الجدول المرجعيّ في التوثيق يربط إصدار ONNX Runtime بأعلى opset مدعوم: onnxruntime 1.17 يدعم opset 20، و1.15 يقف عند opset 19. عدم التوافق يُنتج خطأ صريح عند إنشاء الجلسة، لا في المُنتَج النهائيّ.

الحلّ الثاني: إعادة كتابة الجزء المُشكل

عندما ترفع ترقية opset لا تكفي، الحلّ الثاني هو تعديل النموذج نفسه ليستعمل عمليّات مكافئة مدعومة. هذا يبدو مُكلفًا، لكنّه عمليًّا الحلّ الأسرع في 70% من الحالات، لأنّ التغيير محدود بسطر أو سطرين. أمثلة كلاسيكيّة:

  • طبقة Mish غير مدعومة قبل opset 18: استبدلها بـx * torch.tanh(F.softplus(x)) صراحةً.
  • torch.einsum بصيغ نادرة قد لا تُترجَم جيّدًا: أَعِد كتابتها بـmatmul وpermute.
  • شرط داخل forward يعتمد على قيمة تنسور: أخرجه من forward وطبِّقه في المعالجة القبلية.

مثال ملموس على مُرمِّز النصّ. النسخة الأصليّة تستعمل خدعة نمطيّة لكنّها لا تُصدَّر جيّدًا:

class CodeurAvant(nn.Module):
def forward(self, ids, longueurs):
# حزمة مضغوطة تعتمد على قيم longueurs في وقت التنفيذ
h = nn.utils.rnn.pack_padded_sequence(
self.emb(ids), longueurs, batch_first=True, enforce_sorted=False,
)
_, dernier = self.rnn(h)
return self.tete(dernier.squeeze(0))

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

class CodeurPret(nn.Module):
def forward(self, ids, masque):
# لا ضغط ديناميكيّ؛ قناع boolean واضح
h = self.emb(ids)
sorties, dernier = self.rnn(h)
# نأخذ آخر خطوة صالحة عبر ضرب بالقناع
h_masquee = sorties * masque.unsqueeze(-1).float()
somme = h_masquee.sum(dim=1)
compte = masque.sum(dim=1, keepdim=True).clamp(min=1).float()
vecteur = somme / compte
return self.tete(vecteur)

الرسم الجديد يستعمل فقط Embedding، GRU، Mul، Sum، Div، MatMul، Add — كلّها عمليّات مدعومة رسميًّا منذ opset 11. المُرمِّز يعمل بنفس المنطق تقريبًا، لكنّه صار قابلًا للتصدير على أيّ محرّك.

الحلّ الثالث: العمليّة المخصّصة

عندما إعادة الكتابة غير ممكنة (خوارزميّة أساسيّة للنموذج، أو عمليّة C++ محسَّنة يجب الاحتفاظ بها)، الحلّ هو تعريف عمليّة مخصّصة يُسجَّلها الطرفان: PyTorch لتصديرها في الرسم، وONNX Runtime لتنفيذها.

من جانب التصدير، يُسجَّل رمز خاصّ:

from torch.onnx import register_custom_op_symbolic

def symbol_my_op(g, x, alpha):
# ننتج عقدة ONNX من مجال مخصّص
return g.op("com.myorg::MyOp", x, alpha_f=alpha)

register_custom_op_symbolic("mynamespace::my_op", symbol_my_op, 17)

الاستخدام في التصدير يُنتج عقدة com.myorg::MyOp في الرسم. لتنفيذها، يجب تجميع مكتبة C++ تُطبّق العمليّة وتسجيلها في ONNX Runtime عبر session_options.register_custom_ops_library("libmyops.so"). هذا حلّ قوي لكنّه ثقيل: تعديل C++، توزيع مكتبات مشتركة، دعم لكلّ منصّة مستهدفة. لا تلجأ إليه إلّا عندما لا يوجد بديل، ووثِّقه بعناية للفريق الذي يتحمّل الصيانة.

الحلّ الرابع: تقسيم الرسم

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

# قبل: كلّ شيء في النموذج
class ModeleComplet(nn.Module):
def forward(self, x):
y = self.reseau(x)
return self.postprocess(y) # عمليّة غير مدعومة

# بعد: النموذج ينتج التنسور الخام، والمعالجة اللاحقة في بايثون
class ModeleNu(nn.Module):
def forward(self, x):
return self.reseau(x)

# في الخدمة
y_brut = session.run(None, {"x": x_np})[0]
sortie = postprocess(y_brut) # بايثون خالص، أيّ عمليّة ممكنة

هذا يُقلّل الأداء قليلًا (نقل تنسور إضافيّ)، لكنّه يُبسّط التصدير كثيرًا. مفاضلة معقولة عندما تكون العمليّة نادرة الاستدعاء أو تعتمد على منطق أعمال يتغيّر بشكل مستقلّ عن النموذج.

عودة إلى مُرمِّز النصّ: الحالة الكاملة

المُرمِّز الأصليّ من الوحدة 2 يستعمل pack_padded_sequence. عند التصدير بـopset 17، الخطأ صريح:

RuntimeError: ONNX export failed on ATen operator _pack_padded_sequence:
opset_version >= 11 required.

نُطبِّق المنهجيّة بالترتيب:

  1. قراءة الرسالة: العمليّة موجودة من opset 11. ترقية opset إلى 20 تُغيّر شيئًا؟ لا، الخطأ نفسه.
  2. إعادة الكتابة: نستبدل pack_padded_sequence بقناع اهتمام كما رأينا أعلاه. تصدير جديد ينجح.
  3. التحقّق العدديّ (وحدة 4): الفارق الأقصى 2e-6، نسبة اتّفاق الأصناف 100%.
  4. قياس الأداء (وحدة 8): p50 من 1.8 ms إلى 0.7 ms مع ONNX Runtime، تسريع ×2.6.

النموذج المُعاد كتابته أبسط، أسرع، ومتوافق. الوقت الإجماليّ: 20 دقيقة تعديل، ساعة تحقّق. أرباح كبيرة.

قواعد ذهبيّة في التصميم

بعد عدّة مشاريع، تظهر عادات تُوفّر مشاكل التصدير:

  • تجنّب عمليّات على أشكال ديناميكيّة داخل forward (x.shape[0] استعماله محدود، لا x.shape[0] // 4 أو ما شابه).
  • تجنّب الشروط والحلقات بايثون التي تعتمد على قيم تنسور؛ استعمل عمليّات تنسور (torch.where، torch.masked_select).
  • افصل المعالجة القبلية واللاحقة عن النموذج نفسه؛ اجعل الرسم يقبل ويُنتج تنسورات فقط.
  • اختبر التصدير مبكّرًا في مسار التطوير، ليس بعد أشهر من التدريب. تصميم يُصدَّر بسهولة يُبنى، لا يُنقذ لاحقًا.
الاستراتيجيّة الذكيّة: التتبّع بـdynamo مع الاحتياط

منذ PyTorch 2.1، المُصدِّر الجديد بـtorch.export وdynamo (رأيناه في الوحدة 2 بـdynamo=True) يعالج حالات كثيرة كانت تفشل مع المُصدِّر التقليديّ. عندما تفشل رسالة خطأ مع التتبّع الكلاسيكيّ، جرِّب dynamo قبل إعادة كتابة النموذج: النسبة لصالح dynamo تتحسّن مع كلّ إصدار. لكن احتفظ بخطّة بديلة: dynamo لا يزال يُخطئ في بعض السيناريوهات، والرجوع إلى إعادة الكتابة أو التقسيم يبقى الحلّ الأكيد.

قائمة تحقّق عند الفشل

عندما يفشل تصديرك، اتبع هذا الترتيب بدل التخبّط:

  1. اقرأ الرسالة كاملة، حدِّد اسم العمليّة وإصدار opset الحاليّ.
  2. جرّب ترقية opset إلى أحدث إصدار يدعمه محرّك الإنتاج.
  3. جرّب dynamo=True إن كنت على التتبّع التقليديّ.
  4. ابحث في مستودع PyTorch عن symbolic_opset للعمليّة؛ قد تجد مثالًا لتسجيل يدويّ.
  5. أعد كتابة الجزء المُشكل بعمليّات أساسيّة؛ هذا يعمل في معظم الأحيان.
  6. قسّم الرسم إذا كانت العمليّة على الحواف (قبل/بعد النموذج).
  7. عمليّة مخصّصة كملاذ أخير، مع صيانة C++ متوقّعة.

هذه القائمة تُنقذ من دورات محبطة. المرور من 1 إلى 5 يُغطّي 90% من الحالات في مشاريع الإنتاج.

الخلاصة

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

الوحدة التالية: نضع النموذج المُصدَّر خلف FastAPI بأسلوب إنتاجيّ حقيقيّ.