أداء مذهل لخوارزميات C ++ 17 المتوازية. أسطورة أم حقيقة؟

مساء الخير

من خلال دورتنا "C ++ Developer" ، نقدم لك دراسة صغيرة ومثيرة للاهتمام حول الخوارزميات المتوازية.

دعنا نذهب.

مع ظهور خوارزميات متوازية في C ++ 17 ، يمكنك بسهولة تحديث رمز "الحوسبة" والاستفادة من التنفيذ المتوازي. في هذه المقالة ، أريد أن أعتبر خوارزمية STL ، والتي تكشف بشكل طبيعي عن فكرة الحوسبة المستقلة. هل يمكن أن نتوقع تسارع 10x مع معالج 10 النواة؟ أو ربما أكثر؟ او اقل؟ دعنا نتحدث عن ذلك.

مقدمة في الخوارزميات المتوازية



يقدم C ++ 17 إعداد سياسة تنفيذ لمعظم الخوارزميات:

  • sequenced_policy - نوع سياسة التنفيذ ، يستخدم كنوع فريد للتخلص من التحميل الزائد للخوارزمية المتوازية وشرط أن موازاة تنفيذ الخوارزمية المتوازية أمر مستحيل: الكائن العمومي المقابل هو std::execution::seq ؛
  • parallel_policy - نوع سياسة التنفيذ المستخدم كنوع فريد للتخلص من التحميل الزائد للخوارزمية المتوازية والإشارة إلى أن موازاة تنفيذ الخوارزمية المتوازية أمر ممكن: الكائن العمومي المقابل هو std::execution::par ؛
  • parallel_unsequenced_policy - نوع من سياسة التنفيذ يستخدم كنوع فريد للتخلص من التحميل الزائد للخوارزمية المتوازية والإشارة إلى أن التوازي مع ناقل تنفيذ الخوارزمية المتوازية أمران ممكنان: الكائن العمومي المقابل هو std::execution::par_unseq ؛

باختصار:

  • استخدم std::execution::seq للتنفيذ المتسلسل للخوارزمية ؛
  • استخدم std::execution::par لتنفيذ الخوارزمية بشكل متواز (عادةً ما يستخدم بعض تطبيق Thread Pool (مؤشر ترابط))؛
  • استخدم std::execution::par_unseq للتنفيذ المتوازي للخوارزمية مع القدرة على استخدام أوامر vector (على سبيل المثال ، SSE ، AVX).

كمثال سريع ، اتصل std::sort بالتوازي:

 std::sort(std::execution::par, myVec.begin(), myVec.end()); // ^^^^^^^^^^^^^^^^^^^ //   

لاحظ مدى سهولة إضافة معلمة تنفيذ موازية إلى الخوارزمية! ولكن هل سيتم تحقيق تحسن كبير في الأداء؟ هل ستزيد السرعة؟ أم أن هناك أي تباطؤ؟

std::transform بالتوازي std::transform

في هذه المقالة ، أود الانتباه إلى خوارزمية std::transform_reduce for_each ، والتي من المحتمل أن تكون أساسًا للطرق الموازية الأخرى (جنبًا إلى جنب مع std::transform_reduce for_each ، for_each ، scan ، sort ...).

