unsloth/tests/python/test_docker_cpu_fallback.py
Daniel Han 50b9deacb0
Trim comments in tests (#13844)
Comment and docstring changes only, in test files, split out of #12968 so the test-only part can merge first.
2026-10-15 05:30:27 -07:00

458 lines
20 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""run.sh must not pass --gpus without an NVIDIA GPU; the Studio image defaults UNSLOTH_ALLOW_CPU on."""
import os
import re
import shutil
import stat
import subprocess
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
_DOCKER = os.path.join(os.path.dirname(os.path.dirname(_HERE)), "docker")
_RUN_SH = os.path.join(_DOCKER, "run.sh")
_STUDIO_DF = os.path.join(_DOCKER, "Dockerfile.studio")
_BASE_DF = os.path.join(_DOCKER, "Dockerfile")
_ENTRYPOINT = os.path.join(_DOCKER, "entrypoint.sh")
# Git Bash breaks on path translation and the exec bit; upstream runs this on ubuntu only.
_posix_shell = pytest.mark.skipif(
os.name != "posix" or shutil.which("bash") is None,
reason = "POSIX shell required",
)
def _stub(path, body):
with open(path, "w") as f:
f.write("#!/usr/bin/env bash\n" + body)
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
def _invoke_run_sh(
tmp_path,
*,
nvidia,
amd,
groups = "both",
image = None,
extra_env = None,
):
"""Returns the argv docker/run.sh passes to docker run, via a recording docker stub."""
bindir = tmp_path / "bin"
bindir.mkdir()
argv_log = tmp_path / "argv"
_stub(
str(bindir / "docker"),
'if [ "$1" = "info" ]; then echo " Runtimes: io.containerd.runc.v2 runc"; exit 0; fi\n'
'printf "%s\\n" "$@" > ' + str(argv_log) + "\nexit 0\n",
)
# Always shadowed: a real GPU on /usr/bin would make the no-NVIDIA case vacuous.
if nvidia:
_stub(str(bindir / "nvidia-smi"), 'echo "GPU 0: NVIDIA H100 (UUID: GPU-abc)"\n')
else:
_stub(str(bindir / "nvidia-smi"), "exit 1\n")
# Always shadowed: the host's real groups would make missing-group cases unreachable.
known = {"both": ("44", "992"), "video_only": ("44", None), "none": (None, None)}
vid, ren = known[groups]
_stub(
str(bindir / "getent"),
'case "$2" in\n'
+ (f' video) echo "video:x:{vid}:"; exit 0 ;;\n' if vid else " video) exit 2 ;;\n")
+ (f' render) echo "render:x:{ren}:"; exit 0 ;;\n' if ren else " render) exit 2 ;;\n")
+ "esac\nexit 2\n",
)
dev_root = tmp_path / "root"
(dev_root / "dev").mkdir(parents = True)
if nvidia:
(dev_root / "dev" / "nvidiactl").write_text("")
if amd:
(dev_root / "dev" / "kfd").write_text("")
(dev_root / "dev" / "dri").mkdir()
env = dict(os.environ)
env["PATH"] = str(bindir) + ":/usr/bin:/bin"
env["UNSLOTH_DEV_ROOT"] = str(dev_root)
env["HOME"] = str(tmp_path / "home")
env["UNSLOTH_WORKDIR"] = str(tmp_path)
for leak in (
"HF_TOKEN",
"WANDB_API_KEY",
"UNSLOTH_GPUS",
"UNSLOTH_ALLOW_CPU",
"UNSLOTH_STUDIO_VOLUME",
"UNSLOTH_WORKSPACE_VOLUME",
):
env.pop(leak, None)
env.pop("UNSLOTH_IMAGE", None)
if image:
env["UNSLOTH_IMAGE"] = image
env.update(extra_env or {})
# Absolute: the absent case strips /usr/bin from PATH.
proc = subprocess.run(
[shutil.which("bash") or "/bin/bash", _RUN_SH, "true"],
env = env,
capture_output = True,
text = True,
timeout = 120,
)
assert proc.returncode == 0, f"run.sh failed: {proc.stderr}"
return argv_log.read_text().splitlines(), proc.stderr
@_posix_shell
class TestRunShDegradesWithoutNvidia:
def test_gpus_flag_is_dropped_when_the_host_has_no_nvidia_gpu(self, tmp_path):
"""`--gpus all` on an NVIDIA-less host is exit 125 AT THE DAEMON, so the
container never starts and entrypoint.sh never gets to explain itself."""
argv, stderr = _invoke_run_sh(tmp_path, nvidia = False, amd = False)
assert "--gpus" not in argv, f"run.sh still passed --gpus: {argv}"
assert "no NVIDIA GPU on this host" in stderr
def test_amd_host_gets_the_render_nodes_with_numeric_gids(self, tmp_path):
"""--group-add by NAME resolves inside the container, where the host's
video/render groups do not exist, so the gids must be numeric."""
argv, stderr = _invoke_run_sh(tmp_path, nvidia = False, amd = True)
assert "--gpus" not in argv
assert "--device" in argv
assert "/dev/kfd" in argv and "/dev/dri" in argv
gids = [argv[i + 1] for i, a in enumerate(argv) if a == "--group-add"]
assert all(g.isdigit() for g in gids), f"non-numeric --group-add: {gids}"
# Torch is cu128 and bundled llama.cpp has no HIP/Vulkan backend.
assert "HIP" in stderr and "runs on the CPU" in stderr, (
"an AMD host must be told the container still runs on the CPU:\n" + stderr
)
def test_the_untagged_published_name_is_recognised(self, tmp_path):
"""`unsloth/unsloth` is :latest to Docker, so the AMD notice has to treat it as a
published image rather than as somebody else's."""
_, stderr = _invoke_run_sh(tmp_path, nvidia = False, amd = True, image = "unsloth/unsloth")
assert "runs on the CPU" in stderr, stderr
assert "up to that image" not in stderr, stderr
def test_the_cpu_only_claim_is_scoped_to_the_published_images(self, tmp_path):
"""A custom image may carry a HIP or Vulkan build; run.sh cannot know, so it
must not claim the container runs on the CPU."""
argv, stderr = _invoke_run_sh(tmp_path, nvidia = False, amd = True, image = "myorg/custom:rocm")
assert "/dev/kfd" in argv
assert "runs on the CPU" not in stderr, stderr
assert "myorg/custom:rocm" in stderr and "unsloth/unsloth" in stderr, stderr
def test_the_group_lookup_is_guarded_on_getent_existing(self):
"""A host with no getent at all (busybox, some slim images) must skip the
lookup rather than fail it."""
body = open(_RUN_SH, encoding = "utf-8").read()
idx = body.index("getent group")
assert "command -v getent" in body[:idx]
@pytest.mark.parametrize("groups", ["none", "video_only"])
def test_a_missing_group_record_does_not_abort_the_run(self, tmp_path, groups):
"""getent failing for a missing group must not abort run.sh under pipefail; use the gids
that exist."""
argv, _ = _invoke_run_sh(tmp_path, nvidia = False, amd = True, groups = groups)
assert "/dev/kfd" in argv and "/dev/dri" in argv
assert "--gpus" not in argv
gids = [argv[i + 1] for i, a in enumerate(argv) if a == "--group-add"]
assert all(g.isdigit() for g in gids), f"non-numeric --group-add: {gids}"
expected = {"none": 0, "video_only": 1}[groups]
assert len(gids) == expected, f"expected {expected} gids, got {gids}"
def test_an_nvidia_host_is_untouched(self, tmp_path):
"""The degrade path must not fire where --gpus actually works."""
argv, _ = _invoke_run_sh(tmp_path, nvidia = True, amd = False)
assert "--gpus" in argv
assert "all" in argv
assert "/dev/kfd" not in argv
def _studio_mounts(argv):
return [
argv[i + 1]
for i, a in enumerate(argv)
if a == "-v" and argv[i + 1].endswith(":/opt/unsloth-studio")
]
@_posix_shell
class TestRunShMountsTheStudioVolume:
"""Studio's accounts, chats and trained models live under /opt/unsloth-studio.
The image links its own code in there at every start, so a named volume keeps
the data across `docker rm` without pinning the first image's code. The helper
has to mount it, or the quick start in DOCKERHUB.md keeps data and the helper
does not."""
def test_the_default_is_a_named_volume(self, tmp_path):
argv, _ = _invoke_run_sh(tmp_path, nvidia = True, amd = False)
assert _studio_mounts(argv) == ["unsloth-studio:/opt/unsloth-studio"], argv
@pytest.mark.parametrize("nvidia, amd", [(False, True), (False, False)])
def test_the_amd_and_cpu_paths_mount_it_too(self, tmp_path, nvidia, amd):
argv, _ = _invoke_run_sh(tmp_path, nvidia = nvidia, amd = amd)
assert _studio_mounts(argv) == ["unsloth-studio:/opt/unsloth-studio"], argv
def test_an_empty_value_disables_the_mount(self, tmp_path):
"""`${VAR-default}`, not `${VAR:-default}`: an explicitly empty value is an
opt-out, for example on :core or for a throwaway run."""
argv, _ = _invoke_run_sh(
tmp_path, nvidia = True, amd = False, extra_env = {"UNSLOTH_STUDIO_VOLUME": ""}
)
assert _studio_mounts(argv) == [], argv
def test_a_custom_name_or_host_path_is_one_argv(self, tmp_path):
"""A bind mount path with a space must not be word-split."""
host = str(tmp_path / "studio home")
argv, _ = _invoke_run_sh(
tmp_path, nvidia = True, amd = False, extra_env = {"UNSLOTH_STUDIO_VOLUME": host}
)
assert _studio_mounts(argv) == [f"{host}:/opt/unsloth-studio"], argv
def test_the_flag_is_documented_in_the_header(self):
body = open(_RUN_SH, encoding = "utf-8").read()
assert "UNSLOTH_STUDIO_VOLUME=unsloth-studio" in body
def _workspace_mounts(argv):
return [
argv[i + 1] for i, a in enumerate(argv) if a == "-v" and argv[i + 1].endswith(":/workspace")
]
@_posix_shell
class TestRunShKeepsTheWorkspace:
@pytest.mark.parametrize("nvidia, amd", [(True, False), (False, True), (False, False)])
def test_notebooks_and_their_saves_survive_the_container(self, tmp_path, nvidia, amd):
argv, _ = _invoke_run_sh(tmp_path, nvidia = nvidia, amd = amd)
assert _workspace_mounts(argv) == ["unsloth-workspace:/workspace"], argv
def test_an_empty_value_disables_the_mount(self, tmp_path):
argv, _ = _invoke_run_sh(
tmp_path, nvidia = True, amd = False, extra_env = {"UNSLOTH_WORKSPACE_VOLUME": ""}
)
assert _workspace_mounts(argv) == [], argv
def test_the_nested_mounts_are_still_passed(self, tmp_path):
argv, _ = _invoke_run_sh(tmp_path, nvidia = True, amd = False)
targets = [argv[i + 1].rsplit(":", 1)[1] for i, a in enumerate(argv) if a == "-v"]
for nested in (
"/workspace/host",
"/workspace/.cache/huggingface",
"/workspace/.cache/triton",
):
assert nested in targets, argv
class TestStudioImageAllowsCpu:
def test_studio_image_opts_in_through_its_own_variable(self):
""":latest is the Studio image: without an opt-in every CPU-only, AMD and Docker-Desktop user gets an exit 1."""
body = open(_STUDIO_DF, encoding = "utf-8").read()
env_lines = [
ln.strip()
for ln in body.splitlines()
if "UNSLOTH_IMAGE_ALLOW_CPU=1" in ln and not ln.strip().startswith("#")
]
assert env_lines, "Dockerfile.studio does not default UNSLOTH_IMAGE_ALLOW_CPU=1"
def test_studio_image_env_never_carries_allow_cpu(self):
"""An image ENV reaches every process, so UNSLOTH_ALLOW_CPU=1 there broke training on GPU hosts. Only install.sh may see it, inline."""
body = open(_STUDIO_DF, encoding = "utf-8").read()
env_block = body[body.index("ENV UNSLOTH_STUDIO_HOME") :]
env_block = env_block[: env_block.index("\n\n")]
assert "UNSLOTH_ALLOW_CPU" not in env_block.replace("UNSLOTH_IMAGE_ALLOW_CPU", "")
inline = [
ln.strip()
for ln in body.splitlines()
if "UNSLOTH_ALLOW_CPU=1" in ln and not ln.strip().startswith("#")
]
assert inline == ["UNSLOTH_ALLOW_CPU=1 \\"], inline
def test_the_studio_image_bundles_the_entrypoint_that_reads_its_opt_in(self):
"""The published :core entrypoint predates UNSLOTH_IMAGE_ALLOW_CPU, so without its own copy a standalone build opts in and still exits 1."""
body = open(_STUDIO_DF, encoding = "utf-8").read()
assert re.search(
r"^COPY\s+entrypoint\.sh\s+/usr/local/bin/unsloth-entrypoint\s*$",
body,
re.M,
), "Dockerfile.studio does not bundle its own entrypoint"
def test_the_studio_image_keeps_the_base_entrypoint_directive(self):
"""The bundled file replaces the base's copy at the SAME path and the base's
ENTRYPOINT directive stays: a second ENTRYPOINT or a different destination
would let the two images drift apart again."""
studio = open(_STUDIO_DF, encoding = "utf-8").read()
base = open(_BASE_DF, encoding = "utf-8").read()
dest = re.findall(r"^COPY\s+entrypoint\.sh\s+(\S+)\s*$", base, re.M)
assert dest == ["/usr/local/bin/unsloth-entrypoint"], dest
assert re.findall(r"^COPY\s+entrypoint\.sh\s+(\S+)\s*$", studio, re.M) == dest
# The base must still run the copied entrypoint or UNSLOTH_IMAGE_ALLOW_CPU is lost.
assert re.findall(r"^\s*ENTRYPOINT\s+(.+?)\s*$", base, re.M) == [
'["/usr/local/bin/unsloth-entrypoint"]'
], "base Dockerfile no longer runs the bundled entrypoint"
assert not re.search(
r"^\s*ENTRYPOINT\b", studio, re.M
), "Dockerfile.studio must inherit the base ENTRYPOINT, not declare its own"
def test_the_base_training_image_keeps_the_strict_check(self):
"""FastLanguageModel genuinely needs a GPU, so :core must NOT default it."""
body = open(_BASE_DF, encoding = "utf-8").read()
offenders = [
ln.strip()
for ln in body.splitlines()
if "ALLOW_CPU=1" in ln and not ln.strip().startswith("#")
]
assert not offenders, f"base image weakened the GPU check: {offenders}"
def _studio_image_env():
"""Read the image ENV from the Dockerfile: a test that spells the name itself would pass against an entrypoint that never heard of it."""
body = open(_STUDIO_DF, encoding = "utf-8").read()
env = {}
for block in re.findall(r"^ENV\s+((?:.*\\\n)*.*)$", body, re.M):
for key, value in re.findall(r"(\w*ALLOW_CPU)=(\S+)", block):
env[key] = value.rstrip("\\").strip()
return env
def _run_entrypoint(tmp_path, *, gpu, env_extra):
bindir = tmp_path / "bin"
bindir.mkdir()
if gpu:
_stub(
str(bindir / "nvidia-smi"),
'case "$*" in\n'
' *compute_cap*) echo "8.9" ;;\n'
' *driver_version*) echo "610.43.02" ;;\n'
' *) echo "GPU 0: NVIDIA RTX 6000 Ada Generation (UUID: GPU-abc)" ;;\n'
"esac\n",
)
else:
_stub(str(bindir / "nvidia-smi"), "exit 1\n")
_stub(str(bindir / "python"), "cat > /dev/null\nexit 0\n")
dump = tmp_path / "child_env"
env = {
"PATH": str(bindir) + ":/usr/bin:/bin",
"HOME": str(tmp_path),
"UNSLOTH_STUDIO_HOME": str(tmp_path / "studio"),
}
env.update(env_extra)
proc = subprocess.run(
[shutil.which("bash") or "/bin/bash", _ENTRYPOINT, "bash", "-c", f"env > {dump}"],
env = env,
capture_output = True,
text = True,
timeout = 60,
)
child = {}
if dump.exists():
for line in dump.read_text().splitlines():
key, _, value = line.partition("=")
child[key] = value
return proc.returncode, child, proc.stderr
@_posix_shell
class TestEntrypointAllowCpu:
def test_studio_image_on_a_cpu_host_starts_and_exports_allow_cpu(self, tmp_path):
"""Studio's own processes need UNSLOTH_ALLOW_CPU=1 to import unsloth without a GPU."""
rc, child, stderr = _run_entrypoint(
tmp_path, gpu = False, env_extra = {"UNSLOTH_IMAGE_ALLOW_CPU": "1"}
)
assert rc == 0, stderr
assert child.get("UNSLOTH_ALLOW_CPU") == "1"
assert "continuing on CPU" in stderr
def test_studio_image_on_a_gpu_host_hides_allow_cpu(self, tmp_path):
"""The regression: children that see it train on stock TRL. Env comes from the Dockerfile, so image and entrypoint are exercised as a PAIR."""
image_env = _studio_image_env()
assert image_env, "Dockerfile.studio bakes no *ALLOW_CPU image ENV"
rc, child, stderr = _run_entrypoint(tmp_path, gpu = True, env_extra = image_env)
assert rc == 0, stderr
assert "UNSLOTH_ALLOW_CPU" not in child
def test_an_explicit_allow_cpu_on_a_gpu_host_is_dropped_with_a_warning(self, tmp_path):
"""run.sh forwards a host-shell UNSLOTH_ALLOW_CPU and the docs tell CPU users to pass it, so a GPU host can receive it too."""
rc, child, stderr = _run_entrypoint(
tmp_path, gpu = True, env_extra = {"UNSLOTH_ALLOW_CPU": "1"}
)
assert rc == 0, stderr
assert "UNSLOTH_ALLOW_CPU" not in child
assert "Ignoring UNSLOTH_ALLOW_CPU=1" in stderr
def test_an_explicit_zero_restores_the_strict_check(self, tmp_path):
rc, _, stderr = _run_entrypoint(
tmp_path,
gpu = False,
env_extra = {"UNSLOTH_IMAGE_ALLOW_CPU": "1", "UNSLOTH_ALLOW_CPU": "0"},
)
assert rc == 1
assert "No GPU visible" in stderr
def test_core_without_an_opt_in_still_refuses(self, tmp_path):
rc, _, stderr = _run_entrypoint(tmp_path, gpu = False, env_extra = {})
assert rc == 1
assert "No GPU visible" in stderr
def test_core_with_an_explicit_opt_in_starts_on_cpu(self, tmp_path):
rc, child, stderr = _run_entrypoint(
tmp_path, gpu = False, env_extra = {"UNSLOTH_ALLOW_CPU": "1"}
)
assert rc == 0, stderr
assert child.get("UNSLOTH_ALLOW_CPU") == "1"
@pytest.mark.parametrize("gpu", [True, False])
def test_skipping_the_gpu_check_applies_the_same_rule(self, tmp_path, gpu):
rc, child, stderr = _run_entrypoint(
tmp_path,
gpu = gpu,
env_extra = {"UNSLOTH_IMAGE_ALLOW_CPU": "1", "UNSLOTH_SKIP_GPU_CHECK": "1"},
)
assert rc == 0, stderr
if gpu:
assert "UNSLOTH_ALLOW_CPU" not in child
else:
assert child.get("UNSLOTH_ALLOW_CPU") == "1"
def test_skipping_the_gpu_check_does_not_invent_an_opt_in(self, tmp_path):
"""The only way to reach exec on a CPU host with no opt-in, so the only place an unconditional export would hide and disable the TRL patches."""
rc, child, stderr = _run_entrypoint(
tmp_path, gpu = False, env_extra = {"UNSLOTH_SKIP_GPU_CHECK": "1"}
)
assert rc == 0, stderr
assert "UNSLOTH_ALLOW_CPU" not in child
@pytest.mark.parametrize("mask", ["", "-1"])
def test_cuda_masked_to_nothing_counts_as_no_gpu(self, tmp_path, mask):
"""nvidia-smi -L ignores CUDA_VISIBLE_DEVICES but torch honours it, so the GPU path would strip the opt-in from a process with no device."""
rc, child, stderr = _run_entrypoint(
tmp_path,
gpu = True,
env_extra = {"UNSLOTH_IMAGE_ALLOW_CPU": "1", "CUDA_VISIBLE_DEVICES": mask},
)
assert rc == 0, stderr
assert child.get("UNSLOTH_ALLOW_CPU") == "1"
assert "continuing on CPU" in stderr
def test_a_real_device_mask_is_still_a_gpu(self, tmp_path):
rc, child, stderr = _run_entrypoint(
tmp_path,
gpu = True,
env_extra = {"UNSLOTH_IMAGE_ALLOW_CPU": "1", "CUDA_VISIBLE_DEVICES": "0"},
)
assert rc == 0, stderr
assert "UNSLOTH_ALLOW_CPU" not in child
def test_an_explicit_empty_value_is_not_an_opt_in(self, tmp_path):
"""`-e UNSLOTH_ALLOW_CPU=` means off; it must not fall through to the image opt-in, which `:-` would do."""
rc, _, stderr = _run_entrypoint(
tmp_path,
gpu = False,
env_extra = {"UNSLOTH_IMAGE_ALLOW_CPU": "1", "UNSLOTH_ALLOW_CPU": ""},
)
assert rc == 1
assert "No GPU visible" in stderr