← Retour aux articles
June 26, 2026
5 min de lecture

L'échelle de vitesse du backtest : 298x sur le CPU d'un portable, PnL identique jusqu'au dernier trade

L'échelle de vitesse du backtest : 298x sur le CPU d'un portable, PnL identique jusqu'au dernier trade
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization
Part 1 of 10 · Collection
High-Performance Backtest Engines

Fait partie de la série « Backtests sans illusions ».

📄 Cet article est devenu un article de recherche. Un noyau de backtest dépendant du chemin est implémenté de cinq façons — du pandas naïf jusqu'à un noyau numba parallèle — chaque échelon étant vérifié pour produire un PnL identique par combinaison, de sorte que seule la vitesse diffère. Lisez l'article en ligne (version interactive + PDF) sur speed-ladder.marketmaker.cc, code et données sur github.com/suenot/backtest-speed-ladder.

Soixante-dix secondes. C'est le temps que met l'implémentation de référence naïve pour balayer 80 combinaisons de paramètres d'une stratégie de moyenne mobile sur 150 000 barres : pandas rolling().apply() pour les indicateurs, une simple boucle Python pour les trades. C'est le profil sur lequel tourne une énorme partie du code de recherche réel, parce que c'est le profil qui découle de l'écriture de la stratégie de la manière évidente.

Le même balayage, sur le même portable, produisant le même PnL pour chaque combinaison jusqu'au dernier trade : 0,23 seconde.

L'écart entre ces deux chiffres — un 298x mesuré — est le sujet de cet article. Pas un seul point de pourcentage ne provient de nouveau matériel. Aucun GPU n'était impliqué (il n'y en a même pas de disponible sur cette machine au sens CUDA). Chaque échelon de l'échelle est la même stratégie, les mêmes données, les mêmes frais, le même nombre de trades, vérifié par un test d'équivalence qui fait échouer tout le benchmark si les résultats par combinaison d'une implémentation divergent. Ce qui a changé, c'est uniquement comment le travail est exprimé : ce qui s'exécute dans l'interpréteur, ce qui s'exécute compilé, et ce qui s'exécute en parallèle. Et comme une base délibérément lente peut flatter n'importe quel chiffre choc, un chiffre supplémentaire d'emblée : même face à une implémentation numpy vectorisée compétente — le code qu'un bon programmeur numpy livrerait — le moteur final est encore environ 13x plus rapide.

Quand une recherche de paramètres est lente, le réflexe est de se tourner vers du matériel plus puissant — un GPU, un cluster, un budget cloud. La réalité mesurée de cette expérience pointe vers quelque chose de bien moins glamour : le goulot d'étranglement était le moteur (une boucle interne interprétée faisant des appels Python par fenêtre) et l'orchestration (exécuter des combinaisons indépendantes en série sur un seul cœur). Les deux sont corrigibles en un après-midi, sur la machine que vous possédez déjà, sans aucun changement des résultats.

Voici toute l'échelle d'emblée. Tout ce qui suit est l'anatomie de chaque étape.

Échelon Implémentation Temps réel Accélération Combos/s
M0 pandas : rolling.apply + boucle Python par barre 69.92 s 1.0x 1.1
M1 numpy : WMA à fenêtre glissante + trades vectorisés 3.07 s 22.7x 26.0
M2 numba : WMA @njit + boucle d'événements @njit 1.98 s 35.3x 40.4
M3 numba prange : threads entre combinaisons 0.32 s 217.6x 248.9
M4 pool de processus + numba : processus entre combinaisons 0.23 s 297.9x 340.9

Apple M2 Max (12 cœurs), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) fixé à un seul thread pour que les échelons mono-thread soient véritablement mono-cœur. 150 000 barres × 80 combinaisons, meilleur temps sur 3 essais, échauffement JIT exclu. Tous les échelons — y compris la base pandas — chronométrés en entier et vérifiés pour produire un PnL et un nombre de trades identiques par combinaison sur les 80 combinaisons.

Un noyau, cinq implémentations

Cinq échelons d'un même escalier : le même noyau de backtest grimpant d'une base pandas de 70 secondes à une exécution numba parallèle de 0,23 seconde, chaque étape vérifiée pour produire le même PnL

