|
1 | 1 | """ |
2 | | -DDP training that FAILS on attempt 0 and SUCCEEDS on the JobSet restart. |
3 | | -
|
4 | | -This is a regression test for the clustered-restart fix on this branch: when a |
5 | | -JobSet restarts (``JOBSET_RESTART_ATTEMPT`` > 0), ``upload_outputs`` must first |
6 | | -delete the stale ``error.pb`` written by the previous failed attempt. Otherwise |
7 | | -the successful retry's outputs land alongside a leftover error file and the |
8 | | -execution is still reported as FAILED. |
9 | | -
|
10 | | -Mechanics: |
11 | | - - ``ClusterFailurePolicy(max_restarts=1)`` lets the JobSet restart once. |
12 | | - - Attempt 0 (``JOBSET_RESTART_ATTEMPT`` unset / "0") raises -> writes error.pb. |
13 | | - - Attempt 1 (``JOBSET_RESTART_ATTEMPT`` == "1") runs DDP and uploads outputs. |
14 | | - With the fix, the stale error.pb is cleared and the run ends SUCCEEDED. |
15 | | - Without the fix, the run ends FAILED despite the successful retry. |
| 2 | +DDP training that FAILS on the first torchrun attempt and SUCCEEDS on the in-pod restart. |
| 3 | +
|
| 4 | +Regression test for the stale ``error.pb`` cleanup in the clustered runtime |
| 5 | +(``flyte._internal.runtime.io.clear_stale_clustered_error``): whenever a clustered rank-0 |
| 6 | +worker starts, it removes any ``error.pb`` an earlier restart left under the attempt's output |
| 7 | +prefix, before the task body runs. Without that cleanup a successful restart uploads |
| 8 | +``outputs.pb`` next to the leftover ``error.pb`` and the executor still reports the run FAILED. |
| 9 | +
|
| 10 | +The stale file is produced here without any backend help: |
| 11 | + - ``ClusterFailurePolicy(max_restarts=0)`` makes every attempt look terminal to the SDK's |
| 12 | + terminal-attempt gate (``JOBSET_RESTART_ATTEMPT 0 >= JOBSET_MAX_RESTARTS 0``), so the first |
| 13 | + failure writes ``error.pb`` right away and the worker exits 1. |
| 14 | + - ``PET_MAX_RESTARTS=1`` lets torchrun restart the worker group once inside the same pod. |
| 15 | + (``TorchRun(max_restarts=...)`` is not wired through to torchrun yet, so the env var is set |
| 16 | + directly; torchrun reads ``PET_<FLAG>`` for every CLI flag.) |
| 17 | + - Attempt 0 (``TORCHELASTIC_RESTART_COUNT`` == "0") raises on every rank. |
| 18 | + - Attempt 1 (``TORCHELASTIC_RESTART_COUNT`` == "1") trains and uploads outputs. Rank-0 logs |
| 19 | + "Removed stale ... error.pb" at startup. |
| 20 | +
|
| 21 | +Expected: run phase SUCCEEDED. Without the cleanup: FAILED with the attempt-0 error. |
| 22 | +
|
| 23 | +A JobSet-level restart (``ClusterFailurePolicy(max_restarts >= 1)``) goes through the same cleanup, |
| 24 | +but without free host-maintenance restarts the gate never writes a premature ``error.pb``, so that |
| 25 | +variant passes with or without the fix and is not a useful regression test. |
16 | 26 |
|
17 | 27 | Run: |
18 | 28 | uv run python examples/clustered/ddp_train_restart.py |
|
33 | 43 | ) |
34 | 44 |
|
35 | 45 | # --- Knobs --------------------------------------------------------------------------------------- |
36 | | -USE_GPU = True |
37 | | -REPLICAS = 2 # pods (== nodes) |
38 | | -NPROC_PER_NODE = 1 # processes (one per GPU) per pod => world_size = REPLICAS * NPROC_PER_NODE |
| 46 | +USE_GPU = False |
| 47 | +REPLICAS = 1 # one pod: torchrun's in-pod restart then needs no cross-node re-rendezvous |
| 48 | +NPROC_PER_NODE = 2 # processes per pod => world_size = REPLICAS * NPROC_PER_NODE |
39 | 49 |
|
40 | 50 | _BACKEND = "nccl" if USE_GPU else "gloo" |
41 | 51 |
|
42 | 52 | resources = ( |
43 | | - flyte.Resources(cpu=(2, 4), memory=("4Gi", "8Gi"), gpu="L4:1") |
| 53 | + flyte.Resources(cpu=(2, 4), memory=("4Gi", "8Gi"), gpu="L4:2") # one GPU per process (NPROC_PER_NODE) |
44 | 54 | if USE_GPU |
45 | 55 | else flyte.Resources(cpu=(1, 2), memory=("1Gi", "2Gi")) |
46 | 56 | ) |
|
51 | 61 | resources=resources, |
52 | 62 | replicas=REPLICAS, |
53 | 63 | nproc_per_node=NPROC_PER_NODE, |
54 | | - runtime=TorchRun(rdzv_backend="static", max_restarts=0), |
55 | | - failure_policy=ClusterFailurePolicy(max_restarts=1), # allow ONE JobSet restart |
| 64 | + # max_restarts here is not wired through to torchrun yet; PET_MAX_RESTARTS below is what works today. |
| 65 | + runtime=TorchRun(rdzv_backend="static", max_restarts=1), |
| 66 | + failure_policy=ClusterFailurePolicy(max_restarts=0), # every attempt looks terminal to the SDK gate |
| 67 | + env_vars={"PET_MAX_RESTARTS": "1"}, # ONE in-pod torchrun restart (see module docstring) |
56 | 68 | ) |
57 | 69 |
|
58 | 70 |
|
59 | 71 | @env.task |
60 | 72 | async def train_ddp_with_restart(steps: int = 50, lr: float = 0.05) -> float: |
61 | | - """Fail on the first JobSet attempt, then train + return loss on the restart.""" |
62 | | - restart_attempt = int(os.environ.get("JOBSET_RESTART_ATTEMPT", "0") or "0") |
| 73 | + """Fail on the first torchrun attempt, then train + return loss on the in-pod restart.""" |
| 74 | + restart_attempt = int(os.environ.get("TORCHELASTIC_RESTART_COUNT", "0") or "0") |
63 | 75 | rank = os.environ.get("RANK", "0") |
64 | | - print(f"[rank {rank}] JOBSET_RESTART_ATTEMPT={restart_attempt}", flush=True) |
| 76 | + print( |
| 77 | + f"[rank {rank}] TORCHELASTIC_RESTART_COUNT={restart_attempt} " |
| 78 | + f"JOBSET_RESTART_ATTEMPT={flyte.ctx().restart_attempt}", |
| 79 | + flush=True, |
| 80 | + ) |
65 | 81 |
|
66 | | - # Attempt 0 fails on every worker -> writes error.pb for the execution. |
| 82 | + # Attempt 0 fails on every rank -> rank-0 writes error.pb (the SDK gate sees 0 >= 0) and every |
| 83 | + # worker exits 1, so torchrun restarts the worker group in-pod. |
67 | 84 | if restart_attempt == 0: |
68 | | - raise RuntimeError("Intentional failure on attempt 0 to force a JobSet restart") |
| 85 | + raise RuntimeError("Intentional failure on torchrun attempt 0 to leave a stale error.pb behind") |
69 | 86 |
|
70 | 87 | import torch |
71 | 88 | import torch.distributed as dist |
@@ -120,5 +137,5 @@ async def train_ddp_with_restart(steps: int = 50, lr: float = 0.05) -> float: |
120 | 137 | run = flyte.run(train_ddp_with_restart, steps=50) |
121 | 138 | print("Run URL:", run.url) |
122 | 139 | run.wait() |
123 | | - # Expected WITH the fix: SUCCEEDED. Without it: FAILED (stale error.pb). |
| 140 | + # Expected WITH the cleanup: SUCCEEDED. Without it: FAILED (stale error.pb from attempt 0). |
124 | 141 | print("Final phase:", run.phase) |
0 commit comments