Skip to content

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 → gate In testing → Tested non 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.005s18_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_THREADS sur s42_lgb_capacity_ablation.py + dag_diagnostic__s42.pyaucun thread-pinning. _train_lgb_with_override (appelé par _probe_axis pour chaque point × seed) ne fixe pas num_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

  1. skip_phase_a=false avec use_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) : si use_pin=true ET pin présent, le défaut devrait peut-être être skip_phase_a=true plutôt que compter sur l'opérateur à le mettre — le cold-inutile-puis-FAIL est un piège répétable.
  2. Pas de timeout de tâche Airflow sur discriminate_cell → le pod hung n'a jamais été tué automatiquement (16 h).
  3. 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 relatifpas 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
Le 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" sur s42_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.