Pour qu'une comparaison de vitesse ait un sens, la chose calculée doit être fixée exactement, et chaque implémentation doit être prouvée la calculer. Donc l'expérience fixe un noyau de stratégie et le maintient constant sur les cinq échelons.

Le noyau est un croisement HMA/HMA3 — un système stop-and-reverse sur deux moyennes mobiles de style Hull. Le bloc de construction est la moyenne mobile pondérée :

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 en compose trois pour réduire le retard :

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)

et HMA3 est une variante plus lisse construite à partir de WMA à environ n/6n/6, n/4n/4 et n/2n/2, encore lissée une fois. Par combinaison de paramètres, cela fait sept passes de WMA sur six longueurs de fenêtre distinctes — une vraie pile d'indicateurs, pas un jouet.

La règle de trading est délibérément et utilement à état : la direction est longue quand HMA est sous HMA3 et courte sinon ; ouvrir une position à la première direction définie ; à chaque croisement, clôturer la position, comptabiliser le PnL moins des frais aller-retour de 0,09 %, et inverser. La position se poursuit d'une barre à l'autre — ce que vous faites à la barre ii dépend de l'état accumulé depuis le dernier croisement. Cette dépendance au chemin est tout l'intérêt de l'expérience : c'est la propriété qui différencie les backtests des pipelines de dataframes génériques, et (comme nous le mesurerons) elle complique la question du GPU — mais pas, il s'avère, de la façon dont le dit le folklore.

Le reste de la configuration, pour que vous puissiez juger les chiffres :

  • Données : 150 000 barres de mouvement brownien géométrique synthétique, avec graine (seed=42). La performance ici est limitée par la taille du tableau et les longueurs de fenêtre, pas par la série de prix utilisée — et une série synthétique rend toute l'expérience déterministe et reproductible par n'importe qui.
  • Grille : 80 longueurs HMA distinctes réparties sur [6,200][6, 200] — de sorte que le balayage contient à la fois des combinaisons peu coûteuses à fenêtre courte et coûteuses à fenêtre longue, comme une vraie grille le ferait.
  • Chronométrage : temps réel, meilleur sur 3 par échelon, avec la compilation JIT échauffée en dehors du chronomètre et les workers du pool échauffés avant que l'horloge ne démarre. Chaque échelon — y compris la base pandas — est chronométré en entier sur les 80 combinaisons. BLAS (Accelerate d'Apple) est fixé à un seul thread, donc les échelons mono-thread sont véritablement mono-cœur : l'échelon numpy ne multithread pas silencieusement ses matvecs derrière le dos de la comparaison.
  • Test d'équivalence : après le chronométrage, le vecteur (PnL, nombre de trades) par combinaison de chaque échelon est comparé à la référence — le nombre de trades doit correspondre exactement, le PnL à une tolérance absolue de 10610^{-6} points de pourcentage près. L'exécution enregistrée rapporte all_ok: true pour chaque échelon, y compris la base pandas, sur les 80 combinaisons. Si ce test échoue, il n'y a pas de benchmark — il y a juste cinq programmes calculant cinq choses différentes à cinq vitesses différentes, ce qui est comment beaucoup d'affirmations « notre moteur est 100x plus rapide » fonctionnent silencieusement.

Un chiffre du bloc d'équivalence mérite un moment d'honnêteté : l'empreinte de la première combinaison est un PnL de −5165,58 points de pourcentage sur 57 029 trades. Ce n'est pas un résultat de stratégie dont il faut avoir honte — c'est la longueur HMA la plus courte (6) qui bascule à presque chaque soubresaut d'une marche aléatoire et paie 0,09 % à chaque fois, exactement comme il se doit. C'est une empreinte de correction, pas un backtest négociable. N'y lisez pas d'alpha ; lisez-y du déterminisme — cinq implémentations arrivant aux mêmes 57 029 trades et au même PnL à six décimales près, c'est ce que signifie « identique » ici.

Ceci étant établi, chaque accélération ci-dessous est de la pure vitesse. Rien n'a été approximé au passage.

