Skip to content

common : --fit grows the ubatch of MoE models into spare VRAM, for up to 2.5x faster prompt processing - #321

Open
LeoBorcherding wants to merge 5 commits into
unslothai:base/upstream-5c19a5a75from
LeoBorcherding:fix/moe-ubatch-default
Open

LeoBorcherding wants to merge 5 commits into
unslothai:base/upstream-5c19a5a75from
LeoBorcherding:fix/moe-ubatch-default

Conversation

@LeoBorcherding

@LeoBorcherding LeoBorcherding commented Oct 23, 2026 •

Copy link
Copy Markdown

What this does

llama.cpp reads a prompt in chunks called ubatches. The default chunk is 512 tokens. For MoE models (Qwen3-30B-A3B, gpt-oss, DeepSeek, Qwen3.8-Flash-Next and so on), every chunk has to run through all the experts' weights once. So a 4,600-token prompt means 10 trips through every expert. When the experts live in system RAM, each trip also copies them to the GPU again.

Bigger chunks mean fewer trips, but each chunk needs more GPU memory while it runs. So this PR lets --fit decide:

  1. --fit places the weights and the context exactly as it does today, at 512.
  2. If there's VRAM left over, it doubles the chunk (512 → 1024 → 2048, up to n_batch, max 4096) as long as it still fits within the fit's safety margin.
  3. It never gives up context or moves weights to RAM to make room. If there's no spare VRAM, nothing changes.
  • Only MoE models change (GGUF expert_count > 0). Dense models keep 512.
  • -ub (or LLAMA_ARG_UBATCH) still wins, and -fit off keeps 512.
  • -fitu off (--fit-ubatch off, LLAMA_ARG_FIT_UBATCH=off) turns off just the ubatch growth. The rest of --fit works as before.
  • A draft or MTP model that follows the main ubatch is measured together with it.
  • The log says when it kicks in: MoE model, n_ubatch = 2048 fits in free memory (set -ub or -fitu off to override).

Why isn't this already the default?

As far as I can tell, nobody chose 512 for MoE on purpose. It's left over from three things:

  1. 512 dates from early llama.cpp, when nearly every model was dense. For dense models bigger chunks barely help (0% on Qwen3-8B below), so there was no reason to change it.
  2. --fit was built to answer "will this fit?", not "how fast can this go?" It shrinks context and moves layers to RAM until the model fits, but it never touched n_ubatch. This PR adds that.
  3. The cost only shows up with MoE models whose experts live in system RAM (--cpu-moe, --n-cpu-moe, --fit spilling). Each 512-token chunk copies every expert to the GPU again. That setup only became common recently.

Results

Prompt speed with and without the change

Prompt processing, same machine, with and without this PR:

machine model ubatch picked without with
Radeon AI PRO R9700, Windows 11, ROCm 7.1 Qwen3.8-Flash-Next IQ1_M, experts in RAM (-cmoe) 2048 248 tok/s 616 tok/s (2.5x)
L4 (23 GB), Linux, CUDA 13.0 Qwen3-30B-A3B Q3_K_M 2048 2,136 tok/s 2,971 tok/s (1.4x)
L4 gpt-oss-20b Q4_K_M 2048 2,751 tok/s 3,441 tok/s (1.25x)
L4 Qwen3-30B-A3B Q4_K_M, card nearly full 512 (no room) 2,125 tok/s 2,123 tok/s, same context
L4 Qwen3-30B-A3B Q6_K, --fit spills experts 512 (no room) 723 tok/s 724 tok/s, same placement
L4 Qwen3-8B Q4_K_M (dense, control) 512 2,711 tok/s 2,693 tok/s (noise)
A100 40 GB, Linux, CUDA 13.0 Qwen3.8-Flash-Next IQ1_M, experts in RAM (-cmoe) 2048 107 tok/s 271 tok/s (2.5x)
A100 40 GB Qwen3-30B-A3B Q4_K_M, experts in RAM (-cmoe) 2048 164 tok/s 449 tok/s (2.7x)
A100 40 GB Qwen3-30B-A3B Q4_K_M, fully in VRAM 2048 3,560 tok/s 5,767 tok/s (1.6x)
A100 40 GB gpt-oss-20b, fully in VRAM 2048 4,489 tok/s 5,693 tok/s (1.27x)
RTX PRO 6000 Blackwell 96 GB, Linux, CUDA 13.0 Qwen3.8-Flash-Next IQ1_M, experts in RAM (-cmoe) 2048 556 tok/s 1,320 tok/s (2.4x)
RTX PRO 6000 Qwen3-30B-A3B Q4_K_M, experts in RAM (-cmoe) 2048 886 tok/s 2,318 tok/s (2.6x)
RTX PRO 6000 Qwen3-30B-A3B Q4_K_M, fully in VRAM 2048 9,366 tok/s 14,050 tok/s (1.5x)
RTX PRO 6000 gpt-oss-20b, fully in VRAM 2048 16,657 tok/s 18,794 tok/s (1.13x)
A100 / RTX PRO 6000 Qwen3-8B dense (control) 512 4,494 / 12,586 tok/s 4,492 / 12,619 tok/s (same)
Radeon AI PRO R9700, Windows 11, ROCm 7.1 Qwen3.8-27B UD-Q4_K_M (dense, control) 512 392.5 tok/s 392.7 tok/s (same)
RX 5700 XT 8 GB, Windows 11, Vulkan Qwen3-30B-A3B Q4_K_M, experts in RAM (-cmoe) 2048 26.9 tok/s 42.9 tok/s (1.6x)
RX 5700 XT Qwen3-30B-A3B Q4_K_M, --fit spills experts 1024 (no room for 2048) 32.2 tok/s 40.2 tok/s (1.25x)
RX 6500 XT 4 GB, Windows 11, Vulkan Qwen3-30B-A3B Q4_K_M, experts in RAM (-cmoe) 2048 7.6 tok/s 45.0 tok/s (5.9x), mostly escaping a slow path this card hits at ubatch 512; -ub 1024 by hand does the same
RX 6500 XT Qwen3-30B-A3B Q4_K_M, --fit spills experts 512 (no room) not timed loads identically to base
RX 9060 XT 16 GB, Linux not run the GPU driver doesn't load on that box

Generation speed (writing the answer) doesn't change. What changes is how long you wait for the first word:

Time per request

Time saved before the answer starts

What you actually feel is the wait between sending the prompt and the first word of the answer (time to first token). On a 4.5k-token prompt:

Time to first token

setup wait without wait with saved
NVIDIA A100, Flash-Next, experts in RAM 42.7 s 16.8 s 25.9 s
AMD R9700, Flash-Next, experts in RAM 18.4 s 7.4 s 11.0 s
NVIDIA RTX PRO 6000, Flash-Next, experts in RAM 8.2 s 3.5 s 4.8 s
NVIDIA A100, Qwen3-30B-A3B in VRAM 1.3 s 0.8 s 0.5 s
NVIDIA RTX PRO 6000, Qwen3-30B-A3B in VRAM 0.5 s 0.3 s 0.2 s
AMD R9700, Qwen3.8-27B (dense, not MoE) 11.6 s 11.6 s none, left alone

These are 4,565 prompt tokens divided by the measured prompt speeds above, not separately timed. Longer prompts save proportionally more.

Accuracy

Perplexity with and without the change

Perplexity on wikitext-2 (Qwen3.8-Flash-Next, experts in RAM): 5.365 at ubatch 512 and 5.370 at 2048. The error bars are ±0.08, 16x bigger than the change. This is the same tiny numeric difference you get today by passing -ub 2048 yourself. Greedy output is identical on every config, and when there's no room the output matches the base exactly.

Where to look in the code

  • common.cpp:1288-1304: after a successful fit, MoE models with no -ub call the growth step.
  • fit.cpp:910-975: common_fit_n_ubatch doubles the ubatch, re-measures memory with the fit's own probe, and keeps a step only if every device still has its margin free. Weights and context are never touched.
  • fit.cpp:180: the draft/MTP memory measurement, factored out of the fit's existing code so the growth step can reuse it.
  • common.cpp:1241-1244: reads expert_count from the GGUF.
  • arg.cpp:1683: records that -ub was given, so an explicit value always wins.
  • arg.cpp:2942-2955: the -fitu / --fit-ubatch switch, next to the other --fit options.

Tradeoffs

  • When the card is full or the model spills, there's no speedup. It stays at 512 rather than give up context or VRAM. If you want the speed anyway, -ub 2048 still works: on the spilled Q6_K it gives +110% on long prompts, at the cost of ~8% on short prompts and 316 MiB of experts moved to RAM.
  • Startup takes a moment longer for MoE models with spare VRAM. Each doubling re-measures memory, 0.25 to 0.5 s per step.

How it was tested

  • Warm prompts, two servers per build run alternating, ~485, ~583 and ~4.2k-4.6k-token prompts. Everything was measured on this PR's own base (5c19a5a75), comparing the base and this PR.
  • GPUs: AMD Radeon AI PRO R9700 (ROCm 7.1, Windows 11), NVIDIA L4, A100 40 GB and RTX PRO 6000 Blackwell (CUDA 13.0, Linux, on Colab). Decode speed, greedy output and context placement match the base on every row; the bigger ubatch costs 0.2 to 0.7 GB of compute buffer.
  • The first commit on this branch was a fixed MoE default (details and charts in the comment below). The second commit replaces it with the --fit version. On the configs with spare VRAM, both versions are equally fast (within 0.5%).
  • -fit off -c 49152 on a nearly full L4 loads, same as the base.
  • Not tested yet: a draft or MTP model end to end, multi-GPU, CPU-only, Metal, Vulkan, and -np > 1. llama-fit-params doesn't print the grown -ub yet.

AI disclosure (per CONTRIBUTING): written and benchmarked with Claude Code. Every number above comes from a logged run, and the logs are available on request.

A MoE ubatch reads the weights of nearly every expert, and uploads them when
the experts are in host memory, so prompt processing pays one pass over all
experts per ubatch. Without -ub, MoE models now use the logical batch (up to
4096) instead of 512. -ub still overrides it.

With a draft or MTP model the larger default applies only when the tensor
overrides keep the experts in host memory. The draft context follows the main
context's ubatch, so both compute buffers grow, and with the whole model on the
GPU that can overrun VRAM.
@LeoBorcherding

Copy link
Copy Markdown
Author

Initial version (34765b3): fixed MoE default

Recording the first approach and its numbers here, because the body will change if the --fit-aware version being tested replaces it.

Approach: MoE models (GGUF expert_count > 0) with no -ub default n_ubatch to n_batch (2048, capped at 4096) instead of 512. This is set before --fit runs, so fit plans for the bigger compute buffer. An explicit -ub wins, and a draft/MTP model with the experts on the GPU stays at 512.

Prompt speed

Time per request

Accuracy

Results:

  • Prompt processing is 2.4x faster on an R9700 with experts in RAM, 2.3x on an L4 when --fit spills experts, and 1.2x for MoEs that fit in VRAM.
  • Dense models are unchanged, and so is generation speed.
  • Perplexity is 5.365 vs 5.370 (±0.08).

Why it's being reworked: a fixed default ignores how much VRAM is free. On a nearly full card:

  • --fit paid for the bigger buffer by cutting context (39,424 → 35,584 on Qwen3-30B-A3B Q4_K_M, L4).
  • It also spilled more experts, which made short prompts ~9% slower.
  • With -fit off right at the VRAM limit, the model could fail to load.

The next version lets --fit grow the ubatch only into spare VRAM.

Replaces the fixed MoE default from the previous commit. Without -ub, --fit now places the weights and the context at the default ubatch as before, then doubles n_ubatch towards n_batch (up to 4096) while the larger compute buffers still fit in the memory left over on every device. The fit never gives up context or device weights for a bigger ubatch, and a draft or MTP context that follows the main ubatch is measured with it. -ub still overrides it, and -fit off keeps the default.
@LeoBorcherding LeoBorcherding changed the title common : MoE models default to a 2048-token ubatch, for up to 2.4x faster prompt processing common : --fit grows the ubatch of MoE models into spare VRAM, for up to 2.5x faster prompt processing Oct 24, 2026
The growth stays on by default. -fitub off (or LLAMA_ARG_FIT_UBATCH=off) keeps the ubatch at its default while --fit still places the weights and the context, so the behaviour can be switched off without pinning -ub 512.
-fitub becomes -fitu (as -fitp, -fitt, -fitc), the option moves next to the other --fit options, and the log line names it as the way to turn the growth off.
@danielhanchen danielhanchen self-assigned this Oct 25, 2026
@LeoBorcherding

Copy link
Copy Markdown
Author

@danielhanchen Reopening this because it looks like it fits with your open MoE PRs instead of overlapping them. If you don't think we need it, feel free to close it.

What it does: MoE models still process prompts in chunks of 512 tokens unless you pass -ub by hand. When --fit finishes with memory to spare, this PR raises the chunk to 1024 or 2048, but only while it still fits inside the fit's margin. It never cuts context or moves weights, so on a full card it does nothing. -ub or -fitu off turns it off.

How it fits with your PRs. I merged this PR into each head and measured prompt speed with experts in RAM (-cmoe), at about 4.2k tokens:

merged into R9700 32 GB (ROCm) A100 40 GB L4 24 GB
#264 (CUDA MoE prompt kernels) 379 → 1014 (2.68x) 166 → 456 (2.74x) 164 → 437 (2.66x)
#268 (prefetch + MoE cache) 761 → 1960 (2.58x) 2566 → 5046 (1.97x) 1335 → 2383 (1.79x)
#251 (--moe-cache-mib auto) 793 → 1780 (2.24x) 400 → 1042 (2.61x) 390 → 950 (2.44x)

With the whole model in VRAM, the gain is smaller but still there: about 1.2x on the R9700 and 1.46x to 1.62x on the A100. On the L4 it stays at 512, because there's no room left over. Dense models are never touched.

Correctness: greedy outputs are byte-identical with and without this PR on every pair, card and config, and answer speed doesn't change. Running each PR with -ub 2048 by hand gives the same speed wherever this PR grew the ubatch, so the gain comes entirely from the chunk size.

The merges were local. Both had small conflicts, in common_init_result (#268) and in the new common_fit_apply() (#251), and I'm happy to rebase onto whichever of these lands first.

…ubatch

The growth inferred that the draft follows the target from equal ubatch
values, so --spec-draft-n-ubatch set to the target's ubatch was measured
at the grown size while the draft context is created at the user's value.
Pass whether the draft follows from the caller instead.
@LeoBorcherding

Copy link
Copy Markdown
Author

Codex review on a mirror of 6074bd5f6 is clean. CI is next.

This branch has not been deployed

No deployments
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