سيتم بناء رمز الاختبار الخاص بنا وفقًا للقالب التالي:

 std::transform(execution_policy, // par, seq, par_unseq inVec.begin(), inVec.end(), outVec.begin(), ElementOperation); 

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

أود تجربة الأشياء التالية:

  • حجم مجال المتجه كبير أو صغير ؛
  • تحويل بسيط يمضي معظم الوقت في الوصول إلى الذاكرة ؛
  • المزيد من العمليات الحسابية (ALU) ؛
  • ALU في سيناريو أكثر واقعية.

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

ملاحظة المعيار

بالنسبة لاختباراتي ، أستخدم Visual Studio 2017 ، 15.8 - نظرًا لأن هذا هو التطبيق الوحيد في تطبيق برنامج التحويل البرمجي الشهير / STL في الوقت الحالي (نوفمبر ، 2018) (دول مجلس التعاون الخليجي في الطريق!). علاوة على ذلك ، ركزت فقط على execution::par ، لأن execution::par_unseq متوفر في MSVC (يعمل بشكل مشابه execution::par ).

يوجد جهازي كمبيوتر:

  • i7 8700 - PC، Windows 10، i7 8700 - 3.2 GHz، 6 خيوط / 12 محور (Hyperthreading)؛
  • i7 4720 - الكمبيوتر المحمول ، ويندوز 10 ، i7 4720 ، 2.6 جيجاهرتز ، 4 نوى / 8 سلاسل (Hyperthreading).

يتم تجميع الشفرة في x64 ، الإصدار أكثر ، يتم تمكين ناقل تلقائي بشكل افتراضي ، كما قمت بتضمين مجموعة موسعة من الأوامر (SSE2) و OpenMP (2.0).

الكود موجود في github: github / fenbf / ParSTLTests / TransformTests / TransformTests.cpp

بالنسبة لبرنامج OpenMP (2.0) ، استخدم التوازي فقط للحلقات:

 #pragma omp parallel for for (int i = 0; ...) 

أركض الكود 5 مرات وانظر إلى الحد الأدنى من النتائج.

تحذير : تعكس النتائج ملاحظات تقريبية فقط ؛ تحقق من النظام / التكوين قبل الاستخدام في الإنتاج. الاحتياجات الخاصة بك والمناطق المحيطة بها قد تختلف عن بلدي.

يمكنك قراءة المزيد حول تطبيق MSVC في هذا المنشور. وإليكم آخر تقرير لبيل أونيل مع CppCon 2018 (قام بيل بتنفيذ STL الموازي في MSVC).

حسنًا ، لنبدأ بأمثلة بسيطة!

تحويل بسيط

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

على سبيل المثال:

 std::transform(std::execution::par, vec.begin(), vec.end(), out.begin(), [](double v) { return v * 2.0; } ); 

يشتمل جهاز الكمبيوتر الخاص بي على 6 أو 4 مراكز ... هل يمكنني توقع تنفيذ تسلسلي 4..6x بشكل أسرع؟ فيما يلي نتائجي (الوقت بالميلي ثانية):

العمليةحجم المتجهاتi7 4720 (4 النوى)i7 8700 (6 النوى)
التنفيذ ::10 ك0.0027630.001924
التنفيذ :: الاسم10 ك0.0098690.008983
openmp موازية ل10 ك0.0031580.002246
التنفيذ ::100 ك0.0513180.028872
التنفيذ :: الاسم100 ك0.0430280.025664
openmp موازية ل100 ك0.0225010.009624
التنفيذ ::1000 ك1.695080.52419
التنفيذ :: الاسم1000 ك1.655610.359619
openmp موازية ل1000 ك1.506780.344863

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

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

لماذا هكذا؟

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

قراءة وكتابة متغير في الذاكرة يستغرق حوالي 2-3 دورات إذا تم تخزينه مؤقتًا ، ومئات الدورات إذا لم يتم تخزينه مؤقتًا.
https://www.agner.org/optimize/optimizing_cpp.pdf

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

المزيد من الحسابات

نظرًا لأن عرض النطاق الترددي للذاكرة أمر حاسم ويمكن أن يؤثر على سرعة الأشياء ... فلنزيد من مقدار الحساب الذي يؤثر على كل عنصر.

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

بادئ ذي بدء ، يمكنني استخدام الدوال المثلثية ، على سبيل المثال ، sqrt(sin*cos) (هذه حسابات شرطية في شكل غير مثالي ، فقط لشغل المعالج).

نحن نستخدم sqrt ، و sin ، و cos ، والتي يمكن أن تأخذ ~ 20 لكل sqrt و ~ 100 لكل وظيفة مثلثية. هذا المبلغ من حساب يمكن أن تغطي تأخير وصول الذاكرة.

لمزيد من المعلومات حول تأخيرات الفريق ، راجع دليل Perf الممتاز من Agner Fogh .

هنا هو رمز الاختبار:

 std::transform(std::execution::par, vec.begin(), vec.end(), out.begin(), [](double v) { return std::sqrt(std::sin(v)*std::cos(v)); } ); 

والآن ماذا؟ هل يمكننا الاعتماد على تحسين الأداء مقارنة بالمحاولة السابقة؟

فيما يلي بعض النتائج (الوقت بالميلي ثانية):

العمليةحجم المتجهاتi7 4720 (4 النوى)i7 8700 (6 النوى)
التنفيذ ::10 ك0.1050050.070577
التنفيذ :: الاسم10 ك0.0556610.03176
openmp موازية ل10 ك0.0963210.024702
التنفيذ ::100 ك1.087550.707048
التنفيذ :: الاسم100 ك0.2593540.17195
openmp موازية ل100 ك0.8984650.189915
التنفيذ ::1000 ك10.51597.16254
التنفيذ :: الاسم1000 ك2.444721.10099
openmp موازية ل1000 ك4.786811.89017

وأخيرا ، نرى أرقام لطيفة :)

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

بالنسبة لـ 100 ألف عنصر ، تكون النتيجة على جهاز كمبيوتر أسرع أفضل تقريبًا 9 مرات من الإصدار التسلسلي (على نحو مماثل لإصدار OpenMP).

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

فريسنل وناقلات ثلاثية الأبعاد

في القسم أعلاه ، استخدمت الحسابات "المخترعة" ، لكن ماذا عن الكود الحقيقي؟
دعونا نحل معادلات فريسنل التي تصف انعكاس وانحناء الضوء من سطح أملس مسطح. هذه طريقة شائعة لتوليد إضاءة واقعية في الألعاب ثلاثية الأبعاد.




صور من ويكيميديا

كمثال جيد ، لقد وجدت هذا الوصف والتنفيذ .

حول استخدام مكتبة GLM

بدلاً من إنشاء تطبيقي الخاص ، استخدمت مكتبة glm . أستخدمه غالبًا في مشاريع OpenGl الخاصة بي.

يمكن الوصول إلى المكتبة بسهولة من خلال Conan Package Manager ، لذلك سأستخدمها أيضًا. رابط إلى الحزمة.

ملف كونان:

 [requires] glm/0.9.9.1@g-truc/stable [generators] visual_studio 

وسطر الأوامر لتثبيت المكتبة (يقوم بإنشاء ملفات الدعائم التي يمكنني استخدامها في مشروع Visual Studio):

 conan install . -s build_type=Release -if build_release_x64 -s arch=x86_64 

تتكون المكتبة من رأس ، بحيث يمكنك تنزيلها يدويًا إذا أردت.

الرمز الفعلي والمعيار

لقد قمت بتكييف رمز glm من scratchapixel.com :

 //    https://www.scratchapixel.com float fresnel(const glm::vec4 &I, const glm::vec4 &N, const float ior) { float cosi = std::clamp(glm::dot(I, N), -1.0f, 1.0f); float etai = 1, etat = ior; if (cosi > 0) { std::swap(etai, etat); } //  sini     float sint = etai / etat * sqrtf(std::max(0.f, 1 - cosi * cosi)); //    if (sint >= 1) return 1.0f; float cost = sqrtf(std::max(0.f, 1 - sint * sint)); cosi = fabsf(cosi); float Rs = ((etat * cosi) - (etai * cost)) / ((etat * cosi) + (etai * cost)); float Rp = ((etai * cosi) - (etat * cost)) / ((etai * cosi) + (etat * cost)); return (Rs * Rs + Rp * Rp) / 2.0f; } 

يستخدم الرمز العديد من الإرشادات الرياضية ، والمنتج القياسي ، والضرب ، والقسمة ، وبالتالي فإن المعالج لديه ما يفعله. بدلاً من الموجه المزدوج ، نستخدم متجهًا يتكون من 4 عناصر لزيادة مقدار الذاكرة المستخدمة.

المعيار:

 std::transform(std::execution::par, vec.begin(), vec.end(), vecNormals.begin(), //   vecFresnelTerms.begin(), //  [](const glm::vec4& v, const glm::vec4& n) { return fresnel(v, n, 1.0f); } ); 

وهنا النتائج (الوقت بالميلي ثانية):

العمليةحجم المتجهاتi7 4720 (4 النوى)i7 8700 (6 النوى)
التنفيذ ::1 ك0.0327640.016361
التنفيذ :: الاسم1 ك0.0311860.028551
openmp موازية ل1 ك0.0055260.007699
التنفيذ ::10 ك0.2467220.169383
التنفيذ :: الاسم10 ك0.0907940.067048
openmp موازية ل10 ك0.0497390.029835
التنفيذ ::100 ك2.497221.69768
التنفيذ :: الاسم100 ك0.5301570.283268
openmp موازية ل100 ك0.4950240.291609
التنفيذ ::1000 ك08/25/2816.9457
التنفيذ :: الاسم1000 ك5.152352.33768
openmp موازية ل1000 ك5.118012.95908

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

بالنسبة لجميع الاختبارات ، أظهرت أيضًا نتائج من OpenMP وتطبيقين: يعمل MSVC و OpenMP بشكل مشابه.

الخاتمة

في هذه المقالة ، نظرت إلى ثلاث حالات لاستخدام خوارزميات الحوسبة المتوازية والخوارزميات المتوازية. قد يبدو استبدال الخوارزميات القياسية بالإصدار std :: implementation :: par مغريًا للغاية ، لكن هذا لا يستحق دائمًا القيام به! قد تعمل كل عملية تستخدمها داخل الخوارزمية بشكل مختلف وتكون أكثر اعتمادًا على المعالج أو الذاكرة. لذلك ، فكر في كل تغيير على حدة.

أشياء يجب تذكرها:

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

شكر خاص إلى JFT للمساعدة في هذا المقال!

انتبه أيضًا إلى مصادري الأخرى حول الخوارزميات المتوازية:


ألقِ نظرة على مقالة أخرى تتعلق بالخوارزميات المتوازية: كيفية زيادة الأداء باستخدام خوارزميات Parallel STL و C ++ 17 من Intel

النهاية

نحن في انتظار التعليقات والأسئلة التي يمكنك تركها هنا أو في مدرسنا على الباب المفتوح .

Source: https://habr.com/ru/post/ar433588/


All Articles