mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-25 15:08:03 +00:00
release: v0.13.0
This commit is contained in:
@@ -0,0 +1,152 @@
|
|||||||
|
# Plan — Séries dérivées hoistées sur GPU (« option C ») (2026-07-17)
|
||||||
|
|
||||||
|
Objectif : permettre aux ops fenêtrées du transpileur (`ZScore`, `RollingMean`,
|
||||||
|
`Rsi`, `Roc`, futurs `RollingStd/Sum/...`) de prendre une **expression** en
|
||||||
|
entrée, pas seulement une colonne brute — ce qui débloque l'exemple 16
|
||||||
|
(`zscore(price_ratio - hr_ratio, zwin)`) et toute la famille « indicateur d'un
|
||||||
|
spread/ratio » sur GPU.
|
||||||
|
|
||||||
|
## Le problème (analysé le 2026-07-17, voir la discussion du rapport)
|
||||||
|
|
||||||
|
Le kernel évalue en **streaming** : les intermédiaires vivent une barre dans des
|
||||||
|
registres. Une op fenêtrée doit évincer la valeur qui sort de la fenêtre
|
||||||
|
(`sum -= x[jj-w]`) : pour une colonne (`CCOL`) ou une série hoistée (`HIND`)
|
||||||
|
c'est un accès mémoire ; pour une expression dérivée, la valeur d'il y a `w`
|
||||||
|
barres a été jetée. Le CPU n'a pas ce problème : son évaluateur matérialise
|
||||||
|
chaque nœud du DAG en array.
|
||||||
|
|
||||||
|
Seul `EwmMean` accepte une expression aujourd'hui — une EMA ne regarde jamais
|
||||||
|
en arrière. `RollingMean`, `Rsi`, `ZScore` exigent tous une colonne brute ;
|
||||||
|
cette limite est **préexistante et structurelle**, pas un choix de mon ZScore.
|
||||||
|
|
||||||
|
## Le design
|
||||||
|
|
||||||
|
**Matérialiser l'expression d'entrée comme lignes `hind` supplémentaires**,
|
||||||
|
dédupliquées par résolution de paramètres, remplies par un kernel généré ; l'op
|
||||||
|
fenêtrée reste un **fold in-thread** (par combo) qui lit la ligne dérivée en
|
||||||
|
accès aléatoire (`HINDAT(j, jj)` / `HINDAT(j, jj-w)`).
|
||||||
|
|
||||||
|
```
|
||||||
|
stage 1 hoist_fill : indicateurs feuilles (SMA/EMA/RSI/ROC/ZScore sur colonnes)
|
||||||
|
stage 2 derived_fill : expressions POINTWISE sur (cols ∪ lignes stage 1) ← NOUVEAU
|
||||||
|
sim sweep_t : folds fenêtrés in-thread lisant les lignes dérivées ← ÉTENDU
|
||||||
|
```
|
||||||
|
|
||||||
|
Décisions clés :
|
||||||
|
|
||||||
|
- **Éligibilité** : l'entrée doit être pointwise sur (colonnes brutes ∪ feuilles
|
||||||
|
hoistables ∪ littéraux ∪ params). Sinon → CPU, raison nommée (Phase 0 style) :
|
||||||
|
« windowed op over a non-pointwise expression ».
|
||||||
|
- **Dédup** : clé = empreinte structurelle de l'expression + bits résolus de
|
||||||
|
CHAQUE param référencé (vecteur, pas un seul u64 — une expression peut lire
|
||||||
|
plusieurs params). Même mécanique HashMap que `build_hoist_plan`. Nombre de
|
||||||
|
lignes = résolutions distinctes de l'ENTRÉE (ex. 16 : #smooth distincts, PAS
|
||||||
|
#(smooth × zwin) — la fenêtre reste par combo, in-thread). C'est ce qui rend
|
||||||
|
la VRAM raisonnable à 1M combos.
|
||||||
|
- **`derived_fill` est un kernel GÉNÉRÉ** (comme `sweep_t`), pas un kernel fixe :
|
||||||
|
le corps pointwise vient du MÊME `PipeCtx::emit` que le sim (mêmes sémantiques
|
||||||
|
NaN, division-par-zéro → NaN, etc. — zéro seconde implémentation qui dérive).
|
||||||
|
Pointwise ⇒ barres indépendantes ⇒ un thread par (ligne, barre), coalescé,
|
||||||
|
~1 ms pour 1000 lignes × 44k barres. Lancé entre hoist_fill et sweep_t.
|
||||||
|
- **Chaînage limité en V1** : dérivé = pointwise uniquement. Un fenêtré DANS un
|
||||||
|
fenêtré sur expression (`zscore(sma(expr))`) → CPU, raison nommée. (Extensible
|
||||||
|
plus tard en étages topologiques ; hors périmètre V1.)
|
||||||
|
- **Folds in-thread** : sémantique `*_no_nulls` de bt-expr AU COMPLET, y compris
|
||||||
|
`nan_count` — voir P0 ci-dessous. État ZScore in-thread ≈ 4-6 registres.
|
||||||
|
- **fp32 scan** : lignes dérivées calculées en f64, stockées f32 en mode scan
|
||||||
|
(même argument que le float-hind de la Part 5 : identique au cast côté
|
||||||
|
lecteur).
|
||||||
|
- **Garde VRAM** : bytes(hind + derived) > free/2 → CPU, raison nommée.
|
||||||
|
- **Multi-asset** : hors V1 (comme l'exo), raison nommée.
|
||||||
|
|
||||||
|
## Bit-identité (la chaîne de preuve)
|
||||||
|
|
||||||
|
CPU : matérialise l'entrée (array) puis `zscore_no_nulls(vals)`. GPU : la ligne
|
||||||
|
dérivée doit être **bit-identique à l'array CPU** (mêmes ops pointwise dans le
|
||||||
|
même ordre via le même emit(), fmad off, mêmes sources : cols identiques,
|
||||||
|
lignes stage-1 déjà pinnées à bt-expr) ; puis le fold in-thread refait
|
||||||
|
exactement `zscore_no_nulls` sur les mêmes valeurs. Chaque maillon testé.
|
||||||
|
|
||||||
|
## Étapes
|
||||||
|
|
||||||
|
### P0 — Bug latent découvert par l'analyse (à faire AVANT tout)
|
||||||
|
Le fold SMA **in-thread** (`ss += CCOL(...)`, chemin non-hoisté = multi-asset)
|
||||||
|
n'a AUCUNE gestion NaN, alors que `rolling_mean_pair` (bt-expr) exclut les NaN
|
||||||
|
de ses sommes (`bad_a`/`bad_b`). Divergence silencieuse sur données à trous en
|
||||||
|
multi-asset — même famille que `804de5e`, invisible parce que les tests NaN
|
||||||
|
tournent en mono (hoisting actif) et que le e2e multi n'a pas de NaN.
|
||||||
|
- Auditer les 3 folds in-thread (SMA / RSI / ROC) contre bt-expr sur NaN.
|
||||||
|
- Test : e2e multi avec NaN closes (étendre `gpu_sweep_multi_e2e`), vérifier
|
||||||
|
qu'il MORD (rituel du bug délibéré), corriger les folds.
|
||||||
|
- Estimation : ½ journée. Indépendant de C mais bloquant : les folds dérivés
|
||||||
|
réutiliseront ce code.
|
||||||
|
|
||||||
|
### P1 — Enregistrement des lignes dérivées (host)
|
||||||
|
- `PipeCtx` : détection d'éligibilité (fn `is_pointwise_over_leaves`),
|
||||||
|
empreinte, `derived_slot(expr) -> dj`, table `derived: Vec<DerivedSpec>`.
|
||||||
|
- `build_hoist_plan` étendu : résolution par combo des params de chaque
|
||||||
|
expression dérivée, dédup, `hrow` étendu aux lignes dérivées, garde VRAM.
|
||||||
|
|
||||||
|
### P2 — Kernel `derived_fill` généré + wiring
|
||||||
|
- Génération du source (corps par ligne via emit(), switch sur le kind de
|
||||||
|
ligne), compile NVRTC (cache par hash), launch stage 2, slot dans
|
||||||
|
`MBT_GPU_PROBE`.
|
||||||
|
- `ptxas -v` sur le kernel généré (attendu trivial : pointwise, pas d'état).
|
||||||
|
|
||||||
|
### P3 — Folds in-thread sur lignes dérivées
|
||||||
|
- Macro `HINDAT(j, idx)` (HIND à index arbitraire).
|
||||||
|
- `ZScore` d'abord (débloque ex. 16), puis `RollingMean`, `Rsi`, `Roc` :
|
||||||
|
entrée Column → chemins existants ; entrée dérivée-éligible → fold in-thread
|
||||||
|
avec sémantique bt-expr complète.
|
||||||
|
- **GATE ptxas** : le sim est à 48/48 registres. Si le fold in-thread fait
|
||||||
|
spiller, relâcher `__launch_bounds__` UNIQUEMENT pour les sources générées qui
|
||||||
|
contiennent des folds in-thread (le bound est par source générée, donc par
|
||||||
|
sweep) — mesurer, pas supposer.
|
||||||
|
|
||||||
|
### P4 — Tests (chaque test vérifié MORDANT via bug délibéré)
|
||||||
|
- `derived_fill_matches_bt_expr` : ligne dérivée vs array intermédiaire bt-expr,
|
||||||
|
barre par barre, NaN gaps inclus (harnais `hoist_fill_matches_bt_expr` étendu).
|
||||||
|
- Fold in-thread vs bt-expr : zscore(expr) complet, barre par barre.
|
||||||
|
- e2e forme exemple 16 (exo + spread + zscore, périodes en params) :
|
||||||
|
bit-identité CPU==GPU + non-vacuité + appel direct `run_sweep_lite_gpu`
|
||||||
|
(une retombée = échec, pas une comparaison CPU-vs-CPU silencieuse).
|
||||||
|
- Retombées nommées : non-pointwise, fenêtré imbriqué, VRAM, multi-asset.
|
||||||
|
|
||||||
|
### P5 — Bench + rapport
|
||||||
|
- Exemple 16 réel (hashrate), grille ~10k : cible GPU >> CPU (aujourd'hui :
|
||||||
|
2 589 c/s CPU, GPU retombe). Section dans `gpu_funding_bench.md`.
|
||||||
|
- Mettre à jour `docs/gpu-sweep-coverage-plan.md` (la Phase 2b absorbe C).
|
||||||
|
|
||||||
|
## Estimation & risques
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| P0 | ½ j (correction comprise si le bug se confirme) |
|
||||||
|
| P1+P2 | ~1 j |
|
||||||
|
| P3 | ½ j + gate registres |
|
||||||
|
| P4+P5 | ½ j |
|
||||||
|
| **Total** | **~2-2,5 jours** |
|
||||||
|
|
||||||
|
Risques : (1) pression registres des folds in-thread — gate ptxas, bounds par
|
||||||
|
source ; (2) explosion du nombre de lignes dérivées si une expression référence
|
||||||
|
plusieurs params à forte cardinalité — garde VRAM + retombée nommée ; (3) toute
|
||||||
|
divergence NaN — couverte par les tests barre-par-barre ancrés bt-expr, jamais
|
||||||
|
par des miroirs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## STATUT : LIVRÉ (2026-07-17)
|
||||||
|
|
||||||
|
- P0 : fold SMA in-thread corrigé (bad-count miroir de `rolling`), RSI/ROC
|
||||||
|
audités conformes ; e2e `gpu_multi_sma_nan_volume_{2,5}` vérifié mordant.
|
||||||
|
- P1-P2 : `DerivedReg`/`derived_slot`, plan étendu (dédup multi-params, hrow
|
||||||
|
absolu, garde VRAM sur le total), `derived_fill` généré par le même emit
|
||||||
|
(macros remappées), `hoist_fill` a un stride explicite.
|
||||||
|
- P3 : fold ZScore in-thread sur ligne dérivée via `HINDAT` ; GATE ptxas :
|
||||||
|
bounds relâchés à 7 (f64) / 8 (fp32) blocs pour les sources à folds, zéro
|
||||||
|
spill ; fold-free inchangé (48 regs @10).
|
||||||
|
- P4 : `derived_fill_matches_bt_expr` (mord sur +1e-12 que l'e2e ne voit pas),
|
||||||
|
e2e bit-identité + retombée nommée fenêtré-imbriqué.
|
||||||
|
- P5 : exemple 16 réel, 102x à 30k combos, Part 11 du rapport.
|
||||||
|
- Reste (hors V1, repris en Phase 2b) : `RollingMean`/`Rsi`/`Roc` sur entrée
|
||||||
|
dérivée (mécanique identique au ZScore), multi-asset, chaînage fenêtré.
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les trois couches qui bloquent une stratégie
|
||||||
|
|
||||||
|
1. **Ops transpileur manquants** : `Eq, Lag, Lead, RollingStd, RollingSum,
|
||||||
|
RollingMin, RollingMax, Scan` (→ Kalman et tout indicateur à état custom).
|
||||||
|
2. **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.
|
||||||
|
3. **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.warn` une fois par sweep, et champ
|
||||||
|
`profile["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)
|
||||||
|
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)
|
||||||
|
`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.py` passe 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`](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 via `state_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 (gros ; décision APRÈS Phase 0)
|
||||||
|
|
||||||
|
`orders.is_empty()` est probablement le garde-fou le plus coûteux en usage réel
|
||||||
|
(hypothèse à confirmer par les données de la Phase 0). Résolution intra-bar
|
||||||
|
SL/TP = high/low + sémantique de priorité déjà fixée à la 0.12.0 côté lite CPU.
|
||||||
|
Design séparé — ne pas commencer avant d'avoir les données de fréquence et le
|
||||||
|
merge des phases précédentes.
|
||||||
|
|
||||||
|
## 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 :
|
||||||
|
1. **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).
|
||||||
|
2. **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 → bloque `run_batch`
|
||||||
|
sur toute stratégie à indicateurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invariants transverses (toutes phases)
|
||||||
|
|
||||||
|
1. **Bit-identité fp64 CPU==GPU** : chaque phase ajoute ses cas au harnais e2e
|
||||||
|
(`to_bits` par métrique). Les checksums 21-métriques du bench servent de
|
||||||
|
non-régression (`14fabff006fda0a0` fp64 / `c1be6db787e2f49f` fp32 sur la
|
||||||
|
grille de référence).
|
||||||
|
2. **Parité ancrée à bt-expr**, jamais à un miroir (leçon Part 5).
|
||||||
|
3. **ptxas -v à chaque changement de kernel** (cap 48 regs, spills interdits).
|
||||||
|
4. **Un garde-fou ne se retire qu'avec le test qui prouve le remplacement.**
|
||||||
|
5. **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 |
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "manifoldbt"
|
name = "manifoldbt"
|
||||||
version = "0.12.3"
|
version = "0.13.0"
|
||||||
description = "Rust-powered backtesting engine for quantitative research"
|
description = "Rust-powered backtesting engine for quantitative research"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""manifoldbt: Fast research backtesting with Rust core + Python DSL."""
|
"""manifoldbt: Fast research backtesting with Rust core + Python DSL."""
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
|
||||||
import importlib as _importlib
|
import importlib as _importlib
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ from manifoldbt._native import (
|
|||||||
run_json,
|
run_json,
|
||||||
run_sweep as _run_sweep_native,
|
run_sweep as _run_sweep_native,
|
||||||
run_sweep_lite as _run_sweep_lite_native,
|
run_sweep_lite as _run_sweep_lite_native,
|
||||||
|
sweep_columns as _sweep_columns_native,
|
||||||
run_with_parquet,
|
run_with_parquet,
|
||||||
py_run_walk_forward as _run_walk_forward_native,
|
py_run_walk_forward as _run_walk_forward_native,
|
||||||
py_run_sweep_2d as _run_sweep_2d_native,
|
py_run_sweep_2d as _run_sweep_2d_native,
|
||||||
@@ -869,7 +870,8 @@ def run_sweep_lite(
|
|||||||
store: DataStore,
|
store: DataStore,
|
||||||
*,
|
*,
|
||||||
max_parallelism: int = 0,
|
max_parallelism: int = 0,
|
||||||
device: str = "cpu",
|
device: str = "auto",
|
||||||
|
precision: str = "fp64",
|
||||||
) -> List["BatchResultLite"]:
|
) -> List["BatchResultLite"]:
|
||||||
"""Run a parameter sweep returning only metrics (no Arrow output).
|
"""Run a parameter sweep returning only metrics (no Arrow output).
|
||||||
|
|
||||||
@@ -882,14 +884,47 @@ def run_sweep_lite(
|
|||||||
config: Backtest configuration.
|
config: Backtest configuration.
|
||||||
store: Data store.
|
store: Data store.
|
||||||
max_parallelism: Maximum threads. 0 = all available cores.
|
max_parallelism: Maximum threads. 0 = all available cores.
|
||||||
device: ``"cpu"`` (default) or ``"cuda"``/``"gpu"``. The GPU path
|
device: ``"auto"`` (default), ``"cpu"``, or ``"cuda"``/``"gpu"``.
|
||||||
accelerates single-asset, AtClose + FixedBps sweeps and produces
|
The GPU path produces results numerically identical to the CPU
|
||||||
results numerically identical to the CPU path. **Pro-only**: a
|
path. ``"auto"`` picks per sweep: small grids run on the CPU (the
|
||||||
Community license raises ``PermissionError`` for ``device="cuda"``
|
GPU has a ~50 ms fixed launch floor, so the CPU wins below ~1,000
|
||||||
(Community keeps the full-speed CPU sweep with no restriction).
|
combos -- override with ``MBT_GPU_AUTO_MIN_COMBOS``), large grids
|
||||||
Requires a build with ``--features cuda`` and a CUDA device; for any
|
run on the GPU when the build, a device, and a Pro license are
|
||||||
unsupported strategy/config (or when no GPU is present at runtime) it
|
available, and the CPU otherwise. This is the default because it is
|
||||||
silently falls back to the CPU sweep, so results are never affected.
|
never slower than the better of the two by more than the launch
|
||||||
|
floor and its results match the CPU bit-for-bit, so it is safe to
|
||||||
|
leave on: with no GPU, no Pro license, or a Community build it is
|
||||||
|
simply the CPU sweep. **Pro-only**: a Community license raises
|
||||||
|
``PermissionError`` for ``device="cuda"`` (``"auto"`` simply stays
|
||||||
|
on the CPU; Community keeps the full-speed CPU sweep with no
|
||||||
|
restriction). ``"cuda"`` requires a build with ``--features cuda``
|
||||||
|
and a CUDA device; for any unsupported strategy/config (or when no
|
||||||
|
GPU is present at runtime) it falls back to the CPU sweep with a
|
||||||
|
``UserWarning`` naming the reason, so results are never affected.
|
||||||
|
An unknown device string raises ``ValueError`` instead of silently
|
||||||
|
running on the CPU.
|
||||||
|
precision: ``"fp64"`` (default) runs the GPU sweep in double precision,
|
||||||
|
bit-identical to the CPU path. ``"fp32"`` runs the single-asset GPU
|
||||||
|
kernel in single precision at the cost of approximate results: a
|
||||||
|
signal within ~1e-7 relative of a decision threshold can flip vs f64,
|
||||||
|
so occasional combos diverge. Intended as a **scan-only** accelerator
|
||||||
|
(rank in fp32, re-run the winner in fp64 for an exact P&L). Note the
|
||||||
|
speedup is modest (~1.1x measured on an RTX 3090): the per-bar
|
||||||
|
capital/position recurrence is latency-bound, so fp32's throughput
|
||||||
|
advantage barely applies. ``"fp32"`` requires ``device="cuda"``.
|
||||||
|
|
||||||
|
Metric resolution:
|
||||||
|
The lite path computes risk metrics from one equity point per UTC day
|
||||||
|
(this is what makes it fast), whereas :func:`run` uses the full-resolution
|
||||||
|
curve. ``final_equity``, ``total_return``, ``sharpe``, ``sortino``,
|
||||||
|
``volatility`` and ``max_drawdown`` are unaffected -- they match ``run``
|
||||||
|
exactly. Three annualisation-sensitive metrics differ slightly because
|
||||||
|
they are derived from the daily series: ``cagr`` (it starts from the
|
||||||
|
first daily equity rather than initial capital), ``calmar`` and
|
||||||
|
``ulcer_index``. The gap is small (< ~0.4% relative on a multi-year daily
|
||||||
|
backtest) and is the same for every sweep regardless of orders. Sort and
|
||||||
|
rank on it freely; for an exact single-figure P&L, re-run the winning
|
||||||
|
combo through :func:`run`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
One :class:`BatchResultLite` per combo (Cartesian product order).
|
One :class:`BatchResultLite` per combo (Cartesian product order).
|
||||||
@@ -911,11 +946,58 @@ def run_sweep_lite(
|
|||||||
store,
|
store,
|
||||||
max_parallelism,
|
max_parallelism,
|
||||||
device,
|
device,
|
||||||
|
precision,
|
||||||
)
|
)
|
||||||
except (ValueError, RuntimeError) as exc:
|
except (ValueError, RuntimeError) as exc:
|
||||||
raise _classify_error(exc) from exc
|
raise _classify_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_columns(
|
||||||
|
batch: List["BatchResultLite"],
|
||||||
|
names: Union[str, List[str]],
|
||||||
|
) -> Union["Any", Dict[str, "Any"]]:
|
||||||
|
"""Extract whole metric columns from a sweep as numpy arrays.
|
||||||
|
|
||||||
|
``result.metrics`` builds a 21-key dict per combo, so reading one metric off
|
||||||
|
a large sweep creates millions of throwaway floats. This walks the results
|
||||||
|
once and copies each requested column straight into a numpy array, which is
|
||||||
|
~20x faster: on a 1M-combo sweep, ~1.1s of extraction becomes ~0.05s.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
batch: The list returned by :func:`run_sweep_lite`.
|
||||||
|
names: One column name, or a list of them. Available: ``final_equity``,
|
||||||
|
``trade_count``, and every :class:`PerformanceMetrics` field
|
||||||
|
(``sharpe``, ``sortino``, ``calmar``, ``max_drawdown``, ``alpha``,
|
||||||
|
``beta``, ``tstat_alpha``, ``total_return``, ``cagr``,
|
||||||
|
``volatility``, ``skewness``, ``kurtosis``, ``tail_ratio``,
|
||||||
|
``omega_ratio``, ``ulcer_index``, ``best_day``, ``worst_day``,
|
||||||
|
``avg_daily_return``, ``pct_positive_days``,
|
||||||
|
``max_drawdown_duration_days``, ``tstat_sharpe``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A single ``np.ndarray`` if ``names`` is a string, else a dict mapping
|
||||||
|
each name to its array. Arrays are float64 and in combo order (the same
|
||||||
|
order as ``batch``), so ``np.argmax``/``argsort`` indices map straight
|
||||||
|
back onto it. ``trade_count`` comes back as float64 like the rest.
|
||||||
|
|
||||||
|
Note:
|
||||||
|
The arrays are read-only views over the returned buffers (no copy). Call
|
||||||
|
``.copy()`` if you need to mutate one.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> batch = mbt.run_sweep_lite(strategy, grid, config, store, device="cuda")
|
||||||
|
>>> sharpe = mbt.sweep_columns(batch, "sharpe")
|
||||||
|
>>> best = batch[int(sharpe.argmax())]
|
||||||
|
"""
|
||||||
|
import numpy as _np
|
||||||
|
|
||||||
|
single = isinstance(names, str)
|
||||||
|
wanted = [names] if single else list(names)
|
||||||
|
raw = _sweep_columns_native(batch, wanted)
|
||||||
|
out = {n: _np.frombuffer(raw[n], dtype=_np.float64) for n in wanted}
|
||||||
|
return out[names] if single else out
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Research API
|
# Research API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -31,13 +31,23 @@ def _collect_params(expr: "Expr", out: Dict[str, Any]) -> None:
|
|||||||
if name not in out:
|
if name not in out:
|
||||||
out[name] = expr._param_meta
|
out[name] = expr._param_meta
|
||||||
for arg in expr._args:
|
for arg in expr._args:
|
||||||
if isinstance(arg, Expr):
|
_collect_params_arg(arg, out)
|
||||||
_collect_params(arg, out)
|
|
||||||
elif isinstance(arg, str):
|
|
||||||
# DynPeriod/DynFloat param name — check global registry
|
def _collect_params_arg(arg: Any, out: Dict[str, Any]) -> None:
|
||||||
from manifoldbt.expr import _param_registry
|
if isinstance(arg, Expr):
|
||||||
if arg in _param_registry and arg not in out:
|
_collect_params(arg, out)
|
||||||
out[arg] = _param_registry[arg]
|
elif isinstance(arg, (list, tuple)):
|
||||||
|
# Scan nodes carry their init/update expressions as LISTS of Exprs;
|
||||||
|
# skipping them silently dropped every param() used inside a scan
|
||||||
|
# ("strategy uses undefined parameters: q" on a swept Kalman).
|
||||||
|
for item in arg:
|
||||||
|
_collect_params_arg(item, out)
|
||||||
|
elif isinstance(arg, str):
|
||||||
|
# DynPeriod/DynFloat param name — check global registry
|
||||||
|
from manifoldbt.expr import _param_registry
|
||||||
|
if arg in _param_registry and arg not in out:
|
||||||
|
out[arg] = _param_registry[arg]
|
||||||
|
|
||||||
|
|
||||||
class Strategy:
|
class Strategy:
|
||||||
|
|||||||
Reference in New Issue
Block a user