Transformers documentation

تحسين نماذج اللغة الكبيرة من حيث السرعة والذاكرة

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

and get access to the augmented documentation experience

to get started

تحسين نماذج اللغة الكبيرة من حيث السرعة والذاكرة

تتقدم نماذج اللغة الكبيرة (LLMs) مثل GPT3/4 وFalcon وLlama بسرعة في قدرتها على التعامل مع المهام المرتبطة بالبشر، مما يجعلها أدوات أساسية في الصناعات المعاصرة القائمة على المعرفة. ومع ذلك، لا يزال نشر هذه النماذج في مهام العالم الحقيقي تحدياً:

  • لتحقيق قدرات فهم ونصّ توليد تقترب من القدرات البشرية، تتطلب النماذج اللغوية الكبيرة حالياً أن تتألف من مليارات المعاملات (انظر Kaplan et al، Wei et. al). وهذا يؤدي بالتالي إلى تضخيم متطلبات الذاكرة للاستدلال.
  • في العديد من المهام الواقعية، يجب تزويد نماذج اللغة الكبيرة بمعلومات سياقية شاملة. وهذا يستلزم قدرة النموذج على إدارة تسلسلات إدخال طويلة جداً أثناء الاستدلال.

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

في هذا الدليل، سنستعرض التقنيات الفعّالة لنشر نماذج اللغة الكبيرة (LLM) بكفاءة:

  1. دقة أقل: أظهرت الأبحاث أن العمل بدقة رقمية مخفّضة، أي 8-bit و4-bit، يمكن أن يحقق مزايا حسابية دون انخفاض ملحوظ في أداء النموذج.

  2. Flash Attention: يُعدّ Flash Attention تنويعًا من خوارزمية الانتباه لا يوفر نهجًا أكثر كفاءة في استخدام الذاكرة فحسب، بل يحقق أيضًا كفاءة أعلى بفضل الاستخدام المُحسَّن لذاكرة وحدة معالجة الرسوميات (GPU).

  3. ابتكارات معمارية: نظرًا إلى أن نماذج اللغة الكبيرة تُنشر دائمًا بالطريقة نفسها أثناء الاستدلال، أي توليد النص التلقائي مع سياق إدخال طويل، فقد طُرحت معماريات نماذج متخصصة تتيح استدلالًا أكثر كفاءة. وأهم التطورات في معماريات النماذج في هذا الصدد هي Alibi وRotary embeddings وMulti-Query Attention (MQA) وGrouped-Query-Attention (GQA).

على مدار هذا الدليل، سنقدّم تحليلاً لتوليد النصوص التلقائي (auto-regressive) من منظور المصفوفات (tensors). سنتعمق في مزايا وعيوب اعتماد دقة أقل، ونقدّم استكشافاً شاملاً لأحدث خوارزميات الانتباه (attention)، ونناقش معماريات نماذج اللغة الكبيرة (LLM) المحسّنة. وفي أثناء ذلك، سننفّذ أمثلة عملية تُبرز كل تحسين من تحسينات الميزات.

1. دقة أقل

يمكن فهم متطلبات الذاكرة الخاصة بنماذج اللغة الكبيرة (LLMs) بشكل أفضل من خلال النظر إلى النموذج على أنه مجموعة من مصفوفات الأوزان والمتجهات، والنصوص المدخلة على أنها تسلسل من المتجهات. في ما يلي، سيُستخدم المصطلح weights للإشارة إلى جميع مصفوفات الأوزان والمتجهات الخاصة بالنموذج.

عند كتابة هذا الدليل، تتألف نماذج اللغة الكبيرة (LLMs) من بضع مليارات من المعاملات على الأقل. ويتكون كل معامل من رقم عشري، مثل 4.5689، والذي يُخزَّن عادةً إما بتنسيق float32 أو bfloat16 أو float16. وهذا يتيح لنا حساب المتطلبات الذاكرة اللازمة لتحميل نموذج اللغة الكبيرة في الذاكرة بسهولة:

تحميل أوزان نموذج يحتوي على X مليار معامل يتطلب تقريباً 4 * X غيغابايت من ذاكرة الفيديو (VRAM) بدقة float32

ومع ذلك، نادراً ما تُدرَّب النماذج اليوم بدقة float32 الكاملة، بل عادةً بدقة bfloat16 أو، بشكل أقل شيوعاً، بدقة float16. لذلك تصبح القاعدة العملية:

تحميل أوزان نموذج يحتوي على X مليار معامل يتطلب تقريباً 2 * X جيجابايت من ذاكرة الفيديو (VRAM) بدقة bfloat16/float16

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

لإعطاء بعض الأمثلة على مقدار ذاكرة الفيديو (VRAM) التي يستهلكها تحميل نموذج تقريباً باستخدام bfloat16:

  • يتطلب GPT3 ذاكرة فيديو (VRAM) بحجم 2 * 175 GB = 350 GB
  • يتطلب Bloom ذاكرة فيديو (VRAM) بحجم 2 * 176 GB = 352 GB
  • يتطلب Llama-2-70b ذاكرة فيديو (VRAM) بحجم 2 * 70 GB = 140 GB
  • يتطلب Falcon-40b ذاكرة فيديو (VRAM) بحجم 2 * 40 GB = 80 GB
  • يتطلب MPT-30b ذاكرة فيديو (VRAM) بحجم 2 * 30 GB = 60 GB
  • يتطلب bigcode/starcoder ذاكرة فيديو (VRAM) بحجم 2 * 15.5 = 31 GB

حتى تاريخ كتابة هذا المستند، أكبر شريحة GPU في السوق هي A100 & H100 التي توفر 80GB من ذاكرة VRAM. تتطلب معظم النماذج المذكورة سابقاً أكثر من 80GB فقط لتحميلها، وبالتالي فهي تتطلب حتماً التوازي على مستوى الموترات و/أو التوازي على مستوى خط الأنابيب.

🤗 Transformers now supports tensor parallelism for supported models having base_tp_plan in their respective config classes. Learn more about Tensor Parallelism here. Furthermore, if you’re interested in writing models in a tensor-parallelism-friendly way, feel free to have a look at the text-generation-inference library.

يُتاح توازي خط الأنابيب البسيط (Naive pipeline parallelism) بشكل افتراضي. ولتحقيق ذلك، ما عليك سوى تحميل النموذج باستخدام device="auto"، حيث سيُوزّع تلقائيًا الطبقات المختلفة على وحدات معالجة الرسومات المتاحة، كما هو موضح هنا. ومع ذلك، تجدر الإشارة إلى أنه على الرغم من فعالية هذا التوازي البسيط، فإنه لا يعالج مشكلة خمول وحدات معالجة الرسومات. ويتطلب ذلك استخدام توازي خط أنابيب متقدم، كما هو موضح هنا.

إذا كان لديك وصول إلى عقدة 8 x 80GB A100، يمكنك تحميل BLOOM على النحو التالي

!pip install transformers accelerate bitsandbytes optimum
from transformers import AutoModelForCausalLM

model = AutoModelForCausalLM.from_pretrained("bigscience/bloom", device_map="auto", pad_token_id=0)

باستخدام device_map="auto"، يتم توزيع طبقات الانتباه بالتساوي على جميع وحدات معالجة الرسوميات المتاحة.

في هذا الدليل، سنستخدم bigcode/octocoder لأنه يمكن تشغيله على شريحة جهاز GPU من نوع A100 بسعة 40 غيغابايت واحدة. لاحظ أن جميع تحسينات الذاكرة والسرعة التي سنطبقها لاحقًا تنطبق بنفس القدر على النماذج التي تتطلب توازياً على مستوى النموذج أو على مستوى الموترات.

بما أن النموذج محمّل بدقة bfloat16، وباستخدام قاعدة الإبهام المذكورة أعلاه، نتوقع أن تكون متطلبات الذاكرة لتشغيل الاستدلال مع bigcode/octocoder حوالي 31 جيجابايت من ذاكرة VRAM. لنجرّب ذلك.

نحمّل النموذج ومُجزّئ الرموز أولاً، ثم نمرر كلاهما إلى كائن pipeline في مكتبة Transformers.

from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
import torch

model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", dtype=torch.bfloat16, device_map="auto", pad_token_id=0)
tokenizer = AutoTokenizer.from_pretrained("bigcode/octocoder")

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)
prompt = "Question: Please write a function in Python that transforms bytes to Giga bytes.\n\nAnswer:"

result = pipe(prompt, max_new_tokens=60)[0]["generated_text"][len(prompt):]
result

المخرجات:

Here is a Python function that transforms bytes to Giga bytes:\n\n```python\ndef bytes_to_giga_bytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nThis function takes a single

رائع، يمكننا الآن استخدام النتيجة مباشرةً لتحويل البايتات إلى غيغابايت.

def bytes_to_giga_bytes(bytes):
  return bytes / 1024 / 1024 / 1024

لنقم باستدعاء torch.accelerator.memory.max_memory_allocated لقياس ذروة تخصيص ذاكرة المعالج التسارعي.

bytes_to_giga_bytes(torch.accelerator.max_memory_allocated())

المخرجات:

29.0260648727417

قريبٌ بما يكفي من حسابنا التقديري! نلاحظ أن الرقم ليس دقيقًا تمامًا، إذ إن التحويل من البايتات إلى الكيلوبايتات يتطلب ضربًا في 1024 وليس 1000. وبالتالي، يمكن فهم صيغة الحساب التقديري أيضًا على أنها حساب «بحد أقصى X GB». يُشار إلى أنه لو حاولنا تشغيل النموذج بدقة float32 الكاملة، لكانت هناك حاجة إلى 64 GB من ذاكرة VRAM.

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

إذا كنت غير متأكد من الصيغة التي تُخزَّن بها أوزان النموذج على Hub، يمكنك دائمًا الاطلاع على إعدادات نقطة التفتيش (checkpoint) تحت "dtype"، على سبيل المثال هنا. يُوصى بإعداد النموذج على نفس نوع الدقة المذكور في الإعدادات عند التحميل باستخدام from_pretrained(..., dtype=...)، باستثناء الحالة التي يكون فيها النوع الأصلي float32، حيث يمكن استخدام كل من float16 أو bfloat16 للاستدلال.

لنعرّف دالة flush(...) لتحرير جميع الذاكرة المخصصة، حتى نتمكن من قياس ذروة الذاكرة المخصصة للوحدة الرسومية بدقة.

del pipe
del model

import gc
import torch

def flush():
  gc.collect()
  torch.accelerator.empty_cache()
  torch.accelerator.reset_peak_memory_stats()

لندعُها الآن للتجربة التالية.

flush()

من مكتبة Accelerate، يمكنك أيضاً استخدام طريقة أداة مستقلة عن الجهاز تُسمى release_memory، والتي تأخذ في الاعتبار مختلف واجهات الأجهزة الخلفية مثل XPU وMLU وNPU وMPS وغيرها.

from accelerate.utils import release_memory
# ...

release_memory(model)

لكن ماذا لو لم يتوفر لديك 32 غيغابايت من ذاكرة VRAM على وحدة معالجة الرسوميات؟ لقد تبيّن أنه يمكن تكميم أوزان النموذج إلى 8 بت أو 4 بت دون فقدان كبير في الأداء (انظر Dettmers et al.). ويمكن تكميم النموذج حتى إلى 3 أو 2 بت مع فقدان مقبول في الأداء كما هو موضح في ورقة GPTQ الحديثة 🤯.

بدون الخوض في تفاصيل كثيرة، تهدف مخططات التكميم إلى تقليل دقة الأوزان مع السعي إلى الحفاظ على دقة نتائج الاستدلال للنموذج قدر الإمكان (أي بقربها من bfloat16 ما أمكن). يُلاحظ أن التكميم يعمل بشكل خاص في توليد النصوص، إذ لا يهمنا سوى اختيار مجموعة الرموز التالية الأكثر احتمالاً ولا نهتم فعلياً بالقيم الدقيقة لتوزيع logit للرمز التالي. كل ما يهم هو أن يبقى توزيع logit للرمز التالي متشابهاً تقريباً، بحيث تعطي عملية argmax أو topk النتائج نفسها.

توجد تقنيات مختلفة للتكميم، لن نتناولها بالتفصيل هنا، ولكن بشكل عام، تعمل جميع تقنيات التكميم على النحو التالي:

    1. كمّ جميع الأوزان إلى الدقة المستهدفة
    1. حمّل الأوزان المُكمّاة، ومرّر تسلسل مدخلات المتجهات بدقة bfloat16
    1. إلغاء التكميم الديناميكي للأوزان إلى bfloat16 لأداء الحسابات مع متجهات الإدخال بدقة bfloat16

باختصار، هذا يعني أن ضرب مصفوفة المدخلات مع XX حيث تكون المدخلات, WW مصفوفة الأوزان، و YY هو المخرج: Y=X∗W Y = X * W

تُغيَّر إلى Y=X∗dequantize(W) Y = X * \text{dequantize}(W)

لكل عملية ضرب مصفوفات. يتم تنفيذ إلغاء التكميم وإعادة التكميم بشكل تسلسلي لجميع مصفوفات الأوزان أثناء مرور المدخلات عبر شبكة الرسم البياني.

لذلك، غالباً ما لا ينخفض زمن الاستدلال عند استخدام الأوزان المُكمّاة، بل يزداد. كفى نظرية، فلنجرّب! ولكي تُكمّم الأوزان باستخدام Transformers، يجب التأكد من تثبيت مكتبة bitsandbytes.

!pip install bitsandbytes

يمكننا بعد ذلك تحميل النماذج بتكميم 8 بت عن طريق إضافة علم load_in_8bit=True إلى from_pretrained ببساطة.

model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", quantization_config=BitsAndBytesConfig(load_in_8bit=True), pad_token_id=0)

الآن، لنشغّل مثالنا مرة أخرى ونقيس استهلاك الذاكرة.

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

result = pipe(prompt, max_new_tokens=60)[0]["generated_text"][len(prompt):]
result

المخرجات:

Here is a Python function that transforms bytes to Giga bytes:\n\n```python\ndef bytes_to_giga_bytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nThis function takes a single

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

