Skip to content

MiniMax-M3 support — GQA + MSA block-sparse attention, converter (follow-up to #418) - #601

Open
steve-m wants to merge 22 commits into
JustVugg:devfrom
steve-m:minimax-m3
Open

MiniMax-M3 support — GQA + MSA block-sparse attention, converter (follow-up to #418)#601
steve-m wants to merge 22 commits into
JustVugg:devfrom
steve-m:minimax-m3

Conversation

@steve-m

@steve-m steve-m commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What this is

Early, testable support for MiniMax-M3 (428B total / 23B active MoE, the text backbone of the VL checkpoint) — GQA attention, the MSA "Lightning Indexer" block-sparse attention, the o200k-family tokenizer, converter support, and Vulkan offloads. Opening as WIP so people who want to play with M3 can start early; API/knobs may still shift.

Based on #418 (Vulkan backend) — this is a follow-up. #418's commits are re-included here (rebased onto current dev) until that PR merges; once it does, this branch rebases down to the M3 commits only. Of the 53 commits, roughly the first 38 are the #418 stack plus its follow-ups (int3-g64 fmt=5 in the shaders, VRAM pressure-proofing, N-way mirror replicas + striped reads, multi-worker pilot prefetch, optional second-device tier) — please review only the last 15 commits as new material here (bc6adf3 and up).

What works

  • Engine (ARCH_M3, auto-detected from model_type): GQA 64Q/4KV attention with per-head Gemma QK-norm, partial split-half NEOX RoPE (rotary 64, θ=5e6), swigluoai activation, sigmoid+bias router with renormalized top-4 ×2.0, first-3-dense pattern, 64K context.
  • MSA (Lightning Indexer) block-sparse attention: the 4-head scoring branch, causal max-pool into 128-token blocks, top-16 + forced local block, per-KV-group selection. Validated bit-exact against a numpy oracle on a tiny checkpoint (24/24 teacher-forced prefill + 20/20 incremental decode) and with a real-model needle-in-haystack retrieval at 2.5K tokens. Decode attention is capped at ~2048 attended tokens regardless of context. Containers converted without the indexer weights fall back to full causal attention automatically (exact ≤2048 tokens); add them later with convert --indexer.
  • Tokenizer: tok.h now accepts classic space-separated BPE merges (o200k family) alongside the array form; MiniMax chat template (]~b]role … [e~[) in the serve loop and openai_server.py.
  • KV persistence includes the MSA index-key cache, so resumed conversations stay correct past 2048 tokens.
  • Converter (tools/convert_fp8_to_int4.py --arch m3, auto-detected): maps the VL checkpoint onto the container scheme (drops vision), int4-g64 or int3-g64 (--xbits 3) routed experts, indexer kept at int8 (--idx-bits), per-shard resume.
  • Vulkan: expert tier runs M3 (swigluoai as a shader push-constant), generic dense/attention-projection offload (COLI_VK_DENSE=1), fused shared expert, lm_head offload, opt-in GQA attention core (COLI_VK_ATTN=1, throughput-neutral on the reference box — see commit messages).
  • AVX2 kernel for int3-g64 (matmul_i3 was scalar on x86): 3.8 → 24 GB/s effective on a Zen2 12-core, which is what makes int3 containers viable for decode.

Measured (reference box: Ryzen 9 3900X, 64 GB DDR4, RX 9070 16 GB, 2× NVMe striped mirror)

config decode tok/s
CPU only, single drive, int4 0.67
+ VK expert tier 0.91
+ 2-drive mirror ~1.5
+ 512-expert tier ~2.1
+ VK dense/lm_head/paired submits 2.8 (topp 0.7) / 3.5 (0.5)
int3-g64 container + AVX2 kernel 3.4 (topp 0.7) / 3.8 (0.5)

Prefill 516 tokens: 152.7 → 96.8 s over the same span. --topp here is the expert-routing top-p (~228 → ~155 expert loads/token at 0.7).

How to test

Without the weights (validates the engine math end-to-end, minutes):

python3 tools/make_m3tiny.py /tmp/m3tiny
python3 tools/convert_fp8_to_int4.py --indir /tmp/m3tiny --outdir /tmp/m3tiny_i8 --ebits 8 --io-bits 8
python3 tools/oracle_m3.py /tmp/m3tiny_i8          # writes ref_m3.json
SNAP=/tmp/m3tiny_i8 REF=ref_m3.json TF=1 TF_DECODE=1 IDOT=0 ./colibri 8   # expect 24/24 + 20/20

With the real model (BF16 download is ~796 GB; the int4 container is 225 GB, int3 is 177 GB):

# convert (≈35 min from local NVMe; add --xbits 3 for int3-g64: 25% smaller, needs the AVX2 kernel in this branch)
python3 tools/convert_fp8_to_int4.py --indir <MiniMax-M3-BF16> --outdir <container> --group-size 64
make colibri VK=1
COLI_MODEL=<container> DIRECT=1 PIPE=1 COLI_NO_OMP_TUNE=1 \
COLI_VULKAN=1 COLI_VK_SHADERS=$PWD/shaders/qmatmul.spv \
COLI_VK_EXPERTS=512 COLI_VK_RESERVE_GB=7 COLI_VK_DENSE=1 \
./coli chat --ctx 65536 --topp 0.7

A second copy of the container on another drive via COLI_MODEL_MIRROR=<dir> roughly doubles streaming bandwidth. CPU-only works (drop the COLI_V* vars) but is ~5× slower.

Note the model's custom license (minimax-community): convert your own weights; don't redistribute containers.

Known gaps / WIP

  • Cosmetics: banner still prints the GLM identity; PROF verdict mentions DSA while MSA is active
  • No COLI_MSA=0 kill switch for A/B against full attention
  • Tool calls not yet rendered in the MiniMax chat template (server returns 400)
  • Routing-topp quality evaluation is spot-checks only so far (0.5 looks fine, 0.7 is the conservative default)
  • Rebase onto dev once Vulkan backend: expert tier + dense + MLA attention on any Vulkan 1.2 GPU (successor to #84) #418 merges (drops the first ~38 commits)

@JustVugg

Copy link
Copy Markdown
Owner

Really promising direction (GQA + MSA block-sparse + o200k), and thanks for pushing it. Current status so it's clear where it stands:

  • It's marked WIP and is currently CONFLICTING with dev — needs a rebase before it can be evaluated.
  • It touches the shared core (colibri.c, openai_server.py, sample.h, quant.h, kv_persist.h) and brings a Vulkan backend + shaders in the same PR (+5,050 lines). As one unit that's too much surface to merge safely against the GLM/Inkling path we're actively tuning.

To move it forward:

  1. Rebase onto current dev and resolve the conflicts.
  2. Split it the way we're asking Add Qwen3.6-35B-A3B engine: Vulkan MoE backend, resident expert pinning, serve GPU support + fixes #602 to split: the MiniMax engine/attention/tokenizer as one reviewable piece, and the Vulkan backend as a separate PR — and please coordinate the Vulkan work with Vulkan backend: expert tier + dense + MLA attention on any Vulkan 1.2 GPU (successor to #84) #418 (steve-m) and Add Qwen3.6-35B-A3B engine: Vulkan MoE backend, resident expert pinning, serve GPU support + fixes #602 so the project gets one Vulkan backend, not three overlapping ones.
  3. Keep the core-file changes minimal and called out explicitly (what in colibri.c/sample.h/quant.h MiniMax actually needs), so we can see the blast radius.

Flag it ready-for-review (un-WIP) once it's rebased and split, and we'll go through it. No rush — better landed cleanly than fast.

@JustVugg

JustVugg commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Rebase request — and an apology for how many of these you have had.

dev moved a lot in the last day: 19 PRs landed, including the Vulkan backend (#418), the Kimi K3 GPU tier (#705), Metal grouped-int4 (#457), the shared routing telemetry (#716/#719), and several CUDA and launcher fixes. This PR now conflicts.

Before asking, three things changed on our side so that this is the last one of these you should need for a while.

1. The main source of these conflicts is closed. c/Makefile had a single hand-written TEST_BINS line listing every test gate. Every PR that added a test appended to that same line, so any two such PRs conflicted by construction, even when they touched entirely unrelated code — c/Makefile appeared in 26 of 40 open PRs. #386 hit it twice while being rebased, and said so, which is what sent us looking.

Gates are now derived from the build rules (#733). Adding a test means adding your .c and its own rule, which land in different places in the file. There is no shared list left to conflict on.

2. We resolved what we could ourselves instead of asking. Eight PRs were unblocked by a maintainer pushing the merge to the contributor's branch rather than requesting a rebase — including three where both sides carried a real change and had to be merged rather than picked. No commits were rewritten. Yours is here because its conflict is in engine code (c/colibri.c or a backend), where guessing your intent would be worse than asking.

3. Merge order is oldest-clean-first from now on. A PR that is green and unconflicted merges ahead of anything opened after it. The reason some of you rebased many times is that newer, smaller PRs kept jumping the queue and resetting you — a starvation loop we built, not bad luck on your side.

What we need: one rebase onto current dev. If your only conflict was the TEST_BINS line, just drop your entry — your test is picked up by its own rule now.

If you would rather not, say so and we will close it with thanks and the branch stays yours to reopen. No pressure either way, and no hard feelings — several of these have been open a while through no fault of the author.

@rofl0r

rofl0r commented Aug 5, 2026

Copy link
Copy Markdown

@JustVugg you might wanna cherry-pick 3ab405d regardless of whether/when this PR gets merged - the i3 quant still lacks AVX2 (AVX512 was merged recently, but as you found out during DS4 merge: most consumer chips lack it).

@corzogac

Copy link
Copy Markdown

Heads up — I implemented three of the WIP gaps listed here as a PR into your minimax-m3 branch: steve-m#1.

  • COLI_MSA=0 A/B kill-switch (full causal attention even with indexer weights present)
  • banner + PROF verdict now say MSA/M3 instead of GLM/DSA
  • tool-call rendering + parsing for the MiniMax-M3 template (the dispatcher no longer 400s on tools)

Verified: MSA oracle still 24/24 + 20/20, kill-switch flips to full attention, tool-call render→parse round-trips (nested objects/arrays + schema type coercion), and make test is 167/167 OK. Base is your current head (3ab405d), so it's a clean single-commit diff. Happy to rebase onto dev alongside yours once #418's merge settles, or adjust the template byte-exactness to match what you've been testing against.

@steve-m steve-m changed the title WIP: MiniMax-M3 support — GQA + MSA block-sparse attention, o200k tokenizer, converter (follow-up to #418) MiniMax-M3 support — GQA + MSA block-sparse attention, o200k tokenizer, converter (follow-up to #418) Aug 15, 2026
@steve-m

steve-m commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (v1.6.1) and un-WIPed. The split you asked for resolved itself in the meantime: the Vulkan backend landed via #418/#705, so this PR no longer carries one — it's now 16 commits, +1,335/−76 over 12 files (was +5,050): the MiniMax-M3 engine/converter/oracle as one piece, plus small M3 enablement of the in-tree VK backend (GQA attention core shader, swigluoai in the fused gate+up shader, a vk_gemm resident-dense mark).

Dropped during the rebase as already upstream: everything that merged with #418/#705, the mirror/pilot commits, the space-separated-BPE-merges tokenizer fix (dev's parser is stricter and covers o200k), and the AVX2 int3-g64 kernel — which you already took as #926 (thanks @rofl0r for the ping; it landed with authorship preserved).

Core-file blast radius, called out explicitly:

  • colibri.c — ARCH_M3 + Cfg fields, attention_gqa() (per-head Gemma QK-norm, split-half partial RoPE), MSA Lightning Indexer with auto-fallback when index weights are absent (+ COLI_MSA=0 A/B switch), arch-aware act_glu(), TF_LOGITS/TF_DECODE oracle gates, arch-aware banner/PROF text
  • kv_persist.h — MSA index-key cache rows
  • coli / openai_server.py — minimax chat template detection + tool calling (see below)
  • Makefile — one warning flag + one shader in VK_SPV
  • sample.h, quant.h, tok.h: no longer touched

Also folds in @corzogac's contribution (steve-m#1, authorship preserved): the COLI_MSA=0 kill-switch, M3-correct banner/PROF, and tool calling for the MiniMax-M3 template. On top of it I made the rendering byte-match chat_template.jinja (verified against a jinja2 reference render: tools declaration, tool_call block, and tool-response run byte-identical; parsing the template-rendered block round-trips nested objects/arrays/bools with schema coercion) and added the minimax entry to _tool_stream_markers so streamed replies hold at the tool-call opener.

Validated on v1.6.1 (Ryzen 3900X + RX 9070/RADV): full C test suite; the in-tree tiny-model oracle chain (tools/make_m3tiny.pyconvert --arch m3tools/oracle_m3.py) — CPU exact kernels 24/24 prefill + 20/20 incremental decode at ≤3e-06 max logit delta, same T/T with the VK attention/dense/expert-tier paths active (expert tier CPU-vs-GPU delta 3.0e-06), and COLI_MSA=0 demonstrably flips to full attention.

steve-m pushed a commit to steve-m/colibri that referenced this pull request Aug 15, 2026
… rendering

Three of the WIP gaps called out in JustVugg#601:

1. COLI_MSA=0 A/B kill-switch (colibri.c). Force full causal attention even
   when the MSA indexer weights are present, by zeroing c->msa + idx_type the
   same two lines the indexer-absent auto-detect fallback uses — so it rides
   the already-tested full-attention path for measuring MSA's long-context win.

2. Banner/PROF cosmetics (colibri.c). The startup banner and the PROF
   attention-bound verdict now name MSA/M3 instead of hardcoding GLM/DSA
   (banner peeks config.json model_type before model_init; PROF branches on
   m->c.arch).

3. Tool calls for the MiniMax-M3 template (openai_server.py). Render the
   ]<]minimax[>[<invoke name=…> XML dialect — the developer <tools> declaration,
   assistant tool_calls, and ]~b]tool <response>… tool results — and parse it
   back, so render_chat no longer returns 400 for `tools` and parse_tool_calls
   dispatches to an M3 parser. Mirrors chat_template.jinja (to_xml recursion,
   nested objects/arrays, schema type coercion via _tool_param_types).

Verified: MSA oracle still 24/24 + 20/20; COLI_MSA=0 flips to full attention;
tool-call render/parse round-trips (nested objects/arrays + type coercion);
full `make test` suite 167/167 OK (13 skipped).
@JustVugg

Copy link
Copy Markdown
Owner

I've reviewed this end-to-end and, importantly, ran it rather than reading it. Starting with what works, because it's substantial:

Check Result
make colibri, make colibri VK=1, glslc attention_gqa.comp clean
make test-c all pass
test_launcher_dispatch / test_openai_server / test_doctor / test_resource_plan 209 passed
GLM regression (SNAP=./glm_tiny TF=1 ./colibri 64 16 16) 32/32 token-exact
M3 end-to-end (make_m3tiny.pyconvert --arch autooracle_m3.py) prefill 24/24, decode 20/20

The port is correct and GLM does not regress. Your make_m3tiny.py + oracle_m3.py pair is exactly the right way to prove a new architecture, and it proves it. The shrink from +5,050 to +1,335 after Vulkan landed also made this genuinely reviewable.

The gaps are all in the plumbing around it. Four of them block, and one affects people who have nothing to do with MiniMax.


B1 — coli serve and coli web never learn M3's EOS (coli chat is fine)

The fallback if (eos < 0) eos = tok_id_of(&T, "[e~[") was added to run_serve only (colibri.c:8007-8008). But openai_server.py:1965 always launches the engine with SERVE=1, SERVE_BATCH=1, and colibri.c:10101 routes SERVE_BATCH=1 to run_serve_mux, where EOS is still tok_id_of(&T,"<|endoftext|>") (colibri.c:7824) → -1 on an M3 tokenizer.

Downstream that is not benign: in sample.h:145-175 the #401 batched-serve stop filter is guarded by tok_eos >= 0 (sample.h:164), so with -1 it is skipped and the engine arms every tokenizer special token as a hard stop. The first M3 tool-call marker then ends the turn — which kills parse_tool_calls_m3 / _m3_tools_block, the headline feature of this PR. run_text (colibri.c:7183, i.e. coli run) lacks the fallback too.

Honest caveat: I don't have a real M3 tokenizer.json, so the specific token that trips it is inferred; the code path is certain.

Fix: hoist EOS resolution into one helper used by run_serve, run_serve_mux and run_text.

B2 — the planner reports 0 bytes of KV for M3

resource_plan.py:573-579 reads kv_lora_rank, qk_rope_head_dim, qk_nope_head_dim, v_head_dim, n_routed_experts. The container config produced by write_m3_config (convert_fp8_to_int4.py:259-269) carries none of them — it writes head_dim, num_key_value_heads, num_local_experts, … I ran build_plan() on a converted M3 container: kv_bytes = 0, kv_buffer = 0, configured_experts = 0, so the cap = min(cap, configured_experts) clamp at :617 never fires either.

That reaches coli plan, coli doctor and --auto-tier — every number a user sizes their machine from — and under-reserves roughly a GB at 60L × 4096 ctx. This is the same class as the OOM-budget bugs in this repo's history, and it's the identical shape to what #1006 is fixing for V4.

B3 — this breaks other people's in-progress conversions

convert_fp8_to_int4.py now unconditionally sets bits_map["idx"] = a.idx_bits (:593) and records "arch": a.arch (:758, :1123). check_or_record_params compares the params dict exactly (prev != params) and aborts with "use a fresh --outdir".

So anyone part-way through converting a 400 GB GLM or DeepSeek checkpoint with today's dev converter cannot resume once this merges. They aren't using MiniMax at all. Also layer_idx() semantics changed for every arch (:174-181): it now matches layers.<N> anywhere, not just model.layers.N.

Fix: make the comparison forward-compatible — compare on the intersection, or only write the new keys when non-default.

B4 — nothing in CI would ever notice a regression

No test file added; grep -n "m3\|minimax" c/Makefile returns nothing. oracle_m3.py and make_m3tiny.py work (I ran them green) but they're not in $(TEST_BINS), not matched by test-python's test_*.py glob, and not in ci.yml.

That matters more here than usual, because — unlike a self-contained engine — this PR edits colibri.c's hot paths: rmsnorm becomes x[i]*r*(w[i]+wo) (:1233-1236, wo=0.f for GLM) and ten siluf(g)*u sites become act_glu() (:1250-1259). Bit-identical for GLM today (your 32/32 and mine agree), but it makes GLM's expert inner loop depend on mutable file-statics. Without a gate, the next refactor of act_glu/rmsnorm/MSA breaks M3 silently.

Minimum: a make m3-tiny-check target wired into ci.yml, plus a _BANNER_MODELS entry (c/coli:159-165) — that table is what c/tests/test_launcher_dispatch.py and release.yml's packaged-launcher check iterate, so right now M3 has zero dispatch coverage.


Follow-ups (not blocking)

  • cmd_serve model_id (c/coli:1330-1334) falls through to glm-5.2-colibri, so an M3 server advertises itself as GLM in /v1/models.
  • -Wno-missing-field-initializers was added to all five platform CFLAGS lines (Makefile:65,90,105,139,150). On gcc 15 here, removing it produces zero warnings — which compiler/platform needed it? It silently relaxes the whole repo.
  • New -Walloc-size-larger-than warning at colibri.c:6207 under VK=1 (baseline 2 → 3).
  • g_gemma_norm / g_act_swigluoai / g_swiglu_alpha / g_swiglu_limit are file-static mutable numerics — safe at one-model-per-process, but they belong on Cfg/Model.
  • CTX stays 4096 for a model whose selling point is long context, and MSA is exact only to sparse_topk_blocks × block_size — worth documenting.
  • Minor: the title says "o200k tokenizer", but o200k is already on dev (c/tok_unicode_o200k.h) and this PR touches neither it nor tok.h.

Verdict: merge after B1–B4. The architecture work is done and proven; what's missing is the last mile — one EOS helper, the planner's config keys, converter resume-compatibility, and a CI gate. Given that this rides inside colibri.c rather than a separate engine, that gate isn't bureaucracy: it's the only thing that will tell us when GLM and M3 drift apart.

Nice work on the oracle harness — it's the reason this review could be measurements instead of opinions.

steve-m pushed a commit to steve-m/colibri that referenced this pull request Aug 16, 2026
… rendering

Three of the WIP gaps called out in JustVugg#601:

1. COLI_MSA=0 A/B kill-switch (colibri.c). Force full causal attention even
   when the MSA indexer weights are present, by zeroing c->msa + idx_type the
   same two lines the indexer-absent auto-detect fallback uses — so it rides
   the already-tested full-attention path for measuring MSA's long-context win.

2. Banner/PROF cosmetics (colibri.c). The startup banner and the PROF
   attention-bound verdict now name MSA/M3 instead of hardcoding GLM/DSA
   (banner peeks config.json model_type before model_init; PROF branches on
   m->c.arch).

3. Tool calls for the MiniMax-M3 template (openai_server.py). Render the
   ]<]minimax[>[<invoke name=…> XML dialect — the developer <tools> declaration,
   assistant tool_calls, and ]~b]tool <response>… tool results — and parse it
   back, so render_chat no longer returns 400 for `tools` and parse_tool_calls
   dispatches to an M3 parser. Mirrors chat_template.jinja (to_xml recursion,
   nested objects/arrays, schema type coercion via _tool_param_types).

Verified: MSA oracle still 24/24 + 20/20; COLI_MSA=0 flips to full attention;
tool-call render/parse round-trips (nested objects/arrays + type coercion);
full `make test` suite 167/167 OK (13 skipped).
@steve-m steve-m changed the title MiniMax-M3 support — GQA + MSA block-sparse attention, o200k tokenizer, converter (follow-up to #418) MiniMax-M3 support — GQA + MSA block-sparse attention, converter (follow-up to #418) Aug 16, 2026
@steve-m

steve-m commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for a review that came with measurements attached — B1's stop-filter chain and B2's build_plan() numbers made every one of these a straight fix rather than a discussion. All four blockers are addressed, plus the follow-ups. The branch is also re-rebased onto current dev (0578bba): #1035's expert_ffn() extraction collided with our activation dispatch, and the resolution turned out to be the right refactor anyway — act_glu() now lives inside the shared helper, so the swigluoai dispatch is one line in one place instead of ten call sites. Now 21 commits, +1,652/−99 over 19 files.

B1 — one EOS resolver for every entry point (7cf0c00)

tok_eos_resolve() (<|endoftext|>, then [e~[) is now called by run_text, run_serve and run_serve_mux alike, with a comment explaining exactly the failure you traced: with eos=-1 the #401 stop filter is skipped and every special token becomes a hard stop, killing tool calls. Your caveat about not having a real tokenizer was right in spirit — we do have one, and the batched-serve path did resolve -1 before this.

B2 — the planner learns GQA + MSA (c5e30cd)

build_plan() now branches on kv_lora_rank: absent means GQA, and the formulas mirror the engine's load_cfg aliasing — KV rows are 2 × num_key_value_heads × head_dim, the attention scratch is (2·heads + 2·kv_heads) × head_dim, and MSA adds one sparse_index_dim index-key row per token. configured_experts falls back to num_local_experts. On the real config that's kv_bytes = 1.151 GB + kv_buffer = 0.285 GB at 60L × 4096 — your "roughly a GB". Two new tests in test_resource_plan.py pin the arithmetic and the expert-cap clamp (which now fires). MLA numbers are unchanged (pure refactor of that branch).

B3 — pre-M3 outdirs stay resumable (757a317)

Both new keys are now recorded only when non-default: the idx int8 default moved to the consumer in convert_shard, and "arch" is written only when ≠ glm. A default GLM/DeepSeek run builds a byte-identical params dict to the pre-M3 converter — verified by feeding the fixed code a manifest written by today's dev (resumes cleanly), while a real flag change or --arch m3 on a GLM outdir still aborts, so the #355 guard keeps its teeth. One note for your peace of mind: the "idx" classification can only ever fire on M3's self_attn.index_* names — GLM's DSA indexer is named indexer and is either skipped or handled by --indexer mode — so no other arch's output bytes change either. layer_idx() is re-anchored: model.layers.N plus the one vetted raw form, language_model.model.layers.N (MiniMax-VL), nothing else.

B4 — CI gate + dispatch coverage (7c2dd62)

  • make m3-tiny-generate / make m3-tiny-check: tiny checkpoint → --arch m3 conversion → numpy oracle → the engine's TF gate. Since the engine's REF mode always exits 0, tests/test_m3_tiny.py (import-safe for unittest discover, executes under __main__ like the V4 driver) enforces the token-exact counts. A new ci.yml job runs it on Linux with pinned deps (tools/requirements-m3-tiny.txt — your V4 torch/safetensors pins + numpy). The job comment states the reason you gave: this is the only thing that notices when a GLM-side refactor of the shared hot paths breaks M3.
  • _BANNER_MODELS gets ("minimax", "MiniMax-M3", "426B") (computed from the config; the title's "o200k" claim is gone too, see below). model_arch() returns minimax, routed to the GLM binary on purpose — colibri.c self-dispatches on config.json. test_launcher_dispatch now has a _SHARED_ENGINE declaration for exactly this pair, so an undeclared collision still fails the way coli launcher can't run OLMoE: missing model_arch() branch + CHAT=1 never set #879 did.
  • Auditing every table that consumes the new arch value found real gaps beyond the banner: cmd_build would have raised KeyError, cmd_chat would have spun the sister-engine server path with a wrong model_id instead of the byte-protocol REPL, and cap_for_arch would have sent M3 the legacy 8 instead of the glm 0-sentinel its own binary resolves. All taught. coli serve/coli web/standalone server now advertise minimax-m3-colibri (your follow-up). Multi-KV-slot serve is refused for minimax — not validated in this PR, and I'd rather say so than find out.

Follow-ups

  • -Wno-missing-field-initializers is gone from all five CFLAGS lines. Root cause found: our VK GQA commit widened struct PC with act/alpha/limit push constants and left 16 positional initializers short. They now name every field (the gate_up dispatches overwrite the three right after, so semantics are identical — the fields were implicitly zero) and gcc 15 builds warning-free without the suppression.
  • The -Walloc-size-larger-than site: that's kv_alloc's index-cache calloc — dev's own line; our wider || c->msa guard changed inlining enough to surface it on your gcc (I couldn't reproduce on 15.3). An (unsigned) cast now gives VRP a provable bound either way.
  • docs/minimax-m3.md covers the shared-binary design, the CTX=4096 default, and the MSA exactness bound (exact ≤ sparse_topk_blocks × block_size = 2048 tokens, the model's own approximation beyond).
  • Title fixed — o200k was indeed already yours on dev.

Validation (Ryzen 3900X + RX 9070, this branch tip)

Check Result
make colibri / make colibri VK=1 (no suppression flag) clean, 0 warnings
make m3-tiny-check (the new gate, end-to-end incl. new converter) OK — prefill 24/24, decode 20/20
TF_LOGITS vs oracle, IDOT=0 max|d| = 2.62e-06, 0 argmax flips
VK expert tier (int4 fixture, COLI_PREFILL_CHUNK=2 so the S≤4 tier actually serves) VK-BLOCK nvk 2.00 / ncpu 0.00, CPU-vs-GPU logits bit-identical
make test-c all pass
make test-python 530 tests, OK (32 skipped)

One methodological note from that table: a plain TF prefill run never exercises the VK expert tier (it declines S>4 batches), so the earlier tier numbers and these were both taken with small chunks forced — worth knowing for anyone re-verifying.

@JustVugg

Copy link
Copy Markdown
Owner

Status, and a heads-up that will change the shape of your next rebase — mostly in your favour.

Where this stood: your 2026-08-16 round closed all four blockers with measurements attached, re-rebased onto 0578bba, 20/22 green. From our side it was ready. Then dev took 80+ merges in four days and it went conflicting. That is our pace, not your work — the same thing happened to three other PRs this week.

What changed underneath you, specifically, because it makes some of your diff obsolete rather than conflicted:

  1. Model families are registry-owned now (refactor(core): make model families registry-owned #1063, refactor(core): finish registry-owned family dispatch #1068, planner: add OLMoE + Kimi K3 + Inkling + DeepSeek V4 geometry adapters (closes #1066) #1103). c/coli, c/openai_server.py, c/doctor.py and c/resource_plan.py all read one descriptor table in c/family_registry.py instead of each carrying per-family branches. A new family is now a FamilyDescriptor entry plus a planner_geometry adapter — so most of what your PR adds to those four files should delete rather than merge. Your resource_plan.py geometry becomes a _minimax_geometry(config, context, model_dir) function referenced from the descriptor; see _kimi_geometry / _dsv4_geometry (landed today in planner: add OLMoE + Kimi K3 + Inkling + DeepSeek V4 geometry adapters (closes #1066) #1103) for the exact shape.
  2. Serve framing is a shared codec (refactor(serve): extract shared framing through OLMoE #1087/refactor(serve): migrate Kimi K3 framing to shared codec #1090/refactor(serve): migrate DeepSeek V4 framing to shared codec #1096/refactor(serve): migrate Inkling audio framing to shared codec #1116) — every engine now parses SUBMIT/STOP/CANCEL through c/serve_codec.h behind a byte-exact wire freeze. If your branch touches serve framing, that is the contract to adopt.
  3. c/colibri.c changed a lot: the expert matmul path was rebuilt (plane-nibble int4 layout, a union register tile, a fused expert pass — perf(colibri): K1 — plane-nibble int4 layout + unsigned-VNNI dot (bit-identical) #1079/perf(colibri): K2 — 1×4 union tile in the planar IDOT matmul (bit-identical) #1088/perf(colibri): K3-lite — parallel silu + the down-side half of the #1071 hoist #1093/perf(colibri): K1b — grouped planar IDOT for gs64 containers, opt-in (IDOT_GS=1) #1094) and activation quantization was hoisted to layer level (perf: hoist expert activation quantization to layer level (bit-identical) #1071). All bit-identical, but the lines moved. You already found in the last round that expert_ffn()'s extraction collided with your activation dispatch and that the resolution was the right refactor — expect more of that flavour, in the same file.

On v1.7.0: this is not in it, and should not be. A 1,652-line seventh-model-family PR going in on release evening is how a release gets a bad name — and the release was already cut when your last round landed. It is first in the queue after the tag, not last.

What we can do to make this not be your fourth rebase: say the word and we will carry the mechanical part ourselves — a branch with your commits and authorship intact, conflicts resolved, each decision stated for you to review rather than perform. We did exactly this for #1024#1082 and #790#1113 this week. You would review the registry migration (where your judgement is actually needed) instead of re-fighting colibri.c.

Either way, the MSA block-sparse work is wanted. It has just had the bad luck of landing in the busiest week this repo has had.

@OPS-NeoRetro

Copy link
Copy Markdown

@steve-m, please rebase

steve-m pushed a commit to steve-m/colibri that referenced this pull request Aug 27, 2026
… rendering

Three of the WIP gaps called out in JustVugg#601:

1. COLI_MSA=0 A/B kill-switch (colibri.c). Force full causal attention even
   when the MSA indexer weights are present, by zeroing c->msa + idx_type the
   same two lines the indexer-absent auto-detect fallback uses — so it rides
   the already-tested full-attention path for measuring MSA's long-context win.

2. Banner/PROF cosmetics (colibri.c). The startup banner and the PROF
   attention-bound verdict now name MSA/M3 instead of hardcoding GLM/DSA
   (banner peeks config.json model_type before model_init; PROF branches on
   m->c.arch).

3. Tool calls for the MiniMax-M3 template (openai_server.py). Render the
   ]<]minimax[>[<invoke name=…> XML dialect — the developer <tools> declaration,
   assistant tool_calls, and ]~b]tool <response>… tool results — and parse it
   back, so render_chat no longer returns 400 for `tools` and parse_tool_calls
   dispatches to an M3 parser. Mirrors chat_template.jinja (to_xml recursion,
   nested objects/arrays, schema type coercion via _tool_param_types).

Verified: MSA oracle still 24/24 + 20/20; COLI_MSA=0 flips to full attention;
tool-call render/parse round-trips (nested objects/arrays + type coercion);
full `make test` suite 167/167 OK (13 skipped).
@steve-m

steve-m commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the status — and for the offer, which I'm declining for a reason that is entirely to your credit: the mechanical part turned out to be small, and the part that needed judgement was the registry migration, which is mine either way.

A trial merge against dev produced 26 conflict hunks, 5 of them in colibri.c — three pure adjacency, one nesting our lm_head VK mark inside your new load_boundaries guard, one real decision (below). backend_vulkan.c/h, the shaders and the converter had zero upstream churn, so ~355 lines applied untouched. Carrying that for me would have cost you more than it saved me. Rebased onto def8419.

The registry migration (#1063/#1068/#1103)

You were right that most of the diff should delete. resource_plan.py and test_launcher_dispatch.py are now out of the diff entirely, and coli went from a pile of arch tuples to one descriptor plus four gates. openai_server.py is purely additive apart from two dispatch lines.

Finding MINIMAX_M3_FIXTURE and test_minimax_fixture_can_share_colibri_without_becoming_glm already in test_family_registry.py was the single most useful thing in this round — the descriptor is the shape you specified there. It also means your fixture is now a duplicate of a shipped family, so I applied the same treatment you gave QWEN36_FIXTURE when qwen36 landed: the test asserts against the production descriptor instead of the copy, and the double is deleted. Your expected context_state_bytes == 13_312 is unchanged and now pins the real _minimax_geometry.

_minimax_geometry mirrors the engine rather than the config's plain reading, in two places worth flagging:

  • The MTP row. kv_alloc keeps n_layers + 1 Lc/Rc rows but allocates Ic over n_layers only, so the KV term gets the extra row and the index term does not.
  • Sparsity. Your fixture counted truthy entries in sparse_attention_freq; the engine derives idx_type[i] = (i >= first_dense) from moe_layer_freq, because M3's sparse-attention layers are exactly its MoE layers. They agree on the real config; I mirrored the engine so they cannot silently disagree on a config where only one key is present.

The one decision I'd like you to look at. Several launcher gates were written arch == "glm", which is a claim about the binary, not about the family — and M3 is that binary. They now ask the registry the question they actually mean:

def colibri_core(family):
    return family.engine_group == family_by_id("glm").engine_group

engine_group was previously asserted in exactly one place: your own shared-binary test. This makes it load-bearing. Four sites move, and three of them were real bugs waiting for the first shared-binary family:

site what M3 would have got
env_for_engine the sister-engine OMP_NUM_THREADS treatment, on a binary that sizes its own team
operator_cap CAP= silently ignored
cmd_chat a gateway server spawned instead of the byte-protocol REPL
engine_for "this image contains only the GLM engine" for a model that is the GLM engine

Two gates deliberately keep the narrow arch == "glm" test, and I'd rather say so than have you find them: coli tune's replay protocol (M3 tunes through the persistent-rotation path; handing it GLM's fixed replay means touching six more branches in autotune.py, unvalidated here) and need_worker_model (cluster expert workers are not validated for M3; refusing states that honestly).

Your segment-conformance gate caught the other consequence: release_policy: "all_registered_families" means registering a family obligates a manifest row. minimax_m3 now has one — GQA KV plus the MSA indexer, which reuses the DSA indexer's own cache slots in the engine, so DSA_INDEXER is the honest kind rather than a new one. The real segment adapter is a separate subsystem and stays at six; the manifest records that M3 needs one.

Serve codec (your point 2)

Our diff does not touch serve framing — the only serve-side change is the B1 EOS resolver. Worth noting while I was in there: colibri.c is the one engine still parsing SUBMIT/STOP/CANCEL with its own reader rather than serve_codec.h (it is not on that header's dependency list). Adopting the codec there is a GLM-side migration and I have deliberately not smuggled it into this PR.

colibri.c merge decisions

  • act_glu vs K3's parallel silu. perf: hoist expert activation quantization to layer level (bit-identical) #1071's follow-up parallelised the silu*up loop inside expert_ffn, exactly where our activation dispatch sits. The pragma moves into act_glu, where it covers both activations and every caller instead of one site. Elementwise, so no shared accumulation order and the bits are unchanged; omp_in_parallel() keeps it inert at the call sites already inside a team (default nesting would make that a team of one anyway — explicit, not new).
  • qt_planarize vs our vk_gemm marks land on the same three dense tensors, and they cannot collide: planar_on() is hard-off on GPU builds because the backends read q4 in pair layout. At most one mark is live in any binary. That is recorded at the site, since it is the first question a reader will have.
  • Banner. Your Benchmark: GLM-5.2 int4 on i9-14900K + RTX 3090, native Windows (0.71 tok/s median) #1183 "compute experts@" wording is kept verbatim; the arch peek wraps it.
  • kv_persist.h. Our || c->msa is unioned into your KV8/KV_TQ format tags. Ic is f32 under all three KV representations, so the + index_hd*4 record term is correct for each.

Validation

Ryzen 3900X + RX 9070, this branch tip.

Check Result
make colibri / make colibri VK=1, no suppression flag clean, 0 warnings each
make m3-tiny-check (the new gate, end to end incl. the converter) OK — prefill 24/24, decode 20/20
TF_LOGITS vs oracle, IDOT=0 max abs diff 2.62e-06, 0 argmax flips
Same gate under the VK binary OK — 24/24, 20/20
VK expert tier, CPU vs GPU (int4 fixture) tier served all 20 decode steps (VK-BLOCK: 20 blocks, avg nvk 2.00 / ncpu 0.00); per-position decode output identical to the CPU arm
make test-python 695 tests, OK (35 skipped)
make test-c ALL PASS, 0 failures

A correction to my last round's methodological note. I wrote that the tier needs
COLI_PREFILL_CHUNK<=4 to serve during the gate. That was wrong, and if you tried it
you would have measured nothing: forward_all() calls layers_forward() directly, so
chunking in step() never reaches the teacher-forcing path — with COLI_PREFILL_CHUNK=2
and the decode arm switched off, VK-BLOCK prints zero blocks. What actually exercises
the tier is TF_DECODE=1: decode runs at S=1, inside the tier's S<=4 serving window.
The counters I reported last time were real, but they came from the decode arm, not the
chunk setting. The prefill arm of the gate is CPU-only by construction.

Worth stating plainly: an exact-zero CPU-vs-GPU diff on the prefill logits is not
evidence of agreement here — it is what "the tier never ran" looks like. The claim above
rests on the VK-BLOCK counters plus the per-position decode comparison.


One request, given what this round taught us both: if the shape is acceptable, a merge before dev moves another 80 commits would save a fifth rebase. If you would rather I split anything out — the colibri_core change is the obvious candidate for a separate GLM-side PR — say the word and I will.

@JustVugg

Copy link
Copy Markdown
Owner

This is the best rebase report I have read on this repo, and the work under it holds up. I reviewed it properly rather than trusting the summary, and I want to be specific about what I checked, because the thing that made this reviewable at all is that you told me where to look.

What I verified, and why it was the right thing to check

M3 riding the GLM binary means every shared path is a risk to our flagship model, so I traced each one you touched:

  • tok_eos_resolve() — GLM resolves <|endoftext|> first, so the fallback is unreachable for it. Unchanged. And your reason for needing the fallback is not hypothetical: sample.h's stop filter is gated on tok_eos>=0, so an unresolved EOS would arm every special token as a hard stop and the first tool-call marker would end the turn.
  • Chat template — the else branch is byte-identical to the line it replaces, same tk derivation.
  • act_glu()g_act_swigluoai is set only under if(arch==ARCH_M3), so GLM takes siluf(g)*u exactly as before.
  • rmsnormw[i] + wo with wo == 0.0f is bit-identical for every normal float. The only corner is -0.0f, which is unreachable in a weight tensor and invisible downstream.
  • The OMP pragma moving into act_glu — elementwise, no shared accumulation order, and !omp_in_parallel() makes explicit what default nesting was already doing. Your reasoning there is correct.

The registry migration is the part I would have expected to go wrong and did not. I checked the claim that engine_group was load-bearing nowhere: it is consumed by exactly one assertion in test_family_registry.py and by no production code. So colibri_core() does not just fix four call sites, it gives a decorative field a job — and a field that only a test asserts is a field that drifts. The four sites were asking about the binary while spelling it as a question about the family, and three of them were bugs waiting for the first shared-binary family. I would have written the same helper.

Two more things worth naming, because both are choices rather than accidents:

  • Deleting your own fixture once MINIMAX_M3_FIXTURE shipped, applying the treatment we gave QWEN36_FIXTURE, instead of leaving a duplicate that would quietly diverge.
  • _minimax_geometry mirroring the engine rather than the config's plain reading — the MTP row asymmetry in kv_alloc, and sparsity from moe_layer_freq because that is what idx_type actually derives from. They agree on the real config; mirroring the engine means they cannot silently disagree on one where only one key is present. That is the discipline this repo runs on and it is rarely applied this carefully.

And declining the rebase offer for the reason you gave was the right call.

One thing to fix before this merges

The header comment on attention_gqa says the opposite of what the function does. It reads:

MSA block selection is NOT implemented: full causal attention — EXACT for windows up to sparse_topk_blocks*block = 2048 tokens ... an approximation beyond.

But the body computes the indexer, picks the top-k blocks, and I followed sel all the way through: it gates both the score loop and the value accumulation, so only the selected blocks are attended. Selection is implemented and used. docs/minimax-m3.md describes it correctly; the comment is left over from an earlier revision.

It is not a correctness problem, and I would still be asking for it: in this tree comments carry as much weight as code, and the next person to read that function - to port it to a GPU backend, say - will trust it. Worth a minute.

One thing neither of us could have seen alone

g_gemma_norm and g_act_swigluoai are process globals set at model_init. That was unimpeachable until this week: one process, one model. #1227 and #1245 have since landed the Segment and Edge runtimes, which allow several engines to be open in one process - and a GLM segment beside an M3 segment would fight over those two variables.

It is not a live bug: no shipping binary registers those adapters, and your PR predates both. But when M3 gets its Segment adapter, those globals need to hang off the model rather than the process. A comment at the declaration saying so costs nothing now and saves someone a genuinely nasty afternoon later.

Smaller notes

  • colibri_core() calls family_by_id("glm") on every invocation and would raise if that id were ever renamed. A module constant would be sturdier. Not blocking.
  • Please rebase onto current dev: the Windows UCRT64 job was failing PRs by timing out at 20 minutes on a make check that now takes 19-21, which ci(windows): raise the check timeout the suite has outgrown #1252 just raised to 35. Two of your pending checks are that, not you.

Validation

24/24 teacher-forced prefill and 20/20 decode token-exact against an independent numpy oracle, TF_LOGITS within 2.62e-06 with zero argmax flips, the VK tier serving all 20 decode steps with per-position output identical to the CPU arm, zero warnings on both builds, and the full suite green. That is the standard, and it is met.

Thank you for carrying this for five weeks and for coming back to it with the diff smaller than it started. Fix the comment, add the globals note, rebase, and I will merge it.

Maps the minimax_m3_vl VL checkpoint onto the GLM container scheme so the
engine's MoE loader stays single-naming: strips the language_model. prefix,
block_sparse_moe -> mlp (router gate.weight + e_score_correction_bias line
up with the existing loader), Mixtral w1/w3/w2 -> gate/up/down_proj, drops
the vision tower + the MSA index branch (a later pass, like DSA), keeps
q/k/v_proj under the attn bits class, q_norm/k_norm as f32. Writes a
flattened text_config as the container config.json and carries
chat_template.jinja. Validated on a synthetic tiny checkpoint: full name
flow + bit-exact int4-g64 vs quant_int4_grouped.
steve-m and others added 21 commits August 27, 2026 14:03
…igluoai, Gemma norms

model_type minimax_* in config.json switches the engine to the M3 family:
- load_cfg: M3 key set (num_local_experts, head_dim, num_key_value_heads,
  rotary_dim, dense/shared intermediate, swiglu params, moe_layer_freq ->
  first_dense); the GQA KV rows ride the MLA cache aliases (Lc=K, Rc=V,
  kv_lora/qk_rope = n_kv_heads*head_dim) so kv_alloc/bind/persist/mux work
  unchanged; arch-split config validation.
- loader: self_attn.{q,k,v,o}_proj + per-head q_norm/k_norm for ARCH_M3;
  MoE/router/shared names already line up via the converter mapping.
- attention_gqa(): per-head Gemma QK-norm BEFORE partial split-half (NEOX)
  RoPE on the first rotary_dim dims, standard per-token KV rows, grouped
  scores/softmax/values (repeat_kv h -> h/(H/NK)), o_proj; honors the ragged
  kvs/positions contract; full causal attention (MSA block selection exact
  <=2048 ctx, deferred beyond).
- act_glu(): all 11 silu(g)*u sites now dispatch silu vs swigluoai
  (clamps, alpha, and the (up+1) factor); rmsnorm gains the Gemma (1+w)
  variant behind g_gemma_norm. Both default off -> GLM math untouched.
  K3's parallel combine moves from expert_ffn's body into act_glu, where it
  covers both activations and every caller; elementwise, so bit-identical.
- registry: a FamilyDescriptor entry, not per-file branches. minimax_m3 (and
  the raw minimax_m3_vl wrapper) resolve to engine_artifact "colibri" with
  internal_arch "minimax_m3" -- the shared binary self-dispatches on
  config.json, and the descriptor keeps the two families distinct where it
  matters (template, planner, limits, model id). _minimax_geometry() mirrors
  kv_alloc's GQA rows + the MTP row and attention_gqa()'s scratch set; the
  registry's own minimax double is superseded by the shipped descriptor, so
  its test now pins the production geometry. Registering a family also
  obligates a segment-conformance row (release_policy
  "all_registered_families"), so the manifest, the C fixture matrix and its
  roster gain minimax_m3: GQA KV + the MSA indexer, which reuses the DSA
  indexer's own cache slots in the engine. The REAL segment adapter is a
  separate subsystem and stays at six; the manifest records that M3 needs one.
- VK: expert tier + fused shared expert gated off for M3 (shaders hardcode
  silu); the per-matmul dense chain stays available.
- chat: MiniMax template (]~!b[ / ]~b]role / [e~[) in the serve dialog loop,
  coli run, and openai_server (text subset; tools 400 for now); [e~[ EOS
  fallback.
- tools: make_m3tiny.py (tiny random M3 checkpoint) + oracle_m3.py (numpy
  reference on the DEQUANTIZED container -> the engine's REF/TF gate).
TF_LOGITS=1 prints every teacher-forcing logit row (oracle bisection);
TF_DECODE=1 re-runs the same REF continuation through the incremental
S=1 decode path (fresh kv_alloc, prefill np then per-token steps) and
compares argmax against tf_pred — validates KV append across calls,
which the batch check alone cannot see. M3 tiny results: prefill 24/24
(max logit diff 3.2e-6 vs the numpy oracle with IDOT=0 exact kernels;
the idot default's activation quant explains the earlier 23/24),
decode 20/20.
… M3 expert tier

The fused gate+up shader hardcoded silu(gate)*up, so the VK expert tier was gated
off for MiniMax-M3 (swigluoai) and its experts ran on the CPU. Add a push-const-
selected activation: act=1 computes (up+1)*gate*sigmoid(alpha*gate) with gate/up
clamped to +/-limit (alpha 1.702, limit 7.0), matching act_glu and the numpy oracle;
act=0 keeps GLM's silu bit-identical. The activation is model-global, set once via
coli_vk_set_activation from vk_registry_fill (g_act_swigluoai -> the mode), read into
every gate_up dispatch's push constants (both the G and dev2 G2 paths).

Un-gates vk_registry_fill for M3. Validated on the real 225 GB int4-g64 container,
RX 9070: expert tier fills (384 resident, 10.9 GB, vk 40% of lookups), output stays
correct ('Paris' greedy + coherent prose), decode 0.64 -> 0.91 tok/s (+42%) with the
tier active. Attention (GQA) and the shared expert still run on the CPU (P4).
New attention_gqa.comp: one workgroup per (query row, head) computes
scores/softmax/weighted-V for GQA (query head h reads KV group h/(H/NK)),
reading the persistent on-device K/V mirror — K in the L buffer, V in the R
buffer, both NK*hd wide (the mirror already stores two arbitrary-width rows;
the absorb shader's R<=64 cap is its own, not the mirror's). coli_vk_gqa_attn
mirrors the absorb dispatch (5 bindings, one submit+fence+readback per layer).
Wired into attention_gqa behind COLI_VK_ATTN, single-sequence decode only,
CPU fallback on any failure.

Validated on the real 225 GB container, RX 9070: output correct ('Paris'
greedy + coherent prose) with the core active. HONEST PERF NOTE: it is
throughput-NEUTRAL on this box and stays opt-in/default-off, because the core
is only ~0.7s of an ~8.6s decode-attention cost — the projections dominate
(q/k/v proj+norm+rope ~4.8s, o-proj ~2.9s) and are still on the CPU. The per-
layer submit/readback slightly exceeds the tiny core it replaces. Offloading
the projections (fused, GLM-qprep style, to amortize submits) is the actual
lever and the next step. -Wno-missing-field-initializers quiets the shared
struct PC's zero-filled tail.
…evice)

coli_vk_gqa_attn_project runs the GQA core and the o-projection in one command
buffer: the core writes ctx into the att_ctx device scratch (never read back), a
compute barrier, then the o-proj matmul reads it and only [S,hidden] returns to
the host — the absorb_project pattern, for GQA. attention_gqa tries it first
(fills out directly, skips the CPU o-proj), then the core-only path, then CPU.

Validated correct ('Paris' greedy). Profile confirms the offload: decode
output-projection drops to 0.000s on the CPU, and the core+o-proj VK dispatch
(~2.3s/64tok) replaces CPU core+o-proj (~3.9s) — a ~1.6s attention saving.

HONEST e2e NOTE: still throughput-neutral on this box (fused 1.68/1.71 vs CPU
1.70/1.73). M3 decode here is expert-matmul + disk bound (expert-matmul ~16-17s,
disk-wait ~6s, attention ~9s of a ~34s decode; the Zen2 CPU is AVX2-maxed with
no VNNI, and the 16 GB VRAM caps the expert tier at 512). The saving is real but
masked by expert/disk variance. Kept behind COLI_VK_ATTN (default off for M3);
it pays off on a compute-bound or faster-disk box and sets up full projection
fusion.
M3's native attention is block-sparse beyond 2048 tokens: a small 4-head indexer
scores every key, max-pools into 128-token blocks, and the main GQA attention only
reads the top-16 blocks (+ the local block) per KV group. Our port ran full causal
attention (exact <=2048 ctx, divergent + O(ctx) beyond). This adds the real MSA.

Converter: keep + quantize the 228 indexer tensors (index_{q,k}_proj int8,
index_{q,k}_norm f32) instead of dropping them; --indexer mode gains M3 support for a
supplemental add-on pass (no full re-convert). The sparse config already rides the
flattened container config.

Engine: reuses the DSA index-key cache (Ic) + index_hd/index_nh/idx_type slots (M3's
sparse layers == MoE layers). load_cfg reads sparse_attention_config (index dim 128,
4 heads, block 128, top-16, local 1); guards the GLM DSA index read from clobbering
M3's fields. attention_gqa: the indexer (Gemma-norm + same partial-ROT rope as the
main attention, idx_k mirrored to Ic) runs per sparse layer, block-scores in f64, and
picks top-k blocks (local forced in; greedy ties -> lowest index). The main core then
attends only the selected blocks. VK attention is skipped on sparse layers.

Validated on a tiny indexer-bearing checkpoint (block_size 4 so 24 tokens span 6
blocks): engine vs numpy oracle bit-exact — PREFILL 24/24 + incremental DECODE 20/20
(IDOT=0). The 2 near-tie mismatches during bring-up were a config bug (JSON-bool
use_sparse_attention + the unguarded GLM index read zeroing index_hd), now fixed.
…ights absent

A container converted before the indexer was kept still announces sparse_attention_
config, so the loader would try (and fail) to load index_q_proj on every sparse layer.
Probe the first sparse layer's indexer weight; if absent, disable MSA (full causal
attention, exact <=2048 ctx) and point the user at the --indexer pass. Same
auto-detect discipline as the DSA indexer / MTP.
Three fixes so a container converted before the indexer was kept can get it added
without a full 34-min re-convert:
 - drop the '--arch m3 --indexer not implemented' guard (only --mtp is refused now);
 - the shard pre-filter matched GLM's 'indexer' name only — also match M3's
   'self_attn.index_' so the right shards are read;
 - layer_idx() only parsed 'model.layers.N...'; find 'layers.N' anywhere so it also
   handles M3's 'language_model.model.layers.N...' VL prefix.

Extracts the 228 indexer weights (+scales) to out-idx-*.safetensors; drop them beside
the main container (st.h scans any *.safetensors). Applied to the box container +
mirror: MSA now active on the real model (no [MSA] fallback, output correct).
kv_persist.h gated the Ic rows on has_dsa (GLM DSA), which is false for
M3 (index_topk=0), so .coli_kv persisted K/V but not the MSA index keys:
a resumed conversation carried uninitialized Ic for every restored
position, silently corrupting block selection once the conversation
passed 2048 tokens (topk_blocks * block_size). Gate on (has_dsa || msa)
— the same condition kv_alloc uses for Ic — in the header, record size,
append and load. Old M3 .coli_kv files now mismatch the header
(h[3] 0 -> index_hd) and take the existing 'different model or version'
reject path (start over, no misparse).

Validated on the real 225 GB container:
- record size 245764 -> 274948 B/token (= +57 sparse layers x 128 f32)
- old-format file: clean reject + start-over
- end-to-end: a 2363-token needle document (needle in block 15 of 19)
  saved by one process; a FRESH process resumed 2365 tokens (0.1 s, no
  re-prefill) and answered the needle question exactly. Without the fix
  the zero-key tie-break selects blocks {0..14, local} and drops block
  15, so this recall only works through the restored index keys.
M3's GQA has no MLA low-rank bottleneck: q_proj and o_proj are 50 MB of
int8 each across 60 layers, ~6 GB of weight reads EVERY token that rode
the same ~28 GB/s DDR4 the routed experts stream over — the reason a
428B/23B-active model barely beat the 744B GLM (whose attention/dense
lives in VRAM via CUDA_DENSE). matmul_qt_ex had Metal and CUDA branches
but no Vulkan one; COLI_VK_DENSE only wired GLM's MLA call sites.

Add a generic VK branch to matmul_qt_ex behind COLI_VK_DENSE=1, gated on
a per-tensor vk_gemm flag set at load for M3's resident weights (q/k/v/o,
indexer, dense MLP; shared experts already had wired sites) — routed
expert QTs are slab-transient and never marked. At decode only tensors
>= COLI_VK_GEMM_MB (default 8) are worth the per-call submit; a batched
forward (S>=8) offloads every marked tensor. Weights upload lazily at
the dense priority class (0.75 > tier 0.4), so the launcher reserve
grows to 8 GB and the expert tier self-sizes into the rest.

Measured (RX 9070, 512->~170-200-expert tier, 2-drive mirror, ngen 64):
  decode topp 0.7: 2.12-2.20 -> 2.6-2.84 tok/s (+25-30%)
  decode topp 0.5: 2.49 -> 3.0-3.10 tok/s
  prefill 516 tok: 152.7 -> 102.3 s (-33%); attention projections -67%
Output verified (greedy Paris + coherent lighthouse prose). Launchers
updated to COLI_VK_DENSE=1 RESERVE=8 and the stale MSA caveat rewritten.
Four levers on top of the VK dense matmul (95a565d):
 - lm_head vk_gemm: the 1.2 GB int8 logit matmul rides COLI_VK_DENSE.
 - q+k pair: k_p's 3 MB joins q_p's submit via vk_matmul_pair_qt (a lone
   k submit costs more than its CPU read; paired it is free).
 - fused shared expert un-gated for M3: the gate_up shader takes the
   activation as a push constant since 8dd7fae, and fmt=4/g64 rides the
   same grouped path the expert tier uses — the !g_act_swigluoai and
   fmt gate predated both. 3 submits/layer -> 1 across 57 layers. Also
   set the activation BEFORE vk_registry_fill's early returns, so a
   tier-less run (COLI_VK_EXPERTS=0) can not dispatch silu on an
   swigluoai model.
 - MSA selection: t-outer loop (each cached key row read once for all 4
   index heads), AVX2 4-lane f64 FMA scoring dot (the 64K-context decode
   hot spot), OMP over prefill rows (was single-threaded on 12 cores).

Oracle gate after the selection rewrite: PREFILL 24/24 (exercises the
OMP path), incremental DECODE 20/20 (IDOT=0, tiny checkpoint).

Measured (RX 9070, R8 tier 168 + dense, 2-drive mirror, ngen 64):
  decode topp 0.5: 3.02-3.10 -> 3.49-3.52 tok/s
  decode topp 0.7: 2.78-2.84 (unchanged — 154 experts x 28.3 MB =
    4.4 GB/token sits ON the dual-SSD read floor; compute offloads only
    pay below it, which is the standing argument for topp<=0.55 or int3)
  prefill 516 tok: 102.3 -> 96.8 s (152.7 before VK dense)
RAM_GB=58 probe: no gain (hit 84.8->85.9, wash) — RAM budget is spent.
Quality: Paris + coherent lighthouse prose at 0.5 and 0.7.
…ain pass

Since the indexer weights are kept in the main pass (MSA), they fell into the
generic resident class and silently followed --ebits. Block selection is
discrete: quantization noise flips top-k choices rather than blurring them,
and the validated real-model MSA config used int8 (supplemental pass). New
'idx' class + --idx-bits (default 8) pins them regardless of ebits/xbits;
the tensors are tiny (~215 MB) so the cost is nil.

Flag-path smoke on the tiny checkpoint: --ebits 4 --xbits 3 --group-size 64
converts (routed experts fmt=5 int3-g64, [MIXED] idx=8bit) and the engine
loads and runs the result.
…xact compare

The docstring predates the rebase: dev's engine takes the model dir as
SNAP=<dir> (COLI_MODEL is not an engine env upstream). Also spell out that
the T/T expectation holds under IDOT=0 -- the exact f32 kernels reproduce
this oracle to ~1e-6 max logit delta, while the default int8-activation
IDOT path can flip the argmax at a borderline position of the random tiny
fixture (measured: one 0.058-margin position at 23/24), which is activation
quantization, not a port defect.
… rendering

Three of the WIP gaps called out in JustVugg#601:

1. COLI_MSA=0 A/B kill-switch (colibri.c). Force full causal attention even
   when the MSA indexer weights are present, by zeroing c->msa + idx_type the
   same two lines the indexer-absent auto-detect fallback uses — so it rides
   the already-tested full-attention path for measuring MSA's long-context win.

2. Banner/PROF cosmetics (colibri.c). The startup banner and the PROF
   attention-bound verdict now name MSA/M3 instead of hardcoding GLM/DSA
   (banner peeks config.json model_type before model_init; PROF branches on
   m->c.arch).

3. Tool calls for the MiniMax-M3 template (openai_server.py). Render the
   ]<]minimax[>[<invoke name=…> XML dialect — the developer <tools> declaration,
   assistant tool_calls, and ]~b]tool <response>… tool results — and parse it
   back, so render_chat no longer returns 400 for `tools` and parse_tool_calls
   dispatches to an M3 parser. Mirrors chat_template.jinja (to_xml recursion,
   nested objects/arrays, schema type coercion via _tool_param_types).

Verified: MSA oracle still 24/24 + 20/20; COLI_MSA=0 flips to full attention;
tool-call render/parse round-trips (nested objects/arrays + type coercion);
full `make test` suite 167/167 OK (13 skipped).
…arker

Three adjustments on top of the tool-call support from fork PR #1, all
measured against the official chat_template.jinja rendered via jinja2:

- invoke/to_xml rendering: the template's for-loops are whitespace-trimmed
  on both sides, so argument pairs and nested elements concatenate with NO
  newlines; dropped the extra \n after <invoke name="..."> and between
  argument pairs (the parser was already layout-agnostic).
- developer-block example: the template's full two-invoke example including
  the nested param-2 item block, byte-identical.
- _tool_stream_markers: minimax holds the visible stream at the
  ]<]minimax[>[<tool_call> opener. The arch-dispatched streaming scan
  landed on dev after this PR's base; without a marker a streamed reply
  would leak the raw tool-call block to the client token by token.

Validated against the jinja reference render: tools declaration, assistant
tool_call block, and tool-response run all byte-identical; parsing the
template-rendered block round-trips nested objects/arrays/bools with
schema type coercion.
The [e~[ fallback lived in run_serve only, but openai_server launches the
engine with SERVE_BATCH=1 -> run_serve_mux, and coli run goes through
run_text: both kept eos=-1 on an M3 tokenizer. With -1 the JustVugg#401 stop
filter in sample.h is skipped and every tokenizer special token is armed
as a hard stop, so the first tool-call marker ends the turn. One
tok_eos_resolve() used by all three entry points. (Review B1.)
build_plan sized KV from the MLA keys only (kv_lora_rank/qk_rope_head_dim/
qk_nope_head_dim/v_head_dim), so an M3 container planned with kv_bytes=0,
kv_buffer=0 and configured_experts=0: plan/doctor/--auto-tier under-
reserved ~1.2 GB at 60L x 4096 ctx and the expert-cap clamp never fired.

Since JustVugg#1103 those formulas belong to the family descriptor, so this lands
in _minimax_geometry rather than as a branch in build_plan -- the planner
itself is untouched. The geometry mirrors the engine: Lc/Rc hold full K and
V rows (num_key_value_heads*head_dim each) over n_layers+1 rows, the MSA
index-key cache exists only on the sparse layers and without the MTP row
(kv_alloc allocates Ic over n_layers and skips !idx_type), and the sparse
set is derived the way load_cfg derives it -- from moe_layer_freq, because
M3's sparse attention layers ARE its MoE layers. Workspace is
attention_gqa's scratch set plus the indexer's own projections.

Two test layers: the registry test pins the arithmetic (and now asserts
against the shipped descriptor instead of the double the registry landed
with), and test_resource_plan runs a real M3 container end to end, so it
also fails if build_plan ever stops routing M3 through the geometry.
MLA arithmetic is unchanged. (Review B2.)
Two silent behavior changes for people not using MiniMax leave with this:

- params manifest: bits_map["idx"] was always recorded (default 8) and
  "arch" always written, so check_or_record_params refused to resume any
  conversion started with the pre-M3 converter. The idx int8 default now
  lives at the consumer in convert_shard and both keys are recorded only
  when non-default: a default GLM/DeepSeek run builds a byte-identical
  params dict, while a REAL flag change (or --arch m3 on a GLM outdir)
  still aborts. "idx" classification itself only ever fires on M3's
  self_attn.index_* names (GLM's DSA indexer is "indexer", skipped or
  converted via --indexer), so no other arch's output bytes change.

- layer_idx() matched "layers.<N>" anywhere; back to anchored
  "model.layers.N" plus the one vetted raw form, MiniMax-VL's
  "language_model.model.layers.N". (Review B3.)
…nnot infer

M3 rides the colibri binary, so a GLM-side refactor of the shared hot
paths (rmsnorm/act_glu/expert_ffn/MSA) could break it with every GLM
test green. Now something notices (Review B4):

- make m3-tiny-generate / m3-tiny-check: tiny random checkpoint ->
  --arch m3 int8 conversion -> numpy oracle -> the engine's REF/TF
  gate; tests/test_m3_tiny.py enforces token-exact counts (the engine
  itself always exits 0). New ci.yml job runs it on Linux with pinned
  deps (tools/requirements-m3-tiny.txt).

Everything the earlier revision of this commit added to coli and
openai_server -- banner roster, model_arch, engine dict, build target,
tune prompt, model ids, --arch choices, the KV-slot guard, the
cap sentinel -- is now supplied by the FamilyDescriptor and is gone from
this diff. What the descriptor could NOT express was the handful of
launcher gates written as `arch == "glm"`, which is a claim about the
engine binary, not about the family. They now ask the registry the
question they actually mean, via colibri_core():

    family.engine_group == family_by_id("glm").engine_group

engine_group is the registry's own statement that two families share an
engine implementation -- and therefore its env contract, its CAP channel
and its byte-protocol REPL. Four sites move: env_for_engine (M3 was
about to get the sister-engine OMP treatment for a binary that sizes its
own team), operator_cap (CAP= was silently ignored), cmd_chat (M3 would
have spawned a gateway server instead of the byte-protocol REPL), and
engine_for's COLI_DOCKER_GLM_ONLY guard, which told users a GLM-only
image cannot run M3 -- it can; it is the same binary.

Two gates deliberately keep the narrow `arch == "glm"` test:
`coli tune`'s replay protocol (M3 tunes through the persistent-rotation
path; giving it GLM's fixed replay means touching six more branches in
autotune.py, unvalidated here) and need_worker_model (cluster expert
workers are not validated for M3, and refusing says so).

test_launcher_dispatch needs no M3 entry now: the registry asserts each
family resolves to its own declared engine_artifact, and the intentional
share is declared by the descriptor rather than by a set in the test.
Review follow-ups:
- The Makefile suppression existed only because the VK GQA commit widened
  struct PC with act/alpha/limit push constants and left the existing
  positional initializers short. Name every field at all 16 sites (the
  gate_up dispatches overwrite act/alpha/limit right after, so semantics
  are unchanged -- the fields were implicitly zero before) and remove the
  flag from all five platform CFLAGS lines. gcc 15 builds warning-free.
- kv_alloc's index-cache calloc: (unsigned) cast gives value-range
  propagation a provable bound so -Walloc-size-larger-than stays quiet on
  inlined paths (the alloc is dev's own line; our wider "|| c->msa" guard
  changed inlining enough to surface it on the reviewer's gcc).
- docs/minimax-m3.md: family doc covering the shared-binary design, GQA/
  MSA/swigluoai/router notes, the CTX=4096 default and the MSA 2048-token
  exactness bound, tool-calling dialect, and the m3-tiny CI gate.
…egment

Review feedback from JustVugg#601.

- attention_gqa's header claimed "MSA block selection is NOT implemented:
  full causal attention". It has been implemented since the Lightning
  Indexer landed: `sel` is built per (row, index head) and gates both the
  score loop and the value accumulation, so only the selected blocks are
  read. The comment now says what the body does, keeps the exactness note
  (selection is a no-op while the window fits in topk*block = 2048 tokens,
  because every causal block is selected then) and names the three ways out
  of the sparse path: dense layers, no indexer weights, COLI_MSA=0.

- g_gemma_norm / g_act_swigluoai / g_swiglu_{alpha,limit} are process
  globals, which held while a process opened one model. JustVugg#1227 and JustVugg#1245
  landed the Segment and Edge runtimes, so a GLM segment can now sit beside
  an M3 segment and the two would fight over these: last model_init wins.
  Latent -- no shipping binary registers those adapters -- but M3's segment
  adapter cannot land until they hang off the Model. Recorded at the
  declaration, where the next person to add an adapter will read it.

- colibri_core() resolved family_by_id("glm") on every call to reach a
  string constant, and would have raised if that id were ever renamed. The
  group name moves into family_registry as COLIBRI_CORE_GROUP and both
  descriptors reference it, so the shared engine is single-source and the
  predicate no longer depends on another family's id.
@steve-m

steve-m commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

All three done, rebased onto 692af87.

The attention_gqa header

You read it right, and I checked rather than taking the note on faith: sel is built per (row, index head) at the top of the sparse branch and then gates both loops — the score loop and the value accumulation walk sg[j] and clamp to [b*BLK, b*BLK+BLK), so a block that was not selected is never touched. Selection has been live since the indexer landed; the comment was left over from the revision before it.

It now describes the body: what sel gates, and the three ways out of the sparse path (dense layers, checkpoints without indexer weights, COLI_MSA=0). The exactness note survives, with the real reason attached — selection is a no-op while the window fits in topk*block because every causal block gets selected then, so the key set is full attention's, summation order aside.

You are right that this matters more here than in most trees. The person who ports this to a GPU backend reads that header first, and "selection is not implemented" would have sent them to write it.

The globals

Recorded at the declaration, covering all four (g_gemma_norm, g_act_swigluoai, g_swiglu_alpha, g_swiglu_limit) — last model_init wins and the loser silently gets the other's norm and activation.

Building segment-edge-library on this branch makes it concrete rather than theoretical:

$ nm build/segment/glm.o | grep -E 'gemma_norm|swigluoai'
0000000000099280 b g_act_swigluoai
0000000000099284 b g_gemma_norm

build/segment/glm.o is colibri.c, and M3 rides that same object — so a GLM segment beside an M3 segment would not merely share a convention, they would share those two words of BSS. Still latent, since only that archive defines COLI_SEGMENT_ADAPTER/COLI_EDGE_ADAPTER and no shipping executable links it, but the note now sits where the next person to add an adapter will read it.

Worth flagging: #1245 means colibri.c compiles into a build our PR did not previously touch. I validated that target too — it links, and the four warnings it emits (qwen36.c /* within comment, g_k3_usage, g_metal_prefill, g_direct_heat_explicit) are all on dev's own lines, none from this diff.

colibri_core()

Taken, and one step further than a local constant: the group name moves into family_registry.py as COLIBRI_CORE_GROUP and both descriptors reference it. So the shared engine is single-source, and the predicate stops depending on another family's id in either direction — no lookup per call, and nothing to update if glm is ever renamed. It also reads as what it is, since colibri-core was already named for the binary rather than for a family.

Validation

Ryzen 3900X + RX 9070, rebased tip.

Check Result
make colibri / make colibri VK=1 clean, 0 warnings each
make segment-edge-library (new path via #1245) links; 0 new warnings
make m3-tiny-check, CPU and VK binaries OK — prefill 24/24, decode 20/20 each
TF_LOGITS vs oracle, IDOT=0 max abs diff 2.62e-06, 0 argmax flips
VK expert tier (int4 fixture) VK-BLOCK: 20 blocks, avg nvk 2.00 / ncpu 0.00; decode output identical to the CPU arm at all 20 positions
make test-c ALL PASS, incl. six real Segment and six real Edge adapters
make test-python 695 tests, OK (35 skipped)

A footnote to last round's correction, since I walked into the near-miss again: TF_LOGITS dumps from the prefill loop only, so a CPU-vs-GPU compare on those rows reads 0.000e+00 no matter what the tier did. The decode comparison above is per-position engine output from the two arms, which is the arm the tier actually serves.

Rebase is clean — zero conflicts against the 8 new commits, and it picks up #1252, so the two red Windows checks should go green on this push.

@OPS-NeoRetro OPS-NeoRetro left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@steve-m, I am curious about the reason behind not making a dedicated backend for MiniMax M3.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

model-support Supporto a nuovi modelli

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants