لماذا جمع المصفوفات أسرع في حلقتين من حلقة واحدة؟ تحليل خطأ C++ الشهير
\n\nهل سبق لك أن كتبت كودًا بدا بديهيًا وصحيحًا، لكنه كان بطيئًا؟ ثم، وبالصدفة، قمت بتقسيم حلقة واحدة إلى حلقتين، وحصلت على تسريع بمقدار 2-3 مرات؟ ألم مألوف. يوجد سؤال أسطوري على Stack Overflow جمع آلاف المشاهدات ومئات التعليقات: «لماذا جمع العناصر أسرع بكثير في حلقات منفصلة مقارنة بحلقة واحدة؟». اليوم سنقوم بتحليل هذه الظاهرة بطريقة مبسطة، مع أمثلة كود بلغة C++ وبدون نظريات معقدة.
\n\nهذا ليس سحرًا. إنه أخطاء التخزين المؤقت (cache misses) وكيفية وصول المعالج الفعلي للذاكرة. إذا كنت تكتب بلغة C++ وتريد أن يكون كودك سريعًا جدًا — اقرأ بعناية. في نهاية المقال، ستجد إجراءات عملية محددة يمكنك تطبيقها فورًا.
\n\nجوهر المشكلة: حلقتان مقابل حلقة واحدة
\n\nتخيل أن لدينا مصفوفتين كبيرتين a و b، ونريد إجراء جمع عنصري، ثم ضرب كل عنصر. الحدس يقول: «لنقم بحلقة واحدة، حتى لا نجعل المعالج يذهب ويأتي!».
إليك شكل الحلقة المدمجة:
\n\nconst int N = 1000000;\nfloat a[N], b[N];\n\n// حلقة واحدة — نفعل كل شيء مرة واحدة\nfor (int i = 0; i < N; ++i) {\n a[i] = a[i] + b[i]; // جمع\n a[i] = a[i] * 2.0f; // ضرب\n}\n\nوإليك حلقتين منفصلتين:
\n\n// الحلقة الأولى — جمع فقط\nfor (int i = 0; i < N; ++i) {\n a[i] = a[i] + b[i];\n}\n\n// الحلقة الثانية — ضرب فقط\nfor (int i = 0; i < N; ++i) {\n a[i] = a[i] * 2.0f;\n}\n\nمن المنطقي افتراض أن الخيار الأول أسرع: تكاليف إضافية أقل لتنظيم الحلقة. لكن في الواقع، العكس هو الصحيح — الخيار الثاني غالبًا ما يكون أسرع بمقدار 2-3 مرات. لماذا؟ مرحبًا بكم في عالم التسلسل الهرمي للذاكرة المؤقتة.
\n\nأخطاء التخزين المؤقت: العدو الرئيسي للأداء
\n\nالمعالجات الحديثة لا تصل إلى الذاكرة العشوائية (RAM) مباشرة في كل مرة. إنها تستخدم ذاكرة تخزين مؤقت — ذاكرة سريعة موجودة على الشريحة نفسها. للذاكرة المؤقتة عدة مستويات (L1, L2, L3)، وكل مستوى تالٍ أبطأ ولكن أكبر.
\n\nعندما يقرأ كودك أو يكتب بيانات، يقوم المعالج بتحميل كتلة كاملة (cache line) إلى الذاكرة المؤقتة — عادةً 64 بايت. إذا كان الطلب التالي يقع في نفس الكتلة — فهذه إصابة (hit). إذا لم يكن كذلك — فهذا خطأ (miss)، ويضطر المعالج لانتظار البيانات من RAM البطيئة. خطأ واحد قد يكلف مئات الدورات.
\n\nالآن، لنعد إلى مثالنا. في الحلقة المدمجة، في كل تكرار نصل إلى a[i] و b[i] مرتين: أولاً للقراءة، ثم للكتابة. بينما نعمل مع a[0] و b[0]، تسحب الذاكرة المؤقتة خطوط التخزين الخاصة بهما. لكن في التكرار التالي، نذهب إلى a[1] و b[1] — قد يحدث خطأ إذا كانت البيانات في خطوط تخزين مختلفة. ونتيجة لذلك، نستمر في «سحب» الذاكرة، مما يجبر الذاكرة المؤقتة على إعادة التحميل.
في الحلقتين المنفصلتين، الأمر مختلف تمامًا. الحلقة الأولى تقرأ a[i] و b[i] بشكل تسلسلي وتكتب النتيجة مرة أخرى. البيانات تتحرك خطيًا — الذاكرة المؤقتة يمكن التنبؤ بها وتعمل بشكل مثالي (الموقع المكاني). الحلقة الثانية تمر مرة أخرى على نفس المصفوفة a — البيانات لا تزال ساخنة في الذاكرة المؤقتة L1. لا توجد أخطاء تقريبًا.
أهم الأخطاء التي تقتل الأداء
\n\nالخطأ رقم 1 — خلط العمليات مع مصفوفات مختلفة في حلقة واحدة. إذا كان بإمكانك فصل المنطق إلى تمريرتين (خاصة إذا كانت البيانات كبيرة) — افعل ذلك. الذاكرة المؤقتة ستشكرك.
\n\nالخطأ رقم 2 — تجاهل حجم خط التخزين المؤقت. 64 بايت تعادل تقريبًا 16 float أو 8 double. إذا كانت مصفوفاتك ذات خطوات غريبة أو كنت تقفز بين المؤشرات بشكل غير تسلسلي، فأنت مضمون الحصول على أخطاء تخزين مؤقت.
\n\nالخطأ رقم 3