NVIDIA / NVIDIA/TensorRT-LLM

KV-cache-aware router never matches LoRA or salted requests: lora_id is dropped and cache_salt is hashed with a different algorithm than the engine

Open
#18,156 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Disaggregated serving
Dominant language
Python
Stars
14.7k
Forks
2.8k
Avg merge
2d 23h
Merged PRs (30d)
489

Description

System Info
  • TensorRT-LLM: main @ d9329fb8; behavior is CPU-reproducible (no GPU, no bindings, ~0.1s), script below
  • Affects trtllm-serve disaggregated with KvCacheAwareRouter (workers publishing KV events via event_buffer_max_size > 0)
Reproduction

The KV-aware router scores workers by exact set membership of block hashes it recomputes from the request (tensorrt_llm/serve/router.py, matched_tokens, break on first miss), against hashes the workers publish from their radix trees (tensorrt_llm/runtime/kv_cache_manager_v2/_event_manager.py:653-665). Two request dimensions never survive that recomputation:

  1. lora_id is dropped entirely. hash_v1_block_key has a lora_task_id parameter (tensorrt_llm/runtime/kv_cache_hash.py:56-72) and the worker event hash passes it, but serve/router_utils.py never does; grep -i lora tensorrt_llm/serve/router_utils.py tensorrt_llm/serve/router.py has zero hits. There is a _get_request_cache_salt_id (router_utils.py:408) and no lora equivalent.

  2. cache_salt is hashed with a different algorithm on each side: the router uses blake3 (runtime/kv_cache_hash.py:48, get_cache_salt_id), the V2 engine uses sha256 (_torch/pyexecutor/kv_cache_manager_v2.py:3962, _derive_reuse_salt), and the V1 C++ hasher is a third (std::hash<std::string>, cpp/tensorrt_llm/batch_manager/blockKey.cpp:367). _derive_reuse_salt's docstring says it matches the C++ hashing; it matches neither side.

Because matching breaks on the first missed block, either mismatch gives match_count == 0 for the whole request: cache-aware routing silently degrades to load balancing for every LoRA request and every salted request. The router also collapses all adapters into one hash, so its internal prefix accounting collides adapters with each other.

CPU repro (no GPU, no bindings; reproduces both sides' hash chains from source):

repro_trtllm_kv_router_salt.py (CPU only, ~0.1s)
"""CPU repro: KV-cache-aware router vs V2 engine reuse-namespace mismatch.

No GPU, no CUDA, no compiled tensorrt_llm bindings. Uses only the pure-Python
hashing modules that both sides of the real system import.

Two independent partitioning dimensions are dropped/derived differently on the
router side vs the engine/event side:

  (1) lora_id  -- tensorrt_llm/serve/router_utils.py:159-170 (block_key_hasher)
                  and :174-179 / :333 (v2 hashers) never pass lora_task_id,
                  while tensorrt_llm/runtime/kv_cache_manager_v2/_event_manager.py:653-665
                  publishes event hashes WITH scope.lora_id.

  (2) cache_salt -> salt id
                  router : tensorrt_llm/runtime/kv_cache_hash.py:48  get_cache_salt_id  = blake3(s)[:8]
                  engine : tensorrt_llm/_torch/pyexecutor/kv_cache_manager_v2.py:3972
                           _derive_reuse_salt = sha256(s)[:8]
"""
import hashlib
import sys

import sys, types, pathlib, importlib
_ROOT = pathlib.Path("/home/oneknight/projects/oss/tensorrt-llm")
# Load the two pure-Python modules without executing kv_cache_manager_v2/__init__.py,
# which hard-imports the compiled C++ bindings (see _copy_engine.py:41-73).
sys.path.insert(0, str(_ROOT / "tensorrt_llm" / "runtime"))
_pkg = types.ModuleType("kv_cache_manager_v2")
_pkg.__path__ = [str(_ROOT / "tensorrt_llm" / "runtime" / "kv_cache_manager_v2")]
sys.modules["kv_cache_manager_v2"] = _pkg
from kv_cache_hash import get_cache_salt_id, hash_v1_block_key
from kv_cache_manager_v2._block_radix_tree import ReuseScope, RootBlock, Hasher

TPB = 4
TOKENS = list(range(1, 33))          # 32 prompt tokens
LORA = 7
SALT = "tenant-a"


def derive_reuse_salt(cache_salt):
    """Verbatim copy of KVCacheManagerV2._derive_reuse_salt (kv_cache_manager_v2.py:3962)."""
    if cache_salt is None:
        return None
    return int.from_bytes(hashlib.sha256(cache_salt.encode("utf-8")).digest()[:8], "little")


def blocks(tokens, tpb):
    """Both sides block the prompt the same way: exclude the final token."""
    return [tokens[t:min(t + tpb, len(tokens) - 1)]
            for t in range(0, len(tokens) - 1, tpb)]


def worker_v1_event_hashes(tokens, lora_id, salt_id):
    """_event_manager._v1_hash_from_radix_block chain (lines 606-621)."""
    out, parent = [], 0
    for blk in blocks(tokens, TPB):
        parent = hash_v1_block_key(blk, parent_hash=parent,
                                   lora_task_id=lora_id, cache_salt_id=salt_id)
        out.append(parent)
    return out


def router_v1_hashes(tokens, salt_id):
    """router_utils.block_key_hasher chain (lines 159-170, 346-357)."""
    out, parent = [], None
    for blk in blocks(tokens, TPB):
        parent = hash_v1_block_key(blk, parent_hash=0 if parent is None else parent,
                                   cache_salt_id=salt_id)   # <-- no lora_task_id
        out.append(parent)
    return out


def worker_v2_root(lora_id, salt_id):
    return RootBlock.make_key(ReuseScope(lora_id=lora_id, salt=salt_id))


def router_v2_root(salt_id):
    return RootBlock.make_key(ReuseScope(salt=salt_id))       # <-- router_utils.py:177/333


fails = []

def check(name, condition, detail):
    status = "MATCH  " if condition else "MISMATCH"
    print(f"  [{status}] {name}: {detail}")
    return condition


print("=" * 78)
print("A. baseline: no lora, no salt  (expected: router and worker agree)")
print("=" * 78)
w = worker_v1_event_hashes(TOKENS, None, None)
r = router_v1_hashes(TOKENS, None)
if not check("v1 hashes", w == r, f"worker[0]={w[0]}  router[0]={r[0]}"):
    fails.append("baseline")

print()
print("=" * 78)
print("B. LoRA request (lora_task_id=7, no salt)")
print("   engine partitions by lora_id; router never sends it")
print("=" * 78)
w = worker_v1_event_hashes(TOKENS, LORA, None)
r = router_v1_hashes(TOKENS, None)
overlap = set(w) & set(r)
if check("v1 hashes differ", w != r, f"worker[0]={w[0]}  router[0]={r[0]}"):
    pass
else:
    fails.append("B-differ")
if check("zero overlap -> router can never hit worker blocks",
         len(overlap) == 0, f"|overlap|={len(overlap)} of {len(w)} blocks"):
    pass
else:
    fails.append("B-overlap")

# collision side of the same drop: two DIFFERENT adapters look identical to the router
r_a = router_v1_hashes(TOKENS, None)
r_b = router_v1_hashes(TOKENS, None)
w_a = worker_v1_event_hashes(TOKENS, 7, None)
w_b = worker_v1_event_hashes(TOKENS, 8, None)
if not check("router collapses adapter 7 and adapter 8 into one hash",
             r_a == r_b and w_a != w_b,
             f"router same={r_a == r_b}, worker differs={w_a != w_b}"):
    fails.append("B-collision")

print()
print("=" * 78)
print("C. Salted request (cache_salt='tenant-a')")
print("   router salt id = blake3(s)[:8]   engine salt id = sha256(s)[:8]")
print("=" * 78)
router_salt = get_cache_salt_id(SALT)
engine_salt = derive_reuse_salt(SALT)
print(f"   get_cache_salt_id('{SALT}')  = {router_salt}")
print(f"   _derive_reuse_salt('{SALT}') = {engine_salt}")
if not check("salt ids differ", router_salt != engine_salt,
             f"blake3 {router_salt} != sha256 {engine_salt}"):
    fails.append("C-saltid")

w = worker_v1_event_hashes(TOKENS, None, engine_salt)
r = router_v1_hashes(TOKENS, router_salt)
overlap = set(w) & set(r)
if not check("zero overlap on v1 algo", len(overlap) == 0,
             f"|overlap|={len(overlap)} of {len(w)} blocks"):
    fails.append("C-v1")

wk = worker_v2_root(None, engine_salt)
rk = router_v2_root(router_salt)
if not check("v2 root keys differ -> whole chain diverges", wk != rk,
             f"worker root {wk.hex()[:16]}  router root {rk.hex()[:16]}"):
    fails.append("C-v2root")

print()
print("=" * 78)
print("D. control: if the router used the ENGINE's salt derivation, it matches")
print("=" * 78)
w = worker_v1_event_hashes(TOKENS, None, engine_salt)
r = router_v1_hashes(TOKENS, engine_salt)
if not check("v1 hashes agree once salt derivation is shared", w == r,
             f"worker[0]={w[0]}  router[0]={r[0]}"):
    fails.append("D")

print()
print("=" * 78)
print("RESULT:", "all expectations held" if not fails else f"UNEXPECTED: {fails}")
print("=" * 78)
sys.exit(1 if fails else 0)

Output on main @ d9329fb8: baseline matches; LoRA request: worker[0]=1405807694910447675 vs router[0]=924206229973855, 0/8 block overlap, adapters 7 and 8 collapse to one router hash; salted request: blake3 id 15947361407820284973 vs sha256 id 16248643848220682112, 0/8 overlap, v2 root keys diverge; control with a shared derivation matches again.

Expected behavior

The router recomputes the same block hashes the workers publish, for every dimension that partitions KV reuse, so cache-aware routing works for LoRA and salted traffic.

Actual behavior

Zero matches for any LoRA or salted request; the router falls back to load-balance scoring with no error or log line.

Additional notes
  • Provenance: the sha256 side was added 2026-07-02 by e1ea04901 (#15625) and never reconciled with the router's pre-existing blake3 derivation (23500b55c, #7106); the lora drop is older (router_utils.py has never referenced lora).
  • Why tests stay green: tests/unittest/disaggregated/test_router.py:530 pins salt-id parity against inputs/utils.get_cache_salt_id, which has no engine caller, so the test validates a derivation nothing uses.
  • Proposed fix (patch ready to follow as a PR if the direction is agreed): single source of truth for the salt id in runtime/kv_cache_hash.py with the router moving to the engine's derivation (changing the engine side would change the on-disk/cross-process KV key space); thread lora_id through block_key_hasher / ReuseScope next to the existing salt plumbing, skipping the native fast path when set, exactly as done for salt; re-pin the oracle test against KVCacheManagerV2._derive_reuse_salt and add a router-vs-event-manager round-trip test over {no salt, salt} x {no lora, lora} (the repro above is that test minus the pytest wrapper).

Happy to open the PR; the repro doubles as the regression test.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with tensorrt_llm/serve/router_utils.py and router.py, then compare their hash inputs with runtime/kv_cache_manager_v2/_event_manager.py and kv_cache_manager_v2.py. Run the CPU reproduction and inspect tests/unittest/disaggregated/test_router.py:530. Done means router and worker hashes agree for salted and LoRA requests, with regression coverage for both dimensions.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp, python
Domain
backend, distributed-systems, testing-qa
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.