> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# ابن نموذج لغة بلغة C# من الصفر (1): محرك التعلم Autograd
- URL: https://arabroot.io/articles/ابن-نموذج-لغة-بلغة-csharp-1-محرك-التعلم/
- Published: 2026-10-05T21:15:00.000Z
- Updated: 2026-10-09T09:42:59.000Z
- Description: نبني بلغة C# ودون أي مكتبة المحرك الذي تتعلم به كل الشبكات العصبية، ونتأكد بالأرقام أن كل عملية فيه صحيحة.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الذكاء الاصطناعي والبيانات, الاستضافة الذاتية

في مقال [كيف تعمل نماذج اللغة الكبيرة LLM](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D9%86%D9%85%D8%A7%D8%B0%D8%AC-%D8%A7%D9%84%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D9%83%D8%A8%D9%8A%D8%B1%D8%A9-llm-%D8%AE%D8%B7%D9%88%D8%A9-%D8%A8%D8%AE%D8%B7%D9%88%D8%A9/) شرحنا الفكرة كاملة: النموذج يحول النص إلى توكنز Tokens ثم إلى أرقام، ويمررها في طبقات من الانتباه Attention، ثم يحسب احتمال كل توكن أن يكون التالي، وكل هذا يعتمد على مليارات الأرقام التي تسمى الأوزان Weights، والتي تعلمها النموذج في مرحلة التدريب. ولكن جملة «تعلمها النموذج» تخفي وراءها السؤال الذي يتوقف عنده أغلب المطورين: كيف يعرف البرنامج في أي اتجاه يغير كل رقم من هذه الأرقام حتى يصبح توقعه أفضل؟ وما الذي يحدث بالضبط عندما تكتب في PyTorch السطر `loss.backward()`؟

والطريقة الأفضل للإجابة على هذا السؤال أن نبني كل شيء بأيدينا، لذلك سوف نبني في هذه السلسلة نموذج لغة كاملاً بلغة C# من الصفر، ودون أي مكتبة خارجية، فلا يوجد TorchSharp ولا ML.NET ولا ONNX ولا أي حزمة NuGet، وإنما مكتبة .NET الأساسية فقط: مصفوفات `float[]` وحلقات و`Parallel.For` و`Vector<float>`، حتى تستطيع أنت كمطور C# أن تقرأ كل سطر وتعدله وتضع عليه نقطة توقف Breakpoint في Visual Studio وترى الأرقام وهي تتغير. وفي النهاية سوف نحمل أوزان GPT-2 الحقيقية من OpenAI ونشغلها بنفس الكود، ونثبت أن مخرجه يطابق مخرج مكتبة Hugging Face.

المواضيع في السلسلة:

