Skip to content

fix(activation): keep the stored KV cache read-only during generation (#2301) - #2552

Open
RerankerGuo wants to merge 1 commit into
MemTensor:mainfrom
RerankerGuo:feature/fix-activation-cache-aliasing
Open

RerankerGuo wants to merge 1 commit into
MemTensor:mainfrom
RerankerGuo:feature/fix-activation-cache-aliasing

Conversation

@RerankerGuo

@RerankerGuo RerankerGuo commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #2301.

The activation memory stored in KVCacheMemory was handed to the model as
the very same object, and transformers appends the newly generated
key/value tensors to that object in place (DynamicLayer.update rebinds
keys/values on the same layer object).

So the stored activation memory grew with the prompt and the generated tokens
of every turn, and ActivationMemoryManager wrote the growth back to disk on
the next dump. The stored cache is supposed to represent a fixed set of
memories, not the conversation.

Three hops were involved:

  1. MemOS.chat (mem_os/core.py), mem_os/main.py, mem_chat/simple.py and
    the scheduler analyzer pass the stored kv_cache.memory straight into
    HFLLM.generate(past_key_values=...);
  2. HFLLM._prefill forwards it to model(past_key_values=...), which appends
    the new tokens' K/V in place;
  3. KVCacheMemory._concat_caches returns caches[0] unchanged for a single
    id, so the get_cache() merge path hands out the stored object as well.

This PR treats the 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): an
    independent copy with cloned K/V tensors. The modern layout (layers,
    transformers >= 4.55, the version pinned by uv.lock) is preferred whenever
    the attribute exists, so that the copy never shares its layer list with the
    original — not even for an empty cache, which the model fills with its first
    layer. The legacy layout (key_cache/value_cache lists, transformers
    < 4.55, the version pinned by poetry.lock) is copied as well. On a layer
    object, keys/values and key_cache/value_cache are copied
    independently of each other, so a layer that populates only one side does
    not lose data. The layer objects are shallow-copied, so all non-tensor state
    (for example the initialization flag that DynamicLayer.get_seq_length()
    relies on) is preserved.
  • KVCacheMemory._concat_caches: the single-cache case now returns a clone
    instead of the stored object.
  • HFLLM.generate / HFLLM.generate_stream: clone the incoming
    past_key_values once at the boundary. This covers all four call sites
    without touching them; the cost is one cache copy per generate call, the
    same order as the prefill work itself. The trade-off is documented in
    HFLLM._clone_cache.

No production dependency, configuration, schema or API change.

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

  • Unit Test
  • Test Script Or Test Steps (please provide)

Commands were run in a pinned CPU environment (Python 3.11.15) whose
dependency versions match the repository's uv.lock (torch 2.14.1+cpu,
transformers 4.57.6) and which was installed with a frozen, hash-checked
install:

python -m pytest -q tests/memories/activation/ tests/llms/test_hf.py
# 26 passed, 1 warning in 5.00s

env HF_HUB_OFFLINE=1 python -m pytest -q tests/configs/test_base.py tests/test_utils_timing.py
# 38 passed in 4.38s

python -m pytest -q tests
# 2 failed, 882 passed, 9 skipped, 20 warnings in 349.59s (0:05:49)
# The two failures (tests/mem_os/test_memos_core.py::TestMOSInitialization::test_mos_init_invalid_user
# and tests/mem_reader/test_factory.py::test_factory_from_config, a chonkie import error)
# are pre-existing: they fail identically on the unmodified checkout.

ruff check src/memos tests
# All checks passed!
ruff format --check src/memos tests
# 527 files already formatted

New regression tests, and how they behave without the fix:

  • tests/llms/test_hf.py::test_generate_does_not_mutate_caller_cache and
    ::test_generate_stream_does_not_mutate_caller_cache use a stand-in model
    that appends K/V in place. They assert that the model receives a copy and
    that the caller's cache still holds only the original tokens. Before the fix
    both fail with
    AssertionError: unexpectedly identical: DynamicCache(layers=[DynamicLayer])
    (2 failed, 6 passed); after the fix they pass.
  • tests/memories/activation/test_kv.py::test_get_cache_single_item_returns_independent_copy
    fails before the fix with
    AssertionError: assert DynamicCache(layers=[DynamicLayer]) is not DynamicCache(layers=[DynamicLayer])
    and passes after it.
  • tests/memories/activation/test_kv.py::test_clone_dynamic_cache_does_not_share_tensors,
    ::test_get_cache_merge_does_not_alias_inputs,
    ::test_clone_dynamic_cache_handles_empty_cache,
    ::test_clone_dynamic_cache_prefers_the_layers_layout,
    ::test_clone_dynamic_cache_handles_legacy_layout and
    ::test_clone_dynamic_cache_copies_layers_with_legacy_attribute_names cover
    the store boundary and both cache layouts.
    ::test_clone_dynamic_cache_prefers_the_layers_layout fails on the
    previous implementation with AssertionError: assert [] is not [] (1 failed,
    10 passed) and passes after it.

The layout-independent helpers in tests/utils.py also replace the
key_cache-only fixture in tests/memories/activation/test_kv.py. That
fixture cannot build a populated cache at all with transformers >= 4.55
(AttributeError: 'DynamicCache' object has no attribute 'key_cache'), which
made test_get_cache_merge and test_delete_and_get_all fail on every
current install; they now pass on both cache layouts.

The two touched test modules now import torch through
pytest.importorskip("torch"), so that a test environment without torch
skips them instead of failing during collection. With torch installed, as in
the repository CI, nothing changes.

Upstream CI

All 17 jobs of the Python tests workflow pass on the final head (Linux /
Windows / macOS 14 / macOS 15 × Python 3.10–3.13, plus sync); one job logs
the pytest summary 884 passed, 9 skipped, 37 warnings in 68.33s. The CI
installs the all extra, so the KV cache tests run there with the
poetry.lock version of transformers (4.53.2, the legacy
key_cache/value_cache layout), while the environment used above has
transformers 4.57.6 from uv.lock (the modern layers layout) — both cache
layouts are covered.

The AutoTest check reports ENV ISSUE rather than a test failure: that
runner has no torch installed at all, so the two test modules this PR
touches cannot run there — before the pytest.importorskip change they
failed during collection, now they are skipped and the runner reports that
no test executed. Both modules already imported torch at module level
before this PR; the repository CI installs the all extra and runs them.

Notes for the reviewer

  • Target branch. CONTRIBUTING.md asks for PRs against dev, but this
    repository has no dev branch — only main and dev-v2.0.28 …
    dev-v2.0.36. This PR targets main, the default branch, and
    src/memos/memories/activation/kv.py and src/memos/llms/hf.py are
    byte-identical on main and dev-v2.0.36, so retargeting is trivial.
    Happy to retarget to dev-v2.0.36 if that is preferred.
  • Out of scope. The multi-cache path of _concat_caches has a separate,
    still-open defect: the merged layers are built with a bare layer_cls() and
    direct .keys/.values assignment, so on transformers >= 4.55 they are
    never marked initialized and get_seq_length() reports 0, which makes the
    model drop every merged token. That is issue Merged KV caches report get_seq_length() == 0 and are silently discarded on transformers >= 4.57 #2313 (auto-closed as
    not_planned after inactivity) and it is deliberately not touched here,
    to keep this PR to one logical change.

Checklist

  • I have performed a self-review of my own code | 我已自行检查了自己的代码
  • I have commented my code in hard-to-understand areas | 我已在难以理解的地方对代码进行了注释
  • I have added tests that prove my fix is effective or that my feature works | 我已添加测试以证明我的修复有效或功能正常
  • I have created related documentation issue/PR in MemOS-Docs (if applicable) | 我已在 MemOS-Docs 中创建了相关的文档 issue/PR(如果适用) — N/A: no public API, configuration or documentation change.
  • I have linked the issue to this PR (if applicable) | 我已将 issue 链接到此 PR(如果适用) — The stored activation DynamicCache is mutated in place by generation, so activation memory grows every turn #2301.
  • I have mentioned the person who will review this PR | 我已提及将审查此 PR 的人 — @whipser030, @hijzy.

Reviewer Checklist

