moe: the shared-experts-fusion decision is a per-runner value the loader installs (#33889)
This commit is contained in:
@@ -1,22 +1,24 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.layers.moe.utils import (
|
||||
install_shared_experts_fusion_decision,
|
||||
is_shared_experts_fusion_disabled,
|
||||
)
|
||||
from sglang.srt.models.deepseek_v4 import DeepseekV4ForCausalLM
|
||||
from sglang.srt.runtime_context import get_context, get_exec
|
||||
from sglang.srt.runtime_context import get_context, get_exec, get_flags
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=4, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestDeepseekV4SharedExpertFusionPolicy(unittest.TestCase):
|
||||
"""The disable decision is a load-time resolution: it lands on the
|
||||
published config bag via declare_load_time_override (bag-only; the
|
||||
ServerArgs instance stays pristine)."""
|
||||
"""V4 fuses its shared expert only when explicitly asked to.
|
||||
|
||||
def _make_model(self, n_shared_experts=1):
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(n_shared_experts=n_shared_experts)
|
||||
)
|
||||
The gate is a question the loader asks the model class before any layer
|
||||
exists (``shared_experts_fusion_disable_reason``); the answer is installed
|
||||
on the ACTIVE moe flag, and the config bag keeps the user's intent.
|
||||
"""
|
||||
|
||||
def _publish(self, enforce):
|
||||
override = get_context().override_server_args(
|
||||
@@ -24,25 +26,47 @@ class TestDeepseekV4SharedExpertFusionPolicy(unittest.TestCase):
|
||||
)
|
||||
override.install()
|
||||
self.addCleanup(override.restore)
|
||||
get_flags().moe.disable_shared_experts_fusion = None
|
||||
self.addCleanup(
|
||||
lambda: setattr(get_flags().moe, "disable_shared_experts_fusion", None)
|
||||
)
|
||||
|
||||
def _install(self, n_shared_experts=1):
|
||||
install_shared_experts_fusion_decision(
|
||||
DeepseekV4ForCausalLM,
|
||||
SimpleNamespace(n_shared_experts=n_shared_experts),
|
||||
None,
|
||||
)
|
||||
|
||||
def test_disables_shared_fusion_without_enforce(self):
|
||||
self._publish(enforce=False)
|
||||
model = self._make_model()
|
||||
|
||||
DeepseekV4ForCausalLM.determine_num_fused_shared_experts(model)
|
||||
|
||||
self.assertEqual(model.num_fused_shared_experts, 0)
|
||||
# post-init declaration lands on the published config bag
|
||||
self.assertTrue(get_exec().moe.disable_shared_experts_fusion)
|
||||
self.assertEqual(
|
||||
DeepseekV4ForCausalLM.shared_experts_fusion_disable_reason(
|
||||
SimpleNamespace(n_shared_experts=1), None
|
||||
),
|
||||
"Config does not support fused shared expert(s).",
|
||||
)
|
||||
self._install()
|
||||
# The decision lands on the ACTIVE flag; the config intent is untouched.
|
||||
self.assertTrue(is_shared_experts_fusion_disabled())
|
||||
self.assertFalse(get_exec().moe.disable_shared_experts_fusion)
|
||||
|
||||
def test_enables_shared_fusion_when_enforced(self):
|
||||
self._publish(enforce=True)
|
||||
model = self._make_model()
|
||||
self.assertIsNone(
|
||||
DeepseekV4ForCausalLM.shared_experts_fusion_disable_reason(
|
||||
SimpleNamespace(n_shared_experts=1), None
|
||||
)
|
||||
)
|
||||
self._install()
|
||||
self.assertFalse(is_shared_experts_fusion_disabled())
|
||||
|
||||
DeepseekV4ForCausalLM.determine_num_fused_shared_experts(model)
|
||||
|
||||
self.assertEqual(model.num_fused_shared_experts, 1)
|
||||
self.assertFalse(get_exec().moe.disable_shared_experts_fusion)
|
||||
def test_enforcing_with_more_than_one_shared_expert_is_rejected(self):
|
||||
self._publish(enforce=True)
|
||||
with self.assertRaisesRegex(ValueError, "exactly one shared"):
|
||||
DeepseekV4ForCausalLM.shared_experts_fusion_disable_reason(
|
||||
SimpleNamespace(n_shared_experts=2), None
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Every loader entry class that can reach a fusion-gated family answers for it.
|
||||
|
||||
The loader installs the shared-experts-fusion decision for the class it
|
||||
instantiates (`install_shared_experts_fusion_decision`). A model whose layers
|
||||
read `is_shared_experts_fusion_disabled()` therefore gets whatever answer that
|
||||
*entry* class produced — and an entry class with no
|
||||
`shared_experts_fusion_disable_reason` falls back to the user's intent, silently
|
||||
skipping the family's auto-disable conditions.
|
||||
|
||||
That is easy to miss for a wrapper: `KimiVLForConditionalGeneration` is the
|
||||
registered arch, but a DeepSeek body is built inside it, and the DeepSeek
|
||||
conditions used to be evaluated during that nested construction. This case walks
|
||||
the registry so a new wrapper (or a new MTP/nextn entry) cannot reintroduce the
|
||||
gap.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from sglang.srt.models.registry import ModelRegistry
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=90, suite="base-a-test-cpu")
|
||||
|
||||
GATE = "shared_experts_fusion_disable_reason"
|
||||
FLAG_READERS = (
|
||||
"is_shared_experts_fusion_disabled",
|
||||
"determine_num_fused_shared_experts",
|
||||
)
|
||||
|
||||
# Archs that read the fusion flag but deliberately have no gate: nothing in
|
||||
# their lineage carries auto-disable conditions, so they follow the user's
|
||||
# intent — the behavior they had before the decision moved to the loader.
|
||||
GATELESS_BY_DESIGN = {
|
||||
# The in-tree class has no ``determine_num_fused_shared_experts`` at all
|
||||
# (the call is guarded by ``hasattr`` for a downstream variant).
|
||||
"BailingMoeForCausalLMNextN",
|
||||
# Its target family (Glm4v) is dense; there is no gate to inherit.
|
||||
"GlmOcrForConditionalGenerationNextN",
|
||||
# The vision tower registered on its own: it shares a module with
|
||||
# PixtralForConditionalGeneration (which does answer) but builds no language
|
||||
# model, so there is nothing for a gate to decide.
|
||||
"PixtralVisionModel",
|
||||
}
|
||||
|
||||
|
||||
def _gated_classes(source: str) -> set:
|
||||
"""Classes in this module that define or receive a fusion gate."""
|
||||
names = set()
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ClassDef):
|
||||
for body in node.body:
|
||||
if (
|
||||
isinstance(body, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and body.name == GATE
|
||||
):
|
||||
names.add(node.name)
|
||||
if isinstance(body, ast.Assign) and any(
|
||||
isinstance(t, ast.Name) and t.id == GATE for t in body.targets
|
||||
):
|
||||
names.add(node.name)
|
||||
# ``for cls in (A, B): cls.<GATE> = ...``
|
||||
if (
|
||||
isinstance(node, ast.For)
|
||||
and isinstance(node.iter, (ast.Tuple, ast.List))
|
||||
and GATE in ast.dump(node)
|
||||
):
|
||||
names |= {e.id for e in node.iter.elts if isinstance(e, ast.Name)}
|
||||
if isinstance(node, ast.Assign):
|
||||
for target in node.targets:
|
||||
if (
|
||||
isinstance(target, ast.Attribute)
|
||||
and target.attr == GATE
|
||||
and isinstance(target.value, ast.Name)
|
||||
):
|
||||
names.add(target.value.id)
|
||||
return names
|
||||
|
||||
|
||||
def gated_class_names() -> set:
|
||||
"""Every model class that *resolves* a gate, inherited ones included.
|
||||
|
||||
A subclass like `DeepseekV3ForCausalLM` inherits the gate without naming it,
|
||||
so collecting names from class bodies alone would let a wrapper that builds
|
||||
the subclass slip through.
|
||||
"""
|
||||
names = set()
|
||||
for module_name, module in list(sys.modules.items()):
|
||||
if not module_name.startswith("sglang.srt.models.") or module is None:
|
||||
continue
|
||||
for member in vars(module).values():
|
||||
# transformers re-exports lazy placeholders that raise on any
|
||||
# attribute access when their optional backend is missing.
|
||||
try:
|
||||
if (
|
||||
inspect.isclass(member)
|
||||
and (member.__module__ or "").startswith("sglang.srt.models.")
|
||||
and hasattr(member, GATE)
|
||||
):
|
||||
names.add(member.__name__)
|
||||
except Exception:
|
||||
continue
|
||||
return names
|
||||
|
||||
|
||||
class TestFusionGateCoverage(CustomTestCase):
|
||||
def test_every_entry_class_reaching_a_gated_family_has_a_gate(self):
|
||||
models_dir = list(importlib.import_module("sglang.srt.models").__path__)[0]
|
||||
gates_by_module = {}
|
||||
for name in sorted(os.listdir(models_dir)):
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
with open(os.path.join(models_dir, name), encoding="utf-8") as f:
|
||||
try:
|
||||
gates_by_module[f"sglang.srt.models.{name[:-3]}"] = _gated_classes(
|
||||
f.read()
|
||||
)
|
||||
except SyntaxError:
|
||||
continue
|
||||
|
||||
missing = []
|
||||
all_gated = None
|
||||
for arch in sorted(ModelRegistry.get_supported_archs()):
|
||||
try:
|
||||
model_class, _ = ModelRegistry.resolve_model_cls(arch)
|
||||
except Exception:
|
||||
continue
|
||||
if hasattr(model_class, GATE) or arch in GATELESS_BY_DESIGN:
|
||||
continue
|
||||
module = importlib.import_module(model_class.__module__)
|
||||
try:
|
||||
source = inspect.getsource(module)
|
||||
except OSError:
|
||||
continue
|
||||
reasons = []
|
||||
if any(reader in source for reader in FLAG_READERS):
|
||||
reasons.append("reads the fusion flag")
|
||||
try:
|
||||
tree = ast.parse(source)
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
if tree is not None:
|
||||
# Any *use* of a gated class counts, whatever the shape: a
|
||||
# direct call (`DeepseekV2ForCausalLM(...)`), a module attribute
|
||||
# (`qwen3_5.Qwen3_5MoeForCausalLM`), or a class attribute the
|
||||
# constructor later calls (`body_cls = qwen3_5.Qwen3_5...`).
|
||||
# Only matching calls would miss the last two.
|
||||
if all_gated is None:
|
||||
all_gated = gated_class_names()
|
||||
used = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Name) and node.id in all_gated:
|
||||
used.add(node.id)
|
||||
elif isinstance(node, ast.Attribute) and node.attr in all_gated:
|
||||
used.add(node.attr)
|
||||
for name in sorted(used):
|
||||
if name != model_class.__name__:
|
||||
reasons.append(f"references {name}")
|
||||
if reasons:
|
||||
missing.append(
|
||||
f"{arch} ({model_class.__module__}): {', '.join(reasons)}"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[],
|
||||
missing,
|
||||
"these entry classes reach a fusion-gated family but resolve no "
|
||||
f"{GATE}, so the loader falls back to the user's intent for them and "
|
||||
"the family's auto-disable conditions never run:\n "
|
||||
+ "\n ".join(missing),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,514 @@
|
||||
"""Every MoE family's fusion gate, asked the way the loader asks it.
|
||||
|
||||
`install_shared_experts_fusion_decision` calls
|
||||
`<model class>.shared_experts_fusion_disable_reason(hf_config, quant_config)`
|
||||
before the model is built, so the gate must answer from the config and
|
||||
quantization it is handed — no instance, no layers. These cases pin each
|
||||
family's branch table, which matters because most of these checkpoints cannot
|
||||
be run on a single dev box: a wrong answer here is a silently wrong weight
|
||||
remap (the loader remaps `mlp.shared_experts` into a fused slot the layers
|
||||
never allocated), not a crash.
|
||||
|
||||
Conditions that depend on the device or the parallel topology are exercised
|
||||
through `get_parallel().override(...)`; the ones that are pure config /
|
||||
quantization are exercised directly.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.runtime_context import get_context, get_parallel
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=6, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def _quant(name: str):
|
||||
return SimpleNamespace(get_name=lambda: name)
|
||||
|
||||
|
||||
class _FusionGateCase(CustomTestCase):
|
||||
def _seed(self, **fields):
|
||||
override = get_context().override_server_args(**fields)
|
||||
override.install()
|
||||
self.addCleanup(override.restore)
|
||||
|
||||
def _reason(self, model_class, hf_config, quant_config=None, moe_ep_size=1):
|
||||
# The gates consult the live EP size; without a group installed the
|
||||
# canonical getter asserts, so every case states a topology.
|
||||
with get_parallel().override(moe_ep_size=moe_ep_size):
|
||||
return model_class.shared_experts_fusion_disable_reason(
|
||||
hf_config, quant_config
|
||||
)
|
||||
|
||||
|
||||
class TestDeepseekV2Gate(_FusionGateCase):
|
||||
def _config(self, **kw):
|
||||
base = dict(
|
||||
architectures=["DeepseekV3ForCausalLM"],
|
||||
n_routed_experts=256,
|
||||
n_shared_experts=1,
|
||||
)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
def test_a_foreign_architecture_cannot_fuse(self):
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
|
||||
self._seed()
|
||||
self.assertIn(
|
||||
"does not support",
|
||||
self._reason(
|
||||
DeepseekV2ForCausalLM,
|
||||
self._config(architectures=["SomeOtherForCausalLM"]),
|
||||
),
|
||||
)
|
||||
|
||||
def test_an_unvalidated_expert_count_cannot_fuse(self):
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
|
||||
self._seed()
|
||||
self.assertIn(
|
||||
"does not support",
|
||||
self._reason(DeepseekV2ForCausalLM, self._config(n_routed_experts=128)),
|
||||
)
|
||||
|
||||
def test_the_384_expert_layout_needs_a_quark_checkpoint(self):
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
|
||||
self._seed()
|
||||
config = self._config(n_routed_experts=384)
|
||||
self.assertIn(
|
||||
"does not support",
|
||||
self._reason(DeepseekV2ForCausalLM, config, _quant("compressed-tensors")),
|
||||
)
|
||||
# With Quark the layout is pre-fused, so this branch stops objecting.
|
||||
self.assertNotIn(
|
||||
"does not support",
|
||||
self._reason(DeepseekV2ForCausalLM, config, _quant("quark")) or "",
|
||||
)
|
||||
|
||||
def test_the_nextn_draft_declares_its_own_architecture(self):
|
||||
from sglang.srt.models.deepseek_nextn import DeepseekV3ForCausalLMNextN
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
|
||||
self.assertEqual(
|
||||
DeepseekV3ForCausalLMNextN.fused_shared_experts_architecture,
|
||||
"DeepseekV3ForCausalLMNextN",
|
||||
)
|
||||
self._seed()
|
||||
draft_config = self._config(architectures=["DeepseekV3ForCausalLMNextN"])
|
||||
# The draft's own class accepts it; the target's class does not.
|
||||
self.assertNotIn(
|
||||
"does not support",
|
||||
self._reason(DeepseekV3ForCausalLMNextN, draft_config) or "",
|
||||
)
|
||||
self.assertIn(
|
||||
"does not support", self._reason(DeepseekV2ForCausalLM, draft_config)
|
||||
)
|
||||
|
||||
def test_expert_parallelism_blocks_fusion_off_rocm(self):
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
|
||||
self._seed()
|
||||
self.assertTrue(
|
||||
self._reason(DeepseekV2ForCausalLM, self._config(), moe_ep_size=2)
|
||||
)
|
||||
|
||||
|
||||
class TestGlmMoeLiteGate(_FusionGateCase):
|
||||
def _config(self, **kw):
|
||||
base = dict(architectures=["Glm4MoeLiteForCausalLM"], n_shared_experts=1)
|
||||
base.update(kw)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
def test_more_than_one_shared_expert_cannot_fuse(self):
|
||||
from sglang.srt.models.glm4_moe_lite import Glm4MoeLiteForCausalLM
|
||||
|
||||
self._seed()
|
||||
self.assertTrue(
|
||||
self._reason(Glm4MoeLiteForCausalLM, self._config(n_shared_experts=2))
|
||||
)
|
||||
|
||||
def test_expert_parallelism_blocks_fusion(self):
|
||||
from sglang.srt.models.glm4_moe_lite import Glm4MoeLiteForCausalLM
|
||||
|
||||
self._seed()
|
||||
config = self._config()
|
||||
reason = self._reason(Glm4MoeLiteForCausalLM, config, moe_ep_size=2)
|
||||
self.assertTrue(reason)
|
||||
# This family checks the device capability before expert parallelism, so
|
||||
# only ask *which* branch refused on a device that would otherwise fuse
|
||||
# (a CPU runner never gets past the capability check).
|
||||
if self._reason(Glm4MoeLiteForCausalLM, config) is None:
|
||||
self.assertIn("expert parallelism", reason)
|
||||
|
||||
def test_the_nextn_draft_declares_its_own_architecture(self):
|
||||
from sglang.srt.models.glm4_moe_lite_nextn import Glm4MoeLiteForCausalLMNextN
|
||||
|
||||
self.assertEqual(
|
||||
Glm4MoeLiteForCausalLMNextN.fused_shared_experts_architecture,
|
||||
"Glm4MoeLiteForCausalLMNextN",
|
||||
)
|
||||
|
||||
|
||||
class TestGlmMoeGate(_FusionGateCase):
|
||||
def test_a_w4afp8_checkpoint_cannot_fuse(self):
|
||||
from sglang.srt.models.glm4_moe import Glm4MoeForCausalLM
|
||||
|
||||
self._seed()
|
||||
reason = self._reason(
|
||||
Glm4MoeForCausalLM, SimpleNamespace(n_shared_experts=1), _quant("w4afp8")
|
||||
)
|
||||
self.assertTrue(reason)
|
||||
|
||||
def test_the_dsa_variant_declares_its_own_architecture(self):
|
||||
from sglang.srt.models.glm4_moe import GlmMoeDsaForCausalLM
|
||||
|
||||
self.assertEqual(
|
||||
GlmMoeDsaForCausalLM.fused_shared_experts_architecture,
|
||||
"GlmMoeDsaForCausalLM",
|
||||
)
|
||||
|
||||
|
||||
class TestMiniMaxGates(_FusionGateCase):
|
||||
def test_a_config_without_shared_experts_cannot_fuse(self):
|
||||
from sglang.srt.models.minimax_m3 import MiniMaxM3SparseForCausalLM
|
||||
|
||||
self._seed()
|
||||
self.assertIn(
|
||||
"No shared experts",
|
||||
self._reason(
|
||||
MiniMaxM3SparseForCausalLM, SimpleNamespace(n_shared_experts=0)
|
||||
),
|
||||
)
|
||||
|
||||
def test_a_modelopt_mixed_checkpoint_cannot_fuse(self):
|
||||
from sglang.srt.models.minimax_m3 import MiniMaxM3SparseForCausalLM
|
||||
|
||||
self._seed()
|
||||
reason = self._reason(
|
||||
MiniMaxM3SparseForCausalLM,
|
||||
SimpleNamespace(n_shared_experts=1),
|
||||
_quant("modelopt_mixed"),
|
||||
)
|
||||
self.assertIn("quantization formats", reason)
|
||||
|
||||
def test_the_vl_variant_reads_the_text_config(self):
|
||||
from sglang.srt.models.minimax_m3_vl import (
|
||||
MiniMaxM3SparseForConditionalGeneration,
|
||||
)
|
||||
|
||||
self._seed()
|
||||
wrapper = SimpleNamespace(text_config=SimpleNamespace(n_shared_experts=0))
|
||||
self.assertIn(
|
||||
"No shared experts",
|
||||
self._reason(MiniMaxM3SparseForConditionalGeneration, wrapper),
|
||||
)
|
||||
|
||||
|
||||
class TestQwen3_5Gate(_FusionGateCase):
|
||||
def test_every_entry_class_answers(self):
|
||||
import sglang.srt.models.qwen3_5 as qwen3_5
|
||||
|
||||
for cls in (
|
||||
qwen3_5.Qwen3_5ForCausalLM,
|
||||
qwen3_5.Qwen3_5MoeForCausalLM,
|
||||
qwen3_5.Qwen3_5ForConditionalGeneration,
|
||||
qwen3_5.Qwen3_5MoeForConditionalGeneration,
|
||||
):
|
||||
self.assertTrue(
|
||||
hasattr(cls, "shared_experts_fusion_disable_reason"),
|
||||
f"{cls.__name__} would silently skip the ROCm auto-disable",
|
||||
)
|
||||
|
||||
def test_the_auto_disable_is_rocm_only(self):
|
||||
import sglang.srt.models.qwen3_5 as qwen3_5
|
||||
|
||||
self._seed()
|
||||
# On a non-ROCm build the gate never objects, whatever the checkpoint is.
|
||||
wrapper = SimpleNamespace(
|
||||
text_config=SimpleNamespace(model_type="qwen3_5_moe_text")
|
||||
)
|
||||
if not qwen3_5._is_hip:
|
||||
self.assertIsNone(
|
||||
self._reason(qwen3_5.Qwen3_5MoeForConditionalGeneration, wrapper)
|
||||
)
|
||||
|
||||
|
||||
class TestWrapperEntryClassGates(_FusionGateCase):
|
||||
"""A wrapper model answers with the config it hands its nested family.
|
||||
|
||||
The loader asks the class it instantiates, which for these models is the
|
||||
wrapper — not the DeepSeek/Qwen3.5 body inside it. Each wrapper therefore
|
||||
delegates to its family's gate with the config (and quantization) the
|
||||
nested construction uses; these cases pin *what gets handed over*, because
|
||||
handing over the top-level config instead would answer for the wrong
|
||||
checkpoint (or raise on a config that has no expert counts at all).
|
||||
"""
|
||||
|
||||
def _recording_gate(self, family_cls):
|
||||
seen = {}
|
||||
|
||||
def recorder(hf_config, quant_config):
|
||||
seen["config"] = hf_config
|
||||
seen["quant"] = quant_config
|
||||
return None
|
||||
|
||||
return seen, unittest.mock.patch.object(
|
||||
family_cls,
|
||||
"shared_experts_fusion_disable_reason",
|
||||
staticmethod(recorder),
|
||||
)
|
||||
|
||||
def test_kimi_vl_never_fuses_and_says_why(self):
|
||||
from sglang.srt.models.kimi_vl import KimiVLForConditionalGeneration
|
||||
|
||||
self._seed()
|
||||
config = SimpleNamespace(
|
||||
encoder_only=False,
|
||||
text_config=SimpleNamespace(
|
||||
architectures=["Whatever"], n_routed_experts=256, n_shared_experts=1
|
||||
),
|
||||
)
|
||||
# The construction rewrites the architecture to DeepseekV2ForCausalLM,
|
||||
# which is not the architecture the fused path validated.
|
||||
self.assertIn(
|
||||
"does not support",
|
||||
self._reason(KimiVLForConditionalGeneration, config),
|
||||
)
|
||||
self.assertIsNone(
|
||||
self._reason(
|
||||
KimiVLForConditionalGeneration,
|
||||
SimpleNamespace(encoder_only=True, text_config=None),
|
||||
),
|
||||
"an encoder-only Kimi-VL builds no language model",
|
||||
)
|
||||
|
||||
def test_kimi_k25_hands_over_its_text_config(self):
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV3ForCausalLM
|
||||
from sglang.srt.models.kimi_k25 import KimiK25ForConditionalGeneration
|
||||
|
||||
self._seed()
|
||||
text_config = SimpleNamespace(
|
||||
architectures=["DeepseekV3ForCausalLM"],
|
||||
n_routed_experts=384,
|
||||
n_shared_experts=1,
|
||||
)
|
||||
config = SimpleNamespace(encoder_only=False, text_config=text_config)
|
||||
# The standard compressed-tensors Kimi-K2.5 checkpoint stores its shared
|
||||
# expert loose, so this must refuse to fuse.
|
||||
self.assertIn(
|
||||
"does not support",
|
||||
self._reason(
|
||||
KimiK25ForConditionalGeneration,
|
||||
config,
|
||||
_quant("compressed-tensors"),
|
||||
),
|
||||
)
|
||||
seen, patcher = self._recording_gate(DeepseekV3ForCausalLM)
|
||||
with patcher:
|
||||
self._reason(KimiK25ForConditionalGeneration, config, _quant("quark"))
|
||||
self.assertIs(seen["config"], text_config)
|
||||
self.assertIsNone(
|
||||
self._reason(
|
||||
KimiK25ForConditionalGeneration,
|
||||
SimpleNamespace(encoder_only=True, text_config=None),
|
||||
)
|
||||
)
|
||||
|
||||
def test_pixtral_only_asks_for_its_mla_backbone(self):
|
||||
from sglang.srt.models.mistral_large_3 import MistralLarge3ForCausalLM
|
||||
from sglang.srt.models.pixtral import PixtralForConditionalGeneration
|
||||
|
||||
self._seed()
|
||||
mla_text = SimpleNamespace(
|
||||
model_type="deepseek_v3",
|
||||
architectures=["DeepseekV3ForCausalLM"],
|
||||
n_routed_experts=256,
|
||||
n_shared_experts=1,
|
||||
)
|
||||
seen, patcher = self._recording_gate(MistralLarge3ForCausalLM)
|
||||
with patcher:
|
||||
self._reason(
|
||||
PixtralForConditionalGeneration,
|
||||
SimpleNamespace(text_config=mla_text),
|
||||
)
|
||||
self.assertIs(seen["config"], mla_text)
|
||||
# A GQA text config builds the dense Mistral backbone instead.
|
||||
self.assertIsNone(
|
||||
self._reason(
|
||||
PixtralForConditionalGeneration,
|
||||
SimpleNamespace(text_config=SimpleNamespace(model_type="mistral")),
|
||||
)
|
||||
)
|
||||
|
||||
def test_dots_vlm_hands_over_the_language_config(self):
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
from sglang.srt.models.dots_vlm import DotsVLMForCausalLM
|
||||
|
||||
language_config = SimpleNamespace(
|
||||
architectures=["DeepseekV3ForCausalLM"],
|
||||
n_routed_experts=256,
|
||||
n_shared_experts=1,
|
||||
)
|
||||
config = SimpleNamespace(encoder_only=False, language_config=language_config)
|
||||
seen, patcher = self._recording_gate(DeepseekV2ForCausalLM)
|
||||
with patcher:
|
||||
self._reason(DotsVLMForCausalLM, config, _quant("fp8"))
|
||||
self.assertIs(seen["config"], language_config)
|
||||
self.assertEqual(seen["quant"].get_name(), "fp8")
|
||||
|
||||
def test_deepseek_vl2_mirrors_its_unquantized_language_model(self):
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
from sglang.srt.models.deepseek_vl2 import DeepseekVL2ForCausalLM
|
||||
|
||||
language_config = SimpleNamespace(
|
||||
use_mla=True,
|
||||
architectures=["DeepseekV3ForCausalLM"],
|
||||
n_routed_experts=256,
|
||||
n_shared_experts=1,
|
||||
)
|
||||
seen, patcher = self._recording_gate(DeepseekV2ForCausalLM)
|
||||
with patcher:
|
||||
self._reason(
|
||||
DeepseekVL2ForCausalLM,
|
||||
SimpleNamespace(language_config=language_config),
|
||||
_quant("fp8"),
|
||||
)
|
||||
self.assertIs(seen["config"], language_config)
|
||||
self.assertIsNone(
|
||||
seen["quant"], "the language model is constructed without quantization"
|
||||
)
|
||||
# deepseek-vl2-tiny forbids MLA and builds the dense model instead.
|
||||
self.assertIsNone(
|
||||
self._reason(
|
||||
DeepseekVL2ForCausalLM,
|
||||
SimpleNamespace(language_config=SimpleNamespace(use_mla=False)),
|
||||
)
|
||||
)
|
||||
|
||||
def test_deepseek_ocr_only_asks_for_its_moe_branches(self):
|
||||
from sglang.srt.models.deepseek_ocr import DeepseekOCRForCausalLM
|
||||
from sglang.srt.models.deepseek_v2 import DeepseekV2ForCausalLM
|
||||
|
||||
text_config = SimpleNamespace(
|
||||
topk_method="noaux_tc",
|
||||
use_mla=True,
|
||||
architectures=["DeepseekV3ForCausalLM"],
|
||||
n_routed_experts=256,
|
||||
n_shared_experts=1,
|
||||
)
|
||||
moe_config = SimpleNamespace(
|
||||
vision_config=SimpleNamespace(model_name="deepencoder"),
|
||||
projector_config=SimpleNamespace(input_dim=1280),
|
||||
text_config=text_config,
|
||||
)
|
||||
seen, patcher = self._recording_gate(DeepseekV2ForCausalLM)
|
||||
with patcher:
|
||||
self._reason(DeepseekOCRForCausalLM, moe_config, _quant("fp8"))
|
||||
self.assertIs(seen["config"], text_config)
|
||||
|
||||
# OCR2 (and any non-MLA, non-noaux_tc config) builds the dense model.
|
||||
ocr2 = SimpleNamespace(
|
||||
vision_config=SimpleNamespace(model_name="DeepEncoderV2"),
|
||||
projector_config=SimpleNamespace(input_dim=896),
|
||||
text_config=text_config,
|
||||
)
|
||||
self.assertIsNone(self._reason(DeepseekOCRForCausalLM, ocr2))
|
||||
dense = SimpleNamespace(
|
||||
vision_config=SimpleNamespace(model_name="deepencoder"),
|
||||
projector_config=SimpleNamespace(input_dim=1280),
|
||||
text_config=SimpleNamespace(topk_method="greedy", use_mla=False),
|
||||
)
|
||||
self.assertIsNone(self._reason(DeepseekOCRForCausalLM, dense))
|
||||
|
||||
def test_minicpmv_entries_delegate_to_the_qwen3_5_gate(self):
|
||||
from sglang.srt.models.minicpmv import (
|
||||
MiniCPMV,
|
||||
MiniCPMV4_6ForConditionalGeneration,
|
||||
)
|
||||
from sglang.srt.models.qwen3_5 import Qwen3_5ForCausalLM
|
||||
|
||||
text_config = SimpleNamespace(model_type="qwen3_5_moe_text")
|
||||
for cls in (MiniCPMV, MiniCPMV4_6ForConditionalGeneration):
|
||||
seen, patcher = self._recording_gate(Qwen3_5ForCausalLM)
|
||||
with patcher:
|
||||
self._reason(cls, SimpleNamespace(text_config=text_config))
|
||||
self.assertIs(seen["config"], text_config, cls.__name__)
|
||||
|
||||
def test_the_text_only_qwen3_5_entries_delegate_to_their_body(self):
|
||||
import sglang.srt.models.qwen3_5 as qwen3_5
|
||||
import sglang.srt.models.qwen3_5_text as qwen3_5_text
|
||||
|
||||
# A text-only Qwen3.5 checkpoint resolves to these classes, which shadow
|
||||
# the multimodal ones by name — attaching the gate to the multimodal
|
||||
# classes alone leaves the registry's text-only entries gate-less.
|
||||
self.assertIs(
|
||||
qwen3_5_text.Qwen3_5MoeForCausalLM.body_cls,
|
||||
qwen3_5.Qwen3_5MoeForCausalLM,
|
||||
)
|
||||
text_config = SimpleNamespace(model_type="qwen3_5_moe_text")
|
||||
seen, patcher = self._recording_gate(qwen3_5.Qwen3_5MoeForCausalLM)
|
||||
with patcher:
|
||||
self._reason(
|
||||
qwen3_5_text.Qwen3_5MoeForCausalLM, text_config, _quant("quark")
|
||||
)
|
||||
self.assertIs(seen["config"], text_config)
|
||||
self.assertEqual(seen["quant"].get_name(), "quark")
|
||||
|
||||
def test_the_qwen3_5_mtp_entry_normalizes_its_quantization(self):
|
||||
from sglang.srt.models.qwen3_5 import Qwen3_5ForCausalLM
|
||||
from sglang.srt.models.qwen3_5_mtp import (
|
||||
Qwen3_5ForCausalLMMTP,
|
||||
_mtp_quant_config,
|
||||
)
|
||||
|
||||
# The normalization the constructor applies, shared with the gate.
|
||||
self.assertIsNone(_mtp_quant_config(_quant("modelopt_mixed")))
|
||||
serialized = SimpleNamespace(
|
||||
get_name=lambda: "modelopt_fp4", is_checkpoint_nvfp4_serialized=True
|
||||
)
|
||||
self.assertIsNone(_mtp_quant_config(serialized))
|
||||
# A non-serialized modelopt_fp4 checkpoint still converts on load, so
|
||||
# the MTP module keeps the quantization.
|
||||
online = SimpleNamespace(
|
||||
get_name=lambda: "modelopt_fp4", is_checkpoint_nvfp4_serialized=False
|
||||
)
|
||||
self.assertIs(_mtp_quant_config(online), online)
|
||||
quark_mtp = SimpleNamespace(
|
||||
get_name=lambda: "quark", exclude_layers=["mtp.mlp.experts"]
|
||||
)
|
||||
self.assertIsNone(_mtp_quant_config(quark_mtp))
|
||||
kept = _quant("fp8")
|
||||
self.assertIs(_mtp_quant_config(kept), kept)
|
||||
|
||||
text_config = SimpleNamespace(model_type="qwen3_5_moe_text")
|
||||
seen, patcher = self._recording_gate(Qwen3_5ForCausalLM)
|
||||
with patcher:
|
||||
self._reason(
|
||||
Qwen3_5ForCausalLMMTP,
|
||||
SimpleNamespace(text_config=text_config),
|
||||
serialized,
|
||||
)
|
||||
self.assertIs(seen["config"], text_config)
|
||||
self.assertIsNone(
|
||||
seen["quant"], "the MTP module ships unquantized in that checkpoint"
|
||||
)
|
||||
|
||||
|
||||
class TestFamiliesWithoutAGate(_FusionGateCase):
|
||||
def test_qwen2_moe_style_families_follow_the_intent(self):
|
||||
"""A family with no gate must not grow one by accident: the installer
|
||||
falls back to the user's intent for it."""
|
||||
from sglang.srt.models.qwen2_moe import Qwen2MoeForCausalLM
|
||||
|
||||
self.assertFalse(
|
||||
hasattr(Qwen2MoeForCausalLM, "shared_experts_fusion_disable_reason")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""A draft's construction decides for itself and leaves the process state alone.
|
||||
|
||||
The shared-experts-fusion decision is per checkpoint: each MoE model's gate
|
||||
writes the ACTIVE moe flag (both ways) before its own layers build and read
|
||||
it, and ``draft_model_build_scope`` — which brackets every draft
|
||||
construction — records it on the speculative leaf and restores the target's
|
||||
value on exit. The config bag keeps the
|
||||
user's intent. A draft's weight update does not rewrite the
|
||||
process's model_path record.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.layers.moe.utils import (
|
||||
draft_model_build_scope,
|
||||
install_shared_experts_fusion_decision,
|
||||
is_shared_experts_fusion_disabled,
|
||||
speculative_moe_backend_context,
|
||||
)
|
||||
from sglang.srt.model_executor.model_runner import ModelRunner
|
||||
from sglang.srt.runtime_context import get_context, get_flags, get_model
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class _AlwaysDisables:
|
||||
"""A model class whose checkpoint can never fuse."""
|
||||
|
||||
@staticmethod
|
||||
def shared_experts_fusion_disable_reason(hf_config, quant_config):
|
||||
return "stand-in: this checkpoint cannot fuse."
|
||||
|
||||
|
||||
class _NoGate:
|
||||
"""A model family without an auto-disable gate: it follows the intent."""
|
||||
|
||||
|
||||
def _install(model_class):
|
||||
install_shared_experts_fusion_decision(model_class, SimpleNamespace(), None)
|
||||
|
||||
|
||||
class TestFusionDecisionFlag(CustomTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
moe = get_flags().moe
|
||||
self._saved = (
|
||||
moe.disable_shared_experts_fusion,
|
||||
moe.speculative_disable_shared_experts_fusion,
|
||||
)
|
||||
moe.disable_shared_experts_fusion = None
|
||||
moe.speculative_disable_shared_experts_fusion = None
|
||||
moe.in_speculative_scope = False
|
||||
|
||||
def tearDown(self):
|
||||
moe = get_flags().moe
|
||||
(
|
||||
moe.disable_shared_experts_fusion,
|
||||
moe.speculative_disable_shared_experts_fusion,
|
||||
) = self._saved
|
||||
super().tearDown()
|
||||
|
||||
def _seed(self, **fields):
|
||||
override = get_context().override_server_args(**fields)
|
||||
override.install()
|
||||
self.addCleanup(override.restore)
|
||||
|
||||
def test_unset_flag_falls_back_to_the_config_intent(self):
|
||||
self._seed(disable_shared_experts_fusion=True)
|
||||
self.assertTrue(is_shared_experts_fusion_disabled())
|
||||
self._seed(disable_shared_experts_fusion=False)
|
||||
# A fresh install replaces the published config; the flag is still None.
|
||||
self.assertFalse(is_shared_experts_fusion_disabled())
|
||||
|
||||
def test_the_installed_decision_wins_over_the_intent(self):
|
||||
self._seed(disable_shared_experts_fusion=False)
|
||||
_install(_AlwaysDisables)
|
||||
self.assertTrue(is_shared_experts_fusion_disabled())
|
||||
_install(_NoGate)
|
||||
self.assertFalse(is_shared_experts_fusion_disabled())
|
||||
|
||||
def test_the_intent_short_circuits_the_gate(self):
|
||||
# A user who passed --disable-shared-experts-fusion is not overruled,
|
||||
# and the gate is not even asked.
|
||||
self._seed(disable_shared_experts_fusion=True)
|
||||
_install(_NoGate)
|
||||
self.assertTrue(is_shared_experts_fusion_disabled())
|
||||
|
||||
def test_the_draft_build_scope_restores_the_targets_decision(self):
|
||||
self._seed(disable_shared_experts_fusion=False)
|
||||
_install(_NoGate) # the target's build
|
||||
with draft_model_build_scope():
|
||||
_install(_AlwaysDisables) # the draft's build
|
||||
self.assertTrue(is_shared_experts_fusion_disabled())
|
||||
self.assertFalse(is_shared_experts_fusion_disabled())
|
||||
# The draft's decision stays inspectable on the twin leaf.
|
||||
self.assertTrue(get_flags().moe.speculative_disable_shared_experts_fusion)
|
||||
|
||||
def test_a_gateless_draft_inherits_the_active_decision(self):
|
||||
self._seed(disable_shared_experts_fusion=False)
|
||||
_install(_AlwaysDisables) # the target's build
|
||||
with draft_model_build_scope():
|
||||
# A draft whose family has no gate follows the intent, which is what
|
||||
# the target's own build already resolved to here.
|
||||
self.assertTrue(is_shared_experts_fusion_disabled())
|
||||
self.assertTrue(is_shared_experts_fusion_disabled())
|
||||
|
||||
def test_post_build_scopes_do_not_clobber_the_draft_leaf(self):
|
||||
# init_attention_backends / cuda-graph capture / draft forwards enter
|
||||
# scopes after construction; no gate runs there, so the persisted
|
||||
# draft decision must survive.
|
||||
self._seed(disable_shared_experts_fusion=False)
|
||||
_install(_NoGate) # target's build
|
||||
with draft_model_build_scope():
|
||||
_install(_AlwaysDisables) # draft's build
|
||||
for _ in range(3):
|
||||
with draft_model_build_scope():
|
||||
pass
|
||||
with speculative_moe_backend_context():
|
||||
pass
|
||||
self.assertTrue(get_flags().moe.speculative_disable_shared_experts_fusion)
|
||||
self.assertFalse(get_flags().moe.disable_shared_experts_fusion)
|
||||
|
||||
def test_the_build_scope_leaves_the_runner_backend_alone(self):
|
||||
# Swapping runner_backend is speculative_moe_backend_context's job and
|
||||
# must bracket the draft's whole lifecycle; dflash/dspark run their
|
||||
# draft outside it, so a construction-only swap would build and
|
||||
# execute the draft under different backends.
|
||||
self._seed()
|
||||
before = get_flags().moe.runner_backend
|
||||
with draft_model_build_scope():
|
||||
self.assertEqual(get_flags().moe.runner_backend, before)
|
||||
self.assertEqual(get_flags().moe.runner_backend, before)
|
||||
|
||||
def test_a_record_outside_any_scope_is_target_only(self):
|
||||
self._seed(disable_shared_experts_fusion=False)
|
||||
get_flags().moe.speculative_disable_shared_experts_fusion = True
|
||||
_install(_NoGate) # target's build
|
||||
self.assertTrue(get_flags().moe.speculative_disable_shared_experts_fusion)
|
||||
|
||||
def test_initialize_moe_config_seeds_both_leaves(self):
|
||||
from sglang.srt.layers.moe.utils import initialize_moe_config
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
|
||||
self._seed()
|
||||
initialize_moe_config(
|
||||
ServerArgs(model_path="dummy", disable_shared_experts_fusion=True)
|
||||
)
|
||||
moe = get_flags().moe
|
||||
self.assertTrue(moe.disable_shared_experts_fusion)
|
||||
self.assertTrue(moe.speculative_disable_shared_experts_fusion)
|
||||
|
||||
def test_a_forward_time_read_is_refused(self):
|
||||
# The invariant behind the whole design: the decision is consumed at
|
||||
# construction only. During a draft's build the flag holds the draft's
|
||||
# value, so a forward reading it would race the build window.
|
||||
from sglang.srt.model_executor.forward_context import (
|
||||
ForwardContext,
|
||||
forward_context,
|
||||
)
|
||||
|
||||
self._seed()
|
||||
with forward_context(ForwardContext(attn_backend=SimpleNamespace())):
|
||||
with self.assertRaises(AssertionError):
|
||||
is_shared_experts_fusion_disabled()
|
||||
|
||||
def test_the_intent_stays_on_the_bag(self):
|
||||
self._seed(disable_shared_experts_fusion=False)
|
||||
_install(_AlwaysDisables)
|
||||
from sglang.srt.runtime_context import get_exec
|
||||
|
||||
self.assertFalse(get_exec().moe.disable_shared_experts_fusion)
|
||||
|
||||
|
||||
class TestDraftWeightUpdateRecord(CustomTestCase):
|
||||
def _seed(self, **fields):
|
||||
override = get_context().override_server_args(**fields)
|
||||
server_args = override.install()
|
||||
self.addCleanup(override.restore)
|
||||
return server_args
|
||||
|
||||
def _update(self, *, is_draft_worker: bool):
|
||||
runner = ModelRunner.__new__(ModelRunner)
|
||||
runner.is_draft_worker = is_draft_worker
|
||||
runner.update_model_fields(
|
||||
object(),
|
||||
model_path="/new/checkpoint",
|
||||
load_format="auto",
|
||||
load_config=object(),
|
||||
)
|
||||
|
||||
def test_a_target_update_is_recorded(self):
|
||||
self._seed()
|
||||
self._update(is_draft_worker=False)
|
||||
self.assertEqual(get_model().model_path, "/new/checkpoint")
|
||||
|
||||
def test_a_draft_update_keeps_the_targets_record(self):
|
||||
seeded = self._seed()
|
||||
self._update(is_draft_worker=True)
|
||||
self.assertEqual(get_model().model_path, seeded.model_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,7 +19,6 @@ from sglang.srt.runtime_context import (
|
||||
get_context,
|
||||
get_flags,
|
||||
get_parallel,
|
||||
get_schedule,
|
||||
get_server_args,
|
||||
reset_context,
|
||||
)
|
||||
@@ -403,6 +402,7 @@ class TestMoeFlagsGroup(_IsolatedServerArgs):
|
||||
tbo_token_distribution_threshold=0.48,
|
||||
disable_flashinfer_cutlass_moe_fp4_allgather=False,
|
||||
quantization=None,
|
||||
disable_shared_experts_fusion=False,
|
||||
)
|
||||
defaults.update(kw)
|
||||
initialize_moe_config(SimpleNamespace(**defaults))
|
||||
@@ -964,35 +964,6 @@ class TestPublishLifecycle(_IsolatedServerArgs):
|
||||
get_context().set_server_args(object())
|
||||
self.assertFalse(get_flags().capture.enable_torch_compile)
|
||||
|
||||
def test_declare_load_time_override_writes_the_bag(self):
|
||||
from sglang.srt.arg_groups.overrides import declare_load_time_override
|
||||
|
||||
args = self._publish(page_size=1)
|
||||
declare_load_time_override("model.load_time", {"page_size": 64})
|
||||
# The declaration lands on the config bag; the pristine startup record
|
||||
# (server_args) is untouched.
|
||||
self.assertEqual(get_schedule().page_size, 64)
|
||||
self.assertEqual(args.page_size, 1)
|
||||
|
||||
def test_declare_load_time_override_validates_whitelist(self):
|
||||
from sglang.srt.arg_groups.overrides import declare_load_time_override
|
||||
|
||||
args = self._publish(page_size=1)
|
||||
with self.assertRaises(ValueError):
|
||||
declare_load_time_override("bad", {"nope": 1})
|
||||
self.assertEqual(args.page_size, 1)
|
||||
|
||||
def test_declare_load_time_override_records_provenance(self):
|
||||
from sglang.srt.arg_groups.overrides import declare_load_time_override
|
||||
|
||||
self._publish(page_size=1)
|
||||
declare_load_time_override("model.load_time", {"page_size": 64})
|
||||
self.assertEqual(get_schedule().page_size, 64)
|
||||
self.assertIn(
|
||||
("model.load_time", {"page_size": 64}),
|
||||
get_context().overrides_log(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -69,7 +69,7 @@ class TestServerArgsMutationRatchet(CustomTestCase):
|
||||
f"server_args mutations outside the resolution pipeline grew: "
|
||||
f"{count} > baseline {_BASELINE}. Configuration is resolved in "
|
||||
"ServerArgs.__post_init__; declare through the pipeline "
|
||||
"(passes / declare_load_time_override), change resolved config "
|
||||
"(passes / declare_late_resolution), change resolved config "
|
||||
"with get_context().override(source, ...), or hand the value "
|
||||
"to its runner as a constructor argument — do not assign fields."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user