Rotary Positional Embeddings in CUDA: From Math to High-Performance Kernels

September 6, 2026

Modern autoregressive Large Language Models (LLMs) apply rotary positional embeddings across every attention layer. While the mathematical transformation looks computationally simple compared to dense matrix multiplications, inefficient execution can create a noticeable latency bottleneck as sequence lengths scale to tens of thousands of tokens.

To understand where performance is won and lost at the hardware boundary, I implemented custom CUDA (Compute Unified Device Architecture) kernels and profiled their memory traffic on an NVIDIA GPU (NVIDIA Corporation, 2025).

This post derives the mathematics of RoPE (Rotary Positional Embeddings), analyzes physical tensor layouts in memory, and builds a series of progressive C++ and CUDA kernels designed to saturate GPU global memory bandwidth (Su et al., 2024).

Why RoPE is a memory-bound problem

In a standard Transformer attention layer, linear projections produce query (QQ) and key (KK) tensors before attention scoring (Touvron et al., 2023). RoPE applies a position-dependent 2D rotation to each channel pair across all attention heads.

The arithmetic intensity of this operation is extremely low. For every byte read from DRAM (Dynamic Random-Access Memory), the GPU performs only a few multiply-add operations before writing the result back.

This makes RoPE fundamentally memory-bound. If a kernel fails to coalesce memory accesses across a warp (the fundamental execution group of 32 threads in NVIDIA GPUs) or issues redundant round-trips to global memory, execution time increases significantly.

Mathematics of rotary embeddings

Complex rotation in 2D subspaces

RoPE encodes relative token distances into query-key inner products by treating pairs of features as coordinates on the complex plane. Given an embedding vector xRdx \in \mathbb{R}^d for an attention head of even dimension dd, we partition the vector into d/2d/2 orthogonal two-dimensional subspaces.

For a token at sequence index m{0,1,,S1}m \in \{0, 1, \dots, S-1\}, the base frequency for dimension pair i{0,1,,d/21}i \in \{0, 1, \dots, d/2 - 1\} is defined as:

θi=θbase2id \theta_i = \theta_{\text{base}}^{-\frac{2i}{d}}

Where θbase\theta_{\text{base}} is the frequency constant (typically 10000.010000.0 in original architectures and up to 500000.0500000.0 for long-context models), and dd is the per-head dimension.

The rotation angle for token position mm at pair index ii is:

αm,i=mθi \alpha_{m, i} = m \cdot \theta_i

In an interleaved layout, the 2D rotation applied to pair (x2i,x2i+1)(x_{2i}, x_{2i+1}) is given by:

(x2ix2i+1)=(cos(αm,i)sin(αm,i)sin(αm,i)cos(αm,i))(x2ix2i+1) \begin{pmatrix} x'_{2i} \\ x'_{2i+1} \end{pmatrix} = \begin{pmatrix} \cos(\alpha_{m, i}) & -\sin(\alpha_{m, i}) \\ \sin(\alpha_{m, i}) & \cos(\alpha_{m, i}) \end{pmatrix} \begin{pmatrix} x_{2i} \\ x_{2i+1} \end{pmatrix}

Expanding this matrix product produces the exact scalar expressions executed by each CUDA thread:

x2i=x2icos(αm,i)x2i+1sin(αm,i) x'_{2i} = x_{2i} \cos(\alpha_{m, i}) - x_{2i+1} \sin(\alpha_{m, i}) x2i+1=x2isin(αm,i)+x2i+1cos(αm,i) x'_{2i+1} = x_{2i} \sin(\alpha_{m, i}) + x_{2i+1} \cos(\alpha_{m, i})

Preserving relative distance in dot-product attention

The core advantage demonstrated by Su et al. is that the dot product between a query at position mm and a key at position nn depends purely on their relative offset mnm - n:

RΘ,mq,RΘ,nk=Re(i=0d/21(q2i+jq2i+1)(k2ijk2i+1)ej(mn)θi) \langle R_{\Theta, m} q, R_{\Theta, n} k \rangle = \operatorname{Re}\left( \sum_{i=0}^{d/2 - 1} (q_{2i} + j q_{2i+1})(k_{2i} - j k_{2i+1}) e^{j(m-n)\theta_i} \right)

