"""Optional MLX backend for Apple Silicon native inference and weight editing. MLX is Apple's array framework that runs natively on the Apple Neural Engine and Metal GPU. When available, it provides significantly faster inference and weight manipulation than PyTorch's MPS backend on Apple hardware. This module is entirely optional — if ``mlx`` / ``mlx-lm`` are not installed, ``MLX_AVAILABLE`` is ``False`` and all public functions raise ``RuntimeError``. Install with:: pip install mlx>=0.22 mlx-lm>=0.20 """ from __future__ import annotations import logging from pathlib import Path from typing import Any, Callable logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Availability check # --------------------------------------------------------------------------- MLX_AVAILABLE = False _mx = None # mlx module _mlx_lm = None # mlx-lm module _mlx_nn = None # mlx.nn module try: import mlx.core as _mx_core # type: ignore[import-untyped] import mlx.nn as _mlx_nn_mod # type: ignore[import-untyped] import mlx_lm # type: ignore[import-untyped] _mx = _mx_core _mlx_nn = _mlx_nn_mod _mlx_lm = mlx_lm MLX_AVAILABLE = True logger.info("MLX backend available (mlx %s)", _mx.__version__ if hasattr(_mx, "__version__") else "?") except ImportError: pass def _require_mlx() -> None: if not MLX_AVAILABLE: raise RuntimeError( "MLX backend is not available. Install with: pip install mlx>=0.22 mlx-lm>=0.20" ) # --------------------------------------------------------------------------- # Model loading # --------------------------------------------------------------------------- class MLXModelHandle: """Lightweight wrapper around an MLX-loaded model + tokenizer.""" def __init__(self, model: Any, tokenizer: Any, model_name: str): self.model = model self.tokenizer = tokenizer self.model_name = model_name @property def config(self) -> Any: return getattr(self.model, "config", None) def load_model( model_name: str, dtype: str = "float16", ) -> MLXModelHandle: """Load a HuggingFace model via ``mlx-lm`` for Apple-native execution. Parameters ---------- model_name : str HuggingFace model identifier (e.g. ``"meta-llama/Llama-3.2-3B-Instruct"``). dtype : str One of ``"float16"``, ``"bfloat16"``, ``"float32"``. Returns ------- MLXModelHandle Wrapper with ``.model`` and ``.tokenizer`` attributes. """ _require_mlx() from mlx_lm import load # type: ignore[import-untyped] logger.info("Loading %s via MLX (dtype=%s)", model_name, dtype) model, tokenizer = load(model_name) return MLXModelHandle(model=model, tokenizer=tokenizer, model_name=model_name) # --------------------------------------------------------------------------- # Inference # --------------------------------------------------------------------------- def generate( handle: MLXModelHandle, prompt: str, max_tokens: int = 256, temperature: float = 0.7, top_p: float = 0.9, repetition_penalty: float | None = None, ) -> str: """Generate text using the MLX model. Parameters ---------- handle : MLXModelHandle A loaded MLX model handle. prompt : str The input prompt string. max_tokens : int Maximum number of tokens to generate. temperature : float Sampling temperature. top_p : float Nucleus sampling threshold. repetition_penalty : float or None Repetition penalty (1.0 = no penalty). Returns ------- str Generated text completion. """ _require_mlx() from mlx_lm import generate as mlx_generate # type: ignore[import-untyped] kwargs: dict[str, Any] = { "max_tokens": max_tokens, "temp": temperature, "top_p": top_p, } if repetition_penalty is not None: kwargs["repetition_penalty"] = repetition_penalty return mlx_generate( handle.model, handle.tokenizer, prompt=prompt, **kwargs, ) # --------------------------------------------------------------------------- # Activation extraction # --------------------------------------------------------------------------- def get_activations( handle: MLXModelHandle, prompts: list[str], layer_indices: list[int], max_length: int = 256, ) -> dict[int, list[Any]]: """Extract hidden-state activations from specified layers. Uses MLX's computation graph to capture intermediate outputs. Parameters ---------- handle : MLXModelHandle Loaded model. prompts : list[str] Input prompts to probe. layer_indices : list[int] Which transformer layers to capture. max_length : int Maximum sequence length for tokenization. Returns ------- dict[int, list[mlx.core.array]] Mapping from layer index to list of activation arrays (one per prompt). Each array has shape ``(hidden_dim,)`` — the last-token hidden state. """ _require_mlx() import mlx.core as mx # type: ignore[import-untyped] model = handle.model tokenizer = handle.tokenizer # Identify the transformer block list layers = None for attr in ("model.layers", "transformer.h", "gpt_neox.layers"): obj = model try: for part in attr.split("."): obj = getattr(obj, part) layers = obj break except AttributeError: continue if layers is None: raise RuntimeError( "Cannot locate transformer layers in the MLX model. " "Supported architectures: LLaMA, GPT-2, GPT-NeoX." ) activations: dict[int, list[Any]] = {idx: [] for idx in layer_indices} target_set = set(layer_indices) for prompt in prompts: tokens = tokenizer.encode(prompt) if len(tokens) > max_length: tokens = tokens[:max_length] input_ids = mx.array([tokens]) # Forward through embedding if hasattr(model, "model"): # LLaMA-style: model.model.embed_tokens embed_module = model.model elif hasattr(model, "transformer"): embed_module = model.transformer else: embed_module = model if hasattr(embed_module, "embed_tokens"): h = embed_module.embed_tokens(input_ids) elif hasattr(embed_module, "wte"): h = embed_module.wte(input_ids) else: raise RuntimeError("Cannot find embedding layer in MLX model") # Walk through layers, capturing activations at target indices for i, layer in enumerate(layers): h = layer(h) # Some layers return tuples (hidden, attention) — take first if isinstance(h, tuple): h = h[0] if i in target_set: # Last token hidden state last_hidden = h[0, -1, :] mx.eval(last_hidden) # force evaluation activations[i].append(last_hidden) return activations # --------------------------------------------------------------------------- # Weight manipulation # --------------------------------------------------------------------------- def get_weight(handle: MLXModelHandle, layer_idx: int, param_path: str) -> Any: """Retrieve a weight matrix from the model. Parameters ---------- handle : MLXModelHandle Loaded model. layer_idx : int Transformer layer index. param_path : str Dot-separated path within the layer, e.g. ``"self_attn.o_proj.weight"``. Returns ------- mlx.core.array The weight tensor. """ _require_mlx() model = handle.model # Navigate to the layer layers = _get_layers(model) layer = layers[layer_idx] # Navigate the param path obj = layer for part in param_path.split("."): obj = getattr(obj, part) return obj def modify_weights( handle: MLXModelHandle, layer_idx: int, param_path: str, modifier_fn: Callable[[Any], Any], ) -> None: """Modify a weight matrix in-place using a function. Parameters ---------- handle : MLXModelHandle Loaded model. layer_idx : int Transformer layer index. param_path : str Dot-separated path within the layer to the weight, e.g. ``"self_attn.o_proj.weight"``. modifier_fn : callable Function that takes the current weight (mlx array) and returns the modified weight (mlx array). For abliteration, this would project out the refusal direction. """ _require_mlx() import mlx.core as mx # type: ignore[import-untyped] model = handle.model layers = _get_layers(model) layer = layers[layer_idx] # Navigate to the parent module and leaf attribute parts = param_path.split(".") parent = layer for part in parts[:-1]: parent = getattr(parent, part) leaf_name = parts[-1] old_weight = getattr(parent, leaf_name) new_weight = modifier_fn(old_weight) # MLX uses a functional update pattern if hasattr(parent, "update"): parent.update({leaf_name: new_weight}) else: setattr(parent, leaf_name, new_weight) mx.eval(new_weight) # materialize def project_out_direction(weight: Any, direction: Any) -> Any: """Project a direction out of a weight matrix (abliteration). Given weight matrix W and unit direction d, computes:: W' = W - (W @ d) outer d Parameters ---------- weight : mlx.core.array Weight matrix, shape ``(out_features, in_features)``. direction : mlx.core.array Unit direction vector, shape ``(in_features,)``. Returns ------- mlx.core.array Modified weight with direction projected out. """ _require_mlx() import mlx.core as mx # type: ignore[import-untyped] d = direction.astype(weight.dtype) # W @ d gives the component along d for each row proj = mx.matmul(weight, d[:, None]) # (out, 1) return weight - mx.matmul(proj, d[None, :]) # (out, in) # --------------------------------------------------------------------------- # Save model # --------------------------------------------------------------------------- def save_model( handle: MLXModelHandle, output_dir: str | Path, upload_repo: str | None = None, ) -> Path: """Save the (modified) MLX model to disk. Saves in safetensors format compatible with both MLX and HuggingFace. Parameters ---------- handle : MLXModelHandle Model handle (possibly with modified weights). output_dir : str or Path Directory to save into. upload_repo : str or None If set, also uploads to HuggingFace Hub. Returns ------- Path The output directory. """ _require_mlx() out = Path(output_dir) out.mkdir(parents=True, exist_ok=True) # mlx-lm's save uses safetensors if hasattr(_mlx_lm, "save_model"): _mlx_lm.save_model(str(out), handle.model, handle.tokenizer) else: # Fallback: manual save via mlx.core.save_safetensors import mlx.core as mx # type: ignore[import-untyped] weights = dict(handle.model.parameters()) flat = {} _flatten_dict(weights, "", flat) mx.save_safetensors(str(out / "model.safetensors"), flat) # Save tokenizer via transformers handle.tokenizer.save_pretrained(str(out)) logger.info("MLX model saved to %s", out) if upload_repo: try: from mlx_lm import upload_to_hub # type: ignore[import-untyped] upload_to_hub(str(out), upload_repo) logger.info("Uploaded to %s", upload_repo) except (ImportError, AttributeError): logger.warning("mlx-lm upload not available — push manually with huggingface-cli") return out # --------------------------------------------------------------------------- # Conversion: PyTorch ↔ MLX # --------------------------------------------------------------------------- def torch_tensor_to_mlx(tensor: "torch.Tensor") -> Any: # noqa: F821 """Convert a PyTorch tensor to an MLX array.""" _require_mlx() import mlx.core as mx # type: ignore[import-untyped] # Move to CPU and convert via numpy np_array = tensor.detach().cpu().float().numpy() return mx.array(np_array) def mlx_to_torch_tensor(array: Any, device: str = "cpu") -> "torch.Tensor": # noqa: F821 """Convert an MLX array to a PyTorch tensor.""" import numpy as np import torch np_array = np.array(array) return torch.from_numpy(np_array).to(device) # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _get_layers(model: Any) -> Any: """Locate the transformer block list in an MLX model.""" for attr_path in ("model.layers", "transformer.h", "gpt_neox.layers"): obj = model try: for part in attr_path.split("."): obj = getattr(obj, part) return obj except AttributeError: continue raise RuntimeError("Cannot locate transformer layers in MLX model") def _flatten_dict(d: dict, prefix: str, out: dict) -> None: """Flatten a nested dict with dot-separated keys.""" for k, v in d.items(): key = f"{prefix}{k}" if prefix else k if isinstance(v, dict): _flatten_dict(v, f"{key}.", out) else: out[key] = v