TRL documentation

التدريب لما يتجاوز مليون رمز

You are viewing main version, which requires installation from source. If you'd like regular pip install, checkout the latest stable version (v1.17.1).
Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

التدريب لما يتجاوز مليون رمز

يمكن للوكيل العمل على مهمة لساعات: قراءة الملفات، وتنفيذ الأوامر، وحمل كل جولة من جلسات الحوار إلى الأمام. تصل هذه الجلسة المتراكمة إلى مئات الآلاف من الرموز، وهو السبب الذي يجعل النماذج الرائدة تُعلن الآن عن نوافذ سياق بمليون رمز.

إن جعل النموذج متمكناً من التعامل مع هذا القدر من السياق يعني تدريبه على تسلسلات بهذا الطول، وهنا تكمن الصعوبة: تسلسل بمليون رمز لا يتسع على وحدة معالجة رسومية واحدة، ولا حتى على ثماني وحدات، دون مساعدة.

يُدرّب المثال في هذا الدليل على تسلسل واحد فقط من هذا النوع في كل خطوة، على عقدة واحدة مكوّنة من 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 غيغابايت وازدِ طول التسلسل. أول ما يستنفد الذاكرة ليس النموذج، بل دالة الخسارة. افحص ملف الذاكرة.

Memory through one training step, with a large block of memory between the forward and backward passes

تقع الذروة بين المرورين الأمامي والخلفي، وهو المكان الذي تُحسب فيه الخسارة. لفهم سبب وجود الذروة هناك، نحتاج إلى النظر إلى ما تفعله المراحل الأخيرة من فك الترميز وكيف تُبنى الخسارة منها.

Hidden states times the lm_head weights giving a logits matrix of sequence by vocabulary, then the per-token loss

تُخرج الطبقة الأخيرة من المُفكِّك حالةً خفيةً بشكلL×H L \times H , حيثL L هو طول التسلسل وH H هو حجم الحالة الخفية. ثم تُضرب هذه الحالة الخفية في أوزان رأس نمذجة اللغة لإنتاج مصفوفة logits بشكلL×V L \times V , حيثV V هو حجم المفردات. هذه المصفوفة ضخمة، لأن التسلسل طويل والمفردات كبيرة (تتجاوز مئة ألف عنصر). ثم يُحسب الخسارة باستخدام دالة الانتروبيا المتقاطعة من تلك المصفوفة والوسوم المستهدفة، وماديّتها هي بالضبط ما يُشكّل الذروة.

هل يمكننا حساب الخسارة دون إنشاء مصفوفة اللوجيتس كاملة؟ نعم، يمكننا المتابعة على دفعات.

مصفوفة اللوجيتس الضخمة: قسّمها

بدلاً من حساب القيم اللوجستية (logits) للتسلسل بأكمله دفعة واحدة، نأخذ بضع مئات من الصفوف في كل مرة، ونحسب الخسارة (loss) لتلك الصفوف، ثم نجمع الأجزاء. لا يتم الاحتفاظ بمصفوفة القيم اللوجستية بأكملها في الذاكرة في أي وقت. الصفوف هي البُعد الذي يمكن تقسيمه بشكل نظيف: فالخسارة هي مجموع على الصفوف، والـ softmax داخلها يعمل على طول مفردات صف واحد، لذا فإن مجموعة من الصفوف تحمل كل ما تحتاجه خسارتها الخاصة.

The same computation one chunk of rows at a time, each chunk freed before the next, summing into the same loss

تتحول عملية الضرب الكبيرة الواحدة إلى عدة عمليات ضرب أصغر، ويكون توفير الذاكرة كبيرًا بما يكفي بحيث لم تعد الخسارة هي الذروة مرة أخرى. يستخدم TRL 256 صفًا لكل شريحة، مما يحافظ على حجم كل عملية ضرب كبيرًا بما يكفي لاستخدام وحدة معالجة الرسوميات (GPU) بشكل جيد.

يبدو الملف الشخصي الجديد على النحو التالي.

The same step with the chunked loss, where the memory stays flat throughout

لقد اختفى الذروة.

كيف يتم تفعيل ذلك في TRL؟ لا حاجة لأي إجراء: الخسارة المقسّمة هي الإعداد الافتراضي. إذا كنت ترغب في استخدام الخسارة البسيطة، فاضبط loss_type="nll" في SFTConfig.

