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