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+
6682def _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
14441509def _convert_checkpoint_with_transformers (
0 commit comments