Vision works on the MiniMax-M3-W4A16-GPTQ b12x stack — 33 tok/s + OCR-grade multimodal on 2× GB10

Huge thanks to @a3refaat for the b12x stack + the Sebesky GPTQ/EAGLE3 checkpoints — reproduced the **36 tok/s** text number *exactly* on our 2× DGX Spark (GB10) pair. The recipe notes say *“Vision is not tested or validated but should work in theory.”* We tested it — **it works, and it’s OCR-grade.** Sharing the result and the exact steps in case others want multimodal.

TL;DR

Dropping `–language-model-only` loads the full 32-layer ViT and the b12x backend handles the VL forward path correctly. On our pair:

Config Decode tok/s Context Vision
b12x text-only (a3refaat’s recipe) **36.3** tg32 up to ~196k
**b12x + vision (this post)** **32.9** tg32 (peak 35.7) ~98k (106k-tok pool) ✅ OCR-grade

**Vision costs only ~3 tok/s.** Prefill stays ~1000 tok/s. Coherent, consistent, no VL-specific crashes.

Quality — it’s not just “sees shapes”

Synthetic test (red square / blue circle / “SPARK” text): read all three, position + color correct, 3× consistent.

Then a text-heavy report image (title bar, three `$1,240,000`-style figures, a small bar chart, a footer). Asked for the title, the three values, and who prepared it:

```
Title: Quarterly Sales Report
Q1: $1,240,000 Q2: $1,890,000 Q3: $1,050,000
Prepared by: Finance Dept
```

Every string and every digit exact. The GPTQ W4A16 quantization does not visibly degrade vision.

How to enable it (deltas from the nvfp4-eagle3 recipe)

  1. **Remove `–language-model-only`** — that’s the whole switch; the ViT + patch-merge projector are in the Sebesky checkpoint (519 vision weight keys, 32-layer ViT, hidden 1280).
  2. **Add `–limit-mm-per-prompt ‘{“image”: 4, “video”: 0}’`.** The multimodal profiler reserves a *video* budget at load (~6 GB here) that you never use — turning video off gives it back to the KV pool. Without this, KV init fails (“No available memory for the cache blocks”).
  3. **Budget for the ViT (~4 GB).** With the tower loaded we run `gpu_memory_utilization 0.92` + `max_model_len 98304` → a **106,112-token pool** with vision. (Text-only can push util higher / context longer; the ViT is the tradeoff.)

Everything else is a3refaat’s recipe unchanged: `–attention-backend b12x`, `–kv-cache-dtype nvfp4`, EAGLE3 drafter (`Sebesky/MiniMax-M3-EAGLE3-RTN-INT4`, `num_speculative_tokens 3`), cudagraph `FULL_DECODE_ONLY`.

Two gotchas that bit us hard (both cross-node, both software)

Neither is hardware-specific — same GB10s as everyone — but they cost us hours, so:

  1. **Warm the Triton cache on BOTH nodes, or the cross-node cudagraph deadlocks.** The b12x triton-prewarm mod didn’t cover every serving-shape kernel on our build (its exception is swallowed unless `B12X_TRITON_PREWARM_STRICT=1`), so `eagle_prepare_next`, `_nvfp4_write`, `update_regular_decode`, `rejection_sample` JIT-compiled *during* the first cudagraph replay. On a no-GPUDirect cross-node setup the worker’s cache started nearly empty while the head’s had them → the ranks desync mid-replay → `shm_broadcast: No available shared memory broadcast block found in 60 seconds` → EngineDead / garbage output. Fix: `rsync -a ~/.triton/ :~/.triton/` (identical sm_121 nodes → portable kernels). `/root/.triton` is a host mount, so once both are warm it stays fixed. **This was the source of every “garbage output” symptom we chased** — the model was fine.

  2. **Check GPU clocks on BOTH nodes before trusting a low number.** We got 20 tok/s and nearly blamed the config — the head GPU was **power-wedged** (pinned 611 MHz / ~10 W under 95% load, *zero* active throttle reasons in `nvidia-smi -q -d PERFORMANCE`). On TP=2 the lockstep pair drags to the wedged clock. A **reboot does not clear it — a full AC power-cycle does** (unplug the USB-C PSU). After that: head back to ~2400 MHz, 20 → 36 tok/s instantly. If your cross-node number is suspiciously low, `nvidia-smi --query-gpu=clocks.sm --format=csv` on both nodes first.

(Also: the `fix-prometheus-fastapi-routing` mod hard-failed the launch on our image — package version differs; making its “block not found” branch a graceful skip instead of `SystemExit` fixed it. And on a head running a desktop, the util-0.93 free-mem check fails by ~0.5 GB — we set the nodes headless (`systemctl set-default multi-user.target`) to reclaim it.)

Net

For anyone who needs multimodal M3 on 2× Spark: this stack gives **~33 tok/s with OCR-grade vision at ~98k context**, which as far as we can tell is the fastest coherent M3-vision on a Spark pair by a wide margin. Thanks again @a3refaat — the vision was one flag away the whole time.

Happy to share our recipe/config diffs.

I couldn’t be happier to hear that this worked out for you! I left the ViT in full precision and excluded it from quantization entirely, really cool that its still working cleanly. This model seriously pushes the 2xGB10 to the limit - but in my experience so far it’s worth it. Lately, I’ve experimented with dropping EAGLE-3 to serve the model with 196k context with fp8_e4m3 kv cache to preserve quality. Throughput drops to around 23-24 t/s, which is still manageable for me.

Something worth putting into perspective - MiniMax-M3 scores 44 on artificial analysis, which is on par with Opus 4.6. If you had told me back in February that by July, we would have comparable capabilities on our desk, I wouldn’t have believed it. Who knows where we’ll be a year from now. Enjoy! Hope you continue to get value from the build.

Thanks for your work on it, I’ve been struggling to crack 20tok/s with this model til now.

It’s very cool that I’m now running minimax 3 on two devices. Special thanks to everyone who contributed to this!

Can you share full recipe?

MiniMax-M3-W4A16-GPTQ + b12x + EAGLE3 on 2× DGX Spark — build notes & recipe

Got the vision stack from this thread running on 2× DGX Spark (GB10, SM121), 121 GiB unified memory each, connected over a single 200G ConnectX-7 link. Sharing exactly what we built, the three environment fixes we needed, and our full working recipe — in case anyone else hits the same walls.

Base stack
Repo: a3refaat/spark-vllm-docker, branch minimax-m3-4bit-w4a16 (has DEPLOYMENT.md, the three recipes, and the vendored b12x kernels at commit d7cc622).
Model: Sebesky/MiniMax-M3-W4A16-GPTQ (Marlin W4A16 MoE, ~209 GB) + EAGLE3 drafter Sebesky/MiniMax-M3-EAGLE3-RTN-INT4 (~1.6 GB). Both prefetched to both nodes.
Serving: TP=2, --distributed-executor-backend mp (no Ray), b12x attention backend, nvfp4 KV, EAGLE3 speculative decoding (3 draft tokens), FULL_DECODE_ONLY cudagraphs.
Image: built via ./build-deploy.sh -c <worker-ip> (vLLM from source at the pinned ref + minimax-m3-fused-fp8-kv patch + Rust tool parser, then the b12x layer).
Exact versions that work
vLLM               0.22.1rc1.dev535+g979b56a66 (built from source at 979b56a66c96)
nvidia-cutlass-dsl 4.6.0
apache-tvm-ffi     0.1.12
flashinfer-python  0.6.14
b12x vendored      d7cc62205ca8c7ff903da8de9f4c6491eb2086f6
3 fixes we had to make (this was the whole battle)
The engine loaded fine but crashed on the first inference request of any length. Root cause turned out to be a chain of three version mismatches around the CuTe-DSL kernels on SM121:

