[Feature] Implement update_weights_from_disk for SGLang-D (Diffusion … (#18306)
Co-authored-by: zhaochenyang20 <zhaochen20@outlook.com>
This commit is contained in:
co-authored by
zhaochenyang20
parent
150ed881be
commit
4f980f6f23
@@ -10,6 +10,7 @@ Example:
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -20,6 +21,13 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
_UPDATE_WEIGHTS_FROM_DISK_TEST_FILE = "test_update_weights_from_disk.py"
|
||||
_UPDATE_WEIGHTS_MODEL_PAIR_ENV = "SGLANG_MMGEN_UPDATE_WEIGHTS_PAIR"
|
||||
_UPDATE_WEIGHTS_MODEL_PAIR_IDS = (
|
||||
"FLUX.2-klein-base-4B",
|
||||
"Qwen-Image",
|
||||
)
|
||||
|
||||
SUITES = {
|
||||
"1-gpu": [
|
||||
"test_server_a.py",
|
||||
@@ -29,6 +37,7 @@ SUITES = {
|
||||
"../cli/test_generate_t2i_perf.py",
|
||||
# unit tests (no server needed)
|
||||
"../test_sampling_params_validate.py",
|
||||
"test_update_weights_from_disk.py",
|
||||
# add new 1-gpu test files here
|
||||
],
|
||||
"2-gpu": [
|
||||
@@ -225,6 +234,27 @@ def run_pytest(files, filter_expr=None):
|
||||
return returncode
|
||||
|
||||
|
||||
def _is_in_ci() -> bool:
|
||||
return os.environ.get("SGLANG_IS_IN_CI", "").lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _maybe_pin_update_weights_model_pair(suite_files_rel: list[str]) -> None:
|
||||
if not _is_in_ci():
|
||||
return
|
||||
if _UPDATE_WEIGHTS_FROM_DISK_TEST_FILE not in suite_files_rel:
|
||||
return
|
||||
if os.environ.get(_UPDATE_WEIGHTS_MODEL_PAIR_ENV):
|
||||
print(
|
||||
f"Using preset {_UPDATE_WEIGHTS_MODEL_PAIR_ENV}="
|
||||
f"{os.environ[_UPDATE_WEIGHTS_MODEL_PAIR_ENV]}"
|
||||
)
|
||||
return
|
||||
|
||||
selected_pair = random.choice(_UPDATE_WEIGHTS_MODEL_PAIR_IDS)
|
||||
os.environ[_UPDATE_WEIGHTS_MODEL_PAIR_ENV] = selected_pair
|
||||
print(f"Selected {_UPDATE_WEIGHTS_MODEL_PAIR_ENV}={selected_pair} for this CI run")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
|
||||
@@ -239,6 +269,7 @@ def main():
|
||||
|
||||
# 2. get files from suite
|
||||
suite_files_rel = SUITES[args.suite]
|
||||
_maybe_pin_update_weights_model_pair(suite_files_rel)
|
||||
|
||||
suite_files_abs = []
|
||||
for f_rel in suite_files_rel:
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
"""Tests for diffusion `update_weights_from_disk`.
|
||||
|
||||
This module verifies the ability to update model weights in place without restarting
|
||||
the server, which is critical for RL workflows and iterative fine-tuning scenarios.
|
||||
|
||||
Author:
|
||||
|
||||
Menyang Liu, https://github.com/dreamyang-liu
|
||||
Chenyang Zhao, https://github.com/zhaochenyang20
|
||||
|
||||
We use two model pairs for testing (base model / instruct model pairs):
|
||||
|
||||
- FLUX.2-klein-base-4B / FLUX.2-klein-4B
|
||||
- Qwen/Qwen-Image / Qwen/Qwen-Image-2512
|
||||
|
||||
These model pairs share the same architecture but differ in transformer
|
||||
weights. The basic testing logic is to refit the instruct model into the
|
||||
base model and verify the checksum of the transformer weights are the same,
|
||||
which simulates the real-world RL scenario. However, since these two model
|
||||
pairs only differ in transformer weights, and we want to verify update a
|
||||
specific module with update_weights_from_disk API, we need to create a perturbed
|
||||
instruct model that adds noise to the vae weights. In this sense, the instruct
|
||||
model differs from the base model in vae and transformer weights, the text
|
||||
encoder are still the same.
|
||||
|
||||
To strictly verify the correctness of the refit API, we compare the checksum in
|
||||
SHA-256 on the disk and the server.
|
||||
|
||||
NOTE and TODO: In the refit a specific module test, we randomly select one module
|
||||
from the transformer and vae to refit the server and keep other modules the same.
|
||||
As described above, the vae's weights are perturbed. If we select the vae to be the
|
||||
target module, ideally speaking, we should assert that the refitted vae's checksum
|
||||
is the same as directly computed from the perturbed vae weights in the disk. However,
|
||||
since the there is complex weight-name remapping and QKV merge during model loading,
|
||||
it is not easy to compare the server-disk checksum for vae and text encoder directly.
|
||||
Therefore, if the target module is vae, we only verify that the refitted vae's checksum
|
||||
is different from the base model's vae's checksum.
|
||||
|
||||
It should be good issue to solve for the community to adds comparison the server-disk
|
||||
checksum for vae and text encoder in this test.
|
||||
|
||||
=============================================================================
|
||||
|
||||
Test organization:
|
||||
|
||||
7 test cases in 2 classes;
|
||||
two model pairs are tested locally, one in CI.
|
||||
|
||||
=============================================================================
|
||||
|
||||
Class 1: TestUpdateWeightsFromDisk (6 tests) — API contract, checksum & rollback
|
||||
Class 2: TestUpdateWeightsFromDiskWithOffload (1 test) — Offload-aware update + checksum
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Class 1: TestUpdateWeightsFromDisk
|
||||
|
||||
Validate the update_weights_from_disk API contract, request/response shape,
|
||||
error handling, checksum verification, and corrupted-weight rollback.
|
||||
|
||||
All tests share one class-scoped server (same process, same in-memory weights).
|
||||
Tests that require "base model then update" should be explicitly reset to
|
||||
base model first so behavior is order-independent and updates are real
|
||||
(base -> perturbed), not no-ops (perturbed -> perturbed).
|
||||
|
||||
• test_update_weights_from_disk_default
|
||||
|
||||
base model -> perturbed model with flush_cache=True.
|
||||
Verifies after-update transformer checksum == perturbed model's
|
||||
transformer disk checksum
|
||||
|
||||
|
||||
• test_update_weights_specific_modules
|
||||
|
||||
base -> perturbed with flush_cache=False. Randomly selects one module
|
||||
from _DIFFERING_MODULES (transformer and vae) as target_modules, updates
|
||||
only that module. Verifies that:
|
||||
(1) targeted module's in-memory checksum changed;
|
||||
(2) non-targeted modules' in-memory checksums are unchanged.
|
||||
|
||||
• test_update_weights_nonexistent_model
|
||||
|
||||
model_path set to a non-existent path; must fail (400, success=False).
|
||||
|
||||
Ensure server is healthy after failed update and server's transformer
|
||||
checksums equal base model's transformer disk checksum.
|
||||
|
||||
• test_update_weights_missing_model_path
|
||||
|
||||
Request body empty (no model_path); must fail (400, success=False).
|
||||
|
||||
Ensure server is healthy after failed update and server's transformer
|
||||
checksums equal base model's transformer disk checksum.
|
||||
|
||||
• test_update_weights_nonexistent_module
|
||||
|
||||
target_modules=["nonexistent_module"]; must fail (400, success=False).
|
||||
|
||||
Verify server is healthy after failed update and server's checksums
|
||||
equal base model's transformer disk checksum.
|
||||
|
||||
• test_corrupted_weights_rollback
|
||||
|
||||
All-or-nothing rollback: We first refit the server from base model ->
|
||||
perturbed model. We manually truncate the vae weights of the base
|
||||
model to get a corrupted model. We then call the refit to update
|
||||
the server from the perturbed model -> corrupted model. Verify that:
|
||||
|
||||
1. The update fails due to truncated vae, server should roll back to the
|
||||
perturbed model, i.e., server's transformer weights == perturbed model's
|
||||
transformer weights != base model's transformer weights.
|
||||
|
||||
2. After the rollback, server's vae weights == perturbed model's vae
|
||||
weights != base model's vae weights.
|
||||
|
||||
3. After the rollback, server's text encoder weights == base model's
|
||||
text encoder weights == perturbed model's text encoder weights.
|
||||
|
||||
-----------------------------------------------------------------------------
|
||||
|
||||
Class 2: TestUpdateWeightsFromDiskWithOffload
|
||||
|
||||
|
||||
Ensure weight updates and checksum verification work when layerwise offload is enabled
|
||||
(--dit-layerwise-offload). With offload, parameters live in CPU buffers and only left
|
||||
small torch.empty((1,)) as placeholders on GPU; the updater must write into CPU buffers
|
||||
and update prefetched GPU tensors without shape mismatch.
|
||||
|
||||
• test_update_weights_with_offload_enabled
|
||||
|
||||
Server with --dit-layerwise-offload (base). Load perturbed checkpoint;
|
||||
must succeed (200, success=True), no "Shape mismatch". server's transformer checksum
|
||||
matches perturbed model's transformer disk checksum.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from safetensors.torch import load_file, save_file
|
||||
|
||||
from sglang.multimodal_gen.runtime.loader.utils import (
|
||||
_list_safetensors_files,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.loader.weight_utils import (
|
||||
compute_weights_checksum,
|
||||
safetensors_weights_iterator,
|
||||
)
|
||||
from sglang.multimodal_gen.runtime.utils.hf_diffusers_utils import maybe_download_model
|
||||
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||
from sglang.multimodal_gen.test.server.test_server_utils import (
|
||||
ServerManager,
|
||||
)
|
||||
from sglang.multimodal_gen.test.test_utils import get_dynamic_server_port, is_in_ci
|
||||
|
||||
logger = init_logger(__name__)
|
||||
|
||||
|
||||
_TRANSFORMER_MODULE = "transformer"
|
||||
_VAE_MODULE = "vae"
|
||||
_TEXT_ENCODER_MODULE_PREFIX = "text_encoder"
|
||||
|
||||
|
||||
# Modules whose weights differ between the base model and the perturbed
|
||||
# perturbed checkpoint
|
||||
_DIFFERING_MODULES: list[str] = [_TRANSFORMER_MODULE, _VAE_MODULE]
|
||||
|
||||
_ALL_MODEL_PAIRS: list[tuple[str, str]] = [
|
||||
(
|
||||
"black-forest-labs/FLUX.2-klein-base-4B",
|
||||
"black-forest-labs/FLUX.2-klein-4B",
|
||||
),
|
||||
(
|
||||
"Qwen/Qwen-Image",
|
||||
"Qwen/Qwen-Image-2512",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
_CI_MODEL_PAIR_ENV = "SGLANG_MMGEN_UPDATE_WEIGHTS_PAIR"
|
||||
|
||||
|
||||
def _resolve_active_model_pairs() -> list[tuple[str, str]]:
|
||||
if not is_in_ci():
|
||||
return _ALL_MODEL_PAIRS
|
||||
|
||||
pair_by_id = {pair[0].split("/")[-1]: pair for pair in _ALL_MODEL_PAIRS}
|
||||
selected_pair_id = os.environ.get(_CI_MODEL_PAIR_ENV)
|
||||
if selected_pair_id is None:
|
||||
return [random.choice(_ALL_MODEL_PAIRS)]
|
||||
|
||||
selected_pair = pair_by_id.get(selected_pair_id)
|
||||
if selected_pair is None:
|
||||
valid_ids = ", ".join(sorted(pair_by_id))
|
||||
raise ValueError(
|
||||
f"Invalid {_CI_MODEL_PAIR_ENV}={selected_pair_id!r}. "
|
||||
f"Expected one of: {valid_ids}."
|
||||
)
|
||||
return [selected_pair]
|
||||
|
||||
|
||||
_ACTIVE_MODEL_PAIRS = _resolve_active_model_pairs()
|
||||
_PAIR_IDS = [p[0].split("/")[-1] for p in _ACTIVE_MODEL_PAIRS]
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def _compute_checksum_from_disk(model_path: str, module_name: str) -> str:
|
||||
"""Compute SHA-256 checksum from safetensors files on disk.
|
||||
|
||||
Uses the same compute_weights_checksum function as the server,
|
||||
so the checksums are directly comparable.
|
||||
|
||||
Results are cached (keyed on model_path and module_name) because the
|
||||
same disk checksum is requested multiple times across tests.
|
||||
"""
|
||||
local_path = maybe_download_model(model_path)
|
||||
weights_dir = os.path.join(local_path, module_name)
|
||||
assert os.path.exists(
|
||||
weights_dir
|
||||
), f"No weights dir for {module_name} in {local_path}"
|
||||
|
||||
safetensors_files = _list_safetensors_files(weights_dir)
|
||||
assert safetensors_files, f"No safetensors files in {weights_dir}"
|
||||
|
||||
return compute_weights_checksum(safetensors_weights_iterator(safetensors_files))
|
||||
|
||||
|
||||
def _clone_model_with_modified_module(
|
||||
src_model: str,
|
||||
dst_model: str,
|
||||
target_module: str,
|
||||
transform_safetensor: Callable[[str, str], None],
|
||||
) -> None:
|
||||
# Symlink root-level files (model_index.json, etc.).
|
||||
for fname in os.listdir(src_model):
|
||||
src_path = os.path.join(src_model, fname)
|
||||
dst_path = os.path.join(dst_model, fname)
|
||||
if os.path.isfile(src_path) and not os.path.exists(dst_path):
|
||||
os.symlink(src_path, dst_path)
|
||||
|
||||
for module_dir in sorted(os.listdir(src_model)):
|
||||
src_dir = os.path.join(src_model, module_dir)
|
||||
dst_dir = os.path.join(dst_model, module_dir)
|
||||
if not os.path.isdir(src_dir):
|
||||
continue
|
||||
|
||||
if module_dir != target_module:
|
||||
if not os.path.exists(dst_dir):
|
||||
os.symlink(src_dir, dst_dir)
|
||||
continue
|
||||
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
transformed = False
|
||||
for fname in sorted(os.listdir(src_dir)):
|
||||
src_file = os.path.join(src_dir, fname)
|
||||
dst_file = os.path.join(dst_dir, fname)
|
||||
if not os.path.isfile(src_file):
|
||||
continue
|
||||
|
||||
if not fname.endswith(".safetensors") or transformed:
|
||||
if not os.path.exists(dst_file):
|
||||
os.symlink(src_file, dst_file)
|
||||
continue
|
||||
|
||||
transform_safetensor(src_file, dst_file)
|
||||
transformed = True
|
||||
|
||||
|
||||
def _truncate_safetensor(src_file: str, dst_file: str) -> None:
|
||||
shutil.copy2(src_file, dst_file)
|
||||
size = os.path.getsize(dst_file)
|
||||
with open(dst_file, "r+b") as f:
|
||||
f.truncate(size - 2)
|
||||
logger.info(
|
||||
"Created corrupted safetensors: %s (%d -> %d bytes)",
|
||||
dst_file,
|
||||
size,
|
||||
size - 2,
|
||||
)
|
||||
|
||||
|
||||
def _perturb_safetensor(src_file: str, dst_file: str) -> None:
|
||||
|
||||
tensors = load_file(src_file)
|
||||
perturbed = {
|
||||
k: (t + 0.01 if t.is_floating_point() else t) for k, t in tensors.items()
|
||||
}
|
||||
save_file(perturbed, dst_file)
|
||||
logger.info("Created perturbed safetensors: %s", dst_file)
|
||||
|
||||
|
||||
class _UpdateWeightsApiMixin:
|
||||
def _update_weights(
|
||||
self,
|
||||
base_url: str,
|
||||
model_path: str,
|
||||
flush_cache: bool = True,
|
||||
target_modules: list[str] | None = None,
|
||||
timeout: int = 300,
|
||||
) -> tuple[dict, int]:
|
||||
payload = {"model_path": model_path, "flush_cache": flush_cache}
|
||||
if target_modules is not None:
|
||||
payload["target_modules"] = target_modules
|
||||
response = requests.post(
|
||||
f"{base_url}/update_weights_from_disk",
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
)
|
||||
return response.json(), response.status_code
|
||||
|
||||
def _get_weights_checksum(
|
||||
self,
|
||||
base_url: str,
|
||||
module_names: list[str] | None = None,
|
||||
timeout: int = 300,
|
||||
) -> dict:
|
||||
payload = {}
|
||||
if module_names is not None:
|
||||
payload["module_names"] = module_names
|
||||
response = requests.post(
|
||||
f"{base_url}/get_weights_checksum",
|
||||
json=payload,
|
||||
timeout=timeout,
|
||||
)
|
||||
assert (
|
||||
response.status_code == 200
|
||||
), f"get_weights_checksum failed: {response.status_code} {response.text}"
|
||||
return response.json()
|
||||
|
||||
def _assert_server_matches_model(
|
||||
self,
|
||||
base_url: str,
|
||||
expected_model: str,
|
||||
) -> None:
|
||||
server_checksums = self._get_weights_checksum(
|
||||
base_url, module_names=[_TRANSFORMER_MODULE]
|
||||
)
|
||||
expected_cs = _compute_checksum_from_disk(expected_model, _TRANSFORMER_MODULE)
|
||||
server_cs = server_checksums.get(_TRANSFORMER_MODULE)
|
||||
assert server_cs == expected_cs, (
|
||||
f"Checksum mismatch on '{_TRANSFORMER_MODULE}'\n"
|
||||
f" expected({expected_model}): {expected_cs}\n"
|
||||
f" server: {server_cs}"
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateWeightsFromDisk(_UpdateWeightsApiMixin):
|
||||
|
||||
@pytest.fixture(
|
||||
scope="class",
|
||||
params=_ACTIVE_MODEL_PAIRS,
|
||||
ids=_PAIR_IDS,
|
||||
)
|
||||
def diffusion_server_no_offload(self, request):
|
||||
default_model, source_model = request.param
|
||||
port = get_dynamic_server_port()
|
||||
wait_deadline = float(os.environ.get("SGLANG_TEST_WAIT_SECS", "600"))
|
||||
|
||||
manager = ServerManager(
|
||||
model=default_model,
|
||||
port=port,
|
||||
wait_deadline=wait_deadline,
|
||||
extra_args="--num-gpus 1",
|
||||
)
|
||||
|
||||
# Ensure models are local before spawning threads that need the paths.
|
||||
local_default = maybe_download_model(default_model)
|
||||
local_source = maybe_download_model(source_model)
|
||||
|
||||
perturbed_vae_model_dir = tempfile.mkdtemp(prefix="sglang_perturbed_vae_")
|
||||
corrupted_vae_model_dir = tempfile.mkdtemp(prefix="sglang_corrupted_")
|
||||
|
||||
# Run all disk I/O in background while the server boots.
|
||||
bg_threads = [
|
||||
threading.Thread(
|
||||
target=_compute_checksum_from_disk, args=(default_model, module)
|
||||
)
|
||||
for module in _DIFFERING_MODULES
|
||||
] + [
|
||||
threading.Thread(
|
||||
target=_clone_model_with_modified_module,
|
||||
args=(
|
||||
local_source,
|
||||
perturbed_vae_model_dir,
|
||||
_VAE_MODULE,
|
||||
_perturb_safetensor,
|
||||
),
|
||||
),
|
||||
threading.Thread(
|
||||
target=_clone_model_with_modified_module,
|
||||
args=(
|
||||
local_default,
|
||||
corrupted_vae_model_dir,
|
||||
_VAE_MODULE,
|
||||
_truncate_safetensor,
|
||||
),
|
||||
),
|
||||
]
|
||||
for t in bg_threads:
|
||||
t.start()
|
||||
|
||||
ctx = manager.start()
|
||||
for t in bg_threads:
|
||||
t.join()
|
||||
|
||||
# Sanity: all _DIFFERING_MODULES should differ between base and perturbed.
|
||||
for module in _DIFFERING_MODULES:
|
||||
assert _compute_checksum_from_disk(
|
||||
default_model, module
|
||||
) != _compute_checksum_from_disk(perturbed_vae_model_dir, module), (
|
||||
f"Assumption violated: {module} should differ between "
|
||||
f"{default_model} and {perturbed_vae_model_dir}"
|
||||
)
|
||||
|
||||
try:
|
||||
yield ctx, default_model, perturbed_vae_model_dir, corrupted_vae_model_dir
|
||||
finally:
|
||||
ctx.cleanup()
|
||||
shutil.rmtree(perturbed_vae_model_dir, ignore_errors=True)
|
||||
shutil.rmtree(corrupted_vae_model_dir, ignore_errors=True)
|
||||
|
||||
def test_update_weights_from_disk_default(self, diffusion_server_no_offload):
|
||||
"""Default update (target_modules=None, flush_cache=True): all changed modules updated."""
|
||||
ctx, default_model, perturbed_model_dir, _ = diffusion_server_no_offload
|
||||
base_url = f"http://localhost:{ctx.port}"
|
||||
|
||||
self._update_weights(base_url, default_model, flush_cache=True)
|
||||
|
||||
result, status_code = self._update_weights(
|
||||
base_url, perturbed_model_dir, flush_cache=True
|
||||
)
|
||||
assert status_code == 200
|
||||
assert result.get("success", False), f"Update failed: {result.get('message')}"
|
||||
|
||||
self._assert_server_matches_model(base_url, perturbed_model_dir)
|
||||
|
||||
def test_update_weights_specific_modules(self, diffusion_server_no_offload):
|
||||
ctx, default_model, perturbed_model_dir, _ = diffusion_server_no_offload
|
||||
base_url = f"http://localhost:{ctx.port}"
|
||||
|
||||
# Reset server to default_model.
|
||||
self._update_weights(base_url, default_model)
|
||||
before_checksums = self._get_weights_checksum(
|
||||
base_url, module_names=_DIFFERING_MODULES
|
||||
)
|
||||
|
||||
target_modules = [random.choice(_DIFFERING_MODULES)]
|
||||
result, status_code = self._update_weights(
|
||||
base_url,
|
||||
perturbed_model_dir,
|
||||
target_modules=target_modules,
|
||||
flush_cache=False,
|
||||
)
|
||||
assert status_code == 200, f"Update failed: {result}"
|
||||
assert result.get("success", False), f"Update failed: {result.get('message')}"
|
||||
|
||||
after_checksums = self._get_weights_checksum(
|
||||
base_url, module_names=_DIFFERING_MODULES
|
||||
)
|
||||
|
||||
# Targeted module should have changed.
|
||||
for name in target_modules:
|
||||
assert after_checksums.get(name) != before_checksums.get(name), (
|
||||
f"Targeted module '{name}' checksum should change after update\n"
|
||||
f" before: {before_checksums.get(name)}\n"
|
||||
f" after: {after_checksums.get(name)}"
|
||||
)
|
||||
|
||||
# Non-targeted modules should be unchanged.
|
||||
for name, cs in after_checksums.items():
|
||||
if name in target_modules or cs == "not_found":
|
||||
continue
|
||||
assert cs == before_checksums.get(name), (
|
||||
f"Non-targeted module '{name}' should be unchanged\n"
|
||||
f" before: {before_checksums.get(name)}\n"
|
||||
f" after: {cs}"
|
||||
)
|
||||
|
||||
def test_update_weights_nonexistent_model(self, diffusion_server_no_offload):
|
||||
"""Nonexistent model path must fail (400). Server healthy, checksums == base disk."""
|
||||
ctx, default_model, _, _ = diffusion_server_no_offload
|
||||
base_url = f"http://localhost:{ctx.port}"
|
||||
|
||||
self._update_weights(base_url, default_model)
|
||||
|
||||
result, status_code = self._update_weights(
|
||||
base_url,
|
||||
"/nonexistent/path/to/model",
|
||||
timeout=60,
|
||||
)
|
||||
logger.info(f"Update result for nonexistent model: {result}")
|
||||
|
||||
assert status_code == 400, f"Expected 400, got {status_code}"
|
||||
assert not result.get("success", True), "Should fail for nonexistent model"
|
||||
self._assert_server_matches_model(base_url, default_model)
|
||||
|
||||
def test_update_weights_missing_model_path(self, diffusion_server_no_offload):
|
||||
"""Request without model_path must fail (400). Server healthy, checksums == base disk."""
|
||||
ctx, default_model, _, _ = diffusion_server_no_offload
|
||||
base_url = f"http://localhost:{ctx.port}"
|
||||
|
||||
self._update_weights(base_url, default_model)
|
||||
|
||||
response = requests.post(
|
||||
f"{base_url}/update_weights_from_disk",
|
||||
json={},
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert response.status_code == 400, f"Expected 400, got {response.status_code}"
|
||||
result = response.json()
|
||||
assert not result.get("success", True), "Should fail when model_path is missing"
|
||||
self._assert_server_matches_model(base_url, default_model)
|
||||
|
||||
def test_update_weights_nonexistent_module(self, diffusion_server_no_offload):
|
||||
"""Nonexistent module must fail (400). Server healthy, checksums == base disk."""
|
||||
ctx, default_model, perturbed_model_dir, _ = diffusion_server_no_offload
|
||||
base_url = f"http://localhost:{ctx.port}"
|
||||
|
||||
self._update_weights(base_url, default_model)
|
||||
|
||||
result, status_code = self._update_weights(
|
||||
base_url,
|
||||
perturbed_model_dir,
|
||||
target_modules=["nonexistent_module"],
|
||||
timeout=60,
|
||||
)
|
||||
logger.info(f"Update nonexistent module result: {result}")
|
||||
|
||||
assert status_code == 400, f"Expected 400, got {status_code}"
|
||||
assert not result.get("success", True), "Should fail for nonexistent module"
|
||||
assert "not found in pipeline" in result.get("message", "")
|
||||
self._assert_server_matches_model(base_url, default_model)
|
||||
|
||||
def test_corrupted_weights_rollback(self, diffusion_server_no_offload):
|
||||
ctx, default_model, perturbed_model_dir, corrupted_vae_model_dir = (
|
||||
diffusion_server_no_offload
|
||||
)
|
||||
base_url = f"http://localhost:{ctx.port}"
|
||||
|
||||
# base → perturbed
|
||||
self._update_weights(base_url, default_model)
|
||||
base_checksums = self._get_weights_checksum(base_url)
|
||||
|
||||
result, status_code = self._update_weights(base_url, perturbed_model_dir)
|
||||
assert status_code == 200 and result.get("success")
|
||||
perturbed_checksums = self._get_weights_checksum(base_url)
|
||||
|
||||
text_encoder_modules = sorted(
|
||||
name
|
||||
for name in perturbed_checksums
|
||||
if _TEXT_ENCODER_MODULE_PREFIX in name
|
||||
and perturbed_checksums.get(name) != "not_found"
|
||||
and base_checksums.get(name) != "not_found"
|
||||
)
|
||||
assert (
|
||||
text_encoder_modules
|
||||
), "Expected at least one text encoder module checksum"
|
||||
|
||||
# perturbed → corrupted (should fail and rollback)
|
||||
rollback_targets = [_TRANSFORMER_MODULE, _VAE_MODULE]
|
||||
result, status_code = self._update_weights(
|
||||
base_url,
|
||||
corrupted_vae_model_dir,
|
||||
target_modules=rollback_targets,
|
||||
)
|
||||
assert (
|
||||
status_code == 400
|
||||
), f"Expected 400 on corrupted weights, got {status_code}"
|
||||
assert not result.get("success", True)
|
||||
message = result.get("message", "")
|
||||
assert "rolled back" in message.lower()
|
||||
# The updater reports the first failing module in the error message.
|
||||
# With ordered target_modules=[transformer, vae], this makes the
|
||||
# failure point explicit: transformer is processed first, then vae fails.
|
||||
assert (
|
||||
"Failed to update module 'vae'" in message
|
||||
), f"Expected vae to be the explicit failure point, got: {message}"
|
||||
rolled_back_checksums = self._get_weights_checksum(base_url)
|
||||
|
||||
# 1) transformer: server == perturbed != base
|
||||
transformer_base = base_checksums.get(_TRANSFORMER_MODULE)
|
||||
transformer_perturbed = perturbed_checksums.get(_TRANSFORMER_MODULE)
|
||||
transformer_rolled_back = rolled_back_checksums.get(_TRANSFORMER_MODULE)
|
||||
assert transformer_rolled_back == transformer_perturbed
|
||||
assert transformer_rolled_back != transformer_base
|
||||
|
||||
# 2) vae: server == perturbed != base
|
||||
vae_base = base_checksums.get(_VAE_MODULE)
|
||||
vae_perturbed = perturbed_checksums.get(_VAE_MODULE)
|
||||
vae_rolled_back = rolled_back_checksums.get(_VAE_MODULE)
|
||||
assert vae_rolled_back == vae_perturbed
|
||||
assert vae_rolled_back != vae_base
|
||||
|
||||
# 3) text encoder(s): server == base == perturbed
|
||||
for name in text_encoder_modules:
|
||||
assert rolled_back_checksums.get(name) == perturbed_checksums.get(
|
||||
name
|
||||
), f"Text encoder module '{name}' should stay equal to perturbed"
|
||||
assert rolled_back_checksums.get(name) == base_checksums.get(
|
||||
name
|
||||
), f"Text encoder module '{name}' should stay equal to base"
|
||||
|
||||
|
||||
class TestUpdateWeightsFromDiskWithOffload(_UpdateWeightsApiMixin):
|
||||
"""Test update_weights_from_disk with layerwise offload enabled."""
|
||||
|
||||
@pytest.fixture(scope="class", params=_ACTIVE_MODEL_PAIRS, ids=_PAIR_IDS)
|
||||
def diffusion_server_with_offload(self, request):
|
||||
default_model, source_model = request.param
|
||||
port = get_dynamic_server_port()
|
||||
wait_deadline = float(os.environ.get("SGLANG_TEST_WAIT_SECS", "600"))
|
||||
|
||||
local_source = maybe_download_model(source_model)
|
||||
perturbed_vae_model_dir = tempfile.mkdtemp(prefix="sglang_perturbed_vae_")
|
||||
|
||||
clone_thread = threading.Thread(
|
||||
target=_clone_model_with_modified_module,
|
||||
args=(
|
||||
local_source,
|
||||
perturbed_vae_model_dir,
|
||||
_VAE_MODULE,
|
||||
_perturb_safetensor,
|
||||
),
|
||||
)
|
||||
clone_thread.start()
|
||||
|
||||
manager = ServerManager(
|
||||
model=default_model,
|
||||
port=port,
|
||||
wait_deadline=wait_deadline,
|
||||
extra_args="--num-gpus 1 --dit-layerwise-offload true",
|
||||
)
|
||||
|
||||
ctx = manager.start()
|
||||
clone_thread.join()
|
||||
|
||||
try:
|
||||
yield ctx, default_model, perturbed_vae_model_dir
|
||||
finally:
|
||||
ctx.cleanup()
|
||||
shutil.rmtree(perturbed_vae_model_dir, ignore_errors=True)
|
||||
|
||||
def test_update_weights_with_offload_enabled(self, diffusion_server_with_offload):
|
||||
ctx, _, perturbed_model_dir = diffusion_server_with_offload
|
||||
base_url = f"http://localhost:{ctx.port}"
|
||||
|
||||
result, status_code = self._update_weights(base_url, perturbed_model_dir)
|
||||
assert status_code == 200, f"Expected 200, got {status_code}"
|
||||
assert result.get("success", False), f"Update failed: {result.get('message')}"
|
||||
|
||||
message = result.get("message", "")
|
||||
assert "Shape mismatch" not in message, f"Shape mismatch detected: {message}"
|
||||
|
||||
self._assert_server_matches_model(base_url, perturbed_model_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "-s"])
|
||||
@@ -28,6 +28,8 @@ from typing import Sequence
|
||||
from sglang.multimodal_gen.runtime.platforms import current_platform
|
||||
from sglang.multimodal_gen.runtime.utils.perf_logger import RequestPerfRecord
|
||||
|
||||
DEFAULT_SMALL_MODEL = "Tongyi-MAI/Z-Image-Turbo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToleranceConfig:
|
||||
@@ -339,8 +341,6 @@ TURBOWAN_I2V_sampling_params = DiffusionSamplingParams(
|
||||
fps=4,
|
||||
)
|
||||
|
||||
DEFAULT_SMALL_MODEL = "Tongyi-MAI/Z-Image-Turbo"
|
||||
|
||||
# All test cases with clean default values
|
||||
# To test different models, simply add more DiffusionCase entries
|
||||
ONE_GPU_CASES_A: list[DiffusionTestCase] = [
|
||||
|
||||
Reference in New Issue
Block a user