مقالات معمّقة حول التكنولوجيا التي تشكّل المستقبل.

تنبؤ الفروع في المعالج: الاختناق الخفي في كودك

يحدد تنبؤ الفروع (Branch Prediction) الأداء في الحلقات الضيقة. كيف تتنبأ المعالجات بالفروع، ولماذا تضر الأخطاء بالأداء، وكيف تكتب كودًا أسرع.

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

هناك إجابة شهيرة على Stack Overflow شاهدها أكثر من 3 مليون مرة. السؤال: لماذا تُعالج المصفوفة المرتبة أسرع من غير المرتبة؟ الكود متطابق، حلقة تكرار بداخلها جملة if. الفرق الوحيد هو هل المصفوفة المدخلة مرتبة أم لا. النسخة المرتبة تعمل أسرع بـ6 مرات. والتفسير، تنبؤ الفروع في المعالج (CPU Branch Prediction)، يكشف واحدًا من أهم عوامل الأداء في الحوسبة الحديثة.

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

لماذا تحتاج المعالجات إلى التنبؤ

المعالجات الحديثة تعمل بنظام الأنابيب (Pipeline): لا تنتظر انتهاء تعليمة قبل بدء التي تليها. عمق الأنبوب في المعالج النموذجي 12-20 مرحلة. وبينما تُنفَّذ تعليمة، تكون حوالي 15 تعليمة تالية قد جُلبت وفُكّت وجُهِّزت. وهذا ما يسمح للمعالج بتنفيذ قرابة تعليمة واحدة في كل دورة ساعة، رغم أن كل تعليمة تستغرق دورات عديدة لإكمالها.

لكن عندما يصادف المعالج فرعًا (if x > 0)، تظهر مشكلة. فالتعليمة التالية تعتمد على ما إذا كان الفرع سيُؤخذ أم لا. ولن يعرف المعالج الجواب لعدة دورات، إذ يجب أن تنتقل نتيجة المقارنة عبر الأنبوب. أمامه خياران: إيقاف الأنبوب والانتظار مع إهدار أكثر من 15 دورة بلا فائدة، أو تخمين اتجاه الفرع والاستمرار في العمل.

المعالجات تخمّن دائمًا. يقوم متنبئ الفروع (Branch Predictor) بتوقع الاتجاه، فيجلب المعالج التعليمات وينفذها على المسار المتوقع. إذا كان التوقع صحيحًا فلا يضيع شيء. أما إذا كان خاطئًا فيجب على المعالج تفريغ التعليمات التي نُفذت بشكل خاطئ، والتخلص من نتائجها، والبدء من المسار الصحيح. هذا «تفريغ الأنبوب» (Pipeline Flush) يكلّف حوالي 15-20 دورة في معالجات x86 الحديثة، أي ما يعادل 15-20 تعليمة من العمل المفقود.

كيف تعمل متنبئات الفروع

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

الحالة البسيطة: الفروع المنحازة

كثير من الفروع منحازة بشدة، أي أنها تسير غالبًا في الاتجاه نفسه. فحص خطأ ينجح في 99.9% من الحالات، أو شرط حلقة يكون صحيحًا لـ999 تكرارًا ثم خاطئًا مرة واحدة. يتعلم متنبئ الفروع هذه الأنماط بسرعة ويحقق دقة قريبة من الكمال. الفرع الذي يسير في الاتجاه نفسه 99% من الوقت لا تتجاوز نسبة أخطائه 1% في أسوأ الأحوال.

التعرف على الأنماط

تتجاوز المتنبئات الحديثة تتبع الانحياز البسيط، فهي تتعرف على الأنماط المتكررة. إذا اتبع فرع النمط «مأخوذ-مأخوذ-غير مأخوذ-مأخوذ-مأخوذ-غير مأخوذ...» (بدورة طولها 3)، فسيتعلم المتنبئ هذا النمط ويتوقعه بشكل صحيح. ويعتمد متنبئ TAGE (TAgged GEometric history length) المستخدم في معالجات Intel وAMD الحديثة على عدة جداول تاريخ بأطوال مختلفة، فيلتقط أنماطًا بدورات من 2 إلى عدة مئات.

الفروع المترابطة

أكثر المتنبئات تقدمًا تتعلم أيضًا الترابط بين الفروع. فإذا كان أخذ الفرع A يعني دائمًا عدم أخذ الفرع B (الذي يأتي لاحقًا في الكود)، يستطيع المتنبئ تعلم هذه العلاقة. ويُسمى هذا «التنبؤ التكيفي ثنائي المستوى» (Two-Level Adaptive Prediction)، وهو يتعامل مع أنماط برمجية مثل:

// These branches are correlated
if (x > 0) {      // Branch A
y = x * 2;
}
// ... some code ...
if (x <= 0) {     // Branch B — always opposite of Branch A
y = -x;
}
// The predictor learns: when A is taken, B is not taken, and vice versa
// Both branches are predicted with near-perfect accuracy

حل لغز المصفوفة المرتبة

نعود إلى سؤال Stack Overflow الشهير. الكود يبدو هكذا:

int sum = 0;
for (int i = 0; i < arraySize; i++) {
if (data[i] >= 128)    // This is the critical branch
sum += data[i];
}
// With SORTED data [0, 1, 2, ..., 127, 128, 129, ..., 255]:
// Branch pattern: NNNNNN...NNNTTTTTT...TTT
// First half: always NOT taken. Second half: always taken.
// Prediction accuracy: ~99.9% (only mispredicts at the transition)
// With UNSORTED data [random values 0-255]:
// Branch pattern: TNNTTNNTTTNNTNNT... (random)
// Prediction accuracy: ~50% (no pattern to learn)
// Every misprediction costs ~15 cycles

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

