fix: re-rotate KV fragments to their landing positions when merging - #2304
fix: re-rotate KV fragments to their landing positions when merging#2304jeojdi1 wants to merge 1 commit into
Conversation
transformers applies rotary embedding BEFORE writing to the cache:
query_states, key_states = apply_rotary_pos_emb(query, key, cos, sin)
past_key_value.update(key_states, value_states, self.layer_idx, ...)
so a cached key for token i of a fragment encodes absolute position i *of that
fragment*. `_concat_caches` joins fragments with a bare `torch.cat(dim=-2)`,
which moves fragment 2's keys to slots L1..L1+L2-1 while their phase still says
0..L2-1. Attention scores encode relative distance in the angle between q and k,
so the model's sense of how far apart tokens are is wrong for every fragment
after the first, and gets worse the further in you go.
V is never rotated, which is how this isolates cleanly: on a naive merge the V
halves match a correct reference bit-for-bit while the K halves diverge.
Rotates each fragment forward by the summed length of everything before it. Per
pair the rotation composes exactly -- R(a) then R(b) == R(a+b) -- so this is a
rewrite of phase, not an approximation.
Four cases handled deliberately:
* the delta rotation uses UNSCALED cos/sin. On a model with attention_scaling
!= 1 the stored key is already s * R(m) k; reapplying the scale gives s^2.
* rotary_dim is derived from inv_freq, so partial-rotary models work by
construction.
* rope schedules that recompute frequencies with sequence length (dynamic/NTK)
do not compose, so re-rotating them would be wrong in a different way. Those
are skipped with a warning rather than guessed at.
* when the model is unreachable (non-HF backend), it skips and warns. That
reproduces current behaviour, so this cannot break a working deployment.
SCOPE: this corrects positional phase only. Merging fragments remains an
approximation of a single cache regardless, because each fragment's hidden
states were computed without the others in context. That is a separate and much
smaller error than a wholesale phase mismatch.
Adds `test_merge_rerotates_second_fragment_to_its_new_position`, which grounds
against transformers' own `apply_rotary_pos_emb` rather than a second copy of
this arithmetic. It fails on the current code by 3.473 and passes with this
change. Also adds a test that the non-composing schedule is refused, not guessed.
|
Filed the underlying problem as #2315 and linked it above, per The issue writes up the defect on its own terms, with a standalone reproduction and prior-art check, and lists three possible fixes cheapest-first: (1) make Keeping this as a draft. If you'd prefer option 1 or 2, say so on #2315 and I'll close this in favour of a much smaller patch — I'd rather not push a ~200-line rotary change if a two-line guard is the answer you want. Note also that #2315 and #2313 are both defects in this same function but are independent, and were split deliberately: #2313 is version-gated and clear-cut (its fix is #2314), whereas this one carries a design decision. |
Description
transformersapplies rotary embedding before writing to the cache:So a cached key for token i of a fragment encodes absolute position i of that fragment.
_concat_cachesjoins fragments with a baretorch.cat(dim=-2). That moves fragment 2's keys into slotsL1 … 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.V is never rotated, which is what makes the diagnosis unambiguous: on a naive merge, the V halves match a correct reference bit for bit while the K halves diverge. That isolates the fault to positional phase and nothing else.
This PR rotates each fragment forward by the summed length of everything before it. Per pair the rotation composes exactly —
R(a)thenR(b)==R(a+b)— so it is a rewrite of phase, not an approximation.Four cases are handled deliberately, each of which would be wrong if done naively:
attention_scaling != 1(YaRN-style)s·R(m)·k, so reapplying the scale givess²rotary_dimderived frominv_freq, so these work by constructionScope — what this does not claim
This corrects positional phase only. Merging fragments remains an approximation of a single cache regardless, because each fragment's hidden states were computed without the others in context. That is a separate and much smaller error than a wholesale phase mismatch, but it is real and I don't want to overstate the fix.
Related Issue (Required): #2315
Type of change
How Has This Been Tested?
test_merge_rerotates_second_fragment_to_its_new_positionbuilds two fragments the way the model would, merges them, and compares against keys rotated directly at the positions they land on — using transformers' ownapply_rotary_pos_embas the reference, deliberately not a second copy of this PR's arithmetic, since grounding against your own implementation only proves self-consistency.Fails-before / passes-after on
transformers 5.16.1:test_merge_refuses_to_rerotate_non_composing_ropeasserts a dynamic schedule is skipped with a warning and the fragments are still concatenated.Checklist
A note on the target branch
CONTRIBUTING.mdsays to open PRs againstdev, but nodevbranch exists — onlymainanddev-v2.0.28…dev-v2.0.32. This is againstmain(185ebdb, "Dev v2.0.32"). Happy to retarget.Relationship to the other KV cache PRs
This is one of four independent fixes to
src/memos/memories/activation/kv.py, each kept to a single logical change perCONTRIBUTING.md:transformers >= 4.57(Merged KV caches report get_seq_length() == 0 and are silently discarded on transformers >= 4.57 #2313)get_cachehands out the stored cache by reference, so it grows every turn (The stored activation DynamicCache is mutated in place by generation, so activation memory grows every turn #2301)#2313 and #2315 are both defects in
_concat_cachesbut are independent: #2313 is version-gated and clear-cut, this one is version-independent and carries a design decision. They were split so the first is not held up by the second.