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

سلم سرعة محرك الاختبار الرجعي: 298 ضعفاً على وحدة معالجة كمبيوتر محمول، بنفس PnL حتى آخر صفقة

سلم سرعة محرك الاختبار الرجعي: 298 ضعفاً على وحدة معالجة كمبيوتر محمول، بنفس PnL حتى آخر صفقة
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization

مقال من سلسلة "اختبارات رجعية بلا أوهام".

📄 تطورت هذه المقالة إلى ورقة بحثية. نواة اختبار رجعي واحدة تعتمد على المسار (path-dependent) تم تطبيقها بخمس طرق مختلفة — من pandas الساذجة وحتى نواة numba المتوازية — مع التحقق من كل درجة لضمان إنتاج نفس PnL لكل توليفة، بحيث يكون الفارق الوحيد هو السرعة. اقرأ الورقة على الإنترنت (نسخة تفاعلية + PDF) على speed-ladder.marketmaker.cc، والكود والبيانات على github.com/suenot/backtest-speed-ladder.

سبعون ثانية. هذا هو الوقت الذي يستغرقه التطبيق المرجعي الساذج لمسح 80 توليفة معلمات لاستراتيجية متوسط متحرك واحدة على 150,000 شمعة: rolling().apply() من pandas للمؤشرات، وحلقة Python عادية للصفقات. هذا هو الشكل الذي يعمل به جزء كبير من كود البحث الحقيقي، لأنه الشكل الذي ينتج بشكل طبيعي عند كتابة الاستراتيجية بالطريقة البديهية.

نفس المسح، على نفس الحاسوب المحمول، مُنتجاً نفس PnL لكل توليفة حتى آخر صفقة: 0.23 ثانية.

الفجوة بين هذين الرقمين — 298 ضعفاً مقيسة — هي موضوع هذا المقال. لم تأتِ نقطة مئوية واحدة منها من عتاد جديد. لم تكن هناك وحدة معالجة رسوميات (GPU) في الصورة (لا توجد حتى واحدة متاحة على هذا الجهاز بمعنى CUDA). كل درجة من السلم هي نفس الاستراتيجية، نفس البيانات، نفس الرسوم، نفس عدد الصفقات، مُتحقق منها ببوابة تكافؤ (equivalence gate) تُفشل كل المقارنة إذا اختلفت نتائج أي تطبيق لكل توليفة. ما تغيّر هو فقط كيفية التعبير عن العمل: ما الذي يعمل في المُفسِّر (interpreter)، وما الذي يعمل مُترجَماً (compiled)، وما الذي يعمل بالتوازي. ولأن خط أساس بطيء عن قصد يمكن أن يُجمّل أي رقم عنوان، إليك رقماً إضافياً مسبقاً: حتى مقارنة بتطبيق numpy مُتّجه كفؤ — الكود الذي كان سيكتبه مبرمج numpy قوي — يظل المحرك النهائي أسرع بحوالي 13 ضعفاً.

عندما يكون البحث عن المعلمات بطيئاً، يكون رد الفعل المعتاد هو التوجه نحو عتاد أكبر — وحدة معالجة رسوميات، عنقود حوسبة، ميزانية سحابية. الواقع المقيس لهذه التجربة يشير إلى شيء أقل بريقاً بكثير: كان الاختناق هو المحرك (حلقة داخلية مُفسَّرة تُجري استدعاءات Python لكل نافذة) والتنسيق (orchestration) (تشغيل توليفات مستقلة بشكل تسلسلي على نواة واحدة). كلاهما قابل للإصلاح في فترة بعد ظهر واحدة، على الجهاز الذي تملكه بالفعل، بدون أي تغيير في النتائج.

إليك السلم كاملاً مسبقاً. كل ما يلي هو تشريح كل خطوة.

الدرجة التطبيق الوقت الفعلي التسريع توليفة/ثانية
M0 pandas: rolling.apply + حلقة Python للشموع 69.92 ث 1.0x 1.1
M1 numpy: WMA بنافذة منزلقة + صفقات مُتّجهة 3.07 ث 22.7x 26.0
M2 numba: @njit WMA + حلقة أحداث @njit 1.98 ث 35.3x 40.4
M3 numba prange: خيوط عبر التوليفات 0.32 ث 217.6x 248.9
M4 مجمع عمليات + numba: عمليات عبر التوليفات 0.23 ث 297.9x 340.9

Apple M2 Max (12 نواة)، Python 3.14.6، numpy 2.4.3، numba 0.64.0، BLAS (Accelerate) مُثبّت على خيط واحد بحيث تكون الدرجات أحادية الخيط أحادية النواة فعلياً. 150,000 شمعة × 80 توليفة، أفضل من 3 قياسات للوقت الفعلي، مع استبعاد إحماء JIT. جميع الدرجات — بما فيها خط أساس pandas — مقيسة كاملة ومُتحقق منها لإنتاج نفس PnL ونفس عدد الصفقات لكل توليفة على جميع التوليفات الـ80.

نواة واحدة، خمسة تطبيقات

خمس درجات لسلم واحد: نفس نواة الاختبار الرجعي تتسلق من خط أساس pandas بـ70 ثانية إلى تشغيل numba متوازٍ بـ0.23 ثانية، كل خطوة مُتحقق من إنتاجها لنفس PnL

لكي تكون مقارنة السرعة ذات معنى، يجب تحديد الشيء الذي يُحسب بدقة، ويجب إثبات أن كل تطبيق يحسبه. لذا تُثبّت التجربة نواة استراتيجية واحدة وتُبقيها ثابتة عبر جميع الدرجات الخمس.

النواة هي تقاطع HMA/HMA3 — نظام وقف وعكس (stop-and-reverse) على متوسطين متحركين من نمط Hull. اللبنة الأساسية هي المتوسط المتحرك المُرجّح:

WMAp(x)i=j=1pjxip+jj=1pj\mathrm{WMA}_p(x)_i = \frac{\sum_{j=1}^{p} j \cdot x_{i-p+j}}{\sum_{j=1}^{p} j}

المتوسط المتحرك Hull يُركّب ثلاثة منها لتقليل التأخر:

HMAn(x)=WMAn(2WMAn/2(x)WMAn(x))\mathrm{HMA}_n(x) = \mathrm{WMA}_{\lfloor\sqrt{n}\rceil}\Big(2\,\mathrm{WMA}_{\lfloor n/2\rceil}(x) - \mathrm{WMA}_{n}(x)\Big)

وHMA3 هي نسخة أكثر نعومة تُبنى من WMAs عند تقريباً n/6n/6، n/4n/4 وn/2n/2، مع تنعيم إضافي مرة أخرى. لكل توليفة معلمات، هذا يعني سبعة تمريرات WMA على ستة أطوال نوافذ مختلفة — رزمة مؤشرات حقيقية، وليست لعبة.

قاعدة التداول مُصمَّمة عن قصد لتكون ذات حالة (stateful) ومفيدة: الاتجاه صاعد عندما يكون HMA أقل من HMA3 وهابط في الحالة الأخرى؛ افتح مركزاً عند أول اتجاه مُحدَّد؛ عند كل تقاطع، أغلق المركز، احسب PnL ناقص رسم ذهاب وإياب 0.09%، واعكس. المركز يحمل عبر الشموع — ما تفعله عند الشمعة ii يعتمد على الحالة المتراكمة منذ آخر تقاطع. هذا الاعتماد على المسار هو بيت القصيد من التجربة بأكملها: إنها الخاصية التي تجعل الاختبارات الرجعية مختلفة عن خطوط أنابيب dataframe العامة، و(كما سنقيس) تُعقّد سؤال وحدة معالجة الرسوميات — لكن ليس، كما يتضح، بالطريقة التي تقول بها الحكمة الشعبية.

بقية الإعداد، حتى تحكم على الأرقام:

  • البيانات: 150,000 شمعة من حركة براونية هندسية (geometric Brownian motion) اصطناعية، مُبذَرة (seed=42). الأداء هنا مُقيَّد بحجم المصفوفة وأطوال النوافذ، وليس بأي مسار سعري تُدخله — وسلسلة اصطناعية تجعل التجربة بأكملها حتمية وقابلة للتكرار من قِبل أي شخص.
  • الشبكة: 80 طول HMA مختلف موزع على [6,200][6, 200] — بحيث يحتوي المسح على توليفات رخيصة ذات نافذة قصيرة وأخرى مكلفة ذات نافذة طويلة، مثل أي شبكة حقيقية.
  • التوقيت: الوقت الفعلي (wall-clock)، أفضل من 3 قياسات لكل درجة، مع إحماء تجميع JIT خارج المؤقّت وإحماء عمال المجمع قبل بدء العد. كل درجة — بما فيها خط أساس pandas — تُقاس كاملة عبر جميع التوليفات الـ80. BLAS (Accelerate من Apple) مُثبّت على خيط واحد، بحيث تكون الدرجات أحادية الخيط أحادية النواة فعلياً: درجة numpy لا تُشغّل ضرب المصفوفات في خيوط متعددة بصمت خلف ظهر المقارنة.
  • بوابة التكافؤ: بعد التوقيت، يُقارَن متجه (PnL، عدد الصفقات) لكل توليفة في كل درجة مقابل المرجع — يجب أن يتطابق عدد الصفقات تماماً، وPnL بحدود 10610^{-6} نقطة مئوية مطلقة. التشغيل المُثبّت يُبلّغ all_ok: true لكل درجة، بما فيها خط أساس pandas، على جميع التوليفات الـ80. إذا فشلت هذه البوابة، فلا يوجد مقياس أداء — بل خمسة برامج تحسب خمسة أشياء مختلفة بخمس سرعات مختلفة، وهذا كيف تعمل كثير من ادعاءات "محركنا أسرع بـ100 ضعف" بصمت.

رقم واحد من كتلة التكافؤ يستحق لحظة صدق: بصمة أول توليفة هي PnL بقيمة −5165.58 نقطة مئوية عبر 57,029 صفقة. هذه ليست نتيجة استراتيجية يجب الخجل منها — إنها أقصر طول HMA (6) يتقلب عند كل تذبذب تقريباً لمسار عشوائي ويدفع 0.09% في كل مرة، تماماً كما ينبغي. إنها بصمة صحة (correctness fingerprint)، وليست اختباراً رجعياً قابلاً للتداول. لا تقرأ فيها ميزة (alpha)؛ اقرأ فيها الحتمية — خمسة تطبيقات تصل إلى نفس 57,029 صفقة ونفس PnL حتى ستة منازل عشرية هو ما يعنيه "متطابق" هنا.

مع ثبات ذلك، كل تسريع أدناه هو سرعة خالصة. لم يُقرَّب أو يُختصر أي شيء.

الدرجة M0: ملف pandas الساذج — 69.9 ث

تشريح خط أساس pandas الساذج: نافذة rolling.apply تُولّد استدعاء lambda من Python لكل واحدة من 150,000 شمعة بينما تزحف حلقة المُفسِّر تحتها

خط الأساس ليس حجة واهية (strawman). إنه الكود الذي تحصل عليه عندما تكتب WMA بالطريقة التي يقترحها توثيق pandas، وحلقة الأحداث بالطريقة التي يقرأها وصف الاستراتيجية:

def pd_wma(s: pd.Series, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    w /= w.sum()
    return s.rolling(period).apply(lambda x: np.dot(x, w), raw=True).to_numpy()

def run_pandas_one(close, length):
    h, h3 = pd_hma(close, length), pd_hma3(close, length)  # 7 rolling.apply WMAs
    total, ntr, prev_dir, entry, pos = 0.0, 0, 0, 0.0, 0
    for i in range(len(close)):                            # Python bar loop
        if np.isnan(h[i]) or np.isnan(h3[i]):
            continue
        d = 1 if h[i] < h3[i] else -1
        if prev_dir == 0:
            prev_dir, pos, entry = d, d, close[i]
            continue
        if d != prev_dir:                                  # cross: close + reverse
            pnl = ((close[i] - entry) if pos == 1
                   else (entry - close[i])) / entry * 100 - FEE
            total += pnl
            ntr += 1
            pos, entry, prev_dir = d, close[i], d
    return total, ntr

لماذا هذا بطيء؟ ليس لأن pandas "سيئة" — بل بسبب أين يعيش التكرار. rolling(period).apply(lambda ...) هي حلقة على مستوى Python ترتدي زي تطبيق مُتّجه. لكل واحدة من 150,000 شمعة، تُجسّد pandas نافذة، تعبر حدود C/Python، تستدعي دالة Python قابلة للاستدعاء، وتُغلّف (boxes) النتيجة. حتى مع raw=True (التي على الأقل تُعطي lambda مصفوفة ndarray عارية بدلاً من Series)، النفقات العامة للمُفسِّر لكل استدعاء تطغى بكثير على عشرات إلى مئات عمليات الفاصلة العائمة (FLOPs) التي تحتاجها النافذة فعلياً. اضرب ذلك بسبع تمريرات WMA لكل توليفة، ورزمة المؤشرات وحدها تصبح ملايين الرحلات ذهاباً وإياباً في المُفسِّر. ثم تُشغّل حلقة الشموع 150,000 تكرار مُفسَّر إضافي لكل توليفة، كل واحد يقوم بفهرسة مُتحقق من حدودها على أعداد numpy القياسية، وتغليف الأعداد العشرية، وتوزيع ديناميكي حسب الأنواع التي يُعيد المُفسِّر اكتشافها في كل مرة.

النتيجة: 69.92 ث للمسح، حوالي 0.87 ث لكل توليفة، بمعدل نقل 1.1 توليفة في الثانية. على شبكة من 80 توليفة تهز كتفيك وتنتظر دقيقة. المشكلة أن لا أحد يُشغّل شبكات بـ80 توليفة لفترة طويلة — وهذه التكلفة تتوسع خطياً إلى الأبد. سنعود إلى ذلك.

الدرجة M1: numpy — توقف عن استدعاء Python في حلقة — 3.07 ث، 22.7x

الدرجة الأولى للأعلى تُلغي حلقتي المُفسِّر معاً، ومن المفيد فصل الحيلتين لأن لهما درجة عمومية مختلفة جداً.

جانب المؤشرات هو الجانب السهل، الشامل بالكامل. المتوسط المتحرك المُرجّح عبر كل النوافذ هو ببساطة ضرب مصفوفة في متجه على عرض ممتد (strided view) للمُدخل — بدون نسخ، استدعاء BLAS واحد:

def vec_wma(x: np.ndarray, period: int) -> np.ndarray:
    w = np.arange(1, period + 1, dtype=np.float64)
    win = np.lib.stride_tricks.sliding_window_view(x, period)  # zero-copy view
    out = np.full(len(x), np.nan)
    out[period - 1:] = win @ w / w.sum()                       # one matvec
    return out

sliding_window_view تبني عرضاً بشكل (n − p + 1, p) لنفس الذاكرة، وwin @ w تحسب حاصل الضرب النقطي لكل نافذة في كود مُترجَم. مليون استدعاء lambda يصبح استدعاء مكتبة واحداً.

جانب الصفقات هو الأكثر إثارة للاهتمام، لأن حلقة الأحداث ذات حالة — ومع ذلك، لهذه النواة بالتحديد، فهي تُتَّجه (vectorizes). البصيرة هي أن المركز عند أي شمعة يعتمد فقط على إشارة HMA − HMA3، وليس على أي نتيجة صفقة. الحالة لا تتغذى مرتدةً إلى القرارات أبداً. لذا تنهار الحلقة بأكملها إلى "ابحث عن انقلابات الإشارة، اجمع الأسعار عند تلك الفهارس":

d = np.where(h[idx] < h3[idx], 1, -1)             # direction per valid bar
flips = np.flatnonzero(np.diff(d) != 0) + 1       # bars where it crosses
cross = idx[np.concatenate(([0], flips))]         # entry/exit indices
side  = d[np.concatenate(([0], flips))]
entries, exits, s = close[cross[:-1]], close[cross[1:]], side[:-1]
pnl = np.where(s == 1, (exits - entries) / entries,
               (entries - exits) / entries) * 100 - FEE
return float(pnl.sum()), int(pnl.size)

3.07 ث، تسريع 22.7x، 26.0 توليفة في الثانية — على نواة واحدة، مع تثبيت BLAS على خيط واحد. تستحق هذه الدرجة تسمية: إنها خط الأساس الكفؤ، التطبيق الذي سيكتبه مبرمج numpy قوي، والمقياس العادل لكل ما فوقها. لكن تحذيرين صادقين يرافقان هذه الدرجة.

أولاً، هذا الاتجاه هو إعادة كتابة تحليلية خاصة بالاستراتيجية، وليس تحويلاً آلياً. إنه موجود لأن النواة هي وقف وعكس بدون وقف خسارة، بدون خروج متحرك (trailing exit)، بدون تحجيم مركز يعتمد على PnL الجاري. أضف وقف خسارة — الميزة الأكثر عادية التي يمكن تخيلها — وسيتغير الخروج عند الشمعة ii أي دخول موجود عند الشمعة j>ij > i، تتغذى الحالة مرتدةً إلى المسار، وتتبخر الصيغة المغلقة. معظم النوى الإنتاجية تعيش على الجانب الخاطئ من هذا الخط.

ثانياً، هذه هي الدرجة التي تموت فيها الصحة. محاسبة فهرسة الانقلاب (+1 هنا، [:-1] هناك، بذر الاتجاه الأول) هي بالضبط نوع الكود الذي يُنتج أخطاء تنفيذ من نوع الفرق بواحد (off-by-one) — نفس نوع الخطأ الذي أظهر تصنيف النظر المستقبلي لدينا أنه يمكن أن يُصنّع Sharpe بقيمة 15 من الضوضاء. بوابة التكافؤ ليست شكلية على هذه الدرجة؛ إنها السبب الوحيد للثقة بها. إعادة الكتابة المُتّجهة الذكية بدون فحص تكافؤ مقابل تطبيق مرجعي ساذج هي كيف تنجرف المحركات بعيداً عن الاستراتيجية التي تدّعي اختبارها.

الدرجة M2: numba — تجميع الحلقة التي تريد كتابتها فعلاً — 1.98 ث، 35.3x

حلقة أحداث Python تمر عبر مُترجِم JIT الخاص بـ numba وتخرج ككود آلي مُحكم: نفس المنطق المتشعب لكل شمعة، مُترجَم بدلاً من مُفسَّر

تتبنى الدرجة M2 الفلسفة المعاكسة: بدلاً من تحوير الخوارزمية لتناسب البدائيات المُتّجهة، اكتب الحلقات الساذجة — وتَرجِمها. Numba (Lam وPitrou وSeibert، 2015) تُجمّع بواسطة JIT مجموعة فرعية عددية من Python عبر LLVM إلى كود آلي:

@njit(cache=True)
def nb_wma(x, period):
    n = x.shape[0]
    out = np.full(n, np.nan)
    wsum = period * (period + 1) / 2.0
    for i in range(period - 1, n):        # the "slow" loop, now machine code
        s = 0.0
        for j in range(period):
            s += x[i - period + 1 + j] * (j + 1)
        out[i] = s / wsum
    return out

@njit(cache=True)
def nb_sweep(close, half, full, sq, p3, p2, pi, fee):
    h  = nb_wma(2.0 * nb_wma(close, half) - nb_wma(close, full), sq)
    a  = 3.0 * nb_wma(close, p3) - nb_wma(close, p2) - nb_wma(close, pi)
    h3 = nb_wma(a, pi)

حلقة الأحداث داخل nb_sweep هي حرفياً حلقة M0. التفرعات، continue، الحالة المحمولة في المتغيرات المحلية — كل ذلك. تحت @njit تعيش هذه المتغيرات المحلية في السجلات (registers)، التفرعات هي تعليمات قفز حقيقية، وتكلفة كل تكرار تنخفض من ميكروثوانٍ من توزيع المُفسِّر إلى نانوثوانٍ.

1.98 ث — 35.3x مقابل pandas، لكن فقط حوالي 1.6x مقابل numpy (مُشتقة: 3.07/1.98). هذه الخطوة المتواضعة مفيدة بحد ذاتها: حلقات numpy الداخلية كانت مُترجَمة بالفعل، لذا يقتصر فوز numba على رياضيات المؤشرات على تجاوز تجسيد النافذة والمصفوفات الوسيطة. الجزء التحويلي في مكان آخر:

  1. حلقة الأحداث مجانية الآن — و"مجانية" مقيسة، لا خطابية. أنفقت M1 ذكاءها لجعل منطق الصفقات قابلاً للاتجاه. تجعل M2 هذا الذكاء غير ضروري — الحلقة الساذجة، القابلة للتدقيق، السهلة التعديل تعمل بسرعة الآلة. توقيت مرحلة المؤشرات بشكل منفصل عن حلقة الصفقات داخل هذه النواة المُترجَمة يُنسب 99.3% من وقتها لرياضيات مؤشر WMA وفقط 0.7% لحلقة الأحداث ذات الحالة. يمكنك إضافة وقف خسارة غداً بدون مشروع بحث كامل — وتمسّك بهذا التقسيم؛ إنه يُعيد تحديد حجة وحدة معالجة الرسوميات أدناه.
  2. إنها تفتح الدرجتين التاليتين. نواة مُترجَمة، تُحرّر GIL، وخفيفة التخصيص هي وحدة العمل التي يحتاجها التنسيق المتوازي. لا يمكنك تحويل M0 بالتوازي بشكل مفيد — اثنتا عشرة نسخة من البطيء تظل بطيئة، فقط أكثر دفئاً.

ملاحظة منهجية واحدة: تُترجم numba عند أول استدعاء، ويجب ألا يكون هذا التجميع (مئات الميلي ثانية) داخل المؤقّت — يُحمّي الإطار JIT على شريحة من 500 شمعة قبل القياس، وcache=True يحفظ النوى المُترجَمة عبر عمليات تشغيل البرنامج. مقاييس الأداء التي "تنسى" هذا التفصيل تُنتج أرقام numba إما سيئة بشكل غير عادل (يشمل التجميع البارد) أو غير قابلة للتكرار.

الدرجة M3: prange — التوازي الذي كان لديك بالفعل — 0.32 ث، 217.6x

ثمانون توليفة معلمات مستقلة تنتشر عبر اثنتي عشرة نواة معالج: نوى الأداء والكفاءة تسحب أطوال نوافذ غير متساوية بالتوازي

إليك الملاحظة التي تجعل البحث الجماعي عن المعلمات خاصاً: التوليفات الـ80 مستقلة تماماً. بدون حالة مشتركة، بدون ترتيب، بدون تواصل. هذا عمل قابل للتوازي بشكل مُحرِج كانت الدرجات M0–M2 تُشغّله على نواة واحدة من أصل اثنتي عشرة، من محض العادة.

تجعل numba الإصلاح شبه نحوي بحت — استبدل range في حلقة التوليفات بـprange:

@njit(parallel=True, cache=True)
def nb_sweep_all(close, params, fee):
    N = params.shape[0]
    totals = np.empty(N, dtype=np.float64)
    ntrs = np.empty(N, dtype=np.int64)
    for k in prange(N):                    # threads across combos
        t, ntr = nb_sweep(close, params[k, 0], params[k, 1], params[k, 2],
                          params[k, 3], params[k, 4], params[k, 5], fee)
        totals[k] = t
        ntrs[k] = ntr
    return totals, ntrs

لأن nb_sweep مُترجَمة بوضع nopython، فهي لا تحمل GIL، وطبقة تشغيل الخيوط في numba تُوزّع التكرارات عبر 12 نواة كلها. مصفوفة close للقراءة فقط تُشارَك بين كل الخيوط بتكلفة صفرية.

0.32 ث — 217.6x مقابل pandas، 248.9 توليفة في الثانية. الخطوة فوق M2 أحادية الخيط هي حوالي 6.2x على 12 نواة (مُشتقة: 1.98/0.32)، والعجز عن "الحد الأقصى المثالي 12x" يستحق أن نكون صادقين بشأنه بدلاً من إخفائه: نوى M2 Max الـ12 هي 8 نوى أداء + 4 نوى كفاءة، لذا لم تكن السقف الاسمي 12x أبداً؛ التوليفات الـ80 لها تكاليف متفاوتة بشكل صارخ (طول HMA من 6 أرخص بكثير من طول 200)، لذا تنتهي الخيوط بشكل غير متساوٍ؛ وكل استدعاء نواة يُخصّص مصفوفاته الوسيطة من مُخصِّص ذاكرة مشترك. التسريعات المتوازية على الأجهزة الحقيقية تبدو هكذا. أي شخص يذكر Nx نظيفاً على N نواة لمهام غير متجانسة يقيس شيئاً اصطناعياً.

الدرجة M4: مجمع عمليات للثلث الأخير — 0.23 ث، 297.9x

الدرجة الأخيرة تستبدل الخيوط بعمليات — نفس النواة المُترجَمة، مُنسَّقة بواسطة ProcessPoolExecutor:

with ProcessPoolExecutor(max_workers=12, initializer=_init_worker,
                         initargs=(close,)) as ex:          # ship data ONCE
    list(ex.map(_warmup_worker, range(12 * 3)))             # JIT-warm every worker
    results = list(ex.map(_run_one_combo, grid, chunksize=1))

0.23 ث — 297.9x مقابل pandas، 340.9 توليفة في الثانية. اقرأ ذلك المعدل مرة أخرى: هذا الحاسوب المحمول يُشغّل الآن حوالي 340 اختباراً رجعياً كاملاً من 150,000 شمعة في الثانية، كل واحد يحسب سبعة متوسطات متحركة مُرجّحة ويحاكي عشرات الآلاف من الصفقات ذات الحالة.

الفارق عن prange حقيقي لكنه متواضع — حوالي 1.4x (مُشتقة: 0.32/0.23) — والآليات المحتملة هي الجدولة وعزل الذاكرة: مع chunksize=1 يوزّع المجمع التوليفات واحدة تلو الأخرى، بحيث يتوازن المزيج غير المتساوي من النوافذ الرخيصة والمكلفة ديناميكياً عبر النوى غير المتماثلة، وتحصل كل عملية عامل على مُخصِّص ذاكرة خاص بها، متجنبةً التنافس على المتغيرات المؤقتة لكل توليفة. نُبلّغ هذه كآليات متسقة مع القياس، وليست حقائق مُثبَتة بشكل منفصل.

العمليات ليست مجانية، ويدفع الإطار تكاليفها بصدق خارج المؤقّت حيث تكون تكاليف لمرة واحدة (بدء تشغيل العامل، نقل close إلى كل عامل عبر المُهيِّئ، إحماء JIT لكل عامل) — لأنه في بحث حقيقي، تتوزع هذه التكاليف على آلاف التوليفات، وليس ثمانين. الإرشاد الصادق العام: prange أبسط وعادة كافٍ؛ يفوز مجمع العمليات عندما تكون المهام كبيرة الحجم، أو الشبكة كبيرة، أو عملك لكل توليفة يحمل GIL في مكان لا تستطيع numba الوصول إليه.

وبذلك، يتحلل السلم إلى ملخص واضح. من M0 إلى M2 — المحرك: 35.3x على نواة واحدة، من نقل التكرار خارج المُفسِّر. من M2 إلى M4 — التنسيق: 8.4x إضافية (مُشتقة: 1.98/0.23)، من استخدام النوى التي كانت موجودة بالفعل. مضروبة: 298x. بدون عتاد جديد، نتائج متطابقة. ومقيسة من خط أساس M1 الكفؤ بدلاً من الساذج، يظل المحرك النهائي أعلى بحوالي 13x (مُشتقة: 3.07/0.23) — السلم ليس أثراً لاختيار نقطة بداية بطيئة.

لماذا لا وحدة معالجة رسوميات — النسخة الصادقة

وحدة معالجة رسوميات خاملة بجانب معالج مركزي مُشبَع: رياضيات المتوسط المتحرك القابلة للتجميع بقيت على المعالج المركزي لأن مسحاً من ثمانين توليفة وربع ثانية أضيق وأقصر من أن تدفع ثمن الرحلة

"فقط انقله إلى وحدة معالجة رسوميات" هو أكثر رد فعل شائع لمسح معلمات بطيء، لذا تقيس هذه التجربة الرقمين اللذين يجب أن تبدأ منهما هذه المحادثة — ولا يدعم أي منهما النسخة الكسولة من أي من الجوابين.

يُصنّف نموذج roofline (Williams وWaterman وPatterson، 2009) نواة حسب كثافتها الحسابية (arithmetic intensity) — عمليات فاصلة عائمة (FLOPs) لكل بايت منقول. بالنسبة لرزمة مؤشرات WMA في هذا المسح، بحساب 2p2p عملية فاصلة عائمة لكل شمعة لكل نافذة بطول pp مقابل قراءة واحدة من 8 بايتات لكل شمعة، يصل المسح بأكمله (80 توليفة) إلى حوالي 6.2 جيجا فلوب عبر 576 ميجابايت مُتدفقة:

I=6.21×109 FLOP5.76×108 bytes10.78 FLOPbyteI = \frac{6.21 \times 10^9\ \text{FLOP}}{5.76 \times 10^8\ \text{bytes}} \approx 10.78\ \frac{\text{FLOP}}{\text{byte}}

(هذا هو العدد المثالي عبر نوافذ WMA الستة المختلفة لكل توليفة؛ حساب التمريرات السبع كما تُنفَّذ فعلياً يُعطي 11.07 FLOP/بايت. نفس الاستنتاج في كلتا الحالتين.)

هذا الرقم مهم بسبب ما يستبعده: الادعاء الشائع بأن رياضيات الاختبار الرجعي "محدودة بعرض الذاكرة، لذا لا يمكن لوحدات معالجة الرسوميات أن تساعد" خاطئ هنا. عند ~10.8 FLOP/بايت، رياضيات المؤشرات حسابية بشكل حاسم — أبعد بكثير من نقطة التعادل (ridge point) حيث يتوقف العتاد النموذجي عن كونه محدوداً بعرض النطاق الترددي. يمكن لوحدة معالجة رسوميات فعلاً أن تُجمِّع 80 توليفة × 7 تمريرات WMA في عدد قليل من النوى الكبيرة وتلتهم الحساب. لو كانت رزمة المؤشرات هي المشكلة بأكملها، لكانت حالة وحدة معالجة الرسوميات محترمة.

الرقم المقيس الثاني يقتل الجواب الكسول الآخر — الذي كنا سنلجأ إليه بأنفسنا. توقيت مرحلة المؤشرات بشكل منفصل عن حلقة الصفقات داخل النواة المُترجَمة يُعطي تقسيماً بنسبة 99.3% مؤشرات، 0.7% حلقة أحداث. الحجة المُغرية — "الاختبارات الرجعية لديها حلقة أحداث ذات حالة ومتشعبة، وهذا ما يمنع وحدة معالجة الرسوميات" — خاطئة كمياً هنا: يقضي المعالج المركزي أساساً كل وقته في الجزء بالضبط الذي يمكن لوحدة معالجة الرسوميات تجميعه. أعد صياغة 80 توليفة × 7 تمريرات WMA كعمليات التفاف (convolutions) مُجمَّعة كبيرة وستحصل على حمل عمل تنسوري (tensor workload) معقول تماماً. لذا السؤال الصادق ليس ما إذا كان بإمكان العمل الانتقال إلى وحدة معالجة رسوميات — معظمه يمكنه ذلك. السؤال هو ما إذا كانت الرحلة تستحق العناء، ولهذا المسح لا تستحق، لسببين محددين:

1. العرض القابل للاستغلال هو 80 توليفة — ووحدة معالجة الرسوميات آلة عرض. المحور الصادق الوحيد للتوازي في مسح معلمات هو الشبكة نفسها: داخل توليفة واحدة، مسار الـ150,000 شمعة تسلسلي. تريد وحدة معالجة الرسوميات عشرات الآلاف من عناصر عمل مستقلة لملء ممراتها وإخفاء زمن الاستجابة؛ هذا المسح يُقدّم ثمانين. تُشبع اثنتا عشرة نواة معالج مركزي هذا العرض بالفعل — هذا حرفياً ما قاسته الدرجتان M3–M4. بالنسبة لأعداد التوليفات التي كان عرض وحدة معالجة الرسوميات سيبدأ فيها بالانخراط، يُقدّم سلم المعالج المركزي بالفعل مئات الاختبارات الرجعية الكاملة في الثانية.

2. المهمة بأكملها 0.23 ثانية. بسرعة M4، تكلف كل توليفة حوالي 2.9 مللي ثانية (مُشتقة: 0.23 ث / 80). مقابل تلك الميزانية، تأخيرات إطلاق النواة ونقاط مزامنة الجهاز ليست أخطاء تقريب قابلة للاستهلاك — إنها جزء مادي من المهمة. (على هذا الجهاز ذي الذاكرة الموحدة من Apple، نقل البيانات من المضيف إلى الجهاز مصدر قلق ثانوي؛ على صندوق CUDA بوحدة معالجة رسوميات منفصلة، ينضم أيضاً إلى الفاتورة.) الفوز الكلاسيكي لوحدة معالجة الرسوميات يستهلك النفقات الثابتة عبر دفعات ضخمة من العمل؛ مسح دون ثانية واحدة لا يُنتج ذلك أبداً.

وحلقة الأحداث؟ إنها الجزء الوحيد الذي لن يُجمَّع — تسلسلية، متشعبة، معتمدة على المسار، اعتماد محمول عبر الحلقة (loop-carried dependency) طوله 150,000 شمعة لا يمكن لأي عتاد توازيه داخل توليفة واحدة، مع بالضبط التفرعات المتباعدة التي تكرهها ممرات SIMT. نقل وحدة معالجة رسوميات قد يترك هذا على المعالج المركزي أو يُشغِّله بممر واحد لكل توليفة. لكن عند 0.7% من النواة، إنه حد Amdahl صغير جداً بحيث لا يحسم أي شيء. إنه الجزء الذي لن ينتقل؛ إنه ليس سبب عدم الانتقال. (تذكر من الدرجة M1 أنه بالنسبة للنوى الخالية من التغذية المرتدة، يمكن حتى اتّجاه الحلقة تحليلياً — إعادة الكتابة التي تخسرها لحظة تنمو الاستراتيجية وقف خسارة.)

هامش منصة واحد للاكتمال: على هذا الجهاز (Apple Silicon)، سيكون مسار وحدة معالجة الرسوميات هو MLX أو PyTorch-MPS، وليس CUDA — cupy ونظام CUDA البيئي ببساطة لا ينطبقان — وسيتطلب كلاهما إعادة كتابة المسار الساخن بلهجة تنسورية فقط لمحاولة التجربة. هذه تكلفة حقيقية بدون أي فائدة مُحدَّدة، وفقاً للتحليل أعلاه، لشكل هذا المسح. نقاش وحدة معالجة الرسوميات هنا تحليلي، مبني على الكثافة الحسابية المقيسة وتقسيم المؤشرات/الحلقة المقيس، ونصنّفه على هذا الأساس: لم يُجرَ أي تشغيل CUDA لأن ذلك لم يكن ممكناً على العتاد المُعلَن عنه.

الجملة الملخصة التي كنا سندافع عنها في المراجعة: يمكن لمعظم هذا العمل الانتقال إلى وحدة معالجة رسوميات؛ هذا المسح أضيق وأقصر من أن تستحق الرحلة العناء. واقرأ هذا في كلا الاتجاهين — إنه ليس شطب كامل. إعادة الصياغة "بالمصفوفة الكبيرة" المُجمَّعة — إعادة صياغة المسح كعمليات تنسورية كبيرة عبر آلاف التوليفات دفعة واحدة، أو نواة خالية من التغذية المرتدة حقاً تُجمِّع من البداية للنهاية — هي اتجاه حقيقي وواعد يستحق دراسة مخصصة، وليس رفضاً. عند 80 توليفة و0.23 ثانية، ببساطة لم تكسب التذكرة بعد. إذا كان حمل عملك بهذا العرض، تتغير الحسابات، ويجب عليك إعادة القيام بذلك، وليس الاقتباس منا.

أين يكمن الاختناق الحقيقي: المحرك والتنسيق

الاختناق الحقيقي يُكشف: ساعة رملية حيث يخنق المحرك وتنسيق آلاف توليفات المعلمات التدفق، وليس العتاد الأساسي

ثمانون توليفة هي شبكة توضيحية. البحث الحقيقي عن المعلمات هو حيث تتوقف هذه العوامل عن كونها أكاديمية، لأن الشبكات تنمو ضربياً: أربعة معلمات بعشر قيم لكل منها هي 10410^4 توليفة؛ أضف التحقق بتحسين المشي للأمام بعشرات الطيات وستصل إلى 1.2×1051.2 \times 10^5 اختبار رجعي كامل قبل أن تكون قد استكشفت أي شيء. هذه هي لعنة الأبعاد، وهذا سبب حصول استراتيجية البحث — Optuna، النزول الإحداثي، Sobol — على اهتمام كبير: البحث الأذكى يزور نقاطاً أقل.

لكن السلم يكشف النصف الآخر الأقل نقاشاً من المعادلة: التكلفة لكل نقطة تُزار. باستقراء معدلات النقل المقيسة خطياً (التوليفات مستقلة، لذا هذا حساب، وليس نمذجة):

حجم الشبكة عند M0 (1.1 توليفة/ث) عند M4 (340.9 توليفة/ث)
10,000 توليفة ~2.4 ساعة ~30 ثانية
100,000 توليفة ~24 ساعة ~5 دقائق

نفس التجربة التي هي مهمة دفعية ليلية على المحرك الساذج هي استعلام تفاعلي على المحرك المضبوط. هذا الفارق يتراكم بطريقة تُقلِّل جداول الوقت الفعلي من شأنها: عند 5 دقائق لكل مسح، تُكرّر — تُعيد التشغيل مع إصلاح تسرب، تُضيف طية، توسّع الشبكة، تختبر الفكرة التي خطرت لك على الغداء. عند 24 ساعة لكل مسح، لا تفعل ذلك. سرعة المحرك تُحدد إيقاع حلقة البحث، وإيقاع حلقة البحث هو المنتج الفعلي.

هناك قراءة من منظور قانون Amdahl للسلم بأكمله أيضاً:

S=1(1p)+p/sS = \frac{1}{(1 - p) + p / s}

تسريع أي مرحلة واحدة pp بعامل ss محدود بكل ما تركته بطيئاً. احترم السلم هذا الترتيب: هجم كسب المحرك 35.3x على الحد المهيمن (التكرار المُفسَّر، في رزمة المؤشرات والحلقة على حد سواء)، وهجم كسب التنسيق 8.4x على الحد المهيمن بعد ذلك (إحدى عشرة نواة خاملة). تقسيم المؤشرات/الحلقة هو نفس الدرس مُصغَّراً — لم يكن بإمكاننا تسمية الشكل الحقيقي لحجة وحدة معالجة الرسوميات بدون قياس أين ذهب الوقت فعلياً. حلّل، ثم حسِّن — بهذا الترتيب. نفس المنطق يحكم طبقة البيانات فوق المحرك: وجدت مقارناتنا بين Polars وpandas نفس النمط بالضبط (10-3500x على خطوط أنابيب rolling المُجمَّعة) للنصف المتعلق بالتحميل والتحويل من الرزمة، ونفس الاستنتاج الهجين — محركات عمودية (columnar) لخط الأنابيب، ونواة مُترجَمة للمحاكاة المعتمدة على المسار.

ملاحظتا صدق لإغلاق حلقة العمومية. أولاً، هذه التجربة ذاتية الاكتفاء واصطناعية عن قصد — بيانات مُبذَرة، نواة واحدة، جهاز واحد مُعلَن عنه — بحيث يمكن لأي شخص إعادة إنتاج الظاهرة بشكل حتمي؛ ستختلف أرقام الوقت الفعلي على عتادك، لكن التكافؤ واتجاه السلم لن يختلفا. ثانياً، الظاهرة ليست أثراً للإعداد الاصطناعي: مقياس أداء محرك HMA الإنتاجي لدينا (bench_param_sweep.py، مُشغَّل على بيانات بورصة حقيقية مع نموذج الرسوم والتنفيذ الإنتاجي الكامل) يُظهر نفس شكل السلم، مع مسار numba يهبط تقريباً 100-200x فوق ملف pandas الساذج. توجد التجربة ذاتية الاكتفاء حتى لا تضطر إلى أخذ أرقامنا الإنتاجية على الثقة.

الخلاصات

  1. السلم هو 298x، ويتحلل: 35.3x محرك × 8.4x تنسيق. نقل التكرار خارج المُفسِّر (pandas ← numba) وتوزيع التوليفات المستقلة عبر النوى (واحدة ← اثنتا عشرة) تضاعفا إلى تسريع قريب من ثلاث مراتب من الحجم على حاسوب محمول دون تغيير. 69.92 ث ← 0.23 ث؛ 1.1 ← 340.9 توليفة/ث. وهذا ليس أثراً لخط أساس بطيء: مقابل تطبيق numpy المُتّجه الكفؤ، يظل المحرك النهائي ~13x.
  2. اطلب التكافؤ قبل أن تُعجَب بالسرعة. كل درجة هنا تُنتج نفس PnL ونفس عدد الصفقات لكل توليفة، مُتحقق منه تلقائياً على جميع التوليفات الـ80 (تسامح مطلق 10610^{-6} على PnL، تطابق تام على الصفقات). محرك سريع يحسب شيئاً مختلفاً قليلاً ليس سريعاً — إنه خاطئ بمعدل نقل عالٍ، وإعادة الكتابة المُتّجهة هي حيث يتسلل الخطأ عادة.
  3. @njit يتفوق على الاتجاه الذكي للمنطق ذي الحالة. درجة numpy احتاجت إلى صيغة مغلقة خاصة بالاستراتيجية تموت لحظة تُضيف وقف خسارة. تُترجم درجة numba الحلقة الساذجة القابلة للتدقيق — نفس فئة السرعة، بدون أي هشاشة، وهي وحدة العمل التي تتوازى.
  4. جواب وحدة معالجة الرسوميات هو "ليس لهذا المسح" — لأسباب يجب أن تكون قادراً على تسميتها. رياضيات المؤشرات حسابية (10.78 FLOP/بايت) وهي 99.3% من النواة المُترجَمة، لذا لا "الاختبارات الرجعية محدودة بعرض الذاكرة" ولا "الحلقة ذات الحالة هي المهيمنة" ينجو من القياس. الأسباب الصادقة هي العرض والميزانية: 80 توليفة من التوازي القابل للاستغلال تُشبعها 12 نواة معالج مركزي بالفعل، ومهمة إجمالية بـ0.23 ثانية سيلتهمها إطلاق ومزامنة النفقات العامة. إعادة الصياغة "بالمصفوفة الكبيرة" المُجمَّعة عند عرض حقيقي تظل اتجاهاً واعداً، وليست مرفوضة.
  5. سرعة المحرك هي إيقاع البحث. بمعدل نقل المحرك الساذج، بحث بـ100,000 اختبار رجعي هو يوم كامل؛ بمعدل نقل قمة السلم هو خمس دقائق. قبل شراء عتاد أو استئجار عنقود حوسبة، تحقق مما إذا كان اختناقك عتاداً أصلاً — كان اختناقنا lambda داخل rolling.apply وإحدى عشرة نواة خاملة.

التجربة الكاملة — جميع التطبيقات الخمسة، إطار التكافؤ، حساب roofline، وكل رقم في هذا المقال قابل لإعادة التوليد من نص واحد حتمي — موجودة في الورقة المرافقة على speed-ladder.marketmaker.cc، مع الكود والبيانات على github.com/suenot/backtest-speed-ladder.

المسح الذي استغرق سبعين ثانية يستغرق الآن ربع ثانية واحدة. نفس الصفقات، نفس PnL، نفس الحاسوب المحمول. وحدة معالجة الرسوميات التي كنت على وشك طلبها يمكنها الانتظار؛ حلقة المُفسِّر التي كنت على وشك شحنها لا يمكنها ذلك.

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

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

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

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