عند 100,000 تكرار مع نسبة خطأ حوالي 50%، يكون الناتج 50,000 خطأ تنبؤ × 15 دورة = 750,000 دورة مهدرة. على معالج بتردد 3 جيجاهرتز، هذا يعادل ربع ملي ثانية، وهو جزء كبير من وقت التنفيذ الكلي لهذه الحلقة البسيطة.

كم عدد الفروع التي يستطيع معالجك التعامل معها؟

متنبئ الفروع يملك سعة تخزين محدودة. يتتبع التاريخ لعدد محدود من مواقع الفروع باستخدام بنية تُسمى Branch Target Buffer (BTB) وجداول التاريخ المرتبطة بها. المعالجات الحديثة تستطيع تتبع عشرات الآلاف من مواقع الفروع، لكن قواعد الكود الكبيرة قد تتجاوز هذا الحد.

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

نشر Daniel Lemire أبحاثًا ممتازة تقيس سعة متنبئ الفروع عبر معالجات مختلفة. تُظهر النتائج أن معالجات Intel الحديثة (Alder Lake وما بعده) تستطيع التنبؤ بالفروع بفعالية مع تاريخ يصل إلى نحو 200 فرع، أي أن المتنبئ يأخذ في الحسبان نتائج آخر 200 فرع تقريبًا عند التنبؤ بالفرع الحالي. ومعالجات AMD لها سعة مشابهة. أما معالجات ARM فتتفاوت أكثر، وتتميز متنبئات شرائح Apple من سلسلة M بقوة خاصة.

كتابة كود صديق لتنبؤ الفروع

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

استبدل الفروع بالحساب

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

// Branching version:
int max_branch(int a, int b) {
if (a > b) return a;
return b;
}
// Branchless version (compiler often does this automatically):
int max_branchless(int a, int b) {
return a ^ ((a ^ b) & -(a < b));
}
// Conditional move — the compiler's favorite trick:
int max_cmov(int a, int b) {
// Most compilers will emit a CMOV instruction for this
return (a > b) ? a : b;
}
// Clamping with branches:
int clamp_branch(int x, int lo, int hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
// Clamping branchless:
int clamp_branchless(int x, int lo, int hi) {
// min(max(x, lo), hi)
int t = x > lo ? x : lo;  // CMOV
return t < hi ? t : hi;   // CMOV
}

عادةً ما تكون المترجمات الحديثة جيدة في تحويل الشروط البسيطة إلى تعليمات CMOV (Conditional Move)، التي تتجنب الفروع تمامًا. لكنها لا تستطيع دائمًا ذلك، خاصة عندما تحمل الفروع آثارًا جانبية أو عندما يكون المسار «ثم» مكلفًا. وتساعد الأنماط الخالية من الفروع بشكل صريح في الحلقات الساخنة التي لا يولّد فيها المترجم كودًا مثاليًا.

رتّب البيانات قبل المعالجة

إذا كنت ستفلتر البيانات باستخدام فرع، فإن ترتيبها أولًا يجعل الفرع قابلًا للتنبؤ تمامًا. يبدو هذا معاكسًا للمنطق، فالترتيب تكلفته O(n log n)، لكن مع مجموعات البيانات الكبيرة التي تُنفذ فيها حلقة المعالجة مرات عديدة، أو عندما تكون تكلفة خطأ التنبؤ مرتفعة، يعوّض الترتيب تكلفته.

استخدم جداول البحث

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

// Branch-heavy version:
char to_hex(int nibble) {
if (nibble < 10)
return '0' + nibble;
else
return 'a' + nibble - 10;
}
// Lookup table version (no branches):
static const char hex_table[] = "0123456789abcdef";
char to_hex_lut(int nibble) {
return hex_table[nibble];
}

تلميحات للمترجم

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

// Tell the compiler that errors are unlikely
if (__builtin_expect(result == NULL, 0)) {
handle_error();
}
// C++20 provides cleaner syntax:
if (result == NULL) [[unlikely]] {
handle_error();
}
// Linux kernel defines macros for this:
#define likely(x)   __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (unlikely(ptr == NULL)) {
panic("null pointer");
}

قياس سلوك الفروع

يمكنك قياس أداء تنبؤ الفروع مباشرة باستخدام عدادات الأداء في العتاد. على لينكس، يعطيك الأمر perf stat نسب أخطاء التنبؤ لأي برنامج.

# Measure branch prediction statistics
$ perf stat -e branches,branch-misses ./my_program
Performance counter stats for './my_program':
1,482,937,256  branches          # 892.7M/sec
3,271,842  branch-misses     # 0.22% of all branches
# 0.22% misprediction rate — excellent.
# Anything under 1% is good.
# Over 5% in a hot loop means branch prediction is hurting you.
# Over 10% means you should consider branchless alternatives.

في اختبارات الأداء الصغيرة، يمكن لأدوات مثل Google Benchmark تتبع branch-misses لكل تكرار، مما يعطيك تغذية راجعة دقيقة عما إذا كان تغيير الكود قد حسّن سلوك الفروع. وهذا أنفع من زمن الساعة الجداري، لأنه يتضمن ضوضاء من نشاط النظام الآخر.

متى تهتم ومتى لا تهتم

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

أما بقية الكود، كمنطق الأعمال ومعالجات API وعمليات CRUD، فتنبؤ الفروع لا يعنيك فيها. الفروع في موجّه طلبات خادم الويب ليست اختناقك. لا تعيد كتابة سلاسل if/else القابلة للقراءة إلى حساب غير مقروء في كود يُنفَّذ مرة واحدة لكل طلب HTTP. حسّن حيث يخبرك المُحلِّل (Profiler)، لا حيث تقول لك النظرية أن هناك مشكلة محتملة.

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