مقدمة: الأعداد الكبيرة في عالم صغير
تخيل: أنت تكتب خوارزمية تشفير، محاكاة لعمليات فيزيائية، أو ببساطة تحسب المسافة إلى أقرب مجرة بالنانومتر. وفجأة تدرك أن i64 القياسي (من -9 كوينتيليون إلى 9 كوينتيليون) لا يكفيك بشكل كارثي. في Rust هناك طوق نجاة - النوع i128. ولكن كيف يعمل على معالج لا يستطيع فيزيائياً التعامل مع أعداد 128 بت؟ هل هو سحر؟
إفساد المفاجأة: لا يوجد سحر، بل هندسة عبقرية. في هذه المقالة، سنشرح كيف يقوم Rust (أو بالأحرى LLVM تحت الغطاء) بمحاكاة i128 على بنية 64 بت. ستتعرف على لماذا هذا ليس بطيئاً كما يبدو، وكيف تكتب كوداً فعالاً بهذه الأعداد الضخمة. هذه ليست نظرية جافة - سننتقل من السؤال إلى التطبيق العملي مع أمثلة.
1. الواقع المادي: لماذا 128 بت تمثل تحدياً
المعالجات الحديثة 64 بت (x86-64, ARM64) تحتوي على سجلات للأغراض العامة بحجم 64 بت. هذا هو حجم الكلمة "الأصلي" لها. للعمل مع عدد 128 بت، يجب على المعالج تنفيذ عدة عمليات على نصفين 64 بت: النصف الأدنى (low) والنصف الأعلى (high).
في Rust، النوع i128 (ونظيره غير الموقّع u128) ليس نوعاً مادياً، بل هو نوع تجميعي. يتم تخزينه في الذاكرة ككلمتين متجاورتين 64 بت. يقوم المترجم (عبر LLVM) بتوليد كود يعالج هاتين الكلمتين كوحدة واحدة.
مثال بسيط:
fn main() { let big: i128 = 123456789012345678901234567890; println!("{}", big);}على مستوى لغة التجميع، سيتحول هذا إلى سلسلة من التعليمات التي تعمل مع سجلين 64 بت (مثل rax و rdx). LLVM قادر على "لصق" هذه العمليات بكفاءة عالية.
2. تشريح عملية الجمع: كيف يجمع Rust i128
لننظر إلى العملية الأساسية - جمع اثنين من i128. سنكتب دالة بسيطة ونلقي نظرة تحت الغطاء باستخدام cargo-show-asm أو Compiler Explorer.
fn add_big(a: i128, b: i128) -> i128 { a + b}لنقم بتجميعها لـ x86-64 (بدون تحسينات، لتكون أوضح). سيقوم LLVM بتوليد كود زائف مثل هذا:
- تحميل الـ 64 بت السفلية من
aإلى السجلrax، والعلوية إلىrdx. - تحميل الـ 64 بت السفلية من
bإلىrbx، والعلوية إلىrcx. - جمع الأجزاء السفلية:
add rax, rbx- النتيجة فيrax، يتم تعيين علم الحمل (CF) في حالة حدوث تجاوز. - جمع الأجزاء العلوية مع مراعاة الحمل:
adc rdx, rcx-adc(add with carry) يضيف 1 إذا تم تعيين علم CF. - النتيجة: الـ 64 بت السفلية في
rax، والعلوية فيrdx.
هذه هي المحاكاة. تعليمتان بدلاً من واحدة. بالنسبة للضرب والقسمة، سيكون الكود أكثر تعقيداً، لكن المبدأ هو نفسه - التقسيم إلى عمليات 64 بت.
3. وماذا عن الأداء؟ الخرافات والواقع
يعتقد الكثيرون أن i128 أبطأ مرتين من i64. هذا ليس صحيحاً تماماً. نعم، كل عملية تتحول إلى 2-3 تعليمات بدلاً من واحدة. لكن المعالجات الحديثة تحتوي على:
- خطوط أنابيب وتنفيذ فائق - يمكنها تنفيذ عدة تعليمات في دورة ساعة واحدة.
- توقع الفروع - مهم بشكل خاص للمقارنات.
- ذاكرة تخزين مؤقت كبيرة - العمل مع الذاكرة لا يتأثر، لأن
i128يشغل فقط 16 بايت.
اختبار عملي (Rust، عمليات حسابية بسيطة في حلقة):
use std::time::Instant;
fn main() { let n = 100_000_000; // i64 let start = Instant::now(); let m