Skip to content

qwen36: run the DeltaNet layer end to end on the card (Q36_DN_GPU=1): conv, recurrence and gated norm join the two projections - #1781

Closed
kreuzzelg wants to merge 6 commits into
JustVugg:devfrom
kreuzzelg:qwen36-kda-gpu
Closed

kreuzzelg wants to merge 6 commits into
JustVugg:devfrom
kreuzzelg:qwen36-kda-gpu

Conversation

@kreuzzelg

@kreuzzelg kreuzzelg commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

With the trunk in VRAM (#1657) a decoded token on the 35B still spent about 8 of 39 ms in the thirty DeltaNet layers' convolution, L2 norms, recurrence and gated norm: dn-sub: proj 7.9 | conv 3.0 | l2n+rec 2.4 | norm+out 2.8. None of that is arithmetic -- the convolution is 33k multiply-adds per token, the recurrence 32 outer products of 128 x 128 -- it is the round trip. The in_proj's result came down to the host, the recurrence ran there, its output went back up for the out_proj: four copies and their synchronisations per layer, thirty layers per token. (Found while reading why Ollama 0.34.4 decodes this model at 36 tok/s against our 25: llama.cpp keeps the whole gated-delta layer on the card.)

What the PR does

Backend (coli_cuda_dn_*, five entry points, optional in the DLL loader): one object per DeltaNet layer holds the short-conv ring, the recurrent state [vh x kdim x vdim], the conv and norm weights and one token's scratch in VRAM. coli_cuda_dn_step runs a decode token end to end: x up, in_proj GEMV, causal conv + SiLU with the ring advancing, per value head the L2 norms (double, eps inside the sqrt as on the CPU), the decay, the delta against the decayed state, the write-back, out = q^T S over the updated state, the gated RMSNorm with silu(z), out_proj GEMV, out down. The gates (a/b, 32 outputs) stay on the CPU and travel as kernel parameters. One upload, one download of 8 KB per layer; the state never crosses the bus. Same arithmetic as deltanet(), so the CPU path stays the reference; the fake backend carries a host-side copy of the steps and can be told to fail.

Tier (qt_dn_gpu_*): where the placer put a layer's in_proj (qt_dnproj_init) and out_proj (qt_dense handle) on one device, the layer joins them there. The host copy of the state stays the engine's, moved with set/get_state; get_state works after a failed step, so the engine continues from what the card holds.

Engine (Q36_DN_GPU=1, opt-in): a decode token computes the two gates and hands x to qt_dn_gpu_step. The host arrays remain canonical, with two flags per layer: the device copy is fresh (nothing to upload before a GPU step) or ahead (download before any CPU use). A prompt (S > 1 keeps the batched CPU path) pulls first, a --pin snapshot pulls all layers, reset_recurrent and a pin restore invalidate the device copy, a failing GPU step pulls what the card holds and continues on the CPU. Prefix reuse and the pin machinery are untouched by construction: they only ever see host arrays that are current.

Placer, two small things the measurement needed: auto puts a layer's dnout on the device its dnproj went to when that device has the room (without it 28 of 30 layers qualified on two cards), and COLI_PLACE accepts experts=all -- the routed experts stay on every COLI_GPUS card even when a hand-written list reserves some for the trunk (the form for "trunk on the fast card, experts on both", which the reservation rule of #1361 could not express).

Measured (RTX 3070, per-row int4 35B, 49-token prompt, 200 decoded tokens, trunk in VRAM, COLI_TIMERS=1, output byte-identical between two GPU runs)

Q36_DN_GPU=0 Q36_DN_GPU=1
DeltaNet ms/token 16.2 10.8
token ms 39.4 / 39.3 33.9 / 33.3
tok/s (engine line, TTFT included) 21.2 / 21.3 24.1 / 24.4
decode-only tok/s 25.4 30.0
layers on the card 0 30

The 10.8 ms that remain are the two GEMVs (16 and 8 MB per layer) and the two copies. With only lmhead,dnproj,dnout placed the gain is the same (41.5 -> 35.3 ms). On two cards the layer gains the same 6-7 ms (44.3 -> 37.5 with the trunk split, 40.1 -> 32.9 with the trunk on the 3070 and experts=all), but this box's second card is a Quadro RTX 4000 that runs a dense GEMV about 65 % slower (lm_head 7.3 against 4.4 ms), and at 99-100 % residency the MoE phase only drops from 14.0 to 11.2-13.6 ms (take 5.2 ms/token waiting on the slower card's groups), so two cards end level with the 3070 alone. That is a property of this pair of cards, not of the layer.

Not byte-identical to the CPU path (the GEMVs sum in a different order, as for every placed component); two GPU runs are identical to each other. Against Ollama 0.34.4 on the same box (36.4 tok/s decode, Q4_K_M, both cards) the gap goes from 43 % to 20 %.

Verified

  • tests/test_qwen36_dn_gpu.c (fake backend, in make check): one in-memory DeltaNet layer driven through deltanet(): GPU outputs equal the CPU outputs; the host copy is left alone while the card advances and reads back the CPU path's state; a two-row CPU prefill after GPU tokens pulls first and continues, the GPU token after it pushes first; a failing step turns the layer off and the CPU continues from the card's state.
  • tests/test_qwen36_tier_autoplace.c gains the two placer cases (dnout follows dnproj; experts=all keeps both cards).
  • All existing qwen36 tier tests unchanged and green; CPU-only build carries the stubs; colibri (the loader host) builds; the loader ABI tripwire counts the five optional symbols.
  • Not exercised on the real container yet: --pin snapshots and prefix reuse under Q36_DN_GPU=1 (the code paths only read host arrays, pulled first, and the fake test covers the pull/push order; a serve smoke on the container is what I would run next if you want it before merging).

Where this goes next

c/delta_attention.h says it: the same gated delta step runs the linear-attention layers of Kimi K3 (KDA), GLM-5.3-Flash and Qwen3.8 as well; only the dimensions and the gate differ, and the gate belongs to the engine. The backend object here takes those dimensions as parameters (vh <= 64, kdim/vdim <= 256), so the other engines can hand their layers to the same five calls. I kept this PR to qwen36 so the numbers are the engine's own; happy to follow with qwen38 (48 value heads) if you want the backend step to be the shared one, or to move it behind delta_attention.h's naming if you would rather it mirror coli_kda_step exactly.

Thanks to Claude Code for the help along the way.

kreuzzelg and others added 6 commits September 28, 2026 10:50
One object per DeltaNet layer holds the short-conv ring, the recurrent
state [vh x kdim x vdim], the conv and norm weights and one token's scratch
in VRAM. coli_cuda_dn_step runs a decode token end to end on the card:
x (host) -> in_proj GEMV -> causal conv + SiLU -> per value head the L2
norms (double, eps inside the sqrt), the decay, delta against the decayed
state, the write-back, out = q^T S over the updated state, the gated
RMSNorm with silu(z) -> out_proj GEMV -> out (host). Gates arrive by value.
Only x and out cross the bus; the state never does. Same arithmetic as
qwen36.c's deltanet(), so the CPU path stays the reference.

Optional in the DLL loader (five RESOLVE_OPT); the fake backend carries a
host-side reference of the same steps so an engine test runs without a
card, and can be told to fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s on the card

Where the in_proj (qt_dnproj_init) and the out_proj (a qt_dense handle)
of a DeltaNet layer sit on the same device, qt_dn_gpu_init puts the conv
ring, the recurrent state and the norm/conv weights there too, and
qt_dn_gpu_step runs a decode token through the backend chain. The host
copy of the state stays the engine's, moved with qt_dn_gpu_set/get_state
when the CPU path needs it; get_state works after a failed step as well,
so the engine can continue from what the card holds. A failing step turns
the layer off like every other GPU path here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… end to end

With the trunk in VRAM the CPU still spent ~8 of 39 ms/token on the
convolution, the L2 norms, the recurrence and the gated norm of the thirty
DeltaNet layers -- microseconds of arithmetic, paid as host round trips:
the in_proj result came down, the recurrence ran on the CPU, its output
went back up for the out_proj. Now a decode token computes the two gates
on the CPU (two tiny matmuls) and hands x to qt_dn_gpu_step, which returns
the layer's output; one upload, one download per layer.

The host arrays stay canonical. Per layer, dn_dev_fresh says the card
holds the host's state (nothing to upload before a GPU step) and
dn_host_stale says the card advanced past the host copy. A CPU step (a
prefill, S > 1) pulls first and marks the device copy old; a snapshot
pulls all layers; reset_recurrent and a pin restore invalidate the device
copy. A failing GPU step pulls what the card holds and continues on the
CPU. Opt-in for now: Q36_DN_GPU=1, and only layers whose in_proj and
out_proj the placer put on one card.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…inst the CPU path

One in-memory DeltaNet layer, no container, driven through deltanet() on
the fake backend: decode tokens through the GPU path give the CPU outputs;
the host copy is left alone while the card advances and reads back the CPU
path's state; a two-row CPU prefill after GPU tokens pulls first and
continues, the GPU token after it pushes first; a failing step turns the
layer off and the CPU continues from the card's state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…experts=all

Two placement details the GPU DeltaNet layer needs. The automatic placer
chose the device for each offer by room alone, so a layer's in_proj and
out_proj could land on different cards (28 of 30 layers qualified on two
cards): now dnout goes to the device its dnproj went to when that device
has the room. And a hand-written list that reserves a card for the trunk
hands that card's experts to the others (JustVugg#1361), which rules out "trunk on
the fast card, experts on both"; experts=all keeps every COLI_GPUS card
for the experts, reserved or not. Both in test_qwen36_tier_autoplace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…counts model layers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kreuzzelg

Copy link
Copy Markdown
Contributor Author

Withdrawing this for now: the two-card numbers above showed that the layer on the card only pays off fully once the experts are homed per layer range instead of per expert index (no per-layer join on the slower card). I will bring both back together in one PR once that is measured; nothing here is abandoned.

@kreuzzelg kreuzzelg closed this Sep 28, 2026
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.

1 participant