Files
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

458 lines
16 KiB
Python
Executable File

# SPDX-License-Identifier: MIT AND Apache-2.0
# SPDX-FileCopyrightText: Copyright (c) 2026 LightSeek Foundation
# SPDX-FileCopyrightText: Copyright 2023-2024 SGLang Team
#
# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Base classes for grammar-guided constrained decoding backends."""
import time
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass
from threading import Event, Lock
from typing import Any
from tokenspeed.runtime.utils.server_args import ServerArgs
@dataclass
class CacheEntry:
value: Any
event: Event
# Set non-None when a worker begins compiling for this key. Doubles as
# an ownership marker so a second ``init_value`` call for the same key
# waits on ``event`` instead of starting a redundant compile.
started_at: float | None = None
# Shared future for the in-flight compile, set by
# ``get_cached_or_future_value`` under the cache lock. Concurrent
# callers for the same key reuse this instead of submitting duplicate
# tasks that would saturate the executor on repeated same-schema traffic.
future: Future | None = None
class BaseGrammarObject:
"""Base for compiled grammar wrappers. Concrete backends subclass this."""
is_invalid: bool = False
expires_at: float | None = None
@property
def expired(self) -> bool:
return False
class InvalidGrammarObject(BaseGrammarObject):
"""Sentinel cached when a grammar fails to compile (or compile times out).
Carries the underlying error string so the request abort message can
surface the actual reason — bad regex, malformed schema, timeout, etc.
``expires_at`` (monotonic seconds) lets transient failures decay so a
one-off slow compile doesn't poison a valid key forever. ``None`` means
the failure is permanent (e.g. malformed schema — recompiling won't help).
"""
is_invalid = True
def __init__(
self,
error_message: str = "Unknown grammar error",
expires_at: float | None = None,
) -> None:
super().__init__()
self.error_message = error_message
self.expires_at = expires_at
@property
def expired(self) -> bool:
return self.expires_at is not None and time.monotonic() >= self.expires_at
def __repr__(self) -> str:
return (
f"InvalidGrammarObject(error_message={self.error_message!r}, "
f"expires_at={self.expires_at!r})"
)
@dataclass
class TimeoutHistory:
"""Per-key bookkeeping so the cache can escalate to a permanent failure
after enough transient timeouts (a single slow compile shouldn't poison
the key, but a key that *consistently* times out is broken).
"""
count: int = 0
class BaseGrammarBackend:
"""Base backend with shared cache management for grammar objects."""
def __init__(self):
self.executor = ThreadPoolExecutor()
self.cache: dict[tuple[str, str], CacheEntry] = {}
self.cache_lock = Lock()
# Tracks transient-timeout attempts per key. Cleared on a successful
# compile (in init_value) or on reset().
self.timeout_history: dict[tuple[str, str], TimeoutHistory] = {}
# ------------------------------------------------------------------
# Subclass hook
# ------------------------------------------------------------------
def init_value_impl(
self, key: tuple[str, str], require_reasoning: bool
) -> BaseGrammarObject:
raise NotImplementedError()
# ------------------------------------------------------------------
# Cache API
# ------------------------------------------------------------------
def init_value(self, key: tuple[str, str]) -> BaseGrammarObject:
with self.cache_lock:
entry = self.cache.get(key)
if entry is None:
entry = CacheEntry(None, Event(), started_at=time.monotonic())
self.cache[key] = entry
cache_hit = False
elif entry.event.is_set() or entry.started_at is not None:
# Either already compiled, or another worker claimed the
# compile (``started_at`` set). Wait on the event below.
cache_hit = True
else:
# Entry was pre-created by ``get_cached_or_future_value``
# but no worker has claimed it yet — take ownership.
entry.started_at = time.monotonic()
cache_hit = False
if cache_hit:
entry.event.wait()
else:
# Backends should return InvalidGrammarObject(message); accept
# legacy None as "unknown error" for safety.
if (value := self.init_value_impl(key, False)) is None:
value = InvalidGrammarObject()
with self.cache_lock:
is_active = self.cache.get(key) is entry
if is_active:
# If a parallel caller (e.g. GrammarManager on
# compile timeout) wrote an InvalidGrammarObject
# while we were compiling, keep that marker as long
# as it hasn't expired — otherwise a slow compile
# finishing a moment after timeout would silently
# un-invalidate the cache. Once the marker expires
# we let the freshly-compiled value take over so a
# transient slow compile doesn't poison the key
# forever.
cached = entry.value
if not (
cached is not None and cached.is_invalid and not cached.expired
):
entry.value = value
if not value.is_invalid:
# Successful compile — clear timeout bookkeeping.
self.timeout_history.pop(key, None)
else:
# Orphan: our entry was evicted (e.g. its timeout
# marker expired and ``get_cached_or_future_value``
# replaced it). Don't touch shared state —
# ``timeout_history`` now belongs to the new attempt
# — but still publish our compiled value on the old
# entry so any future/wait still holding a reference
# to it resolves with a usable result instead of
# deadlocking or returning None.
entry.value = value
entry.event.set()
if entry.value.is_invalid:
return entry.value
return entry.value.copy()
def get_cached_or_future_value(
self, key: tuple[str, str]
) -> tuple[BaseGrammarObject | Future, bool]:
"""Return (value, cache_hit).
On cache hit: value is either a fresh grammar copy or an
InvalidGrammarObject carrying the original compile error.
On miss: value is a Future that resolves to the same.
Expired InvalidGrammarObject markers are evicted on lookup so a
transient timeout decays into a retry instead of poisoning the key.
"""
with self.cache_lock:
entry = self.cache.get(key)
if (
entry is not None
and entry.event.is_set()
and entry.value.is_invalid
and entry.value.expired
):
# Drop the stale marker.
del self.cache[key]
entry = None
if entry is not None and entry.event.is_set():
if entry.value.is_invalid:
return entry.value, True
return entry.value.copy(), True
# In-flight compile — share its future so concurrent callers
# for the same key don't each submit duplicate work that
# would park executor workers on the same event.
if entry is not None and entry.future is not None:
return entry.future, False
# Pre-create the entry under the lock so any caller that
# races in after us finds our shared future instead of
# submitting its own.
if entry is None:
entry = CacheEntry(value=None, event=Event())
self.cache[key] = entry
entry.future = self.executor.submit(self.init_value, key)
return entry.future, False
def compile_started_at(self, key: tuple[str, str]) -> float | None:
"""Monotonic timestamp at which the worker thread began running
``init_value_impl`` for ``key``, or None if no compile has ever
started for this key (no cache entry, or the entry was created
directly via ``cache_invalid`` / ``record_compile_timeout`` without
a backing compile).
Returned even after the compile has finished — callers want
"compile-only elapsed", which is ``now - started_at`` regardless of
completion. Otherwise a worker that finishes between a caller's
``future.done()`` check and the elapsed-time check would silently
flip the elapsed calculation back to wall-clock-from-submit and
could trigger a spurious timeout against a request that actually
succeeded.
"""
with self.cache_lock:
entry = self.cache.get(key)
return entry.started_at if entry is not None else None
def record_compile_timeout(
self,
key: tuple[str, str],
error_message: str,
ttl_secs: float,
max_retries: int,
) -> None:
"""Cache a compile-timeout marker for ``key``.
Each call increments a per-key attempt counter. While the count is at
or below ``max_retries`` the marker has a finite TTL so the next
request (after the marker expires) gets a fresh compile attempt.
Once the count crosses the threshold the marker is escalated to
permanent (no TTL) — at that point the compiler is consistently
broken for this key and there's nothing to gain by retrying.
Spurious-timeout safety: if the cache already holds a valid grammar
(the worker just finished compiling, racing this call), this is a
no-op — we don't penalize a working key, and we don't clobber a
freshly-committed result with a stale timeout.
Counter reset semantics:
- cleared on a successful compile (see ``init_value``)
- cleared on ``reset()``
"""
with self.cache_lock:
entry = self.cache.get(key)
if (
entry is not None
and entry.event.is_set()
and not entry.value.is_invalid
):
# A valid grammar landed in the cache while we were about to
# declare timeout. Drop the timeout — the grammar works.
return
history = self.timeout_history.setdefault(key, TimeoutHistory())
history.count += 1
if history.count > max_retries:
expires_at = None # permanent
error_message = (
f"{error_message} (gave up after {history.count - 1} retries)"
)
else:
expires_at = time.monotonic() + ttl_secs
invalid = InvalidGrammarObject(error_message, expires_at=expires_at)
if entry is None:
event = Event()
event.set()
self.cache[key] = CacheEntry(invalid, event)
elif not entry.event.is_set():
entry.value = invalid
entry.event.set()
else:
# entry.value.is_invalid (valid was filtered above) — refresh.
entry.value = invalid
def cache_invalid(self, key: tuple[str, str], error_message: str) -> None:
"""Cache a permanent compile failure (e.g. bad syntax — retrying
won't help). Use ``record_compile_timeout`` for transient failures."""
invalid = InvalidGrammarObject(error_message, expires_at=None)
with self.cache_lock:
entry = self.cache.get(key)
if entry is None:
event = Event()
event.set()
self.cache[key] = CacheEntry(invalid, event)
elif not entry.event.is_set():
entry.value = invalid
entry.event.set()
elif entry.value.is_invalid:
entry.value = invalid
def reset(self):
with self.cache_lock:
self.cache.clear()
self.timeout_history.clear()
def create_grammar_backend(server_args: ServerArgs, tokenizer, vocab_size):
if server_args.grammar_backend == "none":
return None
elif server_args.grammar_backend == "xgrammar":
from tokenspeed.runtime.grammar.xgrammar_backend import (
XGrammarGrammarBackend,
)
grammar_backend = XGrammarGrammarBackend(
tokenizer,
vocab_size=vocab_size,
disable_any_whitespace=server_args.disable_any_whitespace,
)
else:
raise ValueError(f"Invalid grammar backend: {server_args.grammar_backend}")
# Reasoning + grammar deferral lives in the OpenAI serving layer now:
# response_format is rewritten into an xgrammar structural_tag whose
# trigger covers the post-reasoning preamble. gpt-oss is wired today
# (``<|start|>assistant<|channel|>final<|message|>``); other reasoning
# parsers (qwen3-thinking, deepseek-r1, ...) can be added the same
# way as needed. The previous token-id-based ``ReasonerGrammarBackend``
# wrapper has been removed: it couldn't handle multi-token channel
# preambles and carried known P1 bugs around state cloning + reasoning
# initialization.
return grammar_backend
def get_apply_vocab_mask_func(grammar_backend: str):
"""Return the backend-specific in-place vocab-mask-apply function.
The function's signature is ``(logits, vocab_mask) -> None``. It is
stored on ``SamplingBatchInfo.apply_vocab_mask`` so the captured
sampler can call it without branching on backend. Only xgrammar is
wired up today; add branches here when new backends are added.
"""
if grammar_backend == "xgrammar":
from xgrammar import apply_token_bitmask_inplace
# xgrammar's native signature is (logits, bitmask, *, ...);
# adapt to the canonical (logits, vocab_mask) kwargs the
# sampler uses.
def _apply(logits, vocab_mask):
apply_token_bitmask_inplace(logits, vocab_mask)
return _apply
raise ValueError(f"Unsupported grammar backend: {grammar_backend}")