Skip to content

[perf]: MiniMax H3 on GB10 - skip text encoder CPU offload on unified memory (5m49s to 30ms) - #1710

Merged
SolitaryThinker merged 4 commits into
hao-ai-lab:mainfrom
KyleNeverGivesUp:skip-cpu-offload-on-unified-memory
Aug 26, 2026
Merged

[perf]: MiniMax H3 on GB10 - skip text encoder CPU offload on unified memory (5m49s to 30ms)#1710
SolitaryThinker merged 4 commits into
hao-ai-lab:mainfrom
KyleNeverGivesUp:skip-cpu-offload-on-unified-memory

Conversation

@KyleNeverGivesUp

Copy link
Copy Markdown
Contributor

Purpose

CPU offload assumes host memory and device memory are two different pools. On a
device whose GPU reads host RAM they are one, so offloading frees nothing, and
the way it is implemented makes peak memory worse rather than better.

Part of #1709.

The measurement

DGX Spark (GB10, 121 GB usable), loading MiniMax H3's text encoder, a 63 GB
bf16 checkpoint, memory sampled every 5 s with free. Baseline 8 GB, machine
otherwise idle. free's used column excludes buff/cache, so this is process
memory.

offload on offload off
Loading weights took to Loaded module text_encoder 5 min 49 s 7 ms
memory added after the weights read +34 GB 0
peak while loading this component 105 GB 75 GB

The weights themselves arrive in about 17 s in both cases, and 63 GB on disk
becomes roughly 67 GB resident, which is expected. With offload on, memory then
climbs steadily for the next five minutes while fully_shard walks the module
tree, and the run is OOM-killed before the DiT is reached.

