Avoid silent weights corruption when loading Nemotron Nano VL with reusable-buffer loaders like runai distributed streaming (#42244)

Signed-off-by: Noa Neria <nneria@nvidia.com>
This commit is contained in:
Noa Neria
2026-05-11 12:03:14 +00:00
committed by GitHub
parent 617239b70c
commit ac062147fa
2 changed files with 54 additions and 27 deletions
@@ -53,6 +53,19 @@ class _VisionModel:
self.loaded_weights = list(weights)
class _FakeTensor:
"""Sentinel stand-in for torch.Tensor in load_weights tests. Supports the
.detach().clone() chain used by load_weights for buffered mm weights;
both methods return self so identity (and the existing equality
assertions) are preserved through cloning."""
def detach(self):
return self
def clone(self):
return self
def test_nano_nemotron_vl_skips_multimodal_weights_in_text_only_mode():
model = object.__new__(NemotronH_Nano_VL_V2)
language_model = _LanguageModel()
@@ -86,7 +99,7 @@ def test_nano_nemotron_vl_loads_vision_weights_without_sound_encoder():
object.__setattr__(model, "sound_encoder", None)
language_weight = object()
vision_weight = object()
vision_weight = _FakeTensor()
model.load_weights(
[
("language_model.layers.0.weight", language_weight),
+40 -26
View File
@@ -1518,37 +1518,51 @@ class NemotronH_Nano_VL_V2(
def is_sound_weights(name: str) -> bool:
return name.startswith("sound")
# Separate weights by component
llm_weights = []
vision_weights = []
sound_weights = []
# LLM weights (the bulk of the model) are streamed lazily through a
# generator so each tensor is copied into its parameter before the
# iterator advances, avoiding stale-reference corruption with
# reusable-buffer streamers. The smaller mm components (mlp1, vision,
# sound) are detach+cloned on append so they are independent of any
# reusable buffer the streamer may use, then loaded after the LLM.
adapter_weights: list[tuple[str, torch.Tensor]] = []
vision_weights: list[tuple[str, torch.Tensor]] = []
sound_weights: list[tuple[str, torch.Tensor]] = []
for name, w in weights:
if is_llm(name):
# Strip 'language_model.' prefix for LLM weights
llm_weights.append((".".join(name.split(".")[1:]), w))
elif is_adapter_weights((name, w)):
if not load_multimodal_weights:
continue
def llm_weights_gen():
for name, w in weights:
if is_llm(name):
# Strip 'language_model.' prefix for LLM weights
yield ".".join(name.split(".")[1:]), w
elif is_adapter_weights((name, w)):
if not load_multimodal_weights:
continue
trimmed_name = ".".join(name.split(".")[1:])
adapter_weights.append((trimmed_name, w.detach().clone()))
elif is_vision_weights(name):
if not load_multimodal_weights:
continue
# Convert: vision_model.radio_model.* → radio_model.*
hf_key = name[len("vision_model.") :]
vision_weights.append((hf_key, w.detach().clone()))
elif is_sound_weights(name):
if not load_multimodal_weights:
continue
assert self.sound_encoder is not None
sound_weights.append((name, w.detach().clone()))
# Fully drain the generator so every mm tensor is buffered, even if
# the LLM loader stops iterating early.
llm_weights_iter = llm_weights_gen()
self.language_model.load_weights(llm_weights_iter)
for _ in llm_weights_iter:
pass
if load_multimodal_weights:
for trimmed_name, w in adapter_weights:
# Load vision-language adapter weights directly
trimmed_name = ".".join(name.split(".")[1:])
param = adapter_dict[trimmed_name]
with torch.no_grad():
default_weight_loader(param, w)
elif is_vision_weights(name):
if not load_multimodal_weights:
continue
# Convert: vision_model.radio_model.* → radio_model.*
hf_key = name[len("vision_model.") :] # Remove "vision_model." prefix
vision_weights.append((hf_key, w))
elif is_sound_weights(name):
if not load_multimodal_weights:
continue
assert self.sound_encoder is not None
sound_weights.append((name, w))
self.language_model.load_weights(llm_weights)
if load_multimodal_weights:
self.vision_model.load_weights(vision_weights)
if self.sound_encoder is not None and len(sound_weights) > 0:
self.sound_encoder.load_weights(sound_weights)