fix(lang,backend-cpu): free transpose for narrow-float weights - #895
Merged
Conversation
Projections are stored [out, in], but chooseQuantizedMatmul needs [in, out], so Linear.onForward transposes the weight on every call. Transposing a row-major narrow tensor had no fast path: it walked the tensor elementwise through boxed get() and widened to FP32. Measured on an i7-9750H that is 206 ms for a 2048x2048 projection and 4.4 s for 4096x11008 — per weight, per token. KEEP_NATIVE was therefore slower than not using it at all. Add NarrowFloatInputMajorTensorData: a rank-2 narrow weight whose bytes are stored input-major, built once via fromRowMajor(). Input-major storage of [rows, cols] is row-major storage of [cols, rows], so transposedView() hands back an ordinary NarrowFloatDenseTensorData over the same buffer — no copy, and element access stays correct on both sides, because each type indexes the shared bytes with the strides its own shape implies. That is the improvement over the K-quant lazy transpose, where get() on a transposed tensor is meaningless and only the kernel's direct packedData read is valid. Dispatch it from DefaultCpuOpsBase.transpose. Only the input-major type is reinterpreted; a row-major narrow buffer deliberately still falls through to the generic path, because swapping its shape would silently yield a different matrix rather than the transpose. The arm lives in the base class alone — DefaultCpuOpsJvm.transpose intercepts nothing that would shadow it, verified by disabling each arm in turn. Tests cover element access against the row-major original, buffer sharing, relayout round-trip, a square weight (where a wrong permutation still has the right byte count), rank and size rejection, and matmul through the transpose against an FP32 reference. Both narrow codecs are exercised, with vacuity guards asserting they disagree on identical bytes — both formats are 2 bytes per element, so a codec mix-up produces plausible wrong numbers rather than an error. The dispatch test sits in commonTest and passes on linuxX64 as well as the JVM. API change is purely additive. Refs #888.
Row-major bytes match the file, but projections are [out, in] while the narrow matmul dispatch needs [in, out]. A consumer going through Linear sees that transpose fall to the generic elementwise path and widen the tensor anyway, costing far more than KEEP_NATIVE saves. Point such consumers at NarrowFloatInputMajorTensorData.fromRowMajor, and note that gathered tensors — embedding tables above all — should stay row-major. Refs #888.
|
📖 Documentation Preview The documentation has been built successfully for this PR. Generated Files:
Artifacts:
This comment will be updated automatically when the PR is updated. |
aharakal
approved these changes
Jul 29, 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.
Fixes #888.
Problem
Projections are stored
[out, in], butchooseQuantizedMatmulneeds[in, out], soLinear.onForwardtransposes the weight on every call. Transposing a row-major narrow-float tensor had no fast path: it walked the tensor elementwise through boxedget()and widened to FP32.Measured on an i7-9750H that is 206 ms for a 2048x2048 projection and 4.4 s for 4096x11008 — per weight, per token.
KEEP_NATIVEwas therefore slower than not using it at all, which made the narrow-float matmul kernels unreachable in practice.Change
Add
NarrowFloatInputMajorTensorData: a rank-2 narrow weight whose bytes are stored input-major, built once viafromRowMajor(). Input-major storage of[rows, cols]is row-major storage of[cols, rows], sotransposedView()hands back an ordinaryNarrowFloatDenseTensorDataover the same buffer — no copy, and element access stays correct on both sides, because each type indexes the shared bytes with the strides its own shape implies.That is the improvement over the K-quant lazy transpose, where
get()on a transposed tensor is meaningless and only the kernel's directpackedDataread is valid.Dispatch it from
DefaultCpuOpsBase.transpose. Only the input-major type is reinterpreted; a row-major narrow buffer deliberately still falls through to the generic path, because swapping its shape would silently yield a different matrix rather than the transpose. The arm lives in the base class alone —DefaultCpuOpsJvm.transposeintercepts nothing that would shadow it, verified by disabling each arm in turn.The second commit documents the resulting layout caveat on the SafeTensors
KEEP_NATIVEpolicy: consumers that go throughLinearshould relay matmul weights withfromRowMajor, while gathered tensors — embedding tables above all — should stay row-major, since input-major storage would stride exactly the reads they serve.Tests
NarrowFloatInputMajorTensorDataTest(lang-core) andNarrowFloatTransposeDispatchTest(backend-cpu,commonTest) cover:transposedView()Both narrow codecs are exercised, with vacuity guards asserting they disagree on identical bytes — both formats are 2 bytes per element, so a codec mix-up produces plausible wrong numbers rather than an error. The dispatch test passes on linuxX64 as well as the JVM.
Measured effect
Validated downstream in SKaiNET-transformers, which relays matmul weights via
fromRowMajorat load. On i7-9750H / OpenJDK 21, ffn_up 8B at batch 1, BF16: 4465 ms -> 58 ms (77x), and now 1.9x faster than the FP32 SGEMM it replaces.Note this does not address #887 — FP16 remains slow in the kernel itself because
Fp16Codec.decodedominates the inner loop. The two are independent; this PR is what makes BF16KEEP_NATIVEusable at real model sizes.Compatibility
API change is purely additive;
skainet-lang-core.apiupdated accordingly.