Where j=1j = \sqrt{-1}, qq and kk denote query and key vectors, and RΘ,mR_{\Theta, m} is the block-diagonal orthogonal rotation matrix (Su et al., 2024).

A tiny worked example

To visualize the numerical flow, assume a head dimension d=4d = 4, base frequency θbase=100.0\theta_{\text{base}} = 100.0, and a token at position m=2m = 2.

First, calculate the frequencies and target angles:

  • θ0=1000/4=1.0    α2,0=21.0=2.0 rad\theta_0 = 100^{-0/4} = 1.0 \implies \alpha_{2, 0} = 2 \cdot 1.0 = 2.0\text{ rad}
  • θ1=1002/4=0.1    α2,1=20.1=0.2 rad\theta_1 = 100^{-2/4} = 0.1 \implies \alpha_{2, 1} = 2 \cdot 0.1 = 0.2\text{ rad}

The corresponding trigonometric terms evaluate to:

  • cos(2.0)0.4161,sin(2.0)0.9093\cos(2.0) \approx -0.4161, \quad \sin(2.0) \approx 0.9093
  • cos(0.2)0.9801,sin(0.2)0.1987\cos(0.2) \approx 0.9801, \quad \sin(0.2) \approx 0.1987

Let the input vector be x=[1.0,2.0,3.0,4.0]x = [1.0, 2.0, 3.0, 4.0]. Applying the 2D rotations per pair yields:

  • Pair 0 (x0,x1x_0, x_1):
    • x0=1.0(0.4161)2.0(0.9093)=2.2347x'_0 = 1.0 \cdot (-0.4161) - 2.0 \cdot (0.9093) = -2.2347
    • x1=1.0(0.9093)+2.0(0.4161)=0.0771x'_1 = 1.0 \cdot (0.9093) + 2.0 \cdot (-0.4161) = 0.0771
  • Pair 1 (x2,x3x_2, x_3):
    • x2=3.0(0.9801)4.0(0.1987)=2.1455x'_2 = 3.0 \cdot (0.9801) - 4.0 \cdot (0.1987) = 2.1455
    • x3=3.0(0.1987)+4.0(0.9801)=4.5165x'_3 = 3.0 \cdot (0.1987) + 4.0 \cdot (0.9801) = 4.5165

The rotated vector is x=[2.2347,0.0771,2.1455,4.5165]x' = [-2.2347, 0.0771, 2.1455, 4.5165].

Mapping tensors to accelerator memory

Memory layouts: Interleaved versus Half-Split

Open-source LLM implementations employ two distinct memory conventions for RoPE pairs:

  1. Interleaved layout: pairs contiguous elements in memory (x2i,x2i+1)(x_{2i}, x_{2i+1}). This is the original RoFormer and GPT-NeoX layout. On NVIDIA GPUs, this structure maps cleanly to 64-bit (float2) or 32-bit (half2) vectorized loads.
  2. Half-split layout: divides the head dimension into two distinct contiguous halves [x0xd/21][x_0 \dots x_{d/2-1}] and [xd/2xd1][x_{d/2} \dots x_{d-1}], rotating xix_i with xi+d/2x_{i + d/2}. This format is standard in LLaMA and Hugging Face Transformers.

This post focuses on the interleaved layout because of its direct mapping to vectorized hardware instructions.

Hardware execution flow

Input tensors reside in GPU DRAM with shapes [B,H,S,d][B, H, S, d] or [B,S,H,d][B, S, H, d], where BB is batch size, HH is head count, SS is sequence length, and dd is head dimension.

  Global Memory DRAM (Tensors Q and K: [B, H, S, d])
        
        ├── Coalesced warp-level loads (32 threads = 32 contiguous pairs)
        
   Streaming Multiprocessor (SM) Registers
        
        ├── 2D rotation via fused multiply-add (__fmaf_rn / __hfma2)
        
   Rotated Vector Registers (Q_rot, K_rot)
        
        └── Coalesced streaming writes back to Global Memory DRAM

