You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
_concat_caches (src/memos/memories/activation/kv.py:200-255) merges cache fragments with a bare torch.cat(..., dim=-2) per layer, and nothing re-rotates them.
Rotary embedding is applied to keys before they are written to the cache. In transformers v4.53.2 — the version pinned in poetry.lock:5760-5761 — src/transformers/models/qwen2/modeling_qwen2.py:
So a cached key for token i of a fragment carries the phase of absolute position i of that fragment. Concatenating fragment 2 behind fragment 1 moves those keys into slots L1 … L1+L2-1 while their phase still says 0 … L2-1. Attention encodes relative distance in the angle between q and k, so after a merge the model's sense of how far apart tokens are is wrong for every fragment after the first, and the error grows the further into the merged cache you go. The merged cache does not correspond to any prefill of any text.
V is never rotated, and that makes the diagnosis unambiguous: on a naive merge the V halves match a correct reference bit for bit while the K halves diverge. That localises the entire discrepancy to positional phase — it is not a numerical-tolerance question.
Minimal reproduction
Needs only torch + transformers + a small model; the merge function is copied verbatim from kv.py so no MemOS install is required. Runs on CPU in under a minute.
importtorch, transformersfromtransformersimportAutoModelForCausalLM, AutoTokenizer, DynamicCacheMODEL="Qwen/Qwen2.5-0.5B-Instruct"# --- verbatim from src/memos/memories/activation/kv.py:200-255 (`self` removed) ---def_concat_caches(caches):
assertcaches, "Need at least one cache"iflen(caches) ==1:
returncaches[0]
merged=DynamicCache()
ifhasattr(caches[0], "layers"):
num_layers=len(caches[0].layers)
ifnothasattr(merged, "layers"):
merged.layers= []
ifnum_layers>0:
layer_cls=type(caches[0].layers[0])
whilelen(merged.layers) <num_layers:
merged.layers.append(layer_cls())
forlayerinrange(num_layers):
keys= [c.layers[layer].keysforcincaches]
vals= [c.layers[layer].valuesforcincaches]
merged.layers[layer].keys=torch.cat(keys, dim=-2)
merged.layers[layer].values=torch.cat(vals, dim=-2)
elifhasattr(caches[0], "key_cache"):
num_layers=len(caches[0].key_cache)
forlayerinrange(num_layers):
keys= [c.key_cache[layer] forcincaches]
vals= [c.value_cache[layer] forcincaches]
merged.key_cache.append(torch.cat(keys, dim=-2))
merged.value_cache.append(torch.cat(vals, dim=-2))
else:
raiseAttributeError("no layers / key_cache")
returnmerged# ----------------------------------------------------------------------------------print("transformers", transformers.__version__)
tok=AutoTokenizer.from_pretrained(MODEL)
model=AutoModelForCausalLM.from_pretrained(MODEL, dtype=torch.float32).eval()
defprefill(ids, cache=None):
withtorch.no_grad():
returnmodel(input_ids=ids, use_cache=True, past_key_values=cache,
return_dict=True).past_key_valuesA=tok("The user's name is Alice and she lives in Berlin.", return_tensors="pt").input_idsL=A.shape[-1]
merged=_concat_caches([prefill(A), prefill(A)]) # same fragment twicegold=prefill(A, prefill(A)) # same token ids, correct positionsmk, gk=merged.layers[0].keys, gold.layers[0].keysprint("K first half == reference:", torch.equal(mk[:, :, :L, :], gk[:, :, :L, :]))
print("K second half == reference:", torch.equal(mk[:, :, L:, :], gk[:, :, L:, :]))
print("max|K_merged - K_ref| 2nd half:", (mk[:, :, L:, :] -gk[:, :, L:, :]).abs().max().item())
print("merged K: slots[0,L) bit-identical to slots[L,2L) in every layer:",
all(torch.equal(l.keys[:, :, :L, :], l.keys[:, :, L:, :]) forlinmerged.layers))
print("reference K: same check:",
all(torch.equal(l.keys[:, :, :L, :], l.keys[:, :, L:, :]) forlingold.layers))
print("V (never rotated) second half == reference:",
torch.equal(merged.layers[0].values[:, :, L:, :], gold.layers[0].values[:, :, L:, :]))
The same thing through the MemOS API — examples/core_memories/kv_cache_memory.py step 6 already builds this object:
merged=kv_mem.get_cache([item1.id, item2.id])
L=kv_mem.get(item1.id).memory.layers[0].keys.shape[-2]
k=merged.layers[0].keysassertnottorch.equal(k[:, :, :L, :], k[:, :, L:2*L, :]) # fails when item1 and item2 are the same text
Observed
transformers 5.15.1
K first half == reference: True
K second half == reference: False
max|K_merged - K_ref| 2nd half: 29.280248641967773
merged K: slots[0,L) bit-identical to slots[L,2L) in every layer: True
reference K: same check: False
V (never rotated) second half == reference: True
Two identical fragments placed at different offsets come out bit-identical in the merged cache, while a correct prefill of the same tokens makes them differ. That is the positional information being absent.
Expected
Keys of the second fragment should equal the keys the model would produce for those same tokens at their new positions — so the second half should match the reference, and two identical fragments at different offsets must not be bit-identical.
Why it matters
get_cache() is documented and shipped (examples/core_memories/kv_cache_memory.py:113). A user who merges two activation memories gets a cache whose positions are wrong for everything after the first fragment, with no error and no warning.
No issue or PR in this repository mentions RoPE, rotary, or positions in this file — title searches for rope, position, concat and kv cache merge return nothing relevant.
Suggested fix
Three options, cheapest first:
Fail loudly. If a correct merge is not intended to be supported, make _concat_caches raise for len(caches) > 1 (or warn prominently) and document that activation memories must be merged at the text level and re-prefilled. Two lines, and it removes the silent wrongness, which is the expensive part.
Re-prefill. Implement multi-id get_cache as: concatenate the fragments' metadata["source_text"] and call self.llm.build_kv_cache(...) once. Correct by construction; costs one prefill of text you already have.
Re-rotate. Rotate each fragment after the first from its local positions to its destination positions. This is what I've drafted in fix: re-rotate KV fragments to their landing positions when merging #2304 — the per-pair rotation composes exactly, so it is a phase rewrite rather than an approximation, and it handles attention_scaling, partial rotary dims, and refuses dynamic/NTK schedules (whose frequencies recompute with length, so rotations don't compose).
Scope note, stated deliberately: option 3 corrects positional phase only. Merging fragments remains an approximation of a single prefill regardless, because each fragment's hidden states were computed without the others in context. That residual is real and much smaller than a wholesale phase mismatch, and I don't want to overstate what a fix buys.
Related
A second, version-gated defect in the same function — the merged cache reporting get_seq_length() == 0 and being silently discarded on transformers >= 4.57 — is tracked separately in #2313 / #2314. The two are independent; #2313 is the clear-cut bug, this one carries a design decision, which is why they were split.
Environment
main(measured at9119efe, re-checked at185ebdb—src/memos/memories/activation/kv.pyis unchanged upstream since Scheduler: address some issues to run old scheduler example and kv cache example #797, 2025-12-30, so this still applies at HEAD)torch 2.13.0,transformers 5.15.1, CPU, fp32,Qwen/Qwen2.5-0.5B-Instructtransformersversion.Summary
_concat_caches(src/memos/memories/activation/kv.py:200-255) merges cache fragments with a baretorch.cat(..., dim=-2)per layer, and nothing re-rotates them.Rotary embedding is applied to keys before they are written to the cache. In
transformersv4.53.2 — the version pinned inpoetry.lock:5760-5761—src/transformers/models/qwen2/modeling_qwen2.py:So a cached key for token i of a fragment carries the phase of absolute position i of that fragment. Concatenating fragment 2 behind fragment 1 moves those keys into slots
L1 … L1+L2-1while their phase still says0 … L2-1. Attention encodes relative distance in the angle between q and k, so after a merge the model's sense of how far apart tokens are is wrong for every fragment after the first, and the error grows the further into the merged cache you go. The merged cache does not correspond to any prefill of any text.Vis never rotated, and that makes the diagnosis unambiguous: on a naive merge the V halves match a correct reference bit for bit while the K halves diverge. That localises the entire discrepancy to positional phase — it is not a numerical-tolerance question.Minimal reproduction
Needs only
torch+transformers+ a small model; the merge function is copied verbatim fromkv.pyso no MemOS install is required. Runs on CPU in under a minute.The same thing through the MemOS API —
examples/core_memories/kv_cache_memory.pystep 6 already builds this object:Observed
Two identical fragments placed at different offsets come out bit-identical in the merged cache, while a correct prefill of the same tokens makes them differ. That is the positional information being absent.
Expected
Keys of the second fragment should equal the keys the model would produce for those same tokens at their new positions — so the second half should match the reference, and two identical fragments at different offsets must not be bit-identical.
Why it matters
get_cache()is documented and shipped (examples/core_memories/kv_cache_memory.py:113). A user who merges two activation memories gets a cache whose positions are wrong for everything after the first fragment, with no error and no warning.Prior art checked
_concat_cacheshas been touched once, by PR fix bug when calling _concat_caches in kv.py #177 → fix: fix bug when calling _concat_caches in kv.py (from pr#177) #205 ("fix bug when calling_concat_cachesin kv.py", merged 2025-08-01 as5bfac009ff). That fixed only anAttributeErrorfrom atransformersAPI change and did not address position or phase.rope,position,concatandkv cache mergereturn nothing relevant.Suggested fix
Three options, cheapest first:
_concat_cachesraise forlen(caches) > 1(or warn prominently) and document that activation memories must be merged at the text level and re-prefilled. Two lines, and it removes the silent wrongness, which is the expensive part.get_cacheas: concatenate the fragments'metadata["source_text"]and callself.llm.build_kv_cache(...)once. Correct by construction; costs one prefill of text you already have.attention_scaling, partial rotary dims, and refuses dynamic/NTK schedules (whose frequencies recompute with length, so rotations don't compose).Scope note, stated deliberately: option 3 corrects positional phase only. Merging fragments remains an approximation of a single prefill regardless, because each fragment's hidden states were computed without the others in context. That residual is real and much smaller than a wholesale phase mismatch, and I don't want to overstate what a fix buys.
Related
A second, version-gated defect in the same function — the merged cache reporting
get_seq_length() == 0and being silently discarded ontransformers >= 4.57— is tracked separately in #2313 / #2314. The two are independent; #2313 is the clear-cut bug, this one carries a design decision, which is why they were split.