Prefix Caching: Wiederverwendung von KV-Blöcken über Anfragen hinweg
Verantwortung
Viele Anfragen teilen sich einen identischen Prefix: System-Prompt, Chat-Template, Funktionsdefinitionen, Few-Shot-Beispiele (few-shot examples). Die K/V dieser Tokens sind bereits berechnet; wenn die nächste Anfrage sie direkt wiederverwenden kann, entfällt ein ganzer Prefill-Abschnitt. Genau das macht Prefix-Caching (prefix caching): Jeder KV-Block erhält über einen «Content-Hash» einen Fingerabdruck; bei einer neuen Anfrage wird der Hash zuerst nachgeschlagen, und bei Treffer wird der bestehende Block referenziert statt neu berechnet.
Einschalter ist CacheConfig.enable_prefix_caching, standardmäßig True(cache.py:93). EngineCore.__init__ konstruiert bei enable_prefix_caching oder vorhandenem KV-Connector einen request_block_hasher(core.py:210-219). Das ist im Wesentlichen eine von get_request_block_hasher(kv_cache_utils.py:673) zurückgegebene Closure, die die Token-Sequenz der Anfrage nach hash_block_size in Segmente zerlegt und verkettet hasht. Der Hash jedes Blocks füttert «Hash des Eltern-Blocks + Token-IDs des aktuellen Blocks + extra_keys (LoRA / multimodal)» in die Hash-Funktion(kv_cache_utils.py:577-604); der Fingerabdruck eines Blocks ist somit der Fingerabdruck des gesamten Prefix bis zu diesem Block.
KVCacheManager delegiert die Treffer-Abfrage an KVCacheCoordinator.find_longest_cache_hit(kv_cache_coordinator.py:364), und der Coordinator reicht sie an den Coordinator des einzelnen Typs weiter. Bei Treffer gibt allocate_slots diese Blöcke zurück und reserviert nur für den nicht getroffenen Teil neue Blöcke. Scheduler.schedule ruft bei jeder Anfrage zuerst get_computed_blocks(kv_cache_manager.py:206) auf und reduziert bei Treffern das Token-Budget.
Entwurfsmotivation
- Wiederverwendung auf Block- statt Token-Ebene: Der KV-Cache (KV cache) ist nach PagedAttention-Blöcken aufgeteilt; folglich ist der Hash ebenfalls blockweise, mit gröberer Granularität und schnellem Lookup.
- Verkettetes Hashing sichert Prefix-Semantik:
hash_block_tokens(parent_hash, tokens, extra_keys)(kv_cache_utils.py:577) macht den Fingerabdruck jedes Blocks implizit vom gesamten Prefix abhängig; ein Treffer mit «Mitte unterschiedlich, Ende gleich» ist ausgeschlossen. - Mehrere Hash-Algorithmen wählbar:
prefix_caching_hash_algounterstütztsha256/sha256_cbor/xxhash/xxhash_cbor(cache.py:95-110); xxhash ist schneller, aber nicht kryptografisch sicher — in Multi-Tenant-Szenarien ist Vorsicht geboten. - Deaktivierungspfad mit eigenem Coordinator:
KVCacheCoordinatorNoPrefixCache(kv_cache_coordinator.py:377) lässtfind_longest_cache_hitdirekt((), 0)zurückgeben und verhindert Hash-Berechnungen, wenn enable_caching=False ist. - Multimodal / LoRA extra_keys:
generate_block_hash_extra_keys(kv_cache_utils.py:714) nutzt multimodale Eingaben und die LoRA-ID als zusätzliche Hash-Schlüssel, damit unterschiedliche Bilder nicht denselben Block-Slot belegen. - Nicht-kausale Attention-Schicht erzwungen aus:
EngineCore._initialize_kv_cachessetzt beinon_causal=Truedasenable_prefix_cachingaufFalse(core.py:255-269), da die bidirektionale Attention eines Prefix LM die Treffer-Semantik des Prefix-Cachings (prefix caching) durcheinanderbringen würde. - Prefix-Cache-Lesen überspringen:
Request.get_skip_reading_prefix_cache(request.py:266-277) erlaubt Prompt-Logprobs-/Pooling-All-Anfragen, den Prefix explizit nicht zu lesen, weil sie vollständig neu berechnet werden müssen. - Watermark gegen Thrashing:
KVCacheManager.watermark_blocks(kv_cache_manager.py:164-167) hält eine kleine Zahl freier Blöcke reserviert, um häufige Preemption zu vermeiden.
Schlüsseldateien
enable_prefix_caching:93— Standard True; schaltet Prefix-Caching beim Start ein.prefix_caching_hash_algo:95— Eines aussha256/sha256_cbor/xxhash/xxhash_cbor.EngineCore constructs request_block_hasher:210-219— Wählt Hash-Funktion +init_none_hash+get_request_block_hasher.non-causal attention forced off:255-269— Bei Prefix LM / bidirektionaler Attention wirdenable_prefix_caching = False.Request.update_block_hashes:237-240— Ruft_block_hasher(self)auf, um für neu gefüllte Blöcke den Hash zu berechnen.get_skip_reading_prefix_cache:266-277— Prompt-Logprobs / Pooling überspringen Treffer explizit.hash_block_tokens:577— Verkettetes Hashing:hash_function((parent_hash, token_ids_tuple, extra_keys)).get_request_block_hasher:673— Gibt eine Closure zurück, die nur volle Blöcke hasht und Eltern-Hashes verkettet.get_computed_blocks:206— Einstieg: Anfrage → längsten Treffer suchen →(KVCacheBlocks, num_computed_tokens)zurückgeben.allocate_slots:248— Treffer-Teil wiederverwenden; für nicht getroffenen Teil neue Blöcke aus demBlockPoolholen.KVCacheCoordinatorNoPrefixCache:377— Coordinator-Stub für den Deaktivierungspfad.UnitaryKVCacheCoordinator.find_longest_cache_hit:480— Treffer-Abfrage für Modelle mit einer einzelnen KV-Gruppe.Scheduler calls get_computed_blocks:724-726— Beim Schedulen Trefferanzahl abholen, umnum_new_tokenszu reduzieren.
Datenfluss
Wenn eine Anfrage kommt, ruft Scheduler zuerst get_computed_blocks auf; intern läuft über den Coordinator find_longest_cache_hit und fragt für jeden Eintrag in block_hashes beim BlockPool nach, ob bereits ein Block mit gleichem Hash existiert:
# vllm/v1/core/kv_cache_manager.py L206-L246
def get_computed_blocks(self, request: Request) -> tuple[KVCacheBlocks, int]:
"""Get the computed (cached) blocks for the request.
Note that the computed blocks must be full.
"""
# We skip finding the prefix cache hit when prefix caching is
# disabled or the request is marked as skipping kv cache read
# (which happens when the request requires prompt logprobs
# or calls a pooling model with all pooling).
if not self.enable_caching or request.skip_reading_prefix_cache:
return self.empty_kv_cache_blocks, 0
# NOTE: When all tokens hit the cache, we must recompute the last token
# to obtain logits. Thus, set max_cache_hit_length to prompt_length - 1.
# This can trigger recomputation of an entire block, rather than just
# the single last token, because allocate_slots() requires
# num_computed_tokens to be block-size aligned. Removing this limitation
# could slightly improve performance in the future.
max_cache_hit_length = request.num_tokens - 1
computed_blocks, num_new_computed_tokens = (
self.coordinator.find_longest_cache_hit(
request.block_hashes, max_cache_hit_length
)
)
if self.log_stats:
assert self.prefix_cache_stats is not None
self.prefix_cache_stats.record(
num_tokens=request.num_tokens,
num_hits=num_new_computed_tokens,
preempted=request.num_preemptions > 0,
)
return self.create_kv_cache_blocks(computed_blocks), num_new_computed_tokensDer Hash jedes Blocks wird in update_block_hashes der Anfrage selbst berechnet; request_block_hasher ist eine Closure, die nur volle Blöcke hasht und Eltern-Hashes verkettet:
# vllm/v1/core/kv_cache_utils.py L687-L728
def request_block_hasher(request: Request) -> list[BlockHash]:
start_token_idx = len(request.block_hashes) * hash_block_size
num_tokens = request.num_tokens
if start_token_idx + hash_block_size > num_tokens:
# Early stop when there no new full blocks created.
return []
curr_mm_idx = 0
if start_token_idx > 0:
# Set curr_mm_idx = -1 to indicate the last mm input.
# Note that since we reach to this branch only when the block is
# completed with generated tokens, we only need to consider the
# last mm input.
curr_mm_idx = -1
prev_block_hash_value = (
request.block_hashes[-1] if request.block_hashes else None
)
new_block_hashes: list[BlockHash] = []
while True:
end_token_idx = start_token_idx + hash_block_size
if end_token_idx > num_tokens:
# We only hash full blocks
break
# MM and LoRA requests need extra keys for block-hash computation.
extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
request, start_token_idx, end_token_idx, curr_mm_idx
)
# Compute the hash of the current block
block_tokens = request.all_token_ids[start_token_idx:end_token_idx]
block_hash = hash_block_tokens(
caching_hash_fn, prev_block_hash_value, block_tokens, extra_keys
)
new_block_hashes.append(block_hash)
start_token_idx += hash_block_size
prev_block_hash_value = block_hash
return new_block_hashesNachdem Scheduler die Trefferanzahl erhalten hat, zieht er die Treffer von num_new_tokens ab; allocate_slots holt aus dem Block-Pool nur die nicht getroffenen Blöcke. Der Referenzzähler getroffener Blöcke wird um 1 erhöht, die Tokens werden nicht neu berechnet.
Grenzen und Fehler
- Nicht-kausale Attention deaktiviert Prefix-Caching erzwungen:
EngineCore._initialize_kv_cachessetzt beinon_causal=Trueenable_prefix_caching = False(core.py:265-269), sonst würde bei bidirektionaler Attention ein Prefix-Treffer falsches KV lesen. - Prompt-Logprobs / Pooling überspringen Treffer: Wenn
Request.get_skip_reading_prefix_cacheTrue liefert, gibtget_computed_blocksdirekt leer zurück(kv_cache_manager.py:222-223), weil in diesen Szenarien das KV des Prompts neu berechnet werden muss. - Volltreffer erfordert Neuberechnung des letzten Tokens:
max_cache_hit_length = request.num_tokens - 1(kv_cache_manager.py:231), da sonst keine Logits zum Sammeln vorhanden sind. - xxhash ist nicht kryptografisch sicher:
prefix_caching_hash_algo = "xxhash"ist schneller, hat aber theoretisch eine höhere Kollisionswahrscheinlichkeit und kann in Multi-Tenant-Szenarien Prefix-Informationen leaken(cache.py:101-108). - Deaktivierungspfad mit eigenem Stub:
KVCacheCoordinatorNoPrefixCache.find_longest_cache_hitliefert((), 0)(kv_cache_coordinator.py:416-424), damit beienable_caching=Falsekeine Hash-Berechnung verschwendet wird. - BlockPool-LRU-Eviction: Der Referenzzähler wiederverwendeter Blöcke wird um 1 erhöht und erst am Anfrageende dekrementiert; Blöcke, die langfristig niemand referenziert, werden per LRU für neue Anfragen recycelt(
kv_cache_manager.py:508).
Zusammenfassung
Prefix-Caching (prefix caching) ist einer der Schlüsselmechanismen für vLLMs hohen Durchsatz: Der KV-Block wird verkettet über «Eltern-Hash + Token-IDs + extra_keys» gehasht und berechnete Ergebnisse desselben Prefix werden über Anfragen hinweg wiederverwendet. EngineCore entscheidet bei der Initialisierung anhand von enable_prefix_caching, ob ein Hasher konstruiert wird; Scheduler.schedule ruft jedes Mal get_computed_blocks auf, um Treffer zu finden. Die wiederverwendeten Blöcke werden in /kv-cache/kv-cache-manager verwaltet; BlockPool-Details siehe /kv-cache/coordinator-blockpool; die Verdrahtung durch den Engine-Kern siehe /engine/engine-core.