Skip to content

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

Open
kreuzzelg wants to merge 7 commits into
JustVugg:devfrom
kreuzzelg:qwen36-engine
Open

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

Conversation

@kreuzzelg

@kreuzzelg kreuzzelg commented Jul 30, 2026

Copy link
Copy Markdown

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

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

@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 qwen β†’ qwen36 (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
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
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
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
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
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
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 and others added 7 commits August 3, 2026 21:16
…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.
@kreuzzelg

Copy link
Copy Markdown
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.

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.

6 participants