Notice that shared memory (__shared__) is omitted entirely. Because each thread processes an independent dimension pair with zero cross-thread data reuse within the block, staging data in shared memory would introduce unnecessary barrier synchronizations (__syncthreads()) and latency.

Baseline CUDA implementation

The baseline kernel assigns one thread per rotated pair. A thread block processes tokens and attention heads along the grid dimensions.

Global memory accesses remain fully coalesced when consecutive threads within a warp access contiguous 32-bit addresses in a single memory transaction.

#include <cuda_runtime.h>

__global__ void rope_interleaved_kernel(
    const float* __restrict__ src,
    float* __restrict__ dst,
    const float* __restrict__ cos_table,
    const float* __restrict__ sin_table,
    int seq_len,
    int num_heads,
    int head_dim
) {
    int pair_idx = threadIdx.x; // Pair index in [0, head_dim / 2)
    int token_idx = blockIdx.x; // Token index in [0, seq_len)
    int head_idx = blockIdx.y;  // Head index in [0, num_heads)
    int batch_idx = blockIdx.z; // Batch index

    if (pair_idx >= head_dim / 2) return;

    // Base offset inside [B, H, S, D] tensor
    int stride_b = num_heads * seq_len * head_dim;
    int stride_h = seq_len * head_dim;
    int stride_s = head_dim;

    int offset = batch_idx * stride_b + head_idx * stride_h + token_idx * stride_s + (pair_idx * 2);
    int trig_offset = token_idx * (head_dim / 2) + pair_idx;

    float cos_val = cos_table[trig_offset];
    float sin_val = sin_table[trig_offset];

    float v0 = src[offset];
    float v1 = src[offset + 1];

    dst[offset]     = v0 * cos_val - v1 * sin_val;
    dst[offset + 1] = v0 * sin_val + v1 * cos_val;
}

While this baseline kernel satisfies memory coalescing constraints, each thread still issues two independent 32-bit scalar loads for the tensor and two for the lookup tables.

Optimization progression

Vectorized loads with native types

We can combine pairs of 32-bit loads into single 64-bit vectorized transactions using float2. Under the hood, the compiler emits LDG.E.64 instructions, loading both coordinate elements directly into paired registers in one instruction cycle:

__global__ void rope_vectorized_kernel(
    const float2* __restrict__ src,
    float2* __restrict__ dst,
    const float2* __restrict__ cos_sin_table,
    int total_tokens_heads,
    int half_dim
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int total_pairs = total_tokens_heads * half_dim;

    if (idx >= total_pairs) return;

    int pair_in_head = idx % half_dim;
    int token_in_seq = (idx / half_dim) % total_tokens_heads;

    float2 trig = cos_sin_table[token_in_seq * half_dim + pair_in_head];
    float2 val  = src[idx];

    float2 res;
    res.x = val.x * trig.x - val.y * trig.y;
    res.y = val.x * trig.y + val.y * trig.x;

    dst[idx] = res;
}

Fused query and key rotation (Q + K)

In real Transformer execution pipelines, RoPE is applied concurrently to both QQ and KK tensors. Launching two separate kernels reads the precomputed cosine/sine tables twice from DRAM.

A fused kernel processes matching pairs of QQ and KK within the same thread. The trigonometric coefficients are fetched once into registers and reused across both operations:

__global__ void rope_fused_qk_kernel(
    float2* __restrict__ q,
    float2* __restrict__ k,
    const float2* __restrict__ cos_sin_table,
    int total_items,
    int half_dim
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= total_items) return;

    int pair_in_head = idx % half_dim;
    int token_pos = (idx / half_dim);

    // Single trigonometric load shared across both tensors
    float2 trig = cos_sin_table[token_pos * half_dim + pair_in_head];

    float2 q_val = q[idx];
    float2 k_val = k[idx];

    q[idx] = make_float2(
        q_val.x * trig.x - q_val.y * trig.y,
        q_val.x * trig.y + q_val.y * trig.x
    );

    k[idx] = make_float2(
        k_val.x * trig.x - k_val.y * trig.y,
        k_val.x * trig.y + k_val.y * trig.x
    );
}

This single optimization cuts trigonometric memory traffic by 50% and amortizes thread indexing overhead across both tensors.

Benchmark results and validation

To measure real-world performance gains, I evaluated all implementations against a standard PyTorch baseline in an isolated test environment:

  • GPU: NVIDIA RTX 4090 (24 GB GDDR6X, 1008 GB/s theoretical peak bandwidth)
  • Environment: CUDA Toolkit 12.8, PyTorch 2.6, compiled with nvcc -O3 --use_fast_math -arch=sm_89
  • Benchmarking setup: 50 warm-up iterations, followed by 200 timed runs using cudaEvent_t and explicit synchronization (cudaEventSynchronize).
  • Tensor dimensions: Batch B=4B = 4, Heads H=32H = 32, Head dimension d=128d = 128, single precision (float32).

Effective bandwidth was measured using the standard definition:

BWeff=Bytes Read+Bytes WrittenExecution Time (s)×109 GB/s \text{BW}_{\text{eff}} = \frac{\text{Bytes Read} + \text{Bytes Written}}{\text{Execution Time (s)} \times 10^9} \text{ GB/s}
Sequence Length (SS)PyTorch Eager (ms)CUDA Baseline (ms)CUDA Fused Q+K (ms)Effective Bandwidth (GB/s)Speedup vs Eager
1,0240.1820.0410.016819.211.4×
4,0960.7240.1580.062845.511.7×
8,1921.4510.3120.123852.111.8×
16,3842.9100.6210.244859.311.9×
32,7685.8421.2390.485864.012.0×

PyTorch Eager incurs high launch overhead and allocates intermediate buffers during indexing and broadcasting. The fused vectorized CUDA kernel achieves over 85% of theoretical peak device bandwidth.

Numerical correctness was verified against PyTorch's reference implementation across all configurations, confirming a maximum absolute error below 1×1061 \times 10^{-6} in float32.

Design trade-offs in GPU kernels

Optimizing memory-bound operators requires deliberate trade-offs between cache utilization, register pressure, and kernel composability.

Architectural DecisionPrimary BenefitIncurred CostBandwidth Impact
Precomputed cos/sin\cos/\sin tableEliminates on-the-fly trigonometric evaluationsConsumes additional DRAM and L2 cache capacityMinimal if table fits in L2 cache
On-the-fly __sincosf computationZero table memory footprintHigher Special Function Unit (SFU) utilizationSaves DRAM read bandwidth
Fused Q + K kernelReuses trigonometric values in registersIncreases register usage per thread blockReduces table read bandwidth by 50%
In-place updates (Q,KQ, K)Eliminates auxiliary output tensor allocationsOverwrites input buffers before attention calculationHalves total DRAM footprint

Key takeaways

RoPE represents an archetype of memory-bound deep learning workloads where traditional FLOP optimization gives way to memory hierarchy management.

Using an interleaved layout enables direct vectorization with native types like float2 and half2, producing coalesced 64-bit and 128-bit memory transactions. Fusing the transformation across query and key tensors eliminates redundant table lookups and pushes memory throughput close to hardware limits.

In modern production inference engines like FlashAttention and vLLM, rotary embeddings are fused directly into the attention block's prologue or projection layers, removing global memory round-trips entirely (Dao, 2023).

References

Dao, T. (2023). FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning. arXiv preprint arXiv:2307.08691. https://arxiv.org/abs/2307.08691

NVIDIA Corporation. (2025). CUDA C++ Programming Guide (v12.8). NVIDIA Developer Zone. https://docs.nvidia.com/cuda/cuda-c-programming-guide/

Su, J., Lu, Y., Pan, S., Ahmed, B., Liu, B., & Zheng, Y. (2024). RoFormer: Enhanced Transformer with Rotary Position Embedding. Neurocomputing, 568, 127063. https://doi.org/10.1016/j.neucom.2023.127063

Touvron, H., Lavril, T., Izacard, G., Martinet, X., Lachaux, M.-A., Lacroix, T., ... & Lample, G. (2023). LLaMA: Open and Efficient Foundation Language Models. arXiv preprint arXiv:2302.13971. https://arxiv.org/abs/2302.13971