it-swarm.asia

لماذا هو أسرع لمعالجة مجموعة فرزها من مجموعة غير مصنفة؟

إليك قطعة من رمز C++ تبدو غريبة جدًا. لسبب غريب ، فإن فرز البيانات بأعجوبة يجعل الكود أسرع بست مرات.

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::Rand() % 256;

    // !!! With this, the next loop runs faster
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
  • بدون std::sort(data, data + arraySize); ، يعمل الرمز في 11.54 ثانية.
  • مع البيانات المصنفة ، يتم تشغيل الرمز في 1.93 ثانية.

في البداية ، اعتقدت أن هذا قد يكون مجرد لغة أو شذوذ مترجم. لذلك جربته في Java.

import Java.util.Arrays;
import Java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

مع نتيجة مماثلة إلى حد ما ولكن أقل تطرفا.


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

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

أنت ضحية فرع التنبؤ فشل.


ما هو فرع التنبؤ؟

النظر في تقاطع السكك الحديدية:

 Image showing a railroad junction الصورة بواسطة Mecanismo ، عبر ويكيميديا ​​كومنز. تستخدم تحت CC-By-SA 3.0 الترخيص.

الآن من أجل الجدال ، افترض أن هذا قد عاد في القرن التاسع عشر - قبل المسافة الطويلة أو الاتصال اللاسلكي.

أنت عامل تقاطع وتسمع قطار قادم. ليس لديك أي فكرة عن الطريقة التي من المفترض أن تذهب. أوقف القطار ليسأل السائق عن الاتجاه الذي يريده. ثم قمت بتعيين التبديل بشكل مناسب.

(القطارات ثقيلة ولديها الكثير من الجمود. لذا فهي تستغرق إلى الأبد للبدء وتتباطأ.

هل هناك طريقة أفضل؟ تخمين أي اتجاه سوف يذهب القطار!

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

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


خذ بعين الاعتبار if-statement: على مستوى المعالج ، إنه تعليمة فرعية:

Screenshot of compiled code containing an if statement

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

المعالجات الحديثة معقدة ولديها خطوط أنابيب طويلة. لذلك فهي تأخذ إلى الأبد "الاحماء" و "إبطاء".

هل هناك طريقة أفضل؟ تخمين أي اتجاه سوف يذهب الفرع!

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

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


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

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

وبعبارة أخرى ، تحاول تحديد نمط ومتابعته.هذا هو أكثر أو أقل من طريقة عمل تنبؤات الفرع.

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

مزيد من القراءة: مقال "تنبؤ الفرع" على ويكيبيديا .


كما تم التلميح من الأعلى ، فإن الجاني هو عبارة if-statement:

if (data[c] >= 128)
    sum += data[c];

لاحظ أن البيانات موزعة بالتساوي بين 0 و 255. عندما يتم فرز البيانات ، لن يدخل النصف الأول من التكرار في if-statement. بعد ذلك ، سوف يقومون جميعًا بإدخال if-statement.

هذا سهل للغاية للتنبؤ بالفرع حيث أن الفرع يتبع نفس الاتجاه عدة مرات. حتى عداد التشبع البسيط سيتنبأ الفرع بشكل صحيح باستثناء التكرارات القليلة بعد تبديل الاتجاه.

التصور السريع:

T = branch taken
N = branch not taken

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (easy to predict)

ومع ذلك ، عندما تكون البيانات عشوائية تمامًا ، يصبح مؤشر الفروع عديم الفائدة لأنه لا يمكنه التنبؤ ببيانات عشوائية. وبالتالي ربما سيكون هناك حوالي 50 ٪ سوء تقدير. (ليس أفضل من التخمين العشوائي)

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (completely random - hard to predict)

إذن ما الذي يمكن عمله؟

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

يحل محل:

if (data[c] >= 128)
    sum += data[c];

مع:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

هذا يلغي الفرع ويستبدل به بعض عمليات bitwise.

(لاحظ أن هذا الاختراق ليس مكافئًا تمامًا للبيان if الأصلي. ولكن في هذه الحالة ، يكون صالحًا لجميع قيم الإدخال الخاصة بـ data[].)

المعايير: Core i7 920 @ 3.5 GHz

C++ - Visual Studio 2010 - الإصدار x64

//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587

Java - Netbeans 7.1.1 JDK 7 - x64

//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823

الملاحظات:

  • مع الفرع: هناك فرق كبير بين البيانات المصنفة وغير المصنفة.
  • مع الاختراق: لا يوجد فرق بين البيانات المصنفة وغير المصنفة.
  • في حالة C++ ، يكون الاختراق فعليًا أبطأ منه في الفرع عند فرز البيانات.

قاعدة عامة هي الإبهام لتجنب المتفرعة التي تعتمد على البيانات في الحلقات الحرجة. (كما في هذا المثال)


التحديث:

  • دول مجلس التعاون الخليجي 4.6.1 مع -O3 أو -ftree-vectorize على x64 قادرة على توليد حركة مشروطة. لذلك لا يوجد فرق بين البيانات المصنفة وغير المصنفة - كلاهما سريع.

  • يتعذر على VC++ 2010 إنشاء تحركات مشروطة لهذا الفرع حتى تحت /Ox.

  • إنتل المترجم 11 يفعل شيئا معجزة. يقوم بتبادل الحلقتين ، وبالتالي رفع الفرع الذي لا يمكن التنبؤ به إلى الحلقة الخارجية. لذلك ، فهي ليست فقط محصنة من الأفكار الخاطئة ، بل إنها أسرع مرتين من أي شيء يمكن أن يولده VC++ و GCC! وبعبارة أخرى ، استفادت المحكمة الجنائية الدولية من حلقة الاختبار لهزيمة المؤشر ...

  • إذا أعطيت Intel Compiler الكود بدون فروع ، فسيقوم بتوجيهه خارج اليمين ... وبسرعة تامة كما هو الحال مع الفرع (مع تبادل الحلقة).

هذا يدل على أنه حتى المجمعين الحديثين الناضجين يمكن أن يتغيروا بشكل كبير في قدرتهم على تحسين الكود ...

30104
Mysticial

التنبؤ فرع.

مع صفيف مفروزة ، الشرط data[c] >= 128 هو أولاً false لسلسلة من القيم ، ثم يصبح true لجميع القيم اللاحقة. هذا سهل التنبؤ. مع مجموعة غير مصنفة ، تدفعه مقابل تكلفة التفريعات.

3879
Daniel Fischer

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

الآن ، إذا نظرنا إلى الكود

if (data[c] >= 128)
    sum += data[c];

يمكننا أن نجد أن معنى هذا الفرع الخاص بـ if... else... هو إضافة شيء ما عندما يتم استيفاء شرط ما. يمكن تحويل هذا النوع من الفروع بسهولة إلى نقل مشروط عبارة ، والتي سيتم تجميعها في تعليمة نقل مشروط: cmovl ، في نظام x86. الفرع وبالتالي يتم إزالة عقوبة التنبؤ فرع المحتملة.

في C ، وبالتالي C++ ، فإن العبارة ، التي ستجمع مباشرة (دون أي تحسين) في تعليمة النقل الشرطي في x86 ، هي المشغل الثلاثي ... ? ... : .... لذا ، نعيد كتابة العبارة أعلاه إلى ما يعادلها:

sum += data[c] >=128 ? data[c] : 0;

مع الحفاظ على قابلية القراءة ، يمكننا التحقق من عامل التسريع.

في وضع Intel Core i7 - 2600K @ 3.4 جيجا هرتز ووضع إصدار Visual Studio 2010 ، فإن المعيار هو (يتم نسخ التنسيق من Mysticial):

x86

//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

x64

//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

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

الآن دعونا ننظر عن كثب من خلال التحقيق في x86 التجميع الذي يقومون بإنشائه. للبساطة ، نستخدم وظيفتين max1 و max2.

max1 يستخدم الفرع الشرطي if... else ...:

int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

max2 يستخدم المشغل الثلاثي ... ? ... : ...:

int max2(int a, int b) {
    return a > b ? a : b;
}

على جهاز x86-64 ، يقوم GCC -S بإنشاء التجميع أدناه.

:max1
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax
    jle     .L2
    movl    -4(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp     .L4
.L2:
    movl    -8(%rbp), %eax
    movl    %eax, -12(%rbp)
.L4:
    movl    -12(%rbp), %eax
    leave
    ret

:max2
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    %eax, -8(%rbp)
    cmovge  -8(%rbp), %eax
    leave
    ret

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

فلماذا هذه الخطوة الشرطية أداء أفضل؟

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

في حالة الفرع ، يتم تحديد التعليمة التالية من خلال التعليمة السابقة ، لذلك لا يمكننا عمل خطوط الأنابيب. علينا إما الانتظار أو التنبؤ.

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

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

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

3125
WiSaGaN

إذا كنت مهتمًا بمزيد من التحسينات التي يمكن إجراؤها على هذا الرمز ، فكر في هذا:

بدءا من الحلقة الأصلية:

for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

مع حلقة التبادل ، يمكننا تغيير هذه الحلقة بأمان إلى:

for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

بعد ذلك ، يمكنك أن ترى أن الشرط if ثابت طوال تنفيذ الحلقة i ، بحيث يمكنك رفع if الخروج:

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

بعد ذلك ، ترى أنه يمكن طي الحلقة الداخلية في تعبير واحد ، على افتراض أن نموذج النقطة العائمة يسمح بذلك (/ fp: يتم طرح سريع ، على سبيل المثال)

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

هذا هو أسرع 100000 من قبل

2143
vulcan raven

لا شك أن البعض منا مهتم بطرق تحديد الكود الذي يمثل مشكلة بالنسبة لمتنبئ فرع وحدة المعالجة المركزية. تحتوي أداة Valgrind cachegrind على محاكي للتنبؤ بفرع ، يتم تمكينه باستخدام علامة --branch-sim=yes. تشغيله على الأمثلة في هذا السؤال ، مع تقليل عدد الحلقات الخارجية إلى 10000 وتجميعها مع g++ ، يعطي هذه النتائج:

مرتبة:

==32551== Branches:        656,645,130  (  656,609,208 cond +    35,922 ind)
==32551== Mispredicts:         169,556  (      169,095 cond +       461 ind)
==32551== Mispred rate:            0.0% (          0.0%     +       1.2%   )

غير مصنفة:

==32555== Branches:        655,996,082  (  655,960,160 cond +  35,922 ind)
==32555== Mispredicts:     164,073,152  (  164,072,692 cond +     460 ind)
==32555== Mispred rate:           25.0% (         25.0%     +     1.2%   )

الانتقال لأسفل إلى الإخراج سطرا التي تنتجها cg_annotate نرى للحلقة في السؤال:

مرتبة:

          Bc    Bcm Bi Bim
      10,001      4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .      .  .   .      {
           .      .  .   .          // primary loop
 327,690,000 10,016  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .      .  .   .          {
 327,680,000 10,006  0   0              if (data[c] >= 128)
           0      0  0   0                  sum += data[c];
           .      .  .   .          }
           .      .  .   .      }

غير مصنفة:

          Bc         Bcm Bi Bim
      10,001           4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .           .  .   .      {
           .           .  .   .          // primary loop
 327,690,000      10,038  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .           .  .   .          {
 327,680,000 164,050,007  0   0              if (data[c] >= 128)
           0           0  0   0                  sum += data[c];
           .           .  .   .          }
           .           .  .   .      }

يتيح لك ذلك بسهولة تحديد الخط الإشكالي - في الإصدار غير المصنف ، يتسبب سطر if (data[c] >= 128) في 164،050،007 من فروع الشرطية الخاطئة (Bcm) تحت نموذج تنبؤ الفروع في cachegrind ، في حين أنه يتسبب فقط في 10،006 في الإصدار الفرز.


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

perf stat ./sumtest_sorted

مرتبة:

 Performance counter stats for './sumtest_sorted':

  11808.095776 task-clock                #    0.998 CPUs utilized          
         1,062 context-switches          #    0.090 K/sec                  
            14 CPU-migrations            #    0.001 K/sec                  
           337 page-faults               #    0.029 K/sec                  
26,487,882,764 cycles                    #    2.243 GHz                    
41,025,654,322 instructions              #    1.55  insns per cycle        
 6,558,871,379 branches                  #  555.455 M/sec                  
       567,204 branch-misses             #    0.01% of all branches        

  11.827228330 seconds time elapsed

غير مصنفة:

 Performance counter stats for './sumtest_unsorted':

  28877.954344 task-clock                #    0.998 CPUs utilized          
         2,584 context-switches          #    0.089 K/sec                  
            18 CPU-migrations            #    0.001 K/sec                  
           335 page-faults               #    0.012 K/sec                  
65,076,127,595 cycles                    #    2.253 GHz                    
41,032,528,741 instructions              #    0.63  insns per cycle        
 6,560,579,013 branches                  #  227.183 M/sec                  
 1,646,394,749 branch-misses             #   25.10% of all branches        

  28.935500947 seconds time elapsed

ويمكن أيضا أن تفعل الشرح شفرة المصدر مع التفكيك.

perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
 Percent |      Source code & Disassembly of sumtest_unsorted
------------------------------------------------
...
         :                      sum += data[c];
    0.00 :        400a1a:       mov    -0x14(%rbp),%eax
   39.97 :        400a1d:       mov    %eax,%eax
    5.31 :        400a1f:       mov    -0x20040(%rbp,%rax,4),%eax
    4.60 :        400a26:       cltq   
    0.00 :        400a28:       add    %rax,-0x30(%rbp)
...

انظر البرنامج التعليمي للأداء لمزيد من التفاصيل.

1784
caf

لقد قرأت للتو هذا السؤال وإجاباته ، وأشعر أن الإجابة مفقودة.

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

هذا النهج يعمل بشكل عام إذا:

  1. إنها طاولة صغيرة ومن المحتمل أن يتم تخزينها مؤقتًا في المعالج
  2. تقوم بتشغيل الأشياء في حلقة ضيقة جدًا و/أو يمكن للمعالج تحميل البيانات مسبقًا.

الخلفية ولماذا

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

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

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

أول ما نحتاج إلى معرفته هو ما هو صغير ؟ على الرغم من أن الحجم الأصغر أفضل بشكل عام ، فإن إحدى قواعد التجربة هي الالتزام بجداول البحث التي يبلغ حجمها <= 4096 بايت. كحد أعلى: إذا كان جدول البحث أكبر من 64 كيلو بايت ، فمن المحتمل أن يكون الأمر يستحق إعادة النظر.

بناء الجدول

لذلك اكتشفنا أنه يمكننا إنشاء طاولة صغيرة. الشيء التالي الذي يجب القيام به هو الحصول على وظيفة بحث في المكان. وظائف البحث عادة ما تكون وظائف صغيرة تستخدم عددًا من العمليات الصحيحة الصحيحة (و ، أو ، xor ، shift ، add ، remove ، وربما multiply). تريد ترجمة مدخلاتك بواسطة وظيفة البحث إلى نوع من "المفتاح الفريد" في الجدول الخاص بك ، والذي يمنحك ببساطة إجابة عن كل العمل الذي تريده.

في هذه الحالة:> = 128 تعني أنه يمكننا الحفاظ على القيمة ، <128 تعني أننا نتخلص منها. أسهل طريقة للقيام بذلك هي استخدام "AND": إذا احتفظنا بها ، فإننا مع 7FFFFFFF ؛ إذا أردنا التخلص منه ، فسنحصل على 0. لاحظ أيضًا أن 128 قوة 2 - حتى نتمكن من المضي قدمًا وعمل جدول من الأعداد الصحيحة 32768/128 وملءها بصفر واحد والكثير من و7FFFFFFFF.

اللغات المدارة

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

حسنا ، ليس بالضبط ... :-)

كان هناك بعض العمل على إزالة هذا الفرع للغات المدارة. فمثلا:

for (int i = 0; i < array.Length; ++i)
{
   // Use array[i]
}

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

إذا واجهت مشكلة مع عمليات البحث باللغات المدارة - فالمفتاح هو إضافة رمز & 0x[something]FFF إلى وظيفة البحث الخاصة بك لجعل عملية التحقق من الحدود قابلة للتنبؤ بها - ومشاهدتها تسير بشكل أسرع.

نتيجة هذه الحالة

// Generate data
int arraySize = 32768;
int[] data = new int[arraySize];

Random random = new Random(0);
for (int c = 0; c < arraySize; ++c)
{
    data[c] = random.Next(256);
}

/*To keep the spirit of the code intact, I'll make a separate lookup table
(I assume we cannot modify 'data' or the number of loops)*/

int[] lookup = new int[256];

for (int c = 0; c < 256; ++c)
{
    lookup[c] = (c >= 128) ? c : 0;
}

// Test
DateTime startTime = System.DateTime.Now;
long sum = 0;

for (int i = 0; i < 100000; ++i)
{
    // Primary loop
    for (int j = 0; j < arraySize; ++j)
    {
        /* Here you basically want to use simple operations - so no
        random branches, but things like &, |, *, -, +, etc. are fine. */
        sum += lookup[data[j]];
    }
}

DateTime endTime = System.DateTime.Now;
Console.WriteLine(endTime - startTime);
Console.WriteLine("sum = " + sum);
Console.ReadLine();
1247
atlaste

نظرًا لأنه يتم توزيع البيانات بين 0 و 255 عندما يتم فرز المصفوفة ، فلن يدخل حوالي النصف الأول من التكرار عبارة if- (تتم مشاركة عبارة if أدناه).

if (data[c] >= 128)
    sum += data[c];

السؤال هو: ما الذي يجعل العبارة أعلاه غير منفذة في بعض الحالات كما في حالة البيانات المصنفة؟ هنا يأتي "الفرع المتنبأ". جهاز تنبؤ الفروع عبارة عن دائرة رقمية تحاول تخمين الطريقة التي سيذهب بها الفرع (على سبيل المثال بنية if-then-else) قبل معرفة ذلك بالتأكيد. الغرض من تنبؤ الفرع هو تحسين التدفق في خط أنابيب التعليمات. يلعب المتنبئون بالفرع دورًا مهمًا في تحقيق أداء فعّال!

دعونا نفعل بعض علامات مقاعد البدلاء لفهم ذلك بشكل أفضل

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

دعنا نقيس أداء هذه الحلقة بشروط مختلفة:

for (int i = 0; i < max; i++)
    if (condition)
        sum++;

في ما يلي توقيت الحلقة مع أنماط صحيحة وكاذبة مختلفة:

Condition                Pattern             Time (ms)
-------------------------------------------------------
(i & 0×80000000) == 0    T repeated          322

(i & 0xffffffff) == 0    F repeated          276

(i & 1) == 0             TF alternating      760

(i & 3) == 0             TFFFTFFF…           513

(i & 2) == 0             TTFFTTFF…           1675

(i & 4) == 0             TTTTFFFFTTTTFFFF…   1275

(i & 8) == 0             8T 8F 8T 8F …       752

(i & 16) == 0            16T 16F 16T 16F …   490

يمكن للنمط " السيئ " الصواب والخطأ تقديم بيان if- متغير حتى ست مرات أبطأ من نمط " good "! بطبيعة الحال ، فإن أي نمط جيد وأي سيء يعتمد على الإرشادات الدقيقة التي تم إنشاؤها بواسطة المترجم وعلى المعالج المحدد.

لذلك ليس هناك شك حول تأثير تنبؤ الفرع على الأداء!

1118
Saqlain

تتمثل إحدى طرق تجنب أخطاء التنبؤ بالفرع في إنشاء جدول بحث وفهرسته باستخدام البيانات. ناقش ستيفان دي بروين ذلك في جوابه.

لكن في هذه الحالة ، نعلم أن القيم تقع في النطاق [0 ، 255] ونحن نهتم فقط بالقيم> = 128. وهذا يعني أنه يمكننا بسهولة استخراج جزء واحد يخبرنا ما إذا كنا نريد قيمة أم لا: عن طريق التحول البيانات إلى اليمين 7 بت ، يتم تركنا مع 0 بت أو 1 بت ، ونريد فقط إضافة القيمة عندما يكون لدينا 1 بت. دعنا نسمي هذا الشيء "بت القرار".

باستخدام القيمة 0/1 لبت القرار كمؤشر في صفيف ، يمكننا أن نجعل كودًا سريعًا بنفس الدرجة سواء تم فرز البيانات أم لا. سيضيف رمزنا دائمًا قيمة ، ولكن عندما تكون بت القرار 0 ، سنضيف القيمة في مكان لا يهمنا. إليك الكود:

// Test
clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

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

ولكن في الاختبار الذي أجريته ، كان جدول البحث الصريح أسرع قليلاً من هذا ، ربما لأن الفهرسة في جدول البحث كان أسرع قليلاً من التحول في البتات. يوضح هذا كيفية إعداد الشفرة الخاصة بي واستخدامها جدول البحث (يُسمى lut لـ "جدول LookUp" في الكود). إليك رمز C++:

// declare and then fill in the lookup table
int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

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

يمكن استخدام تقنية الفهرسة في صفيف ، بدلاً من استخدام عبارة if ، لتحديد المؤشر الذي يجب استخدامه. رأيت مكتبة تقوم بتنفيذ الأشجار الثنائية ، وبدلاً من امتلاك مؤشرين مسميين (pLeft و pRight أو ما شابه ذلك) تحتوي على صفيف طوله 2 من المؤشرات واستخدمت تقنية "قرار البت" لتحديد أي منها يتبع. على سبيل المثال ، بدلاً من:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;

ستفعل هذه المكتبة شيئًا مثل:

i = (x < node->value);
node = node->link[i];

فيما يلي رابط إلى هذا الرمز: Red Black Trees ، Confusedled Eternally

1039
steveha

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

بالفعل ، يتم تقسيم الصفيف في منطقة متجاورة مع data < 128 والآخر مع data >= 128. لذلك يجب أن تجد نقطة التقسيم باستخدام البحث المزدوج (باستخدام مقارنات Lg(arraySize) = 15) ، ثم قم بإجراء تراكم مباشر من تلك النقطة.

شيء مثل (لم يتم التحقق منه)

int i= 0, j, k= arraySize;
while (i < k)
{
  j= (i + k) >> 1;
  if (data[j] >= 128)
    k= j;
  else
    i= j;
}
sum= 0;
for (; i < arraySize; i++)
  sum+= data[i];

أو ، أكثر بقليل من التعتيم

int i, k, j= (i + k) >> 1;
for (i= 0, k= arraySize; i < k; (data[j] >= 128 ? k : i)= j)
  j= (i + k) >> 1;
for (sum= 0; i < arraySize; i++)
  sum+= data[i];

الطريقة الأسرع من ذلك ، والتي تعطي تقريبي الحل لكل من الفرز أو غير المصنفة هي: sum= 3137536; (بافتراض توزيع موحد حقًا ، 16384 نموذجًا بالقيمة المتوقعة 191.5) :-)

