Adding New Model Architectures¶
This guide explains how to add a new model architecture to mlxcel. It is based on docs/MODEL_ADDITION_GUIDE.md (which is the canonical reference) and describes the concrete control surfaces you must touch.
Table of Contents¶
- Goals
- Before You Start
- Text Model Checklist
- VLM Checklist
- When to Create a New Family Module
- Model Implementation Pattern
- Weight Loading from SafeTensors
- Model Type Detection
- LoadedModel Dispatch
- Common Pitfalls
- Testing the New Model
- Shared Function and Execution Policy
- Dry-Run Workflow Validation
- Review Checklist
- Commit Strategy
Goals¶
- Keep new model additions predictable.
- Reuse existing control-plane helpers instead of adding new one-off branches.
- Add tests alongside the integration points that are easiest to regress.
Before You Start¶
- Identify the reference implementation:
- Text models:
mlx_lm/models/in ml-explore/mlx-lm -
VLMs:
mlx_vlm/models/in Blaizzy/mlx-vlm -
Classify the architecture:
- Brand new model family → new file under
src/models/ - Format alias of an existing family → extend detection in
detection.rs -
VLM wrapper around an existing text model → add vision encoder/connector under
src/vision/and loader undersrc/loading/vlm_<family>.rs -
Check whether an existing loader helper already matches: Start with
src/model_metadata.rs: the converged registration table. Standard text models should extend that registration table first, becausesrc/loading/config_backed.rsconsumes the same source of truth. -
Check for shared execution policy impact:
src/execution/runtime.rsfor device/environment behaviorsrc/execution/sampling.rsfor sampling defaults
Text Model Checklist¶
Follow these steps in order:
-
Add the implementation file under
src/models/<family>.rs. -
Register the module and re-export in
src/models/mod.rs: -
Add a
ModelTypevariant insrc/models/mod.rs: -
Extend
Use shared helpers when the pattern fits:get_model_type()insrc/models/detection.rs: -
Add the
LoadedModelvariant insrc/loaded_model.rs. Prefer extending thedelegate_language_model!macro rather than adding a new repeated match arm. -
Wire loading in
src/loading/mod.rs. For standard config-backed models: - Update
src/model_metadata.rsfor kind, adapter support, and route selection. -
If the model follows the standard text-model path, extend the shared registration surface instead of adding a parallel entry list in
src/loading/config_backed.rs. -
LoRA/adapter support (if applicable): verify
load_model_from_weights()insrc/loading/mod.rs. Non-standard adapter paths go insrc/loading/special.rs.
VLM Checklist¶
- Implement vision components (or reuse existing ones):
- Vision encoder:
src/vision/encoders/ - Connector (bridges encoder output to text model input):
src/vision/connectors/ -
Processor (image preprocessing):
src/vision/processors/ -
Add VLM
ModelTypedetection insrc/models/detection.rs. For families with both text-only and VLM variants, usedetect_text_or_vlm(). -
Add the loader entry in
src/loading/vlm_<family>.rs(or the nearest peer family module). Register the directory entry point intry_load_vlm_model_from_dir()insrc/loading/mod.rs. -
Add
LoadedModelcapability helpers insrc/loaded_model_capabilities.rs. Prefer extendingVlmRuntimeRefover adding family-specific getters. -
Reuse prompt helpers where possible:
- Qwen-VL token insertion:
src/multimodal/qwen_vl.rs - Generic image-token block expansion:
src/multimodal/vlm_prompt.rs - Phi3V prompt tag handling:
src/multimodal/phi3v_prompt.rs
When to Create a New Family Module¶
Do not create a new file by default. Create one when the family has a distinct control-plane identity.
Create a new loader family module when:
- config normalization is not a small variant of an existing family
- token defaults or weight-key remapping need dedicated tests
- the VLM wrapper uses a different prompt/runtime assembly path
- adding the logic inline would obscure an existing family boundary
Keep the model in an existing module when:
- it is primarily an alias or small config delta
- the same loader tests already express the policy
- the family is still recognizable after the change
If you are unsure, extend the existing family module first and split only when the test file or router starts to lose a clear boundary.
Model Implementation Pattern¶
All model implementations use mlxcel-core for MLX C++ bindings via the cxx bridge. The LanguageModel trait is defined in src/lib/mlxcel-core/src/generate.rs:
pub trait LanguageModel {
/// Forward pass: returns logits of shape [batch, seq_len, vocab_size]
fn forward(
&self,
input_ids: &MlxArray,
caches: &mut [KVCache],
mask: Option<&MlxArray>,
) -> UniquePtr<MlxArray>;
/// Create per-layer KV caches
fn make_caches(&self) -> Vec<KVCache>;
/// Number of transformer/SSM layers
fn num_layers(&self) -> usize;
/// EOS token IDs for this model
fn eos_token_ids(&self) -> Vec<i32>;
/// Whether tile-aligned padded prefill is safe (default: true).
/// Recurrent hybrids (NemotronH, Jamba, Mamba) return false because
/// pad positions corrupt their conv/SSM state.
fn supports_padded_prefill(&self) -> bool { true }
/// Drop `excess` pad positions from model-owned state after a padded
/// prefill. The default has nothing to trim; families that keep their
/// own caches and still answer `supports_padded_prefill() == true` must
/// override it.
fn trim_state(&self, seq: Option<SequenceId>, excess: i32) -> Result<(), String>;
/// Whether the scheduler's decode lookahead can be rewound from this
/// model's own per-sequence state (default: false, synchronous decode).
fn supports_decode_lookahead_rewind(&self) -> bool { false }
/// Forward with optional pre-computed embeddings (for VLM prefill).
/// The default ignores the embeddings and calls `forward`.
fn forward_with_embeddings(
&self,
input_ids: &MlxArray,
input_embeddings: Option<&MlxArray>,
caches: &mut [KVCache],
mask: Option<&MlxArray>,
) -> UniquePtr<MlxArray>;
}
The trait has more methods with defaults (chunked prefill, batched forward, sequence-addressed forwards); the excerpt shows the ones a new model most often touches. Attention layers do not choose between dense and paged attention themselves: they call cache.attend(&q, k, v, scale, mask) on their KVCache (or mlxcel_core::cache::attend_batched for a batched step), and the cache dispatches to the dense, rotating, chunked, or paged implementation it holds. A family with model-owned caches implements trim_state to take padded prefill, and supports_decode_lookahead_rewind plus rewind_decode_appends to take the server's lookahead decode; without them the family stays on unpadded prefill and synchronous decode.
Decode runs on one batch-native engine (mlxcel_core::engine::Engine) for the CLI and the server alike, and a single sequence is a batch of one, so a new model decodes through forward_batched / forward_batched_with_ids even when only one request is active. supports_batching() defaults to true. batched_decode_kind() defaults to BatchedDecodeKind::PerRowFallback, which loops forward over the rows; override forward_batched to share one graph across rows and return BatchedDecodeKind::Native, which the server logs at startup as decode path: native batched forward. Per-row fallback works but aggregate throughput does not scale with --parallel. A family whose state lives inside the model (recurrent, linear-attention, or model-owned caches) keys that state by SequenceId (sequence_state_layout, prepare_sequence_state, release_sequence_state_by_id, and forward_batched_with_ids, which receives the ids) and can then also report supports_batching() == true. Return false only for a runtime that serves a single request at a time, such as a seq2seq or duplex-speech worker; the server then clamps max_batch_size to 1.
A minimal text model implementation:
use crate::ffi::{self, MlxArray, MlxStream};
use crate::layers::KVCache;
use cxx::UniquePtr;
pub struct MyNewModel {
// Weight tensors held as UniquePtr<MlxArray>
embed_tokens: UniquePtr<MlxArray>,
layers: Vec<MyTransformerLayer>,
norm: UniquePtr<MlxArray>,
lm_head: UniquePtr<MlxArray>,
n_layers: usize,
}
impl MyNewModel {
/// Load from a weights map (SafeTensors key → MlxArray)
pub fn from_weights(
config: &MyModelConfig,
weights: &std::collections::HashMap<String, UniquePtr<MlxArray>>,
) -> anyhow::Result<Self> {
// Extract tensors from the weights map
let embed_tokens = weights
.get("model.embed_tokens.weight")
.ok_or_else(|| anyhow::anyhow!("missing embed_tokens"))?;
// ... load all layers ...
Ok(Self { /* ... */ })
}
}
impl LanguageModel for MyNewModel {
fn forward(
&self,
input_ids: &MlxArray,
caches: &mut [KVCache],
mask: Option<&MlxArray>,
) -> UniquePtr<MlxArray> {
// 1. Embed input tokens
let mut h = ffi::take(input_ids, &self.embed_tokens, 0);
// 2. Run through transformer layers
for (i, layer) in self.layers.iter().enumerate() {
h = layer.forward(&h, &mut caches[i], mask);
}
// 3. Apply final norm
h = ffi::rms_norm(&h, &self.norm, 1e-5);
// 4. Apply LM head
ffi::linear(&h, &self.lm_head, None)
}
fn make_caches(&self) -> Vec<KVCache> {
(0..self.n_layers).map(|_| KVCache::new()).collect()
}
fn num_layers(&self) -> usize {
self.n_layers
}
fn eos_token_ids(&self) -> Vec<i32> {
vec![2] // </s> token, model-specific
}
}
Weight Loading from SafeTensors¶
Model weights are distributed as SafeTensors files (.safetensors). The loading pipeline in src/loading/ reads these files and builds the weight map that is passed to from_weights().
The weight map is a HashMap<String, UniquePtr<MlxArray>> where the key is the full weight name as found in the SafeTensors file (e.g., "model.layers.0.self_attn.q_proj.weight").
Key helpers:
src/models/sanitize.rs:load_and_sanitize_weights(): loads all.safetensorsfiles from a directory, applying key normalization and tied embedding handling.src/models/sanitize.rs:sanitize_tied_embeddings(): copies the embedding table to the LM head when the model uses tied embeddings.src/models/sanitize.rs:sanitize_config_json(): normalizes known non-standard JSON patterns inconfig.jsonbefore parsing.
Weights read through mlxcel_core::weights (load_weights_from_dir and its filtered variants) are registered with the weight materialization scope that every model-load entry point opens, so a new family inherits eager checkpoint upload at load without code of its own. Tensors the loader drops and modality-tower tensors stay lazy. Do not call eval on weights in a family loader to force residency.
// Loading pattern used by most standard text models
let weights = load_and_sanitize_weights(&model_path)?;
let config: MyModelConfig = serde_json::from_str(&config_str)?;
let model = MyNewModel::from_weights(&config, &weights)?;
Weight Key Remapping¶
When the weight file uses different key names than the implementation expects, add a remapping step in the loader:
let weights: HashMap<String, _> = raw_weights
.into_iter()
.map(|(k, v)| {
let new_key = k
.replace("transformer.h.", "model.layers.")
.replace("ln_f", "model.norm");
(new_key, v)
})
.collect();
Model Type Detection¶
The detection function in src/models/detection.rs reads config.json from the model directory and matches model_type:
pub fn get_model_type(model_path: &Path) -> Result<ModelType> {
let config: serde_json::Value = /* read and parse config.json */;
let model_type = config["model_type"].as_str()?;
match model_type {
"my_new_model" => Ok(ModelType::MyNewModel),
// ...
}
}
If the same model_type string can refer to both a text-only and VLM variant, use the detect_text_or_vlm() helper:
This helper returns the VLM variant when config.json declares a vision tower (a non-empty vision_config, without language_model_only: true) and the text-only variant otherwise. detect_text_or_vlm_by_weights also scans the checkpoint's weight names and picks the text-only variant when no vision-tower weights ship, so a vision-stripped checkpoint loads as its text model.
Add a unit test in src/models/detection_tests.rs to cover the new mapping.
LoadedModel Dispatch¶
src/loaded_model.rs contains the LoadedModel enum that holds one variant per supported model type. The delegate_language_model! macro generates the dispatch code for the LanguageModel trait methods:
For VLMs, also add the capability wiring in src/loaded_model_capabilities.rs:
impl VlmRuntimeRef<'_> {
pub fn encode_images(&self, images: &[ImageData]) -> Result<UniquePtr<MlxArray>> {
match self {
Self::MyFamilyVLM(m) => m.encode_images(images),
// ...
}
}
}
Common Pitfalls¶
| Symptom | Likely Cause |
|---|---|
Unsupported model type: my_new_model at load time | Missing or misspelled entry in detection.rs |
Panic on model.layers.0.self_attn.q_proj.weight not found | Weight key mismatch; check SafeTensors key names |
| Wrong token IDs (garbled output) | Incorrect EOS token IDs in eos_token_ids() |
| VLM loads as text-only | vision_config absent, null, or {}; language_model_only: true in config.json; no vision-tower weights (vision_tower.*, model.visual.*, visual.*, vision_model.*); or detection not using detect_text_or_vlm() |
| Loader output corrupts a tool that parses stdout | Loader progress printed with println!; model loaders log through tracing::info! and keep stdout for generated output |
| LoRA adapters fail to load | Missing adapter support declaration in model_metadata.rs |
| New model type not accepted by server | LoadedModel variant missing or delegate_language_model! not updated |
| Config-backed loader bypasses new model | New model added directly to load_model() instead of the shared registration surface |
| Quantization (e.g., 4-bit) not inherited by VLM | Missing top-level quantization inheritance in VLM loader |
Regression Hotspots¶
Most regressions appear around registration, routing, or capability dispatch rather than in the decoder math itself. Check these files explicitly:
src/models/mod.rs- missing module export or
ModelTypevariant src/models/detection.rs- missing aliases in
get_model_type() - text/VLM misclassification when
vision_configis present src/loading/mod.rs- divergence between
load_model()andload_model_from_weights() - adding a standard config-backed model as a one-off special case
- adding a VLM directly into
load_model()instead oftry_load_vlm_model_from_dir() src/model_metadata.rs- forgetting text/VLM kind, adapter support, route policy, or shared standard-text registration before wiring loaders
src/loading/config_backed.rs- bypassing the shared registration surface
- forgetting wrapper constructors for models such as
Llama4,Gemma3, orMinistral3 src/loading/nonstandard.rs- leaving directory-only loader families in
src/loading/mod.rsinstead of the non-standard registry src/loading/special.rs- adding adapter/owned-weight special handling inline instead of the special-weight registry
- forgetting Qwen3.5 text-config normalization or owned-weight sanitization before construction
src/loading/vlm.rsand sibling family modules undersrc/loading/- wrong default token IDs
- missing top-level quantization inheritance
- incorrect weight-key remapping between text and vision towers
src/loaded_model.rsandsrc/loaded_model_capabilities.rs- missing dispatch arm for a new variant
- missing capability wiring in
VlmRuntimeRef - updating the all-model dispatch macro but forgetting the multimodal switchboard
Keep src/loading/mod.rs focused on route selection. If a new model family adds substantial construction logic, prefer a dedicated sibling module and call it from the router instead of growing load_model() or load_model_from_weights() inline.
For very large model families, extract internal helper hotspots into a focused sibling helper module when the code changes for different reasons than the main decoder stack. Current examples include src/models/gemma3n_helpers.rs, src/models/llama4_helpers.rs, and the VLM family modules under src/loading/.
Testing the New Model¶
Detection Tests¶
Add unit tests in src/models/detection_tests.rs that verify the model_type string maps to the correct ModelType variant:
#[test]
fn test_my_new_model_detection() {
let config = serde_json::json!({"model_type": "my_new_model"});
// write config.json to a temp dir and call get_model_type()
}
Sanitization Tests¶
If the model requires weight key remapping or config normalization, add tests in src/models/sanitize_tests.rs.
Loader Tests¶
For loader normalization or token-default logic, add tests near src/loading/tests.rs or a dedicated src/loading/<family>_tests.rs.
Smoke Test (requires a local model)¶
If you have the model weights locally, run a smoke test through the CLI:
Or via the server:
mlxcel-server --model ./models/my-new-model --port 8080 &
curl http://localhost:8080/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "my-new-model", "messages": [{"role": "user", "content": "Hello"}]}'
Running Ignored Tests¶
For tests that require actual model weights (marked #[ignore]):
Broader Test Expectations¶
Add tests in the same slice as the model/control-plane change:
- model detection helpers: tests near
src/models/detection.rs - sanitization helpers: tests near
src/models/sanitize.rs - loader normalization or token-default logic: tests near
src/loading/tests.rsor the relevantsrc/loading/vlm*_tests.rs - shared vision merge contracts: tests near
src/vision/merge_tests.rs - prompt/token expansion logic: dedicated helper tests such as
src/multimodal/qwen_vl_tests.rs,src/multimodal/vlm_prompt_tests.rs, orsrc/multimodal/phi3v_prompt_tests.rs - runtime validation:
scripts/run_quality_gate.shplus at least one local smoke test when a matching model exists
If the slice touched MLX-heavy ignored helper tests, run them explicitly with --ignored --test-threads=1.
Shared Function and Execution Policy¶
When touching shared functions used by multiple model families, update the usage comments described in AGENTS.md, especially under:
src/lib/mlxcel-core/src/layers.rssrc/lib/mlxcel-core/src/utils.rs
Those comments act as the retest list for future shared-function changes.
Keep entry-point policy in the shared execution layer when behavior must be identical across CLI, server, and future frontends:
src/execution/runtime.rs- environment-driven device selection (
MLXCEL_DEVICE) - GPU wired-memory limit setup
src/execution/sampling.rs- centralized
SamplingConfigassembly from resolved request defaults - shared greedy vs non-greedy branching
If a new frontend or request type needs different defaults, resolve those defaults at the edge and keep the final conversion in src/execution/.
Keep CLI-only prompt formatting and terminal output behavior in src/commands/generate.rs instead of moving it into shared loading or server modules.
For server-only boot behavior, keep startup policy in src/server/startup.rs instead of growing src/server/mod.rs:
- API key / chat-template resolution precedence
- startup-time normalization of CLI-compatible flags
- warmup behavior
- Unix-socket vs TCP binding
Keep shared server types in focused modules:
src/server/config.rsfor request/default configuration structssrc/server/state.rsforAppStateand metrics containerssrc/server/model_provider.rsfor the public request/response channel APIsrc/server/model_worker.rsfor the long-lived worker thread, VLM request prep, and decode state
Keep server edge adapters out of route files once more than one endpoint needs the same behavior:
src/server/chat_request.rsfor OpenAI chat message flattening and prompt fallback assemblysrc/server/request_options.rsfor request-default merging intoServerGenerateOptionssrc/server/media.rsfordata:/file://image-source parsingsrc/server/streaming.rsfor shared SSE channel and[DONE]emission helpers
Dry-Run Workflow Validation¶
Use these dry runs before coding to verify that the architecture still reduces ambiguity when adding new model support.
Dry Run A: Standard Text Model¶
Assume a new text model follows the existing config-backed loading path.
Required surfaces:
src/models/<family>.rssrc/models/mod.rssrc/models/detection.rssrc/model_metadata.rsthrough the converged registration surface plusstatic_model_descriptor()/model_load_policy()src/loading/config_backed.rsonly if shared config-backed loading behavior itself must changesrc/loaded_model.rssrc/loaded_model_capabilities.rsonly if the family changes multimodal capability exposure- tests near
src/models/detection_tests.rs,src/models/sanitize_tests.rs, andsrc/loading/tests.rs
What should not happen:
- no new one-off construction branch inside
load_model() - no direct CLI or server changes unless user-visible behavior changes
- no family-specific getter added to
LoadedModelif an existing capability is enough
This keeps route selection centralized, adapter support declared in one policy surface, and the expected edit list short enough to review before coding.
Dry Run B: New VLM Family¶
Assume a new VLM family needs its own token defaults and weight-key remapping.
Required surfaces:
- text model and/or VLM wrapper under
src/models/orsrc/vision/ src/models/mod.rssrc/models/detection.rssrc/loading/vlm_<family>.rssrc/loading/vlm.rssrc/model_metadata.rsthroughstatic_model_descriptor()/model_load_policy()src/loaded_model.rssrc/loaded_model_capabilities.rsthroughVlmRuntimeRefsrc/multimodal/only if prompt/runtime preparation is truly new- tests near
src/loading/vlm_<family>_tests.rsand any new multimodal helper test file
What should not happen:
- no concrete model-type checks added to CLI or server request paths
- no family-specific loading logic added directly to
src/loading/mod.rs - no duplicated prompt-rewrite logic across CLI and server
Review Checklist¶
Before opening a PR for a new model or VLM family, confirm:
- loading policy was updated through
src/model_metadata.rs LoadedModelwiring stayed insidesrc/loaded_model.rsandsrc/loaded_model_capabilities.rs- CLI and server still depend on shared helpers rather than the concrete model type
- unit tests cover the new policy or normalization logic
- at least one smoke test exists when a local model is available
- loaders and models log through
tracing, notprintln! - attention goes through
KVCache::attend/cache::attend_batched, and a family with model-owned caches either implementstrim_stateor answerssupports_padded_prefill() == false - batched decode is decided on purpose: the family reports
BatchedDecodeKind::Native, or its per-row fallback is acceptable, and per-sequence state is keyed bySequenceId
Commit Strategy¶
Prefer small checkpoints that isolate one control-plane surface:
- model detection
- loader normalization
- prompt preparation
- runtime initialization
This keeps regressions searchable and makes future model additions easier to compare against previous slices.