← Zurück zu den Artikeln
June 26, 2026
5 min read

Die Backtest-Geschwindigkeitsleiter: 298x auf einer Laptop-CPU, identischer PnL bis zum letzten Trade

Die Backtest-Geschwindigkeitsleiter: 298x auf einer Laptop-CPU, identischer PnL bis zum letzten Trade
#algotrading
#backtest
#performance
#numba
#vectorization
#optimization
Part 1 of 10 · Collection
High-Performance Backtest Engines

Teil der Reihe "Backtests ohne Illusionen".

📄 Dieser Artikel ist zu einer Forschungsarbeit gewachsen. Ein pfadabhängiger Backtest-Kernel wird auf fünf Arten implementiert — vom naiven pandas bis zu einem parallelen numba-Kernel — wobei jede Stufe gegengeprüft wird, um pro Kombination identischen PnL zu erzeugen, sodass sich einzig die Geschwindigkeit unterscheidet. Lies die Arbeit online (interaktive Version + PDF) unter speed-ladder.marketmaker.cc, Code und Daten unter github.com/suenot/backtest-speed-ladder.

Siebzig Sekunden. So lange braucht die naive Referenzimplementierung, um 80 Parameterkombinationen einer Strategie mit gleitendem Durchschnitt über 150.000 Bars zu durchlaufen: pandas rolling().apply() für die Indikatoren, eine schlichte Python-Schleife für die Trades. Es ist das Profil, auf dem ein riesiger Anteil realen Forschungscodes läuft, weil es genau das Profil ist, das entsteht, wenn man die Strategie auf die naheliegende Weise schreibt.

Derselbe Sweep, auf demselben Laptop, der für jede Kombination denselben PnL bis zum letzten Trade erzeugt: 0,23 Sekunden.

Der Abstand zwischen diesen beiden Zahlen — gemessene 298x — ist das Thema dieses Artikels. Kein einziger Prozentpunkt davon kam von neuer Hardware. Keine GPU war beteiligt (auf dieser Maschine ist im CUDA-Sinne gar keine verfügbar). Jede Stufe der Leiter ist dieselbe Strategie, dieselben Daten, dieselben Gebühren, dieselbe Trade-Zahl, verifiziert durch ein Äquivalenz-Gate, das den gesamten Benchmark scheitern lässt, sobald die Ergebnisse einer Implementierung pro Kombination abweichen. Geändert hat sich nur, wie die Arbeit ausgedrückt wird: was im Interpreter läuft, was kompiliert läuft und was parallel läuft. Und weil eine absichtlich langsame Basislinie jede Schlagzeilen-Zahl schmeichelhaft aussehen lässt, noch eine Zahl vorweg: Selbst gegen eine solide vektorisierte numpy-Implementierung — den Code, den ein starker numpy-Programmierer ausliefern würde — ist die fertige Engine immer noch etwa 13x schneller.

Wenn eine Parametersuche langsam ist, greift der Reflex nach größerer Hardware — einer GPU, einem Cluster, einem Cloud-Budget. Die gemessene Realität dieses Experiments zeigt auf etwas viel weniger Glamouröses: Der Engpass war die Engine (eine interpretierte innere Schleife, die pro Fenster Python-Aufrufe macht) und die Orchestrierung (unabhängige Kombinationen seriell auf einem Kern laufen zu lassen). Beides lässt sich an einem Nachmittag beheben, auf der Maschine, die man bereits besitzt, ohne jede Änderung an den Ergebnissen.

Hier die ganze Leiter vorweg. Alles Weitere ist die Anatomie jeder Stufe.

Stufe Implementierung Wall-Time Speedup Kombos/s
M0 pandas: rolling.apply + Python-Bar-Schleife 69,92 s 1,0x 1,1
M1 numpy: WMA mit Sliding-Window + vektorisierte 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 über Kombinationen 0,32 s 217,6x 248,9
M4 Prozess-Pool + numba: Prozesse über Kombinationen 0,23 s 297,9x 340,9

Apple M2 Max (12 Kerne), Python 3.14.6, numpy 2.4.3, numba 0.64.0, BLAS (Accelerate) auf einen Thread festgenagelt, sodass die Single-Thread-Stufen wirklich Single-Core sind. 150.000 Bars × 80 Kombinationen, Best-of-3-Wall-Time, JIT-Aufwärmen ausgeschlossen. Alle Stufen — einschließlich der pandas-Basislinie — vollständig gemessen und verifiziert, dass sie auf allen 80 Kombinationen pro Kombination identischen PnL und identische Trade-Zahlen erzeugen.

Ein Kernel, fünf Implementierungen

Fünf Stufen einer Treppe: derselbe Backtest-Kernel klettert von einer 70-Sekunden-pandas-Basislinie zu einem 0,23-Sekunden-parallelen-numba-Lauf, jede Stufe verifiziert, um identischen PnL zu erzeugen

