Compare commits
12 commits
main
...
fix/llama-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bbd1a679a | ||
|
|
b990701a3e | ||
|
|
e9f52c2114 | ||
|
|
889e889d9c | ||
|
|
100e27ffe7 | ||
|
|
ecf90078dd | ||
|
|
1546f0328e | ||
|
|
17070c0886 | ||
|
|
9a8703f43c | ||
|
|
a742dd49ce | ||
|
|
c85c62e456 | ||
|
|
2f88ecd8a4 |
13 changed files with 2232 additions and 86 deletions
65
.github/workflows/studio-gpu-offload-smoke.yml
vendored
Normal file
65
.github/workflows/studio-gpu-offload-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# Cross-platform GPU-offload validation smoke (no GPU required).
|
||||||
|
#
|
||||||
|
# Reproduces the silent CPU-only GGUF bug (#5807 / #5106 / #5830) with a fake
|
||||||
|
# llama-server that "starts and serves HTTP 200" while its log reports CPU-only
|
||||||
|
# or GPU offload, and asserts install_llama_prebuilt.py --smoke-test rejects the
|
||||||
|
# CPU-only-tagged-GPU case (exit 2) and accepts the GPU case (exit 0). Runs on
|
||||||
|
# GPU-less Windows / macOS / Linux runners, which is why this regressed
|
||||||
|
# untested. Also runs the pure-Python selection + classifier unit tests.
|
||||||
|
|
||||||
|
name: Studio GPU Offload Smoke
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'studio/install_llama_prebuilt.py'
|
||||||
|
- 'studio/setup.sh'
|
||||||
|
- 'studio/setup.ps1'
|
||||||
|
- 'studio/backend/core/inference/llama_cpp.py'
|
||||||
|
- 'tests/studio/install/**'
|
||||||
|
- 'tests/sh/test_llama_gpu_smoke.sh'
|
||||||
|
- '.github/workflows/studio-gpu-offload-smoke.yml'
|
||||||
|
push:
|
||||||
|
branches: [main, pip]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gpu-offload-spoof:
|
||||||
|
name: GPU-offload spoof (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 15
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install pytest
|
||||||
|
run: python -m pip install --upgrade pip pytest
|
||||||
|
|
||||||
|
- name: GPU-offload spoof (end to end, real subprocess + HTTP)
|
||||||
|
run: python tests/studio/install/run_smoke_spoof.py
|
||||||
|
|
||||||
|
- name: Selection + classifier unit tests
|
||||||
|
run: >
|
||||||
|
python -m pytest
|
||||||
|
tests/studio/install/test_validate_server_gpu_offload.py
|
||||||
|
tests/studio/install/test_selection_logic.py
|
||||||
|
tests/studio/install/test_gpu_offload_spoof.py
|
||||||
|
-q
|
||||||
|
|
||||||
|
- name: setup.sh smoke-exit classifier (POSIX)
|
||||||
|
if: runner.os != 'Windows'
|
||||||
|
run: bash tests/sh/test_llama_gpu_smoke.sh
|
||||||
|
|
@ -59,6 +59,107 @@ from core.inference.tool_call_parser import (
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── GPU-offload log classifier (shared shape with install_llama_prebuilt) ──
|
||||||
|
# llama-server can serve HTTP 200 while running entirely on CPU; detect that so
|
||||||
|
# Studio can warn instead of silently running a "GPU" model on CPU (#5807 /
|
||||||
|
# #5106 / #5830). Robust across llama.cpp log formats (the "model buffer size"
|
||||||
|
# lines were dropped in recent builds): buffer-size markers, "offloaded N/M
|
||||||
|
# layers to GPU", and the "device_info:" enumeration.
|
||||||
|
_GPU_BUFFER_MARKERS = (
|
||||||
|
"CUDA",
|
||||||
|
"ROCm",
|
||||||
|
"ROCM",
|
||||||
|
"HIP",
|
||||||
|
"Metal",
|
||||||
|
"Vulkan",
|
||||||
|
"OpenCL",
|
||||||
|
"SYCL",
|
||||||
|
"MUSA",
|
||||||
|
"CANN",
|
||||||
|
)
|
||||||
|
_DEVICE_ROW_RE = re.compile(
|
||||||
|
r"-\s*(?P<dev>(?:CUDA|ROCm|ROCM|HIP|Metal|Vulkan|SYCL|OpenCL|MUSA|CANN|CPU)\w*)\s*:",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_GPU_DEVICE_PREFIXES = (
|
||||||
|
"cuda",
|
||||||
|
"rocm",
|
||||||
|
"hip",
|
||||||
|
"metal",
|
||||||
|
"vulkan",
|
||||||
|
"sycl",
|
||||||
|
"opencl",
|
||||||
|
"musa",
|
||||||
|
"cann",
|
||||||
|
)
|
||||||
|
_OFFLOADED_LAYERS_RE = re.compile(
|
||||||
|
r"offloaded\s+(\d+)\s*/\s*(\d+)\s+layers?\s+to\s+gpu", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_OFFLOADING_COUNT_RE = re.compile(
|
||||||
|
r"offloading\s+(\d+)\s+(?:repeating\s+|non-repeating\s+)?layers?\s+to\s+gpu",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_gpu_offload_lines(lines: list[str]) -> Optional[bool]:
|
||||||
|
"""True if the model landed on a GPU, False if it stayed on CPU despite GPU
|
||||||
|
intent, None when the log has no usable signal. Priority: explicit
|
||||||
|
offloaded-layer counts (authoritative), then GPU model-buffer lines, then
|
||||||
|
device_info enumeration."""
|
||||||
|
# Signal 1: explicit offloaded counts win over everything (a KV/compute
|
||||||
|
# buffer can be on the GPU while 0 model layers are offloaded). Any counted
|
||||||
|
# line N>0 => True; all 0 => False (scan all; a draft model can log 0/k
|
||||||
|
# before the main model's 33/33). Uncounted "offloading output layer to GPU"
|
||||||
|
# is only a weak positive when no counted line exists.
|
||||||
|
saw_zero_count = False
|
||||||
|
saw_uncounted_offloading = False
|
||||||
|
for line in lines:
|
||||||
|
match = _OFFLOADED_LAYERS_RE.search(line) or _OFFLOADING_COUNT_RE.search(line)
|
||||||
|
if match:
|
||||||
|
if int(match.group(1)) > 0:
|
||||||
|
return True
|
||||||
|
saw_zero_count = True
|
||||||
|
continue
|
||||||
|
low = line.lower()
|
||||||
|
if "offloading" in low and "to gpu" in low:
|
||||||
|
saw_uncounted_offloading = True
|
||||||
|
if saw_zero_count:
|
||||||
|
return False
|
||||||
|
if saw_uncounted_offloading:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Signal 2: GPU marker on a *model* buffer (exclude host-pinned _Host).
|
||||||
|
saw_model_buffer = False
|
||||||
|
for line in lines:
|
||||||
|
if "model buffer size" not in line:
|
||||||
|
continue
|
||||||
|
saw_model_buffer = True
|
||||||
|
if "_Host" not in line and any(
|
||||||
|
marker in line for marker in _GPU_BUFFER_MARKERS
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
after_device_info = False
|
||||||
|
saw_device_row = False
|
||||||
|
for line in lines:
|
||||||
|
if "device_info:" in line:
|
||||||
|
after_device_info = True
|
||||||
|
continue
|
||||||
|
if not after_device_info:
|
||||||
|
continue
|
||||||
|
match = _DEVICE_ROW_RE.search(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
saw_device_row = True
|
||||||
|
dev = match.group("dev").lower()
|
||||||
|
if any(dev.startswith(prefix) for prefix in _GPU_DEVICE_PREFIXES):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if saw_model_buffer or saw_device_row:
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Pre-compiled patterns for plan-without-action re-prompt ──
|
# ── Pre-compiled patterns for plan-without-action re-prompt ──
|
||||||
# Forward-looking intent signals that indicate the model is
|
# Forward-looking intent signals that indicate the model is
|
||||||
# describing what it *will* do rather than giving a final answer.
|
# describing what it *will* do rather than giving a final answer.
|
||||||
|
|
@ -3865,27 +3966,14 @@ class LlamaCppBackend:
|
||||||
expected_gpu: bool,
|
expected_gpu: bool,
|
||||||
detected_gpus: list[tuple[int, int]],
|
detected_gpus: list[tuple[int, int]],
|
||||||
) -> Optional[bool]:
|
) -> Optional[bool]:
|
||||||
"""True if a GPU model buffer was allocated, False if only CPU
|
"""True if the model landed on a GPU, False if only CPU buffers landed
|
||||||
buffers landed despite GPU intent, None when there's no signal
|
despite GPU intent, None when there's no signal (no GPU detected, no
|
||||||
(no GPU detected, no buffer-size lines, etc.)."""
|
usable log lines, etc.). Delegates to the shared classifier so it tracks
|
||||||
|
current llama.cpp logs (device_info / offloaded-layers), which dropped
|
||||||
|
the older ``model buffer size`` lines this used to key on."""
|
||||||
if not detected_gpus or not expected_gpu:
|
if not detected_gpus or not expected_gpu:
|
||||||
return None
|
return None
|
||||||
# llama-server logs one ``... model buffer size = N MiB`` line
|
return classify_gpu_offload_lines(self._stdout_lines)
|
||||||
# per backend buffer; CUDA0 / ROCm0 / Metal / Vulkan0 /
|
|
||||||
# OpenCL0 / SYCL0 are GPU, CPU / CPU_Mapped are not.
|
|
||||||
gpu_markers = ("CUDA", "ROCm", "Metal", "Vulkan", "OpenCL", "SYCL")
|
|
||||||
saw_buffer_line = False
|
|
||||||
saw_gpu_buffer = False
|
|
||||||
for line in self._stdout_lines:
|
|
||||||
if "model buffer size" not in line:
|
|
||||||
continue
|
|
||||||
saw_buffer_line = True
|
|
||||||
if any(marker in line for marker in gpu_markers):
|
|
||||||
saw_gpu_buffer = True
|
|
||||||
break
|
|
||||||
if not saw_buffer_line:
|
|
||||||
return None
|
|
||||||
return saw_gpu_buffer
|
|
||||||
|
|
||||||
def unload_model(self) -> bool:
|
def unload_model(self) -> bool:
|
||||||
"""Terminate the llama-server subprocess and cancel any in-flight download."""
|
"""Terminate the llama-server subprocess and cancel any in-flight download."""
|
||||||
|
|
|
||||||
|
|
@ -577,3 +577,80 @@ class TestClassifyGpuOffload:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
|
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
|
||||||
|
|
||||||
|
def test_current_device_info_cuda_returns_true(self):
|
||||||
|
# Recent llama.cpp dropped the buffer lines and prints device_info.
|
||||||
|
inst = self._backend(
|
||||||
|
[
|
||||||
|
"0.00 I device_info:",
|
||||||
|
"0.01 I - CUDA0 : NVIDIA GeForce RTX 5070 (12282 MiB free)",
|
||||||
|
"0.01 I - CPU : AMD Ryzen 7 9700X (32000 MiB free)",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert inst._classify_gpu_offload(True, [(0, 12282)]) is True
|
||||||
|
|
||||||
|
def test_current_device_info_cpu_only_returns_false(self):
|
||||||
|
# The #5830 symptom: device_info shows only CPU, CUDA0 missing.
|
||||||
|
inst = self._backend(
|
||||||
|
[
|
||||||
|
"0.00 I device_info:",
|
||||||
|
"0.00 I - CPU : AMD Ryzen 7 9700X (32000 MiB free)",
|
||||||
|
"0.00 I srv llama_server: model loaded",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert inst._classify_gpu_offload(True, [(0, 12282)]) is False
|
||||||
|
|
||||||
|
def test_offloaded_layers_count_decides(self):
|
||||||
|
assert (
|
||||||
|
self._backend(
|
||||||
|
["load_tensors: offloaded 33/33 layers to GPU"]
|
||||||
|
)._classify_gpu_offload(True, [(0, 22805)])
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
self._backend(
|
||||||
|
["load_tensors: offloaded 0/33 layers to GPU"]
|
||||||
|
)._classify_gpu_offload(True, [(0, 22805)])
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_system_info_only_returns_none(self):
|
||||||
|
# A compiled CUDA backend advertised in system_info is not proof of an
|
||||||
|
# available device; without device_info/buffer/offload signal -> None.
|
||||||
|
inst = self._backend(
|
||||||
|
["system_info: n_threads = 8 | CUDA : ARCHS = 1200 | CPU : AVX2 = 1"]
|
||||||
|
)
|
||||||
|
assert inst._classify_gpu_offload(True, [(0, 22805)]) is None
|
||||||
|
|
||||||
|
def test_cuda_host_buffer_is_not_gpu(self):
|
||||||
|
# CUDA_Host is host-pinned CPU RAM; weights on CPU_Mapped means CPU only.
|
||||||
|
inst = self._backend(
|
||||||
|
[
|
||||||
|
"load_tensors: CUDA_Host model buffer size = 21000.0 MiB",
|
||||||
|
"load_tensors: CPU_Mapped model buffer size = 0.6 MiB",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert inst._classify_gpu_offload(True, [(0, 22805)]) is False
|
||||||
|
|
||||||
|
def test_draft_zero_before_main_offload_is_gpu(self):
|
||||||
|
inst = self._backend(
|
||||||
|
[
|
||||||
|
"load_tensors: offloaded 0/2 layers to GPU",
|
||||||
|
"load_tensors: offloaded 33/33 layers to GPU",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
|
||||||
|
|
||||||
|
def test_offloading_zero_repeating_does_not_mask_zero_total(self):
|
||||||
|
inst = self._backend(
|
||||||
|
[
|
||||||
|
"llm_load_tensors: offloading 0 repeating layers to GPU",
|
||||||
|
"llm_load_tensors: offloaded 0/33 layers to GPU",
|
||||||
|
"llm_load_tensors: CPU model buffer size = 7338.64 MiB",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert inst._classify_gpu_offload(True, [(0, 22805)]) is False
|
||||||
|
|
||||||
|
def test_hip_model_buffer_is_gpu(self):
|
||||||
|
inst = self._backend(["load_tensors: HIP0 model buffer size = 21000.0 MiB"])
|
||||||
|
assert inst._classify_gpu_offload(True, [(0, 22805)]) is True
|
||||||
|
|
|
||||||
|
|
@ -452,6 +452,14 @@ class PrebuiltFallback(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GpuOffloadFailure(PrebuiltFallback):
|
||||||
|
"""The binary started and served a completion but loaded the model only on
|
||||||
|
CPU despite GPU offload being requested. A subclass of PrebuiltFallback so
|
||||||
|
the prebuilt resolver still advances to the next candidate, while the
|
||||||
|
--smoke-test CLI can tell this apart from an inconclusive start/serve
|
||||||
|
failure (definite CPU-only -> EXIT_FALLBACK; inconclusive -> EXIT_ERROR)."""
|
||||||
|
|
||||||
|
|
||||||
class BusyInstallConflict(RuntimeError):
|
class BusyInstallConflict(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -1437,7 +1445,14 @@ def direct_linux_release_plan(
|
||||||
)
|
)
|
||||||
if lemonade_choice is not None:
|
if lemonade_choice is not None:
|
||||||
attempts.append(lemonade_choice)
|
attempts.append(lemonade_choice)
|
||||||
else:
|
elif not host.has_usable_nvidia:
|
||||||
|
# CPU-only host (or --cpu-fallback, which zeroes the GPU flags): ship the
|
||||||
|
# CPU bundle. Do NOT append it for a usable-NVIDIA host: if every CUDA
|
||||||
|
# bundle fails the GPU-offload check, let install raise PrebuiltFallback
|
||||||
|
# so setup.sh builds llama.cpp from source for the native arch (which
|
||||||
|
# offloads) instead of silently shipping a CPU-only "GPU" install
|
||||||
|
# (#5807). The CPU prebuilt stays the last resort after the source build,
|
||||||
|
# reached via setup.sh's --cpu-fallback path.
|
||||||
cpu_choice = published_asset_choice_for_kind(bundle, "linux-cpu")
|
cpu_choice = published_asset_choice_for_kind(bundle, "linux-cpu")
|
||||||
if cpu_choice is not None:
|
if cpu_choice is not None:
|
||||||
attempts.append(cpu_choice)
|
attempts.append(cpu_choice)
|
||||||
|
|
@ -1530,19 +1545,26 @@ def direct_upstream_release_plan(
|
||||||
install_kind = "windows-hip",
|
install_kind = "windows-hip",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
cpu_asset = f"llama-{release_tag}-bin-win-cpu-x64.zip"
|
# Append the CPU bundle only for a host with no usable GPU (or
|
||||||
cpu_url = assets.get(cpu_asset)
|
# --cpu-fallback, which zeroes the GPU flags). On a GPU host, omitting it
|
||||||
if cpu_url:
|
# lets install raise PrebuiltFallback when every GPU bundle fails the
|
||||||
attempts.append(
|
# offload check, so setup.ps1 builds from source / falls back to the CPU
|
||||||
AssetChoice(
|
# prebuilt as a labelled last resort instead of silently shipping a
|
||||||
repo = repo,
|
# CPU-only "GPU" install (#5807).
|
||||||
tag = release_tag,
|
if not host.has_usable_nvidia and not host.has_rocm:
|
||||||
name = cpu_asset,
|
cpu_asset = f"llama-{release_tag}-bin-win-cpu-x64.zip"
|
||||||
url = cpu_url,
|
cpu_url = assets.get(cpu_asset)
|
||||||
source_label = "upstream",
|
if cpu_url:
|
||||||
install_kind = "windows-cpu",
|
attempts.append(
|
||||||
|
AssetChoice(
|
||||||
|
repo = repo,
|
||||||
|
tag = release_tag,
|
||||||
|
name = cpu_asset,
|
||||||
|
url = cpu_url,
|
||||||
|
source_label = "upstream",
|
||||||
|
install_kind = "windows-cpu",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
elif host.is_windows and host.is_arm64:
|
elif host.is_windows and host.is_arm64:
|
||||||
# Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-win-cpu-arm64.zip
|
# Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-win-cpu-arm64.zip
|
||||||
# (visible in the b9334 release manifest). Without this branch the
|
# (visible in the b9334 release manifest). Without this branch the
|
||||||
|
|
@ -1589,7 +1611,12 @@ def direct_upstream_release_plan(
|
||||||
install_kind = "macos-x64",
|
install_kind = "macos-x64",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif host.is_linux and host.is_x86_64 and not host.has_usable_nvidia:
|
elif (
|
||||||
|
host.is_linux
|
||||||
|
and host.is_x86_64
|
||||||
|
and not host.has_usable_nvidia
|
||||||
|
and not host.has_rocm
|
||||||
|
):
|
||||||
asset_name = f"llama-{release_tag}-bin-ubuntu-x64.tar.gz"
|
asset_name = f"llama-{release_tag}-bin-ubuntu-x64.tar.gz"
|
||||||
asset_url = assets.get(asset_name)
|
asset_url = assets.get(asset_name)
|
||||||
if asset_url:
|
if asset_url:
|
||||||
|
|
@ -1603,7 +1630,12 @@ def direct_upstream_release_plan(
|
||||||
install_kind = "linux-cpu",
|
install_kind = "linux-cpu",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif host.is_linux and host.is_arm64 and not host.has_usable_nvidia:
|
elif (
|
||||||
|
host.is_linux
|
||||||
|
and host.is_arm64
|
||||||
|
and not host.has_usable_nvidia
|
||||||
|
and not host.has_rocm
|
||||||
|
):
|
||||||
# Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-ubuntu-arm64.tar.gz
|
# Upstream ggml-org/llama.cpp ships llama-bNNNN-bin-ubuntu-arm64.tar.gz
|
||||||
# (visible in the b9334 release manifest). Without this branch the
|
# (visible in the b9334 release manifest). Without this branch the
|
||||||
# selector returned 0 attempts and the installer fell back to a
|
# selector returned 0 attempts and the installer fell back to a
|
||||||
|
|
@ -5577,6 +5609,179 @@ def validate_quantize(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# GPU backend markers that appear in llama-server's "... model buffer size ..."
|
||||||
|
# load lines (older llama.cpp) and in its "device_info:" enumeration (current
|
||||||
|
# llama.cpp). CUDA0 / ROCm0 / Metal / Vulkan0 / OpenCL0 / SYCL0 / HIP0 / MUSA0
|
||||||
|
# / CANN0 are GPU; CPU / CPU_Mapped are not. Kept broad so a single helper
|
||||||
|
# covers every backend Studio ships.
|
||||||
|
_GPU_MODEL_BUFFER_MARKERS = (
|
||||||
|
"CUDA",
|
||||||
|
"ROCm",
|
||||||
|
"ROCM",
|
||||||
|
"HIP",
|
||||||
|
"Metal",
|
||||||
|
"Vulkan",
|
||||||
|
"OpenCL",
|
||||||
|
"SYCL",
|
||||||
|
"MUSA",
|
||||||
|
"CANN",
|
||||||
|
)
|
||||||
|
|
||||||
|
# A device_info row looks like "<ts> I - CUDA0 : NVIDIA B200 (...)" or
|
||||||
|
# "<ts> I - CPU : ...". Matched on the "- <backend> :" shape (the leading
|
||||||
|
# "- " distinguishes it from the system_info line, which prints
|
||||||
|
# "| CUDA : ARCHS = ..." for a *compiled* backend even when the CUDA runtime
|
||||||
|
# failed to initialize at load time). Scanned only after a "device_info:" line.
|
||||||
|
_DEVICE_ROW_RE = re.compile(
|
||||||
|
r"-\s*(?P<dev>(?:CUDA|ROCm|ROCM|HIP|Metal|Vulkan|SYCL|OpenCL|MUSA|CANN|CPU)\w*)\s*:",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Keep in sync with _GPU_MODEL_BUFFER_MARKERS: every GPU backend matched by
|
||||||
|
# _DEVICE_ROW_RE must have a prefix here, or its device row counts as CPU-only
|
||||||
|
# and a working GPU build is wrongly rejected (OpenCL was the gap).
|
||||||
|
_GPU_DEVICE_PREFIXES = (
|
||||||
|
"cuda",
|
||||||
|
"rocm",
|
||||||
|
"hip",
|
||||||
|
"metal",
|
||||||
|
"vulkan",
|
||||||
|
"sycl",
|
||||||
|
"opencl",
|
||||||
|
"musa",
|
||||||
|
"cann",
|
||||||
|
)
|
||||||
|
|
||||||
|
# "load_tensors: offloaded 33/33 layers to GPU".
|
||||||
|
_OFFLOADED_LAYERS_RE = re.compile(
|
||||||
|
r"offloaded\s+(\d+)\s*/\s*(\d+)\s+layers?\s+to\s+gpu", re.IGNORECASE
|
||||||
|
)
|
||||||
|
# "offloading 0 repeating layers to GPU" / "offloading 1 non-repeating ...".
|
||||||
|
# A counted form, so a zero here is a definite CPU-only signal (it usually
|
||||||
|
# precedes "offloaded 0/N"); kept separate from the uncounted
|
||||||
|
# "offloading output layer to GPU" phrasing, which carries no number.
|
||||||
|
_OFFLOADING_COUNT_RE = re.compile(
|
||||||
|
r"offloading\s+(\d+)\s+(?:repeating\s+|non-repeating\s+)?layers?\s+to\s+gpu",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# install_kind values that ship a real GPU backend and are launched with
|
||||||
|
# --n-gpu-layers 1 during validation, so a real Metal / CUDA / HIP backend is
|
||||||
|
# loaded and a broken GPU dylib/DLL surfaces. CPU kinds (windows-cpu,
|
||||||
|
# linux-cpu, ...) are exempt.
|
||||||
|
_GPU_INSTALL_KINDS = frozenset(
|
||||||
|
{"linux-cuda", "linux-rocm", "windows-cuda", "windows-hip", "macos-arm64"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Subset whose CPU-only load is a *fixable* fault -- a missing cudart64_* /
|
||||||
|
# cublas64_* DLL, a PTX-only build on an older driver, or a HIP backend that
|
||||||
|
# did not initialize -- that a different bundle or a source build can repair,
|
||||||
|
# so validation rejects it to advance the resolver (#5807, #5106). macOS Metal
|
||||||
|
# is excluded on purpose: on Apple Silicon the macos-arm64 prebuilt is already
|
||||||
|
# the correct artifact, and a CPU-only load means Metal is unavailable in this
|
||||||
|
# environment (a headless or virtualized host such as a CI runner), which no
|
||||||
|
# rebuild can fix. Rejecting it would force a source build that also runs on
|
||||||
|
# CPU and needlessly breaks the install. macOS is still launched with
|
||||||
|
# --n-gpu-layers (it is in _GPU_INSTALL_KINDS) so a broken libggml-metal.dylib
|
||||||
|
# still surfaces; it is only exempt from the CPU-only *rejection*.
|
||||||
|
_GPU_OFFLOAD_REQUIRED_KINDS = _GPU_INSTALL_KINDS - frozenset({"macos-arm64"})
|
||||||
|
|
||||||
|
|
||||||
|
def server_log_shows_gpu_offload(log_text: str) -> bool | None:
|
||||||
|
"""Classify whether llama-server put the model on the GPU.
|
||||||
|
|
||||||
|
Robust across llama.cpp log formats (the "model buffer size" lines were
|
||||||
|
dropped in recent builds), in priority order:
|
||||||
|
|
||||||
|
1. ``offloaded N/M layers to GPU`` / ``offloading N ... layers to GPU`` --
|
||||||
|
the explicit layer count is authoritative: N>0 => True, all 0 => False.
|
||||||
|
It must win over auxiliary GPU buffers (a KV/compute buffer can sit on
|
||||||
|
the GPU while 0 model layers are offloaded -- still CPU inference).
|
||||||
|
2. ``<backend> model buffer size = N`` lines -- a GPU marker on a *model*
|
||||||
|
buffer (not host-pinned ``CUDA_Host``) => True (older llama.cpp).
|
||||||
|
3. ``device_info:`` enumeration -- a GPU device row (``- CUDA0 :`` etc.)
|
||||||
|
=> True; only a ``- CPU :`` row => False. This is the version-stable
|
||||||
|
signal and matches how the CPU-only-binary bug is diagnosed in the
|
||||||
|
field (``device_info`` shows only CPU, CUDA0 missing -- #5830).
|
||||||
|
|
||||||
|
Returns True (GPU offload confirmed), False (started but stayed on CPU
|
||||||
|
despite GPU intent -- the silent fallback in #5807 / #5106), or None when
|
||||||
|
the log carries no usable signal so callers never reject on no evidence.
|
||||||
|
"""
|
||||||
|
lines = log_text.splitlines()
|
||||||
|
|
||||||
|
# Signal 1: explicit offloaded-layers counts (authoritative). Any counted
|
||||||
|
# line with N>0 => True; if every counted line is 0 => False (a draft model
|
||||||
|
# can log "offloaded 0/k" before the main model's "offloaded 33/33", so scan
|
||||||
|
# all before deciding). The uncounted "offloading output layer to GPU"
|
||||||
|
# phrasing carries no number, so it is only a weak positive used when no
|
||||||
|
# counted line exists at all.
|
||||||
|
saw_zero_count = False
|
||||||
|
saw_uncounted_offloading = False
|
||||||
|
for line in lines:
|
||||||
|
match = _OFFLOADED_LAYERS_RE.search(line) or _OFFLOADING_COUNT_RE.search(line)
|
||||||
|
if match:
|
||||||
|
if int(match.group(1)) > 0:
|
||||||
|
return True
|
||||||
|
saw_zero_count = True
|
||||||
|
continue
|
||||||
|
low = line.lower()
|
||||||
|
if "offloading" in low and "to gpu" in low:
|
||||||
|
saw_uncounted_offloading = True
|
||||||
|
if saw_zero_count:
|
||||||
|
return False
|
||||||
|
if saw_uncounted_offloading:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Signal 2: per-backend model-buffer lines. A GPU marker on a *model* buffer
|
||||||
|
# means model weights live on the GPU. Exclude host-pinned buffers
|
||||||
|
# ("CUDA_Host" / "ROCm_Host" ...): those are CPU RAM the GPU backend pinned,
|
||||||
|
# not device memory. KV/compute buffers are ignored here -- they can be on
|
||||||
|
# the GPU even when all weights are on CPU.
|
||||||
|
saw_model_buffer = False
|
||||||
|
for line in lines:
|
||||||
|
if "model buffer size" not in line:
|
||||||
|
continue
|
||||||
|
saw_model_buffer = True
|
||||||
|
if "_Host" not in line and any(
|
||||||
|
marker in line for marker in _GPU_MODEL_BUFFER_MARKERS
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Signal 3: device_info enumeration. Only trust device rows once the
|
||||||
|
# "device_info:" header has appeared, so the compiled-backend system_info
|
||||||
|
# line ("| CUDA : ARCHS = ...") is never mistaken for an available device.
|
||||||
|
after_device_info = False
|
||||||
|
saw_device_row = False
|
||||||
|
for line in lines:
|
||||||
|
if "device_info:" in line:
|
||||||
|
after_device_info = True
|
||||||
|
continue
|
||||||
|
if not after_device_info:
|
||||||
|
continue
|
||||||
|
match = _DEVICE_ROW_RE.search(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
saw_device_row = True
|
||||||
|
dev = match.group("dev").lower()
|
||||||
|
if any(dev.startswith(prefix) for prefix in _GPU_DEVICE_PREFIXES):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if saw_model_buffer or saw_device_row:
|
||||||
|
# We had a concrete signal, but every buffer/device was CPU.
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_full_log(log_path: Path, *, max_chars: int = 1_000_000) -> str:
|
||||||
|
"""Read a server log from the start (model-load buffer lines appear early,
|
||||||
|
so unlike read_log_excerpt this keeps the head rather than the tail)."""
|
||||||
|
try:
|
||||||
|
content = log_path.read_text(encoding = "utf-8", errors = "replace")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ""
|
||||||
|
return content[:max_chars]
|
||||||
|
|
||||||
|
|
||||||
def validate_server(
|
def validate_server(
|
||||||
server_path: Path,
|
server_path: Path,
|
||||||
probe_path: Path,
|
probe_path: Path,
|
||||||
|
|
@ -5585,6 +5790,7 @@ def validate_server(
|
||||||
*,
|
*,
|
||||||
runtime_line: str | None = None,
|
runtime_line: str | None = None,
|
||||||
install_kind: str | None = None,
|
install_kind: str | None = None,
|
||||||
|
require_gpu_signal: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
last_failure: PrebuiltFallback | None = None
|
last_failure: PrebuiltFallback | None = None
|
||||||
for port_attempt in range(1, SERVER_PORT_BIND_ATTEMPTS + 1):
|
for port_attempt in range(1, SERVER_PORT_BIND_ATTEMPTS + 1):
|
||||||
|
|
@ -5615,15 +5821,10 @@ def validate_server(
|
||||||
# validation. Use the resolved install_kind as the source of
|
# validation. Use the resolved install_kind as the source of
|
||||||
# truth and fall back to host detection when the caller did not
|
# truth and fall back to host detection when the caller did not
|
||||||
# pass one (keeps backwards compatibility with older call sites).
|
# pass one (keeps backwards compatibility with older call sites).
|
||||||
_gpu_kinds = {
|
_gpu_kinds = _GPU_INSTALL_KINDS
|
||||||
"linux-cuda",
|
|
||||||
"linux-rocm",
|
|
||||||
"windows-cuda",
|
|
||||||
"windows-hip",
|
|
||||||
"macos-arm64",
|
|
||||||
}
|
|
||||||
if install_kind is not None:
|
if install_kind is not None:
|
||||||
_enable_gpu_layers = install_kind in _gpu_kinds
|
_enable_gpu_layers = install_kind in _gpu_kinds
|
||||||
|
_require_gpu_offload = install_kind in _GPU_OFFLOAD_REQUIRED_KINDS
|
||||||
else:
|
else:
|
||||||
# Older call sites that don't pass install_kind: keep ROCm
|
# Older call sites that don't pass install_kind: keep ROCm
|
||||||
# hosts in the GPU-validation path so an AMD-only Linux host
|
# hosts in the GPU-validation path so an AMD-only Linux host
|
||||||
|
|
@ -5634,6 +5835,10 @@ def validate_server(
|
||||||
or host.has_rocm
|
or host.has_rocm
|
||||||
or (host.is_macos and host.is_arm64)
|
or (host.is_macos and host.is_arm64)
|
||||||
)
|
)
|
||||||
|
# macOS Metal is exercised but never *required* (a CPU-only Metal
|
||||||
|
# load is an environment limitation, not a fixable binary fault;
|
||||||
|
# see _GPU_OFFLOAD_REQUIRED_KINDS).
|
||||||
|
_require_gpu_offload = host.has_usable_nvidia or host.has_rocm
|
||||||
if _enable_gpu_layers:
|
if _enable_gpu_layers:
|
||||||
command.extend(["--n-gpu-layers", "1"])
|
command.extend(["--n-gpu-layers", "1"])
|
||||||
|
|
||||||
|
|
@ -5657,6 +5862,7 @@ def validate_server(
|
||||||
startup_started = time.time()
|
startup_started = time.time()
|
||||||
response_body = ""
|
response_body = ""
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
completion_succeeded = False
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
if process.poll() is not None:
|
if process.poll() is not None:
|
||||||
process.wait(timeout = 5)
|
process.wait(timeout = 5)
|
||||||
|
|
@ -5697,7 +5903,11 @@ def validate_server(
|
||||||
status_code = response.status
|
status_code = response.status
|
||||||
response_body = response.read().decode("utf-8", "replace")
|
response_body = response.read().decode("utf-8", "replace")
|
||||||
if status_code == 200:
|
if status_code == 200:
|
||||||
return
|
# Served a completion. Confirm real GPU offload
|
||||||
|
# below (outside this try, so GpuOffloadFailure is
|
||||||
|
# not swallowed by the except clauses).
|
||||||
|
completion_succeeded = True
|
||||||
|
break
|
||||||
last_error = RuntimeError(
|
last_error = RuntimeError(
|
||||||
f"unexpected HTTP status {status_code}"
|
f"unexpected HTTP status {status_code}"
|
||||||
)
|
)
|
||||||
|
|
@ -5717,6 +5927,45 @@ def validate_server(
|
||||||
+ output
|
+ output
|
||||||
+ ("\n" + response_body if response_body else "")
|
+ ("\n" + response_body if response_body else "")
|
||||||
)
|
)
|
||||||
|
if completion_succeeded:
|
||||||
|
# The server served a completion. When GPU offload is
|
||||||
|
# *required* (CUDA / ROCm / HIP -- see
|
||||||
|
# _GPU_OFFLOAD_REQUIRED_KINDS), confirm the model actually
|
||||||
|
# landed on the GPU: a binary whose GPU backend failed to
|
||||||
|
# load (CPU-only build, unresolved cudart64_*/cublas64_*
|
||||||
|
# DLLs on Windows, or a PTX-only build on an older driver)
|
||||||
|
# still serves HTTP 200 from CPU, and accepting it ships a
|
||||||
|
# silently CPU-only install (#5807, #5106). Reject so the
|
||||||
|
# resolver advances to the next bundle / source build. macOS
|
||||||
|
# Metal is intentionally excluded: a CPU-only Metal load is
|
||||||
|
# an unfixable environment limitation, not a bad binary.
|
||||||
|
if _require_gpu_offload:
|
||||||
|
log_handle.flush()
|
||||||
|
offload = server_log_shows_gpu_offload(read_full_log(log_path))
|
||||||
|
if offload is False:
|
||||||
|
raise GpuOffloadFailure(
|
||||||
|
"llama-server served a completion but loaded the "
|
||||||
|
"model entirely on CPU despite GPU offload being "
|
||||||
|
f"requested (install_kind={install_kind}). The "
|
||||||
|
"binary's GPU backend did not initialize -- on "
|
||||||
|
"Windows this is usually unresolved "
|
||||||
|
"cudart64_*.dll / cublas64_*.dll, or a build that "
|
||||||
|
"does not match the driver (PTX-only). Rejecting "
|
||||||
|
"so a GPU-capable bundle is selected "
|
||||||
|
"(unslothai/unsloth#5807, #5106):\n"
|
||||||
|
+ read_log_excerpt(log_path)
|
||||||
|
)
|
||||||
|
# No GPU signal: install validation stays conservative
|
||||||
|
# (never reject on no evidence), but --smoke-test sets
|
||||||
|
# require_gpu_signal so its "0 = offload confirmed"
|
||||||
|
# contract does not pass an unproven log.
|
||||||
|
if offload is None and require_gpu_signal:
|
||||||
|
raise PrebuiltFallback(
|
||||||
|
"llama-server served a completion but its startup "
|
||||||
|
"log carried no GPU-offload signal; smoke-test "
|
||||||
|
"result is inconclusive:\n" + read_log_excerpt(log_path)
|
||||||
|
)
|
||||||
|
return
|
||||||
finally:
|
finally:
|
||||||
if process is not None and process.poll() is None:
|
if process is not None and process.poll() is None:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
|
|
@ -6496,6 +6745,47 @@ def validate_prebuilt_attempts(
|
||||||
raise PrebuiltFallback("no prebuilt bundle passed validation")
|
raise PrebuiltFallback("no prebuilt bundle passed validation")
|
||||||
|
|
||||||
|
|
||||||
|
def existing_gpu_install_offloads(
|
||||||
|
install_dir: Path,
|
||||||
|
host: HostInfo,
|
||||||
|
plan: InstallReleasePlan,
|
||||||
|
probe_path: Path,
|
||||||
|
) -> bool:
|
||||||
|
"""For a GPU plan whose metadata matches the existing install, smoke-test
|
||||||
|
the already-installed llama-server's offload before reusing it. Returns True
|
||||||
|
to keep the existing install (non-GPU kind, passed, or inconclusive) and
|
||||||
|
False on a definite CPU-only load so the caller reinstalls. This closes the
|
||||||
|
"reinstall/restart keeps the silently CPU-only binary" path (#5807): without
|
||||||
|
it, a metadata match short-circuits the new offload validation entirely."""
|
||||||
|
choice = plan.attempts[0]
|
||||||
|
# Only re-validate kinds whose CPU-only load is a fixable fault. macOS Metal
|
||||||
|
# is exempt (a CPU-only load is unfixable, see _GPU_OFFLOAD_REQUIRED_KINDS),
|
||||||
|
# so it keeps the existing install without a per-restart smoke test.
|
||||||
|
if choice.install_kind not in _GPU_OFFLOAD_REQUIRED_KINDS:
|
||||||
|
return True
|
||||||
|
server_name = "llama-server.exe" if host.is_windows else "llama-server"
|
||||||
|
try:
|
||||||
|
server_path = discover_installed_executable(install_dir, server_name)
|
||||||
|
except PrebuiltFallback:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
validate_server(
|
||||||
|
server_path,
|
||||||
|
probe_path,
|
||||||
|
host,
|
||||||
|
install_dir,
|
||||||
|
runtime_line = choice.runtime_line,
|
||||||
|
install_kind = choice.install_kind,
|
||||||
|
)
|
||||||
|
except GpuOffloadFailure:
|
||||||
|
return False
|
||||||
|
except PrebuiltFallback:
|
||||||
|
# Inconclusive (failed to start, etc.): keep the existing install rather
|
||||||
|
# than reinstall on uncertain evidence.
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def install_prebuilt(
|
def install_prebuilt(
|
||||||
install_dir: Path,
|
install_dir: Path,
|
||||||
llama_tag: str,
|
llama_tag: str,
|
||||||
|
|
@ -6539,8 +6829,15 @@ def install_prebuilt(
|
||||||
published_repo,
|
published_repo,
|
||||||
published_release_tag,
|
published_release_tag,
|
||||||
)
|
)
|
||||||
if release_plans and existing_install_matches_plan(
|
# Non-(offload-required) match: keep the fast path (no probe
|
||||||
install_dir, host, release_plans[0]
|
# download). A CUDA/ROCm/HIP match must still be smoke-tested below,
|
||||||
|
# so it does not short-circuit here; macOS Metal (not offload
|
||||||
|
# required) keeps the fast path.
|
||||||
|
if (
|
||||||
|
release_plans
|
||||||
|
and existing_install_matches_plan(install_dir, host, release_plans[0])
|
||||||
|
and release_plans[0].attempts[0].install_kind
|
||||||
|
not in _GPU_OFFLOAD_REQUIRED_KINDS
|
||||||
):
|
):
|
||||||
current = release_plans[0]
|
current = release_plans[0]
|
||||||
log(
|
log(
|
||||||
|
|
@ -6557,9 +6854,27 @@ def install_prebuilt(
|
||||||
release_count = len(release_plans)
|
release_count = len(release_plans)
|
||||||
for release_index, plan in enumerate(release_plans):
|
for release_index, plan in enumerate(release_plans):
|
||||||
choice = plan.attempts[0]
|
choice = plan.attempts[0]
|
||||||
if existing_install_matches_plan(install_dir, host, plan):
|
# A metadata match used to skip straight to reuse; now a
|
||||||
|
# matching GPU install is smoke-tested first so a previously
|
||||||
|
# installed CPU-only "GPU" binary is rebuilt instead of kept
|
||||||
|
# forever across reruns/restarts (#5807).
|
||||||
|
matched = existing_install_matches_plan(install_dir, host, plan)
|
||||||
|
existing_install_dir: Path | None = install_dir
|
||||||
|
if matched and not existing_gpu_install_offloads(
|
||||||
|
install_dir, host, plan, probe_path
|
||||||
|
):
|
||||||
log(
|
log(
|
||||||
"existing llama.cpp install already matches fallback release "
|
"existing GPU llama.cpp install served a completion but "
|
||||||
|
"loaded the model on CPU; reinstalling to recover GPU "
|
||||||
|
"offload (unslothai/unsloth#5807)"
|
||||||
|
)
|
||||||
|
matched = False
|
||||||
|
# Don't let validate_prebuilt_attempts short-circuit on
|
||||||
|
# the same bad install we just rejected.
|
||||||
|
existing_install_dir = None
|
||||||
|
if matched:
|
||||||
|
log(
|
||||||
|
"existing llama.cpp install already matches release "
|
||||||
f"{plan.release_tag} upstream_tag={plan.llama_tag}; skipping reinstall"
|
f"{plan.release_tag} upstream_tag={plan.llama_tag}; skipping reinstall"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
@ -6580,7 +6895,7 @@ def install_prebuilt(
|
||||||
release_tag = plan.release_tag,
|
release_tag = plan.release_tag,
|
||||||
approved_checksums = plan.approved_checksums,
|
approved_checksums = plan.approved_checksums,
|
||||||
initial_fallback_used = release_index > 0,
|
initial_fallback_used = release_index > 0,
|
||||||
existing_install_dir = install_dir,
|
existing_install_dir = existing_install_dir,
|
||||||
)
|
)
|
||||||
except ExistingInstallSatisfied:
|
except ExistingInstallSatisfied:
|
||||||
return
|
return
|
||||||
|
|
@ -6616,6 +6931,81 @@ def install_prebuilt(
|
||||||
raise SystemExit(EXIT_FALLBACK) from exc
|
raise SystemExit(EXIT_FALLBACK) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_smoke_test_install_kind(host: HostInfo) -> str:
|
||||||
|
"""Best-effort install_kind for a post-build GPU smoke test, derived from
|
||||||
|
the host. setup.sh / setup.ps1 can override with --install-kind. Offload-
|
||||||
|
required kinds (in _GPU_OFFLOAD_REQUIRED_KINDS) make the smoke test require
|
||||||
|
real GPU offload; CPU kinds and macOS Metal skip that check."""
|
||||||
|
if host.is_macos and host.is_arm64:
|
||||||
|
return "macos-arm64"
|
||||||
|
if host.has_rocm:
|
||||||
|
return "windows-hip" if host.is_windows else "linux-rocm"
|
||||||
|
if host.has_usable_nvidia:
|
||||||
|
return "windows-cuda" if host.is_windows else "linux-cuda"
|
||||||
|
if host.is_windows:
|
||||||
|
return "windows-cpu"
|
||||||
|
if host.is_macos:
|
||||||
|
return "macos-cpu"
|
||||||
|
return "linux-cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_test_server_binary(
|
||||||
|
server_binary: str,
|
||||||
|
host: HostInfo,
|
||||||
|
*,
|
||||||
|
install_dir: str | None = None,
|
||||||
|
probe: str | None = None,
|
||||||
|
install_kind: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Run validate_server against an already-built llama-server binary.
|
||||||
|
|
||||||
|
Raises PrebuiltFallback when the binary fails to start, fails the completion
|
||||||
|
probe, or (on a GPU host) loads the model only on CPU. Returns the resolved
|
||||||
|
install_kind on success.
|
||||||
|
"""
|
||||||
|
server_path = Path(server_binary).expanduser().resolve()
|
||||||
|
if not server_path.exists():
|
||||||
|
raise PrebuiltFallback(
|
||||||
|
f"smoke-test: llama-server binary not found at {server_path}"
|
||||||
|
)
|
||||||
|
resolved_install_dir = (
|
||||||
|
Path(install_dir).expanduser().resolve() if install_dir else server_path.parent
|
||||||
|
)
|
||||||
|
resolved_kind = install_kind or resolve_smoke_test_install_kind(host)
|
||||||
|
# For an offload-required kind (CUDA/ROCm/HIP), the CLI contract is
|
||||||
|
# "0 = offload confirmed", so an inconclusive (no-signal) log must not pass
|
||||||
|
# -- require a positive signal. macOS Metal is exempt (see
|
||||||
|
# _GPU_OFFLOAD_REQUIRED_KINDS): it only needs to load and serve.
|
||||||
|
require_signal = resolved_kind in _GPU_OFFLOAD_REQUIRED_KINDS
|
||||||
|
if probe:
|
||||||
|
probe_path = Path(probe).expanduser().resolve()
|
||||||
|
if not probe_path.exists():
|
||||||
|
raise PrebuiltFallback(f"smoke-test: probe model not found at {probe_path}")
|
||||||
|
validate_server(
|
||||||
|
server_path,
|
||||||
|
probe_path,
|
||||||
|
host,
|
||||||
|
resolved_install_dir,
|
||||||
|
install_kind = resolved_kind,
|
||||||
|
require_gpu_signal = require_signal,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with tempfile.TemporaryDirectory(prefix = "unsloth-llama-smoke-") as tmp:
|
||||||
|
probe_path = Path(tmp) / "stories260K.gguf"
|
||||||
|
download_validation_model(
|
||||||
|
probe_path, validation_model_cache_path(resolved_install_dir)
|
||||||
|
)
|
||||||
|
validate_server(
|
||||||
|
server_path,
|
||||||
|
probe_path,
|
||||||
|
host,
|
||||||
|
resolved_install_dir,
|
||||||
|
install_kind = resolved_kind,
|
||||||
|
require_gpu_signal = require_signal,
|
||||||
|
)
|
||||||
|
return resolved_kind
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description = "Install and validate a prebuilt llama.cpp bundle for Unsloth Studio."
|
description = "Install and validate a prebuilt llama.cpp bundle for Unsloth Studio."
|
||||||
|
|
@ -6647,6 +7037,33 @@ def parse_args() -> argparse.Namespace:
|
||||||
action = "store_true",
|
action = "store_true",
|
||||||
help = "Use the simplified platform-specific prebuilt selection policy.",
|
help = "Use the simplified platform-specific prebuilt selection policy.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--smoke-test",
|
||||||
|
metavar = "LLAMA_SERVER",
|
||||||
|
help = (
|
||||||
|
"Validate an already-built llama-server binary instead of "
|
||||||
|
"installing. Launches it against a tiny probe model and, on a GPU "
|
||||||
|
"host, verifies the model actually offloaded to the GPU. Exits 0 on "
|
||||||
|
"success, 2 (EXIT_FALLBACK) when the binary only ran on CPU, 1 "
|
||||||
|
"(EXIT_ERROR) when inconclusive -- used by setup.sh / setup.ps1 to "
|
||||||
|
"validate source builds."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--probe",
|
||||||
|
help = (
|
||||||
|
"Optional probe GGUF for --smoke-test. Defaults to the bundled "
|
||||||
|
"validation model (downloaded if absent)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--install-kind",
|
||||||
|
help = (
|
||||||
|
"Override the install_kind used by --smoke-test (e.g. linux-cuda, "
|
||||||
|
"windows-cuda, linux-rocm, windows-hip, macos-arm64). Auto-derived "
|
||||||
|
"from the host when omitted."
|
||||||
|
),
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--has-rocm",
|
"--has-rocm",
|
||||||
action = "store_true",
|
action = "store_true",
|
||||||
|
|
@ -6788,6 +7205,48 @@ def main() -> int:
|
||||||
)
|
)
|
||||||
return EXIT_SUCCESS
|
return EXIT_SUCCESS
|
||||||
|
|
||||||
|
if args.smoke_test is not None:
|
||||||
|
# Honor the same host overrides as the install path so a caller that
|
||||||
|
# forwards --has-rocm / --rocm-gfx (setup.sh/setup.ps1 do, because the
|
||||||
|
# installer's own probe can miss amd-smi-only hosts) is held to the GPU
|
||||||
|
# offload check instead of silently resolving a CPU kind.
|
||||||
|
host = _apply_host_overrides(
|
||||||
|
detect_host(),
|
||||||
|
override_has_rocm = args.has_rocm,
|
||||||
|
override_rocm_gfx = args.rocm_gfx,
|
||||||
|
force_cpu = args.cpu_fallback,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resolved_kind = smoke_test_server_binary(
|
||||||
|
args.smoke_test,
|
||||||
|
host,
|
||||||
|
install_dir = args.install_dir,
|
||||||
|
probe = args.probe,
|
||||||
|
install_kind = args.install_kind,
|
||||||
|
)
|
||||||
|
except GpuOffloadFailure as exc:
|
||||||
|
# Definitive: GPU was requested but the model loaded on CPU. Exit
|
||||||
|
# EXIT_FALLBACK so the caller (setup.sh / setup.ps1) rebuilds CPU.
|
||||||
|
log(f"smoke-test failed (CPU-only): {exc}")
|
||||||
|
return EXIT_FALLBACK
|
||||||
|
except PrebuiltFallback as exc:
|
||||||
|
# Inconclusive: the binary could not be exercised (failed to start,
|
||||||
|
# missing probe, ...). Exit EXIT_ERROR so the caller keeps the GPU
|
||||||
|
# build rather than downgrading it on uncertain evidence.
|
||||||
|
log(f"smoke-test inconclusive: {exc}")
|
||||||
|
return EXIT_ERROR
|
||||||
|
if resolved_kind in _GPU_OFFLOAD_REQUIRED_KINDS:
|
||||||
|
log(
|
||||||
|
f"smoke-test passed: {args.smoke_test} "
|
||||||
|
f"(install_kind={resolved_kind}) offloaded to the GPU"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log(
|
||||||
|
f"smoke-test passed: {args.smoke_test} "
|
||||||
|
f"(install_kind={resolved_kind})"
|
||||||
|
)
|
||||||
|
return EXIT_SUCCESS
|
||||||
|
|
||||||
if not args.install_dir:
|
if not args.install_dir:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"install_llama_prebuilt.py: --install-dir is required unless --resolve-llama-tag, --resolve-install-tag, or --resolve-source-build is used"
|
"install_llama_prebuilt.py: --install-dir is required unless --resolve-llama-tag, --resolve-install-tag, or --resolve-source-build is used"
|
||||||
|
|
|
||||||
162
studio/setup.ps1
162
studio/setup.ps1
|
|
@ -2633,6 +2633,16 @@ if (Test-Path -LiteralPath $LlamaServerBin) {
|
||||||
Write-Host " Existing llama-server was built with CUDA but no GPU detected -- rebuilding" -ForegroundColor Yellow
|
Write-Host " Existing llama-server was built with CUDA but no GPU detected -- rebuilding" -ForegroundColor Yellow
|
||||||
$NeedRebuild = $true
|
$NeedRebuild = $true
|
||||||
}
|
}
|
||||||
|
# The cache check catches a CPU build on a GPU host; this catches a CUDA
|
||||||
|
# build that still loads on CPU at runtime (PTX / runtime-init failure,
|
||||||
|
# #5807). Smoke-test before reusing it on a GPU host.
|
||||||
|
if (-not $NeedRebuild -and $HasNvidiaSmi -and $cachedCuda) {
|
||||||
|
& python "$PSScriptRoot\install_llama_prebuilt.py" --smoke-test "$LlamaServerBin" --install-dir "$LlamaCppDir" --install-kind "windows-cuda" 2>&1 | Out-String | Write-Host
|
||||||
|
if ($LASTEXITCODE -eq 2) {
|
||||||
|
Write-Host " Existing CUDA llama-server runs on CPU only -- rebuilding" -ForegroundColor Yellow
|
||||||
|
$NeedRebuild = $true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2657,6 +2667,14 @@ if (-not $NeedLlamaSourceBuild) {
|
||||||
substep "GGUF inference and export will not be available." "Yellow"
|
substep "GGUF inference and export will not be available." "Yellow"
|
||||||
substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow"
|
substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow"
|
||||||
$script:LlamaCppDegraded = $true
|
$script:LlamaCppDegraded = $true
|
||||||
|
} elseif ($HasROCm -and -not $HasNvidiaSmi) {
|
||||||
|
# Windows has no HIP source-build path, so a ROCm host whose HIP prebuilt was
|
||||||
|
# missing or rejected must not silently CPU-source-build. Mark degraded so
|
||||||
|
# the CPU-prebuilt last resort below installs a clearly labelled CPU build
|
||||||
|
# instead of a "built" install that runs on CPU (#5807).
|
||||||
|
Write-Host ""
|
||||||
|
step "llama.cpp" "no usable HIP prebuilt and no Windows HIP source build; using CPU prebuilt" "Yellow"
|
||||||
|
$script:LlamaCppDegraded = $true
|
||||||
} else {
|
} else {
|
||||||
# A source build is committed here. The CUDA toolkit is only needed now, so
|
# A source build is committed here. The CUDA toolkit is only needed now, so
|
||||||
# resolve (and winget-install if needed) it lazily, failing fast if no
|
# resolve (and winget-install if needed) it lazily, failing fast if no
|
||||||
|
|
@ -2952,39 +2970,53 @@ if (-not $NeedLlamaSourceBuild) {
|
||||||
$CmakeArgs += '-DLLAMA_CURL=OFF'
|
$CmakeArgs += '-DLLAMA_CURL=OFF'
|
||||||
}
|
}
|
||||||
$CmakeArgs += '-DCMAKE_EXE_LINKER_FLAGS=/NODEFAULTLIB:LIBCMT'
|
$CmakeArgs += '-DCMAKE_EXE_LINKER_FLAGS=/NODEFAULTLIB:LIBCMT'
|
||||||
# CUDA flags -- only if GPU available, otherwise explicitly disable
|
# CUDA flags -- only if GPU available, otherwise explicitly disable.
|
||||||
|
# $LlamaCudaBuild gates the post-build GPU smoke test and CUDA->CPU
|
||||||
|
# retry below.
|
||||||
|
$LlamaCudaBuild = $false
|
||||||
if ($HasNvidiaSmi -and $NvccPath) {
|
if ($HasNvidiaSmi -and $NvccPath) {
|
||||||
$CmakeArgs += '-DGGML_CUDA=ON'
|
# Resolve a concrete CUDA architecture FIRST. A CUDA build with no
|
||||||
# Accept a host MSVC newer than nvcc's whitelist; a fresh toolkit
|
# -DCMAKE_CUDA_ARCHITECTURES is PTX-only and can fail at runtime on a
|
||||||
# (e.g. CUDA 13.3) otherwise aborts with "#error -- unsupported
|
# driver older than the toolkit ("the provided PTX was compiled with
|
||||||
# Microsoft Visual Studio version!". Mirrors the Linux fix. Via env
|
# an unsupported toolchain", #5854). If we cannot resolve a supported
|
||||||
# (covers the configure probe + build), after Refresh-Environment, idempotent.
|
# arch, build CPU-only instead of shipping a silently broken binary.
|
||||||
$nvccAllowFlag = '-allow-unsupported-compiler'
|
$cudaArchFlag = $null
|
||||||
if ([string]::IsNullOrEmpty($env:NVCC_PREPEND_FLAGS)) {
|
|
||||||
$env:NVCC_PREPEND_FLAGS = $nvccAllowFlag
|
|
||||||
} elseif ($env:NVCC_PREPEND_FLAGS -notlike "*$nvccAllowFlag*") {
|
|
||||||
$env:NVCC_PREPEND_FLAGS = "$($env:NVCC_PREPEND_FLAGS) $nvccAllowFlag"
|
|
||||||
}
|
|
||||||
substep "NVCC_PREPEND_FLAGS = $env:NVCC_PREPEND_FLAGS"
|
|
||||||
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
|
|
||||||
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
|
|
||||||
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
|
|
||||||
if ($CudaArch) {
|
if ($CudaArch) {
|
||||||
# Validate nvcc actually supports this architecture
|
|
||||||
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
|
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
|
||||||
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
|
$cudaArchFlag = "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
|
||||||
} else {
|
} else {
|
||||||
# GPU arch too new for this toolkit -- fall back to highest supported.
|
# GPU arch too new for this toolkit -- fall back to highest
|
||||||
# PTX forward-compatibility will JIT-compile for the actual GPU at runtime.
|
# supported. PTX forward-compat will JIT for the real GPU.
|
||||||
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
|
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
|
||||||
if ($maxArch) {
|
if ($maxArch) {
|
||||||
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
|
$cudaArchFlag = "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
|
||||||
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
|
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
|
||||||
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
|
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
|
||||||
}
|
}
|
||||||
# else: omit flag entirely, let cmake pick defaults
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($cudaArchFlag) {
|
||||||
|
$CmakeArgs += '-DGGML_CUDA=ON'
|
||||||
|
# Accept a host MSVC newer than nvcc's whitelist; a fresh toolkit
|
||||||
|
# (e.g. CUDA 13.3) otherwise aborts with "#error -- unsupported
|
||||||
|
# Microsoft Visual Studio version!". Via env (covers the configure
|
||||||
|
# probe + build), after Refresh-Environment, idempotent.
|
||||||
|
$nvccAllowFlag = '-allow-unsupported-compiler'
|
||||||
|
if ([string]::IsNullOrEmpty($env:NVCC_PREPEND_FLAGS)) {
|
||||||
|
$env:NVCC_PREPEND_FLAGS = $nvccAllowFlag
|
||||||
|
} elseif ($env:NVCC_PREPEND_FLAGS -notlike "*$nvccAllowFlag*") {
|
||||||
|
$env:NVCC_PREPEND_FLAGS = "$($env:NVCC_PREPEND_FLAGS) $nvccAllowFlag"
|
||||||
|
}
|
||||||
|
substep "NVCC_PREPEND_FLAGS = $env:NVCC_PREPEND_FLAGS"
|
||||||
|
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
|
||||||
|
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
|
||||||
|
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
|
||||||
|
$CmakeArgs += $cudaArchFlag
|
||||||
|
$LlamaCudaBuild = $true
|
||||||
|
} else {
|
||||||
|
substep "Could not resolve a supported CUDA architecture for this GPU/toolkit; building CPU-only to avoid a PTX-only binary that fails at runtime (#5854)" "Yellow"
|
||||||
|
$CmakeArgs += '-DGGML_CUDA=OFF'
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$CmakeArgs += '-DGGML_CUDA=OFF'
|
$CmakeArgs += '-DGGML_CUDA=OFF'
|
||||||
}
|
}
|
||||||
|
|
@ -3025,6 +3057,63 @@ if (-not $NeedLlamaSourceBuild) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# -- Step C.5: GPU smoke test + CUDA->CPU fallback (#5807 / #5854) --
|
||||||
|
# A CUDA build whose runtime backend fails to initialize still links and
|
||||||
|
# serves HTTP 200, but only from CPU; and setup.ps1 previously had no CPU
|
||||||
|
# fallback when a CUDA build failed at all. Both gaps are closed here.
|
||||||
|
if ($LlamaCudaBuild -and $BuildOk) {
|
||||||
|
$builtServer = Join-Path $BuildDir "bin\Release\llama-server.exe"
|
||||||
|
if (-not (Test-Path -LiteralPath $builtServer)) {
|
||||||
|
$builtServer = Join-Path $BuildDir "bin\llama-server.exe"
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $builtServer) {
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "--- GPU smoke test ---" -ForegroundColor Cyan
|
||||||
|
# $LlamaCudaBuild gates this block, so the build is CUDA; pass the
|
||||||
|
# explicit kind so the installer's own probe cannot resolve a CPU
|
||||||
|
# kind and skip the offload gate.
|
||||||
|
& python "$PSScriptRoot\install_llama_prebuilt.py" --smoke-test "$builtServer" --install-dir "$LlamaCppDir" --install-kind "windows-cuda" 2>&1 | Out-String | Write-Host
|
||||||
|
$smokeExit = $LASTEXITCODE
|
||||||
|
if ($smokeExit -eq 2) {
|
||||||
|
substep "GPU build runs on CPU only (GPU backend failed to initialize)" "Yellow"
|
||||||
|
$BuildOk = $false
|
||||||
|
$FailedStep = "GPU smoke test (ran on CPU)"
|
||||||
|
} elseif ($smokeExit -ne 0) {
|
||||||
|
substep "GPU smoke test inconclusive (exit $smokeExit); keeping GPU build" "Yellow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If a CUDA build was attempted and configure/build/smoke-test left it
|
||||||
|
# unusable, retry once with CUDA disabled so the user still gets a working
|
||||||
|
# (if slower) CPU llama-server instead of nothing.
|
||||||
|
if ($LlamaCudaBuild -and -not $BuildOk) {
|
||||||
|
substep "CUDA build unusable at: $FailedStep; retrying CPU-only build..." "Yellow"
|
||||||
|
$CpuCmakeArgs = @($CmakeArgs | Where-Object {
|
||||||
|
$_ -ne '-DGGML_CUDA=ON' -and
|
||||||
|
$_ -notlike '-DCMAKE_CUDA_ARCHITECTURES=*' -and
|
||||||
|
$_ -notlike '-DCUDAToolkit_ROOT=*' -and
|
||||||
|
$_ -notlike '-DCUDA_TOOLKIT_ROOT_DIR=*' -and
|
||||||
|
$_ -notlike '-DCMAKE_CUDA_COMPILER=*'
|
||||||
|
})
|
||||||
|
$CpuCmakeArgs += '-DGGML_CUDA=OFF'
|
||||||
|
if (Test-Path -LiteralPath $BuildDir) { Remove-Item -LiteralPath $BuildDir -Recurse -Force }
|
||||||
|
$cpuConfigure = cmake @CpuCmakeArgs 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$cpuBuild = cmake --build $BuildDir --config Release --target llama-server -j $NumCpu 2>&1 | Out-String
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$BuildOk = $true
|
||||||
|
$LlamaCudaBuild = $false
|
||||||
|
$FailedStep = $null
|
||||||
|
substep "CPU-only llama.cpp build succeeded" "Green"
|
||||||
|
} else {
|
||||||
|
Write-LlamaFailureLog -Output $cpuBuild
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-LlamaFailureLog -Output $cpuConfigure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# -- Step D: Build llama-quantize (optional, best-effort) --
|
# -- Step D: Build llama-quantize (optional, best-effort) --
|
||||||
if ($BuildOk) {
|
if ($BuildOk) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
@ -3083,6 +3172,35 @@ if (-not $NeedLlamaSourceBuild) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# Windows GPU: CPU prebuilt as a last resort
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# A GPU host reaches the source build only when no GPU prebuilt offloads (the
|
||||||
|
# CPU prebuilt is deliberately not offered to a GPU host so it does not short
|
||||||
|
# circuit the source build, #5807). If that build produced no binary (no CUDA
|
||||||
|
# toolkit, compile failure), install the CPU prebuilt via --cpu-fallback so the
|
||||||
|
# host still gets a working (if slower) llama-server instead of nothing.
|
||||||
|
if ($script:LlamaCppDegraded -and ($HasNvidiaSmi -or $HasROCm)) {
|
||||||
|
substep "GPU build unavailable; trying CPU prebuilt as a last resort..." "Yellow"
|
||||||
|
$lastResortArgs = @(
|
||||||
|
"$PSScriptRoot\install_llama_prebuilt.py",
|
||||||
|
"--install-dir", $OriginalLlamaCppDir,
|
||||||
|
"--llama-tag", $RequestedLlamaTag,
|
||||||
|
"--published-repo", $HelperReleaseRepo,
|
||||||
|
"--simple-policy",
|
||||||
|
"--cpu-fallback"
|
||||||
|
)
|
||||||
|
$prevEAPLast = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& python @lastResortArgs 2>&1 | Out-String | Write-Host
|
||||||
|
$lastResortExit = $LASTEXITCODE
|
||||||
|
$ErrorActionPreference = $prevEAPLast
|
||||||
|
if ($lastResortExit -eq 0) {
|
||||||
|
step "llama.cpp" "CPU prebuilt installed (GPU unavailable; inference will run on CPU)" "Yellow"
|
||||||
|
$script:LlamaCppDegraded = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Footer
|
# Footer
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
132
studio/setup.sh
132
studio/setup.sh
|
|
@ -919,12 +919,41 @@ if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] && \
|
||||||
[ -z "$_LLAMA_PR" ] && \
|
[ -z "$_LLAMA_PR" ] && \
|
||||||
[ -x "$LLAMA_CPP_DIR/build/bin/llama-server" ] && \
|
[ -x "$LLAMA_CPP_DIR/build/bin/llama-server" ] && \
|
||||||
[ -x "$LLAMA_CPP_DIR/build/bin/llama-quantize" ]; then
|
[ -x "$LLAMA_CPP_DIR/build/bin/llama-quantize" ]; then
|
||||||
step "llama.cpp" "existing source build found; skipping rebuild"
|
_REUSE_SOURCE=true
|
||||||
ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize"
|
# On a GPU host, smoke-test the existing source binary first so a stale
|
||||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
|
# CPU-only build (e.g. an earlier no-toolkit fallback) is rebuilt instead of
|
||||||
: > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
|
# silently reused on a GPU host (#5807). Exit 2 = ran on CPU -> rebuild;
|
||||||
|
# anything else keeps the build (never downgrade on uncertain evidence).
|
||||||
|
_REUSE_KIND=""
|
||||||
|
if [ "$_HOST_SYSTEM" = "Darwin" ] && { [ "$_HOST_MACHINE" = "arm64" ] || [ "$_HOST_MACHINE" = "aarch64" ]; }; then
|
||||||
|
_REUSE_KIND="macos-arm64"
|
||||||
|
elif command -v nvidia-smi >/dev/null 2>&1; then
|
||||||
|
_REUSE_KIND="linux-cuda"
|
||||||
|
elif [ "$_LINUX_HAS_GPU" = true ]; then
|
||||||
|
_REUSE_KIND="linux-rocm"
|
||||||
|
fi
|
||||||
|
if [ -n "$_REUSE_KIND" ]; then
|
||||||
|
if python "$SCRIPT_DIR/install_llama_prebuilt.py" \
|
||||||
|
--smoke-test "$LLAMA_CPP_DIR/build/bin/llama-server" \
|
||||||
|
--install-dir "$LLAMA_CPP_DIR" \
|
||||||
|
--install-kind "$_REUSE_KIND" > "$LLAMA_CPP_DIR/gpu-smoke-existing.log" 2>&1; then
|
||||||
|
_REUSE_RC=0
|
||||||
|
else
|
||||||
|
_REUSE_RC=$?
|
||||||
|
fi
|
||||||
|
if [ "$_REUSE_RC" -eq 2 ]; then
|
||||||
|
substep "existing source build runs on CPU only; rebuilding for GPU..." "$C_WARN"
|
||||||
|
_REUSE_SOURCE=false
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ "$_REUSE_SOURCE" = true ]; then
|
||||||
|
step "llama.cpp" "existing source build found; skipping rebuild"
|
||||||
|
ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize"
|
||||||
|
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
|
||||||
|
: > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
_NEED_LLAMA_SOURCE_BUILD=false
|
||||||
fi
|
fi
|
||||||
_NEED_LLAMA_SOURCE_BUILD=false
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 8. WSL: pre-install GGUF build dependencies for fallback source builds ──
|
# ── 8. WSL: pre-install GGUF build dependencies for fallback source builds ──
|
||||||
|
|
@ -1340,6 +1369,68 @@ else
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Map an install_llama_prebuilt.py --smoke-test exit code to a decision
|
||||||
|
# token. 2 (EXIT_FALLBACK) = definitively GPU-intended but CPU-only ->
|
||||||
|
# rebuild CPU; 0 = offload confirmed; anything else (1/EXIT_ERROR,
|
||||||
|
# signals) = inconclusive -> keep the GPU build rather than downgrade on
|
||||||
|
# uncertain evidence. Tiny + side-effect free so
|
||||||
|
# tests/sh/test_llama_gpu_smoke.sh can exercise it.
|
||||||
|
_classify_smoke_exit() {
|
||||||
|
case "$1" in
|
||||||
|
0) echo "ok" ;;
|
||||||
|
2) echo "cpu_only" ;;
|
||||||
|
*) echo "inconclusive" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Post-build GPU smoke test (#5807 / #5854): a GPU build whose runtime
|
||||||
|
# backend fails to initialize still links and serves HTTP 200, but only
|
||||||
|
# from CPU. Confirm the fresh binary actually offloads to the GPU; if it
|
||||||
|
# ran on CPU only, retry a CPU build so the user gets a working (if
|
||||||
|
# slower) llama-server instead of a silently CPU-only "GPU" build.
|
||||||
|
# _gpu_fallback_label is empty for a pure CPU build (nothing to verify).
|
||||||
|
if [ "$BUILD_OK" = true ]; then
|
||||||
|
_SMOKE_LABEL="$(_gpu_fallback_label)"
|
||||||
|
# Pass the GPU kind setup resolved, not the installer's own probe:
|
||||||
|
# on amd-smi-only / name-inferred ROCm hosts the child probe can miss
|
||||||
|
# the GPU and resolve a CPU kind, which would skip the offload gate.
|
||||||
|
_SMOKE_KIND=""
|
||||||
|
if [ "$_TRY_METAL_CPU_FALLBACK" = true ]; then
|
||||||
|
_SMOKE_KIND="macos-arm64"
|
||||||
|
elif [ "$GPU_BACKEND" = "cuda" ]; then
|
||||||
|
_SMOKE_KIND="linux-cuda"
|
||||||
|
elif [ "$GPU_BACKEND" = "rocm" ]; then
|
||||||
|
_SMOKE_KIND="linux-rocm"
|
||||||
|
fi
|
||||||
|
if [ -n "$_SMOKE_LABEL" ] && [ -n "$_SMOKE_KIND" ] && [ -f "$_BUILD_TMP/build/bin/llama-server" ]; then
|
||||||
|
# if/else keeps set -e from aborting before we read the code.
|
||||||
|
if python "$SCRIPT_DIR/install_llama_prebuilt.py" \
|
||||||
|
--smoke-test "$_BUILD_TMP/build/bin/llama-server" \
|
||||||
|
--install-dir "$_BUILD_TMP" \
|
||||||
|
--install-kind "$_SMOKE_KIND" > "$_BUILD_TMP/gpu-smoke.log" 2>&1; then
|
||||||
|
_SMOKE_RC=0
|
||||||
|
else
|
||||||
|
_SMOKE_RC=$?
|
||||||
|
fi
|
||||||
|
case "$(_classify_smoke_exit "$_SMOKE_RC")" in
|
||||||
|
cpu_only)
|
||||||
|
substep "$_SMOKE_LABEL build runs on CPU only; retrying CPU build..." "$C_WARN"
|
||||||
|
rm -rf "$_BUILD_TMP/build"
|
||||||
|
if run_quiet_no_exit "cmake llama.cpp (cpu fallback)" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CPU_FALLBACK_CMAKE_ARGS; then
|
||||||
|
_BUILD_DESC="building (CPU fallback after $_SMOKE_LABEL smoke test ran on CPU)"
|
||||||
|
GPU_BACKEND=""
|
||||||
|
run_quiet_no_exit "build llama-server (cpu fallback)" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false
|
||||||
|
else
|
||||||
|
BUILD_OK=false
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
inconclusive)
|
||||||
|
substep "GPU smoke test inconclusive (exit $_SMOKE_RC); keeping $_SMOKE_LABEL build" "$C_WARN"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$BUILD_OK" = true ]; then
|
if [ "$BUILD_OK" = true ]; then
|
||||||
run_quiet_no_exit "build llama-quantize" cmake --build "$_BUILD_TMP/build" --config Release --target llama-quantize -j"$NCPU" || true
|
run_quiet_no_exit "build llama-quantize" cmake --build "$_BUILD_TMP/build" --config Release --target llama-quantize -j"$NCPU" || true
|
||||||
fi
|
fi
|
||||||
|
|
@ -1373,27 +1464,36 @@ else
|
||||||
}
|
}
|
||||||
fi # end _SKIP_GGUF_BUILD check
|
fi # end _SKIP_GGUF_BUILD check
|
||||||
|
|
||||||
# ── arm64 Linux GPU: CPU prebuilt as a last resort ──
|
# ── Linux GPU: CPU prebuilt as a last resort ──
|
||||||
# arm64 Linux with a GPU has no CUDA prebuilt anywhere (the unslothai fork is
|
# A Linux GPU host reaches a source build when no GPU prebuilt offloads (the
|
||||||
# x64 only; ggml-org ships no Linux CUDA build), so it source-builds for the
|
# CPU prebuilt is deliberately not offered to a GPU host so it does not short
|
||||||
# GPU above. If that produced no binary, install ggml-org's arm64 CPU prebuilt
|
# circuit the source build -- #5807). If that build produced no binary (no
|
||||||
# instead of leaving the host without llama.cpp.
|
# toolkit, compile failure), install a CPU prebuilt instead of leaving the host
|
||||||
|
# without llama.cpp. arm64 has no CUDA prebuilt anywhere, so it always lands
|
||||||
|
# here on a degraded GPU build; x86_64 only when its source build also failed.
|
||||||
|
# Repo: ggml-org ships the arm64 CPU tarball; x86_64 uses the same published
|
||||||
|
# repo as the primary path (the unslothai fork carries linux-x64-cpu).
|
||||||
if [ "$_LLAMA_CPP_DEGRADED" = true ] \
|
if [ "$_LLAMA_CPP_DEGRADED" = true ] \
|
||||||
&& [ "$_HOST_SYSTEM" = "Linux" ] \
|
&& [ "$_HOST_SYSTEM" = "Linux" ] \
|
||||||
&& { [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; }; then
|
&& [ "$_LINUX_HAS_GPU" = true ]; then
|
||||||
substep "GPU source build unavailable; trying ggml-org arm64 CPU prebuilt..."
|
if [ "$_HOST_MACHINE" = "aarch64" ] || [ "$_HOST_MACHINE" = "arm64" ]; then
|
||||||
_ARM64_CPU_CMD=(
|
_LASTRESORT_CPU_REPO="ggml-org/llama.cpp"
|
||||||
|
else
|
||||||
|
_LASTRESORT_CPU_REPO="$_HELPER_RELEASE_REPO"
|
||||||
|
fi
|
||||||
|
substep "GPU build unavailable; trying $_LASTRESORT_CPU_REPO CPU prebuilt as a last resort..."
|
||||||
|
_LASTRESORT_CPU_CMD=(
|
||||||
python "$SCRIPT_DIR/install_llama_prebuilt.py"
|
python "$SCRIPT_DIR/install_llama_prebuilt.py"
|
||||||
--install-dir "$LLAMA_CPP_DIR"
|
--install-dir "$LLAMA_CPP_DIR"
|
||||||
--llama-tag "$_REQUESTED_LLAMA_TAG"
|
--llama-tag "$_REQUESTED_LLAMA_TAG"
|
||||||
--published-repo "ggml-org/llama.cpp"
|
--published-repo "$_LASTRESORT_CPU_REPO"
|
||||||
--simple-policy
|
--simple-policy
|
||||||
--cpu-fallback
|
--cpu-fallback
|
||||||
)
|
)
|
||||||
# Trust the installer's exit code: it validates the server before exiting 0,
|
# Trust the installer's exit code: it validates the server before exiting 0,
|
||||||
# the same signal the primary prebuilt path above relies on.
|
# the same signal the primary prebuilt path above relies on.
|
||||||
if run_quiet_no_exit "arm64 CPU prebuilt" "${_ARM64_CPU_CMD[@]}"; then
|
if run_quiet_no_exit "CPU prebuilt (last resort)" "${_LASTRESORT_CPU_CMD[@]}"; then
|
||||||
step "llama.cpp" "arm64 CPU prebuilt installed (GPU build unavailable)" "$C_WARN"
|
step "llama.cpp" "CPU prebuilt installed (GPU unavailable; inference will run on CPU)" "$C_WARN"
|
||||||
_LLAMA_CPP_DEGRADED=false
|
_LLAMA_CPP_DEGRADED=false
|
||||||
print_installed_llama_prebuilt_release "$LLAMA_CPP_DIR"
|
print_installed_llama_prebuilt_release "$LLAMA_CPP_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ sh "$TESTS_DIR/sh/test_get_torch_index_url.sh"
|
||||||
sh "$TESTS_DIR/sh/test_mac_intel_compat.sh"
|
sh "$TESTS_DIR/sh/test_mac_intel_compat.sh"
|
||||||
sh "$TESTS_DIR/sh/test_torch_constraint.sh"
|
sh "$TESTS_DIR/sh/test_torch_constraint.sh"
|
||||||
sh "$TESTS_DIR/sh/test_nvcc_meets_llama_minimum.sh"
|
sh "$TESTS_DIR/sh/test_nvcc_meets_llama_minimum.sh"
|
||||||
|
sh "$TESTS_DIR/sh/test_llama_gpu_smoke.sh"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Python tests ==="
|
echo "=== Python tests ==="
|
||||||
|
|
|
||||||
59
tests/sh/test_llama_gpu_smoke.sh
Executable file
59
tests/sh/test_llama_gpu_smoke.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Unit tests for _classify_smoke_exit() from studio/setup.sh.
|
||||||
|
#
|
||||||
|
# After a llama.cpp source build, setup.sh runs install_llama_prebuilt.py
|
||||||
|
# --smoke-test against the fresh binary and maps its exit code to a decision:
|
||||||
|
# 2 (EXIT_FALLBACK) -> "cpu_only" : GPU was requested but the model ran
|
||||||
|
# on CPU -> rebuild CPU (#5807 / #5854).
|
||||||
|
# 0 -> "ok" : GPU offload confirmed -> keep build.
|
||||||
|
# 1 / signals / etc -> "inconclusive" : could not validate -> keep GPU build
|
||||||
|
# (never downgrade on uncertainty).
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh"
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
# Extract just the helper (same approach as test_nvcc_meets_llama_minimum.sh).
|
||||||
|
# The function and its closing brace sit at 8-space indent inside setup.sh.
|
||||||
|
_FUNC_FILE=$(mktemp)
|
||||||
|
sed -n '/ _classify_smoke_exit() {/,/^ }/p' "$SETUP_SH" > "$_FUNC_FILE"
|
||||||
|
|
||||||
|
assert_eq() {
|
||||||
|
_label="$1"; _expected="$2"; _actual="$3"
|
||||||
|
if [ "$_actual" = "$_expected" ]; then
|
||||||
|
echo " PASS: $_label"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " FAIL: $_label (expected '$_expected', got '$_actual')"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_classify() {
|
||||||
|
bash -c ". '$_FUNC_FILE'; _classify_smoke_exit '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== test_llama_gpu_smoke (_classify_smoke_exit) ==="
|
||||||
|
|
||||||
|
assert_eq "exit 0 -> ok" "ok" "$(run_classify 0)"
|
||||||
|
assert_eq "exit 2 -> cpu_only" "cpu_only" "$(run_classify 2)"
|
||||||
|
assert_eq "exit 1 -> inconclusive" "inconclusive" "$(run_classify 1)"
|
||||||
|
assert_eq "exit 3 -> inconclusive" "inconclusive" "$(run_classify 3)"
|
||||||
|
assert_eq "exit 137 -> inconclusive" "inconclusive" "$(run_classify 137)"
|
||||||
|
|
||||||
|
# Sanity: the extracted function is non-empty and well-formed.
|
||||||
|
if [ -s "$_FUNC_FILE" ] && grep -q 'cpu_only' "$_FUNC_FILE"; then
|
||||||
|
echo " PASS: _classify_smoke_exit extracted from setup.sh"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " FAIL: _classify_smoke_exit could not be extracted from setup.sh"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$_FUNC_FILE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[ "$FAIL" -eq 0 ] || exit 1
|
||||||
106
tests/studio/install/fake_llama_server.py
Normal file
106
tests/studio/install/fake_llama_server.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A fake llama-server for GPU-offload validation tests (no GPU required).
|
||||||
|
|
||||||
|
It accepts the arguments install_llama_prebuilt.py's validate_server passes
|
||||||
|
(``-m`` / ``--host`` / ``--port`` / ``--n-gpu-layers`` / ...), prints a canned
|
||||||
|
llama.cpp startup log chosen by the ``FAKE_LLAMA_MODE`` env var, then serves
|
||||||
|
HTTP 200 from ``/completion`` until killed. This lets CI exercise the real
|
||||||
|
validate_server subprocess + HTTP + log-classifier path on GPU-less Windows /
|
||||||
|
macOS / Linux runners: the same binary "starts and serves 200" while its log
|
||||||
|
says CPU-only or GPU, which is exactly the #5807 / #5830 situation.
|
||||||
|
|
||||||
|
FAKE_LLAMA_MODE (default "cuda"):
|
||||||
|
cuda device_info enumerates CUDA0 (GPU offload confirmed)
|
||||||
|
cuda_buffer older "CUDA0 model buffer size" + offloaded 33/33 lines
|
||||||
|
cpu device_info enumerates only CPU (the silent CPU fallback)
|
||||||
|
offloaded_zero "offloaded 0/33 layers to GPU" (definite CPU-only)
|
||||||
|
no_signal a log with no offload evidence (validator must not reject)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
|
||||||
|
|
||||||
|
LOGS = {
|
||||||
|
"cuda": (
|
||||||
|
"0.00 I device_info:\n"
|
||||||
|
"0.01 I - CUDA0 : NVIDIA GeForce RTX 5070 (12282 MiB, 11000 MiB free)\n"
|
||||||
|
"0.01 I - CPU : Generic CPU (32000 MiB free)\n"
|
||||||
|
"0.01 I system_info: n_threads = 8 | CUDA : ARCHS = 1200 | CPU : AVX2 = 1\n"
|
||||||
|
"0.02 I srv llama_server: model loaded\n"
|
||||||
|
),
|
||||||
|
"cuda_buffer": (
|
||||||
|
"load_tensors: offloaded 33/33 layers to GPU\n"
|
||||||
|
"load_tensors: CUDA0 model buffer size = 21000.0 MiB\n"
|
||||||
|
"load_tensors: CPU_Mapped model buffer size = 0.6 MiB\n"
|
||||||
|
"srv llama_server: model loaded\n"
|
||||||
|
),
|
||||||
|
"cpu": (
|
||||||
|
"0.00 I device_info:\n"
|
||||||
|
"0.00 I - CPU : Generic CPU (32000 MiB free)\n"
|
||||||
|
"0.00 I system_info: n_threads = 8 | CPU : AVX2 = 1\n"
|
||||||
|
"0.01 I srv llama_server: model loaded\n"
|
||||||
|
),
|
||||||
|
"offloaded_zero": (
|
||||||
|
"load_tensors: offloaded 0/33 layers to GPU\n"
|
||||||
|
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB\n"
|
||||||
|
"srv llama_server: model loaded\n"
|
||||||
|
),
|
||||||
|
"no_signal": (
|
||||||
|
"INFO [main] starting server\n"
|
||||||
|
"load_tensors: file format = GGUF V3\n"
|
||||||
|
"srv llama_server: model loaded\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _arg(name, default = None):
|
||||||
|
argv = sys.argv
|
||||||
|
for i, token in enumerate(argv):
|
||||||
|
if token == name and i + 1 < len(argv):
|
||||||
|
return argv[i + 1]
|
||||||
|
if token.startswith(name + "="):
|
||||||
|
return token.split("=", 1)[1]
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
def _ok(self):
|
||||||
|
body = b'{"content": "x", "tokens_predicted": 1}'
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
length = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
|
if length:
|
||||||
|
self.rfile.read(length)
|
||||||
|
self._ok()
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self._ok()
|
||||||
|
|
||||||
|
def log_message(self, *args):
|
||||||
|
pass # keep stdout clean for the classifier
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
mode = os.environ.get("FAKE_LLAMA_MODE", "cuda")
|
||||||
|
sys.stdout.write(LOGS.get(mode, LOGS["cuda"]))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
host = _arg("--host", "127.0.0.1")
|
||||||
|
port = int(_arg("--port", "8080"))
|
||||||
|
server = HTTPServer((host, port), _Handler)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
125
tests/studio/install/run_smoke_spoof.py
Normal file
125
tests/studio/install/run_smoke_spoof.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cross-platform GPU-offload spoof driver for CI (no GPU required).
|
||||||
|
|
||||||
|
Builds an OS-appropriate `llama-server` wrapper around fake_llama_server.py and
|
||||||
|
drives install_llama_prebuilt.py --smoke-test against it, asserting the exit
|
||||||
|
code contract the setup scripts depend on:
|
||||||
|
|
||||||
|
FAKE_LLAMA_MODE=cpu , GPU install_kind -> exit 2 (EXIT_FALLBACK) rejected
|
||||||
|
FAKE_LLAMA_MODE=cuda, GPU install_kind -> exit 0 (EXIT_SUCCESS) accepted
|
||||||
|
FAKE_LLAMA_MODE=cpu , CPU install_kind -> exit 0 not gated
|
||||||
|
|
||||||
|
Runs on windows-latest / macos-latest / ubuntu-latest. Exits non-zero on any
|
||||||
|
mismatch so the CI job fails loudly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
REPO = HERE.parents[2]
|
||||||
|
INSTALLER = REPO / "studio" / "install_llama_prebuilt.py"
|
||||||
|
FAKE = HERE / "fake_llama_server.py"
|
||||||
|
IS_WIN = sys.platform == "win32"
|
||||||
|
|
||||||
|
# Offload-required GPU kind for the rejection contract. install_kind is passed
|
||||||
|
# explicitly, so the classifier / validate_server logic is exercised
|
||||||
|
# independent of the (GPU-less) runner OS. macOS Metal is intentionally NOT
|
||||||
|
# offload-required, so use a CUDA kind even on macOS to drive the CUDA/ROCm
|
||||||
|
# rejection path; Metal's accept-CPU behavior is covered by the macOS-only case.
|
||||||
|
GPU_REQ_KIND = "windows-cuda" if IS_WIN else "linux-cuda"
|
||||||
|
CPU_KIND = {
|
||||||
|
"win32": "windows-cpu",
|
||||||
|
"darwin": "macos-cpu",
|
||||||
|
}.get(sys.platform, "linux-cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def make_wrapper(workdir: Path) -> Path:
|
||||||
|
if IS_WIN:
|
||||||
|
wrapper = workdir / "llama-server.bat"
|
||||||
|
wrapper.write_text(f'@"{sys.executable}" "{FAKE}" %*\r\n')
|
||||||
|
return wrapper
|
||||||
|
wrapper = workdir / "llama-server"
|
||||||
|
wrapper.write_text(f'#!/bin/sh\nexec "{sys.executable}" "{FAKE}" "$@"\n')
|
||||||
|
wrapper.chmod(wrapper.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def run_smoke(wrapper: Path, probe: Path, install_kind: str, mode: str) -> int:
|
||||||
|
env = dict(os.environ, FAKE_LLAMA_MODE = mode)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(INSTALLER),
|
||||||
|
"--smoke-test",
|
||||||
|
str(wrapper),
|
||||||
|
"--probe",
|
||||||
|
str(probe),
|
||||||
|
"--install-kind",
|
||||||
|
install_kind,
|
||||||
|
],
|
||||||
|
env = env,
|
||||||
|
capture_output = True,
|
||||||
|
text = True,
|
||||||
|
)
|
||||||
|
sys.stdout.write(proc.stdout)
|
||||||
|
sys.stderr.write(proc.stderr)
|
||||||
|
return proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
failures = []
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
work = Path(tmp)
|
||||||
|
wrapper = make_wrapper(work)
|
||||||
|
probe = work / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF\x00fake")
|
||||||
|
|
||||||
|
cases = [
|
||||||
|
("cpu", GPU_REQ_KIND, 2, "CPU-only binary tagged GPU is rejected"),
|
||||||
|
("offloaded_zero", GPU_REQ_KIND, 2, "offloaded 0/N tagged GPU is rejected"),
|
||||||
|
("cuda", GPU_REQ_KIND, 0, "GPU binary tagged GPU is accepted"),
|
||||||
|
("cuda_buffer", GPU_REQ_KIND, 0, "GPU buffer-format binary is accepted"),
|
||||||
|
("cpu", CPU_KIND, 0, "CPU binary tagged CPU is not gated"),
|
||||||
|
(
|
||||||
|
"no_signal",
|
||||||
|
GPU_REQ_KIND,
|
||||||
|
1,
|
||||||
|
"no-signal GPU log is inconclusive (exit 1)",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
# macOS Metal is not offload-required: a CPU-only Metal load is an
|
||||||
|
# unfixable environment limitation (headless / virtualized host), so
|
||||||
|
# the macos-arm64 prebuilt is accepted rather than rejected.
|
||||||
|
cases.append(
|
||||||
|
(
|
||||||
|
"cpu",
|
||||||
|
"macos-arm64",
|
||||||
|
0,
|
||||||
|
"macOS Metal CPU-only load is accepted (no rebuild remedy)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for mode, kind, expected, label in cases:
|
||||||
|
rc = run_smoke(wrapper, probe, kind, mode)
|
||||||
|
ok = rc == expected
|
||||||
|
print(
|
||||||
|
f"[{'PASS' if ok else 'FAIL'}] {label}: mode={mode} kind={kind} exit={rc} (want {expected})"
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
failures.append(label)
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"\n{len(failures)} spoof case(s) failed: {failures}")
|
||||||
|
return 1
|
||||||
|
print("\nAll GPU-offload spoof cases passed.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
176
tests/studio/install/test_gpu_offload_spoof.py
Normal file
176
tests/studio/install/test_gpu_offload_spoof.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""End-to-end GPU-offload spoof: run the real validate_server (real subprocess
|
||||||
|
+ real HTTP + real log classifier) against a fake llama-server, no GPU needed.
|
||||||
|
|
||||||
|
Unlike test_validate_server_gpu_offload.py (which mocks subprocess/urlopen),
|
||||||
|
this launches an actual process that "starts and serves HTTP 200" while its log
|
||||||
|
reports CPU-only or GPU offload, reproducing #5807 / #5830 end to end. POSIX
|
||||||
|
only: validate_server execs the binary path directly, which needs a shebang
|
||||||
|
wrapper; the Windows equivalent runs in the studio-gpu-offload-smoke workflow
|
||||||
|
via a .bat shim.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
pytest.skip(
|
||||||
|
"POSIX-only (Windows covered by the spoof CI workflow)", allow_module_level = True
|
||||||
|
)
|
||||||
|
|
||||||
|
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py"
|
||||||
|
FAKE_SERVER = Path(__file__).resolve().parent / "fake_llama_server.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"studio_install_llama_prebuilt_e2e", MODULE_PATH
|
||||||
|
)
|
||||||
|
M = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = M
|
||||||
|
SPEC.loader.exec_module(M)
|
||||||
|
|
||||||
|
HostInfo = M.HostInfo
|
||||||
|
|
||||||
|
|
||||||
|
def linux_cuda_host(**overrides):
|
||||||
|
defaults = dict(
|
||||||
|
system = "Linux",
|
||||||
|
machine = "x86_64",
|
||||||
|
is_windows = False,
|
||||||
|
is_linux = True,
|
||||||
|
is_macos = False,
|
||||||
|
is_x86_64 = True,
|
||||||
|
is_arm64 = False,
|
||||||
|
nvidia_smi = "/usr/bin/nvidia-smi",
|
||||||
|
driver_cuda_version = (13, 0),
|
||||||
|
compute_caps = ["120"],
|
||||||
|
visible_cuda_devices = None,
|
||||||
|
has_physical_nvidia = True,
|
||||||
|
has_usable_nvidia = True,
|
||||||
|
has_rocm = False,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return HostInfo(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_server_binary(tmp_path):
|
||||||
|
"""A `llama-server` that execs the fake server, so validate_server runs it
|
||||||
|
exactly as it would a real prebuilt binary."""
|
||||||
|
binary = tmp_path / "llama-server"
|
||||||
|
binary.write_text("#!/bin/sh\n" f'exec "{sys.executable}" "{FAKE_SERVER}" "$@"\n')
|
||||||
|
binary.chmod(binary.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
return binary
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(binary, tmp_path, host, install_kind, mode, monkeypatch):
|
||||||
|
monkeypatch.setenv("FAKE_LLAMA_MODE", mode)
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF\x00fake")
|
||||||
|
M.validate_server(binary, probe, host, tmp_path, install_kind = install_kind)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpu_only_binary_tagged_cuda_is_rejected(
|
||||||
|
fake_server_binary, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
# The #5807 case: a binary that serves 200 but loaded the model on CPU.
|
||||||
|
with pytest.raises(M.GpuOffloadFailure):
|
||||||
|
_validate(
|
||||||
|
fake_server_binary,
|
||||||
|
tmp_path,
|
||||||
|
linux_cuda_host(),
|
||||||
|
"linux-cuda",
|
||||||
|
"cpu",
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offloaded_zero_binary_tagged_cuda_is_rejected(
|
||||||
|
fake_server_binary, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
with pytest.raises(M.GpuOffloadFailure):
|
||||||
|
_validate(
|
||||||
|
fake_server_binary,
|
||||||
|
tmp_path,
|
||||||
|
linux_cuda_host(),
|
||||||
|
"linux-cuda",
|
||||||
|
"offloaded_zero",
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_binary_tagged_cuda_passes(fake_server_binary, tmp_path, monkeypatch):
|
||||||
|
_validate(
|
||||||
|
fake_server_binary,
|
||||||
|
tmp_path,
|
||||||
|
linux_cuda_host(),
|
||||||
|
"linux-cuda",
|
||||||
|
"cuda",
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_buffer_format_passes(fake_server_binary, tmp_path, monkeypatch):
|
||||||
|
_validate(
|
||||||
|
fake_server_binary,
|
||||||
|
tmp_path,
|
||||||
|
linux_cuda_host(),
|
||||||
|
"linux-cuda",
|
||||||
|
"cuda_buffer",
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpu_only_binary_tagged_cpu_is_accepted(
|
||||||
|
fake_server_binary, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
# A linux-cpu bundle is the intentional fallback; never GPU-gated.
|
||||||
|
_validate(
|
||||||
|
fake_server_binary, tmp_path, linux_cuda_host(), "linux-cpu", "cpu", monkeypatch
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_signal_binary_tagged_cuda_is_accepted(
|
||||||
|
fake_server_binary, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
# No offload evidence -> conservative: do not reject on no signal.
|
||||||
|
_validate(
|
||||||
|
fake_server_binary,
|
||||||
|
tmp_path,
|
||||||
|
linux_cuda_host(),
|
||||||
|
"linux-cuda",
|
||||||
|
"no_signal",
|
||||||
|
monkeypatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_test_cli_exit_codes(fake_server_binary, tmp_path, monkeypatch):
|
||||||
|
# The contract setup.sh / setup.ps1 depend on, exercised end to end through
|
||||||
|
# the real --smoke-test CLI: CPU-only -> 2, GPU -> 0.
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF\x00fake")
|
||||||
|
monkeypatch.setattr(M, "detect_host", lambda: linux_cuda_host())
|
||||||
|
|
||||||
|
def run(mode):
|
||||||
|
monkeypatch.setenv("FAKE_LLAMA_MODE", mode)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"install_llama_prebuilt.py",
|
||||||
|
"--smoke-test",
|
||||||
|
str(fake_server_binary),
|
||||||
|
"--probe",
|
||||||
|
str(probe),
|
||||||
|
"--install-kind",
|
||||||
|
"linux-cuda",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return M.main()
|
||||||
|
|
||||||
|
assert run("cpu") == M.EXIT_FALLBACK
|
||||||
|
assert run("cuda") == M.EXIT_SUCCESS
|
||||||
|
|
@ -78,6 +78,7 @@ windows_cuda_upstream_asset_names = (
|
||||||
)
|
)
|
||||||
env_int = INSTALL_LLAMA_PREBUILT.env_int
|
env_int = INSTALL_LLAMA_PREBUILT.env_int
|
||||||
direct_upstream_release_plan = INSTALL_LLAMA_PREBUILT.direct_upstream_release_plan
|
direct_upstream_release_plan = INSTALL_LLAMA_PREBUILT.direct_upstream_release_plan
|
||||||
|
direct_linux_release_plan = INSTALL_LLAMA_PREBUILT.direct_linux_release_plan
|
||||||
_pinned_windows_cuda_fallback = INSTALL_LLAMA_PREBUILT._pinned_windows_cuda_fallback
|
_pinned_windows_cuda_fallback = INSTALL_LLAMA_PREBUILT._pinned_windows_cuda_fallback
|
||||||
CudaRuntimePreference = INSTALL_LLAMA_PREBUILT.CudaRuntimePreference
|
CudaRuntimePreference = INSTALL_LLAMA_PREBUILT.CudaRuntimePreference
|
||||||
published_windows_cuda_attempts = INSTALL_LLAMA_PREBUILT.published_windows_cuda_attempts
|
published_windows_cuda_attempts = INSTALL_LLAMA_PREBUILT.published_windows_cuda_attempts
|
||||||
|
|
@ -2297,11 +2298,14 @@ class TestDirectUpstreamBlackwellPin:
|
||||||
self._release(), host, UPSTREAM_REPO, "latest"
|
self._release(), host, UPSTREAM_REPO, "latest"
|
||||||
)
|
)
|
||||||
order = [(a.tag, a.runtime_line or a.install_kind) for a in plan.attempts]
|
order = [(a.tag, a.runtime_line or a.install_kind) for a in plan.attempts]
|
||||||
|
# No windows-cpu tail: a GPU host must fall through to the source build
|
||||||
|
# when every CUDA bundle fails the offload check, not silently install
|
||||||
|
# the CPU bundle (#5807). The pin + cuda12 are the only attempts.
|
||||||
assert order == [
|
assert order == [
|
||||||
("b9360", "cuda13"),
|
("b9360", "cuda13"),
|
||||||
(self.TAG, "cuda12"),
|
(self.TAG, "cuda12"),
|
||||||
(self.TAG, "windows-cpu"),
|
|
||||||
]
|
]
|
||||||
|
assert all(a.install_kind != "windows-cpu" for a in plan.attempts)
|
||||||
assert plan.attempts[0].name == "llama-b9360-bin-win-cuda-13.1-x64.zip"
|
assert plan.attempts[0].name == "llama-b9360-bin-win-cuda-13.1-x64.zip"
|
||||||
# Direct/upstream path stays unverified-by-manifest (no approved hashes).
|
# Direct/upstream path stays unverified-by-manifest (no approved hashes).
|
||||||
assert plan.approved_checksums.artifacts == {}
|
assert plan.approved_checksums.artifacts == {}
|
||||||
|
|
@ -2324,6 +2328,126 @@ class TestDirectUpstreamBlackwellPin:
|
||||||
assert plan.attempts[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
|
assert plan.attempts[0].name == f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip"
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# N.1c2. simple-policy plans: a GPU host never gets a CPU bundle appended
|
||||||
|
# (#5807). All GPU bundles failing must raise PrebuiltFallback so setup builds
|
||||||
|
# from source for the native arch, not silently install a CPU-only "GPU" build.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestGpuHostNoSilentCpuFallback:
|
||||||
|
LTAG = "bTEST"
|
||||||
|
|
||||||
|
def _linux_release(self, *targets):
|
||||||
|
names = [f"app-{self.LTAG}-linux-x64-{t}.tar.gz" for t in targets]
|
||||||
|
return {
|
||||||
|
"tag_name": self.LTAG,
|
||||||
|
"assets": [
|
||||||
|
{"name": n, "browser_download_url": "https://x/" + n} for n in names
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _win_release(self):
|
||||||
|
names = [
|
||||||
|
"llama-b9999-bin-win-cuda-13.3-x64.zip",
|
||||||
|
"cudart-llama-bin-win-cuda-13.3-x64.zip",
|
||||||
|
"llama-b9999-bin-win-cpu-x64.zip",
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"tag_name": "b9999",
|
||||||
|
"assets": [
|
||||||
|
{"name": n, "browser_download_url": "https://x/" + n} for n in names
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _no_torch(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
INSTALL_LLAMA_PREBUILT,
|
||||||
|
"detect_torch_cuda_runtime_preference",
|
||||||
|
lambda host: CudaRuntimePreference(runtime_line = None, selection_log = []),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- Linux --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_linux_nvidia_host_has_no_cpu_attempt(self, monkeypatch):
|
||||||
|
mock_linux_runtime(monkeypatch, ["cuda13", "cuda12"])
|
||||||
|
self._no_torch(monkeypatch)
|
||||||
|
host = make_host(compute_caps = ["120"], driver_cuda_version = (13, 0))
|
||||||
|
plan = direct_linux_release_plan(
|
||||||
|
self._linux_release("cuda13-newer", "cpu"),
|
||||||
|
host,
|
||||||
|
"unslothai/llama.cpp",
|
||||||
|
"latest",
|
||||||
|
)
|
||||||
|
kinds = [a.install_kind for a in plan.attempts]
|
||||||
|
assert "linux-cuda" in kinds
|
||||||
|
assert "linux-cpu" not in kinds
|
||||||
|
|
||||||
|
def test_linux_nvidia_unmatched_arch_raises_no_cpu(self, monkeypatch):
|
||||||
|
# GPU present but no CUDA bundle covers the arch: must raise (-> source
|
||||||
|
# build), not fall to the CPU bundle.
|
||||||
|
mock_linux_runtime(monkeypatch, ["cuda13", "cuda12"])
|
||||||
|
self._no_torch(monkeypatch)
|
||||||
|
host = make_host(compute_caps = ["50"], driver_cuda_version = (13, 0))
|
||||||
|
with pytest.raises(PrebuiltFallback):
|
||||||
|
direct_linux_release_plan(
|
||||||
|
self._linux_release("cuda13-newer", "cpu"),
|
||||||
|
host,
|
||||||
|
"unslothai/llama.cpp",
|
||||||
|
"latest",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_linux_cpu_host_keeps_cpu_attempt(self, monkeypatch):
|
||||||
|
mock_linux_runtime(monkeypatch, [])
|
||||||
|
host = make_host(
|
||||||
|
has_usable_nvidia = False,
|
||||||
|
has_physical_nvidia = False,
|
||||||
|
nvidia_smi = None,
|
||||||
|
compute_caps = [],
|
||||||
|
)
|
||||||
|
plan = direct_linux_release_plan(
|
||||||
|
self._linux_release("cuda13-newer", "cpu"),
|
||||||
|
host,
|
||||||
|
"unslothai/llama.cpp",
|
||||||
|
"latest",
|
||||||
|
)
|
||||||
|
assert [a.install_kind for a in plan.attempts] == ["linux-cpu"]
|
||||||
|
|
||||||
|
# -- Windows ------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_windows_nvidia_host_has_no_cpu_attempt(self, monkeypatch):
|
||||||
|
mock_windows_runtime(monkeypatch, ["cuda13"])
|
||||||
|
self._no_torch(monkeypatch)
|
||||||
|
host = make_host(
|
||||||
|
system = "Windows",
|
||||||
|
machine = "AMD64",
|
||||||
|
driver_cuda_version = (13, 3),
|
||||||
|
compute_caps = ["120"],
|
||||||
|
)
|
||||||
|
plan = direct_upstream_release_plan(
|
||||||
|
self._win_release(), host, UPSTREAM_REPO, "latest"
|
||||||
|
)
|
||||||
|
kinds = [a.install_kind for a in plan.attempts]
|
||||||
|
assert "windows-cuda" in kinds
|
||||||
|
assert "windows-cpu" not in kinds
|
||||||
|
|
||||||
|
def test_windows_cpu_host_keeps_cpu_attempt(self, monkeypatch):
|
||||||
|
mock_windows_runtime(monkeypatch, [])
|
||||||
|
host = make_host(
|
||||||
|
system = "Windows",
|
||||||
|
machine = "AMD64",
|
||||||
|
has_usable_nvidia = False,
|
||||||
|
has_physical_nvidia = False,
|
||||||
|
nvidia_smi = None,
|
||||||
|
driver_cuda_version = None,
|
||||||
|
compute_caps = [],
|
||||||
|
)
|
||||||
|
plan = direct_upstream_release_plan(
|
||||||
|
self._win_release(), host, UPSTREAM_REPO, "latest"
|
||||||
|
)
|
||||||
|
assert [a.install_kind for a in plan.attempts] == ["windows-cpu"]
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# N.1d. published_windows_cuda_attempts -- version-dynamic ordering seed
|
# N.1d. published_windows_cuda_attempts -- version-dynamic ordering seed
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
|
||||||
648
tests/studio/install/test_validate_server_gpu_offload.py
Normal file
648
tests/studio/install/test_validate_server_gpu_offload.py
Normal file
|
|
@ -0,0 +1,648 @@
|
||||||
|
"""Tests for the GPU-offload validation added to install_llama_prebuilt.py.
|
||||||
|
|
||||||
|
Covers issue unslothai/unsloth#5807 (duplicates #5830 / #5106 / #5827): a
|
||||||
|
llama-server whose GPU backend fails to initialize still serves HTTP 200 from
|
||||||
|
CPU, so the old validate_server accepted it and Studio shipped a silently
|
||||||
|
CPU-only install. The hardened validate_server rejects such a binary when GPU
|
||||||
|
offload was requested, so the resolver / source build falls through to a
|
||||||
|
GPU-capable bundle instead of a silently CPU-only one.
|
||||||
|
|
||||||
|
Stdlib-only; the installer module is spec-loaded by absolute path (no
|
||||||
|
PYTHONPATH / heavy deps), matching test_install_llama_prebuilt_logic.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"studio_install_llama_prebuilt", MODULE_PATH
|
||||||
|
)
|
||||||
|
assert SPEC is not None and SPEC.loader is not None
|
||||||
|
M = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = M
|
||||||
|
SPEC.loader.exec_module(M)
|
||||||
|
|
||||||
|
server_log_shows_gpu_offload = M.server_log_shows_gpu_offload
|
||||||
|
resolve_smoke_test_install_kind = M.resolve_smoke_test_install_kind
|
||||||
|
smoke_test_server_binary = M.smoke_test_server_binary
|
||||||
|
validate_server = M.validate_server
|
||||||
|
PrebuiltFallback = M.PrebuiltFallback
|
||||||
|
HostInfo = M.HostInfo
|
||||||
|
|
||||||
|
|
||||||
|
# -- HostInfo factories ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def nvidia_host(**overrides) -> HostInfo:
|
||||||
|
defaults = dict(
|
||||||
|
system = "Linux",
|
||||||
|
machine = "x86_64",
|
||||||
|
is_windows = False,
|
||||||
|
is_linux = True,
|
||||||
|
is_macos = False,
|
||||||
|
is_x86_64 = True,
|
||||||
|
is_arm64 = False,
|
||||||
|
nvidia_smi = "/usr/bin/nvidia-smi",
|
||||||
|
driver_cuda_version = (13, 0),
|
||||||
|
compute_caps = ["120"],
|
||||||
|
visible_cuda_devices = None,
|
||||||
|
has_physical_nvidia = True,
|
||||||
|
has_usable_nvidia = True,
|
||||||
|
has_rocm = False,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return HostInfo(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def windows_nvidia_host(**overrides) -> HostInfo:
|
||||||
|
return nvidia_host(
|
||||||
|
system = "Windows",
|
||||||
|
is_windows = True,
|
||||||
|
is_linux = False,
|
||||||
|
nvidia_smi = "nvidia-smi",
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rocm_host(**overrides) -> HostInfo:
|
||||||
|
return nvidia_host(
|
||||||
|
nvidia_smi = None,
|
||||||
|
driver_cuda_version = None,
|
||||||
|
compute_caps = [],
|
||||||
|
has_physical_nvidia = False,
|
||||||
|
has_usable_nvidia = False,
|
||||||
|
has_rocm = True,
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def macos_arm_host(**overrides) -> HostInfo:
|
||||||
|
defaults = dict(
|
||||||
|
system = "Darwin",
|
||||||
|
machine = "arm64",
|
||||||
|
is_windows = False,
|
||||||
|
is_linux = False,
|
||||||
|
is_macos = True,
|
||||||
|
is_x86_64 = False,
|
||||||
|
is_arm64 = True,
|
||||||
|
nvidia_smi = None,
|
||||||
|
driver_cuda_version = None,
|
||||||
|
compute_caps = [],
|
||||||
|
visible_cuda_devices = None,
|
||||||
|
has_physical_nvidia = False,
|
||||||
|
has_usable_nvidia = False,
|
||||||
|
has_rocm = False,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return HostInfo(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def cpu_host(**overrides) -> HostInfo:
|
||||||
|
return nvidia_host(
|
||||||
|
nvidia_smi = None,
|
||||||
|
driver_cuda_version = None,
|
||||||
|
compute_caps = [],
|
||||||
|
has_physical_nvidia = False,
|
||||||
|
has_usable_nvidia = False,
|
||||||
|
has_rocm = False,
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Canned llama-server logs ------------------------------------------------
|
||||||
|
# Current llama.cpp (>= mid-2026) dropped the "model buffer size" lines and
|
||||||
|
# enumerates a "device_info:" block instead; older builds still print the
|
||||||
|
# buffer lines. Both formats must classify correctly.
|
||||||
|
|
||||||
|
CUDA_DEVICE_INFO_LOG = (
|
||||||
|
"0.00.667 I log_info: verbosity = 3\n"
|
||||||
|
"0.00.667 I device_info:\n"
|
||||||
|
"0.01.101 I - CUDA0 : NVIDIA GeForce RTX 5070 (12282 MiB, 11000 MiB free)\n"
|
||||||
|
"0.01.101 I - CPU : AMD Ryzen 7 9700X (32000 MiB free)\n"
|
||||||
|
"0.01.101 I system_info: n_threads = 8 | CUDA : ARCHS = 1200 | CPU : AVX2 = 1\n"
|
||||||
|
"0.01.174 I srv llama_server: model loaded\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
CPU_ONLY_DEVICE_INFO_LOG = (
|
||||||
|
"0.00.003 I log_info: verbosity = 3\n"
|
||||||
|
"0.00.003 I device_info:\n"
|
||||||
|
"0.00.003 I - CPU : AMD Ryzen 7 9700X (32000 MiB free)\n"
|
||||||
|
"0.00.003 I system_info: n_threads = 8 | CPU : AVX2 = 1\n"
|
||||||
|
"0.00.174 I srv llama_server: model loaded\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
CUDA_BUFFER_LOG = (
|
||||||
|
"load_tensors: offloaded 33/33 layers to GPU\n"
|
||||||
|
"load_tensors: CUDA0 model buffer size = 21000.0 MiB\n"
|
||||||
|
"load_tensors: CPU_Mapped model buffer size = 0.6 MiB\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
CPU_BUFFER_LOG = (
|
||||||
|
"load_tensors: CPU_Mapped model buffer size = 21000.0 MiB\n"
|
||||||
|
"load_tensors: CPU model buffer size = 0.6 MiB\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
ROCM_BUFFER_LOG = "load_tensors: ROCm0 model buffer size = 21000.0 MiB\n"
|
||||||
|
METAL_BUFFER_LOG = "load_tensors: Metal model buffer size = 8000.0 MiB\n"
|
||||||
|
VULKAN_DEVICE_INFO_LOG = (
|
||||||
|
"device_info:\n - Vulkan0 : AMD Radeon (16000 MiB free)\n - CPU : x86 (free)\n"
|
||||||
|
)
|
||||||
|
OPENCL_DEVICE_INFO_LOG = (
|
||||||
|
"device_info:\n - OpenCL0 : Adreno (4000 MiB free)\n - CPU : arm (free)\n"
|
||||||
|
)
|
||||||
|
OFFLOADED_ZERO_LOG = "load_tensors: offloaded 0/33 layers to GPU\n"
|
||||||
|
NO_SIGNAL_LOG = "INFO [main] starting server\nload_tensors: file format = GGUF V3\n"
|
||||||
|
# system_info advertises a *compiled* backend; not an available device.
|
||||||
|
SYSTEM_INFO_ONLY_LOG = (
|
||||||
|
"system_info: n_threads = 8 | CUDA : ARCHS = 1200 | CPU : AVX2 = 1\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- 1. Pure classifier ------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"log_text,expected",
|
||||||
|
[
|
||||||
|
(CUDA_DEVICE_INFO_LOG, True),
|
||||||
|
(CPU_ONLY_DEVICE_INFO_LOG, False),
|
||||||
|
(CUDA_BUFFER_LOG, True),
|
||||||
|
(CPU_BUFFER_LOG, False),
|
||||||
|
(ROCM_BUFFER_LOG, True),
|
||||||
|
(METAL_BUFFER_LOG, True),
|
||||||
|
(VULKAN_DEVICE_INFO_LOG, True),
|
||||||
|
(OPENCL_DEVICE_INFO_LOG, True),
|
||||||
|
(OFFLOADED_ZERO_LOG, False),
|
||||||
|
(NO_SIGNAL_LOG, None),
|
||||||
|
("", None),
|
||||||
|
(SYSTEM_INFO_ONLY_LOG, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_server_log_shows_gpu_offload(log_text, expected):
|
||||||
|
assert server_log_shows_gpu_offload(log_text) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_opencl_device_in_prefixes():
|
||||||
|
# Regression: OpenCL was matched by the device-row regex but missing from
|
||||||
|
# the GPU prefix list, so an OpenCL-only device_info wrongly classified as
|
||||||
|
# CPU-only and a working OpenCL build was rejected.
|
||||||
|
assert "opencl" in M._GPU_DEVICE_PREFIXES
|
||||||
|
assert server_log_shows_gpu_offload(OPENCL_DEVICE_INFO_LOG) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_info_not_mistaken_for_device():
|
||||||
|
# A CUDA-compiled binary whose runtime failed still prints the system_info
|
||||||
|
# CUDA backend, but enumerates only CPU under device_info -> must be False.
|
||||||
|
log = (
|
||||||
|
"device_info:\n - CPU : x86 (free)\n"
|
||||||
|
"system_info: | CUDA : ARCHS = 1200 | CPU : AVX2 = 1\n"
|
||||||
|
)
|
||||||
|
assert server_log_shows_gpu_offload(log) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal2_offloaded_zero_beats_device_info():
|
||||||
|
# offloaded 0/33 (signal 2) is a definitive "nothing on GPU" and takes
|
||||||
|
# priority over a device_info CUDA0 row -> False.
|
||||||
|
log = (
|
||||||
|
"load_tensors: offloaded 0/33 layers to GPU\n"
|
||||||
|
"device_info:\n - CUDA0 : NVIDIA (free)\n - CPU : x (free)\n"
|
||||||
|
)
|
||||||
|
assert server_log_shows_gpu_offload(log) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_kv_buffer_on_gpu_with_cpu_model_is_not_offload():
|
||||||
|
# A GPU KV/compute buffer while the model weights sit on CPU is still CPU
|
||||||
|
# inference; only a GPU *model* buffer counts as offload.
|
||||||
|
log = (
|
||||||
|
"load_tensors: CUDA0 KV buffer size = 100.0 MiB\n"
|
||||||
|
"load_tensors: CPU_Mapped model buffer size = 0.6 MiB\n"
|
||||||
|
)
|
||||||
|
assert server_log_shows_gpu_offload(log) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_offloading_zero_repeating_does_not_mask_zero_total():
|
||||||
|
# Real CPU-only shape: a planning line says "offloading 0 repeating layers"
|
||||||
|
# before the definitive "offloaded 0/33". The count must decide -> False.
|
||||||
|
log = (
|
||||||
|
"llm_load_tensors: offloading 0 repeating layers to GPU\n"
|
||||||
|
"llm_load_tensors: offloaded 0/33 layers to GPU\n"
|
||||||
|
"llm_load_tensors: CPU model buffer size = 7338.64 MiB\n"
|
||||||
|
)
|
||||||
|
assert server_log_shows_gpu_offload(log) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_uncounted_offloading_output_layer_is_gpu():
|
||||||
|
# The uncounted "offloading output layer to GPU" phrasing (no number, no
|
||||||
|
# later zero count) is a weak positive.
|
||||||
|
log = "llm_load_tensors: offloading output layer to GPU\n"
|
||||||
|
assert server_log_shows_gpu_offload(log) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hip_musa_cann_model_buffer_is_gpu():
|
||||||
|
for marker in ("HIP0", "MUSA0", "CANN0"):
|
||||||
|
log = f"load_tensors: {marker} model buffer size = 21000.0 MiB\n"
|
||||||
|
assert server_log_shows_gpu_offload(log) is True, marker
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_row_case_insensitive():
|
||||||
|
log = "device_info:\n - cuda0 : some gpu (free)\n - CPU : x (free)\n"
|
||||||
|
assert server_log_shows_gpu_offload(log) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_cuda_host_buffer_is_not_gpu_offload():
|
||||||
|
# CUDA_Host is host-pinned CPU RAM, not device memory. A binary that pins
|
||||||
|
# host memory but loads weights on CPU must not pass as GPU offload.
|
||||||
|
log = (
|
||||||
|
"load_tensors: CUDA_Host model buffer size = 21000.0 MiB\n"
|
||||||
|
"load_tensors: CPU_Mapped model buffer size = 0.6 MiB\n"
|
||||||
|
)
|
||||||
|
assert server_log_shows_gpu_offload(log) is False
|
||||||
|
# A real device buffer alongside a CUDA_Host line still reads as GPU.
|
||||||
|
log_ok = (
|
||||||
|
"load_tensors: CUDA_Host model buffer size = 100.0 MiB\n"
|
||||||
|
"load_tensors: CUDA0 model buffer size = 21000.0 MiB\n"
|
||||||
|
)
|
||||||
|
assert server_log_shows_gpu_offload(log_ok) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_draft_offloaded_zero_before_main_offload_is_gpu():
|
||||||
|
# Speculative decoding: a draft model logs "offloaded 0/2" before the main
|
||||||
|
# model's "offloaded 33/33". The N>0 line must win.
|
||||||
|
log = (
|
||||||
|
"load_tensors: offloaded 0/2 layers to GPU\n"
|
||||||
|
"load_tensors: offloaded 33/33 layers to GPU\n"
|
||||||
|
)
|
||||||
|
assert server_log_shows_gpu_offload(log) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_crlf_log_parses_identically():
|
||||||
|
# Windows logs use CRLF; classification must not change.
|
||||||
|
assert (
|
||||||
|
server_log_shows_gpu_offload(CUDA_DEVICE_INFO_LOG.replace("\n", "\r\n")) is True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
server_log_shows_gpu_offload(CPU_ONLY_DEVICE_INFO_LOG.replace("\n", "\r\n"))
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- 2. resolve_smoke_test_install_kind --------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"host,expected",
|
||||||
|
[
|
||||||
|
(nvidia_host(), "linux-cuda"),
|
||||||
|
(windows_nvidia_host(), "windows-cuda"),
|
||||||
|
(rocm_host(), "linux-rocm"),
|
||||||
|
(rocm_host(system = "Windows", is_windows = True, is_linux = False), "windows-hip"),
|
||||||
|
(macos_arm_host(), "macos-arm64"),
|
||||||
|
(cpu_host(), "linux-cpu"),
|
||||||
|
(cpu_host(system = "Windows", is_windows = True, is_linux = False), "windows-cpu"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolve_smoke_test_install_kind(host, expected):
|
||||||
|
assert resolve_smoke_test_install_kind(host) == expected
|
||||||
|
if expected.endswith("-cpu"):
|
||||||
|
assert expected not in M._GPU_INSTALL_KINDS
|
||||||
|
else:
|
||||||
|
assert expected in M._GPU_INSTALL_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
# -- 3. validate_server integration (mocked subprocess + HTTP) ---------------
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, status: int, body: bytes = b"{}"):
|
||||||
|
self.status = status
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProc:
|
||||||
|
"""Stands in for the llama-server subprocess: writes a canned startup log
|
||||||
|
to the handle it was given, then reports itself alive until terminated."""
|
||||||
|
|
||||||
|
def __init__(self, log_text: str):
|
||||||
|
self._log_text = log_text
|
||||||
|
self._alive = True
|
||||||
|
|
||||||
|
def __call__(self, command, *args, **kwargs):
|
||||||
|
handle = kwargs.get("stdout")
|
||||||
|
if handle is not None:
|
||||||
|
handle.write(self._log_text)
|
||||||
|
handle.flush()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return None if self._alive else 0
|
||||||
|
|
||||||
|
def wait(self, timeout = None):
|
||||||
|
self._alive = False
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self._alive = False
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
self._alive = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def patched_server(monkeypatch):
|
||||||
|
"""Patch validate_server's external touchpoints; return a configure()
|
||||||
|
helper so each test picks the canned log it wants."""
|
||||||
|
monkeypatch.setattr(M, "free_local_port", lambda: 18000)
|
||||||
|
monkeypatch.setattr(M, "binary_env", lambda *a, **k: {})
|
||||||
|
monkeypatch.setattr(M.time, "sleep", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(M.urllib.request, "urlopen", lambda *a, **k: _FakeResponse(200))
|
||||||
|
|
||||||
|
def configure(log_text):
|
||||||
|
monkeypatch.setattr(M.subprocess, "Popen", _FakeProc(log_text))
|
||||||
|
|
||||||
|
return configure
|
||||||
|
|
||||||
|
|
||||||
|
def _run_validate(tmp_path, host, install_kind):
|
||||||
|
server = tmp_path / "llama-server"
|
||||||
|
server.write_text("#!/bin/sh\n")
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF")
|
||||||
|
validate_server(server, probe, host, tmp_path, install_kind = install_kind)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_intent_cpu_only_rejected(patched_server, tmp_path):
|
||||||
|
# The #5807 bug: GPU requested, binary ran on CPU -> reject. The exception
|
||||||
|
# must be GpuOffloadFailure specifically (not just the PrebuiltFallback
|
||||||
|
# base) so the --smoke-test CLI can map it to EXIT_FALLBACK vs EXIT_ERROR.
|
||||||
|
patched_server(CPU_ONLY_DEVICE_INFO_LOG)
|
||||||
|
with pytest.raises(M.GpuOffloadFailure, match = "entirely on CPU"):
|
||||||
|
_run_validate(tmp_path, windows_nvidia_host(), "windows-cuda")
|
||||||
|
assert issubclass(M.GpuOffloadFailure, PrebuiltFallback)
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_intent_gpu_offload_accepted(patched_server, tmp_path):
|
||||||
|
patched_server(CUDA_DEVICE_INFO_LOG)
|
||||||
|
_run_validate(tmp_path, windows_nvidia_host(), "windows-cuda") # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_cpu_kind_not_gpu_gated(patched_server, tmp_path):
|
||||||
|
# A windows-cpu bundle is the intentional fallback; never GPU-gated even
|
||||||
|
# if it (obviously) loads on CPU.
|
||||||
|
patched_server(CPU_ONLY_DEVICE_INFO_LOG)
|
||||||
|
_run_validate(tmp_path, windows_nvidia_host(), "windows-cpu") # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_intent_no_signal_accepted(patched_server, tmp_path):
|
||||||
|
# No buffer/device signal -> conservative: do not reject on no evidence
|
||||||
|
# (plain install validation; require_gpu_signal defaults False).
|
||||||
|
patched_server(NO_SIGNAL_LOG)
|
||||||
|
_run_validate(tmp_path, nvidia_host(), "linux-cuda") # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_test_no_signal_gpu_is_inconclusive(patched_server, tmp_path):
|
||||||
|
# The smoke-test CLI sets require_gpu_signal, so a no-signal GPU log is
|
||||||
|
# inconclusive (PrebuiltFallback -> EXIT_ERROR), not a silent pass.
|
||||||
|
patched_server(NO_SIGNAL_LOG)
|
||||||
|
server = tmp_path / "llama-server"
|
||||||
|
server.write_text("#!/bin/sh\n")
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF")
|
||||||
|
with pytest.raises(PrebuiltFallback):
|
||||||
|
smoke_test_server_binary(
|
||||||
|
str(server), nvidia_host(), install_dir = str(tmp_path), probe = str(probe)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rocm_cpu_only_rejected(patched_server, tmp_path):
|
||||||
|
patched_server(CPU_ONLY_DEVICE_INFO_LOG)
|
||||||
|
with pytest.raises(PrebuiltFallback):
|
||||||
|
_run_validate(tmp_path, rocm_host(), "linux-rocm")
|
||||||
|
|
||||||
|
|
||||||
|
def test_macos_metal_not_in_offload_required_kinds():
|
||||||
|
# macos-arm64 ships a GPU backend (Metal) and is launched with
|
||||||
|
# --n-gpu-layers, but a CPU-only Metal load is an unfixable environment
|
||||||
|
# limitation, so it must NOT be in the offload-required (reject) set.
|
||||||
|
assert "macos-arm64" in M._GPU_INSTALL_KINDS
|
||||||
|
assert "macos-arm64" not in M._GPU_OFFLOAD_REQUIRED_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def test_macos_metal_cpu_only_not_rejected(patched_server, tmp_path):
|
||||||
|
# The macOS regression in #5858 CI: GitHub macOS runners have no usable
|
||||||
|
# Metal, so the macos-arm64 prebuilt loads on CPU. It must be accepted, not
|
||||||
|
# rejected into a source build that also runs on CPU and breaks the install.
|
||||||
|
patched_server(CPU_ONLY_DEVICE_INFO_LOG)
|
||||||
|
_run_validate(tmp_path, macos_arm_host(), "macos-arm64") # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_test_macos_metal_cpu_only_passes(patched_server, tmp_path):
|
||||||
|
# The smoke-test CLI must also accept a CPU-only macOS Metal load (exit 0),
|
||||||
|
# so setup.sh does not pointlessly retry a CPU source build on a Mac.
|
||||||
|
patched_server(CPU_ONLY_DEVICE_INFO_LOG)
|
||||||
|
server = tmp_path / "llama-server"
|
||||||
|
server.write_text("#!/bin/sh\n")
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF")
|
||||||
|
# No raise -> the CLI maps this to EXIT_SUCCESS.
|
||||||
|
smoke_test_server_binary(
|
||||||
|
str(server),
|
||||||
|
macos_arm_host(),
|
||||||
|
install_dir = str(tmp_path),
|
||||||
|
probe = str(probe),
|
||||||
|
install_kind = "macos-arm64",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- 4. smoke_test_server_binary ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_test_missing_binary(tmp_path):
|
||||||
|
with pytest.raises(PrebuiltFallback, match = "not found"):
|
||||||
|
smoke_test_server_binary(
|
||||||
|
str(tmp_path / "nope"), nvidia_host(), install_dir = str(tmp_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_test_delegates_to_validate_server(monkeypatch, tmp_path):
|
||||||
|
server = tmp_path / "llama-server"
|
||||||
|
server.write_text("#!/bin/sh\n")
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF")
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def fake_validate(server_path, probe_path, host, install_dir, **kw):
|
||||||
|
seen["install_kind"] = kw.get("install_kind")
|
||||||
|
|
||||||
|
monkeypatch.setattr(M, "validate_server", fake_validate)
|
||||||
|
kind = smoke_test_server_binary(
|
||||||
|
str(server), nvidia_host(), install_dir = str(tmp_path), probe = str(probe)
|
||||||
|
)
|
||||||
|
assert kind == "linux-cuda"
|
||||||
|
assert seen["install_kind"] == "linux-cuda"
|
||||||
|
|
||||||
|
|
||||||
|
def test_smoke_test_propagates_failure(monkeypatch, tmp_path):
|
||||||
|
server = tmp_path / "llama-server"
|
||||||
|
server.write_text("#!/bin/sh\n")
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF")
|
||||||
|
|
||||||
|
def fake_validate(*a, **k):
|
||||||
|
raise PrebuiltFallback("loaded the model entirely on CPU")
|
||||||
|
|
||||||
|
monkeypatch.setattr(M, "validate_server", fake_validate)
|
||||||
|
with pytest.raises(PrebuiltFallback):
|
||||||
|
smoke_test_server_binary(
|
||||||
|
str(server),
|
||||||
|
nvidia_host(),
|
||||||
|
install_dir = str(tmp_path),
|
||||||
|
probe = str(probe),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- 5. --smoke-test CLI exit-code contract (the load-bearing fix contract) --
|
||||||
|
# setup.sh / setup.ps1 branch on: 2 = ran on CPU (rebuild CPU), 1 = inconclusive
|
||||||
|
# (keep GPU build), 0 = offload confirmed.
|
||||||
|
|
||||||
|
|
||||||
|
def _run_main_smoke(monkeypatch, smoke_impl):
|
||||||
|
monkeypatch.setattr(M, "detect_host", lambda: nvidia_host())
|
||||||
|
monkeypatch.setattr(M, "smoke_test_server_binary", smoke_impl)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys, "argv", ["install_llama_prebuilt.py", "--smoke-test", "/x/llama-server"]
|
||||||
|
)
|
||||||
|
return M.main()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_smoke_exit_success(monkeypatch):
|
||||||
|
rc = _run_main_smoke(monkeypatch, lambda *a, **k: "linux-cuda")
|
||||||
|
assert rc == M.EXIT_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_smoke_exit_fallback_on_cpu_only(monkeypatch):
|
||||||
|
def impl(*a, **k):
|
||||||
|
raise M.GpuOffloadFailure("loaded the model entirely on CPU")
|
||||||
|
|
||||||
|
rc = _run_main_smoke(monkeypatch, impl)
|
||||||
|
assert rc == M.EXIT_FALLBACK # 2 -> setup scripts rebuild CPU
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_smoke_exit_error_on_inconclusive(monkeypatch):
|
||||||
|
def impl(*a, **k):
|
||||||
|
raise PrebuiltFallback("llama-server exited during startup")
|
||||||
|
|
||||||
|
rc = _run_main_smoke(monkeypatch, impl)
|
||||||
|
assert rc == M.EXIT_ERROR # 1 -> setup scripts keep the GPU build
|
||||||
|
|
||||||
|
|
||||||
|
# -- 6. existing_gpu_install_offloads: re-validate a matching install (#5807) --
|
||||||
|
|
||||||
|
|
||||||
|
def _gpu_plan(install_kind = "linux-cuda"):
|
||||||
|
choice = M.AssetChoice(
|
||||||
|
repo = "unslothai/llama.cpp",
|
||||||
|
tag = "b9001",
|
||||||
|
name = "app-b9001-linux-x64-cuda13-newer.tar.gz",
|
||||||
|
url = "https://example.com/x",
|
||||||
|
source_label = "published",
|
||||||
|
install_kind = install_kind,
|
||||||
|
)
|
||||||
|
return M.InstallReleasePlan(
|
||||||
|
requested_tag = "latest",
|
||||||
|
llama_tag = "b9001",
|
||||||
|
release_tag = "rel",
|
||||||
|
attempts = [choice],
|
||||||
|
approved_checksums = M.ApprovedReleaseChecksums(
|
||||||
|
repo = "unslothai/llama.cpp",
|
||||||
|
release_tag = "rel",
|
||||||
|
upstream_tag = "b9001",
|
||||||
|
artifacts = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _with_server(tmp_path):
|
||||||
|
server = tmp_path / "llama-server"
|
||||||
|
server.write_text("#!/bin/sh\n")
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF")
|
||||||
|
return probe
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_cpu_kind_install_is_kept(tmp_path):
|
||||||
|
# A non-GPU existing install is never offload-gated.
|
||||||
|
probe = _with_server(tmp_path)
|
||||||
|
assert (
|
||||||
|
M.existing_gpu_install_offloads(
|
||||||
|
tmp_path, nvidia_host(), _gpu_plan("linux-cpu"), probe
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_gpu_install_cpu_only_triggers_reinstall(monkeypatch, tmp_path):
|
||||||
|
probe = _with_server(tmp_path)
|
||||||
|
|
||||||
|
def fake_validate(*a, **k):
|
||||||
|
raise M.GpuOffloadFailure("loaded the model entirely on CPU")
|
||||||
|
|
||||||
|
monkeypatch.setattr(M, "validate_server", fake_validate)
|
||||||
|
assert (
|
||||||
|
M.existing_gpu_install_offloads(
|
||||||
|
tmp_path, nvidia_host(), _gpu_plan("linux-cuda"), probe
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_gpu_install_offloading_is_kept(monkeypatch, tmp_path):
|
||||||
|
probe = _with_server(tmp_path)
|
||||||
|
monkeypatch.setattr(M, "validate_server", lambda *a, **k: None)
|
||||||
|
assert (
|
||||||
|
M.existing_gpu_install_offloads(
|
||||||
|
tmp_path, nvidia_host(), _gpu_plan("linux-cuda"), probe
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_gpu_install_inconclusive_is_kept(monkeypatch, tmp_path):
|
||||||
|
probe = _with_server(tmp_path)
|
||||||
|
|
||||||
|
def fake_validate(*a, **k):
|
||||||
|
raise PrebuiltFallback("llama-server exited during startup")
|
||||||
|
|
||||||
|
monkeypatch.setattr(M, "validate_server", fake_validate)
|
||||||
|
assert (
|
||||||
|
M.existing_gpu_install_offloads(
|
||||||
|
tmp_path, nvidia_host(), _gpu_plan("linux-cuda"), probe
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_gpu_install_no_binary_is_kept(tmp_path):
|
||||||
|
# No llama-server present -> let the normal flow reinstall, don't crash.
|
||||||
|
probe = tmp_path / "probe.gguf"
|
||||||
|
probe.write_bytes(b"GGUF")
|
||||||
|
assert (
|
||||||
|
M.existing_gpu_install_offloads(
|
||||||
|
tmp_path, nvidia_host(), _gpu_plan("linux-cuda"), probe
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue