From bbe8b7dd8a495919215d29f28d00badbd4ea4942 Mon Sep 17 00:00:00 2001 From: Mick Date: Mon, 22 Jun 2026 23:36:18 +0800 Subject: [PATCH] [diffusion] CI: restore Hunyuan3D-2 image-to-3D (#28781) --- .../model_specific_stages/hunyuan3d/shape.py | 17 ++++-- .../test/server/perf_baselines.json | 5 +- .../test/server/test_server_common.py | 16 ++++++ .../test/server/test_server_utils.py | 57 ++++++++++++++++--- .../utils/diffusion/diffusion_case_parser.py | 15 ++++- 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/hunyuan3d/shape.py b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/hunyuan3d/shape.py index 0ef0f1fca..29591a999 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/hunyuan3d/shape.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/stages/model_specific_stages/hunyuan3d/shape.py @@ -381,15 +381,22 @@ class Hunyuan3DShapeDenoisingStage(DenoisingStage): attn_metadata, target_dtype, current_guidance_scale, - image_kwargs: dict[str, Any], - pos_cond_kwargs: dict[str, Any], - neg_cond_kwargs: dict[str, Any], + cfg_policy, + cfg_gate_state, server_args, guidance, latents, ): - """Hunyuan3D-specific CFG: concat latents, single forward, then split.""" - cond = pos_cond_kwargs.get("encoder_hidden_states") + """Hunyuan3D-specific CFG: concat latents, single forward, then split. + + Hunyuan3D pre-stacks ``[uncond, cond]`` in ``prompt_embeds`` and runs a + single batched forward, combining manually. It therefore does not use the + shared multi-branch ``cfg_policy`` machinery; ``cfg_policy`` and + ``cfg_gate_state`` are accepted only to match the base + :meth:`DenoisingStage._predict_noise_with_cfg` signature (the base loop + always passes them) and are intentionally unused here. + """ + cond = batch.prompt_embeds[0] if batch.prompt_embeds else None do_cfg = batch.do_classifier_free_guidance if do_cfg: diff --git a/python/sglang/multimodal_gen/test/server/perf_baselines.json b/python/sglang/multimodal_gen/test/server/perf_baselines.json index 6e138e61a..810c7851b 100644 --- a/python/sglang/multimodal_gen/test/server/perf_baselines.json +++ b/python/sglang/multimodal_gen/test/server/perf_baselines.json @@ -2115,10 +2115,7 @@ "Hunyuan3DShapeBeforeDenoisingStage": 544.59, "Hunyuan3DShapeDenoisingStage": 3306.16, "Hunyuan3DShapeExportStage": 8488.42, - "Hunyuan3DShapeSaveStage": 859.23, - "Hunyuan3DPaintPreprocessStage": 256020.36, - "Hunyuan3DPaintTexGenStage": 23764.05, - "Hunyuan3DPaintPostprocessStage": 7095.01 + "Hunyuan3DShapeSaveStage": 859.23 }, "denoise_step_ms": { "0": 137.54, 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 894735a2b..63a069594 100644 --- a/python/sglang/multimodal_gen/test/server/test_server_common.py +++ b/python/sglang/multimodal_gen/test/server/test_server_common.py @@ -138,6 +138,22 @@ def diffusion_server(case: DiffusionTestCase) -> ServerContext: # picking another one (which causes the test client to connect to the wrong server). extra_args += " --strict-ports" + # Shape-only mesh cases (e.g. hunyuan3d_shape_gen) validate geometry via + # mesh-correctness and must NOT run the paint/texture stages, whose + # verification checks texture artifacts (paint_mesh/normal_maps/renderer) + # that the shape-only path never produces. Inject a pipeline-config override + # disabling paint for these cases. + if server_args.custom_validator == "mesh": + import json as _json + import tempfile as _tempfile + + _paint_off_cfg = os.path.join( + _tempfile.gettempdir(), f"{case.id}_paint_off.json" + ) + with open(_paint_off_cfg, "w") as _f: + _json.dump({"paint_enable": False}, _f) + extra_args += f" --config {_paint_off_cfg}" + for arg in server_args.extras: extra_args += f" {arg}" diff --git a/python/sglang/multimodal_gen/test/server/test_server_utils.py b/python/sglang/multimodal_gen/test/server/test_server_utils.py index 7b47ec808..e11e11b6e 100644 --- a/python/sglang/multimodal_gen/test/server/test_server_utils.py +++ b/python/sglang/multimodal_gen/test/server/test_server_utils.py @@ -732,24 +732,60 @@ class MeshValidator(PerformanceValidator): pass +# Pinned to a ci-data commit (not main): invalidates the per-URL download cache +# whenever the reference is regenerated, and keeps the mesh GT reproducible. +# Bump this SHA when pushing a new hunyuan3d.glb to ci-data. HUNYUAN3D_REFERENCE_URL = ( - "https://raw.githubusercontent.com/sgl-project/sgl-test-files/" - "main/diffusion-ci/consistency_gt/1-gpu/hunyuan3d_2_0/hunyuan3d.glb" + "https://raw.githubusercontent.com/sgl-project/ci-data/" + "395f6e49c37d22a57d79fbcd3653d43984099ae2" + "/diffusion-ci/consistency_gt/1-gpu/hunyuan3d_2_0/hunyuan3d.glb" ) def _download_reference_mesh(url: str) -> Path: - """Download a reference mesh from URL, caching in temp dir.""" + """Download a reference mesh from URL, caching in temp dir. + + Validates that the cached/downloaded file actually *loads* as a non-empty + mesh — not just that a magic/length header looks right. raw.githubusercontent + can briefly serve a truncated or corrupt response for a just-pushed large + file, and a prior run may have cached those bytes on a persistent runner; a + size/magic check can't catch a blob whose byte count matches the declared + length but whose body is corrupt (exactly what poisoned this CI cache and + surfaced as a cryptic trimesh "incorrect header on GLB file" deep inside + validation). Loading via trimesh rejects any such cache (forcing a + re-download) and turns a bad fresh download into a clear error. The ``v2`` + cache prefix also invalidates blobs written by the earlier, weaker checks. + """ import hashlib - cache_name = f"ref_mesh_{hashlib.md5(url.encode()).hexdigest()}.glb" + cache_name = f"ref_mesh_v2_{hashlib.md5(url.encode()).hexdigest()}.glb" cache_path = Path(tempfile.gettempdir()) / cache_name - if cache_path.exists(): + + def _loads_as_mesh(path: Path) -> bool: + try: + import trimesh + + mesh = trimesh.load(str(path), force="mesh") + return ( + getattr(mesh, "vertices", None) is not None and len(mesh.vertices) > 0 + ) + except Exception: + return False + + if cache_path.exists() and _loads_as_mesh(cache_path): logger.info(f"Using cached reference mesh: {cache_path}") return cache_path logger.info(f"Downloading reference mesh from: {url}") cache_path.write_bytes(_urlopen_with_retry(url, timeout=60)) + if not _loads_as_mesh(cache_path): + size = cache_path.stat().st_size if cache_path.exists() else 0 + cache_path.unlink(missing_ok=True) + raise RuntimeError( + f"Reference mesh from {url} did not load as a valid mesh " + f"({size} bytes). The CDN may not have propagated a recently-pushed " + f"file yet; retry shortly." + ) logger.info(f"Reference mesh cached at: {cache_path}") return cache_path @@ -1446,8 +1482,15 @@ def get_generate_fn( if content_resp.status_code != 200: pytest.fail(f"{case_id}: mesh download failed: {content_resp.text}") - temp_path = Path(tempfile.gettempdir()) / f"mesh_test_{mesh_id}.glb" - temp_path.write_bytes(content_resp.content) + content = content_resp.content + # Shape-only Hunyuan3D meshes are returned as OBJ, painted meshes + # as GLB. Pick the extension from the content magic so trimesh.load + # (which dispatches on the file extension) parses it correctly, + # instead of raising "incorrect header on GLB file" when an OBJ + # body is saved under a .glb name. + ext = ".glb" if content[:4] == b"glTF" else ".obj" + temp_path = Path(tempfile.gettempdir()) / f"mesh_test_{mesh_id}{ext}" + temp_path.write_bytes(content) MESH_OUTPUT_PATHS[case_id] = str(temp_path) logger.info(f"[Mesh Gen] Mesh downloaded to {temp_path}") diff --git a/scripts/ci/utils/diffusion/diffusion_case_parser.py b/scripts/ci/utils/diffusion/diffusion_case_parser.py index 6383be85f..0aba7780c 100755 --- a/scripts/ci/utils/diffusion/diffusion_case_parser.py +++ b/scripts/ci/utils/diffusion/diffusion_case_parser.py @@ -102,10 +102,19 @@ class DiffusionTestCaseVisitor(ast.NodeVisitor): if case_id: self.factory_case_ids[stmt.name] = case_id - for stmt in node.body: - if isinstance(stmt, ast.Expr): - self._process_expr(stmt.value) + self.generic_visit(node) + def visit_Expr(self, node: ast.Expr): + """Handle ``LIST.append(...)`` mutations at any nesting level. + + Previously only module-top-level ``ast.Expr`` statements were scanned for + ``.append()`` calls, so cases registered under a platform guard such as + ``if not current_platform.is_hip(): ONE_GPU_CASES.append(...)`` (used by + ``hunyuan3d_shape_gen`` and ``turbo_wan2_1_t2v_1.3b``) were invisible to + the partition planner and therefore never scheduled in CI. Visiting every + ``Expr`` lets ``generic_visit`` reach appends inside ``if``/``else`` blocks. + """ + self._process_expr(node.value) self.generic_visit(node) def visit_Assign(self, node: ast.Assign):