Skip to content

Add Qwen1.5-MoE Q8_0 CPU and single-token TornadoVM GPU inference - #144

Merged
mikepapadim merged 10 commits into
beehive-lab:mainfrom
MRPRESIDENT66:moe-single-token
Aug 6, 2026
Merged

Add Qwen1.5-MoE Q8_0 CPU and single-token TornadoVM GPU inference#144
mikepapadim merged 10 commits into
beehive-lab:mainfrom
MRPRESIDENT66:moe-single-token

Conversation

@MRPRESIDENT66

@MRPRESIDENT66 MRPRESIDENT66 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

This PR adds CPU and single-token TornadoVM GPU inference support for Qwen1.5-MoE-A2.7B-Chat.Q8_0.gguf.

Although the public model name is Qwen1.5-MoE, its GGUF architecture identifier is qwen2moe, which is used internally for model detection and class naming.

The implementation adds:

  • Qwen2-MoE model detection, configuration, loader, state, and Q8_0 weight support.
  • CPU MoE forward inference: router projection, softmax/Top-K routing, routed experts, shared expert, and residual accumulation.
  • Single-token TornadoVM GPU inference for Q8_0 weights, including TaskGraphs and WorkerGrids for the router, routed experts, and shared expert.

Key implementation notes

  • Qwen3ChatFormat now adds the standard newline after <|im_end|>. This affects Qwen-family models that use this shared ChatML formatter, but does not change Llama, Mistral, Granite, or Phi prompt formatting.
  • The CPU Q8_0FloatTensor path now quantizes each FP32 activation block to int8 before the dot product, then performs an int8 × int8 accumulation with the Q8_0 scales. This follows the llama.cpp/ggml Q8_0 matmul approach and affects CPU Q8_0 inference generally, not only Qwen1.5-MoE. I added a switch, so the previous path remains available with -Dllama.quantizeActivation=false. Activation quantization is enabled by default.

Validation

Validated on an RTX 4090 (24 GB), JDK 21, and TornadoVM 5.1 with the PTX backend.

  • ./mvnw -q -DskipTests package passed.
  • CPU and GPU single-token smoke tests produced the same deterministic output prefix with the same prompt, temperature=0, and seed=42.
  • Model: Qwen1.5-MoE-A2.7B-Chat.Q8_0.gguf
  • 32 generated tokens:
    • CPU: 5.08 tok/s
    • GPU PTX: 24.59 tok/s average over three runs (24.89, 23.71, 25.17 tok/s)

CUDA backend

The CUDA backend was also validated on an RTX 4090 using TornadoVM 5.2.1 development build. For CUDA inference, --cuda-graphs is recommended because it significantly reduces the kernel-launch overhead of the MoE execution path.

The following prompt and settings were used:

./llama-tornado --gpu \
  --model Qwen1.5-MoE-A2.7B-Chat.Q8_0.gguf \
  --prompt "Explain briefly how mixture of experts routing works." \
  --max-tokens 200 \
  --temperature 0 \
  --seed 42 \
  --cuda-graphs

Results after fixing the RMSNorm cross-workgroup reduction race:

  • Default CUDA execution: 66.24 tok/s average over three runs (66.19, 66.00, 66.54 tok/s).
  • With --cuda-graphs: 94.16 tok/s average over two runs (94.33, 93.98 tok/s).

All five runs produced the same token-ID hash.

Scope

This PR supports CPU and single-token GPU inference only. Batch prefill/decode support is intentionally out of scope and will be submitted separately.

