إليك قطعة من رمز 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 ثانية.في البداية ، اعتقدت أن هذا قد يكون مجرد لغة أو شذوذ مترجم. لذلك جربته في 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);
}
}
مع نتيجة مماثلة إلى حد ما ولكن أقل تطرفا.
كان أول ما فكرت فيه هو أن الفرز يجلب البيانات إلى ذاكرة التخزين المؤقت ، ولكن بعد ذلك فكرت في مدى سخافة ذلك لأن الصفيف تم إنشاؤه للتو.
أنت ضحية فرع التنبؤ فشل.
النظر في تقاطع السكك الحديدية:
الصورة بواسطة Mecanismo ، عبر ويكيميديا كومنز. تستخدم تحت CC-By-SA 3.0 الترخيص.
الآن من أجل الجدال ، افترض أن هذا قد عاد في القرن التاسع عشر - قبل المسافة الطويلة أو الاتصال اللاسلكي.
أنت عامل تقاطع وتسمع قطار قادم. ليس لديك أي فكرة عن الطريقة التي من المفترض أن تذهب. أوقف القطار ليسأل السائق عن الاتجاه الذي يريده. ثم قمت بتعيين التبديل بشكل مناسب.
(القطارات ثقيلة ولديها الكثير من الجمود. لذا فهي تستغرق إلى الأبد للبدء وتتباطأ.
هل هناك طريقة أفضل؟ تخمين أي اتجاه سوف يذهب القطار!
إذا كنت تفكر بشكل صحيح في كل مرة ، فلن يضطر القطار إلى التوقف أبدًا.
إذا كنت تعتقد أن الخطأ كثيرًا ، فسيقضي القطار كثيرًا من الوقت في التوقف والنسخ الاحتياطي وإعادة التشغيل.
خذ بعين الاعتبار if-statement: على مستوى المعالج ، إنه تعليمة فرعية:
أنت معالج وترى فرعًا. ليس لديك أي فكرة عن الطريقة التي سوف تذهب. ماذا تفعل؟ توقف التنفيذ وانتظر حتى تكتمل التعليمات السابقة. ثم تواصل السير في الطريق الصحيح.
المعالجات الحديثة معقدة ولديها خطوط أنابيب طويلة. لذلك فهي تأخذ إلى الأبد "الاحماء" و "إبطاء".
هل هناك طريقة أفضل؟ تخمين أي اتجاه سوف يذهب الفرع!
إذا كنت تفكر في الصواب في كل مرة ، فلن يتوقف التنفيذ مطلقًا.
إذا كنت تعتقد أن الخطأ كثيرًا ، فأنت تقضي وقتًا طويلاً في التوقف والتراجع وإعادة التشغيل.
هذا هو فرع التنبؤ. أعترف أنه ليس أفضل تشبيه لأن القطار يمكن أن يشير فقط إلى الاتجاه بعلم. ولكن في أجهزة الكمبيوتر ، لا يعرف المعالج الاتجاه الذي سيذهب إليه الفرع حتى آخر لحظة.
فكيف تخمين استراتيجيا لتقليل عدد المرات التي يجب أن القطار احتياطي ومتابعة المسار الآخر؟ نظرتم إلى التاريخ الماضي! إذا غادر القطار 99 ٪ من الوقت ، ثم تخمين اليسار. إذا كان البديل ، فأنت تخمين التخمينات الخاصة بك. إذا سارت الأمور في اتجاه واحد كل 3 مرات ، فأنتم تخمنون نفس الشيء ...
وبعبارة أخرى ، تحاول تحديد نمط ومتابعته.هذا هو أكثر أو أقل من طريقة عمل تنبؤات الفرع.
معظم التطبيقات لها فروع حسن التصرف. لذلك فإن المتنبئين بالفرع الحديث سوف يحققون معدلات نجاح تصل إلى 90٪ لكن عند مواجهة فروع غير متوقعة دون وجود أنماط يمكن التعرف عليها ، فإن تنبؤات الفروع تكون عديمة الجدوى تقريبًا.
مزيد من القراءة: مقال "تنبؤ الفرع" على ويكيبيديا .
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
الملاحظات:
قاعدة عامة هي الإبهام لتجنب المتفرعة التي تعتمد على البيانات في الحلقات الحرجة. (كما في هذا المثال)
التحديث:
دول مجلس التعاون الخليجي 4.6.1 مع -O3
أو -ftree-vectorize
على x64 قادرة على توليد حركة مشروطة. لذلك لا يوجد فرق بين البيانات المصنفة وغير المصنفة - كلاهما سريع.
يتعذر على VC++ 2010 إنشاء تحركات مشروطة لهذا الفرع حتى تحت /Ox
.
إنتل المترجم 11 يفعل شيئا معجزة. يقوم بتبادل الحلقتين ، وبالتالي رفع الفرع الذي لا يمكن التنبؤ به إلى الحلقة الخارجية. لذلك ، فهي ليست فقط محصنة من الأفكار الخاطئة ، بل إنها أسرع مرتين من أي شيء يمكن أن يولده VC++ و GCC! وبعبارة أخرى ، استفادت المحكمة الجنائية الدولية من حلقة الاختبار لهزيمة المؤشر ...
إذا أعطيت Intel Compiler الكود بدون فروع ، فسيقوم بتوجيهه خارج اليمين ... وبسرعة تامة كما هو الحال مع الفرع (مع تبادل الحلقة).
هذا يدل على أنه حتى المجمعين الحديثين الناضجين يمكن أن يتغيروا بشكل كبير في قدرتهم على تحسين الكود ...
التنبؤ فرع.
مع صفيف مفروزة ، الشرط data[c] >= 128
هو أولاً false
لسلسلة من القيم ، ثم يصبح true
لجميع القيم اللاحقة. هذا سهل التنبؤ. مع مجموعة غير مصنفة ، تدفعه مقابل تكلفة التفريعات.
السبب في تحسن الأداء بشكل كبير عند فرز البيانات هو إزالة عقوبة التنبؤ بالفرع ، كما هو موضح بشكل جميل في 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). يمكن أن تساعدنا معرفة اختلاف الأداء بين نقل الفرع والشرطية عندما لا يمكن التنبؤ بها على كتابة تعليمات برمجية ذات أداء أفضل عندما يصبح السيناريو معقدًا إلى درجة يتعذر على المحول البرمجي تحسينها تلقائيًا.
إذا كنت مهتمًا بمزيد من التحسينات التي يمكن إجراؤها على هذا الرمز ، فكر في هذا:
بدءا من الحلقة الأصلية:
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 من قبل
لا شك أن البعض منا مهتم بطرق تحديد الكود الذي يمثل مشكلة بالنسبة لمتنبئ فرع وحدة المعالجة المركزية. تحتوي أداة 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)
...
انظر البرنامج التعليمي للأداء لمزيد من التفاصيل.
لقد قرأت للتو هذا السؤال وإجاباته ، وأشعر أن الإجابة مفقودة.
هناك طريقة شائعة لإزالة تنبؤات الفروع التي وجدتها تعمل بشكل جيد في اللغات التي تتم إدارتها وهي البحث عن جدول بدلاً من استخدام الفرع (على الرغم من أنني لم أختبره في هذه الحالة).
هذا النهج يعمل بشكل عام إذا:
الخلفية ولماذا
من وجهة نظر المعالج ، ذاكرتك بطيئة. للتعويض عن الفارق في السرعة ، يتم إنشاء بضع ذاكرة تخزين مؤقت في المعالج (ذاكرة التخزين المؤقت 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();
نظرًا لأنه يتم توزيع البيانات بين 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 "! بطبيعة الحال ، فإن أي نمط جيد وأي سيء يعتمد على الإرشادات الدقيقة التي تم إنشاؤها بواسطة المترجم وعلى المعالج المحدد.
لذلك ليس هناك شك حول تأثير تنبؤ الفرع على الأداء!
تتمثل إحدى طرق تجنب أخطاء التنبؤ بالفرع في إنشاء جدول بحث وفهرسته باستخدام البيانات. ناقش ستيفان دي بروين ذلك في جوابه.
لكن في هذه الحالة ، نعلم أن القيم تقع في النطاق [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
في الحالة التي تم فرزها ، يمكنك أن تفعل ما هو أفضل من الاعتماد على تنبؤ الفرع الناجح أو أي خدعة مقارنة دون فروع: أزل الفرع تمامًا.
بالفعل ، يتم تقسيم الصفيف في منطقة متجاورة مع 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) :-)
يحدث السلوك أعلاه بسبب التنبؤ فرع.
لفهم تنبؤ الفرع ، يجب أولاً فهم خط أنابيب التعليمات :
يتم تقسيم أي تعليمات إلى سلسلة من الخطوات بحيث يمكن تنفيذ خطوات مختلفة بشكل متزامن بالتوازي. تُعرف هذه التقنية باسم خط أنابيب التعليمات ويستخدم هذا لزيادة الإنتاجية في المعالجات الحديثة. لفهم هذا بشكل أفضل ، يرجى الاطلاع على هذا مثال على ويكيبيديا .
بشكل عام ، تحتوي المعالجات الحديثة على خطوط أنابيب طويلة جدًا ، ولكن لننظر إلى هذه الخطوات الأربع فقط.
خط أنابيب من 4 مراحل بشكل عام للحصول على إرشادات.
بالرجوع إلى السؤال أعلاه ، دعونا ننظر في الإرشادات التالية:
A) if (data[c] >= 128)
/\
/ \
/ \
true / \ false
/ \
/ \
/ \
/ \
B) sum += data[c]; C) for loop or print().
بدون تنبؤ الفرع ، سيحدث ما يلي:
لتنفيذ التعليمات B أو التعليمات C ، سيتعين على المعالج الانتظار حتى لا تصل التعليمة A إلى مرحلة EX في خط الأنابيب ، حيث أن قرار الانتقال إلى التعليمات B أو التعليمات C يعتمد على نتيجة التعليمة A. سوف تبدو مثل هذا.
عندما ترجع الحالة true:
عندما ترجع الحالة false:
نتيجة انتظار نتيجة التعليمة A ، يبلغ إجمالي دورات وحدة المعالجة المركزية (CPU) التي تم إنفاقها في الحالة أعلاه (دون التنبؤ بالفرع ، لكل من الصواب والخطأ) 7.
إذن ما هو التنبؤ الفرع؟
سيحاول تنبؤ الفروع تخمين الطريقة التي سيذهب بها الفرع (بنية if-then-else) قبل أن يكون هذا معروفًا بالتأكيد. لن تنتظر التعليمة A للوصول إلى مرحلة EX لخط الأنابيب ، لكنها ستخمن القرار وتنتقل إلى تلك التعليمات (B أو C في حالة مثالنا).
في حالة التخمين الصحيح ، يبدو خط الأنابيب شيئًا من هذا القبيل:
إذا تم الكشف لاحقًا عن أن التخمين كان خاطئًا ، فسيتم تجاهل الإرشادات المنفذة جزئيًا وأن خط الأنابيب يبدأ من جديد مع الفرع الصحيح ، مما يؤدي إلى تأخير. الوقت الضائع في حالة سوء تقدير الفرع يساوي عدد المراحل في خط الأنابيب من مرحلة الجلب إلى مرحلة التنفيذ. تميل المعالجات الدقيقة الحديثة إلى مد خطوط أنابيب طويلة للغاية بحيث يتراوح تأخير سوء التقدير بين 10 و 20 دورة على مدار الساعة. كلما زاد طول خط الأنابيب كلما زادت الحاجة إلى حسن تنبؤ الفرع .
في كود البروتوكول الاختياري ، وهي المرة الأولى التي يكون فيها المشروط ، لا يحتوي متنبئ الفرع على أي معلومات لتأسيس التنبؤ ، لذلك في المرة الأولى التي يختار فيها عشوائيًا التعليمة التالية. في وقت لاحق للحلقة ، يمكن أن تبني التنبؤ على التاريخ. لصفيف مرتبة بترتيب تصاعدي ، هناك ثلاثة احتمالات:
لنفترض أن المتنبئ سوف يفترض دائمًا الفرع الحقيقي في الجولة الأولى.
في الحالة الأولى ، سيستغرق الأمر دائمًا الفرع الحقيقي نظرًا لأن جميع تنبؤاته صحيحة تاريخيا. في الحالة الثانية ، ستتنبأ في البداية بالخطأ ، ولكن بعد تكرار قليل ، ستتنبأ بشكل صحيح. في الحالة الثالثة ، سيتم التنبؤ بشكل صحيح مبدئيًا إلى أن تكون العناصر أقل من 128. وبعد ذلك سوف تفشل لبعض الوقت وتصحح نفسها عندما ترى فشل التنبؤ بالفرع في التاريخ.
في جميع هذه الحالات ، سيكون الفشل أقل من حيث العدد ونتيجة لذلك ، سوف تحتاج فقط إلى عدة مرات لتجاهل الإرشادات المنفذة جزئيًا والبدء من جديد بالفرع الصحيح ، مما يؤدي إلى تقليل عدد دورات وحدة المعالجة المركزية.
ولكن في حالة وجود صفيف عشوائي غير مصنَّف ، سيحتاج التنبؤ إلى تجاهل الإرشادات المنفذة جزئيًا والبدء من جديد بالفرع الصحيح في معظم الأوقات وينتج عنه مزيد من دورات وحدة المعالجة المركزية مقارنةً بالصفيف المصنف.
الجواب الرسمي سيكون من
يمكنك أيضًا أن ترى من هذا المخطط المخطط لماذا يختلط متنبئ الفرع.
كل عنصر في الكود الأصلي هو قيمة عشوائية
data[c] = std::Rand() % 256;
وبالتالي فإن المتغير سوف يتغير الجوانب مثل ضربة std::Rand()
.
من ناحية أخرى ، بمجرد فرزها ، سينتقل المتنبئ أولاً إلى حالة لم يتم التقاطها بقوة وعندما تتغير القيم إلى القيمة العالية التي يمر بها المنبثق في ثلاث تغيرات من لا يتم نقله بشدة إلى مأخوذ بشدة.
في نفس السطر (أعتقد أن هذا لم يتم تسليط الضوء عليه من خلال أي إجابة) ، من الجيد أن نذكر أنه في بعض الأحيان (خاصةً في البرامج التي يكون فيها الأداء مهمًا - كما هو الحال في نواة Linux) ، يمكنك العثور على بعض إذا كانت عبارات مثل ما يلي:
if (likely( everything_is_ok ))
{
/* Do something */
}
أو بالمثل:
if (unlikely(very_improbable_condition))
{
/* Do something */
}
كل من likely()
و unlikely()
هي في الواقع وحدات ماكرو محددة باستخدام شيء مثل __builtin_expect
الخاص بدول مجلس التعاون الخليجي لمساعدة المترجم على إدراج رمز التنبؤ لصالح الحالة مع مراعاة المعلومات المقدمة من قبل المستخدم. تدعم دول مجلس التعاون الخليجي المكونات المضمنة الأخرى التي يمكن أن تغير سلوك البرنامج قيد التشغيل أو تنبعث منها إرشادات منخفضة المستوى مثل مسح ذاكرة التخزين المؤقت ، وما إلى ذلك. راجع هذه الوثائق التي تمر ببنيات مجلس التعاون الخليجي المتاحة.
عادةً ما يتم العثور على هذا النوع من التحسينات بشكل أساسي في تطبيقات الوقت الحقيقي أو الأنظمة المدمجة حيث يكون وقت التنفيذ مهمًا للغاية. على سبيل المثال ، إذا كنت تبحث عن بعض حالات الخطأ التي تحدث 1/10000000 مرة فقط ، فلماذا لا تبلغ المترجم بهذا؟ بهذه الطريقة ، افتراضيًا ، يفترض التنبؤ الفرعي أن الشرط خاطئ.
عمليات 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
تعبيرًا يجب عدم تقييمه إذا كان a
false
(لن يتم تقييم &&
b
، سوف &
). وبالمثل ، لا يمكن استبدال a || b
بـ a | b
إذا كان b
تعبيرًا لا يجب تقييمه إذا كان a
true
.
يكون استخدام عوامل التشغيل bitwise أكثر فائدة إذا كانت المعاملات هي متغيرات أكثر من كون المعاملات مقارنات:
bool a; double x, y, z;
a = x > y && z < 5.0;
هو الأمثل في معظم الحالات (إلا إذا توقعت أن تعبير &&
لإنشاء العديد من التوقعات الخاطئة للفرع).
بالتأكيد!...
فرع التنبؤ يجعل المنطق تشغيل أبطأ ، بسبب التبديل الذي يحدث في التعليمات البرمجية الخاصة بك! يبدو الأمر كما لو كنت تسير في شارع مستقيم أو في شارع به الكثير من المنعطفات ، بالتأكيد سيتم القيام بذلك بشكل مستقيم! ...
إذا تم فرز المصفوفة ، فشرطتك خاطئة في الخطوة الأولى: data[c] >= 128
، ثم تصبح قيمة حقيقية لكامل الطريق إلى نهاية الشارع. هذه هي الطريقة التي تصل بها إلى نهاية المنطق بشكل أسرع. من ناحية أخرى ، باستخدام صفيف لم يتم فرزه ، فأنت بحاجة إلى الكثير من الدوران والمعالجة ، مما يجعل تشغيل الكود أبطأ بالتأكيد ...
انظر إلى الصورة التي قمت بإنشائها لك أدناه. ما الشارع الذي سيتم الانتهاء منه بشكل أسرع؟
برمجيًا ، التنبؤ بالفرع يتسبب في أن تكون العملية أبطأ ...
في النهاية أيضًا ، من الجيد أن نعرف أن لدينا نوعين من تنبؤات الفروع بأن كل منهما سيؤثر على الشفرة بشكل مختلف:
1. ثابت
2. ديناميكي
يستخدم المعالج الدقيق التنبؤ الثابت للفرع في المرة الأولى التي يتم فيها مواجهة الفرع الشرطي ، ويتم استخدام التنبؤ بالفرع الديناميكي لنجاح عمليات التنفيذ لرمز الفرع الشرطي.
من أجل كتابة التعليمات البرمجية الخاصة بك بفعالية للاستفادة من هذه القواعد ، عند كتابة if-else أو switch العبارات ، تحقق من الحالات الأكثر شيوعًا أولاً ثم انتقل تدريجياً إلى الأقل شيوعًا. لا تتطلب الحلقات بالضرورة أي ترتيب خاص للرمز للتنبؤ بالفرع الثابت ، حيث يتم استخدام شرط تكرار الحلقة فقط.
تم بالفعل الإجابة على هذا السؤال بشكل ممتاز مرات عديدة. ما زلت أرغب في لفت انتباه المجموعة إلى تحليل آخر مثير للاهتمام.
تم استخدام هذا المثال مؤخرًا (تم تعديله قليلًا جدًا) كوسيلة لإظهار كيف يمكن تعريف جزء من التعليمات البرمجية داخل البرنامج نفسه على Windows. على طول الطريق ، يوضح المؤلف أيضًا كيفية استخدام النتائج لتحديد المكان الذي يقضي فيه الكود معظم وقته في كل من الحالة المصنفة وغير المصنفة. أخيرًا ، توضح القطعة أيضًا كيفية استخدام ميزة غير معروفة في طبقة تجريد الأجهزة (طبقة تجريد الأجهزة) لتحديد مقدار سوء تقدير الفرع الذي يحدث في الحالة غير المصنفة.
الرابط هنا: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm
كما ذكرنا بالفعل من قبل الآخرين ، ما وراء الغموض هو فرع التنبؤ .
أنا لا أحاول إضافة شيء ما ولكن شرح المفهوم بطريقة أخرى. هناك مقدمة موجزة على الويكي التي تحتوي على النص والرسم البياني. أنا أحب التفسير أدناه الذي يستخدم رسم تخطيطي لتوضيح فرع التنبؤ بشكل حدسي.
في هندسة الكمبيوتر ، فإن أداة التنبؤ بالفرع هي دائرة رقمية تحاول تخمين الطريقة التي سيذهب بها الفرع (على سبيل المثال ، بنية if-then-else) قبل أن يعرف هذا بالتأكيد. الغرض من تنبؤ الفرع هو تحسين التدفق في خط أنابيب التعليمات. يلعب المتنبئون بالفرع دورًا مهمًا في تحقيق أداء فعّال في العديد من تصميمات المعالجات الدقيقة المجهزة بخطوط أنابيب مثل x86.
عادة ما يتم تطبيق التفريع ثنائي الاتجاه بتعليمات القفز الشرطي. يمكن إما "القفزة" الشرطية "ومواصلة التنفيذ مع الفرع الأول من الكود الذي يتبع مباشرة بعد القفزة الشرطية ، أو يمكن" الانتقال "والانتقال إلى مكان مختلف في ذاكرة البرنامج حيث يكون الفرع الثاني من الكود مخزن. من غير المعروف على وجه اليقين ما إذا كانت القفزة الشرطية ستؤخذ أم لا تؤخذ حتى يتم حساب الشرط وقد مرت القفزة الشرطية بمرحلة التنفيذ في خط أنابيب التعليمات (انظر الشكل 1).
بناءً على السيناريو الموصوف ، قمت بكتابة عرض توضيحي للرسوم المتحركة لإظهار كيفية تنفيذ التعليمات في خط أنابيب في مواقف مختلفة.
بدون تنبؤ الفروع ، سيتعين على المعالج الانتظار حتى يتم اجتياز تعليمة الانتقال الشرطي مرحلة التنفيذ قبل أن تتمكن التعليمة التالية من دخول مرحلة الجلب في خط الأنابيب.
المثال يحتوي على ثلاثة تعليمات وأول واحد هو تعليمة قفزة مشروطة. يمكن أن يذهب الإرشادان الأخيران إلى خط الأنابيب حتى يتم تنفيذ تعليمة الانتقال الشرطي.
سوف يستغرق 9 دورات على مدار الساعة لاستكمال 3 تعليمات.
سوف يستغرق 7 دورات على مدار الساعة لاستكمال 3 تعليمات.
سوف يستغرق 9 دورات على مدار الساعة لاستكمال 3 تعليمات.
الوقت الضائع في حالة سوء تقدير الفرع يساوي عدد المراحل في خط الأنابيب من مرحلة الجلب إلى مرحلة التنفيذ. تميل المعالجات الدقيقة الحديثة إلى مد خطوط أنابيب طويلة للغاية بحيث يتراوح تأخير سوء التقدير بين 10 و 20 دورة على مدار الساعة. ونتيجة لذلك ، فإن عمل خط أنابيب أطول يزيد من الحاجة إلى تنبؤ فرع أكثر تقدمًا.
كما ترون ، يبدو أنه ليس لدينا سبب لعدم استخدام Branch Predictor.
إنه عرض بسيط تمامًا يوضح الجزء الأساسي جدًا من تنبؤ الفرع. إذا كانت هذه الصور مزعجة ، فلا تتردد في إزالتها من الإجابة ويمكن للزوار أيضًا الحصول على العرض التوضيحي من git
مكسب التنبؤ بالفرع!
من المهم أن نفهم أن سوء تقدير الفروع لا يؤدي إلى إبطاء البرامج. تكلفة التنبؤ الفائت هي تمامًا كما لو أن تنبؤ الفروع غير موجود وانتظرت تقييم التعبير لتقرر الكود الذي سيتم تشغيله (توضيح إضافي في الفقرة التالية).
if (expression)
{
// Run 1
} else {
// Run 2
}
كلما كان هناك عبارة if-else
\switch
، يجب تقييم التعبير لتحديد أي كتلة يجب تنفيذها. في رمز التجميع الذي تم إنشاؤه بواسطة برنامج التحويل البرمجي ، يتم إدراج التعليمات الشرطية التعليمات.
يمكن أن يتسبب تعليمة الفرع في بدء الكمبيوتر في تنفيذ تسلسل تعليمي مختلف ، وبالتالي الخروج عن السلوك الافتراضي لتنفيذ التعليمات بالترتيب (أي إذا كان التعبير خاطئًا ، يتخطى البرنامج رمز كتلة if
) وفقًا لبعض الشروط ، والتي هو تقييم التعبير في حالتنا.
ومع ذلك ، يحاول المترجم التنبؤ بالنتيجة قبل تقييمها فعليًا. سوف يتم جلب التعليمات من الكتلة if
، وإذا كان التعبير صحيحًا ، فهذا رائع! لقد اكتسبنا الوقت الذي استغرقناه لتقييمه وحققنا تقدمًا في الكود ؛ إذا لم يكن الأمر كذلك ، فنحن نقوم بتشغيل الرمز الخطأ ، ويتم مسح خط الأنابيب ، ويتم تشغيل الكتلة الصحيحة.
لنفترض أنك بحاجة إلى اختيار المسار 1 أو المسار 2. في انتظار شريكك للتحقق من الخريطة ، لقد توقفت عند ## وانتظرت ، أو يمكنك فقط اختيار المسار 1 وإذا كنت محظوظًا (المسار 1 هو المسار الصحيح) ، ثم رائعًا ، لم يكن عليك الانتظار حتى يتحقق شريكك من الخريطة (لقد وفرت الوقت الذي استغرقه للتحقق من الخريطة) ، وإلا فسوف تعود إلى الوراء.
بينما يتم تنظيف خطوط الأنابيب بسرعة فائقة ، فإن أخذ هذه المقامرة في الوقت الحالي يستحق كل هذا العناء. إن التنبؤ بالبيانات المصنفة أو البيانات التي تتغير ببطء أسهل دائمًا وأفضل من التنبؤ بالتغيرات السريعة.
O Route 1 /-------------------------------
/|\ /
| ---------##/
/ \ \
\
Route 2 \--------------------------------
إلى جانب حقيقة أن التنبؤ بالفرع قد يبطئك ، فإن للصفيف المصنف ميزة أخرى:
يمكن أن يكون لديك شرط إيقاف بدلاً من مجرد التحقق من القيمة ، وبهذه الطريقة تقوم فقط بحلقة فوق البيانات ذات الصلة ، وتجاهل الباقي.
لن يفوتك التنبؤ بالفرع مرة واحدة فقط.
// 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];
}
في 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
تتم معالجة المصفوفات المصنفة بشكل أسرع من مصفوفة غير مصنفة ، بسبب ظواهر تسمى التنبؤ الفرعي.
أداة تنبؤ الفروع هي دائرة رقمية (في هندسة الكمبيوتر) تحاول التنبؤ بالطريقة التي سيذهب بها الفرع ، مما يحسن التدفق في خط التعليمات. تتنبأ الدائرة/الكمبيوتر بالخطوة التالية وتنفذها.
يؤدي إجراء التنبؤ الخاطئ إلى العودة إلى الخطوة السابقة ، والتنفيذ بتنبؤ آخر. على افتراض أن التنبؤ صحيح ، فسوف يستمر الرمز إلى الخطوة التالية. يؤدي التنبؤ الخاطئ إلى تكرار نفس الخطوة ، حتى يحدث التنبؤ الصحيح.
الجواب على سؤالك بسيط جدا.
في صفيف لم يتم فرزه ، يقوم الكمبيوتر بإجراء تنبؤات متعددة ، مما يؤدي إلى زيادة فرصة حدوث أخطاء. بينما ، في الترتيب ، يقوم الكمبيوتر بإجراء تنبؤات أقل مما يقلل من فرصة حدوث أخطاء. صنع مزيد من التنبؤ يتطلب المزيد من الوقت.
مصفوفة مصنفة: طريق مستقيم
____________________________________________________________________________________
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT
مجموعة غير مصنفة: طريق منحني
______ ________
| |__|
تنبؤ الفرع: التخمين/التنبؤ بالطريق المستقيم ومتابعته دون التحقق
___________________________________________ Straight road
|_________________________________________|Longer road
على الرغم من أن كلا الطريقين يصلان إلى نفس الوجهة ، إلا أن الطريق المستقيم أقصر ، والآخر أطول. إذا اخترت الآخر عن طريق الخطأ ، فلن يكون هناك عودة إلى الوراء ، وبالتالي ستضيع بعض الوقت الإضافي إذا اخترت الطريق الأطول. هذا مشابه لما يحدث على الكمبيوتر ، وآمل أن يكون هذا ساعدك على فهم أفضل.
أريد أيضًا أن أذكر Simon_Weaver من التعليقات:
لا تقدم تنبؤات أقل - إنها تنبؤات غير صحيحة أقل. لا يزال يتعين على التنبؤ في كل مرة من خلال الحلقة ..
الافتراض من الإجابات الأخرى التي يحتاج المرء لفرز البيانات غير صحيح.
لا تقوم التعليمة البرمجية التالية بفرز المصفوفة بأكملها ، ولكن فقط شرائح مكونة من 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;
}
هذا أيضًا "يثبت" أنه لا علاقة له بأي مشكلة حسابية مثل ترتيب الفرز ، وهو بالفعل تنبؤ فرعي.
لأنه فرزها!
من السهل استرجاع ومعالجة البيانات المطلوبة من غير المرتبة.
تمامًا مثل طريقة اختيار الملابس من المتاجر (المطلوبة) ومن خزانة الملابس الخاصة بي (عابث).