GPU प्रिसिजन ट्रैप: Apple Metal पर fp32 बैकटेस्ट चुपचाप गार्बेज नतीजे कैसे देता है
यह "भ्रम रहित बैकटेस्ट" सीरीज़ का हिस्सा है।
हमने अपने parameter-sweep बैकटेस्ट को GPU पर पोर्ट किया, और यह 2,796× तेज हो गया। जो नंबर यह लौटा रहा था वे plausible लग रहे थे। मगर पहले काम कर रहे वर्ज़न में वे पूरी तरह गार्बेज भी थे — दो सौ गुना गलत — और न कुछ क्रैश हुआ, न कोई चेतावनी आई, न कुछ गलत दिखा।
यही वह ट्रैप है जो Apple Silicon पर GPU बैकटेस्टिंग को उस तरह खतरनाक बनाता है जैसे CPU कोड कभी नहीं होता: Metal में float64 है ही नहीं। आपका बैकटेस्ट Apple GPU पर जिस भी नंबर को छूता है, वह 32-बिट float होता है — चाहे आपने वह मांगा हो या नहीं। और moving average को vectorize करने का सबसे लुभावना तरीका — वह O(n) prefix-sum ट्रिक जिस तक हर परफॉर्मेंस-सोच वाला क्वांट पहुंचता है — ठीक वही formulation है जिसे fp32 प्राइस-स्केल पर झेल नहीं पाता। यह एरर नहीं देता। यह पूरी स्पीड से चलता है और आपको एक plausible दिखने वाला equity curve थमा देता है, जो एक ऐसे moving average पर बना होता है जो 211 गुना गलत है।
दिलचस्प हिस्सा फिक्स है, क्योंकि यह "ज्यादा बिट्स इस्तेमाल करो" नहीं है (आप कर ही नहीं सकते) और यह "ज्यादा सावधान रहो" भी नहीं है (naive कोड पहले से ही सावधान है)। फिक्स यह है कि उसी गणितीय मात्रा को एक अलग sum से निकाला जाए — एक direct windowed convolution — जो हर intermediate value को इतना छोटा रखे कि fp32 उसे बिल्कुल सटीक रूप से represent कर सके। वही WMA, सात significant figures तक वही नतीजा, और CPU पर single-thread numba से 55.9× तेज। यह लेख उसी का पोस्टमार्टम है: Metal आप पर fp32 क्यों थोपता है, obvious formulation ठीक कहां overflow करता है, सही वाला क्यों नहीं करता, और हमने कैसे साबित किया — curves को घूर कर नहीं, बल्कि trades गिनकर — कि तेज़ वर्ज़न और ईमानदार fp64 वर्ज़न आपस में सहमत हैं।
यहां दिए गए सभी नंबर Apple M2 Max पर मापे गए हैं, 150,000 bars × 80 parameter combinations, best-of-3, रेपो की scripts/bench_param_sweep.py (M5 GPU मेथड, commit 97eadaf), scripts/engine_multitf_gpu.py (04d71e8), और scripts/GPU_NOTES.md में दिए गए design notes से।
Metal में float64 है ही नहीं

