Repository navigation
[Bugfix][Conductor] Clamp query match to the queried token count - #4164
Conversation
Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
There was a problem hiding this comment.
🟡 Changes recommended
Add sglang_bigram regression coverage and align the remaining query documentation.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes over-reported SGLang prefix-cache matches by clamping reusable token counts to the queried prompt length.
Changes:
- Adds clamped GPU, CPU, and disk match accounting.
- Adds SGLang partial-block regression tests.
- Updates conductor design documentation.
File summaries
| File | Summary |
|---|---|
mooncake-conductor/tests/prefix_indexer_test.cpp |
Adds partial-block query coverage. |
mooncake-conductor/src/prefixindex/prefix_indexer.cpp |
Clamps matched token counts. |
docs/source/design/conductor/conductor-architecture-design.md |
Documents strategy-specific partial-block handling. |
Review details
Suppressed comments (1)
mooncake-conductor/tests/prefix_indexer_test.cpp:650
- The new query coverage only registers
sglang;sglang_bigramhas a different block-count formula (ceil((n - 1) / block_size)) but also reaches this accounting path. Add a regression query with a non-multiple prompt to verify its conservative partial-tail result, since the existing bigram test only checks hashing and would not catch a token-accounting regression.
TEST(Query, TrailingPartialBlockNeverReportsMoreThanPromptTokens) {
PrefixCacheTable table;
auto registration = Registration();
registration.profile = SglangProfile();
RegisterOrFail(table, registration);
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| int64_t MatchedTokens(size_t block_count, int64_t block_size, | ||
| size_t queried_tokens) { | ||
| const int64_t queried = | ||
| queried_tokens > | ||
| static_cast<size_t>(std::numeric_limits<int64_t>::max()) |
| The vLLM strategy computes complete-block prefix hashes from token IDs and | ||
| ignores trailing partial blocks during `/query`. The SGLang strategies also hash | ||
| the trailing partial block, so a match that reaches it is reported as the number | ||
| of tokens the request actually carries, never as a whole block. |
|
Mechanism verified by hand against the diff, no build needed: pre-fix, a query whose chain ends in a partial block reports |
|
@pjdurden
You can simply select the bound once in PrefixCacheTable::Query(): size_t queried_tokens = token_ids.size(); Still, with 40 raw tokens and a block size of 16, a full bigram match now should report 39 logical positions. |
…ount The bigram chains hash token pairs: SglangHashChain builds block_count_ over token_ids.size() - 1, and each block hashes up to that logical length. So a query of n raw tokens covers n - 1 reusable KV positions, not n. Clamping those matches at the raw token count therefore still reports one position past what any indexed block holds. Select the bound once in Query() instead, so the bigram strategy caps at n - 1 while the block count keeps bounding it from the other side. Adds two regression tests: a full 40-token bigram match over a 16-token block size now reports 39 rather than 40 or the 48 a whole-block count would give, and a run that stops at the last whole block still reports 32, confirming the clamp does not pull whole-block matches down. Signed-off-by: pjdurden <prajjwalchittori1@gmail.com> Signed-off-by: pjdurden <adobearakap@gmail.com>
|
@Misak2333 thanks for verifying part 1 against real before/after runs, and you are right on part 2. Pushed as I checked the bound against the chain construction rather than taking it on faith, and it holds. In Took your placement, selecting the bound once in const size_t queried_tokens =
(state->profile.strategy == "sglang_bigram" && !token_ids.empty())
? token_ids.size() - 1
: token_ids.size();I used Working through the numbers with the two formulas as written:
which matches the 39 you predicted for the 40-token case, and degrades sanely at Added two tests. One caveat I want to be straight about: I could not build this locally. The conductor's standalone build needs glog, JsonCpp, OpenSSL and msgpack headers, and I have no way to install them on this machine, so the table above is the arithmetic from the two formulas rather than output from a run. The logic and the expected values should be right, but the numbers in those two tests are worth a look from CI or from you before this merges. |
LGTM, thanks for your fixs. |
Issue #4153 — incorrect external prefix-cache reuse from partial hash hits
1. Root cause
PrefixCacheTable::Queryconverts a matched block count into a token countwith
TokensForBlocks(cursor, context.block_size), i.e. it assumes every blockin the query's hash chain covers exactly
block_sizetokens.That assumption holds for the vLLM chain, whose
BlockCount()istoken_ids.size() / block_size(complete blocks only). It does not hold forthe SGLang chains:
SglangHashChaindeliberately mirrors the engine and endsits chain with a partial block —
(see
SglangHashChaininmooncake-conductor/src/prefixindex/hash_strategy.cppand the existing
SglangHashChain.IncludesPartialFinalBlocktest).So whenever a query's prompt length is not a multiple of
block_sizeandthe trailing partial block is present in the index,
Queryreportedfor a prompt of only
ntokens. Concretely, withblock_size = 16and a40-token prompt the conductor answered
longest_match_tokens = 48— 8 tokensthat no indexed block ever covered, and that lie past the end of the prompt
altogether.
This is the mechanism described in the issue: the hit is real at the hash
granularity, but the reported reusable length runs past the region the hash
actually pinned down. A router or scheduler that trusts that length skips
recomputing KV slots whose contents belong to whatever request wrote them,
which surfaces as out-of-order / off-topic generation. The over-report grows
with
block_size, which is why it only became visible on a deployment wherehash blocks and attention blocks were not aligned, and why forcing the two to
be equal (so no partial tail is ever hit) made it disappear.
The three tiers are all affected, because
gpu,cpuanddiskshare onecumulative cursor and all three go through the same conversion.
2. The fix and why
mooncake-conductor/src/prefixindex/prefix_indexer.cppAdded a small
MatchedTokens(block_count, block_size, queried_tokens)helpernext to
TokensForBlocks, and used it for thegpu/cpu/disknumbers.It is
TokensForBlocksclamped to the number of tokens the query actuallycarries.
Why a clamp is exactly right rather than merely safe:
block_count * block_size <= nby construction, so theclamp never fires. Behaviour is bit-for-bit unchanged.
[0, c)covermin(c * block_size, n)tokens, sothe clamp reproduces the true covered length, including the full-match case
where the answer should be the whole prompt.
n - 1bigram positions. Matchingall of them pins every one of the
ntokens, so clamping tonis correct;a partial run still reports
c * block_size, which is one token short ofwhat the bigram overlap strictly proves and therefore conservative.
Under-reporting is a missed cache hit; over-reporting is silent corruption, so
where the two chains disagree the code now rounds toward the safe side.
The alternative — dropping the partial block from the SGLang chain entirely —
was rejected: including it is a deliberate, tested property that mirrors what
the engine publishes, and removing it would throw away genuine hits. The bug is
in the accounting, not in the hashing.
The design doc claimed
/query"ignores trailing partial blocks", which wasalready untrue for the SGLang strategies before this change. That sentence now
states the per-strategy behaviour and the token-count rule.
3. Files changed
mooncake-conductor/src/prefixindex/prefix_indexer.cppMatchedTokenshelper;Queryuses it for the gpu/cpu/disk token counts.mooncake-conductor/tests/prefix_indexer_test.cppSglangProfile()/SglangHashes()helpers plus two regression tests.docs/source/design/conductor/conductor-architecture-design.md/query.4. Risk / uncertainty
std::min, and it canonly lower a reported number. For the vLLM strategy it is provably a no-op
(
block_count = floor(n / block_size)), so existing vLLM deployments and all31 pre-existing conductor unit tests are unaffected.
rounded up to a block boundary now report the real prompt length. That can
change which instance a cache-aware router picks when two candidates are
within one block of each other. This is the intended correction.
c * block_sizerather thanc * block_size + 1). Deliberate: it can only cost a single token of reuse,never invent one. Worth a maintainer's confirmation if bigram partial-run
precision matters to them.
ValidateLayoutstillrequires
effective_block_size == context.block_size, so a publisher whoseattention block differs from its hash block is rejected at registration
rather than handled. If the conductor ever needs to index a engine whose
page size exceeds the hash unit, the match would additionally need rounding
down to the page boundary. Nothing in the current tree exercises that path.
are separate symptoms; this change does not touch them.
5. How it was verified
The container has no glog/gtest/OpenSSL dev packages, so the full CMake build
could not run. The affected translation units were instead compiled and
executed directly, against the real OpenSSL (
libcrypto.so.3, EVP path) sothe hash chain is genuine, with a stub header only for glog's two logging
macros:
Reproduced the bug. A driver registering an
sglangprofile withblock_size = 16, storing all 3 chain hashes of a 40-token prompt, thenquerying it. Against
HEAD'sprefix_indexer.cpp:48 reported tokens for a 40-token prompt — 8 phantom tokens.
Confirmed the fix. Same driver against the patched file:
Ran the real unit-test file.
prefix_indexer_test.cppwas linked againsta fused gtest and run:
HEADbuild with the new tests applied: 31 pass, 1 fail —Query.TrailingPartialBlockNeverReportsMoreThanPromptTokens.So the new test is a genuine regression guard, and no pre-existing conductor
test changed behaviour.
Formatting.
pre-commitis not installed here;clang-format(themooncake-code-formathook's tool) was run over both changed C++ files andproduced no further edits. Trailing whitespace and end-of-file newlines were
checked by hand on all three files.
make htmlwas not run for thedocs/ change — the Sphinx toolchain is not available offline; the edit is a
prose-only sentence replacement inside an existing paragraph, with no links,
directives, or toctree entries touched.