Skip to content

[Bugfix][Offloader] Prioritize sparse MoE experts in UVA CPU offloading - #54610

Closed
antonyamal-3-6-9 wants to merge 3 commits into
vllm-project:mainfrom
antonyamal-3-6-9:fix/cpu-offload-moe-order
Closed

antonyamal-3-6-9 wants to merge 3 commits into
vllm-project:mainfrom
antonyamal-3-6-9:fix/cpu-offload-moe-order

Conversation

@antonyamal-3-6-9

Copy link
Copy Markdown

Description

Fixes #54593.

UVAOffloader currently processes model parameters in Python declaration order when --cpu-offload-params is not explicitly specified. For Transformer MoE architectures, this can cause dense parameters such as attention projections, layer norms, and router gates to be offloaded before sparse expert weights.

Dense parameters are accessed on every forward pass, whereas sparse expert weights are accessed only for the experts selected by the router. Offloading dense parameters first can therefore introduce significantly more PCIe traffic and degrade inference throughput, as demonstrated in #54593.

Changes

  • Workload-aware parameter prioritization

    • When --cpu-offload-params is not specified, prioritize sparse MoE expert parameters for CPU offloading.
    • Dense parameters are considered only after the available expert weights have been exhausted.
  • Robust sparse-expert classification

    • Use dot-delimited parameter-name segments to identify sparse expert parameters.
    • Explicitly avoid classifying shared experts, router/gate parameters, and unrelated parameters as sparse experts.
  • Global multi-layer prioritization

    • Evaluate candidates across all modules passed to wrap_modules().
    • This prevents dense parameters in earlier layers from consuming the offload budget before sparse experts in later layers are considered.
  • Preserve existing behavior

    • Explicit --cpu-offload-params selections continue to take precedence over the default heuristic.
    • Dense-only models retain their existing declaration-order offloading behavior.
    • If the offload budget exceeds the total sparse-expert weight size, remaining capacity is used for dense parameters in declaration order.
    • An informational message is emitted when dense parameters are also offloaded under the default heuristic.

Tests

Added tests/model_executor/offloader/test_uva_offload_priority.py covering:

  • Sparse-expert parameter classification and false-positive cases
  • Sparse-expert prioritization within a single MoE layer
  • Global expert prioritization across multiple layers
  • Spillover from expert to dense parameters when the budget allows
  • Explicit --cpu-offload-params behavior
  • Preservation of declaration-order behavior for dense models

Verification

pytest tests/model_executor/offloader/test_uva_offload_priority.py -v

Result: 6 passed in 0.06s

ruff check vllm/model_executor/offloader/uva.py \
    tests/model_executor/offloader/test_uva_offload_priority.py

Result: All checks passed.

AI Assistance

AI assistance (Google Antigravity) was used during diagnosis, implementation, and test development. The parameter traversal behavior, prioritization logic, and regression tests were reviewed and verified locally.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@TSUMUGI-XE

Copy link
Copy Markdown

Thanks for turning this around so fast. It does not start on the box from #54593, and the
reason looks diagnosable.

Box: 1× Intel Arc Pro B70 (32 GiB), TP=1, UVA backend, --cpu-offload-gb 47, no
--cpu-offload-params. Qwen3.8-Flash-Next MXFP4+FP8 — dense 4.80 GiB, experts 62.11 GiB,
n-gram table 47.75 GiB (on disk, so ~69 GiB has to live in VRAM or host).

                                        offloaded    result
main, default                            47.29 GB    starts, ITL p50 328.65 ms
main, --cpu-offload-params experts       47.24 GB    starts, ITL p50 111.52 ms
this PR, default                          0.84 GB    OOM in wrap_modules

Engine log, in order:

uva.py:178  Dense parameters were offloaded to CPU because the offload budget exceeded
            sparse expert weights or all parameters were eligible.
uva.py:104  Total CPU offloaded parameters: 0.84
uva.py:101  wrap_modules -> FusedMoEFactory -> routed_experts.py:176
torch.OutOfMemoryError: Tried to allocate 400.00 MiB. 31.89 GiB total, 64.68 MiB free.

Two things stand out:

  1. 0.84 GB against a 47 GB budget — almost nothing was offloaded, so the card filled up.
  2. The uva.py:178 branch describes a state that does not hold here. Experts are
    62.11 GiB and the budget is 47 GB, so the budget does not exceed the sparse expert
    weights — yet that branch ran. I would look there first.

Happy to re-run on any change. The --cpu-offload-params experts row above is a clean
reference point on this box if a default-vs-explicit comparison helps.

