diff --git a/.github/workflows/rerun-test.yml b/.github/workflows/rerun-test.yml index e5c8c98c5..658946b8c 100644 --- a/.github/workflows/rerun-test.yml +++ b/.github/workflows/rerun-test.yml @@ -138,6 +138,14 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v5 + # Needed by setuptools-rust to build the bundled native gRPC extension + # (rust/sglang-grpc) when installing the main `sglang` wheel from source. + - name: Install protoc + run: sudo bash scripts/ci/utils/install_protoc.sh + + - name: Install Rust toolchain + run: bash scripts/ci/utils/install_rustup.sh + - name: Install dependencies timeout-minutes: 20 env: diff --git a/python/sglang/jit_kernel/tests/test_dependency.py b/python/sglang/jit_kernel/tests/test_dependency.py deleted file mode 100644 index 9fc981956..000000000 --- a/python/sglang/jit_kernel/tests/test_dependency.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -from sglang.jit_kernel.utils import _REGISTERED_DEPENDENCIES -from sglang.test.ci.ci_register import register_cuda_ci - -register_cuda_ci(est_time=30, suite="stage-b-kernel-unit-1-gpu-large") -register_cuda_ci(est_time=30, suite="nightly-kernel-1-gpu", nightly=True) - - -@pytest.mark.parametrize("name", _REGISTERED_DEPENDENCIES.keys()) -def test_availability(name: str) -> None: - # NOTE: the path resolution should not fail - _REGISTERED_DEPENDENCIES[name]() diff --git a/python/sglang/test/ci/ci_register.py b/python/sglang/test/ci/ci_register.py index 385373510..014c408dd 100644 --- a/python/sglang/test/ci/ci_register.py +++ b/python/sglang/test/ci/ci_register.py @@ -2,7 +2,7 @@ import ast import warnings from dataclasses import dataclass from enum import Enum, auto -from typing import List, Optional +from typing import List, Optional, Tuple __all__ = [ "HWBackend", @@ -87,6 +87,7 @@ class RegistryVisitor(ast.NodeVisitor): def __init__(self, filename: str): self.filename = filename self.registries: list[CIRegistry] = [] + self.has_main_entry: bool = False def _constant_value(self, node: ast.AST) -> object: if isinstance(node, ast.Constant): @@ -180,25 +181,53 @@ class RegistryVisitor(ast.NodeVisitor): disabled=disabled, ) + @staticmethod + def _is_main_block_with_call(stmt: ast.If) -> bool: + """True iff `stmt` is `if __name__ == "__main__":` with a body that + contains at least one call (i.e. actually runs something, not just + `pass`). This is what makes `python3 file.py` execute tests.""" + test = stmt.test + if not isinstance(test, ast.Compare): + return False + if not (isinstance(test.left, ast.Name) and test.left.id == "__name__"): + return False + if len(test.ops) != 1 or not isinstance(test.ops[0], ast.Eq): + return False + if len(test.comparators) != 1: + return False + rhs = test.comparators[0] + if not (isinstance(rhs, ast.Constant) and rhs.value == "__main__"): + return False + for child in ast.walk(ast.Module(body=stmt.body, type_ignores=[])): + if isinstance(child, ast.Call): + return True + return False + def visit_Module(self, node): for stmt in node.body: - if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call): - continue - - cr = self._collect_ci_registry(stmt.value) - if cr is not None: - self.registries.append(cr) + if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call): + cr = self._collect_ci_registry(stmt.value) + if cr is not None: + self.registries.append(cr) + elif isinstance(stmt, ast.If) and self._is_main_block_with_call(stmt): + self.has_main_entry = True self.generic_visit(node) -def ut_parse_one_file(filename: str) -> List[CIRegistry]: +def ut_parse_one_file(filename: str) -> Tuple[List[CIRegistry], bool]: + """Parse a test file and return (registries, has_main_entry). + + `has_main_entry` is True iff the file has `if __name__ == "__main__":` + with a call in its body -- required for `python3 file.py` to actually + run tests (the CI runner's invocation pattern). + """ with open(filename, "r") as f: file_content = f.read() tree = ast.parse(file_content, filename=filename) visitor = RegistryVisitor(filename=filename) visitor.visit(tree) - return visitor.registries + return visitor.registries, visitor.has_main_entry def auto_partition(files: List[CIRegistry], rank: int, size: int) -> List[CIRegistry]: @@ -230,7 +259,7 @@ def auto_partition(files: List[CIRegistry], rank: int, size: int) -> List[CIRegi def collect_tests(files: list[str], sanity_check: bool = True) -> List[CIRegistry]: ci_tests = [] for file in files: - registries = ut_parse_one_file(file) + registries, has_main_entry = ut_parse_one_file(file) if len(registries) == 0: msg = f"No CI registry found in {file}" if sanity_check: @@ -239,6 +268,20 @@ def collect_tests(files: list[str], sanity_check: bool = True) -> List[CIRegistr warnings.warn(msg) continue + # Every file with at least one enabled registry must have an + # executable `if __name__ == "__main__":` block; otherwise + # `python3 file.py -f` (how run_unittest_files invokes tests) + # silently exits and the file shows green without running. + has_enabled = any(r.disabled is None for r in registries) + if sanity_check and has_enabled and not has_main_entry: + raise ValueError( + f'{file}: missing `if __name__ == "__main__":` entry. ' + f"Pytest-style tests in this file will silently skip under " + f"`python3 file.py -f`. Add `unittest.main()` (for " + f"unittest.TestCase) or `sys.exit(pytest.main([__file__, " + f'"-v"]))` (for pytest-style).' + ) + ci_tests.extend(registries) return ci_tests diff --git a/scripts/ci/check_registered_tests.py b/scripts/ci/check_registered_tests.py index 28459a4b8..8f4a910ed 100755 --- a/scripts/ci/check_registered_tests.py +++ b/scripts/ci/check_registered_tests.py @@ -34,7 +34,7 @@ def main() -> int: errors = [] for f in files: try: - registries = ci_register.ut_parse_one_file(f) + registries, _has_main_entry = ci_register.ut_parse_one_file(f) if len(registries) == 0: errors.append(f) except Exception: diff --git a/scripts/ci/utils/ci_coverage_report.py b/scripts/ci/utils/ci_coverage_report.py index 1dc6708d6..717b801fa 100755 --- a/scripts/ci/utils/ci_coverage_report.py +++ b/scripts/ci/utils/ci_coverage_report.py @@ -35,7 +35,7 @@ def collect_all_tests(registered_dir: str) -> list[CIRegistry]: for file in sorted(files): try: - registries = ut_parse_one_file(file) + registries, _ = ut_parse_one_file(file) all_tests.extend(registries) except Exception as e: print(f"Warning: Failed to parse {file}: {e}", file=sys.stderr) diff --git a/test/registered/amd/test_zimage_turbo.py b/test/registered/amd/test_zimage_turbo.py deleted file mode 100644 index 3e5d8c4ea..000000000 --- a/test/registered/amd/test_zimage_turbo.py +++ /dev/null @@ -1,150 +0,0 @@ -"""AMD nightly test for Z-Image-Turbo diffusion model (text-to-image).""" - -import io -import logging -import os - -import pytest - -from sglang.multimodal_gen.test.server.test_server_common import ( # noqa: F401 - DiffusionServerBase, - diffusion_server, -) -from sglang.multimodal_gen.test.server.test_server_utils import ( - ServerContext, - get_generate_fn, -) -from sglang.multimodal_gen.test.server.testcase_configs import ( - DiffusionSamplingParams, - DiffusionServerArgs, - DiffusionTestCase, -) -from sglang.test.ci.ci_register import register_amd_ci - -logger = logging.getLogger(__name__) - -register_amd_ci(est_time=1800, suite="nightly-amd-1-gpu-zimage-turbo", nightly=True) - -AMD_ZIMAGE_CASES = [ - DiffusionTestCase( - "zimage_image_t2i", - DiffusionServerArgs(model_path="Tongyi-MAI/Z-Image-Turbo", modality="image"), - DiffusionSamplingParams( - prompt="Doraemon is eating dorayaki", - output_size="1024x1024", - ), - ), -] - -CLIP_SCORE_THRESHOLD = 0.20 - - -ARTIFACT_DIR = os.environ.get( - "SGLANG_DIFFUSION_ARTIFACT_DIR", "/tmp/diffusion-artifacts" -) - - -def _save_image_and_write_summary( - case_id: str, prompt: str, image_bytes: bytes, clip_score: float | None = None -): - """Save generated image to artifact dir and write summary.""" - ext = "jpg" if image_bytes[:2] == b"\xff\xd8" else "png" - os.makedirs(ARTIFACT_DIR, exist_ok=True) - img_path = os.path.join(ARTIFACT_DIR, f"{case_id}.{ext}") - with open(img_path, "wb") as f: - f.write(image_bytes) - logger.info("Saved image artifact: %s (%d bytes)", img_path, len(image_bytes)) - - summary_file = os.environ.get("GITHUB_STEP_SUMMARY") - if not summary_file: - return - - clip_line = "" - if clip_score is not None: - status = "PASS" if clip_score >= CLIP_SCORE_THRESHOLD else "FAIL" - clip_line = f"| CLIP Score | {clip_score:.4f} ({status}, threshold: {CLIP_SCORE_THRESHOLD}) |\n" - - md = ( - f"### Z-Image-Turbo — `{case_id}`\n\n" - f"| | |\n|---|---|\n" - f"| Prompt | {prompt} |\n" - f"| Size | {len(image_bytes):,} bytes |\n" - f"{clip_line}" - f"| Artifact | `{case_id}.{ext}` (download from Artifacts section above) |\n\n" - ) - - with open(summary_file, "a") as f: - f.write(md) - - -def _compute_clip_score(image_bytes: bytes, prompt: str) -> float | None: - """Compute CLIP cosine similarity between the image and prompt.""" - try: - import torch - from PIL import Image - from transformers import CLIPModel, CLIPProcessor - - model_name = "openai/clip-vit-base-patch32" - processor = CLIPProcessor.from_pretrained(model_name) - model = CLIPModel.from_pretrained(model_name) - model.eval() - - image = Image.open(io.BytesIO(image_bytes)).convert("RGB") - inputs = processor(text=[prompt], images=image, return_tensors="pt") - - with torch.no_grad(): - outputs = model(**inputs) - score = outputs.logits_per_image.item() / 100.0 - - logger.info("CLIP score for '%s': %.4f", prompt, score) - return score - except Exception as e: - logger.warning("CLIP score computation failed: %s", e) - return None - - -class TestZImageTurboAMD(DiffusionServerBase): - """AMD nightly test for Z-Image-Turbo text-to-image generation.""" - - @classmethod - def teardown_class(cls): - try: - super().teardown_class() - except AttributeError: - pass - - @pytest.fixture(params=AMD_ZIMAGE_CASES, ids=lambda c: c.id) - def case(self, request) -> DiffusionTestCase: - return request.param - - def test_diffusion_generation( - self, - case: DiffusionTestCase, - diffusion_server: ServerContext, - ): - generate_fn = get_generate_fn( - model_path=case.server_args.model_path, - modality=case.server_args.modality, - sampling_params=case.sampling_params, - ) - - perf_record, content = self.run_and_collect( - diffusion_server, case.id, generate_fn - ) - - self._validate_and_record(case, perf_record) - self._test_v1_models_endpoint(diffusion_server, case) - - prompt = case.sampling_params.prompt or "" - clip_score = _compute_clip_score(content, prompt) - - if clip_score is not None: - logger.info( - "CLIP score: %.4f (threshold: %.2f)", clip_score, CLIP_SCORE_THRESHOLD - ) - assert clip_score >= CLIP_SCORE_THRESHOLD, ( - f"CLIP score {clip_score:.4f} below threshold {CLIP_SCORE_THRESHOLD} " - f"for prompt '{prompt}'" - ) - - _save_image_and_write_summary(case.id, prompt, content, clip_score) diff --git a/test/registered/debug_utils/comparator/test_preset.py b/test/registered/debug_utils/comparator/test_preset.py index e6b99df65..4ccb84647 100644 --- a/test/registered/debug_utils/comparator/test_preset.py +++ b/test/registered/debug_utils/comparator/test_preset.py @@ -42,3 +42,9 @@ class TestExpandPreset: """Unknown preset name raises ValueError.""" with pytest.raises(ValueError, match="Unknown value for --preset"): expand_preset(["--preset", "nonexistent"], presets=PRESETS) + + +if __name__ == "__main__": + import sys + + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/registered/debug_utils/source_patcher/test_code_patcher.py b/test/registered/debug_utils/source_patcher/test_code_patcher.py index 7d791788d..2106ad6a1 100644 --- a/test/registered/debug_utils/source_patcher/test_code_patcher.py +++ b/test/registered/debug_utils/source_patcher/test_code_patcher.py @@ -254,3 +254,9 @@ class TestCodePatcher: raise RuntimeError("test error") assert obj.greet("world") == "hello world" + + +if __name__ == "__main__": + import sys + + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/registered/debug_utils/source_patcher/test_dumper_integration.py b/test/registered/debug_utils/source_patcher/test_dumper_integration.py index af9d8d077..2b8be98bf 100644 --- a/test/registered/debug_utils/source_patcher/test_dumper_integration.py +++ b/test/registered/debug_utils/source_patcher/test_dumper_integration.py @@ -55,3 +55,11 @@ class TestDumperApplySourcePatches: cls.greet.__code__ = original_code assert obj.greet("world") == "hello world" + + +if __name__ == "__main__": + import sys + + import pytest + + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/registered/debug_utils/source_patcher/test_source_editor.py b/test/registered/debug_utils/source_patcher/test_source_editor.py index 560563dde..fd5334be4 100644 --- a/test/registered/debug_utils/source_patcher/test_source_editor.py +++ b/test/registered/debug_utils/source_patcher/test_source_editor.py @@ -290,3 +290,9 @@ class TestApplyEdits: ] result = apply_edits(source=source, edits=edits) assert result == ("def foo():\n" " x = 1\n" " y = 20\n" " return x\n") + + +if __name__ == "__main__": + import sys + + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/registered/debug_utils/test_dump_comparator.py b/test/registered/debug_utils/test_dump_comparator.py index 8b2d66d54..5d4945189 100644 --- a/test/registered/debug_utils/test_dump_comparator.py +++ b/test/registered/debug_utils/test_dump_comparator.py @@ -1,6 +1,3 @@ -from argparse import Namespace -from pathlib import Path - import pytest import torch @@ -9,9 +6,7 @@ from sglang.srt.debug_utils.dump_comparator import ( _calc_rel_diff, _compute_smaller_dtype, _try_unify_shape, - main, ) -from sglang.srt.debug_utils.dumper import DumperConfig, _Dumper from sglang.test.ci.ci_register import register_cpu_ci register_cpu_ci(est_time=30, suite="stage-a-test-cpu", nightly=True) @@ -59,101 +54,7 @@ class TestComputeSmallerDtype: assert _compute_smaller_dtype(torch.float32, torch.float32) is None -# ----------------------------- Integration tests ----------------------------- +if __name__ == "__main__": + import sys - -def _make_dumper(directory: Path) -> _Dumper: - return _Dumper( - config=DumperConfig( - enable=True, - dir=str(directory), - ) - ) - - -def _create_dumps( - tmp_path: Path, - tensor_names: list[str], - *, - baseline_names: list[str] | None = None, -) -> tuple[Path, Path]: - if baseline_names is None: - baseline_names = tensor_names - - d_baseline: Path = tmp_path / "baseline" - d_target: Path = tmp_path / "target" - d_baseline.mkdir() - d_target.mkdir() - - torch.manual_seed(42) - baseline_tensor: torch.Tensor = torch.randn(10, 10) - target_tensor: torch.Tensor = baseline_tensor + torch.randn(10, 10) * 0.01 - - exp_paths: list[Path] = [] - for d, names, tensor in [ - (d_baseline, baseline_names, baseline_tensor), - (d_target, tensor_names, target_tensor), - ]: - dumper: _Dumper = _make_dumper(d) - for name in names: - dumper.dump(name, tensor) - dumper.step() - exp_paths.append(d / dumper._config.exp_name) - - return exp_paths[0], exp_paths[1] - - -def _make_args( - baseline_path: Path, - target_path: Path, - *, - filter_pattern: str | None = None, -) -> Namespace: - return Namespace( - baseline_path=str(baseline_path), - target_path=str(target_path), - start_step=0, - end_step=1000000, - diff_threshold=1e-3, - filter=filter_pattern, - ) - - -class TestMainBasic: - def test_matching_tensors( - self, tmp_path: Path, capsys: pytest.CaptureFixture - ) -> None: - baseline_path, target_path = _create_dumps(tmp_path, ["tensor_a", "tensor_b"]) - args: Namespace = _make_args(baseline_path, target_path) - - main(args) - - captured: str = capsys.readouterr().out - assert "✅" in captured - - def test_with_filter(self, tmp_path: Path, capsys: pytest.CaptureFixture) -> None: - baseline_path, target_path = _create_dumps(tmp_path, ["tensor_a", "tensor_b"]) - args: Namespace = _make_args( - baseline_path, target_path, filter_pattern="tensor_a" - ) - - main(args) - - captured: str = capsys.readouterr().out - assert "tensor_a" in captured - assert "Check:" in captured - - def test_no_match_skips( - self, tmp_path: Path, capsys: pytest.CaptureFixture - ) -> None: - baseline_path, target_path = _create_dumps( - tmp_path, - ["only_in_target"], - baseline_names=["only_in_baseline"], - ) - args: Namespace = _make_args(baseline_path, target_path) - - main(args) - - captured: str = capsys.readouterr().out - assert "Skip" in captured + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/registered/ops/test_repeat_interleave.py b/test/registered/ops/test_repeat_interleave.py deleted file mode 100644 index ca2c26b91..000000000 --- a/test/registered/ops/test_repeat_interleave.py +++ /dev/null @@ -1,148 +0,0 @@ -import time -from typing import Tuple - -import numpy as np -import pytest -import torch - -from sglang.srt.models.utils import compute_cu_seqlens_from_grid_numpy as cpu_numpy_impl -from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci - -# Ops - Repeat Interleave tests (1-GPU) - - -register_cuda_ci(est_time=7, suite="stage-b-test-1-gpu-small") -register_amd_ci(est_time=75, suite="stage-b-test-1-gpu-small-amd") - - -def torch_ref_impl(grid_thw: torch.Tensor) -> torch.Tensor: - """ - Pure PyTorch implementation of cu_seqlens computation. - Assumes grid_thw is already on the correct device (CPU here). - Shape: [T, 3], columns: [repeat_count, H, W] - """ - cu_seqlens = torch.repeat_interleave( - grid_thw[:, 1] * grid_thw[:, 2], grid_thw[:, 0] - ).cumsum(dim=0) - cu_seqlens = torch.cat( - [ - torch.zeros(1, dtype=torch.int32, device=cu_seqlens.device), - cu_seqlens.to(torch.int32), - ] - ) - return cu_seqlens - - -def benchmark_once(fn, grid_thw, iters: int = 1000): - """ - Run a function `fn` on the same input `grid_thw` for `iters` times - and measure total elapsed time. - """ - start = time.perf_counter() - for _ in range(iters): - out = fn(grid_thw) - end = time.perf_counter() - return (end - start), out - - -# (T, repeat_min, repeat_max) -GRID_TEST_CONFIGS: list[Tuple[int, int, int]] = [ - (16, 1, 4), # small T, small repeat counts - (128, 0, 4), # allow repeat=0 to test edge cases - (512, 1, 8), - (1024, 1, 16), -] - -NUM_CASES_PER_CONFIG = 10 - - -def _generate_random_grid(T: int, repeat_min: int, repeat_max: int) -> torch.Tensor: - """ - grid_thw: [T, 3] - col0: repeat count - col1, col2: arbitrary positive integers (here 1..16) - """ - repeats = torch.randint(repeat_min, repeat_max + 1, (T, 1), dtype=torch.int32) - th = torch.randint(1, 17, (T, 1), dtype=torch.int32) - tw = torch.randint(1, 17, (T, 1), dtype=torch.int32) - grid_thw = torch.cat([repeats, th, tw], dim=1) - return grid_thw - - -class TestRepeatInterleave: - @classmethod - def setup_class(cls): - torch.set_num_threads(1) - - def setup_method(self, method): - torch.manual_seed(0) - np.random.seed(0) - - @pytest.mark.parametrize( - "T,repeat_min,repeat_max", - GRID_TEST_CONFIGS, - ) - @pytest.mark.parametrize("case_idx", range(NUM_CASES_PER_CONFIG)) - def test_cpu_correctness_random_cases( - self, - T: int, - repeat_min: int, - repeat_max: int, - case_idx: int, - ): - torch.manual_seed(case_idx) - np.random.seed(case_idx) - - grid_thw = _generate_random_grid(T, repeat_min, repeat_max) - - grid_clone = grid_thw.clone() - - out_torch = torch_ref_impl(grid_thw) - out_numpy = cpu_numpy_impl(grid_thw) - - assert torch.equal(grid_thw, grid_clone), "Function modified input grid_thw!" - - assert ( - out_torch.shape == out_numpy.shape - ), f"Shape mismatch: torch={out_torch.shape}, numpy={out_numpy.shape}" - - assert ( - out_torch.dtype == torch.int32 - ), f"Unexpected torch dtype: {out_torch.dtype}" - assert ( - out_numpy.dtype == torch.int32 - ), f"Unexpected numpy impl dtype: {out_numpy.dtype}" - - if not torch.equal(out_torch.cpu(), out_numpy.cpu()): - diff_idx = (out_torch.cpu() != out_numpy.cpu()).nonzero(as_tuple=False) - idx0 = diff_idx[0].item() - pytest.fail( - f"Value mismatch, T={T}, case_idx={case_idx}, first differing index={idx0}, " - f"torch={out_torch[idx0].item()}, " - f"numpy={out_numpy[idx0].item()}" - ) - - def test_zero_repeat_edge_case(self): - T = 4 - grid_thw = torch.tensor( - [ - [0, 4, 4], - [1, 2, 3], # 6 - [2, 1, 5], # 5, 5 - [0, 7, 7], # 0 - ], - dtype=torch.int32, - ) - - grid_clone = grid_thw.clone() - - out_torch = torch_ref_impl(grid_thw) - out_numpy = cpu_numpy_impl(grid_thw) - - assert torch.equal( - grid_thw, grid_clone - ), "Function modified input grid_thw with zero repeats!" - - assert torch.equal( - out_torch.cpu(), out_numpy.cpu() - ), f"Zero-repeat case mismatch: torch={out_torch}, numpy={out_numpy}" diff --git a/test/registered/quant/test_bnb.py b/test/registered/quant/test_bnb.py deleted file mode 100644 index ee7964256..000000000 --- a/test/registered/quant/test_bnb.py +++ /dev/null @@ -1,310 +0,0 @@ -""" -Usage: -python3 -m unittest test_bnb.TestVisionModel.test_vlm -python3 -m unittest test_bnb.TestLanguageModel.test_mmlu -""" - -import multiprocessing as mp -import random -from concurrent.futures import ThreadPoolExecutor -from types import SimpleNamespace - -import openai - -from sglang.srt.utils import kill_process_tree -from sglang.test.ci.ci_register import register_cuda_ci -from sglang.test.run_eval import run_eval -from sglang.test.test_utils import ( - DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, - DEFAULT_URL_FOR_TEST, - CustomTestCase, - is_in_ci, - popen_launch_server, -) - -register_cuda_ci(est_time=6, suite="stage-b-test-1-gpu-small") - -VISION_MODELS = [ - "unsloth/Qwen2.5-VL-7B-Instruct-bnb-4bit", - "unsloth/Qwen2-VL-7B-Instruct-bnb-4bit", - "unsloth/Llama-3.2-11B-Vision-Instruct-bnb-4bit", - "unsloth/Llama-3.2-11B-Vision-bnb-4bit", - "unsloth/gemma-3-4b-it-bnb-4bit", - "unsloth/gemma-3-4b-it-unsloth-bnb-4bit", -] -LANGUAGE_MODELS = [ - "unsloth/Qwen2.5-7B-Instruct-bnb-4bit", - "unsloth/Qwen2-7B-Instruct-bnb-4bit", - "unsloth/Llama-3.2-3B-Instruct-bnb-4bit", - "unsloth/gemma-3-1b-it-bnb-4bit", -] - -# image -IMAGE_MAN_IRONING_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/images/man_ironing_on_back_of_suv.png" -IMAGE_SGL_LOGO_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/images/sgl_logo.png" - -# video -VIDEO_JOBS_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/videos/jobs_presenting_ipod.mp4" - -# audio -AUDIO_TRUMP_SPEECH_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/audios/Trump_WEF_2018_10s.mp3" -AUDIO_BIRD_SONG_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/audios/bird_song.mp3" - - -def popen_launch_server_wrapper(base_url, model, other_args): - process = popen_launch_server( - model, - base_url, - timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, - other_args=other_args, - ) - return process - - -class TestVisionModel(CustomTestCase): - @classmethod - def setUpClass(cls): - mp.set_start_method("spawn", force=True) - cls.base_url = DEFAULT_URL_FOR_TEST - cls.base_url += "/v1" - cls.api_key = "sk-123456" - - def _run_single_image_chat_completion(self): - client = openai.Client(api_key=self.api_key, base_url=self.base_url) - - response = client.chat.completions.create( - model="default", - messages=[ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"url": IMAGE_MAN_IRONING_URL}, - }, - { - "type": "text", - "text": "Describe this image in a very short sentence.", - }, - ], - }, - ], - temperature=0, - ) - - assert response.choices[0].message.role == "assistant" - text = response.choices[0].message.content - assert isinstance(text, str) - # `driver` is for gemma-3-it - assert "man" in text or "person" or "driver" in text, text - assert "cab" in text or "taxi" in text or "SUV" in text, text - # MiniCPMO fails to recognize `iron`, but `hanging` - assert "iron" in text or "hang" in text, text - assert response.id - assert response.created - assert response.usage.prompt_tokens > 0 - assert response.usage.completion_tokens > 0 - assert response.usage.total_tokens > 0 - - def _run_multi_turn_chat_completion(self): - client = openai.Client(api_key=self.api_key, base_url=self.base_url) - - response = client.chat.completions.create( - model="default", - messages=[ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"url": IMAGE_MAN_IRONING_URL}, - }, - { - "type": "text", - "text": "Describe this image in a very short sentence.", - }, - ], - }, - { - "role": "assistant", - "content": [ - { - "type": "text", - "text": "There is a man at the back of a yellow cab ironing his clothes.", - } - ], - }, - { - "role": "user", - "content": [ - {"type": "text", "text": "Repeat your previous answer."} - ], - }, - ], - temperature=0, - ) - - assert response.choices[0].message.role == "assistant" - text = response.choices[0].message.content - assert isinstance(text, str) - assert "man" in text or "cab" in text, text - assert response.id - assert response.created - assert response.usage.prompt_tokens > 0 - assert response.usage.completion_tokens > 0 - assert response.usage.total_tokens > 0 - - def _run_multi_images_chat_completion(self): - client = openai.Client(api_key=self.api_key, base_url=self.base_url) - response = client.chat.completions.create( - model="default", - messages=[ - { - "role": "user", - "content": [ - { - "type": "image_url", - "image_url": {"url": IMAGE_MAN_IRONING_URL}, - "modalities": "multi-images", - }, - { - "type": "image_url", - "image_url": {"url": IMAGE_SGL_LOGO_URL}, - "modalities": "multi-images", - }, - { - "type": "text", - "text": "I have two very different images. They are not related at all. " - "Please describe the first image in one sentence, and then describe the second image in another sentence.", - }, - ], - }, - ], - temperature=0, - ) - - assert response.choices[0].message.role == "assistant" - text = response.choices[0].message.content - assert isinstance(text, str) - print("-" * 30) - print(f"Multi images response:\n{text}") - print("-" * 30) - assert "man" in text or "cab" in text or "SUV" in text or "taxi" in text, text - assert "logo" in text or '"S"' in text or "SG" in text, text - assert response.id - assert response.created - assert response.usage.prompt_tokens > 0 - assert response.usage.completion_tokens > 0 - assert response.usage.total_tokens > 0 - - def run_decode_with_image(self, image_id): - client = openai.Client(api_key=self.api_key, base_url=self.base_url) - - content = [] - if image_id == 0: - content.append( - { - "type": "image_url", - "image_url": {"url": IMAGE_MAN_IRONING_URL}, - } - ) - elif image_id == 1: - content.append( - { - "type": "image_url", - "image_url": {"url": IMAGE_SGL_LOGO_URL}, - } - ) - else: - pass - - content.append( - { - "type": "text", - "text": "Describe this image in a very short sentence.", - } - ) - - response = client.chat.completions.create( - model="default", - messages=[ - {"role": "user", "content": content}, - ], - temperature=0, - ) - - assert response.choices[0].message.role == "assistant" - text = response.choices[0].message.content - assert isinstance(text, str) - - def _run_test_mixed_batch(self): - image_ids = [0, 1, 2] * 4 - with ThreadPoolExecutor(4) as executor: - list(executor.map(self.run_decode_with_image, image_ids)) - - def test_vlm(self): - models_to_test = VISION_MODELS - - if is_in_ci(): - models_to_test = [random.choice(VISION_MODELS)] - - for model in models_to_test: - with self.subTest(model=model): - other_args = [ - "--mem-fraction-static", - "0.6", - "--load-format", - "bitsandbytes", - "--enable-multimodal", - ] - try: - process = popen_launch_server_wrapper( - DEFAULT_URL_FOR_TEST, model, other_args - ) - self._run_test_mixed_batch() - self._run_multi_images_chat_completion() - self._run_multi_turn_chat_completion() - self._run_single_image_chat_completion() - finally: - kill_process_tree(process.pid) - - -class TestLanguageModel(CustomTestCase): - @classmethod - def setUpClass(cls): - mp.set_start_method("spawn", force=True) - cls.base_url = DEFAULT_URL_FOR_TEST - # cls.base_url += "/v1" - cls.api_key = "sk-123456" - - def test_mmlu(self): - models_to_test = LANGUAGE_MODELS - - if is_in_ci(): - models_to_test = [random.choice(LANGUAGE_MODELS)] - - for model in models_to_test: - with self.subTest(model=model): - other_args = [ - "--mem-fraction-static", - "0.6", - "--load-format", - "bitsandbytes", - ] - try: - process = popen_launch_server_wrapper( - DEFAULT_URL_FOR_TEST, model, other_args - ) - args = SimpleNamespace( - base_url=self.base_url, - model=model, - eval_name="mmlu", - num_examples=32, - num_threads=16, - ) - - metrics = run_eval(args) - print(f"{metrics=}") - self.assertGreater(metrics["score"], 0.3) - finally: - kill_process_tree(process.pid) diff --git a/test/registered/quant/test_int4fp8_moe.py b/test/registered/quant/test_int4fp8_moe.py deleted file mode 100644 index c46c50447..000000000 --- a/test/registered/quant/test_int4fp8_moe.py +++ /dev/null @@ -1,59 +0,0 @@ -from types import SimpleNamespace - -from sglang.srt.utils import kill_process_tree -from sglang.test.ci.ci_register import register_amd_ci -from sglang.test.run_eval import run_eval -from sglang.test.test_utils import ( - DEFAULT_URL_FOR_TEST, - CustomTestCase, - popen_launch_server, -) - -register_amd_ci(est_time=313, suite="stage-b-test-1-gpu-small-amd") - - -class TestMixtralAccuracy(CustomTestCase): - @classmethod - def setUpClass(cls): - cls.model = "mistralai/Mixtral-8x7B-Instruct-v0.1" - cls.base_url = DEFAULT_URL_FOR_TEST - - other_args = [ - "--tp", - "2", - "--mem-fraction-static", - "0.9", - "--context-length", - "38768", - "--quantization", - "quark_int4fp8_moe", - # The default aiter attention backend raises segmentation faults and other errors - as quark_int4fp8_moe is not related to attention, let's just use triton here. - "--attention-backend", - "triton", - ] - - cls.process = popen_launch_server( - cls.model, - cls.base_url, - timeout=45 * 60, - other_args=other_args, - ) - - @classmethod - def tearDownClass(cls): - kill_process_tree(cls.process.pid) - - def test_gsm8k(self): - args = SimpleNamespace( - base_url=self.base_url, - model=self.model, - eval_name="gsm8k", - api="completion", - max_tokens=512, - num_examples=1400, - num_threads=128, - num_shots=8, - ) - metrics = run_eval(args) - print(f"{metrics=}") - self.assertGreater(metrics["score"], 0.56) diff --git a/test/registered/rotary/test_mrope.py b/test/registered/rotary/test_mrope.py deleted file mode 100644 index 87b34df91..000000000 --- a/test/registered/rotary/test_mrope.py +++ /dev/null @@ -1,174 +0,0 @@ -# Rotary Embedding - MRoPE tests (1-GPU) - -from typing import NamedTuple - -import pytest -import torch -from packaging.version import Version -from transformers import AutoConfig -from transformers import __version__ as TRANSFORMERS_VERSION - -from sglang.srt.layers.rotary_embedding import get_rope -from sglang.srt.server_args import ServerArgs, set_global_server_args_for_scheduler -from sglang.srt.utils import ( - cpu_has_amx_support, - is_cpu, - is_cuda, - is_hip, - is_npu, - is_xpu, -) -from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci - -register_cuda_ci(est_time=7, suite="stage-b-test-1-gpu-large") -register_amd_ci(est_time=15, suite="stage-b-test-1-gpu-small-amd") - -_is_cuda = is_cuda() -_is_hip = is_hip() -_is_cpu = is_cpu() -_is_cpu_amx_available = cpu_has_amx_support() -_is_npu = is_npu() -_is_xpu = is_xpu() - -device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - - -def generate_test_data( - num_tokens: int, - num_q_heads: int, - num_kv_heads: int, - head_size: int, - max_position_embeddings: int, - dtype: torch.dtype, - device: torch.device, -): - """Generate test data for given configuration.""" - torch.manual_seed(42) - # Create 2D positions (3, num_tokens) for multimodal case - positions = torch.randint( - 0, max_position_embeddings // 4, (3, num_tokens), device=device - ) - - # Create query and key tensors - query = torch.randn(num_tokens, num_q_heads * head_size, dtype=dtype, device=device) - key = torch.randn(num_tokens, num_kv_heads * head_size, dtype=dtype, device=device) - - return positions, query, key - - -class MRoPETestInfo(NamedTuple): - model_name: str - atol: float = 1e-2 - rtol: float = 1.6e-2 - marks: list[pytest.MarkDecorator] = [] - - -TRANSFORMERS_BASE_VERSION = Version(TRANSFORMERS_VERSION).base_version - -MODELS_TO_TEST = [ - MRoPETestInfo(model_name="Qwen/Qwen2-VL-7B-Instruct"), - MRoPETestInfo(model_name="Qwen/Qwen2-VL-72B-Instruct"), - MRoPETestInfo(model_name="Qwen/Qwen2.5-VL-72B-Instruct"), -] - -num_tokens_list = [11, 8192] - - -def create_yarn_rope_scaling(original_config, scaling_factor=2.0): - yarn_config = { - "rope_type": "yarn", - "factor": scaling_factor, - "original_max_position_embeddings": original_config.max_position_embeddings, - } - if hasattr(original_config, "rope_scaling") and original_config.rope_scaling: - if "mrope_section" in original_config.rope_scaling: - yarn_config["mrope_section"] = original_config.rope_scaling["mrope_section"] - if "mrope_interleaved" in original_config.rope_scaling: - yarn_config["mrope_interleaved"] = original_config.rope_scaling[ - "mrope_interleaved" - ] - return yarn_config - - -@pytest.mark.skipif(not (_is_cuda or _is_hip), reason="Skipping CUDA/ROCm only tests.") -@pytest.mark.parametrize( - "model_info, model_name", - [ - pytest.param(test_config, test_config.model_name, marks=test_config.marks) - for test_config in MODELS_TO_TEST - ], -) -@pytest.mark.parametrize("tp_size", [1, 2]) -@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float32]) -@pytest.mark.parametrize("num_tokens", num_tokens_list) -@pytest.mark.parametrize( - "rope_scaling_type", ["default", "yarn"], ids=["mrope_default", "mrope_yarn"] -) -def test_mrope( - model_name: str, - model_info: MRoPETestInfo, - tp_size: int, - dtype: torch.dtype, - num_tokens: int, - rope_scaling_type: str, -): - set_global_server_args_for_scheduler(ServerArgs(model_path="dummy")) - - atol = model_info.atol - rtol = model_info.rtol - - config = AutoConfig.from_pretrained(model_name) - config = config.get_text_config() - - # get the model config - total_num_kv_heads = config.num_key_value_heads - total_num_heads = config.num_attention_heads - num_heads = total_num_heads // tp_size - num_kv_heads = max(1, total_num_kv_heads // tp_size) - head_dim = ( - config.head_dim - if hasattr(config, "head_dim") - else config.hidden_size // total_num_heads - ) - is_neox_style = True - - rope_theta = config.rope_theta - max_position = config.max_position_embeddings - partial_rotary_factor = getattr(config, "partial_rotary_factor", 1.0) - rotary_dim = int(head_dim * partial_rotary_factor) - - if rope_scaling_type == "yarn": - rope_scaling_config = create_yarn_rope_scaling(config, scaling_factor=2.0) - else: - rope_scaling_config = config.rope_scaling - - mrope_helper_class = get_rope( - head_size=head_dim, - rotary_dim=rotary_dim, - max_position=max_position, - base=rope_theta, - is_neox_style=is_neox_style, - rope_scaling=rope_scaling_config, - dtype=dtype, - ).to(device=device) - - # create q k v input tensors - # create rotary pos emb input tensors - positions, query, key = generate_test_data( - num_tokens, num_heads, num_kv_heads, head_dim, max_position, dtype, device - ) - - query_native, key_native = mrope_helper_class.forward_native( - positions, - query.clone(), - key.clone(), - ) - - query_cuda, key_cuda = mrope_helper_class.forward( - positions, - query.clone(), - key.clone(), - ) - - torch.testing.assert_close(query_native, query_cuda, atol=atol, rtol=rtol) - torch.testing.assert_close(key_native, key_cuda, atol=atol, rtol=rtol) diff --git a/test/registered/unit/function_call/test_glm47_moe_detector.py b/test/registered/unit/function_call/test_glm47_moe_detector.py deleted file mode 100644 index e0c192119..000000000 --- a/test/registered/unit/function_call/test_glm47_moe_detector.py +++ /dev/null @@ -1,1847 +0,0 @@ -import json -import unittest - -from sglang.srt.entrypoints.openai.protocol import Function, Tool -from sglang.srt.function_call.core_types import StreamingParseResult -from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector -from sglang.srt.function_call.glm47_moe_detector import ( - Glm47MoeDetector, - get_argument_type, -) -from sglang.test.ci.ci_register import register_cpu_ci - -register_cpu_ci(5, "stage-a-test-cpu") - - -class TestGlm47MoeDetector(unittest.TestCase): - def setUp(self): - self.tools = [ - Tool( - type="function", - function=Function( - name="get_weather", - description="Get weather information", - parameters={ - "type": "object", - "properties": { - "city": {"type": "string", "description": "City name"}, - "date": {"type": "string", "description": "Date"}, - }, - "required": ["city", "date"], - }, - ), - ), - ] - self.detector = Glm47MoeDetector() - - # ==================== Basic Parsing Tests (5) ==================== - - def test_single_tool_call(self): - """ - Test basic single tool call parsing. - - Scenario: Parse a complete tool call with two string parameters in a single text block. - Purpose: Verify the detector can correctly identify and extract function name and parameters - from a simple, well-formed tool call. - """ - text = ( - "get_weather" - "cityBeijing" - "date2024-06-27" - "" - ) - result = self.detector.detect_and_parse(text, self.tools) - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "get_weather") - self.assertEqual( - result.calls[0].parameters, '{"city": "Beijing", "date": "2024-06-27"}' - ) - self.assertEqual(result.normal_text, "") - - def test_multiple_tool_calls(self): - """ - Test parsing multiple consecutive tool calls. - - Scenario: Parse two complete tool calls back-to-back without any text in between. - Purpose: Verify the detector correctly handles multiple tool calls and resets state - between calls to avoid parameter leakage or ID conflicts. - """ - text = ( - "get_weather" - "cityBeijing" - "date2024-06-27" - "" - "get_weather" - "cityShanghai" - "date2024-06-28" - "" - ) - result = self.detector.detect_and_parse(text, self.tools) - self.assertEqual(len(result.calls), 2) - self.assertEqual(result.calls[0].name, "get_weather") - self.assertEqual( - result.calls[0].parameters, '{"city": "Beijing", "date": "2024-06-27"}' - ) - self.assertEqual(result.calls[1].name, "get_weather") - self.assertEqual( - result.calls[1].parameters, '{"city": "Shanghai", "date": "2024-06-28"}' - ) - self.assertEqual(result.normal_text, "") - - def test_no_arg_function_non_streaming(self): - """ - Test no-argument function call without streaming. - - Scenario: Parse a tool call for a function that has no parameters (empty properties). - Purpose: Verify the detector generates a single empty object "{}" for no-argument functions - and does not duplicate empty parameter objects. - """ - tools_with_no_args = [ - Tool( - type="function", - function=Function( - name="list_filenames", - description="List filenames", - parameters={ - "type": "object", - "properties": {}, - }, - ), - ), - ] - - text = "list_filenames" - result = self.detector.detect_and_parse(text, tools_with_no_args) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "list_filenames") - params = json.loads(result.calls[0].parameters) - self.assertEqual(params, {}) - - def test_invalid_tool_call(self): - """ - Test handling of invalid tool calls. - - Scenario: Attempt to parse a tool call with a function name that doesn't exist in the tool list. - Purpose: Verify the detector gracefully rejects invalid function calls and returns no calls - rather than throwing an error or accepting invalid input. - """ - text = "invalid_funccityBeijing" - result = self.detector.detect_and_parse(text, self.tools) - self.assertEqual(len(result.calls), 0) - - def test_array_argument_with_escaped_json(self): - """ - Test array arguments containing escaped JSON strings. - - Scenario: Parse tool calls with array parameters containing nested JSON objects with - escaped quotes (both backslash-escaped and raw escaped strings). - Purpose: Verify the detector properly handles JSON escaping without double-escaping, - preserving special characters like backslashes in paths and newline sequences. - """ - tools_with_array = [ - Tool( - type="function", - function=Function( - name="todo_write", - description="Write todos", - parameters={ - "type": "object", - "properties": { - "todos": { - "type": "array", - "description": "The updated todo list", - } - }, - "required": ["todos"], - }, - ), - ), - ] - - def check_params(result): - self.assertEqual(1, len(result.calls)) - self.assertEqual("todo_write", result.calls[0].name) - params = json.loads(result.calls[0].parameters) - self.assertIsInstance(params["todos"], list) - self.assertEqual(4, len(params["todos"])) - self.assertEqual("1", params["todos"][0]["id"]) - self.assertEqual( - "Check for hard-coded issues in the backend code", - params["todos"][0]["task"], - ) - self.assertEqual("in_progress", params["todos"][0]["status"]) - self.assertEqual("2", params["todos"][1]["id"]) - self.assertEqual( - "Check for hard-coded issues in the frontend code", - params["todos"][1]["task"], - ) - self.assertEqual("pending", params["todos"][1]["status"]) - self.assertEqual("3", params["todos"][2]["id"]) - self.assertEqual( - "Check for code violating the Single Responsibility Principle", - params["todos"][2]["task"], - ) - self.assertEqual("pending", params["todos"][2]["status"]) - self.assertEqual("4", params["todos"][3]["id"]) - self.assertEqual( - "Generate a rectification proposal report", params["todos"][3]["task"] - ) - self.assertEqual("pending", params["todos"][3]["status"]) - - # Test with normal escaped JSON in XML - result = self.detector.detect_and_parse( - """todo_writetodos[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}] -""", - tools_with_array, - ) - check_params(result) - - # Test with raw string escaped JSON - result = self.detector.detect_and_parse( - r"""todo_writetodos[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}] -""", - tools_with_array, - ) - check_params(result) - - def check_single_todos(tool_result, expected): - self.assertEqual(1, len(tool_result.calls)) - self.assertEqual("todo_write", tool_result.calls[0].name) - params = json.loads(tool_result.calls[0].parameters) - self.assertIsInstance(params["todos"], list) - self.assertEqual(1, len(params["todos"])) - self.assertEqual("1", params["todos"][0]["id"]) - self.assertEqual(expected, params["todos"][0]["task"]) - self.assertEqual("pending", params["todos"][0]["status"]) - - # Test with escaped backslashes (Windows paths) - expected_path = r"Check file at C:\Users\test.txt" - result = self.detector.detect_and_parse( - """todo_writetodos[{\"id\": \"1\", \"task\": \"Check file at C:\\\\Users\\\\test.txt\", \"status\": \"pending\"}]""", - tools_with_array, - ) - check_single_todos(result, expected_path) - - # Test with literal backslash-n (not newline) - expected_output = r"Print \n to see newline" - result = self.detector.detect_and_parse( - """todo_writetodos[{\"id\": \"1\", \"task\": \"Print \\\\n to see newline\",\"status\": \"pending\"}]""", - tools_with_array, - ) - check_single_todos(result, expected_output) - - # ==================== MTP Core Scenarios (3) ==================== - - def test_mtp_func_and_string_split(self): - """ - Test MTP-style function name and string parameter value splitting across chunks. - - Scenario: Simulate Model Token Provider (MTP) behavior where function names and string - parameter values are split mid-word across multiple chunks. - Purpose: This is the MOST CRITICAL test - verify the detector correctly reassembles: - - Function name split as "create_ta" + "sk" - - String values split as "Go to Bei" + "jing" and "San Fran" + "cisco" - These splits mimic real MTP output where tokenization breaks words arbitrarily. - """ - tools = [ - Tool( - type="function", - function=Function( - name="create_task", - parameters={ - "type": "object", - "properties": { - "title": {"type": "string"}, - "location": {"type": "string"}, - }, - }, - ), - ), - ] - - chunks = [ - "I'll create a task.", # normal text before tool call - "create_ta", # function name split mid-word - "sktitleGo to Bei", # function name completes, param value splits - "jing", # first parameter value completes - "locationSan Fran", # second parameter value splits - "cisco", # second parameter and tool call complete - ] - - detector = Glm47MoeDetector() - all_calls = [] - all_normal_text = "" - - for chunk in chunks: - result = detector.parse_streaming_increment(chunk, tools) - all_calls.extend(result.calls) - all_normal_text += result.normal_text - - # Verify normal text is preserved - self.assertEqual(all_normal_text, "I'll create a task.") - - # Verify function call - func_calls = [c for c in all_calls if c.name] - self.assertEqual(len(func_calls), 1) - self.assertEqual( - func_calls[0].name, "create_task" - ) # "create_ta" + "sk" reassembled - - # Verify parameter reassembly - full_params = "".join([c.parameters for c in all_calls if c.parameters]) - params = json.loads(full_params) - self.assertEqual( - params["title"], "Go to Beijing" - ) # "Go to Bei" + "jing" reassembled - self.assertEqual( - params["location"], "San Francisco" - ) # "San Fran" + "cisco" reassembled - - def test_mtp_noarg_and_multiple_calls(self): - """ - Test MTP-style no-argument function and multiple tool calls with state reset. - - Scenario: Stream a no-argument function call followed by a regular function call, - simulating MTP's output pattern where function completion triggers state reset. - Purpose: Verify: - - No-argument functions emit exactly ONE empty object "{}", not duplicates - - State properly resets between consecutive tool calls (tool_index increments) - - Second tool call doesn't inherit parameters from first call - """ - tools = [ - Tool( - type="function", - function=Function( - name="list_files", - parameters={ - "type": "object", - "properties": {}, - }, - ), - ), - Tool( - type="function", - function=Function( - name="get_weather", - parameters={ - "type": "object", - "properties": { - "city": {"type": "string"}, - }, - }, - ), - ), - ] - - chunks = [ - "list_files", # no-arg function, complete in one chunk - "get_weathercityBeijing", - ] - - detector = Glm47MoeDetector() - all_calls = [] - - for chunk in chunks: - result = detector.parse_streaming_increment(chunk, tools) - all_calls.extend(result.calls) - - # Verify two distinct tool calls - func_calls = [c for c in all_calls if c.name] - self.assertEqual(len(func_calls), 2) - self.assertEqual(func_calls[0].name, "list_files") - self.assertEqual(func_calls[1].name, "get_weather") - - # Verify no duplicate empty objects for no-arg function - empty_object_calls = [c for c in all_calls if c.parameters == "{}"] - self.assertLessEqual( - len(empty_object_calls), - 1, - "No-argument function should emit at most one empty object", - ) - - # Verify second call has correct parameters - weather_params = [ - c.parameters for c in all_calls if c.parameters and c.parameters != "{}" - ] - if weather_params: - full_params = "".join(weather_params) - params = json.loads(full_params) - self.assertEqual(params["city"], "Beijing") - - def test_mtp_number_and_complex_json(self): - """ - Test MTP-style number parameters and complex JSON array splitting. - - Scenario: Parse tool calls with number parameters (int and float) and JSON arrays - split across chunks, including splits within JSON structure. - Purpose: Verify: - - Number types (5.5, 10) are preserved as numbers, not strings - - JSON array content split as "description" + ": \"" maintains validity - - Nested JSON objects in arrays are correctly reconstructed - """ - tools = [ - Tool( - type="function", - function=Function( - name="create_todos", - parameters={ - "type": "object", - "properties": { - "priority": {"type": "number"}, - "count": {"type": "integer"}, - "items": {"type": "array"}, - }, - }, - ), - ), - ] - - chunks = [ - "create_todos", - "priority5.5", # float number - "count10", # integer number - 'items[{"description', # JSON array splits mid-key - '": "Test', # key completes, value starts - 'Todo 1"}, {"description": "TestTodo 2"}]', - ] - - detector = Glm47MoeDetector() - all_calls = [] - - for chunk in chunks: - result = detector.parse_streaming_increment(chunk, tools) - all_calls.extend(result.calls) - - # Verify function name - func_calls = [c for c in all_calls if c.name] - self.assertEqual(len(func_calls), 1) - self.assertEqual(func_calls[0].name, "create_todos") - - # Verify parameters - numbers and JSON array - full_params = "".join([c.parameters for c in all_calls if c.parameters]) - params = json.loads(full_params) - - # Number types should be preserved - self.assertIsInstance(params["priority"], (int, float)) - self.assertEqual(params["priority"], 5.5) - self.assertIsInstance(params["count"], int) - self.assertEqual(params["count"], 10) - - # JSON array should be correctly reconstructed - self.assertIsInstance(params["items"], list) - self.assertEqual(len(params["items"]), 2) - self.assertEqual(params["items"][0]["description"], "TestTodo 1") - self.assertEqual(params["items"][1]["description"], "TestTodo 2") - - # ==================== Streaming Basics (3) ==================== - - def test_streaming_tool_call(self): - """ - Test basic streaming incremental parsing of a single tool call. - - Scenario: Parse a tool call split across 4 chunks with natural boundaries - (function name, first param, second param, closing tag). - Purpose: Verify basic streaming functionality works correctly and accumulates - parameters progressively across chunks. - """ - chunks = [ - "get_weather", - "cityBeijing", - "date2024-06-27", - "", - ] - tool_calls = [] - for chunk in chunks: - result = self.detector.parse_streaming_increment(chunk, self.tools) - for tool_call_chunk in result.calls: - if ( - hasattr(tool_call_chunk, "tool_index") - and tool_call_chunk.tool_index is not None - ): - while len(tool_calls) <= tool_call_chunk.tool_index: - tool_calls.append({"name": "", "parameters": ""}) - tc = tool_calls[tool_call_chunk.tool_index] - if tool_call_chunk.name: - tc["name"] = tool_call_chunk.name - if tool_call_chunk.parameters: - tc["parameters"] += tool_call_chunk.parameters - self.assertEqual(len(tool_calls), 1) - self.assertEqual(tool_calls[0]["name"], "get_weather") - self.assertEqual( - tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}' - ) - - def test_streaming_multiple_tool_calls(self): - """ - Test streaming incremental parsing of multiple consecutive tool calls. - - Scenario: Stream two complete tool calls with the transition "" - occurring within a single chunk. - Purpose: Verify streaming correctly handles multiple tool calls and properly increments - tool_index for each new call. - """ - chunks = [ - "get_weather", - "cityBeijing", - "date2024-06-27", - "get_weather", # two tool calls transition in same chunk - "cityShanghai", - "date2024-06-28", - "", - ] - tool_calls = [] - for chunk in chunks: - result = self.detector.parse_streaming_increment(chunk, self.tools) - for tool_call_chunk in result.calls: - if ( - hasattr(tool_call_chunk, "tool_index") - and tool_call_chunk.tool_index is not None - ): - while len(tool_calls) <= tool_call_chunk.tool_index: - tool_calls.append({"name": "", "parameters": ""}) - tc = tool_calls[tool_call_chunk.tool_index] - if tool_call_chunk.name: - tc["name"] = tool_call_chunk.name - if tool_call_chunk.parameters: - tc["parameters"] += tool_call_chunk.parameters - self.assertEqual(len(tool_calls), 2) - self.assertEqual(tool_calls[0]["name"], "get_weather") - self.assertEqual( - tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}' - ) - self.assertEqual(tool_calls[1]["name"], "get_weather") - self.assertEqual( - tool_calls[1]["parameters"], '{"city": "Shanghai", "date": "2024-06-28"}' - ) - - def test_normal_text_before_tool_call(self): - """ - Test preservation of normal text (including punctuation) before tool calls. - - Scenario: Parse chunks containing normal text with various punctuation marks - (English and Chinese) immediately followed by tool call tags. - Purpose: Verify normal text is preserved in result.normal_text and not lost when - tool call parsing begins. This consolidates 6 previous Chinese punctuation tests. - """ - tools = [ - Tool( - type="function", - function=Function( - name="list_dir", - parameters={ - "type": "object", - "properties": { - "path": {"type": "string"}, - }, - }, - ), - ), - ] - - test_cases = [ - ("Sure, let me help.list_dir", "English with period"), - ("结构:list_dir", "Chinese colon"), - ("问题。list_dir", "Chinese period"), - ("Complete!list_dir", "English exclamation"), - ("说明;list_dir", "Chinese semicolon"), - ] - - for text, description in test_cases: - with self.subTest(description=description): - detector = Glm47MoeDetector() - result = detector.parse_streaming_increment(text, tools) - - before_token = text.split("")[0] - self.assertIn( - before_token, - result.normal_text, - f"Should preserve '{before_token}' in '{description}'", - ) - - # ==================== Boundary Cases (9) ==================== - - def test_boundary_empty_param_value(self): - """ - Test handling of empty parameter values. - - Scenario: Parse a tool call where a parameter value is an empty string. - Purpose: Verify the detector correctly handles empty strings as valid parameter values - and doesn't skip or error on them. - """ - tools = [ - Tool( - type="function", - function=Function( - name="create_note", - parameters={ - "type": "object", - "properties": { - "title": {"type": "string"}, - "content": {"type": "string"}, - }, - }, - ), - ), - ] - - text = "create_notetitleTestcontent" - result = self.detector.detect_and_parse(text, tools) - - self.assertEqual(len(result.calls), 1) - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["title"], "Test") - self.assertEqual(params["content"], "") # empty string should be preserved - - def test_boundary_param_value_extreme_split(self): - """ - Test extreme parameter value splitting - one character per chunk. - - Scenario: Stream a parameter value where each character arrives in a separate chunk, - representing worst-case MTP tokenization. - Purpose: Stress test the buffer reassembly mechanism to ensure it can handle - extremely granular chunk boundaries without data loss or corruption. - """ - tools = [ - Tool( - type="function", - function=Function( - name="search", - parameters={ - "type": "object", - "properties": { - "query": {"type": "string"}, - }, - }, - ), - ), - ] - - chunks = [ - "searchqueryN", - "e", - "w ", - "Y", - "o", - "rk", - ] - - detector = Glm47MoeDetector() - all_calls = [] - - for chunk in chunks: - result = detector.parse_streaming_increment(chunk, tools) - all_calls.extend(result.calls) - - full_params = "".join([c.parameters for c in all_calls if c.parameters]) - params = json.loads(full_params) - self.assertEqual( - params["query"], "New York" - ) # all characters correctly reassembled - - def test_boundary_param_value_with_special_chars(self): - """ - Test parameter values containing special characters and escape sequences. - - Scenario: Parse parameter values with quotes, backslashes, newlines, and other - special characters that require JSON escaping. - Purpose: Verify special characters are properly escaped/unescaped and preserved - through the parsing pipeline without corruption. - """ - tools = [ - Tool( - type="function", - function=Function( - name="execute_command", - parameters={ - "type": "object", - "properties": { - "command": {"type": "string"}, - }, - }, - ), - ), - ] - - # Test with single quotes (no escaping needed) - text = "execute_commandcommandecho 'Hello World'" - result = self.detector.detect_and_parse(text, tools) - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["command"], "echo 'Hello World'") - - # Test with spaces and special chars that don't need escaping - text = "execute_commandcommandecho Hello & World" - result = self.detector.detect_and_parse(text, tools) - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["command"], "echo Hello & World") - - def test_boundary_json_deeply_nested(self): - """ - Test deeply nested JSON structures in parameter values. - - Scenario: Parse a parameter containing a deeply nested JSON object with multiple levels. - Purpose: Verify the detector can handle complex nested structures without stack overflow - or parsing errors. - """ - tools = [ - Tool( - type="function", - function=Function( - name="process_data", - parameters={ - "type": "object", - "properties": { - "data": {"type": "object"}, - }, - }, - ), - ), - ] - - nested_json = ( - '{"level1": {"level2": {"level3": {"level4": {"value": "deep"}}}}}' - ) - text = f"process_datadata{nested_json}" - - result = self.detector.detect_and_parse(text, tools) - params = json.loads(result.calls[0].parameters) - - # Navigate through nested structure - self.assertEqual( - params["data"]["level1"]["level2"]["level3"]["level4"]["value"], "deep" - ) - - def test_boundary_json_empty_structures(self): - """ - Test empty JSON structures (empty objects and arrays) in parameters. - - Scenario: Parse parameters containing empty objects {} and empty arrays []. - Purpose: Verify empty structures are preserved and not confused with no-argument - function empty parameter generation. - """ - tools = [ - Tool( - type="function", - function=Function( - name="create_structure", - parameters={ - "type": "object", - "properties": { - "empty_obj": {"type": "object"}, - "empty_arr": {"type": "array"}, - }, - }, - ), - ), - ] - - text = "create_structureempty_obj{}empty_arr[]" - result = self.detector.detect_and_parse(text, tools) - - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["empty_obj"], {}) - self.assertEqual(params["empty_arr"], []) - - def test_boundary_multi_tags_one_chunk(self): - """ - Test multiple XML tags appearing in a single chunk. - - Scenario: Parse chunks where multiple complete tags (arg_key, arg_value, etc.) - appear together without any chunk boundaries between them. - Purpose: Verify the regex-based tag extraction correctly handles multiple tags - in one chunk and processes them in the correct order. - """ - tools = [ - Tool( - type="function", - function=Function( - name="multi_param", - parameters={ - "type": "object", - "properties": { - "a": {"type": "string"}, - "b": {"type": "string"}, - "c": {"type": "string"}, - }, - }, - ), - ), - ] - - # All three parameters in one chunk - text = "multi_parama1b2c3" - result = self.detector.detect_and_parse(text, tools) - - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["a"], "1") - self.assertEqual(params["b"], "2") - self.assertEqual(params["c"], "3") - - def test_boundary_normal_text_mixed_with_tool(self): - """ - Test normal text interleaved with tool calls. - - Scenario: Parse text with normal text before and after tool calls. - Purpose: Verify normal text segments are correctly separated from tool call parsing - and preserved in the normal_text output. - """ - tools = [ - Tool( - type="function", - function=Function( - name="action", - parameters={ - "type": "object", - "properties": {}, - }, - ), - ), - ] - - text = "First I'll do this.actionThen I'll do that." - result = self.detector.detect_and_parse(text, tools) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "action") - # Verify both text before and after tool calls are preserved - self.assertIn("First I'll do this.", result.normal_text) - self.assertIn("Then I'll do that.", result.normal_text) - - def test_boundary_number_edge_values(self): - """ - Test edge-case number values (zero, negative, scientific notation). - - Scenario: Parse parameters with various numeric edge cases to ensure proper type handling. - Purpose: Verify the detector correctly preserves number types for edge values and doesn't - convert them to strings or lose precision. - """ - tools = [ - Tool( - type="function", - function=Function( - name="calculate", - parameters={ - "type": "object", - "properties": { - "zero": {"type": "number"}, - "negative": {"type": "number"}, - "large": {"type": "number"}, - }, - }, - ), - ), - ] - - text = "calculatezero0negative-42.5large1e10" - result = self.detector.detect_and_parse(text, tools) - - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["zero"], 0) - self.assertEqual(params["negative"], -42.5) - self.assertEqual(params["large"], 1e10) - - def test_boundary_type_string_with_numeric_content(self): - """ - Test string parameters that contain numeric-looking content. - - Scenario: Parse string parameters with values like "123" or "45.67" that look like - numbers but should remain strings based on parameter schema. - Purpose: Verify type preservation based on schema definition, not content appearance. - """ - tools = [ - Tool( - type="function", - function=Function( - name="store_data", - parameters={ - "type": "object", - "properties": { - "id": { - "type": "string" - }, # string type despite numeric content - "code": {"type": "string"}, - }, - }, - ), - ), - ] - - text = "store_dataid12345code67.89" - result = self.detector.detect_and_parse(text, tools) - - params = json.loads(result.calls[0].parameters) - # Should be strings, not numbers - self.assertIsInstance(params["id"], str) - self.assertIsInstance(params["code"], str) - self.assertEqual(params["id"], "12345") - self.assertEqual(params["code"], "67.89") - - # ==================== Error Handling (2) ==================== - - def test_error_undefined_tool(self): - """ - Test error handling for undefined tool names. - - Scenario: Attempt to call a function that doesn't exist in the provided tools list. - Purpose: Verify the detector gracefully handles undefined tools by returning an empty - call list rather than crashing or producing malformed output. - """ - text = "nonexistent_functionparamvalue" - result = self.detector.detect_and_parse(text, self.tools) - - # Should not crash, should return empty calls - self.assertEqual(len(result.calls), 0) - - def test_error_incomplete_buffer_at_end(self): - """ - Test handling of incomplete tool calls at end of stream. - - Scenario: Streaming ends with an incomplete tool call (e.g., missing closing tag). - Purpose: Verify the detector handles incomplete buffers gracefully without throwing - exceptions, as streaming may end mid-parse in real scenarios. - """ - chunks = [ - "get_weathercityBeijing", - # Stream ends here, no closing tags - ] - - detector = Glm47MoeDetector() - - for chunk in chunks: - result = detector.parse_streaming_increment(chunk, self.tools) - # Should not crash - self.assertIsInstance(result, StreamingParseResult) - - # Incomplete call should not be in results - # (or may be partially present - main thing is no exception) - - # ==================== Streamed Raw Length Bug Tests (3) ==================== - - def test_streamed_raw_length_incomplete_xml_tag(self): - """ - Test that _streamed_raw_length is updated even when json_increment is empty. - - Scenario: Stream XML content that is split at an incomplete tag boundary, - causing the state machine to buffer without producing JSON output. - Purpose: Verify that _streamed_raw_length is updated regardless of whether - json_increment is empty, preventing reprocessing of the same input. - - This tests the bug where: - 1. raw_increment is extracted from func_args_raw[self._streamed_raw_length:] - 2. _process_xml_to_json_streaming() returns empty string (buffering state) - 3. If _streamed_raw_length is NOT updated before the early return, - the next call will reprocess the same raw_increment - """ - tools = [ - Tool( - type="function", - function=Function( - name="get_weather", - parameters={ - "type": "object", - "properties": { - "city": {"type": "string"}, - "temperature": {"type": "number"}, - }, - }, - ), - ), - ] - - # Simulate streaming chunks where XML tags are split - chunks = [ - "get_weather", - "cityBei", # Split in middle of value - "jing", # Complete the value - "temperature2", # Split numeric value - "5", - ] - - detector = Glm47MoeDetector() - all_calls = [] - collected_params = "" - - for i, chunk in enumerate(chunks): - result = detector.parse_streaming_increment(chunk, tools) - all_calls.extend(result.calls) - - # Collect parameters - for call in result.calls: - if call.parameters: - collected_params += call.parameters - - # Verify complete parameters were collected without duplication - if collected_params: - params = json.loads(collected_params) - self.assertEqual(params["city"], "Beijing") - self.assertEqual(params["temperature"], 25) - - # Critical: Verify no duplicate JSON output due to reprocessing - # Count occurrences of "city" key - should appear exactly once - city_count = collected_params.count('"city"') - self.assertEqual( - city_count, - 1, - f"'city' key appears {city_count} times, expected 1. " - f"This indicates input reprocessing bug.", - ) - - def test_streamed_raw_length_tag_split_across_chunks(self): - """ - Test _streamed_raw_length update when tag is split across chunk boundaries. - - Scenario: XML tags themselves are split across chunks (e.g., ""). - Purpose: Verify that even when the state machine is buffering partial tags, - _streamed_raw_length is correctly updated to prevent reprocessing. - """ - tools = [ - Tool( - type="function", - function=Function( - name="search", - parameters={ - "type": "object", - "properties": { - "query": {"type": "string"}, - "limit": {"type": "integer"}, - }, - }, - ), - ), - ] - - # Split tags in extreme positions - chunks = [ - "searchqueryPython progra", # Complete tag, split value - "mminglimit10", - ] - - detector = Glm47MoeDetector() - all_params = "" - - for chunk in chunks: - result = detector.parse_streaming_increment(chunk, tools) - for call in result.calls: - if call.parameters: - all_params += call.parameters - - # Verify correct reassembly - params = json.loads(all_params) - self.assertEqual(params["query"], "Python programming") - self.assertEqual(params["limit"], 10) - - # Verify no duplication in output - query_count = all_params.count('"query"') - limit_count = all_params.count('"limit"') - self.assertEqual(query_count, 1, "query key duplicated - reprocessing bug") - self.assertEqual(limit_count, 1, "limit key duplicated - reprocessing bug") - - def test_streamed_raw_length_buffer_only_partial_tag(self): - """ - Test that _streamed_raw_length updates even when state machine returns empty. - - Scenario: Send increment that is ONLY a partial opening tag that state machine - must buffer completely without producing any JSON output. - Purpose: Force json_increment to be empty string to expose the bug where - _streamed_raw_length is not updated before early return. - """ - tools = [ - Tool( - type="function", - function=Function( - name="test_func", - parameters={ - "type": "object", - "properties": { - "key1": {"type": "string"}, - }, - }, - ), - ), - ] - - # Manually call _process_arguments_streaming to have precise control - detector = Glm47MoeDetector() - detector.current_tool_id = 0 - detector.current_tool_name_sent = True - detector._reset_streaming_state() - detector.streamed_args_for_tool = [""] - detector._streamed_raw_length = 0 - - # First call: Complete tag that produces JSON output - func_args_1 = "key1va" - result_1 = detector._process_arguments_streaming( - "test_func", func_args_1, tools - ) - - # Should produce JSON output: {"key1": "va (partial) - self.assertIsNotNone(result_1) - self.assertGreater(len(result_1.parameters), 0) - initial_length = detector._streamed_raw_length - self.assertEqual(initial_length, len(func_args_1)) - - # Second call: Add just partial closing tag - state machine will buffer this - # without producing JSON (it's waiting to see if is complete) - func_args_2 = func_args_1 + "<" # Add partial tag - result_2 = detector._process_arguments_streaming( - "test_func", func_args_2, tools - ) - - # This is the critical test: if _streamed_raw_length is NOT updated when - # json_increment is empty, then detector._streamed_raw_length will still be - # at initial_length, and the next call will reprocess the "<" character - - # Check if length was updated (bug test) - updated_length = detector._streamed_raw_length - - # BUG: If code has bug, updated_length will equal initial_length - # FIXED: If code is correct, updated_length should equal len(func_args_2) - self.assertEqual( - updated_length, - len(func_args_2), - "Bug detected: _streamed_raw_length not updated when json_increment is empty. " - f"Expected {len(func_args_2)}, got {updated_length}", - ) - - def test_streamed_raw_length_multiple_empty_returns(self): - """ - Test consecutive chunks that produce empty json_increment. - - Scenario: Multiple consecutive chunks that all result in empty json_increment - as the state machine buffers complex nested structures. - Purpose: Verify _streamed_raw_length advances correctly through multiple - empty-return cycles without getting stuck or reprocessing. - """ - tools = [ - Tool( - type="function", - function=Function( - name="update_settings", - parameters={ - "type": "object", - "properties": { - "name": {"type": "string"}, - "value": {"type": "string"}, - }, - }, - ), - ), - ] - - # Split XML at positions that may cause state machine buffering - chunks = [ - "update_settingsna", # Split in tag name - "meco", # Complete tag start, split value # codespell:ignore ue - "nf", # Continue value - "ig_v1val", # Complete value, split next key - "ueena", # Complete key name, split value # codespell:ignore ue - "bled", # Complete everything - ] - - detector = Glm47MoeDetector() - all_params = "" - - for i, chunk in enumerate(chunks): - result = detector.parse_streaming_increment(chunk, tools) - - for call in result.calls: - if call.parameters: - all_params += call.parameters - - # Verify final output is correct - self.assertGreater(len(all_params), 0, "Should have generated some parameters") - params = json.loads(all_params) - self.assertEqual(params["name"], "config_v1") - self.assertEqual(params["value"], "enabled") - - # Verify no duplicate keys due to reprocessing - name_count = all_params.count('"name"') - value_count = all_params.count('"value"') - self.assertEqual( - name_count, - 1, - f"'name' appears {name_count} times - indicates reprocessing bug", - ) - self.assertEqual( - value_count, - 1, - f"'value' appears {value_count} times - indicates reprocessing bug", - ) - - -class TestGlm4ComplexJsonSchema(unittest.TestCase): - """Test complex JSON Schema type inference for GLM function call parsers.""" - - def setUp(self): - """Set up test tools with complex JSON schemas.""" - self.tools_with_complex_schema = [ - Tool( - type="function", - function=Function( - name="search", - description="Search for information", - parameters={ - "type": "object", - "properties": { - "query": { - "description": "Search query, can be a string or a complex object", - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "text": {"type": "string"}, - "filters": {"type": "object"}, - }, - }, - ], - }, - "priority": {"enum": ["low", "medium", "high"]}, - "options": { - "oneOf": [{"type": "string"}, {"type": "number"}] - }, - "config": { - "allOf": [ - {"type": "object"}, - {"properties": {"timeout": {"type": "number"}}}, - ] - }, - "tags": {"type": ["string", "null"]}, - "data": { - "type": "object", - "properties": { - "nested": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "value": {"type": "string"} - }, - }, - ] - } - }, - }, - }, - "required": ["query"], - }, - ), - ), - Tool( - type="function", - function=Function( - name="get_weather", - description="Get weather information", - parameters={ - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "Location to get weather for", - }, - "unit": { - "type": "string", - "description": "Temperature unit", - "enum": ["celsius", "fahrenheit"], - }, - }, - "required": ["location"], - }, - ), - ), - ] - self.glm4_detector = Glm4MoeDetector() - self.glm47_detector = Glm47MoeDetector() - - def test_get_argument_type_simple_type(self): - """Test that get_argument_type correctly handles simple type fields.""" - result = get_argument_type( - "get_weather", "location", self.tools_with_complex_schema - ) - self.assertEqual(result, "string") - - def test_get_argument_type_enum_type(self): - """Test that get_argument_type correctly identifies enum as string type.""" - result = get_argument_type( - "get_weather", "unit", self.tools_with_complex_schema - ) - # Current implementation returns the direct type field, which is "string" for the enum parameter - # But it doesn't handle enum-only schemas properly (without type field) - self.assertEqual(result, "string") - - def test_get_argument_type_anyof_type(self): - """Test that get_argument_type correctly handles anyOf type fields.""" - result = get_argument_type("search", "query", self.tools_with_complex_schema) - # anyOf with [{"type": "string"}, {"type": "object", ...}] should return "string" - self.assertEqual(result, "string") # Returns first common type - - def test_get_argument_type_oneof_type(self): - """Test that get_argument_type correctly handles oneOf type fields.""" - result = get_argument_type("search", "options", self.tools_with_complex_schema) - # oneOf with [{"type": "string"}, {"type": "number"}] should return "string" (prioritizes string) - self.assertEqual(result, "string") - - def test_get_argument_type_allof_type(self): - """Test that get_argument_type correctly handles allOf type fields.""" - result = get_argument_type("search", "config", self.tools_with_complex_schema) - # allOf with [{"type": "object"}, ...] should return "object" - self.assertEqual(result, "object") - - def test_get_argument_type_type_array(self): - """Test that get_argument_type correctly handles type arrays.""" - result = get_argument_type("search", "tags", self.tools_with_complex_schema) - # Type arrays should return the first non-null type - self.assertEqual( - result, "string" - ) # ["string", "null"] -> "string" (non-null type) - - def test_glm4_detector_with_complex_schema_anyof(self): - """Test GLM4 detector with anyOf schema - should demonstrate current issues.""" - # This test shows the current behavior with complex schemas - text = ( - "search\n" - "query\nHello world\n" - "priority\nmedium\n" - "" - ) - result = self.glm4_detector.detect_and_parse( - text, self.tools_with_complex_schema - ) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "search") - - # Parse parameters to check if they are correctly handled - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["query"], "Hello world") - self.assertEqual(params["priority"], "medium") - - def test_glm47_detector_with_complex_schema_anyof(self): - """Test GLM47 detector with anyOf schema - should demonstrate current issues.""" - # This test shows the current behavior with complex schemas - text = ( - "search" - "queryHello world" - "prioritymedium" - "" - ) - result = self.glm47_detector.detect_and_parse( - text, self.tools_with_complex_schema - ) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "search") - - # Parse parameters to check if they are correctly handled - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["query"], "Hello world") - self.assertEqual(params["priority"], "medium") - - def test_glm4_detector_with_enum_values(self): - """Test GLM4 detector with enum values in complex schema.""" - text = ( - "search\n" - "query\ntest query\n" - "priority\nhigh\n" - "" - ) - result = self.glm4_detector.detect_and_parse( - text, self.tools_with_complex_schema - ) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "search") - - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["query"], "test query") - self.assertEqual(params["priority"], "high") - - def test_glm47_detector_with_enum_values(self): - """Test GLM47 detector with enum values in complex schema.""" - text = ( - "search" - "querytest query" - "priorityhigh" - "" - ) - result = self.glm47_detector.detect_and_parse( - text, self.tools_with_complex_schema - ) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "search") - - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["query"], "test query") - self.assertEqual(params["priority"], "high") - - def test_glm4_detector_streaming_with_complex_schema(self): - """Test GLM4 detector streaming with complex schema.""" - chunks = [ - "search\n", - "query\nnested object\n", - "priority\nlow\n", - "", - ] - tool_calls = [] - for chunk in chunks: - result = self.glm4_detector.parse_streaming_increment( - chunk, self.tools_with_complex_schema - ) - for tool_call_chunk in result.calls: - if ( - hasattr(tool_call_chunk, "tool_index") - and tool_call_chunk.tool_index is not None - ): - while len(tool_calls) <= tool_call_chunk.tool_index: - tool_calls.append({"name": "", "parameters": ""}) - tc = tool_calls[tool_call_chunk.tool_index] - if tool_call_chunk.name: - tc["name"] = tool_call_chunk.name - if tool_call_chunk.parameters: - tc["parameters"] += tool_call_chunk.parameters - - self.assertEqual(len(tool_calls), 1) - self.assertEqual(tool_calls[0]["name"], "search") - - params = json.loads(tool_calls[0]["parameters"]) - self.assertEqual(params["query"], "nested object") - self.assertEqual(params["priority"], "low") - - def test_glm47_detector_streaming_with_complex_schema(self): - """Test GLM47 detector streaming with complex schema.""" - chunks = [ - "search", - "querynested object", - "prioritylow", - "", - ] - tool_calls = [] - for chunk in chunks: - result = self.glm47_detector.parse_streaming_increment( - chunk, self.tools_with_complex_schema - ) - for tool_call_chunk in result.calls: - if ( - hasattr(tool_call_chunk, "tool_index") - and tool_call_chunk.tool_index is not None - ): - while len(tool_calls) <= tool_call_chunk.tool_index: - tool_calls.append({"name": "", "parameters": ""}) - tc = tool_calls[tool_call_chunk.tool_index] - if tool_call_chunk.name: - tc["name"] = tool_call_chunk.name - if tool_call_chunk.parameters: - tc["parameters"] += tool_call_chunk.parameters - - self.assertEqual(len(tool_calls), 1) - self.assertEqual(tool_calls[0]["name"], "search") - - params = json.loads(tool_calls[0]["parameters"]) - self.assertEqual(params["query"], "nested object") - self.assertEqual(params["priority"], "low") - - def test_type_inference_issue_reproduction(self): - """Reproduce the issue where complex JSON schemas are not properly handled.""" - # This test demonstrates the current limitations - complex_tools = [ - Tool( - type="function", - function=Function( - name="complex_function", - parameters={ - "type": "object", - "properties": { - "complex_param": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": {"value": {"type": "string"}}, - }, - ] - }, - "enum_param": {"enum": ["option1", "option2", "option3"]}, - }, - }, - ), - ) - ] - - # Test that get_argument_type returns appropriate types for complex schemas - anyof_result = get_argument_type( - "complex_function", "complex_param", complex_tools - ) - enum_result = get_argument_type("complex_function", "enum_param", complex_tools) - - # Verify complex schema types are correctly inferred - self.assertEqual(anyof_result, "string") # anyOf prioritizes string type - self.assertEqual(enum_result, "string") # enum values are strings - - def test_expected_behavior_for_complex_schemas(self): - """Test cases that should work but currently fail - demonstrating the issue.""" - # This test shows what the behavior SHOULD be after the fix - complex_tools = [ - Tool( - type="function", - function=Function( - name="complex_function", - parameters={ - "type": "object", - "properties": { - "complex_param": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": {"value": {"type": "string"}}, - }, - ] - }, - "enum_param": {"enum": ["option1", "option2", "option3"]}, - "oneof_param": { - "oneOf": [{"type": "string"}, {"type": "number"}] - }, - "allof_param": { - "allOf": [ - {"type": "object"}, - {"properties": {"timeout": {"type": "number"}}}, - ] - }, - }, - }, - ), - ) - ] - - # These assertions represent the EXPECTED behavior after implementing RFC improvements - # Currently they will fail, demonstrating the issue - anyof_result = get_argument_type( - "complex_function", "complex_param", complex_tools - ) - enum_result = get_argument_type("complex_function", "enum_param", complex_tools) - oneof_result = get_argument_type( - "complex_function", "oneof_param", complex_tools - ) - allof_result = get_argument_type( - "complex_function", "allof_param", complex_tools - ) - - # These should pass after implementing the RFC improvements, but will currently fail - # This demonstrates the issue exists - self.assertIsNotNone( - anyof_result, "anyOf should return a type after RFC implementation" - ) - self.assertEqual( - enum_result, - "string", - "enum should return 'string' type after RFC implementation", - ) - self.assertIsNotNone( - oneof_result, "oneOf should return a type after RFC implementation" - ) - self.assertIsNotNone( - allof_result, "allOf should return a type after RFC implementation" - ) - - def test_complex_schema_type_inference_scenarios(self): - """Test various complex schema scenarios mentioned in the RFC.""" - # Create tools with different complex schema structures - complex_schema_tools = [ - Tool( - type="function", - function=Function( - name="search_complex", - parameters={ - "type": "object", - "properties": { - # anyOf example - parameter can be string or object - "query": { - "description": "Search query, can be a string or a complex object", - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "text": {"type": "string"}, - "filters": {"type": "object"}, - }, - }, - ], - }, - # oneOf example - parameter must be one of the specified types - "priority": { - "oneOf": [{"type": "string"}, {"type": "integer"}] - }, - # enum example - parameter must be one of the enum values - "category": {"enum": ["news", "sports", "tech"]}, - # allOf example - parameter must satisfy all schemas - "config": { - "allOf": [ - {"type": "object"}, - {"properties": {"timeout": {"type": "number"}}}, - ] - }, - # Type array example - "tags": {"type": ["string", "null"]}, - }, - }, - ), - ), - Tool( - type="function", - function=Function( - name="get_data", - parameters={ - "type": "object", - "properties": { - # Complex nested anyOf - "input": { - "anyOf": [ - {"type": "string"}, - {"type": "number"}, - { - "type": "object", - "properties": { - "type": {"type": "string"}, - "value": {}, - }, - }, - ] - } - }, - }, - ), - ), - ] - - # Test each complex type scenario - query_type = get_argument_type("search_complex", "query", complex_schema_tools) - priority_type = get_argument_type( - "search_complex", "priority", complex_schema_tools - ) - category_type = get_argument_type( - "search_complex", "category", complex_schema_tools - ) - config_type = get_argument_type( - "search_complex", "config", complex_schema_tools - ) - tags_type = get_argument_type("search_complex", "tags", complex_schema_tools) - input_type = get_argument_type("get_data", "input", complex_schema_tools) - - # All of these should return appropriate types according to RFC - self.assertEqual(query_type, "string") # anyOf: string | object -> string - self.assertEqual(priority_type, "string") # oneOf: string | integer -> string - self.assertEqual( - category_type, "string" - ) # enum: ["news", "sports", "tech"] -> string - self.assertEqual(config_type, "object") # allOf with object -> object - self.assertEqual( - tags_type, "string" - ) # type array: ["string", "null"] -> string - self.assertEqual( - input_type, "string" - ) # nested anyOf: string | number | object -> string - - def test_glm4_detector_type_handling_with_complex_schema(self): - """Test how GLM4 detector handles type inference for complex schemas in practice.""" - complex_tools = [ - Tool( - type="function", - function=Function( - name="complex_search", - parameters={ - "type": "object", - "properties": { - "query": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": {"text": {"type": "string"}}, - }, - ] - }, - "category": {"enum": ["tech", "news", "sports"]}, - }, - }, - ), - ) - ] - - # Test with string value for anyOf parameter - text = ( - "complex_search\n" - "query\ntest search\n" - "category\ntech\n" - "" - ) - result = self.glm4_detector.detect_and_parse(text, complex_tools) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "complex_search") - - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["query"], "test search") - self.assertEqual(params["category"], "tech") - - def test_glm47_detector_type_handling_with_complex_schema(self): - """Test how GLM47 detector handles type inference for complex schemas in practice.""" - complex_tools = [ - Tool( - type="function", - function=Function( - name="complex_search", - parameters={ - "type": "object", - "properties": { - "query": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": {"text": {"type": "string"}}, - }, - ] - }, - "category": {"enum": ["tech", "news", "sports"]}, - }, - }, - ), - ) - ] - - # Test with string value for anyOf parameter - text = ( - "complex_search" - "querytest search" - "categorytech" - "" - ) - result = self.glm47_detector.detect_and_parse(text, complex_tools) - - self.assertEqual(len(result.calls), 1) - self.assertEqual(result.calls[0].name, "complex_search") - - params = json.loads(result.calls[0].parameters) - self.assertEqual(params["query"], "test search") - self.assertEqual(params["category"], "tech") - - def test_streaming_with_complex_schema_type_inference(self): - """Test streaming behavior with complex schema type inference.""" - complex_tools = [ - Tool( - type="function", - function=Function( - name="stream_test", - parameters={ - "type": "object", - "properties": { - "data": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": {"value": {"type": "string"}}, - }, - ] - }, - "status": {"enum": ["active", "inactive"]}, - }, - }, - ), - ) - ] - - # Test GLM4 detector streaming - chunks = [ - "stream_test\n", - "data\nnested data\n", - "status\nactive\n", - "", - ] - tool_calls = [] - for chunk in chunks: - result = self.glm4_detector.parse_streaming_increment(chunk, complex_tools) - for tool_call_chunk in result.calls: - if ( - hasattr(tool_call_chunk, "tool_index") - and tool_call_chunk.tool_index is not None - ): - while len(tool_calls) <= tool_call_chunk.tool_index: - tool_calls.append({"name": "", "parameters": ""}) - tc = tool_calls[tool_call_chunk.tool_index] - if tool_call_chunk.name: - tc["name"] = tool_call_chunk.name - if tool_call_chunk.parameters: - tc["parameters"] += tool_call_chunk.parameters - - self.assertEqual(len(tool_calls), 1) - self.assertEqual(tool_calls[0]["name"], "stream_test") - - params = json.loads(tool_calls[0]["parameters"]) - self.assertEqual(params["data"], "nested data") - self.assertEqual(params["status"], "active") - - def test_streaming_with_complex_schema_type_inference_glm47(self): - """Test GLM47 streaming behavior with complex schema type inference.""" - complex_tools = [ - Tool( - type="function", - function=Function( - name="stream_test", - parameters={ - "type": "object", - "properties": { - "data": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": {"value": {"type": "string"}}, - }, - ] - }, - "status": {"enum": ["active", "inactive"]}, - }, - }, - ), - ) - ] - - # Test GLM47 detector streaming - chunks = [ - "stream_test", - "datanested data", - "statusactive", - "", - ] - tool_calls = [] - for chunk in chunks: - result = self.glm47_detector.parse_streaming_increment(chunk, complex_tools) - for tool_call_chunk in result.calls: - if ( - hasattr(tool_call_chunk, "tool_index") - and tool_call_chunk.tool_index is not None - ): - while len(tool_calls) <= tool_call_chunk.tool_index: - tool_calls.append({"name": "", "parameters": ""}) - tc = tool_calls[tool_call_chunk.tool_index] - if tool_call_chunk.name: - tc["name"] = tool_call_chunk.name - if tool_call_chunk.parameters: - tc["parameters"] += tool_call_chunk.parameters - - self.assertEqual(len(tool_calls), 1) - self.assertEqual(tool_calls[0]["name"], "stream_test") - - params = json.loads(tool_calls[0]["parameters"]) - self.assertEqual(params["data"], "nested data") - self.assertEqual(params["status"], "active") - - if __name__ == "__main__": - unittest.main() diff --git a/test/registered/unit/mem_cache/test_streaming_session_unit.py b/test/registered/unit/mem_cache/test_streaming_session_unit.py index 3caee6887..b4ac9e8c3 100644 --- a/test/registered/unit/mem_cache/test_streaming_session_unit.py +++ b/test/registered/unit/mem_cache/test_streaming_session_unit.py @@ -4,7 +4,6 @@ import torch from sglang.srt.managers.schedule_batch import FINISH_ABORT from sglang.srt.mem_cache.base_prefix_cache import MatchResult -from sglang.srt.mem_cache.common import release_kv_cache from sglang.srt.session.streaming_session import SessionSlot, StreamingSession from sglang.test.ci.ci_register import register_cpu_ci @@ -76,6 +75,7 @@ class _FakeReq: self.pop_overallocated_calls = 0 self.to_finish = None self.finished_reason = None + self.finished_len = None def pop_committed_kv_cache(self): assert not self.kv_committed_freed @@ -89,36 +89,6 @@ class _FakeReq: return self.kv_committed_len, self.kv_allocated_len -def test_streaming_release_kv_cache_defers_tail_free(monkeypatch): - """Spec tail is NOT trimmed in cache_finished_req; it is deferred to - match_prefix's orphan tail free on the next turn. cache_finished_req - only sets bookkeeping flags and saves the slot as-is.""" - page_size = 16 - req_to_token = torch.arange(128, dtype=torch.int32).reshape(1, 128) - req_to_token_pool = SimpleNamespace(req_to_token=req_to_token, free_slots=[]) - allocator = _FakeAllocator() - tree_cache = StreamingSession( - _FakeInnerCache(req_to_token_pool, allocator, page_size) - ) - req = _FakeReq("session-a", req_pool_idx=0, committed=17, allocated=40) - - monkeypatch.setattr( - "sglang.srt.mem_cache.common.get_global_server_args", - lambda: SimpleNamespace(page_size=page_size, speculative_algorithm="eagle"), - ) - - release_kv_cache(req, tree_cache) - - slot = tree_cache.slots["session-a"] - assert req.kv_committed_freed is True - assert req.kv_overallocated_freed is True - assert req.req_pool_idx is None - # Slot keeps the full allocation — tail free is deferred to match_prefix. - assert slot.kv_committed_len == 17 - assert slot.kv_allocated_len == 40 - assert len(allocator.freed) == 0 - - def test_preabort_detaches_session_and_preserves_slot(): """Pre-aborted req (to_finish set before match_prefix) is detached from the session: session=None, abort_req() called. Slot stays intact.""" @@ -271,3 +241,11 @@ def test_trim_overshoot_postcondition(): # Tail [38, 44) freed by _free_kv_aligned. assert len(allocator.freed) == 1 assert allocator.freed[0].tolist() == list(range(38, 44)) + + +if __name__ == "__main__": + import sys + + import pytest + + sys.exit(pytest.main([__file__, "-v"])) diff --git a/test/registered/unit/model_executor/test_model_hooks.py b/test/registered/unit/model_executor/test_model_hooks.py deleted file mode 100644 index 33c9ee82a..000000000 --- a/test/registered/unit/model_executor/test_model_hooks.py +++ /dev/null @@ -1,156 +0,0 @@ -import argparse -import json - -import torch -import torch.nn as nn - -from sglang.srt.model_executor.hook_manager import register_forward_hooks -from sglang.srt.server_args import ServerArgs -from sglang.test.ci.ci_register import register_amd_ci, register_cuda_ci -from sglang.test.test_utils import CustomTestCase - -register_cuda_ci(est_time=6, suite="stage-b-test-1-gpu-small") -register_amd_ci(est_time=10, suite="stage-b-test-1-gpu-small-amd") - -HOOK_CALLS = [] - - -def dummy_hook_factory(config): - """Factory that returns a forward hook capturing a tag from config.""" - tag = config.get("tag", "default") - - def hook(module, inputs, output): - HOOK_CALLS.append( - { - "module_type": type(module).__name__, - "tag": tag, - "shape": tuple(output.shape), - } - ) - return output - - return hook - - -class TinyModel(nn.Module): - def __init__(self): - super().__init__() - self.inner = nn.Sequential( - nn.Linear(4, 2), - nn.ReLU(), - ) - self.outer = nn.Sequential( - nn.Linear(4, 4), - nn.ReLU(), - self.inner, - ) - - def forward(self, x): - return self.outer(x) - - -class TestAttachHooks(CustomTestCase): - """Tests for register_forward_hooks / resolve_callable integration.""" - - def setUp(self): - HOOK_CALLS.clear() - - def test_hook_is_attached(self): - """Hook from a factory string is registered and fired.""" - hook_specs = [ - { - "target_modules": ["outer.0", "outer.1"], - "hook_factory": "test_model_hooks:dummy_hook_factory", - "config": {"tag": "forward-ok"}, - }, - { - "target_modules": ["inner.*"], - "hook_factory": "test_model_hooks:dummy_hook_factory", - "config": {"tag": "forward-ok"}, - }, - ] - - model = TinyModel() - register_forward_hooks(model, hook_specs) - - x = torch.randn(3, 4) - _ = model(x) - - self.assertEqual( - len(HOOK_CALLS), - 4, - "Forward hook was not called correct number of times", - ) - tags = {call["tag"] for call in HOOK_CALLS} - self.assertIn("forward-ok", tags) - - def test_no_matching_modules_does_not_crash(self): - """Hook spec with no matching modules should not crash.""" - model = TinyModel() - hook_specs = [ - { - "name": "no_match", - "target_modules": ["does_not_exist.*"], - "hook_factory": "test_model_hooks:dummy_hook_factory", - "config": {"tag": "unused"}, - } - ] - - register_forward_hooks(model, hook_specs) - - x = torch.randn(3, 4) - _ = model(x) - - # No hooks should have fired - self.assertEqual(len(HOOK_CALLS), 0) - - def test_cli_hooks_reach_model(self): - """ - Ensure that when hooks are provided via CLI, they are parsed into - ServerArgs, passed to register_forward_hooks, and actually - run during a forward pass. - """ - parser = argparse.ArgumentParser() - ServerArgs.add_cli_args(parser) - - hooks_spec = [ - { - "name": "outer_and_inner_from_cli", - "target_modules": ["outer.0", "outer.1", "inner.*"], - "hook_factory": "test_model_hooks:dummy_hook_factory", - "config": {"tag": "cli-hook"}, - } - ] - - cli_args = [ - "--model-path", - "Qwen/Qwen2-7B-Instruct", # Dummy value; not used in this test - "--forward-hooks", - json.dumps(hooks_spec), - ] - - args = parser.parse_args(cli_args) - server_args = ServerArgs.from_cli_args(args) - - self.assertEqual(server_args.forward_hooks, hooks_spec) - - model = TinyModel() - register_forward_hooks(model, server_args.forward_hooks) - - x = torch.randn(3, 4) - _ = model(x) - - # We expect hooks on outer.0, outer.1, inner.0, inner.1 => 4 calls - self.assertEqual( - len(HOOK_CALLS), - 4, - "CLI-configured hooks did not fire expected number of times", - ) - - tags = {call["tag"] for call in HOOK_CALLS} - self.assertEqual(tags, {"cli-hook"}) - - -if __name__ == "__main__": - pass - # unittest.main() diff --git a/test/registered/vlm/test_patch_embed_perf.py b/test/registered/vlm/test_patch_embed_perf.py deleted file mode 100644 index 052d17095..000000000 --- a/test/registered/vlm/test_patch_embed_perf.py +++ /dev/null @@ -1,166 +0,0 @@ -import os -import statistics - -import pytest -import torch -import torch.nn as nn - -from sglang.srt.models.glm4v import Glm4vVisionPatchEmbed -from sglang.test.ci.ci_register import register_cuda_ci - -register_cuda_ci(est_time=8, suite="stage-b-test-1-gpu-large") - -PATCH_SIZE = 14 -TEMPORAL_PATCH_SIZE = 2 -IN_CHANNELS = 3 -HIDDEN_SIZE = 1536 -FLAT_DIM = IN_CHANNELS * TEMPORAL_PATCH_SIZE * PATCH_SIZE * PATCH_SIZE - - -class ReferenceConv3dPatchEmbed(nn.Module): - def __init__( - self, - patch_size=PATCH_SIZE, - temporal_patch_size=TEMPORAL_PATCH_SIZE, - in_channels=IN_CHANNELS, - hidden_size=HIDDEN_SIZE, - ): - super().__init__() - self.patch_size = patch_size - self.temporal_patch_size = temporal_patch_size - self.in_channels = in_channels - self.hidden_size = hidden_size - - kernel_size = (temporal_patch_size, patch_size, patch_size) - self.proj = nn.Conv3d( - in_channels, - hidden_size, - kernel_size=kernel_size, - stride=kernel_size, - bias=True, - ) - - def forward(self, x: torch.Tensor) -> torch.Tensor: - x = x.view( - -1, - self.in_channels, - self.temporal_patch_size, - self.patch_size, - self.patch_size, - ) - x = self.proj(x).view(-1, self.hidden_size) - return x - - -def _build_modules(device: str, dtype: torch.dtype): - conv_mod = ReferenceConv3dPatchEmbed().to(device=device, dtype=dtype).eval() - linear_mod = ( - Glm4vVisionPatchEmbed( - patch_size=PATCH_SIZE, - temporal_patch_size=TEMPORAL_PATCH_SIZE, - in_channels=IN_CHANNELS, - hidden_size=HIDDEN_SIZE, - ) - .to(device=device, dtype=dtype) - .eval() - ) - - with torch.no_grad(): - linear_mod.proj.weight.copy_(conv_mod.proj.weight) - linear_mod.proj.bias.copy_(conv_mod.proj.bias) - - linear_mod.copy_conv3d_weight_to_linear() - - return conv_mod, linear_mod - - -def _benchmark_cuda_module( - module: nn.Module, - x: torch.Tensor, - warmup: int = 50, - inner_iters: int = 200, - repeats: int = 10, -) -> float: - assert x.is_cuda - module.eval() - - with torch.inference_mode(): - for _ in range(warmup): - module(x) - torch.cuda.synchronize() - - samples = [] - for _ in range(repeats): - start = torch.cuda.Event(enable_timing=True) - end = torch.cuda.Event(enable_timing=True) - - start.record() - for _ in range(inner_iters): - module(x) - end.record() - - torch.cuda.synchronize() - samples.append(start.elapsed_time(end) / inner_iters) - - return statistics.median(samples) - - -def test_patch_embed_linear_matches_conv3d(): - torch.manual_seed(0) - - device = "cpu" - dtype = torch.float32 - - conv_mod, linear_mod = _build_modules(device=device, dtype=dtype) - - x = torch.randn(512, FLAT_DIM, device=device, dtype=dtype) - - with torch.inference_mode(): - y_conv = conv_mod(x) - y_linear = linear_mod(x) - - torch.testing.assert_close( - y_conv, - y_linear, - rtol=1e-5, - atol=1e-5, - ) - - -@pytest.mark.skipif( - not torch.cuda.is_available(), reason="CUDA is required for perf benchmark" -) -def test_patch_embed_linear_conv3d(): - torch.manual_seed(0) - torch.backends.cudnn.benchmark = True - - device = "cuda" - dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16 - - conv_mod, linear_mod = _build_modules(device=device, dtype=dtype) - - num_patches = int(os.getenv("GLM4V_NUM_PATCHES", "4096")) - warmup = int(os.getenv("GLM4V_WARMUP", "50")) - inner_iters = int(os.getenv("GLM4V_INNER_ITERS", "200")) - repeats = int(os.getenv("GLM4V_REPEATS", "10")) - - x = torch.randn(num_patches, FLAT_DIM, device=device, dtype=dtype).contiguous() - - conv_ms = _benchmark_cuda_module( - conv_mod, x, warmup=warmup, inner_iters=inner_iters, repeats=repeats - ) - linear_ms = _benchmark_cuda_module( - linear_mod, x, warmup=warmup, inner_iters=inner_iters, repeats=repeats - ) - - speedup = conv_ms / linear_ms - print( - f"\n[patch_embed perf] conv3d={conv_ms:.4f} ms | " - f"linear={linear_ms:.4f} ms | speedup={speedup:.3f}x" - ) - - min_speedup = float(os.getenv("GLM4V_MIN_SPEEDUP", "1.00")) - assert speedup >= min_speedup, ( - f"Expected speedup >= {min_speedup:.3f}x, but got {speedup:.3f}x " - f"(conv3d={conv_ms:.4f} ms, linear={linear_ms:.4f} ms)" - )