RoFormer: Rotary Position Embedding (RoPE)
Position as a rotation, not an addition. Explained with a clock, because that is what it is.
Download rope_from_scratch.py — everything below runs from it. NumPy only.
I'd been serving LLaMA-family models for a year before I could say what
rope_theta in the config actually did. I knew the
2017 paper adds a position vector to every
token. I knew LLaMA doesn't. What nobody told me is that the replacement
is smaller than the thing it replaced: turn each query and key by
an angle, and the dot product forgets where the sentence started.
Position is a clock hand
The attention page ends with + pe. You know this:
x = embed(token) + pe[pos] # 2017: position is a vector you ADD
q = x @ W_q
k = x @ W_k
score = q @ k # position baked in before the lookup
RoPE takes position out of the embedding and puts it into the lookup itself. Project first. Then turn:
q = embed(token) @ W_q # no position yet
k = embed(token) @ W_k
q = turn(q, by=pos * theta) # 2021: position is an ANGLE you rotate by
k = turn(k, by=pos * theta)
score = q @ k # only sees the angle BETWEEN q and k
That's the paper. RoPE multiplies; it does not add. The payoff is school trigonometry: the dot product of two turned vectors depends only on the angle between them. Turn q by 3θ and k by 7θ, or by 13θ and 17θ — same score. Relative position for free, zero parameters.
Picture a clock with d/2 hands. Each pair of dimensions in q is one hand, position is how far it has turned, and attention sees exactly one thing: the angle between q's hand and k's hand. One hand, from the script:
one hand, 30 deg per position, starting at [1. 0.]
pos 0: turned 0.0 deg -> [1. 0.] length 1.0000
pos 1: turned 30.0 deg -> [0.866 0.5 ] length 1.0000
pos 2: turned 60.0 deg -> [0.5 0.866] length 1.0000
pos 3: turned 90.0 deg -> [0. 1.] length 1.0000 ← never changes
| Term | Means | Clock analogy |
|---|---|---|
| Pair i = dims (2i, 2i+1) | One 2D slice of q | One hand |
| θi | Radians pair i turns per position | How fast that hand ticks |
| m θi | Pair i's angle at position m | Where the hand points |
| (n − m) θi | The only thing the score sees | The angle between two hands |
Why this beat adding a vector
Adding leaks. Expand (q + pe[m]) · (k + pe[n]) and the cross terms q·pe[n] and pe[m]·k carry absolute position, so "the cat sat" scores one way at the start of a document and another way in the middle. The relative fixes before RoPE bolted a learned bias-per-gap table onto the score matrix: parameters, memory, and nothing to attach to in the linear-attention variants that never build that matrix. RoPE has no table and no parameters, is relative by construction, and drops into any dot-product attention. That is why LLaMA, PaLM, GPT-NeoX, Mistral and most models since use it.
The one formula
q = Wqxm and k = Wkxn are the ordinary projections; R(m) is the rotation. Read it as pipeline stages:
| Step | Shape | What it does |
|---|---|---|
θi = 10000−2i/d | (d/2,) | One tick rate per pair. Pair 0 turns 1 rad per position; the last pair ≈ 1/10000 |
m θi | (d/2,) | How far each hand has turned at position m |
R(m) | (d, d), block-diagonal | d/2 independent 2×2 rotations. Never built as a matrix — it's a cos/sin multiply |
R(m) q | (d,) | q with position baked in. Same length, new direction |
qm · kn | scalar | Equals qT R(n − m) k. Only the gap survives |
The middle step is the whole proof. Rotations compose by adding angles and transposing one reverses it, so R(m)T R(n) = R(n − m). That identity is the paper; the rest is bookkeeping.
Fast hands, slow hands
Every pair gets its own tick rate, spanning four orders of magnitude. With d = 8 the rates are round numbers, so that's what the script prints:
d = 8, so 4 hands. theta_i = 10000^(-2i/d) = [1. 0.1 0.01 0.001]
at position 10, each hand has turned:
pair 0: 10.0000 rad = 572.96 deg = 1.5915 turns ← already lapped
pair 1: 1.0000 rad = 57.30 deg = 0.1592 turns
pair 2: 0.1000 rad = 5.73 deg = 0.0159 turns
pair 3: 0.0100 rad = 0.57 deg = 0.0016 turns ← barely moved
Pair 0 is the second hand: it resolves neighbours, but laps roughly every six positions and alone would alias. Pair 3 is the hour hand: useless for neighbours, but it tells position 10 from position 1000. The interview question is "why multiple frequencies?"; the answer is a clock with only second hands. The break table has the numbers.
The score only sees the gap
The paper's claim, so it gets the most careful print. Same q, same k, at positions (3, 7) and then at (13, 17): gap 4 both times. Then the 2017 add scheme, same frequencies.
Don't take my word for it. Here's the actual output:
RoPE, rotate: q@3 . k@7 = -1.261507812773
q@13 . k@17 = -1.261507812773
difference = 2.2e-16 <- float noise
2017, add: q@3 . k@7 = 3.650608771116
q@13 . k@17 = 3.830134199073
difference = 1.8e-01 <- absolute position leaks
unrotated q . k = -0.468002152973
gap 0, e.g. q@5 . k@5 = -0.468002152973 <- identical
The last two lines are the sanity check: at gap 0 the rotation cancels and you get the plain dot product back. Shift the sentence by ten and RoPE does not notice. The add scheme moves by 1.8e-01 — not rounding, a different score.
Far apart scores lower — on average
The paper's second claim is "long-term decay": the further apart two tokens, the smaller their dot product tends to be. It's the clock again: at gap 0 every hand lines up; as the gap grows the fast hands scatter and cancel, and only the slow hands still agree. The script sets q = k = all ones so the answer is just a sum of cosines:
q = k = all ones, d = 64. dot(q at 0, k at gap):
gap 0: 64.000
gap 1: 61.834
gap 16: 38.746
gap 24: 39.446 ← went up
gap 64: 27.812
mean dot over gap buckets (the trend):
gaps 0- 7: 52.767
gaps 8-15: 42.032
gaps 16-23: 38.059
gaps 24-31: 35.912
gaps 32-39: 34.069
gaps 40-47: 34.060
gaps 48-55: 29.433
gaps 56-63: 33.098
not monotonic: the dot RISES at 22 of the 64 steps, e.g.
gap 5 -> 6: 47.008 -> 47.119
Be honest about what this is. From 64 down to about 28 over 64 positions is a real trend, and the paper's figure (an upper bound on this same sum) has the same shape. But it rises at 22 of 64 steps and the last bucket is higher than the one before. RoPE gives the model a tendency to look nearby, not a rule. A trained model attends far away whenever it needs to.
Q and K only. Never V.
The property lives in the score, so the rotation goes on q and k, right
after W_q and W_k and before the dot product. V is
the content being blended and is left alone. The test: five tokens at
positions 0–4, then the same five at 100–104. With RoPE on q and k only the
output must not change at all. Then rotate v as well:
same 5 tokens at positions 0-4, then at 100-104
CORRECT (q, k only): weights max|diff| = 2.2e-16
output max|diff| = 6.7e-16 <- shift-invariant
BROKEN (v too): output max|diff| = 2.0167 <- moves with the sentence
vs correct output = 2.0803
token 0, correct: [ 0.564 0.904 0.593 -0.455 0.815 -0.611 0.502 -0.447]
token 0, v too: [-0.437 -1.176 0.68 -0.335 0.827 -0.585 0.503 -0.445]
With v rotated the weights are still fine — same 2.2e-16 — but each token's output now depends on where the sentence started, precisely what RoPE exists to remove. Rotating after the softmax is worse: the weights were computed with no position at all (max|diff| from plain attention: 0.0000), so you are back to the attention page's "cat ate food" equals "food ate cat" problem, with a scramble on top.
Change the base, slow the clock
Every rate is 10000−2i/d. Change 10000 to 100000 and the fast hands barely notice (pair 0 is 1 rad per position for any base) while the slow hands slow right down. The script uses d = 128, LLaMA's per-head size, and looks at position 4096:
base 10000: slowest hand theta = 1.155e-04 rad/pos
at pos 4096: 0.4730 rad = 27.10 deg = 0.0753 turns
base 100000: slowest hand theta = 1.197e-05 rad/pos
at pos 4096: 0.0490 rad = 2.81 deg = 0.0078 turns
slow hand, base 100000: reaches the base-10000 pos-4096 angle at pos 39513
This is why context extension is possible. A model trained to 4096 tokens
has only ever seen its slowest hand between 0 and 27 degrees; ask for
position 8192 with the same base and that hand points somewhere it never
has, and quality falls off a cliff. Raise the base and the slow hands are
stretched so 8192 lands inside the range training covered, while the fast
hands that handle local structure are left almost untouched. That is the
NTK-aware trick, and why rope_theta in LLaMA 3's config is
500000 rather than 10000.
Position interpolation feeds
position/8 instead (the script prints that too), but it slows every hand
equally, fast ones included, which is why that paper fine-tunes afterwards.
Both are one-line changes to rope_freqs. That's as deep as
this page goes.
LLaMA-family models-ஐ ஒரு வருடம் serve செய்த பிறகும், config-ல் இருக்கும்
rope_theta என்ன செய்கிறது என்று என்னால் சொல்ல முடியவில்லை.
2017 paper ஒவ்வொரு token-உடனும் ஒரு position
vector-ஐக் கூட்டுகிறது என்று தெரியும். LLaMA அப்படிச் செய்வதில்லை
என்பதும் தெரியும். ஆனால் அதற்குப் பதிலாக வந்தது இன்னும் சிறியது
என்பதை யாரும் சொல்லவில்லை: ஒவ்வொரு query-யையும் key-யையும் ஒரு angle-ல்
திருப்புங்கள், dot product-க்கு வாக்கியம் எங்கே தொடங்கியது என்பதே
மறந்துவிடும்.
Position என்பது ஒரு கடிகார முள்
Attention page + pe-உடன் முடிகிறது. இது உங்களுக்குத் தெரியும்:
x = embed(token) + pe[pos] # 2017: position ஒரு vector, அதைக் கூட்டு
q = x @ W_q
k = x @ W_k
score = q @ k # lookup-க்கு முன்பே position உள்ளே
RoPE position-ஐ embedding-லிருந்து எடுத்து lookup-க்குள்ளேயே வைக்கிறது. முதலில் project. பிறகு திருப்பு:
q = embed(token) @ W_q # இன்னும் position இல்லை
k = embed(token) @ W_k
q = turn(q, by=pos * theta) # 2021: position ஒரு angle, அதில் rotate செய்
k = turn(k, by=pos * theta)
score = q @ k # q-க்கும் k-க்கும் இடையேயான angle மட்டுமே
அவ்வளவுதான் paper. RoPE பெருக்குகிறது; கூட்டுவதில்லை. பலன் பள்ளிக்கூட trigonometry: திருப்பப்பட்ட இரு vector-களின் dot product அவற்றுக்கு இடையேயான angle-ஐ மட்டுமே சார்ந்தது. q-ஐ 3θ-லும் k-ஐ 7θ-லும் திருப்புங்கள், அல்லது 13θ-லும் 17θ-லும் — score ஒன்றுதான். Relative position இலவசம், parameters பூஜ்யம்.
d/2 முட்கள் உள்ள ஒரு கடிகாரத்தை நினைத்துக் கொள்ளுங்கள். q-ல் ஒவ்வொரு dimension ஜோடியும் ஒரு முள், position என்பது அது எவ்வளவு திரும்பியிருக்கிறது என்பது, attention பார்ப்பது ஒரே ஒரு விஷயம்: q-வின் முள்ளுக்கும் k-வின் முள்ளுக்கும் இடையேயான angle. Script-லிருந்து ஒரு முள்:
one hand, 30 deg per position, starting at [1. 0.]
pos 0: turned 0.0 deg -> [1. 0.] length 1.0000
pos 1: turned 30.0 deg -> [0.866 0.5 ] length 1.0000
pos 2: turned 60.0 deg -> [0.5 0.866] length 1.0000
pos 3: turned 90.0 deg -> [0. 1.] length 1.0000 ← மாறவே மாறாது
| சொல் | அர்த்தம் | கடிகார ஒப்புமை |
|---|---|---|
| Pair i = dims (2i, 2i+1) | q-வின் ஒரு 2D துண்டு | ஒரு முள் |
| θi | ஒரு position-க்கு pair i திரும்பும் radians | அந்த முள் எவ்வளவு வேகமாக நகர்கிறது |
| m θi | Position m-ல் pair i-ன் angle | முள் எங்கே காட்டுகிறது |
| (n − m) θi | Score பார்க்கும் ஒரே விஷயம் | இரு முட்களுக்கு இடையேயான angle |
Ovvoru dimension jodi-yum oru kadigaara mul. Position enbadhu andha mul evvalavu thirumbi irukku. Attention paakkuradhu onnu dhaan: q-oda mullukkum k-oda mullukkum idaila angle.
Vector கூட்டுவதை இது ஏன் வென்றது
கூட்டல் கசிகிறது. (q + pe[m]) · (k + pe[n]) என்பதை விரித்தால் வரும் cross terms q·pe[n] மற்றும் pe[m]·k absolute position-ஐச் சுமக்கின்றன. எனவே "the cat sat" ஒரு document-ன் தொடக்கத்தில் ஒரு score-ம், நடுவில் வேறு score-ம் பெறும். RoPE-க்கு முந்தைய relative தீர்வுகள் score matrix-உடன் ஒரு learned bias-per-gap table-ஐ ஒட்டின: parameters, memory, மேலும் அந்த matrix-ஐயே கட்டாத linear-attention variants-ல் ஒட்ட இடமே இல்லை. RoPE-க்கு table இல்லை, parameter இல்லை, அமைப்பாலேயே relative, எந்த dot-product attention-லும் அப்படியே பொருந்தும். அதனால்தான் LLaMA, PaLM, GPT-NeoX, Mistral, அதற்குப் பின் வந்த பெரும்பாலான models இதைப் பயன்படுத்துகின்றன.
அந்த ஒரு சூத்திரம்
q = Wqxm மற்றும் k = Wkxn என்பவை வழக்கமான projections; R(m) என்பது rotation. இதை pipeline stages-ஆகப் படியுங்கள்:
| படி | Shape | என்ன செய்கிறது |
|---|---|---|
θi = 10000−2i/d | (d/2,) | ஒரு ஜோடிக்கு ஒரு வேகம். Pair 0 ஒரு position-க்கு 1 rad; கடைசி pair ≈ 1/10000 |
m θi | (d/2,) | Position m-ல் ஒவ்வொரு முள்ளும் எவ்வளவு திரும்பியிருக்கிறது |
R(m) | (d, d), block-diagonal | d/2 தனித்தனி 2×2 rotations. Matrix-ஆக ஒருபோதும் கட்டப்படுவதில்லை — அது ஒரு cos/sin பெருக்கல் |
R(m) q | (d,) | Position உள்ளடங்கிய q. அதே நீளம், புதிய திசை |
qm · kn | scalar | qT R(n − m) k-க்குச் சமம். இடைவெளி மட்டுமே எஞ்சுகிறது |
நடுப்படிதான் முழு நிரூபணம். Rotations angle-களைக் கூட்டிச் சேர்கின்றன, ஒன்றை transpose செய்தால் அது தலைகீழாகிறது, எனவே R(m)T R(n) = R(n − m). அந்த identity-தான் paper; மீதி எல்லாம் கணக்கு வழக்கு.
வேகமான முட்கள், மெதுவான முட்கள்
ஒவ்வொரு ஜோடிக்கும் அதற்கே உரிய வேகம், நான்கு அளவு வரிசைகள் வரை பரவுகிறது. d = 8 என்றால் வேகங்கள் நல்ல வட்ட எண்கள், அதனால்தான் script இதைத்தான் அச்சிடுகிறது:
d = 8, so 4 hands. theta_i = 10000^(-2i/d) = [1. 0.1 0.01 0.001]
at position 10, each hand has turned:
pair 0: 10.0000 rad = 572.96 deg = 1.5915 turns ← ஏற்கனவே சுற்றியாச்சு
pair 1: 1.0000 rad = 57.30 deg = 0.1592 turns
pair 2: 0.1000 rad = 5.73 deg = 0.0159 turns
pair 3: 0.0100 rad = 0.57 deg = 0.0016 turns ← நகரவே இல்லை
Pair 0 என்பது வினாடி முள்: பக்கத்து tokens-ஐப் பிரித்துக் காட்டும், ஆனால் ஏறக்குறைய ஆறு positions-க்கு ஒரு முறை சுற்றி முடித்துவிடும், தனியாக இருந்தால் alias ஆகிவிடும். Pair 3 மணி முள்: அருகிலுள்ளவற்றுக்கு உதவாது, ஆனால் position 10-ஐ position 1000-லிருந்து பிரித்துக் காட்டும். Interview கேள்வி "ஏன் பல frequencies?"; பதில் — வினாடி முட்கள் மட்டுமே உள்ள ஒரு கடிகாரம். Break table-ல் எண்கள் இருக்கின்றன.
Pair 0 vinaadi mul: pakkathu token-ai pirikkum, aana seekram lap aagidum. Pair 3 mani mul: pos 10-ai pos 1000-lerundhu pirikkum. Adhaan pala frequency thevai.
Score பார்ப்பது இடைவெளியை மட்டுமே
இதுதான் paper-ன் claim, எனவே இதற்குத்தான் மிகக் கவனமான print. அதே q, அதே k, முதலில் positions (3, 7)-ல், பிறகு (13, 17)-ல்: இரண்டிலும் இடைவெளி 4. பிறகு 2017 கூட்டல் முறை, அதே frequencies.
என் சொல்லை நம்ப வேண்டாம். இதோ உண்மையான output:
RoPE, rotate: q@3 . k@7 = -1.261507812773
q@13 . k@17 = -1.261507812773
difference = 2.2e-16 <- float noise
2017, add: q@3 . k@7 = 3.650608771116
q@13 . k@17 = 3.830134199073
difference = 1.8e-01 <- absolute position leaks
unrotated q . k = -0.468002152973
gap 0, e.g. q@5 . k@5 = -0.468002152973 <- identical
கடைசி இரு வரிகள்தான் sanity check: இடைவெளி 0-ல் rotation ஒன்றையொன்று ரத்து செய்து, சாதாரண dot product திரும்பக் கிடைக்கிறது. வாக்கியத்தைப் பத்து இடம் நகர்த்துங்கள், RoPE கவனிக்கவே கவனிக்காது. கூட்டல் முறை 1.8e-01 அளவு நகர்கிறது — இது rounding இல்லை, வேறொரு score.
தூரமானவை குறைவாக score செய்யும் — சராசரியாக
Paper-ன் இரண்டாவது claim "long-term decay": இரு tokens எவ்வளவு தூரமோ, அவற்றின் dot product அவ்வளவு சிறியதாக இருக்க முனையும். மீண்டும் அதே கடிகாரம்: இடைவெளி 0-ல் எல்லா முட்களும் ஒரே திசையில்; இடைவெளி பெருகப் பெருக வேகமான முட்கள் சிதறி ஒன்றையொன்று ரத்து செய்கின்றன, மெதுவான முட்கள் மட்டுமே இன்னும் ஒத்துப்போகின்றன. Script q = k = எல்லாம் ஒன்று என வைக்கிறது, அதனால் விடை வெறும் cosine-களின் கூட்டுத்தொகை:
q = k = all ones, d = 64. dot(q at 0, k at gap):
gap 0: 64.000
gap 1: 61.834
gap 16: 38.746
gap 24: 39.446 ← ஏறிவிட்டது
gap 64: 27.812
mean dot over gap buckets (the trend):
gaps 0- 7: 52.767
gaps 8-15: 42.032
gaps 16-23: 38.059
gaps 24-31: 35.912
gaps 32-39: 34.069
gaps 40-47: 34.060
gaps 48-55: 29.433
gaps 56-63: 33.098
not monotonic: the dot RISES at 22 of the 64 steps, e.g.
gap 5 -> 6: 47.008 -> 47.119
இது என்ன என்பதில் நேர்மையாக இருப்போம். 64 positions-ல் 64-லிருந்து சுமார் 28 வரை இறங்குவது உண்மையான போக்குதான், paper-ன் figure-ம் (இதே கூட்டுத்தொகைக்கான ஒரு upper bound) அதே வடிவம்தான். ஆனால் 64 படிகளில் 22-ல் இது ஏறுகிறது, கடைசி bucket அதற்கு முந்தையதை விட உயரமானது. RoPE model-க்குக் கொடுப்பது அருகில் பார்க்கும் ஒரு நாட்டம், ஒரு விதி அல்ல. பயிற்சி பெற்ற model-க்குத் தேவைப்படும்போது தூரத்தையும் பார்க்கும்.
Q மற்றும் K மட்டும். V-ஐ ஒருபோதும் இல்லை.
இந்தப் பண்பு score-ல் வாழ்கிறது, எனவே rotation q மற்றும் k மீது,
W_q மற்றும் W_k-க்குப் பிறகு dot product-க்கு
முன்பு போடப்படுகிறது. V என்பது கலக்கப்படும் உள்ளடக்கம், அதைத் தொடக் கூடாது.
சோதனை: positions 0–4-ல் ஐந்து tokens, பிறகு அதே ஐந்து 100–104-ல். q, k
மீது மட்டும் RoPE இருந்தால் output சிறிதும் மாறக் கூடாது. பிறகு v-ஐயும்
திருப்பிப் பாருங்கள்:
same 5 tokens at positions 0-4, then at 100-104
CORRECT (q, k only): weights max|diff| = 2.2e-16
output max|diff| = 6.7e-16 <- shift-invariant
BROKEN (v too): output max|diff| = 2.0167 <- moves with the sentence
vs correct output = 2.0803
token 0, correct: [ 0.564 0.904 0.593 -0.455 0.815 -0.611 0.502 -0.447]
token 0, v too: [-0.437 -1.176 0.68 -0.335 0.827 -0.585 0.503 -0.445]
v-ஐயும் திருப்பினால் weights இன்னும் சரியாகவே இருக்கும் — அதே 2.2e-16 — ஆனால் ஒவ்வொரு token-ன் output-ம் இப்போது வாக்கியம் எங்கே தொடங்கியது என்பதைச் சார்ந்திருக்கும், அதைத்தான் RoPE நீக்க வந்தது. Softmax-க்குப் பிறகு திருப்புவது இன்னும் மோசம்: weights எந்த position-ம் இல்லாமல் கணக்கிடப்பட்டிருக்கும் (சாதாரண attention-லிருந்து max|diff|: 0.0000), எனவே attention page-ன் "cat ate food" = "food ate cat" பிரச்சனைக்கு மீண்டும் வந்துவிடுவீர்கள், மேலே ஒரு குழப்பமும் சேர்ந்து.
Base-ஐ மாற்று, கடிகாரத்தை மெதுவாக்கு
ஒவ்வொரு வேகமும் 10000−2i/d. 10000-ஐ 100000 ஆக்கினால் வேகமான முட்கள் கிட்டத்தட்ட கவனிக்காது (எந்த base-க்கும் pair 0 ஒரு position-க்கு 1 rad) ஆனால் மெதுவான முட்கள் இன்னும் மெதுவாகிவிடும். Script d = 128-ஐப் பயன்படுத்துகிறது — LLaMA-வின் per-head size — மேலும் position 4096-ஐப் பார்க்கிறது:
base 10000: slowest hand theta = 1.155e-04 rad/pos
at pos 4096: 0.4730 rad = 27.10 deg = 0.0753 turns
base 100000: slowest hand theta = 1.197e-05 rad/pos
at pos 4096: 0.0490 rad = 2.81 deg = 0.0078 turns
slow hand, base 100000: reaches the base-10000 pos-4096 angle at pos 39513
இதனால்தான் context extension சாத்தியம். 4096 tokens வரை பயிற்சி பெற்ற ஒரு
model, தன் மெதுவான முள்ளை 0-க்கும் 27 degrees-க்கும் இடையில் மட்டுமே
பார்த்திருக்கும்; அதே base-உடன் position 8192 கேட்டால் அந்த முள் அது
ஒருபோதும் இல்லாத இடத்தைக் காட்டும், தரம் சரிந்து விழும். Base-ஐ
உயர்த்தினால் மெதுவான முட்கள் நீட்டப்பட்டு 8192 பயிற்சி கண்ட வரம்புக்குள்
வந்துவிடும், அதே சமயம் local structure-ஐக் கவனிக்கும் வேகமான முட்கள்
கிட்டத்தட்ட தொடப்படாமல் இருக்கும். அதுதான் NTK-aware தந்திரம், அதனால்தான்
LLaMA 3-ன் config-ல் rope_theta 10000 அல்ல, 500000.
Position interpolation
அதற்குப் பதிலாக position/8-ஐக் கொடுக்கிறது (script அதையும் அச்சிடுகிறது),
ஆனால் அது வேகமான முட்கள் உட்பட ஒவ்வொரு முள்ளையும் சமமாக மெதுவாக்குகிறது,
அதனால்தான் அந்த paper பிறகு fine-tune செய்கிறது. இரண்டுமே
rope_freqs-ல் ஒரு வரி மாற்றம். இந்தப் பக்கம் இவ்வளவு
ஆழம்தான் போகிறது.
मैं एक साल तक LLaMA-family models serve कर चुका था, फिर भी बता नहीं सकता
था कि config में पड़ा rope_theta असल में करता क्या है। मुझे
पता था कि 2017 का paper हर token में एक
position vector जोड़ता है। यह भी पता था कि LLaMA ऐसा नहीं करता।
जो किसी ने नहीं बताया वह यह कि उसकी जगह आई चीज़ उससे भी छोटी है:
हर query और key को एक angle से घुमा दो, और dot product भूल जाता है कि
वाक्य शुरू कहाँ हुआ था।
Position घड़ी की सुई है
Attention page + pe पर ख़त्म होता है। यह आप जानते हैं:
x = embed(token) + pe[pos] # 2017: position एक vector है, उसे जोड़ो
q = x @ W_q
k = x @ W_k
score = q @ k # lookup से पहले ही position अंदर
RoPE position को embedding से निकालकर lookup के भीतर रख देता है। पहले project करो। फिर घुमाओ:
q = embed(token) @ W_q # अभी position नहीं
k = embed(token) @ W_k
q = turn(q, by=pos * theta) # 2021: position एक angle है, उससे rotate करो
k = turn(k, by=pos * theta)
score = q @ k # सिर्फ़ q और k के बीच का angle दिखता है
बस यही पूरा paper है। RoPE गुणा करता है; जोड़ता नहीं। फ़ायदा स्कूल की trigonometry है: घुमाए गए दो vectors का dot product सिर्फ़ उनके बीच के angle पर निर्भर करता है। q को 3θ और k को 7θ घुमाओ, या 13θ और 17θ — score वही रहेगा। Relative position मुफ़्त में, शून्य parameters।
d/2 सुइयों वाली एक घड़ी की कल्पना कीजिए। q में dimensions की हर जोड़ी एक सुई है, position यह है कि वह कितना घूम चुकी है, और attention ठीक एक ही चीज़ देखता है: q की सुई और k की सुई के बीच का angle। Script से एक सुई:
one hand, 30 deg per position, starting at [1. 0.]
pos 0: turned 0.0 deg -> [1. 0.] length 1.0000
pos 1: turned 30.0 deg -> [0.866 0.5 ] length 1.0000
pos 2: turned 60.0 deg -> [0.5 0.866] length 1.0000
pos 3: turned 90.0 deg -> [0. 1.] length 1.0000 ← कभी नहीं बदलती
| शब्द | मतलब | घड़ी वाली उपमा |
|---|---|---|
| Pair i = dims (2i, 2i+1) | q का एक 2D टुकड़ा | एक सुई |
| θi | हर position पर pair i कितने radians घूमती है | वह सुई कितनी तेज़ चलती है |
| m θi | Position m पर pair i का angle | सुई कहाँ इशारा कर रही है |
| (n − m) θi | Score को सिर्फ़ यही दिखता है | दो सुइयों के बीच का angle |
Vector जोड़ने से यह बेहतर क्यों निकला
जोड़ने में रिसाव है। (q + pe[m]) · (k + pe[n]) को खोलिए और cross terms q·pe[n] तथा pe[m]·k absolute position ढोते हैं, इसलिए "the cat sat" document की शुरुआत में एक score पाता है और बीच में दूसरा। RoPE से पहले के relative उपाय score matrix पर एक learned bias-per-gap table चिपकाते थे: parameters, memory, और उन linear-attention variants में चिपकाने को कुछ नहीं जो वह matrix बनाते ही नहीं। RoPE में न table है न parameters, वह बनावट से ही relative है, और किसी भी dot-product attention में सीधे बैठ जाता है। इसीलिए LLaMA, PaLM, GPT-NeoX, Mistral और उसके बाद के ज़्यादातर models इसे इस्तेमाल करते हैं।
वह एक सूत्र
q = Wqxm और k = Wkxn वही सामान्य projections हैं; R(m) rotation है। इसे pipeline के चरणों की तरह पढ़िए:
| चरण | Shape | यह क्या करता है |
|---|---|---|
θi = 10000−2i/d | (d/2,) | हर जोड़ी की अपनी रफ़्तार। Pair 0 हर position पर 1 rad घूमती है; आख़िरी pair ≈ 1/10000 |
m θi | (d/2,) | Position m पर हर सुई कितना घूम चुकी है |
R(m) | (d, d), block-diagonal | d/2 स्वतंत्र 2×2 rotations। Matrix कभी बनती ही नहीं — यह एक cos/sin गुणा है |
R(m) q | (d,) | Position समाया हुआ q। वही लंबाई, नई दिशा |
qm · kn | scalar | qT R(n − m) k के बराबर। सिर्फ़ फ़ासला बचता है |
बीच वाला चरण ही पूरा प्रमाण है। Rotations angles जोड़कर मिलती हैं और एक को transpose करने से वह उलट जाती है, इसलिए R(m)T R(n) = R(n − m)। वही identity paper है; बाक़ी सब हिसाब-किताब।
तेज़ सुइयाँ, धीमी सुइयाँ
हर जोड़ी की अपनी रफ़्तार है, जो परिमाण की चार कोटियों तक फैली है। d = 8 पर रफ़्तारें गोल संख्याएँ बनती हैं, इसलिए script यही छापता है:
d = 8, so 4 hands. theta_i = 10000^(-2i/d) = [1. 0.1 0.01 0.001]
at position 10, each hand has turned:
pair 0: 10.0000 rad = 572.96 deg = 1.5915 turns ← चक्कर पूरा कर चुकी
pair 1: 1.0000 rad = 57.30 deg = 0.1592 turns
pair 2: 0.1000 rad = 5.73 deg = 0.0159 turns
pair 3: 0.0100 rad = 0.57 deg = 0.0016 turns ← मुश्किल से हिली
Pair 0 सेकंड की सुई है: पड़ोसी tokens अलग कर देती है, पर लगभग हर छह positions में चक्कर पूरा कर लेती है और अकेले alias कर जाएगी। Pair 3 घंटे की सुई है: पड़ोसियों के लिए बेकार, पर position 10 और position 1000 का फ़र्क़ बता देती है। Interview का सवाल है "कई frequencies क्यों?"; जवाब है — सिर्फ़ सेकंड की सुइयों वाली घड़ी। संख्याएँ break table में हैं।
Score को सिर्फ़ फ़ासला दिखता है
यही paper का दावा है, इसलिए इसी की सबसे सावधान print है। वही q, वही k, पहले positions (3, 7) पर और फिर (13, 17) पर: दोनों बार फ़ासला 4। उसके बाद 2017 वाली जोड़ने की स्कीम, उन्हीं frequencies के साथ।
मेरी बात पर मत जाइए। यह रहा असली output:
RoPE, rotate: q@3 . k@7 = -1.261507812773
q@13 . k@17 = -1.261507812773
difference = 2.2e-16 <- float noise
2017, add: q@3 . k@7 = 3.650608771116
q@13 . k@17 = 3.830134199073
difference = 1.8e-01 <- absolute position leaks
unrotated q . k = -0.468002152973
gap 0, e.g. q@5 . k@5 = -0.468002152973 <- identical
आख़िरी दो लाइनें sanity check हैं: फ़ासला 0 पर rotation कट जाती है और आपको सादा dot product वापस मिलता है। वाक्य को दस आगे खिसकाइए, RoPE को पता तक नहीं चलता। जोड़ने वाली स्कीम 1.8e-01 खिसक जाती है — यह rounding नहीं, अलग score है।
दूर वाले कम score पाते हैं — औसतन
Paper का दूसरा दावा "long-term decay" है: दो tokens जितने दूर होंगे, उनका dot product उतना ही छोटा होने की प्रवृत्ति रखेगा। फिर वही घड़ी: फ़ासला 0 पर हर सुई एक कतार में होती है; फ़ासला बढ़ने पर तेज़ सुइयाँ बिखरकर एक-दूसरे को काट देती हैं, और सिर्फ़ धीमी सुइयाँ सहमत रह जाती हैं। Script q = k = सब एक रखता है, इसलिए जवाब बस cosines का जोड़ है:
q = k = all ones, d = 64. dot(q at 0, k at gap):
gap 0: 64.000
gap 1: 61.834
gap 16: 38.746
gap 24: 39.446 ← ऊपर चला गया
gap 64: 27.812
mean dot over gap buckets (the trend):
gaps 0- 7: 52.767
gaps 8-15: 42.032
gaps 16-23: 38.059
gaps 24-31: 35.912
gaps 32-39: 34.069
gaps 40-47: 34.060
gaps 48-55: 29.433
gaps 56-63: 33.098
not monotonic: the dot RISES at 22 of the 64 steps, e.g.
gap 5 -> 6: 47.008 -> 47.119
यह क्या है, इस बारे में ईमानदार रहिए। 64 positions में 64 से लगभग 28 तक गिरना असली रुझान है, और paper का figure (इसी जोड़ पर एक upper bound) भी इसी आकार का है। पर यह 64 में से 22 क़दमों पर ऊपर जाता है और आख़िरी bucket अपने पिछले से ऊँचा है। RoPE model को पास देखने की एक प्रवृत्ति देता है, नियम नहीं। ज़रूरत पड़ने पर trained model दूर तक भी attend करता है।
सिर्फ़ Q और K। V कभी नहीं।
यह गुण score में रहता है, इसलिए rotation q और k पर लगती है — ठीक
W_q और W_k के बाद और dot product से पहले। V वह
सामग्री है जो मिलाई जा रही है, उसे छेड़ा नहीं जाता। जाँच: positions 0–4 पर
पाँच tokens, फिर वही पाँच 100–104 पर। q और k पर ही RoPE हो तो output
ज़रा भी नहीं बदलना चाहिए। फिर v को भी घुमाकर देखिए:
same 5 tokens at positions 0-4, then at 100-104
CORRECT (q, k only): weights max|diff| = 2.2e-16
output max|diff| = 6.7e-16 <- shift-invariant
BROKEN (v too): output max|diff| = 2.0167 <- moves with the sentence
vs correct output = 2.0803
token 0, correct: [ 0.564 0.904 0.593 -0.455 0.815 -0.611 0.502 -0.447]
token 0, v too: [-0.437 -1.176 0.68 -0.335 0.827 -0.585 0.503 -0.445]
v घुमाने पर weights तब भी ठीक रहते हैं — वही 2.2e-16 — पर अब हर token का output इस पर निर्भर करता है कि वाक्य कहाँ शुरू हुआ, ठीक वही जिसे मिटाने के लिए RoPE है। Softmax के बाद घुमाना और भी बुरा है: weights तो बिना किसी position के गिने गए थे (सादे attention से max|diff|: 0.0000), यानी आप attention page वाली "cat ate food" बराबर "food ate cat" समस्या पर लौट आए, ऊपर से एक और घालमेल के साथ।
Base बदलिए, घड़ी धीमी कीजिए
हर रफ़्तार 10000−2i/d है। 10000 को 100000 कीजिए और तेज़ सुइयों को फ़र्क़ ही नहीं पड़ता (किसी भी base पर pair 0 हर position पर 1 rad है) जबकि धीमी सुइयाँ और धीमी हो जाती हैं। Script d = 128 लेता है, जो LLaMA का per-head size है, और position 4096 को देखता है:
base 10000: slowest hand theta = 1.155e-04 rad/pos
at pos 4096: 0.4730 rad = 27.10 deg = 0.0753 turns
base 100000: slowest hand theta = 1.197e-05 rad/pos
at pos 4096: 0.0490 rad = 2.81 deg = 0.0078 turns
slow hand, base 100000: reaches the base-10000 pos-4096 angle at pos 39513
इसीलिए context extension मुमकिन है। 4096 tokens तक trained model ने अपनी
सबसे धीमी सुई को सिर्फ़ 0 से 27 degrees के बीच ही देखा है; उसी base के साथ
position 8192 माँगिए और वह सुई ऐसी जगह इशारा करेगी जहाँ वह कभी रही नहीं,
और quality धड़ाम से गिर जाती है। Base बढ़ाइए तो धीमी सुइयाँ इतनी खिंच जाती
हैं कि 8192 उसी दायरे में आ जाता है जो training ने देखा था, जबकि local
structure सँभालने वाली तेज़ सुइयाँ लगभग अछूती रह जाती हैं। यही NTK-aware
तरकीब है, और इसीलिए LLaMA 3 के config में rope_theta 10000
नहीं, 500000 है।
Position interpolation
इसके बजाय position/8 खिलाता है (script वह भी छापता है), पर वह तेज़ सुइयों
समेत हर सुई को बराबर धीमा करता है, इसीलिए वह paper बाद में fine-tune करता
है। दोनों rope_freqs में एक लाइन का बदलाव हैं। यह page इतनी
ही गहराई तक जाता है।
The code
Pure NumPy. No torch, no autograd, no training — just dot products, so you
can watch the angle between two hands. Every function under 10 lines. The
script also has rope_half (the half-split pairing),
sinusoidal_pe (the 2017 add scheme) and the attention page's
softmax; they're short and not repeated here.
Download the full script — 39 lines of core functions plus seven runnable demos. Then:
python3 rope_from_scratch.py
import numpy as np
# STAGE 1 -- the tick rates. One per PAIR of dims, so d/2 of them:
# theta_i = base^(-2i/d), i = 0 .. d/2 - 1
# Pair 0 turns 1 radian per position (the fast hand); the last pair about
# 1/base (the slow hand). Same frequencies as the 2017 sinusoidal encoding.
# RoPE reuses them and does something different with them.
def rope_freqs(d, base=10000.0):
i = np.arange(d // 2)
return base ** (-2.0 * i / d)
# STAGE 2 -- THE WHOLE PAPER. Turn each 2D pair of dims by its own angle:
# [x1'] [cos a -sin a] [x1]
# [x2'] = [sin a cos a] [x2]
# Pair (0,1) gets angles[0], pair (2,3) gets angles[1], and so on. That is
# the paper's block-diagonal matrix R, never materialised: just a cos/sin
# multiply. No parameters. Nothing learned.
def rotate_pairs(x, angles):
"""x: (d,), angles: (d/2,). Pair i = dims (2i, 2i+1), turned by angles[i]."""
c, s = np.cos(angles), np.sin(angles)
x1, x2 = x[0::2], x[1::2]
out = np.empty_like(x)
out[0::2] = x1 * c - x2 * s
out[1::2] = x1 * s + x2 * c
return out
def rope(x, pos, base=10000.0):
"""Rotary embedding of vector x at integer position pos. Same shape out."""
return rotate_pairs(x, pos * rope_freqs(x.shape[-1], base))
# STAGE 3 -- attention with RoPE. The attention page's function plus two
# lines: turn each row of Q and K by its own position, right after the
# projections and before the dot product. V is left alone.
def attention_with_rope(Q, K, V, positions, base=10000.0):
Qr = np.stack([rope(q, m, base) for q, m in zip(Q, positions)])
Kr = np.stack([rope(k, n, base) for k, n in zip(K, positions)])
scores = Qr @ Kr.T / np.sqrt(Q.shape[-1])
weights = softmax(scores, axis=-1)
return weights @ V, weights
Now break it
This is the part that actually makes it stick. Reading won't do it. You debug for a living, and every row below is already wired into a demo.
| Break this | Watch for |
|---|---|
In attention_with_rope, rotate V as well: V = np.stack([rope(v, m) for v, m in zip(V, positions)]) | Demo 4. The same five tokens at 0–4 and at 100–104 now give outputs that differ by 2.0167. Correct version: 6.7e-16. |
Add instead of turn. In demo 2 replace rope(q, 3) with q + sinusoidal_pe(3, d), and likewise for k | The gap-4 score moves by 1.8e-01 when the sentence shifts by ten. The relative property is gone; you have rebuilt the 2017 scheme. |
Mix pairing conventions: rope(q, 3) @ rope_half(k, 7) (demo 6 does this on its last line) | 11.62921 at (3, 7) but 0.55906 at (13, 17). Either convention alone gives identical numbers under the shift. Hugging Face LLaMA uses rope_half; Meta's code uses adjacent pairs; the weight converter permutes Wq, Wk to match. |
One tick rate for every pair: make rope_freqs return np.full(d // 2, 2 * np.pi / 8) (demo 7 does this locally) | Gap 1, gap 9 and gap 17 all score 2.242033. Positions alias every 8 tokens; the model cannot tell one token back from nine. |
Rotate after the softmax: leave Q, K alone and apply rope to the rows of weights @ V | Demo 4, second block. The attention weights are identical to plain no-position attention, max|diff| 0.0000. Order-blind again, with the outputs scrambled by absolute position for good measure. |
On the maths anxiety
This paper needs less maths than the attention one. A 2×2 rotation matrix is four numbers made of one cos and one sin. "Rotations compose by adding angles" is the only fact used, and you have known it since you first turned a dial. The dot product as similarity, from the attention page, does the rest.
The paper writes the rotation as a complex number, q · eimθ. That is the same 2×2 rotation in shorter notation; read every eimθ as "turn by mθ" and you lose nothing. The one part you can genuinely skip is the Abel-transform bound in the decay section: it proves the trend from demo 3 and adds no mechanism.
What matters for the job: the rotation goes on q and k, not v; know which
pairing convention your checkpoint uses before you port it; and
rope_theta and rope_scaling in a config are the two
knobs from the last section. None of that is research maths.
Where to go next
This page gave you one thing: RoPE as a clock, in code you can break. Two other pieces cover what I skipped, each better at its angle than I'd be.
-
RoFormer: Enhanced Transformer with Rotary Position Embedding
Read the 2D case first: half a page, and it is demo 1. The general form is demo 2 written as a block-diagonal matrix; the long-term decay figure is demo 3. Everything after that is linear attention and benchmarks.
-
Rotary Embeddings: A Relative Revolution
The team that put RoPE into GPT-NeoX and GPT-J. It does the complex-number derivation I waved at, shows the
rotate_halftrick production code uses instead of my pairwise loop, and has the training curves against the alternatives that I did not reproduce. -
You could have designed state of the art positional encoding
Starts from "what properties would you want?" and arrives at RoPE by elimination: integer positions, then binary, then sinusoids, then the rotation. It is the why behind the frequency set I simply asserted.