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

الوحدة 6 — التكميم في ONNX: من float32 إلى int8

بعد تحسين الرسم بدون تغيير الأنواع، الخطوة التالية أكثر جذريّة: تخفيض دقّة الأوزان والتنشيطات من 32 بت إلى 8 بت. المكسب مضاعف: حجم النموذج على القرص يُقسم على أربعة، والاستدلال يتسارع على وحدات المعالجة الحديثة التي تحوي تعليمات int8 مخصّصة. الثمن: انحراف طفيف في الدقّة يجب التحكّم فيه.

المبدأ في سطر واحد

التكميم يستبدل كلّ قيمة عائمة x بعدد صحيح q وفق:

xs(qz)x \approx s \cdot (q - z)

حيث s معامل السلّم (scale) وz نقطة الصفر (zero_point). كلاهما يُختار لكلّ تنسور أو لكلّ قناة بحيث يُغطّي مدى القيم الفعليّ. النتيجة: int8 يمثّل 256 قيمة عوض ملايين قيم float32، لكن إذا اختير s وz جيّدًا، فالفارق أصغر ممّا يخافه المرء.

التكميم الديناميكيّ: الأسرع للنشر

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

from onnxruntime.quantization import quantize_dynamic, QuantType

quantize_dynamic(
model_input="modele.onnx",
model_output="modele-dynq.onnx",
weight_type=QuantType.QInt8,
)

المزايا: بسيط، لا يستدعي بيانات إضافيّة. العيوب: التكميم أثناء التنفيذ يستهلك جزءًا من المكسب المتوقّع، والدقّة قد تنحرف قليلًا. مناسب جدًّا لنماذج اللغة حيث تكاليف الاستدلال تتركّز في MatMul بأوزان كبيرة، وأقلّ ملاءمة لنماذج الرؤية المهيمَنة عليها Conv.

التكميم الثابت: الأدقّ والأسرع

التكميم الثابت (static) يُكمِّم كلّ شيء مسبقًا، بما فيه التنشيطات. لكنّ هذا يفرض معرفة مدى قيم التنشيطات المتوقّعة على الإدخالات الحقيقيّة. الحلّ: مرور معايرة على بضع مئات من العيّنات التمثيليّة.

from onnxruntime.quantization import quantize_static, QuantType, CalibrationDataReader
import numpy as np

class LecteurCalibration(CalibrationDataReader):
def __init__(self, echantillons, nom_entree="image"):
self.echantillons = iter(echantillons)
self.nom = nom_entree

def get_next(self):
try:
x = next(self.echantillons)
except StopIteration:
return None
return {self.nom: x.astype(np.float32)}

# 200 صورة ممثّلة (يفضّل من مجموعة تحقّق حقيقيّة)
echantillons = [np.random.randn(1, 3, 224, 224) for _ in range(200)]

quantize_static(
model_input="modele.onnx",
model_output="modele-statq.onnx",
calibration_data_reader=LecteurCalibration(echantillons),
quant_format="QDQ",
weight_type=QuantType.QInt8,
activation_type=QuantType.QUInt8,
)

المكسب في السرعة أعلى، والانحراف في الدقّة أقلّ عادةً، لأنّ المدى مُقاس على بيانات حقيقيّة لا مقدَّر عند التنفيذ.

QDQ مقابل QOperator: الصيغتان

عندما يُصدَّر نموذج مكمَّم، ONNX يقدّم صيغتَين لتمثيل ذلك:

  • QOperator: كلّ عمليّة قابلة للتكميم تُستبدل بنظيرتها المكمَّمة (QLinearConv بدل Conv).
  • QDQ (Quantize-DeQuantize): تُدرَج عُقد QuantizeLinear وDequantizeLinear حول العمليّات الأصليّة، مع الحفاظ على الرسم الأصليّ ظاهرًا.

الصيغة QDQ هي التوصية الحاليّة: أكثر مرونة، مدعومة على مزوّدي تنفيذ أكثر (خصوصًا TensorRT الذي يرفض QOperator)، وأسهل في التشخيص لأنّ التنسورات العائمة تبقى ظاهرة في الرسم. اختر quant_format="QDQ" افتراضيًّا.

المعايرة: الأداة الحقيقيّة للجودة

