Skip to content

fix(serve/coli): make GPU-vs-fallback counters and chat status actually visible - #829

Merged
JustVugg merged 5 commits into
JustVugg:devfrom
aaristov:fix/metal-i4-gpu-path
Aug 19, 2026
Merged

fix(serve/coli): make GPU-vs-fallback counters and chat status actually visible#829
JustVugg merged 5 commits into
JustVugg:devfrom
aaristov:fix/metal-i4-gpu-path

Conversation

@aaristov

@aaristov aaristov commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Rewritten 2026-08-16. This PR originally enabled the Metal GPU path for
int4-g64 (fmt=4) models β€” MoE experts + fused attention. dev implemented
that independently and went further
, so the merge of dev resolved every one
of those hunks to dev's side. What is left is the part of the branch dev
never picked up: the observability and launcher fixes below. The original
description is preserved at the bottom for history.

What this PR does now

Three fixes that make the GPU tier's behaviour visible. None of them change
what the engine computes.

1. run_serve can report backend counters at all (c/colibri.c, +7)

profile_print only ran on the oracle and generate exit paths, so the
cumulative METAL: / METAL-ATTN: / MIRROR: GPU-vs-fallback counters were
unreachable in serve mode β€” the mode coli chat / coli web / coli serve
all use. A served session could never show whether the GPU tier engaged or
silently fell back per block.

run_serve now takes a wall base at entry and calls profile_print at exit
under PROF=1. Safe against the byte protocol: stdin is at EOF by that point
and the last END/STAT frame is already out, so the stdout lines cannot
interleave with anything a client is still parsing.

2. coli chat status block was always empty (c/coli)

Two independent bugs, both of which silently rendered nothing:

  • The stderr drain used read(), which returns only at EOF. The engine
    stays alive on stdin, so it never returned, the errlog.write() after it
    never ran, and the bounded wait always timed out on an empty file. Now a
    readline() loop with per-line write+flush, so each status line becomes
    visible as it arrives.
  • The ready-line regex scanned stderr for a stdout line. loaded in Xs | resident dense: Y MB is a printf (colibri.c:9595, same in inkling.c /
    olmoe.c), so it arrives in the pre-READY stdout preamble β€” which
    cmd_chat was discarding into lambda b: None. The match could never fire.
    The preamble is now kept (a few hundred bytes) and the regex runs against it.

Also whitelists [METAL] / [CUDA] in the status lines. The splash tagline
(model_banner_line) names the checkpoint, not the backend, so this engine line
is the only confirmation the GPU tier actually engaged.

3. coli bench splash (c/coli)

banner("bench") β†’ banner("bench", model=a.model), so bench names the model
it is about to evaluate like every other command.

Test comment accuracy (c/tests/test_backend_metal.mm, +7/-3)

The fmt=8 fence test documented moe_submit's gate as fmt != 1 && fmt != 2.
On dev the allowlist is {1,2,4,5,6}; comment corrected. No behaviour change.

Merge notes

The mechanical merge of dev left three defects, fixed here:

  • moe_gemv ended up with a duplicate fmt == 4 branch β€” this branch's
    scalar version placed above dev's vectorized one, referencing gsz where
    dev's parameter is named qgs. It would not have compiled. Removed;
    dev's vectorized branch stands.
  • run_attn kept an unused KGS/ng pair; dev derives kvng from kvb_gs.
  • A coli comment referenced backend_tag, which disappeared with dev's
    banner rewrite.

Not carried over: the original backend_tag() (so the splash would not
claim "CPU" under COLI_METAL=1). dev's model_banner_line rewrote that line
to name the checkpoint and omit the backend entirely, so the false claim now
survives only in dev's no-model fallback string. Re-adding it means
redesigning dev's function β€” out of scope here, worth a follow-up.

Verification

  • make metal-test β€” all green, including dev's fmt4-g64/g128 MoE cases and
    the grouped-kv_b attention cases.
  • make colibri and make METAL=1 colibri β€” both build warning-clean.

Measured on M4 base / 34 GB. CI reports no checks on this fork branch, so the
CUDA/Vulkan paths dev added are not exercised by the above.

Original description (superseded by dev)

Problem

With an i4 snapshot (int4 weights + per-group scales = fmt=4, e.g.
GLM-5.2-i4), the Metal backend executed no decode work at all:

  • moe_submit (backend_metal.mm) gated fmt != 1 && fmt != 2 && fmt != 6 β†’
    every routed-expert block on every layer silently fell back to CPU.
  • Both fused-attention gates in colibri.c required kv_b.fmt==2 exactly β†’
    attention fell back too.

Since the [METAL] mode: … banner prints regardless, users saw a "GPU-enabled"
build running 100% on CPU.

Fix (as originally proposed)

  • moe_gemv fmt=4 branch; moe_submit / coli_metal_moe_block[_begin] take a
    gs4 parameter; allowlist {1,2,4,6}.
  • MB_BUILD captures the experts' group size and poisons it on mismatch.
  • a_deqrow decodes per-row (fmt=2) or per-group (fmt=4) kv_b scales;
    kvb_gs threaded through AttnW.
  • Tests run_moe_g4 and run_attn(kvb_g4=1).

dev landed equivalent work with a wider format matrix (fmt=5/6/8), kv_b grid
chunking, CUDA/Vulkan twins, an mb_gs_compat helper in place of the mgs=-1
poison, a !g_moe_exact refinement on both fused-attention gates, and
c/tests/test_moe_gs_guard.c.
The measured numbers below were taken against
this branch's implementation and are kept only as a record of the original
finding.

before after
METAL: counters (all CPU) blocchi GPU 1248 | fallback CPU 0 | expert su GPU 9973
METAL-ATTN: β€” layer GPU 546 (100% of decode layers fused)
GPU utilization (ioreg) 1–13% (β‰ˆidle) 91–94%
decode p50 11.4 s/forward 5.9 s/forward (with PIPE=1 DIRECT=1)

πŸ€– Generated with Claude Code

