بحث
مقال
أخرى

مقال

تاريخ النشر: آخر تحديث: 16 مشاهدة 0 تعليق 23 دقائق قراءة
16 مشاهدة
0 إعجاب
0 تعليق
موثوق 95%

إنشاء نوى مخصصة لـ AMD MI300

تاريخ النشر: 9 يوليو 2025
تحديث على GitHub

نوى AMD

مقدمة

أكثر من مليار طلب يوميًا: هذه تقديرات منخفضة لعدد الطلبات التي يتعامل معها ChatGPT يوميًا، وهو رقم من غير المحتمل أن ينخفض قريبًا. لكل طلب وكل رمز تم إنشاؤه، نقوم بتشغيل استدلال لنموذج يحتوي على عدة مليارات من المعلمات. لهذا السبب، فإن تحسين النموذج أمر بالغ الأهمية على كل مستوى: عندما نتعامل مع هذا النوع من النطاق، حتى مكسب بنسبة 1% في الكمون أو الطاقة يمكن أن يحقق وفورات كبيرة.

لكن من أين يمكن أن يأتي هذا المكسب؟ هياكل النماذج قد تم تأسيسها بالفعل بشكل جيد، والنماذج الشائعة لديها أوزان كمية منذ فترة طويلة. ومع ذلك، لا يزال هناك مستوى حاسم يمكننا من خلاله تحسين استدلال النموذج: مستوى النواة. النوى هي الخوارزميات التي يتم تنفيذها عند القيام بأي عملية في الشبكة الخاصة بك: هناك نوى ضرب المصفوفات، ونوى الالتفاف، ونوى تطبيع الدفعات، وغيرها. النوى هي خوارزميات منخفضة المستوى، عالية التخصيص، وغالبًا ما تكون مصممة للجهاز الذي ستعمل عليه. من المعروف أنها طويلة وصعبة الكتابة، وتتطلب فهمًا جيدًا لآلية عمل وحدة معالجة الرسوميات (GPU).

النوى ضرورية لتشغيل العمليات في الشبكات العصبية - بدون نواة، لا يمكن استخدام العملية بشكل فعال. لهذا السبب، غالبًا ما تطلق الابتكارات الجديدة بنواة "اليوم 0"، والتي عادة ما تكون مُحسّنة فقط لأحدث أجهزة Nvidia. هذا النهج يستبعد العديد من الأجهزة الأخرى، وخاصة وحدات معالجة الرسوميات من AMD، التي رغم تقديمها لمواصفات مماثلة أو متفوقة، غالبًا ما يتم تجاهلها من قبل مطوري النوى. تعاونت Hugging Face مع AMD لتقديم أداء متقدم على منصات AMD ولتقديم الفائدة لمجتمع المصادر المفتوحة. كجزء من هذه الشراكة، قررنا مع AMD التركيز على تقديم نوى مفتوحة المصدر محسّنة لتحسين أداء خدمة Llama 3.1 405B في FP8 على عقدة مكونة من 8 MI300X باستخدام VLLM.

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

  • نواة الاتصال المتبقي المدمجة، ونواة RMS norm ونواة تحويل FP8
  • نواة تنشيط SwiGLU المدمجة ونواة تحويل FP8
  • نواة GEMM النحيفة

حققنا تسريعًا كبيرًا عند تشغيل VLLM على عقدة مزودة بوحدات معالجة الرسوميات MI300X.

Latency gains
Latency gains

تم اتخاذ القياسات بحجم إدخال 1 وحجم إخراج 128 لمحاكاة نظام فك التشفير. نقوم بقياس زمن فك التشفير باستخدام الوسيط على مدى 30 تكرارًا.

تم قياس تلك المكاسب في الأداء في VLLM، ولكن يمكنك أيضًا استخدام النوى بشكل منفصل، كما هو موضح في قسم "كيفية" الذي يلي.

كيفية استخدام هذه النوى

مستودع hf-rocm-kernels

جميع النوى التي تم وصفها سابقًا متاحة في مستودع hf-rocm-kernels الموجود هنا. فيه، ستجد تعليمات حول كيفية تثبيت الحزمة، الشيفرة المصدرية لكل نواة، روابط بايثون الخاصة بها، مجموعة متنوعة من سكريبتات القياس ومجموعة اختبارات. باستخدام سكريبتات القياس وMI300X، يمكنك حتى إعادة إنتاج ما تم ذكره في هذه التدوينة. لضمان نفس النتائج لـ Torch أو VLLM، يمكنك استخدام نفس الحاوية كما فعلنا. يمكنك أيضًا استخدام المستودع كأساس لبناء نوى خاصة بك: يحتوي على تعليمات حول كيفية ربط نواة على نمط CUDA بلغة بايثون ونواة عينة بسيطة. يمكنك حتى إلقاء نظرة على الفروع تحت التطوير لنوى جديدة، مثل نواة حساب وتواصل كما هو موضح هنا.

التكامل في VLLM

ستتم قريبًا دمج النوى الموصوفة في فرع AMD من مشروع VLLM، ولكن إذا كنت ترغب في إلقاء نظرة على كيفية القيام بشيء مشابه بنفسك، يمكنك الاطلاع على هذا الفرع وهذا المستند.

عملية التحسين

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

مقدمة سريعة إلى MI300X

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

الخيوط

أصغر وحدة عمل في GPU هي الخيط. في أي وقت يتم فيه تنفيذ أي عمل على GPU، يكون ذلك لأن خيطًا ما نفذ تعليمات. التعليمات هي عمليات أساسية مثل الجمع، الضرب، التحويل من نوع بيانات إلى آخر، أو التحميل والتخزين. لكل خيط ذاكرته الخاصة، تُسمى السجلات (أو VGPRs)، والتي يمكنه الوصول إليها فقط. يمكن أن يحتوي الخيط على حد أقصى من 256 سجل، كل منها بعرض 32 بت. أدناه يتم تمثيل خيط مع وصول إلى 256 VGPRs الخاصة به.

Representation of a thread
Representation of a thread

يمكن للخيوط، باستثناء عند استخدام تعليمات التحميل أو التخزين، تنفيذ التعليمات فقط على سجلاتها الخاصة. على سبيل المثال، لجمع متجهين A و B معًا، سيقوم كل خيط بـ 1) تحميل عنصر من A إلى سجلاته و2) عنصر آخر من B، ثم 3) إجراء الجمع وتخزين النتيجة في سجل آخر، وأخيرًا 4) تخزين القيمة من ذلك السجل في الذاكرة. هذا إجمالي 4 تعليمات.

مجموعات الخيوط

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

Representation of a warp
Representation of a warp

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

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

تُجمع مجموعات الخيوط في كتل الخيوط: كتل الخيوط هي تجريدات برمجية، لكنها تعمل على مكون مادي يُسمى وحدة المعالجة (CU). يمكن لوحدة معالجة واحدة تشغيل عدة كتل خيوط في وقت واحد، لكنها لا تستطيع استيعاب سوى 16 مجموعة خيوط. تحتوي كل وحدة معالجة على ذاكرة مؤقتة مخصصة من المستوى الأول وذاكرة مشتركة. لا يمكن التحكم في ذاكرة المستوى الأول أو تخصيصها، وتساعد في إعادة استخدام البيانات لجميع المجموعات الموجودة على وحدة المعالجة. بالمقابل، يمكن تخصيص الذاكرة المشتركة واستخدامها كمساحة تخزين مشتركة لجميع المجموعات. على سبيل المثال، عندما نريد أن تصل جميع المجموعات (وبالتالي الخيوط) في وحدة معالجة إلى نفس المخزن المؤقت، نقوم بتخصيصه في الذاكرة المشتركة. كل من الذاكرة المشتركة وذاكرة المستوى الأول سريعة الوصول لأنها "قريبة" من الخيوط.

Representation of a compute unit
Representation of a compute unit

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

XCDs

تُجمع وحدات المعالجة بعد ذلك في رقائق معقدة مساعدة (XCDs)، التي تحتوي على 38 وحدة معالجة لكل منها. على الرغم من أن وحدات المعالجة قد لا تتفاعل مع بعضها البعض، إلا أنها تشترك جميعًا في ذاكرة مؤقتة من المستوى الثاني لا يمكنك التحكم فيها، لكنها قد تكون مفيدة عند إعادة استخدام البيانات. على سبيل المثال، عند الوصول إلى الذاكرة، سيساعد وجود وحدتين معالجة تقعان على نفس XCD في الوصول إلى نفس البيانات على تقليل زمن التحميل بشكل كبير. ذاكرة المستوى الثاني كبيرة جدًا: تبلغ سعتها 4 ميغابايت، بينما تحتوي الذاكرة المشتركة على سعة 64 كيلوبايت وذاكرة المستوى الأول تحتوي على 32 كيلوبايت.

Representation of a XCD
Representation of a XCD

GPU بالكامل (MI300X)

من خلال تجميع 8 XCDs (مما يمنحنا 8 * 38 = 304 وحدات معالجة) وإضافة مستوى ذاكرة مؤقتة أخير (يسمى ذاكرة مؤقتة غير محدودة، بسعة 256 ميغابايت) وكمية كبيرة من ذاكرة الفيديو (192 غيغابايت) نحصل على MI300X.

Representation of a MI300
Representation of a MI300

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

عند النظر إلى هذه الصورة، يمكننا أن نفهم لماذا تُعرف وحدات معالجة الرسوميات (GPUs) بأنها "متوازية بشكل كبير": هنا، لدينا 304 وحدة معالجة، يمكن لكل منها تشغيل 16 مجموعة، وكل مجموعة تحتوي على 64 خيطًا. وهذا يعني أنه يمكن أن يكون لدينا ما يصل إلى 311296 خيطًا يعمل في نفس الوقت، كل منها ينفذ تعليماته الخاصة. تذكر أن التعليمات هي شيء أساسي مثل الجمع، لذا فإن الروتينات البسيطة مثل طريقة نيوتن قد تستغرق وقتًا طويلاً للتنفيذ على خيط واحد. وحدات معالجة الرسوميات ليست مصممة لتشغيل التعليمات بسرعة، أي أن زمن الاستجابة لكل تعليمات منخفض: سيكون ذلك جهازًا موجهًا للزمن. بل هي مصممة لتشغيل العديد من الخيوط معًا، مستهلكةً ومخرجةً كمية كبيرة من البيانات: إنها جهاز موجه للإنتاجية. عند تحسين نواة لوحدة معالجة الرسوميات، نتكيف وفقًا لذلك: من الأفضل أن يكون لدينا خوارزمية تعمل على عدد قليل من التعليمات على العديد من الخيوط في وقت واحد، بدلاً من تشغيل العديد من التعليمات على عدد قليل من الخيوط. ومن هنا جاء وصف الخوارزميات التي تعمل على وحدات معالجة الرسوميات بأنها "متوازية".

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

تحليل الأداء في اليوم الأول

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

Disk plot ok kernels latency
Disk plot ok kernels latency

يمكننا رؤية الأجزاء المختلفة من الشبكة من خلال كل شريحة:

  • شريحة "الاهتمام*"، حيث قمنا بتجميع نوى RoPE والاهتمام وKV cache؛
  • "GEMMs الاهتمام"، التي تشمل مشروعين، QKV وOutput؛
  • "الاتصالات"، التي تتكون من عمليتين لتقليل جميع البيانات، واحدة بعد كتلة الاهتمام وواحدة بعد كتلة MLP، وهذه موجودة لأننا نعمل في وضع التوازي في الموتر (TP8)؛
  • "GEMMs MLP"، التي تشمل المشروعين اللذين تم إنشاؤهما في MLP، Gate / Up وDown؛
  • شريحتا "RMS norm" و"SwiGLU"، واحدة لكل نواة — لاحظ أن نواة RMS norm تُستدعى مرتين لكل كتلة، مرة قبل الاهتمام ومرة قبل MLP؛
  • شريحة "أخرى" التي تجمع النوى التي لم نصنفها كجزء من فئة أكبر لأن تأثيرها ضئيل.

