السلام عليكم ورحمة الله،
سيتمحور حديثنا اليوم عن بعض التفاصيل الداخلية في معالج الرسوميات، لنفهم المبرر وراء بعض الاختلافات التي تواجهنا عند برمجة هذا المعالج مقارنة مع ما اعتدنا التعامل معه عند استخدام المعالج المركزي. إضافة إلى ذلك ستـُـظهر لنا هذه التفاصيل إحدى الاعتبارات الهامة في أداء المظللات.
يقال عن عملية التصيير في الأوساط المختصة بالمعالجة المتوازية بأنها عملية ”متوازية بشكل مخجل“ (embarrassingly parallel)، والقصد هنا أن الحسابات يسهل جداً تشغيلها على التوازي، وهذه الخاصية تأتي من أصل الحسابات. فعملية التصيير التقليدية مثلاً تحتاج إلى حساب لون كل بكسل من الصورة للوصول إلى النتيجة النهائية. ويمكننا حساب لون كل بكسل بشكل مستقل عن البكسل الآخر. ولو فرضنا أننا نملك جهازاً به عدداً من المعالجات يساوي عدد البكسلات المطلوب حسابها، لاستطعنا حساب كامل الصورة بزمن حساب بكسل واحد فقط. المفاجأة الآن هي أن معالج الرسوميات (GPU) بالفعل يعمل هكذا! فهو معالج وحيد لكنه يحتوي على الآلاف من مسارات المعالجة (threads) المتوازية التي يعمل كل منها على حساب لون البكسل المسؤول عنه.
المقطع أدناه يحاول أن يمثل هذا الفرق بطريقة مرحة:
المسارات في معالج الرسوميات تختلف عنها في المعالج المركزي. في هذا الأخير كل مسار يقوم بتشغيل برنامج مستقل وله ذاكرة عمل بمقدار عالي نسبياً (ميجابايت واحد عادةً)، بينما في معالج الرسوميات فإن كافة المسارات تقوم بتشغيل البرنامج نفسه ولا تملك أية ذاكرة عمل باستثناء بعض السجلات المحدودة لحفظ القيم المؤقتة أثناء أداء الحسابات وهي لا تتجاوز البضعة كيلوبايتات بمجملها بأحسن الأحوال.
ليس ذلك فحسب، بل إن المسارات في معالج الرسوميات تعمل سوية كمجموعات متزامنة. مثلاً، يريد المعالج حساب 500 بكسل، فيقوم بتقسيمها إلى مجموعات 16×16. كل مجموعة تحسبها مسارات تعمل بنفس البرنامج وكلها تنفذ نفس التعليمة في الوقت ذاته. هذه نقطة هامة ولها تأثير على الأداء كما سأشرح لاحقاً. لكن الآن فلنقعــّد الكلام ببعض الكود:
1: float4 PS_Main(float2 vUV : TEXCOORD0, float4 vTint : TEXCOORD1) : COLOR0
2: {
3: float4 texDiffuse = tex2D(Diffuse_Sampler,vUV);
4: return texDiffuse * vTint;
5: }
الإجراء أعلاه هو لمظلل بكسلات بسيط، يستقبل قيمتين: إحداثي إكساء ومعامل لوني. يقوم المظلل في السطر الثالث بمعاينة الإكساء باستخدام الإحداثيات vUV وفي السطر الرابع يعيد مضروب كل من لون الإكساء المعاين مع المعامل اللوني، وهذه العملية تؤدي إلى “صبغ” الإكساء باللون المحدد بـ vTint.
الآن عندما نست��دم هذا المظلل في رسم كرة ثلاثية الأبعاد مثلاً. سيقوم معالج الرسوميات بتحديد البكسلات التي تغطيها هذه الكرة (وذلك خلال عملية التسامت - Rasterization) ثم يقوم بتشغيل مثلاً 2000 مسار على التوازي لحساب 2000 بكسل هي التي تغطيها الكرة على الشاشة. كل هذه المسارات ستقوم بتنفيذ الإجراء أعلاه، وكل مجموعة من هذه المسارات (thread group) تعمل متزامنة خطوة بخطوة. ففي الدورة الأولى ستقوم كل المسارات بالمجموعة بتنفيذ السطر 3 وفي الدورة الثانية ستقوم بتنفيذ السطر 4. تنفيذ البرنامج يستهلك فقط دورتين.
لو كانت كل هذه المسارات تقوم بتنفيذ نفس الكود وفي نفس الوقت. فكيف تختلف نتائجها؟ والجواب هو في المدخلات. كل واحد من هذه المسارات يستقبل قيم مختلفة عن المسارات الأخرى. لكن طريقة حساب هذه المدخلات والتعامل معها هي نفسها. في الإجراء السابق، ستختلف النتائج مع اختلاف قيم vUV و vTint. فمثلاً، معاينة الإكساء في السطر 3 قد تعطينا اللون الأحمر عند الإحداثي (0،0) وتعطينا اللون الأزرق عند الإحداثي (1،1). هذا يجعل مظلل البكسلات يعمل بشكل يشبه مبدأ (تعليمة-واحدة-بيانات-عدة Single-Instruction-Multiple-Data) أو SIMD باختصار.
الآن لنعدل الكود تعديلاً طفيفاً:
1: float4 PS_Main(float2 vUV : TEXCOORD0, float4 vTint : TEXCOORD1) : COLOR0
2: {
3: float4 texDiffuse = tex2D(Diffuse_Sampler,vUV);
4: if (texDiffuse.a > 0.5f)
5: {
6: float4 texSecondLayer = tex2D(SecondLayer_Sampler,vUV);
7: texDiffuse += texSecondLayer;
8: }
9: return texDiffuse * vTint;
10: }
لقد أضفنا الأسطر 4 إلى 8 فقط. في السطر الرابع نقوم باختبار قيمة ألفا في الإكساء texDiffuse وإن كانت أعلى من 0.5 فنقوم بمعاينة إكساء آخر (طبقة ثانية) وإضافة لونها على لون الإكساء الأول.
الآن لنعد إلى المسارات التي ستنفذ هذا الإجراء. سيتم تنفيذ السطر 3 في الدورة الأولى. السطر 4 في الدورة الثانية. ثم… ماذا نفعل الآن؟! بعض البكسلات استقبلت قيمة texDiffuse.a أكبر من 0.5 والبعض الآخر استقبل القيمة أصغر من 0.5 ولن ينفذ الأسطر 6 و 7. هنا يحدث شقاق ولن يستطيع معالج الرسوميات بالاستمرار في تنفيذ الإجراء بالتزامن خطوة بخطوة على كل المسارات. فكيف سيتصرف المعالج في هذا الحال؟
الجواب ليس مريحاً. فستقوم المسارات التي استقبلت القيمة texDiffuse.a أكبر من 0.5 بتنفيذ الأسطر 6 و 7 كما هو متوقع منها. أما المسارات الأخرى، فستقوم هي الأخرى أيضاً بتنفيذ نفس الأسطر! لكن مع تفصيلة إضافية صغيرة: ستقوم بحماية السجلات من الكتابة أثناء تنفيذها (write-protection أو بالمصطلح الأدق predication) مما يعني أنه في النهاية لن يؤثر تنفيذ الأسطر 6 و 7 على صحة الحسابات. بهذا الشكل يستطيع معالج الرسوميات الحفاظ على التزامن في تنفيذ التعليمات حتى في الإجراءات التي تحوي تعلميات شرطية كما في المثال السابق.
الخلاصة: هذا هو السبب في أن الجمل الشرطية في مظلل البكسلات تسبب بطءاً إضافياً في الكثير من الحالات، وينصح بتفاديها إن أمكن.
من الجدير بالذكر أن مظلل الرؤوس لا يشكو من هذه الآفة إلا في بعض المعالجات التي تستخدم نفس الدارات الإلكترونية لتشغيل كلاً من مظلل الرؤوس والبكسلات (معمارية موحدة). لمن يحب أن يستزيد في هذا المجال، أدعوكم لقراءة هذه المقالة عن معمارية بطاقة جيفورس 6 من شركة إنفيديا (القسم 30.5.3 من المقالة يتحدث عما ذكرناه للتو).
في النهاية، قد تبدو هذه المعلومات من شأن المهووسين فقط، لكن مستخدمي مظلل الحساب (Compute Shader) أو مكتبة كودا (CUDA) وما شابهها يعرفون هذه المعلومات بالضرورة وإلا ما استطاعوا استخدام تلك المكتبات لبناء برامج تستفيد من قدرات معالج الرسوميات في أداء الحسابات العامة (general computations).
لاحقاً، سنتحدث عن البدائل الأقل كلفة من استخدام الجمل الشرطية في المظللات، لكن تلك تدوينة أخرى… ودمتم سالمين