Skip to content

perf(purejava): let the hidden-state prefill reach the batched path (15.5x) - #199

Open
bsbodden wants to merge 2 commits into
mainfrom
perf/batched-hidden-state-prefill
Open

bsbodden wants to merge 2 commits into
mainfrom
perf/batched-hidden-state-prefill

Conversation

@bsbodden

Copy link
Copy Markdown
Member

What was wrong

prefill has consulted the batched execution plan since it was written. prefillHiddenState never did.

// LlamaForwardPass:817  prefill(Session, …)          → HAS the branch
if (batchedPrefill && tokens.length > 1) { return prefillSessionBatched(session, tokens); }

// LlamaForwardPass:851  prefillHiddenState(Session, …) → NO branch, token-by-token loop

Both variants were sequential, so every hidden-state consumer paid decode rates for work the batched path already knew how to do — embeddings through GgufEmbeddingBackend, and any consumer reading a state off a prompt.

Measured

Granite 4.1 3B Q4_K_M (SHA-256 662b0626…, catalog-verified), Hetzner ccx33, 8 vCPU AMD EPYC-Milan, AVX2, Temurin 25.0.4.1, rust-ffm kernels. Identical input, same process.

prefillHiddenState 128 tokens rate 512 tokens rate
before 66,538 ms 1.9 tok/s
after 4,283 ms 29.9 tok/s 13,973 ms 36.6 tok/s
prefill (logits, batched), reference 4,819 ms 26.6 tok/s 13,444 ms 38.1 tok/s

15.5x at 128 tokens. It now runs slightly faster than the logits route at that length and reaches parity by 512 — the expected shape, since it skips the vocabulary projection on every row but the last.

The hypothesis that was refuted

vectors.gguf.pollMillis was the leading explanation, since vectors 0.1.22 warns that a caller running its own pool beside the persistent executor should set it to zero. Ablated in both directions:

pollMillis 64-token hidden prefill
0 34,385 ms (1.86 tok/s)
5 (default) 34,198 ms (1.87 tok/s)

Identical within noise. It explains none of the gap. The cause is memory bandwidth: a single-token forward streams the whole ~2 GB of Q4_K_M weights to produce one row, where a batched block streams it once for the lot.

The change

Both variants advance every position but the last through the existing qualified batched path, then take one ordinary step for the final token so it still yields a hidden state rather than a vocabulary projection. The batched call's logits are discarded; what is wanted is the cache it leaves. No new machinery. The final full sweep remains as a floor a deeper change could remove.

Tests

Taking the branch is asserted through a counter rather than inferred from timing, and in both directions — a prompt with nothing to batch must leave the counter alone, because a branch never taken looks exactly like a branch that did not matter.

That counter earned itself immediately. The first version of these tests used an all-F32 nano fixture, and F32 is absent from TensorOps.supportsBatchedMatmul, so the fixture could not batch at all and the three equivalence tests were passing vacuously — comparing the sequential path against itself. Only the counter assertion failed. The fixture now uses Q8_0 projections at the dimensions Q8_0 alignment requires.

Also pinned: hidden state, key/value cache contents, and the next generated token all match running the prompt one token at a time, within the repository's existing SIMD_REDUCTION_TOLERANCE.

The execution plan is pinned rather than defaulted, since defaultPlan consults RuntimeFingerprint and a test resting on it would assert one thing on a workstation and another on a benchmark host.

681 backend-java tests pass, no regressions. Spotless and SpotBugs clean.

🤖 Generated with Claude Code

bsbodden and others added 2 commits September 20, 2026 03:53
prefill has consulted batchedPrefill since it was written;
prefillHiddenState never did. Both of its variants ran the whole prompt one
token at a time, so every hidden-state consumer paid decode rates for work
the batched path already knew how to do -- embeddings through
GgufEmbeddingBackend, and any head reading a state.

Measured on Granite 4.1 3B Q4_K_M on an 8-vCPU EPYC-Milan host, identical
input, same process, same kernels: prefill returning logits ran at 26.6
tok/s, prefillHiddenState at 1.9 tok/s. Fourteen times, and the
hidden-state route came out slower than decode despite skipping the
vocabulary projection. The poll budget was the leading hypothesis and was
ablated in both directions -- 1.86 tok/s at pollMillis=0 against 1.87 at the
default -- so it explains none of it. The cause is memory bandwidth: a
single-token forward streams the whole weight set to produce one row, where
a batched block streams it once for the lot.

Both variants now advance every position but the last through the existing
qualified batched path, then take one ordinary step for the final token so
it still yields a hidden state rather than a vocabulary projection. The
batched call's logits are discarded; what is wanted is the cache it leaves.
Nothing else changes, and the final full sweep remains as a floor that a
deeper change could remove.

Taking the branch is asserted through a counter rather than inferred from
timing, and asserted in both directions, because a branch never taken looks
exactly like a branch that did not matter. That counter earned itself
immediately: with F32 projections the nano fixture cannot batch at all --
F32 is absent from TensorOps.supportsBatchedMatmul -- so the three
equivalence tests were passing vacuously, comparing sequential against
sequential. The fixture now uses Q8_0 projections, at the dimensions Q8_0
alignment requires, and the equivalence assertions mean something.

The plan is pinned rather than defaulted. defaultPlan consults
RuntimeFingerprint, so a test resting on it would assert one thing on a
workstation and another on a benchmark host.

681 backend-java tests pass with no regressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen and a half times on the call every hidden-state consumer makes, and
the entry says which consumers those are, since embeddings were paying it as
well as the obvious ones.

It also records the hypothesis that was refuted rather than only the one
that held. The poll budget was the leading explanation and was ablated in
both directions before being discarded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant