Skip to content

Commit 194e69c

Browse files
added initial offloading implementation
Signed-off-by: Rahul Steiger <rsteiger@nvidia.com>
1 parent cbaf5b9 commit 194e69c

15 files changed

Lines changed: 1545 additions & 125 deletions

File tree

.agents/skills/cosmos3-inference/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ All paths below are relative to the cosmos3 package root (`../../../` from this
3030
| Which model should I use? (Nano vs Super, memory, shift) | `README.md` § Models |
3131
| Which modality? (t2i, t2v, i2v, examples) | `README.md` § Modalities |
3232
| What parallelism preset? (latency vs throughput) | `README.md` § Inference |
33+
| How do I lower GPU memory / offload to CPU? (`--offload-stages`) | `docs/inference.md` § CPU Offloading |
3334
| What input fields are available? (prompt, vision_path, num_frames, ...) | `docs/inference.md` § Sample Arguments |
3435
| What are the default parameter values? | `cosmos_framework/inference/defaults/<model_mode>/sample_args.json` (per-modality JSON) |
3536
| How do I use custom defaults? | `docs/inference.md` § Custom Defaults |

.claude/skills/cosmos3-inference/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ All paths below are relative to the cosmos3 package root (`../../../` from this
3030
| Which model should I use? (Nano vs Super, memory, shift) | `README.md` § Models |
3131
| Which modality? (t2i, t2v, i2v, examples) | `README.md` § Modalities |
3232
| What parallelism preset? (latency vs throughput) | `README.md` § Inference |
33+
| How do I lower GPU memory / offload to CPU? (`--offload-stages`) | `docs/inference.md` § CPU Offloading |
3334
| What input fields are available? (prompt, vision_path, num_frames, ...) | `docs/inference.md` § Sample Arguments |
3435
| What are the default parameter values? | `cosmos_framework/inference/defaults/<model_mode>/sample_args.json` (per-modality JSON) |
3536
| How do I use custom defaults? | `docs/inference.md` § Custom Defaults |

cosmos_framework/inference/args_test.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,32 @@ def check_model_equal(actual: pydantic.BaseModel, expected: pydantic.BaseModel):
129129
check_model_equal(OmniSetupOverrides.model_validate(args.model_dump()).build_setup(), args)
130130

131131

132+
def test_offload_stages(tmp_path: Path):
133+
def _build(**kwargs):
134+
return OmniSetupOverrides(
135+
checkpoint_path=DEFAULT_CHECKPOINT_NAME,
136+
output_dir=tmp_path / "outputs",
137+
**kwargs,
138+
).build_setup()
139+
140+
# Default: offloading disabled.
141+
args = _build()
142+
assert args.offload_stages == ()
143+
144+
# Arena stages round-trip through build_setup.
145+
args = _build(offload_stages=("reasoner", "generator", "vae"))
146+
assert args.offload_stages == ("reasoner", "generator", "vae")
147+
148+
# Guardrail offloading is a separate flag, not an --offload-stages value.
149+
for bad in (("guardrails",), ("bogus",)):
150+
with pytest.raises(pydantic.ValidationError):
151+
OmniSetupOverrides(
152+
checkpoint_path=DEFAULT_CHECKPOINT_NAME,
153+
output_dir=tmp_path / "outputs",
154+
offload_stages=bad,
155+
)
156+
157+
132158
def test_sample_args(tmp_path: Path):
133159
setup_args = OmniSetupOverrides(
134160
checkpoint_path=DEFAULT_CHECKPOINT_NAME,

cosmos_framework/inference/common/args.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@
4747
Training = Suppress
4848

4949

50+
# Single-GPU CPU-offload stages selectable via ``--offload-stages``. (Guardrail
51+
# offloading has its own dedicated flag, ``--offload-guardrail-models``.)
52+
OffloadStage = Literal["reasoner", "generator", "vae"]
53+
54+
5055
IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp"]
5156
VIDEO_EXTENSIONS = [".mp4"]
5257
MEDIA_EXTENSIONS = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS
@@ -643,6 +648,7 @@ class SetupArgs(ABC, CheckpointArgs, ParallelismArgs, GuardrailArgs):
643648
warmup: pydantic.NonNegativeInt
644649
max_model_len: pydantic.PositiveInt | None
645650
max_num_seqs: pydantic.PositiveInt | None
651+
offload_stages: tuple[OffloadStage, ...]
646652

647653
# Subclass must implement these fields/methods
648654
# ------------------------------------------------------------
@@ -693,6 +699,14 @@ class SetupOverrides(ABC, CheckpointOverrides, ParallelismOverrides, GuardrailOv
693699
max_num_seqs: pydantic.PositiveInt | None = 1
694700
"""Maximum number of sequences per batch. When set, samples are packed into
695701
batches by number of sequences."""
702+
offload_stages: tuple[OffloadStage, ...] = ()
703+
"""Single-GPU CPU-offload stages. Each named component is offloaded to pinned CPU
704+
storage and staged into one reusable GPU arena only while in use, reducing peak
705+
GPU memory. Choices: 'reasoner' / 'generator' (the MoT towers — enabling either
706+
runs the understanding pathway once as a prefill that caches the per-layer K/V, then
707+
runs the denoise loop generator-only) and 'vae' (the vision tokenizer, staged around
708+
encode/decode). Empty = off (joint path, unchanged). Single-GPU only; incompatible
709+
with CUDA graphs. Guardrail offloading has its own flag, --offload-guardrail-models."""
696710

697711
def _build_setup(self):
698712
pass

cosmos_framework/inference/inference.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from cosmos_framework.inference.common.inference import Inference, sync_distributed_errors
3838
from cosmos_framework.inference.common.init import get_rank, get_world_size
3939
from cosmos_framework.inference.model import Cosmos3OmniConfig, Cosmos3OmniModel
40+
from cosmos_framework.inference.offloading import OffloadPipeline
4041
from cosmos_framework.inference.vision import (
4142
build_conditioned_video_batch,
4243
build_image_edit_batch,
@@ -59,6 +60,91 @@
5960

6061
UpsampleTask = Literal["t2i", "t2v", "i2v"]
6162

63+
# Reasoner/generator CPU-offload group names (single-GPU). The two MoT pathways
64+
# are interleaved per layer, so the groups gather the per-layer understanding vs
65+
# generation submodules (plus the generation-side diffusion heads) by reference.
66+
REASONER_OFFLOAD_PART = "reasoner"
67+
GENERATOR_OFFLOAD_PART = "generator"
68+
VAE_OFFLOAD_PART = "vae"
69+
# Offload stages that ride the diffusion GPU arena (``guardrails`` is handled
70+
# separately by the guardrail runners' own CPU-offload path).
71+
ARENA_OFFLOAD_PARTS = (REASONER_OFFLOAD_PART, GENERATOR_OFFLOAD_PART, VAE_OFFLOAD_PART)
72+
73+
74+
def build_omni_offload_parts(model: OmniMoTModel) -> dict[str, "torch.nn.Module"]:
75+
"""Group the in-tree Cosmos3 model into offloadable module groups.
76+
77+
The MoT packs both pathways into a single network with dual projections per
78+
layer, so rather than pointing at whole towers we gather the per-layer
79+
understanding (``reasoner``) vs generation (``generator``) submodules into two
80+
synthetic ``nn.ModuleList`` groups (referencing the existing submodule objects,
81+
so the module tree, checkpoint loading, and the joint forward are unaffected).
82+
The generation-side diffusion heads (``vae2llm`` / ``llm2vae`` / ``time_embedder``
83+
and the optional action/sound heads) join the generator group because the
84+
reasoner prefill skips vision/action/sound encode and decode entirely. The
85+
``vae`` group is the vision tokenizer network (staged around encode/decode).
86+
87+
All groups are disjoint (distinct module instances), as ``ModuleOffloadManager``
88+
requires. Returns every known group; callers stage only the requested subset.
89+
Modules left out of every staged group stay GPU-resident.
90+
"""
91+
import torch.nn as nn
92+
93+
net = model.net
94+
lm = net.language_model.model # Qwen3VL(Moe)TextModel: embed_tokens, layers, norm, norm_moe_gen
95+
96+
reasoner_modules: list[nn.Module] = [lm.embed_tokens, lm.norm]
97+
generator_modules: list[nn.Module] = [lm.norm_moe_gen]
98+
99+
# Generation-side diffusion encode/decode heads (only present when enabled).
100+
for head_name in ("time_embedder", "vae2llm", "llm2vae", "action2llm", "llm2action", "sound2llm", "llm2sound"):
101+
head = getattr(net, head_name, None)
102+
if isinstance(head, nn.Module):
103+
generator_modules.append(head)
104+
105+
for raw_layer in lm.layers:
106+
# Unwrap torch.compile's OptimizedModule so we reference the real submodules
107+
# (the manager rebinds their parameters between stages, which the compiled
108+
# graph picks up as graph inputs on the next call).
109+
layer = getattr(raw_layer, "_orig_mod", raw_layer)
110+
attn = layer.self_attn
111+
reasoner_modules += [
112+
attn.q_proj,
113+
attn.k_proj,
114+
attn.v_proj,
115+
attn.o_proj,
116+
attn.q_norm,
117+
attn.k_norm,
118+
layer.input_layernorm,
119+
layer.post_attention_layernorm,
120+
layer.mlp,
121+
]
122+
generator_modules += [
123+
attn.q_proj_moe_gen,
124+
attn.k_proj_moe_gen,
125+
attn.v_proj_moe_gen,
126+
attn.o_proj_moe_gen,
127+
attn.q_norm_moe_gen,
128+
attn.k_norm_moe_gen,
129+
layer.input_layernorm_moe_gen,
130+
layer.post_attention_layernorm_moe_gen,
131+
layer.mlp_moe_gen,
132+
]
133+
134+
parts: dict[str, nn.Module] = {
135+
REASONER_OFFLOAD_PART: nn.ModuleList(reasoner_modules),
136+
GENERATOR_OFFLOAD_PART: nn.ModuleList(generator_modules),
137+
}
138+
# The vision tokenizer's underlying network (the offloadable VAE weights).
139+
# ``tokenizer_vision_gen.model`` may itself be a thin (non-Module) wrapper whose
140+
# ``.model`` is the actual nn.Module (e.g. Wan2pt2VAEInterface -> WanVAE -> WanVAE_).
141+
vae = getattr(model.tokenizer_vision_gen, "model", None)
142+
if vae is not None and not isinstance(vae, nn.Module):
143+
vae = getattr(vae, "model", None)
144+
if isinstance(vae, nn.Module):
145+
parts[VAE_OFFLOAD_PART] = vae
146+
return parts
147+
62148

63149
_BatchItem = TypeVar("_BatchItem")
64150

@@ -1071,6 +1157,44 @@ def _create(cls, setup_args: SetupArgs, **kwargs: Any) -> Self:
10711157
model.set_up_scheduler_and_sampler()
10721158
log.debug(f"Sampler overridden to: {sampler_override}")
10731159

1160+
# Single-GPU CPU offloading (opt-in via --offload-stages). The diffusion parts
1161+
# (reasoner / generator / vae) time-share one reusable GPU arena; 'guardrails'
1162+
# is handled separately by the guardrail runners. Default-off; when off the
1163+
# model's ``memory`` stays None (joint path, unchanged).
1164+
arena_stages = tuple(s for s in setup_args.offload_stages if s in ARENA_OFFLOAD_PARTS)
1165+
if arena_stages:
1166+
if compile_config.use_cuda_graphs:
1167+
raise NotImplementedError(
1168+
"CPU offloading is incompatible with CUDA graphs (staging rebinds weight "
1169+
"tensors between calls, which breaks captured static addresses). "
1170+
"Disable --use-cuda-graphs or --offload-stages."
1171+
)
1172+
world = setup_args.dp_shard_size * setup_args.cp_size * setup_args.cfgp_size
1173+
if world != 1:
1174+
raise NotImplementedError(
1175+
f"CPU offloading is single-GPU only (dp_shard*cp*cfgp must be 1, got {world})."
1176+
)
1177+
available_parts = build_omni_offload_parts(model)
1178+
missing = [s for s in arena_stages if s not in available_parts]
1179+
if missing:
1180+
raise ValueError(f"Requested offload stage(s) {missing} are unavailable for this model.")
1181+
offloader = OffloadPipeline(
1182+
stages=arena_stages,
1183+
parts={s: available_parts[s] for s in arena_stages},
1184+
device=torch.device("cuda", torch.cuda.current_device()),
1185+
pin_memory=True,
1186+
)
1187+
offloader.initialize()
1188+
# Drive staging from the model/decode sites without leaking inference-side
1189+
# machinery into the model: callers invoke ``_offload_stage_fn(part)``, which
1190+
# is a no-op for parts that aren't being offloaded (so call sites are uniform).
1191+
model._offloader = offloader # keep alive
1192+
model._offload_stage_fn = lambda part: offloader.context(part) if offloader.has_part(part) else None
1193+
model._reasoner_generator_split = bool(
1194+
{REASONER_OFFLOAD_PART, GENERATOR_OFFLOAD_PART} & set(arena_stages)
1195+
)
1196+
log.info(f"Enabled single-GPU CPU offloading for stages: {', '.join(arena_stages)}.")
1197+
10741198
vae_decode_stream: torch.cuda.Stream | None = None
10751199
if setup_args.use_separate_pipeline_vision_decode_gpu:
10761200
# The CP/CFGP ranks are partitioned into replica-local groups of size
@@ -1468,6 +1592,12 @@ def decode_vision(vision_latent: torch.Tensor) -> torch.Tensor:
14681592

14691593
with self._get_timer(f"{self.model.__class__.__name__}.decode"):
14701594
output_vision = outputs.pop("vision")
1595+
# Stage the VAE onto the GPU arena for decode (no-op unless 'vae' is an
1596+
# offloaded stage). The denoise loop left the generator staged; this
1597+
# rebinds it back to CPU and brings the VAE in.
1598+
_stage_fn = getattr(self.model, "_offload_stage_fn", None)
1599+
if _stage_fn is not None:
1600+
_stage_fn(VAE_OFFLOAD_PART)
14711601
decoded_vision = [decode_vision(vision) for vision in output_vision]
14721602
outputs["vision"] = [cast(torch.Tensor, vision) for vision in decoded_vision]
14731603
if self.vae_decode_stream is not None:

0 commit comments

Comments
 (0)