Skip to content

cuda: grouped-int4 (fmt=4) support in the expert-group kernels — opens the GPU tier to g64 and E8 containers (#334)#451

Merged
JustVugg merged 1 commit into
JustVugg:devfrom
ZacharyZcR:feat/cuda-grouped-g64
Jul 20, 2026
Merged

cuda: grouped-int4 (fmt=4) support in the expert-group kernels — opens the GPU tier to g64 and E8 containers (#334)#451
JustVugg merged 1 commit into
JustVugg:devfrom
ZacharyZcR:feat/cuda-grouped-g64

Conversation

@ZacharyZcR

Copy link
Copy Markdown
Contributor

Makes #334's prevention note real, and unblocks the quantization roadmap's GPU path.

Why

The grouped MoE kernels were per-row-only: GroupDesc had no group-size fields, row_bytes() returned 0 for fmt=4, the scale buffer was hardcoded to O floats — and a fmt=4 group that slipped to the generic path would have been silently decoded as int2. Net effect: the GPU expert tier is closed to every grouped container — the g64 quality line (#225, −63% of the int4 quality loss) and the E8 lattice route (#347, int3-e8-rot measuring better than shipped per-row int4 at 25% fewer bytes) could only run their experts on the CPU. On the bandwidth-walled decode path, bytes are speed — the grouped containers are pointless without this.

What

  • ColiCudaTensor gains gs/scale_count; upload allocates O·ceil(I/gs) scales for fmt=4 and applies the same offset→signed nibble conversion as fmt=2 (identical packing). New ABI entry coli_cuda_tensor_upload_g carries gs without touching the existing symbol — an old Windows DLL missing it returns 0 and the tensor stays CPU-side, no crash.
  • GroupDesc gains per-tensor group sizes; new grouped_hidden_g4_dual / grouped_down_g4 apply the per-group scale inside the accumulation. gs must be even (a packed byte never straddles a group); gs=0 degrades to per-row, so fmt=2 members ride the same launch in mixed groups.
  • coli_cuda_expert_group routes any group containing fmt=4 through the g4 kernels; pure-fmt=2 groups keep today's paths byte-identical. The generic fallback now explicitly rejects fmt=4 instead of decoding garbage.

Verification

  • tests/test_grouped_g4_cuda.cu — kernel-vs-CPU oracle (semantics cloned from matmul_i4_grouped): 50 trials × 3 experts covering gs=64, a non-divisible tail group (200 % 64 = 8), and a per-row member in the same launch — zero mismatches on a 5090.
  • make check 77/77; CPU, CUDA and MinGW cross builds clean.

Next on this line: convert the g64/E8 containers and A/B the VRAM-resident expert tier end-to-end — this PR is the gate that had to open first.

…ugg#334)

The grouped MoE kernels were per-row-only: GroupDesc had no group-size
fields, row_bytes() returned 0 for fmt=4, the scale buffer was hardcoded
to O floats, and a fmt=4 group that reached the generic path would have
been silently decoded as int2. This closed the GPU expert tier to every
grouped container — including the g64 quality line (JustVugg#225) and the E8
lattice route (JustVugg#347) whose whole point is fitting more experts in VRAM.

- ColiCudaTensor gains gs/scale_count; upload allocates O*ceil(I/gs)
  scales for fmt=4 and applies the same offset->signed nibble conversion
  as fmt=2 (identical packing). New ABI entry coli_cuda_tensor_upload_g
  carries gs without touching the existing symbol — an old Windows DLL
  missing it returns 0 and the tensor simply stays CPU-side.
- GroupDesc gains per-tensor group sizes; new grouped_hidden_g4_dual /
  grouped_down_g4 apply the per-group scale inside the accumulation
  (gs is required even, so a packed byte never straddles groups; gs=0
  degrades to per-row, letting fmt=2 members ride the same launch).
- coli_cuda_expert_group routes any group containing fmt=4 through the
  g4 kernels; pure-fmt=2 groups keep the existing paths byte-identical.
  The generic fallback now explicitly rejects fmt=4 instead of decoding
  garbage (JustVugg#334's prevention note, made real).

tests/test_grouped_g4_cuda.cu: kernel-vs-CPU oracle over 50 trials x 3
experts — gs=64, a non-divisible tail group (200 % 64), and a per-row
member in the same launch: zero mismatches on a 5090.

make check 77/77; CPU, CUDA and MinGW builds clean.
@ZacharyZcR

Copy link
Copy Markdown
Contributor Author

Merge-order note, since five of my open PRs touch backend_cuda.cu and all currently read CLEAN against dev — they will conflict with each other as they land, so here is the dependency map to save you the analysis:

Independent, land anytime in any order: #429 (colibri.c only), #453 (tools only), #424 (site/docs only).

Ordered, all in coli_cuda_expert_group:

  1. cuda: decode expert groups take the grouped kernels even under TC_W4A16 — launches −43%/token (#431) #433 — narrows the TC_W4A16 branch gate (9 lines)
  2. cuda: grouped-int4 (fmt=4) support in the expert-group kernels — opens the GPU tier to g64 and E8 containers (#334) #451 (this one) — adds the fmt=4 branch to the same if/else chain, so it wants cuda: decode expert groups take the grouped kernels even under TC_W4A16 — launches −43%/token (#431) #433's gate already in place
  3. cuda: COLI_CUDA_ROUTER=1 — route the decode row on the layer's home device (#431 PR-A) #432 — device router (mostly new functions; small overlap)
  4. cuda: COLI_CUDA_RESID=1 — expert-group results stay on device (#431 PR-C0) #434 — resident accumulation; stacked on cuda: COLI_CUDA_ROUTER=1 — route the decode row on the layer's home device (#431 PR-A) #432's branch, so cuda: COLI_CUDA_ROUTER=1 — route the decode row on the layer's home device (#431 PR-A) #432 must land first or the diff carries it
  5. cuda: COLI_GROUP_ASYNC=1 — overlap the CPU expert rows with the GPU groups at decode (opt-in, +6-8%) #342 — group-dispatch overlap, touches the same scheduling region

#433#451 is the sharp one (same if/else chain); the rest are near-additive.

You do not need to follow this order — merge whatever you have reviewed, and I will rebase the rest the same day, same as I did for #429. Just flagging so a merge doesn't look unexpectedly conflicted.

@monotophic

Copy link
Copy Markdown
Contributor

Authored by Fable 5 in Claude Code, analysis in partnership with @monotophic.

While preparing the Metal twin of this work (#457) we traced #451 against dev (@ e9b3614) for a
CUDA↔Metal parity comparison, and one finding seems worth putting on this thread's radar before the
merge window — especially given the merge-order plan upthread puts #451 in the ordered tier.

Claim, hedged (traced across three files, not run): if #451 merges without #298, there is a
plausible silent-wrong-answer path on the dense/attention-projection CUDA path:

  • qt_load (colibri.c:834) sets t.cuda_eligible=1 unconditionally by fmt under
    CUDA_DENSE=1; only kv_b gets an explicit opt-out (:874). q_a/q_b/kv_a/o/shared-expert
    do not.
  • matmul_qt_ex's CUDA dispatch (colibri.c:419-450) carries no fmt gate (the Metal block at
    the same call site gates on w->fmt==1||w->fmt==2).
  • Today this is accidentally safe: coli_cuda_tensor_upload's row_bytes(4)==0 fails the
    upload, cuda_failed=1 sticks, and the gs-aware CPU fallback (matmul_i4_grouped) serves the
    tensor — slow but correct.
  • After cuda: grouped-int4 (fmt=4) support in the expert-group kernels — opens the GPU tier to g64 and E8 containers (#334) #451 alone: row_bytes(4) is fixed, so the upload at this call site now succeeds
    but through the plain (non-_g) upload wrapper, which sets t->gs=0 and copies only O scale
    floats (not O*ng), and quant_matmul/weight_at then decodes fmt=4 through the untouched
    fmt==3 (int2) fallback branch. Silent wrong answer on GPU.

Bounding the trigger: it fires only if a real checkpoint carries fmt=4 on a dense
(non-expert) tensor AND the user runs CUDA_DENSE=1. Your own commit message hedges the same way
("fmt=4 is primarily an expert format in practice"), and our Metal PR discloses the identical open
question on our side (#457 body, §9) — two independent traces landing on the same named uncertainty
is why we're flagging it rather than assuming baseline-safe-by-omission.

We hit exactly this "two changes must land together or the intermediate state is armed" class on
the Metal branch during review and disarmed it by commit reordering, so it felt worth passing on.
Any one-line guard disarms it here, e.g. a fmt gate at the matmul_qt_ex CUDA dispatch mirroring
the Metal site (smallest), routing this call site through the new _g upload path, or having the
plain upload refuse fmt=4. No opinion on which — and none needed if #298 is expected to land in the
same window, since its combined design covers the dense path.

Great to see the CUDA grouped tier moving — the formats contract lined up cleanly in our parity
check (packing, group math, scale layout), which should make the two platform tiers easy to review
as companions.

@mohamedmastouri2000-boop

Copy link
Copy Markdown
Contributor

@ZacharyZcR — nice work getting grouped-int4 (fmt=4) into the CUDA expert-group kernels here. Testing it on Blackwell (RTX 5080, sm_120) with the public g64 container, I hit one correctness issue you'll want to know about, and it's cleanly isolated.

The expert-group path is correct, but the resident-dense fmt=4 path garbles output when dense weights are pinned to VRAM. Same binary (current dev, this PR merged), same g64 container, one env var apart:

  • COLI_CUDA=1 + experts on GPU, dense on CPU (no CUDA_DENSE): coherent — "CUDA (Compute Unified Device Architecture…"
  • COLI_CUDA=1 CUDA_DENSE=1 (dense resident on GPU): garbage — "odesk odesk odesk…"

CUDA_DENSE=1 is the usual g64-on-GPU config (it's what puts the ~12 GB dense in VRAM), so this hits the main path. My read is that the group size arriving via the global g_upload_gs isn't being applied to the resident dense tensor uploads, so those tensors get per-row scales instead of the gs=64 grouped scales → wrong values. The streaming expert-group kernels are unaffected because they carry gs through the descriptor.

Full write-up + a fix are on #298 (which passes gs as a per-call parameter to coli_cuda_tensor_upload / coli_cuda_matmul and produces coherent output under the identical CUDA_DENSE=1 config): #298 (comment) . Repro on request. Thanks again — this is close, just the resident-dense scale plumbing.

@mohamedmastouri2000-boop

Copy link
Copy Markdown
Contributor

@monotophic — your trace is exactly right, and I can confirm it empirically: I hit precisely the silent-wrong-answer you predicted, running on Blackwell (RTX 5080, sm_120) with the public g64 container.

Your two states map one-to-one to what I saw:

Clean isolation on the same current-dev binary, one env var apart: no CUDA_DENSE (dense on CPU, experts on GPU) → coherent; CUDA_DENSE=1 (dense resident on GPU) → garbage.

And your read that #298 covers the dense path holds up on hardware: my rebased #298 branch, same CUDA_DENSE=1 config, produces coherent output with the full residency (952 tensors / 12.48 GB dense + 109 experts in VRAM, zero fallbacks) — because it routes the dense upload through the per-call gs parameter and copies O*ng grouped scales instead of O.

So: your static trace + my Blackwell repro land on the same thing from opposite directions — #451 alone arms a silent-wrong-answer on the dense path under CUDA_DENSE=1, and #298 disarms it. Details + the rebase are on #298 (#298). Nice catch on the parity comparison — great to have the Metal twin cross-checking this.

@monotophic

Copy link
Copy Markdown
Contributor

Authored by Fable 5 in Claude Code, analysis in partnership with @monotophic.

@mohamedmastouri2000-boop — thank you for running this down on real hardware; a clean one-env-var
isolation with the coherent-vs-garbage pair is exactly the confirmation that trace needed, and your
before/#451 fallback-log observation nails the "accidentally safe" state we described.

@ZacharyZcR — the expert-group path holding up under that test is good news for the tier itself.
Since #298's combined design covers the dense path properly and is rebasing clean, we see two
sound routes: land #298 soon, or an interim one-line fmt gate at matmul_qt_ex's CUDA dispatch
(mirroring the Metal site's w->fmt==1||w->fmt==2) so dev isn't shipping the armed state in the
meantime. Your call on which — happy to see this tier moving either way.

monotophic added a commit to monotophic/colibri that referenced this pull request Jul 20, 2026
The Metal GEMV shader (mm_gemv, backend_metal.mm) handled fmt 0-3 only.
Anything else -- including dev's grouped-int4 format (fmt=4: packed
int4 nibbles, same layout as fmt=2, but one f32 scale per `gs`-element
group along I instead of one scale per row; CPU reference
matmul_i4_grouped in quant.h, active CUDA tier in JustVugg#298/JustVugg#451) -- fell
through the shader's unconditional `else` arm and was decoded AS RAW
F32. This was a silent-wrong-answer defect, not just a missing-
acceleration gap: bind_gemv (used by the fused decode-attention
kernels, coli_metal_attn_decode/coli_metal_layer_decode) accepted a
per-weight fmt from its caller with no validity gate at all, so any
fmt=4 dense/attention tensor reaching that path would "succeed" (ok=1)
while silently computing garbage. coli_metal_matmul/coli_metal_gemm had
explicit fmt<=3 gates and so safely rejected fmt=4 (0/CPU-fallback);
bind_gemv had no such safety net.

COMMIT ORDERING (bisect-safety, see PR_BODY.md sec 1 for the full
argument): this commit lands FIRST, before the qt_from_disk allocator
fix (next commit). At this commit alone, the capability is CORRECT but
INERT: qt_from_disk's fmt=4 scale buffer is still allocated with a bare
falloc() (unregistered, not page-aligned), so resolve() in
backend_metal.mm can never find it, and every real fmt=4 dense/
attention tensor's scale buffer permanently misses bind_gemv/
coli_metal_gemm regardless of what this commit adds to the shader --
safe CPU fallback, not a wrong answer. No commit in this branch is ever
"the shader is fixed but a scale buffer resolves to the wrong/stale
allocation" -- fixed-but-unreachable, then reachable-because-fixed, in
that order, never the reverse. Verified empirically at this exact
commit (this branch, this session): both METAL=0/METAL=1 build clean,
0 warnings, and `make metal-test` passes 27/27 -- metal-test's fmt=4
cases construct their own tensors directly via posix_memalign +
coli_metal_register (never through qt_from_disk), so none of them
depend on the allocator fix landing first; the capability this commit
adds is exercised and proven correct here, just not yet reachable from
a real loaded model.

Shader: unlike fmt 1-3 (single `acc * scale[o]` after the full dot
product), fmt=4's group scale must be folded into the accumulation
per-group, before summing across groups -- this is matmul_i4_grouped's
actual math, not a simplification. New `constant int& gsz [[buffer(9)]]`
parameter carries the group size (ignored, harmlessly, for fmt!=4).
Each of the 32 SIMD lanes owns one packed byte (2 elements) per 64-wide
stride -- memory access stays coalesced and a group never splits a byte
(gs is always even, per the loader's candidate list). Deliberately not
vectorized like fmt=2's uchar4/float4 path -- see PR_BODY.md sec 7 for
why that tradeoff was made on purpose.

Host plumbing: gs threaded through every path that reaches mm_gemv --
coli_metal_matmul, coli_metal_gemm, and bind_gemv (-> AttnW ->
coli_metal_attn_decode / coli_metal_layer_decode) -- plus the colibri.c
call sites (matmul_qt_ex's Metal-GEMM gate now includes fmt==4;
attention_rows/layer_forward_rows pass .gs alongside each .fmt already
being passed, a no-op for every model not using fmt=4). fmt_bytes and
the new fmt_scale_bytes fix coli_metal_matmul's scale-buffer wrap size,
which was previously hardcoded to O floats (wrong for fmt=4's
O*ceil(I/gs)). kv_b (MLA absorption core) and the batched routed-expert
MoE path (moe_gemv/coli_metal_moe_block) are deliberately untouched --
both already gate fmt=4 to a safe CPU fallback today, and extending
them is a structurally separate kernel family; see PR_BODY.md sec 7 for
the full scope statement.

Tests (tests/test_backend_metal.mm): new cpu_ref_grouped (double-
precision oracle mirroring matmul_i4_grouped, same construction
tests/test_i4_grouped.c already established) and run_grouped harness,
using a magnitude-relative tolerance (justified in PR_BODY.md sec 5)
instead of the existing ymax-relative convention, since a grouped dot
product's cancellation can otherwise misreport as a kernel defect.
Covers the spec's shape matrix (I mult/non-mult of 64, I<=64
degenerate, S=1/S>1, outlier-heavy rows) via coli_metal_matmul, one
case via coli_metal_gemm (the large-batch path), and two via a new
run_attn_grouped harness that exercises the real fused-attention
bind_gemv path end-to-end (not just the standalone kernel entry point).
(These two attn cases have known blind spots as first committed here --
closed two commits later, see that commit's message and PR_BODY.md
sec 10; kept as originally written here for bisect fidelity.)

make metal-test: 15/15 (stock e9b3614) -> 27/27 (12 new fmt=4 cases;
original 15 unchanged -- the negative control). make glm METAL=0/
METAL=1: clean, 0 warnings, both before and after. make test-c /
test-python: unaffected, all pass. Full gates, evidence classes,
and DEVIATIONS/UNCERTAINTIES in PR_BODY.md (untracked, worktree root).

(cherry picked from commit f0fa5a9)
(cherry picked from commit 82984cdeef67ede71d48b32f2ddd4734f4622eba)
JustVugg pushed a commit that referenced this pull request Jul 21, 2026
…#298)

#298 fixed the dense-resident path's callers after they fed grouped-scale
tensors to kernels that read scales per row (CUDA_DENSE=1 produced garbage).
The entry points themselves still accept them:

  coli_cuda_matmul   uploads via coli_cuda_tensor_upload (no gs), so
                     scale_count collapses to O and the [O, ceil(I/gs)]
                     buffer is silently truncated, then quant_matmul reads
                     scales[o] per row.
  coli_cuda_expert_mlp  same kernels, same assumption.

Return 0 for fmt=4 at both, so a grouped tensor takes the expert-group path
(#451, which carries per-group scales) or stays on the CPU — a fallback, never
a wrong answer. The caller-side guard in colibri.c already excludes fmt=5 the
same way; this makes the ABI safe regardless of who calls it next.

make check 105/105, zero warnings.
monotophic added a commit to monotophic/colibri that referenced this pull request Jul 23, 2026
The Metal GEMV shader (mm_gemv, backend_metal.mm) handled fmt 0-3 only.
Anything else -- including dev's grouped-int4 format (fmt=4: packed
int4 nibbles, same layout as fmt=2, but one f32 scale per `gs`-element
group along I instead of one scale per row; CPU reference
matmul_i4_grouped in quant.h, active CUDA tier in JustVugg#298/JustVugg#451) -- fell
through the shader's unconditional `else` arm and was decoded AS RAW
F32. This was a silent-wrong-answer defect, not just a missing-
acceleration gap: bind_gemv (used by the fused decode-attention
kernels, coli_metal_attn_decode/coli_metal_layer_decode) accepted a
per-weight fmt from its caller with no validity gate at all, so any
fmt=4 dense/attention tensor reaching that path would "succeed" (ok=1)
while silently computing garbage. coli_metal_matmul/coli_metal_gemm had
explicit fmt<=3 gates and so safely rejected fmt=4 (0/CPU-fallback);
bind_gemv had no such safety net.

COMMIT ORDERING (bisect-safety, see PR_BODY.md sec 1 for the full
argument): this commit lands FIRST, before the qt_from_disk allocator
fix (next commit). At this commit alone, the capability is CORRECT but
INERT: qt_from_disk's fmt=4 scale buffer is still allocated with a bare
falloc() (unregistered, not page-aligned), so resolve() in
backend_metal.mm can never find it, and every real fmt=4 dense/
attention tensor's scale buffer permanently misses bind_gemv/
coli_metal_gemm regardless of what this commit adds to the shader --
safe CPU fallback, not a wrong answer. No commit in this branch is ever
"the shader is fixed but a scale buffer resolves to the wrong/stale
allocation" -- fixed-but-unreachable, then reachable-because-fixed, in
that order, never the reverse. Verified empirically at this exact
commit (this branch, this session): both METAL=0/METAL=1 build clean,
0 warnings, and `make metal-test` passes 27/27 -- metal-test's fmt=4
cases construct their own tensors directly via posix_memalign +
coli_metal_register (never through qt_from_disk), so none of them
depend on the allocator fix landing first; the capability this commit
adds is exercised and proven correct here, just not yet reachable from
a real loaded model.

Shader: unlike fmt 1-3 (single `acc * scale[o]` after the full dot
product), fmt=4's group scale must be folded into the accumulation
per-group, before summing across groups -- this is matmul_i4_grouped's
actual math, not a simplification. New `constant int& gsz [[buffer(9)]]`
parameter carries the group size (ignored, harmlessly, for fmt!=4).
Each of the 32 SIMD lanes owns one packed byte (2 elements) per 64-wide
stride -- memory access stays coalesced and a group never splits a byte
(gs is always even, per the loader's candidate list). Deliberately not
vectorized like fmt=2's uchar4/float4 path -- see PR_BODY.md sec 7 for
why that tradeoff was made on purpose.

Host plumbing: gs threaded through every path that reaches mm_gemv --
coli_metal_matmul, coli_metal_gemm, and bind_gemv (-> AttnW ->
coli_metal_attn_decode / coli_metal_layer_decode) -- plus the colibri.c
call sites (matmul_qt_ex's Metal-GEMM gate now includes fmt==4;
attention_rows/layer_forward_rows pass .gs alongside each .fmt already
being passed, a no-op for every model not using fmt=4). fmt_bytes and
the new fmt_scale_bytes fix coli_metal_matmul's scale-buffer wrap size,
which was previously hardcoded to O floats (wrong for fmt=4's
O*ceil(I/gs)). kv_b (MLA absorption core) and the batched routed-expert
MoE path (moe_gemv/coli_metal_moe_block) are deliberately untouched --
both already gate fmt=4 to a safe CPU fallback today, and extending
them is a structurally separate kernel family; see PR_BODY.md sec 7 for
the full scope statement.

Tests (tests/test_backend_metal.mm): new cpu_ref_grouped (double-
precision oracle mirroring matmul_i4_grouped, same construction
tests/test_i4_grouped.c already established) and run_grouped harness,
using a magnitude-relative tolerance (justified in PR_BODY.md sec 5)
instead of the existing ymax-relative convention, since a grouped dot
product's cancellation can otherwise misreport as a kernel defect.
Covers the spec's shape matrix (I mult/non-mult of 64, I<=64
degenerate, S=1/S>1, outlier-heavy rows) via coli_metal_matmul, one
case via coli_metal_gemm (the large-batch path), and two via a new
run_attn_grouped harness that exercises the real fused-attention
bind_gemv path end-to-end (not just the standalone kernel entry point).
(These two attn cases have known blind spots as first committed here --
closed two commits later, see that commit's message and PR_BODY.md
sec 10; kept as originally written here for bisect fidelity.)

make metal-test: 15/15 (stock e9b3614) -> 27/27 (12 new fmt=4 cases;
original 15 unchanged -- the negative control). make glm METAL=0/
METAL=1: clean, 0 warnings, both before and after. make test-c /
test-python: unaffected, all pass. Full gates, evidence classes,
and DEVIATIONS/UNCERTAINTIES in PR_BODY.md (untracked, worktree root).

(cherry picked from commit f0fa5a9)
(cherry picked from commit 82984cdeef67ede71d48b32f2ddd4734f4622eba)
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.

4 participants