CPU पर, एक Python/numpy बैकटेस्ट डिफॉल्ट रूप से double precision में चलता है। float64 आपको 52-बिट mantissa देता है: integers 2⁵³ ≈ 9×10¹⁵ तक बिल्कुल सटीक represent होते हैं, और relative precision करीब 1.1×10⁻¹⁶ है। आप इसके बारे में लगभग कभी सोचते ही नहीं, क्योंकि प्राइस-स्केल पर — एक BTC close करीब 30,000 का, एक cumulative sum जो 10¹⁴ तक पहुंचे — fp64 के पास काफी मार्जिन बचा रहता है।
Apple का GPU वह मार्जिन नहीं देता, क्योंकि वह float64 बिल्कुल भी नहीं देता। Metal Shading Language में double टाइप है ही नहीं; MLX, PyTorch-MPS, और Apple Silicon को टारगेट करने वाला हर दूसरा framework यही विरासत पाता है। इसे ऑन करने के लिए कोई flag नहीं है और कोई धीमा-लेकिन-सही fallback भी नहीं। अगर आपकी computation GPU को छूती है, तो वह fp32 में ही होती है:
- Mantissa: 23 बिट (implicit leading bit के साथ 24)। Integers सिर्फ 2²⁴ = 16,777,216 ≈ 1.6×10⁷ तक ही सटीक हैं।
- Relative precision: ~1.2×10⁻⁷। मोटे तौर पर सात significant decimal digits, और उससे ज्यादा नहीं।
वह 1.6×10⁷ की ceiling ही पूरी कहानी है। सुनने में यह उदार लगती है — सोलह मिलियन — जब तक आप यह नोटिस नहीं करते कि एक बैकटेस्ट रूटीनली इससे कहीं बड़ी intermediate मात्राएं बनाता है, और जिस पल कोई intermediate 1.6×10⁷ पार करता है, fp32 consecutive integers तक को represent नहीं कर पाता, fractional structure की तो बात ही छोड़िए जिसकी आपको जरूरत है। precision धीरे-धीरे कम नहीं होती; वह एक cliff से गिरती है, और वह cliff ठीक उस नंबर पर है जिसे आपका डेटा बिना दूसरी बार सोचे पार कर जाता है।
ट्रैप यह है कि fp32 लगभग हमेशा ठीक रहता है। बैकटेस्ट का ज्यादातर हिस्सा — prices, returns, PnL, Sharpe — उस रेंज में आराम से रहता है जहां सात digits काफी हैं। तो naive पोर्ट काम करता है, smoke test पास करता है, sane दिखने वाला output देता है। फेलियर ठीक एक ऑपरेशन में सिमटा होता है, वह एक जगह जहां कोई intermediate 10⁷ पार कर जाता है — और वह ऑपरेशन बिल्कुल वही होता है जिसे हर कोई सबसे पहले vectorize करता है।
लुभावना formulation: एक ही prefix-sum sweep में WMA
हमारी strategy Hull moving averages पर टिकी है। एक HMA तीन weighted moving averages को मिलाकर बनता है; HMA3 variant चार को मिलाकर। length p की window पर एक linear kernel वाला weighted moving average यह है:
150k bars पर हजारों parameter combinations को sweep करते हुए, WMA convolutions ही असली cost हैं। तो instinct — CPU पर सही instinct — यह है कि हर WMA को O(n·p) की जगह O(n) बनाया जाए, prefix sums का इस्तेमाल करके। आप एक बार दो cumulative sums precompute करते हैं,
और फिर किसी भी window का linearly-weighted sum S1 और S2 के चंद differences और index shifts में सिमट जाता है। न कोई per-window loop, न कोई per-window reduction — बस दो cumsum passes, और WMAs की पूरी matrix array arithmetic से निकल आती है। यह खूबसूरती से vectorize होता है, GPU के parallel-scan primitive पर बिल्कुल फिट बैठता है, और 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 टर्म्स का running sum है। यह price scale पर नहीं टिकता। यह उस इलाके में चढ़ जाता है जहां fp32 पहले ही गिनना बंद कर चुका होता है।
यह overflow कहां होता है: ट्रैप का arithmetic

