Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fc52d73
feat(qwen36): add Qwen3.6-35B-A3B engine (CPU): hybrid Gated Attentio…
kreuzzelg Jul 30, 2026
9c901b5
feat(qwen36): make engine drivable by coli + SERVE=1 gateway protocol
minne100 Aug 1, 2026
5a0715e
feat(qwen36): group-scaled int4 (gs64) containers — converter flag + …
kreuzzelg Aug 1, 2026
24d3c89
docs(qwen36): user-facing page — prebuilt containers as the easy path
kreuzzelg Aug 3, 2026
2ff73e0
fix(qwen36): serve mode must not require an argv prompt file
kreuzzelg Aug 3, 2026
342f104
fix(qwen36): serve EOS ids from the tokenizer, not the 151k-vocab con…
kreuzzelg Aug 3, 2026
6a9f293
feat(qwen36): qwen chat-template family in the gateway
kreuzzelg Aug 3, 2026
c30c545
fix(qwen36): heap per-thread attention score rows — 8k context smashe…
kreuzzelg Aug 13, 2026
3252675
fix(qwen36): expert_get must not evict an in-flight slot (A5)
kreuzzelg Aug 16, 2026
9ae0c8a
ci: build and ship qwen36, and gate it against a tiny oracle (B5 + B5b)
kreuzzelg Aug 17, 2026
e84005e
ci(qwen36-tiny-check): make the gate deterministic and actually exact
kreuzzelg Aug 17, 2026
4e0dc88
fix(qwen36): validate every config dimension before the forward pass …
kreuzzelg Aug 17, 2026
ca96e12
fix(qwen36): refuse a container whose tensors do not match the config…
kreuzzelg Aug 17, 2026
e755280
qwen36: the remaining cheap items from the review's C list
kreuzzelg Aug 18, 2026
67c7490
qwen36: drop the packed int4 buffers this engine never reads
kreuzzelg Aug 18, 2026
7e226fc
docs(qwen36): fix two stale references
kreuzzelg Aug 18, 2026
046842e
ci(qwen36-tiny-check): the sanitizer step asserts memory safety, not …
kreuzzelg Aug 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ jobs:
run: |
cd c
rc=0
ENGINES="colibri inkling kimi_k3 olmoe"
ENGINES="colibri inkling kimi_k3 olmoe qwen36"
if [ "${{ matrix.v4 }}" = "1" ]; then ENGINES="$ENGINES deepseek-v4"; fi
for t in $ENGINES; do
echo "::group::$t"
Expand Down Expand Up @@ -317,6 +317,111 @@ jobs:
-Xcompiler=-Wall,-Wextra
echo "inkling CUDA syntax check passed"

qwen36-tiny-check:
name: Qwen3.6 tiny oracle (token-exact + ASan/UBSan)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: pip
cache-dependency-path: c/tools/oracle-requirements.txt
- name: Install torch (CPU) + transformers
run: pip install -r c/tools/oracle-requirements.txt
- name: Tiny Qwen3.6-shaped fixture + full-hybrid oracle
run: |
cd c
# Same layout as the 35B model (10 x (3 x DeltaNet -> MoE, 1 x Attention
# -> MoE)) at toy dimensions, so the converter and the engine treat it
# exactly like the real one. --mode full exercises BOTH layer kinds;
# attention_only would leave the DeltaNet path untested.
# The reference comes from make_qwen36_tiny.py, not make_qwen36_oracle.py:
# the oracle encodes a text prompt through AutoTokenizer.from_pretrained(),
# and this fixture is synthetic -- weights, no tokenizer. The tiny script
# already has the model in memory and generates greedily from fixed ids.
# --ref-mode full leaves BOTH layer kinds active; the default
# attention_only replaces the 30 DeltaNet layers with identity, which is
# what Phase 1 computed and would leave three quarters of the engine
# untested.
python3 tools/make_qwen36_tiny.py --out qwen36_tiny --ref-mode full \
--emit-ref qwen36_tiny/ref_full.json
# ebits=8 keeps the expert quantization error below anything that could
# flip a greedy argmax here -- measured. The DENSE weights are the ones
# that matter: the engine quantizes them to int8 by default, the torch
# reference does not, and that alone cost 7 of 16 tokens. The runs below
# therefore set COLI_DENSE_I8=0, which is what the accuracy A/B in
# 07_Tests uses for the same reason. Without it this gate compares two
# different models and reddens on the luck of the draw.
python3 tools/convert_qwen36.py --model qwen36_tiny --out qwen36_tiny_c --ebits 8
- name: Token-exact against the oracle, at several cache capacities
run: |
cd c
make qwen36
# cap=1 evicts on every routed expert, which is where slot bookkeeping
# breaks; cap=8 never would. That is not hypothetical here: the
# in-flight eviction fallback this engine inherited was exactly such a
# bug, and it only shows when cap is smaller than the number of loads
# in flight. The engine exits non-zero on any token mismatch.
for cap in 1 2 8; do
echo "::group::cap=$cap"
COLI_DENSE_I8=0 SNAP=qwen36_tiny_c \
./qwen36 "$cap" 8 qwen36_tiny/ref_full.json
echo "::endgroup::"
done
- name: Same run under ASan + UBSan
run: |
cd c
# Token-exactness alone would not have caught the config-driven heap
# overflows this engine shipped with: they do not necessarily change
# the output. The sanitizers are the half of this gate that watches
# memory, and PILOT=1 puts concurrent expert loads against the cache
# so the slot paths are exercised, not just walked past.
#
# This step asserts MEMORY SAFETY, not token-exactness -- the three
# runs above already own that. -fsanitize changes inlining and
# vectorization, so this is a differently-compiled binary, and float
# reassociation can flip a token wherever the margin is thin. Demanding
# exactness from it conflates two goals and made this job red on a
# docs-only commit: the normal build matched 16/16, the sanitizer build
# did not. So: run it, ignore a token mismatch, fail on any sanitizer
# diagnostic.
make clean >/dev/null 2>&1 || true
make qwen36 EXTRA_CFLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer -g"
ASAN_OPTIONS=detect_leaks=0 UBSAN_OPTIONS=print_stacktrace=1 \
COLI_DENSE_I8=0 SNAP=qwen36_tiny_c PILOT=1 WIDE=2 \
./qwen36 1 8 qwen36_tiny/ref_full.json > san.log 2>&1 || true
if grep -qE "ERROR: AddressSanitizer|runtime error:" san.log; then
echo "FAIL: sanitizer diagnostic under PILOT"; cat san.log; exit 1
fi
echo "sanitizers clean:"; grep -E "Matching tokens" san.log || true
- name: A malformed container is refused, not read
run: |
cd c
# The case ASan caught in review: config.json and qwen36_meta.json ship
# in the same container and disagreed on the layer count, so is_attn was
# sized from one and written from the other. Reproduced without the
# guard as a heap-buffer-overflow WRITE at load_meta; with it the engine
# refuses. A well-formed fixture cannot cover this, which is why it gets
# its own step -- and it asserts the REFUSAL, so a guard that silently
# stops guarding fails here.
cp -r qwen36_tiny_c qwen36_tiny_bad
python3 - <<'PY'
import json
p = "qwen36_tiny_bad/config.json"
cfg = json.load(open(p))
cfg["num_hidden_layers"] = 4 # meta says 8
json.dump(cfg, open(p, "w"))
PY
if COLI_DENSE_I8=0 SNAP=qwen36_tiny_bad ./qwen36 8 8 \
qwen36_tiny/ref_full.json > bad.log 2>&1; then
echo "FAIL: engine accepted a container whose two config files disagree"
cat bad.log; exit 1
fi
grep -q "config.json says 4 layers" bad.log || {
echo "FAIL: refused, but not for the reason under test"; cat bad.log; exit 1; }
echo "refused as expected:"; grep '^\[cfg\]' bad.log