Damit ein Geschwindigkeitsvergleich überhaupt etwas bedeutet, muss die berechnete Sache exakt festgelegt sein, und jede Implementierung muss nachweislich genau diese berechnen. Also fixiert das Experiment einen Strategie-Kernel und hält ihn über alle fünf Stufen konstant.

Der Kernel ist ein HMA/HMA3-Cross — ein Stop-and-Reverse-System auf zwei gleitenden Durchschnitten vom Hull-Typ. Der Baustein ist der gewichtete gleitende Durchschnitt:

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}

Der Hull Moving Average setzt drei davon zusammen, um Lag zu reduzieren:

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)

und HMA3 ist ein glatteres Geschwister, aufgebaut aus WMAs bei etwa n/6n/6, n/4n/4 und n/2n/2, noch einmal geglättet. Pro Parameterkombination sind das sieben WMA-Durchläufe über sechs verschiedene Fensterlängen — ein echter Indikator-Stack, kein Spielzeug.

Die Handelsregel ist bewusst und nützlich zustandsbehaftet: Die Richtung ist long, wenn HMA unter HMA3 liegt, und andernfalls short; die erste definierte Richtung eröffnet eine Position; bei jedem Cross wird die Position geschlossen, PnL abzüglich einer 0,09%-Round-Trip-Gebühr verbucht und umgekehrt. Die Position trägt über Bars hinweg — was du bei Bar ii tust, hängt vom Zustand ab, der seit dem letzten Cross akkumuliert wurde. Diese Pfadabhängigkeit ist der ganze Sinn des Experiments: Sie ist die Eigenschaft, die Backtests von generischen Dataframe-Pipelines unterscheidet, und (wie wir messen werden) sie erschwert die GPU-Frage — allerdings nicht auf die Weise, die die Folklore behauptet.

Der Rest des Setups, damit du die Zahlen beurteilen kannst:

  • Daten: 150.000 Bars synthetischer geometrischer Brownscher Bewegung, geseedet (seed=42). Die Performance hier ist durch Arraygröße und Fensterlängen gebunden, nicht dadurch, welchen Preispfad man einspeist — und eine synthetische Reihe macht das ganze Experiment deterministisch und für jeden reproduzierbar.
  • Gitter: 80 verschiedene HMA-Längen verteilt über [6,200][6, 200] — sodass der Sweep sowohl günstige Kurzfenster-Kombinationen als auch teure Langfenster-Kombinationen enthält, wie es ein echtes Gitter tut.
  • Timing: Wall-Clock, Best-of-3 pro Stufe, mit JIT-Kompilierung außerhalb des Timers aufgewärmt und Pool-Workern vor dem Start der Uhr aufgewärmt. Jede Stufe — einschließlich der pandas-Basislinie — wird über alle 80 Kombinationen vollständig gemessen. BLAS (Apples Accelerate) ist auf einen einzigen Thread festgenagelt, sodass die Single-Thread-Stufen wirklich Single-Core sind: Die numpy-Stufe multithreadet ihre Matvecs nicht heimlich hinter dem Rücken des Vergleichs.
  • Äquivalenz-Gate: Nach dem Timing wird der (PnL, Trade-Zahl)-Vektor jeder Stufe pro Kombination gegen die Referenz verglichen — die Trade-Zahlen müssen exakt übereinstimmen, der PnL bis auf ein absolutes 10610^{-6} Prozentpunkte. Der committete Lauf meldet all_ok: true für jede Stufe, einschließlich der pandas-Basislinie, auf allen 80 Kombinationen. Scheitert dieses Gate, gibt es keinen Benchmark — dann gibt es nur fünf Programme, die fünf verschiedene Dinge mit fünf verschiedenen Geschwindigkeiten berechnen, und genau so funktionieren viele "unsere Engine ist 100x schneller"-Behauptungen im Stillen.

Eine Zahl aus dem Äquivalenzblock verdient einen Moment der Ehrlichkeit: Der Fingerabdruck für die erste Kombination ist ein PnL von −5165,58 Prozentpunkten über 57.029 Trades. Das ist kein Strategieergebnis, für das man sich schämen müsste — es ist die kürzeste HMA-Länge (6), die bei fast jedem Zucken eines Random Walk umschlägt und jedes Mal 0,09% zahlt, genau wie es soll. Es ist ein Korrektheits-Fingerabdruck, kein handelbarer Backtest. Lies kein Alpha hinein; lies Determinismus hinein — fünf Implementierungen, die auf denselben 57.029 Trades und demselben PnL auf sechs Nachkommastellen landen, ist es, was "identisch" hier bedeutet.

Damit festgelegt ist jeder Speedup unten reine Geschwindigkeit. Nichts wurde wegapproximiert.

