diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..7a6fc61 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for configuration classes: CacheConfig, ParallelConfig, SchedulerConfig. + +These tests cover: + - CacheConfig: gpu_memory_utilization bounds (upper + lower), block_size + storage, deferred fields. + - ParallelConfig: _verify_args assertions, data_parallel_size auto-derive + when 0 is passed, world_size computation. + - SchedulerConfig: max_num_batched_tokens vs. max_model_len check, + prefill interval lower bounds. + +No model weights, GPU, or CUDA device are required; all tests run on CPU. + +NOTE: test_cache_config_rejects_zero_utilization and +test_cache_config_rejects_negative_utilization are currently marked xfail +because the lower-bound guard has not yet been added to CacheConfig._verify_args. +They will automatically flip to XPASS (and then be un-marked) once the companion +fix `fix/cache-config-validation` is merged. +""" + +from __future__ import annotations + +import pytest + +from deepinfer.config import CacheConfig, ParallelConfig, SchedulerConfig +from deepinfer.engine.engine_type import EngineType + + +# --------------------------------------------------------------------------- +# CacheConfig +# --------------------------------------------------------------------------- + +class TestCacheConfig: + def test_valid_construction(self) -> None: + cfg = CacheConfig(block_size=16, gpu_memory_utilization=0.9) + assert cfg.block_size == 16 + assert cfg.gpu_memory_utilization == pytest.approx(0.9) + + def test_boundary_utilization_one(self) -> None: + """Exactly 1.0 must be accepted (full GPU allocation).""" + cfg = CacheConfig(block_size=16, gpu_memory_utilization=1.0) + assert cfg.gpu_memory_utilization == pytest.approx(1.0) + + def test_lookahead_tokens_default_zero(self) -> None: + cfg = CacheConfig(block_size=16, gpu_memory_utilization=0.5) + assert cfg.lookahead_tokens == 0 + + def test_lookahead_tokens_stored(self) -> None: + cfg = CacheConfig(block_size=16, gpu_memory_utilization=0.5, lookahead_tokens=4) + assert cfg.lookahead_tokens == 4 + + def test_deferred_fields_initially_none(self) -> None: + cfg = CacheConfig(block_size=16, gpu_memory_utilization=0.5) + assert cfg.num_gpu_blocks is None + assert cfg.num_state_seqs is None + assert cfg.bytes_of_state_per_seqs is None + assert cfg.bytes_per_block is None + + def test_above_one_raises(self) -> None: + with pytest.raises(ValueError, match="GPU memory utilization"): + CacheConfig(block_size=16, gpu_memory_utilization=1.01) + + def test_well_above_one_raises(self) -> None: + with pytest.raises(ValueError, match="GPU memory utilization"): + CacheConfig(block_size=16, gpu_memory_utilization=2.0) + + @pytest.mark.xfail( + strict=True, + reason="Lower-bound guard missing in CacheConfig._verify_args; " + "fixed by PR fix/cache-config-validation.", + ) + def test_zero_utilization_raises(self) -> None: + """gpu_memory_utilization=0.0 should be rejected — reserving zero GPU + memory for KV blocks makes the engine non-functional and produces a + cryptic division-by-zero deep in the memory profiler.""" + with pytest.raises(ValueError, match="GPU memory utilization"): + CacheConfig(block_size=16, gpu_memory_utilization=0.0) + + @pytest.mark.xfail( + strict=True, + reason="Lower-bound guard missing in CacheConfig._verify_args; " + "fixed by PR fix/cache-config-validation.", + ) + def test_negative_utilization_raises(self) -> None: + """Negative gpu_memory_utilization is nonsensical and should be rejected + at construction time rather than surfacing as a cryptic failure later.""" + with pytest.raises(ValueError, match="GPU memory utilization"): + CacheConfig(block_size=16, gpu_memory_utilization=-0.5) + + +# --------------------------------------------------------------------------- +# ParallelConfig +# --------------------------------------------------------------------------- + +def _parallel( + dp: int = 2, + cp: int = 1, + ep: int = 2, + engram: int = 0, + engine_rank: int = 0, + engine_group_rank: int = 0, + engine_type: EngineType = EngineType.NORMAL, +) -> ParallelConfig: + return ParallelConfig( + data_parallel_size=dp, + context_parallel_size=cp, + expert_parallel_size=ep, + engram_parallel_size=engram, + engine_rank=engine_rank, + engine_group_rank=engine_group_rank, + engine_type=engine_type, + ) + + +class TestParallelConfig: + def test_valid_minimal(self) -> None: + cfg = _parallel(dp=2, cp=1, ep=2, engram=0) + assert cfg.data_parallel_size == 2 + assert cfg.context_parallel_size == 1 + assert cfg.expert_parallel_size == 2 + + def test_data_parallel_auto_derive_from_ep_and_cp(self) -> None: + """When data_parallel_size=0, it should be derived as ep // cp.""" + cfg = _parallel(dp=0, cp=2, ep=4, engram=0) + assert cfg.data_parallel_size == 2 # 4 // 2 + + def test_data_parallel_auto_derive_minimum_one(self) -> None: + """Auto-derived data_parallel_size is always at least 1.""" + # ep=1, cp=4 → integer division gives 0, but clamped to 1. + cfg = _parallel(dp=0, cp=4, ep=1, engram=0, engine_rank=0) + assert cfg.data_parallel_size == 1 + + def test_world_size_computed_correctly(self) -> None: + # world_size = max(cp * dp, engram_parallel_size) + cfg = _parallel(dp=4, cp=2, ep=4, engram=0) + # engram defaults to model_parallel_size * dp = 2 * 4 = 8 + assert cfg.world_size == 8 + + def test_data_parallel_rank(self) -> None: + cfg = _parallel(dp=4, cp=1, ep=4, engram=0, engine_rank=3) + assert cfg.data_parallel_rank == 3 % 4 + + def test_model_parallel_size_equals_context_parallel(self) -> None: + cfg = _parallel(dp=2, cp=3, ep=6, engram=0) + assert cfg.model_parallel_size == 3 + + def test_engram_defaults_to_model_times_dp(self) -> None: + cfg = _parallel(dp=2, cp=2, ep=4, engram=0) + # engram_parallel_size = model_parallel_size * dp = 2 * 2 = 4 + assert cfg.engram_parallel_size == 4 + + def test_invalid_ep_not_divisible_raises(self) -> None: + """(cp * dp) must be divisible by ep.""" + with pytest.raises(AssertionError): + _parallel(dp=2, cp=2, ep=3, engram=0) # 2*2=4 not divisible by 3 + + def test_engine_rank_out_of_range_raises(self) -> None: + """engine_rank must be in [0, world_size // model_parallel_size).""" + with pytest.raises(AssertionError): + _parallel(dp=2, cp=1, ep=2, engram=0, engine_rank=999) + + +# --------------------------------------------------------------------------- +# SchedulerConfig +# --------------------------------------------------------------------------- + +class TestSchedulerConfig: + def test_valid_basic(self) -> None: + cfg = SchedulerConfig( + max_num_batched_tokens=None, + max_num_seqs=64, + max_model_len=4096, + ) + assert cfg.max_num_seqs == 64 + assert cfg.max_model_len == 4096 + + def test_max_num_batched_tokens_defaults_to_max_model_len_or_4096(self) -> None: + cfg = SchedulerConfig( + max_num_batched_tokens=None, + max_num_seqs=4, + max_model_len=2048, + ) + # _get_max_num_batched_tokens returns max(max_model_len, 4096, decode_tokens) + assert cfg.max_num_batched_tokens >= 4096 + + def test_explicit_max_num_batched_tokens(self) -> None: + cfg = SchedulerConfig( + max_num_batched_tokens=8192, + max_num_seqs=4, + max_model_len=4096, + ) + assert cfg.max_num_batched_tokens == 8192 + + def test_too_small_batched_tokens_raises(self) -> None: + """max_num_batched_tokens < max_model_len must be rejected when + chunked_prefill is disabled, because it would silently cap the + effective context window.""" + with pytest.raises(ValueError, match="max_num_batched_tokens"): + SchedulerConfig( + max_num_batched_tokens=512, + max_num_seqs=4, + max_model_len=4096, + chunked_prefill=False, + ) + + def test_too_small_batched_tokens_ok_with_chunked_prefill(self) -> None: + """With chunked_prefill=True the constraint is lifted.""" + SchedulerConfig( + max_num_batched_tokens=512, + max_num_seqs=4, + max_model_len=4096, + chunked_prefill=True, + ) + + def test_prefill_attempt_interval_zero_raises(self) -> None: + with pytest.raises(ValueError, match="prefill_attempt_interval"): + SchedulerConfig( + max_num_batched_tokens=None, + max_num_seqs=4, + max_model_len=4096, + prefill_attempt_interval=0, + ) + + def test_prefill_attempt_interval_negative_raises(self) -> None: + with pytest.raises(ValueError, match="prefill_attempt_interval"): + SchedulerConfig( + max_num_batched_tokens=None, + max_num_seqs=4, + max_model_len=4096, + prefill_attempt_interval=-1, + ) + + def test_prefill_any_attempt_interval_zero_raises(self) -> None: + with pytest.raises(ValueError, match="prefill_any_attempt_interval"): + SchedulerConfig( + max_num_batched_tokens=None, + max_num_seqs=4, + max_model_len=4096, + prefill_any_attempt_interval=0, + ) + + def test_runtime_max_model_len_greater_than_max_model_len(self) -> None: + cfg = SchedulerConfig( + max_num_batched_tokens=None, + max_num_seqs=4, + max_model_len=4096, + ) + assert cfg.runtime_max_model_len >= cfg.max_model_len + + def test_stop_preempted_request_stored(self) -> None: + cfg = SchedulerConfig( + max_num_batched_tokens=None, + max_num_seqs=4, + max_model_len=4096, + stop_preempted_request=True, + ) + assert cfg.stop_preempted_request is True diff --git a/tests/test_sampling_params.py b/tests/test_sampling_params.py new file mode 100644 index 0000000..163dedc --- /dev/null +++ b/tests/test_sampling_params.py @@ -0,0 +1,323 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for SamplingParams validation and construction. + +These tests exercise every validation rule in SamplingParams._verify_args(), +the greedy-mode clamping applied after validation, the _make_seed helper, +and the deprecation warning for the `logprobs` parameter. + +All tests are CPU-only and require no model weights or CUDA device. +""" + +from __future__ import annotations + +import random +import warnings + +import pytest +import torch + +from deepinfer.sampling_params import SAMPLING_EPS, SamplingParams + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def make(**kwargs) -> SamplingParams: + """Construct SamplingParams with defaults overridden by kwargs.""" + return SamplingParams(**kwargs) + + +# --------------------------------------------------------------------------- +# Default construction +# --------------------------------------------------------------------------- + +class TestDefaults: + def test_default_construction_succeeds(self) -> None: + sp = SamplingParams() + assert sp.temperature == pytest.approx(1.0) + assert sp.top_p == pytest.approx(1.0) + assert sp.top_k == -1 + assert sp.ignore_eos is False + assert sp.max_tokens == 16 + assert sp.prompt_logprobs is False + assert sp.top_logprobs == 0 + assert sp.so_args is None + assert sp.golden_token_ids is None + + def test_seq_seed_auto_generated(self) -> None: + sp1 = SamplingParams() + sp2 = SamplingParams() + # With overwhelming probability two auto-seeds differ. + # If they collide the test is still valid (just flaky by chance). + assert 0 <= sp1.seq_seed < 2**64 + assert 0 <= sp2.seq_seed < 2**64 + + def test_repr_contains_key_fields(self) -> None: + sp = SamplingParams(temperature=0.8, max_tokens=64) + r = repr(sp) + assert "temperature=0.8" in r + assert "max_tokens=64" in r + assert "top_p=" in r + assert "seq_seed=" in r + + +# --------------------------------------------------------------------------- +# temperature +# --------------------------------------------------------------------------- + +class TestTemperature: + def test_valid_positive(self) -> None: + make(temperature=0.7) + + def test_valid_exactly_one(self) -> None: + make(temperature=1.0) + + def test_valid_high(self) -> None: + make(temperature=2.5) + + def test_zero_triggers_greedy_clamp(self) -> None: + """temperature=0 should not raise; instead it is clamped to SAMPLING_EPS + and top_p/top_k are reset to greedy defaults.""" + sp = make(temperature=0.0, top_p=0.9, top_k=50) + assert sp.temperature == pytest.approx(SAMPLING_EPS) + assert sp.top_p == pytest.approx(1.0) + assert sp.top_k == -1 + + def test_near_zero_below_eps_triggers_greedy_clamp(self) -> None: + sp = make(temperature=SAMPLING_EPS / 2) + assert sp.temperature == pytest.approx(SAMPLING_EPS) + + def test_exactly_eps_is_not_clamped(self) -> None: + sp = make(temperature=SAMPLING_EPS) + # SAMPLING_EPS is NOT strictly less than SAMPLING_EPS, so no clamp. + assert sp.temperature == pytest.approx(SAMPLING_EPS) + + def test_negative_raises(self) -> None: + with pytest.raises(ValueError, match="temperature must be non-negative"): + make(temperature=-0.1) + + +# --------------------------------------------------------------------------- +# top_p +# --------------------------------------------------------------------------- + +class TestTopP: + def test_valid_one(self) -> None: + make(top_p=1.0) + + def test_valid_fraction(self) -> None: + make(top_p=0.9) + + def test_valid_near_zero(self) -> None: + make(top_p=1e-9) + + def test_zero_raises(self) -> None: + with pytest.raises(ValueError, match="top_p must be in"): + make(top_p=0.0) + + def test_above_one_raises(self) -> None: + with pytest.raises(ValueError, match="top_p must be in"): + make(top_p=1.01) + + def test_negative_raises(self) -> None: + with pytest.raises(ValueError, match="top_p must be in"): + make(top_p=-0.5) + + +# --------------------------------------------------------------------------- +# top_k +# --------------------------------------------------------------------------- + +class TestTopK: + def test_disabled(self) -> None: + make(top_k=-1) + + def test_valid_one(self) -> None: + make(top_k=1) + + def test_valid_large(self) -> None: + make(top_k=torch.iinfo(torch.int32).max) + + def test_zero_raises(self) -> None: + with pytest.raises(ValueError, match="top_k must be -1"): + make(top_k=0) + + def test_negative_other_than_minus_one_raises(self) -> None: + with pytest.raises(ValueError, match="top_k must be -1"): + make(top_k=-2) + + def test_above_int32_max_raises(self) -> None: + with pytest.raises(ValueError, match="top_k must be -1"): + make(top_k=torch.iinfo(torch.int32).max + 1) + + +# --------------------------------------------------------------------------- +# max_tokens +# --------------------------------------------------------------------------- + +class TestMaxTokens: + def test_valid_minimum(self) -> None: + sp = make(max_tokens=1) + assert sp.max_tokens == 1 + + def test_valid_default(self) -> None: + sp = make() + assert sp.max_tokens == 16 + + def test_valid_large(self) -> None: + make(max_tokens=131072) + + def test_zero_raises(self) -> None: + """max_tokens=0 must raise ValueError rather than being silently coerced to 1.""" + with pytest.raises(ValueError, match="max_tokens must be >= 1"): + make(max_tokens=0) + + def test_negative_raises(self) -> None: + with pytest.raises(ValueError, match="max_tokens must be >= 1"): + make(max_tokens=-1) + + def test_above_int32_max_raises(self) -> None: + with pytest.raises(ValueError, match="max_tokens must be >= 1"): + make(max_tokens=torch.iinfo(torch.int32).max + 1) + + +# --------------------------------------------------------------------------- +# top_logprobs +# --------------------------------------------------------------------------- + +class TestTopLogprobs: + def test_valid_zero(self) -> None: + make(top_logprobs=0) + + def test_valid_boundary(self) -> None: + make(top_logprobs=1000) + + def test_negative_raises(self) -> None: + with pytest.raises(ValueError, match="top_logprobs must be in"): + make(top_logprobs=-1) + + def test_above_1000_raises(self) -> None: + with pytest.raises(ValueError, match="top_logprobs must be in"): + make(top_logprobs=1001) + + +# --------------------------------------------------------------------------- +# seq_seed +# --------------------------------------------------------------------------- + +class TestSeqSeed: + def test_none_generates_random_seed(self) -> None: + sp = make(seq_seed=None) + assert 0 <= sp.seq_seed < 2**64 + + def test_explicit_seed_stored(self) -> None: + sp = make(seq_seed=42) + assert sp.seq_seed == 42 + + def test_seed_clamped_to_uint64(self) -> None: + # A value larger than uint64 is masked to the lower 64 bits. + big = (2**64) + 5 + sp = make(seq_seed=big) + assert sp.seq_seed == 5 + + def test_zero_seed(self) -> None: + sp = make(seq_seed=0) + assert sp.seq_seed == 0 + + def test_max_uint64_seed(self) -> None: + max_seed = 2**64 - 1 + sp = make(seq_seed=max_seed) + assert sp.seq_seed == max_seed + + def test_non_int_seed_raises(self) -> None: + with pytest.raises(TypeError): + make(seq_seed="not-an-int") # type: ignore[arg-type] + + def test_make_seed_deterministic_for_same_value(self) -> None: + assert SamplingParams._make_seed(123) == SamplingParams._make_seed(123) + + def test_make_seed_none_is_random(self) -> None: + random.seed(0) + s1 = SamplingParams._make_seed(None) + random.seed(0) + s2 = SamplingParams._make_seed(None) + # Same random state → same result. + assert s1 == s2 + + +# --------------------------------------------------------------------------- +# logprobs deprecation +# --------------------------------------------------------------------------- + +class TestLogprobsDeprecation: + def test_logprobs_false_no_warning(self) -> None: + """The default logprobs=False must not emit any warning.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + make(logprobs=False) + + def test_logprobs_true_emits_deprecation_warning(self) -> None: + with pytest.warns(DeprecationWarning, match="logprobs.*deprecated"): + make(logprobs=True) + + def test_logprobs_true_does_not_affect_top_logprobs(self) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + sp = make(logprobs=True, top_logprobs=5) + assert sp.top_logprobs == 5 + + +# --------------------------------------------------------------------------- +# Optional / passthrough fields +# --------------------------------------------------------------------------- + +class TestOptionalFields: + def test_golden_token_ids_stored(self) -> None: + ids = [1, 2, 3] + sp = make(golden_token_ids=ids) + assert sp.golden_token_ids == ids + + def test_golden_token_ids_none_by_default(self) -> None: + assert make().golden_token_ids is None + + def test_so_args_stored(self) -> None: + so = {"schema_type": "json", "schema": "{}"} + sp = make(so_args=so) + assert sp.so_args == so + + def test_ignore_eos_stored(self) -> None: + sp = make(ignore_eos=True) + assert sp.ignore_eos is True + + def test_first_n_token_no_eos_stored(self) -> None: + sp = make(first_n_token_no_eos=3, eos_id=2) + assert sp.first_n_token_no_eos == 3 + assert sp.eos_id == 2 + + def test_prompt_logprobs_stored(self) -> None: + sp = make(prompt_logprobs=True) + assert sp.prompt_logprobs is True + + +# --------------------------------------------------------------------------- +# Greedy mode interaction +# --------------------------------------------------------------------------- + +class TestGreedyMode: + def test_greedy_overrides_top_k(self) -> None: + sp = make(temperature=0.0, top_k=100) + assert sp.top_k == -1 + + def test_non_greedy_preserves_top_k(self) -> None: + sp = make(temperature=0.5, top_k=50) + assert sp.top_k == 50 + + def test_greedy_overrides_top_p(self) -> None: + sp = make(temperature=0.0, top_p=0.95) + assert sp.top_p == pytest.approx(1.0) + + def test_non_greedy_preserves_top_p(self) -> None: + sp = make(temperature=0.5, top_p=0.95) + assert sp.top_p == pytest.approx(0.95)