Skip to content

batch: #2324-#2329, #2331, #2334, #2335 and eight external PRs - #2330

Merged
JustVugg merged 59 commits into
devfrom
batch/kv-oom-2026-10-23
Oct 24, 2026
Merged

JustVugg merged 59 commits into
devfrom
batch/kv-oom-2026-10-23

Conversation

@JustVugg

@JustVugg JustVugg commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

One CI for eighteen PRs (one CI at a time): this branch is dev with each of them merged as it is, in this order. When it is green this PR is merged with its merge commit, which brings every one of them into dev.

Ours:

External:

Checked before this CI, on the merge: every engine builds with VK=1 with no warnings; make check passes (gcc 15); on Lavapipe the decide, mux and qwen chain families of vulkan_engines.sh pass (the qwen chain family up to its GGUF gates, which need gguf-py on that box); on an RTX PRO 4000 the CUDA chain's own check, the float16 serve gates and the external PRs' measurements:

Stamina9 and others added 24 commits October 22, 2026 14:35
Added details about legacy prompt handling and token ID requirements for the GLM engine.
On the CPU the MTP head scored all 248,320 tokens of the vocabulary for
every draft.

Now it scores a list: the 16000 most used tokens (Q38_MTP_VOCAB), the tokens
almost as frequent as the 16000th, and the tokens of the conversation.
Q38_MTP_VOCAB=0 scores the whole vocabulary as before. The token counts
behind the list are saved to a file in the model directory (COLI_VOCAB_USAGE
sets another file) and read by the next run. The head keeps scoring the whole
vocabulary where it has another way to do it: on the dense chain the device
scores it, and Q38_DRAFT_HEAD=int4, Q38_MTP_DUMP and a counts file that is
the expert history's file all turn the list off.

This change adds draft_vocab.h (the counts, the list and the counts file) and
helpers in own_file.h to write a state file as a whole and to compare two
paths. The draft paths of the head in qwen38_core.h return the draft instead
of its logits and take it from q38_mtp_best_token, which scores the listed
rows with the new matmul_q_idot_rows in idot.h. The lm_head keeps its int8
rows in host RAM when COLI_VK_DENSE_HOST releases the host copies, because
the CPU scores the list from them, and the RAM plan counts them. The docs,
two new test programs, a unit test case and a dense-only Vulkan case cover it.

A draft is a proposal that the verify step checks, so it does not need the
score of every token. A token missing from the list can only turn an accepted
draft into a rejected one, and the rows of the rarely used tokens were read
for every draft.

Measured with an earlier version of this change on an AMD Radeon 8060S: 21.75
tok/s with the list off, 22.82 with 16000 tokens (+4.9 %), 22.07 with 1000.
Greedy output was identical with and without the list. The output does not
change, and the dense chain's device scoring is left as it is.

Test: tests/test_draft_vocab and tests/test_own_file (both ok)
Test: tests/test_qwen38_draft_head (34 checks passed)
Test: tests/test_qwen38_idot (11 checks passed)
Test: tests/test_idot_r16 (0 of 132 cases differ)
Test: python -m unittest tests.test_resource_plan (122 tests, OK)
Test: qwen38_mtp_harness.py --skip-head, 3 tiny fixtures (33 checks each)
Test: the same with Q38_MTP_VOCAB=8 (33 checks each, as plain decoding)
Test: vulkan_engines.sh dense-only-qwen38 on Lavapipe (14 cases OK)
Test: qwen38 MTP cases of vulkan_engines.sh qwen-spec on Lavapipe (12 OK)
Test: make -C c check, CPU build (exit 0; 2802 Python tests, 251 skipped)
The MTP head scored its list of frequent tokens from the int8 rows of the
lm_head read as plain rows, one row after the other. On a build with AVX2
or AVX-512 the engine interleaves those rows by 16 for the CPU once they
are loaded, so the list was scored from bytes of other rows.

Now a listed token gets the score that the whole lm_head gives it, on
every build. The draft is the best listed token again.

This change adds i8r16_row, which reads one row out of the interleaved
layout, makes i8r16_unpack use it, and adds matmul_q_idot_rows_r16, which
unpacks each listed row and scores it with the row kernel. The head picks
it when its rows are interleaved. The draft head test gets a model whose
rows are interleaved the way the engine does it and checks the scores and
the draft against the whole vocabulary.

On the real model the drafts were almost never accepted with the list
(0 to 1.6 % against 79 to 91 % without it), while the list itself held
the picks. The scores read the wrong layout. The test of the head only
used plain rows, so it did not see this.

The list can only help the CPU when its scores are the ones the whole
head gives, and the new test fails as soon as a layout of the rows is
read the wrong way.

