[perf] reduce overhead of fill_ids list reconstruction and decref (#27965)

This commit is contained in:
Qiaolin Yu
2026-06-14 00:41:11 -07:00
committed by GitHub
parent f2d7d67603
commit f293ddf3ce
8 changed files with 363 additions and 66 deletions
+37 -9
View File
@@ -695,11 +695,14 @@ class Req(ReqDllmMixin):
if origin_input_ids_unpadded
else self.origin_input_ids
) # Before image padding
# Each decode stage's output ids
# Each decode stage's output ids. Append-only by contract:
# _refresh_fill_ids infers how many output tokens are already in
# full_untruncated_fill_ids from lengths alone, so in-place rewrites
# that preserve length would silently corrupt fill_ids.
self.output_ids = array("q")
# Full untruncated sequence: origin + output (+ DLLM mask block).
# Rebuilt at the top of each init_next_round_input; admission only
# updates fill_len, never mutates this array's length.
# Kept in sync by _refresh_fill_ids; admission only updates fill_len,
# never mutates this array's length.
self.full_untruncated_fill_ids = array("q")
self.fill_len: int = 0
@@ -1069,6 +1072,27 @@ class Req(ReqDllmMixin):
def get_fill_ids(self) -> array:
return self.full_untruncated_fill_ids[: self.fill_len]
def _refresh_fill_ids(self) -> None:
"""Keep full_untruncated_fill_ids == origin_input_ids + output_ids by
appending only the new output tokens.
Falls back to a full rebuild when the in-place append is invalid:
- aliasing: scheduler_pp_mixin assigns full_untruncated_fill_ids =
origin_input_ids directly, so extending in place would write output
tokens into the origin;
- lengths disagree: fresh req (array still empty), retraction
(output_ids reset to empty), or set_finish_with_abort (origin
replaced by a 1-token stub).
"""
n_have_output = len(self.full_untruncated_fill_ids) - len(self.origin_input_ids)
if (
self.full_untruncated_fill_ids is not self.origin_input_ids
and 0 <= n_have_output <= len(self.output_ids)
):
self.full_untruncated_fill_ids.extend(self.output_ids[n_have_output:])
else:
self.full_untruncated_fill_ids = self.origin_input_ids + self.output_ids
def init_next_round_input(
self,
tree_cache: Optional[BasePrefixCache] = None,
@@ -1078,7 +1102,7 @@ class Req(ReqDllmMixin):
self._init_fill_ids_for_dllm()
self.determine_dllm_phase()
else:
self.full_untruncated_fill_ids = self.origin_input_ids + self.output_ids
self._refresh_fill_ids()
input_len = len(self.full_untruncated_fill_ids)
@@ -1098,14 +1122,16 @@ class Req(ReqDllmMixin):
)
self.logprob_start_len = -1
token_ids_to_match = self.full_untruncated_fill_ids[
: self._compute_max_prefix_len(input_len)
]
# Pass the full array with a raw-token cap (limit) instead of slicing,
# avoiding an O(context) copy per prefill-batch build.
token_ids_to_match = self.full_untruncated_fill_ids
key_limit: Optional[int] = self._compute_max_prefix_len(input_len)
# Disable prefix caching when embed overrides are present: same token IDs
# with different override vectors must not share cached KV values.
if self.positional_embed_overrides is not None:
token_ids_to_match = array("q")
key_limit = None
if tree_cache is not None:
if cow_mamba is None:
@@ -1113,7 +1139,9 @@ class Req(ReqDllmMixin):
match_result = tree_cache.match_prefix(
MatchPrefixParams(
key=RadixKey(
token_ids=token_ids_to_match, extra_key=self.extra_key
token_ids=token_ids_to_match,
extra_key=self.extra_key,
limit=key_limit,
),
req=self,
cow_mamba=cow_mamba,
@@ -2314,7 +2342,7 @@ class ScheduleBatch(ScheduleBatchDisaggregationDecodeMixin):
running_bs = running_batch.batch_size()
for req in running_batch.reqs:
req.full_untruncated_fill_ids = req.origin_input_ids + req.output_ids
req._refresh_fill_ids()
req.fill_len = len(req.full_untruncated_fill_ids)
req.set_extend_input_len(1)
@@ -507,7 +507,7 @@ class MambaRadixCache(KVCacheEventMixin, BasePrefixCache):
prev_prefix_len = params.prev_prefix_len
if value is None:
value = torch.tensor([x for x in key.token_ids], dtype=torch.int64)
value = torch.tensor([x for x in key.raw_token_ids()], dtype=torch.int64)
prefix_len, mamba_exist = self._insert_helper(
self.root_node, key, value, mamba_value, params.chunked, prev_prefix_len
)
+27 -6
View File
@@ -56,13 +56,14 @@ if TYPE_CHECKING:
class RadixKey:
"""is_bigram=True: token_ids holds raw tokens (N+1 for N bigrams); slices share one boundary token."""
__slots__ = ("token_ids", "extra_key", "is_bigram")
__slots__ = ("token_ids", "extra_key", "is_bigram", "limit")
def __init__(
self,
token_ids: array[int],
extra_key: Optional[str] = None,
is_bigram: bool = False,
limit: Optional[int] = None,
):
# token ids sequence (raw ints in both modes)
self.token_ids = token_ids
@@ -70,21 +71,40 @@ class RadixKey:
self.extra_key = extra_key
# bigram view over token_ids: length = max(0, len(token_ids) - 1)
self.is_bigram = is_bigram
# Optional cap on raw tokens: behave as if token_ids were sliced to
# token_ids[:limit], without the O(n) copy. None = use all tokens.
self.limit = limit
def _raw_len(self) -> int:
n = len(self.token_ids)
if self.limit is not None and self.limit < n:
return self.limit
return n
def raw_token_ids(self) -> array:
"""token_ids honoring `limit` (copies only when capped)."""
n = self._raw_len()
t = self.token_ids
return t if n == len(t) else t[:n]
def __len__(self) -> int:
n = self._raw_len()
if self.is_bigram:
n = len(self.token_ids)
return n - 1 if n > 0 else 0
return len(self.token_ids)
return n
# TODO(Jialin): vectorize with numpy without PyLong boxing
def __iter__(self) -> Iterator:
t = self.token_ids
n = self._raw_len()
if self.is_bigram:
t = self.token_ids
for i in range(len(t) - 1):
for i in range(n - 1 if n > 0 else 0):
yield (t[i], t[i + 1])
elif n == len(t):
yield from t
else:
yield from self.token_ids
for i in range(n):
yield t[i]
def __getitem__(self, idx: Union[int, slice]) -> RadixKey:
# Normalize int -> 1-element slice so the rest handles one shape.
@@ -166,6 +186,7 @@ class RadixKey:
matched = max(0, min(matched_tokens - 1, len(self), len(other)))
return (matched // page_size) * page_size if page_size > 1 else matched
matched_tokens = min(matched_tokens, len(self), len(other))
if page_size == 1:
return matched_tokens
return (matched_tokens // page_size) * page_size
@@ -101,7 +101,7 @@ class RadixCacheCpp(BasePrefixCache):
def match_prefix(self, params: MatchPrefixParams) -> MatchResult:
key = params.key
device_indices_vec, host_indices_length, node_gpu, node_cpu = (
self.tree.match_prefix(key.token_ids)
self.tree.match_prefix(key.raw_token_ids())
)
return MatchResult(
device_indices=self._merge_tensor(device_indices_vec),
@@ -46,7 +46,8 @@ class _LMCacheLoadBackMarker:
``match_prefix`` call in MP mode.
"""
key: RadixKey # page-aligned key the scheduler matched on
key: RadixKey # detached snapshot of the matched key (the live query key
# aliases the req's growing fill_ids and must not be retained)
value_numel: int # number of tokens already in radix at match time
@@ -216,14 +217,17 @@ class LMCRadixCache(RadixCache):
LMCache has tokens beyond radix. Otherwise releases
the held read locks and returns the radix-only result.
"""
matched = self.lmcache_connector.lookup_kv(key.token_ids, req.rid)
token_ids = key.raw_token_ids()
matched = self.lmcache_connector.lookup_kv(token_ids, req.rid)
if matched <= value.numel():
# Release the read locks; keep the pending session for end_session.
self.lmcache_connector.release_pending(req.rid)
return base_res
if token_ids is key.token_ids:
token_ids = token_ids[:]
self._mp_load_back_markers[req.rid] = _LMCacheLoadBackMarker(
key=key,
key=RadixKey(token_ids, key.extra_key, key.is_bigram),
value_numel=int(value.numel()),
)
return MatchResult(
@@ -254,13 +258,14 @@ class LMCRadixCache(RadixCache):
if uncached_len == 0:
return base_res
token_ids = key.raw_token_ids()
result = self._load_back(
key=key,
value_numel=int(value.numel()),
uncached_len=uncached_len,
last_node=last_node,
load_fn=lambda sm, pp: self._ip_load_back(
token_ids=key.token_ids,
token_ids=token_ids,
value_numel=int(value.numel()),
slot_mapping=sm,
prefix_pad=pp,
+124 -44
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import logging
import time
import uuid
from array import array
from typing import TYPE_CHECKING, Dict, Optional
from sglang.srt.managers.io_struct import (
@@ -94,12 +95,111 @@ class Session:
self.req_nodes: Dict[str, SessionReqNode] = {}
self.close_on_finish: bool = False
self._inflight: bool = False
# Token-array lengths of last_req as of its finish_req. The share path
# appends speculatively beyond these; only finish_req confirms them, so
# _share_token_arrays trims back first (heals aborted turns).
self.committed_origin_len: Optional[int] = None
self.committed_unpadded_len: Optional[int] = None
self.committed_fill_len: Optional[int] = None
def is_timed_out(self) -> bool:
if self.timeout is None:
return False
return time.monotonic() - self.last_active_time > self.timeout
@staticmethod
def _strip_bos_token(req: TokenizedGenerateReqInput, tokenizer) -> None:
"""Trim a leading BOS on an appended turn; shift mm offsets to match."""
if not (
tokenizer is not None
and req.input_ids
and req.input_ids[0] == tokenizer.bos_token_id
):
return
req.input_ids = req.input_ids[1:]
if req.mm_inputs:
for item in req.mm_inputs.mm_items:
if item.offsets:
if any(s == 0 for s, _ in item.offsets):
logging.warning(
"mm_item offset starts at 0 (BOS position), "
"clamping to 0 after BOS strip"
)
item.offsets = [
(max(0, s - 1), max(0, e - 1)) for s, e in item.offsets
]
def _share_token_arrays(self, last_req: Req, new_input_ids):
"""Plain streaming append: reuse last_req's token arrays in place.
Trims each array back to its committed length first — an earlier turn
may have appended its tokens and then aborted before finish_req, and
req_nodes still points at last_req, so anything beyond the committed
lengths is unconfirmed. Then extends with last turn's output and the
new input. Returns (input_ids, input_ids_unpadded, carry_fill);
carry_fill (== the new origin) spares the first fill_ids rebuild.
"""
out_tail = last_req.output_ids[: last_req.sampling_params.max_new_tokens]
input_ids = last_req.origin_input_ids
del input_ids[self.committed_origin_len :]
if last_req.origin_input_ids_unpadded is input_ids:
input_ids_unpadded = input_ids
else:
input_ids_unpadded = last_req.origin_input_ids_unpadded
del input_ids_unpadded[self.committed_unpadded_len :]
carry_fill = last_req.full_untruncated_fill_ids
if (
not isinstance(carry_fill, array)
or carry_fill is input_ids
or carry_fill is input_ids_unpadded
):
# Unexpected type or aliased with an origin array (extending it
# below would double-append): let _refresh_fill_ids rebuild.
carry_fill = None
else:
del carry_fill[self.committed_fill_len :]
baked = len(carry_fill) - len(input_ids)
if 0 <= baked <= len(out_tail):
carry_fill.extend(out_tail[baked:])
carry_fill.extend(new_input_ids)
else:
carry_fill = None
input_ids.extend(out_tail)
input_ids.extend(new_input_ids)
if input_ids_unpadded is not input_ids:
input_ids_unpadded.extend(out_tail)
input_ids_unpadded.extend(new_input_ids)
return input_ids, input_ids_unpadded, carry_fill
@staticmethod
def _concat_token_arrays(
last_req: Req, req: TokenizedGenerateReqInput, session_params
):
"""Copy-based assembly for replace/offset/drop_previous_output turns."""
out_tail = last_req.output_ids[: last_req.sampling_params.max_new_tokens]
input_ids = last_req.origin_input_ids + out_tail
if session_params.drop_previous_output:
input_ids = last_req.origin_input_ids[:]
if session_params.offset and session_params.offset != 0:
input_ids = input_ids[: session_params.offset] + req.input_ids
else:
input_ids += req.input_ids
input_ids_unpadded = last_req.origin_input_ids_unpadded + out_tail
if session_params.drop_previous_output:
input_ids_unpadded = last_req.origin_input_ids_unpadded[:]
if session_params.offset and session_params.offset != 0:
input_ids_unpadded = (
input_ids_unpadded[: session_params.offset] + req.input_ids
)
else:
input_ids_unpadded += req.input_ids
return input_ids, input_ids_unpadded
def create_req(
self,
req: TokenizedGenerateReqInput,
@@ -163,54 +263,28 @@ class Session:
abort_message = "Session request is appending to a request that hasn't finished."
logging.warning(abort_message)
carry_fill = None
if last_req is not None:
# trim bos token if it is an append
if (
tokenizer is not None
and req.input_ids
and req.input_ids[0] == tokenizer.bos_token_id
):
req.input_ids = req.input_ids[1:]
# Adjust mm_item offsets since they were computed on
# the pre-strip sequence (with BOS at position 0)
if req.mm_inputs:
for item in req.mm_inputs.mm_items:
if item.offsets:
if any(s == 0 for s, _ in item.offsets):
logging.warning(
"mm_item offset starts at 0 (BOS position), "
"clamping to 0 after BOS strip"
)
item.offsets = [
(max(0, s - 1), max(0, e - 1)) for s, e in item.offsets
]
input_ids = (
last_req.origin_input_ids
+ last_req.output_ids[: last_req.sampling_params.max_new_tokens]
self._strip_bos_token(req, tokenizer)
# In-place sharing is only safe for the plain streaming append:
# streaming sessions allow a single inflight request, last_req has
# finished, and the committed_* lengths recorded by finish_req let
# _share_token_arrays trim away tokens appended by an aborted turn.
# offset / drop_previous_output rewrite history and must copy.
can_share_token_arrays = (
self.streaming
and self.committed_origin_len is not None
and not session_params.drop_previous_output
and not (session_params.offset and session_params.offset != 0)
)
if session_params.drop_previous_output:
input_ids = last_req.origin_input_ids[:]
if session_params.offset and session_params.offset != 0:
input_ids = input_ids[: session_params.offset] + req.input_ids
else:
input_ids += req.input_ids
input_ids_unpadded = (
last_req.origin_input_ids_unpadded
+ last_req.output_ids[: last_req.sampling_params.max_new_tokens]
)
if session_params.drop_previous_output:
input_ids_unpadded = last_req.origin_input_ids_unpadded[:]
if session_params.offset and session_params.offset != 0:
input_ids_unpadded = (
input_ids_unpadded[: session_params.offset] + req.input_ids
if can_share_token_arrays:
input_ids, input_ids_unpadded, carry_fill = self._share_token_arrays(
last_req, req.input_ids
)
else:
input_ids_unpadded += req.input_ids
input_ids, input_ids_unpadded = self._concat_token_arrays(
last_req, req, session_params
)
else:
input_ids = req.input_ids
input_ids_unpadded = req.input_ids
@@ -243,6 +317,8 @@ class Session:
if last_req is not None:
new_req.multimodal_inputs = last_req.multimodal_inputs
new_req.tokenizer = tokenizer
if carry_fill is not None:
new_req.full_untruncated_fill_ids = carry_fill
if abort:
new_req.set_finish_with_abort(abort_message)
@@ -263,6 +339,10 @@ class Session:
prev_node.req.session = None
self.req_nodes.clear()
self.req_nodes[req.rid] = SessionReqNode(req)
# Confirm this req's token arrays as the session's rollback point.
self.committed_origin_len = len(req.origin_input_ids)
self.committed_unpadded_len = len(req.origin_input_ids_unpadded)
self.committed_fill_len = len(req.full_untruncated_fill_ids)
def abort_req(self):
"""Clear inflight flag on abort (req_nodes stays unchanged)."""
@@ -247,7 +247,7 @@ class StreamingSession(BasePrefixCache):
# token_ids = get_fill_ids()[:input_len-1] (1-token logit reserve
# already applied). min handles retract retry where committed_len
# can exceed len(token_ids) by 1.
prefix_len = min(req.kv_committed_len, len(params.key.token_ids))
prefix_len = min(req.kv_committed_len, len(params.key))
# Streaming sessions are append-only (session_controller rollback
# ensures req_nodes always points to the last successful req).