bytes_to_giga_bytes(torch.accelerator.max_memory_allocated())

المخرجات:

15.219234466552734

أقل بكثير! انخفضنا إلى ما يزيد قليلاً عن 15 غيغابايت، وبالتالي يمكننا تشغيل هذا النموذج على وحدات معالجة رسومية مخصصة للمستهلكين مثل 4090. نشهد مكسبًا ممتازًا في كفاءة الذاكرة، مع عدم وجود تدهور يُذكر في مخرجات النموذج. ومع ذلك، يمكننا أيضًا ملاحظة تباطؤ طفيف أثناء الاستدلال.

نحذف النماذج ثم نُمسح الذاكرة مرة أخرى.

del model
del pipe
flush()

لننظر إلى مقدار ذروة استهلاك ذاكرة وحدة معالجة الرسوميات (GPU) الذي توفره عملية التكميم إلى 4 بت. يمكن تكميم النموذج إلى 4 بت باستخدام نفس واجهة برمجة التطبيقات (API) السابقة، لكن هذه المرة عن طريق تمرير load_in_4bit=True بدلاً من load_in_8bit=True.

model = AutoModelForCausalLM.from_pretrained("bigcode/octocoder", quantization_config=BitsAndBytesConfig(load_in_4bit=True), pad_token_id=0)

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

result = pipe(prompt, max_new_tokens=60)[0]["generated_text"][len(prompt):]
result

المخرجات:

Here is a Python function that transforms bytes to Giga bytes:\n\n```\ndef bytes_to_gigabytes(bytes):\n    return bytes / 1024 / 1024 / 1024\n```\n\nThis function takes a single argument

نرى تقريبًا نفس النص الناتج كما كان من قبل - حيث ينقص فقط python مباشرة قبل مقتطف الشيفرة. دعونا نرى مقدار الذاكرة المطلوبة.

bytes_to_giga_bytes(torch.accelerator.max_memory_allocated())

المخرجات:

9.543574333190918

فقط 9.5 غيغابايت! هذا ليس كثيرًا حقًا لنموذج يتجاوز 15 مليار معامل.

على الرغم من أننا نشهد تدهوراً ضئيلاً جداً في دقة نموذجنا هنا، إلا أن التكميم بدقة 4 بت قد يؤدي في الممارسة العملية غالباً إلى نتائج مختلفة مقارنةً بالتكميم بدقة 8 بت أو الاستدلال الكامل بدقة bfloat16. ويتوقف الأمر على المستخدم لتجربته.

يُشار أيضًا إلى أن الاستدلال هنا كان أبطأ قليلاً مقارنةً بتكميم 8 بت، وذلك بسبب استخدام طريقة تكميم أكثر عدوانية لتكميم 4 بت، مما يؤدي إلى quantize\text{quantize} و dequantize\text{dequantize} استهلاك وقت أطول أثناء الاستدلال.

del model
del pipe
flush()

بشكل عام، لاحظنا أن تشغيل OctoCoder بدقة 8 بت قلّل الذاكرة المخصصة للرسوميات (VRAM) المطلوبة على وحدة معالجة الرسوميات (GPU) من 32 غيغابايت إلى 15 غيغابايت فقط، وأن تشغيل النموذج بدقة 4 بت قلّل الذاكرة المطلوبة بشكل أكبر إلى ما يزيد قليلاً عن 9 غيغابايت.

تتيح عملية التكميم بدقة 4 بت تشغيل النموذج على وحدات معالجة رسومية مثل RTX3090 وV100 وT4، وهي وحدات في متناول معظم المستخدمين.

لمزيد من المعلومات حول التكميم وللاطلاع على كيفية تكميم النماذج بحيث تتطلب ذاكرة GPU VRAM أقل من 4 بت، ننصح بالاطلاع على تطبيق GPT-QModel.

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

إذا لم يكن ذاكرة وحدة معالجة الرسوميات (GPU) قيدًا في حالتك الاستخدامية، فغالبًا لا حاجة للبحث في التكميم. ومع ذلك، لا تستطيع العديد من وحدات معالجة الرسوميات تشغيل نماذج اللغة الكبيرة (LLMs) دون استخدام طرق التكميم، وفي هذه الحالة تُعدّ مخططات التكميم بدقة 4 بت و8 بت أدوات مفيدة للغاية.

لمزيد من المعلومات التفصيلية حول الاستخدام، نوصي بشدة بالاطلاع على وثائق تكميم Transformers. فيما يلي، سننظر في كيفية تحسين الكفاءة الحسابية وكفاءة الذاكرة من خلال استخدام خوارزميات أفضل ومعمارية نموذج محسّنة.

2. Flash Attention

تشارك نماذج اللغة الكبيرة (LLMs) الأعلى أداءً اليوم، إلى حدٍّ ما، نفس البنية الأساسية التي تتكوّن من طبقات تقدّم أمامي (feed-forward) وطبقات تفعيل (activation) وطبقات تطبيع الطبقة (layer normalization) والأهم من ذلك كله طبقات الانتباه الذاتي (self-attention).

تُعدّ طبقات الانتباه الذاتي (Self-attention) جوهرية في نماذج اللغة الكبيرة (LLMs) لأنها تُمكّن النموذج من فهم العلاقات السياقية بين الرموز المدخلة. ومع ذلك، فإن ذروة استهلاك ذاكرة وحدة معالجة الرسوميات (GPU) لطبقات الانتباه الذاتي تنمو تربيعياً في كلٍّ من التعقيد الحسابي وتعقيد الذاكرة مع عدد الرموز المدخلة (المعروف أيضاً باسم طول التسلسل) والذي نرمّزه فيما يلي بـ NN . ورغم أن هذا الأمر لا يُلاحظ بشكل كبير في التسلسلات المدخلة القصيرة (حتى 1000 رمز مدخل)، إلا أنه يصبح مشكلة جسيمة في التسلسلات المدخلة الطويلة (عند حوالي 16000 رمز مدخل).

لنلقِ نظرةً أدقّ. الصيغة المستخدمة لحساب المخرجات O\mathbf{O} لطبقة الانتباه الذاتي لإدخال X\mathbf{X} بطول NN هي: O=Attn(X)=V×Softmax(QKT) with Q=WqX,V=WvX,K=WkX \textbf{O} = \text{Attn}(\mathbf{X}) = \mathbf{V} \times \text{Softmax}(\mathbf{QK}^T) \text{ with } \mathbf{Q} = \mathbf{W}_q \mathbf{X}, \mathbf{V} = \mathbf{W}_v \mathbf{X}, \mathbf{K} = \mathbf{W}_k \mathbf{X}