nvidia-cutlass-dsl must be 4.6.0, not 4.5.2. vLLM pins 4.5.2 in requirements/cuda.txt, but 4.5.2 fails to build the b12x MSA indexer TMA kernel on SM121:
error: unable to partition input tensors for TMA → ValueError: Operation creation failed (in contiguous_kernel.py). Upgrading to 4.6.0 (released 2026-07-02) builds it cleanly. (Note: 4.6.0.dev0 is unusable — its [cu13] flavor is broken, missing libcute_dsl_runtime.so.)
apache-tvm-ffi must be ≥0.1.11 (we used 0.1.12). cutlass-dsl 4.6.0 needs make_kwargs_wrapper(map_dataclass_to_tuple=...); the image shipped 0.1.9 → TypeError: make_kwargs_wrapper() got an unexpected keyword argument 'map_dataclass_to_tuple' during engine init.
One-line b12x patch in vendor/b12x/b12x/attention/_cute/pipeline.py. cutlass 4.6.0 threads loc/ip into pipeline state.advance() calls, but b12x's own PipelineStateSimple.advance(self) didn't accept them → TypeError: PipelineStateSimple.advance() got an unexpected keyword argument 'loc' during warmup. Fix:
def advance(self, *, loc=None, ip=None):   # accept & ignore loc/ip for cutlass-dsl 4.6.0
    if const_expr(self._stages == 1):
        self._phase_index ^= 1
    else:
        self._phase_index += 1
Two smaller things: the pinned vLLM ref is a squash-merged PR commit, so the Dockerfile's git fetch origin && checkout couldn't reach it — we added an explicit git fetch origin <SHA>. And mods/fix-prometheus-fastapi-routing hard-failed on a version drift, so we softened it to a graceful skip.

Debugging tip: iterating on a full 2-node cluster launch is ~10 min. We got the loop down to ~30 sec by reproducing each kernel failure standalone — docker run the image and call the failing b12x API directly (or run the vendor pytest, e.g. tests/test_attention_paged_forward.py). That's what pinned each root cause.

GB10 gotchas
Drop page caches on both nodes right before launch (sync; echo 3 > /proc/sys/vm/drop_caches), or the gpu_memory_utilization 0.92 free-memory check fails boot-to-boot (free 112 < needed 113 GiB).
Warm the Triton/CuTe caches on both nodes. B12X_TRITON_PREWARM_STRICT=1 + rsync ~/.triton and ~/.cache/b12x master↔worker after first warmup, or the cross-node cudagraph deadlocks.
First request after boot is slow (~60–100 s, JIT), then steady-state speed.
Performance (our numbers)
llama-benchy (pp2048/tg32): prefill ~920–1000 t/s across depths; decode ~30–36 t/s at 4K–32K depth, ~42 t/s at depth 0. Vision tower adds essentially 0 overhead on text-only requests (text vs vision recipe identical within noise).
OCR: read a synthetic invoice image — all lines, exact digits (amounts, tax IDs, serials), zero errors.
tool-eval-bench v2.1.0 (69 scenarios, seed 42): 80/100, averaged over 3 runs (80/77/80 — the ±3 is pure temp=1.0 sampling noise). Consistent weak spots: multi-step chains, toolset scale, and it reliably leaks a fake API key from an injected file (TC-58) — worth an agent-side guard.
Full working recipe (vision, 98K ctx)
recipe_version: "1"
name: MiniMax-M3-W4A16-GPTQ-b12x-nvfp4-eagle3-vision
model: Sebesky/MiniMax-M3-W4A16-GPTQ
container: vllm-node-minimax-m3-b12x
build_args:
  - --vllm-ref
  - 979b56a66c969ab67655d2155ab4c6c5bed15f65
  - --apply-vllm-patch
  - minimax-m3-fused-fp8-kv.patch
  - --build-rust
cluster_only: true
mods:
  - mods/minimax-m3-gptq-b12x-eagle3
defaults:
  port: 8000
  served_model_name: MiniMax-M3
  host: 0.0.0.0
  tensor_parallel: 2
  draft_tensor_parallel: 2
  spec_tokens: 3
  gpu_memory_utilization: 0.92
  max_model_len: 98304
  max_num_batched_tokens: 1024
  max_num_seqs: 1
  block_size: 128
  kv_cache_dtype: nvfp4
  attention_backend: b12x
  load_format: safetensors
  drafter_model: Sebesky/MiniMax-M3-EAGLE3-RTN-INT4
env:
  PYTORCH_CUDA_ALLOC_CONF: "expandable_segments:True"
  VLLM_MARLIN_USE_ATOMIC_ADD: "1"
  OMP_NUM_THREADS: "20"
  MKL_NUM_THREADS: "20"
  OPENBLAS_NUM_THREADS: "20"
  NUMEXPR_NUM_THREADS: "20"
  VLLM_USE_BREAKABLE_CUDAGRAPH: "0"
  VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS: "0"
  HF_TOKEN: $HF_TOKEN
  VLLM_M3_FP8_KV_TEST_SCALE: "1.0"
  B12X_TRITON_PREWARM_STRICT: "1"
command: |
  vllm serve Sebesky/MiniMax-M3-W4A16-GPTQ \
      --served-model-name {served_model_name} \
      --trust-remote-code \
      --port {port} --host {host} \
      --gpu-memory-utilization {gpu_memory_utilization} \
      -tp {tensor_parallel} \
      --distributed-executor-backend mp \
      --block-size {block_size} \
      --kv-cache-dtype {kv_cache_dtype} \
      --attention-backend {attention_backend} \
      --attention-config '{{"indexer_kv_dtype": "nvfp4"}}' \
      --compilation-config '{{"mode": 0, "cudagraph_mode": "FULL_DECODE_ONLY", "cudagraph_copy_inputs": true}}' \
      --speculative-config '{{"method":"eagle3","model":"{drafter_model}","draft_tensor_parallel_size":{draft_tensor_parallel},"num_speculative_tokens":{spec_tokens},"attention_backend":"b12x"}}' \
      --default-chat-template-kwargs '{{"thinking_mode":"adaptive"}}' \
      --max-model-len {max_model_len} \
      --max-num-batched-tokens {max_num_batched_tokens} \
      --max-num-seqs {max_num_seqs} \
      --limit-mm-per-prompt '{{"image": 4, "video": 0}}' \
      --load-format {load_format} \
      --reasoning-parser minimax_m3 \
      --enable-auto-tool-choice \
      --tool-call-parser minimax_m3
For text-only we run the same recipe minus the vision deltas: --language-model-only back on, max_model_len: 196608, no --limit-mm-per-prompt. Huge thanks to @a3refaat for the b12x stack + checkpoints and @danielgbates for the vision deltas.

@tonyd615 might be able to further improve :)

I’m working off this, this has a bigger KV pool and vision. I am down to look into this though

Can we further improve ?

I switched back to my previous M3 recipe, this recipe was way too KV constrained for my needs, working on trying to speed up the auto round quant I was using before which leaves more headroom. My work needs very large context.

working on it, porting my previous M3 recipe to the newest vLLM release with its M3 specific up dates, I’ve got the cuda graph issues i ran into before solved, hoping to leverage those into some speed up.

Hi @voktolom and @a3refaat, thank you both for sharing this incredible breakthrough and the detailed build notes.

I have a similar 2x GB10 setup and am looking to deploy this exact NVFP4 + EAGLE-3 recipe for a near-100k context use case.

The benchmark numbers you shared (920-1000 t/s prefill, 30-36 t/s decode) look amazing. However, I noticed that the working recipe restricts max_num_seqs to 1 and max_num_batched_tokens to 1024, presumably due to the tight VRAM budget left by the 428B MoE weights and the Vision tower.

Given this setup:

  1. Have you experimented with increasing max_num_seqs to handle even 2-3 concurrent requests at 100k context, or does it strictly OOM (Out of Memory)?
  2. If multi-user concurrency is a requirement, what parameters would you recommend tweaking first? (e.g., dropping EAGLE-3 for an FP8 KV cache as @a3refaat mentioned, or adjusting gpu_memory_utilization).
  3. For those running production-like workloads on this stack, what are your real-world TTFT (Time to First Token) and aggregate throughput numbers when the system is pushed near that ~98k token pool limit?

Thanks again for the awesome work!