Quando la GPU Ripaga: Il Roofline dello Sweep di Parametri, Dove un 167x da Titolo è in Realtà 27x di Algoritmo per 6.2x di Hardware
Fa parte della serie "Backtest Senza Illusioni".
La scala di velocità si era chiusa su una nota deliberatamente insoddisfacente. Avevamo portato uno sweep di parametri a 80 combo da 69.9 secondi di pandas a 0.23 secondi di numba parallelo su una CPU da laptop — un 298x misurato — e poi avevamo sostenuto che una GPU non era il gradino mancante. La sezione commenti non l'ha accettato in silenzio, e ha fatto bene a non farlo. "Non è il gradino mancante" è un'affermazione su un carico di lavoro, a una dimensione. Non è una legge di natura. Così questo articolo fa la cosa onesta e mette la GPU al banco di prova.
Il risultato non è un verdetto, è una curva. Il vantaggio della GPU sulla CPU non è un singolo numero da stampare su una slide; è una funzione di quanto lavoro le consegni per chiamata. Sul nostro precompute di indicatori multi-timeframe lo speedup della GPU rispetto al motore CPU va da 54.5x quando le chiediamo una combinazione di parametri alla volta fino a 359.6x quando gliene chiediamo 61 in una volta. Stesso kernel, stessi dati, stesso hardware — l'unica cosa cambiata è il batch. Un benchmark che riporta uno di quei numeri e nasconde l'altro non sta misurando la GPU, sta scegliendo un titolo.
E anche 359.6x non è quello che sembra. Sbuccialo, e un grande titolo multi-timeframe di 167x si scompone in 27x di algoritmo — una riscrittura che rende anche la CPU 27x più veloce — per 6.2x di hardware vero. Il 27x non l'ha fatto la GPU. L'ha fatto la matematica. Questo articolo parla di come distinguere i due, perché confonderli è il modo in cui una scheda grafica da $2,000 viene venduta per risolvere un problema che un git commit avrebbe risolto gratis.
Provenienza: tutti i numeri qui sotto sono misurati su un Apple M2 Max, fp32 sulla GPU Metal via MLX contro numba fp64 su dodici thread CPU, dai nostri script di benchmark engine_multitf_gpu.py e bench_param_sweep.py, ciascuno protetto da un controllo di equivalenza che conferma che i percorsi GPU e CPU producono gli stessi trade. Nessun paper companion pubblico questa volta — fanno fede gli script.
La domanda è un roofline, non un benchmark

La ragione per cui "quanto è più veloce la GPU" non ha una risposta unica è il roofline model (Williams, Waterman & Patterson, 2009). Le prestazioni raggiungibili da un processore sono limitate da due tetti allo stesso tempo: uno piatto fissato dal calcolo di picco (FLOP/s), e uno inclinato fissato dalla banda di memoria per l'intensità aritmetica — il numero di operazioni utili che fai per ogni byte spostato. Il lavoro povero-per-byte vive sotto il tetto inclinato ed è bandwidth-bound; il lavoro ricco-per-byte raggiunge il tetto piatto ed è compute-bound. Il tetto piatto della GPU svetta su quello della CPU, ma il suo tetto inclinato e, soprattutto, il suo costo fisso per chiamata non scalano verso il basso con la stessa grazia.
Per uno sweep di parametri, un terzo termine domina il lato sinistro del grafico: l'overhead di lancio e trasferimento. Ogni chiamata GPU paga un prezzo grossomodo fisso — dispatch del kernel, spostamento degli input attraverso il confine di memoria (unificata, su Apple Silicon, ma non gratuita), spostamento dei risultati indietro. Modella il tempo di esecuzione per un batch di combinazioni come
dove è quell'overhead fisso, è il costo marginale per combo della GPU, e quello della CPU. Lo speedup è quindi
Questa singola frazione spiega l'intero articolo. A piccolo la al denominatore schiaccia il rapporto — hai pagato per svegliare la GPU e l'hai usata appena. Al crescere di ammortizzi su più combo e lo speedup sale verso il suo asintoto , il vero rapporto hardware. Il punto di metà strada sta a : un "ridge point" nello spazio dei batch che ti dice quanto largo deve essere il tuo sweep prima che la GPU sia anche solo la metà di quello che può essere. Uno sweep di qualche dozzina di combo sta molto a sinistra di quel ridge. Uno sweep di migliaia sta sulla parte piatta, dove la GPU finalmente si guadagna il suo tetto.
Quindi la domanda giusta non è mai "la GPU è più veloce". È "il mio sweep è a destra del ridge, e il mio lavoro per-combo è abbastanza compute-bound da raggiungere il tetto piatto quando ci arriva". Devono essere vere entrambe. Il resto di questo articolo misura dove cadono davvero quelle soglie.
Il verdetto single-timeframe: la GPU vince di misura

