[kv-shard 2/4] Sharded pools (#37615)
Co-authored-by: Zhangheng <hzh0425@apache.org>
This commit is contained in:
@@ -22,6 +22,10 @@ from sglang.kernels.ops.kvcache.trtllm_mha_page_table import (
|
||||
)
|
||||
from sglang.srt.configs.model_config import AttentionArch
|
||||
from sglang.srt.layers.attention.base_attn_backend import AttentionBackend
|
||||
from sglang.srt.layers.attention.kv_shard_hooks import (
|
||||
get_kv_shard_pool,
|
||||
prepare_kv_shard_forward,
|
||||
)
|
||||
from sglang.srt.layers.attention.verify_mask import VerifyMask, maybe_create_verify_mask
|
||||
from sglang.srt.layers.cp.base import CPAttentionBackendKind, get_cp_strategy
|
||||
from sglang.srt.layers.cp.utils import is_cp_active
|
||||
@@ -46,6 +50,7 @@ from sglang.srt.utils.common import get_device_capability
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.layers.radix_attention import RadixAttention
|
||||
from sglang.srt.mem_cache.page_interleave_pool import PageInterleaveKVPoolMixin
|
||||
from sglang.srt.model_executor.model_runner import ModelRunner
|
||||
|
||||
from sgl_kernel import merge_state_v2
|
||||
@@ -145,6 +150,10 @@ class FlashAttentionBackend(AttentionBackend):
|
||||
needs_cpu_seq_lens: bool = False
|
||||
supports_ragged_verify_graph: bool = True
|
||||
|
||||
# Set from the pool type in __init__; the class default keeps the extend
|
||||
# metadata guard readable on instances built with __new__ (test stubs).
|
||||
_kv_shard_pool: Optional[PageInterleaveKVPoolMixin] = None
|
||||
|
||||
# Chunked-prefix attention reads the stable ForwardBatch cu-seqlens and
|
||||
# KV-index buffers directly, so it needs no backend-private replay state.
|
||||
supports_full_cuda_graph_chunked_prefix = True
|
||||
@@ -205,6 +214,12 @@ class FlashAttentionBackend(AttentionBackend):
|
||||
and model_runner.token_to_kv_pool.swa_layer_nums > 0
|
||||
)
|
||||
|
||||
self._kv_shard_pool = get_kv_shard_pool(self.token_to_kv_pool)
|
||||
# begin_shard_extend builds the owner-major gather plan from host-side
|
||||
# prefix/final lengths. Normal FA3 metadata is device-only, so opt the
|
||||
# sharded variant back into FutureMap's CPU mirror publication.
|
||||
self.needs_cpu_seq_lens = self._kv_shard_pool is not None
|
||||
|
||||
self.topk = get_spec().speculative_eagle_topk or 0
|
||||
self.speculative_num_steps = speculative_num_steps
|
||||
self.speculative_num_draft_tokens = get_spec().speculative_num_draft_tokens
|
||||
@@ -1154,6 +1169,23 @@ class FlashAttentionBackend(AttentionBackend):
|
||||
)
|
||||
)
|
||||
|
||||
# Logical-page KV sharding: capture the batch's gather plan and swap the
|
||||
# page table to scratch rows. During a sharded extend, attention reads
|
||||
# the assembled [prefix | chunk] scratch, never the striped pool rows;
|
||||
# the plan capture also kicks the first layer's prefix gather.
|
||||
#
|
||||
# Runs after KVIndexTranslator and before the `// page_size` reduction.
|
||||
# The unified-memory UnifiedKVPool and page-interleaved pools are
|
||||
# alternatives, so at most one translation fires.
|
||||
if self._kv_shard_pool is not None and prepare_kv_shard_forward(
|
||||
self._kv_shard_pool,
|
||||
self.req_to_token,
|
||||
forward_batch,
|
||||
):
|
||||
metadata.page_table = self._kv_shard_pool.translate_loc_to_scratch(
|
||||
metadata.page_table
|
||||
).to(torch.int32)
|
||||
|
||||
# Convert the page table to a strided format which is needed by FA3 API
|
||||
if self.page_size > 1 and not _unified_read:
|
||||
self.strided_indices = torch.arange(
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Copyright 2023-2026 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.
|
||||
# ==============================================================================
|
||||
|
||||
"""Logical-page KV-shard helpers shared by attention backends.
|
||||
|
||||
Page-interleaved pools build one gather plan per extend batch. Keeping pool
|
||||
detection and that begin/end lifecycle here prevents each compatible attention
|
||||
backend from implementing a subtly different version of the contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.mem_cache.page_interleave_pool import PageInterleaveKVPoolMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.model_executor.forward_batch_info import ForwardBatch
|
||||
|
||||
|
||||
def get_kv_shard_pool(token_to_kv_pool) -> Optional[PageInterleaveKVPoolMixin]:
|
||||
"""Return the page-interleaved pool, if logical-page sharding is active."""
|
||||
return (
|
||||
token_to_kv_pool
|
||||
if isinstance(token_to_kv_pool, PageInterleaveKVPoolMixin)
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def prepare_kv_shard_forward(
|
||||
pool: PageInterleaveKVPoolMixin,
|
||||
req_to_token: torch.Tensor,
|
||||
forward_batch: ForwardBatch,
|
||||
) -> bool:
|
||||
"""Update the pool's gather plan and report whether this is an extend."""
|
||||
if not forward_batch.forward_mode.is_extend_or_draft_extend_or_mixed():
|
||||
pool.end_shard_extend()
|
||||
return False
|
||||
|
||||
req_pool_indices = forward_batch.req_pool_indices
|
||||
prefix_lens = forward_batch.extend_prefix_lens_cpu
|
||||
seq_lens = forward_batch.seq_lens_cpu
|
||||
if req_pool_indices is None or prefix_lens is None or seq_lens is None:
|
||||
raise RuntimeError(
|
||||
"KV-sharded attention requires request indices and CPU length metadata"
|
||||
)
|
||||
|
||||
pool.begin_shard_extend(
|
||||
req_to_token,
|
||||
req_pool_indices,
|
||||
prefix_lens,
|
||||
seq_lens,
|
||||
)
|
||||
return True
|
||||
@@ -50,6 +50,10 @@ from sglang.srt.layers.attention.flashinfer_mla_backend import (
|
||||
FlashInferMLAAttnBackend,
|
||||
FlashInferMLAMultiStepDraftBackend,
|
||||
)
|
||||
from sglang.srt.layers.attention.kv_shard_hooks import (
|
||||
get_kv_shard_pool,
|
||||
prepare_kv_shard_forward,
|
||||
)
|
||||
from sglang.srt.layers.attention.verify_mask import VerifyMask, maybe_create_verify_mask
|
||||
from sglang.srt.layers.dcp.layout import get_dcp_lens
|
||||
from sglang.srt.layers.logits_processor import get_in_autotune_dummy_run
|
||||
@@ -239,6 +243,8 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
||||
self.q_data_type = model_runner.dtype
|
||||
self.page_size = model_runner.page_size
|
||||
self.req_to_token = model_runner.req_to_token_pool.req_to_token
|
||||
self._kv_shard_pool = get_kv_shard_pool(model_runner.token_to_kv_pool)
|
||||
self.needs_cpu_seq_lens |= self._kv_shard_pool is not None
|
||||
|
||||
# Workspace allocation
|
||||
self.workspace_size = DEFAULT_WORKSPACE_SIZE_MB * 1024 * 1024
|
||||
@@ -806,6 +812,13 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
||||
|
||||
def init_forward_metadata(self, forward_batch: ForwardBatch):
|
||||
"""Initialize the metadata for a forward pass."""
|
||||
if self._kv_shard_pool is not None:
|
||||
prepare_kv_shard_forward(
|
||||
self._kv_shard_pool,
|
||||
self.req_to_token,
|
||||
forward_batch,
|
||||
)
|
||||
|
||||
self._decode_kernel_loc = None
|
||||
# Delegate to parent for non-decode modes.
|
||||
if (
|
||||
|
||||
@@ -23,8 +23,8 @@ sees only its own physical pages; the boundary is the pure bijection below.
|
||||
The shard group is the group across which KV storage is replicated today and
|
||||
therefore can be striped without extra compute-time communication:
|
||||
|
||||
- GQA/MHA models: the **attention CP group** — prefill CP already allgathers
|
||||
the full chunk's K/V to every CP rank (``cp_allgather_and_save_kv_cache``).
|
||||
- GQA/MHA models: the **attention CP group** — the prefill CP strategy
|
||||
materializes the full chunk's K/V on every CP rank before the pool write.
|
||||
- MLA models: the **attention TP group** — the latent KV projection is
|
||||
``ReplicatedLinear``, so every attn-TP rank computes identical latent KV.
|
||||
"""
|
||||
@@ -32,10 +32,16 @@ therefore can be striped without extra compute-time communication:
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import msgspec
|
||||
import torch
|
||||
|
||||
from sglang.srt.runtime_context import get_parallel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.distributed.parallel_state import GroupCoordinator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -84,3 +90,24 @@ class PageInterleavePlacement:
|
||||
def filter_local(self, loc: torch.Tensor, rank: int) -> torch.Tensor:
|
||||
"""Logical slots -> this rank's physical pool rows, order-preserving."""
|
||||
return self.local_index(loc[self.local_mask(loc, rank)])
|
||||
|
||||
|
||||
def get_kv_shard_group(use_mla_backend: bool) -> GroupCoordinator:
|
||||
"""The group KV pages are striped across — the axis that replicates KV
|
||||
at rest, chosen by topology:
|
||||
|
||||
- An active attention-CP group takes precedence: prefill CP replicates
|
||||
KV storage across CP ranks for every attention type (GQA via the
|
||||
full-chunk allgather, MLA via the CP latent-KV rebuild).
|
||||
- Without CP, MLA latent KV is still replicated across attention-TP
|
||||
(ReplicatedLinear projection), so the attn-TP group is the shard axis.
|
||||
- GQA without CP has no replicated axis (KV is head-sharded across TP);
|
||||
the returned trivial CP group has world_size 1, which disables
|
||||
sharding in get_kv_shard_group_info.
|
||||
"""
|
||||
cp_group = get_parallel().attn_cp_group
|
||||
if cp_group.world_size > 1:
|
||||
return cp_group
|
||||
if use_mla_backend:
|
||||
return get_parallel().attn_tp_group
|
||||
return cp_group
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
# Copyright 2023-2026 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.
|
||||
# ==============================================================================
|
||||
|
||||
"""KV pools striped at logical-page granularity across a shard group.
|
||||
|
||||
Per-layer pool tensors keep the stock shape and size — identical rows on every
|
||||
rank; only the written rows differ: each rank persists the rows it owns under
|
||||
the pure placement bijection (``mem_cache/page_interleave.py``).
|
||||
|
||||
Central consequence for the forward pass: during a sharded prefill, extend
|
||||
attention never reads the KV pool — a rank's pool holds only its stripe of
|
||||
the prefix *and* of the current chunk. Attention reads a per-layer assembled
|
||||
scratch slot laid out ``[prefix region | chunk region | trash page]``:
|
||||
|
||||
- The prefix is NCCL-allgathered from the shard group into the slot one layer
|
||||
ahead, on a dedicated side stream with a dedicated ``PyNcclCommunicator``
|
||||
ahead. Under rotated
|
||||
owner-classed allocation the owners of a cached prefix's pages are exactly
|
||||
cyclic, so per-rank owned counts differ by <= 1: each rank sends its own
|
||||
prefix pages in local-page order, padded to ``ceil(prefix_pages / N)`` pages
|
||||
with the (never referenced) trash page — a *regular* in-place allgather,
|
||||
owner-major output, no reorder pass.
|
||||
- The chunk region is staged locally on the compute stream where the write
|
||||
path already holds the full chunk (GQA CP allgathers K/V before the pool
|
||||
write; MLA latent is replicated across the shard group at write time).
|
||||
- The trash page absorbs padded/dummy locations (the reserved logical pages
|
||||
covering loc < N*ps, and any location outside the current batch's plan).
|
||||
|
||||
Scratch addressing goes through a per-batch ``logical page -> scratch
|
||||
page`` lookup (``_page_pos``, one int32 per logical page, rebuilt in
|
||||
``begin_shard_extend``): purely local, mirrored by construction, and valid
|
||||
for any consumer index vector (attention page tables, MLA prefix
|
||||
``kv_indices``), not just whole-prefix position arithmetic.
|
||||
|
||||
Layer-ahead pipelining needs no model changes: acquiring layer ``l``'s slot
|
||||
for reading kicks layer ``l+1``'s gather. Reads happen in SPMD lockstep on
|
||||
every rank, so the collective order is symmetric by construction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import nullcontext
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.distributed.device_communicators.pynccl import PyNcclCommunicator
|
||||
from sglang.srt.mem_cache.memory_pool import (
|
||||
GPU_MEMORY_TYPE_KV_CACHE,
|
||||
MHATokenToKVPool,
|
||||
MLATokenToKVPool,
|
||||
RadixAttention,
|
||||
unwrap_write_loc,
|
||||
)
|
||||
from sglang.srt.mem_cache.page_interleave import (
|
||||
PageInterleavePlacement,
|
||||
PageShardSpec,
|
||||
)
|
||||
from sglang.srt.mem_cache.utils import (
|
||||
get_mla_kv_buffer_triton,
|
||||
set_mla_kv_buffer_triton,
|
||||
)
|
||||
from sglang.srt.utils import ceil_div, get_bool_env_var
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sglang.srt.distributed.parallel_state import GroupCoordinator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ScratchSlot:
|
||||
"""One half of the double buffer: scratch tensors + ready event.
|
||||
|
||||
``resident_key`` identifies what the slot currently holds — ``(layer_id,
|
||||
epoch)`` — so repeated reads of the same layer skip the re-gather and a
|
||||
new batch (epoch bump) retires stale residency without any invalidation
|
||||
walk.
|
||||
"""
|
||||
|
||||
def __init__(self, tensors: Dict[str, torch.Tensor], device_module):
|
||||
self.tensors = tensors
|
||||
self.ready = device_module.Event()
|
||||
self.resident_key: Optional[Tuple[int, int]] = None
|
||||
|
||||
|
||||
class PageInterleaveKVPoolMixin:
|
||||
"""Shard-generic state and mechanics; mixed into concrete pools below.
|
||||
|
||||
Subclasses call ``_init_page_shard_state`` after the base pool has created
|
||||
its buffers, and implement ``_scratch_tensor_specs`` (per-slot tensors) and
|
||||
``_gather_pairs`` (pool buffer -> scratch tensor pairs of one layer).
|
||||
"""
|
||||
|
||||
# ---- init ---------------------------------------------------------------
|
||||
|
||||
def _init_page_shard_state(
|
||||
self, shard_spec: PageShardSpec, shard_group: GroupCoordinator
|
||||
):
|
||||
spec = shard_spec
|
||||
assert spec.shard_size > 1, "page-interleave sharding needs shard_size > 1"
|
||||
assert spec.shard_size == shard_group.world_size
|
||||
assert spec.shard_rank == shard_group.rank_in_group
|
||||
assert spec.page_size == self.page_size
|
||||
# The prefix region must fit N * ceil(prefix_pages / N) pages for any
|
||||
# prefix, i.e. be a multiple of the full-group span; the chunk region
|
||||
# is per-page.
|
||||
assert spec.max_prefix_tokens % spec.logical_page_size == 0
|
||||
assert spec.chunk_tokens % spec.page_size == 0
|
||||
|
||||
self.shard_spec = spec
|
||||
self.placement = PageInterleavePlacement(spec)
|
||||
self.shard_rank = spec.shard_rank
|
||||
self.shard_size = spec.shard_size
|
||||
self.device_module = torch.get_device_module(self.device)
|
||||
|
||||
# Dedicated communicator + stream so the layer-ahead gathers never
|
||||
# interleave with the group's main collectives.
|
||||
self.kv_gather_comm: PyNcclCommunicator = PyNcclCommunicator(
|
||||
group=shard_group.cpu_group, device=shard_group.device
|
||||
)
|
||||
self.kv_gather_stream = self.device_module.Stream()
|
||||
|
||||
# Scratch: [prefix | chunk | trash page], double-buffered.
|
||||
scratch_rows = spec.max_prefix_tokens + spec.chunk_tokens + spec.page_size
|
||||
self._chunk_base = spec.max_prefix_tokens
|
||||
self._trash_base = spec.max_prefix_tokens + spec.chunk_tokens
|
||||
with self.memory_saver_adapter.region(GPU_MEMORY_TYPE_KV_CACHE):
|
||||
with (
|
||||
torch.cuda.use_mem_pool(self.custom_mem_pool)
|
||||
if self.custom_mem_pool
|
||||
else nullcontext()
|
||||
):
|
||||
self._slots = [
|
||||
_ScratchSlot(
|
||||
self._scratch_tensor_specs(scratch_rows), self.device_module
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
# Per-batch absolute scratch-page index of every logical page.
|
||||
# Unplanned pages initially target the trash page. Logical page
|
||||
# ids run [0, N * (size/ps + 1)) — N per physical page of one
|
||||
# rank's pool incl. the reserved padded page.
|
||||
self._page_pos = torch.full(
|
||||
(spec.shard_size * (self.size // spec.page_size + 2),),
|
||||
self._trash_base // spec.page_size,
|
||||
dtype=torch.int32,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
self._epoch = 0
|
||||
self._shard_extend_active = False
|
||||
self._block_pages = 0
|
||||
# Strictly larger than any local physical page id: the owner-major
|
||||
# sort key of logical page l is (l % N) * stride + l // N.
|
||||
self._local_page_stride = self.size // spec.page_size + 2
|
||||
self._debug_plan_checks = get_bool_env_var("SGLANG_DEBUG_MEMORY_POOL")
|
||||
self._send_rows: Optional[torch.Tensor] = None
|
||||
self._write_plan_key = None
|
||||
self._write_plan: Optional[Tuple[torch.Tensor, torch.Tensor]] = None
|
||||
self._translate_cache: Dict[Tuple[int, int], torch.Tensor] = {}
|
||||
|
||||
logger.info(
|
||||
"Page-interleave KV sharding enabled: shard_rank=%d shard_size=%d "
|
||||
"page_size=%d scratch_rows=%d x2",
|
||||
self.shard_rank,
|
||||
self.shard_size,
|
||||
spec.page_size,
|
||||
scratch_rows,
|
||||
)
|
||||
|
||||
def set_kv_buffer_prefix_valid(self, *args, **kwargs):
|
||||
raise NotImplementedError(
|
||||
"prefix-valid commit is unsupported under logical-page KV sharding "
|
||||
"(it writes pool rows directly, bypassing the ownership filter)"
|
||||
)
|
||||
|
||||
# ---- subclass hooks -------------------------------------------------------
|
||||
|
||||
def _scratch_tensor_specs(self, rows: int) -> Dict[str, torch.Tensor]:
|
||||
raise NotImplementedError
|
||||
|
||||
def _gather_pairs(self, local_layer: int) -> List[Tuple[torch.Tensor, str]]:
|
||||
"""(pool buffer of ``local_layer``, scratch tensor name) pairs."""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- per-batch plan -------------------------------------------------------
|
||||
|
||||
def begin_shard_extend(
|
||||
self,
|
||||
req_to_token: torch.Tensor,
|
||||
req_pool_indices: torch.Tensor,
|
||||
prefix_lens_cpu: List[int],
|
||||
seq_lens_cpu: List[int],
|
||||
) -> None:
|
||||
"""Capture the batch's gather plan and kick the first layer's gather.
|
||||
|
||||
Called once per scheduled extend batch, at attention-metadata build
|
||||
time (after ``req_to_token`` holds the batch's allocation). All inputs
|
||||
are mirrored across ranks, so every rank derives the identical plan.
|
||||
"""
|
||||
spec = self.shard_spec
|
||||
ps, shard_size = spec.page_size, spec.shard_size
|
||||
|
||||
# Per-request page samples. One logical page per position-page
|
||||
# (whole-page draws + ps-aligned chunk starts), so stride-ps sampling
|
||||
# enumerates each request's pages in position order — no run
|
||||
# collapsing. block_pages (the padded allgather block) is the
|
||||
# sync-free host bound sum_i ceil(K_i / N): each request's prefix is
|
||||
# ONE cyclic rotation run (graft-declined inserts + per-request
|
||||
# bases), so a rank owns at most ceil(K_i / N) of its pages, and the
|
||||
# cross-request dedup below only shrinks per-rank counts.
|
||||
empty = torch.empty((0,), dtype=torch.int64, device=self.device)
|
||||
prefix_parts = []
|
||||
chunk_parts = []
|
||||
block_pages = 0
|
||||
for i in range(len(prefix_lens_cpu)):
|
||||
prefix_len, seq_len = int(prefix_lens_cpu[i]), int(seq_lens_cpu[i])
|
||||
assert prefix_len % ps == 0, (
|
||||
f"sharded prefill requires physical-page-aligned prefixes "
|
||||
f"(the radix-tree match quantum), got prefix_len="
|
||||
f"{prefix_len}, page_size={ps}"
|
||||
)
|
||||
row = req_to_token[req_pool_indices[i]]
|
||||
if prefix_len:
|
||||
pages_i = row[:prefix_len:ps].long() // ps
|
||||
prefix_parts.append(pages_i)
|
||||
block_pages += ceil_div(prefix_len // ps, shard_size)
|
||||
if self._debug_plan_checks:
|
||||
# Owner-congruence: each request's prefix owners must be
|
||||
# exactly cyclic — the block bound above relies on it. A
|
||||
# broken rotation would overflow a rank's block and the
|
||||
# translation would silently address the wrong scratch
|
||||
# rows. Fail loud instead (debug mode syncs).
|
||||
owners = pages_i % shard_size
|
||||
expected = (
|
||||
int(owners[0])
|
||||
+ torch.arange(
|
||||
owners.numel(), dtype=torch.int64, device=self.device
|
||||
)
|
||||
) % shard_size
|
||||
assert torch.equal(owners, expected), (
|
||||
f"sharded prefix owners of request {i} are not "
|
||||
f"cyclic — rotation base out of sync "
|
||||
f"(owners[:16]={owners[:16].tolist()})"
|
||||
)
|
||||
if seq_len > prefix_len:
|
||||
chunk_parts.append(row[prefix_len:seq_len:ps].long() // ps)
|
||||
|
||||
# A prefix page shared by several requests gathers into ONE scratch
|
||||
# slot (torch.unique sorts — deterministic, mirrored); chunk pages
|
||||
# are per-request fresh allocations, disjoint by construction.
|
||||
prefix_pages = torch.unique(torch.cat(prefix_parts)) if prefix_parts else empty
|
||||
chunk_pages = torch.cat(chunk_parts) if chunk_parts else empty
|
||||
n_prefix = prefix_pages.numel()
|
||||
n_chunk = chunk_pages.numel()
|
||||
n_prefix_slots = shard_size * block_pages
|
||||
assert n_prefix_slots * ps <= spec.max_prefix_tokens, (
|
||||
f"prefix ({n_prefix} pages, padded gather span "
|
||||
f"{n_prefix_slots * ps} tokens) exceeds the scratch prefix "
|
||||
f"capacity ({spec.max_prefix_tokens}) — the PrefillAdder scratch "
|
||||
f"reservation should have deferred this batch"
|
||||
)
|
||||
assert n_chunk * ps <= spec.chunk_tokens, (
|
||||
f"chunk ({n_chunk * ps} tokens) exceeds the scratch chunk "
|
||||
f"capacity ({spec.chunk_tokens})"
|
||||
)
|
||||
|
||||
self._page_pos.fill_(self._trash_base // ps)
|
||||
if n_prefix:
|
||||
# Owner-major slot assignment: sort the batch's unique pages by
|
||||
# (owner, local page) so rank r's pages are contiguous at
|
||||
# r * block_pages in local-page order — the same order rank r
|
||||
# packs its send block below, on every rank (mirrored).
|
||||
owners = prefix_pages % shard_size
|
||||
local_pages = prefix_pages // shard_size
|
||||
order = torch.argsort(owners * self._local_page_stride + local_pages)
|
||||
sorted_pages = prefix_pages[order]
|
||||
sorted_owners = owners[order]
|
||||
counts = torch.bincount(sorted_owners, minlength=shard_size)
|
||||
starts = torch.cumsum(counts, 0) - counts
|
||||
within = (
|
||||
torch.arange(n_prefix, dtype=torch.int64, device=self.device)
|
||||
- starts[sorted_owners]
|
||||
)
|
||||
self._page_pos[sorted_pages] = (sorted_owners * block_pages + within).to(
|
||||
torch.int32
|
||||
)
|
||||
if n_chunk:
|
||||
self._page_pos[chunk_pages] = torch.arange(
|
||||
self._chunk_base // ps,
|
||||
self._chunk_base // ps + n_chunk,
|
||||
dtype=torch.int32,
|
||||
device=self.device,
|
||||
)
|
||||
|
||||
self._epoch += 1
|
||||
self._block_pages = block_pages
|
||||
self._shard_extend_active = True
|
||||
self._translate_cache.clear()
|
||||
if block_pages:
|
||||
# This rank's owned pages in slot order (already local-sorted) —
|
||||
# logical page l is its local physical page l // N — padded to
|
||||
# the regular allgather block with the reserved trash page
|
||||
# (local page 0), whose rows the plan never references.
|
||||
own_local = sorted_pages[sorted_owners == self.shard_rank] // shard_size
|
||||
n_pad = block_pages - own_local.numel()
|
||||
if n_pad:
|
||||
own_local = torch.cat(
|
||||
[
|
||||
own_local,
|
||||
torch.zeros((n_pad,), dtype=torch.int64, device=self.device),
|
||||
]
|
||||
)
|
||||
self._send_rows = (
|
||||
own_local[:, None] * ps
|
||||
+ torch.arange(ps, dtype=torch.int64, device=self.device)
|
||||
).reshape(-1)
|
||||
self._prefetch_layer(self.start_layer)
|
||||
else:
|
||||
self._send_rows = None
|
||||
|
||||
def end_shard_extend(self) -> None:
|
||||
"""Mark no sharded extend in flight (non-extend forward modes)."""
|
||||
self._shard_extend_active = False
|
||||
|
||||
# ---- translation ----------------------------------------------------------
|
||||
|
||||
def translate_loc_to_scratch(self, loc: torch.Tensor) -> torch.Tensor:
|
||||
"""Logical token slots -> rows of the current batch's scratch slots.
|
||||
|
||||
``_page_pos`` holds every logical page's absolute scratch page slot:
|
||||
prefix pages owner-major in ``[0, N * block_pages)`` (rank ``l % N``'s
|
||||
pages contiguous at ``rank * block_pages``, local-page order), chunk
|
||||
pages in batch-sequence order at ``_chunk_base``, and anything outside
|
||||
the plan at the trash page.
|
||||
"""
|
||||
ps = self.shard_spec.page_size
|
||||
loc64 = loc.long()
|
||||
scratch_page = self._page_pos[loc64 // ps].long()
|
||||
return scratch_page * ps + loc64 % ps
|
||||
|
||||
# ---- the layer-ahead gather -------------------------------------------------
|
||||
|
||||
def _prefetch_layer(self, layer_id: int) -> None:
|
||||
"""Kick the allgather assembling ``layer_id``'s prefix into its slot,
|
||||
on the gather stream. Idempotent per ``(layer_id, epoch)``."""
|
||||
local_layer = layer_id - self.start_layer
|
||||
if local_layer >= self.layer_num:
|
||||
return
|
||||
slot = self._slots[layer_id % 2]
|
||||
key = (layer_id, self._epoch)
|
||||
if slot.resident_key == key:
|
||||
return
|
||||
block = self._block_pages * self.shard_spec.page_size
|
||||
# Order the gather after all prior compute-stream work: the
|
||||
# previous tenant's reads (attention of layer_id - 2) and the pool
|
||||
# writes that produced the prefix rows.
|
||||
self.kv_gather_stream.wait_stream(self.device_module.current_stream())
|
||||
with self.device_module.stream(self.kv_gather_stream):
|
||||
for pool_buf, name in self._gather_pairs(local_layer):
|
||||
scratch = slot.tensors[name]
|
||||
send = scratch[self.shard_rank * block : (self.shard_rank + 1) * block]
|
||||
torch.index_select(pool_buf, 0, self._send_rows, out=send)
|
||||
# In-place regular allgather: send is exactly the rank's
|
||||
# block of the output, every rank contributes `block` rows.
|
||||
with self.kv_gather_comm.change_state(enable=True):
|
||||
self.kv_gather_comm.all_gather(
|
||||
scratch[: self.shard_size * block], send
|
||||
)
|
||||
slot.ready.record(self.kv_gather_stream)
|
||||
slot.resident_key = key
|
||||
|
||||
def _acquire_slot_for_read(self, layer_id: int) -> _ScratchSlot:
|
||||
"""Block the compute stream on the slot's ready event (a no-op when
|
||||
the layer-ahead gather already landed) and kick the next layer's
|
||||
gather. A residency miss is a caller bug — the batch plan captured by
|
||||
``begin_shard_extend`` must have prefetched this layer."""
|
||||
assert self._shard_extend_active, (
|
||||
"sharded pool read outside an active sharded extend batch "
|
||||
"(begin_shard_extend not called?)"
|
||||
)
|
||||
slot = self._slots[layer_id % 2]
|
||||
if self._block_pages:
|
||||
assert slot.resident_key == (layer_id, self._epoch), (
|
||||
f"prefix scratch miss for layer {layer_id} "
|
||||
f"(resident={slot.resident_key}, epoch={self._epoch})"
|
||||
)
|
||||
self.device_module.current_stream().wait_event(slot.ready)
|
||||
self._prefetch_layer(layer_id + 1)
|
||||
return slot
|
||||
|
||||
def _translate_loc_cached(self, loc: torch.Tensor) -> torch.Tensor:
|
||||
"""Per-batch memoized ``translate_loc_to_scratch`` for the per-layer
|
||||
callers: the same loc tensors (``out_cache_loc`` at every layer's
|
||||
``set_kv_buffer``; the prefix ``kv_indices`` at every layer's
|
||||
``get_mla_kv_buffer``) arrive at all layers of a forward, and the
|
||||
plan is frozen per batch — so each distinct loc tensor is translated
|
||||
once per batch instead of once per layer. ``begin_shard_extend`` clears
|
||||
the cache when it installs a new plan."""
|
||||
key = (loc.data_ptr(), loc.numel())
|
||||
rows = self._translate_cache.get(key)
|
||||
if rows is None:
|
||||
rows = self.translate_loc_to_scratch(loc)
|
||||
self._translate_cache[key] = rows
|
||||
return rows
|
||||
|
||||
# ---- write plan (owner filter), cached per (loc tensor, epoch) --------------
|
||||
|
||||
def _get_write_plan(self, loc: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
"""(owned positions into ``loc``, their local physical rows).
|
||||
|
||||
The same ``out_cache_loc`` tensor is passed for every layer of a
|
||||
forward, so the owner filter is computed once per batch, not per
|
||||
layer.
|
||||
"""
|
||||
key = (loc.data_ptr(), loc.numel(), self._epoch)
|
||||
if self._write_plan_key != key:
|
||||
owned_idx = torch.nonzero(
|
||||
self.placement.local_mask(loc, self.shard_rank)
|
||||
).squeeze(1)
|
||||
local_rows = self.placement.local_index(loc[owned_idx])
|
||||
self._write_plan_key = key
|
||||
self._write_plan = (owned_idx, local_rows)
|
||||
return self._write_plan
|
||||
|
||||
|
||||
class PageInterleaveMHATokenToKVPool(PageInterleaveKVPoolMixin, MHATokenToKVPool):
|
||||
"""MHA/GQA pool striped across the attention CP group.
|
||||
|
||||
The write path relies on the prefill CP strategy materializing the full
|
||||
chunk's K/V before ``set_kv_buffer`` receives the full (unsplit)
|
||||
``out_cache_loc`` on every rank. Each rank persists only its stripe and
|
||||
stages the full chunk into the current layer's scratch chunk region
|
||||
(extend attention reads prefix *and* chunk through the scratch page table).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
shard_spec: PageShardSpec,
|
||||
shard_group: GroupCoordinator,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
assert not self.use_hnd, "KV sharding supports the NHD layout only"
|
||||
assert self.kv_cache_layout == "nhd", (
|
||||
f"KV sharding supports the NHD layout only, got {self.kv_cache_layout}"
|
||||
)
|
||||
assert not self.post_capture_active
|
||||
self._init_page_shard_state(shard_spec, shard_group)
|
||||
|
||||
def _scratch_tensor_specs(self, rows: int) -> Dict[str, torch.Tensor]:
|
||||
return {
|
||||
"k": torch.zeros(
|
||||
(rows, self.head_num, self.head_dim),
|
||||
dtype=self.store_dtype,
|
||||
device=self.device,
|
||||
),
|
||||
"v": torch.zeros(
|
||||
(rows, self.head_num, self.v_head_dim),
|
||||
dtype=self.store_dtype,
|
||||
device=self.device,
|
||||
),
|
||||
}
|
||||
|
||||
def _gather_pairs(self, local_layer: int):
|
||||
return [
|
||||
(self.k_buffer[local_layer], "k"),
|
||||
(self.v_buffer[local_layer], "v"),
|
||||
]
|
||||
|
||||
def set_kv_buffer(
|
||||
self,
|
||||
layer: RadixAttention,
|
||||
loc_info,
|
||||
cache_k: torch.Tensor,
|
||||
cache_v: torch.Tensor,
|
||||
k_scale: Optional[float] = None,
|
||||
v_scale: Optional[float] = None,
|
||||
layer_id_override: Optional[int] = None,
|
||||
dcp_kv_mask: Optional[torch.Tensor] = None,
|
||||
):
|
||||
assert dcp_kv_mask is None, "DCP is mutually exclusive with KV sharding"
|
||||
loc, _, _ = unwrap_write_loc(loc_info)
|
||||
layer_id = (
|
||||
layer_id_override if layer_id_override is not None else layer.layer_id
|
||||
)
|
||||
|
||||
if cache_k.dtype != self.dtype:
|
||||
if k_scale is not None:
|
||||
cache_k = cache_k / k_scale
|
||||
if v_scale is not None:
|
||||
cache_v = cache_v / v_scale
|
||||
cache_k = cache_k.to(self.dtype)
|
||||
cache_v = cache_v.to(self.dtype)
|
||||
if self.store_dtype != self.dtype:
|
||||
cache_k = cache_k.view(self.store_dtype)
|
||||
cache_v = cache_v.view(self.store_dtype)
|
||||
|
||||
if self._shard_extend_active:
|
||||
# Stage the full chunk into this layer's slot on the compute
|
||||
# stream: ordered before this layer's attention read for free
|
||||
# and disjoint from the gather stream, which only writes the
|
||||
# prefix region. Padded locations translate to the trash page.
|
||||
slot = self._slots[layer_id % 2]
|
||||
rows = self._translate_loc_cached(loc)
|
||||
slot.tensors["k"][rows] = cache_k
|
||||
slot.tensors["v"][rows] = cache_v
|
||||
|
||||
owned_idx, local_rows = self._get_write_plan(loc)
|
||||
if owned_idx.numel() > 0:
|
||||
self._store_kv_layer(
|
||||
layer_id - self.start_layer,
|
||||
local_rows,
|
||||
cache_k.index_select(0, owned_idx),
|
||||
cache_v.index_select(0, owned_idx),
|
||||
)
|
||||
|
||||
def get_key_buffer(self, layer_id: int):
|
||||
if self._shard_extend_active:
|
||||
slot = self._acquire_slot_for_read(layer_id)
|
||||
k = slot.tensors["k"]
|
||||
return k.view(self.dtype) if self.store_dtype != self.dtype else k
|
||||
return super().get_key_buffer(layer_id)
|
||||
|
||||
def get_value_buffer(self, layer_id: int):
|
||||
if self._shard_extend_active:
|
||||
slot = self._acquire_slot_for_read(layer_id)
|
||||
v = slot.tensors["v"]
|
||||
return v.view(self.dtype) if self.store_dtype != self.dtype else v
|
||||
return super().get_value_buffer(layer_id)
|
||||
|
||||
|
||||
class PageInterleaveMLATokenToKVPool(PageInterleaveKVPoolMixin, MLATokenToKVPool):
|
||||
"""MLA latent pool striped across its shard group (the attn-CP group when
|
||||
prefill CP is active, the attn-TP group otherwise — see
|
||||
``page_interleave.get_kv_shard_group``).
|
||||
|
||||
The latent KV reaching the write path is identical on every shard-group
|
||||
rank (``ReplicatedLinear`` projection across attn-TP; the prefill CP
|
||||
strategy's latent-KV rebuild across attn-CP), so the write filter needs no
|
||||
compute-time communication. Read consumers, all served from the assembled
|
||||
scratch: the chunked-prefix MHA path fetches prefix rows through
|
||||
``get_mla_kv_buffer``; the absorbed-MLA and one-shot paths read
|
||||
``get_key_buffer``/``get_value_buffer`` through the translated page
|
||||
table. The current chunk is staged into the slot at write time so the
|
||||
page-table consumers cover ``[prefix | chunk]`` uniformly.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
shard_spec: PageShardSpec,
|
||||
shard_group: GroupCoordinator,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
assert not self.use_dsa, "DSA models are not supported by KV sharding yet"
|
||||
self._init_page_shard_state(shard_spec, shard_group)
|
||||
|
||||
def _scratch_tensor_specs(self, rows: int) -> Dict[str, torch.Tensor]:
|
||||
return {
|
||||
"kv": torch.zeros(
|
||||
(rows, 1, self.kv_cache_dim),
|
||||
dtype=self.store_dtype,
|
||||
device=self.device,
|
||||
),
|
||||
}
|
||||
|
||||
def _gather_pairs(self, local_layer: int):
|
||||
return [(self.kv_buffer[local_layer], "kv")]
|
||||
|
||||
def _scratch_kv(self, slot: _ScratchSlot) -> torch.Tensor:
|
||||
kv = slot.tensors["kv"]
|
||||
if self.store_dtype != self.dtype:
|
||||
return kv.view(self.dtype)
|
||||
return kv
|
||||
|
||||
def set_kv_buffer(
|
||||
self,
|
||||
layer: RadixAttention,
|
||||
loc_info,
|
||||
cache_k: torch.Tensor,
|
||||
cache_v: torch.Tensor,
|
||||
layer_id_override: Optional[int] = None,
|
||||
):
|
||||
loc, _, _ = unwrap_write_loc(loc_info)
|
||||
layer_id = (
|
||||
layer_id_override if layer_id_override is not None else layer.layer_id
|
||||
)
|
||||
if self._shard_extend_active:
|
||||
# Stage the full chunk into this layer's slot (compute
|
||||
# stream): the absorbed / one-shot readers cover the current
|
||||
# chunk through the translated page table too.
|
||||
slot = self._slots[layer_id % 2]
|
||||
rows = self._translate_loc_cached(loc)
|
||||
staged_k = cache_k
|
||||
if staged_k.dtype != self.dtype:
|
||||
staged_k = staged_k.to(self.dtype)
|
||||
self._scratch_kv(slot)[rows] = staged_k
|
||||
owned_idx, local_rows = self._get_write_plan(loc)
|
||||
if owned_idx.numel() == 0:
|
||||
return
|
||||
super().set_kv_buffer(
|
||||
layer,
|
||||
local_rows,
|
||||
cache_k.index_select(0, owned_idx),
|
||||
cache_v,
|
||||
layer_id_override=layer_id_override,
|
||||
)
|
||||
|
||||
def set_mla_kv_buffer(
|
||||
self,
|
||||
layer: RadixAttention,
|
||||
loc: torch.Tensor,
|
||||
cache_k_nope: torch.Tensor,
|
||||
cache_k_rope: torch.Tensor,
|
||||
layer_id_override: Optional[int] = None,
|
||||
):
|
||||
layer_id = (
|
||||
layer_id_override if layer_id_override is not None else layer.layer_id
|
||||
)
|
||||
if self._shard_extend_active:
|
||||
slot = self._slots[layer_id % 2]
|
||||
rows = self._translate_loc_cached(loc)
|
||||
staged_nope, staged_rope = cache_k_nope, cache_k_rope
|
||||
if staged_nope.dtype != self.dtype:
|
||||
staged_nope = staged_nope.to(self.dtype)
|
||||
staged_rope = staged_rope.to(self.dtype)
|
||||
if self.store_dtype != self.dtype:
|
||||
staged_nope = staged_nope.view(self.store_dtype)
|
||||
staged_rope = staged_rope.view(self.store_dtype)
|
||||
set_mla_kv_buffer_triton(slot.tensors["kv"], rows, staged_nope, staged_rope)
|
||||
owned_idx, local_rows = self._get_write_plan(loc)
|
||||
if owned_idx.numel() == 0:
|
||||
return
|
||||
super().set_mla_kv_buffer(
|
||||
layer,
|
||||
local_rows,
|
||||
cache_k_nope.index_select(0, owned_idx),
|
||||
cache_k_rope.index_select(0, owned_idx),
|
||||
layer_id_override=layer_id_override,
|
||||
)
|
||||
|
||||
def get_kv_buffer_shape(self):
|
||||
# Shape probes (e.g. the eager runner's DCP-metadata prep) must not
|
||||
# route through the attention getters below — they may run before
|
||||
# this batch's metadata build while the previous batch's shard-extend
|
||||
# flag is still set.
|
||||
k = self.kv_buffer[0]
|
||||
return k.shape, k[..., : self.kv_lora_rank].shape
|
||||
|
||||
def get_key_buffer(self, layer_id: int):
|
||||
# During a sharded extend the pool holds only this rank's stripe;
|
||||
# page-table readers (absorbed MLA, incl. the CP zigzag wrapper) get
|
||||
# the assembled scratch — metadata.page_table is already translated
|
||||
# to scratch rows.
|
||||
if self._shard_extend_active:
|
||||
return self._scratch_kv(self._acquire_slot_for_read(layer_id))
|
||||
return super().get_key_buffer(layer_id)
|
||||
|
||||
def get_value_buffer(self, layer_id: int):
|
||||
if self._shard_extend_active:
|
||||
kv = self._scratch_kv(self._acquire_slot_for_read(layer_id))
|
||||
return kv[..., : self.kv_lora_rank]
|
||||
return super().get_value_buffer(layer_id)
|
||||
|
||||
def get_mla_kv_buffer(
|
||||
self,
|
||||
layer: RadixAttention,
|
||||
loc: torch.Tensor,
|
||||
dst_dtype: Optional[torch.dtype] = None,
|
||||
):
|
||||
slot = self._acquire_slot_for_read(layer.layer_id)
|
||||
rows = self._translate_loc_cached(loc)
|
||||
kv_buffer = slot.tensors["kv"]
|
||||
if self.store_dtype != self.dtype:
|
||||
kv_buffer = kv_buffer.view(self.dtype)
|
||||
dst_dtype = dst_dtype or self.dtype
|
||||
cache_k_nope = torch.empty(
|
||||
(loc.shape[0], 1, self.kv_lora_rank),
|
||||
dtype=dst_dtype,
|
||||
device=kv_buffer.device,
|
||||
)
|
||||
cache_k_rope = torch.empty(
|
||||
(loc.shape[0], 1, self.qk_rope_head_dim),
|
||||
dtype=dst_dtype,
|
||||
device=kv_buffer.device,
|
||||
)
|
||||
get_mla_kv_buffer_triton(kv_buffer, rows, cache_k_nope, cache_k_rope)
|
||||
return cache_k_nope, cache_k_rope
|
||||
+19
-2
@@ -25,6 +25,7 @@ import torch
|
||||
from sglang.srt.configs.model_config import AttentionArch
|
||||
from sglang.srt.layers.attention.flashattention_backend import FlashAttentionBackend
|
||||
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator
|
||||
from sglang.srt.mem_cache.page_interleave_pool import PageInterleaveKVPoolMixin
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
@@ -32,7 +33,13 @@ from sglang.test.test_utils import CustomTestCase
|
||||
register_cuda_ci(est_time=10, stage="base-b", runner_config="1-gpu-small")
|
||||
|
||||
|
||||
def _make_prefill_aware_swa_runner(*, pool_size: int, max_context_len: int = 64):
|
||||
class _FakeShardPool(PageInterleaveKVPoolMixin):
|
||||
pass
|
||||
|
||||
|
||||
def _make_prefill_aware_swa_runner(
|
||||
*, pool_size: int, max_context_len: int = 64, token_to_kv_pool=None
|
||||
):
|
||||
"""A minimal fake ModelRunner that reaches FlashAttentionBackend.__init__'s
|
||||
is_prefill_aware_swa branch (mirrors how models like
|
||||
python/sglang/srt/models/unlimited_ocr.py opt in)."""
|
||||
@@ -63,7 +70,7 @@ def _make_prefill_aware_swa_runner(*, pool_size: int, max_context_len: int = 64)
|
||||
enable_prefill_cp=False,
|
||||
enable_dp_attention=False,
|
||||
)
|
||||
token_to_kv_pool = object()
|
||||
token_to_kv_pool = token_to_kv_pool if token_to_kv_pool is not None else object()
|
||||
token_to_kv_pool_allocator = object()
|
||||
return SimpleNamespace(
|
||||
sliding_window_size=None,
|
||||
@@ -94,6 +101,16 @@ def _make_prefill_aware_swa_runner(*, pool_size: int, max_context_len: int = 64)
|
||||
|
||||
@unittest.skipIf(not torch.cuda.is_available(), "Test requires CUDA")
|
||||
class TestPrefillAwareSwaPrefillLensBound(CustomTestCase):
|
||||
def test_sharded_pool_requests_cpu_sequence_lengths(self):
|
||||
runner = _make_prefill_aware_swa_runner(
|
||||
pool_size=8, token_to_kv_pool=_FakeShardPool()
|
||||
)
|
||||
|
||||
with get_context().override_server_args():
|
||||
backend = FlashAttentionBackend(runner)
|
||||
|
||||
self.assertTrue(backend.needs_cpu_seq_lens)
|
||||
|
||||
def test_buffer_covers_full_req_pool_idx_range(self):
|
||||
pool_size = 8
|
||||
runner = _make_prefill_aware_swa_runner(pool_size=pool_size)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright 2023-2026 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.
|
||||
# ==============================================================================
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from sglang.srt.layers.attention.kv_shard_hooks import (
|
||||
get_kv_shard_pool,
|
||||
prepare_kv_shard_forward,
|
||||
)
|
||||
from sglang.srt.mem_cache.page_interleave_pool import PageInterleaveKVPoolMixin
|
||||
from sglang.srt.model_executor.forward_batch_info import ForwardMode
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class _RecordingPool(PageInterleaveKVPoolMixin):
|
||||
def __init__(self):
|
||||
self.begin_args = None
|
||||
self.begin_calls = 0
|
||||
self.end_calls = 0
|
||||
|
||||
def begin_shard_extend(self, *args):
|
||||
self.begin_args = args
|
||||
self.begin_calls += 1
|
||||
|
||||
def end_shard_extend(self):
|
||||
self.end_calls += 1
|
||||
|
||||
|
||||
def _batch(mode: ForwardMode):
|
||||
return SimpleNamespace(
|
||||
forward_mode=mode,
|
||||
req_pool_indices=torch.tensor([3], dtype=torch.int64),
|
||||
extend_prefix_lens_cpu=[64],
|
||||
seq_lens_cpu=torch.tensor([128], dtype=torch.int64),
|
||||
)
|
||||
|
||||
|
||||
def test_detects_only_page_interleaved_pools():
|
||||
pool = _RecordingPool()
|
||||
|
||||
assert get_kv_shard_pool(pool) is pool
|
||||
assert get_kv_shard_pool(object()) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode, active",
|
||||
[
|
||||
(ForwardMode.EXTEND, True),
|
||||
(ForwardMode.MIXED, True),
|
||||
(ForwardMode.SPLIT_PREFILL, True),
|
||||
(ForwardMode.DECODE, False),
|
||||
(ForwardMode.IDLE, False),
|
||||
(ForwardMode.TARGET_VERIFY, False),
|
||||
(ForwardMode.DRAFT_EXTEND_V2, False),
|
||||
],
|
||||
)
|
||||
def test_prepare_updates_the_pool_lifecycle(mode, active):
|
||||
pool = _RecordingPool()
|
||||
req_to_token = torch.arange(8)
|
||||
batch = _batch(mode)
|
||||
|
||||
assert prepare_kv_shard_forward(pool, req_to_token, batch) is active
|
||||
assert pool.begin_calls == int(active)
|
||||
assert pool.end_calls == int(not active)
|
||||
if active:
|
||||
assert all(
|
||||
actual is expected
|
||||
for actual, expected in zip(
|
||||
pool.begin_args,
|
||||
(
|
||||
req_to_token,
|
||||
batch.req_pool_indices,
|
||||
batch.extend_prefix_lens_cpu,
|
||||
batch.seq_lens_cpu,
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
assert pool.begin_args is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"missing_field",
|
||||
["req_pool_indices", "extend_prefix_lens_cpu", "seq_lens_cpu"],
|
||||
)
|
||||
def test_extend_requires_host_metadata(missing_field):
|
||||
batch = _batch(ForwardMode.EXTEND)
|
||||
setattr(batch, missing_field, None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="requires request indices and CPU"):
|
||||
prepare_kv_shard_forward(_RecordingPool(), torch.empty(0), batch)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(pytest.main([__file__, "-v"]))
|
||||
@@ -11,9 +11,10 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Unit tests for logical-page KV cache sharding (CPU only).
|
||||
"""Tests for logical-page KV cache sharding.
|
||||
|
||||
Pins the pure arithmetic that rotated owner-classed allocation hangs on:
|
||||
Two sections. The first (CPU only, what the CPU CI job runs) pins the pure
|
||||
arithmetic that rotated owner-classed allocation hangs on:
|
||||
|
||||
1. The placement bijection ``loc = Q*(N*ps) + r*ps + o`` — owner / local-row
|
||||
round-trip, disjoint equal partition across ranks.
|
||||
@@ -24,15 +25,33 @@ Pins the pure arithmetic that rotated owner-classed allocation hangs on:
|
||||
3. The host rotation base on ``UnifiedTreeNode`` — stamped at insert, copied
|
||||
on split, read through ``last_node``, and the pre-flight that declines an
|
||||
insert whose pages carry a different base than the chain it would join.
|
||||
4. ``translate_loc_to_scratch`` — the per-batch page->scratch-page lookup mapping
|
||||
any consumer index vector onto the owner-major ``[prefix | chunk | trash]``
|
||||
scratch, checked against a brute-force reference.
|
||||
5. ``begin_shard_extend`` plan capture (page positions, padded send rows,
|
||||
owner-congruence guard) with the gather stubbed out, following the
|
||||
SimpleNamespace binding pattern of ``test_dsa_layer_shard_utils.py``.
|
||||
|
||||
The second section (``TestPageInterleaveGatherMultiGpu``, at the bottom) drives
|
||||
real pools over a real 2-rank process group. It is the only check that the plan
|
||||
the CPU stub validates actually addresses the bytes NCCL delivers, so it is
|
||||
skipped rather than dropped when fewer than 2 CUDA devices are visible — which
|
||||
is every run of the CPU suite this file is registered to.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from array import array
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
import torch.multiprocessing as mp
|
||||
|
||||
from sglang.srt.distributed import (
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
from sglang.srt.mem_cache.allocator.page_interleave import (
|
||||
PageInterleavePoolAllocator,
|
||||
page_interleave_shard_size,
|
||||
@@ -40,19 +59,30 @@ from sglang.srt.mem_cache.allocator.page_interleave import (
|
||||
from sglang.srt.mem_cache.allocator.paged import PagedTokenToKVPoolAllocator
|
||||
from sglang.srt.mem_cache.base_prefix_cache import (
|
||||
DecLockRefParams,
|
||||
EvictResult,
|
||||
InsertParams,
|
||||
MatchPrefixParams,
|
||||
)
|
||||
from sglang.srt.mem_cache.cache_init_params import CacheInitParams
|
||||
from sglang.srt.mem_cache.common import _evict_until_allocatable
|
||||
from sglang.srt.mem_cache.memory_pool import MHATokenToKVPool, ReqToTokenPool
|
||||
from sglang.srt.mem_cache.page_interleave import (
|
||||
PageInterleavePlacement,
|
||||
PageShardSpec,
|
||||
get_kv_shard_group,
|
||||
)
|
||||
from sglang.srt.mem_cache.page_interleave_pool import (
|
||||
PageInterleaveKVPoolMixin,
|
||||
PageInterleaveMHATokenToKVPool,
|
||||
PageInterleaveMLATokenToKVPool,
|
||||
)
|
||||
from sglang.srt.mem_cache.radix_cache import RadixKey
|
||||
from sglang.srt.mem_cache.unified_cache.components import ComponentType
|
||||
from sglang.srt.mem_cache.unified_cache.unified_tree_core import UnifiedTreeCore
|
||||
from sglang.srt.mem_cache.unified_radix_cache import UnifiedRadixCache
|
||||
from sglang.srt.runtime_context import get_parallel, publish
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
from sglang.srt.utils import ceil_div
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
@@ -391,8 +421,6 @@ class TestEvictUntilAllocatable(CustomTestCase):
|
||||
return alloc, outs
|
||||
|
||||
def _tree_stub(self, alloc, frees):
|
||||
from sglang.srt.mem_cache.base_prefix_cache import EvictResult
|
||||
|
||||
stub = SimpleNamespace(calls=0)
|
||||
|
||||
def evict(params):
|
||||
@@ -407,8 +435,6 @@ class TestEvictUntilAllocatable(CustomTestCase):
|
||||
return stub
|
||||
|
||||
def test_iterates_until_min_class_covers(self):
|
||||
from sglang.srt.mem_cache.common import _evict_until_allocatable
|
||||
|
||||
alloc, outs = self._allocator_with_tight_class()
|
||||
# Each round frees ONE class-3 page (a whole 1-page chain): reaching
|
||||
# a min-class floor of 2 pages takes 2 rounds.
|
||||
@@ -418,8 +444,6 @@ class TestEvictUntilAllocatable(CustomTestCase):
|
||||
self.assertEqual(tree.calls, 2)
|
||||
|
||||
def test_terminates_when_tree_dry(self):
|
||||
from sglang.srt.mem_cache.common import _evict_until_allocatable
|
||||
|
||||
alloc, _ = self._allocator_with_tight_class()
|
||||
tree = self._tree_stub(alloc, []) # nothing evictable
|
||||
_evict_until_allocatable(tree, alloc, PS)
|
||||
@@ -767,5 +791,707 @@ class TestRotationGraftDecline(CustomTestCase):
|
||||
self.assertEqual(set(released.tolist()), set(own_locs[:8].tolist()))
|
||||
|
||||
|
||||
def _chain_pages(base, n_pages, local_start=5):
|
||||
"""Logical page ids of one chain: page P has owner (base + P) % N and an
|
||||
arbitrary (here: increasing) local page on its owner."""
|
||||
counter = {r: local_start for r in range(N)}
|
||||
pages = []
|
||||
for p in range(n_pages):
|
||||
r = (base + p) % N
|
||||
pages.append(counter[r] * N + r)
|
||||
counter[r] += 1
|
||||
return pages
|
||||
|
||||
|
||||
def _chain_row(pages, seq_len):
|
||||
row = torch.empty(seq_len, dtype=torch.int32)
|
||||
for i in range(seq_len):
|
||||
row[i] = pages[i // PS] * PS + i % PS
|
||||
return row
|
||||
|
||||
|
||||
def _make_pool_stub(spec, shard_rank=0, debug=True, table_pages=4096):
|
||||
"""A SimpleNamespace carrying exactly the state begin_shard_extend /
|
||||
translate_loc_to_scratch read."""
|
||||
stub = SimpleNamespace()
|
||||
stub.shard_spec = spec
|
||||
stub.shard_rank = shard_rank
|
||||
stub.device = "cpu"
|
||||
stub.start_layer = 0
|
||||
stub._chunk_base = spec.max_prefix_tokens
|
||||
stub._trash_base = spec.max_prefix_tokens + spec.chunk_tokens
|
||||
stub._page_pos = torch.full(
|
||||
(table_pages,), stub._trash_base // PS, dtype=torch.int32
|
||||
)
|
||||
stub._local_page_stride = table_pages
|
||||
stub._epoch = 0
|
||||
stub._write_plan_key = stub._write_plan = None
|
||||
stub._translate_cache = {}
|
||||
stub._debug_plan_checks = debug
|
||||
stub.translate_loc_to_scratch = lambda loc: (
|
||||
PageInterleaveKVPoolMixin.translate_loc_to_scratch(stub, loc)
|
||||
)
|
||||
stub.prefetched = []
|
||||
stub._prefetch_layer = lambda layer_id: stub.prefetched.append(layer_id)
|
||||
return stub
|
||||
|
||||
|
||||
def _run_begin(stub, prefix_lens, seq_lens, rows):
|
||||
width = max(r.numel() for r in rows)
|
||||
padded = [
|
||||
torch.cat([r, torch.zeros(width - r.numel(), dtype=torch.int32)]) for r in rows
|
||||
]
|
||||
PageInterleaveKVPoolMixin.begin_shard_extend(
|
||||
stub,
|
||||
torch.stack(padded),
|
||||
torch.arange(len(rows)),
|
||||
prefix_lens,
|
||||
seq_lens,
|
||||
)
|
||||
return stub
|
||||
|
||||
|
||||
def _reference_prefix_slots(per_request_prefix_pages):
|
||||
"""Brute-force reference of the owner-major slot assignment: the batch's
|
||||
unique prefix pages sorted by (owner, local page), rank r's pages
|
||||
contiguous at r * block; block = sum of per-request ceil(K_i / N)."""
|
||||
block = sum(ceil_div(len(pages), N) for pages in per_request_prefix_pages)
|
||||
uniq = sorted({p for pages in per_request_prefix_pages for p in pages})
|
||||
slots = {}
|
||||
counts = {r: 0 for r in range(N)}
|
||||
for page in sorted(uniq, key=lambda p: (p % N, p // N)):
|
||||
owner = page % N
|
||||
slots[page] = owner * block + counts[owner]
|
||||
counts[owner] += 1
|
||||
return slots, block
|
||||
|
||||
|
||||
class TestBeginShardExtendPlan(CustomTestCase):
|
||||
def test_plan_with_rotated_prefix(self):
|
||||
"""7 prefix pages of a base-2 chain + 9 chunk pages (last partial):
|
||||
owner-major slots, send rows owner-filtered in the same order and
|
||||
padded to the block bound ceil(7/4) = 2 pages."""
|
||||
pages = _chain_pages(base=2, n_pages=16)
|
||||
prefix_len, seq_len = 7 * PS, 16 * PS - 5
|
||||
row = _chain_row(pages, seq_len)
|
||||
slots, block = _reference_prefix_slots([pages[:7]])
|
||||
for rank in range(N):
|
||||
stub = _run_begin(
|
||||
_make_pool_stub(_make_spec(), rank), [prefix_len], [seq_len], [row]
|
||||
)
|
||||
self.assertEqual(stub._block_pages, block)
|
||||
self.assertTrue(stub._shard_extend_active)
|
||||
self.assertEqual(stub._epoch, 1)
|
||||
self.assertEqual(stub.prefetched, [0]) # first layer kicked
|
||||
for page, slot in slots.items():
|
||||
self.assertEqual(int(stub._page_pos[page]), slot)
|
||||
for j, page in enumerate(pages[7:]):
|
||||
self.assertEqual(int(stub._page_pos[page]), stub._chunk_base // PS + j)
|
||||
own = sorted((p for p in pages[:7] if p % N == rank), key=lambda p: p // N)
|
||||
expect = torch.cat(
|
||||
[torch.arange((p // N) * PS, (p // N + 1) * PS) for p in own]
|
||||
)
|
||||
if len(own) < block: # padded with the trash page (local page 0)
|
||||
expect = torch.cat([expect, torch.arange((block - len(own)) * PS)])
|
||||
self.assertTrue(torch.equal(stub._send_rows, expect))
|
||||
|
||||
def test_multi_request_plan_shared_prefix_dedup(self):
|
||||
"""bs > 1: request 0 and request 1 share a 3-page cached prefix
|
||||
(request 1 extends it by 2 pages); request 2 is an unrelated base-2
|
||||
chain. Shared pages must gather into ONE slot (no duplicate plan
|
||||
entries), the block is the per-request ceil sum, and every request's
|
||||
locs translate through the same table."""
|
||||
chain_a = _chain_pages(base=0, n_pages=5)
|
||||
chain_c = _chain_pages(base=2, n_pages=4, local_start=20)
|
||||
# rows: request 0 = A[:3] prefix + 1 chunk page; request 1 = A[:5]
|
||||
# prefix + 2 chunk pages; request 2 = C[:2] prefix + 2 chunk pages.
|
||||
chunk0 = _chain_pages(base=3, n_pages=1, local_start=40)
|
||||
chunk1 = _chain_pages(base=1, n_pages=2, local_start=50)
|
||||
chunk2 = _chain_pages(base=0, n_pages=2, local_start=60)
|
||||
rows = [
|
||||
_chain_row(chain_a[:3] + chunk0, 4 * PS),
|
||||
_chain_row(chain_a[:5] + chunk1, 7 * PS),
|
||||
_chain_row(chain_c[:2] + chunk2, 4 * PS - 3),
|
||||
]
|
||||
stub = _run_begin(
|
||||
_make_pool_stub(_make_spec()),
|
||||
[3 * PS, 5 * PS, 2 * PS],
|
||||
[4 * PS, 7 * PS, 4 * PS - 3],
|
||||
rows,
|
||||
)
|
||||
slots, block = _reference_prefix_slots([chain_a[:3], chain_a[:5], chain_c[:2]])
|
||||
self.assertEqual(block, 1 + 2 + 1)
|
||||
self.assertEqual(stub._block_pages, block)
|
||||
for page, slot in slots.items():
|
||||
self.assertEqual(int(stub._page_pos[page]), slot)
|
||||
# Chunk slots are absolute scratch pages in batch order.
|
||||
for j, page in enumerate(chunk0 + chunk1 + chunk2):
|
||||
self.assertEqual(int(stub._page_pos[page]), stub._chunk_base // PS + j)
|
||||
# Shared pages: both requests' locs hit the SAME scratch rows.
|
||||
shared_loc_r0 = rows[0][:PS].long()
|
||||
shared_loc_r1 = rows[1][:PS].long()
|
||||
t0 = PageInterleaveKVPoolMixin.translate_loc_to_scratch(stub, shared_loc_r0)
|
||||
t1 = PageInterleaveKVPoolMixin.translate_loc_to_scratch(stub, shared_loc_r1)
|
||||
self.assertTrue(torch.equal(t0, t1))
|
||||
# Per-rank send lists fit the block and pad with the trash page.
|
||||
all_prefix = sorted(set(chain_a[:5] + chain_c[:2]))
|
||||
for rank in range(N):
|
||||
stub_r = _run_begin(
|
||||
_make_pool_stub(_make_spec(), rank),
|
||||
[3 * PS, 5 * PS, 2 * PS],
|
||||
[4 * PS, 7 * PS, 4 * PS - 3],
|
||||
rows,
|
||||
)
|
||||
own = sorted((p for p in all_prefix if p % N == rank), key=lambda p: p // N)
|
||||
self.assertLessEqual(len(own), block)
|
||||
self.assertEqual(stub_r._send_rows.numel(), block * PS)
|
||||
expect_head = torch.cat(
|
||||
[torch.arange((p // N) * PS, (p // N + 1) * PS) for p in own]
|
||||
)
|
||||
self.assertTrue(
|
||||
torch.equal(stub_r._send_rows[: len(own) * PS], expect_head)
|
||||
)
|
||||
|
||||
def test_send_order_follows_local_page_not_position(self):
|
||||
"""A freed-and-reused page can give a chain a LOWER local page id at
|
||||
a later position. Slot assignment and send packing must both order
|
||||
by local page id (they only need to agree — a mismatch reads the
|
||||
wrong rank rows)."""
|
||||
# Owner-0 pages appear at positions 0 and 4 with locals 9 then 3.
|
||||
pages = [9 * N + 0, 5 * N + 1, 5 * N + 2, 5 * N + 3, 3 * N + 0]
|
||||
row = _chain_row(pages, 5 * PS)
|
||||
stub = _run_begin(
|
||||
_make_pool_stub(_make_spec(), shard_rank=0),
|
||||
[5 * PS],
|
||||
[5 * PS + PS],
|
||||
[torch.cat([row, _chain_row([7 * N + 1], PS)])],
|
||||
)
|
||||
slots, block = _reference_prefix_slots([pages])
|
||||
self.assertEqual(block, 2)
|
||||
# local 3 gets owner-0's first slot although it sits at position 4.
|
||||
self.assertEqual(int(stub._page_pos[3 * N + 0]), 0)
|
||||
self.assertEqual(int(stub._page_pos[9 * N + 0]), 1)
|
||||
expect = torch.cat(
|
||||
[torch.arange(3 * PS, 4 * PS), torch.arange(9 * PS, 10 * PS)]
|
||||
)
|
||||
self.assertTrue(torch.equal(stub._send_rows, expect))
|
||||
|
||||
def test_plan_without_prefix(self):
|
||||
pages = _chain_pages(base=0, n_pages=2)
|
||||
stub = _run_begin(
|
||||
_make_pool_stub(_make_spec()), [0], [PS + 5], [_chain_row(pages, PS + 5)]
|
||||
)
|
||||
self.assertEqual(stub._block_pages, 0)
|
||||
self.assertTrue(stub._shard_extend_active)
|
||||
self.assertEqual(stub.prefetched, []) # nothing to gather
|
||||
self.assertIsNone(stub._send_rows)
|
||||
self.assertEqual(int(stub._page_pos[pages[0]]), stub._chunk_base // PS)
|
||||
self.assertEqual(int(stub._page_pos[pages[1]]), stub._chunk_base // PS + 1)
|
||||
|
||||
def test_unaligned_prefix_rejected(self):
|
||||
# The tree quantum is the PHYSICAL page: a prefix that is not a
|
||||
# ps-multiple can never come out of match_prefix.
|
||||
pages = _chain_pages(base=0, n_pages=4)
|
||||
with self.assertRaises(AssertionError):
|
||||
_run_begin(
|
||||
_make_pool_stub(_make_spec()),
|
||||
[PS + 3],
|
||||
[4 * PS],
|
||||
[_chain_row(pages, 4 * PS)],
|
||||
)
|
||||
|
||||
def test_owner_congruence_guard(self):
|
||||
"""A rotation-base bug that breaks a request's prefix-owner
|
||||
cyclicity invalidates the sync-free block bound (a rank can own more
|
||||
than ceil(K/N) pages); the debug guard must catch it at plan time."""
|
||||
pages = _chain_pages(base=1, n_pages=8)
|
||||
pages[2], pages[5] = pages[5], pages[2] # same multiset, not cyclic
|
||||
with self.assertRaises(AssertionError) as ctx:
|
||||
_run_begin(
|
||||
_make_pool_stub(_make_spec()),
|
||||
[6 * PS],
|
||||
[8 * PS],
|
||||
[_chain_row(pages, 8 * PS)],
|
||||
)
|
||||
self.assertIn("cyclic", str(ctx.exception))
|
||||
|
||||
|
||||
class TestScratchTranslation(CustomTestCase):
|
||||
def _plan(self, base=2, n_prefix=7, n_chunk=9, rank=1):
|
||||
pages = _chain_pages(base=base, n_pages=n_prefix + n_chunk)
|
||||
seq_len = (n_prefix + n_chunk) * PS
|
||||
stub = _run_begin(
|
||||
_make_pool_stub(_make_spec(), rank),
|
||||
[n_prefix * PS],
|
||||
[seq_len],
|
||||
[_chain_row(pages, seq_len)],
|
||||
)
|
||||
return stub, pages[:n_prefix], pages[n_prefix:]
|
||||
|
||||
def _reference_row(self, stub, prefix_pages, chunk_pages, loc):
|
||||
"""Brute-force reference: owner-major (owner, local-page)-sorted
|
||||
prefix slots, sequence-order chunk."""
|
||||
spec = stub.shard_spec
|
||||
page, off = loc // PS, loc % PS
|
||||
if page in prefix_pages:
|
||||
slots, _ = _reference_prefix_slots([prefix_pages])
|
||||
return slots[page] * PS + off
|
||||
if page in chunk_pages:
|
||||
k = chunk_pages.index(page)
|
||||
return spec.max_prefix_tokens + k * PS + off
|
||||
return stub._trash_base + off
|
||||
|
||||
def test_translation_matches_reference(self):
|
||||
stub, prefix_pages, chunk_pages = self._plan()
|
||||
locs = (
|
||||
[p * PS + o for p in prefix_pages + chunk_pages for o in (0, 3, PS - 1)]
|
||||
+ list(range(0, N)) # reserved pages -> trash
|
||||
+ [3000, 3001] # off-plan -> trash
|
||||
)
|
||||
got = PageInterleaveKVPoolMixin.translate_loc_to_scratch(
|
||||
stub, torch.tensor(locs, dtype=torch.int64)
|
||||
)
|
||||
expect = torch.tensor(
|
||||
[self._reference_row(stub, prefix_pages, chunk_pages, l) for l in locs],
|
||||
dtype=torch.int64,
|
||||
)
|
||||
self.assertTrue(torch.equal(got, expect))
|
||||
|
||||
def test_translation_is_injective_over_the_plan(self):
|
||||
stub, prefix_pages, chunk_pages = self._plan(base=3, n_prefix=5, n_chunk=4)
|
||||
locs = [p * PS + o for p in prefix_pages + chunk_pages for o in range(PS)]
|
||||
rows = PageInterleaveKVPoolMixin.translate_loc_to_scratch(
|
||||
stub, torch.tensor(locs, dtype=torch.int64)
|
||||
)
|
||||
self.assertEqual(len(torch.unique(rows)), len(locs))
|
||||
# Prefix rows stay inside the (padded) gather span, chunk rows inside
|
||||
# the chunk region.
|
||||
n_prefix_tokens = len(prefix_pages) * PS
|
||||
self.assertTrue(
|
||||
bool((rows[:n_prefix_tokens] < N * stub._block_pages * PS).all())
|
||||
)
|
||||
self.assertTrue(
|
||||
bool(
|
||||
(rows[n_prefix_tokens:] >= stub.shard_spec.max_prefix_tokens).all()
|
||||
and (rows[n_prefix_tokens:] < stub._trash_base).all()
|
||||
)
|
||||
)
|
||||
|
||||
def test_int32_page_table_input(self):
|
||||
stub, prefix_pages, chunk_pages = self._plan(base=0, n_prefix=4, n_chunk=1)
|
||||
table = torch.tensor(
|
||||
[prefix_pages[0] * PS, prefix_pages[1] * PS, chunk_pages[0] * PS, 0],
|
||||
dtype=torch.int32,
|
||||
)
|
||||
rows = PageInterleaveKVPoolMixin.translate_loc_to_scratch(stub, table)
|
||||
self.assertEqual(rows.dtype, torch.int64)
|
||||
# Page-aligned inputs land on page-aligned scratch rows (the FA3
|
||||
# stride-divide contract).
|
||||
self.assertTrue(bool((rows[:3] % PS == 0).all()))
|
||||
self.assertEqual(int(rows[3]), stub._trash_base)
|
||||
|
||||
def test_translation_cache_cleared_with_new_plan(self):
|
||||
pages = _chain_pages(base=0, n_pages=2)
|
||||
stub = _make_pool_stub(_make_spec())
|
||||
|
||||
# The first batch treats page 0 as part of the current chunk.
|
||||
_run_begin(stub, [0], [PS], [_chain_row(pages[:1], PS)])
|
||||
loc = _chain_row(pages[:1], PS).long()
|
||||
first = PageInterleaveKVPoolMixin._translate_loc_cached(stub, loc)
|
||||
again = PageInterleaveKVPoolMixin._translate_loc_cached(stub, loc)
|
||||
self.assertIs(again, first)
|
||||
|
||||
# The next batch reuses the same loc tensor after page 0 becomes a
|
||||
# cached prefix. Installing the new plan must discard the old mapping.
|
||||
_run_begin(stub, [PS], [2 * PS], [_chain_row(pages, 2 * PS)])
|
||||
fresh = PageInterleaveKVPoolMixin._translate_loc_cached(stub, loc)
|
||||
self.assertIsNot(fresh, first)
|
||||
self.assertFalse(torch.equal(fresh, first))
|
||||
|
||||
|
||||
class TestWritePlan(CustomTestCase):
|
||||
def test_owner_filter_cached_per_loc_tensor(self):
|
||||
spec = _make_spec(shard_rank=2)
|
||||
stub = SimpleNamespace()
|
||||
stub.placement = PageInterleavePlacement(spec)
|
||||
stub.shard_rank = 2
|
||||
stub._epoch = 1
|
||||
stub._write_plan_key = stub._write_plan = None
|
||||
|
||||
loc = torch.arange(5 * GS, 7 * GS) # two whole groups
|
||||
owned_idx, local_rows = PageInterleaveKVPoolMixin._get_write_plan(stub, loc)
|
||||
self.assertEqual(owned_idx.numel(), 2 * PS)
|
||||
# Owned rows are ps-contiguous runs at [Q*ps, (Q+1)*ps).
|
||||
self.assertTrue(
|
||||
torch.equal(
|
||||
local_rows,
|
||||
torch.cat([torch.arange(5 * PS, 6 * PS), torch.arange(6 * PS, 7 * PS)]),
|
||||
)
|
||||
)
|
||||
# Same tensor + same epoch -> cached (identity).
|
||||
again = PageInterleaveKVPoolMixin._get_write_plan(stub, loc)
|
||||
self.assertIs(again[0], owned_idx)
|
||||
# Epoch bump invalidates.
|
||||
stub._epoch = 2
|
||||
fresh = PageInterleaveKVPoolMixin._get_write_plan(stub, loc)
|
||||
self.assertIsNot(fresh[0], owned_idx)
|
||||
|
||||
def test_partial_tail_page_may_own_nothing(self):
|
||||
spec = _make_spec(shard_rank=3)
|
||||
stub = SimpleNamespace()
|
||||
stub.placement = PageInterleavePlacement(spec)
|
||||
stub.shard_rank = 3
|
||||
stub._epoch = 1
|
||||
stub._write_plan_key = stub._write_plan = None
|
||||
# 10 tokens: all inside owner-0's page of the group.
|
||||
loc = torch.arange(8 * GS, 8 * GS + 10)
|
||||
owned_idx, local_rows = PageInterleaveKVPoolMixin._get_write_plan(stub, loc)
|
||||
self.assertEqual(owned_idx.numel(), 0)
|
||||
self.assertEqual(local_rows.numel(), 0)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Multi-GPU: the real NCCL layer-ahead gather (2 GPUs).
|
||||
#
|
||||
# Everything above is pure arithmetic on a CPU stub. This section drives real
|
||||
# pools over a real process group, which is the only check that the plan the
|
||||
# stub validates actually addresses the bytes the collective delivers:
|
||||
#
|
||||
# 1. MLA pool sharded across the attention-TP group: replicated writes are
|
||||
# owner-filtered into disjoint pool stripes; a later batch's chunked-prefix
|
||||
# read (get_mla_kv_buffer) assembles the full prefix from all ranks via the
|
||||
# layer-ahead NCCL allgather and must return the canonical bytes.
|
||||
# 2. MHA pool sharded across the attention-CP group: the post-allgather full
|
||||
# chunk is staged into the scratch chunk region and owner-persisted; a later
|
||||
# batch reads prefix+chunk through the translated page table (the scratch),
|
||||
# and the assembled rows must match the canonical bytes.
|
||||
#
|
||||
# Skipped unless 2 CUDA devices are visible, so it is inert on the CPU runner
|
||||
# this file is registered to. Run it explicitly with:
|
||||
# CUDA_VISIBLE_DEVICES=0,1 python3 test/registered/unit/mem_cache/\
|
||||
# test_page_interleave_shard.py TestPageInterleaveGatherMultiGpu
|
||||
# =============================================================================
|
||||
|
||||
_GATHER_WORLD = 2
|
||||
_GATHER_LAYER_NUM = 4
|
||||
_GATHER_PAGE_SIZE = 16
|
||||
_GATHER_GRANULE = _GATHER_WORLD * _GATHER_PAGE_SIZE
|
||||
_GATHER_SIZE = _GATHER_PAGE_SIZE * 64 # physical token slots per rank
|
||||
_GATHER_KV_LORA_RANK = 128
|
||||
_GATHER_QK_ROPE = 32
|
||||
_GATHER_HEAD_NUM = 2
|
||||
_GATHER_HEAD_DIM = 32
|
||||
_GATHER_DTYPE = torch.bfloat16
|
||||
|
||||
|
||||
def _mla_value(loc, dim):
|
||||
"""Deterministic canonical latent value for logical slot ``loc``."""
|
||||
loc = loc.to(torch.float32)
|
||||
return (loc.unsqueeze(-1) + torch.arange(dim, device=loc.device) * 0.001).to(
|
||||
_GATHER_DTYPE
|
||||
)
|
||||
|
||||
|
||||
def _dist_init(rank, world, port, attn_cp_size):
|
||||
os.environ["MASTER_ADDR"] = "127.0.0.1"
|
||||
os.environ["MASTER_PORT"] = str(port)
|
||||
os.environ["RANK"] = str(rank)
|
||||
os.environ["WORLD_SIZE"] = str(world)
|
||||
os.environ.setdefault("no_proxy", "127.0.0.1,localhost")
|
||||
torch.cuda.set_device(rank)
|
||||
|
||||
init_distributed_environment(
|
||||
world_size=world,
|
||||
rank=rank,
|
||||
local_rank=rank,
|
||||
distributed_init_method=f"tcp://127.0.0.1:{port}",
|
||||
backend="nccl",
|
||||
)
|
||||
# Publish the widths the groups below are about to be built at. The derived
|
||||
# quotients (attn_tp_size, attn_dcp_size, ...) are projected from these
|
||||
# leaves at publish; initialize_model_parallel no longer supplies them, and
|
||||
# MLATokenToKVPool.set_mla_kv_buffer reads attn_dcp_size on the write path.
|
||||
publish(
|
||||
ServerArgs(model_path="dummy", tp_size=world, attn_cp_size=attn_cp_size),
|
||||
role="scheduler",
|
||||
)
|
||||
initialize_model_parallel(
|
||||
tensor_model_parallel_size=world,
|
||||
attention_context_model_parallel_size=attn_cp_size,
|
||||
)
|
||||
|
||||
|
||||
def _gather_make_spec(shard_rank, max_prefix_groups=16, chunk_groups=4):
|
||||
return PageShardSpec(
|
||||
shard_rank=shard_rank,
|
||||
shard_size=_GATHER_WORLD,
|
||||
page_size=_GATHER_PAGE_SIZE,
|
||||
max_prefix_tokens=max_prefix_groups * _GATHER_GRANULE,
|
||||
chunk_tokens=chunk_groups * _GATHER_GRANULE,
|
||||
)
|
||||
|
||||
|
||||
def _fake_req_to_token(groups, seq_len, device):
|
||||
"""req_to_token row where sequence group j is allocator group groups[j]."""
|
||||
row = torch.zeros(
|
||||
(1, len(groups) * _GATHER_GRANULE), dtype=torch.int32, device=device
|
||||
)
|
||||
for j, q in enumerate(groups):
|
||||
row[0, j * _GATHER_GRANULE : (j + 1) * _GATHER_GRANULE] = torch.arange(
|
||||
q * _GATHER_GRANULE,
|
||||
(q + 1) * _GATHER_GRANULE,
|
||||
dtype=torch.int32,
|
||||
device=device,
|
||||
)
|
||||
return row[:, :seq_len] if seq_len < row.shape[1] else row
|
||||
|
||||
|
||||
def _check(rank, name, got, expect, atol=0.0):
|
||||
ok = torch.allclose(got.float(), expect.float(), atol=atol, rtol=0)
|
||||
max_err = (got.float() - expect.float()).abs().max().item()
|
||||
print(f"[rank {rank}] {name}: max_err={max_err:.6f} {'OK' if ok else 'FAIL'}")
|
||||
assert ok, f"[rank {rank}] {name} mismatch (max_err={max_err})"
|
||||
|
||||
|
||||
def _run_mla(rank, world, port):
|
||||
_dist_init(rank, world, port, attn_cp_size=1)
|
||||
|
||||
group = get_parallel().attn_tp_group
|
||||
assert group.world_size == world
|
||||
# Topology-first shard-group selection: no CP here, so MLA falls back to
|
||||
# the attn-TP axis, while GQA has no replicated axis (world_size 1).
|
||||
assert get_kv_shard_group(use_mla_backend=True) is group
|
||||
assert get_kv_shard_group(use_mla_backend=False).world_size == 1
|
||||
spec = _gather_make_spec(shard_rank=group.rank_in_group)
|
||||
|
||||
pool = PageInterleaveMLATokenToKVPool(
|
||||
_GATHER_SIZE,
|
||||
page_size=_GATHER_PAGE_SIZE,
|
||||
dtype=_GATHER_DTYPE,
|
||||
kv_lora_rank=_GATHER_KV_LORA_RANK,
|
||||
qk_rope_head_dim=_GATHER_QK_ROPE,
|
||||
layer_num=_GATHER_LAYER_NUM,
|
||||
device=f"cuda:{rank}",
|
||||
enable_memory_saver=False,
|
||||
start_layer=0,
|
||||
end_layer=_GATHER_LAYER_NUM - 1,
|
||||
shard_spec=spec,
|
||||
shard_group=group,
|
||||
)
|
||||
device = pool.kv_buffer[0].device
|
||||
|
||||
# ---- chunk 1: replicated write, owner-filtered persist -----------------
|
||||
# "Allocator" hands out fragmented groups (identical on every rank).
|
||||
chunk1_groups = [5, 2, 9]
|
||||
chunk1_locs = _fake_req_to_token(chunk1_groups, 3 * _GATHER_GRANULE, device)[
|
||||
0
|
||||
].long()
|
||||
for layer_id in range(_GATHER_LAYER_NUM):
|
||||
layer = SimpleNamespace(layer_id=layer_id)
|
||||
vals = _mla_value(
|
||||
chunk1_locs + layer_id * 1000, _GATHER_KV_LORA_RANK + _GATHER_QK_ROPE
|
||||
)
|
||||
pool.set_mla_kv_buffer(
|
||||
layer,
|
||||
chunk1_locs,
|
||||
vals[:, :_GATHER_KV_LORA_RANK].unsqueeze(1),
|
||||
vals[:, _GATHER_KV_LORA_RANK:].unsqueeze(1),
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
torch.distributed.barrier()
|
||||
|
||||
# Pool holds only the owned stripe: group Q sits at local rows [Q*ps,(Q+1)*ps)
|
||||
# on every rank, holding that rank's page of the group.
|
||||
for q in chunk1_groups:
|
||||
local_rows = torch.arange(
|
||||
q * _GATHER_PAGE_SIZE, (q + 1) * _GATHER_PAGE_SIZE, device=device
|
||||
)
|
||||
owned_locs = (
|
||||
q * _GATHER_GRANULE
|
||||
+ group.rank_in_group * _GATHER_PAGE_SIZE
|
||||
+ torch.arange(_GATHER_PAGE_SIZE, device=device)
|
||||
)
|
||||
got = pool.kv_buffer[0][local_rows, 0, :].view(_GATHER_DTYPE)
|
||||
expect = _mla_value(owned_locs, _GATHER_KV_LORA_RANK + _GATHER_QK_ROPE)
|
||||
_check(rank, f"mla owned stripe g{q}", got, expect)
|
||||
|
||||
# ---- chunk 2: prefix gather + staged chunk, both read styles -----------
|
||||
seq_groups = chunk1_groups + [12] # one new chunk group
|
||||
prefix_len = 3 * _GATHER_GRANULE
|
||||
seq_len = prefix_len + _GATHER_GRANULE
|
||||
req_to_token = _fake_req_to_token(seq_groups, seq_len, device)
|
||||
chunk2_locs = req_to_token[0, prefix_len:seq_len].long()
|
||||
pool.begin_shard_extend(req_to_token, torch.tensor([0]), [prefix_len], [seq_len])
|
||||
|
||||
for layer_id in range(_GATHER_LAYER_NUM):
|
||||
layer = SimpleNamespace(layer_id=layer_id)
|
||||
# Write the current chunk (stages it into the slot + persists the
|
||||
# owned stripe), like the extend forward does before attention.
|
||||
chunk_vals = _mla_value(
|
||||
chunk2_locs + layer_id * 1000, _GATHER_KV_LORA_RANK + _GATHER_QK_ROPE
|
||||
)
|
||||
pool.set_mla_kv_buffer(
|
||||
layer,
|
||||
chunk2_locs,
|
||||
chunk_vals[:, :_GATHER_KV_LORA_RANK].unsqueeze(1),
|
||||
chunk_vals[:, _GATHER_KV_LORA_RANK:].unsqueeze(1),
|
||||
)
|
||||
# Chunked-prefix MHA style: fetch an arbitrary sub-range of the
|
||||
# prefix through get_mla_kv_buffer.
|
||||
sub = chunk1_locs[_GATHER_PAGE_SIZE // 2 : prefix_len - 3]
|
||||
k_nope, k_rope = pool.get_mla_kv_buffer(layer, sub, _GATHER_DTYPE)
|
||||
expect = _mla_value(
|
||||
sub + layer_id * 1000, _GATHER_KV_LORA_RANK + _GATHER_QK_ROPE
|
||||
)
|
||||
_check(
|
||||
rank,
|
||||
f"mla prefix read l{layer_id}",
|
||||
k_nope[:, 0, :],
|
||||
expect[:, :_GATHER_KV_LORA_RANK],
|
||||
)
|
||||
_check(
|
||||
rank,
|
||||
f"mla prefix rope l{layer_id}",
|
||||
k_rope[:, 0, :],
|
||||
expect[:, _GATHER_KV_LORA_RANK:],
|
||||
)
|
||||
# Absorbed-MLA style (what MLA-under-CP uses): read [prefix | chunk]
|
||||
# from get_key_buffer through the translated page table.
|
||||
all_locs = req_to_token[0, :seq_len].long()
|
||||
rows = pool.translate_loc_to_scratch(all_locs)
|
||||
kv_scratch = pool.get_key_buffer(layer_id)
|
||||
_check(
|
||||
rank,
|
||||
f"mla absorbed read l{layer_id}",
|
||||
kv_scratch[rows, 0, :],
|
||||
_mla_value(
|
||||
all_locs + layer_id * 1000, _GATHER_KV_LORA_RANK + _GATHER_QK_ROPE
|
||||
),
|
||||
)
|
||||
|
||||
torch.distributed.barrier()
|
||||
if rank == 0:
|
||||
print("PASS: MLA page-interleave shard (attn-TP axis)")
|
||||
|
||||
|
||||
def _run_mha(rank, world, port):
|
||||
_dist_init(rank, world, port, attn_cp_size=world)
|
||||
|
||||
group = get_parallel().attn_cp_group
|
||||
assert group.world_size == world
|
||||
# Topology-first shard-group selection: with an active CP group, both
|
||||
# GQA and MLA shard across CP (CP replicates KV for every attention
|
||||
# type; the TP axis is only the no-CP MLA fallback).
|
||||
assert get_kv_shard_group(use_mla_backend=False) is group
|
||||
assert get_kv_shard_group(use_mla_backend=True) is group
|
||||
spec = _gather_make_spec(shard_rank=group.rank_in_group)
|
||||
|
||||
pool = PageInterleaveMHATokenToKVPool(
|
||||
_GATHER_SIZE,
|
||||
page_size=_GATHER_PAGE_SIZE,
|
||||
dtype=_GATHER_DTYPE,
|
||||
head_num=_GATHER_HEAD_NUM,
|
||||
head_dim=_GATHER_HEAD_DIM,
|
||||
layer_num=_GATHER_LAYER_NUM,
|
||||
device=f"cuda:{rank}",
|
||||
enable_memory_saver=False,
|
||||
start_layer=0,
|
||||
end_layer=_GATHER_LAYER_NUM - 1,
|
||||
enable_alt_stream=False,
|
||||
shard_spec=spec,
|
||||
shard_group=group,
|
||||
)
|
||||
device = pool.k_buffer[0].device
|
||||
|
||||
def kv_value(locs, layer_id, is_v):
|
||||
base = locs.to(torch.float32) + layer_id * 1000 + (500000 if is_v else 0)
|
||||
return (
|
||||
base.view(-1, 1, 1)
|
||||
+ torch.arange(_GATHER_HEAD_NUM, device=device).view(1, -1, 1) * 0.01
|
||||
+ torch.arange(_GATHER_HEAD_DIM, device=device).view(1, 1, -1) * 0.0001
|
||||
).to(_GATHER_DTYPE)
|
||||
|
||||
# ---- chunk 1 (prefix-less batch): stage + owner-persist ----------------
|
||||
chunk1_groups = [7, 3]
|
||||
chunk1_locs = _fake_req_to_token(chunk1_groups, 2 * _GATHER_GRANULE, device)[
|
||||
0
|
||||
].long()
|
||||
req_to_token = _fake_req_to_token(chunk1_groups, 2 * _GATHER_GRANULE, device)
|
||||
pool.begin_shard_extend(req_to_token, torch.tensor([0]), [0], [2 * _GATHER_GRANULE])
|
||||
for layer_id in range(_GATHER_LAYER_NUM):
|
||||
layer = SimpleNamespace(layer_id=layer_id)
|
||||
pool.set_kv_buffer(
|
||||
layer,
|
||||
chunk1_locs,
|
||||
kv_value(chunk1_locs, layer_id, False),
|
||||
kv_value(chunk1_locs, layer_id, True),
|
||||
)
|
||||
# The current chunk must be readable through the scratch right away.
|
||||
k_scratch = pool.get_key_buffer(layer_id)
|
||||
rows = pool.translate_loc_to_scratch(chunk1_locs)
|
||||
_check(
|
||||
rank,
|
||||
f"mha chunk stage l{layer_id}",
|
||||
k_scratch[rows],
|
||||
kv_value(chunk1_locs, layer_id, False),
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
torch.distributed.barrier()
|
||||
|
||||
# ---- chunk 2: prefix gathered from peers via translated page table -----
|
||||
seq_groups = chunk1_groups + [11]
|
||||
prefix_len = 2 * _GATHER_GRANULE
|
||||
seq_len = prefix_len + _GATHER_GRANULE
|
||||
req_to_token = _fake_req_to_token(seq_groups, seq_len, device)
|
||||
chunk2_locs = req_to_token[0, prefix_len:seq_len].long()
|
||||
pool.begin_shard_extend(req_to_token, torch.tensor([0]), [prefix_len], [seq_len])
|
||||
|
||||
for layer_id in range(_GATHER_LAYER_NUM):
|
||||
layer = SimpleNamespace(layer_id=layer_id)
|
||||
pool.set_kv_buffer(
|
||||
layer,
|
||||
chunk2_locs,
|
||||
kv_value(chunk2_locs, layer_id, False),
|
||||
kv_value(chunk2_locs, layer_id, True),
|
||||
)
|
||||
all_locs = req_to_token[0, :seq_len].long()
|
||||
rows = pool.translate_loc_to_scratch(all_locs)
|
||||
k_scratch = pool.get_key_buffer(layer_id)
|
||||
v_scratch = pool.get_value_buffer(layer_id)
|
||||
_check(
|
||||
rank,
|
||||
f"mha seq read k l{layer_id}",
|
||||
k_scratch[rows],
|
||||
kv_value(all_locs, layer_id, False),
|
||||
)
|
||||
_check(
|
||||
rank,
|
||||
f"mha seq read v l{layer_id}",
|
||||
v_scratch[rows],
|
||||
kv_value(all_locs, layer_id, True),
|
||||
)
|
||||
|
||||
torch.distributed.barrier()
|
||||
if rank == 0:
|
||||
print("PASS: MHA page-interleave shard (attn-CP axis)")
|
||||
|
||||
|
||||
@unittest.skipIf(
|
||||
torch.cuda.device_count() < 2, "page-interleave gather needs 2 CUDA devices"
|
||||
)
|
||||
class TestPageInterleaveGatherMultiGpu(CustomTestCase):
|
||||
"""Real pools, real NCCL, 2 ranks — one mp.spawn per phase.
|
||||
|
||||
Separate spawns (and separate ports) because each phase builds its own
|
||||
process group with a different attention-CP width.
|
||||
"""
|
||||
|
||||
def test_mla_shard_over_attention_tp(self):
|
||||
mp.spawn(_run_mla, args=(_GATHER_WORLD, 29811), nprocs=_GATHER_WORLD, join=True)
|
||||
|
||||
def test_mha_shard_over_attention_cp(self):
|
||||
mp.spawn(_run_mha, args=(_GATHER_WORLD, 29812), nprocs=_GATHER_WORLD, join=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user