Skip to content

Commit ae95458

Browse files
roboalchemistjoe-verkadaBlaizzy
authored
Add Llama Nemotron VL multimodal embedding model support (#54)
* Add Llama Bidirectional embedding model support Add support for bidirectional Llama-based embedding models (e.g. nvidia/llama-embed-nemotron-8b). Key changes: - New model file: mlx_embeddings/models/llama_bidirec.py - Imports TransformerBlock from mlx_lm for the Llama backbone - Bidirectional attention via full (non-causal) attention masking - Average pooling + L2 normalization for text embeddings - Unit test with synthetic config - README updated with new architecture * Add input_embeddings parameter to LlamaBidirectionalModel Allows passing pre-computed embeddings (e.g. with vision tokens injected) instead of input_ids. Required for the upcoming llama_nemotron_vl model which injects vision features into the token embedding stream. Backward compatible — existing callers are unaffected. * Add Llama Nemotron VL multimodal embedding model support Add support for nvidia/llama-nemotron-embed-vl-1b-v2, a 1.7B multimodal embedding model combining SigLIP vision encoder with bidirectional Llama. Key changes: - New model package: mlx_embeddings/models/llama_nemotron_vl/ - Reuses SiglipVisionTransformer from siglip.py for vision encoding - Reuses LlamaBidirectionalModel from llama_bidirec.py for text encoding - pixel_shuffle + MLP1 projection for vision-to-LLM token injection - Average pooling + L2 normalization for 2048-dim embeddings - Follows mlx-vlm InternVL reference patterns for pixel_shuffle and token merge - Unit test with synthetic config - README updated with new architecture Benchmarked against HF reference: - VL 1B text embeddings: 0.9999 cosine (near-identical) - Conversion works (regular 3.1GB + quantized 1.5GB) - Rankings match 5/5 across all test texts --------- Co-authored-by: Joe Schlesinger <157418651+joe-verkada@users.noreply.github.com> Co-authored-by: Prince Canuma <prince.gdt@gmail.com>
1 parent d3344ec commit ae95458

4 files changed

Lines changed: 334 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ MLX-Embeddings supports a variety of model architectures for text embedding task
2020
- Qwen3 (Qwen3's embedding model)
2121
- Qwen3-VL (multimodal Qwen3-VL embedding and reranking model)
2222
- Llama Bidirectional (Llama-based bidirectional embedding models, e.g. NVIDIA NV-Embed)
23+
- Llama Nemotron VL (multimodal vision-language embedding model with SigLIP vision + bidirectional Llama)
2324

2425
We're continuously working to expand our support for additional model architectures. Check our GitHub repository or documentation for the most up-to-date list of supported models and their specific versions.
2526

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .model import Model, ModelArgs
2+
3+
__all__ = [
4+
"Model",
5+
"ModelArgs",
6+
]
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import math
2+
from dataclasses import dataclass
3+
from typing import Dict, List, Optional, Union
4+
5+
import mlx.core as mx
6+
import mlx.nn as nn
7+
import numpy as np
8+
9+
from ..base import BaseModelArgs, BaseModelOutput, mean_pooling, normalize_embeddings
10+
from ..llama_bidirec import LlamaBidirectionalModel
11+
from ..llama_bidirec import ModelArgs as LlamaBidirectModelArgs
12+
from ..siglip import SiglipVisionTransformer
13+
from ..siglip import VisionConfig as SiglipVisionConfig
14+
15+
16+
@dataclass
17+
class VisionConfig(BaseModelArgs):
18+
model_type: str = "siglip_vision_model"
19+
hidden_size: int = 1152
20+
intermediate_size: int = 4304
21+
num_hidden_layers: int = 27
22+
num_attention_heads: int = 16
23+
num_channels: int = 3
24+
image_size: int = 512
25+
patch_size: int = 16
26+
layer_norm_eps: float = 1e-6
27+
attention_dropout: float = 0.0
28+
hidden_act: str = "gelu_pytorch_tanh"
29+
30+
31+
@dataclass
32+
class TextConfig(BaseModelArgs):
33+
model_type: str = "llama_bidirec"
34+
hidden_size: int = 2048
35+
num_hidden_layers: int = 16
36+
intermediate_size: int = 8192
37+
num_attention_heads: int = 32
38+
num_key_value_heads: Optional[int] = 8
39+
head_dim: Optional[int] = 64
40+
max_position_embeddings: int = 131072
41+
vocab_size: int = 128266
42+
rms_norm_eps: float = 1e-5
43+
rope_theta: float = 500000.0
44+
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
45+
rope_traditional: bool = False
46+
tie_word_embeddings: bool = True
47+
attention_bias: bool = False
48+
mlp_bias: bool = False
49+
layer_types: Optional[List[str]] = None
50+
51+
def __post_init__(self):
52+
if self.num_key_value_heads is None:
53+
self.num_key_value_heads = self.num_attention_heads
54+
if self.head_dim is None:
55+
self.head_dim = self.hidden_size // self.num_attention_heads
56+
if self.layer_types is None:
57+
self.layer_types = ["full_attention"] * self.num_hidden_layers
58+
59+
60+
@dataclass
61+
class ModelArgs(BaseModelArgs):
62+
model_type: str = "llama_nemotron_vl"
63+
vision_config: Optional[VisionConfig] = None
64+
llm_config: Optional[TextConfig] = None
65+
downsample_ratio: float = 0.5
66+
force_image_size: Optional[int] = 512
67+
img_context_token_id: int = 128258
68+
select_layer: int = -1
69+
hidden_size: int = 2048
70+
71+
def __post_init__(self):
72+
if isinstance(self.vision_config, dict):
73+
self.vision_config = VisionConfig.from_dict(self.vision_config)
74+
elif self.vision_config is None:
75+
self.vision_config = VisionConfig()
76+
if isinstance(self.llm_config, dict):
77+
self.llm_config = TextConfig.from_dict(self.llm_config)
78+
elif self.llm_config is None:
79+
self.llm_config = TextConfig()
80+
self.hidden_size = self.llm_config.hidden_size
81+
82+
83+
def pixel_shuffle(input_tensor: mx.array, shuffle_ratio: float) -> mx.array:
84+
batch_size, num_patches, channels = input_tensor.shape
85+
patch_size = int(math.sqrt(num_patches))
86+
87+
input_tensor = input_tensor.reshape(batch_size, patch_size, patch_size, -1)
88+
batch_size, height, width, channels = input_tensor.shape
89+
90+
reshaped = input_tensor.reshape(
91+
batch_size, height, int(width * shuffle_ratio), int(channels / shuffle_ratio)
92+
)
93+
reshaped = reshaped.transpose(0, 2, 1, 3)
94+
reshaped = reshaped.reshape(
95+
batch_size,
96+
int(height * shuffle_ratio),
97+
int(width * shuffle_ratio),
98+
int(channels / (shuffle_ratio**2)),
99+
)
100+
reshaped = reshaped.transpose(0, 2, 1, 3)
101+
102+
return reshaped.reshape(batch_size, -1, reshaped.shape[-1])
103+
104+
105+
class VisionEncoder(nn.Module):
106+
def __init__(self, config: VisionConfig):
107+
super().__init__()
108+
siglip_config = SiglipVisionConfig(
109+
hidden_size=config.hidden_size,
110+
intermediate_size=config.intermediate_size,
111+
num_hidden_layers=config.num_hidden_layers,
112+
num_attention_heads=config.num_attention_heads,
113+
num_channels=config.num_channels,
114+
image_size=config.image_size,
115+
patch_size=config.patch_size,
116+
vision_use_head=True,
117+
)
118+
self.vision_model = SiglipVisionTransformer(siglip_config)
119+
120+
def __call__(self, pixel_values: mx.array) -> mx.array:
121+
x, _ = self.vision_model(pixel_values)
122+
return x
123+
124+
125+
class Model(nn.Module):
126+
def __init__(self, config: ModelArgs):
127+
super().__init__()
128+
self.config = config
129+
self.model_type = config.model_type
130+
131+
self.vision_model = VisionEncoder(config.vision_config)
132+
133+
llm_args = LlamaBidirectModelArgs.from_dict(
134+
{
135+
"model_type": "llama_bidirec",
136+
"hidden_size": config.llm_config.hidden_size,
137+
"num_hidden_layers": config.llm_config.num_hidden_layers,
138+
"intermediate_size": config.llm_config.intermediate_size,
139+
"num_attention_heads": config.llm_config.num_attention_heads,
140+
"num_key_value_heads": config.llm_config.num_key_value_heads,
141+
"head_dim": config.llm_config.head_dim,
142+
"max_position_embeddings": config.llm_config.max_position_embeddings,
143+
"vocab_size": config.llm_config.vocab_size,
144+
"rms_norm_eps": config.llm_config.rms_norm_eps,
145+
"rope_theta": config.llm_config.rope_theta,
146+
"rope_scaling": config.llm_config.rope_scaling,
147+
"rope_traditional": config.llm_config.rope_traditional,
148+
"tie_word_embeddings": config.llm_config.tie_word_embeddings,
149+
"attention_bias": config.llm_config.attention_bias,
150+
"mlp_bias": config.llm_config.mlp_bias,
151+
"layer_types": config.llm_config.layer_types,
152+
}
153+
)
154+
self.language_model = LlamaBidirectionalModel(llm_args)
155+
156+
vit_hidden = config.vision_config.hidden_size
157+
ds = config.downsample_ratio
158+
mlp_input_size = int(vit_hidden * (1 / ds) ** 2)
159+
llm_hidden = config.llm_config.hidden_size
160+
161+
self.mlp1 = [
162+
nn.LayerNorm(mlp_input_size),
163+
nn.Linear(mlp_input_size, llm_hidden),
164+
nn.GELU(),
165+
nn.Linear(llm_hidden, llm_hidden),
166+
]
167+
168+
self.downsample_ratio = ds
169+
self.img_context_token_id = config.img_context_token_id
170+
171+
def get_extended_attention_mask(self, attention_mask):
172+
if attention_mask.ndim == 2:
173+
extended = attention_mask[:, None, None, :]
174+
extended = mx.repeat(extended, attention_mask.shape[-1], -2)
175+
elif attention_mask.ndim == 3:
176+
extended = attention_mask[:, None, :, :]
177+
else:
178+
raise ValueError(
179+
f"Wrong shape for attention_mask (shape {attention_mask.shape})"
180+
)
181+
return extended
182+
183+
def extract_feature(self, pixel_values: mx.array) -> mx.array:
184+
vit_embeds = self.vision_model(pixel_values)
185+
vit_embeds = pixel_shuffle(vit_embeds, shuffle_ratio=self.downsample_ratio)
186+
187+
for layer in self.mlp1:
188+
vit_embeds = layer(vit_embeds)
189+
190+
return vit_embeds
191+
192+
def _merge_input_ids_with_image_features(
193+
self, image_features, inputs_embeds, input_ids
194+
):
195+
B, N, C = inputs_embeds.shape
196+
197+
image_positions = input_ids == self.img_context_token_id
198+
image_indices = np.where(image_positions)[1].tolist()
199+
200+
image_features = image_features.reshape(-1, image_features.shape[-1])
201+
inputs_embeds[:, image_indices, :] = image_features
202+
203+
return inputs_embeds.reshape(B, N, C)
204+
205+
def __call__(
206+
self,
207+
input_ids: mx.array,
208+
attention_mask: Optional[mx.array] = None,
209+
pixel_values: Optional[mx.array] = None,
210+
):
211+
if attention_mask is None:
212+
attention_mask = mx.ones(input_ids.shape)
213+
214+
input_embeds = self.language_model.embed_tokens(input_ids)
215+
216+
if pixel_values is not None:
217+
if pixel_values.ndim == 5:
218+
pixel_values = pixel_values[0]
219+
220+
dtype = (
221+
self.vision_model.vision_model.embeddings.patch_embedding.weight.dtype
222+
)
223+
pixel_values = pixel_values.astype(dtype)
224+
225+
vit_embeds = self.extract_feature(pixel_values)
226+
input_embeds = self._merge_input_ids_with_image_features(
227+
vit_embeds, input_embeds, input_ids
228+
)
229+
230+
extended_mask = self.get_extended_attention_mask(attention_mask)
231+
extended_mask = mx.where(
232+
extended_mask.astype(mx.bool_),
233+
0.0,
234+
-mx.inf,
235+
)
236+
extended_mask = extended_mask.astype(
237+
self.language_model.embed_tokens.weight.dtype
238+
)
239+
240+
out = self.language_model(
241+
input_ids, extended_mask, input_embeddings=input_embeds
242+
)
243+
244+
text_embeds = mean_pooling(out, attention_mask)
245+
text_embeds = normalize_embeddings(text_embeds)
246+
247+
return BaseModelOutput(
248+
last_hidden_state=out,
249+
text_embeds=text_embeds,
250+
pooler_output=None,
251+
)
252+
253+
def sanitize(self, weights):
254+
sanitized = {}
255+
for k, v in weights.items():
256+
if "rotary_emb.inv_freq" in k:
257+
continue
258+
if "lm_head" in k:
259+
continue
260+
if "head.attention.in_proj_weight" in k:
261+
sanitized[k.replace("in_proj_weight", "in_proj.weight")] = v
262+
continue
263+
if "head.attention.in_proj_bias" in k:
264+
sanitized[k.replace("in_proj_bias", "in_proj.bias")] = v
265+
continue
266+
267+
new_key = k
268+
269+
if k.startswith("language_model.model."):
270+
new_key = k.replace("language_model.model.", "language_model.")
271+
272+
if "patch_embedding.weight" in k and v.ndim == 4:
273+
# HF: (out, in, kH, kW) -> MLX: (out, kH, kW, in)
274+
# Skip if already in MLX format (last dim is smallest = in_channels)
275+
if v.shape[1] < v.shape[2]:
276+
v = v.transpose(0, 2, 3, 1)
277+
278+
sanitized[new_key] = v
279+
return sanitized
280+
281+
@property
282+
def layers(self):
283+
return self.language_model.layers

mlx_embeddings/tests/test_models.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,50 @@ def test_siglip2_model(self):
421421
self.assertEqual(outputs.logits_per_image.shape, (batch_size, batch_size))
422422
self.assertEqual(outputs.logits_per_text.shape, (batch_size, batch_size))
423423

424+
def test_llama_nemotron_vl_text_only(self):
425+
from mlx_embeddings.models.llama_nemotron_vl.model import (
426+
Model as NemotronVLModel,
427+
)
428+
from mlx_embeddings.models.llama_nemotron_vl.model import (
429+
ModelArgs as NemotronVLModelArgs,
430+
)
431+
from mlx_embeddings.models.llama_nemotron_vl.model import (
432+
TextConfig,
433+
VisionConfig,
434+
)
435+
436+
config = NemotronVLModelArgs(
437+
model_type="llama_nemotron_vl",
438+
hidden_size=64,
439+
vision_config=VisionConfig(
440+
hidden_size=64,
441+
intermediate_size=128,
442+
num_hidden_layers=2,
443+
num_attention_heads=2,
444+
image_size=32,
445+
patch_size=16,
446+
),
447+
llm_config=TextConfig(
448+
hidden_size=64,
449+
num_hidden_layers=2,
450+
intermediate_size=128,
451+
num_attention_heads=2,
452+
num_key_value_heads=2,
453+
head_dim=32,
454+
max_position_embeddings=128,
455+
vocab_size=1000,
456+
),
457+
downsample_ratio=0.5,
458+
img_context_token_id=999,
459+
)
460+
model = NemotronVLModel(config)
461+
462+
self.model_test_runner(
463+
model,
464+
config.model_type,
465+
config.llm_config.num_hidden_layers,
466+
)
467+
424468
def test_llama_bidirec_model(self):
425469
from mlx_embeddings.models import llama_bidirec
426470

0 commit comments

Comments
 (0)