完整部署快照

This commit is contained in:
root
2026-09-20 02:20:26 +00:00
commit ce553c0dc8
5836 changed files with 1631123 additions and 0 deletions

View File

@@ -0,0 +1,389 @@
/* Copyright 2026 The xLLM Authors. All Rights Reserved.
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
https://github.com/jd-opensource/xllm/blob/main/LICENSE
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.
==============================================================================*/
// Test suite for layerwise split KV cache sharding (PR #2260).
// Adapted for Iluvatar BI-V100 running Qwen3.6-35B-A3B (Qwen3_5 arch).
//
// Verified hardware:
// 4× BI-V100, Bus-Id 4B-4E, NUMA 1, flat PIX topology (all pairs PIX).
// 32768 MiB HBM each, IX-ML 3.2.3, Driver 3.2.1, CUDA 10.2 (CoreX).
// Warp size: 64 (ivcore architecture).
//
// Model: Qwen3.6-35B-A3B
// num_attention_heads=16, num_key_value_heads=4, head_dim=256
// layer_types: interleaved full_attention + linear_attention
// With TP=4: local_q_heads=4, local_kv_heads=1, GQA=4
// KV cache shape: key=(n,1,32,16,8) value=(n,1,256,16)
#include <gflags/gflags.h>
#include <glog/logging.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <numeric>
#include <string>
#include <vector>
#include "config/ilu_hw_constants.h"
#include "config/parallel_config_layerwise.h"
#include "distributed_runtime/layerwise_split_engine_ext.h"
#include "distributed_runtime/layerwise_split_master.h"
#include "framework/kv_cache/kv_cache_estimation_layerwise.h"
#include "framework/kv_cache/layerwise_split_layout.h"
#include "framework/model/model_args.h"
#include "framework/kv_cache/kv_cache_estimation.h"
#include "framework/kv_cache/ilu_layerwise_layout.h"
#include "framework/parallel_state/mapping_ilu.h"
#include "runtime/worker_layerwise_init.h"
namespace xllm {
namespace {
// Matches verified hardware: 4 BI-V100 cards.
constexpr int32_t kBIV100WorldSize = 4;
// Qwen3.5 model constants.
constexpr int64_t kQwen35KVHeads = ilu_hw::kQwen35NumKVHeads; // 4
constexpr int64_t kQwen35HeadDim = ilu_hw::kQwen35HeadDim; // 256
// Helper: build a realistic Qwen3.5 layer_types vector.
// The actual pattern alternates full_attention and linear_attention.
std::vector<std::string> make_qwen35_layer_types(int64_t num_layers) {
std::vector<std::string> types;
types.reserve(static_cast<size_t>(num_layers));
for (int64_t i = 0; i < num_layers; ++i) {
// Qwen3.5 pattern: every other layer is linear_attention
types.push_back(i % 2 == 0 ? "full_attention" : "linear_attention");
}
return types;
}
// ---------------------------------------------------------------------------
// TC-01 Layerwise KV allocation correctness (Qwen3.5 parameters)
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC01_AllocationCorrectness) {
// 16 full-attention layers (Qwen3.5 has interleaved layers, but the
// IluLayerwiseLayout only tracks full-attention layers).
const int64_t num_full_attn = 16;
std::vector<int64_t> per_layer_heads(num_full_attn, kQwen35KVHeads);
auto layout = compute_ilu_layerwise_layout(
num_full_attn, per_layer_heads, kBIV100WorldSize);
ASSERT_EQ(layout.num_layers(), num_full_attn);
// Qwen3.5 with TP=4: kv_heads=4 = world_size → each rank gets 1 head.
for (int64_t lid = 0; lid < num_full_attn; ++lid) {
const auto& spec = layout.layer_spec(lid);
EXPECT_EQ(static_cast<int32_t>(spec.assigned_ranks.size()),
kBIV100WorldSize);
for (int32_t r = 0; r < kBIV100WorldSize; ++r) {
EXPECT_EQ(layout.heads_for_rank(r, lid), 1)
<< "Each rank should have exactly 1 KV head for Qwen3.5 TP=4";
}
}
// Total heads should match.
int64_t total = 0;
for (int64_t lid = 0; lid < num_full_attn; ++lid)
total += layout.layer_spec(lid).total_heads();
EXPECT_EQ(total, num_full_attn * kQwen35KVHeads);
}
// ---------------------------------------------------------------------------
// TC-02 Memory estimation accuracy
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC02_MemoryEstimation) {
const int64_t num_full_attn = 16;
const int64_t n_blocks = 68837; // from real runtime log
const int64_t block_size = 16; // from real runtime log
const int64_t head_dim = kQwen35HeadDim; // 256
const int64_t max_tokens = 262144;
const int dtype_enum = 5; // float16 (from real runtime)
std::vector<int64_t> per_layer_heads(num_full_attn, kQwen35KVHeads);
auto layout = compute_ilu_layerwise_layout(
num_full_attn, per_layer_heads, kBIV100WorldSize);
auto est = estimate_layerwise_kv_memory(
layout, n_blocks, block_size, head_dim, max_tokens, dtype_enum,
kBIV100WorldSize);
// With uniform sharding (kv_heads == world_size), layerwise split
// doesn't save memory — all ranks own all layers with 1 head each.
// Peak should equal uniform in this case.
EXPECT_EQ(est.peak_per_rank_bytes, est.uniform_per_rank_bytes);
EXPECT_GT(est.peak_per_rank_bytes, 0);
EXPECT_GT(est.average_per_rank_bytes, 0);
// Per-rank breakdown should have exactly 4 entries.
EXPECT_EQ(static_cast<int32_t>(est.per_rank_bytes.size()),
kBIV100WorldSize);
// All ranks should have equal memory (symmetric distribution).
for (int32_t r = 1; r < kBIV100WorldSize; ++r) {
EXPECT_EQ(est.per_rank_bytes[0], est.per_rank_bytes[r]);
}
}
// ---------------------------------------------------------------------------
// TC-03 ILU topology-aware mapping (flat PIX)
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC03_IluTopologyMapping) {
const int64_t num_full_attn = 16;
std::vector<int64_t> per_layer_heads(num_full_attn, kQwen35KVHeads);
auto layout = compute_ilu_layerwise_layout(
num_full_attn, per_layer_heads, kBIV100WorldSize,
IluTopoKind::kFlatPIX);
ASSERT_EQ(layout.num_layers(), num_full_attn);
// All layers must have at least one assigned rank.
for (int64_t lid = 0; lid < num_full_attn; ++lid) {
EXPECT_FALSE(layout.layer_spec(lid).assigned_ranks.empty());
}
// Qwen3.5 kv_heads=4 == world_size=4: all layers on all ranks.
for (int64_t lid = 0; lid < num_full_attn; ++lid) {
for (int32_t r = 0; r < kBIV100WorldSize; ++r) {
EXPECT_TRUE(layout.rank_owns_layer(r, lid));
}
}
}
// ---------------------------------------------------------------------------
// TC-03b ILU topology with fewer heads than ranks (future model)
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC03b_FewerHeadsThanRanks) {
const int64_t num_layers = 16;
// Hypothetical model with 2 KV heads per layer (< world_size=4).
std::vector<int64_t> per_layer_heads(num_layers, 2);
auto layout = compute_ilu_layerwise_layout(
num_layers, per_layer_heads, kBIV100WorldSize,
IluTopoKind::kFlatPIX);
ASSERT_EQ(layout.num_layers(), num_layers);
// Each layer should have exactly 2 assigned ranks.
for (int64_t lid = 0; lid < num_layers; ++lid) {
EXPECT_EQ(static_cast<int64_t>(layout.layer_spec(lid).assigned_ranks.size()), 2);
}
// Round-robin should distribute evenly: 16 layers × 2 ranks = 32
// assignments over 4 ranks → ~8 each.
std::vector<int32_t> rank_count(kBIV100WorldSize, 0);
for (int64_t lid = 0; lid < num_layers; ++lid) {
for (auto r : layout.layer_spec(lid).assigned_ranks) {
rank_count[r]++;
}
}
int32_t min_c = *std::min_element(rank_count.begin(), rank_count.end());
int32_t max_c = *std::max_element(rank_count.begin(), rank_count.end());
EXPECT_LE(max_c - min_c, 1)
<< "Round-robin should balance assignments across flat PIX topology";
}
// ---------------------------------------------------------------------------
// TC-04 Distributed engine layout propagation
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC04_EnginePropagation) {
FLAGS_enable_layerwise_split = true;
std::vector<int64_t> heads(16, kQwen35KVHeads);
auto layout = maybe_compute_layerwise_layout(
16, heads, kBIV100WorldSize);
ASSERT_TRUE(layout.has_value());
EXPECT_EQ(layout->num_layers(), 16);
for (int32_t r = 0; r < kBIV100WorldSize; ++r) {
EXPECT_GT(layout->layers_on_rank(r), 0);
}
FLAGS_enable_layerwise_split = false;
}
// ---------------------------------------------------------------------------
// TC-05 Worker layer_cache_owned computation (Qwen3.5 layer types)
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC05_WorkerLayerCacheOwned) {
const int64_t num_layers = 32;
auto layer_types = make_qwen35_layer_types(num_layers);
// 16 full_attention + 16 linear_attention
// rank=0, split_size=2: full-attn layers at even layer_ids (0,2,4,...30)
// layer_id % 2 == 0 for all of them → all owned by rank 0.
// Use rank=1 to see the split: layer_id%2==1 needed, but full layers are
// at even ids → none owned → 16 linear (always owned) + 0 full = 16 owned.
auto owned_r0 = worker_compute_layer_cache_owned(
layer_types, /*layerwise_split_size=*/2, /*rank=*/0, num_layers);
auto owned_r1 = worker_compute_layer_cache_owned(
layer_types, /*layerwise_split_size=*/2, /*rank=*/1, num_layers);
ASSERT_EQ(static_cast<int64_t>(owned_r0.size()), num_layers);
ASSERT_EQ(static_cast<int64_t>(owned_r1.size()), num_layers);
// Linear-attention layers always owned on both ranks.
for (int64_t i = 1; i < num_layers; i += 2) {
EXPECT_TRUE(owned_r0[static_cast<size_t>(i)]);
EXPECT_TRUE(owned_r1[static_cast<size_t>(i)]);
}
// Full-attention layers: rank 0 owns all (even layer_ids % 2 == 0),
// rank 1 owns none.
int64_t r0_full = 0, r1_full = 0;
for (int64_t i = 0; i < num_layers; i += 2) {
if (owned_r0[static_cast<size_t>(i)]) ++r0_full;
if (owned_r1[static_cast<size_t>(i)]) ++r1_full;
}
EXPECT_EQ(r0_full, 16); // all full-attn layers owned
EXPECT_EQ(r1_full, 0); // none owned
}
// ---------------------------------------------------------------------------
// TC-06 Fallback to uniform when disabled
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC06_FallbackUniform) {
FLAGS_enable_layerwise_split = false;
std::vector<int64_t> heads(16, kQwen35KVHeads);
auto layout = maybe_compute_layerwise_layout(
16, heads, kBIV100WorldSize);
EXPECT_FALSE(layout.has_value());
}
// ---------------------------------------------------------------------------
// TC-06b Worker with split_size=1 (all owned)
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC06b_WorkerNoSplit) {
const int64_t num_layers = 32;
auto layer_types = make_qwen35_layer_types(num_layers);
auto owned = worker_compute_layer_cache_owned(
layer_types, /*layerwise_split_size=*/1, /*rank=*/0, num_layers);
ASSERT_EQ(static_cast<int64_t>(owned.size()), num_layers);
// All layers should be owned when split_size=1.
for (int64_t i = 0; i < num_layers; ++i) {
EXPECT_TRUE(owned[static_cast<size_t>(i)]);
}
}
// ---------------------------------------------------------------------------
// TC-07 Upstream-compatible LayerwiseSplitLayout API
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC07_UpstreamCompatibleLayout) {
const LayerwiseSplitLayout layout(/*enabled=*/true,
/*group_size=*/2,
/*local_rank=*/0);
// Round-robin: layer 0 → rank 0 (owns), layer 1 → rank 1 (not owned)
EXPECT_TRUE(layout.owns(0));
EXPECT_FALSE(layout.owns(1));
EXPECT_TRUE(layout.owns(2));
EXPECT_FALSE(layout.owns(3));
// Disabled layout → everything owned.
const LayerwiseSplitLayout disabled(/*enabled=*/false,
/*group_size=*/2,
/*local_rank=*/0);
EXPECT_TRUE(disabled.owns(0));
EXPECT_TRUE(disabled.owns(1));
}
// ---------------------------------------------------------------------------
// TC-08 build_layer_cache_owned with mixed layer types
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC08_BuildLayerCacheOwned) {
std::vector<std::string> types = {
"full_attention", "linear_attention", "full_attention", "linear_attention",
"full_attention", "linear_attention", "full_attention", "linear_attention",
};
const LayerwiseSplitLayout layout(/*enabled=*/true,
/*group_size=*/2,
/*local_rank=*/0);
ModelArgs args;
args.n_layers(8);
args.layer_types(types);
auto owned = build_layer_cache_owned(args, layout, 8);
ASSERT_EQ(owned.size(), 8u);
// Linear-attention layers (indices 1,3,5,7) → always true.
EXPECT_TRUE(owned[1]);
EXPECT_TRUE(owned[3]);
EXPECT_TRUE(owned[5]);
EXPECT_TRUE(owned[7]);
// Full-attention layers (indices 0,2,4,6):
// Upstream uses absolute layer_id % group_size, NOT full_attn_idx.
// layer 0: 0%2==0 → owned (local_rank=0)
// layer 2: 2%2==0 → owned
// layer 4: 4%2==0 → owned
// layer 6: 6%2==0 → owned
// All even layer_ids are owned by rank 0! This is correct upstream behavior.
EXPECT_TRUE(owned[0]);
EXPECT_TRUE(owned[2]);
EXPECT_TRUE(owned[4]);
EXPECT_TRUE(owned[6]);
}
// ---------------------------------------------------------------------------
// TC-09 Model type validation
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC09_ModelTypeValidation) {
EXPECT_TRUE(is_layerwise_split_supported_model("qwen3_5"));
EXPECT_TRUE(is_layerwise_split_supported_model("qwen3_5_moe_text"));
EXPECT_TRUE(is_layerwise_split_supported_model("deepseek_v32"));
EXPECT_TRUE(is_layerwise_split_supported_model("glm_moe_dsa"));
EXPECT_FALSE(is_layerwise_split_supported_model("llama"));
EXPECT_FALSE(is_layerwise_split_supported_model(""));
}
// ---------------------------------------------------------------------------
// TC-10 Block count estimation with layerwise split
// ---------------------------------------------------------------------------
TEST(LayerwiseSplitKV, TC10_BlockCountEstimation) {
// Qwen3.5 per-layer KV cache per block (TP=4, 1 kv head, head_dim=256):
// key: 1 * 16 * 16 * 8 * 2 = 4096 bytes
// value: 1 * 256 * 16 * 2 = 8192 bytes
// total per layer per block: ~12288 bytes (but varies by layout)
//
// Simplified: use 16384 bytes per layer per block (K+V).
const int64_t per_layer_block_bytes = 16384;
const int64_t scratch_block_bytes = 16384;
const int64_t available_bytes = int64_t{30} * 1024 * 1024 * 1024; // ~30 GiB
auto blocks_split1 = estimate_layerwise_split_block_count(
/*layerwise_split_size=*/1, /*num_full_attn_layers=*/16,
per_layer_block_bytes, scratch_block_bytes, available_bytes);
auto blocks_split2 = estimate_layerwise_split_block_count(
/*layerwise_split_size=*/2, /*num_full_attn_layers=*/16,
per_layer_block_bytes, scratch_block_bytes, available_bytes);
// With split_size=2, each rank owns ~8 layers instead of 16,
// so it should be able to afford more blocks.
EXPECT_GT(blocks_split2, blocks_split1);
EXPECT_GT(blocks_split1, 0);
}
} // namespace
} // namespace xllm