X=(x1,...xN)\mathbf{X} = (\mathbf{x}_1, ... \mathbf{x}_{N}) يُشكّل بالتالي تسلسل الإدخال لطبقة الانتباه. أما الإسقاطات Q\mathbf{Q} و K\mathbf{K} فستتكوّن كلٌّ منها من NN متجهات، مما يؤدي إلى أن يكون QKT\mathbf{QK}^T بحجم N2N^2 .

عادةً ما تحتوي نماذج اللغة الكبيرة على عدة رؤوس انتباه، مما يؤدي إلى تنفيذ عمليات الانتباه الذاتي المتعددة بالتوازي. بافتراض أن النموذج يحتوي على 40 رأس انتباه ويعمل بدقة bfloat16، يمكننا حساب المتطلبات الذاكرة لتخزين QKT\mathbf{QK^T} المصفوفات لتكون 40∗2∗N240 * 2 * N^2 بايت. بالنسبة إلى N=1000N=1000 نحتاج فقط إلى حوالي 50 ميغابايت من ذاكرة الفيديو، ولكن بالنسبة إلى N=16000N=16000 سنحتاج إلى 19 غيغابايت من ذاكرة الفيديو، وبالنسبة إلى N=100,000N=100,000 سنحتاج إلى ما يقرب من 1 تيرابايت فقط لتخزين QKT\mathbf{QK}^T المصفوفات.

باختصار، يصبح خوارزمية الانتباه الذاتي الافتراضية مكلفة للغاية من حيث الذاكرة بسرعة عند التعامل مع سياقات إدخال كبيرة.

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

كيف يمكننا التخلّص من متطلبات الذاكرة الباهظة عند التعامل مع مدخلات طويلة؟ نحتاج إلى طريقة جديدة لحساب آلية الانتباه الذاتي تتخلّص من QKT\mathbf{QK}^T المصفوفة. طوّر تري داو وآخرون خوارزمية جديدة تمامًا لهذا الغرض وسماها Flash Attention.

باختصار، تقسم Flash Attention عملية حساب V×Softmax(QKT)\mathbf{V} \times \text{Softmax}(\mathbf{QK}^T) عن العملية، وتقوم بدلاً من ذلك بحساب أجزاء أصغر من المخرجات من خلال التكرار على خطوات حساب softmax متعددة: Oi←sija∗Oi+sijb∗Vj×Softmax(QKi,jT) for multiple i,j iterations \textbf{O}_i \leftarrow s^a_{ij} * \textbf{O}_i + s^b_{ij} * \mathbf{V}_{j} \times \text{Softmax}(\mathbf{QK}^T_{i,j}) \text{ for multiple } i, j \text{ iterations}

مع sijas^a_{ij} و sijbs^b_{ij} حيث تكون إحصاءات تطبيع softmax التي يجب إعادة حسابها لكل ii و jj .

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

الخلاصة الرئيسية هنا هي:

من خلال تتبع إحصاءات تطبيع softmax واستخدام بعض الرياضيات الذكية، يمنح Flash Attention مخرجات متطابقة عدديًا مقارنة بطبقة الانتباه الذاتي الافتراضية، مع تكلفة ذاكرة لا تزيد إلا بشكل خطي مع NN .

عند النظر إلى الصيغة، قد يقول المرء حدسيًا إن Flash Attention يجب أن يكون أبطأ بكثير مقارنةً بصيغة الانتباه الذاتي الافتراضية، نظرًا إلى الحاجة إلى إجراء المزيد من العمليات الحسابية. وفي الواقع، يتطلب Flash Attention عددًا أكبر من عمليات FLOPs مقارنةً بالانتباه العادي، إذ يجب إعادة حساب إحصاءات تطبيع softmax باستمرار (انظر الورقة البحثية لمزيد من التفاصيل إذا كنت مهتمًا).

ومع ذلك، يكون Flash Attention أسرع بكثير في الاستدلال مقارنةً بآلية الانتباه الافتراضية، وذلك بفضل قدرته على تقليل الطلب بشكل كبير على ذاكرة GPU ذات النطاق الترددي العالي والأبطأ (VRAM)، والتركيز بدلاً من ذلك على ذاكرة الشريحة الأسرع (SRAM).

في جوهره، يضمن Flash Attention إمكانية تنفيذ جميع عمليات الكتابة والقراءة الوسيطة باستخدام ذاكرة SRAM السريعة داخل الشريحة، بدلاً من الحاجة إلى الوصول إلى ذاكرة VRAM الأبطأ لحساب متجه الإخراج O\mathbf{O} .

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

3. الابتكارات المعمارية

حتى الآن، نظرنا في تحسين الكفاءة الحسابية وكفاءة الذاكرة من خلال:

  • تحويل الأوزان إلى صيغة دقة أدنى
  • استبدال خوارزمية الانتباه الذاتي بنسخة أكثر كفاءة في استخدام الذاكرة والحوسبة

لننظر الآن في كيفية تغيير بنية نموذج اللغة الكبير (LLM) ليكون الأكثر فعالية وكفاءة للمهام التي تتطلب مدخلات نصية طويلة، على سبيل المثال:

  • الإجابة على الأسئلة المعززة بالاسترجاع،
  • تلخيص،
  • الدردشة

لاحظ أن chat لا يتطلب من نموذج اللغة الكبير (LLM) التعامل مع مدخلات نصية طويلة فحسب، بل يستلزم أيضاً أن يكون النموذج قادراً على التعامل بكفاءة مع الحوار المتبادل بين المستخدم والمساعد (مثل ChatGPT).

بمجرد تدريب النموذج، يصبح من الصعب تغيير بنية النموذج اللغوي الكبير الأساسية، لذا من المهم مراعاة مهام النموذج اللغوي الكبير مسبقًا وتحسين بنية النموذج وفقًا لذلك. توجد مكوّنان مهمان في بنية النموذج يصبحان بسرعة عنقَي زجاجة في الذاكرة و/أو الأداء عند التعامل مع تسلسلات إدخال كبيرة.

  • تضمينات المواقع
  • ذاكرة التخزين المؤقت للمفتاح والقيمة

لنلقِ نظرةً أكثر تفصيلاً على كل مكوّن

3.1 تحسين التضمينات الموضعية في نماذج اللغة الكبيرة

تضع آلية الانتباه الذاتي كل رمز في علاقة مع رموز أخرى. فعلى سبيل المثال، مصفوفة Softmax(QKT)\text{Softmax}(\mathbf{QK}^T) لأمر النص المدخل “«مرحبًا»، «أنا»، «أحب»، «إياك» قد تبدو على النحو التالي:

يُمنح كل رمز كلمة كتلة احتمالية تحدد مدى انتباهه إلى جميع رموز الكلمات الأخرى، وبالتالي يُوضع في علاقة مع جميع رموز الكلمات الأخرى. على سبيل المثال، ينتبه رمز كلمة “love” إلى كلمة “Hello” بنسبة 5%، وإلى “I” بنسبة 30%، وإلى نفسه بنسبة 65%.

لنموذج لغة كبير يعتمد على الانتباه الذاتي، لكن بدون تضمينات الموضع، سيكون من الصعب للغاية فهم مواضع مدخلات النص فيما بينها. وذلك لأن درجة الاحتمال المحسوبة بواسطة QKT\mathbf{QK}^T تربط كل رمز كلمة بكل رمز كلمة آخر في O(1)O(1) حسابات بغض النظر عن المسافة الموضعية النسبية بينهما. لذلك، بالنسبة لنموذج اللغة الكبير بدون تضمينات الموضع، يبدو أن كل رمز على نفس المسافة من جميع الرموز الأخرى، e.g. فالتمييز بين “مرحباً أنا أحبك و “أنت تحب أنا مرحباً سيكون تحدياً كبيراً.

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

قدّم مؤلفو ورقة تضمينات موضعية جيبية P=p1,…,pN\mathbf{P} = \mathbf{p}_1, \ldots, \mathbf{p}_N . حيث يُحسب كل متجه pi\mathbf{p}_i كدالة جيبية لموضعه ii . ثم تُضاف الترميزات الموضعية ببساطة إلى متجهات تسلسل الإدخال X^=x^1,…,x^N\mathbf{\hat{X}} = \mathbf{\hat{x}}_1, \ldots, \mathbf{\hat{x}}_N = x1+p1,…,xN+pN\mathbf{x}_1 + \mathbf{p}_1, \ldots, \mathbf{x}_N + \mathbf{p}_N لإرشاد النموذج إلى تعلم ترتيب الجمل بشكل أفضل.

بدلاً من استخدام ترميزات موضعية ثابتة، استخدم آخرون (مثل Devlin et al.) ترميزًا موضعيًا متعلَّمًا، حيث تُتعلَّم الترميزات الموضعية P\mathbf{P} أثناء التدريب.

كانت ترميزات المواقع الجيبية والتعلمية هي الأساليب السائدة لتشفير ترتيب الجمل في نماذج اللغة الكبيرة، لكن تم العثور على بعض المشكلات المتعلقة بهذه الترميزات الموضعية:

  1. تُعدّ تضمينات المواقع الجيبية والتعلمية كلاهما تضمينات مواقع مطلقة، i.e. أي أنها ترمّز تضمينًا فريدًا لكل معرّف موقع: 0,…,N0, \ldots, N . كما أوضح هوانغ وآخرون و سو وآخرون, تؤدي تضمينات المواقع المطلقة إلى أداء ضعيف للنماذج اللغوية الكبيرة عند التعامل مع مدخلات نصية طويلة. وفي حال المدخلات النصية الطويلة، يكون من المفيد أن يتعلم النموذج المسافة الموضعية النسبية بين الرموز المدخلة بدلاً من موقعها المطلق.
  2. عند استخدام ترميزات المواقع المتعلمة، يجب تدريب نموذج اللغة الكبير على طول إدخال ثابت $N$، مما يجعل من الصعب الاستقراء إلى طول إدخال أطول من ذلك الذي تم تدريبه عليه.

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

كلاهما RoPE و ALiBi يؤكدان أن الطريقة المثلى لإيصال ترتيب الجمل إلى نموذج اللغة الكبير هي الإشارة إليه مباشرةً داخل خوارزمية الانتباه الذاتي، حيث يتم وضع رموز الكلمات في علاقة ببعضها البعض. وبشكل أكثر تحديداً، يجب الإشارة إلى ترتيب الجمل من خلال تعديل عملية حساب QKT\mathbf{QK}^T T

بدخول في تفاصيل كثيرة، RoPE تُشير إلى أنه يمكن ترميز المعلومات الموضعية في أزواج الاستعلام والمفتاح، e.g. qi\mathbf{q}_i و xj\mathbf{x}_j عن طريق تدوير كل متجه بزاوية θ∗i\theta * i و θ∗j\theta * j على التوالي، حيث i,ji, j تصف موضع كل متجمعة في الجملة: q^iTx^j=qiTRθ,i−jxj. \mathbf{\hat{q}}_i^T \mathbf{\hat{x}}_j = \mathbf{{q}}_i^T \mathbf{R}_{\theta, i -j} \mathbf{{x}}_j.

Rθ,i−j\mathbf{R}_{\theta, i - j} مما يجعله يمثل مصفوفة دوران. θ\theta هو غير قابل للتعلم أثناء التدريب، بل يُضبط على قيمة محددة مسبقًا تعتمد على أقصى طول لسلسلة الإدخال أثناء التدريب.

وبذلك، لا يتأثر التقييم الاحتمالي بين qi\mathbf{q}_i و qj\mathbf{q}_j إلا إذا كان i≠ji \ne j ويعتمد حصريًا على المسافة النسبية i−ji - j مهما كانت المواقع المحددة لكل متجه ii و jj .

يُستخدم RoPE في عدد من أهم نماذج اللغة الكبيرة (LLMs) في الوقت الحالي، مثل:

وبدلاً من ذلك، ALiBi تقترح مخططاً أبسط بكثير لتشفير المواقع النسبية. حيث يُضاف المسافة النسبية بين رموز الإدخال كعدد صحيح سالب مُضروب في قيمة محددة مسبقاً m إلى كل عنصر من عناصر الاستعلام-المفتاح في مصفوفة QKT\mathbf{QK}^T مباشرة قبل حساب softmax.

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

يُستخدم ALiBi في عدد من أهم نماذج اللغة الكبيرة (LLMs) اليوم، مثل:

كلاهما RoPE و ALiBi يمكن لتشفيرات المواقع أن تستخلص إلى أطوال مدخلات لم تُرَ أثناء التدريب، بينما تبيّن أن الاستخلاص يعمل بشكل أفضل بكثير دون إعداد مسبق لـ ALiBi مقارنةً بـ RoPE. بالنسبة إلى ALiBi، يكفي ببساطة زيادة قيم مصفوفة المواقع السفلية المثلثية لتتطابق مع طول تسلسل الإدخال. بالنسبة إلى RoPE, الاحتفاظ بنفس θ\theta الذي تم استخدامه أثناء التدريب يؤدي إلى نتائج ضعيفة عند تمرير مدخلات نصية أطول بكثير من تلك التي شوهدت أثناء التدريب، c.f Press et al.. ومع ذلك، وجد المجتمع بعض الحيل الفعّالة التي تكيف $\theta$، مما يتيح RoPE لتمددات المواقع أن تعمل بشكل جيد مع تسلسلات مدخلات النص المستخلَصة (انظر هنا).

