لغز 240 عنصرًا: لماذا يتباطأ كود Rust فجأة؟
\n\nتخيل الموقف: تكتب كودًا عالي الأداء في Rust، كل شيء يعمل بسرعة. تختبر الخوارزمية على مصفوفة من 200 عنصر — ممتاز. تضيف 40 عنصرًا إضافيًا (المجموع 240) — وينخفض الأداء بمقدار 2-3 مرات. يبدو كالسحر؟ لا، إنها الفيزياء. بالأحرى — بنية المعالجات الحديثة.
\n\nعلى Stack Overflow، السؤال "Why is there a large performance impact when looping over an array with 240 or more elements?" حصد آلاف المشاهدات وأصبح كلاسيكيًا. في عام 2025، مع ظهور بنى دقيقة جديدة وتعقيد هرمية الكاش، فهم هذه المشكلة ضروري لأي مطور Rust.
\n\nفي هذا المقال، لن نعيد سرد الإجابة فقط. بل سنعرض كيف تبدو المشكلة في Rust، وكيفية تشخيصها، والأهم — كيفية تجنبها باستخدام الممارسات الحديثة لعام 2025.
\n\nجذر المشكلة: أخطاء الكاش (Cache Misses)
\n\nالمعالج لا يعمل مباشرة مع الذاكرة العشوائية (RAM). إنه يستخدم الكاش — ذاكرة فائقة السرعة على الشريحة. يتم تحميل البيانات إلى الكاش في كتل تُسمى cache lines (عادةً 64 بايت).
\n\nسحر الرقم 240 مرتبط بحجم كاش L1 (عادةً 32 كيلوبايت للبيانات) وحجم العنصر. إذا كانت المصفوفة من نوع i32 (4 بايت)، فإن 240 عنصرًا تشغل 960 بايت. يبدو أنها تتسع في L1. لكن المشكلة أن الخوارزمية قد تعمل مع مصفوفتين أو ثلاث في نفس الوقت، أو أن المترجم لا يستطيع توجيه الكود بكفاءة، مما يؤدي إلى أخطاء الكاش (cache misses).
في السؤال الأصلي بلغة C++، كانت العتبة 240 عنصرًا تحديدًا لأنها اصطدمت بخصائص التحميل المسبق (prefetching) وحجم كاش L1/L2.
\n\nكيف يبدو هذا في Rust؟
\n\nلنحاكي الموقف. سنقوم بجمع عناصر المصفوفة، ولكن بخطوة تكسر الكاش.
\n\nuse std::time::Instant;\n\nfn main() {\n // مصفوفة من 240 عنصرًا\n let size = 240;\n let data: Vec<i32> = (0..size).collect();\n \n // تشغيل بارد\n let start = Instant::now();\n let sum: i32 = data.iter().sum();\n let duration = start.elapsed();\n println!("240 عنصرًا (تسلسلي): {:?}", duration);\n \n // الآن نستخدم وصول "سيء" بخطوة 64 (حجم cache line)\n let mut sum2 = 0i32;\n let start = Instant::now();\n for i in (0..size).step_by(64) {\n sum2 += data[i];\n }\n let duration2 = start.elapsed();\n println!("240 عنصرًا (خطوة 64): {:?}", duration2);\n}\n\n\nستتفاجأ، لكن الفرق قد يصل إلى 10 مرات! الحلقة الأولى تستخدم وصولًا تسلسليًا — يتنبأ المعالج ويحمل cache lines مسبقًا. الثانية — وصول عشوائي بخطوة كبيرة تطرد البيانات من الكاش.
\n\nلماذا 240 تحديدًا؟ السبب العميق
\n\nفي عام 2025، تحتوي المعالجات على كاش L1 للبيانات من 32 كيلوبايت إلى 64 كيلوبايت. لكن المشكلة ليست فقط في الحجم. الأمر يتعلق بارتباطية الكاش (set-associative cache).
\n\nتخيل الكاش كمكتبة بها 64 رفًا (sets). على كل رف يمكن وضع 8 كتب (ways). عندما يصل المعالج إلى الذاكرة، يحسب "رقم الرف" بناءً على العنوان. إذا كان الرف يحتوي بالفعل على 8 كتب، يجب التخلص من واحد.
\n\nللمصفوفات التي حجمها مضاعف لقوة العدد 2 (مثل 256)، قد تقع جميع العناصر على نفس "الرف"، مما يسبب أخطاء تعارض الكاش (cache conflict misses). 240 هو 256 ناقص 16، مما يكسر هذا المضاعف و... بشكل متناقض يجعل الوضع أسوأ بسبب خصائص التحميل المسبق.
\n\nاختبار عملي في Rust
\n\nuse std::time::Instant;\n\nfn bench_access(size: usize) {\n let data: Vec<i32> = (0..size as i32).collect();\n \n // نمط "سيء": الوصول إلى كل عنصر 16\n let mut sum = 0i32;\n let start = Instant::now();\n for i in (0..size).step_by(16)