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

الوحدة 7 — التدريب على GPU والدقّة المختلطة

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

نقل النموذج والبيانات إلى GPU

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

appareil = "cuda" if torch.cuda.is_available() else "cpu"
modele = ClassifieurMode().to(appareil) # مرّة واحدة، قبل الحلقة

for lot_x, lot_y in charg_tr:
lot_x = lot_x.to(appareil, non_blocking=True)
lot_y = lot_y.to(appareil, non_blocking=True)
...

النموذج يُنقَل مرّة واحدة قبل التدريب؛ الحزم تُنقَل في كلّ تكرار. non_blocking=True مفيد فقط إن كان DataLoader مُفعِّلًا pin_memory=True (رأيناه في الوحدة الرابعة). القاعدة العملية: pin_memory في DataLoader، ثم non_blocking في to، معًا لا منفصلَين.

قياس عنق الزجاجة الحقيقيّ

قبل أيّ محاولة تحسين، اقس. أسرع طريقة تفريق قيمة النقل عن قيمة الحساب هي مقارنة الحلقة كاملةً بحلقة تعيد استعمال حزمة واحدة:

import time

# الحلقة الكاملة
depart = time.time()
for lot_x, lot_y in charg_tr:
lot_x, lot_y = lot_x.to(appareil, non_blocking=True), lot_y.to(appareil, non_blocking=True)
opt.zero_grad(); perte = critere(modele(lot_x), lot_y)
perte.backward(); opt.step()
temps_total = time.time() - depart

# حلقة بحزمة واحدة مُعادة (لا وقت قراءة)
premiere_x, premiere_y = next(iter(charg_tr))
premiere_x, premiere_y = premiere_x.to(appareil), premiere_y.to(appareil)
depart = time.time()
for _ in range(len(charg_tr)):
opt.zero_grad(); perte = critere(modele(premiere_x), premiere_y)
perte.backward(); opt.step()
temps_calcul = time.time() - depart

print(f"مع البيانات {temps_total:.1f} ث، بلا بيانات {temps_calcul:.1f} ث")

النتيجة تُخبرك بأيّ محور تحسّن. إذا كان الفارق كبيرًا فبيانات DataLoader هي عنق الزجاجة — راجع num_workers وpin_memory والمعالجة القبلية. وإذا كان الاثنان متقاربَين فالحساب هو المُهيمن، والدقّة المختلطة أوّل حلّ يُحاوَل.

torch.cuda.synchronize: القياس الصحيح على GPU

عمليات GPU غير متزامنة مع بايثون. الاستدعاء يعود قبل أن ينتهي الحساب، وعليه فأيّ قياس دون تزامن يُنتج أرقامًا خاطئة:

torch.cuda.synchronize()
depart = time.time()
sortie = modele(lot_x)
torch.cuda.synchronize()
duree = time.time() - depart

بدون synchronize قد تحصل على مدّة صفر لعمليّة تستغرق ثانية. هذا شرح أوّليّ يفسّر «تسريعًا» موهومًا في تجارب مبتدئ.

torch.cuda.amp: الدقّة المختلطة تلقائيًّا

فكرة الدقّة المختلطة: نُجري معظم العمليات بـfloat16 أو bfloat16، ونحتفظ ببعضها الحسّاس (خصوصًا خطوة المُحسِّن وبعض العمليات الجمعية) بـfloat32. النتيجة: مضاعفة 1.5x إلى 3x على GPU الحديثة، مع فقدان ضئيل جدًّا للجودة.

PyTorch يوفّر إطارًا يفعل الأمر آليًّا:

from torch.cuda.amp import autocast, GradScaler

echelle = GradScaler()

for lot_x, lot_y in charg_tr:
lot_x, lot_y = lot_x.to(appareil), lot_y.to(appareil)
opt.zero_grad()

with autocast(dtype=torch.float16):
sorties = modele(lot_x)
perte = critere(sorties, lot_y)

echelle.scale(perte).backward() # يضخّم الخسارة قبل backward
echelle.step(opt) # يُلغي التضخيم قبل التحديث
echelle.update() # يُكيّف عامل التضخيم آليًّا

autocast يختار النوع المناسب لكلّ عملية. GradScaler يعالج مشكلة تدفّق التدرّج تحت العَتَبة: أرقام صغيرة جدًّا في float16 تصير صفرًا، فتضيع التدرّجات. الحلّ ضرب الخسارة في عامل ضخم قبل backward، ثم قسمة التدرّجات على العامل نفسه قبل خطوة المُحسِّن.

bfloat16 مقابل float16

bfloat16 صيغة أحدث ذات مجال أوسع لكن دقّة أقلّ. متوفرة على بطاقات NVIDIA A100 وH100 وعلى مسرِّعات TPU. ميزتها: لا تحتاج GradScaler لأنّ مجالها مماثل لـfloat32. عيبها: على بطاقات أقدم (V100 وتحت) غير مدعومة.

