Let AWQ mappings absorb scales into a sliced projection - #2996
Conversation
AdaLN-modulated blocks feed their balance layers norm(x) * (1 + scale) + shift, where scale and shift are slices of one shared projection's output rather than modules of their own, so AWQMapping's single smooth_layer has nothing to fold 1/s into. The absorption exists and is exact: dividing the norm weight covers norm(x) * (1 + scale), and dividing the projection rows that emit shift covers the rest, while scale must be left alone so the correction is not applied twice. Add SlicedSmoothTarget to name such a row slice, an optional extra_smooth_targets list on AWQMapping to carry them, and mappings for MiniMax-H3's diffusion transformer as the first consumer. smooth_layer keeps its str type so no registered architecture is affected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Merge Protections🔴 2 of 2 protections blocking · waiting on 👀 reviews
🔴 Require one maintainer reviewWaiting for any of
This rule is failing.All PRs must have at least one approving review from a maintainer before merging.
🔴 Require two reviewsWaiting for
This rule is failing.PRs labelled "two-reviews" must have at least two approving reviews before merging.
|
There was a problem hiding this comment.
Code Review
This pull request introduces support for smoothing AdaLN-modulated blocks in AWQ by adding SlicedSmoothTarget and updating the mapping resolution and smoothing logic to handle extra smooth targets. This is particularly useful for architectures like MiniMaxH3Transformer3DModel where shared projections need to absorb scaling factors for specific slices. The review feedback suggests making absorb_sliced_scales more robust by handling the device transfer of the scales tensor internally, which also simplifies its caller.
| hidden = scales.size(0) | ||
| group = target.num_chunks * hidden |
There was a problem hiding this comment.
To make absorb_sliced_scales more robust and self-contained, it is safer to move the scales tensor to the same device as module.weight inside the function itself. This prevents potential device mismatch runtime errors if the function is called from other contexts or tests where scales is not pre-aligned to the module's device.
| hidden = scales.size(0) | |
| group = target.num_chunks * hidden | |
| scales = scales.to(module.weight.device) | |
| hidden = scales.size(0) | |
| group = target.num_chunks * hidden |
| absorb_sliced_scales( | ||
| module, target, best_scales.to(module.weight.device) | ||
| ) |
|
👋 Hi! Thank you for contributing to llm-compressor. Please add the ready label when the PR is ready for review. Note: This is required to complete the testing suite, please only add the label once the PR is code complete and local testing has been performed. |
|
Hi @Ar4ikov , thanks for raising this. We have hit this norm issue with other custom norm implementations which do We can probably extend this to include for the |
|
Thanks @brian-dellabetta, I looked at There are two distinct AdaLN shapes in diffusers, and they differ in exactly the way that matters here. 1. Modulation hoisted out of the norm, norm keeps its weight. MiniMax-H3: shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = self.adaln_proj(temb)
norm_hidden_states = self.norm1(hidden_states) * (1.0 + scale_msa.index_select(0, adaln_indices)) \
+ shift_msa.index_select(0, adaln_indices)Here the 2. Modulation inside the norm module, norm has no weight. W[scale_rows] /= s
b[scale_rows] = (1 + b[scale_rows]) / s - 1 # the offset_norm trick, on a bias slice
W[shift_rows] /= s
b[shift_rows] /= sVerified the same way on The naive variant is off by 118%, so this really is load-bearing and not a rounding detail. So a single rule covers all three families:
Two things constrain where this can live. The chunk layouts differ: More importantly, Happy to do the work either way you prefer:
One caveat that applies whatever we choose, and that I would rather flag than have found in review: none of this is reachable end to end from llm-compressor today, because |
|
Hi @Ar4ikov , I think I'm following -- I don't have much experience with diffusion models or the diffusers package, and we've not had many user posts regarding it.
Regarding NormCalibrationModule, the name is a bit misleading. It is applied generically to any module in |
SUMMARY:
AWQMapping.smooth_layeris a single module name, and_apply_smoothingfolds1/sinto that one module's weight. That covers every architecture in the registry today,
because in all of them the balance layers' input is produced by one module (a norm, or
a preceding projection).
It cannot express an AdaLN-modulated block. There the balance layers see
and
scale/shiftare slices of one shared projection's output, not modules oftheir own. Concretely, in MiniMax-H3's diffusion transformer
(
MiniMaxH3Transformer3DModel, diffusers#14355):There is no module whose weight can absorb
1/s, so AWQ has nothing to fold into andthese models fall back to
default_mappings, which do not match.The absorption does exist, and it is exact:
norm(x)/scomes from dividing the norm's weight, andshift/sfrom dividing the rowsof the shared projection that emit the
shiftchunk.scaleis left alone: it multiplieselementwise, and the
1 +term is already handled by the norm weight, so scalingscaleas well would double-apply the correction.
This PR adds the smallest thing that can express that:
SlicedSmoothTarget(layer, chunk_index, num_chunks, repeat)names a contiguous rowslice of another projection's output, in the layout produced by
view(-1, num_chunks * hidden)followed bychunk(num_chunks, -1), tiledrepeattimes.
AWQMapping.extra_smooth_targets— an optional list of them, defaulting to empty.absorb_sliced_scales()applies1/sto those rows, weight and bias.MiniMaxH3Transformer3DModel.I deliberately did not widen
smooth_layer's type to a union. That would ripplethrough
match_modules_set,ResolvedMapping,_check_layers_are_compatibleand everyregistered architecture for no benefit, since the primary smooth layer stays a single
module in this case too. The extra targets are resolved the way
activation_hook_targetalready is, by dotted path, with one difference documented inthe dataclass: they resolve against the module holding
smooth_layer, not against thebalance layers' ancestor. In an AdaLN block the balance layers sit under
attn/ffwhile the shared projection is a sibling of the norm, so resolving against the ancestor
would not find it.
Two things left out on purpose:
ff.net.0.proj -> ff.net.2mapping for MiniMax-H3. That projection is a fusedSwiGLU, and the existing Linear-smooth branch folds
1/sinto the lastout_features, which here are the gate half. The block output is not linear in the
gate, so that fold would not be equivalence preserving. Smoothing the up half needs a
sliced target on the smooth side, which is a separate change; the same gap applies to
any fused-SwiGLU architecture, not just this one.
oneshotfeeds tokenized text, while a DiT takes(hidden_states, temb, adaln_indices, rotary_emb, attention_mask)that only a realsampling run produces. So the mapping this PR registers is not reachable end to end
from llm-compressor today. I would rather land the mapping mechanism separately from
the data pipeline, but if you would prefer this to wait until a diffusion calibration
source exists, say so and I will hold it.
TEST PLAN:
New
tests/llmcompressor/modifiers/transform/awq/test_sliced_smooth.py, six unit testson a synthetic AdaLN block, so nothing here depends on diffusers or on a downloaded
checkpoint:
That run covers the whole
transform/awqdirectory, so the 40 pre-existing tests intest_base.pyandtest_dynamic_mappings.pyare in it and still pass: the new dataclassfield is optional and defaults to empty, so no registered architecture changes behaviour.
Environment: torch 2.13.0+cu130, transformers 5.14.1, compressed-tensors
0.17.2.a20260731, CPU only, no GPU needed.
The load-bearing one is
test_absorb_sliced_scales_preserves_output: fold1/sinto thenorm weight and the shift rows, scale the balance layer's input columns by
s, and theblock's output must be unchanged. In float64 it matches to
rtol=atol=1e-12.test_absorbing_the_scale_chunk_is_not_equivalentis the control. It does the same thingbut folds into the
scalechunk instead ofshift, and asserts the output changes.Without it the first test would pass for the wrong reason, since a no-op would also
"preserve" the output.
One caveat on the usual "does the test fail without the patch" check: for a new API it
fails only with
ImportError, which proves nothing. That control test is the real guard,which is why I wrote it.
I also ran the derivation against the real module rather than only the synthetic one.
Building
MiniMaxH3TransformerBlockfrom diffusers'minimax-h3branch, hidden 64,float64, and applying the same fold:
Checks on the changed files:
ruff format --checkreports one pre-existing reformat inbase.py(anassertatline ~789) and one in
test_base.py, neither of which this PR touches; my local ruff is0.15.18 and evidently differs from the pinned one, so I left both alone rather than add
unrelated churn.
Context: I hit this quantizing MiniMax-H3's 33B joint video/audio DiT to fit on 2x24GB.
adaln_projis 13.0B of the 33.1B parameters there, which is also why keeping it out ofthe quantized set matters: the diffusers docstring notes that a rounding applied before
its silu biases every block's modulation identically at every sampling step, so the error
accumulates coherently along the denoising trajectory.
@brian-dellabetta, since you built the mapping mechanism this extends in #2526 and #2451,
and @kylesayrs as a second pair of eyes on the
_apply_smoothingchange. Happy to reshapethe API if you would rather express this differently, or to hold it until a diffusion
calibration source lands.