← लेखों की सूची पर वापस जाएँ
June 26, 2026
5 मिनट का पठन

बैकटेस्ट स्पीड लैडर: लैपटॉप CPU पर 298x, आखिरी ट्रेड तक समान PnL

बैकटेस्ट स्पीड लैडर: लैपटॉप CPU पर 298x, आखिरी ट्रेड तक समान PnL
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization
Part 1 of 10 · Collection
High-Performance Backtest Engines

"Backtests Without Illusions" सीरीज का हिस्सा।

📄 यह लेख एक रिसर्च पेपर में विकसित हो गया। एक path-dependent बैकटेस्ट कर्नेल को पांच तरीकों से इम्प्लीमेंट किया गया है — naive pandas से लेकर एक parallel numba कर्नेल तक — जहां हर पायदान को identical per-combo PnL देने के लिए क्रॉस-चेक किया गया है, ताकि सिर्फ स्पीड में फर्क आए। पेपर ऑनलाइन (interactive version + PDF) पढ़ें speed-ladder.marketmaker.cc पर, कोड और डेटा github.com/suenot/backtest-speed-ladder पर।

सत्तर सेकंड। naive रेफरेंस इम्प्लीमेंटेशन को 150,000 bars पर एक moving-average स्ट्रेटेजी के 80 पैरामीटर कॉम्बिनेशन स्वीप करने में इतना समय लगता है: इंडिकेटर्स के लिए pandas rolling().apply(), ट्रेड्स के लिए एक साधारण Python लूप। यह उस प्रोफाइल का प्रतिनिधित्व करता है जिस पर असली दुनिया के अधिकतर रिसर्च कोड चलते हैं, क्योंकि यही वह प्रोफाइल है जो स्ट्रेटेजी को सबसे स्पष्ट तरीके से लिखने पर स्वाभाविक रूप से निकलती है।

वही स्वीप, उसी लैपटॉप पर, हर कॉम्बिनेशन के लिए आखिरी ट्रेड तक बिल्कुल वही PnL देते हुए: 0.23 सेकंड

इन दो आंकड़ों के बीच का अंतर — मापा गया 298x — इसी लेख का विषय है। इसका एक प्रतिशत भी नए हार्डवेयर से नहीं आया। कोई GPU शामिल नहीं था (इस मशीन पर CUDA अर्थ में कोई उपलब्ध भी नहीं है)। लैडर का हर पायदान वही स्ट्रेटेजी, वही डेटा, वही फीस, वही ट्रेड काउंट है, जिसे एक equivalence गेट से वेरिफाई किया गया है जो पूरे बेंचमार्क को फेल कर देता है अगर किसी भी इम्प्लीमेंटेशन के per-combo नतीजे अलग हों। जो बदला वह सिर्फ यह है कि काम को किस रूप में व्यक्त किया गया: क्या interpreter में चलता है, क्या compile होकर चलता है, और क्या parallel में चलता है। और क्योंकि जानबूझकर धीमी बेसलाइन किसी भी headline नंबर को बढ़ा-चढ़ाकर दिखा सकती है, एक और आंकड़ा पहले ही: एक सक्षम vectorized numpy इम्प्लीमेंटेशन के मुकाबले भी — जो कोड एक मजबूत numpy प्रोग्रामर शिप करेगा — फिनिश्ड इंजन अब भी लगभग 13x तेज है।

जब कोई पैरामीटर सर्च धीमा होता है, तो पहली प्रतिक्रिया बड़े हार्डवेयर की ओर जाने की होती है — एक GPU, एक क्लस्टर, एक क्लाउड बजट। इस प्रयोग की मापी गई वास्तविकता कहीं और, कम आकर्षक जगह इशारा करती है: बॉटलनेक इंजन था (एक interpreted इनर लूप जो हर विंडो पर Python कॉल कर रहा था) और ऑर्केस्ट्रेशन था (स्वतंत्र कॉम्बो को एक core पर सीक्वेंशियली चलाना)। दोनों को एक दोपहर में, आपके पास पहले से मौजूद मशीन पर, नतीजों में बिना कोई बदलाव किए ठीक किया जा सकता है।

यहां पूरी लैडर पहले ही दी गई है। नीचे बाकी सब हर स्टेप की एनाटॉमी है।

पायदान इम्प्लीमेंटेशन Wall time स्पीडअप Combos/s
M0 pandas: rolling.apply + Python bar लूप 69.92 s 1.0x 1.1
M1 numpy: sliding-window WMA + vectorized ट्रेड्स 3.07 s 22.7x 26.0
M2 numba: @njit WMA + @njit event लूप 1.98 s 35.3x 40.4
M3 numba prange: कॉम्बो में थ्रेड्स 0.32 s 217.6x 248.9
M4 process pool + numba: कॉम्बो में प्रोसेस 0.23 s 297.9x 340.9