إلى أي مدى يمكننا دفع طول التسلسل؟ من حيث الذاكرة، هذا التغيير وحده ينقلنا من 32 ألف رمز إلى 160 ألف رمز.

Peak memory against sequence length, the plain loss running out of memory far earlier than the chunked loss

لم يرَ النموذج الموقع 100,000 من قبل: أعد مقياس المواقع باستخدام YaRN

يمكننا الآن استيعاب 160 ألف رمز في الذاكرة. لكن هل يستطيع النموذج فعليًا قراءتها؟

تم تدريب Qwen3-4B على تسلسلات من 40,960 رمزًا. وهذا ما ينص عليه ملف الإعداد:

>>> from transformers import AutoConfig
>>> AutoConfig.from_pretrained("Qwen/Qwen3-4B").max_position_embeddings
40960

كل موضع يتجاوز ذلك هو موضع لم يواجهه النموذج من قبل. لا يحدث أي انهيار، لكن التدريب يستمر على رموز لا يستطيع النموذج تحديد موضعها. فيما يلي قيمة الخسارة لكل رمز في تسلسل يتكون من 160 ألف رمز:

Per-token loss along a 160k-token sequence, flat for most of it then climbing steeply near the end

لا ينهار النموذج عند 40,960. بل يستمر لفترة، ولا يبدأ في فقدان الخيط إلا بعد 70 ألفًا تقريبًا. ومن هناك يتصاعد بشكل ثابت، لينتهي فوق إنتروبيا أحاديّات الكلمات للنص نفسه (6.45): أي أسوأ من نموذج يتجاهل السياق بالكامل ويتنبأ من تواترات الرموز وحدها.

The trained range of positions, the much longer range we ask for, and the same range rescaled to fit inside the trained one

يأتي كل رمز مع موقعه، وقد تعلّم النموذج ما تعنيه المواقع بين 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,
        },
    },
)

نفس القياس، مع تشغيل إعادة التوسيع في الأعلى:

The same per-token loss with a second run using rescaled positions, which stays flat where the first one climbs

تبقى الجولة المُعاد قياس مقياسها مستقرة تمامًا حتى 160 ألفًا. خلال آخر 20 ألف رمز، يبلغ متوسط الخسارة 2.8، مقابل 7.3 دون إعادة قياس المقياس.

تبقى التفعيلات: قم بتفريغها

مع تقسيم الخسارة وإعادة مقياس المواضع، يكفي جهاز واحد من وحدات معالجة الرسوميات (GPU) للوصول إلى 160 ألف رمز. ثم ينفد المساحة مرة أخرى. ما الذي يملأ البطاقة هذه المرة؟

Memory through one step at 96k tokens, with one red band per layer stacking up through the forward and draining through the backward

يُشكّل النموذج ومُحسِّنُه معًا 22 غيغابايتًا ثابتة، بغضّ النظر عن طول التسلسل. فوق ذلك، يمثل كل شريط أحمر تفعيلات محفوظة لطبقة واحدة، بمقدار 0.47 غيغابايت لكل منها. تتراكم هذه التفعيلات أثناء كتابة العملية الأمامية لها، وتبقى هناك، ثم تُزال واحدة تلو الأخرى أثناء استهلاكها في العملية العكسية. أما الجزء الرمادي في الأعلى فيُمثّل كل ما يُعاد حسابه ثم يُتخلّى عنه خلال أجزاء من الثانية.

يُقلّل TRL هذه القيم مسبقًا، وهي مفعّلة افتراضيًا. يحتفظ التفتيش التدرّجي (Gradient checkpointing) فقط بما يدخل في كل طبقة، ويتخلّى عن كل ما تحسبه الطبقة داخليًا: درجات الانتباه، والقيم الوسيطة الواسعة في شبكة MLP، والمعايرات. عندما تحتاج عملية الرجوع (backward pass) إلى هذه القيم، تُشغّل الطبقة مرة ثانية لاستعادتها. ما يبقى هو متجه واحد محفوظ لكل طبقة، بحجم sequence x hidden لكل منها، وعند أطوال التسلسل الطويلة لا يزال هذا أكبر عنصر على البطاقة.

الملاحظة المفيدة هي أن تلك المصفوفات (tensors) غير مطلوبة على الإطلاق أثناء المرور الأمامي. فهي تُكتب، ثم تُترك دون لمس، وتُقرأ في وقت لاحق بكثير. لذا لا يجب أن تبقى على وحدة معالجة الرسوميات (GPU) في تلك الأثناء: يمكن نقلها إلى ذاكرة المعالج المركزي (CPU) وإعادتها عندما يصل المرور العكسي إليها.

training_args = SFTConfig(..., gradient_checkpointing_kwargs={"offload": True})
The same step with the activations offloaded, where almost no red bands remain resident

الخطوة نفسها، والطول نفسه: بدلاً من تراكم 38 نطاقاً، لا يوجد سوى 4 نطاقات مقيمة في أي وقت، وينخفض الذروة من 59.9 غيغابايت إلى 48.8 غيغابايت. وهذا يتيح زيادة طول التسلسل:

Peak memory against sequence length with and without activation offload, the offload line running lower and reaching further

تحت 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 GB

76 غيغابايت، داخل طبقة واحدة، على بطاقة ذاكرتها 80 غيغابايت وقد منحت بالفعل 30 غيغابايت منها للنموذج ومُحسِّناته. في هذه المرحلة، لا تكمن الإجابة في تخزين التسلسل بطريقة أذكى. بل تكمن في منح كل وحدة معالجة رسومية حصة أقل منه.

A sequence of tokens overflowing a single GPU and running out of memory, then the same sequence cut into four slices that each fit on one GPU

امنح كل وحدة معالجة رسومية (GPU) شريحة واحدة من التسلسل. مع وجود أربع وحدات، تحتفظ كل وحدة معالجة رسومية بـ 262,144 رمزًا بدلاً من 1,048,576. تنكمش المصفوفات الأربع الأربعة مع ذلك: 19 جيجابايت بدلاً من 76، والتي إلى جانب 30 جيجابايت الخاصة بالنموذج تتسع على البطاقة.

الاعتراض الواضح هو الانتباه. يجب على كل رمز أن ينتبه إلى كل رمز سابق له، ولا تملك أي وحدة معالجة رسومية الرموز السابقة. لذا تتبادل وحدات المعالجة الرسومية ما ينقصها، وما يخرج هو بالضبط ما كانت ستحسبه وحدة معالجة رسومية واحدة ضخمة.

هناك طريقتان لتنفيذ ذلك التبادل: توازٍ السياق (CP) وتوازٍ تسلسل Ulysses (SP).

CPSP
FSDP2✅❌
DeepSpeed❌✅
الإعدادcp_sizesp_size
ما يتم تقسيمهالرموزرؤوس الانتباه
مدى قابليته للتوسعأي عدد من وحدات معالجة الرسومياتحتى عدد رؤوس KV، وهو 8 هنا
الانتباهSDPA، سببي فقطSDPA أو FlashAttention
يتطلبaccelerate 1.11accelerate 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، وهو ما يستخدمه المثال في أعلى هذا الدليل: فوحدات المعالجة الرسومية فيه ليست عمّالاً مستقلين على دفعات منفصلة، بل هي مجموعة واحدة تتشارك تسلسلاً واحداً.

يحدث تغييران بمجرد تفعيله:

  1. يجب ملء التسلسلات إلى مضاعف لـ cp_size * 2، لذا استخدم pad_to_multiple_of=8 (في SFTConfig) لأربع وحدات معالجة رسومية.
  2. تستبعد متطلّبات 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 أضعاف ممكنة.

وهذا هو آخر الأربعة محاور:

Peak memory against sequence length on one GPU and on four with context parallelism, the four-GPU line reaching a million tokens where the single GPU stops just past 256k

تتوقف بطاقة واحدة فقط بعد 256k بقليل. أما أربع بطاقات فتستوعب المليون كاملًا! لتستقر عند 63.6 غيغابايت مع مساحة كافية. تتسع التسلسل الذي بدأ به هذا الدليل.

اضبط PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True. عند مليون رمز، يحتاج التشغيل المذكور أعلاه إلى 63.6 غيغابايت على بطاقة سعة 80 غيغابايت، ولا يزال يفشل دون ضبطه: فالتنسيقات (tensors) التي يطلبها كبيرة ومتصلة، ولا يستطيع مُخصِّص الذاكرة إيجاد مساحة لها بين الكتل التي يحتفظ بها بالفعل.

قراءات إضافية

Update on GitHub