المزامنة في Vulkan

من ويكي
اذهب إلى: تصفح، ابحث

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

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

المعالجة المتوازية والمضيف

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

توثيق معظم الدوال ستجد تحته قسم اسمه Host Synchronization يخبرك أي الكائنات بحاجة للمزامنة عند الوصول المتزامن، في حال غياب هذا القسم فهذا يعني أن الدالة آمنة كما هو الحال مع vkCreateBuffer()[١]، مثلاً الدالة vkBeginCommandBuffer()

  • Host access to commandBuffer must be externally synchronized
  • Host access to the VkCommandPool that commandBuffer was allocated from must be externally synchronized

تعني أنك بحاجة للتأكد من مزامنة الوصول إلى commandBuffer وكذلك لكائن حوض الذاكرة VkCommandPool الذي استخدمته لإنشاء الكائن commandBuffer، هذا المثال جيد لأنه يعرض لنا أنه لايشترط أن تصل الدالة للكائن مباشرة، فرغم أن حوض ذاكرة الأوامر ليس معامل للدالة إلا أن المشغل قد يخزنه داخلياً في الكائن VkCommandBuffer ويستخدمه بطريقة غير مباشرة وستحتاج لمزامنة الوصول إليه، تسمى الحالة الثانية implicit externally synchronized في وثائق Vulkan، ستجد في القسم Threading Behavior في الوثائق قائمة بما يحتاج للمزامنة.

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

لنأخذ أمثلة بسيطة كي تتضح الفكرة:

إنشاء وهدم الكائنات

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

إنشاء الموارد يتطلب غالباً الكائن VkDevice و VkDeviceMemory ولحسن الحظ أن هذين الكائنين آمن استخدامهم في كل الحالات ولست بحاجة لمزامنة الوصول له إلا عند الهدم باستخدام الدالة vkDestroyDevice() و vkFreeMemory() والذي غالباً مايتم في خيط المعالجة الرئيسي عند نهاية البرنامج، وفي معظم الحالات إنشاء الموارد مثل VkBuffer و VkImage لايتطلب مزامنة، يمكننا إذاً إنشاء مجموعة خيوط معالجة مسؤولة عن الإدخال والإخراج وإنشاء وهدم هذه الكائنات، بحيث أن كل كائن يستخدمه خيط معالجة وحيد فقط يقوم مثلاً بإنشاء الكائن وتجهيز بياناته.

بناء أوامر التصيير بالتوازي

لنفترض أننا نريد رسم ثمانية آلاف مجسم، ولدينا معالج من ثمانية أنوية، يمكننا إنشاء ثمانية خيوط معالجة[٢] نوزع الثمانية آلاف مجسم تلك عليها بحيث أن كل خيط معالجة يتولى مسؤولية تسجيل أوامر تصصيير ألف منها، نعطي كل خيط كائن VkCommandPool وكائن VkCommandBuffer واحد خصصناه من VkCommandPool الخاص بخيط المعالجة هذا، لو أنشأنا ثمانية VkCommandBuffer من حوض ذاكرة واحد فإننا سنحتاج لقفل الوصول إلى VkCommandPool ولن نستفيد عملياً من خيوط المعالجة تلك، لكن باستخدام VkCommandPool خاص بكل خيط معالجة فيمكننا تسجيل الأوامر باستقلال ودون الحاجة لمزامنة أي شيء، عند التسجيل والانتهاء من الأوامر لاتحتاج لهدم ذاكرة الأوامر VkCommandBuffer بل يمكنك إعادة ضبطها باستخدام vkResetCommandBuffer() أو استدعاء vkBeginCommandBuffer() مباشرة كونها تقبل أي ذاكرة أوامر ليست في حالة انتظار المعالجة أو حالة التسجيل، أو تكرار إرسال نفس الأوامر.

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

مزامنة الجهاز

المعالجات الرسوميات أجهزة معالجة متوازية قادرة على تنفيذ آلاف  خيوط المعالجة المتوازية في نفس اللحظة، مشغلات الواجهات البرمجية الأقدم مثل OpenGL و Direct3D 11 مسؤولة عن المزامنة وكانت تواجه مشغلاتها مشاكل مع تحديد أي الموارد أو العمليات بحاجة لمزامنة وأيها مستقل، حيث كانت تعتمد على تخمينات قد لاتكون في بعض الأحيان صحيحة وينتج عن هذا إحداث فراغات في المعالجة تقلل من استغلال كامل قدرات الجهاز.

لاتقدم Vulkan سوى ضمانات مزامنة قليلة، أنت عليك كامل مسؤولية المزامنة بين الأوامر والموارد لأنك تستطيع أن تحدد بنفسك معتى تحتاج للمزامنة ومتى لاتحتاج، تقليل المزامنة سيقلل الفراغات في المعالجة ويرفع النسبة الاستفادة من قدرات الجهاز لأعلى مستوى.

المزامنة بين المضيف والجهاز

في البداية لابد أن نفهم طريقة تنفيذ الأوامر في Vulkan، تنفيذ الأوامر في Vulkan غير متزامن مع المضيف، بمعنى أنك لو أرسلت الأوامر عبر vkQueueSubmit() فإن الدالة سترجع فوراً ولن تنتظر تنفيذ الأوامر التي أرسلتها، في حالات نحن بحاجة للمزامنة بين الجهاز و المضيف، لهذا الغرض توفر Vulkan السياج fences، والذي يمكنك من الاستعلام عن انتهاء تنفيذ الأوامر أو إيقاف تنفيذ عملية المضيف إلى أن يتم تنفيذ تلك الأوامر على الجهاز.

المزامنة داخل الجهاز

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

المزامنة بين الأوامر

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

طريقة التنفيذ هذه ليست مشكلة بل على العكس تسرّع تنفيذ المهام وتعمل على تحقيق أقصى استفادة من الجهاز وسد الفراغات في التنفيذ التي يكون فيها الجهاز خامل، لكن في حالات تريد تنفيذ الأوامر بترتيب معين كأن ترسل دفعة الأوامر تجري حسابات تكتب نتيجتها في الذاكرة، وهناك دفعة أوامر تليها تقرأ النتيجة من تلك الذاكرة، الأوامر الثانية قد تقرأ من ذاكرة لم يكتب لها بعد أو تقرأ من ذاكرة تخزين مؤقت سريعة، لمثل هذه الحالات نحن مضطرين للمزامنة [٣].

هناك ثلاثة آليات مزامنة لمزامنة الأوامر داخل طابور المعالجة:

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

مراحل التصيير render-passes وهي طريقة فريدة لوصف مراحل التصيير في Vulkan ليست موجود في باقي الواجهات البرمجية، حتى تصيير أي شيء في Vulkan، يجب أن تنشئ كائن VkRenderPass يمثل عملية التصيير، مرحلة التصيير مجرد حاوية تحتوي تحتوي على مراحل تصيير فرعية subpass، واحدة على الأقل، وتحتوي على ذاكرة الإطار frame buffer التي تمثل حاوية تجمع ذاكرة العمق والشبلونة والصورة النهائية وغيرها من ملحقات الإطار في كائن VkFrameBuffer واحد، تصف مرحلة التصيير طريقة تحميل المحقات مثلاً ما إذا كنت تريد محو محتوياتها أو تحميلها كما هي، وتصف كذلك العلاقة بينها، ويمكن استخدام أكثر من مرحلة فرعية مثلاً مرحلة تصيير الصورة تليها مرحلة معالجة الصورة الناتجة لعمل تأثير ضبابي عليها.

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

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

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

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

الأحداث آلية مزامنة يمكن الاستغناء عنها بأحد أدواة المزامنة السابقة، لكن في حالات قد تريد مثلاً إجراء معالجة على عدة صور سبق أن نقلتها لذاكرة الجهاز، وتريد نقلها لذاكرة المضيف مثلاً لكتابتها على القرص فور انتهاء معالجتها وتوفرها، فبدلاً من إرسال الصور دفعة دفعة واستخدام سياج للانتظار، يمكنك تمرير أوامر المعالجة تلك دفعة واحدة واستخدام الأحداث للتحقق دورياً كونك لاتستطيع الانتظار وأخذ الصور التي انتهت معالجتها وترك باقي الصور التي لم تنتهي، وهكذا إلى أن تنتهي كل الصور من المعالجة، سنتحدث عن الأحداث وتفاصيلها لاحقاً.
  1. انتبه أن الدالة vkDestroyBuffer() غير آمنة وتتطلب مزامنة الوصول للعامل buffer.
  2. إنشاء خيوط المعالجة عملية مكلفة، لذا غالباً يُنشأ حوض من خيوط معالجة thread pool تبقى عاملة في الخلفية في حلقة تكرار تنتظر وصول مهام عبر طابور مثلاً، C++11 ومايليها تدعم خيوط المعالجة باستخدام الفئة std::thread لكنها لاتوفر حوض خيوط معالجة، لذا قد تكتب واحد بنفسك أو تستخدم مكتبة خارجية.
  3. المزامنة قد ينتج عنها فراغات، هذه الفراغات قد تشغلها بإرسال أوامر مستقلة يستخدمها الجهاز لسد تلك الفراغات والاستفادة منها، وحتى لو لم تكن هناك مهام مثلاً للإطار الحالي، يمكنك بدء إرسال المهام الخاصة بالإطار التالي.