Three phases that only make sense together: a job that names a worker, a worker that reports honestly what it can actually run, and the small defects that made both lie. **Pinning** (Phase 1). `pinned_worker_id` is now honoured in both places that choose a worker — `eligible_workers` and `select_worker` build independent lists, so applying it to one silently leaked work onto whichever machine was least busy. The pin persists across a restart via an additive column, deliberately not alembic (justified in the code, per the precedent already in db.py): quitting mid-render used to drop it without a word. `max_attempts=1` was rejected as the mechanism — it makes the FIRST failure terminal, including the penalty-free ones a stale advisory view produces routinely. Cancel now actually reaches the worker. `WorkerServicer.cancel` had zero callers, so cancelling released the slot while the GPU thread kept running, and a late result could resurrect the task as COMPLETED — `commit_result` assigned that state directly, bypassing the transition table where CANCELLED is terminal by construction. **Honest capabilities** (Phase 4). A worker now probes whether weights are actually present, and a job stops BEFORE dispatch with a typed 409 naming the model and the machine, instead of failing mid-task. The probe fails OPEN: `is_cached`/`cache_is_complete` cannot see a user-managed clone outside the HF layout, so only a positive "absent" refuses. Refusing an engine that works today would break the compatibility promise. `pool.supports` deliberately still ignores `downloaded` — had it not, the scheduler would drop the worker and answer with a terminal NO_CAPABLE_WORKER, which tells the user to check their install when the truth is one download away. The frontend no longer offers "Report this bug" for that state; it offers the download. Catalog tags resolve against the TARGET's OS/arch/backend, not this machine's. From a Mac control plane, a CUDA worker's model list was showing the mlx-community repos it cannot run and hiding the ones it needs. **And the quiet ones** (Phase 0 leftovers): a model's human label rides its own proto field so renaming it cannot orphan breaker history; an empty model_id no longer forks the capacity slot key into two slots for one model; the idle sweep cannot evict an engine out from under a live LOCAL render. Verified on real hardware, which is the only verification that has ever caught anything here: 2025 characters, default settings, routed to an RTX 4090 over the wire — 100% GPU utilisation on the remote box, 119.6 s of 24 kHz audio returned in 16.6 s, 5.7 MB delivered out of band through the artifact path rather than the control stream. Backend 5259 passed, frontend 1808 passed.
322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Capacity derivation and zombie-slot accounting.
|
|
|
|
Every rule here exists because the repo already paid for it: #315
|
|
(torch.compile thread affinity → silent audio corruption), #567 (concurrent
|
|
clone jobs → sticky CUDA abort), and the un-killable GPU thread that made
|
|
``_ResilientGpuPool.reset()`` reclaim nothing.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from worker.capacity import ModelSlot, WorkerCapacity, derive_concurrency
|
|
|
|
GB = 1024**3
|
|
|
|
|
|
def test_compiled_models_are_always_serial():
|
|
"""Thread-local cudagraph state (#315): a second concurrent job produces
|
|
corrupted audio with no exception to catch."""
|
|
assert derive_concurrency(backend="cuda", free_memory_bytes=48 * GB, compiled=True) == 1
|
|
|
|
|
|
def test_apple_unified_memory_is_always_serial():
|
|
"""MPS/MLX share memory with everything else the user is running, so a
|
|
number that was safe at config time is not safe at execution time."""
|
|
for backend in ("mps", "mlx", "cpu"):
|
|
assert derive_concurrency(backend=backend, free_memory_bytes=64 * GB) == 1
|
|
|
|
|
|
def test_cuda_concurrency_derives_from_free_memory():
|
|
assert derive_concurrency(backend="cuda", free_memory_bytes=4 * GB) == 1
|
|
assert derive_concurrency(backend="cuda", free_memory_bytes=11 * GB) == 2
|
|
assert derive_concurrency(backend="cuda", free_memory_bytes=24 * GB) == 4
|
|
|
|
|
|
def test_concurrency_is_capped_regardless_of_card_size():
|
|
assert derive_concurrency(backend="cuda", free_memory_bytes=200 * GB) == 4
|
|
|
|
|
|
def test_model_that_does_not_fit_returns_zero():
|
|
"""A 4 GB card refusing a 6 GB engine is correct behaviour (#1226), and the
|
|
scheduler must read it as 'send it elsewhere', never as a worker fault."""
|
|
assert derive_concurrency(backend="cuda", free_memory_bytes=4 * GB, min_model_bytes=6 * GB) == 0
|
|
assert derive_concurrency(backend="mps", free_memory_bytes=4 * GB, min_model_bytes=6 * GB) == 0
|
|
|
|
|
|
def test_large_model_reduces_derived_concurrency():
|
|
"""A model bigger than the per-job budget takes the space of more than one."""
|
|
small = derive_concurrency(backend="cuda", free_memory_bytes=24 * GB, min_model_bytes=2 * GB)
|
|
large = derive_concurrency(backend="cuda", free_memory_bytes=24 * GB, min_model_bytes=20 * GB)
|
|
assert large < small
|
|
|
|
|
|
# ── Slot accounting ────────────────────────────────────────────────────────
|
|
|
|
|
|
def _cap(**kw) -> WorkerCapacity:
|
|
defaults = dict(worker_id="w1", max_concurrent_tasks=2, backend="cuda")
|
|
defaults.update(kw)
|
|
return WorkerCapacity(**defaults)
|
|
|
|
|
|
def test_reserve_and_release_round_trip():
|
|
cap = _cap()
|
|
assert cap.available_slots == 2
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
assert cap.available_slots == 1
|
|
cap.release("indextts", "IndexTTS-2")
|
|
assert cap.available_slots == 2
|
|
|
|
|
|
def test_engine_only_capability_and_named_task_share_one_slot():
|
|
"""An old worker's model_id="" is a wildcard, not extra capacity."""
|
|
cap = _cap(max_concurrent_tasks=2)
|
|
cap.slots["indextts:"] = ModelSlot(
|
|
engine="indextts", model_id="", derived_concurrency=1
|
|
)
|
|
|
|
cap.reserve("indextts", "indextts:default")
|
|
|
|
assert set(cap.slots) == {"indextts:"}
|
|
assert cap.can_accept("indextts", "indextts:default") is False
|
|
assert cap.release("indextts", "indextts:default") is True
|
|
|
|
|
|
def test_engine_only_task_matching_remains_tolerant():
|
|
"""Restored tasks may still match a capability by engine alone."""
|
|
cap = _cap()
|
|
cap.slots["indextts:"] = ModelSlot(engine="indextts", model_id="")
|
|
|
|
cap.reserve("indextts", "")
|
|
|
|
assert cap.slot_for("indextts", "") is cap.slots["indextts:"]
|
|
assert cap.release("indextts", "") is True
|
|
|
|
|
|
def test_timeout_parks_a_zombie_slot_instead_of_returning_it():
|
|
"""A timed-out GPU job cannot be killed — the thread keeps the device.
|
|
Returning the slot early is how a worker gets overcommitted into an OOM."""
|
|
cap = _cap()
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
cap.release("indextts", "IndexTTS-2", zombie=True)
|
|
|
|
assert cap.zombie_tasks == 1
|
|
assert cap.available_slots == 1, "the stuck thread still holds its slot"
|
|
|
|
cap.reap_zombie("indextts", "IndexTTS-2")
|
|
assert cap.available_slots == 2
|
|
|
|
|
|
# ── Getting a parked slot back (B2) ────────────────────────────────────────
|
|
|
|
|
|
def test_a_parked_slot_is_reclaimed_when_its_ttl_runs_out():
|
|
"""B2: `reap_zombie` had no caller and the protocol has no message that
|
|
would trigger one, so a single lease expiry at max_concurrent_tasks=1 made
|
|
a worker unschedulable forever — while it heartbeated active=0, free=1
|
|
every twenty seconds."""
|
|
cap = _cap(max_concurrent_tasks=1)
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
cap.release("indextts", "IndexTTS-2", zombie=True, zombie_ttl_seconds=300, now=1000.0)
|
|
assert cap.available_slots == 0
|
|
|
|
assert cap.expire_zombies(now=1299.0) == 0
|
|
assert cap.available_slots == 0, "the thread's own budget has not run out yet"
|
|
|
|
assert cap.expire_zombies(now=1301.0) == 1
|
|
assert cap.available_slots == 1
|
|
|
|
|
|
def test_a_busy_heartbeat_does_not_reclaim_a_park():
|
|
"""The inverse of the tempting heuristic, and the reason it is wrong.
|
|
|
|
Reconciling parks against the worker's reported load reads as sensible —
|
|
"it is already at its ceiling, the headroom is spent" — but at a ceiling of
|
|
one the ONLY task such a worker can report is the wedged one itself. So
|
|
"busy" would drop the park, and the very next idle heartbeat would hand the
|
|
slot out with the GPU thread still alive: the overcommit-into-OOM of
|
|
#730/#1190. Parks come back on a timer or on a restart, never on a report.
|
|
"""
|
|
cap = _cap(max_concurrent_tasks=1)
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
cap.release("indextts", "IndexTTS-2", zombie=True, zombie_ttl_seconds=300, now=1000.0)
|
|
|
|
cap.apply_snapshot(active_tasks=1, available_slots=0, now=1001.0)
|
|
assert cap.zombie_tasks == 1
|
|
|
|
# …and the follow-up heartbeat that used to collect the freed slot.
|
|
cap.apply_snapshot(active_tasks=0, available_slots=1, now=1002.0)
|
|
assert cap.zombie_tasks == 1, "a park survived one report only to die on the next"
|
|
|
|
# The TTL remains the way out, so a timeout still cannot cost the worker
|
|
# permanently.
|
|
cap.apply_snapshot(active_tasks=0, available_slots=1, now=1400.0)
|
|
assert cap.zombie_tasks == 0
|
|
|
|
|
|
def test_an_idle_worker_keeps_its_park():
|
|
"""The worker counts asyncio tasks, not GPU threads — its "I am free" is
|
|
exactly the claim the park exists to disbelieve (#730/#1190)."""
|
|
cap = _cap(max_concurrent_tasks=1)
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
cap.release("indextts", "IndexTTS-2", zombie=True, zombie_ttl_seconds=300, now=1000.0)
|
|
|
|
cap.apply_snapshot(active_tasks=0, available_slots=1, now=1010.0)
|
|
|
|
assert cap.zombie_tasks == 1
|
|
assert cap.available_slots == 0
|
|
|
|
|
|
def test_a_ttl_is_clamped_to_something_survivable():
|
|
cap = _cap()
|
|
cap.reserve("a", "m")
|
|
cap.release("a", "m", zombie=True, zombie_ttl_seconds=1, now=1000.0)
|
|
assert cap.expire_zombies(now=1030.0) == 0, "a park that short means nothing"
|
|
|
|
cap.expire_zombies(now=1_000_000.0)
|
|
cap.reserve("a", "m")
|
|
cap.release("a", "m", zombie=True, zombie_ttl_seconds=10**9, now=1000.0)
|
|
assert cap.expire_zombies(now=1000.0 + 3601) == 1, "one timeout cannot cost a session"
|
|
|
|
|
|
def test_a_double_release_cannot_invent_capacity():
|
|
"""`release` guarded its per-model slot but decremented the worker-wide
|
|
count regardless, so two paths ending one attempt overcommitted the
|
|
machine by a slot."""
|
|
cap = _cap()
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
|
|
assert cap.release("indextts", "IndexTTS-2") is True
|
|
assert cap.release("indextts", "IndexTTS-2") is False
|
|
assert cap.active_tasks == 0
|
|
assert cap.available_slots == 2
|
|
|
|
|
|
def test_a_double_release_cannot_invent_a_zombie():
|
|
"""A worker-wide zombie counter kept beside the per-slot ones could be
|
|
incremented for a slot that owned nothing — leaving a park no reap could
|
|
ever find."""
|
|
cap = _cap()
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
cap.release("indextts", "IndexTTS-2", zombie=True, now=1000.0)
|
|
cap.release("indextts", "IndexTTS-2", zombie=True, now=1000.0)
|
|
|
|
assert cap.zombie_tasks == 1
|
|
|
|
|
|
def test_the_ceiling_follows_the_worker_down_as_well_as_up():
|
|
"""The worker computes active+available as its own max_concurrent_tasks.
|
|
A ceiling we refuse to lower is one we keep dispatching against after the
|
|
worker has said it can no longer honour it."""
|
|
cap = _cap(max_concurrent_tasks=1)
|
|
cap.apply_snapshot(active_tasks=0, available_slots=4, now=1000.0)
|
|
assert cap.max_concurrent_tasks == 4
|
|
|
|
cap.apply_snapshot(active_tasks=0, available_slots=1, now=1010.0)
|
|
assert cap.max_concurrent_tasks == 1
|
|
|
|
|
|
def test_worker_wide_cap_binds_before_per_model_cap():
|
|
"""Per-model concurrencies are not independent — they share one VRAM pool."""
|
|
cap = _cap(max_concurrent_tasks=1)
|
|
cap.slots["indextts:IndexTTS-2"] = ModelSlot(
|
|
engine="indextts", model_id="IndexTTS-2", derived_concurrency=4
|
|
)
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
assert cap.can_accept("indextts", "IndexTTS-2") is False
|
|
|
|
|
|
def test_unknown_model_defers_to_the_worker():
|
|
"""The worker's accept/reject is authoritative; the scheduler's view is
|
|
advisory and may be stale."""
|
|
cap = _cap()
|
|
assert cap.can_accept("brand-new-engine", "whatever") is True
|
|
|
|
|
|
def test_snapshot_is_absolute_not_a_delta():
|
|
"""Out-of-order deltas corrupt the count permanently after a reconnect."""
|
|
cap = _cap()
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
cap.reserve("indextts", "IndexTTS-2")
|
|
cap.apply_snapshot(active_tasks=0, available_slots=2, resident_models={"indextts:IndexTTS-2"})
|
|
|
|
assert cap.active_tasks == 0
|
|
assert cap.available_slots == 2
|
|
|
|
|
|
def test_residency_is_visible_to_the_scheduler():
|
|
"""Warm vs cold is the dominant latency term — 8s versus minutes."""
|
|
cap = _cap(resident_models={"indextts:IndexTTS-2"})
|
|
assert cap.is_resident("indextts", "IndexTTS-2") is True
|
|
assert cap.is_resident("cosyvoice", "CosyVoice2") is False
|
|
|
|
|
|
def test_release_never_underflows():
|
|
cap = _cap()
|
|
cap.release("indextts", "IndexTTS-2")
|
|
cap.reap_zombie("indextts", "IndexTTS-2")
|
|
assert cap.active_tasks == 0
|
|
assert cap.zombie_tasks == 0
|
|
|
|
|
|
# ── Latency measurement ────────────────────────────────────────────────────
|
|
|
|
|
|
def _pool_with_worker():
|
|
import time
|
|
|
|
from worker.identity import WorkerKeypair, issue_session
|
|
from worker.pool import WorkerPool
|
|
from worker.registry import RemoteWorker
|
|
|
|
record = RemoteWorker(
|
|
id="w1", name="w1", key_id="k1", public_key=b"\x00" * 32, created_at=time.time()
|
|
)
|
|
pool = WorkerPool()
|
|
pool.connect(
|
|
record,
|
|
session=issue_session(worker_id="w1", key_id="k1", epoch=1, now=time.time()),
|
|
epoch=1,
|
|
now=time.time(),
|
|
)
|
|
return pool
|
|
|
|
|
|
def test_a_single_sample_is_not_published():
|
|
"""The first round trip after connect lands while the worker is still
|
|
importing torch — publishing it shows a wildly wrong number."""
|
|
pool = _pool_with_worker()
|
|
pool.record_latency("w1", 139.4)
|
|
assert pool.get("w1").latency_ms == 0.0
|
|
|
|
|
|
def test_a_startup_outlier_does_not_dominate():
|
|
pool = _pool_with_worker()
|
|
for sample in (139.4, 4.0, 3.8, 4.2, 3.9):
|
|
pool.record_latency("w1", sample)
|
|
# A running average would still be carrying the 139; a median ignores it.
|
|
assert pool.get("w1").latency_ms < 10
|
|
|
|
|
|
def test_the_window_is_bounded():
|
|
pool = _pool_with_worker()
|
|
for sample in range(50):
|
|
pool.record_latency("w1", float(sample))
|
|
assert len(pool.get("w1").latency_samples) <= 5
|
|
|
|
|
|
def test_latency_tracks_a_link_that_degrades():
|
|
pool = _pool_with_worker()
|
|
for _ in range(5):
|
|
pool.record_latency("w1", 4.0)
|
|
assert pool.get("w1").latency_ms == pytest.approx(4.0)
|
|
|
|
for _ in range(5):
|
|
pool.record_latency("w1", 120.0)
|
|
assert pool.get("w1").latency_ms == pytest.approx(120.0)
|
|
|
|
|
|
def test_latency_for_an_unknown_worker_is_ignored():
|
|
_pool_with_worker().record_latency("nosuch", 5.0)
|