|
37 | 37 | from cosmos_framework.inference.common.inference import Inference, sync_distributed_errors |
38 | 38 | from cosmos_framework.inference.common.init import get_rank, get_world_size |
39 | 39 | from cosmos_framework.inference.model import Cosmos3OmniConfig, Cosmos3OmniModel |
| 40 | +from cosmos_framework.inference.offloading import OffloadPipeline |
40 | 41 | from cosmos_framework.inference.vision import ( |
41 | 42 | build_conditioned_video_batch, |
42 | 43 | build_image_edit_batch, |
|
59 | 60 |
|
60 | 61 | UpsampleTask = Literal["t2i", "t2v", "i2v"] |
61 | 62 |
|
| 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 | + |
62 | 148 |
|
63 | 149 | _BatchItem = TypeVar("_BatchItem") |
64 | 150 |
|
@@ -1071,6 +1157,44 @@ def _create(cls, setup_args: SetupArgs, **kwargs: Any) -> Self: |
1071 | 1157 | model.set_up_scheduler_and_sampler() |
1072 | 1158 | log.debug(f"Sampler overridden to: {sampler_override}") |
1073 | 1159 |
|
| 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 | + |
1074 | 1198 | vae_decode_stream: torch.cuda.Stream | None = None |
1075 | 1199 | if setup_args.use_separate_pipeline_vision_decode_gpu: |
1076 | 1200 | # 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: |
1468 | 1592 |
|
1469 | 1593 | with self._get_timer(f"{self.model.__class__.__name__}.decode"): |
1470 | 1594 | 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) |
1471 | 1601 | decoded_vision = [decode_vision(vision) for vision in output_vision] |
1472 | 1602 | outputs["vision"] = [cast(torch.Tensor, vision) for vision in decoded_vision] |
1473 | 1603 | if self.vae_decode_stream is not None: |
|
0 commit comments