Apple M2 Max (12 cores), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) को एक थ्रेड पर पिन किया गया ताकि सिंगल-थ्रेडेड पायदान सचमुच सिंगल-core हों। 150,000 bars × 80 कॉम्बो, best-of-3 wall time, JIT warm-up को बाहर रखा गया। सभी पायदान — pandas बेसलाइन सहित — पूरी तरह टाइम किए गए और सभी 80 कॉम्बो पर identical per-combo PnL और ट्रेड काउंट देने के लिए वेरिफाई किए गए।

एक कर्नेल, पांच इम्प्लीमेंटेशन

Five rungs of one staircase: the same backtest kernel climbing from a 70-second pandas baseline to a 0.23-second parallel numba run, each step verified to produce identical PnL

किसी स्पीड तुलना को अर्थपूर्ण बनाने के लिए, जो चीज गणना की जा रही है उसे बिल्कुल सटीक रूप से तय करना जरूरी है, और हर इम्प्लीमेंटेशन को यह साबित करना जरूरी है कि वह उसी की गणना कर रहा है। इसलिए यह प्रयोग एक स्ट्रेटेजी कर्नेल को फिक्स करता है और उसे सभी पांच पायदानों में स्थिर रखता है।

कर्नेल एक HMA/HMA3 क्रॉस है — दो Hull-style moving averages पर एक stop-and-reverse सिस्टम। बिल्डिंग ब्लॉक है weighted moving average:

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 Moving Average lag घटाने के लिए इनमें से तीन को कंपोज करता है:

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 एक स्मूथ sibling है जो लगभग n/6n/6, n/4n/4 और n/2n/2 पर WMAs से बना है, जिसे एक बार और स्मूथ किया गया है। प्रति पैरामीटर कॉम्बिनेशन यह सात WMA पास हैं छह अलग-अलग विंडो लेंथ पर — एक असली इंडिकेटर स्टैक, कोई toy नहीं।

ट्रेडिंग नियम जानबूझकर, उपयोगी रूप से stateful है: direction long है जब HMA, HMA3 से नीचे हो और अन्यथा short; पहली defined direction पर पोजीशन खोलें; हर क्रॉस पर, पोजीशन बंद करें, 0.09% round-trip फीस माइनस PnL बुक करें, और रिवर्स करें। पोजीशन bars के बीच carry होती है — bar ii पर आप क्या करते हैं यह last क्रॉस के बाद accumulated state पर निर्भर करता है। यह path dependence पूरे प्रयोग का मुख्य बिंदु है: यही वह प्रॉपर्टी है जो बैकटेस्ट को generic dataframe pipelines से अलग बनाती है, और (जैसा हम मापेंगे) यह GPU के सवाल को जटिल बनाती है — हालांकि, ऐसा निकलता है, फोकलोर जिस तरह कहता है उस तरह नहीं।

बाकी सेटअप, ताकि आप नंबरों को परख सकें:

  • डेटा: 150,000 bars synthetic geometric Brownian motion के, seeded (seed=42)। यहां परफॉर्मेंस array size और window lengths से बंधी है, न कि आप कौन सा price path फीड करते हैं इससे — और एक synthetic सीरीज पूरे प्रयोग को deterministic और किसी के भी द्वारा reproducible बनाती है।
  • ग्रिड: [6,200][6, 200] पर फैली 80 अलग HMA lengths — इसलिए स्वीप में सस्ते short-window कॉम्बो और महंगे long-window कॉम्बो दोनों शामिल हैं, ठीक जैसे एक असली ग्रिड में होते हैं।
  • टाइमिंग: wall-clock, प्रति पायदान best-of-3, JIT compilation को timer के बाहर warm किया गया और pool workers को क्लॉक शुरू होने से पहले warm किया गया। हर पायदान — pandas बेसलाइन सहित — सभी 80 कॉम्बो में पूरा टाइम किया गया है। BLAS (Apple का Accelerate) एक ही थ्रेड पर पिन किया गया है, इसलिए सिंगल-थ्रेडेड पायदान सचमुच सिंगल-core हैं: numpy पायदान comparison के पीछे चुपचाप अपने matvecs को multithread नहीं कर रहा।
  • Equivalence गेट: टाइमिंग के बाद, हर पायदान के per-combo (PnL, trade count) वेक्टर की तुलना रेफरेंस से की जाती है — trade counts बिल्कुल मिलने चाहिए, PnL absolute 10610^{-6} percentage points के भीतर। committed run हर पायदान के लिए, pandas बेसलाइन सहित, सभी 80 कॉम्बो पर all_ok: true रिपोर्ट करता है। अगर यह गेट फेल होता है, तो कोई बेंचमार्क नहीं है — सिर्फ पांच प्रोग्राम हैं जो पांच अलग-अलग चीजें पांच अलग-अलग स्पीड पर गणना कर रहे हैं, जो कि यह है कि बहुत से "हमारा इंजन 100x तेज है" वाले दावे चुपचाप कैसे काम करते हैं।

equivalence ब्लॉक का एक नंबर थोड़ी ईमानदारी के लायक है: पहले कॉम्बो का fingerprint 57,029 ट्रेड्स में −5165.58 percentage points का PnL है। यह किसी शर्मिंदगी वाले स्ट्रेटेजी नतीजे की बात नहीं है — यह सबसे छोटी HMA length (6) है, जो एक random walk के लगभग हर wiggle पर flip हो रही है और हर बार 0.09% चुका रही है, बिल्कुल जैसा उसे करना चाहिए। यह एक correctness fingerprint है, tradable बैकटेस्ट नहीं। इसमें alpha मत ढूंढिए; इसमें determinism पढ़िए — पांच इम्प्लीमेंटेशन का उसी 57,029 ट्रेड्स और छह दशमलव तक उसी PnL पर पहुंचना ही यहां "identical" का मतलब है।

यह स्थापित होने के बाद, नीचे हर स्पीडअप शुद्ध स्पीड है। कुछ भी approximate करके नहीं छोड़ा गया।

पायदान M0: naive pandas प्रोफाइल — 69.9 s

Anatomy of the naive pandas baseline: a rolling.apply window spawning a Python lambda call for every one of 150,000 bars while the interpreter loop crawls beneath it

बेसलाइन कोई strawman नहीं है। यह वह कोड है जो तब मिलता है जब आप एक WMA वैसे लिखते हैं जैसे pandas डॉक्यूमेंटेशन सुझाता है, और event लूप वैसे लिखते हैं जैसे स्ट्रेटेजी विवरण पढ़ता है:

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 "खराब" है — बल्कि इसलिए क्योंकि iteration कहां रहती हैrolling(period).apply(lambda ...) एक vectorized पोशाक पहने Python-level लूप है। 150,000 में से हर एक bar के लिए, pandas एक window materialize करता है, C/Python बाउंड्री क्रॉस करता है, एक Python callable इनवोक करता है, और नतीजे को box करता है। raw=True के साथ भी (जो कम से कम lambda को Series की बजाय एक bare ndarray देता है), प्रति-कॉल interpreter overhead उन ~दर्जनों-से-सैकड़ों FLOPs को बौना कर देता है जिनकी window को वास्तव में जरूरत है। प्रति कॉम्बो सात WMA पास से गुणा करें, और अकेले इंडिकेटर स्टैक लाखों interpreter round-trips बन जाता है। फिर bar लूप प्रति कॉम्बो और 150,000 interpreted iterations चलाता है, हर एक में numpy scalars पर bounds-checked indexing होती है, floats को box किया जाता है, और types पर dynamically dispatch किया जाता है जिन्हें interpreter हर बार फिर से खोजता है।

नतीजा: स्वीप के लिए 69.92 s, प्रति कॉम्बो लगभग 0.87 s, 1.1 कॉम्बो प्रति सेकंड का throughput। एक 80-कॉम्बो ग्रिड पर आप कंधे उचकाकर एक मिनट इंतजार कर लेते हैं। समस्या यह है कि कोई भी लंबे समय तक 80-कॉम्बो ग्रिड नहीं चलाता — और यह लागत हमेशा के लिए linearly बढ़ती है। हम इस पर वापस आएंगे।

पायदान M1: numpy — लूप में Python कॉल करना बंद करें — 3.07 s, 22.7x

पहला ऊपरी पायदान दोनों interpreter लूप एक साथ खत्म कर देता है, और इन दोनों trick को अलग करना जरूरी है क्योंकि इनकी generality बहुत अलग है।

इंडिकेटर पक्ष आसान, पूरी तरह general वाला है। किसी weighted moving average को सभी windows पर निकालना बस input की एक strided view के खिलाफ matrix–vector product है — कोई copy नहीं, एक 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 उसी memory का एक (n − p + 1, p) view बनाता है, और win @ w हर window का dot product compiled कोड में निकालता है। लाखों lambda इनवोकेशन एक library कॉल बन जाते हैं।

ट्रेड पक्ष दिलचस्प है, क्योंकि event लूप stateful है — और फिर भी, इस कर्नेल के लिए, यह vectorize हो जाता है। यह इनसाइट है कि किसी भी bar पर पोजीशन सिर्फ HMA − HMA3 के sign पर निर्भर करती है, किसी trade outcome पर नहीं। State कभी वापस decisions में feed नहीं होती। तो पूरा लूप "sign flips ढूंढो, उन indices पर prices इकट्ठा करो" में सिमट जाता है:

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 s, 22.7x स्पीडअप, 26.0 कॉम्बो प्रति सेकंड — एक core पर, BLAS को एक थ्रेड पर पिन करते हुए। इस पायदान को एक label मिलना चाहिए: यह सक्षम बेसलाइन है, वह इम्प्लीमेंटेशन जो एक मजबूत numpy प्रोग्रामर शिप करेगा, और इससे ऊपर की हर चीज के लिए fair yardstick। लेकिन इस पायदान के साथ दो ईमानदार चेतावनियां भी चलती हैं।

