Skip to content

Many small perf improvements, add Gemma 4 family Qwen 3.5+ family, vision and audio - #356

Open
flukejones wants to merge 41 commits into
oxiglade:mainfrom
flukejones:main
Open

Many small perf improvements, add Gemma 4 family Qwen 3.5+ family, vision and audio#356
flukejones wants to merge 41 commits into
oxiglade:mainfrom
flukejones:main

Conversation

@flukejones

Copy link
Copy Markdown

Infra / core

  • mlx-c bump v0.31.2; alloc + cast cleanups
  • id-keyed compile cache (compile_with_id)
  • decode throughput bench harness; CODE_REVIEW.md

KV cache

  • KVCache + attention() trait + pipelined decode
  • sliding-window RotatingKVCache
  • plain quantized KV cache (asymmetric k_bits/v_bits)
  • cap MLX pool 20 MB after load
  • quantized SDPA dtype-stable fix

Runtime / arch

  • generation runtime + family adapters
  • typed config.json via Family enum
  • unified family layout crate::/text/
  • cache/ module + MTP/chunked-prefill hooks

qwen3.5

  • dense + gated-delta-net
  • MoE + nn::switch + MTP
  • vision (VLM) path
  • bench cells (dense/MoE + MTP A/B)

gemma4

  • dense text family
  • MoE (26b-a4b)
  • per-layer-input + KV sharing (E2B/E4B)
  • SigLIP vision tower + VLM dispatch/processor/adapter
  • audio encoder (USM Conformer) + audio front-end
  • MTP speculative decode; shared speculative sampling + current_kv cache

examples/chat

  • REPL example
  • VLM one-shot (--image/--prompt)
  • --draft-model / --mtp-depth

Fixes: BOS prepend in TextOnlyProcessor.

Two new model families (qwen3.5, gemma4) full text+VLM, gemma4 audio, MTP speculative decode end-to-end.

flukejones added 30 commits June 2, 2026 08:28
Zero-byte file carried in from upstream f4aa309; never had content.
Review checks that compile clean but fail or silently corrupt at
runtime on the Metal stream — the class of bug clippy can't catch.

- MLX state/threading/Metal: no thread_local for compiled-graph or
  Array caches (mlx-c v0.31 SIGSEGVs on the destructor/stream race),
  kernel-name caching, grid semantics, f64 reject, bool-mask sentinels.
- FFI: mlx-sys is the only direct mlx-c caller; Drop-once on handles;
  single-source version pin.
- Array clones as per-token FFI roundtrips; borrow-not-clone; async_eval
  decode scheduling; no item() barrier in the hot loop.
- Dtype strictness: silent bf16->f32 promotion poisons the graph;
  cache dtype-bound scalars.
- Fixed-N allocation, decode-only bench methodology, error/type/import
  conventions.
Harness:
- criterion harness; short + long_prompt (T=1024) per model
- llama 1B + qwen3 1.7B at bf16/q8/q4
- MLX_LM_BENCH_SET=full adds llama 3B + qwen3 0.6B at same quants
- MLX_LM_BENCH_ONLY substring filter on per-cell group prefix
- checkpoint cache + auto-download via `hf` CLI
- MLX_LM_BENCH_NO_DOWNLOAD, MLX_LM_BENCH_CACHE, MLX_LM_BENCH_SET
- BENCHMARK.md methodology + run instructions

