Dequantize the bitsandbytes base before the vLLM weight push - #6922
Dequantize the bitsandbytes base before the vLLM weight push#6922behroozazarkhalili wants to merge 16 commits into
Conversation
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.
|
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. |
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.
`_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.
…ase-before-vllm-push
…ase-before-vllm-push
… 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.
…ase-before-vllm-push
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.
…ase-before-vllm-push
…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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ 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 |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit e40372b. Configure here.


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'sLinear4bit.mergeleaves the weight asParams4bit, so nothing is upcast.The measured cause is different. bitsandbytes stores a 4-bit weight as a flat packed
uint8buffer whose scales live inquant_state. The sync path readparam.data, which dropsquant_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 0quant_statetensors 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 aParams4bitback to the model dtype and returns any other parameter's data unchanged. The dequantization acts on the parameter object rather than on.data, because.datahas 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 aLinear4bitwas 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.pyduplicates 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 thequant_statealready gone. And a 4-bit base under FSDP1 unlessuse_orig_paramsisTrue: measured on one H100 with a bitsandbytesLinear4bit(8, 8)wrapped in FSDP1, Accelerate's defaultuse_orig_params=Falsemakessummon_full_paramsexpose a plainParameterof shape(16, 1)with noquant_state, and that packed storage is what the sync would push; withuse_orig_params=Truethe summoned weight is theParams4bitwith its state and the push is the dense(8, 8).DistributedBackendexposesfsdp_use_orig_paramsso 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 readsquantization_configfrom the checkpoint atname_or_pathand allocates packed weights the dense push cannot fill. transformers records that case ashf_quantizer.pre_quantized, derived from the same config field, so the guard takes it as an argument; server mode passesFalsebecause 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_paramsstill sentparam.dataand described the packed(N, 1)uint8 shape in its metadata; it now routes both passes through_dense_param_data, which moved tovllm_client.pyso the client can use it without a circular import.DistributedBackendreadfsdp_versionwith agetattrdefault ofNone, 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 likeVLLMGenerationdoes.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_stateand adding a--quantizationflag, 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 --checkand the pinneddoc-builder stylepass on both files, each run alongside a control so a silent no-op would show up.Params4bitroutes todequantize_4bitwithquant_stateforwarded and the packed shape intact.use_orig_paramssplit was measured on one H100 MIG slice (world size 1):use_orig_params=Falsepushes(16, 1)for an(8, 8)layer,Truepushes(8, 8). The guard's test matrix coversFalse,NoneandTrue; a copy of the tree with the FSDP1 branch removed fails exactly the two refused rows.merge_adapterresult 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.pre_quantizedcolumn with two refused rows and a dense control, a client test checks thatupdate_model_paramssends 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_datain 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
Params4bitweights before pushing them to vLLM, instead of sending the packeduint8buffer (which dropsquant_stateand breaks shape checks)._dense_param_datais the single path for tensors sent to vLLM (clientupdate_model_params,VLLMGenerationsync, and experimental Online DPO). Colocated engines are initialized withoutquantization="bitsandbytes"so vLLM allocates dense weights that match the push._check_quantization_supportedfails fast at vLLM setup for combinations that cannot work with dense sync: 8-bit layers, 4-bit under FSDP2, 4-bit under FSDP1 unlessuse_orig_params=True, and colocate loads from checkpoints already saved quantized.DistributedBackendnow exposesfsdp_use_orig_paramsand treats Accelerate < 1.6.0 as FSDP1 when inferringfsdp_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.