inkling-oracle:
name: Inkling oracle (token-exact vs transformers)
runs-on: ubuntu-latest
Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ jobs:
- name: Build engines
run: |
cd c
for t in colibri inkling kimi_k3 olmoe; do
for t in colibri inkling kimi_k3 olmoe qwen36; do
make $t ${{ matrix.make_args }}
done
ls -lh colibri${{ matrix.ext }} inkling${{ matrix.ext }} kimi_k3${{ matrix.ext }} olmoe${{ matrix.ext }}
ls -lh colibri${{ matrix.ext }} inkling${{ matrix.ext }} kimi_k3${{ matrix.ext }} olmoe${{ matrix.ext }} qwen36${{ matrix.ext }}

# DeepSeek V4 has its own Makefile and its own target name (`deepseek-v4`,
# producing `deepseek_v4`), so it never joined the loop above -- and #858 is
Expand Down Expand Up @@ -120,6 +120,10 @@ jobs:
cp c/inkling${{ matrix.ext }} dist/inkling${{ matrix.ext }}
cp c/kimi_k3${{ matrix.ext }} dist/kimi_k3${{ matrix.ext }}
cp c/olmoe${{ matrix.ext }} dist/olmoe${{ matrix.ext }}
# qwen36 -- plain name, like the others: engine_for() resolves "qwen" to
# a "qwen36" binary next to coli. Unconditional: the engine is portable C
# with no platform gate, so every archive can carry it.
cp c/qwen36${{ matrix.ext }} dist/qwen36${{ matrix.ext }}
# deepseek_v4 -- underscore, not the hyphen of the make target: engine_for()
# looks for "deepseek_v4" next to coli. Guarded, not unconditional, because
# the macos-arm64 archive has no such engine to ship (#858).
Expand Down Expand Up @@ -168,7 +172,7 @@ jobs:
# a green build, green tests, and an archive with no engine for the model the
# user actually had. Presence is not enough -- assert they are executable, and
# that the launcher's own resolver finds them where it looks (next to coli).
SIBLINGS="inkling kimi_k3 olmoe"
SIBLINGS="inkling kimi_k3 olmoe qwen36"
# deepseek_v4 only where the engine builds (COLI_V4_SUPPORTED); asserting it
# unconditionally would fail the macos-arm64 archive for shipping an engine
# that platform cannot have.
Expand Down
16 changes: 16 additions & 0 deletions c/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -876,6 +876,21 @@ fp8-bench: fp8_bench$(EXE)
olmoe$(EXE): olmoe.c st.h json.h compat.h sample.h tok.h tok_unicode.h tok_unicode_o200k.h omp_tune.h route_trace.h serve_codec.h
$(CC) $(NOCUDA_CFLAGS) olmoe.c -o olmoe$(EXE) $(NOCUDA_LDFLAGS)

# Qwen3.6-35B-A3B engine (hybrid Gated Attention + Gated DeltaNet + streaming
# MoE). CPU-only in this target; the optional CUDA expert tier is a separate
# follow-up (see docs/qwen36-phase01.md).
# NOCUDA_*: this file contains no CUDA, so building it with -DCOLI_CUDA and
# linking -lcudart only made `make qwen36 CUDA=1` depend on a toolkit it
# never calls. Same shape as olmoe. (The CUDA expert tier is #713, which
# adds its own sources and takes the CUDA flags back.)
qwen36$(EXE): qwen36.c st.h json.h compat.h
$(CC) $(NOCUDA_CFLAGS) qwen36.c -o qwen36$(EXE) $(NOCUDA_LDFLAGS)

# Context-size gates: KV layout, growth across requests, and the attention
# capacity. Includes qwen36.c directly, so no model file is needed.
tests/test_qwen36_ctx$(EXE): tests/test_qwen36_ctx.c qwen36.c st.h json.h compat.h
$(CC) $(CFLAGS) $< -o $@ $(LDFLAGS)

