← العودة إلى قائمة المقالات
July 6, 2026
5 دقائق للقراءة

فخ الدقة العددية لـ GPU: كيف يُعيد اختبار خلفي بصيغة fp32 على Apple Metal نتائج عشوائية بصمت

فخ الدقة العددية لـ GPU: كيف يُعيد اختبار خلفي بصيغة fp32 على Apple Metal نتائج عشوائية بصمت
#التداول الخوارزمي
#الاختبار الخلفي
#وحدة معالجة الرسومات
#شرائح Apple Silicon
#الفاصلة العائمة
#الاستقرار العددي
#إطار عمل MLX
Part 8 of 10 · Collection
High-Performance Backtest Engines

جزء من سلسلة "اختبارات خلفية بلا أوهام".

قمنا بنقل اختبارنا الخلفي القائم على مسح المعاملات (parameter-sweep) إلى GPU فأصبح أسرع بمقدار 2,796×. الأرقام التي أعادها بدت معقولة. لكنها كانت أيضاً، في أول نسخة عاملة، عشوائية تماماً — بانحراف يبلغ مائتي ضعف تقريباً — ولم يتعطل شيء، ولم يصدر أي تحذير، ولم يبدُ أي شيء خاطئاً.

هذا هو الفخ الذي يجعل الاختبار الخلفي على GPU فوق Apple Silicon خطيراً بطريقة لا يعرفها كود الـ CPU: Metal لا تدعم float64 إطلاقاً. كل رقم يلمسه اختبارك الخلفي على GPU من Apple هو رقم عشري 32-بت (float)، سواء طلبت ذلك أم لا. وأكثر طريقة مغرية لتحويل المتوسط المتحرك إلى صيغة متجهية — حيلة المجموع التراكمي (prefix-sum) ذات التعقيد O(n) التي يلجأ إليها كل كمّي مهووس بالأداء — هي بالتحديد الصيغة التي لا يستطيع fp32 الصمود أمامها عند مقياس الأسعار. إنها لا تفشل بخطأ صريح، بل تعمل بأقصى سرعة وتقدم لك منحنى أسهم يبدو معقولاً، مبنياً على متوسط متحرك خاطئ بمقدار 211 ضعفاً.

الحل هو الجزء المثير للاهتمام، لأنه ليس "استخدم مزيداً من البتات" (لا يمكنك ذلك) وليس "كن أكثر حذراً" (الكود الساذج حذر بالفعل). الحل هو حساب الكمية الرياضية نفسها بمجموع مختلف — التفاف مباشر ذو نافذة (windowed convolution) — يُبقي كل قيمة وسيطة صغيرة بما يكفي ليمثلها fp32 بدقة تامة. نفس WMA، ونفس النتيجة حتى سبعة أرقام معنوية، وأسرع بـ 55.9 مرة من numba أحادي الخيط على الـ CPU. هذا المقال هو التشريح: لماذا تفرض Metal عليك fp32، وأين بالضبط تفيض الصيغة البديهية، ولماذا لا تفيض الصيغة الصحيحة، وكيف أثبتنا — بعدّ الصفقات لا بالنظر إلى المنحنيات — أن النسخة السريعة والنسخة الصادقة بصيغة fp64 متفقتان.

جميع الأرقام هنا قِيست على جهاز Apple M2 Max، بواقع 150,000 شمعة × 80 تركيبة معاملات، أفضل من 3 محاولات، من ملفات المستودع scripts/bench_param_sweep.py (طريقة GPU الخاصة بـ M5، الالتزام (commit) 97eadaf)، وscripts/engine_multitf_gpu.py (04d71e8)، وملاحظات التصميم في scripts/GPU_NOTES.md.

Metal لا تدعم float64

مسار معالجة Metal GPU من Apple مع شطب float64 — كل مسار (lane) مجبر على 32-بت، والأعداد الصحيحة دقيقة فقط حتى 1.6×10⁷

على الـ CPU، يعمل اختبار خلفي بلغة Python/numpy بدقة مزدوجة (double precision) افتراضياً. يمنحك float64 مانتيسا (mantissa) بطول 52 بت: تُمثَّل الأعداد الصحيحة بدقة حتى 2⁵³ ≈ 9×10¹⁵، والدقة النسبية تبلغ نحو 1.1×10⁻¹⁶. وأنت لا تفكر في هذا الأمر تقريباً أبداً، لأنه عند مقياس الأسعار — سعر إغلاق للبيتكوين حول 30,000، أو مجموع تراكمي يصل إلى 10¹⁴ — يملك fp64 هامشاً وافراً.

لا تقدم GPU من Apple ذلك الهامش، لأنها لا تدعم float64 إطلاقاً. لغة Metal Shading لا تحتوي على نوع double أصلاً؛ وكل من MLX وPyTorch-MPS وأي إطار عمل آخر يستهدف Apple Silicon يرث هذا القيد. لا يوجد خيار (flag) لتفعيله، ولا بديل بطيء لكن صحيح. فإذا لامست حسابيتك الـ GPU، فستحدث بصيغة fp32:

  • المانتيسا: 23 بت (أو 24 بت مع البت الضمني الرئيسي). الأعداد الصحيحة دقيقة فقط حتى 2²⁴ = 16,777,216 ≈ 1.6×10⁷.
  • الدقة النسبية: ~1.2×10⁻⁷. أي نحو سبعة أرقام عشرية معنوية، ولا أكثر.

تلك السقف عند 1.6×10⁷ هو لبّ القصة كلها. يبدو رقماً سخياً — ستة عشر مليوناً — إلى أن تلاحظ أن الاختبار الخلفي يبني عادة كميات وسيطة أكبر من ذلك بكثير، وبمجرد أن تتجاوز إحدى القيم الوسيطة 1.6×10⁷، لم يعد fp32 قادراً حتى على تمثيل أعداد صحيحة متتالية، ناهيك عن البنية الكسرية التي تحتاجها. الدقة لا تتدهور تدريجياً؛ بل تسقط من حافة منحدر، وذلك المنحدر يقع عند رقم تتجاوزه بياناتك دون أن ترمش.

والفخ هو أن fp32 يعمل بشكل شبه سليم دائماً. فمعظم الاختبار الخلفي — الأسعار، العوائد، الربح والخسارة (PnL)، نسبة شارب — يعيش براحة ضمن نطاق تكفيه سبعة أرقام. لذا فإن النقل الساذج يعمل، ويجتاز اختبار الدخان (smoke test)، وينتج مخرجات تبدو منطقية. الخلل موضعي تماماً في عملية واحدة فقط، النقطة التي تتجاوز فيها إحدى القيم الوسيطة حاجز 10⁷، وتلك العملية هي بالضبط أول عملية يقوم الجميع بتحويلها إلى صيغة متجهية.

الصيغة المغرية: WMA في مسحة واحدة بالمجموع التراكمي

تعتمد استراتيجيتنا على متوسطات هل المتحركة (Hull moving averages). الـ HMA هو تركيب لثلاثة متوسطات متحركة موزونة معاً؛ أما نسخة HMA3 فتتكون من أربعة. والمتوسط المتحرك الموزون بنواة خطية (linear kernel) على نافذة طولها p يُعطى بالصيغة:

WMAt=k=1pkxtp+kp(p+1)/2.\mathrm{WMA}_t = \frac{\sum_{k=1}^{p} k \cdot x_{t-p+k}}{p(p+1)/2}.

عند مسح آلاف تركيبات المعاملات عبر 150 ألف شمعة (bars)، فإن التفافات WMA هي التكلفة الحقيقية. لذا فإن الغريزة — الغريزة الصحيحة، على الـ CPU — هي جعل كل WMA بتعقيد O(n) بدلاً من O(n·p) باستخدام المجاميع التراكمية (prefix sums). فتحسب مسبقاً مجموعين تراكميين مرة واحدة،

S1[t]=jtxj,S2[t]=jtjxj,S_1[t] = \sum_{j \le t} x_j, \qquad S_2[t] = \sum_{j \le t} j \cdot x_j,

ثم ينهار المجموع الموزون خطياً لأي نافذة إلى حفنة من الفروقات وإزاحات الفهرس على S1 وS2. لا حلقة تكرار لكل نافذة، ولا اختزال لكل نافذة — مجرد مروري cumsum اثنين، وتنبثق مصفوفة WMA بأكملها من حسابيات المصفوفات. إنها تتحول إلى صيغة متجهية بشكل رائع، وتتطابق تماماً مع بدائية المسح المتوازي (parallel-scan) في الـ GPU، وعلى fp64 تكون صحيحة تماماً.

وهي أيضاً أسوأ شيء ممكن أن تفعله على fp32، والسبب مختبئ في S2.

S2 = mx.cumsum(j * price)     # j is the global bar index: 0, 1, 2, ... , n-1

المشكلة تكمن في الحد j · price. فمع تزايد j حتى 150,000 وprice حول 30,000، يكون الحد الأخير وحده 4.5×10⁹، وS2 هو المجموع التراكمي لِـ 150,000 حداً من هذا النوع. إنه لا يبقى عند مقياس الأسعار، بل يتسلق إلى منطقة توقف فيها fp32 بالفعل عن القدرة على العد.

أين يحدث الفيضان: حسابيات الفخ

طرح مجموعين ضخمين من فئة fp32 بحجم ~10¹⁴ لاستعادة قيمة صغيرة خاصة بنافذة معينة، فتختفي هذه القيمة تحت خطأ التقريب الخاص بهما — الإلغاء الكارثي

لنضع مراتب الحجم (orders of magnitude) جنباً إلى جنب، لأن هناك يكمن الخلل بأكمله.

يصل S2 = cumsum(j · price) تقريباً إلى price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴. لنسمّه ~10¹⁴. والآن تذكّر سقف الأعداد الصحيحة الدقيقة في fp32: ~1.6×10⁷. المجموع التراكمي يتجاوز آخر عدد صحيح يستطيع fp32 تمثيله بدقة بمقدار سبع مراتب حجم كاملة.

فما معنى ذلك عملياً؟ بالقرب من 10¹⁴، تكون الفجوة بين رقمين متتاليين قابلين للتمثيل في fp32 — أي وحدة في المرتبة الأخيرة (ULP، وحدة في المكان الأخير) — نحو 2²³ ≈ 8×10⁶. إذن بمجرد أن يصل S2 إلى نطاق 10¹⁴، لا يُعرف إلا بدقة ±8 مليون. كل قيمة يخزّنها قد قُرّبت إلى أقرب مضاعف لِـ ~8×10⁶.

والآن لاحظ ماذا تفعل عملية استخراج WMA بهذا الوضع. لاستخراج المجموع الموزون لنافذة واحدة، تطرح قيمتين متجاورتين من S2 (مع تصحيحات من S1). كل من قيمتي S2 هاتين هي بحجم ~10¹⁴، وكل منهما تحمل ضجيجاً من التقريب مقداره ±8×10⁶. أما الفرق الحقيقي بينهما — وهو القيمة الخاصة بالنافذة التي تريدها فعلاً — فيقابل، بعد التسوية (normalization)، متوسطاً متحركاً WMA من رتبة السعر نفسه، أي ~3×10⁴. إذن الحسابية هي:

(1014±8×106)fp32(1014±8×106)fp32=a quantity you need to a few parts in 104true answer\underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} - \underbrace{(10^{14} \pm 8\times10^6)}_{\text{fp32}} = \underbrace{\text{a quantity you need to a few parts in } 10^4}_{\text{true answer}}

هذا هو الإلغاء الكارثي (catastrophic cancellation) في أنقى صوره: خطأ التقريب لكل معامل (±8×10⁶) أكبر من الإجابة التي تحاول استعادتها. الإشارة أصغر من أرضية الضجيج (noise floor) للأرقام التي استُخرجت منها. والأمر ليس أنك تفقد بضعة أرقام — بل تفقدها كلها، وما يعود إليك يهيمن عليه التقريب المتراكم لعملية cumsum.

المحصلة العملية المقاسة، من GPU_NOTES.md: بالنسبة لـ WMA محسوب بهذه الطريقة على 150 ألف شمعة عند سعر ~30,000، يصل الخطأ النسبي الأقصى مقابل fp64 إلى ~211. ليست 211 بالمئة — بل 211×. يمكن أن يبتعد المتوسط المتحرك المحسوب عن المتوسط الحقيقي بمرتبتي حجم كاملتين. وهنا يكمن ما يجعله فخاً لا مجرد خطأ برمجي: إنه يعمل حتى النهاية ويعيد أرقاماً محدودة ومعقولة الشكل. لا فيضان إلى اللانهاية، ولا NaN، ولا استثناء. متوسط متحرك خاطئ بمقدار 211× لا يزال يبدو مثل متوسط متحرك — إنه سلس، ومحدود (finite)، وفي النطاق الصحيح تقريباً على الشموع التي يكون فيها الإلغاء خفيفاً بالصدفة — لذا فهو يجتاز كل فحص سلامة (sanity check) لا يقارنه مباشرة بمرجع موثوق. تحصل على اختبار خلفي كامل، ومنحنى أسهم كامل، ومجموعة كاملة من المعاملات "المثلى"، كلها مبنية على مؤشر هو محض خيال.

الحل ليس مزيداً من الدقة — بل مجموع مختلف

نافذة منزلقة قصيرة من نحو 200 حد متماثلة الرتبة تُجمع مباشرة عبر الالتفاف (convolution) — يبقى كل مجموع جزئي قريباً من مقياس السعر، ضمن نطاق fp32 الدقيق

رد الفعل الغريزي، بمجرد أن ترى الخطأ، هو التوجه نحو مزيد من الدقة — التجميع في fp64، أو استخدام الجمع التعويضي (Kahan summation). وعلى Metal، الخيار الأول غير متاح ببساطة. لكنك لست بحاجة إلى أي منهما، لأن المشكلة لم تكن يوماً في عدد البتات. المشكلة كانت في الصيغة. فحيلة المجموع التراكمي تصنع قيماً وسيطة بحجم 10¹⁴ ثم تطرحها للعودة إلى الأسفل؛ والمقادير التي تولدها هي نتاج جانبي للخوارزمية، لا للإجابة نفسها. اختر صيغة لا تولّد هذه المقادير أبداً، ويصبح fp32 سليماً تماماً.

تلك الصيغة هي التعريف بذاته: التفاف مباشر ذو نافذة (direct windowed convolution). فبدلاً من مجموعين تراكميين عامّين، تُمرَّر النواة الخطية بطول p عبر السلسلة وتُجمع في مكانها. كل مُخرَج هو مجموع لِـ p ≈ 200 حداً على الأكثر، وكل حد هو weight × price حيث الأوزان مسوّاة (normalized) لتجمع إلى 1 — فيكون كل حد من رتبة price / p، ويبقى كل مجموع جزئي قريباً من مقياس السعر (~3×10⁴)، ولا تقترب أي قيمة وسيطة أبداً إلى ما دون ست مراتب حجم من سقف fp32. لا شيء يُلغى لأن لا شيء تضخّم أصلاً.

في MLX، هذه بدائية واحدة — mx.conv1d — وهي بالضبط ما صُممت الـ GPU لتنفيذه بسرعة:

def _mx_wma_valid(x, period):
    w = mx.arange(1, period + 1, dtype=mx.float32) / (period * (period + 1) / 2.0)
    return mx.conv1d(x.reshape(1, -1, 1), w.reshape(1, period, 1), padding=0).reshape(-1)

إنه نفس الـ WMA، مطابق رياضياً لنسخة المجموع التراكمي، ولنسخة fp64 على الـ CPU: vec_wma/nb_wma. لكن الخطأ النسبي الأقصى المقاس مقابل fp64 أصبح الآن 8.2×10⁻⁷ — عند أرضية ضجيج fp32 نفسها البالغة ~1.2×10⁻⁷ تقريباً، أي سبعة أرقام معنوية متطابقة. الصيغة التي تبدو أبطأ على الورق (O(n·p) بدلاً من O(n)) هي الوحيدة الصحيحة، وبما أنها التفاف كثيف (dense convolution) توازيه الـ GPU عبر الشموع والنوافذ في آن واحد، فهي أيضاً سريعة بشكل مذهل. لقد انتقلنا من خطأ نسبي قدره 211 إلى 8×10⁻⁷ بتغيير طريقة الجمع، لا بعدد البتات التي نجمع بها.

