← Torna agli articoli
July 1, 2026
5 min di lettura

La Scala di Velocità del Backtest: 298x su una CPU da Laptop, PnL Identico Fino all'Ultimo Trade

La Scala di Velocità del Backtest: 298x su una CPU da Laptop, PnL Identico Fino all'Ultimo Trade
#algotrading
#backtest
#performance
#numba
#vettorizzazione
#ottimizzazione

Fa parte della serie "Backtest Senza Illusioni".

📄 Questo articolo è cresciuto fino a diventare un paper di ricerca. Un kernel di backtest path-dependent viene implementato in cinque modi — da pandas naive fino a un kernel numba parallelo — con ogni gradino verificato incrociato per produrre lo stesso PnL per combo, così l'unica cosa che cambia è la velocità. Leggi il paper online (versione interattiva + PDF) su speed-ladder.marketmaker.cc, codice e dati su github.com/suenot/backtest-speed-ladder.

Settanta secondi. È il tempo che impiega l'implementazione di riferimento naive per fare lo sweep di 80 combinazioni di parametri di una strategia a media mobile su 150,000 barre: pandas rolling().apply() per gli indicatori, un semplice loop Python per i trade. È il profilo su cui gira una fetta enorme del codice di ricerca reale, perché è il profilo che ne viene fuori quando si scrive la strategia nel modo ovvio.

Lo stesso sweep, sullo stesso laptop, che produce lo stesso PnL per ogni combinazione fino all'ultimo trade: 0.23 secondi.

Il divario tra questi due numeri — un 298x misurato — è l'oggetto di questo articolo. Non un solo punto percentuale viene da hardware nuovo. Nessuna GPU è stata coinvolta (su questa macchina non ne è nemmeno disponibile una in senso CUDA). Ogni gradino della scala è la stessa strategia, gli stessi dati, le stesse fee, lo stesso numero di trade, verificato da un gate di equivalenza che fa fallire l'intero benchmark se i risultati per combo di una qualsiasi implementazione divergono. Ciò che cambia è solo come il lavoro viene espresso: cosa gira nell'interprete, cosa gira compilato, e cosa gira in parallelo. E poiché una baseline deliberatamente lenta può lusingare qualsiasi numero da titolo, un dato in più in apertura: anche contro un'implementazione numpy vettorizzata competente — il codice che un buon programmatore numpy scriverebbe — il motore finito è comunque circa 13x più veloce.

Quando una ricerca di parametri è lenta, il riflesso è puntare su hardware più grande — una GPU, un cluster, un budget cloud. La realtà misurata di questo esperimento indica qualcosa di molto meno affascinante: il collo di bottiglia era il motore (un loop interno interpretato che fa chiamate Python per ogni finestra) e l'orchestrazione (l'esecuzione seriale di combo indipendenti su un solo core). Entrambi sono risolvibili in un pomeriggio, sulla macchina che già possiedi, senza alcun cambiamento nei risultati.

Ecco l'intera scala in apertura. Tutto quello che segue è l'anatomia di ogni gradino.

Gradino Implementazione Tempo di esecuzione Speedup Combo/s
M0 pandas: rolling.apply + loop Python sulle barre 69.92 s 1.0x 1.1
M1 numpy: WMA a finestra scorrevole + trade vettorizzati 3.07 s 22.7x 26.0
M2 numba: WMA @njit + event loop @njit 1.98 s 35.3x 40.4
M3 numba prange: thread tra i combo 0.32 s 217.6x 248.9
M4 process pool + numba: processi tra i combo 0.23 s 297.9x 340.9

Apple M2 Max (12 core), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) fissato a un thread così che i gradini single-thread siano genuinamente single-core. 150,000 barre × 80 combo, tempo di esecuzione best-of-3, warm-up del JIT escluso. Tutti i gradini — baseline pandas inclusa — cronometrati per intero e verificati per produrre lo stesso PnL per combo e lo stesso numero di trade su tutti gli 80 combo.

Un kernel, cinque implementazioni

Cinque gradini di un'unica scala: lo stesso kernel di backtest che sale da una baseline pandas di 70 secondi a un'esecuzione numba parallela di 0.23 secondi, ogni passo verificato per produrre lo stesso PnL

Perché un confronto di velocità abbia un senso, la cosa che viene calcolata deve essere fissata esattamente, e ogni implementazione deve dimostrare di calcolarla. Quindi l'esperimento fissa un kernel di strategia e lo mantiene costante su tutti e cinque i gradini.

Il kernel è un cross HMA/HMA3 — un sistema stop-and-reverse su due medie mobili in stile Hull. Il blocco costruttivo è la media mobile ponderata:

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}

La Hull Moving Average ne compone tre per ridurre il 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)

e HMA3 è una sorella più smussata costruita da WMA a circa n/6n/6, n/4n/4 e n/2n/2, smussata un'altra volta. Per ogni combinazione di parametri sono sette passate WMA su sei lunghezze di finestra distinte — uno stack di indicatori reale, non un giocattolo.

La regola di trading è deliberatamente, utilmente stateful: la direzione è long quando HMA è sotto HMA3 e short altrimenti; si apre una posizione alla prima direzione definita; a ogni cross, si chiude la posizione, si registra il PnL meno una fee di round-trip dello 0.09%, e si inverte. La posizione si trascina tra le barre — ciò che fai alla barra ii dipende dallo stato accumulato dall'ultimo cross. Questa path dependence è il punto centrale dell'esperimento: è la proprietà che rende i backtest diversi dalle pipeline generiche su dataframe, e (come misureremo) complica la questione della GPU — anche se, come si scopre, non nel modo in cui dice il folklore.

Il resto del setup, così puoi giudicare i numeri:

  • Dati: 150,000 barre di moto browniano geometrico sintetico, seminato (seed=42). Le prestazioni qui sono vincolate dalla dimensione dell'array e dalle lunghezze delle finestre, non da quale percorso di prezzo gli dai in pasto — e una serie sintetica rende l'intero esperimento deterministico e riproducibile da chiunque.
  • Griglia: 80 lunghezze HMA distinte distribuite su [6,200][6, 200] — così lo sweep contiene sia combo economici a finestra corta sia combo costosi a finestra lunga, come fa una griglia reale.
  • Timing: tempo reale (wall-clock), best-of-3 per gradino, con la compilazione JIT scaldata fuori dal timer e i worker del pool scaldati prima che il cronometro parta. Ogni gradino — baseline pandas inclusa — è cronometrato per intero su tutti gli 80 combo. BLAS (Accelerate di Apple) è fissato a un singolo thread, così i gradini single-thread sono genuinamente single-core: il gradino numpy non sta multithreadando di nascosto i suoi matvec alle spalle del confronto.
  • Gate di equivalenza: dopo il timing, il vettore per-combo (PnL, numero di trade) di ogni gradino viene confrontato con il riferimento — il numero di trade deve corrispondere esattamente, il PnL entro 10610^{-6} punti percentuali assoluti. La run committata riporta all_ok: true per ogni gradino, baseline pandas inclusa, su tutti gli 80 combo. Se questo gate fallisce, non c'è benchmark — ci sono solo cinque programmi che calcolano cinque cose diverse a cinque velocità diverse, che è come funzionano di nascosto molte affermazioni tipo "il nostro motore è 100x più veloce".

Un numero dal blocco di equivalenza merita un momento di onestà: l'impronta per il primo combo è un PnL di −5165.58 punti percentuali su 57,029 trade. Non è un risultato di strategia di cui vergognarsi — è la lunghezza HMA più corta (6) che si ribalta a quasi ogni oscillazione di un random walk e paga lo 0.09% ogni volta, esattamente come deve. È un'impronta di correttezza, non un backtest negoziabile. Non leggerci dentro dell'alpha; leggici dentro il determinismo — cinque implementazioni che atterrano sugli stessi 57,029 trade e sullo stesso PnL fino alla sesta cifra decimale è ciò che "identico" significa qui.

Stabilito questo, ogni speedup qui sotto è pura velocità. Niente è stato approssimato via.

Gradino M0: il profilo pandas naive — 69.9 s

Anatomia della baseline pandas naive: una finestra rolling.apply che genera una chiamata lambda Python per ognuna delle 150,000 barre mentre il loop dell'interprete arranca sotto di essa

La baseline non è un uomo di paglia. È il codice che ottieni quando scrivi una WMA nel modo suggerito dalla documentazione di pandas e l'event loop nel modo in cui recita la descrizione della strategia:

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

Perché è così lento? Non perché pandas sia "cattivo" — ma per dove vive l'iterazione. rolling(period).apply(lambda ...) è un loop a livello Python travestito da vettorizzato. Per ognuna delle 150,000 barre, pandas materializza una finestra, attraversa il confine C/Python, invoca un callable Python, e fa il boxing del risultato. Anche con raw=True (che almeno passa alla lambda un ndarray nudo invece di una Series), l'overhead dell'interprete per chiamata sovrasta le poche decine-centinaia di FLOP che la finestra richiede davvero. Moltiplica per sette passate WMA per combo, e lo stack di indicatori da solo è milioni di andirivieni dell'interprete. Poi il loop sulle barre esegue altre 150,000 iterazioni interpretate per combo, ognuna con indicizzazione bounds-checked su scalari numpy, boxing di float, e dispatch dinamico su tipi che l'interprete riscopre ogni singola volta.

Il risultato: 69.92 s per lo sweep, circa 0.87 s per combo, un throughput di 1.1 combo al secondo. Su una griglia da 80 combo alzi le spalle e aspetti un minuto. Il problema è che nessuno fa girare griglie da 80 combo a lungo — e questo costo scala linearmente per sempre. Ci torneremo.

Gradino M1: numpy — basta chiamare Python in un loop — 3.07 s, 22.7x

Il primo gradino elimina entrambi i loop dell'interprete in un colpo solo, ed è utile separare i due trucchi perché hanno una generalità molto diversa.

Il lato degli indicatori è quello facile, pienamente generale. Una media mobile ponderata su tutte le finestre è semplicemente un prodotto matrice-vettore contro una vista strided dell'input — nessuna copia, una sola chiamata 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 costruisce una vista (n − p + 1, p) della stessa memoria, e win @ w calcola il prodotto scalare di ogni finestra in codice compilato. Il milione di invocazioni lambda diventa una sola chiamata di libreria.

Il lato dei trade è quello interessante, perché l'event loop è stateful — eppure, per questo kernel, si vettorizza. L'intuizione è che la posizione a ogni barra dipende solo dal segno di HMA − HMA3, non da nessun esito dei trade. Lo stato non retroagisce mai sulle decisioni. Così l'intero loop collassa in "trova i cambi di segno, raccogli i prezzi a quegli indici":

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, uno speedup di 22.7x, 26.0 combo al secondo — su un solo core, con BLAS fissato a un singolo thread. Questo gradino merita un'etichetta: è la baseline competente, l'implementazione che un buon programmatore numpy spedirebbe, e il metro di paragone onesto per tutto ciò che sta sopra. Ma due avvertenze oneste viaggiano insieme a questo gradino.

Primo, questa vettorizzazione è una riscrittura analitica specifica della strategia, non una trasformazione meccanica. Esiste perché il kernel è stop-and-reverse senza stop, senza uscite trailing, senza position sizing che dipenda dal PnL corrente. Aggiungi uno stop-loss — la feature più ordinaria immaginabile — e l'uscita alla barra ii cambia quale entrata esiste alla barra j>ij > i, lo stato retroagisce sul percorso, e la forma chiusa evapora. La maggior parte dei kernel di produzione vive dal lato sbagliato di quella linea.

