← Terug naar artikelen
June 26, 2026
5 min leestijd

De Backtest-snelheidsladder: 298x op een laptop-CPU, identieke PnL tot de laatste trade

De Backtest-snelheidsladder: 298x op een laptop-CPU, identieke PnL tot de laatste trade
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization
Part 1 of 10 · Collection
High-Performance Backtest Engines

Onderdeel van de reeks "Backtests Without Illusions".

📄 Dit artikel is uitgegroeid tot een researchpaper. Eén padafhankelijke backtestkernel wordt op vijf manieren geïmplementeerd — van naïef pandas tot een parallelle numba-kernel — waarbij elke trede kruislings is gecontroleerd om identieke PnL per combinatie te leveren, zodat alleen de snelheid verschilt. Lees de paper online (interactieve versie + PDF) op speed-ladder.marketmaker.cc, code en data op github.com/suenot/backtest-speed-ladder.

Zeventig seconden. Zo lang duurt het voor de naïeve referentie-implementatie om 80 parametercombinaties van één moving-average-strategie te doorlopen over 150.000 bars: pandas rolling().apply() voor de indicatoren, een gewone Python-loop voor de trades. Dit is het profiel waarop een groot deel van de reële researchcode draait, omdat het simpelweg het profiel is dat ontstaat wanneer je de strategie op de voor de hand liggende manier schrijft.

Diezelfde sweep, op dezelfde laptop, met exact dezelfde PnL voor elke combinatie tot de laatste trade: 0,23 seconden.

Het verschil tussen die twee getallen — een gemeten 298x — is het onderwerp van dit artikel. Geen procentpunt daarvan komt van nieuwe hardware. Er kwam geen GPU aan te pas (er is op deze machine in CUDA-zin zelfs geen beschikbaar). Elke trede van de ladder gebruikt dezelfde strategie, dezelfde data, dezelfde fees, hetzelfde aantal trades, geverifieerd door een equivalentiecontrole die de hele benchmark laat falen zodra de resultaten per combinatie van een implementatie afwijken. Wat verandert is alleen hoe het werk wordt uitgedrukt: wat er in de interpreter draait, wat gecompileerd draait, en wat parallel draait. En omdat een bewust trage baseline elk krantenkopcijfer kan vertekenen, hier alvast één extra getal: zelfs tegenover een degelijke gevectoriseerde numpy-implementatie — de code die een sterke numpy-programmeur zou uitleveren — is de uiteindelijke engine nog steeds ongeveer 13x sneller.

Wanneer een parametersearch traag is, is de reflex om grotere hardware te zoeken — een GPU, een cluster, een cloudbudget. De gemeten werkelijkheid van dit experiment wijst naar iets veel minder glamoureus: het knelpunt was de engine (een geïnterpreteerde binnenste loop met Python-aanroepen per venster) en de orkestratie (onafhankelijke combinaties serieel op één core uitvoeren). Beide zijn in een middag te verhelpen, op de machine die je al bezit, zonder enige verandering aan de resultaten.

Hier is meteen de hele ladder. Alles hieronder is de anatomie van elke stap.

Trede Implementatie Wall time Versnelling Combo's/s
M0 pandas: rolling.apply + Python bar-loop 69.92 s 1.0x 1.1
M1 numpy: sliding-window WMA + gevectoriseerde trades 3.07 s 22.7x 26.0
M2 numba: @njit WMA + @njit event-loop 1.98 s 35.3x 40.4
M3 numba prange: threads over combo's 0.32 s 217.6x 248.9
M4 procespool + numba: processen over combo's 0.23 s 297.9x 340.9

Apple M2 Max (12 cores), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) vastgezet op één thread zodat de single-threaded tredes werkelijk single-core zijn. 150.000 bars × 80 combo's, best-of-3 wall time, JIT-warmloop uitgesloten. Alle tredes — inclusief de pandas-baseline — volledig getimed en geverifieerd op identieke PnL en aantal trades per combinatie voor alle 80 combo's.

Eén kernel, vijf implementaties

Five rungs of one staircase: the same backtest kernel climbing from a 70-second pandas baseline to a 0.23-second parallel numba run, each step verified to produce identical PnL

