الوحدة 6 — المفسّر والمفوَّضون العتاديّون
الملفّ .tflite مصنَّع، والوزن مناسب. الآن يجب أن نفهم كيف يُشغَّل داخل التطبيق. القطعة المسؤولة هي المفسّر (Interpreter): كائن قصير العمر يُحمِّل الرسم، يُخصِّص التنسورات، ينفِّذ العمليّات في التسلسل الصحيح، ويُرجع النتائج. حوله تدور فكرة المفوَّضين (delegates): الطبقة التي تُقرِّر أيّ عتاد سيُنفِّذ أيّ عمليّة، وأين تقع المكاسب الحقيقيّة على الهاتف.
دورة حياة المفسّر في خمس خطوات
كلّ نداء للنموذج على الهاتف يمرّ بالخطوات نفسها:
- إنشاء المفسّر: قراءة الملفّ
.tfliteمن الأصول، وبناء رسم الحساب في الذاكرة. - إضافة المفوَّضين (اختياريًّا): إخبار المفسّر بأنّه يستطيع تفويض بعض العمليّات إلى GPU أو NNAPI مثلًا.
- تخصيص التنسورات (
allocate_tensors): حجز الذاكرة الحيّة لكلّ تنسور وسيط، مرّة واحدة قبل أوّل استدلال. - الاستدلال (
invoke): تعبئة تنسور الإدخال، تشغيل الرسم، قراءة تنسور المخرَج. هذه الخطوة تتكرَّر آلاف المرّات، والباقي يجري مرّة. - الإغلاق: تحرير الموارد، مهمّ حين يُنشَأ عدّة مفسّرين متتاليًا.
فصل الخطوات 1-3 عن 4 يُعطي مكسبًا كبيرًا: لا تُنشئ مفسّرًا جديدًا لكلّ استدلال. أنشئه مرّة عند بدء التطبيق، واحتفظ به. الإنشاء يستغرق مئات المِلّي ثانية، والاستدلال عشرات.
في بايثون: هيكل الاستدلال الأدنى
قبل الانتقال إلى Android، نطبِّق الفكرة نفسها في بايثون لنتحقّق من صحّتها:
import numpy as np
import tensorflow as tf
# 1) إنشاء المفسّر
interpreter = tf.lite.Interpreter(model_path="plantvillage_int8.tflite")
# 2) تخصيص التنسورات
interpreter.allocate_tensors()
# 3) الحصول على معلومات المدخلات والمخرجات
input_details = interpreter.get_input_details()[0]
output_details = interpreter.get_output_details()[0]
print(f"شكل الإدخال : {input_details['shape']}, النوع : {input_details['dtype']}")
print(f"شكل المخرَج : {output_details['shape']}, النوع : {output_details['dtype']}")
# 4) الاستدلال
def predire(image_uint8):
# الصورة الأصليّة uint8 في [0, 255]، النموذج ينتظر int8 مُكمَّم
scale, zero_point = input_details["quantization"]
image_int8 = (image_uint8.astype(np.float32) / 255.0 / scale + zero_point).astype(np.int8)
interpreter.set_tensor(input_details["index"], image_int8[np.newaxis, ...])
interpreter.invoke()
y = interpreter.get_tensor(output_details["index"])
return y
# 5) استعمال متكرِّر
image = np.random.randint(0, 255, (224, 224, 3), dtype=np.uint8)
probs = predire(image)
print(f"أعلى ثلاثة أصناف : {probs[0].argsort()[-3:][::-1]}")
معاملات التكميم (scale وzero_point) تُخزَّن داخل تنسور الإدخال نفسه، وهي ما يسمح بالانتقال بين uint8 وint8 بشكل صحيح. الخطأ الشائع هو تجاهلها ومعاملة الصورة كأنّها float32.
المفوَّضون: نقل العمليّات إلى العتاد المسرَّع
CPU يُنفِّذ كلّ العمليّات، لكن ببطء نسبيّ. الهواتف الحديثة تحتوي على مسرِّعات أخرى: GPU، وNPU (Neural Processing Unit)، وDSP. المفوَّض هو جسر بين المفسّر وهذا العتاد: يقول له « هذه العمليّات أستطيع تنفيذها، خُذها منّي »، والمفسّر يوزِّع الرسم بينه وبين CPU.
خمسة مفوَّضين تُهمّنا:
- XNNPACK: مكتبة CPU مُحسَّنة (مفتوحة، من Google). مفعَّلة افتراضيًّا على TFLite الحديث لعمليّات
float32وبعض عمليّاتint8. مكسب 20-50 % بلا جهد. - GPU Delegate: يُنفِّذ الالتفافات على GPU الهاتف. مكسب كبير على النماذج المتوسّطة والكبير ة (2-4 أضعاف السرعة). يستهلك بطاريّة أكثر من CPU للنماذج الصغيرة.
- NNAPI (Android فقط): واجهة نظام Android تُوجِّه العمليّات إلى أيّ مسرِّع متوفِّر (GPU، DSP، NPU). مكسب متفاوت حسب الهاتف. يُشتَرَط
int8عادةً. - Core ML Delegate (iOS فقط): يُحوِّل جزءًا من النموذج إلى Core ML لاستعمال Neural Engine على أجهزة Apple. مكسب كبير على iPhone و iPad.
- Hexagon Delegate: خاصّ بمعالجات Qualcomm DSP. مفيد للأجهزة القديمة التي لا تدعم NNAPI جيّدًا.
اختيار المفوَّض: قاعدة بسيطة
قاعدة عمليّة تعمل في 90 % من الحالات:
- جرِّب XNNPACK أوّلًا: افتراضيّ، لا جهد، مكسب مؤكَّد.
- إن كان النموذج مُكمَّمًا بـint8 وعلى Android: أضف NNAPI مع رجوع آمن إلى CPU.
- إن كان النموذج بـfloat32 أو float16: أضف GPU Delegate.
- على iOS: جرِّب Core ML Delegate. إن فشل جزء من النموذج، الرجوع تلقائيّ إلى CPU.
- قِس دومًا: مفوَّض يعمل جيّدًا على هاتف قد يبطئ على هاتف آخر.