Compare commits

...

10 Commits

Author SHA1 Message Date
Awni Hannun f3ed856610 support nemotron 3 (#678)
* support nemotron 3

* fix

* bump version
2025-12-16 08:50:44 -08:00
Inferencer ede65a1484 Fix for Devstral-2 (#671)
* Fix for Devstral-2

Convert cache offset to int for mx.arange compatibility in attention scale

* fix

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-12-11 08:40:12 -08:00
Awni Hannun 3d3e0751a3 fix (#669) 2025-12-09 17:11:51 -08:00
Anthony 085e36e6ab Fix SuScaledRoPE (#660) 2025-12-09 07:59:28 -08:00
Angelos Katharopoulos eea2e5f5de Fix server batching condition for SSMs (#655) 2025-12-08 23:18:53 -08:00
Awni Hannun cb763947ee Fix fusion and test (#668) 2025-12-08 16:39:46 -08:00
Awni Hannun b343a0556f fix dsv32 and gemma3 (#664) 2025-12-08 16:14:10 -08:00
Awni Hannun 82dfd39ef2 default repetition penalty to 0.0 in the server (#658) 2025-12-08 16:14:00 -08:00
Awni Hannun 84996808a2 Use test data zipfile in CI (#662)
* make fewer requests in tests

* token
2025-12-08 16:13:34 -08:00
Hritik Kumar 99f8fd6cc8 fix: calling correct dequantize function (#666) 2025-12-08 13:34:42 -08:00
14 changed files with 235 additions and 50 deletions
+3 -1
View File
@@ -38,4 +38,6 @@ jobs:
- name: Run tests
shell: bash -l {0}
run: |
python -m xmlrunner discover -v tests -o test-results/
curl -o test_data.zip -L https://github.com/ml-explore/mlx-lm/releases/download/test_data/test_data.zip
unzip test_data.zip
HF_HOME="." python -m xmlrunner discover -v tests -o test-results/
+1 -1
View File
@@ -1,3 +1,3 @@
# Copyright © 2023-2025 Apple Inc.
__version__ = "0.29.0"
__version__ = "0.29.1"
+2 -1
View File
@@ -76,8 +76,9 @@ def main() -> None:
if args.dequantize:
print("Dequantizing model")
model = dequantize(model)
model = dequantize_model(model)
config.pop("quantization", None)
config.pop("quantization_config", None)
save_path = Path(args.save_path)
save(
+5
View File
@@ -222,6 +222,11 @@ class DeepseekV32Attention(nn.Module):
if mask is not None:
sparse_mask = sparse_mask & mask
mask = sparse_mask
# Ensure the indexer cache is evaluated even if the topk_indices are unused
# to keep the graph from getting too large
if cache is not None and cache[0] is not None:
cache[0].keys = mx.depends(cache[0].keys, (cache[1].keys, cache[1].values))
output = scaled_dot_product_attention(
queries, keys, values, cache=cache[0], scale=self.scale, mask=mask
)
+14 -7
View File
@@ -54,13 +54,20 @@ class Attention(nn.Module):
self.k_norm = RMSNorm(dims=head_dim, eps=args.rms_norm_eps)
self.is_sliding = (layer_idx + 1) % args.sliding_window_pattern != 0
self.rope = initialize_rope(
dims=head_dim,
base=(args.rope_local_base_freq if self.is_sliding else args.rope_theta),
traditional=False,
max_position_embeddings=args.max_position_embeddings,
scaling_config=args.rope_scaling,
)
if self.is_sliding:
self.rope = initialize_rope(
dims=head_dim,
base=args.rope_local_base_freq,
traditional=False,
)
else:
self.rope = initialize_rope(
dims=head_dim,
base=args.rope_theta,
traditional=False,
max_position_embeddings=args.max_position_embeddings,
scaling_config=args.rope_scaling,
)
def __call__(
self,
+10 -6
View File
@@ -36,13 +36,17 @@ class ModelArgs(BaseModelArgs):
self.layer_types = ["full_attention"] * self.num_hidden_layers
def _get_llama_4_attn_scale(
start: int, stop: int, beta: float, max_position_embeddings: int
):
def _get_llama_4_attn_scale(size, offset, beta: float, max_position_embeddings: int):
if isinstance(offset, mx.array) and offset.ndim > 0:
offset = offset[:, None]
scaling = 1 + beta * mx.log(
1 + mx.floor(mx.arange(start, stop) / max_position_embeddings)
1 + mx.floor((mx.arange(size) + offset) / max_position_embeddings)
)
return scaling[:, None]
if scaling.ndim == 2:
return scaling[:, None, :, None]
else:
return scaling[:, None]
class Attention(nn.Module):
@@ -191,8 +195,8 @@ class LanguageModel(nn.Module):
)
attn_scale = _get_llama_4_attn_scale(
inputs.shape[1],
offset,
offset + inputs.shape[1],
self.args.rope_parameters["llama_4_scaling_beta"],
self.args.rope_parameters["original_max_position_embeddings"],
).astype(h.dtype)
+138 -14
View File
@@ -15,6 +15,7 @@ from .base import (
)
from .cache import KVCache, MambaCache
from .ssm import ssm_update
from .switch_layers import SwitchMLP
@dataclass()
@@ -37,24 +38,34 @@ class ModelArgs(BaseModelArgs):
time_step_limit: Tuple[float, float]
mlp_bias: bool
layer_norm_epsilon: float
rms_norm_eps: float
use_bias: bool
use_conv_bias: bool
residual_in_fp32: bool
hybrid_override_pattern: List[str]
head_dim: Optional[int] = None
moe_intermediate_size: Optional[int] = None
moe_shared_expert_intermediate_size: Optional[int] = None
n_group: Optional[int] = None
n_routed_experts: Optional[int] = None
n_shared_experts: Optional[int] = None
topk_group: Optional[int] = None
num_experts_per_tok: Optional[int] = None
norm_topk_prob: Optional[bool] = None
routed_scaling_factor: Optional[float] = None
class MambaRMSNormGated(nn.Module):
def __init__(self, hidden_size: int, eps: float = 1e-6):
def __init__(self, hidden_size: int, eps: float, group_size: int):
super().__init__()
self.eps = eps
self.weight = mx.ones(hidden_size)
self.group_size = group_size
def __call__(self, hidden_states: mx.array, gate: mx.array = None) -> mx.array:
def __call__(self, x: mx.array, gate: mx.array = None) -> mx.array:
if gate is not None:
hidden_states = hidden_states * nn.silu(gate)
return mx.fast.rms_norm(hidden_states, self.weight, self.eps)
x = x * nn.silu(gate)
x = mx.unflatten(x, axis=-1, shape=(-1, self.group_size))
x = mx.fast.rms_norm(x, weight=None, eps=self.eps)
return self.weight * x.flatten(-2)
class NemotronHMamba2Mixer(nn.Module):
@@ -90,8 +101,11 @@ class NemotronHMamba2Mixer(nn.Module):
self.A_log = mx.log(mx.arange(1, self.num_heads + 1, dtype=mx.float32))
self.D = mx.ones(self.num_heads)
group_size = self.intermediate_size // self.n_groups
self.norm = MambaRMSNormGated(
self.intermediate_size, eps=args.layer_norm_epsilon
self.intermediate_size,
eps=args.layer_norm_epsilon,
group_size=group_size,
)
self.out_proj = nn.Linear(
self.intermediate_size, self.hidden_size, bias=args.mamba_proj_bias
@@ -139,7 +153,7 @@ class NemotronHMamba2Mixer(nn.Module):
self.A_log,
B,
C,
self.D,
self.D.astype(hidden_states.dtype),
dt,
self.dt_bias,
state,
@@ -245,24 +259,113 @@ class NemotronHAttention(nn.Module):
class NemotronHMLP(nn.Module):
def __init__(self, args: ModelArgs):
def __init__(self, args: ModelArgs, intermediate_size=None):
super().__init__()
intermediate_size = intermediate_size or args.intermediate_size
self.up_proj = nn.Linear(
args.hidden_size, args.intermediate_size, bias=args.mlp_bias
args.hidden_size, intermediate_size, bias=args.mlp_bias
)
self.down_proj = nn.Linear(
args.intermediate_size, args.hidden_size, bias=args.mlp_bias
intermediate_size, args.hidden_size, bias=args.mlp_bias
)
def __call__(self, x):
return self.down_proj(nn.relu2(self.up_proj(x)))
@mx.compile
def group_expert_select(
gates,
e_score_correction_bias,
top_k,
n_group,
topk_group,
routed_scaling_factor,
norm_topk_prob,
):
orig_scores = scores = mx.sigmoid(gates.astype(mx.float32))
scores = scores + e_score_correction_bias
if n_group > 1:
scores = mx.unflatten(scores, axis=-1, shape=(n_group, -1))
group_scores = mx.topk(scores, 2, axis=-1).sum(axis=-1, keepdims=True)
k = n_group - topk_group
group_idx = mx.argpartition(group_scores, kth=k - 1, axis=-2)[..., :k, :]
scores = mx.put_along_axis(
scores, mx.stop_gradient(group_idx), mx.array(0.0), axis=-2
)
scores = mx.flatten(scores, -2, -1)
k = top_k
inds = mx.argpartition(-scores, kth=k - 1, axis=-1)[..., :k]
scores = mx.take_along_axis(orig_scores, inds, axis=-1)
if top_k > 1 and norm_topk_prob:
denominator = scores.sum(axis=-1, keepdims=True)
scores = scores / (denominator + 1e-20)
scores = scores * routed_scaling_factor
return inds, scores
class MoEGate(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.top_k = config.num_experts_per_tok
self.norm_topk_prob = config.norm_topk_prob
self.n_routed_experts = config.n_routed_experts
self.routed_scaling_factor = config.routed_scaling_factor
self.n_group = config.n_group
self.topk_group = config.topk_group
self.weight = mx.zeros((self.n_routed_experts, config.hidden_size))
self.e_score_correction_bias = mx.zeros((self.n_routed_experts,))
def __call__(self, x):
return group_expert_select(
x @ self.weight.T,
self.e_score_correction_bias,
self.top_k,
self.n_group,
self.topk_group,
self.routed_scaling_factor,
self.norm_topk_prob,
)
class NemotronHMoE(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.num_experts_per_tok = config.num_experts_per_tok
self.switch_mlp = SwitchMLP(
config.hidden_size,
config.moe_intermediate_size,
config.n_routed_experts,
activation=nn.ReLU2(),
)
self.gate = MoEGate(config)
if config.n_shared_experts is not None:
intermediate_size = config.moe_shared_expert_intermediate_size
self.shared_experts = NemotronHMLP(
config, intermediate_size=intermediate_size
)
def __call__(self, x):
inds, scores = self.gate(x)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2).astype(y.dtype)
if self.config.n_shared_experts is not None:
y = y + self.shared_experts(x)
return y
class NemotronHBlock(nn.Module):
def __init__(self, args: ModelArgs, block_type: str):
super().__init__()
self.residual_in_fp32 = args.residual_in_fp32
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.norm = nn.RMSNorm(args.hidden_size, eps=args.layer_norm_epsilon)
self.block_type = block_type
@@ -272,6 +375,8 @@ class NemotronHBlock(nn.Module):
self.mixer = NemotronHAttention(args)
elif self.block_type == "-":
self.mixer = NemotronHMLP(args)
elif self.block_type == "E":
self.mixer = NemotronHMoE(args)
def __call__(
self,
@@ -296,7 +401,7 @@ class NemotronHModel(nn.Module):
NemotronHBlock(args, block_type)
for block_type in args.hybrid_override_pattern
]
self.norm_f = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.norm_f = nn.RMSNorm(args.hidden_size, eps=args.layer_norm_epsilon)
self.fa_idx = 0
self.ssm_idx = 0
for b in args.hybrid_override_pattern:
@@ -372,4 +477,23 @@ class Model(nn.Module):
for k, v in weights.items():
if "conv1d.weight" in k and v.shape[-1] != 1:
weights[k] = v.moveaxis(2, 1)
# Stack experts
for l in range(self.args.num_hidden_layers):
prefix = f"backbone.layers.{l}.mixer"
for m, n in [("down_proj", "fc2"), ("up_proj", "fc1")]:
if f"{prefix}.experts.0.{m}.weight" in weights:
to_join = [
weights.pop(f"{prefix}.experts.{e}.{m}.weight")
for e in range(self.args.n_routed_experts)
]
weights[f"{prefix}.switch_mlp.{n}.weight"] = mx.stack(to_join)
return weights
@property
def cast_predicate(self):
def predicate(k):
return "e_score_correction_bias" not in k and "A_log" not in k
return predicate
+27 -9
View File
@@ -43,18 +43,36 @@ class SuScaledRoPE(nn.Module):
long_mscale (float, optional): Scale the input prior to embedding.
"""
super().__init__()
freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
self._freqs = mx.array(long_factor, dtype=mx.float32) * freqs
self.original_max_position_embeddings = original_max_position_embeddings
self.scale = long_mscale or math.sqrt(
1
+ math.log(max_position_embeddings / original_max_position_embeddings)
/ math.log(original_max_position_embeddings)
)
self.dim = dims
freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
self._short_freqs = mx.array(short_factor, dtype=mx.float32) * freqs
self._long_freqs = mx.array(long_factor, dtype=mx.float32) * freqs
def default_scale(factor):
return math.sqrt(
1 + math.log(factor) / math.log(original_max_position_embeddings)
)
factor = max_position_embeddings / original_max_position_embeddings
self._short_scale = short_mscale or (
1.0 if factor <= 1.0 else default_scale(factor)
)
self._long_scale = long_mscale or (
1.0 if factor <= 1.0 else default_scale(factor)
)
def __call__(self, x, offset: int = 0):
x[..., : self.dim] = self.scale * x[..., : self.dim]
seq_len = offset + x.shape[-2]
if seq_len > self.original_max_position_embeddings:
freqs = self._long_freqs
scale = self._long_scale
else:
freqs = self._short_freqs
scale = self._short_scale
x[..., : self.dim] = scale * x[..., : self.dim]
return mx.fast.rope(
x,
self.dim,
@@ -62,7 +80,7 @@ class SuScaledRoPE(nn.Module):
base=None,
scale=1.0,
offset=offset,
freqs=self._freqs,
freqs=freqs,
)
+1 -1
View File
@@ -139,7 +139,7 @@ def ssm_attn(
dt = compute_dt(dt, dt_bias, time_step_limit)
repeats = h // g
A = -mx.exp(A_log)
A = -mx.exp(A_log).astype(dt.dtype)
dtA = dt * A.reshape(1, 1, -1)
dtx = dt.reshape(b, l, h, 1) * x
+22 -2
View File
@@ -34,7 +34,13 @@ from huggingface_hub import scan_cache_dir
from ._version import __version__
from .generate import BatchGenerator, stream_generate
from .models.cache import can_trim_prompt_cache, make_prompt_cache, trim_prompt_cache
from .models.cache import (
KVCache,
RotatingKVCache,
can_trim_prompt_cache,
make_prompt_cache,
trim_prompt_cache,
)
from .sample_utils import make_logits_processors, make_sampler
from .utils import load
@@ -378,6 +384,7 @@ class ModelProvider:
self.model = None
self.tokenizer = None
self.draft_model = None
self.cache_types = set()
# Preload the default model if it is provided
self.default_model_map = {}
@@ -448,6 +455,15 @@ class ModelProvider:
elif draft_model_path is not None and draft_model_path != "default_model":
self.draft_model, draft_tokenizer = load(draft_model_path)
validate_draft_tokenizer(draft_tokenizer)
# Figure out the cache types and save them in a set for anybody that
# wants to make a decision based on those.
for c in make_prompt_cache(self.model):
self.cache_types.add(type(c))
if self.draft_model is not None:
for c in make_prompt_cache(self.draft_model):
self.cache_types.add(type(c))
return self.model, self.tokenizer
@@ -477,6 +493,7 @@ class ResponseGenerator:
messages,
tools,
add_generation_prompt=True,
tokenize=True,
**self.model_provider.cli_args.chat_template_args,
)
else:
@@ -490,6 +507,9 @@ class ResponseGenerator:
or self.model_provider.cli_args.draft_model is not None
):
return False
for c in self.model_provider.cache_types:
if c not in (KVCache, RotatingKVCache):
return False
if args.logits.logit_bias is not None:
return False
if args.logits.repetition_penalty != 0:
@@ -921,7 +941,7 @@ class APIHandler(BaseHTTPRequestHandler):
self.top_p = self.body.get("top_p", self.response_generator.cli_args.top_p)
self.top_k = self.body.get("top_k", self.response_generator.cli_args.top_k)
self.min_p = self.body.get("min_p", self.response_generator.cli_args.min_p)
self.repetition_penalty = self.body.get("repetition_penalty", 1.0)
self.repetition_penalty = self.body.get("repetition_penalty", 0.0)
self.repetition_context_size = self.body.get("repetition_context_size", 20)
self.xtc_probability = self.body.get("xtc_probability", 0.0)
self.xtc_threshold = self.body.get("xtc_threshold", 0.0)
+6 -4
View File
@@ -21,6 +21,8 @@ class TestDatasets(unittest.TestCase):
cls.test_dir = cls.test_dir_fid.name
if not os.path.isdir(cls.test_dir):
os.mkdir(cls.test_dir_fid.name)
# Only one HF request
AutoTokenizer.from_pretrained(HF_MODEL_PATH)
@classmethod
def tearDownClass(cls):
@@ -37,7 +39,7 @@ class TestDatasets(unittest.TestCase):
data = {"text": "This is an example for the model."}
self.save_data(4 * [data])
args = types.SimpleNamespace(train=True, test=False, data=self.test_dir)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertEqual(len(train), 4)
self.assertEqual(len(valid), 4)
@@ -50,7 +52,7 @@ class TestDatasets(unittest.TestCase):
data = {"prompt": "What is the capital of France?", "completion": "Paris."}
self.save_data(4 * [data])
args = types.SimpleNamespace(train=True, test=False, data=self.test_dir)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertEqual(len(train), 4)
self.assertEqual(len(valid), 4)
@@ -69,7 +71,7 @@ class TestDatasets(unittest.TestCase):
}
self.save_data(4 * [data])
args = types.SimpleNamespace(train=True, test=False, data=self.test_dir)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertEqual(len(train), 4)
self.assertEqual(len(valid), 4)
@@ -91,7 +93,7 @@ class TestDatasets(unittest.TestCase):
test=False,
train=True,
)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertTrue(len(train) > 0)
self.assertTrue(len(train[0]) > 0)
+1 -1
View File
@@ -81,7 +81,7 @@ class TestGenerate(unittest.TestCase):
def test_stream_generate_speculative(self):
# Use same model as draft model, this is not a speed test
draft_model, _ = load(self.HF_MODEL_PATH)
draft_model = self.model
results: List[GenerationResponse] = []
drafted: List[bool] = []
+4 -3
View File
@@ -34,6 +34,7 @@ class TestPromptCache(unittest.TestCase):
def setUpClass(cls):
cls.test_dir_fid = tempfile.TemporaryDirectory()
cls.test_dir = cls.test_dir_fid.name
cls.model, cls.tokenizer = load(HF_MODEL_PATH)
@classmethod
def tearDownClass(cls):
@@ -132,7 +133,7 @@ class TestPromptCache(unittest.TestCase):
self.assertTrue(mx.array_equal(v, lv))
def test_cache_with_generate(self):
model, tokenizer = load(HF_MODEL_PATH)
model, tokenizer = self.model, self.tokenizer
prompt = tokenizer.encode("this is a prompt", return_tensors="mlx")[0]
results = list(generate_step(prompt, model, max_tokens=4))
toks, all_logits = zip(*results)
@@ -212,7 +213,7 @@ class TestPromptCache(unittest.TestCase):
self.assertEqual(num_trimmed, 3)
def test_trim_cache_with_generate(self):
model, tokenizer = load(HF_MODEL_PATH)
model, tokenizer = self.model, self.tokenizer
prompt = tokenizer.encode("this is a prompt", return_tensors="mlx")[0]
prompt_cache = make_prompt_cache(model)
@@ -289,7 +290,7 @@ class TestPromptCache(unittest.TestCase):
self.assertEqual(metadata, loaded_metadata)
def test_cache_to_quantized(self):
model, tokenizer = load(HF_MODEL_PATH)
model, tokenizer = self.model, self.tokenizer
prompt = tokenizer.encode("this is a prompt", return_tensors="mlx")[0]
results = zip(range(4), generate_step(prompt, model))
toks, all_logits = zip(*(r[1] for r in results))
+1
View File
@@ -19,6 +19,7 @@ class DummyModelProvider:
HF_MODEL_PATH = "mlx-community/Qwen1.5-0.5B-Chat-4bit"
self.model, self.tokenizer = load(HF_MODEL_PATH)
self.model_key = (HF_MODEL_PATH, None)
self.cache_types = set([KVCache])
# Add draft model support
self.draft_model = None