Om een snelheidsvergelijking iets te laten betekenen, moet wat er berekend wordt exact vastliggen, en moet van elke implementatie bewezen worden dat ze dat ook daadwerkelijk berekent. Daarom legt het experiment één strategiekernel vast en houdt die constant over alle vijf tredes.

De kernel is een HMA/HMA3-cross — een stop-and-reverse-systeem op twee Hull-achtige moving averages. De bouwsteen is het gewogen voortschrijdend gemiddelde:

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}

De Hull Moving Average combineert er drie om vertraging te verminderen:

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)

en HMA3 is een gladdere variant, opgebouwd uit WMA's bij ongeveer n/6n/6, n/4n/4 en n/2n/2, nog eens extra gladgestreken. Per parametercombinatie is dat zeven WMA-doorgangen over zes verschillende vensterlengtes — een echte indicatorstapel, geen speelgoedvoorbeeld.

De handelsregel is bewust, en nuttig, stateful: de richting is long wanneer HMA onder HMA3 ligt en short in het andere geval; open een positie bij de eerste gedefinieerde richting; sluit bij elke cross de positie, boek de PnL minus een round-trip fee van 0,09%, en draai om. De positie loopt door over bars heen — wat je op bar ii doet, hangt af van de toestand die is opgebouwd sinds de laatste cross. Deze padafhankelijkheid is precies het punt van het experiment: het is de eigenschap die backtests onderscheidt van generieke dataframe-pipelines, en (zoals we zullen meten) compliceert het de GPU-vraag — al niet op de manier die de volksmythe beweert.

De rest van de opzet, zodat je de getallen kunt beoordelen:

  • Data: 150.000 bars synthetische geometrische Brownse beweging, met vaste seed (seed=42). De performance hier wordt bepaald door array-grootte en vensterlengtes, niet door welk prijspad je invoert — en een synthetische reeks maakt het hele experiment deterministisch en voor iedereen reproduceerbaar.
  • Grid: 80 verschillende HMA-lengtes verspreid over [6,200][6, 200] — zodat de sweep zowel goedkope kortevenstercombinaties als dure langevenstercombinaties bevat, zoals een echt grid.
  • Timing: wall-clock, best-of-3 per trede, waarbij JIT-compilatie buiten de timer wordt opgewarmd en poolworkers worden opgewarmd voordat de klok start. Elke trede — inclusief de pandas-baseline — wordt volledig getimed over alle 80 combo's. BLAS (Apple's Accelerate) is vastgezet op één thread, zodat de single-threaded tredes werkelijk single-core zijn: de numpy-trede multithreadt niet stiekem zijn matvecs achter de rug van de vergelijking om.
  • Equivalentiecontrole: na de timing wordt de vector (PnL, aantal trades) per combinatie van elke trede vergeleken met de referentie — het aantal trades moet exact overeenkomen, PnL binnen een absolute tolerantie van 10610^{-6} procentpunt. De gecommitte run rapporteert all_ok: true voor elke trede, inclusief de pandas-baseline, op alle 80 combo's. Faalt deze controle, dan is er geen benchmark — dan zijn er gewoon vijf programma's die vijf verschillende dingen berekenen met vijf verschillende snelheden, en zo werken veel "onze engine is 100x sneller"-claims in stilte.

Eén getal uit het equivalentieblok verdient een moment eerlijkheid: de fingerprint voor de eerste combinatie is een PnL van −5165,58 procentpunt over 57.029 trades. Dat is geen strategieresultaat om je voor te schamen — het is de kortste HMA-lengte (6) die bij vrijwel elke rimpeling van een random walk omslaat en daar telkens 0,09% voor betaalt, precies zoals het hoort. Het is een correctheidsfingerprint, geen verhandelbare backtest. Zoek er geen alpha in; zoek er determinisme in — vijf implementaties die uitkomen op dezelfde 57.029 trades en dezelfde PnL tot op zes decimalen, dat is wat "identiek" hier betekent.

Met dat vastgesteld is elke versnelling hieronder pure snelheid. Er is niets wegbenaderd.

Trede M0: het naïeve pandas-profiel — 69,9 s

Anatomy of the naive pandas baseline: a rolling.apply window spawning a Python lambda call for every one of 150,000 bars while the interpreter loop crawls beneath it

De baseline is geen stroman. Het is de code die je krijgt wanneer je een WMA schrijft zoals de pandas-documentatie voorstelt, en de event-loop zoals de strategiebeschrijving leest:

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

Waarom is dit traag? Niet omdat pandas "slecht" is — maar door waar de iteratie zich bevindt. rolling(period).apply(lambda ...) is een loop op Python-niveau in een gevectoriseerd jasje. Voor elk van de 150.000 bars materialiseert pandas een venster, steekt de C/Python-grens over, roept een Python-callable aan en boxt het resultaat. Zelfs met raw=True (dat de lambda tenminste een kale ndarray geeft in plaats van een Series) overschaduwt de overhead per aanroep ruimschoots de paar tientallen tot honderden FLOP's die het venster daadwerkelijk nodig heeft. Vermenigvuldig dat met zeven WMA-doorgangen per combinatie, en de indicatorstapel alleen al is miljoenen interpreter-heen-en-weertjes. Vervolgens loopt de bar-loop nog eens 150.000 geïnterpreteerde iteraties per combinatie, elk met bounds-checked indexering op numpy-scalars, het boxen van floats, en dynamische dispatch op types die de interpreter iedere keer opnieuw ontdekt.

Het resultaat: 69,92 s voor de sweep, ongeveer 0,87 s per combinatie, een doorvoer van 1,1 combinaties per seconde. Bij een grid van 80 combinaties haal je je schouders op en wacht je een minuutje. Het probleem is dat niemand grids van 80 combinaties draait om ze klein te houden — en deze kosten schalen voor altijd lineair. Daar komen we op terug.

Trede M1: numpy — stop met Python aanroepen in een loop — 3,07 s, 22,7x

De eerste trede omhoog elimineert beide interpreterloops tegelijk, en het loont om de twee trucs afzonderlijk te bekijken, want ze verschillen sterk in generaliseerbaarheid.

De indicatorkant is de makkelijke, volledig algemene. Een gewogen voortschrijdend gemiddelde over alle vensters is gewoon een matrix-vectorproduct tegen een strided view van de invoer — geen kopieën, één BLAS-aanroep:

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 bouwt een (n − p + 1, p)-view op hetzelfde geheugen, en win @ w berekent het puntproduct van elk venster in gecompileerde code. De miljoen lambda-aanroepen worden één library-aanroep.

De tradekant is de interessante, want de event-loop is stateful — en toch, voor deze kernel, valt hij te vectoriseren. Het inzicht is dat de positie op elke bar alleen afhangt van het teken van HMA − HMA3, niet van enig trade-resultaat. Toestand voedt nooit terug naar beslissingen. Dus de hele loop valt samen tot "vind de tekenwissels, verzamel de prijzen op die 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, een versnelling van 22,7x, 26,0 combinaties per seconde — op één core, met BLAS vastgezet op één thread. Deze trede verdient een label: het is de degelijke baseline, de implementatie die een sterke numpy-programmeur zou uitleveren, en de eerlijke maatstaf voor alles daarboven. Maar twee eerlijke kanttekeningen horen bij deze trede.

Ten eerste is deze vectorisatie een strategiespecifieke analytische herschrijving, geen mechanische transformatie. Ze bestaat omdat de kernel stop-and-reverse is zonder stops, zonder trailing exits, zonder positiegrootte die afhangt van lopende PnL. Voeg een stop-loss toe — de meest gewone functie die je je kunt voorstellen — en de exit op bar ii verandert welke entry bestaat op bar j>ij > i, toestand voedt terug in het pad, en de gesloten vorm verdampt. De meeste productiekernels bevinden zich aan de verkeerde kant van die grens.

Ten tweede is dit de trede waar correctheid sneuvelt. De boekhouding rond de flip-indices (+1 hier, [:-1] daar, het zaaien van de eerste richting) is precies het soort code dat off-by-one uitvoeringsfouten veroorzaakt — hetzelfde soort fout dat onze look-ahead-taxonomie liet zien een Sharpe van 15 uit ruis kan fabriceren. De equivalentiecontrole is op deze trede geen formaliteit; het is de enige reden om haar te vertrouwen. Slimme gevectoriseerde herschrijvingen zonder equivalentiecontrole tegen een domme referentie-implementatie zijn hoe engines wegdrijven van de strategie die ze beweren te testen.

Trede M2: numba — compileer de loop die je eigenlijk wilt schrijven — 1,98 s, 35,3x

A Python event loop passing through the numba JIT compiler and emerging as tight machine code: the same branchy bar-by-bar logic, compiled instead of interpreted

Trede M2 hanteert de omgekeerde filosofie: in plaats van het algoritme te verwringen om in gevectoriseerde primitieven te passen, schrijf je de naïeve loops — en compileer je ze. Numba (Lam, Pitrou & Seibert, 2015) JIT-compileert een numeriek subset van Python via LLVM naar machinecode:

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

De event-loop binnen nb_sweep is tekstueel dezelfde loop als M0. Branches, continue, toestand bewaard in lokale variabelen — allemaal. Onder @njit leven die lokale variabelen in registers, zijn de branches echte jump-instructies, en dalen de kosten per iteratie van microseconden interpreter-dispatch naar nanoseconden.

1,98 s — 35,3x ten opzichte van pandas, maar slechts ongeveer 1,6x ten opzichte van numpy (afgeleid: 3.07/1.98). Die bescheiden stap is op zichzelf al leerzaam: de binnenste loops van numpy waren al gecompileerd, dus de winst van numba op de feature-wiskunde beperkt zich tot het overslaan van venstermaterialisatie en tussenliggende arrays. Het transformatieve deel zit elders:

  1. De event-loop is nu gratis — en "gratis" is gemeten, niet retorisch. M1 stak zijn vernuft in het vectoriseerbaar maken van de tradelogica. M2 maakt dat vernuft overbodig — de naïeve, controleerbare, makkelijk aan te passen loop draait op machinesnelheid. Door de feature-fase apart te timen van de trade-loop binnen deze gecompileerde kernel blijkt 99,3% van de tijd toe te schrijven aan de WMA-feature-wiskunde en slechts 0,7% aan de stateful event-loop. Je kunt morgen een stop-loss toevoegen zonder een researchproject — en houd die verdeling vast; ze bepaalt het GPU-argument hieronder opnieuw.
  2. Het ontsluit de volgende twee tredes. Een gecompileerde, GIL-vrijgevende, allocatie-arme kernel is de werkeenheid die parallelle orkestratie nodig heeft. Je kunt M0 niet productief parallelliseren — twaalf kopieën van traag zijn nog steeds traag, alleen warmer.

Eén methodologische kanttekening: numba compileert bij de eerste aanroep, en die compilatie (honderden milliseconden) mag niet binnen de timer vallen — de harness warmt de JIT op met een slice van 500 bars voordat de meting begint, en cache=True bewaart gecompileerde kernels over processtarten heen. Benchmarks die dit detail "vergeten" leveren numba-getallen op die ofwel oneerlijk slecht zijn (koude compilatie meegerekend) ofwel niet reproduceerbaar.

Trede M3: prange — de parallellie die je al had — 0,32 s, 217,6x

Eighty independent parameter combos fanned out across twelve CPU cores: performance and efficiency cores pulling unequal window lengths in parallel

Hier komt de observatie die massale parametersearch bijzonder maakt: de 80 combinaties zijn volledig onafhankelijk. Geen gedeelde toestand, geen volgorde, geen communicatie. Dit is werk dat zich uitzonderlijk goed leent voor parallellisatie, en dat de tredes M0–M2 uit pure gewoonte op één core van de twaalf lieten draaien.

Numba maakt de oplossing bijna syntactisch — vervang de range van de combinatie-loop door 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

Omdat nb_sweep in nopython-modus gecompileerd is, houdt hij geen GIL vast, en spreidt de threading-laag van numba de iteraties over alle 12 cores. De read-only array close wordt door alle threads gedeeld tegen nulkosten.