942
Yves Daoust

يحدث السلوك أعلاه بسبب التنبؤ فرع.

لفهم تنبؤ الفرع ، يجب أولاً فهم خط أنابيب التعليمات :

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

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

  1. إذا - جلب التعليمات من الذاكرة
  2. معرف - فك التعليمات
  3. EX - تنفيذ التعليمات
  4. WB - الكتابة مرة أخرى إلى سجل وحدة المعالجة المركزية

خط أنابيب من 4 مراحل بشكل عام للحصول على إرشادات. 4-stage pipeline in general

بالرجوع إلى السؤال أعلاه ، دعونا ننظر في الإرشادات التالية:

                        A) if (data[c] >= 128)
                                /\
                               /  \
                              /    \
                        true /      \ false
                            /        \
                           /          \
                          /            \
                         /              \
              B) sum += data[c];          C) for loop or print().

بدون تنبؤ الفرع ، سيحدث ما يلي:

لتنفيذ التعليمات B أو التعليمات C ، سيتعين على المعالج الانتظار حتى لا تصل التعليمة A إلى مرحلة EX في خط الأنابيب ، حيث أن قرار الانتقال إلى التعليمات B أو التعليمات C يعتمد على نتيجة التعليمة A. سوف تبدو مثل هذا.

عندما ترجع الحالة true: enter image description here

