[CI] Migrate LoRA tests to test/registered/lora/ (#15176)

This commit is contained in:
Alison Shao
2025-12-17 13:19:42 -08:00
committed by GitHub
parent 011d8d8970
commit 4128d4f5cb
18 changed files with 81 additions and 82 deletions
+53
View File
@@ -0,0 +1,53 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import multiprocessing as mp
import os
import unittest
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.lora_utils import (
ALL_OTHER_MULTI_LORA_MODELS,
CI_MULTI_LORA_MODELS,
run_lora_multiple_batch_on_model_cases,
)
from sglang.test.test_utils import CustomTestCase, is_in_ci
register_cuda_ci(est_time=82, suite="stage-b-test-small-1-gpu")
class TestLoRA(CustomTestCase):
def test_ci_lora_models(self):
run_lora_multiple_batch_on_model_cases(CI_MULTI_LORA_MODELS)
def test_all_lora_models(self):
if is_in_ci():
return
filtered_models = []
for model_case in ALL_OTHER_MULTI_LORA_MODELS:
if "ONLY_RUN" in os.environ and os.environ["ONLY_RUN"] != model_case.base:
continue
filtered_models.append(model_case)
run_lora_multiple_batch_on_model_cases(filtered_models)
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
unittest.main(warnings="ignore")
+78
View File
@@ -0,0 +1,78 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import multiprocessing as mp
import os
import unittest
from typing import List
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.lora_utils import (
ALL_OTHER_LORA_MODELS,
BACKENDS,
CI_LORA_MODELS,
DEFAULT_PROMPTS,
TORCH_DTYPES,
LoRAModelCase,
run_lora_test_one_by_one,
)
from sglang.test.test_utils import CustomTestCase, is_in_ci
register_cuda_ci(est_time=200, suite="stage-b-test-small-1-gpu")
class TestLoRABackend(CustomTestCase):
def _run_backend_on_model_cases(self, model_cases: List[LoRAModelCase]):
for model_case in model_cases:
# If skip_long_prompt is True, filter out prompts longer than 1000 characters
prompts = (
DEFAULT_PROMPTS
if not model_case.skip_long_prompt
else [p for p in DEFAULT_PROMPTS if len(p) < 1000]
)
for torch_dtype in TORCH_DTYPES:
for backend in BACKENDS:
run_lora_test_one_by_one(
prompts,
model_case,
torch_dtype,
max_new_tokens=32,
backend=backend,
)
def test_ci_lora_models(self):
self._run_backend_on_model_cases(CI_LORA_MODELS)
def test_all_lora_models(self):
if is_in_ci():
return
# Retain ONLY_RUN check here
filtered_models = []
for model_case in ALL_OTHER_LORA_MODELS:
if "ONLY_RUN" in os.environ and os.environ["ONLY_RUN"] != model_case.base:
continue
filtered_models.append(model_case)
self._run_backend_on_model_cases(filtered_models)
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
unittest.main(warnings="ignore")
+157
View File
@@ -0,0 +1,157 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import contextlib
import multiprocessing as mp
import unittest
from typing import Dict, List, Tuple
import torch
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.runners import SRTRunner
from sglang.test.test_utils import CustomTestCase
register_cuda_ci(est_time=224, suite="stage-b-test-small-1-gpu")
PROMPTS = [
"AI is a field of computer science focused on",
"""
### Instruction:
Compose a SQL query that uses the following table: users, and returns the user_id and name of all users whose name that does not have a duplicate in the table.
### Response:
SELECT user_id, name FROM users WHERE name LIKE 'A%';
""",
]
ADAPTERS = [
"faridlazuarda/valadapt-llama-3.1-8B-it-chinese", # target_modules = q, v
"philschmid/code-llama-3-1-8b-text-to-sql-lora", # target_modules = q, k, v, o, gate, up, down
]
BASE_MODEL = "meta-llama/Meta-Llama-3.1-8B-Instruct"
@contextlib.contextmanager
def dynamically_loaded_adapter(runner, lora_path: str, lora_name: str):
"""A context manager to load and automatically unload a LoRA adapter."""
try:
runner.load_lora_adapter(lora_name=lora_name, lora_path=lora_path)
yield
finally:
runner.unload_lora_adapter(lora_name=lora_name)
class TestLoRAEviction(CustomTestCase):
def test_lora_eviction_with_different_target_modules(self):
"""
Test LoRA eviction with different target modules.
This test runs inference against two LoRA adapters in different orders to force eviction behavior, and ensures
that the outputs of the same (adapter, prompt) pair are consistent across runs.
"""
output_history = {}
self._run_test(ADAPTERS, output_history, reverse=False)
self._run_test(ADAPTERS, output_history, reverse=True)
def test_lora_eviction_with_reused_lora_name(self):
"""
Test LoRA eviction with reused LoRA names.
This test runs inference against two LoRA adapters with the same name to ensure that the eviction behavior
works correctly when reusing LoRA names.
"""
output_history = {}
self._run_test(ADAPTERS, output_history, reuse_lora_name=True, repeat=1)
self._run_test(ADAPTERS, output_history, reuse_lora_name=False, repeat=1)
def _run_test(
self,
lora_paths: List[str],
output_history: Dict[Tuple[str, str], str],
reverse: bool = False,
repeat: int = 2,
reuse_lora_name: bool = False,
):
REUSED_LORA_NAME = "lora"
max_new_tokens = 256
torch_dtype = torch.float16
base_path = BASE_MODEL
assert len(lora_paths) >= 2
initial_lora_paths = lora_paths if not reuse_lora_name else None
# Initialize runners
with SRTRunner(
base_path,
torch_dtype=torch_dtype,
model_type="generation",
lora_paths=initial_lora_paths,
max_loras_per_batch=1,
enable_lora=True,
max_lora_rank=256,
# Need to list all lora modules, or "all" might include lora modules without assigning lora weights
# lora_target_modules=["all"],
lora_target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
],
) as srt_runner:
adapter_sequence = lora_paths if not reverse else lora_paths[::-1]
for i in range(repeat):
for j, lora_path in enumerate(adapter_sequence):
print(
f"\n========== Testing LoRA eviction with adapter '{lora_path}' (#{j + 1}/{len(adapter_sequence)}), reuse_lora_name: {reuse_lora_name}, reversed: {reverse}, repeat: {i + 1}/{repeat} ---"
)
lora_name = REUSED_LORA_NAME if reuse_lora_name else lora_path
context = (
dynamically_loaded_adapter(srt_runner, lora_path, lora_name)
if reuse_lora_name
else contextlib.nullcontext()
)
with context:
for prompt in PROMPTS:
print("\nprompt:\n", prompt)
srt_outputs = srt_runner.forward(
[prompt],
max_new_tokens=max_new_tokens,
lora_paths=[lora_name],
)
output = srt_outputs.output_strs[0].strip()
print("\noutput:\n", output)
prev_output = output_history.get((lora_path, prompt))
if prev_output is not None:
self.assertEqual(
prev_output,
output,
f"Output mismatch for adapter {lora_path} and prompt '{prompt}' on repeat {j + 1}, previous: '{prev_output}', current: '{output}'.",
)
else:
output_history[(lora_path, prompt)] = output
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
unittest.main(warnings="ignore")
@@ -0,0 +1,193 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Unit tests for LoRA eviction policies.
Tests LRU and FIFO eviction behavior.
"""
import unittest
from sglang.srt.lora.eviction_policy import get_eviction_policy
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(est_time=200, suite="nightly-1-gpu", nightly=True)
class TestLoRAEvictionPolicy(unittest.TestCase):
"""Unit tests for LoRA eviction policies."""
def _test_eviction_policy(
self, policy_name, access_sequence, candidates, expected_victim
):
"""
Helper to test eviction policy with given access pattern.
Args:
policy_name: Name of eviction policy ("lru" or "fifo")
access_sequence: List of adapter IDs in access order
candidates: Set of adapter IDs that can be evicted
expected_victim: Expected adapter ID to be evicted
"""
policy = get_eviction_policy(policy_name)
# Simulate access pattern
for adapter_id in access_sequence:
policy.mark_used(adapter_id)
# Select victim from candidates
victim = policy.select_victim(candidates)
self.assertEqual(
victim,
expected_victim,
f"{policy_name.upper()}: Expected {expected_victim}, got {victim}",
)
def test_lru_basic(self):
"""Test LRU selects least recently used adapter."""
self._test_eviction_policy(
"lru",
access_sequence=["lora1", "lora2", "lora3", "lora4"],
candidates={"lora1", "lora2", "lora3", "lora4"},
expected_victim="lora1",
)
def test_lru_with_reuse(self):
"""Test LRU updates order on reuse."""
self._test_eviction_policy(
"lru",
access_sequence=["lora1", "lora2", "lora3", "lora4", "lora1"],
candidates={"lora1", "lora2", "lora3", "lora4"},
expected_victim="lora2",
)
def test_lru_multiple_reuse(self):
"""Test LRU with multiple reuses."""
self._test_eviction_policy(
"lru",
access_sequence=["lora1", "lora2", "lora3", "lora1", "lora2"],
candidates={"lora1", "lora2", "lora3"},
expected_victim="lora3",
)
def test_lru_with_subset_candidates(self):
"""Test LRU with subset of candidates."""
self._test_eviction_policy(
"lru",
access_sequence=["lora1", "lora2", "lora3", "lora4"],
candidates={"lora2", "lora3", "lora4"},
expected_victim="lora2",
)
def test_lru_base_model_evicted_last(self):
"""Test LRU evicts LoRA adapters before base model (None)."""
self._test_eviction_policy(
"lru",
access_sequence=["lora1", "lora2", "lora3"],
candidates={None, "lora1", "lora2", "lora3"},
expected_victim="lora1",
)
def test_fifo_basic(self):
"""Test FIFO selects first inserted adapter."""
self._test_eviction_policy(
"fifo",
access_sequence=["lora1", "lora2", "lora3", "lora4"],
candidates={"lora1", "lora2", "lora3", "lora4"},
expected_victim="lora1",
)
def test_fifo_ignores_reuse(self):
"""Test FIFO ignores reuse."""
self._test_eviction_policy(
"fifo",
access_sequence=[
"lora1",
"lora2",
"lora3",
"lora4",
"lora4",
"lora3",
"lora2",
"lora1",
],
candidates={"lora1", "lora2", "lora3", "lora4"},
expected_victim="lora1",
)
def test_fifo_with_subset_candidates(self):
"""Test FIFO with subset of candidates."""
self._test_eviction_policy(
"fifo",
access_sequence=["lora1", "lora2", "lora3", "lora4"],
candidates={"lora2", "lora3", "lora4"},
expected_victim="lora2",
)
def test_fifo_base_model_evicted_last(self):
"""Test FIFO evicts LoRA adapters before base model (None)."""
self._test_eviction_policy(
"fifo",
access_sequence=["lora1", "lora2", "lora3"],
candidates={None, "lora1", "lora2", "lora3"},
expected_victim="lora1",
)
def test_policy_remove(self):
"""Test that remove() correctly updates internal state."""
lru = get_eviction_policy("lru")
lru.mark_used("lora1")
lru.mark_used("lora2")
lru.mark_used("lora3")
# Remove lora1, so lora2 becomes LRU
lru.remove("lora1")
victim = lru.select_victim({"lora1", "lora2", "lora3"})
self.assertEqual(victim, "lora2")
def test_eviction_policy_factory(self):
"""Test eviction policy factory function."""
# Test valid policies
lru = get_eviction_policy("lru")
fifo = get_eviction_policy("fifo")
self.assertIsNotNone(lru)
self.assertIsNotNone(fifo)
# Test invalid policy
with self.assertRaises(ValueError):
get_eviction_policy("invalid_policy")
def test_lru_vs_fifo_behavior(self):
"""Test that LRU and FIFO behave differently."""
access_sequence = ["lora1", "lora2", "lora3", "lora1"]
candidates = {"lora1", "lora2", "lora3"}
lru = get_eviction_policy("lru")
for adapter_id in access_sequence:
lru.mark_used(adapter_id)
lru_victim = lru.select_victim(candidates)
fifo = get_eviction_policy("fifo")
for adapter_id in access_sequence:
fifo.mark_used(adapter_id)
fifo_victim = fifo.select_victim(candidates)
self.assertNotEqual(lru_victim, fifo_victim)
self.assertEqual(lru_victim, "lora2")
self.assertEqual(fifo_victim, "lora1")
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -0,0 +1,548 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Test to compare log probabilities between HuggingFace+LoRA and SGLang+LoRA.
This test:
1. Runs SGLang with LoRA and collects log probabilities
2. Runs HuggingFace with LoRA and collects log probabilities
3. Compares the differences (max and mean) between the two implementations
4. Uses unittest framework for easy integration with test suites
Usage:
python test_lora_hf_sgl_logprob_diff.py
or
python -m unittest test_lora_hf_sgl_logprob_diff
"""
import multiprocessing as mp
import unittest
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import torch
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.runners import HFRunner, SRTRunner
register_cuda_ci(
est_time=300,
suite="nightly-1-gpu",
nightly=True,
disabled="Temporarily disabled, will be fixed later",
)
from sglang.test.test_utils import DEFAULT_PORT_FOR_SRT_TEST_RUNNER, CustomTestCase
# Test configuration constants
LORA_BACKEND = "triton"
DISABLE_CUDA_GRAPH = False
LORA_TARGET_MODULES = None
LOGPROB_THRESHOLD = 1e-01
# Default test prompts
DEFAULT_TEST_PROMPTS = [
"SGL is a",
"AI is a field of computer science focused on",
"Computer science is the study of",
"Write a short story.",
"What are the main components of a computer?",
]
# Formatting constants
DIVIDER_WIDTH = 80
SECTION_CHAR = "="
SUBSECTION_CHAR = "-"
def print_section_header(title: str):
"""Print a major section header."""
print("\n" + SECTION_CHAR * DIVIDER_WIDTH)
print(title)
print(SECTION_CHAR * DIVIDER_WIDTH)
def print_subsection_header(title: str):
"""Print a subsection header."""
print(f"\n{SUBSECTION_CHAR * 40}")
print(f"{title}")
print(SUBSECTION_CHAR * 40)
def print_config_info(title: str, config: Dict[str, Any]):
"""Print configuration information in a consistent format."""
print_section_header(title)
for key, value in config.items():
print(f" {key}: {value}")
def compare_logprobs_for_type(
sglang_logprobs: torch.Tensor, hf_logprobs: torch.Tensor, logprob_type: str
) -> Dict[str, Any]:
"""
Compare logprobs for a specific type (prefill or decode).
Args:
sglang_logprobs: SGLang log probabilities
hf_logprobs: HuggingFace log probabilities
logprob_type: Type of logprobs ("prefill" or "decode")
Returns:
Dictionary containing comparison statistics
"""
diff = torch.abs(sglang_logprobs - hf_logprobs)
max_diff = torch.max(diff).item()
mean_diff = torch.mean(diff).item()
shape = list(sglang_logprobs.shape)
matches_threshold = max_diff < LOGPROB_THRESHOLD
return {
"max_diff": max_diff,
"mean_diff": mean_diff,
"shape": shape,
"matches_threshold": matches_threshold,
"type": logprob_type,
}
def print_logprob_comparison(comparison: Dict[str, Any]):
"""Print logprob comparison results in a consistent format."""
logprob_type = comparison["type"].capitalize()
print(f"\n{logprob_type} logprobs:")
print(f" Shape: {comparison['shape']}")
print(f" Max difference: {comparison['max_diff']:.6e}")
print(f" Mean difference: {comparison['mean_diff']:.6e}")
status = "PASS" if comparison["matches_threshold"] else "FAIL"
print(f" Status: {status} (threshold: {LOGPROB_THRESHOLD:.0e})")
def compare_output_strings(
sglang_output: str, hf_output: str, max_display_len: int = 200
) -> Dict[str, Any]:
"""
Compare output strings between SGLang and HuggingFace.
Args:
sglang_output: SGLang generated text
hf_output: HuggingFace generated text
max_display_len: Maximum length for display
Returns:
Dictionary containing comparison results
"""
outputs_match = sglang_output.strip() == hf_output.strip()
# Truncate for display if needed
sglang_display = (
sglang_output[:max_display_len]
if len(sglang_output) > max_display_len
else sglang_output
)
hf_display = (
hf_output[:max_display_len] if len(hf_output) > max_display_len else hf_output
)
return {
"match": outputs_match,
"sglang_output": sglang_output,
"hf_output": hf_output,
"sglang_display": sglang_display,
"hf_display": hf_display,
}
def print_output_comparison(comparison: Dict[str, Any]):
"""Print output string comparison in a consistent format."""
print(f"\nOutput strings:")
status = "MATCH" if comparison["match"] else "DIFFER"
print(f" Status: {status}")
print(f" SGLang: {comparison['sglang_display']}")
print(f" HuggingFace: {comparison['hf_display']}")
def prepare_lora_paths_per_prompt(
lora_paths: List[str], num_prompts: int
) -> List[Optional[str]]:
"""
Prepare LoRA paths for each prompt by cycling through available LoRAs.
Args:
lora_paths: List of available LoRA adapter paths
num_prompts: Number of prompts to generate LoRA paths for
Returns:
List of LoRA paths (one per prompt), or None values if no LoRAs
"""
if not lora_paths:
return [None] * num_prompts
return [lora_paths[i % len(lora_paths)] for i in range(num_prompts)]
def run_sglang_with_lora(
model_path: str,
lora_paths: List[str],
prompts: List[str],
max_new_tokens: int,
torch_dtype: torch.dtype,
lora_backend: str,
port: int,
disable_cuda_graph: bool,
lora_target_modules: Optional[List[str]],
tp_size: int,
) -> Dict[str, Any]:
"""Run SGLang with LoRA and return log probabilities."""
config = {
"Model": model_path,
"LoRA paths": lora_paths,
"LoRA backend": lora_backend,
"Disable CUDA graph": disable_cuda_graph,
"Port": port,
"Number of prompts": len(prompts),
"Tensor parallel size": tp_size,
}
print_config_info("Running SGLang with LoRA", config)
lora_paths_per_prompt = prepare_lora_paths_per_prompt(lora_paths, len(prompts))
with SRTRunner(
model_path,
torch_dtype=torch_dtype,
model_type="generation",
tp_size=tp_size,
lora_paths=lora_paths,
max_loras_per_batch=len(lora_paths) if lora_paths else 1,
lora_backend=lora_backend,
disable_cuda_graph=disable_cuda_graph,
disable_radix_cache=True,
port=port,
mem_fraction_static=0.88,
lora_target_modules=lora_target_modules,
) as srt_runner:
srt_outputs = srt_runner.forward(
prompts,
max_new_tokens=max_new_tokens,
lora_paths=lora_paths_per_prompt,
)
return {
"top_input_logprobs": srt_outputs.top_input_logprobs,
"top_output_logprobs": srt_outputs.top_output_logprobs,
"output_strs": srt_outputs.output_strs,
"lora_paths": lora_paths_per_prompt,
}
def run_hf_with_lora(
model_path: str,
lora_paths: List[str],
prompts: List[str],
max_new_tokens: int,
torch_dtype: torch.dtype,
) -> Dict[str, Any]:
"""Run HuggingFace with LoRA and return log probabilities."""
config = {
"Model": model_path,
"LoRA paths": lora_paths,
"Number of prompts": len(prompts),
}
print_config_info("Running HuggingFace with LoRA", config)
lora_paths_per_prompt = prepare_lora_paths_per_prompt(lora_paths, len(prompts))
with HFRunner(
model_path,
torch_dtype=torch_dtype,
model_type="generation",
patch_model_do_sample_false=True,
) as hf_runner:
hf_outputs = hf_runner.forward(
prompts,
max_new_tokens=max_new_tokens,
lora_paths=lora_paths_per_prompt,
)
return {
"top_input_logprobs": hf_outputs.top_input_logprobs,
"top_output_logprobs": hf_outputs.top_output_logprobs,
"output_strs": hf_outputs.output_strs,
"lora_paths": lora_paths_per_prompt,
}
def compare_single_prompt(
prompt_idx: int,
sglang_data: Dict[str, Any],
hf_data: Dict[str, Any],
) -> Dict[str, Any]:
"""
Compare logprobs and outputs for a single prompt.
Args:
prompt_idx: Index of the prompt being compared
sglang_data: SGLang results data
hf_data: HuggingFace results data
Returns:
Dictionary containing all comparison results
"""
print_subsection_header(f"Prompt {prompt_idx + 1}")
print(f"LoRA adapter: {sglang_data['lora_paths'][prompt_idx]}")
result = {
"prompt_idx": prompt_idx,
"lora_path": sglang_data["lora_paths"][prompt_idx],
}
# Compare prefill (input) logprobs
sglang_prefill = torch.tensor(sglang_data["top_input_logprobs"][prompt_idx])
hf_prefill = torch.tensor(hf_data["top_input_logprobs"][prompt_idx])
prefill_comparison = compare_logprobs_for_type(
sglang_prefill, hf_prefill, "prefill"
)
print_logprob_comparison(prefill_comparison)
# Store prefill results
result["prefill_max_diff"] = prefill_comparison["max_diff"]
result["prefill_mean_diff"] = prefill_comparison["mean_diff"]
result["prefill_shape"] = prefill_comparison["shape"]
result["prefill_logprob_match"] = prefill_comparison["matches_threshold"]
# Compare decode (output) logprobs
sglang_decode = torch.tensor(sglang_data["top_output_logprobs"][prompt_idx])
hf_decode = torch.tensor(hf_data["top_output_logprobs"][prompt_idx])
decode_comparison = compare_logprobs_for_type(sglang_decode, hf_decode, "decode")
print_logprob_comparison(decode_comparison)
# Store decode results
result["decode_max_diff"] = decode_comparison["max_diff"]
result["decode_mean_diff"] = decode_comparison["mean_diff"]
result["decode_shape"] = decode_comparison["shape"]
result["decode_logprob_match"] = decode_comparison["matches_threshold"]
# Overall logprob match
result["overall_logprob_match"] = (
prefill_comparison["matches_threshold"]
and decode_comparison["matches_threshold"]
)
# Compare output strings
sglang_output = sglang_data["output_strs"][prompt_idx]
hf_output = hf_data["output_strs"][prompt_idx]
output_comparison = compare_output_strings(sglang_output, hf_output)
print_output_comparison(output_comparison)
# Store output results
result["outputs_match"] = output_comparison["match"]
result["sglang_output"] = output_comparison["sglang_output"]
result["hf_output"] = output_comparison["hf_output"]
return result
def print_overall_statistics(results: List[Dict[str, Any]]):
"""Print overall statistics across all prompts."""
print_section_header("Overall Statistics")
# Gather statistics
prefill_max_diffs = [r["prefill_max_diff"] for r in results]
prefill_mean_diffs = [r["prefill_mean_diff"] for r in results]
decode_max_diffs = [r["decode_max_diff"] for r in results]
decode_mean_diffs = [r["decode_mean_diff"] for r in results]
# Print logprob statistics
print("\nLogprob Differences:")
print(f" Prefill:")
print(f" Max of max: {max(prefill_max_diffs):.6e}")
print(f" Mean of max: {np.mean(prefill_max_diffs):.6e}")
print(f" Mean of mean: {np.mean(prefill_mean_diffs):.6e}")
print(f" Decode:")
print(f" Max of max: {max(decode_max_diffs):.6e}")
print(f" Mean of max: {np.mean(decode_max_diffs):.6e}")
print(f" Mean of mean: {np.mean(decode_mean_diffs):.6e}")
# Print match statistics
num_prompts = len(results)
logprob_match_count = sum(r["overall_logprob_match"] for r in results)
prefill_match_count = sum(r["prefill_logprob_match"] for r in results)
decode_match_count = sum(r["decode_logprob_match"] for r in results)
outputs_match_count = sum(r["outputs_match"] for r in results)
print(f"\nLogprob Statistics (threshold: {LOGPROB_THRESHOLD:.0e}):")
overall_status = "PASSED" if logprob_match_count == num_prompts else "FAILED"
print(f" Overall logprob: {logprob_match_count}/{num_prompts} {overall_status}")
print(f" Prefill logprob: {prefill_match_count}/{num_prompts}")
print(f" Decode logprob: {decode_match_count}/{num_prompts}")
print(f"\nString Statistics:")
print(f" Output strings: {outputs_match_count}/{num_prompts}")
# Return overall stats for saving
return {
"logprob_differences": {
"prefill": {
"max_of_max_diffs": max(prefill_max_diffs),
"mean_of_max_diffs": float(np.mean(prefill_max_diffs)),
"mean_of_mean_diffs": float(np.mean(prefill_mean_diffs)),
},
"decode": {
"max_of_max_diffs": max(decode_max_diffs),
"mean_of_max_diffs": float(np.mean(decode_max_diffs)),
"mean_of_mean_diffs": float(np.mean(decode_mean_diffs)),
},
},
"match_statistics": {
"overall_logprob_match_rate": logprob_match_count / num_prompts,
"prefill_logprob_match_rate": prefill_match_count / num_prompts,
"decode_logprob_match_rate": decode_match_count / num_prompts,
"outputs_match_rate": outputs_match_count / num_prompts,
},
}
def compare_logprobs(
sglang_logprobs: Dict[str, Any], hf_logprobs: Dict[str, Any]
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""Compare log probabilities and compute statistics."""
print_section_header("Comparing Log Probabilities")
results = []
num_prompts = len(sglang_logprobs["top_input_logprobs"])
for i in range(num_prompts):
result = compare_single_prompt(i, sglang_logprobs, hf_logprobs)
results.append(result)
overall_stats = print_overall_statistics(results)
return results, overall_stats
class TestLoRAHFSGLLogprobDifference(CustomTestCase):
"""
Test case to compare log probabilities between HuggingFace+LoRA and SGLang+LoRA.
"""
def _run_comparison_test(
self,
model_path: str,
lora_paths: List[str],
prompts: List[str],
max_new_tokens: int = 32,
torch_dtype: torch.dtype = torch.float16,
lora_backend: str = LORA_BACKEND,
port: int = DEFAULT_PORT_FOR_SRT_TEST_RUNNER,
disable_cuda_graph: bool = DISABLE_CUDA_GRAPH,
lora_target_modules: Optional[List[str]] = LORA_TARGET_MODULES,
tp_size: int = 1,
):
"""
Run comparison test between SGLang and HuggingFace with LoRA.
"""
print_section_header(f"Testing {model_path} with LoRA adapters")
# Step 1: Run SGLang with LoRA
sglang_logprobs = run_sglang_with_lora(
model_path=model_path,
lora_paths=lora_paths,
prompts=prompts,
max_new_tokens=max_new_tokens,
torch_dtype=torch_dtype,
lora_backend=lora_backend,
port=port,
disable_cuda_graph=disable_cuda_graph,
lora_target_modules=lora_target_modules,
tp_size=tp_size,
)
# Clear GPU memory
print("\nClearing GPU memory...")
torch.cuda.empty_cache()
# Step 2: Run HuggingFace with LoRA
hf_logprobs = run_hf_with_lora(
model_path=model_path,
lora_paths=lora_paths,
prompts=prompts,
max_new_tokens=max_new_tokens,
torch_dtype=torch_dtype,
)
# Step 3: Compare log probabilities
results, overall_stats = compare_logprobs(sglang_logprobs, hf_logprobs)
# Assert that all prompts pass the threshold
for result in results:
self.assertTrue(
result["prefill_logprob_match"],
f"Prefill logprob mismatch for prompt {result['prompt_idx']} "
f"(max_diff={result['prefill_max_diff']:.6e}, threshold={LOGPROB_THRESHOLD:.0e})",
)
self.assertTrue(
result["decode_logprob_match"],
f"Decode logprob mismatch for prompt {result['prompt_idx']} "
f"(max_diff={result['decode_max_diff']:.6e}, threshold={LOGPROB_THRESHOLD:.0e})",
)
print_section_header("Test completed successfully!")
return results, overall_stats
def test_lora_logprob_comparison_basic(self):
"""
Basic test comparing HF and SGLang LoRA logprobs with small model.
"""
model_path = "meta-llama/Llama-2-7b-hf"
lora_paths = ["yushengsu/sglang_lora_logprob_diff_without_tuning"]
prompts = DEFAULT_TEST_PROMPTS[:2] # Use fewer prompts for faster testing
self._run_comparison_test(
model_path=model_path,
lora_paths=lora_paths,
prompts=prompts,
max_new_tokens=32,
)
def test_lora_logprob_comparison_full(self):
"""
Full test comparing HF and SGLang LoRA logprobs with all prompts.
"""
model_path = "meta-llama/Llama-2-7b-hf"
lora_paths = ["yushengsu/sglang_lora_logprob_diff_without_tuning"]
prompts = DEFAULT_TEST_PROMPTS
self._run_comparison_test(
model_path=model_path,
lora_paths=lora_paths,
prompts=prompts,
max_new_tokens=32,
)
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
try:
unittest.main(warnings="ignore", verbosity=2)
finally:
# Final cleanup
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
@@ -0,0 +1,330 @@
"""
Unit tests for OpenAI-compatible LoRA API support.
Tests the model parameter parsing and LoRA adapter resolution logic
that enables OpenAI-compatible LoRA adapter selection.
"""
import unittest
from unittest.mock import MagicMock
from sglang.srt.entrypoints.openai.serving_base import OpenAIServingBase
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(est_time=30, suite="nightly-1-gpu", nightly=True)
from sglang.srt.server_args import ServerArgs
class MockTokenizerManager:
"""Mock TokenizerManager for testing."""
def __init__(self, enable_lora=False):
self.server_args = MagicMock(spec=ServerArgs)
self.server_args.enable_lora = enable_lora
self.server_args.tokenizer_metrics_allowed_custom_labels = None
class ConcreteServingBase(OpenAIServingBase):
"""Concrete implementation for testing abstract base class."""
def _request_id_prefix(self) -> str:
return "test-"
def _convert_to_internal_request(self, request, raw_request=None):
pass
def _validate_request(self, request):
pass
class TestParseModelParameter(unittest.TestCase):
"""Test _parse_model_parameter method."""
def setUp(self):
self.tokenizer_manager = MockTokenizerManager(enable_lora=True)
self.serving = ConcreteServingBase(self.tokenizer_manager)
def test_model_without_adapter(self):
"""Test parsing model without adapter returns None for adapter."""
base_model, adapter = self.serving._parse_model_parameter("llama-3.1-8B")
self.assertEqual(base_model, "llama-3.1-8B")
self.assertIsNone(adapter)
def test_model_with_adapter(self):
"""Test parsing model with adapter extracts both parts."""
base_model, adapter = self.serving._parse_model_parameter(
"llama-3.1-8B:sql-expert"
)
self.assertEqual(base_model, "llama-3.1-8B")
self.assertEqual(adapter, "sql-expert")
def test_model_with_path_and_adapter(self):
"""Test parsing model path with slashes and adapter."""
base_model, adapter = self.serving._parse_model_parameter(
"meta-llama/Llama-3.1-8B-Instruct:adapter-name"
)
self.assertEqual(base_model, "meta-llama/Llama-3.1-8B-Instruct")
self.assertEqual(adapter, "adapter-name")
def test_model_with_multiple_colons(self):
"""Test that only first colon is used for splitting."""
base_model, adapter = self.serving._parse_model_parameter("model:adapter:extra")
self.assertEqual(base_model, "model")
self.assertEqual(adapter, "adapter:extra")
def test_model_with_whitespace(self):
"""Test that whitespace is stripped from both parts."""
base_model, adapter = self.serving._parse_model_parameter(
" model-name : adapter-name "
)
self.assertEqual(base_model, "model-name")
self.assertEqual(adapter, "adapter-name")
def test_model_with_empty_adapter(self):
"""Test model ending with colon returns None for adapter."""
base_model, adapter = self.serving._parse_model_parameter("model-name:")
self.assertEqual(base_model, "model-name")
self.assertIsNone(adapter)
def test_model_with_only_spaces_after_colon(self):
"""Test model with only whitespace after colon returns None for adapter."""
base_model, adapter = self.serving._parse_model_parameter("model-name: ")
self.assertEqual(base_model, "model-name")
self.assertIsNone(adapter)
class TestResolveLoraPath(unittest.TestCase):
"""Test _resolve_lora_path method."""
def setUp(self):
self.tokenizer_manager = MockTokenizerManager(enable_lora=True)
self.serving = ConcreteServingBase(self.tokenizer_manager)
def test_no_adapter_specified(self):
"""Test when neither model nor explicit lora_path has adapter."""
result = self.serving._resolve_lora_path("model-name", None)
self.assertIsNone(result)
def test_adapter_in_model_only(self):
"""Test adapter from model parameter when no explicit path."""
result = self.serving._resolve_lora_path("model:sql-expert", None)
self.assertEqual(result, "sql-expert")
def test_adapter_in_explicit_only(self):
"""Test adapter from explicit lora_path when not in model."""
result = self.serving._resolve_lora_path("model-name", "python-expert")
self.assertEqual(result, "python-expert")
def test_model_parameter_takes_precedence(self):
"""Test model parameter adapter takes precedence over explicit."""
result = self.serving._resolve_lora_path("model:sql-expert", "python-expert")
self.assertEqual(result, "sql-expert")
def test_with_list_explicit_lora_path(self):
"""Test that explicit list is returned when no model adapter."""
explicit = ["adapter1", "adapter2", None]
result = self.serving._resolve_lora_path("model-name", explicit)
self.assertEqual(result, explicit)
def test_model_adapter_overrides_list(self):
"""Test model adapter overrides even when explicit is a list."""
result = self.serving._resolve_lora_path(
"model:sql-expert", ["adapter1", "adapter2"]
)
self.assertEqual(result, "sql-expert")
def test_complex_model_name_with_adapter(self):
"""Test resolution with complex model name."""
result = self.serving._resolve_lora_path(
"org/model-v2.1:adapter-name", "other-adapter"
)
self.assertEqual(result, "adapter-name")
class TestValidateLoraEnabled(unittest.TestCase):
"""Test _validate_lora_enabled method."""
def test_validation_passes_when_lora_enabled(self):
"""Test validation passes when LoRA is enabled."""
tokenizer_manager = MockTokenizerManager(enable_lora=True)
serving = ConcreteServingBase(tokenizer_manager)
# Should not raise
try:
serving._validate_lora_enabled("sql-expert")
except ValueError:
self.fail("_validate_lora_enabled raised ValueError unexpectedly")
def test_validation_fails_when_lora_disabled(self):
"""Test validation fails with helpful message when LoRA is disabled."""
tokenizer_manager = MockTokenizerManager(enable_lora=False)
serving = ConcreteServingBase(tokenizer_manager)
with self.assertRaises(ValueError) as context:
serving._validate_lora_enabled("sql-expert")
error_message = str(context.exception)
self.assertIn("sql-expert", error_message)
self.assertIn("--enable-lora", error_message)
self.assertIn("not enabled", error_message)
def test_validation_error_mentions_adapter_name(self):
"""Test that error message includes the requested adapter name."""
tokenizer_manager = MockTokenizerManager(enable_lora=False)
serving = ConcreteServingBase(tokenizer_manager)
with self.assertRaises(ValueError) as context:
serving._validate_lora_enabled("my-custom-adapter")
self.assertIn("my-custom-adapter", str(context.exception))
class TestIntegrationScenarios(unittest.TestCase):
"""Integration tests for common usage scenarios."""
def setUp(self):
self.tokenizer_manager = MockTokenizerManager(enable_lora=True)
self.serving = ConcreteServingBase(self.tokenizer_manager)
def test_openai_compatible_usage(self):
"""Test typical OpenAI-compatible usage pattern."""
# User specifies adapter in model parameter
model = "meta-llama/Llama-3.1-8B:sql-expert"
explicit_lora = None
lora_path = self.serving._resolve_lora_path(model, explicit_lora)
self.assertEqual(lora_path, "sql-expert")
# Validation should pass
self.serving._validate_lora_enabled(lora_path)
def test_backward_compatible_usage(self):
"""Test backward-compatible usage with explicit lora_path."""
model = "meta-llama/Llama-3.1-8B"
explicit_lora = "sql-expert"
lora_path = self.serving._resolve_lora_path(model, explicit_lora)
self.assertEqual(lora_path, "sql-expert")
# Validation should pass
self.serving._validate_lora_enabled(lora_path)
def test_base_model_usage(self):
"""Test using base model without any adapter."""
model = "meta-llama/Llama-3.1-8B"
explicit_lora = None
lora_path = self.serving._resolve_lora_path(model, explicit_lora)
self.assertIsNone(lora_path)
# No validation needed when no adapter
def test_batch_request_scenario(self):
"""Test batch request with list of adapters."""
model = "meta-llama/Llama-3.1-8B" # No adapter in model
explicit_lora = ["sql-expert", "python-expert", None]
lora_path = self.serving._resolve_lora_path(model, explicit_lora)
self.assertEqual(lora_path, explicit_lora)
# Validate first adapter in list
if isinstance(lora_path, list) and lora_path[0]:
self.serving._validate_lora_enabled(lora_path[0])
def test_adapter_in_model_overrides_batch_list(self):
"""Test that adapter in model parameter overrides batch list."""
model = "meta-llama/Llama-3.1-8B:preferred-adapter"
explicit_lora = ["adapter1", "adapter2"]
lora_path = self.serving._resolve_lora_path(model, explicit_lora)
self.assertEqual(lora_path, "preferred-adapter")
def test_error_when_lora_not_enabled(self):
"""Test comprehensive error flow when LoRA is not enabled."""
# Setup server without LoRA enabled
tokenizer_manager = MockTokenizerManager(enable_lora=False)
serving = ConcreteServingBase(tokenizer_manager)
# User tries to use adapter
model = "meta-llama/Llama-3.1-8B:sql-expert"
lora_path = serving._resolve_lora_path(model, None)
# Should get helpful error
with self.assertRaises(ValueError) as context:
serving._validate_lora_enabled(lora_path)
error = str(context.exception)
self.assertIn("--enable-lora", error)
self.assertIn("sql-expert", error)
class TestEdgeCases(unittest.TestCase):
"""Test edge cases and error conditions."""
def setUp(self):
self.tokenizer_manager = MockTokenizerManager(enable_lora=True)
self.serving = ConcreteServingBase(self.tokenizer_manager)
def test_empty_string_model(self):
"""Test handling of empty string model."""
base, adapter = self.serving._parse_model_parameter("")
self.assertEqual(base, "")
self.assertIsNone(adapter)
def test_only_colon(self):
"""Test model parameter that is just a colon."""
base, adapter = self.serving._parse_model_parameter(":")
self.assertEqual(base, "")
self.assertIsNone(adapter)
def test_empty_list_lora_path(self):
"""Test validation with empty list doesn't crash."""
lora_path = self.serving._resolve_lora_path("model-name", [])
# Empty list is falsy, so validation won't be called
self.assertEqual(lora_path, [])
def test_list_with_none_first(self):
"""Test validation finds first non-None adapter in list."""
lora_path = self.serving._resolve_lora_path("model-name", [None, "adapter2"])
self.assertEqual(lora_path, [None, "adapter2"])
# In actual usage, validation would find "adapter2"
def test_list_all_none(self):
"""Test validation with list of all None values."""
lora_path = self.serving._resolve_lora_path("model-name", [None, None])
self.assertEqual(lora_path, [None, None])
# In actual usage, no validation would occur (no non-None adapters)
def test_unicode_in_adapter_name(self):
"""Test Unicode characters in adapter name."""
base, adapter = self.serving._parse_model_parameter("model:adapter-名前")
self.assertEqual(base, "model")
self.assertEqual(adapter, "adapter-名前")
def test_special_characters_in_adapter(self):
"""Test special characters in adapter name."""
base, adapter = self.serving._parse_model_parameter("model:adapter_v2.1-final")
self.assertEqual(base, "model")
self.assertEqual(adapter, "adapter_v2.1-final")
def test_none_as_explicit_lora_path(self):
"""Test None as explicit lora_path is handled correctly."""
result = self.serving._resolve_lora_path("model:adapter", None)
self.assertEqual(result, "adapter")
def test_empty_string_as_explicit_lora_path(self):
"""Test empty string as explicit lora_path."""
result = self.serving._resolve_lora_path("model-name", "")
self.assertEqual(result, "")
def test_validation_with_empty_adapter_name(self):
"""Test validation with empty adapter name still raises error."""
tokenizer_manager = MockTokenizerManager(enable_lora=False)
serving = ConcreteServingBase(tokenizer_manager)
with self.assertRaises(ValueError):
serving._validate_lora_enabled("")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,282 @@
"""
End-to-end tests for OpenAI-compatible LoRA adapter usage.
Tests the model:adapter syntax and backward compatibility with explicit lora_path.
Usage:
python3 -m unittest openai_server.features.test_lora_openai_compatible.TestLoRAOpenAICompatible.test_model_adapter_syntax
python3 -m unittest openai_server.features.test_lora_openai_compatible.TestLoRAOpenAICompatible.test_explicit_lora_path
python3 -m unittest openai_server.features.test_lora_openai_compatible.TestLoRAOpenAICompatible.test_priority_model_over_explicit
python3 -m unittest openai_server.features.test_lora_openai_compatible.TestLoRAOpenAICompatible.test_base_model_no_adapter
python3 -m unittest openai_server.features.test_lora_openai_compatible.TestLoRAOpenAICompatible.test_completions_api_with_adapter
python3 -m unittest openai_server.features.test_lora_openai_compatible.TestLoRAOpenAICompatible.test_streaming_with_adapter
python3 -m unittest openai_server.features.test_lora_openai_compatible.TestLoRADisabledError.test_lora_disabled_error
"""
import unittest
import openai
from sglang.test.ci.ci_register import register_cuda_ci
register_cuda_ci(est_time=150, suite="nightly-1-gpu", nightly=True)
from sglang.srt.utils import kill_process_tree
from sglang.test.test_utils import (
DEFAULT_SMALL_MODEL_NAME_FOR_TEST,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST,
CustomTestCase,
popen_launch_server,
)
def get_real_lora_adapter() -> str:
"""Use a real LoRA adapter from Hugging Face."""
return "codelion/Llama-3.2-1B-Instruct-tool-calling-lora"
def setup_class(cls, enable_lora=True):
"""Setup test class with LoRA-enabled server."""
cls.model = DEFAULT_SMALL_MODEL_NAME_FOR_TEST
cls.base_url = DEFAULT_URL_FOR_TEST
# Use real LoRA adapter
cls.lora_adapter_path = get_real_lora_adapter()
other_args = [
"--max-running-requests",
"10",
"--disable-radix-cache", # Disable cache for cleaner tests
]
if enable_lora:
other_args.extend(
[
"--enable-lora",
"--lora-paths",
f"tool_calling={cls.lora_adapter_path}",
]
)
cls.process = popen_launch_server(
cls.model,
cls.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
other_args=other_args,
)
cls.client = openai.Client(api_key="EMPTY", base_url=f"{cls.base_url}/v1")
class TestLoRAOpenAICompatible(CustomTestCase):
"""Test OpenAI-compatible LoRA adapter usage."""
@classmethod
def setUpClass(cls):
setup_class(cls, enable_lora=True)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_model_adapter_syntax(self):
"""Test the new model:adapter syntax works correctly."""
response = self.client.chat.completions.create(
# ← New OpenAI-compatible syntax
model=f"{self.model}:tool_calling",
messages=[{"role": "user", "content": "What tools do you have available?"}],
max_tokens=50,
temperature=0,
)
self.assertIsNotNone(response.choices[0].message.content)
self.assertGreater(len(response.choices[0].message.content), 0)
print(f"Model adapter syntax response: {response.choices[0].message.content}")
def test_explicit_lora_path(self):
"""Test backward compatibility with explicit lora_path via extra_body."""
response = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": "What tools do you have available?"}],
# ← Legacy explicit method
extra_body={"lora_path": "tool_calling"},
max_tokens=50,
temperature=0,
)
self.assertIsNotNone(response.choices[0].message.content)
self.assertGreater(len(response.choices[0].message.content), 0)
print(f"Explicit lora_path response: {response.choices[0].message.content}")
def test_priority_model_over_explicit(self):
"""Test that model:adapter syntax takes precedence over explicit lora_path."""
# This test verifies the priority logic in _resolve_lora_path
response = self.client.chat.completions.create(
# ← Model specifies tool_calling adapter
model=f"{self.model}:tool_calling",
messages=[{"role": "user", "content": "What tools do you have available?"}],
# ← Both specify same adapter
extra_body={"lora_path": "tool_calling"},
max_tokens=50,
temperature=0,
)
# Should use tool_calling adapter (model parameter takes precedence)
self.assertIsNotNone(response.choices[0].message.content)
self.assertGreater(len(response.choices[0].message.content), 0)
print(f"Priority test response: {response.choices[0].message.content}")
def test_base_model_no_adapter(self):
"""Test using base model without any adapter."""
response = self.client.chat.completions.create(
model=self.model, # ← No adapter specified
messages=[{"role": "user", "content": "Hello, how are you?"}],
max_tokens=30,
temperature=0,
)
self.assertIsNotNone(response.choices[0].message.content)
self.assertGreater(len(response.choices[0].message.content), 0)
print(f"Base model response: {response.choices[0].message.content}")
def test_completions_api_with_adapter(self):
"""Test completions API with LoRA adapter."""
response = self.client.completions.create(
model=f"{self.model}:tool_calling", # ← Using model:adapter syntax
prompt="What tools do you have available?",
max_tokens=50,
temperature=0,
)
self.assertIsNotNone(response.choices[0].text)
self.assertGreater(len(response.choices[0].text), 0)
print(f"Completions API response: {response.choices[0].text}")
def test_streaming_with_adapter(self):
"""Test streaming with LoRA adapter."""
stream = self.client.chat.completions.create(
model=f"{self.model}:tool_calling",
messages=[{"role": "user", "content": "What tools do you have available?"}],
max_tokens=50,
temperature=0,
stream=True,
)
collected_content = ""
for chunk in stream:
if chunk.choices[0].delta.content:
collected_content += chunk.choices[0].delta.content
self.assertGreater(len(collected_content), 0)
print(f"Streaming response: {collected_content}")
def test_multiple_adapters(self):
"""Test using different adapters in sequence."""
# Test tool_calling adapter
tool_response = self.client.chat.completions.create(
model=f"{self.model}:tool_calling",
messages=[{"role": "user", "content": "What tools do you have available?"}],
max_tokens=30,
temperature=0,
)
# Test base model without adapter
base_response = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": "Hello, how are you?"}],
max_tokens=30,
temperature=0,
)
self.assertIsNotNone(tool_response.choices[0].message.content)
self.assertIsNotNone(base_response.choices[0].message.content)
print(
f"Tool calling adapter response: {tool_response.choices[0].message.content}"
)
print(f"Base model response: {base_response.choices[0].message.content}")
class TestLoRADisabledError(CustomTestCase):
"""Test error handling when LoRA is disabled."""
@classmethod
def setUpClass(cls):
setup_class(cls, enable_lora=False) # ← LoRA disabled
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_lora_disabled_error(self):
"""Test that using LoRA adapter when LoRA is disabled raises appropriate error."""
with self.assertRaises(openai.APIError) as context:
self.client.chat.completions.create(
model=f"{self.model}:tool_calling", # ← Trying to use adapter
messages=[
{"role": "user", "content": "What tools do you have available?"}
],
max_tokens=50,
)
# Verify the error message contains helpful guidance
error_message = str(context.exception)
self.assertIn("LoRA", error_message)
self.assertIn("not enabled", error_message)
print(f"Expected error message: {error_message}")
class TestLoRAEdgeCases(CustomTestCase):
"""Test edge cases for LoRA adapter usage."""
@classmethod
def setUpClass(cls):
setup_class(cls, enable_lora=True)
@classmethod
def tearDownClass(cls):
kill_process_tree(cls.process.pid)
def test_model_with_colon_no_adapter(self):
"""Test model parameter ending with colon (empty adapter)."""
response = self.client.chat.completions.create(
model=f"{self.model}:", # ← Model ends with colon
messages=[{"role": "user", "content": "Hello!"}],
max_tokens=30,
temperature=0,
)
# Should work as base model (no adapter)
self.assertIsNotNone(response.choices[0].message.content)
print(f"Model with colon response: {response.choices[0].message.content}")
def test_explicit_lora_path_none(self):
"""Test explicit lora_path set to None."""
response = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": "Hello!"}],
extra_body={"lora_path": None}, # ← Explicitly None
max_tokens=30,
temperature=0,
)
# Should work as base model
self.assertIsNotNone(response.choices[0].message.content)
print(
f"Explicit None lora_path response: {response.choices[0].message.content}"
)
def test_invalid_adapter_name(self):
"""Test using non-existent adapter name."""
with self.assertRaises(openai.APIError) as context:
self.client.chat.completions.create(
model=f"{self.model}:nonexistent", # ← Non-existent adapter
messages=[{"role": "user", "content": "Hello!"}],
max_tokens=30,
)
error_message = str(context.exception)
print(f"Invalid adapter error: {error_message}")
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
# Copyright 2023-2025 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import multiprocessing as mp
import unittest
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.lora_utils import (
LoRAAdaptor,
LoRAModelCase,
run_lora_multiple_batch_on_model_cases,
)
register_cuda_ci(est_time=97, suite="nightly-1-gpu", nightly=True)
from sglang.test.test_utils import CustomTestCase
LORA_MODELS_QWEN3 = [
LoRAModelCase(
base="Qwen/Qwen3-4B",
adaptors=[
LoRAAdaptor(
name="nissenj/Qwen3-4B-lora-v2",
prefill_tolerance=3e-1,
),
LoRAAdaptor(
name="y9760210/Qwen3-4B-lora_model",
prefill_tolerance=3e-1,
),
],
max_loras_per_batch=2,
),
]
class TestLoRAQwen3(CustomTestCase):
def test_ci_lora_models(self):
run_lora_multiple_batch_on_model_cases(LORA_MODELS_QWEN3)
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
unittest.main(warnings="ignore")
@@ -0,0 +1,82 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import multiprocessing as mp
import unittest
import torch
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.lora_utils import CI_MULTI_LORA_MODELS, run_lora_test_one_by_one
register_cuda_ci(est_time=200, suite="nightly-1-gpu", nightly=True)
from sglang.test.test_utils import CustomTestCase
PROMPTS = [
"AI is a field of computer science focused on",
"""
### Instruction:
Tell me about llamas and alpacas
### Response:
Llamas are large, long-necked animals with a woolly coat. They have two toes on each foot instead of three like other camelids.
### Question:
What do you know about llamas?
### Answer:
""",
]
class TestLoRARadixCache(CustomTestCase):
def test_lora_radix_cache(self):
# Here we need a model case with multiple adaptors for testing correctness of radix cache
model_case = CI_MULTI_LORA_MODELS[0]
torch_dtype = torch.float16
max_new_tokens = 32
batch_prompts = (
PROMPTS
if not model_case.skip_long_prompt
else [p for p in PROMPTS if len(p) < 1000]
)
# Test lora with radix cache
run_lora_test_one_by_one(
batch_prompts,
model_case,
torch_dtype,
max_new_tokens=max_new_tokens,
disable_radix_cache=False,
test_tag="lora-with-radix-cache",
)
# Test lora without radix cache
run_lora_test_one_by_one(
batch_prompts,
model_case,
torch_dtype,
max_new_tokens=max_new_tokens,
disable_radix_cache=True,
test_tag="lora-without-radix-cache",
)
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
unittest.main(warnings="ignore")
+79
View File
@@ -0,0 +1,79 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import multiprocessing as mp
import os
import unittest
from typing import List
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.lora_utils import (
ALL_OTHER_LORA_MODELS,
CI_LORA_MODELS,
DEFAULT_PROMPTS,
TORCH_DTYPES,
LoRAModelCase,
run_lora_test_one_by_one,
)
from sglang.test.test_utils import CustomTestCase, is_in_ci
register_cuda_ci(est_time=116, suite="stage-b-test-small-2-gpu")
class TestLoRATP(CustomTestCase):
def _run_tp_on_model_cases(self, model_cases: List[LoRAModelCase]):
tp_list = [2] # Define TP sizes to iterate over
for model_case in model_cases:
# If skip_long_prompt is True, filter out prompts longer than 1000 characters
prompts = (
DEFAULT_PROMPTS
if not model_case.skip_long_prompt
else [p for p in DEFAULT_PROMPTS if len(p) < 1000]
)
for tp_size in tp_list:
model_case.tp_size = tp_size
for torch_dtype in TORCH_DTYPES:
run_lora_test_one_by_one(
prompts,
model_case,
torch_dtype,
max_new_tokens=32,
test_tag=f"tp={tp_size}",
)
def test_ci_lora_models(self):
self._run_tp_on_model_cases(CI_LORA_MODELS)
def test_all_lora_models(self):
if is_in_ci():
return
# Retain ONLY_RUN check here
filtered_models = []
for model_case in ALL_OTHER_LORA_MODELS:
if "ONLY_RUN" in os.environ and os.environ["ONLY_RUN"] != model_case.base:
continue
filtered_models.append(model_case)
self._run_tp_on_model_cases(filtered_models)
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
unittest.main(warnings="ignore")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
# Copyright 2023-2024 SGLang Team
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
import multiprocessing as mp
import os
import unittest
from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.lora_utils import (
ALL_OTHER_MULTI_LORA_MODELS,
CI_MULTI_LORA_MODELS,
run_lora_multiple_batch_on_model_cases,
)
from sglang.test.test_utils import CustomTestCase, is_in_ci
register_cuda_ci(est_time=60, suite="stage-b-test-small-1-gpu")
# All prompts are used at once in a batch.
PROMPTS = [
"AI is a field of computer science focused on",
"""
### Instruction:
Tell me about llamas and alpacas
### Response:
Llamas are large, long-necked animals with a woolly coat. They have two toes on each foot instead of three like other camelids.
### Question:
What do you know about llamas?
### Answer:
""",
]
class TestMultiLoRABackend(CustomTestCase):
def test_ci_lora_models(self):
run_lora_multiple_batch_on_model_cases(CI_MULTI_LORA_MODELS)
def test_all_lora_models(self):
if is_in_ci():
return
# Retain ONLY_RUN check here
filtered_models = []
for model_case in ALL_OTHER_MULTI_LORA_MODELS:
if "ONLY_RUN" in os.environ and os.environ["ONLY_RUN"] != model_case.base:
continue
filtered_models.append(model_case)
run_lora_multiple_batch_on_model_cases(filtered_models)
if __name__ == "__main__":
try:
mp.set_start_method("spawn")
except RuntimeError:
pass
unittest.main(warnings="ignore")