Files
jundot--omlx/tests/test_memory_monitor_vlm_config.py
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

506 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for VLM nested-config probing in ``_set_model_info_for_monitor``.
VLM / multimodal models (Qwen3.6-VL, Gemma-4, etc.) nest the language-model
dimensions under ``text_config`` / ``language_config`` / ``llm_config``. The
top-level config may hold *vision tower* dimensions instead; reading the
wrong field underestimates KV+SDPA peak memory for the LM by a constant
factor and lets ``_preflight_memory_check`` approve prefills that go on to
crash Metal.
These tests pin the priority: prefer any sub-config that has the LM layer
count, else fall back to the top-level config.
"""
from unittest.mock import MagicMock
import mlx.core as mx
from omlx.memory_monitor import (
_SDPA_FALLBACK_SCORE_DTYPE_SIZE,
_SDPA_FULL_SUPPORTED_HEAD_DIMS,
_SDPA_VECTOR_QUERY_TOKEN_THRESHOLD,
_SDPA_VECTOR_SUPPORTED_HEAD_DIMS,
MemoryMonitor,
estimate_mla_kv_bytes_per_token,
)
from omlx.scheduler import Scheduler, SchedulerConfig
def _make_scheduler() -> Scheduler:
"""Return a Scheduler with a mocked model/tokenizer.
The scheduler is constructed without a paged-SSD cache so
``_set_model_info_for_monitor`` runs against the simple init path.
"""
model = MagicMock()
model.layers = []
tokenizer = MagicMock()
tokenizer.eos_token_id = 2
config = SchedulerConfig(paged_cache_block_size=0)
return Scheduler(model=model, tokenizer=tokenizer, config=config)
class _LMConfig:
"""Minimal LM config: 40 layers, 8 KV heads, head_dim=128."""
num_hidden_layers = 40
num_key_value_heads = 8
num_attention_heads = 32
head_dim = 128
hidden_size = 4096
class _VLMConfigWithTextConfig:
"""Top-level VLM config: vision-tower dims at the top, LM nested."""
num_hidden_layers = 33 # vision tower — must be ignored
num_attention_heads = 16 # vision tower
text_config = _LMConfig()
class _VLMConfigWithLanguageConfig:
"""Variant using the ``language_config`` attribute name."""
num_hidden_layers = 33
language_config = _LMConfig()
class _VLMConfigWithLlmConfig:
"""Variant using the ``llm_config`` attribute name."""
num_hidden_layers = 33
llm_config = _LMConfig()
class _PlainLMConfig:
"""Top-level LM config with no nested sub-configs — fallback path."""
num_hidden_layers = 40
num_key_value_heads = 8
num_attention_heads = 32
head_dim = 128
class _GlmMlaConfig:
"""GLM-5.2-style MLA config with compressed resident KV cache."""
model_type = "glm_moe_dsa"
num_hidden_layers = 78
num_key_value_heads = 64
num_attention_heads = 64
hidden_size = 6144
kv_lora_rank = 512
qk_rope_head_dim = 64
index_head_dim = 128
class _VLMConfigEmptySubConfigs:
"""Sub-configs are present but expose no layer count — skip and fall
back to the top-level config. Defends against accidentally walking
into a useless sub-config."""
num_hidden_layers = 40 # this is the LM at top-level
num_key_value_heads = 8
num_attention_heads = 32
head_dim = 128
text_config = MagicMock(spec=["something_else"]) # no layer count
class TestSetModelInfoForMonitorVLMWalk:
"""``_set_model_info_for_monitor`` must prefer LM dimensions from a
nested sub-config when one exists, otherwise fall back to top-level."""
def test_picks_text_config_over_top_level_vision_dims(self):
sched = _make_scheduler()
# Scheduler.__init__ now constructs a MemoryMonitor in
# estimator-only mode (eviction_enabled=False) so preflight
# estimation works without prior set_model_info. Replace with
# a MagicMock so we can inspect the set_model_info call.
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = _VLMConfigWithTextConfig()
# ``hasattr`` on a MagicMock auto-creates ``args``; remove it so
# the config branch picks ``config`` and not ``args``.
del sched.model.args
sched._set_model_info_for_monitor()
sched.memory_monitor.set_model_info.assert_called_once()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["num_layers"] == 40, (
"Should have read the 40-layer LM from text_config, not the "
"33-layer vision tower at the top level"
)
def test_picks_language_config_over_top_level(self):
sched = _make_scheduler()
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = _VLMConfigWithLanguageConfig()
del sched.model.args
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["num_layers"] == 40
def test_picks_llm_config_over_top_level(self):
sched = _make_scheduler()
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = _VLMConfigWithLlmConfig()
del sched.model.args
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["num_layers"] == 40
def test_falls_back_to_top_level_when_no_subconfig(self):
"""Plain LM (no sub-configs) must still work — regression guard
against the walking helper accidentally requiring a sub-config."""
sched = _make_scheduler()
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = _PlainLMConfig()
del sched.model.args
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["num_layers"] == 40
def test_falls_back_when_subconfig_lacks_layer_count(self):
"""Sub-config without ``num_hidden_layers`` or ``n_layer`` must
not be selected — the top-level LM dims win."""
sched = _make_scheduler()
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = _VLMConfigEmptySubConfigs()
del sched.model.args
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["num_layers"] == 40
def test_n_layer_alias_also_triggers_subconfig_selection(self):
"""GPT-style configs use ``n_layer`` instead of ``num_hidden_layers``.
The walking helper must recognize both."""
class _GPTStyleLM:
n_layer = 24
n_head = 16
n_embd = 1024
class _VLMWithGPTStyleSub:
num_hidden_layers = 12 # vision tower
text_config = _GPTStyleLM()
sched = _make_scheduler()
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = _VLMWithGPTStyleSub()
del sched.model.args
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert (
kwargs["num_layers"] == 24
), "GPT-style ``n_layer`` in the sub-config should be recognized"
class TestMlaKvMemoryEstimate:
def _glm_cache(self):
from mlx_lm.models.cache import CacheList, KVCache
return [CacheList(KVCache(), KVCache()) for _ in range(21)] + [
CacheList(KVCache()) for _ in range(57)
]
def test_glm_mla_helper_uses_latent_cache_dims(self):
bytes_per_token = estimate_mla_kv_bytes_per_token(
_GlmMlaConfig(),
self._glm_cache(),
dtype_size=2,
)
assert bytes_per_token == (78 * (512 + 64) + 21 * 128) * 2
def test_monitor_uses_mla_kv_override_for_prompt_kv(self):
bytes_per_token = estimate_mla_kv_bytes_per_token(
_GlmMlaConfig(),
self._glm_cache(),
dtype_size=2,
)
monitor = MemoryMonitor(max_kv_cache_memory=None, eviction_enabled=False)
monitor.set_model_info(
num_layers=78,
num_kv_heads=64,
head_dim=96,
dtype_size=2,
num_attention_heads=64,
num_kv_cache_layers=99,
compute_dtype_size=2,
kv_bytes_per_token=bytes_per_token,
)
tokens = 32767
standard = tokens * 99 * 64 * 96 * 2 * 2
actual = monitor.estimate_prompt_kv_bytes(tokens)
assert actual == tokens * bytes_per_token
assert actual < standard / 20
def test_scheduler_passes_mla_kv_override_to_monitor(self):
sched = _make_scheduler()
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = _GlmMlaConfig()
sched.model.make_cache.return_value = self._glm_cache()
del sched.model.args
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["num_layers"] == 78
assert kwargs["num_kv_heads"] == 64
assert kwargs["num_kv_cache_layers"] == 99
assert kwargs["kv_bytes_per_token"] == (78 * (512 + 64) + 21 * 128) * 2
class TestSetModelInfoTurboQuantDtype:
def _make_sched_with_config(self, config) -> Scheduler:
from mlx_lm.models.cache import KVCache
sched = _make_scheduler()
sched.memory_monitor = MagicMock()
sched.model = MagicMock()
sched.model.config = config
sched.model.make_cache.return_value = [KVCache() for _ in range(40)]
del sched.model.args
return sched
def test_no_turboquant_uses_full_dtype(self):
sched = self._make_sched_with_config(_PlainLMConfig())
sched._turboquant_kv_bits = None
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["dtype_size"] == 2
def test_turboquant_4bit_without_skip_last_uses_quantized_dtype(self):
sched = self._make_sched_with_config(_PlainLMConfig())
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = False
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
expected = 4.0 / 8.0 + 2.0 / 128
assert abs(kwargs["dtype_size"] - expected) < 1e-9
def test_turboquant_4bit_default_skip_last_keeps_one_full_dtype_layer(self):
sched = self._make_sched_with_config(_PlainLMConfig())
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = True
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
quantized = 4.0 / 8.0 + 2.0 / 128
expected = (39 * quantized + 2.0) / 40
assert abs(kwargs["dtype_size"] - expected) < 1e-9
def test_turboquant_8bit_without_skip_last_uses_quantized_dtype(self):
sched = self._make_sched_with_config(_PlainLMConfig())
sched._turboquant_kv_bits = 8.0
sched._turboquant_skip_last = False
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
expected = 8.0 / 8.0 + 2.0 / 128
assert abs(kwargs["dtype_size"] - expected) < 1e-9
def test_turboquant_dtype_with_vlm_nested_config(self):
sched = self._make_sched_with_config(_VLMConfigWithTextConfig())
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = False
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["num_layers"] == 40
expected = 4.0 / 8.0 + 2.0 / 128
assert abs(kwargs["dtype_size"] - expected) < 1e-9
def test_turboquant_hybrid_arrays_cache_counts_only_kv_layers(self):
from mlx_lm.models.cache import ArraysCache, KVCache
sched = self._make_sched_with_config(_VLMConfigWithTextConfig())
sched.model.make_cache.return_value = [
KVCache() if (i + 1) % 4 == 0 else ArraysCache(size=2) for i in range(40)
]
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = True
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
quantized = 4.0 / 8.0 + 2.0 / 128
expected = (9 * quantized + 2.0) / 10
assert kwargs["num_kv_cache_layers"] == 10
assert abs(kwargs["dtype_size"] - expected) < 1e-9
def test_turboquant_arrays_cache_only_uses_full_dtype(self):
from mlx_lm.models.cache import ArraysCache
sched = self._make_sched_with_config(_PlainLMConfig())
sched.model.make_cache.return_value = [ArraysCache(size=2) for _ in range(40)]
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = False
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["dtype_size"] == 2
def test_turboquant_ineligible_cache_uses_full_dtype(self):
sched = self._make_sched_with_config(_PlainLMConfig())
sched.model.make_cache.return_value = [object()]
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = False
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["dtype_size"] == 2
def test_turboquant_mla_model_uses_full_dtype(self):
class _MLAConfig(_PlainLMConfig):
kv_lora_rank = 512
sched = self._make_sched_with_config(_MLAConfig())
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = False
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["dtype_size"] == 2
def test_turboquant_attention_sink_model_uses_full_dtype(self):
sched = self._make_sched_with_config(_PlainLMConfig())
sched.model.modules = lambda: [{"sinks": mx.zeros((8,))}]
sched._turboquant_kv_bits = 4.0
sched._turboquant_skip_last = True
sched._set_model_info_for_monitor()
kwargs = sched.memory_monitor.set_model_info.call_args.kwargs
assert kwargs["dtype_size"] == 2
def test_reported_scale_fits_after_turboquant_skip_last_accounting(self):
tokens = 327_872
ceiling = 44.0 * 1024**3
current = 27.17 * 1024**3
headroom = ceiling - current
monitor = MemoryMonitor(max_kv_cache_memory=None, eviction_enabled=False)
monitor.set_model_info(
num_layers=40,
num_kv_heads=8,
head_dim=128,
dtype_size=2,
num_attention_heads=32,
num_kv_cache_layers=40,
)
full_dtype_peak = monitor.estimate_prefill_peak_bytes(
tokens, 2048, cached_tokens=0
)
quantized = 4.0 / 8.0 + 2.0 / 128
skip_last_dtype = (39 * quantized + 2.0) / 40
monitor.set_model_info(
num_layers=40,
num_kv_heads=8,
head_dim=128,
dtype_size=skip_last_dtype,
num_attention_heads=32,
num_kv_cache_layers=40,
)
turboquant_peak = monitor.estimate_prefill_peak_bytes(
tokens, 2048, cached_tokens=0
)
assert full_dtype_peak > headroom
assert turboquant_peak < headroom
class TestSdpaDispatchEstimate:
"""MemoryMonitor mirrors MLX SDPA full/vector dispatch support."""
def test_sdpa_dispatch_constants_match_mlx_031(self):
assert _SDPA_VECTOR_QUERY_TOKEN_THRESHOLD == 8
assert frozenset({64, 80, 128}) == _SDPA_FULL_SUPPORTED_HEAD_DIMS
assert frozenset({64, 96, 128, 256}) == _SDPA_VECTOR_SUPPORTED_HEAD_DIMS
def test_estimate_prefill_uses_full_fallback_for_head_dim_256(self):
"""head_dim=256 is not supported by MLX fused full prefill."""
monitor = MemoryMonitor(max_kv_cache_memory=None, eviction_enabled=False)
monitor.set_model_info(
num_layers=28,
num_kv_heads=4,
num_attention_heads=28,
head_dim=256,
dtype_size=2,
)
n_q = 28
hd = 256
chunk = 512
new_tokens = 327872
full_kv_len = new_tokens
eff_chunk = min(chunk, new_tokens)
output_only = n_q * eff_chunk * hd * 4
expected_attn = n_q * eff_chunk * full_kv_len * _SDPA_FALLBACK_SCORE_DTYPE_SIZE
expected_attn += output_only
kv = monitor.estimate_prompt_kv_bytes(new_tokens)
expected_peak = expected_attn + kv
actual = monitor.estimate_prefill_peak_bytes(new_tokens, chunk, cached_tokens=0)
assert actual == expected_peak, (
f"head_dim=256 should use full-score fallback formula "
f"({expected_peak:,} bytes), "
f"got {actual:,} bytes"
)
assert output_only < expected_attn
def test_estimate_chunk_transient_uses_full_fallback_for_head_dim_256(self):
"""head_dim=256 full prefill transient must include fp32 scores."""
monitor = MemoryMonitor(max_kv_cache_memory=None, eviction_enabled=False)
monitor.set_model_info(
num_layers=28,
num_kv_heads=4,
num_attention_heads=28,
head_dim=256,
dtype_size=2,
)
n_q = 28
hd = 256
n_tokens = 512
kv_len = 327872
output_only = n_q * n_tokens * hd * 4
expected = n_q * n_tokens * kv_len * _SDPA_FALLBACK_SCORE_DTYPE_SIZE
expected += output_only
actual = monitor.estimate_chunk_transient_bytes(n_tokens, kv_len)
assert actual == expected, (
f"head_dim=256 chunk transient should be {expected:,} bytes, "
f"got {actual:,} bytes"
)
assert actual > output_only