Secondo, questo è il gradino dove la correttezza va a morire. La contabilità degli indici di flip (+1 qui, [:-1] là, il seeding della prima direzione) è esattamente il tipo di codice che produce bug di esecuzione off-by-one — la stessa specie di bug che la nostra tassonomia del look-ahead ha mostrato poter fabbricare uno Sharpe di 15 dal rumore. Il gate di equivalenza non è una formalità su questo gradino; è l'unica ragione per fidarsene. Riscritture vettorizzate ingegnose senza un controllo di equivalenza contro un'implementazione di riferimento stupida sono il modo in cui i motori si allontanano dalla strategia che affermano di testare.

Gradino M2: numba — compila il loop che vuoi davvero scrivere — 1.98 s, 35.3x

Un event loop Python che passa attraverso il compilatore JIT di numba ed emerge come codice macchina compatto: la stessa logica ramificata barra per barra, compilata invece che interpretata

Il gradino M2 adotta la filosofia opposta: invece di contorcere l'algoritmo per adattarlo a primitive vettorizzate, scrivi i loop naive — e compilali. Numba (Lam, Pitrou & Seibert, 2015) compila JIT un sottoinsieme numerico di Python attraverso LLVM in codice macchina:

@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)

L'event loop dentro nb_sweep è testualmente il loop di M0. Branch, continue, stato portato in variabili locali — tutto quanto. Sotto @njit quelle variabili locali vivono nei registri, i branch sono vere istruzioni di salto, e il costo per iterazione scende da microsecondi di dispatch dell'interprete a nanosecondi.

1.98 s — 35.3x rispetto a pandas, ma solo circa 1.6x rispetto a numpy (derivato: 3.07/1.98). Quel passo modesto è di per sé istruttivo: i loop interni di numpy erano già compilati, quindi il vantaggio di numba sulla matematica delle feature si limita a saltare la materializzazione delle finestre e gli array intermedi. La parte trasformativa è altrove:

  1. L'event loop ora è gratis — e "gratis" è misurato, non retorico. M1 ha speso la sua ingegnosità per rendere vettorizzabile la logica dei trade. M2 rende quell'ingegnosità superflua — il loop naive, verificabile, facile da modificare gira a velocità macchina. Cronometrare la fase delle feature separatamente dal loop dei trade dentro questo kernel compilato attribuisce 99.3% del suo tempo alla matematica delle feature WMA e solo 0.7% all'event loop stateful. Puoi aggiungere uno stop-loss domani senza un progetto di ricerca — e tieni a mente questa suddivisione; ridecide l'argomento GPU più sotto.
  2. Sblocca i prossimi due gradini. Un kernel compilato, che rilascia il GIL, leggero sulle allocazioni, è l'unità di lavoro di cui ha bisogno l'orchestrazione parallela. Non puoi parallelizzare M0 in modo produttivo — dodici copie di lento restano lente, solo più calde.

Una nota metodologica: numba compila alla prima chiamata, e quella compilazione (centinaia di millisecondi) non deve stare dentro il timer — l'harness scalda il JIT su una fetta di 500 barre prima di misurare, e cache=True fa persistere i kernel compilati tra i lanci del processo. I benchmark che "dimenticano" questo dettaglio producono numeri per numba ingiustamente cattivi (compilazione a freddo inclusa) o non riproducibili.

Gradino M3: prange — il parallelismo che avevi già — 0.32 s, 217.6x

Ottanta combo di parametri indipendenti distribuiti su dodici core CPU: core performance ed efficiency che tirano lunghezze di finestra diseguali in parallelo

Ecco l'osservazione che rende speciale la ricerca massiva di parametri: gli 80 combo sono completamente indipendenti. Nessuno stato condiviso, nessun ordinamento, nessuna comunicazione. È un lavoro embarrassingly parallel che i gradini M0–M2 stavano eseguendo su un core su dodici, per pura abitudine.

Numba rende la correzione quasi sintattica — sostituisci il range del loop sui combo con 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

Poiché nb_sweep è compilato nopython, non trattiene il GIL, e il layer di threading di numba distribuisce le iterazioni su tutti i 12 core. L'array close, di sola lettura, è condiviso da tutti i thread a costo zero.

