Skip to content

feat(qwen36): Qwen3.6-35B-A3B engine (CPU): hybrid Gated Attention + Gated DeltaNet + streaming MoE - #712

Merged
JustVugg merged 17 commits into
JustVugg:devfrom
kreuzzelg:qwen36-engine
Aug 18, 2026
Merged

feat(qwen36): Qwen3.6-35B-A3B engine (CPU): hybrid Gated Attention + Gated DeltaNet + streaming MoE#712
JustVugg merged 17 commits into
JustVugg:devfrom
kreuzzelg:qwen36-engine

Conversation

@kreuzzelg

@kreuzzelg kreuzzelg commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

The idea of this and next PR is to optimize loading experters (for MoE model) from RAM to VRAM when the VRAM is limited, so that for example the "Qwen3.6:35b" model can run at a single GPU with 8 GB VRAM with acceptable performance.

Measured (Threadripper 3945WX 12C, RTX 3070 8 GB + Quadro RTX 4000 8 GB, 200-token decode)

configuration decode tok/s VRAM hit rate peak RSS
CPU only ~2.5 ~13 GB
1× 8 GB GPU 9.9 95 % 40 GB
2× 8 GB GPUs 11.3 100 % 29 GB
Ollama 2x 8GB GPUs 10.5 ~40 GB

Relationship to #602: the engine core (hybrid forward, container format, converter, tiny/oracle validation) originates from @minne100's PR; this PR retargets it to dev, adds substantial CPU performance work and converter fixes, and drops the Vulkan/serve parts per review guidance.

Self-contained engine for Qwen3.6-35B-A3B (35B total / 3B active, 256 experts/layer, Apache-2.0). This is the "engine first" slice requested in the #602 review (thanks for the guidance!): no shared GLM/Inkling files are touched, no GPU backend is included — those come as separate follow-ups.

What's included

  • c/qwen36.c — 40-layer hybrid forward: 10x Gated Attention (GQA, partial RoPE rope_dim=64, per-head q/k RMSNorm, output gate) + 30x Gated DeltaNet (causal depthwise conv ring + recurrent gated delta rule), streaming MoE with per-layer LRU expert cache + shared expert, PILOT router-lookahead prefetch, COLIBRI_RESIDENT pinning, per-phase timers (COLI_TIMERS=1).
  • CPU performance: optional per-row int8 quantization of the large dense matrices (COLI_DENSE_I8=1 default; f32 originals freed; =0 restores the bit-exact f32 reference path), hand-written AVX2/FMA int8 GEMV, DeltaNet recurrence parallelized per value head. ~7x vs the scalar baseline.
  • c/tools/convert_qwen36.py — HF→container converter (true int4, per-row scales, --selftest). Containers now run out of the box: flat config.json (VL checkpoints nest dims under text_config) and bundled tokenizer.json.
  • c/tools/make_qwen36_tiny.py, make_qwen36_oracle.py — weight-free validation without the 70 GB checkpoint.
  • docs/qwen36-phase01.md, qwen36-phase02.md.

Validation

  • converter --selftest PASS (int4 pack/unpack exact)
  • tiny-model logits bit-identical to the validated reference (cosine 1.0)
  • real 35B int4 container: coherent generation; dense-int8 logits cosine
    0.9992 vs f32 reference (COLI_DENSE_I8=0 → bit-exact)

Try it

