diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index 9c1c3b82d..ca7566a8d 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1245,6 +1245,45 @@ jobs: with: artifact-suffix: ${{ matrix.part }} + multimodal-gen-unit-test: + needs: [check-changes, call-gate, sgl-kernel-build-wheels] + if: | + always() && + ( + (inputs.target_stage == 'multimodal-gen-unit-test') || + ( + !inputs.target_stage && + ((github.event_name == 'schedule' || inputs.test_parallel_dispatch == true) || (!failure() && !cancelled())) && + needs.check-changes.outputs.multimodal_gen == 'true' + ) + ) + runs-on: 1-gpu-runner + timeout-minutes: 120 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.pr_head_sha || inputs.ref || github.sha }} + + - name: Download artifacts + if: needs.check-changes.outputs.sgl_kernel == 'true' + uses: actions/download-artifact@v4 + with: + path: sgl-kernel/dist/ + merge-multiple: true + pattern: wheel-python3.10-cuda12.9 + + - name: Install dependencies + timeout-minutes: 20 + run: | + CUSTOM_BUILD_SGL_KERNEL=${{needs.check-changes.outputs.sgl_kernel}} bash scripts/ci/cuda/ci_install_dependency.sh diffusion + + - name: Run diffusion unit tests + timeout-minutes: 60 + run: | + cd python + python3 sglang/multimodal_gen/test/run_suite.py --suite unit + stage-c-test-4-gpu-h100: needs: [check-changes, call-gate, wait-for-stage-b] if: | @@ -1669,6 +1708,7 @@ jobs: jit-kernel-unit-test-nightly, jit-kernel-benchmark-test, + multimodal-gen-unit-test, multimodal-gen-test-1-gpu, multimodal-gen-test-2-gpu, diff --git a/python/sglang/multimodal_gen/runtime/server_args.py b/python/sglang/multimodal_gen/runtime/server_args.py index e330fdb60..acd41d59a 100644 --- a/python/sglang/multimodal_gen/runtime/server_args.py +++ b/python/sglang/multimodal_gen/runtime/server_args.py @@ -44,6 +44,7 @@ from sglang.multimodal_gen.utils import ( FlexibleArgumentParser, StoreBoolean, expand_path_fields, + expand_path_kwargs, ) logger = init_logger(__name__) @@ -932,6 +933,7 @@ class ServerArgs: @classmethod def from_dict(cls, kwargs: dict[str, Any]) -> "ServerArgs": """Create a ServerArgs object from a dictionary.""" + kwargs = expand_path_kwargs(dict(kwargs)) attrs = [attr.name for attr in dataclasses.fields(cls)] server_args_kwargs: dict[str, Any] = {} diff --git a/python/sglang/multimodal_gen/test/run_suite.py b/python/sglang/multimodal_gen/test/run_suite.py index a0def40f6..f7182b9bb 100644 --- a/python/sglang/multimodal_gen/test/run_suite.py +++ b/python/sglang/multimodal_gen/test/run_suite.py @@ -31,10 +31,10 @@ _UPDATE_WEIGHTS_MODEL_PAIR_IDS = ( SUITES = { # no GPU required; safe to run on any CPU-only runner "unit": [ - "../unit/test_sampling_params_validate.py", + "../unit/test_sampling_params.py", "../unit/test_storage.py", "../unit/test_lora_format_adapter.py", - "../unit/test_server_args_unit.py", + "../unit/test_server_args.py", # add new unit tests here ], "1-gpu": [ @@ -68,6 +68,7 @@ suites_ascend = { } SUITES.update(suites_ascend) +STRICT_SUITES = {"unit"} def parse_args(): @@ -288,13 +289,17 @@ def main(): for f_rel in suite_files_rel: f_abs = target_dir / f_rel if not f_abs.exists(): - print(f"Warning: Test file {f_rel} not found in {target_dir}. Skipping.") + msg = f"Test file {f_rel} not found in {target_dir}." + if args.suite in STRICT_SUITES: + print(f"Error: {msg}") + sys.exit(1) + print(f"Warning: {msg} Skipping.") continue suite_files_abs.append(str(f_abs)) if not suite_files_abs: print(f"No valid test files found for suite '{args.suite}'.") - sys.exit(0) + sys.exit(1 if args.suite in STRICT_SUITES else 0) # 3. collect all test items and partition by items (not files) all_test_items = collect_test_items(suite_files_abs, filter_expr=args.filter) diff --git a/python/sglang/multimodal_gen/test/unit/test_server_args_unit.py b/python/sglang/multimodal_gen/test/unit/test_server_args.py similarity index 71% rename from python/sglang/multimodal_gen/test/unit/test_server_args_unit.py rename to python/sglang/multimodal_gen/test/unit/test_server_args.py index cee6e2cf3..a25e62898 100644 --- a/python/sglang/multimodal_gen/test/unit/test_server_args_unit.py +++ b/python/sglang/multimodal_gen/test/unit/test_server_args.py @@ -3,22 +3,48 @@ import sys import unittest from unittest.mock import patch +from sglang.multimodal_gen.configs.pipeline_configs.base import PipelineConfig +from sglang.multimodal_gen.configs.pipeline_configs.qwen_image import ( + QwenImagePipelineConfig, +) from sglang.multimodal_gen.registry import _get_config_info from sglang.multimodal_gen.runtime.server_args import ServerArgs from sglang.multimodal_gen.utils import FlexibleArgumentParser class TestServerArgsPathExpansion(unittest.TestCase): + def _from_dict_without_model_resolution(self, kwargs): + with patch.object( + PipelineConfig, "from_kwargs", return_value=QwenImagePipelineConfig() + ): + return ServerArgs.from_dict(kwargs) + def test_tilde_model_path_is_expanded(self): - args = ServerArgs.from_dict({"model_path": "~/fake/local/model"}) + args = self._from_dict_without_model_resolution( + {"model_path": "~/fake/local/model"} + ) expected = os.path.expanduser("~/fake/local/model") self.assertEqual(args.model_path, expected) self.assertFalse(args.model_path.startswith("~")) def test_absolute_path_is_unchanged(self): - args = ServerArgs.from_dict({"model_path": "/data/my-model"}) + args = self._from_dict_without_model_resolution( + {"model_path": "/data/my-model"} + ) self.assertEqual(args.model_path, "/data/my-model") + def test_component_paths_are_expanded_before_pipeline_resolution(self): + args = self._from_dict_without_model_resolution( + { + "model_path": "/data/my-model", + "component_paths": {"vae": "~/fake/local/vae"}, + } + ) + + self.assertEqual( + args.component_paths["vae"], os.path.expanduser("~/fake/local/vae") + ) + class TestModelIdResolution(unittest.TestCase): def setUp(self): @@ -29,9 +55,6 @@ class TestModelIdResolution(unittest.TestCase): # --model-id tells the engine which config to use info = _get_config_info("/data/my-custom-qwen", model_id="Qwen-Image") self.assertIsNotNone(info) - from sglang.multimodal_gen.configs.pipeline_configs.qwen_image import ( - QwenImagePipelineConfig, - ) self.assertIs(info.pipeline_config_cls, QwenImagePipelineConfig) diff --git a/python/sglang/multimodal_gen/utils.py b/python/sglang/multimodal_gen/utils.py index 359fd35f3..2de9132fe 100644 --- a/python/sglang/multimodal_gen/utils.py +++ b/python/sglang/multimodal_gen/utils.py @@ -35,21 +35,25 @@ logger = init_logger(__name__) T = TypeVar("T") +def _expand_path_value(field_name: str, value: Any) -> Any: + eu = os.path.expanduser + if field_name.endswith("_path") and isinstance(value, str): + return eu(value) + if field_name.endswith("_path") and isinstance(value, list): + return [eu(x) if isinstance(x, str) else x for x in value] + if field_name.endswith("_paths") and isinstance(value, dict): + return {k: eu(p) if isinstance(p, str) else p for k, p in value.items()} + return value + + +def expand_path_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]: + return {key: _expand_path_value(key, value) for key, value in kwargs.items()} + + def expand_path_fields(obj) -> None: """In-place expanduser on all dataclass fields whose name ends with '_path' or '_paths'.""" - eu = os.path.expanduser for f in fields(obj): - v = getattr(obj, f.name) - if f.name.endswith("_path") and isinstance(v, str): - setattr(obj, f.name, eu(v)) - elif f.name.endswith("_path") and isinstance(v, list): - setattr(obj, f.name, [eu(x) if isinstance(x, str) else x for x in v]) - elif f.name.endswith("_paths") and isinstance(v, dict): - setattr( - obj, - f.name, - {k: eu(p) if isinstance(p, str) else p for k, p in v.items()}, - ) + setattr(obj, f.name, _expand_path_value(f.name, getattr(obj, f.name))) # TODO(will): used to convert server_args.precision to torch.dtype. Find a