14 KiB
Plan — Couverture GPU du sweep (2026-07-17)
Contexte : le sweep GPU fait 300k combos/s (fp64 bit-identique) / 676k (fp32 scan), mais seules les stratégies dans un périmètre étroit en profitent. Tout le reste retombe sur le CPU (~1-20k combos/s) en silence. Ce plan séquence l'élargissement du périmètre, du moins cher au plus cher, avec un gate data-driven au milieu.
État de départ : PR #74 (18 commits GPU) en attente de merge ; branche
perf/python-metrics-extraction (3 commits : dicts directs, sweep_columns,
fix du pool VRAM) prête. Les phases ci-dessous partent au-dessus.
Log de mesures : gpu_funding_bench.md. Toute nouvelle phase y ajoute sa section.
État au 2026-07-17 : TOUTES les phases 0-5 shippées en 0.13.0
Phases 0, 1a, 1b, 2, 3, 4, 5 LIVRÉES (voir chaque section + gpu_funding_bench.md
Parts 0-17). La 0.13.0 est publiée sur PyPI. Il ne reste que le backlog Phase 6.
Reste à faire (Phase 6), chaque cas se nomme quand il retombe sur CPU :
- Brackets multi-asset : le kernel
sweep_t_multin'a pas de bracket (seul le mono l'a, cf. Part 17). Raison nommée : « exit orders on a multi-asset universe (the multi kernel has no bracket) ». - Frais per-venue (2 exemples) : extension du fast path.
- Cross-sectionnel (1) :
sweep_t_multi, restructuration en 2 passes. - MTF (1) : grilles multiples, mapping d'index par timeframe.
- Multi-asset + dérivées (1) : l'exo/dérivée est globale, layout
[n_cols][n_sym][num_bars].
Branches en attente (hors périmètre GPU, à trier) :
feat/import-dataframe(1 commit) : import de barres depuis un DataFrame en mémoire, sans round-trip CSV. Feature réelle, jamais mergée.fix/python-test-suite(2 commits) : branche pytest en CI + aligne les tests golden/sweep. Vaut le coup (la CI ne teste pas pytest aujourd'hui), MAIS rendrait la CI rouge sur letest_sweep_returns_one_result_per_combogolden préexistant tant qu'il n'est pas réglé (plancher de sortie Pro).
Les trois couches qui bloquent une stratégie
- Ops transpileur manquants :
Eq, Lag, Lead, RollingStd, RollingSum, RollingMin, RollingMax, Scan(→ Kalman et tout indicateur à état custom). - Périmètre fast-path (11 conditions) : la plus coûteuse est probablement
orders.is_empty()— toute stratégie avec SL/TP retombe. Aussi : AtClose only, FixedBps only, frais mono-venue, pas de borrow rate, etc. - Garde-fous sweep : MTF, exogène, multi-source, cross-sectionnel, funding multi-asset.
Tri technique de la couche 3 : funding multi = ~15 lignes (terme déjà écrit pour le mono) ; exogène = ~30 lignes (l'exo est ASOF-joint sur la grille de barres au chargement → colonne ordinaire pour le kernel) ; cross-sectionnel = moyen (le kernel multi a déjà tous les symboles dans le thread, restructuration en 2 passes) ; MTF = moyen-lourd ; multi-source = lourd.
Phase 0 — Rendre la retombée visible (≈1 h) — À FAIRE EN PREMIER
Problème : run_sweep_lite(device="cuda") retombe en silence. L'utilisateur
paie Pro, voit 12k combos/s, ne saura jamais que retirer son SL/TP le rendrait
25x plus rapide. Et nous, on priorise les phases suivantes à l'aveugle.
Livrable
- La raison de retombée (déjà construite :
gpu-sweep-unsupported: <raison>) remonte à l'utilisateur :warnings.warnune fois par sweep, et champprofile["gpu_fallback_reason"]sur les résultats. - Les raisons sont des chaînes stables (elles le sont déjà) → agrégeables plus tard en télémétrie si souhaité.
Validation : sweep avec SL/TP → warning avec la raison exacte ; sweep GPU nominal → aucun warning, champ absent ; e2e inchangés.
Gate de décision : après quelques jours d'usage réel (et un tour des exemples/notebooks), la fréquence des raisons décide l'ordre des phases 2-4. Sans données, l'ordre ci-dessous est notre meilleure estimation.
Phase 1 — Quick wins périmètre (≈½ journée)
1a. Funding multi-asset dans sweep_t_multi (~15 lignes, FAIT bb4396c)
Le terme funding est déjà dans sweep_t (mono) : `capital += -position * close
- rate
par barre, par symbole ici. Retirer le garde-fou"multi-asset funding not yet on the GPU kernel"`.
- Validation : colonne funding synthétique injectée, e2e multi bit-identique CPU==GPU (même harnais que le fix funding mono).
1b. Colonnes exogènes dans le sweep GPU (~30 lignes, FAIT 0a695e4)
load_exo_columns ASOF-joint déjà l'exo sur master_timestamps → un
Float64Array de num_bars, indistinguable de close pour le kernel. Étendre
la résolution des col_names du transpileur aux colonnes exo alignées, retirer
!config.exo_data.is_empty() du garde-fou (garder exo_sources/multi-source
exclus).
- Validation : e2e avec exo synthétique bit-identique CPU==GPU ; l'exemple
16_hashrate_exogene.pypasse sur GPU ; NaN de tête d'ASOF (avant le premier point exo) couverts par le test. - Attention : chemin multi-asset — un exo est global (pas par symbole), le
layout
cols [n_cols][n_sym][num_bars]duplique par symbole ou garde-fou multi+exo conservé en V1.
Phase 2 — Ops transpileur faciles (≈½ journée) — FAIT 2026-07-17
Livré : ZScore/RollingStd/RollingSum/RollingMean/Rsi/Roc sur colonne OU expression pointwise (séries dérivées), Eq, RollingMin/Max (hoistés, rescan exact), scan sans param → colonne hôte. Refusé avec raison nommée : Lag/Lead — mur sémantique null-vs-NaN (le CPU garde la position sur cible null, le kernel n'a qu'un encodage) ; il faudrait un canal de validité. Détails : Part 14 du rapport.
ZScore, RollingStd, Eq, Lag, Lead, RollingSum, RollingMin, RollingMax.
Débloque : Bollinger (rolling_std), breakouts (rolling_max/min), signaux à retard (lag), et retire un pan de retombées silencieuses.
ZScore en tête de liste : mesuré 2026-07-17, c'est ce qui bloque
examples/16_hashrate_exogene.py (la Phase 1b a débloqué son exo, mais il bute
maintenant sur indicator ZScore is not supported). ZScore est une op à part
entière dans bt-expr, pas un rolling_std déguisé — je l'avais manquée dans
l'inventaire initial ; c'est le warning de la Phase 0 qui l'a nommée.
Mise à jour 2026-07-17 (suite) : ZScore-sur-colonne est livré (d883edd),
mais l'exemple 16 fait zscore(EXPRESSION) et retombe encore : TOUTES les ops
fenêtrées du transpileur exigent une colonne brute (limite structurelle
préexistante — l'éviction sum -= x[jj-w] exige un accès aléatoire que le
streaming n'a pas ; seule l'EMA, sans regard arrière, accepte une expression).
Le correctif structurel est le plan « séries dérivées » :
gpu-derived-series-plan.md (~2-2,5 j, P0 = bug
NaN latent des folds in-thread multi-asset découvert par l'analyse).
Mise à jour 2026-07-17 (fin de journée) : plan séries dérivées LIVRÉ
(P0-P5). Le bug NaN du fold SMA in-thread est corrigé (e2e multi NaN-volume,
rouge-puis-vert), et zscore(EXPRESSION) tourne sur GPU : l'entrée pointwise
est matérialisée en lignes dérivées (kernel derived_fill généré par le même
emit), le fold z-score lit la ligne via HINDAT in-thread. Exemple 16 réel :
102x à 30k combos (0.43 s vs 43.6 s CPU), bit-identique. Rapport : Part 11
de gpu_funding_bench.md. Reste de la Phase 2 : brancher RollingMean/Rsi/
Roc sur le même chemin dérivé, puis les ops manquantes ci-dessus.
Trou distinct découvert au passage : une stratégie SANS périodes dynamiques
part dans transpile_sizing (pointwise uniquement) et meurt sur le premier
EwmMean, bien avant le pipeline. L'exemple 16 tel quel tombe là-dessus. Peu
grave pour un sweep (on paramètre les fenêtres, donc on passe par
transpile_pipeline), mais à garder en tête pour run_batch.
Règles héritées de la campagne (non négociables)
- Chaque fold miroir bt-expr op-pour-op, y compris NaN : le bug des folds
NaN (Part 5) venait exactement d'un miroir non ancré au moteur.
Pièges connus :
Eq=(a-b).abs() < f64::EPSILON(pas==) ;RollingStd: même formule de Welford/somme que bt-expr, même fenêtre sale. - Tests de parité contre bt-expr (pattern
gpu_fold_nan_gaps_*), jamais contre un miroir du même algorithme. - Hoisting : chaque nouveau kind hoistable rejoint
hoist_fill(kind + period), sinon état par thread viastate_decls— attention au cap 48 registres (ptxas -v systématique, la table des résultats négatifs Parts 4-5 fait foi).
Phase 3 — scan sur GPU → Kalman — FAIT 2026-07-17
Livré : gate 3a passé (bande Kalman dépliée + fold = 94 regs, zéro
spill) ; lower_scan_tape extrait dans bt-expr (une bande, deux exécuteurs) ;
traduction 1:1 ScanOp→CUDA (Eq EPSILON du VM répliqué, ≠ du == array) ;
derived_fill séquentiel par ligne → fenêtré-sur-scan-sweepé OK (dédup par
bits résolus) ; fix param()-dans-scan (has_dynamic_periods + collecteur
Python). Surface (q,w) 1M réelle : 25.8 s. Refus nommés : log/exp, inits
non-pointwise, colonnes à nulls. Phase 5 (dispatch auto) : device="auto",
seuil mesuré 1k combos, Part 15.
Phase 3 (plan d'origine, conservé pour référence)
3a. Sonde registres (30 min, GATE)
Écrire À LA MAIN le kernel Kalman (bande ScanOp dépliée en CUDA), ptxas -v
sm_86. Le kernel sim est à 48/48 registres zéro marge : si la bande scan
spille, le gain s'évapore → stop, on documente, on ne construit pas le
générateur.
3b. Générateur ScanOp → CUDA
La bande CompiledScan.instructions est en SSA plat (24 opcodes scalaires) →
traduction mécanique, 1 ligne CUDA par opcode, branchée sur
state_decls/state_init/body existants.
Sémantiques à mirrorer exactement (source de vérité : evaluator.rs) :
Div: dénominateur== 0.0→ NaN (pas l'inf IEEE)Eq:(a-b).abs() < f64::EPSILON- init :
prev_state[i] = init_arr.value(0), null →0.0 - ordre d'exécution : registre = pointeur d'instruction, writeback après output
3c. param() dans un nœud scan (bug indépendant, petit)
CompiledScan.param_names existe déjà ; c'est la découverte des paramètres de
stratégie qui ne descend pas dans les nœuds scan ("uses undefined parameters:
q"). Fix côté validation/collecte → débloque le sweep de q (la dimension la
plus intéressante du filtre), même sur CPU.
3d. Acceptance
- Parité GPU vs VM bt-expr bit-identique, NaN gaps inclus.
- e2e sweep Kalman CPU==GPU bit-identique.
- La heatmap 1M Kalman (w×en, puis q×en) sur GPU — l'objectif d'origine. Budget attendu : quelques secondes au lieu de 15 min CPU.
- fp32 : vérifier le ranking (le scan ajoute des Div/Sqrt → sensibilité à mesurer, pas à supposer).
Phase 4 : SL/TP sur le fast path GPU (FAIT 2026-07-17)
Livré : brackets (stop-loss / take-profit / trailing) simulés dans le kernel
CUDA sweep_t, transcription 1:1 des phases B1/B0 de sim_fast_lite_core_single
(la référence CPU, elle-même tenue à run() par backtest_orders.rs). Gate
allow_exits sur fast_path_blocker (mono-asset seulement) ; OHL packés en un
buffer (arité cudarc). 5.3x vs CPU à 24k combos, GPU==CPU exact. Le trou le
plus gros de l'audit exemples (5/18) est bouché → exemples 7→10/18 sur GPU.
Deux bugs livrés trouvés en route : signe de max_drawdown inversé sur tout
chemin lite, et ~360 lignes de bracket lite inatteignables. Détail : Part 17.
Design d'origine (conservé) : résolution intra-bar SL/TP = high/low + sémantique de priorité fixée à la 0.12.0 côté lite CPU.
Phase 5 — Dispatch GPU/CPU (à faire À LA FIN)
Mesuré 2026-07-17 : sur une grille de 100 combos, le GPU est 6x plus LENT
que le CPU (882 vs 5 327 combos/s) — les coûts fixes (upload, launch, hoist,
compile) écrasent le gain. À 6 000 combos le GPU gagne 7x. Il existe donc un
seuil de rentabilité, et aujourd'hui l'utilisateur qui passe device="cuda" sur
une petite grille paie pour être ralenti, sans rien qui le prévienne.
Deux options, à trancher le moment venu :
- Bascule automatique sous un seuil (attention :
device="cuda"explicite qui exécute sur CPU, c'est exactement le genre de magie silencieuse que la Phase 0 vient de supprimer — il faudrait le dire). - Warning symétrique : « grille trop petite pour amortir le GPU (N combos, seuil ~M) », et laisser l'utilisateur décider.
Le seuil dépend de num_bars, n_sym et de la complexité du pipeline : à calibrer par mesure, pas à coder en dur (cf. feedback_portability_first : pas de seuil spécifique à une machine). Placé en fin de plan volontairement : c'est du polish, les phases 1-3 débloquent des stratégies entières.
Phase 6 — Backlog (ordre selon Phase 0)
- Cross-sectionnel dans
sweep_t_multi(2 ops, restructuration 2 passes). - Exo en multi-asset (broadcast de la série globale dans chaque slab symbole).
- MTF (grilles multiples, mapping d'index par timeframe).
- Multi-source / cross-exchange (chemin général, lourd).
- Frais par venue, borrow rate, AtOpen (extensions du fast path).
transpile_sizing(chemin sans params) limité au pointwise → bloquerun_batchsur toute stratégie à indicateurs.
Invariants transverses (toutes phases)
- Bit-identité fp64 CPU==GPU : chaque phase ajoute ses cas au harnais e2e
(
to_bitspar métrique). Les checksums 21-métriques du bench servent de non-régression (14fabff006fda0a0fp64 /c1be6db787e2f49ffp32 sur la grille de référence). - Parité ancrée à bt-expr, jamais à un miroir (leçon Part 5).
- ptxas -v à chaque changement de kernel (cap 48 regs, spills interdits).
- Un garde-fou ne se retire qu'avec le test qui prouve le remplacement.
- Chaque phase = sa branche + sa PR + sa section dans le bench log. Livraison incrémentale, pas de méga-branche de 18 commits une deuxième fois.
Estimation d'ensemble
| Phase | Effort | Gain |
|---|---|---|
| 0 — retombée visible | ~1 h | priorisation data-driven + UX Pro |
| 1 — funding multi + exo | ~½ j | 2 familles débloquées, quasi sans risque |
| 2 — 7 ops faciles | ~½ j | Bollinger/breakout/lag sur GPU |
| 3 — scan → Kalman | 1-2 j (gate 30 min) | la stratégie de l'article sur GPU + échappatoire universel |
| 4 — SL/TP | gros | à chiffrer après Phase 0 |