عندما ترجع الحالة false: enter image description here

نتيجة انتظار نتيجة التعليمة A ، يبلغ إجمالي دورات وحدة المعالجة المركزية (CPU) التي تم إنفاقها في الحالة أعلاه (دون التنبؤ بالفرع ، لكل من الصواب والخطأ) 7.

إذن ما هو التنبؤ الفرع؟

سيحاول تنبؤ الفروع تخمين الطريقة التي سيذهب بها الفرع (بنية if-then-else) قبل أن يكون هذا معروفًا بالتأكيد. لن تنتظر التعليمة A للوصول إلى مرحلة EX لخط الأنابيب ، لكنها ستخمن القرار وتنتقل إلى تلك التعليمات (B أو C في حالة مثالنا).

في حالة التخمين الصحيح ، يبدو خط الأنابيب شيئًا من هذا القبيل: enter image description here

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

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

  1. جميع العناصر أقل من 128
  2. جميع العناصر أكبر من 128
  3. بعض العناصر الجديدة التي تبدأ أقل من 128 وأصبحت فيما بعد أكبر من 128

لنفترض أن المتنبئ سوف يفترض دائمًا الفرع الحقيقي في الجولة الأولى.

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

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

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

765
Harsh Sharma

الجواب الرسمي سيكون من

  1. Intel - تجنب تكلفة سوء تقدير الفروع
  2. Intel - فرع وإعادة تنظيم حلقة لمنع الأخطاء
  3. أوراق علمية - هندسة تنبؤ الفروع
  4. الكتب: J.L. Hennessy، D.A. باترسون: هندسة الكمبيوتر: نهج كمي
  5. مقالات في المنشورات العلمية: T.Y. نعم ، نعم أدلى بات الكثير من هذه التوقعات على فرع.