Test: make tests/test_qwen38_draft_head with -march=native (all ok,
including the two new checks); the same two checks fail without the fix
Test: make tests/test_idot_r16 (0 of 132 cases differ, round trip exact)
Test: make tests/test_draft_vocab (ok)
Test: make tests/test_qwen38_draft_head without -march=native (all ok)
GLM-5.3 Flash's layer-0 v_proj has 148 BF16 weights of 1e20..1e33, so
int4 g64 scales reach 1e34. chain_gemm.comp and qmatmul_coop.comp hold
code * scale in f16, which overflows to inf: the output column becomes
NaN and prefill logs "non-finite logits at layer 3".

upload_tensor_pool now flags a matrix whose scale times the format's
largest code exceeds 65504 (ColiVkTensor.wide) and keeps it on the fp32
GEMM/GEMV path.

Threadripper PRO 5955WX, RTX 3090 24 GB and RTX 4090 24 GB, Windows 11,
NVIDIA 617.14, GCC 16.2.0, glslc 16.6.0, make VK=1: 0 warnings. GLM-5.3
Flash on the 3090, T=0, 24 tokens, "List the first twelve prime numbers":
before "!!!!!!!!" with "[router] non-finite logits"; after "2, 3, 5, 7,
11, 13, 17, 19,". test_vk_memtype, test_vk_tier (also host-pairs) and
test_glm53_vk_f32 pass on both GPUs; test_vk_chain fails the same 18
cases with and without this change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…not by a stream sync inside a frame

cc_attn grew the split decode attention's scratch (adec) with a
cudaStreamSynchronize before the free. Inside a frame that sync waits
on everything queued on the stream -- with the routed experts in the
frame (COLI_CUDA_CHAIN_MOE) that includes the layer before's
cc_wait_host kernel, whose host side (q36cc_dm_serve) comes only after
this layer was recorded. The device spins on the host's word, the host
on the device: a deadlock at the first regrowth past 128 positions
where a layer has experts on the host (Qwen3.6-35B int4 on an RTX 3070
with 82 % of the decode's experts resident, the usual 49-token prompt:
every run stopped at token ~145, position 193, with the GPU at 100 %).

The resize now retires the old scratch into a short list that is freed
after the next frame wait or finish (the stream has passed every frame
that could read it by then), and at shutdown. A fifth retirement before
any wait falls back to the sync, which no step reaches (each regrowth
is 1.5x). Both cc_attn and the windowed variant.

RTX 3070, Qwen3.6-35B-A3B int4, 200 and 600 new tokens: no hang,
14.5-14.7 ms a decode token (68-69 tok/s) against 16.0-17.2 with
COLI_CUDA_CHAIN_MOE=0; the greedy text as before. test_qwen36_cuda_chain
and cuda-chain-check unchanged.
…fest root is refused in 26 ms, not 16 s

qpack_object_has_duplicate_keys compared every pair of keys with strcmp,
n^2 / 2 comparisons.  The files table it guards is capped at 8192 keys
first (at the cap, ~0.8 s here), but the manifest root, the layout root
and each section object reach the scan with no count check at all: the
only bound on their keys is the 16 MiB JSON cap, about 1.4 million short
ones.  Measured on dev 36b713e with qpack-inspect: a manifest root of
100,000 keys took 18.0 s of CPU to refuse, 200,000 took 62 s; 1.4 million
would be most of an hour, all of it before the container is refused.

The keys are now copied to an array of pointers, sorted with qsort and
compared neighbour to neighbour: n log n.  The same inputs on this
commit: 0.01-0.03 s each.  If the pointer array cannot be allocated the
pairwise scan still answers, so memory only ever changes the time, never
the verdict.  What is accepted and what is refused does not change.

tests/test_qpack.c: a files table whose repeated key has other keys
between the copies is refused (the sorted scan must find non-neighbours;
passes before and after), and a 100,000-key manifest root with its first
key repeated as its last is refused within 4 s of CPU time -- 15.9 s and
a failed check against the previous qpack.c, 0.026 s here (0.104 s under
ASan+UBSan).  clock(), not wall time, so a loaded host does not fail it.
…test_v4_hybrid_policy run once per `make check`, not twice

TEST_BINS has been derived from the tests' build rules since 1d8d19b
("build: derive TEST_BINS from the build rules, not from the .c files"):
every tests/test_*$(EXE) rule that is not in TEST_EXCLUDE is a gate.  Two
later additions appended their tests to TEST_BINS by hand as well --
bc44ff0 the three V4 infrastructure tests under COLI_V4_SUPPORTED,
049d444 test_exact_dot under LINUX -- and all four have unconditional
rules, so on the hosts those blocks apply to each was built once and run
twice per `make test`.  The two lines go; nothing else changes:

    make -s --eval='print-bins: ; @echo $(TEST_BINS)' print-bins | tr ' ' '\n' | sort | uniq -d

lists the four on dev 36b713e (226 entries, 222 distinct) and nothing
here (222 entries, 222 distinct, the same 222).  The four already ran on
every platform through their rules, so no host gains or loses a gate.
… preamble

The pi example only names glm-5.2-colibri. Note that the same provider
entry serves the other engines by swapping the model id, and that pi's
preamble (system prompt, tool catalog, project instructions, ~5,000
tokens) has to fit the served context, with a measured pi 1.4.0 /
Qwen3.6-35B-A3B data point.
… (between two chunks) and with the device tiers; the gateway logs a client gone mid-turn
… crashed compiling the shader on a Radeon 780M, #2289); a Vulkan build says when COLI_VULKAN leaves the device unused
…commit too

GlobalMemoryStatusEx's ullAvailPhys is the physical memory free now; whether the next
malloc succeeds depends on ullAvailPageFile, the commit still grantable (RAM + page
file, less what every process has committed). #1375 bounded the planner and
compat_meminfo_gb by it; compat_meminfo (kimi_k3's expert-cache budget, the
telemetry) and deepseek_v4's coli_v4_os_available_memory (its RAM plan) still read
ullAvailPhys alone. On a laptop with WSL running: 6.9 GB physical free, 2.8 GB of
commit, so a budget from the first was refused by Windows.

Both now take the smaller of the two. tests/test_windows_commit_avail_source.py: every
C reader of ullAvailPhys reads ullAvailPageFile beside it (it names compat.h and
deepseek_v4.c without this change).
…the loop's bits

chain_moe_gv.comp's lanes walked a row's 16-byte steps in a loop that waited for each load
in turn: on a Radeon 8060S (RADV) a Qwen3.6 layer's routed experts took 160 us at one row,
78 GB/s of the pairs' weights.

For int4 per group (fmt 4) with groups of whole steps, where the row is a whole number of
steps a lane at coli_vk_gv_lanes' lanes (up to 16; Qwen3.6's 2048 and 512 inputs: eight),
moe_gv_pipe builds the shader with HQ (and the steps a lane in NQ): a lane loads every step
and scale first, gate's and up's together, then sums them. In that path and in the fmt 4
loop each step's product is rounded before it is added (precise), so both give the same
bits: tests/test_vk_chain runs Qwen3.6's shapes, D 2048 and the clamped SwiGLU both ways
and compares them bit for bit, and the tier's small experts (the loop) keep the chain's.
The fmt 4 loop also takes gate and up as two loops instead of one interleaved (160 -> 119
us alone). Formats 1, 2 and 7 are untouched. COLI_VK_MOE_GV_HQ=0 keeps the loop.

Radeon 8060S, COLI_VK_CHAIN_BENCH=moe at Qwen3.6's shapes (K 8 of 256), us per layer:
- one row 160 -> 100, two 282 -> 166, three 385 -> 239

Qwen3.6-35B-A3B int4-g64, Vulkan chain, tier 24 GB, 512 new tokens on an English, a German
and a code prompt, two rounds alternated: 45.0 -> 50.3 tok/s, the same text (md5).

This brings #1952's one-wave kernel (each lane's loads ahead of its sums) into vkc_moe as
asked there, on chain_moe_gv.comp's lanes so a row keeps its bits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…loat16 or a float32 run); the kv16 rules list no headers
…tier's start calls it; gcc 14 and later refuse the implicit declaration)
@JustVugg
JustVugg force-pushed the batch/kv-oom-2026-10-23 branch from 78f0d30 to 3fe0952 Compare October 23, 2026 18:18
@JustVugg JustVugg changed the title batch: #2324-#2329 (KV in float16, Muse on the CUDA chain, Windows commit, #2289, #2290) batch: #2324-#2329, #2331 (KV in float16, Muse on the CUDA chain, Windows commit, #2289, #2290, qwen38 tests) Oct 23, 2026
…o both runs verify the same steps and dump the same logit rows
…_VK_ATTN mirror, GLM chain, split, .coli_kv v4, mux adoption; oracle runs keep float32 unless KV16 is set)
…I_VK_ATTN, split, serve with one and two slots)
…nversations' places closes the places (states home) instead of turning the chain off
…a pin another conversation saved rewound one whose window uploads had left holes); Muse Spark three-slot gate
gcc fuses y += a*x only from -O2 on. In the ASan build (-O1) the scalar tail and the
test's float loop rounded twice while the F16C body used vfmadd, so test_kv16 found 67
rows that differ. MATMUL_F32_MADD fuses where the target has FMA and does not where it
has none, at every optimisation level; at -O3 the bits are the ones gcc already gave.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants