Fix deterministic inference for Inkling (#33417)

This commit is contained in:
Ke Bao
2026-08-08 07:48:15 +08:00
committed by GitHub
parent 1d812865dc
commit ce84df0fa1
11 changed files with 590 additions and 5 deletions
@@ -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_right: Optional[Int32] = None
qhead_per_kvhead_packgqa: cutlass.Constexpr[int] = 1
batch_invariant: cutlass.Constexpr[bool] = False
@cute.jit
def get_n_idx_left_right(
@@ -159,6 +160,11 @@ class BlockInfo:
n_block_min: Int32,
) -> Int32:
"""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
if const_expr(self.qhead_per_kvhead_packgqa > 1):
m_idx_min = m_idx_min // self.qhead_per_kvhead_packgqa
@@ -242,6 +242,7 @@ class FlashAttentionForwardSm100:
v_dequant: bool = False,
q_sf_interleaved: bool = False,
kv_sf_interleaved: bool = False,
batch_invariant: bool = False,
):
# MXFP8 block-scaled attention (see interface._flash_attn_fwd):
# 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
# relative (sheared) bias
self.has_bias = has_bias
self.batch_invariant = batch_invariant
self.rel_extent_padded = rel_extent_padded
assert rel_extent_padded % n_block_size == 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=(
self.qhead_per_kvhead if const_expr(self.pack_gqa) else 1
),
batch_invariant=self.batch_invariant,
)
SeqlenInfoCls = partial(
SeqlenInfoQK.create,
@@ -14,6 +14,9 @@ from cutlass import Float32, Int32
from quack.compile_utils import make_fake_tensor as fake_tensor
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.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.
return out, lse
batch_invariant = is_batch_invariant()
compile_key = (
dtype,
head_dim,
@@ -1130,6 +1134,7 @@ def _flash_attn_fwd(
sfq.ndim if sfq is not None else None,
sfk.ndim if sfk is not None else None,
sfv.ndim if sfv is not None else None,
batch_invariant,
fa_logging.get_fa_log_level(),
)
@@ -1353,6 +1358,7 @@ def _flash_attn_fwd(
v_dequant=v_blockscaled,
q_sf_interleaved=q_sf_interleaved,
kv_sf_interleaved=kv_sf_interleaved,
batch_invariant=batch_invariant,
)
),
)
@@ -268,6 +268,13 @@ class FlashAttentionBackend(AttentionBackend):
)
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:
raise ValueError(f"Invalid version: {self.fa_impl_ver=}")
@@ -52,6 +52,10 @@ def should_run_flashinfer_autotune(
return False
if mr.server_args.disable_flashinfer_autotune:
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
if for_speculative_draft:
@@ -121,7 +121,7 @@ def _rel_proj_kernel_eligible(r: torch.Tensor) -> bool:
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__()
self.d_rel = d_rel
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
# 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._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:
"""``einsum("thd,de->the", r, proj)`` -- but dispatched: in production
@@ -309,7 +313,11 @@ class InklingAttention(nn.Module):
self.rel_extent = rel_extent
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
# path (deletes the external scale kernel; bit-exact rounding).
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()
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 = (
_get_inkling_ar_resources(comm)
if envs.SGLANG_OPT_USE_INKLING_CUSTOM_AR.get()
and not get_exec().deterministic.enable_deterministic_inference
else None
)
# Custom kernels need a 16B-vector-multiple size (validate() enforces it);
+5 -1
View File
@@ -7911,8 +7911,12 @@ class ServerArgs:
# CUDA: use NCCL tree algorithm
os.environ["NCCL_ALGO"] = "allreduce:tree"
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(
"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):
@@ -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}"
)
+39 -1
View File
@@ -18,6 +18,7 @@ import unittest
import requests
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
# 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,
)
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
# 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__":
unittest.main()