inkling$(EXE): inkling.c st.h json.h compat.h $(INK_CUDA_OBJ) $(METAL_OBJ)
$(CC) $(CFLAGS) inkling.c $(INK_CUDA_OBJ) $(METAL_OBJ) -o inkling$(EXE) $(LDFLAGS)

Expand Down Expand Up @@ -1299,6 +1314,7 @@ install: colibri$(EXE) inkling$(EXE) kimi_k3$(EXE) olmoe$(EXE) \
$(INSTALL) -m 755 inkling$(EXE) $(DESTDIR)$(LIBEXECDIR)/inkling$(EXE)
$(INSTALL) -m 755 kimi_k3$(EXE) $(DESTDIR)$(LIBEXECDIR)/kimi_k3$(EXE)
$(INSTALL) -m 755 olmoe$(EXE) $(DESTDIR)$(LIBEXECDIR)/olmoe$(EXE)
$(INSTALL) -m 755 qwen36$(EXE) $(DESTDIR)$(LIBEXECDIR)/qwen36$(EXE)
@if [ -f deepseek_v4$(EXE) ]; then \
$(INSTALL) -m 755 deepseek_v4$(EXE) $(DESTDIR)$(LIBEXECDIR)/deepseek_v4$(EXE); \
fi
Expand Down
54 changes: 54 additions & 0 deletions c/family_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,29 @@ def _glm_geometry(config, context, _model_dir):
return PlannerGeometry(state, 0, workspace, experts)


def _qwen36_geometry(config, context, _model_dir):
"""Hybrid: only the full_attention layers hold a KV cache; the linear
(DeltaNet) layers carry a recurrent state whose size does not depend on the
context at all. One scaled context term would over-promise on a model where
30 of 40 layers never grow."""
layers = _required_int(config, "num_hidden_layers", "qwen36")
kinds = config.get("layer_types")
if not isinstance(kinds, list) or len(kinds) != layers:
raise ValueError("qwen36: missing or invalid planning key 'layer_types'")
full = sum(kind == "full_attention" for kind in kinds)
kv = (full * context * _required_int(config, "num_key_value_heads", "qwen36") *
_required_int(config, "head_dim", "qwen36") * 2 * 4)
key_heads = _required_int(config, "linear_num_key_heads", "qwen36")
key_dim = _required_int(config, "linear_key_head_dim", "qwen36")
value_heads = _required_int(config, "linear_num_value_heads", "qwen36")
value_dim = _required_int(config, "linear_value_head_dim", "qwen36")
conv_k = _required_int(config, "linear_conv_kernel_dim", "qwen36", 2)
conv_dim = key_heads * key_dim * 2 + value_heads * value_dim
fixed = (layers - full) * (value_heads * key_dim * value_dim +
conv_dim * (conv_k - 1)) * 4
return PlannerGeometry(kv, fixed, 0, _required_int(config, "num_experts", "qwen36"))