Parti dal carico di lavoro che la scala di velocità aveva usato: uno sweep HMA/HMA3 single-timeframe, 80 combinazioni su 150,000 barre. A quella scala abbiamo aggiunto un sesto gradino — M5, indicatori sulla GPU Apple via MLX, con i trade ancora estratti sulla CPU. Warm-up fatto, best-of-3, gate di equivalenza verde:
| Metodo | Tempo di esecuzione | vs pandas | Combo/s |
|---|---|---|---|
| M0 pandas + loop | 287.08s | 1.0x | 0.3 |
| M2 numba (1 core) | 2.02s | 142x | 39.7 |
| M4 mp + numba (12 core) | 0.33s | 883x | 245.9 |
| M5 MLX GPU (fp32) | 0.10s | 2796x | 779.2 |
Contro la baseline seriale naive la GPU sembra eroica — 2,796x. Ma è un confronto che nessuna persona onesta dovrebbe fare: mette una buona implementazione GPU contro la peggiore implementazione CPU. Allinea la GPU con la CPU che manderesti davvero in produzione — lo stesso kernel su tutti e dodici i core, M4 — e la vittoria collassa a un sobrio 3.2x (779 contro 246 combo al secondo). Un'intera scheda grafica, che esegue tutto lo sweep, batte un pool CPU a dodici core di un fattore tre.
Tre virgola due non è poca cosa. Ma non è nemmeno la ragione per cui qualcuno compra una GPU. Ed è esattamente ciò che il roofline predice per uno sweep così stretto: 80 combo sono a sinistra del ridge. L'overhead fisso di lancio e trasferimento è ancora una fetta significativa di un job da 0.10 secondi, quindi non raggiungiamo mai l'asintoto . Peggio, parte del costo per-combo è la passata O(n) di estrazione dei trade, che abbiamo deliberatamente lasciato sulla CPU — un termine che la GPU non può accelerare affatto (il perché nella prossima sezione). Per un loop di ricerca single-timeframe di questa dimensione, il verdetto originale della scala di velocità regge: la GPU non è il gradino mancante. Il numba parallelo ti aveva già portato a 0.23–0.33 secondi, e limare quel tempo a 0.10 non è ciò che sblocca un ricercatore. Lo è l'orchestrazione attorno allo sweep.
Le parole interessanti in quel verdetto sono di questa dimensione. Spostati a destra lungo l'asse dei batch e la storia cambia.
Dove vive davvero il costo
Prima di far crescere il batch, guarda cosa stiamo effettivamente pagando, perché il roofline ti premia solo se la parte costosa è compute-bound. Profila lo sweep e quasi tutto è una cosa sola: convoluzioni di medie mobili ponderate. Una HMA è tre WMA; una HMA3 è quattro; ogni combinazione le riesegue sull'intera serie. L'estrazione dei trade — percorrere i due array di indicatori, trovare i cambi di segno di hma - hma3, registrare i fill — è una singola passata O(n) economica. Lo sweep è un carico di lavoro di convoluzioni travestito da strategia di trading.
Quella suddivisione corrisponde esattamente ai due regimi del roofline:
- Le convoluzioni sono compute-bound. Ogni prezzo viene letto in molte somme di finestra sovrapposte, quindi l'intensità aritmetica — operazioni per byte spostato — è alta. Questo lavoro punta al tetto piatto del calcolo, e il tetto piatto della GPU è quello che svetta. Meglio ancora, le finestre si sovrappongono tra le combinazioni: una WMA di lunghezza 40 serve a dozzine di combo, quindi un precompute in batch la condivide una volta invece di ricalcolarla per combo. Il batching non si limita ad ammortizzare l'overhead di lancio; alza l'intensità aritmetica riusando le letture. Questa è la parte che appartiene alla GPU.
- L'estrazione dei trade è bandwidth-bound e piena di branch. Una passata sequenziale, branch dipendenti dai dati a ogni incrocio, essenzialmente nessun riuso. La sua intensità aritmetica è vicina al pavimento, e il suo flusso di controllo è ostile a un dispositivo SIMD. Spingerla sulla GPU comprerebbe poco e costerebbe molto; resta sulla CPU. Il che significa che è la coda seriale di una suddivisione alla legge di Amdahl — un pavimento fisso che lo speedup della GPU non può mai perforare, e parte del motivo per cui il numero single-TF è saturato a 3.2x.
C'è una seconda lezione, più tagliente, nascosta nella versione multi-timeframe di questo kernel, ed è la fonte del 27x che continuiamo a promettere di spiegare. Il motore multi-TF allinea una HMA di timeframe superiore sull'indice base a 1 minuto senza look-ahead. Scritto nel modo ovvio, è lavoro O(length^1.5) per barra — ricalcolare le medie mobili del timeframe superiore a ogni barra base. Ma la HMA allineata è lineare in un breve buffer delle ultime candele chiuse del timeframe superiore più il close corrente, quindi l'intero calcolo per-barra collassa in un vettore di pesi fisso: una conv1d sulla serie delle candele chiuse seguita da un gather O(n). Centinaia di milioni di operazioni ridondanti diventano una convoluzione su una serie molto più corta.
Quel collasso è una vittoria algoritmica, non hardware. È una formula migliore. Gira sulla GPU, e gira altrettanto bene sulla CPU — np.correlate più un gather, in fp64. Tienilo bene a mente: il singolo fattore più grande nel titolo multi-TF è una riscrittura disponibile a una macchina senza alcuna GPU. Quando finalmente scomporremo il 167x, questo è il 27x.
Il vantaggio cresce con la dimensione del batch

Ora la misurazione che il roofline ci ha detto di fare. Prendi l'asse costoso — il precompute della HMA di timeframe superiore allineata sulla serie base a 1 minuto, il flusso di candele più lungo che abbiamo — e dai in pasto alla GPU un numero crescente di combinazioni di lunghezze per chiamata, . La baseline CPU qui è l'onesto motore di produzione: numba con prange su tutti e dodici i core. Per ogni batch misuriamo entrambi e prendiamo il rapporto.
| Batch (combo/chiamata) | Speedup GPU vs motore CPU a 12 core |
|---|---|
| 1 | 54.5x |
| 2 | 102.5x |
| 4 | 129.5x |
| 8 | 187.4x |
| 16 | 267.4x |
| 32 | 245.0x |
| 61 | 359.6x |
Questo è disegnato in misurazioni reali. A la GPU è già 54.5x avanti — perché questo confronto è contro il motore naive per-barra, quindi il collasso algoritmico è incorporato anche a un solo combo — ma è lontanissima dal suo tetto: l'overhead fisso domina ancora una chiamata a un combo. Raddoppia il batch e lo speedup quasi raddoppia a 102.5x; a è 267.4x; a è 359.6x e visibilmente sta ancora salendo. Il vantaggio cresce con la dimensione del problema. È la singola frase più importante su GPU e sweep di parametri, ed è l'esatto opposto di come gli speedup GPU vengono di solito citati — come se fossero una proprietà costante del chip.
Due note di onestà, perché questa è la serie Backtest Senza Illusioni e una tabella monotona e pulita merita sempre sospetto.
Primo, l'avvallamento: segna 245.0x, sotto i 267.4x di . Non è rumore da nascondere sotto il tappeto — è un artefatto di confine di chunk. La nostra conv1d impacchetta 32 lunghezze in un chunk di kernel, quindi riempie esattamente un chunk senza margine, mentre trabocca in un secondo chunk che si dà il caso saturi meglio il device. Il punto del roofline è il trend, e un device reale ha dentro gradini di quantizzazione; riportiamo l'oscillazione invece di fare cherry-picking per aggirarla.
Secondo, e più importante: 54.5x e 359.6x sono entrambi contro il motore CPU naive, e nessuno dei due è la vittoria hardware. Entrambi i numeri contengono ancora il collasso algoritmico di 27x. Se sostituissi la baseline CPU con l'algoritmo collassato eseguito sulla CPU — stessa formula, fp64, entrambi i lati ottimizzati — ogni riga si ridurrebbe grossomodo di quel fattore. Che è precisamente la scomposizione che la prossima sezione rende esatta.
La scomposizione onesta: 27x di algoritmo per 6.2x di hardware

Per separare l'algoritmo dal silicio devi misurare tre percorsi sulla stessa griglia multi-timeframe, non due. Quindi il benchmark esegue:
- cpu-engine — il motore numba di produzione, HMA allineata per-barra su tutti i core. La baseline naive-ma-parallela.
- cpu-collapsed — i vettori di pesi collassati,
np.correlatepiù gather, fp64, sulla CPU. Stesso hardware di (1), algoritmo migliore. - gpu-mlx — i pesi collassati come
conv1din batch sulla GPU Metal, fp32. Stesso algoritmo di (2), hardware diverso.
Mettili in fila e il titolo multi-timeframe completo si fattorizza in modo pulito:
Il fattore di sinistra, 27x, è l'algoritmo — il collasso da per-barra a convoluzione della sezione precedente. Non ha niente a che fare con la GPU. Implementalo in numpy e la CPU del tuo laptop diventa 27x più veloce su questo carico di lavoro, al prezzo di un refactor. Il fattore di destra, 6.2x, è l'hardware — la vittoria onesta, a parità di condizioni, della GPU Metal sullo stesso algoritmo ottimizzato su dodici core CPU. Quel 6.2x è l'unica parte per cui ti serviva davvero una GPU.
Questa è l'intera morale dell'articolo enunciata come aritmetica. Quando un benchmark di un vendor, il README di una libreria o un collega entusiasta ti mostra "167x su GPU", il riflesso dovrebbe essere una sola domanda: qual era la baseline CPU? Se la baseline era l'implementazione naive — e lo è quasi sempre, perché una baseline lenta fa una slide migliore — allora la maggior parte del titolo è una vittoria algoritmica a cui anche la CPU aveva diritto, e solo il residuo è hardware. Qui il residuo è 6.2x. Un 167x citato sovrastima il contributo dell'hardware di circa 27 volte.
E nota come il fattore hardware stesso si sia mosso con la dimensione del problema. Sul piccolo sweep single-timeframe la vera vittoria GPU-contro-migliore-CPU era 3.2x. Sul precompute multi-timeframe più grande era 6.2x — gli stessi due chip, quasi il doppio del vantaggio hardware, puramente perché il carico di lavoro più grande spinge più in alto lungo il roofline, verso il tetto piatto di calcolo della GPU, prima che la CPU riesca a tenere il passo. Nemmeno il vantaggio hardware è una costante. È un punto sulla stessa curva crescente, e il modo per spostarti a destra su quella curva è rendere il batch più grande e il lavoro per-combo più ricco.
Una guida decisionale: quanto largo deve essere lo sweep?