- **ابن نموذج لغة بلغة C# من الصفر (1): محرك التعلم والتفاضل التلقائي Autograd** (الموضوع الحالي).
- [أول نموذج لغة يتوقع الحرف التالي](https://arabroot.io/articles/%D8%A7%D8%A8%D9%86-%D9%86%D9%85%D9%88%D8%B0%D8%AC-%D9%84%D8%BA%D8%A9-%D8%A8%D9%84%D8%BA%D8%A9-csharp-2-%D8%A7%D9%88%D9%84-%D9%86%D9%85%D9%88%D8%B0%D8%AC-%D9%84%D8%BA%D8%A9/): مقسم على مستوى الحرف Character Tokenizer للنص العربي، ثم نموذج Bigram بالعد، ثم نفس النموذج كشبكة مدربة، ثم شبكة MLP تنظر إلى آخر ثمانية حروف، والتدريب على شعر المتنبي.
- [GPT صغير بالانتباه Attention والمحول Transformer](https://arabroot.io/articles/%D8%A7%D8%A8%D9%86-%D9%86%D9%85%D9%88%D8%B0%D8%AC-%D9%84%D8%BA%D8%A9-%D8%A8%D9%84%D8%BA%D8%A9-csharp-3-gpt-%D8%B5%D8%BA%D9%8A%D8%B1/): تضمين التوكن والموضع، والانتباه متعدد الرؤوس Multi-Head Attention، وكتلة Transformer، ونموذج فيه قرابة نصف مليون معامل يتدرب على المعالج في دقائق.
- [تحميل أوزان GPT-2 الحقيقية وتشغيلها](https://arabroot.io/articles/%D8%A7%D8%A8%D9%86-%D9%86%D9%85%D9%88%D8%B0%D8%AC-%D9%84%D8%BA%D8%A9-%D8%A8%D9%84%D8%BA%D8%A9-csharp-4-%D8%AA%D8%AD%D9%85%D9%8A%D9%84-gpt-2/): قراءة ملف safetensors، ومقسم BPE الخاص ب GPT-2، والتوليد مع ذاكرة KV Cache، ومقارنة المخرج رقماً برقم مع Hugging Face.

الصورة التالية تبين السلسلة كاملة، ولاحظ أن الأجزاء الأربعة تقوم على نفس المحرك الذي نبنيه في هذا المقال:

![أربع بطاقات من اليمين إلى اليسار: محرك التعلم Autograd، ثم أول نموذج لغة على مستوى الحرف بشعر المتنبي، ثم GPT صغير فيه 457,440 معاملاً، ثم GPT-2 الحقيقي بأوزانه، وتحتها كلها مستطيل واحد يمثل الكلاس Tensor المشترك](https://arabroot.io/content/images/2026/10/llm-csharp-1-01-series-map.webp)

أربعة أجزاء، كل جزء يبني على الذي قبله، وكلها فوق الكلاس Tensor الذي نكتبه الآن

وسوف نناقش في هذا المقال ما يلي:

- تجهيز المشروع وتشغيله، ولماذا لا نستخدم مكتبة جاهزة.
- ما هي المشتقة Derivative بمثال رقمي صغير، وما هو التدرج Gradient.
- لماذا لا نحسب التدرج بتحريك كل وزن على حدة، وكيف تحل قاعدة السلسلة Chain Rule المشكلة.
- الرسم البياني للحساب Computational Graph والانتشار العكسي Backpropagation خطوة بخطوة.
- الكلاس Tensor والعمليات التي نحتاجها في السلسلة كلها، مع قاعدة الرجوع Backward لكل عملية.
- التحقق من التدرج Gradient Check، وهو الطريقة التي نثبت بها أن كل عملية كتبناها صحيحة، ومثال على خطأ يكتشفه.
- لماذا نحتاج ضرب مصفوفات MatMul سريعاً، والفرق بين الطريقة البسيطة والطريقة التي تستخدم SIMD وكل أنوية المعالج.
- المحسن Optimizer من SGD إلى AdamW، ثم شبكة صغيرة تتعلم أمام عينيك وينخفض خطؤها.

## تجهيز المشروع وتشغيله

قد يتساءل البعض: لماذا نكتب كل هذا بأنفسنا ولدينا [TorchSharp](https://github.com/dotnet/TorchSharp?ref=arabroot.io) التي تجلب PyTorch كاملة إلى .NET؟ والإجابة أن هدف هذه السلسلة هو الفهم وليس الإنتاج Production، فإذا استخدمت مكتبة جاهزة فسوف تكتب `loss.backward()` ويحسب لك كود مكتوب بلغة C++ كل شيء في الخلفية، وتبقى الفكرة الأهم صندوقاً مغلقاً. أما إذا كتبت المحرك بنفسك في بضع مئات من الأسطر فلن يبقى في التدريب شيء غامض، وعندما تعود بعد ذلك إلى PyTorch سوف تعرف ما الذي يحدث في كل سطر. ولكن لا تقم باستخدام هذا الكود في الإنتاج، والسبب أن المكتبات الحقيقية تستخدم البطاقة الرسومية GPU ومئات التحسينات التي لا نحتاجها هنا.

تحتاج فقط إلى [.NET SDK 10](https://dotnet.microsoft.com/en-us/download/dotnet/10.0?ref=arabroot.io)، والكود كاملاً موجود في المجلد `content/labs/llm-csharp/` في مستودع عرب رووت، وهو مشروع Console واحد مقسم إلى مجلدات بحسب أجزاء السلسلة كما يلي:

```text
llm-csharp/
├── LlmCs.csproj            ← لا توجد أي PackageReference
├── Program.cs              ← الأوامر: part1, part2, part3, part4, download
├── Shared/                 ← المحرك المشترك (هذا المقال)
│   ├── Tensor.cs           ← الكلاس Tensor والدالة Backward()
│   ├── Ops.Basic.cs        ← Add, Mul, Scale, Sum, MatMul, MatMulT, Reshape
│   ├── Ops.Nn.cs           ← Tanh, Gelu, Embedding, LayerNorm, SoftmaxCrossEntropy
│   ├── Ops.Attention.cs    ← عمليات الانتباه (الجزء الثالث)
│   ├── Kernels.cs          ← ضرب المصفوفات السريع
│   └── Optimizers.cs       ← SGD و AdamW
├── Part1_Autograd/         ← Part1.cs و GradCheck.cs
├── Part2_CharModel/
├── Part3_TinyGpt/
├── Part4_Gpt2/
└── outputs/                ← المخرجات الحقيقية لكل جزء
```

ولتشغيل هذا الجزء ادخل إلى المجلد ونفذ الأمر التالي، ولاحظ الخيار `-c Release`، فالفرق في السرعة بين Debug وRelease في كود حسابي مثل هذا كبير جداً:

```bash
cd content/labs/llm-csharp
dotnet run -c Release -- part1
```

وكل المخرجات التي سوف تراها في هذا المقال هي مخرج هذا الأمر كما ظهر عندنا، وهي محفوظة كاملة في الملف `outputs/part1.txt`. أما الأزمنة فهي للتوضيح فقط، لأنها قيست على جهاز فيه معالج Intel Core i7-7700K بأربع أنوية وثمانية خيوط وكان يشغل أعمالاً أخرى في نفس الوقت، وسوف تختلف على جهازك.

## المشتقة Derivative: كم يتغير الناتج إذا حركنا المدخل قليلاً؟

لنبدأ من أبسط سؤال ممكن: لدينا دالة `f(x) = x * x`، ونحن عند `x = 3` فالناتج 9، فإذا زدنا x زيادة صغيرة جداً، فكم يزيد الناتج؟ والإجابة على هذا السؤال هي المشتقة Derivative، أي ميل الدالة عند هذه النقطة، أو بلغة المطور: «لو حركت هذا الرقم قليلاً، كم مرة أكبر سوف يتحرك الناتج؟». ويمكن أن نحسبها دون أي رياضيات، فقط بأن نجرب: نحسب `f(3 + h)` لقيمة h صغيرة، ونطرح منها `f(3)`، ونقسم على h. والكود التالي من الملف `Part1_Autograd/Part1.cs` يفعل ذلك بقيم مختلفة ل h:

```csharp
// 1) The derivative as a slope: how much f(x) = x*x moves when x moves a little
static void Derivative()
{
    Console.WriteLine("== 1) Derivative of f(x) = x*x at x = 3 (exact answer: 2*x = 6)");
    static float F(float x) => x * x;
    foreach (float h in new[] { 1f, 0.1f, 0.01f, 0.001f })
        Console.WriteLine($"h = {h,-6} (f(3+h) - f(3)) / h = {(F(3 + h) - F(3)) / h:F4}   central (f(3+h) - f(3-h)) / 2h = {(F(3 + h) - F(3 - h)) / (2 * h):F4}");
    Console.WriteLine();
}
```

والمخرج سوف يكون كما يلي:

```text
== 1) Derivative of f(x) = x*x at x = 3 (exact answer: 2*x = 6)
h = 1      (f(3+h) - f(3)) / h = 7.0000   central (f(3+h) - f(3-h)) / 2h = 6.0000
h = 0.1    (f(3+h) - f(3)) / h = 6.1000   central (f(3+h) - f(3-h)) / 2h = 6.0000
h = 0.01   (f(3+h) - f(3)) / h = 6.0100   central (f(3+h) - f(3-h)) / 2h = 6.0000
h = 0.001  (f(3+h) - f(3)) / h = 6.0005   central (f(3+h) - f(3-h)) / 2h = 5.9996
```

لاحظ أنه كلما صغرت h اقترب الناتج من 6، والقيمة الدقيقة من قواعد الاشتقاق هي `2 * x = 6`. وهذا يعني أنك إذا زدت x بمقدار 0.001 فسوف يزيد الناتج بمقدار 0.006 تقريباً، أي ستة أضعاف. ولاحظ أيضاً العمود الثاني، فهو يحسب الفرق المركزي Central Difference، أي يحرك x للأمام وللخلف ويقسم على 2h، وهذه الطريقة أدق بكثير، فقد أعطت 6 تماماً حتى مع h = 1\. أما السطر الأخير الذي أعطى 5.9996 فسببه دقة النوع `float` نفسه، فعندما تصبح h صغيرة جداً يصبح الفرق بين الرقمين قريباً من حدود الدقة التي يحفظها `float`، وهذه ملاحظة سوف نعود إليها عند التحقق من التدرج.

الصورة التالية تبين المعنى الهندسي لهذه الأرقام:

![منحنى الدالة x ضرب x مع النقطة 3 و9، وخط متقطع يمر بالنقطتين عند 3 و4 ميله 7، وخط أخضر يلمس المنحنى عند 3 ميله 6، وبجانبه جدول فيه قيم h وهي 1 و0.1 و0.01 والناتج 7 و6.1 و6.01 ثم القيمة الدقيقة 6](https://arabroot.io/content/images/2026/10/llm-csharp-1-02-derivative.webp)

المشتقة هي ميل المماس، والفرق مع h صغيرة يقترب منها

## التدرج Gradient: مشتقة لكل وزن

النموذج الحقيقي ليس دالة لها مدخل واحد، وإنما دالة لها ملايين المدخلات (الأوزان)، ومخرجها رقم واحد يسمى الخطأ Loss، وهو يقيس كم كان النموذج بعيداً عن الإجابة الصحيحة، كما شرحنا في قسم [كيف يتعلم النموذج](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D9%86%D9%85%D8%A7%D8%B0%D8%AC-%D8%A7%D9%84%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D9%83%D8%A8%D9%8A%D8%B1%D8%A9-llm-%D8%AE%D8%B7%D9%88%D8%A9-%D8%A8%D8%AE%D8%B7%D9%88%D8%A9/#training). والتدرج Gradient هو مجموعة المشتقات الجزئية للخطأ بالنسبة لكل وزن على حدة: لكل وزن رقم يقول «لو زدت هذا الوزن قليلاً، كم يتغير الخطأ وفي أي اتجاه». فإذا كان التدرج موجباً فزيادة الوزن تزيد الخطأ، لذلك ننقصه، وإذا كان سالباً نزيده، وهذه هي فكرة النزول بالتدرج Gradient Descent كلها: نحرك كل وزن خطوة صغيرة عكس تدرجه، ثم نعيد.

والآن الطريقة البسيطة لحساب التدرج واضحة من القسم السابق: نحرك كل وزن للأمام وللخلف ونحسب الخطأ مرتين. وهذه الطريقة صحيحة ولكنها غير مناسبة إطلاقاً للتدريب، والسبب أن كل وزن يحتاج تشغيلين كاملين للنموذج، وGPT-2 الصغير فيه 124,439,808 وزناً، أي قرابة 249 مليون تشغيل للنموذج لكل خطوة تدريب واحدة. والحل الذي تستخدمه كل مكتبات التعلم العميق هو حساب كل التدرجات في تشغيل واحد للخلف، بفضل قاعدة السلسلة.

## قاعدة السلسلة Chain Rule: اضرب المشتقات على الطريق

لنأخذ مثالاً صغيراً فيه ثلاثة أوزان: `L = (w * x + b) * (w * x + b)` حيث `w = 2` و`x = 3` و`b = 1`. ولنسم الناتج الأوسط `z = w * x + b = 7`، فيصبح `L = z * z = 49`. والسؤال: كم يتغير L إذا حركنا w قليلاً؟

الإجابة تأتي على مرحلتين، فإذا تحرك w قليلاً فإن z يتحرك ثلاثة أضعاف هذه الحركة، لأن `dz/dw = x = 3`، وإذا تحرك z قليلاً فإن L يتحرك 14 ضعفاً، لأن `dL/dz = 2 * z = 14`. إذن إذا تحرك w قليلاً فإن L يتحرك `14 × 3 = 42` ضعفاً، وهذه هي قاعدة السلسلة Chain Rule: مشتقة الطريق كله هي حاصل ضرب المشتقات المحلية Local Derivatives على الطريق. والفكرة المهمة هنا أن كل خطوة تحتاج أن تعرف مشتقتها المحلية فقط، فعملية الضرب لا تعرف شيئاً عن الخطأ L، وإنما تعرف أن مشتقة `a * b` بالنسبة ل a هي b، وتستلم من الخطوة التي بعدها رقماً واحداً وتضربه في مشتقتها وترسله للخلف.

الصورة التالية تبين هذه الفكرة، وتقارن في الأسفل بين الطريقتين:

![ثلاث دوائر w ثم z ثم L متصلة بأسهم، فوق السهم الأول المشتقة المحلية dz/dw تساوي 3 وفوق الثاني dL/dz تساوي 14، وتحتها النتيجة dL/dw تساوي 14 ضرب 3 تساوي 42، وفي الأسفل مقارنة بين الانتشار العكسي بتشغيل واحد للخلف والطريقة البسيطة التي تحتاج تشغيلين لكل وزن](https://arabroot.io/content/images/2026/10/llm-csharp-1-04-chain-rule.webp)

قاعدة السلسلة: 14 × 3 = 42، دون أن نحرك أي وزن

## الرسم البياني للحساب Computational Graph والانتشار العكسي Backpropagation

لكي نطبق قاعدة السلسلة على أي حساب، وليس على مثال كتبناه بأيدينا، نحتاج أن يتذكر البرنامج كيف حسب كل رقم، أي من أي أرقام جاء وبأي عملية. وهذا هو الرسم البياني للحساب Computational Graph: كل قيمة عقدة Node، وكل عقدة تعرف العقد التي جاءت منها والعملية التي أنتجتها. وعند الحساب للأمام Forward Pass يبنى هذا الرسم تلقائياً، ثم يأتي الانتشار العكسي Backpropagation فيمشي على نفس الرسم من النهاية إلى البداية، ويعطي كل عقدة تدرجها، وهذا كل ما تعنيه كلمة التفاضل التلقائي Automatic Differentiation، واختصاراً Autograd. وفكرة تدريب الشبكات بالانتشار العكسي نشرها Rumelhart وHinton وWilliams في [ورقتهم في مجلة Nature سنة 1986](https://www.nature.com/articles/323533a0?ref=arabroot.io)، وما زالت هي الطريقة التي تدرب بها كل النماذج اليوم.

الصورة التالية تبين الرسم البياني للمثال السابق، القيم باللون الأزرق والتدرجات باللون البرتقالي، والأرقام تبين ترتيب الخطوات:

![رسم بياني فيه العقد w تساوي 2 وx تساوي 3 تدخلان عملية ضرب ناتجها 6، ثم عملية جمع مع b تساوي 1 ناتجها z تساوي 7، ثم z تدخل مرتين في عملية ضرب ناتجها L تساوي 49، وتحت كل عقدة تدرجها: L تدرجها 1 وz تدرجها 7 زائد 7 يساوي 14 وb تدرجها 14 وw تدرجها 42 وx تدرجها 28، مع خمس خطوات مرقمة](https://arabroot.io/content/images/2026/10/llm-csharp-1-03-graph.webp)

القيم تذهب للأمام، ثم تعود التدرجات على نفس الطريق للخلف

في الصورة أعلاه لاحظ التالي:

- الخطوة الأولى هي الحساب العادي، ولكن كل عملية تحفظ مدخلاتها، لأنها سوف تحتاجها عند الرجوع، فعملية الضرب مثلاً تحتاج قيمة b لكي تحسب تدرج a.
- الرجوع يبدأ من L بتدرج 1، لأن `dL/dL = 1`.
- العقدة z تدخل في عملية الضرب مرتين، لذلك تصلها مساهمتان، كل واحدة `1 × 7`، ومجموعهما 14، وهذه نقطة سوف نرى أنها مصدر خطأ شائع.
- عملية الجمع تمرر التدرج كما هو لكل مدخلاتها، لأن مشتقة `a + b` بالنسبة لكل منهما هي 1.
- لا يمكن أن نحسب تدرج w قبل أن ننتهي من تدرج z بالكامل، لذلك يجب أن يكون ترتيب الرجوع عكس ترتيب الحساب، وهذا ما يسمى الترتيب الطوبولوجي Topological Order.

## الكلاس Tensor: الأرقام والشكل والتدرج

في المثال السابق كانت كل عقدة رقماً واحداً، ومكتبة [micrograd](https://github.com/karpathy/micrograd?ref=arabroot.io) الشهيرة تعمل بهذه الطريقة لأنها مكتوبة للتعليم، ولكن الشبكة الحقيقية فيها ملايين الأرقام، وإذا جعلنا كل رقم عقدة فسوف يصبح الرسم البياني بحجم ملايين الكائنات Objects في الذاكرة لكل خطوة، وهذا غير مناسب إطلاقاً. لذلك نجعل كل عقدة مصفوفة كاملة من الأرقام تسمى Tensor، فضرب مصفوفتين فيهما مليون رقم يصبح عقدة واحدة في الرسم، وقاعدة الرجوع تعمل على المصفوفة كلها مرة واحدة. والكود التالي من الملف `Shared/Tensor.cs`:

```csharp
public sealed class Tensor
{
    public readonly float[] Data;
    public float[]? Grad;              // dLoss/dThis, same size as Data, created on demand
    public readonly int[] Shape;
    public readonly string Op;         // "leaf" for inputs and parameters, else the op name
    public string Name = "";           // parameters get a name (used to load GPT-2 weights)

    Tensor[] parents = [];
    Action? backward;

    // Inference mode: when true, ops do not record the graph (no parents, no closures).
    [ThreadStatic] public static bool NoGrad;
```

في الكود أعلاه لاحظ التالي:

- الأرقام محفوظة في مصفوفة واحدة مسطحة `float[] Data` مهما كان عدد الأبعاد، والشكل `Shape` هو الذي يقول كيف نقرأها، فالمصفوفة `[2, 3]` هي ستة أرقام متتالية: الصف الأول ثم الصف الثاني، وهذا الترتيب يسمى Row-major، وهو أسرع للمعالج لأنه يقرأ الذاكرة بالترتيب.
- المصفوفة `Grad` بنفس حجم `Data`، وفيها لكل رقم مشتقة الخطأ بالنسبة له، وننشئها عند الحاجة فقط.
- الحقلان `parents` و`backward` هما الرسم البياني نفسه: من أين جاء هذا ال Tensor، والدالة التي ترسل تدرجه إلى مدخلاته.
- الحقل `NoGrad` يوقف بناء الرسم كله، وسوف نستخدمه عند التوليد والتقييم، لأننا لا نحتاج تدرجات هناك، ولا نريد أن ندفع ثمن الذاكرة.

### كيف تسجل كل عملية نفسها في الرسم؟

كل عملية في المحرك تنتهي باستدعاء الدالة `Make`، فتعطيها الناتج والمدخلات ودالة تبني قاعدة الرجوع، والدالة `Backward` تمشي على الرسم من النهاية:

```csharp
// Called by every op: the output knows its inputs and its local backward rule.
internal static Tensor Make(int[] shape, float[] data, string op, Tensor[] inputs, Func<Tensor, Action> backwardFor)
{
    var t = new Tensor(shape, data, op);
    if (!NoGrad)
    {
        t.parents = inputs;
        t.backward = backwardFor(t);
    }
    return t;
}

// Backpropagation: order the graph so every node comes after its inputs,
// seed dLoss/dLoss = 1, then run each node's backward rule from the end to the start.
public void Backward()
{
    if (Size != 1) throw new InvalidOperationException("Backward() starts from a scalar loss");
    var order = TopologicalOrder();
    foreach (var t in order) if (!t.IsLeaf) t.ZeroGrad();   // parameters keep accumulating (see Optimizer.ZeroGrad)
    GradBuf()[0] = 1f;
    for (int i = order.Count - 1; i >= 0; i--) order[i].backward?.Invoke();
}

// Iterative depth-first search (a deep network would overflow a recursive one).
List<Tensor> TopologicalOrder()
{
    var order = new List<Tensor>();
    var visited = new HashSet<Tensor>(ReferenceEqualityComparer.Instance);
    var stack = new Stack<(Tensor node, bool expanded)>();
    stack.Push((this, false));
    while (stack.Count > 0)
    {
        var (node, expanded) = stack.Pop();
        if (expanded) { order.Add(node); continue; }
        if (!visited.Add(node)) continue;
        stack.Push((node, true));
        foreach (var p in node.parents)
            if (!visited.Contains(p)) stack.Push((p, false));
    }
    return order;
}
```

في الكود أعلاه لاحظ التالي:

- الدالة `Backward` لا تعمل إلا على رقم واحد، أي على الخطأ Loss، لأن التدرج دائماً «مشتقة رقم واحد بالنسبة لكل الأوزان».
- الدالة `TopologicalOrder` ترتب العقد بحيث تأتي كل عقدة بعد مدخلاتها، فإذا مشينا على القائمة من آخرها نضمن أن تدرج كل عقدة اكتمل قبل أن نرسله إلى ما قبلها. وقد كتبناها بحلقة و`Stack` بدلاً من الاستدعاء الذاتي Recursion، والسبب أن الشبكة العميقة فيها آلاف العقد، وقد يمتلئ ال Call Stack.
- تدرجات الأوزان (العقد التي لا توجد لها عملية، أي `IsLeaf`) لا تمسح عند كل استدعاء، وإنما يمسحها المحسن Optimizer قبل كل خطوة، وهذا نفس سلوك PyTorch.

## العمليات: الحساب للأمام وقاعدة الرجوع لكل عملية

كل عملية تفعل شيئين: تحسب الناتج، وتعيد دالة تستلم تدرج الناتج وتضيف مساهمتها إلى تدرجات المدخلات. ولنبدأ بأبسطها، الضرب عنصراً بعنصر، من الملف `Shared/Ops.Basic.cs`:

```csharp
// Element by element product of two tensors of the same shape
public static Tensor Mul(Tensor a, Tensor b)
{
    if (a.Size != b.Size) throw new ArgumentException($"shapes differ: {a} {b}");
    var y = new float[a.Size];
    for (int i = 0; i < y.Length; i++) y[i] = a.Data[i] * b.Data[i];
    return Tensor.Make(a.Shape, y, "mul", [a, b], o => () =>
    {
        var g = o.Grad!;
        var ga = a.GradBuf();
        var gb = b.GradBuf();
        for (int i = 0; i < g.Length; i++)
        {
            ga[i] += g[i] * b.Data[i];   // d(a*b)/da = b
            gb[i] += g[i] * a.Data[i];   // d(a*b)/db = a
        }
    });
}
```

لاحظ أن قاعدة الرجوع هي قاعدة السلسلة بالضبط: التدرج الذي وصل من الأمام `g[i]` مضروباً في المشتقة المحلية، فمشتقة `a * b` بالنسبة ل a هي b، ولاحظ أيضاً علامة `+=`، وسوف نعود إليها بعد قليل.

أما أهم عملية في الشبكات كلها فهي ضرب المصفوفات MatMul، وهي الطبقة الخطية Linear Layer التي تراها في كل نموذج:

```csharp
// x[...,K] @ w[K,M] -> [...,M]   (a Linear layer without its bias)
public static Tensor MatMul(Tensor x, Tensor w)
{
    int k = x.Cols, n = x.Rows, m = w.Cols;
    if (w.Shape.Length != 2 || w.Shape[0] != k) throw new ArgumentException($"matmul {x} @ {w}");
    var y = new float[n * m];
    Kernels.MatMul(x.Data, w.Data, y, 1, n, k, m, bBatched: false, accumulate: false);
    return Tensor.Make([.. x.Shape[..^1], m], y, "matmul", [x, w], o => () =>
    {
        var g = o.Grad!;
        // dx = g @ w^T
        Kernels.MatMulTransB(g, w.Data, x.GradBuf(), 1, n, m, k, bBatched: false, accumulate: true);
        // dw = x^T @ g
        var xT = Kernels.Transpose(x.Data, 1, n, k);
        Kernels.MatMul(xT, g, w.GradBuf(), 1, k, n, m, bBatched: false, accumulate: true);
    });
}
```

في الكود أعلاه لاحظ التالي:

- إذا كان `y = x @ w` حيث x شكلها `[N, K]` وw شكلها `[K, M]`، فتدرج x هو `g @ wᵀ` وتدرج w هو `xᵀ @ g`، والطريقة السهلة لتذكر ذلك هي الأشكال، فتدرج x يجب أن يكون `[N, K]` مثل x، والطريقة الوحيدة لضرب g التي شكلها `[N, M]` للحصول على `[N, K]` هي ضربها في `wᵀ`.
- الحساب الفعلي في الكلاس `Kernels`، وسوف نرى لماذا فصلناه في قسم السرعة.
- المعامل `accumulate: true` يعني الإضافة إلى التدرج الموجود، وهو المقابل للعلامة `+=` في عملية الضرب.

والعملية الثالثة التي نحتاجها في كل الأجزاء هي حساب الخطأ نفسه، فالنموذج يخرج لكل موضع رقماً لكل توكن ممكن يسمى Logit، ونحول هذه الأرقام إلى احتمالات بالدالة Softmax كما شرحنا في [كيف يختار النموذج التوكن التالي](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D9%86%D9%85%D8%A7%D8%B0%D8%AC-%D8%A7%D9%84%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D9%83%D8%A8%D9%8A%D8%B1%D8%A9-llm-%D8%AE%D8%B7%D9%88%D8%A9-%D8%A8%D8%AE%D8%B7%D9%88%D8%A9/#next-token)، ثم يكون الخطأ هو سالب لوغاريتم احتمال الإجابة الصحيحة، ويسمى Cross Entropy. وقد دمجنا الاثنين في عملية واحدة من الملف `Shared/Ops.Nn.cs`:

```csharp
// Softmax over every row of logits, then the average of -log(probability of the right answer).
// Fused in one op because the combined gradient is simple: probs - onehot(target).
public static Tensor SoftmaxCrossEntropy(Tensor logits, int[] targets)
{
    int n = logits.Rows, v = logits.Cols;
    if (targets.Length != n) throw new ArgumentException("one target per row");
    var probs = new float[logits.Size];
    var losses = new double[n];
    Kernels.ParallelFor(n, v * 3, (s, e) =>
    {
        for (int r = s; r < e; r++)
        {
            var row = logits.Data.AsSpan(r * v, v);
            var p = probs.AsSpan(r * v, v);
            Softmax(row, p);
            losses[r] = -Math.Log(Math.Max(p[targets[r]], 1e-30f));
        }
    });
    float loss = (float)(losses.Sum() / n);
    return Tensor.Make([1], [loss], "softmax_ce", [logits], o => () =>
    {
        float g = o.Grad![0] / n;
        var gl = logits.GradBuf();
        Kernels.ParallelFor(n, v, (s, e) =>
        {
            for (int r = s; r < e; r++)
            {
                for (int i = 0; i < v; i++) gl[r * v + i] += g * probs[r * v + i];
                gl[r * v + targets[r]] -= g;
            }
        });
    });
}

// Plain softmax of one row (no graph). Subtracting the max first avoids exp() overflow.
public static void Softmax(ReadOnlySpan<float> x, Span<float> p, float temperature = 1f)
{
    float max = float.NegativeInfinity;
    foreach (float a in x) max = MathF.Max(max, a);
    double sum = 0;
    for (int i = 0; i < x.Length; i++) { p[i] = MathF.Exp((x[i] - max) / temperature); sum += p[i]; }
    float inv = (float)(1.0 / sum);
    for (int i = 0; i < x.Length; i++) p[i] *= inv;
}
```

والسبب في الدمج أن قاعدة الرجوع للاثنين معاً بسيطة جداً: الاحتمال الذي أعطاه النموذج لكل توكن، مطروحاً منه 1 للتوكن الصحيح فقط، فإذا أعطى النموذج الإجابة الصحيحة احتمال 0.9 فتدرجها `0.9 - 1 = -0.1`، أي ارفعها قليلاً، وإذا أعطى إجابة خاطئة احتمال 0.3 فتدرجها `0.3`، أي اخفضها. أما إذا حسبنا Softmax ثم اللوغاريتم كعمليتين منفصلتين فسوف نحتاج قسمة على احتمالات صغيرة جداً، وهذا يسبب أخطاء في الدقة. ولاحظ أيضاً الدالة `Softmax` تطرح أكبر قيمة قبل `Exp`، والسبب أن `MathF.Exp(100)` يعطي مالانهاية في `float`.

وبقية العمليات مبنية بنفس الطريقة، وهي التي سوف نحتاجها في الأجزاء القادمة:

| العملية                                                    | الملف                   | أين نستخدمها                                                                                                                                                                                                                                                                                                         |
| ---------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Add (مع التكرار Broadcast) وScale وSum وReshape وMatMulT   | Shared/Ops.Basic.cs     | كل الأجزاء، وMatMulT للطبقة الأخيرة في GPT-2                                                                                                                                                                                                                                                                         |
| Tanh وGelu                                                 | Shared/Ops.Nn.cs        | دوال التفعيل Activation Functions في MLP وفي كتلة Transformer                                                                                                                                                                                                                                                        |
| Embedding                                                  | Shared/Ops.Nn.cs        | تحويل رقم التوكن إلى متجه، وشرحناه في [التضمين Embedding](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%B9%D9%85%D9%84-%D9%86%D9%85%D8%A7%D8%B0%D8%AC-%D8%A7%D9%84%D9%84%D8%BA%D8%A9-%D8%A7%D9%84%D9%83%D8%A8%D9%8A%D8%B1%D8%A9-llm-%D8%AE%D8%B7%D9%88%D8%A9-%D8%A8%D8%AE%D8%B7%D9%88%D8%A9/#embeddings) |
| LayerNorm                                                  | Shared/Ops.Nn.cs        | تطبيع الطبقة Layer Normalization في كتلة Transformer                                                                                                                                                                                                                                                                 |
| BatchMatMul وCausalSoftmax وNarrow وSplitHeads وMergeHeads | Shared/Ops.Attention.cs | الانتباه Attention في الجزء الثالث                                                                                                                                                                                                                                                                                   |

والآن نجرب المحرك على مثال الصورة، والكود التالي من الملف `Part1_Autograd/Part1.cs`:

```csharp
// 2) A tiny graph: L = (w*x + b)^2. Backward() gives every input its gradient in one pass.
static void TinyGraph()
{
    Console.WriteLine("== 2) L = (w*x + b)^2 with w = 2, x = 3, b = 1");
    var w = Tensor.Scalar(2); var x = Tensor.Scalar(3); var b = Tensor.Scalar(1);
    var z = Ops.Add(Ops.Mul(w, x), b);   // z = 7
    var loss = Ops.Mul(z, z);            // L = 49 (z is used twice: its gradients add up)
    loss.Backward();
    Console.WriteLine($"forward:  z = {z.Item}, L = {loss.Item}");
    Console.WriteLine($"backward: dL/dz = {z.Grad![0]}, dL/dw = {w.Grad![0]}, dL/dx = {x.Grad![0]}, dL/db = {b.Grad![0]}");
    float h = 0.001f;
    float Lw(float wv) => (wv * 3 + 1) * (wv * 3 + 1);
    Console.WriteLine($"numeric:  dL/dw = (L(w+h) - L(w-h)) / 2h = {(Lw(2 + h) - Lw(2 - h)) / (2 * h):F3}");
    Console.WriteLine();
}
```

والمخرج سوف يكون كما يلي:

```text
== 2) L = (w*x + b)^2 with w = 2, x = 3, b = 1
forward:  z = 7, L = 49
backward: dL/dz = 14, dL/dw = 42, dL/dx = 28, dL/db = 14
numeric:  dL/dw = (L(w+h) - L(w-h)) / 2h = 42.002
```

لاحظ أن الدالة `Backward` أعطت كل التدرجات في تشغيل واحد، وأنها نفس الأرقام التي حسبناها بأيدينا في الصورة، وأن الحساب العددي بتحريك w أعطى 42.002، والفرق الصغير سببه دقة `float` كما رأينا في قسم المشتقة.

## لماذا += وليس =؟ خطأ لا يظهر إلا عندما يستخدم الرقم مرتين

لنفرض أنك كتبت قاعدة الرجوع لعملية الضرب بعلامة `=` بدلاً من `+=`، فهذا يبدو منطقياً، فالتدرج يحسب ويحفظ، والكود سوف يعمل ويعطي أرقاماً صحيحة في أغلب الحالات، لأن أغلب الأرقام تستخدم مرة واحدة. ولكن عندما يدخل نفس ال Tensor في الحساب مرتين، كما دخلت z في `z * z`، أو كما يدخل ال Tensor في الطبقة التالية وفي الوصلة المتبقية Residual Connection في نفس الوقت كما سوف نرى في الجزء الثالث، فإن المساهمة الثانية تمسح الأولى بدلاً من أن تضاف إليها. وقد كتبنا هذا الخطأ عمداً في الملف `Part1_Autograd/Part1.cs` لنراه:

```csharp
// 3b) A backward rule that ASSIGNS (=) instead of ADDING (+=): correct until a tensor is used twice
static void BreakItOnPurpose()
{
    Console.WriteLine("== 3b) Breaking it on purpose: a Mul whose backward uses = instead of +=");
    var z = Tensor.Scalar(7);
    foreach (var (name, mul) in new (string, Func<Tensor, Tensor, Tensor>)[] { ("Mul (+=)", Ops.Mul), ("MulAssign (=)", MulAssign) })
    {
        z.ZeroGrad();
        mul(z, z).Backward();   // L = z * z, so dL/dz must be 2z = 14
        var (abs, rel) = GradCheck.Run(() => mul(z, z), [z]);
        Console.WriteLine($"{name,-14} dL/dz = {z.GradBuf()[0],4}   max rel diff = {rel:E2}   {(rel < 1e-3 ? "OK" : "FAIL")}");
    }
    Console.WriteLine();
}

static Tensor MulAssign(Tensor a, Tensor b)
{
    var y = new float[a.Size];
    for (int i = 0; i < y.Length; i++) y[i] = a.Data[i] * b.Data[i];
    return Tensor.Make(a.Shape, y, "mul_wrong", [a, b], o => () =>
    {
        var g = o.Grad!;
        var ga = a.GradBuf();
        var gb = b.GradBuf();
        for (int i = 0; i < g.Length; i++)
        {
            ga[i] = g[i] * b.Data[i];   // BUG: = overwrites what the other use of the tensor already added
            gb[i] = g[i] * a.Data[i];
        }
    });
}
```

والمخرج سوف يكون كما يلي:

```text
== 3b) Breaking it on purpose: a Mul whose backward uses = instead of +=
Mul (+=)       dL/dz =   14   max rel diff = 1.14E-005   OK
MulAssign (=)  dL/dz =    7   max rel diff = 3.33E-001   FAIL
```

لاحظ أن النسخة الخاطئة أعطت 7 بدلاً من 14، أي نصف القيمة الصحيحة، ولم يظهر أي خطأ Exception، والتدريب بهذا الخطأ سوف يعمل وينخفض الخطأ ولكن أبطأ وأسوأ، وقد تقضي أياماً تبحث في معدل التعلم وحجم الشبكة والسبب في سطر واحد. لذلك لا تقم بكتابة أي عملية جديدة دون أن تتحقق من تدرجها، وهذا موضوع القسم التالي.

## التحقق من التدرج Gradient Check: كيف نثبت أن كل عملية صحيحة؟

الطريقة البسيطة التي رفضناها للتدريب لأنها بطيئة هي نفسها الطريقة المثالية للاختبار، فعلى مصفوفات صغيرة فيها عشرات الأرقام يمكننا أن نحرك كل رقم للأمام وللخلف ونقيس التدرج عددياً Numerically، ثم نقارنه بالتدرج الذي حسبته الدالة `Backward` تحليلياً Analytically، فإذا تطابقا فقاعدة الرجوع صحيحة. والصورة التالية تبين الفكرة:

![مخطط من ثلاث خطوات: تشغيل Backward مرة واحدة للحصول على التدرج التحليلي، ثم تحريك كل رقم بمقدار h للأمام وللخلف وحساب الميل العددي، ثم مقارنتهما بالخطأ النسبي، وفي الأسفل نتيجتان: عملية الضرب الصحيحة تعطي 14 والنتيجة OK، والعملية التي تستخدم = تعطي 7 والنتيجة FAIL](https://arabroot.io/content/images/2026/10/llm-csharp-1-06-gradient-check.webp)

التدرج الذي يحسبه Backward يجب أن يساوي الميل الذي نقيسه بتحريك الأرقام

والكود التالي من الملف `Part1_Autograd/GradCheck.cs`:

```csharp
public static class GradCheck
{
    // Returns the largest absolute difference over all numbers, and the largest relative error over the
    // input tensors, where relative error = |analytic - numeric| / (|analytic| + |numeric|) using vector lengths.
    public static (float maxAbs, float maxRel) Run(Func<Tensor> lossFn, Tensor[] inputs, float h = 1e-2f)
    {
        foreach (var t in inputs) t.ZeroGrad();
        lossFn().Backward();
        float maxAbs = 0, maxRel = 0;
        foreach (var t in inputs)
        {
            var analytic = t.GradBuf();
            double diffSq = 0, aSq = 0, nSq = 0;
            for (int i = 0; i < t.Size; i++)
            {
                float old = t.Data[i];
                Tensor.NoGrad = true;   // numeric runs do not need a graph
                t.Data[i] = old + h; float plus = lossFn().Item;
                t.Data[i] = old - h; float minus = lossFn().Item;
                Tensor.NoGrad = false;
                t.Data[i] = old;
                float numeric = (plus - minus) / (2 * h);   // central difference
                float a = analytic[i];
                maxAbs = MathF.Max(maxAbs, MathF.Abs(a - numeric));
                diffSq += (a - numeric) * (a - numeric);
                aSq += a * a;
                nSq += numeric * numeric;
            }
            double denom = Math.Sqrt(aSq) + Math.Sqrt(nSq);
            if (denom > 0) maxRel = MathF.Max(maxRel, (float)(Math.Sqrt(diffSq) / denom));
        }
        return (maxAbs, maxRel);
    }
}
```

في الكود أعلاه لاحظ التالي:

- نستخدم الفرق المركزي، لأنه كما رأينا في قسم المشتقة أدق بكثير من الفرق للأمام فقط.
- اخترنا `h = 0.01` وليس قيمة أصغر، والسبب دقة `float` التي رأيناها في السطر الأخير من القسم الأول، فمع h صغيرة جداً يصبح الفرق بين L+ وL− في حدود خطأ التقريب.
- نقيس الخطأ النسبي Relative Error بطول المتجهات كاملة لكل Tensor، أي طول الفرق مقسوماً على مجموع الطولين، والسبب أن المقارنة رقماً برقم تعطي خطأ نسبياً كبيراً لأرقام تدرجها قريب من الصفر بسبب الدقة وحدها، دون أي خطأ في الكود. ونعتبر النتيجة صحيحة إذا كان الخطأ النسبي أقل من `1e-3`، أما الخطأ الحقيقي في قاعدة رجوع فيعطي عادة خطأ نسبياً من رتبة 0.1 أو أكثر، كما رأينا في القسم السابق.
- لكي نختبر عملية مخرجها مصفوفة وليس رقماً واحداً، نضرب المخرج في مصفوفة أرقام عشوائية ثابتة ثم نجمع، فيصبح لكل عنصر في المخرج وزن مختلف، ولا تختفي أخطاء عنصر بعينه.

وعند التشغيل نختبر كل العمليات التي تستخدمها السلسلة كلها، والمخرج سوف يكون كما يلي:

```text
== 3) Gradient check: backward vs numeric derivative (h = 0.01)
Add                    max abs diff = 3.10E-006   max rel diff = 6.46E-007   OK
Add (broadcast bias)   max abs diff = 6.79E-006   max rel diff = 2.23E-006   OK
Mul                    max abs diff = 9.05E-006   max rel diff = 3.83E-006   OK
Scale                  max abs diff = 9.72E-006   max rel diff = 4.46E-006   OK
MatMul                 max abs diff = 2.05E-005   max rel diff = 4.35E-006   OK
MatMulT                max abs diff = 2.56E-005   max rel diff = 2.39E-006   OK
Tanh                   max abs diff = 2.90E-005   max rel diff = 1.16E-005   OK
Gelu                   max abs diff = 2.07E-005   max rel diff = 4.13E-006   OK
Reshape                max abs diff = 2.56E-006   max rel diff = 6.19E-007   OK
Embedding              max abs diff = 3.34E-006   max rel diff = 8.16E-007   OK
LayerNorm              max abs diff = 1.45E-005   max rel diff = 1.31E-005   OK
SoftmaxCrossEntropy    max abs diff = 8.97E-006   max rel diff = 2.69E-005   OK
BatchMatMulT           max abs diff = 5.53E-005   max rel diff = 7.25E-006   OK
BatchMatMul            max abs diff = 9.10E-005   max rel diff = 1.34E-005   OK
CausalSoftmax          max abs diff = 3.08E-006   max rel diff = 5.48E-006   OK
Narrow                 max abs diff = 1.36E-005   max rel diff = 1.37E-006   OK
SplitHeads             max abs diff = 1.22E-005   max rel diff = 2.21E-006   OK
MergeHeads             max abs diff = 1.26E-005   max rel diff = 4.33E-006   OK
```

لاحظ أن أكبر خطأ نسبي في كل العمليات من رتبة `1e-5`، أي أقل من الحد بمئة مرة، وهذا ما تتوقعه من حساب ب `float`، فالتدرجات صحيحة لكل العمليات. وفي الجزء الثالث سوف نطبق نفس الاختبار على GPT صغير كامل بكل طبقاته، ويمكنك أن تشغله الآن بالأمر `dotnet run -c Release -- part3 check`.

## لماذا نحتاج ضرب مصفوفات سريعاً؟

أغلب وقت التدريب والتوليد يذهب في ضرب المصفوفات، ففي GPT-2 مثلاً كل توكن يمر على قرابة 85 مليون عملية ضرب وجمع في الطبقات وحدها، لذلك لا يكفي أن يكون MatMul صحيحاً، بل يجب أن يكون سريعاً أيضاً، وإلا فإن تدريب الجزء الثالث سوف يأخذ ساعات بدلاً من دقائق. والطريقة البسيطة هي الحلقات الثلاث التي تراها في أي كتاب، من الملف `Shared/Kernels.cs`:

```csharp
// C[n,m] = A[n,k] @ B[k,m] : three loops, one thread, no SIMD
public static void MatMulNaive(float[] a, float[] b, float[] c, int n, int k, int m)
{
    for (int i = 0; i < n; i++)
        for (int j = 0; j < m; j++)
        {
            float sum = 0;
            for (int p = 0; p < k; p++) sum += a[i * k + p] * b[p * m + j];   // walks down a column of B: cache-unfriendly
            c[i * m + j] = sum;
        }
}
```

وهذا الكود صحيح ولكنه بطيء لثلاثة أسباب. الأول أن الحلقة الداخلية تقرأ عموداً من المصفوفة b، أي تقفز في الذاكرة بمقدار صف كامل في كل خطوة، والمعالج يحمل الذاكرة إلى ال Cache قطعة متصلة كل مرة، فيستخدم رقماً واحداً منها ويرمي الباقي، وقد شرحنا أثر ال Cache على السرعة في [مقال Hello World](https://arabroot.io/articles/%D8%B1%D8%AD%D9%84%D8%A9-hello-world-%D9%85%D9%86-%D9%84%D9%88%D8%AD%D8%A9-%D8%A7%D9%84%D9%85%D9%81%D8%A7%D8%AA%D9%8A%D8%AD-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%B4%D8%A7%D8%B4%D8%A9/#performance). والثاني أن المعالج يستطيع أن يضرب ثمانية أرقام `float` في تعليمة واحدة باستخدام SIMD (أي AVX2 في هذا المعالج)، والكود يضربها واحداً واحداً. والثالث أنه يعمل على نواة واحدة من ثمان.

والنسخة السريعة `Kernels.MatMul` في نفس الملف تعالج الأسباب الثلاثة: تنسخ أعمدة b التي تحتاجها إلى مصفوفة صغيرة متصلة (وهذا يسمى Packing)، وتحسب أربعة صفوف من الناتج معاً بثمانية متجهات تبقى في سجلات المعالج Registers، وتستخدم [Vector<float>](https://learn.microsoft.com/en-us/dotnet/api/system.numerics.vector-1?ref=arabroot.io) التي تختار حجم المتجه المناسب للمعالج تلقائياً، وتوزع العمل على كل الأنوية ب [Parallel.For](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.parallel.for?ref=arabroot.io)، وقد شرحنا الفرق بين التزامن والتوازي في [المعالج والخيوط والتزامن](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%B9%D8%A7%D9%84%D8%AC-%D9%88%D8%A7%D9%84%D8%AE%D9%8A%D9%88%D8%B7-%D9%88%D8%A7%D9%84%D8%AA%D8%B2%D8%A7%D9%85%D9%86-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#concurrency-parallelism). والمثال التالي من نفس الملف يبين كيف تبدو SIMD في C# دون أي كود Unsafe، وهي الدالة التي تضيف صفاً مضروباً في رقم إلى صف آخر:

```csharp
// y += alpha * x   (MemoryMarshal.Cast views the float span as a span of vectors, without copying)
public static void Axpy(float alpha, ReadOnlySpan<float> x, Span<float> y)
{
    var xv = MemoryMarshal.Cast<float, Vector<float>>(x);
    var yv = MemoryMarshal.Cast<float, Vector<float>>(y.Slice(0, x.Length));
    var va = new Vector<float>(alpha);
    for (int i = 0; i < xv.Length; i++) yv[i] = Vector.FusedMultiplyAdd(va, xv[i], yv[i]);
    for (int i = xv.Length * V; i < x.Length; i++) y[i] += alpha * x[i];
}
```

والمخرج عند ضرب مصفوفتين حجم كل منهما 512 في 512 سوف يكون كما يلي:

```text
== 4) MatMul 512x512 @ 512x512 on 8 logical CPUs
naive:    313.8 ms  (0.86 GFLOP/s)
fast:       3.7 ms  (72.62 GFLOP/s)   max difference = 3.1E-005
```

لاحظ أن النتيجتين متطابقتان تقريباً، والفرق الصغير سببه أن ترتيب الجمع مختلف، والجمع في `float` يعطي نتائج مختلفة قليلاً بحسب الترتيب. أما السرعة فالفرق عشرات المرات، والرقم يختلف من تشغيل لآخر على جهاز مشغول، لذلك خذه كمثال على حجم الفرق وليس كقياس دقيق.

## المحسن Optimizer: من التدرج إلى خطوة

بعد أن يحسب `Backward` التدرجات نحتاج أن نحرك الأوزان، وأبسط طريقة هي النزول بالتدرج العشوائي Stochastic Gradient Descent، واختصاراً SGD: كل وزن ينقص منه تدرجه مضروباً في رقم صغير يسمى معدل التعلم Learning Rate. والكود التالي من الملف `Shared/Optimizers.cs`:

```csharp
// Stochastic gradient descent: w = w - lr * grad, a small step against the gradient
public sealed class Sgd(IReadOnlyList<Tensor> parameters, float lr) : Optimizer(parameters, lr)
{
    public override void Step()
    {
        foreach (var p in Params)
        {
            if (p.Grad == null) continue;
            for (int i = 0; i < p.Size; i++) p.Data[i] -= LearningRate * p.Grad[i];
        }
    }
}
```

وهذا الحل يعمل، ولكن فيه مشكلة رئيسية: كل الأوزان تتحرك بنفس معدل التعلم، والوزن الذي تدرجه صغير جداً يتحرك ببطء شديد حتى لو كان بعيداً عن قيمته المناسبة، والوزن الذي تدرجه كبير يقفز بعيداً، فإذا رفعت معدل التعلم لتسرع الأول خرب الثاني، وإذا خفضته لتحمي الثاني توقف الأول. والحل الذي يستخدمه أغلب من يدرب النماذج اليوم هو [AdamW](https://arxiv.org/abs/1711.05101?ref=arabroot.io)، وهو نسخة من [Adam](https://arxiv.org/abs/1412.6980?ref=arabroot.io) تفصل اضمحلال الأوزان Weight Decay عن التدرج:

```csharp
// AdamW (Loshchilov & Hutter): every weight gets its own step size from running averages
// of its gradient (m) and squared gradient (v), plus weight decay applied directly to the weight.
public sealed class AdamW(IReadOnlyList<Tensor> parameters, float lr, float weightDecay = 0.01f,
                          float beta1 = 0.9f, float beta2 = 0.999f, float eps = 1e-8f) : Optimizer(parameters, lr)
{
    readonly float[][] m = parameters.Select(p => new float[p.Size]).ToArray();
    readonly float[][] v = parameters.Select(p => new float[p.Size]).ToArray();
    int t;

    public override void Step()
    {
        t++;
        float bc1 = 1 - MathF.Pow(beta1, t), bc2 = 1 - MathF.Pow(beta2, t);   // bias correction for the first steps
        for (int k = 0; k < Params.Count; k++)
        {
            var p = Params[k];
            if (p.Grad == null) continue;
            // no decay for vectors (biases, LayerNorm gains): only matrices are pulled toward zero
            float wd = p.Shape.Length >= 2 ? weightDecay : 0f;
            var mk = m[k];
            var vk = v[k];
            for (int i = 0; i < p.Size; i++)
            {
                float g = p.Grad[i];
                mk[i] = beta1 * mk[i] + (1 - beta1) * g;
                vk[i] = beta2 * vk[i] + (1 - beta2) * g * g;
                float mhat = mk[i] / bc1, vhat = vk[i] / bc2;
                p.Data[i] -= LearningRate * (mhat / (MathF.Sqrt(vhat) + eps) + wd * p.Data[i]);
            }
        }
    }
}
```

في الكود أعلاه لاحظ التالي:

- المصفوفة m هي متوسط متحرك للتدرج، أي اتجاه الحركة في الخطوات الأخيرة، وهذا يسمى الزخم Momentum، فإذا كان التدرج يتذبذب من خطوة لأخرى يلغي المتوسط هذا التذبذب.
- المصفوفة v هي متوسط متحرك لمربع التدرج، والقسمة على جذرها تجعل خطوة كل وزن بحجم معدل التعلم تقريباً مهما كان حجم تدرجه، فالوزن ذو التدرج الصغير يأخذ خطوة معقولة، والوزن ذو التدرج الكبير لا يقفز.
- القسمة على `bc1` و`bc2` تصحح بداية التدريب، لأن m وv تبدآن من الصفر فتكونان أصغر من الحقيقة في الخطوات الأولى.
- اضمحلال الأوزان Weight Decay يسحب كل مصفوفة نحو الصفر قليلاً في كل خطوة، وهذا يقلل الحفظ الأعمى للبيانات Overfitting، ولا نطبقه على المتجهات مثل ال Bias، وهذا ما تفعله أغلب تدريبات GPT.

## حلقة التدريب Training Loop: الشبكة الصغيرة تتعلم

الآن عندنا كل القطع، وتدريب أي شبكة، من شبكة فيها 82 وزناً إلى GPT-2، هو نفس الحلقة بأربع خطوات تتكرر، كما تبين الصورة التالية:

![دائرة من أربع خطوات حول شبكة عصبية: الحساب للأمام Forward ثم الخطأ Loss ثم الرجوع Backward ثم التحديث Update، وبجانبها جدول الخطأ للشبكة الصغيرة في الخطوات من 0 إلى 500، حيث ينخفض مع SGD من 0.9454 إلى 0.5638 بدقة 82 بالمئة ومع AdamW إلى 0.0125 بدقة 100 بالمئة](https://arabroot.io/content/images/2026/10/llm-csharp-1-05-training-loop.webp)

أربع خطوات تتكرر، والأرقام من تدريب الشبكة الصغيرة في هذا المقال

والمهمة التي نختبر عليها بسيطة حتى نرى النتيجة بوضوح: لدينا 256 نقطة عشوائية في المربع من −1 إلى 1، والمطلوب أن تتعلم الشبكة هل النقطة داخل الدائرة `x² + y² < 0.5` أم خارجها، دون أن نخبرها بالمعادلة، وإنما من الأمثلة فقط. والشبكة طبقتان: من 2 إلى 16 مع Tanh، ثم من 16 إلى 2، أي 82 وزناً. والكود التالي من الملف `Part1_Autograd/Part1.cs`:

```csharp
// 5) A tiny network learns whether a point (x, y) lies inside a circle
static void TrainTinyNetwork()
{
    Console.WriteLine("== 5) Tiny network 2 -> 16 -> 2: is the point inside the circle x^2 + y^2 < 0.5?");
    var (xs, ys) = MakeCircleData(new Random(3), 256);
    foreach (var useAdam in new[] { false, true })
    {
        var rng = new Random(4);
        var w1 = Tensor.Randn(rng, 0.5f, 2, 16); var b1 = Tensor.Zeros(16);
        var w2 = Tensor.Randn(rng, 0.5f, 16, 2); var b2 = Tensor.Zeros(2);
        Tensor[] ps = [w1, b1, w2, b2];
        Optimizer opt = useAdam ? new AdamW(ps, lr: 0.05f, weightDecay: 0) : new Sgd(ps, lr: 0.05f);
        Console.WriteLine(useAdam ? "-- AdamW, lr = 0.05" : "-- SGD, lr = 0.05");
        for (int step = 0; step <= 500; step++)
        {
            var h = Ops.Tanh(Ops.Add(Ops.MatMul(xs, w1), b1));   // forward
            var logits = Ops.Add(Ops.MatMul(h, w2), b2);
            var loss = Ops.SoftmaxCrossEntropy(logits, ys);       // loss
            opt.ZeroGrad();
            loss.Backward();                                      // backward
            opt.Step();                                           // update
            if (step % 100 == 0)
                Console.WriteLine($"step {step,3}  loss {loss.Item:F4}  accuracy {Accuracy(logits, ys):P0}");
        }
    }
    Console.WriteLine();
}
```

والمخرج سوف يكون كما يلي:

```text
== 5) Tiny network 2 -> 16 -> 2: is the point inside the circle x^2 + y^2 < 0.5?
-- SGD, lr = 0.05
step   0  loss 0.9454  accuracy 48 %
step 100  loss 0.6644  accuracy 58 %
step 200  loss 0.6491  accuracy 64 %
step 300  loss 0.6282  accuracy 72 %
step 400  loss 0.5995  accuracy 78 %
step 500  loss 0.5638  accuracy 82 %
-- AdamW, lr = 0.05
step   0  loss 0.9454  accuracy 48 %
step 100  loss 0.0714  accuracy 100 %
step 200  loss 0.0352  accuracy 100 %
step 300  loss 0.0231  accuracy 100 %
step 400  loss 0.0167  accuracy 100 %
step 500  loss 0.0125  accuracy 100 %
```

في المخرج أعلاه لاحظ التالي:

- البداية واحدة في الحالتين، لأن الأوزان الأولى من نفس البذرة العشوائية Random Seed، والخطأ 0.9454 والدقة 48%، أي أن الشبكة تخمن تخميناً.
- مع SGD ينخفض الخطأ ببطء إلى 0.5638 وتصل الدقة إلى 82% بعد 500 خطوة، والتعلم يحدث فعلاً ولكنه بطيء.
- مع AdamW وبنفس معدل التعلم يصل الخطأ إلى 0.0714 بعد 100 خطوة فقط والدقة 100%، ثم يستمر في الانخفاض.
- لا توجد في الكود أي معادلة للدائرة، فالشبكة وجدت الحدود من الأمثلة، وهذه هي الفكرة نفسها التي سوف تجعل GPT يتعلم شكل البيت الشعري من نصوص المتنبي في الجزء الثالث، ولكن بأوزان أكثر بكثير.

## مصدر الفكرة

هذه السلسلة مستوحاة من مشاريع Andrej Karpathy التعليمية: [micrograd](https://github.com/karpathy/micrograd?ref=arabroot.io) التي تبني محرك Autograd على مستوى الرقم الواحد، و[nanoGPT](https://github.com/karpathy/nanoGPT?ref=arabroot.io) التي تدرب GPT في بضع مئات من الأسطر، و[llm.c](https://github.com/karpathy/llm.c?ref=arabroot.io) التي تدرب GPT-2 بلغة C دون أي مكتبة، وكلها برخصة MIT. ولكن الكود في هذه السلسلة مكتوب بشكل مستقل بلغة C# ولم ينسخ منها، والفرق الأهم أن محركنا يعمل على مستوى ال Tensor وليس الرقم الواحد مثل micrograd، وأنه محرك عام فيه رسم بياني حقيقي، بينما llm.c تكتب قاعدة الرجوع لكل طبقة من GPT-2 بيدها.

## تدريب عملي

- أضف عملية جديدة `Ops.Exp` في الملف `Shared/Ops.Nn.cs` مع قاعدة رجوعها (مشتقة `exp(x)` هي `exp(x)` نفسها)، ثم أضف لها سطراً في `CheckAllOps` وتأكد أن النتيجة OK.
- غير قاعدة رجوع `Tanh` إلى `g[i] * y[i]` بدلاً من `g[i] * (1 - y[i] * y[i])`، وشغل `part1`، وانظر كيف يكتشف التحقق من التدرج الخطأ، ثم انظر ماذا يحدث لتدريب الشبكة الصغيرة.
- جرب SGD بمعدل تعلم 0.5 بدلاً من 0.05، وقارن الخطأ بعد 500 خطوة مع AdamW.

## الخلاصة

- المشتقة تقول كم يتغير الناتج إذا حركنا المدخل قليلاً، والتدرج هو مشتقة الخطأ بالنسبة لكل وزن، والتدريب هو تحريك كل وزن خطوة صغيرة عكس تدرجه.
- حساب التدرج بتحريك كل وزن يحتاج تشغيلين للنموذج لكل وزن، لذلك نستخدم قاعدة السلسلة: كل عملية تعرف مشتقتها المحلية فقط، والانتشار العكسي يمشي على الرسم البياني للحساب مرة واحدة من النهاية إلى البداية.
- الكلاس Tensor مصفوفة `float[]` مسطحة وشكل وتدرج، وكل عملية تسجل مدخلاتها وقاعدة رجوعها، وقاعدة الرجوع تضيف ب `+=` ولا تستبدل.
- لا تقم بكتابة عملية جديدة دون التحقق من تدرجها عددياً، فالخطأ في قاعدة الرجوع لا يظهر كخطأ برمجي، وإنما كتدريب أسوأ.
- ضرب المصفوفات هو أغلب الوقت، والفرق بين الحلقات الثلاث البسيطة والنسخة التي تستخدم Packing وSIMD وكل الأنوية عشرات المرات.
- AdamW يعطي كل وزن خطوة تناسبه، ولذلك تعلمت الشبكة الصغيرة المهمة في 100 خطوة بينما بقي SGD عند دقة 82% بعد 500 خطوة.

وفي الجزء القادم بإذن الله سوف نستخدم هذا المحرك لنبني أول نموذج لغة حقيقي: نحول شعر المتنبي إلى أرقام حرفاً حرفاً، ونبدأ بنموذج يتوقع الحرف التالي بالعد فقط، ثم نجعله شبكة تتعلم، ثم شبكة تنظر إلى آخر ثمانية حروف وتكتب أبياتاً لها شكل الشعر.

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتشغيل كود الجزء الأول على .NET 10 (SDK 10.0.400) دون أي حزمة NuGet.