चलिए orders of magnitude को एक-दूसरे के बगल में रखते हैं, क्योंकि पूरी फेलियर यहीं छुपी है।
S2 = cumsum(j · price) लगभग price · n²/2 ≈ 30,000 · (150,000)²/2 ≈ 3×10¹⁴ तक पहुंचता है। इसे ~10¹⁴ कह लीजिए। अब fp32 की exact-integer ceiling याद कीजिए: ~1.6×10⁷। running sum उस आखिरी integer को, जिसे fp32 बिल्कुल सटीक represent कर सकता है, सात orders of magnitude से ओवरशूट कर जाता है।
इसका ठोस मतलब क्या है? 10¹⁴ के आसपास, दो representable fp32 नंबरों के बीच का gap — यानी एक unit in the last place, ULP — करीब 2²³ ≈ 8×10⁶ होता है। तो एक बार जब S2, 10¹⁴ की रेंज में पहुंच जाए, तो वह सिर्फ ±8 मिलियन की सटीकता तक ही जाना जाता है। उसमें स्टोर हुई हर वैल्यू को ~8×10⁶ के नज़दीकी multiple पर round कर दिया गया होता है।
अब देखिए WMA recovery इसके साथ क्या करती है। एक window का weighted sum निकालने के लिए, आप दो पड़ोसी S2 values को घटाते हैं (साथ में S1 corrections)। वे दोनों S2 values हर एक ~10¹⁴ के हैं, हर एक ±8×10⁶ का rounding noise साथ लिए हुए। उनका असली अंतर — वह windowed मात्रा जो आपको असल में चाहिए — normalization के बाद, प्राइस के ही ऑर्डर के एक WMA के बराबर होता है, ~3×10⁴। तो arithmetic यह बनता है:
यह अपने सबसे शुद्ध रूप में catastrophic cancellation है: हर operand का rounding error (±8×10⁶) उस जवाब से बड़ा है जिसे आप हासिल करने की कोशिश कर रहे हैं। signal उन नंबरों के noise floor से भी छोटा है जिनसे उसे निकाला जा रहा है। बात यह नहीं कि आप कुछ digits खो देते हैं — आप सब के सब खो देते हैं, और जो वापस मिलता है वह cumsum के accumulated rounding से dominate होता है।
GPU_NOTES.md से मापा गया नतीजा: price ~30,000 पर 150k bars पर इस तरह से compute किए गए WMA के लिए, fp64 के मुकाबले maximum relative error ~211 तक पहुंचता है। 211 प्रतिशत नहीं — 211×। computed moving average असली वाले से दो orders of magnitude दूर हो सकता है। और यहीं वह हिस्सा है जो इसे bug नहीं बल्कि ट्रैप बनाता है: यह पूरा चलकर खत्म होता है और finite, plausible नंबर लौटाता है। न infinity तक overflow, न NaN, न कोई exception। 211× गलत moving average भी moving average जैसा ही दिखता है — यह smooth है, finite है, उन bars पर सही ballpark में है जहां cancellation संयोग से हल्का रहा — तो यह हर उस sanity check से पार निकल जाता है जो किसी trusted reference से direct comparison नहीं है। आपको एक पूरा बैकटेस्ट मिलता है, एक पूरा equity curve, "optimal" parameters का एक पूरा सेट — यह सब एक ऐसे indicator पर बना जो कल्पना है।
फिक्स ज्यादा precision नहीं है — यह एक अलग sum है