0.32 s — 217.6x rispetto a pandas, 248.9 combo al secondo. Il passo rispetto a M2 single-thread è di circa 6.2x su 12 core (derivato: 1.98/0.32), e vale la pena essere onesti sullo scarto dal "12x ideale" invece di nasconderlo: i 12 core del M2 Max sono 8 performance + 4 efficiency, quindi il tetto nominale non è mai stato 12x; gli 80 combo hanno costi estremamente diseguali (un HMA di lunghezza 6 è molto più economico di uno di lunghezza 200), quindi i thread finiscono in modo irregolare; e ogni chiamata al kernel alloca i suoi array intermedi da un allocatore condiviso. Gli speedup paralleli su macchine reali sono fatti così. Chiunque citi un pulito Nx-su-N-core per compiti eterogenei sta misurando qualcosa di sintetico.

Gradino M4: un process pool per l'ultimo terzo — 0.23 s, 297.9x

L'ultimo gradino sostituisce i thread con i processi — stesso kernel compilato, orchestrato da un 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 — 297.9x rispetto a pandas, 340.9 combo al secondo. Rileggi quel throughput: questo laptop ora esegue circa 340 backtest completi da 150,000 barre al secondo, ognuno calcolando sette medie mobili ponderate e simulando decine di migliaia di trade stateful.

Il vantaggio rispetto a prange è reale ma modesto — circa 1.4x (derivato: 0.32/0.23) — e le meccaniche plausibili sono lo scheduling e l'isolamento della memoria: con chunksize=1 il pool distribuisce i combo uno alla volta, così il mix irregolare di finestre economiche e costose si bilancia dinamicamente sui core asimmetrici, e ogni processo worker ottiene il proprio allocatore, evitando la contesa sui temporanei per-combo. Riportiamo queste come meccaniche coerenti con la misura, non come fatti dimostrati separatamente.

I processi non sono gratis, e l'harness paga i loro costi onestamente fuori dal timer dove sono costi una tantum (avvio dei worker, spedizione di close a ogni worker tramite l'initializer, warm-up del JIT per worker) — perché in una ricerca reale quei costi si ammortizzano su migliaia di combo, non ottanta. L'indicazione generale onesta: prange è più semplice e di solito basta; un process pool vince quando i task sono corposi, la griglia è grande, o il tuo lavoro per-combo trattiene il GIL da qualche parte che numba non riesce a raggiungere.

E con questo, la scala si scompone in un riepilogo pulito. Da M0 a M2 — il motore: 35.3x su un solo core, spostando l'iterazione fuori dall'interprete. Da M2 a M4 — l'orchestrazione: altri 8.4x (derivato: 1.98/0.23), usando i core che c'erano già. Moltiplicati: 298x. Nessun hardware nuovo, risultati identici. E misurata dalla baseline competente M1 invece che da quella naive, il motore finito resta comunque circa 13x più alto (derivato: 3.07/0.23) — la scala non è un artefatto della scelta di un punto di partenza lento.

Perché non una GPU — la versione onesta

Una GPU inattiva accanto a una CPU satura: matematica delle medie mobili batchabile lasciata sulla CPU perché uno sweep di ottanta combo e un quarto di secondo è troppo stretto e troppo breve per ripagare il viaggio

"Portalo semplicemente su una GPU" è la risposta più comune a uno sweep di parametri lento, quindi questo esperimento misura i due numeri da cui quella conversazione dovrebbe partire — e nessuno dei due supporta la versione pigra dell'una o dell'altra risposta.

Il roofline model (Williams, Waterman & Patterson, 2009) classifica un kernel in base alla sua intensità aritmetica — FLOP per byte spostato. Per lo stack di feature WMA in questo sweep, contando 2p2p FLOP per barra per finestra di lunghezza pp contro una lettura di 8 byte per barra, l'intero sweep da 80 combo risulta in circa 6.2 GFLOP su 576 MB trasferiti:

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}}

(Questo è il conteggio idealizzato sulle sei finestre WMA distinte per combo; contando le sette passate effettivamente eseguite si ottiene 11.07 FLOP/byte. La conclusione è la stessa in entrambi i casi.)

Quel numero conta per ciò che esclude: l'affermazione popolare secondo cui la matematica del backtest è "memory-bound, quindi le GPU non possono aiutare" è falsa qui. A ~10.8 FLOP/byte la matematica delle feature è decisamente compute-ish — ben oltre il ridge point dove l'hardware tipico smette di essere limitato dalla banda. Una GPU potrebbe assolutamente raggruppare in batch 80 combo × 7 passate WMA in una manciata di kernel grandi e macinare l'aritmetica. Se lo stack di feature fosse l'intero problema, il caso GPU sarebbe rispettabile.

Il secondo numero misurato uccide l'altra risposta pigra — quella a cui saremmo arrivati anche noi. Cronometrare la fase delle feature separatamente dal loop dei trade dentro il kernel compilato dà una suddivisione di 99.3% feature, 0.7% event loop. L'argomento allettante — "i backtest hanno un event loop stateful e ramificato, ed è quello a bloccare la GPU" — qui è quantitativamente sbagliato: la CPU spende essenzialmente tutto il suo tempo esattamente nella parte che una GPU potrebbe mettere in batch. Riformula 80 combo × 7 passate WMA come grandi convoluzioni in batch e hai un carico di lavoro tensoriale perfettamente ragionevole. Quindi la domanda onesta non è se il lavoro potrebbe andare su una GPU — la maggior parte potrebbe. La domanda è se il viaggio ripaga, e per questo sweep non ripaga, per due ragioni specifiche:

1. L'ampiezza sfruttabile è di 80 combo — e una GPU è una macchina per l'ampiezza. L'unico asse onesto di parallelismo in uno sweep di parametri è la griglia stessa: dentro un combo, il percorso di 150,000 barre è sequenziale. Una GPU vuole decine di migliaia di elementi di lavoro indipendenti per riempire le sue lane e nascondere la latenza; questo sweep ne offre ottanta. Dodici core CPU già saturano quell'ampiezza — è letteralmente ciò che hanno misurato i gradini M3–M4. Per i conteggi di combo a cui l'ampiezza di una GPU comincerebbe anche solo a entrare in gioco, la scala CPU sta già consegnando centinaia di backtest completi al secondo.

2. L'intero job dura 0.23 secondi. Alla velocità di M4 un combo costa circa 2.9 ms (derivato: 0.23 s / 80). Contro quel budget, le latenze di lancio del kernel e i punti di sincronizzazione del device non sono errori di arrotondamento ammortizzabili — sono una frazione consistente del job. (Su questa macchina Apple a memoria unificata, il trasferimento host-to-device è una preoccupazione minore; su una macchina CUDA con GPU discreta si aggiunge anch'esso al conto.) Il classico vantaggio GPU ammortizza gli overhead fissi su enormi batch di lavoro; uno sweep sub-secondo non ne produce mai uno.

E l'event loop? È l'unica parte che non si metterebbe in batch — seriale, ramificata, path-dependent, una dipendenza loop-carried lunga 150,000 barre che nessun hardware può parallelizzare dentro un combo, con esattamente i branch divergenti che le lane SIMT odiano. Un porting su GPU la lascerebbe sulla CPU o la eseguirebbe una lane per combo. Ma allo 0.7% del kernel, è un termine di Amdahl troppo piccolo per decidere qualcosa. È la parte che non andrebbe; non è la ragione per non andare. (Ricorda dal gradino M1 che per i kernel senza feedback il loop può persino essere vettorizzato analiticamente — la riscrittura che perdi nel momento in cui la strategia acquisisce uno stop.)

Una nota a piè di pagina sulla piattaforma per completezza: su questa macchina (Apple Silicon) il percorso GPU sarebbe MLX o PyTorch-MPS, non CUDA — cupy e l'ecosistema CUDA semplicemente non si applicano — ed entrambi richiederebbero di riscrivere l'hot path in un dialetto tensoriale solo per tentare l'esperimento. È un costo reale con, secondo l'analisi qui sopra, nessun ritorno identificato per la forma di questo sweep. La discussione sulla GPU qui è analitica, fondata sull'intensità aritmetica misurata e sulla suddivisione feature/loop misurata, e la etichettiamo come tale: nessuna run CUDA è stata eseguita perché nessuna era possibile sull'hardware dichiarato.