يمكننا أن نرى بالفعل أن معظم زمن الاستجابة يأتي من GEMMs والاتصالات، ولكن أيضًا أن الاهتمام والعمليات المحيطة به ليست مساهمًا رئيسيًا في زمن الاستجابة. قد يكون هذا مفاجئًا، لأن العديد من الأوراق تركز على الاهتمام وتقليل تكلفته، ولكن يبدو أنه من خلال مجموعة من التخزين المؤقت KV وFlashAttention، الذي تم تحسينه بالفعل في VLLM، قد لا تكون هذه الجزء أولوية قصوى. بشكل مفاجئ، فإن الاستدعاءات الاثنين التي تمت لنواة "RMS norm" مكلفة جدًا، لذا قد يكون هناك فائدة كبيرة من تحسين تلك النواة. جنبًا إلى جنب مع نواة SwiGLU، تمثل 15% من إجمالي زمن الاستجابة، وهو ليس ضئيلًا. بشكل عام، قد يكون العمل على هاتين النواتين، بالإضافة إلى محاولة تحقيق زيادة صغيرة في سرعة GEMMs هو أفضل مسار لنا. للتحقق من أن هذا التحليل للأداء ليس مجرد صدفة، يمكننا إلقاء نظرة على أحجام دفعات أخرى:

Latency distribution over batch sizes
Latency distribution over batch sizes

يمكننا أن نرى أن النمط الذي ظهر لحجم الدفعة 32 يستمر في أحجام دفعات أخرى، على الرغم من أن مساهمة زمن الاستجابة من GEMMs والاتصالات تزداد مع زيادة حجم الدفعة. أيضًا، يبدو أن حجم الدفعة 32 هو حالة شاذة عندما يتعلق الأمر بزمن استجابة GEMMs: ربما لأن GEMMs المختارة عندما يكون حجم الدفعة 32 تم ضبطها يدويًا أو لأن حجم الدفعة 32 يقدم أنماط محاذاة جيدة في الذاكرة، لذا فإن GEMMs لحجم الدفعة 32 أسرع من حجم الدفعة 24 أو 28.

الآن بعد أن حددنا بعض النقاط الساخنة لتحسينها، دعونا نلقي نظرة على النواة الأولى التي كتبناها: نواة RMS norm.


نواة RMS norm

في كل كتلة فك التشفير، لدينا جزئين رئيسيين: كتلة اهتمام وكتلة MLP. كلاهما يبدأ باتصال متبقي بين مدخلين: الحالات المخفية الحالية x x x والاحتياطي r r r. كلاهما لهما نفس الشكل، والذي هو n n n صفوف (بقدر ما هناك من الرموز) وd d d أعمدة. بعد أن يتم إضافتهما معًا، نقوم بتطبيق معيار الجذر التربيعي المتوسط (RMS) على x x x، ونظرًا لأن النموذج في FP8، نقوم بتحويل x x x إلى FP8 باستخدام مقياس s s s. إن دمج هذه العمليات الثلاث في نواة واحدة يمكن أن يقدم زيادة كبيرة في الأداء. رياضيًا، العمليات التي يجب علينا تنفيذها هي كما يلي:

i+j+kxx+rrxV=i=1dxi2xxV+ϵxQ=Qfp8(sxw) \begin{align} \phantom{i + j + k} &\begin{aligned} x &\leftarrow x + r\\ r &\leftarrow x \end{aligned}\\ &\begin{aligned} V &= \sum_{i=1}^{d} x_i^2 \end{aligned}\\ &\begin{aligned} x &\leftarrow \frac{x}{\sqrt{V + \epsilon}} \\ x_Q &= Q_{\text{fp8}} \left( s * x * w\right) \end{aligned} \end{align} i+j+kxrxrx+rxV=i=1dxi2xxQV+ϵx=Qfp8(sxw)

حيث w w w هو متجه وزن بحجم d d d. الخطوتان (1) و (3) بسيطتان للغاية. في الخطوة (1)، نحتاج فقط إلى وضع كل خيط في موقع مختلف في الموتر، وتحميل بعض العناصر من x x x و r r r، ثم إضافتها وتخزينها مرة أخرى في r r r. في الخطوة (3)، يقوم كل خيط بإجراء بعض العمليات العددية (الجمع، الجذر التربيعي، القسمة) وتحويلها إلى FP8. كل هذا يمكن أن يقوم به كل خيط بمفرده: وهذا مناسب تمامًا للطبيعة المتوازية لوحدة معالجة الرسوميات. الخطوة التي يجب الانتباه لها هي (2): نحتاج إلى جمع القيم عبر d d d، مما يعني أن كل خيط سيزور كل عمود من d d d، أو نحتاج إلى تبادل البيانات بين الخيوط. كلما زادت قيمة d d d، زادت كمية البيانات التي يتعين علينا تحميلها للخيار الأول، مما يجعله أقل جدوى. سنختار الخيار الثاني: مزامنة الخيوط على مستوى الكتلة، وسيتبادلون البيانات باستخدام الذاكرة المشتركة. كل خيط سيجمع جزءًا من V V V بمفرده، ثم سنجمع كل تلك الأجزاء عبر كتلة الخيوط، وهو ما نسميه "تقليل". نظرًا لأن V V V يتم حسابه عبر صف كامل، سنخصص كتلة خيوط لكل صف.

عند مقارنتها بإصدار PyTorch الافتراضي، فإن النسخة الأساسية من هذه النواة تحقق زيادة في السرعة تصل إلى 10 أضعاف. ولكن هذا ليس كافيًا: لا تزال هناك العديد من التحسينات التي يمكننا إضافتها فوق ذلك.

تحسين: متعلق بالذاكرة

فيما يتعلق بالزمن المستغرق، تعتبر عملية الوصول إلى VRAM، المعروفة أيضًا باسم الذاكرة العالمية، واحدة من أكثر العمليات تكلفة. لحسن الحظ، هناك بعض المبادئ السهلة المتابعة التي يمكن أن تقلل بشكل كبير من تكلفة تحميل البيانات.

أولاً، يمكننا النظر في مقدار البيانات التي يمكن أن يحملها خيط واحد في تعليمات واحدة: باستخدام دليل تعليمات MI300X، نرى أن أكبر تحميل يمكننا القيام به من الذاكرة العالمية هو بعرض 128 بت. نظرًا لأننا نحمل بيانات FP16، سنقوم بتحميل 128 بت / 16 بت = 8 عناصر لكل تحميل. بالنسبة لعناصر fp32، فإن ذلك سيعادل 4 عناصر لكل تحميل.

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

Two loading scenarios
Two loading scenarios

في هذا السيناريو الافتراضي، لدينا خيطين في نفس المجموعة. يحتاجان بشكل جماعي إلى تحميل 16 عنصرًا من fp32، دون قيود على أي خيط يحمل أي عنصر. هذه حالة "تقليل" نموذجية. نظرًا لأن الخيط يمكنه فقط تحميل 4 عناصر من fp32 لكل تعليمات، لدينا على الأقل طريقتين لقراءة البيانات، كما هو موضح في السيناريو (أ) و (ب). لتحديد أي سيناريو هو الأفضل، نحتاج إلى النظر إلى ذلك من منظور المجموعة، وليس من منظور الخيط. في السيناريو (أ)، يقوم التحميل الأول بجلب العناصر 0،1،2،3،8،9،10،11: نرى أن البيانات ليست متجاورة، لأن هناك فجوة بين العناصر 3 و8. بينما في السيناريو (ب)، يقوم التحميل الأول بجلب العناصر 0،1،2،3،4،5،6،7: نحن نحمل بيانات متجاورة. نفس الشيء ينطبق على التحميل الثاني. وبالتالي، السيناريو (ب) هو الأفضل. على الرغم من أنه في السيناريو (أ) نحصل على 8 عناصر متجاورة لكل خيط، إلا أن ذلك لا يهم: ما يهم هو ما إذا كانت المجموعة تحمل بيانات متجاورة. هذا مهم لأنه إذا كانت المجموعة يمكنها فقط تحميل 8 عناصر متجاورة في دورة واحدة، فإن كل تحميل في السيناريو (أ) يتم معالجته في دورتين، بينما في السيناريو (ب)، كل تحميل يحتاج فقط إلى دورة واحدة.

ثالثًا، نقلل عدد التخزينات: عندما ننظر إلى الخطوتين (1) و (3) يمكننا أن نرى أنه هناك فقط تخزينتين مطلوبتين: واحدة لـ r r r وواحدة لـ xQ x_Q xQ. بعد الخطوة (1) يمكننا بالفعل تخزين r r r والانتهاء من ذلك. لكننا لا نزال بحاجة إلى الوصول إلى النسخة المعدلة من x x x بعد الانتهاء من الخطوة (2). للقيام بذلك، يمكننا تخزين النسخة المعدلة من x x x في الذاكرة العالمية وإعادة تحميلها بعد الانتهاء من الخطوة (2) والاعتماد على نجاحات التخزين المؤقت عند إعادة تحميلها. أو، إذا كانت x x x صغيرة بما فيه الكفاية، يمكننا تخزين نسختها المعدلة في الذاكرة المشتركة: إذا كانت x x x في FP16 ولدينا كتلة خيوط واحدة فقط لكل CU، فيمكننا تخزين 64KB / 2B = 32 * 1024 عنصرًا في الذاكرة المشتركة لكل كتلة خيوط. في حالة Llama 405B، d d d تساوي 16384، لذا فإن ذلك يناسب. استخدام الذاكرة المشتركة يوفر زيادة ملحوظة في السرعة مقارنة بالاعتماد على نجاحات التخزين المؤقت، خاصة عندما تكون العديد من كتل الخيوط نشطة في نفس الوقت: إذا كانت ذاكرة التخزين المؤقت L1 ليست كبيرة بما يكفي لتناسب كل x x x، فعلينا الاعتماد على ذاكرة التخزين المؤقت L2، التي يتم مشاركتها بواسطة 38 CU.

بعيدًا عن الوصول إلى الذاكرة، يمكننا أيضًا تحسين الكفاءة الحسابية، لكننا سنترك ذلك للنواة التالية، حيث ستكون مشابهة في كلا الحالتين.

النتائج

عند تطبيق التحسينات التي تم مناقشتها أعلاه، نحصل على النتائج التالية:

Latency of RMS norm kernels
Latency of RMS norm kernels

عدد الصفوف Torch (μs) VLLM (μs) نحن (μs)
1 38.8998 5.5145 4.18138
2 43.2469 5.65645 4.36976
4 41.1304 5.6893 4.37628
8 43.8883 5.72275 4.39081
16 46.8876 5.85667 4.48165
32 55.2276 6.08502 4.72017
64 75.6086 6.4629 5.54214
128 98.1122 7.49166 6.27341
256 119.727 11.8812 10.739
512 195.782 23.1595 18.5549
1024 355.42 44.8143 34.7204
2048 671.513 81.2089 73.35

مع موتر إدخال بحجم [X، 16384] من نوع FP16. النسخة الأساسية من نواتنا، المشار إليها باسم "نقطة واحدة"، ليس لديها تحسينات متعلقة بالذاكرة وتظهر بالفعل زيادة في السرعة تصل إلى 4 أضعاف مقارنة بـ Torch. إنها أقل كفاءة من تنفيذ VLLM للنواة، لكن تنفيذنا "المتجه" يتفوق على كل من "النقطة الواحدة" و VLLM. هذه هي النسخة من النواة التي تنفذ تحميلات متجاورة بعرض 128 بت، والتي لا تتفوق عليها سوى النسخة "المتجهة + SMEM" (SMEM تعني الذاكرة المشتركة) التي تقدم نسبة زيادة ملحوظة في السرعة مقارنة بـ VLLM لكل من أحجام الدفعات الصغيرة والكبيرة.


نواة SwiGLU

في كتلة MLP، بعد النواة التي كتبنا عنها للتو، تأتي عملية الإسقاط التي أطلقنا عليها حتى الآن اسم "الإسقاط Gate / Up". السبب في تسميتها بهذا الشكل هو أن "الإسقاط Gate / Up" هو في الواقع تجميع لعمليتين إسقاط بنفس المدخل: "Gate" و "Up". وبالتالي، سنكتب نتيجة x x x للإسقاط "Gate / Up" كالتالي x=xGxU x = x_G | x_U x=xG حيث | هو عامل التجميع المطبق على طول محور العمود. xG x_G xG و xU x_U xU لهما نفس الأبعاد. السبب في حاجتنا إلى هذين الإسقاطين هو دالة تفعيل SwiGLU التي تأتي بعد ذلك، والتي تحدد النتيجة y y y وفقًا للمعادلة (4). تتبع دالة تفعيل SwiGLU الإسقاط "Down"، الذي في حالتنا يكون من نوع FP8، لذا نحتاج أيضًا إلى تحويل y y y كما هو موضح في المعادلة (5):

