La trappola della precisione GPU: come un backtest in fp32 su Apple Metal restituisce silenziosamente spazzatura
Parte della serie "Backtest senza illusioni".
Abbiamo portato il nostro backtest a spazzata di parametri sulla GPU ed è diventato 2.796× più veloce. I numeri che restituiva erano plausibili. Erano anche, nella prima versione funzionante, completa spazzatura — sbagliati di un fattore duecento — e nulla è andato in crash, nulla ha avvisato, nulla sembrava sbagliato.
Questa è la trappola che rende il backtesting su GPU su Apple Silicon pericoloso in un modo in cui il codice CPU non lo è: Metal non ha float64. Ogni numero che il tuo backtest tocca su una GPU Apple è un float a 32 bit, che tu l'abbia chiesto o meno. E il modo più tentante in assoluto per vettorizzare una media mobile — il trucco della somma prefissa O(n) a cui ricorre ogni quant attento alle prestazioni — è esattamente la formulazione a cui l'fp32 non può sopravvivere alla scala dei prezzi. Non genera un errore. Gira alla massima velocità e ti consegna una curva di equity dall'aspetto plausibile costruita su una media mobile sbagliata di un fattore 211.
La soluzione è la parte interessante, perché non è "usa più bit" (non puoi) e non è "sii più attento" (il codice ingenuo è già attento). La soluzione è calcolare la stessa quantità matematica con una somma diversa — una convoluzione a finestra diretta — che mantiene ogni valore intermedio abbastanza piccolo da essere rappresentato esattamente dall'fp32. Stessa WMA, stesso risultato fino alla settima cifra significativa, e 55,9× più veloce del numba a thread singolo sulla CPU. Questo articolo è l'autopsia: perché Metal ti impone l'fp32, esattamente dove la formulazione ovvia va in overflow, perché quella corretta no, e come abbiamo dimostrato — contando le operazioni, non guardando a occhio le curve — che la versione veloce e la versione onesta in fp64 concordano.
Tutti i numeri qui sono misurati su un Apple M2 Max, 150.000 barre × 80 combinazioni di parametri, il migliore di 3, tratti da scripts/bench_param_sweep.py del repo (il metodo GPU M5, commit 97eadaf), scripts/engine_multitf_gpu.py (04d71e8), e le note di progettazione in scripts/GPU_NOTES.md.
Metal non ha float64

Su una CPU, un backtest Python/numpy gira in doppia precisione per default. Il float64 ti dà una mantissa a 52 bit: gli interi sono rappresentati esattamente fino a 2⁵³ ≈ 9×10¹⁵, e la precisione relativa è di circa 1,1×10⁻¹⁶. Non ci pensi quasi mai, perché alla scala dei prezzi — un close di BTC intorno a 30.000, una somma cumulativa che raggiunge 10¹⁴ — l'fp64 ha margine da vendere.
La GPU di Apple non offre quel margine, perché non offre float64 affatto. Il Metal Shading Language non ha il tipo double; MLX, PyTorch-MPS, e ogni altro framework che punta ad Apple Silicon eredita questo. Non c'è un flag per attivarlo e non c'è un fallback lento-ma-corretto. Se il tuo calcolo tocca la GPU, avviene in fp32:
- Mantissa: 23 bit (24 con il bit iniziale implicito). Gli interi sono esatti solo fino a 2²⁴ = 16.777.216 ≈ 1,6×10⁷.
- Precisione relativa: ~1,2×10⁻⁷. Circa sette cifre decimali significative, e non di più.
Quel tetto di 1,6×10⁷ è tutta la storia. Sembra generoso — sedici milioni — finché non noti che un backtest costruisce di routine quantità intermedie molto più grandi di così, e nel momento in cui un intermedio supera 1,6×10⁷, l'fp32 non riesce più nemmeno a rappresentare interi consecutivi, per non parlare della struttura frazionaria di cui hai bisogno. La precisione non degrada con grazia; precipita da una scogliera, e la scogliera è a un numero che i tuoi dati oltrepassano senza pensarci due volte.
La trappola è che l'fp32 va quasi sempre bene. La maggior parte di un backtest — prezzi, rendimenti, PnL, Sharpe — vive comodamente nell'intervallo in cui sette cifre sono più che sufficienti. Quindi il port ingenuo funziona, supera uno smoke test, produce un output dall'aspetto sensato. Il fallimento è localizzato in esattamente un'operazione, l'unico punto in cui un intermedio schizza oltre 10⁷, e quell'operazione è quella che tutti vettorizzano per prima.
La formulazione tentante: WMA in una sola passata a somma prefissa
La nostra strategia si appoggia sulle Hull moving average. Una HMA è composta da tre medie mobili ponderate; la variante HMA3 ne ha quattro. Una media mobile ponderata con un kernel lineare su una finestra di lunghezza p è
Spazzando migliaia di combinazioni di parametri su 150k barre, le convoluzioni WMA sono il costo. Quindi l'istinto — l'istinto corretto, su una CPU — è rendere ogni WMA O(n) invece di O(n·p) usando le somme prefisse. Precalcoli due somme cumulative una volta sola,
e poi la somma linearmente ponderata di qualsiasi finestra collassa in una manciata di differenze e scorrimenti di indice di S1 e S2. Nessun loop per finestra, nessuna riduzione per finestra — due passate di cumsum e l'intera matrice delle WMA cade fuori dall'aritmetica sugli array. Si vettorizza splendidamente, si mappa perfettamente sulla primitiva di scan parallelo di una GPU, e in fp64 è esattamente corretto.
È anche la cosa peggiore in assoluto che tu possa fare in fp32, e il motivo si nasconde in S2.
S2 = mx.cumsum(j * price) # j is the global bar index: 0, 1, 2, ... , n-1
Il termine j · price è il problema. Con j che arriva fino a 150.000 e price intorno a 30.000, il solo ultimo termine è 4,5×10⁹, e S2 è la somma corrente di 150.000 di questi termini. Non resta alla scala dei prezzi. Sale nel territorio dove l'fp32 ha già smesso di essere in grado di contare.
Dove va in overflow: l'aritmetica della trappola

Mettiamo gli ordini di grandezza uno accanto all'altro, perché è lì che vive tutto il fallimento.
S2 = cumsum(j · price) raggiunge all'incirca price · n²/2 ≈ 30.000 · (150.000)²/2 ≈ 3×10¹⁴. Chiamiamolo ~10¹⁴. Ora richiama il tetto degli interi esatti dell'fp32: ~1,6×10⁷. La somma corrente supera l'ultimo intero che l'fp32 può rappresentare esattamente di sette ordini di grandezza.
Cosa significa concretamente? Vicino a 10¹⁴, il divario tra due numeri fp32 rappresentabili — un'unità nell'ultima posizione, la ULP — è di circa 2²³ ≈ 8×10⁶. Quindi una volta che S2 è su nell'intervallo 10¹⁴, è nota solo entro ±8 milioni. Ogni valore che memorizza è stato arrotondato al multiplo più vicino di ~8×10⁶.
Ora guarda cosa ci fa il recupero della WMA. Per estrarre la somma ponderata di una singola finestra, sottrai due valori S2 vicini (più le correzioni di S1). Quei due valori S2 sono ciascuno ~10¹⁴, ciascuno portatore di ±8×10⁶ di rumore di arrotondamento. La loro differenza vera — la quantità a finestra che vuoi effettivamente — corrisponde, dopo la normalizzazione, a una WMA dell'ordine del prezzo stesso, ~3×10⁴. Quindi l'aritmetica è:
Questa è la cancellazione catastrofica nella sua forma più pura: l'errore di arrotondamento di ciascun operando (±8×10⁶) è più grande della risposta che stai cercando di recuperare. Il segnale è più piccolo del pavimento di rumore dei numeri da cui viene estratto. Non è che perdi qualche cifra — le perdi tutte, e ciò che torna è dominato dall'arrotondamento accumulato della cumsum.
La conseguenza misurata, da GPU_NOTES.md: per una WMA calcolata in questo modo su 150k barre a prezzo ~30.000, l'errore relativo massimo rispetto all'fp64 raggiunge ~211. Non 211 percento — 211×. La media mobile calcolata può essere due ordini di grandezza lontana da quella vera. Ed ecco la parte che ne fa una trappola piuttosto che un bug: gira fino al completamento e restituisce numeri finiti e plausibili. Nessun overflow all'infinito, nessun NaN, nessuna eccezione. Una media mobile sbagliata di 211× sembra comunque una media mobile — è liscia, è finita, è più o meno nel campo giusto sulle barre dove la cancellazione capita di essere lieve — quindi supera senza problemi ogni controllo di sanità che non sia un confronto diretto con un riferimento fidato. Ottieni un backtest completo, una curva di equity completa, un set completo di parametri "ottimali", tutto costruito su un indicatore che è finzione.
La soluzione non è più precisione — è una somma diversa

