Support for Zyphra zaya1 model (#26347)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
ChengYao-amd
2026-06-10 02:44:47 -07:00
committed by GitHub
co-authored by github-actions[bot]
parent b40f365732
commit 255843d454
9 changed files with 3167 additions and 1 deletions
+2
View File
@@ -39,6 +39,7 @@ from sglang.srt.configs.step3_vl import (
)
from sglang.srt.configs.step3p5 import Step3p5Config
from sglang.srt.configs.step3p7 import Step3p7Config
from sglang.srt.configs.zaya import ZayaConfig
__all__ = [
"AfmoeConfig",
@@ -80,4 +81,5 @@ __all__ = [
"Step3p5Config",
"Step3p7Config",
"Qwen3ASRConfig",
"ZayaConfig",
]
+326
View File
@@ -0,0 +1,326 @@
# SPDX-License-Identifier: Apache-2.0
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""Configuration class for Zyphra ZAYA1 series models."""
from __future__ import annotations
from typing import TYPE_CHECKING, List, Optional
from transformers.configuration_utils import PretrainedConfig
if TYPE_CHECKING:
from sglang.srt.configs.mamba_utils import Mamba2CacheParams
class ZayaConfig(PretrainedConfig):
"""HuggingFace configuration for ZAYA1 hybrid (CCA attention + MoE) models.
Mirrors the field set used by Zyphra/ZAYA1-base/config.json. Most fields
are surfaced as constructor arguments so the same class can be instantiated
either from a published checkpoint via ``AutoConfig.from_pretrained`` or
programmatically in unit tests.
"""
model_type = "zaya"
keys_to_ignore_at_inference = ["past_key_values"]
def __init__(
self,
cca: bool = True,
num_query_groups: int = 2,
use_cache: bool = True,
attention_bias: bool = False,
lm_head_bias: bool = False,
vocab_size: int = 262272,
hidden_size: int = 2048,
ffn_hidden_size: int = 4096,
num_hidden_layers: int = 80,
num_experts: int = 16,
num_attention_heads: int = 8,
head_dim: int = 128,
activation_func: str = "swiglu",
max_position_embeddings: int = 32768,
norm_epsilon: float = 1e-5,
pad_token_id: int = 0,
bos_token_id: int = 2,
eos_token_id: int = 1,
tie_word_embeddings: bool = True,
rope_theta: float = 1_000_000.0,
attention_dropout: float = 0.0,
moe_router_topk: int = 1,
normalization: str = "RMSNorm",
zaya_mlp_expansion=256,
zaya_use_mod: bool = True,
zaya_high_prec: bool = True,
zaya_use_eda: bool = True,
add_bias_linear: bool = False,
gated_linear_unit: bool = True,
scale_residual_merge: bool = True,
fused_add_norm: bool = False,
residual_in_fp32: bool = True,
apply_rope_fusion: bool = True,
bias_activation_fusion: bool = True,
activation_func_fp8_input_store: bool = False,
sliding_window=None,
rope_scaling=None,
rope_parameters=None,
partial_rotary_factor: float = 0.5,
num_key_value_heads: int = 2,
clamp_temp: bool = False,
cca_time0: int = 2,
cca_time1: int = 2,
swa_layers=None,
swa_rotary_base=None,
zaya_layers=None,
cca_num_q_heads=None,
num_query_groups_list=None,
ffn_hidden_size_list=None,
kv_channels=None,
_attn_implementation: str = "eager",
**kwargs,
):
self.cca = cca
self.use_cache = use_cache
self.attention_bias = attention_bias
self.lm_head_bias = lm_head_bias
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.num_experts = num_experts
# ZAYA1-base ships a ``zaya_layers`` list whose entries are either the
# literal string ``"a"`` (attention layer) or an integer (number of
# experts in a MoE layer). When present it is the source of truth for
# both the total layer count and the per-layer placement. The HF
# config also carries a scalar ``num_hidden_layers`` that can disagree
# with ``len(zaya_layers)`` for historical reasons, so the list takes
# precedence whenever it is non-empty.
self.zaya_layers = list(zaya_layers) if zaya_layers else None
if self.zaya_layers:
self.num_hidden_layers = len(self.zaya_layers)
else:
self.num_hidden_layers = num_hidden_layers
# When the per-layer lists are present, derive each active scalar
# field from the first non-zero entry of the corresponding list.
# This matches ZAYA1-base in practice: every attention layer shares
# the same ``cca_num_q_heads`` (e.g. 8) and ``num_query_groups``
# (e.g. 2), and every MoE layer shares the same ``ffn_hidden_size``
# (e.g. 4096) and ``zaya_mlp_expansion`` (e.g. 256). When no list is
# provided, the constructor argument is used unchanged.
self.cca_num_q_heads_list = list(cca_num_q_heads) if cca_num_q_heads else None
self.num_query_groups_list = (
list(num_query_groups_list) if num_query_groups_list else None
)
self.ffn_hidden_size_list = (
list(ffn_hidden_size_list) if ffn_hidden_size_list else None
)
if isinstance(zaya_mlp_expansion, (list, tuple)):
self.zaya_mlp_expansion_list = list(zaya_mlp_expansion)
zaya_mlp_expansion_scalar = next(
(v for v in self.zaya_mlp_expansion_list if v), 256
)
else:
self.zaya_mlp_expansion_list = None
zaya_mlp_expansion_scalar = int(zaya_mlp_expansion)
if self.cca_num_q_heads_list:
self.num_attention_heads = next(
(v for v in self.cca_num_q_heads_list if v), num_attention_heads
)
else:
self.num_attention_heads = num_attention_heads
if self.num_query_groups_list:
self.num_query_groups = next(
(v for v in self.num_query_groups_list if v), num_query_groups
)
else:
self.num_query_groups = num_query_groups
if self.ffn_hidden_size_list:
self.ffn_hidden_size = next(
(v for v in self.ffn_hidden_size_list if v), ffn_hidden_size
)
else:
self.ffn_hidden_size = ffn_hidden_size
self.zaya_mlp_expansion = zaya_mlp_expansion_scalar
# The HF config exposes the per-head dim as ``kv_channels``; accept
# either spelling and keep both attributes in sync for downstream code.
if head_dim is None and kv_channels is not None:
head_dim = int(kv_channels)
self.head_dim = head_dim
self.kv_channels = kv_channels if kv_channels is not None else head_dim
assert self.head_dim is not None, "head_dim is required for ZayaConfig"
assert (
self.num_query_groups == num_key_value_heads
), "num_query_groups must equal num_key_value_heads for ZAYA1 checkpoints"
self.num_key_value_heads = num_key_value_heads
self.activation_func = activation_func
self.max_position_embeddings = max_position_embeddings
self.norm_epsilon = norm_epsilon
self.normalization = normalization
self.pad_token_id = pad_token_id
self.bos_token_id = bos_token_id
self.eos_token_id = eos_token_id
self.tie_word_embeddings = tie_word_embeddings
self.attention_dropout = attention_dropout
self.moe_router_topk = moe_router_topk
self.zaya_use_mod = zaya_use_mod
self.zaya_high_prec = zaya_high_prec
self.zaya_use_eda = zaya_use_eda
self.add_bias_linear = add_bias_linear
self.gated_linear_unit = gated_linear_unit
self.scale_residual_merge = scale_residual_merge
self.fused_add_norm = fused_add_norm
self.residual_in_fp32 = residual_in_fp32
self.apply_rope_fusion = apply_rope_fusion
self.bias_activation_fusion = bias_activation_fusion
self.activation_func_fp8_input_store = activation_func_fp8_input_store
self.sliding_window = sliding_window
self.partial_rotary_factor = partial_rotary_factor
self.rope_theta = rope_theta
self.rope_scaling = rope_scaling
if isinstance(rope_parameters, dict):
rope_parameters_dict = dict(rope_parameters)
elif isinstance(rope_scaling, dict):
rope_parameters_dict = dict(rope_scaling)
else:
rope_parameters_dict = {"rope_type": "default"}
if "type" in rope_parameters_dict:
rope_parameters_dict.setdefault(
"rope_type", rope_parameters_dict.pop("type")
)
rope_parameters_dict.setdefault("rope_theta", rope_theta)
rope_parameters_dict.setdefault("partial_rotary_factor", partial_rotary_factor)
self.rope_parameters = rope_parameters_dict
self.clamp_temp = clamp_temp
self.cca_time0 = cca_time0
self.cca_time1 = cca_time1
self.swa_layers = swa_layers
self.swa_rotary_base = swa_rotary_base
self._attn_implementation = _attn_implementation
super().__init__(
pad_token_id=pad_token_id,
bos_token_id=bos_token_id,
eos_token_id=eos_token_id,
tie_word_embeddings=self.tie_word_embeddings,
**kwargs,
)
# -- Hybrid model interface (HybridReqToTokenPool / MambaPool) ----------
@property
def full_attention_layer_ids(self) -> List[int]:
if self.zaya_layers:
return [i for i, lt in enumerate(self.zaya_layers) if lt == "a"]
return [i for i in range(self.num_hidden_layers) if i % 2 == 0]
@property
def linear_layer_ids(self) -> List[int]:
return self.full_attention_layer_ids
@property
def mamba_chunk_size(self) -> int:
return 1
@property
def mamba2_cache_params(self) -> Optional[Mamba2CacheParams]:
from sglang.srt.configs.mamba_utils import (
Mamba2CacheParams,
Mamba2StateShape,
mamba2_state_dtype,
)
attn_layer_ids = self.linear_layer_ids
if not attn_layer_ids:
return None
# ``conv[0]`` (conv_qk left padding) is sized per TP rank because CCA
# is head-parallel. ``conv[1]`` (prev_hs) carries the full hidden_state
# and feeds the replicated val_proj1 / val_proj2, so it stays at full
# ``hidden_size`` on every rank.
#
# Use the *global* TP world size -- the same accessor that
# ``ZayaAttention`` / ``CCA`` use to split heads and over which
# ``o_proj`` all-reduces -- so the cache shape and the per-rank
# ``in_out_ch`` stay in lockstep. ZAYA1 asserts the attention-TP group
# equals the global TP group (DP attention is unsupported), so the two
# are always identical in practice.
try:
from sglang.srt.distributed import (
get_tensor_model_parallel_world_size,
)
tp_size = get_tensor_model_parallel_world_size()
except (AssertionError, RuntimeError):
tp_size = 1
in_out_ch_full = (
self.num_attention_heads + self.num_key_value_heads
) * self.head_dim
assert in_out_ch_full % tp_size == 0, (
f"CCA channels ({in_out_ch_full}) must be divisible by TP size "
f"({tp_size}); both num_attention_heads and num_query_groups must "
"be divisible by tp_size for ZAYA1 head-parallel attention."
)
in_out_ch_per_rank = in_out_ch_full // tp_size
total_padding = (self.cca_time0 - 1) + (self.cca_time1 - 1)
shape = Mamba2StateShape(
conv=[
(in_out_ch_per_rank, total_padding),
(self.hidden_size, 1),
],
temporal=(1, 1, 0),
intermediate_size=in_out_ch_per_rank,
conv_dim=in_out_ch_per_rank,
ssm_state_size=0,
num_heads=1,
head_dim=1,
state_size=0,
conv_kernel=total_padding + 1,
)
return Mamba2CacheParams(
shape=shape,
layers=attn_layer_ids,
dtype=mamba2_state_dtype(self),
)
def register_zaya_config() -> None:
"""Register :class:`ZayaConfig` with HuggingFace ``AutoConfig``.
Safe to call multiple times. ``AutoConfig.register`` raises ``ValueError``
on duplicate registration, which is suppressed so importing this module
stays idempotent.
"""
try:
from transformers import AutoConfig
AutoConfig.register(ZayaConfig.model_type, ZayaConfig)
except (ValueError, ImportError):
# Either the installed ``transformers`` does not expose
# ``AutoConfig.register``, or the "zaya" model type is already
# registered – nothing to do in either case.
pass
register_zaya_config()
@@ -52,6 +52,7 @@ from sglang.srt.configs import (
Qwen3_5Config,
Qwen3_5MoeConfig,
Qwen3NextConfig,
ZayaConfig,
)
from sglang.srt.configs.device_config import DeviceConfig
from sglang.srt.configs.linear_attn_model_registry import get_linear_attn_config
@@ -2181,7 +2182,8 @@ class ModelRunner(ModelRunnerKVCacheMixin):
| NemotronHConfig
| Lfm2Config
| Lfm2MoeConfig
| Lfm2VlConfig,
| Lfm2VlConfig
| ZayaConfig,
):
return config
if isinstance(config, NemotronH_Nano_VL_V2_Config):
File diff suppressed because it is too large Load Diff
+7
View File
@@ -2745,6 +2745,13 @@ class ServerArgs:
"as the first layer might not be an attention layer"
)
elif model_arch in ["ZayaForCausalLM"]:
self._handle_mamba_radix_cache(
model_arch=model_arch,
support_mamba_cache=True,
support_mamba_cache_extra_buffer=False,
)
if (
model_arch in ["Qwen3VLForConditionalGeneration"]
and is_hip()
+127
View File
@@ -0,0 +1,127 @@
"""End-to-end server test for Zyphra ZAYA1 (hybrid CCA attention + MoE).
This test boots a real ``Zyphra/ZAYA1-base`` SGLang server via
``popen_launch_server``, sends a handful of completions through the HTTP API,
and finishes with a small MMLU sanity slice.
The test is gated behind ``RUN_ZAYA_E2E=1`` so the registered suite does not
have to download the full ZAYA1-base checkpoint (≈17 GB) on every run; the CI
job that owns this test sets the variable explicitly.
"""
import os
import unittest
from types import SimpleNamespace
from sglang.srt.utils import is_hip, kill_process_tree
from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci
from sglang.test.run_eval import run_eval
from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
# ZAYA1-base is a heavyweight launch (≈120 transformer layers with MoE), so
# the estimated time is set generously to keep the CI scheduler from preempting
# the job before the server finishes warming up.
register_cuda_ci(est_time=420, stage="extra-a", runner_config="1-gpu-large")
register_amd_ci(est_time=420, suite="stage-b-test-1-gpu-large-amd")
_MODEL_PATH = os.environ.get("ZAYA_MODEL_PATH", "Zyphra/ZAYA1-base")
def _zaya_enabled() -> bool:
return os.environ.get("RUN_ZAYA_E2E", "0") == "1"
@unittest.skipUnless(
_zaya_enabled(),
"Set RUN_ZAYA_E2E=1 to enable the ZAYA1 end-to-end server test "
"(requires downloading the model weights).",
)
class TestZayaServer(CustomTestCase):
@classmethod
def setUpClass(cls):
cls.model = _MODEL_PATH
cls.base_url = DEFAULT_URL_FOR_TEST
other_args = [
"--mem-fraction-static",
"0.5",
"--max-running-requests",
"8",
]
if is_hip():
other_args += ["--attention-backend", "triton"]
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=other_args,
)
@classmethod
def tearDownClass(cls):
if getattr(cls, "process", None) is not None:
kill_process_tree(cls.process.pid)
def test_generation_basic(self):
"""Send three prompts through the ``/generate`` endpoint and require
non-empty completions for each."""
import requests
prompts = [
"The capital of France is",
"1 + 2 + 3 + 4 + 5 =",
"Write a haiku about silicon:",
]
for prompt in prompts:
resp = requests.post(
f"{self.base_url}/generate",
json={
"text": prompt,
"sampling_params": {
"temperature": 0.0,
"max_new_tokens": 16,
},
},
timeout=60,
)
self.assertEqual(resp.status_code, 200, resp.text)
data = resp.json()
self.assertIn("text", data, data)
self.assertGreater(len(data["text"].strip()), 0, data)
def test_mmlu_sanity(self):
"""32-example MMLU sanity slice.
ZAYA1-base is a pretrained (non instruction-tuned) checkpoint that
emits long ``<think>…</think>`` reasoning blocks before settling on a
final letter, so ``max_tokens`` must be large enough for the evaluator
to see the chosen answer. The threshold sits just above chance: it is
a regression sanity check rather than a production-quality gate. An
instruction-tuned ZAYA1 checkpoint scores meaningfully higher and
should raise this bound when wired in.
"""
args = SimpleNamespace(
base_url=self.base_url,
model=self.model,
eval_name="mmlu",
num_examples=32,
num_threads=8,
max_tokens=1024,
)
metrics = run_eval(args)
self.assertGreaterEqual(
metrics["score"],
0.30,
f"MMLU sanity below threshold: {metrics}",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,101 @@
"""Unit tests for ``sglang.srt.configs.zaya.ZayaConfig``."""
import unittest
from transformers import AutoConfig
from sglang.srt.configs.zaya import ZayaConfig, register_zaya_config
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 TestZayaConfig(CustomTestCase):
def test_default_fields_match_zaya1_base(self):
"""Defaults reflect Zyphra/ZAYA1-base reference checkpoint."""
cfg = ZayaConfig()
self.assertEqual(cfg.model_type, "zaya")
self.assertEqual(cfg.hidden_size, 2048)
self.assertEqual(cfg.head_dim, 128)
self.assertEqual(cfg.num_attention_heads, 8)
self.assertEqual(cfg.num_query_groups, 2)
self.assertEqual(cfg.num_key_value_heads, 2)
self.assertEqual(cfg.num_experts, 16)
self.assertEqual(cfg.moe_router_topk, 1)
self.assertEqual(cfg.ffn_hidden_size, 4096)
self.assertEqual(cfg.zaya_mlp_expansion, 256)
self.assertEqual(cfg.cca_time0, 2)
self.assertEqual(cfg.cca_time1, 2)
self.assertTrue(cfg.tie_word_embeddings)
self.assertTrue(cfg.zaya_use_eda)
self.assertTrue(cfg.zaya_use_mod)
self.assertTrue(cfg.scale_residual_merge)
self.assertEqual(cfg.partial_rotary_factor, 0.5)
self.assertEqual(cfg.rope_theta, 1_000_000.0)
def test_rope_parameters_auto_derived(self):
"""When neither ``rope_scaling`` nor ``rope_parameters`` is supplied,
both ``rope_theta`` and ``partial_rotary_factor`` should still appear
inside ``rope_parameters`` together with a default ``rope_type``.
"""
cfg = ZayaConfig()
rp = cfg.rope_parameters
self.assertEqual(rp["rope_type"], "default")
self.assertEqual(rp["rope_theta"], 1_000_000.0)
self.assertEqual(rp["partial_rotary_factor"], 0.5)
def test_rope_parameters_explicit_takes_priority(self):
cfg = ZayaConfig(rope_parameters={"type": "linear", "factor": 4.0})
rp = cfg.rope_parameters
# ``type`` is normalized to ``rope_type``.
self.assertEqual(rp["rope_type"], "linear")
self.assertEqual(rp["factor"], 4.0)
# Defaults are still merged in.
self.assertEqual(rp["rope_theta"], 1_000_000.0)
def test_head_dim_required(self):
with self.assertRaises(AssertionError):
ZayaConfig(head_dim=None)
def test_num_query_groups_must_equal_kv_heads(self):
with self.assertRaises(AssertionError):
ZayaConfig(num_query_groups=4, num_key_value_heads=2)
def test_hybrid_model_properties(self):
"""Verify properties required for HybridReqToTokenPool integration."""
cfg = ZayaConfig()
# Default 80 layers: even layers are attention, odd are MoE
self.assertEqual(cfg.full_attention_layer_ids, list(range(0, 80, 2)))
self.assertEqual(cfg.linear_layer_ids, cfg.full_attention_layer_ids)
self.assertEqual(cfg.mamba_chunk_size, 1)
params = cfg.mamba2_cache_params
self.assertIsNotNone(params)
# conv[0] = conv_state: (in_out_ch, total_padding)
in_out_ch = (cfg.num_attention_heads + cfg.num_key_value_heads) * cfg.head_dim
total_padding = (cfg.cca_time0 - 1) + (cfg.cca_time1 - 1)
self.assertEqual(params.shape.conv[0], (in_out_ch, total_padding))
# conv[1] = prev_hs: (hidden_size, 1)
self.assertEqual(params.shape.conv[1], (cfg.hidden_size, 1))
self.assertEqual(params.layers, cfg.linear_layer_ids)
def test_hybrid_model_properties_with_zaya_layers(self):
"""When zaya_layers is provided, layer IDs derive from the list."""
cfg = ZayaConfig(zaya_layers=["a", 16, "a", 16])
self.assertEqual(cfg.num_hidden_layers, 4)
self.assertEqual(cfg.full_attention_layer_ids, [0, 2])
self.assertEqual(cfg.linear_layer_ids, [0, 2])
def test_auto_config_registration_is_idempotent(self):
# Calling the helper twice must not raise even though importing the
# module already registered the model type.
register_zaya_config()
register_zaya_config()
# ``AutoConfig.for_model`` now resolves to ``ZayaConfig``.
cfg = AutoConfig.for_model("zaya")
self.assertIsInstance(cfg, ZayaConfig)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,765 @@
"""Numerical and state-cache correctness tests for the ZAYA1 CCA module.
The CCA per-request conv-state cache must satisfy the following invariants,
which are each exercised by a dedicated test case:
1. A single-chunk extend forward (no prefix) is numerically equivalent to the
reference torch implementation that processes the whole sequence at once.
2. Splitting a sequence into one prefill of ``S0`` tokens and ``S1`` single-
token decode steps produces the same q / k / v tensors as the equivalent
single-chunk run.
3. A batched two-request decode for request 0 yields identical q / k / v to a
single-request decode of request 0 at the same step.
4. Multi-request prefills update only the conv state and ``prev_hs`` slots for
each request and leave unused slots zero.
5. A simulated tensor-parallel (TP=2) CCA produces per-rank q / k / v slices
that match the corresponding head slices of a TP=1 reference, both for
prefill (``_forward_extend``) and for decode (``_forward_decode``).
All tests run on CPU with a tiny configuration so they stay fast and have no
GPU dependency. State is stored in a mock centralized pool that mirrors the
``HybridReqToTokenPool`` / ``MambaPool`` interface used at serving time.
"""
import os
import unittest
from contextlib import contextmanager
from dataclasses import dataclass
from types import SimpleNamespace
from typing import List, Optional
import torch
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=30, suite="base-a-test-cpu")
def _ensure_dist_initialized() -> None:
"""Set up a minimal single-rank gloo distributed environment plus the
SGLang model-parallel groups (TP=1, PP=1, EP=1). The CCA module reads
``get_tensor_model_parallel_rank()`` / ``get_tensor_model_parallel_world_size()``
inside ``__init__`` to size its head-parallel projections, so the world
group and model parallel groups must both be initialized before any CCA
construction.
"""
os.environ.setdefault("MASTER_ADDR", "127.0.0.1")
os.environ.setdefault("MASTER_PORT", "29632")
os.environ.setdefault("RANK", "0")
os.environ.setdefault("WORLD_SIZE", "1")
os.environ.setdefault("LOCAL_RANK", "0")
from sglang.srt.distributed.parallel_state import (
init_distributed_environment,
initialize_model_parallel,
model_parallel_is_initialized,
)
if not torch.distributed.is_initialized():
init_distributed_environment(
world_size=1,
rank=0,
local_rank=0,
backend="gloo",
)
if not model_parallel_is_initialized():
# Pass arguments as kwargs because ``ensure_model_parallel_initialized``
# forwards positional ``backend`` into the ``attention_data_parallel_size``
# slot of ``initialize_model_parallel``, which then explodes on
# ``int // str``. Using kwargs avoids that footgun.
initialize_model_parallel(
tensor_model_parallel_size=1,
expert_model_parallel_size=1,
pipeline_model_parallel_size=1,
backend="gloo",
)
# ---------------------------------------------------------------------------
# Mock centralized pool
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class _MockLayerCache:
conv: List[torch.Tensor]
temporal: torch.Tensor
class _MockReqToTokenPool:
"""Minimal stand-in for ``HybridReqToTokenPool`` providing the two methods
that CCA calls: ``mamba2_layer_cache`` and ``get_mamba_indices``.
For TP-aware tests, ``tp_size`` controls the per-rank ``in_out_ch`` of the
``conv[0]`` state. ``conv[1]`` (prev_hs) is replicated and stays at full
``hidden_size``.
"""
def __init__(self, pool_size: int, cca_config, tp_size: int = 1):
in_out_ch_full = (
cca_config.num_attention_heads + cca_config.num_key_value_heads
) * cca_config.head_dim
assert in_out_ch_full % tp_size == 0
in_out_ch_per_rank = in_out_ch_full // tp_size
total_padding = (cca_config.cca_time0 - 1) + (cca_config.cca_time1 - 1)
num_layers = len(cca_config.linear_layer_ids)
self.conv_state = torch.zeros(
num_layers, pool_size + 1, in_out_ch_per_rank, total_padding
)
self.prev_hs_state = torch.zeros(
num_layers, pool_size + 1, cca_config.hidden_size, 1
)
self.temporal = torch.zeros(num_layers, pool_size + 1, 1, 1, 0)
self._layer_map = {lid: i for i, lid in enumerate(cca_config.linear_layer_ids)}
self._identity_map = torch.arange(pool_size + 1, dtype=torch.int32)
def mamba2_layer_cache(self, layer_id: int):
idx = self._layer_map[layer_id]
return _MockLayerCache(
conv=[self.conv_state[idx], self.prev_hs_state[idx]],
temporal=self.temporal[idx],
)
def get_mamba_indices(self, req_pool_indices: torch.Tensor) -> torch.Tensor:
return req_pool_indices.to(torch.int32)
@contextmanager
def _mock_pool_context(pool: _MockReqToTokenPool):
"""Install a mock ``ForwardContext`` whose ``req_to_token_pool`` is ``pool``."""
from sglang.srt.model_executor.forward_context import (
ForwardContext,
set_forward_context,
)
backend = SimpleNamespace(req_to_token_pool=pool, token_to_kv_pool=None)
ctx = ForwardContext(attn_backend=backend)
prev = set_forward_context(ctx)
try:
yield pool
finally:
set_forward_context(prev)
# ---------------------------------------------------------------------------
# Helper factories
# ---------------------------------------------------------------------------
def _make_forward_batch(
*,
is_decode: bool,
extend_seq_lens_cpu,
extend_prefix_lens_cpu,
req_pool_indices,
input_ids: torch.Tensor,
):
from sglang.srt.model_executor.forward_batch_info import ForwardMode
mode = ForwardMode.DECODE if is_decode else ForwardMode.EXTEND
forward_batch = SimpleNamespace()
forward_batch.forward_mode = mode
forward_batch.input_ids = input_ids
forward_batch.req_pool_indices = torch.as_tensor(
req_pool_indices, dtype=torch.int32
)
forward_batch.extend_seq_lens_cpu = list(extend_seq_lens_cpu)
forward_batch.extend_prefix_lens_cpu = list(extend_prefix_lens_cpu)
return forward_batch
def _make_tiny_config(num_hidden_layers: int = 2):
from sglang.srt.configs.zaya import ZayaConfig
return ZayaConfig(
hidden_size=16,
ffn_hidden_size=32,
num_hidden_layers=num_hidden_layers,
num_experts=2,
num_attention_heads=4,
num_query_groups=2,
num_key_value_heads=2,
head_dim=8,
cca_time0=2,
cca_time1=2,
max_position_embeddings=64,
moe_router_topk=1,
zaya_mlp_expansion=8,
attention_bias=False,
)
def _make_tiny_cca(
seed: int = 0,
tp_rank: Optional[int] = None,
tp_size: Optional[int] = None,
layer_id: int = 0,
config=None,
):
from sglang.srt.models.zaya import CCA
if config is None:
config = _make_tiny_config()
torch.manual_seed(seed)
cca = CCA(
config=config,
cca_num_k_heads=config.num_query_groups,
cca_num_q_heads=config.num_attention_heads,
hidden_size=config.hidden_size,
head_dim=config.head_dim,
cca_time0=config.cca_time0,
cca_time1=config.cca_time1,
layer_id=layer_id,
tp_rank=tp_rank,
tp_size=tp_size,
)
cca.eval()
with torch.no_grad():
for p in cca.parameters():
p.data.normal_(mean=0.0, std=0.05)
cca.temp.data.zero_()
return cca, config
class TestZayaCCA(CustomTestCase):
@classmethod
def setUpClass(cls) -> None:
_ensure_dist_initialized()
def test_single_chunk_matches_reference(self):
"""A single-chunk extend with empty prefix matches the no-state path."""
cca, config = _make_tiny_cca(seed=1)
cca_ref, _ = _make_tiny_cca(seed=1)
with torch.no_grad():
cca_ref.load_state_dict(cca.state_dict())
S = 5
hs = torch.randn(S, cca.hidden_size, dtype=torch.float32) * 0.1
q_ref, k_ref, v_ref = cca_ref._forward_no_state(hs)
pool = _MockReqToTokenPool(pool_size=8, cca_config=config)
fb = _make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S, dtype=torch.int64),
)
with _mock_pool_context(pool):
q, k, v = cca.forward(hs, fb)
torch.testing.assert_close(q, q_ref, atol=1e-5, rtol=1e-5)
torch.testing.assert_close(k, k_ref, atol=1e-5, rtol=1e-5)
torch.testing.assert_close(v, v_ref, atol=1e-5, rtol=1e-5)
def test_prefill_then_decode_matches_full_sequence(self):
"""Prefill(S0) followed by ``S1`` single-token decode steps matches a
one-shot reference over ``S0 + S1`` tokens."""
cca, config = _make_tiny_cca(seed=2)
cca_ref, _ = _make_tiny_cca(seed=2)
with torch.no_grad():
cca_ref.load_state_dict(cca.state_dict())
S0, S1 = 4, 2
S_total = S0 + S1
torch.manual_seed(77)
hs = torch.randn(S_total, cca.hidden_size, dtype=torch.float32) * 0.1
q_ref, k_ref, v_ref = cca_ref._forward_no_state(hs)
pool = _MockReqToTokenPool(pool_size=8, cca_config=config)
with _mock_pool_context(pool):
fb_prefill = _make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S0],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S0, dtype=torch.int64),
)
q0, k0, v0 = cca.forward(hs[:S0], fb_prefill)
q_decodes = [q0]
k_decodes = [k0]
v_decodes = [v0]
for t in range(S1):
fb_decode = _make_forward_batch(
is_decode=True,
extend_seq_lens_cpu=[],
extend_prefix_lens_cpu=[],
req_pool_indices=[0],
input_ids=torch.tensor([0], dtype=torch.int64),
)
qd, kd, vd = cca.forward(hs[S0 + t : S0 + t + 1], fb_decode)
q_decodes.append(qd)
k_decodes.append(kd)
v_decodes.append(vd)
q_cat = torch.cat(q_decodes, dim=0)
k_cat = torch.cat(k_decodes, dim=0)
v_cat = torch.cat(v_decodes, dim=0)
torch.testing.assert_close(q_cat, q_ref, atol=1e-4, rtol=1e-4)
torch.testing.assert_close(k_cat, k_ref, atol=1e-4, rtol=1e-4)
torch.testing.assert_close(v_cat, v_ref, atol=1e-4, rtol=1e-4)
def test_batched_decode_matches_single_decode(self):
"""A two-request batched decode of request 0 must produce the same
q / k / v tensors as a single-request decode of request 0."""
cca_single, config = _make_tiny_cca(seed=11)
cca_batched, _ = _make_tiny_cca(seed=11)
with torch.no_grad():
cca_batched.load_state_dict(cca_single.state_dict())
S0 = 4
torch.manual_seed(202)
hs0 = torch.randn(S0, cca_single.hidden_size, dtype=torch.float32) * 0.1
hs1 = torch.randn(S0, cca_single.hidden_size, dtype=torch.float32) * 0.1
decode0 = torch.randn(cca_single.hidden_size, dtype=torch.float32) * 0.1
decode1 = torch.randn(cca_single.hidden_size, dtype=torch.float32) * 0.1
pool_single = _MockReqToTokenPool(pool_size=8, cca_config=config)
with _mock_pool_context(pool_single):
cca_single.forward(
hs0,
_make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S0],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S0, dtype=torch.int64),
),
)
q_solo, k_solo, v_solo = cca_single.forward(
decode0.unsqueeze(0),
_make_forward_batch(
is_decode=True,
extend_seq_lens_cpu=[],
extend_prefix_lens_cpu=[],
req_pool_indices=[0],
input_ids=torch.tensor([0], dtype=torch.int64),
),
)
pool_batched = _MockReqToTokenPool(pool_size=8, cca_config=config)
with _mock_pool_context(pool_batched):
cca_batched.forward(
torch.cat([hs0, hs1], dim=0),
_make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S0, S0],
extend_prefix_lens_cpu=[0, 0],
req_pool_indices=[0, 1],
input_ids=torch.arange(2 * S0, dtype=torch.int64),
),
)
q_batch, k_batch, v_batch = cca_batched.forward(
torch.stack([decode0, decode1], dim=0),
_make_forward_batch(
is_decode=True,
extend_seq_lens_cpu=[],
extend_prefix_lens_cpu=[],
req_pool_indices=[0, 1],
input_ids=torch.tensor([0, 1], dtype=torch.int64),
),
)
torch.testing.assert_close(q_batch[0:1], q_solo, atol=1e-5, rtol=1e-5)
torch.testing.assert_close(k_batch[0:1], k_solo, atol=1e-5, rtol=1e-5)
torch.testing.assert_close(v_batch[0:1], v_solo, atol=1e-5, rtol=1e-5)
def test_two_requests_state_isolation(self):
"""A batched prefill of two requests must update only the requests'
own slots in the centralized pool."""
cca, config = _make_tiny_cca(seed=4)
S0, S1 = 3, 2
hs0 = torch.randn(S0, cca.hidden_size, dtype=torch.float32) * 0.1
hs1 = torch.randn(S1, cca.hidden_size, dtype=torch.float32) * 0.1
hs = torch.cat([hs0, hs1], dim=0)
pool = _MockReqToTokenPool(pool_size=8, cca_config=config)
fb = _make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S0, S1],
extend_prefix_lens_cpu=[0, 0],
req_pool_indices=[2, 5],
input_ids=torch.arange(S0 + S1, dtype=torch.int64),
)
with _mock_pool_context(pool):
cca.forward(hs, fb)
layer_cache = pool.mamba2_layer_cache(0)
conv_state = layer_cache.conv[0]
prev_hs_state = layer_cache.conv[1]
self.assertTrue(torch.any(conv_state[2] != 0))
self.assertTrue(torch.any(conv_state[5] != 0))
torch.testing.assert_close(
prev_hs_state[2].squeeze(-1).to(torch.float32),
hs0[-1].to(torch.float32),
atol=1e-5,
rtol=1e-5,
)
torch.testing.assert_close(
prev_hs_state[5].squeeze(-1).to(torch.float32),
hs1[-1].to(torch.float32),
atol=1e-5,
rtol=1e-5,
)
for idx in (0, 1, 3, 4):
self.assertTrue(torch.all(conv_state[idx] == 0))
self.assertTrue(torch.all(prev_hs_state[idx] == 0))
def test_mamba_indices_resolved_once_per_forward_step(self):
"""The req -> MambaPool-slot mapping is identical for every CCA layer in
a step, so it (and its GPU->CPU ``.tolist()`` sync) must be resolved once
per forward step and shared across layers, not recomputed per layer.
Regression guard for the per-layer mamba-sync fix: two CCA layers driven
by a single ForwardBatch must trigger exactly one ``get_mamba_indices``
lookup and one host materialization for the whole step.
"""
class _CountingPool(_MockReqToTokenPool):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.get_mamba_indices_calls = 0
def get_mamba_indices(self, req_pool_indices):
self.get_mamba_indices_calls += 1
return super().get_mamba_indices(req_pool_indices)
# num_hidden_layers=4 -> CCA (even) layers live at ids 0 and 2.
config = _make_tiny_config(num_hidden_layers=4)
self.assertEqual(config.linear_layer_ids, [0, 2])
cca0, _ = _make_tiny_cca(seed=5, layer_id=0, config=config)
cca2, _ = _make_tiny_cca(seed=6, layer_id=2, config=config)
S = 4
hs = torch.randn(S, config.hidden_size, dtype=torch.float32) * 0.1
def _fresh_fb():
return _make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S, dtype=torch.int64),
)
pool = _CountingPool(pool_size=8, cca_config=config)
with _mock_pool_context(pool):
fb = _fresh_fb()
cca0.forward(hs, fb)
cca2.forward(hs, fb)
# Two CCA layers, one forward step -> one shared lookup, both the
# device tensor and its host mirror memoized on the ForwardBatch.
self.assertEqual(pool.get_mamba_indices_calls, 1)
self.assertTrue(hasattr(fb, "_zaya_mamba_indices"))
self.assertTrue(hasattr(fb, "_zaya_mamba_indices_cpu"))
self.assertEqual(fb._zaya_mamba_indices_cpu, [0])
# A new forward step (fresh ForwardBatch) resolves the mapping again.
cca0.forward(hs, _fresh_fb())
self.assertEqual(pool.get_mamba_indices_calls, 2)
def test_decode_path_does_not_sync_indices_to_host(self):
"""The decode path indexes the pool entirely on-device, so it must not
populate the host-side index cache (keeping it CUDA-graph friendly)."""
cca, config = _make_tiny_cca(seed=7)
pool = _MockReqToTokenPool(pool_size=8, cca_config=config)
with _mock_pool_context(pool):
cca.forward(
torch.randn(3, config.hidden_size, dtype=torch.float32) * 0.1,
_make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[3],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(3, dtype=torch.int64),
),
)
fb_decode = _make_forward_batch(
is_decode=True,
extend_seq_lens_cpu=[],
extend_prefix_lens_cpu=[],
req_pool_indices=[0],
input_ids=torch.tensor([0], dtype=torch.int64),
)
cca.forward(
torch.randn(1, config.hidden_size, dtype=torch.float32) * 0.1,
fb_decode,
)
# Device indices are memoized, but the host ``.tolist()`` mirror is only
# built by the extend path.
self.assertTrue(hasattr(fb_decode, "_zaya_mamba_indices"))
self.assertFalse(hasattr(fb_decode, "_zaya_mamba_indices_cpu"))
class TestZayaCCATensorParallel(CustomTestCase):
"""Head-parallel TP equivalence:
For each TP rank, the CCA's q / k / v output must equal the head slice of
the TP=1 reference's output that corresponds to that rank's heads. This
verifies that the grouped-mean step and ``conv_qk.1`` (groups = num_q_heads
+ num_k_heads) are correctly partitioned across heads with no cross-rank
leakage.
"""
TP_SIZE = 2
@classmethod
def setUpClass(cls) -> None:
_ensure_dist_initialized()
def _slice_full_state_dict_into_rank(self, ref_cca, tp_cca, tp_rank: int):
"""Copy the reference's full weights into the per-rank CCA, using the
per-parameter ``weight_loader`` that the CCA installs on its own
parameters during ``__init__``. This mirrors what
``ZayaForCausalLM.load_weights`` does at serving time and is the
only way TP correctness is exercised end-to-end.
"""
ref_state = dict(ref_cca.state_dict())
from sglang.srt.model_loader.weight_utils import default_weight_loader
with torch.no_grad():
for name, param in tp_cca.named_parameters():
full_weight = ref_state[name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, full_weight)
def _check_per_rank_outputs(
self,
full_q: torch.Tensor,
full_k: torch.Tensor,
full_v: torch.Tensor,
rank_q: torch.Tensor,
rank_k: torch.Tensor,
rank_v: torch.Tensor,
tp_rank: int,
cfg,
):
"""Compare a TP=2 rank's output against the corresponding head slice
of the TP=1 reference output. Q heads and K heads are partitioned
contiguously across ranks: rank ``r`` owns
``[r*Q_per_rank, (r+1)*Q_per_rank)`` for Q and similarly for K.
"""
q_heads_per_rank = cfg.num_attention_heads // self.TP_SIZE
k_heads_per_rank = cfg.num_query_groups // self.TP_SIZE
q_lo, q_hi = tp_rank * q_heads_per_rank, (tp_rank + 1) * q_heads_per_rank
k_lo, k_hi = tp_rank * k_heads_per_rank, (tp_rank + 1) * k_heads_per_rank
torch.testing.assert_close(
rank_q, full_q[:, q_lo:q_hi, :], atol=1e-5, rtol=1e-5
)
torch.testing.assert_close(
rank_k, full_k[:, k_lo:k_hi, :], atol=1e-5, rtol=1e-5
)
torch.testing.assert_close(
rank_v, full_v[:, k_lo:k_hi, :], atol=1e-5, rtol=1e-5
)
def test_tp2_extend_matches_full(self):
"""Single-chunk extend with TP=2 produces the same q / k / v slices
as a TP=1 reference, verified rank-by-rank.
"""
ref_cca, cfg = _make_tiny_cca(seed=21, tp_rank=0, tp_size=1)
S = 6
torch.manual_seed(901)
hs = torch.randn(S, ref_cca.hidden_size, dtype=torch.float32) * 0.1
ref_pool = _MockReqToTokenPool(pool_size=8, cca_config=cfg, tp_size=1)
ref_fb = _make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S, dtype=torch.int64),
)
with _mock_pool_context(ref_pool):
full_q, full_k, full_v = ref_cca.forward(hs, ref_fb)
for tp_rank in range(self.TP_SIZE):
rank_cca, _ = _make_tiny_cca(
seed=21 + tp_rank, tp_rank=tp_rank, tp_size=self.TP_SIZE
)
self._slice_full_state_dict_into_rank(ref_cca, rank_cca, tp_rank)
rank_pool = _MockReqToTokenPool(
pool_size=8, cca_config=cfg, tp_size=self.TP_SIZE
)
rank_fb = _make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S, dtype=torch.int64),
)
with _mock_pool_context(rank_pool):
rank_q, rank_k, rank_v = rank_cca.forward(hs, rank_fb)
self._check_per_rank_outputs(
full_q, full_k, full_v, rank_q, rank_k, rank_v, tp_rank, cfg
)
def test_tp2_decode_matches_full(self):
"""Prefill(S0) + decode(1 token) with TP=2 produces the same q / k / v
slices as a TP=1 reference, verifying that the per-rank conv state
and prev_hs cache (which is replicated on every rank) agree.
"""
ref_cca, cfg = _make_tiny_cca(seed=22, tp_rank=0, tp_size=1)
S0 = 5
torch.manual_seed(902)
hs_prefill = torch.randn(S0, ref_cca.hidden_size, dtype=torch.float32) * 0.1
hs_decode = torch.randn(1, ref_cca.hidden_size, dtype=torch.float32) * 0.1
ref_pool = _MockReqToTokenPool(pool_size=8, cca_config=cfg, tp_size=1)
with _mock_pool_context(ref_pool):
ref_cca.forward(
hs_prefill,
_make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S0],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S0, dtype=torch.int64),
),
)
full_q, full_k, full_v = ref_cca.forward(
hs_decode,
_make_forward_batch(
is_decode=True,
extend_seq_lens_cpu=[],
extend_prefix_lens_cpu=[],
req_pool_indices=[0],
input_ids=torch.tensor([0], dtype=torch.int64),
),
)
for tp_rank in range(self.TP_SIZE):
rank_cca, _ = _make_tiny_cca(
seed=22 + tp_rank, tp_rank=tp_rank, tp_size=self.TP_SIZE
)
self._slice_full_state_dict_into_rank(ref_cca, rank_cca, tp_rank)
rank_pool = _MockReqToTokenPool(
pool_size=8, cca_config=cfg, tp_size=self.TP_SIZE
)
with _mock_pool_context(rank_pool):
rank_cca.forward(
hs_prefill,
_make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S0],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S0, dtype=torch.int64),
),
)
rank_q, rank_k, rank_v = rank_cca.forward(
hs_decode,
_make_forward_batch(
is_decode=True,
extend_seq_lens_cpu=[],
extend_prefix_lens_cpu=[],
req_pool_indices=[0],
input_ids=torch.tensor([0], dtype=torch.int64),
),
)
self._check_per_rank_outputs(
full_q, full_k, full_v, rank_q, rank_k, rank_v, tp_rank, cfg
)
def test_tp2_conv_state_is_per_rank_sliced(self):
"""After a TP=2 prefill, each rank's conv state must equal the head
slice of the TP=1 conv state corresponding to that rank's heads.
"""
ref_cca, cfg = _make_tiny_cca(seed=23, tp_rank=0, tp_size=1)
S = 4
torch.manual_seed(903)
hs = torch.randn(S, ref_cca.hidden_size, dtype=torch.float32) * 0.1
ref_pool = _MockReqToTokenPool(pool_size=4, cca_config=cfg, tp_size=1)
with _mock_pool_context(ref_pool):
ref_cca.forward(
hs,
_make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S, dtype=torch.int64),
),
)
full_state = ref_pool.mamba2_layer_cache(0).conv[0][0] # [in_out_ch_full, pad]
head_dim = cfg.head_dim
num_q_heads_full = cfg.num_attention_heads
num_k_heads_full = cfg.num_query_groups
latent_q_full = num_q_heads_full * head_dim
q_per_rank = num_q_heads_full // self.TP_SIZE
k_per_rank = num_k_heads_full // self.TP_SIZE
for tp_rank in range(self.TP_SIZE):
rank_cca, _ = _make_tiny_cca(
seed=23 + tp_rank, tp_rank=tp_rank, tp_size=self.TP_SIZE
)
self._slice_full_state_dict_into_rank(ref_cca, rank_cca, tp_rank)
rank_pool = _MockReqToTokenPool(
pool_size=4, cca_config=cfg, tp_size=self.TP_SIZE
)
with _mock_pool_context(rank_pool):
rank_cca.forward(
hs,
_make_forward_batch(
is_decode=False,
extend_seq_lens_cpu=[S],
extend_prefix_lens_cpu=[0],
req_pool_indices=[0],
input_ids=torch.arange(S, dtype=torch.int64),
),
)
rank_state = rank_pool.mamba2_layer_cache(0).conv[0][0]
q_lo = tp_rank * q_per_rank * head_dim
q_hi = q_lo + q_per_rank * head_dim
k_lo = latent_q_full + tp_rank * k_per_rank * head_dim
k_hi = k_lo + k_per_rank * head_dim
expected = torch.cat([full_state[q_lo:q_hi], full_state[k_lo:k_hi]], dim=0)
torch.testing.assert_close(rank_state, expected, atol=1e-5, rtol=1e-5)
def test_tp_assertions_reject_indivisible_head_counts(self):
"""The CCA constructor must reject TP sizes that don't evenly divide
both num_q_heads and num_k_heads, since both grouped-mean and
conv_qk.1 require each rank to hold whole K-head groups.
"""
from sglang.srt.models.zaya import CCA
cfg = _make_tiny_config()
# tiny config has num_query_groups=2; TP=4 cannot divide it cleanly.
with self.assertRaises(AssertionError):
CCA(
config=cfg,
cca_num_k_heads=cfg.num_query_groups,
cca_num_q_heads=cfg.num_attention_heads,
hidden_size=cfg.hidden_size,
head_dim=cfg.head_dim,
cca_time0=cfg.cca_time0,
cca_time1=cfg.cca_time1,
layer_id=0,
tp_rank=0,
tp_size=4,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,178 @@
"""Numerical correctness test for ZAYA1 MoE + MOD under TP>1.
Background: the MOD (mixture-of-depths) skip-expert residual blend must be
combined with the experts output on the correct side of the cross-rank
all-reduce. ``mod_out = hidden_states * prob`` is replicated on every TP rank,
so all-reducing it would multiply it by ``tp_size``. The model therefore masks
the *per-rank partial* experts output before the reduce and only adds the
replicated ``mod_out`` afterwards:
sum_r(mask · partial_r) + (1 - mask) · mod_out
= mask · experts_out_full + (1 - mask) · mod_out
This test drives the *real* helpers used by ``ZayaBlock.forward`` --
``mod_premask_experts`` and ``mod_blend`` -- so a regression in that math is
caught. The cross-rank all-reduce is simulated by summing the per-rank partials
(the masks are replicated, so the sum is exact), which keeps the test runnable
on CPU CI without a live ``torch.distributed`` group.
"""
import unittest
import torch
from sglang.srt.models.zaya import mod_blend, mod_premask_experts
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")
def _reference_blend(
hidden_states: torch.Tensor, # [T, H]
probs: torch.Tensor, # [T, 1]
indices: torch.Tensor, # [T, 1]
experts_out_full: torch.Tensor, # [T, H] -- already-reduced full experts output
num_moe_experts: int,
) -> torch.Tensor:
"""Reference: apply the MOD mask to the *full* (already-reduced) experts
output, then add the skip path. Mirrors the intended algebra directly.
"""
mod_mask = (indices != num_moe_experts).to(experts_out_full.dtype)
mod_out = hidden_states * probs
return mod_mask * experts_out_full + (1.0 - mod_mask) * mod_out
def _real_tp_blend(
hidden_states: torch.Tensor,
probs: torch.Tensor,
indices: torch.Tensor,
partial_experts_per_rank: list[torch.Tensor], # one [T, H] per rank
num_moe_experts: int,
) -> torch.Tensor:
"""Production path: ``mod_premask_experts`` per rank -> simulated all-reduce
(sum) -> ``mod_blend``. Uses the exact helpers ``ZayaBlock.forward`` calls.
"""
mod_out = hidden_states * probs
reduced = None
mod_mask = None
for partial in partial_experts_per_rank:
mask, masked = mod_premask_experts(partial, indices, num_moe_experts)
mod_mask = mask
reduced = masked if reduced is None else reduced + masked
return mod_blend(reduced, mod_mask, mod_out)
def _buggy_old_tp_blend(
hidden_states: torch.Tensor,
probs: torch.Tensor,
indices: torch.Tensor,
partial_experts_per_rank: list[torch.Tensor],
num_moe_experts: int,
) -> torch.Tensor:
"""Old, broken sequence: all-reduce the replicated ``mod_out`` (so it gets
scaled by ``tp_size``) then mix. Proves the test catches a regression.
"""
tp_size = len(partial_experts_per_rank)
mod_out_replicated = hidden_states * probs
mod_out_after_allreduce = mod_out_replicated * tp_size # all-reduce of replicated
experts_out_full = torch.stack(partial_experts_per_rank, dim=0).sum(dim=0)
mod_mask = (indices != num_moe_experts).to(experts_out_full.dtype)
return mod_mask * experts_out_full + (1.0 - mod_mask) * mod_out_after_allreduce
class TestZayaMODUnderTP(CustomTestCase):
def _make_partials(self, T: int, H: int, tp_size: int):
torch.manual_seed(31)
experts_out_full = torch.randn(T, H, dtype=torch.float32) * 0.1
# Split into ``tp_size`` random partial tensors that sum to the full output.
partials = []
remaining = experts_out_full.clone()
for _ in range(tp_size - 1):
p = torch.randn_like(remaining) * 0.05
partials.append(p)
remaining = remaining - p
partials.append(remaining)
return experts_out_full, partials
def _make_inputs(self, T: int, H: int, num_experts: int, frac_skip: float):
torch.manual_seed(7)
hidden_states = torch.randn(T, H, dtype=torch.float32)
probs = torch.rand(T, 1, dtype=torch.float32)
# Build indices: with probability ``frac_skip`` mark token as skip-expert.
skip_id = num_experts # MOD uses ``num_moe_experts`` as the skip slot
rand = torch.rand(T, 1)
real = torch.randint(0, num_experts, (T, 1))
indices = torch.where(rand < frac_skip, torch.full_like(real, skip_id), real)
return hidden_states, probs, indices
def test_real_helpers_match_reference_for_tp(self):
"""The real ``mod_premask_experts`` / ``mod_blend`` path must equal the
reference blend for any TP size and any skip fraction.
"""
T, H = 8, 16
num_experts = 4
for tp_size in (2, 4, 8):
for frac_skip in (0.0, 0.5, 1.0):
hidden_states, probs, indices = self._make_inputs(
T, H, num_experts, frac_skip
)
full, partials = self._make_partials(T, H, tp_size)
ref = _reference_blend(hidden_states, probs, indices, full, num_experts)
real = _real_tp_blend(
hidden_states, probs, indices, partials, num_experts
)
torch.testing.assert_close(
real,
ref,
atol=1e-5,
rtol=1e-5,
msg=f"tp_size={tp_size} frac_skip={frac_skip}",
)
def test_premask_zeroes_skip_tokens(self):
"""``mod_premask_experts`` must zero the experts contribution exactly on
skip-routed tokens and pass through real-expert tokens unchanged.
"""
T, H = 6, 8
num_experts = 4
experts_out = torch.randn(T, H, dtype=torch.float32)
# Alternate skip / real tokens.
indices = torch.tensor(
[[num_experts], [0], [num_experts], [1], [num_experts], [2]],
dtype=torch.long,
)
mod_mask, masked = mod_premask_experts(experts_out, indices, num_experts)
skip_rows = indices.squeeze(-1) == num_experts
self.assertTrue(torch.all(masked[skip_rows] == 0))
torch.testing.assert_close(masked[~skip_rows], experts_out[~skip_rows])
# mask is 0 on skip rows, 1 elsewhere.
self.assertTrue(torch.all(mod_mask.squeeze(-1)[skip_rows] == 0))
self.assertTrue(torch.all(mod_mask.squeeze(-1)[~skip_rows] == 1))
def test_old_blend_is_wrong_when_skip_used(self):
"""Sanity: confirm the old (all-reduce mod_out) formula diverges from the
reference so a regression to that behavior would be caught.
"""
T, H = 8, 16
num_experts = 4
tp_size = 4
hidden_states, probs, indices = self._make_inputs(
T, H, num_experts, frac_skip=0.5
)
full, partials = self._make_partials(T, H, tp_size)
ref = _reference_blend(hidden_states, probs, indices, full, num_experts)
buggy = _buggy_old_tp_blend(
hidden_states, probs, indices, partials, num_experts
)
with self.assertRaises(AssertionError):
torch.testing.assert_close(buggy, ref, atol=1e-3, rtol=1e-3)
if __name__ == "__main__":
unittest.main()