Adds config/weights/state/model/loader classes for Qwen2-MoE and
implements the router + top-k expert + shared-expert FFN block in
InferenceCore.forwardJavaQwen2MoE, plus architecture detection for
"qwen2moe" GGUF files.
Qwen2MoEState inherited Qwen2State.createStateFields, which casts the
Configuration to Qwen2Configuration. Since Qwen2MoEConfiguration is a
separate record (not a subtype of Qwen2Configuration), this cast threw
a ClassCastException at runtime when creating a new state. Overriding
with a Qwen2MoEConfiguration cast fixes state allocation.
@mikepapadim
mikepapadim requested review from mikepapadim and orionpapadakis and removed request for orionpapadakis August 5, 2026 22:53
@mikepapadim mikepapadim self-assigned this Aug 5, 2026
@mikepapadim

mikepapadim commented Aug 5, 2026

Copy link
Copy Markdown
Member

@MRPRESIDENT66 thank you for your contribution. I wander if you tried the cuda backend instead of ptx?
I am keen on merging it, but i think running it with the cuda backend will be faster.

Can you share prompts you used?

@mikepapadim
mikepapadim requested a review from stratika August 5, 2026 23:00
@MRPRESIDENT66

MRPRESIDENT66 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@mikepapadim Thanks! I tested the CUDA backend too, and it works fine. It achieved 88.64 tok/s on the RTX 4090.

The prompt I used was: Explain briefly how mixture of experts routing works.

@mikepapadim

Copy link
Copy Markdown
Member

@MRPRESIDENT66 Confirmed on my side too — CUDA backend, RTX 4090, JDK 21, TornadoVM develop @ba28f1526, this branch at b227bc2. With your prompt at 64 tokens I get 84.40 tok/s, right next to your 88.64. Nice work getting a MoE architecture running end to end; the routed/shared expert split in Qwen2MoEQ8_0FFNLayers reads cleanly, and having the CPU and GPU paths agree deterministically is exactly the right thing to have validated first.

One thing worth adding to your numbers: turn on CUDA graphs. It is off by default (llama.cudaGraphs=false) and needs the --cuda-graphs flag.

export JAVA_HOME=/path/to/jdk21
export TORNADOVM_HOME=/path/to/tornadovm-sdk    # built with: make BACKEND=cuda
export LLAMA_ROOT=$PWD

./llama-tornado --gpu \
  --model Qwen1.5-MoE-A2.7B-Chat.Q8_0.gguf \
  --prompt "Explain briefly how mixture of experts routing works." \
  --max-tokens 256 --temperature 0 --seed 42 \
  --cuda-graphs

On this model, medians of 5 runs at 256 tokens:

tok/s
default 62.01
--cuda-graphs 85.42

+37.8%, and the generated text is byte-identical (checked at temperature 0, seed 42).

The reason it pays off so well here is the launch count. Your FFN layer builds 15 fixed tasks plus 2 per routed-expert slot, and the GGUF says expert_used_count = 4, block_count = 24 — so roughly 560 kernel launches per token. Without graphs each one is dispatched individually; with graphs the whole per-layer graph is captured once and replayed. Dense models see maybe +28% from the same flag, MoE more, precisely because of that launch count.

So the honest headline for this PR is closer to ~85 tok/s on CUDA rather than 62, with no code change. Might be worth quoting that in the description.

Full details, plus a negative result and why FP16 does not fit on a 24 GB card, in my other comment above.

@mikepapadim

Copy link
Copy Markdown
Member

@MRPRESIDENT66 I prototyped suggestion 2 from my earlier comment — collapsing the per-slot routed-expert launches — and it is worth more than I expected. Branch: mikepapadim/GPULlama3.javaF:opt/moe-fused-expert-launches (3940767, +182/−20 over this PR). Take it or leave it, it is yours if useful.

What it does

Today the layer graph emits two tasks per routed slot:

for (int slot = 0; slot < config.numberOfExpertsUsed(); slot++) {
    layer.task("routed_expert_gate_up_" + slot, ...);
    layer.task("routed_expert_down_" + slot,    ...);
}

