Compare commits

...
Author SHA1 Message Date
grenadeandClaude Opus 5 bf1e80ed55 fix(qwen4_exp): 640 is not a whole number of k-quant blocks
CI / Config consistency (push) Successful in 11s
CI / Classify changes (push) Successful in 23s
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m37s
CI / Test (push) Successful in 7m2s
CI / CUDA type-check (push) Successful in 19m17s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The first real cold load on beast got through routing, resolved all
131 shards, parsed the config, reached decoder layer 0 — and died
there:

    quantize experts to Q4K: quantized tensor must have their last dim
    divisible by block size [2560, 640] 256

ggml quantises along the last dimension and a row must be a whole
number of blocks. `gate_up` reduces over `hidden_size` = 2560, which
is ten blocks of 256. `down_proj` reduces over `moe_intermediate_size`
= 640, which is two and a half. So `quant = "q4k"` could never have
loaded this model, on any card, whatever else was fixed first.

It is not an orientation mistake — `[2560, 640]` is the `[out, in]`
layout a `QMatMul` wants, and 640 is genuinely the reduction. The
constraint is the number itself. llama.cpp meets the same wall and
answers it the same way: substitute a 32-block type for the row that
will not divide. Q4_0 and Q4K are both 4.5 bpw, so the memory
arithmetic every placement decision rests on is unchanged; the trade
is quality, on one of the three expert matrices, against not loading
at all. A row that divides neither 256 nor 32 is an error, because
silently leaving those weights dense would be a memory surprise rather
than a mercy.

The substitution is decided once per layer from the shapes rather than
per expert -- 512 identical lines a layer would bury the one fact
worth reading.

The test uses the checkpoint's own widths, 2560 and 640, scaled down
only in expert count. The fixture that missed this was 256 in both
dimensions -- the narrowest a k-quant accepts -- so every row divided
and the two halves were indistinguishable. A test whose fixture cannot
express the failure is not evidence about it.

Refs #307, #315, #320.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-06 20:59:39 +03:00
grenade a14d1af447 Merge branch 'fix/qwen4-exp-load-reporting-and-pool-trim'
build-prerelease / Resolve version stamps + change detection (push) Successful in 21s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m43s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m35s
build-prerelease / Build neuron-ampere (push) Successful in 2m36s
build-prerelease / Test (push) Successful in 10m54s
build-prerelease / Build neuron-blackwell (push) Successful in 14m28s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m7s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m16s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m8s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 30s
2026-09-06 19:58:00 +03:00
grenadeandClaude Opus 5 f1af926c30 fix(qwen4_exp): route the arch on the path that actually loads it
CI / Config consistency (push) Successful in 12s
CI / Classify changes (push) Successful in 27s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 15s
CI / Clippy (push) Successful in 2m39s
CI / Test (push) Successful in 6m49s
CI / CUDA type-check (push) Successful in 19m10s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
`2f2f306c` added qwen4_exp to `DENSE_SUPPORTED_MODEL_TYPES` and to
`candle::load_arch_dense`. That is the CPU fallback. Every CUDA load
goes through the device worker's own `load_dense_inner`, which had no
arm for it — so on a GPU host the model passed preflight and then hit

    unrouted supported model_type 'qwen4_exp' — DENSE_SUPPORTED_MODEL_TYPES
    and load_dense_inner must stay in sync

in about 200 ms, having read none of the 360 GB. The bail's own comment
is the specification this violated, and it was written before there was
a second loader to violate it.

The arm mirrors the CPU one, and the job now carries the parsed
`quant`. It has to: the worker load never had a quant parameter, so
even a routed qwen4_exp would have loaded BF16 experts — 241.6 GB of
them, measured from the checkpoint's own safetensors headers, against
a 32.6 GB card. In-situ quantisation reaching only the CPU path is the
same defect #320 fixed one layer up, and it would have looked like
#320's fix had not worked.

The test asserts every model_type the preflight admits has an arm here,
by driving a bare `{"model_type": ...}` at the loader and requiring the
failure to be anything other than "unrouted". Adding a name to the list
with no loader behind it now fails in CI rather than on a GPU host at
load time — which is the only place the difference between the two
loaders was observable.

Refs #307, #315, #320.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-06 19:36:46 +03:00
grenadeandClaude Opus 5 430b6be62b fix(neuron): let the loaded model answer for itself, and give the pool back
CI / Test (push) Blocked by required conditions
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 21s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / CUDA type-check (push) Successful in 19m23s
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m41s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
Three things the first qwen4_exp cold load would have got wrong, none
of which the load itself has to reach to matter.

The single-GPU path decided prefix-snapshot capability by matching
config.json's model_type against a list of two, while
ModelArch::supports_kv_snapshot had grown to three. So qwen4_exp
loaded with snapshots reported unavailable and batched decode silently
off — the work that landed the snapshot was reachable only through a
list somewhere else that nobody had reason to look at. The dense load
reply now carries the arch's own answer (DenseLoad), taken while the
model is still in hand on the worker thread, which is the only place
that can be asked. The TP path still re-derives it; that is correct
for the two archs it serves today and it is commented as the hazard it
is, because #319 will add a third.

Nothing built a context profile for qwen4_exp either, so derive_limit
had no physics and the model would have advertised the static prompt
cap. The profile is the qwen3_5 one plus a term: the QSA indexer keeps
a second cache on every full-attention layer, 12 x 1 x 128 x 2 bytes =
3 KiB/token beside the main cache's 24. Advertising against the main
cache alone overstates what the card can hold by an eighth, which is
the #281 failure mode. The test drives both builders off the
checkpoint's verbatim config.json and asserts each declines the
other's, since the load path now tries them in turn.

And a load that fails part-way left its bytes in cudarc's pool. The
image path learned this at the benjy cold-swap and trims after every
load and drop; the text path never did. A dense load that runs out of
VRAM has typically allocated tens of gigabytes first, and the model
that was evicted to make room for it needs them back — so LoadGguf,
LoadDense, DropArch and DropTp now trim the way LoadImage and
DropImage always have.

Refs #307, #310, #281.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015v1FQqShiRp9rrJEEcgNER
2026-09-06 19:10:08 +03:00
grenade af40bac9b8 Merge branch 'fix/320-isq-reachable-and-log-levels'
build-prerelease / Resolve version stamps + change detection (push) Successful in 18s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 4m7s
build-prerelease / Test (push) Successful in 9m15s
build-prerelease / Build neuron-ampere (push) Successful in 2m11s
build-prerelease / Build neuron-ada (push) Successful in 2m14s
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Successful in 14m18s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m6s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 29s
2026-09-06 18:03:15 +03:00
grenade d5738bfb89 fix(neuron): read quant as ISQ when the repo has no GGUF (#320)
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m36s
CI / Test (push) Successful in 9m44s
CI / CUDA type-check (push) Successful in 19m12s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The single-GPU load paths treated a non-empty `quant` as "select the
GGUF whose filename contains this string", while the TP path treats the
same field as in-situ quantisation of safetensors. A model published
only as safetensors is therefore loadable quantised on two cards and
not on one, which is backwards — the single card is the one that needs
the quantisation.

Fall through to the dense loader (which applies ISQ) when the repo has
no GGUF at all but does have safetensors. A repo that does carry GGUFs
keeps the old behaviour exactly, including the old error when none of
them match: that case is a typo, not an ISQ request, and silently
turning it into a full-precision load would be a worse answer than the
error.

Also drop the per-model context-limit line from debug to trace. Every
input to it is fixed at load, so it repeats byte-identically on every
`GET /models` poll — roughly 8,600 lines a day per model saying the
same thing, on top of whatever you were actually reading the journal
for.

And set RUST_LOG=debug for neuron fleet-wide from deploy.yml, in the
model.conf drop-in it already manages. The code default is `info`,
which is too coarse to reconstruct a load or a routing decision after
the fact; before this, one host had `debug` because it was set there by
hand, and the others quietly did not. A verbosity you have to remember
to set is one you discover you did not have at the moment you needed
it. It sorts after local.conf, so the managed value wins.
2026-09-03 16:22:19 +03:00
grenade ecbfe22124 Merge branch 'feat/qwen4-exp-quantised-experts'
build-prerelease / Resolve version stamps + change detection (push) Successful in 13s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m45s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m27s
build-prerelease / Build neuron-ampere (push) Successful in 2m28s
build-prerelease / Test (push) Successful in 10m36s
build-prerelease / Build neuron-blackwell (push) Successful in 14m47s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m3s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m11s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m10s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 24s
2026-09-02 19:52:10 +03:00
grenade 3e5d39807d fix(qwen4_exp): point the CUDA leader load at the moved parse_quant_string
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m32s
CI / Test (push) Successful in 9m9s
CI / CUDA type-check (push) Successful in 19m20s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Moving the function out of tp/worker.rs left dispatch.rs calling it
through worker's `use` import, which is private — so the call compiled
nowhere except in my head. The CUDA type-check caught it; the CPU
build cannot, because that call site is cfg(cuda) and every local check
I ran was blind to it.

That is what the job is for, and it is the second time this port has
relied on it. Worth remembering that "fmt, clippy and test are green"
means green on roughly half of this crate.
2026-09-02 19:30:08 +03:00
grenade 05a5aff25e feat(qwen4_exp): quantise the experts in situ, which is what makes it loadable
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 20s
CI / Format (push) Successful in 12s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Clippy (push) Successful in 2m46s
CI / Test (push) Successful in 12m52s
CI / CUDA type-check (push) Failing after 19m32s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
120.8 B routed parameters are 241.6 GB at BF16 and 67.9 GB at q4k,
against 64 GB of VRAM and roughly 110 GB of usable host RAM. Everything
else in this port has been waiting on that arithmetic.

The experts are quantised out of the banked tensors rather than out of
the checkpoint, which is what bounds the peak: one layer's fused pair is
5.03 GB, and it is dropped once its 512 experts are quantised, so the
full 241.6 GB never exists at once. That is the reason the previous
commit banked them.

q4k was chosen on evidence rather than taste. fp8 experts are excluded
by arithmetic — 120.8 GB of experts plus 51.2 GB of PLE tables is
176.9 GB, which does not fit even counting host RAM. NVFP4 and IQ4_XS
are memory-equivalent to q4k at ~4-4.5 bpw, so they separate only on
kernels, and neuron already implements the ggml k-quants through the
same in-situ path the 27B and the image model use in production. NVFP4
is the optimisation to race later, against a *batched* measurement:
FreeToken measured it losing to plain Triton at M=1 for expert widths
below 1024, and this model is 640.

parse_quant_string moves out from behind cfg(feature = "cuda"). A
catalogue quant name means the same thing whichever loader reads it, and
it was unavailable to precisely the path that now needs it.

One trap worth the comment it got: QTensor::quantize reads
src.storage() — the whole backing buffer, ignoring shape and offset — so
a view into the fused tensor hands it every expert's bytes while the
shape claims one, and candle panics on a block count deep inside
from_float. Neither contiguous() nor copy() helps, because a slice along
dim 0 is already contiguous and both are then free to keep sharing the
parent buffer. The source has to own its storage outright.

The test uses 256-wide tensors because the k-quants block on 256
elements, so a toy fixture cannot be quantised at all — and it compares
against an unrelated expert as well as against the dense one, so a
quantised path that returned something smoothly wrong would not pass.

Refs #307, #315, #259.
2026-09-02 18:52:24 +03:00
grenade cd74eeaf1c Merge branch 'feat/qwen4-exp-expert-bank'
build-prerelease / Resolve version stamps + change detection (push) Successful in 22s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m36s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 9m29s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 2m25s
build-prerelease / Build neuron-ada (push) Successful in 2m28s
build-prerelease / Build neuron-blackwell (push) Successful in 17m0s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m5s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m9s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m5s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 29s
2026-09-02 18:16:31 +03:00
grenade 60bbe88ba7 feat(qwen4_exp): bank the experts instead of slicing them apart
CI / Config consistency (push) Successful in 14s
CI / Classify changes (push) Successful in 28s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m59s
CI / Test (push) Successful in 10m36s
CI / CUDA type-check (push) Successful in 20m31s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Slicing the checkpoint's two fused tensors into 512 per-expert modules
copies 241.6 GB at BF16, 5.03 GB per layer. That fits in neither
beast's 64 GB of VRAM nor its 123 GB of host RAM, and no quantisation
rescues it — #309 measured 59.4 GB of 63.7 used at 4.25 bpw with only
four layers' experts offloaded. The loader was the reason nothing could
be loaded.

So the block now takes the experts in whichever shape the checkpoint
gives, and views one per routed token. The routing is untouched: it was
already correct and duplicating it to get a second storage layout would
have invited exactly the kind of drift that is hard to see — softmax in
f32 over all 512, top-k, renormalise, sigmoid-gated shared expert.

Banking costs nothing per call. A slice along dim 0 of a contiguous
tensor is contiguous, and x.matmul(w.t()) is what Linear does anyway,
so the views feed the same GEMMs without a copy.

Because "we only changed where the bytes live" is a claim rather than a
fact, the same weights are expressed both ways and asserted to compute
the same function. Reading the gate and up halves in the wrong order is
pinned separately, since the banked path would get that wrong as
silently as the sliced one did.

It is also the layout a host-resident expert bank with a device-side
gather wants (#318), so that work starts from this rather than having
to undo the slicing first.

One consequence worth noting: MLP::from_weights has no caller outside
tests any more, so it goes back behind cfg(test).

Refs #307, #312, #318.
2026-09-02 17:06:44 +03:00
grenade 499ed5013e Merge branch 'feat/qwen4-exp-mtp-head'
build-prerelease / Resolve version stamps + change detection (push) Successful in 26s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m59s
build-prerelease / Test (push) Successful in 9m52s
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 3m15s
build-prerelease / Build neuron-ada (push) Successful in 3m22s
build-prerelease / Build neuron-blackwell (push) Successful in 16m5s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m6s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m14s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m24s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 29s
2026-09-02 14:43:04 +03:00
grenade 0ab159e144 Merge branch 'docs/mtp-wiring-settled' 2026-09-02 14:43:04 +03:00
grenade e3a82e412d Merge branch 'feat/qwen4-exp-snapshot' 2026-09-02 14:43:04 +03:00
grenade 2c852c05cb feat(qwen4_exp): the MTP draft head
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 18s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m32s
CI / Test (push) Successful in 9m9s
CI / CUDA type-check (push) Successful in 19m30s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The checkpoint ships a trained draft model — a second full-attention
decoder layer with its own indexer, its own 512-expert MoE and both
hyper-connections — so speculative decoding here is a wiring problem
rather than a training one. It reuses embed_tokens and lm_head, and
mtp.layers.0 is exactly the decoder layer we already have, so what is
new is only how the target's state and the next token's embedding
become an input for it.

Three parts of that were read from vLLM rather than inferred, and none
are recoverable from the tensor shapes.

pre_fc_norm_hidden is ungrouped: one normalisation over the whole
10240, where every other norm that wide in this architecture does four
of 2560. vLLM has a GroupedGemmaRMSNorm and deliberately does not use
it here. Our port spec asserted the opposite; grouping it rescales the
draft input in four independent pieces and shows up as a poor
acceptance rate rather than as an error, so it is pinned by value on an
input the two readings cannot agree on.

The hidden state is the pre-final-mixer stream, not the 2560 the LM
head sees — which is why the model now returns it. Both are "the hidden
state" and only one is right, so the head refuses the collapsed one by
width instead of broadcasting it into something plausible.

And the head emits both streams: the collapse for lm_head, and the
pre-mixer stream that its own next step consumes. A head returning only
the first cannot be chained past one token.

Building the test also settled something the loop will need: the head
owns its own KV and indexer caches, so it has to walk the prompt
alongside the target before it can draft. A head fed only the newest
token attends over an empty past, and here that is caught rather than
served, because the indexer's divergence check fires. Prefill is part
of speculation's cost, not just decode.

Refs #307, #313.
2026-09-02 12:43:32 +03:00
grenade 1862f0ad2d Merge branch 'docs/mtp-wiring-settled' into feat/qwen4-exp-mtp-head 2026-09-02 12:38:30 +03:00
grenade 559ff46f2a doc(qwen4_exp): settle the MTP wiring, and correct a norm this got wrong
CI / Classify changes (push) Successful in 16s
CI / Config consistency (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / CUDA type-check (push) Has been skipped
CI / Format (push) Has been skipped
CI / Clippy (push) Has been skipped
CI / Test (push) Has been skipped
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
§8 said every 10240-wide norm in the model is grouped over 2560 and
named mtp.pre_fc_norm_hidden among them. It is not. vLLM applies a
plain GemmaRMSNorm over the whole 10240 there, while using its
GroupedGemmaRMSNorm for the hyper-connection — both classes exist in
that tree, so the distinction is deliberate. Grouping it would rescale
the draft head's input in four independent pieces and surface as a poor
acceptance rate rather than as an error, which is the kind of wrong this
document exists to prevent.

§9 now carries the wiring instead of describing it as clear and leaving
it. Three parts of it are not recoverable from the tensor shapes: the
norm above; that the hidden state is the pre-final-mixer multi-stream
rather than the 2560 the LM head sees; and that the head emits both, the
collapsed stream for the head and the multi stream for its own next
step.

It also separates the wiring from the accept rule, which §9 had
conflated. The accept rule is greedy prefix match or the Leviathan
scheme and is not specific to this architecture.

Read from vllm/models/qwen4_exp/nvidia/mtp.py at 76ba321.

Refs #307, #313.
2026-09-02 12:34:19 +03:00
grenade bc57e8423e test(qwen4_exp): round-trip the snapshot, not the logits
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m30s
CI / Test (push) Successful in 10m19s
CI / CUDA type-check (push) Successful in 19m56s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Taken from llama.cpp's test for this architecture (their #27941), which
fixed the indexer cache dropping ext.x/ext.y on restore. Their note on
why the test is shaped that way is the point: comparing generated output
cannot see a field dropped on the way back in, because a dropped field
need not change the next token. Their fix moved 198 bytes of 335,692.

The existing test here compares logits after a restore and has exactly
that blind spot. This one snapshots, restores, snapshots again, and
requires the two to agree field for field — so anything
restore_kv_cache silently fails to put back shows up as a shape or a
value difference rather than as nothing at all.

The indexer cache gets its own assertion, since it is the field their
bug was in and the one this architecture adds.

Refs #307, #312.
2026-09-02 12:26:54 +03:00
grenade 25157ca473 feat(qwen4_exp): snapshot the state, all of it
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m30s
CI / Test (push) Successful in 10m3s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
Prefix caching (#11) needs everything clear_kv_cache destroys, captured
at one token boundary. For this architecture that is more than the
attention K/V.

The QSA indexer keeps a second cache — one head of 128 dims per
position — and a snapshot holding only the K/V restores a layer whose
indexer still holds the previous request's keys. It then selects blocks
of somebody else's text and returns prose about it. So the two travel
together in a single FullSparse variant rather than as a variant plus
an optional field: the type makes capturing one without the other
unrepresentable, which is what clear_kv_cache already does at the other
end.

PLE contributes two things of different natures. The dilated conv's
left context is nine positions of a 10240-wide signal, deep-copied
rather than shared — the buffer is replaced by assignment today, so a
shallow clone would be sound, but narrow().contiguous() on an
already-contiguous slice is a view, which makes aliasing a property of
the data rather than of the code. That is the conditional the
GatedDeltaNet states are deep-copied to avoid. The carried n-gram ids
are two integers per sequence and decide which of 320 million rows the
next token reads.

Batched decode is explicitly refused rather than half-supported:
assemble_batch and extract_row bail on a FullSparse layer instead of
assembling it as plain attention, which would drop the indexer cache
and hand every row a selection computed from another request's keys.
That is the next piece of work, not a gap to be discovered by a wrong
answer.

The test moves the live state two decode steps past the snapshot before
restoring, so it measures a rewind rather than a coincidence, and its
control asserts that a cleared model cannot take the step at all —
which is what distinguishes restore from clear, and is now recorded
rather than assumed.

Refs #307, #312.
2026-09-02 12:13:17 +03:00
grenade b303a453cb refactor: move the cache snapshot out of arch/qwen3_5
The snapshot types are not qwen3_5's. tp_qwen3_5 already constructs
them, ModelArch::snapshot_kv_cache hands one back whatever the
architecture, and qwen4_exp is about to capture state through them
too. They lived under arch/qwen3_5/ because that was once the only arch
with a snapshot.

Pure move plus import updates — no behaviour change, and none intended.
It is a separate commit so the qwen4_exp extension that follows is
reviewable as the change it actually is, rather than as a diff mixed
with a rename.
2026-09-02 12:07:14 +03:00
grenade da749be5d7 Merge branch 'feat/rustingface-always'
build-prerelease / Resolve version stamps + change detection (push) Successful in 20s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m42s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m29s
build-prerelease / Build neuron-ampere (push) Successful in 2m32s
build-prerelease / Test (push) Successful in 6m40s
build-prerelease / Build neuron-blackwell (push) Successful in 15m11s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m3s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m8s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m11s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 24s
2026-09-02 09:48:13 +03:00
grenade c62bfccb3a Merge branch 'feat/qwen4-exp-harness' 2026-09-02 09:48:13 +03:00
grenade 2eb683441a feat(deploy): fetch every model through rustingface, not huggingface.co
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 9s
CI / CUDA type-check (push) Successful in 19m1s
CI / Clippy (push) Successful in 2m33s
CI / Test (push) Successful in 9m40s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
rustingface has been running at rf.internal since before this change,
with a client token provisioned for exactly this purpose — the token
file carries the comment "helexa neurons -> rf.internal as HF_ENDPOINT
(added 2026-08-31)". Nothing on the fleet ever used it. neuron honours
HF_ENDPOINT (d99b4d91) and no host sets it, so every neuron has been
pulling from huggingface.co directly and caching only to its own disk.

This makes the registry a deploy-managed fact rather than a per-host
one. registry.conf sorts after local.conf, so the managed values win
over a hand-set HF_TOKEN, which is the point: the registry stops being
something each host is separately talked into (#287).

It also makes rustingface a hard dependency for every cold load on the
fleet, and that is the substance of the change rather than a cost of it.
An outage on our own host is one we can end; an outage at huggingface.co
is one we can only wait out. A serving stack that stops when someone
else's CDN does is not sovereign in any sense worth the word. The
comment in the workflow says so, and says not to add an upstream
fallback — that would read as a robustness improvement while deleting
the property the change exists for.

Three properties worth stating, because each is a way this could go
wrong quietly.

It is written only when the secret is non-empty. Pointing HF_ENDPOINT at
a registry we cannot authenticate to turns every cold load into a 401,
so a missing secret warns and changes nothing.

It is mode 0600, unlike model.conf's 0644, because it carries a
credential and a world-readable drop-in is a world-readable token. It is
staged under umask 077 so it is never briefly readable on the way in.
Deciding whether it changed therefore needs root to read it back, which
is a second sudoers line, scoped to that one path.

And the token never reaches a log: the applied-message prints the
endpoint only.

Refs #287.
2026-09-02 09:17:38 +03:00
grenade 69c2c53920 docs: what qwen4_exp is, and what it cannot do yet
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m33s
CI / Test (push) Successful in 7m36s
CI / CUDA type-check (push) Successful in 19m16s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The architecture is implemented and routed, so CLAUDE.md should say what
a reader will meet in arch/qwen4_exp/ — four residual streams instead of
one, no layernorms outside the hyper-connections, a hashed n-gram table
that is 28% of the parameters, and a second KV cache the budgeting has
to know about.

The sharp edges are the part worth having written down. Most of them are
things that run happily when wrong: an I64 buffer read as bf16, a mask
the flash path discards in silence, a dilated conv reused as a plain
one, and our own defaults quietly overriding the checkpoint's stated
ones.

And the honest list of what it does not do: no batched decode, no tensor
parallelism, an undecided residency for the table, and gate 6 open. Each
is a deliberate boundary rather than an oversight, which is only true if
it is written down somewhere other than a commit message.
2026-09-02 08:51:28 +03:00
grenade 2f2f306c8d feat(qwen4_exp): route the architecture, so neuron can load it
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 17s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 3m0s
CI / Test (push) Has been cancelled
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
Until now the architecture existed and nothing could reach it. This is
the dispatch: model_type "qwen4_exp" joins the supported set, gets a
ModelArch variant and a load arm that builds it through the sharded
backend, and forwards and cache-clears alongside the others.

Cache clearing is the arm worth reading. It drops the KV caches, the QSA
indexer's separate cache and PLE's carried token ids together, because
any one of them surviving a request reads the previous request's text
into the next one — and none of the three fails loudly when it does.

The arch takes the single-sequence path deliberately. It has no
snapshot support, so supports_kv_snapshot stays false and the batched
paths fall through to their existing "no batched-decode support" arms.
That is correct rather than temporary-and-forgotten: extending
snapshot.rs to carry PLE's two state slots and the indexer cache is its
own piece of work, and until it lands this model serves one request at a
time whatever max_in_flight says.

Tensor parallelism is refused, by being absent from
TP_SUPPORTED_MODEL_TYPES. There is no tp_qwen4_exp.rs, and an operator
who asks for it should be told rather than silently handed a
single-device load. That list is cfg(cuda) and so is the function that
reads it, which means no CPU-build test can cover it; a test that never
executes would be worse than the comment now sitting on the list, so
that is what it got.

Refs #307, #312, #315.
2026-09-02 08:37:53 +03:00
grenade 671a8e8659 Merge branch 'feat/qwen4-exp-qsa-oracle'
build-prerelease / Resolve version stamps + change detection (push) Successful in 12s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m43s
build-prerelease / Build neuron-ampere (push) Successful in 2m39s
build-prerelease / Build neuron-ada (push) Successful in 2m39s
build-prerelease / Test (push) Successful in 7m10s
build-prerelease / Build neuron-blackwell (push) Successful in 14m59s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m3s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m6s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m32s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 29s
2026-09-02 08:34:53 +03:00
grenade a34c86ebc7 test(qwen4_exp): gate 5 — the batched selection against a naive one
CI / Config consistency (push) Successful in 6s
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 8s
CI / Clippy (push) Successful in 2m35s
CI / Test (push) Successful in 7m48s
CI / CUDA type-check (push) Successful in 19m20s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Gate 4 proves the layer is exactly dense attention below the budget.
That is a strong statement about the easy case and says nothing about
the hard one, where the selection actually discards blocks and the
ordering of the scores decides which. This is the hard one.

The oracle is the spec's reference shape: one query at a time, its own
pooling in f64, its own visibility, its own scoring and top-k and tail
assembly, all in plain Rust. It shares only the projection and the
rotation with the implementation, because those are the layer's weights
rather than the subject. 23 positions against a budget of two blocks in
five, so three blocks are discarded per query.

Also checked across a decode boundary, where the reference sees one flat
sequence and the implementation sees a prefill and then a step reading
its own cache — the place where an off-by-one in past_len still returns
a plausible set.

Two guards, because a differential test that agrees on nothing is worse
than no test. The comparison asserts that at least one query really did
discard a block, so it cannot quietly degenerate into two
implementations of dense attention agreeing. And the oracle was checked
against a mutation: removing the ReLU before the head sum fails both of
these and nothing else in the file, which is the property that makes
them worth running.

Refs #307, #311.
2026-09-02 07:53:58 +03:00
grenade 29c0ce06ba Merge branch 'feat/qwen4-exp-model'
build-prerelease / Resolve version stamps + change detection (push) Successful in 15s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m41s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 2m38s
build-prerelease / Build neuron-ada (push) Successful in 2m43s
build-prerelease / Test (push) Successful in 8m7s
build-prerelease / Build neuron-blackwell (push) Successful in 15m18s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m5s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m5s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m56s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 30s
2026-09-02 07:50:11 +03:00
grenade 2fb863f8c8 Merge branch 'feat/qwen4-exp-decoder'
build-prerelease / Resolve version stamps + change detection (push) Successful in 18s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m39s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m27s
build-prerelease / Build neuron-ampere (push) Successful in 2m29s
build-prerelease / Test (push) Successful in 8m44s
build-prerelease / Build neuron-blackwell (push) Successful in 15m19s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m6s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m13s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m15s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 28s
2026-09-01 20:29:30 +03:00
grenade fb79fae5e0 feat(qwen4_exp): the model, and a checkpoint small enough to test it on
CI / Classify changes (push) Successful in 20s
CI / Config consistency (push) Successful in 9s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m32s
CI / Test (push) Successful in 8m40s
CI / CUDA type-check (push) Successful in 19m41s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Embeddings in, logits out. Four identical streams from the token
embedding, 48 layers, the mixer's collapse, the head — and the n-gram
lookup hoisted to the model, since it is a function of the input ids
and exactly one layer consumes it.

The tensor names in this commit are not the spec's; they are the
checkpoint's own index, and reading it found a bug the spec would not
have. The QSA indexer lives at self_attn.indexer.index_qk_proj, a
submodule of the attention block rather than a sibling, so the loader
written from the spec looked one level too high and would have failed on
the first real load. Everything else the spec predicted is confirmed
verbatim, including that hyper_connection_mixer has no
block_inject_weight and there is no model.norm anywhere — the two claims
this composition rests on.

The three PLE buffers are I64 in a checkpoint whose activations are not,
so they are read through a dtype-forced VarBuilder. Inherited, a bf16
load would round multipliers of order 1e13 and prime vocab sizes of 2e7
into nonsense, the table would be sized wrong, and the addressing would
still return rows.

Every loader is now exercised against a real safetensors file rather
than by inspection: a two-layer model, one of each attention flavour,
PLE on layer 1 because ple_layer_ids is one-indexed, a sharded table
with real prime vocab sizes, written to a temp dir and loaded through
the same ShardedVarBuilder production uses. It produces finite logits,
and a decode step follows a prefill — which is where the KV caches, the
rotary offset and PLE's carried ids all have to agree, and where any one
of them being wrong still returns a tensor of the right shape.

Names and shapes are the failure mode no maths test reaches, and a
360 GB download is the expensive place to meet them.

Refs #307, #311, #312.
2026-09-01 19:44:17 +03:00
grenade cc114cc781 feat(qwen4_exp): the n-gram gather, behind a seam #310 can move
Sixteen rows of 160 per token out of 320,001,536 — a few KB drawn from
27 GB. That ratio is the whole reason this table can live off-device for
almost nothing, and the reason #310 is a residency decision rather than
an algorithm. So the gather arrives as a trait, and where the bytes sit
changes one implementation and nothing above it.

ShardedNGramTable is the naive reading of the checkpoint: the 128 shards
held wherever the VarBuilder put them. That is the control #310 measures
its host- and NVMe-resident variants against, not its answer. The shards
stay separate rather than concatenated because joining 128 tensors of
2,500,012 x 160 would want the whole table free a second time — and
because a flat split means a global row index addresses straight into
shard `row / rows_per_shard` with no bookkeeping.

The gather visits each shard once and then undoes the permutation. That
inverse is the part worth a test: get it wrong and every token is handed
somebody else's n-gram, in the right shape, with no error. The test asks
for five rows out of order across a shard boundary and asserts the
contents, since rows that carry their own index are the only way to see
it.

Bounds are an error rather than a panic. An out-of-range row means the
hashing upstream is wrong, and finding that out through a killed forward
mid-request is the worst available way to learn it.

Refs #307, #310, #312.
2026-09-01 19:37:47 +03:00
grenade 61e2c9b50d Merge branch 'feat/qwen4-exp-linear-attn'
build-prerelease / Resolve version stamps + change detection (push) Successful in 21s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m38s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 2m40s
build-prerelease / Build neuron-ampere (push) Successful in 2m43s
build-prerelease / Test (push) Successful in 9m53s
build-prerelease / Build neuron-blackwell (push) Failing after 25m6s
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been skipped
# Conflicts:
#	crates/neuron/src/harness/arch/qwen4_exp/mod.rs
2026-09-01 19:13:40 +03:00
grenade d6e4ac3c5f feat(qwen4_exp): the decoder layer, where four streams replace the norms
CI / Classify changes (push) Successful in 22s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 10s
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m48s
CI / Test (push) Successful in 10m55s
CI / CUDA type-check (push) Successful in 20m37s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The composition. Every other architecture we serve wraps a sublayer as
x = x + sublayer(norm(x)) over one residual stream; this one carries
four, and has no input_layernorm, no post_attention_layernorm and no
final model.norm anywhere in the checkpoint. The hc_norm inside each
hyper-connection does all three jobs, which is why looking for the
missing tensor is a dead end.

The spec's warning about this layer is the reason for its shape: a wrong
stream mix still produces fluent text. So the plumbing is a free
function taking the sublayer as a closure, and can be checked against a
known function without building an attention block. Two properties are
asserted there, because both are invisible downstream:

The sublayer is handed the mixed 2560 vector, not the 10240 stream —
every sublayer in the model is built for the narrow one, so the wide
tensor would be caught, but the narrow-but-wrong one would not.

And a sublayer that returns zero leaves the stream exactly as it was,
not as its normalised form. That single assertion is what separates a
correct residual from one that quietly re-normalises the model at every
layer, which is a plausible reading of a block whose only norm lives
inside the hyper-connection.

PLE is added into the stream before the attention hyper-connection
rather than merged into it, and only on the layers ple_layer_ids names
— one-indexed, so layer 1. A layer that carries the block and is handed
no gathered embedding says so rather than continuing without it.

Refs #307, #312.
2026-09-01 19:12:43 +03:00
grenade f876af5e04 Merge branch 'main' into feat/qwen4-exp-decoder
# Conflicts:
#	crates/neuron/src/harness/arch/qwen4_exp/mod.rs
2026-09-01 19:09:14 +03:00
grenade e5df6ebe48 Merge branch 'feat/qwen4-exp-moe'
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Build neuron-blackwell (push) Blocked by required conditions
build-prerelease / Build neuron-ampere (push) Blocked by required conditions
build-prerelease / Build neuron-ada (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 21s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m40s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been cancelled
build-prerelease / Build helexa-router binary (push) Has been cancelled
build-prerelease / Build helexa-tools binary (push) Has been cancelled
build-prerelease / Package helexa-angels RPM (push) Has been cancelled
build-prerelease / Package helexa-router RPM (push) Has been cancelled
build-prerelease / Package helexa-tools RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
# Conflicts:
#	crates/neuron/src/harness/arch/qwen4_exp/mod.rs
2026-09-01 19:08:39 +03:00
grenade a890f61b42 feat(qwen4_exp): 36 layers reused whole, and the two settings that differ
CI / Classify changes (push) Successful in 22s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 17s
CI / Format (push) Successful in 9s
CI / Clippy (push) Successful in 2m34s
CI / Test (push) Successful in 7m9s
CI / CUDA type-check (push) Successful in 22m20s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The GatedDeltaNet carries over untouched — same tensor names, same
split in_proj layout, same delta rule, same conv, same state shapes. The
largest piece of the Qwen3.6 port is also the largest piece of this one,
already written. So this module is a page, and what is in it is the two
places our defaults would answer a question the checkpoint has already
answered.

output_gate_type is sigmoid. Qwen3.6 leaves it unset and the gated
output norm falls back to SiLU, which is what our loader hardcoded.
Inheriting that applies the wrong nonlinearity on every one of these 36
layers, and the failure mode is a model that talks.

mamba_ssm_dtype is float32, against the bf16 round-trip we default to.
That default is deliberate — #284 keeps the flag off because every #283
measurement was taken against the bf16 baseline — but it is a decision
about our model, not this one. The checkpoint wins where it speaks;
NEURON_GDN_STATE_F32 still decides where it is silent, and there is a
test for that case rather than a hardcoded answer.

Making that possible meant the recurrent-state dtype stops being a
process-wide OnceLock read inside the forward and becomes a field, and
the loader learns to take a GatedDeltaNetParams instead of a
qwen3_5::TextConfig. qwen3_5 builds those params from its own config
with today's exact behaviour — SiLU regardless of hidden_act, and the
env flag — so nothing about the 27B moves.

Refs #307, #312.
2026-09-01 18:43:16 +03:00
grenade ca68d0c9b0 Merge branch 'feat/qwen4-exp-full-attn'
build-prerelease / Resolve version stamps + change detection (push) Successful in 16s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m12s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 3m13s
build-prerelease / Build neuron-ada (push) Successful in 2m56s
build-prerelease / Test (push) Successful in 9m16s
build-prerelease / Build neuron-blackwell (push) Successful in 17m48s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m7s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m7s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 3m54s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 30s
2026-09-01 18:38:27 +03:00
grenade 049eaf7a6d feat(qwen4_exp): the MoE, loaded from two tensors instead of 512 modules
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 20s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 13s
CI / Clippy (push) Successful in 3m2s
CI / Test (push) Successful in 7m41s
CI / CUDA type-check (push) Successful in 19m21s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Every layer's FFN, since this architecture has no dense one. The
routing is qwen3_5's unchanged — softmax over all 512 in f32, top 10,
renormalise, sigmoid-gated shared expert — so this is a loader, not an
algorithm. The checkpoint stacks the routed experts into
gate_up_proj (512, 1280, 2560) and down_proj (512, 2560, 640) where our
loader expects experts.{i}.{gate,up,down}_proj, and slicing is the whole
difference.

The one thing that can go wrong silently is the order inside
gate_up_proj: the first moe_intermediate_size rows are the gate and the
last are the up projection. Reversed, the block computes
silu(up) * gate — a different function of the same weights, no shape
error, no failure, just a model that reasons a little worse. The test
runs an expert whose gate row reads one input channel and whose up row
reads the other, so the two readings differ by silu(1)*2 against
silu(2)*1 and the assertion has to pick one.

Keeping the fused layout would be faster — it is what a grouped GEMM
wants — but that is an algorithm change and this stage is deliberately
not it. At 10 of 512 with an intermediate of 640, the routing dominates
the GEMM shape anyway.

Two constructors on qwen3_5 stop being test-only to make this possible:
MLP::from_weights, and a from_parts on the MoE block. Neither changes
how qwen3_5 loads or routes.

Refs #307, #312.
2026-09-01 18:34:47 +03:00
grenade 9d0e7717f4 Merge branch 'feat/qwen4-exp-qsa-indexer'
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Build neuron-blackwell (push) Blocked by required conditions
build-prerelease / Build neuron-ampere (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 24s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m36s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has started running
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-09-01 18:31:55 +03:00
grenade f0a3563879 feat(qwen4_exp): the full-attention layer, and a mask the kernel cannot lie about
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 18s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Successful in 2m49s
CI / Test (push) Successful in 9m24s
CI / CUDA type-check (push) Successful in 19m21s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
One layer in four. Mechanically it is qwen3_5's — GQA 24:2, per-head
(1+w) norms on q and k, sigmoid output gate in a widened q_proj — so
what is worth reading is the two places it is not.

The gate splits within each head, not down the middle of the vector.
q_proj is num_heads * head_dim * 2 wide and the halves are taken inside
each head; halving the flat 12288 takes the first twelve heads' queries
as the query and the last twelve as the gate. Both readings produce a
tensor of the right shape and a model that talks. The test puts a single
token through, where attention is the identity on v and the output is
the gate alone, so the two readings differ by sigmoid(2) against
sigmoid(3) in a value that is asserted.

And the QSA mask cannot reach the flash kernel, because it is no longer
expressible there. attention_context took Option<&Tensor> and read
is_some() as "causal", never looking at the tensor — its own doc-comment
recorded that as an invariant and asked that a future non-causal mask
extend the signature rather than pass through silently. It does now:
AttnMask is None, Causal, or Additive, and Additive forces the eager
path. Nothing about qwen3_5 changes; Some becomes Causal.

The indexer declines to produce a mask below the budget, where its
answer would be the causal mask exactly. That keeps the flash path
available for short prompts instead of surrendering it to a mask that
says nothing, and skips the pooling, rotation and scoring whose result
is already known. Both caches advance either way, and both clear
together — an indexer cache that outlived a request would select blocks
of the previous one's text.

Refs #307, #311.
2026-09-01 18:16:54 +03:00
grenade 74c8647ec5 feat(qwen4_exp): the QSA mask, and where it must never be sent
CI / Config consistency (push) Successful in 8s
CI / Classify changes (push) Successful in 20s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m36s
CI / Test (push) Successful in 6m52s
CI / CUDA type-check (push) Successful in 21m4s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Turns the selection into the shape the attention layer consumes: the
same additive (B, 1, L, past + L) mask qwen3_5 already builds, so it
goes where that one goes. It is already causal — the selection only
offers positions at or behind the query — so it replaces the causal
mask rather than being ANDed with it.

Gate 4 is now stated in its consumable form: below the budget the mask
is the plain causal mask value for value. That is what makes short-
prompt parity against dense attention an exact test rather than a
tolerance.

Two things this surfaced that are worth more than the code.

The mask must never reach the flash-attn path. That path reads
attn_mask.is_some() as "apply causal masking" and never looks at the
tensor, so a QSA mask handed to it is silently replaced by a dense
causal one: the model runs, the budget does nothing, and the only
symptom is that long context costs exactly what it did before. A layer
building this has to take the eager path until the spec's open question
about gathering rather than masking is answered.

And the query does not always attend its own position. The tail carries
the diagonal, and when the visible count is a whole number of blocks
there is no tail — the query's own position sits in the last complete
block and competes for the budget like any other. That follows from the
spec's step 7 as written, and it is pinned here rather than left to be
discovered, but it deserves a reference trace: the alternative reading,
that the current block is always kept, is also a sensible design and
the two differ only every fourth position.

The dense mask is the correctness-first form. At the full window it
would be a 68 G-element tensor per layer, so serving wants the gather
the spec asks for; that is a separate change with a test to hold it
honest.

Refs #307, #311.
2026-09-01 18:09:13 +03:00
grenade 5745708e89 feat(qwen4_exp): the QSA indexer, and the second KV cache nobody counted
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 18s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / Clippy (push) Has started running
CI / Test (push) Has been cancelled
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
The selection arithmetic landed without the layer around it. This is the
layer: the fused index_qk_proj, the two norms, the rotation, and the
indexer's own cache — 12 layers x 1 head x 128 dims, about 3 KiB/token
at bf16 beside the main 24. llama.cpp measures 2304 MB of it against
6144 MB of main cache at the full window, so it is a ~27% surcharge on
every KV budget arithmetic this epic has written so far, and it now
exists somewhere the budgeting can find it.

Three orderings decide whether this is the right model, and none of them
are visible in the shapes.

The cached key is raw — pre-norm, pre-RoPE. A block is rotated once, at
its first position, so caching rotated keys would put three of every
four at the wrong angle and still return a plausible selection. The test
asserts the cache against the projection output rather than against a
shape.

Pooling comes before the norm, so a block is normalised as a block.

And index_qk_proj is fused 4:1, not halved: 512 query channels and 128
key channels of 640. A halving takes 320 and 320 and puts half a query
head into the key, which is a permutation error that reads as a weak
indexer rather than as a bug.

Gate 4 is now testable through the whole layer and not just the
selector: below the budget every query attends its entire past, so the
layer is exactly dense causal attention. A decode step is separately
asserted to select what a prefill covering the same text would, with a
budget small enough that the selection actually binds — otherwise the
property is vacuous.

Pooling and scoring are batched into one matmul, since a block's pooled
key does not depend on which query reads it; only the top-k runs per
query. The reference's double loop is an oracle, not a design.

Rotation needed a seam: apply_cos_sin asserts the head width against its
own head_dim, and these heads are 128 wide rotating the same 64 dims as
the 256-wide attention heads. The narrow-rotate-cat is now
apply_partial_rotary, parameterised on rotary_dim, because the rotation
is a property of the frequencies rather than of the head reading them.
cos_sin_at gathers at non-contiguous positions, which is what rotating
every fourth position needs.

Refs #307, #311.
2026-09-01 18:06:26 +03:00
grenade 41dd2c39b5 Merge branch 'feat/qwen4-exp-config'
build-prerelease / Resolve version stamps + change detection (push) Successful in 21s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m47s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 2m43s
build-prerelease / Build neuron-ada (push) Successful in 2m44s
build-prerelease / Test (push) Successful in 7m58s
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-blackwell (push) Successful in 15m13s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m13s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m17s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m57s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 30s
2026-09-01 17:51:55 +03:00
grenade 0ace8f77bf Merge branch 'feat/qwen4-exp-qsa-selection'
build-prerelease / Test (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-ada RPM (push) Blocked by required conditions
build-prerelease / Package helexa-neuron-blackwell RPM (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 19s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m36s
build-prerelease / Build neuron-blackwell (push) Successful in 14m37s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Successful in 2m35s
build-prerelease / Build neuron-ada (push) Successful in 2m34s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m6s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
# Conflicts:
#	crates/neuron/src/harness/arch/qwen4_exp/mod.rs
2026-09-01 17:29:47 +03:00
grenade 366bfcbb47 feat(qwen4_exp): the checkpoint's config, and what it does not say
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 16s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m34s
CI / Test (push) Successful in 7m57s
CI / CUDA type-check (push) Successful in 19m40s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Read against the real config.json rather than the spec's summary of it,
which turned up two things the summary could not: there is no
intermediate_size at all — every layer's FFN is the MoE, so there is no
dense width to declare — and norm_topk_prob is simply absent, so the
port has to supply upstream's default rather than find it in the file.

Three fields mean something other than what they say, and each is
asserted rather than commented.

ple_layer_ids is one-indexed. The shipped [2] is layer 1. Read as a
layer number it puts 28% of the model's parameters one layer late, and
the model still speaks — so a zero here is rejected as a config written
against the wrong convention rather than accepted as a layer.

output_gate_type is sigmoid where hidden_act is silu. Qwen3.6 leaves the
field unset and falls back to hidden_act, so inheriting that habit is
wrong on all 36 linear-attention layers.

mamba_ssm_dtype is float32, against our bf16 round-trip default (#284).
It is exposed as an Option so the absence of the field and an explicit
choice are distinguishable, because silently overriding upstream with
our own tradeoff is how a port inherits a bug it never decided to take.

The rope block is reused from qwen3_5 verbatim — same struct, not just
the same maths — and a test pins the four values its interleave masks
were written against. That closes the caveat on #312's M-RoPE
verification: the reuse is only as good as the config that feeds it.

Refs #307, #312.
2026-09-01 17:21:09 +03:00
grenade 37f6dc72a0 Merge branch 'feat/qwen4-exp-ple-consumption'
build-prerelease / Build neuron-blackwell (push) Blocked by required conditions
build-prerelease / Resolve version stamps + change detection (push) Successful in 20s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m34s
build-prerelease / Build cortex binary (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Build helexa-upstream binary (push) Has been skipped
build-prerelease / Package cortex RPM (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Package helexa-upstream RPM (push) Has been skipped
build-prerelease / Build helexa-angels binary (push) Has been skipped
build-prerelease / Package helexa-angels RPM (push) Has been skipped
build-prerelease / Build helexa-router binary (push) Has been skipped
build-prerelease / Build helexa-tools binary (push) Has been skipped
build-prerelease / Package helexa-router RPM (push) Has been skipped
build-prerelease / Package helexa-tools RPM (push) Has been skipped
build-prerelease / Build neuron-ada (push) Successful in 3m6s
build-prerelease / Build neuron-ampere (push) Successful in 3m7s
build-prerelease / Test (push) Successful in 8m10s
build-prerelease / Package helexa-neuron-ada RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been cancelled
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been cancelled
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Has been cancelled
2026-09-01 17:16:57 +03:00
grenade 5414fce1e8 feat(qwen4_exp): which blocks of the past a layer may look at
CI / Config consistency (push) Successful in 9s
CI / Classify changes (push) Successful in 22s
CI / Format (push) Successful in 13s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Clippy (push) Successful in 2m33s
CI / Test (push) Successful in 11m16s
CI / CUDA type-check (push) Successful in 19m26s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
QSA is what makes a 262k window affordable: the twelve full-attention
layers score blocks of four past positions with a small side-channel and
attend only the best 512 of them. This is the selection — pooling,
scoring, and the position set that falls out. The indexer's own KV
cache, the RoPE, and the mask stay with the attention layer; the
selection is the part that can be checked exactly.

Below the budget it selects everything, so a prompt under 2048 tokens
must be bit-identical to dense causal attention. That is the cheapest
gate this architecture offers — no reference trace, no 360 GB download —
and it is asserted here rather than left as a note for whoever wires the
layer up.

Three things are pinned because each is a plausible reading of the
reference that quietly changes the model:

The ReLU comes before the head sum. Summed signed, a head that rejects a
block cancels one that wants it, and the top-512 is chosen on a
different quantity.

The tail is never scored and never budgeted. The `n_visible mod 4`
positions that cannot form a block include the query's own, so budgeting
them is not a degradation but a hole in the causal chain.

Pooling is f32 and stays f32. Casting the mean back to the cache dtype
undoes it: near 0.25 a bf16 tick is 0.002, so a bf16 accumulation and a
cast-back f32 one land on the same wrong number. The test asserts the
value, not the dtype, because only the value distinguishes them.

Refs #307, #311.
2026-09-01 16:54:37 +03:00
grenade c1205a7b45 feat(qwen4_exp): the PLE gate, and the dilated conv behind it
CI / Config consistency (push) Successful in 14s
CI / Classify changes (push) Successful in 26s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 22s
CI / Clippy (push) Successful in 2m45s
CI / Test (push) Successful in 15m21s
CI / CUDA type-check (push) Successful in 19m41s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The addressing commit left two pieces: the gather, and what consumes
it. This is the consumer — the gated read that turns a gathered n-gram
row into something the four residual streams can absorb. Each stream
scores the proposed key against its own state, and that score alone
decides how much of the value it takes; the streams disagree, which is
the point of carrying four of them.

The gather stays out. It is the only part of PLE that depends on where
a 27 GB table lives, and that is #310's decision — nothing here changes
whichever way it goes.

Two details are pinned by tests because both run happily when wrong.
The short conv is dilated by ngram_size, so it reaches nine positions
back rather than three: the test drops a single impulse and asserts
where the response lands, which is position 3 with the dilation and
position 1 without it. It cannot reuse run_causal_conv1d — that path,
and the fused cuda kernel under it, assume dilation 1 — so the rolling
state is cached at the full reach rather than at kernel_size, and a
step-by-step decode is asserted equal to the whole-sequence prefill to
prove the width is right.

The gate's signed sqrt is the other one. It compresses magnitude
without losing sign, so a disagreeing stream still shuts the gate;
skipping it leaves a gate that is merely too confident, which reads as
a slightly worse model rather than as a bug. The hand-computed case
pins 0.80 against the 0.88 you get without it.

Refs #307, #312.
2026-09-01 16:48:52 +03:00
grenade 08608262aa Merge branch 'feat/qwen4-exp-ple-hashing'
build-prerelease / Resolve version stamps + change detection (push) Successful in 26s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m37s
build-prerelease / Build helexa-router binary (push) Successful in 1m59s
build-prerelease / Build helexa-bench binary (push) Successful in 2m9s
build-prerelease / Build cortex binary (push) Successful in 2m29s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m51s
build-prerelease / Build helexa-angels binary (push) Successful in 3m15s
build-prerelease / Build helexa-tools binary (push) Successful in 1m45s
build-prerelease / Build neuron-ampere (push) Successful in 2m14s
build-prerelease / Build neuron-ada (push) Successful in 2m37s
build-prerelease / Test (push) Successful in 14m3s
build-prerelease / Build neuron-blackwell (push) Successful in 15m14s
build-prerelease / Package helexa-router RPM (push) Successful in 19s
build-prerelease / Package helexa-bench RPM (push) Successful in 21s
build-prerelease / Package helexa-upstream RPM (push) Successful in 23s
build-prerelease / Package helexa-tools RPM (push) Successful in 27s
build-prerelease / Package cortex RPM (push) Successful in 36s
build-prerelease / Package helexa-angels RPM (push) Successful in 35s
build-prerelease / Package helexa-neuron-ada RPM (push) Successful in 1m9s
build-prerelease / Package helexa-neuron-ampere RPM (push) Successful in 1m12s
build-prerelease / Package helexa-neuron-blackwell RPM (push) Successful in 4m1s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 32s
2026-09-01 16:42:15 +03:00
grenade b87d24e2f6 Merge branch 'fix/seed-cutlass-anonymously' 2026-09-01 16:42:15 +03:00
grenade 7c45ce95b6 fix(ci): record which wire protocol was refused
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 23s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m33s
CI / Test (push) Successful in 9m13s
CI / CUDA type-check (push) Successful in 20m10s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
"fatal: expected flush after ref listing" comes from git's protocol v2
ref-listing path, and it appears in both recorded failures. That is a
detail worth testing rather than skipping past.

git has defaulted to v2 since 2.26. Under v2 the ref listing is a POST
to git-upload-pack carrying a Git-Protocol header; under v0 it is a
plain GET of /info/refs first. libgit2 still speaks v0 — and cargo,
which uses libgit2, was observed fetching two repositories from this
same host seconds before the git CLI was refused by it. The two clients
were never making the same request, so "the network was fine for cargo"
never implied "the network was fine".

This also matches the clone in run 1330, which completed its ref
advertisement and then failed on the POST that followed.

So measure it: on failure, fetch once under each protocol version and
report both. A genuine access failure fails both, as a nonexistent
repository does under test. v0 succeeding where v2 fails would place the
fault in how something on the path handles the newer protocol rather
than in access to a public repository — and would make pinning
protocol.version=0 for this one fetch the entire fix.

Stated as a hypothesis with a control, not a conclusion. It has not been
observed on a failing runner yet.
2026-09-01 15:20:24 +03:00
grenade cd8d5a7893 fix(ci): trace the real fetch, not a request of our own choosing
CI / CUDA type-check (push) Blocked by required conditions
CI / Classify changes (push) Successful in 20s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 9s
CI / Format (push) Successful in 13s
CI / Clippy (push) Successful in 2m39s
CI / Test (push) Successful in 6m50s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
The probe added previously does a GET of the ref advertisement. That is
not the request that failed. In the clone that broke the build, the
advertisement succeeded — "Clone succeeded, but checkout failed" — and
the refusal came on the POST to git-upload-pack that followed it. A GET
probe would have come back clean on a machine that was actively broken,
which makes it worse than no probe: it invites the wrong conclusion.

Replay the fetch under GIT_TRACE_CURL on the failure path and record
which request was refused and what came back. This also decides, without
inference, whether anything attached credentials: git logs an
Authorization header as <redacted>, so the line appearing at all means
one was sent, and its absence means one was not. That question has been
answered by argument twice in this file's history and never by
observation.

Redaction is left on; the trace prints headers, not bodies.
2026-09-01 15:12:00 +03:00
grenade 5d35b1326f fix(ci): probe the CUTLASS remote on every build, not only on failure
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Classify changes (push) Successful in 21s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 13s
CI / Format (push) Successful in 14s
CI / Clippy (push) Successful in 2m35s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
A diagnostic that fires only on failure yields one isolated sample of a
fault that is intermittent and fleet-wide. There is nothing to compare
it against, so it cannot show whether the refusal correlates with an
edge, an egress address, or a time of day.

Probe on every run instead. Each build now records the status, peer
address, TLS verification result, connection count and elapsed time for
one request to the repository's git endpoint, plus the headers that
identify who answered. Twenty healthy samples make the twenty-first
legible.

The probe issues a single request, not two: it runs on every build, and
doubling traffic to an endpoint suspected of refusing us on volume would
corrupt the measurement it exists to take.

On the first failed attempt, add the checks worth their latency only
once something is wrong: git and kernel versions, resolved addresses,
public egress address, proxy environment, the git config that could
authenticate or redirect a fetch, the API rate-limit budget remaining to
this egress, the TLS chain, and the refusal body. Between them these
separate a throttle from a credential from an interception, which is the
distinction two previous readings of this failure both got wrong.

The egress lookup costs one call to a third party, so it stays on the
failure path rather than the common one.
2026-09-01 15:04:18 +03:00
grenade 8b19c7983c fix(ci): report who refused the CUTLASS fetch, instead of guessing
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 22s
CI / Classify changes (push) Successful in 22s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 3m26s
CI / Test (push) Successful in 7m17s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
The seeding failure has been diagnosed twice from the error text alone
and both readings were wrong. An inherited credential and a GitHub
throttle produce the same "could not read Username" message, and the
logs do not distinguish them. Neither theory survived the evidence.

So capture the answer rather than infer it. On the first failed attempt
only, print the resolved address for the remote, any proxy environment,
the git config that could authenticate or redirect a fetch, and the
response headers from the repository's git endpoint. A GitHub reply
carries `server: GitHub-Babel` and `x-github-request-id`; a proxy or
captive resolver answering 401 carries neither. That single line decides
which of the two problems this is.

Deliberately no public-IP lookup: that would mean calling a third party
from every failing build, and the reply headers already answer the
question.

Everything runs on a path that has already gone wrong, so all of it is
best effort and none of it can fail the script. The anonymity note is
reworded to match: sending no credential removes our side as a variable,
which is not the same as having shown a credential was ever the cause.
2026-09-01 14:48:33 +03:00
grenade 1621aa0599 fix(ci): only seed CUTLASS for the flavour that actually needs it
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Config consistency (push) Successful in 10s
CI / Classify changes (push) Successful in 24s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / Clippy (push) Successful in 2m31s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
The seed step had no guard, so all three neuron flavours ran it. Only
blackwell enables flash-attn, and only candle-flash-attn wants CUTLASS,
so ampere and ada were fetching a dependency they never use.

Run 1335 shows what that costs: all three flavours started the same
CUTLASS fetch within the same second, and all three were refused. Ada
got through on its fourth attempt, blackwell exhausted all five and
failed the build. Two of those three requests were for nothing, and
they made the burst three times larger than the work required.

This does not explain the refusals, which are still untraced — but it
stops the pipeline from generating two thirds of the load that runs
into them.
2026-09-01 14:41:12 +03:00
grenade 9a85dd69e6 Revert "docs(ci): correct how long the CUTLASS cache lives, and why the clone fails"
This reverts commit b2c6dbccc7.
2026-09-01 11:50:01 +03:00
grenade b2c6dbccc7 docs(ci): correct how long the CUTLASS cache lives, and why the clone fails
CI / Config consistency (push) Successful in 13s
CI / Classify changes (push) Successful in 34s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 12s
CI / CUDA type-check (push) Successful in 19m13s
CI / Clippy (push) Successful in 2m40s
CI / Test (push) Successful in 7m3s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Both jobs run under act's hostexecutor, directly on the machine as root,
not in a per-job container: the logs show the hostexecutor path and a
real /root home. So ${CUDAFORGE_HOME:-$HOME/.cudaforge} is per machine
and survives between jobs — "one VM per job so that cache starts empty
every time" was wrong. It is cold on a machine that has not built this
flavour yet, not on every job.

Drop the rate-limit explanation with it. A burst of concurrent jobs is
not what refuses these clones; an inherited credential is, and root's
git config being long-lived machine state is what makes it vary between
otherwise identical runners.
2026-09-01 11:43:21 +03:00
grenade ce4bfb7199 fix(ci): make the CUTLASS fetch anonymous, in the build as well as the seed
CI / Clippy (push) Blocked by required conditions
CI / Test (push) Blocked by required conditions
CI / CUDA type-check (push) Blocked by required conditions
CI / Classify changes (push) Successful in 18s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Config consistency (push) Successful in 9s
CI / Format (push) Successful in 12s
CI / Build cortex SRPM (push) Has been cancelled
CI / Build neuron SRPM (push) Has been cancelled
CI / Publish cortex to COPR (push) Has been cancelled
CI / Publish neuron to COPR (push) Has been cancelled
CI / Bump version in source (push) Has been cancelled
The blackwell neuron build failed on main with an HTTP 401 from GitHub,
five times in a row, instantly each time:

  error: RPC failed; HTTP 401 curl 22 The requested URL returned error: 401

It is not a rate limit. Seconds earlier in the same step on the same
machine, `cargo fetch` pulled two GitHub repositories without complaint;
cargo fetches through gitoxide, which ignores http.extraheader and
credential helpers, while the seed script shells out to the git CLI.
Only the git CLI sees the credential, and CUTLASS is public, so the
credential is what turns a 200 into a 401. The same script had seeded on
attempt 1 twenty minutes earlier on another machine in the pool, which is
why this looked intermittent.

Discard global and system git config for the fetch rather than clearing
individual keys. Clearing credential.helper and http.<url>.extraheader
does neutralise an injected auth header — verified — but leaves
url.<...>.insteadOf rewrites and GIT_CONFIG_COUNT injection in place, and
nothing here needs to know which vector a given machine carries.

Give the CUDA build steps the same environment. Seeding is best effort,
so when it fails cudaforge still shells out to `git clone` and still dies
on the same credential; that is the path that actually failed the job.
Scoped to the build step, since actions/checkout writes global config.

Verified against a reproduction of the failure: with an inherited
http.extraHeader that makes a plain `git ls-remote` of CUTLASS fail with
"could not read Username", the script seeds at attempt 1 in 2.7s, at the
pinned commit, with include/ and tools/util/include populated, and
short-circuits on a warm cache.
2026-09-01 11:38:59 +03:00
grenade 797db2f7b6 feat(qwen4_exp): addressing for the PLE hashed n-gram table
CI / Config consistency (push) Successful in 7s
CI / Classify changes (push) Successful in 15s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 10s
CI / CUDA type-check (push) Successful in 20m55s
CI / Clippy (push) Successful in 2m34s
CI / Test (push) Successful in 7m48s
CI / Build cortex SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
The n-gram table is 28.44% of this model's parameters — 320,001,536
rows that each token touches sixteen of. This is the addressing half:
turning token ids into row indices. The gather, and the consumption
around it, come separately.

Two behaviours here are invisible on a single-turn prompt and wrong
everywhere else, so both are pinned. Shifts must not read across an EOS:
the first token of a segment sees EOS as its predecessor rather than
borrowing the previous document's tokens. And the bigram heads must
depend on two tokens where the trigram heads see three, which the tests
check by varying only the grandparent token and asserting exactly half
the row moves.

The modulo is rem_euclid rather than %. On this checkpoint the two agree
— upstream derives multiplier_max as i64::MAX / vocab_size precisely so
products cannot overflow, which keeps every sign bit clear and mixed
non-negative, and there is a test asserting that bound rather than
trusting the comment. But torch follows Python's sign convention, so a
checkpoint with different constants would silently produce negative row
indices under Rust's %. Euclidean makes the addressing correct rather
than incidentally correct.

The three derived buffers ship in the checkpoint, so the prime search
and splitmix64 multiplier derivation are deliberately not reimplemented;
NGramHasher::new validates that what was loaded agrees with the declared
geometry instead.

Refs #307, #312.
2026-08-31 16:15:38 +03:00
grenade 505353fdfc Merge branch 'feat/hf-endpoint-env'
build-prerelease / Resolve version stamps + change detection (push) Successful in 16s
build-prerelease / Build helexa-bench binary (push) Successful in 2m16s
build-prerelease / Lint (fmt + clippy) (push) Successful in 3m5s
build-prerelease / Build cortex binary (push) Successful in 2m36s
build-prerelease / Build helexa-upstream binary (push) Successful in 2m42s
build-prerelease / Build helexa-angels binary (push) Successful in 3m9s
build-prerelease / Build neuron-blackwell (push) Failing after 5m14s
build-prerelease / Build neuron-ada (push) Successful in 4m42s
build-prerelease / Build neuron-ampere (push) Successful in 6m26s
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Test (push) Successful in 7m45s
build-prerelease / Package helexa-bench RPM (push) Successful in 23s
build-prerelease / Package helexa-upstream RPM (push) Successful in 22s
build-prerelease / Package cortex RPM (push) Successful in 27s
build-prerelease / Package helexa-angels RPM (push) Successful in 29s
build-prerelease / Build helexa-tools binary (push) Successful in 1m21s
build-prerelease / Build helexa-router binary (push) Successful in 1m26s
build-prerelease / Package helexa-tools RPM (push) Successful in 17s
build-prerelease / Package helexa-router RPM (push) Successful in 21s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 19s
2026-08-31 16:15:30 +03:00
grenade cb75856ab8 Merge branch 'fix/seed-cutlass-in-cuda-ci' 2026-08-31 16:15:30 +03:00
grenade 3f30487a7b fix(ci): seed the CUTLASS checkout instead of cloning it mid-build
CI / Config consistency (push) Successful in 6s
CI / Classify changes (push) Successful in 14s
CI / Web (lint + typecheck + i18n + build) (push) Has been skipped
CI / Format (push) Successful in 11s
CI / Clippy (push) Successful in 2m37s
CI / Test (push) Successful in 8m46s
CI / CUDA type-check (push) Successful in 19m0s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
Every CUDA job clones CUTLASS from GitHub. candle-flash-attn's build
script hands cudaforge a pinned commit, cudaforge caches it under
~/.cudaforge/git/checkouts/cutlass-<commit[..16]>, and our runners are
one VM per job, so that cache starts empty every single time. Four
feature branches plus main's three neuron flavours was enough to start
getting refused:

  fatal: could not read Username for 'https://github.com': No such device
  Error: GitOperationFailed("git clone failed with status: exit status: 128")

which reads like a credentials problem and is actually a rate limit. It
failed a 20-minute job, and the build script has no retry.

cudaforge skips the fetch entirely when the cache already holds include/
at the right commit (dependency.rs, fetch_with_lock), so seeding it up
front removes GitHub from the build's critical path. The seed is a
sparse, depth-1 fetch of the pinned SHA: 2.6s and 42 MB against the
20 minutes it protects.

The commit is read from the candle-flash-attn source cargo actually
resolved rather than duplicated into the workflow, so it cannot drift
from the candle pin. GIT_TERMINAL_PROMPT=0 turns a refused clone into a
fast error rather than a blocked read on a username nobody will type.

Best-effort throughout, deliberately: cargo fetch is guarded and the
script exits 0 on every failure path, so a build that would have passed
today still passes, and cargo still produces the authoritative error
when the fetch genuinely cannot be made. It only ever removes a failure
mode.

Worth noting what this does not fix: the build still depends on GitHub
serving anonymous clones the first time. Mirroring CUTLASS somewhere we
control, or baking it into the runner image, is the durable answer.
2026-08-31 15:53:39 +03:00
38 changed files with 7009 additions and 230 deletions
+28
View File
@@ -564,6 +564,25 @@ jobs:
# escalating to a slower uncached rebuild. The cache covers the
# ~600-crate host-side dep tree (the bulk of the 10-14 min build),
# shared across all three flavours, so even one run seeds the next.
# candle-flash-attn's build script clones CUTLASS from GitHub, and the
# runners are one VM per job so that cache starts empty every time.
# A burst of concurrent CUDA jobs is enough to start getting refused
# ("could not read Username for 'https://github.com'"), which fails a
# long job on a transient rate limit. Seed the cache first, with the
# retries the build script does not have. Both commands are
# best-effort: on failure the build proceeds exactly as it would have
# and reports the authoritative error itself.
#
# Only the flash-attn flavour pulls in candle-flash-attn, and only
# candle-flash-attn wants CUTLASS. Guard on that: unguarded, all
# three flavours raced for the same checkout at the same moment and
# two of them never used it, tripling a burst of exactly the request
# that gets refused.
- name: Seed the CUTLASS checkout
if: contains(matrix.cargo_features, 'flash-attn')
run: |
cargo fetch || true
script/seed-cutlass.sh
- name: Build neuron with CUDA (${{ matrix.flavour }})
run: |
export PATH="${{ matrix.cuda_home }}/bin:${PATH}"
@@ -579,6 +598,15 @@ jobs:
CUDA_COMPUTE_CAP: ${{ matrix.compute_cap }}
CARGO_BUILD_JOBS: ${{ matrix.build_jobs }}
NVCC_THREADS: ${{ matrix.nvcc_threads }}
# cudaforge shells out to `git clone` for CUTLASS if the seed
# step above did not populate the cache. Give that clone the
# same anonymous git environment the seed script uses: CUTLASS
# is public, and an inherited credential turns a 200 into a
# 401. Scoped to this step — actions/checkout writes global
# config and must not see these.
GIT_CONFIG_GLOBAL: /dev/null
GIT_CONFIG_SYSTEM: /dev/null
GIT_CONFIG_COUNT: "0"
- name: Stage binary
run: |
+22
View File
@@ -290,6 +290,18 @@ jobs:
# script (see build-prerelease.yml's neuron build for the same
# pattern). It probes for sccache and, on a rustc SIGSEGV / OOM,
# keeps the cache and fails fast rather than rebuilding uncached.
# candle-flash-attn's build script clones CUTLASS from GitHub, and the
# runners are one VM per job so that cache starts empty every time.
# A burst of concurrent CUDA jobs is enough to start getting refused
# ("could not read Username for 'https://github.com'"), which fails a
# long job on a transient rate limit. Seed the cache first, with the
# retries the build script does not have. Both commands are
# best-effort: on failure the build proceeds exactly as it would have
# and reports the authoritative error itself.
- name: Seed the CUTLASS checkout
run: |
cargo fetch || true
script/seed-cutlass.sh
- name: cargo check --features cuda (sccache escalation)
run: |
# act launches the step shell without /etc/profile, so the
@@ -302,6 +314,16 @@ jobs:
export LD_LIBRARY_PATH="/usr/local/cuda-13.0/targets/x86_64-linux/lib:/usr/local/cuda-13.0/lib64:${LD_LIBRARY_PATH:-}"
export LIBRARY_PATH="/usr/local/cuda-13.0/targets/x86_64-linux/lib:/usr/local/cuda-13.0/lib64:${LIBRARY_PATH:-}"
script/ci-cargo-escalate.sh cargo check -p neuron --features cuda,flash-attn --all-targets
env:
# cudaforge shells out to `git clone` for CUTLASS if the seed
# step above did not populate the cache. Give that clone the
# same anonymous git environment the seed script uses: CUTLASS
# is public, and an inherited credential turns a 200 into a
# 401. Scoped to this step — actions/checkout writes global
# config and must not see these.
GIT_CONFIG_GLOBAL: /dev/null
GIT_CONFIG_SYSTEM: /dev/null
GIT_CONFIG_COUNT: "0"
srpm-cortex:
name: Build cortex SRPM
+85 -15
View File
@@ -317,21 +317,82 @@ jobs:
# restart decision below, exactly like the drop-in.
neuron_toml_changed="${{ steps.neuronconf.outputs.changed }}"
# ── Desired per-model systemd drop-in ─────────────────────────
# model.conf carries NEURON_MAX_PROMPT_TOKENS so the context cap
# is deterministic per host and rolled out (with a restart) by
# ── Desired per-host systemd drop-in ──────────────────────────
# model.conf carries the environment neuron is started with, so
# it is deterministic per host and rolled out (with a restart) by
# this workflow, not hand-edited. It sorts after local.conf, so a
# deploy-managed value wins over any manual local override of the
# same variable. See doc/context-limits.md.
# same variable.
#
# RUST_LOG is fleet-uniform and unconditional. The code default
# is `info`, which is too coarse to diagnose a load or a routing
# decision after the fact, and before this it was `debug` on
# exactly one host because someone set it there by hand. A
# verbosity you have to remember to set is a verbosity you find
# out you did not have at the moment you needed it. Bumping a
# module (`debug,neuron::harness::candle=trace`) is a one-line
# change here, applied everywhere, with an audit trail.
#
# NEURON_MAX_PROMPT_TOKENS is per-host and stays conditional;
# see doc/context-limits.md.
conf=/etc/systemd/system/neuron.service.d/model.conf
config_changed=${neuron_toml_changed:-0}
rust_log="debug"
desired=$(printf '%s\n%s\n%s' \
"# Managed by .gitea/workflows/deploy.yml - do not edit by hand." \
"[Service]" \
"Environment=RUST_LOG=${rust_log}")
if [ -n "${max_prompt_tokens}" ]; then
desired=$(printf '%s\n%s\n%s\n%s' \
"# Managed by .gitea/workflows/deploy.yml - do not edit by hand." \
"# Per-model context cap; see doc/context-limits.md." \
"[Service]" \
desired=$(printf '%s\n%s' "${desired}" \
"Environment=NEURON_MAX_PROMPT_TOKENS=${max_prompt_tokens}")
[ "${desired}" = "$(cat "${conf}" 2>/dev/null || true)" ] || config_changed=1
fi
[ "${desired}" = "$(cat "${conf}" 2>/dev/null || true)" ] || config_changed=1
# ── Desired registry drop-in ──────────────────────────────────
# Every model fetch goes through rustingface rather than
# huggingface.co: it holds the upstream credential, and it
# caches to the operator's store, so the second host to want a
# checkpoint does not re-cross the internet for it.
#
# This makes rustingface a hard dependency for every cold load
# on the fleet. That is the point, not a side effect. An outage
# on our own host is one we can end; an outage at huggingface.co
# is one we can only wait out, and a serving stack that stops
# when someone else's CDN does is not sovereign in any sense
# worth the word.
#
# So do NOT add a fallback to huggingface.co here. It would read
# as a robustness improvement and would delete the property this
# exists for: the fleet would silently resume depending on a
# third party at exactly the moments that dependency matters.
#
# registry.conf sorts after local.conf, so the deploy-managed
# values win over a hand-set HF_TOKEN — which is the point:
# this is the mechanism that stops the registry being a
# hand-synced per-host decision (#287).
#
# Mode 0600, and compared by hash under sudo, because it
# carries a token. Written ONLY when the secret is non-empty:
# pointing HF_ENDPOINT at a registry we cannot authenticate to
# turns every cold load into a 401, which is strictly worse
# than fetching from upstream.
rf_conf=/etc/systemd/system/neuron.service.d/registry.conf
rf_token='${{ secrets.RUSTINGFACE_TOKEN }}'
rf_endpoint='https://rf.internal'
if [ -n "${rf_token}" ]; then
rf_desired=$(printf '%s\n%s\n%s\n%s\n%s\n%s' \
"# Managed by .gitea/workflows/deploy.yml - do not edit by hand." \
"# All model fetches go through rustingface, which holds the" \
"# upstream Hugging Face credential and caches to our store." \
"[Service]" \
"Environment=HF_ENDPOINT=${rf_endpoint}" \
"Environment=HF_TOKEN=${rf_token}")
rf_have=$(sudo /usr/bin/sha256sum "${rf_conf}" 2>/dev/null | cut -d' ' -f1 || true)
rf_want=$(printf '%s\n' "${rf_desired}" | sha256sum | cut -d' ' -f1)
[ "${rf_have}" = "${rf_want}" ] || config_changed=1
else
echo "WARNING: RUSTINGFACE_TOKEN is empty - leaving ${rf_conf} as-is."
echo " Fetches use whatever HF_ENDPOINT/HF_TOKEN the host already has."
fi
# ── Package version gate (manifest rationale: see deploy-cortex) ──
@@ -364,15 +425,24 @@ jobs:
echo "installed=${installed} published=${latest:-unknown} pkg_changed=${pkg_changed} config_changed=${config_changed} — deploying"
# Write the drop-in (staged in gitea_ci's dir, installed root-owned).
# Guarded on max_prompt_tokens as well as config_changed: since
# a neuron.toml change also sets config_changed, an entry with
# no max_prompt_tokens would otherwise reach this with `desired`
# unset.
if [ "${config_changed}" -eq 1 ] && [ -n "${max_prompt_tokens}" ]; then
# `desired` is always set now that RUST_LOG is unconditional, so
# config_changed alone is the guard.
if [ "${config_changed}" -eq 1 ]; then
printf '%s\n' "${desired}" > /var/lib/gitea_ci/model.conf
sudo /usr/bin/install -o root -g root -m 0644 -D /var/lib/gitea_ci/model.conf "${conf}"
rm -f /var/lib/gitea_ci/model.conf
echo "applied ${conf}: NEURON_MAX_PROMPT_TOKENS=${max_prompt_tokens}"
echo "applied ${conf}: RUST_LOG=${rust_log} NEURON_MAX_PROMPT_TOKENS=${max_prompt_tokens:-<unset>}"
fi
# Staged 0600 in gitea_ci's own dir before install, so the token
# is never briefly world-readable on the way in.
if [ -n "${rf_token}" ] && [ "${rf_have:-}" != "${rf_want:-}" ]; then
umask 077
printf '%s\n' "${rf_desired}" > /var/lib/gitea_ci/registry.conf
sudo /usr/bin/install -o root -g root -m 0600 -D /var/lib/gitea_ci/registry.conf "${rf_conf}"
rm -f /var/lib/gitea_ci/registry.conf
umask 022
echo "applied ${rf_conf}: HF_ENDPOINT=${rf_endpoint} (token not logged)"
fi
if systemctl is-active --quiet neuron.service; then
+82
View File
@@ -1061,3 +1061,85 @@ ampere flavour.
- Deferred by operator decision: Z-Image base variant, operator
fine-tunes, Z-Image-Edit (`images/edits`). Qwen-Image-2512 is the
parked "frontier image" tier (own epic if taken).
## 2026-09-02 addendum: qwen4_exp (Qwen3.8-Flash-Next) text path
`Qwen/Qwen3.8-Flash-Next` is `model_type: qwen4_exp` — a new
architecture generation, not a Qwen3 variant. Epic #307; the port spec
is `doc/qwen4_exp-port-spec.md` and remains the reference for every
dimension. The text path is implemented in `crates/neuron/src/harness/
arch/qwen4_exp/` and routed in `harness/candle.rs`; it loads a
checkpoint and returns logits.
**What is genuinely new**, and where each lives:
- `hyper.rs` — **four residual streams**, not one. The inter-layer
residual is `hidden_size * hc_count` (10240) wide, initialised as four
copies of the token embedding. There is **no `input_layernorm`, no
`post_attention_layernorm` and no final `model.norm`** in the
checkpoint: the `hc_norm` inside each hyper-connection does all three
jobs. Do not go looking for the missing tensor.
- `ple.rs` — a hashed n-gram embedding on **zero-indexed layer 1**
(`ple_layer_ids: [2]` is one-indexed upstream) over a 320,001,536-row
table, 28.44% of the model's parameters. Three pieces: addressing
(`NGramHasher`), the gather (`NGramTable` trait — residency is #310),
and consumption (`PleBlock`).
- `qsa.rs` — sparse attention. Blocks of 4 past positions are scored by
a 4-head side channel and only the best 512 attended, which is what
makes a 262k window affordable. Keeps a **second KV cache** (12
layers × 1 head × 128 dims, ~3 KiB/token) that every `kv_budget_mb`
arithmetic must account for.
- `decoder.rs`, `model.rs` — the composition.
**Reused from `arch/qwen3_5/` unchanged**: `rope.rs` (identical
interleaved M-RoPE config), the GatedDeltaNet for 36 of 48 layers, and
the MoE routing. Those reuses needed three small seams in `qwen3_5`,
all behaviour-preserving: `apply_partial_rotary` / `cos_sin_at` on the
rotary, `GatedDeltaNetParams` on the linear attention, and `from_parts`
constructors on the MoE and MLP.
### Sharp edges
- **Read `model.safetensors.index.json` before writing a loader.** It is
170 KB, needs no auth, and `curl`-able from HF at the pinned revision.
It caught a real bug: the QSA indexer is at
`...self_attn.indexer.index_qk_proj.weight`, a **submodule of the
attention block**, where the spec's prose implied a sibling. That
failure would otherwise have surfaced after a 360 GB download.
- **The three PLE buffers are I64 in a bf16 checkpoint.** Read through
the model's VarBuilder they round multipliers of ~1e13 and prime vocab
sizes of ~2e7 into nonsense; the table is then sized wrong and the
addressing still returns rows. Force the dtype.
- **A QSA mask must never reach the flash-attn path.**
`attention_context` used to take `Option<&Tensor>` and read
`is_some()` as "causal", never looking at the tensor — so an arbitrary
mask was silently replaced by a dense causal one. It is now
`AttnMask::{None, Causal, Additive}`, and `Additive` forces the eager
path. Whether QSA can use flash-attn at all (it needs a *gather*, not
a mask) is open and now on the critical path.
- **PLE's short conv is dilated** by `ngram_size`, so it reaches 9
positions back and its cached context is 9 wide, not `kernel_size`. It
cannot reuse `run_causal_conv1d`, which assumes dilation 1.
- **`output_gate_type: sigmoid` and `mamba_ssm_dtype: float32`** are the
checkpoint's explicit choices, against our SiLU fallback and #284's
bf16 round-trip default. A port must not answer an upstream choice
with our own.
### What the text path does not yet do
- **No batched decode.** `supports_kv_snapshot` is false, so it serves
one request at a time whatever `max_in_flight` says. Extending
`snapshot.rs` to carry PLE's two state slots and the indexer cache is
its own piece of work.
- **No tensor parallelism** — absent from `TP_SUPPORTED_MODEL_TYPES`, so
a `tensor_parallel` request is refused rather than silently served
from one device.
- **The n-gram table's residency is undecided** (#310). The shipped
`ShardedNGramTable` holds the shards wherever the VarBuilder put them,
which for 27 GB is the problem that issue exists to solve; it is the
control, not the answer.
- **Gate 6 (full-model logits parity against a reference trace) is
open.** Gates 4 and 5 — QSA below budget is exactly dense, and above
budget matches a naive oracle — are closed.
- **MTP (#313), vision (#314), quantisation and rollout (#315)** are
untouched.
+8
View File
@@ -18,6 +18,14 @@ gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/neuron/neuron.toml
# NEURON_MAX_PROMPT_TOKENS: gitea_ci stages it in its own dir, then
# installs it root-owned. Exact source/dest paths; see doc/context-limits.md.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -o root -g root -m 0644 -D /var/lib/gitea_ci/model.conf /etc/systemd/system/neuron.service.d/model.conf
# deploy.yml also writes the registry drop-in, which points every model
# fetch at rustingface and carries the client token for it. Mode 0600
# rather than model.conf's 0644: this one holds a credential, and a
# world-readable systemd drop-in is a world-readable token.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -o root -g root -m 0600 -D /var/lib/gitea_ci/registry.conf /etc/systemd/system/neuron.service.d/registry.conf
# Reading it back to decide whether it changed needs root too, since
# 0600 excludes gitea_ci. Restricted to the one file.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/sha256sum /etc/systemd/system/neuron.service.d/registry.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start neuron.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop neuron.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable --now neuron.service
+1
View File
@@ -22,3 +22,4 @@
pub mod qwen3_5;
pub mod qwen4_exp;
pub mod snapshot;
@@ -18,6 +18,7 @@ use candle_core::{Module, Tensor};
use candle_nn::var_builder::ShardedVarBuilder;
use std::sync::Arc;
use super::super::snapshot::LayerKvSnapshot;
use super::TextConfig;
use super::full_attn::Qwen3_5Attention;
use super::linear_attn::GatedDeltaNet;
@@ -25,7 +26,6 @@ use super::mlp::Qwen3_5MLP;
use super::moe::Qwen3_5MoeBlock;
use super::rmsnorm::Qwen3_5RmsNorm;
use super::rope::RotaryEmbedding;
use super::snapshot::LayerKvSnapshot;
/// One of the two attention flavours sitting in a decoder layer's
/// attention slot. Full-attention layers need the rotary table and
@@ -43,6 +43,62 @@ fn flash_attn_enabled() -> bool {
})
}
/// Which mask an attention call carries — and whether the flash kernel
/// can express it.
///
/// The flash path encodes causality as a boolean flag and never reads a
/// mask tensor. While the only mask this crate built was the causal
/// one, `Option<&Tensor>` was enough: `Some` meant causal. It is no
/// longer, because `qwen4_exp`'s QSA selection is an arbitrary additive
/// mask — and handed to the flash path it would be silently replaced by
/// a dense causal one. The model would run, the sparsity would do
/// nothing, and the only symptom would be that long context cost what
/// it always did.
///
/// So the distinction is a type rather than a comment: an
/// [`AttnMask::Additive`] mask always takes the eager path.
#[derive(Clone, Copy)]
pub(crate) enum AttnMask<'a> {
/// Decode, or a single position — nothing to mask.
None,
/// The standard causal mask, which the flash kernel applies as a
/// flag rather than reading.
Causal(&'a Tensor),
/// A mask the kernel cannot express. Forces the eager path.
Additive(&'a Tensor),
}
impl<'a> AttnMask<'a> {
/// The historical `Option<&Tensor>` contract: present means causal.
pub(crate) fn causal(mask: Option<&'a Tensor>) -> Self {
match mask {
Some(m) => Self::Causal(m),
None => Self::None,
}
}
/// The tensor the eager path adds to the scores, if any.
fn tensor(&self) -> Option<&'a Tensor> {
match self {
Self::None => None,
Self::Causal(m) | Self::Additive(m) => Some(m),
}
}
/// Whether the flash kernel may be used at all for this mask.
/// Only the flash path asks, so only that build compiles it.
#[cfg(feature = "flash-attn")]
fn reaches_flash(&self) -> bool {
!matches!(self, Self::Additive(_))
}
/// The kernel's `causal` flag.
#[cfg(feature = "flash-attn")]
fn is_causal(&self) -> bool {
matches!(self, Self::Causal(_))
}
}
/// Attention core shared by the single-GPU and TP full-attention
/// layers (#95): `(B, H, L, D)` query and `(B, H_kv, S, D)` key/value
/// (post-KV-cache, NOT GQA-repeated) → `(B, H, L, D)` context.
@@ -56,19 +112,18 @@ fn flash_attn_enabled() -> bool {
/// prefill continuation needs: a chunk of L new queries against
/// `offset + L` cached keys masks correctly.
///
/// INVARIANT: `attn_mask` is either `None` (decode / single position)
/// or the standard causal mask — the only mask the qwen3_5 forward
/// constructs. The flash path encodes it as `causal = attn_mask
/// .is_some()`; a future non-causal mask must extend this signature,
/// not silently pass through.
/// The mask arrives as an [`AttnMask`] rather than an `Option`, because
/// the flash path cannot express an arbitrary one and would discard it
/// in silence — see that type.
///
/// Falls back to the eager matmul→softmax→matmul everywhere else
/// (CPU, f32, feature off, or `NEURON_FLASH_ATTN=0`).
/// (CPU, f32, feature off, `NEURON_FLASH_ATTN=0`, or a mask the kernel
/// cannot express).
pub(crate) fn attention_context(
q: &Tensor,
k: &Tensor,
v: &Tensor,
attn_mask: Option<&Tensor>,
attn_mask: AttnMask<'_>,
num_kv_groups: usize,
scale: f64,
) -> candle_core::Result<Tensor> {
@@ -84,6 +139,7 @@ pub(crate) fn attention_context(
// byte-identical either way, so this split is purely a
// performance routing decision.
if flash_attn_enabled()
&& attn_mask.reaches_flash()
&& q.dim(2)? > 1
&& q.device().is_cuda()
&& (dtype == DType::F16 || dtype == DType::BF16)
@@ -92,7 +148,7 @@ pub(crate) fn attention_context(
let qf = q.transpose(1, 2)?.contiguous()?;
let kf = k.transpose(1, 2)?.contiguous()?;
let vf = v.transpose(1, 2)?.contiguous()?;
let causal = attn_mask.is_some();
let causal = attn_mask.is_causal();
let ctx = candle_flash_attn::flash_attn(&qf, &kf, &vf, scale as f32, causal)?;
return ctx.transpose(1, 2)?.contiguous();
}
@@ -102,7 +158,7 @@ pub(crate) fn attention_context(
let k = repeat_kv(k.clone(), num_kv_groups)?.contiguous()?;
let v = repeat_kv(v.clone(), num_kv_groups)?.contiguous()?;
let mut scores = (q.matmul(&k.transpose(2, 3)?)? * scale)?;
if let Some(m) = attn_mask {
if let Some(m) = attn_mask.tensor() {
scores = scores.broadcast_add(m)?;
}
let probs = candle_nn::ops::softmax_last_dim(&scores)?;
@@ -222,7 +278,14 @@ impl Qwen3_5Attention {
// 5+6. Attention core — FlashAttention when available, eager
// GQA-repeat + masked softmax otherwise (#95).
let scale = 1.0_f64 / (self.head_dim as f64).sqrt();
let ctx = attention_context(&q, &k, &v, attn_mask, self.num_kv_groups, scale)?; // (B, H, L, D)
let ctx = attention_context(
&q,
&k,
&v,
AttnMask::causal(attn_mask),
self.num_kv_groups,
scale,
)?; // (B, H, L, D)
// 7. Reshape back, apply the output gate, project.
let ctx = ctx
@@ -67,7 +67,7 @@ use candle_nn::var_builder::ShardedVarBuilder;
#[cfg(test)]
use super::RopeParameters;
use super::TextConfig;
use super::rmsnorm::{Qwen3_5RmsNormGated, l2norm};
use super::rmsnorm::{OutputGate, Qwen3_5RmsNormGated, l2norm};
/// Per-rank, per-layer state for the linear-attention block.
///
@@ -112,15 +112,69 @@ pub struct GatedDeltaNet {
// `clear_kv_cache`; otherwise the state persists across forwards
// and the per-token offset advances naturally.
state: GatedDeltaNetState,
/// Whether the recurrent state is carried in f32 between forwards
/// (#284). Per layer rather than per process, because it is a
/// property of the checkpoint for architectures that declare
/// `mamba_ssm_dtype`.
state_f32: bool,
}
/// Everything a [`GatedDeltaNet`] needs that is not in its weights.
///
/// Split out of `TextConfig` so an architecture that is not `qwen3_5`
/// can build one of these layers without owning a `qwen3_5` config —
/// the tensor names and layout are shared, only the config type and two
/// settings differ. `qwen4_exp` reuses 36 of its 48 layers this way.
#[derive(Debug, Clone, Copy)]
pub struct GatedDeltaNetParams {
pub hidden_size: usize,
pub num_value_heads: usize,
pub num_key_heads: usize,
pub key_head_dim: usize,
pub value_head_dim: usize,
pub conv_kernel_size: usize,
pub rms_norm_eps: f64,
/// The output gate's nonlinearity. Qwen3.6 leaves `output_gate_type`
/// unset and gets SiLU; `qwen4_exp` states sigmoid, and inheriting
/// SiLU there is wrong on every linear-attention layer.
pub output_gate: OutputGate,
/// Carry the recurrent state in f32 between forwards (#284) rather
/// than round-tripping it through the activation dtype. For
/// `qwen3_5` this follows `NEURON_GDN_STATE_F32`; an architecture
/// whose checkpoint states `mamba_ssm_dtype` should follow the
/// checkpoint instead.
pub state_f32: bool,
}
impl GatedDeltaNetParams {
/// The `qwen3_5` reading, preserving today's behaviour exactly:
/// SiLU regardless of `hidden_act`, and the process-wide flag.
pub fn from_qwen3_5(cfg: &TextConfig) -> Self {
Self {
hidden_size: cfg.hidden_size,
num_value_heads: cfg.linear_num_value_heads,
num_key_heads: cfg.linear_num_key_heads,
key_head_dim: cfg.linear_key_head_dim,
value_head_dim: cfg.linear_value_head_dim,
conv_kernel_size: cfg.linear_conv_kernel_dim,
rms_norm_eps: cfg.rms_norm_eps,
output_gate: OutputGate::Silu,
state_f32: gdn_state_f32(),
}
}
}
impl GatedDeltaNet {
pub fn load(cfg: &TextConfig, vb: &ShardedVarBuilder) -> Result<Self> {
let num_v_heads = cfg.linear_num_value_heads;
let num_k_heads = cfg.linear_num_key_heads;
let head_k_dim = cfg.linear_key_head_dim;
let head_v_dim = cfg.linear_value_head_dim;
let conv_kernel_size = cfg.linear_conv_kernel_dim;
Self::load_with_params(GatedDeltaNetParams::from_qwen3_5(cfg), vb)
}
pub fn load_with_params(cfg: GatedDeltaNetParams, vb: &ShardedVarBuilder) -> Result<Self> {
let num_v_heads = cfg.num_value_heads;
let num_k_heads = cfg.num_key_heads;
let head_k_dim = cfg.key_head_dim;
let head_v_dim = cfg.value_head_dim;
let conv_kernel_size = cfg.conv_kernel_size;
if num_v_heads == 0 || num_k_heads == 0 {
anyhow::bail!(
@@ -192,7 +246,12 @@ impl GatedDeltaNet {
.with_context(|| format!("load '{}/A_log'", vb.prefix()))?;
// ----- Output gated RMSNorm (per-head_v_dim). -----
let norm = Qwen3_5RmsNormGated::load(&vb.pp("norm"), head_v_dim, cfg.rms_norm_eps)?;
let norm = Qwen3_5RmsNormGated::load_with_gate(
&vb.pp("norm"),
head_v_dim,
cfg.rms_norm_eps,
cfg.output_gate,
)?;
Ok(Self {
in_proj_qkv,
@@ -213,6 +272,7 @@ impl GatedDeltaNet {
conv_dim,
conv_kernel_size,
state: GatedDeltaNetState::default(),
state_f32: cfg.state_f32,
})
}
@@ -392,7 +452,7 @@ impl GatedDeltaNet {
// by this point, and prefix snapshots deep-copy
// (`snapshot_state`) precisely so they never share storage with
// a buffer a later forward will overwrite.
self.state.recurrent_state = Some(if gdn_state_f32() {
self.state.recurrent_state = Some(if self.state_f32 {
new_state
} else {
new_state.to_dtype(dtype)?
@@ -498,7 +558,7 @@ fn chunked_prefill_enabled() -> bool {
///
/// Costs ~2× the recurrent slab: +37.7 MB per GPU per sequence on the
/// deployed 27B under TP=2, so +302 MB/GPU at `max_in_flight = 8`.
fn gdn_state_f32() -> bool {
pub(crate) fn gdn_state_f32() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var("NEURON_GDN_STATE_F32")
@@ -1394,6 +1454,7 @@ mod tests {
conv_dim,
conv_kernel_size: cfg.linear_conv_kernel_dim,
state: GatedDeltaNetState::default(),
state_f32: gdn_state_f32(),
};
let x = Tensor::ones(&[b, l, cfg.hidden_size], dtype, &dev).unwrap();
@@ -17,7 +17,13 @@ pub struct Qwen3_5MLP {
}
impl Qwen3_5MLP {
/// Construct directly from pre-built projections (MoE-block tests).
/// Construct directly from pre-built projections.
///
/// Test-only again: `qwen4_exp` briefly built its 512 experts this
/// way before the fused tensors stayed banked (`Experts::Banked`),
/// which removed the last caller outside tests. The differential
/// test that proves banking neutral still needs it, to express the
/// same weights in the layout it is being compared against.
#[cfg(test)]
pub(crate) fn from_weights(gate_proj: Linear, up_proj: Linear, down_proj: Linear) -> Self {
Self {
+11 -7
View File
@@ -79,7 +79,6 @@ pub mod mlp;
pub mod moe;
pub mod rmsnorm;
pub mod rope;
pub mod snapshot;
pub mod vision;
use decoder::Qwen3_5DecoderLayer;
@@ -534,15 +533,18 @@ impl Qwen3_5Model {
/// counter as one consistent prefix snapshot (#11). Only valid at
/// a token boundary — i.e. between forward calls, which is the
/// only time the caller can reach this anyway.
pub fn snapshot_kv_cache(&self) -> candle_core::Result<snapshot::KvCacheSnapshot> {
pub fn snapshot_kv_cache(
&self,
) -> candle_core::Result<crate::harness::arch::snapshot::KvCacheSnapshot> {
let layers = self
.layers
.iter()
.map(|l| l.snapshot_kv())
.collect::<candle_core::Result<Vec<_>>>()?;
Ok(snapshot::KvCacheSnapshot {
Ok(crate::harness::arch::snapshot::KvCacheSnapshot {
layers,
rope_delta: self.rope_delta,
ple: None,
})
}
@@ -550,7 +552,7 @@ impl Qwen3_5Model {
/// snapshot. The snapshot stays valid for further restores.
pub fn restore_kv_cache(
&mut self,
snap: &snapshot::KvCacheSnapshot,
snap: &crate::harness::arch::snapshot::KvCacheSnapshot,
) -> candle_core::Result<()> {
if snap.layers.len() != self.layers.len() {
candle_core::bail!(
@@ -582,7 +584,7 @@ impl Qwen3_5Model {
/// new token per batch row — with each row at its own sequence
/// position `positions[i]` (typically `prefix_lens[i] + step`).
/// The cache must hold batched state (see
/// `snapshot::assemble_batch`); `attn_mask` is the padding mask
/// `crate::harness::arch::snapshot::assemble_batch`); `attn_mask` is the padding mask
/// from [`Self::batch_decode_mask`] (or `None` when no row is
/// padded). Text-only: `rope_delta` is ignored — positions are
/// explicit and vision requests never enter the batch path.
@@ -1084,14 +1086,16 @@ impl Qwen3_5ForCausalLM {
}
/// See [`Qwen3_5Model::snapshot_kv_cache`].
pub fn snapshot_kv_cache(&self) -> candle_core::Result<snapshot::KvCacheSnapshot> {
pub fn snapshot_kv_cache(
&self,
) -> candle_core::Result<crate::harness::arch::snapshot::KvCacheSnapshot> {
self.base.snapshot_kv_cache()
}
/// See [`Qwen3_5Model::restore_kv_cache`].
pub fn restore_kv_cache(
&mut self,
snap: &snapshot::KvCacheSnapshot,
snap: &crate::harness::arch::snapshot::KvCacheSnapshot,
) -> candle_core::Result<()> {
self.base.restore_kv_cache(snap)
}
+443 -12
View File
@@ -29,18 +29,218 @@
//! replaces the loop behind the same `forward` signature.
use anyhow::{Context, Result};
use candle_core::{DType, Module, Tensor};
use candle_core::quantized::{GgmlDType, QMatMul, QTensor};
use candle_core::{DType, IndexOp, Module, Tensor};
use candle_nn::Linear;
use candle_nn::var_builder::ShardedVarBuilder;
use super::TextConfig;
use super::mlp::Qwen3_5MLP;
/// How a layer's routed experts are stored.
///
/// Two shapes, because the right one depends on whether the model fits.
///
/// `PerExpert` is the Qwen3-Next layout: one module per expert, sliced
/// out at load. Fine when the weights are device-resident anyway.
///
/// `Banked` keeps the checkpoint's own fused tensors whole and takes a
/// view per routed expert. `qwen4_exp` needs it: slicing its 512
/// experts into modules copies **241.6 GB** at BF16 — 5.03 GB per
/// layer — which fits in neither 64 GB of VRAM nor 123 GB of host RAM,
/// at any quantisation. It is also the layout a host-resident bank with
/// a device-side gather wants (#318), so the fused form is where that
/// work starts rather than something it would have to undo.
///
/// Banking costs nothing per call: a slice along dim 0 of a contiguous
/// tensor is contiguous, and `x.matmul(w.t())` is what `Linear` does
/// anyway, so the views feed the same GEMMs without a copy.
pub(crate) enum Experts {
PerExpert(Vec<Qwen3_5MLP>),
/// Quantised in situ at load (#315). One `(gate, up, down)` triple
/// per expert.
///
/// This is what makes `qwen4_exp` loadable at all: 120.8 B routed
/// parameters are 241.6 GB at BF16 and 67.9 GB at q4k, against
/// beast's 64 GB of VRAM and ~110 GB of usable host RAM. Nothing
/// about the arithmetic works until they shrink.
///
/// Quantising *from* the banked tensor is what keeps the peak
/// bounded: one layer's fused BF16 tensor is 5.03 GB, and it is
/// dropped once its 512 experts are quantised, so the full 241.6 GB
/// never exists at once.
Quantized(Vec<QuantizedExpert>),
Banked {
/// `(num_experts, 2 * intermediate, hidden)` — gate rows first,
/// then up. Reversing them computes `silu(up) * gate`, which is
/// a different function of the same weights (#312).
gate_up: Tensor,
/// `(num_experts, hidden, intermediate)`
down: Tensor,
intermediate: usize,
},
}
/// One expert's three projections, quantised.
pub(crate) struct QuantizedExpert {
gate: QMatMul,
up: QMatMul,
down: QMatMul,
}
/// The quantisation type actually usable for rows of `row` elements.
///
/// ggml quantises along the last dimension, and a row must be a whole
/// number of blocks. The k-quants (`Q2K`..`Q8K`) block on 256; the
/// older types block on 32. `qwen4_exp`'s `down_proj` reduces over
/// `moe_intermediate_size = 640`, which is 2.5 blocks of 256 — so a
/// `quant = "q4k"` load fails on the very first layer with
/// `quantized tensor must have their last dim divisible by block size
/// [2560, 640] 256`, having read 131 shards to get there.
///
/// Substituting a 32-block type for that half is what llama.cpp does
/// with the same constraint, and it costs almost nothing here: `Q4_0`
/// and `Q4K` are both 4.5 bpw, so the memory arithmetic the placement
/// decisions rest on is unchanged. The quality is a little worse per
/// bit — `Q4_0` has one scale per 32 values where `Q4K` has a
/// two-level hierarchy — which is a real trade, but it applies to one
/// of the three expert matrices and the alternative is not loading.
///
/// Errors rather than falls back further if the row will not even
/// divide 32: that is a config we do not understand, and silently
/// leaving those weights dense would be a memory surprise, not a
/// mercy.
pub(crate) fn quant_for_row(requested: GgmlDType, row: usize) -> candle_core::Result<GgmlDType> {
if row.is_multiple_of(requested.block_size()) {
return Ok(requested);
}
let fallback = match requested {
GgmlDType::Q2K | GgmlDType::Q3K | GgmlDType::Q4K => GgmlDType::Q4_0,
GgmlDType::Q5K => GgmlDType::Q5_0,
GgmlDType::Q6K | GgmlDType::Q8K => GgmlDType::Q8_0,
// Already a 32-block type: there is nothing coarser to fall
// back to, so this is the error case below.
other => other,
};
if row.is_multiple_of(fallback.block_size()) {
return Ok(fallback);
}
candle_core::bail!(
"cannot quantise a row of {row} elements as {requested:?} (block \
{}) or {fallback:?} (block {}): the row divides neither",
requested.block_size(),
fallback.block_size(),
)
}
impl Experts {
pub(crate) fn len(&self) -> usize {
match self {
Experts::PerExpert(v) => v.len(),
Experts::Banked { gate_up, .. } => gate_up.dims()[0],
Experts::Quantized(v) => v.len(),
}
}
/// Quantise a banked pair into per-expert triples.
///
/// Takes the fused tensors by reference and drops nothing itself —
/// the caller owns the peak, and should hold one layer at a time.
///
/// The two halves may not end up the same dtype: see
/// [`quant_for_row`]. `qwen4_exp`'s `down_proj` reduces over 640,
/// which no k-quant can block.
pub(crate) fn quantize_banked(
gate_up: &Tensor,
down: &Tensor,
intermediate: usize,
dtype: GgmlDType,
) -> candle_core::Result<Self> {
let n = gate_up.dims()[0];
// Decided once, from the shapes, rather than per expert —
// every expert in a layer has the same two row widths, and
// 512 identical log lines per layer would bury the fact.
let gate_up_dtype = quant_for_row(dtype, gate_up.dims()[2])?;
let down_dtype = quant_for_row(dtype, down.dims()[2])?;
if gate_up_dtype != dtype || down_dtype != dtype {
tracing::info!(
requested = ?dtype,
gate_up_row = gate_up.dims()[2],
gate_up_dtype = ?gate_up_dtype,
down_row = down.dims()[2],
down_dtype = ?down_dtype,
"expert quantisation: a row width does not divide the requested \
block size; substituting a 32-block type for that half"
);
}
let mut out = Vec::with_capacity(n);
for e in 0..n {
let gu = gate_up.i(e)?;
// The source must own its storage outright.
// `QTensor::quantize` reads `src.storage()` — the whole
// backing buffer, ignoring shape and offset — so a view into
// the fused tensor hands it every expert's bytes while the
// shape claims one, and candle panics on a block-count
// mismatch inside `from_float`. Neither `contiguous()` nor
// `copy()` saves you here: a slice along dim 0 is already
// contiguous, so both are free to hand back something still
// sharing the parent's buffer. Round-tripping through a Vec
// is the part that actually allocates, and it is also where
// the f32 the quantiser wants comes from.
let q = |t: Tensor, dtype: GgmlDType| -> candle_core::Result<QMatMul> {
let dims = t.dims().to_vec();
let vals = t.to_dtype(DType::F32)?.flatten_all()?.to_vec1::<f32>()?;
let owned = Tensor::from_vec(vals, dims, t.device())?;
QMatMul::from_qtensor(QTensor::quantize(&owned, dtype)?)
};
out.push(QuantizedExpert {
gate: q(gu.narrow(0, 0, intermediate)?, gate_up_dtype)?,
up: q(gu.narrow(0, intermediate, intermediate)?, gate_up_dtype)?,
down: q(down.i(e)?, down_dtype)?,
});
}
Ok(Experts::Quantized(out))
}
/// Expert `e` applied to the rows routed to it.
pub(crate) fn forward_one(&self, e: usize, xs: &Tensor) -> candle_core::Result<Tensor> {
match self {
Experts::PerExpert(v) => v[e].forward(xs),
Experts::Banked {
gate_up,
down,
intermediate,
} => {
let gu = gate_up.i(e)?;
let gate_w = gu.narrow(0, 0, *intermediate)?;
let up_w = gu.narrow(0, *intermediate, *intermediate)?;
let lhs = candle_nn::ops::silu(&xs.matmul(&gate_w.t()?)?)?;
let rhs = xs.matmul(&up_w.t()?)?;
(lhs * rhs)?.matmul(&down.i(e)?.t()?)
}
Experts::Quantized(v) => {
let q = &v[e];
// The quantised kernels accumulate in f32 whatever the
// activation dtype, so the cast is explicit here rather
// than implied — and the result comes back in the
// caller's dtype so the routing arithmetic upstream
// does not have to know which layout it got.
let dtype = xs.dtype();
let xs32 = xs.to_dtype(DType::F32)?;
let lhs = candle_nn::ops::silu(&q.gate.forward(&xs32)?)?;
let rhs = q.up.forward(&xs32)?;
q.down.forward(&(lhs * rhs)?)?.to_dtype(dtype)
}
}
}
}
pub struct Qwen3_5MoeBlock {
/// Router: `(num_experts, hidden)`, checkpoint name `mlp.gate`.
gate: Linear,
/// Routed experts, checkpoint names `mlp.experts.{i}.{gate,up,down}_proj`.
experts: Vec<Qwen3_5MLP>,
/// Routed experts. Per-expert modules for `qwen3_5`; the
/// checkpoint's fused tensors for `qwen4_exp` — see [`Experts`].
experts: Experts,
/// Always-on expert, `mlp.shared_expert.*`. `None` when the config
/// declares no shared expert (Qwen3-30B-A3B style).
shared_expert: Option<Qwen3_5MLP>,
@@ -52,6 +252,32 @@ pub struct Qwen3_5MoeBlock {
}
impl Qwen3_5MoeBlock {
/// Assemble from parts.
///
/// The routing arithmetic is identical across the checkpoints that
/// use this block; only the storage of the experts differs.
/// `qwen4_exp` ships them as fused 3D tensors and slices them
/// itself, so it builds the block this way rather than through
/// [`Self::load`], which expects per-expert modules and a
/// `qwen3_5` config.
pub(crate) fn from_parts(
gate: Linear,
experts: Experts,
shared_expert: Option<Qwen3_5MLP>,
shared_expert_gate: Option<Linear>,
num_experts_per_tok: usize,
norm_topk_prob: bool,
) -> Self {
Self {
gate,
experts,
shared_expert,
shared_expert_gate,
num_experts_per_tok,
norm_topk_prob,
}
}
pub fn load(cfg: &TextConfig, vb: &ShardedVarBuilder) -> Result<Self> {
anyhow::ensure!(
cfg.num_experts > 0 && cfg.num_experts_per_tok > 0 && cfg.moe_intermediate_size > 0,
@@ -103,7 +329,7 @@ impl Qwen3_5MoeBlock {
Ok(Self {
gate,
experts,
experts: Experts::PerExpert(experts),
shared_expert,
shared_expert_gate,
num_experts_per_tok: cfg.num_experts_per_tok,
@@ -175,13 +401,13 @@ impl Module for Qwen3_5MoeBlock {
)?;
let mut ys = xs_flat.zeros_like()?;
for (e, expert) in self.experts.iter().enumerate() {
for e in 0..self.experts.len() {
if tokens_for[e].is_empty() {
continue;
}
let rows = Tensor::new(tokens_for[e].as_slice(), xs.device())?;
let picked = xs_flat.index_select(&rows, 0)?;
let out = expert.forward(&picked)?;
let out = self.experts.forward_one(e, &picked)?;
let w = Tensor::new(weights_for[e].as_slice(), xs.device())?
.to_dtype(out.dtype())?
.reshape(((), 1))?;
@@ -226,7 +452,7 @@ mod tests {
let block = Qwen3_5MoeBlock {
gate: Linear::new(randn(&[n_exp, hidden]), None),
experts: (0..n_exp).map(|_| rand_mlp(hidden, inter)).collect(),
experts: Experts::PerExpert((0..n_exp).map(|_| rand_mlp(hidden, inter)).collect()),
shared_expert: Some(rand_mlp(hidden, inter)),
shared_expert_gate: Some(Linear::new(randn(&[1, hidden]), None)),
num_experts_per_tok: top_k,
@@ -257,8 +483,9 @@ mod tests {
let mut expect = vec![0f32; hidden];
for &e in selected {
let w = probs[e] / denom;
let out: Vec<f32> = block.experts[e]
.forward(&row)
let out: Vec<f32> = block
.experts
.forward_one(e, &row)
.unwrap()
.flatten_all()
.unwrap()
@@ -310,7 +537,7 @@ mod tests {
let (hidden, inter, n_exp) = (4, 2, 3);
let block = Qwen3_5MoeBlock {
gate: Linear::new(randn(&[n_exp, hidden]), None),
experts: (0..n_exp).map(|_| rand_mlp(hidden, inter)).collect(),
experts: Experts::PerExpert((0..n_exp).map(|_| rand_mlp(hidden, inter)).collect()),
shared_expert: None,
shared_expert_gate: None,
num_experts_per_tok: 1,
@@ -342,8 +569,9 @@ mod tests {
.max_by(|&a, &b| exps[a].partial_cmp(&exps[b]).unwrap())
.unwrap();
let w = exps[best] / sum;
let out: Vec<f32> = block.experts[best]
.forward(&xs.reshape(((), hidden)).unwrap())
let out: Vec<f32> = block
.experts
.forward_one(best, &xs.reshape(((), hidden)).unwrap())
.unwrap()
.flatten_all()
.unwrap()
@@ -357,4 +585,207 @@ mod tests {
);
}
}
/// Banking is a storage change, so the two layouts must compute the
/// same function. Same weights, expressed both ways, asserted
/// identical — otherwise "we only changed where the bytes live" is
/// a claim rather than a fact.
#[test]
fn banked_experts_equal_per_expert_modules() {
let (n_experts, hidden, inter) = (3usize, 4usize, 2usize);
// One source of truth for the weights, laid out both ways.
let gate_up = randn(&[n_experts, inter * 2, hidden]);
let down = randn(&[n_experts, hidden, inter]);
let per_expert: Vec<Qwen3_5MLP> = (0..n_experts)
.map(|e| {
let gu = gate_up.i(e).unwrap();
Qwen3_5MLP::from_weights(
Linear::new(gu.narrow(0, 0, inter).unwrap().contiguous().unwrap(), None),
Linear::new(
gu.narrow(0, inter, inter).unwrap().contiguous().unwrap(),
None,
),
Linear::new(down.i(e).unwrap().contiguous().unwrap(), None),
)
})
.collect();
let sliced = Experts::PerExpert(per_expert);
let banked = Experts::Banked {
gate_up,
down,
intermediate: inter,
};
assert_eq!(sliced.len(), banked.len());
let xs = randn(&[5, hidden]);
for e in 0..n_experts {
let a: Vec<f32> = sliced
.forward_one(e, &xs)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
let b: Vec<f32> = banked
.forward_one(e, &xs)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(b.iter()) {
assert!(
(x - y).abs() < 1e-5,
"expert {e} differs between layouts: {a:?} vs {b:?}"
);
}
}
}
/// And the halves are not interchangeable: reading `gate_up` the
/// other way round computes silu(up) * gate, which the banked path
/// would do just as silently as the sliced one.
#[test]
fn banked_reads_the_gate_before_the_up() {
let dev = Device::Cpu;
// gate row picks channel 0, up row picks channel 1.
let gate_up = Tensor::from_vec(vec![1.0f32, 0.0, 0.0, 1.0], (1, 2, 2), &dev).unwrap();
let down = Tensor::from_vec(vec![1.0f32, 0.0], (1, 2, 1), &dev).unwrap();
let banked = Experts::Banked {
gate_up,
down,
intermediate: 1,
};
let xs = Tensor::from_vec(vec![1.0f32, 2.0], (1, 2), &dev).unwrap();
let got: Vec<f32> = banked
.forward_one(0, &xs)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
// silu(gate.x) * (up.x) = silu(1) * 2
let want = (1.0f32 / (1.0 + (-1.0f32).exp())) * 2.0;
assert!((got[0] - want).abs() < 1e-5, "got {got:?} want {want}");
// the swapped reading would be silu(2) * 1
let swapped = (2.0f32 / (1.0 + (-2.0f32).exp())) * 1.0;
assert!(
(got[0] - swapped).abs() > 1e-3,
"this is silu(up) * gate: {got:?}"
);
}
/// In-situ quantisation has to approximate the dense expert, not
/// merely produce numbers of the right shape.
///
/// Dimensions are 256 because the k-quants block on 256 elements —
/// a tiny fixture silently cannot be quantised at all, which is the
/// first thing that bites when this is tried on a toy model.
///
/// `qwen4_exp`'s real expert widths, which no k-quant can block.
///
/// `moe_intermediate_size` is 640, so `down_proj` is `[2560, 640]`
/// and its 640-element rows are 2.5 blocks of 256. The load died
/// on beast at decoder layer 0 with `quantized tensor must have
/// their last dim divisible by block size [2560, 640] 256`, after
/// resolving all 131 shards.
///
/// The fixture that missed it was 256 wide in both dimensions —
/// the smallest width a k-quant accepts — so every row divided
/// and the orientation could not matter. This one uses the
/// checkpoint's own numbers, scaled down only in expert count.
#[test]
fn the_real_expert_widths_quantise_despite_a_640_wide_reduction() {
assert_eq!(
quant_for_row(GgmlDType::Q4K, 2560).unwrap(),
GgmlDType::Q4K,
"gate_up reduces over hidden_size, which is 10 whole blocks"
);
assert_eq!(
quant_for_row(GgmlDType::Q4K, 640).unwrap(),
GgmlDType::Q4_0,
"down reduces over moe_intermediate_size, which is not"
);
assert_eq!(quant_for_row(GgmlDType::Q6K, 640).unwrap(), GgmlDType::Q8_0);
assert_eq!(
quant_for_row(GgmlDType::Q8_0, 640).unwrap(),
GgmlDType::Q8_0
);
// 100 divides neither 256 nor 32.
assert!(quant_for_row(GgmlDType::Q4K, 100).is_err());
// And the whole path runs at those widths.
let (n_experts, hidden, inter) = (2usize, 2560usize, 640usize);
let gate_up = randn(&[n_experts, inter * 2, hidden]);
let down = randn(&[n_experts, hidden, inter]);
let quantised = Experts::quantize_banked(&gate_up, &down, inter, GgmlDType::Q4K)
.expect("the real widths must quantise");
let xs = randn(&[2, hidden]);
let got = quantised.forward_one(0, &xs).expect("forward");
assert_eq!(got.dims(), &[2, hidden]);
assert!(
got.flatten_all()
.unwrap()
.to_vec1::<f32>()
.unwrap()
.iter()
.all(|v| v.is_finite()),
"a substituted dtype must still produce finite output"
);
}
/// The assertion is relative error against the dense output, with a
/// noise baseline: a q8_0 expert should be close, a q4k expert
/// looser, and both far nearer the truth than an unrelated expert
/// is. Without that last comparison a "quantised" path that
/// returned anything smoothly wrong would pass.
#[test]
fn quantised_experts_approximate_the_dense_ones() {
let (n_experts, hidden, inter) = (2usize, 256usize, 256usize);
let gate_up = randn(&[n_experts, inter * 2, hidden]);
let down = randn(&[n_experts, hidden, inter]);
let banked = Experts::Banked {
gate_up: gate_up.clone(),
down: down.clone(),
intermediate: inter,
};
let xs = randn(&[4, hidden]);
let rel_err = |a: &[f32], b: &[f32]| -> f32 {
let num: f32 = a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum();
let den: f32 = a.iter().map(|x| x * x).sum::<f32>().max(1e-12);
(num / den).sqrt()
};
let out = |e: &Experts, i: usize| -> Vec<f32> {
e.forward_one(i, &xs)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap()
};
let dense0 = out(&banked, 0);
// Baseline: a different expert on the same input. Any quantised
// reading must beat this by a wide margin.
let noise = rel_err(&dense0, &out(&banked, 1));
for (dtype, tol) in [(GgmlDType::Q8_0, 0.05f32), (GgmlDType::Q4K, 0.20f32)] {
let q = Experts::quantize_banked(&gate_up, &down, inter, dtype).unwrap();
assert_eq!(q.len(), n_experts);
let err = rel_err(&dense0, &out(&q, 0));
assert!(err < tol, "{dtype:?} relative error {err:.4} exceeds {tol}");
assert!(
err < noise / 2.0,
"{dtype:?} error {err:.4} is not clearly better than an unrelated \
expert ({noise:.4}) — the quantised path may not be reading \
this expert at all"
);
}
}
}
+98 -37
View File
@@ -23,7 +23,7 @@
use anyhow::Result;
use candle_core::{DType, Device, IndexOp, Tensor};
use super::TextConfig;
use super::{RopeParameters, TextConfig};
#[derive(Debug, Clone)]
pub struct RotaryEmbedding {
@@ -97,8 +97,30 @@ fn mrope_masks(
impl RotaryEmbedding {
pub fn new(dtype: DType, cfg: &TextConfig, dev: &Device) -> Result<Self> {
let head_dim = cfg.head_dim;
let rope = &cfg.rope_parameters;
Self::from_parts(
dtype,
cfg.head_dim,
cfg.max_position_embeddings,
&cfg.rope_parameters,
dev,
)
}
/// Build from the three things the rotary actually reads, rather
/// than from a whole config.
///
/// `qwen4_exp` declares an identical `rope_parameters` block and the
/// same `head_dim` (spec §7), so it reuses this module unchanged —
/// but it owns a different config type, and fabricating a foreign
/// one to pass through here would be a lie the compiler could not
/// catch.
pub fn from_parts(
dtype: DType,
head_dim: usize,
max_seq_len: usize,
rope: &RopeParameters,
dev: &Device,
) -> Result<Self> {
let rotary_dim = (head_dim as f32 * rope.partial_rotary_factor) as usize;
if !rotary_dim.is_multiple_of(2) {
anyhow::bail!(
@@ -113,7 +135,6 @@ impl RotaryEmbedding {
rope.partial_rotary_factor
);
}
let max_seq_len = cfg.max_position_embeddings;
let inv_freq: Vec<f32> = (0..rotary_dim)
.step_by(2)
.map(|i| 1f32 / rope.rope_theta.powf(i as f64 / rotary_dim as f64) as f32)
@@ -165,10 +186,23 @@ impl RotaryEmbedding {
/// row, one decode token per step. [`Self::apply_cos_sin`] detects
/// the rank-3 shape and broadcasts per row instead of per position.
pub fn batch_cos_sin(&self, positions: &[usize]) -> candle_core::Result<(Tensor, Tensor)> {
let (cos, sin) = self.cos_sin_at(positions)?;
Ok((cos.unsqueeze(1)?, sin.unsqueeze(1)?))
}
/// cos/sin gathered at arbitrary, possibly non-contiguous positions,
/// shape `(positions.len(), rotary_dim/2)` — the same per-position
/// rank-2 form [`Self::plain_cos_sin`] returns, so it feeds the
/// sequence axis rather than a batch axis.
///
/// `qwen4_exp`'s QSA indexer needs this: its pooled block keys are
/// rotated at the *first* position of each block, which is every
/// fourth position rather than a contiguous run.
pub fn cos_sin_at(&self, positions: &[usize]) -> candle_core::Result<(Tensor, Tensor)> {
let idx: Vec<u32> = positions.iter().map(|&p| p as u32).collect();
let idx = Tensor::from_vec(idx, positions.len(), self.cos.device())?;
let cos = self.cos.index_select(&idx, 0)?.unsqueeze(1)?;
let sin = self.sin.index_select(&idx, 0)?.unsqueeze(1)?;
let cos = self.cos.index_select(&idx, 0)?;
let sin = self.sin.index_select(&idx, 0)?;
Ok((cos, sin))
}
@@ -212,38 +246,65 @@ impl RotaryEmbedding {
) -> candle_core::Result<(Tensor, Tensor)> {
let (_, _, _seq_len, head_dim_in) = q.dims4()?;
debug_assert_eq!(head_dim_in, self.head_dim, "q head_dim mismatch");
let per_row = cos.rank() == 3;
let rope = |x: &Tensor| -> candle_core::Result<Tensor> {
if per_row {
rope_per_row(x, cos, sin)
} else {
candle_nn::rotary_emb::rope_slow(x, cos, sin)
}
};
if self.rotary_dim == self.head_dim {
let q_embed = rope(&q.contiguous()?)?;
let k_embed = rope(&k.contiguous()?)?;
Ok((q_embed, k_embed))
} else {
// Partial rotation: narrow → rotate → cat the untouched tail.
let tail = self.head_dim - self.rotary_dim;
let q_rot = q
.narrow(candle_core::D::Minus1, 0, self.rotary_dim)?
.contiguous()?;
let q_pass = q.narrow(candle_core::D::Minus1, self.rotary_dim, tail)?;
let k_rot = k
.narrow(candle_core::D::Minus1, 0, self.rotary_dim)?
.contiguous()?;
let k_pass = k.narrow(candle_core::D::Minus1, self.rotary_dim, tail)?;
let q_rotated = rope(&q_rot)?;
let k_rotated = rope(&k_rot)?;
let q_embed =
Tensor::cat(&[&q_rotated, &q_pass.contiguous()?], candle_core::D::Minus1)?;
let k_embed =
Tensor::cat(&[&k_rotated, &k_pass.contiguous()?], candle_core::D::Minus1)?;
Ok((q_embed, k_embed))
}
Ok((
apply_partial_rotary(q, cos, sin, self.rotary_dim)?,
apply_partial_rotary(k, cos, sin, self.rotary_dim)?,
))
}
/// How many of each head's dims rotate. Heads narrower than
/// `head_dim` share the same rotation — `qwen4_exp`'s QSA indexer
/// keys are 128 wide and still rotate 64 dims against the same
/// cos/sin.
pub fn rotary_dim(&self) -> usize {
self.rotary_dim
}
/// The head width `apply_cos_sin` expects.
pub fn head_dim(&self) -> usize {
self.head_dim
}
}
/// Rotate the first `rotary_dim` dims of `x` and pass the rest through.
///
/// `x` is `(B, H, L, width)` for any `width >= rotary_dim`, and
/// `cos`/`sin` are `(L, rotary_dim/2)` — or `(B, L, rotary_dim/2)` for
/// the batched-decode path (#98), dispatched on rank.
///
/// Split out of [`RotaryEmbedding::apply_cos_sin`] because the rotation
/// is a property of the *frequencies*, not of the head it is applied
/// to: `qwen4_exp`'s sparse-attention indexer rotates 64 of its 128
/// indexer dims using the same cos/sin the 256-wide attention heads
/// use, so it needs the rotation without the head-width assertion.
pub fn apply_partial_rotary(
x: &Tensor,
cos: &Tensor,
sin: &Tensor,
rotary_dim: usize,
) -> candle_core::Result<Tensor> {
let width = x.dim(candle_core::D::Minus1)?;
debug_assert!(
width >= rotary_dim,
"rotary_dim {rotary_dim} exceeds head width {width}"
);
let per_row = cos.rank() == 3;
let rope = |t: &Tensor| -> candle_core::Result<Tensor> {
if per_row {
rope_per_row(t, cos, sin)
} else {
candle_nn::rotary_emb::rope_slow(t, cos, sin)
}
};
if rotary_dim == width {
return rope(&x.contiguous()?);
}
// Partial rotation: narrow → rotate → cat the untouched tail.
let rot = x
.narrow(candle_core::D::Minus1, 0, rotary_dim)?
.contiguous()?;
let pass = x.narrow(candle_core::D::Minus1, rotary_dim, width - rotary_dim)?;
Tensor::cat(&[&rope(&rot)?, &pass.contiguous()?], candle_core::D::Minus1)
}
/// GLM rotate-half (same convention as candle's private
@@ -0,0 +1,508 @@
//! `qwen4_exp`'s `config.json`, and the geometry derived from it.
//!
//! Read from the checkpoint's own file
//! (`Qwen/Qwen3.8-Flash-Next`, revision
//! `de4b8e4d43b917e7706784d8bb445c9af86a3540`), which nests everything
//! the text path needs under `text_config` alongside a `vision_config`
//! this module ignores until #314.
//!
//! Most of the vocabulary overlaps [`super::super::qwen3_5`] — the same
//! hybrid `layer_types`, the same GatedDeltaNet dimensions, the same
//! nested `rope_parameters` — so the reused blocks read their settings
//! from the same shapes. What is new is declared here: the four
//! residual streams, the hashed n-gram table, and the sparse-attention
//! indexer.
//!
//! Three of these fields say something other than what they appear to:
//!
//! 1. **`ple_layer_ids` is one-indexed.** The shipped `[2]` means
//! zero-indexed layer **1**. Upstream computes
//! `ple_layer_ids.index(layer_idx + 1)`; reading it as a plain layer
//! number puts 28% of the model's parameters on the wrong layer, and
//! the model still speaks.
//! 2. **`output_gate_type: sigmoid`.** Qwen3.6 leaves this unset and
//! falls back to `hidden_act`, which is SiLU. Inheriting that here
//! is wrong on all 36 linear-attention layers.
//! 3. **`mamba_ssm_dtype: float32`.** We default the equivalent
//! GatedDeltaNet state to a bf16 round-trip (#284). This checkpoint
//! makes an explicit choice and a port must not silently override
//! it with ours.
//!
//! There is no `intermediate_size`: every layer's FFN is the MoE, so
//! there is no dense width to declare. See
//! `doc/qwen4_exp-port-spec.md`.
use anyhow::{Context, Result, ensure};
use serde::Deserialize;
use crate::harness::arch::qwen3_5::RopeParameters;
use crate::harness::arch::qwen3_5::rmsnorm::OutputGate;
/// `model_type` for the multimodal wrapper.
pub const MODEL_TYPE: &str = "qwen4_exp";
/// `model_type` of the nested `text_config`.
pub const TEXT_MODEL_TYPE: &str = "qwen4_exp_text";
/// The top-level config. `vision_config` is deliberately not modelled —
/// serde drops it, and #314 owns the tower.
#[derive(Debug, Clone, Deserialize)]
pub struct Config {
#[serde(default)]
pub model_type: String,
pub text_config: TextConfig,
#[serde(default)]
pub tie_word_embeddings: bool,
#[serde(default)]
pub image_token_id: Option<u32>,
#[serde(default)]
pub video_token_id: Option<u32>,
}
impl Config {
pub fn from_config_json(json: &str) -> Result<Self> {
let mut cfg: Self = serde_json::from_str(json).context("parse qwen4_exp config.json")?;
cfg.text_config.fill_layer_types()?;
cfg.text_config.validate()?;
Ok(cfg)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct TextConfig {
pub vocab_size: usize,
pub hidden_size: usize,
pub num_hidden_layers: usize,
pub num_attention_heads: usize,
pub num_key_value_heads: usize,
pub head_dim: usize,
pub max_position_embeddings: usize,
pub rms_norm_eps: f64,
/// Reused verbatim from `qwen3_5` — the nesting and every field in
/// it are identical, which is what makes `qwen3_5/rope.rs` reusable
/// unchanged (spec §7).
pub rope_parameters: RopeParameters,
#[serde(default = "default_hidden_act")]
pub hidden_act: String,
#[serde(default)]
pub tie_word_embeddings: bool,
/// 248044 for this checkpoint — the id PLE's segment-aware shifts
/// fill with, so it is load-bearing beyond stopping generation.
pub eos_token_id: i64,
/// Authoritative per-layer dispatch. Derived from
/// `full_attention_interval` if absent.
#[serde(default)]
pub layer_types: Vec<String>,
#[serde(default)]
pub full_attention_interval: Option<usize>,
// --- Hyper-connections (§1) ----------------------------------------
/// Parallel residual streams. The inter-layer residual is
/// `hidden_size * hc_count` wide.
pub hc_count: usize,
/// Bottleneck width of the stream-mixing gate.
pub hc_lowrank: usize,
// --- PLE (§2) ------------------------------------------------------
/// **One-indexed.** Use [`TextConfig::ple_layers`].
#[serde(default)]
pub ple_layer_ids: Vec<usize>,
pub ple_embed_dim: usize,
pub ple_conv_kernel_size: usize,
/// Also the short conv's dilation.
pub ngram_size: usize,
pub heads_per_ngram: usize,
pub ngram_vocab_size_base: i64,
pub make_ngram_vocab_size_divisible_by: usize,
/// Shards the table is stored in; a flat split, concatenated in
/// shard-index order.
pub split_ngram_parts: usize,
// --- QSA indexer (§3) ----------------------------------------------
pub indexer_n_heads: usize,
pub indexer_kv_heads: usize,
pub indexer_head_dim: usize,
/// In **tokens**, not blocks.
pub indexer_budget: usize,
/// Positions pooled into one scored block.
pub indexer_compress_ratio: usize,
// --- Linear attention (§5) -----------------------------------------
pub linear_num_key_heads: usize,
pub linear_key_head_dim: usize,
pub linear_num_value_heads: usize,
pub linear_value_head_dim: usize,
pub linear_conv_kernel_dim: usize,
/// `"float32"` here; see the module note.
#[serde(default)]
pub mamba_ssm_dtype: Option<String>,
/// `"sigmoid"` here, where Qwen3.6 falls back to `hidden_act`.
#[serde(default)]
pub output_gate_type: Option<String>,
// --- MoE (§6) ------------------------------------------------------
pub num_experts: usize,
pub num_experts_per_tok: usize,
pub moe_intermediate_size: usize,
pub shared_expert_intermediate_size: usize,
/// Absent from this `config.json`; upstream's default is `true`.
#[serde(default = "default_true")]
pub norm_topk_prob: bool,
// --- MTP (§9, #313) ------------------------------------------------
#[serde(default)]
pub mtp_num_hidden_layers: usize,
}
impl TextConfig {
/// Zero-indexed layers carrying a PLE block — `[2]` becomes `[1]`.
pub fn ple_layers(&self) -> Vec<usize> {
self.ple_layer_ids.iter().map(|id| id - 1).collect()
}
/// `(ngram_size - 1) * heads_per_ngram` — 16. There is no unigram
/// head.
pub fn ngram_heads(&self) -> usize {
(self.ngram_size - 1) * self.heads_per_ngram
}
/// Dims each n-gram head contributes to the 2560-wide embedding.
pub fn ngram_head_dim(&self) -> usize {
self.ple_embed_dim / self.ngram_heads()
}
/// The inter-layer residual width, `hidden_size * hc_count`.
pub fn stream_width(&self) -> usize {
self.hidden_size * self.hc_count
}
/// Blocks the QSA indexer may select.
pub fn block_topk(&self) -> usize {
self.indexer_budget / self.indexer_compress_ratio
}
/// Which nonlinearity the GatedDeltaNet output gate applies.
/// Falls back to `hidden_act` the way upstream does — but this
/// checkpoint states it, and the two disagree.
pub fn output_gate(&self) -> Result<OutputGate> {
OutputGate::from_name(self.output_gate_type.as_deref().unwrap_or(&self.hidden_act))
}
/// Whether to carry the GatedDeltaNet recurrent state in f32.
/// `mamba_ssm_dtype` is upstream's explicit choice; absent, we keep
/// our own default rather than inventing one.
pub fn ssm_state_is_f32(&self) -> Option<bool> {
self.mamba_ssm_dtype
.as_deref()
.map(|d| d.eq_ignore_ascii_case("float32") || d.eq_ignore_ascii_case("f32"))
}
pub fn is_full_attention(&self, layer: usize) -> bool {
self.layer_types
.get(layer)
.is_some_and(|t| t == "full_attention")
}
fn fill_layer_types(&mut self) -> Result<()> {
if !self.layer_types.is_empty() {
return Ok(());
}
let interval = self.full_attention_interval.unwrap_or(4);
ensure!(
interval > 0,
"full_attention_interval must be >= 1 to derive layer_types"
);
// Every `interval`-th layer counting from one, so with
// interval 4 the full-attention layers are 3, 7, … 47.
self.layer_types = (0..self.num_hidden_layers)
.map(|i| {
if (i + 1).is_multiple_of(interval) {
"full_attention".to_string()
} else {
"linear_attention".to_string()
}
})
.collect();
Ok(())
}
fn validate(&self) -> Result<()> {
ensure!(
self.layer_types.len() == self.num_hidden_layers,
"layer_types has {} entries for {} layers",
self.layer_types.len(),
self.num_hidden_layers
);
ensure!(self.hc_count >= 1, "hc_count must be >= 1");
ensure!(
self.ngram_size >= 2,
"ngram_size must be >= 2; {} leaves no n-gram heads",
self.ngram_size
);
let heads = self.ngram_heads();
ensure!(
heads > 0 && self.ple_embed_dim.is_multiple_of(heads),
"ple_embed_dim ({}) must divide evenly among {heads} n-gram heads",
self.ple_embed_dim
);
for id in &self.ple_layer_ids {
// One-indexed: 0 is not a layer, and N is the last one.
ensure!(
*id >= 1 && *id <= self.num_hidden_layers,
"ple_layer_ids is one-indexed; {id} is outside 1..={}",
self.num_hidden_layers
);
}
ensure!(
self.indexer_compress_ratio >= 1
&& self
.indexer_budget
.is_multiple_of(self.indexer_compress_ratio),
"indexer_budget ({}) must be a whole number of blocks of {}",
self.indexer_budget,
self.indexer_compress_ratio
);
ensure!(
self.num_experts_per_tok <= self.num_experts,
"num_experts_per_tok ({}) exceeds num_experts ({})",
self.num_experts_per_tok,
self.num_experts
);
Ok(())
}
}
fn default_hidden_act() -> String {
"silu".to_string()
}
fn default_true() -> bool {
true
}
/// Whether a `config.json` declares this architecture, so the harness
/// can route it here rather than to the `qwen3_5` sibling.
pub fn is_qwen4_exp(json: &str) -> bool {
serde_json::from_str::<serde_json::Value>(json)
.ok()
.and_then(|v| {
v.get("model_type")
.and_then(|m| m.as_str())
.map(|m| m == MODEL_TYPE)
})
.unwrap_or(false)
}
/// The shipped `config.json`, verbatim apart from the vision block
/// and the 48-entry `layer_types` (spelled out below in
/// `SHIPPED_LAYER_TYPES` so the derivation can be tested against
/// the real one). Values are from revision
/// `de4b8e4d43b917e7706784d8bb445c9af86a3540`.
#[cfg(test)]
pub(crate) const SHIPPED: &str = r#"{
"architectures": ["Qwen4ExpForConditionalGeneration"],
"image_token_id": 248056,
"language_model_only": false,
"model_type": "qwen4_exp",
"tie_word_embeddings": false,
"video_token_id": 248057,
"vision_start_token_id": 248053,
"vision_end_token_id": 248054,
"vision_config": {"depth": 27, "hidden_size": 1152},
"text_config": {
"attention_bias": false,
"attention_dropout": 0.0,
"bos_token_id": 248044,
"dtype": "bfloat16",
"eos_token_id": 248044,
"full_attention_interval": 4,
"hc_count": 4,
"hc_lowrank": 320,
"head_dim": 256,
"heads_per_ngram": 8,
"hidden_act": "silu",
"hidden_size": 2560,
"indexer_budget": 2048,
"indexer_compress_ratio": 4,
"indexer_head_dim": 128,
"indexer_kv_heads": 1,
"indexer_n_heads": 4,
"initializer_range": 0.02,
"linear_conv_kernel_dim": 4,
"linear_key_head_dim": 128,
"linear_num_key_heads": 16,
"linear_num_value_heads": 48,
"linear_value_head_dim": 128,
"make_ngram_vocab_size_divisible_by": 128,
"mamba_ssm_dtype": "float32",
"max_position_embeddings": 262144,
"model_type": "qwen4_exp_text",
"moe_intermediate_size": 640,
"mtp": {"hybrid": true, "num_hidden_layers": 1, "rope_theta": 10000000},
"mtp_num_hidden_layers": 1,
"mtp_use_dedicated_embeddings": false,
"ngram_size": 3,
"ngram_vocab_size_base": 20000000,
"num_attention_heads": 24,
"num_experts": 512,
"num_experts_per_tok": 10,
"num_hidden_layers": 48,
"num_key_value_heads": 2,
"output_gate_type": "sigmoid",
"output_router_logits": false,
"pad_token_id": null,
"partial_rotary_factor": 0.25,
"ple_conv_kernel_size": 4,
"ple_embed_dim": 2560,
"ple_layer_ids": [2],
"rms_norm_eps": 1e-06,
"rope_parameters": {
"mrope_interleaved": true,
"mrope_section": [11, 11, 10],
"partial_rotary_factor": 0.25,
"rope_theta": 10000000,
"rope_type": "default"
},
"router_aux_loss_coef": 0.001,
"shared_expert_intermediate_size": 640,
"split_ngram_parts": 128,
"tie_word_embeddings": false,
"use_cache": true,
"vocab_size": 248320
}
}"#;
#[cfg(test)]
mod tests {
use super::*;
/// The real file's `layer_types`, as shipped.
const SHIPPED_FULL_ATTENTION_LAYERS: [usize; 12] =
[3, 7, 11, 15, 19, 23, 27, 31, 35, 39, 43, 47];
fn shipped() -> Config {
Config::from_config_json(SHIPPED).unwrap()
}
#[test]
fn parses_the_shipped_config() {
let cfg = shipped();
assert_eq!(cfg.model_type, MODEL_TYPE);
assert!(!cfg.tie_word_embeddings);
let t = &cfg.text_config;
assert_eq!(t.hidden_size, 2560);
assert_eq!(t.num_hidden_layers, 48);
assert_eq!(t.vocab_size, 248_320);
assert_eq!(t.max_position_embeddings, 262_144);
assert_eq!(t.eos_token_id, 248_044);
// Four streams of 2560 — the residual every decoder block sees.
assert_eq!(t.stream_width(), 10_240);
assert_eq!(t.hc_lowrank, 320);
assert!(is_qwen4_exp(SHIPPED));
}
/// The single most expensive off-by-one available here: reading
/// `[2]` as a layer number rather than a one-indexed position puts
/// PLE on layer 2 instead of layer 1.
#[test]
fn ple_layer_ids_are_one_indexed() {
let t = &shipped().text_config;
assert_eq!(t.ple_layer_ids, vec![2], "as shipped, one-indexed");
assert_eq!(t.ple_layers(), vec![1], "zero-indexed, where it runs");
}
/// Sixteen heads of 160, and no unigram head — `ngram_size 3` runs
/// the bigram and trigram orders only.
#[test]
fn ngram_geometry_is_sixteen_heads_of_one_sixty() {
let t = &shipped().text_config;
assert_eq!(t.ngram_heads(), 16);
assert_eq!(t.ngram_head_dim(), 160);
assert_eq!(t.ngram_heads() * t.ngram_head_dim(), t.ple_embed_dim);
// The conv's dilation is the n-gram size, not its kernel.
assert_eq!(t.ple_conv_kernel_size, 4);
assert_eq!(t.ngram_size, 3);
}
/// The derivation must reproduce the file's own `layer_types`:
/// every fourth layer counting from one, so 3, 7, … 47.
#[test]
fn derived_layer_types_match_the_shipped_ones() {
let t = &shipped().text_config;
let full: Vec<usize> = (0..t.num_hidden_layers)
.filter(|i| t.is_full_attention(*i))
.collect();
assert_eq!(full, SHIPPED_FULL_ATTENTION_LAYERS);
assert_eq!(full.len(), 12);
assert_eq!(t.num_hidden_layers - full.len(), 36);
}
/// `output_gate_type` overrides `hidden_act`, and the two disagree:
/// inheriting SiLU here is wrong on all 36 linear-attention layers.
#[test]
fn the_output_gate_is_sigmoid_not_the_hidden_act() {
let t = &shipped().text_config;
assert_eq!(t.hidden_act, "silu");
assert_eq!(t.output_gate().unwrap(), OutputGate::Sigmoid);
assert_eq!(t.ssm_state_is_f32(), Some(true));
}
/// The reused rope module is only reusable if these four values are
/// what its masks were pinned against (spec §7).
#[test]
fn rope_parameters_are_the_ones_qwen3_5_already_implements() {
let t = &shipped().text_config;
let r = &t.rope_parameters;
assert_eq!(r.rope_theta, 10_000_000.0);
assert_eq!(r.partial_rotary_factor, 0.25);
assert_eq!(r.mrope_section, vec![11, 11, 10]);
assert!(r.mrope_interleaved);
// 64 of 256 head dims rotate; 32 frequencies, which is what
// mrope_section sums to.
assert_eq!((t.head_dim as f32 * r.partial_rotary_factor) as usize, 64);
assert_eq!(r.mrope_section.iter().sum::<usize>(), 32);
}
#[test]
fn qsa_geometry_selects_512_blocks_of_four() {
let t = &shipped().text_config;
assert_eq!(t.block_topk(), 512);
assert_eq!(t.indexer_compress_ratio, 4);
assert_eq!(t.indexer_n_heads, 4);
assert_eq!(t.indexer_kv_heads, 1);
assert_eq!(t.indexer_head_dim, 128);
}
/// A one-indexed field that reads 0 is a config written against the
/// wrong convention, not a layer.
#[test]
fn rejects_a_zero_ple_layer_id() {
let bad = SHIPPED.replace("\"ple_layer_ids\": [2]", "\"ple_layer_ids\": [0]");
let err = Config::from_config_json(&bad).unwrap_err().to_string();
assert!(err.contains("one-indexed"), "got: {err}");
}
#[test]
fn rejects_layer_types_that_do_not_cover_every_layer() {
let bad = SHIPPED.replace(
"\"num_hidden_layers\": 48",
"\"num_hidden_layers\": 48, \"layer_types\": [\"full_attention\"]",
);
let err = Config::from_config_json(&bad).unwrap_err().to_string();
assert!(err.contains("layer_types"), "got: {err}");
}
/// The harness dispatches on `model_type`, and the sibling archs
/// must not claim this checkpoint or be claimed by it.
#[test]
fn architecture_detection_is_exact() {
assert!(is_qwen4_exp(SHIPPED));
assert!(!is_qwen4_exp(r#"{"model_type": "qwen3_5"}"#));
assert!(!is_qwen4_exp(r#"{"model_type": "qwen3_next"}"#));
// The nested text_config's own model_type is a different string
// and must not be what we match on.
assert_ne!(MODEL_TYPE, TEXT_MODEL_TYPE);
assert!(!is_qwen4_exp(r#"{"model_type": "qwen4_exp_text"}"#));
assert!(!is_qwen4_exp("not json"));
}
}
@@ -0,0 +1,389 @@
//! `qwen4_exp`'s decoder layer — the composition, and the only part of
//! it that is new.
//!
//! Every other architecture we serve wraps a sublayer as
//! `x = x + sublayer(norm(x))` over one residual stream. This one
//! carries **four**, concatenated into a `hidden_size * hc_count`
//! tensor, and has **no `input_layernorm`, no
//! `post_attention_layernorm` and no final `model.norm`** anywhere in
//! the checkpoint. The `hc_norm` inside each hyper-connection does all
//! three jobs.
//!
//! ```text
//! h [B,T,10240]
//! ├─ (layer 1 only) h = h + PLE(h, gathered n-grams)
//! ├─ attn_hyper_connection: split → linear_attn | self_attn → scatter
//! └─ mlp_hyper_connection: split → sparse_moe → scatter
//! ```
//!
//! The spec's warning about this layer is worth repeating: a wrong
//! stream mix still produces fluent text. So the plumbing lives in
//! [`through`], which takes the sublayer as a closure and can be tested
//! against a known function without constructing an attention block —
//! and the two things it must get right are asserted there:
//!
//! 1. **The sublayer sees the mixed `hidden_size` vector**, not the
//! four-stream tensor. It is 2560 wide, and the sublayers are built
//! for exactly that.
//! 2. **The residual added back is the un-normalised `h`.** Only the
//! mixing and injection paths see the normalised form.
//!
//! PLE is added into the stream *before* the attention
//! hyper-connection, not merged into it — the layer's own blocks then
//! run on the sum. See `doc/qwen4_exp-port-spec.md`.
use anyhow::{Result, anyhow};
use candle_core::{Module, Tensor};
use candle_nn::var_builder::ShardedVarBuilder;
use std::sync::Arc;
use crate::harness::arch::qwen3_5::linear_attn::GatedDeltaNet;
use crate::harness::arch::qwen3_5::moe::Qwen3_5MoeBlock;
use crate::harness::arch::qwen3_5::rope::RotaryEmbedding;
use candle_core::quantized::GgmlDType;
use super::config::TextConfig;
use super::full_attn::Attention;
use super::hyper::HyperConnection;
use super::ple::PleBlock;
use super::{linear_attn, moe};
use crate::harness::arch::snapshot::LayerKvSnapshot;
/// The attention slot: one layer in four is full attention with the QSA
/// indexer, the rest are the recurrent delta rule.
enum Mixer {
Full(Box<Attention>),
Linear(Box<GatedDeltaNet>),
}
pub struct DecoderLayer {
attn_hc: HyperConnection,
mlp_hc: HyperConnection,
mixer: Mixer,
/// Every layer's FFN is the MoE; there is no dense width in this
/// config to build an alternative from.
mlp: Qwen3_5MoeBlock,
/// Present on exactly the layers `ple_layer_ids` names — layer 1,
/// zero-indexed, for the shipped checkpoint.
ple: Option<PleBlock>,
}
impl DecoderLayer {
pub fn load(
cfg: &TextConfig,
rotary: Arc<RotaryEmbedding>,
layer_idx: usize,
quant: Option<GgmlDType>,
vb: &ShardedVarBuilder,
) -> Result<Self> {
let layer_type = cfg
.layer_types
.get(layer_idx)
.map(String::as_str)
.ok_or_else(|| {
anyhow!(
"layer_types[{layer_idx}] missing (have {} entries)",
cfg.layer_types.len()
)
})?;
Self::load_typed(
cfg,
rotary,
layer_type,
cfg.ple_layers().contains(&layer_idx),
quant,
vb,
)
}
/// Load a layer whose type the caller knows, rather than one looked
/// up in `layer_types`.
///
/// The MTP head's layer (#313) needs this: it lives under
/// `mtp.layers.0` with its own single-entry type list, and it
/// carries no PLE wherever the main model puts one.
pub fn load_typed(
cfg: &TextConfig,
rotary: Arc<RotaryEmbedding>,
layer_type: &str,
with_ple: bool,
quant: Option<GgmlDType>,
vb: &ShardedVarBuilder,
) -> Result<Self> {
let mixer = match layer_type {
"full_attention" => {
Mixer::Full(Box::new(Attention::load(cfg, rotary, &vb.pp("self_attn"))?))
}
"linear_attention" => {
Mixer::Linear(Box::new(linear_attn::load(cfg, &vb.pp("linear_attn"))?))
}
other => anyhow::bail!(
"unknown layer_type '{other}' (expected 'full_attention' or \
'linear_attention')"
),
};
let hc = |name: &str| {
HyperConnection::load(
&vb.pp(name),
cfg.hidden_size,
cfg.hc_count,
cfg.hc_lowrank,
cfg.rms_norm_eps,
// Decoder hyper-connections always scatter back; only
// the top-level mixer does not.
true,
)
};
let ple = if with_ple {
Some(PleBlock::load(
&vb.pp("ple"),
cfg.hidden_size,
cfg.hc_count,
cfg.ple_conv_kernel_size,
// The dilation is the n-gram size, not the kernel.
cfg.ngram_size,
cfg.rms_norm_eps,
)?)
} else {
None
};
Ok(Self {
attn_hc: hc("attn_hyper_connection")?,
mlp_hc: hc("mlp_hyper_connection")?,
mixer,
mlp: moe::load(cfg, quant, &vb.pp("mlp"))?,
ple,
})
}
/// `h` is the four-stream residual, `[B, L, hidden * hc_count]`.
///
/// `ple_embed` is the gathered n-gram embedding for this step,
/// `[B, L, hidden]` — required on the PLE layer and ignored
/// everywhere else.
pub fn forward(
&mut self,
h: &Tensor,
ple_embed: Option<&Tensor>,
causal_mask: Option<&Tensor>,
cos: &Tensor,
sin: &Tensor,
past_len: usize,
) -> Result<Tensor> {
// Split the borrows so the sublayer closures can take `&mut`
// while the hyper-connections are read.
let Self {
attn_hc,
mlp_hc,
mixer,
mlp,
ple,
} = self;
let h = match ple {
Some(block) => {
let e = ple_embed.ok_or_else(|| {
anyhow!(
"this layer carries a PLE block but no gathered n-gram \
embedding was supplied"
)
})?;
(h + block.forward(h, e)?)?
}
None => h.clone(),
};
let h = through(attn_hc, &h, |x| match mixer {
Mixer::Full(attn) => attn.forward(x, causal_mask, cos, sin, past_len),
// The recurrent path's causality is in its state lifecycle,
// so it takes neither mask nor rotary.
Mixer::Linear(net) => Ok(net.forward(x)?),
})?;
through(mlp_hc, &h, |x| Ok(mlp.forward(x)?))
}
pub fn clear_kv_cache(&mut self) -> Result<()> {
match &mut self.mixer {
Mixer::Full(attn) => attn.clear_kv_cache(),
Mixer::Linear(net) => net.clear_kv_cache(),
}
if let Some(ple) = &mut self.ple {
ple.clear_state();
}
Ok(())
}
/// Capture this layer's attention-side state.
pub fn snapshot_kv(&self) -> candle_core::Result<LayerKvSnapshot> {
Ok(match &self.mixer {
Mixer::Full(attn) => {
let (kv, indexer_keys) = attn.snapshot_kv();
LayerKvSnapshot::FullSparse { kv, indexer_keys }
}
Mixer::Linear(net) => {
let (conv_state, recurrent_state) = net.snapshot_state()?;
LayerKvSnapshot::Linear {
conv_state,
recurrent_state,
}
}
})
}
/// Replace this layer's attention-side state. A variant mismatch
/// means the snapshot came from a different model, or from the same
/// model with a different `layer_types` — either way, restoring it
/// would put a recurrent state into an attention layer.
pub fn restore_kv(&mut self, snap: &LayerKvSnapshot) -> candle_core::Result<()> {
match (&mut self.mixer, snap) {
(Mixer::Full(attn), LayerKvSnapshot::FullSparse { kv, indexer_keys }) => {
attn.restore_kv(kv.as_ref(), indexer_keys.as_ref())
}
(
Mixer::Linear(net),
LayerKvSnapshot::Linear {
conv_state,
recurrent_state,
},
) => net.restore_state(conv_state.as_ref(), recurrent_state.as_ref()),
_ => candle_core::bail!(
"restore_kv: snapshot layer kind does not match this layer's mixer kind"
),
}
}
/// The PLE block's rolling conv context, if this layer carries one.
pub fn snapshot_ple(&self) -> candle_core::Result<Option<Tensor>> {
match &self.ple {
Some(block) => block.snapshot_state(),
None => Ok(None),
}
}
/// Restore the PLE conv context. Refuses a snapshot that disagrees
/// with this layer about whether PLE lives here at all.
pub fn restore_ple(&mut self, conv_state: Option<&Tensor>) -> candle_core::Result<()> {
match &mut self.ple {
Some(block) => block.restore_state(conv_state),
None if conv_state.is_none() => Ok(()),
None => candle_core::bail!(
"restore_ple: snapshot carries PLE state but this layer has no PLE block"
),
}
}
pub fn has_ple(&self) -> bool {
self.ple.is_some()
}
}
/// Run one sublayer through a hyper-connection.
///
/// Split the four streams into the `hidden_size` vector the sublayer
/// expects, call it, and scatter the result back across the streams
/// with the per-stream injection weights. The tensor added back is the
/// **un-normalised** `h`; only the mixing and injection paths see the
/// normalised form.
fn through<F>(hc: &HyperConnection, h: &Tensor, sublayer: F) -> Result<Tensor>
where
F: FnOnce(&Tensor) -> Result<Tensor>,
{
let split = hc.split(h)?;
let inject = split.inject.as_ref().ok_or_else(|| {
anyhow!("a decoder hyper-connection must carry block_inject_weight to scatter back")
})?;
let out = sublayer(&split.input)?;
Ok(hc.combine(&split.residual, &out, inject)?)
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::{DType, Device};
use std::cell::RefCell;
/// With every projection zeroed the gate is exactly 0.5 and the
/// injection exactly 1, so `through` reduces to
/// `h + (sublayer(mean of normalised streams) broadcast to all)`.
fn hc(hidden: usize, hc_count: usize) -> HyperConnection {
HyperConnection::zeroed_for_test(hidden, hc_count, 2, true)
}
fn stream(values: &[f32]) -> Tensor {
Tensor::from_vec(values.to_vec(), (1, 1, values.len()), &Device::Cpu).unwrap()
}
/// The sublayer is handed the mixed `hidden_size` vector, not the
/// four-stream tensor. Feeding it the raw stream would be four
/// times too wide and every sublayer in the model is built for the
/// narrow one.
#[test]
fn the_sublayer_sees_one_stream_and_the_result_is_four() {
let seen = RefCell::new(Vec::new());
let h = stream(&[3.0, 4.0, 30.0, 40.0]);
let out = through(&hc(2, 2), &h, |x| {
seen.borrow_mut().extend_from_slice(x.dims());
Ok(x.zeros_like()?)
})
.unwrap();
assert_eq!(seen.into_inner(), vec![1, 1, 2], "sublayer input width");
assert_eq!(out.dims(), &[1, 1, 4], "the residual stays four streams");
}
/// A sublayer that returns zero must leave the stream exactly as it
/// was — not as its normalised form. This is the assertion that
/// separates a correct residual from one that quietly re-normalises
/// the model every layer.
#[test]
fn a_silent_sublayer_leaves_the_stream_untouched() {
let h = stream(&[3.0, 4.0, 30.0, 40.0]);
let out = through(&hc(2, 2), &h, |x| Ok(x.zeros_like()?)).unwrap();
let got: Vec<f32> = out.flatten_all().unwrap().to_vec1().unwrap();
assert_eq!(got, vec![3.0, 4.0, 30.0, 40.0]);
}
/// The sublayer's output reaches every stream, scaled by that
/// stream's injection weight — 1.0 for all of them here.
#[test]
fn the_result_is_injected_into_every_stream() {
let h = stream(&[3.0, 4.0, 30.0, 40.0]);
let out = through(&hc(2, 2), &h, |x| Ok((x.ones_like()? * 7.0)?)).unwrap();
let got: Vec<f32> = out.flatten_all().unwrap().to_vec1().unwrap();
assert_eq!(got, vec![10.0, 11.0, 37.0, 47.0]);
}
/// A hyper-connection with no `block_inject_weight` is the
/// top-level mixer, which collapses the streams instead of feeding
/// a sublayer. Using one here would silently drop the residual.
#[test]
fn a_mixer_cannot_stand_in_for_a_decoder_hyper_connection() {
let mixer = HyperConnection::zeroed_for_test(2, 2, 2, false);
let h = stream(&[1.0, 1.0, 1.0, 1.0]);
let err = through(&mixer, &h, |x| Ok(x.clone()))
.unwrap_err()
.to_string();
assert!(err.contains("block_inject_weight"), "got: {err}");
}
#[test]
fn ple_layers_are_the_zero_indexed_ones() {
let cfg =
super::super::config::Config::from_config_json(super::super::config::SHIPPED).unwrap();
// The composition asks the config, and the config is one-indexed.
assert_eq!(cfg.text_config.ple_layers(), vec![1]);
assert!(!cfg.text_config.ple_layers().contains(&2));
}
#[test]
fn zeroed_stream_keeps_its_dtype_and_shape() {
let h = Tensor::zeros((2, 3, 8), DType::F32, &Device::Cpu).unwrap();
let out = through(&hc(2, 4), &h, |x| Ok(x.clone())).unwrap();
assert_eq!(out.dims(), &[2, 3, 8]);
}
}
@@ -0,0 +1,381 @@
//! `qwen4_exp`'s full-attention layer — one in every four.
//!
//! Mechanically this is `qwen3_5`'s: GQA 24:2, per-head `(1 + w)`
//! RMSNorm on q and k, a sigmoid output gate carried in a widened
//! `q_proj`, and `head_dim^-0.5` scaling. It is written here rather
//! than reused because of the one thing that differs — the [`Indexer`]
//! that decides which of the past this layer may look at — and because
//! the two architectures' configs are different types.
//!
//! Two details are worth stating where the code can be read against
//! them:
//!
//! 1. **The gate split is per head, not a halving.** `q_proj` is
//! `num_heads * head_dim * 2` wide; reshape to
//! `(.., num_heads, 2 * head_dim)` and take the halves *within* each
//! head. Splitting the flat 12288 vector down the middle gives a
//! different permutation — the first twelve heads' queries as the
//! query and the last twelve as the gate — which trains nothing and
//! reads as a weak model.
//! 2. **QSA replaces the causal mask; it does not join it.** The
//! selection only ever offers positions at or behind the query, so
//! the returned mask is already causal. Below the budget the indexer
//! declines to produce one at all and this layer keeps the plain
//! causal mask — which is also what keeps the flash path available
//! for short prompts, since an additive mask forecloses it.
//!
//! See `doc/qwen4_exp-port-spec.md` §3 and §4.
use anyhow::{Context, Result};
use candle_core::{Module, Tensor};
use candle_nn::Linear;
use candle_nn::kv_cache::ConcatKvCache;
use candle_nn::var_builder::ShardedVarBuilder;
use std::sync::Arc;
use crate::harness::arch::qwen3_5::full_attn::{AttnMask, attention_context};
use crate::harness::arch::qwen3_5::rmsnorm::Qwen3_5RmsNorm;
use crate::harness::arch::qwen3_5::rope::RotaryEmbedding;
use super::config::TextConfig;
use super::qsa::Indexer;
pub struct Attention {
q_proj: Linear,
k_proj: Linear,
v_proj: Linear,
o_proj: Linear,
q_norm: Qwen3_5RmsNorm,
k_norm: Qwen3_5RmsNorm,
/// The sparse-attention side channel. Owns its own KV cache, which
/// must be cleared with this layer's.
indexer: Indexer,
num_heads: usize,
num_kv_heads: usize,
num_kv_groups: usize,
head_dim: usize,
/// `num_heads * head_dim` — the attention output width, which is
/// not `hidden_size` here (6144 against 2560).
attn_width: usize,
rotary: Arc<RotaryEmbedding>,
kv_cache: ConcatKvCache,
}
impl Attention {
pub fn load(
cfg: &TextConfig,
rotary: Arc<RotaryEmbedding>,
vb: &ShardedVarBuilder,
) -> Result<Self> {
let head_dim = cfg.head_dim;
let num_heads = cfg.num_attention_heads;
let num_kv_heads = cfg.num_key_value_heads;
anyhow::ensure!(
num_kv_heads > 0 && num_heads.is_multiple_of(num_kv_heads),
"num_attention_heads ({num_heads}) must be a positive multiple of \
num_key_value_heads ({num_kv_heads})"
);
let attn_width = num_heads * head_dim;
Ok(Self {
// Doubled output: query and gate, interleaved per head.
q_proj: linear(vb, "q_proj", cfg.hidden_size, attn_width * 2)?,
k_proj: linear(vb, "k_proj", cfg.hidden_size, num_kv_heads * head_dim)?,
v_proj: linear(vb, "v_proj", cfg.hidden_size, num_kv_heads * head_dim)?,
o_proj: linear(vb, "o_proj", attn_width, cfg.hidden_size)?,
q_norm: Qwen3_5RmsNorm::load(&vb.pp("q_norm"), head_dim, cfg.rms_norm_eps)?,
k_norm: Qwen3_5RmsNorm::load(&vb.pp("k_norm"), head_dim, cfg.rms_norm_eps)?,
// The indexer is a submodule of the attention block, not a
// sibling: `self_attn.indexer.index_qk_proj`, per the
// checkpoint's own index.
indexer: Indexer::load(&vb.pp("indexer"), cfg)?,
num_heads,
num_kv_heads,
num_kv_groups: num_heads / num_kv_heads,
head_dim,
attn_width,
rotary,
kv_cache: ConcatKvCache::new(2),
})
}
/// `x` is the hyper-connection's mixed `hidden_size` input, not the
/// four-stream residual. `causal_mask` is the layer's usual mask,
/// used whenever the indexer declines to narrow it. `past_len` is
/// the sequence offset, and must match what the KV cache holds.
pub fn forward(
&mut self,
x: &Tensor,
causal_mask: Option<&Tensor>,
cos: &Tensor,
sin: &Tensor,
past_len: usize,
) -> Result<Tensor> {
let (b, l, _) = x.dims3()?;
// The indexer reads the same hidden states this layer does, and
// advances its own cache whether or not it produces a mask.
let selected = self
.indexer
.binding_mask(x, &self.rotary, past_len, x.dtype())?;
// q_proj is doubled: (query, gate) *within* each head.
let q_raw = self
.q_proj
.forward(x)?
.reshape((b, l, self.num_heads, self.head_dim * 2))?;
let q = q_raw.narrow(3, 0, self.head_dim)?;
let gate = q_raw
.narrow(3, self.head_dim, self.head_dim)?
.contiguous()?
.reshape((b, l, self.attn_width))?;
let q = self
.q_norm
.forward(&q.contiguous()?)?
.transpose(1, 2)?
.contiguous()?;
let k = self
.k_proj
.forward(x)?
.reshape((b, l, self.num_kv_heads, self.head_dim))?;
let k = self
.k_norm
.forward(&k.contiguous()?)?
.transpose(1, 2)?
.contiguous()?;
let v = self
.v_proj
.forward(x)?
.reshape((b, l, self.num_kv_heads, self.head_dim))?
.transpose(1, 2)?
.contiguous()?;
let (q, k) = self.rotary.apply_cos_sin(&q, &k, cos, sin)?;
let (k, v) = self.kv_cache.append(&k, &v)?;
// A QSA mask is additive and arbitrary, so it must not reach the
// flash kernel — the type says so, rather than a comment.
let mask = match &selected {
Some(m) => AttnMask::Additive(m),
None => AttnMask::causal(causal_mask),
};
let scale = 1.0_f64 / (self.head_dim as f64).sqrt();
let ctx = attention_context(&q, &k, &v, mask, self.num_kv_groups, scale)?;
let ctx = ctx
.transpose(1, 2)?
.contiguous()?
.reshape((b, l, self.attn_width))?;
let gated = (ctx * candle_nn::ops::sigmoid(&gate)?)?;
Ok(self.o_proj.forward(&gated)?)
}
/// Capture both caches at one token boundary.
///
/// The same reason `clear_kv_cache` drops them together: a
/// snapshot holding only the attention K/V restores a layer whose
/// indexer still holds the previous request's keys, and the
/// selection it computes from them is wrong without being invalid.
pub fn snapshot_kv(&self) -> (Option<(Tensor, Tensor)>, Option<Tensor>) {
let kv = match (self.kv_cache.k(), self.kv_cache.v()) {
(Some(k), Some(v)) => Some((k.clone(), v.clone())),
_ => None,
};
(kv, self.indexer.snapshot_keys())
}
/// Replace both caches from a snapshot.
pub fn restore_kv(
&mut self,
kv: Option<&(Tensor, Tensor)>,
indexer_keys: Option<&Tensor>,
) -> candle_core::Result<()> {
self.kv_cache.reset();
if let Some((k, v)) = kv {
self.kv_cache.append(k, v)?;
}
self.indexer.restore_keys(indexer_keys);
Ok(())
}
/// Both caches, together. Dropping only one leaves the indexer
/// selecting blocks of a previous request's text.
pub fn clear_kv_cache(&mut self) {
self.kv_cache.reset();
self.indexer.clear_cache();
}
pub fn indexer(&self) -> &Indexer {
&self.indexer
}
}
fn linear(vb: &ShardedVarBuilder, name: &str, in_dim: usize, out_dim: usize) -> Result<Linear> {
let weight = vb
.pp(name)
.get((out_dim, in_dim), "weight")
.with_context(|| format!("load '{}/{name}/weight'", vb.prefix()))?;
Ok(Linear::new(weight, None))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness::arch::qwen3_5::TextConfig as Qwen3_5TextConfig;
use crate::harness::arch::qwen4_exp::qsa::BlockSelector;
use candle_core::{DType, Device};
const HIDDEN: usize = 4;
const HEADS: usize = 2;
const HEAD_DIM: usize = 2;
fn rope() -> Arc<RotaryEmbedding> {
let json = format!(
r#"{{"vocab_size": 8, "hidden_size": {HIDDEN}, "intermediate_size": 8,
"num_hidden_layers": 1, "num_attention_heads": {HEADS},
"num_key_value_heads": 1, "head_dim": {HEAD_DIM},
"max_position_embeddings": 64, "rms_norm_eps": 1e-6,
"rope_parameters": {{"rope_theta": 10000.0,
"partial_rotary_factor": 1.0}}}}"#
);
let cfg: Qwen3_5TextConfig = serde_json::from_str(&json).unwrap();
Arc::new(RotaryEmbedding::new(DType::F32, &cfg, &Device::Cpu).unwrap())
}
/// Columns of a weight matrix, given as rows of `[out]` values for
/// input channel 0 and zeros elsewhere — so `W x` for `x = e0` is
/// exactly the values given.
fn from_first_column(values: &[f32], in_dim: usize) -> Linear {
let out = values.len();
let mut w = vec![0.0f32; out * in_dim];
for (row, v) in values.iter().enumerate() {
w[row * in_dim] = *v;
}
Linear::new(
Tensor::from_vec(w, (out, in_dim), &Device::Cpu).unwrap(),
None,
)
}
fn eye(n: usize) -> Linear {
let mut w = vec![0.0f32; n * n];
for i in 0..n {
w[i * n + i] = 1.0;
}
Linear::new(Tensor::from_vec(w, (n, n), &Device::Cpu).unwrap(), None)
}
/// A layer whose `q_proj` produces `[1,1, 2,2, 3,3, 4,4]` for the
/// unit input, so the per-head and the halved reading of the gate
/// disagree: per head the gate is `[2,2, 4,4]`, halved it would be
/// `[3,3, 4,4]`.
fn layer(budget: usize) -> Attention {
let attn_width = HEADS * HEAD_DIM;
Attention {
q_proj: from_first_column(&[1.0, 1.0, 2.0, 2.0, 3.0, 3.0, 4.0, 4.0], HIDDEN),
k_proj: from_first_column(&[0.0; HEAD_DIM], HIDDEN),
v_proj: from_first_column(&[1.0; HEAD_DIM], HIDDEN),
o_proj: eye(attn_width),
q_norm: Qwen3_5RmsNorm::from_weight(
Tensor::zeros(HEAD_DIM, DType::F32, &Device::Cpu).unwrap(),
1e-6,
None,
),
k_norm: Qwen3_5RmsNorm::from_weight(
Tensor::zeros(HEAD_DIM, DType::F32, &Device::Cpu).unwrap(),
1e-6,
None,
),
indexer: Indexer::from_parts(
from_first_column(&[0.5; 2 * HEAD_DIM], HIDDEN),
BlockSelector::new(1, HEAD_DIM, 4, budget).unwrap(),
1,
1,
HEAD_DIM,
),
num_heads: HEADS,
num_kv_heads: 1,
num_kv_groups: HEADS,
head_dim: HEAD_DIM,
attn_width,
rotary: rope(),
kv_cache: ConcatKvCache::new(2),
}
}
fn unit_input(seq: usize) -> Tensor {
let mut v = vec![0.0f32; seq * HIDDEN];
for i in 0..seq {
v[i * HIDDEN] = 1.0;
}
Tensor::from_vec(v, (1, seq, HIDDEN), &Device::Cpu).unwrap()
}
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
/// The gate is the second half of each head, not the second half of
/// the vector. With one position the attention is the identity on
/// `v`, so the output is the gate alone — and the halved reading
/// gives sigmoid(3) where the correct one gives sigmoid(2).
#[test]
fn the_output_gate_splits_within_each_head() {
let mut attn = layer(2048);
let (cos, sin) = attn.rotary.plain_cos_sin(0, 1).unwrap();
let out: Vec<f32> = attn
.forward(&unit_input(1), None, &cos, &sin, 0)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
let want = [sigmoid(2.0), sigmoid(2.0), sigmoid(4.0), sigmoid(4.0)];
for (g, w) in out.iter().zip(want.iter()) {
assert!((g - w).abs() < 1e-5, "got {out:?} want {want:?}");
}
// The halved reading would put sigmoid(3) in the first two.
assert!(
(out[0] - sigmoid(3.0)).abs() > 1e-3,
"this is the halved split, not the per-head one: {out:?}"
);
}
/// The indexer's cache and the main KV cache must advance together
/// across a prefill and a decode step, and be dropped together — a
/// surviving indexer cache selects blocks of the previous request.
#[test]
fn both_caches_advance_and_clear_together() {
let mut attn = layer(2048);
let (cos, sin) = attn.rotary.plain_cos_sin(0, 5).unwrap();
attn.forward(&unit_input(5), None, &cos, &sin, 0).unwrap();
assert_eq!(attn.indexer().cached_len(), 5);
let (cos, sin) = attn.rotary.plain_cos_sin(5, 1).unwrap();
attn.forward(&unit_input(1), None, &cos, &sin, 5).unwrap();
assert_eq!(attn.indexer().cached_len(), 6);
// Six positions is one block of four plus a tail — nowhere near
// a 2048-token budget, so the layer stayed on the causal path.
assert!(attn.indexer().selector().is_dense(6));
attn.clear_kv_cache();
assert_eq!(attn.indexer().cached_len(), 0);
}
/// A layer whose budget binds still produces a well-formed output —
/// the sparse path is exercised end to end, not just the mask.
#[test]
fn the_sparse_path_runs() {
let mut attn = layer(4);
let (cos, sin) = attn.rotary.plain_cos_sin(0, 12).unwrap();
let out = attn.forward(&unit_input(12), None, &cos, &sin, 0).unwrap();
assert_eq!(out.dims(), &[1, 12, HIDDEN]);
assert!(!attn.indexer().selector().is_dense(12));
let values: Vec<f32> = out.flatten_all().unwrap().to_vec1().unwrap();
assert!(
values.iter().all(|v| v.is_finite()),
"a masked-out row would softmax to NaN: {values:?}"
);
}
}
@@ -94,6 +94,43 @@ impl HyperConnection {
})
}
/// A hyper-connection whose three projections are all zero, for
/// tests in this module and its siblings.
///
/// That makes the whole gate analytic: `down(hn) = 0` so
/// `silu(0) = 0`, `up(0) = 0` so `sigmoid(0) = 0.5`, and
/// `block_inject(hn) = 0` so `2 * sigmoid(0) = 1`. The mix weight is
/// exactly 0.5 everywhere and every stream is injected with weight
/// 1, so any error in the `/hc_count` divisions, the sigmoid
/// constants, or mean-versus-sum shows up as a wrong number rather
/// than a wrong shape.
#[cfg(test)]
pub(crate) fn zeroed_for_test(
hidden_size: usize,
hc_count: usize,
hc_lowrank: usize,
use_combine: bool,
) -> Self {
use candle_core::{DType, Device};
let dev = Device::Cpu;
let wide = hidden_size * hc_count;
let z = |o: usize, i: usize| {
Linear::new(Tensor::zeros((o, i), DType::F32, &dev).unwrap(), None)
};
Self {
hc_norm: crate::harness::arch::qwen3_5::rmsnorm::Qwen3_5RmsNorm::from_weight(
Tensor::zeros(wide, DType::F32, &dev).unwrap(),
1e-6,
Some(hidden_size),
),
mix_down: z(hc_lowrank, wide),
mix_up: z(wide, hc_lowrank),
block_inject: use_combine.then(|| z(hc_count, wide)),
hidden_size,
hc_count,
}
}
/// Split the stream tensor into a sublayer input plus what is needed
/// to rejoin.
pub fn split(&self, h: &Tensor) -> candle_core::Result<HyperSplit> {
@@ -171,7 +208,7 @@ fn linear(vb: &ShardedVarBuilder, name: &str, in_dim: usize, out_dim: usize) ->
#[cfg(test)]
mod tests {
use super::*;
use candle_core::{DType, Device};
use candle_core::Device;
/// Build a hyper-connection whose three projections are all zero.
/// That makes the whole gate analytic:
@@ -181,23 +218,7 @@ mod tests {
/// injected with weight 1. Any error in the /hc_count divisions, the
/// sigmoid constants, or mean-vs-sum shows up as a wrong number.
fn zeroed(hidden: usize, hc: usize, lowrank: usize) -> HyperConnection {
let dev = Device::Cpu;
let wide = hidden * hc;
let z = |o: usize, i: usize| {
Linear::new(Tensor::zeros((o, i), DType::F32, &dev).unwrap(), None)
};
HyperConnection {
hc_norm: Qwen3_5RmsNorm::from_weight(
Tensor::zeros(wide, DType::F32, &dev).unwrap(),
1e-6,
Some(hidden),
),
mix_down: z(lowrank, wide),
mix_up: z(wide, lowrank),
block_inject: Some(z(hc, wide)),
hidden_size: hidden,
hc_count: hc,
}
HyperConnection::zeroed_for_test(hidden, hc, lowrank, true)
}
#[test]
@@ -0,0 +1,115 @@
//! `qwen4_exp`'s linear-attention layer — 36 of 48.
//!
//! This is `qwen3_5`'s GatedDeltaNet with nothing changed. The tensor
//! names, the split (non-fused) `in_proj_*` layout, the delta rule, the
//! conv and the state shapes are all identical, which is why the
//! largest single piece of the Qwen3.6 port carries over untouched and
//! this module is a page rather than a file.
//!
//! What does not carry over is two settings, both of which our
//! defaults would get wrong in the same quiet way:
//!
//! 1. **`output_gate_type: sigmoid`.** Qwen3.6 leaves the field unset
//! and the gated output norm falls back to SiLU. Inheriting that
//! here applies the wrong nonlinearity on every one of these 36
//! layers — fluent output, worse model.
//! 2. **`mamba_ssm_dtype: float32`.** We carry the recurrent state
//! through a bf16 round-trip by default, deliberately (#284: the
//! flag is off because the #283 measurements were taken against
//! that baseline). This checkpoint states f32, and a port must not
//! silently answer an upstream choice with our own. So the
//! checkpoint wins here, and `NEURON_GDN_STATE_F32` remains the
//! default only where the config is silent.
//!
//! See `doc/qwen4_exp-port-spec.md` §5.
use anyhow::Result;
use candle_nn::var_builder::ShardedVarBuilder;
use crate::harness::arch::qwen3_5::linear_attn::{
GatedDeltaNet, GatedDeltaNetParams, gdn_state_f32,
};
use super::config::TextConfig;
/// `vb` should be `.pp(...)`-ed to the layer's `linear_attn` prefix.
pub fn load(cfg: &TextConfig, vb: &ShardedVarBuilder) -> Result<GatedDeltaNet> {
GatedDeltaNet::load_with_params(params(cfg)?, vb)
}
/// The settings this architecture asks for, as opposed to the ones
/// `qwen3_5` would supply.
pub(crate) fn params(cfg: &TextConfig) -> Result<GatedDeltaNetParams> {
Ok(GatedDeltaNetParams {
hidden_size: cfg.hidden_size,
num_value_heads: cfg.linear_num_value_heads,
num_key_heads: cfg.linear_num_key_heads,
key_head_dim: cfg.linear_key_head_dim,
value_head_dim: cfg.linear_value_head_dim,
conv_kernel_size: cfg.linear_conv_kernel_dim,
rms_norm_eps: cfg.rms_norm_eps,
output_gate: cfg.output_gate()?,
// The checkpoint's choice where it makes one; ours only where
// it is silent.
state_f32: cfg.ssm_state_is_f32().unwrap_or_else(gdn_state_f32),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::harness::arch::qwen3_5::rmsnorm::OutputGate;
use crate::harness::arch::qwen4_exp::config::{Config, SHIPPED};
fn shipped() -> Config {
Config::from_config_json(SHIPPED).unwrap()
}
/// The layout `qwen3_5` already loads: 16 key heads and 48 value
/// heads of 128, a conv of 4. If these ever diverge the reuse claim
/// is void, so they are asserted rather than assumed.
#[test]
fn the_geometry_is_the_one_qwen3_5_already_loads() {
let p = params(&shipped().text_config).unwrap();
assert_eq!(p.hidden_size, 2560);
assert_eq!(p.num_key_heads, 16);
assert_eq!(p.num_value_heads, 48);
assert_eq!(p.key_head_dim, 128);
assert_eq!(p.value_head_dim, 128);
assert_eq!(p.conv_kernel_size, 4);
// conv_dim = 2 * 16 * 128 + 48 * 128 = 10240, the same width as
// the four-stream residual, which is a coincidence worth not
// reading anything into.
assert_eq!(
2 * p.num_key_heads * p.key_head_dim + p.num_value_heads * p.value_head_dim,
10240
);
}
/// The two deltas, together — this is the whole reason the module
/// exists.
#[test]
fn the_checkpoint_overrides_both_of_our_defaults() {
let p = params(&shipped().text_config).unwrap();
assert_eq!(
p.output_gate,
OutputGate::Sigmoid,
"inheriting hidden_act would give SiLU on all 36 layers"
);
assert!(
p.state_f32,
"mamba_ssm_dtype is float32; our #284 default is a bf16 round-trip"
);
}
/// Where the checkpoint says nothing, our default stands rather
/// than a guess — the fallback is the process-wide #284 flag, not a
/// hardcoded answer.
#[test]
fn a_silent_checkpoint_leaves_our_default_in_place() {
let json = SHIPPED.replace("\"mamba_ssm_dtype\": \"float32\",", "");
let cfg = Config::from_config_json(&json).unwrap();
assert_eq!(cfg.text_config.ssm_state_is_f32(), None);
assert_eq!(params(&cfg.text_config).unwrap().state_f32, gdn_state_f32());
}
}
@@ -14,6 +14,33 @@
//!
//! Built in dependency order, so each piece can be parity-tested before
//! anything stacks on it:
//! - [`config`] — the checkpoint's own `config.json`, and what its
//! fields mean where they are not what they look like.
//! - [`hyper`] — hyper-connections, the residual structure itself.
//! - [`decoder`] — the composition: four residual streams, PLE on
//! layer 1, and no layernorm outside the hyper-connections.
//! - [`full_attn`] — one layer in four, plus the indexer that
//! narrows what it may attend.
//! - [`linear_attn`] — the other three layers in four, reused whole
//! from `qwen3_5` with two settings corrected.
//! - [`model`] — embeddings in, logits out; four streams, one
//! n-gram lookup, and no final norm.
//! - [`mtp`] — the shipped draft head: one more decoder layer, fed
//! the target's pre-mixer stream and the next token's embedding.
//! - [`moe`] — the FFN every layer has, loaded from fused expert
//! tensors instead of 512 modules.
//! - [`ple`] — the hashed n-gram table on layer 1: how its rows
//! are addressed, and how the gathered rows are consumed.
//! - [`qsa`] — which blocks of the past a full-attention layer is
//! allowed to look at.
pub mod config;
pub mod decoder;
pub mod full_attn;
pub mod hyper;
pub mod linear_attn;
pub mod model;
pub mod moe;
pub mod mtp;
pub mod ple;
pub mod qsa;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
//! `qwen4_exp`'s MoE FFN — every layer's FFN, since there is no dense
//! one.
//!
//! The routing is [`Qwen3_5MoeBlock`]'s, unchanged: softmax over all
//! 512 in f32, top 10, renormalise, add a sigmoid-gated shared expert.
//! What differs is **storage**. This checkpoint ships the routed
//! experts as two fused 3D tensors rather than 512 modules:
//!
//! ```text
//! mlp.experts.gate_up_proj (512, 1280, 2560) gate ++ up, stacked
//! mlp.experts.down_proj (512, 2560, 640)
//! ```
//!
//! against our loader's `mlp.experts.{i}.{gate,up,down}_proj`. So this
//! module is a loader, not an algorithm: it slices the fused tensors
//! into the per-expert SwiGLUs the block already knows how to route.
//!
//! **In `gate_up_proj` the first `moe_intermediate_size` rows are the
//! gate and the last are the up projection.** Reading them the other
//! way round computes `silu(up) * gate` instead of `silu(gate) * up` —
//! a different function of the same weights, which is exactly the kind
//! of error that produces a model that talks and reasons slightly
//! worse. It is asserted below rather than commented.
//!
//! ## Why the fused tensors stay fused
//!
//! Slicing them into 512 per-expert modules copies **241.6 GB** at
//! BF16, 5.03 GB per layer. That fits in neither beast's 64 GB of VRAM
//! nor its 123 GB of host RAM, and no quantisation rescues it: #309
//! measured 59.4 GB of 63.7 used at 4.25 bpw with only *four* layers'
//! experts offloaded. A model that cannot be held cannot be loaded.
//!
//! So the block takes `Experts::Banked` and views one expert per
//! routed token instead. A slice along dim 0 of a contiguous tensor is
//! contiguous, and `x.matmul(w.t())` is what `Linear` does anyway, so
//! the views feed the same GEMMs with no copy. It is also the layout a
//! host-resident bank with a device-side gather wants (#318).
//!
//! See `doc/qwen4_exp-port-spec.md` §6.
use anyhow::{Context, Result};
use candle_core::Tensor;
use candle_core::quantized::GgmlDType;
use candle_nn::Linear;
use candle_nn::var_builder::ShardedVarBuilder;
use crate::harness::arch::qwen3_5::mlp::Qwen3_5MLP;
use crate::harness::arch::qwen3_5::moe::{Experts, Qwen3_5MoeBlock};
use super::config::TextConfig;
/// `vb` should be `.pp(...)`-ed to the layer's `mlp` prefix.
///
/// `quant` quantises the routed experts in situ (#315). Without it the
/// experts stay banked at the checkpoint's precision, which is only
/// useful for a model small enough to hold — 120.8 B routed parameters
/// are 241.6 GB at BF16 and fit nowhere on this hardware, so a real
/// load of `Qwen3.8-Flash-Next` always passes one.
pub fn load(
cfg: &TextConfig,
quant: Option<GgmlDType>,
vb: &ShardedVarBuilder,
) -> Result<Qwen3_5MoeBlock> {
let (h, inter) = (cfg.hidden_size, cfg.moe_intermediate_size);
anyhow::ensure!(
cfg.num_experts > 0 && cfg.num_experts_per_tok > 0 && inter > 0,
"MoE needs num_experts ({}), num_experts_per_tok ({}) and \
moe_intermediate_size ({inter}) all > 0",
cfg.num_experts,
cfg.num_experts_per_tok,
);
let gate = Linear::new(
vb.pp("gate")
.get((cfg.num_experts, h), "weight")
.with_context(|| format!("load '{}/gate/weight'", vb.prefix()))?,
None,
);
let experts_vb = vb.pp("experts");
let gate_up = experts_vb
.get((cfg.num_experts, inter * 2, h), "gate_up_proj")
.with_context(|| format!("load '{}/gate_up_proj'", experts_vb.prefix()))?;
let down = experts_vb
.get((cfg.num_experts, h, inter), "down_proj")
.with_context(|| format!("load '{}/down_proj'", experts_vb.prefix()))?;
check_fused_experts(&gate_up, &down, inter)?;
// The fused pair is the peak: 5.03 GB per layer at BF16. Quantising
// here and letting it drop at the end of this function is what keeps
// the whole 241.6 GB from ever existing.
let experts = match quant {
Some(dtype) => Experts::quantize_banked(&gate_up, &down, inter, dtype)
.with_context(|| format!("quantize experts to {dtype:?}"))?,
None => Experts::Banked {
gate_up,
down,
intermediate: inter,
},
};
let (shared_expert, shared_expert_gate) = if cfg.shared_expert_intermediate_size > 0 {
let shared = Qwen3_5MLP::load_with_dims(
&vb.pp("shared_expert"),
h,
cfg.shared_expert_intermediate_size,
)
.context("load shared_expert")?;
let gate_w = vb
.pp("shared_expert_gate")
.get((1, h), "weight")
.with_context(|| format!("load '{}/shared_expert_gate/weight'", vb.prefix()))?;
(Some(shared), Some(Linear::new(gate_w, None)))
} else {
(None, None)
};
Ok(Qwen3_5MoeBlock::from_parts(
gate,
experts,
shared_expert,
shared_expert_gate,
cfg.num_experts_per_tok,
cfg.norm_topk_prob,
))
}
/// Validate the fused pair before it is banked.
///
/// The block will take views of these per routed expert, so a
/// disagreement here surfaces as a shape error mid-forward on whichever
/// token first routes to the offending expert — which is a worse place
/// to find it than at load.
pub(crate) fn check_fused_experts(
gate_up: &Tensor,
down: &Tensor,
intermediate: usize,
) -> Result<()> {
let (experts, fused, _) = gate_up.dims3()?;
anyhow::ensure!(
fused == intermediate * 2,
"gate_up_proj is {fused} wide, expected 2 x {intermediate}"
);
anyhow::ensure!(
down.dims3()?.0 == experts,
"down_proj holds {} experts, gate_up_proj holds {experts}",
down.dims3()?.0
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::{DType, Device};
/// The loader validates the fused pair up front. Without this the
/// first token to route to a bad expert fails mid-forward, which is
/// a worse place to learn the checkpoint disagrees with the config.
#[test]
fn a_fused_pair_that_disagrees_with_the_config_is_rejected() {
let dev = Device::Cpu;
let gate_up = Tensor::zeros((2, 6, 4), DType::F32, &dev).unwrap();
let down = Tensor::zeros((2, 4, 3), DType::F32, &dev).unwrap();
assert!(check_fused_experts(&gate_up, &down, 3).is_ok());
// 2 x 4 != 6
assert!(check_fused_experts(&gate_up, &down, 4).is_err());
// expert counts must agree
let down_short = Tensor::zeros((1, 4, 3), DType::F32, &dev).unwrap();
assert!(check_fused_experts(&gate_up, &down_short, 3).is_err());
}
}
@@ -0,0 +1,263 @@
//! The multi-token-prediction head — `qwen4_exp`'s shipped draft model.
//!
//! The checkpoint carries a complete second decoder layer under `mtp.*`:
//! full attention with its own QSA indexer, its own 512-expert MoE, both
//! hyper-connections, and a final mixer. It reuses `embed_tokens` and
//! `lm_head` (`mtp_use_dedicated_embeddings: false`), so what is new
//! here is only how the target model's state and the next token's
//! embedding are fused into an input for it.
//!
//! ```text
//! e = fc_embedding(pre_fc_norm_embedding(embed(t))) [T, 2560]
//! h = pre_fc_norm_hidden(h.flatten(-2)) ONE norm over 10240
//! h = fc_hidden(h.view(T, hc, 2560)) per stream, shared matrix
//! h = e.unsqueeze(-2) + h embedding into every stream
//! h = h.flatten(-2) [T, 10240] -> the layer
//! ```
//!
//! vLLM calls this `residual_linear_shared`, as against the
//! `Linear(2H, H)` + repeat other MTP variants use. Three parts of it
//! cannot be recovered from the tensor shapes, and all three were read
//! from `vllm/models/qwen4_exp/nvidia/mtp.py` rather than inferred:
//!
//! 1. **`pre_fc_norm_hidden` is ungrouped** — one normalisation over the
//! whole 10240, unlike every other 10240-wide norm in this
//! architecture. vLLM has a `GroupedGemmaRMSNorm` and deliberately
//! does not use it here. Grouping it rescales the draft input in four
//! independent pieces, which costs acceptance rate and reports
//! nothing.
//! 2. **The hidden state is the *pre-final-mixer* multi-stream** — `h`
//! after the target's last decoder layer, before its mixer collapses
//! it. Not the 2560 the LM head sees.
//! 3. **The head emits both** — a collapsed stream for `lm_head` and the
//! pre-final-mixer stream for its own next step. A head that returned
//! only the first cannot be chained past one token.
//!
//! PLE is off in this layer while the stream count is kept, which the
//! absence of `mtp.layers.0.ple.*` in the checkpoint agrees with.
//!
//! **The head keeps its own KV and indexer caches, and they are not the
//! target's.** It therefore has to walk the prompt alongside the target
//! before it can draft: a head fed only the newest token is attending
//! over an empty past, and this architecture says so rather than
//! guessing, because the indexer's divergence check fires. That makes
//! prefill part of the speculative loop's cost, not just decode.
//!
//! See `doc/qwen4_exp-port-spec.md` §9 and #313.
use anyhow::{Context, Result};
use candle_core::quantized::GgmlDType;
use candle_core::{D, Module, Tensor};
use candle_nn::Linear;
use candle_nn::var_builder::ShardedVarBuilder;
use std::sync::Arc;
use crate::harness::arch::qwen3_5::rmsnorm::Qwen3_5RmsNorm;
use crate::harness::arch::qwen3_5::rope::RotaryEmbedding;
use super::config::TextConfig;
use super::decoder::DecoderLayer;
use super::hyper::HyperConnection;
/// One draft step's two outputs.
#[derive(Debug)]
pub struct MtpStep {
/// `[B, L, hidden]` — the mixer's collapse, for `lm_head`.
pub sample_hidden: Tensor,
/// `[B, L, hidden * hc_count]` — pre-final-mixer, and the input to
/// the *next* draft step. Chaining reads this, not the collapse.
pub multi_stream: Tensor,
}
pub struct MtpHead {
pre_fc_norm_embedding: Qwen3_5RmsNorm,
/// Ungrouped over `hidden * hc_count` — see the module note.
pre_fc_norm_hidden: Qwen3_5RmsNorm,
fc_embedding: Linear,
/// Shared across the streams: one `hidden -> hidden` matrix applied
/// to each branch, not a `wide -> wide` one.
fc_hidden: Linear,
layer: DecoderLayer,
mixer: HyperConnection,
hidden_size: usize,
hc_count: usize,
}
impl MtpHead {
/// `vb` is the checkpoint root; the head hangs off `mtp.`.
pub fn load(
cfg: &TextConfig,
rotary: Arc<RotaryEmbedding>,
quant: Option<GgmlDType>,
vb: &ShardedVarBuilder,
) -> Result<Self> {
let mtp = vb.pp("mtp");
let (h, hc) = (cfg.hidden_size, cfg.hc_count);
Ok(Self {
pre_fc_norm_embedding: Qwen3_5RmsNorm::load(
&mtp.pp("pre_fc_norm_embedding"),
h,
cfg.rms_norm_eps,
)?,
// `load`, not `load_grouped`: the one 10240-wide norm in
// this architecture that is not per-stream.
pre_fc_norm_hidden: Qwen3_5RmsNorm::load(
&mtp.pp("pre_fc_norm_hidden"),
h * hc,
cfg.rms_norm_eps,
)?,
fc_embedding: linear(&mtp, "fc_embedding", h, h)?,
fc_hidden: linear(&mtp, "fc_hidden", h, h)?,
layer: DecoderLayer::load_typed(
cfg,
rotary,
"full_attention",
false,
quant,
&mtp.pp("layers").pp(0),
)
.context("load mtp.layers.0")?,
mixer: HyperConnection::load(
&mtp.pp("hyper_connection_mixer"),
h,
hc,
cfg.hc_lowrank,
cfg.rms_norm_eps,
false,
)
.context("load mtp.hyper_connection_mixer")?,
hidden_size: h,
hc_count: hc,
})
}
/// One draft step.
///
/// `token_embed` is `embed_tokens(t)` for the token being predicted
/// *from*, `[B, L, hidden]`. `hidden` is the pre-final-mixer stream,
/// `[B, L, hidden * hc_count]` — from the target model on the first
/// step, from this head's own previous [`MtpStep::multi_stream`]
/// afterwards.
pub fn forward(
&mut self,
token_embed: &Tensor,
hidden: &Tensor,
causal_mask: Option<&Tensor>,
cos: &Tensor,
sin: &Tensor,
past_len: usize,
) -> Result<MtpStep> {
let (b, l, wide) = hidden.dims3()?;
anyhow::ensure!(
wide == self.hidden_size * self.hc_count,
"mtp: expected the pre-final-mixer stream ({} wide), got {wide} — \
passing the collapsed hidden state here is the likely mistake",
self.hidden_size * self.hc_count
);
let e = self
.fc_embedding
.forward(&self.pre_fc_norm_embedding.forward(token_embed)?)?;
// Normalise the flattened stream, *then* split it: one
// normalisation over the whole width.
let h = self.pre_fc_norm_hidden.forward(hidden)?.reshape((
b,
l,
self.hc_count,
self.hidden_size,
))?;
let h = self.fc_hidden.forward(&h)?;
// The embedding enters every stream.
let h = h
.broadcast_add(&e.unsqueeze(D::Minus2)?)?
.flatten_from(D::Minus2)?;
let h = self
.layer
.forward(&h, None, causal_mask, cos, sin, past_len)
.context("mtp draft layer")?;
Ok(MtpStep {
sample_hidden: self.mixer.collapse(&h)?,
multi_stream: h,
})
}
/// Drop the draft layer's caches. The head has its own KV and its
/// own indexer cache, independent of the target model's.
pub fn clear_kv_cache(&mut self) -> Result<()> {
self.layer.clear_kv_cache()
}
}
fn linear(vb: &ShardedVarBuilder, name: &str, in_dim: usize, out_dim: usize) -> Result<Linear> {
let weight = vb
.pp(name)
.get((out_dim, in_dim), "weight")
.with_context(|| format!("load '{}/{name}/weight'", vb.prefix()))?;
Ok(Linear::new(weight, None))
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::{DType, Device};
/// `pre_fc_norm_hidden` normalises the whole 10240 at once, where
/// every other norm this wide in the architecture normalises four
/// streams of 2560 independently. The port spec asserted the
/// opposite until vLLM settled it, and the difference is invisible
/// downstream — it rescales the draft input and costs acceptance
/// rate.
///
/// So it is pinned by value, on an input the two readings cannot
/// agree on: two streams whose magnitudes differ threefold. Grouped,
/// each is normalised to its own scale and both come out all-ones.
/// Ungrouped, the larger stream stays larger.
#[test]
fn the_hidden_pre_norm_is_ungrouped() {
let dev = Device::Cpu;
let (hidden, hc) = (4usize, 2usize);
let wide = hidden * hc;
// Zero weight: (1 + w) = 1, so this is a pure RMS normalisation.
let norm =
Qwen3_5RmsNorm::from_weight(Tensor::zeros(wide, DType::F32, &dev).unwrap(), 1e-6, None);
let h = Tensor::from_vec(
vec![1.0f32, 1.0, 1.0, 1.0, 3.0, 3.0, 3.0, 3.0],
(1, 1, wide),
&dev,
)
.unwrap();
let got: Vec<f32> = norm
.forward(&h)
.unwrap()
.flatten_all()
.unwrap()
.to_vec1()
.unwrap();
// rms over all eight = sqrt((4*1 + 4*9)/8) = sqrt(5)
let rms = 5.0f32.sqrt();
let want = [
1.0 / rms,
1.0 / rms,
1.0 / rms,
1.0 / rms,
3.0 / rms,
3.0 / rms,
3.0 / rms,
3.0 / rms,
];
for (g, w) in got.iter().zip(want.iter()) {
assert!((g - w).abs() < 1e-5, "got {got:?} want {want:?}");
}
// The grouped reading would flatten both streams to all-ones.
assert!(
(got[4] - 1.0).abs() > 1e-3,
"this is the grouped reading, which the spec had and vLLM does not: {got:?}"
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,12 @@
//! Cache-state snapshots for prefix KV caching (#11).
//!
//! Shared across architectures rather than owned by one: `qwen3_5`,
//! its TP mirror `tp_qwen3_5`, and `qwen4_exp` all capture state
//! through these types, and `ModelArch::snapshot_kv_cache` hands one
//! back whatever the architecture. It lived under `arch/qwen3_5/`
//! while that was the only arch with a snapshot, which stopped being
//! true.
//!
//! A snapshot captures everything `clear_kv_cache` would destroy, at
//! one consistent token boundary:
//!
@@ -40,6 +47,17 @@ pub enum LayerKvSnapshot {
conv_state: Option<Tensor>,
recurrent_state: Option<Tensor>,
},
/// A full-attention layer that also keeps a QSA indexer cache
/// (`qwen4_exp`). The indexer's raw keys are a second, separately
/// shaped cache — one head of 128 dims per position — and a
/// snapshot that captured only `kv` would restore a layer whose
/// indexer still holds the *previous* request's keys, selecting
/// blocks of somebody else's text. It fails fluently, so the two
/// travel together or not at all.
FullSparse {
kv: Option<(Tensor, Tensor)>,
indexer_keys: Option<Tensor>,
},
}
/// One consistent cache snapshot of a `Qwen3_5Model` (or its TP
@@ -50,6 +68,9 @@ pub enum LayerKvSnapshot {
pub struct KvCacheSnapshot {
pub(crate) layers: Vec<LayerKvSnapshot>,
pub(crate) rope_delta: i64,
/// `qwen4_exp`'s PLE state. `None` for architectures without a
/// hashed n-gram layer, which is every other one today.
pub(crate) ple: Option<PleSnapshot>,
}
impl KvCacheSnapshot {
@@ -79,8 +100,44 @@ impl KvCacheSnapshot {
conv_state.as_ref().map(t_bytes).unwrap_or(0)
+ recurrent_state.as_ref().map(t_bytes).unwrap_or(0)
}
LayerKvSnapshot::FullSparse { kv, indexer_keys } => {
kv.as_ref()
.map(|(k, v)| t_bytes(k) + t_bytes(v))
.unwrap_or(0)
+ indexer_keys.as_ref().map(t_bytes).unwrap_or(0)
}
})
.sum()
.sum::<u64>()
+ self.ple.as_ref().map(PleSnapshot::size_bytes).unwrap_or(0)
}
}
/// PLE's per-request state (`qwen4_exp`), captured at the same token
/// boundary as the layers.
///
/// Two pieces with different natures. `conv_state` is the dilated
/// short conv's left context — nine positions of a 10240-wide signal,
/// deep-copied like the GatedDeltaNet states because the conv writes
/// its rolling buffer in place. `carried_ids` is the previous
/// `ngram_size - 1` **token ids** per batch row, which upstream keeps
/// as a third "conv state" on the same layer; they are ids, not
/// activations, and cost two integers per sequence.
pub struct PleSnapshot {
pub(crate) conv_state: Option<Tensor>,
pub(crate) carried_ids: Vec<Vec<i64>>,
}
impl PleSnapshot {
fn size_bytes(&self) -> u64 {
self.conv_state
.as_ref()
.map(|t| (t.elem_count() * t.dtype().size_in_bytes()) as u64)
.unwrap_or(0)
+ self
.carried_ids
.iter()
.map(|r| (r.len() * std::mem::size_of::<i64>()) as u64)
.sum::<u64>()
}
}
@@ -145,6 +202,7 @@ pub fn assemble_batch(seqs: &[(&KvCacheSnapshot, usize)]) -> candle_core::Result
}
Ok(BatchedKvState {
snapshot: KvCacheSnapshot {
ple: None,
layers,
rope_delta: 0,
},
@@ -159,6 +217,14 @@ fn assemble_layer(
padded_len: usize,
) -> candle_core::Result<LayerKvSnapshot> {
match &seqs[0].0.layers[li] {
// Batched decode for the sparse-attention layers is not built
// yet. Assembling them as if they were plain full-attention
// would drop the indexer cache and hand every row a selection
// computed from another request's keys, which produces text.
LayerKvSnapshot::FullSparse { .. } => candle_core::bail!(
"assemble_batch: layer {li} carries a QSA indexer cache; batched decode \
for this architecture is not implemented"
),
LayerKvSnapshot::Full(_) => {
let mut ks = Vec::with_capacity(seqs.len());
let mut vs = Vec::with_capacity(seqs.len());
@@ -242,6 +308,10 @@ pub fn extract_row(
let mut layers = Vec::with_capacity(snap.layers.len());
for (li, layer) in snap.layers.iter().enumerate() {
layers.push(match layer {
LayerKvSnapshot::FullSparse { .. } => candle_core::bail!(
"extract_row: layer {li} carries a QSA indexer cache; batched decode \
for this architecture is not implemented"
),
LayerKvSnapshot::Full(Some((k, v))) => {
let (b, _h, s, _d) = k.dims4()?;
if row >= b {
@@ -274,6 +344,7 @@ pub fn extract_row(
});
}
Ok(KvCacheSnapshot {
ple: None,
layers,
rope_delta: 0,
})
@@ -312,7 +383,7 @@ fn pad_seq(t: &Tensor, padded_len: usize) -> candle_core::Result<Tensor> {
#[cfg(test)]
mod tests {
use super::super::{Qwen3_5Model, RopeParameters, TextConfig};
use crate::harness::arch::qwen3_5::{Qwen3_5Model, RopeParameters, TextConfig};
use candle_core::{DType, Device, Tensor};
use std::collections::HashMap;
+175 -27
View File
@@ -555,7 +555,7 @@ impl LoadedHandle {
#[derive(Clone)]
pub enum KvSnapshotRef {
Worker(super::device_worker::KvSnapshotId),
Local(Arc<super::arch::qwen3_5::snapshot::KvCacheSnapshot>),
Local(Arc<super::arch::snapshot::KvCacheSnapshot>),
#[cfg(feature = "cuda")]
Tp(u64),
}
@@ -1050,6 +1050,16 @@ pub enum ModelArch {
// are real; forward bails "not implemented yet". See
// `arch/qwen3_5.rs` for the open architecture work.
Qwen3_5Dense(super::arch::qwen3_5::Qwen3_5ForCausalLM),
// Qwen3.8-Flash-Next (model_type "qwen4_exp") — a new architecture
// generation, not a Qwen3 variant: four residual streams joined by
// hyper-connections, QSA sparse attention, and a hashed n-gram
// table on one layer. Boxed for the same reason as LlamaDense.
//
// No snapshot or batched-decode support yet, so it falls through to
// the "no batched-decode support" arms and serves one request at a
// time. See `arch/qwen4_exp/` and the epic on #307.
Qwen4Exp(Box<super::arch::qwen4_exp::model::Qwen4ExpForCausalLM>),
}
impl ModelArch {
@@ -1066,6 +1076,7 @@ impl ModelArch {
ModelArch::LlamaQuantized(m) => m.forward(input, offset)?,
ModelArch::LlamaDense(m) => m.forward(input, offset)?,
ModelArch::Qwen3_5Dense(m) => m.forward(input, offset)?,
ModelArch::Qwen4Exp(m) => m.forward(input, offset)?,
};
squeeze_to_vocab(&raw)
}
@@ -1093,6 +1104,10 @@ impl ModelArch {
m.clear_kv_cache();
Ok(())
}
// Clears the KV caches, the QSA indexer's separate cache and
// PLE's carried ids together — a survivor of any one of them
// reads the previous request's text.
ModelArch::Qwen4Exp(m) => m.clear_kv_cache(),
}
}
@@ -1101,15 +1116,16 @@ impl ModelArch {
/// candle-transformers archs keep theirs private, so they stay on
/// the clear-every-request path.
pub fn supports_kv_snapshot(&self) -> bool {
matches!(self, ModelArch::Qwen3_5Dense(_))
matches!(self, ModelArch::Qwen3_5Dense(_) | ModelArch::Qwen4Exp(_))
}
/// Capture the live cache state as a prefix snapshot. See
/// `arch/qwen3_5/snapshot.rs` for what a snapshot contains and the
/// copy-semantics constraints.
pub fn snapshot_kv_cache(&self) -> Result<super::arch::qwen3_5::snapshot::KvCacheSnapshot> {
pub fn snapshot_kv_cache(&self) -> Result<super::arch::snapshot::KvCacheSnapshot> {
match self {
ModelArch::Qwen3_5Dense(m) => Ok(m.snapshot_kv_cache()?),
ModelArch::Qwen4Exp(m) => Ok(m.snapshot_kv_cache()?),
_ => anyhow::bail!("snapshot_kv_cache: architecture has no snapshot support"),
}
}
@@ -1118,10 +1134,11 @@ impl ModelArch {
/// restore-instead-of-clear half of prefix caching.
pub fn restore_kv_cache(
&mut self,
snap: &super::arch::qwen3_5::snapshot::KvCacheSnapshot,
snap: &super::arch::snapshot::KvCacheSnapshot,
) -> Result<()> {
match self {
ModelArch::Qwen3_5Dense(m) => Ok(m.restore_kv_cache(snap)?),
ModelArch::Qwen4Exp(m) => Ok(m.restore_kv_cache(snap)?),
_ => anyhow::bail!("restore_kv_cache: architecture has no snapshot support"),
}
}
@@ -1332,8 +1349,14 @@ impl LlamaDense {
/// value. New entries land alongside a new `ModelArch` variant + a
/// dispatch branch in `load_arch_dense` (plus, for TP, a parallel
/// pattern in `tp_qwen3.rs`).
const DENSE_SUPPORTED_MODEL_TYPES: &[&str] =
&["llama", "qwen3", "qwen3_5", "qwen3_moe", "qwen3_next"];
pub(crate) const DENSE_SUPPORTED_MODEL_TYPES: &[&str] = &[
"llama",
"qwen3",
"qwen3_5",
"qwen3_moe",
"qwen3_next",
"qwen4_exp",
];
/// Pre-flight check the operator's `config.json` against the set of
/// architectures the dense path actually knows how to build. Surfaces
@@ -1388,6 +1411,11 @@ pub(crate) fn check_dense_config_supported(config_json: &str, model_id: &str) ->
/// families than the TP path because each TP-aware module is a real
/// chunk of work (`tp_qwen3.rs` is the only one shipped today).
#[cfg(feature = "cuda")]
// `qwen4_exp` is deliberately absent: the single-GPU path loads it, but
// there is no `tp_qwen4_exp.rs`, so a `tensor_parallel` request for it
// is refused here rather than silently served from one device. This
// list is `cfg(cuda)`, so no CPU-build test can cover it — the gate is
// the list itself.
const TP_SUPPORTED_MODEL_TYPES: &[&str] = &["qwen3", "qwen3_5", "qwen3_next"];
/// TP-side counterpart to `check_dense_config_supported`. Gates the
@@ -2707,6 +2735,9 @@ impl CandleHarness {
self.resolve_dense_files(spec, source_id).await?;
let device_for_load = device.clone();
let model_id_for_log = spec.model_id.clone();
// Resolved out here: `spec` is borrowed and does not outlive the
// blocking task.
let isq = super::quant::parse_quant_string(spec.quant.as_deref())?;
let arch = tokio::task::spawn_blocking(move || -> Result<ModelArch> {
let cfg_text = std::fs::read_to_string(&config_path).context("read config.json")?;
@@ -2802,6 +2833,40 @@ impl CandleHarness {
.context("build Qwen3-Next dense model")?;
Ok(ModelArch::Qwen3_5Dense(model))
}
"qwen4_exp" => {
// Qwen3.8-Flash-Next. Like qwen3_5 it loads through
// the sharded backend; unlike it, the config is a
// separate type — the field vocabulary overlaps but
// the model-level plumbing does not.
let cfg = super::arch::qwen4_exp::config::Config::from_config_json(&cfg_text)
.context("parse qwen4_exp config.json")?;
let sharded_vb = unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
&safetensors_paths,
dtype,
&device_for_load,
)
.context("build ShardedVarBuilder for qwen4_exp")?
};
// ISQ is not optional for this architecture in
// practice: without it the routed experts are
// 241.6 GB. `quant` comes from the catalogue, and a
// load without one will fail on memory rather than
// on anything this code can say (#315).
// ISQ is not really optional here: without it the
// routed experts are 241.6 GB and the load fails on
// memory rather than on anything this code can say.
// The catalogue's `quant` is the knob (#315, #252).
let model = super::arch::qwen4_exp::model::Qwen4ExpForCausalLM::load(
&cfg,
dtype,
isq,
&device_for_load,
&sharded_vb,
)
.context("build qwen4_exp model")?;
Ok(ModelArch::Qwen4Exp(Box::new(model)))
}
other => {
// Defensive: `check_dense_config_supported` already
// gated on the supported set, so this branch is
@@ -2820,6 +2885,59 @@ impl CandleHarness {
Ok((tokenizer_path, arch))
}
/// Whether this spec's `quant` names a GGUF the repo actually has.
///
/// `quant` is overloaded (#320): the TP path reads it as in-situ
/// quantisation of safetensors, while the single-GPU paths read it
/// as "select the GGUF whose filename contains this string". A repo
/// with no GGUF at all cannot mean the second, so rather than fail
/// with an error about a file that was never going to exist, the
/// same string is read the way the TP path reads it.
///
/// This is deliberately narrow. A repo that *does* carry GGUFs keeps
/// the old behaviour exactly, including the old error when none of
/// them match the requested quant — that case is a typo, not an ISQ
/// request, and turning it into a silent 360 GB dense load would be
/// a worse answer than the error.
async fn quant_selects_a_gguf(
&self,
spec: &ModelSpec,
source_id: &cortex_core::source::ModelSourceId,
) -> Result<bool> {
let Some(quant) = spec.quant.as_deref().filter(|q| !q.is_empty()) else {
return Ok(false);
};
let api = self.hf_api_for(&source_id.scheme)?;
let info = api
.model(source_id.repo_path())
.info()
.await
.with_context(|| format!("fetch HF repo info for {source_id}"))?;
let names: Vec<String> = info
.siblings
.iter()
.map(|s| s.rfilename.to_lowercase())
.collect();
let any_gguf = names.iter().any(|n| n.ends_with(".gguf"));
if any_gguf {
return Ok(true);
}
let any_safetensors = names.iter().any(|n| n.ends_with(".safetensors"));
if any_safetensors {
tracing::info!(
model = %source_id,
quant = %quant,
"no GGUF in this repo; reading `quant` as in-situ quantization of \
the safetensors (#320)"
);
return Ok(false);
}
anyhow::bail!(
"repo {source_id} has neither a GGUF nor safetensors; `quant = {quant:?}` \
cannot be satisfied"
)
}
/// Resolve a model spec to local GGUF and tokenizer file paths via
/// hf-hub. Downloads on first use; subsequent calls are cached.
async fn resolve_files(
@@ -3804,7 +3922,7 @@ impl Harness for CandleHarness {
context_profile,
) = if let Some(w) = &worker {
// CUDA path: resolve, then load in the worker.
if spec.quant.is_some() {
if self.quant_selects_a_gguf(spec, &source_id).await? {
let (gguf_path, tokenizer_path) = self.resolve_files(spec, &source_id).await?;
let handle = w
.load_gguf(gguf_path, spec.model_id.clone())
@@ -3828,36 +3946,38 @@ impl Harness for CandleHarness {
let (config_path, tokenizer_path, safetensors_paths) =
self.resolve_dense_files(spec, &source_id).await?;
let meta = VisionMeta::from_config_path(&config_path);
// Prefix snapshots (#11) exist only for the in-tree
// qwen3_5 arch — which serves BOTH its model_types
// (qwen3_5 and the flat-config qwen3_next MoE family);
// the worker holds the ModelArch so the async side
// decides from config.json instead.
let snapshot_capable = matches!(
config_model_type(&config_path).as_deref(),
Some(super::arch::qwen3_5::MODEL_TYPE)
| Some(super::arch::qwen3_5::MODEL_TYPE_NEXT)
);
// Context-limit physics (#67): single-GPU → world_size 1.
// `None` for non-qwen3_5 dense archs.
// `None` for dense archs with no profile builder — they
// fall back to the static prompt cap.
let context_profile =
super::context_limit::profile_from_qwen3_5_config(&config_path, 1);
let handle = w
.load_dense(config_path, safetensors_paths, spec.model_id.clone())
super::context_limit::profile_from_qwen3_5_config(&config_path, 1).or_else(
|| super::context_limit::profile_from_qwen4_exp_config(&config_path, 1),
);
// In-situ quantisation (#320) reaches the worker load
// too, not just the CPU one — parsed here because
// `spec` does not cross the channel.
let isq = super::quant::parse_quant_string(spec.quant.as_deref())?;
let load = w
.load_dense(config_path, safetensors_paths, spec.model_id.clone(), isq)
.await
.map_err(|e| anyhow::anyhow!("worker load_dense: {e:#}"))?;
// Prefix snapshots (#11): the built model answers for
// itself on the way back, rather than the async side
// re-deriving the answer from `config.json`. See
// `DenseLoad` — the re-derivation is what silently
// dropped batched decode for qwen4_exp.
(
tokenizer_path,
None,
Some(handle),
Some(load.handle),
meta,
snapshot_capable,
load.supports_kv_snapshot,
context_profile,
)
}
} else {
// CPU path: legacy spawn_blocking + Arc<Mutex<ModelArch>>.
let (tokenizer_path, arch) = if spec.quant.is_some() {
let (tokenizer_path, arch) = if self.quant_selects_a_gguf(spec, &source_id).await? {
self.load_arch_gguf(spec, &source_id, &device).await?
} else {
self.load_arch_dense(spec, &source_id, &device).await?
@@ -4675,6 +4795,13 @@ impl CandleHarness {
image_token_id: vision_meta.image_token_id,
image_grid_factor: vision_meta.image_grid_factor,
spec: spec.clone(),
// Snapshot capability re-derived from `config.json`
// rather than asked of the loaded shard. Correct today —
// `TP_SUPPORTED_MODEL_TYPES` is exactly these two — but it
// is the same shape as the bug `DenseLoad` fixed on the
// single-GPU path, so whoever adds an arch here (#319)
// must add it in both places or lose batched decode
// silently.
prefix_cache: self.new_prefix_cache(matches!(
config_model_type(&config_path).as_deref(),
Some(super::arch::qwen3_5::MODEL_TYPE)
@@ -6226,9 +6353,14 @@ struct VisionMeta {
}
/// Peek at `config.json` for its `model_type`. Best-effort — `None`
/// on any read/parse error. The load path uses this to decide prefix-
/// snapshot capability for worker-held models the async side can't
/// inspect directly.
/// on any read/parse error.
///
/// Only the TP load path still asks: the single-GPU path takes the
/// arch's own answer off the load reply (`DenseLoad`) rather than
/// re-deriving it here, which is what stopped the two from drifting.
/// The TP path has the same hazard and not yet the same cure — see
/// the note at its call site.
#[cfg(feature = "cuda")]
fn config_model_type(config_path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(config_path).ok()?;
let v: serde_json::Value = serde_json::from_str(&text).ok()?;
@@ -8113,6 +8245,22 @@ mod tests {
check_dense_config_supported(cfg, "Qwen/Qwen3-1.7B").expect("qwen3 should pass");
}
#[test]
fn check_dense_config_accepts_qwen4_exp() {
// Qwen3.8-Flash-Next is a new architecture generation rather
// than a Qwen3 variant, so it has its own model_type and its own
// arm in `load_arch_dense`. The gate and that match must agree —
// a type in the supported set with no arm reaches the defensive
// "unrouted" bail instead of loading.
let cfg = r#"{
"model_type": "qwen4_exp",
"architectures": ["Qwen4ExpForConditionalGeneration"],
"text_config": {"hidden_size": 2560}
}"#;
check_dense_config_supported(cfg, "Qwen/Qwen3.8-Flash-Next")
.expect("qwen4_exp should be in the supported set");
}
#[test]
fn check_dense_config_rejects_unsupported_arch_with_clear_message() {
// Use a deliberately-fake model_type so this test stays
+139 -3
View File
@@ -252,6 +252,85 @@ pub fn profile_from_qwen3_5_config(config_path: &Path, world_size: u32) -> Optio
})
}
/// Bytes of **QSA indexer** cache one token adds, per card (#307).
///
/// `qwen4_exp` keeps a second cache beside the main one: on every
/// full-attention layer the indexer stores the raw index key,
/// `indexer_kv_heads * indexer_head_dim` wide, in the compute dtype
/// (`arch::qwen4_exp::qsa::Indexer::key_cache`). Keys only — the
/// indexer scores blocks, it does not attend them — so there is no
/// `2 *` here.
///
/// It does not shard: `indexer_kv_heads` is 1, so a TP rank holds the
/// whole thing however wide the world is. At the checkpoint's 12
/// full-attention layers x 1 head x 128 dims that is 3 KiB/token on
/// top of the main cache's 24 — the surcharge the epic's original
/// arithmetic omitted, and it grows with context exactly as the main
/// cache does.
pub fn indexer_bytes_per_token(
n_full_attn_layers: usize,
indexer_kv_heads: usize,
indexer_head_dim: usize,
dtype_bytes: usize,
) -> u64 {
(n_full_attn_layers * indexer_kv_heads * indexer_head_dim * dtype_bytes) as u64
}
/// Build a [`ContextProfile`] from a `qwen4_exp` `config.json` — the
/// sibling of [`profile_from_qwen3_5_config`] for the other hybrid
/// arch. Returns `None` for any other `model_type` or an unparseable
/// config.
///
/// Same shape of physics, one term more: the per-token cost is the
/// main KV cache **plus** the QSA indexer's, because both grow per
/// token and both come out of the same card. Advertising against the
/// main cache alone would overstate what is servable by about an
/// eighth, which is the class of error #281 is about.
///
/// `layer_types` is always populated by the time `from_config_json`
/// returns (it fills the array from `full_attention_interval` when the
/// checkpoint omits it), so counting it is authoritative here.
pub fn profile_from_qwen4_exp_config(
config_path: &Path,
world_size: u32,
) -> Option<ContextProfile> {
let text = std::fs::read_to_string(config_path).ok()?;
if !super::arch::qwen4_exp::config::is_qwen4_exp(&text) {
return None;
}
let cfg = super::arch::qwen4_exp::config::Config::from_config_json(&text).ok()?;
let tc = &cfg.text_config;
let n_full_attn_layers = tc
.layer_types
.iter()
.filter(|t| t.as_str() == "full_attention")
.count();
// A model with no full-attention layer has no growing cache, and
// the derivation divides by this. Nothing in the family looks like
// that, so treat it as a config we do not understand rather than
// inventing an unbounded context.
if n_full_attn_layers == 0 {
return None;
}
let kv_bytes_per_token_per_card = kv_bytes_per_token(
n_full_attn_layers,
tc.num_key_value_heads,
tc.head_dim,
KV_CACHE_DTYPE_BYTES,
world_size,
) + indexer_bytes_per_token(
n_full_attn_layers,
tc.indexer_kv_heads,
tc.indexer_head_dim,
KV_CACHE_DTYPE_BYTES,
);
Some(ContextProfile {
max_position_embeddings: tc.max_position_embeddings,
kv_bytes_per_token_per_card,
world_size,
})
}
/// Round a token count down to a clean boundary so the advertised limit
/// doesn't jitter by a handful of tokens as live VRAM / the throughput
/// EMA wobble between polls.
@@ -375,9 +454,15 @@ pub fn derive_limit(
// Observability (#126): every input and intermediate term, so a
// surprising advertised limit is diagnosable from the journal
// instead of re-deriving by hand. DEBUG — this runs on every
// `GET /models` poll (a few lines per poll cycle).
tracing::debug!(
// instead of re-deriving by hand.
//
// TRACE, not debug: this runs on every `GET /models` poll and every
// input is fixed at load, so the line is byte-identical each time —
// about 8,600 a day per model at a 10s poll. It is worth having when
// reasoning about a context ceiling and worth nothing the rest of
// the time, and at debug it buries the lines that are not routine
// (#320).
tracing::trace!(
max_pos = profile.max_position_embeddings,
kv_bytes_per_token_per_card = profile.kv_bytes_per_token_per_card,
free_tightest_mb,
@@ -418,6 +503,57 @@ pub fn derive_limit(
#[cfg(test)]
mod tests {
/// The advertised limit for `qwen4_exp` must price both caches.
///
/// The main KV cache is 12 full-attention layers x 2 kv heads x 256
/// dims x 2 (K and V) x 2 bytes = 24 KiB/token, and the QSA indexer
/// adds 12 x 1 x 128 x 2 bytes = 3 KiB on top. Counting only the
/// first would advertise ~12.5% more context than the card can
/// hold — the failure mode #281 exists to stop, and the one the
/// epic's own arithmetic made before the indexer cache was found.
///
/// Driven from the checkpoint's verbatim `config.json`, so a field
/// that changes shape upstream fails here rather than in
/// production.
#[test]
fn a_qwen4_exp_profile_counts_the_indexer_cache_too() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
std::fs::write(&path, super::super::arch::qwen4_exp::config::SHIPPED).unwrap();
let profile = profile_from_qwen4_exp_config(&path, 1)
.expect("the shipped config.json must yield a profile");
assert_eq!(profile.max_position_embeddings, 262_144);
assert_eq!(
profile.kv_bytes_per_token_per_card,
24 * 1024 + 3 * 1024,
"main KV plus the indexer's second cache"
);
}
/// The two hybrid archs must not answer for each other: whichever
/// builder runs first has to decline a config it does not own, or
/// the load path's `or_else` chain silently prices one model with
/// the other's physics.
#[test]
fn the_profile_builders_decline_each_others_configs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.json");
std::fs::write(&path, super::super::arch::qwen4_exp::config::SHIPPED).unwrap();
assert!(
profile_from_qwen3_5_config(&path, 1).is_none(),
"the qwen3_5 builder must decline a qwen4_exp config"
);
let path = dir.path().join("other.json");
std::fs::write(&path, r#"{"model_type": "qwen3_next"}"#).unwrap();
assert!(
profile_from_qwen4_exp_config(&path, 1).is_none(),
"the qwen4_exp builder must decline everything else"
);
}
/// The deploy's smoke probe must not set the advertised context.
///
/// 19 tokens in 130 ms reads as ~145 tok/s on a host that measures
@@ -13,11 +13,13 @@
//! ARCH model state in this state slab will gain a companion
//! `tp_models: HashMap<TpHandle, Box<TpLeaderModel>>`.
use crate::harness::arch::qwen3_5::snapshot::KvCacheSnapshot;
use crate::harness::arch::snapshot::KvCacheSnapshot;
use crate::harness::candle::ModelArch;
#[cfg(feature = "cuda")]
use crate::harness::device_worker::jobs::TpHandle;
use crate::harness::device_worker::jobs::{ArchHandle, ImageHandle, ImageInput, Job, KvSnapshotId};
use crate::harness::device_worker::jobs::{
ArchHandle, DenseLoad, ImageHandle, ImageInput, Job, KvSnapshotId,
};
use crate::harness::image::ZImagePipeline;
#[cfg(feature = "cuda")]
use crate::harness::tp::TpLeaderModel;
@@ -131,17 +133,44 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
} => {
let result = load_gguf_inner(&state.device, &gguf_path, &model_id)
.map(|arch| insert_arch(&mut state, Box::new(arch)));
// Same reasoning as `LoadImage`: release load-time
// transients before serving, and — on the failure
// path — give back whatever the partial load had
// allocated before it gave up. Without this a load
// that OOMs leaves its bytes in the pool, where the
// driver still counts them against the next load.
trim_device_pool(&state);
let _ = reply.send(result);
}
Job::LoadDense {
config_path,
safetensors_paths,
model_id,
quant,
reply,
} => {
let result =
load_dense_inner(&state.device, &config_path, &safetensors_paths, &model_id)
.map(|arch| insert_arch(&mut state, Box::new(arch)));
let result = load_dense_inner(
&state.device,
&config_path,
&safetensors_paths,
&model_id,
quant,
)
.map(|arch| {
// Ask the model, once, while it is still in
// hand. See `DenseLoad`.
let supports_kv_snapshot = arch.supports_kv_snapshot();
let handle = insert_arch(&mut state, Box::new(arch));
DenseLoad {
handle,
supports_kv_snapshot,
}
});
// As for `LoadGguf`. It matters more here: a dense
// load that runs out of VRAM part-way has typically
// allocated tens of gigabytes first, and the evicted
// model waiting to be restored needs them back.
trim_device_pool(&state);
let _ = reply.send(result);
}
Job::DropArch { handle, reply } => {
@@ -158,6 +187,14 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
// the end of the arm; calling drop() explicitly just
// makes the intent visible.
drop(removed);
// Return the freed blocks to the system rather than
// the stream-ordered pool, so the next load actually
// sees the VRAM — `DropImage` has done this since the
// benjy cold-swap, and an unload is an unload whatever
// modality asked for it.
if was_present {
trim_device_pool(&state);
}
tracing::debug!(
device_index,
handle = handle.0,
@@ -374,6 +411,13 @@ pub(crate) fn run(device_index: u32, rx: Receiver<Job>, poisoned: Arc<AtomicBool
let was_present = removed.is_some();
drop(removed);
state.tp_kv_snapshots.retain(|(h, _), _| *h != handle);
// As `DropArch`: this is only the leader's shard —
// the other ranks free theirs in their own processes
// — but the leader's is the one sharing a card with
// whatever loads next.
if was_present {
trim_device_pool(&state);
}
tracing::debug!(
device_index,
tp_handle = handle.0,
@@ -895,6 +939,7 @@ fn load_dense_inner(
config_path: &std::path::Path,
safetensors_paths: &[std::path::PathBuf],
model_id: &str,
quant: Option<candle_core::quantized::GgmlDType>,
) -> anyhow::Result<ModelArch> {
use anyhow::Context;
use candle_core::DType;
@@ -988,6 +1033,36 @@ fn load_dense_inner(
.context("build Qwen3-Next dense model")?;
Ok(ModelArch::Qwen3_5Dense(model))
}
"qwen4_exp" => {
// Qwen3.8-Flash-Next. Mirrors the CPU path in
// `candle::load_arch_dense` — same config type, same
// sharded backend, same in-situ quantisation.
//
// ISQ is not optional for this architecture: the routed
// experts are 241.6 GB at BF16 (measured from the
// checkpoint's own headers), against 32.6 GB of card. A
// load without a `quant` will fail on memory rather than
// on anything this code can say (#315, #252).
let cfg = crate::harness::arch::qwen4_exp::config::Config::from_config_json(&cfg_text)
.context("parse qwen4_exp config.json")?;
let sharded_vb = unsafe {
candle_nn::var_builder::ShardedSafeTensors::var_builder(
safetensors_paths,
dtype,
device,
)
.context("build ShardedVarBuilder for qwen4_exp")?
};
let model = crate::harness::arch::qwen4_exp::model::Qwen4ExpForCausalLM::load(
&cfg,
dtype,
quant,
device,
&sharded_vb,
)
.context("build qwen4_exp model")?;
Ok(ModelArch::Qwen4Exp(Box::new(model)))
}
other => anyhow::bail!(
"unrouted supported model_type '{other}' — \
DENSE_SUPPORTED_MODEL_TYPES and load_dense_inner \
@@ -1045,7 +1120,7 @@ fn tp_load_shard_inner(
"qwen3_5" | "qwen3_next" => {
let cfg = crate::harness::tp::tp_qwen3_5::Config::from_config_json(config_json)
.context("parse Qwen3-Next Config JSON for leader load")?;
let quant_dtype = crate::harness::tp::worker::parse_quant_string(quant)?;
let quant_dtype = crate::harness::quant::parse_quant_string(quant)?;
TpLeaderModel::Qwen3_5(crate::harness::tp::tp_qwen3_5::TpQwen3_5ForCausalLM::load(
cfg,
&vb,
@@ -1179,7 +1254,7 @@ fn tp_assemble_kv_batch(
})?;
pairs.push((snap, *len));
}
let batch = crate::harness::arch::qwen3_5::snapshot::assemble_batch(&pairs)?;
let batch = crate::harness::arch::snapshot::assemble_batch(&pairs)?;
let model = tp_models
.get_mut(&handle)
.ok_or_else(|| anyhow::anyhow!("TpAssembleKvBatch: no model for handle {}", handle.0))?;
@@ -1212,9 +1287,8 @@ fn tp_extract_kv_rows(
.snapshot_kv_cache()?;
let mut total = 0u64;
for (&(row, prefix_len), &id) in rows.iter().zip(snapshot_ids) {
let snap = crate::harness::arch::qwen3_5::snapshot::extract_row(
&live, row, prefix_len, padded_len, steps,
)?;
let snap =
crate::harness::arch::snapshot::extract_row(&live, row, prefix_len, padded_len, steps)?;
total += snap.size_bytes();
state.tp_kv_snapshots.insert((handle, id), snap);
}
@@ -1337,7 +1411,7 @@ fn assemble_kv_batch(
})?;
pairs.push((snap, *len));
}
let batch = crate::harness::arch::qwen3_5::snapshot::assemble_batch(&pairs)?;
let batch = crate::harness::arch::snapshot::assemble_batch(&pairs)?;
let arch = models
.get_mut(&handle)
.ok_or_else(|| anyhow::anyhow!("AssembleKvBatch: no model for handle {}", handle.0))?;
@@ -1363,9 +1437,8 @@ fn extract_kv_rows(
.snapshot_kv_cache()?;
let mut out = Vec::with_capacity(rows.len());
for &(row, prefix_len) in rows {
let snap = crate::harness::arch::qwen3_5::snapshot::extract_row(
&live, row, prefix_len, padded_len, steps,
)?;
let snap =
crate::harness::arch::snapshot::extract_row(&live, row, prefix_len, padded_len, steps)?;
let id = KvSnapshotId(state.next_kv_snapshot_id);
state.next_kv_snapshot_id = state.next_kv_snapshot_id.wrapping_add(1);
let bytes = snap.size_bytes();
@@ -1671,3 +1744,42 @@ fn drain_poisoned(job: Job, device_index: u32) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every architecture the preflight admits must have a loader arm
/// on **this** path, not only on the CPU one in
/// `candle::load_arch_dense`.
///
/// There are two dense loaders — the worker's (every CUDA load)
/// and candle's (the CPU fallback) — and one list gating both.
/// `qwen4_exp` was added to the list and to the CPU loader, so it
/// passed preflight and then hit the "unrouted supported
/// model_type" bail here: on a GPU host the model was
/// unloadable, in about 200 ms, having read nothing. The bail's
/// own comment said the two must stay in sync; nothing checked.
///
/// A bare `{"model_type": ...}` reaches the arm and then fails on
/// the typed parse, which is fine — this asserts only that an arm
/// exists to fail in.
#[test]
fn every_supported_dense_model_type_has_a_loader_arm() {
let dir = tempfile::tempdir().unwrap();
for model_type in crate::harness::candle::DENSE_SUPPORTED_MODEL_TYPES {
let path = dir.path().join(format!("{model_type}.json"));
std::fs::write(&path, format!(r#"{{"model_type": "{model_type}"}}"#)).unwrap();
let msg =
match load_dense_inner(&candle_core::Device::Cpu, &path, &[], model_type, None) {
Ok(_) => panic!("a bare config must not build a model"),
Err(e) => format!("{e:#}"),
};
assert!(
!msg.contains("unrouted"),
"`{model_type}` is in DENSE_SUPPORTED_MODEL_TYPES but load_dense_inner \
has no arm for it: {msg}"
);
}
}
}
@@ -19,6 +19,27 @@ use tokio::sync::oneshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ArchHandle(pub u64);
/// What a dense load hands back: the handle, plus what the arch it
/// built says about itself.
///
/// The async side cannot ask the model anything — it lives in the
/// worker's slab and never crosses the channel — so anything the
/// serving path needs to know has to ride back on the reply. Prefix
/// snapshot support (#11) is the first such fact, and it is here
/// rather than re-derived from `config.json` because that is exactly
/// how it went wrong: the async side matched a list of `model_type`
/// strings that `ModelArch::supports_kv_snapshot` had since grown
/// past, so `qwen4_exp` loaded with batched decode silently off.
/// A capability the arch answers for itself cannot drift from the
/// arch.
#[derive(Debug, Clone, Copy)]
pub struct DenseLoad {
pub handle: ArchHandle,
/// [`crate::harness::candle::ModelArch::supports_kv_snapshot`], as
/// answered by the model that was actually built.
pub supports_kv_snapshot: bool,
}
/// Opaque handle to a `TpLeaderModel` stored in the worker thread's
/// state slab. Same shape as [`ArchHandle`] but in a separate
/// namespace so the two slabs can coexist without ambiguity. Phase 3
@@ -107,7 +128,11 @@ pub enum Job {
config_path: PathBuf,
safetensors_paths: Vec<PathBuf>,
model_id: String,
reply: oneshot::Sender<Result<ArchHandle>>,
/// In-situ quantisation (#320): the catalogue's `quant`,
/// already parsed. `None` loads at the checkpoint dtype.
/// Archs that cannot quantise in situ ignore it.
quant: Option<candle_core::quantized::GgmlDType>,
reply: oneshot::Sender<Result<DenseLoad>>,
},
/// Remove the model from the slab and drop it. The `Drop` runs on
/// the worker thread so CUDA tensors release their memory on the
@@ -163,7 +188,7 @@ pub enum Job {
/// state and install it as the model's live state (#98). `seqs`
/// pairs each snapshot id with its true token length; attention
/// K/V is right-padded to the batch max and `cat`ed on dim 0 (see
/// `arch::qwen3_5::snapshot::assemble_batch`). Replies with the
/// `arch::snapshot::assemble_batch`). Replies with the
/// padded uniform KV length. The source snapshots remain stored —
/// the caller drops them via `DropKvSnapshot` when the sequences
/// leave the batch.
@@ -51,7 +51,7 @@ use tokio::sync::oneshot;
#[cfg(feature = "cuda")]
pub use jobs::TpHandle;
pub use jobs::{ArchHandle, ImageHandle, Job, KvSnapshotId};
pub use jobs::{ArchHandle, DenseLoad, ImageHandle, Job, KvSnapshotId};
/// Errors returned by `DeviceWorkerHandle` submit methods.
#[derive(Debug, thiserror::Error)]
@@ -221,7 +221,8 @@ impl DeviceWorkerHandle {
config_path: std::path::PathBuf,
safetensors_paths: Vec<std::path::PathBuf>,
model_id: String,
) -> Result<ArchHandle, WorkerError> {
quant: Option<candle_core::quantized::GgmlDType>,
) -> Result<DenseLoad, WorkerError> {
if self.poisoned.load(Ordering::Acquire) {
return Err(WorkerError::Poisoned {
device_index: self.device_index,
@@ -233,6 +234,7 @@ impl DeviceWorkerHandle {
config_path,
safetensors_paths,
model_id,
quant,
reply: reply_tx,
})
.map_err(|_| WorkerError::Gone {
@@ -1243,9 +1245,11 @@ mod tests {
fixture.join("config.json"),
vec![fixture.join("model.safetensors")],
"qwen3_next-tiny".into(),
None,
)
.await
.expect("load tiny fixture");
.expect("load tiny fixture")
.handle;
let prompts: [&[u32]; 3] = [&[1, 2, 3], &[4, 5], &[7, 3, 2, 5, 6]];
let steps: [&[u32]; 3] = [&[11, 12, 13], &[9, 8, 7], &[21, 22, 23]];
+3 -1
View File
@@ -1261,9 +1261,11 @@ mod tests {
fixture.join("config.json"),
vec![fixture.join("model.safetensors")],
"qwen3_next-tiny".into(),
None,
)
.await
.expect("load fixture");
.expect("load fixture")
.handle;
let admission_cfg = AdmissionConfig {
max_in_flight: 3,
+1
View File
@@ -12,6 +12,7 @@ pub mod image;
pub mod prefix_cache;
pub mod preflight;
pub mod preprocess;
pub(crate) mod quant;
pub mod reasoning_budget;
pub mod reasoning_effort;
pub mod sampling;
+49
View File
@@ -0,0 +1,49 @@
//! Catalogue quant names to ggml dtypes.
//!
//! Shared rather than owned by the TP path: `quant = "q4k"` in
//! `models.toml` means the same thing whichever loader reads it. It sat
//! behind `cfg(feature = "cuda")` while only the TP worker used it,
//! which put it out of reach of the single-GPU path — where, for
//! `qwen4_exp`, in-situ quantisation is the difference between a model
//! that loads and 241.6 GB that does not (#315).
/// Parse a `ModelSpec.quant` string into a `GgmlDType`. Accepts the
/// common ggml format names (case-insensitive). `None` and `Some("")`
/// both map to "no quantization".
///
/// Supported: `q4_0`, `q4_1`, `q5_0`, `q5_1`, `q8_0`, `q8_1`,
/// `q2k`/`q2_k`, `q3k`/`q3_k`, `q4k`/`q4_k`, `q5k`/`q5_k`,
/// `q6k`/`q6_k`, `q8k`/`q8_k`, `f16`, `bf16`, `f32`. The underscore
/// is optional and the prefix is case-insensitive.
pub(crate) fn parse_quant_string(
s: Option<&str>,
) -> anyhow::Result<Option<candle_core::quantized::GgmlDType>> {
use candle_core::quantized::GgmlDType;
let s = match s {
Some(s) if !s.is_empty() => s,
_ => return Ok(None),
};
let normalised = s.to_ascii_lowercase().replace('_', "");
let dtype = match normalised.as_str() {
"q40" => GgmlDType::Q4_0,
"q41" => GgmlDType::Q4_1,
"q50" => GgmlDType::Q5_0,
"q51" => GgmlDType::Q5_1,
"q80" => GgmlDType::Q8_0,
"q81" => GgmlDType::Q8_1,
"q2k" => GgmlDType::Q2K,
"q3k" => GgmlDType::Q3K,
"q4k" | "q4km" => GgmlDType::Q4K,
"q5k" | "q5km" => GgmlDType::Q5K,
"q6k" => GgmlDType::Q6K,
"q8k" => GgmlDType::Q8K,
"f16" => GgmlDType::F16,
"bf16" => GgmlDType::BF16,
"f32" => GgmlDType::F32,
other => anyhow::bail!(
"unknown quant '{other}' (expected one of: q4_0, q4_1, q5_0, q5_1, q8_0, \
q8_1, q2k, q3k, q4k, q5k, q6k, q8k, f16, bf16, f32)"
),
};
Ok(Some(dtype))
}
+2 -2
View File
@@ -138,7 +138,7 @@ impl TpLeaderModel {
/// Capture rank 0's cache state for a prefix snapshot (#11).
pub fn snapshot_kv_cache(
&self,
) -> candle_core::Result<crate::harness::arch::qwen3_5::snapshot::KvCacheSnapshot> {
) -> candle_core::Result<crate::harness::arch::snapshot::KvCacheSnapshot> {
match self {
TpLeaderModel::Qwen3(_) => {
candle_core::bail!("snapshot_kv_cache: qwen3 (dense) has no snapshot support")
@@ -150,7 +150,7 @@ impl TpLeaderModel {
/// Replace rank 0's live cache state with a stored snapshot.
pub fn restore_kv_cache(
&mut self,
snap: &crate::harness::arch::qwen3_5::snapshot::KvCacheSnapshot,
snap: &crate::harness::arch::snapshot::KvCacheSnapshot,
) -> candle_core::Result<()> {
match self {
TpLeaderModel::Qwen3(_) => {
+3 -2
View File
@@ -45,10 +45,10 @@ use super::tp_linear::{ColumnParallelLinear, RowParallelLinear};
use crate::harness::arch::qwen3_5::linear_attn::repeat_interleave;
use crate::harness::arch::qwen3_5::rmsnorm::{Qwen3_5RmsNorm, Qwen3_5RmsNormGated, l2norm};
use crate::harness::arch::qwen3_5::rope::RotaryEmbedding;
use crate::harness::arch::qwen3_5::snapshot::{KvCacheSnapshot, LayerKvSnapshot};
use crate::harness::arch::qwen3_5::splice_runs;
use crate::harness::arch::qwen3_5::vision::VisionTower;
pub use crate::harness::arch::qwen3_5::{Config, TextConfig};
use crate::harness::arch::snapshot::{KvCacheSnapshot, LayerKvSnapshot};
// ─── linear-attention (Gated DeltaNet) ──────────────────────────────
@@ -661,7 +661,7 @@ impl TpQwen3_5Attention {
&q,
&k,
&v,
attn_mask,
crate::harness::arch::qwen3_5::full_attn::AttnMask::causal(attn_mask),
self.num_kv_groups,
scale,
)?;
@@ -1700,6 +1700,7 @@ impl TpQwen3_5Model {
Ok(KvCacheSnapshot {
layers,
rope_delta: self.rope_delta,
ple: None,
})
}
+13 -51
View File
@@ -16,6 +16,11 @@ use anyhow::Result;
use std::collections::HashMap;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
// Only the CUDA shard-load path calls this; the CPU build of this
// module has no caller.
#[cfg(feature = "cuda")]
use crate::harness::quant::parse_quant_string;
use super::nccl_state::NcclState;
use super::rpc::{WorkerRequest, WorkerResponse};
@@ -119,7 +124,7 @@ impl WorkerModel {
/// `TpLeaderModel` support first.
fn snapshot_kv_cache(
&self,
) -> candle_core::Result<crate::harness::arch::qwen3_5::snapshot::KvCacheSnapshot> {
) -> candle_core::Result<crate::harness::arch::snapshot::KvCacheSnapshot> {
match self {
WorkerModel::Qwen3(_) => {
candle_core::bail!("snapshot_kv_cache: qwen3 (dense) has no snapshot support")
@@ -130,7 +135,7 @@ impl WorkerModel {
fn restore_kv_cache(
&mut self,
snap: &crate::harness::arch::qwen3_5::snapshot::KvCacheSnapshot,
snap: &crate::harness::arch::snapshot::KvCacheSnapshot,
) -> candle_core::Result<()> {
match self {
WorkerModel::Qwen3(_) => {
@@ -227,7 +232,7 @@ struct WorkerState {
/// pool and shared across all ranks. Dropped with the shard on
/// `UnloadModel`.
#[cfg(feature = "cuda")]
kv_snapshots: HashMap<(String, u64), crate::harness::arch::qwen3_5::snapshot::KvCacheSnapshot>,
kv_snapshots: HashMap<(String, u64), crate::harness::arch::snapshot::KvCacheSnapshot>,
/// Placeholder mirroring `models` on the non-cuda build.
#[cfg(not(feature = "cuda"))]
#[allow(dead_code)]
@@ -689,11 +694,10 @@ impl WorkerState {
};
pairs.push((snap, *len));
}
let result =
crate::harness::arch::qwen3_5::snapshot::assemble_batch(&pairs).and_then(|batch| {
model.restore_kv_cache(&batch.snapshot)?;
Ok(batch.padded_len)
});
let result = crate::harness::arch::snapshot::assemble_batch(&pairs).and_then(|batch| {
model.restore_kv_cache(&batch.snapshot)?;
Ok(batch.padded_len)
});
match result {
Ok(padded_len) => WorkerResponse::KvBatchAssembled {
padded_len: padded_len as u64,
@@ -756,7 +760,7 @@ impl WorkerState {
};
let mut total_bytes = 0u64;
for (&(row, prefix_len), &id) in rows.iter().zip(snapshot_ids) {
match crate::harness::arch::qwen3_5::snapshot::extract_row(
match crate::harness::arch::snapshot::extract_row(
&live, row, prefix_len, padded_len, steps,
) {
Ok(snap) => {
@@ -1036,45 +1040,3 @@ impl WorkerState {
}
}
}
/// Parse a `ModelSpec.quant` string into a `GgmlDType`. Accepts the
/// common ggml format names (case-insensitive). `None` and `Some("")`
/// both map to "no quantization".
///
/// Supported: `q4_0`, `q4_1`, `q5_0`, `q5_1`, `q8_0`, `q8_1`,
/// `q2k`/`q2_k`, `q3k`/`q3_k`, `q4k`/`q4_k`, `q5k`/`q5_k`,
/// `q6k`/`q6_k`, `q8k`/`q8_k`, `f16`, `bf16`, `f32`. The underscore
/// is optional and the prefix is case-insensitive.
#[cfg(feature = "cuda")]
pub(crate) fn parse_quant_string(
s: Option<&str>,
) -> anyhow::Result<Option<candle_core::quantized::GgmlDType>> {
use candle_core::quantized::GgmlDType;
let s = match s {
Some(s) if !s.is_empty() => s,
_ => return Ok(None),
};
let normalised = s.to_ascii_lowercase().replace('_', "");
let dtype = match normalised.as_str() {
"q40" => GgmlDType::Q4_0,
"q41" => GgmlDType::Q4_1,
"q50" => GgmlDType::Q5_0,
"q51" => GgmlDType::Q5_1,
"q80" => GgmlDType::Q8_0,
"q81" => GgmlDType::Q8_1,
"q2k" => GgmlDType::Q2K,
"q3k" => GgmlDType::Q3K,
"q4k" | "q4km" => GgmlDType::Q4K,
"q5k" | "q5km" => GgmlDType::Q5K,
"q6k" => GgmlDType::Q6K,
"q8k" => GgmlDType::Q8K,
"f16" => GgmlDType::F16,
"bf16" => GgmlDType::BF16,
"f32" => GgmlDType::F32,
other => anyhow::bail!(
"unknown quant '{other}' (expected one of: q4_0, q4_1, q5_0, q5_1, q8_0, \
q8_1, q2k, q3k, q4k, q5k, q6k, q8k, f16, bf16, f32)"
),
};
Ok(Some(dtype))
}
+50 -13
View File
@@ -470,11 +470,21 @@ conventions — this is a solved problem in our tree.
**What is new is `group_size`.** `Qwen4ExpTextRMSNorm(dim, group_size)`
reshapes the last axis to `(-1, group_size)`, normalises each group
independently, and flattens back. Every 10240-wide norm in this model
(`hc_norm`, `norm_key`, `norm_query`, `norm_conv`,
`mtp.pre_fc_norm_hidden`) uses `group_size = 2560`, i.e. four
independent RMS normalisations, not one over 10240. A single norm over
the full 10240 vector compiles, runs, and is wrong.
independently, and flattens back. The 10240-wide norms in the decoder
(`hc_norm`, `norm_key`, `norm_query`, `norm_conv`) use
`group_size = 2560`, i.e. four independent RMS normalisations, not one
over 10240. A single norm over the full 10240 vector compiles, runs,
and is wrong.
**`mtp.pre_fc_norm_hidden` is the exception, and this document had it
wrong.** It is 10240 wide and **ungrouped** — one normalisation over
the whole vector. vLLM distinguishes the two cases explicitly: its
hyper-connection uses `GroupedGemmaRMSNorm` with
`group_size = hidden_size`, and the MTP head uses a plain
`GemmaRMSNorm(hidden_size * hc_count)`. Both classes exist in that
tree, so the choice is deliberate rather than an oversight. Grouping it
would rescale the draft head's input in four independent pieces and
show up as a poor acceptance rate, not as an error. See §9.
Variance is computed in f32 and the `(1 + w)` shift is applied in f32
before the multiply — same reasoning as the note already in
@@ -501,14 +511,41 @@ mtp.layers.0.* # a complete full_attention
**Transformers does not implement the MTP head.**
`Qwen4ExpPreTrainedModel._keys_to_ignore_on_load_unexpected = [r"^mtp.*"]`
— the weights are dropped on load. So for #313 the upstream reference is
*not* usable, and the authorities are instead llama.cpp PR #27836
(`--spec-type draft-mtp`) and vLLM's `speculative-config
{"method": "mtp"}` path. The tensor names above and the two `fc_*`
projections make the intended wiring clear (project the next token's
embedding and the accepted hidden state, sum, run one decoder layer,
reuse `lm_head`), but the accept rule and the proposal depth must come
from one of those two implementations, not from this document.
— the weights are dropped on load.
**The wiring, settled against vLLM** (`vllm/models/qwen4_exp/nvidia/
mtp.py`, read 2026-09-02). vLLM calls this shape
`residual_linear_shared`, as against the `Linear(2H, H)` + repeat that
other MTP variants use:
```
e = fc_embedding(pre_fc_norm_embedding(embed(t))) # [T, 2560]
h = pre_fc_norm_hidden(h.flatten(-2)) # ONE norm over 10240
h = fc_hidden(h.view(T, hc, 2560)) # per stream, shared matrix
h = e.unsqueeze(-2) + h # embedding broadcast to all four
h = h.flatten(-2) # [T, 10240] -> the decoder layer
```
Three things here are not guessable from the tensor shapes:
1. **`pre_fc_norm_hidden` is applied to the flattened 10240 and is
ungrouped** — see §8, which this document previously got wrong.
2. **The hidden state is the *pre-final-mixer* multi-stream**, i.e. `h`
after the last decoder layer and *before* `hyper_connection_mixer`
collapses it. Not the 2560 the LM head sees. vLLM calls this
"scheme A"; on later draft steps the head consumes its own previous
multi-stream rather than the target's.
3. **The head emits two things per step**: the collapsed `[T, 2560]`
for `lm_head`, and the pre-final-mixer `[T, 10240]` for the next
draft step.
PLE is forced off in the draft layer while the HC stream count is kept
(`ngram_context=None`), which is consistent with `mtp.layers.0` shipping
no `ple.*` tensors.
The accept rule is standard (greedy prefix match, or the Leviathan
rejection scheme) and is not architecture-specific; this document
previously conflated it with the wiring above.
`mtp.layers.0` carries its own full 512-expert MoE — 2.61 B params,
5.2 GB at BF16. It is not free to keep resident and its placement is
+252
View File
@@ -0,0 +1,252 @@
#!/bin/env bash
#
# Pre-seed the CUTLASS checkout that cudaforge would otherwise clone from
# GitHub during every CUDA build.
#
# candle-flash-attn's build script calls `cudaforge`, which fetches CUTLASS
# into `${CUDAFORGE_HOME:-$HOME/.cudaforge}/git/checkouts/cutlass-<commit[..16]>`.
# If the cache starts empty — as it does on a fresh or single-use build
# machine — every CUDA job clones CUTLASS afresh, and a clone GitHub
# refuses fails the whole job late, after the 20 minutes of kernel
# compilation that preceded it:
#
# fatal: could not read Username for 'https://github.com': No such device
# Error: GitOperationFailed("git clone failed with status: exit status: 128")
#
# That message reads like a missing credential. It is the opposite: see
# the note on anonymity below.
#
# cudaforge short-circuits when the cache directory already holds `include/`
# at the right commit (dependency.rs, `fetch_with_lock`), so seeding it here
# — with retries and backoff, which the build script has none of — removes
# the fetch from the build's critical path.
#
# The fetch is forced ANONYMOUS, and probes the remote on every run.
# CUTLASS is public, so no credential is needed or wanted; sending none
# takes our side of the connection out of the picture, so a failure is
# unambiguously not something this build machine attached to the request.
# What has actually been observed is a 401 that nobody has yet traced to
# a responder:
#
# error: RPC failed; HTTP 401 curl 22 The requested URL returned error: 401
#
# Do not assume that is GitHub. GitHub rate-limits its API, not clones,
# and a filtering proxy or a captive resolver on the path answers 401
# just as readily. The diagnostics below print who replied and with what
# headers on every build, so a refusal arrives with healthy samples
# beside it rather than alone.
#
# Best effort by design: if seeding fails, the build behaves exactly as it
# does today and cargo emits the authoritative error. This script never
# fails a build that would otherwise have passed.
set -uo pipefail
# Read no git configuration at all for the duration of this script.
# Clearing the individual keys instead (an empty credential.helper and an
# empty http.<url>.extraheader) does neutralise an injected auth header,
# but it leaves url.<...>.insteadOf rewrites and any config injected
# through GIT_CONFIG_COUNT untouched — both of which can also redirect or
# authenticate the fetch. Discarding the config files closes every vector
# at once and needs no guess about which one a given machine carries;
# this script has no configuration of its own to lose.
# GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM require git >= 2.32.
export GIT_CONFIG_GLOBAL=/dev/null
export GIT_CONFIG_SYSTEM=/dev/null
export GIT_CONFIG_COUNT=0
# Never prompt: without this a 401 blocks on a username read instead of
# failing, which turns a refused fetch into a confusing hang.
export GIT_TERMINAL_PROMPT=0
export GIT_ASKPASS=/bin/true
CUTLASS_REPO="${CUTLASS_REPO:-https://github.com/NVIDIA/cutlass.git}"
CACHE_ROOT="${CUDAFORGE_HOME:-$HOME/.cudaforge}/git/checkouts"
# The commit is pinned in candle-flash-attn, not here — read it from the
# source cargo actually resolved, so this cannot drift from the candle pin.
BUILD_RS=$(find "${CARGO_HOME:-$HOME/.cargo}/git/checkouts" \
-path '*/candle-flash-attn/build.rs' -print -quit 2>/dev/null)
if [[ -z "${BUILD_RS}" ]]; then
echo "seed-cutlass: candle-flash-attn not in the cargo git checkouts yet;" >&2
echo " run 'cargo fetch' before this step. Skipping." >&2
exit 0
fi
COMMIT=$(sed -nE 's/^const CUTLASS_COMMIT: &str = "([0-9a-f]+)";/\1/p' "${BUILD_RS}")
if [[ ! "${COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then
echo "seed-cutlass: could not read CUTLASS_COMMIT from ${BUILD_RS}. Skipping." >&2
exit 0
fi
DEST="${CACHE_ROOT}/cutlass-${COMMIT:0:16}"
echo "seed-cutlass: commit ${COMMIT}"
echo "seed-cutlass: target ${DEST}"
# Same short-circuit cudaforge applies, so a warm cache costs nothing.
if [[ -d "${DEST}/include" ]] && [[ "$(git -C "${DEST}" rev-parse HEAD 2>/dev/null)" == "${COMMIT}" ]]; then
echo "seed-cutlass: already seeded"
exit 0
fi
mkdir -p "${CACHE_ROOT}" || exit 0
rm -rf "${DEST}.partial"
# ---------------------------------------------------------------------
# Instrumentation. Two levels, both best effort: none of it may fail a
# build, so every probe is bounded by a timeout and every failure is
# swallowed.
#
# probe_remote runs on EVERY seeding run, successful ones included,
# so consecutive builds form a comparable series. A
# failure observed alone is nearly unreadable; the same
# failure beside twenty healthy samples is evidence.
# diagnose runs once, on the first failed attempt, adding the
# slower checks only worth their time once something has
# already gone wrong.
#
# What each field is for:
#
# server, x-github-request-id whether GitHub answered at all. A proxy
# or captive portal returning 401 on its
# behalf carries neither.
# x-github-edge-region, peer which edge served us, so a fault
# specific to one edge becomes visible.
# x-ratelimit-* whether this egress address is being
# throttled, measured rather than assumed.
# response body distinguishes "Repository not found"
# from any other refusal.
# tls issuer a re-signed certificate means something
# is terminating TLS in the path.
# egress the identity any per-source limit is
# applied to, and the only field that
# makes failures on different runners
# comparable. Costs one call to a third
# party, which is why it is in the
# failure path and not the common one.
# ---------------------------------------------------------------------
REFS_URL="${CUTLASS_REPO%.git}.git/info/refs?service=git-upload-pack"
probe_remote() {
fmt='http=%{http_code} peer=%{remote_ip}:%{remote_port} tls_verify=%{ssl_verify_result}'
fmt="${fmt} connects=%{num_connects} redirects=%{num_redirects}"
fmt="${fmt} bytes=%{size_download} time=%{time_total}s"
# One request, not two: this runs on every build, and doubling the
# traffic to the endpoint under suspicion would corrupt what it
# measures. Headers land in a file so the summary and the identity
# come from the same response.
hdr=$(mktemp 2>/dev/null) || return 0
echo "seed-cutlass: probe $(curl -sS -o /dev/null -D "${hdr}" --max-time 20 \
-w "${fmt}" "${REFS_URL}" 2>&1 | tr '\n' ' ')" >&2
tr -d '\r' < "${hdr}" \
| grep -iE '^(HTTP/|server:|www-authenticate:|retry-after:|x-github-request-id:|x-github-edge-region:|x-ratelimit-)' \
| sed 's/^/seed-cutlass: /' >&2 || true
rm -f "${hdr}"
}
diagnose() {
echo "seed-cutlass: --- diagnostics ---" >&2
echo "seed-cutlass: $(git --version 2>/dev/null || echo 'git --version failed')" \
"| $(uname -sr 2>/dev/null) | host $(hostname 2>/dev/null)" >&2
echo "seed-cutlass: dns $(getent ahosts github.com 2>/dev/null \
| awk '{print $1}' | sort -u | tr '\n' ' ')" >&2
echo "seed-cutlass: egress $(curl -sS --max-time 10 https://api.ipify.org 2>/dev/null \
|| echo unknown)" >&2
proxies=$(env | grep -Ei '^(https?_proxy|all_proxy|no_proxy)=' | tr '\n' ' ')
echo "seed-cutlass: proxy env ${proxies:-none}" >&2
# `env -u` restores the real git configuration for this inspection
# only; the fetch itself still runs with config disabled.
relevant=$(env -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_CONFIG_COUNT \
git config --list --show-origin 2>/dev/null \
| grep -Ei 'extraheader|insteadof|credential|proxy|askpass|http\.' | tr '\n' ' ')
echo "seed-cutlass: git config of interest ${relevant:-none}" >&2
echo "seed-cutlass: what this egress has left on the github api:" >&2
curl -sS -o /dev/null -D - --max-time 15 https://api.github.com/rate_limit 2>&1 \
| tr -d '\r' | grep -iE '^(HTTP/|x-ratelimit-)' \
| sed 's/^/seed-cutlass: /' >&2 || true
echo "seed-cutlass: tls path:" >&2
curl -sSv -o /dev/null --max-time 20 "${REFS_URL}" 2>&1 \
| grep -iE 'subject:|issuer:|SSL connection using|ALPN, server accepted|subjectAltName' \
| sed 's/^[*] *//; s/^/seed-cutlass: /' >&2 || true
echo "seed-cutlass: refusal body: $(curl -sS --max-time 20 "${REFS_URL}" 2>&1 \
| head -c 200 | tr '\n' ' ')" >&2
# Replay the fetch under git's own HTTP tracing. Everything above
# inspects a request of our choosing — a GET of the ref advertisement.
# The exchange that actually fails is the POST to git-upload-pack that
# follows it, and anything in the path may treat the two differently,
# so a clean GET proves very little. This shows which request was
# refused and what came back.
#
# It also settles, without inference, whether anything attached
# credentials: git logs an Authorization header here as <redacted>, so
# the line appearing at all means one was sent.
echo "seed-cutlass: traced replay of the real fetch:" >&2
rm -rf "${DEST}.trace"
if git init -q "${DEST}.trace" 2>/dev/null \
&& git -C "${DEST}.trace" remote add origin "${CUTLASS_REPO}" 2>/dev/null; then
GIT_TRACE_CURL=1 GIT_TRACE_CURL_NO_DATA=1 GIT_TRACE_REDACT=1 \
git -C "${DEST}.trace" fetch --depth 1 origin "${COMMIT}" 2>&1 \
| grep -iE 'Send header: (GET|POST)|Recv header: (HTTP/|server:|www-authenticate:|x-github|content-type:|retry-after:)|Authorization|fatal:|error:' \
| sed 's/.*\(Send header\|Recv header\)/\1/; s/^/seed-cutlass: /' \
| head -40 >&2 || true
fi
rm -rf "${DEST}.trace"
# Which wire protocol was refused? git >= 2.26 defaults to v2, where
# the ref listing is a POST to git-upload-pack carrying a
# Git-Protocol header; v0 is a plain GET of /info/refs first. libgit2
# — which cargo uses, and which has been observed reaching this same
# host seconds before git failed — still speaks v0. If v0 succeeds
# here while v2 fails, the fault is in how something on the path
# handles the newer protocol, not in access to the repository, and
# pinning protocol.version=0 for this fetch is the whole fix.
for v in 0 2; do
rm -rf "${DEST}.proto"
if git init -q "${DEST}.proto" 2>/dev/null \
&& git -C "${DEST}.proto" remote add origin "${CUTLASS_REPO}" 2>/dev/null; then
if git -c protocol.version="${v}" -C "${DEST}.proto" \
fetch -q --depth 1 origin "${COMMIT}" 2>/dev/null; then
echo "seed-cutlass: protocol v${v}: OK" >&2
else
echo "seed-cutlass: protocol v${v}: FAILED" >&2
fi
fi
rm -rf "${DEST}.proto"
done
echo "seed-cutlass: --- end diagnostics ---" >&2
}
# Unconditional: one sample per run, so the failures have a baseline.
probe_remote
for attempt in 1 2 3 4 5; do
if git init -q "${DEST}.partial" \
&& git -C "${DEST}.partial" remote add origin "${CUTLASS_REPO}" \
&& git -C "${DEST}.partial" config core.sparseCheckout true \
&& printf 'include/\ntools/util/include/\n' \
> "${DEST}.partial/.git/info/sparse-checkout" \
&& git -C "${DEST}.partial" fetch -q --depth 1 origin "${COMMIT}" \
&& git -C "${DEST}.partial" checkout -q FETCH_HEAD; then
rm -rf "${DEST}"
mv "${DEST}.partial" "${DEST}"
echo "seed-cutlass: seeded at attempt ${attempt}"
exit 0
fi
rm -rf "${DEST}.partial"
if [[ "${attempt}" == 1 ]]; then
diagnose
fi
backoff=$((attempt * 15))
echo "seed-cutlass: attempt ${attempt} failed; retrying in ${backoff}s" >&2
sleep "${backoff}"
done
echo "seed-cutlass: could not seed after 5 attempts; leaving it to the build" >&2
exit 0