Échelon M0 : le profil pandas naïf — 69,9 s

Anatomie de la base pandas naïve : une fenêtre rolling.apply générant un appel lambda Python pour chacune des 150 000 barres pendant que la boucle de l'interpréteur rampe en dessous

La base n'est pas un homme de paille. C'est le code que vous obtenez en écrivant une WMA comme la documentation pandas le suggère et la boucle d'événements comme la description de la stratégie le dit :

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

Pourquoi est-ce lent ? Pas parce que pandas est « mauvais » — à cause de où vit l'itération. rolling(period).apply(lambda ...) est une boucle au niveau Python déguisée en costume vectorisé. Pour chacune des 150 000 barres, pandas matérialise une fenêtre, traverse la frontière C/Python, invoque un appelable Python, et encapsule le résultat. Même avec raw=True (qui au moins donne à la lambda un ndarray brut plutôt qu'une Series), le surcoût de l'interpréteur par appel écrase largement les quelques dizaines à centaines de FLOPs que la fenêtre nécessite réellement. Multipliez par sept passes de WMA par combinaison, et la seule pile d'indicateurs représente des millions d'allers-retours de l'interpréteur. Puis la boucle de barres exécute 150 000 autres itérations interprétées par combinaison, chacune effectuant une indexation avec vérification de limites sur des scalaires numpy, encapsulant des floats et dispatchant dynamiquement sur des types que l'interpréteur redécouvre à chaque fois.

Le résultat : 69,92 s pour le balayage, environ 0,87 s par combinaison, un débit de 1,1 combinaisons par seconde. Sur une grille de 80 combinaisons, on hausse les épaules et on attend une minute. Le problème est que personne n'exécute des grilles de 80 combinaisons longtemps — et ce coût s'échelonne linéairement pour toujours. Nous y reviendrons.

Échelon M1 : numpy — arrêter d'appeler Python dans une boucle — 3,07 s, 22,7x

Le premier échelon vers le haut élimine les deux boucles d'interpréteur d'un coup, et il vaut la peine de séparer les deux astuces car elles ont des degrés de généralité très différents.

Le côté indicateur est le facile, entièrement général. Une moyenne mobile pondérée sur toutes les fenêtres est juste un produit matrice-vecteur contre une vue à pas (stride) de l'entrée — pas de copies, un seul appel 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 construit une vue (n − p + 1, p) de la même mémoire, et win @ w calcule le produit scalaire de chaque fenêtre en code compilé. Le million d'invocations de lambda devient un seul appel de bibliothèque.

Le côté trades est celui qui est intéressant, parce que la boucle d'événements a un état — et pourtant, pour ce noyau, elle se vectorise. L'idée est que la position à n'importe quelle barre ne dépend que du signe de HMA − HMA3, pas d'un résultat de trade quelconque. L'état ne rétroagit jamais sur les décisions. Donc toute la boucle s'effondre en « trouver les inversions de signe, récupérer les prix à ces indices » :

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, une accélération de 22,7x, 26,0 combinaisons par seconde — sur un seul cœur, avec BLAS fixé à un seul thread. Cet échelon mérite une étiquette : c'est la base compétente, l'implémentation qu'un bon programmeur numpy livrerait, et l'étalon équitable pour tout ce qui est au-dessus. Mais deux mises en garde honnêtes accompagnent cet échelon.

D'abord, cette vectorisation est une réécriture analytique spécifique à la stratégie, pas une transformation mécanique. Elle existe parce que le noyau est stop-and-reverse sans stops, sans sorties trailing, sans dimensionnement de position dépendant du PnL courant. Ajoutez un stop-loss — la fonctionnalité la plus ordinaire imaginable — et la sortie à la barre ii change quelle entrée existe à la barre j>ij > i, l'état rétroagit sur le chemin, et la forme fermée s'évapore. La plupart des noyaux de production vivent du mauvais côté de cette ligne.