At expert_used_count = 4 that is 8 kernel launches per layer, 192 per token across 24 layers. The change turns them into two:

  • fusedRoutedExpertsGateUpSwiGLUQ8_0All — folds the slot index into the work-group id (slot = groupIdx / moeHiddenDim), so one launch covers all slots. wrapExpertGate grows from moeHiddenDim to moeHiddenDim * numberOfExpertsUsed so slots write disjoint windows (22 KB total, nothing).
  • routedExpertsDownProjectAndAccumulateQ8_0All — loops the slots inside one launch, and folds each into the residual through lane 0's running accumulator, so the residual is read and written once per row instead of four times.

8 launches per layer → 2.

Numbers

RTX 4090, JDK 21, TornadoVM develop @ba28f1526, 256 tokens, medians of 5 runs, the two jars interleaved run-by-run so clock drift hits both arms equally:

baseline (this PR) fused gain
default 61.98 tok/s 71.54 +15.4 %
--cuda-graphs 85.52 tok/s 92.00 +7.6 %

Variance was tight — baseline 61.73–62.31 and 85.48–85.59, fused 71.42–71.70 and 91.70–92.22.

Stacked with the --cuda-graphs flag from my earlier comment: 61.98 → 92.00 tok/s, +48.4 % over the current default.

The gain is smaller with graphs on, exactly as you would expect — graphs already amortise launch cost, so what is left is the single residual accumulation instead of four.

On correctness — and a bug that is not mine

My first version accumulated all slots into one partial sum before the reduction. That changes the summation order, and the text diverged from baseline after ~40 tokens. I rewrote the down-projection so each slot is reduced with the same tree and folded in the same order as the per-slot kernels, which should be arithmetically identical.

Checking that turned up something more important. The MoE GPU path is not deterministic at longer generations. Three runs of the unmodified PR branch, same jar, --temperature 0 --seed 42, 200 tokens:

b6c032c4a9619683060ee14168bdbc83  run 1
d1e4e4682ea7ef266292fa35ba8369c3  run 2
61645db0413d4d27021953c385f6b4bf  run 3

Three different outputs. It reproduces with and without --cuda-graphs, so graphs are not the cause, and it is present without my patch — this is on the existing path, not something the fusion introduced.

Short generations are stable: at 24 tokens, baseline is identical across 3 runs, fused is identical across 3 runs, and baseline == fused. So the fusion is sound on the deterministic prefix, and your PR's "same deterministic output prefix" validation holds — it is the longer tail that drifts.

Worth chasing before this lands, since it undercuts CPU/GPU comparison at realistic lengths. My first suspect would be a buffer that is read before the producing task's write is visible, or a reduction whose work-group count does not match its assumption; but I have not localised it.

One more constraint

--max-tokens 1024 does not run on a 4090:

TornadoOutOfMemoryException: Unable to allocate 330612752 bytes of memory.
  at TornadoVMMasterPlanSingleToken.forceCopyInReadOnlyData(...:116)

15.23 GB of Q8_0 weights leaves little headroom for the KV cache. Fine at 256, worth knowing as a documented limit.

Happy to open this as a PR against your branch, or leave it here for you to cherry-pick.

@mikepapadim

Copy link
Copy Markdown
Member

@MRPRESIDENT66 To make the offer concrete: please take the routed-expert fusion as your own separate PR if you like it. It is your subsystem, and it will review better as a focused follow-up than bolted onto this one. Branch is mikepapadim/GPULlama3.javaF:opt/moe-fused-expert-launches (3940767, 3 files, +182/−20) — cherry-pick it, rewrite it, or ignore it, no attribution needed.

The change in one line

Two tasks per routed slot become two tasks total: the slot index is folded into the work-group id for gate/up, and the down-projection loops slots internally with lane 0 carrying a running residual accumulator. At expert_used_count = 4 that is 8 kernel launches per layer → 2, i.e. 192 → 48 per token across 24 layers.

Backing numbers

RTX 4090, JDK 21, TornadoVM develop @ba28f1526, 256 tokens, medians of 5 runs, the two jars interleaved run-by-run so GPU clock drift hits both arms equally:

this PR + fusion gain
default 61.98 tok/s 71.54 +15.4 %
--cuda-graphs 85.52 tok/s 92.00 +7.6 %

Spreads: baseline 61.73–62.31 and 85.48–85.59; fused 71.42–71.70 and 91.70–92.22.

Combined with --cuda-graphs: 61.98 → 92.00 tok/s, +48.4 % over today's default, no change to model quality — identical 24-token prefix, stable across 3 runs on both jars.

On the batched-prefill PR you mentioned

Worth doing for the API surface, but I would not expect a large decode-side win on this model, and I would rather tell you that now than have you build it expecting one. A rough probe, single-token path, --max-tokens 1, warm:

prompt wall
280 words 7.34 s
1680 words 8.32 s

About 1 s for ~1400 extra words, i.e. roughly 0.5 ms per prompt token, against ~11–16 ms per token during decode. So prefill on this path is already far cheaper per token than decode — nothing like the ratio you see on dense models where batched prefill is the headline. Treat that as a rough probe rather than a measurement: model load dominates the wall clock, and I did not instrument prefill separately.

So my suggested ordering, for whatever it is worth:

  1. This PR, plus a note about --cuda-graphs — biggest win, zero code.
  2. The routed-expert fusion as its own PR — +15 % default, +7.6 % with graphs.
  3. The non-determinism at longer generations (three different outputs from three identical runs, with and without graphs, on the unmodified branch) — this one I would fix before the fusion, since it makes every subsequent A/B harder to trust.
  4. PREFILL_DECODE for QWEN_2_MOE — for API completeness and prompt-heavy workloads, with the caveat above about expected gain.

Happy to open the fusion PR myself if you would rather not carry it, just say which you prefer.

@mikepapadim

Copy link
Copy Markdown
Member

Opened it as offered: MRPRESIDENT66#1, targeting your moe-single-token branch so it stacks on this PR rather than duplicating it — one commit on top of b227bc2, so the diff there is only the optimisation.

Merge, rewrite, or close it as you see fit; no attribution needed. If you would rather carry the change yourself, close it and cherry-pick 3940767 — that is equally good from my side.

Recap of the numbers, all interleaved run-by-run against your branch: 61.98 → 71.54 tok/s (+15.4 %) by default, 85.52 → 92.00 (+7.6 %) with --cuda-graphs, identical 24-token output.

The one thing I would sequence ahead of it is the non-determinism at longer generations, since it makes every later comparison harder to trust.

@CLAassistant

CLAassistant commented Aug 6, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@MRPRESIDENT66

Copy link
Copy Markdown
Contributor Author

@mikepapadim Thanks for your work on this! I found the cause of the inconsistent output in longer generations. The RMSNorm reduction used multiple work-groups to calculate the sum of squares, so work-group 0 could read partial results before the other groups had finished. I have now changed it to use a single work-group and pushed the fix.

@MRPRESIDENT66

MRPRESIDENT66 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@mikepapadim I’ll keep the routed-expert fusion separate. Once #144 is merged, I’ll create a new branch from the updated main, cherry-pick your commit, test it, and submit it as a follow-up PR.

@mikepapadim
mikepapadim merged commit 9019a50 into beehive-lab:main Aug 6, 2026
4 of 8 checks passed
orionpapadakis added a commit that referenced this pull request Aug 28, 2026
Step 0 of the agreed merge sequence: land the three PRs on main in roadmap order and
absorb them here one at a time, so that each absorb is one merge, one gate run, and one
PR to blame. main had moved 32 commits ahead in the meantime, including an entire new
family — Qwen1.5/Qwen2-MoE, from #144, #145 and #147 — so it is merged on its own first.
Had it ridden in with #129, the very first absorb would have carried two things at once
and lost the attribution the sequence exists to preserve.

Conflicts

- pom.xml — took main's revision 1.0.0; kept the TornadoVM 5.2.0 pin Phase 0 chose, and
  its comment. main is on 5.0.0, which predates BFloat16Array and the deterministic
  kernel source this branch's gates rely on.
- InferenceCoreBatchPrefillDecode — kept both sides. Ours writes the real token count to
  batchStartPosHolder[1] so padding rows do not write KV; main's sets Qwen2MoEState's own
  activeBatchSizeHolder. They are parallel mechanisms for the same invariant; unifying
  them belongs to M6, which changes KV ownership anyway.

Adapting the MoE family to M4/M5

It was written against main, so against the pre-M4 APIs and against no ArchUnit rules.

- Qwen2MoEStandardWeights, Qwen2MoETornadoWeights — GGMLType to DataType, getWeightType()
  to dataType() (T4.4, T4.6).
- Qwen2MoEModelLoader — tensor.* to format.* imports; DataTypeMapping.sourceType for the
  CPU weight set and materializedType(.., GPU) for the Tornado one (T4.2).
- Qwen2MoEProvider, its service-file line, and a GgufRecognition case on qwen2moe (T5.2,
  T5.3). Without these the family would not load at all on this branch: a ModelType entry
  stopped being what loads a model. The architecture string is taken from main's own
  detection code, not guessed.

One defect, one default

Qwen2MoEQ8_0FFNLayers called Qwen3Kernels.ropeRotationWithCacheCopy without the ropeTheta
argument this branch made mandatory — it was written against the version with a hardcoded
RoPE base, the defect recorded in review/model-family-sweep.md that produced wrong output
on DeepSeek. Now passes config.ropeTheta(). The MoE batch kernel was already correct on
both rules: theta as a parameter, and an activeBatchSizeHolder early-return for padding
rows.

llama.quantizeActivation (main commit 25dd61b) now defaults to false. It was default-on
and it is strictly dominated:

  Llama-3.2-1B-Q8_0, CPU, 128 tokens, seed 42, 5 interleaved runs each
    off: median 32.86 tok/s (31.55-33.40)
    on : median  7.54 tok/s ( 7.32- 7.77)   4.4x slower

dotQ8Activation is a scalar loop and returns before the USE_VECTOR_API branch, so enabling
it trades a SIMD float dot for a scalar int8 one. Quantizing to int8 does cut arithmetic,
but not enough to pay for losing vectorization. A ByteVector implementation could plausibly
win — that is why llama.cpp does this — but this is not one. It also cost CPU/GPU parity,
which the GPU does not quantize activations to match: 64.08% of elements outside budget
with it on, 0% with it off. Output stayed semantically intact (0/64 argmax disagreements),
so this is a numerics divergence rather than a defect — but not one a default should carry.
The flag still selects llama.cpp numerics for anyone comparing against llama.cpp.

Worth reporting upstream: this shipped default-on, and main has neither a parity gate nor
a CPU benchmark gate, which is why a 4.4x CPU regression went in unnoticed.

Allowlists

Six entries, across Rules 1, 2, 4 and 5, all for the MoE family. Each is the exact
counterpart of an entry already listed for Qwen2 — same rule, same package, same reason,
same removing milestone. Policy item 3 requires an ADR or a recorded maintainer decision
before a list may grow, so ADR-010 records the decision and bounds when a later merge may
do the same: only for a violation whose kind already exists on that rule's list for a
sibling family. #120 adds a family the same way and is expected to need it.

Gates

  mvn test                 175/175
  mvn test -Paccel-tests    14/14 — goldens bit-identical, CPU/GPU parity,
                                    compiled-program identity, provider load parity
  make test-scripts         OK

Goldens did not move, which is the point. They are GPU-only captures, so nothing but the
parity test could have caught the Q8_0 change; that is worth remembering when reading a
green suite on the next three merges.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants