Skip to content

Commit 2bfe944

Browse files
materialize reasoner and generator on cpu instead of gpu
Signed-off-by: Rahul Steiger <rsteiger@nvidia.com>
1 parent 194e69c commit 2bfe944

3 files changed

Lines changed: 198 additions & 116 deletions

File tree

cosmos_framework/inference/inference.py

Lines changed: 72 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from cosmos_framework.tools.visualize.video import save_img_or_video
5252
from cosmos_framework.configs.base.defaults.compile import CompileConfig
5353
from cosmos_framework.configs.base.defaults.parallelism import ParallelismConfig
54-
from cosmos_framework.model.vfm.omni_mot_model import OmniMoTModel
54+
from cosmos_framework.model.vfm.omni_mot_model import OmniMoTModel, cpu_offload_materialization
5555
from cosmos_framework.model.vfm.vlm.qwen3_vl.utils import _SYSTEM_PROMPT_IMAGE_EDITING
5656
from cosmos_framework.model.vfm.upsampler.prompts import is_upsampled_prompt
5757

@@ -74,67 +74,19 @@
7474
def build_omni_offload_parts(model: OmniMoTModel) -> dict[str, "torch.nn.Module"]:
7575
"""Group the in-tree Cosmos3 model into offloadable module groups.
7676
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.
77+
The reasoner/generator partition is owned by the network
78+
(:meth:`Cosmos3VFMNetwork.offload_module_groups`) so the load-time CPU
79+
materialization and this runtime ``OffloadPipeline`` share one source of truth.
80+
Each group is wrapped in an ``nn.ModuleList`` referencing the existing submodule
81+
objects (no module-tree changes), and the ``vae`` group is added for the vision
82+
tokenizer network. All groups are disjoint; callers stage only the requested
83+
subset, and modules left out of every staged group stay GPU-resident.
9084
"""
9185
import torch.nn as nn
9286

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-
}
87+
groups = model.net.offload_module_groups() # {"reasoner": [...], "generator": [...]}
88+
parts: dict[str, nn.Module] = {name: nn.ModuleList(modules) for name, modules in groups.items()}
89+
13890
# The vision tokenizer's underlying network (the offloadable VAE weights).
13991
# ``tokenizer_vision_gen.model`` may itself be a thin (non-Module) wrapper whose
14092
# ``.model`` is the actual nn.Module (e.g. Wan2pt2VAEInterface -> WanVAE -> WanVAE_).
@@ -1102,60 +1054,68 @@ def _create(cls, setup_args: SetupArgs, **kwargs: Any) -> Self:
11021054
sampler_override = setup_args.sampler
11031055
parallelism_config = cls._get_parallelism_config(setup_args)
11041056
compile_config = cls._get_compile_config(setup_args)
1105-
if setup_args.checkpoint_type == CheckpointType.DCP and setup_args.config_file_type == ConfigFileType.MODULE:
1106-
from cosmos_framework.inference.common.config import save_config
1107-
from cosmos_framework.utils.vfm.model_loader import load_model_from_checkpoint
1108-
1109-
if not setup_args.experiment:
1110-
raise ValueError("'experiment' is required")
1111-
if not setup_args.config_file:
1112-
raise ValueError("'config_file' is required")
1113-
1114-
Cosmos3OmniModel.before_load_model()
1115-
model, config = load_model_from_checkpoint(
1116-
experiment_name=setup_args.experiment,
1117-
config_file=setup_args.config_file,
1118-
checkpoint_path=setup_args.checkpoint_path,
1119-
credential_path=setup_args.credential_path or None,
1120-
parallelism_config=attrs.asdict(parallelism_config),
1121-
compile_config=attrs.asdict(compile_config),
1122-
load_ema_to_reg=setup_args.use_ema_weights,
1123-
experiment_opts=[
1124-
*setup_args.experiment_overrides,
1125-
f"model.config.rectified_flow_inference_config.scheduler_type={sampler_override}",
1126-
],
1127-
use_cache_checkpoint=setup_args.checkpoint_cache_dir is not None,
1128-
cache_checkpoint_rootdir=str(setup_args.checkpoint_cache_dir or ""),
1129-
)
1130-
model = cast("OmniMoTModel", model)
1131-
Cosmos3OmniModel.after_load_model(model)
1132-
save_config(config, setup_args.output_dir)
1133-
else:
1134-
checkpoint_path = setup_args.download_checkpoint()
1135-
if setup_args.config_file_type == ConfigFileType.MODULE:
1136-
config = None
1057+
# Two-phase materialization for single-GPU CPU offloading: build the reasoner/
1058+
# generator towers directly on CPU during model construction so they never occupy
1059+
# GPU memory (the checkpoint shards load straight into CPU tensors). No-op when
1060+
# those stages aren't requested.
1061+
net_offload_parts = tuple(
1062+
s for s in setup_args.offload_stages if s in (REASONER_OFFLOAD_PART, GENERATOR_OFFLOAD_PART)
1063+
)
1064+
with cpu_offload_materialization(net_offload_parts):
1065+
if setup_args.checkpoint_type == CheckpointType.DCP and setup_args.config_file_type == ConfigFileType.MODULE:
1066+
from cosmos_framework.inference.common.config import save_config
1067+
from cosmos_framework.utils.vfm.model_loader import load_model_from_checkpoint
1068+
1069+
if not setup_args.experiment:
1070+
raise ValueError("'experiment' is required")
1071+
if not setup_args.config_file:
1072+
raise ValueError("'config_file' is required")
1073+
1074+
Cosmos3OmniModel.before_load_model()
1075+
model, config = load_model_from_checkpoint(
1076+
experiment_name=setup_args.experiment,
1077+
config_file=setup_args.config_file,
1078+
checkpoint_path=setup_args.checkpoint_path,
1079+
credential_path=setup_args.credential_path or None,
1080+
parallelism_config=attrs.asdict(parallelism_config),
1081+
compile_config=attrs.asdict(compile_config),
1082+
load_ema_to_reg=setup_args.use_ema_weights,
1083+
experiment_opts=[
1084+
*setup_args.experiment_overrides,
1085+
f"model.config.rectified_flow_inference_config.scheduler_type={sampler_override}",
1086+
],
1087+
use_cache_checkpoint=setup_args.checkpoint_cache_dir is not None,
1088+
cache_checkpoint_rootdir=str(setup_args.checkpoint_cache_dir or ""),
1089+
)
1090+
model = cast("OmniMoTModel", model)
1091+
Cosmos3OmniModel.after_load_model(model)
1092+
save_config(config, setup_args.output_dir)
11371093
else:
1138-
model_dict = setup_args.load_model_config_dict()
1139-
if setup_args.vlm_processor_from_checkpoint:
1140-
# Source the VLM processor from the loaded checkpoint's own
1141-
# bundled files instead of the repository hardcoded in the
1142-
# model config. Drops the redundant base-model download.
1143-
tokenizer_cfg = model_dict["config"]["vlm_config"]["tokenizer"]
1144-
tokenizer_cfg.pop("repository", None)
1145-
tokenizer_cfg.pop("revision", None)
1146-
tokenizer_cfg.pop("subdir", None)
1147-
tokenizer_cfg["tokenizer_type"] = str(checkpoint_path)
1148-
config = Cosmos3OmniConfig(model=model_dict)
1149-
model = Cosmos3OmniModel.from_pretrained_dcp(
1150-
checkpoint_path,
1151-
config=config,
1152-
parallelism_config=parallelism_config,
1153-
compile_config=compile_config,
1154-
).model
1155-
if model.config.rectified_flow_inference_config.scheduler_type != sampler_override:
1156-
model.config.rectified_flow_inference_config.scheduler_type = sampler_override
1157-
model.set_up_scheduler_and_sampler()
1158-
log.debug(f"Sampler overridden to: {sampler_override}")
1094+
checkpoint_path = setup_args.download_checkpoint()
1095+
if setup_args.config_file_type == ConfigFileType.MODULE:
1096+
config = None
1097+
else:
1098+
model_dict = setup_args.load_model_config_dict()
1099+
if setup_args.vlm_processor_from_checkpoint:
1100+
# Source the VLM processor from the loaded checkpoint's own
1101+
# bundled files instead of the repository hardcoded in the
1102+
# model config. Drops the redundant base-model download.
1103+
tokenizer_cfg = model_dict["config"]["vlm_config"]["tokenizer"]
1104+
tokenizer_cfg.pop("repository", None)
1105+
tokenizer_cfg.pop("revision", None)
1106+
tokenizer_cfg.pop("subdir", None)
1107+
tokenizer_cfg["tokenizer_type"] = str(checkpoint_path)
1108+
config = Cosmos3OmniConfig(model=model_dict)
1109+
model = Cosmos3OmniModel.from_pretrained_dcp(
1110+
checkpoint_path,
1111+
config=config,
1112+
parallelism_config=parallelism_config,
1113+
compile_config=compile_config,
1114+
).model
1115+
if model.config.rectified_flow_inference_config.scheduler_type != sampler_override:
1116+
model.config.rectified_flow_inference_config.scheduler_type = sampler_override
1117+
model.set_up_scheduler_and_sampler()
1118+
log.debug(f"Sampler overridden to: {sampler_override}")
11591119

