Skip to content

feat: shared narrow-float (BF16 + FP16) layer#886

Open
michalharakal wants to merge 4 commits into
developfrom
feat/narrow-float-codec-884
Open

feat: shared narrow-float (BF16 + FP16) layer#886
michalharakal wants to merge 4 commits into
developfrom
feat/narrow-float-codec-884

Conversation

@michalharakal

Copy link
Copy Markdown
Contributor

Implements all four stages of #884. One 16-bit float layer parameterized by codec, rather than two half-built stacks.

Reviewable commit-by-commit — each stage builds clean on its own:

Commit Stage Tests
dff4a6b6 B1 codec + dense storage 41
87a28fce B3 loader KEEP_NATIVE 9
df03f625 B2 FP16 kernels + codec dispatch 11
cb1af5e7 B4 lattice repair + completeness 17

Why

Downstream, the FunctionGemma-270M export dequantized Q5_K to FP32, held it through the whole trace, then narrowed straight back to bf16 — with zero arithmetic in between (VoidTensorOps is @Backend(id = "void", displayName = "Shape-only")). Measured: 324 weight globals, 436,111,680 elements = 1.62 GiB FP32 against an 832 MiB bf16 destination, ~4.3 GiB peak live. A ×6.5 inflation to squeeze straight back down, because nothing downstream could carry narrow dense storage.

What landed

B1NarrowFloatCodec with Bf16Codec / Fp16Codec (pure integer bit math in commonMain, so every KMP target gets it); NarrowFloatDenseTensorData; Fp16DenseTensorData.

B3 — SafeTensors gains the F16 KEEP_NATIVE arm BF16 already had; GGUF stops unconditionally widening F16/BF16. Require(FP16) and Require(BF16) are accepted instead of throwing.

B2NarrowFloatMatmulKernel SPI carrying a codec; ScalarFp16MatmulKernel + PanamaVectorFp16MatmulKernel; dispatch widened from is Bf16TensorData to is NarrowFloatTensorData, selecting by codec.

B4supportsFloatOps admits BF16; convert rounds properly and accepts BF16; DenseTensorDataFactory BF16 arms; the dtype lattice repaired.

Result: an F16/BF16 checkpoint loads with Require(...), stays at 2 bytes per element, dispatches to a codec-selected kernel, and traces with the correct dtype into MLIR — no widening anywhere.

Eight latent bugs fixed along the way

Not the assignment, but found while wiring this up:

  • TraceSession had no BF16 arm, so BF16 tensors traced as FP32. This is why the downstream exporter has to regex-rewrite xf32>xbf16> in emitted MLIR text — it unblocks SKaiNET-transformers#248.
  • TensorStorageFactory decoded 2-byte narrow data with a hard 4-byte reader, yielding half the element count with each "float" assembled from two adjacent elements. Readers already tagged Dense(2); only the decode ignored it. Silent garbage, not an error.
  • FP32.promoteTo(Ternary) returned Ternary — a promotion to lower precision. Its own comment said FP32.
  • FP32.isCompatible(Ternary) returned false while its comment said Ternary promotes to FP32.
  • FP16.promoteTo(Int32) returned FP16, dropping ~21 bits, while Int32.promoteTo(FP16) correctly said FP32.
  • Int8.promoteTo(BF16) returned FP32 though bf16's 8 significand bits hold every Int8 value exactly — and BF16.promoteTo(Int8) said BF16.
  • commonPrecisionWith was a second lattice with no BF16 arm, disagreeing with promoteTo. Now delegates, so they cannot drift.
  • isConvertibleTo had no BF16 arm — every BF16 conversion reported false.

Review notes

Bf16Codec.encode truncates rather than rounding. Deliberate and contractual: it matches the existing floatToBf16Bits, the safetensors writer, and the on-device A/B that qualified bf16 as a bit-exact drop-in for the f16 vmfb. A regression test pins the two implementations together so a future "fix" to rounding fails loudly.

Kernel selection is by codec, not width. Both formats are 2 bytes per element, so a mix-up cannot fail loudly — decoding F16 bytes through the BF16 shift gives plausible-looking wrong numbers. fp16_weights_are_not_decoded_by_the_bf16_kernel pins it from both sides: the dispatch must match the FP16 decode, and the BF16 decode of the same bytes must differ by >10×, so the test cannot silently pass if they converge. The same applies in the loaders: a policy naming one narrow format widens the other rather than mis-tagging it.

