لماذا يبطئ التكرار على مصفوفة من 240 عنصرًا في Rust؟ تحليل

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

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

ابدأ الدورة

لغز 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).

\n\n

في السؤال الأصلي بلغة C++، كانت العتبة 240 عنصرًا تحديدًا لأنها اصطدمت بخصائص التحميل المسبق (prefetching) وحجم كاش L1/L2.

\n\n

كيف يبدو هذا في Rust؟

\n\n

لنحاكي الموقف. سنقوم بجمع عناصر المصفوفة، ولكن بخطوة تكسر الكاش.

\n\n
use 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\n
use 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)                

المدونات

توصيات الكتب