Skip to content

fmt=8: accept UE8M0 block scales alongside the f32 ones - #779

Merged
JustVugg merged 3 commits into
devfrom
feat/fmt8-ue8m0
Aug 2, 2026
Merged

fmt=8: accept UE8M0 block scales alongside the f32 ones#779
JustVugg merged 3 commits into
devfrom
feat/fmt8-ue8m0

Conversation

@JustVugg

@JustVugg JustVugg commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Stacked on #778. Requested by @DrewZt on #165, where this was the one shared-infrastructure blocker for moving the DeepSeek V4 engine onto the common quant path.

What changes

Native fp8 checkpoints write the fmt=8 block scale as one UE8M0 byte per block instead of one f32. The geometry is identical — same shape, same meaning, same multiply — only the encoding of the number differs.

The load path now reads the sidecar with st_read_scale_f32 (from #778), which accepts either encoding and always yields f32.

if(fmt==8) st_read_scale_f32(...);   // new branch
else       st_read_f32_cap(...);     // unchanged

matmul_fp8 is untouched. Expanding at load rather than decoding per block keeps the kernel a single implementation with no branch in the hot loop — the same choice kimi_k3.c already makes for MXFP4 in mx4_scale. The scales are ~1/16384 of the weight bytes, so the memory cost is noise.

Nothing else moves

  • fmt 0/1/2/4/5/6: byte-for-byte unchanged, same call as before.
  • fmt=8 with f32 scales: also unchanged — st_read_scale_f32 dispatches to st_read_f32 for an F32 sidecar, which is the exact call that ran before.

Verified against the real checkpoint

DeepSeek-V4-Flash-0731, all 167 GB of it, downloaded and size-verified:

weight layers.0.attn.wq_a.weight  dtype=4 (F8_E4M3)  4,194,304 bytes
scale  layers.0.attn.wq_a.scale   dtype=5 (F8_E8M0)  256 scales
  -> min 2.441e-04  max 4.883e-04  NaN 0
  -> dequantised weights: |max| 0.0938  |mean| 0.0185

The scales come back as exactly 2^-12 and 2^-11. UE8M0 can only encode powers of two, so a decode wrong by anything at all could not produce them. The weight magnitudes are what attention projections should look like.

All four engines build; make check 288 tests OK (skipped=13).

JustVugg and others added 3 commits August 2, 2026 12:06
…aders

st_dtype_code knew BF16/F16/F32/U8/I8 and called exit(1) on anything else, so a
native fp8 checkpoint could not be opened at all. Pointed a probe at
DeepSeek-V4-Flash-0731 and it died on the first tensor:

    unsupported dtype: I64

That checkpoint holds 2,329 F8_E8M0 tensors (every scale), 25 F8_E4M3 (the dense
weights) and 3 I64 (gate.tid2eid, the frozen token->expert table for the hash-MoE
layers). None of them reachable.

Codes 4, 5 and 6 are new; 0-3 are untouched, so no existing container reads
differently.

THE PART THAT MATTERS MORE THAN THE ADDITION. Both float readers ended in an
 that assumes F16 for any dtype that is not 0 or 2. With only 0-3 in
existence that was correct. The moment codes 4/5/6 exist it becomes a trap: an
F8_E4M3 or I64 tensor would be read as half-precision and produce plausible,
wrong numbers in silence. They now refuse by name and say which reader to use.
That makes st.h safer than it was before this commit, not less.

Element size moves into st_dtype_esz(). It was written out three times as
, which was right for four types and would have claimed
2 bytes for an 8-byte I64.

Adds ue8m0_to_f32 and st_read_scale_f32: a block-scale sidecar written as one
UE8M0 byte per block (2^(v-127), 0xff NaN) instead of one f32. Same geometry,
same meaning, different encoding of the number -- so it expands to f32 once at
load and every kernel downstream stays a single implementation with no branch in
the hot loop. Scales are ~1/16384 of the weight bytes (half a megabyte for
DeepSeek-V4's 8.4 GB dense set), so the memory cost is noise. kimi_k3.c already
does exactly this for MXFP4's ue8m0 scales in mx4_scale.

ue8m0_to_f32 uses ldexpf rather than the bit trick : that
trick is exact for v in [1,254] and at v==0 produces 0x00000000, which is EXACT
ZERO and not 2^-127 -- a whole weight block scaled to nothing instead of to
almost nothing. Caught by testing all 256 values against the spec.

Verified: all 256 UE8M0 values correct; st.h indexes 72,317 tensors of the real
DeepSeek-V4-Flash checkpoint; a real scale sidecar reads back as exactly 2^-12
and 2^-11 (UE8M0 can only encode powers of two, so an off-by-anything decode
could not produce them); all four engines build; make check 288 tests OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All 256 UE8M0 values rather than a sample: the first implementation used the
bit trick (v << 23), exact for v in [1,254] and silently wrong at v == 0, where
the all-zero pattern is exact zero and not 2^-127. Sampling would have missed
the one value that was broken.

Also pins st_dtype_esz for all seven codes. A wrong element size is an
out-of-bounds read rather than a wrong number, and that function replaced three
copies of a ternary that would have claimed 2 bytes for an 8-byte I64.

Includes the two scales actually observed in DeepSeek-V4's attention tensors
(2^-12 and 2^-11) so the test is anchored to a real checkpoint, not only to the
spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Native fp8 checkpoints write the fmt=8 block scale as one UE8M0 byte per block
rather than one f32. The geometry is identical -- same shape, same meaning, same
multiply -- so the load path now reads the sidecar with st_read_scale_f32, which
accepts either encoding and always yields f32.

matmul_fp8 is untouched and stays a single implementation with no branch in the
hot loop, which is the point of expanding at load rather than decoding per block.

Every other format still goes through st_read_f32_cap exactly as before: fmt
0/1/2/4/5/6 are byte-for-byte unchanged. An f32-scaled fmt=8 container behaves
identically too, since st_read_scale_f32 dispatches to st_read_f32 for an F32
sidecar -- the same call that ran before.

Requested by DrewZt on #165, where it was the one shared-infrastructure blocker
for moving the DeepSeek V4 engine onto the common quant path.

Verified against the real DeepSeek-V4-Flash-0731 checkpoint: the attention
sidecars read back as exactly 2^-12 and 2^-11, and the dequantised weights land
at |max| 0.094 / |mean| 0.018. All four engines build; make check 288 tests OK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JustVugg
JustVugg merged commit fd5d72e into dev Aug 2, 2026
13 checks passed
JustVugg pushed a commit that referenced this pull request Aug 4, 2026
…scale aware

fmt=8 tensors (raw e4m3 bytes, one f32 scale per 128x128 block of [O,I],
matmul_fp8's semantics) were CPU-only: qt_cuda_upload refused them because
row_bytes()/weight_at() had no case and every kernel would have applied
per-row scale semantics to a block-scaled container.

Backend:
- quant_matmul gains an fmt=8 branch decoding through c_e4m3, a 256-float
  __constant__ LUT published from quant.h's E4M3_LUT via the new
  coli_cuda_fp8_set_lut (same single-source-of-truth arrangement as the E8
  codebook). The block geometry derives from I alone (FP8_BLOCK is a fixed
  property of the format), so the branch is correct from every call site --
  dense, expert MLP, and the async fallback loop included.
- grouped_hidden_f8_dual / grouped_down_f8 mirror the g4 expert kernels
  (silu fused in the dual epilogue); the sync and async expert-group paths
  dispatch all-fmt=8 groups to them. Mixed groups take the per-expert loop
  (sync) or refuse (async), like E8.
- Uploads of fmt=8 are refused until the LUT is published: a kernel reading
  the zero-initialized table would compute silent zeros. The engine gates on
  the same condition (g_cuda_fp8_ready), so an old DLL without the symbol
  degrades to CPU exactly like fmt=6 does without its codebook.
- The absorb-path entries refuse fmt>4 via absorb_fmt_ok: weight_at +
  absorb_scale know per-row and fmt=4 scales only, so fmt=5/6/8 would be
  mis-decoded there. Callers keep their CPU attention path; a block-scale
  absorb kernel is follow-up work. (proj tensors are exempt -- they run
  through quant_matmul.)

Engine: qt_cuda_upload/qt_cuda_update/matmul_qt_ex route fmt=8 weights from
q8 and gate on g_cuda_fp8_ready, published in cuda_boot next to the E8 grid.
Loader: fp8_set_lut resolved as optional, like e8_set_grid.

tests/test_fp8_cuda.cu: oracle vs a reference that replicates matmul_fp8
(LUT decode cross-checked arithmetically, float-in-block/double-across
accumulation, block scales at the checkpoint-realistic 2^-12 magnitude from
#779's verification), covering partial tail blocks on both axes; API phase
checks the LUT gate refuses before publish, dense matmul, sync expert group
vs oracle, and async issue/take bit-identical to sync.
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