A pre-converted, self-contained int4 container (tokenizer + flat config bundled, produced with this PR's converter) is available: https://huggingface.co/Kreuzzelg/qwen36-35b-a3b-colibri-i4

Notes

  • The earlier published i4/i8 HF containers predate the converter fixes: they lack tokenizer.json and ship the nested VL config (workaround: TOK= and a flattened config.json; new conversions are self-contained).

Thanks on Claude Code.

@jazir555

Copy link
Copy Markdown

Can you add Qwen 27B support? It would be awesome to use since it's pretty much entirely out of reach for my system right now. 32 GB ram, 12 GB 4070 super, 12700k. 27B support with your method would make it usable and fit entirely in VRAM i think

@minne100

minne100 commented Aug 1, 2026 via email

Copy link
Copy Markdown
Contributor

terrizoaguimor pushed a commit to terrizoaguimor/colibri that referenced this pull request Aug 1, 2026
…wrong

The site said colibri runs GLM-5.2. It has run four families since v1.3.0, and
two of the model cards contradicted the project outright:

  Inkling   975B MoE - Planned  -> Live   (docs/inkling.md ships; runs on 25 GB)
  Kimi K2   1T MoE   - Planned  -> Kimi K3, 2.8T MoE, Live

Telling visitors that Inkling and Kimi are on the roadmap, while the README
front page says both run today, is the kind of contradiction someone finds in
thirty seconds.

Hero rewritten rather than merely widened. The old line worked because it put
two incompatible things next to each other -- an enormous model, your machine.
Replacing that with a range ('744B to 2.8T') informs and stops landing; the
contradiction was the message. It now reads:

  These models do not fit in your machine. They run in it anyway.

Same rhetorical shape as the copy further down the page ('Weights are not state
to hold. They are data to stage.'), so the page speaks with one voice. The
subtitle now also explains WHY it is possible -- a MoE token touches a small
fraction of the weights -- which was missing entirely and is what turns an
unbelievable claim into an understandable one. The numbers move there, where
they serve the reader who wants detail instead of the one who is skimming.

DeepSeek and Qwen3 deliberately stay 'Planned': JustVugg#165 and JustVugg#712 are not merged,
and the site should not promise what the code does not do.

Text only. No CSS, structure or script changes -- the sole markup edits are the
two cards' buttons becoming real links now that both models are runnable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LionelColaso

LionelColaso commented Aug 1, 2026

Copy link
Copy Markdown

@minne100 already exists #544
@kreuzzelg support for KAT-Coder-V2.5-Dev would be awesome

@JustVugg

JustVugg commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Reviewed this properly, and I owe you both a compliment and a correction of something I said internally about it.

The file shape is exactly right. c/qwen36.c as a single engine, a converter, two oracle-fixture generators, six lines of Makefile, and zero changes to any existing file. That is the shape inkling.c and kimi_k3.c landed in and it is what we ask new engines for. You got there without being told, and it made this quick to read.

But it cannot be driven by coli yet, and that is the blocker rather than anything about the engine:

kimi_k3.c qwen36.c
emits the READY handshake ❌ none
serve mode (SERVE=1) ❌ none
known to c/coli ✅ (#676) ❌ not touched

So coli chat, coli web and coli serve would all fail: the launcher does not know the arch exists, and even pointed at the binary the engine does not speak the protocol. Users do not run engines directly — they run coli chat. An engine that only generates from argv is, in practice, not reachable.

What it takes

1. c/coli, two lines.

# model_arch(), c/coli:160
if "qwen" in model_type: return "qwen"        # config.json says model_type: qwen3_5_moe

# engine_for(), c/coli:176
name = "inkling" if arch == "inkling" else "kimi_k3"   # -> add "qwen36"

coli reads the model's config.json, picks the binary, and for non-GLM engines spawns the local gateway and attaches the TUI — so chat, web, serve and the OpenAI-compatible API all come from those two lines plus the chat template.

2. Serve mode in the engine. kimi_k3.c's serve_loop is the shortest example: emit \x01\x01READY\x01\x01\n, then a STAT line, then read SUBMIT/CANCEL frames. #676 is the worked example of the whole thing for Kimi.

3. One thing that will bite you on Windows, which we only found today. Call coli_serve_binary_mode() from compat.h before emitting the sentinel. On Windows the CRT rewrites the trailing \n as \r\n, the gateway never matches the sentinel and the session hangs forever with no error. inkling.c and kimi_k3.c both had this bug and it cost a user a 42-minute load followed by silence (#748, fixed in #749). It is one line, and you get it for free by adding it now rather than shipping it and finding out.

Two questions

Which container should people use? Qwen3.6-35B-A3B upstream is bf16, so it needs converting — convert_qwen36.py does that, and you have also published Kreuzzelg/qwen36-35b-a3b-colibri-i4 at ~20 GB. Should the docs point at your prebuilt container as the easy path, with the converter as the do-it-yourself route? That is how docs/inkling.md is structured and it works well.

Per-row int4 or gs64? Your container is per-row. On GLM, per-row int4 was the root cause of #455's think-mode loops and never-terminating generations, and the group-scaled gs64 container fixed those controlled A/Bs — that is why the GLM README steers people away from the per-row mirrors. With moe_intermediate_size=512 the groups on Qwen are short, so I would expect per-row to lose more here rather than less, but I have not measured it on this model and would not want to assert it. If you can run the same prompt on both containers it would settle the question, and it is the kind of measurement that is much cheaper for you than for us.

Related

#729 (@minne100) is a Vulkan MoE GEMV backend for integrated/AMD GPUs, explicitly written as the GPU half for this engine and currently a draft blocked on this PR landing. Worth the two of you talking — there is also a question there about a vendored 549 KB vulkan_core.h versus the system header the merged Vulkan backend (#418) uses, which is easier to settle between you than after the fact.

No rush from us, and none of this is a criticism of the engine — 2,009 lines that build clean and leave every existing file untouched is a good PR. It just needs the front end before people can actually reach it.

minne100 added a commit to minne100/colibri that referenced this pull request Aug 1, 2026
Addresses the main blocker on JustVugg#712: the engine built clean and touched no
existing files, but coli could not reach it.

c/coli:
- model_arch(): return 'qwen' for any model_type containing 'qwen'
- engine_for(): resolve 'qwen36' binary
- need_model(): qwen36 build target
- chat banner model_id: qwen36-colibri

c/qwen36.c (SERVE=1 mode):
- implements the colibri gateway wire protocol (READY + STAT + SUBMIT/
  CANCEL + DATA + DONE + PROF), mirroring kimi_k3.c / inkling.c so
  'coli chat' / 'coli web' / 'coli serve' can drive the engine
- READY/STAT handshake, SUBMIT payload -> encode_text -> step() prefill +
  per-token step() decode, stream decoded bytes as DATA frames, stop on
  EOS (Qwen3 151645, Q36_EOS override), DONE/STAT on completion
- temperature + top-p sampler (ported from kimi_k3.c, qsort O(V log V))
- Windows binary-mode fix inlined (JustVugg#748 hang: CRT rewrites n->rn and the
  gateway never matches READY). Uses _setmode(_fileno(stdout/stdin),
  _O_BINARY) before the sentinel; switch to compat.h's
  coli_serve_binary_mode() once JustVugg#749 lands (helper not in base yet).
@minne100

minne100 commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

@maintainer — pushed the coli integration + SERVE=1 gateway protocol and addressing the review point by point. (Note: I can't edit this PR's head/body via API — I have no push access to the kreuzzelg/colibri head branch and I'm not a collaborator on upstream — so here's everything as a comment. The updated branch is pushed to minne100/colibri:feat/qwen36-gpu-residency-serve, now at bcae842, sitting on top of the residency/GPU fixes. Happy to retarget this PR's head to that branch or open a fresh PR from my fork — see last point.)

1. coli reachability — DONE

c/coli now recognizes the engine:

  • model_arch(): if "qwen" in model_type: return "qwen" (before the glm fallback).
  • engine_for() / need_model() map qwenqwen36 (binary + build target).
  • chat banner shows model_id="qwen36-colibri" for the qwen arch.
    So coli <qwen36-model> now drives qwen36.exe exactly like it drives glm / inkling / kimi. Commit bcae842.

2. SERVE=1 gateway protocol — DONE

Added a serve mode to c/qwen36.c that speaks the existing non-GLM wire protocol (the one kimi_k3.c / inkling.c already use):

  • READY sentinel + STAT <rss> + SUBMIT <id> <slot> <plen> <max_tok> <temp> <top_p> + ACCEPT + DATA <id> <n> + DONE <id> STAT <gen> <tps> <hit%> <rss> <np> <limited> (+ PROF). No END frame — that's GLM/colibri.c-only. CANCEL <id> is handled by the gateway.
  • Sampler: ported kimi's top-p / qsort sampler. Previously generate() was greedy-only; serve_sample() now honors temp / top_p.
  • EOS: Qwen3 <|im_end|> = 151645 (Q36_EOS env override).
  • Windows fix: inlined _setmode(_fileno(stdout/stdin), _O_BINARY) inside serve_loop(). coli_serve_binary_mode() from fix(win): put stdout in binary mode before the serve handshake (closes #748) #749 is not in base yet (confirmed absent), so I inlined it with a // TODO: switch to shared helper once #749 lands. Will swap it out the moment fix(win): put stdout in binary mode before the serve handshake (closes #748) #749 merges.

3. Which container to document

Plan: document the prebuilt container plus the self-convert path, following the docs/inkling.md pattern.

  • Namespace correction: the prebuilt containers are published under the minne100/ Hugging Face namespace — minne100/qwen36-35b-a3b-colibri-i4 (and -i8) — not kreuzzelg/. The GitHub fork account (kreuzzelg) and the HF publish account (minne100) are different handles, so the container path shouldn't be derived from the fork name.
  • Docs will point at the prebuilt i4 by default and at c/tools/convert_qwen36.py (true-int4 by default) for self-converting from the upstream HF safetensors.

4. per-row int4 vs gs64

Recommendation: switch to group-scaled int4 (gs64, group size 64) as the default converter output, replacing the current per-row int4.

  • Rationale: GLM issue GLM-5.2 int4 think mode: stochastic reasoning loops / EOS starvation on stock v1.0.0; #307's TEMP=0.9 NUCLEUS=0.95 stopgap makes it severe #455 showed per-row int4 triggered think-mode infinite loops; gs64 fixed it. For Qwen, moe_intermediate_size=512 makes the rows short, so per-row quantization loses proportionally more.
  • Caveat (honest): I have not yet run an A/B on Qwen to measure the actual accuracy delta. Before flipping the default I'll (a) add gs64 to convert_qwen36.py, (b) produce a gs64 container, and (c) A/B it against per-row int4 (logit-cosine vs the HF oracle + a generation-quality check). Current default stays per-row int4 until that's measured — I won't change the default on assertion alone.

5. #729 / Vulkan header coordination

qwen36.c currently vendors vulkan_core.h (~9.8k lines) to support the in-shader int4 decode path. You flagged this against #418's system-header approach — agreed it's the wrong end state.

6. Push / head — action needed from you

Because I can't push to the kreuzzelg/colibri head branch that this PR tracks (and can't edit the PR head/body via API), the updated code lives at minne100/colibri:feat/qwen36-gpu-residency-serve (bcae842). Please pick one:

  • (a) retarget this PR's head to minne100:feat/qwen36-gpu-residency-serve, or
  • (b) I open a fresh PR from my fork against dev.

Either way the coli + SERVE work above is already in that branch. Let me know which you prefer and I'll proceed (and fold in the gs64 converter + #729 header reconcile once measured/decided).

@kreuzzelg

kreuzzelg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@JustVugg — ran the per-row vs gs64 A/B you asked for. Short version: gs64 is measurably more accurate on Qwen (≈44 % less quantization error), but per-row does not reproduce the #455 loop symptom here — so switching is a quality improvement, not a bug fix. Details below; both containers built from the same bf16 base, same engine, same prompts, only the expert quant differs.

1. Accuracy — first-token logit distribution vs an int8 anchor

int8 (per-row) container as the near-lossless reference; both int4 containers compared against it. 4 prompts (MoE-explain, a multi-step math word problem, a primes/twin-primes list, a self-critique task).

prompt cosine per-row cosine gs64 KL per-row KL gs64
p1 0.99141 0.99568 0.0673 0.0620
p2 0.98053 0.99237 0.1439 0.0446
p3 0.99061 0.99531 0.0783 0.0363
p4 0.98855 0.98915 0.1470 0.1751
mean 0.98777 0.99313 0.1091 0.0795

gs64 wins on cosine on all 4 prompts and on KL on 3 of 4 (p4 is a wash on cosine and slightly worse on KL — honest outlier, not cherry-picked out). Mean cosine error to the anchor drops 0.01223 → 0.00687, i.e. ~44 % less. So your intuition that per-row loses more with moe_intermediate_size=512 holds — just not dramatically.

2. Behavior — the #455 symptom does not reproduce on Qwen

Same 4 prompts, greedy decode, 512 tokens, both containers. I checked for the degenerate-repetition / non-termination pattern from #455 (top-4-gram repeat count, unique-token ratio):

per-row gs64
max 4-gram repeat (any prompt) 2 2
unique-token ratio range 0.53–0.71 0.54–0.73
runaway loops none none

Both generate healthy, non-repetitive text on all 4 prompts. Whatever made per-row int4 loop on GLM think-mode, it isn't firing on Qwen3.6-35B-A3B here. (Caveat: this engine's argv text mode generates a fixed token count and doesn't stop on EOS, so this measures degeneration, not natural termination — the serve path with EOS handling would be the place to confirm termination.)

3. Recommendation

Make gs64 the default converter output — it's a free accuracy win (44 % lower logit error, no measured downside except ~10 % larger container: 20 → 22 GB for the 35B). But since per-row is not broken on Qwen, I'd suggest keeping per-row selectable (--gs 0) rather than removing it, and not treating existing per-row containers as unsafe.

Implementation is already on the branch (gs64-ab): convert_qwen36.py --gs N (group-scaled expert quant, expert_gs in meta) + engine support (matmul_q_gs scalar + AVX2, sized from expert_gs), validated on the tiny model (gs beats per-row vs the int8 reference) and on the real 35B above. Happy to fold it into whichever branch becomes the merge head.

Containers (both self-contained, produced with this converter):

Raw numbers + harness are in the branch; happy to paste the full per-prompt
table or the harness script if useful.

Thanks to Claude :-)

@kreuzzelg

kreuzzelg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Hi @minne100 — thanks for turning the coli + SERVE=1 integration around so fast, that was the real blocker and you've solved it. Answering your point 6 directly: please open a fresh PR from your fork against dev. A cross-fork head retarget isn't possible on our side anyway, so a new PR from minne100/colibri is the clean path, and it makes sense for the engine PR to be authored by you since the engine is yours.

One thing we'd like to keep from #712 so it isn't lost when this one closes — all small and self-contained, on top of the base engine:

  • CPU decode perf: hand-written AVX2/FMA int8 expert GEMV, optional per-row int8 quantization of the dense matrices (COLI_DENSE_I8, f32 freed after), DeltaNet recurrence parallelized per value head. ~7× over the scalar path.
  • Converter run-out-of-the-box fixes: bundle tokenizer.json, write a flat config.json for the VL checkpoint (keep the original as config.hf.json).
  • gs64 group-scaled expert quant (--gs, expert_gs in meta + engine matmul_q_gs): the A/B above shows ~44 % lower logit error vs per-row.

Whatever's least work for you: we can (a) hand you these as cherry-pickable commits / a patch to fold into your PR, or (b) submit them as one small follow-up PR stacked on yours. We slightly lean toward (b) since the maintainer asked for small reviewable PRs, but your call — you own the engine head.

Once your PR is the engine base, we'll close #712 and rebase our #713 (CUDA VRAM expert tier) onto your branch so it stacks cleanly on top. #713 stays our PR (dedicated-NVIDIA path), your Vulkan #729 stays yours (iGPU/AMD) — complementary, as discussed.

On the containers: no ownership issue from our side — we published the two under the Kreuzzelg/ HF namespace only because that's the GitHub handle here. The converter now makes self-contained containers either way, so point the docs at whichever namespace you and the maintainer prefer (yours is fine); we'll keep ours as mirrors. And agreed on the vulkan_core.h question — best reconciled with #418 before it lands, between the two of you.

Just say which option (a/b) you prefer and I'll get our three items over to you.


@kreuzzelg

kreuzzelg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@minne100 — to save you the wait on whichever option you pick, I've prepared our three items as clean single-purpose commits on top of your bcae842, so they cherry-pick without conflicts:

Branch: kreuzzelg/colibri:qwen36-improvements-for-minne100

  1. fix(qwen36): self-contained containers — bundle tokenizer.json, flat config.json
  2. perf(qwen36): AVX2 int8 expert GEMV + dense int8 + parallel DeltaNet
  3. feat(qwen36): group-scaled int4 (gs64) — the ~44 % accuracy win from the A/B

Each builds and passes tiny-model validation on your branch (per-row unchanged, grouped path verified, dense-int8 cosine 0.9992 / bit-exact with COLI_DENSE_I8=0).

git remote add kreuzzelg https://github.com/kreuzzelg/colibri
git fetch kreuzzelg
git cherry-pick c1677f6 ca9cca2 94495ec   # or pick a subset

If you'd rather I open them as a follow-up PR on top of your fresh PR (option b), just say so. .patch files are attached in case the SHAs drift.

@kreuzzelg

Copy link
Copy Markdown
Contributor Author

Heads-up on a gs64 + GPU-tier gap I hit while benchmarking, so nobody trips over it:

gs64 containers don't run on the CUDA VRAM tier yet — gs64 is CPU-path only right now.

The two pieces were built on separate branches and haven't met:

So a gs64 container loaded by the tier binary is rejected at load (scale array is 49152 elems — expected 3072), and there's currently no single build that has both. Practical consequence:

  • the per-row int4 containers are the ones that run on the GPU tier today (~11 tok/s on 2×8 GB);
  • the gs64 containers (the ~44 % more accurate ones) currently only run on the CPU path (~2.5 tok/s).

The good news: backend_cuda.cu already has fmt=4 (grouped int4) kernels, so extending the tier to gs64 is a wiring job — thread expert_gs through qt_note/upload and send fmt=4 with the grouped scales instead of fmt=2. I'm planning to do that so gs64 gets the GPU speed too, and I'll post gs64-on-GPU tok/s once it's in. Flagging now so the published gs64 containers aren't mistaken for GPU-tier-ready.

@kreuzzelg

kreuzzelg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on the gs64 note above: the reason gs64 containers were CPU-only so far is a backend gap, not an engine limitation — the async CUDA expert-group path (coli_cuda_expert_group_issue) never dispatched grouped
int4 (fmt=4) and silently fell back to a per-row scale. That is now fixed in #762 (standalone against dev, backend + test only — no overlap with this PR or #713).

Once #762 lands, the gs64 containers (Kreuzzelg/qwen36-35b-a3b-colibri-i4-gs64) run on the CUDA VRAM tier as
well: verified downstream on the 35B container, CPU vs 2-GPU tier at logits cosine 1.0000000 over 24 greedy tokens, token-identical output.

@rofl0r

rofl0r commented Aug 1, 2026

Copy link
Copy Markdown

how about cherry-picking bcae842 onto this branch rather than starting over with the original PR that was dead for a week until you revived it here? the same might happen again - it appears the original author has way less time to work on this than you do.

@kreuzzelg

kreuzzelg commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@LionelColaso — the pre-converted container is up:
https://huggingface.co/Kreuzzelg/kat-coder-v2.5-dev-colibri-i4-gs64 (~22 GB, self-contained, Apache-2.0).

Recap from the verification above: KAT-Coder-V2.5-Dev shares the Qwen3.6-35B-A3B architecture field for field, so the qwen36 engine runs it with zero code changes. I published the gs64 (group-scaled int4) variant — on the sibling
base model it cuts first-token logit error by ~44 % vs per-row, and since the async grouped-int4 backend fix (#762) it runs on the CUDA VRAM expert tier at full speed: 11.3 tok/s warm on 2×8 GB, 10.4 tok/s on a single 8 GB card
(200-token decode, TTFT ~2 s, peak RSS 30/40 GB).

Which commits you need (until the PRs land):

One caveat worth knowing: there are two KAT-Coder-V2.5 versions. The paper's scores are for the commercial flagship (e.g. SWE-Bench Pro 65.2, Terminal-Bench 2.1 60.7); the open weights — and this container — are KAT-Coder-V2.5-Dev (35B/A3B, text-only, Apache-2.0), which reports considerably lower agentic-coding scores (SWE-Bench Pro 45.96, Terminal-Bench 2.1 41.02, but SWE-Bench Verified 69.40). The model card has the full comparison table, so nobody benchmarks the Dev container against flagship expectations. Also still true: it's a Dev checkpoint — wire up its chat template + EOS for real use; in raw completion mode it keeps writing after the code block.

Since you seem to follow the KAT releases: do you know anything more about the commercial flagship V2.5 — parameter count, architecture, whether it stays closed or open weights are planned? Kwaipilot doesn't disclose specs in the
paper, and it would help to judge how far the open Dev checkpoint is from the scores people usually quote.

kreuzzelg pushed a commit to kreuzzelg/colibri that referenced this pull request Aug 3, 2026
Addresses the main blocker on JustVugg#712: the engine built clean and touched no
existing files, but coli could not reach it.

c/coli:
- model_arch(): return 'qwen' for any model_type containing 'qwen'
- engine_for(): resolve 'qwen36' binary
- need_model(): qwen36 build target
- chat banner model_id: qwen36-colibri

c/qwen36.c (SERVE=1 mode):
- implements the colibri gateway wire protocol (READY + STAT + SUBMIT/
  CANCEL + DATA + DONE + PROF), mirroring kimi_k3.c / inkling.c so
  'coli chat' / 'coli web' / 'coli serve' can drive the engine
- READY/STAT handshake, SUBMIT payload -> encode_text -> step() prefill +
  per-token step() decode, stream decoded bytes as DATA frames, stop on
  EOS (Qwen3 151645, Q36_EOS override), DONE/STAT on completion
- temperature + top-p sampler (ported from kimi_k3.c, qsort O(V log V))
- Windows binary-mode fix inlined (JustVugg#748 hang: CRT rewrites n->rn and the
  gateway never matches READY). Uses _setmode(_fileno(stdout/stdin),
  _O_BINARY) before the sentinel; switch to compat.h's
  coli_serve_binary_mode() once JustVugg#749 lands (helper not in base yet).
@kreuzzelg

Copy link
Copy Markdown
Contributor Author

Front-end wiring is in — done the way @rofl0r suggested: @minne100's bcae842 cherry-picked with authorship preserved (coli's model_arch/engine_for/need_model + the SERVE=1 gateway loop), with the inlined Windows fix switched to compat.h's coli_serve_binary_mode() now that #749 is merged, as that commit's own message intended.

Driving the handshake end-to-end then found three more gaps, all fixed on the branch:

  1. Serve mode required an argv prompt file. main loaded argv[3] (default ref.json) before the SERVE branch, so an engine launched exactly the way coli launches it — SERVE=1, no prompt argument — exited 1 before ever emitting READY.
  2. The EOS id was the 151k-vocab constant. serve_one compared against a hardcoded 151645; Qwen3.6's 248320-token vocab puts the specials at 248044+ (<|endoftext|> 248044, <|im_end|> 248046), so EOS never matched and every turn ran to max_tok. Now resolved from the tokenizer's added_tokens — which is exactly what upstream generation_config.json declares (eos_token_id: [248046, 248044]).
  3. The gateway had no qwen template family. openai_server.py would have rendered GLM frames. render_chat_qwen mirrors Qwen3.6's chat_template byte for byte in the text-only subset (inkling's approach). One non-obvious detail the template encodes: the generation prompt must open the <think> block — the model was never trained on a bare assistant\n state, and greedy argmax there is an EOS special (measured: gen=0 on the first token).

Verified: READY/STAT → SUBMIT → ACCEPT → DATA → DONE transcript on the real container, and — closing the caveat from the A/B comment above — natural termination on the serve path: with the model's own sampling (temperature 1.0, top_p 0.95 per generation_config.json), the gs64 container ends its turn on <|im_end|> after 214 tokens and the per-row container after 222, both limited=0. So the #455 non-termination symptom does not reproduce on Qwen on the EOS-aware path either. (Greedy decode in think mode does spiral — 2048 tokens of word-counting on the gs64 run — but that is the documented Qwen3 "do not use greedy" pathology, not a container property. Small gap worth noting: serve_sample implements temperature+top_p but not top_k, which upstream sets to 20.)

On your two questions: docs/qwen36.md is added in docs/inkling.md's structure — prebuilt containers as the easy path, converter as DIY, gs64 recommended per the A/B above with per-row still selectable. And the gs64 support from that A/B is now folded into this branch as promised (convert_qwen36.py --gs + grouped-scale expert GEMVs), so the engine can actually read the container the docs recommend.

Branch is 7 commits: engine, serve protocol (@minne100), gs64, docs, and the three fixes above.

@JustVugg

JustVugg commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Heads-up: #165 (DeepSeek V4 target-only CPU engine) has landed on dev, and this PR now conflicts. Same for #713. I resolved the conflicts #165 caused on several other PRs myself and pushed the merges to their branches, but I am deliberately not doing that here, and I want to be precise about why.

The conflict is additive in intent. Your render_chat_qwen and dev's new render_chat_v4 are two independent functions that happen to have been added at the same place in c/openai_server.py. Nothing is contested — both should exist.

But git's conflict boundaries interleave the two function bodies. The hunks cut across render_chat_qwen and render_chat_v4 mid-definition: one hunk ends at your parts = [] and dev's parts = [bos], the next mixes both message loops. Resolving it means reconstructing both functions by hand rather than picking sides, and a chat template that is subtly wrong does not fail loudly — it produces degraded output that looks like a model problem. That is not a call I should make inside someone else's template.

For you it is a couple of minutes: keep both functions whole, and make sure the dispatch covers qwen and deepseek_v4.

The rest is mechanical:

  • c/coli — 4 hunks, the cmd_run / arch-dispatch region. dev added a deepseek_v4 branch beside yours.
  • ARCH = "glm" — just the trailing comment listing the architectures; the union is glm | inkling | kimi | qwen | deepseek_v4.
  • c/Makefile — resolves cleanly, no action.

One thing worth knowing since it cost three weeks here: your CI had also never run. The workflow sat in action_required, invisible from the PR page. I approved the blocked runs across the repo earlier today, so #712 and #713 now have real check results for the first time — worth a look before you rebase, in case they surface something.

@vladislavlad

vladislavlad commented Aug 5, 2026

Copy link
Copy Markdown

Hello!
What do you think about adding support for Qwen3.5-397B-A17B and Qwen3.5-122B-A10B?
What would I need to do to add support for these models?

@rofl0r

rofl0r commented Aug 5, 2026

Copy link
Copy Markdown

What do you think about adding support for Qwen3.5-397B-A17B and Qwen3.5-122B-A10B?
What would I need to do to add support for these models?

afaik they (qwen 3.5 models) have the same architecture as qwen 3.6 , so they should mostly work as-is on this branch - they only need to be converted into a quantization format colibri understands. same holds for the qwen 3.8 series which will be released this week (crossing fingers they'll release also a 35B-A3B model... - the sweet spot for local inference)

kreuzzelg pushed a commit to kreuzzelg/colibri that referenced this pull request Aug 6, 2026
Addresses the main blocker on JustVugg#712: the engine built clean and touched no
existing files, but coli could not reach it.

c/coli:
- model_arch(): return 'qwen' for any model_type containing 'qwen'
- engine_for(): resolve 'qwen36' binary
- need_model(): qwen36 build target
- chat banner model_id: qwen36-colibri

c/qwen36.c (SERVE=1 mode):
- implements the colibri gateway wire protocol (READY + STAT + SUBMIT/
  CANCEL + DATA + DONE + PROF), mirroring kimi_k3.c / inkling.c so
  'coli chat' / 'coli web' / 'coli serve' can drive the engine
- READY/STAT handshake, SUBMIT payload -> encode_text -> step() prefill +
  per-token step() decode, stream decoded bytes as DATA frames, stop on
  EOS (Qwen3 151645, Q36_EOS override), DONE/STAT on completion
- temperature + top-p sampler (ported from kimi_k3.c, qsort O(V log V))
- Windows binary-mode fix inlined (JustVugg#748 hang: CRT rewrites n->rn and the
  gateway never matches READY). Uses _setmode(_fileno(stdout/stdin),
  _O_BINARY) before the sentinel; switch to compat.h's
  coli_serve_binary_mode() once JustVugg#749 lands (helper not in base yet).
BColsey pushed a commit to BColsey/colibri that referenced this pull request Aug 18, 2026
…quivalent

The int8-activation fast path exists only under __AVX2__ and quantizes
activations per 32-block; the scalar route is byte-exact. With the old
on-by-default, the same model with the same settings produced different
tokens on x86 vs ARM, and no measurement ever blessed that default.
Same class and same one-line fix as olmoe (JustVugg#1044) and qwen36 (JustVugg#712
review, kreuzzelg's finding). IDOT=1 restores the fast path explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BColsey pushed a commit to BColsey/colibri that referenced this pull request Aug 18, 2026
…gg#1081)

Every tiny-oracle job ran on x86, so NEON branches were never compiled,
let alone executed — which is how the olmoe (JustVugg#1044) / qwen36 (JustVugg#712
review) / inkling (JustVugg#1080) IDOT class shipped undetected.

Two additions:

1. tests/test_int_kernel_exact.c — integer kernels have no rounding
   excuse: against a pure-C reference on the same inputs they must match
   bit for bit on every ISA. Covers dot_i4i8 (pair int4xint8),
   planarize_i4 round-trip, dot_i4p_u (K1 plane layout, the -8*sum(x)
   identity), and matmul_i4p_idot vs matmul_i4_idot bitwise. The test
   encodes the real activation contract: qrow_i8 clamps to [-127,127],
   and -128 must never reach the kernels (the abs/sign VNNI trick cannot
   represent its negation) — the first draft generated full-range int8
   and produced 30 false failures against the SHIPPED kernel, which is
   the contract documenting itself.

2. An ubuntu-24.04-arm job (timeout-bounded per JustVugg#953's direction):
   builds every engine (NEON compile coverage — K1's dot_i4p_u NEON
   branch had never been compiled by anyone), runs the exactness gate
   with the NEON branches live, and replays the glm_tiny teacher-forcing
   oracle against a fixture generated on the same runner (same-machine
   torch reference, no cross-ISA float excuses).

The x86 efficiency job also runs the exactness gate, so both ISAs hold
the same bar. deepseek-v4 tiny-check on ARM is follow-up (its ARCH
plumbing needs checking for aarch64 first).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kreuzzelg and others added 13 commits August 18, 2026 18:35
…n + Gated DeltaNet + streaming MoE

Self-contained engine for Qwen3.6-35B-A3B (35B total / 3B active, 256 experts
per layer), split out of PR JustVugg#602 per maintainer guidance (engine first, GPU
backends as follow-ups; no shared GLM/Inkling files touched):

- c/qwen36.c: 40-layer hybrid forward (10x Gated Attention with partial RoPE
  + per-head q/k RMSNorm; 30x Gated DeltaNet recurrent linear attention with
  carried conv ring + per-head state), streaming MoE with per-layer LRU expert
  cache, shared expert, router lookahead prefetch (PILOT), resident-expert
  pinning (COLIBRI_RESIDENT), per-phase timers (COLI_TIMERS=1).
- CPU performance work: optional per-row int8 quantization of the large dense
  matrices (COLI_DENSE_I8, on by default, f32 originals freed), hand-written
  AVX2/FMA int8 GEMV, DeltaNet recurrence parallelized per value head.
  Decode on a 12-core Zen2 + int4 container: ~2.5 tok/s CPU-only
  (vs 0.35 tok/s scalar baseline).
- c/tools/convert_qwen36.py: HF -> container converter (true int4 packing,
  per-row scales, --selftest). Containers now run out of the box: config.json
  is written flat (VL checkpoints nest dims under text_config) and
  tokenizer.json is bundled.
- tools/make_qwen36_tiny.py + make_qwen36_oracle.py: weight-free tiny model
  and oracle for engine validation without the 70 GB checkpoint.
- docs/qwen36-phase01.md, qwen36-phase02.md: design notes.

Numerics: logits cosine vs f32 reference 0.9992 with COLI_DENSE_I8=1,
bit-identical with COLI_DENSE_I8=0.

Co-authored-by: minne100 <xufenglu@hotmail.com>
Addresses the main blocker on JustVugg#712: the engine built clean and touched no
existing files, but coli could not reach it.

c/coli:
- model_arch(): return 'qwen' for any model_type containing 'qwen'
- engine_for(): resolve 'qwen36' binary
- need_model(): qwen36 build target
- chat banner model_id: qwen36-colibri

c/qwen36.c (SERVE=1 mode):
- implements the colibri gateway wire protocol (READY + STAT + SUBMIT/
  CANCEL + DATA + DONE + PROF), mirroring kimi_k3.c / inkling.c so
  'coli chat' / 'coli web' / 'coli serve' can drive the engine
- READY/STAT handshake, SUBMIT payload -> encode_text -> step() prefill +
  per-token step() decode, stream decoded bytes as DATA frames, stop on
  EOS (Qwen3 151645, Q36_EOS override), DONE/STAT on completion
- temperature + top-p sampler (ported from kimi_k3.c, qsort O(V log V))
- Windows binary-mode fix inlined (JustVugg#748 hang: CRT rewrites n->rn and the
  gateway never matches READY). Uses _setmode(_fileno(stdout/stdin),
  _O_BINARY) before the sentinel; switch to compat.h's
  coli_serve_binary_mode() once JustVugg#749 lands (helper not in base yet).
…CPU read path

convert_qwen36.py --gs N writes one f32 scale per N input elements per row
(scales [O, ceil(I/gs)], expert_gs in qwen36_meta.json) instead of one per
row. The engine reads expert_gs and dispatches expert GEMVs through
matmul_qe: per-row scales unchanged, grouped scales via matmul_q_gs.

Same trade as GLM's gs64 containers: ~9% more container bytes for scales
that track short rows (moe_intermediate_size=512) much more closely.
Validated on the tiny fixture: grouped beats per-row on cosine to the int8
anchor. Full-model A/B on the published containers is in the PR thread.
Mirrors docs/inkling.md's structure: pre-converted containers first
(gs64 recommended, per-row available), converter as the DIY route,
coli chat as the primary interface now that the engine speaks the serve
protocol. The container recommendation follows the GLM JustVugg#455 experience;
the per-row-vs-gs64 measurement backing it is in the PR thread.
main loaded the prompt (argv[3], default ref.json) before the SERVE
branch, so an engine launched the way coli launches it — SERVE=1 and no
prompt argument — exited 1 on the missing ref.json before ever emitting
READY. Serve gets its prompts over the wire; skip the argv prompt load
in serve mode. Found by running the serve handshake end-to-end.
…stant

serve_one compared against a hardcoded 151645 — <|im_end|> in the older
151k-token Qwen vocabs. Qwen3.6's vocab is 248320 tokens with the specials
at 248044+ (<|endoftext|> 248044, <|im_end|> 248046), so EOS never matched
and every serve turn ran to max_tok; in the worst case 151645 is an
ordinary BPE piece here and could truncate a completion mid-sentence.
Resolve <|im_end|> and <|endoftext|> from the tokenizer's added_tokens
(same spirit as kimi_k3's config-driven eos list); Q36_EOS still
overrides. Found by the serve-path termination check the A/B comment
called for.
openai_server.py knew glm/inkling/kimi only, so a qwen model fell to the
GLM renderer and the engine never saw its native frames. render_chat_qwen
mirrors Qwen3.6's chat_template byte for byte in the text-only subset
(inkling's approach): <|im_start|>role\n...<|im_end|>\n frames and a
generation prompt that OPENS the mandatory <think> block — the model was
never trained on a bare 'assistant\n' state, and greedy argmax there is
an EOS special (measured: gen=0 on the very first token). Thinking
disabled pre-closes the block, exactly like the official jinja.

Arch detection, --arch choice, renderer dispatch and the single-KV-slot
guard follow the kimi pattern. Spawn needs nothing new: argv is (cap),
bits defaults to 4, tokenizer comes from SNAP.
…d the stack

attention() kept one score per attended key in a fixed `float sc[8192]` on
the stack and filled it with `for (t = 0; t <= qpos; t++)`, so any position
past 8191 wrote off the end. serve_one() carried the same literal as the
Q36_MAXT default and refused longer requests, but generate() and tf_nll() set
max_t straight from the caller and never consulted it — and raising Q36_MAXT
moved the guard without moving the buffer, turning a refusal into a stack
smash.

Same bug and the same fix as JustVugg#110 in the MLA path (found again independently
in JustVugg#122): the score rows move to a per-thread heap buffer sized from max_t,
allocated in ensure_kv() where the other context-sized buffers already grow —
not per call, which would put a malloc in the hot parallel loop.

The capacity is now named once, QWEN36_ATTN_MAX_CTX (262144 =
max_position_embeddings), and enforced on all three paths that set max_t.
Q36_MAXT still defaults to a conservative 8192 because context costs 40 KB per
token here (10 attention layers, f32 KV; the 30 DeltaNet layers keep a
fixed-size recurrent state instead), but it can now be raised safely.

tests/test_qwen36_ctx.c gates it without a model file: KV layout, growth
across requests, per-thread score rows at twice the old buffer, and that
Q36_MAXT can lower the ceiling but never raise it past the capacity.

Greedy output is bit-identical before and after.
expert_get's last resort was 'if (lru < 0) lru = 0;' -- take slot 0 no matter
what. olmoe.c:471-486, which this cache derives from, deleted that exact line
and replaced it with wait-and-rescan, and its comment names the consequence:
the buffer is owned by an unlocked pread that will publish into it, so two
writers race the same slab and whichever publishes last decides which expert
the resident bytes answer to. Silently wrong weights, not a crash.

Reachable in ordinary use, not only on a malformed container: PILOT queues up
to 128 candidates per layer against cap slots, so any box small enough to run
a low cache/layer can hit it.

The wait-and-rescan loop is taken verbatim from olmoe, comment included -- the
point of the fix is that the correction stops going missing when this cache is
copied again.

Also guards cap >= 1 (one row of the A2 table). Without it the new loop would
wait for a publish that cannot come: cap=0 leaves every layer cache empty, so
no slot ever becomes evictable. The old fallback turned that same input into a
heap OOB via slots[0]. Neither is a failure mode to ship, and the guard belongs
with the change that introduces the wait.
B5 -- the engine shipped nowhere. ci.yml built four engines, release.yml built,
copied and asserted four; qwen36 was in none of them, so it was never compiled
on Windows/macOS/aarch64 and a tagged archive would have carried a coli that
dispatches to a missing binary. Third recurrence of JustVugg#720/JustVugg#858. Added at all
four sites: ENGINES, the release build loop, the dist/ copy, and SIBLINGS.

B5b -- a gate rather than a claim. qwen36-tiny-check generates the tiny
Qwen3.6-shaped fixture, converts it, and requires token-exactness at cache
capacities 1, 2 and 8. cap=1 evicts on every routed expert, which is where slot
bookkeeping breaks -- the inkling job uses the same staffel for the same reason,
and the in-flight eviction bug fixed in 4e79f0c is exactly that class.

The run repeats under ASan+UBSan with PILOT=1. Token-exactness alone would not
have caught the config-driven heap overflows in review section A: they do not
necessarily change the output.

Two things the wiring needed that the suggestion could not know:

- The reference comes from make_qwen36_tiny.py, not make_qwen36_oracle.py. The
  oracle encodes a text prompt via AutoTokenizer.from_pretrained(); this fixture
  is synthetic and has no tokenizer. The tiny script already holds the model, so
  it gained --ref-mode full -- its ref.json was attention_only only, which
  replaces the 30 DeltaNet layers with identity and would have left three
  quarters of the engine untested.

- The engine returned 0 regardless of the result in oracle mode, printing
  'Matching tokens: n/m' and exiting green. inkling.c returns
  (match == ngen) ? 0 : 1 and its CI job depends on it. Without this the whole
  gate could not fail.

Verified locally end to end: 16/16 at cap 1, 2 and 8; ASan+UBSan clean.
A malformed-container case belongs here once A1/A2 land -- noted as a TODO,
since a well-formed fixture cannot reproduce what ASan found.
The job went green on one push and red on the next with byte-identical
trees. Two causes, both mine:

1. make_qwen36_tiny.py set no seed, so every run built a different random
   model -- and with a random model the logit margins are random too. Three
   local draws passed; one CI draw failed at 11/16. A gate that reddens on
   the luck of the draw gets muted within a week. Seeded now.

2. The comparison was between two different models. The engine quantizes
   DENSE weights to int8 by default (COLI_DENSE_I8); the torch reference uses
   full precision. Measured: that alone costs 7 of 16 tokens, deterministically
   and identically at cache capacity 1, 2 and 8 -- which also proves it is not
   an eviction bug. The expert container at ebits=8 was never the problem.
   The runs now set COLI_DENSE_I8=0, which is what the accuracy A/B in
   07_Tests/UserTest has always done, for exactly this reason.

I first built a --quantize-experts round-trip for the reference and threw it
away: it did not move the number, because the experts were not the cause.

Verified end to end with the seeded fixture: 16/16 at all three capacities,
exit codes 0, and the ASan+UBSan run clean.
…uses it (A1+A2)

Both config.json and qwen36_meta.json ship INSIDE the container, so a
mismatched or hostile pair is a supply-chain input rather than a programmer
error -- and the repo just spent six advisories removing this bug class.

A1: load_cfg sized is_attn from config.json's layer count, load_meta then
overwrote n_layers and wrote is_attn up to the new one. Reproduced here
without the guard: heap-buffer-overflow, WRITE of size 1, load_meta
qwen36.c:959 -- the same signature as the review. A container whose two files
disagree is broken, so it is refused rather than papered over by reallocating.

A2: one guarded expression per dimension, following kimi_k3.c. The ceilings
are not arbitrary -- each names the fixed buffer that sets it:
  keep[1024] in moe()       -> num_experts <= 1024
  idx/val[256] in moe()     -> topk        <= 256
  kvl/dl[512] in deltanet() -> dn_vdim     <= 512  (inside an OpenMP region)
plus dn_kheads > 0 (rep = vh/vk divides), dn_convk >= 2 (the ring is convk-1),
dn_conv_dim == 2*kheads*kdim + vheads*vdim, q_heads a multiple of kv_heads,
and positivity for the rest. Each was verified to refuse:

  num_experts=2048  topk=512  dn_vdim=1024  dn_kheads=0
  dn_convk=1        dn_conv_dim=999         n_layers=0

The malformed-container case gets its own CI step, asserting the REFUSAL and
its reason -- a well-formed fixture cannot reproduce it, and a guard that
silently stops guarding would otherwise go unnoticed. The real fixture still
matches its oracle 16/16.
… (A3+A4)

A3: load_t allocated whatever st_numel reported, while every read afterwards
used CONFIG dims -- so a short tensor was a plain heap OOB read, and embed is
indexed as m->embed + ids[s]*D. Now every dense tensor states the element
count the forward pass will index with, the way kimi_k3.c's f32_load does and
the way this engine already treated experts. Verified: a container whose meta
doubles 'hidden' is refused with

  model.embed_tokens.weight: 20480 elements, config implies 40960 -- refusing

A4: token ids were never bounds-checked before the gather. They arrive from
three places -- the tokenizer, a serve request, and the engine's own sampler --
one of which is remote. An id of 999999 against a 320-token vocabulary now
refuses instead of reading off the end.

Also, from the NEON note on the PR: IDOT was default-ON here. That path
quantizes the ACTIVATIONS per 16-element block, which the scalar path does
not, so the two are not numerically equivalent -- exactly what cost olmoe its
token-exactness in JustVugg#1044 and was fixed in af48fe8 by making IDOT opt-in.
qwen36 inherited the same default from the same family of kernels, and the
tiny-oracle gate could not have caught it: that job runs on x86. Now opt-in.

No regression: the fixture still matches its oracle 16/16 at cap 1 and 8, and
the context gate is green.
- make qwen36 CUDA=1 no longer compiles -DCOLI_CUDA and links -lcudart for a
  file that contains no CUDA. NOCUDA_CFLAGS, same shape as olmoe; verified the
  binary no longer links cudart in either build mode. (JustVugg#713 adds real CUDA
  sources and takes the flags back.)
- serve_one now resets first_step, seen and momentum_logits. They are
  per-REQUEST state that was only ever initialised per process, so
  COLIBRI_RESIDENT=1 collected on the server's first request and never again,
  and the router EMA carried one conversation's history into the next.
- --ram is documented as unsupported rather than silently ignored: this engine
  requires full RAM residency for the expert set, so there is no budget to
  size. A flag that looks like it works and does not is worse than one that
  says so.

No regression: fixture still 16/16, context gate green.
@kreuzzelg

Copy link
Copy Markdown
Contributor Author

B3 and the rebase are done, plus the rest of list C. Head is e755280 on current dev, 22/22 green, mergeable.

Including — and I did not expect this when I wrote that note — ARM (engines + NEON kernel exactness + tiny oracle), your #1083 job, which compiles qwen36.c on ARM for the first time and passes. The branch whose default we fixed is now built by the job that finding produced. Thank you for turning it into a class fix rather than a one-line patch on my file; the x = −128 contract you found on day one is the better half of that story.

Everything in the PR, against your review

Item Status How
A1 is_attn sized from config.json, written from meta Reproduced your ASan signature first (heap-buffer-overflow, WRITE of size 1, load_meta:959), then refused: two files in one container disagreeing on the layer count is a broken container, not something to reallocate around
A2 7 unvalidated-config OOB sites One guard per dimension, kimi_k3.c shape. Each ceiling names the buffer that sets it. All seven verified to refuse: num_experts=2048, topk=512, dn_vdim=1024, dn_kheads=0, dn_convk=1, dn_conv_dim=999, n_layers=0
A3 dense tensor shapes unchecked Every dense tensor states the element count the forward pass indexes with, like f32_load
A4 token ids unchecked before the gather Bounds-checked; ids arrive from tokenizer, serve request and sampler — one of them remote
A5 expert_get fallback regression Wait-and-rescan verbatim from olmoe.c. Instrumented the old path first: at cap=2 it never fires, at cap=1 it does. Plus cap >= 1, or the new wait spins on the input that used to walk off slots[0]
B1 every chat request 400s FamilyLimits(8192, 262144, 1024, 8192, 1, 8, "Q36_MAXT") — the registry answered this while I was writing the question
B2 coli build KeyError build_target in the descriptor
B3 response_format desyncs the session capabilities.grammar_payload=False; the hardcoded tuple is gone from dev entirely
B4 no dispatch test coverage Tests iterate FAMILIES
B5 engine ships nowhere ENGINES, release build loop, dist/, SIBLINGS, make install — the registry coverage test enforces all five once the family is registered, and it is what caught the missing make install
B5b the gate you asked for qwen36-tiny-check: token-exact at cache capacities 1/2/8, then ASan+UBSan with PILOT=1, plus a malformed-container step asserting the refusal and its reason
C coli run → GLM template has_cli_adapter=False. cmd_run still dispatches per arch after that gate, so claiming an adapter we do not have would reinstate exactly the bug. The user gets "use coli chat or coli serve", which is true
C cmd_stop, /v1/models, namespace over-claim process_names, default_model_id, exact model_typesqwen2/qwen3_moe/my_qwen_model no longer route here, asserted by the registry test
C make install Added
C make qwen36 CUDA=1 links -lcudart NOCUDA_CFLAGS, olmoe's shape; binary no longer links cudart in either mode
C first_step/seen/momentum_logits Reset per request. They were per-process, so COLIBRI_RESIDENT=1 collected on request #1 and never again, and the router EMA carried one conversation into the next
C --ram silently ignored Documented as unsupported: this engine needs full RAM residency, so there is no budget to size
C g4/u4/d4 unread, +50% RSS Moved to #713 as you suggested — that is where they are read
C tests/test_qwen36_ctx never runs Does run: TEST_RULES (c/Makefile:370) derives gates from build rules
D resource_plan.py plans 0 bytes KV _qwen36_geometry — two terms, because only 10 of 40 layers hold a KV cache and the other 30 carry a fixed-size recurrent state. Your QWEN36_FIXTURE test specified it before I wrote it
D no top_k in serve_sample Left in D on purpose: top_k is declared unsupported repo-wide (openai_server.py, Anthropic endpoint). Teaching it to qwen36 alone would make this engine inconsistent with every other and with the documented API
D telemetry, KV prefix reuse, CANCEL, docs links Follow-ups after merge

Not on your list, found on the way

Item Why it matters
8192 stack overflow float sc[8192] on the stack with Q36_MAXT as a second literal; raising the env moved the guard and not the buffer. Same class and fix as #110/#122
IDOT default-on The one that became #1080/#1081/#1083
Oracle mode returned 0 on mismatch The tiny gate could not have failed. Now match == n_new ? 0 : 1, as inkling.c does
make_qwen36_tiny.py emitted attention_only refs only A gate built on that reference would never touch 30 of 40 layers. --ref-mode full added
The gate compared two different models It went green then red on identical trees. The engine quantizes dense weights to int8 by default, the torch reference does not — 7 of 16 tokens, deterministic and identical at every cache capacity, which is also what ruled out an eviction bug. COLI_DENSE_I8=0 now, and the fixture is seeded: unseeded it passed three local draws and failed one in CI

Remaining from #1040: cuda_binary() checks GLM unconditionally, so --gpu auto fails for every sibling engine with CUDA linked. @sehHeiden found it; it is not qwen36-specific and I have not touched it here — say the word if you would rather have it in this PR than as its own.

Review list C: g4/u4/d4 were malloc'd and filled on every int4 expert load and
read nowhere in this PR -- +50% expert-cache RSS on the recommended gs64
container, for nothing. They are the upload source for the CUDA expert tier and
come back with it in JustVugg#713, which is where qt_note() and stage() actually read
them.

I had reported this as done in the summary table before doing it. It was not;
the buffers were still allocated. Corrected here and in a follow-up comment.

Verified: the real gs64 container still loads and unpacks int4 to int8 for the
CPU path, the fixture still matches its oracle 16/16 at cap 1 and 8, context
gate green.
@kreuzzelg

Copy link
Copy Markdown
Contributor Author

Correction to my summary table: I marked g4/u4/d4 as "moved to #713" when they were still allocated here. That was a status I reported before doing the work, not a decision — my mistake, and thank you for a list precise enough that re-reading it caught me.

Done properly now in 67c7490: the packed int4 buffers are gone from this engine. It only ever read the unpacked int8 copy, so keeping them doubled expert-cache RSS on the very container the docs recommend. #713 restores them, which is where qt_note() and stage() actually read them — I verified that before removing rather than assuming it.

The gs64 container still loads and unpacks int4 to int8 for the CPU path; fixture still 16/16 at cap 1 and 8; suite green.

That closes list C apart from cuda_binary(), which is @sehHeiden's find and not qwen36-specific.

- docs/qwen36-cuda-tier.md is JustVugg#713's file and does not exist in this PR; the
  link pointed at nothing (review list D). Now points at the PR and says so.
- the engine is no longer selected by 'model_type contains qwen'. Since
  fe3879c it is an exact match against the family registry
  (qwen3_5_moe / qwen3_5_moe_text), which is what stops this engine claiming
  qwen2_moe, qwen3_moe and every Qwen finetune.
@kreuzzelg

Copy link
Copy Markdown
Contributor Author

Where to get the model

Three pre-converted containers, all self-contained (weights + tokenizer.json + meta), Apache 2.0 like the upstream checkpoint:

Container Format Size Use
Kreuzzelg/qwen36-35b-a3b-colibri-i4-gs64 int4, one scale per 64-weight group ~20 GB recommended
Kreuzzelg/qwen36-35b-a3b-colibri-i4 int4, one scale per row ~20 GB the A/B baseline
Kreuzzelg/kat-coder-v2.5-dev-colibri-i4-gs64 int4-gs64 22 GB second model, same engine unchanged
hf download Kreuzzelg/qwen36-35b-a3b-colibri-i4-gs64 --local-dir ~/Models/qwen36_i4_gs64
make -C c qwen36
COLI_MODEL=~/Models/qwen36_i4_gs64 ./c/coli chat

Or convert the bf16 checkpoint yourself (~70 GB download):

python3 c/tools/convert_qwen36.py --repo Qwen/Qwen3.6-35B-A3B --out ~/Models/qwen36_i4_gs64 --gs 64

Take gs64 unless you are reproducing an A/B. Controlled measurement on this model: cosine to the int8 anchor 0.98777 → 0.99313 and KL 0.109 → 0.080, i.e. ~44% less quantization error. The reason is moe_intermediate_size=512 — Qwen's expert rows are short, so per-row error concentrates the way it did on GLM in #455.

One caveat worth stating, because it cuts the other way on small cards: gs64 costs 1.69 MB/expert against per-row's 1.52, so the same VRAM budget holds ~10% fewer experts. On 8 GB that still came out ahead warm (11.55 vs 10.90 tok/s on a 3070) because accuracy outweighed the residency loss, but cold was ~8% worse. @sehHeiden measured the per-row container on a 6 GB 1660 Ti in #1040; whether the trade flips at that size is genuinely open, and the number to watch there is the [qtier] VRAM hit rate line rather than tok/s alone.

The KAT-Coder container is there because the engine takes any architecture-identical checkpoint unchanged — same config geometry, no code path of its own. It is a dev checkpoint, not a flagship; treat its outputs accordingly.

Also pushed just now (7e226fc), two stale references in docs/qwen36.md that this made me re-read:

  • it linked docs/qwen36-cuda-tier.md, which is feat(qwen36): CUDA VRAM expert tier — heat-based placement across GPUs via the shared CUDA backend #713's file and does not exist in this PR — that was the docs item in your list D;
  • it still said the engine is picked because model_type contains qwen. Since fe3879c it is an exact match against the registry (qwen3_5_moe / qwen3_5_moe_text), which is precisely what stops this engine claiming qwen2_moe, qwen3_moe and every Qwen finetune — your list C item. The prose was describing the bug, not the fix.

…tokens

It went red on a docs-only commit: the three normal runs matched 16/16, the
sanitizer run did not. Same source, different binary -- -fsanitize changes
inlining and vectorization, so float reassociation can flip a token wherever
the margin is thin, and a random-init fixture has thin margins by nature. Not
reproducible locally, which is itself the tell: it depends on how a particular
compiler builds a particular binary, not on the engine.

That was my design error. Memory safety and token-exactness are two goals and
this step was asserting both; the three runs above already own exactness, and
they use the binary users actually get. The sanitizer step now runs the same
path under ASan+UBSan with PILOT=1, ignores a token mismatch, and fails on any
sanitizer diagnostic -- verified that the pattern catches the real
heap-buffer-overflow this engine had before the A1 guard.
@JustVugg
JustVugg merged commit 6784aa8 into JustVugg:dev Aug 18, 2026
22 checks passed
@JustVugg

Copy link
Copy Markdown
Owner

Merged. Three weeks, the longest review in this repo's history, and it shows in what landed: an engine that refused to ship its own bugs — the A2 guards each verified to refuse, the oracle that could never fail fixed so it can, the gate that compared two different models caught and seeded, the IDOT default that became a repo-wide fix (#1080) and a new ARM CI job (#1083), and an RSS doubling caught by re-reading your own status table. colibrì's sixth engine, and the bar it was held to is now the bar.

Follow-ups, so nothing on the list gets lost:

The three HF containers in your comment are exactly what the release notes will point at. Thank you — this is what "model support" should mean.

This was referenced Aug 19, 2026
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.

7 participants