[Feature] Add leveled invariant-check primitive for nan/inf/oob validity checks (#32308)

This commit is contained in:
Liangsheng Yin
2026-07-24 04:27:20 -07:00
committed by GitHub
parent de816e1eb5
commit a31542ebd9
7 changed files with 477 additions and 23 deletions
+22
View File
@@ -210,6 +210,22 @@ class ToolStrictLevel(IntEnum):
PARAMETER = 2
class InvariantCheckLevel(IntEnum):
"""Signal level for value/index validity checks (see invariants.py).
OFF: data layer only (sanitize/containment); no detection, no signal.
WARN: detect + throttled log/count; degrade, never crash (prod on-demand).
STRICT: detect + crash on GUARD/FATAL violations (CI default).
The data layer is unconditional and independent of this level; only the
detection + signal layer is gated here.
"""
OFF = 0
WARN = 1
STRICT = 2
class Envs:
# Raise on bare server_args field assignments after resolution; mutation
@@ -786,6 +802,12 @@ class Envs:
# page alignment). Off in prod; tests turn it on to fail-fast on
# numerical / index violations instead of getting silent NaN cascades.
SGLANG_ENABLE_ASYNC_ASSERT = EnvBool(False)
# Signal level for value/index validity checks (nan/inf/oob/...); see
# invariants.py. OFF (prod default) runs only the free data layer, WARN
# adds throttled logging, STRICT (CI default) crashes on violations.
# Supersedes SGLANG_ENABLE_ASYNC_ASSERT, which is bridged as STRICT until
# every callsite migrates.
SGLANG_INVARIANT_CHECK = EnvInt(InvariantCheckLevel.OFF)
# Sanitize NaN logits before sampling kernels and log a throttled warning
# (see sanitize_nan_logits).
SGLANG_SANITIZE_NAN_LOGITS = EnvBool(False)
@@ -49,12 +49,17 @@ from sglang.srt.speculative.ragged_verify import (
read_ragged_verify_mode,
)
from sglang.srt.utils import add_prefix, is_blackwell_supported
from sglang.srt.utils.async_probe import maybe_detect_in_closed_range
from sglang.srt.utils.invariants import Bucket, InClosedRange, Invariant, expect
logger = logging.getLogger(__name__)
_PAD_NUM_HEADS = 64
# DSpark confidence is a per-token score that must stay in [0, 1].
_CONFIDENCE = Invariant(
"dspark.model.confidence", Bucket.GUARD, InClosedRange(0.0, 1.0)
)
def apply_rotary_emb(
x: torch.Tensor, freqs_cis: torch.Tensor, inverse: bool = False
@@ -749,9 +754,7 @@ class DeepseekV4ForCausalLMDSpark(nn.Module):
markov_embed_stack = None
confidence_raw = confidence_head(x_post_hc, markov_embed_stack)
confidence = confidence_head.apply_sts(confidence_raw)
maybe_detect_in_closed_range(
confidence, 0.0, 1.0, "DSpark confidence must lie in [0, 1]."
)
expect(_CONFIDENCE, confidence)
return confidence
def load_weights(self, weights: Iterable[Tuple[str, torch.Tensor]]) -> None:
@@ -26,11 +26,28 @@ from sglang.srt.speculative.spec_info import (
spec_scale_global_num_tokens,
)
from sglang.srt.speculative.spec_utils import draft_tp_context
from sglang.srt.utils.async_probe import maybe_detect_nan
from sglang.srt.utils.invariants import Bucket, Invariant, NotNaN, expect
logger = logging.getLogger(__name__)
def _one_hot_token0(probs: torch.Tensor) -> torch.Tensor:
degenerate = torch.isnan(probs[:, :1])
one_hot = torch.zeros_like(probs)
one_hot[:, 0] = 1.0
return torch.where(degenerate, one_hot, probs)
# Draft step logits: NaN is a bug, but -inf is legitimate (masking). The data
# layer lives downstream (probs one-hot below, or the fast kernel's clamp).
_DRAFT_STEP_LOGITS = Invariant("dspark.draft.step_logits", Bucket.GUARD, NotNaN())
# Draft sampling probs: SOFTEN (tolerate + count), matching the original
# unconditional clamp; an all-NaN row would otherwise make multinomial raise.
_DRAFT_PROBS = Invariant(
"dspark.draft.probs", Bucket.SOFTEN, NotNaN(), recover=_one_hot_token0
)
class DraftBlockResult(msgspec.Struct, frozen=True):
draft_tokens: torch.Tensor
corrected_logits: Optional[torch.Tensor]
@@ -181,13 +198,13 @@ def sample_draft_block(
if not any_sampling:
def sampler(step_logits: torch.Tensor, step_idx: int) -> torch.Tensor:
maybe_detect_nan(step_logits, f"dspark draft step {step_idx}")
expect(_DRAFT_STEP_LOGITS, step_logits, msg=f"step {step_idx}")
return torch.argmax(step_logits, dim=-1)
else:
def sampler(step_logits: torch.Tensor, step_idx: int) -> torch.Tensor:
maybe_detect_nan(step_logits, f"dspark draft step {step_idx}")
expect(_DRAFT_STEP_LOGITS, step_logits, msg=f"step {step_idx}")
if fast_sampling:
exp_noise = torch.empty(
step_logits.shape, dtype=torch.float32, device=step_logits.device
@@ -202,11 +219,7 @@ def sample_draft_block(
probs = torch.softmax(
step_logits.float() / temperatures[:, None], dim=-1
)
# All-NaN rows make multinomial raise; clamp to one-hot token 0.
degenerate_rows = torch.isnan(probs[:, :1])
one_hot_token0 = torch.zeros_like(probs)
one_hot_token0[:, 0] = 1.0
probs = torch.where(degenerate_rows, one_hot_token0, probs)
probs = expect(_DRAFT_PROBS, probs)
argmax_tokens = torch.argmax(step_logits, dim=-1)
sampled_tokens = torch.multinomial(probs, num_samples=1).squeeze(-1)
return torch.where(greedy_mask, argmax_tokens, sampled_tokens)
@@ -12,7 +12,7 @@ from sglang.kernels.ops.speculative.dspark.dspark_schedule import (
compute_sort_survival,
)
from sglang.srt.distributed import get_tp_group
from sglang.srt.environ import envs
from sglang.srt.environ import InvariantCheckLevel, envs
from sglang.srt.layers.dp_attention import is_dp_attention_enabled
from sglang.srt.managers.overlap_utils import (
CONFIDENCE_RELAY_RING_LAG,
@@ -41,14 +41,25 @@ from sglang.srt.speculative.ragged_verify import (
read_ragged_verify_mode,
round_up_grid,
)
from sglang.srt.utils.async_probe import (
maybe_assert_async,
maybe_detect_in_closed_range,
)
from sglang.srt.utils.common import require_mlp_tp_gather
from sglang.srt.utils.invariants import (
Bucket,
InClosedRange,
Invariant,
IsTrue,
expect,
resolve_level,
)
logger = logging.getLogger(__name__)
# DSpark confidence is a per-token score that must stay in [0, 1].
_CONFIDENCE = Invariant(
"dspark.planner.confidence", Bucket.GUARD, InClosedRange(0.0, 1.0)
)
# Scheduled verify lengths must not exceed the per-step token budget.
_VERIFY_LEN_BUDGET = Invariant("dspark.verify_len_budget", Bucket.GUARD, IsTrue())
class VerifyWindow(msgspec.Struct, frozen=True):
positions_2d: torch.Tensor
@@ -580,12 +591,13 @@ class DSparkVerifyPlanner:
cfg=self._schedule_cfg,
).to(device=device, dtype=torch.int32)
if envs.SGLANG_ENABLE_ASYNC_ASSERT.get():
if resolve_level() >= InvariantCheckLevel.WARN:
verify_lens_64 = verify_lens.to(torch.int64)
effective_floor = max(self._schedule_cfg.min_verify_len, 1)
maybe_assert_async(
expect(
_VERIFY_LEN_BUDGET,
(verify_lens_64 - effective_floor).sum() <= budget,
f"DSpark verify-len budget violated (budget={budget})",
msg=f"budget={budget}",
)
if envs.SGLANG_DSPARK_DEBUG_CONFIDENCE_PREFIX_SCHEDULER.get():
@@ -907,7 +919,7 @@ def compute_confidence(
markov_embed_stack = None
confidence_raw = confidence_head(draft_hidden, markov_embed_stack)
confidence = confidence_head.apply_sts(confidence_raw)
maybe_detect_in_closed_range(confidence, 0.0, 1.0, "DSpark confidence")
expect(_CONFIDENCE, confidence)
return confidence
@@ -37,7 +37,11 @@ from sglang.srt.speculative.dspark_components.dspark_planner import (
apply_logits_adjustments_strided,
)
from sglang.srt.speculative.ragged_verify import RaggedVerifyLayout
from sglang.srt.utils.async_probe import maybe_detect_nan
from sglang.srt.utils.invariants import Bucket, Invariant, NotNaN, expect
# Draft proposal probs feeding rejection sampling; the data layer is the
# in-kernel NaN-q guard in reject_sampling.py, so this is signal-only.
_VERIFY_DRAFT_PROBS = Invariant("dspark.verify.draft_probs", Bucket.GUARD, NotNaN())
def verify_logits_adjustments_are_noop(sampling_info) -> bool:
@@ -678,7 +682,7 @@ def accept_draft_tokens(
temperatures=draft_block.temperatures,
rows_per_request=gamma_rows,
).view(bs, gamma_rows, vocab)
maybe_detect_nan(draft_probs, "dspark verify: draft_probs")
expect(_VERIFY_DRAFT_PROBS, draft_probs)
if not sampling_info.is_any_greedy:
return AcceptSampling.execute(
candidates=candidates,
+245
View File
@@ -0,0 +1,245 @@
"""Value/index validity checks -- the invariant-check model.
Two layers per check:
* data layer (sanitize / containment): unconditional, branchless, ~free.
May live in `recover` here or inside the kernel (then `recover=None`).
* signal layer (detect + log/crash): gated by SGLANG_INVARIANT_CHECK
(off / warn / strict).
A `Bucket` (blast radius x recoverability) and the level decide whether a hit
crashes, logs, or is silent. Detection is async (no GPU-CPU sync): crashes via
torch's async assert, counts via a pinned-memory readback.
"""
from __future__ import annotations
import enum
import logging
from typing import Callable, Optional
import torch
from sglang.srt.environ import InvariantCheckLevel, envs
logger = logging.getLogger(__name__)
class Bucket(enum.Enum):
"""Invariant classification by blast radius and recoverability."""
SOFTEN = "soften" # recoverable + legitimate event (never a bug)
GUARD = "guard" # recoverable + is-a-bug / root cause unknown
FATAL_CONTAINABLE = "fatal_containable" # no correct fallback; cheap containment
FATAL_UNCONTAINABLE = "fatal_uncontainable" # no containment; global corruption
def resolve_level() -> InvariantCheckLevel:
"""Current signal level; an unset SGLANG_INVARIANT_CHECK falls back to the
legacy SGLANG_ENABLE_ASYNC_ASSERT=true as STRICT so CI keeps failing loud."""
if envs.SGLANG_INVARIANT_CHECK.is_set():
return InvariantCheckLevel(envs.SGLANG_INVARIANT_CHECK.get())
if envs.SGLANG_ENABLE_ASYNC_ASSERT.get():
return InvariantCheckLevel.STRICT
return InvariantCheckLevel.OFF
class Property:
"""A per-element validity predicate; returns an elementwise bool, no reduction."""
name: str
def ok(self, value: torch.Tensor) -> torch.Tensor:
raise NotImplementedError
class NotNaN(Property):
"""Not NaN, tolerating +-Inf (e.g. legitimately masked -inf logits)."""
name = "not_nan"
def ok(self, value: torch.Tensor) -> torch.Tensor:
return ~torch.isnan(value)
class NotInf(Property):
"""Not +-Inf (e.g. fp16 overflow), tolerating NaN."""
name = "not_inf"
def ok(self, value: torch.Tensor) -> torch.Tensor:
return ~torch.isinf(value)
class Finite(Property):
"""Neither NaN nor Inf -- use when inf is also a bug for this tensor."""
name = "finite"
def ok(self, value: torch.Tensor) -> torch.Tensor:
return torch.isfinite(value)
class InRange(Property):
"""Half-open [lo, hi) -- unifies oob / range / index-domain checks."""
def __init__(self, lo, hi):
self.lo = lo
self.hi = hi
self.name = f"in_range[{lo},{hi})"
def ok(self, value: torch.Tensor) -> torch.Tensor:
return (value >= self.lo) & (value < self.hi)
class InClosedRange(Property):
"""Closed [lo, hi] -- for value sanity (e.g. a probability / confidence)."""
def __init__(self, lo, hi):
self.lo = lo
self.hi = hi
self.name = f"in_closed_range[{lo},{hi}]"
def ok(self, value: torch.Tensor) -> torch.Tensor:
return (value >= self.lo) & (value <= self.hi)
class PageAligned(Property):
def __init__(self, page_size: int):
self.page_size = page_size
self.name = f"page_aligned[{page_size}]"
def ok(self, value: torch.Tensor) -> torch.Tensor:
return value % self.page_size == 0
class IsTrue(Property):
"""The value is itself the boolean condition (for derived / scalar asserts)."""
name = "is_true"
def ok(self, value: torch.Tensor) -> torch.Tensor:
return value
# name -> Invariant; enumerated by the CI meta-test to enforce injection coverage.
_REGISTRY: dict[str, Invariant] = {}
class Invariant:
"""A declared invariant; constructing one registers it. `recover` is the
optional python-side data layer (None = signal-only, in the kernel);
FATAL_UNCONTAINABLE forbids it."""
def __init__(
self,
name: str,
bucket: Bucket,
prop: Property,
*,
recover: Optional[Callable[[torch.Tensor], torch.Tensor]] = None,
):
if bucket is Bucket.FATAL_UNCONTAINABLE and recover is not None:
raise ValueError(f"uncontainable FATAL {name!r} cannot have a recover")
if name in _REGISTRY:
raise ValueError(f"duplicate invariant {name!r}")
self.name = name
self.bucket = bucket
self.prop = prop
self.recover = recover
_REGISTRY[name] = self
def registered_invariants() -> dict[str, Invariant]:
return dict(_REGISTRY)
def _get_rank() -> int:
if torch.distributed.is_available() and torch.distributed.is_initialized():
return torch.distributed.get_rank()
return 0
# Per-(rank, name) checks between aggregated log flushes.
_FLUSH_EVERY = 512
class _CheckReporter:
"""Sync-free per-(rank, name) hit counter. Counts accumulate on-device and
mirror to pinned host via a non-blocking copy; the host reads the (stale)
count on a later call. First hit logs immediately, the rest on a cadence."""
def __init__(self):
self._dev: dict[str, torch.Tensor] = {}
self._host: dict[str, torch.Tensor] = {}
self._calls: dict[str, int] = {}
self._logged_total: dict[str, int] = {}
def record(self, key: str, bucket: Bucket, hit_count: torch.Tensor, msg: str):
dev = self._dev.get(key)
if dev is None:
dev = torch.zeros(1, dtype=torch.int64, device=hit_count.device)
self._dev[key] = dev
self._host[key] = torch.zeros(
1, dtype=torch.int64, pin_memory=hit_count.is_cuda
)
self._calls[key] = 0
self._logged_total[key] = 0
dev.add_(hit_count.reshape(1).to(torch.int64))
self._host[key].copy_(dev, non_blocking=True) # async, no sync
self._calls[key] += 1
total = int(self._host[key][0]) # stale host read, no sync
last = self._logged_total[key]
first_hit = last == 0 and total > 0
cadence = self._calls[key] % _FLUSH_EVERY == 0
if total > last and (first_hit or cadence):
level = logging.INFO if bucket is Bucket.SOFTEN else logging.WARNING
logger.log(
level,
"invariant-check [%s]: +%d hit(s), %d total. %s",
key,
total - last,
total,
msg,
)
self._logged_total[key] = total
_reporter = _CheckReporter()
def _crashes(bucket: Bucket, level: InvariantCheckLevel) -> bool:
"""The (bucket x level) crash decision, pure and total."""
if bucket is Bucket.SOFTEN:
return False
if bucket is Bucket.FATAL_UNCONTAINABLE:
return level >= InvariantCheckLevel.WARN
return level == InvariantCheckLevel.STRICT # GUARD, FATAL_CONTAINABLE
def _signal(ok: torch.Tensor, *, inv: Invariant, level: InvariantCheckLevel, msg: str):
if _crashes(inv.bucket, level):
# Loud: async assert surfaces at the next sync point (no CPU sync).
torch._assert_async(ok.all(), f"invariant-check FAILED [{inv.name}]: {msg}")
return
_reporter.record(f"{inv.name}@rank{_get_rank()}", inv.bucket, (~ok).sum(), msg)
def expect(
inv: Invariant,
value: Optional[torch.Tensor],
*,
msg: str = "",
) -> Optional[torch.Tensor]:
"""Check `inv` over `value` per the (bucket x level) matrix. The data layer
(`inv.recover`) is applied unconditionally; only detection is gated. Returns
the recovered value."""
level = resolve_level()
if level >= InvariantCheckLevel.WARN and value is not None and value.numel() > 0:
detail = f"{inv.prop.name}: {msg}" if msg else inv.prop.name
_signal(inv.prop.ok(value), inv=inv, level=level, msg=detail)
if inv.recover is not None and value is not None:
value = inv.recover(value)
return value