Merge 2afdb3d2d8ed1fb3a83a0169485aa25d281196e8 into 553741557a57df44a46fb3ab72b6aafeaf945337

This commit is contained in:
Akash Murali 2026-10-26 15:22:59 +09:00 committed by GitHub
commit c9dba916a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 581 additions and 0 deletions

258
tests/test_config.py Normal file
View File

@ -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

View File

@ -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)