Il riflesso, una volta visto l'errore, è ricorrere a più precisione — accumulare in fp64, o usare la sommatoria compensata (Kahan). Su Metal, la prima è semplicemente non disponibile. Ma non ti serve nessuna delle due, perché il problema non è mai stato il numero di bit. Il problema era la formulazione. Il trucco della somma prefissa fabbrica intermedi in scala 10¹⁴ e poi li sottrae di nuovo verso il basso; le grandezze che crea sono un artefatto dell'algoritmo, non della risposta. Scegli una formulazione che non le crea mai e l'fp32 va bene.
Quella formulazione è la definizione stessa: una convoluzione a finestra diretta. Invece di due somme cumulative globali, fai scorrere il kernel lineare di lunghezza p lungo la serie e sommalo sul posto. Ogni output è una somma di al più p ≈ 200 termini, e ogni termine è weight × price dove i pesi sono normalizzati per sommare a 1 — quindi ogni termine è dell'ordine di price / p, ogni somma parziale resta intorno alla scala dei prezzi (~3×10⁴), e nessun intermedio arriva mai entro sei ordini di grandezza dal tetto dell'fp32. Non c'è nulla da cancellare perché nulla si è mai gonfiato.
In MLX questa è una sola primitiva — mx.conv1d — che è esattamente ciò che le GPU sono costruite per fare velocemente:
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)
Stessa WMA, matematicamente identica alla versione a somma prefissa e alle vec_wma/nb_wma in fp64 della CPU. Ma ora l'errore relativo massimo misurato rispetto all'fp64 è 8,2×10⁻⁷ — proprio al pavimento di rumore dell'fp32 di ~1,2×10⁻⁷, sette cifre significative di accordo. La formulazione che sulla carta sembra più lenta (O(n·p) invece di O(n)) è l'unica che è corretta, e — poiché è una convoluzione densa che la GPU parallelizza sia sulle barre che sulle finestre contemporaneamente — è anche fulmineamente veloce. Siamo passati da un errore relativo di 211 a 8×10⁻⁷ cambiando come sommiamo, non con quanti bit sommiamo.
Due note pratiche che discendono dal farlo in questo modo. Primo, MLX non propaga i NaN attraverso un conv1d come fa numpy, quindi la regione di warm-up (le prime p−1 barre, dove una media a finestra non è definita) non può essere marcata con NaN sulla GPU. Non abbiamo bisogno che lo sia: l'inizio valido di ogni serie è noto analiticamente, i prefissi non validi sono riempiti con zeri che non vengono mai letti, e il padding NaN viene ripristinato sulla CPU in seguito — bit per bit la stessa maschera di validità delle versioni vettorizzata e numba. Secondo, l'intera spazzata condivide una sola serie cand_close e riutilizza pesantemente le finestre tra le combo, quindi un singolo conv1d batch con molti canali di output calcola ogni WMA unica di cui la spazzata ha bisogno in una sola chiamata GPU, materializzata con un solo mx.eval().
Dimostrare di non esserci caduto: parità per conteggio delle operazioni
Ecco la domanda scomoda che la sezione precedente dovrebbe sollevare: se una WMA sbagliata di 211× sembra comunque una WMA, come fai a sapere che la versione a 8×10⁻⁷ è effettivamente corretta e non solo sbagliata in modo più sottile? Non puoi giudicarla a occhio. Ti serve un invariante che una parte discreta a valle della pipeline esponga — e un backtest te ne consegna uno perfetto: le operazioni.
Gli altri metodi GPU nella scala (M0–M4) girano interamente in fp64, quindi li teniamo a un assert di equivalenza rigido — conteggio di operazioni identico, PnL coincidente fino a atol=1e-6. Il metodo GPU in fp32 (M5) non può passarlo per costruzione, e allentare in sordina l'assert per tutti pur di accomodarlo sarebbe esattamente il tipo di disonestà che questa serie esiste per combattere. Quindi M5 riceve il suo report di parità quantitativa dedicato, report_equiv_fp32, che confronta le sue operazioni estratte con il riferimento fp64.
Il meccanismo di qualsiasi disaccordo residuo merita di essere nominato con precisione, perché non è la catastrofe della cancellazione — è l'ordinario, minuscolo arrotondamento fp32 che ti aspetteresti. La strategia scatta all'incrocio di due medie Hull, h vs h3. Un errore relativo di ~1×10⁻⁶ su un indicatore a prezzo ~30.000 è un'oscillazione assoluta di ~0,03. Sulla stragrande maggioranza delle barre le due curve sono più distanti di così e l'incrocio è inequivocabile. Ma su una barra al limite — dove h − h3 è esso stesso entro 0,03 dallo zero — quell'oscillazione può ribaltare il segno del confronto, spostando un incrocio di una singola barra, aggiungendo o rimuovendo un'operazione.
Ecco perché "frazione di combo che differiscono" è una metrica di salute senza valore, e il nostro primo controllo di parità si è messo in imbarazzo usandola. A 150k barre ogni combo ha migliaia di incroci, quindi almeno una barra al limite compare su quasi ogni combo — 37 su 80 combo "differivano", il che suona allarmante e non significa nulla. La metrica che conta è di quanto:
- Delta di PnL su tutte le 80 combo: max |Δ| = 1,843 punti percentuali, max relativo = 1,25×10⁻²; soglia di crash 5 p.p.
- Deriva del conteggio operazioni per combo: max |Δn| = 4 operazioni su migliaia, max relativo = 2,5×10⁻³; soglia di crash 1%.
- In aggregato: 90 operazioni spostate su 479.016 — 0,019%.
Novanta operazioni su quasi mezzo milione, ognuna di esse un incrocio al limite spinto da un'oscillazione di arrotondamento più piccola di un tick di prezzo, nessuna di esse neanche lontanamente vicina alle soglie di crash. Questa è la firma di un metodo fp32 che è corretto — disaccordo piccolo, limitato, spiegabile — ed è un animale completamente diverso da un errore relativo di 211. Le soglie esistono per catturare una formulazione rotta che si maschera da "beh, è solo fp32"; i delta reali arrivano un ordine di grandezza al di sotto di esse. Il conteggio delle operazioni è l'oracolo che la curva di equity si è rifiutata di essere.
Il payoff, e dove la GPU smette di aiutare
Con la correttezza stabilita, la velocità merita di essere dichiarata — e poi onestamente qualificata, perché il vantaggio della GPU non è uniforme lungo la pipeline.
Sulle pure convoluzioni WMA in isolamento — l'operazione che l'intero metodo esiste per accelerare — il batch conv1d in fp32 gira 55,9× più veloce del numba a thread singolo, a quell'errore relativo di 8,2×10⁻⁷. Questo è il numero pulito, mele-con-mele, GPU-vs-CPU: stessa matematica, un thread di codice CPU compilato contro la GPU Metal.
Ma una spazzata non è solo convoluzioni. Una volta che le matrici HMA/HMA3 sono calcolate sulla GPU, le operazioni devono ancora essere estratte — una passata O(n) sugli incroci di ogni combo — e questo lo facciamo sulla CPU in fp64, riutilizzando la semantica esatta delle operazioni degli altri metodi anziché reimplementarla sulla GPU. La cifra timed() end-to-end include tutto: warm-up del kernel escluso (simmetrico con l'esclusione della compilazione di numba), ma trasferimento GPU→CPU ed estrazione delle operazioni su CPU inclusi. Su 150k barre × 80 combo, il migliore di 3, M2 Max:
| Metodo | Muro | Speedup vs baseline | combo/s |
|---|---|---|---|
| M0 pandas + Python loop* | 287,08s | 1,0× | 0,3 |
| M1 vectorized numpy | 3,14s | 91,5× | 25,5 |
| M2 numba (serial) | 2,02s | 142,3× | 39,7 |
| M3 multiprocess + vectorized | 0,50s | 570,2× | 158,9 |
| M4 multiprocess + numba (12 cores) | 0,33s | 882,5× | 245,9 |
| M5 MLX GPU (fp32) | 0,10s | 2796,0× | 779,2 |
*M0 estrapolato da un campione uniforme di 5 combo.
L'M5 a motore completo fa 779 combo/s — 2.796× rispetto al baseline pandas, 19,6× rispetto al numba seriale, e 3,2× rispetto all'intero pool CPU a 12 core che gira numba (M4). Una sola GPU batte ogni core CPU che la macchina possiede, di un fattore tre.
Ora la qualifica onesta: nota che il vantaggio GPU end-to-end (19,6× su M2) è più piccolo del vantaggio sola-convoluzione (55,9× su numba). Quel divario è la legge di Amdahl che arriva puntuale. La GPU annienta le convoluzioni così completamente che smettono di essere il collo di bottiglia; ciò che rimane — l'estrazione delle operazioni su CPU O(n) che la GPU non ha accelerato per nulla — ora domina il tempo di muro di M5. Questa è la stessa lezione su cui i pezzi scala di velocità e tassa IPC in questa serie continuano ad atterrare: oltre un certo punto, la vittoria non è "rendi più veloce la parte veloce", è orchestrazione — dove vivono i dati, quale stadio è ora seriale, cosa stai pagando per spostare tra dispositivo e host. Inseguire un ipotetico M6 che spinge l'estrazione delle operazioni in un kernel Metal personalizzato recupererebbe solo la fetta di CPU che si va restringendo, motivo per cui non l'abbiamo costruito.
La lezione generale: la spazzatura numerica silenziosa è il default
Fai un passo indietro dalle medie Hull e da MLX, perché la trappola si generalizza ben oltre questo singolo indicatore.
Il richiamo seducente del backtesting su GPU è "una grande matrice": impila ogni combinazione di parametri in un tensore, esegui l'intera spazzata come una manciata di operazioni dense sugli array, lascia che l'hardware la mangi. Quel richiamo è reale — gli speedup sopra sono reali. Ma cambia in sordina il regime numerico sotto di te, e il cambiamento è invisibile nel codice. Sulla CPU i tuoi default ti proteggevano: fp64, propagazione dei NaN, una cumsum che poteva arrivare a 10¹⁴ senza accorgersene. Sposta la stessa espressione di array su Metal e sei in fp32 con un tetto rigido di 1,6×10⁷ per gli interi, e la identica riga di codice — cumsum(j * price) — passa da esatta a spazzatura. Nulla nella sintassi ti avvisa. Il compilatore è contento. L'output è finito e plausibile. L'fp32 non fallisce rumorosamente; fallisce educatamente, con numeri.
Le tre abitudini che ti proteggono davvero costano poco:
- Sappi dove vivono i tuoi intermedi, non solo i tuoi input e output. Gli input (prezzi ~10⁴) e gli output (una WMA ~10⁴) erano entrambi comodamente dentro l'intervallo esatto dell'fp32. Il disastro era interamente in un intermedio nascosto (
S2~10¹⁴) che né l'API né i tipi rendevano visibile. Prima di fidarti di qualsiasi riduzione fp32, chiediti cosa raggiunge la più grande somma parziale — e se supera ~10⁷, cambia la formulazione. - Preferisci formulazioni che mantengono le grandezze limitate. Convoluzione diretta invece di somme prefisse; finestre locali invece di scan globali; centratura/differenza prima di sommare anziché dopo. Grande-poi-cancella è l'anti-pattern. L'algoritmo corretto è spesso quello che sulla carta sembra asintoticamente peggiore ma non fabbrica mai una quantità che deve poi cancellare via.
- Convalida contro un oracolo fp64 attraverso un invariante discreto. Non confrontare le curve; confronta qualcosa di quantizzato e a valle — conteggio delle operazioni, numero di incroci, eventi di cambio posizione. Un invariante discreto trasforma un silenzioso errore di 211× in un urlante fallimento di assert, e trasforma un accettabile errore di 8×10⁻⁷ in un delta piccolo, limitato, spiegabile. Questa è la stessa disciplina del test di scorrimento di una barra per il look-ahead bias: una diagnostica economica che converte un fallimento invisibile in uno visibile.
Nulla di tutto ciò è analisi numerica esotica. È l'ordinaria igiene del non fidarsi di un backtest veloce finché uno lento e fidato non l'ha ratificato — estesa all'unico punto in cui il linguaggio smette di avvisarti che la precisione è silenziosamente scesa di dodici ordini di grandezza.
Punti chiave

- La GPU di Apple non ha float64 — ogni numero GPU nel tuo backtest è fp32. Gli interi sono esatti solo fino a ~1,6×10⁷ e la precisione è ~1,2×10⁻⁷. Non c'è flag, non c'è fallback. La maggior parte di un backtest sopravvive a questo; esattamente un'operazione di solito no.
- La WMA a somma prefissa è la trappola.
cumsum(j · price)sale fino a ~10¹⁴, sette ordini oltre il tetto esatto dell'fp32, e recuperare una finestra ti costringe a sottrarre due di questi numeri il cui errore di arrotondamento (±8×10⁶) già eclissa la risposta. Errore relativo massimo misurato: 211×. Non va mai in crash — restituisce spazzatura plausibile. - La soluzione è una somma diversa, non più bit. Una convoluzione a finestra diretta (
mx.conv1d) mantiene ogni somma parziale vicina alla scala dei prezzi, quindi l'fp32 tiene sette cifre oneste: errore relativo 8,2×10⁻⁷, e 55,9× più veloce del numba a thread singolo. Non puoi comprare l'fp64 su Metal, e non ne hai bisogno. - Verifica con un invariante discreto, mai con la curva. La parità del conteggio operazioni l'ha catturato: il conv1d in fp32 ha discordato con l'fp64 su 90 operazioni su 479.016 (0,019%), tutte incroci al limite, tutte molto sotto le soglie di crash — la firma di un metodo corretto, inconfondibilmente diversa da un errore di 211×. "Frazione di combo che differiscono" è una metrica-esca; misura di quanto.
- La spazzata completa fa 779 combo/s — 2.796× rispetto al baseline pandas, 3,2× rispetto all'intero pool CPU a 12 core — ma la vittoria end-to-end (19,6× sul numba seriale) è più piccola della vittoria sola-convoluzione (55,9×) perché l'estrazione delle operazioni su CPU è ora il collo di bottiglia. Oltre un certo punto, la velocità è orchestrazione, non aritmetica.
Il port su GPU era 2.796× più veloce e, nella sua prima versione funzionante, completamente sbagliato — e i due fatti non avevano nulla a che fare l'uno con l'altro. La velocità era reale. La spazzatura era un intermedio nascosto in scala 10¹⁴ che l'fp32 non poteva contenere e che nessun messaggio di errore avrebbe menzionato. Se un backtest diventa drasticamente più veloce e i numeri sembrano ancora a posto, quella non è una conferma. Su Metal, "sembra a posto" è esattamente l'aspetto di un errore relativo di 211.
Questa è la rung GPU della scala che questa serie ha scalato: la scala di velocità del motore di backtest, la tassa IPC del multiprocessing, la tassonomia del look-ahead delle fughe, e il design della funzione obiettivo che decide cosa significhi persino "buono". La velocità è inutile se è veloce nel calcolare il numero sbagliato.
Autori
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.