Six review findings, three of them evict-then-fail orderings: - The chat load reclaimed the GPU without telling the arbiter it existed. A chat load holds no llama-server process until its GGUF has downloaded, which is minutes, so a competing Images/Video acquire in that window found nothing to cancel, took the GPU, and the chat load then spawned onto the same device. It now registers an in-flight marker through acquire_for's register hook (under the arbiter lock, as the image and video loads do), the evictor cancels a marked load, and the route undoes itself if ownership moved while it loaded. - The Hub-download conflict check ran after that handoff, so a GGUF the download manager already owns destroyed the resident Images/Video pipeline and then 409'd, having loaded nothing. It moves above the handoff, together with the marker it handshakes with. - The image load released the engine router's transition lock before registering the load, so a second load choosing the other engine could unload the still-idle engine this one captured; the load then landed on a deactivated engine, where generate, status, unload and the arbiter's evictor can no longer reach it. Registration now happens under that lock and refuses if the engine changed. - Training a DiT family on a host with no GPU was accepted: nf4 is not a CPU fallback, its 4-bit load goes through bitsandbytes, which requires CUDA, XPU or MPS. The start unloaded the working Images pipeline, pulled the text encoders, and only then died in the child. Rejected before the teardown now, and /info stops advertising a precision that always 400s. SDXL keeps its documented fp32-on-CPU path. - Both diffusion pages kept the routed-pick marker forever, so re-picking the same checkpoint (after chat evicted it) neither loaded nor cleared the query string. The marker is released once the query is gone. The Images key also carried a stray NUL byte, which made the file read as binary to grep and other tooling. - diffusers dropped Python 3.9 in 0.38, so the unconditional >=0.39.0 pin left pip no candidate at all on 3.9 and made every install that composes the huggingface extras unresolvable there. The floor is conditional now. Also fixes tests that were already red on the branch: two hand-built request fakes had gone stale against fields this branch added, and the handoff-ordering test only failed on a host with fewer than two GPUs.
251 lines
8.4 KiB
Python
251 lines
8.4 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Unit tests for the single-GPU arbiter.
|
|
|
|
The real evictors (which tear down live backends) are replaced with recorders, so
|
|
these verify only the ownership/eviction sequencing — no torch, GPU, or subprocess.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
import core.inference.gpu_arbiter as arb
|
|
|
|
|
|
@pytest.fixture
|
|
def calls(monkeypatch):
|
|
recorded: list[str] = []
|
|
monkeypatch.setattr(arb, "_owner", None)
|
|
monkeypatch.setitem(arb._EVICTORS, arb.CHAT, lambda: recorded.append("evict-chat"))
|
|
monkeypatch.setitem(arb._EVICTORS, arb.DIFFUSION, lambda: recorded.append("evict-diffusion"))
|
|
return recorded
|
|
|
|
|
|
def test_first_acquire_evicts_nothing(calls):
|
|
arb.acquire_for(arb.CHAT)
|
|
assert calls == []
|
|
assert arb.current_owner() == arb.CHAT
|
|
|
|
|
|
def test_diffusion_load_evicts_chat(calls):
|
|
arb.acquire_for(arb.CHAT)
|
|
arb.acquire_for(arb.DIFFUSION)
|
|
assert calls == ["evict-chat"]
|
|
assert arb.current_owner() == arb.DIFFUSION
|
|
|
|
|
|
def test_chat_load_evicts_diffusion(calls):
|
|
arb.acquire_for(arb.DIFFUSION)
|
|
arb.acquire_for(arb.CHAT)
|
|
assert calls == ["evict-diffusion"]
|
|
assert arb.current_owner() == arb.CHAT
|
|
|
|
|
|
def test_reacquiring_same_owner_does_not_evict(calls):
|
|
arb.acquire_for(arb.CHAT)
|
|
arb.acquire_for(arb.CHAT)
|
|
assert calls == []
|
|
assert arb.current_owner() == arb.CHAT
|
|
|
|
|
|
def test_release_clears_owner(calls):
|
|
arb.acquire_for(arb.DIFFUSION)
|
|
arb.release(arb.DIFFUSION)
|
|
assert arb.current_owner() is None
|
|
# A subsequent chat load then has nothing to evict.
|
|
arb.acquire_for(arb.CHAT)
|
|
assert calls == []
|
|
|
|
|
|
def test_release_by_non_owner_is_noop(calls):
|
|
arb.acquire_for(arb.CHAT)
|
|
arb.release(arb.DIFFUSION)
|
|
assert arb.current_owner() == arb.CHAT
|
|
|
|
|
|
def test_unknown_owner_raises(calls):
|
|
with pytest.raises(ValueError):
|
|
arb.acquire_for("gpu")
|
|
|
|
|
|
def test_evict_chat_unloads_a_still_loading_chat_backend(monkeypatch):
|
|
# A chat model still starting up is is_active (process exists) but not yet
|
|
# is_loaded (healthy). Eviction must still unload it, or the load would keep
|
|
# allocating VRAM after the GPU was handed to diffusion.
|
|
import core.inference as core_inference
|
|
import routes.inference as routes_inference
|
|
|
|
unloaded: list[bool] = []
|
|
|
|
class _FakeLlama:
|
|
is_active = True
|
|
is_loaded = False # still loading: skipped if eviction gates on is_loaded
|
|
|
|
def unload_model(self):
|
|
unloaded.append(True)
|
|
|
|
def _wait_for_vram_settle(self, *, since_kill):
|
|
pass
|
|
|
|
class _FakeOrchestrator:
|
|
active_model_name = None
|
|
|
|
def unload_model(self, name):
|
|
pass
|
|
|
|
def _shutdown_subprocess(self, timeout = 5.0):
|
|
pass
|
|
|
|
monkeypatch.setattr(routes_inference, "get_llama_cpp_backend", lambda: _FakeLlama())
|
|
monkeypatch.setattr(core_inference, "get_inference_backend", lambda: _FakeOrchestrator())
|
|
|
|
arb._evict_chat()
|
|
|
|
assert unloaded == [True] # still-loading chat backend was unloaded, not skipped
|
|
|
|
|
|
def test_release_if_drops_only_when_predicate_true(calls):
|
|
arb.acquire_for(arb.DIFFUSION)
|
|
# Predicate false -> ownership kept.
|
|
assert arb.release_if(arb.DIFFUSION, lambda: False) is False
|
|
assert arb.current_owner() == arb.DIFFUSION
|
|
# Predicate true -> ownership dropped.
|
|
assert arb.release_if(arb.DIFFUSION, lambda: True) is True
|
|
assert arb.current_owner() is None
|
|
|
|
|
|
def test_release_if_by_non_owner_is_noop(calls):
|
|
arb.acquire_for(arb.CHAT)
|
|
# Predicate is never even consulted for a non-owner; ownership is untouched.
|
|
consulted: list[bool] = []
|
|
assert arb.release_if(arb.DIFFUSION, lambda: consulted.append(True) or True) is False
|
|
assert consulted == []
|
|
assert arb.current_owner() == arb.CHAT
|
|
|
|
|
|
def test_release_if_predicate_sees_a_reregistered_same_owner_load(calls):
|
|
# The race release_if closes: a slow unload's predicate reports a load now in flight (a
|
|
# re-registered same-owner load), so ownership must stay with DIFFUSION.
|
|
arb.acquire_for(arb.DIFFUSION)
|
|
loading = {"in_flight": True}
|
|
assert arb.release_if(arb.DIFFUSION, lambda: not loading["in_flight"]) is False
|
|
assert arb.current_owner() == arb.DIFFUSION
|
|
|
|
|
|
def test_register_runs_under_ownership_and_returns_result(calls):
|
|
# A register callback runs after ownership transfers (owner already set) and its
|
|
# return value is forwarded -- the route uses this to register the in-flight load.
|
|
seen_owner: list = []
|
|
|
|
def register():
|
|
seen_owner.append(arb.current_owner())
|
|
return "status-dict"
|
|
|
|
result = arb.acquire_for(arb.DIFFUSION, register)
|
|
assert result == "status-dict"
|
|
assert seen_owner == [arb.DIFFUSION]
|
|
assert arb.current_owner() == arb.DIFFUSION
|
|
|
|
|
|
def test_register_failure_leaves_ownership_in_place(calls):
|
|
# A failing register (e.g. begin_load reporting a load already in progress) propagates
|
|
# but must not drop ownership -- the prior handoff (chat already evicted) stands.
|
|
arb.acquire_for(arb.CHAT)
|
|
|
|
def register():
|
|
raise RuntimeError("A diffusion load is already in progress.")
|
|
|
|
with pytest.raises(RuntimeError):
|
|
arb.acquire_for(arb.DIFFUSION, register)
|
|
assert calls == ["evict-chat"]
|
|
assert arb.current_owner() == arb.DIFFUSION
|
|
|
|
|
|
def test_competing_acquire_blocks_until_register_completes(monkeypatch):
|
|
# While DIFFUSION registers its load, a competing VIDEO acquire must block (not evict) until
|
|
# the load is in-flight; holding the lock across register makes eviction never race it.
|
|
import threading
|
|
import time
|
|
|
|
monkeypatch.setattr(arb, "_owner", None)
|
|
evicted: list = []
|
|
monkeypatch.setitem(arb._EVICTORS, arb.DIFFUSION, lambda: evicted.append("evict-diffusion"))
|
|
monkeypatch.setitem(arb._EVICTORS, arb.VIDEO, lambda: evicted.append("evict-video"))
|
|
|
|
in_register = threading.Event()
|
|
release_register = threading.Event()
|
|
|
|
def register():
|
|
in_register.set()
|
|
# Hold the arbiter lock here; a competing acquire_for(VIDEO) must block until we return.
|
|
assert release_register.wait(2.0)
|
|
return "loading"
|
|
|
|
loader = threading.Thread(target = lambda: arb.acquire_for(arb.DIFFUSION, register))
|
|
loader.start()
|
|
assert in_register.wait(2.0)
|
|
|
|
competitor_done = threading.Event()
|
|
threading.Thread(
|
|
target = lambda: (arb.acquire_for(arb.VIDEO), competitor_done.set()),
|
|
).start()
|
|
|
|
# The competitor cannot evict DIFFUSION while register still holds the lock.
|
|
time.sleep(0.1)
|
|
assert evicted == []
|
|
assert not competitor_done.is_set()
|
|
|
|
# Let register finish; ownership is now safely registered, so the competitor proceeds.
|
|
release_register.set()
|
|
loader.join(2.0)
|
|
assert competitor_done.wait(2.0)
|
|
assert evicted == ["evict-diffusion"]
|
|
assert arb.current_owner() == arb.VIDEO
|
|
|
|
|
|
def test_evict_chat_cancels_a_chat_load_that_has_not_spawned_yet(monkeypatch):
|
|
# An HF chat load has no llama-server process until its GGUF finished downloading, which is
|
|
# minutes. Gating only on is_active let the evictor find nothing to cancel, grant the GPU to
|
|
# the image/video load, and the chat load then spawned onto the same device: two big models
|
|
# allocating at once. The in-flight marker is what makes that load cancellable.
|
|
import core.inference as core_inference
|
|
import routes.inference as routes_inference
|
|
from core.inference.llama_cpp import chat_load_in_flight
|
|
|
|
unloaded: list[bool] = []
|
|
|
|
class _FakeLlama:
|
|
is_active = False # nothing spawned yet: the download is still running
|
|
is_loaded = False
|
|
|
|
def unload_model(self):
|
|
unloaded.append(True)
|
|
|
|
def _wait_for_vram_settle(self, *, since_kill):
|
|
pass
|
|
|
|
class _FakeOrchestrator:
|
|
active_model_name = None
|
|
|
|
def unload_model(self, name):
|
|
pass
|
|
|
|
def _shutdown_subprocess(self, timeout = 5.0):
|
|
pass
|
|
|
|
monkeypatch.setattr(routes_inference, "get_llama_cpp_backend", lambda: _FakeLlama())
|
|
monkeypatch.setattr(core_inference, "get_inference_backend", lambda: _FakeOrchestrator())
|
|
|
|
# No load in flight: nothing to cancel.
|
|
arb._evict_chat()
|
|
assert unloaded == []
|
|
|
|
with chat_load_in_flight():
|
|
arb._evict_chat()
|
|
assert unloaded == [True]
|
|
|
|
# The marker is released with the load, so a later eviction is a no-op again.
|
|
arb._evict_chat()
|
|
assert unloaded == [True]
|