Skip to content

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

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

RerankerGuo wants to merge 2 commits 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: 9332194afb1f56cb
Base: main
Head: feature/fix-activation-cache-aliasing
Head SHA: 59fb97af2cfd1a791abda499d97692a40ce56a7e

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


1. tests/utils.py (L56-L59)

dynamic_cache_key_tensors appends tensor even when the layer exposes neither keys nor key_cache, so the returned list can silently mix tensors with None. transformers >= 4.55 creates placeholder layers (DynamicLayer() with keys = None) when update() is called for a non-contiguous layer_idx, and clone_dynamic_cache in kv.py preserves such unpoulated layers. A caller then gets an opaque AttributeError: 'NoneType' object has no attribute 'shape' from dynamic_cache_seq_len instead of a clear failure, and dynamic_cache_layer_count still counts that layer. Consider failing explicitly (or documenting the placeholder behaviour) so tests do not mis-diagnose such caches.

💡 Suggested Change

Before:

            tensor = getattr(layer, "keys", None)
            if tensor is None:
                tensor = getattr(layer, "key_cache", None)
            tensors.append(tensor)

After:

            tensor = getattr(layer, "keys", None)
            if tensor is None:
                tensor = getattr(layer, "key_cache", None)
            if tensor is None:
                raise AttributeError(
                    "Cache layer exposes neither 'keys' nor 'key_cache' "
                    "(unpopulated placeholder layer)"
                )
            tensors.append(tensor)

2. src/memos/memories/activation/kv.py (L58-L59)

The "a stored cache is never an owned copy" invariant is only enforced in add(), while load() (lines ~209-226) is the other path that populates kv_cache_memories and assigns items directly without clearing the marker. If a marker-carrying cache is ever persisted (or written into the dict by a caller, e.g. a cache obtained from get_cache() which is marked owned), the guard silently breaks after a reload and the model appends to the stored cache in place again — exactly what this change prevents. Also, the getattr guard is redundant: is_owned_cache defaults to False, so the flag can be written unconditionally, which makes the marker consistently present on every stored cache.

Suggestion: set the flag unconditionally here and call this helper for each item loaded in load() (and on the dict values assigned from the deserialized memories).

💡 Suggested Change

Before:

    if getattr(cache, _OWNED_CACHE_MARKER, False):
        setattr(cache, _OWNED_CACHE_MARKER, False)

After:

    setattr(cache, _OWNED_CACHE_MARKER, False)

3. src/memos/memories/activation/kv.py (L388)

Layout probing and the failure message are now duplicated: _concat_caches uses hasattr(caches[0], "layers") / hasattr(caches[0], "key_cache"), while clone_dynamic_cache uses getattr(..., None) is not None. The two predicates are not equivalent (an existing-but-None layers attribute is treated as "modern" by _concat_caches -> len(None) TypeError, but as "legacy" here), and both raise the same hand-written AttributeError text, so the two paths can silently drift apart. Consider extracting one shared helper (e.g. _detect_cache_layout(cache) -> "layers" | "legacy") that both _concat_caches and clone_dynamic_cache use for detection and for the error message.


4. src/memos/memories/activation/kv.py (L329-L334)

copy.copy(layer) only de-aliases the four hard-coded attribute names, so any other tensor held by a cache layer stays shared with the stored cache, which contradicts the documented guarantee that the copy "shares no tensor with the original". transformers layers already carry extra tensor state depending on the version/features (e.g. DynamicSlidingWindowLayer bookkeeping next to keys/values, pre-allocated/offloading buffers, quantized caches), and update() may mutate such state in place, which would silently corrupt the stored activation memory. Clone every tensor-valued attribute of the layer instead of a fixed allow-list.

💡 Suggested Change

Before:

    cloned = copy.copy(layer)
    for attribute in ("keys", "key_cache", "values", "value_cache"):
        tensor = getattr(layer, attribute, None)
        if tensor is not None:
            setattr(cloned, attribute, _clone_tensor(tensor))
    return cloned

After:

    import torch

    cloned = copy.copy(layer)
    # Clone every tensor-valued attribute (keys/values, sliding-window
    # bookkeeping, offloading buffers, ...), not just a fixed allow-list, so
    # that the copy really shares no tensor with the original.
    state = dict(getattr(layer, "__dict__", {}))
    for attribute in ("keys", "key_cache", "values", "value_cache"):
        state.setdefault(attribute, getattr(layer, attribute, None))
    for attribute, value in state.items():
        if isinstance(value, torch.Tensor):
            setattr(cloned, attribute, value.clone())
    return cloned

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

@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

@RerankerGuo
RerankerGuo force-pushed the feature/fix-activation-cache-aliasing branch from 6f4b12b to 59fb97a Compare November 3, 2026 03:51
@RerankerGuo

Copy link
Copy Markdown
Contributor Author

Both findings are addressed in 59fb97af. The branch is also rebased onto the current main (44a87f7e).

1. Double clone on the get_cache() -> generate() path — confirmed and fixed

The mechanism is real. KVCacheMemory.get_cache() ends in _concat_caches(), and the single-cache branch returned clone_dynamic_cache(caches[0]) — copy 1. HFLLM.generate() then called _clone_cache(past_key_values), which is clone_dynamic_cache() again — copy 2. A caller following the public API therefore kept three sets of K/V tensors alive during the generation (stored + copy 1 + copy 2), and the second copy was pure overhead: copy 1 already shares no tensor with the stored cache.

Ownership is now explicit, so there is exactly one copy on that path:

  • clone_dynamic_cache() marks the cache it returns as an owned copy: it shares no tensor with the original, so the caller may hand it to a model that appends to it in place.
  • The merge path of _concat_caches() marks the merged cache the same way — it is built from freshly concatenated tensors and aliases nothing.
  • HFLLM._clone_cache() returns an already owned cache unchanged. A borrowed cache (the stored one, which is what the in-repo chat paths pass) is still copied, exactly as before.
  • KVCacheMemory.add() drops the marker from a cache that is stored back into the memory, so a stored cache is never treated as owned and the model still copies it before it appends to it.

The core guarantee is unchanged: the stored cache is read-only during the generation. The model never writes into a cache that aliases it — an owned copy never aliases the stored cache, and a borrowed cache is copied first. The number of K/V tensor sets alive during a generation on the public path drops from three to two.

New regression tests:

  • tests/llms/test_hf.py::test_generation_from_get_cache_copies_the_kv_tensors_only_once walks the public path (get_cache() -> generate()), counts the copies of the K/V tensors (exactly one) and pins that the model received the cache the memory handed out rather than a second copy of it. It also pins that the cache the model wrote into does not alias the stored tensors and that the stored cache still holds only its original tokens.
  • tests/llms/test_hf.py::test_generation_from_get_cache_keeps_the_stored_cache_read_only pins that the stored cache is not rewritten, also when an owned copy is stored back into the memory.
  • tests/memories/activation/test_kv.py pins the ownership marker on the single-cache path, on the merge path and on the legacy layout, and that a stored cache is never an owned copy.

The two existing tests that pin the model-boundary copy (test_generate_does_not_mutate_caller_cache, test_generate_stream_does_not_mutate_caller_cache) are unchanged and still pass: they pass a plain cache, which is borrowed and therefore still copied.

2. AutoTest "ENV ISSUE" — an environment gap, not a test failure

The AutoTest runner has no torch installed, which its own log shows:

[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'

This is not introduced by this PR. On main, both modules import torch at module level:

tests/llms/test_hf.py            -> import torch
tests/memories/activation/test_kv.py -> import torch

so the same collection error occurs there in an environment without torch. The PR does not add that dependency; it only makes the two modules skip instead of erroring during collection, which is why the last AutoTest report says "No required business tests executed" rather than "collection error" — the runner has nothing it can execute.

The repository's own workflow does install torch (the all extra) and runs these tests: the Python tests workflow on the previous head of this branch was green on all 17 jobs, with

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

For the new head, the same workflow is running now. Locally, with transformers 4.57.6 (the version pinned in uv.lock, i.e. the modern layers layout) and the dependencies installed in a uv venv:

python -m pytest -q tests/memories/activation/test_kv.py tests/llms/test_hf.py
# 25 passed, 2 warnings in 5.42s

ruff check --no-fix src/memos/llms/hf.py src/memos/memories/activation/kv.py \
    tests/llms/test_hf.py tests/memories/activation/test_kv.py tests/utils.py
# All checks passed!

ruff format --check <the same files>
# 5 files already formatted

(ruff 0.11.8, the version pinned in .pre-commit-config.yaml. The CI workflow installs the all extra, so it runs with transformers 4.53.2 from poetry.lock, i.e. the legacy key_cache/value_cache layout; both layouts are covered by the tests above.)

@Memtensor-AI

Copy link
Copy Markdown
Collaborator

✅ Automated Test Results: PASSED

Required tests passed (25/25 executed, 0 skipped). Duration: 26s

Branch: feature/fix-activation-cache-aliasing

Tested source: 59fb97af2cfd1a791abda499d97692a40ce56a7e
Tested base: 44a87f7ee78d6b38e9337acd236a139b6f1e8fcf
Tested commit: 59fb97af2cfd1a791abda499d97692a40ce56a7e
These results apply only to this immutable source/base snapshot.

@Memtensor-AI Memtensor-AI added status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发 and removed status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 labels Nov 3, 2026
…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.
The activation memory hands out an owned copy of the stored cache
(`get_cache()`), and `HFLLM.generate` copied that copy again before it
was handed to the model. A caller that follows the public API therefore
paid for two full copies of every K/V tensor and kept three sets alive
during the generation: the stored cache, the copy of the copy, and the
one the model writes into.

Ownership is now explicit. A cache that leaves the activation memory is
an *owned copy*: it shares no tensor with the stored cache, so it may be
handed to a model that appends to it in place. `clone_dynamic_cache()`
and the merge path of `_concat_caches()` mark the cache they return, and
the model boundary skips the copy for a cache that is already owned. A
cache that is stored back into the memory loses the marker, so the model
still copies it before it appends to it.

The stored cache stays read-only in every case: the model never writes
into a cache that aliases it, and the number of K/V tensor sets alive
during a generation drops from three to two.

Tests:

- `tests/llms/test_hf.py::test_generation_from_get_cache_copies_the_kv_tensors_only_once`
  walks the public path (`get_cache()` -> `generate()`), counts the copies
  (exactly one) and pins that the model received the cache the memory
  handed out instead of a second copy of it.
- `tests/llms/test_hf.py::test_generation_from_get_cache_keeps_the_stored_cache_read_only`
  pins that the stored cache is not rewritten, also when an owned copy is
  stored back into the memory.
- `tests/memories/activation/test_kv.py` pins the ownership marker on the
  single-cache and on the merge path, and that a stored cache is never an
  owned copy.

Fixes MemTensor#2301.
@RerankerGuo
RerankerGuo force-pushed the feature/fix-activation-cache-aliasing branch from 59fb97a to 55d2d84 Compare November 4, 2026 13:58
@Memtensor-AI Memtensor-AI added status:in-progress Someone or AI is working on it | 人工或 AI 正在处理 and removed status:ready Ready for implementation; waiting for assignee or AI dispatch | 可进入实现,等待认领或派发 labels Nov 4, 2026
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

4 participants