Skip to content

[Model] Add Self-Forcing (causal Wan2.1-T2V-1.3B, 4-step DMD) with stepwise streaming - #8754

Open
Windyskr wants to merge 3 commits into
vllm-project:mainfrom
Windyskr:self-forcing
Open

Windyskr wants to merge 3 commits into
vllm-project:mainfrom
Windyskr:self-forcing

Conversation

@Windyskr

@Windyskr Windyskr commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Adds Self-Forcing (weights, Apache-2.0): Wan2.1-T2V-1.3B distilled into a block-causal generator that produces each three-latent-frame block in four DMD steps and caches the clean block's K/V for the next one. RFC #6227 lists it as a reference realtime system; at 1.3B on one GPU it is a cheap workload for the stepwise AR-Diffusion path that LingBot World 2.0 uses (#6844).

Closes #8755.

What is added

  • SelfForcingTransformer3DModel: the LingBot causal Wan block without the camera path. Self- and cross-attention, and both K/V paths (request-local and runner-paged), are imported from lingbot_world.transformer rather than re-implemented. from_config validates the released checkpoint geometry.
  • SelfForcingPipeline:
    • request mode (Omni, POST /v1/videos) rolls the clip out against request-local K/V;
    • step execution (self_forcing_stepwise.yaml, ARDiffusionEngine) streams one AR block per chunk from the runner's paged session, keeps the text K/V in the session's cross-attention pool, and decodes each chunk with the stateful WanStreamingDecoder;
    • mid-stream prompt updates through PromptInteractionHandler.
      Both modes run the same probe/transition/commit code and draw noise in the same order, so streamed chunks are bitwise equal to request mode.
  • Registration: diffusion registry (and _NO_CACHE_ACCELERATION), post-process, interaction registry, video output metadata, pipeline registry, model_extras defaults, stepwise deploy YAML.
  • tools/prepare_self_forcing_checkpoint.py: the release ships only checkpoints/self_forcing_dmd.pt, so this assembles a model directory from it plus the Wan2.1-T2V-1.3B-Diffusers text encoder, tokenizer and VAE.
  • Docs: recipes/gdhe17/Self-Forcing-A800.md, supported_models.md, diffusion_features.md.

Fix in LingBot's direct attention path (needed here)

LingBotSelfAttention's direct (request-local) path paged the visible K/V with the whole chunk as one block (block_size = key.shape[1]). At 832x480 a three-frame chunk is 4680 tokens, and FA2 on SM80 rejects it: Paged KV cache block size must be divisible by 16. It now pages at paging_block_size(...), the unit the AR runner already uses for 1560-token frames, and pads the last page (never read past seq_lens). Chunks that were already legal pages keep their old paging. Self-Forcing's request mode at its default geometry depends on this. The tiny-geometry GPU tests in tests/diffusion/models/self_forcing fail without it, and LingBot's suites still pass with it (below). I can split this into its own PR if preferred.