with autocast(dtype=torch.bfloat16):
...
# ولا حاجة إلى GradScaler

عند التوفّر، تفضّلها كثير من الفرق للبساطة. تحقّق أوّلاً بـtorch.cuda.get_device_capability().

عندما يُفقد التسريع

الدقّة المختلطة لا تُعطي دومًا. حالات معروفة:

  • حزمة صغيرة جدًّا: GPU يظلّ أقلّ من قدرته حتى بـfloat32؛ الدقّة المختلطة لا تُضيف شيئًا. رفع حجم الحزمة أوّلًا.
  • بطاقة قديمة: قبل توسير (Turing) نواة TensorCore غائبة، والفرق ضئيل.
  • نموذج مقيَّد بالبيانات: عنق الزجاجة في DataLoader، ولا يعالجه تسريع الحساب.

القاعدة قبل التفعيل: قِس التسريع فعليًّا على نموذجك وحزمك.

torch.compile: النظرة الأولى

منذ PyTorch 2.0، مُترجم اسمه torch.compile يُحوّل النموذج إلى رسم مُحسَّن، بتحسينات مماثلة لما يفعله tf.function في TensorFlow (دورة 08). التفعيل واحد السطر:

modele = torch.compile(modele)               # مرّة واحدة قبل الحلقة

الاستعمال الأوّل يُنفِق ثوانيَ إلى دقائق في التجميع، ثم تجني تسريعًا يتراوح بين 1.2x و2x حسب النموذج. لا يُغيّر واجهة الاستعمال: modele(x)، backward، step كلّها تعمل بالضبط كما قبل.

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

هدف يشمل كلّ ما سبق

نُلحق التسريع بحلقة الوحدة السادسة:

appareil = "cuda"
modele = ClassifieurMode().to(appareil)
modele = torch.compile(modele)
opt = torch.optim.AdamW(modele.parameters(), lr=3e-4, weight_decay=1e-2)
plan = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=20)
critere = torch.nn.CrossEntropyLoss()
echelle = torch.cuda.amp.GradScaler()

for epoque in range(1, 21):
modele.train()
for lot_x, lot_y in charg_tr:
lot_x = lot_x.to(appareil, non_blocking=True)
lot_y = lot_y.to(appareil, non_blocking=True)
opt.zero_grad()
with torch.cuda.amp.autocast(dtype=torch.float16):
perte = critere(modele(lot_x), lot_y)
echelle.scale(perte).backward()
echelle.step(opt)
echelle.update()
plan.step()

هذا الهيكل هو الذي نُنمّيه في الوحدة الثامنة بنقاط الحفظ، ثم في التاسعة بشبكة ResNet18 مُدرَّبة مسبقًا، ثم في العاشرة بتصديره للنشر.

قياس التسريع بأمانة

انشر عدد الصور المعالَجة في الثانية (images/sec)، لا الوقت لكلّ حقبة. الأول ثابت عبر أحجام البيانات، والثاني ينخدع بها:

n_images = 0
depart = time.time()
for lot_x, lot_y in charg_tr:
...
n_images += lot_x.size(0)
torch.cuda.synchronize()
duree = time.time() - depart
print(f"{n_images / duree:.0f} صورة/ث")

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

GradScaler مع bfloat16 خطأ

GradScaler مصمَّم لعلاج ضياع التدرّج في float16. bfloat16 لا يعاني من هذه المشكلة، وتضخيم الخسارة معه يُفسِد الأرقام دون سبب. عند استعمال bfloat16، لا تلفّه بـGradScaler؛ اتّبع النموذج البسيط: autocast وحده مع backward وstep عاديَّين.

قبل التحسين، تأكّد أنّ الحلقة سليمة

الدقّة المختلطة لا تُصلح خطأ في الحلقة، وقد تُخفيه. إن اكتشفت NaN بعد تفعيلها، ارجع إلى float32 وتحقّق: هل التدرّجات كبيرة؟ هل معدّل التعلّم مفرط؟ هل نسيت zero_grad؟ عالِج المشكلة الأصلية، ثم أعِد تفعيل الدقّة المختلطة بثقة.

في الخلاصة

  • pin_memory مع non_blocking يسرِّعان نقل الحزم إلى GPU؛ لا فائدة لأحدهما دون الآخر، ولا لأيّهما دون قياس.
  • الدقّة المختلطة تُعطي مضاعفة 1.5x إلى 3x على البطاقات الحديثة؛ GradScaler مطلوب مع float16، وليس مع bfloat16.
  • torch.compile يُضيف تسريعًا بلا تعديل في واجهة الاستعمال؛ الاستعمال الأوّل يُنفق وقتًا في التجميع، ثم يستفيد لاحقًا.
  • torch.cuda.synchronize لازم في كلّ قياس زمنيّ على GPU؛ بغيره تحصل على أرقام كاذبة.

الوحدة التالية: نقاط الحفظ الكاملة، لاستئناف تدريب مقطوع دون فقدان دقيقة من التقدّم.