diff --git a/docs/.nav.yml b/docs/.nav.yml index cd5585b3f..e55f28ce1 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -47,6 +47,7 @@ nav: - configuration/* - Models: - models/supported_models.md + - SeedVR2: models/seedvr2.md - Features: - Overview: features/README.md - Runtime and Stage Execution: @@ -151,6 +152,7 @@ nav: - Hybrid Sharded Data Parallel (HSDP): design/feature/hsdp.md - Pipeline Parallel: design/feature/pipeline_parallel.md - Sequence Parallel: design/feature/sequence_parallel.md + - Window Sequence Parallel: design/feature/window_sequence_parallel.md - Tensor Parallel: design/feature/tensor_parallel.md - VAE Patch Parallelism: design/feature/vae_parallel.md - Attention Optimization: diff --git a/docs/configuration/environment_variables.md b/docs/configuration/environment_variables.md index 039565030..9f0e1cc91 100644 --- a/docs/configuration/environment_variables.md +++ b/docs/configuration/environment_variables.md @@ -214,7 +214,7 @@ collection, examples, bug reports, or logs. ## Model-specific variables -The audit found 58 variables read by a single model or pipeline family. They are +The inventory includes 66 variables read by a single model or pipeline family. They are not listed as public usage options here because doing so would turn implementation escape hatches into an accidental compatibility contract. @@ -223,11 +223,11 @@ Every audited model-specific name has a migration disposition in the | Disposition | Count | Required outcome | | --- | ---: | --- | -| Promote | 35 | Move a stable setting into typed stage or model configuration. | -| Request scope | 6 | Move request-varying behavior into a declared request-option schema. | +| Promote | 38 | Move a stable setting into typed stage or model configuration. | +| Request scope | 5 | Move request-varying behavior into a declared request-option schema. | | External | 0 | Retain only when a supported third-party library owns the contract. | -| Internalize | 11 | Keep a debug or diagnostic switch out of public documentation and configuration. | -| Deprecate/remove | 6 | Remove a compatibility escape hatch that has no continuing contract. | +| Internalize | 16 | Keep a debug or diagnostic switch out of public documentation and configuration. | +| Deprecate/remove | 7 | Remove a compatibility escape hatch that has no continuing contract. | The disposition is a migration target, not a statement that the existing environment switch is stable. Promote or request-scope work should land in diff --git a/docs/design/feature/window_sequence_parallel.md b/docs/design/feature/window_sequence_parallel.md new file mode 100644 index 000000000..3d869964a --- /dev/null +++ b/docs/design/feature/window_sequence_parallel.md @@ -0,0 +1,186 @@ +# Window-Aligned Sequence Parallelism (SeedVR2) + +## Motivation + +SeedVR2's diffusion transformer (`NaDiT`) has no global attention: every layer +partitions the video tokens into 3D windows and attends only inside a window. +The window size is normalised against a fixed 720p reference area, so raising the +output resolution grows the *number* of windows, never their size. + +Ulysses redistributes the sequence and head dimensions around attention. +For SeedVR2, window-aligned SP offers a different communication tradeoff: one +hidden-state exchange per layout boundary versus Q/K/V and output exchanges +for Ulysses. The actual performance crossover requires measurement. +Window-aligned SP instead assigns **whole windows** to ranks: + +* every rank keeps **all** attention heads, so no head-count divisibility is + required, +* attention inside a layer needs **zero** communication, +* regular and shifted layouts alternate at every layer, so the released + 32-layer model has 31 inter-layer transitions, plus one text reduction per + layer and a final video gather. A transition can be a local permutation + when ownership does not change. + +## Window layouts + +`nadit.py` ports the reference windowing verbatim (Apache-2.0, +ByteDance SeedVR2). For a post-patch token grid `(T, H, W)`: + +```text +scale = sqrt(45 * 80 / (H * W)) # 720p reference area +wh, ww = ceil(round(H * scale) / nh), ceil(round(W * scale) / nw) +wt = ceil(min(T, 30) / nt) # BYTEDANCE_MAX_TEMPORAL_WINDOW +``` + +The regular method tiles with `slice(it * wt, min((it + 1) * wt, T))`; the +shifted method moves every cut by half a window and **clips at the boundary** +(`max(..., 0)` / `min(..., size)`) -- it is not a cyclic `torch.roll`. Both +methods produce an exact partition of the grid; the shifted layout is genuinely +different, so an `A -> B -> A -> B` layer schedule needs both directions of +re-sharding. + +Windows are enumerated `for iw ... for ih ... for it ...` (w outermost, t +innermost) and tokens inside a window are row-major in `(t, h, w)`. The +canonical token id is `(t * H + h) * W + w`, which is exactly what the reference +obtains by windowing `torch.arange`. + +Consequences that matter (all verified against the reference): + +* Window token counts are **ragged** (edge windows are clipped), so a shard must + be balanced by token count, not by window count. +* The number of layout transitions follows the actual layer schedule. For the + released 3B checkpoint (`num_layers=32`, alternating methods) there are 31 + inter-layer transitions, not 16; `ensure_layout(current, required)` derives + them instead of assuming `layer % 2`. + +## Planner + +`nadit.py` implements a deterministic LPT assignment: + +```text +windows sorted by (-video_token_count, original_window_id) +each window is placed on the rank with the smallest (assigned_tokens, rank_id) +each rank then lists its windows in ascending original order +``` + +The cache key covers the post-patch token grid, the full window method and +parameters (via a SHA-1 geometry fingerprint), the geometry version, the **SP +world size** and the planner version, so a different degree can never reuse a +stale assignment. Caches are LRU-bounded. + +## Plan A redistribution + +At a layout boundary the activations must move from the source assignment to the +destination assignment. With `Z_src[r]` / `Z_dst[d]` the canonical token ids held +by each rank and `C[r, d]` the number of rows rank `d` needs from rank `r`: + +* `input_split_sizes` of rank `r` is row `r` of `C`, `output_split_sizes` is + column `r`, +* each peer segment is packed in **destination-local order**, so the receiver can + reorder with a single `argsort` of the concatenated destination positions, +* `network_exchange_required` is computed from the global count matrix, so every + rank takes the same branch; a rank that happens to exchange nothing never + skips the collective alone. + +The runtime is a variable-split `torch.distributed.all_to_all_single` over an +regular SP process group (`get_sp_group().device_group`), plus a local-permutation fast +path when the whole group stays in place (which is the SP=1 case). The first +version is deliberately synchronous: no extra streams, no per-layer +`cuda.synchronize()` or barrier "fixups", and no hidden all-gather. + +## Text stream + +Text is replicated on every rank and stays identical: each layer's attention +produces one text output per window, ranks sum their local window outputs, +one SUM all-reduce runs on the window-SP group, and every rank divides by the +**global** window count of the current layout. + +```text +T_next = (sum_r sum_{w in Omega_r} T_w) / W_global +``` + +This is neither a token-weighted mean nor a mean of rank means; a rank with no +window contributes a zero tensor (with the *same dtype as its peers* -- a dtype +mismatch changes the collective and deadlocks NCCL, which is why the empty-rank +path is covered by a GPU test). + +## Existing SP configuration + +Configure `ulysses_degree=N` to create the existing N-rank SP group. Pass +`DiffusionParallelConfig` to `SeedVR2NaDiT.build_runtime(parallel_config=...)`; +the model validates the configuration and obtains `get_sp_group().device_group`. +This adds no framework parallel option or group-construction branch. + +For SeedVR2 this selects **window-aligned Plan A**, not head-sharded Ulysses. +Every rank retains all 20 attention heads, so SP=8 does not require the head +count to divide by eight. The model has no `_sp_plan`, and its attention uses +`skip_sequence_parallel=True`; generic sequence hooks and Ulysses attention +exchanges are not installed. Reports name the actual execution path explicitly. + +SeedVR2 rejects ring, allgather, `advanced_uaa`, and `ulysses_a2a_permute`. +Other diffusion models keep their existing Ulysses/Ring/AllGather behavior. + +## SeedVR2 integration + +`SeedVR2WindowRuntime` owns, per request: the layer schedule, the entry +distribution, `ensure_layout` transitions, the text reduction and the final +reconstruction back to canonical token order. The DiT forward keeps only this +rank's video rows from the patch embedding onwards; the patch projection, +norms, ADA modulation, residual and MLP are all tokenwise, so they run locally. + +The block never calls a parallel attention strategy: the shared `Attention` +layer is created with `skip_sequence_parallel=True`, and window attention is a +packed-varlen call over `[video window, replicated text]` segments (with a +grouped-SDPA fallback for backends without a packed-varlen entry point). + +## Reference quirks reproduced on purpose + +* `vid_out_ada` is declared with `layers=["out"]`, whose embedding regrouping is + arithmetically incompatible with its 1-D parameters. The reference never uses + that value: it asks the forward-wide cache for `emb_repeat_0_vid`, a key the + first block already wrote, and therefore applies the *block* attention + modulation together with the learned `out_scale` / `out_shift`. The port + applies that effective modulation directly; changing it would break parity. +* `mm_layers=10` controls weight sharing only. Text is consumed and produced by + all 32 blocks; only the last block freezes its text MLP path. +* RoPE positions are window-local: a video token at window-local `(dt, dh, dw)` + uses axial indices `(text_len + dt, dh, dw)`, and every window restarts at 0. +* The rotary span is `3 * (rope_dim // 3)` (126 of 128 channels for the 3B), so + the last two channels pass through unrotated. + +## Checkpoint compatibility + +The parameter layout of the port mirrors the reference, with one explicit +exception: the released checkpoint stores the per-block RoPE table as +`blocks..attn.rope.rope.freqs`, while this port registers it as +`blocks..attn.rope.freqs`. The validation loader normalizes exactly that +suffix (and only that suffix); every other key must already match. + +> The validation loader explicitly normalizes the reference RoPE buffer suffix +> from `.rope.rope.freqs` to `.rope.freqs`. Checkpoint validation fails on any +> remaining missing or unexpected key after normalization. + +Normalization is restricted and collision-checked: a key that is already +normalized is left untouched, and two source keys mapping onto the same target +key raise instead of silently overwriting a tensor. Loading then fails on any +remaining missing, unexpected or shape-mismatched key, so a checkpoint that does +not match the port cannot reach inference. Full 32-layer loading is the +acceptance path; truncating the block list is a development fixture and requires +an explicit flag. + +The native serving loader applies the same suffix normalization and strict +checkpoint check. + +## Integration limits + +The native serving pipeline passes its parallel configuration to the DiT runtime +and returns restored video through the standard media path. The causal VAE can +run replicated, tiled, or height-sharded across the window-SP group. + +## Limitations + +* Plan A re-shards the full activation at every layout boundary. Plan B halo + exchange is a follow-up with a separate crossover evaluation. +* The packed-varlen attention kernel needs a backend that accepts + `cu_seqlens`; the grouped-SDPA path is the portable fallback and is what the + pinned L20 environment exercises. diff --git a/docs/models/seedvr2.md b/docs/models/seedvr2.md new file mode 100644 index 000000000..91e8ee5a3 --- /dev/null +++ b/docs/models/seedvr2.md @@ -0,0 +1,280 @@ +# SeedVR2 3B video restoration + +The native `SeedVR2Pipeline` restores an input video using the released 3B FP16 +NaDiT and s8/c16/t4 causal VAE. It uses fixed checkpoint conditioning, CFG=1, +and one Euler step. Text prompts do not change conditioning. + +## Code ownership + +The model package groups each computation stage in one file: `nadit.py` owns +window geometry, routing, RoPE and the DiT; `vae.py` owns the causal VAE and its +temporal/spatial tiling; `pipeline_seedvr2.py` owns input validation, decoding, +conditioning, the Euler step and colour transfer. `config.py` owns model limits, +and `long_video.py` owns the optional HTTP job/window lifecycle. Framework code +only registers the model, its pre/postprocessors, metadata and HTTP routes. + +`color_correction_method` is model-specific: online requests use the existing +`extra_params` JSON object, and offline sampling uses +`extra_args={"color_correction_method": "wavelet"}`. The default remains `lab`; +invalid values are rejected by model-side validation before inference. It is +not a top-level field in the common video API. + +## Model directory + +Place these files together, retaining their names: + +| File | SHA-256 of the validated release | +| --- | --- | +| `seedvr2_ema_3b_fp16.safetensors` | `2fd0e03a3dad24e07086750360727ca437de4ecd456f769856e960ae93e2b304` | +| `ema_vae_fp16.safetensors` | `20678548f420d98d26f11442d3528f8b8c94e57ee046ef93dbb7633da8612ca1` | +| `pos_emb.pt` | `fa07a14844314772266b66c3b95deb0027696d8fe7065721263db5176f45d799` | + +Add `model_index.json` containing: + +```json +{"_class_name": "SeedVR2Pipeline"} +``` + +The loader checks every checkpoint tensor, including RoPE buffers, and rejects +missing, duplicate, unexpected, or incompatible tensors. Install PyAV for video +decoding. The validated environment uses PyAV 18.1, PyTorch 2.13/CUDA 13, and L20 GPUs. + +## Serve and restore a video + +```bash +vllm serve "$MODEL_DIR" --omni \ + --model-class-name SeedVR2Pipeline --dtype float16 --enforce-eager \ + --num-gpus 1 --host 127.0.0.1 --port 8098 + +curl --fail-with-body http://127.0.0.1:8098/v1/videos/sync \ + -F 'prompt= ' \ + -F 'input_references=@input.mp4;type=video/mp4' \ + -F 'size=224x128' -F 'num_inference_steps=1' \ + -F 'guidance_scale=1' -F 'seed=7723' \ + --output restored.mp4 +``` + +For two-rank window sequence parallelism, expose two GPUs and replace `--num-gpus 1` with: + +```bash +--num-gpus 2 --distributed-executor-backend mp \ + --stage-overrides '{"0":{"ulysses_degree":2}}' +``` + +Use degree 4 and four visible GPUs for SP4. Window-SP alone replicates the VAE. +To shard VAE activations across the same ranks, use: + +```bash +--vae-use-tiling --num-gpus 2 --distributed-executor-backend mp \ + --stage-overrides '{"0":{"ulysses_degree":2,"vae_patch_parallel_size":2,"vae_parallel_mode":"spatial_shard_height"}}' +``` + +The VAE patch degree must match the window-SP degree. +For a 16-frame 1280×720 input restored at its original size, use four GPUs, +VAE tiling, and height sharding with both degrees set to four: + +```bash +CUDA_VISIBLE_DEVICES=0,1,2,3 vllm serve "$MODEL_DIR" --omni \ + --model-class-name SeedVR2Pipeline --dtype float16 --enforce-eager \ + --vae-use-tiling --num-gpus 4 --distributed-executor-backend mp \ + --stage-overrides '{"0":{"ulysses_degree":4,"vae_patch_parallel_size":4,"vae_parallel_mode":"spatial_shard_height"}}' \ + --host 127.0.0.1 --port 8098 +``` + +Request `size=1280x720` for this landscape input or `size=720x1280` for a +portrait input of the same dimensions. Keep the requested output size equal to +the input size when testing restoration without upscaling. + +The multipart API requires a prompt field; a single space supplies a blank +prompt. Nonblank text is rejected. Omit `fps` to retain the source frame rate; +an explicit rate must match the source. Output dimensions are explicit multiples +of 16. Input frames are resized with antialiased bicubic interpolation. The final +frame is repeated internally to reach 4n+1, and the decoded output is cropped back +to the original frame count. Five frames remain five; six frames are internally +padded to nine and return six. + +## Colour correction + +Restoration reproduces detail faithfully but shifts global colour, so the +resized input is used to transfer colour back onto the restored frames. This is +on by default; pass `extra_params={"color_correction_method":"wavelet"}` to choose how: + +| Value | Behavior | +| --- | --- | +| `lab` (default) | Swaps the lowest frequency band, then histogram-matches CIELAB chroma. Most faithful colour. | +| `wavelet` | Swaps only the lowest frequency band, keeping every higher band from the restoration. | +| `adain` | Matches per-channel mean and standard deviation. Cheapest, and corrects only a global tint. | +| `none` | Skips the transfer and returns raw restored colour. | + +```bash +curl --fail-with-body http://127.0.0.1:8098/v1/videos/sync \ + -F 'prompt= ' -F 'input_references=@input.mp4;type=video/mp4' \ + -F 'size=224x128' -F 'num_inference_steps=1' -F 'guidance_scale=1' \ + -F 'seed=7723' -F 'extra_params={"color_correction_method":"wavelet"}' \ + --output restored.mp4 +``` + +`lab` and `wavelet` retain the restored high-frequency detail; `adain` measurably +softens it because it rescales every frequency. The transfer runs in FP32 after +the VAE decode and costs a fraction of a second per frame. Unrecognized values +are rejected with 400 before the clip is admitted. Correction cannot recover +colour lost to 4:2:0 chroma subsampling in the returned MP4, which dominates the +remaining deviation once the transfer is applied. + +## Request admission + +The default 3B limit is 848×480 pixels per input or output frame and 2,035,200 +pixels across the requested output clip. With VAE tiling and window SP of at +least four ranks whose `vae_patch_parallel_size` matches `ulysses_degree`, the +per-frame limit rises to 2560×1472 and the clip budget to 18,841,600 pixels. +Raising the degree never lowers the budget. Both budgets count temporal +padding to 4n+1 frames: the default admits up to five 848×480 frames or 93 +192×112 frames; the sharded profile admits up to 45 848×480 frames or five +2560×1472 frames. A 16-frame 1280×720 or 720×1280 clip pads to 17 frames and +uses 15,667,200 of the sharded clip's 18,841,600 pixels. Both orientations passed +original-size HTTP restoration on four RTX 5090 GPUs with VAE tiling and height +sharding. An independent 257-frame cap bounds per-frame decoder work for tiny +inputs. The other longer combinations above are admission bounds, not completed +GPU validation. The decoder checks +declared duration, frame count, and input dimensions when available, then +enforces the limits as frames arrive. Requests outside these budgets return 400 +before building the resized whole-clip tensor. + +The per-frame cap is calibrated for the smallest qualified device. The clip cap +scales with the smallest visible device's memory: it stays at the calibrated +five 2560×1472 frames on a 32 GB device and grows on larger ones from a +per-rank memory model measured at SP4 (see `sharded_budget` in +`vllm_omni/diffusion/models/seedvr2/pipeline_seedvr2.py`), which keeps 15% of the device +free and leaves room for allocator fragmentation. It stops at 2,118,057,984 +padded pixels (513 frames at 1536×2688), the largest clip validated on four +B300 ranks; 80 GB devices get about 1.08 billion and 141 GB or larger devices +reach the cap. Clips at the cap in both memory regimes (561 frames at 2560×1472 +and 2,049 frames at 768×1344) peaked at 54 GiB of activations per rank. Set +these before starting the server to override the caps; each must be a positive +integer, and validating an override on the target hardware is the operator's +responsibility: + +| Variable | Default | Bounds | +| --- | --- | --- | +| `VLLM_OMNI_SEEDVR2_SHARDED_FRAME_PIXELS` | 3,768,320 | Pixels per frame on the sharded profile | +| `VLLM_OMNI_SEEDVR2_SHARDED_CLIP_PIXELS` | 18,841,600, scaled up with device memory | Padded pixels per clip on the sharded profile | +| `VLLM_OMNI_SEEDVR2_MAX_FRAMES` | 257 | Decoder-work frame cap, all profiles | + +The sharded profile applies to any `ulysses_degree` of four or more, so eight +ranks inherit a budget calibrated on four. Raising these caps trades a 400 for a +CUDA OOM: the admission check passes and the request fails later inside the DiT +forward or the VAE decode, which can abort every in-flight request on the engine +rather than only the oversized one. Before serving with raised caps, restore the +largest clip they admit once and confirm it completes. + +A 362-frame 1536×2688 2x upscale was restored on eight ranks with +`ulysses_degree=8`, VAE tiling and height sharding, using 4,300,000 for the +per-frame cap, the memory-scaled clip cap and 2,000 for the frame cap. The long-video +route sizes its windows from these caps, so the clip cap sets how many frames +each window covers. + +## Long-video restoration + +`POST /v1/seedvr2/restore-long` accepts one uploaded 24 FPS video, a blank +prompt, `size`, `num_frames` (up to 7,200), and optional `loop_input=true` and +`extra_params={"color_correction_method":"wavelet"}`, which it applies to every window. +An output frame must fit the sharded per-frame pixel cap. It returns a job ID; poll +`GET /v1/seedvr2/restore-long/{id}` and download the completed MP4 from +`GET /v1/seedvr2/restore-long/{id}/content`. `DELETE +/v1/seedvr2/restore-long/{id}` asks a running job to stop; it settles as +`cancelled` at the next window boundary. A job whose owning process is gone, +such as after a server restart, reports `failed` on the next poll. + +Jobs live on the API server's local disk, so the route requires +`--api-server-count 1` and runs one job at a time. Storage is bounded by an +upload cap and by deleting settled jobs once they age out: + +| Variable | Default | Bounds | +| --- | --- | --- | +| `VLLM_OMNI_SEEDVR2_LONG_OUTPUT_DIR` | system temp dir | Parent of the job directories | +| `VLLM_OMNI_SEEDVR2_LONG_MAX_UPLOAD_BYTES` | 8 GiB | Largest accepted upload | +| `VLLM_OMNI_SEEDVR2_LONG_JOB_TTL_SECONDS` | 3,600 | Age at which a settled job is deleted | +| `VLLM_OMNI_SEEDVR2_LONG_MAX_WINDOW` | 121 | Longest model window, in frames | + +Download a completed job before its TTL expires; the sweep runs on each new +submission and removes the output with the job directory. + +The service sends windows through the existing SeedVR2 endpoint, blends four +frames at each boundary, and writes one continuous MP4 encoder. Each window is +the longest 4n+1-frame clip that the sharded clip-pixel cap, the frame cap and +`VLLM_OMNI_SEEDVR2_LONG_MAX_WINDOW` admit at the output size. Every window pays +a fixed round trip through the serving stack and recomputes its overlap, and a +window of four latent frames gets no temporal attention in the DiT, so raise +the clip cap as far as the device allows. Windows carry source pixels, which +the model resizes on the device, and cross the endpoint losslessly; only the +final MP4 is compressed. The next window is restored while the previous one is +blended and encoded. It repeats source frames and audio only when +`loop_input=true`. The sharded serving profile above (VAE tiling, VAE height +sharding, Ulysses degree of at least four) is required. This route keeps a +window within the whole-clip pixel budget; it does not raise the +`/v1/videos/sync` 257-frame or pixel limits. The long route +uses fixed one-step, CFG=1 conditioning and requires `imageio[ffmpeg]` for +audio muxing. The 7,200-frame 768×1344 case is still undergoing full GPU E2E. + +## Temporal and spatial VAE tiling + +Add `--vae-use-tiling` to bound VAE intermediate activations along time. The +encoder processes nine frames first, then eight per chunk; the decoder processes +two latent frames per chunk. Each causal convolution carries its past inputs +within that encode/decode call, with temporal stride and first-frame upsampling +alignment preserved. GroupNorm and attention still see the entire spatial frame. +There is no overlap blend or independent restart at chunk boundaries. + +Five-frame clips use one temporal chunk. On a single GPU, convolutions with +more than 256 input rows additionally process tiles of 128 output rows with +exact halos. GroupNorm and bottleneck attention retain full-frame statistics. +This bounds convolution workspace; full-frame activations and the DiT's +whole-clip activations still consume memory. + +With VAE patch parallelism, each rank owns a band of latent rows and the +corresponding encoder/decoder rows. Neighbor exchanges supply convolution halos; +all-reduced centered statistics preserve full-frame GroupNorm. Bottleneck +attention gathers the low-resolution frame before splitting it again. Encoder +parameters are gathered before latent sampling, and decoded pixels are gathered +before returning the video. Unequal bands are supported; fewer latent rows than +ranks fall back to replicated execution. Width sharding and batch slicing are +unsupported. FP16 reduction and convolution order can change rounding, so +parallel outputs are numerically close rather than bitwise identical. + +## Current integration scope + +| Property | Contract | +| --- | --- | +| Weights / dtype | Released 3B DiT and VAE, FP16 | +| Reference semantics | C0 whole clip, colour correction on by default | +| Video input | One uploaded file, or offline TCHW RGB floats / PIL frames | +| Timing | Constant frame rate, increasing PTS; normalize the video origin to zero | +| Audio | First mono/stereo track, aligned by source PTS, cropped to the video interval, re-encoded as AAC | +| Randomness | Per-request generator; preserve reference latent strides when sampling noise | +| Sequence parallelism | Native engine SP1/2/4 correctness verified on five-frame L20 cases | +| VAE placement | Replicated by default; optional height sharding on the window-SP group | +| Unsupported | VFR, multichannel audio, 7B, other sampling schedules, quantization, cache acceleration, VAE width sharding / batch slicing, CPU offload, CFG/TP/PP parallelism, compiled execution, LoRA | + +Unsupported engine modes are rejected before process hooks and worker creation. +`ulysses_degree` selects the model-owned SP group. This branch assigns whole +windows to ranks; the specialized head-sharded path is a dependent change. +Ring and AllGather-KV are unsupported. + +The practical P0 reference's five-frame batching, overlap, and CPU swapping are +separate execution semantics. Temporal tiling and VAE patch +parallelism reduce activation peaks but do not bound the whole-clip DiT memory. +Large output frames may still exceed device capacity. + +Invalid inputs return 400. A single-GPU model OOM fails that request; a +subsequent short request succeeds. With VAE patch parallelism, restart the +service after an OOM: another rank may still be waiting in a collective. Cancelling an in-progress video job removes it through the +existing DELETE endpoint; in-flight GPU work may drain before memory is reusable. +A lost SP worker fails the request and makes health return 503. Restart the +service to restore availability; automatic rank recovery is not provided. + +## Reproducible deployment + +See the [RTX 5090 recipe](https://github.com/vllm-project/vllm-omni/blob/main/recipes/ByteDance/SeedVR2-RTX-5090.md) for the +input/output contract, complete serving command, and media checks. The local +tests in `tests/diffusion/models/seedvr2/test_seedvr2_e2e.py` exercise the full +3B pipeline, all colour modes, and HTTP restoration with USP 1 and 8. diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 3ea7fe2f1..5b8bf3464 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -54,6 +54,7 @@ th { | `LingBotVideoPipeline` | LingBot-Video dense and MoE T2I, T2V, TI2V | `robbyant/lingbot-video-dense-1.3b`, `robbyant/lingbot-video-moe-30b-a3b` | ✅︎ | | | | — | | `SanaVideoPipeline` | SANA-Video-2B T2V (native 480p/720p; Diffusers adapter validated at both resolutions) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/NVIDIA/SANA-Video-2B.md) | | `SanaImageToVideoPipeline` | SANA-Video-2B I2V (native and Diffusers adapter validated at 480p/720p) | `Efficient-Large-Model/SANA-Video_2B_480p_diffusers`, `Efficient-Large-Model/SANA-Video_2B_720p_diffusers` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/NVIDIA/SANA-Video-2B.md) | +| `SeedVR2Pipeline` | [SeedVR2 3B](seedvr2.md) video restoration | `ByteDance-Seed/SeedVR2-3B` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/ByteDance/SeedVR2-RTX-5090.md) | | `MiniMaxH3Pipeline` | MiniMax H3 T2VA, first/last-keyframe FL2VA, and mixed-reference Ref2VA | `MiniMaxAI/MiniMax-H3` | ✅︎ | ✅︎ | | | [Published](https://recipes.vllm.ai/MiniMaxAI/MiniMax-H3) | | `Magi2Pipeline` | MAGI-2 native Preview-stage T2VA and I2VA (272p/540p) | `sand-ai/MAGI-2-preview` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SandAI/MAGI-2-preview-L20X.md) | | `DreamZeroPipeline` | DreamZero-DROID | `GEAR-Dreams/DreamZero-DROID` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — | diff --git a/recipes/ByteDance/SeedVR2-RTX-5090.md b/recipes/ByteDance/SeedVR2-RTX-5090.md new file mode 100644 index 000000000..ce838033d --- /dev/null +++ b/recipes/ByteDance/SeedVR2-RTX-5090.md @@ -0,0 +1,199 @@ +# SeedVR2 video restoration — RTX 5090 + +## Summary + +- Vendor: ByteDance +- Model: SeedVR2 3B +- Task: restore or upscale an uploaded video +- Mode: native offline/HTTP pipeline, one Euler step +- Hardware: NVIDIA GeForce RTX 5090, 32 GiB per device +- Maintainer: 0z5a + +## When to use this recipe + +Run short, constant-frame-rate videos with the native SeedVR2 pipeline. This +recipe covers the whole-clip path and the separate long-video route. Color +correction from external applications is a separate execution semantic. + +## Supported model contract + +| Property | Contract | +| --- | --- | +| Model directory | See [checkpoint files and hashes](../../docs/models/seedvr2.md#model-directory) | +| Input | One uploaded video; blank prompt; positive dimensions divisible by 16 | +| Output | MP4 at requested geometry, original frame count and source frame rate | +| Audio | First mono/stereo track, aligned by PTS; re-encoded as AAC | +| Sampling | One Euler step, CFG=1, per-request seed | +| Precision | 3B DiT and VAE FP16 | +| Parallelism | Whole-window SP via `ulysses_degree`; replicated model weights | +| Frame padding | Internal 4n+1 padding, cropped back; five returns five, six returns six | + +## References + +- [Canonical SeedVR](https://github.com/ByteDance-Seed/SeedVR) +- [Model guide](../../docs/models/seedvr2.md) +- [Integration RFC](https://github.com/vllm-project/vllm-omni/issues/7723) + +## Hardware + +RTX 5090 with 32 GiB per GPU, PCIe, one GPU for the default command or two/four +for SP. Each GPU must fit a complete model copy and its activations. Host RAM +must accommodate checkpoint staging per rank. Admission uses a padded +five-frame 848×480 output pixel budget by default, allowing longer clips at +smaller resolutions, plus a 257-frame decoder-work cap. The validated SP4 +configuration with VAE tiling and `vae_patch_parallel_size=4` uses a padded +five-frame 2560×1472 budget, as does any higher matched degree. On devices +with more memory the clip budget grows automatically; the other caps are raised +through `VLLM_OMNI_SEEDVR2_SHARDED_FRAME_PIXELS` and +`VLLM_OMNI_SEEDVR2_MAX_FRAMES`, and `VLLM_OMNI_SEEDVR2_SHARDED_CLIP_PIXELS` +overrides the clip budget. The model guide lists +exact bounds and which profiles have completed GPU validation. + +## Software environment + +Linux x86-64, Python 3.12, PyTorch 2.13.0+cu130, CUDA 13.0, vLLM 0.29.0, and +PyAV in the existing environment. Use the vLLM-Omni branch containing this recipe. + +## Command + +Set `MODEL_DIR` to the authorized checkpoint directory documented in the model +guide; it must also contain `model_index.json` selecting `SeedVR2Pipeline`. + +```bash +CUDA_VISIBLE_DEVICES=0 vllm serve "$MODEL_DIR" --omni \ + --model-class-name SeedVR2Pipeline --dtype float16 --enforce-eager \ + --num-gpus 1 --host 127.0.0.1 --port 8098 +``` + +For SP=2, expose two GPUs and replace the GPU count with: + +```bash +--num-gpus 2 --distributed-executor-backend mp \ + --stage-overrides '{"0":{"ulysses_degree":2}}' +``` + +Use degree 4 and four visible GPUs for SP=4. Combine any feature-specific +settings below into the same stage-overrides object; do not repeat that flag. +The validated 16-frame 720p original-size profile is: + +```bash +CUDA_VISIBLE_DEVICES=0,1,2,3 vllm serve "$MODEL_DIR" --omni \ + --model-class-name SeedVR2Pipeline --dtype float16 --enforce-eager \ + --vae-use-tiling --num-gpus 4 --distributed-executor-backend mp \ + --stage-overrides '{"0":{"ulysses_degree":4,"vae_patch_parallel_size":4,"vae_parallel_mode":"spatial_shard_height"}}' \ + --host 127.0.0.1 --port 8098 +``` + +Use `size=1280x720` for a landscape 1280×720 input or `size=720x1280` for a +portrait 720×1280 input. Both require 16 decoded source frames and retain the +source frame rate; no upscaling is requested. + +## Verification + +```bash +curl --fail-with-body http://127.0.0.1:8098/v1/videos/sync \ + -F 'prompt= ' -F 'input_references=@input.mp4;type=video/mp4' \ + -F 'size=224x128' -F 'num_inference_steps=1' \ + -F 'guidance_scale=1' -F 'seed=7723' --output restored.mp4 +python - <<'PY' +import av +with av.open('restored.mp4') as video: + stream = video.streams.video[0] + print(stream.width, stream.height, stream.average_rate, + sum(1 for _ in video.decode(video=0)), len(video.streams.audio)) +PY +``` + +Expect HTTP 200 and a completely decodable 224×128 video. Check the frame count, +FPS, timestamps, and audio against the input. Repeat the same seed and compare +decoded frames; changing the seed should change the restored video. Inspect +matching first/middle/last frames at the same display scale. AAC is re-encoded, +so input/output audio packet hashes need not be identical. + +The released 3B checkpoint also passed these four-GPU SP4 HTTP cases with the +configuration above. Each input had 16 frames at 24 FPS; the output retained +the original size, 16 decoded frames, timestamps, and decodable audio. All four +workers and the server exited normally after each run. + +| 1× input → output | Earlier six-frame admission | Revised HTTP result | Request time | +| --- | --- | --- | ---: | +| 1280×720, 16 frames → 1280×720 | Rejected | HTTP 200; media checks passed | 3.553 s | +| 720×1280, 16 frames → 720×1280 | Rejected | HTTP 200; media checks passed | 3.388 s | + +These are single requests on a shared host, not performance comparisons. + +### One long-video request + +For a 24 FPS source, the long route can restore up to 7,200 frames at +768×1344. Use the SP4/VAE height-sharding command above, serve with +`--api-server-count 1`, and set a persistent +`VLLM_OMNI_SEEDVR2_LONG_OUTPUT_DIR` before starting. If the source is shorter +than 300 seconds, `loop_input=true` repeats its frames and audio. The service +runs the longest model windows the clip budget admits, with four-frame overlap, +and writes one MP4; +it does not concatenate separate video files. Download the result within +`VLLM_OMNI_SEEDVR2_LONG_JOB_TTL_SECONDS`, after which the job directory is +swept; `DELETE` on the job URL stops a run at the next window boundary. + +```bash +curl --fail-with-body http://127.0.0.1:8098/v1/seedvr2/restore-long \ + -F 'input_references=@input.mp4;type=video/mp4' \ + -F 'prompt= ' -F 'size=768x1344' -F 'num_frames=7200' \ + -F 'loop_input=true' -F 'seed=7723' -F 'extra_params={"color_correction_method":"lab"}' +# Use the returned id to poll /v1/seedvr2/restore-long/{id}; +# download /v1/seedvr2/restore-long/{id}/content when completed. +``` + +The input must have constant 24 FPS timestamps starting at zero. Only one job +can run per API server, and `--api-server-count` must be one. The existing +whole-clip `/v1/videos/sync` limits remain in force. Full 7,200-frame GPU +validation is in progress; use this route only with a memory-checked SP4 +deployment. + +Set `VLLM_TEST_SEEDVR2_MODEL_DIR` to the directory containing all model files: + +```bash +python -m pytest -o addopts='' -v tests/diffusion/models/seedvr2/test_seedvr2_e2e.py +``` + +The tests run the complete native pipeline for every colour mode and the HTTP +long-video route at USP 1 and 8 (eight GPUs required for the latter). The HTTP +cases force a 13-frame window, restore 20 frames across a seam, preserve audio +and timestamps, reject invalid `extra_params`, and cancel a longer job. They +use the released model rather than mocking its forward pass. + +## Supported features + +| Feature | Status | +| --- | --- | +| [Window SP](../../docs/models/seedvr2.md) | Model-local regular/shifted window attention | +| CPU offload, LoRA, compiled execution, CFG/TP/PP | Unsupported | +| VFR or multichannel audio | Unsupported | +| VAE temporal/spatial tiling | Available with `--vae-use-tiling` | +| [Colour correction](../../docs/models/seedvr2.md) | On by default; `color_correction_method=lab\|wavelet\|adain\|none` | +| Quantization | Separate opt-in feature | + +## Measurement scope + +Compare identical inputs, seeds, dimensions, GPU counts, and warmups. Separate +correctness from performance: use repeated interleaved HTTP measurements before +claiming a stable gain. Keep raw logs, tensors, and generated media outside the +source diff; link selected visuals from the PR's test results. + +## Temporal tiling and VAE patch parallelism + +Use `--vae-use-tiling` for causal temporal chunks. On a single GPU this also +bounds large convolution workspace with exact spatial halos. To shard VAE +activations across the same two SP ranks, use: + +```bash +--vae-use-tiling --num-gpus 2 --distributed-executor-backend mp \ + --stage-overrides '{"0":{"ulysses_degree":2,"vae_patch_parallel_size":2,"vae_parallel_mode":"spatial_shard_height"}}' +``` + +The VAE patch degree must equal the SP degree. Height sharding is supported; +width sharding and batch slicing are not. Compare clips that cross a temporal +chunk boundary as well as five/six-frame clips, and inspect frames adjacent to +chunk boundaries. Reduced peak memory does not by itself establish lower latency. +High-resolution clips can still exceed device capacity; validate the intended +frame count and output size on the target GPUs. diff --git a/tests/config/test_environment_variables.py b/tests/config/test_environment_variables.py index 6d3c4588a..e0434a358 100644 --- a/tests/config/test_environment_variables.py +++ b/tests/config/test_environment_variables.py @@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts(): EnvironmentVariableCategory.PUBLIC_OMNI: 32, EnvironmentVariableCategory.INHERITED_VLLM: 20, EnvironmentVariableCategory.PLATFORM_EXTERNAL: 29, - EnvironmentVariableCategory.MODEL_SPECIFIC: 59, + EnvironmentVariableCategory.MODEL_SPECIFIC: 66, EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 21, EnvironmentVariableCategory.INTERNAL: 2, } @@ -178,7 +178,7 @@ def test_inventory_matches_reviewed_snapshot_counts(): if item.category is EnvironmentVariableCategory.MODEL_SPECIFIC ) assert {disposition: disposition_counts[disposition] for disposition in ModelEnvironmentVariableDisposition} == { - ModelEnvironmentVariableDisposition.PROMOTE: 31, + ModelEnvironmentVariableDisposition.PROMOTE: 38, ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 5, ModelEnvironmentVariableDisposition.EXTERNAL: 0, ModelEnvironmentVariableDisposition.INTERNALIZE: 16, diff --git a/tests/diffusion/models/seedvr2/test_seedvr2_e2e.py b/tests/diffusion/models/seedvr2/test_seedvr2_e2e.py new file mode 100644 index 000000000..a82d577b0 --- /dev/null +++ b/tests/diffusion/models/seedvr2/test_seedvr2_e2e.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""End-to-end native video restoration and the HTTP long-video route. + +Set VLLM_TEST_SEEDVR2_MODEL_DIR to the directory containing the DiT, VAE, +conditioning tensor, and model_index.json to run the complete video test. +Each rank exits normally; the launcher does not terminate workers. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import time +from fractions import Fraction +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.local_model, pytest.mark.cuda, pytest.mark.diffusion, pytest.mark.parallel] +MODEL_DIR_ENV = "VLLM_TEST_SEEDVR2_MODEL_DIR" +# Two model windows with one four-frame seam, at the smallest legal frame size. +LONG_FRAMES = 20 +LONG_SIZE = 64 +FPS = 24 + + +@pytest.mark.skipif(not os.environ.get(MODEL_DIR_ENV), reason=f"set {MODEL_DIR_ENV} to an authorized model directory") +@pytest.mark.parametrize("method", ["lab", "wavelet", "adain", "none"]) +def test_seedvr2_native_video_e2e(method: str) -> None: + import numpy as np + import torch + + from vllm_omni.entrypoints.omni import Omni + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + model_dir = Path(os.environ[MODEL_DIR_ENV]).resolve() + for name in ("seedvr2_ema_3b_fp16.safetensors", "ema_vae_fp16.safetensors", "pos_emb.pt", "model_index.json"): + assert (model_dir / name).is_file(), f"Missing SeedVR2 model file: {name}" + frames = torch.rand(5, 3, 64, 112, generator=torch.Generator().manual_seed(7723)) + engine = Omni( + model=str(model_dir), + model_class_name="SeedVR2Pipeline", + dtype="float16", + enforce_eager=True, + vae_use_tiling=True, + ) + try: + output = engine.generate( + {"prompt": " ", "multi_modal_data": {"video": frames}}, + OmniDiffusionSamplingParams( + height=128, + width=224, + num_frames=5, + fps=24, + num_inference_steps=1, + guidance_scale=1.0, + seed=7723, + output_type="np", + extra_args={"color_correction_method": method}, + ), + use_tqdm=False, + ) + finally: + engine.close() + assert len(output) == 1 + restored = np.asarray(output[0].images[0]) + assert restored.shape == (1, 5, 128, 224, 3) + assert np.isfinite(restored).all() + + +def _write_source(path: Path, frames: int) -> None: + """A constant-rate source with audio, which the route requires.""" + import imageio_ffmpeg + + subprocess.run( + [ + imageio_ffmpeg.get_ffmpeg_exe(), + "-nostdin", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + f"testsrc=size={LONG_SIZE}x{LONG_SIZE}:rate={FPS}", + "-f", + "lavfi", + "-i", + "sine=frequency=440", + # The generators are endless, so bound the video by frame count and + # the audio by a slightly longer wall time. + "-frames:v", + str(frames), + "-t", + str((frames + 1) / FPS), + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + str(path), + ], + check=True, + ) + + +def _await_status(base: str, job_id: str, timeout: float) -> dict: + """Poll until the job leaves the queued/running states.""" + import requests + + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + record = requests.get(f"{base}/{job_id}", timeout=30).json() + if record["status"] not in {"queued", "running"}: + return record + time.sleep(2) + raise AssertionError(f"SeedVR2 long-video job stayed {record['status']} for {timeout}s") + + +@pytest.mark.skipif(not os.environ.get(MODEL_DIR_ENV), reason=f"set {MODEL_DIR_ENV} to an authorized model directory") +@pytest.mark.parametrize("degree", [1, 8]) +def test_seedvr2_long_video_route_e2e(tmp_path: Path, degree: int) -> None: + """Restore a clip spanning two model windows, then cancel a longer job.""" + import av + import requests + import torch + + if torch.accelerator.device_count() < degree: + pytest.skip(f"SeedVR2 USP={degree} requires {degree} CUDA devices") + + from tests.helpers.runtime import OmniServer + from vllm_omni.diffusion.models.seedvr2.long_video import MAX_FRAMES + + model_dir = Path(os.environ[MODEL_DIR_ENV]).resolve() + source = tmp_path / "input.mp4" + _write_source(source, LONG_FRAMES) + server = OmniServer( + model=str(model_dir), + serve_args=[ + "--model-class-name", + "SeedVR2Pipeline", + "--dtype", + "float16", + "--enforce-eager", + "--num-gpus", + str(degree), + "--vae-use-tiling", + "--stage-overrides", + json.dumps( + { + "0": { + "ulysses_degree": degree, + "vae_patch_parallel_size": degree, + "vae_parallel_mode": "spatial_shard_height", + } + } + ), + "--api-server-count", + "1", + ], + env_dict={ + "VLLM_OMNI_SEEDVR2_LONG_OUTPUT_DIR": str(tmp_path / "jobs"), + "VLLM_OMNI_SEEDVR2_LONG_MAX_WINDOW": "13", + }, + ) + + with server: + base = f"http://{server.host}:{server.port}/v1/seedvr2/restore-long" + + def submit(**overrides: str) -> requests.Response: + with source.open("rb") as upload: + return requests.post( + base, + data={ + "prompt": " ", + "size": f"{LONG_SIZE}x{LONG_SIZE}", + "num_frames": str(LONG_FRAMES), + "seed": "7723", + **overrides, + }, + files={"input_references": ("input.mp4", upload, "video/mp4")}, + timeout=120, + ) + + assert submit(num_frames=str(MAX_FRAMES + 1)).status_code == 400 + + # Validation must happen before a job is admitted, through model-owned + # extra_params on both the long route and the common video endpoint. + for invalid in ("{", "[]", '{"color_correction_method":"invalid"}'): + assert submit(extra_params=invalid).status_code == 400 + with source.open("rb") as upload: + invalid = requests.post( + f"http://{server.host}:{server.port}/v1/videos/sync", + data={ + "prompt": " ", + "size": f"{LONG_SIZE}x{LONG_SIZE}", + "num_inference_steps": "1", + "guidance_scale": "1", + "extra_params": '{"color_correction_method":"invalid"}', + }, + files={"input_references": ("input.mp4", upload, "video/mp4")}, + timeout=120, + ) + assert invalid.status_code == 400, invalid.text + accepted = submit(extra_params='{"color_correction_method":"wavelet"}') + assert accepted.status_code == 202, accepted.text + job_id = accepted.json()["id"] + record = _await_status(base, job_id, timeout=900) + assert record["status"] == "completed", record["error"] + assert record["frames"] == LONG_FRAMES + + restored = tmp_path / "restored.mp4" + content = requests.get(f"{base}/{job_id}/content", timeout=300) + assert content.status_code == 200 + restored.write_bytes(content.content) + with av.open(str(restored)) as container: + video = container.streams.video[0] + timestamps = [frame.pts * frame.time_base for frame in container.decode(video=0)] + assert (video.width, video.height) == (LONG_SIZE, LONG_SIZE) + assert video.average_rate == Fraction(FPS) + assert timestamps == [Fraction(index, FPS) for index in range(LONG_FRAMES)] + assert container.streams.audio, "the route must carry the source audio through" + + # Many windows, so the cancel lands well before the job could finish. + running = submit(num_frames="600", loop_input="true") + assert running.status_code == 202, running.text + cancelled_id = running.json()["id"] + assert submit().status_code == 409, "the route runs one job per server" + assert requests.delete(f"{base}/{cancelled_id}", timeout=30).status_code == 202 + settled = _await_status(base, cancelled_id, timeout=900) + assert settled["status"] == "cancelled", settled + assert settled["frames"] < 600 + assert requests.get(f"{base}/{cancelled_id}/content", timeout=30).status_code == 404 diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index f0e7b7d53..0dead804f 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -388,8 +388,13 @@ def isolated_video_backends(tmp_path, monkeypatch): return store, tasks, storage +@pytest.mark.parametrize("model_class_name", [None, "SeedVR2Pipeline", "auto"]) @pytest.mark.asyncio -async def test_server_worker_keeps_engine_alive_until_http_shutdown(monkeypatch): +async def test_server_worker_keeps_engine_alive_until_http_shutdown(monkeypatch, model_class_name): + # Resolve imports outside the timed server-lifecycle assertions. + from vllm_omni.diffusion.models.seedvr2 import long_video # noqa: F401 + + app = FastAPI() events: list[str] = [] serve_started = asyncio.Event() http_shutdown = asyncio.Event() @@ -399,6 +404,11 @@ async def test_server_worker_keeps_engine_alive_until_http_shutdown(monkeypatch) class FakeEngine: stage_configs = [] + def get_diffusion_od_config(self): + if model_class_name == "auto": + return SimpleNamespace(model_class_name="SeedVR2Pipeline") + return None + async def get_supported_tasks(self): return ("generate",) @@ -435,7 +445,7 @@ async def test_server_worker_keeps_engine_alive_until_http_shutdown(monkeypatch) events.append("init_app_state") monkeypatch.setattr(api_server, "build_async_omni", fake_build_async_omni) - monkeypatch.setattr(api_server, "build_openai_app", lambda args, supported_tasks: FastAPI()) + monkeypatch.setattr(api_server, "build_openai_app", lambda args, supported_tasks: app) monkeypatch.setattr(api_server, "serve_http", fake_serve_http) monkeypatch.setattr(api_server.STORAGE_MANAGER, "start", fake_storage_start) monkeypatch.setattr(api_server.openai_app_state, "_get_vllm_config", fake_get_vllm_config) @@ -461,9 +471,16 @@ async def test_server_worker_keeps_engine_alive_until_http_shutdown(monkeypatch) h11_max_header_count=None, ) + if model_class_name != "auto" and model_class_name is not None: + args.model_class_name = model_class_name + worker_task = asyncio.create_task(api_server.omni_run_server_worker("127.0.0.1:0", sock, args)) await asyncio.wait_for(serve_started.wait(), timeout=2) + route_paths = app.openapi()["paths"] + assert ("/v1/seedvr2/restore-long" in route_paths) == (model_class_name is not None) + if model_class_name is not None: + assert app.state.seedvr2_long_port == args.port assert not engine_context_exited.is_set() http_shutdown.set() @@ -2646,6 +2663,7 @@ def test_extra_params_merged_into_extra_args(test_client, mocker: MockerFixture) "pyramid_num_stages": 3, "pyramid_num_inference_steps_list": [1, 1, 1], "use_cfg_zero_star": True, + "color_correction_method": "wavelet", } response = test_client.post( "/v1/videos", @@ -2664,6 +2682,7 @@ def test_extra_params_merged_into_extra_args(test_client, mocker: MockerFixture) assert captured.extra_args["pyramid_num_stages"] == 3 assert captured.extra_args["pyramid_num_inference_steps_list"] == [1, 1, 1] assert captured.extra_args["use_cfg_zero_star"] is True + assert captured.extra_args["color_correction_method"] == "wavelet" def test_extra_params_none_by_default(test_client, mocker: MockerFixture): diff --git a/tests/model_tests/diffusion/test_alignment.py b/tests/model_tests/diffusion/test_alignment.py index 0bf19a1a3..d3ef4fd12 100644 --- a/tests/model_tests/diffusion/test_alignment.py +++ b/tests/model_tests/diffusion/test_alignment.py @@ -32,6 +32,8 @@ EXCLUDED_MODELS = [ # dedicated MammothModa2 bridge/pipeline tests and real-checkpoint E2E. "MammothModa2DiTPipeline", "WanPipeline", + # Uses a full checkpoint and video input; covered by its checkpoint-gated E2E test. + "SeedVR2Pipeline", "WanDMDPipeline", "WanVACEPipeline", "LTX2TwoStagePipeline", diff --git a/vllm_omni/config/environment_variable_inventory.py b/vllm_omni/config/environment_variable_inventory.py index 69c1df1c3..4ae58c00f 100644 --- a/vllm_omni/config/environment_variable_inventory.py +++ b/vllm_omni/config/environment_variable_inventory.py @@ -174,6 +174,13 @@ _MODEL_PROMOTE = ( "VLLM_GEPARD_LOOKBACK_FRAMES", "VLLM_OMNI_FISH_KVCACHE_ATTN", "VLLM_OMNI_SANA_WM_STAGE1_TEXT_ENCODER", + "VLLM_OMNI_SEEDVR2_LONG_JOB_TTL_SECONDS", + "VLLM_OMNI_SEEDVR2_LONG_MAX_UPLOAD_BYTES", + "VLLM_OMNI_SEEDVR2_LONG_MAX_WINDOW", + "VLLM_OMNI_SEEDVR2_LONG_OUTPUT_DIR", + "VLLM_OMNI_SEEDVR2_MAX_FRAMES", + "VLLM_OMNI_SEEDVR2_SHARDED_CLIP_PIXELS", + "VLLM_OMNI_SEEDVR2_SHARDED_FRAME_PIXELS", "VLLM_OMNI_SENSENOVA_PAGED_DECODE", ) diff --git a/vllm_omni/diffusion/model_metadata.py b/vllm_omni/diffusion/model_metadata.py index 1059093b6..aaefb2cee 100644 --- a/vllm_omni/diffusion/model_metadata.py +++ b/vllm_omni/diffusion/model_metadata.py @@ -33,6 +33,7 @@ BOOGU_IMAGE_MAX_INPUT_IMAGES = 1 _DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = { + "SeedVR2Pipeline": DiffusionModelMetadata(supports_multimodal_inputs=True, final_output_type="video"), "Flux2KleinPipeline": DiffusionModelMetadata( supports_multimodal_inputs=True, max_multimodal_image_inputs=FLUX2_KLEIN_MAX_INPUT_IMAGES, diff --git a/vllm_omni/diffusion/models/seedvr2/__init__.py b/vllm_omni/diffusion/models/seedvr2/__init__.py new file mode 100644 index 000000000..8de615374 --- /dev/null +++ b/vllm_omni/diffusion/models/seedvr2/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Native SeedVR2 video restoration.""" diff --git a/vllm_omni/diffusion/models/seedvr2/config.py b/vllm_omni/diffusion/models/seedvr2/config.py new file mode 100644 index 000000000..0f428f586 --- /dev/null +++ b/vllm_omni/diffusion/models/seedvr2/config.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Operator limits and colour options owned by SeedVR2.""" + +import os +import tempfile +from collections.abc import Callable +from typing import Any + +COLOR_CORRECTION_METHODS = ("lab", "wavelet", "adain", "none") +DEFAULT_COLOR_CORRECTION_METHOD = "lab" + + +def _positive_int(name: str, default: int | None) -> int | None: + """Read an operator-tuned limit, rejecting values that would disable it.""" + value = os.environ.get(name) + if value is None: + return default + if not value.strip().isdigit() or not int(value): + raise ValueError(f"{name} must be a positive integer, got {value!r}") + return int(value) + + +environment_variables: dict[str, Callable[[], Any]] = { + # ================== SeedVR2 Restoration Env Vars ================== + # Admission budgets for the sharded serving profile. The defaults are + # calibrated for the smallest qualified device, so larger accelerators raise + # them here; setting them beyond what the device holds surfaces as a CUDA + # OOM during the forward pass. See docs/models/seedvr2.md. + "VLLM_OMNI_SEEDVR2_MAX_FRAMES": lambda: _positive_int("VLLM_OMNI_SEEDVR2_MAX_FRAMES", 257), + "VLLM_OMNI_SEEDVR2_SHARDED_FRAME_PIXELS": lambda: _positive_int( + "VLLM_OMNI_SEEDVR2_SHARDED_FRAME_PIXELS", 2560 * 1472 + ), + # Unset, the clip budget scales with device memory from the calibrated + # 32 GB value; see seedvr2.pipeline_seedvr2.sharded_budget. + "VLLM_OMNI_SEEDVR2_SHARDED_CLIP_PIXELS": lambda: _positive_int("VLLM_OMNI_SEEDVR2_SHARDED_CLIP_PIXELS", None), + # Bounds for the long-video restoration route. + "VLLM_OMNI_SEEDVR2_LONG_MAX_UPLOAD_BYTES": lambda: _positive_int( + "VLLM_OMNI_SEEDVR2_LONG_MAX_UPLOAD_BYTES", 8 * 1024**3 + ), + # Longest model window. The clip budget usually binds first; this caps it + # at 30 latent frames, the DiT's largest temporal attention window. + "VLLM_OMNI_SEEDVR2_LONG_MAX_WINDOW": lambda: _positive_int("VLLM_OMNI_SEEDVR2_LONG_MAX_WINDOW", 121), + "VLLM_OMNI_SEEDVR2_LONG_JOB_TTL_SECONDS": lambda: _positive_int("VLLM_OMNI_SEEDVR2_LONG_JOB_TTL_SECONDS", 3600), + "VLLM_OMNI_SEEDVR2_LONG_OUTPUT_DIR": lambda: os.environ.get( + "VLLM_OMNI_SEEDVR2_LONG_OUTPUT_DIR", tempfile.gettempdir() + ), +} + + +def __getattr__(name): + # lazy evaluation of environment variables + if name in environment_variables: + return environment_variables[name]() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return list(environment_variables.keys()) diff --git a/vllm_omni/diffusion/models/seedvr2/long_video.py b/vllm_omni/diffusion/models/seedvr2/long_video.py new file mode 100644 index 000000000..bdd894a28 --- /dev/null +++ b/vllm_omni/diffusion/models/seedvr2/long_video.py @@ -0,0 +1,502 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""One asynchronous SeedVR2 request, bounded model windows, one MP4 writer.""" + +import asyncio +import hashlib +import io +import itertools +import json +import logging +import os +import shutil +import subprocess +import tempfile +import threading +import time +from collections import deque +from collections.abc import Iterator +from concurrent.futures import Future, ThreadPoolExecutor +from fractions import Fraction +from pathlib import Path +from uuid import uuid4 + +import av +import imageio_ffmpeg +import numpy as np +import regex as re +import requests +from fastapi import APIRouter, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse + +from vllm_omni.diffusion.models.seedvr2 import config as envs +from vllm_omni.diffusion.models.seedvr2.config import COLOR_CORRECTION_METHODS, DEFAULT_COLOR_CORRECTION_METHOD +from vllm_omni.diffusion.models.seedvr2.pipeline_seedvr2 import max_frames, sharded_budget + +logger = logging.getLogger(__name__) +router = APIRouter() +MAX_FRAMES = 7200 +FPS = 24 +OVERLAP = 4 +# Windows cross the HTTP boundary losslessly (x264 qp 0, which is also its +# fastest mode); only the final MP4 is compressed. +LOSSLESS = {"preset": "ultrafast", "qp": "0"} +UPLOAD_CHUNK = 1 << 20 +JOB_ID = re.compile(r"[0-9a-f]{32}") +active_task: asyncio.Task[None] | None = None +job_lock = asyncio.Lock() + + +class JobError(Exception): + """An error whose message is safe to return to the client.""" + + +class JobCancelledError(JobError): + """The client asked the job to stop.""" + + +def _jobs_root() -> Path: + return Path(envs.VLLM_OMNI_SEEDVR2_LONG_OUTPUT_DIR) / "seedvr2-long" + + +def _job_dir(job_id: str) -> Path: + """Resolve a client-supplied job id, which must never shape the path.""" + if not JOB_ID.fullmatch(job_id): + raise HTTPException(404, "SeedVR2 long-video job not found") + return _jobs_root() / job_id + + +def _sweep_expired_jobs() -> None: + """Drop settled job directories so repeated submissions cannot fill the disk.""" + deadline = time.time() - envs.VLLM_OMNI_SEEDVR2_LONG_JOB_TTL_SECONDS + for job in _jobs_root().glob("*"): + status = job / "status.json" + if not job.is_dir() or not JOB_ID.fullmatch(job.name) or not status.exists(): + continue + record = json.loads(status.read_text()) + # A job owned by a dead process is settled too, or a restart would leak it. + settled = record["status"] not in {"queued", "running"} or record.get("pid") != os.getpid() + if settled and status.stat().st_mtime <= deadline: + shutil.rmtree(job, ignore_errors=True) + + +def _store_upload(upload: UploadFile, destination: Path) -> None: + """Copy the upload under a size cap so one client cannot fill the disk.""" + limit = envs.VLLM_OMNI_SEEDVR2_LONG_MAX_UPLOAD_BYTES + written = 0 + with destination.open("wb") as target: + while chunk := upload.file.read(UPLOAD_CHUNK): + written += len(chunk) + if written > limit: + raise HTTPException(413, f"SeedVR2 long-video upload exceeds {limit} bytes") + target.write(chunk) + if not written: + raise HTTPException(400, "SeedVR2 long-video upload is empty") + + +def _window(width: int, height: int) -> int: + """Longest window the whole-clip budget admits at this output size. + + Every window pays a fixed cost for its round trip through the serving + stack and throws away its overlap, so the route uses the largest window the + model accepts. The model pads clips to 4n+1 frames, so the window has that + length too; 0 means not even one frame fits. + """ + frame_pixels, clip_pixels = sharded_budget() + if width * height > frame_pixels: + return 0 + fit = min(clip_pixels // (width * height), max_frames(), envs.VLLM_OMNI_SEEDVR2_LONG_MAX_WINDOW) + return fit - (fit - 1) % 4 + + +def _status(job: Path, stage: str, frames: int, error: str = "") -> None: + # The owning PID lets a poller detect a job that a server restart abandoned. + record = {"status": stage, "frames": frames, "error": error, "pid": os.getpid()} + pending = job / "status.json.tmp" + pending.write_text(json.dumps(record) + "\n") + pending.replace(job / "status.json") + + +def _frames(source: Path, width: int, height: int, loop: bool) -> Iterator[av.VideoFrame]: + while True: + count = 0 + with av.open(str(source)) as container: + video = container.streams.video[0] + if video.average_rate != Fraction(FPS): + raise JobError("SeedVR2 long video requires 24 FPS input") + for frame in container.decode(video=0): + if frame.pts is None or frame.time_base is None or frame.pts * frame.time_base != Fraction(count, FPS): + raise JobError("SeedVR2 long video requires constant 24 FPS timestamps starting at zero") + count += 1 + # The model resizes to the output on the device, so windows carry + # source pixels; only a larger source shrinks here to stay in budget. + if frame.width * frame.height > width * height: + yield frame.reformat(width=width, height=height, format="yuv420p", interpolation="BICUBIC") + else: + yield frame.reformat(width=frame.width & ~1, height=frame.height & ~1, format="yuv420p") + if count == 0: + raise JobError("Input video has no frames") + if not loop: + return + + +# Consecutive windows share their overlap frames and encoding stamps each +# frame's pts, so two in-flight windows must not encode at the same time. +_segment_lock = threading.Lock() + + +def _segment(frames: list[av.VideoFrame]) -> bytes: + buffer = io.BytesIO() + with _segment_lock, av.open(buffer, "w", format="matroska") as container: + stream = container.add_stream("libx264", rate=FPS) + stream.width, stream.height, stream.pix_fmt = frames[0].width, frames[0].height, "yuv420p" + stream.options = LOSSLESS + for index, frame in enumerate(frames): + frame.pts, frame.time_base = index, Fraction(1, FPS) + for packet in stream.encode(frame): + container.mux(packet) + for packet in stream.encode(): + container.mux(packet) + return buffer.getvalue() + + +def _restore( + frames: list[av.VideoFrame], + width: int, + height: int, + seed: int, + color_correction_method: str, + port: int, + authorization: str, +) -> list[np.ndarray]: + response = requests.post( + f"http://127.0.0.1:{port}/v1/videos/sync", + data={ + "prompt": " ", + "size": f"{width}x{height}", + "num_frames": str(len(frames)), + "num_inference_steps": "1", + "guidance_scale": "1", + "seed": str(seed), + "extra_params": json.dumps( + {"video_codec_options": LOSSLESS, "color_correction_method": color_correction_method} + ), + }, + files={"input_references": ("window.mkv", _segment(frames), "video/x-matroska")}, + headers={"Authorization": authorization} if authorization else {}, + timeout=900, + ) + try: + response.raise_for_status() + except requests.HTTPError as error: + raise JobError("SeedVR2 window restoration failed") from error + with av.open(io.BytesIO(response.content)) as container: + video = container.streams.video[0] + decoded = list(container.decode(video=0)) + if (video.width, video.height, video.average_rate, len(decoded)) != (width, height, Fraction(FPS), len(frames)): + raise JobError("SeedVR2 window restoration returned an unexpected geometry or frame count") + if [frame.pts * frame.time_base for frame in decoded] != [Fraction(index, FPS) for index in range(len(frames))]: + raise JobError("SeedVR2 window restoration returned invalid timestamps") + return [frame.to_ndarray(format="rgb24") for frame in decoded] + + +def _run( + job: Path, + width: int, + height: int, + target: int, + loop: bool, + seed: int, + color_correction_method: str, + port: int, + authorization: str, +) -> None: + window = _window(width, height) + if window <= OVERLAP: + raise JobError("SeedVR2 long video output exceeds the configured SeedVR2 clip budget") + with tempfile.TemporaryDirectory(prefix="seedvr2-long-") as scratch: + _restore_to( + job, Path(scratch), window, width, height, target, loop, seed, color_correction_method, port, authorization + ) + + +def _restore_to( + job: Path, + scratch: Path, + window: int, + width: int, + height: int, + target: int, + loop: bool, + seed: int, + color_correction_method: str, + port: int, + authorization: str, +) -> None: + """Restore into local scratch files, then publish the result into ``job``.""" + source = job / "input.mp4" + video_path = scratch / "video.mp4" + output = scratch / "output.mp4" + source_frames = _frames(source, width, height, loop) + + def windows() -> Iterator[list[av.VideoFrame]]: + batch = list(itertools.islice(source_frames, min(window, target))) + start = 0 + while True: + if len(batch) != min(window, target - start): + raise JobError("Input video has fewer frames than requested; set loop_input=true to repeat it") + yield batch + start += len(batch) - OVERLAP + if start + OVERLAP >= target: + return + batch = batch[-OVERLAP:] + list(itertools.islice(source_frames, min(window, target - start) - OVERLAP)) + + def restore(frames: list[av.VideoFrame]) -> Future[list[np.ndarray]]: + if (job / "cancel").exists(): + raise JobCancelledError("SeedVR2 long-video job was cancelled") + return pool.submit(_restore, frames, width, height, seed, color_correction_method, port, authorization) + + written = 0 + pending: list[np.ndarray] = [] + # Two windows stay in flight, so the engine always has the next one queued + # while this thread blends and encodes, and the other prepares its upload. + with ThreadPoolExecutor(max_workers=2) as pool, av.open(str(video_path), "w", format="mp4") as container: + video = container.add_stream("libx264", rate=FPS) + video.width, video.height, video.pix_fmt = width, height, "yuv420p" + video.options = {"preset": "veryfast", "crf": "18", "tune": "zerolatency", "bf": "0"} + + def write(array: np.ndarray) -> None: + nonlocal written + frame = av.VideoFrame.from_ndarray(array, format="rgb24") + frame.pts, frame.time_base = written, Fraction(1, FPS) + for packet in video.encode(frame): + container.mux(packet) + written += 1 + + batches = windows() + in_flight = deque(restore(batch) for batch in itertools.islice(batches, 2)) + while in_flight: + restored = in_flight.popleft().result() + following = next(batches, None) + if following is not None: + in_flight.append(restore(following)) + last = not in_flight + if not pending: + for frame in restored if last else restored[:-OVERLAP]: + write(frame) + else: + for offset in range(OVERLAP): + newer = (offset + 1) / (OVERLAP + 1) + write(np.rint(pending[offset] * (1 - newer) + restored[offset] * newer).astype(np.uint8)) + for frame in restored[OVERLAP:] if last else restored[OVERLAP:-OVERLAP]: + write(frame) + _status(job, "running", written) + pending = restored[-OVERLAP:] + for packet in video.encode(): + container.mux(packet) + if written != target: + raise JobError(f"SeedVR2 restored {written} of {target} requested frames") + + with av.open(str(source)) as container: + has_audio = bool(container.streams.audio) + if has_audio: + subprocess.run( + [ + imageio_ffmpeg.get_ffmpeg_exe(), + "-nostdin", + "-y", + "-hide_banner", + "-loglevel", + "error", + "-stream_loop", + "-1" if loop else "0", + "-i", + str(source), + "-i", + str(video_path), + "-map", + "1:v:0", + "-map", + "0:a:0", + "-frames:v", + str(target), + "-t", + str(target / FPS), + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "160k", + "-movflags", + "+faststart", + str(output), + ], + check=True, + ) + else: + video_path.replace(output) + with av.open(str(output)) as container: + video = container.streams.video[0] + decoded = 0 + for decoded, frame in enumerate(container.decode(video=0), start=1): + if ( + frame.pts is None + or frame.time_base is None + or frame.pts * frame.time_base != Fraction(decoded - 1, FPS) + ): + raise JobError("SeedVR2 output encoding produced invalid timestamps") + if (video.width, video.height, video.average_rate, decoded) != (width, height, Fraction(FPS), target): + raise JobError("SeedVR2 output encoding failed frame or geometry validation") + if has_audio and not container.streams.audio: + raise JobError("SeedVR2 output encoding lost the audio track") + if has_audio: + with av.open(str(output)) as container: + audio = container.streams.audio[0] + samples = sum(frame.samples for frame in container.decode(audio=0)) + if samples < (target / FPS - 1) * audio.rate: + raise JobError("SeedVR2 output audio ends before the video") + with output.open("rb") as content: + hasher = hashlib.sha256() + for chunk in iter(lambda: content.read(UPLOAD_CHUNK), b""): + hasher.update(chunk) + digest = hasher.hexdigest() + # One sequential copy is the only write to the job directory, which may sit + # on network storage where the encoder's and muxer's small writes are slow. + staged = job / "output.mp4.tmp" + shutil.move(output, staged) + staged.replace(job / "output.mp4") + (job / "result.json").write_text( + json.dumps({"frames": target, "width": width, "height": height, "fps": FPS, "sha256": digest}) + "\n" + ) + _status(job, "completed", target) + + +def _background( + job: Path, + width: int, + height: int, + target: int, + loop: bool, + seed: int, + color_correction_method: str, + port: int, + authorization: str, +) -> None: + try: + _run(job, width, height, target, loop, seed, color_correction_method, port, authorization) + except Exception as error: + logger.exception("SeedVR2 long-video job %s failed", job.name) + current = json.loads((job / "status.json").read_text()) + detail = str(error) if isinstance(error, JobError) else "SeedVR2 long-video restoration failed" + _status(job, "cancelled" if isinstance(error, JobCancelledError) else "failed", current["frames"], detail) + + +@router.post("/v1/seedvr2/restore-long", status_code=202) +async def create_long_video( + raw_request: Request, + input_references: UploadFile = File(...), + num_frames: int = Form(...), + size: str = Form(...), + loop_input: bool = Form(False), + prompt: str = Form(" "), + seed: int = Form(7723), + extra_params: str | None = Form(None), +) -> dict[str, str | int]: + global active_task + if raw_request.app.state.api_server_count != 1: + raise HTTPException(409, "SeedVR2 long-video jobs require one API server") + try: + width, height = (int(value) for value in size.lower().split("x")) + except ValueError as error: + raise HTTPException(400, "size must be WIDTHxHEIGHT") from error + if not 1 <= num_frames <= MAX_FRAMES or min(width, height) < 16 or width % 16 or height % 16: + raise HTTPException(400, "SeedVR2 long-video frame count or dimensions are invalid") + if prompt.strip(): + raise HTTPException(400, "SeedVR2 long video requires a blank prompt") + if _window(width, height) <= OVERLAP: + raise HTTPException(400, "SeedVR2 long video output exceeds the configured SeedVR2 frame or clip budget") + if not 0 <= seed <= 2**32 - 1: + raise HTTPException(400, "Seed must be a 32-bit unsigned integer") + try: + options = json.loads(extra_params) if extra_params is not None else {} + except (TypeError, ValueError) as error: + raise HTTPException(400, "extra_params must be a JSON object") from error + if not isinstance(options, dict): + raise HTTPException(400, "extra_params must be a JSON object") + unknown = options.keys() - {"color_correction_method"} + if unknown: + raise HTTPException(400, f"Unsupported SeedVR2 long-video extra_params: {sorted(unknown)}") + color_correction_method = options.get("color_correction_method", DEFAULT_COLOR_CORRECTION_METHOD) + if color_correction_method is None: + color_correction_method = DEFAULT_COLOR_CORRECTION_METHOD + if color_correction_method not in COLOR_CORRECTION_METHODS: + raise HTTPException(400, f"color_correction_method must be one of {list(COLOR_CORRECTION_METHODS)}") + async with job_lock: + if active_task is not None and not active_task.done(): + raise HTTPException(409, "A SeedVR2 long-video job is already running") + await asyncio.to_thread(_sweep_expired_jobs) + job_id = uuid4().hex + job = _jobs_root() / job_id + job.mkdir(parents=True) + try: + await asyncio.to_thread(_store_upload, input_references, job / "input.mp4") + except HTTPException: + shutil.rmtree(job, ignore_errors=True) + raise + _status(job, "queued", 0) + active_task = asyncio.create_task( + asyncio.to_thread( + _background, + job, + width, + height, + num_frames, + loop_input, + seed, + color_correction_method, + raw_request.app.state.seedvr2_long_port, + raw_request.headers.get("authorization", ""), + ) + ) + return {"id": job_id, "status": "queued", "max_frames": MAX_FRAMES} + + +@router.get("/v1/seedvr2/restore-long/{job_id}") +async def get_long_video(job_id: str) -> dict[str, str | int]: + status = _job_dir(job_id) / "status.json" + if not status.exists(): + raise HTTPException(404, "SeedVR2 long-video job not found") + record = json.loads(status.read_text()) + if record["status"] in {"queued", "running"} and record.get("pid") != os.getpid(): + # Only this process runs jobs, so another owner means a restart lost it. + record["status"], record["error"] = "failed", "SeedVR2 long-video job was interrupted by a server restart" + return record + + +@router.get("/v1/seedvr2/restore-long/{job_id}/content") +async def download_long_video(job_id: str) -> FileResponse: + job = _job_dir(job_id) + if not (job / "result.json").exists(): + raise HTTPException(404, "SeedVR2 long-video output is not ready") + return FileResponse(job / "output.mp4", media_type="video/mp4", filename=f"{job_id}.mp4") + + +@router.delete("/v1/seedvr2/restore-long/{job_id}", status_code=202) +async def cancel_long_video(job_id: str) -> dict[str, str]: + """Ask the running job to stop; it settles at the next window boundary.""" + job = _job_dir(job_id) + status = job / "status.json" + if not status.exists(): + raise HTTPException(404, "SeedVR2 long-video job not found") + record = json.loads(status.read_text()) + if record["status"] not in {"queued", "running"}: + raise HTTPException(409, f"SeedVR2 long-video job already {record['status']}") + (job / "cancel").touch() + return {"id": job_id, "status": "cancelling"} + + +def register_routes(app: FastAPI, port: int) -> None: + """Attach this model's optional long-video API to its serving process.""" + app.state.seedvr2_long_port = port + app.include_router(router) diff --git a/vllm_omni/diffusion/models/seedvr2/nadit.py b/vllm_omni/diffusion/models/seedvr2/nadit.py new file mode 100644 index 000000000..3886ca37a --- /dev/null +++ b/vllm_omni/diffusion/models/seedvr2/nadit.py @@ -0,0 +1,2116 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""SeedVR2 DiT: window geometry, sequence parallelism, rotary embeddings and NaDiT.""" + +from __future__ import annotations + +import hashlib +import math +from collections import OrderedDict +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from math import ceil, sqrt + +import torch +import torch.distributed as dist +import torch.nn.functional as F +from torch import nn +from vllm.logger import init_logger + +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.layer import Attention +from vllm_omni.diffusion.data import DiffusionParallelConfig +from vllm_omni.diffusion.distributed.parallel_state import get_sp_group + + +def validate_seedvr2_parallel_config(parallel: DiffusionParallelConfig) -> None: + if ( + parallel.sequence_parallel_size != parallel.ulysses_degree + or parallel.ring_degree != 1 + or parallel.allgather_degree != 1 + or parallel.ulysses_mode != "strict" + or parallel.ulysses_a2a_permute + ): + raise ValueError( + "SeedVR2 window SP requires pure ulysses_degree with ring_degree=allgather_degree=1, " + "ulysses_mode='strict', and ulysses_a2a_permute=False. " + "The degree selects the SP group; SeedVR2 keeps all attention heads on each rank." + ) + + +# Ported from the Apache-2.0 SeedVR2 reference implementation: +# https://github.com/ByteDance-Seed/SeedVR (seedvr/models/...) +# https://github.com/numz/ComfyUI-SeedVR2_VideoUpscaler (src/models/dit_3b/window.py) + + +#: 720p reference area used to normalise window sizes (``BYTEDANCE_720P_REF_AREA``). +REFERENCE_AREA = 45 * 80 + +#: Temporal windows are capped at this many latent frames +#: (``BYTEDANCE_MAX_TEMPORAL_WINDOW``). +MAX_TEMPORAL_WINDOW = 30 + +#: Bumped whenever the semantics of the geometry below change in a way that +#: invalidates cached layouts / redistributions. +GEOMETRY_VERSION = 1 + +WindowSlices = list[tuple[slice, slice, slice]] +Size3 = tuple[int, int, int] + + +def _window_size(size: Size3, num_windows: Size3) -> tuple[int, int, int]: + """Return ``(wt, wh, ww)`` for the 720p-normalised window grid.""" + t, h, w = size + resized_nt, resized_nh, resized_nw = num_windows + scale = sqrt(REFERENCE_AREA / (h * w)) + resized_h, resized_w = round(h * scale), round(w * scale) + wh, ww = ceil(resized_h / resized_nh), ceil(resized_w / resized_nw) + wt = ceil(min(t, MAX_TEMPORAL_WINDOW) / resized_nt) + return wt, wh, ww + + +def make_720p_windows(size: Size3, num_windows: Size3) -> WindowSlices: + """Regular (non-shifted) window slices, reference-faithful.""" + t, h, w = size + wt, wh, ww = _window_size(size, num_windows) + nt, nh, nw = ceil(t / wt), ceil(h / wh), ceil(w / ww) + return [ + ( + slice(it * wt, min((it + 1) * wt, t)), + slice(ih * wh, min((ih + 1) * wh, h)), + slice(iw * ww, min((iw + 1) * ww, w)), + ) + for iw in range(nw) + if min((iw + 1) * ww, w) > iw * ww + for ih in range(nh) + if min((ih + 1) * wh, h) > ih * wh + for it in range(nt) + if min((it + 1) * wt, t) > it * wt + ] + + +def make_720p_shifted_windows(size: Size3, num_windows: Size3) -> WindowSlices: + """Shifted window slices with boundary clipping, reference-faithful.""" + t, h, w = size + wt, wh, ww = _window_size(size, num_windows) + + st, sh, sw = ( + 0.5 if wt < t else 0, + 0.5 if wh < h else 0, + 0.5 if ww < w else 0, + ) + nt, nh, nw = ceil((t - st) / wt), ceil((h - sh) / wh), ceil((w - sw) / ww) + nt, nh, nw = ( + nt + 1 if st > 0 else 1, + nh + 1 if sh > 0 else 1, + nw + 1 if sw > 0 else 1, + ) + return [ + ( + slice(max(int((it - st) * wt), 0), min(int((it - st + 1) * wt), t)), + slice(max(int((ih - sh) * wh), 0), min(int((ih - sh + 1) * wh), h)), + slice(max(int((iw - sw) * ww), 0), min(int((iw - sw + 1) * ww), w)), + ) + for iw in range(nw) + if min(int((iw - sw + 1) * ww), w) > max(int((iw - sw) * ww), 0) + for ih in range(nh) + if min(int((ih - sh + 1) * wh), h) > max(int((ih - sh) * wh), 0) + for it in range(nt) + if min(int((it - st + 1) * wt), t) > max(int((it - st) * wt), 0) + ] + + +WINDOW_METHODS: dict[str, Callable[[Size3, Size3], WindowSlices]] = { + "720pwin_by_size_bysize": make_720p_windows, + "720pswin_by_size_bysize": make_720p_shifted_windows, +} + +#: Default per-layer schedule of the 3B checkpoint: regular/shifted alternating. +DEFAULT_WINDOW_METHODS: tuple[str, ...] = ("720pwin_by_size_bysize", "720pswin_by_size_bysize") + +#: ``window = num_layers * [(4, 3, 3)]`` in the released 3B config. +DEFAULT_WINDOW: Size3 = (4, 3, 3) + + +def get_window_op(name: str) -> Callable[[Size3, Size3], WindowSlices]: + """Return the window function for a reference ``window_method`` name.""" + try: + return WINDOW_METHODS[name] + except KeyError: # pragma: no cover - defensive + raise ValueError(f"Unknown windowing method: {name}") from None + + +def window_layout_geometry(size: Size3, window_method: str, window: Size3 = DEFAULT_WINDOW): + """Build the CPU geometry (offsets, canonical ids, shapes) of one layout. + + Returns ``(window_offsets, window_token_ids, window_shapes)`` where + + * ``window_offsets`` is int64 ``[W + 1]`` (start row of every window in + window-packed order), + * ``window_token_ids`` is int64 ``[N]`` with the canonical token id of every + packed row, and + * ``window_shapes`` is int64 ``[W, 3]`` with the ``(t, h, w)`` extent of + every window. + + The ids satisfy ``window_token_ids[offsets[i]:offsets[i + 1]]`` = canonical + ids of window ``i``, in reference window order and reference in-window + flatten order. + """ + t, h, w = size + if t <= 0 or h <= 0 or w <= 0: + raise ValueError(f"token grid must be positive, got {size}") + window_slices = get_window_op(window_method)(size, window) + + ids: list[torch.Tensor] = [] + shapes: list[tuple[int, int, int]] = [] + offsets = [0] + total = 0 + for st, sh, sw in window_slices: + nt, nh, nw = st.stop - st.start, sh.stop - sh.start, sw.stop - sw.start + if nt <= 0 or nh <= 0 or nw <= 0: + continue + tt = torch.arange(st.start, st.stop, dtype=torch.int64) + hh = torch.arange(sh.start, sh.stop, dtype=torch.int64) + ww = torch.arange(sw.start, sw.stop, dtype=torch.int64) + grid = (tt[:, None, None] * h + hh[None, :, None]) * w + ww[None, None, :] + ids.append(grid.reshape(-1)) + shapes.append((nt, nh, nw)) + total += nt * nh * nw + offsets.append(total) + + if not ids: + raise ValueError(f"window geometry produced no windows for size={size}, method={window_method}") + + window_token_ids = torch.cat(ids) + window_shapes = torch.tensor(shapes, dtype=torch.int64) + window_offsets = torch.tensor(offsets, dtype=torch.int64) + + # Every layout must be an exact partition of the token grid: this is the + # invariant the whole redistribution design relies on. + num_tokens = t * h * w + if total != num_tokens: + raise ValueError( + f"window layout is not a partition of the grid: {total} packed rows for {num_tokens} tokens " + f"(size={size}, method={window_method}, window={window})" + ) + if not torch.equal(torch.sort(window_token_ids).values, torch.arange(num_tokens, dtype=torch.int64)): + raise ValueError(f"window layout covers tokens with duplicates or holes (size={size}, method={window_method})") + + return window_offsets, window_token_ids, window_shapes + + +def geometry_fingerprint( + size: Size3, window_method: str, window: Size3, geometry_version: int = GEOMETRY_VERSION +) -> str: + """Deterministic fingerprint of a layout geometry. + + Uses SHA-1 over the parameters and the materialised window boundaries so the + value is stable across processes (unlike Python's randomised ``hash()``). + """ + hasher = hashlib.sha1() + hasher.update(repr((tuple(size), window_method, tuple(window), int(geometry_version))).encode()) + window_slices = get_window_op(window_method)(size, window) + for st, sh, sw in window_slices: + hasher.update(f"{st.start}:{st.stop},{sh.start}:{sh.stop},{sw.start}:{sw.stop};".encode()) + return hasher.hexdigest()[:16] + + +# Vendored from the Apache-2.0 SeedVR2 reference implementation +# (`src/models/dit_3b/rope.py`) and the MIT-licensed `rotary_embedding_torch` +# primitives it builds on, so the port does not add a runtime dependency. + + +#: The reference always builds the axial table for the *first* ``dim // 2`` +#: frequency slots of a ``lang`` frequency bank. +LANG_THETA = 10000.0 + + +def lang_freqs(axis_dim: int, theta: float = LANG_THETA) -> torch.Tensor: + """``1 / theta ** (arange(0, axis_dim, 2) / axis_dim)`` (``rotary_embedding_torch``).""" + return 1.0 / (theta ** (torch.arange(0, axis_dim, 2)[: axis_dim // 2].float() / axis_dim)) + + +def _axis_table(positions: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor: + """Outer product of positions with the frequency bank, each freq duplicated.""" + angles = positions.float().unsqueeze(-1) * freqs.float().unsqueeze(0) + return torch.repeat_interleave(angles, 2, dim=-1) + + +def rotate_half(x: torch.Tensor) -> torch.Tensor: + x = x.unflatten(-1, (-1, 2)) + x1, x2 = x.unbind(dim=-1) + return torch.stack((-x2, x1), dim=-1).flatten(-2) + + +def apply_rotary_emb(freqs: torch.Tensor, t: torch.Tensor) -> torch.Tensor: + """Rotate ``t`` with precomputed ``freqs`` (fp32 math, original dtype out). + + Mirrors ``rotary_embedding_torch.apply_rotary_emb`` for the 2-D frequency + tables and 3-D ``(heads, seq, dim)`` inputs used here: the rotated span is + ``freqs.shape[-1]`` channels and any remaining channels are untouched. + """ + dtype = t.dtype + rot_dim = freqs.shape[-1] + if rot_dim > t.shape[-1]: + raise ValueError(f"rotary dim {rot_dim} exceeds the feature dim {t.shape[-1]}") + if freqs.shape[0] < t.shape[-2]: + raise ValueError(f"freqs cover {freqs.shape[0]} positions but the sequence has {t.shape[-2]}") + freqs = freqs[-t.shape[-2] :].to(torch.float32) + + t_middle = t[..., :rot_dim].float() + rotated = t_middle * freqs.cos() + rotate_half(t_middle) * freqs.sin() + if rot_dim == t.shape[-1]: + return rotated.to(dtype) + return torch.cat((rotated, t[..., rot_dim:]), dim=-1).to(dtype) + + +class NaMMRotaryEmbedding3d(nn.Module): + """Reference ``mmrope3d``: axial 3-D RoPE for windows plus 1-D RoPE for text.""" + + mm = True + + def __init__(self, rotary_dim: int, num_axes: int = 3, theta: float = LANG_THETA) -> None: + super().__init__() + if num_axes <= 0 or rotary_dim < num_axes: + raise ValueError(f"invalid rotary_dim={rotary_dim} for num_axes={num_axes}") + self.rotary_dim = int(rotary_dim) + self.num_axes = int(num_axes) + # Reference: ``RotaryEmbedding(dim=rope_dim // 3, freqs_for="lang")`` and + # the axial table concatenates one such bank per axis. + self.axis_dim = self.rotary_dim // self.num_axes + self.rot_dim = self.axis_dim * self.num_axes + if self.rot_dim % 2: + raise ValueError( + f"rotary_dim={rotary_dim} with num_axes={num_axes} gives an odd rotary span " + f"({self.rot_dim}); the reference pairs channels two at a time" + ) + # Registered as `freqs`. The released checkpoint stores this table one + # module deeper (`...attn.rope.rope.freqs`) than this port registers it, so + # the validation loader normalizes exactly that suffix; every other key + # must already match the port's parameter layout. + self.register_buffer("freqs", lang_freqs(self.axis_dim, theta), persistent=True) + self._axis_cache: dict[tuple, torch.Tensor] = {} + + # -- frequency construction ------------------------------------------- + def _axis_angles(self, positions: torch.Tensor, *, device, dtype) -> torch.Tensor: + key = (positions.numel(), int(positions[0]), float(positions[-1]), str(device), str(dtype)) + cached = self._axis_cache.get(key) + if cached is None: + cached = _axis_table(positions.to(torch.float32), self.freqs.float()).to(device=device, dtype=dtype) + if len(self._axis_cache) > 256: + self._axis_cache.clear() + self._axis_cache[key] = cached + return cached + + def _table(self, dims: tuple[int, ...], *, device, dtype) -> torch.Tensor: + """Axial table of shape ``(*dims, rot_dim)`` (``get_axial_freqs``).""" + axes = [] + ref = self.freqs + for axis, dim in enumerate(dims): + positions = torch.arange(dim, device=ref.device, dtype=torch.float32) + angles = self._axis_angles(positions, device=device, dtype=dtype) + shape = [1] * len(dims) + [angles.shape[-1]] + shape[axis] = dim + axes.append(angles.reshape(shape)) + return torch.cat(torch.broadcast_tensors(*axes), dim=-1) + + # -- per-window / per-text positions ---------------------------------- + def window_freqs(self, window_shape: tuple[int, int, int], text_len: int, *, device, dtype) -> torch.Tensor: + """``[f * h * w, rot_dim]`` frequencies of one window, window-local.""" + f, h, w = (int(v) for v in window_shape) + table = self._table((text_len + f, h, w), device=device, dtype=dtype) + return table[text_len : text_len + f].reshape(-1, table.shape[-1]) + + def window_freqs_batch( + self, + window_shapes: torch.Tensor | list[tuple[int, int, int]], + text_len: int, + *, + device, + dtype, + ) -> torch.Tensor: + """Concatenated per-window video frequencies for a window batch.""" + if isinstance(window_shapes, torch.Tensor): + shapes = [tuple(int(v) for v in row) for row in window_shapes.tolist()] + else: + shapes = [(int(a), int(b), int(c)) for a, b, c in window_shapes] + if not shapes: + return torch.empty((0, self.rot_dim), device=device, dtype=dtype) + return torch.cat([self.window_freqs(s, text_len, device=device, dtype=dtype) for s in shapes], dim=0) + + def text_freqs(self, text_len: int, *, device, dtype) -> torch.Tensor: + """``[text_len, rot_dim]`` text frequencies (one axis repeated ``rope_dim`` times).""" + if text_len <= 0: + return torch.empty((0, self.rot_dim), device=device, dtype=dtype) + angles = self._axis_angles( + torch.arange(text_len, device=self.freqs.device, dtype=torch.float32), device=device, dtype=dtype + ) + return angles.repeat(1, self.num_axes) + + # -- forward ---------------------------------------------------------- + def forward( + self, + vid_q: torch.Tensor, # (L h d) + vid_k: torch.Tensor, + vid_freqs: torch.Tensor, # (L rot_dim) + txt_q: torch.Tensor, # (l h d) + txt_k: torch.Tensor, + txt_freqs: torch.Tensor, # (l rot_dim) + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + vid_q = apply_rotary_emb(vid_freqs, vid_q.transpose(0, 1)).transpose(0, 1) + vid_k = apply_rotary_emb(vid_freqs, vid_k.transpose(0, 1)).transpose(0, 1) + txt_q = apply_rotary_emb(txt_freqs, txt_q.transpose(0, 1)).transpose(0, 1) + txt_k = apply_rotary_emb(txt_freqs, txt_k.transpose(0, 1)).transpose(0, 1) + return vid_q, vid_k, txt_q, txt_k + + +#: Bumped whenever assignment or routing semantics change. +PLANNER_VERSION = 1 + +#: int32 ``cu_seqlens`` bounds (torch's varlen kernels take int32 offsets). +INT32_MAX = 2**31 - 1 + + +# ============================================================================= +# Layout / assignment / plan data structures (CPU metadata) +# ============================================================================= + + +@dataclass(frozen=True) +class WindowLayoutKey: + """Identity of one window layout (shape + geometry, not ownership).""" + + token_grid: tuple[int, int, int] + window_method: str + window_shape: tuple[int, int, int] + geometry_version: int + geometry_fingerprint: str + + +@dataclass(frozen=True, eq=False) +class WindowLayout: + """A partition of the video tokens into windows, in window-packed order. + + ``window_token_ids`` holds canonical token ids (``(t * H + h) * W + w``) of + every packed row; ``window_offsets`` delimits the windows inside it. + """ + + key: WindowLayoutKey + num_tokens: int + window_offsets: torch.Tensor # CPU int64 [W + 1] + window_token_ids: torch.Tensor # CPU int64 [N] + window_shapes: torch.Tensor # CPU int64 [W, 3] + + @property + def num_windows(self) -> int: + return int(self.window_offsets.numel() - 1) + + def window_lengths(self) -> torch.Tensor: + return (self.window_offsets[1:] - self.window_offsets[:-1]).to(torch.int64) + + def window_token_ids_of(self, window_ids: torch.Tensor) -> torch.Tensor: + """Canonical ids of the given windows, concatenated in that order.""" + if window_ids.numel() == 0: + return torch.empty(0, dtype=torch.int64) + ids = window_ids.tolist() + pieces = [self.window_token_ids[int(self.window_offsets[w]) : int(self.window_offsets[w + 1])] for w in ids] + return torch.cat(pieces) + + +@dataclass(frozen=True, eq=False) +class WindowAssignment: + """Which rank owns which window (a whole window belongs to one rank).""" + + layout_key: WindowLayoutKey + world_size: int + planner_version: int + owner_rank: torch.Tensor # CPU int64 [W] + rank_window_ids: tuple[torch.Tensor, ...] + rank_token_counts: tuple[int, ...] + rank_window_counts: tuple[int, ...] + max_window_tokens: int = 0 + + @property + def max_rank_tokens(self) -> int: + return max(self.rank_token_counts) if self.rank_token_counts else 0 + + @property + def ideal_lower_bound(self) -> int: + """Largest window, or perfectly balanced load, whichever is bigger.""" + total = sum(self.rank_token_counts) + return max(self.max_window_tokens, math.ceil(total / self.world_size)) + + def load_imbalance(self) -> float: + total = sum(self.rank_token_counts) + if total == 0: + return 1.0 + return self.max_rank_tokens / (total / self.world_size) + + +@dataclass(frozen=True, eq=False) +class RankWindowPlan: + """Rank-local view of a layout: what this rank owns and how it is packed.""" + + layout_key: WindowLayoutKey + rank: int + window_ids: torch.Tensor # CPU int64 [W_r] + global_token_ids: torch.Tensor # CPU int64 [N_r], canonical ids in local row order + video_cu_seqlens: torch.Tensor # CPU int32 [W_r + 1] + window_shapes: torch.Tensor # CPU int64 [W_r, 3] + + @property + def num_local_tokens(self) -> int: + return int(self.global_token_ids.numel()) + + @property + def num_local_windows(self) -> int: + return int(self.window_ids.numel()) + + +@dataclass(frozen=True, eq=False) +class WindowRedistributionPlan: + """Plan A routing contract for one rank of one layout transition. + + ``input_split_sizes[d]`` is the number of rows this rank sends to rank ``d`` + and ``output_split_sizes[s]`` the number of rows it receives from rank + ``s`` (feature rows, not bytes). ``send_indices`` selects rows of the + local source tensor in send order; ``recv_to_dst_indices`` permutes the + received rows into destination-local order. + """ + + src_key: WindowLayoutKey + dst_key: WindowLayoutKey + world_size: int + rank: int + input_split_sizes: tuple[int, ...] + output_split_sizes: tuple[int, ...] + send_indices: torch.Tensor # CPU int64 [N_src] + recv_to_dst_indices: torch.Tensor # CPU int64 [N_dst] + network_exchange_required: bool + planner_version: int = PLANNER_VERSION + + @property + def num_send_rows(self) -> int: + return int(self.send_indices.numel()) + + @property + def num_recv_rows(self) -> int: + return int(self.recv_to_dst_indices.numel()) + + +@dataclass(frozen=True) +class DeviceWindowRedistributionPlan: + """Device-side materialisation of :class:`WindowRedistributionPlan`.""" + + send_indices: torch.Tensor # int64 [N_src] + recv_to_dst_indices: torch.Tensor # int64 [N_dst] + input_split_sizes: tuple[int, ...] + output_split_sizes: tuple[int, ...] + num_recv_rows: int + network_exchange_required: bool + + +# ============================================================================= +# Layouts and assignment +# ============================================================================= + + +def build_window_layout( + token_grid: tuple[int, int, int], + window_method: str, + *, + window: tuple[int, int, int] = DEFAULT_WINDOW, + geometry_version: int = GEOMETRY_VERSION, +) -> WindowLayout: + """Build the CPU layout of one layer's window partition.""" + + window_offsets, window_token_ids, window_shapes = window_layout_geometry(token_grid, window_method, window) + key = WindowLayoutKey( + token_grid=(int(token_grid[0]), int(token_grid[1]), int(token_grid[2])), + window_method=window_method, + window_shape=(int(window[0]), int(window[1]), int(window[2])), + geometry_version=int(geometry_version), + geometry_fingerprint=geometry_fingerprint(token_grid, window_method, window, geometry_version), + ) + return WindowLayout( + key=key, + num_tokens=int(window_token_ids.numel()), + window_offsets=window_offsets, + window_token_ids=window_token_ids, + window_shapes=window_shapes, + ) + + +def build_window_assignment( + layout: WindowLayout, + world_size: int, + *, + planner_version: int = PLANNER_VERSION, +) -> WindowAssignment: + """Deterministic token-balanced window -> rank assignment (LPT). + + Windows are considered in ``(-video_token_count, window_id)`` order and each + is placed on the rank with the smallest ``(assigned_tokens, rank_id)``. The + result is independent of container iteration order. + """ + if world_size < 1: + raise ValueError(f"world_size must be >= 1, got {world_size}") + lengths = [int(v) for v in layout.window_lengths().tolist()] + if not lengths: + raise ValueError("layout has no windows") + if any(n <= 0 for n in lengths): + raise ValueError(f"every window must have a positive token count, got {lengths}") + + loads = [0] * world_size + buckets: list[list[int]] = [[] for _ in range(world_size)] + for w in sorted(range(len(lengths)), key=lambda w: (-lengths[w], w)): + r = min(range(world_size), key=lambda r: (loads[r], r)) + buckets[r].append(w) + loads[r] += lengths[w] + + rank_window_ids = tuple(torch.tensor(sorted(b), dtype=torch.int64) for b in buckets) + owner_rank = torch.empty(len(lengths), dtype=torch.int64) + for r, ids in enumerate(rank_window_ids): + if ids.numel(): + owner_rank[ids] = r + + return WindowAssignment( + layout_key=layout.key, + world_size=world_size, + planner_version=planner_version, + owner_rank=owner_rank, + rank_window_ids=rank_window_ids, + rank_token_counts=tuple(loads), + rank_window_counts=tuple(int(ids.numel()) for ids in rank_window_ids), + max_window_tokens=max(lengths), + ) + + +def build_rank_window_plan(layout: WindowLayout, assignment: WindowAssignment, rank: int) -> RankWindowPlan: + """Rank-local metadata for one layout (used for windows/attention packing).""" + _check_same_layout(layout, assignment.layout_key) + if not 0 <= rank < assignment.world_size: + raise ValueError(f"rank {rank} out of range for world_size {assignment.world_size}") + + window_ids = assignment.rank_window_ids[rank] + lengths = layout.window_lengths() + if window_ids.numel(): + local_lengths = lengths[window_ids] + local_counts = torch.cumsum(local_lengths, dim=0) + offsets = torch.cat([torch.zeros(1, dtype=torch.int64), local_counts]) + global_token_ids = layout.window_token_ids_of(window_ids) + window_shapes = layout.window_shapes[window_ids] + else: + offsets = torch.zeros(1, dtype=torch.int64) + global_token_ids = torch.empty(0, dtype=torch.int64) + window_shapes = torch.empty((0, 3), dtype=torch.int64) + + video_cu_seqlens = to_cu_seqlens(offsets, context=f"rank {rank} video packing") + return RankWindowPlan( + layout_key=layout.key, + rank=rank, + window_ids=window_ids, + global_token_ids=global_token_ids, + video_cu_seqlens=video_cu_seqlens, + window_shapes=window_shapes, + ) + + +def to_cu_seqlens(offsets: torch.Tensor, *, context: str = "") -> torch.Tensor: + """Convert int64 offsets to int32 ``cu_seqlens`` with an explicit overflow check.""" + offsets = offsets.to(torch.int64) + if offsets.numel() == 0: + raise ValueError("cu_seqlens must contain at least the leading zero") + if int(offsets[0]) != 0: + raise ValueError(f"cu_seqlens must start at 0, got {int(offsets[0])} ({context})") + if int(offsets[-1]) > INT32_MAX: + raise ValueError(f"sequence length {int(offsets[-1])} exceeds int32 cu_seqlens range ({context})") + if bool((offsets[1:] < offsets[:-1]).any()): + raise ValueError(f"cu_seqlens must be non-decreasing ({context})") + return offsets.to(torch.int32) + + +def joint_cu_seqlens(video_cu_seqlens: torch.Tensor, text_len: int) -> torch.Tensor: + """``[W + 1]`` int32 joint (video + replicated text) offsets for local attention. + + ``video_cu_seqlens`` describes video packing only and must not be used + directly as joint Q/K offsets. The offsets are accumulated in int64 on the + input's device and converted to int32 only after validation, so CUDA + metadata never round-trips through the host and an overflow is caught before + a wrapped offset can be produced. ``text_len`` may be 0; an input of ``[0]`` + (a rank without windows) yields ``[0]``. + """ + if text_len < 0: + raise ValueError(f"text_len must be >= 0, got {text_len}") + video = video_cu_seqlens.to(torch.int64) + if video.numel() == 0: + raise ValueError("video_cu_seqlens must contain at least the leading zero") + if bool((video[0] != 0).item()): + raise ValueError(f"video_cu_seqlens must start at 0, got {int(video[0])}") + if bool((video[1:] < video[:-1]).any().item()): + raise ValueError("video_cu_seqlens must be non-decreasing") + windows = video.numel() - 1 + text_offsets = torch.arange(windows + 1, device=video.device, dtype=torch.int64) * int(text_len) + return to_cu_seqlens(video + text_offsets, context="joint video+text packing") + + +# ============================================================================= +# Plan A routing +# ============================================================================= + + +def _check_same_layout(layout: WindowLayout, key: WindowLayoutKey) -> None: + if layout.key != key: + raise ValueError( + "layout/assignment mismatch: " + f"layout={layout.key.token_grid}/{layout.key.window_method}, " + f"assignment={key.token_grid}/{key.window_method}" + ) + + +def _rank_global_token_ids(layout: WindowLayout, assignment: WindowAssignment) -> list[torch.Tensor]: + return [layout.window_token_ids_of(assignment.rank_window_ids[r]) for r in range(assignment.world_size)] + + +def build_redistribution_plans( + src_layout: WindowLayout, + src_assignment: WindowAssignment, + dst_layout: WindowLayout, + dst_assignment: WindowAssignment, + *, + planner_version: int = PLANNER_VERSION, +) -> tuple[WindowRedistributionPlan, ...]: + """Build the bidirectional Plan A routing plans for every rank. + + Returns one :class:`WindowRedistributionPlan` per rank, indexed by SP-local + rank. Both directions (regular -> shifted and shifted -> regular) are plain + calls to this function; neither is derived from the other by transposing. + """ + _check_same_layout(src_layout, src_assignment.layout_key) + _check_same_layout(dst_layout, dst_assignment.layout_key) + if src_assignment.world_size != dst_assignment.world_size: + raise ValueError( + f"source world_size {src_assignment.world_size} != destination world_size {dst_assignment.world_size}" + ) + if src_layout.num_tokens != dst_layout.num_tokens: + raise ValueError(f"layouts cover different token counts: {src_layout.num_tokens} != {dst_layout.num_tokens}") + world_size = src_assignment.world_size + + src_ids = _rank_global_token_ids(src_layout, src_assignment) + dst_ids = _rank_global_token_ids(dst_layout, dst_assignment) + num_tokens = src_layout.num_tokens + + # global token id -> (source owner rank, local row) + src_owner = torch.full((num_tokens,), -1, dtype=torch.int64) + src_local_pos = torch.full((num_tokens,), -1, dtype=torch.int64) + for r, ids in enumerate(src_ids): + if ids.numel(): + src_owner[ids] = r + src_local_pos[ids] = torch.arange(ids.numel(), dtype=torch.int64) + if bool((src_owner < 0).any()): + raise ValueError("source assignment does not cover every token exactly once") + + counts = torch.zeros((world_size, world_size), dtype=torch.int64) # counts[src, dst] + send_rows: dict[tuple[int, int], torch.Tensor] = {} + dst_positions: dict[tuple[int, int], torch.Tensor] = {} + + for d in range(world_size): + z = dst_ids[d] + if z.numel(): + owners = src_owner[z] + if bool((owners < 0).any()): + raise ValueError(f"destination rank {d} requires tokens missing from the source layouts") + order = torch.argsort(owners, stable=True) + offsets = torch.cumsum(torch.bincount(owners, minlength=world_size), dim=0) + else: + order = torch.empty(0, dtype=torch.int64) + offsets = torch.zeros(world_size, dtype=torch.int64) + + start = 0 + for r in range(world_size): + end = int(offsets[r]) + seg = order[start:end] + start = end + counts[r, d] = end - (int(offsets[r - 1]) if r else 0) + if seg.numel(): + send_rows[(r, d)] = src_local_pos[z[seg]] + dst_positions[(r, d)] = seg + else: + send_rows[(r, d)] = torch.empty(0, dtype=torch.int64) + dst_positions[(r, d)] = torch.empty(0, dtype=torch.int64) + + # Group consistency: computed from the global count matrix, so every rank + # reaches the same decision and no rank skips a collective alone. + off_diagonal = counts.clone() + off_diagonal.fill_diagonal_(0) + network_exchange_required = bool(int(off_diagonal.sum()) > 0) + + plans: list[WindowRedistributionPlan] = [] + for rank in range(world_size): + send_indices = torch.cat([send_rows[(rank, d)] for d in range(world_size)]) + received_dst_positions = torch.cat([dst_positions[(s, rank)] for s in range(world_size)]) + num_src_rows = int(src_ids[rank].numel()) + num_dst_rows = int(dst_ids[rank].numel()) + if send_indices.numel() != num_src_rows: + raise ValueError(f"rank {rank} routes {send_indices.numel()} rows but owns {num_src_rows} source rows") + if received_dst_positions.numel() != num_dst_rows: + raise ValueError( + f"rank {rank} receives {received_dst_positions.numel()} rows but needs {num_dst_rows} destination rows" + ) + if num_dst_rows and not torch.equal( + torch.sort(received_dst_positions).values, torch.arange(num_dst_rows, dtype=torch.int64) + ): + raise ValueError(f"rank {rank} received positions are not a permutation of its destination rows") + recv_to_dst_indices = torch.argsort(received_dst_positions) + + input_split_sizes = tuple(int(v) for v in counts[rank].tolist()) + output_split_sizes = tuple(int(v) for v in counts[:, rank].tolist()) + if sum(input_split_sizes) != num_src_rows or sum(output_split_sizes) != num_dst_rows: + raise ValueError( + f"rank {rank} split sizes are inconsistent with its row counts " + f"(send {sum(input_split_sizes)}/{num_src_rows}, recv {sum(output_split_sizes)}/{num_dst_rows})" + ) + + plans.append( + WindowRedistributionPlan( + src_key=src_layout.key, + dst_key=dst_layout.key, + world_size=world_size, + rank=rank, + input_split_sizes=input_split_sizes, + output_split_sizes=output_split_sizes, + send_indices=send_indices, + recv_to_dst_indices=recv_to_dst_indices, + network_exchange_required=network_exchange_required, + planner_version=planner_version, + ) + ) + return tuple(plans) + + +# ============================================================================= +# Cache +# ============================================================================= + + +class WindowPlanCache: + """Small LRU cache for layouts, assignments, rank plans and routes. + + The assignment / routing identity includes the SP world size, the planner + version and the geometry fingerprint, so a change in any of them cannot hit + a stale entry. Runtime (device) objects are intentionally *not* cached + here: they are owned by the caller and must be released when the process + group or device changes. + """ + + def __init__(self, capacity: int = 32) -> None: + if capacity < 1: + raise ValueError(f"capacity must be >= 1, got {capacity}") + self._capacity = capacity + self._layouts: OrderedDict[WindowLayoutKey, WindowLayout] = OrderedDict() + self._assignments: OrderedDict[tuple, WindowAssignment] = OrderedDict() + self._rank_plans: OrderedDict[tuple, RankWindowPlan] = OrderedDict() + self._routes: OrderedDict[tuple, tuple[WindowRedistributionPlan, ...]] = OrderedDict() + self.hits = 0 + self.misses = 0 + + def _touch(self, cache: OrderedDict, key): + value = cache.pop(key) + cache[key] = value + return value + + def _store(self, cache: OrderedDict, key, value): + cache[key] = value + while len(cache) > self._capacity: + cache.popitem(last=False) + return value + + def clear(self) -> None: + self._layouts.clear() + self._assignments.clear() + self._rank_plans.clear() + self._routes.clear() + + def layout(self, token_grid, window_method, window=DEFAULT_WINDOW, geometry_version=GEOMETRY_VERSION): + key = (tuple(token_grid), window_method, tuple(window), int(geometry_version)) + if key in self._layouts: + self.hits += 1 + return self._touch(self._layouts, key) + self.misses += 1 + layout = build_window_layout(token_grid, window_method, window=window, geometry_version=geometry_version) + return self._store(self._layouts, key, layout) + + def assignment(self, layout: WindowLayout, world_size: int, planner_version: int = PLANNER_VERSION): + key = (layout.key, int(world_size), int(planner_version)) + if key in self._assignments: + self.hits += 1 + return self._touch(self._assignments, key) + self.misses += 1 + return self._store( + self._assignments, key, build_window_assignment(layout, world_size, planner_version=planner_version) + ) + + def rank_plan(self, layout: WindowLayout, assignment: WindowAssignment, rank: int): + key = ( + layout.key, + assignment.layout_key, + int(assignment.world_size), + int(assignment.planner_version), + int(rank), + ) + if key in self._rank_plans: + self.hits += 1 + return self._touch(self._rank_plans, key) + self.misses += 1 + return self._store(self._rank_plans, key, build_rank_window_plan(layout, assignment, rank)) + + def route( + self, + src_layout: WindowLayout, + src_assignment: WindowAssignment, + dst_layout: WindowLayout, + dst_assignment: WindowAssignment, + ): + key = ( + src_layout.key, + src_assignment.world_size, + src_assignment.planner_version, + dst_layout.key, + dst_assignment.world_size, + dst_assignment.planner_version, + ) + if key in self._routes: + self.hits += 1 + return self._touch(self._routes, key) + self.misses += 1 + plans = build_redistribution_plans(src_layout, src_assignment, dst_layout, dst_assignment) + return self._store(self._routes, key, plans) + + +# ============================================================================= +# Runtime (device) +# ============================================================================= + + +def materialize_redistribution_plan( + plan: WindowRedistributionPlan, *, device: torch.device | str +) -> DeviceWindowRedistributionPlan: + """Move one CPU redistribution plan to the device (index tensors only).""" + device = torch.device(device) + return DeviceWindowRedistributionPlan( + send_indices=plan.send_indices.to(device=device, dtype=torch.int64, non_blocking=True), + recv_to_dst_indices=plan.recv_to_dst_indices.to(device=device, dtype=torch.int64, non_blocking=True), + input_split_sizes=plan.input_split_sizes, + output_split_sizes=plan.output_split_sizes, + num_recv_rows=plan.num_recv_rows, + network_exchange_required=plan.network_exchange_required, + ) + + +def redistribute_window_rows( + hidden: torch.Tensor, + plan: DeviceWindowRedistributionPlan, + *, + group: dist.ProcessGroup, +) -> torch.Tensor: + """Re-shard local video rows from one window layout to another (Plan A). + + ``hidden`` is ``[N_src, C]`` (contiguous trailing feature dimension) in the + source layout's rank-local row order; the result is ``[N_dst, C]`` in the + destination layout's rank-local row order. Only row placement and order + change -- values are moved bit-exactly. + + The first version is deliberately synchronous: no extra streams, no + ``cuda.synchronize()`` / barrier "fixups", and no hidden catch-up all-gather. + """ + if hidden.dim() != 2: + raise ValueError(f"hidden must be 2D [rows, features], got shape {tuple(hidden.shape)}") + expected_src = sum(plan.input_split_sizes) + if hidden.shape[0] != expected_src: + raise ValueError(f"hidden has {hidden.shape[0]} rows but the plan sends {expected_src}") + + if not plan.network_exchange_required: + # No rank crosses a boundary: every token stays local, only the packed + # order changes (e.g. SP = 1, or identical layouts). + if plan.send_indices.numel() == hidden.shape[0]: + return hidden.index_select(0, plan.send_indices) + return hidden + + send = hidden.index_select(0, plan.send_indices).contiguous() + recv = torch.empty( + (plan.num_recv_rows, hidden.shape[1]), + dtype=hidden.dtype, + device=hidden.device, + ) + dist.all_to_all_single( + recv, + send, + output_split_sizes=list(plan.output_split_sizes), + input_split_sizes=list(plan.input_split_sizes), + group=group, + async_op=False, + ) + if plan.recv_to_dst_indices.numel() == plan.num_recv_rows: + return recv.index_select(0, plan.recv_to_dst_indices) + return recv + + +def reduction_dtype(dtype: torch.dtype) -> torch.dtype: + """Accumulator dtype for a window reduction (fp16/bf16 reduce in fp32).""" + return torch.float32 if dtype in (torch.float16, torch.bfloat16) else dtype + + +def global_window_mean( + local_window_sum: torch.Tensor, + global_window_count: int, + *, + group: dist.ProcessGroup | None = None, + dtype: torch.dtype | None = None, +) -> torch.Tensor: + """Reference text reduction: ``(sum over all windows) / global_window_count``. + + Every rank contributes the *sum* of its own windows' text attention output + (a zero tensor on ranks without windows) and all ranks divide by the global + window count. This is neither a token-weighted mean nor a mean of rank + means. + + ``group=None`` means *local* reduction: no collective is issued, and ``None`` + is never forwarded to ``all_reduce`` (which would silently use the default + process group). fp16/bf16 inputs are accumulated in fp32, fp32/fp64 keep + their own precision, the caller's tensor is not modified, and ``dtype`` is + applied only after the reduction and the division. + """ + if global_window_count <= 0: + raise ValueError(f"global_window_count must be positive, got {global_window_count}") + accumulate = reduction_dtype(local_window_sum.dtype) + if group is None: + total = local_window_sum.to(accumulate) + else: + total = local_window_sum.to(accumulate).clone() + dist.all_reduce(total, op=dist.ReduceOp.SUM, group=group) + total = total / float(global_window_count) + return total.to(dtype) if dtype is not None else total + + +def transition_count(methods: Sequence[str], num_layers: int | None = None) -> int: + """Number of layout transitions for a layer schedule (diagnostic only). + + The 3B schedule alternates, so every consecutive layer pair is a transition; + the entry distribution and the final reconstruction are counted separately + by the caller. + """ + schedule = [methods[i % len(methods)] for i in range(num_layers if num_layers is not None else len(methods))] + return sum(1 for a, b in zip(schedule, schedule[1:]) if a != b) + + +def schedule_methods(num_layers: int, methods: Iterable[str] = DEFAULT_WINDOW_METHODS) -> tuple[str, ...]: + methods = tuple(methods) + return tuple(methods[i % len(methods)] for i in range(num_layers)) + + +# Every key here carries the geometry fingerprint, the SP world size and the +# planner version, and nothing device-owned is stored, so one cache can serve +# every request in the process. Rebuilding it per request costs seconds on a +# large token grid, which is pure host time with the accelerators idle. +_SHARED_PLAN_CACHE = WindowPlanCache(capacity=64) + + +def shared_plan_cache() -> WindowPlanCache: + """The process-wide plan cache that requests reuse across identical geometry.""" + return _SHARED_PLAN_CACHE + + +class WindowLayoutManager: + """Drives ``ensure_layout`` across a model's per-layer window schedule. + + One manager per request / per model instance. It owns the device plans of + the current layout pair and is the only place that decides whether a + transition is needed and in which direction. The CPU plan cache is shared + process-wide, so a second request with the same geometry does not rebuild + layouts and routing plans that cost seconds on a large token grid. + """ + + def __init__( + self, + token_grid: tuple[int, int, int], + *, + group: dist.ProcessGroup | None, + world_size: int, + rank: int, + window: tuple[int, int, int] = DEFAULT_WINDOW, + methods: Sequence[str] = DEFAULT_WINDOW_METHODS, + num_layers: int = 32, + cache: WindowPlanCache | None = None, + planner_version: int = PLANNER_VERSION, + ) -> None: + if group is not None and world_size != dist.get_world_size(group): + raise ValueError( + f"world_size {world_size} does not match the window-SP group size {dist.get_world_size(group)}" + ) + if group is None and world_size != 1: + raise ValueError("a window-SP group is required when world_size > 1") + if not 0 <= rank < world_size: + raise ValueError(f"rank {rank} out of range for world_size {world_size}") + self.token_grid = (int(token_grid[0]), int(token_grid[1]), int(token_grid[2])) + self.group = group + self.world_size = int(world_size) + self.rank = int(rank) + self.window = tuple(window) + self.methods = tuple(methods) + self.num_layers = int(num_layers) + self.planner_version = int(planner_version) + self.cache = shared_plan_cache() if cache is None else cache + self.transitions = 0 + self._device: torch.device | None = None + self._device_plans: dict[tuple[WindowLayoutKey, WindowLayoutKey], DeviceWindowRedistributionPlan] = {} + + # -- layouts ----------------------------------------------------------- + def layer_method(self, layer_index: int) -> str: + return self.methods[layer_index % len(self.methods)] + + def layout(self, window_method: str) -> WindowLayout: + return self.cache.layout(self.token_grid, window_method, self.window) + + def layer_layout(self, layer_index: int) -> WindowLayout: + return self.layout(self.layer_method(layer_index)) + + def layout_for_key(self, key: WindowLayoutKey) -> WindowLayout: + return self.cache.layout(key.token_grid, key.window_method, key.window_shape) + + def assignment(self, layout: WindowLayout) -> WindowAssignment: + return self.cache.assignment(layout, self.world_size, self.planner_version) + + def rank_plan(self, layout: WindowLayout, assignment: WindowAssignment | None = None) -> RankWindowPlan: + return self.rank_plan_for(layout, self.rank, assignment) + + def rank_plan_for( + self, layout: WindowLayout, rank: int, assignment: WindowAssignment | None = None + ) -> RankWindowPlan: + assignment = assignment or self.assignment(layout) + return self.cache.rank_plan(layout, assignment, rank) + + # -- transitions ------------------------------------------------------- + def device_plan(self, src_layout: WindowLayout, dst_layout: WindowLayout) -> DeviceWindowRedistributionPlan: + key = (src_layout.key, dst_layout.key) + plan = self._device_plans.get(key) + if plan is None: + src_assignment = self.assignment(src_layout) + dst_assignment = self.assignment(dst_layout) + routes = self.cache.route(src_layout, src_assignment, dst_layout, dst_assignment) + plan = materialize_redistribution_plan(routes[self.rank], device=self.device) + self._device_plans[key] = plan + return plan + + @property + def device(self) -> torch.device: + if self._device is None: + raise RuntimeError("WindowLayoutManager device is unset; call set_device() first") + return self._device + + def set_device(self, device: torch.device | str) -> None: + device = torch.device(device) + if self._device != device: + self._device = device + self._device_plans.clear() + + def ensure_layout( + self, + hidden: torch.Tensor, + src_layout_key: WindowLayoutKey, + dst_layout_key: WindowLayoutKey, + ) -> torch.Tensor: + """Return ``hidden`` re-sharded into the destination layout (or as-is).""" + if src_layout_key == dst_layout_key: + return hidden + self.set_device(hidden.device) + src_layout = self.cache.layout( + src_layout_key.token_grid, src_layout_key.window_method, src_layout_key.window_shape + ) + dst_layout = self.cache.layout( + dst_layout_key.token_grid, dst_layout_key.window_method, dst_layout_key.window_shape + ) + plan = self.device_plan(src_layout, dst_layout) + if not plan.network_exchange_required and plan.send_indices.numel() == hidden.shape[0]: + if torch.equal(plan.send_indices, torch.arange(hidden.shape[0], device=hidden.device)): + return hidden + self.transitions += 1 + return redistribute_window_rows(hidden, plan, group=self.group) + + +@dataclass +class LocalWindowContext: + """Everything one rank needs to run local window attention for one layout.""" + + layout_key: WindowLayoutKey + window_shapes: torch.Tensor # int64 [W_r, 3] + video_cu_seqlens: torch.Tensor # int32 [W_r + 1] + joint_cu_seqlens: torch.Tensor # int32 [W_r + 1] + joint_order: torch.Tensor # int64 [N_r + W_r * L] + vid_src: torch.Tensor # int64 [N_r] (positions in the attention output) + txt_src: torch.Tensor # int64 [W_r * L] (positions in the attention output) + text_len: int + local_windows: int + global_windows: int + + @property + def num_video_tokens(self) -> int: + return int(self.vid_src.numel()) + + @property + def joint_len(self) -> int: + return int(self.joint_order.numel()) + + @property + def joint_lengths(self) -> torch.Tensor: + return (self.joint_cu_seqlens[1:] - self.joint_cu_seqlens[:-1]).to(torch.int64) + + @property + def max_joint_len(self) -> int: + return int(self.joint_lengths.max()) if self.local_windows else 0 + + +def build_local_window_context( + rank_plan: RankWindowPlan, + *, + text_len: int, + global_windows: int, + device: torch.device | str, +) -> LocalWindowContext: + """Build the device-local attention metadata for one rank and one layout.""" + device = torch.device(device) + window_shapes = rank_plan.window_shapes.to(device=device, dtype=torch.int64, non_blocking=True) + video_cu_seqlens = rank_plan.video_cu_seqlens.to(device=device, non_blocking=True) + num_windows = int(window_shapes.shape[0]) + num_video = int(rank_plan.global_token_ids.numel()) + + if num_windows: + lengths = (video_cu_seqlens[1:] - video_cu_seqlens[:-1]).to(torch.int64) + # The joint offsets come from the shared helper; the packing and unpacking + # indices below are derived from exactly the same values. + joint_cu = joint_cu_seqlens(video_cu_seqlens, text_len) + window_joint_lengths = lengths + int(text_len) + joint_offsets = torch.cumsum(window_joint_lengths, dim=0) + base = joint_offsets - window_joint_lengths + joint_pieces = [] + vid_pieces = [] + txt_pieces = [] + text_range = torch.arange(int(text_len), dtype=torch.int64, device=device) + for index in range(num_windows): + start = int(video_cu_seqlens[index]) + count = int(lengths[index]) + window_base = int(base[index]) + text_base = window_base + count + joint_pieces.append(torch.arange(start, start + count, dtype=torch.int64, device=device)) + joint_pieces.append(torch.arange(num_video, num_video + int(text_len), dtype=torch.int64, device=device)) + vid_pieces.append(torch.arange(window_base, window_base + count, dtype=torch.int64, device=device)) + txt_pieces.append(text_range + text_base) + joint_order = torch.cat(joint_pieces) + vid_src = torch.cat(vid_pieces) if vid_pieces else torch.empty(0, dtype=torch.int64, device=device) + txt_src = torch.cat(txt_pieces) if txt_pieces else torch.empty(0, dtype=torch.int64, device=device) + else: + # Empty rank: the same helper must produce a device-local int32 ``[0]``. + joint_cu = joint_cu_seqlens(video_cu_seqlens, text_len) + joint_order = torch.empty(0, dtype=torch.int64, device=device) + vid_src = torch.empty(0, dtype=torch.int64, device=device) + txt_src = torch.empty(0, dtype=torch.int64, device=device) + + return LocalWindowContext( + layout_key=rank_plan.layout_key, + window_shapes=window_shapes, + video_cu_seqlens=video_cu_seqlens, + joint_cu_seqlens=joint_cu, + joint_order=joint_order, + vid_src=vid_src, + txt_src=txt_src, + text_len=int(text_len), + local_windows=num_windows, + global_windows=int(global_windows), + ) + + +def pack_joint_windows(video: torch.Tensor, text: torch.Tensor, ctx: LocalWindowContext) -> torch.Tensor: + """Interleave ``[video window, replicated text]`` pairs for varlen attention.""" + if ctx.local_windows == 0: + return torch.empty((0,) + video.shape[1:], dtype=video.dtype, device=video.device) + joined = torch.cat([video, text], dim=0) + return joined.index_select(0, ctx.joint_order) + + +def unpack_joint_windows(joint_out: torch.Tensor, ctx: LocalWindowContext) -> tuple[torch.Tensor, torch.Tensor]: + """Split the attention output into packed video rows and per-window text rows.""" + if ctx.local_windows == 0: + empty_video = torch.empty((0,) + joint_out.shape[1:], dtype=joint_out.dtype, device=joint_out.device) + empty_text = torch.empty((0,) + joint_out.shape[1:], dtype=joint_out.dtype, device=joint_out.device) + return empty_video, empty_text + video_out = joint_out.index_select(0, ctx.vid_src) + text_out = joint_out.index_select(0, ctx.txt_src) + return video_out, text_out.view(ctx.local_windows, ctx.text_len, *joint_out.shape[1:]) + + +class SeedVR2WindowRuntime: + """Owns the window-SP plan for one request and drives the layer schedule.""" + + def __init__( + self, + token_grid: tuple[int, int, int], + *, + text_len: int, + group: dist.ProcessGroup | None = None, + world_size: int = 1, + rank: int = 0, + window: tuple[int, int, int] = DEFAULT_WINDOW, + methods: tuple[str, ...] = DEFAULT_WINDOW_METHODS, + num_layers: int = 32, + planner_version: int = PLANNER_VERSION, + ) -> None: + self.text_len = int(text_len) + self.world_size = int(world_size) + self.rank = int(rank) + self.group = group + self.num_layers = int(num_layers) + self.manager = WindowLayoutManager( + token_grid, + group=group, + world_size=self.world_size, + rank=self.rank, + window=window, + methods=methods, + num_layers=num_layers, + planner_version=planner_version, + ) + self._contexts: dict[WindowLayoutKey, LocalWindowContext] = {} + self.stats: dict[str, int] = { + "layout_transitions": 0, + "network_transitions": 0, + "local_reorders": 0, + "remote_video_rows": 0, + "text_all_reduces": 0, + "fused_text_mean_calls": 0, + } + + # -- layouts and contexts --------------------------------------------- + def layout_for_layer(self, layer_index: int) -> WindowLayout: + return self.manager.layer_layout(layer_index) + + def context(self, layout: WindowLayout, device: torch.device | str) -> LocalWindowContext: + ctx = self._contexts.get(layout.key) + if ctx is None or ctx.joint_order.device != torch.device(device): + rank_plan = self.manager.rank_plan(layout) + ctx = build_local_window_context( + rank_plan, + text_len=self.text_len, + global_windows=layout.num_windows, + device=device, + ) + self._contexts[layout.key] = ctx + return ctx + + # -- token placement --------------------------------------------------- + def local_rows_for(self, canonical_rows: torch.Tensor, layout: WindowLayout) -> torch.Tensor: + """Select this rank's rows of a canonical ``[N, C]`` tensor.""" + rank_plan = self.manager.rank_plan(layout) + ids = rank_plan.global_token_ids.to(canonical_rows.device) + return canonical_rows.index_select(0, ids) + + def to_canonical_rows(self, local_rows: torch.Tensor, layout: WindowLayout) -> torch.Tensor: + """Gather every rank's rows back into canonical token order (all ranks).""" + rank_plan = self.manager.rank_plan(layout) + local_ids = rank_plan.global_token_ids + num_tokens = layout.num_tokens + if self.world_size == 1 or self.group is None: + out = torch.empty((num_tokens,) + local_rows.shape[1:], dtype=local_rows.dtype, device=local_rows.device) + return out.index_copy(0, local_ids.to(local_rows.device), local_rows) + + counts = torch.tensor([int(local_ids.numel())], dtype=torch.int64, device=local_rows.device) + all_counts = [torch.zeros_like(counts) for _ in range(self.world_size)] + dist.all_gather(all_counts, counts, group=self.group) + sizes = [int(c) for c in all_counts] + max_rows = max(sizes) if sizes else 0 + padded = torch.zeros((max_rows,) + local_rows.shape[1:], dtype=local_rows.dtype, device=local_rows.device) + if local_rows.shape[0]: + padded[: local_rows.shape[0]] = local_rows + gathered = [torch.empty_like(padded) for _ in range(self.world_size)] + dist.all_gather(gathered, padded, group=self.group) + + out = torch.empty((num_tokens,) + local_rows.shape[1:], dtype=local_rows.dtype, device=local_rows.device) + for source, rows in enumerate(gathered): + if sizes[source] == 0: + continue + ids = self.manager.rank_plan_for(layout, source).global_token_ids.to(local_rows.device) + out.index_copy_(0, ids, rows[: sizes[source]]) + return out + + # -- transitions ------------------------------------------------------- + def ensure_layout(self, hidden: torch.Tensor, current: WindowLayoutKey, required: WindowLayoutKey) -> torch.Tensor: + if current == required: + return hidden + self.manager.set_device(hidden.device) + src_layout = self.manager.layout_for_key(current) + dst_layout = self.manager.layout_for_key(required) + plan = self.manager.device_plan(src_layout, dst_layout) + self.stats["layout_transitions"] += 1 + if plan.network_exchange_required: + self.stats["network_transitions"] += 1 + self.stats["remote_video_rows"] += sum( + count for index, count in enumerate(plan.input_split_sizes) if index != self.rank + ) + else: + self.stats["local_reorders"] += 1 + return self.manager.ensure_layout(hidden, current, required) + + def remote_video_bytes(self, hidden_channels: int, element_size: int) -> int: + """Logical source->destination payload of the transitions seen so far.""" + return self.stats["remote_video_rows"] * int(hidden_channels) * int(element_size) + + @property + def transitions(self) -> int: + return self.manager.transitions + + # -- text reduction ---------------------------------------------------- + def collective_group(self) -> dist.ProcessGroup | None: + """Group for the text reduction, or ``None`` for a local mean. + + ``world_size == 1`` is the local case. A multi-rank runtime without a + usable group is rejected rather than silently degrading to a rank-local + mean, which would produce a different text state. + """ + if self.world_size == 1: + return None + if self.group is None: + raise RuntimeError("window-SP with world_size > 1 requires a process group for the text reduction") + if dist.get_world_size(self.group) != self.world_size: + raise RuntimeError( + f"window-SP group size {dist.get_world_size(self.group)} does not match world_size {self.world_size}" + ) + return self.group + + def reduce_text(self, local_window_sum: torch.Tensor, global_windows: int) -> torch.Tensor: + """Global window mean of the per-window text attention outputs. + + Shape adaptation and the runtime statistics live here; the arithmetic and + the collective live in :func:`global_window_mean`, which the tests and the + single-rank attention path use as well. + """ + if local_window_sum.numel() == 0: + local_window_sum = torch.zeros( + (self.text_len,) + local_window_sum.shape[1:], + dtype=local_window_sum.dtype, + device=local_window_sum.device, + ) + group = self.collective_group() + reduced = global_window_mean(local_window_sum, global_windows, group=group) + if group is None: + self.stats["fused_text_mean_calls"] += 1 + else: + self.stats["text_all_reduces"] += 1 + return reduced + + def current_device(self) -> torch.device | None: + return self.manager._device # noqa: SLF001 - single internal owner + + +# Ported from the Apache-2.0 SeedVR2 reference implementation: +# https://github.com/ByteDance-Seed/SeedVR (models/dit_v2) +# https://github.com/numz/ComfyUI-SeedVR2_VideoUpscaler (src/models/dit_3b) + + +logger = init_logger(__name__) + +# Reference checkpoint hyper-parameters for the released 3B model. +SEEDVR2_3B_CONFIG: dict = { + "vid_in_channels": 33, + "vid_out_channels": 16, + "vid_dim": 2560, + "txt_in_dim": 5120, + "heads": 20, + "head_dim": 128, + "expand_ratio": 4, + "norm_eps": 1e-5, + "patch_size": (1, 2, 2), + "num_layers": 32, + "mm_layers": 10, + "window": DEFAULT_WINDOW, + "window_method": DEFAULT_WINDOW_METHODS, + "rope_dim": 128, + "vid_out_norm": True, +} + + +# ============================================================================= +# Reference-faithful building blocks +# ============================================================================= + + +class MMArg: + """Pair of per-stream values (video / text) used by :class:`MMModule`.""" + + __slots__ = ("vid", "txt") + + def __init__(self, vid, txt) -> None: + self.vid = vid + self.txt = txt + + +class MMModule(nn.Module): + """Apply a module to the video and text streams, optionally sharing weights. + + ``shared_weights=True`` builds a single submodule used for both streams + (the reference's ``all`` prefix in the checkpoint); ``vid_only=True`` drops + the text branch entirely. + """ + + def __init__(self, factory, dims: MMArg, *, shared_weights: bool = False, vid_only: bool = False) -> None: + super().__init__() + self.shared_weights = shared_weights + self.vid_only = vid_only + if shared_weights: + self.all = factory(dims.vid) + else: + self.vid = factory(dims.vid) + self.txt = None if vid_only else factory(dims.txt) + + def forward(self, vid: torch.Tensor, txt: torch.Tensor | None, *args, **kwargs): + vid_module = self.all if self.shared_weights else self.vid + vid = vid_module(vid, *args, **kwargs) + if not self.vid_only and txt is not None: + txt_module = self.all if self.shared_weights else self.txt + txt = txt_module(txt, *args, **kwargs) + return vid, txt + + +class RMSNorm(nn.Module): + """Reference ``CustomRMSNorm``: normalise in the input dtype, optional affine.""" + + def __init__(self, dim: int, eps: float = 1e-5, elementwise_affine: bool = True) -> None: + super().__init__() + self.eps = float(eps) + if elementwise_affine: + self.weight = nn.Parameter(torch.ones(dim)) + else: + self.register_parameter("weight", None) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + variance = x.pow(2).mean(dim=-1, keepdim=True) + x = x / torch.sqrt(variance + self.eps) + if self.weight is not None: + x = x * self.weight.to(x.dtype) + return x + + +class AdaSingle(nn.Module): + """Timestep modulation with per-layer shift / scale / gate parameters.""" + + def __init__(self, dim: int, emb_dim: int, layers: Sequence[str], modes: Sequence[str] = ("in", "out")) -> None: + super().__init__() + if emb_dim != 6 * dim: + raise ValueError(f"AdaSingle requires emb_dim == 6 * dim, got {emb_dim} != {6 * dim}") + self.dim = int(dim) + self.emb_dim = int(emb_dim) + self.layers = list(layers) + modes = set(modes) + for layer in self.layers: + if "in" in modes: + self.register_parameter(f"{layer}_shift", nn.Parameter(torch.randn(dim) / dim**0.5)) + self.register_parameter(f"{layer}_scale", nn.Parameter(torch.randn(dim) / dim**0.5 + 1)) + if "out" in modes: + self.register_parameter(f"{layer}_gate", nn.Parameter(torch.randn(dim) / dim**0.5)) + + def slice(self, emb: torch.Tensor, layer: str) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """``(shift, scale, gate)`` embedding slices for ``layer``, each ``[1, dim]``.""" + view = emb.view(emb.shape[0], self.dim, len(self.layers), 3)[..., self.layers.index(layer), :] + shift, scale, gate = view.unbind(-1) + return shift, scale, gate + + def forward(self, hid: torch.Tensor, emb: torch.Tensor, layer: str, mode: str) -> torch.Tensor: + shift_a, scale_a, gate_a = self.slice(emb, layer) + shift_a = shift_a.to(hid.dtype) + scale_a = scale_a.to(hid.dtype) + gate_a = gate_a.to(hid.dtype) + if mode == "in": + return hid * (scale_a + getattr(self, f"{layer}_scale")) + (shift_a + getattr(self, f"{layer}_shift")) + if mode == "out": + gate_b = getattr(self, f"{layer}_gate", None) + return hid * (gate_a + gate_b if gate_b is not None else gate_a) + raise NotImplementedError(mode) + + +class OutAda(nn.Module): + """The released ``vid_out_ada`` parameters (see the module docstring).""" + + def __init__(self, dim: int) -> None: + super().__init__() + self.out_shift = nn.Parameter(torch.zeros(dim)) + self.out_scale = nn.Parameter(torch.ones(dim)) + + +class TimeEmbedding(nn.Module): + """Sinusoidal timestep embedding followed by a two-layer SiLU MLP.""" + + def __init__(self, sinusoidal_dim: int, hidden_dim: int, output_dim: int) -> None: + super().__init__() + self.sinusoidal_dim = int(sinusoidal_dim) + self.proj_in = nn.Linear(sinusoidal_dim, hidden_dim) + self.proj_hid = nn.Linear(hidden_dim, hidden_dim) + self.proj_out = nn.Linear(hidden_dim, output_dim) + self.act = nn.SiLU() + + @staticmethod + def _sinusoidal(timesteps: torch.Tensor, dim: int) -> torch.Tensor: + half = dim // 2 + exponent = -math.log(10000.0) * torch.arange(half, device=timesteps.device, dtype=torch.float32) / half + emb = timesteps.float().unsqueeze(1) * torch.exp(exponent).unsqueeze(0) + emb = torch.cat([torch.sin(emb), torch.cos(emb)], dim=-1) + if dim % 2 == 1: + emb = F.pad(emb, (0, 1)) + return emb + + def forward(self, timestep, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + if not torch.is_tensor(timestep): + timestep = torch.tensor([timestep], device=device, dtype=dtype) + timestep = timestep.to(device=device) + if timestep.ndim == 0: + timestep = timestep[None] + emb = self._sinusoidal(timestep, self.sinusoidal_dim).to(dtype) + emb = self.proj_in(emb) + emb = self.act(emb) + emb = self.proj_hid(emb) + emb = self.act(emb) + return self.proj_out(emb) + + +class SwiGLUMLP(nn.Module): + """Reference SwiGLU MLP (``multiple_of=256``, no biases).""" + + def __init__(self, dim: int, expand_ratio: int, multiple_of: int = 256) -> None: + super().__init__() + hidden = int(2 * dim * expand_ratio / 3) + hidden = multiple_of * ((hidden + multiple_of - 1) // multiple_of) + self.proj_in_gate = nn.Linear(dim, hidden, bias=False) + self.proj_in = nn.Linear(dim, hidden, bias=False) + self.proj_out = nn.Linear(hidden, dim, bias=False) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.proj_out(F.silu(self.proj_in_gate(x)) * self.proj_in(x)) + + +# ============================================================================= +# Window attention +# ============================================================================= + + +def grouped_window_sdpa( + q: torch.Tensor, + k: torch.Tensor, + v: torch.Tensor, + ctx: LocalWindowContext, + *, + softmax_scale: float, +) -> torch.Tensor: + """Exact window-local attention without a varlen kernel. + + Windows are packed back-to-back in the joint sequence, so this groups the + equal-length windows and runs one batched SDPA per group (ragged window + sizes keep the group count small). It is the portable path used when the + selected backend has no packed-varlen entry point. + """ + if ctx.local_windows == 0: + return torch.empty_like(q) + lengths = ctx.joint_lengths + offsets = ctx.joint_cu_seqlens.to(torch.int64) + out = torch.empty_like(q) + for length in torch.unique(lengths).tolist(): + window_ids = torch.nonzero(lengths == length, as_tuple=False).flatten().tolist() + rows = torch.cat( + [torch.arange(int(offsets[i]), int(offsets[i]) + int(length), device=q.device) for i in window_ids] + ) + num = len(window_ids) + qq = q.index_select(0, rows).view(num, int(length), q.shape[1], q.shape[2]).transpose(1, 2) + kk = k.index_select(0, rows).view(num, int(length), k.shape[1], k.shape[2]).transpose(1, 2) + vv = v.index_select(0, rows).view(num, int(length), v.shape[1], v.shape[2]).transpose(1, 2) + attended = F.scaled_dot_product_attention(qq, kk, vv, scale=softmax_scale) + out.index_copy_(0, rows, attended.transpose(1, 2).reshape(-1, q.shape[1], q.shape[2])) + return out + + +class NaSwinAttention(nn.Module): + """Joint video+text window attention with a globally averaged text output.""" + + def __init__( + self, + *, + vid_dim: int, + txt_dim: int, + heads: int, + head_dim: int, + qk_bias: bool, + qk_norm_eps: float, + rope_dim: int, + shared_weights: bool, + use_varlen_kernel: bool = True, + ) -> None: + super().__init__() + inner_dim = heads * head_dim + self.heads = int(heads) + self.head_dim = int(head_dim) + self.inner_dim = int(inner_dim) + self.softmax_scale = 1.0 / math.sqrt(head_dim) + dims = MMArg(vid_dim, txt_dim) + self.proj_qkv = MMModule( + lambda d: nn.Linear(int(d), 3 * inner_dim, bias=qk_bias), dims, shared_weights=shared_weights + ) + self.proj_out = MMModule(lambda d: nn.Linear(inner_dim, int(d)), dims, shared_weights=shared_weights) + self.norm_q = MMModule( + lambda d: RMSNorm(int(d), eps=qk_norm_eps, elementwise_affine=True), + MMArg(head_dim, head_dim), + shared_weights=shared_weights, + ) + self.norm_k = MMModule( + lambda d: RMSNorm(int(d), eps=qk_norm_eps, elementwise_affine=True), + MMArg(head_dim, head_dim), + shared_weights=shared_weights, + ) + self.rope = NaMMRotaryEmbedding3d(rotary_dim=rope_dim, num_axes=3) + # Window-local attention is communication free by construction, so the + # shared layer must never re-shard it through a parallel strategy. + self.attention = Attention( + num_heads=heads, + head_size=head_dim, + causal=False, + softmax_scale=self.softmax_scale, + role="self", + skip_sequence_parallel=True, + ) + # ``use_varlen_kernel`` is a request, not a capability proof: a backend + # that ignores ``cu_seqlens`` would attend across every local window + # (and the replicated text stream), which is silently wrong and differs + # by SP degree. Resolve the request against the selected backend once. + requested_varlen = bool(use_varlen_kernel) + backend = self.attention.attn_backend + supports_varlen = bool(backend is not None and backend.supports_multi_doc_packed_varlen()) + self.use_varlen_kernel = requested_varlen and supports_varlen + self.attention_backend_name = backend.get_name() if backend is not None else None + self.attention_path = "packed_varlen" if self.use_varlen_kernel else "grouped_sdpa" + self.varlen_fallback_reason: str | None = None + if requested_varlen and not supports_varlen: + backend_name = self.attention_backend_name or "custom_attention" + # Stable message without a layer id: 32 layers must warn once. + self.varlen_fallback_reason = f"backend {backend_name} does not support multi-document packed varlen" + logger.warning_once( + "SeedVR2: attention backend %s does not support multi-document packed varlen; " + "using grouped window SDPA.", + backend_name, + ) + self.attention_stats = {"packed_varlen_calls": 0, "grouped_sdpa_calls": 0, "no_local_windows_calls": 0} + + def _split_heads(self, qkv: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + return qkv.view(qkv.shape[0], 3, self.heads, self.head_dim).unbind(1) + + def _apply_rope(self, vid_q, vid_k, txt_q, txt_k, ctx: LocalWindowContext): + device = vid_q.device + vid_freqs = self.rope.window_freqs_batch(ctx.window_shapes, ctx.text_len, device=device, dtype=torch.float32) + txt_freqs = self.rope.text_freqs(ctx.text_len, device=device, dtype=torch.float32) + if vid_freqs.shape[0] != vid_q.shape[0]: + raise ValueError(f"window RoPE covers {vid_freqs.shape[0]} rows but the video shard has {vid_q.shape[0]}") + return self.rope(vid_q, vid_k, vid_freqs, txt_q, txt_k, txt_freqs) + + def forward( + self, + vid: torch.Tensor, + txt: torch.Tensor, + ctx: LocalWindowContext, + runtime: SeedVR2WindowRuntime | None, + ) -> tuple[torch.Tensor, torch.Tensor]: + vid_qkv, txt_qkv = self.proj_qkv(vid, txt) + vid_q, vid_k, vid_v = self._split_heads(vid_qkv) + txt_q, txt_k, txt_v = self._split_heads(txt_qkv) + + vid_q, txt_q = self.norm_q(vid_q, txt_q) + vid_k, txt_k = self.norm_k(vid_k, txt_k) + vid_q, vid_k, txt_q, txt_k = self._apply_rope(vid_q, vid_k, txt_q, txt_k, ctx) + + joint_q = pack_joint_windows(vid_q, txt_q, ctx) + joint_k = pack_joint_windows(vid_k, txt_k, ctx) + joint_v = pack_joint_windows(vid_v, txt_v, ctx) + + if not ctx.local_windows: + self.attention_stats["no_local_windows_calls"] += 1 + elif self.use_varlen_kernel: + self.attention_stats["packed_varlen_calls"] += 1 + else: + self.attention_stats["grouped_sdpa_calls"] += 1 + + if self.use_varlen_kernel and ctx.local_windows: + metadata = AttentionMetadata( + extra={ + "cu_seqlens_q": ctx.joint_cu_seqlens, + "cu_seqlens_k": ctx.joint_cu_seqlens, + "max_seqlen_q": ctx.max_joint_len, + "max_seqlen_k": ctx.max_joint_len, + } + ) + joint_out = self.attention( + joint_q.unsqueeze(0), joint_k.unsqueeze(0), joint_v.unsqueeze(0), metadata + ).squeeze(0) + else: + joint_out = grouped_window_sdpa(joint_q, joint_k, joint_v, ctx, softmax_scale=self.softmax_scale) + + vid_out, txt_windows = unpack_joint_windows(joint_out, ctx) + vid_out = vid_out.reshape(-1, self.inner_dim) + if ctx.local_windows: + local_text_sum = txt_windows.reshape(ctx.local_windows, ctx.text_len, self.inner_dim).sum(0) + else: + # Ranks without windows still join the text reduction; the dtype must + # match the other ranks' contribution exactly (a mismatch changes the + # collective's dtype and deadlocks NCCL). + local_text_sum = torch.zeros((ctx.text_len, self.inner_dim), device=vid.device, dtype=vid.dtype) + if runtime is not None: + txt_out = runtime.reduce_text(local_text_sum, ctx.global_windows).to(vid.dtype) + else: + # Same reduction as the runtime path, without a collective. + txt_out = global_window_mean(local_text_sum, ctx.global_windows, group=None, dtype=vid.dtype) + + return self.proj_out(vid_out, txt_out) + + +# ============================================================================= +# Transformer block +# ============================================================================= + + +class NaMMSRTransformerBlock(nn.Module): + """Reference ``NaMMSRTransformerBlock``: modulated attention + SwiGLU MLP.""" + + def __init__( + self, + *, + vid_dim: int, + txt_dim: int, + emb_dim: int, + heads: int, + head_dim: int, + expand_ratio: int, + norm_eps: float, + qk_bias: bool, + mlp_type: str, + shared_weights: bool, + rope_dim: int, + is_last_layer: bool, + use_varlen_kernel: bool = True, + ) -> None: + super().__init__() + if mlp_type != "swiglu": + raise NotImplementedError(f"unsupported mlp_type {mlp_type!r} for SeedVR2") + dims = MMArg(vid_dim, txt_dim) + self.attn_norm = MMModule( + lambda d: RMSNorm(int(d), eps=norm_eps, elementwise_affine=False), dims, shared_weights=shared_weights + ) + self.attn = NaSwinAttention( + vid_dim=vid_dim, + txt_dim=txt_dim, + heads=heads, + head_dim=head_dim, + qk_bias=qk_bias, + qk_norm_eps=norm_eps, + rope_dim=rope_dim, + shared_weights=shared_weights, + use_varlen_kernel=use_varlen_kernel, + ) + self.mlp_norm = MMModule( + lambda d: RMSNorm(int(d), eps=norm_eps, elementwise_affine=False), + dims, + shared_weights=shared_weights, + vid_only=is_last_layer, + ) + self.mlp = MMModule( + lambda d: SwiGLUMLP(int(d), expand_ratio), dims, shared_weights=shared_weights, vid_only=is_last_layer + ) + self.ada = MMModule( + lambda d: AdaSingle(int(d), emb_dim, layers=["attn", "mlp"]), + dims, + shared_weights=shared_weights, + vid_only=is_last_layer, + ) + self.is_last_layer = bool(is_last_layer) + + def _modulate( + self, vid: torch.Tensor, txt: torch.Tensor | None, emb: torch.Tensor, layer: str, mode: str + ) -> tuple[torch.Tensor, torch.Tensor | None]: + vid_ada = self.ada.all if self.ada.shared_weights else self.ada.vid + vid = vid_ada(vid, emb, layer, mode) + if txt is not None and not self.ada.vid_only: + txt_ada = self.ada.all if self.ada.shared_weights else self.ada.txt + txt = txt_ada(txt, emb, layer, mode) + return vid, txt + + def forward( + self, + vid: torch.Tensor, + txt: torch.Tensor, + emb: torch.Tensor, + ctx: LocalWindowContext, + runtime: SeedVR2WindowRuntime | None, + ) -> tuple[torch.Tensor, torch.Tensor]: + vid_norm, txt_norm = self.attn_norm(vid, txt) + vid_norm, txt_norm = self._modulate(vid_norm, txt_norm, emb, "attn", "in") + vid_attn, txt_attn = self.attn(vid_norm, txt_norm, ctx, runtime) + vid_attn, txt_attn = self._modulate(vid_attn, txt_attn, emb, "attn", "out") + vid_attn = vid_attn + vid + txt_attn = txt_attn + txt + + vid_mlp, txt_mlp = self.mlp_norm(vid_attn, txt_attn) + vid_mlp, txt_mlp = self._modulate(vid_mlp, txt_mlp, emb, "mlp", "in") + vid_mlp, txt_mlp = self.mlp(vid_mlp, txt_mlp) + vid_mlp, txt_mlp = self._modulate(vid_mlp, txt_mlp, emb, "mlp", "out") + return vid_mlp + vid_attn, txt_mlp + txt_attn + + +# ============================================================================= +# NaDiT +# ============================================================================= + + +class NaDiTOutput: + """Container matching the reference ``NaDiTOutput``.""" + + def __init__(self, vid_sample: torch.Tensor) -> None: + self.vid_sample = vid_sample + + +class SeedVR2NaDiT(nn.Module): + """SeedVR2 3B ``NaDiT`` transformer with window-aligned SP support.""" + + def __init__( + self, + *, + vid_in_channels: int = 33, + vid_out_channels: int = 16, + vid_dim: int = 2560, + txt_in_dim: int = 5120, + emb_dim: int | None = None, + heads: int = 20, + head_dim: int = 128, + expand_ratio: int = 4, + norm_eps: float = 1e-5, + patch_size: Sequence[int] = (1, 2, 2), + num_layers: int = 32, + mm_layers: int = 10, + mlp_type: str = "swiglu", + window: Sequence[int] = DEFAULT_WINDOW, + window_method: Sequence[str] = DEFAULT_WINDOW_METHODS, + rope_type: str = "mmrope3d", + rope_dim: int = 128, + vid_out_norm: bool = True, + use_varlen_kernel: bool = True, + ) -> None: + super().__init__() + if rope_type != "mmrope3d": + raise NotImplementedError(f"unsupported rope_type {rope_type!r} for SeedVR2") + txt_dim = vid_dim + emb_dim = emb_dim or 6 * vid_dim + self.patch_size = tuple(int(v) for v in patch_size) + if self.patch_size[0] != 1: + raise NotImplementedError("only the released temporal patch size 1 is supported") + self.vid_dim = int(vid_dim) + self.txt_dim = int(txt_dim) + self.num_layers = int(num_layers) + self.window = tuple(int(v) for v in window) + self.window_method = tuple(window_method) + + self.vid_in = _NaPatchIn(in_channels=vid_in_channels, patch_size=self.patch_size, dim=vid_dim) + self.txt_in = nn.Linear(txt_in_dim, txt_dim) if txt_in_dim != txt_dim else nn.Identity() + self.emb_in = TimeEmbedding(sinusoidal_dim=256, hidden_dim=max(vid_dim, txt_dim), output_dim=emb_dim) + + self.blocks = nn.ModuleList( + [ + NaMMSRTransformerBlock( + vid_dim=vid_dim, + txt_dim=txt_dim, + emb_dim=emb_dim, + heads=heads, + head_dim=head_dim, + expand_ratio=expand_ratio, + norm_eps=norm_eps, + qk_bias=False, + mlp_type=mlp_type, + shared_weights=not (index < mm_layers), + rope_dim=rope_dim, + is_last_layer=(index == num_layers - 1), + use_varlen_kernel=use_varlen_kernel, + ) + for index in range(num_layers) + ] + ) + + self.vid_out_norm = RMSNorm(vid_dim, eps=norm_eps, elementwise_affine=True) if vid_out_norm else None + self.vid_out_ada = OutAda(vid_dim) + self.vid_out = _NaPatchOut(out_channels=vid_out_channels, patch_size=self.patch_size, dim=vid_dim) + + # -- runtime ----------------------------------------------------------- + def build_runtime( + self, + token_grid: tuple[int, int, int], + *, + text_len: int, + group=None, + world_size: int = 1, + rank: int = 0, + parallel_config: DiffusionParallelConfig | None = None, + ) -> SeedVR2WindowRuntime: + """Create the window-SP driver for one request (SP=1 included).""" + if parallel_config is not None: + validate_seedvr2_parallel_config(parallel_config) + sp = get_sp_group() + if sp.world_size != parallel_config.sequence_parallel_size: + raise ValueError("SeedVR2 SP group size does not match its parallel configuration") + group, world_size, rank = sp.device_group, sp.world_size, sp.rank_in_group + return SeedVR2WindowRuntime( + token_grid, + text_len=text_len, + group=group, + world_size=world_size, + rank=rank, + window=self.window, + methods=self.window_method, + num_layers=self.num_layers, + ) + + def token_grid_for(self, vid_shape: torch.Tensor) -> tuple[int, int, int]: + frames, height, width = (int(v) for v in vid_shape[0].tolist()) + t, h, w = self.patch_size + if t > 1 and frames % t != 1: + raise ValueError(f"frame count {frames} must satisfy frames % {t} == 1") + return frames // t, height // h, width // w + + # -- forward ----------------------------------------------------------- + def forward( + self, + vid: torch.Tensor, + txt: torch.Tensor, + vid_shape: torch.Tensor, + txt_shape: torch.Tensor, + timestep, + runtime: SeedVR2WindowRuntime | None = None, + ) -> NaDiTOutput: + """Run the transformer. + + ``vid`` is the flattened pre-patchify latent ``[T*H*W, C]`` with + ``vid_shape`` holding the raw latent ``(T, H, W)``; ``txt`` is + ``[L, txt_in_dim]``. With ``runtime`` set, only this rank's windows are + ever materialised; without it the model runs the SP=1 path through the + same planner, so a regular -> shifted transition still permutes rows. + """ + weight = next(self.vid_in.parameters()) + frames, height, width = (int(v) for v in vid_shape[0].tolist()) + token_grid = self.token_grid_for(vid_shape) + + txt_tokens = self.txt_in(txt.to(weight.dtype)) + text_len = int(txt_tokens.shape[0]) + + canonical_rows, _ = patchify(vid, (frames, height, width), self.patch_size) + canonical_rows = canonical_rows.to(weight.dtype) + + if runtime is None: + runtime = self.build_runtime(token_grid, text_len=text_len, world_size=1, rank=0) + + first_layout = runtime.layout_for_layer(0) + local_rows = runtime.local_rows_for(canonical_rows, first_layout) + vid_hidden = self.vid_in.proj(local_rows) + emb = self.emb_in(timestep, device=vid_hidden.device, dtype=vid_hidden.dtype) + + current = first_layout.key + for index, block in enumerate(self.blocks): + layout = runtime.layout_for_layer(index) + vid_hidden = runtime.ensure_layout(vid_hidden, current, layout.key) + current = layout.key + ctx = runtime.context(layout, vid_hidden.device) + vid_hidden, txt_tokens = block(vid_hidden, txt_tokens, emb, ctx, runtime) + + vid_hidden = self._output_projection(vid_hidden, emb) + vid_hidden = runtime.to_canonical_rows(vid_hidden, runtime.layout_for_layer(self.num_layers - 1)) + return NaDiTOutput(vid_sample=unpatchify(vid_hidden, token_grid, self.patch_size)) + + # -- attention path reporting ----------------------------------------- + def attention_path_summary(self) -> dict: + """Requested vs resolved attention path, backends and per-path call counts.""" + layers_per_path: dict[str, int] = {} + backends: set[str] = set() + fallbacks: set[str] = set() + stats = {"packed_varlen_calls": 0, "grouped_sdpa_calls": 0, "no_local_windows_calls": 0} + for block in self.blocks: + attn = block.attn + layers_per_path[attn.attention_path] = layers_per_path.get(attn.attention_path, 0) + 1 + if attn.attention_backend_name: + backends.add(attn.attention_backend_name) + if attn.varlen_fallback_reason: + fallbacks.add(attn.varlen_fallback_reason) + for key, value in attn.attention_stats.items(): + stats[key] += value + return { + "layers_per_path": layers_per_path, + "backend_names": sorted(backends), + "varlen_fallback_reasons": sorted(fallbacks), + **stats, + } + + def reset_attention_stats(self) -> None: + for block in self.blocks: + for key in block.attn.attention_stats: + block.attn.attention_stats[key] = 0 + + # -- internals --------------------------------------------------------- + def _output_projection(self, vid_hidden: torch.Tensor, emb: torch.Tensor) -> torch.Tensor: + if self.vid_out_norm is not None: + vid_hidden = self.vid_out_norm(vid_hidden) + block_ada = self.blocks[0].ada + ada = block_ada.all if block_ada.shared_weights else block_ada.vid + shift_a, scale_a, _ = ada.slice(emb, "attn") + vid_hidden = vid_hidden * (scale_a.to(vid_hidden.dtype) + self.vid_out_ada.out_scale) + ( + shift_a.to(vid_hidden.dtype) + self.vid_out_ada.out_shift + ) + return self.vid_out.proj(vid_hidden) + + +# ============================================================================= +# Patch in / out +# ============================================================================= + + +class _NaPatchIn(nn.Module): + """Reference ``NaPatchIn``: ``(T t)(H h)(W w) c -> T H W (t h w c)`` + Linear.""" + + def __init__(self, in_channels: int, patch_size: Sequence[int], dim: int) -> None: + super().__init__() + t, h, w = (int(v) for v in patch_size) + self.patch_size = (t, h, w) + self.proj = nn.Linear(in_channels * t * h * w, dim) + + +class _NaPatchOut(nn.Module): + """Reference ``NaPatchOut``: Linear then ``T H W (t h w c) -> (T t)(H h)(W w) c``.""" + + def __init__(self, out_channels: int, patch_size: Sequence[int], dim: int) -> None: + super().__init__() + t, h, w = (int(v) for v in patch_size) + self.patch_size = (t, h, w) + self.proj = nn.Linear(dim, out_channels * t * h * w) + + +def patchify(vid: torch.Tensor, shape: tuple[int, int, int], patch_size: Sequence[int]): + """Patch embedding layout of the reference (token-major rows).""" + frames, height, width = (int(v) for v in shape) + t, h, w = (int(v) for v in patch_size) + if t != 1: + raise NotImplementedError("only temporal patch size 1 is supported") + channels = vid.shape[-1] + grid = vid.reshape(frames // t, t, height // h, h, width // w, w, channels) + rows = grid.permute(0, 2, 4, 1, 3, 5, 6).reshape(-1, t * h * w * channels) + return rows, (frames // t, height // h, width // w) + + +def unpatchify(rows: torch.Tensor, token_grid: tuple[int, int, int], patch_size: Sequence[int]) -> torch.Tensor: + """Inverse of :func:`patchify`: ``[N, t*h*w*c] -> [T*H*W, c]`` (reference order).""" + t, h, w = (int(v) for v in patch_size) + frames, height, width = (int(v) for v in token_grid) + channels = rows.shape[-1] // (t * h * w) + grid = rows.reshape(frames, height, width, t, h, w, channels) + return grid.permute(0, 3, 1, 4, 2, 5, 6).reshape(frames * t * height * h * width * w, channels) diff --git a/vllm_omni/diffusion/models/seedvr2/pipeline_seedvr2.py b/vllm_omni/diffusion/models/seedvr2/pipeline_seedvr2.py new file mode 100644 index 000000000..38dad47a0 --- /dev/null +++ b/vllm_omni/diffusion/models/seedvr2/pipeline_seedvr2.py @@ -0,0 +1,671 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""SeedVR2 restoration pipeline: input admission, one Euler step and colour transfer.""" + +from __future__ import annotations + +import functools +from collections.abc import Callable, Iterable, Iterator +from dataclasses import dataclass +from fractions import Fraction +from functools import partial +from pathlib import Path +from typing import ClassVar + +import av +import numpy as np +import torch +from PIL import Image +from torch import Tensor, nn +from torch.nn import functional as F +from vllm.utils.torch_utils import set_torch_threads_for_runtime + +from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.distributed.utils import get_local_device +from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader +from vllm_omni.diffusion.models.seedvr2 import config as envs +from vllm_omni.diffusion.models.seedvr2.config import COLOR_CORRECTION_METHODS, DEFAULT_COLOR_CORRECTION_METHOD +from vllm_omni.diffusion.models.seedvr2.nadit import SEEDVR2_3B_CONFIG, SeedVR2NaDiT, validate_seedvr2_parallel_config +from vllm_omni.diffusion.models.seedvr2.vae import SeedVR2VAE +from vllm_omni.diffusion.offloader.config import offload_enabled +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.errors import OmniClientError +from vllm_omni.model_executor.model_loader.weight_utils import download_weights_from_hf_specific + +# Bound per-frame decode bookkeeping; the padded clip-pixel budget is tighter +# for normal video resolutions. +MAX_FRAME_PIXELS = 848 * 480 +MAX_CLIP_PIXELS = 5 * MAX_FRAME_PIXELS + + +def max_frames() -> int: + """Decoder-work frame cap, shared by every serving profile.""" + return envs.VLLM_OMNI_SEEDVR2_MAX_FRAMES + + +# The sharded clip budget validated on the smallest qualified device (32 GB, SP4): +# five 2560x1472 frames. +CALIBRATED_SHARDED_CLIP_PIXELS = 5 * 2560 * 1472 +# The largest clip run through the forward at SP4 on a 268 GiB B300 (513 frames +# at 1536x2688, 55.5 GiB peak activations). Past it the DiT's fp32 RoPE +# intermediates and allocator fragmentation outgrow the model below, so the +# scaled budget stops here. It also keeps one channel of a clip under 2**31 +# elements. +VALIDATED_SHARDED_CLIP_PIXELS = 513 * 1536 * 2688 +# Peak activation bytes per rank of one sharded forward at SP4, the smallest +# sharded degree (more ranks need less). Measured on B300 from 5 to 1,921 frames +# at 768x1344, 2560x1472 and 1536x2688, weights excluded: +# max(ENCODER * frame_pixels * min(frames, 9) + CLIP * clip_pixels, +# DECODED * clip_pixels) +# The VAE encoder's first temporal chunk (up to nine frames) peaks for short +# clips; the whole-clip fp16 input and gathered decode peak for long ones. +ENCODER_CHUNK_FRAMES = 9 +ENCODER_BYTES_PER_PIXEL = 1140 +CLIP_BYTES_PER_PIXEL = 8.5 +DECODED_BYTES_PER_PIXEL = 28 +WEIGHT_BYTES = 7 * 1024**3 +# Room for the CUDA context and communicator buffers, then for allocator +# fragmentation, which reached 37% of the allocated activations near OOM. +USABLE_MEMORY_FRACTION = 0.85 +FRAGMENTATION = 1.37 + + +@functools.cache +def _device_memory() -> int | None: + """Smallest total memory among the visible accelerators, if it can be read.""" + from vllm_omni.platforms import current_omni_platform + + try: + count = torch.accelerator.device_count() + return min(int(current_omni_platform.get_device_total_memory(index)) for index in range(count)) or None + except (AttributeError, NotImplementedError, RuntimeError, ValueError): + return None + + +def scaled_clip_pixels(frame_pixels: int, device_memory: int | None) -> int: + """Largest padded clip the memory model admits on ``device_memory``. + + The result never falls below the calibrated budget or rises above the + validated one. The worst case for a clip budget is a clip of full-size + frames, so the model is evaluated at ``frame_pixels``; smaller frames only + need less. + """ + if device_memory is None: + return CALIBRATED_SHARDED_CLIP_PIXELS + budget = (device_memory * USABLE_MEMORY_FRACTION - WEIGHT_BYTES) / FRAGMENTATION + chunk = ENCODER_CHUNK_FRAMES * frame_pixels + clip = budget / (ENCODER_BYTES_PER_PIXEL + CLIP_BYTES_PER_PIXEL) + if clip > chunk: + clip = min( + (budget - ENCODER_BYTES_PER_PIXEL * chunk) / CLIP_BYTES_PER_PIXEL, + budget / DECODED_BYTES_PER_PIXEL, + ) + return min(max(CALIBRATED_SHARDED_CLIP_PIXELS, int(clip)), VALIDATED_SHARDED_CLIP_PIXELS) + + +def sharded_budget() -> tuple[int, int]: + """Per-frame and padded-clip budgets for the VAE-sharded serving profile. + + An unset clip budget scales with the smallest visible device's memory. + """ + frame_pixels = envs.VLLM_OMNI_SEEDVR2_SHARDED_FRAME_PIXELS + clip_pixels = envs.VLLM_OMNI_SEEDVR2_SHARDED_CLIP_PIXELS + if clip_pixels is None: + clip_pixels = scaled_clip_pixels(frame_pixels, _device_memory()) + return frame_pixels, clip_pixels + + +def validate_clip_size(frame_count: int, height: int, width: int, frame_pixels: int, clip_pixels: int) -> None: + padded_frames = frame_count + (1 - frame_count) % 4 + if frame_count > max_frames() or height * width > frame_pixels or padded_frames * height * width > clip_pixels: + raise OmniClientError("SeedVR2 clip exceeds the configured frame or pixel budget") + + +@dataclass(frozen=True) +class SourceVideo: + fps: float + pts: tuple[int, ...] + time_base: Fraction + audio: torch.Tensor | None + audio_sample_rate: int | None + + +def read_video( + path: str | Path, frame_pixels: int = MAX_FRAME_PIXELS, clip_pixels: int = MAX_CLIP_PIXELS +) -> tuple[torch.Tensor, SourceVideo]: + try: + return _read_video(path, frame_pixels, clip_pixels) + except av.FFmpegError as exc: + raise OmniClientError(f"Cannot decode SeedVR2 input video: {exc.strerror}") from exc + + +def _read_video(path: str | Path, frame_pixels: int, clip_pixels: int) -> tuple[torch.Tensor, SourceVideo]: + with av.open(str(path)) as container: + if not container.streams.video: + raise OmniClientError("SeedVR2 input contains no video stream") + stream = container.streams.video[0] + rate = stream.average_rate + if rate is None or rate <= 0: + raise OmniClientError("SeedVR2 input must declare a positive frame rate") + height, width = stream.codec_context.height, stream.codec_context.width + if height > 0 and width > 0: + validate_clip_size(max(1, stream.frames), height, width, frame_pixels, clip_pixels) + if stream.duration is not None and stream.time_base is not None: + if stream.duration * stream.time_base * rate > max_frames(): + raise OmniClientError("SeedVR2 input exceeds the maximum duration") + frames = [] + pts = [] + for frame in container.decode(stream): + validate_clip_size(len(frames) + 1, frame.height, frame.width, frame_pixels, clip_pixels) + if frame.pts is None: + raise OmniClientError("SeedVR2 input video is missing frame timestamps") + frames.append(torch.from_numpy(frame.to_ndarray(format="rgb24"))) + pts.append(frame.pts) + if not frames: + raise OmniClientError("SeedVR2 input video contains no decoded frames") + time_base = Fraction(stream.time_base) + if any(b <= a for a, b in zip(pts, pts[1:])): + raise OmniClientError("SeedVR2 input timestamps must increase") + if any( + abs((pts[index] - pts[0]) * time_base - Fraction(index, 1) / rate) > time_base for index in range(len(pts)) + ): + raise OmniClientError("SeedVR2 currently supports constant-frame-rate video only") + audio = None + sample_rate = None + with av.open(str(path)) as container: + if container.streams.audio: + stream = container.streams.audio[0] + sample_rate = stream.codec_context.sample_rate + resampler = av.AudioResampler(format="fltp", layout=stream.codec_context.layout, rate=sample_rate) + channels = len(stream.codec_context.layout.channels) + if channels not in (1, 2): + raise OmniClientError("SeedVR2 audio preservation currently supports mono or stereo") + sample_count = round(len(frames) / rate * sample_rate) + waveform = np.zeros((channels, sample_count), dtype=np.float32) + + def resampled_frames() -> Iterator[av.AudioFrame]: + for frame in container.decode(stream): + yield from resampler.resample(frame) + yield from resampler.resample(None) + + for frame in resampled_frames(): + if frame.pts is None: + raise OmniClientError("SeedVR2 audio is missing timestamps") + offset = round((frame.pts * frame.time_base - pts[0] * time_base) * sample_rate) + if offset >= sample_count: + break + chunk = frame.to_ndarray() + start, stop = max(0, offset), min(sample_count, offset + chunk.shape[1]) + if stop > start: + waveform[:, start:stop] = chunk[:, start - offset : stop - offset] + audio = torch.from_numpy(waveform).unsqueeze(0) + # Frames stay uint8 THWC: the device converts them, so the host never builds + # (or ships to every rank) a float copy four times the size. + return torch.stack(frames), SourceVideo( + float(rate), + tuple(pts), + time_base, + audio, + sample_rate, + ) + + +# Decomposition depth: the fifth band already isolates the illumination tint. +WAVELET_LEVELS = 5 +# Restoration luminance is the reason to run the model, so chroma is matched +# fully while luminance keeps this much of the restored value. +DEFAULT_LUMINANCE_WEIGHT = 0.8 + +# CIE 15 lightness transfer, sRGB (IEC 61966-2-1) primaries, D65 white point. +_CIELAB_DELTA = 6.0 / 29.0 +_D65_WHITE = (0.95047, 1.0, 1.08883) +_SRGB_TO_XYZ = ( + (0.4124564, 0.3575761, 0.1804375), + (0.2126729, 0.7151522, 0.0721750), + (0.0193339, 0.1191920, 0.9503041), +) +_XYZ_TO_SRGB = ( + (3.2404542, -1.5371385, -0.4985314), + (-0.9692660, 1.8760108, 0.0415560), + (0.0556434, -0.2040259, 1.0572252), +) + + +def _binomial_lowpass(image: Tensor, radius: int) -> Tensor: + """One a-trous low-pass step with the separable 1-2-1 binomial kernel.""" + channels = image.shape[1] + weights = image.new_tensor([1.0, 2.0, 1.0]) / 4.0 + kernel = torch.outer(weights, weights).expand(channels, 1, 3, 3) + padded = F.pad(image, (radius,) * 4, mode="replicate") + return F.conv2d(padded, kernel, groups=channels, dilation=radius) + + +def _lowest_band(image: Tensor, levels: int = WAVELET_LEVELS) -> Tensor: + """Residual band after ``levels`` doubling-radius low-pass steps. + + Summing the per-level high-frequency residuals telescopes to + ``image - lowest_band(image)``, so only this band has to be materialized. + """ + # Keep a dilated tap well inside the frame so it samples real signal rather + # than replicated edges; this bound matches the reference decomposition. + limit = max(1, min(image.shape[-2:]) // 8) + for level in range(levels): + image = _binomial_lowpass(image, min(1 << level, limit)) + return image + + +def _match_histogram(source: Tensor, reference: Tensor) -> Tensor: + """Give ``source`` the value distribution of ``reference``, rank for rank. + + Both channels hold the same pixel count, so ranks map one to one and the + match is exact without quantile interpolation. + """ + order = source.flatten().argsort() + matched = torch.empty_like(order, dtype=reference.dtype) + matched[order] = reference.flatten().sort().values + return matched.view_as(source) + + +def _srgb_to_lab(rgb: Tensor) -> Tensor: + linear = torch.where(rgb > 0.04045, ((rgb + 0.055) / 1.055).pow(2.4), rgb / 12.92) + matrix = rgb.new_tensor(_SRGB_TO_XYZ) + xyz = torch.einsum("ij,njhw->nihw", matrix, linear) + xyz = xyz / xyz.new_tensor(_D65_WHITE).view(1, 3, 1, 1) + scaled = torch.where( + xyz > _CIELAB_DELTA**3, + xyz.clamp_min(0.0).pow(1.0 / 3.0), + xyz / (3.0 * _CIELAB_DELTA**2) + 4.0 / 29.0, + ) + x, y, z = scaled[:, 0], scaled[:, 1], scaled[:, 2] + return torch.stack([116.0 * y - 16.0, 500.0 * (x - y), 200.0 * (y - z)], dim=1) + + +def _lab_to_srgb(lab: Tensor) -> Tensor: + lightness, chroma_a, chroma_b = lab[:, 0], lab[:, 1], lab[:, 2] + y = (lightness + 16.0) / 116.0 + scaled = torch.stack([y + chroma_a / 500.0, y, y - chroma_b / 200.0], dim=1) + xyz = torch.where( + scaled > _CIELAB_DELTA, + scaled.pow(3.0), + 3.0 * _CIELAB_DELTA**2 * (scaled - 4.0 / 29.0), + ) + xyz = xyz * xyz.new_tensor(_D65_WHITE).view(1, 3, 1, 1) + linear = torch.einsum("ij,njhw->nihw", lab.new_tensor(_XYZ_TO_SRGB), xyz) + rgb = torch.where( + linear > 0.0031308, + 1.055 * linear.clamp_min(0.0).pow(1.0 / 2.4) - 0.055, + 12.92 * linear, + ) + return rgb.clamp_(0.0, 1.0) + + +def _wavelet_transfer(restored: Tensor, reference: Tensor) -> Tensor: + return (restored - _lowest_band(restored) + _lowest_band(reference)).clamp_(0.0, 1.0) + + +def _channel_stats(image: Tensor, eps: float) -> tuple[Tensor, Tensor]: + dims = (2, 3) + deviation = (image.var(dims, correction=0, keepdim=True) + eps).sqrt() + return image.mean(dims, keepdim=True), deviation + + +def _adain_transfer(restored: Tensor, reference: Tensor, eps: float = 1e-5) -> Tensor: + restored_mean, restored_std = _channel_stats(restored, eps) + reference_mean, reference_std = _channel_stats(reference, eps) + normalized = (restored - restored_mean) / restored_std + return (normalized * reference_std + reference_mean).clamp_(0.0, 1.0) + + +def _lab_transfer(restored: Tensor, reference: Tensor, luminance_weight: float) -> Tensor: + restored_lab = _srgb_to_lab(_wavelet_transfer(restored, reference)) + reference_lab = _srgb_to_lab(reference) + lightness = restored_lab[:, 0] + if luminance_weight < 1.0: + matched = _match_histogram(lightness, reference_lab[:, 0]) + lightness = lightness * luminance_weight + matched * (1.0 - luminance_weight) + corrected = torch.stack( + [ + lightness, + _match_histogram(restored_lab[:, 1], reference_lab[:, 1]), + _match_histogram(restored_lab[:, 2], reference_lab[:, 2]), + ], + dim=1, + ) + return _lab_to_srgb(corrected) + + +def correct_video_color( + video: Tensor, + reference: Tensor, + method: str = DEFAULT_COLOR_CORRECTION_METHOD, + luminance_weight: float = DEFAULT_LUMINANCE_WEIGHT, +) -> Tensor: + """Transfer ``reference`` colour onto ``video``; both are ``[B,C,T,H,W]`` in ``[0,1]``. + + Frames are processed one at a time because histogram matching sorts every + pixel of a channel. Inputs are never modified. + """ + if method not in COLOR_CORRECTION_METHODS: + raise ValueError(f"SeedVR2 color_correction_method must be one of {list(COLOR_CORRECTION_METHODS)}: {method!r}") + if method == "none": + return video + if video.shape != reference.shape: + raise ValueError(f"SeedVR2 color correction needs matching shapes, got {video.shape} and {reference.shape}") + if not 0.0 <= luminance_weight <= 1.0: + raise ValueError(f"SeedVR2 luminance_weight must be within [0, 1]: {luminance_weight!r}") + + # Colour math is scale sensitive, so it runs in fp32 regardless of the model dtype. + frames = video.movedim(2, 0).flatten(0, 1).float() + references = reference.movedim(2, 0).flatten(0, 1).float() + corrected = torch.empty_like(frames) + for index in range(frames.shape[0]): + frame, guide = frames[index : index + 1], references[index : index + 1] + if method == "wavelet": + corrected[index : index + 1] = _wavelet_transfer(frame, guide) + elif method == "adain": + corrected[index : index + 1] = _adain_transfer(frame, guide) + else: + corrected[index : index + 1] = _lab_transfer(frame, guide, luminance_weight) + corrected = corrected.unflatten(0, (video.shape[2], video.shape[0])).movedim(0, 2) + return corrected.to(dtype=video.dtype) + + +def validate_seedvr2_config(config: OmniDiffusionConfig) -> None: + # Read the operator-tuned budgets here so a bad value is a startup error + # rather than a traceback from whichever module imports them first. + max_frames() + sharded_budget() + if config.dtype != torch.float16: + raise ValueError("SeedVR2 3B requires dtype=float16") + if not config.enforce_eager: + raise ValueError("SeedVR2 requires enforce_eager=True; compiled execution is not supported") + if config.cache_backend != "none" or config.quantization_config is not None: + raise ValueError("SeedVR2 requires unquantized weights and cache_backend=none for its single Euler step") + if config.vae_use_slicing: + raise ValueError("SeedVR2 whole-clip VAE does not support batch slicing") + if offload_enabled(config): + raise ValueError("SeedVR2 native whole-clip execution does not support CPU offload") + parallel = config.parallel_config + validate_seedvr2_parallel_config(parallel) + if parallel.data_parallel_size is not None and parallel.data_parallel_size > 1: + raise ValueError("SeedVR2 does not support data_parallel_size > 1") + if parallel.ulysses_degree > 20: + raise ValueError("SeedVR2 3B Ulysses requires at least one of its 20 heads per rank") + degrees = { + "cfg_parallel_size": parallel.cfg_parallel_size, + "tensor_parallel_size": parallel.tensor_parallel_size, + "pipeline_parallel_size": parallel.pipeline_parallel_size, + "text_encoder_tp_size": parallel.text_encoder_tp_size, + "ring_degree": parallel.ring_degree, + "allgather_degree": parallel.allgather_degree, + } + for name, degree in degrees.items(): + if degree != 1: + raise ValueError(f"SeedVR2 requires {name}=1; use ulysses_degree for its model-owned SP") + if parallel.vae_patch_parallel_size > 1 and parallel.vae_patch_parallel_size != parallel.ulysses_degree: + raise ValueError("SeedVR2 requires vae_patch_parallel_size to match ulysses_degree") + if parallel.vae_parallel_mode == "spatial_shard_width": + raise ValueError("SeedVR2 VAE supports spatial_shard_height or tile mode") + if parallel.use_hsdp or parallel.enable_expert_parallel: + raise ValueError("SeedVR2 does not support HSDP or expert parallelism") + if config.lora_path is not None: + raise ValueError("SeedVR2 does not support LoRA adapters") + + +def _check_frames(frames: torch.Tensor, height: int, width: int) -> None: + """Validate host frames: uint8 ``[T,H,W,3]`` or float ``[T,3,H,W]`` in ``[0,1]``.""" + rgb_axis = 3 if frames.dtype == torch.uint8 else 1 + if frames.ndim != 4 or frames.shape[rgb_axis] != 3 or min(frames.shape) < 1: + raise OmniClientError("SeedVR2 requires nonempty RGB frames with shape [T,3,H,W]") + if min(height, width) < 16 or height % 16 or width % 16: + raise OmniClientError("SeedVR2 output dimensions must be positive multiples of 16") + if frames.dtype == torch.uint8: + return + if not frames.is_floating_point() or not torch.isfinite(frames).all(): + raise OmniClientError("SeedVR2 frames must be finite floating-point RGB values") + if torch.any(frames < 0) or torch.any(frames > 1): + raise OmniClientError("SeedVR2 RGB values must be in [0,1]") + + +def prepare_video(frames: torch.Tensor, height: int, width: int, device: torch.device) -> torch.Tensor: + """Resize RGB frames to the model grid on ``device`` and repeat the final frame. + + ``frames`` is uint8 ``[T,H,W,3]`` or float ``[T,3,H,W]`` in ``[0,1]``. Frames + are converted one at a time, so the device never holds a float copy of the + whole clip, and returns fp16 ``[1,3,T',H,W]`` in ``[-1,1]`` with ``T' = 4n+1``. + """ + count = frames.shape[0] + sample = torch.empty((1, 3, count + (1 - count) % 4, height, width), device=device, dtype=torch.float16) + for index in range(count): + frame = frames[index].to(device, non_blocking=True) + frame = frame.permute(2, 0, 1).float() / 255 if frame.dtype == torch.uint8 else frame.float() + if frame.shape[-2:] != (height, width): + frame = F.interpolate( + frame.unsqueeze(0), size=(height, width), mode="bicubic", align_corners=False, antialias=True + ).squeeze(0) + sample[0, :, index] = frame.clamp(0, 1) * 2 - 1 + sample[0, :, count:] = sample[0, :, count - 1 : count] + return sample + + +@dataclass(frozen=True) +class SeedVR2Input: + # uint8 [T,H,W,3] for decoded video and PIL frames, float [T,3,H,W] otherwise; + # resizing and normalization run on the device in ``prepare_video``. + frames: torch.Tensor + frame_count: int + source: SourceVideo | None = None + + +def _admission_budget(config: OmniDiffusionConfig) -> tuple[int, int]: + """Pick the budget for the serving profile; more ranks never admit less.""" + parallel = config.parallel_config + sharded = parallel.ulysses_degree == parallel.vae_patch_parallel_size + if config.vae_use_tiling and sharded and parallel.ulysses_degree >= 4: + return sharded_budget() + return MAX_FRAME_PIXELS, MAX_CLIP_PIXELS + + +def prepare_request( + request: OmniDiffusionRequest, + *, + frame_pixels: int = MAX_FRAME_PIXELS, + clip_pixels: int = MAX_CLIP_PIXELS, +) -> OmniDiffusionRequest: + params = request.sampling_params + if request.is_dummy_run(): + # The engine's generic warmup has no source video. Only internal warmup + # requests may synthesize conditioning; real requests stay strict. + request.prompt = {"prompt": "", "multi_modal_data": {"video": torch.zeros(1, 3, params.height, params.width)}} + params.num_frames = 1 + params.num_inference_steps = 1 + params.guidance_scale = 1.0 + if params.num_inference_steps not in (None, 1) or params.guidance_scale != 1.0: + raise OmniClientError("SeedVR2 whole-clip restoration requires one Euler step and guidance_scale=1") + method = params.extra_args.get("color_correction_method") + if method is not None and method not in COLOR_CORRECTION_METHODS: + raise OmniClientError(f"SeedVR2 color_correction_method must be one of {list(COLOR_CORRECTION_METHODS)}") + prompt = request.prompt + if not isinstance(prompt, dict) or "multi_modal_data" not in prompt: + raise OmniClientError("SeedVR2 requires multi_modal_data.video") + if params.height is None or params.width is None: + raise OmniClientError("SeedVR2 requires explicit output height and width") + if min(params.height, params.width) < 16 or params.height % 16 or params.width % 16: + raise OmniClientError("SeedVR2 output dimensions must be positive multiples of 16") + validate_clip_size(1, params.height, params.width, frame_pixels, clip_pixels) + frames = prompt["multi_modal_data"].get("video") + source = None + if isinstance(frames, list) and len(frames) == 1 and isinstance(frames[0], (str, Path)): + frames = frames[0] + if isinstance(frames, (str, Path)): + frames, source = read_video(frames, frame_pixels, clip_pixels) + if params.fps is not None and abs(params.fps - source.fps) > 1e-6: + raise OmniClientError("SeedVR2 preserves source FPS; omit fps or match the input frame rate") + if isinstance(frames, list) and frames and all(isinstance(frame, Image.Image) for frame in frames): + for frame in frames: + validate_clip_size(len(frames), frame.height, frame.width, frame_pixels, clip_pixels) + frames = torch.stack([torch.from_numpy(np.array(frame.convert("RGB"))) for frame in frames]) + if not isinstance(frames, torch.Tensor): + raise OmniClientError("SeedVR2 video must be a TCHW RGB tensor or a list of PIL frames") + if frames.ndim == 4: + frame_height, frame_width = frames.shape[1:3] if frames.dtype == torch.uint8 else frames.shape[2:] + validate_clip_size(frames.shape[0], frame_height, frame_width, frame_pixels, clip_pixels) + validate_clip_size(frames.shape[0], params.height, params.width, frame_pixels, clip_pixels) + _check_frames(frames, params.height, params.width) + if (prompt.get("prompt") or "").strip(): + raise OmniClientError("SeedVR2 uses fixed checkpoint conditioning; prompt text is unsupported") + if params.num_outputs_per_prompt != 1: + raise OmniClientError("SeedVR2 produces one restored video per request") + request.prepared_layout = SeedVR2Input(frames, frames.shape[0], source) + return request + + +def get_seedvr2_pre_process_func( + od_config: OmniDiffusionConfig, +) -> Callable[[OmniDiffusionRequest], OmniDiffusionRequest]: + # This factory runs in the serving process. Keep SeedVR2's CPU scheduling + # policy local, and respect an explicit OMP_NUM_THREADS override. + set_torch_threads_for_runtime() + frame_pixels, clip_pixels = _admission_budget(od_config) + return partial(prepare_request, frame_pixels=frame_pixels, clip_pixels=clip_pixels) + + +def _seedvr2_post_process(output: dict[str, object]) -> dict[str, object]: + payload = output["payload"] + assert isinstance(payload, dict) + video = payload["video"] + assert isinstance(video, torch.Tensor) and video.dtype == torch.uint8 + # The device already produced uint8 [B,T,H,W,3], the layout encoders take. + return {"payload": {**payload, "video": video.cpu().numpy()}, "metadata": output["metadata"]} + + +def get_seedvr2_post_process_func(_od_config: OmniDiffusionConfig) -> Callable[[dict[str, object]], dict[str, object]]: + return _seedvr2_post_process + + +def finish_video(decoded: torch.Tensor, sample: torch.Tensor, frame_count: int, method: str) -> torch.Tensor: + """Colour-correct and quantize decoded ``[B,3,T,H,W]`` frames to uint8 ``[B,T,H,W,3]``. + + One frame at a time: whole-clip float copies would dominate device memory on + long clips, and a per-frame slice of one would need 64-bit indexing once the + clip passes 2**31 pixels. + """ + batch_size, _, _, height, width = decoded.shape + video = torch.empty((batch_size, frame_count, height, width, 3), device=decoded.device, dtype=torch.uint8) + for index in range(frame_count): + frame = slice(index, index + 1) + # Restoration shifts global colour, so the resized input carries the + # reference colour back onto the restored detail. + corrected = correct_video_color( + ((decoded[:, :, frame].float() + 1) / 2).clamp(0, 1), + ((sample[:, :, frame].float() + 1) / 2).clamp(0, 1), + method=method, + ) + # Quantize on the device, matching the encoders' rint(clip(x) * 255), so + # the host copy and the IPC payload are a quarter of the float size. + video[:, index] = (corrected[:, :, 0].clamp(0, 1) * 255).round_().permute(0, 2, 3, 1) + return video + + +def sample_noise(condition: torch.Tensor, generator: torch.Generator) -> torch.Tensor: + # Reference randn_like preserves latent strides; RNG values follow storage order. + return torch.empty_like(condition).normal_(generator=generator) + + +class SeedVR2Pipeline(nn.Module): + supports_request_batch = True + _dit_modules: ClassVar[list[str]] = ["transformer"] + _vae_modules: ClassVar[list[str]] = ["vae"] + _encoder_modules: ClassVar[list[str]] = [] + + def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None: + super().__init__() + validate_seedvr2_config(od_config) + set_torch_threads_for_runtime() + self.device = get_local_device() + self.od_config = od_config + self.frame_pixels, self.clip_pixels = _admission_budget(od_config) + self.transformer = SeedVR2NaDiT(**SEEDVR2_3B_CONFIG, use_varlen_kernel=False) + self.vae = SeedVR2VAE() + self.weights_sources = [ + DiffusersPipelineLoader.ComponentSource( + model_or_path=od_config.model, + subfolder=None, + revision=od_config.revision, + prefix=component + ".", + fall_back_to_pt=False, + allow_patterns_overrides=[filename], + ) + for component, filename in ( + ("transformer", "seedvr2_ema_3b_fp16.safetensors"), + ("vae", "ema_vae_fp16.safetensors"), + ) + ] + model_path = Path(od_config.model) + if not model_path.is_dir(): + model_path = Path( + download_weights_from_hf_specific(od_config.model, None, ["pos_emb.pt"], revision=od_config.revision) + ) + text = torch.load(model_path / "pos_emb.pt", map_location="cpu", weights_only=True) + if not isinstance(text, torch.Tensor) or text.ndim != 2 or text.shape[1] != 5120 or text.shape[0] == 0: + raise ValueError("SeedVR2 pos_emb.pt must contain a tensor of shape [L,5120]") + self.register_buffer("text", text.to(device=self.device, dtype=od_config.dtype), persistent=False) + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + targets = self.state_dict() + loaded: set[str] = set() + for name, weight in weights: + if name.endswith(".rope.rope.freqs"): + name = name.removesuffix(".rope.rope.freqs") + ".rope.freqs" + if name not in targets or name in loaded: + raise ValueError(f"Unexpected or duplicate SeedVR2 checkpoint key: {name}") + if targets[name].shape != weight.shape: + raise ValueError(f"SeedVR2 checkpoint shape mismatch for {name}") + targets[name].copy_(weight) + loaded.add(name) + missing = targets.keys() - loaded + if missing: + raise ValueError(f"Missing SeedVR2 checkpoint keys: {sorted(missing)}") + return loaded + + @torch.inference_mode() + def forward(self, batch: DiffusionRequestBatch) -> list[DiffusionOutput]: + outputs = [] + for request in batch.requests: + params = request.sampling_params + if not isinstance(request.prepared_layout, SeedVR2Input): + prepare_request(request, frame_pixels=self.frame_pixels, clip_pixels=self.clip_pixels) + prepared = request.prepared_layout + sample = prepare_video(prepared.frames, params.height, params.width, self.device) + generator = params.generator + if generator is None: + generator = torch.Generator(device=self.device).manual_seed(params.seed) + if not isinstance(generator, torch.Generator): + raise ValueError("SeedVR2 accepts one generator per request") + latent = self.vae.encode(sample).sample(generator=generator) + condition = latent.permute(0, 2, 3, 4, 1).squeeze(0) * 0.9152 + noise = sample_noise(condition, generator) + video = torch.cat((noise, condition, torch.ones_like(condition[..., :1])), dim=-1).reshape(-1, 33) + shape = torch.tensor([condition.shape[:3]], device=self.device, dtype=torch.long) + text_shape = torch.tensor([[self.text.shape[0]]], device=self.device, dtype=torch.long) + timestep = torch.tensor([1000.0], device=self.device, dtype=torch.float16) + runtime = self.transformer.build_runtime( + self.transformer.token_grid_for(shape), + text_len=self.text.shape[0], + parallel_config=self.od_config.parallel_config, + ) + velocity = self.transformer(video, self.text, shape, text_shape, timestep, runtime).vid_sample + # Reference Euler returns fp32, then VAE casts to fp16 before scaling. + restored = noise - velocity.reshape_as(noise) + decoded = self.vae.decode((restored / 0.9152).permute(3, 0, 1, 2).unsqueeze(0)) + method = params.extra_args.get("color_correction_method") or DEFAULT_COLOR_CORRECTION_METHOD + video = finish_video(decoded, sample, prepared.frame_count, method) + payload: dict[str, object] = {"video": video} + fps = prepared.source.fps if prepared.source is not None else params.fps + metadata: dict[str, object] = {"video": {"fps": fps}} if fps is not None else {} + if prepared.source is not None and prepared.source.audio is not None: + payload["audio"] = prepared.source.audio + metadata["audio"] = {"sample_rate": prepared.source.audio_sample_rate} + outputs.append(DiffusionOutput(output={"payload": payload, "metadata": metadata})) + return outputs diff --git a/vllm_omni/diffusion/models/seedvr2/vae.py b/vllm_omni/diffusion/models/seedvr2/vae.py new file mode 100644 index 000000000..827dcbd24 --- /dev/null +++ b/vllm_omni/diffusion/models/seedvr2/vae.py @@ -0,0 +1,380 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""SeedVR2 causal VAE with temporal tiling and spatial height sharding.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import torch +import torch.distributed as dist +from diffusers.models.attention_processor import Attention +from diffusers.models.autoencoders.vae import DiagonalGaussianDistribution +from einops import rearrange +from torch import nn +from torch.nn import functional as F + +from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor import DistributedVaeMixin +from vllm_omni.diffusion.distributed.autoencoders.wan_spatial_shard import halo_exchange +from vllm_omni.diffusion.distributed.parallel_state import get_sp_group + + +@dataclass(frozen=True) +class SpatialContext: + group: dist.ProcessGroup + rank: int + units: tuple[int, ...] + + def split(self, x: torch.Tensor) -> torch.Tensor: + scale = x.shape[-2] // sum(self.units) + start = sum(self.units[: self.rank]) * scale + return x.narrow(-2, start, self.units[self.rank] * scale).contiguous() + + def gather(self, x: torch.Tensor) -> torch.Tensor: + scale = x.shape[-2] // self.units[self.rank] + heights = [n * scale for n in self.units] + padded = F.pad(x, (0, 0, 0, max(heights) - x.shape[-2])).contiguous() + parts = [torch.empty_like(padded) for _ in heights] + dist.all_gather(parts, padded, group=self.group) + return torch.cat([part.narrow(-2, 0, h) for part, h in zip(parts, heights)], dim=-2) + + def normalize(self, norm: nn.GroupNorm, x: torch.Tensor) -> torch.Tensor: + b, c, t, h, w = x.shape + grouped = x.to(torch.float32, copy=True).reshape(b, norm.num_groups, c // norm.num_groups, t, h, w) + variance, mean = torch.var_mean(grouped, dim=(2, 4, 5), correction=0, keepdim=True) + count = c // norm.num_groups * h * w + moments = torch.stack((mean * count, torch.full_like(mean, count))) + dist.all_reduce(moments, group=self.group) + global_mean = moments[0] / moments[1] + centered = (variance + (mean - global_mean).square()) * count + dist.all_reduce(centered, group=self.group) + normalized = grouped.sub_(global_mean).mul_(torch.rsqrt(centered / moments[1] + norm.eps)).reshape_as(x) + normalized.mul_(norm.weight.float().view(1, -1, 1, 1, 1)) + normalized.add_(norm.bias.float().view(1, -1, 1, 1, 1)) + return normalized.to(x.dtype) + + def convolution(self, conv: nn.Conv3d, x: torch.Tensor) -> torch.Tensor: + halo = conv.kernel_size[1] // 2 + if halo: + x, _, _ = halo_exchange(x, group=self.group, halo_size=halo) + if conv.padding[1] == 0: + # Downsampling uses right/bottom padding, not symmetric padding. + x = x[:, :, :, halo:] + return F.conv3d(x, conv.weight, conv.bias, conv.stride, (0, 0, conv.padding[2]), conv.dilation, conv.groups) + + +def tiled_convolution(conv: nn.Conv3d, x: torch.Tensor, rows: int = 128) -> torch.Tensor: + """Tile output rows; only each tile's spatial padding/workspace is materialized.""" + shape = [(x.shape[i + 2] + 2 * conv.padding[i] - conv.kernel_size[i]) // conv.stride[i] + 1 for i in range(3)] + output = x.new_empty((x.shape[0], conv.out_channels, *shape)) + stride, padding, kernel = conv.stride[1], conv.padding[1], conv.kernel_size[1] + for start in range(0, shape[1], rows): + end = min(start + rows, shape[1]) + lo, hi = start * stride - padding, (end - 1) * stride - padding + kernel + tile = x[:, :, :, max(0, lo) : min(x.shape[-2], hi)] + tile = F.pad(tile, (0, 0, max(0, -lo), max(0, hi - x.shape[-2]))) + output[:, :, :, start:end] = F.conv3d( + tile, conv.weight, conv.bias, conv.stride, (0, 0, conv.padding[2]), conv.dilation, conv.groups + ) + return output + + +# Adapted from SeedVR, Copyright 2025 ByteDance Ltd., Apache-2.0. + + +@dataclass +class TemporalContext: + """Past convolution inputs owned by one encode or decode call.""" + + first: bool = True + cache_temporal: bool = False + tile_spatial: bool = False + spatial: SpatialContext | None = None + history: dict[nn.Module, torch.Tensor] = field(default_factory=dict) + + +def _frame_norm_silu(norm: nn.GroupNorm, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + if context is not None and context.spatial is not None: + return F.silu(context.spatial.normalize(norm, x)) + return F.silu(_frame_norm(norm, x)) + + +def _frame_norm(norm: nn.GroupNorm, x: torch.Tensor) -> torch.Tensor: + frames = x.shape[2] + x = norm(rearrange(x, "b c t h w -> (b t) c h w")) + return rearrange(x, "(b t) c h w -> b c t h w", t=frames) + + +class CausalConv3d(nn.Conv3d): + """Replicate the first frame to pad only the past temporal context.""" + + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: tuple[int, int, int] = (3, 3, 3), + stride: tuple[int, int, int] = (1, 1, 1), + padding: tuple[int, int, int] = (1, 1, 1), + ) -> None: + super().__init__(in_channels, out_channels, kernel_size, stride, (0, padding[1], padding[2])) + self.temporal_padding = 2 * padding[0] + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + if self.temporal_padding: + if context is None or not context.cache_temporal or context.first: + head = x[:, :, :1].expand(-1, -1, self.temporal_padding, -1, -1) + else: + head = context.history[self] + x = torch.cat((head, x), dim=2) + if context is not None and context.cache_temporal: + # Copy the halo so it does not retain a whole chunk's storage. + context.history[self] = x[:, :, -self.temporal_padding :].clone() + if not context.first: + x = x[:, :, self.stride[0] - 1 :] + if context is not None: + if context.spatial is not None: + return context.spatial.convolution(self, x) + if context.tile_spatial and x.shape[-2] > 256: + return tiled_convolution(self, x) + return super().forward(x) + + +class ResnetBlock3d(nn.Module): + def __init__(self, in_channels: int, out_channels: int, groups: int) -> None: + super().__init__() + self.norm1 = nn.GroupNorm(groups, in_channels, eps=1e-6) + self.conv1 = CausalConv3d(in_channels, out_channels) + self.norm2 = nn.GroupNorm(groups, out_channels, eps=1e-6) + self.conv2 = CausalConv3d(out_channels, out_channels) + self.conv_shortcut = ( + CausalConv3d(in_channels, out_channels, (1, 1, 1), padding=(0, 0, 0)) + if in_channels != out_channels + else None + ) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + h = self.conv1(_frame_norm_silu(self.norm1, x, context), context) + h = self.conv2(_frame_norm_silu(self.norm2, h, context), context) + return (self.conv_shortcut(x, context) if self.conv_shortcut is not None else x) + h + + +class Downsample3d(nn.Module): + def __init__(self, channels: int, temporal: bool) -> None: + super().__init__() + self.conv = CausalConv3d( + channels, + channels, + (3 if temporal else 1, 3, 3), + (2 if temporal else 1, 2, 2), + (1 if temporal else 0, 0, 0), + ) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + bottom = 0 if context is not None and context.spatial is not None else 1 + return self.conv(F.pad(x, (0, 1, 0, bottom)), context) + + +class Upsample3d(nn.Module): + def __init__(self, channels: int, temporal: bool) -> None: + super().__init__() + self.temporal_ratio = 2 if temporal else 1 + self.upscale_conv = nn.Conv3d(channels, channels * 4 * self.temporal_ratio, kernel_size=1) + self.conv = CausalConv3d(channels, channels) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + x = rearrange( + self.upscale_conv(x), + "b (x y z c) t h w -> b c (t z) (h x) (w y)", + x=2, + y=2, + z=self.temporal_ratio, + ) + if self.temporal_ratio == 2 and (context is None or context.first): + x = torch.cat((x[:, :, :1], x[:, :, 2:]), dim=2) + return self.conv(x, context) + + +class EncoderBlock3d(nn.Module): + def __init__(self, in_channels: int, out_channels: int, groups: int, index: int) -> None: + super().__init__() + self.resnets = nn.ModuleList( + [ResnetBlock3d(in_channels, out_channels, groups), ResnetBlock3d(out_channels, out_channels, groups)] + ) + self.downsamplers = nn.ModuleList([Downsample3d(out_channels, temporal=index >= 1)] if index < 3 else []) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + for resnet in self.resnets: + x = resnet(x, context) + for downsampler in self.downsamplers: + x = downsampler(x, context) + return x + + +class DecoderBlock3d(nn.Module): + def __init__(self, in_channels: int, out_channels: int, groups: int, index: int) -> None: + super().__init__() + self.resnets = nn.ModuleList( + [ResnetBlock3d(in_channels, out_channels, groups)] + + [ResnetBlock3d(out_channels, out_channels, groups) for _ in range(2)] + ) + self.upsamplers = nn.ModuleList([Upsample3d(out_channels, temporal=index < 2)] if index < 3 else []) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + for resnet in self.resnets: + x = resnet(x, context) + for upsampler in self.upsamplers: + x = upsampler(x, context) + return x + + +class MidBlock3d(nn.Module): + def __init__(self, channels: int, groups: int) -> None: + super().__init__() + self.resnets = nn.ModuleList([ResnetBlock3d(channels, channels, groups) for _ in range(2)]) + self.attentions = nn.ModuleList( + [ + Attention( + channels, + heads=1, + dim_head=channels, + eps=1e-6, + norm_num_groups=groups, + residual_connection=True, + bias=True, + upcast_softmax=True, + _from_deprecated_attn_block=True, + ) + ] + ) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + frames = x.shape[2] + x = self.resnets[0](x, context) + spatial = context.spatial if context is not None else None + if spatial is not None: + x = spatial.gather(x) + x = self.attentions[0](rearrange(x, "b c t h w -> (b t) c h w")) + x = rearrange(x, "(b t) c h w -> b c t h w", t=frames) + if spatial is not None: + x = spatial.split(x) + return self.resnets[1](x, context) + + +class Encoder3d(nn.Module): + def __init__(self, channels: tuple[int, int, int, int], groups: int, latent_channels: int) -> None: + super().__init__() + self.conv_in = CausalConv3d(3, channels[0]) + self.down_blocks = nn.ModuleList( + [EncoderBlock3d(channels[max(0, i - 1)], width, groups, i) for i, width in enumerate(channels)] + ) + self.mid_block = MidBlock3d(channels[-1], groups) + self.conv_norm_out = nn.GroupNorm(groups, channels[-1], eps=1e-6) + self.conv_out = CausalConv3d(channels[-1], 2 * latent_channels) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + x = self.conv_in(x, context) + for block in self.down_blocks: + x = block(x, context) + x = self.mid_block(x, context) + return self.conv_out(_frame_norm_silu(self.conv_norm_out, x, context), context) + + +class Decoder3d(nn.Module): + def __init__(self, channels: tuple[int, int, int, int], groups: int, latent_channels: int) -> None: + super().__init__() + self.conv_in = CausalConv3d(latent_channels, channels[-1]) + self.mid_block = MidBlock3d(channels[-1], groups) + widths = tuple(reversed(channels)) + self.up_blocks = nn.ModuleList( + [DecoderBlock3d(widths[max(0, i - 1)], width, groups, i) for i, width in enumerate(widths)] + ) + self.conv_norm_out = nn.GroupNorm(groups, channels[0], eps=1e-6) + self.conv_out = CausalConv3d(channels[0], 3) + + def forward(self, x: torch.Tensor, context: TemporalContext | None = None) -> torch.Tensor: + x = self.mid_block(self.conv_in(x, context), context) + for block in self.up_blocks: + x = block(x, context) + return self.conv_out(_frame_norm_silu(self.conv_norm_out, x, context), context) + + +class SeedVR2VAE(nn.Module, DistributedVaeMixin): + """Whole-clip VAE; sampling uses the caller's request-local generator.""" + + spatial_scale_factor = 8 + temporal_scale_factor = 4 + + def __init__( + self, + channels: tuple[int, int, int, int] = (128, 256, 512, 512), + groups: int = 32, + latent_channels: int = 16, + ) -> None: + super().__init__() + self.use_tiling = False + self._spatial_group: dist.ProcessGroup | None = None + self.encoder = Encoder3d(channels, groups, latent_channels) + self.decoder = Decoder3d(channels, groups, latent_channels) + + def set_parallel_size(self, parallel_size: int, mode: str = "tile") -> None: + if mode == "spatial_shard_width": + raise ValueError("SeedVR2 VAE supports height sharding; select spatial_shard_height or tile") + if parallel_size > 1: + group = get_sp_group().device_group + if dist.get_world_size(group) != parallel_size: + raise ValueError("SeedVR2 VAE patch parallel size must match the SP group") + self._spatial_group = group + else: + self._spatial_group = None + + def _context(self, latent_height: int) -> TemporalContext: + spatial = None + if self.use_tiling and self._spatial_group is not None: + group = self._spatial_group + size = dist.get_world_size(group) + if latent_height >= size: + units = tuple((i + 1) * latent_height // size - i * latent_height // size for i in range(size)) + spatial = SpatialContext(group, dist.get_rank(group), units) + return TemporalContext(tile_spatial=self.use_tiling, spatial=spatial) + + def encode(self, x: torch.Tensor, *, chunk_size: int | None = None) -> DiagonalGaussianDistribution: + if chunk_size is None and self.use_tiling: + chunk_size = 8 + if chunk_size is not None and (chunk_size < 4 or chunk_size % 4): + raise ValueError("SeedVR2 encode chunk_size must be a positive multiple of four") + context = self._context(x.shape[-2] // 8) + if context.spatial is not None: + x = context.spatial.split(x) + if chunk_size is None or x.shape[2] <= chunk_size + 1: + encoded = self.encoder(x, context) + else: + context.cache_temporal = True + chunks = [self.encoder(x[:, :, : chunk_size + 1], context)] + context.first = False + for start in range(chunk_size + 1, (x.shape[2] - 1) // 4 * 4 + 1, chunk_size): + chunks.append(self.encoder(x[:, :, start : start + chunk_size], context)) + encoded = torch.cat(chunks, dim=2) + if context.spatial is not None: + encoded = context.spatial.gather(encoded) + return DiagonalGaussianDistribution(encoded) + + def decode(self, z: torch.Tensor, *, chunk_size: int | None = None) -> torch.Tensor: + if chunk_size is None and self.use_tiling: + chunk_size = 2 + if chunk_size is not None and chunk_size < 1: + raise ValueError("SeedVR2 decode chunk_size must be positive") + context = self._context(z.shape[-2]) + if context.spatial is not None: + z = context.spatial.split(z) + if chunk_size is None or z.shape[2] <= chunk_size: + decoded = self.decoder(z, context) + else: + context.cache_temporal = True + chunks = [] + for start in range(0, z.shape[2], chunk_size): + chunks.append(self.decoder(z[:, :, start : start + chunk_size], context)) + context.first = False + decoded = torch.cat(chunks, dim=2) + if context.spatial is not None: + decoded = context.spatial.gather(decoded) + return decoded diff --git a/vllm_omni/diffusion/registry.py b/vllm_omni/diffusion/registry.py index ff6c82899..d67817ca3 100644 --- a/vllm_omni/diffusion/registry.py +++ b/vllm_omni/diffusion/registry.py @@ -34,6 +34,7 @@ def resolve_native_single_file(model_class_name: str | None) -> str | None: _DIFFUSION_MODELS = { # arch:(mod_folder, mod_relname, cls_name) + "SeedVR2Pipeline": ("seedvr2", "pipeline_seedvr2", "SeedVR2Pipeline"), "QwenImagePipeline": ( "qwen_image", "pipeline_qwen_image", @@ -693,6 +694,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = { "SanaVideoPipeline": "get_sana_video_post_process_func", "SanaImageToVideoPipeline": "get_sana_video_i2v_post_process_func", "Magi2Pipeline": "get_magi2_post_process_func", + "SeedVR2Pipeline": "get_seedvr2_post_process_func", "OmniVoicePipeline": "get_omnivoice_post_process_func", "SenseNovaU1Pipeline": "get_sensenova_u1_post_process_func", "Cosmos3OmniDiffusersPipeline": "get_cosmos3_post_process_func", @@ -716,6 +718,7 @@ _DIFFUSION_IR_OP_PRIORITY_FUNCS = { } _DIFFUSION_PRE_PROCESS_FUNCS = { + "SeedVR2Pipeline": "get_seedvr2_pre_process_func", # arch: pre_process_func # `pre_process_func` function must be placed in {mod_folder}/{mod_relname}.py, # where mod_folder and mod_relname are defined and mapped using `_DIFFUSION_MODELS` via the `arch` key diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 5d474306b..f9f3849f4 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -345,6 +345,13 @@ async def omni_run_server_worker( remove_route_from_app(app, "/v1/models", {"GET"}) # Remove upstream /v1/models to use omni's handler remove_route_from_app(app, "/health", {"GET"}) app.include_router(router) + get_od_config = getattr(engine_client, "get_diffusion_od_config", None) + od_config = get_od_config() if callable(get_od_config) else None + model_class_name = getattr(od_config, "model_class_name", None) or getattr(args, "model_class_name", None) + if model_class_name == "SeedVR2Pipeline": + from vllm_omni.diffusion.models.seedvr2.long_video import register_routes + + register_routes(app, args.port) # OMNI: Override upstream exception handlers with Omni-aware versions # that understand the multi-stage orchestrator lifecycle.