Design notes

  • No supports_chunk_step_grouping: it saved no time here (81 frames over WS, 10.57 s without vs 10.58 s with) and applies a mid-chunk prompt update one block late ([Bug]: Chunk step grouping applies a mid-chunk interaction one chunk late #8756, fix in [Bugfix] Fix grouped chunk interaction latency #8810).
  • Generic warmup skipped (dummy_run_num_frames = 0): its 2-step 512x512 probe is not a valid DMD rollout.
  • Rejected with a clear error: guidance_scale > 1, negative prompts, num_inference_steps other than 4, image inputs, TP/SP/CFG/PP/HSDP/VAE patch parallel. TeaCache and Cache-DiT are not applicable (all four DMD steps are distinct).
  • Clips beyond 81 frames use a 21-latent-frame sliding window, past the trained horizon.
  • Prompt updates apply from the next block, but cached K/V of earlier frames keep the scene ("prompt inertia", LongLive §4.4). LongLive's K/V re-cache (§3.1) is a possible follow-up.

Not duplicating existing work

Searched open and closed PRs and issues for "Self-Forcing", "self_forcing", "SelfForcing" and "causal Wan2.1 DMD": no Self-Forcing integration exists. Related: #4441 (CausVid draft, predates the AR-Diffusion runtime), #8647 (Matrix-Game 2.0 issue on the same causal Wan 1.3B backbone, no PR yet; this transformer could serve as its base), RFC #6227, RFC #4480.

Test Plan

vLLM Version: 0.31.0 (PyTorch 2.13.0+cu132)

vLLM-Omni Commit: this branch on b1fc5fcf. Measurements were taken on 194b9366; the one commit in between only changes ROCm CI. The full test set was rerun after the rebase.

Hardware: 1x NVIDIA A800-SXM4-80GB (SM80), driver 590.48.01, on a shared host. Timings are bounded local measurements.

# Unit, registry and LingBot regression (CPU + 1 GPU)
pytest tests/diffusion/models/self_forcing tests/model_tests/diffusion/test_alignment.py \
  tests/entrypoints/openai_api/test_video_pipeline_capability.py tests/diffusion/models/lingbot_world \
  tests/diffusion/ar_diffusion/test_streaming_decode.py tests/config/test_lingbot_world_stepwise_deploy.py \
  -m "not parallel"
# Checkpoint E2E: streamed chunks vs request mode
python tools/prepare_self_forcing_checkpoint.py --output-dir ./self-forcing-dmd
VLLM_OMNI_SELF_FORCING_MODEL_PATH=./self-forcing-dmd pytest tests/e2e/offline_inference/test_self_forcing.py
# LingBot multi-GPU direct/paged after the paging fix
pytest tests/diffusion/models/lingbot_world/test_lingbot_world_transformer.py -m parallel

# Serving
vllm serve ./self-forcing-dmd --omni --enforce-eager --port 8091
curl -s http://localhost:8091/v1/videos/sync -F "prompt=..." -F width=832 -F height=480 -F num_frames=81 -F fps=16 -F seed=0 -o out.mp4
vllm serve ./self-forcing-dmd --omni --deploy-config vllm_omni/deploy/self_forcing_stepwise.yaml --port 8091
python examples/online_serving/streaming_video_generation/streaming_video_client.py --port 8091 \
  --model ./self-forcing-dmd --prompt "..." --width 832 --height 480 --num-frames 81 --seed 0 \
  --no-helios-distilled-preset [--prompt-updates '[{"at": 0, "prompt": "...", "transition_chunks": 0}]']

Reference: guandeh17/Self-Forcing at 33593df, PyTorch 2.9.1+cu128, flash-attn 2.8.3, generator_ema weights, bf16, same GPU. Comparison scripts are in the collapsed section below.

Test Result

Tests: 347 passed, 3 deselected (the deselected are LingBot's multi-GPU tests); LingBot SP2 direct passed (relative L2 2.5e-7 vs SP1); SP4 direct and TP2+SP2 paged not run (four free GPUs were not available on the shared host); at their 256-token frames the paging fix leaves the page size unchanged.

Accuracy (480x832, 81 frames, bf16)

Check vLLM-Omni Reference
Per-call DiT output on identical inputs, all 35 calls rel-L2 0.8-3.3%, cosine >= 0.9994 -
Error against an fp32 oracle, median call 1.38% 1.59%
VAE decode of identical latents PSNR 53.1 dB between the two decoders -
CLIP score (ViT-H-14), 32 MovieGen prompts, seed 0 0.368 ± 0.049 0.364 ± 0.046

Free-running rollouts are chaotic at bf16. The reference drifts to 24% latent rel-L2 against itself when FlashAttention is swapped for SDPA, so exact pixel agreement with the reference is not a meaningful bar. The comparison therefore uses per-call parity (tolerance: no worse than the reference's own bf16 error against fp32) plus CLIP score.

Performance, request mode. Each side: 1 warmup + 5 measured runs, same prompt. The reference is timed around CausalInferencePipeline.inference (GPU tensor out); vLLM-Omni around Omni.generate (uint8 frames in the client process).

vLLM-Omni Reference Ratio
End-to-end, median (min-max) 10.20 s (10.20-10.20) 14.93 s (14.92-14.94) 0.68x
DiT rollout (7 blocks x 5 forwards) 7.67 s 7.93 s 0.97x
VAE decode 2.21 s 6.94 s 0.32x
Text encode 0.06 s 0.06 s -
Peak reserved memory 26.0 GiB 35.1 GiB (30.3 GiB allocated) 0.74x

The rollout runs at parity with the reference. Most of the end-to-end gap is the VAE decode (diffusers AutoencoderKLWan vs the reference's Wan VAE, both bf16, frame-by-frame causal decode).

Serving

  • POST /v1/videos/sync (eager): 81-frame 832x480 MP4 at 16 fps; 10.9 s first request, 10.2 s warm (engine time). Without --enforce-eager the first request compiles (~60 s) and warm requests take 8.6 s; compiled output quality was not evaluated.
  • WS /v1/realtime/video, 81 frames: first chunk (9 frames) at 1.80 s; later chunks 1.16-1.74 s each for 12 frames (0.75 s of playout); all 81 frames at 10.57 s, about 0.48x realtime. Eager A800 does not sustain 16 fps playout.
  • Through AsyncOmni, streamed latents and decoded frames are bitwise equal to request mode for the same seed.
  • 153-frame stream (13 chunks, past the trained horizon): per-chunk latency rises while the 21-frame window fills, then holds at 1.73-1.75 s for chunks 7-13; device memory (nvidia-smi) is 30.1 GiB after load and 36.5 GiB from the second chunk to the last, with no growth per chunk.
  • Prompt update sent at video.start: chunk 0 byte-identical to the base run, update active from chunk 1. Visually the scene stays (prompt inertia, see above).
  • Layerwise CPU offload (request mode): bitwise-equal latents, same latency (7.69 vs 7.70 s, latent output), peak reserved 20.5 to 17.8 GiB.

Not run: H100/FA3, TP/SP (rejected by the pipeline), quality under torch.compile, multi-session streaming.

The scripts behind these numbers (evidence, not part of the diff) are in this gist.

AI assistance: Claude Code (Claude Opus) wrote the implementation, tests, benchmark scripts, docs and this description under my direction, and ran the tests and measurements above on my A800. I reviewed every changed line.

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@vllm-omni-review-bot

Copy link
Copy Markdown

This PR appears to belong to: docs/design/module/diffusion/index.md.

Module owners: @david6666666 @wtomin @Isotr0py

Routing: @david6666666 via module of the changed files, module named in the PR description, CODEOWNERS; @wtomin via module of the changed files, module named in the PR description, CODEOWNERS; @Isotr0py via module of the changed files, module named in the PR description

@Windyskr, please review your own changes and leave a short self-review comment describing what you checked. PRs without author self-review may not be assigned a reviewer.

Please take a look when you have a chance. If you would like an automated review, mention @vllm-omni-review-bot in a comment.

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot routing record

Assigned Strict on cursor (cursor-grok-4.6-high) under experiment fleet-strict-cursor-grok46-zcode-glm53flash-5050-c5-z10-20261002.

@vllm-omni-review-bot

vllm-omni-review-bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Omni ReviewBot triage note

Automated triage of commit e21263931bd0 produced:

  • Priority: high. Prompt maintainer attention is suggested.

These are automated triage suggestions only — the final decision belongs to the maintainers.

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (failed; retrying strict/cursor/cursor-grok-4.6-high in 120s (try 2 of 3)).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (failed; retrying strict/cursor/cursor-grok-4.6-high in 600s (try 3 of 3)).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (failed; falling back to direct/cursor/auto).

@Windyskr

Copy link
Copy Markdown
Contributor Author

Self-review. I read every changed line. Things I checked in particular:

  • Commit 1 (LingBot paging): chunks that are already a legal FA2 page keep their old paging. Only non-multiple-of-16 chunks such as 4680 tokens are paged at 16 and padded past seq_lens. LingBot's suites still pass, including SP2 direct.
  • Request mode and step execution share one probe/transition/commit path and draw noise in the same order, which is why streamed chunks equal request mode.
  • The checkpoint contract and the rejected settings (CFG, other step counts, negative prompts, TP/SP, etc.) match the reference.
  • The registry, metadata, alignment-test and docs entries are consistent with each other.
  • Validation, one A800: the commands in the description pass (347 passed plus the checkpoint e2e test). vllm serve works with /v1/videos/sync and WS /v1/realtime/video. The reference comparison numbers come from the scripts in the description.

AI assistance: this comment was drafted with Claude Code.

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (Cursor exited with 1: ActionRequiredError: You've hit your usage limit You've saved $3501 on API model usage this month with Ultra. Switch to a different model or set a Spend Limit to continue with Auto. Your usage limits will reset when your monthly cycle ends on 10/20/2026.; retrying direct/cursor).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (Cursor exited with 1: ActionRequiredError: You've hit your usage limit You've saved $3501 on API model usage this month with Ultra. Switch to a different model or set a Spend Limit to continue with Auto. Your usage limits will reset when your monthly cycle ends on 10/20/2026.; retrying direct/cursor).

@vllm-omni-review-bot

Copy link
Copy Markdown
Omni ReviewBot attempt record

Review attempt ended as failed (Cursor exited with 1: ActionRequiredError: You've hit your usage limit You've saved $3501 on API model usage this month with Ultra. Switch to a different model or set a Spend Limit to continue with Auto. Your usage limits will reset when your monthly cycle ends on 10/20/2026.; no fallback: the fallb).

@hsliuustc0106 hsliuustc0106 added new model add new model diffusion codes related to diffusion models labels Oct 12, 2026
@hsliuustc0106 hsliuustc0106 added the high priority high priority issue, needs to be done asap label Oct 15, 2026

Copy link
Copy Markdown
Collaborator

@Windyskr, one merge-readiness TODO for head 4b1f1be379de4f4408cbd48757912e9290887762:

  • Update this branch against main and resolve the merge conflicts: GitHub currently reports mergeable=false / mergeable_state=dirty. After updating the branch, rerun the documented Self-Forcing and LingBot regression suites and record the new head, commands and actual results; preserve the existing request/stepwise equivalence and LingBot attention coverage.

Acceptance: GitHub can merge the updated branch cleanly and the relevant regressions pass on that new head. Your existing passing checks and test evidence are retained for the present head; they do not validate a future conflict-resolution commit. Initial readiness triage only; substantive review remains pending.

@hsliuustc0106

Copy link
Copy Markdown
Collaborator

@Windyskr gentle ping — this high-priority PR has seen no updates for 10 days and review feedback is waiting. Could you share a status or ETA when you get a chance? Thanks!

@Windyskr

Copy link
Copy Markdown
Contributor Author

Thanks for the ping, and sorry for the silence — that one is on me.

Status: your 10-16 merge-readiness TODO is what I am working on, and nothing had been pushed since. I have just gone through the conflicts against current main (the branch is 213 commits behind). Six files conflict:

Plan and ETA:

  1. Rebase onto main and resolve the conflicts — today.
  2. Rerun the documented suites on the new head and post the head, commands and actual results. The Self-Forcing checkpoint needs regenerating on my side first, and the A800 host is shared, so I am giving this 2 days, by 2026-10-23. If the multi-GPU LingBot cases cannot get enough free GPUs again I will say so explicitly rather than leave them implied.

One thing that may be useful on your side: every ReviewBot attempt on 10-11 ended in failed (the last few with a Cursor usage-limit error), so no automated findings were ever produced for this PR. If you were expecting bot feedback to exist, there is none to act on — your TODO is the only review item open.

Happy to split the LingBot paging fix into its own PR if that makes this easier to land; it is self-contained and main still has the bug.

Windyskr and others added 2 commits October 21, 2026 09:04
The direct (request-local) path paged the visible K/V with the whole chunk
as one FlashAttention block. At 832x480 a three-frame chunk is 4680 tokens,
which FA2 on SM80 rejects ("Paged KV cache block size must be divisible by
16"). Page at paging_block_size() like the AR runner and pad the last page;
the padding past seq_lens is never read. Chunks that were already legal
pages keep their paging.

Signed-off-by: windyskr <54269955+windyskr@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Self-Forcing distills Wan2.1-T2V-1.3B into a block-causal generator that
produces each three-latent-frame block in four DMD steps. The transformer
reuses LingBot World's causal attention and K/V paths. The pipeline runs in
request mode and in step execution on the AR-Diffusion engine, streaming one
block per chunk with stateful VAE decode and mid-stream prompt updates.

Adds the stepwise deploy config, registry and metadata entries,
model_extras defaults, a checkpoint preparation tool, tests and an A800
recipe.

Signed-off-by: windyskr <54269955+windyskr@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Windyskr

Copy link
Copy Markdown
Contributor Author

Done, and the branch now merges cleanly. New head 56b39eb0, rebased onto aa3fd1d9; main has moved 34 commits further since and it still merges without conflict.

The rebase. 213 commits, six files conflicting. Five were registry and doc entries added on both sides (docs/models/supported_models.md, recipes/README.md, tests/model_tests/diffusion/test_alignment.py, vllm_omni/diffusion/interaction/registry.py, vllm_omni/diffusion/registry.py) and both sides are kept.

The sixth is worth a note: vllm_omni/diffusion/models/lingbot_world/transformer.py, where main gained an NPU path after this branch was cut (#8610, #8859, #8112), so the direct branch is now query.is_cuda or query.device.type == "npu". The FA2 paging fix in the first commit still applies and is still needed — main continues to page the visible K/V as a single block (block_size = key.shape[1]). It is also safe on the NPU side: ar_diffusion_paged_attention's NPU branch gathers whole blocks with index_select, flattens and truncates to max_seq_len, so it imposes no block-size constraint and the padding this fix adds falls outside the truncation. The resolution keeps main's condition and comment and layers the paging on top.

One environment note for anyone reproducing this: main now requires vLLM 0.32.0 (#9066), so a 0.31.0 environment fails at import with cannot import name 'RequestError' from 'vllm.outputs'. Everything below ran against vLLM 0.32.0.

Results on 56b39eb0. 1x A800-SXM4-80GB (sm80), driver 590.48.01, torch 2.13.0+cu132, vLLM 0.32.0, shared host.

# Unit, registry and LingBot regression (CPU + 1 GPU)
pytest tests/diffusion/models/self_forcing tests/model_tests/diffusion/test_alignment.py \
  tests/entrypoints/openai_api/test_video_pipeline_capability.py tests/diffusion/models/lingbot_world \
  tests/diffusion/ar_diffusion/test_streaming_decode.py tests/config/test_lingbot_world_stepwise_deploy.py \
  -m "not parallel"
# -> 393 passed, 3 deselected in 48.68s

# Checkpoint E2E: streamed chunks vs request mode
python tools/prepare_self_forcing_checkpoint.py --output-dir ./self-forcing-dmd
VLLM_OMNI_SELF_FORCING_MODEL_PATH=./self-forcing-dmd pytest tests/e2e/offline_inference/test_self_forcing.py
# -> 1 passed in 61.91s

# LingBot multi-GPU direct/paged after the paging fix
pytest tests/diffusion/models/lingbot_world/test_lingbot_world_transformer.py -m parallel
# -> 1 passed, 2 skipped, 54 deselected in 17.66s

The request/stepwise equivalence test is the one in the E2E run, and it passes on the new head. The two skips in the parallel run are the SP4 and TP2+SP2 cases, which need four free GPUs; at their 256-token frames the paging fix leaves the page size unchanged, as before. The previous figure in the description was 347 passed; the increase to 393 is tests main added in those directories over the 213 commits.

For transparency: the E2E first failed with torch.OutOfMemoryError because another job on this shared host held 66 GiB of the card it landed on. It passed on a card with free memory; nothing about the test or the branch changed between the two runs.

#8757 stacks on this PR and I will rebase it as soon as this lands.

@Bounty-hunter Bounty-hunter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Must fix

  • tools/prepare_self_forcing_checkpoint.py:L75 — Please reject an output directory that aliases the local base-model directory before deleting any targets. With --base-model /models/wan --output-dir /models/wan, each source resolves to /models/wan/<component> and target names the same directory; lines 80–83 then unlink/rmtree the source itself. --copy subsequently fails because its source is gone, while the default mode creates a dangling symlink to the deleted path. This can irreversibly destroy the user's tokenizer, text encoder, and VAE. Resolve both roots up front, reject overlap (at minimum equality), and add a regression test that verifies the source tree remains intact.

Verdict: Blocking issue found in the checkpoint assembly path; no formal approval or request-changes action was taken.

@Windyskr

Copy link
Copy Markdown
Contributor Author

@Bounty-hunter thank you — this is real, and worse than "fails to assemble". Fixed in cc177a11.

I traced it to confirm rather than take it on faith. With --base-model /models/wan --output-dir /models/wan, for each component source = (base_dir / name).resolve() and target = output_dir / name name the same path, so shutil.rmtree(target) on line 83 removes the source. --copy then fails with the source gone; the default path leaves a symlink pointing at the directory it just deleted. The part that makes it unrecoverable is one level up: base_dir.is_dir() is still true, so the script never re-downloads, and the user's tokenizer, text encoder and VAE are simply gone.

The fix compares the resolved roots and refuses before anything is removed:

if output_dir.resolve() == base_dir.resolve():
    raise ValueError(
        f"--output-dir must not be the base model directory ({base_dir}). Assembling in place "
        f"would delete the base components this script reads from."
    )

Resolving both ends also covers an output path that reaches the base directory through a symlink, which has the same effect and would otherwise slip past a plain equality check.

Regression tests are in tests/tools/test_prepare_self_forcing_checkpoint.py, alongside the other tool tests: the equal-path case in both link and --copy mode, and the symlinked-alias case. Each asserts the component files are still intact after the refusal, so the test fails if the guard ever stops running early enough. Removing the guard fails all three; with it, 3 passed.

I left nesting alone — --output-dir inside the base directory, or the other way round, gives distinct source and target paths and does not delete anything — so the check stays narrow rather than refusing layouts that work. Say the word if you would rather it reject any overlap.

@vllm-omni-review-bot

vllm-omni-review-bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Omni ReviewBot: superseded

The CI failure noted on cc177a113ff1 refers to an earlier head; the pull request now points at 812018b1a731.

@Bounty-hunter Bounty-hunter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental review of cc177a113ff1

The previously reported in-place assembly bug is fixed: the resolved-root guard runs before component deletion and the new cases cover literal and symlink aliases.

Must fix

  • tests/tools/test_prepare_self_forcing_checkpoint.py:L47 — Keep these regression tests hermetic instead of invoking the default checkpoint download. Every case calls main() without --checkpoint; main() therefore reaches hf_hub_download() at tools/prepare_self_forcing_checkpoint.py:L69-L72 before the new alias guard at line 79. The referenced checkpoints/self_forcing_dmd.pt is 5,676,252,553 bytes according to the Hugging Face model metadata, so a clean test worker must access the network and download roughly 5.7 GB before testing a local path check. Pass a dummy local --checkpoint (the guard raises before torch.load), or stub hf_api and assert that no download is attempted; preferably move the path guard ahead of checkpoint resolution as well. The current pre-commit run stops earlier because this test file is also missing the required level and hardware marks, so CI has not validated this path.

Verdict: The original data-loss issue is resolved, but the new regression test is not CI-safe yet. No formal approval or request-changes action was taken.

@Windyskr

Copy link
Copy Markdown
Contributor Author

Right on all three, and the first one would have bitten CI hard. Fixed in 812018b1.

The ordering was the real bug in my fix. The guard sat after checkpoint resolution, so main() reached hf_hub_download() first and a clean worker would have pulled 5.7 GB before reaching a check about local paths. My own run passed in 33 s only because the checkpoint was already in this machine's Hub cache from assembling the model earlier — exactly the blind spot you are describing. The guard now runs before the checkpoint is resolved, which is also the better shape you suggested: a bad pair of paths costs nothing.

The tests are offline now. hf_api is patched on vllm_omni.transformers_utils.repo_utils (the script imports it inside main(), so patching the script's namespace does not take), with a stub that raises on any attribute and records what was attempted. Each case then asserts nothing was attempted:

assert no_downloads == [], f"the guard ran after a Hub call: {no_downloads}"

That assertion is what keeps the ordering honest, rather than relying on anyone noticing the line order later. Moving the guard back after checkpoint resolution fails all three tests; with it in front, 3 passed in 4.65s under HF_HUB_OFFLINE=1.

Marks added: pytestmark = [pytest.mark.core_model, pytest.mark.cpu], matching the other files in tests/tools/. Thanks for pointing out that pre-commit was stopping before this file — I had taken the local pass as evidence the path was covered, and it was not.

I kept the guard to the equality and alias cases. Nesting in either direction gives distinct source and target paths and deletes nothing, so refusing it would reject layouts that work today.

@Bounty-hunter Bounty-hunter left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental review of 812018b1a731

The substantive follow-ups are fixed: the alias guard now runs before checkpoint resolution, the regression tests fail any Hub access and assert no download was attempted, and the required pytest marks are present. I found no new correctness or architecture issue in this increment.

Must fix

  • tests/tools/test_prepare_self_forcing_checkpoint.py:L65 — Please commit the ruff-generated changes so the required pre-commit check passes. The current head fails because both ruff-check and ruff-format modify this file: CI moves the repo_utils import below the tool import and reformats the long test_output_dir_equal_to_base_model_is_refused signature. Running ruff format --check on the frozen file also reproduces 1 file would be reformatted. This is mechanical, but the head remains blocked until the generated diff is committed and CI is rerun.

Verdict: The previous data-loss, download-before-guard, offline-isolation, and mark issues are resolved; only the required formatting check remains red. No formal approval or request-changes action was taken.

… model

With `--output-dir` naming the same directory as `--base-model`, every target
in the component loop resolves to its own source. The loop removes whatever
sits at the target before linking or copying, so it deletes the user's
tokenizer, text encoder and VAE, and then either fails with the source gone
(`--copy`) or leaves a symlink pointing at the deleted path. The base model is
not re-downloaded in that case, since the directory still exists, so the loss
is unrecoverable.

Compare the resolved roots and refuse before anything is removed, which also
catches an output path that reaches the base directory through a symlink. The
check runs before the checkpoint is resolved, so a bad pair of paths costs
nothing rather than a multi-gigabyte download first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: windyskr <54269955+windyskr@users.noreply.github.com>
@Windyskr

Copy link
Copy Markdown
Contributor Author

Committed in e2126393. ruff check and ruff format --check are both clean on the two files now, and the three tests still pass offline (3 passed in 2.86s under HF_HUB_OFFLINE=1).

The diff was the two things you named: the repo_utils import moves below the tool import, and the test_output_dir_equal_to_base_model_is_refused signature folds onto one line.

My mistake was not running the repo's own pre-commit after editing — I had checked the tests and reasoned about the code, but took the file's formatting for granted. Thanks for the three rounds on this one; the ordering issue in particular was mine to catch and I did not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

diffusion codes related to diffusion models high priority high priority issue, needs to be done asap new model add new model

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[New Model]: Self-Forcing (causal Wan2.1-T2V-1.3B, 4-step DMD) on the stepwise AR-Diffusion path

4 participants