unsloth/studio/backend/tests/test_gpu_arbiter.py
Daniel Han bc00a8e797 Serialize the GPU handoffs, gate DiT training on a GPU, and keep 3.9 installable
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.
2026-07-26 14:46:02 +00:00

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]