يعتمد كلٌّ من RoPE و ALiBi على تمثيلات موضعية نسبية غير متعلَّمة أثناء التدريب، بل تستند بدلاً من ذلك إلى الحدسيّات التالية:

  • يجب تقديم إشارات موضعية حول مدخلات النص مباشرة إلى QKT\mathbf{QK}^T مصفوفة طبقة الانتباه الذاتي.
  • يجب تحفيز نموذج اللغة الكبير على تعلم ترميز موضعي مسافة نسبية ثابتة.
  • كلما ابتعدت رموز الإدخال النصية عن بعضها البعض، انخفضت احتمالية الاستعلام-القيمة الخاصة بها. وكلا من RoPE وALiBi يخفضان احتمالية الاستعلام-المفتاح للرموز البعيدة عن بعضها البعض. يخفض RoPE ذلك عن طريق تقليل حاصل ضرب المتجهات من خلال زيادة الزاوية بين متجهات الاستعلام-المفتاح. أما ALiBi فيخفض ذلك عن طريق إضافة أرقام سالبة كبيرة إلى حاصل ضرب المتجهات.

ختاماً، تُدرَّب نماذج اللغة الكبيرة (LLMs) المخصصة للمهام التي تتطلب معالجة مدخلات نصية كبيرة بشكل أفضل باستخدام التضمينات الموضعية النسبية، مثل RoPE وALiBi. كما يُشار إلى أنه حتى لو كان نموذج لغة كبيرة يستخدم RoPE وALiBi قد تم تدريبه على طول ثابت فقط، مثل N1=2048N_1 = 2048 فلا يزال بإمكانه استخدام مدخلات نصية أكبر بكثير من $N_1$، مثل $N_2 = 8192 > N_1$، من خلال الاستقراء على التضمينات الموضعية.

3.2 ذاكرة التخزين المؤقت للمفتاح والقيمة

يعمل توليد النص التلقائي (Auto-regressive) باستخدام نماذج اللغة الكبيرة (LLMs) من خلال إدخال تسلسل مدخلات بشكل متكرر، ثم أخذ عينة من الرمز (token) التالي، وإلحاقه بتسلسل المدخلات، والمضي قدمًا في هذه العملية حتى تنتج نموذج اللغة الكبيرة رمزًا يشير إلى اكتمال عملية التوليد.

يرجى الاطلاع على دليل توليد النص في Transformers للحصول على شرح بصري أكثر تفصيلاً لكيفية عمل التوليد الذاتي.

لننفذ مقتطفًا برمجيًا سريعًا لإظهار كيفية عمل التوليد التلقائي في الممارسة العملية. سنكتفي بأخذ الرمز التالي الأكثر احتمالًا عبر torch.argmax.

input_ids = tokenizer(prompt, return_tensors="pt")["input_ids"].to(model.device)

for _ in range(5):
  next_logits = model(input_ids)["logits"][:, -1:]
  next_token_id = torch.argmax(next_logits,dim=-1)

  input_ids = torch.cat([input_ids, next_token_id], dim=-1)
  print("shape of input_ids", input_ids.shape)

generated_text = tokenizer.batch_decode(input_ids[:, -5:])
generated_text

المخرجات:

shape of input_ids torch.Size([1, 21])
shape of input_ids torch.Size([1, 22])
shape of input_ids torch.Size([1, 23])
shape of input_ids torch.Size([1, 24])
shape of input_ids torch.Size([1, 25])
[' Here is a Python function']

كما نلاحظ، في كل مرة نزيد رموز مدخلات النص بالرمز الذي تم عينته للتو.

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

نتيجةً لذلك، لا تعتمد الرموز أبدًا على الرموز اللاحقة، وبشكل أكثر تحديدًا، لا يُوضع متجه qi\mathbf{q}_i في علاقة مع أي متجهات مفاتيح أو قيم kj,vj\mathbf{k}_j, \mathbf{v}_j إذا كان $j > i$. وبدلًا من ذلك، qi\mathbf{q}_i يُركّز فقط على متجهات المفاتيح والقيم السابقة $\mathbf{k}${m < i}, \mathbf{v}${m < i} \text{ , for } m \in {0, \ldots i - 1}$. ولتقليل الحسابات غير الضرورية، يمكن تخزين متجهات المفاتيح والقيم لكل طبقة لجميع الخطوات الزمنية السابقة في ذاكرة مؤقتة.

فيما يلي، سنطلب من نموذج اللغة الكبير (LLM) الاستفادة من ذاكرة المفتاح-القيمة (key-value cache) من خلال استرجاعها وتحويلها في كل تمريرة أمامية (forward pass). في Transformers، يمكننا استرجاع ذاكرة المفتاح-القيمة عن طريق تمرير علم use_cache إلى استدعاء forward، ثم تمريرها مع الرمز الحالي.

past_key_values = None # past_key_values is the key-value cache
generated_tokens = []
next_token_id = tokenizer(prompt, return_tensors="pt")["input_ids"].to(model.device)

for _ in range(5):
  next_logits, past_key_values = model(next_token_id, past_key_values=past_key_values, use_cache=True).to_tuple()
  next_logits = next_logits[:, -1:]
  next_token_id = torch.argmax(next_logits, dim=-1)

  print("shape of input_ids", next_token_id.shape)
  print("length of key-value cache", past_key_values.get_seq_length())  # past_key_values are of shape [num_layers, 0 for k, 1 for v, batch_size, length, hidden_dim]
  generated_tokens.append(next_token_id.item())

generated_text = tokenizer.batch_decode(generated_tokens)
generated_text

المخرجات:

shape of input_ids torch.Size([1, 1])
length of key-value cache 20
shape of input_ids torch.Size([1, 1])
length of key-value cache 21
shape of input_ids torch.Size([1, 1])
length of key-value cache 22
shape of input_ids torch.Size([1, 1])
length of key-value cache 23
shape of input_ids torch.Size([1, 1])
length of key-value cache 24
[' Here', ' is', ' a', ' Python', ' function']

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

استخدام ذاكرة التخزين المؤقت للمفاتيح والقيم يعني أن QKT\mathbf{QK}^T تُختزل في الأساس إلى qcKT\mathbf{q}_c\mathbf{K}^T حيث qc\mathbf{q}_c هو إسقاط الاستعلام للرمز المُدخل حاليًا، وهو دائمًا متجه واحد فقط.

يتمتع استخدام ذاكرة التخزين المؤقت للمفتاح والقيمة بمزاياَ اثنتين:

  • زيادة كبيرة في الكفاءة الحسابية نظرًا لأن عدد العمليات الحسابية أقل مقارنةً بحساب المصفوفة الكاملة QKT\mathbf{QK}^T المصفوفة. يؤدي ذلك إلى زيادة في سرعة الاستدلال
  • لا يتزايد الحد الأقصى للذاكرة المطلوبة بشكل تربيعي مع عدد الرموز المولّدة، بل يتزايد بشكل خطي فقط.

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

