Arbitrate over any number of tenants by priority

Completes the generalisation. Classification and release were already data; the
decision loop was still two hardcoded rules -- yield Ollama when ComfyUI is busy,
purge ComfyUI when Ollama is starved -- which could not express a third participant.

plan_release() works from the registry instead. A busy tenant that cannot reach its
declared needs_vram_gb is starved, and the memory comes from idle reclaimable tenants
below it in priority, lowest first, stopping once enough is freed. Tenants that cannot
be released are named as blockers rather than passed over, so an impossible plan says
which process is in the way. The plan is returned before being acted on, so the
decision is testable and is logged before anything is released. Idle release is now
per-tenant too, replacing the ComfyUI-specific purge timer.

Two bugs found by running it against the live machine rather than only in tests:

Starvation was measured against free VRAM alone, so a tenant working perfectly well on
13 GB was flagged as demanding simply because little was left over -- which is the
normal state of a busy GPU, and would have caused pointless releases from everything
else. A tenant is starved only if it cannot reach what it needs counting what it
already holds.

Fields added to the tenant schema were silently absent from the config already written
to disk, so needs_vram_gb defaulted to 0 and starvation could never trigger for the two
tenants that mattered. Shipped defaults are now merged into an existing config on load,
with explicit user values still winning.

Tests: 242 (was 231), including a three-application contention case -- the property the
hardcoded pair of rules could not express.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-09-07 14:47:32 -07:00
parent 1bcfbb2335
commit 4a38cd68b3
4 changed files with 319 additions and 2 deletions

View File

@@ -172,3 +172,112 @@ class TestPriority:
prios = [r["priority"] for r in rows]
assert prios == sorted(prios, reverse=True)
assert all("reclaimable" in r for r in rows)
class TestReleasePlanning:
"""Deciding who gives up VRAM, generically over any number of applications.
The two-application version was a pair of hardcoded rules -- yield Ollama when
ComfyUI is busy, purge ComfyUI when Ollama is starved -- which could not express a
third participant at all.
"""
def _state(self, **overrides):
base = [
{"name": "desktop", "priority": 90, "vram_gb": 0.01, "busy": False,
"reclaimable": False},
{"name": "stt-relay", "priority": 70, "vram_gb": 0.8, "busy": False,
"reclaimable": False},
{"name": "ollama", "priority": 60, "vram_gb": 0.0, "busy": True,
"reclaimable": True},
{"name": "comfyui", "priority": 50, "vram_gb": 7.0, "busy": False,
"reclaimable": True},
]
for s in base:
s.update(overrides.get(s["name"], {}))
return base
def test_a_tenant_already_holding_what_it_needs_is_not_starved(self):
# A busy GPU has little free by definition. Comparing free VRAM alone flagged a
# tenant working fine on 13 GB as demanding, which would have caused pointless
# releases from everything else.
state = self._state(ollama={"vram_gb": 13.0})
plan = T.plan_release("ollama", state, free_gb=1.5, needed_gb=4.0)
assert plan["release"] == []
assert "already free" in plan["reason"]
def test_starved_tenant_reclaims_from_the_idle_one_below_it(self):
plan = T.plan_release("ollama", self._state(), free_gb=1.5, needed_gb=14.9)
assert plan["release"] == ["comfyui"]
def test_a_busy_tenant_is_never_a_victim(self):
state = self._state(comfyui={"busy": True})
plan = T.plan_release("ollama", state, free_gb=1.5, needed_gb=14.9)
assert plan["release"] == []
assert any(b["name"] == "comfyui" and b["why"] == "busy"
for b in plan["blockers"])
def test_unreclaimable_tenants_are_named_as_blockers_not_ignored(self):
# The user needs to know a third-party process is what stands in the way.
plan = T.plan_release("ollama", self._state(), free_gb=0.0, needed_gb=15.5)
blockers = {b["name"]: b["why"] for b in plan["blockers"]}
assert blockers["stt-relay"] == "declares no release mechanism"
assert plan["possible"] is False
def test_higher_priority_tenants_are_not_victims(self):
state = self._state(comfyui={"priority": 99, "busy": False})
plan = T.plan_release("ollama", state, free_gb=1.0, needed_gb=14.9)
assert "comfyui" not in plan["release"]
def test_lowest_priority_is_released_first(self):
state = self._state() + [
{"name": "batch", "priority": 10, "vram_gb": 3.0, "busy": False,
"reclaimable": True}]
plan = T.plan_release("ollama", state, free_gb=0.0, needed_gb=5.0)
assert plan["release"][0] == "batch"
def test_releases_only_as_many_tenants_as_needed(self):
state = self._state() + [
{"name": "batch", "priority": 10, "vram_gb": 9.0, "busy": False,
"reclaimable": True}]
plan = T.plan_release("ollama", state, free_gb=0.0, needed_gb=8.0)
assert plan["release"] == ["batch"] # 9 GB covers it; comfyui is left alone
def test_three_applications_can_all_participate(self):
# The property the hardcoded pair of rules could not express.
state = [
{"name": "llm", "priority": 60, "vram_gb": 0.0, "busy": True,
"reclaimable": True},
{"name": "diffusion", "priority": 50, "vram_gb": 4.0, "busy": False,
"reclaimable": True},
{"name": "trainer", "priority": 40, "vram_gb": 5.0, "busy": False,
"reclaimable": True},
]
plan = T.plan_release("llm", state, free_gb=0.0, needed_gb=9.0)
assert set(plan["release"]) == {"trainer", "diffusion"}
assert plan["possible"] is True
def test_unknown_tenant_is_rejected_cleanly(self):
plan = T.plan_release("nope", self._state(), free_gb=0.0, needed_gb=1.0)
assert plan["possible"] is False and plan["release"] == []
class TestConfigUpgrade:
def test_fields_added_later_are_merged_into_an_existing_config(self, cfg):
# A config written before needs_vram_gb existed must not silently lose the
# behaviour that field controls.
cfg.write_text(json.dumps([{
"name": "ollama",
"match": {"names": ["ollama"]},
}]))
t = T.get_tenant("ollama")
assert t.needs_vram_gb > 0
assert t.release.type == "http_post"
def test_explicit_user_values_still_win_over_defaults(self, cfg):
cfg.write_text(json.dumps([{
"name": "ollama", "priority": 5, "needs_vram_gb": 99.0,
"match": {"names": ["ollama"]},
}]))
t = T.get_tenant("ollama")
assert t.priority == 5 and t.needs_vram_gb == 99.0