Accumulation is FP32 everywhere. The narrow format is a storage width, never an accumulate width.

convert to a narrow target keeps float-backed storage (values rounded, tag correct), matching how DenseTensorDataFactory already handles narrow tags. Emitting genuinely 2-byte storage from convert would change what every existing caller receives — a separate decision.

Three pre-existing tests asserted the old limitations (require_fp16_fails_with_explicit_message, GGUF's two require_*_fails_fast_with_clear_message). Rewritten to assert the new capability; the behaviour they guarded is gone by design.

Verification

78 new tests. Full engine build clean at 2133 tasks after every stage; apiDump regenerated and apiCheck green; every pre-existing BF16 suite (Bf16TensorDataTest, PanamaVectorBf16MatmulKernelParityTest, NativeBf16MatmulKernelParityTest, Bf16MatmulDispatchTest, the SafeTensors BF16 policy tests) passes unmodified.

Two exclusions needed for a green local build, both failing identically on clean develop and unrelated to this work: :skainet-apps:skainet-grayscale-cli:test (testGpuCapabilitiesDetection asserts CUDA/IREE are absent; this machine has them) and jsBrowserTest (no ChromeHeadless installed).

Not included

  • Native FFM FP16 kernel (priority 100) — filed as FP16: native FFM matmul kernel (priority 100) to match BF16's tier #885. FP16 tops out at the Panama tier, so it currently runs a tier below BF16 on machines where the native provider wins.
  • Vectorizing the BF16 dequant (IntVector.lanewise(LSHL, 16)) that PanamaVectorBf16MatmulKernel's KDoc flags — throughput only, on an already-correct kernel.
  • Downstream payoff — retiring SKaiNET-transformers#248's regex rewrites and lowering the kgemma test heap are now unblocked but live in the other repo.

Closes #884

🤖 Generated with Claude Code

michalharakal and others added 4 commits July 26, 2026 18:02
First stage (B1) of #884. Introduces one 16-bit float layer parameterized by
codec, so BF16 and FP16 share an implementation instead of growing two
half-built stacks.

BF16 had storage, kernels, dispatch and a SafeTensors KEEP_NATIVE path but no
trace capture, no elementwise ops and no `convert` target; FP16 had the dtype
tag, `convert` and trace capture but no storage, no kernel and no loader. The
gaps are complementary, so finishing either alone leaves the other broken.

Added:

- `NarrowFloatCodec` with `Bf16Codec` and `Fp16Codec` (skainet-lang-core,
  commonMain — pure integer bit math, no JDK dependency, so every KMP target
  gets it). Becomes the canonical home for float<->half conversion; the six
  duplicated private `halfToFloat`/`decodeHalf` copies can now collapse onto it.
- `NarrowFloatTensorData` / `NarrowFloatDenseTensorData` — the shared 2-byte
  dense storage and the recognition surface for narrow-float kernel dispatch.
- `Fp16DenseTensorData` — the class the SafeTensors and GGUF loaders already
  name in their "no FP16 backing yet" errors, which is why `Require(FP16)` had
  to be rejected outright. Unblocks the loader work (B3).

`Bf16Codec.encode` TRUNCATES rather than rounding to nearest. This is
contractual, not an oversight: it matches the existing `floatToBf16Bits`, the
safetensors writer, and the on-device A/B that qualified bf16 as a bit-exact
drop-in for the f16 vmfb. A regression test pins the two implementations
together so a future "fix" fails loudly.

`Fp16Codec` implements full IEEE 754 binary16 with round-to-nearest-ties-to-even:
normals, gradual underflow into subnormals, overflow to +/-Inf past 65504, and
NaN payload preservation (never collapsing a NaN into an infinity).

Fixed two latent bugs found while wiring this up:

- `TraceSession.refOf` had no `BF16::class` arm, so a BF16 tensor traced as
  FP32. The captured dtype is what the StableHLO converter reads to pick an
  MLIR element type, so this is precisely why bf16 weights could only be
  produced by regex-rewriting the emitted MLIR text after the fact
  (SKaiNET-transformers#248).
- `TensorStorageFactory.toTensorData` decoded FLOAT16/BFLOAT16 through the
  FLOAT32 branch's hard 4-byte reader, yielding half the element count with
  each "float" assembled from two adjacent narrow elements — silent garbage.
  Readers already tagged these `Dense(bytesPerElement = 2)`; only the decode
  side ignored it. Widening to f32 here preserves the method's existing
  contract; preserving 2-byte storage end-to-end is the loader KEEP_NATIVE
  path, not this one.

`Bf16DenseTensorData` is re-parented onto `NarrowFloatDenseTensorData` and
`Bf16TensorData` onto `NarrowFloatTensorData`. Public API is preserved:
`floatToBf16Bits`/`bf16BitsToFloat` now delegate to `Bf16Codec`, and the moved
members remain resolvable via normal JVM hierarchy lookup. NOTE: `Bf16TensorData`
gains `getCodec()` with a default body, so an out-of-tree class implementing
that interface and compiled against the old API would hit AbstractMethodError.
`Bf16DenseTensorData` is the only implementor in this repo.

Tests: 41 new (21 codec, 16 dense storage, 4 storage-decode regression). The
pre-existing BF16 suites pass unmodified — Bf16TensorDataTest (12),
PanamaVectorBf16MatmulKernelParityTest (10), Bf16MatmulDispatchTest (3); the
last of these confirms `is Bf16TensorData` still matches after re-parenting.
apiDump regenerated; apiCheck green. Full engine build clean (2133 tasks).

Refs #884

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second stage (B3) of #884. Both loaders can now hand 16-bit weights through
in their on-disk 2-bytes-per-element layout instead of widening every one to
FP32 at load. Builds on the Fp16DenseTensorData added in B1.

SafeTensors:
- F16 gains the KEEP_NATIVE arm that BF16 already had. It was absent purely
  because no Fp16DenseTensorData existed to back it.
- `mapPolicyToNarrow(policy, native)` replaces the two hand-written mappings,
  so one function resolves the policy for either format.
- `Bf16LoadPolicy` becomes a typealias to the new `NarrowFloatLoadPolicy`,
  with `toDTypePolicy()` moved to an extension function. Existing call sites
  and tests compile unchanged.

GGUF:
- F16 and BF16 stop unconditionally widening; `Require(BF16)` / `Require(FP16)`
  are accepted instead of throwing. This is the KEEP_NATIVE GGUF path the
  CHANGELOG had parked, and it is what makes Require(BF16) real on GGUF.
- `keepsNative(policy, native)` is the single decision point.

A policy naming one narrow format WIDENS the other rather than keeping it
packed. This matters more than it looks: the two encodings are different bit
layouts, so mis-tagging F16 bytes as BF16 would decode to silent garbage
rather than failing. Enforced by `policy.target == native` in both loaders and
covered by a dedicated test on each side.

Three pre-existing tests asserted the old limitations
(`require_fp16_fails_with_explicit_message`, and GGUF's two
`require_*_fails_fast_with_clear_message`). Rewritten to assert the new
capability — the behaviour they guarded is gone by design, not by accident.

Tests: 4 new SafeTensors FP16 integration tests (packed bytes preserved
verbatim, decode bit-identical to the widening path, the two policies
independent on a mixed F16+BF16 file) plus 5 policy-mapping tests across both
loaders. Existing BF16 suites pass unmodified. apiDump regenerated; full
engine build clean (2133 tasks).

Refs #884

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third stage (B2) of #884. FP16 tensors now reach a real kernel instead of
paying a per-element decode through `get`, which is what made the B3
KEEP_NATIVE path correct-but-slow for FP16.

SPI:
- `NarrowFloatMatmulKernel` carries a `codec` and the existing matmul
  signature; `Bf16MatmulKernel` and the new `Fp16MatmulKernel` extend it.
  The two formats differ by a decode step and nothing else.
- `KernelProvider.matmulFp16()`, defaulting to null so providers without an
  FP16 kernel cascade as before.

Kernels:
- `ScalarFp16MatmulKernel` (priority 0, every KMP target) — the correctness
  reference.
- `PanamaVectorFp16MatmulKernel` (priority 50) — vectorized FP32 FMA over n.
  The DECODE stays scalar into a lane-width scratch buffer: BF16 widens with
  an integer shift, but binary16 needs exponent rebiasing and mainstream JDKs
  expose no FP16 vector species. Expect this to trail the BF16 kernel; that is
  the format on this platform, not a defect here.

Dispatch:
- `DefaultCpuOpsJvm.chooseQuantizedMatmul` matches `NarrowFloatTensorData`
  rather than `Bf16TensorData` and picks the kernel by `codec`.

Selecting by codec rather than width is load-bearing. Both formats are 2 bytes
per element, so a mix-up cannot fail loudly — decoding F16 bytes through the
BF16 shift yields plausible-looking wrong numbers with no exception.
`fp16_weights_are_not_decoded_by_the_bf16_kernel` pins this from both sides:
the dispatch must match the FP16 decode, AND the BF16 decode of the same bytes
must differ by >10x, so the test cannot silently pass if the two converge.

Accumulation is FP32 in every implementation. The narrow format is a storage
width, never an accumulate width.

Tests: 11 new (7 parity incl. lane-aligned/tail/k==0/empty shapes and an
exact-FP32-reference check, 4 dispatch). Existing BF16 suites pass unmodified
(10 parity + 3 dispatch). apiDump regenerated; full engine build clean.

NOT included, deliberately:
- Native FFM FP16 kernel (priority 100). BF16's lives in native/src/bf16_matmul.c;
  an FP16 peer needs new C plus build wiring. FP16 tops out at the Panama tier.
- Vectorizing the BF16 dequant (IntVector.lanewise(LSHL, 16)) that
  PanamaVectorBf16MatmulKernel's own KDoc flags — a throughput change to an
  already-correct kernel, separable from making FP16 work.

Refs #884

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ctory

Final stage (B4) of #884. Closes the gaps that left BF16 half-supported and
repairs a type lattice that disagreed with itself.

Lattice — five bugs, four of them code contradicting its own comment:

- `FP32.promoteTo(Ternary)` returned Ternary: a promotion to LOWER precision,
  which no promotion lattice should ever produce. Comment said FP32.
- `FP32.isCompatible(Ternary)` returned false; comment said Ternary promotes
  to FP32.
- `FP16.promoteTo(Int32)` returned FP16, silently dropping ~21 bits of integer
  precision, while `Int32.promoteTo(FP16)` correctly returned FP32.
- `Int8.promoteTo(BF16)` returned FP32 even though bf16's 8 significand bits
  represent every Int8 value exactly — and `BF16.promoteTo(Int8)` said BF16.
  Now consistent with how Int8 already treats FP16.
- `commonPrecisionWith` was a SECOND hand-written lattice with no BF16 arm at
  all, so `BF16.commonPrecisionWith(Int8)` said FP32 while
  `BF16.promoteTo(Int8)` said BF16. It now delegates to `promoteTo`, so the
  two cannot drift apart again.

`isConvertibleTo` had no BF16 arm either — every BF16 conversion reported
false. Now expressed via a new `DType.isFloatingPoint()` so any float width
converts to any other.

Completeness:
- `supportsFloatOps` admits BF16, which was excluded and therefore left
  BF16-tagged tensors matmul-only: every elementwise and unary op fell through
  to the generic scalar path. Callers still re-check for FloatArrayTensorData,
  so genuinely narrow-storage tensors keep falling through safely.
- `convert` to FP16/BF16 now ROUNDS. It previously shared the FP32 branch, so
  `convert(x, FP16)` only re-tagged the dtype while keeping full FP32
  precision — the tensor claimed to be FP16 while holding values no FP16 can
  represent, with no rounding, no overflow to Inf past the format range, and
  no NaN handling. BF16 was rejected as a target outright.
- `DenseTensorDataFactory`: BF16 arms for `ones`/`full`/`randn`, which threw,
  AND for both `fromFloatArray` overloads — the latter was not in the original
  survey and is why the first convert-to-BF16 run still failed.

Storage for a narrow `convert` target stays float-backed (values rounded, tag
correct), matching how DenseTensorDataFactory already handles narrow tags.
Emitting genuinely 2-byte storage from `convert` would change what every
existing caller receives and is a separate decision.

Tests: 9 lattice-consistency (incl. agreement across all 196 dtype pairs,
float/int symmetry, and a "promotion never narrows" invariant) and 8 convert
tests (rounding, fp16 overflow to Inf where bf16 stays finite, subnormal
underflow, idempotence). apiDump regenerated; full engine build clean.

Refs #884

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

📖 Documentation Preview

The documentation has been built successfully for this PR.

Generated Files:

  • Operator documentation: docs/modules/operators/_generated_/
  • JSON schema output: operators.json

Artifacts:

  • Download the documentation-preview-886 artifact to view the complete documentation locally.

This comment will be updated automatically when the PR is updated.

@michalharakal
michalharakal requested a review from aharakal July 26, 2026 20:27
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.

Shared narrow-float layer: complete BF16, add FP16 on the same SPI

2 participants