पहली, यह vectorization एक strategy-specific analytical rewrite है, कोई mechanical transformation नहीं। यह इसलिए है क्योंकि कर्नेल stop-and-reverse है — बिना stops के, बिना trailing exits के, बिना किसी position sizing के जो running PnL पर निर्भर हो। एक stop-loss जोड़ें — जो कि सबसे साधारण feature है — तो bar ii पर exit यह बदल देगा कि bar j>ij > i पर कौन सी entry मौजूद है, state वापस path में feed होती है, और closed form गायब हो जाता है। ज्यादातर production कर्नेल इस लाइन के गलत तरफ रहते हैं।

दूसरी, यही वह पायदान है जहां correctness मरती है। flip-index की बहीखाता (यहां +1, वहां [:-1], first-direction seeding) ठीक उसी तरह का कोड है जो off-by-one execution bugs पैदा करता है — बग की उसी प्रजाति की जो हमारी look-ahead taxonomy ने दिखाया कि noise से 15 का Sharpe बना सकती है। equivalence गेट इस पायदान पर औपचारिकता नहीं है; यह इस पर भरोसा करने का इकलौता कारण है। किसी dumb रेफरेंस इम्प्लीमेंटेशन के खिलाफ equivalence check के बिना क्लीवर vectorized rewrites ही वह तरीका है जिससे इंजन उस स्ट्रेटेजी से भटक जाते हैं जिसे वे टेस्ट करने का दावा करते हैं।

पायदान M2: numba — जो लूप आप वास्तव में लिखना चाहते हैं उसे compile करें — 1.98 s, 35.3x

A Python event loop passing through the numba JIT compiler and emerging as tight machine code: the same branchy bar-by-bar logic, compiled instead of interpreted

पायदान M2 उल्टा फिलॉसफी अपनाता है: algorithm को vectorized primitives में फिट करने के लिए मरोड़ने की बजाय, naive लूप लिखें — और उन्हें compile करें। Numba (Lam, Pitrou & Seibert, 2015) Python के numeric subset को LLVM के जरिए machine कोड में JIT-compile करता है:

@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 के अंदर का event लूप शब्दशः M0 वाला लूप है। branches, continue, locals में carry होता state — यह सब। @njit के तहत ये locals registers में रहते हैं, branches असली jump instructions होते हैं, और प्रति-iteration लागत interpreter dispatch के microseconds से nanoseconds पर आ जाती है।

1.98 s — pandas पर 35.3x, लेकिन numpy पर सिर्फ लगभग 1.6x (derived: 3.07/1.98)। यह मामूली सा कदम खुद ही instructive है: numpy की inner loops पहले से ही compiled थीं, इसलिए feature math पर numba की जीत सिर्फ window materialization और intermediate arrays छोड़ने तक सीमित है। असली परिवर्तनकारी हिस्सा कहीं और है:

  1. Event लूप अब मुफ्त है — और "मुफ्त" मापा गया है, बयानबाजी नहीं। M1 ने अपनी सारी चतुराई trade logic को vectorizable बनाने में लगाई। M2 उस चतुराई को अनावश्यक बना देता है — naive, auditable, आसानी से modify होने वाला लूप machine speed पर चलता है। इस compiled कर्नेल के अंदर feature stage को event लूप से अलग टाइम करने पर 99.3% समय WMA feature math को और सिर्फ 0.7% stateful event लूप को जाता है। आप कल एक stop-loss बिना किसी research प्रोजेक्ट के जोड़ सकते हैं — और इस split को याद रखिए; यह नीचे GPU तर्क को फिर से तय करता है।
  2. यह अगले दो पायदानों को unlock करता है। एक compiled, GIL-releasing, allocation-light कर्नेल वह unit-of-work है जिसकी parallel ऑर्केस्ट्रेशन को जरूरत होती है। आप M0 को उत्पादक ढंग से parallelize नहीं कर सकते — बारह copies of slow अब भी slow ही हैं, बस थोड़ी warm।

एक methodological नोट: numba पहली कॉल पर compile करता है, और वह compilation (सैकड़ों milliseconds) timer के अंदर नहीं होना चाहिए — harness JIT को 500-bar slice पर मापने से पहले warm करता है, और cache=True compiled कर्नेल को process launches के पार persist करता है। जो बेंचमार्क इस detail को "भूल" जाते हैं वे या तो अनुचित रूप से खराब (cold compile शामिल) या unreproducible numba नंबर देते हैं।

पायदान M3: prange — वह parallelism जो आपके पास पहले से थी — 0.32 s, 217.6x

Eighty independent parameter combos fanned out across twelve CPU cores: performance and efficiency cores pulling unequal window lengths in parallel

यहां वह observation है जो mass parameter search को खास बनाती है: 80 कॉम्बो पूरी तरह स्वतंत्र हैं। कोई shared state नहीं, कोई ordering नहीं, कोई communication नहीं। यह embarrassingly parallel काम है जिसे पायदान M0–M2 महज आदत से बारह में से एक core पर चला रहे थे।

Numba इस fix को लगभग syntactic बना देता है — कॉम्बो लूप के 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-compiled है, यह कोई GIL नहीं रोकता, और numba की threading layer iterations को सभी 12 cores पर फैला देती है। read-only close array सभी threads द्वारा zero cost पर शेयर होता है।

0.32 s — pandas पर 217.6x, 248.9 कॉम्बो प्रति सेकंड। सिंगल-थ्रेडेड M2 पर यह कदम 12 cores पर लगभग 6.2x है (derived: 1.98/0.32), और "ideal 12x" से जो कमी है उसे छुपाने की बजाय ईमानदारी से बताना जरूरी है: M2 Max के 12 cores 8 performance + 4 efficiency cores हैं, तो नाममात्र ceiling कभी 12x नहीं थी; 80 कॉम्बो की लागत बहुत असमान है (एक length-6 HMA एक length-200 वाले से कहीं सस्ता है), तो threads असमान रूप से खत्म होते हैं; और हर कर्नेल कॉल अपने intermediate arrays एक shared allocator से allocate करती है। असली मशीनों पर parallel स्पीडअप ऐसे ही दिखते हैं। heterogeneous tasks के लिए साफ Nx-on-N-cores बताने वाला कोई भी कुछ synthetic माप रहा है।

पायदान M4: आखिरी तिहाई के लिए एक process pool — 0.23 s, 297.9x

आखिरी पायदान threads को processes से बदल देता है — वही compiled कर्नेल, एक 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 s — pandas पर 297.9x, 340.9 कॉम्बो प्रति सेकंड। उस throughput को फिर पढ़िए: यह लैपटॉप अब लगभग 340 पूरे 150,000-bar बैकटेस्ट प्रति सेकंड चला रहा है, हर एक सात weighted moving averages निकाल रहा है और दसियों हजार stateful trades simulate कर रहा है।

prange पर बढ़त असली है लेकिन मामूली — लगभग 1.4x (derived: 0.32/0.23) — और संभावित कारण scheduling और memory isolation हैं: chunksize=1 के साथ pool कॉम्बो एक-एक करके बांटता है, तो सस्ते और महंगे windows का असमान मिश्रण असमान cores पर dynamically load-balance होता है, और हर worker process को अपना खुद का allocator मिलता है, जो per-combo temporaries पर contention से बचाता है। हम इन्हें measurement के अनुरूप mechanics के रूप में रिपोर्ट करते हैं, अलग से साबित हुए facts के रूप में नहीं।

Processes मुफ्त नहीं हैं, और harness उनकी लागतें ईमानदारी से timer के बाहर चुकाता है जहां वे एक-बार वाली लागतें हैं (worker startup, initializer के जरिए हर worker को close भेजना, प्रति-worker JIT warm-up) — क्योंकि किसी असली search में ये लागतें हजारों कॉम्बो पर amortize होती हैं, अस्सी पर नहीं। ईमानदार सामान्य सलाह: prange सरल है और आमतौर पर काफी है; एक process pool तब जीतता है जब tasks chunky हों, ग्रिड बड़ा हो, या आपका per-combo काम कहीं ऐसी जगह GIL रोकता हो जहां numba नहीं पहुंच सकता।

और इसके साथ, लैडर एक साफ सारांश में विभाजित हो जाती है। M0 से M2 तक — इंजन: सिंगल core पर 35.3x, iteration को interpreter से बाहर निकालने से। M2 से M4 तक — ऑर्केस्ट्रेशन: और 8.4x (derived: 1.98/0.23), उन cores का इस्तेमाल करने से जो पहले से मौजूद थे। गुणा करने पर: 298x। कोई नया हार्डवेयर नहीं, identical नतीजे। और naive की बजाय सक्षम M1 बेसलाइन से मापने पर भी, फिनिश्ड इंजन अब भी लगभग 13x ऊंचा खड़ा है (derived: 3.07/0.23) — लैडर किसी धीमे starting point चुनने का artifact नहीं है।

GPU क्यों नहीं — ईमानदार वर्जन

A GPU sitting idle beside a saturated CPU: batchable moving-average math left on the CPU because a sweep of eighty combos and a quarter of a second is too narrow and too short to pay for the trip

"बस इसे GPU पर पोर्ट कर दो" किसी धीमी parameter sweep के लिए सबसे आम प्रतिक्रिया है, तो यह प्रयोग उन दो नंबरों को मापता है जिनसे वह बातचीत शुरू होनी चाहिए — और इनमें से कोई भी किसी भी जवाब के आलसी वर्जन का समर्थन नहीं करता।

Roofline model (Williams, Waterman & Patterson, 2009) किसी कर्नेल को उसकी arithmetic intensity — प्रति byte moved FLOPs — से वर्गीकृत करता है। इस स्वीप के WMA feature stack के लिए, length pp की एक window पर प्रति bar 2p2p FLOPs को एक प्रति-bar 8-byte read के खिलाफ गिनते हुए, पूरा 80-कॉम्बो स्वीप लगभग 576 MB streamed पर 6.2 GFLOP निकलता है:

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 windows पर idealized count है; सातों passes को actually executed के तौर पर गिनने पर 11.07 FLOP/byte मिलता है। दोनों ही तरह नतीजा वही है।)

यह नंबर इसलिए मायने रखता है कि यह क्या खारिज करता है: यह लोकप्रिय दावा कि बैकटेस्ट math "memory-bound है, इसलिए GPU मदद नहीं कर सकते" यहां गलत है। ~10.8 FLOP/byte पर feature math निश्चित रूप से compute-ish है — उस ridge point से काफी आगे जहां typical हार्डवेयर bandwidth-limited होना बंद कर देता है। एक GPU निश्चित रूप से 80 कॉम्बो × 7 WMA passes को कुछ बड़े kernels में batch करके arithmetic में तेजी से गुजर सकता है। अगर feature stack ही पूरी समस्या होता, तो GPU का पक्ष सम्मानजनक होता।

दूसरा मापा गया नंबर दूसरे आलसी जवाब को मार देता है — वही जिस पर हम खुद पहुंचे होते। इस compiled कर्नेल के अंदर feature stage को event लूप से अलग टाइम करने पर 99.3% features, 0.7% event लूप का split मिलता है। लुभावना तर्क — "बैकटेस्ट में एक stateful, branchy event लूप होता है, और वही GPU को रोकता है" — यहां quantitatively गलत है: CPU अपना लगभग सारा समय ठीक उसी हिस्से में बिताता है जिसे GPU batch कर सकता है। 80 कॉम्बो × 7 WMA passes को बड़े batched convolutions के रूप में recast करें और आपके पास एक बिल्कुल reasonable tensor workload है। तो ईमानदार सवाल यह नहीं है कि क्या काम GPU पर जा सकता है — ज्यादातर जा सकता है। सवाल यह है कि क्या यह ट्रिप फायदेमंद है, और इस स्वीप के लिए यह नहीं है, दो खास वजहों से:

1. Exploitable width 80 कॉम्बो है — और एक GPU एक width मशीन है। किसी parameter sweep में parallelism का इकलौता ईमानदार axis खुद ग्रिड है: एक कॉम्बो के अंदर, 150,000-bar path sequential है। एक GPU को अपनी lanes भरने और latency छुपाने के लिए दसियों हजार स्वतंत्र work items चाहिए; यह sweep अस्सी देता है। बारह CPU cores पहले से ही उस width को saturate कर देते हैं — यही तो पायदान M3–M4 ने मापा। जिन combo counts पर GPU की width काम में आना शुरू भी होगी, उन पर CPU लैडर पहले से ही सैकड़ों पूरे बैकटेस्ट प्रति सेकंड दे रही है।

2. पूरा काम 0.23 सेकंड का है। M4 स्पीड पर एक कॉम्बो की लागत लगभग 2.9 ms है (derived: 0.23 s / 80)। उस बजट के मुकाबले, kernel-launch latencies और device synchronization points amortizable rounding errors नहीं हैं — वे काम का एक material हिस्सा हैं। (इस unified-memory Apple मशीन पर, host-to-device transfer एक मामूली चिंता है; एक discrete-GPU CUDA box पर यह भी बिल में जुड़ जाता है।) क्लासिक GPU जीत fixed overheads को काम के बड़े-बड़े batches पर amortize करती है; एक sub-second sweep कभी एक नहीं पैदा करता।

और event लूप? यही वह इकलौता हिस्सा है जो batch नहीं होता — sequential, branchy, path-dependent, 150,000 bars लंबी एक loop-carried dependency जिसे कोई हार्डवेयर किसी कॉम्बो के अंदर parallelize नहीं कर सकता, बिल्कुल उन divergent branches के साथ जिनसे SIMT lanes नफरत करते हैं। एक GPU पोर्ट इसे CPU पर छोड़ देता या प्रति कॉम्बो एक lane पर चलाता। लेकिन कर्नेल के 0.7% पर, यह एक Amdahl term है जो कुछ भी तय करने के लिए बहुत छोटा है। यह वह हिस्सा है जो नहीं जाएगा; यह न जाने की वजह नहीं है। (पायदान M1 से याद करें कि feedback-free कर्नेल के लिए लूप को analytically vectorize भी किया जा सकता है — वह rewrite जिसे आप उसी पल खो देते हैं जब स्ट्रेटेजी में एक stop बढ़ता है।)