aaristov and others added 2 commits August 4, 2026 23:06
- The splash strapline was hardcoded ("GLM-5.2 Β· 744B MoE Β· int4 Β· streaming
  CPU") regardless of the loaded model or backend. It is now derived from the
  model dir's config.json (name + expert count) and the requested backend
  (COLI_METAL/COLI_CUDA/COLI_VULKAN); the old text remains only as fallback
  for model-less commands. The engine's own [METAL]/[CUDA] line stays the
  confirmation that the tier actually engaged.
- The stderr drain called p.stderr.read(), which only returns at EOF; the
  engine stays alive on stdin, so every load-time status line was invisible
  (the bounded 1s wait always expired against an empty file). Now reads
  readline() in a loop with per-line write+flush.
- The "ready in Xs" detector scanned stderr for "loaded in ...", but that
  line goes to stdout and was being discarded by stream_turn. Now captured
  from the preamble and matched there.
- Added [METAL]/[CUDA] to the chat status-line whitelist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sed attention

With an i4 snapshot (int4 weights, per-group scales = fmt=4, e.g. GLM-5.2-i4)
the Metal backend never executed any decode work: moe_submit gated
fmt != 1/2/6 and both fused-attention gates required kv_b.fmt==2 exactly, so
every routed-expert block and every attention layer silently fell back to
CPU. The [METAL] banner printed regardless, making the idle GPU hard to see.

Routed experts:
- moe_gemv gains an fmt=4 branch (per-group scale folded into the MAC,
  mirroring mm_gemv's; gsz at buffer index 10).
- moe_submit / coli_metal_moe_block[_begin] take a gs4 parameter; the fmt
  allowlist now includes 4 (even positive group size required, else CPU).
- colibri.c's MB_BUILD captures the experts' group size and poisons it on
  any mismatch (gs4=-1 -> moe_submit refuses -> whole subset stays on the
  CPU path, never wrong results).

Fused attention:
- a_deqrow (shared by a_qabs/a_ctx) now decodes per-row (fmt=2) or
  per-group (fmt=4) kv_b scales; kvb_gs threaded through AttnW and
  coli_metal_attn_decode / coli_metal_layer_decode, validated in
  encode_attention. Both colibri.c gates relaxed to kv_b.fmt 2 or 4.

Observability:
- run_serve now calls profile_print at exit under PROF=1: the cumulative
  METAL:/METAL-ATTN: counters were structurally unreachable in serve mode
  (only the oracle/generate exit paths printed them), so a served session
  could never show GPU-vs-fallback truth. stdin has hit EOF by then, so the
  frames cannot interleave with protocol a client is parsing.

Tests (make metal-test, all green):
- run_moe_g4: two batched-MoE fmt=4 cases vs a per-group CPU reference.
- run_attn(kvb_g4=1): three fused-attention cases with grouped kv_b.
- test-local FP8 helper renamed ref_fp8_nblk: quant.h:483 gained fp8_nblk
  returning int64_t, colliding with the test's self-contained int version
  (C++ cannot overload on return type), which broke the metal-test build.

Measured on an M4 / 34 GB with GLM-5.2-i4 (391 GB, NGEN=8, RAM_GB=24):
before: METAL blocks all-CPU, decode p50 11.4 s/forward; after: blocchi GPU
1248 | fallback CPU 0, METAL-ATTN layer GPU 546 (100% of decode layers),
p50 5.9 s/forward with PIPE=1 DIRECT=1 (remaining time is expert I/O on
this RAM-constrained host, not compute).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JustVugg

JustVugg commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Welcome, and thank you β€” this is a real fix for a real defect, and you found the same root cause I did when I went through #813 independently:

-  if (!g_dev || (fmt != 1 && fmt != 2 && fmt != 6)) return nil;
+  if (!g_dev || (fmt != 1 && fmt != 2 && fmt != 4 && fmt != 6)) return nil;

I have to tell you something before you invest more in it, and I would rather you hear it from me now than from a merge conflict later.

#587 is doing the same work, and has been for two weeks. @RDouglasSharp opened it against #585 on 21 July. It touches the same four files this does β€” backend_metal.h, backend_metal.mm, colibri.c, tests/test_backend_metal.mm β€” carries 27 comments of review, and on 1 August he wrote "Rebased onto current dev and green… Ready to merge". dev then moved again underneath him.

That is not your fault. Nothing on #813 or #585 pointed at #587 as work-in-progress, and the only reason I know is that I compared the file lists. The gap is mine to close, and I have asked myself the same question about DeepSeek twice this week.

Your PR is not redundant, though, and this is the part worth keeping. #587 fixes the decode path. Yours also fixes why nobody noticed for two weeks:

the METAL: fallback counters are printed by profile_print, which serve mode never called

That is the actual bug behind #813. The banner said "GPU-enabled", the counters that would have contradicted it were never printed, and a user running coli serve on an i4 snapshot had no way to learn their GPU was idle. run_serve calling profile_print under PROF=1 is a small change with more value than it looks, and I do not see it in #587.

There is also MB_BUILD poisoning the group size on a per-expert mismatch so the fallback is clean rather than wrong β€” that instinct, fail to CPU rather than compute garbage, is exactly right for a mixed-format container.

What I am proposing, and it is a proposal β€” you two decide, not me:

@RDouglasSharp has seniority here by two weeks and 27 comments, so #587 lands the decode path first. @aaristov, would you be willing to rebase this onto it and keep the observability and the MB_BUILD guard as a focused follow-up? That is a smaller PR, it reviews in one pass, and it closes #813 properly rather than by side effect.

If instead you two look at the two diffs and conclude the reverse β€” that this one is the better base and #587 should reduce to its delta β€” say so and I will take that. You are both closer to the Metal code than I am, and the last two overlaps on this repo were settled better by the contributors than by me.

One thing I can promise: whoever ends up rebasing will not be doing it because I let it sit. Both of you have a decision within a day.

Your CI had never run, incidentally β€” it was held in action_required, which GitHub applies to a first contribution from a fork and shows nowhere on the PR page. I approved it: 13 checks, no failures.

@JustVugg

Copy link
Copy Markdown
Owner

@aaristov this is wanted β€” it is the decode-path half of what #918 now proposes for prefill, and both should share the same expert-view plumbing β€” but it's marked CONFLICTING against dev. A rebase would put it back in the review queue; ping here if anything in the conflict looks like it came from our side and we'll help untangle it.

@JustVugg
JustVugg changed the base branch from main to dev August 11, 2026 15:07
aaristov and others added 2 commits August 16, 2026 11:54
dev implemented this PR's feature independently (fmt=4 MoE experts as `qgs`,
fused attention `kvb_gs`, the `kv_b.fmt==2||fmt==4` gates), and went further:
fmt=5/6/8, kv_b grid chunking, CUDA/Vulkan twins, an `mb_gs_compat` helper
replacing this branch's `mgs=-1` poison, and a `!g_moe_exact` refinement on
both fused-attention gates. Every conflicting hunk resolves to dev's side.

Three merge defects fixed while resolving:
- moe_gemv had a DUPLICATE `fmt == 4` branch (this branch's scalar version
  shadowing dev's vectorized one) that referenced `gsz` where dev's parameter
  is `qgs` -- it would not have compiled.
- run_attn kept an unused `KGS`/`ng` pair; dev computes `kvng` from kvb_gs.
- a coli comment referenced `backend_tag`, dropped with dev's banner rewrite.

What remains of this branch over dev:
- run_serve calls profile_print under PROF=1, so the cumulative METAL: /
  METAL-ATTN: GPU-vs-fallback counters are reachable in serve mode at all.
- coli chat: stderr drained by readline() rather than read() (which only
  returns at EOF, so the status block always rendered empty), the ready-line
  regex moved to the stdout preamble where the engine actually printf's it,
  and [METAL]/[CUDA] whitelisted.
- coli bench passes model= to banner().

Verified: make metal-test all green (incl. fmt4-g64/g128 MoE and grouped-kv_b
attention), make colibri and make METAL=1 colibri both build warning-clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 'loaded in ... | resident dense:' printf moved 9209 -> 9595 when dev's
commits landed in the merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aaristov aaristov changed the title fix(metal): int4-g64 (fmt=4) models never used the GPU β€” enable MoE experts + fused attention fix(serve/coli): make GPU-vs-fallback counters and chat status actually visible Aug 16, 2026
@aaristov

Copy link
Copy Markdown
Contributor Author

Conflicts resolved against dev

All 37 conflicting hunks across 5 files resolved to dev's side, because
dev had already implemented this branch's feature independently β€” fmt=4 MoE
experts (as qgs), fused attention kvb_gs, and the kv_b.fmt==2||fmt==4
gates β€” and carried it further: fmt=5/6/8, kv_b grid chunking, CUDA/Vulkan
twins, an mb_gs_compat helper in place of this branch's mgs=-1 poison, a
!g_moe_exact refinement on both attention gates, and test_moe_gs_guard.c.
Nothing of this branch's compute-path work is kept; dev's is strictly ahead.

Three defects the mechanical merge left behind, fixed in the merge commit:

  1. moe_gemv ended up with a duplicate fmt == 4 branch β€” this branch's
    scalar version above dev's vectorized one, referencing gsz where dev's
    parameter is qgs. It would not have compiled. Removed.
  2. run_attn kept an unused KGS/ng pair; dev derives kvng from kvb_gs.
  3. A coli comment referenced backend_tag, dropped with dev's banner
    rewrite. (Follow-up: backend_tag is not carried over β€” see the
    description. dev's no-model fallback string still hardcodes "streaming
    CPU".)

What survives is 37 lines that dev never picked up: the serve-mode
profile_print call and the two coli chat status bugs. Title and description
rewritten to match.

Verified locally (M4 base): make metal-test all green, including dev's
fmt4-g64/g128 MoE and grouped-kv_b attention cases; make colibri and
make METAL=1 colibri both warning-clean.

⚠️ CI has not run. Both CI and check queued on each push and concluded
action_required β€” the fork-PR approval gate. A maintainer needs to approve the
workflow runs. The CUDA/Vulkan paths dev added are not covered by my local
macOS verification.

πŸ€– Generated with Claude Code

@JustVugg

Copy link
Copy Markdown
Owner

Deferred to the next release (v1.7.1/v1.8.0) β€” not on merit, on infrastructure. Your PR is 21/22 green; the one job left is CUDA syntax check, whose NVIDIA CDN download has been hanging on GitHub's runners all afternoon. Two reruns changed nothing β€” the stall is on their side, not in your change.

For the record, the rewrite you did in August (resolving all 37 hunks to dev's side once dev implemented the Metal fmt=4 path independently, leaving only the observability fixes) is exactly the right way to handle being overtaken β€” and it's why this is now a clean +37/-9 that will go in as soon as the runners recover.

@JustVugg
JustVugg merged commit 692e61e into JustVugg:dev Aug 19, 2026
90 of 94 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Difetto verificato nel codice metal Backend Metal/Apple needs-rebase Confligge, serve rebase dell'autore

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants