Skip to content

vendoring mlx-lm componenets - #880

Open
Lazarus-931 wants to merge 22 commits into
Blaizzy:mainfrom
Lazarus-931:vendor-mlxlm
Open

vendoring mlx-lm componenets#880
Lazarus-931 wants to merge 22 commits into
Blaizzy:mainfrom
Lazarus-931:vendor-mlxlm

Conversation

@Lazarus-931

@Lazarus-931 Lazarus-931 commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Moves the transformer code mlx-audio's speech models rely on (KV caches, masks, samplers, the decode loop, and nine backbones) out of mlx-lm and into mlx_audio/lm/, so mlx-lm is no longer a core dependency. Files are copied from mlx-lm v0.31.3 with a provenance header, laid out in the same directory shape so re-syncing stays easy. The one exception is the S2S pipeline's chat model, which loads an arbitrary LLM — that still uses mlx-lm, lazily, behind an optional [llm] extra.

Verification: 1,531 tests pass, 1,237 with mlx-lm uninstalled entirely. 286 differential tests assert bitwise equality against mlx-lm 0.31.3. Found and fixed a few bugs on the way, including one where stream_generate re-emitted the previous token instead of EOS, which shifted codec framing at the end of TTS output.

Lazarus-931 and others added 19 commits August 4, 2026 22:34
Each supporting ASR model now accepts a uniform hotwords: list[str] in its own
generate() and folds it into its native prompt field: qwen3_asr->system_prompt,
whisper->initial_prompt, vibevoice_asr->context, moss_transcribe_diarize->prompt.
fun_asr_nano already handles hotwords natively. Backends without a hook ignore
the field (silent drop, per maintainer preference). Shared merge_hotwords helper
in stt/utils.py; models keep their native fields for backward compatibility.
Scaffolding for vendoring mlx-lm's transformer machinery into mlx-audio.
The lm package stays import-cheap (no transformers at import time); parity
tests live outside the CI test matrix and pin mlx-lm to the exact version
being vendored from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trimmed copy of mlx_lm/models/base.py: drops the quantized SDPA path since no
cache in mlx-audio exposes .bits. Swaps the 13 import sites. Parity tests
assert bitwise equality with mlx-lm 0.31.3 across the mask cross-product, and
that create_attention_mask returns the same *type* (str/None/array) so SDPA
dispatch cannot silently change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trimmed copy of mlx_lm/models/cache.py keeping the caches mlx-audio uses:
KVCache, RotatingKVCache, BatchKVCache, ArraysCache, make_prompt_cache and the
create_causal_mask re-export. Drops QuantizedKVCache, ConcatenateKVCache,
ChunkedKVCache, CacheList, BatchRotatingKVCache, the prompt-cache save/load/trim
helpers and the prompt trie. Extracted by AST so retained code is byte-identical
to upstream. Swaps the 33 import sites.

Parity tests cover RotatingKVCache wraparound across max_size/keep/sequence
shapes comparing state after every step, BatchKVCache.merge in the shape
continuous batching actually uses, and KVCache growth across the step-256
boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verbatim copy of mlx_lm/sample_utils.py (zero internal deps). Swaps the 23
import sites.

Parity tests handle the mx.compile/RNG-state trap: these kernels are decorated
with inputs=mx.random.state, so every comparison re-seeds before each call and
also asserts the post-call RNG state matches, otherwise later sampler calls
would diverge. Two upstream failure modes are pinned as parity evidence rather
than hidden - min_p=0 hits math.log(0) and min_tokens_to_keep hits a
put_along_axis signature mismatch in mlx-lm 0.31.3; both raise identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream passes a Python bool to mx.put_along_axis, so any caller with
min_p > 0 and min_tokens_to_keep > 1 raises TypeError. That path is reachable
from vibevoice_asr, qwen3_asr, fun_asr_nano, glmasr and moss_music, all of
which expose both knobs. Now that mlx-audio owns this code, fix it rather than
pin the crash: pass a bool array, so min_tokens_to_keep is honoured (verified
exactly N survivors under aggressive filtering) while the min_tokens_to_keep=1
path stays bitwise-identical to upstream.

Also moves the parity CI job below its own comment so the modular job's
comment is no longer orphaned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rope_utils.py and switch_layers.py copied verbatim; activations.py keeps only
swiglu (xielu/XieLU is used solely by the apertus backbone, which mlx-audio
does not vendor). Swaps the one SwitchGLU import in zonos2. This is the support
layer the transformer backbones need.

Parity tests cover initialize_rope across linear/llama3/yarn scaling with
traditional on/off and non-zero offsets, SwitchLinear/SwitchGLU forward passes
with shared weights, and the quantized switch path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds mlx_audio/lm/{generate,load,convert}.py and flips the remaining call
sites: generate_step/stream_generate/generate across 13 STT and 4 TTS models,
the conversion helpers used by mlx_audio/convert.py and the per-model convert
scripts, and dramabox's text-encoder loader. Retargets the fun_asr_nano
monkeypatch, which patched mlx_lm.generate by module name and would otherwise
have passed vacuously.

Two bugs fixed in the vendored conversion helpers:
- save_model's donate_model path passed flattened dotted keys to
  model.update(), which expects a nested tree; use tree_map over
  model.parameters() as upstream does. This broke every quantized-checkpoint
  round trip (caught by the cohere_asr 4-bit and 8-bit generate tests).
- quantize_model dropped upstream's fine-grained branch, so re-quantizing an
  already-quantized config recorded no per-layer parameters.

Only sts/voice_pipeline.py still imports mlx-lm, by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mlx-lm moves to an optional [llm] extra, needed only by the speech-to-speech
pipeline's in-process LLM responder - the one place that loads an arbitrary
chat model rather than a speech model's own transformer. Its imports move
inside LocalLLMResponseEngine, with an actionable ImportError.

This also fixes a live bug: voice_pipeline imported mlx_lm at module scope, so
'pip install mlx-audio[sts]' could not import VoicePipeline at all. The modular
CI job only passed because the [all] install happened to pull mlx-lm in.

Adds test_no_mlx_lm.py as a permanent guard: an AST walk rejecting mlx_lm
imports outside the allowed file, a regex sweep for the dynamic
importlib/__import__ form, and a check that mlx-lm never returns to
[project.dependencies].

Verified with mlx-lm uninstalled: 1237 passed, 40 skipped, no collection
errors; parity modules skip cleanly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
utils.py injects config["model_path"] for every model, but from_dict forwarded
**config_dict straight into the dataclass, so loading LFM2.5-Audio raised
TypeError before any model code ran. Filter to declared fields, matching how
BaseModelArgs.from_dict behaves.

Pre-existing and unrelated to the mlx-lm vendoring; found while verifying
models on an M4 mini.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generate parity test used a stub model, so it exercised none of the risky
paths. Replace it with two structurally identical llama backbones sharing
weights, compared across: greedy and sampled streams, logits processors,
prefill chunk sizes including a single-step and an oversized one, max_kv_size
(which forces RotatingKVCache wraparound), the max_tokens boundary, and
input_embeddings. Adds a cross control running the vendored loop against the
upstream model, so a loop bug is distinguishable from a backbone bug.

Also documents the import table in the contributing guide and makes the guard
test's failure message point at mlx_audio.lm, since several in-flight PRs add
new mlx_lm imports and would otherwise hit an unexplained failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
generate.py: the EOS break happened before last_token was assigned, so the
terminal GenerationResponse could never report finish_reason='stop' and
re-emitted the previous token instead of the stop token. That matters for the
four TTS consumers that append response.token to input_ids: the appended value
was a duplicate audio code rather than the EOS the parsers filter, shifting
codec framing at the tail of an utterance. Also restores a final response when
the first sampled token is EOS, makes max_tokens=-1 unbounded again, and
restores upstream's wired-limit warning.

convert.py: save_model wrote a single monolithic safetensors file and donated
the model only after serializing. Restore 5 GB sharding with the index map, and
release references before writing so shards free as they go. mixed-quant now
gives lm_head high bits unconditionally, as upstream does - the previous gate
dropped it to low bits on models with fewer than eight layers.

load.py: load weights strictly, so a mismatched text encoder raises instead of
silently loading partly random weights.

pyproject: [sts] and [all] now include mlx-lm. Dropping it from core also
dropped it from those extras, which would have made the default S2S pipeline
raise at startup.

Adds parity coverage for the conversion helpers (all four mixed-quant recipes
across 4/8/32 layers, which is what caught the lm_head gate) and permanent
EOS-semantics tests that survive the parity teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Local isort 7.0.0 wraps single-name imports in parentheses; the version CI
pins keeps them inline. Run pre-commit rather than bare black/isort so the
hook versions match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Lazarus-931
Lazarus-931 marked this pull request as ready for review August 10, 2026 13:32
@Lazarus-931
Lazarus-931 requested review from Blaizzy and lucasnewman and removed request for lucasnewman August 10, 2026 13:38
@Lazarus-931 Lazarus-931 changed the title wip of vendoring mlx-lm vendoring mlx-lm componenets Aug 10, 2026
Comment thread mlx_audio/lm/models/activations.py Outdated
@@ -0,0 +1,15 @@
# Copyright © 2023-2024 Apple Inc.
# Vendored from mlx-lm v0.31.3 (ed1fca4cef15a824c5f1702c80f70b4cffc8e4dd),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the commit hashes and implementation notes are particularly useful here, unless you think so?

Comment thread mlx_audio/sts/voice_pipeline.py Outdated
from mlx_lm.utils import load as load_llm
except ImportError as exc:
raise ImportError(
"The in-process LLM responder needs mlx-lm: pip install 'mlx-audio[llm]'. "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make this a little bit more straightforward for the user? "Please run pip install -U mlx-lm to run this script" or something like that.

@lucasnewman lucasnewman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Lazarus-931 Overall this looks great. I had a couple of minor comments inline.

Lazarus-931 and others added 3 commits August 10, 2026 13:28
Signed-off-by: Alazer Manakelew <alazermanakelewb@gmail.com>
Signed-off-by: Alazer Manakelew <alazermanakelewb@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants