fix: stop activation KV cache from growing every turn - #2359
Conversation
Generation appends new K/V tensors to the DynamicCache object it receives, but the stored activation cache was handed to the model by reference, so every chat turn permanently grew the store (and the re-dumped memory file). Make stored caches read-only by construction: - add clone_dynamic_cache() in memories/activation/kv.py, compatible with both the legacy key_cache/value_cache structure and the newer layers structure (transformers >= 4.56); - _concat_caches now returns a clone in the single-cache case instead of the stored object; - HFLLM.generate / generate_stream clone the incoming past_key_values once at the boundary, which fixes all four call sites (mem_os/core.py, mem_os/main.py, mem_chat/simple.py, scheduler analyzer) without changing them.
There was a problem hiding this comment.
🟡 Changes recommended
Layered cache cloning may remain incorrect on transformers ≥4.56, and streaming generation lacks regression coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes activation KV-cache aliasing that causes stored caches to grow across turns.
Changes:
- Adds cache cloning for legacy and layered cache formats.
- Clones caches at retrieval and HF generation boundaries.
- Adds cache-isolation regression tests.
File summaries
| File | Summary |
|---|---|
tests/memories/activation/test_kv.py |
Tests cloning and merge isolation. |
tests/llms/test_hf.py |
Tests that generation does not mutate caller caches. |
src/memos/memories/activation/kv.py |
Adds cache cloning. Critical (3 votes): layered cache cloning may reset required layer state on transformers ≥4.56; clone layer state and test a real update. |
src/memos/llms/hf.py |
Clones caches before generation. Nit (1 vote): add regression coverage for streaming generation. |
Review details
Suppressed comments (2)
src/memos/llms/hf.py:116
- The new
generate_streamboundary is not covered by the regression test, which only exercisesgenerate. A future change could preserve the caller cache for non-streaming generation while reintroducing aliasing on the streaming path; add a streaming test that performs the same in-place K/V append and checks the original cache length.
from memos.memories.activation.kv import clone_dynamic_cache
yield from self._generate_with_cache_stream(
prompt, clone_dynamic_cache(past_key_values)
)
src/memos/memories/activation/kv.py:279
- The new-layer branch only copies
keys/values, but this codebase already supports layer objects that expose the alternativekey_cache/value_cachenames inmove_dynamic_cache_htod. For those caches, the cloned layer is appended without any tensors, so generation receives an empty cache or fails instead of using the stored activation memory. Copy both attribute variants.
if getattr(layer, "keys", None) is not None:
new_layer.keys = layer.keys.clone()
new_layer.values = layer.values.clone()
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| new_layer = type(layer)() | ||
| if getattr(layer, "keys", None) is not None: | ||
| new_layer.keys = layer.keys.clone() | ||
| new_layer.values = layer.values.clone() | ||
| cloned.layers.append(new_layer) |
🤖 Open Code ReviewTarget: PR #2359 🔍 OpenCodeReview found 7 issue(s) in this PR. 1.
|
|
- Use deterministic non-EOS argmax logits in the no-mutation regression mock so sampling cannot end the loop early (~1% flake), and fall back to positional args for past_key_values. - Assert tensor-storage independence via in-place fill_ mutations, so a clone that shares storage is caught, not just slot rebinding. - Guard keys/values independently in clone_dynamic_cache legacy-layer path (keys without values no longer raises AttributeError) and cover it with a dedicated test.
|
Thanks for the automated review — all 4 findings are addressed in 2808420:
All 16 tests in the two affected files pass locally. |
|
- clone_dynamic_cache: also copy per-layer key_cache/value_cache attributes (some transformers versions carry that shape instead of keys/values, mirroring move_dynamic_cache_htod), with a dedicated storage-independence test. - test_hf mock: drop the unreachable positional fallback for past_key_values and document why .get() stays. - get_cache independence test: add an in-place fill_ assertion so a storage-sharing clone is caught, matching the clone tests.
|
Second review round addressed in 04e3cc4:
On the ENV ISSUE flag: the two new test files import |
❌ Automated Test Results: FAILED
Failed tests:
Error detailsBranch: |
Fixes #2301.
Root cause
The stored activation cache and the cache handed to generation were the same object, so every turn permanently grew the store (and the re-dumped memory file):
MemOS.chat(mem_os/core.py),mem_os/main.py,mem_chat/simple.pyandmos_for_test_scheduler.pypass the storedkv_cache.memorystraight intoHFLLM.generate(past_key_values=...);HFLLM._prefillforwards it tomodel(past_key_values=...), and transformers appends the new tokens' K/V in place (DynamicLayer.updaterebindskeys/valueson the same object; identity is preserved);KVCacheMemory._concat_cachesreturnscaches[0]unchanged for a single id, so theget_cache()merge path hands out the stored object as well.No caller reads the cache back expecting it to have grown — the growth is only observable as leaked state (which
ActivationMemoryManagerthen re-dumps to disk).Fix
Treat stored caches as read-only by construction, at the two boundaries where a cache leaves the store or enters the model:
clone_dynamic_cache()(new,memories/activation/kv.py): independent copy with cloned K/V tensors, compatible with both the legacykey_cache/value_cachestructure (transformers <= 4.55, perpoetry.lock) and the newerlayersstructure (>= 4.56, still < 5.0.0 in the supported range);KVCacheMemory._concat_caches: the single-cache case now returns a clone instead of the stored object;HFLLM.generate/generate_stream: clone the incomingpast_key_valuesonce at the boundary. This fixes all four call sites without touching them; the cost is one cache copy per generate call, the same order as the prefill work itself.Tests
test_generate_with_cache_does_not_mutate_caller_cache(tests/llms/test_hf.py): mocks a model forward that appends K/V in place, asserts the caller's cache is unchanged — fails on currentmain, passes with this fix;test_get_cache_single_item_returns_independent_copy+test_get_cache_multi_item_merge_does_not_alias_inputs(tests/memories/activation/test_kv.py):get_cache()never aliases the store;test_clone_dynamic_cache_copies_legacy_tensors+test_clone_dynamic_cache_handles_layers_structure: cover both cache structures;tests/memories/+tests/llms/: 117 passed (+4 subtests), no regressions (Python 3.13, torch 2.14 CPU, transformers 4.53.2 perpoetry.lock).cc @issue reporter — thanks for the exceptionally detailed write-up; the line-level analysis made this straightforward to confirm and fix.