community engineering note Code (fork) Model

Cosmos3-Edge · single-GPU post-training

Fitting a 4B world-model fine-tune into 24 GB — without giving up training quality

NVIDIA's vision_sft_edge recipe for Cosmos3-Edge is written for 8×H100. Run as-is on one GPU it needs ~36 GiB. We traced every byte, proved which cuts are mathematically free, and landed the whole thing on a 22 GiB A10G — same effective batch, same EMA math, zero sample truncation.

Peak GPU memory

36 → 20 GiB

−44% vs stock recipe on one GPU

Effective batch parity

282 ≈ 283

samples/step vs stock 8-GPU config

EMA divergence vs stock

0.0

trainable-key A/B test, exact equality

VAE latents

bit-identical

offloaded vs resident, torch.equal

1 · The problem

Cosmos3-Edge is a 4.5B-parameter omni world model (3.36B transformer tower + Wan2.2 VAE + vision encoder). Its supervised fine-tuning recipe trains a 1.415B-parameter subset (keys_to_select: the generation-pathway MoE MLPs, time embedder, and modality bridges) on packed video sequences — designed and tested on 8×H100 80 GB. On a single 24 GB card (22.06 GiB usable) the stock configuration is ~14 GiB over budget, and the naive fixes — cutting sequence length, dropping EMA — are exactly the ones that threaten training quality.

The goal: make it fit without changing what the model learns. Every lever below is either provably equivalent to stock or was rejected.

2 · Where the memory actually goes

We read the training code path end-to-end (with independent adversarial re-verification of each claim) and reconciled it against live measurements. Hover any segment for details.

GPU memory by component — stock vs optimized

GiB on one A10G · dashed capacity line = 22.06 GiB usable · stock row is the code-derived estimate; optimized rows anchored to measured runs

ComponentStock (est.)L1 config (measured)Full stackWhat changed
Tower weights, bf16 (3.36B)6.36.36.3
EMA (fp32)12.500 (on CPU)full-tower GPU clone → pinned-host subset buffers, same math
Adam m+v, bf16 (1.415B trainable)5.35.35.3
Gradients, bf16 (trainable only)2.62.62.6
Wan2.2 VAE, bf161.31.30 (on CPU)moved to GPU only during encode; deterministic
CUDA context + buffers2.42.42.4
Activations (full act-ckpt)5.6 @45k tokens3.4 @12k3.4packing cap 45,056 → 12,288; batch restored via grad-accum
Total~3621.3 measured~20

3 · Three facts that make it free

These came out of the code audit; each was re-derived by an independent verification pass before we built on it.

The token cap never truncates

The packer bin-packs whole samples; an over-cap sample rides alone, never cut. Measured across all 1,222 Bridge clips: samples span 1,777–3,374 tokens, so a 12,288 cap drops nothing. The cap is purely an effective-batch knob.

Grad-accum compensation is exact

The tower is dense (no MoE routing) and normalizes with LayerNorm/RMSNorm only — no batch statistics. Cap 12,288 + grad_accum_iter=61 reproduces the stock effective batch: 282 vs 283 samples per optimizer step (0.4%).

EMA of a frozen weight is the weight

Only the 1.415B trainable params ever change; the frozen 1.95B params' EMA is identically their initial value. So an fp32 EMA over just the trainable subset — held in pinned CPU memory — is equivalent to the stock 12.5 GiB GPU clone.

4 · The design

Four levers, each zero-quality-risk. Both code changes are opt-in env flags — default off, stock behavior untouched.

LeverMechanismGPU savedQuality proof
L1 · packing cap + grad-accum max_sequence_length 45,056 → 12,288; grad_accum_iter 2 → 61 ~2–3 GiB same effective batch; zero drops (packing simulation over the full dataset)
L2+L3 · CPU-resident subset EMA
COSMOS_EMA_CPU_SUBSET=1
replaces the fp32 GPU net_ema clone with fp32 pinned-host buffers over trainable params; same foreach lerp, same beta schedule; checkpoint save reconstructs the full net_ema.* dict exactly 12.5 GiB 10-step A/B vs stock updater: trainable-key diff 0.0; save→load round-trip identical
L4 · VAE CPU round-trip
COSMOS_VAE_CPU_OFFLOAD=1
frozen Wan2.2 VAE parked on CPU; moved to GPU only inside encode/decode 1.3 GiB encode is deterministic (mean-only, no sampling): latents bit-identical, +0.4 s/call

Rejected on evidence (the anti-levers)

5 · Implementation

Everything lives on the feat/24gb-single-gpu-sft branch: two guarded code paths (+679/−22 lines), three recipe TOMLs, and launchers.

The heart of it — the CPU subset EMA update (same lerp the stock GPU worker runs):

# cosmos_framework/utils/generator/cpu_subset_ema.py
@torch.no_grad()
def update_average(self, net, beta):
    """ema = beta * ema + (1 - beta) * param  — identical math to the stock worker."""
    names = []
    for name, p in net.named_parameters():
        if name in self._buffers:                     # trainable subset only
            self._staging[name].copy_(dt2lt(p).detach(), non_blocking=True)  # async D2H
            names.append(name)
    torch.cuda.synchronize()
    targets = [self._buffers[n] for n in names]       # fp32, pinned host memory
    sources = [self._staging[n].to(torch.float32) for n in names]
    torch._foreach_mul_(targets, beta)
    torch._foreach_add_(targets, sources, alpha=1.0 - beta)

At checkpoint time the full net_ema.* state dict is reconstructed exactly: EMA buffers for trainable keys, fp32 casts of the live net for frozen keys (equal by definition). Save→load round-trips are verified identical, and ema_scope (used by validation/sampling) swaps the subset in and out losslessly.

Run it

git clone -b feat/24gb-single-gpu-sft https://github.com/linjiw/cosmos-framework
# ...standard cosmos-framework setup (uv sync --group cu128), stage dataset + DCP ckpt...

COSMOS_EMA_CPU_SUBSET=1 COSMOS_VAE_CPU_OFFLOAD=1 \
NPROC_PER_NODE=1 WAN_VAE_PATH=$WAN \
  bash examples/launch_sft_vision_edge_24gb.sh
# variants: MEMTEST=1 (fast memory smoke) · EMAVAL=1 (EMA-correctness config)

6 · Validation status

GateMethodResult
Config run trains sanely18 optimizer steps @ cap 12,288 on an empty GPUPASS — loss 2.1–3.6, finite grads, 0 drops
Peak memory measured2 s VRAM sampler across the full run21.3 GiB (L1 only) — above the 19.5 estimate; backward transient at 12k is real
VAE offload equivalenceencode a fixed clip, resident vs offloadedPASStorch.equal true; +0.38 s/round-trip
EMA equivalence (unit)10-step A/B vs stock DTensorFastEmaModelUpdaterPASS — trainable keys exactly equal; frozen keys exact (stock itself drifts 7e-9 lerping constants)
EMA save/load round-tripstate-dict reconstruction + reloadPASS — identical buffers
Integrated run, EMA on + both flagssmoke train with all levers activequeued — waits for a co-tenant-free window on our shared box
500-iter run + sample-quality evalfull fine-tune, export, i2v eval vs basepending the above

Honest caveats. The measured 21.3 GiB peak for L1-only exceeded our 19.5 GiB model — the backward transient at 12k tokens is bigger than block-boundary math suggests. With the full stack (~20 GiB) an empty 24 GB card fits with ~2 GiB of headroom; on a busy shared GPU we recommend cap 8,192 + grad_accum_iter=94 (identical effective batch, more slack). Single-GPU parity costs wall-clock: one A10G doing eight H100s' work runs the 500-iter recipe in roughly 2–3 days.