@TSUMUGI-XE

Copy link
Copy Markdown

Found the cause and verified a shape that fixes it on this box. Not opening a competing PR —
this is yours, and per AGENTS.md I would not submit code I cannot defend end-to-end.

Cause

wrap_modules changed from offloading as the generator yields to materialising every
layer first
:

# before
modules = [self._maybe_offload_to_cpu(m, prefix) for m in modules_generator]
# this PR
modules = list(modules_generator)      # <- all 48 decoder layers built on device
self._offload_modules(modules, prefix)

make_layers (models/utils.py) passes a generator that constructs each decoder layer.
list(...) builds all of them before any offloading happens, so on a model that does not fit
the card, the device fills up during construction and OOMs before _offload_modules is ever
reached for the language model. Offloading exists to avoid that allocation, so the eager
materialisation defeats it.

Instrumented _offload_modules to confirm:

this PR:  UVA probe #1: modules=1 candidates=333 (sparse=0 dense=333) prefix='visual.'
          UVA probe #1 done: bytes=0.84GB stopped_by_budget=False
          <OOM in wrap_modules -> FusedMoEFactory -> routed_experts.py:176>

Only the tower ran. The language model never became a candidate, and it stopped because
candidates ran out — not because the budget was reached. That also explains the
uva.py:178 warning firing: with sparse=0, dense=333 the dense flag is set, but the text
("budget exceeded sparse expert weights") does not describe this situation.

Shape that works

Keep the incremental offload, get the priority from two passes:

modules = []
for module in modules_generator:
    self._offload_modules([module], prefix, only_priority=0)   # sparse experts, as built
    modules.append(module)
if self.cpu_offload_bytes < self.cpu_offload_max_bytes:
    self._offload_modules(modules, prefix, only_priority=1)    # dense, if budget remains

(only_priority filters the candidate list; ~15 lines total.)

Measured

1× Arc Pro B70 (32 GiB), TP=1, UVA, --cpu-offload-gb 47, no --cpu-offload-params,
Qwen3.8-Flash-Next MXFP4+FP8. Same window, VM off, warmup discarded, median of 8.

                          offloaded    ITL p50     tok/s    4 concurrent
main (no PR), default       47.29 GB   328.65 ms    3.04         —
this PR, default             0.84 GB   OOM           —           —
this PR + shape above       47.69 GB   111.05 ms    9.00      14.00 tok/s
--cpu-offload-params experts 47.24 GB  111.52 ms    8.97      13.62 tok/s

So the default reaches the explicit setting, which is what the PR is for: 3.04 -> 9.00 tok/s.
The probe also shows the two passes behaving: tower sparse=0, then language layers arriving
with sparse=4 each, stopping at stopped_by_budget=True at 47.69 GB.

None of this looks XPU-specific — the offloader is generic, and the failure needs only a model
that does not fit the card. It would not show on a box with enough VRAM, which may be why tests
pass.

Happy to re-run whatever you land on.


Measurements were taken by me on the hardware described; the write-up was done with AI
assistance. Numbers are from the box, not generated.

@antonyamal-3-6-9

Copy link
Copy Markdown
Author

Thanks for the detailed reproduction. This is really helpful. I’ll dig into the "uva.py:178" branch and compare the default path with the working "--cpu-offload-params experts" configuration.

I’ll be able to investigate this properly later today and will get back to you once I’ve traced the issue.

@antonyamal-3-6-9

Copy link
Copy Markdown
Author

@TSUMUGI-XE

I’ve implemented the two-pass lazy offloading approach and added a regression test for the eager-materialization case. The focused tests, Ruff, formatting, and mypy all pass. I’ve also verified the temporal ordering with mutation testing. I’ve pushed the update to the PR — please feel free to rerun the B70 reproduction when you get a chance.

@TSUMUGI-XE

Copy link
Copy Markdown

Reran the B70 reproduction against c7ce3bbcb. The default path now matches the explicit setting.

Box: 1× Intel Arc Pro B70 (32 GiB), TP=1, UVA backend, --cpu-offload-gb 47, Qwen3.8-Flash-Next MXFP4+FP8 (117 GiB), one local patch in mxfp4/xpu.py for XPU. Both arms are the same image and the same window, differing only in --cpu-offload-params.

arm offloaded ITL p50 tok/s (1/ITL) tok/s (wall clock) 4 concurrent
main without this PR, default 47.29 GB 328.65 ms 3.04 — —
this PR, default (no flag) 47.69 GB 110.73 ms 9.03 7.50 10.62
this PR, --cpu-offload-params experts 47.24 GB 111.64 ms 8.96 7.42 10.71

Default vs explicit differ by 0.8% on ITL and 1.1% on wall clock — not distinguishable. That is what the PR was for: 3.04 -> 9.03 tok/s on the default path.

Two notes on the numbers, so they are read correctly:

  • The tok/s (1/ITL) column is steady-state and excludes prefill; tok/s (wall clock) is total tokens over total request time. Both are here because they answer different questions, and the ratio between them is the first-token cost, not a discrepancy.
  • The 4-concurrent column is not comparable to the 14.00 / 13.62 I posted earlier. Those used one identical prompt per request; these use a varied prompt set. With host-side expert offload, identical prompts make every sequence fetch the same experts, so the batch shares one transfer and the number comes out much higher. Same instrument, different input — worth knowing if anyone benchmarks CPU offload under concurrency.

I confirmed the explicit arm really ran with the flag ('cpu_offload_params': ['experts'] in the engine args), rather than reading it back from a formatting helper.

The two-pass shape reads as I would expect: sparse offloaded incrementally as the generator yields, dense attempted afterwards only if budget remains, so the eager-materialization case cannot come back without failing your new regression test.


Measurements were taken by me on the hardware described; the write-up was done with AI
assistance. Numbers are from the box, not generated.

@antonyamal-3-6-9

Copy link
Copy Markdown
Author

Thanks for rerunning the B70 reproduction and confirming that the default path now matches the explicit expert-offload behavior. The additional benchmarking context is also helpful. Glad to see the regression test guards the lazy construction ordering and that the original reproduction is resolved.

@mergify

mergify Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @antonyamal-3-6-9.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 9, 2026
antonyamal-3-6-9 and others added 3 commits September 10, 2026 02:24
Fixes vllm-project#54593

The default UVA CPU offloader currently processes parameters in Python
declaration order. In Mixture-of-Experts (MoE) architectures, dense
attention and normalization layers are declared before expert blocks,
causing the hottest parameters (read on 100% of tokens) to be evicted
to CPU host RAM before sparse expert weights. This results in severe
PCIe bus saturation and substantial throughput degradation.

This change introduces:
1. Segment-based sparse-expert prioritization in `UVAOffloader` when
   `--cpu-offload-params` is not explicitly set, ensuring sparse expert
   blocks consume CPU offload budgets before dense parameters.
2. Global multi-layer parameter ordering across `wrap_modules()`,
   preventing earlier layers' dense weights from consuming budget before
   subsequent layers' sparse experts are evaluated.
3. Informational diagnostic logging when offload budgets exceed total
   expert weights and spill over into dense layers.
4. Comprehensive regression tests covering classification, multi-layer
   prioritization, explicit filter overrides, budget spillover, and
   dense-model parity.

Signed-off-by: Amal Antony <antonyamal071@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
…dules

Preserve lazy layer construction during wrap_modules() to prevent VRAM
allocation spikes on memory-constrained GPUs, while maintaining sparse-expert
priority and declaration-order dense spillover.

Signed-off-by: Amal Antony <antonyamal071@gmail.com>
…dules

Preserve lazy layer construction in `UVAOffloader.wrap_modules()` to
prevent VRAM allocation spikes on memory-constrained devices during model
initialization, while maintaining global sparse-expert priority and
declaration-order dense spillover.

1. Pass 1: Iterates `modules_generator` incrementally and offloads
   Priority 0 parameters (sparse experts / explicit offload filters)
   immediately as each layer is yielded, freeing device VRAM before the
   generator instantiates subsequent layers.
2. Pass 2: If remaining offload budget exists after all layers are
   constructed, offloads Priority 1 parameters (dense attention / norms)
   in sequential declaration order across the model.
3. Guard non-UVA functional_call wrapping against double-application
   when a module participates in both Pass 1 and Pass 2.
4. Add temporal ordering regression tests ensuring each layer's
   sparse experts are offloaded before the next layer is constructed,
   along with multi-layer constrained budget spillover tests.

Signed-off-by: Amal Antony <antonyamal071@gmail.com>
Co-authored-by: Antigravity <antigravity@google.com>
@antonyamal-3-6-9
antonyamal-3-6-9 force-pushed the fix/cpu-offload-moe-order branch from c7ce3bb to f91112b Compare September 9, 2026 20:57
@antonyamal-3-6-9

Copy link
Copy Markdown
Author

Rebased onto current main at 8c87c333b8.