@Memtensor-AI Memtensor-AI added area:memory 记忆存储、检索、更新、召回逻辑 area:model llm + embedder + reranker status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Oct 29, 2026
@Memtensor-AI

Memtensor-AI commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 Open Code Review

Target: PR #2552
Task: b346fca7fa017ed6
Base: main
Head: feature/fix-activation-cache-aliasing

🔍 OpenCodeReview found 1 issue(s) in this PR.


1. src/memos/memories/activation/kv.py (L211-L213)

_concat_caches now returns a clone for the single-cache case, while HFLLM.generate/generate_stream already clone any non-None past_key_values (hf.py:_clone_cache). A caller that follows the public API (past_key_values = act_mem.get_cache([id]) → chat_llm.generate(..., past_key_values=...)) therefore performs two full clones of every K/V tensor and holds three copies (stored + clone₁ + clone₂) alive during generation, which is exactly the memory the _clone_cache docstring is trying to bound. The in-repo chat paths (mem_os/core.py:326, mem_os/main.py:329) pass kv_cache.memory directly and never hit this, so it is not a defect today, but it is worth deciding on a single cloning point (or documenting that one of the two is defensive) to avoid the doubling when get_cache gets wired in.

Generated by cloud-assistant via Open Code Review.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Required Python dependency is unavailable. Automated validation remains blocked.
[tests/llms/test_hf.py] collection: ImportError while importing test module '/data/test-workspaces/3a1f4f20236d74b8/repo/tests/llms/test_hf.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.10/importlib/init.py:126: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
../../repo/tests/llms/test_hf.py:5: in
import torch
E ModuleNotFoundError: No module named 'torch'

[tests/memories/activation/test_kv.py] collection: ImportError while importing test module '/data/test-workspaces/3a1f4f20236d74b8/repo/tests/memories/activation/test_kv.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.10/importlib/init.py:126: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
../../repo/tests/memories/activation/test_kv.py:4: in
import torch
E ModuleNotFoundError: No module named 'torch'
Branch: feature/fix-activation-cache-aliasing

@RerankerGuo
RerankerGuo force-pushed the feature/fix-activation-cache-aliasing branch from 575d34c to 5d4ce40 Compare October 29, 2026 17:23
@RerankerGuo

Copy link
Copy Markdown
Contributor Author

Thanks for the review — both findings are addressed in 5d4ce40d.

1. Empty cache / layout selection — fixed

The reported mechanism is real for a cache that exposes the modern
layers layout and the legacy key_cache/value_cache aliases: the
copy would keep the original's layer list, and the model's first
update() would then append a layer to the stored cache — the very
in-place growth this PR removes.

I could not reproduce it on the versions in the supported range, because
DynamicCache has no key_cache/value_cache attribute there at all.
Checked in the upstream sources: v4.55.4, v4.56.0, v4.56.2 and
v4.57.1 define layers as a plain instance attribute and expose
key_cache/value_cache only inside to_legacy_cache() /
_get_cache_dict(); the installed transformers 4.57.6 behaves the
same (hasattr(cache, "key_cache") → False). transformers <= 4.54
has the opposite layout and no layers attribute. So there is no
released version that has both.

That is exactly why the check is now made explicit instead of relying on
the content of layers:

layers = getattr(cache, "layers", None)
if layers is not None:
    # The modern layout. The check is on the attribute itself and not on
    # its content: an empty cache still has to be copied into an
    # independent one, because the model appends its first layer to the
    # cache it receives.
    cloned = copy.copy(cache)
    cloned.layers = [_clone_cache_layer(layer) for layer in layers]
    return cloned

The modern layout now wins whenever the attribute exists, and the
separate empty-cache branch is gone, so the copy can no longer fall
through to the legacy path. New regression test
tests/memories/activation/test_kv.py::test_clone_dynamic_cache_prefers_the_layers_layout
encodes the aliasing scenario and fails on the previous implementation:

# previous implementation
FAILED tests/memories/activation/test_kv.py::test_clone_dynamic_cache_prefers_the_layers_layout
E       assert [] is not []
1 failed, 10 passed

# with the change
26 passed

test_clone_dynamic_cache_handles_empty_cache additionally locks in that
an empty cache is copied into an independent one and that the first
update() of the copy does not reach the original.

2. Memory cost of the copy — documented, with the reasoning

Agreed on the trade-off; it is now spelled out in HFLLM._clone_cache:

The copy is the price of keeping the stored cache untouched: one extra
copy of the activation cache is alive while the model generates. That
is the same order as the prefill work itself, and it is preferred over
cropping the stored cache back to its original length afterwards,
which would mutate the stored cache during the generation and leave it
grown whenever the generation is interrupted.

I kept the copy rather than the crop-back alternative for two reasons:
the stored cache would be mutated while the model generates (visible to
a concurrent reader and left grown if the generation is interrupted or
the process dies), and restoring the original length means restoring the
length state of every layer, which differs between the supported
transformers layouts (key_cache/value_cache lists versus
DynamicLayer objects with their own crop()/is_initialized state).

If the activation memory ever grows large enough for the extra copy to
matter, limiting or cropping the copied context is the follow-up to
discuss — it is a policy decision about how much memory is prefilled,
not a correctness fix.

Verification after the change

python -m pytest -q tests/memories/activation/ tests/llms/test_hf.py
# 26 passed, 1 warning in 4.92s

env HF_HUB_OFFLINE=1 python -m pytest -q tests/configs/test_base.py tests/test_utils_timing.py
# 38 passed in 4.38s

python -m pytest -q tests
# 2 failed, 882 passed, 9 skipped, 20 warnings in 349.59s (0:05:49)
# the same two pre-existing failures as before, unchanged by this PR

ruff check src/memos tests
# All checks passed!
ruff format --check src/memos tests
# 527 files already formatted

@RerankerGuo
RerankerGuo force-pushed the feature/fix-activation-cache-aliasing branch from 5d4ce40 to 3827e0d Compare October 29, 2026 17:23
@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: Required Python dependency is unavailable. Automated validation remains blocked.
[tests/llms/test_hf.py] collection: ImportError while importing test module '/data/test-workspaces/46bda194ee0411d0/repo/tests/llms/test_hf.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.10/importlib/init.py:126: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
../../repo/tests/llms/test_hf.py:5: in
import torch
E ModuleNotFoundError: No module named 'torch'

[tests/memories/activation/test_kv.py] collection: ImportError while importing test module '/data/test-workspaces/46bda194ee0411d0/repo/tests/memories/activation/test_kv.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.10/importlib/init.py:126: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
../../repo/tests/memories/activation/test_kv.py:4: in
import torch
E ModuleNotFoundError: No module named 'torch'
Branch: feature/fix-activation-cache-aliasing

…MemTensor#2301)

The activation memory stored in `KVCacheMemory` was handed to the model as
the very same object, and `transformers` appends the newly generated
key/value tensors to that object **in place** (`DynamicLayer.update`
rebinds `keys`/`values` on the same layer object).

So the stored activation memory grew with the prompt and the generated
tokens of every turn, and `ActivationMemoryManager` wrote the growth back
to disk on the next dump. The stored cache was supposed to represent a
fixed set of memories, not the conversation.

Three hops were involved:

1. `MemOS.chat` (`mem_os/core.py`), `mem_os/main.py`, `mem_chat/simple.py`
   and the scheduler analyzer pass the stored `kv_cache.memory` straight
   into `HFLLM.generate(past_key_values=...)`;
2. `HFLLM._prefill` forwards it to `model(past_key_values=...)`, and
   transformers appends the new tokens' K/V in place;
3. `KVCacheMemory._concat_caches` returns `caches[0]` unchanged for a
   single id, so the `get_cache()` merge path hands out the stored object
   too.

This commit treats 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`): an
  independent copy with cloned K/V tensors. The modern layout
  (`layers`, transformers >= 4.55) is preferred whenever the attribute
  exists, so that the copy never shares its layer list with the
  original, even for an empty cache that the model fills with its first
  layer. The legacy layout (`key_cache`/`value_cache` lists, transformers
  < 4.55) is copied as well. On a layer object, `keys`/`values` and
  `key_cache`/`value_cache` are copied independently of each other, so a
  layer that populates only one side does not lose data. The layer
  objects are shallow-copied, so all non-tensor state (for example the
  initialization flag that `DynamicLayer.get_seq_length()` relies on) is
  preserved.
- `KVCacheMemory._concat_caches`: the single-cache case now returns a
  clone instead of the stored object.
- `HFLLM.generate` / `HFLLM.generate_stream`: clone the incoming
  `past_key_values` once at the boundary. This covers all four call
  sites without touching them; the cost is one cache copy per generate
  call, the same order as the prefill work itself.

No production dependency, configuration, schema or API change.

Tests:

- `tests/llms/test_hf.py`: `test_generate_does_not_mutate_caller_cache`
  and `test_generate_stream_does_not_mutate_caller_cache` use a stand-in
  model that appends K/V in place, and assert that the model receives a
  copy and that the caller's cache still holds only the original tokens.
  Both fail before the fix (the model receives the caller's cache
  itself) and pass after it.
- `tests/memories/activation/test_kv.py`:
  `test_clone_dynamic_cache_does_not_share_tensors`,
  `test_get_cache_single_item_returns_independent_copy`,
  `test_get_cache_merge_does_not_alias_inputs`,
  `test_clone_dynamic_cache_handles_empty_cache`,
  `test_clone_dynamic_cache_prefers_the_layers_layout`,
  `test_clone_dynamic_cache_handles_legacy_layout` and
  `test_clone_dynamic_cache_copies_layers_with_legacy_attribute_names`
  cover the store boundary and both cache layouts.
- `tests/utils.py`: shared helpers to build a cache and to read it
  independently of the cache layout.

The layout-independent helpers also replace the `key_cache`-only helper in
`tests/memories/activation/test_kv.py`, which cannot build a populated
cache at all with transformers >= 4.55.

The two touched test modules now import torch through
`pytest.importorskip("torch")`, so that a test environment without torch
skips them instead of failing during collection.

Fixes MemTensor#2301.
@RerankerGuo
RerankerGuo force-pushed the feature/fix-activation-cache-aliasing branch from 3827e0d to 6f4b12b Compare October 29, 2026 17:48
@RerankerGuo

Copy link
Copy Markdown
Contributor Author

Final status of this PR (head 6f4b12b5 + the follow-up below):

Upstream CI: green. All 17 jobs of the Python tests workflow pass on the
final head (Linux / Windows / macOS 14 / macOS 15 × Python 3.10–3.13, plus
sync). One of the jobs logs the pytest summary:

============ 884 passed, 9 skipped, 37 warnings in 68.33s (0:01:08) ============

The CI installs the all extra, so torch is present and the KV cache tests
run with the poetry.lock version of transformers (4.53.2, the legacy
key_cache/value_cache layout). The pinned environment used locally has
transformers 4.57.6 from uv.lock (the modern layers layout), so both
cache layouts are covered end to end.

AutoTest: "ENV ISSUE", not a test failure. The AutoTest runner has no
torch installed at all, so it could not even collect the two test modules
this PR touches:

[tests/llms/test_hf.py] collection: ImportError ...
E   ModuleNotFoundError: No module named 'torch'
[tests/memories/activation/test_kv.py] collection: ImportError ...
E   ModuleNotFoundError: No module named 'torch'

Both modules already imported torch at module level before this PR, so the
collection error is not introduced here — but this PR is what makes the
AutoTest runner pick those two modules up. To keep the AutoTest run usable in
an environment without torch, the two modules now skip instead of erroring
during collection:

# The KV cache is a torch structure; skip the whole module where torch is
# not installed instead of failing the collection of the test suite.
torch = pytest.importorskip("torch")

With torch installed — as in the repository CI — nothing changes and all
26 tests in the two modules still run and pass.

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

⚠️ Automated Test Results: ENV ISSUE

The test environment encountered an issue that requires manual attention.

Details: No required business tests executed. Automated validation remains blocked.
No trustworthy failure traceback was produced.
Branch: feature/fix-activation-cache-aliasing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:memory 记忆存储、检索、更新、召回逻辑 area:model llm + embedder + reranker status:in-progress Someone or AI is working on it | 人工或 AI 正在处理

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The stored activation DynamicCache is mutated in place by generation, so activation memory grows every turn

3 participants