Shared decode step (so bench can't drift from production):
- models::decode_step — one per-token unit (reshape, forward, slice
  last position, sample). Both Generate::next() and the bench drive it.
- decode is synchronous here (no async pipelining yet); the bench times
  Generate::next() one token at a time, eval-fenced — the real cost.
- unify the duplicated per-model ModelInput into nn::ModelInput.
- fix qwen3 decode: it sampled un-sliced [B,1,vocab] logits (NewAxis
  then stacked each step); now slices last position like llama.

Also hoists pre-existing crate::utils::scaled_dot_product_attention
qualifiers in both models to top-level use (check-paths).

Pre-runtime scaffolding: models::decode_step + the per-model Generate
iterators benched here are the only decode path until the LanguageModel/
ModelContext runtime lands, which replaces both and rewrites this bench
onto the production ModelContext path. The throwaway is intentional — the
runtime can't precede the models it abstracts.
Hand-picked from clippy pedantic; pass-by-value on public
constructors deferred to perf-scope commit where the API break
has a measured justification.

mlx-rs:
- iter().cloned() -> copied() on i32 slices (linear, indexing)
- iter().for_each() -> for loop (module trait)
- iter() removed from for-loop heads (modules, optimizers, io)
- match Some/None -> let-else (utils::mlx_closure_payload)
- match self.is_frozen { true/false } -> if/else (param)
- match self.nesterov { true/false } -> if/else (sgd)
- match align_corners -> if/else (upsample)
- panic-in-if -> assert! (indexing ellipsis check)
- needless borrow on randint scratch (arithmetic test)
- allow(match_same_arms) on Dtype::promote_with table:
  per-pair enumeration is intentional documentation

mlx-lm:
- match (k,v) -> let-else for quantized/non-quantized pair
  (utils::scaled_dot_product_attention)

mlx-lm-utils:
- iter().map(..).flatten() -> flat_map (tokenizer test)

mlx-tests:
- allow(dead_code) on QuantizableExample (derive-only example)

Restores cargo clippy --workspace --all-targets -- -D warnings.
- bump mlx-c submodule a1290d -> fba4470 (v0.5.0 -> v0.31.2)
- workspace 0.25.3 -> 0.26.0
- Stream::sync, Array::wait + is_available
- nn::RmsNorm weight Option (no-scale variant)
- transforms::compile rewrite: TypeId cache, GAT CallMut,
  per-arity shape markers; closures +Send
- fast::MetalKernel + MetalKernelConfig
- random default state thread-local (v0.31 TLS hazard)
- memory module
- fft / quantization ops adapt to v0.31 signatures
- mlx-rs/benches/compile_overhead
- nn::Rope + mlx-lm utils::rope: pass 4-D `[B,N,T,D]` direct;
  prior reshape zeroed all but head-0 on decode
- nn::Alibi: bias-matrix cache moved from a thread_local to a struct
  field — v0.31 SIGSEGVs when a thread_local Array cache destructor
  races the GPU stream at thread exit. Alibi gains the cache field +
  Default; matrix() takes &self.
- mlx-lm: quantization module + qwen3/llama loader rewrites
  `<prefix>.weight` -> `<prefix>.inner.weight` for keys with a
  `.scales` sibling (q4/q8 against v0.31 QuantizedLinear)
- qwen3 loader handles single-file + sharded checkpoints
Squashed port of 12 fork commits targeting upstream-resident files.

perf:
- defer eager FFI calls in unwrap_or hot paths: 27 sites switched to
  unwrap_or_else so mlx_array_new only fires on None
  (fast::*, ops::quantization::*, ops::arithmetic::block_masked_mm,
   nn::transformer builders, random::{randint,categorical,truncated_normal})
- fuse indexing presence-prove + position scans: index_op_to_array and
  expand_ellipsis_operations halve the scan count on the hot
  KV-cache slicing / attention-mask construction path
- drain VectorArray::try_into_array into stack [T; N] via MaybeUninit;
  no Vec heap alloc on the quantized KV cache write path
- scalar-output compile/grad without Option unwrap: VectorArray::try_into_one
  reads the lone Array from the C-vector directly; CompiledState gains
  *_with_one paths used by 3 single-output CallMut impls

refactor:
- Entry API for get_mut_or_insert_with (optimizers update_single)
- propagate NumCast failures in arange/linspace as Exception::custom
  instead of silent panic on NaN / out-of-range
- VectorArray::try_into_array fixed-N drain helper
- drop over-defensive Option unwraps across crate (no behaviour change)
- drop Option unwraps in random/adafactor
- drop unnecessary Result wraps; allow on trait-bound sites
- document remaining trait-impl unwraps via .expect("…") so panics are
  self-documenting (PartialEq, Sum, Neg, IndexOp, IndexMutOp, Array::t)

feat:
- ops::contiguous wrapper (mx.contiguous) for callers that need an
  explicit row-major copy
- new loader.rs with apply_post_load_memory_policy(): clear_cache then
  set_cache_limit to the resolved cap; called after eval_params in
  load_llama_model and load_qwen3_model
- DEFAULT_CACHE_LIMIT_BYTES = 20 MB (matches mlx-swift LLM guidance)
- set_cache_limit_override(n): programmatic override, first-call-wins
- MLX_LM_CACHE_LIMIT_BYTES env var: runtime override
- precedence: override > env > 20 MB default

Loading n GB of safetensors stages through scratch buffers the MLX
allocator parks in the reuse pool. Multi-model bench runs accumulate
this dead memory. Decode-time reuse is small (<100 MB) and capping has
no measurable perf cost on the upstream-supported models.
Trait:
- fn attention(queries, keys, values, scale, mask) -> Array entry on
  KeyValueCache. Default: update_and_fetch + scaled_dot_product_attention.
- resolve_sdpa_mask: None + n_q>1 is Causal; None + decode is no mask.
- assert_mask_matches_keys (debug-only): catches [L, L] causal masks
  built without cache.offset().
- llama and qwen3 Attention::forward route through cache.attention().

Cache:
- KVCache: pre-allocated [B, H, capacity, D] buffers. First call
  allocates init_capacity (default 64) tokens; overflows double the
  buffer (Vec-style geometric growth). update_and_fetch slice-writes
  the new tokens into the next [offset..offset+s] rows and returns
  graph-view Array slices over the populated [..offset] range.
- Eliminates per-step concatenate_axis (O(N) per step → O(1) amortised).
- Replaces ConcatKeyValueCache as the only KV cache type. Trait keeps
  is_quantized/group_size/bits as default no-ops for a future
  quantised-cache type to populate.

Decode pipelining (added to the shared models::decode_step):
- decode_step now async_evals its result: submit step N+1's forward +
  sample before the caller syncs N, so the GPU runs N+1 during N's sync.
  Generate::step delegates to it; the bench inherits the same overlap,
  so production and measurement still can't drift.
- Generate gains DecodeFirst (primes the pipeline on first decode) +
  Decode states; prefill stays a single prompt forward.
- inv_temp (1.0/temp) cached once on the iterator and threaded into
  decode_step; greedy (temp 0) takes the argmax path.
- generate/mod.rs (disabled module): pipeline GenerateToken, read
  tokens back in one batched eval instead of try_item per step.
quantized_scaled_dot_product_attention had two dtype hazards (mlx is
strict; a stray f32 promotes the whole graph and Metal rejects f64):

- `queries * scale` (f32 scalar) promoted bf16/fp16 queries to f32,
  poisoning gemm + softmax for the rest of the forward. Stage scale
  into the query dtype: Array::from_f32(scale).as_dtype(q_dtype).
- the bool-mask nucleus sentinel was Array::from_f64(finfo_min); f64
  lands on the Metal stream and the dispatch fails. Build as f32, cast
  to the scores dtype.

Both latent on this branch (only the quantized-cache SDPA branch hits
them, and KVCache reports is_quantized() == false; the non-quantized
path delegates scale to fast::scaled_dot_product_attention). Real bugs
for any quantised KV cache that lands later.
The `generate/` module was disabled (`// pub mod generate`) and unused;
its only consumers were the `ModelInput`/`ModelOutput`/`ModelInputBuilder`
glue traits in lib.rs, which nothing else references. Remove both.

The per-model `models::*::ModelInput` structs (the `Module::forward`
input) are unaffected — distinct from the deleted `ModelInput` trait.
Parse config.json once into a typed ModelConfig with a Family enum
(serde tag = model_type). Unknown model_type fails with
`unknown variant` at parse instead of falling through a loader.

- config.rs: ModelConfig::from_dir + Family + quantization() that
  reconciles `quantization` vs legacy `quantization_config` once.
- family.rs: EosSpec (int | [int] normaliser); envelopes carry
  eos_token_id.
- models/{llama,qwen3}: ModelArgs loses model_type + quantization*
  (now on the Family tag / outer struct), gains eos_token_id.
  Loaders read off ModelConfig::from_dir; drop get_*_model_args +
  the per-loader resolve_quantization duplication. Dead model_type()
  accessor removed.
- error.rs: typed Error enum (Config/Shape builders + From<Error>
  for Exception bridge).
The production runtime and the family-adapter dispatch land together, so
the model-local decode loop they replace never has to be introduced and
then removed across commits.

- LanguageModel/UserInputProcessor traits, ModelContext, generate(), the
  chat-template + user-input + sampler surface (model_context.rs,
  language_model.rs, lm_input.rs, user_input.rs, chat_template.rs,
  sampler.rs).
- load() dispatch via the Family enum + per-family adapters
  (llama/adapter.rs, qwen3/adapter.rs, family.rs).
- shared nn/ layer: AttentionInput + SwiGLU MLP hoisted out of the
  byte-identical llama/qwen3 copies (nn/{attention_input,swiglu_mlp}.rs).
- decode_step is a ModelContext method over &mut dyn LanguageModel;
  generate() and the bench both call it, so pipelining can't drift
  between production and measurement. benches/lm_decode.rs runs one
  family-agnostic path over ModelContext.

Breaking: mlx_lm::models::{llama,qwen3}::Generate and
models::{decode_step,sample_logits,inv_temp} removed; public entry is
mlx_lm::{load, generate}.
Restructure llama + qwen3 into one per-family layout so qwen3.5 (and
future families) drop in identically. Pure relocation + dispatch
convention — model graphs and internals unchanged, decode path
untouched, bench-neutral.

- models/<family>.rs -> <family>/text/model.rs; <family>/adapter.rs ->
  <family>/text/adapter.rs; ModelArgs split into <family>/text/config.rs.
- Family: name() + as_llama()/as_qwen3(). Adapters take (cfg, args, dir)
  and stop re-reading config; load_context(cfg, dir) dispatches via
  as_<family>(). model_context::dispatch_load passes cfg through.
- Drop the models/ module (code ported, not removed).
Groundwork for the qwen3.5 port: shared runtime + cache reshape, no
family wired in yet. KVCache + attention() carried verbatim (perf wins
preserved); FullAttnCache is a Standard-only KVCache passthrough.

- cache.rs -> cache/{mod,trait_def,kvcache,full_attn,options}.rs.
  KVCache + KeyValueCache::attention() byte-identical (re-export keeps
  crate::cache::KVCache path). FullAttnCache: Standard(KVCache) only,
  zero per-step cost; Quantized arm + Pi rotation deferred to quant-KV.
  CacheOptions/CacheKind + prefill-chunk helpers. build_rotation->None.
- language_model.rs: defaulted trait hooks prefill_chunk(_size),
  has_mtp, try_mtp_decode, set_mtp_depth, set_cache_options. llama/qwen3
  override nothing.
- model_context.rs: GenerateParams.disable_mtp, run_mtp_loop, use_mtp
  branch, chunked run_prefill. decode_step path unchanged.
- .gitignore: un-ignore mlx-lm/src/cache/ (bare cache/ swallowed it).
QuantizedKVCache: affine-quant K/V with independent k_bits/v_bits, six
packed buffers grown geometrically. K is the softmax-sensitive tensor
(kept high), V tolerates low bits.

CacheKind::Quantized is { group_size, k_bits, v_bits } with presets
quantized_q8/k8_v8/k8_v4/q4 (DEFAULT_KV_GROUP_SIZE=64, MIN_K_BITS=8).
with_config clamps unsafe configs (k_bits up to MIN_K_BITS, v_bits down
to k_bits) with a stderr warning; unsupported bit-widths are a hard error.

Read dispatches on k_bits == v_bits: equal -> packed
quantized_scaled_dot_product_attention (shared bits); unequal -> dequant
each at its own bits then dense SDPA (one quantized_matmul can't mix
bit-widths). No rotation (TurboQuant lands later).

- utils::quantized_scaled_dot_product_attention queries Array -> &Array
  (no per-call clone; only caller is the dead routing wrapper)
Add Compile::compile_with_id + allocate_compile_id; key the mlx-c compile
cache on a process-wide monotonic id instead of TypeId.

Two distinct fn-pointers cast to the same concrete signature share one
TypeId, so keying mlx_detail_compile on the type id made the second
compile() silently reuse the first function's compiled graph — e.g. an
attention_gate of the same (&Array,&Array) signature returning
sigmoid(output)*gate after a swiglu warmed the slot. Monotonic ids give
one compiled-graph slot per call regardless of source type; a stable
per-op id (allocate_compile_id) lets many module instances share one
compiled Metal kernel.
Interactive REPL against any mlx_lm checkpoint (ported from realign,
server path dropped). Streams assistant output through a small
think/answer colouriser: reasoning (<think>…</think>, tags stripped)
renders dim, the answer bold green, and the readline prompt bold cyan
so user input reads distinctly. Resets the KV cache per turn and
re-renders full chat history each request.

Flags: --model, --temperature/--top-p, --max-tokens, --think
(on|off|default), --prefill-chunk-size, and the KV-cache controls:
  --kv-cache standard|q8|q4   preset (default standard)
  --k-bits / --v-bits         per-tensor bit override (forces quantised;
                              unset tensor follows the preset, else the
                              other bit; bare --k-bits 8 -> k8/v8)
  --kv-group                  quantisation group size (default 64)
The resolved K/V/group config is logged to stderr before generation.

- new workspace member examples/chat (publish = false)
- no markdown/table rendering; deps trimmed to argh/rustyline/anyhow/
  serde_json/env_logger
Dense text path for the Qwen3.5 hybrid family: full-attention layers +
gated-delta-net (GDN) linear-attention layers, dispatched per
config.json layer_types. Loads + decodes a real dense checkpoint
(text-only load from a VLM checkpoint ignores vision-tower weights).

- qwen3_5/text/: config (text_config envelope), mrope, attention +
  SwiGLU MLP, GDN block (depthwise Conv1d + cached Metal recurrent
  scan), hybrid LayerCache, weights, dense adapter.
- config: Family::Qwen35 + as_qwen35()/name(); lib + dispatch arm.
- activations: plain swiglu/attention_gate.
- loader: load_tokenizer/list_shards/rewrite_quantised_keys.
- mlx-tests: dense GDN-hybrid load+decode e2e (ignored; needs MODEL).
- Qwen35MoeBlock: shared+routed DeepSeek-style MoE over the qwen3.5
  hybrid GDN + full-attention spine; sigmoid-gated shared expert.
- nn::switch: SwitchLinear/QuantizedSwitchLinear (gather_mm/gather_qmm),
  SplitSwitchFfn + SwigluActivation; expert-id sort above threshold.
- quantization: per-tensor overrides + for_path; QuantMode enum.
  mlp.gate/shared_expert_gate requantised to 8-bit when overridden.
- MTP self-speculative decode: adapter_moe rejection loop + sampling
  top_p_keep_mask; SamplerState gains sampler()/masked_log_probs().
- Family::Qwen35Moe variant (model_type qwen3_5_moe) + dispatch.
- qwen3_5 dense GDN-hybrid (Qwen3.5-4B q8/q4): GDN scan kernel +
  full-attn hybrid spine on the production decode path.
- qwen3_5_moe (Qwen3.6-35B-A3B q8): gather_qmm experts + MoE routing.
- maybe_bench_mtp A/B: generate() MTP-on vs -off throughput at temp>0
  (top-p), keyed on emitted tokens.
- Full set only (heavy models); each self-skips when checkpoint absent.
Add the cross-cutting public surface a vision adapter consumes, gated on
a new default-on `image` cargo feature (text-only builds compile with
--no-default-features, no codec dep):

- LMInput.image: Option<ProcessedImage> + ProcessedImage {pixels, grids}
- UserInput.images: Vec<Image> + with_images builder; Image enum
  (Decoded(DynamicImage) | Pixels {array, grid})
- Error::OutOfBounds + Error::out_of_bounds (multimodal index checks)
- lib re-exports: ProcessedImage, Image

Runtime-inert: no VLM code yet. Text adapters set image: None; the whole
vision surface compiles out feature-off.
Add the qwen3.5 VLM under qwen3_5/image/ (feature-gated on `image`):

- vision.rs: Qwen3-VL ViT tower (Conv3d PatchEmbed, 2D-rope cu_seqlens
  attention, bilinear pos-embed interp, PatchMerger).
- processor.rs: Qwen35ImageProcessor (smart_resize, patchify) over the
  image crate.
- multimodal.rs: merge_input_ids_with_image_features + mrope
  get_rope_index_single_batch/_batched + pack_position_ids.
- weights.rs: load_full_model splits LM + vision-tower params.
- adapter.rs: Qwen35VlmAdapter wraps Qwen35DenseAdapter; Qwen35Processor.

Re-adds the seams stripped in the dense/MoE commits, now at their VLM
consumer (all #[cfg(feature = "image")]): Bucketed::Vision routing in
text::weights (text loaders drop it), Qwen35Model/Decoder forward_embeds
+ the inputs_embeds path, Qwen35DenseAdapter cursor/rope_delta +
prefill_embeds.

Family::Qwen35Vl + dispatch; load_context probes preprocessor_config.json
to route VL checkpoints, text-only fallback when the feature is off.

mlx-tests gains a feature-gated, ignored VL e2e (needs a dense qwen3.5-VL
checkpoint via MODEL + IMAGE).
2× ring-buffer KV cache for sliding-window attention (Gemma 3/4 sliding
layers). Layout [B,H,keep+2*window,D]: keep prefix is write-once, the
rotating region wraps with O(window) amortised-O(1)/token compaction so
steady-state decode is one try_index_mut write + one contiguous view.
Multi-token prefill returns old_window ++ new so each new token attends
the full window without a mask recompute.

Implements KeyValueCache (offset/max_size/update_and_fetch; attention via
the trait default). inherent trim/is_trimmable. No steel-prefill kernel
(uses fast::sdpa) and no state serialization yet — added with a consumer.
Hybrid sliding/global attention (per-layer head_dim, q/k/v norms),
proportional partial-rotation rope, GeGLU MLP, four norms per layer,
embedding scaling, final-logit soft-capping, tied embeddings. Validated
against gemma-4-31b (dense, 60 layers, sliding_window 1024). Wired into
Family::Gemma4 + load dispatch.

Shared additions (reusable beyond gemma):
- nn::RmsNormNoScale (rms_norm with no learnable gain)
- activations: geglu / logit_softcap / residual_add_scale compiled caches
- cache::effective_prefill_chunk_opt (windowed prefill cap)

MoE, per-layer-input embeddings (E2B/E4B), KV-sharing, and vision are
deferred; each re-adds at its own consumer. The sliding cache's single
forward must not exceed sliding_window — the adapter's prefill_chunk_size
enforces it via effective_prefill_chunk_opt.
The fast tokenizer's post_processor omits BOS for several mlx-community
conversions (Gemma drops it entirely; the single-template adds no
special tokens), so raw-text prompts reached the model without <bos>.
BOS-sensitive families collapse without it — Gemma 4 emitted degenerate
repetition (" France is France is...") instead of coherent text.

- resolve_bos_id: read add_bos_token + bos_token from tokenizer_config,
  resolve the id via the tokenizer; honour explicit add_bos_token:false,
  else prepend when a bos_token exists (HF default for Gemma/Llama 3).
- TextOnlyProcessor gains bos_id; prepend in prepare unless already
  present (chat templates emitting {{ bos_token }} keep one BOS).
- Wire through all five text adapters. Qwen (bos_token:null) -> None,
  byte-identical output, no regression.
Dual-branch decoder layer for the enable_moe_block variant: the dense
GeGLU MLP and a 128-expert top-8 routed FFN run in parallel and sum.
Reuses the shared SplitSwitchFfn + router_topk kernel; the experts bind
the checkpoint's split experts.switch_glu.{gate,up,down}_proj keys.

- nn/switch.rs: GegluActivation (SwitchActivation over the geglu cache).
- gemma4/text/moe.rs: Router (rms_norm with scale*hidden^-0.5 -> proj ->
  router_topk -> *per_expert_scale[idx]) + Experts
  (SplitSwitchFfn<GegluActivation>).
- DecoderLayer: enable_moe gate with Option router/experts + 3 extra
  norms (post_ff_1, pre_ff_2, post_ff_2). forward_layer now returns
  AttentionOut and takes shared_kv/offset/per_layer_input (None in the
  dense+MoE base) — the one forward-compat hook for the E2B/E4B follow-on.
- Single Family::Gemma4 dispatch: dense vs MoE decided per-layer in
  DecoderLayer::new from enable_moe_block. No second adapter; the loader
  binds split experts/router via the existing quantised-key rewrite.
- bench: gemma4 26b_a4b q8/q4 cells. e2e: gemma4_moe_e2e (Paris).
The E2B/E4B variants add two mechanisms over the dense/MoE base, both
config-gated (inert when their fields are 0, so 31b/26b are unchanged):

- Per-layer-input embeddings (hidden_size_per_layer_input > 0): a separate
  embedding plus a projection of the main hidden, scaled/normed/averaged
  into a [B,L,num_layers,pl] tensor sliced per layer; each layer gates it
  in via gelu(gate(h)) * pl_in -> proj -> norm -> add (between the FFN
  residual and the layer-scalar multiply). Scales staged in h dtype.
- KV sharing (num_kv_shared_layers > 0): the last N layers own no K/V
  projection and reuse a prior same-kind layer's (k, v) + offset. Attention
  K/V/norm become Option (has_kv gate); compute_previous_kvs builds the
  source-index table; the model threads each layer's fetched KV through
  intermediates into its downstream sharer; make_caches yields None slots
  for shared layers; the loader drops their self_attn.k_*/v_* keys.
- Mlp::new takes the effective intermediate width — doubled on KV-shared
  layers when use_double_wide_mlp (E2B).

Reuses the C-A forward_layer signature (shared_kv/offset/per_layer_input)
unchanged. Validated e2e (chat -> Paris) on gemma-4-e4b-it-8bit (42 layers,
18 shared) and gemma-4-e2b-it-8bit (35 layers, 20 shared, double-wide).
- gemma4/image/vision.rs: PatchEmbedder (Linear patchify + per-axis
  position table), bidirectional VisionAttention (q/k norm, param-free
  v-norm, 2-D RoPE, scale=1), GeGLU VisionMlp, VisionEncoderLayer
  (gemma sandwich norms), VisionPooler (k×k avg-pool ×√hidden),
  VisionModel (standardize), EmbedVision (RMS-noscale → quantized Linear)
- gemma4/image/config.rs: VisionConfig (gemma4_vision)
- gemma4/image/multimodal.rs: stitch over qwen merge helper
- text.rs: forward_embeds (Model + Gemma4TextModel) + embed_tokens
  accessor; extract embed_scaled/forward_from_hidden/apply_head; text
  embeds carry embed_scale, stitched vision features do not
- text/weights.rs: expose rewrite_outer_key + is_shared_kv_layer_key

Vision tower weights bf16; projector quantized. Synthetic shape tests.
- text/config.rs: vision_config (image-gated) + image/boi/eoi token ids
  on gemma4 ModelConfig
- gemma4/mod.rs: route vision_config + processor_config.json checkpoints
  to the VLM adapter (image feature); else text-only
- image/processor.rs: Gemma4ImageProcessor — aspect-preserving resize to
  a pooling·patch-divisible grid, rescale to [0,1], channels-first
- image/weights.rs: load_full_model — bucket keys into text / bf16 vision
  tower / quantized projector; quantize text + embed_vision only
- image/adapter.rs: Gemma4VlmAdapter (tower → projector → embed_scaled →
  stitch → forward_embeds; image-token ids masked to 0 for per-layer
  inputs) + Gemma4Processor (chat render, <|image|>→boi+image×N+eoi,
  token-count assert)
- vision.rs: standardize after the pooler's √hidden scaling (per-token
  bias, not per-patch) — the order that matches the reference
- gemma4_vision_e2e.rs: ignored MODEL+IMAGE caption smoke test

Validated on gemma-4-31b-it-4bit: caption matches mlx-vlm reference.
- --image <path>: decode + send as one user_with_image turn, then exit
  (skips the REPL). VLM dispatch is automatic once UserInput carries the
  image; --prompt is the instruction (default blank).
- factor think-kwarg + sampling/params resolution into apply_think /
  build_params, shared by the one-shot path and the REPL loop.
- image dep (png/jpeg) for image::open.

Validated on gemma-4-31b-it-4bit: --image + prompt -> correct caption;
blank prompt -> model captions unprompted; text REPL unchanged.
Opt-in `audio` feature (implies `image`). Fixes e2b/e4b load: the audio
tower keys are now bound (audio on) or dropped (audio off) instead of
landing unbound in the text bucket.

- gemma4/audio/config.rs: AudioConfig; ModelConfig gains audio_config +
  audio/boa/eoa token ids.
- gemma4/audio/clippable.rs: ClippableLinear (Linear + live input/output
  clamps; audio sets use_clipped_linears=true).
- gemma4/audio/encoder.rs: SubSampleConvProjection (2x Conv2d subsample),
  ConformerFeedForward (macaron), AudioAttention (chunked local attn,
  relative-position bias + relshift, per-dim-scale softplus, logit
  softcap, f32), ConformerLightConv1d (depthwise causal-conv GLU),
  ConformerBlock, AudioEncoder, EmbedAudio (RMS-noscale -> quantized
  Linear 1536->text_hidden).
- gemma4/audio/multimodal.rs: stitch over the shared masked-scatter.
- vlm/weights.rs: generalized VLM loader returns LoadedTowers with an
  optional audio tower+projector; audio bf16, projector quantized; vision
  clip buffers dropped, audio clip buffers kept (live).
- vlm/adapter.rs: adapter holds the optional audio tower; prepare()
  stitches image and/or audio; image+audio token ids masked to 0 for PLE.
- LMInput gains an audio carrier (ProcessedAudio log-mel).

Synthetic shape/clip unit tests. Validated: e4b loads clean with audio on
(tower binds) and off (keys dropped). Front-end + input wiring follow.
- gemma4/audio/feature.rs: log_mel front-end (16kHz mono -> [1,T,128]) --
  framed periodic-Hann + rfft + HTK mel filterbank + log; num_audio_tokens
  derives the soft-token count from the conv-subsample frame math.
- vlm/adapter.rs: Gemma4Processor audio branch (log_mel, <|audio|> ->
  boa+audio*N+eoa expand, token-count assert) -> ProcessedAudio carrier;
  modality-generic marker expansion shared with images.
- chat_template.rs: ContentPart::Audio + ChatMessage::user_with_audio.
- user_input.rs: Audio carrier + with_audio; lm_input ProcessedAudio.
- examples/chat: --audio one-shot (16kHz mono WAV via hound; behind the
  chat `audio` feature).

Tests (committed sine fixture + golden .f32, CI-safe, no model):
- gemma4_audio_logmel: log_mel vs golden (front-end numerics lock).
- gemma4_audio_encoder: seeded synthetic-weights encoder vs golden
  (encoder math lock).
- vlm/weights.rs audio_encoder_real_weights_stats (#[ignore], MODEL):
  real-weight load-path lock (clip buffers, key binding).
- gemma4_audio_e2e (#[ignore], MODEL+AUDIO): transcription smoke test.

Validated on gemma-4-e4b-it-8bit: transcript matches the reference;
log_mel <0.003 max-abs on the committed fixture.
@flukejones

Copy link
Copy Markdown
Author

I went well overboard with this. Most of it is reviewed multiple times by myself and a few AI agents. The last 4-5 commits could use a finer eyeball I think.

The series has been project for the last few weeks since I needed some particular features for my own goals.

I've done my best to maintain benchmarks, and have quite thoroughly tested the bulk of all the series and note some steady perf increases in most things except for qwen35/gemma4 as these are a reimplemented of work I did in another branch that was very throughly tweaked.

Extract the family-agnostic speculative-decode primitives so a second
family (gemma4 drafter) can reuse them; zero behavior change.

- crate::speculative: top_p_keep_mask + sample_draft/accept_mask/
  resample_on_reject (was qwen3_5/text/sampling.rs + adapter_moe.rs) and
  a generic CacheSnapshot<T> (was the qwen-local two-cache struct).
- qwen3_5 MoE adapter calls the shared helpers; the two-cache snapshot
  becomes two single-cache guards (committed/rolled together — same
  behavior).
- KeyValueCache::current_kv: non-mutating dense (k,v) over the cached
  history (None when empty; quantized dequants). KVCache/RotatingKVCache/
  FullAttnCache/QuantizedKVCache + gemma LayerCache. RotatingKVCache
  promotes snapshot_window.

Gate: qwen MoE mtp_greedy_matches_plain_decode unchanged; current_kv
unit test vs update_and_fetch.
Load a separate `gemma4_assistant` drafter checkpoint alongside a gemma4
target and run speculative decode: the drafter (a 4-layer Q-only gemma4
stack) borrows the target's per-type cached K/V + last hidden, drafts γ
tokens, and the target verifies them in one parallel forward. Greedy is
byte-exact vs plain decode (the spec-decode guarantee).

- gemma4/mtp/config.rs: DrafterConfig (`gemma4_assistant`; nested gemma4
  TextConfig, backbone width, centroid flags, per-size depth).
- gemma4/mtp/drafter.rs: Drafter = gemma4::text DecoderLayers (all
  KV-shared → Q-only) + pre_projection(2*backbone→draft) +
  post_projection(draft→backbone) + own tied embed/head. Borrows target
  K/V via the existing `shared_kv` seam (cache None — read-only).
- gemma4/mtp/centroid.rs: MaskedEmbedder sparse lm head (E2B/E4B
  `use_ordered_embeddings`): centroid scores → top-k clusters → exact
  logits for those tokens via `token_ordering`, scatter to vocab.
- gemma4/mtp/decode.rs: snapshot → draft γ (read-only) → one verify
  forward → accept prefix + bonus (greedy) or rejection-sample, rolling
  back + re-committing the accepted prefix on a partial reject.
- gemma4/mtp/weights.rs: assistant loader; hydrates the token_ordering
  buffer (not a #[param]).
- text.rs: forward_hidden_and_logits + embed_scaled_token (the drafter
  concat uses the embed_scale×√hidden token embedding the assistant was
  trained on). config.rs: deserialize_softcap accepts null (drafter sets
  final_logit_softcapping=null).
- adapter.rs: Gemma4Adapter holds Option<Drafter>; has_mtp/try_mtp_decode/
  set_mtp_depth; captures prev_hidden in prepare/step/prefill_chunk.
- model_context.rs: load_with_drafter(dir, draft_dir); gemma4-only.
- gemma4_mtp_e2e.rs: greedy parity (drafter == plain greedy) — the
  correctness gate.

Validated on gemma-4-e4b-it-8bit (target) + gemma-4-E4B-it-assistant:
greedy with/without the drafter match token-for-token.
- --draft-model <dir>: load a gemma4 MTP assistant (drafter) alongside
  the target via load_with_drafter; speculative decode runs automatically.
- --mtp-depth <n>: override drafter depth γ (default per assistant size).

Validated: gemma-4-e4b-it-8bit + gemma-4-E4B-it-assistant one-shot
generates correctly with the drafter loaded.
- DrafterConfig gains optional quantization (from assistant config.json)
- load_drafter applies try_into_quantized before binding, matching the
  rewrite_quantised_keys inner.weight key path
- prior loader assumed bf16 drafters; quantized assistant checkpoints
  (e.g. *-assistant-8bit) failed to bind embed_tokens/proj keys
- new Family::Gemma4Unified variant + dispatch (model_type gemma4_unified)
- gemma4_unified/{config,adapter,mod}: dense text + MTP speculative decode
- reuses gemma4::text::{Model,cache} + gemma4::mtp wholesale; the unified
  text backbone + assistant drafter are structurally identical
- gemma4 text loader: load_model takes &TextConfig (shared by both
  families); drop vision_embedder keys (encoder-free MM front-end)
- gemma4 text loader honours per-tensor quant overrides (mlx-community
  4bit ships MLP gate/down/up_proj at 8bit over a 4bit body)
- extract requantise_linear to crate::quantization; qwen3_5 MoE reuses it
- encoder-free vision/audio front-ends deferred to follow-on milestones
- image/{config,embedder,processor,weights,adapter}: encoder-free vision
  path. No SigLIP tower — patch_ln1 → patch_dense → patch_ln2 →
  factorised-2D-posemb → pos_norm → RMSNorm(no-scale) → projection
- processor extracts model_patch_size (48px) HWC pixel blocks + 2D
  position ids, pads to num_soft_tokens with -1; equals the HF
  convert_image_to_patches + patches_merge result (cross-checked in test)
- VLM adapter strips padding patches, stitches soft tokens into
  image_token_id slots, decodes via Model::forward_embeds
- MTP composes with vision: image affects prefill only, so the drafter
  decodes off the populated KV cache (forward_embeds_hidden_and_logits
  seeds prev_hidden). --draft-model + --image now both active
- mod.rs routes to the VLM adapter when vision_config + processor_config
  present (drafter optional)
- ProcessedImage gains optional position_ids (encoder-free vision needs
  per-patch 2D ids; tower families pass None)
- rewrite_quantised_keys also redirects <prefix>.bias → inner.bias for
  quantised linears with a bias (patch_dense); was weight-only
- audio/{config,feature,embedder,weights}: encoder-free audio path. No USM
  Conformer, no mel — raw 16kHz waveform framed into audio_samples_per_token
  (640 = 40ms) rows, projected via RMSNorm(no-scale) → linear into text space
- feature: pad + reshape waveform to [T, 640]; 25 tokens/sec
- VLM adapter handles audio alongside image: encode_audio + stitch into
  audio_token_id slots; composes with MTP (audio is prefill-only)
- processor frames clips, expands <|audio|> markers to boa+audio×N+eoa,
  asserts token count; render_prompt/count_match gain audio
- ProcessedAudio.mel renamed to .features (mel for gemma4 USM tower, raw
  frames for unified); gemma4 vlm adapter updated to match
- chat --audio one-shot works for the unified family
- in-tree converter: load bf16 shards → per-family Rewriter → quantise →
  sharded safetensors + index + quantised config.json + tokenizer assets
- Rewriter trait + QuantClass (Skip/Body/Pinned) drive per-tensor rules;
  unknown shapes hard-error rather than silently dropping tensors
- runner evals per shard to cap peak at ~1x model size
- qwen3_5 family wired (dense + MoE incl. MTP, gate-up split)
- --verify round-trips the output through mlx_lm::load + a short generate
- workspace deps: add serde/serde_json/anyhow/argh/env_logger
- Gemma4UnifiedRewriter: uniform body-quant (no per-tensor pins) → a true
  q4, unlike mlx-community's mixed 4bit (MLP pinned q8)
- body-quant projection weights (attn q/k/v/o, MLP gate/down/up,
  embed_tokens, encoder-free patch_dense + embed_vision/audio projections);
  skip norms (RMSNorm + vision LayerNorm), layer_scalar, pos_embedding, biases
- keys pass through unchanged (already the loader's expected form)
- drop_mm flag (default false) → full-MM q4; true → text-only
- --family gemma4_unified wired into the bin

=== downloading bf16 mirror, check progress ===
du -sh /tmp/full-models/gemma-4-12B-it-bf16 2>/dev/null; tail -1 /tmp/bf16_dl.log 2>/dev/null
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.

1 participant