التسريع
درس 7 من 8
7التسريع
الصورة الكبيرةالتسريع الحقيقي يأتي من ثلاثة أماكن بالترتيب: كود متّجه بدل حلقات بايثون، ودقة أقل للأرقام حيث لا تضر، وتجميع النموذج. وكل تسريع يجب أن يُقاس على جهازك، لأن ما يسرّع على بطاقة رسوميات قد يبطئ على معالج.
ماذا تشاهد في الرسم: 16 بت تعني نصف الذاكرة: 14 غيغابايت بدل 28. النوعان بنفس الحجم، لكن bfloat16 يحتفظ بمدى float32 فلا يفيض بسهولة، ولهذا صار الخيار المفضّل للتدريب حين تدعمه البطاقة.
خارطة هذا الجزء
المفاهيم بالترتيب، اضغط أيًّا منها للقفز إليه:
طريقان إلى نفس الفكرة
تستطيع أن تجعل التدريب أسرع بطريقتين: أن تحسب بأرقام أقصر (مثل أن تقرّب الأسعار لأقرب ريال بدل الهللات)، أو أن تطلب من المترجم أن يحضّر الحسابات مسبقًا. لكن السرعة لها ثمن في التعقيد، فلا تدفعه إلا إن كان البطء يؤخرك فعلًا.
الدقة المختلطة torch.autocast تحسب بـfloat16 أو bfloat16 وتحفظ الأوزان بـfloat32، ومعها GradScaler لمنع تلاشي الميل. وtorch.compile يدمج العمليات في نوى أسرع. تقيس قبل وبعد بزمن الحقبة واستهلاك الذاكرة، وتتأكد أن الدقة لم تنخفض.
قبل أن تبدأ
🧊الموتر
تعرف أن dtype يحدّد حجم كل رقم في الذاكرة، وأن الموتر يعيش على جهاز.
🚚Dataset و DataLoader
تعرف num_workers، فأول تسريع هو أن تبقى البطاقة مشغولة.
الفكرة الجوهرية
السرعة الحقيقية تأتي من ثلاثة أماكن بالترتيب. في هذا الدرس تتخلّص من حلقات Python أولًا، ثم تفهم حدود الأرقام بـ16 بت، ثم تلفّ الخطوة بالدقة المختلطة، ثم تقيس على جهازك قبل أن تصدّق.
1العمليات المتّجهة أولًا
قبل أي حيلة متقدّمة، تأكّد أن الحساب الثقيل لا يجري في حلقة Python. هذا وحده غالبًا أكبر تسريع ستحصل عليه، وأرخصه.
import time
import numpy as np
x = np.random.default_rng(0).normal(size=1_000_000)
start = time.perf_counter()
total = 0.0
for v in x:
total += v * v
loop_s = time.perf_counter() - start
start = time.perf_counter()
total_np = float((x * x).sum())
vec_s = time.perf_counter() - start
print("same result:", abs(total - total_np) < 1e-6)
print("vectorised at least 3x faster:", vec_s * 3 < loop_s)
نفس الناتج، والنسخة المتّجهة أسرع بأضعاف كثيرة (عشرات أو مئات المرات حسب الجهاز). السبب الذي رأيته في درس المصفوفات: العملية المتّجهة تنفّذ حلقة واحدة مكتوبة بلغة C، بينما حلقة بايثون تفسّر كل عنصر على حدة.
2حدود الأرقام بـ16 بت
الرقم بـ16 بت يأخذ نصف ذاكرة الرقم بـ32 بت، لكنه يدفع الثمن في المدى أو في الدقة. انظر إلى الرسم: float16 يعطي الأس 5 بتات فقط، لذلك أكبر قيمة فيه 65504.
import numpy as np
x = np.float32(1.0)
print("float32 1 + 1e-8:", x + np.float32(1e-8) == x)
h = np.float16(1000.0)
print("float16 1000 + 0.3:", h + np.float16(0.3))
print("float16 max:", np.finfo(np.float16).max)
print("float16 70000:", np.float16(70000.0))
print("bytes per value: float32 =", np.dtype(np.float32).itemsize, "float16 =", np.dtype(np.float16).itemsize)
float16 لا يستطيع تمثيل 1000.3، فيقرّبه إلى 1000.5، وأكبر قيمة فيه 65504، و70000 تصير لانهاية. لهذا تحتاج float16 إلى GradScaler الذي يكبّر الخسارة قبل backward حتى لا تصير الميول الصغيرة صفرًا، ثم يصغّرها. bfloat16 يتجنّب مشكلة المدى، ويدفع ثمنها بدقة أقل.
float16 أدق لكنه ينفجر بسهولة، وbfloat16 أقل دقة لكن مداه مدى float32 نفسه، فلا يحتاج حيل تكبير الخسارة.
3الدقة المختلطة: autocast وGradScaler
الدقة المختلطة تنفّذ أغلب العمليات بـ16 بت وتبقي الحسّاسة بـ32. انظر إلى الرسم: الأوزان الأصلية تبقى float32، والحساب وحده ينزل إلى 16 بت.
هذا الشكل الكامل لخطوة تدريب بـfloat16. لا يعمل بدون بطاقة رسوميات، فهو للقراءة هنا.
scaler = torch.amp.GradScaler("cuda")
model = torch.compile(model)
for xb, yb in loader:
xb, yb = xb.to("cuda"), yb.to("cuda")
with torch.autocast("cuda", dtype=torch.float16):
loss = loss_fn(model(xb), yb)
optimizer.zero_grad()
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
الأسطر الخمسة الأساسية لم تتغيّر، فقط لُفّت. autocast يختار الدقة لكل عملية، وscaler يحمي الميول الصغيرة. ولاحظ torch.compile في الأعلى: سطر واحد، وأول دفعة ستأخذ دقيقة أو أكثر للتجميع.
4قِس على جهازك قبل أن تصدّق
شغّل الجزء المشكوك فيه أكثر من مرة بـtime.perf_counter()، لأن المرة الأولى فيها تحميل وتسخين. انظر إلى الرسم: النصيحة العامة تقول إن الدقة الأقل أسرع، وهذا المعالج يقول العكس.
هذا الكود يحتاج PyTorch على جهازك، فالناتج المعروض مسجَّل من تشغيل حقيقي. ثبّته بـpip install torch وشغّله بنفسك.
import time
import torch
from torch import nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(512, 2048), nn.GELU(), nn.Linear(2048, 512))
x = torch.randn(256, 512)
def timed(fn, repeats=20):
fn()
start = time.perf_counter()
for _ in range(repeats):
fn()
return (time.perf_counter() - start) / repeats * 1000
with torch.no_grad():
fp32 = timed(lambda: model(x))
with torch.autocast("cpu", dtype=torch.bfloat16):
out = model(x)
bf16 = timed(lambda: model(x))
print("output dtype under autocast:", out.dtype)
print(f"fp32 {fp32:.1f} ms, bf16 {bf16:.1f} ms per batch")
على هذا المعالج، bfloat16 أبطأ بأكثر من الضعف. المعالجات العادية لا تملك وحدات حساب سريعة لـ16 بت، فيدفع التحويل ثمنه بلا مقابل. على بطاقة رسوميات حديثة ستنقلب النتيجة. هذا بالضبط سبب قاعدة «قِس قبل أن تقبل»: النصيحة العامة «الدقة الأقل أسرع» خاطئة هنا.
على بطاقة الرسوميات استدعِ torch.cuda.synchronize() قبل قراءة الساعة، لأن العمليات هناك غير متزامنة. وtorch.compile يأتي في الآخر، للتدريب الطويل فقط.
كيف يعمل، خطوة بخطوة: كيف تسرّع التدريب بالترتيب
قِس أولًا بـ time.perf_counter() مع إحماء وتكرار، واستدعِ torch.cuda.synchronize() على البطاقة قبل قراءة الوقت.
أخرج الحساب الثقيل من حلقات بايثون إلى عمليات متّجهة، فهذا غالبًا أكبر تسريع.
لفّ المرور الأمامي والخسارة بـ torch.autocast، وأضف GradScaler إن كنت تستخدم float16.
أضف torch.compile(model) للتدريب الطويل، وتجاهل الدفعات الأولى عند القياس لأنها تشمل وقت التجميع.
مثال محلول
قبل أي حيلة متقدّمة، تأكّد أن الحساب الثقيل لا يجري في حلقة بايثون. هذا وحده غالبًا أكبر تسريع ستحصل عليه.
٢. قِس قبل أن تحسّن
استخدم time.perf_counter() حول الجزء الذي تشكّ فيه، وشغّله أكثر من مرة لأن التشغيل الأول يتضمّن التحميل والإحماء. على بطاقة الرسوميات، استدعِ torch.cuda.synchronize() قبل قراءة الوقت، لأن العمليات هناك غير متزامنة.
torch.cuda.synchronize(). العمليات تُرسل للبطاقة وتعود فورًا قبل أن تنتهي، فتقيس وقت الإرسال لا وقت الحساب، وتظن أن تعديلك سرّع الكود مئة مرة.
torch.compile. سجّل الوقت لكل دورة قبل كل تغيير وبعده.
جرّب بلا كود
خرافة مقابل حقيقة
| الخرافة | الحقيقة |
|---|---|
| دقة أقل تعني دائمًا سرعة أكبر. | على المعالج في الدرس كان bfloat16 أبطأ بأكثر من الضعف، لأن المعالجات العادية بلا وحدات حساب سريعة لـ16 بت. |
| في الدقة المختلطة تُخزَّن الأوزان بـ16 بت. | الأوزان الأصلية تبقى float32، والحساب فقط يجري بدقة أقل. تحديث صغير مثل 0.0001 يضيع إن جُمع على وزن بـ float16. |
| torch.compile يسرّع من أول دفعة. | التشغيل الأول أبطأ لأنه يحلّل النموذج ويولّد الكود. التسريع يظهر في التدريب الطويل، وقد لا يستحق في تدريب يستغرق دقائق. |
جرّب بنفسك
أضفت torch.compile فصار التدريب أبطأ في الدقيقة الأولى. ما التفسير؟
torch.compile يحلّل النموذج ويولّد كودًا في أول استدعاء، وهذا يأخذ وقتًا. القياس الصحيح يتجاهل الدفعات الأولى. في تدريب دقائق معدودة قد لا يستحق ذلك أصلًا.
أي نوع أنسب لتدريب بدقة مختلطة إن كانت بطاقتك تدعم الاثنين، ولماذا؟
مشكلة التدريب بـ16 بت هي المدى لا الدقة: الميول والتنشيطات قد تتجاوز 65504 أو تصغر لصفر. bfloat16 يحلّ هذا مباشرة، ولهذا صار افتراضيًا في تدريب النماذج الكبيرة على البطاقات التي تدعمه.
بطاقات مراجعة سريعة
torch.compileتحقق من فهمك
لماذا لا تُحفظ الأوزان الأصلية بـfloat16 في الدقة المختلطة، بل تبقى float32؟
لأن تحديث الوزن غالبًا رقم صغير جدًّا مقارنة بالوزن نفسه، مثل إضافة 0.0001 إلى 1.0. في float16 هذه الإضافة تُقرَّب إلى صفر كما رأيت مع 1000 + 0.3، فيتوقّف التعلّم بصمت. حفظ الأوزان بـfloat32 يسمح بتراكم التحديثات الصغيرة، والحساب الثقيل وحده يجري بـ16 بت.
لماذا صار 70000 لانهاية في float16 بينما 1000 بقي رقمًا عاديًا؟
لأن أكبر قيمة يمثّلها float16 هي 65504، فأي شيء أكبر يفيض إلى لانهاية. لهذا يحتاج float16 إلى GradScaler بينما bfloat16 لا يحتاجه.
مصادر هذا الدرس
مرتّبة بالنوع. ابدأ بالتوثيق الرسمي إذا تبي المرجع الدقيق، وبالمقال أو الفيديو إذا تبي الشرح أولًا.
خلّصت الدرس؟
سجّل الدخول ليُحفظ تقدّمك.