From ee623198c9edc1df8a81420ec0625b92b351a53b Mon Sep 17 00:00:00 2001 From: H1yori233 Date: Sat, 13 Jun 2026 06:21:33 +0000 Subject: [PATCH] fix baed on review --- examples/inference/basic/basic_glm_image.py | 119 +++++++--- examples/inference/basic/edit_glm_image.py | 129 ++++++++--- .../basic/glm_image/stages/denoising.py | 2 +- tests/local_tests/glm_image/README.md | 207 ++++-------------- 4 files changed, 236 insertions(+), 221 deletions(-) diff --git a/examples/inference/basic/basic_glm_image.py b/examples/inference/basic/basic_glm_image.py index 4639d4839..534218909 100644 --- a/examples/inference/basic/basic_glm_image.py +++ b/examples/inference/basic/basic_glm_image.py @@ -1,47 +1,106 @@ # SPDX-License-Identifier: Apache-2.0 +"""Run GLM-Image text-to-image generation through FastVideo. -import os +User story: + "I have the HF `zai-org/GLM-Image` checkpoint and want a minimal + text-to-image generation command, saved as a PNG." +""" +import argparse +from pathlib import Path from PIL import Image from fastvideo import VideoGenerator - -OUTPUT_PATH = "image_output" - -PROMPT = ( - "A beautiful landscape photography with rolling hills, " - "a winding river, and a vibrant sunset in the background. " - "Warm golden light, photorealistic style." +from fastvideo.api import ( + EngineConfig, + GenerationRequest, + GeneratorConfig, + OutputConfig, + ParallelismConfig, + PipelineSelection, + SamplingConfig, ) +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run GLM-Image text-to-image generation.") + parser.add_argument( + "--model-path", + default="zai-org/GLM-Image", + help="HF id or local diffusers-format GLM-Image weights directory.", + ) + parser.add_argument( + "--output", + default="image_output/landscape.png", + help="Output PNG path.", + ) + parser.add_argument( + "--prompt", + default=("A beautiful landscape photography with rolling hills, " + "a winding river, and a vibrant sunset in the background. " + "Warm golden light, photorealistic style."), + help="Text prompt.", + ) + parser.add_argument("--height", type=int, default=1024) + parser.add_argument("--width", type=int, default=1024) + parser.add_argument("--steps", type=int, default=50) + parser.add_argument("--guidance-scale", type=float, default=1.5) + parser.add_argument("--seed", type=int, default=1024) + parser.add_argument("--num-gpus", type=int, default=1) + parser.add_argument("--tp-size", type=int, default=None) + parser.add_argument("--sp-size", type=int, default=None) + return parser.parse_args() + + def main() -> None: - generator = VideoGenerator.from_pretrained( - "zai-org/GLM-Image", - num_gpus=1, + args = parse_args() + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + tp_size = args.tp_size if args.tp_size is not None else (args.num_gpus if args.num_gpus > 1 else 1) + sp_size = args.sp_size if args.sp_size is not None else (1 if args.num_gpus > 1 else args.num_gpus) + + # GLM-Image needs trust_remote_code for its AR encoder; offload and the + # pipeline class come from the model's registered defaults — don't override. + generator_config = GeneratorConfig( + model_path=args.model_path, trust_remote_code=True, + engine=EngineConfig( + num_gpus=args.num_gpus, + parallelism=ParallelismConfig(tp_size=tp_size, sp_size=sp_size), + ), + pipeline=PipelineSelection(workload_type="t2i"), ) - os.makedirs(OUTPUT_PATH, exist_ok=True) + generator = VideoGenerator.from_config(generator_config) + try: + request = GenerationRequest( + prompt=args.prompt, + sampling=SamplingConfig( + height=args.height, + width=args.width, + num_frames=1, + fps=1, + num_inference_steps=args.steps, + guidance_scale=args.guidance_scale, + seed=args.seed, + ), + output=OutputConfig( + output_path=str(output.parent), + save_video=False, + return_frames=True, + ), + ) + result = generator.generate(request) + if isinstance(result, list): + result = result[0] - result = generator.generate_video( - prompt=PROMPT, - output_path=OUTPUT_PATH, - save_video=False, - return_frames=True, - height=1024, - width=1024, - num_inference_steps=50, - guidance_scale=1.5, - ) - - frames = result.get("frames") if isinstance(result, dict) else None - if frames: - img = Image.fromarray(frames[0]) - img.save(os.path.join(OUTPUT_PATH, "landscape.png")) - print(f"Saved image to {OUTPUT_PATH}/landscape.png") - - generator.shutdown() + frames = result.frames + if frames is not None and len(frames): + Image.fromarray(frames[0]).save(output) + print(f"Saved image to {output}") + finally: + generator.shutdown() if __name__ == "__main__": diff --git a/examples/inference/basic/edit_glm_image.py b/examples/inference/basic/edit_glm_image.py index 6db524890..9fb95c75b 100644 --- a/examples/inference/basic/edit_glm_image.py +++ b/examples/inference/basic/edit_glm_image.py @@ -1,46 +1,119 @@ # SPDX-License-Identifier: Apache-2.0 +"""Run GLM-Image image-to-image (edit) generation through FastVideo. -import os +User story: + "I have the HF `zai-org/GLM-Image` checkpoint and a condition image, and + want a minimal edit command (text + image -> edited image), saved as a PNG." + +GLM-Image is a single unified pipeline: passing a condition image switches it +from text-to-image to the edit path (the condition enters the DiT via a KV-cache +write pass), so the generator config is identical to `basic_glm_image.py` — the +`inputs.pil_image` on the request is what selects the edit mode. +""" +import argparse +from pathlib import Path from PIL import Image from fastvideo import VideoGenerator +from fastvideo.api import ( + EngineConfig, + GenerationRequest, + GeneratorConfig, + InputConfig, + OutputConfig, + ParallelismConfig, + PipelineSelection, + SamplingConfig, +) -OUTPUT_PATH = "image_output" -CONDITION_IMAGE = "assets/images/couple.jpg" -PROMPT = "Change the background to a snowy mountain landscape at golden hour." +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run GLM-Image image-to-image (edit) generation.") + parser.add_argument( + "--model-path", + default="zai-org/GLM-Image", + help="HF id or local diffusers-format GLM-Image weights directory.", + ) + parser.add_argument( + "--image", + default="assets/images/couple.jpg", + help="Condition image to edit.", + ) + parser.add_argument( + "--output", + default="image_output/edited.png", + help="Output PNG path.", + ) + parser.add_argument( + "--prompt", + default="Change the background to a snowy mountain landscape at golden hour.", + help="Edit instruction.", + ) + parser.add_argument("--height", type=int, default=1024) + parser.add_argument("--width", type=int, default=1024) + parser.add_argument("--steps", type=int, default=50) + parser.add_argument("--guidance-scale", type=float, default=1.5) + parser.add_argument("--seed", type=int, default=1024) + parser.add_argument("--num-gpus", type=int, default=1) + parser.add_argument("--tp-size", type=int, default=None) + parser.add_argument("--sp-size", type=int, default=None) + return parser.parse_args() def main() -> None: - generator = VideoGenerator.from_pretrained( - "zai-org/GLM-Image", - num_gpus=1, + args = parse_args() + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + condition = Image.open(args.image).convert("RGB") + tp_size = args.tp_size if args.tp_size is not None else (args.num_gpus if args.num_gpus > 1 else 1) + sp_size = args.sp_size if args.sp_size is not None else (1 if args.num_gpus > 1 else args.num_gpus) + + # GLM-Image needs trust_remote_code for its AR encoder; offload and the + # pipeline class come from the model's registered defaults — don't override. + # The pipeline is registered as t2i; passing inputs.pil_image below switches + # it to the edit path. + generator_config = GeneratorConfig( + model_path=args.model_path, trust_remote_code=True, + engine=EngineConfig( + num_gpus=args.num_gpus, + parallelism=ParallelismConfig(tp_size=tp_size, sp_size=sp_size), + ), + pipeline=PipelineSelection(workload_type="t2i"), ) - os.makedirs(OUTPUT_PATH, exist_ok=True) - condition = Image.open(CONDITION_IMAGE).convert("RGB") + generator = VideoGenerator.from_config(generator_config) + try: + request = GenerationRequest( + prompt=args.prompt, + inputs=InputConfig(pil_image=condition), + sampling=SamplingConfig( + height=args.height, + width=args.width, + num_frames=1, + fps=1, + num_inference_steps=args.steps, + guidance_scale=args.guidance_scale, + seed=args.seed, + ), + output=OutputConfig( + output_path=str(output.parent), + save_video=False, + return_frames=True, + ), + ) + result = generator.generate(request) + if isinstance(result, list): + result = result[0] - result = generator.generate_video( - prompt=PROMPT, - pil_image=condition, - output_path=OUTPUT_PATH, - save_video=False, - return_frames=True, - height=1024, - width=1024, - num_inference_steps=50, - guidance_scale=1.5, - ) - - frames = result.get("frames") if isinstance(result, dict) else None - if frames: - img = Image.fromarray(frames[0]) - img.save(os.path.join(OUTPUT_PATH, "edited.png")) - print(f"Saved image to {OUTPUT_PATH}/edited.png") - - generator.shutdown() + frames = result.frames + if frames is not None and len(frames): + Image.fromarray(frames[0]).save(output) + print(f"Saved image to {output}") + finally: + generator.shutdown() if __name__ == "__main__": diff --git a/fastvideo/pipelines/basic/glm_image/stages/denoising.py b/fastvideo/pipelines/basic/glm_image/stages/denoising.py index db2e98d55..57780138a 100644 --- a/fastvideo/pipelines/basic/glm_image/stages/denoising.py +++ b/fastvideo/pipelines/basic/glm_image/stages/denoising.py @@ -29,7 +29,7 @@ class GlmImageDenoisingStage(DenoisingStage): def forward(self, batch: ForwardBatch, fastvideo_args: FastVideoArgs) -> ForwardBatch: device = get_local_torch_device() dtype = torch.bfloat16 - guidance_scale = getattr(batch, "guidance_scale", 1.5) + guidance_scale = batch.guidance_scale do_cfg = guidance_scale > 1.0 latents = getattr(batch, "latent", getattr(batch, "latents", None)) diff --git a/tests/local_tests/glm_image/README.md b/tests/local_tests/glm_image/README.md index 4362eae45..baf4201c1 100644 --- a/tests/local_tests/glm_image/README.md +++ b/tests/local_tests/glm_image/README.md @@ -1,150 +1,49 @@ # GLM-Image Local Tests -Local-only parity and smoke tests for the `glm_image` FastVideo port. These -tests compare FastVideo against the official HF `transformers` + `diffusers` -GLM-Image implementation and are not expected to run in CI unless explicitly -promoted later. +Local-only parity and smoke tests for the `glm_image` port: FastVideo vs the +official HF `transformers` + `diffusers` GLM-Image implementation. These are +**not run in CI** — they need the full weights, CUDA, and (for the reference +side) the diffusers GLM-Image classes; each test skips with an actionable +message when a prerequisite is missing. -Port progress, open questions, issues, and handoff notes are tracked locally -alongside these tests (not shipped as part of this PR). +## What you need -## Reference Assets - -| Field | Value | +| | | |---|---| -| Model family | `glm_image` | -| Workload types | `T2I` (primary), `I2I` (planned follow-up) | -| Official reference | HF `transformers.models.glm_image` + HF `diffusers.pipelines.glm_image` | -| Local reference dir (relative to repo root) | `reference/transformers/src/transformers/models/glm_image/`, `reference/diffusers/src/diffusers/pipelines/glm_image/`, `reference/diffusers/src/diffusers/models/transformers/transformer_glm_image.py` | -| Official commit/version | transformers `a65bf6c9d03322c1bf1963ef7c64289c4b3f0757`, diffusers `ff3b86b4755b46a7b5656dfcf84d25bd25ad4740` | -| HF weights | `zai-org/GLM-Image` | -| HF revision | default (`main`) | -| Local weights dir (relative to repo root) | `official_weights/glm_image` (downloaded) | -| Source layout | `diffusers` | -| Needs conversion | `no` (HF repo already in diffusers component layout) | +| Weights | `zai-org/GLM-Image` → `official_weights/glm_image` (~30 GB: AR encoder 4 shards, transformer 3 shards, vae + text_encoder) | +| Runtime deps | `transformers>=5.0.0` (first release with the AR encoder `GlmImageForConditionalGeneration`) and `diffusers>=0.38.0` — both committed in `pyproject.toml` | +| Test-only deps | the diffusers `GlmImageTransformer2DModel` / `GlmImagePipeline` reference classes need `diffusers>=0.37.0.dev0` (used **only** by the parity tests) | -Token env var (set externally if needed for gated downloads): `HF_TOKEN`. -The repo is currently public; no token required for inspect / public read. - -## Shared Environment Setup - -Run from the FastVideo repo root in the same conda/env used for FastVideo. -Do not create a separate upstream environment for parity tests. - -Sparse-checkout HF reference repos (already done during prep): +Download weights (from the repo root; cache via `HF_HOME` if `/` is small): ```bash -git clone --depth 1 --filter=blob:none --sparse https://github.com/huggingface/transformers reference/transformers -git -C reference/transformers sparse-checkout init --cone -git -C reference/transformers sparse-checkout set src/transformers/models/glm_image - -git clone --depth 1 --filter=blob:none --sparse https://github.com/huggingface/diffusers reference/diffusers -git -C reference/diffusers sparse-checkout init --cone -git -C reference/diffusers sparse-checkout set \ - src/diffusers/pipelines/glm_image \ - src/diffusers/models/transformers \ - src/diffusers/models/autoencoders \ - src/diffusers/schedulers +python ".agents/skills/add-model-01-prep/scripts/download_hf_weights.py" \ + "zai-org/GLM-Image" "official_weights/glm_image" ``` -Editable installs are intentionally **not** used here: parity tests will import -`transformers.models.glm_image` and `diffusers.pipelines.glm_image` from the -already-installed HF packages on the active env, while the cloned reference -trees serve as source-readable parity references (Phase 1 architecture study, -key/shape dumps). - -**`pyproject.toml` pins `transformers==5.0.0rc3`** — the first release shipping -GLM-Image's AR encoder (`GlmImageForConditionalGeneration`). So the AR model and -the full T2I/I2I pipeline run on the committed pins; no local override is needed. - -Diffusers: production uses FastVideo's native DiT + a stock `AutoencoderKL`, so -`diffusers>=0.33.1` is enough. The diffusers `GlmImageTransformer2DModel` / -`GlmImagePipeline` classes are used **only by the parity tests** and need -`diffusers>=0.37.0.dev0`; those tests skip with actionable messages on older -diffusers (install it locally to run them): +Optionally install the diffusers reference classes to run the parity side: ```bash uv pip install -U "git+https://github.com/huggingface/diffusers.git@ff3b86b4" ``` -| Class | Used in | First available in | +Parity was checked against transformers `a65bf6c9` + diffusers `ff3b86b4`. + +## Parity results (GB200) + +| Component | Test | Result | |---|---|---| -| `transformers.GlmImageForConditionalGeneration` | production (AR encoder) | `transformers>=5.0.0rc0` — **the committed pin** | -| `diffusers.GlmImageTransformer2DModel`, `GlmImagePipeline` | parity tests only | `diffusers>=0.37.0.dev0` | +| `transformer` (DiT) | `test_transformer_parity.py` | fp32 cosine `1.000000` (100% within 5e-3); bf16 cosine `0.999969` (element-wise rounding over 30 layers vs a different SDPA kernel — not a bug; RoPE proven exactly equivalent). fp32 + bf16 + strict-load | +| `vae` (`AutoencoderKL`) | `test_vae_parity.py` | decode + encode + config, 3/3 | +| `text_encoder` (T5) + **ByT5** tokenizer | `test_t5_parity.py` | tokenizer + glyph extract + embeds, 3/3 | +| `vision_language_encoder` (GLM-4-9B AR) | `test_ar_parity.py` | surface + to/eval + greedy determinism, 3/3 | +| `pipeline` (T2I) | `test_pipeline_parity.py` | injected-input determinism: latent cosine `0.999997`, decoded-image MAE `0.159/255`. validity + deterministic parity, 2/2 | +| `pipeline` (I2I / edit) | `test_edit_pipeline_parity.py` | validity / wiring gate (condition image enters via a KV-cache write pass, not noise-the-latent). diffusers distribution parity deferred (stochastic AR) | -Do not change core dependency versions (`torch`, `flash-attn`, `triton`, CUDA -packages) without explicit approval. +`processor` (`GlmImageProcessor`) and `scheduler` (`FlowMatchEulerDiscreteScheduler`, +shared flow-matching) are exercised via the AR and pipeline tests. -## Official Environment Status - -```text -dependency_changes: pyproject pins transformers==5.0.0rc3 (committed); diffusers test-only classes installed locally -official_env_status: imports_ok -private_dep_stubs: none -blocked_on: none -``` - -## Weight Setup - -Run from the repo root: - -```bash -python ".agents/skills/add-model-01-prep/scripts/download_hf_weights.py" \ - "zai-org/GLM-Image" \ - "official_weights/glm_image" -``` - -Weights are ~30 GB (vision_language_encoder 4 shards, transformer 3 shards, -vae + text_encoder). **Downloaded** to `official_weights/glm_image` (relative to -repo root). Cache via `HF_HOME` if `/` is small. - -## Prototype And Conversion Artifacts - -`needs_conversion=no` for the base layout: HF weights are already in diffusers -component subfolders that FastVideo's diffusers-style loaders can consume, -**after** native components exist with state-dict surfaces that match. Param -mapping work belongs in `fastvideo/configs/models/dits/glm_image.py` -(`param_names_mapping`) and the new native VAE / encoder configs, not in a -conversion script. - -```text -official_key_dumps: - transformer: converted_weights/glm_image/_mapping/transformer_official_keys.json - vae: converted_weights/glm_image/_mapping/vae_official_keys.json - text_encoder: converted_weights/glm_image/_mapping/text_encoder_official_keys.json - vision_language_encoder: converted_weights/glm_image/_mapping/vision_language_encoder_official_keys.json -fastvideo_key_dumps: - transformer: converted_weights/glm_image/_mapping/transformer_fastvideo_keys.json - vae: converted_weights/glm_image/_mapping/vae_fastvideo_keys.json - text_encoder: converted_weights/glm_image/_mapping/text_encoder_fastvideo_keys.json - vision_language_encoder: converted_weights/glm_image/_mapping/vision_language_encoder_fastvideo_keys.json -conversion_script: none (param_names_mapping in configs handles in-place rename at load) -conversion_source_layout: diffusers -converted_weights_dir: none -strict_load_status: not_run -``` - -## Expected Parity Tests - -Local tests for this family (non-skip PASS on GB200; transformers 5.0.0rc3 — the -committed pin — plus diffusers 0.37.1 for the test-only reference classes): - -| Component | Official files / args | Test | Concerns | Status | -|---|---|---|---|---| -| `transformer` (DiT) | `pipeline_glm_image.py::GlmImagePipeline.__init__` → `GlmImageTransformer2DModel` from `transformer/config.json`; defined in `transformer_glm_image.py` | `tests/local_tests/glm_image/test_transformer_parity.py` | Re-ported from diffusers ref. fp32 cosine 1.000000 (100% within 5e-3); bf16 cosine 0.999969 — the bf16 element-wise drift is rounding over 30 layers vs a different SDPA kernel, not a bug (I020). RoPE proven exactly equivalent. | `non_skip_pass` (fp32 + bf16 + strict-load) | -| `vae` (AutoencoderKL) | `pipeline_glm_image.py::GlmImagePipeline.__init__` → `AutoencoderKL` from `vae/config.json` | `tests/local_tests/glm_image/test_vae_parity.py` | Native `AutoencoderKL` under the lazy-wrapper exception file; config mirrors `vae/config.json` (block_out_channels, latents_mean/std). | `non_skip_pass` (decode + encode + config, 3/3) | -| `text_encoder` (T5) + `tokenizer` (**ByT5**) | `T5EncoderModel` + `ByT5Tokenizer` per `pipeline_glm_image.py:191-193` | `tests/local_tests/glm_image/test_t5_parity.py` | ByT5Tokenizer asserted; `compute_glyph_embeds` mirrors diffusers `_get_glyph_embeds` (Q003 answered — stock T5/ByT5 path works). | `non_skip_pass` (tokenizer + glyph extract + embeds, 3/3) | -| `vision_language_encoder` (GLM-4-9B AR) | `GlmImageForConditionalGeneration` from `modeling_glm_image.py`; instantiated via `from_pretrained(vision_language_encoder/)` | `tests/local_tests/glm_image/test_ar_parity.py` | Lazy-wrapper exception (E001) in `fastvideo/models/encoders/glm_image_ar_loader.py` confines the HF import. | `non_skip_pass` (surface + to/eval + greedy determinism, 3/3) | -| `processor` (`GlmImageProcessor`) | `transformers/models/glm_image/processing_glm_image.py` | covered alongside AR encoder | Loaded via `ProcessorLoader` (`AutoProcessor`, allowed utility). | `non_skip_pass` (via AR + pipeline) | -| `scheduler` (`FlowMatchEulerDiscreteScheduler`) | shared with other FastVideo flow-matching pipelines; `scheduler/scheduler_config.json` ships shift defaults | covered by pipeline parity test | Dynamic-shift schedule confirmed in practice by pipeline parity (I006). | `non_skip_pass` (via pipeline) | -| `pipeline` | `pipeline_glm_image.py::GlmImagePipeline.__call__` | `tests/local_tests/glm_image/test_pipeline_parity.py` | Full-pipeline pixel parity is ill-posed (stochastic AR + independent latent RNG), so the test removes both: it injects matched `(prompt_embeds, prior_token_ids, latents)` into the diffusers pipeline and the real `GlmImageDenoisingStage`, then compares denoised latents + decoded image. GB200: latent cosine `0.999997`, decoded-image MAE `0.159/255`. | `non_skip_pass` (validity + deterministic parity, 2/2) | -| `pipeline` (I2I / edit) | `pipeline_glm_image.py::GlmImagePipeline.__call__` with `image=` (KV-cache write/read path) | `tests/local_tests/glm_image/test_edit_pipeline_parity.py` | Condition image conditions the DiT via a KV-cache write pass (not noise-the-latent). Validity/wiring gate; diffusers distribution parity deferred (same stochastic-AR caveat). Live-verified on GB200 (temp 5.0.0rc3 bump). | `non_skip_pass` (live validity); diffusers parity `deferred` | - -Include reused components in this table. Reuse is accepted only after the -FastVideo component definition and official instantiation arguments have both -been checked and the component parity test passes non-skip. - -Run the relevant tests with: +Run: ```bash pytest tests/local_tests/glm_image/test_transformer_parity.py -v -s @@ -155,37 +54,21 @@ pytest tests/local_tests/glm_image/test_pipeline_parity.py -v -s pytest tests/local_tests/glm_image/test_edit_pipeline_parity.py -v -s ``` -## Review Notes +## Design notes (load-bearing — don't regress) -- This is a **re-design** of an existing branch (`feature/glm-image-inference`). - The current branch contains a port adapted from SGLang with the following - defects that the re-design must fix: - 1. Runtime `from transformers import GlmImageForConditionalGeneration` / - `AutoProcessor` / `AutoConfig` in `fastvideo/models/loader/component_loader.py` - and `from diffusers import AutoencoderKL` — violates production-boundary - rule. - 2. Stray `print("DEBUG: …")` calls in - `fastvideo/pipelines/stages/glm_image_decoding.py`. - 3. `FV_USE_SGL_PRIORS` debug branch in - `fastvideo/pipelines/stages/glm_image_before_denoising.py` that loads - dumped tensors from a hardcoded `/workspace/debug_dumps/sgl/...` path. - 4. SSIM regression sits at **0.715** on L40S - (`fastvideo/tests/ssim/test_glm_image_similarity.py`) — too low to - function as a real quality bar. - 5. T5 tokenizer is plain T5 instead of **ByT5** (diffusers reference uses - `ByT5Tokenizer`). - 6. AR sampling temperature/top_p inconsistent between - `glm_image_before_denoising.py` (`top_p=0.7, top_k=50, temperature=1.0`) - and the pipeline config (`ar_top_p=0.75, ar_temperature=0.95`). - 7. Pipeline stages live under `fastvideo/pipelines/stages/glm_image_*.py` - rather than `fastvideo/pipelines/basic/glm_image/stages/` per the - `add-model` Files Map. - 8. Missing local parity tests (none under `tests/local_tests/glm_image/`, - `transformers/`, `vaes/`, `encoders/`, or `pipelines/`). -- Required before handoff: non-skip PASS for each required component parity - test, including reused components that own weights or numerical behavior. -- Pipeline parity may start as a scaffold, but final handoff requires non-skip - PASS or an explicit blocker accepted through the escape-hatch process. -- User decisions and pause points are tracked as `E###` rows in the local - handoff notes (not shipped in this PR); do not rely on chat history for - escape-hatch context. +- **Pipeline parity is by injection.** Full-pipeline pixel parity is ill-posed + (stochastic AR + independent latent RNG), so the pipeline tests inject matched + `(prompt_embeds, prior_token_ids, latents)` into both the diffusers pipeline + and the real `GlmImageDenoisingStage`, then compare denoised latents + decoded + image — not raw end-to-end pixels. +- **HF imports are confined** to the lazy-wrapper loader + `fastvideo/models/encoders/glm_image_ar_loader.py`, never the production + loaders (production-boundary rule). +- **Tokenizer is ByT5**, matching the diffusers reference (not plain T5); + `compute_glyph_embeds` mirrors diffusers `_get_glyph_embeds`. +- **Model-specific stages** live under + `fastvideo/pipelines/basic/glm_image/stages/` per the `add-model` Files Map. +- **Param mapping** is handled in-place at load via `param_names_mapping` + (`fastvideo/configs/models/dits/glm_image.py`) + the native VAE/encoder + configs — no conversion script; the loader raises on any unmatched param and + `test_transformer_strict_load.py` asserts completeness with `strict=True`.