_GLM_EXPERT = re.compile(
r"(?:^|\.)model\.layers\.(\d+)\.mlp\.experts\.(\d+)\."
)
Expand Down Expand Up @@ -263,6 +286,37 @@ def _inkling_expert_inventory(name, size, config):
has_cli_adapter=True,
tune_prompt_template="<|user|>\n{prompt}\n<|assistant|>\n",
),
FamilyDescriptor(
id="qwen36",
model_types=("qwen3_5_moe", "qwen3_5_moe_text"),
display_name="Qwen3.6-35B-A3B",
display_scale="35B",
engine_artifact="qwen36",
engine_aliases=(),
engine_group="qwen36",
internal_arch="qwen36",
build_target="qwen36",
process_names=("qwen36",),
default_model_id="qwen3.6-colibri",
cli_adapter="qwen36",
gateway_adapter="qwen36",
planner_id="qwen36_hybrid",
planner_geometry=_qwen36_geometry,
planner_unsupported_reason="",
expert_inventory=_individual_expert_inventory(_GLM_EXPERT),
config_section="text_config",
limits=FamilyLimits(8192, 262144, 1024, 8192, 1, 8, "Q36_MAXT"),
capabilities=FamilyCapabilities(False, False, False, True),
has_gateway_adapter=True,
# coli run stays unwired on purpose: cmd_run dispatches per arch after
# this gate, and without a qwen36 branch the engine would inherit GLM's
# prompt template -- a wrong template does not fail loudly, it degrades
# the answer. False gives the user "use coli chat or coli serve", which
# is true and actionable; chat/serve/web all work through the gateway.
has_cli_adapter=False,
tune_prompt_template=(
"<|im_start|>user\n{prompt}<|im_end|>\n<|im_start|>assistant\n<think>\n"),
),
FamilyDescriptor(
id="deepseek_v4",
model_types=("deepseek_v4",),
Expand Down
35 changes: 34 additions & 1 deletion c/openai_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,8 @@ def _tool_hold():
return max(len(m) for m in _tool_stream_markers()) - 1


ARCH = "glm" # set in main(): glm | inkling | kimi | deepseek_v4
ARCH = "glm" # set in main(): a family id from family_registry (glm | inkling |
# kimi | olmoe | qwen36 | deepseek_v4)

INK_THINK, INK_TEXT = "<|content_thinking|>", "<|content_text|>"

Expand Down Expand Up @@ -917,6 +918,37 @@ def render_chat_olmoe(messages, enable_thinking=False, reasoning_effort=None, to
return "".join(parts)


def render_chat_qwen(messages, enable_thinking=False, reasoning_effort=None, tools=None,
tool_choice=None):
"""Text-only subset of Qwen3.6's chat_template: <|im_start|>role\\n ...
<|im_end|>\\n frames, then the generation prompt. The official template
opens a mandatory <think> block after `<|im_start|>assistant\\n` — the
model was never trained on the bare `assistant\\n` state, and greedy
argmax there lands on an EOS special (measured: gen=0). With thinking
disabled the template pre-closes the block instead; both branches are
mirrored here byte for byte."""
if not isinstance(messages, list) or not messages:
raise APIError(400, "`messages` must be a non-empty array.", "messages")
if tools or tool_choice not in (None, "none"):
raise APIError(400, "Tool use is not wired up for the qwen36 engine yet.",
"tools", "unsupported_parameter")
parts = []
for index, message in enumerate(messages):
if not isinstance(message, dict):
raise APIError(400, "Each message must be an object.", f"messages.{index}")
role = message.get("role")
if role == "developer":
role = "system"
if role not in ("system", "user", "assistant"):
raise APIError(400, f"Unsupported role {role!r}.", f"messages.{index}.role")
raw = message.get("content")
text = content_text(raw, f"messages.{index}.content") if raw is not None else ""
parts.append(f"<|im_start|>{role}\n{text}<|im_end|>\n")
parts.append("<|im_start|>assistant\n")
parts.append("<think>\n" if enable_thinking else "<think>\n\n</think>\n\n")
return "".join(parts)


def render_chat_inkling(messages, enable_thinking=False, reasoning_effort=None, tools=None,
tool_choice=None, audio_out=None):
"""Text-only subset of Inkling's chat_template.jinja: role tokens with
Expand Down Expand Up @@ -1092,6 +1124,7 @@ def render_chat_for_arch(messages, enable_thinking=False, reasoning_effort=None,
return render_chat_inkling(messages, enable_thinking, reasoning_effort, tools,
tool_choice, audio_out=audio_out)
renderer = (render_chat_kimi if ARCH == "kimi" else
render_chat_qwen if ARCH == "qwen36" else
render_chat_v4 if ARCH == "deepseek_v4" else
render_chat_olmoe if ARCH == "olmoe" else render_chat)
return renderer(messages, enable_thinking, reasoning_effort, tools, tool_choice)
Expand Down
Loading
Loading