Skip to content

Commit ae64756

Browse files
committed
fix(checkpoint): support single-GPU Nemotron-H MoE LoRA checkpoint load
Loading a merged-expert Nemotron-H MoE checkpoint through the default DCP / set_model_state_dict path transiently materializes a second on-device copy of the expert weights, which OOMs a 30B-class model on a single 80GB GPU. Checkpointer.load now routes single-device custom-model safetensors through the frugal full-state path (load to CPU, merge from_hf on CPU, copy into the model), keeping device memory at ~model size. _load_full_state_dict_into_model normalizes stray real (CPU) buffers left behind by custom-model meta materialization onto the parameter device (avoids 'Multiple devices found'), and uses plain load_state_dict for non-DTensor models so the full state dict is not moved on-device a second time. Adds a [nemotron-singlegpu-lora] note plus per-site tags documenting these single-device special cases, links the exercising recipe (examples/llm_finetune/nemotron/nemotron_nano_v3_singlegpu_lora.yaml), and flags the load path for a future refactor. Signed-off-by: Alexandros Koumparoulis <akoumparouli@nvidia.com>
1 parent 762f298 commit ae64756

1 file changed

Lines changed: 67 additions & 2 deletions

File tree

nemo_automodel/components/checkpoint/checkpointing.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,22 @@
6363
from transformers.tokenization_utils_base import PreTrainedTokenizerBase
6464

6565

66+
# NOTE [nemotron-singlegpu-lora]: the branches tagged with this marker below exist to make
67+
# single-GPU LoRA SFT of merged-expert Nemotron-H MoE (30B-class) fit on one 80GB GPU. The
68+
# default DCP / set_model_state_dict load path transiently materializes a second on-device
69+
# copy of the (merged) expert weights, which OOMs when the whole model lives on one device.
70+
# The affected sites are:
71+
# * Checkpointer.load -- route single-device custom safetensors through the
72+
# frugal full-state path instead of DCP
73+
# * _load_full_state_dict_into_model -- normalize stray real (CPU) buffers onto the param
74+
# device, and use plain load_state_dict when the model
75+
# is not DTensor-sharded
76+
# Exercised by: examples/llm_finetune/nemotron/nemotron_nano_v3_singlegpu_lora.yaml
77+
# These are point fixes bolted onto an already-overloaded load path; a future checkpoint
78+
# refactor should consolidate the single-device vs. sharded loading logic into one place.
79+
# `grep -n nemotron-singlegpu-lora` finds every affected site.
80+
81+
6682
def _is_geq_torch_2_9() -> bool:
6783
"""
6884
Check if the current torch version is greater than or equal to 2.9.0.
@@ -442,10 +458,28 @@ def load_model(
442458
# the broadcast_from_rank0 hang where rank 0's synchronous CPU→GPU copies
443459
# fall behind other ranks' async allocations.
444460
is_safetensors = _is_safetensors_checkpoint(model_path)
461+
# [nemotron-singlegpu-lora] (see module note at top of file)
462+
# Custom models (e.g. NemotronH) normally take the DCP path below, which converts the
463+
# model's state dict to_hf to build load destinations. For merged-expert MoE models that
464+
# transiently materializes a second copy of the expert weights on-device, which OOMs when
465+
# the whole model lives on one GPU. On a single device there is no DTensor sharding, so the
466+
# frugal full-state path (load to CPU, from_hf-merge on CPU, copy into the model) is correct
467+
# and keeps device memory at ~model size — letting 30B-class MoE LoRA SFT fit on one 80GB GPU.
468+
# World size inline (not via components.distributed) so the checkpoint component stays
469+
# independent per the import-linter contract.
470+
if torch.distributed.is_initialized():
471+
world_size = torch.distributed.get_world_size()
472+
else:
473+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
474+
single_device_custom_safetensors = is_safetensors and _is_custom_model(model_state.model[0]) and world_size == 1
445475
if (
446476
is_init_step
447477
and len(model_state.model) == 1
448-
and (_is_bin_checkpoint(model_path) or (is_safetensors and not _is_custom_model(model_state.model[0])))
478+
and (
479+
_is_bin_checkpoint(model_path)
480+
or (is_safetensors and not _is_custom_model(model_state.model[0]))
481+
or single_device_custom_safetensors
482+
)
449483
):
450484
t0 = time.monotonic()
451485
weights_only = not _is_remote_code_model(model_state.model[0])
@@ -1429,6 +1463,22 @@ def _load_full_state_dict_into_model(
14291463
if key not in state_dict:
14301464
state_dict[key] = torch.tensor([], dtype=torch.uint8)
14311465

1466+
# [nemotron-singlegpu-lora] (see module note at top of file)
1467+
# set_model_state_dict(full_state_dict=True) requires every parameter/buffer of a
1468+
# part to live on a single device. Custom models (e.g. NemotronH/Mamba) can leave a
1469+
# concrete CPU buffer behind after meta materialization (initialize_model_weights only
1470+
# relocates *meta* buffers), which would trip "Multiple devices found". Normalize any
1471+
# stray real buffer onto the part's single (non-meta) parameter device. This is a no-op
1472+
# for HF models, which are already device-uniform.
1473+
for part in model_parts:
1474+
param_devices = {p.device for p in part.parameters() if p.device.type != "meta"}
1475+
if len(param_devices) == 1:
1476+
target_device = next(iter(param_devices))
1477+
for module in part.modules():
1478+
for buf_name, buf in list(module._buffers.items()):
1479+
if buf is not None and buf.device.type != "meta" and buf.device != target_device:
1480+
module._buffers[buf_name] = buf.to(target_device)
1481+
14321482
# full_state_dict=True WITHOUT broadcast_from_rank0: every rank already
14331483
# has the full checkpoint, so _distribute_state_dict slices each rank's
14341484
# local DTensor shard independently -- zero NCCL collectives.
@@ -1437,8 +1487,23 @@ def _load_full_state_dict_into_model(
14371487
full_state_dict=True,
14381488
)
14391489

1490+
try:
1491+
from torch.distributed.tensor import DTensor
1492+
except ImportError: # pragma: no cover - older torch
1493+
from torch.distributed._tensor import DTensor
1494+
1495+
# [nemotron-singlegpu-lora] (see module note at top of file)
14401496
for part in model_parts:
1441-
set_model_state_dict(part, model_state_dict=state_dict, options=options)
1497+
if any(isinstance(p, DTensor) for p in part.parameters()):
1498+
# Sharded model (FSDP/TP): set_model_state_dict slices each rank's local
1499+
# DTensor shard from the full state dict.
1500+
set_model_state_dict(part, model_state_dict=state_dict, options=options)
1501+
else:
1502+
# Single-device (non-sharded) model: copy tensor-by-tensor with plain
1503+
# load_state_dict so device memory stays at ~model size. set_model_state_dict's
1504+
# _distribute_state_dict would instead move the *entire* state dict onto the
1505+
# device (a second full copy), OOMing a 30B model on one 80GB GPU.
1506+
part.load_state_dict(state_dict, strict=False)
14421507

14431508

14441509
def _convert_checkpoint_with_transformers(

0 commit comments

Comments
 (0)