La frase di sintesi che difenderemmo in review: quasi tutto questo lavoro potrebbe andare su una GPU; questo sweep è troppo stretto e troppo breve perché il viaggio ripaghi. E leggila in entrambe le direzioni — non è una liquidazione. La riformulazione "big-matrix" in batch — riformulare lo sweep come grandi operazioni tensoriali su migliaia di combo in una volta, o un kernel genuinamente senza feedback che fa batch end-to-end — è una direzione reale e promettente che merita uno studio dedicato, non una liquidazione. A 80 combo e 0.23 secondi, semplicemente non si è ancora guadagnata il biglietto. Se il tuo carico di lavoro ha quell'ampiezza, l'aritmetica cambia, e dovresti rifarlo tu, non citare noi.

Dov'è il vero collo di bottiglia: motore e orchestrazione

Il vero collo di bottiglia rivelato: una clessidra dove il motore e l'orchestrazione di migliaia di combo di parametri strozzano il flusso, non l'hardware sottostante

Ottanta combo è una griglia dimostrativa. La ricerca di parametri reale è dove questi fattori smettono di essere accademici, perché le griglie crescono moltiplicativamente: quattro parametri con dieci valori ciascuno fanno 10410^4 combo; aggiungi la validazione walk-forward con una dozzina di fold e sei a 1.2×1051.2 \times 10^5 backtest completi prima ancora di aver esplorato qualcosa. Questa è la maledizione della dimensionalità, ed è per questo che la strategia di ricerca — Optuna, coordinate descent, Sobol — riceve così tanta attenzione: una ricerca più intelligente visita meno punti.

Ma la scala espone l'altra metà dell'equazione, meno discussa: il costo per punto visitato. Estrapolando linearmente i throughput misurati (i combo sono indipendenti, quindi questa è aritmetica, non modellazione):

Dimensione griglia Con M0 (1.1 combo/s) Con M4 (340.9 combo/s)
10,000 combo ~2.4 ore ~30 secondi
100,000 combo ~24 ore ~5 minuti

Lo stesso esperimento che è un batch job notturno sul motore naive è una query interattiva su quello ottimizzato. Quella differenza si accumula in un modo che le tabelle di tempo reale sottostimano: a 5 minuti per sweep tu iteri — rilanci con un leak corretto, aggiungi un fold, allarghi la griglia, testi l'idea che ti è venuta a pranzo. A 24 ore per sweep, non lo fai. La velocità del motore fissa il tempo del loop di ricerca, e il tempo del loop di ricerca è il prodotto vero e proprio.

C'è anche una lettura secondo la legge di Amdahl dell'intera scala:

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