يمكنك أيضًا أن ترى من هذا المخطط المخطط لماذا يختلط متنبئ الفرع.

 2-bit state diagram

كل عنصر في الكود الأصلي هو قيمة عشوائية

data[c] = std::Rand() % 256;

وبالتالي فإن المتغير سوف يتغير الجوانب مثل ضربة std::Rand().

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


669
Surt

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

if (likely( everything_is_ok ))
{
    /* Do something */
}

أو بالمثل:

if (unlikely(very_improbable_condition))
{
    /* Do something */    
}

كل من likely() و unlikely() هي في الواقع وحدات ماكرو محددة باستخدام شيء مثل __builtin_expect الخاص بدول مجلس التعاون الخليجي لمساعدة المترجم على إدراج رمز التنبؤ لصالح الحالة مع مراعاة المعلومات المقدمة من قبل المستخدم. تدعم دول مجلس التعاون الخليجي المكونات المضمنة الأخرى التي يمكن أن تغير سلوك البرنامج قيد التشغيل أو تنبعث منها إرشادات منخفضة المستوى مثل مسح ذاكرة التخزين المؤقت ، وما إلى ذلك. راجع هذه الوثائق التي تمر ببنيات مجلس التعاون الخليجي المتاحة.

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

634
rkachach

عمليات Boolean المستخدمة بشكل متكرر في C++ إنتاج العديد من الفروع في برنامج المترجمة. إذا كانت هذه الفروع داخل حلقات ويصعب التنبؤ بها ، فيمكنها إبطاء التنفيذ بشكل كبير. يتم تخزين المتغيرات المنطقية كأعداد صحيحة 8 بت مع القيمة 0 لـ false و 1 لـ true.

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

bool a, b, c, d;
c = a && b;
d = a || b;

يتم تطبيق هذا عادةً بواسطة برنامج التحويل البرمجي بالطريقة التالية:

bool a, b, c, d;
if (a != 0) {
    if (b != 0) {
        c = 1;
    }
    else {
        goto CFALSE;
    }
}
else {
    CFALSE:
    c = 0;
}
if (a == 0) {
    if (b == 0) {
        d = 0;
    }
    else {
        goto DTRUE;
    }
}
else {
    DTRUE:
    d = 1;
}

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

char a = 0, b = 1, c, d;
c = a & b;
d = a | b;

يتم استخدام char بدلاً من bool من أجل تمكين استخدام معاملات bitwise (& و |) بدلاً من مشغلي Boolean (&& و ||). عوامل تشغيل bitwise هي إرشادات واحدة تستغرق دورة ساعة واحدة فقط. يعمل المشغل OR (|) حتى إذا كانت a و b تحتوي على قيم أخرى غير 0 أو 1. قد يعطي عامل التشغيل AND (&) و EXCLUSIVE OR عامل التشغيل (^) نتائج غير متناسقة إذا كان للمعاملات قيم أخرى غير 0 و 1.

لا يمكن استخدام ~ لـ NOT. بدلاً من ذلك ، يمكنك إنشاء Boolean NOT على متغير يُعرف بأنه 0 أو 1 بواسطة XOR'ing مع 1:

