RCA — diagnostic__s42 full run hang (CVN-N001-EI-S04)¶
Statut : DRAFT r2 (revue opérateur foldée — root cause CONFIRMÉE par signature CPU Prometheus). Une fois validé → lié au hub S04, puis le fix. Date incident : 2026-06-01 → 2026-06-02 · Story : CVN-N001-EI-S04 (wp#227) · Run :
diagnostic__s42(Block 2, capacité LGB),crypto_group=defi_top5 / fold_id=3.
1. Résumé (TL;DR)¶
Le run scientifique diagnostic__s42 s'est figé (livelock) : les 5 cell-pods sont restés Running ~16 h sans produire un seul fit. Root cause (CONFIRMÉE) : aucun num_threads n'est pinné dans les fits LightGBM de l'ablation → chaque pod spawn 16 threads OpenMP (tous les cœurs du nœud) alors que le cgroup le throttle à 4 cœurs ; avec 5 pods concurrents les barrières OpenMP busy-wait. Confirmé par Prometheus : les 5 pods brûlent ~3.2 cœurs en continu, 0 progrès, 12h — signature busy-wait/livelock (ni idle=deadlock, ni 1-cœur=boucle Python). Impact : ~16 h de nœud compute brûlées, aucun verdict status-B produit. Le run a été tué manuellement (Console).
2. Impact¶
- 0 verdict : la Story S04 (
In testing) n'a pas obtenu son verdict status-B → gateIn testing → Testednon franchi. - ~16 h de nœud PRO2-M compute occupé pour rien (67 % mem / 64 % cpu en requests, 0 progrès).
- Aucune corruption de données (read-only diagnostic, ADR-2) ; aucun impact production trading.
3. Timeline (cellule OPUSDC, représentative)¶
| Heure (UTC, 2026-06-01) | Event |
|---|---|
| ~17:30 → 19:37 | Phase A cold-capture (~2 h) : s18_step0_training_done elapsed_s=7433 (BacktestEngine LdP loop, 17857 bougies) |
| 19:37:37 | Phase A FAIL : s18_step0_verdict status=FAIL observed=0.3645 expected=0.3331 abs_delta=0.0314 > epsilon=0.005 → s18_step0_failed next_action=ESCALATE reason=local_replay_diverges_from_canonical |
| 19:37:46 | s41_pin_hit + s22a1_parquet_loaded (n_train=9969) + s22a1_hp_resolved (NUM_LEAVES=31, …) |
| 19:37:46 → +16 h | SILENCE — l'ablation capacité (625 fits) ne produit aucun event. Pod Running, log plat. |
| 2026-06-02 (mid-day) | Loki : 0 activité s42 en 6 h. 5 pods toujours Running (16 h). Run tué (Console). |
Les 5 pods : dernier log entre 19:10 et 20:48 le 2026-06-01, plat ensuite.
4. Root cause¶
Oversubscription de threads OpenMP × throttling cgroup, dans la boucle de fit LGB de l'ablation.
grep n_jobs|num_threads|OMP_NUM_THREADSsurs42_lgb_capacity_ablation.py+dag_diagnostic__s42.py→ aucun thread-pinning._train_lgb_with_override(appelé par_probe_axispour chaque point × seed) ne fixe pasnum_threads→ LightGBM prend tous les cœurs du nœud (16).- Nœud PRO2-M = 16 cœurs. 5 cell-pods concurrents, chacun limité à 4 cœurs par le cgroup pod (
limits.cpu=4). - 5 × 16 = 80 threads OpenMP pour 16 cœurs physiques, chaque pod throttlé à 4 cœurs. Les threads OpenMP d'un pod se synchronisent sur des barrières mais sont throttlés par le cgroup → attente mutuelle → livelock (pas « lent » : zéro fit complété en 16 h).
Pourquoi livelock et pas juste lent : une barrière OpenMP attend tous les threads ; quand le cgroup throttle agressivement N threads pour 4 cœurs, la barrière ne se libère quasi jamais dans un budget CPU réaliste → throughput effondré à ~0. Pathologie OpenMP-in-cgroup connue.
Confirmation par signature d'exécution (cross-check ADR-0093 §0bis — pas juste le grep)¶
Le grep prouve que la vulnérabilité existait ; la signature CPU prouve qu'elle a tiré. Prometheus rate(container_cpu_usage_seconds_total{pod=~"diagnostic-s42-discriminate-cell.*"}[5m]) sur la fenêtre de hang (2026-06-01 20:00 → 2026-06-02 08:00) :
| Pod | CPU cores (mean, 12h) |
|---|---|
| cell-2uygvxyn / 6xoom9xx / mng992do / rwkmzcxl / w2z0vn4a | 3.19 / 3.17 / 3.19 / 3.19 / 3.18 |
~3.2 cœurs brûlés en continu (sur limite 4), 0 progrès, 12h. Discrimination : ~0 = deadlock I/O (exclu), ~1.0 = boucle Python GIL (exclu), ~saturé multi-cœur + 0 progrès = busy-wait OpenMP livelock (la cause). La cause réelle est confirmée par l'exécution, pas inférée du code disponible. Les 5 pods montrent la MÊME signature (3.17-3.19) → systématique, pas un fluke d'une cellule — ça scelle l'attribution (la vulnérabilité de threading frappe tous les pods identiquement).
5. Facteurs contributifs¶
skip_phase_a=falseavecuse_pin=true→ Phase A a tourné en cold ~2 h/cellule alors que le pin S07 était dispo (s41_pin_hit). Gaspillage + a déclenché le FAIL d'ancre. (N'est PAS la cause du hang, mais a allongé + bruité l'incident.) À reconsidérer (runbook s43) : siuse_pin=trueET pin présent, le défaut devrait peut-être êtreskip_phase_a=trueplutôt que compter sur l'opérateur à le mettre — le cold-inutile-puis-FAIL est un piège répétable.- Pas de timeout de tâche Airflow sur
discriminate_cell→ le pod hung n'a jamais été tué automatiquement (16 h). - Pas de progress-logging par fit dans la fenêtre : le hang était invisible jusqu'à inspection manuelle (le dernier event utile est
s22a1_hp_resolved, puis rien).
6. Finding séparé (à ne pas confondre avec le hang)¶
Phase A replay divergence : s18_step0_verdict status=FAIL observed=0.3645 expected=0.3331 abs_delta=0.0314 (epsilon 0.005). Le replay local du f1 canonique diverge de 0.031 → la reproductibilité du replay Phase A est en cause. Contourné par le pin S07 (le verdict s42 utilise le fold pinné, pas le replay), donc non-bloquant pour s42. MAIS : abs_delta=0.031 sur un f1 ≈ 0.33 = ~9% d'écart relatif — pas marginal. Même classe de divergence replay-vs-canonique que celle qui rôde depuis les FTF runs (repro Phase A). À traiter comme une investigation reproductibilité à part entière (A6), pas juste « contourné par le pin ».
7. Résolution¶
Fix PRIMAIRE = caps de threads ENV-LEVEL (tout-process), pas un param modèle. num_threads LGB ne contrôle que l'OpenMP de LightGBM ; le pod fait aussi du numpy/scipy/sklearn (bootstrap, E(θ) vectorisé, precision_recall_fscore_support, …) qui passe par BLAS (OpenBLAS/MKL) avec ses propres threads (défaut = tous les cœurs). Donc pinner seulement LGB laisse la porte ouverte au même livelock par les threads BLAS. Caps env au démarrage du pod (s42 et s43) :
OMP_NUM_THREADS=4
OPENBLAS_NUM_THREADS=4
MKL_NUM_THREADS=4
NUMEXPR_NUM_THREADS=4
OMP_WAIT_POLICY=passive
num_threads=4 dans les params LGB devient le belt-and-suspenders, pas le fix primaire. PR de correctif (s42 In testing → pas de push main direct).
Critique pour s43 : le workload s43 = B=2000 resamples × theta_curve = massivement numpy → les threads BLAS sont LE contributeur dominant, pas LGB. Un cap LGB-seul sur s43 ne préviendrait PAS le livelock → l'invariant A3 doit être env-level, sinon raté d'un cran.
Défense en profondeur : ajouter OMP_WAIT_POLICY=passive (env pod) → les threads OpenMP dorment aux barrières au lieu de busy-wait. Conséquence : même si l'oversubscription recurrait (mauvais num_threads, nouveau code path), la dégradation serait un slowdown visible, pas un livelock invisible. Belt-and-suspenders sur un mode de défaillance qui a déjà coûté 16h.
Contention nœud résiduelle (à acter) : même avec num_threads=4, 5 pods × 4 = 20 threads sur un nœud 16 cœurs. Le cgroup garantit le budget par-pod (barrières intra-pod résolues) → plus de livelock, mais slowdown résiduel ~25%. Choix explicite : (a) accepter (~25% slower, OK) ; (b) pod anti-affinity (étaler sur plusieurs nœuds, 0 contention) ; (c) num_threads=3 (5×3=15≤16, mais gaspille 1 cœur du budget/pod). Recommandé : (a) pour le verdict one-shot, (b) si on industrialise.
Re-trigger corrigé : skip_phase_a=true (le warm pin suffit, évite les ~2 h cold/cellule).
8. Prévention / action items¶
| # | Action | Owner |
|---|---|---|
| A0 | ✅ DONE — confirmer la signature CPU (Prometheus) avant de coder le fix. Résultat : ~3.2 cœurs/pod, 0 progrès, 12h → busy-wait livelock CONFIRMÉ (≠ deadlock idle, ≠ boucle 1-cœur). Le fix num_threads n'est PAS un placebo. |
dococeven |
| A1 | Fix s42 : pinner num_threads=<pod cpu limit> dans _train_lgb_with_override (PR) |
dococeven |
| A2 | Re-trigger s42 (skip_phase_a=true) après A1 = confirmation finale de la cause. L'attribution OpenMP/BLAS reste inférentielle (pas de thread-dump libgomp, pods tués) ; la preuve directe = le fix qui marche. Si le re-trigger hang ENCORE malgré les caps env → le busy-wait n'était pas (uniquement) celui qu'on pense → ROUVRIR, ne pas assumer le fix. |
dococeven |
| A3 | Invariant — concret + testable + ENV-LEVEL → ADR-0096. (i) ADR-0096 règle exacte : « tout pod compute CPU-bound DOIT poser des caps de threads env-level (OMP_NUM_THREADS, OPENBLAS_NUM_THREADS, MKL_NUM_THREADS, NUMEXPR_NUM_THREADS) = cgroup CPU limit, ET OMP_WAIT_POLICY=passive » — pas « num_threads dans les params du modèle » (qui rate les threads BLAS, dominants en numpy-lourd). (ii) Tests, à deux niveaux (un unit test ne tourne pas dans le pod → pas d'accès à la cgroup limit runtime) : unit (I7) = asserter que les caps sont explicitement settés depuis une config/env (pas None, pas défaut, pas hardcodé 16) dans le chemin de fit ; smoke/integration = la valeur effective ≤ cgroup limit observée (pod réel). Modèle : le spy call_count==0 de S03/Q1.g qui pète si on ré-enfreint. |
dococeven |
| A4 | Task timeout Airflow sur discriminate_cell (s42/s43). ⚠️ doit couvrir le worst-case LÉGITIME incluant le fallback cold (pin miss → ~2h cold/cellule + ablation) → un 6-8h tuerait un run lent-mais-sain. ~10-12h (généreux, accepte qu'un hang brûle jusqu'au timeout) ou timeout conditionnel (plus long si cold-path). À chiffrer en tenant compte du fallback. |
dococeven |
| A5 | Progress-logging par fit (event=sNN_fit_progress) → un hang devient visible en Loki |
dococeven |
| A6 | Replay divergence Phase A (s18_step0, abs_delta=0.031 ≈ 9% relatif sur f1≈0.33 — pas marginal). Premier pas concret : vérifier le déterminisme seed de s18_step0_replay (2 runs identiques → même f1 ?) ; suspects = seed non figé, ordre de la BacktestEngine LdP loop, fenêtre de données canonical-vs-replay. Lier au thème repro FTF (même classe que la divergence replay-vs-canonique qui rôde depuis les FTF runs). |
dococeven |
9. Evidence¶
- Pods :
kubectl get pods→ 5 ×diagnostic-s42-discriminate-cell-*Running 16h. Dernier log 19:10-20:48 (2026-06-01), plat ensuite. - Loki :
{namespace="cvntrade"} |~ "s42_|s18_step0|class_balance_applied"since 6h → 0 ligne. - Code :
grep -n "num_threads|n_jobs|OMP_NUM_THREADS"surs42_lgb_capacity_ablation.py+ DAG → aucun match. - Log pod :
s18_step0_training_done elapsed_s=7433,s18_step0_verdict status=FAIL ... abs_delta=0.0314,s22a1_hp_resolvedà 19:37:46, puis silence. - Prometheus (A0, décisif) :
sum by(pod) (rate(container_cpu_usage_seconds_total{namespace="cvntrade",pod=~"diagnostic-s42-discriminate-cell.*"}[5m]))sur 2026-06-01 20:00 → 2026-06-02 08:00 → ~3.2 cœurs/pod constant, 12h, 0 progrès = busy-wait livelock.