Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/actions/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ runs:
export PYTHONPATH=$(pwd)
export NEMORUN_HOME=$(pwd)
export NCCL_DEBUG=INFO
export PIP_DEFAULT_TIMEOUT=120
export PIP_RETRIES=5
export UV_HTTP_TIMEOUT=120
uv venv .venv
uv cache clean
uv sync --no-cache --only-group test
Expand Down Expand Up @@ -169,6 +172,9 @@ runs:

export PYTHONPATH=$(pwd)
export NEMORUN_HOME=$(pwd)
export PIP_DEFAULT_TIMEOUT=120
export PIP_RETRIES=5
export UV_HTTP_TIMEOUT=120
uv venv .venv
uv cache clean
uv sync --no-cache --only-group test
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/_build_test_publish_wheel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ jobs:

pushd $BUILD_DIR
rm LICENSE || true
for i in 1 2 3; do
docker pull "$IMAGE" && break
echo "docker pull attempt $i failed, retrying..."
sleep 10
done
docker run --rm -e NO_VCS_VERSION=1 -v $(pwd):/workspace -w /workspace $IMAGE bash -c '\
for python_version in cp311 cp312 cp313; do \
/opt/python/${python_version}-${python_version}/bin/pip install --upgrade "setuptools>=80" build; \
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/cicd-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,8 @@ jobs:
PIP_DISABLE_PIP_VERSION_CHECK: 1
PIP_NO_PYTHON_VERSION_WARNING: 1
PIP_ROOT_USER_ACTION: ignore
PIP_DEFAULT_TIMEOUT: 120
PIP_RETRIES: 5
steps:
- name: Checkout
uses: actions/checkout@v6
Expand Down Expand Up @@ -865,6 +867,8 @@ jobs:
PIP_DISABLE_PIP_VERSION_CHECK: 1
PIP_NO_PYTHON_VERSION_WARNING: 1
PIP_ROOT_USER_ACTION: ignore
PIP_DEFAULT_TIMEOUT: 120
PIP_RETRIES: 5
if: |
!cancelled()
&& needs.cicd-integration-gate.outputs.should_run == 'true'
Expand Down Expand Up @@ -962,6 +966,8 @@ jobs:
PIP_DISABLE_PIP_VERSION_CHECK: 1
PIP_NO_PYTHON_VERSION_WARNING: 1
PIP_ROOT_USER_ACTION: ignore
PIP_DEFAULT_TIMEOUT: 120
PIP_RETRIES: 5
if: |
!cancelled()
&& needs.cicd-integration-gate.outputs.should_run == 'true'
Expand Down
43 changes: 38 additions & 5 deletions tests/test_utils/python_scripts/launch_nemo_run_workload.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,50 @@ def is_flaky_failure(concat_allranks_logs: str) -> bool:
or "zmq.error.ZMQError: Address already in use" in concat_allranks_logs
or "We couldn't connect to 'https://huggingface.co'" in concat_allranks_logs
or "Unpack failed: incomplete input" in concat_allranks_logs
or "The read operation timed out" in concat_allranks_logs
or "Read timed out" in concat_allranks_logs
or "TimeoutError" in concat_allranks_logs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "TimeoutError" match is too broad

TimeoutError is a Python builtin that appears in stack traces for any kind of timeout — NCCL collective timeouts, test-harness wait timeouts, asyncio cancellations, custom training deadlines — not just network I/O. Any genuine performance regression that causes a training job to exceed its allotted time will match this string, get classified as flaky, and be silently retried up to the configured number of attempts, masking the regression entirely. The other two new network patterns ("The read operation timed out" and "Read timed out") are already specific enough to catch the pip/uv scenario described in the PR. Consider removing the bare "TimeoutError" or replacing it with a more qualified string such as "TimeoutError: The read operation" to keep it scoped to network I/O.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

or "Connection broken" in concat_allranks_logs
or "Temporary failure in name resolution" in concat_allranks_logs
or "unspecified launch failure" in concat_allranks_logs
or "free(): corrupted unsorted chunks" in concat_allranks_logs
or "Segfault encountered" in concat_allranks_logs
or "The following metrics failed" in concat_allranks_logs
)


def _collect_failure_logs(workdir: pathlib.Path) -> list[str]:
"""Reads every log file that may carry a flaky-failure signature.

The per-rank ``attempt_0/*/std*.log`` files only contain torchrun training
output. The golden-value comparison runs in ``run_ci_test.sh`` and emits its
assertion (e.g. ``The following metrics failed``) to the nemo-run task log,
which lives outside that per-rank tree. Globbing both ensures harness-side
failures are seen by ``is_flaky_failure`` and therefore eligible for retry.

Args:
workdir: Working directory under which nemo-run writes all log files.

Returns:
The concatenated lines of every discovered log file, deduplicated by
resolved path to avoid double-counting overlapping globs.
"""
seen_paths = set()
collected_lines: list[str] = []
for pattern in ("**/attempt_0/*/std*.log", "**/*.log"):
for log_file_path in workdir.glob(pattern):
resolved = log_file_path.resolve()
if resolved in seen_paths or not log_file_path.is_file():
continue
seen_paths.add(resolved)
try:
with open(log_file_path, "r", errors="replace") as f:
collected_lines.extend(f.readlines())
Comment on lines +71 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unbounded **/*.log glob may read very large log sets into memory

workdir.glob("**/*.log") is recursive and matches every .log file under the working directory, including build artefacts, prior run logs, or any container-mounted directories that happen to be under cwd. In a large CI environment with multiple prior attempts or deep output trees, this can pull hundreds of megabytes of log data into a single in-memory list, potentially causing the launcher process itself to be OOM-killed. Bounding the search depth or capping total bytes read would guard against this.

except OSError as error:
logger.warning("Could not read log file %s: %s", log_file_path, error)
return collected_lines


@click.command()
@click.option("--scope", required=True, type=str, help="Scope of the workload")
@click.option("--model", required=True, type=str, help="Model of the workload")
Expand Down Expand Up @@ -194,12 +231,8 @@ def __getattr__(self, name):
sys.exit(0)

logger.error(f"Job failed with status: {job_dict['status']}")
log_file_paths = pathlib.Path(os.getcwd()).glob("**/attempt_0/*/std*.log")
all_ranks_all_logs = [tee_buffer.getvalue()]
for log_file_path in log_file_paths:
with open(log_file_path, "r") as f:
all_logs = f.readlines()
all_ranks_all_logs.extend(all_logs)
all_ranks_all_logs.extend(_collect_failure_logs(pathlib.Path(os.getcwd())))
all_ranks_all_logs_string = "\n".join(all_ranks_all_logs)
if is_flaky_failure(all_ranks_all_logs_string):
logger.warning("Detected flaky failure, attempt restart.")
Expand Down
Loading