0,32 s — 217,6x ten opzichte van pandas, 248,9 combinaties per seconde. De stap ten opzichte van de single-threaded M2 is ongeveer 6,2x op 12 cores (afgeleid: 1.98/0.32), en het tekort ten opzichte van "ideaal 12x" verdient het om eerlijk benoemd te worden in plaats van verstopt: de 12 cores van de M2 Max zijn 8 performance- + 4 efficiency-cores, dus het nominale plafond was nooit 12x; de 80 combinaties hebben zeer ongelijke kosten (een HMA van lengte 6 is veel goedkoper dan één van lengte 200), waardoor threads ongelijk afronden; en elke kernelaanroep alloceert zijn tussenliggende arrays uit een gedeelde allocator. Parallelle versnellingen op echte machines zien er zo uit. Wie een schone Nx-op-N-cores citeert voor heterogene taken, meet iets synthetisch.

Trede M4: een procespool voor het laatste derde — 0,23 s, 297,9x

De laatste trede vervangt threads door processen — dezelfde gecompileerde kernel, georkestreerd door een 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 ten opzichte van pandas, 340,9 combinaties per seconde. Lees die doorvoer nog eens: deze laptop voert nu ongeveer 340 volledige backtests van 150.000 bars per seconde uit, elk met de berekening van zeven gewogen voortschrijdende gemiddelden en de simulatie van tienduizenden stateful trades.

De voorsprong op prange is reëel maar bescheiden — ongeveer 1,4x (afgeleid: 0.32/0.23) — en de plausibele mechanismen zijn scheduling en geheugenisolatie: met chunksize=1 verdeelt de pool combinaties één voor één, zodat de ongelijke mix van goedkope en dure vensters dynamisch over de asymmetrische cores load-balanced, en elk workerproces krijgt zijn eigen allocator, waardoor contentie op de tussenliggende data per combinatie wordt omzeild. We presenteren dit als mechanismen die consistent zijn met de meting, niet als afzonderlijk bewezen feiten.

Processen zijn niet gratis, en de harness betaalt hun kosten eerlijk buiten de timer, waar het eenmalige kosten zijn (workerstart, het versturen van close naar elke worker via de initializer, JIT-opwarming per worker) — want in een echte search worden die kosten afgeschreven over duizenden combinaties, niet tachtig. De eerlijke algemene richtlijn: prange is eenvoudiger en meestal voldoende; een procespool wint wanneer taken brokkelig zijn, het grid groot is, of je werk per combinatie ergens de GIL vasthoudt waar numba niet bij kan.

En daarmee valt de ladder uiteen in een heldere samenvatting. Van M0 naar M2 — de engine: 35,3x op één core, door iteratie uit de interpreter te halen. Van M2 naar M4 — de orkestratie: nog eens 8,4x (afgeleid: 1.98/0.23), door de cores te gebruiken die er al waren. Vermenigvuldigd: 298x. Geen nieuwe hardware, identieke resultaten. En gemeten vanaf de degelijke M1-baseline in plaats van de naïeve, staat de uiteindelijke engine nog steeds zo'n 13x hoger (afgeleid: 3.07/0.23) — de ladder is geen artefact van het kiezen van een trage startpositie.

Waarom geen GPU — de eerlijke versie

A GPU sitting idle beside a saturated CPU: batchable moving-average math left on the CPU because a sweep of eighty combos and a quarter of a second is too narrow and too short to pay for the trip

"Zet het gewoon om naar een GPU" is de meest voorkomende reactie op een trage parametersweep, dus meet dit experiment de twee getallen waar dat gesprek mee zou moeten beginnen — en geen van beide onderbouwt de luie versie van beide antwoorden.

Het roofline-model (Williams, Waterman & Patterson, 2009) classificeert een kernel op basis van zijn rekenintensiteit — FLOP's per verplaatste byte. Voor de WMA-featurestapel in deze sweep, met 2p2p FLOP's per bar per venster van lengte pp tegenover één 8-byte read per bar, komt de hele sweep van 80 combinaties uit op ongeveer 6,2 GFLOP over 576 MB gestreamde data:

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

(Dat is de geïdealiseerde telling over de zes verschillende WMA-vensters per combinatie; het tellen van de zeven daadwerkelijk uitgevoerde doorgangen geeft 11,07 FLOP/byte. Dezelfde conclusie in beide gevallen.)

Dat getal is van belang vanwege wat het uitsluit: de populaire claim dat backtestwiskunde "geheugengebonden is, dus GPU's kunnen niet helpen" is hier onwaar. Bij ~10,8 FLOP/byte is de feature-wiskunde overduidelijk reken-georiënteerd — ruim voorbij het knikpunt waar typische hardware ophoudt bandbreedtegebonden te zijn. Een GPU zou 80 combinaties × 7 WMA-doorgangen zeker kunnen bundelen in een handvol grote kernels en door de rekenwerk heen malen. Als de featurestapel het hele probleem was, zou de GPU-optie respectabel zijn.

Het tweede gemeten getal fnuikt het andere luie antwoord — het antwoord waar wijzelf ook naar gegrepen zouden hebben. Door de feature-fase apart te timen van de trade-loop binnen de gecompileerde kernel ontstaat een verdeling van 99,3% features, 0,7% event-loop. Het verleidelijke argument — "backtests hebben een stateful, vertakte event-loop, en dat is wat de GPU blokkeert" — is hier kwantitatief onjuist: de CPU besteedt vrijwel al zijn tijd precies aan het deel dat een GPU wel zou kunnen bundelen. Herformuleer 80 combinaties × 7 WMA-doorgangen als grote gebatchte convoluties en je hebt een volstrekt redelijke tensorwerklast. De eerlijke vraag is dus niet of het werk naar een GPU zou kunnen — het merendeel zou dat kunnen. De vraag is of de trip loont, en voor deze sweep is dat niet zo, om twee specifieke redenen:

1. De benutbare breedte is 80 combinaties — en een GPU is een breedtemachine. De ene eerlijke as van parallellie in een parametersweep is het grid zelf: binnen één combinatie is het pad van 150.000 bars sequentieel. Een GPU wil tienduizenden onafhankelijke werkitems om zijn lanes te vullen en latentie te verbergen; deze sweep biedt er tachtig. Twaalf CPU-cores verzadigen die breedte al — dat is letterlijk wat de tredes M3–M4 gemeten hebben. Voor de aantallen combinaties waarbij de breedte van een GPU zelfs maar zou beginnen mee te tellen, levert de CPU-ladder al honderden volledige backtests per seconde.

2. De hele job duurt 0,23 seconden. Bij M4-snelheid kost een combinatie ongeveer 2,9 ms (afgeleid: 0.23 s / 80). Tegen dat budget zijn kernel-launch-latenties en device-synchronisatiepunten geen afrondbare marginale kosten — ze vormen een aanzienlijk deel van de job. (Op deze Apple-machine met unified memory is host-naar-device-transfer een kleine zorg; op een discrete CUDA-GPU-machine komt dat er nog bovenop.) De klassieke GPU-winst schrijft vaste overheadkosten af over enorme batches werk; een sweep van minder dan een seconde levert die nooit op.

En de event-loop? Dat is het ene deel dat niet zou bundelen — serieel, vertakt, padafhankelijk, een loop-gebonden afhankelijkheid van 150.000 bars lang die geen enkele hardware binnen één combinatie kan parallelliseren, met precies de divergente branches die SIMT-lanes verafschuwen. Een GPU-portering zou dit op de CPU laten staan of één lane per combinatie draaien. Maar bij 0,7% van de kernel is het een Amdahl-term die te klein is om iets te beslissen. Het is het deel dat niet zou meegaan; het is niet de reden om niet te gaan. (Herinner uit trede M1 dat voor terugkoppelingsvrije kernels de loop zelfs analytisch te vectoriseren valt — de herschrijving die je verliest zodra de strategie een stop krijgt.)

Eén platformkanttekening voor de volledigheid: op deze machine (Apple Silicon) zou het GPU-pad MLX of PyTorch-MPS zijn, geen CUDA — cupy en het CUDA-ecosysteem zijn simpelweg niet van toepassing — en beide zouden een herschrijving van het hete pad in een tensor-dialect vereisen om het experiment zelfs maar te proberen. Dat is een reële kost zonder, volgens de analyse hierboven, geïdentificeerde opbrengst voor de vorm van deze sweep. De GPU-discussie hier is analytisch, gegrond in de gemeten rekenintensiteit en de gemeten feature/loop-verdeling, en we benoemen het als zodanig: er is geen CUDA-run uitgevoerd omdat dat op de gerapporteerde hardware niet mogelijk was.

