Skip to content

Commit 8452734

Browse files
authored
test(train): Fail fast in HyperPod SFT integ tests (#6449)
The two HyperPod Nova Micro SFT integ tests polled only for the job's S3 manifest, for up to 6 hours. When the cluster cannot schedule the job, or the job succeeds but its outputs are never synced to S3, the manifest never appears, so the tests held the GPU integ build until CodeBuild timed out, and every run left another PyTorchJob queued in the cluster. Add tests/integ/train/hyperpod_job_wait.py, which also watches the PyTorchJob through the Kubernetes API: - fail immediately if the job fails; - skip if the job does not start running within 30 minutes; - fail if it runs for more than 75 minutes; - fail with a pointer to the output sync if the job succeeded but no manifest appears within 15 minutes. The job is removed from the cluster when the wait ends (helm uninstall of the HyperPod CLI release, falling back to deleting the PyTorchJob).
1 parent e02c7f7 commit 8452734

3 files changed

Lines changed: 242 additions & 57 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
"""Bounded wait for deep integ tests that train on a HyperPod (EKS) cluster.
14+
15+
``SFTTrainer.train()`` on HyperPod submits a Kubeflow PyTorchJob through the
16+
HyperPod CLI and returns its name. Completion is signalled by
17+
``{s3_output_path}/{job_name}/manifest.json``, which a service-managed component
18+
copies from the cluster to S3 after training. Polling only for that manifest
19+
cannot tell "still training" from "never scheduled" or "succeeded but the
20+
outputs were never synced", so a broken cluster made such tests poll for hours
21+
until the CodeBuild build timed out, and every run left another job queued in
22+
the cluster.
23+
24+
``wait_for_hyperpod_job`` also watches the PyTorchJob itself:
25+
26+
* the job failed -> fail immediately with its condition message;
27+
* the job never starts running within ``schedule_timeout`` (no status, or pods
28+
that stay Pending) -> skip as a cluster capacity/scheduling problem;
29+
* the job runs longer than ``run_timeout`` -> fail;
30+
* the job succeeded but no manifest appears within ``manifest_timeout`` -> fail
31+
with a message pointing at the output sync.
32+
33+
Whatever the outcome, the job is removed from the cluster when the wait ends
34+
(``helm uninstall`` of the release the HyperPod CLI created, falling back to
35+
deleting the PyTorchJob), so tests no longer leave jobs behind.
36+
"""
37+
38+
from __future__ import absolute_import
39+
40+
import logging
41+
import subprocess
42+
import time
43+
44+
import pytest
45+
46+
from sagemaker.train.base_trainer import BaseTrainer
47+
48+
logger = logging.getLogger(__name__)
49+
50+
PYTORCHJOB_GROUP = "kubeflow.org"
51+
PYTORCHJOB_VERSION = "v1"
52+
PYTORCHJOB_PLURAL = "pytorchjobs"
53+
54+
# Healthy HyperPod Nova Micro SFT jobs in this suite were scheduled within
55+
# seconds and finished in ~25-55 minutes, and the manifest followed within a
56+
# few minutes. Worst case for one test: 30 + 75 + 15 minutes, well inside the
57+
# CI build budget.
58+
DEFAULT_SCHEDULE_TIMEOUT_SECONDS = 30 * 60
59+
DEFAULT_RUN_TIMEOUT_SECONDS = 75 * 60
60+
DEFAULT_MANIFEST_TIMEOUT_SECONDS = 15 * 60
61+
DEFAULT_POLL_INTERVAL_SECONDS = 60
62+
63+
64+
def _load_k8s_clients():
65+
"""Return (CustomObjectsApi, CoreV1Api) for the kubeconfig written by
66+
``hyperpod connect-cluster`` during ``train()``.
67+
68+
``kubernetes`` is a dependency of the HyperPod CLI, which these tests need
69+
anyway, so it is imported lazily.
70+
"""
71+
from kubernetes import client, config
72+
73+
config.load_kube_config()
74+
return client.CustomObjectsApi(), client.CoreV1Api()
75+
76+
77+
def _true_condition(job_obj, condition_type):
78+
for cond in (job_obj.get("status") or {}).get("conditions") or []:
79+
if cond.get("type") == condition_type and str(cond.get("status")) == "True":
80+
return cond
81+
return None
82+
83+
84+
def _pod_phases(core_api, namespace, job_name):
85+
pods = core_api.list_namespaced_pod(
86+
namespace, label_selector=f"training.kubeflow.org/job-name={job_name}"
87+
)
88+
return [p.status.phase for p in pods.items]
89+
90+
91+
def delete_hyperpod_job(job_name, namespace="kubeflow", custom_api=None):
92+
"""Best-effort removal of a HyperPod CLI job from the cluster.
93+
94+
The CLI installs each job as a Helm release named after the job (one
95+
ConfigMap plus one PyTorchJob), so ``helm uninstall`` removes both. If helm is
96+
unavailable or fails, delete the PyTorchJob directly.
97+
"""
98+
try:
99+
result = subprocess.run(
100+
["helm", "uninstall", job_name, "--namespace", namespace],
101+
capture_output=True,
102+
text=True,
103+
)
104+
if result.returncode == 0:
105+
logger.info("Removed HyperPod job %s (helm uninstall)", job_name)
106+
return
107+
logger.warning("helm uninstall %s failed: %s", job_name, result.stderr.strip())
108+
except FileNotFoundError:
109+
logger.warning("helm not on PATH; deleting PyTorchJob %s directly", job_name)
110+
try:
111+
if custom_api is None:
112+
custom_api, _ = _load_k8s_clients()
113+
custom_api.delete_namespaced_custom_object(
114+
PYTORCHJOB_GROUP, PYTORCHJOB_VERSION, namespace, PYTORCHJOB_PLURAL, job_name
115+
)
116+
logger.info("Deleted PyTorchJob %s", job_name)
117+
except Exception as e: # pylint: disable=broad-except
118+
logger.warning("Failed to delete PyTorchJob %s: %s", job_name, e)
119+
120+
121+
def wait_for_hyperpod_job(
122+
job_name,
123+
output_s3_path,
124+
sagemaker_session=None,
125+
namespace="kubeflow",
126+
schedule_timeout=DEFAULT_SCHEDULE_TIMEOUT_SECONDS,
127+
run_timeout=DEFAULT_RUN_TIMEOUT_SECONDS,
128+
manifest_timeout=DEFAULT_MANIFEST_TIMEOUT_SECONDS,
129+
poll_interval=DEFAULT_POLL_INTERVAL_SECONDS,
130+
cleanup=True,
131+
):
132+
"""Wait for a HyperPod PyTorchJob and its S3 manifest, failing fast.
133+
134+
Args:
135+
job_name (str): Job name returned by ``train(wait=False)``.
136+
output_s3_path (str): The trainer's ``s3_output_path``.
137+
sagemaker_session: Session used to read the manifest from S3.
138+
namespace (str): Kubernetes namespace the job was submitted to.
139+
schedule_timeout (int): Seconds the job may wait to start running before
140+
the test is skipped.
141+
run_timeout (int): Seconds the job may run before the test fails.
142+
manifest_timeout (int): Seconds after the job succeeded that the manifest
143+
may take to appear before the test fails.
144+
poll_interval (int): Seconds between checks.
145+
cleanup (bool): Remove the job from the cluster when the wait ends.
146+
147+
Returns:
148+
str: The checkpoint S3 URI from the manifest.
149+
"""
150+
custom_api, core_api = _load_k8s_clients()
151+
start = time.time()
152+
running_since = None
153+
succeeded_since = None
154+
# Hard cap so persistent API errors cannot keep the loop alive past the phases.
155+
max_wait = schedule_timeout + run_timeout + manifest_timeout
156+
try:
157+
while time.time() - start < max_wait:
158+
checkpoint = BaseTrainer._resolve_checkpoint_from_manifest(
159+
job_name=job_name,
160+
output_s3_path=output_s3_path,
161+
sagemaker_session=sagemaker_session,
162+
)
163+
if checkpoint:
164+
logger.info("Job %s produced checkpoint %s", job_name, checkpoint)
165+
return checkpoint
166+
167+
now = time.time()
168+
try:
169+
job_obj = custom_api.get_namespaced_custom_object(
170+
PYTORCHJOB_GROUP, PYTORCHJOB_VERSION, namespace, PYTORCHJOB_PLURAL, job_name
171+
)
172+
phases = _pod_phases(core_api, namespace, job_name)
173+
except Exception as e: # pylint: disable=broad-except
174+
if getattr(e, "status", None) == 404:
175+
pytest.fail(f"PyTorchJob {job_name} no longer exists in {namespace}.")
176+
logger.warning("Could not read PyTorchJob %s (will retry): %s", job_name, e)
177+
time.sleep(poll_interval)
178+
continue
179+
180+
failed = _true_condition(job_obj, "Failed")
181+
if failed:
182+
pytest.fail(
183+
f"HyperPod job {job_name} failed: {failed.get('reason')}: "
184+
f"{failed.get('message')}"
185+
)
186+
187+
if _true_condition(job_obj, "Succeeded"):
188+
succeeded_since = succeeded_since or now
189+
if now - succeeded_since > manifest_timeout:
190+
pytest.fail(
191+
f"HyperPod job {job_name} succeeded but no manifest appeared at "
192+
f"{output_s3_path.rstrip('/')}/{job_name}/manifest.json within "
193+
f"{manifest_timeout}s; the cluster's output sync to S3 is not "
194+
f"delivering job outputs."
195+
)
196+
else:
197+
if _true_condition(job_obj, "Running") or "Running" in phases:
198+
running_since = running_since or now
199+
if now - running_since > run_timeout:
200+
pytest.fail(
201+
f"HyperPod job {job_name} still running after {run_timeout}s "
202+
f"(pod phases: {phases})."
203+
)
204+
elif running_since is None and now - start > schedule_timeout:
205+
pytest.skip(
206+
f"HyperPod job {job_name} did not start within {schedule_timeout}s "
207+
f"(job conditions: {(job_obj.get('status') or {}).get('conditions')}, "
208+
f"pod phases: {phases}); the cluster has no schedulable capacity."
209+
)
210+
time.sleep(poll_interval)
211+
pytest.fail(f"HyperPod job {job_name} did not complete within {max_wait}s.")
212+
finally:
213+
if cleanup:
214+
delete_hyperpod_job(job_name, namespace=namespace, custom_api=custom_api)

‎sagemaker-train/tests/integ/train/test_nova_sft_hyperpod.py‎

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from sagemaker.core.training.configs import HyperPodCompute
2626
from sagemaker.core.shapes import StoppingCondition
2727

28+
from .hyperpod_job_wait import wait_for_hyperpod_job
29+
2830
logger = logging.getLogger(__name__)
2931

3032
S3_BUCKET_PREFIX = "mc-flows-sdk-testing"
@@ -114,28 +116,12 @@ def test_sft_trainer_nova_micro_hyperpod_lora(
114116

115117
logger.info(f"HyperPod SFT job submitted: {job_name}")
116118

117-
# Poll for job completion by checking for the manifest in S3
118-
from sagemaker.train.base_trainer import BaseTrainer
119-
120-
max_wait_time = 21600 # 6 hour timeout (HyperPod jobs can take longer)
121-
poll_interval = 60 # Check every 60 seconds
122-
start_time = time.time()
123-
checkpoint_path = None
124-
125-
while time.time() - start_time < max_wait_time:
126-
checkpoint_path = BaseTrainer._resolve_checkpoint_from_manifest(
127-
job_name=job_name,
128-
output_s3_path=s3_output_path,
129-
)
130-
if checkpoint_path:
131-
logger.info(f"Checkpoint resolved: {checkpoint_path}")
132-
break
133-
134-
elapsed = int(time.time() - start_time)
135-
logger.info(f"Waiting for manifest... ({elapsed}s elapsed)")
136-
time.sleep(poll_interval)
137-
138-
assert (
139-
checkpoint_path is not None
140-
), f"Job {job_name} did not produce a manifest within {max_wait_time}s"
119+
# Wait for the PyTorchJob and its S3 manifest, failing fast on a stuck cluster,
120+
# and remove the job from the cluster afterwards (see hyperpod_job_wait.py).
121+
checkpoint_path = wait_for_hyperpod_job(
122+
job_name=job_name,
123+
output_s3_path=s3_output_path,
124+
namespace=hyperpod_compute.namespace,
125+
)
126+
assert checkpoint_path is not None
141127
logger.info(f"Training complete. Checkpoint: {checkpoint_path}")

‎sagemaker-train/tests/integ/train/test_sft_data_mixing_hyperpod.py‎

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,16 @@
3333
import logging
3434
import time
3535
import random
36+
import subprocess
3637

3738
import pytest
3839
from sagemaker.train.sft_trainer import SFTTrainer
3940
from sagemaker.train.common import TrainingType
40-
from sagemaker.train.base_trainer import BaseTrainer
4141
from sagemaker.train.data_mixing_config import DataMixingConfig
4242
from sagemaker.core.training.configs import HyperPodCompute
4343

44+
from .hyperpod_job_wait import delete_hyperpod_job, wait_for_hyperpod_job
45+
4446
logging.basicConfig(
4547
level=logging.INFO,
4648
format="%(levelname)s - %(name)s - %(message)s",
@@ -179,43 +181,26 @@ def test_sft_trainer_nova_micro_data_mixing_hyperpod(
179181
logger.info(f"HyperPod training job submitted: {job_name}")
180182

181183
# Verify the job exists on the cluster via hyperpod get-job
182-
import subprocess
183-
184+
namespace = compute.namespace or "kubeflow"
184185
get_job_result = subprocess.run(
185186
["hyperpod", "get-job", "--job-name", job_name],
186187
capture_output=True,
187188
text=True,
188189
)
189-
assert (
190-
get_job_result.returncode == 0
191-
), f"hyperpod get-job failed for '{job_name}': {get_job_result.stderr}"
190+
if get_job_result.returncode != 0:
191+
delete_hyperpod_job(job_name, namespace=namespace)
192+
pytest.fail(f"hyperpod get-job failed for '{job_name}': {get_job_result.stderr}")
192193
logger.info(f"Verified job '{job_name}' exists on the cluster using hp-cli.")
193194

194-
# Poll for job completion by checking for the manifest in S3.
195-
# The manifest is written under {output_s3_path}/{job_name}/manifest.json
196-
# once training finishes, so its presence confirms end-to-end completion.
197-
198-
output_s3_path = training_resources["s3_output_path"]
199-
max_wait_time = 21600 # 6 hour timeout (HyperPod jobs can take longer)
200-
poll_interval = 60 # Check every 60 seconds
201-
start_time = time.time()
202-
checkpoint_path = None
203-
204-
while time.time() - start_time < max_wait_time:
205-
checkpoint_path = BaseTrainer._resolve_checkpoint_from_manifest(
206-
job_name=job_name,
207-
output_s3_path=output_s3_path,
208-
sagemaker_session=sagemaker_session_us_east_1,
209-
)
210-
if checkpoint_path:
211-
logger.info(f"Checkpoint resolved: {checkpoint_path}")
212-
break
213-
214-
elapsed = int(time.time() - start_time)
215-
logger.info(f"Waiting for manifest... ({elapsed}s elapsed)")
216-
time.sleep(poll_interval)
217-
218-
assert (
219-
checkpoint_path is not None
220-
), f"Job {job_name} did not produce a manifest within {max_wait_time}s"
195+
# Wait for the PyTorchJob and its S3 manifest
196+
# ({output_s3_path}/{job_name}/manifest.json), failing fast on a stuck
197+
# cluster, and remove the job from the cluster afterwards
198+
# (see hyperpod_job_wait.py).
199+
checkpoint_path = wait_for_hyperpod_job(
200+
job_name=job_name,
201+
output_s3_path=training_resources["s3_output_path"],
202+
sagemaker_session=sagemaker_session_us_east_1,
203+
namespace=namespace,
204+
)
205+
assert checkpoint_path is not None
221206
logger.info(f"Training complete. Checkpoint: {checkpoint_path}")

0 commit comments

Comments
 (0)