Skip to content
Skip to lesson
Back to Topics
LearnTransformer Deep DivesPositional Encoding: RoPE & ALiBi
🧠HardTransformer Architecture

Positional Encoding: RoPE & ALiBi

Understand why transformers need position information, how sinusoidal encodings work, how RoPE and ALiBi encode relative position, and why long-context extrapolation needs careful evaluation.

53 min read

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

"The model loaded the checkpoint" and "The checkpoint loaded the model" share the exact same words, but reverse who loaded what. Content-only, unmasked self-attention follows each word wherever it moves; it has no slot index that marks the subject as preceding the object.

expose that gap with image patches, and text models face the exact same dilemma. The mechanism compares content, not sequence position. Predict what happens when the rows for "model", "loaded", and "ckpt" are shuffled.

The answer gives us our first foundational diagnostic: unmasked attention shuffles its outputs right alongside its inputs. That's permutation equivariance. A breaks that symmetry inside a . It can enter as an additive vector merged into , a rotation on queries and keys, or 's linear distance bias on attention .

Then we'll stress that same principle at long context lengths. A mathematical formula can produce a value at position 32,000 even when pretraining stopped at 4,096. That calculation is valid arithmetic, but it isn't evidence that the model can actually retrieve a buried passkey there.

One boundary stays fixed throughout: a position method can rank permitted keys, while a causal mask must still hide future keys. A reliable diagnostic test reorders the sequence, verifies the permitted scores, and confirms that forbidden positions remain forbidden.


First, watch attention lose the order

Before adding any position feature, make one prediction. If every token vector stays the same but the rows move, should content-only attention preserve the original row order, or should its outputs move with them?

Standard self-attention computes:

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right) V

For an input sequence of NN tokens represented as matrix X∈RN×dmodelX \in \mathbb{R}^{N \times d_{\text{model}}}, linear projection matrices WQ,WK∈Rdmodel×dkW_Q, W_K \in \mathbb{R}^{d_{\text{model}} \times d_k} and WV∈Rdmodel×dvW_V \in \mathbb{R}^{d_{\text{model}} \times d_v} produce Q=XWQQ = X W_Q, K=XWKK = X W_K, and V=XWVV = X W_V. The dot product QKTQ K^T forms an N×NN \times N score matrix. Dividing by dk\sqrt{d_k} controls variance under the usual independent, unit-variance component assumptions. Row-wise produces attention weight matrix AA, which mixes values into output Y=AV∈RN×dvY = A V \in \mathbb{R}^{N \times d_v}. No term in that formula reads "first", "second", or "third."[1]

Now apply an arbitrary N×NN \times N permutation matrix PP. Permuting the input rows yields X′=PXX' = P X. Because matrix multiplication is associative, the projections reorder identically:

Q′=(PX)WQ=PQ,K′=(PX)WK=PK,V′=(PX)WV=PVQ' = (P X) W_Q = P Q, \quad K' = (P X) W_K = P K, \quad V' = (P X) W_V = P V

Compute the attention logits under permutation:

S′=Q′(K′)Tdk=(PQ)(PK)Tdk=P(QKTdk)PT=PSPTS' = \frac{Q' (K')^T}{\sqrt{d_k}} = \frac{(P Q)(P K)^T}{\sqrt{d_k}} = P \left(\frac{Q K^T}{\sqrt{d_k}}\right) P^T = P S P^T

Row-wise softmax distributes cleanly over row and column permutations:

A′=softmax(PSPT)=Psoftmax(S)PT=PAPTA' = \text{softmax}(P S P^T) = P \text{softmax}(S) P^T = P A P^T

Finally, multiply by permuted values V′=PVV' = P V:

Y′=A′V′=(PAPT)(PV)=PA(PTP)VY' = A' V' = (P A P^T)(P V) = P A (P^T P) V

Because any permutation matrix is orthogonal (PTP=IP^T P = I), the inner permutation matrices cancel out completely:

Y′=PAIV=P(AV)=PYY' = P A I V = P (A V) = P Y

That equality, Attention(PX)=PAttention(X)\text{Attention}(P X) = P \text{Attention}(X), defines permutation equivariance for this deterministic, unmasked calculation. Shuffling input rows shuffles output rows; the output matrix is not unchanged. Token-wise projections, residual additions, and token-wise normalization preserve this property. Dropout needs to be disabled or its sampled mask permuted consistently for an exact numerical comparison.

Use a three-token slice of our running example, with one 2D vector per token:

TokenVector
model[1,0][1, 0]
loaded[0,1][0, 1]
ckpt[1,1][1, 1]

Permutation P=[2,0,1]P = [2, 0, 1] rewrites the sequence as ckpt, model, loaded. Unmasked attention on the shuffled rows produces exactly the shuffled original output: Y′=PYY' = P Y. The block preserves content relationships, but remains blind to command order.

Two attention traces compare token order before and after permutation P=[2,0,1]. Token rows and attention-weight matrices reorder together, producing Y' = P Y and showing permutation equivariance without a position signal.
Unmasked self-attention is content-based. The weight matrix reorders with the tokens. Position encodings supply the missing index, direction, and distance signal.

The small check below recomputes attention after the permutation. Read True as a diagnosis: no hidden slot number entered the calculation.

permutation-equivariance.py
import math

tokens = {
    "model": [1.0, 0.0],
    "loaded": [0.0, 1.0],
    "ckpt": [1.0, 1.0],
}
order = ["model", "loaded", "ckpt"]
permutation = [2, 0, 1]

def attention(rows: list[list[float]]) -> list[list[float]]:
    scores = [
        [sum(q_i * k_i for q_i, k_i in zip(q, k)) / math.sqrt(2) for k in rows]
        for q in rows
    ]
    outputs = []
    for row in scores:
        maximum = max(row)
        exponentials = [math.exp(value - maximum) for value in row]
        normalizer = sum(exponentials)
        weights = [value / normalizer for value in exponentials]
        outputs.append([
            sum(weight * value[column] for weight, value in zip(weights, rows))
            for column in range(2)
        ])
    return outputs

baseline = attention([tokens[name] for name in order])
shuffled = attention([tokens[order[index]] for index in permutation])
expected = [baseline[index] for index in permutation]

same_output_up_to_permutation = all(
    abs(actual - target) < 1e-12
    for actual_row, target_row in zip(shuffled, expected)
    for actual, target in zip(actual_row, target_row)
)
print(same_output_up_to_permutation)
print([round(row[0], 3) for row in baseline])
print([round(row[0], 3) for row in shuffled])
Output
True
[0.802, 0.599, 0.752]
[0.752, 0.802, 0.599]

A changes the symmetry by removing future keys from view. In a decoder, token ii sees keys j≤ij \le i, including itself. If token rows move while that triangular mask stays fixed, the score calculation generally changes. Permuting the mask along with the rows would preserve equivariance, but would represent a different visibility pattern.

Haviv et al. trained NoPos causal language models and found competitive perplexity in their tested 125M-to-1.3B settings. Probes recovered approximate absolute positions; counting visible predecessors is their proposed explanation, not a proven counter built into softmax.[2] Identical value rows illustrate the limit: their weighted average stays identical even when one query sees more rows. The mask permits position learning; it does not guarantee it for every input or model.

Parallel prefill still uses the decoder's causal mask. Computing many query rows at once does not make them bidirectional. A BERT-style masked-language-model objective is different: masking some input words does not itself impose triangular attention visibility. Without any position or order-dependent signal, an unmasked block stays permutation equivariant and can still learn content relationships. Haviv et al.'s bidirectional NoPos language-model experiment degraded sharply; that is not a claim that every permutation-equivariant task fails.

Absolute encodings, RoPE, and ALiBi insert position at different points in the block. We'll compare them separately. Architectures can combine position mechanisms; reproducing a checkpoint means following its actual design.

The table is a map of those insertion points. For each row, ask what downstream quantity can first "feel" position.

Three aligned attention pipelines compare where position enters: absolute encodings add vectors at token embeddings, RoPE rotates projected queries and keys in 2D subspaces, and ALiBi injects linear distance penalties directly into pre-softmax attention logits.
Absolute encodings change the input vector, RoPE changes Q/K geometry, and ALiBi changes the pre-softmax score. One block, three insertion points.

When debugging a position mismatch, inspect the first tensor that changed: the embedding, projected Q/K, or attention score matrix. A method added accidentally changes the checkpoint's computation even if another position mechanism is already present.

The first map entry adds position before any Q, K, or V projection. Start there, then we'll move the signal deeper into attention.


Absolute position: give every slot a multi-speed fingerprint

Nearby slots need distinguishable labels, while far slots need a slower signal that tracks broad location. The original Transformer meets both needs with several paired waves added to each token embedding.[1]

At position 0, every sine starts at 0 and every cosine at 1. Move to position 2 and the fastest pair has moved two radians, while slower pairs barely move. The combined phases form a position fingerprint before the network sees Q, K, or V.

For position pos\text{pos} and dimension ii, with dmodeld_{\text{model}} the embedding width:

PE(pos,2i)=sin⁡(pos100002i/dmodel)PE_{(\text{pos}, 2i)} = \sin\left(\frac{\text{pos}}{10000^{2i/d_{\text{model}}}}\right)

PE(pos,2i+1)=cos⁡(pos100002i/dmodel)PE_{(\text{pos}, 2i+1)} = \cos\left(\frac{\text{pos}}{10000^{2i/d_{\text{model}}}}\right)

The paired waves carry one useful algebraic property. For a pair ordered as [sin⁡(pω),cos⁡(pω)][\sin(p\omega),\cos(p\omega)], shifting by kk gives

[sin⁡((p+k)ω)cos⁡((p+k)ω)]=[cos⁡(kω)sin⁡(kω)−sin⁡(kω)cos⁡(kω)][sin⁡(pω)cos⁡(pω)].\begin{bmatrix} \sin((p+k)\omega) \\ \cos((p+k)\omega) \end{bmatrix} =\begin{bmatrix} \cos(k\omega)&\sin(k\omega) \\ -\sin(k\omega)&\cos(k\omega) \end{bmatrix} \begin{bmatrix} \sin(p\omega) \\ \cos(p\omega) \end{bmatrix}.

The depends on the offset, not the starting position. Its sign convention follows the sine-first ordering; RoPE below uses ordinary Cartesian [x,y][x,y] pairs. This algebra gives later layers a route to learning relative relationships. Adding the vectors doesn't itself force attention to depend only on relative offset.

The generator below makes the dial picture concrete with an 8-by-8 table. Before running it, predict row 0 and the first pair at row 2.

sinusoidal-positional-encoding.py
import math

def sinusoidal_positional_encoding(max_len: int, d_model: int) -> list[list[float]]:
    if (type(max_len) is not int or type(d_model) is not int
            or max_len < 0 or d_model <= 0 or d_model % 2):
        raise ValueError("max_len must be nonnegative and d_model positive and even")
    table: list[list[float]] = []
    for pos in range(max_len):
        row = [0.0] * d_model
        for i in range(0, d_model, 2):
            frequency = math.exp(-(math.log(10000.0) * i) / d_model)
            angle = pos * frequency
            row[i] = math.sin(angle)
            row[i + 1] = math.cos(angle)
        table.append(row)
    return table

pe = sinusoidal_positional_encoding(max_len=8, d_model=8)
print(len(pe), len(pe[0]))
print([round(x, 3) for x in pe[0]])
print(round(pe[2][0], 3), round(pe[2][1], 3))
Output
8 8
[0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
0.909 -0.416

The dials have different wavelengths. With dmodel=512d_{\text{model}}=512, a pair whose first dimension is rr has wavelength λr=2π⋅10000r/dmodel\lambda_r = 2\pi\cdot10000^{r/d_{\text{model}}}. Here r=2ir=2i: the pair index and the dimension index aren't interchangeable.

Dimension pairWavelengthWhat it tracks
(0,1)(0, 1)∼6\sim 6 tokensFine local position
(64,65)(64, 65)∼20\sim 20 tokensMedium-scale structure
(510,511)(510, 511)∼60,000\sim 60{,}000 tokensCoarse global position
sinusoidal-wavelengths.py
import math

d_model = 512
for first_dimension in [0, 64, 510]:
    angular_frequency = 10000 ** (-first_dimension / d_model)
    wavelength = 2 * math.pi / angular_frequency
    print(first_dimension, round(wavelength))
Output
0 6
64 20
510 60611

The waves are easier to read as lanes than as a matrix:

Three sinusoidal positional-encoding lanes at d_model=512 show fast, medium, and slow frequency pairs. A shared p=12 marker samples each sine/cosine pair and forms a multi-scale position fingerprint.
At position 12, the fast pair has wound almost twice around while the slow pair has barely moved. The dials plot cosine horizontally and sine vertically; the encoding stores sine first.

What addition guarantees, and what it leaves learned

Addition gives the attention dot product several ways to use position. Let token mm have content vector xmx_m and position vector pmp_m, and let token nn have content xnx_n and position pnp_n. The network projects the combined sums:

qm=(xm+pm)WQ,kn=(xn+pn)WKq_m = (x_m + p_m) W_Q, \quad k_n = (x_n + p_n) W_K

Expanding their unscaled attention dot product yields four terms:

qmknT=(xm+pm)WQWKT(xn+pn)T=xmWQWKTxnT⏟content-content+xmWQWKTpnT⏟content-position+pmWQWKTxnT⏟position-content+pmWQWKTpnT⏟position-positionq_m k_n^T = (x_m + p_m) W_Q W_K^T (x_n + p_n)^T = \underbrace{x_m W_Q W_K^T x_n^T}_{\text{content-content}} + \underbrace{x_m W_Q W_K^T p_n^T}_{\text{content-position}} + \underbrace{p_m W_Q W_K^T x_n^T}_{\text{position-content}} + \underbrace{p_m W_Q W_K^T p_n^T}_{\text{position-position}}

Here vectors use the row convention of Q=XWQQ=XW_Q; qmknTq_m k_n^T is a scalar. The familiar column-vector notation is qmTknq_m^T k_n after transposing both vectors consistently.

The expansion separates three questions:

  1. Is the position-only term relative? If WQWKT=IW_Q W_K^T=I, each sine/cosine pair contributes cos⁡((m−n)ω)\cos((m-n)\omega), so that term depends only on offset. For a general learned product, shared-shift invariance is not guaranteed. More broadly it requires MkTAMk=AM_k^T A M_k=A for column vectors with bilinear matrix AA; an exact block-diagonal rotation is not the only solution. Even setting WQ=WKW_Q=W_K does not ensure this condition.
  2. Do the cross-terms vanish? No. Content can interact with absolute position, which may help some tasks or hurt shift generalization. Their existence does not prove a particular learned spurious affinity.
  3. Can we supply an unseen position? A learned table with Lmax⁡L_{\max} rows has indices 00 through Lmax⁡−1L_{\max}-1. Position Lmax⁡L_{\max} is already outside it; extending or remapping rows changes the design and needs adaptation.[3][4] A sinusoidal formula has no finite table boundary, but a cached implementation may still need a larger buffer. Its bounded sine/cosine coordinates cannot by themselves guarantee quality or force exploding logits. Press et al. measured poor length extrapolation for their sinusoidal language models.[7]
PropertySinusoidalLearned
Parameters0 (fixed formula)Lmax⁡×dL_{\max} \times d
FlexibilityFixed inductive biasAdapts during training
Past the supplied/trained rangeFormula is defined; buffer limits and quality still need checkingExtend or remap missing rows before lookup
Original Transformer translation ablationFixed encoding baselineNearly identical result in that experiment

Vaswani et al. tested both and reported "nearly identical results" (Table 3, row E). Sinusoids were attractive because their formula can be evaluated at unseen indices, but definition isn't generalization.

Why add instead of concatenate? Put two dd-wide vectors side by side and the next layer sees width 2d2d. Unless you project back down, square matrices such as WQW_Q, WKW_K, and WVW_V then grow about 4x. Addition keeps width dd, but it also gives later projections one mixed vector rather than separate content and position channels.

Absolute methods leave us with three questions. Does quality hold beyond the trained length? Can the model infer an offset from two absolute vectors? Is mixing content and position in one stream a good trade? Relative methods move the distance signal into attention itself, which is the next experiment.


RoPE: make a shared offset visible inside QK

Now move the position signal past the embedding stage. Keep one query vector and one key vector fixed, then place them at positions (m,n)(m, n). If both positions shift forward by 100 slots, should their attention routing score change?

Rotary Position Embedding (RoPE) rotates query and key vectors in 2D subspaces.[5] For fixed unrotated Q/K and one shared frequency schedule, its explicit position dependence reduces to the relative offset n−mn-m. Earlier layers can already make those unrotated vectors depend on context and position. The identity below is a statement about this rotation step, not translation invariance of an entire trained model.

Two RoPE unit-circle pairs compare positions (m=2,n=1) and (m=102,n=101) at theta=0.5. Both have relative phase delta=-0.5 and the same q-prime dot k-prime score of 0.877583 after shifting both positions by 100.
Shift both tokens by 100. Absolute angles change. The relative phase stays $-0.5$, so the positional part of $q'^{\top}k'$ stays $0.878$.

The 2D rotation matrix and relative offset invariance

To encode position into a 2D coordinate pair (q2i,q2i+1)(q_{2i}, q_{2i+1}) at sequence index mm, RoPE applies a 2D rotation matrix Rθi,mR_{\theta_i, m}:

(q2i(m)q2i+1(m))=(cos⁡(mθi)−sin⁡(mθi)sin⁡(mθi)cos⁡(mθi))(q2iq2i+1)\begin{pmatrix} q_{2i}^{(m)} \\ q_{2i+1}^{(m)} \end{pmatrix} = \begin{pmatrix} \cos(m\theta_i) & -\sin(m\theta_i) \\ \sin(m\theta_i) & \cos(m\theta_i) \end{pmatrix} \begin{pmatrix} q_{2i} \\ q_{2i+1} \end{pmatrix}

For a full head dimension dkd_k (where dkd_k is even), RoPE constructs a block-diagonal rotation matrix RΘ,m∈Rdk×dkR_{\Theta, m} \in \mathbb{R}^{d_k \times d_k} consisting of dk/2d_k / 2 independent 2D rotation blocks:

RΘ,m=(Rθ0,m0…00Rθ1,m…0⋮⋮⋱⋮00…Rθdk/2−1,m)R_{\Theta, m} = \begin{pmatrix} R_{\theta_0, m} & 0 & \dots & 0 \\ 0 & R_{\theta_1, m} & \dots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \dots & R_{\theta_{d_k/2 - 1}, m} \end{pmatrix}

The angular frequencies follow a geometric progression parameterized by rotary dimension drotary≤dkd_{\text{rotary}} \le d_k and base bb:

θi=b−2i/drotary,i∈{0,1,…,drotary2−1}\theta_i = b^{-2i/d_{\text{rotary}}}, \quad i \in \left\{0, 1, \dots, \frac{d_{\text{rotary}}}{2} - 1\right\}

For partial RoPE, build these blocks only for the even-sized rotary prefix and use an identity transform on the remaining coordinates. The rotation preserves the full head's norm. The original schedule uses base 1000010000; checkpoint bases and frequency rescaling vary. Raising the base slows pairs with i>0i>0, while the first frequency θ0=1\theta_0=1 stays unchanged. Llama 3.1 uses a base of 500,000500{,}000 plus a piecewise frequency transform, discussed below.[8]

Now examine the inner product between query q(m)=RΘ,mqq^{(m)} = R_{\Theta, m} q and key k(n)=RΘ,nkk^{(n)} = R_{\Theta, n} k:

(q(m))Tk(n)=(RΘ,mq)T(RΘ,nk)=qT(RΘ,mTRΘ,n)k(q^{(m)})^T k^{(n)} = (R_{\Theta, m} q)^T (R_{\Theta, n} k) = q^T (R_{\Theta, m}^T R_{\Theta, n}) k

Because RΘ,mR_{\Theta, m} is block-diagonal, evaluate the product block-by-block. For one 2D pair at frequency θ\theta:

Rθ,mTRθ,n=(cos⁡(mθ)sin⁡(mθ)−sin⁡(mθ)cos⁡(mθ))(cos⁡(nθ)−sin⁡(nθ)sin⁡(nθ)cos⁡(nθ))R_{\theta, m}^T R_{\theta, n} = \begin{pmatrix} \cos(m\theta) & \sin(m\theta) \\ -\sin(m\theta) & \cos(m\theta) \end{pmatrix} \begin{pmatrix} \cos(n\theta) & -\sin(n\theta) \\ \sin(n\theta) & \cos(n\theta) \end{pmatrix}

Multiplying out the matrix entries using standard trigonometric difference identities yields:

(Rθ,mTRθ,n)11=cos⁡(mθ)cos⁡(nθ)+sin⁡(mθ)sin⁡(nθ)=cos⁡((n−m)θ)(Rθ,mTRθ,n)12=−cos⁡(mθ)sin⁡(nθ)+sin⁡(mθ)cos⁡(nθ)=−sin⁡((n−m)θ)(Rθ,mTRθ,n)21=−sin⁡(mθ)cos⁡(nθ)+cos⁡(mθ)sin⁡(nθ)=sin⁡((n−m)θ)(Rθ,mTRθ,n)22=sin⁡(mθ)sin⁡(nθ)+cos⁡(mθ)cos⁡(nθ)=cos⁡((n−m)θ)\begin{align*} (R_{\theta, m}^T R_{\theta, n})_{11} &= \cos(m\theta)\cos(n\theta) + \sin(m\theta)\sin(n\theta) = \cos((n-m)\theta) \\ (R_{\theta, m}^T R_{\theta, n})_{12} &= -\cos(m\theta)\sin(n\theta) + \sin(m\theta)\cos(n\theta) = -\sin((n-m)\theta) \\ (R_{\theta, m}^T R_{\theta, n})_{21} &= -\sin(m\theta)\cos(n\theta) + \cos(m\theta)\sin(n\theta) = \sin((n-m)\theta) \\ (R_{\theta, m}^T R_{\theta, n})_{22} &= \sin(m\theta)\sin(n\theta) + \cos(m\theta)\cos(n\theta) = \cos((n-m)\theta) \end{align*}

Combining those terms proves the fundamental RoPE identity:

Rθ,mTRθ,n=Rθ,n−mR_{\theta, m}^T R_{\theta, n} = R_{\theta, n-m}

With fixed Q/K and the same frequencies for both sides, absolute indices cancel from the explicit rotation:

(RΘ,mq)T(RΘ,nk)=qTRΘ,n−mk(R_{\Theta, m} q)^T (R_{\Theta, n} k) = q^T R_{\Theta, n-m} k

Shift both indices by 1,000 while keeping Q/K and the frequency schedule fixed. The rotation matrix RΘ,(n+1000)−(m+1000)=RΘ,n−mR_{\Theta, (n+1000) - (m+1000)} = R_{\Theta, n-m} gives the same score in exact arithmetic. Finite-precision angles can introduce numerical differences.

Complex number formulation and implementation

Identifying each 2D coordinate pair (q2i,q2i+1)(q_{2i}, q_{2i+1}) with a complex number z=q2i+iq2i+1∈Cz = q_{2i} + i q_{2i+1} \in \mathbb{C} reveals the rotation as complex multiplication:

q(m)=(q2i+iq2i+1)eimθi,k(n)=(k2i+ik2i+1)einθiq^{(m)} = (q_{2i} + i q_{2i+1}) e^{i m \theta_i}, \quad k^{(n)} = (k_{2i} + i k_{2i+1}) e^{i n \theta_i}

The Euclidean inner product of two real 2D vectors equals the real part of their complex inner product Re(z1z2∗)\text{Re}(z_1 z_2^*):

⟨q(m),k(n)⟩=Re((qeimθi)(keinθi)∗)=Re(qkˉei(m−n)θi)\langle q^{(m)}, k^{(n)} \rangle = \text{Re}\left( (q e^{i m \theta_i}) (k e^{i n \theta_i})^* \right) = \text{Re}\left( q \bar{k} e^{i (m - n)\theta_i} \right)

In real tensor arithmetic, avoid building the sparse dk×dkd_k \times d_k matrix. Instead, apply the rotation via elementwise products:

Rθi,m(q2iq2i+1)=(q2iq2i+1)⊙(cos⁡(mθi)cos⁡(mθi))+(−q2i+1q2i)⊙(sin⁡(mθi)sin⁡(mθi))R_{\theta_i, m} \begin{pmatrix} q_{2i} \\ q_{2i+1} \end{pmatrix} = \begin{pmatrix} q_{2i} \\ q_{2i+1} \end{pmatrix} \odot \begin{pmatrix} \cos(m\theta_i) \\ \cos(m\theta_i) \end{pmatrix} + \begin{pmatrix} -q_{2i+1} \\ q_{2i} \end{pmatrix} \odot \begin{pmatrix} \sin(m\theta_i) \\ \sin(m\theta_i) \end{pmatrix}

This operation has two core properties:

  1. Norm preservation: An orthogonal rotation satisfies ∥RΘ,mq∥2=∥q∥2\|R_{\Theta,m}q\|_2=\|q\|_2 in exact arithmetic. Individual coordinate variances can change; preservation of vector length does not prove unchanged per-coordinate statistics. An extra YaRN amplitude factor is a separate scaling operation.
  2. Routing versus aggregation: Standard RoPE here rotates Q/K and leaves V unrotated. That introduces position into routing without directly rotating the retrieved values. Other trained architectures can modify values; adding an unexpected V rotation to this checkpoint would change its computation.
rope-relative-offset.py
import math

def rotate_2d(vec: list[float], pos: int, theta: float) -> list[float]:
    cosine, sine = math.cos(pos * theta), math.sin(pos * theta)
    x, y = vec
    return [x * cosine - y * sine, x * sine + y * cosine]

def dot(left: list[float], right: list[float]) -> float:
    return sum(x * y for x, y in zip(left, right))

query = [0.8, 0.6]
key = [0.7, 0.5]
theta = 0.5
score_a = dot(rotate_2d(query, 5, theta), rotate_2d(key, 2, theta))
score_b = dot(rotate_2d(query, 105, theta), rotate_2d(key, 102, theta))
print(round(score_a, 6), round(score_b, 6))
Output
0.040884 0.040884

The output confirms the shared-shift prediction. It also marks the limit of this toy proof: a trained model’s depends on content-dependent Q and K vectors across many layers, not on this identity alone.

Use a unit-vector walk-through to isolate the phase term. Let q=k=[1,0]q = k = [1, 0] and θ=0.5\theta = 0.5.

  • Query at m=2m=2, key at n=1n=1: offset d=n−m=−1d = n-m = -1, score cos⁡(−0.5)≈0.878\cos(-0.5) \approx 0.878.
  • Query at m=102m=102, key at n=101n=101: same offset, same score.

At offset −2-2, the same pair scores cos⁡(−1.0)≈0.540\cos(-1.0) \approx 0.540. Don’t turn that comparison into “farther is always weaker.” Cosine phases can rise again, and a real head sums many frequencies.

Take a head with dk=drotary=8d_k=d_{\text{rotary}}=8, containing four pairs. With the standard base-1000010000 schedule, their frequencies are 11, 0.10.1, 0.010.01, and 0.0010.001. For q=k=[1,0]q=k=[1,0] in every pair and positions m=4m=4, n=2n=2, predict which lane turns negative before looking at the figure. The pair scores are cos⁡(−2θi)\cos(-2\theta_i):

RoPE with rotary and key dimensions both 8. Four pair scores sum to a dot product of 2.564. Dividing by sqrt(8) gives the scaled attention logit 0.906.
The fast pair has gone negative at offset $-2$. The slow pairs still look almost aligned. Sum the four pair scores to get the dot product, then divide by $\sqrt{d_k}$ for the attention logit.

Complex multiplication is the same 2D rotation: (x+iy) eimθ(x + iy)\,e^{i m \theta}. The next helper rotates a whole head, then checks the shared-shift property across all its pairs.

rope-complex-rotation.py
import cmath
import math

def rope_freqs(dim: int, base: float = 10000.0) -> list[float]:
    if type(dim) is not int or dim <= 0 or dim % 2 or not math.isfinite(base) or base <= 0:
        raise ValueError("rotary dimension must be positive and even; base must be positive")
    return [1.0 / (base ** (index / dim)) for index in range(0, dim, 2)]

def rotate(vec: list[float], pos: int, freqs: list[float]) -> list[float]:
    if len(vec) != 2 * len(freqs):
        raise ValueError("this helper rotates the full vector: one frequency per pair")
    rotated: list[float] = []
    for pair, freq in enumerate(freqs):
        x, y = vec[2 * pair], vec[2 * pair + 1]
        spun = complex(x, y) * cmath.rect(1.0, pos * freq)
        rotated.extend([spun.real, spun.imag])
    return rotated

def dot(left: list[float], right: list[float]) -> float:
    return sum(x * y for x, y in zip(left, right))

freqs = rope_freqs(dim=4)
vec = [1.0, 0.0, 1.0, 0.0]
score_a = dot(rotate(vec, 0, freqs), rotate(vec, 2, freqs))
score_b = dot(rotate(vec, 1, freqs), rotate(vec, 3, freqs))
print(round(score_a, 6), round(score_b, 6))
print("norm preserved:", abs(dot(vec, vec) - dot(rotate(vec, 2, freqs), rotate(vec, 2, freqs))) < 1e-12)
Output
0.583653 0.583653
norm preserved: True

This helper uses adjacent pairs and rotates the entire vector. With partial RoPE, rotate only the configured prefix and concatenate the untouched suffix. Other implementations pair the first and second halves instead. These conventions are equivalent after a matching coordinate permutation, but swapping them on an existing checkpoint without converting its projections changes its scores.[9]

For four coordinates, adjacent pairing reads [(x0, y0), (x1, y1)] from [x0, y0, x1, y1]. Split-half rotate_half reads the same pairs from [x0, x1, y0, y1]: its partner transform is [-y0, -y1, x0, x1]. The cosine/sine arrays must follow that layout too. Meta's reference uses adjacent complex pairs; Transformers' Llama code uses split halves.[8][9]

Both rotations preserve norms, so a norm check won't catch a layout mismatch. Compare scores after converting both Q and K, then deliberately skip the conversion:

rope-pair-layouts.py
import math

def rotate_layout(vector, position, frequencies, split_half=False):
    result = vector.copy()
    half = len(vector) // 2
    for pair, frequency in enumerate(frequencies):
        a, b = (pair, pair + half) if split_half else (2 * pair, 2 * pair + 1)
        cosine, sine = math.cos(position * frequency), math.sin(position * frequency)
        result[a] = vector[a] * cosine - vector[b] * sine
        result[b] = vector[a] * sine + vector[b] * cosine
    return result

def score(q, k, split_half=False):
    rq = rotate_layout(q, 5, [1.0, 0.01], split_half)
    rk = rotate_layout(k, 2, [1.0, 0.01], split_half)
    return sum(a * b for a, b in zip(rq, rk))

q, k = [1.0, 2.0, 3.0, 4.0], [4.0, 3.0, 2.0, 1.0]
permutation = [0, 2, 1, 3]
converted_q = [q[i] for i in permutation]
converted_k = [k[i] for i in permutation]
adjacent = score(q, k)
converted = score(converted_q, converted_k, split_half=True)
wrong_layout = score(q, k, split_half=True)

print("adjacent score", round(adjacent, 6))
print("converted split-half score", round(converted, 6))
print("unconverted split-half score", round(wrong_layout, 6))
assert math.isclose(adjacent, converted, abs_tol=1e-12)
assert not math.isclose(adjacent, wrong_layout, abs_tol=1e-12)
Output
adjacent score -0.760002
converted split-half score -0.760002
unconverted split-half score -1.61558

For a checkpoint conversion, this permutation belongs on the Q/K projection output coordinates for every affected head, including their biases if present. Keep it within the configured rotary region for partial RoPE. Reordering cached rotated keys or swapping a helper alone doesn't convert the model.

RoPE rotates Q and K, not V in the standard attention setup here. Q and K route attention, so their relative geometry decides which value rows get weight. V supplies the rows being mixed and isn’t directly rotated in that attention step, though earlier layers can already have put position-dependent context into it.

Keep routing and aggregation separate when comparing the three methods:

PropertySinusoidalLearnedRoPE
Position infoAdded to embeddingsAdded to embeddingsApplied to Q, K only
Relative positionImplicitImplicitExplicit in the dot product
Direct position input to VIncluded in V=(X+PE)WVV=(X+PE)W_VIncluded in V=(X+P)WVV=(X+P)W_VNo direct rotary transform here
Extra parameters0Lmax⁡×dL_{\max} \times d0

Image patches need coordinates, not just storage slots

The vision lesson flattened a patch grid into rows. That storage order doesn't require a position method to forget the grid. A simple axial RoPE assigns one set of coordinate pairs to height and another to width. With just two pairs, write

R(h,w)=diag⁡(Rθh,h,Rθw,w).R(h,w)=\operatorname{diag}\left(R_{\theta_h,h},R_{\theta_w,w}\right).

Its QK identity now depends on two offsets, (hk−hq,wk−wq)(h_k-h_q,w_k-w_q), rather than one sequence distance. Shifting both patches by the same height and width leaves the fixed-vector score unchanged. Raster index h * grid_width + w has a different limitation: the index gap for vertically adjacent patches changes when the grid width changes.

Qwen2.5-VL's report describes a three-axis MRoPE design for time, height, and width. Text uses the same position ID on all axes; an image uses a constant temporal ID and spatial IDs for its patch locations.[10] The next two-axis toy isolates that geometry. It isn't the checkpoint's complete frequency allocation or processor.

rope-patch-coordinates.py
import math

def rotate_patch(vector, height, width):
    result = []
    for (x, y), coordinate in zip([vector[:2], vector[2:]], [height, width]):
        angle = 0.5 * coordinate
        result.extend([x * math.cos(angle) - y * math.sin(angle),
                       x * math.sin(angle) + y * math.cos(angle)])
    return result

def patch_score(query_position, key_position):
    vector = [1.0, 0.0, 1.0, 0.0]
    query = rotate_patch(vector, *query_position)
    key = rotate_patch(vector, *key_position)
    return sum(q * k for q, k in zip(query, key))

original = patch_score((1, 1), (0, 1))
shifted = patch_score((5, 8), (4, 8))
lost_height = patch_score((0, 1), (0, 1))
print("one row apart", round(original, 6))
print("shared spatial shift", round(shifted, 6))
print("height IDs discarded", round(lost_height, 6))
vertical_gaps = []
for grid_width in [2, 4]:
    query_index = 1 * grid_width + 1  # query at (height=1, width=1)
    key_index = 0 * grid_width + 1    # key directly above it
    vertical_gaps.append(query_index - key_index)
print("vertical raster gaps", vertical_gaps)
assert math.isclose(original, shifted, abs_tol=1e-12)
assert not math.isclose(original, lost_height, abs_tol=1e-12)
Output
one row apart 1.877583
shared spatial shift 1.877583
height IDs discarded 2.0
vertical raster gaps [2, 4]

Dropping height IDs makes the two different patches appear colocated to this position transform. In a multimodal runtime, preserve the processor's axis IDs, frequency-band allocation, and the checkpoint's continuation-position rule. Advancing every axis by the cache's token count invents a different geometry.[11]

A relative phase can rise again

RoFormer analyzes a long-term decay bound aggregated over frequencies, not a guarantee that one pair’s cosine falls forever.[5] For identical unit vectors in one pair, SΔ=cos⁡(Δθ)S_{\Delta} = \cos(\Delta\theta), which oscillates.

That distinction is worth testing directly. If distance itself must always lower the positional term, what should happen when a cosine comes back up?

rope-offset-is-not-monotonic.py
import math

theta = 0.5
distances = [1, 2, 4, 6, 8]
scores = [math.cos(distance * theta) for distance in distances]
print(distances)
print([round(score, 3) for score in scores])
print(scores[-1] > scores[-2])
Output
[1, 2, 4, 6, 8]
[0.878, 0.54, -0.416, -0.99, -0.654]
True
Two distance-response plots compare one RoPE pair S_delta=cos(0.5 delta) with an ALiBi slope B_delta=-0.1 delta. RoPE rises from -0.990 at distance 6 to -0.654 at distance 8, while ALiBi keeps decreasing.
One RoPE frequency oscillates. ALiBi's logit bias keeps dropping. A full RoPE head is a sum over frequencies plus content, so you still have to measure it.

The non-monotonic example is not just a toy warning. Men et al. show a production trap in Llama-2 experiments: can look acceptable while retrieval collapses. They saw this with a RoPE base below their derived lower bound, including a small base such as 500, and in some larger-base settings where next-token loss stayed low.[12] A changed RoPE config still needs retrieval and task evaluation at the deployed length.

RoPE exposes offset through phase. ALiBi makes a different choice: it writes “farther is weaker” directly into the score.


ALiBi: make recency a score bias

Suppose two visible keys have the exact same raw dot-product score with a query, but one sits four positions earlier in the sequence. Should an attention head be allowed to prefer the more recent key by a fixed margin? ALiBi (Attention with Linear Biases) answers yes without altering token embeddings or rotating queries and keys. Instead of modifying vectors, it injects a static, head-specific linear distance penalty directly into the pre-softmax attention logits:[7]

Attention(Q,K,V)=softmax(QKTdk+B(h))V,Bij(h)=−mh(i−j) for j≤i\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}} + B^{(h)}\right) V,\quad B^{(h)}_{ij} = -m_h(i-j)\ \text{for}\ j \le i