error देखते ही जो reflex आता है वह है ज्यादा precision की तरफ भागना — fp64 में accumulate करो, या compensated (Kahan) summation इस्तेमाल करो। Metal पर, पहला विकल्प सीधे-सीधे उपलब्ध ही नहीं है। लेकिन आपको इनमें से किसी की भी जरूरत नहीं, क्योंकि समस्या कभी बिट्स की संख्या थी ही नहीं। समस्या formulation थी। prefix-sum ट्रिक 10¹⁴-स्केल के intermediates बनाती है और फिर उन्हें वापस घटा देती है; जो magnitudes यह पैदा करती है वे algorithm का artifact हैं, जवाब का नहीं। एक ऐसा formulation चुनिए जो इन्हें कभी बनाए ही नहीं, और fp32 बिल्कुल ठीक रहेगा।
वह formulation खुद परिभाषा ही है: एक direct windowed convolution। दो global cumulative sums की जगह, length-p वाले linear kernel को series पर स्लाइड कीजिए और इसे वहीं sum कर दीजिए। हर output अधिकतम p ≈ 200 terms का sum है, और हर term weight × price है जहां weights normalize होकर 1 तक जोड़ते हैं — तो हर term price / p के ऑर्डर का है, हर partial sum price scale (~3×10⁴) के आसपास रहता है, और कोई भी intermediate कभी fp32 की ceiling के छह orders of magnitude के भीतर तक नहीं पहुंचता। यहां कुछ भी cancel करने को नहीं है क्योंकि कुछ भी कभी फूला ही नहीं।
MLX में यह एक ही primitive है — mx.conv1d — जो ठीक वही है जिसे तेज़ी से करने के लिए GPUs बनाए गए हैं:
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, prefix-sum वर्ज़न और CPU के fp64 vec_wma/nb_wma के गणितीय रूप से बिल्कुल बराबर। लेकिन अब मापा गया fp64 के मुकाबले maximum relative error 8.2×10⁻⁷ है — ठीक fp32 के ~1.2×10⁻⁷ noise floor पर, सात significant figures का agreement। जो formulation कागज पर धीमा दिखता है (O(n) की जगह O(n·p)), वही अकेला सही है, और — चूंकि यह एक dense convolution है जिसे GPU bars और windows दोनों पर एक साथ parallelize करता है — यह बेहद तेज़ भी है। हम 211 के relative error से 8×10⁻⁷ तक पहुंचे, यह बदलकर कि हम sum कैसे करते हैं, न कि कितने बिट्स में sum करते हैं।
इस तरीके से करने पर दो प्रैक्टिकल नोट्स निकलते हैं। पहला, MLX एक conv1d के जरिए NaNs को उस तरह propagate नहीं करता जैसे numpy करता है, तो warm-up region (शुरुआती p−1 bars, जहां windowed average परिभाषित ही नहीं है) को GPU पर NaN से मार्क नहीं किया जा सकता। हमें इसकी जरूरत भी नहीं: हर series की valid शुरुआत analytically पता है, invalid prefixes को zeros से भर दिया जाता है जो कभी पढ़े ही नहीं जाते, और NaN padding को बाद में CPU पर restore कर दिया जाता है — bit-for-bit वही validity mask जो vectorized और numba वर्ज़न में है। दूसरा, पूरा sweep एक ही cand_close series शेयर करता है और combos के बीच windows को भारी मात्रा में reuse करता है, तो कई output channels वाला एक ही batched conv1d, sweep को चाहिए हर unique WMA को एक ही GPU call में compute कर देता है, और एक ही mx.eval() से materialize करता है।
यह साबित करना कि आप फंसे नहीं: trade count से parity
पिछले सेक्शन को यह असहज सवाल उठाना चाहिए: अगर 211× गलत WMA भी WMA जैसा दिखता है, तो आपको कैसे पता चले कि 8×10⁻⁷ वाला वर्ज़न असल में सही है, और सिर्फ ज्यादा सूक्ष्म ढंग से गलत नहीं है? इसे आप घूरकर नहीं जान सकते। आपको एक ऐसा invariant चाहिए जिसे pipeline का कोई downstream, discrete हिस्सा उजागर करे — और एक बैकटेस्ट आपको एक बिल्कुल परफेक्ट invariant थमाता है: trades।
ladder के बाकी GPU methods (M0–M4) पूरी तरह fp64 में चलते हैं, तो हम उन्हें एक सख्त equivalence assert पर रखते हैं — identical trade counts, PnL atol=1e-6 तक मैच करता हुआ। fp32 GPU method (M5) अपने बनावट (construction) से ही इसे पास नहीं कर सकता, और इसे adjust करने के लिए सबके लिए चुपचाप assert को ढीला कर देना ठीक वैसी बेईमानी होगी जिससे लड़ने के लिए यह सीरीज़ मौजूद है। तो M5 को अपनी खुद की quantitative parity report मिलती है, report_equiv_fp32, जो इसके निकाले गए trades की तुलना fp64 reference से करती है।
बचे-खुचे किसी भी disagreement के mechanism को सटीक रूप से नाम देना जरूरी है, क्योंकि यह cancellation वाली तबाही नहीं है — यह वही आम, मामूली fp32 rounding है जिसकी आप उम्मीद करेंगे। strategy दो Hull averages, h बनाम h3, के crossover पर फायर करती है। price ~30,000 पर किसी indicator पर ~1×10⁻⁶ का relative error, ~0.03 का absolute wobble बनता है। ज्यादातर bars पर दोनों curves इससे कहीं ज्यादा दूर होते हैं और crossover बेशक साफ होता है। लेकिन किसी borderline bar पर — जहां h − h3 खुद शून्य के 0.03 के भीतर हो — वह wobble comparison का sign पलट सकता है, एक crossover को एक ही bar से खिसका सकता है, एक trade जोड़ या हटा सकता है।
"कितने प्रतिशत combos अलग हैं" एक बेकार health metric है, और हमारे पहले parity check ने इसे इस्तेमाल करके खुद को शर्मिंदा किया। 150k bars पर हर combo में हजारों crossovers होते हैं, तो लगभग हर combo पर कम से कम एक borderline bar दिख ही जाता है — 80 में से 37 combos "अलग" थे, जो सुनने में अलार्मिंग लगता है और इसका मतलब कुछ भी नहीं। जो metric मायने रखता है वह है कितना अलग:
- सभी 80 combos पर PnL delta: max |Δ| = 1.843 percentage points, max relative = 1.25×10⁻²; crash threshold 5 p.p.
- हर combo में trade-count drift: max |Δn| = 4 trades हज़ारों में से, max relative = 2.5×10⁻³; crash threshold 1%.
- कुल मिलाकर: 479,016 में से 90 trades शिफ्ट हुए — 0.019%।
करीब आधे मिलियन में से नब्बे trades, हर एक एक borderline crossover जिसे एक price tick से भी छोटे rounding wobble ने हल्का सा धकेल दिया, इनमें से कोई भी crash thresholds के आसपास तक नहीं। यही एक सही fp32 method का signature है — छोटा, bounded, समझ में आने वाला disagreement — और यह 211 के relative error से बिल्कुल अलग जानवर है। thresholds इसलिए मौजूद हैं ताकि एक टूटे हुए formulation को पकड़ा जा सके जो "अरे, यह तो बस fp32 है" की आड़ ले रहा हो; असली deltas इनसे एक order of magnitude नीचे आते हैं। trade count वह oracle है जो equity curve बनने से इनकार कर गया।
फायदा, और जहां GPU मदद करना बंद कर देता है
सही होने की पुष्टि हो जाने के बाद, स्पीड बताने लायक है — और फिर ईमानदारी से qualify करने लायक भी, क्योंकि GPU का फायदा पूरे pipeline में एक-समान नहीं है।
अकेले, pure WMA convolutions पर — वह ऑपरेशन जिसे तेज़ करने के लिए यह पूरा method मौजूद है — fp32 conv1d batch single-thread numba से 55.9× तेज़ चलता है, उसी 8.2×10⁻⁷ के relative error पर। यही clean, apples-to-apples GPU-बनाम-CPU नंबर है: वही गणित, compiled CPU कोड का एक थ्रेड, Metal GPU के मुकाबले।
लेकिन एक sweep सिर्फ convolutions नहीं है। एक बार HMA/HMA3 matrices GPU पर compute हो जाएं, तो trades को अभी भी निकालना पड़ता है — हर combo के crossovers पर एक O(n) walk — और यह हम CPU पर fp64 में करते हैं, बाकी methods के trade semantics को दोबारा GPU पर implement करने की बजाय exactly reuse करते हुए। end-to-end timed() का आंकड़ा सब कुछ शामिल करता है: kernel warm-up बाहर (numba के compile को बाहर रखने के सिमेट्रिक), लेकिन GPU→CPU transfer और CPU trade extraction शामिल। 150k bars × 80 combos पर, best-of-3, M2 Max पर:
| Method | Wall | Speedup vs baseline | combos/s |
|---|---|---|---|
| M0 pandas + Python लूप* | 287.08s | 1.0× | 0.3 |
| M1 vectorized numpy | 3.14s | 91.5× | 25.5 |
| M2 numba (सीरियल) | 2.02s | 142.3× | 39.7 |
| M3 multiprocess + vectorized | 0.50s | 570.2× | 158.9 |
| M4 multiprocess + numba (12 कोर) | 0.33s | 882.5× | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796.0× | 779.2 |
*M0 को एक uniform 5-combo sample से extrapolate किया गया है।
पूरा-engine M5 779 combos/s करता है — pandas baseline से 2,796× ज्यादा, serial numba से 19.6× ज्यादा, और numba चला रहे पूरे 12-core CPU pool (M4) से 3.2× ज्यादा। एक GPU, मशीन के पास मौजूद हर CPU core को तिगुने अंतर से हरा देता है।
अब ईमानदार qualifier: गौर कीजिए कि end-to-end GPU advantage (M2 से 19.6× ज्यादा) सिर्फ-convolution वाले advantage (numba से 55.9× ज्यादा) से छोटा है। यह gap ठीक समय पर पहुंचा Amdahl's law है। GPU convolutions को इतनी बुरी तरह खत्म कर देता है कि वे bottleneck रहते ही नहीं; जो बचता है — वह O(n) CPU trade extraction जिसे GPU ने बिल्कुल भी तेज़ नहीं किया — अब M5 के wall time पर हावी है। यह वही सबक है जिस पर इस सीरीज़ के speed-ladder और IPC-tax वाले लेख बार-बार पहुंचते हैं: एक बिंदु के बाद, जीत "तेज़ हिस्से को और तेज़ बनाओ" नहीं होती, वह orchestration होती है — डेटा कहां रहता है, कौन सा stage अब serial है, device और host के बीच data भेजने के लिए आप क्या चुका रहे हैं। एक काल्पनिक M6 का पीछा करना, जो trade extraction को एक custom Metal kernel में धकेल दे, सिर्फ सिकुड़ते हुए CPU हिस्से को वापस पाएगा — इसलिए हमने इसे नहीं बनाया।
आम सबक: silent numerical गार्बेज ही डिफॉल्ट है
Hull averages और MLX से एक कदम पीछे हटिए, क्योंकि यह ट्रैप इस एक indicator से कहीं आगे तक सामान्यीकृत होता है।
GPU बैकटेस्टिंग की लुभावनी पिच है "one big matrix": हर parameter combination को एक tensor में stack कर दो, पूरे sweep को चंद dense array ops की तरह चला दो, हार्डवेयर को इसे निगलने दो। वह पिच असली है — ऊपर दिए गए speedups असली हैं। लेकिन यह आपके नीचे का numerical regime चुपचाप बदल देती है, और यह बदलाव कोड में कहीं दिखता नहीं। CPU पर आपके defaults आपकी हिफाजत करते थे: fp64, NaN propagation, एक cumsum जो बिना नोटिस किए 10¹⁴ तक चल सकता था। उसी array expression को Metal पर ले जाइए और आप fp32 में हैं, एक सख्त 1.6×10⁷ integer ceiling के साथ, और कोड की बिल्कुल वही लाइन — cumsum(j * price) — सटीक से गार्बेज बन जाती है। syntax में कुछ भी आपको आगाह नहीं करता। compiler खुश रहता है। output finite और plausible होता है। fp32 जोर से फेल नहीं होता; यह विनम्रता से, नंबरों के साथ फेल होता है।
जो तीन आदतें असल में आपकी हिफाजत करती हैं, वे सस्ती हैं:
- यह जानिए कि आपके intermediates कहां रहते हैं, सिर्फ inputs और outputs नहीं। inputs (prices ~10⁴) और outputs (एक WMA ~10⁴) दोनों आराम से fp32 की exact रेंज के भीतर थे। पूरी तबाही एक छुपे हुए intermediate (
S2~10¹⁴) में थी, जिसे न तो API ने और न ही types ने दिखाई दिया। किसी भी fp32 reduction पर भरोसा करने से पहले पूछिए कि सबसे बड़ा partial sum कहां तक पहुंचता है — और अगर वह ~10⁷ पार करता है, तो formulation बदल दीजिए। - ऐसे formulations चुनिए जो magnitudes को bounded रखें। prefix sums की जगह direct convolution; global scans की जगह local windows; sum करने के बाद नहीं, बल्कि पहले centering/differencing। Big-then-cancel एक anti-pattern है। जो सही algorithm होता है, वह अक्सर वही होता है जो कागज पर asymptotically बुरा दिखता है लेकिन ऐसी कोई मात्रा कभी नहीं बनाता जिसे बाद में cancel करना पड़े।
- एक discrete invariant के जरिए fp64 oracle के मुकाबले validate कीजिए। curves की तुलना मत कीजिए; कुछ quantized और downstream चीज़ की तुलना कीजिए — trade count, crossovers की संख्या, position-change events। एक discrete invariant एक silent 211× error को एक चीखती हुई assertion failure में बदल देता है, और एक acceptable 8×10⁻⁷ error को एक छोटे, bounded, explainable delta में बदल देता है। यह वही discipline है जो look-ahead bias के लिए one-bar shift test में है: एक सस्ता diagnostic जो एक अदृश्य फेलियर को दिखने वाली बना देता है।
इसमें से कुछ भी exotic numerical analysis नहीं है। यह बस वह मामूली hygiene है कि किसी तेज़ बैकटेस्ट पर तब तक भरोसा न करें जब तक कोई धीमा, trusted वाला उसे मान्य न कर दे — बस इसे उस एक जगह तक बढ़ा दिया गया है जहां भाषा आपको यह चेतावनी देना बंद कर देती है कि precision चुपचाप बारह orders of magnitude गिर चुकी है।
मुख्य बातें

- Apple के GPU में float64 है ही नहीं — आपके बैकटेस्ट में हर GPU नंबर fp32 है। Integers सिर्फ ~1.6×10⁷ तक सटीक हैं और precision ~1.2×10⁻⁷ है। न कोई flag, न कोई fallback। बैकटेस्ट का ज्यादातर हिस्सा इसे झेल जाता है; आमतौर पर ठीक एक ऑपरेशन नहीं झेल पाता।
- prefix-sum WMA ही ट्रैप है।
cumsum(j · price)~10¹⁴ तक चढ़ जाता है, fp32 की exact ceiling से सात orders आगे, और एक window निकालने के लिए आपको ऐसे दो नंबरों को घटाना पड़ता है जिनका rounding error (±8×10⁶) पहले से ही जवाब को बौना कर चुका होता है। मापा गया max relative error: 211×। यह कभी crash नहीं होता — यह plausible गार्बेज लौटाता है। - फिक्स एक अलग sum है, ज्यादा बिट्स नहीं। एक direct windowed convolution (
mx.conv1d) हर partial sum को price scale के पास रखता है, तो fp32 सात ईमानदार digits थाम लेता है: 8.2×10⁻⁷ का relative error, और single-thread numba से 55.9× तेज़। आप Metal पर fp64 खरीद नहीं सकते, और आपको इसकी जरूरत भी नहीं। - एक discrete invariant से verify कीजिए, कभी curve से नहीं। Trade-count parity ने इसे पकड़ा: fp32 conv1d, fp64 से 479,016 में से 90 trades (0.019%) पर असहमत था, सब के सब borderline crossovers, सब crash thresholds से बहुत नीचे — यह एक सही method का signature है, जो 211× के error से साफ-साफ अलग है। "कितने प्रतिशत combos अलग हैं" एक भटकाने वाला metric है; कितना अलग नापिए।
- पूरा sweep 779 combos/s करता है — pandas baseline से 2,796× ज्यादा, पूरे 12-core CPU pool से 3.2× ज्यादा — लेकिन end-to-end जीत (serial numba से 19.6× ज्यादा) convolution-only जीत (55.9×) से छोटी है, क्योंकि CPU trade extraction अब bottleneck है। एक बिंदु के बाद, स्पीड orchestration है, arithmetic नहीं।
GPU port 2,796× तेज़ था, और अपने पहले काम कर रहे वर्ज़न में पूरी तरह गलत भी — और इन दोनों तथ्यों का आपस में कोई लेना-देना नहीं था। स्पीड असली थी। गार्बेज एक छुपा हुआ 10¹⁴ intermediate था जिसे fp32 थाम नहीं सका और जिसका जिक्र कोई error message नहीं करता। अगर कोई बैकटेस्ट नाटकीय रूप से तेज़ हो जाए और नंबर फिर भी ठीक दिखें, तो यह पुष्टि नहीं है। Metal पर, "ठीक दिखना" ठीक वैसा ही दिखता है जैसा 211 का relative error दिखता है।
यह उस ladder की GPU वाली सीढ़ी है जिस पर यह सीरीज़ चढ़ती आई है: backtest-engine speed ladder, multiprocessing का IPC tax, leaks की look-ahead taxonomy, और objective-function design जो यह तय करता है कि "अच्छा" का मतलब आखिर होता क्या है। अगर स्पीड गलत नंबर compute करने में तेज़ है, तो वह बेकार है।
Authors
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.