Nothing is upcast. The checkpoint is uniformly bf16 ({'BF16': 43} across the
first shard's header, "dtype": "bfloat16" in its config), so the extra 34 GB
is not dtype conversion.

Why it happens

shard_model calls fully_shard(..., offload_policy=CPUOffloadPolicy(...)) per
module, bottom up. Each call allocates that module's parameters on the host and
copies them over before the device copy goes away. On a discrete card the two
allocations come from different pools, so the device side empties as the host
side fills. On unified memory both come from the same RAM, so during the walk
the machine holds part of the model twice, and the walk is long enough that the
overlap is substantial.

Changes

  • Platform.has_unified_memory, default False, so discrete accelerators keep
    the existing path unchanged.

  • MpsPlatform returns True.

  • CudaPlatformBase reads cudaDeviceProp::integrated, which torch surfaces as
    is_integrated. Confirmed on the hardware in question:

    name          : NVIDIA GB10
    is_integrated : 1
    total_memory  : 131 GB
    

    The probe is guarded: a torch build without the field, or a machine with no
    visible device, returns False rather than changing offload behaviour on
    hardware that cannot be classified.

  • The text encoder loader skips sharding on such a device and logs why, in a
    branch next to the existing MPS one.

The text_encoder offload flag itself is deliberately left alone. It states
what the user wants, and the same script should keep working on both an A100,
where offload is real, and a Spark, where it cannot be. The platform decides
whether the request is achievable, and says so in the log rather than silently
doing nothing.

MPS already skipped sharding, but for an unrelated reason ("not compatible").
That branch stays as it is; this adds a second reason that applies to CUDA
devices with the same memory topology.

Test Plan

pytest fastvideo/tests/platforms/test_unified_memory.py -q
pre-commit run --files fastvideo/platforms/interface.py fastvideo/platforms/cuda.py \
                       fastvideo/platforms/mps.py fastvideo/models/loader/component_loader.py \
                       fastvideo/tests/platforms/test_unified_memory.py

CPU only, no accelerator needed. The CUDA cases monkeypatch
torch.cuda.get_device_properties, since what needs guarding is the decision,
not the driver.

Covered: the base default stays False; MPS is True; CUDA follows
is_integrated in both directions; a torch build without the attribute falls
back to False; a probe that raises does not take the run down.

Test Results

Test output
$ pytest fastvideo/tests/platforms/test_unified_memory.py -q
......
6 passed, 14 warnings in 0.02s

$ pre-commit run --files ...
yapf.....................................................................Passed
ruff (legacy alias)......................................................Passed
codespell................................................................Passed

mypy could not run in my checkout, whose directory name contains a hyphen and
so is not a valid package name. It passes in CI.

End to end on the GB10, the numbers in the table above. Reproduce with:

python examples/inference/basic/basic_minimax_h3_t2v.py \
  --model-path noctuashap/MiniMax-H3-pruned-r16 \
  --prompt "A robotic arm pours liquid into a glass, cinematic lighting." \
  --num-gpus 1 --height 480 --width 832 --num-frames 25 --steps 8 \
  --output outputs/h3_smoke

# alongside it
while true; do echo "$(date +%T) $(free -g | awk '/^Mem:/ {print $3}')"; sleep 5; done

and compare the gap between the two log lines with and without this change.

Scope

This does not make H3 fit on a single GB10. Its four components are 111 GB on
disk against 121 GB of memory, so it still runs out, just without the extra
34 GB and five minutes on top. What this fixes is a cost that every unified
memory device pays on every model that requests text encoder offload, which is
the default in several of the example scripts.

Checklist

  • I ran pre-commit on the changed files and fixed all issues
  • I added or updated tests for my changes
  • I updated documentation if needed
  • I considered GPU memory impact of my changes

@mergify mergify Bot added type: perf Performance improvement scope: infra CI, tests, Docker, build scope: model Model architecture (DiTs, encoders, VAEs) labels Aug 15, 2026
@mergify

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42bca784f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fastvideo/models/loader/component_loader.py Outdated
@KyleNeverGivesUp

Copy link
Copy Markdown
Contributor Author

Verified on the GB10 with the change in place and text_encoder=True left as the example ships it, so the offload request is still made and the platform is what declines it:

22:11:21.705 [component_loader.py:398] Loading weights took 29.34 seconds
22:11:21.732 [component_loader.py:419] Skipping CPU offload for the text encoder: NVIDIA GB10 has unified memory, so host and device allocations share one pool and offloading would hold two copies instead of moving one.
22:11:21.735 [composed_pipeline_base.py:475] Loaded module text_encoder

30 ms from weights read to module ready, against 5 min 49 s on main with the same flag set.

For completeness, the three cases side by side, same machine, same checkpoint:

text_encoder offload weights read to module ready
main True 5 min 49 s, +34 GB
main, flag flipped off in the example False 7 ms
this PR True 30 ms

The middle row is what the table in the description was measured from. The last row is the one that matters here, since it keeps the user's request intact and lets the platform decide whether it can be honoured.

@KyleNeverGivesUp KyleNeverGivesUp changed the title [perf]: skip text encoder CPU offload on unified memory [perf]: skip text encoder CPU offload on unified memory (MiniMax H3 on GB10: 5m49s to 30ms) Aug 15, 2026
@KyleNeverGivesUp KyleNeverGivesUp changed the title [perf]: skip text encoder CPU offload on unified memory (MiniMax H3 on GB10: 5m49s to 30ms) [perf]: MiniMax H3 on GB10 - skip text encoder CPU offload on unified memory (5m49s to 30ms) Aug 15, 2026
KyleNeverGivesUp added a commit to KyleNeverGivesUp/FastVideo that referenced this pull request Aug 18, 2026
…ader's

Every offload flag moves weights to host memory so the device can drop them.
That trade only pays when the two are separate pools. On a unified-memory device
they are one, so the move frees nothing, the copy is a pure loss, and the peak
becomes the sum instead of the max.

hao-ai-lab#1710 added `Platform.has_unified_memory()` and used it at one site: the text
encoder's FSDP offload during loading. That is not enough, because a single flag
acts in more than one place. `text_encoder_cpu_offload` does three things:

  component_loader.py:353   picks the load-time target device, so the model is
                            placed on the host
  component_loader.py:407   gates the FSDP offload path, which hao-ai-lab#1710 covers
  minimax_h3_conditioning.py:292-302
                            gates a `.to(device)` before the conditioning
                            forward and a `.to("cpu")` after it

Gating only the loader leaves the model on the host and moves the copy to
inference time. On a DGX Spark loading MiniMax H3 that is a 48 GB move in the
middle of generation with about 11 GiB free, and the worker is killed.

Decide it once, in `check_fastvideo_args`, so every call site sees the same
answer. `UNIFIED_MEMORY_OFFLOAD_FLAGS` names the five flags and a test asserts
the tuple still matches the dataclass, because missing one is silent: the run
works and quietly pays twice, which is exactly how the text encoder survived the
first pass at this.

`use_fsdp_inference` is deliberately left alone. Sharding across ranks is a
separate decision from where the weights live, and a unified-memory host can
have more than one of these devices. MPS keeps its own branch, which does
disable it, and reaches this one through `elif`.

hao-ai-lab#1710's loader guard is not made redundant. `TextEncoderLoader.load` takes an
explicit `cpu_offload` argument that bypasses the args-level decision, and that
path still needs it.

Measured on a DGX Spark, GB10, 121 GiB unified memory, one GPU, with hao-ai-lab#1710,
hao-ai-lab#1711 and hao-ai-lab#1714 also applied. Before this change the conditioning stage is
terminated 1.5 s in, every time, with nothing reported. After it, the run gets
through input preparation, conditioning, latent preparation and the full
denoising loop, and is terminated in the video decode instead.

Getting past that last step also needs the video VAE decoder in fp16 rather than
the fp32 it is pinned to at `models/vaes/minimax_h3_video.py:565`. With that as a
local patch on top, MiniMax H3 completes a text to video generation on one GB10:
320 by 192, 124 frames, video and audio, 11.69 s. It could not load at all before
this series. The VAE change is not in this PR because the encode path has to stay
fp32 for FL2VA and Ref2VA, so it needs its own design and its own measurements.

fastvideo/tests/platforms/test_unified_memory_offload.py: 11 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SolitaryThinker
SolitaryThinker force-pushed the skip-cpu-offload-on-unified-memory branch from 42bca78 to 1d874b6 Compare August 23, 2026 22:55
@mergify mergify Bot added the scope: inference Inference pipeline, serving, CLI label Aug 23, 2026
@SolitaryThinker
SolitaryThinker force-pushed the skip-cpu-offload-on-unified-memory branch 2 times, most recently from fb1c107 to 0fa8b1f Compare August 26, 2026 05:10
@SolitaryThinker

Copy link
Copy Markdown
Collaborator

/merge

@github-actions github-actions Bot added the ready PR is ready to merge label Aug 26, 2026
KyleNeverGivesUp and others added 4 commits August 25, 2026 22:26
On integrated CUDA and MPS devices, host and accelerator allocations share one physical pool. Text-encoder CPU offload therefore frees no memory and can briefly retain both copies; on DGX Spark this made the 63 GB MiniMax H3 encoder spend 5m49s sharding before the process was killed.

Apply the decision to the worker-local FastVideoArgs only after that worker binds its selected device. This avoids creating a CUDA context in the parent process and avoids classifying every worker from device 0. Clearing the text_encoder_cpu_offload flag keeps load placement, FSDP, generic text encoding, and MiniMax H3 conditioning consistent.

Keep a role-aware loader fallback for direct text-encoder callers. ImageEncoderLoader shares this implementation, so its role remains unchanged here and can opt into the same policy separately. CUDA classification follows cudaDeviceProp integrated, while discrete and unknown platforms preserve existing behavior.
@SolitaryThinker
SolitaryThinker force-pushed the skip-cpu-offload-on-unified-memory branch from 5345aa5 to 450aad5 Compare August 26, 2026 05:26
@SolitaryThinker

Copy link
Copy Markdown
Collaborator

/test full

1 similar comment
@SolitaryThinker

Copy link
Copy Markdown
Collaborator

/test full

@KyleNeverGivesUp

Copy link
Copy Markdown
Contributor Author

The full suite run from /merge left two lanes red, bar-chart-ssim-tests and test-tube-training-tests. I cannot reproduce either on the hardware I have, so I am posting what I checked in case the Buildkite output makes it obvious to someone who can read it.

Why I cannot reproduce them. My box is a single GB10. Both lanes need more, and the training one also refuses to run on this device at all:

  • fastvideo/tests/training/Vanilla/test_training_loss.py launches torchrun with --num_gpus 2 --sp_size 2 --tp_size 2.
  • The same file selects its reference summary by device name and ends the branch with raise ValueError(f"Unknown device: {device_name}") after A40, L40S, H200, and B200, so GB10 raises before any comparison happens.
  • The SSIM suite's MINIMAX_H3_PARAMS uses num_gpus: 4, sp_size: 4 and resolves a per-device reference folder that does not exist for GB10.

What I could check. I ran the unit lane's pytest command on a GB10 for this branch and for its merge base. The failures there are identical between the two, so nothing in the unit lane is caused by these changes. I could not find a second pull request that has run either of the two failing lanes, so there is no comparison point for them.

One thing that may matter for the SSIM lane. The build came from /merge through ci-slash-commands.yml, which sets TEST_SCOPE: "full". Only ci-trigger-full-suite.yml runs plan_merge_ci.py and passes MERGE_SSIM_TESTS, and that workflow failed at its cancel step before the planner ran, which is the subject of #1762. With no FASTVIDEO_SSIM_TEST_FILES and a non-merge scope, .buildkite/scripts/lanes/ssim.sh falls back to selected=all, so this ran the entire SSIM suite rather than the subset the planner would have chosen for this diff. That may be worth ruling out before treating the result as a regression from this branch.

What would help. The failing output from those two lanes. If they were already failing before this branch, or if the SSIM lane failed on a test unrelated to the loader and platform changes here, that closes it. If they are real, the most likely candidate in this diff is the ctypes probe added to fastvideo/platforms/cuda.py, since its own comment notes that driver initialization is not pre-fork safe and these are the only two multi-process, multi-GPU lanes in the suite. That is a guess, not a finding.

SolitaryThinker pushed a commit to KyleNeverGivesUp/FastVideo that referenced this pull request Aug 26, 2026
…fails

The Trigger Merge Gate workflow cancels stale Buildkite builds in step 2 and
starts the merge-gate build in step 7. A step failure ends the job, so when
the cancel step dies the gate build is never started at all. The check then
goes red for a reason unrelated to the pull request, and nothing in the log
distinguishes "the tests failed" from "the tests never ran".

That is what happened on hao-ai-lab#1710:

    jq: error (at <stdin>:1): Cannot index string with string "env"
    Process completed with exit code 5

`curl` is called without `--fail-with-body`, so an HTTP error is treated as
success and its body is piped onward. Buildkite answers a rate limit or an
unauthorized read with an object such as {"message": "Not Found"}, and `.[]`
over an object yields its values, so `.env` then runs against a string and jq
exits non-zero.

Three changes, all to the same step:

`continue-on-error: true`, because cancelling stale builds only saves agent
time. Failing to cancel wastes an agent; failing to trigger means untested
code, and step 7 keeps its hard failure.

`--fail-with-body` on the lookup, with the response echoed as a warning, so a
lookup that fails says why instead of feeding an error body to the parser.

`if type == "array" then .[] else empty end` in the filter, so a response that
is not a build list yields no matches rather than aborting.

Verified against six response shapes: a matching build, a build for another
PR, the error object from hao-ai-lab#1710, an empty array, a build with no env, and a
bare string. The first returns the build number and the rest return nothing,
where the error object previously exited 5.
@SolitaryThinker
SolitaryThinker merged commit c9c5585 into hao-ai-lab:main Aug 26, 2026
31 of 37 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready PR is ready to merge scope: inference Inference pipeline, serving, CLI scope: infra CI, tests, Docker, build scope: model Model architecture (DiTs, encoders, VAEs) type: perf Performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants