Studio: refine GGUF per-GPU selection (gpu_ids) (#7239)
--------- Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
This commit is contained in:
parent
629cc50f1a
commit
a7761e1740
12 changed files with 771 additions and 216 deletions
|
|
@ -2023,6 +2023,10 @@ class LlamaCppBackend:
|
||||||
self._tensor_split: Optional[List[float]] = None
|
self._tensor_split: Optional[List[float]] = None
|
||||||
# User-picked physical GPU indices (None = automatic selection).
|
# User-picked physical GPU indices (None = automatic selection).
|
||||||
self._gpu_ids: Optional[List[int]] = None
|
self._gpu_ids: Optional[List[int]] = None
|
||||||
|
# RAW requested GPU pin, before the fit narrowed it. self._gpu_ids records the
|
||||||
|
# EFFECTIVE (fit-narrowed) pin for /status; dedupe compares this raw value so a
|
||||||
|
# [0, 1] narrowed to [0] and re-sent as [0, 1] still matches (#7239).
|
||||||
|
self._requested_gpu_ids: Optional[List[int]] = None
|
||||||
# Layer load kept multi-GPU only to honor a downgraded tensor request, so a
|
# Layer load kept multi-GPU only to honor a downgraded tensor request, so a
|
||||||
# later explicit tensor-off reloads instead of deduping to it (#6659).
|
# later explicit tensor-off reloads instead of deduping to it (#6659).
|
||||||
self._layer_preserves_tensor_intent: bool = False
|
self._layer_preserves_tensor_intent: bool = False
|
||||||
|
|
@ -2494,6 +2498,46 @@ class LlamaCppBackend:
|
||||||
"""User-picked physical GPU indices, or None for automatic selection."""
|
"""User-picked physical GPU indices, or None for automatic selection."""
|
||||||
return self._gpu_ids
|
return self._gpu_ids
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requested_gpu_ids(self) -> Optional[List[int]]:
|
||||||
|
"""RAW requested GPU pin (before the fit narrowed it), or None for auto.
|
||||||
|
gpu_ids echoes the EFFECTIVE pin for /status."""
|
||||||
|
return self._requested_gpu_ids
|
||||||
|
|
||||||
|
def matches_gpu_ids(self, gpu_ids: Optional[List[int]]) -> bool:
|
||||||
|
"""Whether a requested pin is already satisfied by the active runner.
|
||||||
|
|
||||||
|
A regular GGUF load may narrow the requested placement pool to the
|
||||||
|
smallest fitting subset. Accept both the original request and the
|
||||||
|
effective status-echoed subset so either can round-trip without a
|
||||||
|
needless reload. Diffusion drives one device and keeps its existing
|
||||||
|
lowest-device normalization.
|
||||||
|
"""
|
||||||
|
if self._is_diffusion:
|
||||||
|
requested = [sorted(int(x) for x in gpu_ids)[0]] if gpu_ids else None
|
||||||
|
return requested == (self._gpu_ids or None)
|
||||||
|
|
||||||
|
requested = sorted(int(x) for x in gpu_ids) if gpu_ids else None
|
||||||
|
raw = self._requested_gpu_ids or None
|
||||||
|
effective = self._gpu_ids or None
|
||||||
|
return requested == raw or requested == effective
|
||||||
|
|
||||||
|
def _record_matching_gpu_request(self, gpu_ids: Optional[List[int]]) -> None:
|
||||||
|
"""Adopt the caller's explicit pool after a full already-loaded match.
|
||||||
|
|
||||||
|
Matching an effective subset avoids a reload, but the incoming request
|
||||||
|
is still the user's latest placement intent. Record it so status and a
|
||||||
|
later reload do not restore GPUs the user just removed.
|
||||||
|
"""
|
||||||
|
if self._is_diffusion:
|
||||||
|
self._requested_gpu_ids = [sorted(int(x) for x in gpu_ids)[0]] if gpu_ids else None
|
||||||
|
else:
|
||||||
|
self._requested_gpu_ids = sorted(int(x) for x in gpu_ids) if gpu_ids else None
|
||||||
|
if self._last_load_kwargs is not None:
|
||||||
|
self._last_load_kwargs["gpu_ids"] = (
|
||||||
|
list(self._requested_gpu_ids) if self._requested_gpu_ids else None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def n_layers(self) -> Optional[int]:
|
def n_layers(self) -> Optional[int]:
|
||||||
"""Model layer count (GGUF block_count), or None if unknown."""
|
"""Model layer count (GGUF block_count), or None if unknown."""
|
||||||
|
|
@ -4581,6 +4625,14 @@ class LlamaCppBackend:
|
||||||
LlamaCppBackend._gguf_skip_value(f, atype)
|
LlamaCppBackend._gguf_skip_value(f, atype)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _gguf_path_is_diffusion(cls, gguf_path: str, model_identifier: str) -> bool:
|
||||||
|
"""Classify a downloaded GGUF without mutating the active backend."""
|
||||||
|
probe = object.__new__(cls)
|
||||||
|
probe._model_identifier = model_identifier
|
||||||
|
probe._read_gguf_metadata(gguf_path)
|
||||||
|
return probe._is_diffusion
|
||||||
|
|
||||||
def _read_gguf_metadata(self, gguf_path: str) -> None:
|
def _read_gguf_metadata(self, gguf_path: str) -> None:
|
||||||
"""Read context_length, architecture params, and chat_template from a GGUF header.
|
"""Read context_length, architecture params, and chat_template from a GGUF header.
|
||||||
|
|
||||||
|
|
@ -5032,11 +5084,14 @@ class LlamaCppBackend:
|
||||||
# the unload reset) so /status doesn't misreport TP and an identical
|
# the unload reset) so /status doesn't misreport TP and an identical
|
||||||
# re-Apply doesn't reload against stale tensor-parallel state.
|
# re-Apply doesn't reload against stale tensor-parallel state.
|
||||||
self._tensor_parallel = False
|
self._tensor_parallel = False
|
||||||
# Record only the single device the runner actually uses (the lowest
|
# The single-device runner records only the lowest selected GPU (chosen
|
||||||
# selected GPU, chosen above) -- not the whole pick. The diffusion runner
|
# above), not the whole pick, and clears any explicit pin from a prior
|
||||||
# is single-device, so echoing a multi-GPU list would misreport placement
|
# chat load; a multi-GPU list would misreport placement and mis-dedup.
|
||||||
# in /status and let a re-Apply dedup against GPUs the runner never used.
|
|
||||||
self._gpu_ids = [sorted(gpu_ids)[0]] if gpu_ids else None
|
self._gpu_ids = [sorted(gpu_ids)[0]] if gpu_ids else None
|
||||||
|
# The frontend prefers requested_gpu_ids when hydrating the picker.
|
||||||
|
# Diffusion uses only one device, so echo the collapsed effective pin,
|
||||||
|
# not unused members of the original request.
|
||||||
|
self._requested_gpu_ids = list(self._gpu_ids) if self._gpu_ids else None
|
||||||
if hf_variant:
|
if hf_variant:
|
||||||
self._hf_variant = hf_variant
|
self._hf_variant = hf_variant
|
||||||
elif gguf_path:
|
elif gguf_path:
|
||||||
|
|
@ -6161,6 +6216,8 @@ class LlamaCppBackend:
|
||||||
gpu_layers: int = -1,
|
gpu_layers: int = -1,
|
||||||
n_cpu_moe: int = 0,
|
n_cpu_moe: int = 0,
|
||||||
tensor_split: Optional[List[float]] = None,
|
tensor_split: Optional[List[float]] = None,
|
||||||
|
# Explicit GPU placement pool (issue #7164). None/[] = auto-select;
|
||||||
|
# the fitter may pin the smallest subset of this pool that fits.
|
||||||
gpu_ids: Optional[List[int]] = None,
|
gpu_ids: Optional[List[int]] = None,
|
||||||
n_threads: Optional[int] = None,
|
n_threads: Optional[int] = None,
|
||||||
n_gpu_layers: Optional[int] = None, # caller compat, unused
|
n_gpu_layers: Optional[int] = None, # caller compat, unused
|
||||||
|
|
@ -6258,15 +6315,63 @@ class LlamaCppBackend:
|
||||||
|
|
||||||
self._cancel_event.clear()
|
self._cancel_event.clear()
|
||||||
|
|
||||||
# ── Phase 1: kill old process (under lock, fast) ──────────
|
|
||||||
with self._lock:
|
|
||||||
self._kill_process()
|
|
||||||
|
|
||||||
# Resolve llama-server now but defer a not-found error: a block-diffusion
|
# Resolve llama-server now but defer a not-found error: a block-diffusion
|
||||||
# GGUF uses the diffusion runner, and its arch is only known after the header.
|
# GGUF uses the diffusion runner, and its arch is only known after the header.
|
||||||
binary = self._find_llama_server_binary()
|
binary = self._find_llama_server_binary()
|
||||||
is_vulkan_backend = self._is_vulkan_backend(binary)
|
is_vulkan_backend = self._is_vulkan_backend(binary)
|
||||||
|
|
||||||
|
# ── Vulkan-ordinal preflight (BEFORE the Phase 1 kill) ────────
|
||||||
|
# An explicit Vulkan pin the ggml probe never enumerated cannot be honored.
|
||||||
|
# Validate it ABOVE the kill so an invalid selection leaves the live model
|
||||||
|
# untouched: CUDA ids are range-checked at the route, but Vulkan ordinals are
|
||||||
|
# not, so a stale gpu_ids=[99] used to kill the server then 400, leaving
|
||||||
|
# nothing running (#7239). _get_gpu_memory needs only the binary (safe pre-
|
||||||
|
# download) and reuses the later fit's issubset logic. Guarded on a found
|
||||||
|
# Vulkan build + a pin so a deferred not-found stays deferred for diffusion.
|
||||||
|
if is_vulkan_backend and gpu_ids and binary:
|
||||||
|
_pf_wanted = {int(x) for x in gpu_ids}
|
||||||
|
_pf_probed = {g[0] for g in self._get_gpu_memory(binary)}
|
||||||
|
if not _pf_wanted.issubset(_pf_probed):
|
||||||
|
raise ValueError(
|
||||||
|
f"Requested Vulkan GPU ordinal(s) {sorted(_pf_wanted)} not "
|
||||||
|
f"present. Available Vulkan devices: {sorted(_pf_probed)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
# A remote uncached GGUF may only reveal that it needs the
|
||||||
|
# single-device diffusion runner after download. On Vulkan, an
|
||||||
|
# explicit gpu_ids request cannot be mapped from ggml ordinals to
|
||||||
|
# that runner's CUDA physical index. Download and classify the main
|
||||||
|
# file before killing the healthy server so this late rejection is
|
||||||
|
# non-destructive. The Phase 2 call below reuses this cached path.
|
||||||
|
_preflight_model_path = None
|
||||||
|
if is_vulkan_backend and gpu_ids and hf_repo:
|
||||||
|
_resolved_repo = _resolve_repo_id_casing(hf_repo)
|
||||||
|
if _resolved_repo != hf_repo:
|
||||||
|
logger.info(
|
||||||
|
"Using cached repo_id casing '%s' for requested '%s'",
|
||||||
|
_resolved_repo,
|
||||||
|
hf_repo,
|
||||||
|
)
|
||||||
|
hf_repo = _resolved_repo
|
||||||
|
with _hf_offline_if_dns_dead():
|
||||||
|
_preflight_model_path = self._download_gguf(
|
||||||
|
hf_repo = hf_repo,
|
||||||
|
hf_variant = hf_variant,
|
||||||
|
hf_token = hf_token,
|
||||||
|
)
|
||||||
|
if self._gguf_path_is_diffusion(_preflight_model_path, model_identifier):
|
||||||
|
raise ValueError(
|
||||||
|
"GPU selection (gpu_ids) is not supported for a DiffusionGemma "
|
||||||
|
"GGUF on a Vulkan llama.cpp build: the diffusion runner selects "
|
||||||
|
"its device by CUDA physical index, which has no defined mapping "
|
||||||
|
"to ggml Vulkan device ordinals. Omit gpu_ids to use the default "
|
||||||
|
"device."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Phase 1: kill old process (under lock, fast) ──────────
|
||||||
|
with self._lock:
|
||||||
|
self._kill_process()
|
||||||
|
|
||||||
# ── Phase 2: download (NO lock held, so cancel can proceed) ──
|
# ── Phase 2: download (NO lock held, so cancel can proceed) ──
|
||||||
# mtp_draft_path arrives set for local Gemma loads (detected
|
# mtp_draft_path arrives set for local Gemma loads (detected
|
||||||
# sibling); for -hf loads it's None here and resolved just below.
|
# sibling); for -hf loads it's None here and resolved just below.
|
||||||
|
|
@ -6288,7 +6393,7 @@ class LlamaCppBackend:
|
||||||
)
|
)
|
||||||
hf_repo = _resolved_repo
|
hf_repo = _resolved_repo
|
||||||
with _hf_offline_if_dns_dead():
|
with _hf_offline_if_dns_dead():
|
||||||
model_path = self._download_gguf(
|
model_path = _preflight_model_path or self._download_gguf(
|
||||||
hf_repo = hf_repo,
|
hf_repo = hf_repo,
|
||||||
hf_variant = hf_variant,
|
hf_variant = hf_variant,
|
||||||
hf_token = hf_token,
|
hf_token = hf_token,
|
||||||
|
|
@ -6338,6 +6443,18 @@ class LlamaCppBackend:
|
||||||
# Block-diffusion GGUFs (DiffusionGemma) cannot run on llama-server;
|
# Block-diffusion GGUFs (DiffusionGemma) cannot run on llama-server;
|
||||||
# serve them with the diffusion runner (same OpenAI-compat interface).
|
# serve them with the diffusion runner (same OpenAI-compat interface).
|
||||||
if self._is_diffusion:
|
if self._is_diffusion:
|
||||||
|
# The diffusion runner pins its child by CUDA visibility mask, so a
|
||||||
|
# ggml Vulkan ordinal cannot be honored (wrong GPU / CPU fallback).
|
||||||
|
# Route and remote-download preflights reject before teardown; keep
|
||||||
|
# this as a final defense if classification ever disagrees.
|
||||||
|
if is_vulkan_backend and gpu_ids:
|
||||||
|
raise ValueError(
|
||||||
|
"GPU selection (gpu_ids) is not supported for a DiffusionGemma "
|
||||||
|
"GGUF on a Vulkan llama.cpp build: the diffusion runner selects "
|
||||||
|
"its device by CUDA physical index, which has no defined mapping "
|
||||||
|
"to ggml Vulkan device ordinals. Omit gpu_ids to use the default "
|
||||||
|
"device."
|
||||||
|
)
|
||||||
# Not a tensor/layer GGUF: clear any preserved-fallback flag from a
|
# Not a tensor/layer GGUF: clear any preserved-fallback flag from a
|
||||||
# prior load (this path skips the command builder that clears it).
|
# prior load (this path skips the command builder that clears it).
|
||||||
self._layer_preserves_tensor_intent = False
|
self._layer_preserves_tensor_intent = False
|
||||||
|
|
@ -6558,6 +6675,12 @@ class LlamaCppBackend:
|
||||||
# Layer-fallback min GPUs; raised below on a tensor downgrade. Bound
|
# Layer-fallback min GPUs; raised below on a tensor downgrade. Bound
|
||||||
# before the try so the --fit-on except path still has it (no UnboundLocal).
|
# before the try so the --fit-on except path still has it (no UnboundLocal).
|
||||||
_layer_min_gpus = 1
|
_layer_min_gpus = 1
|
||||||
|
# An explicit Vulkan ordinal absent from the ggml probe cannot be
|
||||||
|
# honored; flag it in the fit and reject after the try (raising inside
|
||||||
|
# would be swallowed into the --fit-on fallback). Bound before the try.
|
||||||
|
_vulkan_explicit_unmatched = False
|
||||||
|
_vulkan_requested_ids: list[int] = []
|
||||||
|
_vulkan_available_ordinals: list[int] = []
|
||||||
try:
|
try:
|
||||||
gguf_size = self._get_gguf_size_bytes(model_path)
|
gguf_size = self._get_gguf_size_bytes(model_path)
|
||||||
# Include GPU-loaded mmproj in the fit budget (#5825).
|
# Include GPU-loaded mmproj in the fit budget (#5825).
|
||||||
|
|
@ -6570,6 +6693,28 @@ class LlamaCppBackend:
|
||||||
# Pass binary so a Vulkan build probes ggml's Vulkan ordinals.
|
# Pass binary so a Vulkan build probes ggml's Vulkan ordinals.
|
||||||
_gpu_mem = self._get_gpu_memory(binary)
|
_gpu_mem = self._get_gpu_memory(binary)
|
||||||
gpus = [(idx, free) for idx, free, _t in _gpu_mem]
|
gpus = [(idx, free) for idx, free, _t in _gpu_mem]
|
||||||
|
# Restrict the fit (and thus the layer plan + pin env) to the
|
||||||
|
# selected GPUs; fail-open if none match so a stale UI choice
|
||||||
|
# can't strand the load on CPU (issue #7164).
|
||||||
|
if gpu_ids:
|
||||||
|
# A Vulkan build indexes by ggml ordinal. An explicit ordinal
|
||||||
|
# absent from the probe can't be pinned, so reject after the try
|
||||||
|
# rather than fail-open onto a device the user didn't pick.
|
||||||
|
_wanted_ids = {int(x) for x in gpu_ids}
|
||||||
|
# Reject if ANY requested ordinal is absent, not only when none
|
||||||
|
# match: [0, 99] against {0, 1} silently drops 99. Comparing the
|
||||||
|
# full requested set (before filter narrows) still lets the fitter
|
||||||
|
# pick a valid subset later -- that is narrowing, not absence.
|
||||||
|
_probed_ordinals = {g[0] for g in gpus}
|
||||||
|
if is_vulkan_backend and not _wanted_ids.issubset(_probed_ordinals):
|
||||||
|
_vulkan_explicit_unmatched = True
|
||||||
|
_vulkan_requested_ids = sorted(_wanted_ids)
|
||||||
|
_vulkan_available_ordinals = sorted(_probed_ordinals)
|
||||||
|
# Restrict the probed pool to the selection; fail-open (keep the
|
||||||
|
# full pool) if none match so a stale UI choice can't strand the
|
||||||
|
# load on CPU (issue #7164).
|
||||||
|
_sel_gpus = [g for g in gpus if g[0] in _wanted_ids]
|
||||||
|
gpus = _sel_gpus if _sel_gpus else gpus
|
||||||
total_by_idx = {idx: total for idx, _f, total in _gpu_mem}
|
total_by_idx = {idx: total for idx, _f, total in _gpu_mem}
|
||||||
# GPU picker: restrict every mode to the chosen devices, so
|
# GPU picker: restrict every mode to the chosen devices, so
|
||||||
# auto selection only considers them and manual mask to
|
# auto selection only considers them and manual mask to
|
||||||
|
|
@ -7396,6 +7541,17 @@ class LlamaCppBackend:
|
||||||
tp_tensor_split = None
|
tp_tensor_split = None
|
||||||
effective_ctx = requested_ctx # fall back to original
|
effective_ctx = requested_ctx # fall back to original
|
||||||
|
|
||||||
|
# An unenumerated explicit Vulkan ordinal can't be pinned; fail loudly
|
||||||
|
# instead of fitting onto an unselected device. Clear the raw selection
|
||||||
|
# the early state-publish recorded so it never leaks into gpu_ids (#7239).
|
||||||
|
if _vulkan_explicit_unmatched:
|
||||||
|
self._gpu_ids = None
|
||||||
|
self._requested_gpu_ids = None
|
||||||
|
raise ValueError(
|
||||||
|
f"Requested Vulkan GPU ordinal(s) {_vulkan_requested_ids} not "
|
||||||
|
f"present. Available Vulkan devices: {_vulkan_available_ordinals}."
|
||||||
|
)
|
||||||
|
|
||||||
# GPU picker: when no narrower subset was chosen (manual, or
|
# GPU picker: when no narrower subset was chosen (manual, or
|
||||||
# a failed/file-size selection), pin the whole picked set so the
|
# a failed/file-size selection), pin the whole picked set so the
|
||||||
# model can't spill onto an unpicked GPU.
|
# model can't spill onto an unpicked GPU.
|
||||||
|
|
@ -7759,11 +7915,45 @@ class LlamaCppBackend:
|
||||||
", ".join(unsupported_cache_flags),
|
", ".join(unsupported_cache_flags),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vulkan pins via --device (a cmd arg, unlike the env-based
|
# Vulkan pins via --device (a cmd arg), before user extras so a user
|
||||||
# CUDA/ROCm pin below), emitted BEFORE user extras so llama.cpp's
|
# --device wins. Fall back to raw ids when the fit did not narrow.
|
||||||
# last-wins parsing lets a user --device override Unsloth's pick.
|
_vulkan_pin_ids = gpu_indices if gpu_indices is not None else (gpu_ids or None)
|
||||||
if is_vulkan_backend and gpu_indices is not None:
|
|
||||||
cmd += LlamaCppBackend._vulkan_pin_args(gpu_indices)
|
# Record the pin actually applied (fit-narrowed gpu_indices, else the raw
|
||||||
|
# request) for the keep-warm loop, dedupe, and /status, so an explicit
|
||||||
|
# [0, 1] narrowed to [0] records [0] and /status never echoes an ordinal
|
||||||
|
# the child never saw. Auto selection (no gpu_ids) stays None (#7239).
|
||||||
|
if is_vulkan_backend:
|
||||||
|
# Only record an EXPLICIT Vulkan pin: an auto pick still narrows +
|
||||||
|
# pins below, but recording it would misreport an explicit pin and
|
||||||
|
# make dedupe miss the loaded server; mirrors the CUDA/ROCm branch.
|
||||||
|
self._gpu_ids = (
|
||||||
|
sorted(int(x) for x in _vulkan_pin_ids)
|
||||||
|
if (gpu_ids and _vulkan_pin_ids)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
elif gpu_ids:
|
||||||
|
# Physical pin: the fit-selected subset when the fit ran, else the raw
|
||||||
|
# user selection so an explicit choice is honoured even when the fit
|
||||||
|
# could not size the model.
|
||||||
|
_effective_pin_ids = (
|
||||||
|
[int(x) for x in gpu_indices]
|
||||||
|
if gpu_indices is not None
|
||||||
|
else [int(x) for x in gpu_ids]
|
||||||
|
)
|
||||||
|
self._gpu_ids = (
|
||||||
|
sorted(int(x) for x in _effective_pin_ids) if _effective_pin_ids else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._gpu_ids = None
|
||||||
|
|
||||||
|
# Also record the RAW requested pin (before the fit narrowed it). Load
|
||||||
|
# dedupe compares this so a [0, 1] narrowed to [0] and re-sent as [0, 1]
|
||||||
|
# still matches, while /status keeps echoing the effective pin (#7239).
|
||||||
|
self._requested_gpu_ids = sorted(int(x) for x in gpu_ids) if gpu_ids else None
|
||||||
|
|
||||||
|
if is_vulkan_backend and _vulkan_pin_ids is not None:
|
||||||
|
cmd += LlamaCppBackend._vulkan_pin_args(_vulkan_pin_ids)
|
||||||
|
|
||||||
# User pass-through args go last so llama.cpp's last-wins parsing
|
# User pass-through args go last so llama.cpp's last-wins parsing
|
||||||
# lets the user override Unsloth's auto-set flags. Already
|
# lets the user override Unsloth's auto-set flags. Already
|
||||||
|
|
@ -7832,10 +8022,10 @@ class LlamaCppBackend:
|
||||||
f"Data-center GPU detected: applied DC llama.cpp env tuning (multi_gpu={multi_gpu})"
|
f"Data-center GPU detected: applied DC llama.cpp env tuning (multi_gpu={multi_gpu})"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pin to selected GPU(s). On ROCm, narrowing only
|
# Pin to selected GPU(s) (issue #7164; resolved above into gpu_indices).
|
||||||
# CUDA_VISIBLE_DEVICES leaves an AMD child seeing the full set, so
|
# On ROCm, narrowing only CUDA_VISIBLE_DEVICES leaves the AMD child
|
||||||
# set HIP_VISIBLE_DEVICES too. Vulkan is pinned via --device
|
# seeing the full set, so set HIP_VISIBLE_DEVICES too. Vulkan is pinned
|
||||||
# (above), not here.
|
# via --device (above), not here.
|
||||||
# A deliberate zero-offload load with no GPU companions runs
|
# A deliberate zero-offload load with no GPU companions runs
|
||||||
# entirely on CPU, yet a visible CUDA device still costs the child
|
# entirely on CPU, yet a visible CUDA device still costs the child
|
||||||
# ~0.5 GB (context + compute scratch) that the CPU-only
|
# ~0.5 GB (context + compute scratch) that the CPU-only
|
||||||
|
|
@ -8756,16 +8946,10 @@ class LlamaCppBackend:
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
# A changed GPU pick must reload (compare order-insensitively; None/[]
|
# A changed GPU pick must reload. Regular GGUF accepts either the raw
|
||||||
# both mean automatic). The diffusion runner collapses a multi-GPU pick
|
# requested placement pool or the effective status-echoed subset;
|
||||||
# to its single lowest device, so self._gpu_ids holds just that device;
|
# diffusion compares its normalized single-device pick.
|
||||||
# normalize the request the same way, or a multi-GPU pick that resolves
|
if not self.matches_gpu_ids(gpu_ids):
|
||||||
# to the same device needlessly reloads.
|
|
||||||
if self._is_diffusion:
|
|
||||||
requested_gpu_pick = [sorted(gpu_ids)[0]] if gpu_ids else None
|
|
||||||
else:
|
|
||||||
requested_gpu_pick = sorted(gpu_ids) if gpu_ids else None
|
|
||||||
if (self._gpu_ids or None) != requested_gpu_pick:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Compare on the canonical requested mode. With --spec-type in
|
# Compare on the canonical requested mode. With --spec-type in
|
||||||
|
|
@ -8823,6 +9007,7 @@ class LlamaCppBackend:
|
||||||
current = list(self._extra_args) if self._extra_args is not None else []
|
current = list(self._extra_args) if self._extra_args is not None else []
|
||||||
if list(extra_args) != current:
|
if list(extra_args) != current:
|
||||||
return False
|
return False
|
||||||
|
self._record_matching_gpu_request(gpu_ids)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _classify_gpu_offload(
|
def _classify_gpu_offload(
|
||||||
|
|
@ -8954,12 +9139,15 @@ class LlamaCppBackend:
|
||||||
self._supports_preserve_thinking = False
|
self._supports_preserve_thinking = False
|
||||||
self._supports_tools = False
|
self._supports_tools = False
|
||||||
self._cache_type_kv = None
|
self._cache_type_kv = None
|
||||||
|
# GPU-pin state describes the active runner only; clear it so an explicit
|
||||||
|
# pin never leaks into the next (or diffusion) runner.
|
||||||
|
self._gpu_ids = None
|
||||||
|
self._requested_gpu_ids = None
|
||||||
self._tensor_parallel = False
|
self._tensor_parallel = False
|
||||||
self._gpu_memory_mode = "auto"
|
self._gpu_memory_mode = "auto"
|
||||||
self._gpu_layers = -1
|
self._gpu_layers = -1
|
||||||
self._n_cpu_moe = 0
|
self._n_cpu_moe = 0
|
||||||
self._tensor_split = None
|
self._tensor_split = None
|
||||||
self._gpu_ids = None
|
|
||||||
self._layer_preserves_tensor_intent = False
|
self._layer_preserves_tensor_intent = False
|
||||||
self._speculative_type = None
|
self._speculative_type = None
|
||||||
self._requested_spec_mode = None
|
self._requested_spec_mode = None
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ class LoadRequest(BaseModel):
|
||||||
)
|
)
|
||||||
gpu_ids: Optional[List[int]] = Field(
|
gpu_ids: Optional[List[int]] = Field(
|
||||||
None,
|
None,
|
||||||
description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries. For GGUF models the picked devices are pinned via CUDA/HIP_VISIBLE_DEVICES.",
|
description = "GPU placement pool, for example [0, 1]. Omit or pass [] to use automatic selection. CUDA/ROCm values are physical GPU indices and are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries; Vulkan values are ggml device ordinals. For GGUF models the fitter may pin the smallest subset of this pool that fits.",
|
||||||
)
|
)
|
||||||
speculative_type: Optional[str] = Field(
|
speculative_type: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
|
|
@ -485,7 +485,14 @@ class LoadResponse(BaseModel):
|
||||||
)
|
)
|
||||||
gpu_ids: Optional[List[int]] = Field(
|
gpu_ids: Optional[List[int]] = Field(
|
||||||
None,
|
None,
|
||||||
description = "Physical GPU indices the model is pinned to, or None for automatic selection.",
|
description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.",
|
||||||
|
)
|
||||||
|
requested_gpu_ids: Optional[List[int]] = Field(
|
||||||
|
None,
|
||||||
|
description = (
|
||||||
|
"GPU placement pool requested by the user before fit-time narrowing, "
|
||||||
|
"or None for automatic selection."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -649,7 +656,14 @@ class InferenceStatusResponse(BaseModel):
|
||||||
)
|
)
|
||||||
gpu_ids: Optional[List[int]] = Field(
|
gpu_ids: Optional[List[int]] = Field(
|
||||||
None,
|
None,
|
||||||
description = "Physical GPU indices the model is pinned to, or None for automatic selection.",
|
description = "Effective GPU indices the model is using after fit-time narrowing, or None for automatic selection.",
|
||||||
|
)
|
||||||
|
requested_gpu_ids: Optional[List[int]] = Field(
|
||||||
|
None,
|
||||||
|
description = (
|
||||||
|
"GPU placement pool requested by the user before fit-time narrowing, "
|
||||||
|
"or None for automatic selection."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
llama_cpp_supports_mtp: bool = Field(
|
llama_cpp_supports_mtp: bool = Field(
|
||||||
True,
|
True,
|
||||||
|
|
|
||||||
|
|
@ -3241,15 +3241,10 @@ def _request_matches_loaded_settings(
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
# A changed GPU pick must reload. The diffusion runner collapses a multi-GPU
|
# A regular GGUF may narrow the requested placement pool. Accept either the
|
||||||
# request to its single lowest device (it drives one device only), so the
|
# original request or the effective status-echoed subset; diffusion keeps
|
||||||
# backend records just that device; compare the request the same way, or a
|
# its single-device normalization.
|
||||||
# multi-GPU pick that resolves to the same device needlessly reloads.
|
if not llama_backend.matches_gpu_ids(request.gpu_ids):
|
||||||
if llama_backend.is_diffusion:
|
|
||||||
_req_gpu_ids = [sorted(request.gpu_ids)[0]] if request.gpu_ids else None
|
|
||||||
else:
|
|
||||||
_req_gpu_ids = sorted(request.gpu_ids) if request.gpu_ids else None
|
|
||||||
if _req_gpu_ids != llama_backend.gpu_ids:
|
|
||||||
return False
|
return False
|
||||||
# Preserved tensor->layer fallback (both report tensor=off, so the check above
|
# Preserved tensor->layer fallback (both report tensor=off, so the check above
|
||||||
# matches): if the user now explicitly drops tensor intent, reload so placement
|
# matches): if the user now explicitly drops tensor intent, reload so placement
|
||||||
|
|
@ -3897,15 +3892,19 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]:
|
||||||
"""Classify a GGUF as diffusion, normal, or unknown before it is loaded.
|
"""Classify a GGUF as diffusion, normal, or unknown before it is loaded.
|
||||||
|
|
||||||
``None`` is important here: a remote GGUF whose header is not cached can
|
``None`` is important here: a remote GGUF whose header is not cached can
|
||||||
still be routed to the single-GPU diffusion runner after download. Treating
|
still be routed to the single-GPU diffusion runner after download. Default
|
||||||
that case as normal would let Manual mode skip the training guard even
|
placement keeps that unknown case guarded until the header is available.
|
||||||
though the runner ignores Manual's llama-server placement controls.
|
|
||||||
"""
|
"""
|
||||||
identity = " ".join(
|
identity = " ".join(
|
||||||
str(getattr(config, attr, "") or "") for attr in ("identifier", "gguf_hf_repo", "gguf_file")
|
str(getattr(config, attr, "") or "") for attr in ("identifier", "gguf_hf_repo", "gguf_file")
|
||||||
).lower()
|
).lower()
|
||||||
if "diffusion" in identity:
|
# Name-only hint, used ONLY as a pre-download fallback, scoped to the
|
||||||
return True
|
# DiffusionGemma runner family: a bare "diffusion" substring is common in
|
||||||
|
# ordinary text-model names/paths (e.g. "stable-diffusion-prompt"), and treating
|
||||||
|
# those as diffusion falsely rejects a valid Vulkan+gpu_ids GGUF (#7239). Normalize
|
||||||
|
# non-alphanumerics so "DiffusionGemma"/"diffusion-gemma" collapse to one token.
|
||||||
|
# The local header below stays authoritative.
|
||||||
|
name_says_diffusion = "diffusiongemma" in _re.sub(r"[^a-z0-9]+", "", identity)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
main = getattr(config, "gguf_file", None)
|
main = getattr(config, "gguf_file", None)
|
||||||
|
|
@ -3915,23 +3914,86 @@ def _classify_diffusion_gguf(config: ModelConfig) -> Optional[bool]:
|
||||||
if repo and variant:
|
if repo and variant:
|
||||||
from hub.utils.gguf import resolve_local_gguf_path
|
from hub.utils.gguf import resolve_local_gguf_path
|
||||||
main = resolve_local_gguf_path(repo, variant)
|
main = resolve_local_gguf_path(repo, variant)
|
||||||
if not main or not Path(main).is_file():
|
if main and Path(main).is_file():
|
||||||
return None
|
# The local GGUF header is authoritative (same probe the loader uses), so
|
||||||
|
# it can't be fooled by a "diffusion"-flavored name/path.
|
||||||
probe = LlamaCppBackend()
|
probe = LlamaCppBackend()
|
||||||
probe._read_gguf_metadata(str(main))
|
probe._read_gguf_metadata(str(main))
|
||||||
if probe.is_diffusion:
|
if probe.is_diffusion:
|
||||||
return True
|
return True
|
||||||
# A successfully decoded architecture proves that this is a normal
|
# A decoded architecture proves a normal llama-server GGUF; no architecture
|
||||||
# llama-server GGUF. No architecture means the lightweight probe could
|
# means the probe was inconclusive, so fall through to the name hint below.
|
||||||
# not establish the routing decision, so preserve the unknown state.
|
if getattr(probe, "_architecture", None):
|
||||||
if getattr(probe, "_architecture", None):
|
return False
|
||||||
return False
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not identify diffusion GGUF for training guard: %s", e)
|
logger.debug("Could not identify diffusion GGUF for training guard: %s", e)
|
||||||
|
|
||||||
|
# Header unavailable (remote uncached) or inconclusive: True only for the
|
||||||
|
# DiffusionGemma name family; otherwise None keeps an unknown remote GGUF guarded
|
||||||
|
# as potentially diffusion until its header proves otherwise.
|
||||||
|
return True if name_says_diffusion else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_gguf_gpu_ids_for_request(
|
||||||
|
config: ModelConfig, gpu_ids: Optional[List[int]]
|
||||||
|
) -> Optional[List[int]]:
|
||||||
|
"""Resolve and fully validate an explicit GGUF GPU placement pool.
|
||||||
|
|
||||||
|
CUDA and ROCm use physical IDs. Vulkan uses ggml ordinals, so its device
|
||||||
|
existence check comes from the same ggml probe used by the loader. Both
|
||||||
|
/load and /validate call this before their training guard or any teardown.
|
||||||
|
"""
|
||||||
|
if not gpu_ids:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
from utils.hardware import DeviceType, get_device
|
||||||
|
from utils.hardware.hardware import resolve_requested_gpu_ids
|
||||||
|
|
||||||
|
is_vulkan = LlamaCppBackend._is_vulkan_backend()
|
||||||
|
if get_device() == DeviceType.XPU and not is_vulkan:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 400,
|
||||||
|
detail = (
|
||||||
|
"GPU selection (gpu_ids) is not supported on Intel XPU. "
|
||||||
|
"Omit gpu_ids to use all devices."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_vulkan and _classify_diffusion_gguf(config) is True:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 400,
|
||||||
|
detail = (
|
||||||
|
"GPU selection (gpu_ids) is not supported for a DiffusionGemma "
|
||||||
|
"GGUF on a Vulkan llama.cpp build: the diffusion runner selects "
|
||||||
|
"its device by CUDA physical index, which has no defined mapping "
|
||||||
|
"to ggml Vulkan device ordinals. Omit gpu_ids to use the default "
|
||||||
|
"device."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = resolve_requested_gpu_ids(gpu_ids, is_vulkan = is_vulkan)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code = 400, detail = str(exc)) from exc
|
||||||
|
|
||||||
|
if is_vulkan and resolved:
|
||||||
|
binary = LlamaCppBackend._find_llama_server_binary()
|
||||||
|
if binary:
|
||||||
|
probed = {
|
||||||
|
gpu[0] for gpu in await asyncio.to_thread(LlamaCppBackend._get_gpu_memory, binary)
|
||||||
|
}
|
||||||
|
wanted = {int(gpu_id) for gpu_id in resolved}
|
||||||
|
if not wanted.issubset(probed):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 400,
|
||||||
|
detail = (
|
||||||
|
f"Requested Vulkan GPU ordinal(s) {sorted(wanted)} not "
|
||||||
|
f"present. Available Vulkan devices: {sorted(probed)}."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def _guard_chat_load_against_training(
|
def _guard_chat_load_against_training(
|
||||||
config: ModelConfig,
|
config: ModelConfig,
|
||||||
|
|
@ -3971,8 +4033,18 @@ def _guard_chat_load_against_training(
|
||||||
if is_gguf and gpu_memory_mode == "manual" and diffusion_kind is False:
|
if is_gguf and gpu_memory_mode == "manual" and diffusion_kind is False:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Vulkan GGUF pins are ggml ordinals, not CUDA physical IDs. Detect this
|
||||||
|
# before deriving a possible diffusion fallback device so an unknown remote
|
||||||
|
# GGUF never sends its ordinal through the CUDA single-device path.
|
||||||
|
is_vulkan = False
|
||||||
|
if is_gguf:
|
||||||
|
try:
|
||||||
|
is_vulkan = LlamaCppBackend._is_vulkan_backend()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not detect Vulkan backend for chat-load guard: %s", e)
|
||||||
|
|
||||||
diffusion_gpu = None
|
diffusion_gpu = None
|
||||||
if is_gguf and diffusion_kind is not False:
|
if is_gguf and diffusion_kind is not False and not (is_vulkan and requested_gpu_ids):
|
||||||
# Use the same token selection as the runner: an explicit pick wins,
|
# Use the same token selection as the runner: an explicit pick wins,
|
||||||
# followed by DG_GPU, the first parent-visible token, then GPU 0.
|
# followed by DG_GPU, the first parent-visible token, then GPU 0.
|
||||||
diffusion_gpu = LlamaCppBackend._diffusion_gpu_arg(
|
diffusion_gpu = LlamaCppBackend._diffusion_gpu_arg(
|
||||||
|
|
@ -3999,6 +4071,7 @@ def _guard_chat_load_against_training(
|
||||||
max_seq_length = max_seq_length,
|
max_seq_length = max_seq_length,
|
||||||
requested_gpu_ids = requested_gpu_ids,
|
requested_gpu_ids = requested_gpu_ids,
|
||||||
is_gguf = is_gguf,
|
is_gguf = is_gguf,
|
||||||
|
is_vulkan = is_vulkan,
|
||||||
required_override_gb = required_override_gb,
|
required_override_gb = required_override_gb,
|
||||||
single_device_gpu = diffusion_gpu,
|
single_device_gpu = diffusion_gpu,
|
||||||
)
|
)
|
||||||
|
|
@ -4305,6 +4378,7 @@ async def _load_model_impl(
|
||||||
# Skip if a prior audio probe failed -- let load_model retry.
|
# Skip if a prior audio probe failed -- let load_model retry.
|
||||||
and getattr(llama_backend, "_audio_probed", True)
|
and getattr(llama_backend, "_audio_probed", True)
|
||||||
):
|
):
|
||||||
|
llama_backend._record_matching_gpu_request(request.gpu_ids)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Model already loaded (GGUF): "
|
"Model already loaded (GGUF): "
|
||||||
f"{model_log_label} variant={request.gguf_variant or llama_backend.hf_variant}, skipping reload"
|
f"{model_log_label} variant={request.gguf_variant or llama_backend.hf_variant}, skipping reload"
|
||||||
|
|
@ -4351,6 +4425,7 @@ async def _load_model_impl(
|
||||||
n_layers = llama_backend.n_layers,
|
n_layers = llama_backend.n_layers,
|
||||||
n_moe_layers = llama_backend.n_moe_layers,
|
n_moe_layers = llama_backend.n_moe_layers,
|
||||||
gpu_ids = llama_backend.gpu_ids,
|
gpu_ids = llama_backend.gpu_ids,
|
||||||
|
requested_gpu_ids = llama_backend.requested_gpu_ids,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if (
|
if (
|
||||||
|
|
@ -4417,41 +4492,12 @@ async def _load_model_impl(
|
||||||
# Normalize gpu_ids: empty list means auto-selection, same as None
|
# Normalize gpu_ids: empty list means auto-selection, same as None
|
||||||
effective_gpu_ids = request.gpu_ids if request.gpu_ids else None
|
effective_gpu_ids = request.gpu_ids if request.gpu_ids else None
|
||||||
|
|
||||||
# GGUF supports gpu_ids: validate the pick up front (before the training
|
# Validate the full GGUF placement pool before the training guard so an
|
||||||
# guard) so a bad pick is a clean 400, not masked by a VRAM 409. Rejects
|
# invalid physical ID or Vulkan ordinal is a clean 400, not a masked VRAM
|
||||||
# negative / out-of-range / duplicate ids and UUID/MIG parents. XPU hosts
|
# 409. The same helper is used by /validate.
|
||||||
# are rejected outright: the picker's indices are torch-xpu ordinals neither
|
gguf_gpu_ids: Optional[List[int]] = None
|
||||||
# applicator speaks (CUDA/HIP masks don't apply, the Vulkan --device pin
|
if config.is_gguf:
|
||||||
# uses ggml's own Vulkan ordinals), so a pick could land on the wrong device.
|
gguf_gpu_ids = await _resolve_gguf_gpu_ids_for_request(config, effective_gpu_ids)
|
||||||
if config.is_gguf and effective_gpu_ids is not None:
|
|
||||||
from utils.hardware import DeviceType, get_device
|
|
||||||
from utils.hardware.hardware import resolve_requested_gpu_ids
|
|
||||||
|
|
||||||
if get_device() == DeviceType.XPU:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code = 400,
|
|
||||||
detail = (
|
|
||||||
"GPU selection (gpu_ids) is not supported on Intel XPU. "
|
|
||||||
"Omit gpu_ids to use all devices."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
# Same reasoning for a Vulkan-only build: --device pins ggml's own
|
|
||||||
# Vulkan ordinals, so a physical pick can land on the wrong card on
|
|
||||||
# masked or non-contiguous hosts.
|
|
||||||
if LlamaCppBackend._is_vulkan_backend():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code = 400,
|
|
||||||
detail = (
|
|
||||||
"GPU selection (gpu_ids) is not supported with a Vulkan "
|
|
||||||
"llama.cpp build: physical GPU ids have no defined "
|
|
||||||
"mapping to Vulkan device ordinals. Omit gpu_ids to use "
|
|
||||||
"all devices."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
resolve_requested_gpu_ids(effective_gpu_ids)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code = 400, detail = str(exc)) from exc
|
|
||||||
if not config.is_gguf and _mlx_distributed_launch_detected():
|
if not config.is_gguf and _mlx_distributed_launch_detected():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code = 400,
|
status_code = 400,
|
||||||
|
|
@ -4575,8 +4621,9 @@ async def _load_model_impl(
|
||||||
gpu_layers = request.gpu_layers,
|
gpu_layers = request.gpu_layers,
|
||||||
n_cpu_moe = request.n_cpu_moe,
|
n_cpu_moe = request.n_cpu_moe,
|
||||||
tensor_split = request.tensor_split,
|
tensor_split = request.tensor_split,
|
||||||
gpu_ids = effective_gpu_ids,
|
|
||||||
n_parallel = _n_parallel,
|
n_parallel = _n_parallel,
|
||||||
|
# Issue #7164: explicit GPU pin resolved to physical ids above.
|
||||||
|
gpu_ids = gguf_gpu_ids,
|
||||||
)
|
)
|
||||||
if config.gguf_hf_repo:
|
if config.gguf_hf_repo:
|
||||||
# HF mode: download via huggingface_hub then start llama-server
|
# HF mode: download via huggingface_hub then start llama-server
|
||||||
|
|
@ -4750,6 +4797,7 @@ async def _load_model_impl(
|
||||||
n_layers = llama_backend.n_layers,
|
n_layers = llama_backend.n_layers,
|
||||||
n_moe_layers = llama_backend.n_moe_layers,
|
n_moe_layers = llama_backend.n_moe_layers,
|
||||||
gpu_ids = llama_backend.gpu_ids,
|
gpu_ids = llama_backend.gpu_ids,
|
||||||
|
requested_gpu_ids = llama_backend.requested_gpu_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Standard path: load via Unsloth/transformers ──────────
|
# ── Standard path: load via Unsloth/transformers ──────────
|
||||||
|
|
@ -5043,36 +5091,8 @@ async def validate_model(
|
||||||
# Apply the same training coexistence policy as /load before the frontend
|
# Apply the same training coexistence policy as /load before the frontend
|
||||||
# unloads the current model.
|
# unloads the current model.
|
||||||
effective_gpu_ids = request.gpu_ids if request.gpu_ids else None
|
effective_gpu_ids = request.gpu_ids if request.gpu_ids else None
|
||||||
# Mirror /load: GGUF supports gpu_ids, so validate the pick (a bad one is
|
if config.is_gguf:
|
||||||
# a clean 400) before the guard sizes the model against training VRAM.
|
await _resolve_gguf_gpu_ids_for_request(config, effective_gpu_ids)
|
||||||
# XPU-host picks are rejected like /load (no defined mapping from the
|
|
||||||
# picker's torch-xpu ordinals to the launcher's device spaces).
|
|
||||||
if config.is_gguf and effective_gpu_ids is not None:
|
|
||||||
from utils.hardware import DeviceType, get_device
|
|
||||||
from utils.hardware.hardware import resolve_requested_gpu_ids
|
|
||||||
|
|
||||||
if get_device() == DeviceType.XPU:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code = 400,
|
|
||||||
detail = (
|
|
||||||
"GPU selection (gpu_ids) is not supported on Intel XPU. "
|
|
||||||
"Omit gpu_ids to use all devices."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if LlamaCppBackend._is_vulkan_backend():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code = 400,
|
|
||||||
detail = (
|
|
||||||
"GPU selection (gpu_ids) is not supported with a Vulkan "
|
|
||||||
"llama.cpp build: physical GPU ids have no defined "
|
|
||||||
"mapping to Vulkan device ordinals. Omit gpu_ids to use "
|
|
||||||
"all devices."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
resolve_requested_gpu_ids(effective_gpu_ids)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code = 400, detail = str(exc)) from exc
|
|
||||||
effective_load_in_4bit = _effective_load_in_4bit(config, request.load_in_4bit)
|
effective_load_in_4bit = _effective_load_in_4bit(config, request.load_in_4bit)
|
||||||
|
|
||||||
# Both checks cover the [adapter, base] set (matching the scan route and workers):
|
# Both checks cover the [adapter, base] set (matching the scan route and workers):
|
||||||
|
|
@ -5897,6 +5917,7 @@ async def get_status(current_subject: str = Depends(get_current_subject)):
|
||||||
n_layers = llama_backend.n_layers,
|
n_layers = llama_backend.n_layers,
|
||||||
n_moe_layers = llama_backend.n_moe_layers,
|
n_moe_layers = llama_backend.n_moe_layers,
|
||||||
gpu_ids = llama_backend.gpu_ids,
|
gpu_ids = llama_backend.gpu_ids,
|
||||||
|
requested_gpu_ids = llama_backend.requested_gpu_ids,
|
||||||
llama_cpp_supports_mtp = _supports_mtp,
|
llama_cpp_supports_mtp = _supports_mtp,
|
||||||
spec_fallback_reason = llama_backend.spec_fallback_reason,
|
spec_fallback_reason = llama_backend.spec_fallback_reason,
|
||||||
llama_cpp_prebuilt_stale = _stale,
|
llama_cpp_prebuilt_stale = _stale,
|
||||||
|
|
|
||||||
|
|
@ -225,6 +225,7 @@ def can_load_chat_during_training(
|
||||||
max_seq_length: int,
|
max_seq_length: int,
|
||||||
requested_gpu_ids: Optional[List[int]],
|
requested_gpu_ids: Optional[List[int]],
|
||||||
is_gguf: bool = False,
|
is_gguf: bool = False,
|
||||||
|
is_vulkan: bool = False,
|
||||||
required_override_gb: Optional[float] = None,
|
required_override_gb: Optional[float] = None,
|
||||||
single_device_gpu: Optional[str] = None,
|
single_device_gpu: Optional[str] = None,
|
||||||
) -> Tuple[bool, Dict[str, Any]]:
|
) -> Tuple[bool, Dict[str, Any]]:
|
||||||
|
|
@ -233,11 +234,15 @@ def can_load_chat_during_training(
|
||||||
chat model against the free VRAM that remains). Sizes/places it the same way
|
chat model against the free VRAM that remains). Sizes/places it the same way
|
||||||
the loader will: HF auto reuses auto_select_gpu_ids; HF explicit requires an
|
the loader will: HF auto reuses auto_select_gpu_ids; HF explicit requires an
|
||||||
even-share per-GPU floor for device_map="balanced"; GGUF sizes from
|
even-share per-GPU floor for device_map="balanced"; GGUF sizes from
|
||||||
required_override_gb over the visible pool. ``single_device_gpu`` is the
|
required_override_gb over the visible pool. A Vulkan GGUF selection picks by ggml
|
||||||
exact physical device token selected by a single-device runner.
|
Vulkan ordinal (separate index space from CUDA ids), so its requested_gpu_ids is
|
||||||
`load_in_4bit` must be effective (LoRA can flip 4-bit -> 16-bit). Non-CUDA
|
NOT resolved against the CUDA set (which would raise -> invalid_gpu_ids -> bypass
|
||||||
allows the load; default-deny on any CUDA case it can't size, so a load never
|
the OOM check); conservatively size an N-device request against the least-free
|
||||||
OOMs training."""
|
N visible GPUs instead.
|
||||||
|
``single_device_gpu`` is the exact physical device token selected by a
|
||||||
|
single-device runner. `load_in_4bit` must be effective (LoRA can flip 4-bit
|
||||||
|
-> 16-bit). Non-CUDA allows the load; default-deny on any CUDA case it can't
|
||||||
|
size, so a load never OOMs training."""
|
||||||
try:
|
try:
|
||||||
from utils.hardware import (
|
from utils.hardware import (
|
||||||
DeviceType,
|
DeviceType,
|
||||||
|
|
@ -258,6 +263,11 @@ def can_load_chat_during_training(
|
||||||
max_seq_length = max_seq_length or 2048,
|
max_seq_length = max_seq_length or 2048,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A Vulkan GGUF selection uses ggml Vulkan ordinals, not CUDA physical ids;
|
||||||
|
# size it against the full visible pool (GGUF self-placement) rather than
|
||||||
|
# resolving ordinals against the CUDA parent-visible set.
|
||||||
|
vulkan_gguf = is_gguf and is_vulkan
|
||||||
|
|
||||||
# HF auto: reuse the loader's selector; fits iff its pick clears the margin.
|
# HF auto: reuse the loader's selector; fits iff its pick clears the margin.
|
||||||
if not requested_gpu_ids and not is_gguf:
|
if not requested_gpu_ids and not is_gguf:
|
||||||
_selected, meta = auto_select_gpu_ids(model_name, **est_kwargs)
|
_selected, meta = auto_select_gpu_ids(model_name, **est_kwargs)
|
||||||
|
|
@ -283,7 +293,9 @@ def can_load_chat_during_training(
|
||||||
}
|
}
|
||||||
|
|
||||||
# Explicit GPUs, or GGUF: size directly and check live free VRAM.
|
# Explicit GPUs, or GGUF: size directly and check live free VRAM.
|
||||||
if single_device_gpu is not None:
|
if requested_gpu_ids and vulkan_gguf:
|
||||||
|
mode = "gguf_vulkan"
|
||||||
|
elif single_device_gpu is not None:
|
||||||
mode = "single_device"
|
mode = "single_device"
|
||||||
elif is_gguf:
|
elif is_gguf:
|
||||||
mode = "gguf"
|
mode = "gguf"
|
||||||
|
|
@ -296,7 +308,17 @@ def can_load_chat_during_training(
|
||||||
return False, {"mode": mode, "reason": "estimate_unavailable"}
|
return False, {"mode": mode, "reason": "estimate_unavailable"}
|
||||||
|
|
||||||
free_by_index = _free_vram_by_index(get_visible_gpu_utilization().get("devices", []))
|
free_by_index = _free_vram_by_index(get_visible_gpu_utilization().get("devices", []))
|
||||||
if single_device_gpu is not None:
|
if requested_gpu_ids and vulkan_gguf:
|
||||||
|
# Vulkan ordinals cannot be mapped to CUDA physical indices. Budget
|
||||||
|
# the least-free N visible cards for an N-device request. If that
|
||||||
|
# conservative subset fits, any physical mapping of the ordinals
|
||||||
|
# fits, without collapsing a multi-GPU request to one card.
|
||||||
|
visible_free = list(free_by_index.values())
|
||||||
|
if not visible_free:
|
||||||
|
return False, {"mode": "gguf_vulkan", "reason": "no_visible_gpus"}
|
||||||
|
n_pins = min(len(requested_gpu_ids), len(visible_free))
|
||||||
|
free_vals = sorted(visible_free)[:n_pins]
|
||||||
|
elif single_device_gpu is not None:
|
||||||
token = str(single_device_gpu).strip()
|
token = str(single_device_gpu).strip()
|
||||||
if not token:
|
if not token:
|
||||||
# Empty token = a CPU-only single-device runner (e.g. a CPU
|
# Empty token = a CPU-only single-device runner (e.g. a CPU
|
||||||
|
|
@ -324,7 +346,8 @@ def can_load_chat_during_training(
|
||||||
return True, {"mode": mode, "reason": "invalid_gpu_ids"}
|
return True, {"mode": mode, "reason": "invalid_gpu_ids"}
|
||||||
free_vals = [free_by_index.get(i, 0.0) for i in resolved]
|
free_vals = [free_by_index.get(i, 0.0) for i in resolved]
|
||||||
else:
|
else:
|
||||||
# GGUF: llama.cpp picks the GPU(s); any visible GPU is a candidate.
|
# GGUF self-placement / auto Vulkan (no requested ids): llama.cpp picks
|
||||||
|
# the GPU(s), so any visible GPU is a candidate -> size the whole pool.
|
||||||
free_vals = list(free_by_index.values())
|
free_vals = list(free_by_index.values())
|
||||||
|
|
||||||
if not free_vals:
|
if not free_vals:
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase):
|
||||||
estimate = None,
|
estimate = None,
|
||||||
single_device_gpu = None,
|
single_device_gpu = None,
|
||||||
gpu_ids = None,
|
gpu_ids = None,
|
||||||
|
is_vulkan = False,
|
||||||
):
|
):
|
||||||
with (
|
with (
|
||||||
patch("utils.hardware.get_device", return_value = DeviceType.CUDA),
|
patch("utils.hardware.get_device", return_value = DeviceType.CUDA),
|
||||||
|
|
@ -185,6 +186,7 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase):
|
||||||
max_seq_length = 0,
|
max_seq_length = 0,
|
||||||
requested_gpu_ids = gpu_ids,
|
requested_gpu_ids = gpu_ids,
|
||||||
is_gguf = True,
|
is_gguf = True,
|
||||||
|
is_vulkan = is_vulkan,
|
||||||
required_override_gb = required_override,
|
required_override_gb = required_override,
|
||||||
single_device_gpu = single_device_gpu,
|
single_device_gpu = single_device_gpu,
|
||||||
)
|
)
|
||||||
|
|
@ -234,6 +236,35 @@ class TestCanLoadGGUF(_GpuCacheResetMixin, unittest.TestCase):
|
||||||
self.assertFalse(blocked)
|
self.assertFalse(blocked)
|
||||||
self.assertEqual(blocked_info["usable_gb"], 10.0)
|
self.assertEqual(blocked_info["usable_gb"], 10.0)
|
||||||
|
|
||||||
|
def test_vulkan_pin_takes_precedence_over_unknown_diffusion_fallback(self):
|
||||||
|
# An uncached GGUF can carry a speculative single-device fallback while
|
||||||
|
# its explicit pin is actually a ggml Vulkan ordinal. Never interpret
|
||||||
|
# that ordinal as the same-numbered CUDA physical device.
|
||||||
|
ok, info, _ = self._run(
|
||||||
|
devices = _devices((0, 80, 0), (1, 80, 78)),
|
||||||
|
required_override = 20.0,
|
||||||
|
single_device_gpu = "0",
|
||||||
|
gpu_ids = [0],
|
||||||
|
is_vulkan = True,
|
||||||
|
)
|
||||||
|
self.assertFalse(ok)
|
||||||
|
self.assertEqual(info["mode"], "gguf_vulkan")
|
||||||
|
self.assertEqual(info["usable_gb"], 2.0)
|
||||||
|
|
||||||
|
def test_vulkan_multi_gpu_guard_counts_requested_devices(self):
|
||||||
|
# The ordinal mapping is unknown, so use the least-free two visible
|
||||||
|
# cards for a two-device request. Their aggregate capacity is still
|
||||||
|
# available instead of collapsing the request to one card.
|
||||||
|
ok, info, _ = self._run(
|
||||||
|
devices = _devices((0, 80, 70), (1, 80, 70), (2, 80, 0)),
|
||||||
|
required_override = 10.0,
|
||||||
|
gpu_ids = [0, 1],
|
||||||
|
is_vulkan = True,
|
||||||
|
)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
self.assertEqual(info["mode"], "gguf_vulkan")
|
||||||
|
self.assertEqual(info["usable_gb"], 18.5)
|
||||||
|
|
||||||
def test_single_device_unresolved_token_sizes_against_worst_device(self):
|
def test_single_device_unresolved_token_sizes_against_worst_device(self):
|
||||||
# A non-numeric device token (a CUDA UUID / MIG handle) can't map to a
|
# A non-numeric device token (a CUDA UUID / MIG handle) can't map to a
|
||||||
# free-VRAM index. The runner still drives ONE device, so size against the
|
# free-VRAM index. The runner still drives ONE device, so size against the
|
||||||
|
|
@ -478,58 +509,19 @@ class TestChatLoadGuardRoute(unittest.TestCase):
|
||||||
def test_manual_known_normal_gguf_bypasses_training_estimate(self):
|
def test_manual_known_normal_gguf_bypasses_training_estimate(self):
|
||||||
captured = []
|
captured = []
|
||||||
config = SimpleNamespace(is_gguf = True)
|
config = SimpleNamespace(is_gguf = True)
|
||||||
with patch.object(self.route, "_classify_diffusion_gguf", return_value = False):
|
with patch.object(self.route, "_classify_diffusion_gguf", return_value = False) as classify:
|
||||||
self._guard(
|
self._guard(
|
||||||
config = config,
|
config = config,
|
||||||
captured = captured,
|
captured = captured,
|
||||||
training_active = True,
|
training_active = True,
|
||||||
decision = (False, {"reason": "must not run"}),
|
decision = (False, {"reason": "must not run"}),
|
||||||
gpu_memory_mode = "manual",
|
gpu_memory_mode = "manual",
|
||||||
|
requested_gpu_ids = [1, 3],
|
||||||
)
|
)
|
||||||
|
classify.assert_called_once_with(config)
|
||||||
self.assertEqual(captured, [])
|
self.assertEqual(captured, [])
|
||||||
|
|
||||||
def test_manual_unknown_gguf_keeps_single_device_training_guard(self):
|
def test_manual_diffusion_keeps_single_device_training_guard(self):
|
||||||
captured = []
|
|
||||||
config = SimpleNamespace(is_gguf = True)
|
|
||||||
with (
|
|
||||||
patch.object(self.route, "_classify_diffusion_gguf", return_value = None),
|
|
||||||
patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5),
|
|
||||||
patch.object(
|
|
||||||
self.route.LlamaCppBackend,
|
|
||||||
"_diffusion_gpu_arg",
|
|
||||||
return_value = "2",
|
|
||||||
),
|
|
||||||
):
|
|
||||||
self._guard(
|
|
||||||
config = config,
|
|
||||||
captured = captured,
|
|
||||||
training_active = True,
|
|
||||||
decision = (True, {"mode": "single_device"}),
|
|
||||||
gpu_memory_mode = "manual",
|
|
||||||
)
|
|
||||||
self.assertEqual(len(captured), 1)
|
|
||||||
self.assertEqual(captured[0]["single_device_gpu"], "2")
|
|
||||||
|
|
||||||
def test_manual_diffusion_uses_single_device_guard(self):
|
|
||||||
captured = []
|
|
||||||
config = SimpleNamespace(is_gguf = True)
|
|
||||||
with (
|
|
||||||
patch.object(self.route, "_classify_diffusion_gguf", return_value = True),
|
|
||||||
patch.object(self.route, "_estimate_gguf_required_gb", return_value = 12.5),
|
|
||||||
):
|
|
||||||
self._guard(
|
|
||||||
config = config,
|
|
||||||
captured = captured,
|
|
||||||
training_active = True,
|
|
||||||
decision = (True, {"mode": "gguf"}),
|
|
||||||
gpu_memory_mode = "manual",
|
|
||||||
requested_gpu_ids = [3, 1],
|
|
||||||
)
|
|
||||||
self.assertEqual(len(captured), 1)
|
|
||||||
self.assertEqual(captured[0]["single_device_gpu"], "1")
|
|
||||||
self.assertEqual(captured[0]["requested_gpu_ids"], [3, 1])
|
|
||||||
|
|
||||||
def test_unpinned_diffusion_uses_runner_default_gpu(self):
|
|
||||||
captured = []
|
captured = []
|
||||||
config = SimpleNamespace(is_gguf = True)
|
config = SimpleNamespace(is_gguf = True)
|
||||||
with (
|
with (
|
||||||
|
|
@ -540,11 +532,6 @@ class TestChatLoadGuardRoute(unittest.TestCase):
|
||||||
"_effective_gpu_count",
|
"_effective_gpu_count",
|
||||||
return_value = 2,
|
return_value = 2,
|
||||||
),
|
),
|
||||||
patch.object(
|
|
||||||
self.route.LlamaCppBackend,
|
|
||||||
"_diffusion_gpu_arg",
|
|
||||||
return_value = "3",
|
|
||||||
) as gpu_arg,
|
|
||||||
):
|
):
|
||||||
self._guard(
|
self._guard(
|
||||||
config = config,
|
config = config,
|
||||||
|
|
@ -552,9 +539,11 @@ class TestChatLoadGuardRoute(unittest.TestCase):
|
||||||
training_active = True,
|
training_active = True,
|
||||||
decision = (True, {"mode": "single_device"}),
|
decision = (True, {"mode": "single_device"}),
|
||||||
gpu_memory_mode = "manual",
|
gpu_memory_mode = "manual",
|
||||||
|
requested_gpu_ids = [3, 1],
|
||||||
)
|
)
|
||||||
gpu_arg.assert_called_once_with(None, cpu_only = False)
|
self.assertEqual(len(captured), 1)
|
||||||
self.assertEqual(captured[0]["single_device_gpu"], "3")
|
self.assertEqual(captured[0]["single_device_gpu"], "1")
|
||||||
|
self.assertEqual(captured[0]["requested_gpu_ids"], [3, 1])
|
||||||
|
|
||||||
def test_refuses_with_headroom_number(self):
|
def test_refuses_with_headroom_number(self):
|
||||||
info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"}
|
info = {"required_gb": 30.0, "usable_gb": 6.0, "needed_gb": 39.0, "mode": "auto"}
|
||||||
|
|
|
||||||
|
|
@ -591,10 +591,23 @@ def test_load_request_accepts_gpu_ids():
|
||||||
@pytest.mark.parametrize("model_cls", [LoadResponse, InferenceStatusResponse])
|
@pytest.mark.parametrize("model_cls", [LoadResponse, InferenceStatusResponse])
|
||||||
def test_response_models_emit_gpu_ids(model_cls):
|
def test_response_models_emit_gpu_ids(model_cls):
|
||||||
if model_cls is LoadResponse:
|
if model_cls is LoadResponse:
|
||||||
obj = model_cls(status = "loaded", model = "m", display_name = "m", inference = {}, gpu_ids = [1])
|
obj = model_cls(
|
||||||
|
status = "loaded",
|
||||||
|
model = "m",
|
||||||
|
display_name = "m",
|
||||||
|
inference = {},
|
||||||
|
gpu_ids = [1],
|
||||||
|
requested_gpu_ids = [1, 2],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
obj = model_cls(gpu_ids = [1])
|
obj = model_cls(gpu_ids = [1], requested_gpu_ids = [1, 2])
|
||||||
assert obj.model_dump()["gpu_ids"] == [1]
|
assert obj.model_dump()["gpu_ids"] == [1]
|
||||||
|
assert obj.model_dump()["requested_gpu_ids"] == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gguf_load_and_status_responses_include_requested_gpu_pool():
|
||||||
|
route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8")
|
||||||
|
assert route_src.count("requested_gpu_ids = llama_backend.requested_gpu_ids") == 3
|
||||||
|
|
||||||
|
|
||||||
def test_gpu_ids_property_default_and_reset():
|
def test_gpu_ids_property_default_and_reset():
|
||||||
|
|
@ -625,6 +638,10 @@ def _target_state_gpu_ids(backend, gpu_ids):
|
||||||
def test_gpu_ids_reload_detection_is_order_insensitive():
|
def test_gpu_ids_reload_detection_is_order_insensitive():
|
||||||
backend = _loaded_backend("auto")
|
backend = _loaded_backend("auto")
|
||||||
backend._gpu_ids = [0, 1]
|
backend._gpu_ids = [0, 1]
|
||||||
|
# A real non-narrowed load records the raw request too; the non-diffusion
|
||||||
|
# dedupe now compares that raw pin (#7239). Set it to match the effective pin
|
||||||
|
# (no narrowing) so this exercises the order-insensitive comparison.
|
||||||
|
backend._requested_gpu_ids = [0, 1]
|
||||||
# Same set, different order -> no reload.
|
# Same set, different order -> no reload.
|
||||||
assert _target_state_gpu_ids(backend, [1, 0]) is True
|
assert _target_state_gpu_ids(backend, [1, 0]) is True
|
||||||
# Different set -> reload.
|
# Different set -> reload.
|
||||||
|
|
@ -633,6 +650,26 @@ def test_gpu_ids_reload_detection_is_order_insensitive():
|
||||||
assert _target_state_gpu_ids(backend, None) is False
|
assert _target_state_gpu_ids(backend, None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_ids_reload_detection_accepts_raw_and_effective_pin():
|
||||||
|
backend = _loaded_backend("auto")
|
||||||
|
backend._requested_gpu_ids = [0, 1]
|
||||||
|
backend._gpu_ids = [0]
|
||||||
|
backend._last_load_kwargs = {"gpu_ids": [0, 1], "model_identifier": "owner/repo"}
|
||||||
|
|
||||||
|
# The original request still matches after the fitter narrows it.
|
||||||
|
assert _target_state_gpu_ids(backend, [1, 0]) is True
|
||||||
|
assert backend.requested_gpu_ids == [0, 1]
|
||||||
|
# The status response echoes the effective pin, which must also round-trip.
|
||||||
|
# Treat the incoming subset as the latest intent so status and a future
|
||||||
|
# reload do not restore GPU 1 after the user removed it.
|
||||||
|
assert _target_state_gpu_ids(backend, [0]) is True
|
||||||
|
assert backend.requested_gpu_ids == [0]
|
||||||
|
assert backend._last_load_kwargs == {"gpu_ids": [0], "model_identifier": "owner/repo"}
|
||||||
|
# A genuinely different placement pool still reloads.
|
||||||
|
assert _target_state_gpu_ids(backend, [1]) is False
|
||||||
|
assert _target_state_gpu_ids(backend, None) is False
|
||||||
|
|
||||||
|
|
||||||
def test_gpu_ids_reload_detection_collapses_diffusion_to_single_device():
|
def test_gpu_ids_reload_detection_collapses_diffusion_to_single_device():
|
||||||
# The diffusion runner drives only its single lowest device, so the backend
|
# The diffusion runner drives only its single lowest device, so the backend
|
||||||
# records [lowest]. A later multi-GPU request that still resolves to that
|
# records [lowest]. A later multi-GPU request that still resolves to that
|
||||||
|
|
@ -642,6 +679,7 @@ def test_gpu_ids_reload_detection_collapses_diffusion_to_single_device():
|
||||||
backend._is_diffusion = True
|
backend._is_diffusion = True
|
||||||
backend._gpu_ids = [1] # loaded on the lowest of an earlier [3, 1] pick
|
backend._gpu_ids = [1] # loaded on the lowest of an earlier [3, 1] pick
|
||||||
assert _target_state_gpu_ids(backend, [3, 1]) is True
|
assert _target_state_gpu_ids(backend, [3, 1]) is True
|
||||||
|
assert backend.requested_gpu_ids == [1]
|
||||||
assert _target_state_gpu_ids(backend, [1]) is True
|
assert _target_state_gpu_ids(backend, [1]) is True
|
||||||
# Lowest device changes (2, not 1) -> reload.
|
# Lowest device changes (2, not 1) -> reload.
|
||||||
assert _target_state_gpu_ids(backend, [3, 2]) is False
|
assert _target_state_gpu_ids(backend, [3, 2]) is False
|
||||||
|
|
@ -649,6 +687,56 @@ def test_gpu_ids_reload_detection_collapses_diffusion_to_single_device():
|
||||||
assert _target_state_gpu_ids(backend, None) is False
|
assert _target_state_gpu_ids(backend, None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_vulkan_diffusion_preflight_runs_before_teardown(monkeypatch):
|
||||||
|
def _mark_diffusion(probe, path):
|
||||||
|
assert path == "/cache/model.gguf"
|
||||||
|
probe._is_diffusion = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(LlamaCppBackend, "_read_gguf_metadata", _mark_diffusion)
|
||||||
|
assert LlamaCppBackend._gguf_path_is_diffusion("/cache/model.gguf", "owner/model") is True
|
||||||
|
|
||||||
|
src = inspect.getsource(llama_cpp_module.LlamaCppBackend.load_model)
|
||||||
|
preflight = src.index("_preflight_model_path = self._download_gguf(")
|
||||||
|
teardown = src.index("# ── Phase 1: kill old process")
|
||||||
|
assert preflight < teardown
|
||||||
|
assert "model_path = _preflight_model_path or self._download_gguf(" in src
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_vulkan_diffusion_rejection_keeps_active_server(monkeypatch):
|
||||||
|
backend = LlamaCppBackend()
|
||||||
|
killed = []
|
||||||
|
monkeypatch.setattr(backend, "_find_llama_server_binary", lambda **_kwargs: "/bin/llama")
|
||||||
|
monkeypatch.setattr(backend, "_is_vulkan_backend", lambda _binary = None: True)
|
||||||
|
monkeypatch.setattr(backend, "_get_gpu_memory", lambda _binary = None: [(0, 1024, 2048)])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
backend,
|
||||||
|
"_download_gguf",
|
||||||
|
lambda **_kwargs: "/cache/diffusion.gguf",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(backend, "_gguf_path_is_diffusion", lambda *_args: True)
|
||||||
|
monkeypatch.setattr(backend, "_kill_process", lambda: killed.append(True))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
llama_cpp_module,
|
||||||
|
"_resolve_repo_id_casing",
|
||||||
|
lambda repo: repo,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
llama_cpp_module,
|
||||||
|
"_hf_offline_if_dns_dead",
|
||||||
|
lambda: __import__("contextlib").nullcontext(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match = "DiffusionGemma"):
|
||||||
|
backend.load_model(
|
||||||
|
hf_repo = "owner/model",
|
||||||
|
hf_variant = "Q4_K_M",
|
||||||
|
model_identifier = "owner/model",
|
||||||
|
gpu_ids = [0],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert killed == []
|
||||||
|
|
||||||
|
|
||||||
def test_start_diffusion_server_resets_tensor_parallel():
|
def test_start_diffusion_server_resets_tensor_parallel():
|
||||||
# A prior tensor-parallel chat load leaves self._tensor_parallel True (load_model
|
# A prior tensor-parallel chat load leaves self._tensor_parallel True (load_model
|
||||||
# phase 1 only kills the process, it skips the unload reset). Diffusion is never
|
# phase 1 only kills the process, it skips the unload reset). Diffusion is never
|
||||||
|
|
@ -656,18 +744,16 @@ def test_start_diffusion_server_resets_tensor_parallel():
|
||||||
# diffusion re-Apply reloads against stale tensor-parallel state.
|
# diffusion re-Apply reloads against stale tensor-parallel state.
|
||||||
src = inspect.getsource(llama_cpp_module.LlamaCppBackend._start_diffusion_server)
|
src = inspect.getsource(llama_cpp_module.LlamaCppBackend._start_diffusion_server)
|
||||||
assert "self._tensor_parallel = False" in src
|
assert "self._tensor_parallel = False" in src
|
||||||
|
assert "self._requested_gpu_ids = list(self._gpu_ids) if self._gpu_ids else None" in src
|
||||||
|
|
||||||
|
|
||||||
def test_route_matches_loaded_settings_collapses_diffusion_gpu_ids():
|
def test_route_matches_loaded_settings_uses_shared_gpu_pin_matcher():
|
||||||
# The route-level reload dedupe mirrors the backend: for a loaded diffusion
|
# Route-level and backend race dedupe must share one normalization path so
|
||||||
# model it compares the request against the single recorded device, not the
|
# raw, effective, and diffusion pins cannot drift apart.
|
||||||
# full requested list, or a same-device multi-GPU pick reloads needlessly.
|
|
||||||
route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8")
|
route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8")
|
||||||
match_impl = route_src[route_src.index("def _request_matches_loaded_settings") :]
|
match_impl = route_src[route_src.index("def _request_matches_loaded_settings") :]
|
||||||
guard = match_impl.index("if llama_backend.is_diffusion:")
|
assert "if not llama_backend.matches_gpu_ids(request.gpu_ids):" in match_impl
|
||||||
collapse = match_impl.index("[sorted(request.gpu_ids)[0]] if request.gpu_ids else None")
|
assert "llama_backend._record_matching_gpu_request(request.gpu_ids)" in match_impl
|
||||||
compare = match_impl.index("if _req_gpu_ids != llama_backend.gpu_ids:")
|
|
||||||
assert guard < collapse < compare
|
|
||||||
|
|
||||||
|
|
||||||
# ── Manual tensor split: child enumeration pinned to the picker's order ──────
|
# ── Manual tensor split: child enumeration pinned to the picker's order ──────
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,26 @@ class TestResolveRequestedGpuIds(_GpuCacheResetMixin, unittest.TestCase):
|
||||||
):
|
):
|
||||||
self.assertEqual(resolve_requested_gpu_ids([]), [1, 3])
|
self.assertEqual(resolve_requested_gpu_ids([]), [1, 3])
|
||||||
|
|
||||||
|
def test_vulkan_ordinals_bypass_cuda_parent_visible_validation(self):
|
||||||
|
# Vulkan build on a CPU-only torch host: no CUDA parent-visible set and a
|
||||||
|
# zero physical count, yet a valid Vulkan ordinal must not be rejected as
|
||||||
|
# a CUDA physical id (issue #7239).
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {}, clear = True),
|
||||||
|
patch("utils.hardware.hardware.get_physical_gpu_count", return_value = 0),
|
||||||
|
):
|
||||||
|
# As a CUDA physical id, [0] is outside the empty parent-visible set.
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
resolve_requested_gpu_ids([0])
|
||||||
|
# As Vulkan ordinals, [0] and [0, 1] pass through unchanged.
|
||||||
|
self.assertEqual(resolve_requested_gpu_ids([0], is_vulkan = True), [0])
|
||||||
|
self.assertEqual(resolve_requested_gpu_ids([0, 1], is_vulkan = True), [0, 1])
|
||||||
|
# Malformed ordinals are still rejected.
|
||||||
|
with self.assertRaisesRegex(ValueError, "duplicate GPU IDs"):
|
||||||
|
resolve_requested_gpu_ids([0, 0], is_vulkan = True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "non-negative"):
|
||||||
|
resolve_requested_gpu_ids([-1], is_vulkan = True)
|
||||||
|
|
||||||
def test_apply_gpu_ids_only_updates_cuda_visible_devices(self):
|
def test_apply_gpu_ids_only_updates_cuda_visible_devices(self):
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
|
|
@ -853,6 +873,171 @@ class TestRouteErrors(unittest.TestCase):
|
||||||
|
|
||||||
self.assertIn("only supported on CUDA devices", str(exc_info.exception))
|
self.assertIn("only supported on CUDA devices", str(exc_info.exception))
|
||||||
|
|
||||||
|
def test_inference_route_resolves_gguf_gpu_ids(self):
|
||||||
|
# GGUF gpu_ids are now supported: /load routes them through the same
|
||||||
|
# resolution as non-GGUF loads (rejecting only genuinely invalid ids with
|
||||||
|
# the resolver's actionable message) rather than a blanket "not supported"
|
||||||
|
# reject, so /validate can stay consistent with /load (#7239).
|
||||||
|
import utils.hardware.hardware as hardware_mod
|
||||||
|
|
||||||
|
inference_route = _load_route_module(
|
||||||
|
"inference_route_module_for_gguf_gpu_ids_test",
|
||||||
|
"routes/inference.py",
|
||||||
|
)
|
||||||
|
request = LoadRequest(model_path = "unsloth/test.gguf", gpu_ids = [0, 1])
|
||||||
|
model_config = SimpleNamespace(
|
||||||
|
is_gguf = True,
|
||||||
|
is_lora = False,
|
||||||
|
gguf_hf_repo = None,
|
||||||
|
gguf_file = "/tmp/test.gguf",
|
||||||
|
gguf_mmproj_file = None,
|
||||||
|
gguf_variant = None,
|
||||||
|
identifier = "unsloth/test.gguf",
|
||||||
|
display_name = "unsloth/test.gguf",
|
||||||
|
is_vision = False,
|
||||||
|
is_audio = False,
|
||||||
|
audio_type = None,
|
||||||
|
has_audio_input = False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _fake_resolve(ids, is_vulkan = False):
|
||||||
|
raise ValueError("SENTINEL requested GPUs are outside the parent-visible set")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
inference_route,
|
||||||
|
"ModelConfig",
|
||||||
|
SimpleNamespace(from_identifier = lambda **_kwargs: model_config),
|
||||||
|
),
|
||||||
|
# Patch both the package re-export and the defining module so the stub
|
||||||
|
# fires no matter which import path the route uses.
|
||||||
|
patch("utils.hardware.resolve_requested_gpu_ids", _fake_resolve),
|
||||||
|
patch.object(hardware_mod, "resolve_requested_gpu_ids", _fake_resolve),
|
||||||
|
patch.object(
|
||||||
|
inference_route,
|
||||||
|
"_guard_chat_load_against_training",
|
||||||
|
return_value = None,
|
||||||
|
),
|
||||||
|
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||||
|
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||||
|
):
|
||||||
|
with self.assertRaises(HTTPException) as exc_info:
|
||||||
|
asyncio.run(
|
||||||
|
inference_route._load_model_impl(
|
||||||
|
request,
|
||||||
|
SimpleNamespace(
|
||||||
|
app = SimpleNamespace(
|
||||||
|
state = SimpleNamespace(llama_parallel_slots = 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
current_subject = "test-user",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# The selection was routed through resolution (not the old blanket reject).
|
||||||
|
self.assertEqual(exc_info.exception.status_code, 400)
|
||||||
|
self.assertIn("SENTINEL", exc_info.exception.detail)
|
||||||
|
self.assertNotIn("not supported for GGUF", exc_info.exception.detail)
|
||||||
|
|
||||||
|
def test_load_rejects_unavailable_vulkan_ordinal_before_training_guard(self):
|
||||||
|
inference_route = _load_route_module(
|
||||||
|
"inference_route_module_for_vulkan_preflight_test",
|
||||||
|
"routes/inference.py",
|
||||||
|
)
|
||||||
|
request = LoadRequest(model_path = "unsloth/test.gguf", gpu_ids = [99])
|
||||||
|
model_config = SimpleNamespace(
|
||||||
|
is_gguf = True,
|
||||||
|
is_lora = False,
|
||||||
|
gguf_hf_repo = None,
|
||||||
|
gguf_file = "/tmp/test.gguf",
|
||||||
|
gguf_mmproj_file = None,
|
||||||
|
gguf_variant = None,
|
||||||
|
identifier = "unsloth/test.gguf",
|
||||||
|
display_name = "unsloth/test.gguf",
|
||||||
|
is_vision = False,
|
||||||
|
is_audio = False,
|
||||||
|
audio_type = None,
|
||||||
|
has_audio_input = False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
inference_route,
|
||||||
|
"ModelConfig",
|
||||||
|
SimpleNamespace(from_identifier = lambda **_kwargs: model_config),
|
||||||
|
),
|
||||||
|
patch("utils.hardware.get_device", return_value = DeviceType.CUDA),
|
||||||
|
patch.object(inference_route, "_classify_diffusion_gguf", return_value = None),
|
||||||
|
patch.object(
|
||||||
|
inference_route.LlamaCppBackend,
|
||||||
|
"_is_vulkan_backend",
|
||||||
|
return_value = True,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
inference_route.LlamaCppBackend,
|
||||||
|
"_find_llama_server_binary",
|
||||||
|
return_value = "/tmp/llama-server",
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
inference_route.LlamaCppBackend,
|
||||||
|
"_get_gpu_memory",
|
||||||
|
return_value = [(0, 8 * 1024**3, 16 * 1024**3)],
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
inference_route,
|
||||||
|
"_guard_chat_load_against_training",
|
||||||
|
return_value = None,
|
||||||
|
) as training_guard,
|
||||||
|
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||||
|
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||||
|
):
|
||||||
|
with self.assertRaises(HTTPException) as exc_info:
|
||||||
|
asyncio.run(
|
||||||
|
inference_route._load_model_impl(
|
||||||
|
request,
|
||||||
|
SimpleNamespace(
|
||||||
|
app = SimpleNamespace(
|
||||||
|
state = SimpleNamespace(llama_parallel_slots = 1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
current_subject = "test-user",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(exc_info.exception.status_code, 400)
|
||||||
|
self.assertIn("Vulkan GPU ordinal(s) [99]", exc_info.exception.detail)
|
||||||
|
training_guard.assert_not_called()
|
||||||
|
|
||||||
|
def test_vulkan_ordinals_are_allowed_on_xpu_hosts(self):
|
||||||
|
import utils.hardware.hardware as hardware_mod
|
||||||
|
|
||||||
|
inference_route = _load_route_module(
|
||||||
|
"inference_route_module_for_xpu_vulkan_test",
|
||||||
|
"routes/inference.py",
|
||||||
|
)
|
||||||
|
config = SimpleNamespace(is_gguf = True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("utils.hardware.get_device", return_value = DeviceType.XPU),
|
||||||
|
patch.object(
|
||||||
|
inference_route.LlamaCppBackend,
|
||||||
|
"_is_vulkan_backend",
|
||||||
|
return_value = True,
|
||||||
|
),
|
||||||
|
patch.object(inference_route, "_classify_diffusion_gguf", return_value = False),
|
||||||
|
patch.object(hardware_mod, "resolve_requested_gpu_ids", return_value = [0, 1]),
|
||||||
|
patch.object(
|
||||||
|
inference_route.LlamaCppBackend,
|
||||||
|
"_find_llama_server_binary",
|
||||||
|
return_value = None,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
resolved = asyncio.run(
|
||||||
|
inference_route._resolve_gguf_gpu_ids_for_request(config, [1, 0])
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(resolved, [0, 1])
|
||||||
|
|
||||||
def test_inference_route_validates_gpu_ids_for_gguf(self):
|
def test_inference_route_validates_gpu_ids_for_gguf(self):
|
||||||
# gpu_ids is now SUPPORTED for GGUF (the GPU picker), but still
|
# gpu_ids is now SUPPORTED for GGUF (the GPU picker), but still
|
||||||
# validated: a rejected pick surfaces as a clean 400, not the old
|
# validated: a rejected pick surfaces as a clean 400, not the old
|
||||||
|
|
@ -861,7 +1046,7 @@ class TestRouteErrors(unittest.TestCase):
|
||||||
import utils.hardware.hardware as hardware_mod
|
import utils.hardware.hardware as hardware_mod
|
||||||
|
|
||||||
inference_route = _load_route_module(
|
inference_route = _load_route_module(
|
||||||
"inference_route_module_for_gguf_gpu_ids_test",
|
"inference_route_module_for_gguf_gpu_ids_test2",
|
||||||
"routes/inference.py",
|
"routes/inference.py",
|
||||||
)
|
)
|
||||||
request = LoadRequest(model_path = "unsloth/test.gguf", gpu_ids = [0, 1])
|
request = LoadRequest(model_path = "unsloth/test.gguf", gpu_ids = [0, 1])
|
||||||
|
|
@ -886,6 +1071,17 @@ class TestRouteErrors(unittest.TestCase):
|
||||||
"ModelConfig",
|
"ModelConfig",
|
||||||
SimpleNamespace(from_identifier = lambda **_kwargs: model_config),
|
SimpleNamespace(from_identifier = lambda **_kwargs: model_config),
|
||||||
),
|
),
|
||||||
|
# Patch both the package re-export and the defining module so the stub
|
||||||
|
# fires no matter which import path the route uses.
|
||||||
|
patch(
|
||||||
|
"utils.hardware.resolve_requested_gpu_ids",
|
||||||
|
side_effect = ValueError("Invalid gpu_ids [0, 1]: rejected by test"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
hardware_mod,
|
||||||
|
"resolve_requested_gpu_ids",
|
||||||
|
side_effect = ValueError("Invalid gpu_ids [0, 1]: rejected by test"),
|
||||||
|
),
|
||||||
patch.object(
|
patch.object(
|
||||||
inference_route,
|
inference_route,
|
||||||
"_guard_chat_load_against_training",
|
"_guard_chat_load_against_training",
|
||||||
|
|
@ -893,11 +1089,6 @@ class TestRouteErrors(unittest.TestCase):
|
||||||
),
|
),
|
||||||
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
patch.object(inference_route.asyncio, "to_thread", new = _inline_to_thread),
|
||||||
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
patch.object(inference_route, "_hf_offline_if_dns_dead", nullcontext),
|
||||||
patch.object(
|
|
||||||
hardware_mod,
|
|
||||||
"resolve_requested_gpu_ids",
|
|
||||||
side_effect = ValueError("Invalid gpu_ids [0, 1]: rejected by test"),
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
with self.assertRaises(HTTPException) as exc_info:
|
with self.assertRaises(HTTPException) as exc_info:
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
|
|
|
||||||
|
|
@ -1639,17 +1639,34 @@ def get_parent_visible_gpu_ids() -> list[int]:
|
||||||
return list(parent_visible_ids) if parent_visible_ids is not None else []
|
return list(parent_visible_ids) if parent_visible_ids is not None else []
|
||||||
|
|
||||||
|
|
||||||
def resolve_requested_gpu_ids(gpu_ids: Optional[list[int]]) -> list[int]:
|
def resolve_requested_gpu_ids(
|
||||||
|
gpu_ids: Optional[list[int]], *, is_vulkan: bool = False
|
||||||
|
) -> list[int]:
|
||||||
parent_visible_spec = _get_parent_visible_gpu_spec()
|
parent_visible_spec = _get_parent_visible_gpu_spec()
|
||||||
parent_visible_ids = get_parent_visible_gpu_ids()
|
parent_visible_ids = get_parent_visible_gpu_ids()
|
||||||
physical_gpu_count = get_physical_gpu_count()
|
physical_gpu_count = get_physical_gpu_count()
|
||||||
|
|
||||||
if gpu_ids is None:
|
if gpu_ids is None:
|
||||||
return parent_visible_ids
|
return [] if is_vulkan else parent_visible_ids
|
||||||
|
|
||||||
requested_ids = list(gpu_ids)
|
requested_ids = list(gpu_ids)
|
||||||
if len(requested_ids) == 0:
|
if len(requested_ids) == 0:
|
||||||
return parent_visible_ids
|
return [] if is_vulkan else parent_visible_ids
|
||||||
|
|
||||||
|
if is_vulkan:
|
||||||
|
# A Vulkan build selects by ggml Vulkan ordinal (--device VulkanN), a separate
|
||||||
|
# index space from CUDA/ROCm ids that may be empty under CPU-only torch. The
|
||||||
|
# CUDA parent-visible / physical-count checks below do not apply; only reject
|
||||||
|
# malformed ordinals (issue #7239).
|
||||||
|
if len(set(requested_ids)) != len(requested_ids):
|
||||||
|
raise ValueError(f"Invalid gpu_ids {requested_ids}: duplicate GPU IDs are not allowed.")
|
||||||
|
negative_ids = [gpu_id for gpu_id in requested_ids if gpu_id < 0]
|
||||||
|
if negative_ids:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid gpu_ids {requested_ids}: GPU IDs must be non-negative. "
|
||||||
|
f"Rejected IDs: {negative_ids}."
|
||||||
|
)
|
||||||
|
return requested_ids
|
||||||
|
|
||||||
if not parent_visible_spec["supports_explicit_gpu_ids"]:
|
if not parent_visible_spec["supports_explicit_gpu_ids"]:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
|
|
@ -2193,12 +2210,13 @@ def auto_select_gpu_ids(
|
||||||
metadata["selection_mode"] = "auto"
|
metadata["selection_mode"] = "auto"
|
||||||
metadata["selected_gpu_ids"] = selected
|
metadata["selected_gpu_ids"] = selected
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Selected GPUs automatically",
|
"Selected GPUs automatically: model=%s selected=%s usable_gb=%s "
|
||||||
model_name = model_name,
|
"required_gb=%s multi_gpu_overhead=%s",
|
||||||
selected_gpu_ids = selected,
|
model_name,
|
||||||
usable_gb = metadata["usable_gb"],
|
selected,
|
||||||
required_gb = metadata.get("required_gb"),
|
metadata["usable_gb"],
|
||||||
multi_gpu_overhead = multi_gpu_overhead,
|
metadata.get("required_gb"),
|
||||||
|
multi_gpu_overhead,
|
||||||
)
|
)
|
||||||
return selected, metadata
|
return selected, metadata
|
||||||
|
|
||||||
|
|
@ -2214,12 +2232,13 @@ def auto_select_gpu_ids(
|
||||||
metadata["usable_gb"] = round(fallback_usable, 3)
|
metadata["usable_gb"] = round(fallback_usable, 3)
|
||||||
metadata["selected_gpu_ids"] = fallback_all
|
metadata["selected_gpu_ids"] = fallback_all
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Falling back to all visible GPUs -- model may not fit",
|
"Falling back to all visible GPUs; model may not fit: model=%s "
|
||||||
model_name = model_name,
|
"selected=%s usable_gb=%s required_gb=%s multi_gpu_overhead=%s",
|
||||||
selected_gpu_ids = fallback_all,
|
model_name,
|
||||||
usable_gb = metadata["usable_gb"],
|
fallback_all,
|
||||||
required_gb = metadata.get("required_gb"),
|
metadata["usable_gb"],
|
||||||
multi_gpu_overhead = multi_gpu_overhead,
|
metadata.get("required_gb"),
|
||||||
|
multi_gpu_overhead,
|
||||||
)
|
)
|
||||||
return fallback_all, metadata
|
return fallback_all, metadata
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,9 @@ export function applyActiveModelStatusToStore(
|
||||||
incomingGpuMode === "manual" ? (status.n_cpu_moe ?? null) : null;
|
incomingGpuMode === "manual" ? (status.n_cpu_moe ?? null) : null;
|
||||||
const incomingSplit =
|
const incomingSplit =
|
||||||
incomingGpuMode === "manual" ? (status.tensor_split ?? null) : null;
|
incomingGpuMode === "manual" ? (status.tensor_split ?? null) : null;
|
||||||
const incomingGpuIds = status.is_gguf ? (status.gpu_ids ?? null) : null;
|
const incomingGpuIds = status.is_gguf
|
||||||
|
? (status.requested_gpu_ids ?? status.gpu_ids ?? null)
|
||||||
|
: null;
|
||||||
const gpuStatusChanged =
|
const gpuStatusChanged =
|
||||||
prevState.loadedGpuMemoryMode !== incomingGpuMode ||
|
prevState.loadedGpuMemoryMode !== incomingGpuMode ||
|
||||||
prevState.loadedGpuLayers !== incomingGpuLayers ||
|
prevState.loadedGpuLayers !== incomingGpuLayers ||
|
||||||
|
|
|
||||||
|
|
@ -605,6 +605,7 @@ export function loadedGpuMemoryFields(resp: {
|
||||||
n_layers?: number | null;
|
n_layers?: number | null;
|
||||||
n_moe_layers?: number;
|
n_moe_layers?: number;
|
||||||
gpu_ids?: number[] | null;
|
gpu_ids?: number[] | null;
|
||||||
|
requested_gpu_ids?: number[] | null;
|
||||||
}) {
|
}) {
|
||||||
// GPU-memory state is meaningful only for a GGUF chat load. A non-GGUF response
|
// GPU-memory state is meaningful only for a GGUF chat load. A non-GGUF response
|
||||||
// still carries gpu_memory_mode (its default "auto" is serialized), so gate on
|
// still carries gpu_memory_mode (its default "auto" is serialized), so gate on
|
||||||
|
|
@ -631,7 +632,9 @@ export function loadedGpuMemoryFields(resp: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const mode = resp.gpu_memory_mode ?? "auto";
|
const mode = resp.gpu_memory_mode ?? "auto";
|
||||||
const gpuIds = resp.gpu_ids ?? null;
|
// Keep the user's placement pool editable across status/load hydration.
|
||||||
|
// gpu_ids remains the effective fitted subset for diagnostics.
|
||||||
|
const gpuIds = resp.requested_gpu_ids ?? resp.gpu_ids ?? null;
|
||||||
// Layer/MoE/split knobs apply (and are reported) only in manual mode; in auto
|
// Layer/MoE/split knobs apply (and are reported) only in manual mode; in auto
|
||||||
// the server ignores them, so don't seed the loaded baseline or the editable
|
// the server ignores them, so don't seed the loaded baseline or the editable
|
||||||
// knobs with values it never applied. In manual, the server reports gpu_layers
|
// knobs with values it never applied. In manual, the server reports gpu_layers
|
||||||
|
|
@ -669,7 +672,7 @@ export function loadedGpuMemoryFields(resp: {
|
||||||
ggufLayerCount: resp.n_layers ?? null,
|
ggufLayerCount: resp.n_layers ?? null,
|
||||||
// MoE expert-layer count: the n_cpu_moe slider max, and 0 hides the slider.
|
// MoE expert-layer count: the n_cpu_moe slider max, and 0 hides the slider.
|
||||||
moeLayerCount: resp.n_moe_layers ?? null,
|
moeLayerCount: resp.n_moe_layers ?? null,
|
||||||
// The picker reflects what loaded (the request sent the user's pick).
|
// The picker reflects the requested placement pool, not a fitted subset.
|
||||||
selectedGpuIds: gpuIds,
|
selectedGpuIds: gpuIds,
|
||||||
loadedGpuIds: gpuIds,
|
loadedGpuIds: gpuIds,
|
||||||
...manualKnobs,
|
...manualKnobs,
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,10 @@ export interface LoadModelResponse {
|
||||||
n_layers?: number | null;
|
n_layers?: number | null;
|
||||||
/** Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not MoE. */
|
/** Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not MoE. */
|
||||||
n_moe_layers?: number;
|
n_moe_layers?: number;
|
||||||
|
/** Effective GPU placement after fit-time narrowing. */
|
||||||
gpu_ids?: number[] | null;
|
gpu_ids?: number[] | null;
|
||||||
|
/** User-requested GPU placement pool before fit-time narrowing. */
|
||||||
|
requested_gpu_ids?: number[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnloadModelRequest {
|
export interface UnloadModelRequest {
|
||||||
|
|
@ -240,7 +243,10 @@ export interface InferenceStatusResponse {
|
||||||
/** n_ctx the active GGUF load was invoked with (0 = Auto); re-seeds a
|
/** n_ctx the active GGUF load was invoked with (0 = Auto); re-seeds a
|
||||||
* Manual + Auto-layers context pin on hydration. Null for non-GGUF. */
|
* Manual + Auto-layers context pin on hydration. Null for non-GGUF. */
|
||||||
requested_context_length?: number | null;
|
requested_context_length?: number | null;
|
||||||
|
/** Effective GPU placement after fit-time narrowing. */
|
||||||
gpu_ids?: number[] | null;
|
gpu_ids?: number[] | null;
|
||||||
|
/** User-requested GPU placement pool before fit-time narrowing. */
|
||||||
|
requested_gpu_ids?: number[] | null;
|
||||||
n_layers?: number | null;
|
n_layers?: number | null;
|
||||||
/** Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not MoE. */
|
/** Model's MoE expert-layer count (the n_cpu_moe ceiling); 0 if not MoE. */
|
||||||
n_moe_layers?: number;
|
n_moe_layers?: number;
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,19 @@ def test_active_model_config_round_trips_gpu_fields():
|
||||||
assert "export function gpuFieldsSignature" in shared
|
assert "export function gpuFieldsSignature" in shared
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_picker_round_trips_requested_pool_not_fitted_subset():
|
||||||
|
"""A GGUF fit may narrow [0, 1] to [0], but load/status hydration must keep
|
||||||
|
[0, 1] as the editable pool so a later reload can grow back onto GPU 1."""
|
||||||
|
types = _read("features/chat/types/api.ts")
|
||||||
|
assert types.count("requested_gpu_ids?: number[] | null") >= 2
|
||||||
|
|
||||||
|
store = _read("features/chat/stores/chat-runtime-store.ts")
|
||||||
|
assert "resp.requested_gpu_ids ?? resp.gpu_ids ?? null" in store
|
||||||
|
|
||||||
|
status = _read("features/chat/lib/apply-inference-status-to-store.ts")
|
||||||
|
assert "status.requested_gpu_ids ?? status.gpu_ids ?? null" in status
|
||||||
|
|
||||||
|
|
||||||
def test_compare_load_uses_each_models_gpu_config():
|
def test_compare_load_uses_each_models_gpu_config():
|
||||||
src = _read("features/chat/shared-composer.tsx")
|
src = _read("features/chat/shared-composer.tsx")
|
||||||
assert "ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode" in src
|
assert "ownConfig.gpuMemoryMode ?? compareLoadKnobs.gpuMemoryMode" in src
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue