[Speculative Decoding] Add native UNO serving support (#37667)
Co-authored-by: drproduck <drproduck@MacBook-Air-2.local> Co-authored-by: BBuf <1182563586@qq.com>
This commit is contained in:
co-authored by
drproduck
BBuf
parent
354ed6d66b
commit
2bb25dc18b
@@ -0,0 +1,121 @@
|
||||
"""CUDA correctness tests for the fused suffix-attention merge."""
|
||||
|
||||
import math
|
||||
import unittest
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.kernels.ops.attention.suffix_attention_merge import (
|
||||
merge_suffix_attention_in_place,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cuda_ci(
|
||||
est_time=15,
|
||||
stage="base-b-kernel-unit",
|
||||
runner_config="1-gpu-large",
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(torch.cuda.is_available(), "requires CUDA")
|
||||
class TestSuffixAttentionMerge(CustomTestCase):
|
||||
def _case(self, *, num_queries: int, head_dim: int, dtype: torch.dtype):
|
||||
torch.manual_seed(7)
|
||||
device = torch.device("cuda")
|
||||
num_q_heads = 8
|
||||
num_kv_heads = 2
|
||||
num_slots = 2 * num_queries + 11
|
||||
|
||||
q = torch.randn(num_queries, num_q_heads, head_dim, device=device, dtype=dtype)
|
||||
k_cache = torch.randn(
|
||||
num_slots, num_kv_heads, head_dim, device=device, dtype=dtype
|
||||
)
|
||||
v_cache = torch.randn_like(k_cache)
|
||||
page_table = torch.stack(
|
||||
[
|
||||
torch.randperm(num_slots, device=device)[:num_queries]
|
||||
for _ in range(num_queries)
|
||||
]
|
||||
).to(torch.int32)
|
||||
suffix_lengths = (
|
||||
torch.arange(num_queries, device=device, dtype=torch.int32)
|
||||
.remainder(num_queries)
|
||||
.add_(1)
|
||||
)
|
||||
prefix = torch.randn_like(q)
|
||||
prefix_lse = torch.randn(
|
||||
num_q_heads, num_queries, device=device, dtype=torch.float32
|
||||
)
|
||||
scale = 1.0 / math.sqrt(head_dim)
|
||||
|
||||
reference = prefix.float().clone()
|
||||
heads_per_kv = num_q_heads // num_kv_heads
|
||||
kv_heads = torch.arange(num_q_heads, device=device) // heads_per_kv
|
||||
for token in range(num_queries):
|
||||
length = int(suffix_lengths[token])
|
||||
slots = page_table[token, :length].long()
|
||||
keys = k_cache[slots][:, kv_heads].float()
|
||||
values = v_cache[slots][:, kv_heads].float()
|
||||
scores = torch.einsum("lhd,hd->lh", keys, q[token].float()) * scale
|
||||
maximum = torch.maximum(prefix_lse[:, token], scores.max(dim=0).values)
|
||||
prefix_weight = torch.exp(prefix_lse[:, token] - maximum)
|
||||
suffix_weights = torch.exp(scores - maximum)
|
||||
reference[token] = (
|
||||
reference[token] * prefix_weight[:, None]
|
||||
+ torch.einsum("lh,lhd->hd", suffix_weights, values)
|
||||
) / (prefix_weight + suffix_weights.sum(dim=0))[:, None]
|
||||
|
||||
static_prefix = prefix.clone()
|
||||
merge_suffix_attention_in_place(
|
||||
q,
|
||||
k_cache,
|
||||
v_cache,
|
||||
page_table,
|
||||
suffix_lengths,
|
||||
static_prefix,
|
||||
prefix_lse,
|
||||
scale,
|
||||
)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
static_prefix.copy_(prefix)
|
||||
merge_suffix_attention_in_place(
|
||||
q,
|
||||
k_cache,
|
||||
v_cache,
|
||||
page_table,
|
||||
suffix_lengths,
|
||||
static_prefix,
|
||||
prefix_lse,
|
||||
scale,
|
||||
)
|
||||
graph.replay()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
torch.testing.assert_close(
|
||||
static_prefix.float(), reference, rtol=2e-2, atol=2e-2
|
||||
)
|
||||
|
||||
def test_representative_shapes(self):
|
||||
cases = (
|
||||
(16, 64, torch.float16),
|
||||
(60, 128, torch.bfloat16),
|
||||
)
|
||||
for num_queries, head_dim, dtype in cases:
|
||||
with self.subTest(
|
||||
num_queries=num_queries,
|
||||
head_dim=head_dim,
|
||||
dtype=dtype,
|
||||
):
|
||||
self._case(
|
||||
num_queries=num_queries,
|
||||
head_dim=head_dim,
|
||||
dtype=dtype,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""End-to-end CUDA-graph coverage for linear and tree UNO decoding.
|
||||
|
||||
The test runs both modes on the same prompts. Linear UNO alternates
|
||||
LoRA-draft and clean-target variants in one graph runner. Tree UNO uses a
|
||||
private LoRA-draft runner before native EAGLE tree verification. Besides the
|
||||
generation contract, short greedy comparisons guard lossless output parity
|
||||
with autoregressive decoding, and the stochastic comparison guards that tree
|
||||
search improves TPF over the linear proposal on a small, fixed GSM8K sample.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from typing import NamedTuple
|
||||
|
||||
import requests
|
||||
|
||||
from sglang.srt.utils import kill_process_tree
|
||||
from sglang.test.ci.ci_register import register_cuda_ci
|
||||
from sglang.test.test_utils import (
|
||||
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
DEFAULT_URL_FOR_TEST,
|
||||
CustomTestCase,
|
||||
popen_launch_server,
|
||||
)
|
||||
|
||||
register_cuda_ci(
|
||||
est_time=480,
|
||||
stage="base-b",
|
||||
runner_config="1-gpu-large",
|
||||
)
|
||||
|
||||
MODEL = "Qwen/Qwen3-8B"
|
||||
DEFAULT_UNO_LORA = "s-sahoo/uno-qwen3-8B"
|
||||
LORA_PATH_ENV = "SGLANG_TEST_UNO_LORA_PATH"
|
||||
MAX_NEW_TOKENS = 128
|
||||
# AR decode and UNO verification use different kernel shapes, so compare a
|
||||
# bounded greedy prefix instead of requiring full-sequence bitwise identity.
|
||||
PARITY_TOKENS = 32
|
||||
# One LoRA draft forward plus one clean verification forward.
|
||||
FORWARDS_PER_UNO_CYCLE = 2
|
||||
PROMPTS = (
|
||||
(
|
||||
"Question: Janet's ducks lay 16 eggs per day. She eats three for "
|
||||
"breakfast every morning and bakes muffins for her friends every day "
|
||||
"with four. She sells the remainder at the farmers' market daily for "
|
||||
"$2 per fresh duck egg. How much in dollars does she make every day "
|
||||
"at the farmers' market?\nAnswer:"
|
||||
),
|
||||
(
|
||||
"Question: A robe takes 2 bolts of blue fiber and half that much "
|
||||
"white fiber. How many bolts in total does it take?\nAnswer:"
|
||||
),
|
||||
(
|
||||
"Question: Josh decides to try flipping a house. He buys a house for "
|
||||
"$80,000 and then puts in $50,000 in repairs. This increased the value "
|
||||
"of the house by 150%. How much profit did he make?\nAnswer:"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _UnoConfig(NamedTuple):
|
||||
name: str
|
||||
speculative_num_steps: int
|
||||
speculative_eagle_topk: int
|
||||
speculative_num_draft_tokens: int
|
||||
|
||||
|
||||
LINEAR_CONFIG = _UnoConfig(
|
||||
name="linear",
|
||||
speculative_num_steps=1,
|
||||
speculative_eagle_topk=1,
|
||||
speculative_num_draft_tokens=8, # F = 8
|
||||
)
|
||||
TREE_CONFIG = _UnoConfig(
|
||||
name="tree",
|
||||
speculative_num_steps=7, # F = 8
|
||||
speculative_eagle_topk=16,
|
||||
speculative_num_draft_tokens=8, # Q = 8
|
||||
)
|
||||
|
||||
|
||||
class TestUnoCudaGraph(CustomTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.base_url = DEFAULT_URL_FOR_TEST
|
||||
cls.adapter_path = os.environ.get(LORA_PATH_ENV, DEFAULT_UNO_LORA)
|
||||
|
||||
def _server_args(self, config: _UnoConfig | None) -> list[str]:
|
||||
args = [
|
||||
"--dtype",
|
||||
"bfloat16",
|
||||
"--attention-backend",
|
||||
"fa3",
|
||||
"--max-running-requests",
|
||||
str(len(PROMPTS)),
|
||||
"--cuda-graph-max-bs-decode",
|
||||
str(len(PROMPTS)),
|
||||
"--mem-fraction-static",
|
||||
"0.7",
|
||||
"--page-size",
|
||||
"1",
|
||||
"--disable-radix-cache",
|
||||
"--random-seed",
|
||||
"17",
|
||||
]
|
||||
if config is not None:
|
||||
args.extend(
|
||||
[
|
||||
"--speculative-algorithm",
|
||||
"UNO",
|
||||
"--uno-lora-path",
|
||||
self.adapter_path,
|
||||
"--speculative-num-steps",
|
||||
str(config.speculative_num_steps),
|
||||
"--speculative-eagle-topk",
|
||||
str(config.speculative_eagle_topk),
|
||||
"--speculative-num-draft-tokens",
|
||||
str(config.speculative_num_draft_tokens),
|
||||
]
|
||||
)
|
||||
return args
|
||||
|
||||
def _run_ar_reference(self) -> list[list[int]]:
|
||||
process = None
|
||||
try:
|
||||
process = popen_launch_server(
|
||||
MODEL,
|
||||
self.base_url,
|
||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
other_args=self._server_args(None),
|
||||
)
|
||||
return self._run_greedy_output_ids()
|
||||
finally:
|
||||
if process is not None:
|
||||
kill_process_tree(process.pid)
|
||||
|
||||
def _run_config(self, config: _UnoConfig) -> tuple[float, list[list[int]]]:
|
||||
process = None
|
||||
try:
|
||||
process = popen_launch_server(
|
||||
MODEL,
|
||||
self.base_url,
|
||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
other_args=self._server_args(config),
|
||||
)
|
||||
greedy_output_ids = self._run_greedy_output_ids()
|
||||
tpf = self._run_generation_contract(config)
|
||||
return tpf, greedy_output_ids
|
||||
finally:
|
||||
if process is not None:
|
||||
kill_process_tree(process.pid)
|
||||
|
||||
def _run_greedy_output_ids(self) -> list[list[int]]:
|
||||
# A list-valued request can be admitted with different prefill batch
|
||||
# shapes across server launches. Run each parity prompt at BS1 so the
|
||||
# AR and UNO comparisons use the same execution shape.
|
||||
output_ids = []
|
||||
for prompt in PROMPTS:
|
||||
response = requests.post(
|
||||
self.base_url + "/generate",
|
||||
json={
|
||||
"text": prompt,
|
||||
"sampling_params": {
|
||||
"temperature": 0,
|
||||
"max_new_tokens": PARITY_TOKENS,
|
||||
"ignore_eos": True,
|
||||
},
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
|
||||
result = response.json()
|
||||
self.assertIn("output_ids", result, result)
|
||||
self.assertEqual(
|
||||
len(result["output_ids"]),
|
||||
PARITY_TOKENS,
|
||||
f"Wrong greedy output length for prompt {prompt!r}",
|
||||
)
|
||||
output_ids.append(result["output_ids"])
|
||||
return output_ids
|
||||
|
||||
def _assert_ar_parity(
|
||||
self,
|
||||
mode: str,
|
||||
actual: list[list[int]],
|
||||
expected: list[list[int]],
|
||||
) -> None:
|
||||
for prompt, actual_ids, expected_ids in zip(PROMPTS, actual, expected):
|
||||
self.assertEqual(
|
||||
actual_ids,
|
||||
expected_ids,
|
||||
f"{mode} UNO diverged from AR within the first "
|
||||
f"{PARITY_TOKENS} tokens for prompt {prompt!r}",
|
||||
)
|
||||
|
||||
def _run_generation_contract(self, config: _UnoConfig) -> float:
|
||||
server_info = requests.get(self.base_url + "/server_info", timeout=30).json()
|
||||
self.assertEqual(
|
||||
server_info["speculative_eagle_topk"], config.speculative_eagle_topk
|
||||
)
|
||||
self.assertEqual(
|
||||
server_info["speculative_num_steps"], config.speculative_num_steps
|
||||
)
|
||||
self.assertEqual(
|
||||
server_info["speculative_num_draft_tokens"],
|
||||
config.speculative_num_draft_tokens,
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
self.base_url + "/generate",
|
||||
json={
|
||||
"text": PROMPTS,
|
||||
"sampling_params": {
|
||||
"temperature": 0.7,
|
||||
"top_k": 50,
|
||||
"top_p": 0.95,
|
||||
"max_new_tokens": MAX_NEW_TOKENS,
|
||||
"ignore_eos": True,
|
||||
},
|
||||
},
|
||||
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200, response.text)
|
||||
|
||||
results = response.json()
|
||||
self.assertEqual(len(results), len(PROMPTS))
|
||||
total_completion_tokens = 0
|
||||
total_verify_ct = 0
|
||||
for result in results:
|
||||
self.assertTrue(result["text"].strip())
|
||||
meta_info = result["meta_info"]
|
||||
self.assertEqual(meta_info["completion_tokens"], MAX_NEW_TOKENS)
|
||||
total_completion_tokens += meta_info["completion_tokens"]
|
||||
total_verify_ct += meta_info.get("spec_verify_ct", 0)
|
||||
|
||||
self.assertGreater(
|
||||
total_verify_ct, 0, f"{config.name} performed no verify steps"
|
||||
)
|
||||
total_forwards = FORWARDS_PER_UNO_CYCLE * total_verify_ct
|
||||
tpf = total_completion_tokens / total_forwards
|
||||
self.assertGreater(
|
||||
tpf,
|
||||
1.5,
|
||||
f"{config.name} did not advance beyond autoregressive decoding: {tpf=}",
|
||||
)
|
||||
return tpf
|
||||
|
||||
def test_ar_parity_and_tree_tpf_exceeds_linear(self):
|
||||
ar_output_ids = self._run_ar_reference()
|
||||
|
||||
linear_tpf, linear_output_ids = self._run_config(LINEAR_CONFIG)
|
||||
self._assert_ar_parity("Linear", linear_output_ids, ar_output_ids)
|
||||
|
||||
tree_tpf, tree_output_ids = self._run_config(TREE_CONFIG)
|
||||
self._assert_ar_parity("Tree", tree_output_ids, ar_output_ids)
|
||||
|
||||
print(f"UNO GSM8K sample: {linear_tpf=:.3f}, {tree_tpf=:.3f}")
|
||||
self.assertGreater(
|
||||
tree_tpf,
|
||||
linear_tpf,
|
||||
f"Tree UNO did not improve TPF: {linear_tpf=:.3f}, {tree_tpf=:.3f}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Regression test for UNO's base-only LoRA routing fast path."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.lora.lora_manager import LoRAManager
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class _InactiveSkippingBackend:
|
||||
skip_inactive_lora_batches = True
|
||||
|
||||
def __init__(self):
|
||||
self.batch_info = object()
|
||||
self.prepare_called = False
|
||||
|
||||
def reset_batch_state(self):
|
||||
self.batch_info = None
|
||||
|
||||
def prepare_lora_batch(self, *args, **kwargs):
|
||||
self.prepare_called = True
|
||||
|
||||
|
||||
class TestUnoInactiveLoRABatch(CustomTestCase):
|
||||
def test_all_base_batch_clears_stale_routing_before_graph_metadata(self):
|
||||
backend = _InactiveSkippingBackend()
|
||||
manager = LoRAManager.__new__(LoRAManager)
|
||||
manager.lora_backend = backend
|
||||
forward_batch = SimpleNamespace(lora_ids=[None], batch_size=1)
|
||||
|
||||
manager.prepare_lora_batch(forward_batch)
|
||||
|
||||
self.assertIsNone(backend.batch_info)
|
||||
self.assertFalse(backend.prepare_called)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Target-layer validation for UNO's specialized LoRA backend."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.layers.linear import (
|
||||
ColumnParallelLinear,
|
||||
ReplicatedLinear,
|
||||
RowParallelLinear,
|
||||
)
|
||||
from sglang.srt.layers.moe.fused_moe_triton.layer import FusedMoE
|
||||
from sglang.srt.lora.backend.triton_backend import TritonLoRABackend
|
||||
from sglang.srt.lora.backend.uno_cublas_backend import UnoCublasLoRABackend
|
||||
from sglang.srt.lora.lora_manager import LoRAManager
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestUnoLoRATargets(CustomTestCase):
|
||||
def setUp(self):
|
||||
self.backend = UnoCublasLoRABackend.__new__(UnoCublasLoRABackend)
|
||||
self.backend._pending_lora_a = None
|
||||
self.backend._use_cublas_lora_b = False
|
||||
|
||||
@staticmethod
|
||||
def _model(modules, **attributes):
|
||||
return SimpleNamespace(
|
||||
named_modules=lambda: modules,
|
||||
**attributes,
|
||||
)
|
||||
|
||||
def test_supported_decoder_targets_are_accepted(self):
|
||||
modules = [
|
||||
(
|
||||
"model.layers.0.qkv_proj",
|
||||
ColumnParallelLinear.__new__(ColumnParallelLinear),
|
||||
),
|
||||
(
|
||||
"model.layers.0.o_proj",
|
||||
RowParallelLinear.__new__(RowParallelLinear),
|
||||
),
|
||||
(
|
||||
"model.layers.0.fused_qkv_a_proj_with_mqa",
|
||||
ReplicatedLinear.__new__(ReplicatedLinear),
|
||||
),
|
||||
]
|
||||
self.backend.validate_lora_targets(
|
||||
base_model=self._model(modules),
|
||||
target_modules={
|
||||
"qkv_proj",
|
||||
"o_proj",
|
||||
"fused_qkv_a_proj_with_mqa",
|
||||
},
|
||||
)
|
||||
|
||||
def test_unsupported_targets_are_rejected(self):
|
||||
cases = {
|
||||
"unknown decoder layer": (
|
||||
self._model(
|
||||
[
|
||||
(
|
||||
"model.layers.0.custom_proj",
|
||||
torch.nn.Linear(2, 2),
|
||||
)
|
||||
]
|
||||
),
|
||||
{"custom_proj"},
|
||||
"Linear",
|
||||
),
|
||||
"fused MoE": (
|
||||
self._model(
|
||||
[
|
||||
(
|
||||
"model.layers.0.mlp",
|
||||
FusedMoE.__new__(FusedMoE),
|
||||
)
|
||||
]
|
||||
),
|
||||
{"gate_up_proj", "down_proj"},
|
||||
"FusedMoE",
|
||||
),
|
||||
}
|
||||
|
||||
for name, (model, targets, expected) in cases.items():
|
||||
with self.subTest(name=name), self.assertRaisesRegex(ValueError, expected):
|
||||
self.backend.validate_lora_targets(
|
||||
base_model=model,
|
||||
target_modules=targets,
|
||||
)
|
||||
|
||||
def test_nonoverlap_dense_calls_fall_back_to_triton(self):
|
||||
x = object()
|
||||
weights = object()
|
||||
hidden = object()
|
||||
base_output = object()
|
||||
pruned_batch_info = object()
|
||||
expected = object()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
TritonLoRABackend,
|
||||
"run_lora_a_sgemm",
|
||||
return_value=hidden,
|
||||
) as run_lora_a,
|
||||
patch.object(
|
||||
TritonLoRABackend,
|
||||
"run_lora_b_sgemm",
|
||||
return_value=expected,
|
||||
) as run_lora_b,
|
||||
):
|
||||
actual_hidden = self.backend.run_lora_a_sgemm(
|
||||
x,
|
||||
weights,
|
||||
pruned_batch_info=pruned_batch_info,
|
||||
)
|
||||
actual = self.backend.run_lora_b_sgemm(
|
||||
actual_hidden,
|
||||
weights,
|
||||
base_output=base_output,
|
||||
pruned_batch_info=pruned_batch_info,
|
||||
)
|
||||
|
||||
self.assertIs(actual_hidden, hidden)
|
||||
self.assertIs(actual, expected)
|
||||
run_lora_a.assert_called_once_with(
|
||||
x,
|
||||
weights,
|
||||
pruned_batch_info,
|
||||
1,
|
||||
)
|
||||
run_lora_b.assert_called_once_with(
|
||||
hidden,
|
||||
weights,
|
||||
base_output,
|
||||
pruned_batch_info,
|
||||
)
|
||||
|
||||
def test_overlap_launch_selects_cublas(self):
|
||||
pending = object()
|
||||
x = object()
|
||||
weights = object()
|
||||
hidden = object()
|
||||
base_output = object()
|
||||
expected = object()
|
||||
self.backend._pending_lora_a = pending
|
||||
self.backend._consume_lora_a_overlap = MagicMock(return_value=hidden)
|
||||
self.backend._run_lora_b = MagicMock(return_value=expected)
|
||||
|
||||
with (
|
||||
patch.object(TritonLoRABackend, "run_lora_a_sgemm") as run_lora_a,
|
||||
patch.object(TritonLoRABackend, "run_lora_b_sgemm") as run_lora_b,
|
||||
):
|
||||
actual_hidden = self.backend.run_lora_a_sgemm(x, weights)
|
||||
actual = self.backend.run_lora_b_sgemm(
|
||||
actual_hidden,
|
||||
weights,
|
||||
base_output=base_output,
|
||||
)
|
||||
|
||||
self.assertIs(actual_hidden, hidden)
|
||||
self.assertIs(actual, expected)
|
||||
self.backend._consume_lora_a_overlap.assert_called_once_with(pending)
|
||||
self.backend._run_lora_b.assert_called_once_with(
|
||||
hidden,
|
||||
weights,
|
||||
base_output,
|
||||
)
|
||||
self.assertFalse(self.backend._use_cublas_lora_b)
|
||||
run_lora_a.assert_not_called()
|
||||
run_lora_b.assert_not_called()
|
||||
|
||||
def test_nonoverlap_qkv_call_falls_back_to_triton(self):
|
||||
expected = object()
|
||||
args = {
|
||||
"x": object(),
|
||||
"qkv_lora_a": object(),
|
||||
"qkv_lora_b": object(),
|
||||
"output_offset": object(),
|
||||
"output_offset_cpu": object(),
|
||||
"max_qkv_out_dim": 128,
|
||||
"base_output": object(),
|
||||
"n_slices": 2,
|
||||
}
|
||||
|
||||
with patch.object(
|
||||
TritonLoRABackend,
|
||||
"run_qkv_lora",
|
||||
return_value=expected,
|
||||
) as run_qkv_lora:
|
||||
actual = self.backend.run_qkv_lora(**args)
|
||||
|
||||
self.assertIs(actual, expected)
|
||||
run_qkv_lora.assert_called_once_with(
|
||||
args["x"],
|
||||
args["qkv_lora_a"],
|
||||
args["qkv_lora_b"],
|
||||
args["output_offset"],
|
||||
128,
|
||||
args["base_output"],
|
||||
2,
|
||||
)
|
||||
|
||||
def test_manager_preflights_targets_before_wrapping(self):
|
||||
manager = LoRAManager.__new__(LoRAManager)
|
||||
manager.base_model = object()
|
||||
manager.lora_backend = MagicMock()
|
||||
manager._experts_shared_outer_override = None
|
||||
manager.init_lora_adapters = MagicMock()
|
||||
manager.init_lora_shapes = MagicMock(
|
||||
side_effect=lambda **_: setattr(manager, "target_modules", {"qkv_proj"})
|
||||
)
|
||||
manager._detect_shared_outer_loras = MagicMock(return_value=False)
|
||||
manager.init_lora_modules = MagicMock()
|
||||
manager.init_memory_pool = MagicMock()
|
||||
manager.update_lora_info = MagicMock()
|
||||
manager.lora_backend.validate_lora_targets.side_effect = ValueError(
|
||||
"unsupported target"
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "unsupported target"):
|
||||
manager.init_state(max_lora_rank=1, target_modules={"q_proj"})
|
||||
|
||||
manager.lora_backend.validate_lora_targets.assert_called_once_with(
|
||||
base_model=manager.base_model,
|
||||
target_modules={"qkv_proj"},
|
||||
)
|
||||
manager.init_lora_modules.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,6 +7,7 @@ import torch
|
||||
from sglang.srt.managers.scheduler_components.batch_result_processor import (
|
||||
SchedulerBatchResultProcessor,
|
||||
)
|
||||
from sglang.srt.managers.utils import GenerationBatchResult
|
||||
from sglang.srt.model_executor.forward_batch_info import CaptureHiddenMode
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
@@ -178,17 +179,8 @@ class TestDecodeHiddenStateRetention(CustomTestCase):
|
||||
second_step = torch.arange(16, dtype=torch.float32).view(8, 2)[4:]
|
||||
|
||||
def result(hidden_states):
|
||||
return SimpleNamespace(
|
||||
copy_done=None,
|
||||
auxiliary_host_output=None,
|
||||
routed_experts_output=None,
|
||||
indexer_topk_output=None,
|
||||
return GenerationBatchResult(
|
||||
logits_output=SimpleNamespace(hidden_states=hidden_states),
|
||||
next_token_ids=None,
|
||||
can_run_cuda_graph=False,
|
||||
num_correct_drafts=0,
|
||||
num_block_accept_tokens=0,
|
||||
num_cap_tokens=0,
|
||||
speculative_num_draft_tokens=4,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sglang.srt.managers.scheduler import Scheduler
|
||||
from sglang.srt.managers.scheduler_components.batch_result_processor import (
|
||||
SchedulerBatchResultProcessor,
|
||||
)
|
||||
from sglang.srt.managers.utils import GenerationBatchResult
|
||||
from sglang.srt.runtime_context import get_context
|
||||
from sglang.srt.sampling.sampling_params import SamplingParams
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
@@ -78,17 +79,9 @@ def _make_processor() -> SchedulerBatchResultProcessor:
|
||||
|
||||
|
||||
def _make_result():
|
||||
return SimpleNamespace(
|
||||
copy_done=None,
|
||||
auxiliary_host_output=None,
|
||||
routed_experts_output=None,
|
||||
indexer_topk_output=None,
|
||||
return GenerationBatchResult(
|
||||
logits_output=SimpleNamespace(hidden_states=None, customized_info=None),
|
||||
next_token_ids=[4],
|
||||
can_run_cuda_graph=False,
|
||||
num_correct_drafts=0,
|
||||
num_block_accept_tokens=0,
|
||||
num_cap_tokens=0,
|
||||
speculative_num_draft_tokens=0,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from sglang.srt.managers.schedule_batch import Req
|
||||
from sglang.srt.managers.scheduler_components.batch_result_processor import (
|
||||
SchedulerBatchResultProcessor,
|
||||
)
|
||||
from sglang.srt.managers.utils import GenerationBatchResult
|
||||
from sglang.srt.sampling.sampling_params import (
|
||||
REQUEST_REASONING_END_TOKEN_IDS_KEY,
|
||||
SamplingParams,
|
||||
@@ -101,16 +102,10 @@ def _make_req(terminate_after: int) -> Req:
|
||||
|
||||
|
||||
def _make_result(num_draft_tokens, accept_lens, flat_tokens):
|
||||
return SimpleNamespace(
|
||||
return GenerationBatchResult(
|
||||
next_token_ids=torch.tensor(flat_tokens, dtype=torch.long),
|
||||
accept_lens=torch.tensor(accept_lens, dtype=torch.long),
|
||||
speculative_num_draft_tokens=num_draft_tokens,
|
||||
num_correct_drafts=None,
|
||||
num_correct_drafts_per_req_cpu=None,
|
||||
block_accept_lens=None,
|
||||
cap_lens=None,
|
||||
copy_done=None,
|
||||
grammar_advanced=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Scheduler containment for unsupported UNO requests."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase, maybe_stub_sgl_kernel
|
||||
|
||||
maybe_stub_sgl_kernel()
|
||||
|
||||
from sglang.srt.disaggregation.utils import DisaggregationMode
|
||||
from sglang.srt.managers.scheduler import Scheduler
|
||||
|
||||
register_cpu_ci(est_time=2, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestSchedulerUnoRequestValidation(CustomTestCase):
|
||||
def test_invalid_request_is_aborted_before_scheduler_admission(self):
|
||||
scheduler = Scheduler.__new__(Scheduler)
|
||||
scheduler.enable_session_radix_cache = False
|
||||
scheduler.model_config = SimpleNamespace(
|
||||
hf_eos_token_id={1},
|
||||
vocab_size=128,
|
||||
)
|
||||
scheduler.disaggregation_mode = DisaggregationMode.NULL
|
||||
scheduler.metrics_reporter = SimpleNamespace(enable_metrics=False)
|
||||
scheduler.tokenizer = None
|
||||
scheduler.dllm_config = None
|
||||
scheduler._maybe_namespace_elastic_radix_cache = MagicMock()
|
||||
scheduler.spec_algorithm = SimpleNamespace(
|
||||
is_dflash_family=lambda: False,
|
||||
is_uno=lambda: True,
|
||||
)
|
||||
scheduler.init_req_max_new_tokens = MagicMock()
|
||||
scheduler._add_request_to_queue = MagicMock()
|
||||
|
||||
recv_req = MagicMock(
|
||||
session_params=None,
|
||||
session_id=None,
|
||||
input_embeds=None,
|
||||
bootstrap_port=1,
|
||||
)
|
||||
req = MagicMock()
|
||||
error = "UNO request is unsupported."
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sglang.srt.managers.scheduler.BeamCoordinator.request_beam_width",
|
||||
return_value=1,
|
||||
),
|
||||
patch("sglang.srt.managers.scheduler.Req", return_value=req),
|
||||
patch(
|
||||
"sglang.srt.managers.scheduler.validate_uno_request",
|
||||
return_value=error,
|
||||
) as validate_uno_request,
|
||||
):
|
||||
scheduler.handle_generate_request(recv_req)
|
||||
|
||||
validate_uno_request.assert_called_once_with(req)
|
||||
req.set_finish_with_abort.assert_called_once_with(error)
|
||||
scheduler.init_req_max_new_tokens.assert_called_once_with(req)
|
||||
scheduler._add_request_to_queue.assert_called_once_with(req)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""CPU regressions for UNO aggregate token accounting."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.managers.scheduler import Scheduler
|
||||
from sglang.srt.managers.scheduler_components.metrics_reporter import (
|
||||
SchedulerMetricsReporter,
|
||||
)
|
||||
from sglang.srt.managers.utils import GenerationBatchResult
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestUnoTokenAccounting(CustomTestCase):
|
||||
def setUp(self):
|
||||
self.batch_size = 2
|
||||
self.result = GenerationBatchResult(
|
||||
num_correct_drafts=3,
|
||||
num_non_draft_tokens_per_req=2,
|
||||
)
|
||||
|
||||
def test_generated_token_count_includes_both_non_draft_tokens(self):
|
||||
self.assertEqual(self.result.get_num_generated_tokens(self.batch_size), 7)
|
||||
self.assertEqual(
|
||||
GenerationBatchResult(num_correct_drafts=3).get_num_generated_tokens(
|
||||
self.batch_size
|
||||
),
|
||||
5,
|
||||
)
|
||||
|
||||
def test_spec_metrics_keep_generated_and_draft_counts_separate(self):
|
||||
reporter = SchedulerMetricsReporter.__new__(SchedulerMetricsReporter)
|
||||
reporter.spec_num_accept_tokens = 0
|
||||
reporter.spec_num_correct_drafts = 0
|
||||
reporter.spec_num_forward_ct = 0
|
||||
reporter.spec_num_block_accept_tokens = 0
|
||||
reporter.spec_num_cap_tokens = 0
|
||||
|
||||
reporter.update_spec_metrics(
|
||||
self.batch_size,
|
||||
self.result.num_correct_drafts,
|
||||
num_accept_tokens=self.result.get_num_generated_tokens(self.batch_size),
|
||||
)
|
||||
|
||||
self.assertEqual(reporter.spec_num_accept_tokens, 7)
|
||||
self.assertEqual(reporter.spec_num_correct_drafts, 3)
|
||||
self.assertEqual(reporter.spec_num_forward_ct, 2)
|
||||
|
||||
def test_decode_moment_receives_full_generated_token_count(self):
|
||||
scheduler = Scheduler.__new__(Scheduler)
|
||||
scheduler._prev_step = (1, 10.0, False)
|
||||
scheduler.decode_moment_totals = [0.0] * 6
|
||||
batch = SimpleNamespace(
|
||||
forward_mode=SimpleNamespace(
|
||||
is_extend_without_speculative=lambda: False,
|
||||
is_decode=lambda: True,
|
||||
is_target_verify=lambda: False,
|
||||
),
|
||||
reqs=[SimpleNamespace(rid="req-0"), SimpleNamespace(rid="req-1")],
|
||||
forward_iter=2,
|
||||
launch_ts=10.001,
|
||||
after_idle_gap=False,
|
||||
)
|
||||
|
||||
scheduler._record_step_counters(batch, self.result)
|
||||
|
||||
self.assertEqual(scheduler.decode_moment_totals[5], 7)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Unit tests for UNO allocation sizing."""
|
||||
|
||||
import unittest
|
||||
|
||||
from sglang.srt.mem_cache.allocation_sizing import (
|
||||
get_alloc_len_per_decode,
|
||||
get_alloc_reserve_per_decode,
|
||||
get_req_to_token_extra_context_len,
|
||||
)
|
||||
from sglang.srt.runtime_context import get_context, get_parallel
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestUnoAllocationSizing(CustomTestCase):
|
||||
def test_page_size_one_row_covers_decode_reserve(self):
|
||||
with (
|
||||
get_context().override_server_args(
|
||||
speculative_algorithm="UNO",
|
||||
speculative_num_draft_tokens=8,
|
||||
page_size=1,
|
||||
),
|
||||
get_parallel().override(attn_dcp_size=1),
|
||||
):
|
||||
self.assertEqual(get_alloc_len_per_decode(), 9)
|
||||
self.assertEqual(get_alloc_reserve_per_decode(), 18)
|
||||
self.assertGreaterEqual(
|
||||
get_req_to_token_extra_context_len(),
|
||||
get_alloc_reserve_per_decode(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -14,6 +14,7 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sglang.srt.model_executor.model_runner import ModelRunner
|
||||
from sglang.srt.speculative.spec_info import SpeculativeAlgorithm
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
@@ -25,6 +26,7 @@ class TestDraftRunnerSkipsLoRA(CustomTestCase):
|
||||
runner = ModelRunner.__new__(ModelRunner)
|
||||
runner.is_draft_worker = is_draft_worker
|
||||
runner.lora_manager = None
|
||||
runner.spec_algorithm = SpeculativeAlgorithm.NONE
|
||||
with patch.object(ModelRunner, "init_lora_manager") as init_lora:
|
||||
with patch("sglang.srt.model_executor.model_runner.get_lora") as get_lora:
|
||||
get_lora.return_value.enable_lora = enable_lora
|
||||
|
||||
@@ -45,6 +45,10 @@ _DFLASH_DECODE = (
|
||||
"speculative/dflash_info_v2.py",
|
||||
"DFlashDraftInputV2.prepare_for_decode",
|
||||
)
|
||||
_UNO_DECODE = (
|
||||
"speculative/uno_info.py",
|
||||
"UnoDraftInput.prepare_for_decode",
|
||||
)
|
||||
_RESOLVE = (
|
||||
"managers/scheduler_components/batch_result_processor.py",
|
||||
"SchedulerBatchResultProcessor._resolve_spec_v2_tokens",
|
||||
@@ -71,6 +75,8 @@ _OWNER_SITES = {
|
||||
# one of these two owners for each speculative decode iteration.
|
||||
(*_DFLASH_DECODE, "decode_batch_idx"): 1,
|
||||
(*_DFLASH_DECODE, "evict"): 1,
|
||||
(*_UNO_DECODE, "decode_batch_idx"): 1,
|
||||
(*_UNO_DECODE, "evict"): 1,
|
||||
(
|
||||
"mem_cache/allocation.py",
|
||||
"alloc_for_spec_decode",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""CPU contracts for the fused suffix-attention merge dispatch guard."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.kernels.ops.attention.suffix_attention_merge import (
|
||||
can_use_fused_suffix_attention_merge,
|
||||
)
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestSuffixAttentionMergeDispatch(CustomTestCase):
|
||||
def _inputs(self):
|
||||
layer = SimpleNamespace(
|
||||
head_dim=64,
|
||||
v_head_dim=64,
|
||||
is_cross_attention=False,
|
||||
logit_cap=0.0,
|
||||
)
|
||||
q = torch.empty((16, 8 * 64), dtype=torch.bfloat16)
|
||||
key_cache = torch.empty((8, 16, 2, 64), dtype=torch.bfloat16)
|
||||
value_cache = torch.empty_like(key_cache)
|
||||
return layer, q, key_cache, value_cache
|
||||
|
||||
def _eligible(self, **overrides):
|
||||
layer, q, key_cache, value_cache = self._inputs()
|
||||
arguments = dict(
|
||||
layer=layer,
|
||||
q=q,
|
||||
key_cache=key_cache,
|
||||
value_cache=value_cache,
|
||||
extra_kwargs={},
|
||||
)
|
||||
arguments.update(overrides)
|
||||
return can_use_fused_suffix_attention_merge(**arguments)
|
||||
|
||||
def test_standard_attention_is_eligible(self):
|
||||
self.assertTrue(self._eligible())
|
||||
|
||||
def test_special_attention_features_fall_back(self):
|
||||
self.assertFalse(self._eligible(extra_kwargs={"sinks": object()}))
|
||||
|
||||
layer, _, _, _ = self._inputs()
|
||||
layer.is_cross_attention = True
|
||||
self.assertFalse(self._eligible(layer=layer))
|
||||
|
||||
layer, _, _, _ = self._inputs()
|
||||
layer.logit_cap = 20.0
|
||||
self.assertFalse(self._eligible(layer=layer))
|
||||
|
||||
def test_unsupported_tensor_layout_falls_back(self):
|
||||
layer, _, _, _ = self._inputs()
|
||||
layer.v_head_dim = 32
|
||||
self.assertFalse(self._eligible(layer=layer))
|
||||
|
||||
_, q, key_cache, value_cache = self._inputs()
|
||||
self.assertFalse(
|
||||
self._eligible(
|
||||
q=q.float(),
|
||||
key_cache=key_cache.float(),
|
||||
value_cache=value_cache.float(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Request-admission validation for UNO speculative decoding."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sglang.srt.speculative.uno_validation import validate_uno_request
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
def _make_request(**overrides):
|
||||
sampling_params = SimpleNamespace(
|
||||
min_p=0.0,
|
||||
json_schema=None,
|
||||
regex=None,
|
||||
ebnf=None,
|
||||
structural_tag=None,
|
||||
frequency_penalty=0.0,
|
||||
presence_penalty=0.0,
|
||||
repetition_penalty=1.0,
|
||||
min_new_tokens=0,
|
||||
logit_bias=None,
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
sampling_params=sampling_params,
|
||||
grammar=None,
|
||||
return_logprob=False,
|
||||
return_hidden_states_mode=SimpleNamespace(need_capture=lambda: False),
|
||||
custom_logit_processor=None,
|
||||
lora_id=None,
|
||||
)
|
||||
for name, value in overrides.items():
|
||||
target, field = name.split("__", maxsplit=1)
|
||||
owner = sampling_params if target == "sampling_params" else request
|
||||
setattr(owner, field, value)
|
||||
return request
|
||||
|
||||
|
||||
class TestUnoRequestValidation(CustomTestCase):
|
||||
def test_supported_request_is_accepted(self):
|
||||
self.assertIsNone(validate_uno_request(_make_request()))
|
||||
|
||||
def test_unsupported_request_features_are_rejected(self):
|
||||
cases = {
|
||||
"min_p": ({"sampling_params__min_p": 0.1}, "min_p"),
|
||||
"grammar": ({"sampling_params__regex": "[0-9]+"}, "grammar"),
|
||||
"logprobs": ({"request__return_logprob": True}, "logprobs"),
|
||||
"hidden states": (
|
||||
{
|
||||
"request__return_hidden_states_mode": SimpleNamespace(
|
||||
need_capture=lambda: True
|
||||
)
|
||||
},
|
||||
"return_hidden_states",
|
||||
),
|
||||
"frequency penalty": (
|
||||
{"sampling_params__frequency_penalty": 0.1},
|
||||
"penalties",
|
||||
),
|
||||
"presence penalty": (
|
||||
{"sampling_params__presence_penalty": 0.1},
|
||||
"penalties",
|
||||
),
|
||||
"repetition penalty": (
|
||||
{"sampling_params__repetition_penalty": 1.1},
|
||||
"penalties",
|
||||
),
|
||||
"minimum new tokens": (
|
||||
{"sampling_params__min_new_tokens": 1},
|
||||
"penalties",
|
||||
),
|
||||
"logit bias": (
|
||||
{"sampling_params__logit_bias": {1: 0.5}},
|
||||
"logit_bias",
|
||||
),
|
||||
"custom processor": (
|
||||
{"request__custom_logit_processor": "processor"},
|
||||
"custom logit processors",
|
||||
),
|
||||
"public LoRA": ({"request__lora_id": "adapter"}, "LoRA"),
|
||||
}
|
||||
|
||||
for name, (overrides, expected) in cases.items():
|
||||
with self.subTest(name=name):
|
||||
error = validate_uno_request(_make_request(**overrides))
|
||||
self.assertIsNotNone(error)
|
||||
self.assertIn(expected, error)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Startup validation for UNO configuration."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from sglang.srt.arg_groups.speculative_hook import _handle_uno
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestUnoTreeConfig(CustomTestCase):
|
||||
def test_unsupported_runtime_modes_are_rejected_at_startup(self):
|
||||
cases = {
|
||||
"deterministic inference": (
|
||||
{"enable_deterministic_inference": True},
|
||||
"enable-deterministic-inference",
|
||||
),
|
||||
"strict thinking": (
|
||||
{"enable_strict_thinking": True},
|
||||
"enable-strict-thinking",
|
||||
),
|
||||
}
|
||||
|
||||
for name, (overrides, expected) in cases.items():
|
||||
with self.subTest(name=name):
|
||||
values = {
|
||||
"device": "cuda",
|
||||
"speculative_draft_model_path": None,
|
||||
"uno_lora_path": "/tmp/uno-lora",
|
||||
"enable_deterministic_inference": False,
|
||||
"enable_strict_thinking": False,
|
||||
}
|
||||
values.update(overrides)
|
||||
server_args = SimpleNamespace(**values)
|
||||
with (
|
||||
patch(
|
||||
"sglang.srt.arg_groups.speculative_hook.resolving_view",
|
||||
side_effect=lambda args: args,
|
||||
),
|
||||
self.assertRaisesRegex(ValueError, expected),
|
||||
):
|
||||
_handle_uno(server_args)
|
||||
|
||||
def test_parent_list_overflow_is_rejected_at_startup(self):
|
||||
"""An invalid tree must not survive startup and crash on first decode."""
|
||||
|
||||
server_args = SimpleNamespace(
|
||||
device="cuda",
|
||||
enable_deterministic_inference=False,
|
||||
enable_strict_thinking=False,
|
||||
speculative_draft_model_path=None,
|
||||
uno_lora_path="/tmp/uno-lora",
|
||||
speculative_num_draft_tokens=8,
|
||||
speculative_num_steps=3,
|
||||
speculative_eagle_topk=2,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sglang.srt.arg_groups.speculative_hook.resolving_view",
|
||||
side_effect=lambda args: args,
|
||||
),
|
||||
patch("sglang.srt.arg_groups.speculative_hook.declare_resolution"),
|
||||
self.assertRaisesRegex(ValueError, "parent-list ABI"),
|
||||
):
|
||||
_handle_uno(server_args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,105 @@
|
||||
"""CPU contracts for UNO tree compact target sampling."""
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.speculative.eagle_utils import (
|
||||
_can_use_sparse_uno_tree_target_sampling,
|
||||
)
|
||||
from sglang.srt.speculative.uno_utils import sample_uno_tree_target_tokens
|
||||
from sglang.test.ci.ci_register import register_cpu_ci
|
||||
from sglang.test.test_utils import CustomTestCase
|
||||
|
||||
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
|
||||
|
||||
|
||||
class TestUnoTreeSparseSampling(CustomTestCase):
|
||||
def test_sparse_dispatch_guard(self):
|
||||
sampling_info = SimpleNamespace(
|
||||
sampling_seed=None,
|
||||
need_min_p_sampling=False,
|
||||
)
|
||||
spec_config = SimpleNamespace(
|
||||
speculative_use_rejection_sampling=False,
|
||||
)
|
||||
with (
|
||||
patch("sglang.srt.speculative.eagle_utils._is_cuda", True),
|
||||
patch(
|
||||
"sglang.srt.speculative.eagle_utils.get_spec",
|
||||
return_value=spec_config,
|
||||
),
|
||||
):
|
||||
self.assertTrue(
|
||||
_can_use_sparse_uno_tree_target_sampling(128, sampling_info)
|
||||
)
|
||||
self.assertFalse(
|
||||
_can_use_sparse_uno_tree_target_sampling(None, sampling_info)
|
||||
)
|
||||
self.assertFalse(
|
||||
_can_use_sparse_uno_tree_target_sampling(129, sampling_info)
|
||||
)
|
||||
|
||||
sampling_info.sampling_seed = torch.tensor([1])
|
||||
self.assertFalse(
|
||||
_can_use_sparse_uno_tree_target_sampling(128, sampling_info)
|
||||
)
|
||||
sampling_info.sampling_seed = None
|
||||
sampling_info.need_min_p_sampling = True
|
||||
self.assertFalse(
|
||||
_can_use_sparse_uno_tree_target_sampling(128, sampling_info)
|
||||
)
|
||||
sampling_info.need_min_p_sampling = False
|
||||
spec_config.speculative_use_rejection_sampling = True
|
||||
self.assertFalse(
|
||||
_can_use_sparse_uno_tree_target_sampling(128, sampling_info)
|
||||
)
|
||||
|
||||
def test_targets_are_sampled_from_compact_support(self):
|
||||
support_ids = torch.tensor(
|
||||
[
|
||||
[[10, 11], [20, 21], [30, 31]],
|
||||
[[40, 41], [50, 51], [60, 61]],
|
||||
],
|
||||
dtype=torch.int64,
|
||||
)
|
||||
support_probs = torch.full((2, 3, 2), 0.5)
|
||||
sampled_offsets = torch.tensor(
|
||||
[[0], [1], [0], [1], [0], [1]],
|
||||
dtype=torch.long,
|
||||
)
|
||||
sampling_info = SimpleNamespace()
|
||||
next_token_logits = torch.empty((6, 100))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"sglang.srt.speculative.uno_utils._build_sparse_target_support",
|
||||
return_value=(support_ids, support_probs),
|
||||
) as build_support,
|
||||
patch(
|
||||
"sglang.srt.speculative.uno_utils.fast_sample",
|
||||
return_value=(torch.empty((6, 1)), sampled_offsets),
|
||||
),
|
||||
):
|
||||
targets = sample_uno_tree_target_tokens(
|
||||
next_token_logits=next_token_logits,
|
||||
sampling_info=sampling_info,
|
||||
batch_size=2,
|
||||
verify_width=3,
|
||||
max_top_k=2,
|
||||
)
|
||||
|
||||
self.assertEqual(targets.tolist(), [[10, 21, 30], [41, 50, 61]])
|
||||
build_support.assert_called_once_with(
|
||||
next_token_logits=next_token_logits,
|
||||
sampling_info=sampling_info,
|
||||
batch_size=2,
|
||||
forward_width=3,
|
||||
max_top_k=2,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user