Boosting LLM Inference Speed: High Performance, Zero Compromise

Large Language Models (LLMs) are the powerhouses behind sophisticated AI applications like chatbots and writing assistants. However, their sheer size and complexity can make the inference process — generating text, translations, or other outputs — computationally demanding.
Let’s delve into strategies to significantly enhance the speed of LLM inference without altering the model itself, keeping its abilities intact.
1. Parallelization: Batching for Efficiency
Core Concept: Traditionally, models process inputs one by one. Parallelization flips this script, allowing us to handle a group of inputs (batch) simultaneously. This leverages the inherent parallelism of modern hardware, particularly beneficial when dealing with multiple queries arriving within a short window.
Under the Hood: Hardware like CPUs and GPUs contain multiple cores that can execute instructions concurrently. Batching feeds each core an input from the batch, enabling them to work in parallel. This significantly reduces idle time and overall processing time.
LLM Example: Imagine a chatbot system receiving questions from ten users concurrently. Instead of processing each question individually, parallelization allows us to batch these questions and distribute them across available cores for faster response generation.
Using PyTorch for batching:
import torch
from LLM_lib import LLMModel # Hypothetical LLM model import
model = LLMModel()
inputs = [input1, input2, …, input10] # List of inputs
input_tensor = torch.stack([torch.tensor(input) for input in inputs])
output = model(input_tensor) # Batching inputs for parallel processing2. Vectorization: Unleashing the Power of Single Instruction/Multiple Data
Core Concept: CPUs can perform operations on multiple data elements simultaneously using SIMD (Single Instruction, Multiple Data) instructions. Vectorization takes advantage of this by restructuring computations to operate on entire vectors (arrays) of data in one go, rather than iterating over them element-by-element using loops.
Deep Dive: Many deep learning libraries (NumPy, PyTorch) employ optimized linear algebra routines like BLAS (Basic Linear Algebra Subprograms) under the hood. BLAS provides highly efficient vectorized implementations for common operations like matrix multiplication, vector addition, and dot products.
LLM Example: Consider an LLM calculating word similarities during text generation. Vectorization enables the LLM to compare the similarities of entire word vectors in one operation instead of looping through each element individually, leading to a substantial speedup.
Python Example for Vectorization with PyTorch Tensors:
Let’s say we want to perform a dot product operation between two vectors. Instead of iterating over the elements and multiplying them one by one, we can use PyTorch’s vectorized operations to do this in a more efficient way.
import torch
# Creating two 1D tensors (vectors) in PyTorch
tensor_a = torch.tensor([1, 2, 3, 4], dtype=torch.float32)
tensor_b = torch.tensor([5, 6, 7, 8], dtype=torch.float32)
# Performing a vectorized dot product
dot_product = torch.dot(tensor_a, tensor_b)
print(dot_product)In this example, torch.dot performs the dot product of the two tensors in a vectorized manner, utilizing SIMD under the hood for performance optimization. This is significantly more efficient than manually iterating over the tensor elements to calculate the dot product, particularly for large tensors.
The dot product of two vectors is calculated as the sum of the products of their corresponding elements. So, in above case:
\text{dot_product} = 1 \times 5 + 2 \times 6 + 3 \times 7 + 4 \times 8
\text{dot_product} = 5 + 12 + 21 + 32
\text{dot_product} = 70
Thus, the script will output 70.0. This value is a float because the tensors were defined with dtype=torch.float32.3. Loop Tiling: Cache-Friendly Optimizations
Core Concept: Loop tiling, primarily used in lower-level languages (C++, C), tackles the issue of cache locality. It breaks down large loops processing multidimensional arrays into smaller, manageable chunks (tiles). This ensures data actively being used fits within the CPU’s cache, reducing the need to fetch data from slower main memory, significantly improving performance.
Optimizing Cache Usage: CPUs have a cache hierarchy — a small, fast memory closer to the processor core. Data reuse within a loop is crucial for efficient processing. Loop tiling ensures the most frequently accessed data from the array resides in the cache for the entire loop iteration, minimizing cache misses and data retrieval delays.
LLM Example: During LLM inference, large matrices involving word embeddings or hidden states might be processed. Loop tiling ensures these matrices reside in the cache for the entire loop processing the data, minimizing cache misses and speeding up computations.
Processes a large 2D tensor in tiled chunks using PyTorch:
import torch
def tiled_processing(tensor, tile_size):
n, m = tensor.shape
for i in range(0, n, tile_size):
for j in range(0, m, tile_size):
# Define the tile boundaries
i_end = min(i + tile_size, n)
j_end = min(j + tile_size, m)
# Process the tile
tile = tensor[i:i_end, j:j_end]
# Example operation on the tile - let's say we're incrementing each element
tensor[i:i_end, j:j_end] = tile + 1
# Example usage
large_tensor = torch.rand(1000, 1000) # Large 1000x1000 tensor
tiled_processing(large_tensor, tile_size=100) # Process in 100x100 tilesIn this code, tiled_processing function processes large_tensor in chunks of size tile_size x tile_size. By doing this, it ensures that each chunk (or tile) of the tensor is more likely to stay in the CPU cache while being processed, which can lead to faster execution compared to processing the entire tensor at once, especially for very large tensors.
4. Operator Fusion: Streamlining the Pipeline
Core Concept: Operator fusion combines multiple consecutive loops performing smaller operations into a single, more efficient loop. This reduces loop control overhead, potentially enables further vectorization optimizations, and improves cache locality.
Reducing Overhead: Each loop iteration incurs overhead for loop control mechanisms (e.g., checking loop counter, incrementing). Fusing multiple loops eliminates this overhead for intermediate loops, streamlining the processing pipeline.
LLM Example: An LLM might involve multiple activation functions (e.g., ReLU) applied sequentially within a layer. Operator fusion combines the loops iterating through these activations into a single loop, reducing loop control overhead and potentially enabling vectorization for the combined operation.
Python Example: Fusion with NumPy:
import numpy as np
def fused_operation(x):
return np.exp(x) * np.log(x) # Fusing exponentiation and logarithm
x = np.array([1, 2, 3, 4])
result = fused_operation(x)5. Quantization: Trading Precision for Speed and Efficiency
Core Concept: Quantization leverages the insight that deep neural networks, including LLMs, are often surprisingly robust to reduced numerical precision. It works by converting the floating-point numbers (typically 32-bit floats) representing the model’s weights and activations into lower-precision representations, commonly 8-bit integers (int8) or even 4-bit integers.
Benefits:
Shrinking Model Footprint: Quantized models occupy less memory, making them ideal for deployment on resource-constrained devices like smartphones or embedded systems.
Accelerated Computation: Lower-precision computations execute significantly faster on modern hardware. CPUs and GPUs often have optimized instructions specifically for integer operations.
Improved Energy Efficiency: Reduced memory footprint and faster computations translate into lower energy consumption, a major benefit in mobile or power-sensitive scenarios.
The Accuracy Trade-off: While powerful, quantization often introduces a slight decrease in model accuracy. It’s crucial to carefully evaluate this trade-off between accuracy and the computational gains it offers.
Types of Quantization
Post-training Quantization: The most straightforward approach. Here, a model is trained with standard floating-point precision. After training, the weights are quantized. This technique is simple but might lead to greater accuracy loss.
Quantization-aware Training (QAT): In QAT, quantization is simulated during the training process itself. This enables the model to learn adaptively, compensating for the errors introduced by quantization, minimizing the potential accuracy drop.
Quantization in Action: A PyTorch Example
import torch
import torch.nn as nn
import torch.quantization
# 1. Define a standard model architecture
class MyModel(nn.Module):
def __init__(self):
super(MyModel, self).__init__()
# Define your model layers, for example:
self.conv = nn.Conv2d(1, 20, 5, 1)
self.fc1 = nn.Linear(4*4*20, 500)
self.fc2 = nn.Linear(500, 10)
def forward(self, x):
x = torch.relu(self.conv(x))
x = torch.flatten(x, 1)
x = torch.relu(self.fc1(x))
x = self.fc2(x)
return x
# 2. Instantiate the model
model = MyModel()
# 3. Post-training quantization (simple)
model.eval()
# Fuse model Conv, bn and relu layers if present
model_fused = torch.quantization.fuse_modules(model, [['conv', 'relu']])
# Specify the quantization configuration for inference
model_fused.qconfig = torch.quantization.get_default_qconfig('fbgemm')
# Prepare and convert the model to a quantized version
model_quantized = torch.quantization.prepare(model_fused, inplace=False)
model_quantized = torch.quantization.convert(model_quantized, inplace=False)
# 4. Quantization-aware training (more involved)
model.train()
# Copy the original model for QAT
model_qat = MyModel().train()
# Fuse model Conv, bn and relu layers if present
model_qat_fused = torch.quantization.fuse_modules(model_qat, [['conv', 'relu']])
# Configure quantization
model_qat_fused.qconfig = torch.quantization.get_default_qat_qconfig('fbgemm')
# Prepare the model for QAT
model_prepared_qat = torch.quantization.prepare_qat(model_qat_fused)
# … Training loop with simulated quantization …
# For demonstration, let's use a dummy training loop
for i in range(5):
# Simulate training with dummy inputs and labels
inputs = torch.randn(64, 1, 28, 28)
labels = torch.randint(0, 10, (64,))
optimizer = torch.optim.SGD(model_prepared_qat.parameters(), lr=0.01)
optimizer.zero_grad()
output = model_prepared_qat(inputs)
loss = nn.CrossEntropyLoss()(output, labels)
loss.backward()
optimizer.step()
# Convert to a fully quantized model
quantized_model_qat = torch.quantization.convert(model_prepared_qat.eval(), inplace=False)Post-training quantization explained
- Set the Model to Evaluation Mode
model.eval()In evaluation mode, certain layers like dropout and batch normalization behave differently (e.g., batch normalization uses running statistics instead of batch statistics). This is essential for quantization since the model should not be in a training state.
2. Fuse Model Layers:
model_fused = torch.quantization.fuse_modules(model, [['conv', 'relu']])This fusion is a common practice in preparing a model for quantization because it can lead to more efficient execution during inference and often doesn’t harm the model’s performance.
3. Specify the Quantization Configuration:
model_fused.qconfig = torch.quantization.get_default_qconfig('fbgemm')This line sets the quantization configuration for the model. torch.quantization.get_default_qconfig(‘fbgemm’) provides a default configuration tailored for x86 CPUs using FBGEMM (Facebook General Matrix Multiplication). This configuration includes settings for how weights and activations are to be quantized (like the type of observers used for calibration).
4. Prepare the Model for Quantization:
model_quantized = torch.quantization.prepare(model_fused, inplace=False)5. Convert the Model to a Quantized Version:
model_quantized = torch.quantization.convert(model_quantized, inplace=False)Finally, torch.quantization.convert converts the model into a quantized version. This step changes the model to use quantized versions of its weights and activations. As with the previous step, inplace=False creates a new quantized model, leaving the original unaltered.
Quantization-aware training code explained
1. Copy the original model for QAT: This step creates a copy of the model that will be used for quantization-aware training. During QAT, the model learns to adjust to the effects of quantization, helping to maintain accuracy post-quantization.
model_qat = MyModel().train()
2. Fuse model layers if present: Fusing combines layers like Convolution, Batch Normalization, and ReLU into a single module. This is a standard practice before quantization, as it can lead to improved performance and accuracy in the quantized model.
model_qat_fused = torch.quantization.fuse_modules(model_qat, [['conv', 'relu']])3. Configure quantization: This step involves setting the quantization configuration (qconfig). The get_default_qat_qconfig function specifies how weights and activations are to be quantized. ‘fbgemm’ is typically used for x86 architectures and helps define the quantization scheme (like affine quantization) and quantization parameters (like bit width, which is 8-bit in this case).
model_qat_fused.qconfig = torch.quantization.get_default_qat_qconfig('fbgemm')4. Prepare the model for QAT: The prepare_qat function modifies the model for quantization-aware training. This step inserts quantization and dequantization operations into the model, allowing it to “simulate” quantization effects during training.
model_prepared_qat = torch.quantization.prepare_qat(model_qat_fused)
After this preparation, the model is trained normally. The training process will take into account the effects of quantization, so that when the model is finally quantized (converted to use 8-bit integer weights and activations), the impact on accuracy is minimized. Once training is complete, the model is converted to a fully quantized version using torch.quantization.convert. This final model uses 8-bit integers for both weights and activations, making it more efficient for inference, especially on CPUs or edge devices.
There are some other techniques like Knowledge Distillation and pruning but they affect the model architecture leading to smaller models so they are not covered here.
Homework
Exploring the Practicality of Multi-GPU Utilization for Model Inference
“While leveraging multiple GPUs has the theoretical potential to accelerate model inference, it’s often not the most efficient or practical route in real-world scenarios. Can you ponder why this might be the case?”
Hint: Consider factors like inter-GPU communication overhead, data parallelism bottlenecks, and the complexity of effectively distributing inference tasks across multiple GPUs.
Assessing the Use Cases for Vectorization and Loop Tiling
“Vectorization and loop tiling are both powerful strategies for optimizing array element access during operations. In what scenarios would you ideally employ each of these techniques?”
Hint for Vectorization: Think about situations where operations can be applied uniformly across large data sets.
Hint for Loop Tiling: Reflect on cases where data locality and cache utilization are critical for performance enhancement, especially with large multi-dimensional arrays.
Happy optimizing LLM!






