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

الوحدة 6 — المفسّر والمفوَّضون العتاديّون

الملفّ .tflite مصنَّع، والوزن مناسب. الآن يجب أن نفهم كيف يُشغَّل داخل التطبيق. القطعة المسؤولة هي المفسّر (Interpreter): كائن قصير العمر يُحمِّل الرسم، يُخصِّص التنسورات، ينفِّذ العمليّات في التسلسل الصحيح، ويُرجع النتائج. حوله تدور فكرة المفوَّضين (delegates): الطبقة التي تُقرِّر أيّ عتاد سيُنفِّذ أيّ عمليّة، وأين تقع المكاسب الحقيقيّة على الهاتف.

دورة حياة المفسّر في خمس خطوات

كلّ نداء للنموذج على الهاتف يمرّ بالخطوات نفسها:

  1. إنشاء المفسّر: قراءة الملفّ .tflite من الأصول، وبناء رسم الحساب في الذاكرة.
  2. إضافة المفوَّضين (اختياريًّا): إخبار المفسّر بأنّه يستطيع تفويض بعض العمليّات إلى GPU أو NNAPI مثلًا.
  3. تخصيص التنسورات (allocate_tensors): حجز الذاكرة الحيّة لكلّ تنسور وسيط، مرّة واحدة قبل أوّل استدلال.
  4. الاستدلال (invoke): تعبئة تنسور الإدخال، تشغيل الرسم، قراءة تنسور المخرَج. هذه الخطوة تتكرَّر آلاف المرّات، والباقي يجري مرّة.
  5. الإغلاق: تحرير الموارد، مهمّ حين يُنشَأ عدّة مفسّرين متتاليًا.

فصل الخطوات 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 % من الحالات:

  1. جرِّب XNNPACK أوّلًا: افتراضيّ، لا جهد، مكسب مؤكَّد.
  2. إن كان النموذج مُكمَّمًا بـint8 وعلى Android: أضف NNAPI مع رجوع آمن إلى CPU.
  3. إن كان النموذج بـfloat32 أو float16: أضف GPU Delegate.
  4. على iOS: جرِّب Core ML Delegate. إن فشل جزء من النموذج، الرجوع تلقائيّ إلى CPU.
  5. قِس دومًا: مفوَّض يعمل جيّدًا على هاتف قد يبطئ على هاتف آخر.

التطبيق: NNAPI على نموذج مُكمَّم

في Android (Kotlin)، إضافة المفوَّض تُختصر في سطرَين:

import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.nnapi.NnApiDelegate

class LeafClassifier(context: Context) {
private val interpreter: Interpreter

init {
val model = FileUtil.loadMappedFile(context, "plantvillage_int8.tflite")
val options = Interpreter.Options().apply {
// XNNPACK مفعَّل افتراضيّا
addDelegate(NnApiDelegate()) // NNAPI مع رجوع إلى CPU للعمليّات غير المدعومة
setNumThreads(4) // عند الرجوع إلى CPU
}
interpreter = Interpreter(model, options)
}

fun classify(input: ByteBuffer, output: Array<ByteArray>) {
interpreter.run(input, output)
}
}

الرجوع إلى CPU يجري تلقائيًّا: كلّ عمليّة لا يدعمها NNAPI (على هاتف بعينه) تعود إلى CPU، بلا تدخّل من المطوّر. هذا أساسيّ لتفادي أخطاء التشغيل على هواتف قديمة أو غير مدعومة.

في بايثون: تجربة GPU Delegate

على المكتب، يمكن تجربة GPU Delegate الخاصّ بـTFLite (يعمل على GPU الحاسوب):

# GPU Delegate يعمل على المكتب مع OpenGL/OpenCL، وعلى الهاتف مع OpenGL ES
interpreter = tf.lite.Interpreter(
model_path="plantvillage_fp16.tflite",
experimental_delegates=[
tf.lite.experimental.load_delegate("libtensorflowlite_gpu_delegate.so"),
],
)

على الهاتف، الطريقة نفسها بأسماء مكتبات مختلفة (Android: TfLiteGpu.so، iOS: TensorFlowLiteC مع خيار GPU).

غياب المفوَّض ليس فشلًا، لكنّه يجب أن يُسجَّل

حين لا يُتاح المفوَّض على الهاتف (نسخة Android قديمة مثلًا)، TFLite لا يُلقي خطأً بل يرجع إلى CPU بصمت. المستخدم لن يلاحظ فرقًا في الجودة، لكنّ زمن الاستجابة سيقفز من 30 مِلّي ثانية إلى 120. سجِّل دومًا أيّ مفوَّض نجحت إضافته، وأيّ عدد من العمليّات رُحِّل فعليًّا. أدوات القياس في الوحدة 9 تُظهر هذا.

قياس الأثر: جدول مقارنة

على هاتفنا المرجعيّ (Snapdragon 680) مع نموذج PlantVillage int8:

المفوَّضزمن استدلال وسيطاستهلاك بطّاريّةملاحظة
CPU (خيط واحد)96 msمرتفعكأساس
CPU (4 خيوط، XNNPACK)48 msمتوسّطافتراضيّ
GPU Delegate34 msمرتفع للنماذج الصغيرةمفيد للنماذج الأكبر
NNAPI (Hexagon DSP)18 msمنخفضالأمثل لهذا النموذج

الاختيار: NNAPI، يعطي الأسرع والأقلّ استهلاكًا. على iPhone، يعادل هذا Core ML على Neural Engine.

الفخّ: مفوَّض لا يدعم كلّ العمليّات

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

الحلّ: ابنِ النموذج مع علم بالمفوَّض المستهدَف. تجنَّب العمليّات النادرة، اجعل الرسم متجانسًا، اختبر باكرًا. Benchmark Tool (الوحدة 9) يُظهر تحديدًا عدد العمليّات المُرحَّلة إلى المفوَّض، وكم بقي على CPU.

الخلاصة

  • المفسّر يُنشَأ مرّة، ويُستدلّ به آلاف المرّات: الإنشاء وتخصيص التنسورات مُكلِف؛ الاستدلال ذاته سريع. لا تُنشئ مفسّرًا جديدًا لكلّ صورة.
  • معاملات التكميم داخل تنسور الإدخال: scale وzero_point هما ما يسمح بالانتقال بين uint8 الصورة الخام وint8 المُكمَّم. تجاهلهما = مخرَجات عشوائيّة.
  • خمسة مفوَّضين، قاعدة اختيار بسيطة: XNNPACK افتراضيًّا، NNAPI للـint8 على Android، GPU للـfloat32/16، Core ML على iOS، Hexagon للأجهزة القديمة. قِس دومًا.
  • الرجوع الآمن إلى CPU تصميم قصديّ في TFLite: مفوَّض غير مدعوم يرجع تلقائيًّا. مسؤوليّتك أن تُسجِّل ما جرى وتقيس أنّ المفوَّض يعمل فعلًا.

الوحدة التالية تنقل هذا المفسّر إلى تطبيق Android حقيقيّ: تحميل، معالجة صورة كاميرا، خيط خلفيّ، ومكتبة Task.