View File

@@ -0,0 +1,482 @@
"""Parallel-layout tests for the Qwen3.5 Python model (DP/EP).
Ported from xLLM upstream commit 78aa2a85 (PR #2258)
Original: tests/python/test_deepseek_v32_parallel.py
Adapted: Qwen3.5 MoeSparseBlock with 256 routed experts + shared expert
Test coverage maps to the issue's test cases:
TC-01 MoE expert routing DP isolation
TC-02 CUDA/ACL graph capture per DP rank
TC-03 DP broadcast/gather correctness
TC-04 Executor DP initialization
TC-05 End-to-end DP parallel test suite
"""
from __future__ import annotations
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
# Insert repo root so "python.*" resolves to project_6/python/*, not stdlib
_repo_root = str(Path(__file__).resolve().parents[2])
if _repo_root not in sys.path:
sys.path.insert(0, _repo_root)
import pytest
import torch
import torch.nn as nn
# Block the repo-root __init__.py from pulling in vllm
sys.modules.setdefault("project_6", MagicMock())
from python.layers.fused_moe import DPAwareMoEMixin # noqa: E402
from python.models.qwen3_5 import ( # noqa: E402
_dp_all_gather,
_dp_all_gather_variable,
configure_dp,
dp_forward_moe_wrapper,
)
from python.model_executor.runners.decode_cuda_graph import ( # noqa: E402
DecodeCudaGraphRunner,
_decode_bucket,
_decode_graph_buckets,
)
from python.model_executor.runners.decode_acl_graph import ( # noqa: E402
DecodeAclGraphRunner,
)
from python.model_executor.executor import ModelExecutor # noqa: E402
from python.attention.backend import ( # noqa: E402
DPBackendConfig, create_attention_backend, register_backend,
)
# ---------------------------------------------------------------------------
# Mock MoeSparseBlock for testing
# ---------------------------------------------------------------------------
class MockMoeSparseBlock(nn.Module):
"""Minimal mock of Qwen3_5MoeSparseBlock for DP testing."""
def __init__(self, hidden_size: int = 64, num_experts: int = 256):
super().__init__()
self.hidden_size = hidden_size
self.num_experts = num_experts
self.dp_size = 1
self.dp_rank = 0
# Dummy weight so next(model.parameters()) works
self.dummy = nn.Parameter(torch.zeros(1))
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
"""Identity forward — just returns input (MoE logic mocked)."""
return hidden_states
def _make_mock_moe(dp_size: int = 1, dp_rank: int = 0) -> MockMoeSparseBlock:
moe = MockMoeSparseBlock()
configure_dp(moe, dp_size, dp_rank)
return moe
def _mock_metadata(
dp_token_counts=(4,),
is_prefill=False,
is_chunked_prefill=False,
dp_is_decode=None,
execution_state=None,
):
metadata = SimpleNamespace(
dp_token_counts=dp_token_counts,
is_prefill=is_prefill,
is_chunked_prefill=is_chunked_prefill,
execution_state=execution_state,
)
if dp_is_decode is not None:
metadata.dp_is_decode = dp_is_decode
return metadata
# ---------------------------------------------------------------------------
# TC-01: MoE expert routing DP isolation
# ---------------------------------------------------------------------------
class TestMoEDPIsolation:
"""Verify that DP replicas route experts independently."""
def test_dp1_no_gather(self):
"""With dp_size=1, no all-gather should occur."""
moe = _make_mock_moe(dp_size=1)
hidden = torch.randn(4, 64)
metadata = _mock_metadata(dp_token_counts=(4,))
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
assert result.shape == hidden.shape
def test_dp2_calls_gather(self):
"""With dp_size=2, all-gather should be invoked."""
moe = _make_mock_moe(dp_size=2, dp_rank=0)
hidden = torch.randn(3, 64)
metadata = _mock_metadata(
dp_token_counts=(3, 4), execution_state="graph"
)
with patch(
"python.models.qwen3_5._dp_all_gather"
) as mock_gather:
mock_gather.side_effect = lambda x, **kw: x.repeat(
kw.get("world_size", 1), *([1] * (x.dim() - 1))
)
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
mock_gather.assert_called_once()
call_kwargs = mock_gather.call_args[1]
assert call_kwargs["dim"] == 0
assert call_kwargs["world_size"] == 2
assert call_kwargs["group_name"] == "dp"
def test_dp2_different_inputs_route_independently(self):
"""Different DP replicas with different inputs produce different
routing decisions (verified by output shapes being correct)."""
moe_r0 = _make_mock_moe(dp_size=2, dp_rank=0)
moe_r1 = _make_mock_moe(dp_size=2, dp_rank=1)
hidden_r0 = torch.randn(3, 64)
hidden_r1 = torch.randn(4, 64)
metadata = _mock_metadata(
dp_token_counts=(3, 4), dp_is_decode=(1, 1)
)
# Rank 0
result_r0 = dp_forward_moe_wrapper(
moe_r0, hidden_r0, moe_r0.forward, metadata
)
assert result_r0.shape[0] == 3
# Rank 1
result_r1 = dp_forward_moe_wrapper(
moe_r1, hidden_r1, moe_r1.forward, metadata
)
assert result_r1.shape[0] == 4
# ---------------------------------------------------------------------------
# TC-02: CUDA/ACL graph capture per DP rank
# ---------------------------------------------------------------------------
class TestCudaGraphDPCapture:
"""CUDA graph bucket computation and graph key with DP."""
def test_bucket_dp1(self):
buckets = _decode_graph_buckets(32, dp_size=1)
assert 1 in buckets
assert buckets[-1] == 32
def test_bucket_dp2_halves_capacity(self):
buckets = _decode_graph_buckets(32, dp_size=2)
# max_local_batch = ceil(32/2) = 16
assert buckets[-1] <= 16
def test_graph_key_dp1(self):
runner = DecodeCudaGraphRunner(
nn.Linear(1, 1), torch.device("cpu"), max_batch=16
)
input_ids = torch.zeros(4, dtype=torch.int32)
key = runner.graph_key(input_ids)
assert key is not None
padded, counts = key
assert padded == _decode_bucket(4)
assert counts == (padded,)
def test_graph_key_dp2(self):
runner = DecodeCudaGraphRunner(
nn.Linear(1, 1), torch.device("cpu"), max_batch=32,
dp_size=2, dp_rank=0
)
input_ids = torch.zeros(4, dtype=torch.int32)
key = runner.graph_key(
input_ids,
dp_token_counts=(4, 3),
dp_is_decode=(1, 1),
)
assert key is not None
padded, counts = key
assert len(counts) == 2
assert counts[0] == counts[1] == padded
def test_graph_key_dp2_exceeds_capacity_returns_none(self):
runner = DecodeCudaGraphRunner(
nn.Linear(1, 1), torch.device("cpu"), max_batch=8,
dp_size=2, dp_rank=0
)
input_ids = torch.zeros(100, dtype=torch.int32)
key = runner.graph_key(
input_ids,
dp_token_counts=(100, 100),
dp_is_decode=(1, 1),
)
assert key is None
def test_graph_key_dp_mismatched_counts_raises(self):
runner = DecodeCudaGraphRunner(
nn.Linear(1, 1), torch.device("cpu"), max_batch=32,
dp_size=2, dp_rank=0
)
input_ids = torch.zeros(4, dtype=torch.int32)
with pytest.raises(RuntimeError, match="dp_token_counts"):
runner.graph_key(input_ids, dp_token_counts=(4,))
class TestAclGraphDPCapture:
"""ACL graph runner DP validation."""
def test_acl_dp2_init(self):
runner = DecodeAclGraphRunner(
nn.Linear(1, 1), torch.device("cpu"), max_batch=32,
dp_size=2, dp_rank=0
)
assert runner.dp_size == 2
assert runner.dp_rank == 0
assert runner.max_batch == 16 # ceil(32/2)
def test_acl_dp_validate_wrong_counts_raises(self):
runner = DecodeAclGraphRunner(
nn.Linear(1, 1), torch.device("cpu"), max_batch=32,
dp_size=2, dp_rank=0
)
with pytest.raises(RuntimeError, match="dp_token_counts"):
runner._validate_dp_token_counts((4,))
def test_acl_dp1_validate_accepts_none(self):
runner = DecodeAclGraphRunner(
nn.Linear(1, 1), torch.device("cpu"), max_batch=32,
dp_size=1, dp_rank=0
)
# dp_size=1: validation is a no-op
runner._validate_dp_token_counts(None)
# ---------------------------------------------------------------------------
# TC-03: DP broadcast/gather correctness
# ---------------------------------------------------------------------------
class TestDPBroadcastGather:
"""Verify that gather + scatter preserves local token identity."""
def test_padded_gather_rank0_recovers_local(self):
"""dp_rank=0: after padded gather → slice, output shape matches input."""
moe = _make_mock_moe(dp_size=2, dp_rank=0)
hidden = torch.randn(3, 64)
metadata = _mock_metadata(
dp_token_counts=(3, 4), execution_state="graph"
)
with patch("python.models.qwen3_5._dp_all_gather") as mock_g:
# Simulate all_gather: pad to 4, gather 2 replicas → [8, 64]
mock_g.side_effect = lambda x, **kw: x.repeat(
kw.get("world_size", 1), *([1] * (x.dim() - 1))
)
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
# dp_rank=0, padded=4: narrow(0, 0, 3) → [3, 64]
assert result.shape[0] == 3
def test_padded_gather_rank1_recovers_local(self):
"""dp_rank=1: output is sliced from the second replica's region."""
moe = _make_mock_moe(dp_size=2, dp_rank=1)
hidden = torch.randn(4, 64)
metadata = _mock_metadata(
dp_token_counts=(3, 4), execution_state="graph"
)
with patch("python.models.qwen3_5._dp_all_gather") as mock_g:
mock_g.side_effect = lambda x, **kw: x.repeat(
kw.get("world_size", 1), *([1] * (x.dim() - 1))
)
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
# dp_rank=1, padded=4: narrow(0, 4, 4) → [4, 64]
assert result.shape[0] == 4
def test_compact_gather_rank0(self):
"""Eager decode path uses compact gather; rank 0 gets first slice."""
moe = _make_mock_moe(dp_size=2, dp_rank=0)
hidden = torch.randn(3, 64)
compact = torch.randn(7, 64) # 3 + 4 tokens
metadata = _mock_metadata(
dp_token_counts=(3, 4), dp_is_decode=(1, 1)
)
with patch("python.models.qwen3_5._dp_all_gather_variable") as mock_gv:
mock_gv.return_value = compact
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
mock_gv.assert_called_once()
# dp_rank=0: offset=0, narrow(0, 0, 3)
assert result.shape[0] == 3
def test_compact_gather_rank1(self):
"""Eager decode path: rank 1 gets second slice."""
moe = _make_mock_moe(dp_size=2, dp_rank=1)
hidden = torch.randn(4, 64)
compact = torch.randn(7, 64)
metadata = _mock_metadata(
dp_token_counts=(3, 4), dp_is_decode=(1, 1)
)
with patch("python.models.qwen3_5._dp_all_gather_variable") as mock_gv:
mock_gv.return_value = compact
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
# dp_rank=1: offset=sum([3])=3, narrow(0, 3, 4)
assert result.shape[0] == 4
# ---------------------------------------------------------------------------
# TC-04: Executor DP initialization
# ---------------------------------------------------------------------------
class TestExecutorDPInit:
"""Verify ModelExecutor reads and validates DP config."""
def _make_model(self):
return nn.Linear(10, 10)
def test_dp_size_stored(self):
model = self._make_model()
executor = ModelExecutor(model, {"dp_size": 2, "dp_rank": 0}, 32)
assert executor.dp_size == 2
def test_dp1_default(self):
model = self._make_model()
executor = ModelExecutor(model, {}, 32)
assert executor.dp_size == 1
def test_dp_with_unsupported_backend_raises(self):
model = self._make_model()
with pytest.raises(NotImplementedError, match="data parallel"):
ModelExecutor(
model,
{"dp_size": 2, "dp_rank": 0, "python_graph_backend": "inductor"},
32,
)
def test_dp_with_cudagraphs_accepted(self):
model = self._make_model()
executor = ModelExecutor(
model,
{"dp_size": 2, "dp_rank": 0, "python_graph_backend": "cudagraphs"},
32,
)
assert executor.decode_graph_runner is not None
def test_dp_with_aclgraph_accepted(self):
model = self._make_model()
executor = ModelExecutor(
model,
{"dp_size": 2, "dp_rank": 0, "python_graph_backend": "aclgraph"},
32,
)
assert executor.decode_graph_runner is not None
# ---------------------------------------------------------------------------
# TC-05: End-to-end DP parallel test suite
# ---------------------------------------------------------------------------
class TestEndToEndDP:
"""Integration tests combining executor + MoE + graph runner."""
def test_2way_dp_moe_shapes(self):
"""2-way DP: both ranks produce correct output shapes."""
for rank in (0, 1):
moe = _make_mock_moe(dp_size=2, dp_rank=rank)
local_tokens = 3 if rank == 0 else 4
hidden = torch.randn(local_tokens, 64)
metadata = _mock_metadata(
dp_token_counts=(3, 4), dp_is_decode=(1, 1)
)
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
assert result.shape[0] == local_tokens
def test_4way_dp_moe_shapes(self):
"""4-way DP: all ranks produce correct output shapes."""
counts = (2, 3, 4, 5)
for rank in range(4):
moe = _make_mock_moe(dp_size=4, dp_rank=rank)
hidden = torch.randn(counts[rank], 64)
metadata = _mock_metadata(
dp_token_counts=counts, dp_is_decode=(1, 1, 1, 1)
)
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
assert result.shape[0] == counts[rank]
def test_dp_plus_tp_cuda_graph_buckets(self):
"""DP=2, TP=4 on 8 devices: graph buckets respect DP-reduced capacity."""
# max_batch=64 across 2 DP replicas → 32 per replica
buckets = _decode_graph_buckets(64, dp_size=2)
assert buckets[-1] <= 32
def test_varying_batch_sizes_padded_path(self):
"""Different batch sizes per DP rank use padded gather."""
moe = _make_mock_moe(dp_size=2, dp_rank=0)
for local_size in (1, 5, 8, 16):
other_size = local_size + 2
hidden = torch.randn(local_size, 64)
metadata = _mock_metadata(
dp_token_counts=(local_size, other_size),
execution_state="graph",
)
with patch("python.models.qwen3_5._dp_all_gather") as mock_g:
mock_g.side_effect = lambda x, **kw: x.repeat(
kw.get("world_size", 1), *([1] * (x.dim() - 1))
)
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
assert result.shape[0] == local_size
# ---------------------------------------------------------------------------
# TC-07: Existing tests unbroken (sanity)
# ---------------------------------------------------------------------------
class TestBackwardCompat:
"""Verify dp_size=1 (default) doesn't change existing behaviour."""
def test_dp1_identity(self):
moe = _make_mock_moe(dp_size=1)
hidden = torch.randn(8, 64)
metadata = _mock_metadata(dp_token_counts=(8,))
result = dp_forward_moe_wrapper(
moe, hidden, moe.forward, metadata
)
assert torch.equal(result, hidden)
def test_executor_dp1_no_graph_runner(self):
model = nn.Linear(10, 10)
executor = ModelExecutor(model, {}, 32)
assert executor.decode_graph_runner is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])