bool a, b;
b = !a;

يمكن تحسينها من أجل:

char a = 0, b;
b = a ^ 1;

لا يمكن استبدال a && b بـ a & b إذا كان b تعبيرًا يجب عدم تقييمه إذا كان afalse (لن يتم تقييم &&b ، سوف &). وبالمثل ، لا يمكن استبدال a || b بـ a | b إذا كان b تعبيرًا لا يجب تقييمه إذا كان atrue.

يكون استخدام عوامل التشغيل bitwise أكثر فائدة إذا كانت المعاملات هي متغيرات أكثر من كون المعاملات مقارنات:

bool a; double x, y, z;
a = x > y && z < 5.0;

هو الأمثل في معظم الحالات (إلا إذا توقعت أن تعبير && لإنشاء العديد من التوقعات الخاطئة للفرع).

603
Maciej

بالتأكيد!...

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

إذا تم فرز المصفوفة ، فشرطتك خاطئة في الخطوة الأولى: data[c] >= 128 ، ثم تصبح قيمة حقيقية لكامل الطريق إلى نهاية الشارع. هذه هي الطريقة التي تصل بها إلى نهاية المنطق بشكل أسرع. من ناحية أخرى ، باستخدام صفيف لم يتم فرزه ، فأنت بحاجة إلى الكثير من الدوران والمعالجة ، مما يجعل تشغيل الكود أبطأ بالتأكيد ...

انظر إلى الصورة التي قمت بإنشائها لك أدناه. ما الشارع الذي سيتم الانتهاء منه بشكل أسرع؟

 Branch Prediction

برمجيًا ، التنبؤ بالفرع يتسبب في أن تكون العملية أبطأ ...

في النهاية أيضًا ، من الجيد أن نعرف أن لدينا نوعين من تنبؤات الفروع بأن كل منهما سيؤثر على الشفرة بشكل مختلف:

1. ثابت

2. ديناميكي

 Branch Prediction

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

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

280
Alireza

تم بالفعل الإجابة على هذا السؤال بشكل ممتاز مرات عديدة. ما زلت أرغب في لفت انتباه المجموعة إلى تحليل آخر مثير للاهتمام.

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

الرابط هنا: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm

262
ForeverLearning

كما ذكرنا بالفعل من قبل الآخرين ، ما وراء الغموض هو فرع التنبؤ .

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

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

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

 figure 1

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

  1. بدون فرع التنبؤ.

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

المثال يحتوي على ثلاثة تعليمات وأول واحد هو تعليمة قفزة مشروطة. يمكن أن يذهب الإرشادان الأخيران إلى خط الأنابيب حتى يتم تنفيذ تعليمة الانتقال الشرطي.

 without branch predictor

سوف يستغرق 9 دورات على مدار الساعة لاستكمال 3 تعليمات.

  1. استخدام فرع التوقع ولا تأخذ قفزة مشروطة. لنفترض أن التوقع هو لا أخذ القفزة الشرطية.

 enter image description here

