الخطأ الشهير الذي لا يزال يطارد مطوري C++
\n\nفي عام 2013، ظهر سؤال على Stack Overflow حصد مئات الآلاف من المشاهدات وأسس لثقافة حقيقية بين محسّني C++. كان السؤال كالتالي: "لقد استبدلت int بـ uint64_t في عداد الحلقة - وانخفض الأداء 10 مرات. لماذا؟". لم يكن الجواب في السحر، بل في البنية الدقيقة لمعالجات Intel. في عام 2025، لا تزال هذه المشكلة قائمة: الرقائق الحديثة لا تزال تتعثر بسبب تغيير يبدو غير ضار لنوع البيانات.
اليوم سنقوم بتحليل هذه المفارقة باستخدام تعليمة _mm_popcnt_u64 (عد البتات الأحادية في كلمة 64 بت)، وسنعرض كودًا حقيقيًا يعاني من هذا الشذوذ، ونقدم توصيات واضحة حول كيفية كتابة كود C++ سريع ويمكن التنبؤ به في عام 2025.
جوهر المشكلة: عداد 32 بت مقابل عداد 64 بت
\n\nتخيل حلقة بسيطة تقوم بجمع عدد البتات الأحادية (popcount) في مصفوفة من الأعداد الصحيحة 64 بت. سنستخدم الدالة الداخلية _mm_popcnt_u64 من مجموعة SSE4.2. إليك نسختان من الكود - الأولى بعداد 32 بت، والثانية بعداد 64 بت:
#include <cstdint>\n#include <vector>\n#include <nmmintrin.h> // من أجل _mm_popcnt_u64\n\n// النسخة 1: عداد 32 بت (سريع)\nuint64_t popcnt_32bit(const std::vector<uint64_t>& data) {\n uint64_t result = 0;\n for (int i = 0; i < data.size(); ++i) { // int — 32 بت\n result += _mm_popcnt_u64(data[i]);\n }\n return result;\n}\n\n// النسخة 2: عداد 64 بت (بطيء)\nuint64_t popcnt_64bit(const std::vector<uint64_t>& data) {\n uint64_t result = 0;\n for (uint64_t i = 0; i < data.size(); ++i) { // uint64_t — 64 بت\n result += _mm_popcnt_u64(data[i]);\n }\n return result;\n}\n\n\nإذا قمت بقياس السرعة على معالج Intel (مثل Core i7-13700K أو حتى Haswell الأقدم)، سيكون الفرق صادمًا: النسخة الثانية قد تكون أبطأ بمقدار 3-10 مرات. بينما على AMD Ryzen لا يوجد هذا الشذوذ - حيث تعمل كلتا النسختين بنفس السرعة.
\n\nلماذا يحدث هذا؟ جذر الشر - التبعية الخاطئة
\n\nتعليمة POPCNT على معالجات Intel (حتى الأحدث منها) لديها تبعية خاطئة (false dependency) على سجل الوجهة. رسميًا، تقوم POPCNT بإعادة كتابة سجل الإخراج بالكامل، ولكن من الناحية المعمارية الدقيقة، يتصرف المعالج كما لو كان يقرأ النتيجة القديمة قبل الكتابة. هذا خطأ (أو ميزة) في تنفيذ Intel.
الآن انظر إلى الحلقة مع العداد 64 بت i. يضطر المترجم لاستخدام نفس السجل (مثل rax) لتخزين كل من العداد والنتائج الوسيطة لـ POPCNT. كل تكرار ينشئ سلسلة من التبعيات: لا يمكن أن تبدأ POPCNT التالية حتى تكتمل السابقة، لأن المعالج "يعتقد" أنها تحتاج إلى النتيجة القديمة. هذا يقتل خط الأنابيب.
مع العداد 32 بت، يستخدم المترجم سجلات مختلفة (مثل ecx للعداد و rax لتجميع النتيجة)، ولا تنشأ التبعية الخاطئة - يتم تنفيذ POPCNT بالتوازي.
تحليل مفصل بلغة التجميع
\n\nللتأكد، دعنا نرى إلى ماذا يتم ترجمة كل نسخة (GCC 13، الأعلام -O3 -march=native):
عداد 32 بت (سريع):
\n.L3:\n popcnt rcx, QWORD PTR [rdx] ; popcnt في rcx (سجل مستقل)\n add rax, rcx ; نجمع مع المجمع\n add rdx, 8 ; إزاحة المؤشر\n cmp rdi, rdx ; مقارنة\n jne .L3\n\n\nعداد 64 بت (بطيء):
\n.L7:\n popcnt rax, QWORD PTR [rdx] ; popcnt في rax - نفس السجل المستخدم