In a causal decoder, the current token at index ii suffers zero penalty when attending to itself (i−j=0i - j = 0). One step back receives −mh-m_h, two steps back receives −2mh-2m_h, and so on. Future keys (j>ij > i) are hard-masked with −∞-\infty by the causal mask. Notice an operational rule: add the bias after dividing QKTQK^T by dk\sqrt{d_k}. Dividing the bias by dk\sqrt{d_k} would silently weaken every slope and tie the recency penalty to the per-head dimension.

Slope schedule derivation and the recency spectrum

The head slopes mhm_h control the strength of each head's recency bias. For a power-of-two head count HH, the reference slopes form a geometric progression with ratio 2−8/H2^{-8/H}:

mh=(2−8/H)h=2−8h/H,h∈{1,2,…,H}m_h = \left(2^{-8/H}\right)^h = 2^{-8h/H}, \quad h \in \{1, 2, \dots, H\}

The smallest slope is 2−8=1/2562^{-8}=1/256 in this schedule. The largest is 2−8/H2^{-8/H} and therefore changes with head count: 1/41/4 for four heads, 1/21/2 for eight, and 1/21/\sqrt{2} for sixteen. For the eight-head case:

  • Head 1 (m=1/2m=1/2): Ten positions back gives a −5-5 bias. Against a self-key with equal content score, its softmax odds ratio is e−5≈0.0067e^{-5}\approx0.0067. That is not a universal multiplier on its normalized probability, and it does not make a hard window.
  • Head 8 (m=1/256m=1/256): At distance 256, the bias is −1-1. It applies a weaker penalty than Head 1; actual attention still depends on content and the other visible keys.

For 16 heads, the ratio is 2−8/16=2−0.5=1/2≈0.70712^{-8/16} = 2^{-0.5} = 1/\sqrt{2} \approx 0.7071, yielding slopes 2−0.5,2−1.0,2−1.5,…,2−8.02^{-0.5}, 2^{-1.0}, 2^{-1.5}, \dots, 2^{-8.0}. If a model has a non-power-of-two head count HH, the reference implementation finds the closest power of two p=2⌊log⁡2H⌋p = 2^{\lfloor \log_2 H \rfloor}, constructs the pp-head schedule, and appends the first H−pH - p slopes sampled at stride 2 from a 2p2p-head schedule.[13]

ALiBi visual with the eight-head slope spectrum from 1/2 to 1/256, a four-token causal bias matrix for the steepest head, and a raw-logit to softmax trace showing finite past penalties and hard future masking.
Steeper slopes apply stronger recency bias. Past keys get finite penalties; the causal mask blocks future keys. Content scores still determine the final weights.

Read the slope table as a prediction tool: which head should hold onto an older key more strongly?

HeadSlope mmDistance penalty at Δ=16\Delta = 16Effect
Head 11/21/2−8.0-8.0Strongest bias in this eight-head set
Head 41/161/16−1.0-1.0Intermediate bias
Head 81/2561/256−0.0625-0.0625Weakest bias in this set

Run that rule on a token-indexed slice: "The" at 0, "model" at 1, "has" at 2, and "loaded" at 3. Suppose "loaded" has a scaled QK score of 2.52.5 for both "model" and "The". Head 4 has m=1/16=0.0625m=1/16=0.0625, so the distances 2 and 3 produce logits 2.3752.375 and 2.31252.3125. Head 1 would subtract 1.01.0 and 1.51.5 instead. With unequal content scores, a strong semantic match can still outvote the distance penalty.

The helper below applies one slope to one visible key. Its raw_score is already qTk/dkq^Tk/\sqrt{d_k}, before adding the bias. Predict the outputs for distance four and scaled content logit 4.0.

alibi-one-score.py
import math

def alibi_score(raw_score: float, query_pos: int, key_pos: int, slope: float) -> float:
    if (type(query_pos) is not int or type(key_pos) is not int
            or query_pos < 0 or key_pos < 0
            or not math.isfinite(raw_score) or not math.isfinite(slope) or slope < 0):
        raise ValueError("positions must be nonnegative integers; scores and slope finite, slope nonnegative")
    if key_pos > query_pos:
        return float("-inf")  # causal mask, not the distance bias
    distance = query_pos - key_pos
    return raw_score - slope * distance

raw = 4.0
query_pos = 10
key_pos = 6
head_1 = alibi_score(raw, query_pos, key_pos, slope=1 / 2)
head_8 = alibi_score(raw, query_pos, key_pos, slope=1 / 256)
print(head_1, round(head_8, 3))
print("future:", alibi_score(raw, query_pos, key_pos=11, slope=1 / 2))
Output
2.0 3.984
future: -inf

One score hides an important boundary, so the next snippet builds the full causal bias . It includes the non-power-of-two slope helper from the reference implementation and writes −∞-\infty only for future keys.

alibi-bias-matrix.py
import math

def alibi_slopes_power_of_two(n_heads: int) -> list[float]:
    if type(n_heads) is not int or n_heads <= 0 or n_heads & (n_heads - 1):
        raise ValueError("n_heads must be a positive power of two")
    start = 2 ** (-8 / n_heads)
    return [start * (start ** head) for head in range(n_heads)]

def alibi_slopes(n_heads: int) -> list[float]:
    if type(n_heads) is not int or n_heads <= 0:
        raise ValueError("n_heads must be a positive integer")
    if n_heads & (n_heads - 1) == 0:
        return alibi_slopes_power_of_two(n_heads)
    closest = 1 << (n_heads.bit_length() - 1)
    extra = alibi_slopes(2 * closest)[0::2][: n_heads - closest]
    return alibi_slopes_power_of_two(closest) + extra

def build_alibi_bias(n_heads: int, seq_len: int) -> list[list[list[float]]]:
    if type(seq_len) is not int or seq_len <= 0:
        raise ValueError("seq_len must be a positive integer")
    slopes = alibi_slopes(n_heads)
    bias: list[list[list[float]]] = []
    for slope in slopes:
        head = []
        for query in range(seq_len):
            row = []
            for key in range(seq_len):
                if key > query:
                    row.append(float("-inf"))
                else:
                    row.append(-slope * (query - key))
            head.append(row)
        bias.append(head)
    return bias

bias = build_alibi_bias(n_heads=4, seq_len=4)
print(len(bias), len(bias[0]), len(bias[0][0]))
print([round(value, 4) if math.isfinite(value) else value for value in bias[0][3]])
print([round(value, 4) if math.isfinite(value) else value for value in bias[-1][3]])
print("3-head slopes:", alibi_slopes(3))
Output
4 4 4
[-0.75, -0.5, -0.25, -0.0]
[-0.0117, -0.0078, -0.0039, -0.0]
3-head slopes: [0.0625, 0.00390625, 0.25]

Extrapolation mechanics and the long-range penalty trap

ALiBi's rule has no finite table boundary or periodic phase. It can be evaluated at new distances; those larger biases and visibility patterns can still be unfamiliar to a checkpoint. Press et al.'s 1.3B experiment trained at length 1,024 and evaluated at 2,048, matching a longer-trained sinusoidal baseline's perplexity. They measured about 11% less memory and 11% faster progress to a given validation perplexity in that setup.[7] These are particular training comparisons, not a speed or quality guarantee for every model.

Its monotonic recency prior is a tradeoff worth calculating. At distance 16,384, the eight-head slopes above give biases of −8192-8192 and −64-64 at the extremes. Compare the gentlest head's far key against the self-key: with equal content scores, the far-to-self odds are e−64e^{-64}. A content-score advantage greater than 64 reverses that two-key comparison. Against an adjacent key, the bias difference is (16384−1)/256(16384-1)/256, not exactly 64.

Content logits depend on learned norms and projections, and other heads and layers can relay information. A single head's penalty therefore does not determine the whole model's retrieval ability. RoPE uses relative phase rather than this linear bias; its frequency and training choices also need evaluation.

alibi-bias-is-not-a-mask.py
import math

def softmax(logits: list[float]) -> list[float]:
    maximum = max(logits)
    shifted = [math.exp(value - maximum) for value in logits]
    total = sum(shifted)
    return [value / total for value in shifted]

raw_scores = [2.0, 2.0, 2.0, 100.0]
query_position = 2
biased_scores = [
    score - 0.5 * (query_position - key) if key <= query_position else -math.inf
    for key, score in enumerate(raw_scores)
]
weights = softmax(biased_scores)
print([round(weight, 3) for weight in weights])
print(weights[0] > 0)
print("future weight:", weights[3])
far_vs_self = softmax([65.0 - 16384 / 256, 0.0])
print("far key with a 65-logit advantage:", round(far_vs_self[0], 3))
Output
[0.186, 0.307, 0.506, 0.0]
True
future weight: 0.0
far key with a 65-logit advantage: 0.731

A closed-form bias can be evaluated at offsets the model never trained on. Whether the checkpoint still retrieves buried evidence is a different question, and long-context evaluation makes that question concrete.


Long context: a defined formula still needs evidence

Here's the operational handoff: a checkpoint trained at 4K now has a 32K serving target, with a passkey early in the document and a retrieval question near token 30,000. We need to test the large separation between evidence and query, not merely a large absolute index. Writing down PE32000PE_{32000} or R32000R_{32000} only proves the rule is defined.

Unchanged RoPE evaluates larger offsets without difficulty in real-number arithmetic. Fast phases may already have wrapped many times during training; slower bands may enter less-trained phase regions. Longer sequences also change distractor count and context-dependent features. Direct extension failed in Chen et al.'s tested LLaMA runs, but "an unseen angle destroys attention" is not a complete or universal explanation.[14][12] Compute phases accurately and measure behavior.

Use the table as an experiment map. Each row separates what arithmetic does past the training length from what a paper actually measured.

MethodWhat happens past training lengthWhat the papers actually measured
SinusoidalFormula can compute unseen indicesPress et al.: sharp WikiText-103 perplexity increase past train LL
Learned absoluteNo row for index Lmax⁡L_{\max} or laterExtend or replace the table, then train and evaluate
RoPE (unchanged)Rotations continue at unseen offsetsChen et al.: poor direct extension in their Llama runs
ALiBiSame linear bias at new distancesPress et al.: 1024 → 2048 perplexity match in their 1.3B setup
RoPE with PI or YaRNGeometry is rescaled, usually with adaptationChen et al.: PI up to 32K; Peng et al.: YaRN up to 128K on evaluated Llama-family models

Use two evidence gates: short-context behavior must survive, and retrieval plus target tasks must work at the new length. Sweep evidence positions and evidence-to-query distances, include distractors and missing-evidence cases, and reserve tokens for generation. Average next-token perplexity can be dominated by locally predictable text; it does not replace those tests.[12]

Even correct position IDs can be lost before the rotation. Casting indices 4096 and 4097 to bfloat16 rounds both to 4096. Computing cosine in float32 afterward can't recover the distinction. Transformers' Llama implementation computes phases from floating-point position IDs and frequencies in float32, then casts the resulting sine/cosine tensors to the activation dtype.[9]

rope-position-precision.py
import torch

positions = torch.tensor([4096, 4097], dtype=torch.int64)
rounded_first = positions.to(torch.bfloat16).float()
phase_fp32 = positions.float()  # fastest pair has frequency 1
bad_cosine = rounded_first.cos()
good_cosine = phase_fp32.cos()

print("positions after bfloat16", rounded_first.tolist())
print("bad phases identical", bool(torch.equal(rounded_first[:1], rounded_first[1:])))
print("bad cosines", [round(x, 6) for x in bad_cosine.tolist()])
print("float32 cosines", [round(x, 6) for x in good_cosine.tolist()])
assert bad_cosine[0] == bad_cosine[1]
assert good_cosine[0] != good_cosine[1]
Output
positions after bfloat16 [4096.0, 4096.0]
bad phases identical True
bad cosines [0.803991, 0.803991]
float32 cosines [0.803991, 0.934772]

Keep position IDs as integers until deliberate phase computation. Check the phase tensor's dtype separately from Q/K and the cache dtype. This CPU example diagnoses a conversion error; it doesn't benchmark a fused kernel. float32 also has finite precision, so a proposed much larger range needs numerical checks at its actual indices.

PI: compress coordinates into the trained range

Position Interpolation (PI) compresses the target sequence positions so they fit within the pretraining domain:[14]

m′=m⋅LtrainLtarget=msm' = m \cdot \frac{L_{\text{train}}}{L_{\text{target}}} = \frac{m}{s}

where s=Ltarget/Ltrains=L_{\text{target}}/L_{\text{train}}. This is equivalent to using frequencies θi/s\theta_i/s with unchanged positions. For target indices 0≤m<Ltarget0\le m<L_{\text{target}}, effective coordinates satisfy 0≤m′<Ltrain0\le m'<L_{\text{train}}. They can be fractional; PI is not a lookup of an unchanged integer position table.

Chen et al. extended tested 2,048-token LLaMA checkpoints up to 32,768 with fine-tuning within 1,000 steps.[14] For our separate scale-4 example, position 16,383 maps to 4095.754095.75. This is below 4,096 but slightly above the last integer training index 4,095. "Compressed into the window" does not mean every coordinate was observed exactly during pretraining.

position-interpolation-map.py
train_length = 4096
target_length = 16384
scale = train_length / target_length

for target_position in [0, 4096, 8192, 16383]:
    effective_position = target_position * scale
    print(target_position, round(effective_position, 2))
Output
0 0.0
4096 1024.0
8192 2048.0
16383 4095.75

PI also compresses every adjacent-token phase difference by 1/s1/s, including the fastest pair. YaRN's authors hypothesize that this loss of high-frequency separation contributes to short-context degradation in tested extensions.[15] It does not make adjacent indices identical or prove a universal grammar failure. Measure short tasks after adaptation as well as long retrieval.

NTK-aware scaling: stretch frequencies unevenly

The name NTK-aware comes from reasoning about neural tangent kernels and Fourier-feature frequency separation. It is not a theorem guaranteeing length generalization for a trained LLM. The proposed base transform stretches lower frequencies more while preserving the fastest pair:[15]

b′=b⋅sd/(d−2)b' = b \cdot s^{d/(d-2)}

Here d=drotary>2d=d_{\text{rotary}}>2 is even. The formula is undefined at d=2d=2. Consider its effect on θi′=(b′)−2i/d\theta_i'=(b')^{-2i/d}:

θi′=(b⋅sd/(d−2))−2i/d=b−2i/d⋅s−2i/(d−2)=θi⋅s−2i/(d−2)\theta_i' = \left( b \cdot s^{d/(d-2)} \right)^{-2i/d} = b^{-2i/d} \cdot s^{-2i/(d-2)} = \theta_i \cdot s^{-2i/(d-2)}

Analyze the two extremes of that spectrum:

  • Highest frequency (i=0i=0): θ0′=1\theta_0'=1. This pair keeps its original phase step; it does not guarantee preserved syntax or all local model behavior.
  • Lowest frequency (i=d/2−1i=d/2-1): θd/2−1′=θd/2−1/s\theta_{d/2-1}'=\theta_{d/2-1}/s.
  • Middle frequencies: Smoothly transition from zero compression to full interpolation.

Code Llama's paper raises its base to 10610^6 during long-context fine-tuning.[16] A manually chosen larger base is related to this frequency-stretching idea; it does not by itself establish that the checkpoint uses the exact scale formula above.

YaRN: treat frequency bands differently

YaRN combines NTK-by-parts frequency treatment with an empirical attention-temperature adjustment.[15] Start with each pair's wavelength λi=2π/θi\lambda_i=2\pi/\theta_i and count rotations across the original window:

ri=Ltrainλi=Ltrainθi2π.r_i=\frac{L_{\text{train}}}{\lambda_i}=\frac{L_{\text{train}}\theta_i}{2\pi}.

The paper's thresholds α\alpha and β\beta are rotation counts, not token wavelengths. Its illustrative LLaMA settings use α=1\alpha=1 and β=32\beta=32. Set

γi=clip⁡(ri−αβ−α,0,1),θi′=(1−γi)θis+γiθi.\gamma_i=\operatorname{clip}\left(\frac{r_i-\alpha}{\beta-\alpha},0,1\right),\qquad \theta_i'=\left(1-\gamma_i\right)\frac{\theta_i}{s}+\gamma_i\theta_i.

Pairs completing fewer than one turn use full interpolation; those completing more than 32 keep their frequencies. The middle blends them. Transformers 5.17.0's YaRN code instead ramps linearly over pair indices between correction bounds, with optional bound rounding.[17] The helper below implements the paper's rotation-count ramp, not that runtime's exact output. Match the checkpoint's implementation; neither ramp guarantees local syntax.

Try d=8d=8, L=4096L=4096, and s=4s=4. The four original frequencies are [1,0.1,0.01,0.001][1,0.1,0.01,0.001]. Which two complete more than 32 turns? Which completes less than one?

yarn-frequency-bands.py
import math

def yarn_paper_frequencies(dim: int, train_length: int, scale: float,
                           base: float = 10000.0,
                           alpha: float = 1.0, beta: float = 32.0) -> list[float]:
    if (type(dim) is not int or dim <= 0 or dim % 2
            or type(train_length) is not int or train_length <= 0
            or not all(math.isfinite(x) for x in (scale, base, alpha, beta))
            or scale < 1 or base <= 1 or not 0 <= alpha < beta):
        raise ValueError("positive even dim, positive integer length, finite scale >= 1, base > 1, 0 <= alpha < beta")
    frequencies = [base ** (-2 * pair / dim) for pair in range(dim // 2)]
    result = []
    for frequency in frequencies:
        turns = train_length * frequency / (2 * math.pi)
        preserve = min(1.0, max(0.0, (turns - alpha) / (beta - alpha)))
        result.append(frequency * (preserve + (1 - preserve) / scale))
    return result

original = [1.0, 0.1, 0.01, 0.001]
adjusted = yarn_paper_frequencies(8, 4096, 4)
turns = [4096 * frequency / (2 * math.pi) for frequency in original]
print("turns:", [round(value, 3) for value in turns])
print("new frequencies:", [round(value, 6) for value in adjusted])
print("ratios:", [round(new / old, 3) for new, old in zip(adjusted, original)])
Output
turns: [651.899, 65.19, 6.519, 0.652]
new frequencies: [1.0, 0.1, 0.003835, 0.00025]
ratios: [1.0, 1.0, 0.384, 0.25]

YaRN additionally uses a=1/t=1+0.1ln⁡sa=\sqrt{1/t}=1+0.1\ln s as an empirical fit for tested LLaMA/Llama 2 models. Multiplying both rotated Q and K by aa multiplies their dot-product logits by a2a^2. For s=4s=4, a≈1.139a\approx1.139, a2≈1.296a^2\approx1.296, and t≈0.771t\approx0.771. This scaling changes norms and, for nonuniform logits, sharpens softmax. More keys do not inevitably increase entropy: a newly added key with a dominant score can concentrate the distribution. The empirical adjustment needs its own task evaluation.

rope-scaling-numbers.py
import math

rotary_dim, base, scale = 128, 10000.0, 4.0
new_base = base * scale ** (rotary_dim / (rotary_dim - 2))
ratios = [(base / new_base) ** (2 * pair / rotary_dim) for pair in [0, 16, 32, 48, 63]]
amplitude = 1 + 0.1 * math.log(scale)
print("new base:", round(new_base))
print("frequency ratios:", [round(value, 3) for value in ratios])
print("q/k factor:", round(amplitude, 3))
print("logit factor:", round(amplitude ** 2, 3))
print("temperature:", round(1 / amplitude ** 2, 3))
Output
new base: 40890
frequency ratios: [1.0, 0.703, 0.495, 0.348, 0.25]
q/k factor: 1.139
logit factor: 1.296
temperature: 0.771

Cache positions are part of the computation

During cached generation, use the new query's actual sequence position and the same frequency schedule as its cached keys. A cache's storage-slot number is not necessarily its position ID, especially with padding, packed examples, or sliding retention. Restarting the query at zero changes offsets. Rotating a key twice changes them too.

For one fixed-Q/K layer, the next CPU example compares a full causal pass with token-by-token cached attention. It then resets one query's position and changes only the query's frequency schedule. Predict which comparisons survive.

rope-cache-consistency.py
import torch
import torch.nn.functional as F

def rotate_pairs(rows: torch.Tensor, positions: torch.Tensor,
                 frequencies: torch.Tensor) -> torch.Tensor:
    if rows.ndim != 2 or rows.shape[1] != 2 * frequencies.numel():
        raise ValueError("one frequency per adjacent coordinate pair")
    if positions.shape != (rows.shape[0],) or frequencies.ndim != 1:
        raise ValueError("one position per row and a one-dimensional frequency schedule")
    pairs = rows.reshape(rows.shape[0], -1, 2)
    angles = positions[:, None] * frequencies[None, :]
    cos, sin = angles.cos(), angles.sin()
    x, y = pairs[..., 0], pairs[..., 1]
    return torch.stack((x * cos - y * sin, x * sin + y * cos), -1).flatten(1)

q = torch.tensor([[1., 0., 1., 0.], [0., 1., 1., 1.],
                  [1., 1., 0., 1.]], dtype=torch.float64)
k = torch.tensor([[1., 1., 1., 0.], [1., 0., 0., 1.],
                  [0., 1., 1., 1.]], dtype=torch.float64)
v = torch.eye(3, dtype=torch.float64)
positions = torch.arange(800, 803, dtype=torch.float64)
frequencies = torch.tensor([1., 0.01], dtype=torch.float64)
rotated_q = rotate_pairs(q, positions, frequencies)
rotated_k = rotate_pairs(k, positions, frequencies)

def attend(query, keys, values, causal=False, mask=None):
    return F.scaled_dot_product_attention(query[None, None], keys[None, None],
                                         values[None, None], dropout_p=0.,
                                         is_causal=causal, attn_mask=mask)[0, 0]

full = attend(rotated_q, rotated_k, v, causal=True)
cached = torch.cat([attend(rotated_q[i:i+1], rotated_k[:i+1], v[:i+1])
                    for i in range(3)])
chunk_mask = positions[None, :] <= positions[1:, None]
chunk = attend(rotated_q[1:], rotated_k, v, mask=chunk_mask)
unmasked_chunk = attend(rotated_q[1:], rotated_k, v)
reset_query = rotate_pairs(q[-1:], torch.zeros(1, dtype=q.dtype), frequencies)
new_frequencies = torch.tensor([1., 0.005], dtype=q.dtype)
mixed_query = rotate_pairs(q[-1:], positions[-1:], new_frequencies)
consistent_keys = rotate_pairs(k, positions, new_frequencies)
fixed_layer_repaired = attend(mixed_query, consistent_keys, v)
all_new = attend(rotate_pairs(q, positions, new_frequencies), consistent_keys, v, causal=True)

print("full and cached match:", torch.allclose(full, cached, atol=1e-12, rtol=0))
print("full and cached chunk match:", torch.allclose(full[1:], chunk, atol=1e-12, rtol=0))
print("chunk without mask matches:", torch.allclose(full[1:], unmasked_chunk, atol=1e-12, rtol=0))
print("chunk first query sees future:", bool(chunk_mask[0, -1]))
print("reset position matches:", torch.allclose(full[-1:], attend(reset_query, rotated_k, v), atol=1e-12, rtol=0))
print("mixed schedules match new pass:", torch.allclose(all_new[-1:], attend(mixed_query, rotated_k, v), atol=1e-12, rtol=0))
print("fixed layer repaired:", torch.allclose(all_new[-1:], fixed_layer_repaired, atol=1e-12, rtol=0))
Output
full and cached match: True
full and cached chunk match: True
chunk without mask matches: False
chunk first query sees future: False
reset position matches: False
mixed schedules match new pass: False
fixed layer repaired: True

Every single-token step sees only its current prefix, so it needs no additional causal mask. Its one-row query is already the last position. The two-token chunk is different: query 801 must hide key 802, while query 802 may see it. The explicit mask compares their logical positions; in this PyTorch API, True permits a pair. A rectangular is_causal=True call can have different alignment; use the explicit offset-aware mask from the attention lesson for cached chunks. Matching rotary offsets doesn't repair future-token leakage.

YaRN's paper recommends caching unrotated keys when applying a changing schedule.[15] Converting old rotated keys with Rnew(p)Rold(p)TR_{\mathrm{new}}(p)R_{\mathrm{old}}(p)^T repairs the rotation for fixed unrotated keys in exact arithmetic. In a multilayer decoder, earlier attention changes can also change later K/V features; rerotating stored keys alone need not reproduce a full pass under the new schedule. Use a supported cache policy or recompute the prefix when full-pass equivalence is required. This toy verifies arithmetic, not trained-model cache quality.

Three RoPE extension panels use a 4K-to-16K example. Position Interpolation maps target positions into 0-to-4K, NTK-aware scaling changes the base from 10,000 to about 40,890 and slows later frequency pairs, and YaRN combines band treatment with an attention factor of 1.139.
PI compresses indices. NTK-aware scaling changes the base, leaving the fastest pair at frequency 1 and slowing later pairs. YaRN adds band-wise treatment plus an attention factor.

Read the whole checkpoint configuration

Llama 3.1's reference code starts from base 500,000500{,}000 and uses a piecewise transform based on the original 8,192-token window.[8] Wavelengths below 2,048 stay unchanged; those above 8,192 have frequencies divided by eight; the middle blends the two using the rotation-count ratio. The factor eight is a frequency-scaling parameter, not an arithmetic proof of the checkpoint's 128K window. The Llama 3 report also describes staged long-context training.[6]

Gemma 3 (2025) uses five local attention layers for each global layer, with bases 10,000 locally and 1,000,000 globally.[18] Its report combines global RoPE rescaling with a final long-context training stage for the 4B/12B/27B models. One base number does not describe that whole design.

LongRoPE2 (2025) searches per-dimension scaling using loss on synthetic needle-answer tokens, then mixes original-RoPE short-context training and rescaled-RoPE long-context training.[19] Its LLaMA3-8B and Phi3-mini experiments illustrate a useful principle: optimize and measure distant-evidence use, while checking short-context regression. The title's "near-lossless" describes the paper's evaluated results, not every downstream task.

As checked October 10, 2026, Transformers' documentation exposes variants through rope_parameters and supports different settings per layer type.[11] Load the checkpoint's settings and match the installed runtime's schema. The same advertised method name can hide differences in rotary width, pairing, base, frequency ramp, amplitude, and cache policy.


Choose by mechanism, then earn the window

Choose the insertion point that matches the behavior you want. Then test short-context quality, long-range retrieval, generation, and target tasks at the length you plan to advertise.

GoalCandidateWhat you still have to measure
Reproduce the original TransformerSinusoidal absolute encodingQuality at trained lengths and any longer lengths you serve
Finite learned slotsLearned absolute embeddingsHow new rows are initialized and adapted
Relative offset inside QK scoresRoPETarget-length retrieval and generation, especially after scaling
Explicit monotonic recency biasALiBiWhether the recency prior hides evidence long tasks need
Extend an existing RoPE checkpointPI or YaRN-style adaptationShort-context regression and long-context behavior after adaptation

The implementation choice decides where position enters attention. The checkpoint still has to earn the window you advertise.

Check the three insertion points

  • Explain why unmasked self-attention without a position signal is permutation equivariant.
  • Point to where sinusoids, RoPE, and ALiBi insert position: embeddings, Q/K geometry, or logits.
  • Compute a RoPE relative phase and an ALiBi distance penalty on a tiny example.
  • Compare full-pass and cached-chunk outputs while holding pairing, frequency schedule, position IDs, and permitted keys fixed.
  • Treat configured context length as a claim that needs retrieval and task evidence at the deployed length.

Common misconceptions

  • Order seems implicit: Symptom: you say unmasked attention already knows sequence order. Cause: you skipped the permutation-equivariance check. Fix: start from content-only attention, then account separately for position signals and masking.
  • All methods sound the same: Symptom: sinusoids, learned tables, RoPE, and ALiBi blur together. Cause: you aren't tracking the insertion point. Fix: embeddings vs Q/K geometry vs .
  • RoPE rotates the wrong tensor: Symptom: you apply RoPE to embeddings or V. Cause: routing and aggregation got merged. Fix: rotate Q and K after projection; leave V without a direct RoPE rotation.
  • Long context looks solved on paper: Symptom: perplexity improves but retrieval still fails. Cause: you treated mathematical validity as behavioral proof. Fix: run passkey, retrieval, summarization, and target-task evals at the deployed length.
  • Any base change is enough: Symptom: the config advertises a larger window but distant facts disappear. Cause: a new θ\theta doesn't teach the model to use long-range evidence. Fix: pair geometry changes with an adaptation method, long-context data, and task-level checks.
  • Concatenate by default: Symptom: width and projection cost balloon. Cause: you doubled the hidden width without accounting for downstream matrices. Fix: reproduce the checkpoint's position design; a new concatenated design can project back down or explicitly budget for a wider stack.
Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.An unmasked self-attention block with dropout disabled receives token embeddings with no positional signal. If the input rows are permuted before computing Q, K, and V, what should happen to the block output?
2.An engineer wants to combine a d-dimensional token embedding with a d-dimensional absolute position vector. If the model concatenates them and keeps the transformer at width 2d instead of adding them, what's the main cost?
3.In sinusoidal positional encoding with d_model = 8, dimensions 0 and 1 use angle equal to the position. At position 2, what are those two values, and why are sine and cosine paired at each frequency?
4.A RoPE attention head rotates only Q and K. In one 2D pair, fixed q = k = [1, 0] and theta = 0.5. What happens to the query-key dot product for positions (m=5, n=3) versus (m=105, n=103), and what role does V play in this RoPE step?
5.A developer says RoPE behaves like ALiBi because larger offsets always reduce the positional term. In one 2D RoPE pair with fixed q = k = [1, 0] and theta = 1.0, what do offsets Delta = 3 and Delta = 6 show?
6.In an 8-head causal decoder using ALiBi slopes 1/2 through 1/256, a query at position 10 has raw logit 4.0 for a visible key at position 6. What logits enter softmax for Head 1 and Head 8?
7.One model uses a learned absolute position table with L_max = 4096. Another uses ALiBi biases. You want to run 8192-token inputs without changing the position mechanism. What's the key difference, and what caveat remains?
8.Your 4096-token RoPE checkpoint is being adapted for 16384-token inputs. Which option correctly pairs each extension method with the part of RoPE or attention it changes?
9.A YaRN configuration multiplies both rotated queries and keys by 1.139. Holding everything else fixed, how does it change their pre-softmax dot-product logits?
10.A 4K-trained RoPE model is configured for 64K by changing the RoPE base. Long-document perplexity barely changes, but passkey retrieval at 64K gets worse. What should you conclude?

10 questions remaining.

References

Attention Is All You Need.

Vaswani, A., et al. · 2017

https://arxiv.org/abs/1706.03762

Transformer Language Models without Positional Encodings Still Learn Positional Information.

Haviv, A., Ram, O., Press, O., Izsak, P., & Levy, O. · 2022 · Findings of EMNLP 2022

https://arxiv.org/abs/2203.16634

BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.

Devlin, J., et al. · 2019 · NAACL 2019

https://arxiv.org/abs/1810.04805

GPT-2 Source Implementation.

OpenAI · 2019

https://github.com/openai/gpt-2/blob/master/src/model.py

RoFormer: Enhanced Transformer with Rotary Position Embedding.

Su, J., et al. · 2021

https://arxiv.org/abs/2104.09864

The Llama 3 Herd of Models.

Dubey, A., et al. · 2024 · arXiv preprint

https://arxiv.org/abs/2407.21783

Train Short, Test Long: Attention with Linear Biases Enables Input Length Extrapolation.

Press, O., Smith, N. A., & Lewis, M. · 2022 · ICLR 2022

https://arxiv.org/abs/2108.12409

Llama 3.1 reference rotary embedding and frequency scaling

Meta · 2026

https://github.com/meta-llama/llama-models/blob/f45cdfd624b98b6655540f7101d8d9cb432e631c/models/llama3_1/reference_impl/model.py

Llama rotary embedding implementation

Hugging Face · 2026

https://github.com/huggingface/transformers/blob/main/src/transformers/models/llama/modeling_llama.py

Qwen2.5-VL Technical Report

Qwen Team, Alibaba Group · 2025

https://arxiv.org/abs/2502.13923

Utilities for Rotary Embedding

Hugging Face · 2026

https://huggingface.co/docs/transformers/main/internal/rope_utils

Base of RoPE Bounds Context Length.

Men, X., et al. · 2024 · NeurIPS 2024

https://arxiv.org/abs/2405.14591

ALiBi reference transformer implementation

Ofir Press et al. · 2026

https://github.com/ofirpress/attention_with_linear_biases/blob/master/fairseq/models/transformer.py

Extending Context Window of Large Language Models via Positional Interpolation.

Chen, S., et al. · 2023

https://arxiv.org/abs/2306.15595

YaRN: Efficient Context Window Extension of Large Language Models.

Peng, B., et al. · 2023

https://arxiv.org/abs/2309.00071

Code Llama: Open Foundation Models for Code

Rozière, M., et al. · 2023

https://arxiv.org/abs/2308.12950

Transformers 5.17.0 YaRN rotary parameter implementation

Hugging Face Contributors · 2026

https://github.com/huggingface/transformers/blob/v5.17.0/src/transformers/modeling_rope_utils.py

Gemma 3 Technical Report

Gemma Team et al. · 2025

https://arxiv.org/abs/2503.19786

LongRoPE2: Near-Lossless LLM Context Window Scaling

Shang, N., Zhang, L. L., Wang, S., et al. · 2025

https://arxiv.org/abs/2502.20082

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.