Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
prefillhas consulted the batched execution plan since it was written.prefillHiddenStatenever did.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.prefillHiddenStateprefill(logits, batched), reference15.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.pollMilliswas 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:pollMillisIdentical 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
defaultPlanconsultsRuntimeFingerprintand a test resting on it would assert one thing on a workstation and another on a benchmark host.681
backend-javatests pass, no regressions. Spotless and SpotBugs clean.🤖 Generated with Claude Code