De samenvattende zin die we in een review zouden verdedigen: bijna al dit werk zou naar een GPU kunnen; deze sweep is te smal en te kort om de trip te laten lonen. En lees dat in beide richtingen — het is geen afschrijving. De gebatchte "grote-matrix"-herformulering — de sweep herschrijven als grote tensorbewerkingen over duizenden combinaties tegelijk, of een echt terugkoppelingsvrije kernel die end-to-end bundelt — is een reële en veelbelovende richting die een eigen studie verdient, geen verwerping. Bij 80 combinaties en 0,23 seconden heeft het simpelweg het ticket nog niet verdiend. Als jouw workload die breedte heeft, verandert de rekensom, en zou je haar opnieuw moeten doen — niet ons citeren.

Waar het echte knelpunt zit: engine en orkestratie

The real bottleneck revealed: an hourglass where the engine and the orchestration of thousands of parameter combos choke the flow, not the hardware underneath

Tachtig combinaties is een demonstratiegrid. Echte parametersearch is waar deze factoren ophouden academisch te zijn, want grids groeien multiplicatief: vier parameters met elk tien waarden levert 10410^4 combinaties op; voeg walk-forward-validatie toe met een dozijn folds en je zit al op 1,2×1051,2 \times 10^5 volledige backtests voordat je nog maar iets verkend hebt. Dit is de vloek van de dimensionaliteit, en het is waarom searchstrategie — Optuna, coordinate descent, Sobol — zoveel aandacht krijgt: slimmer zoeken bezoekt minder punten.

Maar de ladder legt de andere, minder besproken helft van de vergelijking bloot: de kosten per bezocht punt. Door de gemeten doorvoer lineair te extrapoleren (combinaties zijn onafhankelijk, dus dit is rekenkunde, geen modellering):

Gridgrootte Bij M0 (1,1 combo's/s) Bij M4 (340,9 combo's/s)
10.000 combo's ~2,4 uur ~30 seconden
100.000 combo's ~24 uur ~5 minuten

Hetzelfde experiment dat op de naïeve engine een batchjob van een hele nacht is, is op de getunede versie een interactieve query. Dat verschil telt door op een manier die wall-clock-tabellen onderschatten: bij 5 minuten per sweep itereer je — je draait opnieuw met een gedicht lek, je voegt een fold toe, je verbreedt het grid, je test het idee dat je tijdens de lunch kreeg. Bij 24 uur per sweep doe je dat niet. De snelheid van de engine bepaalt het tempo van de researchloop, en het tempo van de researchloop is het eigenlijke product.

Er is ook een Amdahl's-law-lezing van de hele ladder:

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

Het versnellen van één enkele fase pp met factor ss wordt begrensd door alles wat verder traag blijft. De ladder respecteerde die volgorde: de engine-winst van 35,3x pakte de term aan die domineerde (geïnterpreteerde iteratie, zowel in de featurestapel als de loop), en de orkestratiewinst van 8,4x pakte de term aan die daarna domineerde (elf inactieve cores). De feature/loop-verdeling is dezelfde les in het klein — we hadden de werkelijke vorm van het GPU-argument niet kunnen benoemen zonder te meten waar de tijd daadwerkelijk naartoe ging. Eerst profileren, dan optimaliseren — in die volgorde. Dezelfde logica geldt voor de datalaag stroomopwaarts van de engine: onze Polars vs pandas-benchmarks vonden hetzelfde patroon (10–3500x op gegroepeerde rolling-pipelines) voor de laad-en-transformeerhelft van de stack, en dezelfde hybride conclusie — kolomgeoriënteerde engines voor de pipeline, een gecompileerde kernel voor de padafhankelijke simulatie.

