القياسات أدناه مسح تاريخي صالح، لكن العبارة بأن بوابة Ember الحالية تعمل قرب 32 MFLOP قديمة. مسار Q8×Q8 decode الحالي يستخدم بوابة 1,048,576 MAC (نحو 2.1 MFLOP). كذلك نقطة العبور تعتمد على عدد الـthreads: أول وسيط أعلى من 1× لعدد 2 و8 يقع بين نقطتي 14.2 و25.2، بينما 4 threads أعلى بقليل فقط عند أول نقطة. هذه وسائط مرصودة وليست عتبة عامة للعتاد.
معظم محركات الاستدلال تعطيك إعداد واحد للتحكم في التوازي: عدد الـthreads. تحطه على 4 أو 8 أو 16 وتفترض إن الرقم الأكبر أسرع.
طيب، هل يتحسن أداء توليد token واحد فعلًا لما تزيد عدد الـthreads؟
الإجابة تعتمد على حجم النموذج ونواة ضرب المصفوفات — وترا ما اختبرت إلا معالج واحد (i5-1135G7). هالملاحظة تحدد نقطة الانتقال اللي يصير عندها تشغيل عدة threads مفيد فعلًا، وتوضح لماذا عدد الـthreads الثابت على مستوى المحرك تجريد خاطئ.
لما يكون في مرحلة decode، يولد النموذج token واحد في كل خطوة. يمر هالـtoken عبر كل الطبقات: إسقاطات الانتباه، وعمليات الـMLP، وRMSNorm، وRoPE. معظم هذا العمل ضرب مصفوفات — عمليات صغيرة، واحدة تلو الأخرى، تضرب كل واحدة منها صفًا واحدًا من التنشيطات في مصفوفة أوزان مكمّمة.
ضرب المصفوفات بطبيعته قابل للتوازي: تقسم بُعد المخرج على عدة threads، تحسب الجداءات النقطية الجزئية، ثم تجمع الناتج. لكن إطلاق الـthreads له كلفة ثابتة. إذا كان حجم الشغل لكل عملية صغير مرّة، كلفة تقسيم العمل والجدولة تتجاوز الفايدة، ويصير تشغيل thread واحد أسرع.
فالسؤال يصير كمي:
عند أي حجم من العمل الحسابي، مقاسًا بعدد الـFLOPs لكل matmul، يصبح التوازي على مستوى الـthreads مفيدًا فعلًا لتوليد token واحد بنواة Q8_0 على معالج استهلاكي؟
سويت تجربتين، على نفس المعالج (i5-1135G7، 4 أنوية فعلية / 8 معالجات منطقية)، وبنفس نواة Q8_0:
على مستوى النموذج: شغّلت 3 نماذج حقيقية بـ 1 و2 و4 و8 threads. قست معدل التوليد باستخدام متتبع العمليات في Ember.
على مستوى النواة: شغّلت مسح اصطناعي يستدعي
matmul_q8_0_decode عند 6 أحجام مصفوفات
بنسبة أبعاد ثابتة 1:3
(embed_dim : inter_dim ≈ 1:3).
100 قياس لكل خلية، وسجّلت الوسيط ومعامل الاختلاف.
كل الـcode في Ember.
| النموذج | embed_dim | inter_dim | MFLOPs/matmul | 1 thread | 2 threads | 4 threads | 8 threads |
|---|---|---|---|---|---|---|---|
| Qwen3-0.6B | 1024 | 3072 | 6.3 | 6.97 tok/s | 6.48 (0.93×) | 5.89 (0.85×) | — |
| LLaMA-3.2-1B | 2048 | 5632 | 23.1 | 2.84 tok/s | 2.73 (0.96×) | 2.78 (0.98×) | — |
| LLaMA-3.2-3B | 3072 | 8192 | 50.3 | 1.05 tok/s | 1.53 (1.46×) | 1.64 (1.56×) | 1.55 (1.48×) |
كل خلية: وسيط 2–3 قياسات بعد 1–2 تشغيلة تسخين. القاعدة الهندسية تحت تعتمد أساسًا على المسح الاصطناعي (100 قياس لكل خلية)؛ خذ أرقام النماذج كتأكيد اتجاهي، مو تقديرات دقيقة عالية الثقة.
عند 6.3 و23.1 MFLOP لكل matmul،
ما جابت زيادة الـthreads أي مكسب
فعلي في التشغيل الكامل للنموذج — بالعكس، صار فيه تراجع
شوي في الأداء. السبب: بوابة
should_parallel_q8_decode في
Ember تعيد false عند هذه الأحجام
— تبقى النواة على المسار التسلسلي، والـthread pool
الأكبر ما يقدم شغل متوازي مفيد.
عند 50.3 MFLOP، 2 threads جابت تسارع 1.46×، و4 threads جابت 1.56×. 8 threads تراجعت إلى 1.48× مع تباين عالٍ — المعالج فيه 4 أنوية فعلية فقط، ويمكن إن التنافس على الذاكرة المخبئية وعرض نطاق الذاكرة عبر Hyper-Threading يكون سبب من أسباب هالتراجع.
يعني على جنب: عبر كل النماذج، بقي
matmul_q8_0 مستحوذًا على 99.4–99.6%
من زمن التنفيذ بغض النظر عن عدد الـthreads.
العمليات غير المصفوفية (RMSNorm،
RoPE، SiLU) بقيت تحت 1%
وما صارت عنق الزجاجة في ولا تجربة.
عشان أقيس منطقة الانتقال عند نقاط محددة، شغّلت مسح على مستوى النواة بس
بأوزان Q8_0 اصطناعية عند 6 مستويات
MFLOP. العمود ms/op هو
وسيط زمن التنفيذ لاستدعاء واحد للنواة — هذا ليس
إنتاجية النموذج الكامل.
| MFLOPs | 1 thread ms/op | 2 threads ms/op | تسارع | σ/median % | 4 threads ms/op | تسارع | σ/median % | 8 threads ms/op | تسارع | σ/median % |
|---|---|---|---|---|---|---|---|---|---|---|
| 14.2 | 1.68 | 2.00 | 0.84× | 9.1 | 1.62 | 1.04× | 6.1 | 1.69 | 1.00× | 6.0 |
| 25.2 | 2.97 | 1.91 | 1.55× | 11.3 | 1.19 | 2.50× | 14.1 | 1.15 | 2.59× | 13.7 |
| 31.9 | 3.84 | 2.19 | 1.75× | 10.9 | 1.65 | 2.33× | 12.8 | 1.54 | 2.50× | 15.1 |
| 39.3 | 4.67 | 3.76 | 1.24× | 18.7 | 1.94 | 2.40× | 6.6 | 1.86 | 2.51× | 13.3 |
| 47.6 | 6.88 | 3.40 | 2.02× | 12.5 | 2.32 | 2.96× | 12.0 | 2.21 | 3.11× | 9.9 |
| 61.4 | 7.23 | 4.68 | 1.54× | 8.2 | 3.30 | 2.19× | 10.7 | 2.93 | 2.46× | 10.1 |
التسارع = ms/op(1 thread) / ms/op(n threads).
كل خلية تمثل 100 قياس.
ملاحظة مهمة: المسح الاصطناعي يتجاوز بوابة
should_parallel_q8_decode عمدًا — الهدف
هنا قياس اللي تقدر تسويه النواة المتوازية لو
شغّلتها دايم، مو اللي يصير فعلًا داخل النموذج. في
التشغيل الحقيقي وقت إجراء هذا المسح، كانت البوابة القديمة
تمنع المسار المتوازي في الأحجام الصغيرة. البوابة الحالية
هي 1,048,576 MAC (نحو 2.1 MFLOP)؛
لذلك لا يجوز تفسير هذه القياسات على أنها وصف لمسار القرار الحالي.
منحنى الـ 2 threads يعدي 1.0× بين 14 و25 MFLOP. 4 و8 threads كلهم يتفوقون على 2 threads بأغلب الأحجام فوق 25 MFLOP. نتائج 8 threads أقل استقرار، بتشتت نسبي أعلى (σ/median يصل 15% مقابل 6–14% لـ 4 threads)، عشان كذا 4 أنوية فعلية هي الخيار الآمن على هالمعالج.
كلفة إرسال العمل إلى thread pool في
Rayon، مقاسة بحلقة فارغة
par_iter().for_each():
| الـthreads | الوسيط (ns) |
|---|---|
| 1 | 35 |
| 2 | 4,127 |
| 4 | 5,891 |
| 8 | 10,250 |
عند 14.2 MFLOP، زمن النواة حوالي 1.7 ms، وكلفة الجدولة ما تتعدى 4 µs مع 2 threads. يعني المشكلة مو في كلفة الجدولة لحالها — البوابة المستخدمة وقت القياس كانت تبقي هالاستدعاءات الصغيرة على المسار التسلسلي. التطبيق الحالي خفّض البوابة كثيرًا؛ هالفقرة تصف التشغيل التاريخي، مو السلوك الحالي.
الخلاصة: على هالمعالج وبنواة Q8_0 المعزولة، أول وسيط تسارع مرصود يعتمد على عدد الـthreads. خيطان و8 خيوط يعبران 1× بين نقطتي 14.2 و25.2 MFLOP، بينما 4 خيوط أعلى من 1× بفارق طفيف عند 14.2. هذا المسح لا يثبت عتبة عامة، وبوابة Ember الحالية معايرة بصورة مستقلة.
في تجربة النموذج الحقيقي، الأداء وصل ذروته عند 4 أنوية فعلية. في المسح الاصطناعي المعزول، أنتجت 8 threads أحيانًا قيم تسارع أعلى شوي في الوسيط، بس بنتائج أقل استقرارًا. اللي يطمن حاليًا: ثبت الـthreads على الأنوية الفعلية.
هذي النتيجة محددة بهالمعالج وهالنواة، ومو قاعدة عامة. لازم تعيد الاختبار على:
عدد threads ثابت على مستوى المحرك، بكل بساطة، تجريد خاطئ.
العمليات المختلفة داخل نفس خطوة الـdecode لها أحجام مصفوفات مختلفة تمامًا. حجم إسقاط Q في الانتباه يقارب ضعف حجم إسقاطَي K وV. عمليات الإسقاط في الـMLP (gate/up/down) أكبر بنحو ثلاثة أضعاف من إسقاط خرج الانتباه. طبقة LM head أعرض بعشرات المرات من إسقاطات الطبقات الداخلية. تعيين نفس عدد الـthreads للجميع يهدر الأنوية على العمليات الصغيرة وما يستغلها زين على العمليات الكبيرة.
الخطوة الجاية: نبني مجدول تكيفي.
if estimated_work < crossover:
serial SIMD
else:
physical-core parallel path
بيانات الانتقال اللي جمعتها هنا تعطينا نقطة المعايرة. وهذا هو المطلوب: ينتقل المشروع من مجرد تحليل أداء المحرك إلى تصميم قرارات الـruntime نفسه.
أقرب نظام منشور هو ProfInfer (2026)، الذي يربط مجسات eBPF بمحركات الاستدلال لتحليل الأداء على مستوى العمليات دون الحاجة إلى تعديل الـcode أو إعادة الترجمة. متتبع Ember يقدم الرؤية المقابلة على مستوى دلالات النموذج: تتبع لكل عملية مع تحديد الطبقة والأبعاد وعدد الـFLOPs، بالإضافة إلى بصمات عددية لفحص التطابق العددي. الفرق الأساسي: ProfInfer يقيس اللي يصير داخل المحرك من الخارج، بس Ember يسجل القياس مع هوية العملية نفسها — اسمها، طبقتها، أبعاد مصفوفاتها — مما يجعل المقارنة بين النماذج والمعالجات ممكنة دون إعادة توصيل المجسات.
أداة llama-bench في
llama.cpp تعطي أرقام إنتاجية كلية
لكنها لا تفصل التسارع لكل عملية على حدة. المسح
الاصطناعي هنا مكمل — يقيس النواة بشكل معزول
لتحديد نقطة الانتقال بدقة.
جميع البيانات الخام وcode المتتبع في مستودع Ember.