Stufe M0: das naive pandas-Profil — 69,9 s

Anatomie der naiven pandas-Basislinie: ein rolling.apply-Fenster, das für jeden der 150.000 Bars einen Python-Lambda-Aufruf erzeugt, während die Interpreter-Schleife darunter kriecht

Die Basislinie ist kein Strohmann. Es ist der Code, den man erhält, wenn man eine WMA so schreibt, wie die pandas-Dokumentation vorschlägt, und den Event-Loop so, wie sich die Strategiebeschreibung liest:

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

Warum ist das langsam? Nicht weil pandas "schlecht" wäre — sondern wegen wo die Iteration lebt. rolling(period).apply(lambda ...) ist eine Schleife auf Python-Ebene im vektorisierten Kostüm. Für jeden der 150.000 Bars materialisiert pandas ein Fenster, überquert die C/Python-Grenze, ruft ein Python-Callable auf und boxt das Ergebnis. Selbst mit raw=True (das dem Lambda zumindest ein nacktes ndarray statt einer Series übergibt) überwiegt der Interpreter-Overhead pro Aufruf die ~Dutzende-bis-Hunderte FLOPs, die das Fenster tatsächlich braucht, bei Weitem. Multipliziere mit sieben WMA-Durchläufen pro Kombination, und allein der Indikator-Stack sind Millionen von Interpreter-Hin-und-Rückwegen. Dann läuft die Bar-Schleife weitere 150.000 interpretierte Iterationen pro Kombination, jede mit grenzgeprüftem Indexieren auf numpy-Skalaren, dem Boxen von Floats und dynamischem Dispatching auf Typen, die der Interpreter jedes einzelne Mal neu entdeckt.

Das Ergebnis: 69,92 s für den Sweep, etwa 0,87 s pro Kombination, ein Durchsatz von 1,1 Kombinationen pro Sekunde. Auf einem 80-Kombinationen-Gitter zuckst du mit den Schultern und wartest eine Minute. Das Problem ist, dass niemand lange 80-Kombinationen-Gitter fährt — und diese Kosten skalieren für immer linear. Darauf kommen wir zurück.

Stufe M1: numpy — hör auf, Python in einer Schleife aufzurufen — 3,07 s, 22,7x

Die erste Stufe hinauf eliminiert beide Interpreter-Schleifen auf einmal, und es lohnt sich, die zwei Tricks zu trennen, weil sie sehr unterschiedliche Allgemeinheit besitzen.

Die Indikatorseite ist die einfache, vollständig allgemeine. Ein gewichteter gleitender Durchschnitt über alle Fenster ist nur ein Matrix-Vektor-Produkt gegen eine gestridete Sicht des Inputs — keine Kopien, ein BLAS-Aufruf:

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 baut eine (n − p + 1, p)-Sicht auf denselben Speicher, und win @ w berechnet das Skalarprodukt jedes Fensters in kompiliertem Code. Aus den Millionen Lambda-Aufrufen wird ein einziger Bibliotheksaufruf.

Die Trade-Seite ist die interessante, weil der Event-Loop zustandsbehaftet ist — und dennoch vektorisiert er sich für diesen Kernel. Die Erkenntnis ist, dass die Position an jedem Bar nur vom Vorzeichen von HMA − HMA3 abhängt, nicht von einem Trade-Ergebnis. Der Zustand fließt nie in Entscheidungen zurück. Also kollabiert die gesamte Schleife zu "finde die Vorzeichenwechsel, sammle Preise an diesen Indizes":

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, eine 22,7-fache Beschleunigung, 26,0 Kombinationen pro Sekunde — auf einem Kern, mit BLAS auf einen einzigen Thread festgenagelt. Diese Stufe verdient ein Etikett: Sie ist die solide Basislinie, die Implementierung, die ein starker numpy-Programmierer ausliefern würde, und der faire Maßstab für alles darüber. Aber zwei ehrliche Vorbehalte begleiten diese Stufe.

Erstens ist diese Vektorisierung ein strategiespezifisches analytisches Umschreiben, keine mechanische Transformation. Sie existiert, weil der Kernel Stop-and-Reverse ist, ohne Stops, ohne Trailing-Exits, ohne Positionsgrößen, die vom laufenden PnL abhängen. Füge einen Stop-Loss hinzu — das gewöhnlichste denkbare Feature — und der Exit bei Bar ii ändert, welcher Entry bei Bar j>ij > i existiert, der Zustand fließt in den Pfad zurück, und die geschlossene Form verdampft. Die meisten Produktions-Kernel leben auf der falschen Seite dieser Linie.

Zweitens ist dies die Stufe, auf der die Korrektheit stirbt. Die Flip-Index-Buchhaltung (+1 hier, [:-1] dort, das Seeding der ersten Richtung) ist genau die Art Code, die Off-by-One-Ausführungsfehler produziert — dieselbe Spezies Bug, von der unsere Look-Ahead-Taxonomie zeigte, dass sie aus Rauschen einen Sharpe von 15 fabrizieren kann. Das Äquivalenz-Gate ist auf dieser Stufe keine Formalität; es ist der einzige Grund, ihr zu trauen. Clevere vektorisierte Umschreibungen ohne Äquivalenzprüfung gegen eine dumme Referenzimplementierung sind der Weg, auf dem Engines von der Strategie abdriften, die sie zu testen behaupten.

Stufe M2: numba — kompiliere die Schleife, die du eigentlich schreiben willst — 1,98 s, 35,3x

Ein Python-Event-Loop, der durch den numba-JIT-Compiler geht und als straffer Maschinencode herauskommt: dieselbe verzweigte Bar-für-Bar-Logik, kompiliert statt interpretiert

Stufe M2 verfolgt die gegenteilige Philosophie: Statt den Algorithmus zu verrenken, damit er zu vektorisierten Primitiven passt, schreibe die naiven Schleifen — und kompiliere sie. Numba (Lam, Pitrou & Seibert, 2015) JIT-kompiliert eine numerische Teilmenge von Python über LLVM in Maschinencode:

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

Der Event-Loop innerhalb von nb_sweep ist textuell die M0-Schleife. Verzweigungen, continue, Zustand, der in lokalen Variablen getragen wird — alles davon. Unter @njit leben diese lokalen Variablen in Registern, die Verzweigungen sind echte Sprungbefehle, und die Kosten pro Iteration fallen von Mikrosekunden Interpreter-Dispatch auf Nanosekunden.

1,98 s — 35,3x über pandas, aber nur etwa 1,6x über numpy (abgeleitet: 3,07/1,98). Dieser bescheidene Schritt ist selbst lehrreich: numpys innere Schleifen waren bereits kompiliert, sodass numbas Gewinn bei der Feature-Mathematik darauf beschränkt ist, Fenstermaterialisierung und Zwischenarrays zu überspringen. Der transformative Teil liegt anderswo:

  1. Der Event-Loop ist jetzt kostenlos — und "kostenlos" ist gemessen, nicht rhetorisch. M1 verausgabte seine Cleverness darauf, die Trade-Logik vektorisierbar zu machen. M2 macht diese Cleverness unnötig — die naive, prüfbare, leicht modifizierbare Schleife läuft mit Maschinengeschwindigkeit. Wenn man die Feature-Phase separat vom Trade-Loop innerhalb dieses kompilierten Kernels misst, schreibt es 99,3% seiner Zeit der WMA-Feature-Mathematik und nur 0,7% dem zustandsbehafteten Event-Loop zu. Du kannst morgen einen Stop-Loss hinzufügen, ohne ein Forschungsprojekt — und behalte diese Aufteilung im Hinterkopf; sie entscheidet das GPU-Argument unten neu.
  2. Es schaltet die nächsten zwei Stufen frei. Ein kompilierter, GIL-freigebender, allokationsarmer Kernel ist die Arbeitseinheit, die parallele Orchestrierung braucht. Man kann M0 nicht produktiv parallelisieren — zwölf Kopien von langsam sind immer noch langsam, nur wärmer.

Eine methodische Anmerkung: numba kompiliert beim ersten Aufruf, und diese Kompilierung (Hunderte Millisekunden) darf nicht im Timer sein — der Harness wärmt den JIT auf einem 500-Bar-Slice vor der Messung auf, und cache=True erhält kompilierte Kernel über Prozessstarts hinweg. Benchmarks, die dieses Detail "vergessen", produzieren numba-Zahlen, die entweder unfair schlecht (kalte Kompilierung eingeschlossen) oder nicht reproduzierbar sind.

Stufe M3: prange — die Parallelität, die du schon hattest — 0,32 s, 217,6x

Achtzig unabhängige Parameterkombinationen, ausgefächert über zwölf CPU-Kerne: Performance- und Effizienzkerne ziehen parallel ungleiche Fensterlängen

Hier ist die Beobachtung, die die massenhafte Parametersuche besonders macht: Die 80 Kombinationen sind vollständig unabhängig. Kein geteilter Zustand, keine Reihenfolge, keine Kommunikation. Das ist peinlich parallele Arbeit, die die Stufen M0–M2 aus purer Gewohnheit auf einem von zwölf Kernen liefen.

Numba macht die Behebung nahezu syntaktisch — tausche das range der Kombinationsschleife gegen 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

Weil nb_sweep nopython-kompiliert ist, hält es keinen GIL, und numbas Threading-Schicht fächert die Iterationen über alle 12 Kerne. Das schreibgeschützte close-Array wird von allen Threads zu null Kosten geteilt.

