177 lines
5.6 KiB
Python
177 lines
5.6 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for _StoreCacheGate.
|
|
|
|
The gate is a non-blocking counter that bounds how many KV caches are alive
|
|
in the post-completion store-cache pipeline. Backpressure is applied at
|
|
admission (_schedule_waiting declines new prefills while in_flight >= cap)
|
|
rather than by blocking the generation step, so cache persistence never
|
|
stalls token generation (#1496). The cap still bounds concurrent extracted-KV
|
|
copies, the OOM guard for the burst-finish RAM growth reported in #1383.
|
|
"""
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from omlx.scheduler import Scheduler, _StoreCacheGate
|
|
|
|
|
|
class TestCounter:
|
|
def test_note_submitted_increments(self):
|
|
gate = _StoreCacheGate(cap=3)
|
|
gate.note_submitted()
|
|
gate.note_submitted()
|
|
assert gate.in_flight == 2
|
|
|
|
def test_note_done_decrements(self):
|
|
gate = _StoreCacheGate(cap=3)
|
|
gate.note_submitted()
|
|
gate.note_submitted()
|
|
gate.note_done()
|
|
assert gate.in_flight == 1
|
|
|
|
def test_note_done_does_not_underflow(self):
|
|
gate = _StoreCacheGate(cap=2)
|
|
gate.note_done()
|
|
gate.note_done()
|
|
assert gate.in_flight == 0
|
|
|
|
def test_note_submitted_never_blocks_past_cap(self):
|
|
"""Submitting beyond cap must not block the caller (#1496).
|
|
|
|
The generation step calls note_submitted() for a completed request
|
|
even when the pipeline is already at cap; it records the slot and
|
|
returns immediately rather than waiting on an SSD write.
|
|
"""
|
|
gate = _StoreCacheGate(cap=1)
|
|
gate.note_submitted()
|
|
|
|
returned = threading.Event()
|
|
|
|
def submit_again():
|
|
gate.note_submitted()
|
|
returned.set()
|
|
|
|
t = threading.Thread(target=submit_again)
|
|
t.start()
|
|
assert returned.wait(1.0), "note_submitted blocked past cap"
|
|
t.join()
|
|
assert gate.in_flight == 2
|
|
|
|
|
|
class TestHasCapacity:
|
|
def test_true_below_cap(self):
|
|
gate = _StoreCacheGate(cap=2)
|
|
assert gate.has_capacity is True
|
|
gate.note_submitted()
|
|
assert gate.has_capacity is True
|
|
|
|
def test_false_at_cap(self):
|
|
gate = _StoreCacheGate(cap=2)
|
|
gate.note_submitted()
|
|
gate.note_submitted()
|
|
assert gate.has_capacity is False
|
|
|
|
def test_recovers_after_note_done(self):
|
|
gate = _StoreCacheGate(cap=1)
|
|
gate.note_submitted()
|
|
assert gate.has_capacity is False
|
|
gate.note_done()
|
|
assert gate.has_capacity is True
|
|
|
|
def test_shrinking_cap_below_in_flight_blocks_admission(self):
|
|
"""Cap shrink under load removes capacity until writes drain."""
|
|
gate = _StoreCacheGate(cap=3)
|
|
gate.note_submitted()
|
|
gate.note_submitted()
|
|
assert gate.has_capacity is True
|
|
gate.set_cap(2) # shrink under memory pressure
|
|
assert gate.has_capacity is False
|
|
gate.note_done()
|
|
assert gate.has_capacity is True
|
|
|
|
|
|
class TestSetCap:
|
|
def test_clamps_to_minimum_one(self):
|
|
gate = _StoreCacheGate(cap=4)
|
|
gate.set_cap(0)
|
|
assert gate.cap == 1
|
|
gate.set_cap(-5)
|
|
assert gate.cap == 1
|
|
|
|
def test_set_cap_updates_value(self):
|
|
gate = _StoreCacheGate(cap=4)
|
|
gate.set_cap(2)
|
|
assert gate.cap == 2
|
|
gate.set_cap(7)
|
|
assert gate.cap == 7
|
|
|
|
|
|
class TestAdjustStoreCacheCap:
|
|
"""Tests for Scheduler.adjust_store_cache_cap pressure mapping."""
|
|
|
|
def _fake_scheduler(self, cap, max_num_seqs=8):
|
|
from unittest.mock import MagicMock
|
|
|
|
sched = MagicMock(spec=["config", "_store_cache_gate"])
|
|
sched.config = MagicMock(spec=["max_num_seqs"])
|
|
sched.config.max_num_seqs = max_num_seqs
|
|
sched._store_cache_gate = _StoreCacheGate(cap=cap)
|
|
return sched
|
|
|
|
def test_ok_grows_by_one(self):
|
|
sched = self._fake_scheduler(cap=4, max_num_seqs=8)
|
|
Scheduler.adjust_store_cache_cap(sched, "ok")
|
|
assert sched._store_cache_gate.cap == 5
|
|
|
|
def test_ok_clamps_to_max_num_seqs(self):
|
|
sched = self._fake_scheduler(cap=8, max_num_seqs=8)
|
|
Scheduler.adjust_store_cache_cap(sched, "ok")
|
|
assert sched._store_cache_gate.cap == 8
|
|
|
|
def test_soft_shrinks_by_one(self):
|
|
sched = self._fake_scheduler(cap=5)
|
|
Scheduler.adjust_store_cache_cap(sched, "soft")
|
|
assert sched._store_cache_gate.cap == 4
|
|
|
|
def test_hard_shrinks_by_one(self):
|
|
sched = self._fake_scheduler(cap=3)
|
|
Scheduler.adjust_store_cache_cap(sched, "hard")
|
|
assert sched._store_cache_gate.cap == 2
|
|
|
|
def test_shrink_floor_at_one(self):
|
|
sched = self._fake_scheduler(cap=1)
|
|
Scheduler.adjust_store_cache_cap(sched, "hard")
|
|
assert sched._store_cache_gate.cap == 1
|
|
|
|
def test_no_op_when_gate_missing(self):
|
|
from unittest.mock import MagicMock
|
|
|
|
sched = MagicMock(spec=["config", "_store_cache_gate"])
|
|
sched.config = MagicMock(spec=["max_num_seqs"])
|
|
sched.config.max_num_seqs = 8
|
|
sched._store_cache_gate = None
|
|
# Should not raise.
|
|
Scheduler.adjust_store_cache_cap(sched, "ok")
|
|
|
|
|
|
class TestThreadSafety:
|
|
@pytest.mark.timeout(5)
|
|
def test_counter_consistent_under_contention(self):
|
|
"""Concurrent submit/done pairs must net to zero in_flight."""
|
|
gate = _StoreCacheGate(cap=4)
|
|
|
|
def worker():
|
|
for _ in range(200):
|
|
gate.note_submitted()
|
|
time.sleep(0.0005)
|
|
gate.note_done()
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(16)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
assert gate.in_flight == 0
|