Modalities

Long context: RoPE scaling, context parallelism

Models now claim 128K, 200K, even 1M token context windows. Serving them is a different beast entirely. RoPE scaling extends the positional encoding, but the real challenge is fitting the KV cache in memory and keeping prefill latency under control with context parallelism.

When Llama 3.1 shipped with a 128K context window, my first reaction was excitement. My second reaction, as someone who thinks about inference, was dread. A 128K context at 70B parameters means a KV cache of roughly 40 GB per request in FP16. That is more than the entire memory of many GPUs, for a single request. Serving long context is not just about making the model work at long sequences. It is about making the economics work.

RoPE: how position encoding scales

Most modern LLMs use Rotary Position Embeddings (RoPE) to encode token positions. RoPE works by rotating the query and key vectors in 2D subspaces at frequencies that depend on the position. The rotation angle for position m at frequency index i is:

# RoPE rotation angle
theta_i = base ** (-2i / d)  # base is typically 10,000
angle_m_i = m * theta_i      # rotation angle for position m

The key property: RoPE encodes relative positions through the angle difference between two tokens. The attention score between tokens at positions m and n depends on (m - n), not on the absolute positions.

The problem is that RoPE is trained with a maximum sequence length. If you try to use positions beyond the training range, the rotation angles become values the model has never seen, and attention patterns break down. The model starts hallucinating or losing coherence.

RoPE scaling methods

Three main approaches have emerged to extend RoPE beyond the training length:

Linear scaling (Position Interpolation). Instead of extrapolating to new positions, you interpolate: divide all positions by a scale factor so they fit within the original training range. If the model was trained on 4K tokens and you want 32K, set scale = 8 and use position m/8 instead of m.

# Position Interpolation
scale_factor = target_length / train_length  # e.g., 32768 / 4096 = 8
angle_m_i = (m / scale_factor) * theta_i

This works surprisingly well with minimal fine-tuning (a few hundred steps). The downside is that you compress all position information into the original range, which can reduce the model's ability to distinguish nearby positions.

NTK-aware scaling. Instead of scaling all frequencies equally, NTK-aware scaling modifies the RoPE base frequency. This preserves high-frequency (local) position information while stretching only the low-frequency (long-range) components:

# NTK-aware scaling
alpha = scale_factor
new_base = base * alpha ** (d / (d - 2))
theta_i = new_base ** (-2i / d)

The intuition is that local attention patterns (which depend on high-frequency rotations) should not change, while long-range patterns (low-frequency) can stretch. This gives better perplexity at long contexts without fine-tuning.

YaRN (Yet another RoPE extensioN). YaRN combines NTK-aware scaling with an attention temperature correction. It divides the frequency dimensions into three groups: low frequencies get NTK scaling, high frequencies get no scaling, and middle frequencies get interpolated. It also adds a temperature scaling factor to the attention logits to compensate for the changed magnitude of position encodings.

Which one to use

If you are serving a model that was already trained with long context (like Llama 3.1 128K), the RoPE configuration is baked in and you do not need to choose. If you are extending a short-context model, YaRN gives the best quality. Linear interpolation with a few hundred steps of fine-tuning is the simplest option and works well for modest extensions (4x to 8x).

The real bottleneck: KV cache memory

RoPE scaling is the easy part. The hard part is memory. The KV cache stores the key and value tensors for every token in the context, at every layer, for every attention head. For a 70B model with 80 layers, 8 KV heads (GQA), and head dimension 128:

# KV cache size for one request at 128K context
layers = 80
kv_heads = 8
head_dim = 128
seq_len = 128_000
bytes_per_param = 2  # FP16

kv_bytes = 2 * layers * kv_heads * head_dim * seq_len * bytes_per_param
# 2 (K and V) * 80 * 8 * 128 * 128000 * 2
# = 2 * 80 * 8 * 128 * 128000 * 2
# = 41,943,040,000 bytes ≈ 42 GB per request

42 GB for a single request. An H100 has 80 GB of HBM. After loading the model weights (roughly 140 GB for 70B in FP16, so you need at least TP=2), you have maybe 5 to 10 GB per GPU for KV cache. That means you can serve zero concurrent 128K requests on a 2-GPU setup, and maybe one on a 4-GPU setup.

This is why long-context serving requires a fundamentally different approach.

KV cache compression techniques

Several strategies reduce the KV cache footprint:

Context parallelism for long prefill

Even if you can fit the KV cache, the prefill computation for 128K tokens is enormous. Prefill time scales quadratically with sequence length for standard attention (linearly with FlashAttention, but with a large constant). A 128K prefill on a single A100 with FlashAttention-2 takes roughly 30 to 60 seconds for a 70B model. That is an unacceptable TTFT.

Context parallelism, which I explored for video generation, applies here too. Split the 128K sequence across multiple GPUs, run Ring Attention, and reduce the prefill time by roughly the number of GPUs. With CP=8, that 30-second prefill drops to about 4 seconds.

The key difference from video CP is that for LLM long context, you typically combine CP with TP. A common setup for Llama 3.1 70B at 128K context:

Chunked prefill: the practical middle ground

If you do not have enough GPUs for context parallelism, chunked prefill is the fallback. Instead of processing all 128K tokens in one monolithic prefill, break the prompt into chunks of 8K to 16K tokens and process them sequentially, interleaving decode steps for other requests between chunks.

# Chunked prefill scheduling
def chunked_prefill(prompt_tokens, chunk_size=8192):
    chunks = [prompt_tokens[i:i+chunk_size]
              for i in range(0, len(prompt_tokens), chunk_size)]
    for chunk in chunks:
        prefill_chunk(chunk)  # ~2-4 seconds per chunk
        # Between chunks, run decode iterations for other requests
        run_pending_decodes()

This prevents a single long-context request from blocking all other requests. The tradeoff is higher total prefill time (no parallelism benefit) but better system-level fairness and throughput.

Long context is a systems problem, not just a modeling problem. The model may handle 128K tokens perfectly, but if your serving system cannot fit the KV cache, parallelize the prefill, and manage memory, that context window is a marketing number, not a usable feature.

Tomorrow we shift to EAGLE speculative decoding, a clever approach that drafts at the feature level rather than the token level to achieve higher acceptance rates.