0,32 s — 217,6x über pandas, 248,9 Kombinationen pro Sekunde. Der Schritt über das Single-Thread-M2 ist etwa 6,2x auf 12 Kernen (abgeleitet: 1,98/0,32), und den Fehlbetrag gegenüber "idealen 12x" sollte man ehrlich benennen statt verstecken: Die 12 Kerne des M2 Max sind 8 Performance- + 4 Effizienzkerne, sodass die nominale Obergrenze nie 12x war; die 80 Kombinationen haben wild ungleiche Kosten (ein Länge-6-HMA ist weit günstiger als ein Länge-200-HMA), sodass Threads ungleichmäßig fertig werden; und jeder Kernel-Aufruf allokiert seine Zwischenarrays aus einem geteilten Allokator. Parallele Speedups auf echten Maschinen sehen so aus. Wer sauberes Nx-auf-N-Kernen für heterogene Aufgaben zitiert, misst etwas Synthetisches.

Stufe M4: ein Prozess-Pool für das letzte Drittel — 0,23 s, 297,9x

Die letzte Stufe ersetzt Threads durch Prozesse — derselbe kompilierte Kernel, orchestriert von einem 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 über pandas, 340,9 Kombinationen pro Sekunde. Lies diesen Durchsatz noch einmal: Dieser Laptop führt jetzt rund 340 vollständige 150.000-Bar-Backtests pro Sekunde aus, von denen jeder sieben gewichtete gleitende Durchschnitte berechnet und Zehntausende zustandsbehafteter Trades simuliert.

Der Vorsprung gegenüber prange ist real, aber bescheiden — etwa 1,4x (abgeleitet: 0,32/0,23) — und die plausible Mechanik ist Scheduling und Speicherisolation: Mit chunksize=1 gibt der Pool die Kombinationen einzeln aus, sodass sich der ungleichmäßige Mix aus günstigen und teuren Fenstern dynamisch über die asymmetrischen Kerne lastverteilt, und jeder Worker-Prozess erhält seinen eigenen Allokator, wodurch Konkurrenz um die Zwischenwerte pro Kombination umgangen wird. Wir berichten dies als Mechanik, die mit der Messung konsistent ist, nicht als separat bewiesene Fakten.

Prozesse sind nicht kostenlos, und der Harness zahlt ihre Kosten ehrlich außerhalb des Timers, wo sie einmalige Kosten sind (Worker-Start, Versand von close an jeden Worker über den Initializer, JIT-Aufwärmen pro Worker) — weil sich diese Kosten in einer echten Suche über Tausende von Kombinationen amortisieren, nicht über achtzig. Die ehrliche allgemeine Empfehlung: prange ist einfacher und meist ausreichend; ein Prozess-Pool gewinnt, wenn die Aufgaben klobig sind, das Gitter groß ist oder deine Arbeit pro Kombination irgendwo den GIL hält, wo numba nicht hinreicht.

Und damit zerfällt die Leiter in eine saubere Zusammenfassung. Von M0 zu M2 — die Engine: 35,3x auf einem einzigen Kern, indem man die Iteration aus dem Interpreter herausbewegt. Von M2 zu M4 — die Orchestrierung: weitere 8,4x (abgeleitet: 1,98/0,23), indem man die Kerne nutzt, die schon da waren. Multipliziert: 298x. Keine neue Hardware, identische Ergebnisse. Und gemessen von der soliden M1-Basislinie statt der naiven, steht die fertige Engine immer noch etwa 13x höher (abgeleitet: 3,07/0,23) — die Leiter ist kein Artefakt der Wahl eines langsamen Startpunkts.

Warum keine GPU — die ehrliche Version

Eine GPU, die untätig neben einer gesättigten CPU liegt: batchfähige Mathematik gleitender Durchschnitte bleibt auf der CPU, weil ein Sweep von achtzig Kombinationen und einer Viertelsekunde zu schmal und zu kurz ist, um die Reise zu bezahlen

"Portiere es einfach auf eine GPU" ist die häufigste Antwort auf einen langsamen Parameter-Sweep, also misst dieses Experiment die zwei Zahlen, von denen dieses Gespräch ausgehen sollte — und keine stützt die faule Version einer der beiden Antworten.

Das Roofline-Modell (Williams, Waterman & Patterson, 2009) klassifiziert einen Kernel nach seiner arithmetischen Intensität — FLOPs pro bewegtem Byte. Für den WMA-Feature-Stack in diesem Sweep, wenn man 2p2p FLOPs pro Bar pro Fenster der Länge pp gegen einen 8-Byte-Lesevorgang pro Bar zählt, ergibt der ganze 80-Kombinationen-Sweep etwa 6,2 GFLOP über 576 MB gestreamt:

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

(Das ist die idealisierte Zählung über die sechs verschiedenen WMA-Fenster pro Kombination; zählt man die sieben Durchläufe wie tatsächlich ausgeführt, ergibt das 11,07 FLOP/Byte. Die Schlussfolgerung ist in beiden Fällen dieselbe.)

Diese Zahl ist wichtig wegen dessen, was sie ausschließt: Die populäre Behauptung, Backtest-Mathematik sei "speichergebunden, also können GPUs nicht helfen", ist hier falsch. Bei ~10,8 FLOP/Byte ist die Feature-Mathematik entschieden compute-lastig — weit jenseits des Ridge-Punkts, an dem typische Hardware aufhört, bandbreitenbegrenzt zu sein. Eine GPU könnte durchaus 80 Kombinationen × 7 WMA-Durchläufe in eine Handvoll großer Kernel batchen und sich durch die Arithmetik kauen. Wäre der Feature-Stack das ganze Problem, wäre der GPU-Fall respektabel.

Die zweite gemessene Zahl tötet die andere faule Antwort — die, nach der wir selbst gegriffen hätten. Wenn man die Feature-Phase separat vom Trade-Loop innerhalb des kompilierten Kernels misst, ergibt sich eine Aufteilung von 99,3% Features, 0,7% Event-Loop. Das verlockende Argument — "Backtests haben einen zustandsbehafteten, verzweigten Event-Loop, und der ist es, der die GPU blockiert" — ist hier quantitativ falsch: Die CPU verbringt praktisch ihre gesamte Zeit genau in dem Teil, den eine GPU batchen könnte. Formuliere 80 Kombinationen × 7 WMA-Durchläufe als große gebatchte Faltungen um, und du hast eine völlig vernünftige Tensor-Workload. Die ehrliche Frage ist also nicht, ob die Arbeit auf eine GPU gehen könnte — das meiste davon könnte. Die Frage ist, ob sich die Reise auszahlt, und für diesen Sweep tut sie es nicht, aus zwei spezifischen Gründen:

1. Die ausnutzbare Breite ist 80 Kombinationen — und eine GPU ist eine Breitenmaschine. Die eine ehrliche Achse der Parallelität in einem Parameter-Sweep ist das Gitter selbst: Innerhalb einer Kombination ist der 150.000-Bar-Pfad sequenziell. Eine GPU will Zehntausende unabhängiger Work-Items, um ihre Lanes zu füllen und Latenz zu verstecken; dieser Sweep bietet achtzig. Zwölf CPU-Kerne sättigen diese Breite bereits — genau das haben die Stufen M3–M4 gemessen. Bei den Kombinationszahlen, bei denen eine GPU-Breite überhaupt zu greifen begänne, liefert die CPU-Leiter bereits Hunderte vollständiger Backtests pro Sekunde.

2. Der ganze Job dauert 0,23 Sekunden. Bei M4-Geschwindigkeit kostet eine Kombination etwa 2,9 ms (abgeleitet: 0,23 s / 80). Gegen dieses Budget sind Kernel-Launch-Latenzen und Geräte-Synchronisationspunkte keine amortisierbaren Rundungsfehler — sie sind ein materieller Bruchteil des Jobs. (Auf dieser Apple-Maschine mit einheitlichem Speicher ist der Host-zu-Gerät-Transfer eine geringe Sorge; auf einer diskreten-GPU-CUDA-Box gesellt er sich ebenfalls zur Rechnung.) Der klassische GPU-Gewinn amortisiert feste Overheads über riesige Arbeits-Batches; ein Sub-Sekunden-Sweep produziert nie einen solchen.

Und der Event-Loop? Er ist der eine Teil, der nicht batchen würde — seriell, verzweigt, pfadabhängig, eine schleifengetragene Abhängigkeit von 150.000 Bars Länge, die keine Hardware innerhalb einer Kombination parallelisieren kann, mit genau den divergenten Verzweigungen, die SIMT-Lanes hassen. Ein GPU-Port würde ihn auf der CPU lassen oder eine Lane pro Kombination laufen lassen. Aber bei 0,7% des Kernels ist er ein Amdahl-Term, der zu klein ist, um irgendetwas zu entscheiden. Er ist der Teil, der nicht gehen würde; er ist nicht der Grund, nicht zu gehen. (Erinnere dich aus Stufe M1, dass für feedbackfreie Kernel die Schleife sogar analytisch vektorisiert werden kann — die Umschreibung, die du in dem Moment verlierst, in dem die Strategie einen Stop bekommt.)

