لماذا يكسر العداد 64 بت في الحلقة أداء _mm_popcnt_u64

مدرب بايثون أونلاين للمبتدئين

تعلم Python بسهولة دون إرهاق نظري. حل مهام عملية مع التحقق التلقائي واكتب الكود مباشرة في المتصفح.

ابدأ الدورة

الخطأ الشهير الذي لا يزال يطارد مطوري C++

\n\n

في عام 2013، ظهر سؤال على Stack Overflow حصد مئات الآلاف من المشاهدات وأسس لثقافة حقيقية بين محسّني C++. كان السؤال كالتالي: "لقد استبدلت int بـ uint64_t في عداد الحلقة - وانخفض الأداء 10 مرات. لماذا؟". لم يكن الجواب في السحر، بل في البنية الدقيقة لمعالجات Intel. في عام 2025، لا تزال هذه المشكلة قائمة: الرقائق الحديثة لا تزال تتعثر بسبب تغيير يبدو غير ضار لنوع البيانات.

\n\n

اليوم سنقوم بتحليل هذه المفارقة باستخدام تعليمة _mm_popcnt_u64 (عد البتات الأحادية في كلمة 64 بت)، وسنعرض كودًا حقيقيًا يعاني من هذا الشذوذ، ونقدم توصيات واضحة حول كيفية كتابة كود C++ سريع ويمكن التنبؤ به في عام 2025.

\n\n

جوهر المشكلة: عداد 32 بت مقابل عداد 64 بت

\n\n

تخيل حلقة بسيطة تقوم بجمع عدد البتات الأحادية (popcount) في مصفوفة من الأعداد الصحيحة 64 بت. سنستخدم الدالة الداخلية _mm_popcnt_u64 من مجموعة SSE4.2. إليك نسختان من الكود - الأولى بعداد 32 بت، والثانية بعداد 64 بت:

\n\n
#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.

\n\n

الآن انظر إلى الحلقة مع العداد 64 بت i. يضطر المترجم لاستخدام نفس السجل (مثل rax) لتخزين كل من العداد والنتائج الوسيطة لـ POPCNT. كل تكرار ينشئ سلسلة من التبعيات: لا يمكن أن تبدأ POPCNT التالية حتى تكتمل السابقة، لأن المعالج "يعتقد" أنها تحتاج إلى النتيجة القديمة. هذا يقتل خط الأنابيب.

\n\n

مع العداد 32 بت، يستخدم المترجم سجلات مختلفة (مثل ecx للعداد و rax لتجميع النتيجة)، ولا تنشأ التبعية الخاطئة - يتم تنفيذ POPCNT بالتوازي.

\n\n

تحليل مفصل بلغة التجميع

\n\n

للتأكد، دعنا نرى إلى ماذا يتم ترجمة كل نسخة (GCC 13، الأعلام -O3 -march=native):

\n\n

عداد 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 - نفس السجل المستخدم                

المدونات

توصيات الكتب