Files
esh-pfi-infrastructure/services/semif-serve/tests/test_engine.py
T
vh 77b8cb449c feat(semif): 0.1.3 — order averaging, fast kernels, bug-hunt hardening (Prime)
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.
2026-09-27 03:27:15 -07:00

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