1. مقدمة: لماذا نتخلى عن بايثون ونصنع محرك استدلال للذكاء الاصطناعي باستخدام C++؟
في تطوير الذكاء الاصطناعي الحديث، تعتبر بايثون (Python) هي المعيار الفعلي. بفضل أطر العمل القوية مثل PyTorch و TensorFlow، يمكنك بناء وتدريب واستدلال شبكات عصبية معقدة ببضعة أسطر من التعليمات البرمجية. ومع ذلك، وراء كواليس هذه الأطر، تتولى اللغات ذات المستوى المنخفض مثل C++ و CUDA معالجة العمليات الحسابية الثقيلة. إن بايثون تلعب مجرد دور “الصمغ”.
إذن، لماذا نحتاج إلى استبعاد بايثون وبناء محرك استدلال للذكاء الاصطناعي باستخدام C++ بمفردها؟ هناك عدة أسباب قوية لذلك:
- الأداء الفائق وزمن الانتقال المنخفض: يمكن التخلص تمامًا من النفقات الإضافية الناتجة عن القفل العالمي للمترجم (GIL) والنوع الديناميكي في بايثون. خاصة في الأنظمة التي تتطلب العمل في الوقت الفعلي، حيث يكون التأخير بالمللي ثانية أمرًا قاتلًا.
- سهولة النشر: من الصعب للغاية بناء بيئة بايثون (مجموعة ضخمة من المكتبات، جحيم التبعيات) في بيئة المستخدم النهائي. باستخدام C++، كل ما عليك فعله هو توزيع ملف تنفيذي ثنائي واحد مرتبط بشكل ثابت (مثل
.exeأو ثنائيات ELF). - دعم الأجهزة الطرفية: في البيئات ذات الموارد المحدودة مثل الهواتف الذكية، والأجهزة المدمجة، و Raspberry Pi، لا توجد سعة لتشغيل بيئة تشغيل بايثون التي تستهلك جيجابايتات من الذاكرة.
- التحكم المباشر في الأجهزة: توفر C++ إمكانية التحكم في المستوى المنخفض، مثل توقيت تخصيص الذاكرة، والاستخدام الصريح لتعليمات SIMD، وتحسين عمليات نقل الذاكرة مع وحدة المعالجة الرسومية (GPU).
في هذه المقالة، ومن خلال استلهام كبير من بنية مكتبة “GGML” التي طورها Georgi Gerganov، سنغوص في الأعماق التقنية لشرح عملية بناء محرك استدلال من الصفر باستخدام C++ فقط لتشغيل النماذج اللغوية الكبيرة (LLMs) وغيرها.
2. النظرة العامة على بنية محرك الاستدلال
تعد عملية الاستدلال في الذكاء الاصطناعي في جوهرها “سلسلة من حسابات المصفوفات الضخمة”. لتنفيذ ذلك بكفاءة، يجب أن يتكون محرك الاستدلال من المكونات التالية.
- إدارة الموترات (Tensor): إدارة هيكل بيانات المصفوفات متعددة الأبعاد، والخطوة (Stride) لكل بُعد.
- الرسم البياني الحسابي (Computation Graph): تمثيل العمليات الحسابية لكل طبقة من طبقات الشبكة العصبية كرسم بياني موجه غير دوري (DAG).
- ساحة الذاكرة (Memory Arena): آلية إدارة ذاكرة مخصصة مسبقًا لتجنب النفقات الإضافية لتخصيص الذاكرة الديناميكي (
mallocأوnew). - الواجهة الخلفية (Backend): تنفيذ العمليات (النواة/Kernels) المحسّنة لأجهزة معينة مثل CPU أو GPU.
سنقوم ببناء هذه المكونات معًا باستخدام ميزات C++ القوية (مثل القوالب، حساب المؤشرات، و RAII).
3. أسرار إدارة الذاكرة: ساحة الذاكرة (Memory Arena) ومحاذاة SIMD
تعتبر إدارة الذاكرة في محرك الاستدلال من أهم العناصر التي تؤثر بشكل مباشر على الأداء. أثناء الاستدلال، خاصة عند المرور عبر كل طبقة من نموذج Transformer، يتم إنشاء عدد هائل من الموترات الوسيطة. إذا قمنا بتخصيص وتحرير الذاكرة في كل مرة باستخدام malloc القياسي، فسيؤدي ذلك إلى تجزئة الكومة (Heap) وتحويل السياق في نظام التشغيل، مما يتسبب في انخفاض فادح في السرعة.
لذلك، نعتمد نهج “ساحة الذاكرة (Memory Arena)”. تتمثل هذه الطريقة في حساب (أو تحديد) الحد الأقصى للذاكرة المطلوبة في بداية الاستدلال وتخصيصها دفعة واحدة، ثم استقطاع الذاكرة ببساطة عن طريق زيادة المؤشر.
3.1 أهمية المحاذاة (Alignment)
تدعم وحدات المعالجة المركزية (CPUs) الحديثة تعليمات SIMD (تعليمة واحدة، بيانات متعددة). مثل AVX2/AVX-512 في Intel/AMD، و NEON في ARM. تقوم هذه التعليمات بمعالجة 256 بت (32 بايت) أو 512 بت (64 بايت) من البيانات دفعة واحدة، ولكن يجب أن تكون ذاكرة البيانات المعالجة محاذاة (Aligned) عند حدود بايت معينة (عادةً 32 بايت أو 64 بايت).
فيما يلي مثال على تنفيذ ساحة ذاكرة في C++ يأخذ المحاذاة في الاعتبار:
| |
بهذه الطريقة، يتم دائمًا الحصول على الذاكرة من خلال هذه الساحة عند إنشاء الموترات. في كل مرة تنتهي فيها خطوة الاستدلال (مثلًا بعد كل توليد رمز)، يمكنك إعادة استخدام الذاكرة في لمح البصر عن طريق استدعاء reset().
4. سحر هيكل بيانات الموترات والخطوة (Stride)
الموتر هو تعميم لمفاهيم القيم العددية، والمتجهات، والمصفوفات. في التنفيذ، من المهم أن البيانات الفعلية توضع في الذاكرة كـ مصفوفة متجاورة أحادية البعد، بينما نستخدم مفهوم “الخطوة (Stride)” لتفسيرها على أنها متعددة الأبعاد.
| |
تمثل الخطوة nb[i] المسافة بالبايت في الذاكرة بين العناصر المتجاورة في البُعد i.
على سبيل المثال، إذا كانت مصفوفة تحتوي على $M \times N$ عنصر (من نوع FP32، أي 4 بايت لكل عنصر) مخزنة بتنسيق أولوية الصفوف (Row-Major)، فستكون الخطوات كالتالي:
nb[0]= 4 (بايت) : الانتقال في اتجاه العمودnb[1]= $N \times 4$ (بايت) : الانتقال في اتجاه الصف
باستخدام هذا المفهوم، يمكننا إجراء عمليات مثل “المنقول (Transpose)” أو “العرض (View)” ببساطة عن طريق التبديل بين قيم الخطوات دون الحاجة إلى نسخ الذاكرة. هذه طريقة أنيقة وسريعة جدًا.
5. بناء الرسم البياني الحسابي (DAG) والتقييم المؤجل
على غرار PyTorch وغيرها، سيعتمد محرك الاستدلال الخاص بنا على التقييم المؤجل (Lazy Evaluation). بمعنى آخر، عند استدعاء دالة عملية رياضية، لا يتم إجراء الحساب الفعلي حينها، بل يتم فقط بناء الرسم البياني (تبعيات العقد).
| |
سيكون تدفق عملية الاستدلال على النحو التالي:
عند تقييم الرسم البياني (المسار الأمامي)، نستخدم الفرز الطوبولوجي لتنفيذ العمليات بدءًا من العقد التي لا تحتوي على تبعيات. نظرًا لأننا نقوم بالاستدلال فقط، فلا داعي للاحتفاظ بالتدرجات الخاصة بالانتشار الخلفي، مما يجعل إدارة الذاكرة بسيطة للغاية.
6. جوهر الرياضيات والتحسين: ضرب المصفوفات (GEMM)
يتم استهلاك أكثر من 90% من العمليات الحسابية في استدلال الذكاء الاصطناعي في عمليات ضرب المصفوفات (GEMM: General Matrix Multiply). فآلية الانتباه والشبكات العصبية المتقدمة (FFN) التي تُعد جوهر نماذج Transformer، هي في الأساس عمليات ضرب لمصفوفات ضخمة.
حاصل ضرب $C = A B$ (بحجم $M \times N$) لمصفوفتين $A$ (بحجم $M \times K$) و $B$ (بحجم $K \times N$) يُعبر عنه رياضياً كالتالي:
$$ C_{i,j} = \sum_{k=0}^{K-1} A_{i,k} \cdot B_{k,j} $$إذا قمنا بتنفيذ ذلك باستخدام حلقة ثلاثية بسيطة، فسيحدث عدد كبير من إخفاقات ذاكرة التخزين المؤقت، ولن نحصل على أداء جيد إطلاقًا.
6.1 حظر ذاكرة التخزين المؤقت وتحسينات SIMD على وحدة المعالجة المركزية
تتلخص الإستراتيجية الأساسية لتسريع عملية GEMM على وحدة المعالجة المركزية في التالي:
- تجانب الحلقات (Cache Blocking): تقسيم المصفوفات إلى كتل صغيرة تتناسب مع حجم ذاكرة التخزين المؤقت L1/L2 لتسهيل الحسابات.
- تعبئة البيانات: إعادة ترتيب البيانات داخليًا بحيث تصبح أنماط الوصول إلى الذاكرة متسلسلة.
- الاستفادة من SIMD: استخدام تعليمات FMA (الضرب والجمع المدمج) مثل
_mm512_fmadd_psفي AVX-512 لتنفيذ العديد من عمليات الضرب والجمع في دورة ساعة واحدة.
إليك مثال مبسط لعملية الضرب النقطي للمتجهات باستخدام C++ و SIMD Intrinsics:
| |
بهذه التعديلات البسيطة فقط، يمكننا الحصول على زيادة في السرعة تتراوح من عدة أضعاف إلى أكثر من عشرة أضعاف مقارنة بالتنفيذ البسيط.
7. تجاوز حدود الأجهزة: دمج واجهات CUDA و Metal
رغم أن تنفيذ C++ الخالص يعمل بشكل معقول على وحدة المعالجة المركزية، إلا أنه لتشغيل نماذج ضخمة مثل LLM بسرعة عملية، فإن قوة المعالجة المتوازية في وحدة المعالجة الرسومية (GPU) تعتبر ضرورية. لذلك، سنقدم طبقة تجريد للواجهات الخلفية في محركنا.
7.1 تجريد الواجهة الخلفية
باستخدام تعدد الأشكال في C++، يمكننا التبديل بين مُنفذي العمليات الحسابية (Executors).
| |
7.2 تنفيذ الواجهة الخلفية NVIDIA CUDA
للاستفادة من وحدات معالجة الرسوميات من NVIDIA، سنقوم بتنفيذ واجهة خلفية باستخدام امتدادات CUDA C++. من الممكن كتابة نواة خاصة بنا، ولكن فيما يتعلق بضرب المصفوفات، فإن استخدام “cuBLAS”، وهي أفضل مكتبة تقدمها NVIDIA، هو الحل الأمثل.
| |
نظرًا لأن نقل البيانات بين ذاكرة CUDA وذاكرة المضيف (cudaMemcpy) عملية مكلفة للغاية، فمن المهم تصميم النظام بحيث يحتفظ بجميع الأوزان والموترات الوسيطة داخل VRAM قدر الإمكان طوال فترة الاستدلال.
7.3 الواجهة الخلفية لـ Apple Silicon (Metal)
في السنوات الأخيرة، أثبتت شرائح M1/M2/M3 في أجهزة Mac كفاءتها العالية كأجهزة استدلال للذكاء الاصطناعي. والسبب في ذلك يعود إلى “الذاكرة الموحدة”. نظرًا لأن CPU و GPU يشتركان في نفس مساحة الذاكرة، فإننا نستغني تمامًا عن عمليات نقل الذاكرة المكلفة بين المضيف والجهاز عبر ناقل PCIe كما هو الحال في CUDA.
لاستدعاء Metal من C++، نستخدم Objective-C++ (ملفات .mm) كجسر أو نستخدم مكتبة metal-cpp.
يتم كتابة النواة باستخدام Metal Compute Shader.
| |
في بيئة Apple Silicon، تتوفر أيضًا مكتبة محسنة لعمليات ضرب المصفوفات تُسمى MPS. باستخدام هذه المكتبة، يمكننا تحقيق سرعات استدلال مذهلة.
8. المعالجات الخاصة بنماذج Transformer: الانتباه وذاكرة التخزين المؤقت KV
تعتمد أحدث نماذج LLM مثل LLaMA 2/3 و GPT على بنية Transformer. لتنفيذها في C++، من الضروري بناء خوارزمية الانتباه “Scaled Dot-Product Attention” الممثلة في المعادلة التالية:
$$ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$بالإضافة إلى ذلك، في توليد الرموز ذاتي الانحدار، من الضروري الاحتفاظ بنتائج حسابات الرموز السابقة. هذا ما يُسمى “ذاكرة التخزين المؤقت KV”.
يتم تخصيص الذاكرة لذاكرة التخزين المؤقت KV عن طريق حجز مساحة ذاكرة للحد الأقصى لطول السياق مسبقًا في ساحة الذاكرة بطريقة مشابهة للمخزن المؤقت الحلقي. هذا يمنع الحاجة إلى إعادة التخصيص في كل خطوة توليد.
بالنسبة للتشفير الموضعي، سنقوم بتنفيذ “RoPE (Rotary Position Embedding)”. إنها طريقة لتضمين معلومات الموقع كمتجه دوران في مساحة معقدة. إن مفتاح الأداء هنا يكمن في تحسين استدعاء دوال sin و cos في C++.
9. التحسين الفائق من خلال تكميم النموذج (Quantization)
إذا تم تحميل نموذج ضخم (مثل نموذج LLaMA بـ 7 مليارات معلمة) بصيغة FP32، فسيستهلك حوالي 28 جيجابايت من الذاكرة (VRAM) للأوزان فقط. ومع إضافة ذاكرة التخزين المؤقت KV، يمكن أن يتجاوز بسهولة 30 جيجابايت، مما يجعله مستحيلاً للتشغيل على وحدات المعالجة الرسومية العادية.
لذلك، يصبح من الضروري استخدام تقنية “التكميم”. وهذا هو صميم وأهم ما يميز تنسيق GGML.
التكميم هو تقنية لتقليل دقة الأوزان عمدًا:
- FP16 (16-bit): يقلل الحجم إلى النصف. مع عدم وجود انخفاض ملحوظ في الدقة.
- INT8 (8-bit): يقلل الحجم إلى الربع. تدهور طفيف.
- INT4 (4-bit): يقلل الحجم إلى الثُمن. باستخدام عوامل القياس والحجب الخاصة، يمكن إجراء استدلال عملي.
على جانب محرك الاستدلال، تُقرأ الأوزان المضغوطة بصيغة INT4 (أو INT8) من الذاكرة، ومباشرة بعد تحميلها في سجلات CPU أو GPU، يتم فك تكميمها (Dequantize) إلى FP16 أو FP32 لإجراء الحسابات.
بشكل يثير الدهشة، فإن تقليل كمية البيانات المقروءة من الذاكرة حتى لو أدى ذلك لزيادة العمليات الحسابية يجعل العملية أسرع. وذلك لأن عنق الزجاجة لمهام الاستدلال في الأجهزة الحديثة ليس في “قوة الحوسبة” بل في “عرض النطاق الترددي للذاكرة”. باستخدام محرك مبني بـ C++ ومكمم بتقنية INT4، يمكنك تشغيل نماذج LLM محليًا بسلاسة حتى على جهاز MacBook Air بذاكرة 8 جيجابايت.
10. ضبط الأداء: معمارية NUMA وتجمع الخيوط
عند إجراء الاستدلال باستخدام وحدة المعالجة المركزية، يكون تعدد الخيوط أمرًا ضروريًا. ومع ذلك، فإن مجرد بدء تشغيل العديد من std::thread لا يعتبر الحل الأمثل.
في الخوادم الحديثة أو معالجات سطح المكتب المتطورة، يتم اعتماد معمارية NUMA (Non-Uniform Memory Access). الوصول إلى الذاكرة القريبة ماديًا من نواة CPU محددة يكون سريعًا، في حين أن الوصول إلى ذاكرة مرتبطة بمعالج آخر يكون بطيئًا للغاية.
في محركات الاستدلال المتقدمة المكتوبة بـ C++، يتم استخدام التقنيات التالية:
- تثبيت الخيوط (Thread Pinning): تثبيت كل خيط بنواة CPU معينة لمنع تدمير ذاكرة التخزين المؤقت الناتج عن تبديل السياق.
- تخصيص مدرك لـ NUMA: تخصيص الذاكرة على نفس عقدة NUMA التي توجد بها الخيوط التي تعالج تلك البيانات.
- تجمع خيوط لسرقة العمل (Work-Stealing): تقسيم كل عقدة في الرسم البياني الحسابي إلى مهام دقيقة، وتنفيذ مُجدوِل فعّال يسمح للخيوط الخاملة بسرقة المهام وتنفيذها تلقائيًا.
من خلال الاستفادة من هذه التقنيات، يمكننا إبقاء استخدام وحدة المعالجة المركزية قريبًا من 100%، وتحقيق إنتاجية قريبة من القيمة النظرية.
11. الخلاصة: متعة قيادة الذكاء الاصطناعي “بعضلات” C++
بايثون لغة مفيدة حقًا. ومع ذلك، بمجرد الانتقال إلى مرحلة “تشغيل النموذج المكتمل بكفاءة على جميع الأجهزة في العالم الحقيقي”، يحين وقت C++.
إن الشعور بالإنجاز عند رؤية محرك استدلال قمت ببنائه بنفسك من خلال معالجة متتاليات البايت في الذاكرة مباشرة، والضغط على السجلات إلى الحد الأقصى بتعليمات SIMD، والمصارعة مع عرض النطاق الترددي لـ VRAM، وهو يولد نصوصًا طبيعية على سطر الأوامر، هو “فرحة هندسية خالصة” لا يمكن أن تحصل عليها أبدًا بمجرد استدعاء model.generate() في أحد أطر عمل بايثون.
غالبًا ما يُنظر إلى تقنيات الذكاء الاصطناعي كـ “صندوق أسود”، ولكن بكتابة كل شيء بنفسك باستخدام C++ بدءًا من عمليات الموترات وحتى تخصيص الذاكرة، يمكنك فهم الآلية الحقيقية بعمق وكيف “تفكر” النماذج اللغوية الكبيرة.
إذا كان لديك معرفة أساسية بـ C++ واهتمام قوي بتقنيات الذكاء الاصطناعي الحالية، فجرّب التحدي المتمثل في تطوير محرك الاستدلال الخاص بك. ستكون الأكواد المصدرية لمشاريع مثل GGML أو llama.cpp أفضل الكتب المدرسية الحية.
هيا بنا إذن، تخلص من بيئة تشغيل بايثون الثقيلة، ودعنا نُشغّل أحدث تقنيات الذكاء الاصطناعي مستخدمين عضلات C++!
