Sentence Similarity
MLX
Safetensors
ministral3
apple-silicon
embeddings
text-embeddings
feature-extraction
retrieval
semantic-search
rag
mteb
nemotron
quantized
4-bit precision
information-retrieval
dense-retrieval
vector-search
matryoshka
arxiv:2502.13595
Eval Results (legacy)
Instructions to use shadowrock-io/Nemotron-3-Embed-8B-Community-MLX-4bit with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use shadowrock-io/Nemotron-3-Embed-8B-Community-MLX-4bit with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] hf download shadowrock-io/Nemotron-3-Embed-8B-Community-MLX-4bit --local-dir Nemotron-3-Embed-8B-Community-MLX-4bit
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
- Atomic Chat
CI sync from nemotron-embed-quant @ 8ce22703
Browse files- nemotron3_embed_mlx.py +26 -9
- scripts/convert_8b.py +35 -14
- scripts/eval_8b.py +20 -9
nemotron3_embed_mlx.py
CHANGED
|
@@ -8,19 +8,24 @@ Changes: converted the causal-decoder mlx-lm ministral3 implementation into a
|
|
| 8 |
|
| 9 |
Dependencies: mlx, mlx-lm, transformers, numpy
|
| 10 |
"""
|
|
|
|
| 11 |
import json
|
| 12 |
from pathlib import Path
|
| 13 |
|
| 14 |
import mlx.core as mx
|
| 15 |
import mlx.nn as nn
|
| 16 |
import numpy as np
|
| 17 |
-
from mlx_lm.models.ministral3 import (
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
| 19 |
from transformers import AutoTokenizer
|
| 20 |
|
| 21 |
NEG_INF = -1e9
|
| 22 |
PREFIXES = {"query": "query: ", "passage": "passage: "}
|
| 23 |
|
|
|
|
| 24 |
class NemotronEmbedModel(nn.Module):
|
| 25 |
def __init__(self, args: ModelArgs):
|
| 26 |
super().__init__()
|
|
@@ -32,7 +37,8 @@ class NemotronEmbedModel(nn.Module):
|
|
| 32 |
def __call__(self, input_ids: mx.array, attention_mask: mx.array) -> mx.array:
|
| 33 |
h = self.embed_tokens(input_ids)
|
| 34 |
attn_scale = _get_llama_4_attn_scale(
|
| 35 |
-
input_ids.shape[1],
|
|
|
|
| 36 |
self.args.rope_parameters["llama_4_scaling_beta"],
|
| 37 |
self.args.rope_parameters["original_max_position_embeddings"],
|
| 38 |
).astype(h.dtype)
|
|
@@ -44,17 +50,23 @@ class NemotronEmbedModel(nn.Module):
|
|
| 44 |
emb = (h * m).sum(axis=1) / m.sum(axis=1)
|
| 45 |
return emb / mx.linalg.norm(emb, axis=-1, keepdims=True)
|
| 46 |
|
|
|
|
| 47 |
def load(path: str):
|
| 48 |
p = Path(path)
|
| 49 |
if not p.is_dir():
|
| 50 |
from huggingface_hub import snapshot_download
|
|
|
|
| 51 |
p = Path(snapshot_download(path))
|
| 52 |
cfg = json.loads((p / "config.json").read_text())
|
| 53 |
model = NemotronEmbedModel(ModelArgs.from_dict(cfg))
|
| 54 |
q = cfg.get("quantization")
|
| 55 |
if q:
|
| 56 |
-
nn.quantize(
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
model.load_weights(str(p / "model.safetensors"))
|
| 59 |
model.eval()
|
| 60 |
mx.eval(model.parameters())
|
|
@@ -62,14 +74,19 @@ def load(path: str):
|
|
| 62 |
tok.padding_side = "right"
|
| 63 |
return model, tok
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
if input_type is not None:
|
| 68 |
texts = [PREFIXES[input_type] + t for t in texts]
|
| 69 |
out = []
|
| 70 |
for i in range(0, len(texts), batch_size):
|
| 71 |
-
b = tokenizer(
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
e = model(mx.array(b["input_ids"]), mx.array(b["attention_mask"]))
|
| 74 |
out.append(np.asarray(e.astype(mx.float32)))
|
| 75 |
mx.clear_cache()
|
|
|
|
| 8 |
|
| 9 |
Dependencies: mlx, mlx-lm, transformers, numpy
|
| 10 |
"""
|
| 11 |
+
|
| 12 |
import json
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
import mlx.core as mx
|
| 16 |
import mlx.nn as nn
|
| 17 |
import numpy as np
|
| 18 |
+
from mlx_lm.models.ministral3 import (
|
| 19 |
+
ModelArgs,
|
| 20 |
+
TransformerBlock,
|
| 21 |
+
_get_llama_4_attn_scale,
|
| 22 |
+
)
|
| 23 |
from transformers import AutoTokenizer
|
| 24 |
|
| 25 |
NEG_INF = -1e9
|
| 26 |
PREFIXES = {"query": "query: ", "passage": "passage: "}
|
| 27 |
|
| 28 |
+
|
| 29 |
class NemotronEmbedModel(nn.Module):
|
| 30 |
def __init__(self, args: ModelArgs):
|
| 31 |
super().__init__()
|
|
|
|
| 37 |
def __call__(self, input_ids: mx.array, attention_mask: mx.array) -> mx.array:
|
| 38 |
h = self.embed_tokens(input_ids)
|
| 39 |
attn_scale = _get_llama_4_attn_scale(
|
| 40 |
+
input_ids.shape[1],
|
| 41 |
+
0,
|
| 42 |
self.args.rope_parameters["llama_4_scaling_beta"],
|
| 43 |
self.args.rope_parameters["original_max_position_embeddings"],
|
| 44 |
).astype(h.dtype)
|
|
|
|
| 50 |
emb = (h * m).sum(axis=1) / m.sum(axis=1)
|
| 51 |
return emb / mx.linalg.norm(emb, axis=-1, keepdims=True)
|
| 52 |
|
| 53 |
+
|
| 54 |
def load(path: str):
|
| 55 |
p = Path(path)
|
| 56 |
if not p.is_dir():
|
| 57 |
from huggingface_hub import snapshot_download
|
| 58 |
+
|
| 59 |
p = Path(snapshot_download(path))
|
| 60 |
cfg = json.loads((p / "config.json").read_text())
|
| 61 |
model = NemotronEmbedModel(ModelArgs.from_dict(cfg))
|
| 62 |
q = cfg.get("quantization")
|
| 63 |
if q:
|
| 64 |
+
nn.quantize(
|
| 65 |
+
model,
|
| 66 |
+
group_size=q["group_size"],
|
| 67 |
+
bits=q["bits"],
|
| 68 |
+
mode=q.get("mode", "affine"),
|
| 69 |
+
)
|
| 70 |
model.load_weights(str(p / "model.safetensors"))
|
| 71 |
model.eval()
|
| 72 |
mx.eval(model.parameters())
|
|
|
|
| 74 |
tok.padding_side = "right"
|
| 75 |
return model, tok
|
| 76 |
|
| 77 |
+
|
| 78 |
+
def encode(model, tokenizer, texts, input_type=None, batch_size=8, max_length=4096):
|
| 79 |
if input_type is not None:
|
| 80 |
texts = [PREFIXES[input_type] + t for t in texts]
|
| 81 |
out = []
|
| 82 |
for i in range(0, len(texts), batch_size):
|
| 83 |
+
b = tokenizer(
|
| 84 |
+
texts[i : i + batch_size],
|
| 85 |
+
padding=True,
|
| 86 |
+
truncation=True,
|
| 87 |
+
max_length=max_length,
|
| 88 |
+
return_tensors="np",
|
| 89 |
+
)
|
| 90 |
e = model(mx.array(b["input_ids"]), mx.array(b["attention_mask"]))
|
| 91 |
out.append(np.asarray(e.astype(mx.float32)))
|
| 92 |
mx.clear_cache()
|
scripts/convert_8b.py
CHANGED
|
@@ -44,8 +44,11 @@ def main():
|
|
| 44 |
ap.add_argument("--out", required=True)
|
| 45 |
ap.add_argument("--bits", type=int, required=True)
|
| 46 |
ap.add_argument("--group-size", type=int, required=True)
|
| 47 |
-
ap.add_argument(
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
| 49 |
args = ap.parse_args()
|
| 50 |
|
| 51 |
import mlx.core as mx
|
|
@@ -62,10 +65,13 @@ def main():
|
|
| 62 |
weights = {}
|
| 63 |
for shard in sorted(src.glob("model-*.safetensors")):
|
| 64 |
weights.update(mx.load(str(shard))) # mmap — not materialized yet
|
| 65 |
-
print(
|
|
|
|
|
|
|
| 66 |
model.load_weights(list(weights.items()))
|
| 67 |
|
| 68 |
if args.mixed:
|
|
|
|
| 69 |
def class_predicate(path, module):
|
| 70 |
if not hasattr(module, "to_quantized"):
|
| 71 |
return False
|
|
@@ -73,23 +79,30 @@ def main():
|
|
| 73 |
return {"group_size": args.group_size, "bits": 8}
|
| 74 |
return True
|
| 75 |
|
| 76 |
-
nn.quantize(
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
else:
|
| 82 |
nn.quantize(model, group_size=args.group_size, bits=args.bits)
|
| 83 |
-
quant_cfg = {"group_size": args.group_size, "bits": args.bits,
|
| 84 |
-
"mode": "affine"}
|
| 85 |
|
| 86 |
mx.eval(model.parameters())
|
| 87 |
|
| 88 |
from mlx.utils import tree_flatten
|
| 89 |
|
| 90 |
flat = dict(tree_flatten(model.parameters()))
|
| 91 |
-
mx.save_safetensors(
|
| 92 |
-
|
|
|
|
| 93 |
|
| 94 |
cfg_out = dict(cfg)
|
| 95 |
cfg_out["quantization"] = quant_cfg
|
|
@@ -100,8 +113,16 @@ def main():
|
|
| 100 |
shutil.copy(MLX_1B_DIR / "nemotron3_embed_mlx.py", out / "nemotron3_embed_mlx.py")
|
| 101 |
|
| 102 |
size_gb = (out / "model.safetensors").stat().st_size / 1e9
|
| 103 |
-
print(
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
|
| 107 |
if __name__ == "__main__":
|
|
|
|
| 44 |
ap.add_argument("--out", required=True)
|
| 45 |
ap.add_argument("--bits", type=int, required=True)
|
| 46 |
ap.add_argument("--group-size", type=int, required=True)
|
| 47 |
+
ap.add_argument(
|
| 48 |
+
"--mixed",
|
| 49 |
+
action="store_true",
|
| 50 |
+
help="keep embed_tokens at 8-bit (body at --bits)",
|
| 51 |
+
)
|
| 52 |
args = ap.parse_args()
|
| 53 |
|
| 54 |
import mlx.core as mx
|
|
|
|
| 65 |
weights = {}
|
| 66 |
for shard in sorted(src.glob("model-*.safetensors")):
|
| 67 |
weights.update(mx.load(str(shard))) # mmap — not materialized yet
|
| 68 |
+
print(
|
| 69 |
+
f"loaded {len(weights)} tensors from {len(list(src.glob('model-*.safetensors')))} shards"
|
| 70 |
+
)
|
| 71 |
model.load_weights(list(weights.items()))
|
| 72 |
|
| 73 |
if args.mixed:
|
| 74 |
+
|
| 75 |
def class_predicate(path, module):
|
| 76 |
if not hasattr(module, "to_quantized"):
|
| 77 |
return False
|
|
|
|
| 79 |
return {"group_size": args.group_size, "bits": 8}
|
| 80 |
return True
|
| 81 |
|
| 82 |
+
nn.quantize(
|
| 83 |
+
model,
|
| 84 |
+
group_size=args.group_size,
|
| 85 |
+
bits=args.bits,
|
| 86 |
+
class_predicate=class_predicate,
|
| 87 |
+
)
|
| 88 |
+
quant_cfg = {
|
| 89 |
+
"group_size": args.group_size,
|
| 90 |
+
"bits": args.bits,
|
| 91 |
+
"mode": "affine",
|
| 92 |
+
"overrides": {"embed_tokens": {"bits": 8}},
|
| 93 |
+
}
|
| 94 |
else:
|
| 95 |
nn.quantize(model, group_size=args.group_size, bits=args.bits)
|
| 96 |
+
quant_cfg = {"group_size": args.group_size, "bits": args.bits, "mode": "affine"}
|
|
|
|
| 97 |
|
| 98 |
mx.eval(model.parameters())
|
| 99 |
|
| 100 |
from mlx.utils import tree_flatten
|
| 101 |
|
| 102 |
flat = dict(tree_flatten(model.parameters()))
|
| 103 |
+
mx.save_safetensors(
|
| 104 |
+
str(out / "model.safetensors"), flat, metadata={"format": "mlx"}
|
| 105 |
+
)
|
| 106 |
|
| 107 |
cfg_out = dict(cfg)
|
| 108 |
cfg_out["quantization"] = quant_cfg
|
|
|
|
| 113 |
shutil.copy(MLX_1B_DIR / "nemotron3_embed_mlx.py", out / "nemotron3_embed_mlx.py")
|
| 114 |
|
| 115 |
size_gb = (out / "model.safetensors").stat().st_size / 1e9
|
| 116 |
+
print(
|
| 117 |
+
json.dumps(
|
| 118 |
+
{
|
| 119 |
+
"out": str(out),
|
| 120 |
+
"quantization": quant_cfg,
|
| 121 |
+
"n_tensors": len(flat),
|
| 122 |
+
"size_gb": round(size_gb, 2),
|
| 123 |
+
}
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
|
| 127 |
|
| 128 |
if __name__ == "__main__":
|
scripts/eval_8b.py
CHANGED
|
@@ -41,8 +41,12 @@ def main():
|
|
| 41 |
ap.add_argument("--make-ref", action="store_true")
|
| 42 |
ap.add_argument("--mlx-dir", default=None)
|
| 43 |
ap.add_argument("--out", default=None)
|
| 44 |
-
ap.add_argument(
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
args = ap.parse_args()
|
| 47 |
|
| 48 |
import numpy as np
|
|
@@ -74,24 +78,31 @@ def main():
|
|
| 74 |
|
| 75 |
ref = np.load(REF_NPZ)["embeddings"]
|
| 76 |
m = embed_fn(texts)
|
| 77 |
-
cos = [
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
|
| 80 |
suite = pooling_fixtures.run(embed_fn, f"mlx-8b:{mlx_dir.name}")
|
| 81 |
report = {
|
| 82 |
"mlx_dir": str(mlx_dir),
|
| 83 |
-
"quantization": json.loads((mlx_dir / "config.json").read_text()).get(
|
|
|
|
|
|
|
| 84 |
"fixture_parity_vs_transformers_bf16": [round(c, 6) for c in cos],
|
| 85 |
"parity_mean": round(float(np.mean(cos)), 6),
|
| 86 |
"parity_min": round(float(np.min(cos)), 6),
|
| 87 |
"pooling_suite": suite,
|
| 88 |
-
"arch_ok": bool(
|
| 89 |
-
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
out = Path(args.out) if args.out else ROOT / "runs" / f"mlx_8b_{mlx_dir.name}.json"
|
| 92 |
out.write_text(json.dumps(report, indent=2))
|
| 93 |
-
print(
|
| 94 |
-
|
|
|
|
| 95 |
if args.min_cos and min(cos) < args.min_cos:
|
| 96 |
sys.exit(1)
|
| 97 |
|
|
|
|
| 41 |
ap.add_argument("--make-ref", action="store_true")
|
| 42 |
ap.add_argument("--mlx-dir", default=None)
|
| 43 |
ap.add_argument("--out", default=None)
|
| 44 |
+
ap.add_argument(
|
| 45 |
+
"--min-cos",
|
| 46 |
+
type=float,
|
| 47 |
+
default=0.0,
|
| 48 |
+
help="optional gate on min fixture cosine (0 = report only)",
|
| 49 |
+
)
|
| 50 |
args = ap.parse_args()
|
| 51 |
|
| 52 |
import numpy as np
|
|
|
|
| 78 |
|
| 79 |
ref = np.load(REF_NPZ)["embeddings"]
|
| 80 |
m = embed_fn(texts)
|
| 81 |
+
cos = [
|
| 82 |
+
float(np.dot(a / np.linalg.norm(a), b / np.linalg.norm(b)))
|
| 83 |
+
for a, b in zip(m, ref)
|
| 84 |
+
]
|
| 85 |
|
| 86 |
suite = pooling_fixtures.run(embed_fn, f"mlx-8b:{mlx_dir.name}")
|
| 87 |
report = {
|
| 88 |
"mlx_dir": str(mlx_dir),
|
| 89 |
+
"quantization": json.loads((mlx_dir / "config.json").read_text()).get(
|
| 90 |
+
"quantization"
|
| 91 |
+
),
|
| 92 |
"fixture_parity_vs_transformers_bf16": [round(c, 6) for c in cos],
|
| 93 |
"parity_mean": round(float(np.mean(cos)), 6),
|
| 94 |
"parity_min": round(float(np.min(cos)), 6),
|
| 95 |
"pooling_suite": suite,
|
| 96 |
+
"arch_ok": bool(
|
| 97 |
+
suite["results"]["padding_invariance"]["pass"]
|
| 98 |
+
and suite["results"]["suffix_sensitivity"]["pass"]
|
| 99 |
+
),
|
| 100 |
}
|
| 101 |
out = Path(args.out) if args.out else ROOT / "runs" / f"mlx_8b_{mlx_dir.name}.json"
|
| 102 |
out.write_text(json.dumps(report, indent=2))
|
| 103 |
+
print(
|
| 104 |
+
json.dumps({k: v for k, v in report.items() if k != "pooling_suite"}, indent=2)
|
| 105 |
+
)
|
| 106 |
if args.min_cos and min(cos) < args.min_cos:
|
| 107 |
sys.exit(1)
|
| 108 |
|