cuda: decode fmt=9 (bf16) in the dense matmul - #1620
DebugSultan wants to merge 2 commits into
Conversation
bf16 is already a first-class dtype on the CPU side of this repo (bf16_to_f32 in c/st.h:122, used by qwen38_core.h, deepseek_v41.c and inkling.c), but the CUDA backend cannot decode it, so a checkpoint whose dense side is bf16 keeps every dense projection on the CPU even when a device is present. Add fmt=9 to coli_cuda_matmul: row_bytes(9, I) == I * 2, a one-shift bf16_at() that mirrors st.h bit for bit (no <cuda_bf16.h> dependency), and a dedicated branch in quant_matmul next to the 4/6/7/8 ones. Consolidate the scale-free rule into fmt_scale_free(). It was spelled by hand at eight sites in two different wordings, and the backwards one in coli_cuda_tensor_update() (!fmt || fmt==6) let fmt=9 fall through to a cudaMemcpy from a NULL host pointer. The new test caught it on its first run; adding a ninth hand-written list is how such lists drift. Formats 0..8 keep their exact truth table at every converted site. Test: c/tests/test_bf16_cuda.cu, wired into make cuda-test, with four independently falsifiable claims (decode over all 65536 bit patterns, matmul parity against a double reference, scale-free upload plus in-place refresh, and a negative control on fmt=1). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kreuzzelg
left a comment
There was a problem hiding this comment.
Reviewed as the author of the dense path this extends (coli_cuda_matmul staging, qt_dense_*, the grouped fmt 1 in #1552). Approve, with two notes.
The consolidation is the right call. fmt && fmt != 6 spelled nine times in two directions is exactly the kind of list that drifts, and coli_cuda_tensor_update ending in !fmt || fmt==6 || cudaMemcpy(...) is the proof: a sweep for the forward form never finds the backward one. fmt_scale_free() keeps the truth table for 0..8 (I checked every converted site against the old expression) and the NULL-memcpy the test caught is a real fix on its own. bf16_at() as a 16-bit shift mirrors st.h's bf16_to_f32 bit for bit, NaN/Inf included, and needs no <cuda_bf16.h> -- good for the toolchains the DLL job builds with.
1. This line meets #1552 (grouped int8 trunk) in the epilogue. #1552 changes the same statement to
(fmt && fmt != 4 && fmt != 6 && fmt != 7 && fmt != 8 && !(fmt == 1 && gs > 0)) ? partial[0] * scales[o] : partial[0]because fmt 1 uploaded with a group size takes the grouped branch (scales applied per group inside the loop) and must not be multiplied by scales[o] again. Whichever of the two lands second rebases; the merged form is
(!fmt_scale_free(fmt) && fmt != 4 && fmt != 7 && fmt != 8 && !(fmt == 1 && gs > 0)) ? ...Flagging it here so the gs > 0 term does not get lost in the conflict -- losing it would double-scale every grouped trunk matrix silently (the selftest would catch it, the CI would not).
2. What I did not do. I have not run test_bf16_cuda.cu here: the two cards are serving Ollama at the moment and the house rule is that Colibrì and Ollama never share the GPUs. The CI has only the syntax check for CUDA, so until someone with a free card runs it, the test's evidence is the author's run. I will run it on the 3070/Quadro pair as soon as the cards are free and post the line.
One small thing, not blocking: the PR body's absorb_scale paragraph is cut off mid-sentence ("would have re"), probably a paste.
|
Ran
So the evidence stands on a second toolchain and card once the literal is a variable. Approval holds; please fold the one-liner in. |
|
One thing before this goes in: the build fix from the review is not on the branch yet. int devs[1] = {0};
if (!coli_cuda_init(devs, 1)) { printf("FAIL cuda init\n"); return 1; }Push that and I will merge; the kernel itself and the scale-free predicate are fine, and the sweep over all 65536 patterns against |
`(int[]){0}` is a compound literal: a GNU extension that ISO C++ forbids,
so nvcc's C++ front end rejects taking its address and the test failed to
build with gcc as the host compiler. Use a named array, as the other CUDA
tests already do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
cuda: decode fmt=9 (bf16) in the dense matmul
Why
bf16is already a first-class dtype on the CPU side of this repo:bf16_to_f32lives in
c/st.h:122and is used byqwen38_core.h,deepseek_v41.c,inkling.candst.h's own tensor readers. The CUDA backend does not know it.A checkpoint whose dense side is bf16 end to end therefore has to keep every
dense projection on the CPU even when a device is present and the expert tier is
already running on it.
This adds the missing decode:
fmt=9incoli_cuda_matmul.What it does
row_bytes(9, I) == I * 2— two raw bytes per weight, no scale array.bf16_at():bf16 -> f32is the identity on the top 16 bits, so the decoderis one shift. No rounding, no lookup table, and no
<cuda_bf16.h>dependency — this compiles on the same toolchains everything else here does.
It mirrors
st.h'sbf16_to_f32bit for bit, NaN and Inf patterns included.quant_matmul, alongside the existing 4/6/7/8 branches.The shared scale-free predicate
fmt=9carries its magnitude in the weights, so likefmt=0(f32) andfmt=6(E8, scales inside the block) it has no scale array to allocate, upload, charge
against the device budget or refresh. Upstream spells that rule out by hand, and
in more than one wording:
fmt && fmt != 6at seven call sites, a longer variantin the matmul epilogue, and
!fmt || fmt==6— the same rule read backwards— in
coli_cuda_tensor_update. Adding a fourth scale-free format to ninehand-written lists spelled two different ways is how such lists drift, so this PR
consolidates them into one predicate:
Every converted site keeps the same truth table for formats 0..8, so no existing
container changes behaviour. Three of the conversions deserve a note:
coli_cuda_tensor_update()is the one that mattered. It ended inreturn !tensor->fmt || tensor->fmt==6 || cudaMemcpy(tensor->scales, scales, ...).That is the scale-free rule written in the opposite direction, so a sweep for
fmt && fmt != 6never surfaces it, andfmt=9fell straight through to amemcpy from a NULL host pointer —
invalid argumenton the first in-placerefresh. The test below caught it, and that is the concrete reason this PR
consolidates the rule instead of adding a ninth hand-written list.
absorb_scale()tested!fmtand would have readwscale[row]for ascale-free format. It is unreachable in practice —
absorb_fmt_ok()gates theabsorb kernels on
coli_cuda_weight_at_supported(), which admits{0,1,2,3,4}only — so this one is hardening, not a fix for a live bug.
fmt != 6explicitly; routing it throughthe predicate is what keeps
fmt=9out ofpartial[0] * scales[o], wherescalesisNULL.What it deliberately does not do
coli_cuda_weight_at_supported()keeps upstream's{0,1,2,3,4}. Callers askthat predicate "can
weight_atdecode it", and bf16 ridescoli_cuda_matmul'sown branch instead — the same arrangement fmt=6, 7 and 8 already have. The
header comment now records that choice so a later reader does not "fix" it.
Test
c/tests/test_bf16_cuda.cu, wired intomake cuda-test. Four independentlyfalsifiable claims:
bf16_at()reproducesst.h'sbf16_to_f32over all 65536 bitpatterns, compared as raw
u32so a NaN cannot hide a mismatch.coli_cuda_matmul(fmt=9)against adoubleCPU reference on ashape with an odd
I(tail) andS > 1.scales == NULL,coli_cuda_tensor_bytes == I*O*2, and an in-placecoli_cuda_tensor_update()with a NULL scale pointer.This claim is not decorative: it failed on the first run, and that failure is
what exposed the
!fmt || fmt==6site above.fmt=1) withscales == NULLmuststill be refused, so claim 3 cannot pass by the guard being gone.
It includes
backend_cuda.cudirectly (kernel-level oracle), the same patterntest_fp8_cuda.cuandtest_cuda_fmt_trap_cuda.cuuse, and runs early in therecipe: it is the cheapest kernel test there and has no host-dependent failure
mode.
🤖 Generated with Claude Code