Ensuite, c'est l'échelon où la correction va mourir. La comptabilité des indices d'inversion (+1 ici, [:-1] là, l'amorçage de la première direction) est exactement le genre de code qui produit des bugs d'exécution du type décalage d'un — la même espèce de bug que notre taxonomie du look-ahead a montré pouvoir fabriquer un Sharpe de 15 à partir de bruit. Le test d'équivalence n'est pas une formalité sur cet échelon ; c'est la seule raison de lui faire confiance. Les réécritures vectorisées astucieuses sans vérification d'équivalence contre une implémentation de référence simpliste sont la façon dont les moteurs dérivent loin de la stratégie qu'ils prétendent tester.

Échelon M2 : numba — compiler la boucle que vous voulez vraiment écrire — 1,98 s, 35,3x

Une boucle d'événements Python passant à travers le compilateur JIT numba et en ressortant sous forme de code machine compact : la même logique branchue barre par barre, compilée au lieu d'interprétée

L'échelon M2 adopte la philosophie opposée : au lieu de contorsionner l'algorithme pour l'adapter à des primitives vectorisées, écrire les boucles naïves — et les compiler. Numba (Lam, Pitrou & Seibert, 2015) compile en JIT un sous-ensemble numérique de Python via LLVM en code machine :

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

La boucle d'événements à l'intérieur de nb_sweep est textuellement la boucle de M0. Branches, continue, état conservé dans des variables locales — tout cela. Sous @njit, ces variables locales vivent dans des registres, les branches sont de vraies instructions de saut, et le coût par itération chute de microsecondes de dispatch d'interpréteur à des nanosecondes.

1,98 s — 35,3x par rapport à pandas, mais seulement environ 1,6x par rapport à numpy (dérivé : 3,07/1,98). Ce pas modeste est lui-même instructif : les boucles internes de numpy étaient déjà compilées, donc le gain de numba sur les calculs de features se limite à sauter la matérialisation des fenêtres et les tableaux intermédiaires. La partie transformatrice est ailleurs :

  1. La boucle d'événements est gratuite maintenant — et « gratuite » est mesuré, pas rhétorique. M1 a dépensé son ingéniosité à rendre la logique de trading vectorisable. M2 rend cette ingéniosité inutile — la boucle naïve, auditable, facile à modifier s'exécute à la vitesse machine. Chronométrer l'étape de features séparément de la boucle de trading à l'intérieur de ce noyau compilé attribue 99,3 % de son temps aux calculs de WMA de features et seulement 0,7 % à la boucle d'événements à état. Vous pouvez ajouter un stop-loss demain sans projet de recherche — et retenez cette répartition ; elle redécide l'argument du GPU plus bas.
  2. Elle débloque les deux échelons suivants. Un noyau compilé, libérant le GIL, léger en allocations, est l'unité de travail dont l'orchestration parallèle a besoin. Vous ne pouvez pas paralléliser productivement M0 — douze copies de lent restent lentes, juste plus chaudes.

Une remarque méthodologique : numba compile au premier appel, et cette compilation (des centaines de millisecondes) ne doit pas être dans le chronomètre — le harnais échauffe le JIT sur une tranche de 500 barres avant de mesurer, et cache=True conserve les noyaux compilés entre les lancements de processus. Les benchmarks qui « oublient » ce détail produisent des chiffres numba qui sont soit injustement mauvais (compilation à froid incluse) soit non reproductibles.

Échelon M3 : prange — le parallélisme que vous aviez déjà — 0,32 s, 217,6x

Quatre-vingts combinaisons de paramètres indépendantes déployées sur douze cœurs de CPU : cœurs de performance et d'efficacité traitant des longueurs de fenêtre inégales en parallèle

Voici l'observation qui rend la recherche massive de paramètres spéciale : les 80 combinaisons sont complètement indépendantes. Pas d'état partagé, pas d'ordre, pas de communication. C'est du travail embarrassant à paralléliser que les échelons M0–M2 exécutaient sur un seul cœur parmi douze, par pure habitude.

Numba rend la correction quasi syntaxique — remplacer le range de la boucle de combinaisons par 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

Comme nb_sweep est compilé en mode nopython, il ne retient pas le GIL, et la couche de threads de numba répartit les itérations sur les 12 cœurs. Le tableau close en lecture seule est partagé par tous les threads à coût nul.