Twee eerlijkheidskanttekeningen om de generaliseerbaarheidscirkel te sluiten. Ten eerste is dit experiment bewust op zichzelf staand en synthetisch — geseede data, één kernel, één bekendgemaakte machine — zodat iedereen het fenomeen deterministisch kan reproduceren; de wall-clock-getallen zullen op jouw hardware verschillen, maar de equivalentie en de richting van de ladder niet. Ten tweede is het fenomeen geen artefact van de synthetische opzet: de benchmark van onze productie-HMA-engine (bench_param_sweep.py, gedraaid op echte beursdata met het volledige productie-fee- en fillmodel) toont dezelfde laddervorm, waarbij het numba-pad ongeveer 100–200x boven het naïeve pandas-profiel uitkomt. Het op zichzelf staande experiment bestaat zodat je onze productiegetallen niet op goed vertrouwen hoeft aan te nemen.

Belangrijkste conclusies

  1. De ladder is 298x, en hij ontbindt: 35,3x engine × 8,4x orkestratie. Iteratie uit de interpreter halen (pandas → numba) en onafhankelijke combinaties over cores spreiden (één → twaalf) vermenigvuldigden zich tot een versnelling van bijna drie ordes van grootte, op een ongewijzigde laptop. 69,92 s → 0,23 s; 1,1 → 340,9 combo's/s. En het is geen artefact van een trage baseline: tegenover de degelijke gevectoriseerde numpy-implementatie is de uiteindelijke engine nog altijd ~13x sneller.
  2. Eis equivalentie voordat je snelheid bewondert. Elke trede hier levert identieke PnL en aantal trades per combinatie, automatisch gecontroleerd op alle 80 combinaties (absolute tolerantie van 10610^{-6} op PnL, exact op trades). Een snelle engine die iets subtiel anders berekent, is niet snel — die is fout met hoge doorvoer, en gevectoriseerde herschrijvingen zijn waar die fout er meestal insluipt.
  3. @njit verslaat slimme vectorisatie voor stateful logica. De numpy-trede vereiste een strategiespecifieke gesloten vorm die sneuvelt zodra je een stop-loss toevoegt. De numba-trede compileert de naïeve, controleerbare loop — dezelfde snelheidsklasse, geen van de kwetsbaarheid, en het is de eenheid die parallelliseert.
  4. Het GPU-antwoord is "niet voor deze sweep" — om redenen die je moet kunnen benoemen. De feature-wiskunde is reken-georiënteerd (10,78 FLOP/byte) en vormt 99,3% van de gecompileerde kernel, dus overleeft noch "backtests zijn geheugengebonden" noch "de stateful loop domineert" de meting. De eerlijke redenen zijn breedte en budget: 80 combinaties aan benutbare parallellie die 12 CPU-cores al verzadigen, en een totale job van 0,23 s die launch- en synchronisatie-overhead zou opsouperen. De gebatchte grote-matrix-herformulering op echte breedte blijft een veelbelovende richting, geen weerlegde.
  5. Enginesnelheid is researchtempo. Bij naïeve-engine-doorvoer is een search van 100.000 backtests een dag werk; op de doorvoer van de bovenkant van de ladder is het vijf minuten. Controleer, voordat je hardware koopt of een cluster huurt, of je knelpunt überhaupt silicium is — het onze was een lambda binnen rolling.apply en elf inactieve cores.

Het volledige experiment — alle vijf implementaties, de equivalentieharness, de roofline-berekening, en elk getal in dit artikel opnieuw te genereren vanuit één deterministisch script — staat in de bijbehorende paper op speed-ladder.marketmaker.cc, met code en data op github.com/suenot/backtest-speed-ladder.

De sweep die zeventig seconden duurde, duurt nu een kwart van één. Dezelfde trades, dezelfde PnL, dezelfde laptop. De GPU die je op het punt stond aan te vragen kan wachten; de interpreterloop die je op het punt stond uit te leveren, kan dat niet.

Disclaimer: De informatie in dit artikel is uitsluitend bedoeld voor educatieve en informatieve doeleinden en vormt geen financieel, beleggings- of handelsadvies. Het handelen in cryptovaluta brengt een aanzienlijk risico op verlies met zich mee.

Auteurs

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

Blijf de markt voor

Abonneer je op onze nieuwsbrief voor exclusieve AI-handelsinzichten, marktanalyses en platformupdates.

We respecteren je privacy. Je kunt je op elk moment afmelden.