حيث x x x هو متجه بحجم n n n، وy y y هو عدد حقيقي. لحساب sn s_n sn، نقوم بتحميل n n n عنصر وننفذ n1 n-1 n1 عملية جمع. لحساب tn t_n tn، نقوم بتحميل عنصر واحد وننفذ 2n1 2n-1 2n1 عمليات جمع وضرب. لذا فإن "كثافة العمليات الحسابية" لحساب sn s_n sn هي n1n \frac{n-1}{n} nn1 بينما tn t_n tn هي 2n1 2n - 1 2n1 : حساب tn t_n tn هو أكثر "كثافة حسابية" من حساب sn s_n sn. ما نراه هنا هو أنه عندما تكون كثافة العمليات الحسابية أقل، نحتاج إلى تحميل المزيد من البيانات لأداء العمل.

لماذا يهمنا هذا؟ حسنًا، لقد رأينا أن تحميل البيانات من VRAM له تكلفة زمنية عالية، وهو ما لا يناسب GPU. بعبارة أخرى، فإن الأحمال ذات الكثافة الحسابية المنخفضة غير مناسبة لـ GPU، ويتضح أن GEMMs النحيفة لديها كثافة حسابية أقل من نظيراتها غير النحيفة. يصبح هذا بديهيًا عند النظر إلى الشكل أدناه: يمكننا أن نرى أنه عند تقسيم كمية البيانات المحملة إلى نصفين، نقسم عدد معاملات الإخراج إلى أربعة، بسبب الطبيعة التربيعية لأبعاد GEMM.

The arithmetic intensity of two GEMMs
The arithmetic intensity of two GEMMs

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

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

Async GEMM mechanism
Async GEMM mechanism

ما يجعل العملية كلها تعمل هو أنه بمجرد أن يتم ملء المخزن 0 0 0 بواسطة منتج، يمكنه البدء في العمل على المخزن 1 1 1 دون الانتظار حتى يقوم المستهلك بتحميل البيانات من المخزن 0 0 0. الهدف هو أن تكون لدينا قائمة انتظار كبيرة بما يكفي ليكون المنتجون مشغولين دائمًا بملء المخازن والمستهلكون مشغولين دائمًا باستهلاكها. حجم قائمة الانتظار مقيد بحجم الذاكرة المشتركة.

نحتاج أيضًا إلى ضبط نسبة المنتجين إلى المستهلكين: لقد قلنا إن لدينا كثافة حسابية منخفضة، لذا نحتاج إلى تحميل الكثير من البيانات للقيام بحساب سريع نسبيًا. وبالتالي، سيكون لدينا العديد من الواربات المنتجة (عادةً 8 أو 10) لقليل من الواربات المستهلكة (شيء مثل 2 أو 3). علاوة على ذلك، يمكننا استغلال حقيقة أن GEMM نحيف من خلال وجود منتجين منفصلين للإدخال (المصفوفة النحيفة) والأوزان (المصفوفة غير النحيفة). لجعل بلاطة الإخراج أكبر في البعد الذي لا يتقيد فيه، وهو بعد الأعمدة، نخصص المزيد من المنتجين للأوزان.

للحصول على تدوينة أكثر تفصيلاً حول GEMMs غير المتزامنة، أشجعك على الاطلاع على هذه التدوينة. الكثير من محتوياتها ليست قابلة للتطبيق في حالتنا، على الرغم من ذلك: MI300X ليس لديه حواجز على مستوى الواربات، بل فقط حاجز على مستوى كتلة الخيوط. أدى ذلك إلى "مواقف ممتعة" مثل ASM لضمان انتظار الواربات عند حواجزها، وحل تحميلات وتخزينات الذاكرة المشتركة قبل التحقق من حالة الحاجز، والتعامل الدقيق مع الطبيعة التكرارية لقائمة الانتظار. كل هذا سيكون خارج السياق هنا، لكنني أشجعك على الاطلاع على الكود أو طرح الأسئلة في التعليقات. قد يأتي استكشاف عميق لتفاصيل التعامل غير المتزامن في المستقبل.

من خلال تخصص الواربات والعمل غير المتزامن، يمكننا تكييف النواة الخاصة بنا مع الأحمال ذات الكثافة الحسابية المنخفضة، لكن هل يكفي ذلك للتفوق على مكتبات مثل hipBLASLT؟ الجواب هو نعم، في بعض الحالات.

النتائج

نظرًا لأن Torch يرتبط بالفعل بـ GEMM محسن للغاية مأخوذ من مكتبة الجبر الخطي الخاصة بـ AMD، فلن نحصل على تسريعات في نفس النطاق كما في النواتين الأخيرتين. سنلقي أولاً نظرة على أبعاد GEMM الثلاثة التي تهمنا: وهي أبعاد GEMM المرتبطة بإسقاط QKV، وإسقاط البوابة / الإسقاط لأعلى، والإسقاط لأسفل. تم استبعاد إسقاط الإخراج لأن أبعاده لا تتوافق مع حالة GEMM النحيف.

M (صفوف) N (أعمدة) K (عمق) وقت Torch (ميكروثانية) وقت SkG (ميكروثانية) تسريع
1 2304 16384 14.938 ± 0.292 11.685 ± 0.299 127.84 %
8 2304 16384 16.300 ± 0.282 12.342 ± 0.375 132.07 %
16 2304 16384 16.693 ± 0.233 13.909 ± 0.295 120.02 %
32 2304 16384 16.817 ± 0.124 17.021 ± 0.133 98.80 %
1 13312 16384 77.636 ± 0.364 54.717 ± 0.628 141.88 %
8 13312 16384 80.031 ± 0.449 58.355 ± 0.612 137.15 %
16 13312 16384 75.236 ± 0.378 59.973 ± 1.922 125.45 %
32 13312 16384 82.198 ± 0.590 69.483 ± 1.672 118.30 %
1 16384 6656 31.066 ± 0.193 27.613 ± 0.218 112.51 %
8 16384 6656 31.559 ± 0.200 28.134 ± 0.209 112.17 %
16 16384 6656 31.671 ± 0.250 30.233 ± 0.267 104.76 %
32 16384 6656 35.561 ± 0.335 35.052 ± 1.365 101.45 %

تمت قياس النتائج بعد 500 دورة تسخين، على مدار 2000 دورة تقييم، باستخدام رسم بياني CUDA وأوزان متعددة لتجنب ضربات التخزين المؤقت. بالتتابع، تتوافق أبعاد GEMM الموضحة أعلاه مع إسقاط QKV (N = 2304 وK = 16384)، وإسقاط البوابة / الإسقاط لأعلى (N = 13312 وK = 16384) والإسقاط لأسفل (N= 16384 وK = 6656). يمكننا أن نرى أنه بالنسبة لتلك الأبعاد، التي تم ضبطها، هناك تسريع ملحوظ لعدد قليل من الصفوف (M = 1، 8، 16) ولكن أقل بالنسبة للصفوف الأكثر (M = 32). خاصة بالنسبة للأبعاد التي يمكننا استخدام خدعة التخلخل فيها (M = 1، 8) نرى تسريعًا ملحوظًا مقارنةً بـ Torch، الذي ربما يملأ كل شيء إلى 16 صفًا لاستخدام أصغر تعليمات MFMA.

الخاتمة

في هذه التدوينة، استكشفنا فقط عددًا قليلاً من تقنيات تحسين النواة المتاحة. إذا كنت مهتمًا بتجربتها، فلا تتردد في الغوص في مستودع hf-rocm-kernels وابدأ في التلاعب! وإذا قمت بتطوير نواة تعجبك وترغب في توزيعها، تأكد من مراجعة kernel-builder وkernels - حزمتي Hugging Face المصممتين لمساعدة بناة النوى في جعل عملهم متاحًا على نطاق واسع وأكثر تأثيرًا.

قم ببناء ومشاركة نوى ROCm بسهولة مع Hugging Face.

من الصفر إلى GPU: دليل لبناء وتوسيع نوى CUDA الجاهزة للإنتاج

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

المجتمع

عمل رائع! بالنسبة لـ Skinny GEMM، هل لديك أرقام لتحسينات النسب المئوية من mfma المتناثر وتخصص warp (WS) بشكل منفصل؟ أريد فهم تأثير WS لأشكال مختلفة، دون mfma المتناثر.

عند النظر إلى الشيفرة، يبدو أن الفهم هو أن الندرة تُستخدم فقط لـ M = 8. هل هذا صحيح؟

كيف تعمل معالجة العمليات غير المتزامنة؟

· قم بالتسجيل أو تسجيل الدخول للتعليق

التعليقات 0

سجل دخولك لإضافة تعليق

لا توجد تعليقات بعد. كن أول من يعلق!