Velocizzare una singola fase pp di un fattore ss è limitato da tutto il resto che hai lasciato lento. La scala ha rispettato questo ordine: il guadagno di 35.3x del motore ha attaccato il termine che dominava (l'iterazione interpretata, sia nello stack di feature sia nel loop), e il guadagno di 8.4x dell'orchestrazione ha attaccato il termine che dominava dopo (undici core inattivi). La suddivisione feature/loop è la stessa lezione in miniatura — non avremmo potuto definire la forma reale dell'argomento GPU senza misurare dove andasse davvero il tempo. Profila, poi ottimizza — in quest'ordine. La stessa logica governa il layer dei dati a monte del motore: i nostri benchmark Polars vs pandas hanno trovato lo stesso pattern identico (10–3500x su pipeline rolling raggruppate) per la metà load-and-transform dello stack, e la stessa conclusione ibrida — motori colonnari per la pipeline, un kernel compilato per la simulazione path-dependent.

Due note di onestà per chiudere il cerchio sulla generalità. Primo, questo esperimento è deliberatamente autocontenuto e sintetico — dati seminati, un kernel, una macchina dichiarata — così chiunque può riprodurre il fenomeno in modo deterministico; i numeri di tempo reale differiranno sul tuo hardware, ma l'equivalenza e la direzione della scala no. Secondo, il fenomeno non è un artefatto del setup sintetico: il benchmark del nostro motore HMA di produzione (bench_param_sweep.py, eseguito su dati reali di exchange con il modello completo di fee e fill di produzione) mostra la stessa forma di scala, con il percorso numba che atterra circa 100–200x sopra il profilo pandas naive. L'esperimento autocontenuto esiste perché tu non debba prendere per fede i nostri numeri di produzione.

Punti Chiave

  1. La scala è 298x, e si scompone: 35.3x motore × 8.4x orchestrazione. Spostare l'iterazione fuori dall'interprete (pandas → numba) e distribuire i combo indipendenti sui core (uno → dodici) si sono moltiplicati in uno speedup vicino a tre ordini di grandezza su un laptop invariato. 69.92 s → 0.23 s; 1.1 → 340.9 combo/s. E non è un artefatto della baseline lenta: contro l'implementazione numpy vettorizzata competente, il motore finito è ancora ~13x.
  2. Pretendi l'equivalenza prima di ammirare la velocità. Ogni gradino qui produce lo stesso PnL per combo e lo stesso numero di trade, verificato automaticamente su tutti gli 80 combo (tolleranza assoluta di 10610^{-6} sul PnL, esatta sui trade). Un motore veloce che calcola qualcosa di sottilmente diverso non è veloce — è sbagliato ad alto throughput, e le riscritture vettorizzate sono dove l'errore di solito si infila.
  3. @njit batte la vettorizzazione ingegnosa per la logica stateful. Il gradino numpy richiedeva una forma chiusa specifica della strategia che muore nel momento in cui aggiungi uno stop-loss. Il gradino numba compila il loop naive, verificabile — stessa classe di velocità, nessuna fragilità, ed è l'unità che si parallelizza.
  4. La risposta sulla GPU è "non per questo sweep" — per ragioni che dovresti saper nominare. La matematica delle feature è compute-ish (10.78 FLOP/byte) ed è il 99.3% del kernel compilato, quindi né "i backtest sono memory-bound" né "il loop stateful domina" sopravvivono alla misura. Le ragioni oneste sono ampiezza e budget: 80 combo di parallelismo sfruttabile che 12 core CPU già saturano, e un job totale di 0.23 s che l'overhead di lancio e sincronizzazione divorerebbe. La riformulazione big-matrix in batch a ampiezza reale resta una direzione promettente, non una confutata.
  5. La velocità del motore è il tempo della ricerca. Al throughput del motore naive, una ricerca da 100,000 backtest dura un giorno; al throughput in cima alla scala dura cinque minuti. Prima di comprare hardware o affittare un cluster, verifica se il tuo collo di bottiglia sia davvero silicio — il nostro era una lambda dentro rolling.apply e undici core inattivi.

L'esperimento completo — tutte e cinque le implementazioni, l'harness di equivalenza, il calcolo roofline, e ogni numero di questo articolo rigenerabile da un singolo script deterministico — si trova nel paper di accompagnamento su speed-ladder.marketmaker.cc, con codice e dati su github.com/suenot/backtest-speed-ladder.

Lo sweep che impiegava settanta secondi ora ne impiega un quarto. Stessi trade, stesso PnL, stesso laptop. La GPU che stavi per richiedere può aspettare; il loop dell'interprete che stavi per spedire in produzione no.

Disclaimer: le informazioni fornite in questo articolo hanno solo scopo didattico e informativo e non costituiscono consulenza finanziaria, di investimento o di trading. Il trading di criptovalute comporta un rischio significativo di perdita.

Autori

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

Resta un Passo Avanti al Mercato

Iscriviti alla nostra newsletter per approfondimenti esclusivi sul trading con IA, analisi di mercato e aggiornamenti sulla piattaforma.

Rispettiamo la tua privacy. Annulla l'iscrizione in qualsiasi momento.