وهناك ملاحظتان عمليتان تنبثقان من اتباع هذا الأسلوب. أولاً، لن تُنشر NaN عبر conv1d في MLX بالطريقة التي تفعلها numpy، لذا فإن منطقة الإحماء (warm-up) — أي أول p−1 شمعة، حيث لا يكون المتوسط المتحرك النافذي معرَّفاً — لا يمكن وسمها بـ NaN على الـ GPU. ولسنا بحاجة إلى ذلك: فبداية الصلاحية لكل سلسلة معروفة تحليلياً، وتُملأ البادئات غير الصالحة بأصفار لا تُقرأ أبداً، وتُستعاد حشوة NaN على الـ CPU لاحقاً — بنفس قناع الصلاحية (validity mask) حرفياً كما في النسختين المتجهية ونسخة numba. ثانياً، تشترك المسحة بأكملها في سلسلة واحدة هي cand_close وتعيد استخدام النوافذ بكثافة عبر التركيبات، لذا فإن استدعاء conv1d دفعياً واحداً بقنوات إخراج متعددة يحسب كل WMA فريد تحتاجه المسحة في استدعاء GPU واحد، يتحقق فعلياً (materialized) عبر mx.eval() واحد.

إثبات أنك لم تقع في الفخ: التكافؤ عبر عدد الصفقات

إليك السؤال المزعج الذي يجب أن يثيره القسم السابق: إذا كان WMA خاطئاً بمقدار 211 ضعفاً لا يزال يبدو مثل WMA، فكيف تعرف أن نسخة 8×10⁻⁷ صحيحة فعلاً وليست مجرد خاطئة بشكل أكثر دقة؟ لا يمكنك الحكم بالعين المجردة. أنت بحاجة إلى ثابت (invariant) يكشفه جزء منفصل ومتقطع من خط الأنابيب في مرحلة لاحقة — والاختبار الخلفي يمنحك واحداً مثالياً: الصفقات.

الطرق الأخرى في سلّم الأداء (M0 إلى M4) تعمل بالكامل على fp64، لذا نُخضعها لتأكيد تكافؤ (equivalence assert) صارم — عدد صفقات مطابق تماماً، وتطابق في الربح والخسارة (PnL) بدقة atol=1e-6. أما طريقة GPU بصيغة fp32 (M5) فهي لا يمكنها اجتياز ذلك بحكم بنيتها، وتخفيف هذا التأكيد بهدوء للجميع لاستيعابها كان سيمثل بالضبط ذلك النوع من عدم الأمانة الذي وُجدت هذه السلسلة لمكافحته. لذا حصلت M5 على تقرير تكافؤ كمّي خاص بها، report_equiv_fp32، يقارن الصفقات المستخرجة منها بالمرجع fp64.

يستحق آلية أي خلاف متبقٍّ أن تُسمّى بدقة، لأنها ليست كارثة الإلغاء — بل هي تقريب fp32 العادي الضئيل الذي تتوقعه. تُطلق الاستراتيجية صفقاتها عند تقاطع متوسطي هل (Hull)، h مقابل h3. خطأ نسبي بمقدار ~1×10⁻⁶ على مؤشر عند سعر ~30,000 يعني تذبذباً مطلقاً بمقدار ~0.03. وفي الغالبية العظمى من الشموع يكون المنحنيان أبعد من ذلك عن بعضهما، فيكون التقاطع واضحاً لا لبس فيه. لكن على شمعة حدّية (borderline) — حيث يكون h − h3 نفسه ضمن 0.03 من الصفر — يمكن لذلك التذبذب أن يقلب إشارة المقارنة، فينقل تقاطعاً واحداً بمقدار شمعة واحدة، مضيفاً صفقة أو حاذفاً إياها.

ولهذا فإن "نسبة التركيبات المختلفة" مقياس صحة عديم القيمة، وقد وقع أول فحص تكافؤ أجريناه في إحراج استخدامه. فعند 150 ألف شمعة، يملك كل تركيب آلاف التقاطعات، لذا تظهر شمعة حدّية واحدة على الأقل في كل تركيب تقريباً — 37 من أصل 80 تركيبة "اختلفت"، وهو رقم يبدو مقلقاً لكنه لا يعني شيئاً. المقياس المهم هو بأي مقدار:

  • فرق PnL عبر جميع التركيبات الـ80: أقصى |Δ| = 1.843 نقطة مئوية، وأقصى نسبي = 1.25×10⁻²؛ عتبة الانهيار 5 نقاط مئوية.
  • انحراف عدد الصفقات لكل تركيب: أقصى |Δn| = 4 صفقات من أصل آلاف، وأقصى نسبي = 2.5×10⁻³؛ عتبة الانهيار 1%.
  • إجمالاً: 90 صفقة منزاحة من أصل 479,016 — أي 0.019%.

تسعون صفقة من أصل ما يقارب نصف مليون، كل واحدة منها تقاطع حدّي دُفع قليلاً بتذبذب تقريب أصغر من نبضة سعر واحدة (price tick)، ولا شيء منها قريب من عتبات الانهيار. هذا هو بصمة طريقة fp32 صحيحة — خلاف صغير، محدود، وقابل للتفسير — وهو حيوان مختلف تماماً عن خطأ نسبي قدره 211. العتبات موجودة لرصد صيغة معطوبة تتنكر بحجة "حسناً، إنه مجرد fp32"؛ أما الفروق الحقيقية فتأتي أقل من العتبات بمرتبة حجم كاملة. عدد الصفقات هو الوحي (oracle) الذي رفض منحنى الأسهم أن يكونه.

الثمرة، وأين تتوقف الـ GPU عن المساعدة

بعد إثبات الصحة، تستحق السرعة أن تُذكر — ثم تُقيّد بأمانة، لأن ميزة الـ GPU ليست موحدة عبر خط الأنابيب بأكمله.

على التفافات WMA الخالصة بمعزل عن غيرها — وهي العملية التي وُجدت الطريقة بأكملها لتسريعها — تعمل دفعة conv1d بصيغة fp32 بسرعة أكبر بـ 55.9 مرة من numba أحادي الخيط، عند نفس الخطأ النسبي 8.2×10⁻⁷. هذا هو الرقم النظيف المتكافئ (apples-to-apples) بين GPU وCPU: نفس الرياضيات، خيط واحد من كود CPU مُصرَّف مقابل GPU من Metal.

لكن المسحة ليست مجرد التفافات فقط. فبمجرد حساب مصفوفات HMA/HMA3 على الـ GPU، لا يزال يتعين استخراج الصفقات — وهو مرور بتعقيد O(n) عبر تقاطعات كل تركيب — ونحن ننفذ ذلك على الـ CPU بصيغة fp64، معيدين استخدام دلالة الصفقات (trade semantics) الدقيقة نفسها المستخدمة في الطرق الأخرى بدلاً من إعادة تنفيذها على الـ GPU. رقم timed() من طرف إلى طرف يشمل كل شيء: باستثناء إحماء النواة (kernel warm-up) (بشكل متماثل مع استثناء وقت تصريف numba)، لكن مع تضمين نقل البيانات من GPU إلى CPU واستخراج الصفقات على الـ CPU. على 150 ألف شمعة × 80 تركيبة، أفضل من 3 محاولات، على M2 Max:

Method Wall Speedup vs baseline combos/s
M0 pandas + حلقة Python* 287.08s 1.0× 0.3
M1 numpy بصيغة متجهية 3.14s 91.5× 25.5
M2 numba (متسلسل) 2.02s 142.3× 39.7
M3 معالجة متعددة العمليات + صيغة متجهية 0.50s 570.2× 158.9
M4 معالجة متعددة العمليات + numba (12 نواة) 0.33s 882.5× 245.9
M5 MLX GPU (fp32) 0.10s 2796.0× 779.2

*M0 مُستقرأ (extrapolated) من عينة موحدة من 5 تركيبات.

محرك M5 الكامل ينجز 779 تركيبة/ثانية — أي 2,796× أسرع من خط أساس pandas، و19.6× أسرع من numba المتسلسل، و3.2× أسرع من كامل مجمّع الـ 12 نواة CPU الذي يشغّل numba (M4). وحدة GPU واحدة تتفوق على كل نواة CPU يملكها الجهاز، بثلاثة أضعاف.

والآن التقييد الصادق: لاحظ أن ميزة الـ GPU من طرف إلى طرف (19.6× مقابل M2) أصغر من ميزة الالتفاف فقط (55.9× مقابل numba). هذه الفجوة هي قانون أمدال (Amdahl's law) يصل في موعده. تُبيد الـ GPU الالتفافات إبادة تامة لدرجة أنها تتوقف عن كونها عنق الزجاجة؛ وما تبقى — استخراج الصفقات على CPU بتعقيد O(n)، الذي لم تُسرّعه الـ GPU إطلاقاً — أصبح الآن يهيمن على زمن التنفيذ الفعلي (wall time) لـ M5. وهذا هو نفس الدرس الذي تخلص إليه مقالتا سلّم سرعة محرك الاختبار الخلفي وضريبة IPC في هذه السلسلة مراراً: بعد نقطة معينة، لا يكون الفوز في "جعل الجزء السريع أسرع"، بل في التنسيق (orchestration) — أين تقيم البيانات، وأي مرحلة أصبحت الآن متسلسلة، وما الذي تدفعه ثمناً لنقل البيانات بين الجهاز (device) والمضيف (host). مطاردة نسخة M6 افتراضية تدفع استخراج الصفقات إلى نواة Metal مخصصة لن تستعيد سوى الشريحة المتقلصة من CPU، ولهذا لم نبنِها.

الدرس العام: القمامة العددية الصامتة هي الافتراضي

لنبتعد قليلاً عن متوسطات هل وMLX، لأن هذا الفخ يتعمم إلى ما هو أبعد بكثير من هذا المؤشر الواحد.

العرض المغري للاختبار الخلفي على GPU هو "مصفوفة كبيرة واحدة": كدّس كل تركيبة معاملات في موتر (tensor) واحد، شغّل المسحة بأكملها كحفنة من عمليات مصفوفات كثيفة، ودع العتاد يلتهمها. هذا العرض حقيقي — والتسريعات المذكورة أعلاه حقيقية. لكنه يغيّر بهدوء النظام العددي الذي تعمل تحته، والتغيير غير مرئي في الكود. على الـ CPU كانت إعداداتك الافتراضية تحميك: fp64، ونشر NaN، وcumsum يمكنه الوصول إلى 10¹⁴ دون أن يُلاحَظ. انقل نفس تعبير المصفوفة إلى Metal وستجد نفسك في fp32 بسقف صارم للأعداد الصحيحة عند 1.6×10⁷، وسطر الكود نفسه بالضبطcumsum(j * price) — ينتقل من الدقة التامة إلى القمامة. لا شيء في الصياغة (syntax) يحذّرك. المصرّف (compiler) سعيد. المخرجات محدودة (finite) ومعقولة. fp32 لا يفشل بصخب؛ بل يفشل بأدب، بأرقام.

العادات الثلاث التي تحميك فعلاً رخيصة التكلفة:

  1. اعرف أين تقيم قيمك الوسيطة، لا مدخلاتك ومخرجاتك فقط. كانت المدخلات (أسعار ~10⁴) والمخرجات (WMA ~10⁴) كلتاهما براحة ضمن النطاق الدقيق لـ fp32. أما الكارثة فكانت بأكملها في قيمة وسيطة مخفية (S2 ~10¹⁴) لم تُظهرها لا واجهة البرمجة (API) ولا الأنواع (types). قبل أن تثق بأي عملية اختزال (reduction) على fp32، اسأل ما هو أكبر مجموع جزئي يصله — وإذا تجاوز ~10⁷، غيّر الصيغة.
  2. فضّل الصيغ التي تُبقي المقادير محدودة. الالتفاف المباشر بدلاً من المجاميع التراكمية؛ النوافذ المحلية بدلاً من المسوحات العامة؛ التمركز/التفاضل (centering/differencing) قبل الجمع لا بعده. النمط المضاد هو "تضخيم ثم إلغاء". الخوارزمية الصحيحة غالباً هي تلك التي تبدو أسوأ تقاربياً (asymptotically) على الورق لكنها لا تصنع أبداً مقداراً يتعين عليها إلغاؤه لاحقاً.
  3. تحقق مقابل مرجع (oracle) من فئة fp64 عبر ثابت متقطع (discrete invariant). لا تقارن المنحنيات؛ بل قارن شيئاً مُكمَّاً (quantized) ولاحقاً في خط الأنابيب — عدد الصفقات، عدد التقاطعات، أحداث تغيّر المركز. الثابت المتقطع يحوّل خطأً صامتاً بمقدار 211× إلى فشل تأكيد صارخ، ويحوّل خطأً مقبولاً بمقدار 8×10⁻⁷ إلى فرق صغير، محدود، وقابل للتفسير. وهذا هو نفس الانضباط المتّبع في اختبار إزاحة الشمعة الواحدة لتحيّز الاستطلاع المسبق (look-ahead bias): تشخيص رخيص يحوّل فشلاً غير مرئي إلى فشل مرئي.

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

الخلاصات

التحول بسطر واحد: cumsum(j·price) مشطوب عند خطأ 211×، وmx.conv1d بجانبه عند 8×10⁻⁷ — نفس WMA، مجموع مختلف

  1. الـ GPU من Apple لا يدعم float64 — كل رقم على GPU في اختبارك الخلفي هو fp32. الأعداد الصحيحة دقيقة فقط حتى ~1.6×10⁷ والدقة نحو ~1.2×10⁻⁷. لا يوجد خيار لتفعيله، ولا بديل. معظم الاختبار الخلفي ينجو من هذا؛ لكن عملية واحدة بالتحديد عادة لا تنجو.
  2. WMA بالمجموع التراكمي هو الفخ. يتسلق cumsum(j · price) إلى ~10¹⁴، أي سبع مراتب حجم متجاوزة سقف fp32 الدقيق، واستعادة نافذة واحدة تجبرك على طرح رقمين من هذا النوع خطأ التقريب فيهما (±8×10⁶) يفوق الإجابة نفسها بالفعل. أقصى خطأ نسبي مقاس: 211×. لا يتعطل أبداً — بل يُعيد قمامة معقولة الشكل.
  3. الحل مجموع مختلف، لا مزيد من البتات. الالتفاف المباشر ذو النافذة (mx.conv1d) يُبقي كل مجموع جزئي قريباً من مقياس السعر، فيحافظ fp32 على سبعة أرقام صادقة: خطأ نسبي 8.2×10⁻⁷، وأسرع بـ 55.9 مرة من numba أحادي الخيط. لا يمكنك شراء fp64 على Metal، ولست بحاجة إلى ذلك.
  4. تحقق بثابت متقطع، لا بالمنحنى أبداً. رصد تكافؤ عدد الصفقات المشكلة: اختلف conv1d بصيغة fp32 مع fp64 في 90 صفقة من أصل 479,016 (0.019%)، كلها تقاطعات حدّية، وكلها بعيدة جداً عن عتبات الانهيار — وهي بصمة طريقة صحيحة، مختلفة تماماً وبلا لبس عن خطأ بمقدار 211×. "نسبة التركيبات المختلفة" مقياس مضلِّل؛ قِس بأي مقدار.
  5. المسحة الكاملة تنجز 779 تركيبة/ثانية — أي 2,796× أسرع من خط أساس pandas، و3.2× أسرع من كامل مجمّع الـ 12 نواة CPU — لكن الفوز من طرف إلى طرف (19.6× مقابل numba المتسلسل) أصغر من فوز الالتفاف فقط (55.9×) لأن استخراج الصفقات على CPU أصبح الآن عنق الزجاجة. بعد نقطة معينة، تصبح السرعة تنسيقاً (orchestration)، لا حسابيات.

كان نقل الكود إلى GPU أسرع بـ 2,796×، وفي أول نسخة عاملة منه، خاطئاً تماماً — ولم تكن للحقيقتين أي علاقة ببعضهما. السرعة كانت حقيقية. أما القمامة فكانت قيمة وسيطة مخفية بحجم 10¹⁴ لم يستطع fp32 احتواءها ولن تذكرها أي رسالة خطأ. إذا أصبح اختبارك الخلفي أسرع بشكل درامي وظلت الأرقام تبدو سليمة، فهذا ليس تأكيداً. فعلى Metal، "تبدو سليمة" هو بالضبط ما يبدو عليه خطأ نسبي قدره 211.

هذه هي درجة GPU في السلّم الذي ما فتئت هذه السلسلة تتسلقه: سلّم سرعة محرك الاختبار الخلفي، وضريبة IPC للمعالجة المتعددة، وتصنيف تحيّز الاستطلاع المسبق للتسريبات، وتصميم دالة الهدف الذي يقرر ما تعنيه كلمة "جيد" أصلاً. السرعة عديمة القيمة إذا كانت سريعة في حساب الرقم الخاطئ.

blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

ابقَ متقدماً على السوق

اشترك في نشرتنا الإخبارية للحصول على رؤى حصرية حول تداول الذكاء الاصطناعي وتحليلات السوق وتحديثات المنصة.

نحترم خصوصيتك. يمكنك إلغاء الاشتراك في أي وقت.