refactor(unified-memory): translate the KV write location once, at ForwardBatch construction (#35245)
Co-authored-by: Caihua Li <caihua.li@bytedance.com> Co-authored-by: Cheng Wan <cheng.wan@radixark.ai>
This commit is contained in:
co-authored by
Caihua Li
Cheng Wan
parent
4f997a432a
commit
29578d5578
@@ -62,6 +62,7 @@ _TRITON_KERNELS = [
|
|||||||
("cache_ops", "concat_and_cast_mha_k_triton"),
|
("cache_ops", "concat_and_cast_mha_k_triton"),
|
||||||
("cache_ops", "launch_reshape_and_cache_flash"),
|
("cache_ops", "launch_reshape_and_cache_flash"),
|
||||||
("pd_dcp_gather", "copy_mla_rows_into_pack"),
|
("pd_dcp_gather", "copy_mla_rows_into_pack"),
|
||||||
|
("kv_read_table", "build_kv_read_table"),
|
||||||
("kv_indices", "create_flashinfer_kv_indices_triton"),
|
("kv_indices", "create_flashinfer_kv_indices_triton"),
|
||||||
("kv_indices", "create_flashmla_kv_indices_triton"),
|
("kv_indices", "create_flashmla_kv_indices_triton"),
|
||||||
("kv_indices", "create_chunked_prefix_cache_kv_indices"),
|
("kv_indices", "create_chunked_prefix_cache_kv_indices"),
|
||||||
|
|||||||
@@ -7,19 +7,33 @@ FLASHMLA_CREATE_KV_BLOCK_SIZE_TRITON = tl.constexpr(_FLASHMLA_CREATE_KV_BLOCK_SI
|
|||||||
|
|
||||||
@triton.jit
|
@triton.jit
|
||||||
def create_flashinfer_kv_indices_triton(
|
def create_flashinfer_kv_indices_triton(
|
||||||
req_to_token_ptr, # [max_batch, max_context_len]
|
req_to_token_ptr, # [max_batch, max_context_len] token table; at
|
||||||
|
# ENTRY_PAGE_SIZE > 1 a PAGE-granular table (the unified pool's read table)
|
||||||
req_pool_indices_ptr,
|
req_pool_indices_ptr,
|
||||||
page_kernel_lens_ptr,
|
page_kernel_lens_ptr,
|
||||||
kv_indptr,
|
kv_indptr,
|
||||||
kv_start_idx,
|
kv_start_idx,
|
||||||
kv_indices_ptr,
|
kv_indices_ptr,
|
||||||
req_to_token_ptr_stride: tl.constexpr,
|
# Runtime, not constexpr: the translator's eager table is allocated at the
|
||||||
|
# batch's live width, so a constexpr stride would JIT-specialize per width
|
||||||
|
# (a recompile every few decode steps at small page sizes).
|
||||||
|
req_to_token_ptr_stride,
|
||||||
|
ENTRY_PAGE_SIZE: tl.constexpr = 1,
|
||||||
):
|
):
|
||||||
|
"""Gather per-request token ids into a flat CSR kv_indices stream.
|
||||||
|
|
||||||
|
``ENTRY_PAGE_SIZE == 1`` (default): the source table is token-granular and
|
||||||
|
entries are emitted verbatim -- byte-identical to the historical kernel.
|
||||||
|
``ENTRY_PAGE_SIZE == ps``: the source is the translator's PAGE-granular
|
||||||
|
read table (entries already kernel-facing page ids); token ids are rebuilt
|
||||||
|
as ``token = entry * ps + pos % ps``, exact because converting an id keeps
|
||||||
|
its offset inside the page.
|
||||||
|
"""
|
||||||
BLOCK_SIZE: tl.constexpr = 512
|
BLOCK_SIZE: tl.constexpr = 512
|
||||||
pid = tl.program_id(axis=0)
|
pid = tl.program_id(axis=0)
|
||||||
|
|
||||||
# find the req pool idx, this is for batch to token
|
# find the req pool idx, this is for batch to token
|
||||||
req_pool_index = tl.load(req_pool_indices_ptr + pid)
|
req_pool_index = tl.load(req_pool_indices_ptr + pid).to(tl.int64)
|
||||||
kv_indices_offset = tl.load(kv_indptr + pid)
|
kv_indices_offset = tl.load(kv_indptr + pid)
|
||||||
|
|
||||||
kv_start = 0
|
kv_start = 0
|
||||||
@@ -34,6 +48,7 @@ def create_flashinfer_kv_indices_triton(
|
|||||||
# index into req_to_token_ptr needs to be int64
|
# index into req_to_token_ptr needs to be int64
|
||||||
offset = tl.arange(0, BLOCK_SIZE).to(tl.int64) + i * BLOCK_SIZE
|
offset = tl.arange(0, BLOCK_SIZE).to(tl.int64) + i * BLOCK_SIZE
|
||||||
mask = offset < kv_end - kv_start
|
mask = offset < kv_end - kv_start
|
||||||
|
if ENTRY_PAGE_SIZE == 1:
|
||||||
data = tl.load(
|
data = tl.load(
|
||||||
req_to_token_ptr
|
req_to_token_ptr
|
||||||
+ req_pool_index * req_to_token_ptr_stride
|
+ req_pool_index * req_to_token_ptr_stride
|
||||||
@@ -41,6 +56,15 @@ def create_flashinfer_kv_indices_triton(
|
|||||||
+ offset,
|
+ offset,
|
||||||
mask=mask,
|
mask=mask,
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
pos = kv_start + offset
|
||||||
|
entry = tl.load(
|
||||||
|
req_to_token_ptr
|
||||||
|
+ req_pool_index * req_to_token_ptr_stride
|
||||||
|
+ pos // ENTRY_PAGE_SIZE,
|
||||||
|
mask=mask,
|
||||||
|
)
|
||||||
|
data = entry.to(tl.int64) * ENTRY_PAGE_SIZE + pos % ENTRY_PAGE_SIZE
|
||||||
tl.store(kv_indices_ptr + kv_indices_offset + offset, data, mask=mask)
|
tl.store(kv_indices_ptr + kv_indices_offset + offset, data, mask=mask)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
"""Builds the per-batch read table for the unified memory pool.
|
||||||
|
|
||||||
|
One fused gather-and-translate. For each request row it reads the virtual ids
|
||||||
|
out of `req_to_token`, converts each to the id the kernels can use, and writes
|
||||||
|
the result into `out`:
|
||||||
|
|
||||||
|
out[b, c] = clamp(v2p[req_to_token[req[b], c * ps] // ps] * multiplier, 0)
|
||||||
|
for c < ceil(seq_lens[b] / ps) -- the row's LIVE prefix
|
||||||
|
|
||||||
|
`v2p` is the pool's virtual->physical page table and `multiplier` scales a
|
||||||
|
physical page into the id space the per-layer views use (1 when they are not
|
||||||
|
dense). Since only the page number is rewritten, a token-level consumer can
|
||||||
|
rebuild flat ids as `entry * ps + offset`.
|
||||||
|
|
||||||
|
PREFIX-ONLY per row: columns past the live prefix are never written, so a
|
||||||
|
caller-owned buffer keeps what it had there -- which is what lets a captured
|
||||||
|
cuda-graph buffer be refreshed in place. Readers bound themselves by
|
||||||
|
`cache_seqlens` and never look past the prefix.
|
||||||
|
|
||||||
|
A `-1` in `req_to_token` and a freed (`-1`) v2p row both clamp to entry 0, the
|
||||||
|
reserved padding slot, so a kernel dereferences padding, not a wild address.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import triton
|
||||||
|
import triton.language as tl
|
||||||
|
|
||||||
|
_BLOCK_COLS = 256
|
||||||
|
|
||||||
|
|
||||||
|
@triton.jit
|
||||||
|
def build_kv_read_table_kernel(
|
||||||
|
req_to_token_ptr, # in: [max_reqs, max_context] -- VIRTUAL token ids
|
||||||
|
req_pool_indices_ptr, # in: [bs] -- row per batch lane
|
||||||
|
seq_lens_ptr, # in: [bs]
|
||||||
|
v2p_ptr, # in: [num_pages + 1] int64 -- virtual->physical page table
|
||||||
|
out_ptr, # out: [>=bs, >=max_pages] int32 -- the read table
|
||||||
|
req_stride, # runtime: req_to_token row stride (elements)
|
||||||
|
out_stride, # runtime: out row stride (elements)
|
||||||
|
mult, # runtime: kernel_page_multiplier of the target sub-pool
|
||||||
|
PAGE_SIZE: tl.constexpr,
|
||||||
|
BLOCK: tl.constexpr,
|
||||||
|
):
|
||||||
|
bid = tl.program_id(0)
|
||||||
|
blk = tl.program_id(1)
|
||||||
|
req = tl.load(req_pool_indices_ptr + bid).to(tl.int64)
|
||||||
|
seqlen = tl.load(seq_lens_ptr + bid)
|
||||||
|
n_pages = (seqlen + PAGE_SIZE - 1) // PAGE_SIZE
|
||||||
|
|
||||||
|
cols = blk * BLOCK + tl.arange(0, BLOCK)
|
||||||
|
mask = cols < n_pages
|
||||||
|
tok = tl.load(
|
||||||
|
req_to_token_ptr + req * req_stride + cols.to(tl.int64) * PAGE_SIZE,
|
||||||
|
mask=mask,
|
||||||
|
other=0,
|
||||||
|
).to(tl.int64)
|
||||||
|
# Triton's `//` truncates toward zero, so `-1 // ps` is 0 for ps > 1 but
|
||||||
|
# -1 at ps == 1, which would read one element BEFORE `v2p`.
|
||||||
|
page = tl.where(tok < 0, 0, tok // PAGE_SIZE)
|
||||||
|
phys = tl.load(v2p_ptr + page, mask=mask, other=0)
|
||||||
|
entry = tl.maximum(phys * mult, 0).to(tl.int32)
|
||||||
|
tl.store(out_ptr + bid.to(tl.int64) * out_stride + cols, entry, mask=mask)
|
||||||
|
|
||||||
|
|
||||||
|
def build_kv_read_table(
|
||||||
|
*,
|
||||||
|
req_to_token: torch.Tensor,
|
||||||
|
req_pool_indices: torch.Tensor,
|
||||||
|
seq_lens: torch.Tensor,
|
||||||
|
v2p: torch.Tensor,
|
||||||
|
multiplier: int,
|
||||||
|
page_size: int,
|
||||||
|
max_pages: int,
|
||||||
|
out: torch.Tensor,
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Fill ``out``'s live prefix with read-table entries.
|
||||||
|
|
||||||
|
``out`` is caller-owned (fresh zeros for the eager path, the module's
|
||||||
|
capture-stable buffer for replay) and only its ``[:bs, :max_pages]``
|
||||||
|
region's live prefix is written -- never rebound, never tail-cleared.
|
||||||
|
"""
|
||||||
|
bs = int(req_pool_indices.numel())
|
||||||
|
assert (
|
||||||
|
out.dtype == torch.int32
|
||||||
|
), f"build_kv_read_table: out must be int32, got {out.dtype}"
|
||||||
|
assert out.dim() == 2 and out.shape[0] >= bs and out.shape[1] >= max_pages, (
|
||||||
|
f"build_kv_read_table: out {tuple(out.shape)} cannot hold "
|
||||||
|
f"(bs={bs}, max_pages={max_pages})"
|
||||||
|
)
|
||||||
|
assert out.stride(1) == 1, "build_kv_read_table: out rows must be packed"
|
||||||
|
assert (max_pages - 1) * page_size < req_to_token.shape[1], (
|
||||||
|
f"build_kv_read_table: max_pages={max_pages} x ps={page_size} "
|
||||||
|
f"exceeds req_to_token width {req_to_token.shape[1]}"
|
||||||
|
)
|
||||||
|
if bs == 0 or max_pages == 0:
|
||||||
|
return out
|
||||||
|
|
||||||
|
if not req_to_token.is_cuda:
|
||||||
|
cols = torch.arange(max_pages, device=req_to_token.device)
|
||||||
|
live = cols[None, :] < (
|
||||||
|
(seq_lens[:bs, None].to(torch.int64) + page_size - 1) // page_size
|
||||||
|
)
|
||||||
|
tok = req_to_token[
|
||||||
|
req_pool_indices[:bs, None].to(torch.int64), (cols * page_size)[None, :]
|
||||||
|
].to(torch.int64)
|
||||||
|
pages = torch.where(tok < 0, 0, tok // page_size)
|
||||||
|
entry = (v2p[pages] * multiplier).clamp(min=0).to(torch.int32)
|
||||||
|
dst = out[:bs, :max_pages]
|
||||||
|
dst.copy_(torch.where(live, entry, dst))
|
||||||
|
return out
|
||||||
|
|
||||||
|
grid = (bs, triton.cdiv(max_pages, _BLOCK_COLS))
|
||||||
|
build_kv_read_table_kernel[grid](
|
||||||
|
req_to_token,
|
||||||
|
req_pool_indices,
|
||||||
|
seq_lens,
|
||||||
|
v2p,
|
||||||
|
out,
|
||||||
|
req_to_token.stride(0),
|
||||||
|
out.stride(0),
|
||||||
|
multiplier,
|
||||||
|
PAGE_SIZE=page_size,
|
||||||
|
BLOCK=_BLOCK_COLS,
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -262,11 +262,21 @@ def handle_unified_memory_pool(server_args: Any) -> None:
|
|||||||
# Only monolithic decode cuda-graph capture is wired; piecewise prefill
|
# Only monolithic decode cuda-graph capture is wired; piecewise prefill
|
||||||
# capture is not. Guard when the user opts into it.
|
# capture is not. Guard when the user opts into it.
|
||||||
_cg_cfg = cfg.cuda_graph_config
|
_cg_cfg = cfg.cuda_graph_config
|
||||||
if _cg_cfg is not None and _cg_cfg.prefill.backend == Backend.TC_PIECEWISE:
|
if _cg_cfg is not None and _cg_cfg.prefill.backend != Backend.DISABLED:
|
||||||
|
if cfg.cuda_graph_backend_prefill is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"--enable-unified-memory supports monolithic (decode) "
|
"--enable-unified-memory supports decode cuda-graph "
|
||||||
"cuda-graph capture only; disable piecewise prefill capture "
|
"capture only; prefill capture is not wired (the prefill "
|
||||||
"(e.g. --cuda-graph-backend-prefill=disabled)."
|
"graph runner bypasses the unified virtual->physical loc "
|
||||||
|
"rebind). Got --cuda-graph-backend-prefill="
|
||||||
|
f"{cfg.cuda_graph_backend_prefill!r}; pass "
|
||||||
|
"--cuda-graph-backend-prefill=disabled."
|
||||||
|
)
|
||||||
|
_cg_cfg.prefill.backend = Backend.DISABLED
|
||||||
|
logger.warning(
|
||||||
|
"--enable-unified-memory: disabling prefill cuda-graph "
|
||||||
|
"capture (not wired for the unified pool's loc rebind); "
|
||||||
|
"decode capture is unaffected."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from sglang.srt.layers.dcp import (
|
|||||||
get_dcp_lens,
|
get_dcp_lens,
|
||||||
)
|
)
|
||||||
from sglang.srt.layers.radix_attention import AttentionType
|
from sglang.srt.layers.radix_attention import AttentionType
|
||||||
|
from sglang.srt.mem_cache.base_swa_memory_pool import BaseSWAKVPool
|
||||||
from sglang.srt.mem_cache.memory_pool import KVWriteLoc
|
from sglang.srt.mem_cache.memory_pool import KVWriteLoc
|
||||||
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
|
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
|
||||||
from sglang.srt.model_executor.cuda_graph_config import (
|
from sglang.srt.model_executor.cuda_graph_config import (
|
||||||
@@ -208,14 +209,8 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
# Lets the Triton wrappers specialize on PAGE_SIZE; page_size=1 is
|
# Lets the Triton wrappers specialize on PAGE_SIZE; page_size=1 is
|
||||||
# byte-identical to the slot-based envelope.
|
# byte-identical to the slot-based envelope.
|
||||||
self.page_size = getattr(model_runner, "page_size", 1) or 1
|
self.page_size = getattr(model_runner, "page_size", 1) or 1
|
||||||
# Unified pool v2p hook (None = no-op): req_to_token holds VIRTUAL ids but
|
self.kv_index_translator = model_runner.kv_index_translator
|
||||||
# kernels need the kernel-facing id space — PHYSICAL for MHA, DENSE for the
|
self.kv_read_tables = None
|
||||||
# per-layer-view MLA pool (translate_kv_loc_for_kernel falls back to the physical
|
|
||||||
# translate when kernel_page_multiplier == 1, so preferring it is exact for
|
|
||||||
# both). Applied eagerly so the captured graph has no translate.
|
|
||||||
self._translate_kv_loc = getattr(
|
|
||||||
self.token_to_kv_pool_allocator, "translate_kv_loc_for_kernel", None
|
|
||||||
) or getattr(self.token_to_kv_pool_allocator, "translate_kv_loc", None)
|
|
||||||
self.num_draft_tokens = get_spec().speculative_num_draft_tokens
|
self.num_draft_tokens = get_spec().speculative_num_draft_tokens
|
||||||
self.speculative_num_steps = get_spec().speculative_num_steps
|
self.speculative_num_steps = get_spec().speculative_num_steps
|
||||||
self.topk = get_spec().speculative_eagle_topk or 0
|
self.topk = get_spec().speculative_eagle_topk or 0
|
||||||
@@ -466,19 +461,20 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
self,
|
self,
|
||||||
bs: int,
|
bs: int,
|
||||||
seq_lens: torch.Tensor,
|
seq_lens: torch.Tensor,
|
||||||
req_pool_indices: torch.Tensor,
|
index_table,
|
||||||
kv_indices: torch.Tensor,
|
kv_indices: torch.Tensor,
|
||||||
) -> torch.Tensor:
|
) -> torch.Tensor:
|
||||||
kv_indptr = self.kv_indptr[: bs + 1]
|
kv_indptr = self.kv_indptr[: bs + 1]
|
||||||
kv_indptr[1:] = torch.cumsum(seq_lens, dim=0)
|
kv_indptr[1:] = torch.cumsum(seq_lens, dim=0)
|
||||||
create_flashinfer_kv_indices_triton[(bs,)](
|
create_flashinfer_kv_indices_triton[(bs,)](
|
||||||
self.req_to_token,
|
index_table.ids,
|
||||||
req_pool_indices,
|
index_table.row_ids,
|
||||||
seq_lens,
|
seq_lens,
|
||||||
kv_indptr,
|
kv_indptr,
|
||||||
None,
|
None,
|
||||||
kv_indices,
|
kv_indices,
|
||||||
self.req_to_token.stride(0),
|
index_table.row_stride,
|
||||||
|
ENTRY_PAGE_SIZE=index_table.entry_page_size,
|
||||||
)
|
)
|
||||||
return kv_indptr
|
return kv_indptr
|
||||||
|
|
||||||
@@ -487,6 +483,7 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
bs: int,
|
bs: int,
|
||||||
seq_lens: torch.Tensor,
|
seq_lens: torch.Tensor,
|
||||||
req_pool_indices: torch.Tensor,
|
req_pool_indices: torch.Tensor,
|
||||||
|
index_table,
|
||||||
):
|
):
|
||||||
"""Fill KV (and SWA) cuda-graph buffers for decode/idle mode.
|
"""Fill KV (and SWA) cuda-graph buffers for decode/idle mode.
|
||||||
|
|
||||||
@@ -495,6 +492,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
``num_kv_splits_lens`` is the per-request length used to size kv splits
|
``num_kv_splits_lens`` is the per-request length used to size kv splits
|
||||||
(per-DCP-rank length clamped to >=1 when DCP is enabled, full seq_lens
|
(per-DCP-rank length clamped to >=1 when DCP is enabled, full seq_lens
|
||||||
otherwise).
|
otherwise).
|
||||||
|
|
||||||
|
``index_table`` is the captured read-index view: under the unified pool the
|
||||||
|
gathers below read the converted tables.
|
||||||
"""
|
"""
|
||||||
seq_lens = seq_lens[:bs]
|
seq_lens = seq_lens[:bs]
|
||||||
req_pool_indices = req_pool_indices[:bs]
|
req_pool_indices = req_pool_indices[:bs]
|
||||||
@@ -512,27 +512,20 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
num_kv_splits_lens = dcp_seq_lens.clamp_min(1)
|
num_kv_splits_lens = dcp_seq_lens.clamp_min(1)
|
||||||
else:
|
else:
|
||||||
kv_indptr = self._fill_kv_indptr_and_indices(
|
kv_indptr = self._fill_kv_indptr_and_indices(
|
||||||
bs, seq_lens, req_pool_indices, self.cuda_graph_kv_indices
|
bs, seq_lens, index_table, self.cuda_graph_kv_indices
|
||||||
)
|
)
|
||||||
# Unified pool: VIRTUAL ids written here are translated to PHYSICAL in
|
|
||||||
# init_forward_metadata_out_graph (replay-prep) so the captured graph
|
|
||||||
# carries zero translate nodes.
|
|
||||||
num_kv_splits_lens = seq_lens
|
num_kv_splits_lens = seq_lens
|
||||||
window_kv_indptr = self.window_kv_indptr
|
window_kv_indptr = self.window_kv_indptr
|
||||||
window_kv_lens = None
|
window_kv_lens = None
|
||||||
if self.sliding_window_size is not None and self.sliding_window_size > 0:
|
if self.sliding_window_size is not None and self.sliding_window_size > 0:
|
||||||
# Unified pool: leave the window VIRTUAL too (translated alongside the
|
|
||||||
# full kv_indices later); baseline SWA keeps the eager window translate.
|
|
||||||
window_kv_indptr, _, window_kv_lens, _ = update_sliding_window_buffer(
|
window_kv_indptr, _, window_kv_lens, _ = update_sliding_window_buffer(
|
||||||
self.window_kv_indptr,
|
self.window_kv_indptr,
|
||||||
self.req_to_token,
|
index_table,
|
||||||
self.sliding_window_size,
|
self.sliding_window_size,
|
||||||
seq_lens,
|
seq_lens,
|
||||||
req_pool_indices,
|
|
||||||
bs,
|
bs,
|
||||||
token_to_kv_pool=self.token_to_kv_pool,
|
token_to_kv_pool=self.token_to_kv_pool,
|
||||||
window_kv_indices=self.cuda_graph_window_kv_indices,
|
window_kv_indices=self.cuda_graph_window_kv_indices,
|
||||||
skip_full_to_swa_translation=(self._translate_kv_loc is not None),
|
|
||||||
)
|
)
|
||||||
return kv_indptr, window_kv_indptr, window_kv_lens, num_kv_splits_lens
|
return kv_indptr, window_kv_indptr, window_kv_lens, num_kv_splits_lens
|
||||||
|
|
||||||
@@ -540,8 +533,8 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
self,
|
self,
|
||||||
bs: int,
|
bs: int,
|
||||||
seq_lens: torch.Tensor,
|
seq_lens: torch.Tensor,
|
||||||
req_pool_indices: torch.Tensor,
|
|
||||||
spec_info,
|
spec_info,
|
||||||
|
index_table,
|
||||||
):
|
):
|
||||||
"""Fill all cuda-graph buffers for target_verify mode."""
|
"""Fill all cuda-graph buffers for target_verify mode."""
|
||||||
# Prefer the spec_info's per-request query length (DSpark draft propose
|
# Prefer the spec_info's per-request query length (DSpark draft propose
|
||||||
@@ -561,7 +554,7 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
device=self.device,
|
device=self.device,
|
||||||
)
|
)
|
||||||
kv_indptr = self._fill_kv_indptr_and_indices(
|
kv_indptr = self._fill_kv_indptr_and_indices(
|
||||||
bs, seq_lens, req_pool_indices, self.cuda_graph_kv_indices
|
bs, seq_lens, index_table, self.cuda_graph_kv_indices
|
||||||
)
|
)
|
||||||
window_kv_indptr = self.window_kv_indptr
|
window_kv_indptr = self.window_kv_indptr
|
||||||
window_kv_indices = None
|
window_kv_indices = None
|
||||||
@@ -574,10 +567,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
window_kv_indptr, window_kv_indices, _, window_kv_offsets[:bs] = (
|
window_kv_indptr, window_kv_indices, _, window_kv_offsets[:bs] = (
|
||||||
update_sliding_window_buffer(
|
update_sliding_window_buffer(
|
||||||
self.window_kv_indptr,
|
self.window_kv_indptr,
|
||||||
self.req_to_token,
|
index_table,
|
||||||
self.sliding_window_size,
|
self.sliding_window_size,
|
||||||
seq_lens[:bs],
|
seq_lens[:bs],
|
||||||
req_pool_indices,
|
|
||||||
bs,
|
bs,
|
||||||
token_to_kv_pool=self.token_to_kv_pool,
|
token_to_kv_pool=self.token_to_kv_pool,
|
||||||
window_kv_indices=window_kv_indices,
|
window_kv_indices=window_kv_indices,
|
||||||
@@ -611,9 +603,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
self,
|
self,
|
||||||
bs: int,
|
bs: int,
|
||||||
seq_lens: torch.Tensor,
|
seq_lens: torch.Tensor,
|
||||||
req_pool_indices: torch.Tensor,
|
|
||||||
forward_mode: ForwardMode,
|
forward_mode: ForwardMode,
|
||||||
spec_info: Optional[SpecInput],
|
spec_info: Optional[SpecInput],
|
||||||
|
index_table,
|
||||||
):
|
):
|
||||||
"""Fill QO + KV cuda-graph buffers for draft_extend mode."""
|
"""Fill QO + KV cuda-graph buffers for draft_extend mode."""
|
||||||
seq_lens = seq_lens[:bs]
|
seq_lens = seq_lens[:bs]
|
||||||
@@ -644,7 +636,7 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
extend_seq_lens = torch.zeros(bs, dtype=torch.int32, device=seq_lens.device)
|
extend_seq_lens = torch.zeros(bs, dtype=torch.int32, device=seq_lens.device)
|
||||||
kv_lens = torch.clamp(seq_lens - extend_seq_lens, min=0).to(torch.int32)
|
kv_lens = torch.clamp(seq_lens - extend_seq_lens, min=0).to(torch.int32)
|
||||||
kv_indptr = self._fill_kv_indptr_and_indices(
|
kv_indptr = self._fill_kv_indptr_and_indices(
|
||||||
bs, kv_lens, req_pool_indices, self.cuda_graph_kv_indices
|
bs, kv_lens, index_table, self.cuda_graph_kv_indices
|
||||||
)
|
)
|
||||||
return qo_indptr, kv_indptr, num_tokens_per_req
|
return qo_indptr, kv_indptr, num_tokens_per_req
|
||||||
|
|
||||||
@@ -689,7 +681,7 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
forward_mode=forward_mode,
|
forward_mode=forward_mode,
|
||||||
spec_info=spec_info,
|
spec_info=spec_info,
|
||||||
)
|
)
|
||||||
out_cache_loc_full_physical = self._translate_cuda_graph_shared_pool_locs(
|
out_cache_loc_full_physical = self._fill_cuda_graph_write_locs(
|
||||||
forward_batch, bs
|
forward_batch, bs
|
||||||
)
|
)
|
||||||
swa_out_cache_loc = self._fill_cuda_graph_swa_out_cache_loc(forward_batch)
|
swa_out_cache_loc = self._fill_cuda_graph_swa_out_cache_loc(forward_batch)
|
||||||
@@ -709,15 +701,16 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
spec_info=spec_info,
|
spec_info=spec_info,
|
||||||
)
|
)
|
||||||
# Metadata view is reused from capture; just refill the buffers.
|
# Metadata view is reused from capture; just refill the buffers.
|
||||||
self._translate_cuda_graph_shared_pool_locs(forward_batch, bs)
|
self._fill_cuda_graph_write_locs(forward_batch, bs)
|
||||||
self._fill_cuda_graph_swa_out_cache_loc(forward_batch)
|
self._fill_cuda_graph_swa_out_cache_loc(forward_batch)
|
||||||
|
|
||||||
def _fill_cuda_graph_swa_out_cache_loc(
|
def _fill_cuda_graph_swa_out_cache_loc(
|
||||||
self, forward_batch: ForwardBatch
|
self, forward_batch: ForwardBatch
|
||||||
) -> Optional[torch.Tensor]:
|
) -> Optional[torch.Tensor]:
|
||||||
"""Refill the SWA write-target buffer from live out_cache_loc, returning the
|
"""Refill the SWA write-target buffer from the batch's derived
|
||||||
[:n] view (None for non-SWA / multi-step draft) so the captured store reads
|
sliding-window write loc, returning the [:n] view (None for non-SWA /
|
||||||
fresh slots on replay."""
|
multi-step draft) so the captured store reads fresh slots on replay.
|
||||||
|
"""
|
||||||
if not self.use_sliding_window_kv_pool:
|
if not self.use_sliding_window_kv_pool:
|
||||||
return None
|
return None
|
||||||
out_cache_loc = forward_batch.out_cache_loc
|
out_cache_loc = forward_batch.out_cache_loc
|
||||||
@@ -726,68 +719,32 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
or out_cache_loc.shape[0] > self.cuda_graph_swa_out_cache_loc.shape[0]
|
or out_cache_loc.shape[0] > self.cuda_graph_swa_out_cache_loc.shape[0]
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
swa_write_loc = self.kv_index_translator.sliding_window_write_loc_for(
|
||||||
|
out_cache_loc
|
||||||
|
)
|
||||||
n = out_cache_loc.shape[0]
|
n = out_cache_loc.shape[0]
|
||||||
self.cuda_graph_swa_out_cache_loc[n:].zero_()
|
self.cuda_graph_swa_out_cache_loc[n:].zero_()
|
||||||
self.cuda_graph_swa_out_cache_loc[:n].copy_(
|
self.cuda_graph_swa_out_cache_loc[:n].copy_(swa_write_loc)
|
||||||
self.token_to_kv_pool.translate_loc_from_full_to_swa(out_cache_loc)
|
|
||||||
)
|
|
||||||
return self.cuda_graph_swa_out_cache_loc[:n]
|
return self.cuda_graph_swa_out_cache_loc[:n]
|
||||||
|
|
||||||
def _translate_cuda_graph_shared_pool_locs(
|
def _fill_cuda_graph_write_locs(
|
||||||
self, forward_batch: ForwardBatch, bs: int
|
self, forward_batch: ForwardBatch, bs: int
|
||||||
) -> Optional[torch.Tensor]:
|
) -> Optional[torch.Tensor]:
|
||||||
"""Unified pool: eager v2p translate of the cuda-graph read+write LOC buffers,
|
"""Copy the cuda-graph WRITE loc into the capture-stable buffer and
|
||||||
run BEFORE graph.replay() reading the live post-compaction v2p, so the
|
return the ``[:n]`` view; no-op for non-unified pools.
|
||||||
captured graph carries zero translate nodes. No-op for non-unified pools.
|
|
||||||
|
|
||||||
Read buffers (full kv_indices, SWA window) are translated IN PLACE; the
|
Runs BEFORE graph.replay() so it reads the live post-compaction v2p.
|
||||||
full-attn WRITE loc is RETURNED as the [:n] view of the backend-owned
|
The capture batch is runner-built with zeros, which is safe because
|
||||||
out_cache_loc_full_physical buffer. Eager .item() bounds are fine here
|
slot 0 is the reserved sink in every id space.
|
||||||
(out-of-graph), so no in-graph translate variant is needed.
|
|
||||||
"""
|
"""
|
||||||
if self._translate_kv_loc is None:
|
if not self.kv_index_translator.is_translating:
|
||||||
return None
|
return None
|
||||||
# seq_lens_sum is the reliable "mirror present" signal: it is
|
|
||||||
# None-preserving into the replay view, unlike seq_lens_cpu (always a
|
|
||||||
# non-None but stale slice for gpu_only batches). None -> fall back to a
|
|
||||||
# per-step D2H `.item()` on the indptr.
|
|
||||||
have_cpu_mirror = forward_batch.seq_lens_sum is not None
|
|
||||||
# Full-attention read path. kv_indptr[bs] == seq_lens_sum.
|
|
||||||
n_kv = (
|
|
||||||
forward_batch.seq_lens_sum
|
|
||||||
if have_cpu_mirror
|
|
||||||
else int(self.kv_indptr[bs].item())
|
|
||||||
)
|
|
||||||
if n_kv > 0:
|
|
||||||
self.cuda_graph_kv_indices[:n_kv] = self._translate_kv_loc(
|
|
||||||
self.cuda_graph_kv_indices[:n_kv]
|
|
||||||
)
|
|
||||||
# SWA window read path. window_kv_indptr[bs] == sum(min(seq_len, window)).
|
|
||||||
if self.sliding_window_size is not None and self.sliding_window_size > 0:
|
|
||||||
if have_cpu_mirror:
|
|
||||||
n_win = int(
|
|
||||||
forward_batch.seq_lens_cpu[:bs]
|
|
||||||
.clamp(max=self.sliding_window_size)
|
|
||||||
.sum()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
n_win = int(self.window_kv_indptr[bs].item())
|
|
||||||
if n_win > 0:
|
|
||||||
self.cuda_graph_window_kv_indices[:n_win] = (
|
|
||||||
self.token_to_kv_pool.translate_loc_from_full_to_swa(
|
|
||||||
self.cuda_graph_window_kv_indices[:n_win]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Full-attention write path: translate out_cache_loc -> physical into the
|
|
||||||
# capture-stable buffer and RETURN the [:n] view.
|
|
||||||
out_cache_loc = forward_batch.out_cache_loc
|
out_cache_loc = forward_batch.out_cache_loc
|
||||||
n = out_cache_loc.shape[0]
|
n = out_cache_loc.shape[0]
|
||||||
# Zero the padded tail first: a smaller replay batch leaves [n:] holding
|
# Zero the padded tail first: a smaller replay batch leaves [n:] holding
|
||||||
# stale ids that the captured store would write; send them to slot 0 (sink).
|
# stale ids that the captured store would write; send them to slot 0 (sink).
|
||||||
self.cuda_graph_out_cache_loc_full_physical[n:].zero_()
|
self.cuda_graph_out_cache_loc_full_physical[n:].zero_()
|
||||||
self.cuda_graph_out_cache_loc_full_physical[:n].copy_(
|
self.cuda_graph_out_cache_loc_full_physical[:n].copy_(out_cache_loc)
|
||||||
self._translate_kv_loc(out_cache_loc)
|
|
||||||
)
|
|
||||||
return self.cuda_graph_out_cache_loc_full_physical[:n]
|
return self.cuda_graph_out_cache_loc_full_physical[:n]
|
||||||
|
|
||||||
def init_forward_metadata(self, forward_batch: ForwardBatch):
|
def init_forward_metadata(self, forward_batch: ForwardBatch):
|
||||||
@@ -806,6 +763,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
|
|
||||||
if forward_batch.forward_mode.is_decode_or_idle():
|
if forward_batch.forward_mode.is_decode_or_idle():
|
||||||
if spec_info is None or spec_info.kv_indptr is None:
|
if spec_info is None or spec_info.kv_indptr is None:
|
||||||
|
index_table = self.kv_index_translator.index_table_for_batch(
|
||||||
|
forward_batch
|
||||||
|
)
|
||||||
# kv_indptr is None for draft-extend's idle batch; build from seq_lens.
|
# kv_indptr is None for draft-extend's idle batch; build from seq_lens.
|
||||||
if self.dcp_size > 1:
|
if self.dcp_size > 1:
|
||||||
# DCP: per-rank sharded KV indices, else each rank reads the
|
# DCP: per-rank sharded KV indices, else each rank reads the
|
||||||
@@ -826,11 +786,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
kv_indptr = self._fill_kv_indptr_and_indices(
|
kv_indptr = self._fill_kv_indptr_and_indices(
|
||||||
bs,
|
bs,
|
||||||
forward_batch.seq_lens,
|
forward_batch.seq_lens,
|
||||||
forward_batch.req_pool_indices,
|
index_table,
|
||||||
kv_indices,
|
kv_indices,
|
||||||
)
|
)
|
||||||
if self._translate_kv_loc is not None:
|
|
||||||
kv_indices = self._translate_kv_loc(kv_indices)
|
|
||||||
if (
|
if (
|
||||||
self.sliding_window_size is not None
|
self.sliding_window_size is not None
|
||||||
and self.sliding_window_size > 0
|
and self.sliding_window_size > 0
|
||||||
@@ -838,10 +796,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
window_kv_indptr, window_kv_indices, window_kv_lens, _ = (
|
window_kv_indptr, window_kv_indices, window_kv_lens, _ = (
|
||||||
update_sliding_window_buffer(
|
update_sliding_window_buffer(
|
||||||
self.window_kv_indptr,
|
self.window_kv_indptr,
|
||||||
self.req_to_token,
|
index_table,
|
||||||
self.sliding_window_size,
|
self.sliding_window_size,
|
||||||
forward_batch.seq_lens,
|
forward_batch.seq_lens,
|
||||||
forward_batch.req_pool_indices,
|
|
||||||
bs,
|
bs,
|
||||||
self.device,
|
self.device,
|
||||||
self.token_to_kv_pool,
|
self.token_to_kv_pool,
|
||||||
@@ -931,10 +888,11 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
kv_indices = torch.empty(
|
kv_indices = torch.empty(
|
||||||
seq_lens_sum, dtype=torch.int64, device=self.device
|
seq_lens_sum, dtype=torch.int64, device=self.device
|
||||||
)
|
)
|
||||||
|
index_table = self.kv_index_translator.index_table_for_batch(forward_batch)
|
||||||
kv_indptr = self._fill_kv_indptr_and_indices(
|
kv_indptr = self._fill_kv_indptr_and_indices(
|
||||||
bs,
|
bs,
|
||||||
forward_batch.seq_lens,
|
forward_batch.seq_lens,
|
||||||
forward_batch.req_pool_indices,
|
index_table,
|
||||||
kv_indices,
|
kv_indices,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -947,10 +905,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
window_kv_offsets,
|
window_kv_offsets,
|
||||||
) = update_sliding_window_buffer(
|
) = update_sliding_window_buffer(
|
||||||
self.window_kv_indptr,
|
self.window_kv_indptr,
|
||||||
self.req_to_token,
|
index_table,
|
||||||
self.sliding_window_size,
|
self.sliding_window_size,
|
||||||
forward_batch.seq_lens,
|
forward_batch.seq_lens,
|
||||||
forward_batch.req_pool_indices,
|
|
||||||
bs,
|
bs,
|
||||||
self.device,
|
self.device,
|
||||||
self.token_to_kv_pool,
|
self.token_to_kv_pool,
|
||||||
@@ -969,6 +926,7 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
attn_lse = None
|
attn_lse = None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
index_table = self.kv_index_translator.index_table_for_batch(forward_batch)
|
||||||
if self.dcp_size > 1:
|
if self.dcp_size > 1:
|
||||||
kv_indptr, kv_indices, _ = self._dcp_kv_indices(
|
kv_indptr, kv_indices, _ = self._dcp_kv_indices(
|
||||||
forward_batch.req_pool_indices,
|
forward_batch.req_pool_indices,
|
||||||
@@ -989,11 +947,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
kv_indptr = self._fill_kv_indptr_and_indices(
|
kv_indptr = self._fill_kv_indptr_and_indices(
|
||||||
bs,
|
bs,
|
||||||
forward_batch.extend_prefix_lens,
|
forward_batch.extend_prefix_lens,
|
||||||
forward_batch.req_pool_indices,
|
index_table,
|
||||||
kv_indices,
|
kv_indices,
|
||||||
)
|
)
|
||||||
if self._translate_kv_loc is not None:
|
|
||||||
kv_indices = self._translate_kv_loc(kv_indices)
|
|
||||||
if self.sliding_window_size is not None and self.sliding_window_size > 0:
|
if self.sliding_window_size is not None and self.sliding_window_size > 0:
|
||||||
(
|
(
|
||||||
window_kv_indptr,
|
window_kv_indptr,
|
||||||
@@ -1002,10 +958,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
window_kv_offsets,
|
window_kv_offsets,
|
||||||
) = update_sliding_window_buffer(
|
) = update_sliding_window_buffer(
|
||||||
self.window_kv_indptr,
|
self.window_kv_indptr,
|
||||||
self.req_to_token,
|
index_table,
|
||||||
self.sliding_window_size,
|
self.sliding_window_size,
|
||||||
forward_batch.extend_prefix_lens,
|
forward_batch.extend_prefix_lens,
|
||||||
forward_batch.req_pool_indices,
|
|
||||||
bs,
|
bs,
|
||||||
self.device,
|
self.device,
|
||||||
self.token_to_kv_pool,
|
self.token_to_kv_pool,
|
||||||
@@ -1027,18 +982,7 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
|
|
||||||
swa_out_cache_loc = None
|
swa_out_cache_loc = None
|
||||||
if self.use_sliding_window_kv_pool and forward_batch.out_cache_loc is not None:
|
if self.use_sliding_window_kv_pool and forward_batch.out_cache_loc is not None:
|
||||||
swa_out_cache_loc = self.token_to_kv_pool.translate_loc_from_full_to_swa(
|
swa_out_cache_loc = self.kv_index_translator.sliding_window_write_loc_for(
|
||||||
forward_batch.out_cache_loc
|
|
||||||
)
|
|
||||||
|
|
||||||
# Unified pool full-attention WRITE loc (virtual out_cache_loc -> physical),
|
|
||||||
# carried in the metadata (-> KVWriteLoc.full_loc). None for non-unified pools.
|
|
||||||
out_cache_loc_full_physical = None
|
|
||||||
if (
|
|
||||||
self._translate_kv_loc is not None
|
|
||||||
and forward_batch.out_cache_loc is not None
|
|
||||||
):
|
|
||||||
out_cache_loc_full_physical = self._translate_kv_loc(
|
|
||||||
forward_batch.out_cache_loc
|
forward_batch.out_cache_loc
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1058,7 +1002,11 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
window_kv_offsets,
|
window_kv_offsets,
|
||||||
swa_attn_logits=swa_attn_logits,
|
swa_attn_logits=swa_attn_logits,
|
||||||
swa_out_cache_loc=swa_out_cache_loc,
|
swa_out_cache_loc=swa_out_cache_loc,
|
||||||
out_cache_loc_full_physical=out_cache_loc_full_physical,
|
out_cache_loc_full_physical=(
|
||||||
|
forward_batch.out_cache_loc
|
||||||
|
if self.kv_index_translator.is_translating
|
||||||
|
else None
|
||||||
|
),
|
||||||
lean_Mp=lean_Mp,
|
lean_Mp=lean_Mp,
|
||||||
lean_Lp=lean_Lp,
|
lean_Lp=lean_Lp,
|
||||||
lean_Op=lean_Op,
|
lean_Op=lean_Op,
|
||||||
@@ -1179,7 +1127,7 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
device=self.device,
|
device=self.device,
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._translate_kv_loc is not None:
|
if self.kv_index_translator.is_translating:
|
||||||
# Unified pool full-attention write-target buffer, refilled at replay
|
# Unified pool full-attention write-target buffer, refilled at replay
|
||||||
# (-> KVWriteLoc.full_loc). Capture-stable, mirrors cuda_graph_swa_out_cache_loc.
|
# (-> KVWriteLoc.full_loc). Capture-stable, mirrors cuda_graph_swa_out_cache_loc.
|
||||||
self.cuda_graph_out_cache_loc_full_physical = torch.zeros(
|
self.cuda_graph_out_cache_loc_full_physical = torch.zeros(
|
||||||
@@ -1187,6 +1135,9 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
dtype=torch.int64,
|
dtype=torch.int64,
|
||||||
device=self.device,
|
device=self.device,
|
||||||
)
|
)
|
||||||
|
self.kv_read_tables = self.kv_index_translator.make_capture_tables(
|
||||||
|
max_bs=max_bs, max_context_len=self.max_context_len
|
||||||
|
)
|
||||||
|
|
||||||
def _build_cuda_graph_forward_metadata(
|
def _build_cuda_graph_forward_metadata(
|
||||||
self,
|
self,
|
||||||
@@ -1300,16 +1251,21 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
seq_lens: torch.Tensor,
|
seq_lens: torch.Tensor,
|
||||||
forward_mode: ForwardMode,
|
forward_mode: ForwardMode,
|
||||||
spec_info: Optional[SpecInput],
|
spec_info: Optional[SpecInput],
|
||||||
):
|
) -> None:
|
||||||
"""Shared capture+replay body for the cuda-graph init path.
|
"""Shared capture+replay body for the cuda-graph init path.
|
||||||
|
|
||||||
Public entry: :py:meth:`init_forward_metadata_out_graph`.
|
Public entry: :py:meth:`init_forward_metadata_out_graph`.
|
||||||
"""
|
"""
|
||||||
# NOTE: encoder_lens expected to be zeros or None
|
# NOTE: encoder_lens expected to be zeros or None
|
||||||
|
index_table = self.kv_index_translator.build_index_table(
|
||||||
|
req_pool_indices=req_pool_indices,
|
||||||
|
seq_lens=seq_lens,
|
||||||
|
into=self.kv_read_tables,
|
||||||
|
)
|
||||||
if forward_mode.is_decode_or_idle():
|
if forward_mode.is_decode_or_idle():
|
||||||
assert spec_info is None, "Multi-step cuda graph init is not done here."
|
assert spec_info is None, "Multi-step cuda graph init is not done here."
|
||||||
_, _, window_kv_lens, num_kv_splits_lens = self._update_decode_kv_buffers(
|
_, _, window_kv_lens, num_kv_splits_lens = self._update_decode_kv_buffers(
|
||||||
bs, seq_lens, req_pool_indices
|
bs, seq_lens, req_pool_indices, index_table
|
||||||
)
|
)
|
||||||
self.get_num_kv_splits(
|
self.get_num_kv_splits(
|
||||||
self.cuda_graph_num_kv_splits[:bs], num_kv_splits_lens[:bs]
|
self.cuda_graph_num_kv_splits[:bs], num_kv_splits_lens[:bs]
|
||||||
@@ -1320,12 +1276,10 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
)
|
)
|
||||||
elif forward_mode.is_target_verify():
|
elif forward_mode.is_target_verify():
|
||||||
bs = len(req_pool_indices)
|
bs = len(req_pool_indices)
|
||||||
self._update_target_verify_buffers(
|
self._update_target_verify_buffers(bs, seq_lens, spec_info, index_table)
|
||||||
bs, seq_lens, req_pool_indices, spec_info
|
|
||||||
)
|
|
||||||
elif forward_mode.is_draft_extend_v2():
|
elif forward_mode.is_draft_extend_v2():
|
||||||
self._update_draft_extend_buffers(
|
self._update_draft_extend_buffers(
|
||||||
bs, seq_lens, req_pool_indices, forward_mode, spec_info
|
bs, seq_lens, forward_mode, spec_info, index_table
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -1402,10 +1356,11 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
pool = self.token_to_kv_pool
|
pool = self.token_to_kv_pool
|
||||||
cache_loc = forward_batch.out_cache_loc
|
cache_loc = forward_batch.out_cache_loc
|
||||||
if isinstance(pool, SWAKVPool) and pool.layers_mapping[layer.layer_id][1]:
|
if isinstance(pool, SWAKVPool) and pool.layers_mapping[layer.layer_id][1]:
|
||||||
cache_loc = pool.translate_loc_from_full_to_swa(cache_loc)
|
assert self.forward_metadata.swa_out_cache_loc is not None, (
|
||||||
elif self._translate_kv_loc is not None:
|
"window-layer read-back before the metadata carried a "
|
||||||
# Unified pool: buffers are indexed in the kernel-facing id space.
|
"sliding-window write loc"
|
||||||
cache_loc = self._translate_kv_loc(cache_loc)
|
)
|
||||||
|
cache_loc = self.forward_metadata.swa_out_cache_loc
|
||||||
k_buffer, v_buffer = pool.get_kv_buffer(layer.layer_id)
|
k_buffer, v_buffer = pool.get_kv_buffer(layer.layer_id)
|
||||||
k = k_buffer[cache_loc]
|
k = k_buffer[cache_loc]
|
||||||
v = v_buffer[cache_loc]
|
v = v_buffer[cache_loc]
|
||||||
@@ -1776,15 +1731,12 @@ class TritonAttnBackend(AttentionBackend):
|
|||||||
and isinstance(pool, SWAKVPool)
|
and isinstance(pool, SWAKVPool)
|
||||||
and pool.layers_mapping[layer.layer_id][1]
|
and pool.layers_mapping[layer.layer_id][1]
|
||||||
):
|
):
|
||||||
# Consumes VIRTUAL ids, so it must see out_cache_loc untranslated.
|
extend_kv_indices = self.forward_metadata.swa_out_cache_loc
|
||||||
extend_kv_indices = pool.translate_loc_from_full_to_swa(extend_kv_indices)
|
assert extend_kv_indices is not None, (
|
||||||
|
"window-layer extend before the metadata carried a "
|
||||||
|
"sliding-window write loc"
|
||||||
|
)
|
||||||
elif self.forward_metadata.out_cache_loc_full_physical is not None:
|
elif self.forward_metadata.out_cache_loc_full_physical is not None:
|
||||||
# Unified pool: this kernel reads the extend half OUT OF THE POOL (the
|
|
||||||
# 2-stage path takes it from the k/v arguments), so it needs the same
|
|
||||||
# translated loc the KV write uses -- otherwise the prefix is read at
|
|
||||||
# physical ids and the extend tokens at virtual ones. Reuse the
|
|
||||||
# per-forward translation rather than re-translating: this runs once
|
|
||||||
# per layer.
|
|
||||||
extend_kv_indices = self.forward_metadata.out_cache_loc_full_physical
|
extend_kv_indices = self.forward_metadata.out_cache_loc_full_physical
|
||||||
|
|
||||||
# Handle cases where extend_seq_lens or extend_start_loc might not be set
|
# Handle cases where extend_seq_lens or extend_start_loc might not be set
|
||||||
@@ -2246,15 +2198,13 @@ class TritonMultiStepDraftBackend:
|
|||||||
|
|
||||||
def update_sliding_window_buffer(
|
def update_sliding_window_buffer(
|
||||||
window_kv_indptr,
|
window_kv_indptr,
|
||||||
req_to_token,
|
index_table,
|
||||||
sliding_window_size,
|
sliding_window_size,
|
||||||
seq_lens,
|
seq_lens,
|
||||||
req_pool_indices,
|
|
||||||
bs,
|
bs,
|
||||||
device=None,
|
device=None,
|
||||||
token_to_kv_pool=None,
|
token_to_kv_pool=None,
|
||||||
window_kv_indices=None,
|
window_kv_indices=None,
|
||||||
skip_full_to_swa_translation=False,
|
|
||||||
):
|
):
|
||||||
"""Fill window KV buffers for sliding-window attention.
|
"""Fill window KV buffers for sliding-window attention.
|
||||||
|
|
||||||
@@ -2262,13 +2212,12 @@ def update_sliding_window_buffer(
|
|||||||
path); omit it (or pass ``None``) to allocate a fresh tensor (eager path,
|
path); omit it (or pass ``None``) to allocate a fresh tensor (eager path,
|
||||||
requires ``device``).
|
requires ``device``).
|
||||||
|
|
||||||
``skip_full_to_swa_translation=True`` leaves ``window_kv_indices`` as VIRTUAL
|
``index_table`` is the batch's read-index source view. Unified pool: the
|
||||||
full-token ids (no eager full->swa translate). The unified-memory-pool cuda-graph
|
gather reads the parallel SWA array (built directly from virtual ids
|
||||||
builder passes this so the window translate is deferred to
|
through the swa side's own v2p), so the window indices come out
|
||||||
``TritonAttnBackend._translate_cuda_graph_shared_pool_locs`` (run in
|
already swa-side ids -- no translate here, eager or captured. Static SWA
|
||||||
``init_forward_metadata_out_graph``, BEFORE ``graph.replay()``), which reads
|
pools gather full-token ids from req_to_token and keep the legacy
|
||||||
the live v2p and rewrites the static window buffer to swa-physical in place;
|
full->swa translate below.
|
||||||
baseline SWA leaves it False (eager).
|
|
||||||
"""
|
"""
|
||||||
window_kv_lens = torch.minimum(
|
window_kv_lens = torch.minimum(
|
||||||
seq_lens,
|
seq_lens,
|
||||||
@@ -2281,18 +2230,18 @@ def update_sliding_window_buffer(
|
|||||||
window_kv_indptr[-1], dtype=torch.int64, device=device
|
window_kv_indptr[-1], dtype=torch.int64, device=device
|
||||||
)
|
)
|
||||||
window_kv_start_idx = seq_lens - window_kv_lens
|
window_kv_start_idx = seq_lens - window_kv_lens
|
||||||
|
source_ids = index_table.sliding_window_read_ids()
|
||||||
create_flashinfer_kv_indices_triton[(bs,)](
|
create_flashinfer_kv_indices_triton[(bs,)](
|
||||||
req_to_token,
|
source_ids,
|
||||||
req_pool_indices,
|
index_table.row_ids,
|
||||||
window_kv_lens,
|
window_kv_lens,
|
||||||
window_kv_indptr,
|
window_kv_indptr,
|
||||||
window_kv_start_idx,
|
window_kv_start_idx,
|
||||||
window_kv_indices,
|
window_kv_indices,
|
||||||
req_to_token.stride(0),
|
source_ids.stride(0),
|
||||||
|
ENTRY_PAGE_SIZE=index_table.entry_page_size,
|
||||||
)
|
)
|
||||||
if not skip_full_to_swa_translation and hasattr(
|
if not index_table.is_translated and isinstance(token_to_kv_pool, BaseSWAKVPool):
|
||||||
token_to_kv_pool, "translate_loc_from_full_to_swa"
|
|
||||||
):
|
|
||||||
kv_last_index = window_kv_indptr[-1]
|
kv_last_index = window_kv_indptr[-1]
|
||||||
window_kv_indices[:kv_last_index] = (
|
window_kv_indices[:kv_last_index] = (
|
||||||
token_to_kv_pool.translate_loc_from_full_to_swa(
|
token_to_kv_pool.translate_loc_from_full_to_swa(
|
||||||
|
|||||||
@@ -294,11 +294,8 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
|||||||
self._v2p_page_table = _hooks.v2p_page_table
|
self._v2p_page_table = _hooks.v2p_page_table
|
||||||
self._kernel_page_multiplier = _hooks.kernel_page_multiplier
|
self._kernel_page_multiplier = _hooks.kernel_page_multiplier
|
||||||
self._unified_mla = _hooks.enabled
|
self._unified_mla = _hooks.enabled
|
||||||
# virtual token id -> DENSE kernel-facing id, for the KV write loc.
|
# Per-forward kernel-facing write loc ([:n] view of a capture-stable buffer);
|
||||||
self._translate_kv_loc_dense = _hooks.translate_kv_loc_for_kernel
|
# None on the eager path, which passes out_cache_loc straight through.
|
||||||
# Per-forward kernel-facing write loc ([:n] view of a capture-stable buffer),
|
|
||||||
# set by the cuda-graph out-graph hook; None on the eager path (where the
|
|
||||||
# write translates through the pool's _full_translate hook instead).
|
|
||||||
self._decode_kernel_loc: Optional[torch.Tensor] = None
|
self._decode_kernel_loc: Optional[torch.Tensor] = None
|
||||||
self.cuda_graph_out_cache_loc_kernel: Optional[torch.Tensor] = None
|
self.cuda_graph_out_cache_loc_kernel: Optional[torch.Tensor] = None
|
||||||
# Fused KV-scatter + q-concat on the decode dense-loc path (one launch
|
# Fused KV-scatter + q-concat on the decode dense-loc path (one launch
|
||||||
@@ -636,7 +633,7 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
|||||||
out_cache_loc = forward_batch.out_cache_loc
|
out_cache_loc = forward_batch.out_cache_loc
|
||||||
n = out_cache_loc.shape[0]
|
n = out_cache_loc.shape[0]
|
||||||
dst = self.cuda_graph_out_cache_loc_kernel[:n]
|
dst = self.cuda_graph_out_cache_loc_kernel[:n]
|
||||||
self._translate_kv_loc_dense(out_cache_loc, out=dst)
|
dst.copy_(out_cache_loc)
|
||||||
# Replay-prep receives the RAW (unpadded) out_cache_loc
|
# Replay-prep receives the RAW (unpadded) out_cache_loc
|
||||||
# (build_replay_fb_view), but the captured write kernel consumes the
|
# (build_replay_fb_view), but the captured write kernel consumes the
|
||||||
# full captured tier of this buffer. Zero the tail so pad rows write
|
# full captured tier of this buffer. Zero the tail so pad rows write
|
||||||
@@ -651,8 +648,6 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
|||||||
|
|
||||||
def init_forward_metadata(self, forward_batch: ForwardBatch):
|
def init_forward_metadata(self, forward_batch: ForwardBatch):
|
||||||
"""Initialize the metadata for a forward pass."""
|
"""Initialize the metadata for a forward pass."""
|
||||||
# Eager path: no capture-stable kernel-facing write loc; the pool's _full_translate
|
|
||||||
# hook translates the write loc (safe out of a cuda graph).
|
|
||||||
self._decode_kernel_loc = None
|
self._decode_kernel_loc = None
|
||||||
# Delegate to parent for non-decode modes.
|
# Delegate to parent for non-decode modes.
|
||||||
if (
|
if (
|
||||||
@@ -950,11 +945,9 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
|||||||
q: torch.Tensor,
|
q: torch.Tensor,
|
||||||
q_rope: torch.Tensor,
|
q_rope: torch.Tensor,
|
||||||
) -> Optional[torch.Tensor]:
|
) -> Optional[torch.Tensor]:
|
||||||
"""Decode: scatter the KV row at ``loc`` (already physical — the
|
"""Decode: scatter the KV row at ``loc`` (already kernel-facing) and
|
||||||
dense-loc buffer on the unified pool, or out_cache_loc on the static
|
build the [q_nope | q_rope] fmha query in one kernel launch (saves one
|
||||||
pool where ``_full_translate`` is identity) and build the
|
launch per MLA layer and keeps the PDL chain intact).
|
||||||
[q_nope | q_rope] fmha query in one kernel launch (saves one launch
|
|
||||||
per MLA layer and keeps the PDL chain intact).
|
|
||||||
|
|
||||||
Returns the concatenated query, or None when the fused kernel does
|
Returns the concatenated query, or None when the fused kernel does
|
||||||
not cover the inputs (caller falls back to the two-kernel path).
|
not cover the inputs (caller falls back to the two-kernel path).
|
||||||
@@ -1100,8 +1093,6 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
|||||||
k is not None and k_rope is not None
|
k is not None and k_rope is not None
|
||||||
), "For populating trtllm_mla kv cache, both k_nope and k_rope should be not None."
|
), "For populating trtllm_mla kv cache, both k_nope and k_rope should be not None."
|
||||||
if self._decode_kernel_loc is not None:
|
if self._decode_kernel_loc is not None:
|
||||||
# cuda-graph path: kernel-facing write loc precomputed out-of-graph, so
|
|
||||||
# the in-graph write captures no translate allocation.
|
|
||||||
if merge_query and self._fused_set_kv_concat_q:
|
if merge_query and self._fused_set_kv_concat_q:
|
||||||
# Fused: KV scatter + [q_nope | q_rope] concat in one
|
# Fused: KV scatter + [q_nope | q_rope] concat in one
|
||||||
# launch; None when the inputs are not covered.
|
# launch; None when the inputs are not covered.
|
||||||
@@ -1115,21 +1106,16 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
|||||||
)
|
)
|
||||||
if query is None:
|
if query is None:
|
||||||
self.token_to_kv_pool.set_mla_kv_buffer(
|
self.token_to_kv_pool.set_mla_kv_buffer(
|
||||||
layer,
|
layer, self._decode_kernel_loc, k, k_rope
|
||||||
self._decode_kernel_loc,
|
|
||||||
k,
|
|
||||||
k_rope,
|
|
||||||
loc_is_kernel_facing=True,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# eager (or static pool): the pool's _full_translate handles it.
|
# eager (or static pool): out_cache_loc is kernel-facing.
|
||||||
if (
|
if (
|
||||||
merge_query
|
merge_query
|
||||||
and self._fused_set_kv_concat_q
|
and self._fused_set_kv_concat_q
|
||||||
and not self._unified_mla
|
and not self._unified_mla
|
||||||
):
|
):
|
||||||
# Static pool: _full_translate is identity, so
|
# Static pool only, conservatively.
|
||||||
# out_cache_loc is already the physical write loc.
|
|
||||||
query = self._set_kv_and_concat_q_fused(
|
query = self._set_kv_and_concat_q_fused(
|
||||||
layer=layer,
|
layer=layer,
|
||||||
loc=forward_batch.out_cache_loc,
|
loc=forward_batch.out_cache_loc,
|
||||||
@@ -1265,7 +1251,7 @@ class TRTLLMMLABackend(FlashInferMLAAttnBackend):
|
|||||||
), "For populating trtllm_mla kv cache, both k_nope and k_rope should be not None."
|
), "For populating trtllm_mla kv cache, both k_nope and k_rope should be not None."
|
||||||
if self._decode_kernel_loc is not None:
|
if self._decode_kernel_loc is not None:
|
||||||
self.token_to_kv_pool.set_mla_kv_buffer(
|
self.token_to_kv_pool.set_mla_kv_buffer(
|
||||||
layer, self._decode_kernel_loc, k, k_rope, loc_is_kernel_facing=True
|
layer, self._decode_kernel_loc, k, k_rope
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.token_to_kv_pool.set_mla_kv_buffer(
|
self.token_to_kv_pool.set_mla_kv_buffer(
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
"""Turns the KV ids stored in `req_to_token` into ids attention kernels can use.
|
||||||
|
|
||||||
|
A KV slot can be named in three id spaces:
|
||||||
|
|
||||||
|
* **virtual** - what `req_to_token` stores. Keeps naming the same logical
|
||||||
|
slot even after the pool moves data around.
|
||||||
|
* **physical** - where that slot sits in the pool right now.
|
||||||
|
* **kernel-facing** - what a kernel can index the per-layer K/V tensors
|
||||||
|
with. Same as physical on a plain pool; under the unified pool it is the
|
||||||
|
physical page scaled by the per-page block count.
|
||||||
|
|
||||||
|
All three coincide on a plain pool, so nothing here does any work there.
|
||||||
|
|
||||||
|
Backends get a `KVIndexTable`, which answers "what do I gather from, and
|
||||||
|
which row is mine?":
|
||||||
|
|
||||||
|
ids[row_ids[b], pos]
|
||||||
|
|
||||||
|
plain pool : ids = req_to_token, row_ids = req_pool_indices (those very
|
||||||
|
objects - no copy, no kernel)
|
||||||
|
unified : ids = a built array of kernel-facing ids,
|
||||||
|
row_ids = arange(batch_size)
|
||||||
|
|
||||||
|
Backends call their own copy a *page table* (fa3) or a *block table*
|
||||||
|
(trtllm); here it is the **index table**.
|
||||||
|
|
||||||
|
Converting only ever rewrites the page number and keeps the in-page offset, so
|
||||||
|
one page-granular table serves both kinds of consumer: a block-table backend
|
||||||
|
uses its rows as-is, and one that wants flat per-token ids rebuilds them as
|
||||||
|
|
||||||
|
token_id = entry * entry_page_size + pos % entry_page_size
|
||||||
|
|
||||||
|
WRITES, IN TWO PHASES. The full-side write loc is rebound to kernel-facing
|
||||||
|
ids at ForwardBatch construction - the earliest consumer can snapshot it
|
||||||
|
right after. The sliding-window write loc is derived at the same moment as
|
||||||
|
read table, into the same index table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import weakref
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from sglang.kernels.ops.kvcache.kv_read_table import build_kv_read_table
|
||||||
|
from sglang.srt.mem_cache.multi_ended_allocator import (
|
||||||
|
UnifiedMambaTokenToKVPoolAllocator,
|
||||||
|
UnifiedSWATokenToKVPoolAllocator,
|
||||||
|
)
|
||||||
|
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
|
||||||
|
|
||||||
|
|
||||||
|
class KVReadTables(msgspec.Struct, frozen=True):
|
||||||
|
"""One capture-stable destination, however many id spaces the pool has.
|
||||||
|
|
||||||
|
A backend holds this and hands it back to `build_index_table(into=...)`;
|
||||||
|
it never has to know whether there is a sliding-window space behind it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
full: torch.Tensor
|
||||||
|
sliding_window: Optional[torch.Tensor]
|
||||||
|
|
||||||
|
|
||||||
|
class KVIndexTable(msgspec.Struct, frozen=True):
|
||||||
|
"""Collection of what one batch gathers from."""
|
||||||
|
|
||||||
|
ids: torch.Tensor # 2-D array of KV ids to gather from
|
||||||
|
row_ids: torch.Tensor # which row belongs to batch lane b
|
||||||
|
row_stride: int # stride between rows of `ids`, in elements
|
||||||
|
entry_page_size: int # what one entry covers: 1 = a token, N = a page of N
|
||||||
|
is_translated: bool # entries are already kernel-facing ids
|
||||||
|
sliding_window_ids: Optional[torch.Tensor] # SWA models: the parallel swa array
|
||||||
|
|
||||||
|
def sliding_window_read_ids(self) -> torch.Tensor:
|
||||||
|
"""Which array a sliding-window gather reads: the parallel swa array
|
||||||
|
when translated, else the full-attention array, which the caller maps
|
||||||
|
through the pool's own full->swa map."""
|
||||||
|
return self.sliding_window_ids if self.is_translated else self.ids
|
||||||
|
|
||||||
|
|
||||||
|
class KVIndexTranslator:
|
||||||
|
"""Built once per ModelRunner."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
req_to_token: torch.Tensor,
|
||||||
|
token_to_kv_pool_allocator,
|
||||||
|
token_to_kv_pool,
|
||||||
|
page_size: int,
|
||||||
|
device: str,
|
||||||
|
):
|
||||||
|
self.req_to_token = req_to_token
|
||||||
|
self.page_size = page_size
|
||||||
|
self.device = device
|
||||||
|
|
||||||
|
self.is_translating = (
|
||||||
|
isinstance(
|
||||||
|
token_to_kv_pool_allocator,
|
||||||
|
(UnifiedMambaTokenToKVPoolAllocator, UnifiedSWATokenToKVPoolAllocator),
|
||||||
|
)
|
||||||
|
and token_to_kv_pool_allocator.get_kvcache() is token_to_kv_pool
|
||||||
|
)
|
||||||
|
if self.is_translating:
|
||||||
|
alloc = token_to_kv_pool_allocator
|
||||||
|
self._full_v2p_table = alloc.full_v2p_page_table
|
||||||
|
self._full_p2v_table = alloc.full_p2v_page_table
|
||||||
|
self._full_page_multiplier = alloc.kernel_page_multiplier
|
||||||
|
self._translate_full = alloc.translate_kv_loc_for_kernel
|
||||||
|
if isinstance(alloc, UnifiedSWATokenToKVPoolAllocator):
|
||||||
|
self._swa_v2p_table = alloc.swa_v2p_page_table
|
||||||
|
self._swa_page_multiplier = alloc.swa_kernel_page_multiplier
|
||||||
|
self._swa_write_loc_from_full = self._swa_write_loc_unified
|
||||||
|
else:
|
||||||
|
self._swa_v2p_table = None
|
||||||
|
self._swa_page_multiplier = 1
|
||||||
|
self._swa_write_loc_from_full = None
|
||||||
|
else:
|
||||||
|
self._full_v2p_table = None
|
||||||
|
self._full_p2v_table = None
|
||||||
|
self._full_page_multiplier = 1
|
||||||
|
self._translate_full = None
|
||||||
|
self._swa_v2p_table = None
|
||||||
|
self._swa_page_multiplier = 1
|
||||||
|
self._swa_write_loc_from_full = (
|
||||||
|
token_to_kv_pool.translate_loc_from_full_to_swa
|
||||||
|
if isinstance(token_to_kv_pool, SWAKVPool)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
self._rows: Optional[torch.Tensor] = (
|
||||||
|
torch.arange(req_to_token.shape[0], dtype=torch.int64, device=device)
|
||||||
|
if self.is_translating
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
self._index_table_memo: Optional[Tuple[weakref.ref, KVIndexTable]] = None
|
||||||
|
|
||||||
|
def make_capture_tables(
|
||||||
|
self, *, max_bs: int, max_context_len: int
|
||||||
|
) -> Optional[KVReadTables]:
|
||||||
|
"""Capture-stable destinations for a backend to own, or None when this
|
||||||
|
pool needs no translation and the backend will never fill any.
|
||||||
|
|
||||||
|
Zero-filled: entry 0 is the reserved padding slot in every id space, so
|
||||||
|
a captured graph replaying before its first refresh reads padding, not
|
||||||
|
garbage.
|
||||||
|
"""
|
||||||
|
if not self.is_translating:
|
||||||
|
return None
|
||||||
|
max_pages = -(-max_context_len // self.page_size)
|
||||||
|
|
||||||
|
def _zeros():
|
||||||
|
return torch.zeros(
|
||||||
|
(max_bs, max_pages), dtype=torch.int32, device=self.device
|
||||||
|
)
|
||||||
|
|
||||||
|
return KVReadTables(
|
||||||
|
full=_zeros(),
|
||||||
|
sliding_window=_zeros() if self._swa_v2p_table is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- per-batch view --------------------------------------------------------
|
||||||
|
|
||||||
|
def build_index_table(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
req_pool_indices: torch.Tensor,
|
||||||
|
seq_lens: torch.Tensor,
|
||||||
|
max_pages: Optional[int] = None,
|
||||||
|
into: Optional[KVReadTables] = None,
|
||||||
|
) -> KVIndexTable:
|
||||||
|
"""The one per-batch entry point.
|
||||||
|
|
||||||
|
Non-unified: the raw ``(req_to_token, req_pool_indices)`` passthrough,
|
||||||
|
no tensor ops and no copies. Unified: fills each row's live prefix and
|
||||||
|
returns the table WHOLE, so a caller needing a stable pointer (a
|
||||||
|
captured graph bakes it) passes its own tables in ``into``;
|
||||||
|
``into=None`` allocates of width ``max_pages`` instead.
|
||||||
|
"""
|
||||||
|
if not self.is_translating:
|
||||||
|
return KVIndexTable(
|
||||||
|
ids=self.req_to_token,
|
||||||
|
row_ids=req_pool_indices,
|
||||||
|
row_stride=self.req_to_token.stride(0),
|
||||||
|
entry_page_size=1,
|
||||||
|
is_translated=False,
|
||||||
|
sliding_window_ids=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
bs = int(req_pool_indices.numel())
|
||||||
|
if into is not None:
|
||||||
|
out_full = into.full
|
||||||
|
out_swa = into.sliding_window
|
||||||
|
# A caller-owned table may be padded wider than req_to_token's span
|
||||||
|
# (trtllm_mla / flashmla pad to a page-count bound); the columns
|
||||||
|
# past it have no source to read, so stop there.
|
||||||
|
width = min(
|
||||||
|
out_full.shape[1] if max_pages is None else max_pages,
|
||||||
|
-(-self.req_to_token.shape[1] // self.page_size),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert max_pages is not None, (
|
||||||
|
"KVIndexTranslator.build_index_table: allocating needs max_pages "
|
||||||
|
"(from the batch's seq_lens_cpu max)"
|
||||||
|
)
|
||||||
|
width = max_pages
|
||||||
|
out_full = torch.zeros((bs, width), dtype=torch.int32, device=self.device)
|
||||||
|
out_swa = (
|
||||||
|
torch.zeros((bs, width), dtype=torch.int32, device=self.device)
|
||||||
|
if self._swa_v2p_table is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
build_kv_read_table(
|
||||||
|
req_to_token=self.req_to_token,
|
||||||
|
req_pool_indices=req_pool_indices,
|
||||||
|
seq_lens=seq_lens,
|
||||||
|
v2p=self._full_v2p_table,
|
||||||
|
multiplier=self._full_page_multiplier,
|
||||||
|
page_size=self.page_size,
|
||||||
|
max_pages=width,
|
||||||
|
out=out_full,
|
||||||
|
)
|
||||||
|
if out_swa is not None:
|
||||||
|
build_kv_read_table(
|
||||||
|
req_to_token=self.req_to_token,
|
||||||
|
req_pool_indices=req_pool_indices,
|
||||||
|
seq_lens=seq_lens,
|
||||||
|
v2p=self._swa_v2p_table,
|
||||||
|
multiplier=self._swa_page_multiplier,
|
||||||
|
page_size=self.page_size,
|
||||||
|
max_pages=width,
|
||||||
|
out=out_swa,
|
||||||
|
)
|
||||||
|
return KVIndexTable(
|
||||||
|
ids=out_full,
|
||||||
|
row_ids=self._rows[:bs],
|
||||||
|
row_stride=out_full.stride(0),
|
||||||
|
entry_page_size=self.page_size,
|
||||||
|
is_translated=True,
|
||||||
|
sliding_window_ids=out_swa,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fill_read_table(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
out: torch.Tensor,
|
||||||
|
req_pool_indices: torch.Tensor,
|
||||||
|
seq_lens: torch.Tensor,
|
||||||
|
) -> None:
|
||||||
|
"""`build_index_table(into=...)` for a caller that owns a bare block
|
||||||
|
table rather than a KVReadTables: trtllm_mla / flashmla consume that
|
||||||
|
table directly, its rows already being the index table's rows.
|
||||||
|
"""
|
||||||
|
assert (
|
||||||
|
self.is_translating
|
||||||
|
), "KVIndexTranslator.fill_read_table on a pool that needs no translation"
|
||||||
|
self.build_index_table(
|
||||||
|
req_pool_indices=req_pool_indices,
|
||||||
|
seq_lens=seq_lens,
|
||||||
|
into=KVReadTables(full=out, sliding_window=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def index_table_for_batch(self, forward_batch) -> KVIndexTable:
|
||||||
|
"""Eager per-batch view, memoized in one slot keyed by batch identity
|
||||||
|
so multi-consumer metadata builds share a build. The next batch
|
||||||
|
replaces the slot; consumers only read during their own build. The
|
||||||
|
captured path does not memoize -- it refreshes its buffers per
|
||||||
|
replay."""
|
||||||
|
memo = self._index_table_memo
|
||||||
|
if memo is not None and memo[0]() is forward_batch:
|
||||||
|
return memo[1]
|
||||||
|
max_pages = None
|
||||||
|
if self.is_translating:
|
||||||
|
# `seq_lens_cpu` is a non-None but STALE slice on a gpu_only
|
||||||
|
# batch; `seq_lens_sum` is the signal that it is live. A stale max
|
||||||
|
# under-sizes the table and the tail then reads as the sink.
|
||||||
|
slc = forward_batch.seq_lens_cpu
|
||||||
|
if (
|
||||||
|
forward_batch.seq_lens_sum is not None
|
||||||
|
and slc is not None
|
||||||
|
and slc.numel() > 0
|
||||||
|
):
|
||||||
|
max_seq = int(slc.max())
|
||||||
|
else:
|
||||||
|
max_seq = self.req_to_token.shape[1]
|
||||||
|
max_pages = max(-(-max_seq // self.page_size), 1)
|
||||||
|
view = self.build_index_table(
|
||||||
|
req_pool_indices=forward_batch.req_pool_indices,
|
||||||
|
seq_lens=forward_batch.seq_lens,
|
||||||
|
max_pages=max_pages,
|
||||||
|
)
|
||||||
|
self._index_table_memo = (weakref.ref(forward_batch), view)
|
||||||
|
return view
|
||||||
|
|
||||||
|
# -- write loc (phase 1; phase 2 lives in build_index_table) ----------------
|
||||||
|
|
||||||
|
def rebind_write_loc(self, forward_batch) -> None:
|
||||||
|
"""Phase 1 of the WRITE contract: translate the batch's write loc to
|
||||||
|
FULL-side kernel-facing ids exactly once, at ForwardBatch
|
||||||
|
construction. No-op on non-unified pools.
|
||||||
|
|
||||||
|
REBIND, never mutate: the translate returns a FRESH tensor, so the
|
||||||
|
ScheduleBatch's aliased tensor stays VIRTUAL for the radix / accept /
|
||||||
|
in-flight machinery that reads it.
|
||||||
|
"""
|
||||||
|
self._index_table_memo = None
|
||||||
|
if not self.is_translating or forward_batch.out_cache_loc is None:
|
||||||
|
return
|
||||||
|
forward_batch.out_cache_loc = self._translate_full(forward_batch.out_cache_loc)
|
||||||
|
|
||||||
|
def sliding_window_write_loc_for(
|
||||||
|
self, out_cache_loc: Optional[torch.Tensor]
|
||||||
|
) -> Optional[torch.Tensor]:
|
||||||
|
"""This batch's sliding-window write loc, or None when there is no loc
|
||||||
|
this forward or the pool has no sliding-window id space."""
|
||||||
|
if out_cache_loc is None or self._swa_write_loc_from_full is None:
|
||||||
|
return None
|
||||||
|
return self._swa_write_loc_from_full(out_cache_loc)
|
||||||
|
|
||||||
|
def _swa_write_loc_unified(self, kernel_loc: torch.Tensor) -> torch.Tensor:
|
||||||
|
"""Sliding-window write loc, derived pointwise from FULL-side
|
||||||
|
kernel-facing values (phase 2 of the write contract).
|
||||||
|
"""
|
||||||
|
full_stride = self.page_size * self._full_page_multiplier
|
||||||
|
offset = kernel_loc % full_stride # == virtual_token % page_size
|
||||||
|
# An unmapped physical page reads back as -1; clamp it rather than let
|
||||||
|
# the gather wrap onto the v2p table's last element.
|
||||||
|
virt_page = self._full_p2v_table[kernel_loc // full_stride].clamp_(min=0)
|
||||||
|
swa_stride = self.page_size * self._swa_page_multiplier
|
||||||
|
return (self._swa_v2p_table[virt_page] * swa_stride + offset).clamp_(min=0)
|
||||||
|
|
||||||
|
# -- token-level translate surface (the mixin / local-attn consumers) ------
|
||||||
|
|
||||||
|
def translate_full_attn_ids(
|
||||||
|
self, kv_indices: torch.Tensor, *, out: Optional[torch.Tensor] = None
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Virtual token ids -> kernel-facing full-attention ids (the identity
|
||||||
|
when no translation is needed, so callers never branch)."""
|
||||||
|
if not self.is_translating:
|
||||||
|
assert out is None, "passthrough translate takes no out="
|
||||||
|
return kv_indices
|
||||||
|
return self._translate_full(kv_indices, out=out)
|
||||||
@@ -81,7 +81,10 @@ from sglang.srt.utils import (
|
|||||||
is_npu,
|
is_npu,
|
||||||
next_power_of_2,
|
next_power_of_2,
|
||||||
)
|
)
|
||||||
from sglang.srt.utils.async_probe import maybe_detect_oob
|
from sglang.srt.utils.async_probe import (
|
||||||
|
maybe_detect_kernel_facing_loc,
|
||||||
|
maybe_detect_oob,
|
||||||
|
)
|
||||||
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
|
from sglang.srt.utils.torch_memory_saver_adapter import TorchMemorySaverAdapter
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -1595,21 +1598,22 @@ class KVWriteLoc:
|
|||||||
"""Write target(s) for ``KVCache.set_kv_buffer``.
|
"""Write target(s) for ``KVCache.set_kv_buffer``.
|
||||||
|
|
||||||
All location info lives here (in the attention metadata), NOT in the pool:
|
All location info lives here (in the attention metadata), NOT in the pool:
|
||||||
- ``loc``: the generic per-token write location (the allocated
|
- ``loc``: the generic per-token write location (``out_cache_loc``).
|
||||||
``out_cache_loc``). VIRTUAL under the unified memory pool (it indexes the
|
KERNEL-FACING on every pool: physical by allocation on non-unified
|
||||||
virtual slot space); already physical for a non-unified memory pool.
|
pools, rebound at ForwardBatch construction (``rebind_write_loc``) on
|
||||||
- ``swa_loc``: the pre-translated SWA-sub-pool PHYSICAL location for hybrid
|
the unified pool.
|
||||||
SWA pools (``None`` otherwise).
|
- ``swa_loc``: the pre-resolved SWA-sub-pool location for hybrid SWA pools
|
||||||
- ``full_loc``: the pre-translated full-attention-sub-pool PHYSICAL location
|
(``None`` otherwise).
|
||||||
for the unified memory pool (``None`` otherwise), computed once per forward in
|
- ``full_loc``: the full-attention-sub-pool location for the unified
|
||||||
attention metadata (``ForwardMetadata.out_cache_loc_full_physical``). The
|
memory pool (``None`` otherwise), carried in attention metadata
|
||||||
shared full pool writes it directly; the pool never translates (replacing
|
(``ForwardMetadata.out_cache_loc_full_physical``). Since the
|
||||||
the former per-layer v2p gather / ``set_full_loc`` pin).
|
construction-time rebind it is the SAME id space as ``loc``; the shared
|
||||||
|
full pool writes it directly and never translates.
|
||||||
|
|
||||||
``swa_loc`` and ``full_loc`` are the parallel pair (each a pre-resolved
|
``swa_loc`` and ``full_loc`` are the parallel pair (each a pre-resolved
|
||||||
PHYSICAL loc into its sub-pool, mirroring ``swa_kv_pool`` / ``full_kv_pool``);
|
loc into its sub-pool, mirroring ``swa_kv_pool`` / ``full_kv_pool``);
|
||||||
``loc`` is the generic, possibly-virtual fallback. Bundling them lets a
|
``loc`` is the generic fallback. Bundling them lets a backend issue one
|
||||||
backend issue one ``set_kv_buffer`` call regardless of pool type.
|
``set_kv_buffer`` call regardless of pool type.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
loc: torch.Tensor
|
loc: torch.Tensor
|
||||||
@@ -1690,6 +1694,10 @@ class KVCache(abc.ABC):
|
|||||||
):
|
):
|
||||||
self.size = size
|
self.size = size
|
||||||
self.page_size = page_size
|
self.page_size = page_size
|
||||||
|
# Row-blocks one page holds in this pool's kernel-facing id space; >1
|
||||||
|
# only where the per-layer views are dense (the unified pool), and then
|
||||||
|
# a write loc must have been translated into that space first.
|
||||||
|
self.kernel_page_blocks = 1
|
||||||
self.dtype = dtype
|
self.dtype = dtype
|
||||||
self.device = device
|
self.device = device
|
||||||
if dtype in (torch.float8_e5m2, torch.float8_e4m3fn, torch.float8_e4m3fnuz):
|
if dtype in (torch.float8_e5m2, torch.float8_e4m3fn, torch.float8_e4m3fnuz):
|
||||||
@@ -2389,6 +2397,9 @@ class MHATokenToKVPool(KVCache):
|
|||||||
# Catch stale slot ids here instead of as illegal-addr / silent KV
|
# Catch stale slot ids here instead of as illegal-addr / silent KV
|
||||||
# corruption in the store_kvcache write (gated on SGLANG_ENABLE_ASYNC_ASSERT).
|
# corruption in the store_kvcache write (gated on SGLANG_ENABLE_ASYNC_ASSERT).
|
||||||
maybe_detect_oob(loc, 0, self.size + self.page_size, "set_kv_buffer (MHA)")
|
maybe_detect_oob(loc, 0, self.size + self.page_size, "set_kv_buffer (MHA)")
|
||||||
|
maybe_detect_kernel_facing_loc(
|
||||||
|
loc, self.page_size, self.kernel_page_blocks, "set_kv_buffer (MHA)"
|
||||||
|
)
|
||||||
layer_id = (
|
layer_id = (
|
||||||
layer_id_override if layer_id_override is not None else layer.layer_id
|
layer_id_override if layer_id_override is not None else layer.layer_id
|
||||||
)
|
)
|
||||||
@@ -3654,9 +3665,10 @@ class HybridLinearKVPool(KVCache):
|
|||||||
# virtual->physical mamba-slot translate for the HiCache offload path;
|
# virtual->physical mamba-slot translate for the HiCache offload path;
|
||||||
# identity for a static pool, the allocator's `translate` for the unified pool.
|
# identity for a static pool, the allocator's `translate` for the unified pool.
|
||||||
self._mamba_translate = lambda ids: ids
|
self._mamba_translate = lambda ids: ids
|
||||||
# virtual->kernel-facing full-KV translate for the model-level MLA entry points
|
# The MLA doors take DIFFERENT id spaces: `get_mla_kv_buffer` gets
|
||||||
# (`set_mla_kv_buffer` / `get_mla_kv_buffer` receive VIRTUAL locs);
|
# ForwardBatch-built read indices (prefix_chunk_kv_indices /
|
||||||
# identity for a static pool, `translate_kv_loc_for_kernel` for the unified pool.
|
# fetch_mha_one_shot_kv_indices), still VIRTUAL, so it translates;
|
||||||
|
# `set_mla_kv_buffer` gets out_cache_loc, already kernel-facing.
|
||||||
self._full_translate = lambda ids: ids
|
self._full_translate = lambda ids: ids
|
||||||
self.use_mla = use_mla
|
self.use_mla = use_mla
|
||||||
if full_kv_pool is not None:
|
if full_kv_pool is not None:
|
||||||
@@ -3943,17 +3955,8 @@ class HybridLinearKVPool(KVCache):
|
|||||||
loc: torch.Tensor,
|
loc: torch.Tensor,
|
||||||
cache_k_nope: torch.Tensor,
|
cache_k_nope: torch.Tensor,
|
||||||
cache_k_rope: torch.Tensor,
|
cache_k_rope: torch.Tensor,
|
||||||
loc_is_kernel_facing: bool = False,
|
|
||||||
):
|
):
|
||||||
assert self.use_mla, "set_mla_kv_buffer called when use_mla is False"
|
assert self.use_mla, "set_mla_kv_buffer called when use_mla is False"
|
||||||
# Model-level MLA entry point: `loc` is a VIRTUAL loc under the unified
|
|
||||||
# pool, so translate to the kernel-facing id space here.
|
|
||||||
#
|
|
||||||
# `loc_is_kernel_facing`: the caller already translated `loc` (the unified-pool
|
|
||||||
# cuda-graph decode precomputes it out-of-graph into a capture-stable
|
|
||||||
# buffer, so the in-graph write does not capture a translate allocation).
|
|
||||||
if not loc_is_kernel_facing:
|
|
||||||
loc = self._full_translate(loc)
|
|
||||||
with self._transfer_id_context(layer):
|
with self._transfer_id_context(layer):
|
||||||
self.full_kv_pool.set_mla_kv_buffer(layer, loc, cache_k_nope, cache_k_rope)
|
self.full_kv_pool.set_mla_kv_buffer(layer, loc, cache_k_nope, cache_k_rope)
|
||||||
|
|
||||||
@@ -4092,6 +4095,9 @@ class MLATokenToKVPool(KVCache):
|
|||||||
):
|
):
|
||||||
loc, _, _ = unwrap_write_loc(loc_info)
|
loc, _, _ = unwrap_write_loc(loc_info)
|
||||||
maybe_detect_oob(loc, 0, self.size + self.page_size, "set_kv_buffer (MLA)")
|
maybe_detect_oob(loc, 0, self.size + self.page_size, "set_kv_buffer (MLA)")
|
||||||
|
maybe_detect_kernel_facing_loc(
|
||||||
|
loc, self.page_size, self.kernel_page_blocks, "set_kv_buffer (MLA)"
|
||||||
|
)
|
||||||
layer_id = (
|
layer_id = (
|
||||||
layer_id_override if layer_id_override is not None else layer.layer_id
|
layer_id_override if layer_id_override is not None else layer.layer_id
|
||||||
)
|
)
|
||||||
@@ -4176,6 +4182,9 @@ class MLATokenToKVPool(KVCache):
|
|||||||
(self.size + self.page_size) * get_parallel().attn_dcp_size,
|
(self.size + self.page_size) * get_parallel().attn_dcp_size,
|
||||||
"set_mla_kv_buffer (MLA)",
|
"set_mla_kv_buffer (MLA)",
|
||||||
)
|
)
|
||||||
|
maybe_detect_kernel_facing_loc(
|
||||||
|
loc, self.page_size, self.kernel_page_blocks, "set_mla_kv_buffer (MLA)"
|
||||||
|
)
|
||||||
layer_id = (
|
layer_id = (
|
||||||
layer_id_override if layer_id_override is not None else layer.layer_id
|
layer_id_override if layer_id_override is not None else layer.layer_id
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1906,6 +1906,11 @@ class UnifiedMambaTokenToKVPoolAllocator(BaseTokenToKVPoolAllocator):
|
|||||||
"""
|
"""
|
||||||
return self.full_attn_allocator.virtual_to_physical
|
return self.full_attn_allocator.virtual_to_physical
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_p2v_page_table(self) -> torch.Tensor:
|
||||||
|
"""Page-level physical->virtual table of the full sub-pool."""
|
||||||
|
return self.full_attn_allocator.physical_to_virtual
|
||||||
|
|
||||||
def translate_kv_loc_for_kernel(
|
def translate_kv_loc_for_kernel(
|
||||||
self,
|
self,
|
||||||
loc: torch.Tensor,
|
loc: torch.Tensor,
|
||||||
@@ -2257,6 +2262,11 @@ class UnifiedSWATokenToKVPoolAllocator(SWATokenToKVPoolAllocator):
|
|||||||
"""Page-level virtual->physical table of the full sub-pool."""
|
"""Page-level virtual->physical table of the full sub-pool."""
|
||||||
return self.full_attn_allocator.virtual_to_physical
|
return self.full_attn_allocator.virtual_to_physical
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_p2v_page_table(self) -> torch.Tensor:
|
||||||
|
"""Page-level physical->virtual table of the full sub-pool."""
|
||||||
|
return self.full_attn_allocator.physical_to_virtual
|
||||||
|
|
||||||
def translate_kv_loc_for_kernel(
|
def translate_kv_loc_for_kernel(
|
||||||
self,
|
self,
|
||||||
loc: torch.Tensor,
|
loc: torch.Tensor,
|
||||||
|
|||||||
@@ -548,6 +548,7 @@ class UnifiedMHATokenToKVPool(MHATokenToKVPool):
|
|||||||
enable_kv_cache_copy=False,
|
enable_kv_cache_copy=False,
|
||||||
kv_cache_layout="page_major",
|
kv_cache_layout="page_major",
|
||||||
)
|
)
|
||||||
|
self.kernel_page_blocks = spec.blocks_per_page()
|
||||||
|
|
||||||
def _create_buffers(self):
|
def _create_buffers(self):
|
||||||
self.k_buffer = self._k_views
|
self.k_buffer = self._k_views
|
||||||
@@ -641,7 +642,7 @@ class UnifiedMLATokenToKVPool(MLATokenToKVPool):
|
|||||||
max_slots = unified_buffer.max_slots(sub_pool_name)
|
max_slots = unified_buffer.max_slots(sub_pool_name)
|
||||||
self._num_pages = max_slots // page_size
|
self._num_pages = max_slots // page_size
|
||||||
self._page_bytes = page_size * spec.entry_bytes()
|
self._page_bytes = page_size * spec.entry_bytes()
|
||||||
self._view_rows = self._num_pages * spec.layer_num * page_size
|
self._view_rows = self._num_pages * spec.blocks_per_page() * page_size
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
# OOB checks bound locs by `size + page_size`; kernel-facing ids run to
|
# OOB checks bound locs by `size + page_size`; kernel-facing ids run to
|
||||||
@@ -655,6 +656,7 @@ class UnifiedMLATokenToKVPool(MLATokenToKVPool):
|
|||||||
device=unified_buffer.device,
|
device=unified_buffer.device,
|
||||||
enable_memory_saver=False, # buffer owned by UnifiedKVPool
|
enable_memory_saver=False, # buffer owned by UnifiedKVPool
|
||||||
)
|
)
|
||||||
|
self.kernel_page_blocks = spec.blocks_per_page()
|
||||||
|
|
||||||
def _create_buffers(self):
|
def _create_buffers(self):
|
||||||
self.kv_buffer = self._kv_views
|
self.kv_buffer = self._kv_views
|
||||||
@@ -1243,9 +1245,6 @@ def init_unified_mamba_pools(
|
|||||||
req_to_token_pool.mamba_allocator = mamba_slot_allocator
|
req_to_token_pool.mamba_allocator = mamba_slot_allocator
|
||||||
token_to_kv_pool._mamba_translate = mamba_slot_allocator.translate
|
token_to_kv_pool._mamba_translate = mamba_slot_allocator.translate
|
||||||
if use_mla_backend:
|
if use_mla_backend:
|
||||||
# Model-level MLA entry points (`set_mla_kv_buffer` / `get_mla_kv_buffer`)
|
|
||||||
# receive VIRTUAL locs and translate to the kernel-facing space internally
|
|
||||||
# (eager-prefill-only paths; never captured in a cuda graph).
|
|
||||||
token_to_kv_pool._full_translate = allocator.translate_kv_loc_for_kernel
|
token_to_kv_pool._full_translate = allocator.translate_kv_loc_for_kernel
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -827,6 +827,8 @@ class ForwardBatch(ForwardBatchDeepSeekMHAMixin):
|
|||||||
|
|
||||||
device = model_runner.device
|
device = model_runner.device
|
||||||
|
|
||||||
|
model_runner.kv_index_translator.rebind_write_loc(ret)
|
||||||
|
|
||||||
if envs.SGLANG_KV_CANARY_ENABLE_TOKEN_ORACLE.get():
|
if envs.SGLANG_KV_CANARY_ENABLE_TOKEN_ORACLE.get():
|
||||||
hashed = _hash_rids_to_tensor(
|
hashed = _hash_rids_to_tensor(
|
||||||
rids=[req.rid for req in batch.reqs],
|
rids=[req.rid for req in batch.reqs],
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ from sglang.srt.mem_cache.allocator import BaseTokenToKVPoolAllocator
|
|||||||
from sglang.srt.mem_cache.kv_cache_configurator import (
|
from sglang.srt.mem_cache.kv_cache_configurator import (
|
||||||
KVCacheConfigurator,
|
KVCacheConfigurator,
|
||||||
)
|
)
|
||||||
|
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator
|
||||||
from sglang.srt.mem_cache.memory_pool import HybridReqToTokenPool, ReqToTokenPool
|
from sglang.srt.mem_cache.memory_pool import HybridReqToTokenPool, ReqToTokenPool
|
||||||
from sglang.srt.model_executor.cuda_graph_config import (
|
from sglang.srt.model_executor.cuda_graph_config import (
|
||||||
cuda_graph_fully_disabled,
|
cuda_graph_fully_disabled,
|
||||||
@@ -856,6 +857,18 @@ class ModelRunner:
|
|||||||
return
|
return
|
||||||
self.pre_model_load_memory += preloaded_weights_bytes / (1 << 30)
|
self.pre_model_load_memory += preloaded_weights_bytes / (1 << 30)
|
||||||
|
|
||||||
|
def init_kv_index_translator(self):
|
||||||
|
"""The one object that converts KV ids for this runner: attention
|
||||||
|
backends build their read indices from the table it hands them instead
|
||||||
|
of probing the pool's id spaces themselves."""
|
||||||
|
self.kv_index_translator = KVIndexTranslator(
|
||||||
|
req_to_token=self.req_to_token_pool.req_to_token,
|
||||||
|
token_to_kv_pool_allocator=self.token_to_kv_pool_allocator,
|
||||||
|
token_to_kv_pool=self.token_to_kv_pool,
|
||||||
|
page_size=self.page_size or 1,
|
||||||
|
device=self.device,
|
||||||
|
)
|
||||||
|
|
||||||
def alloc_memory_pool(self, memory_pool_config: Optional[MemoryPoolConfig] = None):
|
def alloc_memory_pool(self, memory_pool_config: Optional[MemoryPoolConfig] = None):
|
||||||
"""Allocate KV cache memory pools only (no backends or cuda graphs)."""
|
"""Allocate KV cache memory pools only (no backends or cuda graphs)."""
|
||||||
if memory_pool_config is not None:
|
if memory_pool_config is not None:
|
||||||
@@ -882,6 +895,8 @@ class ModelRunner:
|
|||||||
def _init_post_memory_pool_components(self):
|
def _init_post_memory_pool_components(self):
|
||||||
"""Post-pool component wiring, split out of alloc_memory_pool so forks
|
"""Post-pool component wiring, split out of alloc_memory_pool so forks
|
||||||
that build bespoke memory pools can reuse it after allocating them."""
|
that build bespoke memory pools can reuse it after allocating them."""
|
||||||
|
self.init_kv_index_translator()
|
||||||
|
|
||||||
# Must be called AFTER init_memory_pool so the pool object exists for
|
# Must be called AFTER init_memory_pool so the pool object exists for
|
||||||
# canary to monkey-patch, and BEFORE init_decode_cuda_graph so warmup
|
# canary to monkey-patch, and BEFORE init_decode_cuda_graph so warmup
|
||||||
# forwards captured into the graph see the patched pool methods.
|
# forwards captured into the graph see the patched pool methods.
|
||||||
|
|||||||
@@ -141,6 +141,27 @@ def maybe_detect_oob(indices: Optional[torch.Tensor], low: int, high: int, msg:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_detect_kernel_facing_loc(
|
||||||
|
indices: Optional[torch.Tensor], page_size: int, blocks_per_page: int, msg: str
|
||||||
|
):
|
||||||
|
"""Async check that a write loc is in the pool's KERNEL-FACING id space.
|
||||||
|
|
||||||
|
A kernel-facing id is `phys_page * (page_size * blocks_per_page) + offset`
|
||||||
|
with `offset < page_size`, so its remainder modulo the page stride is
|
||||||
|
below page_size; a VIRTUAL id satisfies that only in the first block.
|
||||||
|
Vacuous at blocks_per_page 1. Virtual ids are in range for the OOB probe,
|
||||||
|
so this is the only check that separates them.
|
||||||
|
"""
|
||||||
|
if blocks_per_page <= 1 or not envs.SGLANG_ENABLE_ASYNC_ASSERT.get():
|
||||||
|
return
|
||||||
|
if indices is None or indices.numel() == 0:
|
||||||
|
return
|
||||||
|
torch._assert_async(
|
||||||
|
(indices % (page_size * blocks_per_page) < page_size).all(),
|
||||||
|
f"write loc outside the kernel-facing id space (virtual ids?): {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def maybe_detect_page_aligned(
|
def maybe_detect_page_aligned(
|
||||||
indices: Optional[torch.Tensor], page_size: int, msg: str
|
indices: Optional[torch.Tensor], page_size: int, msg: str
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -411,6 +411,7 @@ class MockModelRunner(ModelRunner):
|
|||||||
page_size=case.page_size,
|
page_size=case.page_size,
|
||||||
get_kvcache=lambda: self.token_to_kv_pool,
|
get_kvcache=lambda: self.token_to_kv_pool,
|
||||||
)
|
)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ class DSAMockModelRunner(ModelRunner):
|
|||||||
kv_cache_dim=pool_kv_cache_dim,
|
kv_cache_dim=pool_kv_cache_dim,
|
||||||
)
|
)
|
||||||
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ class DualChunkMockModelRunner(ModelRunner):
|
|||||||
enable_alt_stream=False,
|
enable_alt_stream=False,
|
||||||
)
|
)
|
||||||
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -318,6 +318,7 @@ class MockGDNModelRunner(ModelRunner):
|
|||||||
page_size=case.page_size,
|
page_size=case.page_size,
|
||||||
get_kvcache=lambda: self.token_to_kv_pool,
|
get_kvcache=lambda: self.token_to_kv_pool,
|
||||||
)
|
)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -317,6 +317,7 @@ class MockKDAModelRunner(ModelRunner):
|
|||||||
enable_alt_stream=False,
|
enable_alt_stream=False,
|
||||||
)
|
)
|
||||||
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ class MockLightningModelRunner(ModelRunner):
|
|||||||
enable_alt_stream=False,
|
enable_alt_stream=False,
|
||||||
)
|
)
|
||||||
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ class MockMamba2ModelRunner(ModelRunner):
|
|||||||
enable_alt_stream=False,
|
enable_alt_stream=False,
|
||||||
)
|
)
|
||||||
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ class MockMLAModelRunner(ModelRunner):
|
|||||||
enable_memory_saver=False,
|
enable_memory_saver=False,
|
||||||
)
|
)
|
||||||
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
self.token_to_kv_pool_allocator = SimpleNamespace(page_size=case.page_size)
|
||||||
|
self.init_kv_index_translator()
|
||||||
self.attn_cp_size = 1
|
self.attn_cp_size = 1
|
||||||
self.attention_chunk_size = None
|
self.attention_chunk_size = None
|
||||||
self.hisparse_coordinator = None
|
self.hisparse_coordinator = None
|
||||||
|
|||||||
@@ -75,6 +75,60 @@ class TestCreateKvIndices(CustomTestCase):
|
|||||||
for batch in BATCH:
|
for batch in BATCH:
|
||||||
self._run_test(batch, MAX_BATCH, MAX_CONTEXT_LEN)
|
self._run_test(batch, MAX_BATCH, MAX_CONTEXT_LEN)
|
||||||
|
|
||||||
|
def _run_page_table_test(self, batch, ps, with_window_start):
|
||||||
|
"""ENTRY_PAGE_SIZE > 1: the source is a PAGE-granular table (the unified
|
||||||
|
pool's read table); the kernel must reconstruct token ids by the affine
|
||||||
|
rule token = entry * ps + pos % ps -- including the kv_start_idx
|
||||||
|
(sliding-window) offset path, whose pos is an absolute token position."""
|
||||||
|
max_batch, max_pages = 64, 128
|
||||||
|
page_table = torch.randint(
|
||||||
|
0, 1 << 20, (max_batch, max_pages), dtype=torch.int32
|
||||||
|
)
|
||||||
|
req_pool_indices = torch.tensor(
|
||||||
|
np.random.choice(range(max_batch), size=batch, replace=False),
|
||||||
|
dtype=torch.int32,
|
||||||
|
)
|
||||||
|
lens = torch.tensor(
|
||||||
|
np.random.randint(1, max_pages * ps, size=batch), dtype=torch.int32
|
||||||
|
)
|
||||||
|
if with_window_start:
|
||||||
|
start = torch.clamp(
|
||||||
|
lens - torch.randint(1, ps * 3, (batch,), dtype=torch.int32), min=0
|
||||||
|
)
|
||||||
|
gather_lens = lens - start
|
||||||
|
else:
|
||||||
|
start, gather_lens = None, lens
|
||||||
|
kv_indptr = torch.zeros((batch + 1,), dtype=torch.int32)
|
||||||
|
kv_indptr[1:] = torch.cumsum(gather_lens, dim=0)
|
||||||
|
|
||||||
|
# ref: absolute positions [start, start+len) through the affine rule
|
||||||
|
refs = []
|
||||||
|
for i in range(batch):
|
||||||
|
s = int(start[i]) if start is not None else 0
|
||||||
|
pos = torch.arange(s, s + int(gather_lens[i]), dtype=torch.int64)
|
||||||
|
entry = page_table[int(req_pool_indices[i])][pos // ps].to(torch.int64)
|
||||||
|
refs.append(entry * ps + pos % ps)
|
||||||
|
ref = torch.cat(refs).contiguous()
|
||||||
|
|
||||||
|
out = torch.empty(int(kv_indptr[-1]), dtype=torch.int64)
|
||||||
|
create_flashinfer_kv_indices_triton[(batch,)](
|
||||||
|
page_table,
|
||||||
|
req_pool_indices,
|
||||||
|
gather_lens,
|
||||||
|
kv_indptr,
|
||||||
|
start,
|
||||||
|
out,
|
||||||
|
page_table.size(1),
|
||||||
|
ENTRY_PAGE_SIZE=ps,
|
||||||
|
)
|
||||||
|
self.assertTrue(torch.equal(ref, out))
|
||||||
|
|
||||||
|
def test_page_table_source_reconstruction(self):
|
||||||
|
for batch in (1, 37):
|
||||||
|
for ps in (4, 64, 256):
|
||||||
|
self._run_page_table_test(batch, ps, with_window_start=False)
|
||||||
|
self._run_page_table_test(batch, ps, with_window_start=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ pools hold none and never translate — the write loc reaching `set_kv_buffer` i
|
|||||||
always PHYSICAL. Two routing contracts are pinned here:
|
always PHYSICAL. Two routing contracts are pinned here:
|
||||||
|
|
||||||
1. Full-attention. The full-physical loc is carried in `KVWriteLoc.full_loc`
|
1. Full-attention. The full-physical loc is carried in `KVWriteLoc.full_loc`
|
||||||
(from `ForwardBatch.out_cache_loc_full_physical`) and written directly.
|
(from `ForwardMetadata.out_cache_loc_full_physical`) and written directly.
|
||||||
`UnifiedSWAKVPool` asserts it's present (the unified memory pool always precomputes
|
`UnifiedSWAKVPool` asserts it's present (the unified memory pool always precomputes
|
||||||
it); `HybridLinearKVPool` falls back to `loc` for a static (non-shared) pool,
|
it); `HybridLinearKVPool` falls back to `loc` for a static (non-shared) pool,
|
||||||
where `loc` is itself already physical.
|
where `loc` is itself already physical.
|
||||||
@@ -264,8 +264,10 @@ class TestHybridLinearMLARouting(unittest.TestCase):
|
|||||||
- `set_kv_buffer` (MLA branch) mirrors the MHA branch — write the
|
- `set_kv_buffer` (MLA branch) mirrors the MHA branch — write the
|
||||||
pre-translated `KVWriteLoc.full_loc` when present (unified pool, where it
|
pre-translated `KVWriteLoc.full_loc` when present (unified pool, where it
|
||||||
carries the DENSE loc), else the raw `loc` (static pool, already physical).
|
carries the DENSE loc), else the raw `loc` (static pool, already physical).
|
||||||
- `set_mla_kv_buffer` / `get_mla_kv_buffer` receive VIRTUAL locs and apply
|
- `set_mla_kv_buffer` forwards `loc` untouched (kernel-facing since the
|
||||||
`_full_translate` exactly once (identity for a static pool)."""
|
ForwardBatch rebind); `get_mla_kv_buffer` applies `_full_translate`
|
||||||
|
exactly once (its indices are req_to_token-produced, virtual under the
|
||||||
|
unified pool)."""
|
||||||
|
|
||||||
def _make_bare_pool(self, translate=None):
|
def _make_bare_pool(self, translate=None):
|
||||||
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
|
from sglang.srt.mem_cache.memory_pool import HybridLinearKVPool
|
||||||
@@ -274,7 +276,7 @@ class TestHybridLinearMLARouting(unittest.TestCase):
|
|||||||
pool.full_kv_pool = _RecordingMLAPool()
|
pool.full_kv_pool = _RecordingMLAPool()
|
||||||
pool.use_mla = True
|
pool.use_mla = True
|
||||||
pool.full_attention_layer_id_mapping = {0: 0}
|
pool.full_attention_layer_id_mapping = {0: 0}
|
||||||
pool._full_translate = translate if translate is not None else (lambda x: x)
|
pool._full_translate = translate if translate is not None else (lambda ids: ids)
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
def test_mla_writes_full_loc_from_write_loc(self):
|
def test_mla_writes_full_loc_from_write_loc(self):
|
||||||
@@ -311,28 +313,26 @@ class TestHybridLinearMLARouting(unittest.TestCase):
|
|||||||
forwarded, _ = pool.full_kv_pool.calls[0]
|
forwarded, _ = pool.full_kv_pool.calls[0]
|
||||||
self.assertIs(forwarded, phys_loc)
|
self.assertIs(forwarded, phys_loc)
|
||||||
|
|
||||||
def test_set_mla_kv_buffer_translates_exactly_once(self):
|
def test_set_mla_kv_buffer_door_never_translates(self):
|
||||||
calls = []
|
"""Physical-loc contract: the write door forwards `loc` UNTOUCHED.
|
||||||
|
The translate happens exactly once at ForwardBatch construction
|
||||||
def translate(ids):
|
(rebind_write_loc, kernel-facing-first); a door that translated
|
||||||
calls.append(ids)
|
again would double-translate every unified MLA write. Deleting the
|
||||||
return ids + 100
|
forward (or re-adding a door translate) turns this red."""
|
||||||
|
pool = self._make_bare_pool()
|
||||||
pool = self._make_bare_pool(translate=translate)
|
loc = torch.tensor([107, 108, 109], dtype=torch.int64)
|
||||||
virtual_loc = torch.tensor([7, 8, 9], dtype=torch.int64)
|
|
||||||
layer = types.SimpleNamespace(layer_id=0)
|
layer = types.SimpleNamespace(layer_id=0)
|
||||||
|
|
||||||
pool.set_mla_kv_buffer(
|
pool.set_mla_kv_buffer(layer, loc, torch.zeros(3, 1, 6), torch.zeros(3, 1, 2))
|
||||||
layer, virtual_loc, torch.zeros(3, 1, 6), torch.zeros(3, 1, 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(len(calls), 1)
|
|
||||||
self.assertEqual(len(pool.full_kv_pool.mla_set_calls), 1)
|
self.assertEqual(len(pool.full_kv_pool.mla_set_calls), 1)
|
||||||
self.assertTrue(
|
self.assertIs(pool.full_kv_pool.mla_set_calls[0], loc)
|
||||||
torch.all(pool.full_kv_pool.mla_set_calls[0] == virtual_loc + 100)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_get_mla_kv_buffer_translates_exactly_once(self):
|
def test_get_mla_kv_buffer_translates_exactly_once(self):
|
||||||
|
"""READ door: `loc` is produced from req_to_token (VIRTUAL under the
|
||||||
|
unified pool), so the get side still translates here — exactly once.
|
||||||
|
The WRITE door (case above) never translates: the split is the write
|
||||||
|
flip's contract."""
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
def translate(ids):
|
def translate(ids):
|
||||||
|
|||||||
@@ -0,0 +1,684 @@
|
|||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
"""KVIndexTranslator -- the read-path id translator.
|
||||||
|
|
||||||
|
Covers, CPU-only (the builder's pure-torch reference path; GPU parity of the
|
||||||
|
Triton kernel is a later CUDA CI pin):
|
||||||
|
- strict passthrough: a non-unified source returns the SAME req_to_token /
|
||||||
|
req_pool_indices objects -- zero tensor ops, no copies (the property that
|
||||||
|
makes backend re-pointing byte-identical for every non-unified server);
|
||||||
|
- static SWA pools keep their legacy full->swa mapping on the view;
|
||||||
|
- the read table matches the hand formula
|
||||||
|
entry[b, c] = clamp(v2p[req_to_token[req[b], c*ps] // ps] * mult, 0)
|
||||||
|
over the REAL SWA composite's tables (full AND swa, ps in {1, 4},
|
||||||
|
multiplier in {1, 2L}), with the swa table built from VIRTUAL ids;
|
||||||
|
- sink routing: dead lanes (seq_len 0), -1 req_to_token entries, and
|
||||||
|
tombstoned v2p pages all read entry 0;
|
||||||
|
- the capture contract: buffers are zero-filled and idempotent; a refresh
|
||||||
|
updates ONLY the live prefix (stale tails and rows beyond bs keep prior
|
||||||
|
contents); the returned table is the WHOLE buffer (pointer-stable);
|
||||||
|
- the eager-view memo: a single source-resident slot keyed by batch
|
||||||
|
identity (same batch shares one build; the next batch replaces it; a
|
||||||
|
dead batch never matches);
|
||||||
|
- the two-phase write contract: the rebind touches only the full side, and
|
||||||
|
the sliding-window write loc derives POINTWISE from the kernel-facing values
|
||||||
|
(pads, slices, and fresh copies included), for both pool families.
|
||||||
|
|
||||||
|
python -m pytest test/registered/unit/mem_cache/test_kv_index_translator.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
|
||||||
|
register_cpu_ci(est_time=8, suite="base-a-test-cpu")
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from test_multi_ended_allocator import _FakeUnifiedSWAKVPool
|
||||||
|
|
||||||
|
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator
|
||||||
|
from sglang.srt.mem_cache.multi_ended_allocator import (
|
||||||
|
UnifiedSWATokenToKVPoolAllocator,
|
||||||
|
)
|
||||||
|
from sglang.srt.mem_cache.swa_memory_pool import SWAKVPool
|
||||||
|
from sglang.srt.mem_cache.unified_memory_pool import MHASubPoolSpec, UnifiedKVPool
|
||||||
|
|
||||||
|
_DEV = "cpu"
|
||||||
|
_FULL_L = 2
|
||||||
|
_SWA_L = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _build_composite(ps, collapse=False, n_full_pages=16, n_swa_pages=8):
|
||||||
|
full_spec = MHASubPoolSpec(
|
||||||
|
name="full",
|
||||||
|
layer_num=_FULL_L,
|
||||||
|
head_num=2,
|
||||||
|
head_dim=4,
|
||||||
|
store_dtype=torch.float16,
|
||||||
|
grow_direction="up",
|
||||||
|
)
|
||||||
|
swa_spec = MHASubPoolSpec(
|
||||||
|
name="swa",
|
||||||
|
layer_num=_SWA_L,
|
||||||
|
head_num=2,
|
||||||
|
head_dim=4,
|
||||||
|
store_dtype=torch.float16,
|
||||||
|
grow_direction="down",
|
||||||
|
)
|
||||||
|
n_full, n_swa = n_full_pages * ps, n_swa_pages * ps
|
||||||
|
total = n_full * full_spec.entry_bytes() + n_swa * swa_spec.entry_bytes()
|
||||||
|
pool = UnifiedKVPool(
|
||||||
|
total_bytes=total,
|
||||||
|
sub_pool_specs=[full_spec, swa_spec],
|
||||||
|
device=_DEV,
|
||||||
|
enable_memory_saver=False,
|
||||||
|
page_size=ps,
|
||||||
|
)
|
||||||
|
kvcache = _FakeUnifiedSWAKVPool(pool)
|
||||||
|
allocator = UnifiedSWATokenToKVPoolAllocator(
|
||||||
|
unified_buffer=pool,
|
||||||
|
kvcache=kvcache,
|
||||||
|
device=_DEV,
|
||||||
|
full_max_total_num_tokens=n_full,
|
||||||
|
swa_max_total_num_tokens=n_swa,
|
||||||
|
page_size=ps,
|
||||||
|
need_sort=False,
|
||||||
|
forward_stream=None,
|
||||||
|
)
|
||||||
|
if collapse:
|
||||||
|
# The multiplier-1 arm, where kernel-facing ids ARE the physical ones.
|
||||||
|
# No unified sub-pool reports 1 today, so pin the regime here.
|
||||||
|
allocator.full_attn_allocator.kernel_page_multiplier = 1
|
||||||
|
allocator.swa_attn_allocator.kernel_page_multiplier = 1
|
||||||
|
# The fake IS the runner's token_to_kv_pool, and the real UnifiedSWAKVPool
|
||||||
|
# carries the pool-level full->swa translate, so the fake must too.
|
||||||
|
kvcache.translate_loc_from_full_to_swa = allocator.translate_loc_from_full_to_swa
|
||||||
|
return allocator
|
||||||
|
|
||||||
|
|
||||||
|
def _make_source(allocator, req_to_token, ps):
|
||||||
|
"""The owning runner's source: its token_to_kv_pool IS the allocator's own
|
||||||
|
kvcache. (A runner can share the allocator while owning a different pool --
|
||||||
|
see TestPoolOwnership.)"""
|
||||||
|
return KVIndexTranslator(
|
||||||
|
req_to_token=req_to_token,
|
||||||
|
token_to_kv_pool_allocator=allocator,
|
||||||
|
token_to_kv_pool=allocator.get_kvcache(),
|
||||||
|
page_size=ps,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_table(req_to_token, req_pool_indices, seq_lens, v2p, mult, ps, width):
|
||||||
|
"""Independent python derivation of the read-table formula."""
|
||||||
|
bs = req_pool_indices.numel()
|
||||||
|
out = torch.zeros((bs, width), dtype=torch.int32)
|
||||||
|
for b in range(bs):
|
||||||
|
req = int(req_pool_indices[b])
|
||||||
|
n_pages = -(-int(seq_lens[b]) // ps)
|
||||||
|
for c in range(min(n_pages, width)):
|
||||||
|
tok = int(req_to_token[req, c * ps])
|
||||||
|
page = 0 if tok < 0 else tok // ps
|
||||||
|
out[b, c] = max(int(v2p[page]) * mult, 0)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class TestPassthrough(unittest.TestCase):
|
||||||
|
def test_non_unified_returns_same_objects(self):
|
||||||
|
"""The strict-passthrough property: no copy, no branch, the exact
|
||||||
|
tensors backends read today. A regression here (any tensor op on the
|
||||||
|
non-unified path) breaks byte-identity for every static-pool server."""
|
||||||
|
req_to_token = torch.arange(64, dtype=torch.int64).reshape(4, 16)
|
||||||
|
src = KVIndexTranslator(
|
||||||
|
req_to_token=req_to_token,
|
||||||
|
token_to_kv_pool_allocator=SimpleNamespace(), # not a composite
|
||||||
|
token_to_kv_pool=SimpleNamespace(), # not an SWAKVPool
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
self.assertFalse(src.is_translating)
|
||||||
|
rows = torch.tensor([2, 0])
|
||||||
|
view = src.build_index_table(
|
||||||
|
req_pool_indices=rows, seq_lens=torch.tensor([5, 3])
|
||||||
|
)
|
||||||
|
self.assertIs(view.ids, req_to_token)
|
||||||
|
self.assertIs(view.row_ids, rows)
|
||||||
|
self.assertEqual(view.row_stride, req_to_token.stride(0))
|
||||||
|
self.assertEqual(view.entry_page_size, 1)
|
||||||
|
self.assertFalse(view.is_translated)
|
||||||
|
self.assertIsNone(view.sliding_window_ids)
|
||||||
|
# And the translate surface is the identity, not a wrapped copy.
|
||||||
|
t = torch.tensor([1, 2, 3])
|
||||||
|
self.assertIs(src.translate_full_attn_ids(t), t)
|
||||||
|
|
||||||
|
|
||||||
|
def _alloc_and_fill(allocator, ps, lens):
|
||||||
|
"""Allocate per-request virtual runs and write them into a fake
|
||||||
|
req_to_token; returns (req_to_token, req_pool_indices, seq_lens)."""
|
||||||
|
width = 16 * ps
|
||||||
|
req_to_token = torch.full((len(lens), width), -1, dtype=torch.int64)
|
||||||
|
for r, n in enumerate(lens):
|
||||||
|
n_alloc = -(-n // ps) * ps # page-aligned virtual run
|
||||||
|
v = allocator.alloc(n_alloc)
|
||||||
|
assert v is not None
|
||||||
|
req_to_token[r, :n] = v[:n]
|
||||||
|
return (
|
||||||
|
req_to_token,
|
||||||
|
torch.arange(len(lens), dtype=torch.int64),
|
||||||
|
torch.tensor(lens, dtype=torch.int64),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadTableBuild(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_read_table_matches_reference_dense_and_strided(self):
|
||||||
|
"""The load-bearing formula pin: full AND swa read tables equal
|
||||||
|
the independent per-element derivation, across page sizes and both
|
||||||
|
multiplier regimes (strided=1, dense=2L). The swa table agreeing with
|
||||||
|
a formula over VIRTUAL ids is also the never-chained-through-
|
||||||
|
full-physical proof."""
|
||||||
|
for ps in (1, 4):
|
||||||
|
for collapse in (True, False):
|
||||||
|
allocator = _build_composite(ps, collapse=collapse)
|
||||||
|
full_mult = allocator.kernel_page_multiplier
|
||||||
|
swa_mult = allocator.swa_kernel_page_multiplier
|
||||||
|
req_to_token, rows, seq_lens = _alloc_and_fill(
|
||||||
|
allocator, ps, lens=[5 * ps, 2 * ps, 3 * ps - 1]
|
||||||
|
)
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
self.assertTrue(src.is_translating)
|
||||||
|
width = 6
|
||||||
|
view = src.build_index_table(
|
||||||
|
req_pool_indices=rows, seq_lens=seq_lens, max_pages=width
|
||||||
|
)
|
||||||
|
self.assertTrue(view.is_translated)
|
||||||
|
self.assertEqual(view.entry_page_size, ps)
|
||||||
|
self.assertTrue(
|
||||||
|
torch.equal(view.row_ids, torch.arange(3, dtype=torch.int64))
|
||||||
|
)
|
||||||
|
want_full = _reference_table(
|
||||||
|
req_to_token,
|
||||||
|
rows,
|
||||||
|
seq_lens,
|
||||||
|
allocator.full_v2p_page_table,
|
||||||
|
full_mult,
|
||||||
|
ps,
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
want_swa = _reference_table(
|
||||||
|
req_to_token,
|
||||||
|
rows,
|
||||||
|
seq_lens,
|
||||||
|
allocator.swa_v2p_page_table,
|
||||||
|
swa_mult,
|
||||||
|
ps,
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
torch.equal(view.ids, want_full),
|
||||||
|
f"full read table off-formula (ps={ps}, mult={full_mult})",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
torch.equal(view.sliding_window_ids, want_swa),
|
||||||
|
f"swa read table off-formula (ps={ps}, mult={swa_mult})",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sink_routing(self):
|
||||||
|
"""Dead lanes (seq_len 0), -1 slots inside the live prefix, and
|
||||||
|
tombstoned v2p pages must ALL read entry 0 -- one wild entry is a
|
||||||
|
captured-graph OOB read at replay."""
|
||||||
|
ps = 4
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
req_to_token, rows, seq_lens = _alloc_and_fill(
|
||||||
|
allocator, ps, lens=[3 * ps, 2 * ps, ps]
|
||||||
|
)
|
||||||
|
seq_lens[1] = 0 # dead lane
|
||||||
|
req_to_token[0, ps] = -1 # unwritten slot inside the live prefix
|
||||||
|
# Tombstone row 2's first page on BOTH sides.
|
||||||
|
tomb_page = int(req_to_token[2, 0]) // ps
|
||||||
|
allocator.full_v2p_page_table[tomb_page] = -1
|
||||||
|
allocator.swa_v2p_page_table[tomb_page] = -1
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
view = src.build_index_table(
|
||||||
|
req_pool_indices=rows, seq_lens=seq_lens, max_pages=4
|
||||||
|
)
|
||||||
|
for table in (view.ids, view.sliding_window_ids):
|
||||||
|
self.assertTrue(bool((table >= 0).all()))
|
||||||
|
self.assertTrue(bool((table[1] == 0).all()), "dead lane not sunk")
|
||||||
|
self.assertEqual(int(table[0, 1]), 0, "-1 slot not sunk")
|
||||||
|
self.assertEqual(int(table[2, 0]), 0, "tombstone not sunk")
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildInto(unittest.TestCase):
|
||||||
|
"""fill_read_table fills a backend-owned padded block table's live prefix with
|
||||||
|
FULL-side read-table entries -- the trtllm_mla / flashmla consumption route
|
||||||
|
(their rows ARE the read table's rows)."""
|
||||||
|
|
||||||
|
def test_prefix_filled_tail_sentinel_preserved_width_capped(self):
|
||||||
|
"""Three contracts in one batch: entries equal the read-table formula,
|
||||||
|
lanes past each row's live pages keep the backend's -1 sentinel
|
||||||
|
(prefix-only -- a tail write scatters the trtllm sentinel contract),
|
||||||
|
and a table padded WIDER than the req_to_token page span (trtllm's
|
||||||
|
LCM alignment) is capped instead of tripping the builder's width
|
||||||
|
assert."""
|
||||||
|
ps = 4
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
full_mult = allocator.kernel_page_multiplier
|
||||||
|
lens = [5, 2 * ps + 1, 1]
|
||||||
|
req_to_token, rows, seq_lens = _alloc_and_fill(allocator, ps, lens=lens)
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
self.assertTrue(src.is_translating)
|
||||||
|
|
||||||
|
width_pages = req_to_token.shape[1] // ps + 3 # wider than the span
|
||||||
|
out = torch.full((len(lens), width_pages), -1, dtype=torch.int32)
|
||||||
|
src.fill_read_table(out=out, req_pool_indices=rows, seq_lens=seq_lens)
|
||||||
|
|
||||||
|
want = _reference_table(
|
||||||
|
req_to_token,
|
||||||
|
rows,
|
||||||
|
seq_lens,
|
||||||
|
allocator.full_v2p_page_table,
|
||||||
|
full_mult,
|
||||||
|
ps,
|
||||||
|
width_pages,
|
||||||
|
)
|
||||||
|
for b, n in enumerate(lens):
|
||||||
|
n_pages = -(-n // ps)
|
||||||
|
self.assertTrue(
|
||||||
|
torch.equal(out[b, :n_pages], want[b, :n_pages]),
|
||||||
|
f"row {b} live prefix off-formula",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
bool((out[b, n_pages:] == -1).all()),
|
||||||
|
f"row {b} tail sentinel clobbered",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_passthrough_source_refuses(self):
|
||||||
|
"""Callers dispatch on `enabled`; a passthrough source has no v2p to
|
||||||
|
build from and must fail loud, not fill garbage."""
|
||||||
|
src = KVIndexTranslator(
|
||||||
|
req_to_token=torch.zeros((2, 4), dtype=torch.int64),
|
||||||
|
token_to_kv_pool_allocator=SimpleNamespace(),
|
||||||
|
token_to_kv_pool=SimpleNamespace(),
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
src.fill_read_table(
|
||||||
|
out=torch.zeros((1, 4), dtype=torch.int32),
|
||||||
|
req_pool_indices=torch.tensor([0]),
|
||||||
|
seq_lens=torch.tensor([1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPoolOwnership(unittest.TestCase):
|
||||||
|
"""A runner only gets the kernel-facing id space when the pool IT reads and
|
||||||
|
writes is the one the allocator's ids address.
|
||||||
|
|
||||||
|
Guarded shape: a runner handed a SHARED allocator (one slot index space,
|
||||||
|
one req_to_token) while owning a SEPARATE KV buffer sized to the
|
||||||
|
allocator's SLOT count. Probing the allocator alone reports "unified" for
|
||||||
|
that runner, so its indices would be mapped into the composite's
|
||||||
|
kernel-facing space (kernel-facing ids up to num_pages * multiplier) and then used
|
||||||
|
to address a buffer with only num_slots rows -- out of bounds on both the
|
||||||
|
read gather and the KV store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_real_factory_bundle_satisfies_the_ownership_identity(self):
|
||||||
|
"""The guard rests on `allocator.get_kvcache() is token_to_kv_pool`
|
||||||
|
holding for a REAL target bundle. If a factory ever returned a pool
|
||||||
|
the allocator does not hold, the guard would silently disable the
|
||||||
|
unified path for EVERY model -- so pin it against the real factory
|
||||||
|
rather than against this file's own construction."""
|
||||||
|
from sglang.srt.mem_cache.unified_memory_pool import init_unified_swa_pools
|
||||||
|
|
||||||
|
bundle = init_unified_swa_pools(
|
||||||
|
device="cpu",
|
||||||
|
kv_cache_dtype=torch.float16,
|
||||||
|
head_num=2,
|
||||||
|
head_dim=8,
|
||||||
|
v_head_dim=8,
|
||||||
|
swa_head_num=2,
|
||||||
|
swa_head_dim=8,
|
||||||
|
swa_v_head_dim=8,
|
||||||
|
page_size=1,
|
||||||
|
start_layer=0,
|
||||||
|
end_layer=4,
|
||||||
|
swa_attention_layer_ids=[1, 3],
|
||||||
|
full_attention_layer_ids=[0, 2],
|
||||||
|
full_max_total_num_tokens=64,
|
||||||
|
swa_max_total_num_tokens=32,
|
||||||
|
enable_memory_saver=False,
|
||||||
|
need_sort=False,
|
||||||
|
)
|
||||||
|
self.assertIs(
|
||||||
|
bundle.token_to_kv_pool_allocator.get_kvcache(),
|
||||||
|
bundle.token_to_kv_pool,
|
||||||
|
)
|
||||||
|
src = KVIndexTranslator(
|
||||||
|
req_to_token=torch.zeros((2, 8), dtype=torch.int32, device=_DEV),
|
||||||
|
token_to_kv_pool_allocator=bundle.token_to_kv_pool_allocator,
|
||||||
|
token_to_kv_pool=bundle.token_to_kv_pool,
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
self.assertTrue(src.is_translating)
|
||||||
|
|
||||||
|
def test_runner_with_its_own_pool_is_disabled(self):
|
||||||
|
"""Same allocator, different pool: must stay disabled."""
|
||||||
|
alloc = _build_composite(ps=1)
|
||||||
|
req_to_token = torch.zeros((2, 8), dtype=torch.int32, device=_DEV)
|
||||||
|
own_pool = SimpleNamespace() # a separate buffer, not the composite's
|
||||||
|
src = KVIndexTranslator(
|
||||||
|
req_to_token=req_to_token,
|
||||||
|
token_to_kv_pool_allocator=alloc,
|
||||||
|
token_to_kv_pool=own_pool,
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
self.assertFalse(src.is_translating)
|
||||||
|
|
||||||
|
def test_disabled_source_is_strict_passthrough(self):
|
||||||
|
"""Consequence of the guard: such a runner must see RAW virtual ids on
|
||||||
|
the read table -- they index its own pool directly. A translate here is
|
||||||
|
the out-of-bounds bug the ownership identity exists to prevent."""
|
||||||
|
alloc = _build_composite(ps=1)
|
||||||
|
req_to_token = torch.arange(16, dtype=torch.int32, device=_DEV).view(2, 8)
|
||||||
|
src = KVIndexTranslator(
|
||||||
|
req_to_token=req_to_token,
|
||||||
|
token_to_kv_pool_allocator=alloc,
|
||||||
|
token_to_kv_pool=SimpleNamespace(),
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
rows = torch.tensor([1, 0], dtype=torch.int32, device=_DEV)
|
||||||
|
view = src.build_index_table(
|
||||||
|
req_pool_indices=rows,
|
||||||
|
seq_lens=torch.tensor([3, 2], dtype=torch.int32, device=_DEV),
|
||||||
|
)
|
||||||
|
# Read table: the EXACT objects a static-pool backend reads today.
|
||||||
|
self.assertIs(view.ids, req_to_token)
|
||||||
|
self.assertIs(view.row_ids, rows)
|
||||||
|
self.assertFalse(view.is_translated)
|
||||||
|
# And the token-level surface is the identity, same guard.
|
||||||
|
t = torch.tensor([5, 6], dtype=torch.int64, device=_DEV)
|
||||||
|
self.assertIs(src.translate_full_attn_ids(t), t)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaptureContract(unittest.TestCase):
|
||||||
|
def test_caller_owned_table_is_returned_whole_and_filled_prefix_only(self):
|
||||||
|
ps = 4
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
req_to_token = torch.full((4, 16 * ps), -1, dtype=torch.int64)
|
||||||
|
v = allocator.alloc(2 * ps)
|
||||||
|
req_to_token[1, : 2 * ps] = v
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
|
||||||
|
tables = src.make_capture_tables(max_bs=4, max_context_len=8 * ps)
|
||||||
|
cap, cap_swa = tables.full, tables.sliding_window
|
||||||
|
self.assertTrue(bool((cap == 0).all()), "read tables must start zeroed")
|
||||||
|
self.assertIsNotNone(cap_swa, "the SWA composite has a second id space")
|
||||||
|
|
||||||
|
# Poison everything, then refresh a 1-row batch: ONLY its live prefix
|
||||||
|
# may change -- stale tails and other rows are the fa3 contract.
|
||||||
|
cap.fill_(7)
|
||||||
|
cap_swa.fill_(7)
|
||||||
|
view = src.build_index_table(
|
||||||
|
req_pool_indices=torch.tensor([1]),
|
||||||
|
seq_lens=torch.tensor([2 * ps]),
|
||||||
|
into=tables,
|
||||||
|
)
|
||||||
|
self.assertIs(view.ids, cap, "the caller's table comes back WHOLE")
|
||||||
|
want = allocator.full_v2p_page_table[req_to_token[1, ::ps][:2] // ps] * (
|
||||||
|
2 * _FULL_L
|
||||||
|
)
|
||||||
|
self.assertTrue(torch.equal(cap[0, :2], want.to(torch.int32)))
|
||||||
|
self.assertTrue(bool((cap[0, 2:] == 7).all()), "stale tail was cleared")
|
||||||
|
self.assertTrue(bool((cap[1:] == 7).all()), "rows beyond bs were touched")
|
||||||
|
|
||||||
|
def test_row_ids_not_reallocated_across_builds(self):
|
||||||
|
"""`row_ids` is a constant arange sized once from the request pool, so
|
||||||
|
builds at different batch sizes hand back slices of ONE buffer. A
|
||||||
|
per-build `torch.arange` would be correct but would spend an allocation
|
||||||
|
and a launch on every replay prep."""
|
||||||
|
allocator = _build_composite(1)
|
||||||
|
req_to_token, rows, seq_lens = _alloc_and_fill(allocator, 1, lens=[4, 2, 3])
|
||||||
|
src = _make_source(allocator, req_to_token, 1)
|
||||||
|
self.assertTrue(src.is_translating)
|
||||||
|
first = src.build_index_table(
|
||||||
|
req_pool_indices=rows[:2], seq_lens=seq_lens[:2], max_pages=4
|
||||||
|
)
|
||||||
|
second = src.build_index_table(
|
||||||
|
req_pool_indices=rows, seq_lens=seq_lens, max_pages=4
|
||||||
|
)
|
||||||
|
self.assertEqual(first.row_ids.data_ptr(), second.row_ids.data_ptr())
|
||||||
|
self.assertTrue(torch.equal(first.row_ids, torch.arange(2, device=_DEV)))
|
||||||
|
self.assertTrue(torch.equal(second.row_ids, torch.arange(3, device=_DEV)))
|
||||||
|
# Sized to bound any batch the request pool can hold.
|
||||||
|
self.assertGreaterEqual(src._rows.numel(), req_to_token.shape[0])
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeForwardBatch:
|
||||||
|
"""Weakref-able stand-in (SimpleNamespace is not) carrying the fields
|
||||||
|
`index_table_for_batch` and `rebind_write_loc` read. `seq_lens_sum`
|
||||||
|
defaults to the real sum: it is the signal that the CPU mirror is live,
|
||||||
|
and a real ForwardBatch always carries it (None only when gpu_only)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
req_pool_indices=None,
|
||||||
|
seq_lens=None,
|
||||||
|
seq_lens_cpu=None,
|
||||||
|
out_cache_loc=None,
|
||||||
|
seq_lens_sum=-1,
|
||||||
|
):
|
||||||
|
self.req_pool_indices = req_pool_indices
|
||||||
|
self.seq_lens = seq_lens
|
||||||
|
self.seq_lens_cpu = seq_lens_cpu
|
||||||
|
self.out_cache_loc = out_cache_loc
|
||||||
|
self.seq_lens_sum = (
|
||||||
|
(None if seq_lens is None else int(seq_lens.sum()))
|
||||||
|
if seq_lens_sum == -1
|
||||||
|
else seq_lens_sum
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewMemo(unittest.TestCase):
|
||||||
|
"""The eager view is memoized ON THE SOURCE in a single slot keyed by
|
||||||
|
batch identity -- per-batch state stays out of the ForwardBatch (it does
|
||||||
|
not scale with the number of id spaces), and one metadata build's many
|
||||||
|
consumers still share one table build."""
|
||||||
|
|
||||||
|
def _fb(self, allocator, ps, lens):
|
||||||
|
req_to_token, rows, seq_lens = _alloc_and_fill(allocator, ps, lens=lens)
|
||||||
|
fb = _FakeForwardBatch(
|
||||||
|
req_pool_indices=rows,
|
||||||
|
seq_lens=seq_lens,
|
||||||
|
seq_lens_cpu=seq_lens,
|
||||||
|
)
|
||||||
|
return fb, req_to_token
|
||||||
|
|
||||||
|
def test_same_batch_returns_the_memoized_view(self):
|
||||||
|
ps = 1
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
fb, req_to_token = self._fb(allocator, ps, lens=[3, 2])
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
v1 = src.index_table_for_batch(fb)
|
||||||
|
v2 = src.index_table_for_batch(fb)
|
||||||
|
self.assertIs(v1, v2)
|
||||||
|
|
||||||
|
def test_next_batch_replaces_the_single_slot(self):
|
||||||
|
ps = 1
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
fb1, req_to_token = self._fb(allocator, ps, lens=[3, 2])
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
v1 = src.index_table_for_batch(fb1)
|
||||||
|
fb2 = _FakeForwardBatch(
|
||||||
|
req_pool_indices=fb1.req_pool_indices,
|
||||||
|
seq_lens=fb1.seq_lens,
|
||||||
|
seq_lens_cpu=fb1.seq_lens_cpu,
|
||||||
|
)
|
||||||
|
v2 = src.index_table_for_batch(fb2)
|
||||||
|
self.assertIsNot(v1, v2)
|
||||||
|
# Single slot: fb1 no longer matches and rebuilds.
|
||||||
|
v1_again = src.index_table_for_batch(fb1)
|
||||||
|
self.assertIsNot(v1_again, v1)
|
||||||
|
|
||||||
|
def test_dead_batch_never_matches(self):
|
||||||
|
"""A garbage-collected batch's slot must not serve a later batch: the
|
||||||
|
weakref key goes dead and the build runs fresh."""
|
||||||
|
import gc
|
||||||
|
|
||||||
|
ps = 1
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
fb1, req_to_token = self._fb(allocator, ps, lens=[3, 2])
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
v1 = src.index_table_for_batch(fb1)
|
||||||
|
del fb1
|
||||||
|
gc.collect()
|
||||||
|
fb2, _ = self._fb(allocator, ps, lens=[2])
|
||||||
|
v2 = src.index_table_for_batch(fb2)
|
||||||
|
self.assertIsNot(v2, v1)
|
||||||
|
self.assertEqual(v2.ids.shape[0], 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteLoc(unittest.TestCase):
|
||||||
|
"""The two-phase write contract: phase 1 (`rebind_write_loc`) rebinds the
|
||||||
|
full side once at ForwardBatch construction; phase 2 derives the
|
||||||
|
sliding-window write loc on demand, POINTWISE from the full-side
|
||||||
|
values. Value-based derivation is the property under test: pads, slices,
|
||||||
|
and fresh copies of the loc must all derive correctly with no handover
|
||||||
|
and no stored per-forward state."""
|
||||||
|
|
||||||
|
def _built(self, ps=1, n=4):
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
req_to_token, rows, seq_lens = _alloc_and_fill(allocator, ps, lens=[max(n, 1)])
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
virt = allocator.alloc(-(-n // ps) * ps)[:n]
|
||||||
|
want_full = allocator.translate_kv_loc_for_kernel(virt)
|
||||||
|
want_swa = allocator.translate_loc_from_full_to_swa(virt)
|
||||||
|
return src, allocator, rows, seq_lens, virt, want_full, want_swa
|
||||||
|
|
||||||
|
def _field(self, src, rows, seq_lens, kernel_loc):
|
||||||
|
return src.sliding_window_write_loc_for(kernel_loc)
|
||||||
|
|
||||||
|
def test_rebind_translates_full_side_only(self):
|
||||||
|
for ps in (1, 4):
|
||||||
|
src, _, _, _, virt, want_full, _ = self._built(ps=ps, n=3 * ps)
|
||||||
|
keep = virt.clone()
|
||||||
|
fb = _FakeForwardBatch(out_cache_loc=virt)
|
||||||
|
src.rebind_write_loc(fb)
|
||||||
|
# Full side: rebound to a FRESH kernel-facing tensor; the
|
||||||
|
# ScheduleBatch's aliased virtual tensor is untouched.
|
||||||
|
self.assertIsNot(fb.out_cache_loc, virt)
|
||||||
|
self.assertTrue(torch.equal(fb.out_cache_loc, want_full))
|
||||||
|
self.assertTrue(torch.equal(virt, keep))
|
||||||
|
|
||||||
|
def test_swa_write_loc_round_trips_from_dense(self):
|
||||||
|
"""The derived property behind phase 2: for any virtual run t,
|
||||||
|
deriving from the dense full-side values must equal the direct
|
||||||
|
virtual->swa translate — `field(full(t)) == swa(t)` across page sizes
|
||||||
|
and multipliers."""
|
||||||
|
for ps in (1, 4, 64):
|
||||||
|
src, _, rows, seq_lens, _, want_full, want_swa = self._built(
|
||||||
|
ps=ps, n=3 * ps
|
||||||
|
)
|
||||||
|
got = self._field(src, rows, seq_lens, want_full)
|
||||||
|
self.assertTrue(torch.equal(got, want_swa))
|
||||||
|
|
||||||
|
def test_pad_lanes_derive_to_sink(self):
|
||||||
|
"""The DP pad appends zeros; dense 0 is the reserved padding slot in
|
||||||
|
every id space, so pad lanes must derive to swa slot 0 with no
|
||||||
|
`num_live` bookkeeping."""
|
||||||
|
src, _, rows, seq_lens, _, want_full, want_swa = self._built(n=3)
|
||||||
|
padded = torch.cat([want_full, want_full.new_zeros(2)])
|
||||||
|
got = self._field(src, rows, seq_lens, padded)
|
||||||
|
self.assertTrue(torch.equal(got[:3], want_swa))
|
||||||
|
self.assertTrue(bool((got[3:] == 0).all()), "pad lanes must land on slot 0")
|
||||||
|
|
||||||
|
def test_slice_and_copy_derive_pointwise_without_handover(self):
|
||||||
|
"""REGRESSION (design): the retired identity-resolver refused any
|
||||||
|
tensor it had not been handed -- a TBO child's re-padded slice or a
|
||||||
|
registry's fresh copy raised. Value-based derivation must accept
|
||||||
|
both, pointwise, with no adopt/handover call."""
|
||||||
|
src, _, rows, seq_lens, _, want_full, want_swa = self._built(n=4)
|
||||||
|
padded = torch.cat([want_full, want_full.new_zeros(2)])
|
||||||
|
# TBO-child shape: a slice crossing the pad boundary.
|
||||||
|
got = self._field(src, rows, seq_lens, padded[2:6])
|
||||||
|
self.assertTrue(torch.equal(got[:2], want_swa[2:4]))
|
||||||
|
self.assertTrue(bool((got[2:] == 0).all()))
|
||||||
|
# Registry shape: a fresh equal-value copy.
|
||||||
|
got2 = self._field(src, rows, seq_lens, want_full.clone())
|
||||||
|
self.assertTrue(torch.equal(got2, want_swa))
|
||||||
|
|
||||||
|
def test_tombstoned_swa_page_clamps_to_sink(self):
|
||||||
|
src, allocator, rows, seq_lens, virt, want_full, _ = self._built(ps=1, n=2)
|
||||||
|
allocator.swa_v2p_page_table[int(virt[0])] = -1
|
||||||
|
got = self._field(src, rows, seq_lens, want_full[:1])
|
||||||
|
self.assertEqual(int(got[0]), 0)
|
||||||
|
|
||||||
|
def test_static_swa_pool_derives_via_pool_translate(self):
|
||||||
|
"""Static SWA pools: the field is the pool's own legacy full->swa
|
||||||
|
translate, computed at the same build; the rebind stays a no-op."""
|
||||||
|
pool = SWAKVPool.__new__(SWAKVPool)
|
||||||
|
pool.full_to_swa_index_mapping = torch.arange(10, dtype=torch.int64)
|
||||||
|
pool.translate_loc_from_full_to_swa = lambda t: t + 100
|
||||||
|
src = KVIndexTranslator(
|
||||||
|
req_to_token=torch.zeros((2, 4), dtype=torch.int64),
|
||||||
|
token_to_kv_pool_allocator=SimpleNamespace(),
|
||||||
|
token_to_kv_pool=pool,
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
loc = torch.tensor([5, 6], dtype=torch.int64)
|
||||||
|
fb = _FakeForwardBatch(out_cache_loc=loc)
|
||||||
|
src.rebind_write_loc(fb)
|
||||||
|
self.assertIs(fb.out_cache_loc, loc, "disabled rebind must be a no-op")
|
||||||
|
self.assertTrue(torch.equal(src.sliding_window_write_loc_for(loc), loc + 100))
|
||||||
|
|
||||||
|
def test_no_loc_or_no_swa_side_yields_none(self):
|
||||||
|
# Unified swa composite, but there is no write loc this forward.
|
||||||
|
src, _, rows, seq_lens, _, _, _ = self._built(n=2)
|
||||||
|
self.assertIsNone(src.sliding_window_write_loc_for(None))
|
||||||
|
# Passthrough on a non-SWA pool: a loc is given, but there is no swa
|
||||||
|
# id space to derive into.
|
||||||
|
plain = KVIndexTranslator(
|
||||||
|
req_to_token=torch.zeros((2, 4), dtype=torch.int64),
|
||||||
|
token_to_kv_pool_allocator=SimpleNamespace(),
|
||||||
|
token_to_kv_pool=SimpleNamespace(),
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
plain.sliding_window_write_loc_for(torch.tensor([3], dtype=torch.int64))
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rebind_retires_the_view_memo(self):
|
||||||
|
ps = 1
|
||||||
|
allocator = _build_composite(ps)
|
||||||
|
req_to_token, rows, seq_lens = _alloc_and_fill(allocator, ps, lens=[3, 2])
|
||||||
|
src = _make_source(allocator, req_to_token, ps)
|
||||||
|
fb = _FakeForwardBatch(
|
||||||
|
req_pool_indices=rows, seq_lens=seq_lens, seq_lens_cpu=seq_lens
|
||||||
|
)
|
||||||
|
v1 = src.index_table_for_batch(fb)
|
||||||
|
src.rebind_write_loc(_FakeForwardBatch(out_cache_loc=None))
|
||||||
|
v2 = src.index_table_for_batch(fb)
|
||||||
|
self.assertIsNot(v2, v1, "rebind starts the next forward: stale views die")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -539,6 +539,31 @@ class TestMultiEndedAllocator(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(int(buf[1].item()), 0)
|
self.assertEqual(int(buf[1].item()), 0)
|
||||||
|
|
||||||
|
def test_slot_zero_sink_invariant_survives_churn(self):
|
||||||
|
"""PINNED INVARIANT: virtual 0 <-> physical 0 (the padding sink), so
|
||||||
|
`translate_kv_loc(zeros) == zeros` -- after init AND after alloc/free/
|
||||||
|
compaction churn. The cuda-graph capture path RELIES on this: the
|
||||||
|
physical-loc contract replaced capture-time translate with a plain
|
||||||
|
copy of the zero-filled static buffer, which is only equivalent while
|
||||||
|
v2p[0] == 0. If an allocator change breaks this, captured stores would
|
||||||
|
write pad lanes to a live slot."""
|
||||||
|
_, full_alloc, _, full_kv, _ = self._build_pair()
|
||||||
|
zeros = torch.zeros(4, dtype=torch.int64)
|
||||||
|
|
||||||
|
self.assertEqual(int(full_alloc.virtual_to_physical[0].item()), 0)
|
||||||
|
self.assertTrue(torch.equal(full_alloc.translate_kv_loc(zeros), zeros))
|
||||||
|
|
||||||
|
# Churn: allocate, free interior (forces compaction moves), re-allocate.
|
||||||
|
a = self._alloc(full_alloc, full_kv, 6)
|
||||||
|
b = self._alloc(full_alloc, full_kv, 6)
|
||||||
|
self._free(full_alloc, full_kv, a)
|
||||||
|
c = self._alloc(full_alloc, full_kv, 4)
|
||||||
|
self._free(full_alloc, full_kv, b)
|
||||||
|
self._free(full_alloc, full_kv, c)
|
||||||
|
|
||||||
|
self.assertEqual(int(full_alloc.virtual_to_physical[0].item()), 0)
|
||||||
|
self.assertTrue(torch.equal(full_alloc.translate_kv_loc(zeros), zeros))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared SWA composite — unit tests
|
# Shared SWA composite — unit tests
|
||||||
|
|||||||
@@ -483,8 +483,9 @@ class TestUnifiedMHATokenToKVPool(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestFactoryDenseViews(unittest.TestCase):
|
class TestFactoryDenseViews(unittest.TestCase):
|
||||||
"""The real SWA factory builds both sub-pools and wires the matching
|
"""The real SWA factory builds dense sub-pools and wires the matching
|
||||||
kernel-facing multipliers into the composite allocator."""
|
kernel-facing multipliers into the composite allocator. End-to-end over
|
||||||
|
that factory, the rebind must emit BOTH kernel-facing write locs."""
|
||||||
|
|
||||||
# _swa_factory geometry: L_full = L_swa = 2, uniform 8/8 dims, ps = 1.
|
# _swa_factory geometry: L_full = L_swa = 2, uniform 8/8 dims, ps = 1.
|
||||||
FULL_MULT = 4 # 2 * L_full
|
FULL_MULT = 4 # 2 * L_full
|
||||||
@@ -526,6 +527,42 @@ class TestFactoryDenseViews(unittest.TestCase):
|
|||||||
self.assertEqual(b.token_to_kv_pool.swa_kv_pool.k_buffer[0].dim(), 3)
|
self.assertEqual(b.token_to_kv_pool.swa_kv_pool.k_buffer[0].dim(), 3)
|
||||||
self.assertGreater(pool.view_tail_pad_bytes, 0)
|
self.assertGreater(pool.view_tail_pad_bytes, 0)
|
||||||
|
|
||||||
|
def test_rebind_emits_dense_full_and_build_derives_swa(self):
|
||||||
|
"""End-to-end over the real factory: rebind_write_loc rebinds
|
||||||
|
out_cache_loc to FULL-kernel-facing ids (phase 1), and the per-batch build
|
||||||
|
derives the SWA-DENSE write loc pointwise from those kernel-facing values
|
||||||
|
(phase 2) — both checked against the formulas over the VIRTUAL
|
||||||
|
ids."""
|
||||||
|
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator
|
||||||
|
|
||||||
|
b = self._bundle()
|
||||||
|
alloc = b.token_to_kv_pool_allocator
|
||||||
|
v = alloc.alloc(4)
|
||||||
|
self.assertIsNotNone(v)
|
||||||
|
expected_full = alloc.full_v2p_page_table[v] * self.FULL_MULT # ps=1
|
||||||
|
expected_swa = alloc.swa_v2p_page_table[v] * self.SWA_MULT
|
||||||
|
|
||||||
|
class _FB:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fb = _FB()
|
||||||
|
fb.out_cache_loc = v.clone()
|
||||||
|
source = KVIndexTranslator(
|
||||||
|
req_to_token=torch.zeros((2, 8), dtype=torch.int64),
|
||||||
|
token_to_kv_pool_allocator=alloc,
|
||||||
|
token_to_kv_pool=b.token_to_kv_pool,
|
||||||
|
page_size=1,
|
||||||
|
device="cpu",
|
||||||
|
)
|
||||||
|
self.assertTrue(source.is_translating)
|
||||||
|
source.rebind_write_loc(fb)
|
||||||
|
self.assertTrue(torch.equal(fb.out_cache_loc, expected_full))
|
||||||
|
self.assertTrue(
|
||||||
|
torch.equal(
|
||||||
|
source.sliding_window_write_loc_for(fb.out_cache_loc), expected_swa
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
"""ForwardBatch construction wires the unified write-loc rebind.
|
||||||
|
|
||||||
|
`init_new` must call `kv_index_translator.rebind_write_loc`: a construction
|
||||||
|
path that skips it ships VIRTUAL write ids to the kernels, a silent
|
||||||
|
wrong-slot store. Also runs the REAL `_pad_inputs_to_size` against a live
|
||||||
|
translator, since pad lanes are zeros and zeros must derive to the slot-0
|
||||||
|
sink. Sliding-window semantics are pinned in test_kv_index_translator.py.
|
||||||
|
|
||||||
|
python -m pytest test/registered/unit/model_executor/test_unified_out_cache_loc_rebind.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import inspect
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from sglang.srt.mem_cache.kv_index_translator import KVIndexTranslator
|
||||||
|
from sglang.srt.model_executor.forward_batch_info import ForwardBatch, ForwardMode
|
||||||
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
from sglang.test.test_utils import CustomTestCase
|
||||||
|
|
||||||
|
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||||
|
|
||||||
|
_DEV = "cpu"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fb(out_cache_loc, **kw):
|
||||||
|
"""Minimal ForwardBatch with only the required core fields."""
|
||||||
|
n = 0 if out_cache_loc is None else out_cache_loc.shape[0]
|
||||||
|
defaults = dict(
|
||||||
|
forward_mode=ForwardMode.DECODE,
|
||||||
|
batch_size=max(n, 1),
|
||||||
|
input_ids=torch.zeros(max(n, 1), dtype=torch.int64),
|
||||||
|
req_pool_indices=torch.zeros(max(n, 1), dtype=torch.int64),
|
||||||
|
seq_lens=torch.ones(max(n, 1), dtype=torch.int64),
|
||||||
|
out_cache_loc=out_cache_loc,
|
||||||
|
seq_lens_sum=max(n, 1),
|
||||||
|
)
|
||||||
|
defaults.update(kw)
|
||||||
|
return ForwardBatch(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def _armed_source(v2p, swa_map):
|
||||||
|
"""A KVIndexTranslator hand-armed with fake translates: this file pins the
|
||||||
|
ForwardBatch-side wiring, not the composite's formulas (those are pinned
|
||||||
|
in test_kv_index_translator.py over the real allocator)."""
|
||||||
|
src = KVIndexTranslator(
|
||||||
|
req_to_token=torch.zeros((1, 4), dtype=torch.int64),
|
||||||
|
token_to_kv_pool_allocator=SimpleNamespace(),
|
||||||
|
token_to_kv_pool=SimpleNamespace(),
|
||||||
|
page_size=1,
|
||||||
|
device=_DEV,
|
||||||
|
)
|
||||||
|
src.is_translating = True
|
||||||
|
src._translate_full = lambda t, out=None: v2p[t.to(torch.int64)]
|
||||||
|
# Phase 2 derives from DENSE values through p2v + the swa v2p; arm the
|
||||||
|
# inverse of the fake v2p (ps=1, both multipliers 1: dense == physical,
|
||||||
|
# and the expected swa loc for virtual t is swa_map[t]).
|
||||||
|
p2v = torch.zeros(int(v2p.max()) + 1, dtype=torch.int64)
|
||||||
|
p2v[v2p] = torch.arange(v2p.numel(), dtype=torch.int64)
|
||||||
|
src._full_p2v_table = p2v
|
||||||
|
src._swa_v2p_table = swa_map
|
||||||
|
src._full_page_multiplier = 1
|
||||||
|
src._swa_page_multiplier = 1
|
||||||
|
return src
|
||||||
|
|
||||||
|
|
||||||
|
def _call_names(func) -> list:
|
||||||
|
"""Dotted call targets appearing in `func`'s body, e.g.
|
||||||
|
'model_runner.kv_index_translator.rebind_write_loc'."""
|
||||||
|
tree = ast.parse(textwrap.dedent(inspect.getsource(func)))
|
||||||
|
names = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
parts = []
|
||||||
|
cur = node.func
|
||||||
|
while isinstance(cur, ast.Attribute):
|
||||||
|
parts.append(cur.attr)
|
||||||
|
cur = cur.value
|
||||||
|
if isinstance(cur, ast.Name):
|
||||||
|
parts.append(cur.id)
|
||||||
|
names.append(".".join(reversed(parts)))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
class TestForwardBatchWiring(CustomTestCase):
|
||||||
|
"""Critical-path bookkeeping: the construction-time call sites."""
|
||||||
|
|
||||||
|
def test_init_new_calls_the_rebind(self):
|
||||||
|
self.assertIn(
|
||||||
|
"model_runner.kv_index_translator.rebind_write_loc",
|
||||||
|
_call_names(ForwardBatch.init_new.__func__),
|
||||||
|
"init_new must rebind the write loc through the source; a batch "
|
||||||
|
"built without it ships virtual ids to the kernels",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPadComposesWithDerivation(CustomTestCase):
|
||||||
|
def _fake_runner_for_pad(self, src):
|
||||||
|
return SimpleNamespace(
|
||||||
|
attn_backend=SimpleNamespace(get_cuda_graph_seq_len_fill_value=lambda: 0),
|
||||||
|
kv_index_translator=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pad_lanes_derive_to_sink_and_slices_stay_pointwise(self):
|
||||||
|
"""The REAL `_pad_inputs_to_size` composes with phase 2: pad lanes are
|
||||||
|
zeros, zeros derive to the slot-0 sink, and any slice of the padded
|
||||||
|
tensor (the TBO-child shape) derives pointwise -- no handover call
|
||||||
|
exists for the pad to make."""
|
||||||
|
n, padded = 3, 6
|
||||||
|
v2p = torch.arange(64, dtype=torch.int64) * 3
|
||||||
|
swa_map = torch.arange(64, dtype=torch.int64) * 5
|
||||||
|
src = _armed_source(v2p, swa_map)
|
||||||
|
virt = torch.tensor([11, 12, 13], dtype=torch.int64)
|
||||||
|
fb = _make_fb(virt.clone())
|
||||||
|
fb.positions = torch.arange(n, dtype=torch.int64)
|
||||||
|
fb.lora_ids = [None] * fb.batch_size
|
||||||
|
src.rebind_write_loc(fb)
|
||||||
|
self.assertTrue(torch.equal(fb.out_cache_loc, v2p[virt]))
|
||||||
|
|
||||||
|
fb._pad_inputs_to_size(self._fake_runner_for_pad(src), padded, fb.batch_size)
|
||||||
|
|
||||||
|
self.assertEqual(fb.out_cache_loc.shape[0], padded)
|
||||||
|
# Padded tail lanes go to slot 0 -- the reserved dummy-write sink.
|
||||||
|
self.assertTrue(bool((fb.out_cache_loc[n:] == 0).all()))
|
||||||
|
loc = src._swa_write_loc_unified(fb.out_cache_loc)
|
||||||
|
self.assertTrue(torch.equal(loc[:n], swa_map[virt]))
|
||||||
|
self.assertTrue(bool((loc[n:] == 0).all()))
|
||||||
|
self.assertEqual(loc.dtype, torch.int64)
|
||||||
|
# The TBO-child shape: a slice of the PADDED tensor derives pointwise.
|
||||||
|
sub = src._swa_write_loc_unified(fb.out_cache_loc[1:5])
|
||||||
|
self.assertTrue(torch.equal(sub, loc[1:5]))
|
||||||
|
|
||||||
|
def test_the_probe_separates_kernel_facing_from_virtual_ids(self):
|
||||||
|
"""A skipped rebind is the failure mode this contract has no other
|
||||||
|
guard against: virtual ids stay inside the OOB probe's bounds (they are
|
||||||
|
`blocks_per_page` times SMALLER than a kernel-facing id), so the store lands on
|
||||||
|
the wrong slots and only the output is wrong. The kernel-facing probe
|
||||||
|
is what separates them -- the in-page offset of a kernel-facing id is always
|
||||||
|
below page_size, and a virtual id's is not unless it happens to fall in
|
||||||
|
the first block."""
|
||||||
|
for page_size, blocks in ((1, 8), (4, 6)):
|
||||||
|
with self.subTest(page_size=page_size, blocks=blocks):
|
||||||
|
stride = page_size * blocks
|
||||||
|
virt = torch.arange(1, 2 * stride, dtype=torch.int64)
|
||||||
|
dense = (virt // page_size) * stride + virt % page_size
|
||||||
|
in_space = dense % stride < page_size
|
||||||
|
self.assertTrue(bool(in_space.all()), "kernel-facing ids must pass")
|
||||||
|
# Virtual ids pass only in the first block; that is why the
|
||||||
|
# probe needs a batch, not one id, to be conclusive.
|
||||||
|
caught = ~(virt % stride < page_size)
|
||||||
|
self.assertTrue(bool(caught.any()), "virtual ids must be caught")
|
||||||
|
|
||||||
|
def test_empty_loc_rebinds_to_empty(self):
|
||||||
|
src = _armed_source(
|
||||||
|
torch.arange(8, dtype=torch.int64), torch.arange(8, dtype=torch.int64)
|
||||||
|
)
|
||||||
|
fb = _make_fb(torch.empty(0, dtype=torch.int64))
|
||||||
|
src.rebind_write_loc(fb)
|
||||||
|
self.assertEqual(fb.out_cache_loc.numel(), 0)
|
||||||
|
self.assertEqual(src._swa_write_loc_unified(fb.out_cache_loc).numel(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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.
|
||||||
|
# ==============================================================================
|
||||||
|
"""`--enable-unified-memory` disables PREFILL cuda-graph capture.
|
||||||
|
|
||||||
|
BUG REGRESSION. Only decode capture is wired: the prefill graph runner builds
|
||||||
|
its ForwardBatch directly, so it never runs the unified pool's write-loc
|
||||||
|
rebind (rebind_write_loc) and the captured batch holds VIRTUAL ids -- the
|
||||||
|
captured store would silently write wrong slots.
|
||||||
|
|
||||||
|
The old gate only rejected `TC_PIECEWISE`, but the generic prefill default is
|
||||||
|
`BREAKABLE` -- so the DEFAULT unified invocation was broken; it only ever
|
||||||
|
worked when `--disable-piecewise-cuda-graph` (a deprecated alias for
|
||||||
|
`--cuda-graph-backend-prefill=disabled`) happened to be passed.
|
||||||
|
|
||||||
|
Pinned: the default is auto-disabled with a warning (unified boots out of the
|
||||||
|
box), an EXPLICIT prefill backend still raises (never silently override a
|
||||||
|
user's stated intent), and decode capture is untouched either way.
|
||||||
|
|
||||||
|
python -m pytest test/registered/unit/server_args/test_unified_prefill_cuda_graph_gate.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from sglang.srt.arg_groups.kv_cache_hook import handle_unified_memory_pool
|
||||||
|
from sglang.srt.model_executor.cuda_graph_config import Backend
|
||||||
|
from sglang.srt.server_args import ServerArgs
|
||||||
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
|
||||||
|
register_cpu_ci(est_time=5, suite="base-a-test-cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_handler(*, prefill_backend, explicit):
|
||||||
|
"""Run just `handle_unified_memory_pool` over a minimal stand-in."""
|
||||||
|
sa = ServerArgs.__new__(ServerArgs)
|
||||||
|
cg = SimpleNamespace(
|
||||||
|
prefill=SimpleNamespace(backend=prefill_backend),
|
||||||
|
decode=SimpleNamespace(backend=Backend.FULL),
|
||||||
|
)
|
||||||
|
for name, value in {
|
||||||
|
"enable_unified_memory": True,
|
||||||
|
"disaggregation_mode": "null",
|
||||||
|
"speculative_algorithm": None,
|
||||||
|
"speculative_eagle_topk": None,
|
||||||
|
"enable_hierarchical_cache": False,
|
||||||
|
"enable_lmcache": False,
|
||||||
|
"dcp_size": 1,
|
||||||
|
"cuda_graph_config": cg,
|
||||||
|
"cuda_graph_backend_prefill": prefill_backend if explicit else None,
|
||||||
|
}.items():
|
||||||
|
object.__setattr__(sa, name, value)
|
||||||
|
handle_unified_memory_pool(sa)
|
||||||
|
return cg
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnifiedPrefillCudaGraphGate(unittest.TestCase):
|
||||||
|
def test_default_prefill_capture_is_auto_disabled(self):
|
||||||
|
"""The generic default (BREAKABLE) must be turned off, not crash the
|
||||||
|
server 30 seconds later inside graph capture."""
|
||||||
|
for backend in (Backend.BREAKABLE, Backend.FULL, Backend.TC_PIECEWISE):
|
||||||
|
cg = _run_handler(prefill_backend=backend, explicit=False)
|
||||||
|
self.assertEqual(cg.prefill.backend, Backend.DISABLED)
|
||||||
|
# Decode capture is the wired path and must survive untouched.
|
||||||
|
self.assertEqual(cg.decode.backend, Backend.FULL)
|
||||||
|
|
||||||
|
def test_explicit_prefill_backend_is_refused(self):
|
||||||
|
"""A user who explicitly asked for prefill graphs gets a clear error,
|
||||||
|
not a silent override of their stated intent."""
|
||||||
|
for backend in (Backend.BREAKABLE, Backend.FULL, Backend.TC_PIECEWISE):
|
||||||
|
with self.assertRaises(ValueError) as ctx:
|
||||||
|
_run_handler(prefill_backend=backend, explicit=True)
|
||||||
|
self.assertIn("prefill capture is not wired", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_already_disabled_is_a_no_op(self):
|
||||||
|
cg = _run_handler(prefill_backend=Backend.DISABLED, explicit=True)
|
||||||
|
self.assertEqual(cg.prefill.backend, Backend.DISABLED)
|
||||||
|
self.assertEqual(cg.decode.backend, Backend.FULL)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user