पूर्णता के लिए एक platform फुटनोट: इस मशीन पर (Apple Silicon) GPU path MLX या PyTorch-MPS होगा, CUDA नहीं — cupy और CUDA ecosystem बस लागू ही नहीं होते — और प्रयोग की कोशिश करने के लिए भी दोनों में से किसी को hot path को एक tensor dialect में फिर से लिखना पड़ेगा। यह एक असली लागत है जिसका, ऊपर के विश्लेषण के अनुसार, इस sweep के shape के लिए कोई पहचाना गया फायदा नहीं है। यहां GPU discussion विश्लेषणात्मक है, मापी गई arithmetic intensity और मापे गए feature/loop split पर आधारित है, और हम इसे वैसा ही लेबल करते हैं: कोई CUDA run नहीं किया गया क्योंकि disclosed हार्डवेयर पर कोई संभव ही नहीं था।

वह summary वाक्य जिसका हम review में बचाव करेंगे: लगभग यह सारा काम GPU पर जा सकता है; यह sweep ट्रिप को फायदेमंद बनाने के लिए बहुत संकरा और बहुत छोटा है। और इसे दोनों दिशाओं में पढ़ें — यह कोई write-off नहीं है। batched "big-matrix" reformulation — sweep को एक साथ हजारों कॉम्बो पर बड़े tensor operations के रूप में recast करना, या एक genuinely feedback-free कर्नेल जो end to end batch करता है — एक असली और आशाजनक दिशा है जो एक dedicated study की हकदार है, खारिज किए जाने की नहीं। 80 कॉम्बो और 0.23 सेकंड पर, इसने बस अभी तक टिकट नहीं कमाया है। अगर आपके workload में वह width है, तो arithmetic बदल जाता है, और आपको इसे दोबारा करना चाहिए, हमारा हवाला नहीं देना चाहिए।

असली बॉटलनेक कहां है: इंजन और ऑर्केस्ट्रेशन

The real bottleneck revealed: an hourglass where the engine and the orchestration of thousands of parameter combos choke the flow, not the hardware underneath

अस्सी कॉम्बो एक demonstration ग्रिड है। असली parameter search वह जगह है जहां ये फैक्टर academic होना बंद कर देते हैं, क्योंकि ग्रिड multiplicatively बढ़ते हैं: दस-दस values वाले चार पैरामीटर 10410^4 कॉम्बो हैं; एक दर्जन folds वाला walk-forward validation जोड़ें और आप कुछ भी explore करने से पहले 1.2×1051.2 \times 10^5 पूरे बैकटेस्ट पर पहुंच जाते हैं। यही curse of dimensionality है, और यही वजह है कि search strategy — Optuna, coordinate descent, Sobol — को इतना ध्यान मिलता है: समझदार search कम points visit करता है।

लेकिन लैडर समीकरण का दूसरा, कम चर्चित हिस्सा उजागर करती है: प्रति visited point लागत। मापे गए throughputs को linearly extrapolate करते हुए (कॉम्बो स्वतंत्र हैं, तो यह arithmetic है, modeling नहीं):

ग्रिड साइज M0 पर (1.1 combos/s) M4 पर (340.9 combos/s)
10,000 कॉम्बो ~2.4 घंटे ~30 सेकंड
100,000 कॉम्बो ~24 घंटे ~5 मिनट

वही प्रयोग जो naive इंजन पर एक overnight batch job है, tuned इंजन पर एक interactive क्वेरी है। यह अंतर एक ऐसे तरीके से compound होता है जिसे wall-clock टेबल्स कम आंकती हैं: 5 मिनट प्रति sweep पर आप iterate करते हैं — आप एक ठीक किए गए leak के साथ फिर चलाते हैं, एक fold जोड़ते हैं, ग्रिड चौड़ी करते हैं, वह idea टेस्ट करते हैं जो लंच पर आपके दिमाग में आया था। 24 घंटे प्रति sweep पर, आप नहीं करते। इंजन की स्पीड research लूप का tempo तय करती है, और research लूप का tempo ही असली product है।

पूरी लैडर का एक Amdahl's-law पठन भी है:

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

किसी भी एक stage pp को factor ss से तेज करना बाकी सब चीजों से bound है जिसे आपने धीमा छोड़ा। लैडर ने उस ordering का सम्मान किया: 35.3x इंजन gain ने उस term पर हमला किया जो हावी था (interpreted iteration, feature stack में और लूप दोनों में समान रूप से), और 8.4x ऑर्केस्ट्रेशन gain ने उसके बाद हावी term पर हमला किया (ग्यारह idle cores)। feature/loop split वही सीख छोटे रूप में है — हम यह मापे बिना कि समय असल में कहां जा रहा था, GPU तर्क का असली shape नाम ही नहीं दे पाते। पहले profile करें, फिर optimize करें — इसी क्रम में। यही तर्क इंजन के ऊपर data layer को भी नियंत्रित करता है: हमारे Polars vs pandas बेंचमार्क ने stack के load-and-transform आधे हिस्से के लिए वैसा ही pattern पाया (grouped rolling pipelines पर 10–3500x), और वही hybrid निष्कर्ष — pipeline के लिए columnar engines, path-dependent simulation के लिए एक compiled कर्नेल।

generality पर लूप बंद करने के लिए दो ईमानदारी वाले नोट। पहला, यह प्रयोग जानबूझकर self-contained और synthetic है — seeded डेटा, एक कर्नेल, एक disclosed मशीन — ताकि कोई भी इस phenomenon को deterministically reproduce कर सके; wall-clock नंबर आपके हार्डवेयर पर अलग होंगे, लेकिन equivalence और लैडर की दिशा नहीं बदलेगी। दूसरा, यह phenomenon synthetic सेटअप का artifact नहीं है: हमारे production HMA इंजन के बेंचमार्क (bench_param_sweep.py, असली exchange डेटा पर पूरे production fee और fill model के साथ चलाया गया) वही लैडर shape दिखाता है, जहां numba path naive pandas प्रोफाइल से लगभग 100–200x ऊपर पहुंचता है। यह self-contained प्रयोग इसलिए है ताकि आपको हमारे production नंबरों पर भरोसे से यकीन न करना पड़े।

मुख्य निष्कर्ष

  1. लैडर 298x है, और यह विभाजित होती है: 35.3x इंजन × 8.4x ऑर्केस्ट्रेशन। iteration को interpreter से बाहर निकालना (pandas → numba) और स्वतंत्र कॉम्बो को cores पर फैलाना (एक → बारह) एक अपरिवर्तित लैपटॉप पर तीन-orders-of-magnitude के करीब स्पीडअप में गुणा हो गया। 69.92 s → 0.23 s; 1.1 → 340.9 combos/s। और यह किसी धीमे-बेसलाइन का artifact नहीं है: सक्षम vectorized numpy इम्प्लीमेंटेशन के मुकाबले भी, फिनिश्ड इंजन अब भी ~13x है।
  2. स्पीड की तारीफ करने से पहले equivalence मांगें। यहां हर पायदान identical per-combo PnL और trade counts देता है, सभी 80 कॉम्बो पर automatically gated (PnL पर absolute 10610^{-6} tolerance, trades पर exact)। एक तेज इंजन जो कुछ सूक्ष्म रूप से अलग गणना करता है वह तेज नहीं है — यह उच्च throughput पर गलत है, और vectorized rewrites वे जगहें हैं जहां आमतौर पर यह गलती चुपके से घुस जाती है।
  3. @njit stateful logic के लिए क्लीवर vectorization को मात देता है। numpy पायदान को एक strategy-specific closed form की जरूरत थी जो stop-loss जोड़ते ही मर जाता है। numba पायदान naive, auditable लूप को compile करता है — वही speed class, कोई fragility नहीं, और यही वह unit है जो parallelize होता है।
  4. GPU का जवाब है "इस sweep के लिए नहीं" — उन वजहों से जिन्हें आप नाम दे सकें। feature math compute-ish है (10.78 FLOP/byte) और यह compiled कर्नेल का 99.3% है, तो न "बैकटेस्ट memory-bound हैं" और न "stateful लूप हावी है" measurement के सामने टिकता है। असली वजहें width और budget हैं: 80 कॉम्बो की exploitable parallelism जिसे 12 CPU cores पहले से saturate करते हैं, और एक 0.23 s total job जिसे launch और synchronization overhead खा जाएगा। असली width पर batched big-matrix reformulation एक आशाजनक दिशा बनी हुई है, खारिज की गई नहीं।
  5. इंजन की स्पीड ही research tempo है। naive-इंजन throughput पर, एक 100,000-बैकटेस्ट search एक दिन का काम है; ladder-top throughput पर यह पांच मिनट है। हार्डवेयर खरीदने या क्लस्टर किराए पर लेने से पहले, जांचें कि क्या आपका बॉटलनेक सचमुच silicon है — हमारा तो rolling.apply के अंदर एक lambda और ग्यारह idle cores थे।

पूरा प्रयोग — सभी पांच इम्प्लीमेंटेशन, equivalence harness, roofline computation, और इस लेख का हर नंबर एक deterministic script से फिर से generate होने योग्य — companion पेपर में speed-ladder.marketmaker.cc पर है, कोड और डेटा के साथ github.com/suenot/backtest-speed-ladder पर।

जिस sweep में सत्तर सेकंड लगते थे वह अब एक चौथाई सेकंड में हो जाता है। वही ट्रेड्स, वही PnL, वही लैपटॉप। जिस GPU को आप मंगवाने ही वाले थे वह इंतजार कर सकता है; जो interpreter लूप आप शिप करने ही वाले थे वह नहीं कर सकता।

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

बाज़ार से आगे रहें

AI ट्रेडिंग इनसाइट्स, मार्केट एनालिसिस और प्लेटफ़ॉर्म अपडेट के लिए हमारे न्यूज़लेटर को सब्सक्राइब करें।

हम आपकी गोपनीयता का सम्मान करते हैं। किसी भी समय अनसब्सक्राइब करें।