From 908c7186af30447e5343ad33dff65dd25831d291 Mon Sep 17 00:00:00 2001 From: Prozac614 Date: Wed, 10 Dec 2025 16:51:47 +0800 Subject: [PATCH] [diffusion] CI: Add LoRA support to diffusion server configuration and test cases (#14697) --- .../runtime/pipelines_core/lora_pipeline.py | 1 - .../test/server/perf_baselines.json | 193 ++++++++++++++++++ .../test/server/test_server_common.py | 114 +++++++++++ .../test/server/testcase_configs.py | 55 +++++ 4 files changed, 362 insertions(+), 1 deletion(-) diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/lora_pipeline.py b/python/sglang/multimodal_gen/runtime/pipelines_core/lora_pipeline.py index 8dc85dcc8..1e6876f26 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/lora_pipeline.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/lora_pipeline.py @@ -261,7 +261,6 @@ class LoRAPipeline(ComposedPipelineBase): else: continue - print(f"{name} -> {target_name}") if target_name in self.lora_adapters[lora_nickname]: raise ValueError( f"Dit target weight name {target_name} already exists in lora_adapters[{lora_nickname}]" diff --git a/python/sglang/multimodal_gen/test/server/perf_baselines.json b/python/sglang/multimodal_gen/test/server/perf_baselines.json index 45224de95..90a688efa 100644 --- a/python/sglang/multimodal_gen/test/server/perf_baselines.json +++ b/python/sglang/multimodal_gen/test/server/perf_baselines.json @@ -1013,6 +1013,199 @@ "expected_e2e_ms": 814884.71, "expected_avg_denoise_ms": 16062.92, "expected_median_denoise_ms": 16039.62 + }, + "wan2_2_t2v_a14b_lora_2gpu": { + "stages_ms": { + "InputValidationStage": 0.06, + "TextEncodingStage": 2582.35, + "ConditioningStage": 0.02, + "TimestepPreparationStage": 2.11, + "LatentPreparationStage": 1.45, + "DenoisingStage": 80688.12, + "DecodingStage": 1346.2, + "per_frame_generation": null + }, + "denoise_step_ms": { + "0": 19129.79, + "1": 770.1, + "2": 895.12, + "3": 756.23, + "4": 761.9, + "5": 758.9, + "6": 760.59, + "7": 756.3, + "8": 761.7, + "9": 754.23, + "10": 756.11, + "11": 755.59, + "12": 756.8, + "13": 763.12, + "14": 757.43, + "15": 760.82, + "16": 758.13, + "17": 759.67, + "18": 756.83, + "19": 757.86, + "20": 757.75, + "21": 757.41, + "22": 755.53, + "23": 759.37, + "24": 758.09, + "25": 756.58, + "26": 32148.89, + "27": 760.46, + "28": 756.69, + "29": 756.47, + "30": 759.02, + "31": 757.31, + "32": 754.43, + "33": 759.34, + "34": 760.11, + "35": 758.23, + "36": 763.78, + "37": 758.4, + "38": 758.26, + "39": 758.46 + }, + "expected_e2e_ms": 84633.71, + "expected_avg_denoise_ms": 2006.04, + "expected_median_denoise_ms": 758.24 + }, + "wan2_1_t2v_1_3b_lora_1gpu": { + "stages_ms": { + "InputValidationStage": 0.08, + "TextEncodingStage": 2392.95, + "ConditioningStage": 0.02, + "TimestepPreparationStage": 2.19, + "LatentPreparationStage": 1.28, + "DenoisingStage": 8752.65, + "DecodingStage": 743.42, + "per_frame_generation": null + }, + "denoise_step_ms": { + "0": 1782.77, + "1": 149.53, + "2": 147.3, + "3": 143.89, + "4": 143.48, + "5": 142.72, + "6": 141.11, + "7": 142.73, + "8": 138.91, + "9": 143.41, + "10": 142.53, + "11": 139.12, + "12": 142.67, + "13": 143.35, + "14": 142.36, + "15": 139.34, + "16": 142.94, + "17": 141.88, + "18": 138.48, + "19": 148.75, + "20": 138.57, + "21": 138.3, + "22": 138.4, + "23": 137.78, + "24": 138.1, + "25": 138.35, + "26": 138.75, + "27": 138.31, + "28": 138.48, + "29": 137.58, + "30": 137.96, + "31": 145.2, + "32": 145.89, + "33": 143.23, + "34": 144.19, + "35": 142.49, + "36": 141.82, + "37": 142.4, + "38": 144.86, + "39": 144.42, + "40": 142.97, + "41": 142.26, + "42": 142.75, + "43": 142.45, + "44": 142.62, + "45": 145.97, + "46": 147.18, + "47": 143.28, + "48": 142.66, + "49": 142.32 + }, + "expected_e2e_ms": 11905.69, + "expected_avg_denoise_ms": 174.94, + "expected_median_denoise_ms": 142.57 + }, + "wan2_1_i2v_14b_lora_2gpu": { + "stages_ms": { + "InputValidationStage": 37.9, + "TextEncodingStage": 2581.79, + "ImageEncodingStage": 1607.56, + "ConditioningStage": 0.01, + "TimestepPreparationStage": 2.13, + "LatentPreparationStage": 0.13, + "ImageVAEEncodingStage": 2405.17, + "DenoisingStage": 188146.58, + "DecodingStage": 3392.12, + "per_frame_generation": null + }, + "denoise_step_ms": { + "0": 5373.79, + "1": 3671.13, + "2": 3676.21, + "3": 3678.12, + "4": 3687.34, + "5": 3693.02, + "6": 3699.22, + "7": 3701.94, + "8": 3709.51, + "9": 3711.35, + "10": 3713.63, + "11": 3714.26, + "12": 3732.48, + "13": 3734.85, + "14": 3718.94, + "15": 3719.6, + "16": 3724.61, + "17": 3725.0, + "18": 3727.77, + "19": 3727.89, + "20": 3726.51, + "21": 3727.26, + "22": 3726.27, + "23": 3726.55, + "24": 3725.91, + "25": 3726.53, + "26": 3725.54, + "27": 3728.39, + "28": 3724.19, + "29": 3727.76, + "30": 3720.69, + "31": 3724.38, + "32": 3723.14, + "33": 3723.73, + "34": 3728.07, + "35": 3728.38, + "36": 3745.83, + "37": 3733.19, + "38": 3724.01, + "39": 3722.51, + "40": 3733.28, + "41": 3723.43, + "42": 3724.11, + "43": 3725.56, + "44": 3720.57, + "45": 3719.53, + "46": 3713.17, + "47": 3721.05, + "48": 3721.72, + "49": 3715.61 + }, + "expected_e2e_ms": 198187.89, + "expected_avg_denoise_ms": 3751.95, + "expected_median_denoise_ms": 3724.06 } } } diff --git a/python/sglang/multimodal_gen/test/server/test_server_common.py b/python/sglang/multimodal_gen/test/server/test_server_common.py index 18b56f5d7..b7666de04 100644 --- a/python/sglang/multimodal_gen/test/server/test_server_common.py +++ b/python/sglang/multimodal_gen/test/server/test_server_common.py @@ -13,6 +13,7 @@ from typing import Any, Callable import openai import pytest +import requests from openai import OpenAI from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger @@ -54,6 +55,9 @@ def diffusion_server(case: DiffusionTestCase) -> ServerContext: extra_args += ( f" --num-gpus {server_args.num_gpus} --ulysses-degree {server_args.num_gpus}" ) + # LoRA support + if server_args.lora_path: + extra_args += f" --lora-path {server_args.lora_path}" # start server manager = ServerManager( @@ -358,6 +362,112 @@ Consider updating perf_baselines.json with the snippets below: """ logger.error(output) + def _test_lora_api_functionality( + self, + ctx: ServerContext, + case: DiffusionTestCase, + generate_fn: Callable[[str, openai.Client], str], + ) -> None: + """ + Test LoRA API functionality with end-to-end validation: merge, unmerge, and set_lora. + This test verifies that each API call succeeds AND that generation works after each operation. + """ + base_url = f"http://localhost:{ctx.port}/v1" + client = OpenAI(base_url=base_url, api_key="dummy") + + # Test 1: unmerge_lora_weights - API should succeed and generation should work + logger.info("[LoRA E2E] Testing unmerge_lora_weights for %s", case.id) + resp = requests.post(f"{base_url}/unmerge_lora_weights") + assert resp.status_code == 200, f"unmerge_lora_weights failed: {resp.text}" + + logger.info("[LoRA E2E] Verifying generation after unmerge for %s", case.id) + output_after_unmerge = generate_fn(case.id, client) + assert output_after_unmerge is not None, "Generation after unmerge failed" + logger.info("[LoRA E2E] Generation after unmerge succeeded") + + # Test 2: merge_lora_weights - API should succeed and generation should work + logger.info("[LoRA E2E] Testing merge_lora_weights for %s", case.id) + resp = requests.post(f"{base_url}/merge_lora_weights") + assert resp.status_code == 200, f"merge_lora_weights failed: {resp.text}" + + logger.info("[LoRA E2E] Verifying generation after re-merge for %s", case.id) + output_after_merge = generate_fn(case.id, client) + assert output_after_merge is not None, "Generation after merge failed" + logger.info("[LoRA E2E] Generation after merge succeeded") + + # Test 3: set_lora (re-set the same adapter) - API should succeed and generation should work + logger.info("[LoRA E2E] Testing set_lora for %s", case.id) + resp = requests.post(f"{base_url}/set_lora", json={"lora_nickname": "default"}) + assert resp.status_code == 200, f"set_lora failed: {resp.text}" + + logger.info("[LoRA E2E] Verifying generation after set_lora for %s", case.id) + output_after_set = generate_fn(case.id, client) + assert output_after_set is not None, "Generation after set_lora failed" + logger.info("[LoRA E2E] Generation after set_lora succeeded") + + logger.info("[LoRA E2E] All LoRA API E2E tests passed for %s", case.id) + + def _test_lora_dynamic_switch_e2e( + self, + ctx: ServerContext, + case: DiffusionTestCase, + generate_fn: Callable[[str, openai.Client], str], + second_lora_path: str, + ) -> None: + """ + Test dynamic LoRA switching with end-to-end validation. + This test verifies that switching between LoRA adapters works correctly + and generation succeeds after each switch. + """ + base_url = f"http://localhost:{ctx.port}/v1" + client = OpenAI(base_url=base_url, api_key="dummy") + + # Test 1: Generate with initial LoRA + logger.info( + "[LoRA Switch E2E] Testing generation with initial LoRA for %s", case.id + ) + output_initial = generate_fn(case.id, client) + assert output_initial is not None, "Generation with initial LoRA failed" + logger.info("[LoRA Switch E2E] Generation with initial LoRA succeeded") + + # Test 2: Switch to second LoRA and generate + logger.info( + "[LoRA Switch E2E] Switching to second LoRA adapter for %s", case.id + ) + resp = requests.post( + f"{base_url}/set_lora", + json={"lora_nickname": "lora2", "lora_path": second_lora_path}, + ) + assert ( + resp.status_code == 200 + ), f"set_lora to second adapter failed: {resp.text}" + + logger.info( + "[LoRA Switch E2E] Verifying generation with second LoRA for %s", case.id + ) + output_second = generate_fn(case.id, client) + assert output_second is not None, "Generation with second LoRA failed" + logger.info("[LoRA Switch E2E] Generation with second LoRA succeeded") + + # Test 3: Switch back to original LoRA and generate + logger.info("[LoRA Switch E2E] Switching back to original LoRA for %s", case.id) + resp = requests.post(f"{base_url}/set_lora", json={"lora_nickname": "default"}) + assert resp.status_code == 200, f"set_lora back to default failed: {resp.text}" + + logger.info( + "[LoRA Switch E2E] Verifying generation after switching back for %s", + case.id, + ) + output_switched_back = generate_fn(case.id, client) + assert ( + output_switched_back is not None + ), "Generation after switching back failed" + logger.info("[LoRA Switch E2E] Generation after switching back succeeded") + + logger.info( + "[LoRA Switch E2E] All dynamic switch E2E tests passed for %s", case.id + ) + def test_diffusion_perf( self, case: DiffusionTestCase, @@ -382,3 +492,7 @@ Consider updating perf_baselines.json with the snippets below: generate_fn, ) self._validate_and_record(case, perf_record) + + # LoRA API functionality test with E2E validation (only for LoRA-enabled cases) + if case.server_args.lora_path: + self._test_lora_api_functionality(diffusion_server, case, generate_fn) diff --git a/python/sglang/multimodal_gen/test/server/testcase_configs.py b/python/sglang/multimodal_gen/test/server/testcase_configs.py index 3e8ed441a..75208a56e 100644 --- a/python/sglang/multimodal_gen/test/server/testcase_configs.py +++ b/python/sglang/multimodal_gen/test/server/testcase_configs.py @@ -119,6 +119,8 @@ class DiffusionServerArgs: custom_validator: str | None = None # optional custom validator name # resources num_gpus: int = 1 + # LoRA + lora_path: str | None = None # LoRA adapter path (HF repo or local path) @dataclass(frozen=True) @@ -300,6 +302,24 @@ ONE_GPU_CASES_B: list[DiffusionTestCase] = [ output_size="848x480", ), ), + # LoRA test case for single transformer + merge/unmerge API test + DiffusionTestCase( + "wan2_1_t2v_1_3b_lora_1gpu", + DiffusionServerArgs( + model_path="Wan-AI/Wan2.1-T2V-1.3B-Diffusers", + modality="video", + warmup_text=0, + warmup_edit=0, + custom_validator="video", + num_gpus=1, + lora_path="Cseti/Wan-LoRA-Arcane-Jinx-v1", + ), + DiffusionSamplingParams( + prompt="csetiarcane Nfj1nx with blue hair, a woman walking in a cyberpunk city at night", + output_size="480x320", + num_frames=8, + ), + ), # NOTE(mick): flaky # DiffusionTestCase( # id="hunyuan_video", @@ -402,6 +422,23 @@ TWO_GPU_CASES_A = [ output_size="720x480", ), ), + # LoRA test case for transformer_2 support + DiffusionTestCase( + "wan2_2_t2v_a14b_lora_2gpu", + DiffusionServerArgs( + model_path="Wan-AI/Wan2.2-T2V-A14B-Diffusers", + modality="video", + warmup_text=0, + warmup_edit=0, + custom_validator="video", + num_gpus=2, + lora_path="Cseti/wan2.2-14B-Arcane_Jinx-lora-v1", + ), + DiffusionSamplingParams( + prompt="Nfj1nx with blue hair, a woman walking in a cyberpunk city at night", + output_size="720x480", + ), + ), DiffusionTestCase( "wan2_1_t2v_14b_2gpu", DiffusionServerArgs( @@ -436,6 +473,24 @@ TWO_GPU_CASES_B = [ image_path="https://github.com/lm-sys/lm-sys.github.io/releases/download/test/TI2I_Qwen_Image_Edit_Input.jpg", ), ), + # I2V LoRA test case + DiffusionTestCase( + "wan2_1_i2v_14b_lora_2gpu", + DiffusionServerArgs( + model_path="Wan-AI/Wan2.1-I2V-14B-720P-Diffusers", + modality="video", + warmup_text=0, + warmup_edit=0, + custom_validator="video", + num_gpus=2, + lora_path="starsfriday/Wan2.1-Divine-Power-LoRA", + ), + DiffusionSamplingParams( + prompt="faxiang, the person raises hands, a giant translucent golden figure appears behind", + image_path="https://github.com/lm-sys/lm-sys.github.io/releases/download/test/TI2I_Qwen_Image_Edit_Input.jpg", + output_size="832x1104", + ), + ), DiffusionTestCase( "wan2_1_i2v_14b_720P_2gpu", DiffusionServerArgs(