لاحظ أنه، على الرغم من توصيتنا باستخدام ذاكرات التخزين المؤقت للمفتاح والقيمة، فقد يكون ناتج نموذج اللغة الخاص بك مختلفًا قليلاً عند استخدامها. هذه خاصية نواتج ضرب المصفوفات نفسها — يمكنك قراءة المزيد حول ذلك هنا.

3.2.1 محادثة متعددة الجولات

يُعدّ ذاكرة التخزين المؤقت للمفتاح والقيمة مفيدة بشكل خاص للتطبيقات مثل الدردشة التي تتطلب عدة جولات من فك الترميز التلقائي التتابعي. دعونا ننظر إلى مثال.

User: How many people live in France?
Assistant: Roughly 75 million people live in France
User: And how many are in Germany?
Assistant: Germany has ca. 81 million inhabitants

في هذا الدردشة، يقوم نموذج اللغة الكبير بتشغيل فك الترميز التلقائي التكراري مرتين:

  1. في المرة الأولى، يكون مخزن المفاتيح والقيم فارغاً، ويكون مُوجِّه الإدخال "User: How many people live in France?"، ويولّد النموذج النص "Roughly 75 million people live in France" بشكل تلقائي متتابع مع زيادة حجم مخزن المفاتيح والقيم في كل خطوة فكّ تشفير.
  2. في المرة الثانية، يكون مُوجِّه الإدخال "User: How many people live in France? \n Assistant: Roughly 75 million people live in France \n User: And how many in Germany?". بفضل ذاكرة التخزين المؤقت، تكون جميع متجهات المفتاح-القيمة للجملتين الأوليين محسوبة بالفعل. لذلك يتكوّن مُوجِّه الإدخال فقط من "User: And how many in Germany?". أثناء معالجة مُوجِّه الإدخال المختصر، تُدمج متجهات المفتاح-القيمة المحسوبة له مع ذاكرة التخزين المؤقت للمفتاح-القيمة الخاصة بفك الترميز الأولي. ثم يتم توليد إجابة المساعد الثانية "Germany has ca. 81 million inhabitants" بشكل تلقائي متدرج، مع ذاكرة تخزين مؤقت للمفتاح-القيمة تتكوّن من متجهات المفتاح-القيمة المُرمّزة لـ "User: How many people live in France? \n Assistant: Roughly 75 million people live in France \n User: And how many are in Germany?".

يجب الإشارة إلى أمرين هنا:

  1. يُعدّ الاحتفاظ بكل السياق أمراً بالغ الأهمية بالنسبة لنماذج اللغة الكبيرة (LLMs) المُنفَّذة في المحادثات، حتى تتمكن النماذج من فهم كامل السياق السابق للمحادثة. فعلى سبيل المثال، في المثال أعلاه، يحتاج النموذج إلى فهم أن المستخدم يشير إلى عدد السكان عندما يسأل "And how many are in Germany".
  2. يُعدّ ذاكرة التخزين المؤقت للمفتاح والقيمة مفيدة للغاية في المحادثة، إذ تتيح لنا توسيع سجل المحادثة المُرمَّز باستمرار بدلاً من الحاجة إلى إعادة ترميز سجل المحادثة من الصفر (كما هو الحال عند استخدام بنية مُرمِّز-مُفكِّك، على سبيل المثال).

في transformers، ستُرجع استدعاء generate قيمة past_key_values عند تمرير return_dict_in_generate=True، بالإضافة إلى الإعداد الافتراضي use_cache=True. يُرجى ملاحظة أن هذه الميزة غير متاحة بعد عبر واجهة pipeline.

# Generation as usual
prompt = system_prompt + "Question: Please write a function in Python that transforms bytes to Giga bytes.\n\nAnswer: Here"
model_inputs = tokenizer(prompt, return_tensors='pt')
generation_output = model.generate(**model_inputs, max_new_tokens=60, return_dict_in_generate=True)
decoded_output = tokenizer.batch_decode(generation_output.sequences)[0]

# Piping the returned `past_key_values` to speed up the next conversation round
prompt = decoded_output + "\nQuestion: How can I modify the function above to return Mega bytes instead?\n\nAnswer: Here"
model_inputs = tokenizer(prompt, return_tensors='pt')
generation_output = model.generate(
  **model_inputs,
  past_key_values=generation_output.past_key_values,
  max_new_tokens=60,
  return_dict_in_generate=True
)
tokenizer.batch_decode(generation_output.sequences)[0][len(prompt):]

المخرجات:

 is a modified version of the function that returns Mega bytes instead.

def bytes_to_megabytes(bytes):
   return bytes / 1024 / 1024

Answer: The function takes a number of bytes as input and returns the number of

رائع، فلا يُستغرق وقت إضافي لإعادة حساب نفس المفاتيح والقيم لطبقة الانتباه! ومع ذلك، هناك نقطة واحدة. فبينما يُقلَّل الذروة المطلوبة للذاكرة بشكل كبير للمصفوفة QKT\mathbf{QK}^T ، يمكن أن يصبح الاحتفاظ بذاكرة المفاتيح والقيم في الذاكرة مكلفًا للغاية من حيث استهلاك الذاكرة في حال وجود تسلسلات إدخال طويلة أو محادثات متعددة الأدوار. تذكّر أن ذاكرة المفاتيح والقيم تحتاج إلى تخزين متجهات المفاتيح والقيم لجميع متجهات الإدخال السابقة xi, for i∈{1,…,c−1}\mathbf{x}_i \text{, for } i \in \{1, \ldots, c - 1\} لكل طبقات الانتباه الذاتي ولكل رؤوس الانتباه.

لنحسب عدد قيم الفاصلة العائمة التي يجب تخزينها في ذاكرة التخزين المؤقت للمفتاح-القيمة (key-value cache) لنموذج اللغة الكبير bigcode/octocoder الذي استخدمناه سابقاً. يبلغ عدد قيم الفاصلة العائمة ضعف طول التسلسل مضروباً في عدد رؤوس الانتباه (attention heads) مضروباً في بُعد رأس الانتباه (attention head dimension) مضروباً في عدد الطبقات. عند حساب ذلك لنموذج اللغة الكبير الخاص بنا مع افتراض طول تسلسل مدخلات يبلغ 16000، نحصل على:

config = model.config
2 * 16_000 * config.n_layer * config.n_head * config.n_embd // config.n_head

المخرجات:

7864320000

حوالي 8 مليارات قيمة من نوع float! تخزين 8 مليارات قيمة من نوع float بدقة float16 يتطلب حوالي 15 غيغابايت من ذاكرة الوصول العشوائي، أي ما يعادل تقريبًا نصف حجم أوزان النموذج نفسها! اقترح الباحثون طريقتين تتيحان تقليل التكلفة الذاكرة لتخزين ذاكرة المؤشرات والقيم بشكل كبير، وسيتم استكشافهما في الأقسام الفرعية التالية.

3.2.2 الانتباه متعدد الاستعلامات (MQA)

قُدم Multi-Query-Attention في ورقة Noam Shazeer المعنونة Fast Transformer Decoding: One Write-Head is All You Need. وكما يشير العنوان، اكتشف Noam أنه بدلاً من استخدام أوزان إسقاطات المفتاح والقيمة لـ n_head، يمكن استخدام زوج واحد من أوزان إسقاط الرأس والقيمة يُشارَك عبر جميع رؤوس الانتباه دون أن يتدهور أداء النموذج بشكل كبير.

باستخدام زوج واحد من أوزان الإسقاط للرأس والقيمة، يجب أن تكون متجهات المفتاح والقيمة ki,vi\mathbf{k}_i, \mathbf{v}_i متطابقة عبر جميع رؤوس الانتباه، مما يعني أننا نحتاج فقط إلى تخزين زوج واحد من إسقاطات المفتاح والقيمة في ذاكرة التخزين المؤقت بدلاً من n_head أزواج.

بما أن معظم نماذج LLM تستخدم ما بين 20 و100 رأس انتباه، فإن MQA يقلل بشكل كبير من استهلاك ذاكرة ذاكرة التخزين المؤقت للمفتاح والقيمة. وبالنسبة لنموذج LLM المستخدم في هذا الدفتر، كان من الممكن بالتالي تقليل استهلاك الذاكرة المطلوب من 15 جيجابايت إلى أقل من 400 ميجابايت عند طول تسلسل الإدخال 16000.

بالإضافة إلى توفير الذاكرة، يؤدي MQA أيضًا إلى تحسين الكفاءة الحسابية كما هو موضح فيما يلي. في فك الترميز التلقائي التكراري (auto-regressive decoding)، يجب إعادة تحميل متجهات المفتاح-القيمة الكبيرة، وربطها بزوج متجهات المفتاح-القيمة الحالية لتغذية عملية qcKT\mathbf{q}_c\mathbf{K}^T الحساب في كل خطوة. في فك الترميز التلقائي التكراري، يمكن أن يصبح عرض نطاق الذاكرة المطلوب لإعادة التحميل الثابتة عنق زجاجة زمنيًا خطيرًا. من خلال تقليل حجم متجهات المفتاح-القيمة، تقل الحاجة إلى الوصول إلى الذاكرة، مما يقلل من عنق زجاجة عرض نطاق الذاكرة. لمزيد من التفاصيل، يرجى الاطلاع على ورقة Noam.

الجزء المهم الذي يجب فهمه هنا هو أن تقليل عدد رؤوس الانتباه المفتاح-القيمة إلى 1 لا معنى له إلا إذا تم استخدام ذاكرة تخزين مؤقت للمفتاح-القيمة. يبقى استهلاك الذروة من الذاكرة للنموذج في تمريرة أمامية واحدة بدون ذاكرة تخزين مؤقت للمفتاح-القيمة دون تغيير، لأن كل رأس انتباه لا يزال لديه متجه استعلام فريد، وبالتالي لا يزال لكل رأس انتباه QKT\mathbf{QK}^T مصفوفة.

لقد حظي MQA بانتشار واسع في المجتمع، وهو الآن مستخدم في العديد من أشهر نماذج اللغة الكبيرة (LLMs):

كما أن نقطة التفتيش المستخدمة في هذا الدفتر - bigcode/octocoder - تستخدم تقنية MQA.

3.2.3 الانتباه الاستعلامي المجمّع (GQA)

وجدت دراسة Grouped-Query-Attention التي اقترحها Ainslie وآخرون من Google أن استخدام MQA قد يؤدي في كثير من الأحيان إلى تدهور الجودة مقارنةً باستخدام إسقاطات رؤوس المفاتيح والقيم المتعددة التقليدية. وتجادل الورقة بأن أداء النموذج يمكن الحفاظ عليه بشكل أكبر من خلال تقليل عدد أوزان إسقاط رؤوس الاستعلام بشكل أقل جذرية. وبدلاً من استخدام وزن إسقاط مفاتيح وقيم واحد فقط، ينبغي استخدام n < n_head من أوزان إسقاط المفاتيح والقيم. وبخيار قيمة n أصغر بكثير من n_head، مثل 2 أو 4 أو 8، يمكن الحفاظ على معظم مكاسب الذاكرة والسرعة من MQA مع التضحية بقدر أقل من سعة النموذج، وبالتالي بأداء أقل على الأرجح.

علاوة على ذلك، وجد مؤلفو GQA أنه يمكن إعادة تدريب نقاط التفتيش الموجودة للنماذج على معمارية GQA باستخدام ما لا يقل عن 5% فقط من حسابات التدريب المسبق الأصلية. ورغم أن 5% من حسابات التدريب المسبق الأصلية قد لا تزال كمية هائلة، فإن إعادة التدريب على GQA يتيح الاستفادة من نقاط التفتيش الموجودة لفترة أطول مع تسلسلات الإدخال الأطول.

لم يُقترح GQA إلا مؤخراً، ولهذا السبب فإن انتشاره أقل وقت كتابة هذا الدفتر. أبرز تطبيق لـ GQA هو Llama-v2.

كخلاصة، يُوصى بشدة بالاستفادة من GQA أو MQA إذا كان نموذج LLM مُفعَّلاً بفك الترميز التلقائي التتابعي (auto-regressive decoding) وكان مطلوباً منه التعامل مع تسلسلات إدخال كبيرة، كما هو الحال على سبيل المثال في تطبيقات الدردشة.

الخلاصة

تتوصل مجتمع البحث باستمرار إلى طرق جديدة وفعّالة لتسريع زمن الاستدلال في نماذج اللغة الكبيرة (LLMs) المتزايدة الحجم. ومن الأمثلة على ذلك اتجاه بحثي واعد هو التفكيك التخميني، حيث يتم توليد "الرموز السهلة" بواسطة نماذج لغة أصغر وأسرع، بينما تولّد نموذج اللغة الكبيرة (LLM) نفسه "الرموز الصعبة" فقط. إن الغوص في التفاصيل يتجاوز نطاق هذا الدفتر، لكن يمكن الاطلاع عليها في هذا المنشور الرائع.

يعود السبب في قدرة النماذج اللغوية الضخمة مثل GPT3/4 وLlama-2-70b وClaude وPaLM على العمل بسرعة كبيرة في واجهات الدردشة مثل Hugging Face Chat أو ChatGPT، إلى حدٍّ كبير، إلى التحسينات المذكورة أعلاه في الدقة والخوارزميات والبنية. في المستقبل، ستصبح المعالجات المتسارعة مثل GPUs وTPUs وغيرها أسرع، وستتيح مساحة ذاكرة أكبر، لكن يجب دائماً الحرص على استخدام أفضل الخوارزميات والبنى المتاحة للحصول على أفضل قيمة مقابل التكلفة 🤗

Update on GitHub