0,32 s — 217,6x par rapport à pandas, 248,9 combinaisons par seconde. Le pas par rapport à M2 mono-thread est d'environ 6,2x sur 12 cœurs (dérivé : 1,98/0,32), et l'écart par rapport au « 12x idéal » mérite d'être honnête plutôt que caché : les 12 cœurs du M2 Max sont 8 cœurs de performance + 4 d'efficacité, donc le plafond nominal n'a jamais été 12x ; les 80 combinaisons ont des coûts extrêmement inégaux (une HMA de longueur 6 est bien moins coûteuse qu'une de longueur 200), donc les threads finissent de façon désordonnée ; et chaque appel du noyau alloue ses tableaux intermédiaires depuis un allocateur partagé. Les accélérations parallèles sur des machines réelles ressemblent à ça. Quiconque cite un Nx propre sur N cœurs pour des tâches hétérogènes mesure quelque chose de synthétique.

Échelon M4 : un pool de processus pour le dernier tiers — 0,23 s, 297,9x

Le dernier échelon remplace les threads par des processus — même noyau compilé, orchestré par 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 par rapport à pandas, 340,9 combinaisons par seconde. Relisez ce débit : ce portable exécute maintenant environ 340 backtests complets de 150 000 barres par seconde, chacun calculant sept moyennes mobiles pondérées et simulant des dizaines de milliers de trades à état.

L'avantage sur prange est réel mais modeste — environ 1,4x (dérivé : 0,32/0,23) — et la mécanique plausible est l'ordonnancement et l'isolation mémoire : avec chunksize=1 le pool distribue les combinaisons une par une, donc le mélange inégal de fenêtres bon marché et coûteuses s'équilibre dynamiquement sur les cœurs asymétriques, et chaque processus worker obtient son propre allocateur, évitant la contention sur les temporaires par combinaison. Nous rapportons ceci comme une mécanique cohérente avec la mesure, pas comme des faits séparément prouvés.

Les processus ne sont pas gratuits, et le harnais paie leurs coûts honnêtement en dehors du chronomètre là où ce sont des coûts uniques (démarrage des workers, envoi de close à chaque worker via l'initialiseur, échauffement JIT par worker) — parce que dans une vraie recherche ces coûts s'amortissent sur des milliers de combinaisons, pas quatre-vingts. La recommandation honnête générale : prange est plus simple et généralement suffisant ; un pool de processus gagne quand les tâches sont volumineuses, la grille est grande, ou votre travail par combinaison retient le GIL quelque part que numba ne peut atteindre.

Et avec cela, l'échelle se décompose en un résumé net. De M0 à M2 — le moteur : 35,3x sur un seul cœur, en sortant l'itération de l'interpréteur. De M2 à M4 — l'orchestration : encore 8,4x (dérivé : 1,98/0,23), en utilisant les cœurs qui étaient déjà là. Multiplié : 298x. Aucun nouveau matériel, résultats identiques. Et mesuré depuis la base compétente M1 plutôt que la naïve, le moteur final se tient encore à environ 13x plus haut (dérivé : 3,07/0,23) — l'échelle n'est pas un artefact du choix d'un point de départ lent.

Pourquoi pas un GPU — la version honnête

Un GPU inactif à côté d'un CPU saturé : des calculs de moyenne mobile traitables par lots laissés sur le CPU parce qu'un balayage de quatre-vingts combinaisons et un quart de seconde est trop étroit et trop court pour justifier le voyage

« Portez-le simplement sur GPU » est la réponse la plus courante à un balayage de paramètres lent, donc cette expérience mesure les deux nombres par lesquels cette conversation devrait commencer — et aucun ne soutient la version paresseuse de l'une ou l'autre réponse.

Le modèle roofline (Williams, Waterman & Patterson, 2009) classe un noyau par son intensité arithmétique — FLOPs par octet déplacé. Pour la pile de features WMA dans ce balayage, en comptant 2p2p FLOPs par barre par fenêtre de longueur pp contre une lecture de 8 octets par barre, tout le balayage de 80 combinaisons donne environ 6,2 GFLOP sur 576 Mo transférés :

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

(C'est le compte idéalisé sur les six fenêtres WMA distinctes par combinaison ; compter les sept passes telles qu'effectivement exécutées donne 11,07 FLOP/octet. Même conclusion dans les deux cas.)

Ce nombre compte pour ce qu'il exclut : l'affirmation populaire selon laquelle les calculs de backtest sont « liés à la mémoire, donc les GPU ne peuvent pas aider » est fausse ici. À ~10,8 FLOP/octet, les calculs de features sont résolument du côté du calcul — bien au-delà du point de crête où le matériel typique cesse d'être limité par la bande passante. Un GPU pourrait absolument regrouper 80 combinaisons × 7 passes WMA en une poignée de gros noyaux et dévorer l'arithmétique. Si la pile de features était tout le problème, le cas du GPU serait respectable.

Le second nombre mesuré tue l'autre réponse paresseuse — celle que nous aurions nous-mêmes choisie. Chronométrer l'étape de features séparément de la boucle de trading à l'intérieur du noyau compilé donne une répartition de 99,3 % features, 0,7 % boucle d'événements. L'argument tentant — « les backtests ont une boucle d'événements à état, branchue, et c'est ça qui bloque le GPU » — est quantitativement faux ici : le CPU passe essentiellement tout son temps exactement dans la partie qu'un GPU pourrait regrouper par lots. Reformulez 80 combinaisons × 7 passes WMA en grandes convolutions par lots et vous avez une charge de travail tensorielle parfaitement raisonnable. Donc la question honnête n'est pas de savoir si le travail pourrait aller sur GPU — la majeure partie le pourrait. La question est de savoir si le voyage en vaut la peine, et pour ce balayage ce n'est pas le cas, pour deux raisons précises :

1. La largeur exploitable est de 80 combinaisons — et un GPU est une machine de largeur. Le seul axe honnête de parallélisme dans un balayage de paramètres est la grille elle-même : au sein d'une combinaison, le chemin de 150 000 barres est séquentiel. Un GPU veut des dizaines de milliers d'éléments de travail indépendants pour remplir ses voies et masquer la latence ; ce balayage en offre quatre-vingts. Douze cœurs CPU saturent déjà cette largeur — c'est littéralement ce que les échelons M3–M4 ont mesuré. Pour les nombres de combinaisons où la largeur d'un GPU commencerait même à entrer en jeu, l'échelle CPU délivre déjà des centaines de backtests complets par seconde.

2. Tout le travail dure 0,23 seconde. À la vitesse M4, une combinaison coûte environ 2,9 ms (dérivé : 0,23 s / 80). Face à ce budget, les latences de lancement de noyau et les points de synchronisation de dispositif ne sont pas des erreurs d'arrondi amortissables — ce sont une fraction importante du travail. (Sur cette machine Apple à mémoire unifiée, le transfert hôte-vers-dispositif est une préoccupation mineure ; sur une machine CUDA à GPU discret, il s'ajoute aussi à la note.) La victoire classique du GPU amortit les coûts fixes sur d'énormes lots de travail ; un balayage de moins d'une seconde n'en produit jamais.