Ripiega il roofline in una decisione che puoi prendere prima di spendere soldi. La GPU ripaga quando entrambe le condizioni del roofline valgono allo stesso tempo: il tuo sweep è a destra del ridge dei batch (, così l'overhead fisso di lancio e trasferimento è ammortizzato), e il tuo lavoro per-combo è compute-bound (intensità aritmetica abbastanza ricca da raggiungere il tetto piatto, non una passata O(n) sottile). In concreto, da quello che abbiamo misurato:
- Qualche dozzina di combo di una strategia single-timeframe: salta la GPU. Sei a sinistra del ridge; la vittoria onesta sul numba parallelo è ~3.2x su un job che già dura un decimo di secondo. Il collo di bottiglia non è il kernel, è tutto ciò che gli sta intorno.
- Migliaia di combo, o un precompute genuinamente multi-timeframe / multi-indicatore: la GPU si guadagna il suo posto. L'overhead si ammortizza, le convoluzioni condivise alzano l'intensità aritmetica, e la vittoria hardware sale a 6.2x e continua a crescere con il batch. Questo è il regime in cui una GPU trasforma uno sweep notturno in una pausa caffè.
- Sali prima la scala CPU — costa meno e viene prima. Il 298x sulla CPU e il collasso algoritmico di 27x sono gratis o quasi, e sono prerequisiti, non alternative: il 6.2x della GPU è sopra l'algoritmo collassato, che dovevi comunque scrivere. Una GPU imbullonata su una pipeline naive misura per lo più l'ingenuità.
C'è anche una tassa sul ramo GPU che non ha niente a che fare con la velocità, e devi metterla nel conto: la GPU Metal di Apple non ha fp64, per niente. Tutto gira in fp32, precisione relativa di ~1.2e-7. Questo uccide il trucco da manuale per le medie mobili veloci — la WMA O(n) a prefix-sum — perché a una scala di prezzo vicina a 30,000 su 150k barre le somme correnti raggiungono ~1e14, sette ordini di grandezza oltre l'intervallo intero sicuro di fp32; abbiamo misurato errori relativi fino a ~2e2 (un fattore duecento, non il due percento). La formulazione che funziona è la convoluzione diretta a finestra, dove ogni somma di finestra è un numero limitato di termini di grandezza comparabile e fp32 resta accurato fino a ~8e-7. Anche così, una strategia che decide sul segno di hma - hma3 ogni tanto ribalterà un incrocio su una barra borderline dove le due curve quasi si toccano, perché l'arrotondamento fp32 fa pendere un quasi-pareggio. Ecco perché il percorso GPU viene consegnato con un gate di equivalenza che misura di quanto i trade divergono — delta di PnL in basis point, spostamento relativo nel numero di trade — invece di asserire un output bit-identico che non potrà mai consegnare. Sulla nostra run quella divergenza è stata di 90 fill spostati su 479,016 (0.019%), ben dentro la tolleranza, ma l'onere è reale: andare sulla GPU significa farsi carico di una storia di parità numerica, non solo di un clock più veloce. Anche quel costo ingegneristico fa parte del break-even.
I numeri hanno la forma di Apple; la curva no
Ogni cifra qui sopra è un Apple M2 Max: un dispositivo a memoria unificata dove GPU e CPU condividono un unico pool, e una GPU solo-fp32 senza doppia precisione. Una scheda discreta NVIDIA o AMD cambia le costanti, e vale la pena essere espliciti su in che direzione si muove ciascuna, perché la forma dell'argomento sopravvive anche quando i numeri no.
- L'overhead di trasferimento peggiora, non migliora. Una scheda discreta sta dietro PCIe, quindi input e risultati fanno una copia reale attraverso il bus che la memoria unificata evita. Questo spinge il ridge dei batch a destra — ti serve uno sweep ancora più largo prima che una GPU discreta ammortizzi il suo lancio. Il bordo sinistro del roofline è più ripido su un dispositivo PCIe, non più dolce.
- Il tetto piatto diventa più alto. Una GPU da data center ha molti più FLOP/s e banda di una integrata, quindi la vittoria hardware asintotica su uno sweep che satura è più grande del nostro 6.2x. Il premio per raggiungere il lato destro della curva cresce; cresce anche il pedaggio per restare seduti a sinistra.
- fp64 torna, e con lui torna il trucco del prefix-sum. Su una scheda con vera doppia precisione la WMA O(n) a prefix-sum è di nuovo praticabile e il gate di parità può essere serrato verso l'esattezza bit per bit. La specifica tassa fp32 che abbiamo pagato — convoluzione diretta invece di prefix sum, un gate che misura la divergenza invece di un assert — è un dettaglio di Apple Silicon, non una legge.
Niente di tutto questo cambia la tesi. Su qualsiasi dispositivo, : un overhead fisso da ammortizzare, un asintoto a cui ti avvicini solo da destra. Le costanti sono hardware; la curva è aritmetica. Misura i tuoi , e con uno sweep di batch da cinque righe prima di fidarti del titolo di chiunque — incluso il nostro.
Dove si collega tutto questo
Questa è la quarta misurazione di una piccola sotto-serie su da dove venga davvero la velocità dei backtest, e i pezzi si incastrano in un unico argomento su cosa ottimizzare prima di spendere:
- La scala di velocità è salita da pandas a numba parallelo per 298x sulla sola CPU, e ha lasciato la GPU come domanda aperta. Questo articolo risponde: la GPU è un quinto gradino reale ma condizionale, che vale 3.2x–6.2x sopra il gradino CPU più alto, e solo quando lo sweep è abbastanza largo da scalare il roofline.
- La tassa IPC ha fatto la stessa mossa nella direzione opposta — misurando cosa costa uscire dal processo — ed è arrivata alla stessa forma di conclusione: il confine (un socket, un lancio GPU) è economico; la tassa sta nel quanto spesso e quanto chatty lo attraversi. Metti in batch le chiamate GPU per la stessa ragione per cui metti in batch l'IPC: per ammortizzare un costo fisso per attraversamento.
- La cache parquet aggregata è la versione lato CPU di ciò che fa il precompute GPU — calcolare una volta gli indicatori condivisi, riusarli per ogni combo. La GPU porta semplicemente quel principio di riuso e batching fino al silicio.
- E il gate di parità fp32 è un problema di parità backtest-live in miniatura: nel momento in cui il tuo percorso veloce calcola qualcosa di leggermente diverso dal tuo riferimento, devi un resoconto quantificato della divergenza, non un gesto vago della mano.
La disciplina che collega tutto è la stessa che questa intera serie spinge: misura la cosa che ti stanno effettivamente vendendo. Uno speedup è un rapporto, e un rapporto ha un numeratore e un denominatore. La maggior parte delle delusioni da GPU viene da un denominatore — la baseline CPU — scelto per lusingare, e la maggior parte dello spreco da GPU viene dall'eseguire uno sweep troppo piccolo per lasciare il bordo sinistro del roofline.
Takeaway
- Uno speedup GPU è una curva, non un numero. Sul nostro precompute multi-timeframe il vantaggio sulla CPU è andato da 54.5x a un combo per chiamata a 359.6x a sessantuno — stesso chip, stessi dati. Qualsiasi cifra singola è un punto su quella curva; chiedi a quale dimensione di batch è stata misurata.
- Interroga sempre la baseline CPU. Il titolo multi-timeframe di 167x si fattorizza in modo pulito in 27x di algoritmo (un collasso da per-barra a convoluzione, che accelera altrettanto la CPU) per 6.2x di hardware vero. La vittoria equa GPU-contro-migliore-CPU qui è 3.2x single-timeframe, 6.2x multi — non 167x.
- Il vantaggio cresce con la dimensione del problema, e con lui il fattore hardware. Batch più grande e lavoro per-combo più ricco ti spingono su per il roofline: la stessa vittoria hardware onesta è salita da 3.2x a 6.2x puramente rendendo più grande il carico di lavoro. Gli sweep piccoli stanno a sinistra del ridge e ne beneficiano appena.
- Prima sistema l'algoritmo e sali la scala CPU — la vittoria GPU si appoggia sopra di loro, non li sostituisce. Il 6.2x è misurato contro l'algoritmo collassato che dovevi comunque scrivere. Imbullona una GPU su una pipeline naive e la maggior parte di ciò che misuri è l'ingenuità, non il silicio.
- Andare sulla GPU significa farsi carico di una storia di parità numerica. Niente fp64 su Metal, il trucco della WMA a prefix-sum muore alla scala dei prezzi (errore relativo ~2e2), e le strategie a segno-di-incrocio si ribaltano sulle barre borderline. Consegna un gate di equivalenza che quantifichi la divergenza in basis point; conta quel costo ingegneristico nel tuo break-even.
Quando qualcuno ti dice che la GPU ha reso il suo backtest cento volte più veloce, non ti ha detto quasi niente. Chiedigli la dimensione del batch e la baseline CPU, e il cento di solito si risolve in una vittoria hardware a una cifra avvolta attorno a una algoritmica che avrebbe potuto avere gratis — che vale la pena avere, su uno sweep abbastanza grande, esattamente per le ragioni che dice il roofline e per nemmeno una di più.
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.