Understanding PageAttention Memory Optimization
PageAttention serves as a critical memory management strategy within high-performance inference engines, distinct from attention mechanism variants like MLA. Its primary goal is to resolve inefficiencies associated with traditional Key-Value (KV) cache allocation. Standard implementations often suffer from three specific memory issues:
- Reserved Waste: Memory is pre-allocated for potential future tokens that may never be generated, locking resources that other requests could utilize.
- Internal Fragmentation: Systems typically allocate contiguous memory blocks based on the maximum possible sequence length (e.g., 2048 tokens). Since actual sequence lengths vary significantly, much of this pre-allocated space remains unused.
- External Fragmentation: Free memory exists in scattered blocks across the GPU. Evenif the total free space is sufficient, the lack of continuity prevents allocation for larger sequences.
Empirical data suggests that only between 20.4% and 38.2% of allocated token space is actively used in traditional setups. PageAttention mitigates this by allowing continuous logical sequences to be stored in non-contiguous physical memory. The KV cache is segmented into fixed-size blocks. During attention computation, the kernel utilizes a mapping table to locate and retrieve these blocks efficiently.
Consider a generation task producing the phrase "fathers brought a car". Traditional methods might reserve space for a much longer sentence upfront. In contrast, PageAttention employs a paging strategy. If a block holds 4 tokens, the system allocates blocks dynamically as needed. A Block Table maintains the mapping between logical token positions and physical block addresses. When a block fills up, the sequence continues in a newly allocated block, regardless of physical location.
Technically, the continuous VRAM space for Keys and Values is divided into fixed-size Pages (Blocks). The Block Table records the storage status and capacity of each block, managing the mapping between logical sequence positions and physical blocks.
Handling Softmax Across Blocks
A critical challenge arises during attention calculation: since KV pairs are scattered across different blocks, computing the Softmax denominator requires global information. The standard Softmax formula is:
Integration Patterns
vLLM supports two primary integration modes: offline batch processing and online serving via API. The following example demonstrates the offline usage pattern with refactored variable names for clarity.
from vllm import LLM as InferenceEngine
from vllm import SamplingParams as GenerationConfig
# Define input sequences
input_sequences = [
"Hello, my name is",
"The president of the United States is",
"The capital of France is",
"The future of AI is",
]
# Configure generation parameters
generation_config = GenerationConfig(temperature=0.8, top_p=0.95)
# Initialize the engine with a specific model
inference_engine = InferenceEngine(model="facebook/opt-125m")
# Execute generation
results = inference_engine.generate(input_sequences, generation_config)
# Process outputs
for result in results:
original_prompt = result.prompt
generated_content = result.outputs[0].text
print(f"Prompt: {original_prompt!r}, Generated text: {generated_content!r}")
Internal Framework Architecture
The core of vLLM relies on the LLMEngine class, which orchestrates both synchronous (offline) and asynchronous (online) execution flows. When a request is submitted, the engine manages the lifecycle from prompt acceptance to token generation.
Initialization Lifecycle
Before generating output, the system undergoes a three-stage preparation process:
- Model initialization and weight loading.
- VRAM pre-alulation calculation.
- GPU memory binding for the KV Cache.
The LLM wrapper instantiates an LLMEngine, which in turn creates an EngineCoreClient. This client handles the underlying communication, potentially using multiprocessing or asynchronous methods depending on the deployment environment.
Memory Pre-allocation Logic
Upon loading the model, the system calculates the required VRAM for the KV cache. The available memory is determined by subtracting non-cache memory usage from the total allocated GPU memory budget.
Calculation Steps:
- Determine Budget:
requested_memory = total_gpu_memory * utilization_ratio. - Calculate Cache Space:
available_kv_cache_bytes = requested_memory - non_kv_cache_memory. - Compute Block Count: The total number of blocks is derived by dividing the available cache bytes by the size of a single block and the number of layers.
For a specific model configuration, the block size depends on the data type (e.g., FP16 uses 2 bytes), the number of KV heads, head size, and the block token capacity. For instance, with 12 layers and a block size of 16 tokens, the system calculates the total number of manageable blocks.
The total token capacity is then estimated using:
GPU Buffer Allocation
Once the dimensions are calculated, the system allocates zero-initialized tensors on the GPU. These tensors are reshaped to match the required block structure and bound to the model's attention modules.
# vllm/v1/worker/gpu_model_runner.py (Refactored)
def allocate_gpu_buffers(self, memory_layout):
# Allocate raw memory tensors for KV cache
raw_cache_tensors = self._create_zeroed_tensors(memory_layout)
# Reshape tensors to match block structure
structured_caches = self._reshape_tensors(memory_layout, raw_cache_tensors)
# Determine attention module count based on model type
attn_module_count = 2 if self.model_config.hf_config.model_type == "longcat_flash" else 1
# Bind the allocated memory to the model context
bind_kv_cache(
structured_caches,
self.compilation_config.static_forward_context,
self.kv_caches,
attn_module_count
)
return structured_caches
def _create_zeroed_tensors(self, memory_layout):
cache_buffers = {}
for tensor_spec in memory_layout.cache_tensors:
# Initialize tensor with zeros on the target device
buffer = torch.zeros(
tensor_spec.size,
dtype=torch.int8,
device=self.device
)
# Map buffer to shared layers
for layer_name in tensor_spec.shared_by:
cache_buffers[layer_name] = buffer
return cache_buffers
The KVCacheConfig object passed to these functions contains the computed number of blocks and the specific tensor sizes required for each layer group. The allocation process ensures that every attention layer, such as model.decoder.layers.0.self_attn.attn, has a dedicated memory region initialized to zero, ready to store key and value states during inference.