Et la boucle d'événements ? C'est la seule partie qui ne se regrouperait pas par lots — séquentielle, branchue, dépendante du chemin, une dépendance portée par la boucle sur 150 000 barres de long qu'aucun matériel ne peut paralléliser au sein d'une combinaison, avec exactement les branches divergentes que les voies SIMT détestent. Un portage GPU la laisserait sur le CPU ou l'exécuterait une voie par combinaison. Mais à 0,7 % du noyau, c'est un terme d'Amdahl trop petit pour décider quoi que ce soit. C'est la partie qui n'irait pas ; ce n'est pas la raison de ne pas y aller. (Rappelez-vous de l'échelon M1 que pour les noyaux sans rétroaction, la boucle peut même être vectorisée analytiquement — la réécriture que vous perdez au moment où la stratégie gagne un stop.)

Une note de plateforme pour être complet : sur cette machine (Apple Silicon) le chemin GPU serait MLX ou PyTorch-MPS, pas CUDA — cupy et l'écosystème CUDA ne s'appliquent tout simplement pas — et l'un ou l'autre exigerait de réécrire le chemin critique dans un dialecte tensoriel juste pour tenter l'expérience. C'est un coût réel sans, selon l'analyse ci-dessus, gain identifié pour la forme de ce balayage. La discussion sur le GPU ici est analytique, fondée sur l'intensité arithmétique mesurée et la répartition features/boucle mesurée, et nous l'étiquetons comme telle : aucune exécution CUDA n'a été effectuée car aucune n'était possible sur le matériel divulgué.

La phrase résumé que nous défendrions en revue : presque tout ce travail pourrait aller sur GPU ; ce balayage est trop étroit et trop court pour que le voyage en vaille la peine. Et lisez-la dans les deux sens — ce n'est pas un abandon. La reformulation « grande matrice » par lots — repenser le balayage comme de grandes opérations tensorielles sur des milliers de combinaisons à la fois, ou un noyau véritablement sans rétroaction qui se regroupe de bout en bout — est une direction réelle et prometteuse qui mérite une étude dédiée, pas un rejet. À 80 combinaisons et 0,23 seconde, elle n'a simplement pas encore mérité le billet. Si votre charge de travail a cette largeur, l'arithmétique change, et vous devriez la refaire vous-même, pas nous citer.

Où se situe le vrai goulot d'étranglement : moteur et orchestration

Le vrai goulot d'étranglement révélé : un sablier où le moteur et l'orchestration de milliers de combinaisons de paramètres étranglent le flux, pas le matériel sous-jacent

Quatre-vingts combinaisons, c'est une grille de démonstration. La vraie recherche de paramètres est là où ces facteurs cessent d'être académiques, parce que les grilles croissent multiplicativement : quatre paramètres à dix valeurs chacun donnent 10410^4 combinaisons ; ajoutez une validation walk-forward avec une douzaine de folds et vous êtes à 1,2×1051,2 \times 10^5 backtests complets avant d'avoir exploré quoi que ce soit. C'est la malédiction de la dimensionnalité, et c'est pourquoi la stratégie de recherche — Optuna, descente par coordonnées, Sobol — reçoit tant d'attention : une recherche plus intelligente visite moins de points.

Mais l'échelle expose l'autre moitié, moins discutée, de l'équation : le coût par point visité. En extrapolant linéairement les débits mesurés (les combinaisons sont indépendantes, donc c'est de l'arithmétique, pas de la modélisation) :

Taille de grille À M0 (1,1 combos/s) À M4 (340,9 combos/s)
10 000 combos ~2,4 heures ~30 secondes
100 000 combos ~24 heures ~5 minutes

La même expérience qui est un travail par lots d'une nuit entière sur le moteur naïf est une requête interactive sur celui optimisé. Cette différence s'accumule d'une manière que les tableaux de temps réel sous-estiment : à 5 minutes par balayage, vous itérez — vous relancez avec une fuite corrigée, vous ajoutez un fold, vous élargissez la grille, vous testez l'idée qui vous est venue au déjeuner. À 24 heures par balayage, non. La vitesse du moteur fixe le tempo de la boucle de recherche, et le tempo de la boucle de recherche est le vrai produit.

Il y a aussi une lecture selon la loi d'Amdahl de toute l'échelle :

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

Accélérer n'importe quelle étape unique pp d'un facteur ss est borné par tout ce que vous avez laissé lent par ailleurs. L'échelle a respecté cet ordre : le gain de moteur de 35,3x a attaqué le terme qui dominait (itération interprétée, dans la pile de features comme dans la boucle), et le gain d'orchestration de 8,4x a attaqué le terme qui dominait ensuite (onze cœurs inactifs). La répartition features/boucle est la même leçon en miniature — nous n'aurions pas pu nommer la forme réelle de l'argument du GPU sans mesurer où le temps allait vraiment. Profiler, puis optimiser — dans cet ordre. La même logique gouverne la couche de données en amont du moteur : nos benchmarks Polars vs pandas ont trouvé le même schéma identique (10–3500x sur des pipelines rolling groupés) pour la moitié chargement-et-transformation de la pile, et la même conclusion hybride — moteurs colonnaires pour le pipeline, noyau compilé pour la simulation dépendante du chemin.

Deux notes d'honnêteté pour clore la boucle sur la généralité. Premièrement, cette expérience est délibérément autonome et synthétique — données à graine fixe, un noyau, une machine divulguée — de sorte que quiconque peut reproduire le phénomène de manière déterministe ; les chiffres de temps réel différeront sur votre matériel, mais l'équivalence et la direction de l'échelle non. Deuxièmement, le phénomène n'est pas un artefact de la configuration synthétique : le benchmark de notre moteur HMA de production (bench_param_sweep.py, exécuté sur des données d'exchange réelles avec le modèle complet de frais et d'exécution de production) montre la même forme d'échelle, avec le chemin numba atterrissant à environ 100–200x au-dessus du profil pandas naïf. L'expérience autonome existe pour que vous n'ayez pas à prendre nos chiffres de production sur la foi.

Points clés à retenir

  1. L'échelle est 298x, et elle se décompose : 35,3x moteur × 8,4x orchestration. Sortir l'itération de l'interpréteur (pandas → numba) et répartir des combinaisons indépendantes sur des cœurs (un → douze) s'est multiplié en une accélération proche de trois ordres de grandeur sur un portable inchangé. 69,92 s → 0,23 s ; 1,1 → 340,9 combos/s. Et ce n'est pas un artefact de base lente : face à l'implémentation numpy vectorisée compétente, le moteur final est encore ~13x.
  2. Exigez l'équivalence avant d'admirer la vitesse. Chaque échelon ici produit un PnL et un nombre de trades identiques par combinaison, vérifié automatiquement sur les 80 combinaisons (tolérance absolue de 10610^{-6} sur le PnL, exacte sur les trades). Un moteur rapide qui calcule quelque chose de subtilement différent n'est pas rapide — il est faux à haut débit, et les réécritures vectorisées sont là où l'erreur se glisse généralement.
  3. @njit bat la vectorisation astucieuse pour la logique à état. L'échelon numpy nécessitait une forme fermée spécifique à la stratégie qui meurt dès que vous ajoutez un stop-loss. L'échelon numba compile la boucle naïve, auditable — même classe de vitesse, aucune de la fragilité, et c'est l'unité qui se parallélise.
  4. La réponse GPU est « pas pour ce balayage » — pour des raisons que vous devriez pouvoir nommer. Les calculs de features sont du côté calcul (10,78 FLOP/octet) et représentent 99,3 % du noyau compilé, donc ni « les backtests sont limités par la mémoire » ni « la boucle à état domine » ne survivent à la mesure. Les raisons honnêtes sont la largeur et le budget : 80 combinaisons de parallélisme exploitable que 12 cœurs CPU saturent déjà, et un travail total de 0,23 s que le lancement et la synchronisation dévoreraient. La reformulation grande matrice par lots à largeur réelle reste une direction prometteuse, pas une réfutée.
  5. La vitesse du moteur est le tempo de recherche. Au débit du moteur naïf, une recherche de 100 000 backtests prend une journée ; au débit du sommet de l'échelle, cinq minutes. Avant d'acheter du matériel ou de louer un cluster, vérifiez si votre goulot d'étranglement est même du silicium. Le nôtre était un lambda à l'intérieur de rolling.apply et onze cœurs inactifs.

L'expérience complète — les cinq implémentations, le harnais d'équivalence, le calcul roofline, et chaque chiffre de cet article régénérable à partir d'un script déterministe unique — est dans l'article complémentaire sur speed-ladder.marketmaker.cc, avec code et données sur github.com/suenot/backtest-speed-ladder.

Le balayage qui prenait soixante-dix secondes en prend un quart d'une. Mêmes trades, même PnL, même portable. Le GPU que vous étiez sur le point de réquisitionner peut attendre ; la boucle d'interpréteur que vous étiez sur le point de livrer ne le peut pas.

blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Gardez une longueur d'avance sur le marché

Abonnez-vous à notre newsletter pour des insights exclusifs sur le trading IA, des analyses de marché et des mises à jour de la plateforme.

Nous respectons votre vie privée. Désabonnement possible à tout moment.