Skip to content

Dequantize the bitsandbytes base before the vLLM weight push - #6922

Open
behroozazarkhalili wants to merge 16 commits into
mainfrom
fix/4973-dequantize-base-before-vllm-push
Open

Dequantize the bitsandbytes base before the vLLM weight push#6922
behroozazarkhalili wants to merge 16 commits into
mainfrom
fix/4973-dequantize-base-before-vllm-push

Conversation

@behroozazarkhalili

@behroozazarkhalili behroozazarkhalili commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #4973. This is direction A from the discussion on that issue; direction B is still open, see below.

The report attributed the failure to merge_adapter() dequantizing the base. That is not what happens: PEFT's Linear4bit.merge leaves the weight as Params4bit, so nothing is upcast.

The measured cause is different. bitsandbytes stores a 4-bit weight as a flat packed uint8 buffer whose scales live in quant_state. The sync path read param.data, which drops quant_state, and handed that buffer to vLLM. On Qwen2.5-3B, 14 of 27 tensors failed the shape assert in vLLM's weight loader, and 0 quant_state tensors were transferred, so the scales never crossed even for the tensors whose shapes did fit.

The change

Every push site now goes through _dense_param_data, which dequantizes a Params4bit back to the model dtype and returns any other parameter's data unchanged. The dequantization acts on the parameter object rather than on .data, because .data has already discarded the quantization state by the time the push sees it.

The colocate engine is built dense to match. It previously received quantization="bitsandbytes" whenever a Linear4bit was found, which allocates packed [out_features, in_features // 2] weights and would reject the dense push with the same assert. The two halves only work together.

trl/experimental/online_dpo/online_dpo_trainer.py duplicates this block and is updated in lockstep.

Which combinations are refused at build time. 8-bit anywhere, since vLLM has no in-flight 8-bit path. A 4-bit base under FSDP2, which reads weights from state_dict() as plain tensors with the quant_state already gone. And a 4-bit base under FSDP1 unless use_orig_params is True: measured on one H100 with a bitsandbytes Linear4bit(8, 8) wrapped in FSDP1, Accelerate's default use_orig_params=False makes summon_full_params expose a plain Parameter of shape (16, 1) with no quant_state, and that packed storage is what the sync would push; with use_orig_params=True the summoned weight is the Params4bit with its state and the push is the dense (8, 8). DistributedBackend exposes fsdp_use_orig_params so the guard can tell the two apart, and the error names the setting to flip. And, in colocate mode, a checkpoint that was saved already quantized with bitsandbytes: vLLM reads quantization_config from the checkpoint at name_or_path and allocates packed weights the dense push cannot fill. transformers records that case as hf_quantizer.pre_quantized, derived from the same config field, so the guard takes it as an argument; server mode passes False because the trainer does not know what the server was started from, and the docstring says so.

Two more sites were out of step. VLLMClient.update_model_params still sent param.data and described the packed (N, 1) uint8 shape in its metadata; it now routes both passes through _dense_param_data, which moved to vllm_client.py so the client can use it without a circular import. DistributedBackend read fsdp_version with a getattr default of None, which reports "no FSDP" on an Accelerate before 1.6.0, where the plugin has no such attribute and can only configure FSDP1; the version is now checked explicitly and older plugins report 1. OnlineDPO read the attribute directly, which raised there, and now builds the backend like VLLMGeneration does.

Cost

The vLLM engine now holds a full-precision copy of the base, so the QLoRA memory saving covers training but not the rollout engine.

The alternative

Direction B, sending the packed weights together with quant_state and adding a --quantization flag, keeps the engine quantized and avoids that cost. It is a larger change and I have not built it. If you prefer B, say so and I will close this.

Verification

  • ruff check, ruff format --check and the pinned doc-builder style pass on both files, each run alongside a control so a silent no-op would show up.
  • Both branches of the helper are covered: a plain parameter passes its storage through, and a Params4bit routes to dequantize_4bit with quant_state forwarded and the packed shape intact.
  • The FSDP1 use_orig_params split was measured on one H100 MIG slice (world size 1): use_orig_params=False pushes (16, 1) for an (8, 8) layer, True pushes (8, 8). The guard's test matrix covers False, None and True; a copy of the tree with the FSDP1 branch removed fails exactly the two refused rows.
  • Not evaluable here: the dtype of a PEFT merge_adapter result on a bitsandbytes 4-bit base. PEFT raised a shape mismatch inside the merge before any dtype could be read, so the helper's documented model-dtype output remains verified only for the unmerged path.
  • Not yet done: an end-to-end QLoRA plus vLLM run on GPU. The reproduction needs two devices and I have not had them.
  • Third round (review follow-ups): the guard matrix gains a pre_quantized column with two refused rows and a dense control, a client test checks that update_model_params sends the dequantized tensor with dense metadata, and three backend tests cover a current plugin, a pre-1.6.0 plugin, and no plugin. All 23 pass on the fixed tree; a copy with the pre-quantized branch removed fails exactly the two refused rows; the previous head cannot collect the file at all, since it has no _dense_param_data in the client.

Note

Medium Risk
Changes the critical training→vLLM weight sync path and distributed setup guards; wrong behavior would break rollouts or silently mis-load weights, though unsupported setups now error explicitly.

Overview
Fixes QLoRA + vLLM weight sync (#4973) by dequantizing bitsandbytes Params4bit weights before pushing them to vLLM, instead of sending the packed uint8 buffer (which drops quant_state and breaks shape checks).

_dense_param_data is the single path for tensors sent to vLLM (client update_model_params, VLLMGeneration sync, and experimental Online DPO). Colocated engines are initialized without quantization="bitsandbytes" so vLLM allocates dense weights that match the push.

_check_quantization_supported fails fast at vLLM setup for combinations that cannot work with dense sync: 8-bit layers, 4-bit under FSDP2, 4-bit under FSDP1 unless use_orig_params=True, and colocate loads from checkpoints already saved quantized. DistributedBackend now exposes fsdp_use_orig_params and treats Accelerate < 1.6.0 as FSDP1 when inferring fsdp_version.

Tests cover dequantization, metadata for packed weights, the guard matrix, and FSDP version detection.

Reviewed by Cursor Bugbot for commit e40372b. Bugbot is set up for automated code reviews on this repo. Configure here.

A 4-bit base is stored as a flat packed uint8 buffer whose scales live in
`quant_state`. The weight sync read `param.data`, which drops `quant_state`,
and pushed that buffer into vLLM unchanged. On Qwen2.5-3B, 14 of 27 tensors
failed the shape assert in vLLM's weight loader, and no scales crossed at all.

Route every push site through `_dense_param_data`, which dequantizes a
`Params4bit` back to the model dtype and returns any other parameter's data
unchanged. The dequantization has to act on the parameter object, because
`.data` has already discarded the quantization state.

Build the colocate engine dense to match. It previously received
`quantization="bitsandbytes"` whenever a `Linear4bit` was present, which
allocates packed `[out_features, in_features // 2]` weights and rejects a dense
push with the same assert. The two halves only work together.

The cost is that the vLLM engine holds a full-precision copy of the base, so
the QLoRA memory saving covers training but not the rollout engine.

online_dpo_trainer.py duplicates this block and is updated in lockstep.
@bot-ci-comment

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

Comment thread trl/generation/vllm_generation.py Outdated
FSDP2 syncs weights from `state_dict()`, which returns plain tensors. The
bitsandbytes `quant_state` holding the scales is dropped there, so
`_dense_param_data` has nothing to dequantize from and the packed storage
reaches vLLM unchanged.

Before the engine was made dense this was silent: packed weights met a packed
engine and the scales simply never crossed. Now the same push hits the shape
assert instead. Neither is correct, so detect the combination when the engine
is built and say what is wrong.

The check covers FSDP2 only. The FSDP1 path reads parameters through
`summon_full_params` and has not been measured, so it is deliberately left
alone rather than guarded on an assumption.

online_dpo_trainer.py carries the same weight-sync block and is updated in
lockstep, using the `fsdp_plugin` lookup that file already uses.
The branch was 21 commits behind and GitHub reported it unmergeable. Main
had meanwhile restructured the vLLM weight-sync path into _iter_named_params,
which is the function this branch changes, so the conflict landed squarely
on trl/generation/vllm_generation.py.

Resolved by taking main's version of the generator and re-applying the three
changes this branch makes on top of it: the _dense_param_data helper, the
_check_quantization_supported guard extracted from __init__, and the three
yield sites that now route through the helper instead of param.data.

Verified after resolution rather than assumed: the helper is defined once and
called at all three sites, the guard is defined and called once, no stale
bare `quantization` name survives, and the file compiles. Running
tests/test_vllm_client_server.py on the merged tree and on origin/main gives
0 failures on both, with the merged tree collecting exactly the two test
functions this branch adds and dropping none.
The two guards this PR adds had no committed test. Both are pure functions
over a module, so they run without an accelerator or a live vLLM engine.

_check_quantization_supported gets seven cases: the two that must raise, an
8-bit base at any FSDP version and a 4-bit base under FSDP2, and five that
must not, including a 4-bit base under FSDP1 and with no FSDP at all, since
FSDP1 gathers through summon_full_params and keeps the quant_state that
FSDP2's state_dict has already dropped. _dense_param_data gets a passthrough
case asserting an unquantized parameter comes back as the same storage.

The negative cases are the point. A guard that raises on everything would
pass a suite that only tested the failures.

Checked by mutation rather than by reading: flipping the FSDP2 version
comparison, deleting the 8-bit raise, and breaking the passthrough each turn
the suite red, and all three revert to green.
Comment thread trl/generation/vllm_generation.py
`_check_quantization_supported` declared `fsdp_version: int` and documented
`0` for no FSDP. Neither matches the caller. `DistributedBackend` sets

    self.fsdp_version = getattr(fsdp_plugin, "fsdp_version", None) if fsdp_plugin else None

so the guard is handed `None`, not `0`, whenever FSDP is off, which is the
common case.

The guard still behaves correctly today, because `None == 2` is `False`. The
problem is that the annotation, the docstring and the tests all described a
sentinel that never reaches the function, so the contract was documented and
verified against itself rather than against the call site. Any later rewrite
to a numeric form such as `fsdp_version >= 2` would raise `TypeError` in
production while the suite stayed green.

Annotate `int | None`, document `None` as the no-FSDP value and name
`DistributedBackend` as its source, and add the three cases the caller can
actually produce: 8-bit with `None` must raise, 4-bit and dense with `None`
must not.

Checked by mutation, not by reading: substituting `>= 2` for `== 2` fails
exactly one test, `test_check_quantization_supported[<lambda>-None-None0]`,
one of the cases added here. The seven pre-existing cases all pass against
that mutant. Baseline and restored runs are 11 passed.

Reported by Cursor Bugbot on this PR.
… with OnlineDPO

The guard that refuses an 8-bit base, or a 4-bit base under FSDP2, sat inside
the colocate branch of `_init_vllm`. Server mode pushes the same dense weights
through `_dense_param_data`, so it needs the same refusal; before this change a
server-mode run with an 8-bit base built fine and failed at the first sync.
The call now runs before the mode branch.

OnlineDPO carried its own copy of the check with two `getattr` fallbacks that
reported FSDP1 when FSDP was off, where the shared helper reports `None`. It now
calls `_check_quantization_supported` with the plugin's version, in both modes,
and drops the bitsandbytes import the copy needed.

The guard test gains the 8-bit row for `fsdp_version=1`, the one sharding
strategy the "independent of sharding strategy" matrix left out.
The guard accepted a 4-bit base under FSDP1 on the claim that
`summon_full_params` keeps the `quant_state`. Measured on one H100
with a bitsandbytes `Linear4bit(8, 8)` wrapped in FSDP1: with
Accelerate's default `use_orig_params=False` the summoned weight is a
plain `Parameter` with no `quant_state`, shape `(16, 1)`, and that
packed storage is what the sync pushes; with `use_orig_params=True` the
summoned weight is the `Params4bit` with its state and the push is the
dense `(8, 8)`. The claim holds only for the non-default setting.

`DistributedBackend` now exposes `fsdp_use_orig_params` next to
`fsdp_version`, the guard takes it and refuses a 4-bit base under FSDP1
when it is not `True`, naming the setting to flip, and both call sites
(`VLLMGeneration` and OnlineDPO) pass it. The docstring states the
measured shapes instead of the claim. The test matrix gains the
`use_orig_params` column: `False` and `None` are refused under FSDP1,
`True` is accepted, and the dense-base rows stay accepted. A copy of the
tree with the new branch removed fails exactly the two refused rows.

The same probe also tried PEFT's `merge_adapter` on a bitsandbytes
`Linear4bit` base to check the merged dtype; PEFT raised a shape
mismatch before any dtype could be observed, so that question is
recorded as unevaluable in this environment rather than answered.
…n the duplicated sync blocks

The branch that is the fix for #4973 had no test: deleting the Params4bit check left the suite green, since the only helper test fed it a plain Parameter. A Params4bit built around a packed uint8 buffer now goes through a stand-in for dequantize_4bit that records the buffer and the quant_state it receives, which fails the moment the branch is removed. The kernel itself needs a GPU, so it is not executed here.

Two FSDP2 push sites bypass the helper on purpose, because state_dict() has already dropped the quant_state and the guard refuses a 4-bit base on that path; both now say so. The guard's docstring marked two required parameters as optional, and the duplicated "Skip PEFT layers" comment in the OnlineDPO copy used a curly apostrophe, so the blocks were not byte-identical.
…ize in update_model_params, read fsdp_version by version

Colocate mode builds the engine from `model.name_or_path`. When that checkpoint was saved already
quantized with bitsandbytes, vLLM reads its `quantization_config` and allocates packed weights, and the
dense push this PR relies on cannot fill them. transformers records that case as
`hf_quantizer.pre_quantized`, derived from the same config field vLLM reads, so the guard now takes it
and refuses the combination up front. Server mode passes `False`: the trainer does not know what the
server was started from, and the docstring says so.

`VLLMClient.update_model_params` still sent `param.data` and described the packed `(N, 1)` uint8 shape
in its metadata. It now routes both passes through `_dense_param_data`, which moves to `vllm_client.py`
so the client can use it without a circular import; `vllm_generation.py` and OnlineDPO import it from
there. The two passes dequantize twice on purpose so the send stays lazy.

`DistributedBackend` read `fsdp_version` with a `getattr` default of `None`, which reports "no FSDP" on
an Accelerate before 1.6.0, where the plugin has no such attribute and can only configure FSDP1. The
version is now checked explicitly and older plugins report 1. OnlineDPO read the attribute directly,
which raised there; it now builds the backend like `VLLMGeneration` does.

Tests: the guard matrix gains a `pre_quantized` column with two refused rows and one dense control; a
client test checks that `update_model_params` sends the dequantized tensor with dense metadata; three
backend tests cover a current plugin, a pre-1.6.0 plugin, and no plugin.

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e40372b. Configure here.

# Both modes push dense weights at sync time (see `_dense_param_data`), so the check runs before either branch.
# Only colocate mode builds the engine from `model.name_or_path`, where a checkpoint saved already quantized
# makes vLLM allocate packed weights; `hf_quantizer.pre_quantized` records whether the checkpoint was one.
pre_quantized = self.mode == "colocate" and model.hf_quantizer is not None and model.hf_quantizer.pre_quantized

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Colocate init assumes hf_quantizer exists

High Severity

Colocate vLLM init reads model.hf_quantizer to decide pre_quantized. Transformers only sets that attribute when a quantizer ran during from_pretrained, so an unquantized model raises AttributeError and never builds the engine. Colocate is the default mode, so ordinary dense training hits this before any weight sync.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e40372b. Configure here.

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.

[GRPO] AssertionError in vLLM when using QLoRA (4-bit) due to weight shape mismatch during merge_adapter

1 participant