config: pin that resolution is reproducible from the raw input (#34094)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
63833f8034
commit
a2199c1dee
@@ -0,0 +1,375 @@
|
|||||||
|
"""Resolution is a pure function of the raw input plus this node's environment.
|
||||||
|
|
||||||
|
The end state for the configuration tier keeps ``ServerArgs`` at the user's raw
|
||||||
|
input and lets every process that publishes derive the resolved values itself
|
||||||
|
(bags do not cross a process boundary — a child projects its own from the record
|
||||||
|
it is handed). That is only sound if resolving the same raw input twice gives
|
||||||
|
the same answer, so this pins it:
|
||||||
|
|
||||||
|
- twice in this process, from equal raw inputs, every field agrees;
|
||||||
|
- the resolution is not order-dependent on a shared registry (a second config
|
||||||
|
resolved after the first does not inherit its declarations);
|
||||||
|
- the raw record the two started from is itself unchanged by resolving a
|
||||||
|
sibling.
|
||||||
|
|
||||||
|
A failure here means some resolution step reads state it also writes, and the
|
||||||
|
"re-derive in the child" contract would silently diverge between the launcher
|
||||||
|
and its schedulers.
|
||||||
|
|
||||||
|
Scope: this pins reproducibility *within one process*, which is the fork case
|
||||||
|
(the child inherits the parent's environment and its module-level caches). A
|
||||||
|
spawn child starts with cold module state instead -- ``functools`` memos in
|
||||||
|
``runtime_context`` among them -- so a divergence that needs a cold cache to
|
||||||
|
show up is outside what these cases can see.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from sglang.srt.environ import EnvField, envs
|
||||||
|
from sglang.srt.server_args import ServerArgs
|
||||||
|
from sglang.srt.utils import is_cuda
|
||||||
|
from sglang.test.ci.ci_register import (
|
||||||
|
register_amd_ci,
|
||||||
|
register_cpu_ci,
|
||||||
|
register_cuda_ci,
|
||||||
|
)
|
||||||
|
from sglang.test.test_utils import CustomTestCase
|
||||||
|
|
||||||
|
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
|
||||||
|
# Also on a GPU runner: the resolution branches that matter most (backend
|
||||||
|
# defaults, DeepSeek handlers, capability gates) go through `is_cuda()` /
|
||||||
|
# `is_hip()` / device capability, which inspect the actual hardware -- passing
|
||||||
|
# device="cuda" on a CPU box does not reach them, so a leak confined to a GPU
|
||||||
|
# handler would never fail the CPU registration alone.
|
||||||
|
register_cuda_ci(est_time=10, stage="base-b", runner_config="1-gpu-small")
|
||||||
|
# ROCm too: `is_hip()` gates its own set of backend and DeepSeek handlers, which
|
||||||
|
# neither the CPU suite nor a CUDA runner reaches.
|
||||||
|
register_amd_ci(est_time=10, suite="stage-b-test-1-gpu-small-amd")
|
||||||
|
|
||||||
|
_MINI_CONFIG = {
|
||||||
|
"architectures": ["LlamaForCausalLM"],
|
||||||
|
"model_type": "llama",
|
||||||
|
"hidden_size": 16,
|
||||||
|
"intermediate_size": 32,
|
||||||
|
"num_attention_heads": 2,
|
||||||
|
"num_key_value_heads": 2,
|
||||||
|
"num_hidden_layers": 2,
|
||||||
|
"vocab_size": 128,
|
||||||
|
"max_position_embeddings": 2048,
|
||||||
|
}
|
||||||
|
|
||||||
|
_DEEPSEEK_MINI_CONFIG = {
|
||||||
|
"architectures": ["DeepseekV3ForCausalLM"],
|
||||||
|
"model_type": "deepseek_v3",
|
||||||
|
"hidden_size": 16,
|
||||||
|
"intermediate_size": 32,
|
||||||
|
"moe_intermediate_size": 32,
|
||||||
|
"num_attention_heads": 2,
|
||||||
|
"num_key_value_heads": 2,
|
||||||
|
"num_hidden_layers": 2,
|
||||||
|
"n_routed_experts": 8,
|
||||||
|
"n_shared_experts": 1,
|
||||||
|
"num_experts_per_tok": 2,
|
||||||
|
"first_k_dense_replace": 1,
|
||||||
|
"vocab_size": 128,
|
||||||
|
"max_position_embeddings": 2048,
|
||||||
|
"kv_lora_rank": 8,
|
||||||
|
"q_lora_rank": 8,
|
||||||
|
"qk_nope_head_dim": 8,
|
||||||
|
"qk_rope_head_dim": 8,
|
||||||
|
"v_head_dim": 8,
|
||||||
|
"topk_method": "greedy",
|
||||||
|
"scoring_func": "softmax",
|
||||||
|
# index_topk puts this config on the DSA path, whose handlers are the ones
|
||||||
|
# that fan out the most (and write process state on the way through).
|
||||||
|
"index_topk": 4,
|
||||||
|
"index_head_dim": 8,
|
||||||
|
"index_n_heads": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
_MULTIMODAL_MINI_CONFIG = {
|
||||||
|
"architectures": ["Qwen2VLForConditionalGeneration"],
|
||||||
|
"model_type": "qwen2_vl",
|
||||||
|
"hidden_size": 16,
|
||||||
|
"intermediate_size": 32,
|
||||||
|
"num_attention_heads": 2,
|
||||||
|
"num_key_value_heads": 2,
|
||||||
|
"num_hidden_layers": 2,
|
||||||
|
"vocab_size": 128,
|
||||||
|
"max_position_embeddings": 2048,
|
||||||
|
"vision_config": {
|
||||||
|
"depth": 2,
|
||||||
|
"hidden_size": 16,
|
||||||
|
"num_heads": 2,
|
||||||
|
"in_chans": 3,
|
||||||
|
"patch_size": 14,
|
||||||
|
"spatial_merge_size": 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# The shapes the step-12 audit calls out as the ones whose resolution branches
|
||||||
|
# touch process state: a plain text model, a speculative launch, and a MoE/MLA
|
||||||
|
# architecture whose handlers fan out the most.
|
||||||
|
_SHAPES = (
|
||||||
|
("plain", _MINI_CONFIG, {}),
|
||||||
|
(
|
||||||
|
"speculative",
|
||||||
|
_MINI_CONFIG,
|
||||||
|
dict(
|
||||||
|
speculative_algorithm="EAGLE",
|
||||||
|
speculative_num_steps=2,
|
||||||
|
speculative_eagle_topk=1,
|
||||||
|
speculative_num_draft_tokens=3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# The multimodal transport handler is the one that writes
|
||||||
|
# SGLANG_USE_CUDA_IPC_TRANSPORT and reads `is_set()` on the way in, so the
|
||||||
|
# shape that exercises it belongs in the dual-resolve matrix.
|
||||||
|
("multimodal", _MULTIMODAL_MINI_CONFIG, {}),
|
||||||
|
# device="cpu" is the one device every host can resolve, and it is what
|
||||||
|
# reaches `_handle_cpu_backends` — the golden device="cuda" default never
|
||||||
|
# does, so without this shape a leak confined to the CPU handlers would
|
||||||
|
# pass the whole matrix.
|
||||||
|
("plain_cpu_device", _MINI_CONFIG, dict(device="cpu")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolving the DSA shape needs a physical device: the DeepSeek-DSA arm of
|
||||||
|
# `_handle_model_specific_adjustments` probes `torch.cuda.get_device_capability()`
|
||||||
|
# to pick the KV dtype and split backends, and that raises on a driverless
|
||||||
|
# host. The GPU registrations are what exercise this shape; a GPU-less runner
|
||||||
|
# resolves the other shapes only. (Whether a CPU runner even *reaches* the DSA
|
||||||
|
# arm depends on the installed transformers surfacing `index_topk` from the
|
||||||
|
# mini config, so without this gate the crash appears runner-dependently.)
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
_SHAPES = _SHAPES + (("deepseek_dsa", _DEEPSEEK_MINI_CONFIG, {}),)
|
||||||
|
|
||||||
|
# The one field a previous resolution genuinely dictates for the next one in
|
||||||
|
# this process: `_handle_multimodal_feature_transport` writes
|
||||||
|
# SGLANG_USE_CUDA_IPC_TRANSPORT so tokenizer workers inherit the decision, and
|
||||||
|
# the next resolution reads `is_set()` and adopts it -- even for a text-only
|
||||||
|
# model, and even across Engines. That is main's behaviour (reproduced on the
|
||||||
|
# stack's base commit), it is inert for a text model, and pinning it here is
|
||||||
|
# deliberate: the assertion below states the exception explicitly so a *new*
|
||||||
|
# sticky field fails this case instead of hiding behind it.
|
||||||
|
_STICKY_ACROSS_RESOLUTIONS = frozenset({"mm_feature_transport"})
|
||||||
|
|
||||||
|
# `random_seed` is pinned by `_resolved` so it would compare equal anyway; it
|
||||||
|
# stays listed because a case that stops pinning it must not silently start
|
||||||
|
# comparing a value resolution randomizes.
|
||||||
|
_NOT_COMPARABLE = frozenset({"random_seed"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolutionIsReproducible(CustomTestCase):
|
||||||
|
def _config_dir(self, config: dict = None) -> str:
|
||||||
|
config_dir = tempfile.mkdtemp(prefix="resolution_repro_")
|
||||||
|
self.addCleanup(shutil.rmtree, config_dir, ignore_errors=True)
|
||||||
|
with open(os.path.join(config_dir, "config.json"), "w") as handle:
|
||||||
|
json.dump(config or _MINI_CONFIG, handle)
|
||||||
|
return config_dir
|
||||||
|
|
||||||
|
def _process_state(self):
|
||||||
|
"""What a resolution may leave behind: the environment and the
|
||||||
|
descriptor-level flag `EnvField.set()` flips, which `os.environ` does
|
||||||
|
not carry."""
|
||||||
|
# Walk the MRO: `vars(type(envs))` alone would miss fields declared on
|
||||||
|
# a base class.
|
||||||
|
fields = {}
|
||||||
|
for klass in reversed(type(envs).__mro__):
|
||||||
|
for name, field in vars(klass).items():
|
||||||
|
if isinstance(field, EnvField):
|
||||||
|
fields[name] = field
|
||||||
|
return (
|
||||||
|
dict(os.environ),
|
||||||
|
{name: field._set_to_none for name, field in fields.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _restore_process_state(self, state):
|
||||||
|
saved_environ, saved_none_flags = state
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(saved_environ)
|
||||||
|
for name, was_none in saved_none_flags.items():
|
||||||
|
getattr(type(envs), name)._set_to_none = was_none
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Resolution writes process state on the way through --
|
||||||
|
# `_handle_multimodal_feature_transport` sets SGLANG_USE_CUDA_IPC_TRANSPORT
|
||||||
|
# so tokenizer workers inherit the decision, and the same handler reads
|
||||||
|
# `is_set()` on the way in. One resolution is therefore visible to the
|
||||||
|
# next one in this process. These cases restore what they touched, and
|
||||||
|
# it is a standing caveat on the determinism pinned here: the guarantee
|
||||||
|
# holds per raw input *and* the process state a previous resolution left.
|
||||||
|
self._pristine_state = self._process_state()
|
||||||
|
self.addCleanup(self._restore_process_state, self._pristine_state)
|
||||||
|
|
||||||
|
def _callTestMethod(self, method):
|
||||||
|
# No retry here. CustomTestCase retries once in CI, but `addCleanup`
|
||||||
|
# runs after the last attempt, so a second attempt would start from the
|
||||||
|
# state the first one leaked -- exactly the regression these cases exist
|
||||||
|
# to catch, turned into a pass.
|
||||||
|
unittest.TestCase._callTestMethod(self, method)
|
||||||
|
|
||||||
|
def _resolved(self, model_path: str, **kwargs) -> ServerArgs:
|
||||||
|
# device="cuda" keeps the golden path host-independent: an
|
||||||
|
# accelerator-less runner resolves only the base platform, where
|
||||||
|
# get_device() raises.
|
||||||
|
kwargs.setdefault("device", "cuda")
|
||||||
|
kwargs.setdefault("random_seed", 42)
|
||||||
|
return ServerArgs(model_path=model_path, **kwargs)
|
||||||
|
|
||||||
|
def _comparable(self, server_args: ServerArgs) -> dict:
|
||||||
|
"""The dataclass fields, and only those.
|
||||||
|
|
||||||
|
Non-field artifacts a resolution leaves on the instance (the
|
||||||
|
`_resolved_overrides` provenance, a cached `model_config`) are not in
|
||||||
|
here; `test_the_declaration_provenance_is_reproducible` is what covers
|
||||||
|
the one of those that a shared mutable could corrupt.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for field in dataclasses.fields(server_args):
|
||||||
|
if field.name in _NOT_COMPARABLE:
|
||||||
|
continue
|
||||||
|
value = getattr(server_args, field.name)
|
||||||
|
# Nested dataclasses (cuda_graph_config) compare structurally, and
|
||||||
|
# everything else is deep-copied: a snapshot that stored the live
|
||||||
|
# list/dict would follow an in-place mutation, which is exactly the
|
||||||
|
# regression `test_resolving_a_sibling_leaves_the_first_alone` looks
|
||||||
|
# for.
|
||||||
|
out[field.name] = (
|
||||||
|
dataclasses.asdict(value)
|
||||||
|
if dataclasses.is_dataclass(value)
|
||||||
|
else copy.deepcopy(value)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def test_two_resolutions_of_the_same_input_agree(self):
|
||||||
|
for label, config, kwargs in _SHAPES:
|
||||||
|
with self.subTest(shape=label):
|
||||||
|
# Each shape starts from the state the test method started in,
|
||||||
|
# not from what the previous shape's resolution left behind.
|
||||||
|
self._restore_process_state(self._pristine_state)
|
||||||
|
model_path = self._config_dir(config)
|
||||||
|
first = self._resolved(model_path, **kwargs)
|
||||||
|
second = self._resolved(model_path, **kwargs)
|
||||||
|
self.assertEqual(self._comparable(first), self._comparable(second))
|
||||||
|
|
||||||
|
def test_a_resolution_does_not_leak_into_the_next(self):
|
||||||
|
# A config resolved with an explicit, non-default backend must not shift
|
||||||
|
# what the next one picks. Residual process state (env, caches) is the
|
||||||
|
# hazard, not the declaration registry, whose providers are import-time.
|
||||||
|
model_path = self._config_dir()
|
||||||
|
# The control has to be taken *before* the explicit resolution: if that
|
||||||
|
# one contaminated the process, a control read afterwards would inherit
|
||||||
|
# the same contamination and the assertion would pass vacuously.
|
||||||
|
default_before = self._resolved(model_path)
|
||||||
|
# torch_native is not the default on either CPU or CUDA, so the probe
|
||||||
|
# really diverges on the suite that runs this.
|
||||||
|
explicit = self._resolved(model_path, attention_backend="torch_native")
|
||||||
|
self.assertEqual(explicit.attention_backend, "torch_native")
|
||||||
|
self.assertNotEqual(
|
||||||
|
self._comparable(explicit),
|
||||||
|
self._comparable(default_before),
|
||||||
|
"the probe resolved to the same config as the default, so this case "
|
||||||
|
"would pass without exercising order dependence",
|
||||||
|
)
|
||||||
|
default_after = self._resolved(model_path)
|
||||||
|
# Every field, not just the backend: a declaration registry takes
|
||||||
|
# arbitrary field dicts, so a leak can land anywhere.
|
||||||
|
self.assertEqual(
|
||||||
|
self._comparable(default_after), self._comparable(default_before)
|
||||||
|
)
|
||||||
|
|
||||||
|
# A backend probe only diverges on the kernel fields. The shapes whose
|
||||||
|
# handlers write process state on the way through -- the multimodal
|
||||||
|
# transport one sets SGLANG_USE_CUDA_IPC_TRANSPORT and reads `is_set()`
|
||||||
|
# on the way in, DSA fans out the furthest -- are the ones that can
|
||||||
|
# leave something the *next* default reads, so each gets its own turn as
|
||||||
|
# the intermediate. Whether those writes actually fire is
|
||||||
|
# device-dependent, which is why this case is registered on the GPU
|
||||||
|
# runners as well as CPU.
|
||||||
|
intermediates = (
|
||||||
|
("multimodal", _MULTIMODAL_MINI_CONFIG, {}),
|
||||||
|
("torch_compile", _MINI_CONFIG, dict(enable_torch_compile=True)),
|
||||||
|
)
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
# Same device gate as _SHAPES: the DSA arm probes the device
|
||||||
|
# capability during resolution.
|
||||||
|
intermediates += (("deepseek_dsa", _DEEPSEEK_MINI_CONFIG, {}),)
|
||||||
|
for label, config, kwargs in intermediates:
|
||||||
|
with self.subTest(intermediate=label):
|
||||||
|
# Each intermediate starts from the pristine process, for two
|
||||||
|
# reasons: it must not inherit what the previous iteration left,
|
||||||
|
# and the handlers under test branch on *unset* state -- the
|
||||||
|
# multimodal one auto-selects the transport only when
|
||||||
|
# SGLANG_USE_CUDA_IPC_TRANSPORT is not set, and any earlier
|
||||||
|
# resolution in this process has already set it. `default_before`
|
||||||
|
# is the control precisely because it was taken on this state.
|
||||||
|
self._restore_process_state(self._pristine_state)
|
||||||
|
# The auto-selection branch under test requires the legacy
|
||||||
|
# variable UNSET; a runner that exports it (a supported
|
||||||
|
# deployment setting) would otherwise pin every resolution to
|
||||||
|
# its value and this subtest would assert the environment
|
||||||
|
# rather than the handler. `_STICKY_ACROSS_RESOLUTIONS`
|
||||||
|
# already excludes the affected field from the equality
|
||||||
|
# against `default_before`, so clearing it here does not skew
|
||||||
|
# that comparison.
|
||||||
|
envs.SGLANG_USE_CUDA_IPC_TRANSPORT.clear()
|
||||||
|
self._resolved(self._config_dir(config), **kwargs)
|
||||||
|
after = self._resolved(model_path)
|
||||||
|
without_sticky = lambda snapshot: {
|
||||||
|
k: v
|
||||||
|
for k, v in snapshot.items()
|
||||||
|
if k not in _STICKY_ACROSS_RESOLUTIONS
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
without_sticky(self._comparable(after)),
|
||||||
|
without_sticky(self._comparable(default_before)),
|
||||||
|
)
|
||||||
|
if label == "multimodal":
|
||||||
|
# And the documented exception, asserted rather than
|
||||||
|
# assumed: the multimodal handler's env write does reach
|
||||||
|
# the next resolution. What it carries is the
|
||||||
|
# intermediate's own device-dependent selection — cuda_ipc
|
||||||
|
# on single-node CUDA, cpu on the CPU/ROCm runners (the
|
||||||
|
# same `is_cuda()` gate the handler branches on).
|
||||||
|
expected = "cuda_ipc" if is_cuda() else "cpu"
|
||||||
|
self.assertEqual(after.mm_feature_transport, expected)
|
||||||
|
|
||||||
|
def test_resolving_a_sibling_leaves_the_first_alone(self):
|
||||||
|
for label, config, kwargs in _SHAPES:
|
||||||
|
with self.subTest(shape=label):
|
||||||
|
self._restore_process_state(self._pristine_state)
|
||||||
|
model_path = self._config_dir(config)
|
||||||
|
first = self._resolved(model_path, **kwargs)
|
||||||
|
snapshot = self._comparable(first)
|
||||||
|
self._resolved(
|
||||||
|
model_path, tp_size=2, chunked_prefill_size=1024, **kwargs
|
||||||
|
)
|
||||||
|
self.assertEqual(self._comparable(first), snapshot)
|
||||||
|
|
||||||
|
def test_the_declaration_provenance_is_reproducible(self):
|
||||||
|
model_path = self._config_dir()
|
||||||
|
first = self._resolved(model_path)
|
||||||
|
# Snapshot before the second resolution: if a regression had the
|
||||||
|
# registry hand out a shared mutable list, resolving `second` would
|
||||||
|
# mutate what `first` still points at and the two would compare equal.
|
||||||
|
first_provenance = copy.deepcopy(getattr(first, "_resolved_overrides", None))
|
||||||
|
second = self._resolved(model_path)
|
||||||
|
self.assertEqual(
|
||||||
|
first_provenance,
|
||||||
|
getattr(second, "_resolved_overrides", None),
|
||||||
|
)
|
||||||
|
# And the first record's own list is untouched by the second
|
||||||
|
# resolution -- a shared mutable would show up here.
|
||||||
|
self.assertEqual(getattr(first, "_resolved_overrides", None), first_provenance)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user