Skip to content

safemlx: gemma-4 E4B decode ~1.5 tok/s vs ~36 tok/s for mlx-lm on the same M4 — per-token cost scales with prompt length #4

Description

@texchi2

Thanks for the crate — the vendored MLX with checksums and the offline build are exactly what I wanted from a Rust MLX stack, and safemlx-lm built cleanly first try on two accounts (3 m 3x s each) with Xcode 26.6 / Metal toolchain 17F109.

I measured it against mlx-lm on the same model, same snapshot, same machine, and the decode path looks like it is recomputing the whole context every step. Reporting in case it is a known gap for gemma-4, or a misuse on my side.

Environment

• Mac mini M4, 16 GB, macOS 26.5.2, Xcode 26.6 (clang 21.0.0), Metal toolchain 17F109 (metal version 32023.883), cmake 4.4.3, cargo 1.98.0
• safemlx-sys 0.2.3 / safemlx 0.3.1 / safemlx-lm 0.4.1 from crates.io, cargo build --release --locked -j 4, default features
• Model: mlx-community/gemma-4-e4b-it-4bit, snapshot 475b9088d29754a3379866cf5aeb6b41acd313c2
• Comparison: mlx-lm 0.31.3 on mlx 0.31.2, same snapshot, python -m mlx_lm.generate --temp 0 --max-tokens 128
• Both runs: greedy, 128 generated tokens, two other idle model servers resident (~4.6 GB free); pausing them changes wall time by ~2 s, so this is not memory pressure

safemlx-lm — examples/qwen35_moe_bench.rs pointed at the gemma-4 snapshot (it loads through LoadedModel::load; profiling off)

$ ./target/release/examples/qwen35_moe_bench <snapshot> 128 prefill_128
load_s=2.512
warmup_s=0.469    case,prompt_tokens,prefill_s,generated_tokens,decode_s,decode_tok_s,first_id,last_id 

prefill_128,152,0.489611,128,85.149649,1.491,107,106

$ ./target/release/examples/qwen35_moe_bench <snapshot> 128 prefill_512
load_s=2.239
warmup_s=0.458
prefill_512,529,1.665251,128,241.580618,0.526,107,106

So: prefill ~310 tok/s (fine), but decode 1.49 tok/s at a 152-token prompt and 0.53 tok/s at 529 tokens. Per generated token that is 0.66 s and 1.89 s — almost exactly the measured prefill time of the whole prompt in each case, which is what made me suspect the cache is not being extended for this architecture. The gemma4_e4b_probe example shows the same rate (90 tokens in ~27 s including a
2.5 s load), and it uses the same generate_input_with_cache path.

mlx-lm on the same snapshot

Prompt: 160 tokens, 253.398 tokens-per-sec
Generation: 128 tokens, 36.350 tokens-per-sec
Peak memory: 4.448 GB

36 tok/s is ~110 GB/s of weight traffic, close to the M4's 120 GB/s bus, so mlx-lm is at the bandwidth wall; safemlx-lm is at ~4 % of it.

Questions

  1. Is incremental KV-cache decode expected to work for the gemma-4 loader in 0.4.1, or is the per-step recompute known?
  2. Is there a flag or a different generation entry point I should be using for gemma-4 (the bench example is the Qwen3.5-MoE one; I used it only because it is the one that prints prefill/decode separately)?

Happy to re-run any diagnostic build on this machine and post the numbers.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions