fix: physically reorder packed-quant blocks in ops.transpose (all-zero matmul, not just Q5_0/Q5_1) - #969
Merged
Conversation
…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>
aharakal
approved these changes
Aug 12, 2026
This was referenced Aug 12, 2026
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.
Summary
Fixes #968 —
ops.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 samepackedDatabytes, 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, soops.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 whenblocksPerInputDim == 1.DefaultCpuOps.transpose()'s "same bytes, new shape" optimization silently assumed they always coincide.Fix
DefaultCpuOps.transpose()now performs the realO(bytes)block-grid permutation (newtransposePackedBlockshelper) 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.inputDimnot a multiple of the format's block size) now fails loudly (IllegalArgumentExceptionviarequirePackedBlockAligned) instead of silently truncating a partial trailing block.DefaultCpuOpsJvm.transpose()'s redundant, independently-buggyQ4_KTensorData(ByteArray-backed) shape-swap-only interception is removed — it now falls through to the shared, corrected base implementation. (The separateQ4MemorySegmentMarker/Q8MemorySegmentMarkerMemSeg-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-cpujvmTest), native kernel tier forced viaKernelRegistry.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 viaPackedBlockStorage.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):After this fix (all 8 cases green):
classicandpre-transposednow also match each other bit-for-bit (both derive from the same physically-correct kernel-native bytes post-fix), which they did not before forblocksPerInputDim > 1.Other test updates
PackedMatmulDispatchTest(backend-cpucommonTest, runsjvmTest+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 realops.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 useblocksPerInputDim == 1configurations, 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 newNativeLazyTransposeGroundTruthReproTest(8/8):skainet-backends:skainet-backend-cpu:jvmTest— full suite green, including updatedPackedMatmulDispatchTestandQuantizedMemSegMatmulTest: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-cpuhas noapiChecktask)Closes #968.
🤖 Generated with Claude Code