جودة التكميم الثابت تعتمد كليًّا على كيفية اختيار s وz انطلاقًا من عيّنات المعايرة. ONNX Runtime يعرض ثلاث طرق:

  • MinMax: أبسط، تأخذ الأدنى والأقصى المشاهدَين. حسّاسة للقيم الشاذّة.
  • Entropy: تُقلّل تباعد كولباك-لايبلر بين توزيع float32 والتوزيع المكمَّم. أدقّ عمومًا، أبطأ في المعايرة.
  • Percentile: تُقصّر الذيول (99.99% من القيم مثلًا). حلّ وسط جيّد.

الاختيار عبر:

from onnxruntime.quantization import CalibrationMethod

quantize_static(
...,
calibrate_method=CalibrationMethod.Entropy,
)

على نماذج الرؤية، Entropy أو Percentile عادةً تُعطي دقّة أفضل من MinMax. القاعدة العمليّة: ابدأ بـEntropy، وإذا كان الوقت مشكلة (معايرة 5 دقائق على 1000 عيّنة تصير 30 دقيقة)، جرِّب Percentile.

أثر التكميم على النموذجَين من الخيط الأحمر

نطبّق التكميم الثابت على ResNet18 وعلى مُرمِّز النصّ، ونقارن:

ResNet18 (تكميم ثابت INT8):

  • الحجم: 45 ميغابايت → 12 ميغابايت (÷3.75)
  • السرعة على CPU: 22 ms → 9 ms (×2.5)
  • الدقّة على ImageNet: 69.7% → 69.4% (خسارة 0.3 نقطة)

مُرمِّز النصّ (تكميم ديناميكيّ INT8):

  • الحجم: 8 ميغابايت → 2.5 ميغابايت
  • السرعة: 4 ms → 2 ms
  • F1 على SST-2: 87.3% → 87.1%

هذه أرقام نموذجيّة. تُعرَض هنا كنظام تصنيف: مكسب كبير في الحجم والسرعة، ثمن صغير في الدقّة. القرار يجب أن يُتّخذ بأرقام حقيقيّة على مهمّتك، لا بالافتراض.

متى لا يعمل التكميم بشكل جيّد؟

ليس كلّ نموذج مرشّح جيّد للتكميم:

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

في المقابل، الشبكات التلافيفيّة الكبيرة (ResNet، MobileNet، YOLO) والمحوّلات المعياريّة (BERT، DistilBERT) مرشّحات ممتازة.

توافق العتاد: أين يعمل INT8 فعلًا؟

المكسب في السرعة يحدث فقط إذا كان العتاد المستهدَف يدعم تعليمات INT8:

  • CPU حديث (Intel Cascade Lake وأحدث، AMD Zen 4 وأحدث): يدعم VNNI أو AVX-512 VNNI، مكسب حقيقيّ.
  • CPU أقدم: التكميم يُنفَّذ بمحاكاة، قد يكون أبطأ من float32.
  • GPU NVIDIA حديث (Turing وأحدث): يدعم INT8 عبر Tensor Cores، مع TensorRT.
  • GPU AMD: دعم متغيّر، اختباره ضروريّ.

القاعدة: دائمًا قِس على العتاد الفعليّ للإنتاج، لا على حاسوبك المحلّي. رسم مكمَّم يعمل جيّدًا على i9 حديث قد يكون كارثيًّا على خادم قديم.

INT4 وأقل: عالم البحث، ليس الإنتاج

مع صعود نماذج اللغة الكبيرة، ظهرت تقنيات تكميم إلى INT4 وحتى INT2 (GPTQ، AWQ). ONNX Runtime يدعمها جزئيًّا. لكنّ الأدوات ليست ناضجة كمثيلاتها في INT8، والدعم على المحرّكات محدود. القاعدة: التزم بـINT8 للإنتاج المستقرّ، وارصد INT4 كتقنية استراتيجيّة تُختبر بحذر.

الخلاصة

  • التكميم يستبدل float32 بـint8 عبر معامل سلّم ونقطة صفر؛ الحجم ÷4، السرعة ×2 إلى ×4 على العتاد المناسب.
  • الديناميكيّ أبسط (لا معايرة)، جيّد للنصّ؛ الثابت أدقّ وأسرع، ضروريّ للرؤية.
  • استعمل صيغة QDQ لتوافق أوسع، خصوصًا مع TensorRT.
  • المعايرة بـEntropy أو Percentile تُقلّص الانحراف؛ العتاد الحديث ضروريّ لتحقيق المكسب.

الوحدة التالية: مزوّدو التنفيذ، الأداة التي تُترجم رسم ONNX إلى تنفيذ على العتاد المستهدَف.