Skip to content

fix: make PerGroupCodebook reachable and lossless on packed FP4 - #3

Merged
khwstolle merged 7 commits into
masterfrom
fix/fp4-nibble-descriptor
Aug 18, 2026
Merged

fix: make PerGroupCodebook reachable and lossless on packed FP4#3
khwstolle merged 7 commits into
masterfrom
fix/fp4-nibble-descriptor

Conversation

@khwstolle

@khwstolle khwstolle commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Draft: do not merge. A review after opening found two blockers and a
factual error in the original description. Both are recorded below.

Correction to the original description

The original text claimed the codec "has been unselectable since accepts
could never be satisfied, so no container written by a release carries a
plane encoded this way."

That is wrong. MxFp4Deinterleave already emits a value plane with
Role::Value{Fp4E2m1}, ElementWidth::Nibble and is_nibble_packed: true
(mxfp4_deinterleave.rs:88), which satisfies all three conditions, and the
chain explorer can build chains containing it. The claim came from a sweep
of PRODUCTION_CHAINS only; "absent from the production table" was
generalized to "unreachable", which does not follow.

Blockers

Existing containers stop decoding. A container written through
MxFp4Deinterleave stores n_assignments = L/32. The new decode computes
decoded_nibbles = L*2 and therefore expected_assignments = L/16, so it
mismatches and returns CodecDecode. Neither the codec id nor
STATE_FORMAT_VERSION was bumped, so nothing distinguishes old payloads
from new.

Compression can abort where it previously succeeded. encode now
requires the plane byte count to be a multiple of 16. Packed FP4 has
exactly one production chain, and a codec's encode error propagates out of
the trial encode and drops the whole candidate chain, so a packed-FP4
tensor whose byte count is not a multiple of 16 fails outright rather than
falling back.

Underlying disagreement this exposes

Three producers disagree about what a packed value plane is.
MxFp4Deinterleave labels its plane is_nibble_packed: true while
physically emitting one nibble per byte. Source now labels the same flag
on genuinely packed bytes. op.rs:148 validates length_bytes as a value
count and agrees with neither.

The codec was correct for its actual caller and wrong for the packed
convention; this branch fixes it for packed input and regresses it for the
existing caller, which now gets expanded a second time. Picking one
convention across the three producers is the real fix, and it is larger
than this branch.

What still stands

  • Packed FP4 reaching the trial menu at all, and the dtype mapping having a
    single owner rather than a drifted duplicate.
  • The codec is genuinely lossy on packed input: it read low nibbles only
    and returned them with the high half zeroed.
  • Test coverage that would have caught it: a fixture with data in both
    nibbles, and an assertion that the compress and decompress descriptor
    paths agree on length_bytes for every dtype.

Verification

cargo test --workspace: 909 passed, 0 failed, in both debug and release.
cargo clippy --all-targets and cargo fmt --check clean. None of that
exercises the two blockers, which is the point: the suite has no coverage
of MxFp4Deinterleave feeding this codec, nor of a packed plane whose byte
count is not a multiple of 16.

PerGroupCodebook accepts a plane only when it is nibble-packed at nibble
width with a Value role. Every chain starts at Source, which derived
ElementWidth from a dtype table where packed FP4 fell into the Byte
catch-all, and set is_nibble_packed to a hardcoded false. Nothing
downstream writes either field: terminals rewrite only the role. So two of
the codec's three preconditions were unsatisfiable and it could not be
selected by any chain, on any tensor. The chain builder documented as
"enables PerGroupCodebook" did not enable it.

Maps Float4E2M1FNx2 to nibble width, derives is_nibble_packed from the
dtype, and replaces the bytes-per-element helper with one that accumulates
in bits. The old helper computed bits_per_element / 8, which floors a
4-bit nibble to zero bytes per element and would have produced
zero-length descriptors rather than visibly wrong ones.

Element width and the packed flag stay separate fields. Width is how wide
an element is; the flag is that elements share a byte. Codecs reading
packed nibbles need both, and transforms that cannot handle sharing
(BurrowsWheeler, IndexPack) already reject on the flag alone.

Byte and word dtypes keep their previous lengths, covered by a new
regression test alongside the two FP4 cases.
… map

source_descriptor_for carried its own copy of the dtype-code to
element-width match, and that copy is the one the compression path
actually uses. Fixing the transform alone changed nothing observable: the
duplicate still placed packed FP4 in the byte catch-all and still
hardcoded is_nibble_packed to false, so PerGroupCodebook went on accepting
no planes.

Both fields now come from transforms::source, which owns the mapping. The
duplicate's own docstring claimed to follow that mapping while having
drifted from it, which is how the gap survived review.
…byte

The codec models a nibble alphabet and its internals work on one nibble
per byte, but `accepts` requires `is_nibble_packed`, so the planes it is
handed carry two values per byte. Encode read `plane[i] & 0x0F` per byte
and decode emitted one byte per nibble with the high half zeroed, so half
the data was never coded and never reconstructed. Round-tripping a packed
plane returned the low nibbles intact and zeros elsewhere: 89% of bytes
wrong, length preserved, caught by both the plane CRC and the payload hash.

Encode now expands the packed plane before modelling, decode re-packs
before returning, and the group arithmetic counts nibbles rather than
bytes on both sides (`decoded_len` is a byte count; groups are 32 nibbles).

The existing roundtrip tests build one nibble per byte, so their high
halves are all zero and they pass whether or not those halves survive.
That is why this shipped. Adds a fixture whose every byte carries data in
both halves, plus a pack/unpack identity check.

The codec has been unreachable in production since `accepts` could never
be satisfied, so no container written by a release carries a plane encoded
this way.
…real nibbles

Two defects in the FP4 work, both from the same confusion in opposite
directions.

`storage_bytes` derived the byte count from element width, halving it for
packed FP4. One `Float4E2M1FNx2` element *is* one byte carrying two fp4
values, which is what `Dtype::element_size` reports and what a packed
tensor's shape counts. The compress path takes the raw byte count directly
while the decompress path rebuilds it from the chain's Source node, so the
two disagreed and a packed plane decoded at half length, failing its CRC.
Adds a test asserting the two paths agree for FP4, byte and word dtypes,
which is the invariant that was missing.

The shared-state codebook fitter still called `histograms` on planes that
are now genuinely packed: it fitted on low nibbles alone and cut group
boundaries at 32 bytes where the encoder's group is 32 nibbles. Round
trips stayed lossless because `encode` recomputes its own histograms, so
this cost ratio rather than correctness. It now fits on expanded nibbles,
with a test that two planes differing only in their high nibbles must
produce different codebooks.

Also restores the doc comment that a const insertion had reattached to the
wrong item, and drops two stale cross-references.
…ting

Unreachable from `decode`, whose nibble count is always a multiple of
GROUP_SIZE, so this is not a live defect. Guarded anyway because of how it
would fail rather than whether it can: `chunks_exact` drops a trailing
nibble silently, the plane returns one byte short, and the container
reports a CRC mismatch pointing nowhere near nibble handling. Getting from
that symptom to the cause is what the packed-versus-expanded confusion in
this file already cost once.

A debug assertion keeps release behavior unchanged and puts the message at
the site a future caller would actually hit. The should_panic test makes
the contract executable rather than a comment.
@khwstolle
khwstolle marked this pull request as draft August 18, 2026 13:40
`debug_assert!` compiles out under `[profile.release]`, which carries no
`debug-assertions` override, so the guard was absent exactly where a
silent truncation would go unnoticed. Its doc comment claimed to prevent
that. The `#[should_panic]` test also failed under `cargo test --release`,
since nothing panicked.

`pack_nibbles` runs once per plane decode rather than per byte, so an
unconditional assertion costs nothing measurable.

Also corrects a doc path that named a pre-rename package directory.
… throughout

Reverses the packing change earlier in this branch. That change assumed the
codec consumes nibble-packed planes; it consumes expanded ones, and three
pieces of the design say so. `GROUP_SIZE` is 32 and documented as the MXFP4
block size in nibbles; `MxFp4Deinterleave` emits exactly 32 single-nibble
slots per block; and `histograms` documents one nibble per byte. Under the
packed reading a group spans half a block, which has no meaning for a
format whose scale is per block.

So the codec was right and its `accepts` was wrong: it demanded
`is_nibble_packed`, which no correct producer sets for it. The flag now
means one thing everywhere, "two values share a byte", and:

- `accepts` takes expanded planes and declines packed ones, so a plane it
  would misread by dropping every high nibble is refused rather than
  silently halved.
- `accepts` also declines lengths that are not whole groups. Dispatch drops
  a candidate chain when a codec errors and packed FP4 has one chain, so a
  misaligned tensor previously failed outright; declining leaves an
  `encode` error meaning a violated invariant.
- `MxFp4Deinterleave` reports its value plane as not packed, which is what
  it emits.
- `expected_byte_len` stops halving, so `length_bytes` is a storage count
  for every producer instead of a value count for one of them.

Adds the coverage whose absence let the descriptor mismatch persist: an
end-to-end test driving the deinterleave op into the codec and asserting a
bit-exact round trip, plus rejection tests for the packed and misaligned
cases. The existing capability tests asserted the old flag; they now assert
the new one, and the role and width cases were switched to expanded
descriptors so they still isolate what they name rather than passing
because the plane is packed.

No wire-format change, so containers written through the deinterleave op
keep decoding.
@khwstolle
khwstolle marked this pull request as ready for review August 18, 2026 18:11
@khwstolle
khwstolle merged commit 90bcb84 into master Aug 18, 2026
11 of 12 checks passed
@khwstolle
khwstolle deleted the fix/fp4-nibble-descriptor branch August 18, 2026 18:11
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.

1 participant