Order averaging (Prime, after the 739aa03 spike):
- A decision may set orderings: rotations|all (all only for <= 4 options). Every
ordering goes to the engine in one shared batch.
- The reply keeps each native result and adds combined {probabilities (log-mean),
top, agreement, spread}.
- Through the service on SemIf's labelled sets (252 rows): 78.6% -> 88.1%
(group-bootstrap 95% CI +5.1..+14.3). Unanimous agreement is 94.5% accurate.
Fast kernels: flash-linear-attention 0.5.2 and causal-conv1d 1.7.0 are now the
default build. A/B on the empty GPU 3:
- parity with upstream went from 142/144 to 144/144;
- a ~2k-token /decide went from 169 to 92 ms server-side;
- short 3-rotation batches cost ~3-6 ms more.
triton builds a C shim at runtime, so the image carries gcc. Without it the
warm-up failed and startup failed closed.
Heid bug-hunt panel (4/4 arms, thread 01M3H3F4RR7XBP90KQ3A39H4SX), folded:
- Startup validation: VRAM cap 0 no longer means uncapped (C1); limits must be
>= 1 (S1); the token must be visible ASCII (S2); the calibration file must
exist and parse, with T in [0.05, 20] (S8, and C3's NaN leg).
- The body limit is checked before a chunk is kept, and a Unicode-digit
Content-Length no longer crashes (C2, S3).
- Failures while building the response now get the 500 envelope (C3).
- 429 busy past SEMIF_MAX_QUEUE requests in progress (C6).
- The engine releases memory on every non-validation failure, unchained after
gc; an empty OOM message is handled; 'out of memory' RuntimeErrors map to 503
(C4, C5, S9).
- The entry point forces HF_HUB_OFFLINE (S10). README wording fixed (S5, S6).
- New guard tests close the gaps the arms' mutation grids exposed: early stop of
the body read, a shared-route lock, calibration pass-through, the gc cycle,
the exact caps, TorchEngine.load's arch and device checks, and the offline
entry point.
86 tests.
Deployed on fv-ml1 GPU 1: parity 144/144, OOM and burst release verified, shared
capacity 63/51/26/16 rows at ~140/520/1960/3900 prefix tokens.
114 lines
4.7 KiB
Python
114 lines
4.7 KiB
Python
"""TorchEngine's OOM path against a fake torch (INV-4). Found on the card 2026-09-27: after a
|
|
503 the failed call's tensors stayed alive (11.92 GiB allocated) because the raised
|
|
OutOfMemory chained back to the torch exception, whose traceback held the scorer's frames."""
|
|
import weakref
|
|
|
|
import pytest
|
|
|
|
from semif_serve.config import Settings
|
|
from semif_serve.engine import TorchEngine
|
|
from semif_serve.errors import OutOfMemory, ScoringFailed
|
|
|
|
|
|
class FakeTorch:
|
|
class cuda:
|
|
class OutOfMemoryError(RuntimeError):
|
|
pass
|
|
|
|
empties = [] # for each empty_cache() call: was the failed call's tensor already freed?
|
|
watched = []
|
|
|
|
@classmethod
|
|
def empty_cache(cls):
|
|
cls.empties.append(all(ref() is None for ref in cls.watched))
|
|
|
|
|
|
class Tensor:
|
|
pass
|
|
|
|
|
|
def failing_scorer(*_args):
|
|
kv_cache = Tensor() # stands in for the replicated prefix cache
|
|
FakeTorch.cuda.watched.append(weakref.ref(kv_cache))
|
|
raise FakeTorch.cuda.OutOfMemoryError("CUDA out of memory. Tried to allocate 490.00 MiB.\nGPU 0 has ...")
|
|
|
|
|
|
@pytest.mark.parametrize("call", ["direct", "shared"])
|
|
def test_oom_frees_the_failed_call_before_emptying_the_cache_and_keeps_nothing_alive(call):
|
|
FakeTorch.cuda.empties.clear(), FakeTorch.cuda.watched.clear()
|
|
engine = TorchEngine(FakeTorch, model=None, tokenizer=None, metadata={}, settings=Settings(api_token="t" * 40),
|
|
direct_fn=failing_scorer, shared_fn=failing_scorer)
|
|
with pytest.raises(OutOfMemory) as info:
|
|
getattr(engine, call)({})
|
|
assert str(info.value) == "CUDA out of memory. Tried to allocate 490.00 MiB."
|
|
assert info.value.__cause__ is None and info.value.__context__ is None # no chain back to the frames
|
|
assert FakeTorch.cuda.watched[0]() is None # nothing keeps the tensor alive
|
|
assert FakeTorch.cuda.empties == [True] # emptied once, after it was freed
|
|
|
|
|
|
def test_other_scorer_errors_pass_through_unchanged():
|
|
def bad(*_args):
|
|
raise ValueError("Row r: 5000 input tokens exceed limit 4096")
|
|
engine = TorchEngine(FakeTorch, None, None, {}, Settings(api_token="t" * 40), direct_fn=bad, shared_fn=bad)
|
|
with pytest.raises(ValueError, match="exceed limit"):
|
|
engine.direct({})
|
|
|
|
|
|
class ReservingTorch(FakeTorch):
|
|
class cuda(FakeTorch.cuda):
|
|
reserved = 0
|
|
emptied = 0
|
|
|
|
@classmethod
|
|
def memory_reserved(cls, _device=0):
|
|
return cls.reserved
|
|
|
|
@classmethod
|
|
def empty_cache(cls):
|
|
cls.emptied += 1
|
|
|
|
|
|
@pytest.mark.parametrize("reserved_after, released", [(8 * 2**30, 0), (8 * 2**30 + 512 * 2**20, 0),
|
|
(8 * 2**30 + 513 * 2**20, 1), (12 * 2**30, 1)])
|
|
def test_a_burst_is_returned_to_the_driver_after_the_call(reserved_after, released):
|
|
ReservingTorch.cuda.emptied = 0
|
|
|
|
def scorer(*_args):
|
|
ReservingTorch.cuda.reserved = reserved_after
|
|
return {"ok": True}
|
|
|
|
engine = TorchEngine(ReservingTorch, None, None, {}, Settings(api_token="t" * 40),
|
|
direct_fn=scorer, shared_fn=scorer, release_above_bytes=8 * 2**30 + 512 * 2**20)
|
|
assert engine.direct({}) == {"ok": True}
|
|
assert ReservingTorch.cuda.emptied == released
|
|
|
|
|
|
def cyclic_tensor():
|
|
"""A tensor held in a reference cycle, as real frames and tensors often are: only gc frees it."""
|
|
t = Tensor()
|
|
t.self_ref = t
|
|
FakeTorch.cuda.watched.append(weakref.ref(t))
|
|
return t
|
|
|
|
|
|
@pytest.mark.parametrize("raised, expected_type, expected_message", [
|
|
(lambda: FakeTorch.cuda.OutOfMemoryError(""), OutOfMemory, "CUDA out of memory"),
|
|
(lambda: RuntimeError("CUBLAS_STATUS_ALLOC_FAILED: CUDA error: out of memory"), OutOfMemory,
|
|
"CUBLAS_STATUS_ALLOC_FAILED: CUDA error: out of memory"),
|
|
(lambda: RuntimeError("Invalid native prefix cache"), ScoringFailed, "RuntimeError: Invalid native prefix cache"),
|
|
(lambda: KeyError("option_logits"), ScoringFailed, "KeyError: 'option_logits'"),
|
|
])
|
|
def test_every_non_validation_failure_is_released_unchained_after_gc(raised, expected_type, expected_message):
|
|
FakeTorch.cuda.empties.clear(), FakeTorch.cuda.watched.clear()
|
|
|
|
def scorer(*_args):
|
|
kv_cache = cyclic_tensor() # noqa: F841 — alive in this frame when it raises
|
|
raise raised()
|
|
|
|
engine = TorchEngine(FakeTorch, None, None, {}, Settings(api_token="t" * 40), direct_fn=scorer, shared_fn=scorer)
|
|
with pytest.raises(expected_type) as info:
|
|
engine.shared([])
|
|
assert str(info.value) == expected_message
|
|
assert info.value.__cause__ is None and info.value.__context__ is None
|
|
assert FakeTorch.cuda.empties == [True] # gc freed the cycle BEFORE the cache was emptied
|