Eine Plattform-Fußnote der Vollständigkeit halber: Auf dieser Maschine (Apple Silicon) wäre der GPU-Pfad MLX oder PyTorch-MPS, nicht CUDA — cupy und das CUDA-Ökosystem sind schlicht nicht anwendbar — und beide würden erfordern, den heißen Pfad in einem Tensor-Dialekt umzuschreiben, nur um das Experiment zu versuchen. Das ist ein realer Preis, laut der obigen Analyse ohne identifizierte Auszahlung für die Form dieses Sweeps. Die GPU-Diskussion hier ist analytisch, gestützt auf die gemessene arithmetische Intensität und die gemessene Feature/Loop-Aufteilung, und wir kennzeichnen sie als solche: Kein CUDA-Lauf wurde durchgeführt, weil auf der offengelegten Hardware keiner möglich war.

Der Zusammenfassungssatz, den wir im Review verteidigen würden: Fast die gesamte Arbeit könnte auf eine GPU gehen; dieser Sweep ist zu schmal und zu kurz, als dass sich die Reise auszahlt. Und lies das in beide Richtungen — es ist keine Abschreibung. Die gebatchte "Großmatrix"-Umformulierung — den Sweep als große Tensor-Operationen über Tausende von Kombinationen auf einmal umzuformen, oder ein wirklich feedbackfreier Kernel, der Ende-zu-Ende batcht — ist eine reale und vielversprechende Richtung, die eine eigene Studie verdient, keine Abweisung. Bei 80 Kombinationen und 0,23 Sekunden hat sie das Ticket schlicht noch nicht verdient. Hat dein Workload diese Breite, ändert sich die Arithmetik, und du solltest sie neu machen, nicht uns zitieren.

Wo der eigentliche Engpass liegt: Engine und Orchestrierung

Der eigentliche Engpass enthüllt: eine Sanduhr, in der die Engine und die Orchestrierung Tausender Parameterkombinationen den Fluss abwürgen, nicht die Hardware darunter

Achtzig Kombinationen sind ein Demonstrationsgitter. Echte Parametersuche ist der Ort, an dem diese Faktoren aufhören, akademisch zu sein, weil Gitter multiplikativ wachsen: vier Parameter mit je zehn Werten sind 10410^4 Kombinationen; füge eine Walk-Forward-Validierung mit einem Dutzend Folds hinzu, und du bist bei 1,2×1051,2 \times 10^5 vollständigen Backtests, bevor du überhaupt etwas erkundet hast. Das ist der Fluch der Dimensionalität, und deshalb bekommt die Suchstrategie — Optuna, Koordinatenabstieg, Sobol — so viel Aufmerksamkeit: Klügere Suche besucht weniger Punkte.

Aber die Leiter legt die andere, weniger diskutierte Hälfte der Gleichung offen: die Kosten pro besuchtem Punkt. Extrapoliert man die gemessenen Durchsätze linear (Kombinationen sind unabhängig, also ist das Arithmetik, kein Modellieren):

Gittergröße Bei M0 (1,1 Kombos/s) Bei M4 (340,9 Kombos/s)
10.000 Kombinationen ~2,4 Stunden ~30 Sekunden
100.000 Kombinationen ~24 Stunden ~5 Minuten

Dasselbe Experiment, das auf der naiven Engine ein nächtlicher Batch-Job ist, ist auf der abgestimmten eine interaktive Abfrage. Dieser Unterschied verstärkt sich auf eine Weise, die Wall-Clock-Tabellen unterschätzen: Bei 5 Minuten pro Sweep iterierst du — du lässt mit einem behobenen Leck erneut laufen, fügst einen Fold hinzu, verbreiterst das Gitter, testest die Idee, die dir beim Mittagessen kam. Bei 24 Stunden pro Sweep tust du das nicht. Die Geschwindigkeit der Engine setzt den Takt der Forschungsschleife, und der Takt der Forschungsschleife ist das eigentliche Produkt.

Es gibt auch eine Lesart der ganzen Leiter nach Amdahls Gesetz:

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

Eine einzelne Phase pp um den Faktor ss zu beschleunigen ist durch alles andere begrenzt, das du langsam gelassen hast. Die Leiter respektierte diese Reihenfolge: Der 35,3x-Engine-Gewinn attackierte den dominierenden Term (interpretierte Iteration, im Feature-Stack ebenso wie in der Schleife), und der 8,4x-Orchestrierungs-Gewinn attackierte den danach dominierenden Term (elf untätige Kerne). Die Feature/Loop-Aufteilung ist dieselbe Lektion im Kleinen — wir hätten die eigentliche Form des GPU-Arguments nicht benennen können, ohne zu messen, wohin die Zeit tatsächlich ging. Profilieren, dann optimieren — in dieser Reihenfolge. Dieselbe Logik regiert die Datenschicht stromaufwärts der Engine: Unsere Polars-vs-pandas-Benchmarks fanden dasselbe Muster (10–3500x auf gruppierten Rolling-Pipelines) für die Lade-und-Transformier-Hälfte des Stacks und dieselbe hybride Schlussfolgerung — spaltenorientierte Engines für die Pipeline, ein kompilierter Kernel für die pfadabhängige Simulation.

Zwei Ehrlichkeitsanmerkungen, um den Kreis zur Allgemeinheit zu schließen. Erstens ist dieses Experiment bewusst in sich geschlossen und synthetisch — geseedete Daten, ein Kernel, eine offengelegte Maschine — sodass jeder das Phänomen deterministisch reproduzieren kann; die Wall-Clock-Zahlen werden auf deiner Hardware abweichen, aber die Äquivalenz und die Richtung der Leiter nicht. Zweitens ist das Phänomen kein Artefakt des synthetischen Setups: Der Benchmark unserer Produktions-HMA-Engine (bench_param_sweep.py, ausgeführt auf echten Börsendaten mit dem vollständigen Produktions-Gebühren- und Fill-Modell) zeigt dieselbe Leiterform, wobei der numba-Pfad rund 100–200x über dem naiven pandas-Profil landet. Das in sich geschlossene Experiment existiert, damit du unsere Produktionszahlen nicht auf Treu und Glauben nehmen musst.

Erkenntnisse

  1. Die Leiter ist 298x, und sie faktorisiert: 35,3x Engine × 8,4x Orchestrierung. Iteration aus dem Interpreter herauszubewegen (pandas → numba) und unabhängige Kombinationen über Kerne zu verteilen (einer → zwölf) multiplizierte sich zu einer fast dreistelligen Größenordnungs-Beschleunigung auf einem unveränderten Laptop. 69,92 s → 0,23 s; 1,1 → 340,9 Kombos/s. Und es ist kein Langsame-Basislinie-Artefakt: gegen die solide vektorisierte numpy-Implementierung ist die fertige Engine immer noch ~13x.
  2. Verlange Äquivalenz, bevor du Geschwindigkeit bewunderst. Jede Stufe hier erzeugt identischen PnL und identische Trade-Zahlen pro Kombination, automatisch gegated auf allen 80 Kombinationen (absolute 10610^{-6}-Toleranz auf PnL, exakt auf Trades). Eine schnelle Engine, die etwas subtil Anderes berechnet, ist nicht schnell — sie ist bei hohem Durchsatz falsch, und vektorisierte Umschreibungen sind der Ort, an dem sich die Fehlerhaftigkeit meist einschleicht.
  3. @njit schlägt clevere Vektorisierung für zustandsbehaftete Logik. Die numpy-Stufe brauchte eine strategiespezifische geschlossene Form, die in dem Moment stirbt, in dem du einen Stop-Loss hinzufügst. Die numba-Stufe kompiliert die naive, prüfbare Schleife — dieselbe Geschwindigkeitsklasse, keine der Fragilität, und sie ist die Einheit, die parallelisiert.
  4. Die GPU-Antwort ist "nicht für diesen Sweep" — aus Gründen, die du benennen können solltest. Die Feature-Mathematik ist compute-lastig (10,78 FLOP/Byte) und sie ist 99,3% des kompilierten Kernels, sodass weder "Backtests sind speichergebunden" noch "die zustandsbehaftete Schleife dominiert" die Messung überlebt. Die ehrlichen Gründe sind Breite und Budget: 80 Kombinationen ausnutzbarer Parallelität, die 12 CPU-Kerne bereits sättigen, und ein 0,23 s Gesamtjob, den Launch- und Synchronisations-Overhead auffressen würde. Die gebatchte Großmatrix-Umformulierung bei echter Breite bleibt eine vielversprechende Richtung, keine widerlegte.
  5. Engine-Geschwindigkeit ist Forschungstakt. Bei Naive-Engine-Durchsatz ist eine 100.000-Backtest-Suche ein Tag; bei Leiter-Spitzen-Durchsatz sind es fünf Minuten. Bevor du Hardware kaufst oder einen Cluster mietest, prüfe, ob dein Engpass überhaupt Silizium ist — unserer war ein lambda innerhalb von rolling.apply und elf untätige Kerne.

Das vollständige Experiment — alle fünf Implementierungen, der Äquivalenz-Harness, die Roofline-Berechnung und jede Zahl in diesem Artikel, reproduzierbar aus einem deterministischen Skript — ist im Begleitpapier unter speed-ladder.marketmaker.cc, mit Code und Daten unter github.com/suenot/backtest-speed-ladder.

Der Sweep, der siebzig Sekunden brauchte, braucht eine Viertelsekunde. Dieselben Trades, derselbe PnL, derselbe Laptop. Die GPU, die du gerade anfordern wolltest, kann warten; die Interpreter-Schleife, die du gerade ausliefern wolltest, nicht.

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

Dem Markt einen Schritt voraus

Abonniere unseren Newsletter für exklusive KI-Trading-Einblicke, Marktanalysen und Plattform-Updates.

Wir respektieren deine Privatsphäre. Jederzeit abbestellbar.