11601120
# Single-GPU CPU offloading (opt-in via --offload-stages). The diffusion parts
11611121
# (reasoner / generator / vae) time-share one reusable GPU arena; 'guardrails'

cosmos_framework/model/vfm/mot/cosmos3_vfm_network.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,52 @@ def init_weights(self, buffer_device: torch.device | None):
269269

270270
self.language_model.init_weights(buffer_device=buffer_device)
271271

272+
def offload_module_groups(self) -> dict[str, list[torch.nn.Module]]:
273+
"""Partition the network into the reasoner vs generator CPU-offload groups.
274+
275+
The MoT interleaves both pathways per layer, so each group gathers the
276+
per-layer understanding (reasoner) vs generation submodules — plus the
277+
generation-side diffusion encode/decode heads, which the reasoner prefill
278+
never touches. Returns references to the existing submodule objects (no
279+
module-tree changes); the two groups are disjoint, as the offload manager
280+
and the two-phase materialization both require. Single source of truth for
281+
both the load-time CPU materialization and the runtime ``OffloadPipeline``.
282+
"""
283+
lm = self.language_model.model # Qwen3VL(Moe)TextModel
284+
reasoner: list[torch.nn.Module] = [lm.embed_tokens, lm.norm]
285+
generator: list[torch.nn.Module] = [lm.norm_moe_gen]
286+
for head_name in ("time_embedder", "vae2llm", "llm2vae", "action2llm", "llm2action", "sound2llm", "llm2sound"):
287+
head = getattr(self, head_name, None)
288+
if isinstance(head, torch.nn.Module):
289+
generator.append(head)
290+
for raw_layer in lm.layers:
291+
# Unwrap torch.compile's OptimizedModule so we reference the real submodules.
292+
layer = getattr(raw_layer, "_orig_mod", raw_layer)
293+
attn = layer.self_attn
294+
reasoner += [
295+
attn.q_proj,
296+
attn.k_proj,
297+
attn.v_proj,
298+
attn.o_proj,
299+
attn.q_norm,
300+
attn.k_norm,
301+
layer.input_layernorm,
302+
layer.post_attention_layernorm,
303+
layer.mlp,
304+
]
305+
generator += [
306+
attn.q_proj_moe_gen,
307+
attn.k_proj_moe_gen,
308+
attn.v_proj_moe_gen,
309+
attn.o_proj_moe_gen,
310+
attn.q_norm_moe_gen,
311+
attn.k_norm_moe_gen,
312+
layer.input_layernorm_moe_gen,
313+
layer.post_attention_layernorm_moe_gen,
314+
layer.mlp_moe_gen,
315+
]
316+
return {"reasoner": reasoner, "generator": generator}
317+
272318
def generate_reasoner_text(
273319
self,
274320
input_ids: torch.Tensor,

cosmos_framework/model/vfm/omni_mot_model.py

Lines changed: 80 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import collections
7+
import contextvars
78
import time
89
from contextlib import contextmanager
910
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
@@ -59,6 +60,66 @@
5960
from cosmos_framework.utils.vfm.parallelism import ParallelDims
6061

6162

63+
# Names of the network offload groups (from ``Cosmos3VFMNetwork.offload_module_groups``)
64+
# whose weights should be materialized directly on CPU at load time (two-phase
65+
# materialization), so they never occupy GPU memory. Set by the inference layer via
66+
# ``cpu_offload_materialization`` around model construction; read inside ``build_net``.
67+
_CPU_OFFLOAD_NET_PARTS: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
68+
"_cpu_offload_net_parts", default=()
69+
)
70+
71+
72+
@contextmanager
73+
def cpu_offload_materialization(net_parts: tuple[str, ...]):
74+
"""Materialize the named network offload groups on CPU during model construction.
75+
76+
Wrap model loading (``from_pretrained_dcp`` / ``load_model_from_checkpoint``) with
77+
this so the listed groups (e.g. ``("reasoner", "generator")``) are built directly on
78+
CPU and the checkpoint shards load into CPU tensors — they never touch the GPU. Empty
79+
``net_parts`` is a no-op (default joint materialization, unchanged).
80+
"""
81+
token = _CPU_OFFLOAD_NET_PARTS.set(tuple(net_parts))
82+
try:
83+
yield
84+
finally:
85+
_CPU_OFFLOAD_NET_PARTS.reset(token)
86+
87+
88+
def _offloaded_tensor_ids(net: torch.nn.Module, net_parts: tuple[str, ...]) -> set[int]:
89+
"""Collect ``id()`` of every parameter/buffer belonging to the offloaded groups."""
90+
groups = net.offload_module_groups()
91+
ids: set[int] = set()
92+
for part in net_parts:
93+
for module in groups.get(part, []):
94+
for tensor in (*module.parameters(recurse=True), *module.buffers(recurse=True)):
95+
ids.add(id(tensor))
96+
return ids
97+
98+
99+
def _materialize_meta_tensors(net: torch.nn.Module, device: torch.device, skip_ids: set[int] | None) -> None:
100+
"""Materialize every still-``meta`` parameter/buffer on ``device`` (skipping ``skip_ids``).
101+
102+
Allocates real empty tensors in place. ``skip_ids`` leaves the offloaded tensors on
103+
``meta`` for the first (GPU) pass so they sidestep the wasted random init, then a
104+
second pass with ``skip_ids=None`` lands them on CPU.
105+
"""
106+
for module in net.modules():
107+
for name, param in list(module._parameters.items()):
108+
if param is None or not param.is_meta:
109+
continue
110+
if skip_ids is not None and id(param) in skip_ids:
111+
continue
112+
module._parameters[name] = torch.nn.Parameter(
113+
torch.empty_like(param, device=device), requires_grad=param.requires_grad
114+
)
115+
for name, buffer in list(module._buffers.items()):
116+
if buffer is None or not buffer.is_meta:
117+
continue
118+
if skip_ids is not None and id(buffer) in skip_ids:
119+
continue
120+
module._buffers[name] = torch.empty_like(buffer, device=device)
121+
122+
62123
class OmniMoTModel(ImaginaireModel):
63124
"""
64125
Mixture of Transformers (MoT) model to be trained with the flow matching objective
@@ -241,12 +302,27 @@ def build_net(self, dtype: torch.dtype):
241302

242303
with misc.timer("meta to cuda and broadcast model states"):
243304
net = net.to(dtype=dtype)
244-
net.to_empty(device=DEVICE)
305+
cpu_offload_net_parts = _CPU_OFFLOAD_NET_PARTS.get() if DEVICE == Device.CUDA else ()
306+
if cpu_offload_net_parts:
307+
# Single-GPU CPU offloading: materialize only the non-offloaded modules on
308+
# the GPU and keep the offloaded towers on ``meta``, so ``init_weights``
309+
# skips their random init — pure waste here (they come entirely from the
310+
# checkpoint and have no non-persistent buffers) and crippling on CPU.
311+
_materialize_meta_tensors(
312+
net, torch.device("cuda"), skip_ids=_offloaded_tensor_ids(net, cpu_offload_net_parts)
313+
)
314+
else:
315+
net.to_empty(device=DEVICE)
316+
317+
# Weight init is only needed on CUDA (CPU/meta are for checkpoint conversion and
318+
# smoke tests). It initializes the non-offloaded modules and their non-persistent
319+
# buffers (e.g. RoPE); the offloaded towers stay on ``meta`` (init no-ops there).
245320
if DEVICE == Device.CUDA:
246-
# Weight initialization is not needed for other devices (cpu,
247-
# meta), since they are only for checkpoint conversion and smoke
248-
# tests.
249321
net.init_weights(buffer_device=DEVICE)
322+
if cpu_offload_net_parts:
323+
# Land the offloaded towers on CPU; ``dcp.load`` fills them next, so they
324+
# never occupy GPU memory.
325+
_materialize_meta_tensors(net, torch.device("cpu"), skip_ids=None)
250326
if getattr(self.config, "lora_enabled", False):
251327
self._init_lora_weights_post_materialization(net)
252328

0 commit comments

Comments
 (0)