Executive Overview
The deployment of Large Language Models (LLMs) and transformer architectures has transitioned from an academic pursuit to a cornerstone of modern software engineering. However, developers transitioning from training transformer models in PyTorch to running them in production frequently encounter a sharp learning curve. While the underlying neural network code may remain identical, the operational dynamics of training and inference are fundamentally divergent.
During the training phase, models process large batches of fixed-length token sequences simultaneously, relying heavily on parallelized matrix multiplications and the backward pass to update model weights. Conversely, inference relies on fixed weights where the model generates new tokens sequentially, one at a time. This autoregressive nature places entirely different demands on hardware, shifting performance bottlenecks from raw compute throughput to memory bandwidth, data movement, and state management.
To bridge this efficiency gap, high-performance serving engines segment inference into two distinct computational phases: prefill and decode. Furthermore, they utilize a Key-Value (KV) Cache to eliminate redundant computations. This technical deep dive explores the mechanics of autoregressive generation, the architectural breakdown of prefill and decode phases, how to implement a custom KV cache in PyTorch, and the critical memory management strategies required to scale modern AI infrastructure.
Detailed Chronology: The Mechanics of Transformer Inference
To understand why standard PyTorch training scripts fail to scale during inference, we must examine how text is generated token by token.
1. Autoregressive Generation and Causal Attention
Decoder-only transformer models predict the subsequent token based strictly on the context of preceding tokens. This directional constraint is enforced via a causal attention mechanism. When provided with an input sequence—such as [“The”, “cat”, “sat”, “on”, “the”]—the model outputs a probability distribution over its entire vocabulary for the next token.
Crucially, the model does not output a readable word directly; instead, it outputs logits, which are unnormalized scores assigned to every token in the vocabulary. The generation loop follows a strict iterative cycle:
- Pass the current sequence of token IDs through the transformer.
- Extract the logits corresponding to the final token in the sequence.
- Select the most probable token using a decoding strategy (e.g., greedy search or sampling).
- Append the newly generated token to the input sequence.
- Repeat the process until a stopping criterion (such as an end-of-sequence token or maximum length) is met.
Because each new token depends entirely on its predecessors, generation is inherently autoregressive. The tenth output token cannot be computed until the first nine have been finalized.
2. The Computational Inefficiency of Naive Generation
Consider a naive implementation of a greedy decoding loop in PyTorch:
import torch
@torch.no_grad()
def greedy_decode(model, input_ids, max_new_tokens):
output_ids = input_ids.clone()
for _ in range(max_new_tokens):
logits = model(output_ids)
next_token_logits = logits[:, -1, :]
next_token = next_token_logits.argmax(dim=-1, keepdim=True)
output_ids = torch.cat([output_ids, next_token], dim=1)
return output_ids
While clean and easy to conceptualize, this code is computationally disastrous at scale. At every iteration of the loop, the entire growing sequence is fed back into the model. If a prompt contains 1,000 tokens and the model generates 100 new tokens, the system repeatedly recomputes the hidden states and attention scores for the original 1,000 prompt tokens.
Without optimizations, self-attention complexity scales quadratically ($O(N^2)$) relative to the sequence length $N$. If the output sequence length is $N = P + G$ (where $P$ is the prompt length and $G$ is the number of generated tokens), naive computation scales at roughly $O(P^2G + PG^2 + G^3)$. For large prompts or long outputs, this approach quickly saturates GPU compute resources with redundant math.
Supporting Context & Metrics: Prefill, Decode, and the KV Cache
To overcome quadratic scaling penalties, production systems separate the generation process into two distinct operational phases: prefill and decode.
[Prompt Tokens (P)] ---> PREFILL PHASE ---> Compute Q, K, V for all P tokens
|
v
[Generated Token] ---> DECODE PHASE ---> Append to KV Cache, compute only new token
The Prefill Phase
Generation begins with a prompt whose tokens are entirely known beforehand. Instead of processing these tokens sequentially, the model ingests the entire prompt in a single, highly parallelized forward pass. This is the prefill phase.
During prefill, the model computes the hidden states for all prompt tokens, generates logits for the very first output token, and—most importantly—computes and stores the keys ($K$) and values ($V$) for every attention layer. These cached tensors form the foundation of the KV cache.
The Decode Phase
Once the first new token is selected, generation shifts to the decode phase. In this phase, the model no longer processes the entire historical sequence. Instead, it receives only the single newest token.
The model computes the query ($Q$), key ($K$), and value ($V$) projections solely for this new token. The newly minted key and value tensors are appended to the existing KV cache. The attention mechanism then computes the dot-product of the new query against all cached keys, multiplying the resulting attention weights by the cached values.
By caching past keys and values, the per-token computational complexity during the decode phase drops from quadratic ($O(N^2)$) to linear ($O(N)$) for a sequence of length $N$. While the initial prefill step remains $O(N^2)$, it executes only once per request.

Real-World Workload Metrics
Inference engines evaluate performance based on which phase dominates a given workload:
- Short prompt + Long answer: Heavily stresses the decode phase, demanding high memory bandwidth to stream KV caches back and forth from GPU high-bandwidth memory (HBM).
- Long prompt + Short answer: Heavily stresses the prefill phase, demanding massive raw compute throughput (FLOPs) to process large context windows initially.
- Chat applications with extensive conversation history: Stress both phases simultaneously, requiring aggressive memory management to prevent out-of-memory (OOM) errors.
Code Architecture: Building a KV Cache in PyTorch
To demonstrate how these concepts translate into code, we can construct a miniature transformer model equipped with a manual KV cache.
Self-Attention and Block Implementation
The following implementation demonstrates how a self-attention module handles cached keys and values (past_kv), concatenates them with current projections, and applies a causal mask:
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
class SelfAttention(nn.Module):
def __init__(self, hidden_size, num_heads):
super().__init__()
assert hidden_size % num_heads == 0
self.num_heads = num_heads
self.head_dim = hidden_size // num_heads
self.qkv = nn.Linear(hidden_size, 3 * hidden_size)
self.out = nn.Linear(hidden_size, hidden_size)
def forward(self, x, past_kv=None):
batch_size, seq_len, hidden_size = x.shape
qkv = self.qkv(x).view(batch_size, seq_len, 3, self.num_heads, self.head_dim)
qkv = qkv.permute(2, 0, 3, 1, 4)
q, k, v = qkv[0], qkv[1], qkv[2]
if past_kv is not None:
past_k, past_v = past_kv
k = torch.cat([past_k, k], dim=2)
v = torch.cat([past_v, v], dim=2)
total_len = k.size(2)
past_len = total_len - seq_len
scores = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim)
causal_mask = torch.ones(seq_len, total_len, device=x.device, dtype=torch.bool)
causal_mask = torch.tril(causal_mask, diagonal=past_len)
scores = scores.masked_fill(~causal_mask, float("-inf"))
attn = F.softmax(scores, dim=-1)
y = attn @ v
y = y.transpose(1, 2).contiguous().view(batch_size, seq_len, hidden_size)
return self.out(y), (k, v)
The Caching Generation Loop
With the attention mechanism caching keys and values, a memory-efficient generation loop processes the prefill phase once, then utilizes the cache for all subsequent decode steps:
@torch.no_grad()
def greedy_decode_with_cache(model, input_ids, max_new_tokens):
output_ids = input_ids.clone()
# Prefill Phase: process the entire prompt once
logits, cache = model(input_ids)
next_token = logits[:, -1, :].argmax(dim=-1, keepdim=True)
output_ids = torch.cat([output_ids, next_token], dim=1)
assert max_new_tokens > 0, "max_new_tokens must be positive"
# Decode Phase: process only the most recent token using the KV cache
for _ in range(max_new_tokens - 1):
logits, cache = model(next_token, past_kv=cache)
next_token = logits[:, -1, :].argmax(dim=-1, keepdim=True)
output_ids = torch.cat([output_ids, next_token], dim=1)
return output_ids
Notice that only keys and values are cached, while query tensors are discarded after each step. To predict the next token, the model needs only the query of the most recent token, multiplying it against the accumulated keys in the cache to derive attention scores.
Official Statements & Memory Footprint Analysis
While the KV cache dramatically accelerates inference speed, it introduces a severe memory bottleneck. The memory consumed by a KV cache scales directly with batch size, sequence length, number of layers, and hidden dimensions.
Calculating KV Cache Memory Consumption
The approximate memory usage of a KV cache can be calculated using the following formula:
$$textBytes = 2 times ntextlayers times b times s times ntextkv_heads times dtexthead times btextelem$$
Where:
- Factor of $2$ accounts for both Keys ($K$) and Values ($V$).
- $n_textlayers$ is the total number of transformer blocks.
- $b$ is the batch size.
- $s$ is the sequence length (context window).
- $n_textkv_heads$ is the number of Key-Value attention heads (which may be reduced in Grouped-Query Attention architectures).
- $d_texthead$ is the dimension per attention head.
- $b_textelem$ is the byte size per data type (e.g., 2 bytes for FP16 or BF16).
Real-World Calculation
Consider a modern mid-sized LLM configured with:
- Layers ($n_textlayers$): 32
- KV Heads ($n_textkv_heads$): 32
- Head Dimension ($d_texthead$): 128
- Precision: BF16 (2 bytes per element)
- Batch Size ($b$): 1
- Sequence Length ($s$): 4,096 tokens
Plugging these values into the equation:
$$textMemory = 2 times 32 times 1 times 4096 times 32 times 128 times 2 = 2,147,483,648 text bytes approx 2 text GiB$$
This 2 GiB allocation represents the KV cache for a single request processing a 4K context window. It excludes static model weights, temporary activation memory, tokenization buffers, and framework overhead. When scaling to handle hundreds or thousands of concurrent users, KV cache memory consumption quickly outpaces GPU VRAM capacity.
Future Outlook: Production Memory Management
Naive implementations that rely on Python garbage collection and dynamic tensor concatenation (torch.cat()) suffer from high memory fragmentation and allocation overhead. In enterprise-grade production environments, managing the KV cache requires advanced systems engineering:
- PagedAttention and Virtual Memory: Modern serving frameworks (such as vLLM) borrow concepts from operating system virtual memory, breaking the KV cache into fixed-size blocks. This eliminates internal and external memory fragmentation, allowing non-contiguous physical memory blocks to be mapped to contiguous logical token sequences.
- Quantization: Storing keys and values in FP8 or INT4 precision rather than BF16 drastically reduces memory footprints with minimal degradation in model generation quality.
- Continuous Batching: Rather than waiting for an entire batch of requests to finish before starting new ones, serving engines dynamically insert new requests into ongoing decode cycles, maximizing GPU utilization.
- Disaggregated Prefill and Decode: Emerging architectures separate compute hardware pools—routing heavy prefill workloads to clusters optimized for raw FLOP throughput while routing memory-bound decode workloads to clusters optimized for high memory bandwidth.
Summary
Transitioning transformer models from training to production requires a fundamental shift in architectural perspective. By splitting generation into prefill and decode phases and implementing a robust Key-Value cache, developers can transform quadratic performance bottlenecks into linear scaling realities. Mastering these foundational concepts serves as the prerequisite for deploying scalable, high-throughput generative AI systems in the enterprise.