سوف يستغرق 7 دورات على مدار الساعة لاستكمال 3 تعليمات.

  1. استخدام فرع التوقع واتخاذ قفزة مشروطة. لنفترض أن التوقع هو لا أخذ القفزة الشرطية.

 enter image description here

سوف يستغرق 9 دورات على مدار الساعة لاستكمال 3 تعليمات.

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

كما ترون ، يبدو أنه ليس لدينا سبب لعدم استخدام Branch Predictor.

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

176
Gearon

مكسب التنبؤ بالفرع!

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

if (expression)
{
    // Run 1
} else {
    // Run 2
}

كلما كان هناك عبارة if-else\switch ، يجب تقييم التعبير لتحديد أي كتلة يجب تنفيذها. في رمز التجميع الذي تم إنشاؤه بواسطة برنامج التحويل البرمجي ، يتم إدراج التعليمات الشرطية التعليمات.

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

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

التصور:

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

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

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------
168
Tony Tannous

إلى جانب حقيقة أن التنبؤ بالفرع قد يبطئك ، فإن للصفيف المصنف ميزة أخرى:

يمكن أن يكون لديك شرط إيقاف بدلاً من مجرد التحقق من القيمة ، وبهذه الطريقة تقوم فقط بحلقة فوق البيانات ذات الصلة ، وتجاهل الباقي.
لن يفوتك التنبؤ بالفرع مرة واحدة فقط.

 // sort backwards (higher values first), may be in some other part of the code
 std::sort(data, data + arraySize, std::greater<int>());

 for (unsigned c = 0; c < arraySize; ++c) {
       if (data[c] < 128) {
              break;
       }
       sum += data[c];               
 }
107
Yochai Timmer

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

MOV R0, #0     // R0 = sum = 0
MOV R1, #0     // R1 = c = 0
ADR R2, data   // R2 = addr of data array (put this instruction outside outer loop)
.inner_loop    // Inner loop branch label
    LDRB R3, [R2, R1]     // R3 = data[c]
    CMP R3, #128          // compare R3 to 128
    ADDGE R0, R0, R3      // if R3 >= 128, then sum += data[c] -- no branch needed!
    ADD R1, R1, #1        // c++
    CMP R1, #arraySize    // compare c to arraySize
    BLT inner_loop        // Branch to inner_loop if c < arraySize
103
Luke Hutchison

تتم معالجة المصفوفات المصنفة بشكل أسرع من مصفوفة غير مصنفة ، بسبب ظواهر تسمى التنبؤ الفرعي.

أداة تنبؤ الفروع هي دائرة رقمية (في هندسة الكمبيوتر) تحاول التنبؤ بالطريقة التي سيذهب بها الفرع ، مما يحسن التدفق في خط التعليمات. تتنبأ الدائرة/الكمبيوتر بالخطوة التالية وتنفذها.

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

الجواب على سؤالك بسيط جدا.

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

مصفوفة مصنفة: طريق مستقيم

____________________________________________________________________________________
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

مجموعة غير مصنفة: طريق منحني

______   ________
|     |__|

تنبؤ الفرع: التخمين/التنبؤ بالطريق المستقيم ومتابعته دون التحقق

___________________________________________ Straight road
 |_________________________________________|Longer road

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


أريد أيضًا أن أذكر Simon_Weaver من التعليقات:

لا تقدم تنبؤات أقل - إنها تنبؤات غير صحيحة أقل. لا يزال يتعين على التنبؤ في كل مرة من خلال الحلقة ..

92
Omkaar.K

الافتراض من الإجابات الأخرى التي يحتاج المرء لفرز البيانات غير صحيح.

لا تقوم التعليمة البرمجية التالية بفرز المصفوفة بأكملها ، ولكن فقط شرائح مكونة من 200 عنصر ، وبالتالي يتم تشغيلها بشكل أسرع.

يؤدي فرز أقسام k-element فقط إلى إكمال المعالجة المسبقة في الوقت الخطي بدلاً من n.log(n).

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

    for (unsigned c = 0; c < l; ++c)
        data[c] = std::Rand() % 256;

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i) {
        for (unsigned c = 0; c < sizeof data / sizeof(int); ++c) {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

هذا أيضًا "يثبت" أنه لا علاقة له بأي مشكلة حسابية مثل ترتيب الفرز ، وهو بالفعل تنبؤ فرعي.

14
user2297550

لأنه فرزها!

من السهل استرجاع ومعالجة البيانات المطلوبة من غير المرتبة.

تمامًا مثل طريقة اختيار الملابس من المتاجر (المطلوبة) ومن خزانة الملابس الخاصة بي (عابث).

0
Arun Joshla