perf(modelopt): skip redundant weight fake-quant during frozen-weight logprobs - #3441
perf(modelopt): skip redundant weight fake-quant during frozen-weight logprobs#3441babyplutokurt wants to merge 1 commit into
Conversation
|
Hi @terrykong This PR improve the speed of the QAT Training, by stashing the fake quantization cache and remove unncessary per-forward wrapper. 62% speed up compare to current in-house w4a16 QAT in logprobs stage. |
56810f7 to
60b1e30
Compare
|
@sharonyu-115 @mxinO could you review? |
|
/ok to test 60b1e30 |
|
Thanks for the cotribution, this is a good idea. But this is over complicated solution, modelopt has a @contextmanager
def temporarily_fold_weights(model):
modules = [
m
for m in model.modules()
if hasattr(m, "weight") and hasattr(m, "weight_quantizer")
]
original_weights = [(m.weight, m.weight.detach().clone()) for m in modules]
active_quantizers = [m.weight_quantizer for m in modules if m.weight_quantizer.is_enabled]
try:
mtq.fold_weight(model, keep_attrs=True)
yield
finally:
with torch.no_grad():
for weight, original in original_weights:
weight.copy_(original)
for quantizer in active_quantizers:
quantizer.enable()and use it in the def get_logprobs(self, *args, **kwargs):
if not self.cfg.get("quant_fold_frozen_weight_snap", False):
return super().get_logprobs(*args, **kwargs)
with temporarily_fold_weights(self.model):
return super().get_logprobs(*args, **kwargs) |
b7e0d9c to
715e510
Compare
|
@mxinO Thanks, this is much better, and I've reimplemented it your way. Pushed as a single commit. Result: Three context managers and eight helpers collapse to one context manager and one helper; two mutually-exclusive config keys (with precedence rules) collapse to a single You were also right that the split was unnecessary. I'd assumed W4A4 needed its own mode, but |
In ModelOpt QAT the weight quantizer sits inside the linear, so every forward recomputes weight_quantizer(weight). During the get_logprobs re-scoring stage the weights are frozen (no_grad, no optimizer step between microbatches), so that result is identical across every microbatch and is pure wasted work. Add an opt-in policy.quant_fold_frozen_weight_snap that wraps the stage in a context manager which folds each enabled fake-quant weight quantizer using ModelOpt's fold formula (QuantModule.fold_weight): the fake-quantized value is written into the parameter and the weight quantizer is disabled -- exactly the frozen-weight steady state. Forwards during the stage then read an already-snapped weight instead of re-snapping per microbatch. Weights and quantizers are restored on exit, including on exception. Fold per discovered pair rather than delegating to mtq.fold_weight: the upstream utility selects on fake_quant alone and dereferences weight.data unconditionally, so it crashes with AttributeError on Megatron models with tied word embeddings, where the tied output_layer exposes a weight_quantizer but carries weight = None (the embedding weight is borrowed at forward time). Verified end-to-end on Qwen3-0.6B QA-GRPO, which ties embeddings. Skip disabled quantizers during discovery: their forward is the identity, so folding them is a no-op and cloning their weights for restore is pure memory waste (the disabled lm_head/embedding quantizers in the standard recipes hold ~40% of the quantized-weight bytes). Calibration state (_amax/_pre_quant_scale) is never touched. Discover quantizers the same way fold_weight does, by the *_weight_quantizer attribute-name suffix, rather than looking up a plain module.weight_quantizer. Fused and MoE modules expose names like w13_weight_quantizer and gate_up_proj_weight_quantizer; a narrower lookup would leave them folded and disabled for the rest of training. The option applies to any quantization format. Only the weight quantizer is disabled, so activation-quantized recipes (W4A4) keep their input/output quantizers running and produce unchanged logprobs. Default off, and scoped to the no-grad frozen-weight stage. Includes config examples, documentation, and unit coverage that exercises the real ModelOpt library: forward output is bit-identical folded vs unfolded, weights restore through their original storage, amax survives, restoration holds on exception, fused *_weight_quantizer modules are restored, tied-embedding modules with weight=None are skipped, and disabled quantizers are left untouched. Signed-off-by: babyplutokurt <attaboykurt.yang@gmail.com>
715e510 to
9be4279
Compare
|
I've moved off While validating the fold end-to-end (QA-GRPO, Megatron + vLLM), the first Root cause: There's no way to filter from outside: weight.data.copy_(quantizer(weight.float()).to(weight.dtype))
quantizer.disable()with two guards the upstream walk lacks: skip Same math, verified: logits are bit-identical folded vs unfolded (W4A16 and W4A4, Worth an upstream ModelOpt issue for the missing guards at |
|
Filed an upstream issue related to mtq.fold_weight: NVIDIA/Model-Optimizer#2131 |
|
fixed ModelOpt upstream at: NVIDIA/Model-Optimizer#2132 |
|
Thanks, the |
|
Thanks @mxinO, I looked at the diff in NVIDIA/Model-Optimizer#2140 and mtq.temporarily_fold_weights looks like exactly the right upstream home for this. Question on sequencing: since this PR is opt-in and already verified against the ModelOpt commit NeMo-RL currently pins, can we merge it first and then migrate the internals to mtq.temporarily_fold_weights in a follow-up once the pin is bumped past #2140? Or should we wait for nemo_RL track updated modelOpt that contains your PR? |
What does this PR do?
Adds an opt-in
policy.quant_fold_frozen_weight_snapflag that eliminates redundant weight fake-quantization during the QAT logprob stages.In ModelOpt QAT the weight quantizer sits inside the linear, so every forward recomputes
weight_quantizer(weight). During theget_logprobsre-scoring stage the weights are frozen (no_grad, nooptimizer step between microbatches), so that result is identical across every microbatch and is pure wasted work.
With the flag set, the stage is wrapped in a context manager that folds each enabled fake-quant weight quantizer once, using ModelOpt's fold formula (
QuantModule.fold_weight): the fake-quantizedvalue is written into the parameter and the weight quantizer is disabled, which is exactly the frozen-weight steady state. Forwards then read an already-snapped weight instead of re-snapping per
microbatch. Weights and quantizers are restored on exit, including on exception. Default off; when unset the path is byte-for-byte the base implementation.
Why fold per pair instead of calling
mtq.fold_weightThe upstream utility selects quantizers on
fake_quantalone and dereferencesweight.dataunconditionally (quant_module.py, line 147). On Megatron models with tied word embeddings theoutput_layeris built withskip_weight_param_allocationand carriesweight = None(the embedding weight is borrowed at forward time), while ModelOpt still attaches aweight_quantizerto it.mtq.fold_weightcrashes there withAttributeError: 'NoneType' object has no attribute 'data'. Reproduced end to end on Qwen3-0.6B QA-GRPO, which ties embeddings; HF never hits this because itexpresses tying as an aliased tensor.
The per-pair fold applies the identical upstream formula but:
output_layercase), andlm_head/embedding quantizers hold roughly 40% ofthe quantized-weight bytes on Qwen3-0.6B).
Discovery uses the same
*_weight_quantizerattribute-name suffix scan asfold_weight, so fused and MoE modules (w13_weight_quantizer,gate_up_proj_weight_quantizer) are folded and, critically,restored.
Correctness and performance verification
fused quantizers are restored, tied-embedding modules with
weight = Noneare skipped, disabled quantizers are untouched.state_dictbit-identical with and without the fold. Only the weight quantizer is disabled, so W4A4 keeps its activation quantizersrunning.
runs unquantized by design).
forward on the smoke setup, shrinking toward 1.0x for long-sequence recipes such as the 30k-token DAPO config.
Usage
Enabled in the QA recipe configs in this PR; costs one temporary copy of each folded weight shard for the duration of the stage. Megatron QAT only.