The overlap with #53120 in UVAOffloader was resolved by retaining the sparse-first, two-pass lazy offloading behavior while preserving upstream’s:

  • original accelerator-device capture,
  • non-UVA synchronization context,
  • prefix-aware matching, and
  • handling for already-offloaded parameters.

Local validation:

.venv/bin/python -m pytest \
  --confcutdir=tests/model_executor/offloader \
  tests/model_executor/offloader/test_uva_offload_priority.py -v

Result: 9 passed

pre-commit run ruff-check --files <changed Python files>

Result: Passed

pre-commit run ruff-format --files <changed Python files>

Result: Passed

git diff --check origin/main...HEAD

Result: Passed

Repository-wide pytest initialization was also attempted, but the local environment failed before test collection while loading libtorchcodec_image.so. The isolated offloader test suite above completed successfully.

@TSUMUGI-XE

Copy link
Copy Markdown

Rechecked on the box from #54593 after your 2026-09-09 rebase onto 8c87c333b8. The fix still
holds
, and the default path still lands where the explicit one does. One thing I would flag.

Box: 1× Intel Arc Pro B70 (32 GiB), TP=1, UVA backend, --cpu-offload-gb 47,
Qwen3.8-Flash-Next MXFP4+FP8 (dense 4.80 GiB, sparse experts 62.11 GiB, n-gram table 47.75 GiB
on disk). Throughput is a 64-token generation, n=5 median, spread shown.

Applied as a file-level swap: I replaced vllm/model_executor/offloader/uva.py in my image with
the version at f91112b rather than building the branch — the PR touches only that file plus
tests. My image carries unrelated local patches (an mxfp4 register_fake, an expert cache);
none of them touch the offloader, so what is measured below is your code.

                                       offloaded    tok/s
main (pre-PR), default                  47.29 GB    2.74   (spread 0.1%)
main (pre-PR), --cpu-offload-params experts  47.24 GB    7.11   (spread 0.3%)
this PR @ f91112b, default              47.69 GB    7.14   (spread 0.7%)

So after the rebase, default still reaches explicit-experts performance — 2.61× over the
unprioritised path
, and the 0.4% gap between default and explicit sits inside the run-to-run
spread (0.3–0.7%), so they are the same point rather than merely close. It also starts — the
0.84 GB / OOM failure from my earlier report is gone.

The same gap was there on 09-01 (3.04 → 8.97 tok/s, derived from ITL p50; 2.6× here by
end-to-end throughput). Different instrument, so the two are not directly comparable — the point
is that the gap is still there and of the same order, two weeks later, on a rebuilt image and
with a different measurement.

The thing I would flag

With the PR at default, the run still logs:

Dense parameters were offloaded to CPU because the offload budget exceeded sparse expert
weights or all parameters were eligible.

I instrumented the priority == 1 branch to see what that was. On this model it is the entire
vision tower
:

[PROBE] dense offloaded #1: visual.patch_embed.proj.weight   (0.003 GiB)
[PROBE] dense offloaded #3: visual.pos_embed.weight          (0.005 GiB)
[PROBE] dense offloaded #6: visual.merger.linear_fc1.weight  (0.040 GiB)
[PROBE] dense total: 333 params / 0.836 GiB
        (budget 47.000 GiB, cpu_offload_bytes at that moment: 0.836 GiB)

The last line is the part I find interesting: at the moment the dense pass ran, the total
offloaded was 0.836 GiB — no sparse expert had been offloaded yet.
That is consistent with an
offload call whose module set contains no sparse parameters, running while the budget was still
empty.

Whole-model arithmetic says dense should never be reached here: sparse experts are 62.11 GiB
against a 47 GiB budget.

The two numbers do not fully reconcile, so I will not overstate it: the end state differs by
0.45 GiB (47.69 at default versus 47.24 with explicit --cpu-offload-params experts) while
the probe totals 0.836 GiB for the dense pass. Expert granularity may absorb part of the
difference. What the probe shows directly is the ordering, not the accounting — dense ran
while the budget was still empty, before any expert had competed for it.

Text-only throughput is unaffected here (7.14 vs 7.11), so this is not costing us speed. What it
costs is placement: the vision tower ends up resident on the host — the outcome the
prioritisation exists to prevent.

I have not pinned down which call it arrives through, and I would rather not guess at your
construction order — _maybe_offload_to_cpu passing only_priority=None is the shape I would
look at first, but you will know better. The probe is a three-line patch in the priority == 1
branch; happy to paste the diff if it saves you reproducing it, or to rerun anything on this
hardware.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: --cpu-offload-gb evicts in declaration order, which for MoE offloads the hottest weights first

2 participants