Skip to content

fix: physically reorder packed-quant blocks in ops.transpose (all-zero matmul, not just Q5_0/Q5_1) - #969

Merged
michalharakal merged 1 commit into
developfrom
fix/q5-native-lazy-transpose-allzero
Aug 12, 2026
Merged

michalharakal merged 1 commit into
developfrom
fix/q5-native-lazy-transpose-allzero

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Summary

Fixes #968ops.transpose() for packed-quant weights (Q4_0/Q5_0/Q5_1/Q8_0/Q4_K/Q5_K/Q6_K) implemented the "lazy transpose" as a bare shape relabel over the same packedData bytes, on the claim that the matmul kernels index those bytes input-block-major regardless of physical layout. That's only true when there's a single quantization block per row; for any wider row (virtually every real model — hidden dims are always many multiples of the 32/256-element block size), the shape swap doesn't reorder the physically row-major bytes a fresh/GGUF-loaded weight actually has, so ops.matmul(x, ops.transpose(W)) feeds the packed-quant matmul dispatch (native FFM / Panama / scalar kernels alike) bytes in the wrong order — silently wrong output, sometimes all zero.

Discovered downstream via SKaiNET-transformers#307 (Q5_0/Q5_1, native kernel tier, all-zero output). Full root cause + a per-format ground-truth matrix proving this is general (not Q5-specific) is in #968.

Root cause

See #968 for the full writeup. Short version: canonical (row-major: o * blocksPerInputDim + blockIdx) and kernel-native (blockIdx * outputDim + o) block orderings are literal transposes of the (outputDim, blocksPerInputDim) block grid, and only coincide when blocksPerInputDim == 1. DefaultCpuOps.transpose()'s "same bytes, new shape" optimization silently assumed they always coincide.

Fix

  • DefaultCpuOps.transpose() now performs the real O(bytes) block-grid permutation (new transposePackedBlocks helper) instead of a shape-only swap, for all seven packed formats. Still avoids the FP32 dequant round-trip the lazy-transpose optimization exists to dodge — just not "free" (O(1)) anymore, since it has to actually be correct.
  • A misaligned packed tensor (inputDim not a multiple of the format's block size) now fails loudly (IllegalArgumentException via requirePackedBlockAligned) instead of silently truncating a partial trailing block.
  • DefaultCpuOpsJvm.transpose()'s redundant, independently-buggy Q4_KTensorData (ByteArray-backed) shape-swap-only interception is removed — it now falls through to the shared, corrected base implementation. (The separate Q4MemorySegmentMarker/Q8MemorySegmentMarker MemSeg-backed arms are untouched — different data classes, different kernel convention, out of scope here; flagged as a follow-up risk to check in Packed-quant lazy transpose silently corrupts matmul for any weight with >1 block/row (all formats, not just Q5_0/Q5_1) #968.)

Before / after evidence

New NativeLazyTransposeGroundTruthReproTest (native-cpu jvmTest), native kernel tier forced via KernelRegistry.register(NativeKernelProvider). Per packed format: builds the SAME logical weight as canonical (row-major) vs. kernel-native (repacked) byte packings, computes an independent ground truth via PackedBlockStorage.toFloatArray()'s block-sequential dequant, and compares the classic (ops.transpose + matmul) and pre-transposed (skip-transpose) paths against it.

Before this fix (on develop, confirmed while investigating #968):

[Q5_0] blocksPerInputDim=8 classicMatchesGroundTruth=false preMatchesGroundTruth=true
[Q5_1] blocksPerInputDim=8 classicMatchesGroundTruth=false preMatchesGroundTruth=true
[Q4_0] blocksPerInputDim=8 classicMatchesGroundTruth=false preMatchesGroundTruth=true
[Q8_0] blocksPerInputDim=8 classicMatchesGroundTruth=false preMatchesGroundTruth=true
[Q4_K] blocksPerInputDim=2 classicMatchesGroundTruth=false preMatchesGroundTruth=true
[Q5_K] blocksPerInputDim=2 classicMatchesGroundTruth=false preMatchesGroundTruth=true
[Q6_K] blocksPerInputDim=2 classicMatchesGroundTruth=false preMatchesGroundTruth=true
[Q5_0-singleblock] blocksPerInputDim=1 classicMatchesGroundTruth=true  preMatchesGroundTruth=true

After this fix (all 8 cases green):

[Q5_0] blocksPerInputDim=8 classicMatchesGroundTruth=true preMatchesGroundTruth=true
[Q5_1] blocksPerInputDim=8 classicMatchesGroundTruth=true preMatchesGroundTruth=true
[Q4_0] blocksPerInputDim=8 classicMatchesGroundTruth=true preMatchesGroundTruth=true
[Q8_0] blocksPerInputDim=8 classicMatchesGroundTruth=true preMatchesGroundTruth=true
[Q4_K] blocksPerInputDim=2 classicMatchesGroundTruth=true preMatchesGroundTruth=true
[Q5_K] blocksPerInputDim=2 classicMatchesGroundTruth=true preMatchesGroundTruth=true
[Q6_K] blocksPerInputDim=2 classicMatchesGroundTruth=true preMatchesGroundTruth=true
[Q5_0-singleblock] blocksPerInputDim=1 classicMatchesGroundTruth=true preMatchesGroundTruth=true

classic and pre-transposed now also match each other bit-for-bit (both derive from the same physically-correct kernel-native bytes post-fix), which they did not before for blocksPerInputDim > 1.

Other test updates

  • PackedMatmulDispatchTest (backend-cpu commonTest, runs jvmTest + linuxX64Test) rebuilt its Q5_1/Q4_K/Q6_K synthetic byte generators to produce genuinely canonical (row-major) bytes instead of already-kernel-native bytes. Before this change the test was self-fulfilling — its synthetic data was pre-arranged to make the old bare-shape-swap "transpose" happen to work, so it never exercised the real ops.matmul(x, ops.transpose(W)) contract for real (row-major) input.
  • QuantizedMemSegMatmulTest's Q4_K/Q6_K "lazy transpose keeps the same packedData reference (zero-copy)" assertions are now content-equality checks (contentEquals) instead of reference-identity (===) — those tests use blocksPerInputDim == 1 configurations, so content is still correctly preserved, but zero-copy is no longer part of the correctness contract (transpose is a real permutation now, not a wrapper).

Test plan

  • :skainet-backends:skainet-backend-native-cpu:jvmTest — full suite green, including new NativeLazyTransposeGroundTruthReproTest (8/8)
  • :skainet-backends:skainet-backend-cpu:jvmTest — full suite green, including updated PackedMatmulDispatchTest and QuantizedMemSegMatmulTest
  • :skainet-backends:skainet-backend-native-cpu:linuxX64Test — full suite green
  • :skainet-backends:skainet-backend-cpu:linuxX64Test — full suite green
  • :skainet-backends:skainet-backend-cpu:apiCheck — green, no public API signature changes (skainet-backend-native-cpu has no apiCheck task)

Closes #968.

🤖 Generated with Claude Code

…pose

DefaultCpuOps.transpose() implemented the packed-quant "lazy transpose"
(Q4_0/Q5_0/Q5_1/Q8_0/Q4_K/Q5_K/Q6_K) as a bare shape relabel over the
same packedData bytes, on the assumption that the matmul kernels index
those bytes input-block-major from the post-swap shape regardless of
physical layout. That's only true when the bytes are already
kernel-native (input-block-major); a freshly-built or GGUF-loaded
packed tensor is canonical/row-major instead, and the two orderings
coincide only when there's a single quant block per row. For any wider
row - i.e. virtually every real model - `ops.matmul(x,
ops.transpose(W))` fed the packed-quant matmul dispatch
(chooseQuantizedMatmulHeap, native/Panama/scalar kernels alike) bytes
in the wrong physical order, silently: wrong output, sometimes all
zero.

Found downstream via SKaiNET-transformers#307 (Q5_0/Q5_1, native
kernel tier). Ground-truth reproduction here shows it's general to
every packed format the dispatch serves, not Q5-specific. Root cause
and full format matrix in SKaiNET#968.

transpose() now performs the real O(bytes) block-grid permutation
(transposePackedBlocks) instead of the shape-only swap, for all seven
formats, and fails loudly on a misaligned (non-block-multiple) input
instead of silently truncating. DefaultCpuOpsJvm's redundant,
independently-buggy Q4_K shape-swap-only interception is removed in
favor of the shared, corrected base implementation.

Adds NativeLazyTransposeGroundTruthReproTest (native-cpu jvmTest): per
format, compares the classic (transpose) and pre-transposed
(skip-transpose) native-kernel paths against an independently computed
ground truth. Updates PackedMatmulDispatchTest to build genuinely
canonical (row-major) synthetic bytes instead of already-kernel-native
bytes, so it exercises the real contract instead of self-fulfilling
around the bug. Updates QuantizedMemSegMatmulTest's Q4_K/Q6_K
"zero-copy" assertions to content-equality, since zero-copy is no
longer the correctness contract.

Fixes #968

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@michalharakal
michalharakal requested a review from aharakal August 12, 2026 07:00
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.

Packed-quant lazy transpose silently corrupts matmul for any weight with >1 block/row (all formats, not just Q5_0/Q5_1)

2 participants