TRL documentation
التدريب لما يتجاوز مليون رمز
التدريب لما يتجاوز مليون رمز
يمكن للوكيل العمل على مهمة لساعات: قراءة الملفات، وتنفيذ الأوامر، وحمل كل جولة من جلسات الحوار إلى الأمام. تصل هذه الجلسة المتراكمة إلى مئات الآلاف من الرموز، وهو السبب الذي يجعل النماذج الرائدة تُعلن الآن عن نوافذ سياق بمليون رمز.
إن جعل النموذج متمكناً من التعامل مع هذا القدر من السياق يعني تدريبه على تسلسلات بهذا الطول، وهنا تكمن الصعوبة: تسلسل بمليون رمز لا يتسع على وحدة معالجة رسومية واحدة، ولا حتى على ثماني وحدات، دون مساعدة.
يُدرّب المثال في هذا الدليل على تسلسل واحد فقط من هذا النوع في كل خطوة، على عقدة واحدة مكوّنة من 8 وحدات معالجة رسومية.
شغّلها
تحتاج إلى عقدة واحدة مزودة بثمانية معالجات H100 (أو أفضل)، وtransformers>=5.17.0: حيث يستخدم المثال خاصية offload ضمن نقطة التفتيش للتدرجات، والتي أُضيفت في تلك الإصدار.
torchrun --nproc_per_node 8 examples/sft_qwen3_8b_1m_context/sft_qwen3_8b_1m_context.py
يقوم السكربت بضبط نموذج Qwen3-8B على كتب من PG-19، حيث تُدمج الكتب من النهاية إلى البداية حتى يبلغ طول كل كتاب حوالي مليون رمز. وبعد نحو عشر دقائق من التحميل وتقسيم الرموز، يصل إلى الخطوة الأولى:
{'loss': 4.311, 'grad_norm': 29.25, 'num_tokens': 1049000.0, 'epoch': 0.25}
8%|▊ | 1/12 [06:20<1:09:41, 380.10s/it]ثلاثة أرقام تستحق القراءة:
num_tokensيساوي 1.049e6. أي أنها تسلسل واحد، وليس دفعة من تسلسلات قصيرة. كل رمز يهتم بكل رمز سابق له، على مدى المليون رمز بأكملها.- تبدأ الخسارة عند 4.3، وهي قيمة بداية طبيعية. أما التشغيل الذي يكون مُعدًّا بشكل خاطئ بشكل طفيف للسياق الطويل فيبدأ عند 10 تقريبًا، وسنعود لاحقًا لشرح السبب.
- 380 ثانية لكل خطوة. الخطوة الواحدة تعني تسلسلًا واحدًا، أي ما يزيد قليلاً عن ست دقائق لكل تسلسل.
بشكلٍ ساذج، تتطلب هذه التسلسل 288 غيغابايت لكل وحدة معالجة رسومية. والجزء المتبقي من هذا الدليل هو قصة كيف تنخفض هذه القيمة إلى 56.
ما الذي حدث للتو
كل ما يلي يُبنى على إعداد نعرفه مسبقًا: وحدة معالجة رسومية واحدة، وألفا رمز لكل تسلسل. تبدأ المشكلات بالظهور مع نمو طول التسلسل، وتظهر وفق ترتيب ثابت.
الخسارة هي أول شيء ينكسر
خذ نموذج Qwen3-4B على بطاقة واحدة بسعة 80 غيغابايت وازدِ طول التسلسل. أول ما يستنفد الذاكرة ليس النموذج، بل دالة الخسارة. افحص ملف الذاكرة.
تقع الذروة بين المرورين الأمامي والخلفي، وهو المكان الذي تُحسب فيه الخسارة. لفهم سبب وجود الذروة هناك، نحتاج إلى النظر إلى ما تفعله المراحل الأخيرة من فك الترميز وكيف تُبنى الخسارة منها.
تُخرج الطبقة الأخيرة من المُفكِّك حالةً خفيةً بشكل, حيث هو طول التسلسل و هو حجم الحالة الخفية. ثم تُضرب هذه الحالة الخفية في أوزان رأس نمذجة اللغة لإنتاج مصفوفة logits بشكل, حيث هو حجم المفردات. هذه المصفوفة ضخمة، لأن التسلسل طويل والمفردات كبيرة (تتجاوز مئة ألف عنصر). ثم يُحسب الخسارة باستخدام دالة الانتروبيا المتقاطعة من تلك المصفوفة والوسوم المستهدفة، وماديّتها هي بالضبط ما يُشكّل الذروة.
هل يمكننا حساب الخسارة دون إنشاء مصفوفة اللوجيتس كاملة؟ نعم، يمكننا المتابعة على دفعات.
مصفوفة اللوجيتس الضخمة: قسّمها
بدلاً من حساب القيم اللوجستية (logits) للتسلسل بأكمله دفعة واحدة، نأخذ بضع مئات من الصفوف في كل مرة، ونحسب الخسارة (loss) لتلك الصفوف، ثم نجمع الأجزاء. لا يتم الاحتفاظ بمصفوفة القيم اللوجستية بأكملها في الذاكرة في أي وقت. الصفوف هي البُعد الذي يمكن تقسيمه بشكل نظيف: فالخسارة هي مجموع على الصفوف، والـ softmax داخلها يعمل على طول مفردات صف واحد، لذا فإن مجموعة من الصفوف تحمل كل ما تحتاجه خسارتها الخاصة.
تتحول عملية الضرب الكبيرة الواحدة إلى عدة عمليات ضرب أصغر، ويكون توفير الذاكرة كبيرًا بما يكفي بحيث لم تعد الخسارة هي الذروة مرة أخرى. يستخدم TRL 256 صفًا لكل شريحة، مما يحافظ على حجم كل عملية ضرب كبيرًا بما يكفي لاستخدام وحدة معالجة الرسوميات (GPU) بشكل جيد.
يبدو الملف الشخصي الجديد على النحو التالي.
لقد اختفى الذروة.
كيف يتم تفعيل ذلك في TRL؟ لا حاجة لأي إجراء: الخسارة المقسّمة هي الإعداد الافتراضي. إذا كنت ترغب في استخدام الخسارة البسيطة، فاضبط loss_type="nll" في SFTConfig.
إلى أي مدى يمكننا دفع طول التسلسل؟ من حيث الذاكرة، هذا التغيير وحده ينقلنا من 32 ألف رمز إلى 160 ألف رمز.
لم يرَ النموذج الموقع 100,000 من قبل: أعد مقياس المواقع باستخدام YaRN
يمكننا الآن استيعاب 160 ألف رمز في الذاكرة. لكن هل يستطيع النموذج فعليًا قراءتها؟
تم تدريب Qwen3-4B على تسلسلات من 40,960 رمزًا. وهذا ما ينص عليه ملف الإعداد:
>>> from transformers import AutoConfig
>>> AutoConfig.from_pretrained("Qwen/Qwen3-4B").max_position_embeddings
40960كل موضع يتجاوز ذلك هو موضع لم يواجهه النموذج من قبل. لا يحدث أي انهيار، لكن التدريب يستمر على رموز لا يستطيع النموذج تحديد موضعها. فيما يلي قيمة الخسارة لكل رمز في تسلسل يتكون من 160 ألف رمز:
لا ينهار النموذج عند 40,960. بل يستمر لفترة، ولا يبدأ في فقدان الخيط إلا بعد 70 ألفًا تقريبًا. ومن هناك يتصاعد بشكل ثابت، لينتهي فوق إنتروبيا أحاديّات الكلمات للنص نفسه (6.45): أي أسوأ من نموذج يتجاهل السياق بالكامل ويتنبأ من تواترات الرموز وحدها.
يأتي كل رمز مع موقعه، وقد تعلّم النموذج ما تعنيه المواقع بين 0 و40,960. اسأله عن الموقع 100,000 ولن يجد ما يستند إليه.
الطريق للخروج هو ألّا نمنحه موضعًا لا يعرفه. نعيد التحجيم: يصبح الموضع 100,000 هو الموضع 25,000، ويعود كل موضع في التسلسل إلى داخل النطاق الذي يعرفه النموذج. لا تتغير الرموز، بل يتغير فقط الموضع الممنوح لكل منها. هذا ما يفعله YaRN، والعامل ببساطة هو مدى البعد الذي نتجه نحوه: 40,960 تم تدريبها، و163,840 مطلوبة، أي 4.
كيف يتم تفعيل ذلك في TRL؟ مرّر إعداد RoPE عند تحميل النموذج:
training_args = SFTConfig(
...,
model_init_kwargs={
"rope_parameters": {
"rope_type": "yarn",
"rope_theta": 1_000_000, # has to match what the model ships with
"factor": 4.0,
"original_max_position_embeddings": 40960,
},
},
)نفس القياس، مع تشغيل إعادة التوسيع في الأعلى:
تبقى الجولة المُعاد قياس مقياسها مستقرة تمامًا حتى 160 ألفًا. خلال آخر 20 ألف رمز، يبلغ متوسط الخسارة 2.8، مقابل 7.3 دون إعادة قياس المقياس.
تبقى التفعيلات: قم بتفريغها
مع تقسيم الخسارة وإعادة مقياس المواضع، يكفي جهاز واحد من وحدات معالجة الرسوميات (GPU) للوصول إلى 160 ألف رمز. ثم ينفد المساحة مرة أخرى. ما الذي يملأ البطاقة هذه المرة؟
يُشكّل النموذج ومُحسِّنُه معًا 22 غيغابايتًا ثابتة، بغضّ النظر عن طول التسلسل. فوق ذلك، يمثل كل شريط أحمر تفعيلات محفوظة لطبقة واحدة، بمقدار 0.47 غيغابايت لكل منها. تتراكم هذه التفعيلات أثناء كتابة العملية الأمامية لها، وتبقى هناك، ثم تُزال واحدة تلو الأخرى أثناء استهلاكها في العملية العكسية. أما الجزء الرمادي في الأعلى فيُمثّل كل ما يُعاد حسابه ثم يُتخلّى عنه خلال أجزاء من الثانية.
يُقلّل TRL هذه القيم مسبقًا، وهي مفعّلة افتراضيًا. يحتفظ التفتيش التدرّجي (Gradient checkpointing) فقط بما يدخل في كل طبقة، ويتخلّى عن كل ما تحسبه الطبقة داخليًا: درجات الانتباه، والقيم الوسيطة الواسعة في شبكة MLP، والمعايرات. عندما تحتاج عملية الرجوع (backward pass) إلى هذه القيم، تُشغّل الطبقة مرة ثانية لاستعادتها. ما يبقى هو متجه واحد محفوظ لكل طبقة، بحجم sequence x hidden لكل منها، وعند أطوال التسلسل الطويلة لا يزال هذا أكبر عنصر على البطاقة.
الملاحظة المفيدة هي أن تلك المصفوفات (tensors) غير مطلوبة على الإطلاق أثناء المرور الأمامي. فهي تُكتب، ثم تُترك دون لمس، وتُقرأ في وقت لاحق بكثير. لذا لا يجب أن تبقى على وحدة معالجة الرسوميات (GPU) في تلك الأثناء: يمكن نقلها إلى ذاكرة المعالج المركزي (CPU) وإعادتها عندما يصل المرور العكسي إليها.
training_args = SFTConfig(..., gradient_checkpointing_kwargs={"offload": True})
الخطوة نفسها، والطول نفسه: بدلاً من تراكم 38 نطاقاً، لا يوجد سوى 4 نطاقات مقيمة في أي وقت، وينخفض الذروة من 59.9 غيغابايت إلى 48.8 غيغابايت. وهذا يتيح زيادة طول التسلسل:
تحت 48 ألفًا، يكون كلاهما متطابقًا، لأنّ الطول لا يحتوي على ما يُحرَّك تقريبًا. بعد ذلك، يبقى التشغيل المُحمَّل أقل، حيث أنّ التسلسل الذي كان يتوقف عند 160 ألفًا يصل الآن إلى 256 ألفًا على نفس البطاقة.
هذه الطريقة ليست مجانية. كل تفعيل محفوظ يعبر إلى المضيف ثم يعود إليه، ويجب أن يتسع هذا المرور بين عمليات الحساب المجاورة له.
لا تكفي وحدة معالجة رسومية واحدة: قسّم التسلسل على وحدات معالجة رسومية متعددة
خسارة مقسّمة، وتفعيلات مُحمّلة: بطاقة واحدة الآن تُدرّب على 256 ألف رمز. كيف نصل إلى مليون؟ لمعرفة ما يعترض الطريق، انظر إلى داخل طبقة واحدة أثناء المرور العكسي (القمم الرمادية في الملف أعلاه).
تعني نقطة التدرّج أن الأمامي لا يحتفظ تقريبًا بشيء: كل طبقة تخزّن مدخلاتها وتُلقي بالباقي. تُعاد حساب الطبقة لاحقًا، عندما يصل الخلفي إليها، وهذا هو اللحظة التي يوجد فيها كل شيء في آنٍ واحد. في MLP، سطر واحد من النموذج،
down_proj(act_fn(gate_proj(x)) * up_proj(x))
تُبنى أربعة متجهات (تِنسورات) على العرض الوسيط الكامل، وتُبقي النسخة القياسية جميعها الأربعة حتى يتجاوز التدرج العكسي (backward) هذه المتجهات. عند مليون رمز، في نموذج يوسّع طبقة الشبكة العصبية متعددة الطبقات (MLP) كل رمز من 2560 إلى 9728:
gate_proj(x) 19 GB = 1,048,576 x 9728 x 2 bytes
act_fn(gate_proj(x)) 19 GB
up_proj(x) 19 GB
act_fn(gate_proj(x)) * up_proj(x) 19 GB
total 76 GB76 غيغابايت، داخل طبقة واحدة، على بطاقة ذاكرتها 80 غيغابايت وقد منحت بالفعل 30 غيغابايت منها للنموذج ومُحسِّناته. في هذه المرحلة، لا تكمن الإجابة في تخزين التسلسل بطريقة أذكى. بل تكمن في منح كل وحدة معالجة رسومية حصة أقل منه.
امنح كل وحدة معالجة رسومية (GPU) شريحة واحدة من التسلسل. مع وجود أربع وحدات، تحتفظ كل وحدة معالجة رسومية بـ 262,144 رمزًا بدلاً من 1,048,576. تنكمش المصفوفات الأربع الأربعة مع ذلك: 19 جيجابايت بدلاً من 76، والتي إلى جانب 30 جيجابايت الخاصة بالنموذج تتسع على البطاقة.
الاعتراض الواضح هو الانتباه. يجب على كل رمز أن ينتبه إلى كل رمز سابق له، ولا تملك أي وحدة معالجة رسومية الرموز السابقة. لذا تتبادل وحدات المعالجة الرسومية ما ينقصها، وما يخرج هو بالضبط ما كانت ستحسبه وحدة معالجة رسومية واحدة ضخمة.
هناك طريقتان لتنفيذ ذلك التبادل: توازٍ السياق (CP) وتوازٍ تسلسل Ulysses (SP).
| CP | SP | |
|---|---|---|
| FSDP2 | ✅ | ❌ |
| DeepSpeed | ❌ | ✅ |
| الإعداد | cp_size | sp_size |
| ما يتم تقسيمه | الرموز | رؤوس الانتباه |
| مدى قابليته للتوسع | أي عدد من وحدات معالجة الرسوميات | حتى عدد رؤوس KV، وهو 8 هنا |
| الانتباه | SDPA، سببي فقط | SDPA أو FlashAttention |
| يتطلب | accelerate 1.11 | accelerate 1.12، DeepSpeed 0.18.1 |
في FSDP2، استخدم CP. يحتفظ كل معالج رسومي بشقته الخاصة، وتصل الشقوق الأخرى إليه بالتناوب، بحيث يرى كل رمز في النهاية كل الرموز السابقة له.
from accelerate import ParallelismConfig
training_args = SFTConfig(..., fsdp=True, parallelism_config=ParallelismConfig(cp_size=4))على DeepSpeed، استخدم SP. بدلاً من نقل الشرائح، يعيد ترتيب الدفعة (batch) مباشرة قبل الانتباه (attention) بحيث تحتفظ كل وحدة معالجة رسومية (GPU) بكل الرموز (tokens) لكن ربع رؤوس الانتباه فقط، ثم يعيد الترتيب بعد ذلك.
training_args = SFTConfig(
..., deepspeed="examples/deepspeed_configs/zero3.json", parallelism_config=ParallelismConfig(sp_size=4, sp_backend="deepspeed")
)لا تتقاطع الخياريان اليوم: يتطلب cp_size استخدام FSDP2 بينما يعمل sp_size فقط مع DeepSpeed. اختر الخلفية (backend) وستتبع الطريقة ذلك. يتبع بقية هذا القسم مسار FSDP2، وهو ما يستخدمه المثال في أعلى هذا الدليل: فوحدات المعالجة الرسومية فيه ليست عمّالاً مستقلين على دفعات منفصلة، بل هي مجموعة واحدة تتشارك تسلسلاً واحداً.
يحدث تغييران بمجرد تفعيله:
- يجب ملء التسلسلات إلى مضاعف لـ
cp_size * 2، لذا استخدمpad_to_multiple_of=8(في SFTConfig) لأربع وحدات معالجة رسومية. - تستبعد متطلّبات causal-SDPA استخدام التعبئة (packing)، التي تعتمد على قناع قطري كتلي (block-diagonal mask) لمنع الوثائق من قراءة بعضها البعض، كما يثير TRL خطأً إذا طلبتَ كلا الأمرين معًا. كما تستبعد النماذج التي تستخدم طبقاتها انتباهًا نافذيًا منزلقًا (sliding-window) أو انتباهًا مقسّمًا (chunked attention)، وهو ما يرفضه accelerate: OpenAI GPT-OSS، وGemma 3 و4، وQwen3.5 وما بعدها. أما Qwen3 وQwen3-MoE فيستخدمان انتباهًا كاملًا (full attention) في جميع الطبقات، ولهذا هما النموذجان المعنيّان هنا.
إن تمرير الشرائح بين الوحدات يكلف أقل مما قد تتوقع. عند استخدام 131 ألف رمز على عقدة واحدة، يستغرق كل خطوة 34.6 ثانية عبر بطاقتين، و17.8 ثانية عبر أربع بطاقات، و9.5 ثانية عبر ثمانية. كل مضاعفة لمجموع البطاقات تُقلل زمن الخطوة إلى النصف تقريبًا، والانتقال من بطاقتين إلى ثمانية يستعيد 3.7 أضعاف من أصل 4 أضعاف ممكنة.
وهذا هو آخر الأربعة محاور:
تتوقف بطاقة واحدة فقط بعد 256k بقليل. أما أربع بطاقات فتستوعب المليون كاملًا! لتستقر عند 63.6 غيغابايت مع مساحة كافية. تتسع التسلسل الذي بدأ به هذا الدليل.
اضبط
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True. عند مليون رمز، يحتاج التشغيل المذكور أعلاه إلى 63.6 غيغابايت على بطاقة سعة 80 غيغابايت، ولا يزال يفشل دون ضبطه: فالتنسيقات (tensors) التي يطلبها كبيرة ومتصلة، ولا يستطيع مُخصِّص الذاكرة إيجاد مساحة لها بين الكتل التي يحتفظ بها بالفعل.
قراءات إضافية
- التوازي السياقي، دليل accelerate الخاص بـ
cp_sizeوشبكة الأجهزة التي يبنيها. - انتباه أوليسيس وانتباه الحلقة، حول كيفية اختلاف التبادلين وما يكلفه كل منهما من اتصالات.
- YaRN، وهو إعادة تحجيم الموضع المستخدمة في قسم RoPE.