Skip to content

[QNN EP] Sign-extend sub-byte weights when folding a constant DequantizeLinear - #728

Merged
qti-yuduo merged 1 commit into
mainfrom
dev/qti-yuduo/int4-sign-extend-folded-weights
Aug 19, 2026
Merged

[QNN EP] Sign-extend sub-byte weights when folding a constant DequantizeLinear#728
qti-yuduo merged 1 commit into
mainfrom
dev/qti-yuduo/int4-sign-extend-folded-weights

Conversation

@qti-yuduo

@qti-yuduo qti-yuduo commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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 as int8_t. FoldConstantDequantizeLinear()DequantizePerChannel() therefore decodes every negative 4-bit weight as q + 16 (-115, -79), leaving the folded FLOAT_32 result with no negative values at all. The bit width is unrecoverable there: CreateMapQuantize collapses INT4/INT2 into SFIXED_POINT_8 on non-GPU backends.

Fix

New utils::SignExtendUnpackedSubByteData() for INT4/INT2, over the existing Int4x2::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 in BatchNormalization'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 QuantizeData has no sub-byte case.

Deliberately not fixed by dropping the mask in UnpackInt4ToInt8 (re-exposes the QNN bug across unaudited call sites), nor by teaching DequantizePerChannel about 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 output uint8, so CheckQDQNodes() declines the node unit — and fold their weights through this path. The fallback is correct behaviour; only its int4 decode was wrong.

Validation

Check Before After
16 SpinQuant R3 Hadamard weights (analytically ±1/√128 = ±0.0883883) all positive; spurious 0.113642 = 0.0883883 × 9/7, i.e. −7 read as 9 exactly ±0.0883883
PSNR vs AIMET QuantSim, min/mean prompt −7.66 dB prompt 14.48 / 26.59 dB, token 16.73 / 34.30 dB
Windowed wikitext perplexity, 2032 scored tokens baseline 223.98 (unquantized ref 203.47) 208.72, ΔCE −0.0706 ± 0.0270

All 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

@qti-yuduo
qti-yuduo force-pushed the dev/qti-yuduo/int4-sign-extend-folded-weights branch from 96428e4 to f19e661 Compare August 13, 2026 21:17
@qti-yuduo
qti-yuduo force-pushed the dev/qti-yuduo/int4-sign-extend-folded-weights branch 2 times, most recently from eaeaa1a to 8e51a9d Compare August 13, 2026 22:52
@qti-yuduo
qti-yuduo marked this pull request as ready for review August 13, 2026 22:55

@yath1 yath1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice find! LGTM.

@qti-yuduo
qti-yuduo force-pushed the dev/qti-yuduo/int4-sign-extend-folded-weights branch from 8e51a9d to 8fecb51 Compare August 17, 2026 22:37
…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
qti-yuduo force-pushed the dev/qti-yuduo/int4-sign-extend-folded-weights branch from 8fecb51 to 94eac0b Compare August 18, 2026 21:42
@qti-yuduo
qti-yuduo merged commit 527934a into main Aug 19, 2026
61 checks passed
@qti-yuduo
qti-yuduo deleted the dev/qti-yuduo/int4-sign-extend-folded-weights branch August 19, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants