Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ public interface KernelProvider {
return when (weight) {
"Float32" -> matmulFp32() != null
"BFloat16" -> matmulBf16() != null
"Float16" -> matmulFp16() != null
"Q4_K" -> matmulQ4K() != null
"Q8_0" -> matmulQ8_0() != null
"Q4_0" -> matmulQ4_0() != null
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package sk.ainet.exec.kernel

import jdk.incubator.vector.FloatVector
import jdk.incubator.vector.IntVector
import jdk.incubator.vector.VectorOperators
import jdk.incubator.vector.VectorSpecies
import sk.ainet.backend.api.kernel.Fp16MatmulKernel
import sk.ainet.lang.types.Fp16Codec
Expand All @@ -15,16 +17,35 @@ import sk.ainet.lang.types.Fp16Codec
* its dequant. Binary16 needs exponent rebiasing and gradual-underflow handling, and mainstream
* JDKs expose no FP16 vector species, so each element is decoded scalar into a lane-width scratch
* buffer before the vectorized multiply-accumulate. The FMA over `n` is still fully vectorized —
* only the widening is not. Expect this kernel to trail the BF16 one; that is inherent to the
* format on this platform, not a defect in this implementation.
* only the widening is not.
*
* Numerical parity vs [ScalarFp16MatmulKernel] is asserted by
* **Why not [Fp16Codec] (#887).** The codec is portable integer bit math, and calling it once per
* weight element made this kernel run at a flat ~0.5 GFLOP/s — 2-18x *slower* than the FP32 SGEMM
* it replaces, while the structurally identical BF16 kernel was 1.5-2.1x faster. `float16ToFloat`
* is a HotSpot intrinsic (JDK 20+) that lowers to a single `vcvtph2ps` on F16C hardware and to a
* compact branch-free sequence elsewhere, so the scratch fill stops dominating the inner loop.
* The kernel is JVM-only and this provider already gates on JDK 21+, so the intrinsic is always
* available where this code runs.
*
* The substitution is exact: the JDK conversion and [Fp16Codec.decode] agree bit-for-bit on all
* 65536 inputs, which `Fp16CodecIntrinsicParityTest` asserts exhaustively. Reaching that agreement
* is why the codec now quiets NaN — the hardware conversion does, so the codec was aligned with it
* in the same change rather than the kernel being held back. Numerical parity vs
* [ScalarFp16MatmulKernel] — which still goes through the codec — is asserted by
* `PanamaVectorFp16MatmulKernelParityTest`.
*/
public object PanamaVectorFp16MatmulKernel : Fp16MatmulKernel {

private val floatSpecies: VectorSpecies<Float> = FloatVector.SPECIES_PREFERRED

/**
* Derived from [floatSpecies]' shape rather than taken as `SPECIES_PREFERRED`, so the two
* always have the same lane count — [widen] reinterprets between them lanewise.
*/
private val intSpecies: VectorSpecies<Int> = IntVector.SPECIES_PREFERRED.withShape(
floatSpecies.vectorShape(),
) as VectorSpecies<Int>

override fun matmul(
a: FloatArray, aOffset: Int, aStride: Int,
b: ByteArray, bByteOffset: Int, bByteStride: Int,
Expand All @@ -45,7 +66,8 @@ public object PanamaVectorFp16MatmulKernel : Fp16MatmulKernel {

val laneCount = floatSpecies.length()
val bound = floatSpecies.loopBound(n)
val scratch = FloatArray(laneCount)
// Raw 16-bit patterns, not decoded floats: the widening happens in the vector domain.
val scratch = IntArray(laneCount)

// Zero the output block first — the i-p-j outer product accumulates into it.
for (i in 0 until m) {
Expand All @@ -66,9 +88,9 @@ public object PanamaVectorFp16MatmulKernel : Fp16MatmulKernel {
for (lane in 0 until laneCount) {
val lo = b[byteBase + lane * 2].toInt() and 0xFF
val hi = b[byteBase + lane * 2 + 1].toInt() and 0xFF
scratch[lane] = Fp16Codec.decode((hi shl 8) or lo)
scratch[lane] = (hi shl 8) or lo
}
val bVec = FloatVector.fromArray(floatSpecies, scratch, 0)
val bVec = widen(IntVector.fromArray(intSpecies, scratch, 0))
val outVec = FloatVector.fromArray(floatSpecies, out, outRowOff + j)
aBcast.fma(bVec, outVec).intoArray(out, outRowOff + j)
j += laneCount
Expand All @@ -78,10 +100,67 @@ public object PanamaVectorFp16MatmulKernel : Fp16MatmulKernel {
val bByteIdx = bRowByteOff + j * 2
val lo = b[bByteIdx].toInt() and 0xFF
val hi = b[bByteIdx + 1].toInt() and 0xFF
out[outRowOff + j] += aIp * Fp16Codec.decode((hi shl 8) or lo)
out[outRowOff + j] += aIp * halfToFloat(lo, hi)
j++
}
}
}
}

/**
* Widen one little-endian binary16 element to FP32, for the scalar tail.
*
* `toShort()` keeps the low 16 bits, which is exactly the packed element; the sign extension
* that produces is what `float16ToFloat` expects. The tail runs at most `laneCount - 1` times
* per row, so the intrinsic is enough here and the vector path is reserved for [widen].
*/
private fun halfToFloat(lo: Int, hi: Int): Float =
java.lang.Float.float16ToFloat((((hi shl 8) or lo).toShort()))

/**
* Widen a whole vector of binary16 patterns to FP32, branch-free.
*
* The classic shift-and-rebias conversion, done lanewise. Shifting the sign-free pattern left
* by 13 lands binary16's exponent and mantissa in FP32's positions; adding `(127 - 15) << 23`
* rebiases the exponent. Two cases need a correction on top, and both are applied under a mask
* rather than a branch:
*
* - **Inf/NaN** (exponent all ones) needs a second `(128 - 16) << 23`, which saturates the
* FP32 exponent to all ones.
* - **Zero and subnormals** (exponent zero) are renormalized by the FPU instead of by a loop:
* bump the exponent by one and subtract `2⁻¹⁴`. For a subnormal `m * 2⁻²⁴` the bumped value
* is `2⁻¹⁴ * (1 + m * 2⁻¹⁰)`, so the subtraction leaves exactly `m * 2⁻²⁴`; for zero it
* leaves `+0`, and the sign is reapplied afterwards either way.
*
* A signaling NaN stays signaling here, where [Fp16Codec.decode] would quiet it. That is not
* observable: every lane feeds the FMA below, and the FMA quiets it. The exhaustive kernel
* sweep in `PanamaVectorFp16MatmulKernelParityTest` asserts bit equality with the codec on
* every non-NaN pattern and NaN-ness on the rest, which is exactly this contract.
*/
private fun widen(h: IntVector): FloatVector {
val shifted = h.and(0x7FFF).lanewise(VectorOperators.LSHL, 13)
val expField = shifted.and(EXP_FIELD)

var biased = shifted.add(EXP_REBIAS)
biased = biased.add(EXP_REBIAS, expField.compare(VectorOperators.EQ, EXP_FIELD))

val renormalized = biased.add(SUBNORMAL_BUMP).reinterpretAsFloats().sub(SUBNORMAL_MAGIC)
val subnormal = expField.compare(VectorOperators.EQ, 0).cast(floatSpecies)

val magnitude = biased.reinterpretAsFloats().blend(renormalized, subnormal)
val sign = h.and(0x8000).lanewise(VectorOperators.LSHL, 16)
return magnitude.reinterpretAsInts().or(sign).reinterpretAsFloats()
}

/** binary16's exponent field once shifted into FP32 position: `0x7C00 shl 13`. */
private const val EXP_FIELD = 0x0F80_0000

/** `(127 - 15) shl 23` — the FP32/binary16 exponent bias difference. */
private const val EXP_REBIAS = 0x3800_0000

/** `1 shl 23` — one exponent step, to lift a subnormal into the magic constant's binade. */
private const val SUBNORMAL_BUMP = 0x0080_0000

/** `2⁻¹⁴` (bits `113 shl 23`) — binary16's smallest normal, subtracted to renormalize. */
private val SUBNORMAL_MAGIC: Float = Float.fromBits(0x3880_0000)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import sk.ainet.lang.types.Fp16Codec
import kotlin.math.abs
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
Expand Down Expand Up @@ -95,6 +96,49 @@ class PanamaVectorFp16MatmulKernelParityTest {
assertTrue(PanamaVectorFp16MatmulKernel.codec === Fp16Codec)
}

@Test
fun panama_decode_matches_the_codec_on_every_bit_pattern() {
// The Panama kernel widens through Float.float16ToFloat while the scalar kernel goes
// through Fp16Codec (#887). A 1xN matmul with a = [1] and a zeroed accumulator makes
// out[j] the decoded weight exactly — 1*x + 0 is exact — so this compares the two decode
// paths directly over the whole domain rather than sampling.
//
// Chunked at 999 columns so every chunk has a vectorized body plus a scalar tail for any
// lane count in {2, 4, 8, 16}, and the tail lands on different patterns in each chunk.
val chunk = 999
val a = floatArrayOf(1f)
var base = 0
while (base <= 0xFFFF) {
val n = minOf(chunk, 0x1_0000 - base)
val b = ByteArray(n * 2)
for (j in 0 until n) {
val bits = base + j
b[j * 2] = (bits and 0xFF).toByte()
b[j * 2 + 1] = ((bits ushr 8) and 0xFF).toByte()
}
val out = FloatArray(n)
PanamaVectorFp16MatmulKernel.matmul(a, 0, 1, b, 0, n * 2, out, 0, n, 1, n, 1)

for (j in 0 until n) {
val bits = base + j
val expected = Fp16Codec.decode(bits)
val actual = out[j]
when {
// FMA quiets signaling NaNs and may rewrite the payload, so only NaN-ness is
// meaningful here; the payload itself is pinned by Fp16CodecIntrinsicParityTest.
expected.isNaN() -> assertTrue(actual.isNaN(), "expected NaN at 0x${bits.toString(16)}")
// Accumulating -0 into +0 yields +0, so the sign of zero cannot survive a matmul.
expected == 0f -> assertTrue(actual == 0f, "expected zero at 0x${bits.toString(16)}")
else -> assertEquals(
expected.toRawBits(), actual.toRawBits(),
"decode diverged at 0x${bits.toString(16)}: codec=$expected panama=$actual",
)
}
}
base += n
}
}

@Test
fun fp16_kernel_result_tracks_an_exact_fp32_reference() {
// With operands exactly representable in binary16, the kernel must reproduce a plain
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ set(SKAINET_KERNEL_SOURCES
src/q6k_matmul.c
src/fp32_matmul.c
src/bf16_matmul.c
src/fp16_matmul.c
src/q8_0_matmul.c
src/q4_0_matmul.c
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,23 @@ SKAINET_API void skainet_bf16_matmul(
int32_t m, int32_t n, int32_t k
);

/*
* Row-major FP32 × FP16 matmul: C(m, n) = A(m, k) * B(k, n).
*
* Identical contract to skainet_bf16_matmul, with B packed as IEEE
* binary16 little-endian (2 bytes per element) instead of BF16.
*
* FP16 → FP32 needs exponent rebiasing and gradual-underflow handling
* rather than BF16's single shift; it is done branch-free so the inner
* loop still vectorizes. See src/fp16_matmul.c.
*/
SKAINET_API void skainet_fp16_matmul(
const float* a, int32_t a_offset, int32_t a_stride,
const uint8_t* b, int32_t b_byte_offset, int32_t b_byte_stride,
float* c, int32_t c_offset, int32_t c_stride,
int32_t m, int32_t n, int32_t k
);

/*
* Q8_0 matrix-vector multiply.
*
Expand Down
Loading
Loading