Files
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

4931 lines
189 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""
Tests for Scheduler module.
Tests cover:
- SchedulerConfig: default values, custom values
- SchedulerOutput: dataclass behavior
- Scheduler initialization with mock model/tokenizer
- add_request(): adding requests, tokenization
- abort_request(): aborting waiting/running requests
- has_requests(), get_num_waiting(), get_num_running()
- get_request(): request lookup
- get_stats(): statistics
Note: BatchGenerator is mocked; step() coverage is limited to targeted paths.
"""
import concurrent.futures
import json
from collections import deque
from types import SimpleNamespace
from unittest.mock import MagicMock, call, patch
import mlx.core as mx
import pytest
import omlx.scheduler as scheduler_module
from omlx.request import Request, RequestOutput, RequestStatus, SamplingParams
from omlx.scheduler import (
Scheduler,
SchedulerConfig,
SchedulerOutput,
SchedulingPolicy,
_PrefillState,
_StoreCacheGate,
_VLMMTPDecodeState,
)
class _ParserStopFactory:
kind = "test"
stop_token_ids = set()
thinking_end_text = None
def create_session(self, tokenizer):
return _ParserStopSession()
class _ParserStopSession:
def process_token(self, token_id):
from omlx.adapter.output_parser import OutputParserTokenResult
return OutputParserTokenResult(
stream_text="",
visible_text="",
is_stop=True,
record_token=False,
)
def finalize(self):
from omlx.adapter.output_parser import OutputParserFinalizeResult
return OutputParserFinalizeResult()
class TestSchedulerConfig:
"""Tests for SchedulerConfig dataclass."""
def test_default_values(self):
"""Test SchedulerConfig has correct defaults."""
config = SchedulerConfig()
assert config.max_num_seqs == 256
assert config.max_num_batched_tokens == 8192
assert config.policy == SchedulingPolicy.FCFS
assert config.completion_batch_size == 32
assert config.embedding_batch_size == 32
assert config.prefill_step_size == 2048
assert config.paged_cache_block_size == 256
assert config.max_cache_blocks is None
assert config.initial_cache_blocks == 256
assert config.paged_ssd_cache_dir is None
assert config.paged_ssd_cache_max_size == 100 * 1024 * 1024 * 1024 # 100GB
assert config.model_name == ""
assert config.gc_cleanup_interval == 0
assert config.mlx_cache_cleanup_interval == 512
def test_custom_values(self):
"""Test SchedulerConfig with custom values."""
config = SchedulerConfig(
max_num_seqs=128,
max_num_batched_tokens=4096,
policy=SchedulingPolicy.PRIORITY,
completion_batch_size=16,
embedding_batch_size=12,
prefill_step_size=1024,
paged_cache_block_size=128,
max_cache_blocks=500,
initial_cache_blocks=100,
paged_ssd_cache_dir="/tmp/cache",
paged_ssd_cache_max_size=50 * 1024 * 1024 * 1024,
model_name="test-model",
gc_cleanup_interval=5,
mlx_cache_cleanup_interval=20,
)
assert config.max_num_seqs == 128
assert config.max_num_batched_tokens == 4096
assert config.policy == SchedulingPolicy.PRIORITY
assert config.completion_batch_size == 16
assert config.embedding_batch_size == 12
assert config.prefill_step_size == 1024
assert config.paged_cache_block_size == 128
assert config.max_cache_blocks == 500
assert config.initial_cache_blocks == 100
assert config.paged_ssd_cache_dir == "/tmp/cache"
assert config.paged_ssd_cache_max_size == 50 * 1024 * 1024 * 1024
assert config.model_name == "test-model"
assert config.gc_cleanup_interval == 5
assert config.mlx_cache_cleanup_interval == 20
class TestVLMExtraSlicing:
"""Tests for VLM prompt-aligned extra kwargs used during external prefill."""
def test_slice_and_advance_token_type_ids(self):
"""Multimodal token types should stay aligned with inputs_embeds chunks."""
extra = {
"mm_token_type_ids": mx.array([[0, 1, 1, 0]]),
"token_type_ids": mx.array([[0, 1, 1, 0]]),
"per_layer_inputs": mx.zeros((1, 4, 2, 3)),
"scalar": mx.array(7),
}
sliced = scheduler_module._slice_vlm_extra(extra, 3)
assert sliced["mm_token_type_ids"].tolist() == [[0, 1, 1]]
assert sliced["token_type_ids"].tolist() == [[0, 1, 1]]
assert sliced["per_layer_inputs"].shape == (1, 3, 2, 3)
assert sliced["scalar"] is extra["scalar"]
advanced = scheduler_module._advance_vlm_extra(extra, 1)
assert advanced["mm_token_type_ids"].tolist() == [[1, 1, 0]]
assert advanced["token_type_ids"].tolist() == [[1, 1, 0]]
assert advanced["per_layer_inputs"].shape == (1, 3, 2, 3)
assert advanced["scalar"] is extra["scalar"]
class TestSchedulingPolicy:
"""Tests for SchedulingPolicy enum."""
def test_fcfs_policy(self):
"""Test FCFS policy value."""
assert SchedulingPolicy.FCFS.value == "fcfs"
def test_priority_policy(self):
"""Test Priority policy value."""
assert SchedulingPolicy.PRIORITY.value == "priority"
class TestSchedulerOutput:
"""Tests for SchedulerOutput dataclass."""
def test_default_values(self):
"""Test SchedulerOutput has correct defaults."""
output = SchedulerOutput()
assert output.scheduled_request_ids == []
assert output.num_scheduled_tokens == 0
assert output.finished_request_ids == set()
assert output.outputs == []
assert output.has_work is False
def test_custom_values(self):
"""Test SchedulerOutput with custom values."""
outputs = [
RequestOutput(
request_id="req-1",
new_token_ids=[100],
new_text="hello",
)
]
output = SchedulerOutput(
scheduled_request_ids=["req-1", "req-2"],
num_scheduled_tokens=100,
finished_request_ids={"req-1"},
outputs=outputs,
has_work=True,
)
assert output.scheduled_request_ids == ["req-1", "req-2"]
assert output.num_scheduled_tokens == 100
assert output.finished_request_ids == {"req-1"}
assert len(output.outputs) == 1
assert output.outputs[0].request_id == "req-1"
assert output.has_work is True
class TestSchedulerStepOutputs:
"""Tests for Scheduler.step output assembly."""
def test_decode_outputs_preserve_prefill_rejections(
self, mock_model, mock_tokenizer
):
"""Decode responses must not overwrite earlier rejection outputs."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
prefill_error = RequestOutput(
request_id="prefill-failed",
finished=True,
finish_reason="error",
error="Memory limit exceeded during prefill",
)
decode_output = RequestOutput(
request_id="running",
new_token_ids=[123],
new_text="x",
)
scheduler._schedule_waiting = MagicMock(return_value=([], [prefill_error]))
scheduler._process_batch_responses = MagicMock(
return_value=([decode_output], {"running"})
)
scheduler._cleanup_finished = MagicMock()
scheduler.running = {"running": MagicMock()}
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.next_generated.return_value = iter([MagicMock()])
output = scheduler.step()
assert output.outputs == [prefill_error, decode_output]
assert output.finished_request_ids == {"running"}
scheduler._cleanup_finished.assert_called_once_with({"running"})
class TestSchedulerInitialization:
"""Tests for Scheduler initialization."""
def test_init_with_defaults(self, mock_model, mock_tokenizer):
"""Test Scheduler initializes with default config."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler.model is mock_model
# Scheduler deep-copies tokenizer for thread safety (Rust RefCell
# isolation between event loop and MLX executor threads).
assert scheduler.tokenizer is not mock_tokenizer
assert isinstance(scheduler.config, SchedulerConfig)
assert isinstance(scheduler.waiting, deque)
assert len(scheduler.waiting) == 0
assert scheduler.running == {}
assert scheduler.requests == {}
assert scheduler.finished_req_ids == set()
assert scheduler.request_id_to_uid == {}
assert scheduler.uid_to_request_id == {}
assert scheduler.batch_generator is None
def test_init_with_custom_config(self, mock_model, mock_tokenizer):
"""Test Scheduler initializes with custom config."""
config = SchedulerConfig(
max_num_seqs=64,
)
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=config,
)
assert scheduler.config.max_num_seqs == 64
def test_llama4_effective_cap_is_serial(self, mock_model, mock_tokenizer):
"""Llama 4 uses ChunkedKVCache layers that are serialized for now."""
mock_model.config.model_type = "llama4"
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=SchedulerConfig(max_num_seqs=8),
)
assert scheduler._effective_max_num_seqs() == 1
def test_init_falls_back_when_paged_ssd_cache_unavailable(
self, mock_model, mock_tokenizer, tmp_path, monkeypatch, caplog
):
"""Unusable SSD cache directories should not leave partial cache state."""
if not scheduler_module.HAS_TIERED_CACHE:
pytest.skip("tiered cache modules are unavailable")
class BrokenPagedSSDCacheManager:
def __init__(self, *args, **kwargs):
raise OSError("cache directory is not writable")
monkeypatch.setattr(
scheduler_module,
"PagedSSDCacheManager",
BrokenPagedSSDCacheManager,
)
config = SchedulerConfig(paged_ssd_cache_dir=str(tmp_path / "missing-drive"))
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
assert scheduler.paged_ssd_cache_manager is None
assert scheduler.paged_cache_manager is None
assert scheduler.block_aware_cache is None
assert scheduler._boundary_snapshot_store is None
assert scheduler._store_cache_executor is None
assert scheduler._store_cache_gate is None
assert "Failed to initialize paged SSD cache" in caplog.text
def test_init_statistics_zero(self, mock_model, mock_tokenizer):
"""Test Scheduler initializes with zero statistics."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler.num_requests_processed == 0
assert scheduler.total_prompt_tokens == 0
assert scheduler.total_completion_tokens == 0
def test_snapshot_for_admin_is_isolated_from_live_state(
self, mock_model, mock_tokenizer
):
"""Published admin snapshot must not mutate when live state changes."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-snap",
prompt=[1, 2, 3],
sampling_params=SamplingParams(max_tokens=8),
)
request.prompt_token_ids = [1, 2, 3]
request.num_prompt_tokens = 3
scheduler.waiting.append(request)
scheduler.running["req-snap"] = request
scheduler._publish_admin_snapshot()
snap = scheduler.snapshot_for_admin()
assert snap["running_by_id"] == {"req-snap": request}
assert snap["waiting"] == [request]
scheduler.running.clear()
scheduler.waiting.clear()
# Snapshot reflects the published moment, not the live state.
assert snap["running_by_id"] == {"req-snap": request}
assert snap["waiting"] == [request]
class TestSchedulerAddRequest:
"""Tests for Scheduler.add_request()."""
def _scheduler_with_mock_block_cache(
self,
mock_model,
mock_tokenizer,
*,
hot_cache_max_size: int = 1024,
hot_cache_only: bool = False,
):
config = SchedulerConfig(
hot_cache_max_size=hot_cache_max_size,
hot_cache_only=hot_cache_only,
)
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=config,
)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = MagicMock()
scheduler.paged_ssd_cache_manager = MagicMock()
scheduler._prefill_memory_guard = True
scheduler._memory_limit_bytes = 100
return scheduler
def test_add_request_with_string_prompt(self, mock_model, mock_tokenizer):
"""Test adding a request with string prompt."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello, world!",
sampling_params=SamplingParams(max_tokens=50),
)
scheduler.add_request(request)
assert "test-001" in scheduler.requests
assert request in scheduler.waiting
assert request.prompt_token_ids is not None
assert len(request.prompt_token_ids) > 0
assert request.num_prompt_tokens == len(request.prompt_token_ids)
def test_add_request_with_token_ids(self, mock_model, mock_tokenizer):
"""Test adding a request with pre-tokenized prompt."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
token_ids = [1, 100, 200, 300]
request = Request(
request_id="test-002",
prompt=token_ids,
sampling_params=SamplingParams(max_tokens=50),
)
# Pre-set token IDs
request.prompt_token_ids = token_ids
request.num_prompt_tokens = len(token_ids)
scheduler.add_request(request)
assert "test-002" in scheduler.requests
assert request.prompt_token_ids == token_ids
assert request.num_prompt_tokens == 4
def test_add_duplicate_request_raises(self, mock_model, mock_tokenizer):
"""Test adding duplicate request raises ValueError."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
with pytest.raises(ValueError, match="already exists"):
scheduler.add_request(request)
def test_add_multiple_requests(self, mock_model, mock_tokenizer):
"""Test adding multiple requests."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
for i in range(5):
request = Request(
request_id=f"test-{i:03d}",
prompt=f"Prompt {i}",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
assert len(scheduler.requests) == 5
assert len(scheduler.waiting) == 5
def test_add_request_exact_cache_hit_trims_one_token(
self, mock_model, mock_tokenizer
):
"""Exact cache hit should use (N-1) cache + last token for kickoff."""
from omlx.cache.paged_cache import BlockTable
class TrimCache:
def __init__(self):
self.trim_calls = 0
def trim(self, n):
self.trim_calls += 1
return n
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = MagicMock()
block_table = BlockTable(request_id="req-exact", block_ids=[1, 2], num_tokens=4)
trim_cache_a = TrimCache()
trim_cache_b = TrimCache()
scheduler.block_aware_cache.fetch_cache.return_value = (block_table, [])
scheduler.block_aware_cache.reconstruct_cache.return_value = [
trim_cache_a,
trim_cache_b,
]
request = Request(
request_id="req-exact",
prompt=[11, 12, 13, 14],
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
scheduler._prepare_prefix_cache_for_request(request)
assert request.cached_tokens == 3
assert request.remaining_tokens == [14]
assert request.prompt_cache is not None
assert trim_cache_a.trim_calls == 1
assert trim_cache_b.trim_calls == 1
def test_add_request_exact_cache_hit_falls_back_if_not_trimmable(
self, mock_model, mock_tokenizer
):
"""Exact cache hit should fallback when any layer cannot trim."""
from omlx.cache.paged_cache import BlockTable
class NonTrimmableCache:
pass
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = MagicMock()
block_table = BlockTable(request_id="req-fallback", block_ids=[3], num_tokens=4)
scheduler.block_aware_cache.fetch_cache.return_value = (block_table, [])
scheduler.block_aware_cache.reconstruct_cache.return_value = [
NonTrimmableCache()
]
request = Request(
request_id="req-fallback",
prompt=[21, 22, 23, 24],
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
scheduler._prepare_prefix_cache_for_request(request)
assert request.cached_tokens == 0
assert request.remaining_tokens == [21, 22, 23, 24]
assert request.prompt_cache is None
scheduler.paged_cache_manager.delete_block_table.assert_called_once_with(
"req-fallback"
)
def test_add_request_exact_cache_hit_rotating_forces_fallback(
self, mock_model, mock_tokenizer
):
"""Rotating cache exact hit should fallback to full prefill."""
from omlx.cache.paged_cache import BlockTable
RotatingCacheWithTrim = type(
"RotatingKVCache",
(),
{"trim": lambda self, n: n},
)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = MagicMock()
block_table = BlockTable(request_id="req-rotating", block_ids=[9], num_tokens=4)
scheduler.block_aware_cache.fetch_cache.return_value = (block_table, [])
scheduler.block_aware_cache.reconstruct_cache.return_value = [
RotatingCacheWithTrim()
]
request = Request(
request_id="req-rotating",
prompt=[31, 32, 33, 34],
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
scheduler._prepare_prefix_cache_for_request(request)
assert request.cached_tokens == 0
assert request.remaining_tokens == [31, 32, 33, 34]
assert request.prompt_cache is None
scheduler.paged_cache_manager.delete_block_table.assert_called_once_with(
"req-rotating"
)
def test_add_request_under_pressure_skips_hot_cache_preload_and_promotion(
self, mock_model, mock_tokenizer
):
"""Memory pressure should bypass optional SSD hot-cache RAM copies."""
from omlx.cache.paged_cache import BlockTable
scheduler = self._scheduler_with_mock_block_cache(
mock_model,
mock_tokenizer,
)
scheduler._current_usage_bytes = MagicMock(return_value=100)
block_table = BlockTable(
request_id="req-pressure",
block_ids=[1],
num_tokens=2,
)
scheduler.block_aware_cache.fetch_cache.return_value = (
block_table,
[13, 14],
)
scheduler.block_aware_cache.reconstruct_cache.return_value = [MagicMock()]
request = Request(
request_id="req-pressure",
prompt=[11, 12, 13, 14],
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
scheduler._prepare_prefix_cache_for_request(request)
scheduler.block_aware_cache.preload_blocks.assert_not_called()
scheduler.block_aware_cache.reconstruct_cache.assert_called_once_with(
block_table,
promote_to_hot_cache=False,
)
scheduler._current_usage_bytes.assert_called_once_with()
def test_add_request_below_pressure_keeps_hot_cache_preload_and_promotion(
self, mock_model, mock_tokenizer
):
"""Normal memory state should preserve existing hot-cache acceleration."""
from omlx.cache.paged_cache import BlockTable
scheduler = self._scheduler_with_mock_block_cache(
mock_model,
mock_tokenizer,
)
scheduler._current_usage_bytes = MagicMock(return_value=99)
block_table = BlockTable(
request_id="req-normal",
block_ids=[1],
num_tokens=2,
)
scheduler.block_aware_cache.fetch_cache.return_value = (
block_table,
[13, 14],
)
scheduler.block_aware_cache.reconstruct_cache.return_value = [MagicMock()]
request = Request(
request_id="req-normal",
prompt=[11, 12, 13, 14],
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
scheduler._prepare_prefix_cache_for_request(request)
scheduler.block_aware_cache.preload_blocks.assert_called_once_with(block_table)
scheduler.block_aware_cache.reconstruct_cache.assert_called_once_with(
block_table
)
def test_add_request_hot_cache_only_ignores_pressure_bypass(
self, mock_model, mock_tokenizer
):
"""hot_cache_only mode must keep RAM hot-cache behavior unchanged."""
from omlx.cache.paged_cache import BlockTable
scheduler = self._scheduler_with_mock_block_cache(
mock_model,
mock_tokenizer,
hot_cache_only=True,
)
scheduler._current_usage_bytes = MagicMock(return_value=100)
block_table = BlockTable(
request_id="req-hot-only",
block_ids=[1],
num_tokens=2,
)
scheduler.block_aware_cache.fetch_cache.return_value = (
block_table,
[13, 14],
)
scheduler.block_aware_cache.reconstruct_cache.return_value = [MagicMock()]
request = Request(
request_id="req-hot-only",
prompt=[11, 12, 13, 14],
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
scheduler._prepare_prefix_cache_for_request(request)
scheduler.block_aware_cache.preload_blocks.assert_called_once_with(block_table)
scheduler.block_aware_cache.reconstruct_cache.assert_called_once_with(
block_table
)
scheduler._current_usage_bytes.assert_not_called()
def test_admission_defers_for_relevant_inflight_store(
self, mock_model, mock_tokenizer
):
"""A same-conversation request should defer lookup until store finishes."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
prompt = list(range(9001))
scheduler.block_aware_cache.fetch_cache.return_value = (None, prompt)
future = MagicMock()
future.done.return_value = False
future.result.return_value = None
scheduler._inflight_store_futures["req-prev"] = future
scheduler._inflight_store_info["req-prev"] = (
scheduler_module._InflightStoreInfo(tokens=list(range(9000)))
)
request = Request(
request_id="req-next",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
future.result.assert_not_called()
scheduler.block_aware_cache.fetch_cache.assert_not_called()
assert scheduler._should_defer_for_cache_freshness(request) is True
future.result.assert_not_called()
scheduler.block_aware_cache.fetch_cache.assert_not_called()
assert request.request_id in scheduler._cache_freshness_waits
future.done.return_value = True
assert scheduler._should_defer_for_cache_freshness(request) is False
scheduler._prepare_prefix_cache_for_request(request)
scheduler.block_aware_cache.fetch_cache.assert_called_once()
def test_admission_defers_for_ratio_relevant_inflight_store(
self, mock_model, mock_tokenizer
):
"""A large prompt should defer when the shared prefix is a high ratio."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
prompt = list(range(9001))
scheduler.block_aware_cache.fetch_cache.return_value = (None, prompt)
future = MagicMock()
future.done.return_value = False
future.result.return_value = None
scheduler._inflight_store_futures["req-prev"] = future
scheduler._inflight_store_info["req-prev"] = (
scheduler_module._InflightStoreInfo(tokens=list(range(3000)))
)
request = Request(
request_id="req-next",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
future.result.assert_not_called()
scheduler.block_aware_cache.fetch_cache.assert_not_called()
assert scheduler._should_defer_for_cache_freshness(request) is True
assert request.request_id in scheduler._cache_freshness_waits
def test_admission_does_not_defer_below_common_and_ratio_thresholds(
self, mock_model, mock_tokenizer
):
"""A moderate shared prefix should not defer below both relevance gates."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
prompt = list(range(30000))
future = MagicMock()
future.done.return_value = False
future.result.return_value = None
scheduler._inflight_store_futures["req-prev"] = future
scheduler._inflight_store_info["req-prev"] = (
scheduler_module._InflightStoreInfo(tokens=list(range(7000)))
)
request = Request(
request_id="req-next",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
future.result.assert_not_called()
scheduler.block_aware_cache.fetch_cache.assert_not_called()
assert scheduler._should_defer_for_cache_freshness(request) is False
assert request.request_id not in scheduler._cache_freshness_waits
def test_admission_does_not_defer_for_short_prompt_even_with_high_ratio(
self, mock_model, mock_tokenizer
):
"""Prompts below the freshness minimum should never wait on store_cache."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
prompt = list(range(7000))
future = MagicMock()
future.done.return_value = False
future.result.return_value = None
scheduler._inflight_store_futures["req-prev"] = future
scheduler._inflight_store_info["req-prev"] = (
scheduler_module._InflightStoreInfo(tokens=list(range(6000)))
)
request = Request(
request_id="req-next",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
future.result.assert_not_called()
scheduler.block_aware_cache.fetch_cache.assert_not_called()
assert scheduler._should_defer_for_cache_freshness(request) is False
assert request.request_id not in scheduler._cache_freshness_waits
def test_admission_defers_for_relevant_store_during_active_work(
self, mock_model, mock_tokenizer
):
"""Active decode/prefill rows should not skip a highly relevant store."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
prompt = list(range(9001))
scheduler.block_aware_cache.fetch_cache.return_value = (None, prompt)
scheduler.running["req-running"] = MagicMock()
scheduler.prefilling.append(MagicMock())
future = MagicMock()
future.done.return_value = False
future.result.return_value = None
scheduler._inflight_store_futures["req-prev"] = future
scheduler._inflight_store_info["req-prev"] = (
scheduler_module._InflightStoreInfo(tokens=list(range(9000)))
)
request = Request(
request_id="req-next",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
future.result.assert_not_called()
scheduler.block_aware_cache.fetch_cache.assert_not_called()
assert scheduler._should_defer_for_cache_freshness(request) is True
def test_schedule_waiting_defers_cache_freshness_without_blocking(
self, mock_model, mock_tokenizer
):
"""Freshness waits must defer admission, not block scheduler execution."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
prompt = list(range(9001))
future = MagicMock()
future.done.return_value = False
scheduler._inflight_store_futures["req-prev"] = future
scheduler._inflight_store_info["req-prev"] = (
scheduler_module._InflightStoreInfo(tokens=list(range(9000)))
)
scheduler._ensure_batch_generator = MagicMock()
request = Request(
request_id="req-next",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=16),
)
scheduler.add_request(request)
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert rejected == []
assert list(scheduler.waiting) == [request]
assert request.request_id in scheduler._cache_freshness_waits
future.result.assert_not_called()
scheduler.block_aware_cache.fetch_cache.assert_not_called()
scheduler._ensure_batch_generator.assert_not_called()
def test_admission_does_not_defer_for_mismatched_extra_keys(
self, mock_model, mock_tokenizer
):
"""Token-only overlap must not delay VLM requests with different cache keys."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
prompt = list(range(9001))
scheduler.block_aware_cache.fetch_cache.return_value = (None, prompt)
future = MagicMock()
future.done.return_value = False
scheduler._inflight_store_futures["req-prev"] = future
scheduler._inflight_store_info["req-prev"] = (
scheduler_module._InflightStoreInfo(
tokens=list(range(9000)),
extra_keys=("image-a",),
extra_key_token_start=0,
)
)
request = Request(
request_id="req-next",
prompt=prompt,
sampling_params=SamplingParams(max_tokens=16),
vlm_image_hash="image-b",
vlm_cache_key_start=0,
)
scheduler.add_request(request)
future.result.assert_not_called()
assert scheduler._should_defer_for_cache_freshness(request) is False
scheduler._prepare_prefix_cache_for_request(request)
scheduler.block_aware_cache.fetch_cache.assert_called_once()
def test_async_store_cache_worker_forwards_hot_cache_write_back_flag(
self, mock_model, mock_tokenizer
):
"""The async store worker must pass pressure mode to store_cache."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.block_aware_cache = MagicMock()
scheduler.block_aware_cache.store_cache.return_value = None
scheduler.paged_cache_manager = MagicMock()
scheduler.paged_cache_manager.get_block_table.return_value = None
with patch("omlx.scheduler._safe_sync_stream"):
scheduler._async_store_cache_worker(
"req-store",
[1, 2, 3, 4],
[],
None,
None,
None,
None,
None,
hot_cache_write_back=False,
)
scheduler.block_aware_cache.store_cache.assert_called_once_with(
"req-store",
[1, 2, 3, 4],
[],
model_cache_config=None,
boundary_snapshots=None,
extra_keys=None,
extra_key_token_start=None,
extra_key_ranges=None,
hot_cache_write_back=False,
)
class TestSchedulerAbortRequest:
"""Tests for Scheduler.abort_request() (deferred abort pattern)."""
def test_abort_enqueues_request(self, mock_model, mock_tokenizer):
"""Test abort_request() enqueues for deferred processing."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
result = scheduler.abort_request("test-001")
# abort_request always returns True (enqueue is always successful)
assert result is True
# Request should still be in waiting (not yet processed)
assert "test-001" in scheduler._pending_abort_ids
def test_abort_waiting_request(self, mock_model, mock_tokenizer):
"""Test aborting a waiting request via deferred processing."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
scheduler.abort_request("test-001")
scheduler._process_pending_aborts()
assert request.status == RequestStatus.FINISHED_ABORTED
assert request not in scheduler.waiting
assert "test-001" in scheduler.finished_req_ids
def test_abort_nonexistent_request(self, mock_model, mock_tokenizer):
"""Test aborting a non-existent request is silently ignored."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
result = scheduler.abort_request("nonexistent")
# Enqueue always succeeds
assert result is True
# Processing a non-existent abort is a no-op
scheduler._process_pending_aborts()
def test_abort_sets_finish_reason(self, mock_model, mock_tokenizer):
"""Test aborting sets correct finish reason."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
scheduler.abort_request("test-001")
scheduler._process_pending_aborts()
assert request.get_finish_reason() == "abort"
def test_abort_running_request_removes_from_batch(self, mock_model, mock_tokenizer):
"""Abort must remove active UID from BatchGenerator."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-run",
prompt="Hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1]
request.num_prompt_tokens = 1
request.status = RequestStatus.RUNNING
uid = 7
scheduler.requests["req-run"] = request
scheduler.running["req-run"] = request
scheduler.request_id_to_uid["req-run"] = uid
scheduler.uid_to_request_id[uid] = "req-run"
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.active_batch = MagicMock(uids=[uid])
scheduler.abort_request("req-run")
scheduler._process_pending_aborts()
scheduler.batch_generator.remove.assert_called_once_with([uid])
def test_abort_running_request_always_calls_remove(
self, mock_model, mock_tokenizer
):
"""Abort always calls remove() — BatchGenerator handles unknown UIDs."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-run-missing",
prompt="Hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1]
request.num_prompt_tokens = 1
request.status = RequestStatus.RUNNING
uid = 8
scheduler.requests["req-run-missing"] = request
scheduler.running["req-run-missing"] = request
scheduler.request_id_to_uid["req-run-missing"] = uid
scheduler.uid_to_request_id[uid] = "req-run-missing"
scheduler.batch_generator = MagicMock()
scheduler.abort_request("req-run-missing")
scheduler._process_pending_aborts()
scheduler.batch_generator.remove.assert_called_once_with([uid])
def test_abort_vlm_mtp_request_clears_active_generator(
self, mock_model, mock_tokenizer
):
"""Aborting negative vlm_mtp UIDs must release the serialized drafter."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-vlm-mtp",
prompt="Hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1]
request.num_prompt_tokens = 1
request.status = RequestStatus.RUNNING
class ClosableGenerator:
closed = False
def __next__(self):
return 1
def close(self):
self.closed = True
generator = ClosableGenerator()
uid = -1
scheduler.requests[request.request_id] = request
scheduler.running[request.request_id] = request
scheduler.request_id_to_uid[request.request_id] = uid
scheduler.uid_to_request_id[uid] = request.request_id
scheduler._vlm_mtp_active[uid] = _VLMMTPDecodeState(
generator=generator,
request=request,
prompt_cache=[],
sampler=MagicMock(),
state_machine=MagicMock(),
max_tokens=16,
)
scheduler.batch_generator = MagicMock()
scheduler.abort_request(request.request_id)
scheduler._process_pending_aborts()
assert uid not in scheduler._vlm_mtp_active
assert generator.closed is True
scheduler.batch_generator.remove.assert_not_called()
def test_abort_cleans_all_scheduler_state(self, mock_model, mock_tokenizer):
"""Abort must clean running, uid mappings, and requests dict.
Regression test: previously _cleanup_request (engine_core) removed
the request from self.requests before the deferred abort ran,
causing _do_abort_request to early-return and leave ghost state
in running/uid mappings/active batch.
"""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-ghost",
prompt="Hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1]
request.num_prompt_tokens = 1
request.status = RequestStatus.RUNNING
uid = 10
scheduler.requests["req-ghost"] = request
scheduler.running["req-ghost"] = request
scheduler.request_id_to_uid["req-ghost"] = uid
scheduler.uid_to_request_id[uid] = "req-ghost"
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.active_batch = MagicMock(uids=[uid])
scheduler.abort_request("req-ghost")
scheduler._process_pending_aborts()
# All scheduler state must be cleaned
assert "req-ghost" not in scheduler.running
assert "req-ghost" not in scheduler.requests
assert "req-ghost" not in scheduler.request_id_to_uid
assert uid not in scheduler.uid_to_request_id
class TestPrefillAbortInterrupt:
"""Tests for prefill abort interrupt via _check_pending_aborts_for_uids."""
def test_check_pending_aborts_returns_aborted_uids(
self, mock_model, mock_tokenizer
):
"""_check_pending_aborts_for_uids returns UIDs with pending aborts."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
# Set up UID mapping
scheduler.uid_to_request_id[0] = "req-a"
scheduler.uid_to_request_id[1] = "req-b"
scheduler._pending_abort_ids.add("req-a")
result = scheduler._check_pending_aborts_for_uids([0, 1])
assert result == [0]
def test_check_pending_aborts_empty_when_no_aborts(
self, mock_model, mock_tokenizer
):
"""Returns empty list when no pending aborts."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.uid_to_request_id[0] = "req-a"
result = scheduler._check_pending_aborts_for_uids([0])
assert result == []
def test_external_prefill_abort_reclaims_metal_before_raise(
self, mock_model, mock_tokenizer
):
"""Aborted external prefill must clear transients before unwinding."""
from omlx.scheduler import _PrefillAbortedError
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=SchedulerConfig(prefill_step_size=2),
)
request = Request(
request_id="req-prefill-abort",
prompt=[1, 2, 3],
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3]
request.num_prompt_tokens = 3
cache = [SimpleNamespace(state=mx.array([0]))]
uid = 42
scheduler.request_id_to_uid[request.request_id] = uid
scheduler.uid_to_request_id[uid] = request.request_id
scheduler._pending_abort_ids.add(request.request_id)
with patch.object(scheduler_module, "_sync_and_clear_cache") as clear_cache:
with pytest.raises(_PrefillAbortedError):
scheduler._do_external_prefill(
request,
request.prompt_token_ids,
cache,
)
assert clear_cache.call_args_list == [
call(scheduler._stream),
call(scheduler._stream),
]
assert request._prefill_saved_rope_deltas is None
def test_prefill_abort_cleanup_removes_temp_uid_and_pending_abort(
self, mock_model, mock_tokenizer
):
"""Schedule-time prefill aborts must not leave orphan scheduler state."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-clean-prefill-abort",
prompt=[1, 2, 3],
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3]
request.num_prompt_tokens = 3
scheduler.requests[request.request_id] = request
temp_uid = id(request)
scheduler.request_id_to_uid[request.request_id] = temp_uid
scheduler.uid_to_request_id[temp_uid] = request.request_id
scheduler._pending_abort_ids.add(request.request_id)
scheduler._cleanup_prefill_abort_request(request, temp_uid=temp_uid)
assert request.status == RequestStatus.FINISHED_ABORTED
assert request.request_id not in scheduler.requests
assert request.request_id not in scheduler.request_id_to_uid
assert temp_uid not in scheduler.uid_to_request_id
assert request.request_id not in scheduler._pending_abort_ids
def test_prefill_aborted_error_resets_batch_generator(
self, mock_model, mock_tokenizer
):
"""_PrefillAbortedError in step() resets batch_generator to None."""
from omlx.scheduler import _PrefillAbortedError
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.batch_generator = MagicMock()
# Make next_generated() raise _PrefillAbortedError
# (simulates abort during external prefill in _schedule_waiting)
scheduler.batch_generator.next_generated.side_effect = _PrefillAbortedError(
[0], 1024
)
# Need running requests for next_generated() to be called
request = Request(
request_id="req-prefill",
prompt="Hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1]
request.num_prompt_tokens = 1
request.status = RequestStatus.RUNNING
scheduler.running["req-prefill"] = request
scheduler.requests["req-prefill"] = request
output = scheduler.step()
# batch_generator should be reset
assert scheduler.batch_generator is None
# Request should be moved back to waiting
assert "req-prefill" not in scheduler.running
assert len(scheduler.waiting) > 0
class TestSchedulerQueryMethods:
"""Tests for Scheduler query methods."""
def test_has_requests_empty(self, mock_model, mock_tokenizer):
"""Test has_requests() returns False when empty."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler.has_requests() is False
def test_has_requests_with_waiting(self, mock_model, mock_tokenizer):
"""Test has_requests() returns True with waiting requests."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
assert scheduler.has_requests() is True
def test_has_requests_with_pending_async_cleanup(self, mock_model, mock_tokenizer):
"""Async store-cache cleanup keeps the scheduler stepping."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
future = concurrent.futures.Future()
scheduler._pending_async_removes.append((123, "req-cleanup", future))
assert scheduler.has_requests() is True
def test_get_num_waiting(self, mock_model, mock_tokenizer):
"""Test get_num_waiting() returns correct count."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler.get_num_waiting() == 0
for i in range(3):
request = Request(
request_id=f"test-{i}",
prompt=f"Prompt {i}",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
assert scheduler.get_num_waiting() == 3
def test_get_num_running(self, mock_model, mock_tokenizer):
"""Test get_num_running() returns correct count."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler.get_num_running() == 0
# Manually add to running for testing
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.running["test-001"] = request
assert scheduler.get_num_running() == 1
def test_get_request(self, mock_model, mock_tokenizer):
"""Test get_request() returns correct request."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
retrieved = scheduler.get_request("test-001")
assert retrieved is request
def test_get_request_nonexistent(self, mock_model, mock_tokenizer):
"""Test get_request() returns None for nonexistent request."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler.get_request("nonexistent") is None
class TestSchedulerStatistics:
"""Tests for Scheduler.get_stats()."""
def test_get_stats_initial(self, mock_model, mock_tokenizer):
"""Test get_stats() returns correct initial values."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
stats = scheduler.get_stats()
assert stats["num_waiting"] == 0
assert stats["num_running"] == 0
assert stats["num_requests_processed"] == 0
assert stats["total_prompt_tokens"] == 0
assert stats["total_completion_tokens"] == 0
def test_get_stats_with_requests(self, mock_model, mock_tokenizer):
"""Test get_stats() reflects added requests."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
for i in range(3):
request = Request(
request_id=f"test-{i}",
prompt=f"Prompt {i}",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
stats = scheduler.get_stats()
assert stats["num_waiting"] == 3
assert stats["num_running"] == 0
class TestSchedulerReset:
"""Tests for Scheduler reset methods."""
def test_reset_clears_state(self, mock_model, mock_tokenizer):
"""Test reset() clears all scheduler state."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
# Add some requests
for i in range(3):
request = Request(
request_id=f"test-{i}",
prompt=f"Prompt {i}",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
scheduler.reset()
assert len(scheduler.waiting) == 0
assert len(scheduler.running) == 0
assert len(scheduler.requests) == 0
assert scheduler.batch_generator is None
def test_reset_clears_async_store_cache_bookkeeping(
self, mock_model, mock_tokenizer
):
"""reset() must drop _pending_async_removes and _inflight_store_futures.
Regression for #1459: a slow async store_cache worker can leave the
deferred _drain_pending_async_removes step that nulls req._extracted_cache
pending. If reset() leaves these two containers populated, the futures
keep Request references alive and the KV cache stays pinned for the rest
of the process lifetime.
"""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
fake_future = MagicMock()
scheduler._pending_async_removes.append((999, "req-leaked", fake_future))
scheduler._inflight_store_futures["req-leaked"] = fake_future
scheduler.reset()
assert len(scheduler._pending_async_removes) == 0
assert len(scheduler._inflight_store_futures) == 0
def test_shutdown_drains_after_bounded_wait(self, mock_model, mock_tokenizer):
"""shutdown() must drain pending removes after the bounded wait.
Regression for #1459. If the bounded wait completes, every future is
done and the second drain releases skipped entries. If it does not
complete, shutdown takes the fatal-exit path instead of leaving a
partially torn-down engine alive.
Asserts: drain runs both before and after executor.shutdown(wait=False).
"""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
fake_executor = MagicMock()
scheduler._store_cache_executor = fake_executor
scheduler._store_cache_gate = MagicMock()
# Seed an inflight future so shutdown() enters the wait branch.
scheduler._inflight_store_futures["req-slow"] = MagicMock()
call_order = []
original_drain = scheduler._drain_pending_async_removes
def record_drain():
call_order.append("drain")
original_drain()
def record_executor_shutdown(wait=True):
call_order.append("executor_shutdown")
fake_executor.shutdown.side_effect = record_executor_shutdown
scheduler._drain_pending_async_removes = record_drain
with patch("concurrent.futures.wait", return_value=({object()}, set())):
scheduler.shutdown()
assert call_order == [
"drain",
"executor_shutdown",
"drain",
], f"Expected drain to bracket executor.shutdown, got: {call_order}"
fake_executor.shutdown.assert_called_once_with(wait=False)
def test_shutdown_closes_boundary_snapshot_store(
self, mock_model, mock_tokenizer
):
"""shutdown() must stop the boundary snapshot writer thread.
cleanup_all() only clears the store contents. If shutdown() is skipped,
the writer thread can keep its last raw tensor-byte queue item alive.
"""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
store = MagicMock()
scheduler._boundary_snapshot_store = store
scheduler.shutdown()
store.cleanup_all.assert_called_once_with()
store.shutdown.assert_called_once_with()
assert scheduler._boundary_snapshot_store is None
def test_deep_reset_closes_boundary_snapshot_store(
self, mock_model, mock_tokenizer
):
"""deep_reset() destroys the scheduler and must stop snapshot writers."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
store = MagicMock()
scheduler._boundary_snapshot_store = store
scheduler.deep_reset()
store.cleanup_all.assert_called_once_with()
store.shutdown.assert_called_once_with()
assert scheduler._boundary_snapshot_store is None
def test_shutdown_fatal_exits_when_store_cache_worker_times_out(
self, mock_model, mock_tokenizer
):
"""A stuck store-cache worker is fatal during scheduler shutdown."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
fake_executor = MagicMock()
scheduler._store_cache_executor = fake_executor
scheduler._store_cache_gate = MagicMock()
future = MagicMock()
scheduler._inflight_store_futures["req-stuck"] = future
with (
patch("concurrent.futures.wait", return_value=(set(), {future})),
patch("omlx.scheduler.fatal_exit", side_effect=SystemExit) as fatal,
pytest.raises(SystemExit),
):
scheduler.shutdown()
assert "Scheduler shutdown timed out after 60s" in fatal.call_args.args[0]
fake_executor.shutdown.assert_not_called()
class TestSchedulerStopTokens:
"""Tests for stop token handling."""
def test_get_stop_tokens(self, mock_model, mock_tokenizer):
"""Test _get_stop_tokens() retrieves EOS token."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
stop_tokens = scheduler._get_stop_tokens()
# MockTokenizer has eos_token_id = 2
assert mock_tokenizer.eos_token_id in stop_tokens
def test_includes_eot_token_id(self, mock_model, mock_tokenizer):
"""Test _get_stop_tokens() includes end-of-turn token when available."""
# eot_token_id as a single int
mock_tokenizer.eot_token_id = 106
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
stop_tokens = scheduler._get_stop_tokens()
assert 106 in stop_tokens
assert mock_tokenizer.eos_token_id in stop_tokens # EOS still there too
def test_includes_eot_token_id_list(self, mock_model, mock_tokenizer):
"""Test _get_stop_tokens() handles eot_token_id as a list."""
mock_tokenizer.eot_token_id = [106, 107]
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
stop_tokens = scheduler._get_stop_tokens()
assert 106 in stop_tokens
assert 107 in stop_tokens
def test_falls_back_to_eot_token_encoding(self, mock_model, mock_tokenizer):
"""When eot_token_id is absent but eot_token string is present, encode it."""
mock_tokenizer.eot_token = "<turn|>"
# Ensure eot_token_id is NOT present
assert (
not hasattr(mock_tokenizer, "eot_token_id")
or mock_tokenizer.eot_token_id is None
)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
stop_tokens = scheduler._get_stop_tokens()
# The MockTokenizer.encode() returns hash-based IDs, so we get something
assert len([t for t in stop_tokens if t != mock_tokenizer.eos_token_id]) > 0
def test_no_eot_token_when_absent(self, mock_model, mock_tokenizer):
"""When neither eot_token_id nor eot_token string is present, no crash."""
# MockTokenizer has no eot_token_id or eot_token by default
assert not hasattr(mock_tokenizer, "eot_token_id")
assert not hasattr(mock_tokenizer, "eot_token")
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
stop_tokens = scheduler._get_stop_tokens()
assert mock_tokenizer.eos_token_id in stop_tokens
class TestSchedulerSuppressTokens:
"""Tests for generation_config.suppress_tokens handling."""
def test_loads_generation_config_suppress_tokens(
self, mock_model, mock_tokenizer, tmp_path
):
(tmp_path / "generation_config.json").write_text(
json.dumps({"suppress_tokens": [258883, 258882]}),
encoding="utf-8",
)
mock_tokenizer.name_or_path = str(tmp_path)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler._model_suppress_tokens == {258883, 258882}
def test_suppress_logits_processor_masks_configured_ids(
self, mock_model, mock_tokenizer
):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._model_suppress_tokens = {2}
_, processors = scheduler._build_sampler_and_processors(SamplingParams())
assert processors
logits = mx.array([[0.0, 4.0, 100.0, 2.0]])
masked = processors[-1](mx.array([1]), logits)
mx.eval(masked)
assert float(masked[0, 2].item()) == float("-inf")
assert float(masked[0, 1].item()) == 4.0
def test_vlm_mtp_first_bonus_uses_suppressing_sampler(
self, mock_model, mock_tokenizer
):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._model_suppress_tokens = {3}
scheduler._vlm_mtp_drafter = MagicMock()
class FakeLanguageModel:
def rollback_speculative_cache(self):
pass
def __call__(self, *args, **kwargs):
return SimpleNamespace(
logits=mx.array([[[0.0, 0.0, 5.0, 99.0, 0.0]]]),
hidden_states=mx.zeros((1, 1, 4)),
shared_kv_states={},
)
class FakeVLMAdapter:
def __init__(self):
self._language_model = FakeLanguageModel()
self.calls = []
self.batch_rope_deltas = None
def set_batch_rope_deltas(self, deltas):
self.batch_rope_deltas = deltas
def __call__(self, *args, **kwargs):
self.calls.append((args, kwargs))
return self._language_model(*args, **kwargs)
mock_model = FakeVLMAdapter()
scheduler.model = mock_model
request = Request(
request_id="req-mtp",
prompt=[1],
sampling_params=SamplingParams(max_tokens=4),
)
request.prompt_token_ids = [1]
request.rope_deltas = 123.0
cache = [SimpleNamespace(state=mx.array([0]))]
def sampler(logits):
return mx.argmax(logits, axis=-1)
captured = {}
def fake_run_vlm_mtp_decode(**kwargs):
captured.update(kwargs)
def _gen():
yield kwargs["first_bonus"]
return _gen()
with patch.object(
scheduler_module,
"run_vlm_mtp_decode",
side_effect=fake_run_vlm_mtp_decode,
):
uid = scheduler._route_to_vlm_mtp(
request,
cache,
[1],
sampler,
state_machine=object(),
)
assert uid is not None
assert mock_model.calls
assert captured["target_language_model"] is mock_model
assert float(mock_model.batch_rope_deltas.item()) == 123.0
assert captured["first_bonus"] == 2
assert "prompt_tokens" not in captured
round_logits = mx.array([[0.0, 0.0, 1.0, 99.0, 0.0]])
round_token = captured["sampler"](round_logits)
mx.eval(round_token)
assert int(round_token.item()) == 2
class TestSchedulerXtcSpecialTokens:
"""Tests for _get_xtc_special_tokens()."""
def test_includes_newline_and_eos(self, mock_model, mock_tokenizer):
"""Test that XTC special tokens include newline encoding and EOS."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
tokens = scheduler._get_xtc_special_tokens()
# Should include tokens from encoding "\n"
newline_tokens = mock_tokenizer.encode("\n")
for t in newline_tokens:
assert t in tokens
# Should include eos_token_id (MockTokenizer has eos_token_id=2)
assert mock_tokenizer.eos_token_id in tokens
def test_includes_eos_token_ids_plural(self, mock_model, mock_tokenizer):
"""Test that eos_token_ids (plural) is used when available."""
mock_tokenizer.eos_token_ids = [2, 100, 200]
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
tokens = scheduler._get_xtc_special_tokens()
for eos_id in [2, 100, 200]:
assert eos_id in tokens
def test_falls_back_to_singular_eos(self, mock_model, mock_tokenizer):
"""Test fallback to eos_token_id when eos_token_ids is absent."""
# MockTokenizer has eos_token_id=2 but no eos_token_ids
assert not hasattr(mock_tokenizer, "eos_token_ids")
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
tokens = scheduler._get_xtc_special_tokens()
assert 2 in tokens
def test_includes_parser_stop_tokens_without_base_stop(
self, mock_model, mock_tokenizer
):
"""Parser stop tokens are XTC-protected but not BatchGenerator stops."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._output_parser_factory = _ParserStopFactory()
scheduler._output_parser_factory.stop_token_ids = {101, 102}
stop_tokens = scheduler._get_stop_tokens()
xtc_tokens = scheduler._get_xtc_special_tokens()
assert 101 not in stop_tokens
assert 102 not in stop_tokens
assert 101 in xtc_tokens
assert 102 in xtc_tokens
class TestSyncAndClearCache:
"""Tests for module-level _sync_and_clear_cache() helper (#300, #888)."""
def test_swallows_generation_stream_thread_error(self):
"""generation_stream sync failing must not break cache clear.
Reproduces #888: on some MLX builds mx.synchronize(generation_stream)
raises 'There is no Stream(gpu, 0) in current thread' when called
from an executor thread that has not submitted work to that stream
(e.g. during teardown before the thread submits work). The helper must
swallow that RuntimeError and still drain the default stream + clear
the cache.
"""
from omlx import scheduler as sched_mod
calls = []
def fake_gen_sync(stream):
calls.append(("gen_sync", stream))
raise RuntimeError("There is no Stream(gpu, 0) in current thread.")
def fake_default_sync():
calls.append(("default_sync",))
def fake_clear_cache():
calls.append(("clear_cache",))
def dispatch(*args, **kwargs):
if args:
fake_gen_sync(args[0])
else:
fake_default_sync()
with (
patch.object(sched_mod.mx, "synchronize", side_effect=dispatch),
patch.object(sched_mod.mx, "clear_cache", side_effect=fake_clear_cache),
):
sched_mod._sync_and_clear_cache()
assert calls[0][0] == "gen_sync"
assert ("default_sync",) in calls
assert ("clear_cache",) in calls
def test_propagates_default_stream_error(self):
"""Errors on the default stream sync are not swallowed."""
from omlx import scheduler as sched_mod
def dispatch(*args, **kwargs):
if not args:
raise RuntimeError("default stream failure")
with (
patch.object(sched_mod.mx, "synchronize", side_effect=dispatch),
patch.object(sched_mod.mx, "clear_cache") as clear_cache,
):
with pytest.raises(RuntimeError, match="default stream failure"):
sched_mod._sync_and_clear_cache()
clear_cache.assert_not_called()
class TestStoreCacheWorkerSync:
"""Tests for store-cache worker stream-scoped sync (#1437).
Worker must wait on generation_stream specifically, not on the
default stream. mx.synchronize() with no args only blocks on the
default stream (gpu:0) and leaves the gpu:2 dispatched work
unwaited, racing the buffer-protocol access in
_extract_tensor_bytes -> SIGABRT in get_command_encoder(gpu:2).
"""
def test_safe_sync_passes_generation_stream(self):
"""_safe_sync_stream() with no args must invoke mx.synchronize with
the module-level _default_generation_stream object, not call the
no-args variant.
Regression: PR #1146 wired the worker to bare mx.synchronize()
under the (incorrect) assumption that it was a global barrier
and that stream-scoped sync was unsafe cross-thread. Both
assumptions are wrong: synchronize() defaults to a single
stream, and Stream objects are not thread-local. The worker
path now routes through this helper so the regression has a
single chokepoint to assert against.
"""
from omlx import scheduler as sched_mod
calls = []
def fake_sync(*args, **kwargs):
calls.append(args)
with patch.object(sched_mod.mx, "synchronize", side_effect=fake_sync):
sched_mod._safe_sync_stream()
assert len(calls) == 1
assert (
calls[0] and calls[0][0] is sched_mod._default_generation_stream
), f"Worker sync must target _default_generation_stream, got: {calls}"
def test_safe_sync_swallows_no_stream_runtime_error(self):
"""A 'no Stream' RuntimeError from cross-thread sync must be
swallowed so the worker can still proceed to extract bytes.
On some MLX builds mx.synchronize(stream) raises 'There is no
Stream(gpu, X) in current thread' from a thread that has not
submitted work to that stream. In the store-cache worker that
condition means there is no in-flight gpu:2 work to drain, so
it is safe to continue.
"""
from omlx import scheduler as sched_mod
def fake_sync(*args, **kwargs):
raise RuntimeError("There is no Stream(gpu, 2) in current thread.")
with patch.object(sched_mod.mx, "synchronize", side_effect=fake_sync):
sched_mod._safe_sync_stream()
def test_safe_sync_propagates_other_runtime_errors(self):
"""Real GPU errors must not be silently swallowed."""
from omlx import scheduler as sched_mod
def fake_sync(*args, **kwargs):
raise RuntimeError("Metal command buffer execution failed")
with patch.object(sched_mod.mx, "synchronize", side_effect=fake_sync):
with pytest.raises(RuntimeError, match="command buffer execution failed"):
sched_mod._safe_sync_stream()
class TestSchedulerFormatBytes:
"""Tests for Scheduler._format_bytes()."""
def test_format_bytes_bytes(self):
"""Test formatting bytes."""
assert Scheduler._format_bytes(100) == "100 B"
assert Scheduler._format_bytes(1023) == "1023 B"
def test_format_bytes_kilobytes(self):
"""Test formatting kilobytes."""
result = Scheduler._format_bytes(1024)
assert "KB" in result
result = Scheduler._format_bytes(2048)
assert "2.00 KB" in result
def test_format_bytes_megabytes(self):
"""Test formatting megabytes."""
result = Scheduler._format_bytes(1024 * 1024)
assert "MB" in result
result = Scheduler._format_bytes(5 * 1024 * 1024)
assert "5.00 MB" in result
def test_format_bytes_gigabytes(self):
"""Test formatting gigabytes."""
result = Scheduler._format_bytes(1024 * 1024 * 1024)
assert "GB" in result
result = Scheduler._format_bytes(2 * 1024 * 1024 * 1024)
assert "2.00 GB" in result
class TestSchedulerRemoveFinishedRequest:
"""Tests for Scheduler.remove_finished_request()."""
def test_remove_finished_request(self, mock_model, mock_tokenizer):
"""Test removing a finished request from tracking."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="test-001",
prompt="Hello",
sampling_params=SamplingParams(),
)
scheduler.add_request(request)
removed = scheduler.remove_finished_request("test-001")
assert removed is request
assert "test-001" not in scheduler.requests
def test_remove_nonexistent_request(self, mock_model, mock_tokenizer):
"""Test removing nonexistent request returns None."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
result = scheduler.remove_finished_request("nonexistent")
assert result is None
class TestSchedulerBoundarySnapshots:
"""Tests for boundary cache snapshots on non-sliceable cache models."""
def test_capture_boundary_snapshot_at_block_boundary(
self, mock_model, mock_tokenizer
):
"""Capture snapshot when total tokens land exactly on block boundary."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler._boundary_snapshot_required = True
# New API: _extract_boundary_snapshot uses batch_generator.extract_cache()
# which returns {uid: (cache_list, tokens_list)}.
mock_layer_cache = MagicMock()
type(mock_layer_cache).__name__ = "BatchArraysCache"
# Positional-consistency guard: the cache must hold exactly the
# boundary token count at capture time (standard decode invariant).
mock_layer_cache.offset = 4
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.extract_cache.return_value = {
123: ([mock_layer_cache], [10, 11, 12, 13])
}
request = Request(
request_id="req-boundary",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [10, 11]
request.num_prompt_tokens = 2
request.output_token_ids = [12, 13] # Total = 4 (boundary)
scheduler._maybe_capture_boundary_snapshot(request, 123)
assert 4 in scheduler._boundary_cache_snapshots["req-boundary"]
snapshot = scheduler._boundary_cache_snapshots["req-boundary"][4]
# Non-sliceable cache layer is kept as-is in the snapshot
assert snapshot == [mock_layer_cache]
def test_boundary_snapshot_skipped_on_speculative_skew(
self, mock_model, mock_tokenizer
):
"""Speculative (MTP) decode advances the cache in bursts and emits
from a queue, so the cache can be a few tokens ahead of the emitted
count when the boundary token surfaces. A snapshot captured then
would pair the boundary label with recurrent state from a different
position — the capture must be skipped instead."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler._boundary_snapshot_required = True
mock_layer_cache = MagicMock()
type(mock_layer_cache).__name__ = "BatchArraysCache"
mock_layer_cache.offset = 6 # cache ran ahead of the emitted count
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.extract_cache.return_value = {
123: ([mock_layer_cache], [10, 11, 12, 13])
}
request = Request(
request_id="req-skew",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [10, 11]
request.num_prompt_tokens = 2
request.output_token_ids = [12, 13] # Total = 4 (boundary)
scheduler._maybe_capture_boundary_snapshot(request, 123)
assert "req-skew" not in scheduler._boundary_cache_snapshots
@staticmethod
def _cache_list_layer(leaf_offset: int):
"""CacheList-style wrapper: no own offset, sub-caches carry it."""
class _Leaf:
offset = leaf_offset
class _PoolingLeaf:
# Offset counts pooled windows, not tokens — must not be used
# by the guard (it walks depth-first, leading leaf wins).
offset = leaf_offset // 4
class _CacheListLike:
caches = [_Leaf(), _PoolingLeaf()]
return _CacheListLike()
def test_boundary_snapshot_skew_guard_sees_cachelist_sub_offsets(
self, mock_model, mock_tokenizer
):
"""DeepSeek/GLM-style CacheList layers expose no offset themselves;
the guard must walk into sub-caches or a skewed capture passes."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler._boundary_snapshot_required = True
layer = self._cache_list_layer(leaf_offset=6) # ran ahead of boundary 4
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.extract_cache.return_value = {
123: ([layer], [10, 11, 12, 13])
}
request = Request(
request_id="req-cl-skew",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [10, 11]
request.num_prompt_tokens = 2
request.output_token_ids = [12, 13] # Total = 4 (boundary)
scheduler._maybe_capture_boundary_snapshot(request, 123)
assert "req-cl-skew" not in scheduler._boundary_cache_snapshots
def test_boundary_snapshot_captured_through_cachelist(
self, mock_model, mock_tokenizer
):
"""Aligned CacheList sub-offsets pass the guard and capture proceeds."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler._boundary_snapshot_required = True
layer = self._cache_list_layer(leaf_offset=4)
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.extract_cache.return_value = {
123: ([layer], [10, 11, 12, 13])
}
request = Request(
request_id="req-cl-ok",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [10, 11]
request.num_prompt_tokens = 2
request.output_token_ids = [12, 13]
scheduler._maybe_capture_boundary_snapshot(request, 123)
assert 4 in scheduler._boundary_cache_snapshots["req-cl-ok"]
def test_cleanup_finished_skips_store_without_boundary_snapshot(
self, mock_model, mock_tokenizer
):
"""Snapshot-needing models must not store live non-sliceable state
when no boundary-aligned snapshot exists (e.g. every capture was
skipped by the speculative-decode skew guard)."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = None
scheduler._boundary_snapshot_required = True
request = Request(
request_id="req-no-snap",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3, 4]
request.num_prompt_tokens = 4
request.output_token_ids = [5, 6, 7, 8] # Total = 8 (2 full blocks)
request._extracted_cache = [{"state": "live-cache"}]
request._model_cache_config = "live-config"
scheduler.running["req-no-snap"] = request
scheduler.requests["req-no-snap"] = request
# No boundary snapshots recorded for this request.
scheduler._cleanup_finished({"req-no-snap"})
scheduler.block_aware_cache.store_cache.assert_not_called()
scheduler.block_aware_cache.clear_request_entry.assert_called_with(
"req-no-snap"
)
def test_cleanup_finished_skips_output_tokens_for_reasoning_model(
self, mock_model, mock_tokenizer
):
"""Reasoning models (needs_think_prefix=True) should store only prompt tokens."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = None
request = Request(
request_id="req-reasoning",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3, 4, 5, 6, 7, 8]
request.num_prompt_tokens = 8
request.output_token_ids = [9, 10, 11, 12]
request.needs_think_prefix = True
request._extracted_cache = [{"state": "cache"}]
request._model_cache_config = None
scheduler.running["req-reasoning"] = request
scheduler.requests["req-reasoning"] = request
scheduler._cleanup_finished({"req-reasoning"})
scheduler.block_aware_cache.store_cache.assert_called_once()
args, kwargs = scheduler.block_aware_cache.store_cache.call_args
assert args[0] == "req-reasoning"
assert args[1] == [1, 2, 3, 4, 5, 6, 7, 8] # prompt only
def test_cleanup_finished_stores_output_tokens_for_non_reasoning_model(
self, mock_model, mock_tokenizer
):
"""Non-reasoning models should store prompt + output tokens."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = None
request = Request(
request_id="req-nonreasoning",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3, 4]
request.num_prompt_tokens = 4
request.output_token_ids = [5, 6, 7, 8]
request._extracted_cache = [{"state": "cache"}]
request._model_cache_config = None
scheduler.running["req-nonreasoning"] = request
scheduler.requests["req-nonreasoning"] = request
scheduler._cleanup_finished({"req-nonreasoning"})
scheduler.block_aware_cache.store_cache.assert_called_once()
args, kwargs = scheduler.block_aware_cache.store_cache.call_args
assert args[1] == [1, 2, 3, 4, 5, 6, 7, 8] # prompt + output
def test_cleanup_finished_uses_boundary_snapshot_for_partial_trailing_tokens(
self, mock_model, mock_tokenizer
):
"""When final length has trailing partial tokens, store boundary snapshot."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = None
request = Request(
request_id="req-partial",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3, 4]
request.num_prompt_tokens = 4
request.output_token_ids = [5, 6, 7] # Total = 7 (partial trailing block)
request._extracted_cache = [{"state": "final-cache"}]
request._model_cache_config = "final-config"
scheduler.running["req-partial"] = request
scheduler.requests["req-partial"] = request
scheduler._boundary_cache_snapshots["req-partial"] = {4: [MagicMock()]}
snapshot_extracted = [{"state": "boundary-cache"}]
with patch.object(
scheduler,
"_extract_cache_states",
return_value=(snapshot_extracted, "boundary-config"),
):
scheduler._cleanup_finished({"req-partial"})
scheduler.block_aware_cache.store_cache.assert_called_once()
args, kwargs = scheduler.block_aware_cache.store_cache.call_args
assert args[0] == "req-partial"
assert args[1] == [1, 2, 3, 4]
assert args[2] == snapshot_extracted
assert kwargs["model_cache_config"] == "boundary-config"
assert "req-partial" not in scheduler._boundary_cache_snapshots
def test_boundary_override_preextracts_in_memory_intermediate_snapshots(
self, mock_model, mock_tokenizer
):
"""In-memory boundary snapshots must be extracted before worker access."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
request = Request(
request_id="req-hot-cache",
prompt="hello",
sampling_params=SamplingParams(),
)
scheduler.requests["req-hot-cache"] = request
raw_intermediate = object()
raw_latest = object()
extracted_intermediate = [{"state": ("intermediate",)}]
extracted_latest = [{"state": ("latest",)}]
scheduler._boundary_cache_snapshots["req-hot-cache"] = {
4: raw_intermediate,
8: raw_latest,
}
def extract(raw_cache):
if raw_cache is raw_latest:
return extracted_latest, "latest-config"
if raw_cache is raw_intermediate:
return extracted_intermediate, "intermediate-config"
raise AssertionError("unexpected raw cache")
with patch.object(
scheduler, "_extract_cache_states", side_effect=extract
) as ex:
result = scheduler._get_boundary_store_override(
"req-hot-cache", list(range(10))
)
assert result is not None
token_sequence, cache_to_store, model_config, provider = result
assert token_sequence == list(range(8))
assert cache_to_store is extracted_latest
assert model_config == "latest-config"
assert 4 in provider
ex.reset_mock()
assert provider[4] is extracted_intermediate
ex.assert_not_called()
def test_cleanup_finished_pre_evals_intermediate_boundary_snapshots(
self, mock_model, mock_tokenizer
):
"""Intermediate boundary snapshot arrays are materialized on engine thread."""
from omlx import scheduler as sched_mod
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = None
latest_arr = mx.zeros((1,))
intermediate_arr = mx.ones((1,))
latest_cache = [{"state": (latest_arr,), "cache_type": "ArraysCache"}]
intermediate_cache = [
{"state": (intermediate_arr,), "cache_type": "ArraysCache"}
]
provider = sched_mod._BoundarySnapshotProvider(
store=None,
request_id="req-hot-cache",
valid_tcs=[4],
in_memory_snapshots={4: intermediate_cache},
)
request = Request(
request_id="req-hot-cache",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3, 4]
request.num_prompt_tokens = 4
request.output_token_ids = [5, 6, 7]
request._extracted_cache = [{"state": ("final",)}]
request._model_cache_config = None
scheduler.running["req-hot-cache"] = request
scheduler.requests["req-hot-cache"] = request
with (
patch.object(
scheduler,
"_get_boundary_store_override",
return_value=([1, 2, 3, 4], latest_cache, None, provider),
),
patch.object(sched_mod.mx, "eval") as eval_,
patch.object(sched_mod, "_safe_sync_stream"),
):
scheduler._cleanup_finished({"req-hot-cache"})
eval_.assert_called_once()
assert eval_.call_args.args == (latest_arr, intermediate_arr)
def test_boundary_snapshot_synchronizes_generation_stream(
self, mock_model, mock_tokenizer
):
"""Boundary snapshot extraction must synchronize generation_stream
before accessing batch cache tensors to prevent Metal command buffer conflicts.
"""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler._boundary_snapshot_required = True
mock_batch = MagicMock()
mock_batch.uids = [42]
mock_batch.extract_cache.return_value = [MagicMock()]
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.active_batch = mock_batch
request = Request(
request_id="req-sync",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2]
request.num_prompt_tokens = 2
request.output_token_ids = [3, 4] # Total = 4 (boundary)
with patch("omlx.scheduler.mx") as mock_mx:
scheduler._maybe_capture_boundary_snapshot(request, 42)
mock_mx.synchronize.assert_called()
mock_mx.stream.assert_called()
def test_cleanup_finished_synchronizes_before_cache_store(
self, mock_model, mock_tokenizer
):
"""_cleanup_finished must synchronize generation_stream before cache
storage even when active_batch is None (all requests finished)."""
config = SchedulerConfig(paged_cache_block_size=4)
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer, config=config)
scheduler.block_aware_cache = MagicMock()
scheduler.paged_cache_manager = None
# Simulate active_batch = None (all requests finished in this step)
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.active_batch = None
request = Request(
request_id="req-cleanup-sync",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2, 3, 4]
request.num_prompt_tokens = 4
request.output_token_ids = [5]
request._extracted_cache = [{"state": "cache"}]
request._model_cache_config = None
scheduler.running["req-cleanup-sync"] = request
scheduler.requests["req-cleanup-sync"] = request
with patch("omlx.scheduler.mx") as mock_mx:
scheduler._cleanup_finished({"req-cleanup-sync"})
mock_mx.synchronize.assert_called()
mock_mx.stream.assert_called()
# Metal buffer cache clear is now DEFERRED by _DEFERRED_CLEAR_DELAY
# generation steps to avoid IOKit completeMemory() race (#435).
# It should NOT be called immediately in _cleanup_finished.
mock_mx.clear_cache.assert_not_called()
assert scheduler._deferred_clear_at == (
scheduler._step_counter + Scheduler._DEFERRED_CLEAR_DELAY
)
def test_prefill_boundary_snapshot_records_rotating_cache(
self, mock_model, mock_tokenizer
):
"""Prefill callback should store rotating boundary snapshots.
Regression: deliberately leave ``request_id_to_uid`` /
``uid_to_request_id`` unset, matching what happens in production
during prefill (the request has not been inserted into
BatchGenerator yet). The earlier shape passed a uid that
resolved to None and silently dropped the snapshot.
"""
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=SchedulerConfig(paged_cache_block_size=4),
)
scheduler.block_aware_cache = MagicMock()
request = Request(
request_id="req-prefill-boundary",
prompt="hello",
sampling_params=SamplingParams(),
)
scheduler.requests[request.request_id] = request
scheduler.running[request.request_id] = request
RotatingStub = type("RotatingKVCache", (), {})
snapshot_cache = [RotatingStub()]
scheduler._on_prefill_boundary_snapshot(request.request_id, snapshot_cache, 4)
assert 4 in scheduler._boundary_cache_snapshots[request.request_id]
assert (
scheduler._boundary_cache_snapshots[request.request_id][4] == snapshot_cache
)
assert scheduler._boundary_snapshot_required is True
def test_prefill_boundary_snapshot_ignores_non_boundary_token_count(
self, mock_model, mock_tokenizer
):
"""Prefill callback should ignore non-boundary token counts."""
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=SchedulerConfig(paged_cache_block_size=4),
)
scheduler.block_aware_cache = MagicMock()
request = Request(
request_id="req-prefill-non-boundary",
prompt="hello",
sampling_params=SamplingParams(),
)
scheduler.requests[request.request_id] = request
scheduler.running[request.request_id] = request
RotatingStub = type("RotatingKVCache", (), {})
scheduler._on_prefill_boundary_snapshot(request.request_id, [RotatingStub()], 3)
assert request.request_id not in scheduler._boundary_cache_snapshots
def test_emit_prefill_boundary_snapshot_persists_before_uid_assignment(
self, mock_model, mock_tokenizer
):
"""Snapshots emitted during prefill must persist even though the
request has not yet been inserted into BatchGenerator.
The regression this guards against: the wrapper used to route
through ``request_id_to_uid.get(rid, -1)`` →
``uid_to_request_id.get(-1)`` → ``None`` → silent return, so
every block-boundary snapshot during prefill was dropped. For
hybrid (ArraysCache / GDN) models that meant every non-last
cached block stored a placeholder and identical-prefix re-
uploads re-prefilled from scratch.
"""
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=SchedulerConfig(paged_cache_block_size=4),
)
scheduler.block_aware_cache = MagicMock()
request = Request(
request_id="req-prefill-pre-insert",
prompt="hello",
sampling_params=SamplingParams(),
)
scheduler.requests[request.request_id] = request
scheduler.running[request.request_id] = request
# Deliberately do NOT populate request_id_to_uid /
# uid_to_request_id — that mirrors production state at the
# time _emit_prefill_boundary_snapshot fires.
assert request.request_id not in scheduler.request_id_to_uid
RotatingStub = type("RotatingKVCache", (), {})
prompt_cache = [RotatingStub()]
scheduler._emit_prefill_boundary_snapshot(request, prompt_cache, 4)
assert request.request_id in scheduler._boundary_cache_snapshots
assert 4 in scheduler._boundary_cache_snapshots[request.request_id]
class TestSchedulerRotatingBlockAlignment:
"""Tests for rotating window/block-size alignment."""
def test_aligns_block_size_to_rotating_window(self, mock_tokenizer):
RotatingStub = type("RotatingKVCache", (), {})
class RotatingModel:
def __init__(self):
self.config = MagicMock()
self.config.num_hidden_layers = 1
def make_cache(self):
cache = RotatingStub()
cache.max_size = 128
return [cache]
scheduler = Scheduler(
model=RotatingModel(),
tokenizer=mock_tokenizer,
config=SchedulerConfig(paged_cache_block_size=256),
)
scheduler.config.paged_ssd_cache_dir = "/tmp/cache"
scheduler._align_block_size_with_rotating_window()
# window_size=128 is below _ROTATING_BLOCK_SIZE_MIN (512),
# so it gets rounded up to 512 (smallest multiple of 128 >= 512).
assert scheduler.config.paged_cache_block_size == 512
def test_multiple_rotating_window_sizes_raise(self, mock_tokenizer):
RotatingStub = type("RotatingKVCache", (), {})
class MultiRotatingModel:
def __init__(self):
self.config = MagicMock()
self.config.num_hidden_layers = 2
def make_cache(self):
c1 = RotatingStub()
c1.max_size = 128
c2 = RotatingStub()
c2.max_size = 256
return [c1, c2]
scheduler = Scheduler(
model=MultiRotatingModel(),
tokenizer=mock_tokenizer,
config=SchedulerConfig(paged_cache_block_size=256),
)
scheduler.config.paged_ssd_cache_dir = "/tmp/cache"
with pytest.raises(ValueError):
scheduler._align_block_size_with_rotating_window()
def test_cleanup_finished_always_calls_remove_for_mapped_uid(
self, mock_model, mock_tokenizer
):
"""_cleanup_finished should always call remove() for UIDs with mapping,
regardless of whether the UID is in the active batch.
The new _remove_uid_from_active_batch unconditionally calls remove()."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-skip-remove",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2]
request.num_prompt_tokens = 2
request.output_token_ids = [3]
uid = 55
scheduler.running["req-skip-remove"] = request
scheduler.requests["req-skip-remove"] = request
scheduler.request_id_to_uid["req-skip-remove"] = uid
scheduler.uid_to_request_id[uid] = "req-skip-remove"
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.active_batch = MagicMock(uids=[77])
scheduler._cleanup_finished({"req-skip-remove"})
scheduler.batch_generator.remove.assert_called_once_with([uid])
def test_cleanup_finished_removes_uid_from_active_batch(
self, mock_model, mock_tokenizer
):
"""_cleanup_finished should remove active UID from batch."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-remove-active",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2]
request.num_prompt_tokens = 2
request.output_token_ids = [3]
uid = 56
scheduler.running["req-remove-active"] = request
scheduler.requests["req-remove-active"] = request
scheduler.request_id_to_uid["req-remove-active"] = uid
scheduler.uid_to_request_id[uid] = "req-remove-active"
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.active_batch = MagicMock(uids=[uid])
scheduler._cleanup_finished({"req-remove-active"})
scheduler.batch_generator.remove.assert_called_once_with([uid])
def test_cleanup_finished_defers_metal_buffer_cache_clear(
self, mock_model, mock_tokenizer
):
"""_cleanup_finished must defer Metal buffer cache clear (#435).
Immediate mx.clear_cache() after request completion races with
IOKit's asynchronous completeMemory() callbacks. Instead,
_cleanup_finished sets _deferred_clear_at so the clear happens
after _DEFERRED_CLEAR_DELAY generation steps.
"""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = Request(
request_id="req-clear-cache",
prompt="hello",
sampling_params=SamplingParams(),
)
request.prompt_token_ids = [1, 2]
request.num_prompt_tokens = 2
request.output_token_ids = [3]
scheduler.running["req-clear-cache"] = request
scheduler.requests["req-clear-cache"] = request
with patch("omlx.scheduler.mx") as mock_mx:
scheduler._cleanup_finished({"req-clear-cache"})
# Should NOT clear immediately — deferred to avoid IOKit race
mock_mx.clear_cache.assert_not_called()
# Target step should be set for deferred clearing
assert scheduler._deferred_clear_at == (
scheduler._step_counter + Scheduler._DEFERRED_CLEAR_DELAY
)
def test_cleanup_finished_skips_clear_cache_when_no_finished(
self, mock_model, mock_tokenizer
):
"""_cleanup_finished must not schedule deferred clear when no requests finished."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
with patch("omlx.scheduler.mx") as mock_mx:
scheduler._cleanup_finished(set())
mock_mx.clear_cache.assert_not_called()
assert scheduler._deferred_clear_at is None
def test_cleanup_finished_extends_deferred_clear_for_concurrent_completions(
self, mock_model, mock_tokenizer
):
"""Concurrent completions must extend the deferred clear window (#557).
With max_num_seqs > 1, two requests can finish in the same batch
or in consecutive steps. Each completion must get a full
_DEFERRED_CLEAR_DELAY window from its own finish step, otherwise
the second request's KV cache blocks can be re-allocated before
IOKit finishes completeMemory() callbacks.
"""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
# Simulate first completion at step 0
req1 = Request(
request_id="req-concurrent-1",
prompt="hello",
sampling_params=SamplingParams(),
)
req1.prompt_token_ids = [1, 2]
req1.num_prompt_tokens = 2
req1.output_token_ids = [3]
scheduler.running["req-concurrent-1"] = req1
scheduler.requests["req-concurrent-1"] = req1
with patch("omlx.scheduler.mx"):
scheduler._cleanup_finished({"req-concurrent-1"})
first_target = scheduler._deferred_clear_at
assert first_target == scheduler._step_counter + Scheduler._DEFERRED_CLEAR_DELAY
# Advance step counter to simulate a later step
scheduler._step_counter += 3
# Simulate second completion at step 3
req2 = Request(
request_id="req-concurrent-2",
prompt="world",
sampling_params=SamplingParams(),
)
req2.prompt_token_ids = [4, 5]
req2.num_prompt_tokens = 2
req2.output_token_ids = [6]
scheduler.running["req-concurrent-2"] = req2
scheduler.requests["req-concurrent-2"] = req2
with patch("omlx.scheduler.mx"):
scheduler._cleanup_finished({"req-concurrent-2"})
# Target must be extended to cover the second completion's full window
second_target = scheduler._step_counter + Scheduler._DEFERRED_CLEAR_DELAY
assert scheduler._deferred_clear_at == second_target
assert scheduler._deferred_clear_at > first_target
class TestPeriodicClearGating:
"""Tests for the conditional periodic clear (#978/#1040 mitigation)."""
def test_periodic_clear_skipped_when_cache_below_threshold(
self, mock_model, mock_tokenizer
):
"""Periodic clear should NOT fire when MLX buffer pool is small.
The pre-fix behavior fired every mlx_cache_cleanup_interval steps
unconditionally, producing IOGPUFamily refcount transitions even
when there was nothing meaningful to release. After the fix, the
clear only fires when accumulated cache memory exceeds the
threshold (memory_limit/3 or absolute 2 GiB floor).
"""
from omlx import scheduler as sched_mod
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._step_counter = scheduler.config.mlx_cache_cleanup_interval
scheduler._memory_limit_bytes = 0 # → use absolute 2 GiB threshold
# 1 GiB cached, well under the 2 GiB threshold
with patch.object(sched_mod.mx, "get_cache_memory", return_value=1 * 1024**3):
assert scheduler._should_periodic_clear_cache() is False
def test_periodic_clear_fires_when_cache_above_threshold(
self, mock_model, mock_tokenizer
):
"""Periodic clear must fire when MLX buffer pool exceeds threshold."""
from omlx import scheduler as sched_mod
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._step_counter = scheduler.config.mlx_cache_cleanup_interval
scheduler._memory_limit_bytes = 0 # → 2 GiB absolute floor
# 3 GiB cached, exceeds the 2 GiB threshold
with patch.object(sched_mod.mx, "get_cache_memory", return_value=3 * 1024**3):
assert scheduler._should_periodic_clear_cache() is True
def test_periodic_clear_threshold_scales_with_memory_limit(
self, mock_model, mock_tokenizer
):
"""Threshold must be max(memory_limit/3, 2 GiB) when limit is set."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
# Limit 30 GiB → threshold 10 GiB (memory_limit / 3)
scheduler._memory_limit_bytes = 30 * 1024**3
assert scheduler._periodic_clear_threshold_bytes() == 10 * 1024**3
# Limit 3 GiB → threshold 2 GiB (floor wins)
scheduler._memory_limit_bytes = 3 * 1024**3
assert scheduler._periodic_clear_threshold_bytes() == 2 * 1024**3
# No limit → 2 GiB absolute floor
scheduler._memory_limit_bytes = 0
assert scheduler._periodic_clear_threshold_bytes() == 2 * 1024**3
class TestExtractCacheStatesCacheList:
"""Tests for CacheList handling in _extract_cache_states."""
@pytest.fixture
def scheduler(self):
"""Create a minimal scheduler mock for testing _extract_cache_states."""
from omlx.scheduler import Scheduler
mock_scheduler = MagicMock(spec=Scheduler)
mock_scheduler.model_name = "test"
mock_scheduler._extract_cache_states = Scheduler._extract_cache_states.__get__(
mock_scheduler, Scheduler
)
return mock_scheduler
def test_extract_cache_states_cache_list(self, scheduler):
"""Test CacheList layer extraction."""
# Create a mock CacheList object
mock_kv_sub = MagicMock(spec=[])
mock_kv_sub.__class__ = type("KVCache", (), {})
mock_kv_sub.state = (MagicMock(), MagicMock())
mock_kv_sub.meta_state = (32,)
mock_cache_list = MagicMock(spec=[])
mock_cache_list.__class__ = type("CacheList", (), {})
mock_cache_list.caches = (mock_kv_sub,)
mock_cache_list.state = [(MagicMock(), MagicMock())] # CacheList.state
mock_cache_list.meta_state = (["KVCache"], [(32,)])
# Standard KVCache layer
mock_kv = MagicMock(spec=[])
mock_kv.__class__ = type("KVCache", (), {})
mock_kv.state = (MagicMock(), MagicMock())
mock_kv.meta_state = (64,)
raw_cache = [mock_cache_list, mock_kv]
extracted, config = scheduler._extract_cache_states(raw_cache)
assert len(extracted) == 2
assert extracted[0]["class_name"] == "CacheList"
assert extracted[0]["cache_type"] == "CacheList"
assert isinstance(extracted[0]["state"], list)
assert isinstance(extracted[0]["meta_state"], tuple)
assert len(extracted[0]["meta_state"]) == 2
def test_extract_cache_states_cache_list_no_handlers(self, scheduler):
"""Test CacheList extraction when HAS_CACHE_TYPE_HANDLERS=False."""
# Use real stub classes so type(obj).__name__ returns the correct name
# (needed because the fallback branch uses type().__name__ for detection)
KVCacheStub = type(
"KVCache",
(),
{
"state": (MagicMock(), MagicMock()),
"meta_state": (32,),
},
)
mock_kv_sub = KVCacheStub()
CacheListStub = type(
"CacheList",
(),
{
"caches": (mock_kv_sub,),
"state": [(MagicMock(), MagicMock())],
"meta_state": (["KVCache"], [(32,)]),
},
)
mock_cache_list = CacheListStub()
raw_cache = [mock_cache_list]
# Patch HAS_CACHE_TYPE_HANDLERS to False
with patch("omlx.scheduler.HAS_CACHE_TYPE_HANDLERS", False):
extracted, config = scheduler._extract_cache_states(raw_cache)
# Must still have 1 extracted entry (Issue #1: no layer count mismatch)
assert len(extracted) == 1
assert extracted[0]["class_name"] == "CacheList"
assert isinstance(extracted[0]["state"], list)
class TestExtractCacheStatesRotatingNormalization:
"""Tests for RotatingKVCache snapshot normalization during extraction."""
def test_extract_cache_states_normalizes_oversized_rotating_snapshot(
self, mock_model, mock_tokenizer
):
"""Oversized rotating snapshot should be canonicalized to max_size."""
mx = pytest.importorskip("mlx.core")
cache_mod = pytest.importorskip("mlx_lm.models.cache")
RotatingKVCache = cache_mod.RotatingKVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
rotating = RotatingKVCache(max_size=128, keep=0)
rotating.keys = mx.arange(255).reshape(1, 1, 255, 1)
rotating.values = mx.arange(1000, 1255).reshape(1, 1, 255, 1)
rotating.offset = 1280
rotating._idx = 255
expected_keys = rotating.keys[..., -128:, :]
expected_values = rotating.values[..., -128:, :]
extracted, _ = scheduler._extract_cache_states([rotating])
assert len(extracted) == 1
normalized_keys, normalized_values = extracted[0]["state"]
normalized_meta = tuple(extracted[0]["meta_state"])
assert normalized_keys.shape == (1, 1, 128, 1)
assert normalized_values.shape == (1, 1, 128, 1)
assert bool(mx.all(normalized_keys == expected_keys).item())
assert bool(mx.all(normalized_values == expected_values).item())
assert normalized_meta == ("0", "128", "1280", "128")
def test_extract_cache_states_normalizes_buffered_rotating_snapshot(
self, mock_model, mock_tokenizer
):
"""mlx-vlm MTP BufferedRotatingKVCache should use rotating semantics."""
mx = pytest.importorskip("mlx.core")
class BufferedRotatingKVCache:
def __init__(self):
self.keys = mx.arange(255).reshape(1, 1, 255, 1)
self.values = mx.arange(1000, 1255).reshape(1, 1, 255, 1)
self.keep = 0
self.max_size = 128
self.offset = 1280
self._idx = 255
@property
def state(self):
return self.keys, self.values
@property
def meta_state(self):
return ("0", "128", "1280", "255", "1025", "64")
def _temporal_order(self, tensor):
return tensor
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
buffered = BufferedRotatingKVCache()
expected_keys = buffered.keys[..., -128:, :]
expected_values = buffered.values[..., -128:, :]
extracted, _ = scheduler._extract_cache_states([buffered])
assert len(extracted) == 1
assert extracted[0]["class_name"] == "BufferedRotatingKVCache"
assert extracted[0]["cache_type"] == "RotatingKVCache"
normalized_keys, normalized_values = extracted[0]["state"]
normalized_meta = tuple(extracted[0]["meta_state"])
assert normalized_keys.shape == (1, 1, 128, 1)
assert normalized_values.shape == (1, 1, 128, 1)
assert bool(mx.all(normalized_keys == expected_keys).item())
assert bool(mx.all(normalized_values == expected_values).item())
assert normalized_meta == ("0", "128", "1280", "128")
def test_extract_cache_states_normalizes_nested_cachelist_rotating_snapshot(
self, mock_model, mock_tokenizer
):
"""CacheList sub-caches should receive the same rotating normalization."""
mx = pytest.importorskip("mlx.core")
cache_mod = pytest.importorskip("mlx_lm.models.cache")
CacheList = cache_mod.CacheList
RotatingKVCache = cache_mod.RotatingKVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
rotating = RotatingKVCache(max_size=128, keep=0)
rotating.keys = mx.arange(255).reshape(1, 1, 255, 1)
rotating.values = mx.arange(1000, 1255).reshape(1, 1, 255, 1)
rotating.offset = 1280
rotating._idx = 255
cache_list = CacheList(rotating)
expected_keys = rotating.keys[..., -128:, :]
expected_values = rotating.values[..., -128:, :]
extracted, _ = scheduler._extract_cache_states([cache_list])
assert len(extracted) == 1
assert extracted[0]["class_name"] == "CacheList"
sub_states = extracted[0]["state"]
sub_class_names, sub_meta_states = extracted[0]["meta_state"]
normalized_keys, normalized_values = sub_states[0]
assert sub_class_names == ["RotatingKVCache"]
assert tuple(sub_meta_states[0]) == ("0", "128", "1280", "128")
assert normalized_keys.shape == (1, 1, 128, 1)
assert normalized_values.shape == (1, 1, 128, 1)
assert bool(mx.all(normalized_keys == expected_keys).item())
assert bool(mx.all(normalized_values == expected_values).item())
class TestSchedulerSSDLayerSignature:
"""Tests for pre-lookup SSD layer signature refresh."""
def test_refresh_uses_final_turboquant_layout_and_sweeps(
self, mock_tokenizer, tmp_path
):
from mlx_lm.models.cache import KVCache
from omlx.cache.paged_ssd_cache import (
PagedSSDBlockMetadata,
_cache_compat_signature,
)
class TwoLayerModel:
config = SimpleNamespace(
num_hidden_layers=2,
num_key_value_heads=2,
num_attention_heads=2,
head_dim=32,
)
def make_cache(self):
return [KVCache(), KVCache()]
scheduler = Scheduler(
model=TwoLayerModel(),
tokenizer=mock_tokenizer,
config=SchedulerConfig(
paged_ssd_cache_dir=str(tmp_path),
paged_cache_block_size=4,
model_name="test-model",
),
)
try:
manager = scheduler.paged_ssd_cache_manager
assert manager is not None
assert manager._expected_layer_cache_types is None
stale = PagedSSDBlockMetadata(
block_hash=b"stale".ljust(32, b"\0"),
file_path=tmp_path / "stale.safetensors",
file_size=1024,
token_count=4,
created_at=0.0,
last_access=0.0,
num_layers=2,
model_name="test-model",
block_size=4,
layer_cache_types=["KVCache", "KVCache"],
)
fresh = PagedSSDBlockMetadata(
block_hash=b"fresh".ljust(32, b"\0"),
file_path=tmp_path / "fresh.safetensors",
file_size=1024,
token_count=4,
created_at=0.0,
last_access=0.0,
num_layers=2,
model_name="test-model",
block_size=4,
layer_cache_types=["TurboQuantKVCache", "KVCache"],
# TurboQuant blocks must prove their bit depth to survive a
# sweep under an active depth expectation (#2045); this is
# the signature the save path stamps since the fix.
cache_signature=_cache_compat_signature(
model_name="test-model",
num_layers=2,
block_size=4,
layer_cache_types=["TurboQuantKVCache", "KVCache"],
turboquant_kv_bits=4.0,
),
)
manager._index.add(stale)
manager._index.add(fresh)
scheduler._turboquant_kv_bits = 4.0
scheduler._turboquant_skip_last = True
layer_cache_types = scheduler.refresh_ssd_layer_signature()
assert layer_cache_types == ["TurboQuantKVCache", "KVCache"]
assert manager._expected_layer_cache_types == layer_cache_types
assert manager._index.get(stale.block_hash) is None
assert manager._index.get(fresh.block_hash) is not None
finally:
scheduler.shutdown()
def test_refresh_infers_layout_without_model_make_cache(
self, mock_tokenizer, tmp_path
):
# Plain dense models (most mlx-lm architectures) define no
# make_cache; the request path builds their caches through
# make_prompt_cache's per-layer KVCache fallback. The refresh must
# infer through the same fallback — requiring model.make_cache made
# it a silent no-op for these models, so the manager never learned
# the TurboQuant bit depth and mixed-width blocks kept loading
# after a turboquant_kv_bits change (#2045).
from omlx.cache.paged_ssd_cache import (
PagedSSDBlockMetadata,
_cache_compat_signature,
)
turbo_types = ["TurboQuantKVCache", "KVCache"]
class PlainDenseModel:
config = SimpleNamespace(
num_hidden_layers=2,
num_key_value_heads=2,
num_attention_heads=2,
head_dim=32,
)
layers = [object(), object()]
scheduler = Scheduler(
model=PlainDenseModel(),
tokenizer=mock_tokenizer,
config=SchedulerConfig(
paged_ssd_cache_dir=str(tmp_path),
paged_cache_block_size=4,
model_name="test-model",
),
)
try:
manager = scheduler.paged_ssd_cache_manager
assert manager is not None
def _block(block_hash: bytes, bits: float | None) -> PagedSSDBlockMetadata:
return PagedSSDBlockMetadata(
block_hash=block_hash,
file_path=tmp_path / "never-touched.safetensors",
file_size=1024,
token_count=4,
created_at=0.0,
last_access=0.0,
num_layers=2,
model_name="test-model",
block_size=4,
layer_cache_types=turbo_types,
cache_signature=_cache_compat_signature(
model_name="test-model",
num_layers=2,
block_size=4,
layer_cache_types=turbo_types,
turboquant_kv_bits=bits,
),
)
old_depth = _block(b"old".ljust(32, b"\0"), 4.0)
unproven = _block(b"unproven".ljust(32, b"\0"), None)
current = _block(b"current".ljust(32, b"\0"), 6.0)
manager._index.add(old_depth)
manager._index.add(unproven)
manager._index.add(current)
scheduler._turboquant_kv_bits = 6.0
scheduler._turboquant_skip_last = True
layer_cache_types = scheduler.refresh_ssd_layer_signature()
assert layer_cache_types == turbo_types
assert manager._expected_turboquant_kv_bits == 6.0
assert manager._index.get(old_depth.block_hash) is None
assert manager._index.get(unproven.block_hash) is None
assert manager._index.get(current.block_hash) is not None
finally:
scheduler.shutdown()
class TestSpecPrefillDraftCacheSignature:
"""Regression coverage for SpecPrefill draft-cache isolation (#1925)."""
def test_draft_cache_reconstructs_with_target_turboquant_enabled(
self, mock_tokenizer, tmp_path
):
from mlx_lm.models.cache import ArraysCache, KVCache
from omlx.cache.paged_ssd_cache import _canonicalize_layer_cache_types
class HybridModel:
def __init__(self, repeats: int):
self.repeats = repeats
self.config = SimpleNamespace(
num_hidden_layers=repeats * 4,
num_key_value_heads=2,
num_attention_heads=2,
head_dim=32,
)
def make_cache(self):
return [
cache
for _ in range(self.repeats)
for cache in (
ArraysCache(size=2),
ArraysCache(size=2),
ArraysCache(size=2),
KVCache(),
)
]
scheduler = Scheduler(
model=HybridModel(repeats=2),
tokenizer=mock_tokenizer,
config=SchedulerConfig(
paged_ssd_cache_dir=str(tmp_path),
paged_cache_block_size=4,
model_name="target-model",
),
)
try:
scheduler._turboquant_kv_bits = 4.0
scheduler._turboquant_skip_last = True
target_manager = scheduler.paged_ssd_cache_manager
target_types = [
"ArraysCache",
"ArraysCache",
"ArraysCache",
"TurboQuantKVCache",
"ArraysCache",
"ArraysCache",
"ArraysCache",
"KVCache",
]
assert scheduler.refresh_ssd_layer_signature() == target_types
assert target_manager is not None
scheduler.set_specprefill_draft_model(
HybridModel(repeats=1), draft_model_name="draft-model"
)
draft_cache = scheduler._draft_prefix_cache
assert draft_cache is not None
draft_manager = draft_cache.paged_ssd_cache
assert draft_manager is not target_manager
assert target_manager._expected_model_name == "target-model"
assert target_manager._expected_layer_cache_types == target_types
assert draft_manager._expected_model_name == "draft-model"
assert draft_manager._expected_layer_cache_types == [
"ArraysCache", "ArraysCache", "ArraysCache", "KVCache"
]
block_size = draft_cache.block_size
arrays_state = (
mx.ones((1, 3, 8)),
mx.ones((1, 2, 8, 8)),
)
kv_state = (
mx.ones((1, 2, 4, 32)),
mx.ones((1, 2, 4, 32)),
)
draft_types = [
"ArraysCache",
"ArraysCache",
"ArraysCache",
"KVCache",
]
block_metadata = {
"model_name": "draft-model",
"num_layers": 4,
"block_size": block_size,
"layer_cache_types": draft_types,
"layer_meta_states": [(), (), (), ()],
}
with (
patch.object(
draft_cache.paged_ssd_cache,
"has_block",
return_value=True,
),
patch.object(
draft_cache.paged_ssd_cache,
"load_block_with_metadata",
return_value=(
[arrays_state, arrays_state, arrays_state, kv_state],
block_metadata,
),
),
):
block_table, remaining_tokens = draft_cache.fetch_cache(
"draft-request", list(range(block_size))
)
assert block_table is not None
assert remaining_tokens == []
reconstructed = draft_cache.reconstruct_cache(block_table)
assert reconstructed is not None
reconstructed_types = [type(layer).__name__ for layer in reconstructed]
assert _canonicalize_layer_cache_types(reconstructed_types) == draft_types
scheduler.deep_reset()
assert scheduler._specprefill_draft_model is None
assert scheduler._draft_prefix_cache is None
assert scheduler._draft_paged_ssd_cache_manager is None
assert draft_manager._writer_thread is None or not (
draft_manager._writer_thread.is_alive()
)
finally:
scheduler.shutdown()
def test_replacing_draft_preserves_manager_when_close_fails(
self, mock_model, mock_tokenizer
):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
previous_manager = MagicMock()
previous_manager.close.side_effect = RuntimeError("writer still active")
previous_model = object()
previous_manager._writer_thread = None
previous_prefix_cache = object()
scheduler._draft_paged_ssd_cache_manager = previous_manager
scheduler._specprefill_draft_model = previous_model
scheduler._draft_prefix_cache = previous_prefix_cache
try:
with pytest.raises(
RuntimeError,
match="Could not close the previous SpecPrefill draft SSD cache manager",
):
scheduler.set_specprefill_draft_model(
object(), draft_model_name="replacement-draft"
)
assert scheduler._draft_paged_ssd_cache_manager is previous_manager
assert scheduler._specprefill_draft_model is previous_model
assert scheduler._draft_prefix_cache is previous_prefix_cache
finally:
previous_manager.close.side_effect = None
scheduler.shutdown()
class TestCacheCorruptionRecovery:
"""Tests for cache corruption detection and recovery."""
def _make_scheduler(self, mock_model, mock_tokenizer):
"""Create a Scheduler with requests in running state."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
for i in range(3):
req = Request(
request_id=f"req-{i}",
prompt=f"test prompt {i}",
sampling_params=SamplingParams(),
prompt_token_ids=[1, 2, 3],
num_prompt_tokens=3,
status=RequestStatus.RUNNING,
batch_uid=i,
remaining_tokens=[1, 2, 3],
)
scheduler.running[req.request_id] = req
scheduler.requests[req.request_id] = req
return scheduler
def test_reschedule_resets_all_fields(self, mock_model, mock_tokenizer):
"""Rescheduling must reset all request fields for clean re-prefill."""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
req = scheduler.running["req-0"]
# Simulate partial generation state
req.output_token_ids = [10, 20, 30]
req.output_text = "partial output"
req.num_computed_tokens = 5
req.block_table = MagicMock()
req.shared_prefix_blocks = 4
req._extracted_cache = MagicMock()
req._model_cache_config = MagicMock()
req.think_prefix_sent = True
req.prompt_cache = MagicMock()
req.cached_tokens = 10
scheduler._reschedule_running_requests()
assert req.status == RequestStatus.WAITING
assert req.batch_uid is None
assert req.prompt_cache is None
assert req.cached_tokens == 0
assert req.remaining_tokens == [1, 2, 3]
assert req.block_table is None
assert req.shared_prefix_blocks == 0
assert req.output_token_ids == []
assert req.output_text == ""
assert req.num_computed_tokens == 0
assert req._extracted_cache is None
assert req._model_cache_config is None
assert req.think_prefix_sent is False
def test_reschedule_corruption_increments_counter(self, mock_model, mock_tokenizer):
"""Corruption reschedule increments per-request retry counter."""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
failed = scheduler._reschedule_running_requests(is_corruption=True)
assert failed == []
for req in scheduler.waiting:
assert req.cache_corruption_retries == 1
def test_reschedule_corruption_fails_after_max_retries(
self, mock_model, mock_tokenizer
):
"""Requests exceeding max corruption retries are failed."""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
# Set one request near the limit
scheduler.running["req-1"].cache_corruption_retries = 3
failed = scheduler._reschedule_running_requests(
is_corruption=True, max_corruption_retries=3
)
assert failed == ["req-1"]
assert "req-1" not in scheduler.running
assert "req-1" not in scheduler.requests
# Other requests should be rescheduled
waiting_ids = {r.request_id for r in scheduler.waiting}
assert "req-0" in waiting_ids
assert "req-2" in waiting_ids
def test_reschedule_no_corruption_does_not_increment_counter(
self, mock_model, mock_tokenizer
):
"""Non-corruption reschedule (e.g. PrefillAborted) does not touch counter."""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
failed = scheduler._reschedule_running_requests(is_corruption=False)
assert failed == []
for req in scheduler.waiting:
assert req.cache_corruption_retries == 0
def test_fail_all_requests_clears_everything(self, mock_model, mock_tokenizer):
"""fail_all_requests removes all running and waiting requests."""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
# Also add a waiting request
wait_req = Request(
request_id="req-wait",
prompt="waiting",
sampling_params=SamplingParams(),
prompt_token_ids=[4, 5],
num_prompt_tokens=2,
)
scheduler.waiting.append(wait_req)
scheduler.requests[wait_req.request_id] = wait_req
failed_ids = scheduler.fail_all_requests()
assert set(failed_ids) == {"req-0", "req-1", "req-2", "req-wait"}
assert len(scheduler.running) == 0
assert len(scheduler.waiting) == 0
assert not scheduler.has_requests()
# Verify requests dict is also cleaned up (no memory leak)
for rid in failed_ids:
assert rid not in scheduler.requests
def test_fail_all_requests_republishes_admin_snapshot(
self, mock_model, mock_tokenizer
):
"""fail_all_requests must publish a fresh (empty) admin snapshot.
The snapshot is normally published at the end of a successful step().
When step() raises and fail_all_requests() clears the queues, the
stale snapshot would keep listing the dead requests, so the dashboard
and the macOS app show them as "generating" forever (#2126).
"""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
# Simulate the last successful step publishing the running requests.
scheduler._publish_admin_snapshot()
assert len(scheduler.snapshot_for_admin()["running_by_id"]) == 3
scheduler.fail_all_requests()
snap = scheduler.snapshot_for_admin()
assert snap["running_by_id"] == {}
assert snap["waiting"] == []
def test_fail_all_requests_removes_prefill_tracker_entries(
self, mock_model, mock_tokenizer
):
"""fail_all_requests must drop PrefillProgressTracker entries.
A request failed mid-prefill never reaches processed >= total, so its
tracker entry has no auto-removal path. The local RuntimeError
handlers in the prefill paths cover memory errors (#1405), but other
exception types bubble up to fail_all_requests and would leave a
phantom "PP" row on the dashboard forever (#2126).
"""
from omlx.prefill_progress import get_prefill_tracker
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
tracker = get_prefill_tracker()
tracker.clear()
tracker.update("req-0", processed=10, total=100, model_id="test")
assert tracker.get_model_progress("test"), "tracker entry not set up"
try:
scheduler.fail_all_requests()
assert tracker.get_model_progress("test") == []
finally:
tracker.clear()
def test_fail_all_requests_preserves_cache(self, mock_model, mock_tokenizer):
"""fail_all_requests resets batch_generator but preserves block cache."""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
scheduler.batch_generator = MagicMock()
scheduler.block_aware_cache = MagicMock()
scheduler.fail_all_requests()
assert scheduler.batch_generator is None
assert scheduler._current_sampler_params is None
# Cache should NOT be cleared (not a corruption error)
scheduler.block_aware_cache.clear.assert_not_called()
def test_fail_all_requests_includes_in_flight_orphans(
self, mock_model, mock_tokenizer
):
"""Catch requests popped from self.waiting but not yet in self.running.
Regression test for the hang triggered when ``_do_external_prefill``
raises inside ``_schedule_waiting``: the request has already been
popped from ``self.waiting`` and has not yet been inserted into
``self.running``, so the three-queue sweep misses it. The orphan
still lives in ``self.requests`` and the HTTP collector for its id
keeps awaiting a result that never arrives.
"""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
orphan = Request(
request_id="req-orphan",
prompt="orphan",
sampling_params=SamplingParams(),
prompt_token_ids=[6, 7],
num_prompt_tokens=2,
)
# Orphan only: present in self.requests, absent from all three queues.
scheduler.requests[orphan.request_id] = orphan
# _schedule_waiting assigns a temp_uid (id(request)) before prefill and
# only clears it on the success path, so an orphan leaves both uid maps
# populated.
temp_uid = id(orphan)
scheduler.request_id_to_uid[orphan.request_id] = temp_uid
scheduler.uid_to_request_id[temp_uid] = orphan.request_id
assert orphan.request_id not in scheduler.waiting
assert orphan.request_id not in scheduler.running
assert orphan.request_id not in scheduler.prefilling
failed_ids = scheduler.fail_all_requests()
assert "req-orphan" in failed_ids
assert "req-orphan" not in scheduler.requests
# Stale uid mappings for the orphan must be cleared too.
assert "req-orphan" not in scheduler.request_id_to_uid
assert temp_uid not in scheduler.uid_to_request_id
def test_fail_all_requests_excludes_async_cleanup_in_flight(
self, mock_model, mock_tokenizer
):
"""Finished requests awaiting async cache-store cleanup must not be failed.
``_cleanup_finished`` keeps a finished request in ``self.requests``
and registers its store future in ``_inflight_store_futures`` until
``_drain_pending_async_removes`` finalizes the cleanup. That request
has already emitted ``finished=True`` to its collector; if
``fail_all_requests`` runs during this window, appending an error
output via the orphan sweep would override the success for
non-streaming ``generate()`` (engine_core returns the last queued
output). The sweep must skip ids present in
``_inflight_store_futures`` and leave them for the async drain.
"""
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
finished_pending_cleanup = Request(
request_id="req-async-cleanup",
prompt="finished",
sampling_params=SamplingParams(),
prompt_token_ids=[8, 9],
num_prompt_tokens=2,
)
# Simulate _cleanup_finished's terminal state: request lives in
# self.requests + _inflight_store_futures, absent from all three queues.
scheduler.requests[finished_pending_cleanup.request_id] = (
finished_pending_cleanup
)
scheduler._inflight_store_futures[finished_pending_cleanup.request_id] = (
MagicMock()
)
# Its uid mapping is still live for _drain_pending_async_removes and
# must survive fail_all_requests untouched.
scheduler.request_id_to_uid[finished_pending_cleanup.request_id] = 999
scheduler.uid_to_request_id[999] = finished_pending_cleanup.request_id
failed_ids = scheduler.fail_all_requests()
assert "req-async-cleanup" not in failed_ids
assert "req-async-cleanup" in scheduler.requests
assert "req-async-cleanup" in scheduler._inflight_store_futures
# uid mapping preserved for the async drain.
assert scheduler.request_id_to_uid["req-async-cleanup"] == 999
assert scheduler.uid_to_request_id[999] == "req-async-cleanup"
class TestGenerationOverflowRecovery:
"""Tests for MLX __next_prime overflow recovery."""
def _make_scheduler(self, mock_model, mock_tokenizer, count: int = 2):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.next_generated.side_effect = OverflowError(
"__next_prime overflow"
)
for i in range(count):
request = Request(
request_id=f"req-overflow-{i}",
prompt=f"prompt {i}",
sampling_params=SamplingParams(max_tokens=4),
prompt_token_ids=[1, 2, 3],
num_prompt_tokens=3,
status=RequestStatus.RUNNING,
batch_uid=i,
remaining_tokens=[1, 2, 3],
)
request.output_token_ids = [10, 11]
request.output_text = "partial"
request.num_computed_tokens = 2
scheduler.running[request.request_id] = request
scheduler.requests[request.request_id] = request
scheduler.request_id_to_uid[request.request_id] = i
scheduler.uid_to_request_id[i] = request.request_id
return scheduler
def test_generation_overflow_detection(self, mock_model, mock_tokenizer):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
assert scheduler._is_generation_overflow_error(
OverflowError("__next_prime overflow")
)
assert not scheduler._is_generation_overflow_error(
OverflowError("integer conversion overflow")
)
assert not scheduler._is_generation_overflow_error(
RuntimeError("__next_prime overflow")
)
def test_generation_overflow_reschedules_for_serial_retry(
self, mock_model, mock_tokenizer
):
scheduler = self._make_scheduler(mock_model, mock_tokenizer, count=3)
scheduler.config.max_num_seqs = 8
with patch("omlx.scheduler._sync_and_clear_cache"):
output = scheduler.step()
assert output.outputs == []
assert output.has_work is True
assert scheduler.batch_generator is None
assert scheduler.running == {}
assert list(scheduler.request_id_to_uid) == []
waiting_ids = [request.request_id for request in scheduler.waiting]
assert waiting_ids == [
"req-overflow-0",
"req-overflow-1",
"req-overflow-2",
]
for request in scheduler.waiting:
assert request.generation_overflow_retries == 1
assert request.output_token_ids == []
assert request.output_text == ""
assert request.num_computed_tokens == 0
assert scheduler._effective_max_num_seqs() == 1
def test_generation_overflow_fails_after_serial_retry(
self, mock_model, mock_tokenizer
):
scheduler = self._make_scheduler(mock_model, mock_tokenizer, count=1)
request = next(iter(scheduler.running.values()))
request.generation_overflow_retries = 1
with patch("omlx.scheduler._sync_and_clear_cache"):
output = scheduler.step()
assert len(output.outputs) == 1
error_output = output.outputs[0]
assert error_output.request_id == request.request_id
assert error_output.finished is True
assert error_output.finish_reason == "error"
assert "Generation overflow not recoverable" in error_output.error
assert request.request_id in output.finished_request_ids
assert scheduler.running == {}
assert scheduler.waiting == deque()
assert request.request_id not in scheduler.requests
assert scheduler.has_requests() is False
def test_unrelated_overflow_still_raises(self, mock_model, mock_tokenizer):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.batch_generator = MagicMock()
scheduler.batch_generator.next_generated.side_effect = OverflowError(
"integer conversion overflow"
)
request = Request(
request_id="req-other-overflow",
prompt="prompt",
sampling_params=SamplingParams(max_tokens=4),
prompt_token_ids=[1],
num_prompt_tokens=1,
status=RequestStatus.RUNNING,
)
scheduler.running[request.request_id] = request
scheduler.requests[request.request_id] = request
with pytest.raises(OverflowError, match="integer conversion overflow"):
scheduler.step()
class TestStoreCacheAdmissionBackpressure:
"""Tests for store-cache admission backpressure (#1684)."""
def _make_request(self, request_id: str = "req-store-gate") -> Request:
return Request(
request_id=request_id,
prompt="hello",
sampling_params=SamplingParams(max_tokens=4),
prompt_token_ids=[1],
num_prompt_tokens=1,
)
def _queue_request(self, scheduler: Scheduler, request: Request) -> None:
scheduler.waiting.append(request)
scheduler.requests[request.request_id] = request
def test_schedule_waiting_defers_when_gate_full_without_running(
self, mock_model, mock_tokenizer
):
"""Sequential turns must respect store-cache backpressure."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
gate = _StoreCacheGate(cap=1)
gate.note_submitted()
scheduler._store_cache_gate = gate
request = self._make_request()
self._queue_request(scheduler, request)
scheduler._ensure_batch_generator = MagicMock()
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert rejected == []
assert list(scheduler.waiting) == [request]
assert scheduler.running == {}
scheduler._ensure_batch_generator.assert_not_called()
def test_memory_guard_fails_persistent_admission_stall(
self, mock_model, mock_tokenizer
):
"""A persistent memory-gated head-of-line wait should fail cleanly."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
request = self._make_request("req-stalled")
self._queue_request(scheduler, request)
running = self._make_request("req-running")
scheduler.running[running.request_id] = running
scheduler.requests[running.request_id] = running
scheduler._prefill_memory_guard = True
scheduler._memory_limit_bytes = 100
scheduler._current_usage_bytes = MagicMock(return_value=101)
scheduler._memory_admission_blocked_request_id = request.request_id
scheduler._memory_admission_blocked_since = 0.0
with patch("omlx.scheduler.time.monotonic", return_value=61.0):
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert len(rejected) == 1
assert rejected[0].request_id == request.request_id
assert rejected[0].finish_reason == "error"
assert rejected[0].error_code == "memory_admission_stalled"
assert request.request_id not in scheduler.requests
assert list(scheduler.waiting) == []
assert scheduler.running[running.request_id] is running
def test_store_cache_gate_fails_persistent_non_memory_stall(
self, mock_model, mock_tokenizer
):
"""A stuck store-cache gate must not leave admission waiting forever."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
gate = _StoreCacheGate(cap=1)
gate.note_submitted()
scheduler._store_cache_gate = gate
request = self._make_request("req-store-stalled")
self._queue_request(scheduler, request)
scheduler._prefill_memory_guard = True
scheduler._memory_limit_bytes = 100
scheduler._current_usage_bytes = MagicMock(return_value=50)
scheduler._store_cache_admission_blocked_request_id = request.request_id
scheduler._store_cache_admission_blocked_since = 0.0
scheduler._ensure_batch_generator = MagicMock()
with patch("omlx.scheduler.time.monotonic", return_value=61.0):
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert len(rejected) == 1
assert rejected[0].request_id == request.request_id
assert rejected[0].finish_reason == "error"
assert rejected[0].error_code == "store_cache_admission_stalled"
assert rejected[0].error_metadata["store_cache_in_flight"] == 1
assert request.request_id not in scheduler.requests
assert list(scheduler.waiting) == []
scheduler._ensure_batch_generator.assert_not_called()
def test_store_cache_stall_timer_clears_when_gate_recovers_before_freshness_wait(
self, mock_model, mock_tokenizer
):
"""A recovered store-cache gate must not accumulate stale stall time."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
gate = _StoreCacheGate(cap=2)
gate.note_submitted()
gate.note_submitted()
scheduler._store_cache_gate = gate
request = self._make_request("req-store-recovered")
self._queue_request(scheduler, request)
scheduler._prefill_memory_guard = True
scheduler._memory_limit_bytes = 100
scheduler._current_usage_bytes = MagicMock(return_value=50)
scheduler._ensure_batch_generator = MagicMock()
with patch("omlx.scheduler.time.monotonic", return_value=0.0):
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert rejected == []
assert scheduler._store_cache_admission_blocked_request_id == request.request_id
gate.note_done()
scheduler._should_defer_for_cache_freshness = MagicMock(return_value=True)
with patch("omlx.scheduler.time.monotonic", return_value=30.0):
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert rejected == []
assert list(scheduler.waiting) == [request]
assert scheduler._store_cache_admission_blocked_request_id is None
gate.note_submitted()
scheduler._should_defer_for_cache_freshness = MagicMock(return_value=False)
with patch("omlx.scheduler.time.monotonic", return_value=61.0):
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert rejected == []
assert list(scheduler.waiting) == [request]
assert scheduler._store_cache_admission_blocked_request_id == request.request_id
assert scheduler._store_cache_admission_blocked_since == 61.0
scheduler._ensure_batch_generator.assert_not_called()
def test_schedule_waiting_allows_when_gate_has_capacity(
self, mock_model, mock_tokenizer
):
"""Below-cap store-cache cleanup must not block admission."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
gate = _StoreCacheGate(cap=2)
gate.note_submitted()
scheduler._store_cache_gate = gate
request = self._make_request()
self._queue_request(scheduler, request)
batch_generator = MagicMock()
batch_generator.insert.return_value = [42]
scheduler.batch_generator = batch_generator
scheduler._ensure_batch_generator = MagicMock()
scheduler._build_sampler_and_processors = MagicMock(
return_value=(MagicMock(), [])
)
scheduler._build_state_machine = MagicMock(return_value=MagicMock())
scheduler._preflight_memory_check = MagicMock(return_value=None)
scheduled, rejected = scheduler._schedule_waiting()
assert rejected == []
assert scheduled == [request]
assert scheduler.waiting == deque()
assert scheduler.running[request.request_id] is request
scheduler._ensure_batch_generator.assert_called_once_with(
request.sampling_params
)
def test_llama4_admission_serializes_waiting_requests(
self, mock_model, mock_tokenizer
):
"""A second Llama 4 request stays queued instead of forming a batch."""
mock_model.config.model_type = "llama4"
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=SchedulerConfig(max_num_seqs=4),
)
first = self._make_request("req-llama4-1")
second = self._make_request("req-llama4-2")
self._queue_request(scheduler, first)
self._queue_request(scheduler, second)
batch_generator = MagicMock()
batch_generator.insert.return_value = [42]
scheduler.batch_generator = batch_generator
scheduler._ensure_batch_generator = MagicMock()
scheduler._build_sampler_and_processors = MagicMock(
return_value=(MagicMock(), [])
)
scheduler._build_state_machine = MagicMock(return_value=MagicMock())
scheduler._preflight_memory_check = MagicMock(return_value=None)
scheduled, rejected = scheduler._schedule_waiting()
assert rejected == []
assert scheduled == [first]
assert list(scheduler.waiting) == [second]
assert list(scheduler.running) == [first.request_id]
assert batch_generator.insert.call_count == 1
def test_schedule_waiting_defers_when_pending_cleanups_reach_cap(
self, mock_model, mock_tokenizer
):
"""Deferred removals still own cache refs even if the gate has room."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._store_cache_gate = _StoreCacheGate(cap=2)
scheduler._pending_async_removes.append(
(1, "req-cleanup-1", concurrent.futures.Future())
)
scheduler._pending_async_removes.append(
(2, "req-cleanup-2", concurrent.futures.Future())
)
request = self._make_request()
self._queue_request(scheduler, request)
scheduler._ensure_batch_generator = MagicMock()
scheduled, rejected = scheduler._schedule_waiting()
assert scheduled == []
assert rejected == []
assert list(scheduler.waiting) == [request]
scheduler._ensure_batch_generator.assert_not_called()
def test_drain_pending_async_removes_releases_done_entries_out_of_order(
self, mock_model, mock_tokenizer
):
"""One slow store-cache future must not pin later completed caches."""
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler.batch_generator = MagicMock()
gate = _StoreCacheGate(cap=2)
gate.note_submitted()
gate.note_submitted()
scheduler._store_cache_gate = gate
slow_future = concurrent.futures.Future()
done_future = concurrent.futures.Future()
done_future.set_result(None)
slow_request = self._make_request("req-slow")
done_request = self._make_request("req-done")
slow_request._extracted_cache = object()
done_request._extracted_cache = object()
scheduler.requests[slow_request.request_id] = slow_request
scheduler.requests[done_request.request_id] = done_request
scheduler._inflight_store_futures[slow_request.request_id] = slow_future
scheduler._inflight_store_futures[done_request.request_id] = done_future
scheduler._inflight_store_info[slow_request.request_id] = (
scheduler_module._InflightStoreInfo(tokens=[1, 2, 3])
)
scheduler._inflight_store_info[done_request.request_id] = (
scheduler_module._InflightStoreInfo(tokens=[1, 2, 3])
)
scheduler.request_id_to_uid[slow_request.request_id] = 1
scheduler.request_id_to_uid[done_request.request_id] = 2
scheduler.uid_to_request_id[1] = slow_request.request_id
scheduler.uid_to_request_id[2] = done_request.request_id
scheduler._pending_async_removes.append(
(1, slow_request.request_id, slow_future)
)
scheduler._pending_async_removes.append(
(2, done_request.request_id, done_future)
)
with patch("omlx.scheduler._safe_sync_stream"):
drained = scheduler._drain_pending_async_removes()
assert drained is True
assert list(scheduler._pending_async_removes) == [
(1, slow_request.request_id, slow_future)
]
assert slow_request.request_id in scheduler.requests
assert done_request.request_id not in scheduler.requests
assert done_request.request_id not in scheduler._inflight_store_futures
assert done_request.request_id not in scheduler._inflight_store_info
assert slow_request.request_id in scheduler._inflight_store_info
assert done_request.request_id not in scheduler.request_id_to_uid
assert 2 not in scheduler.uid_to_request_id
assert gate.in_flight == 1
scheduler.batch_generator.remove.assert_called_once_with([2])
class TestBatchGeneratorAllTokens:
"""TokenBuffer seed passed to mlx-lm BatchGenerator.insert."""
def _make_scheduler(self, mock_model, mock_tokenizer):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
batch_generator = MagicMock()
batch_generator.insert.return_value = [42]
scheduler.batch_generator = batch_generator
scheduler._ensure_batch_generator = MagicMock()
scheduler._build_sampler_and_processors = MagicMock(
return_value=(MagicMock(), [])
)
scheduler._build_state_machine = MagicMock(return_value=MagicMock())
scheduler._preflight_memory_check = MagicMock(return_value=None)
scheduler._validate_cache = MagicMock(return_value=True)
return scheduler
def _queue_request(
self,
scheduler: Scheduler,
request: Request,
*,
prompt_tokens: list[int],
remaining_tokens: list[int],
cached_tokens: int = 0,
prompt_cache=None,
) -> None:
request.prompt_token_ids = prompt_tokens
request.num_prompt_tokens = len(prompt_tokens)
request.remaining_tokens = remaining_tokens
request.cached_tokens = cached_tokens
request.prompt_cache = prompt_cache
scheduler.waiting.append(request)
scheduler.requests[request.request_id] = request
def test_cache_hit_insert_seeds_prompt_prefix(self, mock_model, mock_tokenizer):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
request = Request(
request_id="req-cache-hit-all-tokens",
prompt=[11, 12, 13, 14],
sampling_params=SamplingParams(max_tokens=4),
)
self._queue_request(
scheduler,
request,
prompt_tokens=[11, 12, 13, 14],
remaining_tokens=[14],
cached_tokens=3,
prompt_cache=[MagicMock()],
)
scheduler._schedule_waiting()
call_kwargs = scheduler.batch_generator.insert.call_args.kwargs
assert call_kwargs["all_tokens"] == [[11, 12, 13]]
def test_external_prefill_insert_seeds_prompt_prefix(
self, mock_model, mock_tokenizer
):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
scheduler._do_external_prefill = MagicMock(return_value=([MagicMock()], [14]))
request = Request(
request_id="req-prefill-all-tokens",
prompt=[11, 12, 13, 14],
sampling_params=SamplingParams(max_tokens=4),
)
self._queue_request(
scheduler,
request,
prompt_tokens=[11, 12, 13, 14],
remaining_tokens=[11, 12, 13, 14],
)
scheduler._schedule_waiting()
call_kwargs = scheduler.batch_generator.insert.call_args.kwargs
assert call_kwargs["all_tokens"] == [[11, 12, 13]]
def test_single_token_prompt_uses_empty_seed(self, mock_model, mock_tokenizer):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
request = Request(
request_id="req-single-token-all-tokens",
prompt=[99],
sampling_params=SamplingParams(max_tokens=4),
)
self._queue_request(
scheduler,
request,
prompt_tokens=[99],
remaining_tokens=[99],
)
scheduler._schedule_waiting()
call_kwargs = scheduler.batch_generator.insert.call_args.kwargs
assert call_kwargs["all_tokens"] == [[]]
def test_concurrent_inserts_keep_per_request_seed(self, mock_model, mock_tokenizer):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
first = Request(
request_id="req-concurrent-a",
prompt=[11, 12, 13],
sampling_params=SamplingParams(max_tokens=4),
)
second = Request(
request_id="req-concurrent-b",
prompt=[21, 22, 23, 24],
sampling_params=SamplingParams(max_tokens=4),
)
self._queue_request(
scheduler,
first,
prompt_tokens=[11, 12, 13],
remaining_tokens=[13],
prompt_cache=[MagicMock()],
)
self._queue_request(
scheduler,
second,
prompt_tokens=[21, 22, 23, 24],
remaining_tokens=[24],
prompt_cache=[MagicMock()],
)
scheduler._schedule_waiting()
calls = scheduler.batch_generator.insert.call_args_list
assert [call.kwargs["all_tokens"] for call in calls] == [
[[11, 12]],
[[21, 22, 23]],
]
def test_chunked_prefill_insert_seeds_prompt_prefix(
self, mock_model, mock_tokenizer
):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
request = Request(
request_id="req-chunked-all-tokens",
prompt=[11, 12, 13, 14],
sampling_params=SamplingParams(max_tokens=4),
)
request.prompt_token_ids = [11, 12, 13, 14]
request.num_prompt_tokens = 4
state = _PrefillState(
request=request,
cache=[MagicMock()],
tokens_remaining=mx.array([[]]),
last_token=[14],
tokens_processed=3,
base_size=0,
emitted_boundaries={},
boundary_enabled=False,
block_size=0,
total_length=4,
sampler=MagicMock(),
sm=MagicMock(),
per_row_lps=[],
)
scheduled = []
scheduler._insert_prefilled_request(request, state, scheduled)
call_kwargs = scheduler.batch_generator.insert.call_args.kwargs
assert call_kwargs["all_tokens"] == [[11, 12, 13]]
assert scheduled == [request]
def test_chunked_prefill_converts_turboquant_cache_before_insert(
self, mock_model, mock_tokenizer
):
"""Chunked prefill must mirror external prefill's TQ epilogue."""
from mlx_lm.models.cache import KVCache
from mlx_vlm.turboquant import TurboQuantKVCache
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
scheduler._turboquant_skip_last = False
kv_cache = KVCache()
kv_cache.update_and_fetch(
mx.random.normal((1, 2, 4, 32)),
mx.random.normal((1, 2, 4, 32)),
)
request = Request(
request_id="req-chunked-tq",
prompt=[11, 12, 13, 14, 15],
sampling_params=SamplingParams(max_tokens=4),
)
request.prompt_token_ids = [11, 12, 13, 14, 15]
request.num_prompt_tokens = 5
request.cached_tokens = 4
state = _PrefillState(
request=request,
cache=[kv_cache],
tokens_remaining=mx.array([[]]),
last_token=[15],
tokens_processed=4,
base_size=4,
emitted_boundaries={},
boundary_enabled=False,
block_size=0,
total_length=5,
sampler=MagicMock(),
sm=MagicMock(),
per_row_lps=[],
)
scheduled = []
with patch("omlx.scheduler._materialize_cache_storage") as materialize:
with patch("omlx.scheduler._sync_and_clear_cache") as sync_clear:
scheduler._insert_prefilled_request(request, state, scheduled)
call_kwargs = scheduler.batch_generator.insert.call_args.kwargs
inserted_cache = call_kwargs["caches"][0][0]
assert isinstance(inserted_cache, TurboQuantKVCache)
assert state.cache[0] is inserted_cache
materialize.assert_called_once_with(state.cache)
sync_clear.assert_called_once_with(scheduler._stream)
assert scheduled == [request]
def test_chunked_prefill_converts_after_sized_arrays_restore(
self, mock_model, mock_tokenizer
):
"""Restored ArraysCache wrappers must not skip the TQ epilogue."""
from mlx_lm.models.cache import ArraysCache, KVCache
from mlx_vlm.turboquant import TurboQuantKVCache
from omlx.cache.type_handlers import SizedArraysCache
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
scheduler._turboquant_skip_last = False
arrays_cache = ArraysCache(size=2)
arrays_cache.cache[0] = mx.random.normal((1, 3, 16))
arrays_cache.cache[1] = mx.random.normal((1, 2, 16, 16))
sized_arrays_cache = SizedArraysCache(arrays_cache, token_count=4)
kv_cache = KVCache()
kv_cache.update_and_fetch(
mx.random.normal((1, 2, 4, 32)),
mx.random.normal((1, 2, 4, 32)),
)
request = Request(
request_id="req-chunked-tq-sized-arrays",
prompt=[11, 12, 13, 14, 15],
sampling_params=SamplingParams(max_tokens=4),
)
request.prompt_token_ids = [11, 12, 13, 14, 15]
request.num_prompt_tokens = 5
request.cached_tokens = 4
state = _PrefillState(
request=request,
cache=[sized_arrays_cache, kv_cache],
tokens_remaining=mx.array([[]]),
last_token=[15],
tokens_processed=4,
base_size=4,
emitted_boundaries={},
boundary_enabled=False,
block_size=0,
total_length=5,
sampler=MagicMock(),
sm=MagicMock(),
per_row_lps=[],
)
scheduled = []
with patch("omlx.scheduler._materialize_cache_storage") as materialize:
with patch("omlx.scheduler._sync_and_clear_cache") as sync_clear:
scheduler._insert_prefilled_request(request, state, scheduled)
call_kwargs = scheduler.batch_generator.insert.call_args.kwargs
inserted_cache = call_kwargs["caches"][0]
assert inserted_cache[0] is sized_arrays_cache
assert isinstance(inserted_cache[1], TurboQuantKVCache)
assert state.cache[1] is inserted_cache[1]
materialize.assert_called_once_with(state.cache)
sync_clear.assert_called_once_with(scheduler._stream)
assert scheduled == [request]
class TestDetectNeedsThinkPrefix:
"""Tests for _detect_needs_think_prefix() method.
Verifies that <think></think> (disabled thinking) patterns are correctly
distinguished from <think>\\n (enabled thinking) patterns.
"""
def _make_scheduler(self, mock_model, think_start_id, think_end_id=None):
"""Create scheduler with think token IDs on the tokenizer."""
from conftest import MockTokenizer
tokenizer = MockTokenizer()
tokenizer.think_start_id = think_start_id
if think_end_id is not None:
tokenizer.think_end_id = think_end_id
return Scheduler(model=mock_model, tokenizer=tokenizer)
def _make_request(self, prompt_token_ids):
"""Create a request with given prompt token IDs."""
return Request(
request_id="test-think",
prompt="test",
sampling_params=SamplingParams(),
prompt_token_ids=list(prompt_token_ids),
num_prompt_tokens=len(prompt_token_ids),
)
def test_enabled_thinking_with_newline(self, mock_model):
"""<think> + \\n at end -> True (enabled thinking, e.g. DeepSeek)."""
scheduler = self._make_scheduler(
mock_model, think_start_id=100, think_end_id=101
)
request = self._make_request([1, 2, 3, 100, 198]) # 198 = \n
assert scheduler._detect_needs_think_prefix(request) is True
def test_enabled_thinking_last_token(self, mock_model):
"""<think> as last token -> True."""
scheduler = self._make_scheduler(
mock_model, think_start_id=100, think_end_id=101
)
request = self._make_request([1, 2, 3, 100])
assert scheduler._detect_needs_think_prefix(request) is True
def test_disabled_thinking_adjacent(self, mock_model):
"""<think></think> adjacent -> False (disabled, e.g. Nemotron)."""
scheduler = self._make_scheduler(
mock_model, think_start_id=100, think_end_id=101
)
request = self._make_request([1, 2, 3, 100, 101])
assert scheduler._detect_needs_think_prefix(request) is False
def test_disabled_thinking_with_prefix(self, mock_model):
"""X <think></think> -> False (disabled with preceding token)."""
scheduler = self._make_scheduler(
mock_model, think_start_id=100, think_end_id=101
)
request = self._make_request([1, 2, 50, 100, 101])
assert scheduler._detect_needs_think_prefix(request) is False
def test_no_think_token_in_tail(self, mock_model):
"""No <think> in last 3 tokens -> False."""
scheduler = self._make_scheduler(
mock_model, think_start_id=100, think_end_id=101
)
request = self._make_request([1, 2, 3, 4, 5])
assert scheduler._detect_needs_think_prefix(request) is False
def test_no_think_start_id_on_tokenizer(self, mock_model):
"""Tokenizer without think_start_id -> False."""
from conftest import MockTokenizer
tokenizer = MockTokenizer()
scheduler = Scheduler(model=mock_model, tokenizer=tokenizer)
request = self._make_request([1, 2, 3])
assert scheduler._detect_needs_think_prefix(request) is False
def test_empty_prompt(self, mock_model):
"""Empty prompt -> False."""
scheduler = self._make_scheduler(
mock_model, think_start_id=100, think_end_id=101
)
request = self._make_request([])
assert scheduler._detect_needs_think_prefix(request) is False
def test_no_think_end_id_still_sets_true(self, mock_model):
"""<think> found but no think_end_id resolvable -> True (safe fallback)."""
scheduler = self._make_scheduler(mock_model, think_start_id=100)
request = self._make_request([1, 2, 100, 101])
assert scheduler._detect_needs_think_prefix(request) is True
def test_think_start_id_raises_type_error(self, mock_model):
"""Tokenizer whose think_start_id raises TypeError -> False.
Models like context-1 (harmony parser) have _think_start_tokens=None
in their mlx-lm tokenizer, causing think_start_id to raise TypeError.
"""
from unittest.mock import PropertyMock
from conftest import MockTokenizer
tokenizer = MockTokenizer()
type(tokenizer).think_start_id = PropertyMock(
side_effect=TypeError("object of type 'NoneType' has no len()")
)
scheduler = Scheduler(model=mock_model, tokenizer=tokenizer)
request = self._make_request([1, 2, 3])
assert scheduler._detect_needs_think_prefix(request) is False
class TestOutputParserSmoke:
"""Smoke tests for scheduler output parser session integration."""
class _Detokenizer:
def __init__(self, decode_one):
self._decode_one = decode_one
self.last_segment = ""
def reset(self):
self.last_segment = ""
def add_token(self, token_id):
self.last_segment = self._decode_one(token_id)
def finalize(self):
self.last_segment = ""
class _GemmaTokenizer:
def __init__(self, token_map):
self._token_map = token_map
self.eos_token_id = 2
self.pad_token_id = 0
self.bos_token_id = 1
@property
def detokenizer(self):
return TestOutputParserSmoke._Detokenizer(
lambda token_id: self._token_map[token_id]
)
def encode(self, text: str, add_special_tokens: bool = True):
if text == "\n":
return [198]
return [10]
def decode(self, token_ids, skip_special_tokens: bool = True):
return "".join(self._token_map.get(token_id, "") for token_id in token_ids)
class _DeepSeekV4Tokenizer(_GemmaTokenizer):
has_tool_calling = True
tool_call_start = "<DSMLtool_calls>"
tool_call_end = "</DSMLtool_calls>"
def tool_parser(self, text: str, tools=None):
from omlx.patches.deepseek_v4.tool_parser_v4 import parse_tool_call
return parse_tool_call(text, tools)
def test_gemma4_session_selected_and_markers_hidden(self, mock_model):
mock_model.config.model_type = "gemma4"
tokenizer = self._GemmaTokenizer(
{
11: "<|channel>",
12: "thought\n",
13: "reasoning",
14: "<channel|>",
15: "answer",
16: "<turn|>",
}
)
scheduler = Scheduler(
model=mock_model,
tokenizer=tokenizer,
config=SchedulerConfig(model_name="google/gemma-4b"),
)
assert scheduler._output_parser_kind == "gemma4"
request = Request(
request_id="gemma-req",
prompt="prompt",
sampling_params=SamplingParams(max_tokens=5),
prompt_token_ids=[1, 2, 3],
num_prompt_tokens=3,
status=RequestStatus.RUNNING,
batch_uid=99,
)
scheduler.running[request.request_id] = request
scheduler.requests[request.request_id] = request
scheduler.uid_to_request_id[99] = request.request_id
scheduler.request_id_to_uid[request.request_id] = 99
responses = [
type("Resp", (), {"uid": 99, "token": 11, "finish_reason": None})(),
type("Resp", (), {"uid": 99, "token": 12, "finish_reason": None})(),
type("Resp", (), {"uid": 99, "token": 13, "finish_reason": None})(),
type("Resp", (), {"uid": 99, "token": 14, "finish_reason": None})(),
type("Resp", (), {"uid": 99, "token": 15, "finish_reason": None})(),
type("Resp", (), {"uid": 99, "token": 16, "finish_reason": "length"})(),
]
outputs, finished_ids = scheduler._process_batch_responses(responses)
assert finished_ids == {"gemma-req"}
assert outputs[-1].finished is True
assert outputs[-1].output_text == "<think>\nreasoning</think>\nanswer"
full_stream = "".join(output.new_text for output in outputs)
assert "<|channel>" not in full_stream
assert "<channel|>" not in full_stream
assert full_stream == "<think>\nreasoning</think>\nanswer"
def test_gemma4_batch_stop_token_not_streamed(self, mock_model):
mock_model.config.model_type = "gemma4"
tokenizer = self._GemmaTokenizer(
{
2: "<eos>",
}
)
scheduler = Scheduler(
model=mock_model,
tokenizer=tokenizer,
config=SchedulerConfig(model_name="google/gemma-4b"),
)
assert scheduler._output_parser_kind == "gemma4"
request = Request(
request_id="gemma-stop-req",
prompt="prompt",
sampling_params=SamplingParams(max_tokens=5),
prompt_token_ids=[1, 2, 3],
num_prompt_tokens=3,
status=RequestStatus.RUNNING,
batch_uid=99,
)
scheduler.running[request.request_id] = request
scheduler.requests[request.request_id] = request
scheduler.uid_to_request_id[99] = request.request_id
scheduler.request_id_to_uid[request.request_id] = 99
responses = [
type("Resp", (), {"uid": 99, "token": 2, "finish_reason": "stop"})(),
]
outputs, finished_ids = scheduler._process_batch_responses(responses)
assert finished_ids == {"gemma-stop-req"}
assert outputs[-1].finished is True
assert outputs[-1].finish_reason == "stop"
assert outputs[-1].new_text == ""
assert outputs[-1].output_text == ""
assert outputs[-1].new_token_ids == []
assert outputs[-1].output_token_ids == []
def test_parser_stop_sets_finish_reason(self, mock_model):
tokenizer = self._GemmaTokenizer({11: "<|return|>"})
scheduler = Scheduler(
model=mock_model,
tokenizer=tokenizer,
config=SchedulerConfig(model_name="test-model"),
)
scheduler._output_parser_factory = _ParserStopFactory()
request = Request(
request_id="parser-stop-req",
prompt="prompt",
sampling_params=SamplingParams(max_tokens=5),
prompt_token_ids=[1, 2, 3],
num_prompt_tokens=3,
status=RequestStatus.RUNNING,
batch_uid=99,
)
scheduler.running[request.request_id] = request
scheduler.requests[request.request_id] = request
scheduler.uid_to_request_id[99] = request.request_id
scheduler.request_id_to_uid[request.request_id] = 99
responses = [
type("Resp", (), {"uid": 99, "token": 11, "finish_reason": None})(),
]
outputs, finished_ids = scheduler._process_batch_responses(responses)
assert finished_ids == {"parser-stop-req"}
assert outputs[-1].finished is True
assert outputs[-1].finish_reason == "stop"
def test_deepseek_v4_tool_block_end_stops_batch_row(self, mock_model):
mock_model.config.model_type = "deepseek_v4"
tokenizer = self._DeepSeekV4Tokenizer(
{
11: "Working\n",
12: '<DSMLtool_calls>\n<DSMLinvoke name="Bash">\n',
13: '<DSMLparameter name="command" string="true">ls</DSMLparameter>\n'
"</DSMLinvoke>\n"
"</DSMLtool_calls>\n"
'<DSMLparameter name="command" string="true">pwd</DSMLparameter>',
}
)
scheduler = Scheduler(
model=mock_model,
tokenizer=tokenizer,
config=SchedulerConfig(model_name="DeepSeek-V4-Flash-oQ4e"),
)
assert scheduler._output_parser_kind == "deepseek_v4"
request = Request(
request_id="deepseek-v4-tool-req",
prompt="prompt",
sampling_params=SamplingParams(max_tokens=5),
prompt_token_ids=[1, 2, 3],
num_prompt_tokens=3,
status=RequestStatus.RUNNING,
batch_uid=99,
)
scheduler.running[request.request_id] = request
scheduler.requests[request.request_id] = request
scheduler.uid_to_request_id[99] = request.request_id
scheduler.request_id_to_uid[request.request_id] = 99
responses = [
type("Resp", (), {"uid": 99, "token": 11, "finish_reason": None})(),
type("Resp", (), {"uid": 99, "token": 12, "finish_reason": None})(),
type("Resp", (), {"uid": 99, "token": 13, "finish_reason": None})(),
]
outputs, finished_ids = scheduler._process_batch_responses(responses)
assert finished_ids == {"deepseek-v4-tool-req"}
assert outputs[-1].finished is True
assert outputs[-1].finish_reason == "tool_calls"
assert outputs[-1].output_text == "Working\n"
assert outputs[-1].new_token_ids == []
assert outputs[-1].tool_calls
assert outputs[-1].tool_calls[0]["name"] == "Bash"
assert json.loads(outputs[-1].tool_calls[0]["arguments"]) == {"command": "ls"}
full_stream = "".join(output.new_text for output in outputs)
assert full_stream == "Working\n"
assert "pwd" not in full_stream
assert "<DSMLparameter" not in full_stream
class TestVLMPositionStateClearing:
"""Tests for conditional mRoPE position state clearing (#531).
VLM batches must preserve position state set by get_input_embeddings();
text-only batches must clear stale VLM position state.
"""
def _make_vlm_model(self):
"""Create a mock model with clear_vlm_position_state.
Includes make_cache (returning empty list) so that
_do_external_prefill can call make_prompt_cache(model)
without hitting AttributeError on model.layers.
"""
model = MagicMock(
spec=[
"__call__",
"clear_vlm_position_state",
"parameters",
"make_cache",
]
)
model.clear_vlm_position_state = MagicMock()
model.make_cache.return_value = []
return model
def test_schedule_waiting_preserves_vlm_position_state(self, mock_tokenizer):
"""VLM request in _schedule_waiting should NOT clear position state.
With external prefill, clear_vlm_position_state is called inside
_do_external_prefill only for text-only requests (vlm_embeds is None).
VLM requests pass vlm_embeds, so the clear is skipped.
"""
model = self._make_vlm_model()
scheduler = Scheduler(model=model, tokenizer=mock_tokenizer)
# Minimal batch generator mock
mock_bg = MagicMock()
mock_bg.insert = MagicMock(return_value=[42])
scheduler.batch_generator = mock_bg
request = Request(
request_id="vlm-001",
prompt="describe this image",
sampling_params=SamplingParams(max_tokens=50),
)
request.prompt_token_ids = [1, 2, 3, 4, 5]
request.num_prompt_tokens = 5
# Use a real mx.array so _do_external_prefill can slice it
request.vlm_inputs_embeds = mx.zeros((1, 5, 64))
scheduler.waiting.append(request)
scheduler.requests[request.request_id] = request
scheduler._schedule_waiting()
model.clear_vlm_position_state.assert_not_called()
def test_schedule_waiting_clears_text_only_position_state(self, mock_tokenizer):
"""Text-only request in _schedule_waiting should clear position state.
With external prefill, clear_vlm_position_state is called inside
_do_external_prefill when vlm_embeds is None (text-only).
"""
model = self._make_vlm_model()
scheduler = Scheduler(model=model, tokenizer=mock_tokenizer)
mock_bg = MagicMock()
mock_bg.insert = MagicMock(return_value=[42])
scheduler.batch_generator = mock_bg
request = Request(
request_id="text-001",
prompt="hello world",
sampling_params=SamplingParams(max_tokens=50),
)
request.prompt_token_ids = [1, 2, 3, 4, 5]
request.num_prompt_tokens = 5
# vlm_inputs_embeds is None by default (text-only)
scheduler.waiting.append(request)
scheduler.requests[request.request_id] = request
scheduler._schedule_waiting()
model.clear_vlm_position_state.assert_called_once()
def test_cached_text_only_prefill_seeds_zero_mrope_delta(self, mock_tokenizer):
"""Cached text-only mRoPE suffixes must start at the restored offset."""
model = self._make_vlm_model()
model._language_model = MagicMock()
model._language_model._rope_deltas = mx.array([[123]])
scheduler = Scheduler(model=model, tokenizer=mock_tokenizer)
request = Request(
request_id="text-cached-001",
prompt="hello world",
sampling_params=SamplingParams(max_tokens=50),
)
request.prompt_token_ids = [1, 2, 3, 4]
request.num_prompt_tokens = 4
request.cached_tokens = 2048
scheduler._do_external_prefill(
request,
tokens=[1, 2, 3, 4],
existing_cache=[],
vlm_embeds=None,
)
model.clear_vlm_position_state.assert_called_once()
seeded = model._language_model._rope_deltas
assert seeded.shape == (1, 1)
assert seeded.item() == 0
class TestBuildStateMachineStopStrings:
"""Tests for _build_state_machine stop-string tokenization.
The scheduler must convert SamplingParams.stop (a list of strings)
into token-sequence transitions on the per-request state machine,
so mlx-lm's BatchGenerator can halt on user-supplied stop sequences.
"""
def _make_scheduler(self, mock_model, mock_tokenizer):
return Scheduler(model=mock_model, tokenizer=mock_tokenizer)
def _request_with_stop(self, stop):
return Request(
request_id="stop-001",
prompt="hello",
sampling_params=SamplingParams(max_tokens=10, stop=stop),
)
def test_no_stop_string_only_eos_transitions(self, mock_model, mock_tokenizer):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
sm = scheduler._build_state_machine(self._request_with_stop([]))
# SequenceStateMachine has internal _states dict; non-empty implies
# at least the EOS transitions are present.
assert sm._states
def test_stop_string_added_as_token_sequence(self, mock_model, mock_tokenizer):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
# MockTokenizer encodes "delta" to a single hash-derived token id.
expected_seq = mock_tokenizer.encode("delta", add_special_tokens=False)
assert expected_seq, "MockTokenizer must produce a token for 'delta'"
sm = scheduler._build_state_machine(self._request_with_stop(["delta"]))
# Walk the trie following expected_seq; the terminal node must
# have a __match__ entry, meaning the sequence is registered.
node = sm._states["normal"][0]
for tok in expected_seq:
assert tok in node, f"token {tok} missing from trie"
node = node[tok]
assert "__match__" in node, "stop sequence not terminated in trie"
def test_empty_or_non_string_entries_skipped(self, mock_model, mock_tokenizer):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
# Mixed list with empty string and non-string entry; only "real"
# should be tokenized.
sm = scheduler._build_state_machine(self._request_with_stop(["", "real", 123]))
real_seq = mock_tokenizer.encode("real", add_special_tokens=False)
node = sm._states["normal"][0]
for tok in real_seq:
assert tok in node
node = node[tok]
assert "__match__" in node
def test_multiple_stop_strings_all_registered(self, mock_model, mock_tokenizer):
scheduler = self._make_scheduler(mock_model, mock_tokenizer)
sm = scheduler._build_state_machine(self._request_with_stop(["foo", "bar"]))
for stop_str in ("foo", "bar"):
seq = mock_tokenizer.encode(stop_str, add_special_tokens=False)
node = sm._states["normal"][0]
for tok in seq:
assert tok in node
node = node[tok]
assert "__match__" in node
class TestTurboQuantMLAGuard:
"""Regression tests for #1613: MLA models must not be TurboQuant-converted.
GLM-4.7-Flash / DeepSeek use Multi-head Latent Attention and read fetched
cache tensors directly (k_pe.swapaxes(...)), which crashes on TurboQuant's
quantized NamedTuple states ('TurboQuantMSEState' object has no attribute
'swapaxes'). _turboquant_eligible() must return False for them so they stay
fp16.
"""
def test_mla_model_ineligible_by_config(self, mock_model, mock_tokenizer):
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
# MLA config exposes kv_lora_rank (GLM-4.7-Flash, DeepSeek-V*).
scheduler.model = SimpleNamespace(args=SimpleNamespace(kv_lora_rank=512))
scheduler._mla_model = None
assert scheduler._model_uses_mla() is True
assert scheduler._turboquant_eligible([KVCache()]) is False
def test_mla_model_ineligible_by_architecture(self, mock_model, mock_tokenizer):
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
# No kv_lora_rank in config, but an attention submodule with the MLA
# down-projection / latent layernorm.
attn = SimpleNamespace(
kv_a_proj_with_mqa=object(),
kv_a_layernorm=object(),
kv_lora_rank=512,
)
scheduler.model = SimpleNamespace(
args=SimpleNamespace(), modules=lambda: [attn]
)
scheduler._mla_model = None
assert scheduler._model_uses_mla() is True
assert scheduler._turboquant_eligible([KVCache()]) is False
def test_standard_model_still_eligible(self, mock_model, mock_tokenizer):
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
# Standard MHA/GQA model: no kv_lora_rank, no MLA submodules.
scheduler.model = SimpleNamespace(
args=SimpleNamespace(num_hidden_layers=4), modules=lambda: []
)
scheduler._mla_model = None
assert scheduler._model_uses_mla() is False
assert scheduler._turboquant_eligible([KVCache()]) is True
def test_mla_model_ineligible_nested_text_config(self, mock_model, mock_tokenizer):
# VLM MLA (e.g. kimi_vl): kv_lora_rank is nested under text_config, not
# on the top-level args.
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
scheduler.model = SimpleNamespace(
args=SimpleNamespace(text_config=SimpleNamespace(kv_lora_rank=512))
)
scheduler._mla_model = None
assert scheduler._model_uses_mla() is True
assert scheduler._turboquant_eligible([KVCache()]) is False
def test_mla_vlm_adapter_delegates_to_language_model(
self, mock_model, mock_tokenizer
):
# VLMModelAdapter exposes _language_model; its args surface kv_lora_rank.
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
lm = SimpleNamespace(args=SimpleNamespace(kv_lora_rank=512))
scheduler.model = SimpleNamespace(args=SimpleNamespace(), _language_model=lm)
scheduler._mla_model = None
assert scheduler._model_uses_mla() is True
assert scheduler._turboquant_eligible([KVCache()]) is False
def test_mla_detection_is_memoized(self, mock_model, mock_tokenizer):
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
calls = {"n": 0}
def _modules():
calls["n"] += 1
return []
scheduler.model = SimpleNamespace(args=SimpleNamespace(), modules=_modules)
scheduler._mla_model = None
assert scheduler._model_uses_mla() is False
assert scheduler._model_uses_mla() is False
assert calls["n"] == 1 # walked once, then cached
class TestTurboQuantAttentionSinkGuard:
"""Attention-sink models must not use TQ kernels that drop sink logits."""
def test_attention_sink_model_ineligible_by_module_mapping(
self, mock_model, mock_tokenizer
):
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
scheduler.model = SimpleNamespace(
args=SimpleNamespace(),
modules=lambda: [{"sinks": mx.zeros((8,))}],
)
scheduler._mla_model = None
scheduler._attention_sink_model = None
assert scheduler._model_uses_attention_sinks() is True
assert scheduler._turboquant_eligible([KVCache()]) is False
def test_attention_sink_model_ineligible_by_module_attribute(
self, mock_model, mock_tokenizer
):
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
attn = SimpleNamespace(sinks=mx.zeros((8,)))
scheduler.model = SimpleNamespace(
args=SimpleNamespace(), modules=lambda: [attn]
)
scheduler._mla_model = None
scheduler._attention_sink_model = None
assert scheduler._model_uses_attention_sinks() is True
assert scheduler._turboquant_eligible([KVCache()]) is False
def test_standard_model_without_sinks_still_eligible(
self, mock_model, mock_tokenizer
):
from mlx_lm.models.cache import KVCache
scheduler = Scheduler(model=mock_model, tokenizer=mock_tokenizer)
scheduler._turboquant_kv_bits = 4.0
scheduler.model = SimpleNamespace(
args=SimpleNamespace(model_type="llama"), modules=lambda: []
)
scheduler._mla_model = None
scheduler._attention_sink_model = None
assert scheduler._model_uses_attention_sinks() is False
assert scheduler._turboquant_eligible([KVCache()]) is True
class TestSchedulerModelIdDerivation:
"""Regression tests for PR #2178: the scheduler must use
config.model_name directly as model_id for tracker lookups, rather than
os.path.basename(config.model_name). HF cache snapshot hashes must not
leak into prefill progress tracking."""
def test_prompt_progress_uses_model_name_not_basename(
self, mock_model, mock_tokenizer
):
"""_on_prompt_progress passes config.model_name to the tracker, not
os.path.basename(config.model_name)."""
from omlx.prefill_progress import get_prefill_tracker
config = SchedulerConfig(
model_name="Jundot--Qwen3.6-35B-oQ4",
model_path="/cache/models--Jundot--Qwen3.6-35B-oQ4/snapshots/def456",
)
scheduler = Scheduler(
model=mock_model,
tokenizer=mock_tokenizer,
config=config,
)
tracker = get_prefill_tracker()
tracker.clear()
# _on_prompt_progress looks up request_id via uid_to_request_id
scheduler.uid_to_request_id[0] = "test-req"
scheduler._on_prompt_progress([(0, 50, 200)])
progress = tracker.get_model_progress("Jundot--Qwen3.6-35B-oQ4")
assert len(progress) == 1
progress_none = tracker.get_model_progress("def456")
assert len(progress_none) == 0
tracker.clear()