[QNN EP] Sign-extend sub-byte weights when folding a constant DequantizeLinear - #728
Merged
Merged
Conversation
qti-yuduo
force-pushed
the
dev/qti-yuduo/int4-sign-extend-folded-weights
branch
from
August 13, 2026 21:17
96428e4 to
f19e661
Compare
qti-yuduo
force-pushed
the
dev/qti-yuduo/int4-sign-extend-folded-weights
branch
2 times, most recently
from
August 13, 2026 22:52
eaeaa1a to
8e51a9d
Compare
qti-yuduo
marked this pull request as ready for review
August 13, 2026 22:55
qti-yuduo
requested review from
qti-ashwshan,
qti-chuteng,
qti-jkilpatrick,
qti-kromero,
tirupath-qti and
yath1
as code owners
August 13, 2026 22:55
qti-yuduo
force-pushed
the
dev/qti-yuduo/int4-sign-extend-folded-weights
branch
from
August 17, 2026 22:37
8e51a9d to
8fecb51
Compare
…izeLinear
QnnModelWrapper::UnpackInitializerData() expands a sub-byte initializer to one
byte per element and masks off the unused high bits (UnpackInt4ToInt8() applies
`dst[i] &= 0x0F`). That mask works around a QNN INT4 accuracy bug and has to stay.
GetEffectivelyConstantTensorBytes() hands those same bytes to callers that read
them as int8_t, though. FoldConstantDequantizeLinear() -> DequantizePerChannel()
therefore decodes every negative 4-bit weight as q + 16: -1 comes back as 15, -7
as 9, and the folded FLOAT_32 result has no negative values at all. The bit width
is unrecoverable there because CreateMapQuantize collapses INT4 into
SFIXED_POINT_8 on non-GPU backends.
Sign-extend the masked bytes back to plain two's complement before returning
them. utils::SignExtendUnpackedSubByteData() does this for INT4 and INT2, and
GetEffectivelyConstantTensorBytes() calls it on the initializer branch, which
also covers the same latent defect in BatchNormalization's folded-scale path.
UINT4 and UINT2 are unsigned, so the masked byte already holds the value and is
left alone; the folded-constant branch is unaffected because a folded tensor
holds fp32 or 8/16/32-bit bytes (QuantizeData rejects sub-byte types).
Observed on a w4a16 Qwen3 0.6B QDQ model. There, 32 KV-producing ops take the
float fallback -- their DQ input is uint16 and their Q output is uint8, so
CheckQDQNodes() declines the node unit -- and fold their weights through this
path:
* The 16 SpinQuant R3 Hadamard weights, whose entries are analytically
+/-1/sqrt(128) = +/-0.0883883, came out all-positive with a spurious
0.113642 = 0.0883883 * 9/7, i.e. -7 read back as 9. They now land exactly
on +/-0.0883883.
* PSNR against the AIMET QuantSim reference, min/mean over all outputs on
byte-identical inputs, went from -7.66 dB to 14.48/26.59 dB (prompt) and
16.73/34.30 dB (token). The qairt-onnx-converter baseline scores
10.99/20.25 and 16.73/33.54 on the same graph. All 57 outputs match.
* Windowed wikitext perplexity over 2032 scored tokens: 208.72 for this
build, 223.98 for the qairt baseline, 203.47 for the unquantized
reference; cross-entropy delta against the baseline -0.0706 +/- 0.0270.
Emitted DLCs are otherwise structurally identical to the unpatched build -- same
op and tensor counts, same dtype histograms -- so the change is numeric only.
Tests: Convf32_PerChannelInt4DQConstWeight_SignRegression, on the CPU and HTP
backends, folds a per-channel INT4 DQ whose weights span the negative range and
compares against the CPU EP; both fail without the fix. At the function level,
SignExtendUnpackedSubByteData_* in qnn_utils_test.cc covers every INT4 and INT2
value plus the types the helper must leave alone, and qdq_constant_folding_test.cc
pins GetEffectivelyConstantTensorBytes() over mocked INT4, UINT4 and UINT8
initializers.
qti-yuduo
force-pushed
the
dev/qti-yuduo/int4-sign-extend-folded-weights
branch
from
August 18, 2026 21:42
8fecb51 to
94eac0b
Compare
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.
Problem
UnpackInitializerData()expands a sub-byte initializer to one byte per element and masks off the unused high bits (UnpackInt4ToInt8():dst[i] &= 0x0F). That mask works around a QNN INT4 accuracy bug, so it has to stay.GetEffectivelyConstantTensorBytes()hands those bytes to callers that read them asint8_t.FoldConstantDequantizeLinear()→DequantizePerChannel()therefore decodes every negative 4-bit weight asq + 16(-1→15,-7→9), leaving the foldedFLOAT_32result with no negative values at all. The bit width is unrecoverable there:CreateMapQuantizecollapses INT4/INT2 intoSFIXED_POINT_8on non-GPU backends.Fix
New
utils::SignExtendUnpackedSubByteData()for INT4/INT2, over the existingInt4x2::SignExtendLower4Bits/Int2x4::SignExtendLower2Bits. It masks before extending, so it is well-defined for any input byte and idempotent.GetEffectivelyConstantTensorBytes()calls it on the initializer branch, which also covers the same defect inBatchNormalization's folded-scale path.UINT4/UINT2 need nothing — the masked byte is already the value. The folded-constant branch needs nothing either: those bytes are fp32 or 8/16/32-bit, since
QuantizeDatahas no sub-byte case.Deliberately not fixed by dropping the mask in
UnpackInt4ToInt8(re-exposes the QNN bug across unaudited call sites), nor by teachingDequantizePerChannelabout ONNX types (misses BatchNormalization).Where it shows up
A w4a16 Qwen3 0.6B QDQ model: 32 KV-producing ops take the float fallback — DQ input
uint16, Q outputuint8, soCheckQDQNodes()declines the node unit — and fold their weights through this path. The fallback is correct behaviour; only its int4 decode was wrong.Validation
0.113642=0.0883883 × 9/7, i.e. −7 read as 9All 57 part2 outputs match; inputs byte-identical across arms. DLCs are otherwise structurally identical — same op and tensor counts, same dtype histograms — so the change is numeric only.
Perplexity caveats: SpinQuant R2+R3 only (no AdaScale/SeqMSE), so the absolute PPL is not recipe-faithful, and the sweep is windowed (disjoint 128-token windows, empty KV). All arms consume byte-identical windows, so the comparison holds.
Tests
Convf32_PerChannelInt4DQConstWeight_SignRegression(conv_test.cc, CPU and HTP): folds a per-channel INT4 DQ with weights spanning −8..7 against the CPU EP. Both variants fail without the fix, while the neighbouring int8 chain tests keep passing.qnn_utils_test.cc: every INT4 and INT2 value, the types the helper must leave alone, the empty span.qdq_constant_folding_test.cc(new): mocked INT4 / UINT4 / UINT8 initializers plus the not-found path.627 tests pass. Negative control: neutering the sign extension fails exactly the 2 graph tests and 3 sign-sensitive unit tests, nothing else.
Notes
UnpackZeroPoints()hand-rolls the same unmasking inline, and [QNN EP] Sign-extend sub-byte weights when pre-dequantizing a per-channel integer-op weight #729 adds a third consumer. Converging them — or lettingUnpackInitializerData()sign-extend on request, where the element type is already in hand — would remove the recurring trap for the ~80 call sites sharing the implicit "bytes are masked" convention.