Fix deterministic inference for Inkling (#33417)
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
"""Process-wide so it need not thread through the autograd entry points. Set
|
||||||
|
before the first kernel compile; it keys the forward compile cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
_batch_invariant = False
|
||||||
|
|
||||||
|
|
||||||
|
def set_batch_invariant(enabled: bool) -> None:
|
||||||
|
global _batch_invariant
|
||||||
|
_batch_invariant = bool(enabled)
|
||||||
|
|
||||||
|
|
||||||
|
def is_batch_invariant() -> bool:
|
||||||
|
return _batch_invariant
|
||||||
@@ -22,6 +22,7 @@ class BlockInfo:
|
|||||||
window_size_left: Optional[Int32] = None
|
window_size_left: Optional[Int32] = None
|
||||||
window_size_right: Optional[Int32] = None
|
window_size_right: Optional[Int32] = None
|
||||||
qhead_per_kvhead_packgqa: cutlass.Constexpr[int] = 1
|
qhead_per_kvhead_packgqa: cutlass.Constexpr[int] = 1
|
||||||
|
batch_invariant: cutlass.Constexpr[bool] = False
|
||||||
|
|
||||||
@cute.jit
|
@cute.jit
|
||||||
def get_n_idx_left_right(
|
def get_n_idx_left_right(
|
||||||
@@ -159,6 +160,11 @@ class BlockInfo:
|
|||||||
n_block_min: Int32,
|
n_block_min: Int32,
|
||||||
) -> Int32:
|
) -> Int32:
|
||||||
"""If we have separate iterations with causal or local masking at the start, where do we stop"""
|
"""If we have separate iterations with causal or local masking at the start, where do we stop"""
|
||||||
|
# The boundary carries -seqlen_q and the two paths round differently, so a
|
||||||
|
# row's bits depend on how many queries share the pass. Masking every
|
||||||
|
# block is identity below the diagonal.
|
||||||
|
if const_expr(self.batch_invariant):
|
||||||
|
return n_block_min
|
||||||
m_idx_min = m_block * self.tile_m
|
m_idx_min = m_block * self.tile_m
|
||||||
if const_expr(self.qhead_per_kvhead_packgqa > 1):
|
if const_expr(self.qhead_per_kvhead_packgqa > 1):
|
||||||
m_idx_min = m_idx_min // self.qhead_per_kvhead_packgqa
|
m_idx_min = m_idx_min // self.qhead_per_kvhead_packgqa
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ class FlashAttentionForwardSm100:
|
|||||||
v_dequant: bool = False,
|
v_dequant: bool = False,
|
||||||
q_sf_interleaved: bool = False,
|
q_sf_interleaved: bool = False,
|
||||||
kv_sf_interleaved: bool = False,
|
kv_sf_interleaved: bool = False,
|
||||||
|
batch_invariant: bool = False,
|
||||||
):
|
):
|
||||||
# MXFP8 block-scaled attention (see interface._flash_attn_fwd):
|
# MXFP8 block-scaled attention (see interface._flash_attn_fwd):
|
||||||
# qk_blockscaled: Q/K fp8 e4m3 + per-32 UE8M0 scales; QK^T runs as
|
# qk_blockscaled: Q/K fp8 e4m3 + per-32 UE8M0 scales; QK^T runs as
|
||||||
@@ -331,6 +332,7 @@ class FlashAttentionForwardSm100:
|
|||||||
self.pack_gqa = pack_gqa
|
self.pack_gqa = pack_gqa
|
||||||
# relative (sheared) bias
|
# relative (sheared) bias
|
||||||
self.has_bias = has_bias
|
self.has_bias = has_bias
|
||||||
|
self.batch_invariant = batch_invariant
|
||||||
self.rel_extent_padded = rel_extent_padded
|
self.rel_extent_padded = rel_extent_padded
|
||||||
assert rel_extent_padded % n_block_size == 0
|
assert rel_extent_padded % n_block_size == 0
|
||||||
self.bias_n_max = rel_extent_padded // n_block_size if has_bias else 0
|
self.bias_n_max = rel_extent_padded // n_block_size if has_bias else 0
|
||||||
@@ -2069,6 +2071,7 @@ class FlashAttentionForwardSm100:
|
|||||||
qhead_per_kvhead_packgqa=(
|
qhead_per_kvhead_packgqa=(
|
||||||
self.qhead_per_kvhead if const_expr(self.pack_gqa) else 1
|
self.qhead_per_kvhead if const_expr(self.pack_gqa) else 1
|
||||||
),
|
),
|
||||||
|
batch_invariant=self.batch_invariant,
|
||||||
)
|
)
|
||||||
SeqlenInfoCls = partial(
|
SeqlenInfoCls = partial(
|
||||||
SeqlenInfoQK.create,
|
SeqlenInfoQK.create,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ from cutlass import Float32, Int32
|
|||||||
from quack.compile_utils import make_fake_tensor as fake_tensor
|
from quack.compile_utils import make_fake_tensor as fake_tensor
|
||||||
|
|
||||||
from sglang.kernels.jit.utils import is_arch_support_pdl
|
from sglang.kernels.jit.utils import is_arch_support_pdl
|
||||||
|
from sglang.kernels.ops.attention.flash_attn.cute.batch_invariance import (
|
||||||
|
is_batch_invariant,
|
||||||
|
)
|
||||||
from sglang.kernels.ops.attention.flash_attn.cute.cache_utils import get_jit_cache
|
from sglang.kernels.ops.attention.flash_attn.cute.cache_utils import get_jit_cache
|
||||||
from sglang.kernels.ops.attention.flash_attn.cute.testing import is_fake_mode
|
from sglang.kernels.ops.attention.flash_attn.cute.testing import is_fake_mode
|
||||||
|
|
||||||
@@ -1071,6 +1074,7 @@ def _flash_attn_fwd(
|
|||||||
# Benchmark hook: time just the shear-prep kernels, skip the attention kernel.
|
# Benchmark hook: time just the shear-prep kernels, skip the attention kernel.
|
||||||
return out, lse
|
return out, lse
|
||||||
|
|
||||||
|
batch_invariant = is_batch_invariant()
|
||||||
compile_key = (
|
compile_key = (
|
||||||
dtype,
|
dtype,
|
||||||
head_dim,
|
head_dim,
|
||||||
@@ -1130,6 +1134,7 @@ def _flash_attn_fwd(
|
|||||||
sfq.ndim if sfq is not None else None,
|
sfq.ndim if sfq is not None else None,
|
||||||
sfk.ndim if sfk is not None else None,
|
sfk.ndim if sfk is not None else None,
|
||||||
sfv.ndim if sfv is not None else None,
|
sfv.ndim if sfv is not None else None,
|
||||||
|
batch_invariant,
|
||||||
fa_logging.get_fa_log_level(),
|
fa_logging.get_fa_log_level(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1353,6 +1358,7 @@ def _flash_attn_fwd(
|
|||||||
v_dequant=v_blockscaled,
|
v_dequant=v_blockscaled,
|
||||||
q_sf_interleaved=q_sf_interleaved,
|
q_sf_interleaved=q_sf_interleaved,
|
||||||
kv_sf_interleaved=kv_sf_interleaved,
|
kv_sf_interleaved=kv_sf_interleaved,
|
||||||
|
batch_invariant=batch_invariant,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -268,6 +268,13 @@ class FlashAttentionBackend(AttentionBackend):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._get_scheduler_metadata = None
|
self._get_scheduler_metadata = None
|
||||||
|
if model_runner.server_args.enable_deterministic_inference:
|
||||||
|
# Must precede the first kernel compile.
|
||||||
|
from sglang.kernels.ops.attention.flash_attn.cute.batch_invariance import (
|
||||||
|
set_batch_invariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
set_batch_invariant(True)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Invalid version: {self.fa_impl_ver=}")
|
raise ValueError(f"Invalid version: {self.fa_impl_ver=}")
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ def should_run_flashinfer_autotune(
|
|||||||
return False
|
return False
|
||||||
if mr.server_args.disable_flashinfer_autotune:
|
if mr.server_args.disable_flashinfer_autotune:
|
||||||
return False
|
return False
|
||||||
|
if mr.server_args.enable_deterministic_inference:
|
||||||
|
# Tuned configs are per problem shape, so the reduction order would follow
|
||||||
|
# the batch shape.
|
||||||
|
return False
|
||||||
|
|
||||||
server_args = mr.server_args
|
server_args = mr.server_args
|
||||||
if for_speculative_draft:
|
if for_speculative_draft:
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ def _rel_proj_kernel_eligible(r: torch.Tensor) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
class RelLogitsProj(nn.Module):
|
class RelLogitsProj(nn.Module):
|
||||||
def __init__(self, d_rel: int, rel_extent: int):
|
def __init__(self, d_rel: int, rel_extent: int, *, deterministic: bool = False):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.d_rel = d_rel
|
self.d_rel = d_rel
|
||||||
self.rel_extent = rel_extent
|
self.rel_extent = rel_extent
|
||||||
@@ -134,7 +134,11 @@ class RelLogitsProj(nn.Module):
|
|||||||
# territory. Rounding moves with the fold (r*tau rounds to bf16 before
|
# territory. Rounding moves with the fold (r*tau rounds to bf16 before
|
||||||
# the GEMM instead of after); flag-off keeps the exact legacy post-scale.
|
# the GEMM instead of after); flag-off keeps the exact legacy post-scale.
|
||||||
self._prescale_tau = envs.SGLANG_OPT_USE_INKLING_FUSED_LOG_TAU.get()
|
self._prescale_tau = envs.SGLANG_OPT_USE_INKLING_FUSED_LOG_TAU.get()
|
||||||
self._proj_dispatch = envs.SGLANG_OPT_USE_INKLING_REL_PROJ_DISPATCH.get()
|
# The dispatch keys off the token count, so a row's kernel would follow
|
||||||
|
# the batch composition.
|
||||||
|
self._proj_dispatch = (
|
||||||
|
envs.SGLANG_OPT_USE_INKLING_REL_PROJ_DISPATCH.get() and not deterministic
|
||||||
|
)
|
||||||
|
|
||||||
def _project(self, r: torch.Tensor) -> torch.Tensor:
|
def _project(self, r: torch.Tensor) -> torch.Tensor:
|
||||||
"""``einsum("thd,de->the", r, proj)`` -- but dispatched: in production
|
"""``einsum("thd,de->the", r, proj)`` -- but dispatched: in production
|
||||||
@@ -309,7 +313,11 @@ class InklingAttention(nn.Module):
|
|||||||
self.rel_extent = rel_extent
|
self.rel_extent = rel_extent
|
||||||
self.local_extent = None
|
self.local_extent = None
|
||||||
|
|
||||||
self.rel_logits_proj = RelLogitsProj(self.d_rel, self.rel_extent)
|
self.rel_logits_proj = RelLogitsProj(
|
||||||
|
self.d_rel,
|
||||||
|
self.rel_extent,
|
||||||
|
deterministic=get_exec().deterministic.enable_deterministic_inference,
|
||||||
|
)
|
||||||
# Fold the conditional log-scaling tau into the fused prologue's q
|
# Fold the conditional log-scaling tau into the fused prologue's q
|
||||||
# path (deletes the external scale kernel; bit-exact rounding).
|
# path (deletes the external scale kernel; bit-exact rounding).
|
||||||
self._fused_log_tau = envs.SGLANG_OPT_USE_INKLING_FUSED_LOG_TAU.get()
|
self._fused_log_tau = envs.SGLANG_OPT_USE_INKLING_FUSED_LOG_TAU.get()
|
||||||
|
|||||||
@@ -455,9 +455,12 @@ def symm_mem_all_reduce(
|
|||||||
):
|
):
|
||||||
n = input.numel()
|
n = input.numel()
|
||||||
num_tokens = input.shape[0] if input.dim() >= 2 else n
|
num_tokens = input.shape[0] if input.dim() >= 2 else n
|
||||||
|
# select_ar_config() keys off the token count; plain multimem below
|
||||||
|
# reduces in a shape-independent order.
|
||||||
res = (
|
res = (
|
||||||
_get_inkling_ar_resources(comm)
|
_get_inkling_ar_resources(comm)
|
||||||
if envs.SGLANG_OPT_USE_INKLING_CUSTOM_AR.get()
|
if envs.SGLANG_OPT_USE_INKLING_CUSTOM_AR.get()
|
||||||
|
and not get_exec().deterministic.enable_deterministic_inference
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
# Custom kernels need a 16B-vector-multiple size (validate() enforces it);
|
# Custom kernels need a 16B-vector-multiple size (validate() enforces it);
|
||||||
|
|||||||
@@ -7911,8 +7911,12 @@ class ServerArgs:
|
|||||||
# CUDA: use NCCL tree algorithm
|
# CUDA: use NCCL tree algorithm
|
||||||
os.environ["NCCL_ALGO"] = "allreduce:tree"
|
os.environ["NCCL_ALGO"] = "allreduce:tree"
|
||||||
self.disable_custom_all_reduce = True
|
self.disable_custom_all_reduce = True
|
||||||
|
# should_torch_symm_mem_allreduce() takes the
|
||||||
|
# symmetric-memory path only below a byte threshold, so
|
||||||
|
# which reduce runs would follow the token count.
|
||||||
|
self.enable_torch_symm_mem = False
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"NCCL_ALGO is set to 'allreduce:tree' and custom all reduce is disabled for deterministic inference when TP size > 1."
|
"NCCL_ALGO is set to 'allreduce:tree', and custom and symmetric-memory all reduce are disabled for deterministic inference when TP size > 1."
|
||||||
)
|
)
|
||||||
|
|
||||||
def _handle_unified_memory_pool(self):
|
def _handle_unified_memory_pool(self):
|
||||||
|
|||||||
@@ -0,0 +1,490 @@
|
|||||||
|
"""Cache/scheduling consistency test: score the same token contexts repeatedly
|
||||||
|
and require bitwise-identical greedy output tokens and top-k logprobs.
|
||||||
|
|
||||||
|
The workload generates a continuation for each independently sampled prefix,
|
||||||
|
then submits shuffled requests starting from different cuts of that
|
||||||
|
continuation. This scores each shared context multiple times after different
|
||||||
|
batching and cache histories and compares the resulting tokens and logprobs, so
|
||||||
|
a hybrid-cache (SWA + Mamba/sconv) or overlap-scheduler bug that corrupts a
|
||||||
|
reused context shows up as a diverging token or logprob rather than as a small
|
||||||
|
accuracy drop no eval would catch.
|
||||||
|
|
||||||
|
The engine is put under additional pressure in three ways: probabilistic timing
|
||||||
|
jitter is enqueued after CUDA event record and wait operations to amplify stream
|
||||||
|
races, ``max_total_tokens`` constrains the KV cache, and periodic request
|
||||||
|
retractions introduce further scheduling and cache disruption.
|
||||||
|
|
||||||
|
This assumes a batch-invariant serving path: reducing the same context under a
|
||||||
|
different batch shape must produce the same bits, or the differences reported are
|
||||||
|
in reduction order rather than in cache and scheduling behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gc
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from functools import partial
|
||||||
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import triton
|
||||||
|
import triton.language as tl
|
||||||
|
from triton.language.extra.cuda import globaltimer, smid
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sglang.srt.entrypoints.engine import Engine
|
||||||
|
|
||||||
|
|
||||||
|
@triton.jit
|
||||||
|
def _random_jitter_kernel(
|
||||||
|
enabled: tl.tensor,
|
||||||
|
PROBABILITY: tl.constexpr,
|
||||||
|
MAX_TIME_US: tl.constexpr,
|
||||||
|
) -> None:
|
||||||
|
if tl.load(enabled) != 0:
|
||||||
|
start = globaltimer().to(tl.uint64)
|
||||||
|
sample = start ^ (smid().to(tl.uint64) << 32)
|
||||||
|
sample ^= sample << 13
|
||||||
|
sample ^= sample >> 7
|
||||||
|
sample ^= sample << 17
|
||||||
|
if sample.to(tl.uint32) < int(PROBABILITY * 2**32):
|
||||||
|
sample ^= sample << 13
|
||||||
|
sample ^= sample >> 7
|
||||||
|
sample ^= sample << 17
|
||||||
|
duration_ns = (
|
||||||
|
sample.to(tl.uint32).to(tl.uint64) * (MAX_TIME_US * 1_000 + 1)
|
||||||
|
) >> 32
|
||||||
|
now = globaltimer().to(tl.uint64)
|
||||||
|
while now - start < duration_ns:
|
||||||
|
now = globaltimer().to(tl.uint64)
|
||||||
|
|
||||||
|
|
||||||
|
def _random_jitter(enabled: torch.Tensor, probability: float, max_time_us: int) -> None:
|
||||||
|
kernel: Any = _random_jitter_kernel
|
||||||
|
kernel[(1,)](
|
||||||
|
enabled,
|
||||||
|
PROBABILITY=probability,
|
||||||
|
MAX_TIME_US=max_time_us,
|
||||||
|
num_warps=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scheduler_process_with_jitter(
|
||||||
|
server_args: Any,
|
||||||
|
port_args: Any,
|
||||||
|
gpu_id: int,
|
||||||
|
*scheduler_args: Any,
|
||||||
|
jitter_probability: float,
|
||||||
|
jitter_max_time_us: int,
|
||||||
|
**scheduler_kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
from sglang.srt.managers.scheduler import Scheduler, run_scheduler_process
|
||||||
|
|
||||||
|
original_record = torch.cuda.Event.record
|
||||||
|
original_wait = torch.cuda.Event.wait
|
||||||
|
original_capture_end = torch.cuda.CUDAGraph.capture_end
|
||||||
|
original_flush_cache = Scheduler.flush_cache
|
||||||
|
capture_streams: dict[tuple[torch.device, int], torch.cuda.Stream] = {}
|
||||||
|
probability = jitter_probability / server_args.tp_size
|
||||||
|
jitter_active = False
|
||||||
|
|
||||||
|
# Compile and load the kernel before an event hook can run during CUDA
|
||||||
|
# graph capture, where Triton compilation is not allowed. Captured kernels
|
||||||
|
# retain this device address and read its current value at every replay.
|
||||||
|
with torch.cuda.device(gpu_id):
|
||||||
|
enabled = torch.zeros(1, dtype=torch.uint8, device="cuda")
|
||||||
|
_random_jitter(enabled, probability, jitter_max_time_us)
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
|
||||||
|
def wrap_event(original: Any) -> Any:
|
||||||
|
def with_jitter(
|
||||||
|
event: torch.cuda.Event,
|
||||||
|
stream: torch.cuda.Stream | None = None,
|
||||||
|
) -> None:
|
||||||
|
original(event, stream)
|
||||||
|
target_stream = torch.cuda.current_stream() if stream is None else stream
|
||||||
|
with torch.cuda.stream(target_stream):
|
||||||
|
capturing = torch.cuda.is_current_stream_capturing()
|
||||||
|
if capturing or jitter_active:
|
||||||
|
_random_jitter(enabled, probability, jitter_max_time_us)
|
||||||
|
if capturing:
|
||||||
|
capture_streams[(target_stream.device, target_stream.cuda_stream)] = (
|
||||||
|
target_stream
|
||||||
|
)
|
||||||
|
|
||||||
|
return with_jitter
|
||||||
|
|
||||||
|
def flush_cache(self: Any, empty_cache: bool = True) -> bool:
|
||||||
|
nonlocal jitter_active
|
||||||
|
success = original_flush_cache(self, empty_cache)
|
||||||
|
if success and not jitter_active:
|
||||||
|
enabled.fill_(1)
|
||||||
|
torch.cuda.synchronize()
|
||||||
|
jitter_active = True
|
||||||
|
return success
|
||||||
|
|
||||||
|
torch.cuda.Event.record = wrap_event(original_record)
|
||||||
|
torch.cuda.Event.wait = wrap_event(original_wait)
|
||||||
|
Scheduler.flush_cache = flush_cache
|
||||||
|
|
||||||
|
# A post-record delay can extend a captured side stream past the
|
||||||
|
# event that originally joined it. Join those streams before ending
|
||||||
|
# capture; eager execution needs no corresponding special case.
|
||||||
|
def capture_end(self: torch.cuda.CUDAGraph) -> None:
|
||||||
|
origin_stream = torch.cuda.current_stream()
|
||||||
|
origin_key = (origin_stream.device, origin_stream.cuda_stream)
|
||||||
|
try:
|
||||||
|
for key, source_stream in capture_streams.items():
|
||||||
|
if key == origin_key:
|
||||||
|
continue
|
||||||
|
done = torch.cuda.Event()
|
||||||
|
original_record(done, source_stream)
|
||||||
|
original_wait(done, origin_stream)
|
||||||
|
original_capture_end(self)
|
||||||
|
finally:
|
||||||
|
capture_streams.clear()
|
||||||
|
|
||||||
|
torch.cuda.CUDAGraph.capture_end = capture_end
|
||||||
|
print(
|
||||||
|
"Installed stream-sync jitter "
|
||||||
|
f"(probability={jitter_probability}, world_size={server_args.tp_size}, "
|
||||||
|
f"per_rank_probability={probability}, "
|
||||||
|
f"time_us=uniform[0,{jitter_max_time_us}], starts_after_cache_flush=True)",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
run_scheduler_process(
|
||||||
|
server_args,
|
||||||
|
port_args,
|
||||||
|
gpu_id,
|
||||||
|
*scheduler_args,
|
||||||
|
**scheduler_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_jitter_engine(
|
||||||
|
*,
|
||||||
|
jitter_probability: float = 0.1,
|
||||||
|
jitter_max_time_us: int = 10_000,
|
||||||
|
retract_interval: int = 500,
|
||||||
|
**engine_kwargs: Any,
|
||||||
|
) -> Iterator[Engine]:
|
||||||
|
from sglang.srt.entrypoints.engine import Engine
|
||||||
|
|
||||||
|
assert 0.0 <= jitter_probability <= 1.0, f"{jitter_probability=}"
|
||||||
|
assert jitter_max_time_us > 0, f"{jitter_max_time_us=}"
|
||||||
|
assert retract_interval > 0, f"{retract_interval=}"
|
||||||
|
assert "model_path" in engine_kwargs, "model_path is required"
|
||||||
|
|
||||||
|
# Force chunked prefill and optimistic admission into the deliberately
|
||||||
|
# undersized pool; metrics let the workload verify that retraction occurred.
|
||||||
|
engine_kwargs.setdefault("chunked_prefill_size", 512)
|
||||||
|
engine_kwargs.setdefault("enable_metrics", True)
|
||||||
|
engine_kwargs.setdefault("max_total_tokens", 20_480)
|
||||||
|
engine_kwargs.setdefault("schedule_conservativeness", 0.05)
|
||||||
|
|
||||||
|
if (
|
||||||
|
engine_kwargs.get("enable_dp_attention")
|
||||||
|
and "dist_init_addr" not in engine_kwargs
|
||||||
|
):
|
||||||
|
import portpicker
|
||||||
|
|
||||||
|
from sglang.srt.server_args import DP_ATTENTION_HANDSHAKE_PORT_DELTA
|
||||||
|
|
||||||
|
port = portpicker.pick_unused_port_range( # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
DP_ATTENTION_HANDSHAKE_PORT_DELTA + 1
|
||||||
|
)[
|
||||||
|
0
|
||||||
|
]
|
||||||
|
engine_kwargs["dist_init_addr"] = f"127.0.0.1:{port}"
|
||||||
|
|
||||||
|
scheduler_process = (
|
||||||
|
partial(
|
||||||
|
_run_scheduler_process_with_jitter,
|
||||||
|
jitter_probability=jitter_probability,
|
||||||
|
jitter_max_time_us=jitter_max_time_us,
|
||||||
|
)
|
||||||
|
if jitter_probability > 0
|
||||||
|
else Engine.run_scheduler_process_func
|
||||||
|
)
|
||||||
|
|
||||||
|
class JitterEngine(Engine):
|
||||||
|
run_scheduler_process_func = staticmethod(scheduler_process)
|
||||||
|
|
||||||
|
engine = None
|
||||||
|
test_env = {
|
||||||
|
"SGLANG_OPT_UNIFIED_CACHE_FREE_OUT_OF_WINDOW_SLOTS": "True",
|
||||||
|
"SGLANG_TEST_RETRACT": "True",
|
||||||
|
"SGLANG_TEST_RETRACT_INTERVAL": str(retract_interval),
|
||||||
|
}
|
||||||
|
original_test_env = {name: os.environ.get(name) for name in test_env}
|
||||||
|
os.environ.update(test_env)
|
||||||
|
# enable_metrics registers collectors on the process-global prometheus
|
||||||
|
# registry, so a second engine in the same process (a CustomTestCase retry)
|
||||||
|
# would die on re-registration and hide the original failure.
|
||||||
|
from prometheus_client import REGISTRY as _prom_registry
|
||||||
|
|
||||||
|
collectors_before = set(_prom_registry._collector_to_names)
|
||||||
|
try:
|
||||||
|
engine = JitterEngine(**engine_kwargs)
|
||||||
|
yield engine
|
||||||
|
finally:
|
||||||
|
if engine is not None:
|
||||||
|
engine.shutdown()
|
||||||
|
del engine
|
||||||
|
gc.collect()
|
||||||
|
torch.cuda.empty_cache()
|
||||||
|
for collector in set(_prom_registry._collector_to_names) - collectors_before:
|
||||||
|
_prom_registry.unregister(collector)
|
||||||
|
for name, value in original_test_env.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(name)
|
||||||
|
else:
|
||||||
|
os.environ[name] = value
|
||||||
|
|
||||||
|
|
||||||
|
# --- internals -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _Request(NamedTuple):
|
||||||
|
prefix: int
|
||||||
|
cut: int
|
||||||
|
input_ids: list[int]
|
||||||
|
max_new_tokens: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"prefix{self.prefix}/cut{self.cut}"
|
||||||
|
|
||||||
|
|
||||||
|
# An observation key identifies a predicted continuation position within one
|
||||||
|
# independently sampled prefix and its baseline continuation.
|
||||||
|
_Key = tuple[int, int]
|
||||||
|
# (token id at position, top-k token->logprob)
|
||||||
|
_Obs = tuple[int, dict[int, float]]
|
||||||
|
|
||||||
|
|
||||||
|
def _topk_equal(a: dict[int, float], b: dict[int, float]) -> bool:
|
||||||
|
"""Compare top-k maps bitwise, tolerating rank-k boundary ties."""
|
||||||
|
if len(a) != len(b):
|
||||||
|
return False
|
||||||
|
if any(a[token] != b[token] for token in a.keys() & b.keys()):
|
||||||
|
return False
|
||||||
|
return all(
|
||||||
|
source[token] == min(other.values())
|
||||||
|
for only, source, other in (
|
||||||
|
(a.keys() - b.keys(), a, b),
|
||||||
|
(b.keys() - a.keys(), b, a),
|
||||||
|
)
|
||||||
|
for token in only
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_response(
|
||||||
|
observations: dict[_Key, list[_Obs]],
|
||||||
|
req: _Request,
|
||||||
|
out: dict[str, Any],
|
||||||
|
baseline: list[int] | None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""Record every scored position of one response; returns its output ids.
|
||||||
|
|
||||||
|
`baseline` is the prefix's expected greedy continuation (None while
|
||||||
|
recording the baseline itself). Positions past the first divergence from
|
||||||
|
the baseline are dropped: their context differs from every other request
|
||||||
|
at the same nominal position, so comparisons there are meaningless. The
|
||||||
|
divergence position itself still has identical context and is recorded —
|
||||||
|
its top-k diff is exactly the interesting signal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
meta = out["meta_info"]
|
||||||
|
|
||||||
|
out_ids = [int(t) for t in out["output_ids"]]
|
||||||
|
out_lps = meta["output_token_logprobs"]
|
||||||
|
out_top = meta["output_top_logprobs"]
|
||||||
|
assert (
|
||||||
|
len(out_ids) == req.max_new_tokens
|
||||||
|
), f"{req.label}: got {len(out_ids)} output tokens, expected {req.max_new_tokens}"
|
||||||
|
expected = out_ids if baseline is None else baseline[req.cut :]
|
||||||
|
for m, (tid, lp_entry, top) in enumerate(
|
||||||
|
zip(out_ids, out_lps, out_top, strict=True)
|
||||||
|
):
|
||||||
|
assert int(lp_entry[1]) == tid, f"{req.label}: output logprob misaligned at {m}"
|
||||||
|
pos = len(req.input_ids) + m
|
||||||
|
topk_map = {int(entry[1]): float(entry[0]) for entry in top}
|
||||||
|
assert len(topk_map) == len(top), f"duplicate token ids in top-k: {top}"
|
||||||
|
observations[(req.prefix, pos)].append((tid, topk_map))
|
||||||
|
if tid != expected[m]:
|
||||||
|
break
|
||||||
|
return out_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_batch(
|
||||||
|
engine: Engine, requests: list[_Request], topk: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""One batched generate call; per-request fields go through the batch
|
||||||
|
API's list parameters, results come back in submission order."""
|
||||||
|
outs = engine.generate(
|
||||||
|
input_ids=[list(r.input_ids) for r in requests],
|
||||||
|
sampling_params=[
|
||||||
|
{"temperature": 0.0, "max_new_tokens": r.max_new_tokens, "ignore_eos": True}
|
||||||
|
for r in requests
|
||||||
|
],
|
||||||
|
return_logprob=[True] * len(requests),
|
||||||
|
# -1 requests output logprobs only and permits full radix-cache reuse.
|
||||||
|
logprob_start_len=[-1] * len(requests),
|
||||||
|
top_logprobs_num=[topk] * len(requests),
|
||||||
|
)
|
||||||
|
results = list(outs)
|
||||||
|
assert all(isinstance(r, dict) for r in results)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _total_retracted_requests() -> float:
|
||||||
|
from prometheus_client import CollectorRegistry
|
||||||
|
from prometheus_client import multiprocess as prom_multiprocess
|
||||||
|
|
||||||
|
assert "PROMETHEUS_MULTIPROC_DIR" in os.environ
|
||||||
|
registry = CollectorRegistry()
|
||||||
|
prom_multiprocess.MultiProcessCollector(registry)
|
||||||
|
return sum(
|
||||||
|
sample.value
|
||||||
|
for metric in registry.collect()
|
||||||
|
if "retracted_req" in metric.name
|
||||||
|
for sample in metric.samples
|
||||||
|
if sample.name.endswith("_total")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_jitter_test(
|
||||||
|
engine: Engine,
|
||||||
|
*,
|
||||||
|
num_unique_prefixes: int = 10, # independently sampled random-token prefixes
|
||||||
|
requests_per_prefix: int = 10, # output-prefix cut requests per prefix
|
||||||
|
prefix_len_min: int = 2048, # prefix length range, inclusive
|
||||||
|
prefix_len_max: int = 4096,
|
||||||
|
new_tokens: int = 2048, # baseline generation length
|
||||||
|
topk: int = 8, # how many logprob entries per position are compared
|
||||||
|
seed: int = 0, # seeds every draw: token sequence, lengths, cuts, shuffle
|
||||||
|
min_retracted_requests: int = 10,
|
||||||
|
vocab_size: int = 199_998, # excludes special tokens / padded embedding tail
|
||||||
|
) -> None:
|
||||||
|
"""Run the workload on a caller-constructed engine and assert bitwise
|
||||||
|
consistency across all overlapping observations."""
|
||||||
|
assert min_retracted_requests >= 0
|
||||||
|
prefix_rng = random.Random(seed + 1)
|
||||||
|
prefix_lens = sorted(
|
||||||
|
prefix_rng.randint(prefix_len_min, prefix_len_max)
|
||||||
|
for _ in range(num_unique_prefixes)
|
||||||
|
)
|
||||||
|
# Random output-cut lengths per (prefix, cut) pair, capped so every
|
||||||
|
# request still decodes at least 64 tokens; 0 stays in range (an exact
|
||||||
|
# duplicate of the baseline scheduled in a different batch mix =
|
||||||
|
# run-to-run check).
|
||||||
|
cut_rng = random.Random(seed + 2)
|
||||||
|
cut_lens = [
|
||||||
|
[cut_rng.randint(0, new_tokens - 64) for _ in range(requests_per_prefix)]
|
||||||
|
for _ in range(num_unique_prefixes)
|
||||||
|
]
|
||||||
|
token_rng = random.Random(seed)
|
||||||
|
prefix_tokens = [
|
||||||
|
[token_rng.randrange(vocab_size) for _ in range(prefix_len)]
|
||||||
|
for prefix_len in prefix_lens
|
||||||
|
]
|
||||||
|
|
||||||
|
observations: dict[_Key, list[_Obs]] = defaultdict(list)
|
||||||
|
|
||||||
|
# --- Phase 1: greedy baselines, one request per prefix; outputs define
|
||||||
|
# the prefixes' canonical continuations ---
|
||||||
|
t1 = time.monotonic()
|
||||||
|
baseline_reqs = [
|
||||||
|
_Request(prefix, 0, tokens, new_tokens)
|
||||||
|
for prefix, tokens in enumerate(prefix_tokens)
|
||||||
|
]
|
||||||
|
outs = _generate_batch(engine, baseline_reqs, topk)
|
||||||
|
print(f"[timing] phase 1 (baselines): {time.monotonic() - t1:.1f}s")
|
||||||
|
baselines = {}
|
||||||
|
for req, out in zip(baseline_reqs, outs, strict=True):
|
||||||
|
baselines[req.prefix] = _record_response(observations, req, out, baseline=None)
|
||||||
|
|
||||||
|
# The scheduler only flushes when fully idle; the batch API can return
|
||||||
|
# before the last request has fully drained, so retry briefly.
|
||||||
|
for attempt in range(10):
|
||||||
|
flush_result = engine.flush_cache()
|
||||||
|
if getattr(flush_result, "success", True):
|
||||||
|
break
|
||||||
|
time.sleep(1.0)
|
||||||
|
else:
|
||||||
|
raise AssertionError(f"cache flush failed after retries: {flush_result}")
|
||||||
|
retractions_before_jitter = (
|
||||||
|
_total_retracted_requests() if min_retracted_requests else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Phase 2: shuffled jitter batch — per prefix, requests from the
|
||||||
|
# prefix plus a random-length cut of the baseline output, decoding out to
|
||||||
|
# the baseline's total length ---
|
||||||
|
jitter_reqs = [
|
||||||
|
_Request(
|
||||||
|
prefix,
|
||||||
|
cut,
|
||||||
|
tokens + baselines[prefix][:cut],
|
||||||
|
new_tokens - cut,
|
||||||
|
)
|
||||||
|
for prefix, tokens in enumerate(prefix_tokens)
|
||||||
|
for cut in cut_lens[prefix]
|
||||||
|
]
|
||||||
|
random.Random(seed).shuffle(jitter_reqs)
|
||||||
|
t2 = time.monotonic()
|
||||||
|
jitter_outs = _generate_batch(engine, jitter_reqs, topk)
|
||||||
|
print(f"[timing] phase 2 (jitter batch): {time.monotonic() - t2:.1f}s")
|
||||||
|
for req, out in zip(jitter_reqs, jitter_outs, strict=True):
|
||||||
|
_record_response(observations, req, out, baseline=baselines[req.prefix])
|
||||||
|
|
||||||
|
# --- Cross-check every observation sharing a key ---
|
||||||
|
num_cross_checked_positions = 0
|
||||||
|
num_mismatches = 0
|
||||||
|
mismatch_details: list[str] = []
|
||||||
|
for key in sorted(observations):
|
||||||
|
group = observations[key]
|
||||||
|
if len(group) >= 2:
|
||||||
|
num_cross_checked_positions += 1
|
||||||
|
ref_tid, ref_topk = group[0]
|
||||||
|
for tid, top in group[1:]:
|
||||||
|
if tid == ref_tid and _topk_equal(ref_topk, top):
|
||||||
|
continue
|
||||||
|
num_mismatches += 1
|
||||||
|
if len(mismatch_details) < 5:
|
||||||
|
shared = ref_topk.keys() & top.keys()
|
||||||
|
mismatch_details.append(
|
||||||
|
f"prefix={key[0]} pos={key[1]} token {ref_tid} vs {tid} "
|
||||||
|
f"max|dlogprob|="
|
||||||
|
f"{max((abs(ref_topk[t] - top[t]) for t in shared), default=0.0):.3e}"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"checked {len(observations)} positions, "
|
||||||
|
f"{num_cross_checked_positions} observed by >=2 requests, "
|
||||||
|
f"{sum(len(g) for g in observations.values())} observations total, "
|
||||||
|
f"{num_mismatches} mismatches"
|
||||||
|
)
|
||||||
|
# Guard against the test silently becoming vacuous: most continuation
|
||||||
|
# positions must be observed by several requests.
|
||||||
|
assert num_cross_checked_positions >= len(prefix_lens) * new_tokens // 2, (
|
||||||
|
f"only {num_cross_checked_positions} positions were cross-checked; "
|
||||||
|
"the request construction no longer produces overlapping observations"
|
||||||
|
)
|
||||||
|
assert num_mismatches == 0, "\n".join(
|
||||||
|
[f"{num_mismatches} mismatching observations"] + mismatch_details
|
||||||
|
)
|
||||||
|
if min_retracted_requests:
|
||||||
|
retractions_during_jitter = (
|
||||||
|
_total_retracted_requests() - retractions_before_jitter
|
||||||
|
)
|
||||||
|
assert retractions_during_jitter >= min_retracted_requests, (
|
||||||
|
f"expected at least {min_retracted_requests} retractions during the jitter "
|
||||||
|
f"batch, got {retractions_during_jitter}"
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ import unittest
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from sglang.srt.utils import kill_process_tree
|
from sglang.srt.utils import kill_process_tree
|
||||||
|
from sglang.test.cache_consistency_jitter import get_jitter_engine, run_jitter_test
|
||||||
from sglang.test.ci.ci_register import register_cuda_ci
|
from sglang.test.ci.ci_register import register_cuda_ci
|
||||||
|
|
||||||
# Aliased so pytest does not collect the imported `test_`-prefixed helper as a test.
|
# Aliased so pytest does not collect the imported `test_`-prefixed helper as a test.
|
||||||
@@ -31,7 +32,7 @@ from sglang.test.test_utils import (
|
|||||||
popen_launch_server,
|
popen_launch_server,
|
||||||
)
|
)
|
||||||
|
|
||||||
register_cuda_ci(est_time=250, stage="base-b", runner_config="1-gpu-large")
|
register_cuda_ci(est_time=450, stage="base-b", runner_config="1-gpu-large")
|
||||||
|
|
||||||
# Defaults to the HF `test` revision; override MODEL/REVISION to point at a
|
# Defaults to the HF `test` revision; override MODEL/REVISION to point at a
|
||||||
# local checkpoint. Empty REVISION drops the flag (for local paths).
|
# local checkpoint. Empty REVISION drops the flag (for local paths).
|
||||||
@@ -187,5 +188,42 @@ class TestInklingServer(CustomTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInklingCacheConsistency(CustomTestCase):
|
||||||
|
"""Bitwise version of the KL check above: the same context is scored under
|
||||||
|
different batch shapes, cache histories and retraction timing, and every
|
||||||
|
overlapping observation must agree. Boots its own in-process engine because
|
||||||
|
the harness patches the scheduler process to inject stream-sync jitter."""
|
||||||
|
|
||||||
|
def test_scored_contexts_are_bitwise_identical(self):
|
||||||
|
engine_kwargs = {
|
||||||
|
"model_path": _MODEL_PATH,
|
||||||
|
"trust_remote_code": True,
|
||||||
|
"attention_backend": "fa4",
|
||||||
|
"page_size": 128,
|
||||||
|
"mamba_radix_cache_strategy": "extra_buffer",
|
||||||
|
"swa_full_tokens_ratio": 0.1,
|
||||||
|
"mamba_full_memory_ratio": 0.1,
|
||||||
|
"mem_fraction_static": 0.5,
|
||||||
|
"enable_deterministic_inference": True,
|
||||||
|
}
|
||||||
|
if _MODEL_REVISION:
|
||||||
|
engine_kwargs["revision"] = _MODEL_REVISION
|
||||||
|
|
||||||
|
with get_jitter_engine(**engine_kwargs) as engine:
|
||||||
|
# Sized to the 20480-token pool the harness pins: large enough to
|
||||||
|
# force retraction, small enough that the batch still admits.
|
||||||
|
run_jitter_test(
|
||||||
|
engine,
|
||||||
|
num_unique_prefixes=4,
|
||||||
|
requests_per_prefix=5,
|
||||||
|
prefix_len_min=384,
|
||||||
|
prefix_len_max=512,
|
||||||
|
new_tokens=256,
|
||||||
|
# How many requests get retracted tracks pool size, hence GPU
|
||||||
|
# memory, so only assert the path ran at all.
|
||||||
|
min_retracted_requests=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user