denpex-mlflow 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- denpex_mlflow-0.1.0/.gitignore +6 -0
- denpex_mlflow-0.1.0/PKG-INFO +105 -0
- denpex_mlflow-0.1.0/README.md +80 -0
- denpex_mlflow-0.1.0/denpex_mlflow/__init__.py +31 -0
- denpex_mlflow-0.1.0/denpex_mlflow/_client.py +233 -0
- denpex_mlflow-0.1.0/denpex_mlflow/evidence.py +72 -0
- denpex_mlflow-0.1.0/denpex_mlflow/watch.py +116 -0
- denpex_mlflow-0.1.0/pyproject.toml +37 -0
- denpex_mlflow-0.1.0/tests/test_denpex_mlflow.py +288 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: denpex-mlflow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Attach Denpex GPU and training-failure diagnoses to your MLflow runs in one line.
|
|
5
|
+
Project-URL: Homepage, https://denpex.com
|
|
6
|
+
Project-URL: Documentation, https://denpex.com/docs/integrations
|
|
7
|
+
Project-URL: API Reference, https://denpex.com/docs/api
|
|
8
|
+
Project-URL: Python Agent & CLI, https://denpex.com/docs/agent
|
|
9
|
+
Project-URL: VS Code Extension, https://denpex.com/vscode
|
|
10
|
+
Project-URL: Open VSX Extension, https://denpex.com/openvsx
|
|
11
|
+
Project-URL: GPU Failure Encyclopedia, https://denpex.com/failures
|
|
12
|
+
Project-URL: Free GPU Debugging Tools, https://denpex.com/tools
|
|
13
|
+
Project-URL: Changelog, https://denpex.com/changelog
|
|
14
|
+
Project-URL: Repository, https://github.com/Denpex/gpu-failure-cheatsheet
|
|
15
|
+
Project-URL: Issue Tracker, https://denpex.com/contact
|
|
16
|
+
Author: Denpex
|
|
17
|
+
License: MIT
|
|
18
|
+
Keywords: cuda,denpex,diagnosis,gpu,mlflow,nccl,pytorch,training
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Requires-Python: >=3.8
|
|
23
|
+
Requires-Dist: mlflow>=2.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# denpex-mlflow
|
|
27
|
+
|
|
28
|
+
Attach **[Denpex](https://denpex.com)** GPU and training-failure diagnoses to your **MLflow** runs in one line.
|
|
29
|
+
|
|
30
|
+
When a run crashes, Denpex reads the traceback (and optionally your log file), diagnoses
|
|
31
|
+
the root cause (NCCL, Xid, CUDA, OOM, checkpoint, etc.), and writes the **root cause + fix** back
|
|
32
|
+
onto the active MLflow run as tags and a `denpex_diagnosis.md` artifact, then re-raises
|
|
33
|
+
your original exception so nothing is hidden.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
```bash
|
|
37
|
+
pip install denpex-mlflow
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
```python
|
|
42
|
+
import mlflow
|
|
43
|
+
import denpex_mlflow as denpex
|
|
44
|
+
|
|
45
|
+
with mlflow.start_run():
|
|
46
|
+
with denpex.watch(job_name="llama-7b-sft"):
|
|
47
|
+
train() # if this raises, Denpex diagnoses it and tags the run
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Decorator form:
|
|
51
|
+
```python
|
|
52
|
+
@denpex.watch_run("llama-7b-sft")
|
|
53
|
+
def main():
|
|
54
|
+
train()
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
One-shot (no MLflow needed):
|
|
58
|
+
```python
|
|
59
|
+
diag = denpex.diagnose(open("train.err").read())
|
|
60
|
+
print(diag.failure_type, diag.confidence, diag.action)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Auth
|
|
64
|
+
Set an API key (create one at **[denpex.com/dashboard](https://denpex.com/dashboard)** under API keys):
|
|
65
|
+
```bash
|
|
66
|
+
export DENPEX_API_KEY=dpx_...
|
|
67
|
+
# optional, for self-hosted / staging:
|
|
68
|
+
export DENPEX_API_URL=https://api.denpex.com
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## What gets written to the run
|
|
72
|
+
| MLflow tag | Value |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `denpex.failure_type` | e.g. `NCCL_TIMEOUT`, `CUDA_OOM`, `GPU_XID_ECC` |
|
|
75
|
+
| `denpex.confidence` | 0 to 100 |
|
|
76
|
+
| `denpex.fix_summary` | the root cause (truncated) |
|
|
77
|
+
| `denpex.diagnosis_id` | link back to [denpex.com/dashboard](https://denpex.com/dashboard) |
|
|
78
|
+
| artifact `denpex_diagnosis.md` | full root cause + fix |
|
|
79
|
+
|
|
80
|
+
## Notes
|
|
81
|
+
- **Best-effort & non-masking:** if Denpex is unreachable, unauthenticated, or over the
|
|
82
|
+
free-plan quota, it logs one line to stderr and still re-raises your real error.
|
|
83
|
+
- `KeyboardInterrupt` / `SystemExit` are passed through undiagnosed.
|
|
84
|
+
- The diagnosis runs through the same `/api/diagnose` endpoint as the rest of Denpex, so
|
|
85
|
+
it also lands in your dashboard history and fires your configured alert channels.
|
|
86
|
+
- Pass `logfile="train.err"` to `watch()` to include the tail of a log file alongside the
|
|
87
|
+
traceback.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Official Denpex Ecosystem & Documentation
|
|
92
|
+
|
|
93
|
+
| Resource | Description | Link |
|
|
94
|
+
| :--- | :--- | :--- |
|
|
95
|
+
| **Denpex Platform** | Deterministic GPU and ML training failure diagnostics | [denpex.com](https://denpex.com) |
|
|
96
|
+
| **Documentation Hub** | Quickstart guides, architecture, and operational reference | [denpex.com/docs](https://denpex.com/docs) |
|
|
97
|
+
| **Python Agent & CLI** | On-host monitoring, evidence capture, and zero-egress CLI | [denpex.com/docs/agent](https://denpex.com/docs/agent) |
|
|
98
|
+
| **REST API Reference** | Programmatic `/api/diagnose` and preflight endpoints | [denpex.com/docs/api](https://denpex.com/docs/api) |
|
|
99
|
+
| **VS Code Extension** | Offline GPU crash diagnostics in VS Code | [denpex.com/vscode](https://denpex.com/vscode) |
|
|
100
|
+
| **Open VSX Extension** | Extension for Cursor, Windsurf, VSCodium, and Gitpod | [denpex.com/openvsx](https://denpex.com/openvsx) |
|
|
101
|
+
| **Failure Encyclopedia** | 500+ documented CUDA, NCCL, Xid, Slurm, and vLLM failures | [denpex.com/failures](https://denpex.com/failures) |
|
|
102
|
+
| **Free GPU Tools** | VRAM, NCCL, DeepSpeed ZeRO, and Slurm calculators | [denpex.com/tools](https://denpex.com/tools) |
|
|
103
|
+
| **NVIDIA Xid Reference** | Hardware vs software Xid fault routing table | [denpex.com/xid](https://denpex.com/xid) |
|
|
104
|
+
| **Release Changelog** | Platform, agent, SDK, and extension release notes | [denpex.com/changelog](https://denpex.com/changelog) |
|
|
105
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# denpex-mlflow
|
|
2
|
+
|
|
3
|
+
Attach **[Denpex](https://denpex.com)** GPU and training-failure diagnoses to your **MLflow** runs in one line.
|
|
4
|
+
|
|
5
|
+
When a run crashes, Denpex reads the traceback (and optionally your log file), diagnoses
|
|
6
|
+
the root cause (NCCL, Xid, CUDA, OOM, checkpoint, etc.), and writes the **root cause + fix** back
|
|
7
|
+
onto the active MLflow run as tags and a `denpex_diagnosis.md` artifact, then re-raises
|
|
8
|
+
your original exception so nothing is hidden.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
```bash
|
|
12
|
+
pip install denpex-mlflow
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
```python
|
|
17
|
+
import mlflow
|
|
18
|
+
import denpex_mlflow as denpex
|
|
19
|
+
|
|
20
|
+
with mlflow.start_run():
|
|
21
|
+
with denpex.watch(job_name="llama-7b-sft"):
|
|
22
|
+
train() # if this raises, Denpex diagnoses it and tags the run
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Decorator form:
|
|
26
|
+
```python
|
|
27
|
+
@denpex.watch_run("llama-7b-sft")
|
|
28
|
+
def main():
|
|
29
|
+
train()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
One-shot (no MLflow needed):
|
|
33
|
+
```python
|
|
34
|
+
diag = denpex.diagnose(open("train.err").read())
|
|
35
|
+
print(diag.failure_type, diag.confidence, diag.action)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Auth
|
|
39
|
+
Set an API key (create one at **[denpex.com/dashboard](https://denpex.com/dashboard)** under API keys):
|
|
40
|
+
```bash
|
|
41
|
+
export DENPEX_API_KEY=dpx_...
|
|
42
|
+
# optional, for self-hosted / staging:
|
|
43
|
+
export DENPEX_API_URL=https://api.denpex.com
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What gets written to the run
|
|
47
|
+
| MLflow tag | Value |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `denpex.failure_type` | e.g. `NCCL_TIMEOUT`, `CUDA_OOM`, `GPU_XID_ECC` |
|
|
50
|
+
| `denpex.confidence` | 0 to 100 |
|
|
51
|
+
| `denpex.fix_summary` | the root cause (truncated) |
|
|
52
|
+
| `denpex.diagnosis_id` | link back to [denpex.com/dashboard](https://denpex.com/dashboard) |
|
|
53
|
+
| artifact `denpex_diagnosis.md` | full root cause + fix |
|
|
54
|
+
|
|
55
|
+
## Notes
|
|
56
|
+
- **Best-effort & non-masking:** if Denpex is unreachable, unauthenticated, or over the
|
|
57
|
+
free-plan quota, it logs one line to stderr and still re-raises your real error.
|
|
58
|
+
- `KeyboardInterrupt` / `SystemExit` are passed through undiagnosed.
|
|
59
|
+
- The diagnosis runs through the same `/api/diagnose` endpoint as the rest of Denpex, so
|
|
60
|
+
it also lands in your dashboard history and fires your configured alert channels.
|
|
61
|
+
- Pass `logfile="train.err"` to `watch()` to include the tail of a log file alongside the
|
|
62
|
+
traceback.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Official Denpex Ecosystem & Documentation
|
|
67
|
+
|
|
68
|
+
| Resource | Description | Link |
|
|
69
|
+
| :--- | :--- | :--- |
|
|
70
|
+
| **Denpex Platform** | Deterministic GPU and ML training failure diagnostics | [denpex.com](https://denpex.com) |
|
|
71
|
+
| **Documentation Hub** | Quickstart guides, architecture, and operational reference | [denpex.com/docs](https://denpex.com/docs) |
|
|
72
|
+
| **Python Agent & CLI** | On-host monitoring, evidence capture, and zero-egress CLI | [denpex.com/docs/agent](https://denpex.com/docs/agent) |
|
|
73
|
+
| **REST API Reference** | Programmatic `/api/diagnose` and preflight endpoints | [denpex.com/docs/api](https://denpex.com/docs/api) |
|
|
74
|
+
| **VS Code Extension** | Offline GPU crash diagnostics in VS Code | [denpex.com/vscode](https://denpex.com/vscode) |
|
|
75
|
+
| **Open VSX Extension** | Extension for Cursor, Windsurf, VSCodium, and Gitpod | [denpex.com/openvsx](https://denpex.com/openvsx) |
|
|
76
|
+
| **Failure Encyclopedia** | 500+ documented CUDA, NCCL, Xid, Slurm, and vLLM failures | [denpex.com/failures](https://denpex.com/failures) |
|
|
77
|
+
| **Free GPU Tools** | VRAM, NCCL, DeepSpeed ZeRO, and Slurm calculators | [denpex.com/tools](https://denpex.com/tools) |
|
|
78
|
+
| **NVIDIA Xid Reference** | Hardware vs software Xid fault routing table | [denpex.com/xid](https://denpex.com/xid) |
|
|
79
|
+
| **Release Changelog** | Platform, agent, SDK, and extension release notes | [denpex.com/changelog](https://denpex.com/changelog) |
|
|
80
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""denpex-mlflow — attach Denpex GPU/training-failure diagnoses to your MLflow runs.
|
|
2
|
+
|
|
3
|
+
import denpex_mlflow as denpex
|
|
4
|
+
with denpex.watch(job_name="llama-7b-sft"):
|
|
5
|
+
train()
|
|
6
|
+
|
|
7
|
+
On failure, Denpex diagnoses the crash and writes the root cause + fix to the active
|
|
8
|
+
MLflow run as tags (`denpex.failure_type`, `denpex.fix_summary`, …) and a
|
|
9
|
+
`denpex_diagnosis.md` artifact, then re-raises your original exception.
|
|
10
|
+
"""
|
|
11
|
+
from ._client import (
|
|
12
|
+
DenpexAuthError,
|
|
13
|
+
DenpexClient,
|
|
14
|
+
DenpexError,
|
|
15
|
+
DenpexQuotaError,
|
|
16
|
+
Diagnosis,
|
|
17
|
+
)
|
|
18
|
+
from .watch import diagnose, watch, watch_run
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = [
|
|
22
|
+
"watch",
|
|
23
|
+
"watch_run",
|
|
24
|
+
"diagnose",
|
|
25
|
+
"DenpexClient",
|
|
26
|
+
"Diagnosis",
|
|
27
|
+
"DenpexError",
|
|
28
|
+
"DenpexAuthError",
|
|
29
|
+
"DenpexQuotaError",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Zero-dependency client for the Denpex diagnosis API.
|
|
2
|
+
|
|
3
|
+
Targets the live `POST /api/diagnose` endpoint (API-key auth via the `X-Denpex-Key`
|
|
4
|
+
header). This is the same path the website console and the cluster agent use, so a diagnosis
|
|
5
|
+
from here is saved to your history and fans out to your alert channels too.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, List, Optional
|
|
16
|
+
|
|
17
|
+
from .evidence import select_evidence
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = os.environ.get("DENPEX_API_URL", "https://api.denpex.com")
|
|
20
|
+
MAX_LOG_CHARS = 50_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DenpexError(Exception):
|
|
24
|
+
"""Base error for any diagnosis failure."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DenpexAuthError(DenpexError):
|
|
28
|
+
"""Missing/invalid API key (HTTP 401)."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DenpexQuotaError(DenpexError):
|
|
32
|
+
"""Monthly free-plan quota exhausted (HTTP 429, UPGRADE_REQUIRED)."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Diagnosis:
|
|
37
|
+
"""The canonical diagnosis contract, as every other Denpex surface returns it.
|
|
38
|
+
|
|
39
|
+
This carried six fields: type, confidence, summary, action, source, and id. It
|
|
40
|
+
silently discarded the rest of the response. What it dropped is the half an
|
|
41
|
+
operator acts on: the fallback when the primary fix does not work, the
|
|
42
|
+
emergency stopgap, the command that PROVES recovery, the evidence the verdict
|
|
43
|
+
rests on, which rank/GPU initiated the failure, whether a checkpoint is safe
|
|
44
|
+
to resume from, and the questions the engine asks when it cannot decide.
|
|
45
|
+
|
|
46
|
+
An MLflow user therefore saw a confident one-line answer with no escalation
|
|
47
|
+
path and no way to check whether it worked, from the same API call that
|
|
48
|
+
returned all of it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
failure_type: str
|
|
52
|
+
# CALIBRATED display value, not the raw score. `meta.confidence` includes
|
|
53
|
+
# bands measured to be unreliable; `fix.confidenceDisplay` is null when the
|
|
54
|
+
# band is not accurate enough to show, and None here means "withheld", never
|
|
55
|
+
# "zero". Printing the raw number re-adds a band with known-bad precision.
|
|
56
|
+
confidence: Optional[int]
|
|
57
|
+
summary: str
|
|
58
|
+
action: str
|
|
59
|
+
source: Optional[str] = None
|
|
60
|
+
diagnosis_id: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
# ── The escalation ladder ──
|
|
63
|
+
fallback_action: Optional[str] = None
|
|
64
|
+
emergency_action: Optional[str] = None
|
|
65
|
+
verification_command: Optional[str] = None
|
|
66
|
+
resume_from: Optional[str] = None
|
|
67
|
+
|
|
68
|
+
# ── The reasoning trail ──
|
|
69
|
+
evidence: List[str] = field(default_factory=list)
|
|
70
|
+
initiator: Optional[str] = None
|
|
71
|
+
competing_hypotheses: List[str] = field(default_factory=list)
|
|
72
|
+
clarifying_questions: List[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
def as_markdown(self) -> str:
|
|
75
|
+
confidence = f"{self.confidence}%" if self.confidence is not None else "withheld (calibration band too wide to report)"
|
|
76
|
+
parts = [
|
|
77
|
+
f"# Denpex diagnosis: {self.failure_type}\n",
|
|
78
|
+
f"**Confidence:** {confidence}\n",
|
|
79
|
+
]
|
|
80
|
+
if self.initiator:
|
|
81
|
+
parts.append(f"**Initiating failure:** {self.initiator}\n")
|
|
82
|
+
parts.append(f"## Root cause\n{self.summary or '_n/a_'}\n")
|
|
83
|
+
parts.append(f"## Fix\n{self.action or '_n/a_'}\n")
|
|
84
|
+
if self.fallback_action:
|
|
85
|
+
parts.append(f"## If that does not work\n{self.fallback_action}\n")
|
|
86
|
+
if self.emergency_action:
|
|
87
|
+
parts.append(f"## Emergency stopgap\n{self.emergency_action}\n")
|
|
88
|
+
if self.verification_command:
|
|
89
|
+
# An exit code is not proof of recovery; this is what proves it.
|
|
90
|
+
parts.append(f"## Verify the fix worked\n```\n{self.verification_command}\n```\n")
|
|
91
|
+
if self.resume_from:
|
|
92
|
+
parts.append(f"## Safe resume point\n`{self.resume_from}`\n")
|
|
93
|
+
if self.evidence:
|
|
94
|
+
parts.append("## Evidence\n" + "\n".join(f"- {e}" for e in self.evidence) + "\n")
|
|
95
|
+
if self.competing_hypotheses:
|
|
96
|
+
parts.append("## Also considered\n" + "\n".join(f"- {h}" for h in self.competing_hypotheses) + "\n")
|
|
97
|
+
if self.clarifying_questions:
|
|
98
|
+
parts.append(
|
|
99
|
+
"## To narrow this down\n"
|
|
100
|
+
+ "\n".join(f"- {q}" for q in self.clarifying_questions)
|
|
101
|
+
+ "\n"
|
|
102
|
+
)
|
|
103
|
+
if self.diagnosis_id:
|
|
104
|
+
parts.append(f"\n_Diagnosis `{self.diagnosis_id}` · https://denpex.com/dashboard_\n")
|
|
105
|
+
return "\n".join(parts)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class DenpexClient:
|
|
109
|
+
"""Thin client over POST /api/diagnose with bounded retry/backoff."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
api_key: Optional[str] = None,
|
|
114
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
115
|
+
# 300s, not 120s. The canonical deep tier has a 290s deadline, so a
|
|
116
|
+
# 120s client timeout abandoned answers the server was still producing
|
|
117
|
+
# and surfaced them to the user as a failure.
|
|
118
|
+
timeout: float = 300.0,
|
|
119
|
+
retries: int = 2,
|
|
120
|
+
backoff: float = 0.5,
|
|
121
|
+
):
|
|
122
|
+
self.api_key = api_key if api_key is not None else os.environ.get("DENPEX_API_KEY", "")
|
|
123
|
+
self.base_url = base_url.rstrip("/")
|
|
124
|
+
self.timeout = timeout
|
|
125
|
+
self.retries = retries
|
|
126
|
+
self.backoff = backoff
|
|
127
|
+
|
|
128
|
+
def diagnose(self, logs: str, job_name: Optional[str] = None) -> Diagnosis:
|
|
129
|
+
if not self.api_key:
|
|
130
|
+
raise DenpexAuthError("DENPEX_API_KEY is not set (get a key at denpex.com/dashboard).")
|
|
131
|
+
if not logs or len(logs.strip()) < 6:
|
|
132
|
+
raise DenpexError("Provide the original machine event or failure log to diagnose.")
|
|
133
|
+
|
|
134
|
+
selected_logs, _truncated = select_evidence(logs, MAX_LOG_CHARS)
|
|
135
|
+
payload = json.dumps({"logs": selected_logs, "jobName": job_name}).encode("utf-8")
|
|
136
|
+
last_err: Optional[DenpexError] = None
|
|
137
|
+
|
|
138
|
+
for attempt in range(self.retries + 1):
|
|
139
|
+
req = urllib.request.Request(
|
|
140
|
+
self.base_url + "/api/diagnose",
|
|
141
|
+
data=payload,
|
|
142
|
+
method="POST",
|
|
143
|
+
headers={
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
"X-Denpex-Key": self.api_key,
|
|
146
|
+
"User-Agent": "Denpex-MLflow/0.1",
|
|
147
|
+
},
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as r:
|
|
151
|
+
data = json.loads(r.read().decode("utf-8") or "{}")
|
|
152
|
+
return self._parse(data)
|
|
153
|
+
except urllib.error.HTTPError as e:
|
|
154
|
+
body = self._read_err(e)
|
|
155
|
+
if e.code == 401:
|
|
156
|
+
raise DenpexAuthError("Invalid DENPEX_API_KEY.") from None
|
|
157
|
+
if e.code == 429 and body.get("code") == "UPGRADE_REQUIRED":
|
|
158
|
+
raise DenpexQuotaError(body.get("error", "Monthly quota exceeded.")) from None
|
|
159
|
+
if e.code == 400:
|
|
160
|
+
raise DenpexError(body.get("guidance") or body.get("error", "Bad request.")) from None
|
|
161
|
+
# 429 rate-limit or 5xx -> retryable
|
|
162
|
+
last_err = DenpexError(body.get("error", f"HTTP {e.code}"))
|
|
163
|
+
if not (e.code == 429 or 500 <= e.code < 600):
|
|
164
|
+
raise last_err from None
|
|
165
|
+
except urllib.error.URLError as e:
|
|
166
|
+
last_err = DenpexError(f"Network error: {e.reason}")
|
|
167
|
+
except (TimeoutError, OSError) as e:
|
|
168
|
+
last_err = DenpexError(f"Connection error: {e}")
|
|
169
|
+
|
|
170
|
+
if attempt < self.retries:
|
|
171
|
+
time.sleep(self.backoff * (2 ** attempt))
|
|
172
|
+
|
|
173
|
+
raise last_err or DenpexError("Diagnosis failed.")
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _read_err(e: urllib.error.HTTPError) -> dict:
|
|
177
|
+
try:
|
|
178
|
+
return json.loads(e.read().decode("utf-8"))
|
|
179
|
+
except Exception:
|
|
180
|
+
return {}
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _parse(data: dict) -> Diagnosis:
|
|
184
|
+
if not data.get("success"):
|
|
185
|
+
raise DenpexError(data.get("error", "Diagnosis failed."))
|
|
186
|
+
meta = data.get("meta") or {}
|
|
187
|
+
fix = data.get("fix") or {}
|
|
188
|
+
|
|
189
|
+
def _strs(value: Any, limit: int = 12) -> List[str]:
|
|
190
|
+
"""Response fields are untrusted input even from our own API."""
|
|
191
|
+
if not isinstance(value, list):
|
|
192
|
+
return []
|
|
193
|
+
out = []
|
|
194
|
+
for item in value[:limit]:
|
|
195
|
+
if isinstance(item, str) and item.strip():
|
|
196
|
+
out.append(item.strip())
|
|
197
|
+
elif isinstance(item, dict):
|
|
198
|
+
# clarifyingChoices / rankedHypotheses arrive as objects; take
|
|
199
|
+
# the human-readable field rather than dropping them entirely.
|
|
200
|
+
text = item.get("question") or item.get("theory") or item.get("label")
|
|
201
|
+
if isinstance(text, str) and text.strip():
|
|
202
|
+
out.append(text.strip())
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
# `confidenceDisplay` is the calibrated value and may legitimately be
|
|
206
|
+
# null. That means "withheld", which is not the same as zero, so it is
|
|
207
|
+
# NOT coerced with `or 0`. Falling back to meta.confidence only when the
|
|
208
|
+
# key is absent entirely keeps older responses working.
|
|
209
|
+
display = fix.get("confidenceDisplay", "__missing__")
|
|
210
|
+
if display == "__missing__":
|
|
211
|
+
raw = meta.get("confidence")
|
|
212
|
+
confidence = int(raw) if isinstance(raw, (int, float)) else None
|
|
213
|
+
elif display is None:
|
|
214
|
+
confidence = None
|
|
215
|
+
else:
|
|
216
|
+
confidence = int(display)
|
|
217
|
+
|
|
218
|
+
return Diagnosis(
|
|
219
|
+
failure_type=meta.get("failureType", "UNKNOWN"),
|
|
220
|
+
confidence=confidence,
|
|
221
|
+
summary=fix.get("summary", ""),
|
|
222
|
+
action=fix.get("action", ""),
|
|
223
|
+
source=meta.get("source"),
|
|
224
|
+
diagnosis_id=data.get("diagnosisId"),
|
|
225
|
+
fallback_action=fix.get("fallbackAction") or None,
|
|
226
|
+
emergency_action=fix.get("emergencyAction") or None,
|
|
227
|
+
verification_command=fix.get("verificationCommand") or None,
|
|
228
|
+
resume_from=fix.get("resumeFrom") or None,
|
|
229
|
+
evidence=_strs(fix.get("evidence") or meta.get("evidence")),
|
|
230
|
+
initiator=(fix.get("initiator") or meta.get("initiator") or None),
|
|
231
|
+
competing_hypotheses=_strs(fix.get("competingHypotheses") or fix.get("rankedHypotheses")),
|
|
232
|
+
clarifying_questions=_strs(fix.get("clarifyingChoices") or fix.get("clarifyingQuestions")),
|
|
233
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Bounded causal evidence selection for the MLflow integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Dict, Tuple
|
|
7
|
+
|
|
8
|
+
_EVIDENCE = re.compile(
|
|
9
|
+
r"error|fail|fatal|traceback|exception|assert|panic|segfault|xid|nvrm|ecc|"
|
|
10
|
+
r"fallen off the bus|nccl|cuda|oom|out of memory|timeout|nan\b|\binf\b|"
|
|
11
|
+
r"node_fail|killed|terminated|version|driver|kernel|container|slurm|kubernetes|"
|
|
12
|
+
r"rank|node|host|gpu|pci|topology|nvlink|nvswitch|infiniband|rdma|roce",
|
|
13
|
+
re.I,
|
|
14
|
+
)
|
|
15
|
+
_CAUSAL = re.compile(
|
|
16
|
+
r"fatal|critical|traceback|exception|assert|panic|segfault|xid|uncorrectable|"
|
|
17
|
+
r"fallen off the bus|out of memory|watchdog timeout|crc mismatch|loss\s*=\s*(?:nan|inf)|"
|
|
18
|
+
r"insufficient|unhealthy|ibv_modify_qp|no space left|compil(?:e|ation) (?:error|failed)|"
|
|
19
|
+
r"no available memory|checksum mismatch|childfailederror|backendcompilerfailed|"
|
|
20
|
+
r"failedscheduling|input/output error|nonfinite|divergence|first failure|initiator|caused by",
|
|
21
|
+
re.I,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _clip_line(line: str, maximum: int = 2000) -> str:
|
|
26
|
+
if len(line) <= maximum:
|
|
27
|
+
return line
|
|
28
|
+
marker = " [line clipped by Denpex] "
|
|
29
|
+
side = max(1, (maximum - len(marker)) // 2)
|
|
30
|
+
return (line[:side] + marker + line[-side:])[:maximum]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def select_evidence(logs: str, maximum: int) -> Tuple[str, bool]:
|
|
34
|
+
if len(logs) <= maximum:
|
|
35
|
+
return logs, False
|
|
36
|
+
lines = logs.splitlines()
|
|
37
|
+
candidates: Dict[int, int] = {}
|
|
38
|
+
|
|
39
|
+
def add(index: int, radius: int, priority: int) -> None:
|
|
40
|
+
for current in range(max(0, index - radius), min(len(lines), index + radius + 1)):
|
|
41
|
+
candidates[current] = max(priority, candidates.get(current, 0))
|
|
42
|
+
|
|
43
|
+
for index in range(min(48, len(lines))):
|
|
44
|
+
candidates[index] = 300
|
|
45
|
+
for index in range(max(0, len(lines) - 96), len(lines)):
|
|
46
|
+
candidates[index] = 800
|
|
47
|
+
|
|
48
|
+
found_first = False
|
|
49
|
+
for index, line in enumerate(lines):
|
|
50
|
+
if not _EVIDENCE.search(line):
|
|
51
|
+
continue
|
|
52
|
+
causal = bool(_CAUSAL.search(line))
|
|
53
|
+
add(index, 3 if causal else 1, 1000 if causal else 700)
|
|
54
|
+
if not found_first:
|
|
55
|
+
add(index, 6, 1100)
|
|
56
|
+
found_first = True
|
|
57
|
+
|
|
58
|
+
warning = (
|
|
59
|
+
f"[Denpex condensed input: input exceeded {maximum:,} characters. "
|
|
60
|
+
"Environment, causal, rank, topology, middle, and final-tail evidence were selected.]"
|
|
61
|
+
)
|
|
62
|
+
used = len(warning)
|
|
63
|
+
selected = set()
|
|
64
|
+
for index, _priority in sorted(candidates.items(), key=lambda item: (-item[1], item[0])):
|
|
65
|
+
line = _clip_line(lines[index])
|
|
66
|
+
if used + len(line) + 1 > maximum:
|
|
67
|
+
continue
|
|
68
|
+
selected.add(index)
|
|
69
|
+
used += len(line) + 1
|
|
70
|
+
output = [warning]
|
|
71
|
+
output.extend(_clip_line(lines[index]) for index in sorted(selected))
|
|
72
|
+
return "\n".join(output), True
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""`denpex.watch()` — wrap a training run so that, on failure, Denpex diagnoses the
|
|
2
|
+
crash and attaches the root cause + fix straight to the active MLflow run.
|
|
3
|
+
|
|
4
|
+
import denpex_mlflow as denpex
|
|
5
|
+
with denpex.watch(job_name="llama-7b-sft"):
|
|
6
|
+
train() # raises -> diagnosed, tagged, re-raised
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import traceback
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ._client import DenpexClient, Diagnosis, DenpexError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _collect_logs(exc: BaseException, logfile: Optional[str], tail_lines: int = 200) -> str:
|
|
21
|
+
parts = []
|
|
22
|
+
if logfile:
|
|
23
|
+
try:
|
|
24
|
+
with open(logfile, "r", errors="replace") as f:
|
|
25
|
+
parts.append(f.read())
|
|
26
|
+
except OSError:
|
|
27
|
+
pass
|
|
28
|
+
parts.append("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)))
|
|
29
|
+
return "\n".join(p for p in parts if p and p.strip())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _attach_to_mlflow(diag: Diagnosis) -> bool:
|
|
33
|
+
"""Write the diagnosis to the active MLflow run as tags + an artifact. No-op if
|
|
34
|
+
mlflow isn't installed or there's no active run. Never raises."""
|
|
35
|
+
try:
|
|
36
|
+
import mlflow
|
|
37
|
+
except Exception:
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
if mlflow.active_run() is None:
|
|
41
|
+
return False
|
|
42
|
+
mlflow.set_tag("denpex.failure_type", diag.failure_type)
|
|
43
|
+
mlflow.set_tag("denpex.confidence", diag.confidence)
|
|
44
|
+
mlflow.set_tag("denpex.fix_summary", (diag.summary or "")[:500])
|
|
45
|
+
if diag.diagnosis_id:
|
|
46
|
+
mlflow.set_tag("denpex.diagnosis_id", diag.diagnosis_id)
|
|
47
|
+
mlflow.log_text(diag.as_markdown(), "denpex_diagnosis.md")
|
|
48
|
+
return True
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_summary(diag: Diagnosis, attached: bool) -> None:
|
|
54
|
+
where = " (attached to MLflow run)" if attached else ""
|
|
55
|
+
# ASCII only — this writes to whatever stderr the training box has (may be cp1252/ascii).
|
|
56
|
+
sys.stderr.write(
|
|
57
|
+
f"\n[denpex] {diag.failure_type} - {diag.confidence}% confidence{where}\n"
|
|
58
|
+
f"[denpex] root cause: {diag.summary}\n"
|
|
59
|
+
f"[denpex] fix: {diag.action}\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@contextmanager
|
|
64
|
+
def watch(
|
|
65
|
+
job_name: Optional[str] = None,
|
|
66
|
+
*,
|
|
67
|
+
api_key: Optional[str] = None,
|
|
68
|
+
base_url: Optional[str] = None,
|
|
69
|
+
logfile: Optional[str] = None,
|
|
70
|
+
reraise: bool = True,
|
|
71
|
+
client: Optional[DenpexClient] = None,
|
|
72
|
+
):
|
|
73
|
+
"""Context manager that diagnoses any exception raised inside it.
|
|
74
|
+
|
|
75
|
+
The diagnosis is best-effort and never masks your original error: if Denpex is
|
|
76
|
+
unreachable or unauthenticated we log a line to stderr and still re-raise.
|
|
77
|
+
KeyboardInterrupt / SystemExit pass straight through, undiagnosed.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
yield
|
|
81
|
+
except (KeyboardInterrupt, SystemExit):
|
|
82
|
+
raise
|
|
83
|
+
except BaseException as exc: # noqa: BLE001 - we re-raise below
|
|
84
|
+
try:
|
|
85
|
+
c = client or DenpexClient(
|
|
86
|
+
api_key=api_key if api_key is not None else os.environ.get("DENPEX_API_KEY", ""),
|
|
87
|
+
base_url=base_url or os.environ.get("DENPEX_API_URL", "https://api.denpex.com"),
|
|
88
|
+
)
|
|
89
|
+
diag = c.diagnose(_collect_logs(exc, logfile), job_name=job_name)
|
|
90
|
+
_print_summary(diag, _attach_to_mlflow(diag))
|
|
91
|
+
except DenpexError as inner:
|
|
92
|
+
sys.stderr.write(f"[denpex] diagnosis skipped: {inner}\n")
|
|
93
|
+
except Exception as inner: # never let the SDK mask the real failure
|
|
94
|
+
sys.stderr.write(f"[denpex] diagnosis error: {inner}\n")
|
|
95
|
+
if reraise:
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def watch_run(job_name: Optional[str] = None, **watch_kwargs):
|
|
100
|
+
"""Decorator form: `@denpex.watch_run("my-job")` around a training entrypoint."""
|
|
101
|
+
def decorator(fn):
|
|
102
|
+
@functools.wraps(fn)
|
|
103
|
+
def wrapper(*args, **kwargs):
|
|
104
|
+
with watch(job_name=job_name or fn.__name__, **watch_kwargs):
|
|
105
|
+
return fn(*args, **kwargs)
|
|
106
|
+
return wrapper
|
|
107
|
+
return decorator
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def diagnose(logs: str, job_name: Optional[str] = None, *, api_key: Optional[str] = None, base_url: Optional[str] = None) -> Diagnosis:
|
|
111
|
+
"""One-shot convenience: diagnose a log string directly."""
|
|
112
|
+
client = DenpexClient(
|
|
113
|
+
api_key=api_key if api_key is not None else os.environ.get("DENPEX_API_KEY", ""),
|
|
114
|
+
base_url=base_url or os.environ.get("DENPEX_API_URL", "https://api.denpex.com"),
|
|
115
|
+
)
|
|
116
|
+
return client.diagnose(logs, job_name=job_name)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "denpex-mlflow"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Attach Denpex GPU and training-failure diagnoses to your MLflow runs in one line."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Denpex" }]
|
|
13
|
+
keywords = ["mlflow", "gpu", "training", "diagnosis", "denpex", "nccl", "cuda", "pytorch"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
18
|
+
]
|
|
19
|
+
# Runtime is intentionally light: the client uses only the stdlib. mlflow is the host
|
|
20
|
+
# tool you're already running; it's imported lazily so the client also works standalone.
|
|
21
|
+
dependencies = ["mlflow>=2.0"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
"Homepage" = "https://denpex.com"
|
|
25
|
+
"Documentation" = "https://denpex.com/docs/integrations"
|
|
26
|
+
"API Reference" = "https://denpex.com/docs/api"
|
|
27
|
+
"Python Agent & CLI" = "https://denpex.com/docs/agent"
|
|
28
|
+
"VS Code Extension" = "https://denpex.com/vscode"
|
|
29
|
+
"Open VSX Extension" = "https://denpex.com/openvsx"
|
|
30
|
+
"GPU Failure Encyclopedia" = "https://denpex.com/failures"
|
|
31
|
+
"Free GPU Debugging Tools" = "https://denpex.com/tools"
|
|
32
|
+
"Changelog" = "https://denpex.com/changelog"
|
|
33
|
+
"Repository" = "https://github.com/Denpex/gpu-failure-cheatsheet"
|
|
34
|
+
"Issue Tracker" = "https://denpex.com/contact"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["denpex_mlflow"]
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""Unit tests for denpex-mlflow. Stdlib unittest and mock only. No network or real
|
|
2
|
+
mlflow (a fake module is injected). Run from the package root:
|
|
3
|
+
|
|
4
|
+
cd packages/denpex-mlflow && python -m unittest discover -s tests -v
|
|
5
|
+
"""
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import types
|
|
11
|
+
import unittest
|
|
12
|
+
import urllib.error
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
# Make the package importable when run from the package root.
|
|
16
|
+
sys.path.insert(0, ".")
|
|
17
|
+
|
|
18
|
+
import denpex_mlflow as denpex # noqa: E402
|
|
19
|
+
from denpex_mlflow._client import DenpexClient # noqa: E402
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ok_response(payload: dict):
|
|
23
|
+
body = io.BytesIO()
|
|
24
|
+
import json
|
|
25
|
+
body.write(json.dumps(payload).encode())
|
|
26
|
+
body.seek(0)
|
|
27
|
+
cm = mock.MagicMock()
|
|
28
|
+
cm.__enter__.return_value.read.return_value = body.getvalue()
|
|
29
|
+
cm.__exit__.return_value = False
|
|
30
|
+
return cm
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _http_error(code: int, payload: dict):
|
|
34
|
+
import json
|
|
35
|
+
return urllib.error.HTTPError(
|
|
36
|
+
url="https://api.denpex.com/api/diagnose", code=code, msg="err",
|
|
37
|
+
hdrs=None, fp=io.BytesIO(json.dumps(payload).encode()),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
SUCCESS = {
|
|
42
|
+
"success": True,
|
|
43
|
+
"diagnosisId": "diag123",
|
|
44
|
+
"fix": {"summary": "Rank 3 stalled the all-reduce.", "action": "Restart with NCCL_TIMEOUT=6000."},
|
|
45
|
+
"meta": {"failureType": "NCCL_TIMEOUT", "confidence": 91, "source": "pattern", "tier": "fast"},
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ClientTests(unittest.TestCase):
|
|
50
|
+
def test_diagnose_success_parses_and_sends_key(self):
|
|
51
|
+
c = DenpexClient(api_key="dpx_test")
|
|
52
|
+
with mock.patch("urllib.request.urlopen", return_value=_ok_response(SUCCESS)) as uo:
|
|
53
|
+
diag = c.diagnose("a" * 50, job_name="job-1")
|
|
54
|
+
self.assertEqual(diag.failure_type, "NCCL_TIMEOUT")
|
|
55
|
+
self.assertEqual(diag.confidence, 91)
|
|
56
|
+
self.assertEqual(diag.diagnosis_id, "diag123")
|
|
57
|
+
self.assertIn("NCCL_TIMEOUT", diag.as_markdown())
|
|
58
|
+
# request carried the API key + correct path
|
|
59
|
+
req = uo.call_args.args[0]
|
|
60
|
+
self.assertEqual(req.get_header("X-denpex-key"), "dpx_test")
|
|
61
|
+
self.assertEqual(req.get_header("User-agent"), "Denpex-MLflow/0.1")
|
|
62
|
+
self.assertTrue(req.full_url.endswith("/api/diagnose"))
|
|
63
|
+
|
|
64
|
+
def test_short_logs_rejected_without_network(self):
|
|
65
|
+
c = DenpexClient(api_key="dpx_test")
|
|
66
|
+
with mock.patch("urllib.request.urlopen", side_effect=AssertionError("should not call")):
|
|
67
|
+
with self.assertRaises(denpex.DenpexError):
|
|
68
|
+
c.diagnose("oom")
|
|
69
|
+
|
|
70
|
+
def test_missing_api_key(self):
|
|
71
|
+
with self.assertRaises(denpex.DenpexAuthError):
|
|
72
|
+
DenpexClient(api_key="").diagnose("a" * 50)
|
|
73
|
+
|
|
74
|
+
def test_auth_error_401(self):
|
|
75
|
+
c = DenpexClient(api_key="dpx_bad")
|
|
76
|
+
with mock.patch("urllib.request.urlopen", side_effect=_http_error(401, {"error": "Unauthorized"})):
|
|
77
|
+
with self.assertRaises(denpex.DenpexAuthError):
|
|
78
|
+
c.diagnose("a" * 50)
|
|
79
|
+
|
|
80
|
+
def test_quota_error_429_upgrade_required(self):
|
|
81
|
+
c = DenpexClient(api_key="dpx_test")
|
|
82
|
+
with mock.patch("urllib.request.urlopen", side_effect=_http_error(429, {"error": "Quota exceeded", "code": "UPGRADE_REQUIRED"})):
|
|
83
|
+
with self.assertRaises(denpex.DenpexQuotaError):
|
|
84
|
+
c.diagnose("a" * 50)
|
|
85
|
+
|
|
86
|
+
def test_input_quality_error_preserves_guidance(self):
|
|
87
|
+
c = DenpexClient(api_key="dpx_test")
|
|
88
|
+
response = _http_error(400, {
|
|
89
|
+
"error": "Input rejected.",
|
|
90
|
+
"code": "INSUFFICIENT_DIAGNOSTIC_SIGNAL",
|
|
91
|
+
"guidance": "Paste the original CUDA failure line.",
|
|
92
|
+
})
|
|
93
|
+
with mock.patch("urllib.request.urlopen", side_effect=response):
|
|
94
|
+
with self.assertRaisesRegex(denpex.DenpexError, "original CUDA failure"):
|
|
95
|
+
c.diagnose("hello hello")
|
|
96
|
+
|
|
97
|
+
def test_retry_on_5xx_then_success(self):
|
|
98
|
+
c = DenpexClient(api_key="dpx_test", retries=1, backoff=0)
|
|
99
|
+
seq = [_http_error(503, {"error": "boom"}), _ok_response(SUCCESS)]
|
|
100
|
+
with mock.patch("urllib.request.urlopen", side_effect=seq), mock.patch("time.sleep"):
|
|
101
|
+
diag = c.diagnose("a" * 50)
|
|
102
|
+
self.assertEqual(diag.failure_type, "NCCL_TIMEOUT")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class FakeClient:
|
|
106
|
+
def __init__(self):
|
|
107
|
+
self.calls = []
|
|
108
|
+
|
|
109
|
+
def diagnose(self, logs, job_name=None):
|
|
110
|
+
self.calls.append((logs, job_name))
|
|
111
|
+
return denpex.Diagnosis(failure_type="CUDA_OOM", confidence=88, summary="OOM at step 42", action="Lower batch size.", diagnosis_id="d1")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class WatchTests(unittest.TestCase):
|
|
115
|
+
def setUp(self):
|
|
116
|
+
# Inject a fake mlflow module that records tag/artifact writes.
|
|
117
|
+
self.tags = {}
|
|
118
|
+
self.artifacts = {}
|
|
119
|
+
fake = types.ModuleType("mlflow")
|
|
120
|
+
fake.active_run = lambda: object()
|
|
121
|
+
fake.set_tag = lambda k, v: self.tags.__setitem__(k, v)
|
|
122
|
+
fake.log_text = lambda text, path: self.artifacts.__setitem__(path, text)
|
|
123
|
+
self._saved = sys.modules.get("mlflow")
|
|
124
|
+
sys.modules["mlflow"] = fake
|
|
125
|
+
|
|
126
|
+
def tearDown(self):
|
|
127
|
+
if self._saved is not None:
|
|
128
|
+
sys.modules["mlflow"] = self._saved
|
|
129
|
+
else:
|
|
130
|
+
sys.modules.pop("mlflow", None)
|
|
131
|
+
|
|
132
|
+
def test_watch_diagnoses_attaches_and_reraises(self):
|
|
133
|
+
fc = FakeClient()
|
|
134
|
+
with self.assertRaises(ValueError):
|
|
135
|
+
with denpex.watch(job_name="jx", client=fc):
|
|
136
|
+
raise ValueError("boom at step 42")
|
|
137
|
+
self.assertEqual(len(fc.calls), 1)
|
|
138
|
+
self.assertIn("boom at step 42", fc.calls[0][0]) # traceback captured
|
|
139
|
+
self.assertEqual(fc.calls[0][1], "jx") # job_name passed through
|
|
140
|
+
self.assertEqual(self.tags["denpex.failure_type"], "CUDA_OOM")
|
|
141
|
+
self.assertIn("denpex_diagnosis.md", self.artifacts)
|
|
142
|
+
|
|
143
|
+
def test_watch_passes_complete_log_to_the_bounded_selector(self):
|
|
144
|
+
fc = FakeClient()
|
|
145
|
+
handle, path = tempfile.mkstemp(prefix="denpex-mlflow-", suffix=".log")
|
|
146
|
+
os.close(handle)
|
|
147
|
+
try:
|
|
148
|
+
with open(path, "w", encoding="utf-8") as stream:
|
|
149
|
+
stream.write("PyTorch version: 2.7.1\n")
|
|
150
|
+
stream.write("ordinary progress\n" * 400)
|
|
151
|
+
stream.write("[rank 7][node gpu-03] first failure: NCCL timeout\n")
|
|
152
|
+
with self.assertRaises(RuntimeError):
|
|
153
|
+
with denpex.watch(job_name="complete-log", client=fc, logfile=path):
|
|
154
|
+
raise RuntimeError("final fatal tail")
|
|
155
|
+
sent = fc.calls[0][0]
|
|
156
|
+
self.assertIn("PyTorch version: 2.7.1", sent)
|
|
157
|
+
self.assertIn("first failure: NCCL timeout", sent)
|
|
158
|
+
self.assertIn("final fatal tail", sent)
|
|
159
|
+
finally:
|
|
160
|
+
os.unlink(path)
|
|
161
|
+
|
|
162
|
+
def test_watch_keyboardinterrupt_passes_through_undiagnosed(self):
|
|
163
|
+
fc = FakeClient()
|
|
164
|
+
with self.assertRaises(KeyboardInterrupt):
|
|
165
|
+
with denpex.watch(client=fc):
|
|
166
|
+
raise KeyboardInterrupt()
|
|
167
|
+
self.assertEqual(fc.calls, [])
|
|
168
|
+
|
|
169
|
+
def test_watch_swallows_sdk_errors_but_reraises_original(self):
|
|
170
|
+
class Boom:
|
|
171
|
+
def diagnose(self, *a, **k):
|
|
172
|
+
raise denpex.DenpexError("api down")
|
|
173
|
+
with self.assertRaises(RuntimeError):
|
|
174
|
+
with denpex.watch(client=Boom()):
|
|
175
|
+
raise RuntimeError("real failure")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
if __name__ == "__main__":
|
|
179
|
+
unittest.main()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── The canonical diagnosis contract (2026-08-21) ────────────────────────────
|
|
183
|
+
#
|
|
184
|
+
# The client parsed six fields and discarded the rest of the response: the
|
|
185
|
+
# fallback fix, the emergency stopgap, the command that PROVES recovery, the
|
|
186
|
+
# evidence, the initiating rank/GPU, the safe resume point, and the engine's
|
|
187
|
+
# clarifying questions. An MLflow user got a confident one-liner with no
|
|
188
|
+
# escalation path and no way to check whether it worked, from the same API
|
|
189
|
+
# call that returned all of it.
|
|
190
|
+
#
|
|
191
|
+
# It also printed `meta.confidence`, the RAW score. The calibrated value is
|
|
192
|
+
# `fix.confidenceDisplay`, which is deliberately null when the band is not
|
|
193
|
+
# accurate enough to report; rendering the raw number re-adds a band measured
|
|
194
|
+
# to be unreliable.
|
|
195
|
+
|
|
196
|
+
from denpex_mlflow._client import DenpexClient
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _full_response():
|
|
200
|
+
return {
|
|
201
|
+
"success": True,
|
|
202
|
+
"diagnosisId": "dx_1",
|
|
203
|
+
"meta": {"failureType": "GPU_XID_ECC_CASCADE", "confidence": 95, "source": "cascade"},
|
|
204
|
+
"fix": {
|
|
205
|
+
"summary": "Rank 3 failed first; the other timeouts are collateral.",
|
|
206
|
+
"action": "Drain the node and reset the GPU.",
|
|
207
|
+
"confidenceDisplay": 90,
|
|
208
|
+
"fallbackAction": "If the Xid recurs after reset, open an RMA.",
|
|
209
|
+
"emergencyAction": "Cordon the node now to stop new work landing on it.",
|
|
210
|
+
"verificationCommand": "nvidia-smi -q -d ECC",
|
|
211
|
+
"resumeFrom": "step_9000",
|
|
212
|
+
"evidence": ["NVRM: Xid (PCI:0000:1a:00): 48"],
|
|
213
|
+
"initiator": "rank 3 / GPU-aaaabbbb",
|
|
214
|
+
"competingHypotheses": [{"theory": "NCCL fabric flap"}],
|
|
215
|
+
"clarifyingChoices": [{"question": "Does it recur after a reset?"}],
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_uses_the_calibrated_confidence_not_the_raw_score():
|
|
221
|
+
d = DenpexClient._parse(_full_response())
|
|
222
|
+
assert d.confidence == 90, "must use fix.confidenceDisplay, not meta.confidence (95)"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_withheld_confidence_is_none_not_zero():
|
|
226
|
+
# None means "we decline to give a number"; 0 means "we are certain it is
|
|
227
|
+
# zero percent". Coercing with `or 0` prints the second and means the first.
|
|
228
|
+
r = _full_response()
|
|
229
|
+
r["fix"]["confidenceDisplay"] = None
|
|
230
|
+
d = DenpexClient._parse(r)
|
|
231
|
+
assert d.confidence is None
|
|
232
|
+
assert "withheld" in d.as_markdown()
|
|
233
|
+
assert "0%" not in d.as_markdown()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_falls_back_to_meta_confidence_for_older_responses():
|
|
237
|
+
r = _full_response()
|
|
238
|
+
del r["fix"]["confidenceDisplay"]
|
|
239
|
+
assert DenpexClient._parse(r).confidence == 95
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_carries_the_whole_escalation_ladder():
|
|
243
|
+
d = DenpexClient._parse(_full_response())
|
|
244
|
+
assert d.fallback_action and "RMA" in d.fallback_action
|
|
245
|
+
assert d.emergency_action and "Cordon" in d.emergency_action
|
|
246
|
+
assert d.verification_command == "nvidia-smi -q -d ECC"
|
|
247
|
+
assert d.resume_from == "step_9000"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_carries_the_reasoning_trail():
|
|
251
|
+
d = DenpexClient._parse(_full_response())
|
|
252
|
+
assert d.evidence == ["NVRM: Xid (PCI:0000:1a:00): 48"]
|
|
253
|
+
assert d.initiator == "rank 3 / GPU-aaaabbbb"
|
|
254
|
+
# Objects, not strings. The client must read the human-readable field
|
|
255
|
+
# rather than dropping structured entries on the floor.
|
|
256
|
+
assert d.competing_hypotheses == ["NCCL fabric flap"]
|
|
257
|
+
assert d.clarifying_questions == ["Does it recur after a reset?"]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_markdown_shows_the_operator_everything_it_received():
|
|
261
|
+
md = DenpexClient._parse(_full_response()).as_markdown()
|
|
262
|
+
for expected in ("If that does not work", "Emergency stopgap", "Verify the fix worked",
|
|
263
|
+
"Safe resume point", "Evidence", "Also considered", "To narrow this down"):
|
|
264
|
+
assert expected in md, f"markdown dropped: {expected}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_a_sparse_response_does_not_render_empty_sections():
|
|
268
|
+
d = DenpexClient._parse({"success": True, "meta": {"failureType": "X", "confidence": 50},
|
|
269
|
+
"fix": {"summary": "s", "action": "a"}})
|
|
270
|
+
md = d.as_markdown()
|
|
271
|
+
assert "If that does not work" not in md
|
|
272
|
+
assert "Verify the fix worked" not in md
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_malformed_list_fields_do_not_raise():
|
|
276
|
+
# The response is untrusted input even though we serve it.
|
|
277
|
+
r = _full_response()
|
|
278
|
+
r["fix"]["evidence"] = "not a list"
|
|
279
|
+
r["fix"]["clarifyingChoices"] = [None, 42, {"nothing": "useful"}]
|
|
280
|
+
d = DenpexClient._parse(r)
|
|
281
|
+
assert d.evidence == []
|
|
282
|
+
assert d.clarifying_questions == []
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_client_timeout_outlives_the_server_deadline():
|
|
286
|
+
# The canonical deep tier runs to 290s. A 120s client timeout abandoned
|
|
287
|
+
# answers the server was still producing and reported them as failures.
|
|
288
|
+
assert DenpexClient().timeout >= 290.0
|