لماذا جمع المصفوفات أسرع في حلقتين من حلقة واحدة: تحليل أخطاء C++

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

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

ابدأ الدورة

لماذا جمع المصفوفات أسرع في حلقتين من حلقة واحدة؟ تحليل خطأ C++ الشهير

\n\n

هل سبق لك أن كتبت كودًا بدا بديهيًا وصحيحًا، لكنه كان بطيئًا؟ ثم، وبالصدفة، قمت بتقسيم حلقة واحدة إلى حلقتين، وحصلت على تسريع بمقدار 2-3 مرات؟ ألم مألوف. يوجد سؤال أسطوري على Stack Overflow جمع آلاف المشاهدات ومئات التعليقات: «لماذا جمع العناصر أسرع بكثير في حلقات منفصلة مقارنة بحلقة واحدة؟». اليوم سنقوم بتحليل هذه الظاهرة بطريقة مبسطة، مع أمثلة كود بلغة C++ وبدون نظريات معقدة.

\n\n

هذا ليس سحرًا. إنه أخطاء التخزين المؤقت (cache misses) وكيفية وصول المعالج الفعلي للذاكرة. إذا كنت تكتب بلغة C++ وتريد أن يكون كودك سريعًا جدًا — اقرأ بعناية. في نهاية المقال، ستجد إجراءات عملية محددة يمكنك تطبيقها فورًا.

\n\n

جوهر المشكلة: حلقتان مقابل حلقة واحدة

\n\n

تخيل أن لدينا مصفوفتين كبيرتين a و b، ونريد إجراء جمع عنصري، ثم ضرب كل عنصر. الحدس يقول: «لنقم بحلقة واحدة، حتى لا نجعل المعالج يذهب ويأتي!».

\n\n

إليك شكل الحلقة المدمجة:

\n\n
const 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] — قد يحدث خطأ إذا كانت البيانات في خطوط تخزين مختلفة. ونتيجة لذلك، نستمر في «سحب» الذاكرة، مما يجبر الذاكرة المؤقتة على إعادة التحميل.

\n\n

في الحلقتين المنفصلتين، الأمر مختلف تمامًا. الحلقة الأولى تقرأ a[i] و b[i] بشكل تسلسلي وتكتب النتيجة مرة أخرى. البيانات تتحرك خطيًا — الذاكرة المؤقتة يمكن التنبؤ بها وتعمل بشكل مثالي (الموقع المكاني). الحلقة الثانية تمر مرة أخرى على نفس المصفوفة a — البيانات لا تزال ساخنة في الذاكرة المؤقتة L1. لا توجد أخطاء تقريبًا.

\n\n

أهم الأخطاء التي تقتل الأداء

\n\n

الخطأ رقم 1 — خلط العمليات مع مصفوفات مختلفة في حلقة واحدة. إذا كان بإمكانك فصل المنطق إلى تمريرتين (خاصة إذا كانت البيانات كبيرة) — افعل ذلك. الذاكرة المؤقتة ستشكرك.

\n\n

الخطأ رقم 2 — تجاهل حجم خط التخزين المؤقت. 64 بايت تعادل تقريبًا 16 float أو 8 double. إذا كانت مصفوفاتك ذات خطوات غريبة أو كنت تقفز بين المؤشرات بشكل غير تسلسلي، فأنت مضمون الحصول على أخطاء تخزين مؤقت.

\n\n

الخطأ رقم 3

المدونات

توصيات الكتب