Fix: shape ops are never folded into quantized weights, leaving them mirrored vs dense - #2831
Open
kasper0406 wants to merge 1 commit into
Open
Fix: shape ops are never folded into quantized weights, leaving them mirrored vs dense#2831kasper0406 wants to merge 1 commit into
kasper0406 wants to merge 1 commit into
Conversation
`merge_affine_dequantize_with_consecutive_ops` folds shape-only ops (transpose/reshape/expand_dims/squeeze) into the quantized data of a constexpr op. For the very common `weight -> transpose -> matmul` pattern this never happens today, for two independent reasons. 1. The pass only matches `constexpr_affine_dequantize` (iOS16). Anything quantized against the iOS18 opset emits `constexpr_blockwise_shift_scale`, which the pass never matches, so the optimization silently does not happen for any iOS18-quantized model. Widen it to `constexpr_blockwise_shift_scale`. For that op `scale` (and `offset`) have the same rank as `data`, so a shape op is safe to fold exactly when applying the same op to the parameters preserves the block structure: transpose, expand_dims and squeeze always do; reshape does not, and stays allowed only for single-element (per-tensor) parameters. Sub-byte dtypes and `offset` are carried through. The pass declines when the parameters are themselves produced by another constexpr op, which would otherwise undo the compression. 2. `fuse_transpose_matmul` runs at pipeline index 53, long before the merge pass at 84, and consumes the transpose into `transpose_y` first. The result is that a quantized weight lands in the mirrored matmul orientation relative to the identical dense weight, for which `const_elimination` folds `transpose(const)` at index 10. Make `fuse_transpose_matmul` decline a transpose whose input is one of the constexpr ops the merge pass supports. This cannot lose a fusion: every pipeline containing _COMMON_PASSES also contains _CLEANUP_PASSES, which already runs the merge pass and then `fuse_transpose_matmul` again for exactly this leftover case. Adds TestBlockwiseShiftScaleConstElimination (per-tensor / per-channel / blockwise scales, offsets, int4, op chains, the negative cases, the pipeline ordering, and a before/after prediction comparison) and two cases to TestFuseTransposeMatmul.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
common::merge_affine_dequantize_with_consecutive_opsexists to fold shape-only ops (transpose,reshape,expand_dims,squeeze) into aconstexprop's quantized data, so they cost nothing at runtime. For the very commonweight -> transpose -> matmulpattern it never fires, for two independent reasons — so a quantized weight ends up in the mirrored matmul layout relative to the identical dense weight.1. The pass only matches the iOS16 op. The gate is
if op.op_type != "constexpr_affine_dequantize": continue. Anything quantized against the iOS18 opset emitsconstexpr_blockwise_shift_scale, which never matches, so the pass is effectively dead for iOS18-quantized models.2. Pass ordering pre-empts it. In
PassPipeline.DEFAULT,fuse_transpose_matmul(index 53) folds the transpose intotranspose_ylong before the merge pass (84) can fold it into the weight. So even with (1) fixed, the transpose is already gone. Note_CLEANUP_PASSESorders these correctly — merge at 84, thenfuse_transpose_matmulat 86 with the comment "there might be left over transpose … now can be fused back with matmul". The intended design is "fold into the weight first, fuse what's left"; the occurrence at 53 breaks it.A dense weight is unaffected, because
const_elimination(index 10) foldstranspose(const)into a new const before either pass runs.const_eliminationcannot do that for aconstexprop, by design. So today the same graph converts differently depending only on whether the weight is compressed.Evidence
constexpr(data=[K,N], scale=[1,N]) -> transpose -> matmul(transpose_y=True)— the shape of every linear layer in a decoder-only LLM — afterPassPipeline.DEFAULT:Both quantized paths were mirrored relative to dense; after the fix all three agree, and
scale/offsetare permuted along with the data. Repro script at the bottom.This also has a real performance cost — a downstream user converting a 4-bit LLM measured decode going from 74.8 to 12.9 ms/token once the weights were forced into the dense layout (M4 Pro, macOS 26.6.2,
.cpuAndGPU; I have not reproduced it). But the claim here is not thattranspose_y=Trueis universally faster — only that a quantized weight should be laid out like the dense weight it replaces, and that folding a transpose into the weight is strictly better than folding it into a flag, since it removes the same op and additionally costs nothing at runtime.The fix
1. Widen the merge pass to
constexpr_blockwise_shift_scale. For this opscale/offsetsharedata's rank withblock_size[i] = data.shape[i] // scale.shape[i], so safety is provable per shape op:transpose,expand_dimsandsqueezeare always safe (apply the identical op to the parameters);reshapeis excluded for blockwise and allowed only when parameters are a single element (per-tensor). This mirrors the existingSUPPORTED_OP_TYPES_PER_CHANNELreasoning — a conservative subset, skipping anything not provably correct.offsetgets the same treatment asscale; sub-byte dtypes survive; the pass declines when parameters are themselves produced by anotherconstexprop, which would undo the compression.Deliberately out of scope: the lut variants.
luthas rank K+2 with avector_axis, a materially different correctness argument. Those keep today's behaviour exactly, so nothing regresses — clean follow-up.2. Teach
fuse_transpose_matmulto decline a transposedconstexprweight whose producer is an op type the merge pass supports, so the transpose survives to index 84.This cannot lose a fusion: every pipeline containing
_COMMON_PASSESalso contains_CLEANUP_PASSES, which runs the merge pass and thenfuse_transpose_matmulagain — if the merge pass declines, the transpose is still fused at 86 by the exact fallback that comment describes. It is scoped to the supported op set, soconstexpr_lut_to_denseand friends keep today's behaviour bit for bit, and non-constexpr operands are untouched.Alternatives rejected: moving the merge pass earlier would run it before
const_deduplication/dead_code_elimination, which it is annotated as depending on (it bails when a weight has multiple child ops), and would change folding behaviour ~30 passes earlier for all quantized models; running it in both positions has the same hazard plus a second full graph traversal per conversion.Tests
New: 21 cases across
TestBlockwiseShiftScaleConstElimination(20) andTestFuseTransposeMatmul(2, counting parametrization). 19 fail on unpatchedmain; the 2 that pass are negative tests that should pass either way. Coverage includes per-tensor / per-channel on either axis / true blockwise scales with and withoutoffset, sub-byte dtype preservation,reshapefolded only when per-tensor, thePassPipeline.DEFAULTordering regression for both iOS16 and iOS18, andconstexpr_lut_to_densestill being fused unchanged.Correctness verified beyond op counts: every fold compared for exact equality against
constexpr_blockwise_shift_scale.decompressof the original op; a standalone sweep over int4/int8, offsets, rank-4 permutations and op chains; and real Core ML predictions before vs after. Optimized vs unoptimized outputs differ by 2.3e-3 relative to output scale, and both sit the same distance from an fp32 numpy reference (2.2e-3 and 1.0e-3) — fp16 accumulation noise from the changed reduction order, not a regression.Suite results:
passes/tests/1973 passed, 6 failed (all 6 reproduce identically on unmodifiedmain);ops/tests/iOS18/test_compression.py2055 passed, 0 failed;test/optimize/coreml/unchanged (644 pre-existing failures from missingsklearn/kmeans1d, byte-identical list before and after).repro.py