Skip to content

[feat]: run FastH3 across two DGX Sparks with Ray sequence parallel - #1803

Merged
SolitaryThinker merged 18 commits into
hao-ai-lab:mainfrom
aryan5v:aryan/dgx-spark-pair
Sep 1, 2026
Merged

[feat]: run FastH3 across two DGX Sparks with Ray sequence parallel#1803
SolitaryThinker merged 18 commits into
hao-ai-lab:mainfrom
aryan5v:aryan/dgx-spark-pair

Conversation

@aryan5v

@aryan5v aryan5v commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Rebased onto current main so this PR does not fight #1793. Sequential MiniMax H3 start (h3_sequential_load, GPU-direct DiT load) stays as merged. This branch adds the rest of the GB10 / dual-Spark work on top of that.

From #1761 (authors preserved)

Kyle's lazy_module_load / LazyModule path: load each opted-in component on first use and free it after its last stage, with in-process reload for a later generate(). MiniMax-H3 opts in. Satyam's follow-ups: compile across lazy reloads, LoRA setup must not materialize lazy DiTs, share compiled VSA graphs across H3 layers. Geometry is read from checkpoint config.json so input prep / unpatchify do not hold a live DiT through VAE decode.

GPU-direct DiT load is not re-landed here; it already merged in #1793.

Dual-Spark FastH3 (this PR's original work)

  • Ray executor: driver-local set_log_queue / clear_log_queue (Queue is not picklable across nodes) and copy NCCL_* onto workers.
  • basic_fasth3.py --execution-backend {mp,ray} (auto-ray when RAY_ADDRESS is set).
  • QSFP / Ray bring-up, spark_pair_env.sh, generate YAML, cookbook recipe.

Sequence parallel replicates the DiT (~66 GiB/node), so each Spark still needs h3_sequential_load and lazy_module_load (both auto on unified memory).

Measured on two GB10s over QSFP RoCE (~21 GB/s NCCL busbw), same alpine 768×1344 FastH3 recipe, Triton VSA, no FA4, sequential + lazy load, parallel VAE, cold process:

Run E2E Denoise (incl. DiT load) Decode
1 GPU, 124 frames 374–393 s 180–188 s 151–156 s
SP=2, 124 frames 292 s 122 s 102 s
SP=2, 345 frames (~14.4 s) 587 s 351 s 173 s

That 292 s is faster than the ~330 s one-Spark clip from earlier bring-up. It is not a lower bound: first decode still pays VAE torch.compile; TAEH3 (#1795) is a separate opt-in and was not used.

Test plan

  • pytest fastvideo/tests/worker/test_ray_distributed_executor.py
  • pytest fastvideo/tests/stages/test_minimax_h3_sequential_start.py fastvideo/tests/stages/test_lazy_module_load.py
  • Two Sparks: source examples/inference/optimizations/spark_pair_env.sh, ray start on QSFP IPs with FASTVIDEO_HOST_IP matching --node-ip-address, then python examples/inference/basic/basic_fasth3.py --num-gpus 2 --execution-backend ray --vsa-kernel triton --no-fa4 ...

@mergify mergify Bot added type: feat New feature or capability scope: inference Inference pipeline, serving, CLI scope: attention Attention backends (VSA, STA, Flash, etc.) scope: infra CI, tests, Docker, build scope: docs Documentation scope: model Model architecture (DiTs, encoders, VAEs) labels Sep 1, 2026
@mergify

mergify Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 1 of 1 protections blocking · waiting on 👀 reviews and 🤖 CI

Protection Waiting on
🔴 PR merge requirements 👀 reviews and 🤖 CI

🔴 PR merge requirements

Waiting for

  • #approved-reviews-by>=1
  • check-success=fastcheck-passed
  • check-success=full-suite-passed
This rule is failing.
  • #approved-reviews-by>=1
  • check-success=fastcheck-passed
  • check-success=full-suite-passed
  • check-success~=pre-commit
  • title~=(?i)^\[(feat|feature|bugfix|fix|refactor|perf|ci|doc|docs|misc|chore|kernel|new.?model|skill|skills|infra)\]

@KyleNeverGivesUp

Copy link
Copy Markdown
Contributor

#1761 has now been independently rebased onto main and is mergeable with no conflicts. #1803 currently includes the #1761 commit stack because it was built on top of that work. Please merge #1761 first, then rebase #1803 onto the updated main so that #1803 contains only its follow-up changes.

This would leave #1803 focused on the geometry fix, the dual-Spark Ray work, and the accompanying recipe documentation, which should be reviewed separately.

@mergify

mergify Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

This PR has merge conflicts with the base branch. Please rebase:

git fetch origin main
git rebase origin/main
# Resolve any conflicts, then:
git push --force-with-lease

@mergify mergify Bot added the needs-rebase PR has merge conflicts label Sep 1, 2026
@SolitaryThinker

Copy link
Copy Markdown
Collaborator

Review: lazy module load + dual-Spark Ray path

Recall-oriented review of the full diff against main (merge-base a28f2bab). Each item below was verified against the PR-head tree; several were reproduced empirically (noted inline). Ordered most severe first.

Correctness

1. fastvideo/pipelines/basic/minimax_h3/minimax_h3_pipeline.py:74 — the PR's own test fails at head.
_apply_h3_checkpoint_arch_configs unconditionally reads pipeline_config.dit_config.patch_size (and vae_config.arch_config.*) for its log line, so post_init raises AttributeError: 'DiTConfig' object has no attribute 'patch_size' whenever FastVideoArgs.pipeline_config is the generic default. test_minimax_h3_sequential_start.py::test_inference_defers_dit_and_vae_until_after_conditioning — which this PR extends and lists in its test plan — passes 6/6 at base and fails 1/6 at PR head.

2. fastvideo/pipelines/lora_pipeline.py:281 — LoRA + lazy_module_load silently drops the adapter and never frees the DiT.
convert_to_lora_layers materializes the DiT proxy and pins the real blocks in self.lora_layers / LoRAModelLayers.block_mapping, so LazyModule.release() frees nothing; the next materialize() re-runs the plain loader and produces a base-weight DiT while lora_initialized stays True, and set_lora_adapter / merge_lora_weights edit orphaned layers. Triggered by basic_fasth3_lora_preview.py --num-gpus 1 on a discrete GPU (it inherits the num_gpus == 1 → lazy on override). Reproduced: a block parameter survives release().

3. fastvideo/worker/ray_distributed_executor.py:80 — copying NCCL_SOCKET_IFNAME / NCCL_IB_HCA / GLOO_SOCKET_IFNAME from the driver breaks pairs with asymmetric NIC names.
The push goes through update_environment_variables, which overwrites (warning only) the per-node value the user set before ray start — exactly what spark_pair_env.sh and spark_pair.md tell them to do when ibdev2netdev differs per box. Remote worker then binds a non-existent interface: NCCL "no socket interface found" / Gloo "Unable to find address for". Reproduced that per-node exports do reach Ray actors and only this push clobbers them; GLOO_SOCKET_IFNAME is exact-match so no single driver value covers both boxes. Base copied no NCCL_*/GLOO_* vars. Suggest setdefault semantics for the interface-binding trio, or drop them from the list.

4. minimax_h3_pipeline.py:227 — text encoder re-read from disk on every request on discrete GPUs.
forward now always routes through _run_condition_then_denoise, which unconditionally calls _release_text_encoder(); the base fast path if self._denoise_stages_ready: return super().forward(...) is gone. With both h3_sequential_load and lazy_module_load off (the --num-gpus 4 default), request 2's _ensure_text_encoder finds the encoder missing and re-reads Qwen3-VL per rank. text_encoder_cpu_offload parking is thrown away, contradicting the fastvideo_args.py:164-165 contract. Reproduced: PR head reloads every request, base 0.

5. minimax_h3_pipeline.py:187compile.vae_enabled: true in the shipped Spark YAML is a no-op; text-encoder compile applies to request 1 only.
Pipeline-level compile (_maybe_compile_pipeline_module / set_materialize_transform) runs only in post_init on modules present then. On the sequential path vae/audio_vae/transformer are absent, so the call returns at if module_name not in self.modules — while post_init still logs "Torch Compile enabled for VAE". Proxies later added by _load_denoise_modules and the new _ensure_text_encoder (every path) get no transform. Reproduced: vae transform=None, prepare_for_compile calls: 0. offloading.md:148 ("compile setup is reapplied after each reload") and spark_pair.md:181 ("later generate() calls are cheaper") don't hold for this model.

6. fastvideo/pipelines/composed_pipeline_base.py:438 — release schedule pins proxy identity; add_module never rebuilds it.
_release_text_encoder pops the original proxy and _ensure_text_encoder installs a new one via add_module, so from request 2 the conditioning stage's _lazy_modules_to_release is a stale released proxy (non-sequential) or () (sequential). The reloaded ~66 GiB encoder is freed only by refcount inside _release_text_encoder; if conditioning raises on request 2, nothing releases it (see 7). "Released deferred module ... freed X GiB" accounting silently stops covering the encoder.

7. minimax_h3_pipeline.py:306 — the new abort-path cleanup is dead for the only pipeline that opts in.
ComposedPipelineBase.forward's except BaseException: self._release_all_lazy_modules() never runs for H3 because MiniMaxH3BasePipeline.forward overrides forward without super() or an equivalent wrapper. A denoise failure on GB10 leaves audio_vae (T2VA) or vae+audio_vae (FL2VA/Ref2VA) materialized — the retry the docstring says this protects starts with less headroom. _release_all_lazy_modules has no other caller.

8. examples/inference/basic/basic_fasth3.py:285 (and basic_minimax_h3_t2v.py:92) — examples force lazy on for any single GPU regardless of device.
True if args.lazy_module_load is None and args.num_gpus == 1 else ... bypasses the tri-state policy this PR adds in finalize_device_offload_policy. On a single discrete GB200/B200 every generate() now releases and re-reads the DiT (~35 s by the PR's numbers), both VAEs, and the encoder for no memory benefit; --warmup --repeats N measures reloads. The two help strings already disagree with add_cli_args.

9. fastvideo/pipelines/basic/minimax_h3/stages/minimax_h3_latent_preparation.py:320 — audio VAE materialized two stages early and held through denoise.
patch_size moved off the live DiT, but forward still reads self.audio_vae.latent_channels (320/323/334, T2VA path) and self.vae.latent_channels (196/244). Under lazy load the audio VAE's last user is audio_decoding_stage, so denoise peak = DiT + audio VAE (+ video VAE for FL2VA/Ref2VA), not the "largest single component" offloading.md promises. Same fix pattern as h3_dit_patch_size: read from pipeline_config.*_config.arch_config.latent_channels.

10. composed_pipeline_base.py:705 — four WARNINGs + schedule rebuilds on the first request of the default GB10 / shipped-YAML path.
Post_init installs hooks with only the two condition stages; _add_denoise_stages then hits add_stage's "added after the deferred-release schedule was built; rebuilding" WARNING four times per worker (8 across the pair). Reproduced: 4 on request 1, 0 on request 2, 0 with either flag off. The designed happy path is logged as an anomaly.

11. composed_pipeline_base.py:667 — WARNING on every non-H3 model on unified memory.
lazy_module_load=None auto-resolves True on GB10/Jetson/MPS for every pipeline, but only H3 declares _lazy_module_names, so Wan/Hunyuan/Cosmos/LTX/... all log "lazy_module_load is on but no deferred module is held by a stage ... may load its modules eagerly" on the documented opt-out state. Skip hook install (or INFO) when _lazy_module_names is empty.

12. fastvideo/pipelines/stages/base.py:165BaseException in output verification skips release.
_execute is wrapped in except BaseException but output verification in except Exception, so a KeyboardInterrupt/SystemExit inside verify_output never releases the stage's deferred modules — and H3 has no outer net (7). A single try/finally closes it. (torch.cuda.OutOfMemoryError is an Exception, so OOM is covered.)

13. ray_distributed_executor.py:42 (and :242) — the Gloo-loopback change is a no-op with a misleading test.
Base node_gpus was keyed by Ray node id, so two one-GPU Sparks already gave len(node_gpus) == 2; with the pre-existing n_nodes != n_ips guard, len(set(worker_ips)) <= 1 is equivalent in every configuration that passes. The docstring, commit message, and test_gloo_loopback_follows_worker_ips_not_node_count describe a case that never occurred, so whatever actually failed (likely 3) is misattributed. The rewritten hunk also keeps a literal "127.0.0.1" instead of get_loopback_ip() (used by MultiprocExecutor), so FASTVIDEO_LOOPBACK_IP is ignored on Ray.

14. minimax_h3_pipeline.py:250 — audio VAE sampling rate hardcoded on the Ref2VA input path.
_input_audio_vae(ref2va=True) now always returns _default_audio_geometry() (32000); neither the overlay nor this helper reads audio_vae/config.json or pipeline_config.audio_vae_config. A checkpoint declaring a different rate would resample reference audio to the wrong rate before audio_vae.encode uses the real one. All three published checkpoints declare 32000, so nothing shipped is affected today; base only fell back on the deferred Spark path.

Docs

15. docs/inference/offloading.md:28 still says "Sequential load currently cannot re-encode a new prompt on that worker; start a new generator", which this PR's _ensure_text_encoder makes false and which spark_pair.md:115/195 and the extended sequential-start test directly contradict.

Below the cut (verified, lower impact)

_regional_compile_layer_idx is now a dead per-block tensor and prepare_for_compile runs twice on the regional path (video_sparse_attn_h3.py:500); generic/training compile now does VSA pooling/top-k on dense layers before OR-ing an all-True mask (:624); the enumerated ADDITIONAL_ENV_VARS list silently drops any unlisted NCCL knob; the activation-trace WARNING at composed_pipeline_base.py:361 fires without checking FASTVIDEO_TRACE_ACTIVATIONS; _apply_h3_checkpoint_arch_configs duplicates the loader overlay (no drift today); set_log_queue is a third copy of the base-class body; h3_dit_patch_size re-validates per keyframe; exclude_lora_layers is readable from pipeline_config without materializing the DiT.

One candidate was refuted: _compile_layer_idx landing on CPU under dit_cpu_offload — the non-FSDP inference loader materializes on the local CUDA device regardless of that flag.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G6j6Dce9rizzdLuecd4sxY

aryan5v added a commit to aryan5v/FastVideo that referenced this pull request Sep 1, 2026
…#1803 review

Keep deferral, compile, and later generate() from fighting each other, stop LoRA bookkeeping from pinning a released DiT, and leave per-node NCCL/Gloo interface names alone.
KyleNeverGivesUp and others added 16 commits September 1, 2026 01:26
…last stage

A pipeline materializes every component before the first stage runs, so peak
memory is the sum of all components even though no two are needed at the same
moment. The CPU offload flags cannot help with this: they act after loading,
and on a unified-memory device moving weights to the host frees nothing because
it is the same pool.

Add lazy_module_load, off by default. Heavy components become a LazyModule
proxy that loads on first use, and the pipeline installs a release hook on the
last stage that holds each one. Peak becomes the largest overlapping set
instead of the sum.

Measured on MiniMax-H3 r16, 121 GiB GB10, 1 GPU, 192x320, 4 steps. Peak CUDA
allocated is 57.7 GiB, reached during conditioning where the text encoder and
video VAE overlap. The four deferred components account for 96.3 GiB together,
which is what stays resident without the flag: text encoder 48.0, DiT 37.8,
video VAE 9.7, audio VAE 0.8. Generation completed and wrote a video.

Details worth flagging:

The release hook lives on PipelineStage.__call__, not in the pipeline stage
loop, so pipelines that override forward still free.

The proxy forwards __class__, so isinstance stays honest. A proxy answering
False to isinstance(module, FSDPModule) would take the wrong branch silently.

Self-returning methods hand back the proxy rather than the component. Stages
write self.vae = self.vae.to(device) in a dozen places; returning the component
there would replace the proxy with a reference the pipeline cannot release, and
the run would look normal while freeing nothing.

Releasing is a latency cost, never a correctness one: a released component
reloads on next access. Training keeps everything resident and warns if the
flag is set. If no stage holds a deferred component the pipeline warns rather
than silently doing nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example builds its OffloadConfig directly rather than going through
FastVideoArgs.add_cli_args, so the new flag needs its own switch to be
reachable from the motivating command line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The schedule maps each deferred component to the last stage that holds it,
derived once after create_pipeline_stages. Every pipeline in the tree builds
its stages there, but nothing enforced it. A stage appended afterwards could
hold a component an earlier stage had already been told to free, and would
then be handed a released component mid-run with no error.

add_stage now rebuilds the schedule and says so, turning an invariant nothing
checked into a visible self-correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same reason as the MiniMax H3 T2V example: this script builds its OffloadConfig
directly rather than going through FastVideoArgs.add_cli_args, so the flag needs
its own switch here to be reachable. FastH3 is the case the flag exists for,
since its components sum past what a 121 GiB unified-memory device can hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lure

Review of the first version found that the flag was unsafe as a global
default. Releasing a component and loading it again is only correct when
nothing outside the loader has changed it, and two habits in this tree break
that without raising:

  * mutating a component after load. LongCatPipeline.initialize_pipeline turns
    on block-sparse attention and writes parameters into every transformer
    block. That runs once, so a re-materialized component silently comes back
    with the feature off and the second generation quietly degrades.
  * reading a component's attributes while stages are built. The shared
    DenoisingStage.__init__ derives the attention backend from
    transformer.hidden_size, which materializes the DiT during post_init and
    defeats the deferral it was meant to gain.

_lazy_module_names is now empty in the base class, so an unchecked pipeline
gets no deferral and says so. MiniMax-H3 opts in to the four components this
PR measured, and nothing else changes behaviour.

Also from review:

Releasing on the failure path. A stage's hook frees only what that stage is
the last user of, and it ran only after a successful forward. Both the stage
and the whole run now release on the way out, so the retry a memory
constrained caller attempts does not start from a worse position than the
request that just failed. A failing release cannot replace the exception
being propagated.

Walking into nested stages. Cosmos25AutoDenoisingStage keeps the transformer
inside child stages, so a one-level scan called it unreferenced and never
freed it. The scan now recurses through stages and containers with cycle
protection.

Keeping the proxy when torch.compile is skipped. The FSDP check ran after the
proxy had already been replaced by the real module, so an FSDP-wrapped
component lost both the compile and its release hook.

Not attaching the activation trace to a deferred component, since the hook
manager pins every module it wraps.

test_parser.py asserted on a whole serialized config dict, so adding a field
to OffloadConfig broke it. Grepping the field name could not find that; only
running the suite could.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The existing tests build stages and pipelines by hand, so they would stay
green through both defects review found: a load_modules that never reaches the
deferral, and a stage constructor that reads a component's attributes and
materializes it during post_init.

Three tests now run the production paths. Two call the real
ComposedPipelineBase.load_modules with the component loader stubbed and a
counter on it, asserting that only opted-in names become proxies and that the
loader is never asked for them. The third builds the real MiniMax-H3 stages
over tracked proxies and asserts nothing materialized.

The third one was mutation-checked: adding a single transformer.patch_size
read to MiniMaxH3DenoisingStage.__init__ makes it fail, which is the habit
that defeats deferral in the shared DenoisingStage today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Register pipeline-level compilation as a lazy materialization transform so every freshly loaded component receives the same compile setup. Keep whole-module compilation behind the proxy, preserve release scheduling, and add regression coverage for conditional and whole-module compile paths.
Defer transformer config access until LoRA conversion is actually requested so base inference keeps lazy DiTs unloaded through conditioning. Initialize transformer bookkeeping per pipeline and cover deferred no-LoRA construction, on-demand exclusion setup, and instance isolation.
… the DiT before VAE decode

Input prep and unpatchify were holding live VAE/DiT proxies just for two integers, which loaded the video VAE before Qwen and kept the DiT resident through decode. Auto-enable --lazy-module-load on unified memory and on single-GPU FastH3 examples.
Ray could not start a two-node FastH3 job because the executor was still abstract and NCCL env was not copied to workers. Document the QSFP bring-up, cookbook recipe, and measured 292 s / 587 s pair runs on top of lazy module load.
…page

The pair YAML was already in this PR; the cookbook gallery only lists recipes
that declare a family and hardware evidence.
… on worker IPs

Sequential load dropped Qwen after the first request, so warmup+repeats crashed.
Two 1-GPU Sparks also Gloo'd to 127.0.0.1 when node_gpus looked like a single node.
…d recipe

Record the 512x896 1-GPU and dual-Spark medians we measured, and note native
480p is 480x832. Geometry stays a CLI/YAML knob.
On GB10 both flags auto-enable; sequential then strips DiT/VAEs before
post_init, so VAE torch.compile logs as enabled but never attaches.
…#1803 review

Keep deferral, compile, and later generate() from fighting each other, stop LoRA bookkeeping from pinning a released DiT, and leave per-node NCCL/Gloo interface names alone.
@aryan5v
aryan5v force-pushed the aryan/dgx-spark-pair branch from 4a5d5ce to 809d74c Compare September 1, 2026 08:33
@SolitaryThinker
SolitaryThinker merged commit aaef49b into hao-ai-lab:main Sep 1, 2026
5 of 11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-rebase PR has merge conflicts scope: attention Attention backends (VSA, STA, Flash, etc.) scope: docs Documentation scope: inference Inference pipeline, serving, CLI scope: infra CI, tests, Docker, build scope: model Model architecture (DiTs, encoders, VAEs) type: feat New feature or capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants