Machine learning / Advanced ML topics
Boosting PyTorch Model Inference Speed
Contents
- Optimizing PyTorch Model Inference Speed
- Using torch.no\grad() for Inference
- Model Quantization
- TorchScript and JIT Compilation
- Batch Processing
- GPU Acceleration
- Model Pruning
- Fusion of Operations
- Real-life Example: Image Classification
- Real-life Example: Natural Language Processing
- Optimizing Data Loading
- Half-Precision Inference
- Model Distillation
- Optimizing Model Architecture
- Additional Resources
Optimizing PyTorch Model Inference Speed
PyTorch is a powerful deep learning framework, but model inference can sometimes be slow. This presentation will explore various techniques to boost your PyTorch model's inference speed, making it more efficient for real-world applications.
import torch
import time
def measure_inference_time(model, input_tensor, num_runs=100):
start_time = time.time()
with torch.no_grad():
for _ in range(num_runs):
_ = model(input_tensor)
end_time = time.time()
return (end_time - start_time) / num_runs
# Example usage
model = torch.nn.Linear(100, 10)
input_tensor = torch.randn(1, 100)
avg_time = measure_inference_time(model, input_tensor)
print(f"Average inference time: {avg_time:.6f} seconds")
Using torch.no_grad() for Inference
During inference, we don't need to compute gradients. Using torch.no_grad() context manager disables gradient computation, reducing memory usage and speeding up inference.
import torch
model = torch.nn.Linear(100, 10)
input_tensor = torch.randn(1, 100)
# Without torch.no_grad()
output = model(input_tensor)
# With torch.no_grad()
with torch.no_grad():
output_no_grad = model(input_tensor)
print("Gradient required:", output.requires_grad)
print("Gradient not required:", output_no_grad.requires_grad)
Model Quantization
Quantization reduces the precision of model weights and activations, decreasing memory usage and computation time. PyTorch supports various quantization techniques, including post-training static quantization.
import torch
# Define a simple model
class SimpleModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.conv = torch.nn.Conv2d(3, 6, 5)
self.fc = torch.nn.Linear(6 * 28 * 28, 10)
def forward(self, x):
x = self.conv(x)
x = x.view(-1, 6 * 28 * 28)
x = self.fc(x)
return x
# Create and quantize the model
model = SimpleModel()
quantized_model = torch.quantization.quantize_dynamic(
model, {torch.nn.Linear, torch.nn.Conv2d}, dtype=torch.qint8
)
# Compare model sizes
print(f"Original model size: {torch.save(model.state_dict(), '/tmp/model.pth')}")
print(f"Quantized model size: {torch.save(quantized_model.state_dict(), '/tmp/quantized_model.pth')}")
TorchScript and JIT Compilation
TorchScript allows you to serialize and optimize your models for production environments. Just-in-Time (JIT) compilation can significantly improve inference speed.
import torch
class SimpleModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.fc = torch.nn.Linear(100, 10)
def forward(self, x):
return self.fc(x)
model = SimpleModel()
input_tensor = torch.randn(1, 100)
# Convert to TorchScript
scripted_model = torch.jit.script(model)
# Save the scripted model
scripted_model.save("scripted_model.pt")
# Load and use the scripted model
loaded_model = torch.jit.load("scripted_model.pt")
output = loaded_model(input_tensor)
print("Output shape:", output.shape)
Batch Processing
Processing inputs in batches can significantly improve throughput, especially when using GPU acceleration. Let's compare single input processing to batch processing.
import torch
import time
model = torch.nn.Linear(100, 10)
single_input = torch.randn(1, 100)
batch_input = torch.randn(64, 100)
# Single input processing
start_time = time.time()
with torch.no_grad():
for _ in range(64):
_ = model(single_input)
single_time = time.time() - start_time
# Batch processing
start_time = time.time()
with torch.no_grad():
_ = model(batch_input)
batch_time = time.time() - start_time
print(f"Single input processing time: {single_time:.6f} seconds")
print(f"Batch processing time: {batch_time:.6f} seconds")
print(f"Speedup: {single_time / batch_time:.2f}x")
GPU Acceleration
Moving your model and input data to a GPU can dramatically speed up inference, especially for larger models and datasets.
import torch
import time
model = torch.nn.Linear(1000, 100)
input_tensor = torch.randn(64, 1000)
# CPU inference
start_time = time.time()
with torch.no_grad():
output_cpu = model(input_tensor)
cpu_time = time.time() - start_time
# GPU inference
if torch.cuda.is_available():
model = model.cuda()
input_tensor = input_tensor.cuda()
start_time = time.time()
with torch.no_grad():
output_gpu = model(input_tensor)
gpu_time = time.time() - start_time
print(f"CPU inference time: {cpu_time:.6f} seconds")
print(f"GPU inference time: {gpu_time:.6f} seconds")
print(f"Speedup: {cpu_time / gpu_time:.2f}x")
else:
print("CUDA is not available. Unable to perform GPU inference.")
Model Pruning
Pruning removes unnecessary weights from your model, reducing its size and potentially increasing inference speed. PyTorch provides tools for structured and unstructured pruning.
import torch
import torch.nn.utils.prune as prune
class SimpleModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.fc1 = torch.nn.Linear(100, 50)
self.fc2 = torch.nn.Linear(50, 10)
def forward(self, x):
x = torch.relu(self.fc1(x))
return self.fc2(x)
model = SimpleModel()
# Apply pruning
prune.l1_unstructured(model.fc1, name='weight', amount=0.3)
prune.l1_unstructured(model.fc2, name='weight', amount=0.3)
# Check sparsity
print("Sparsity in fc1.weight:", 100. * float(torch.sum(model.fc1.weight == 0)) / float(model.fc1.weight.nelement()))
print("Sparsity in fc2.weight:", 100. * float(torch.sum(model.fc2.weight == 0)) / float(model.fc2.weight.nelement()))
# Make pruning permanent
prune.remove(model.fc1, 'weight')
prune.remove(model.fc2, 'weight')
Fusion of Operations
Fusing multiple operations into a single operation can reduce memory access and improve inference speed. PyTorch provides some built-in fused operations.
import torch
import torch.nn as nn
class UnfusedModel(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(3, 64, kernel_size=3, padding=1)
self.bn = nn.BatchNorm2d(64)
self.relu = nn.ReLU()
def forward(self, x):
x = self.conv(x)
x = self.bn(x)
x = self.relu(x)
return x
class FusedModel(nn.Module):
def __init__(self):
super().__init__()
self.conv_bn_relu = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU()
)
def forward(self, x):
return self.conv_bn_relu(x)
# Fuse the operations
fused_model = torch.nn.utils.fusion.fuse_conv_bn_eval(FusedModel().conv_bn_relu[0], FusedModel().conv_bn_relu[1])
input_tensor = torch.randn(1, 3, 224, 224)
unfused_output = UnfusedModel()(input_tensor)
fused_output = fused_model(input_tensor)
print("Max difference:", torch.max(torch.abs(unfused_output - fused_output)))
Real-life Example: Image Classification
Let's optimize a ResNet18 model for faster image classification inference.
import torch
import torchvision.models as models
import time
# Load a pre-trained ResNet18 model
model = models.resnet18(pretrained=True)
# Prepare input tensor (simulating an image)
input_tensor = torch.randn(1, 3, 224, 224)
# Measure inference time before optimization
def measure_inference_time(model, input_tensor, num_runs=100):
start_time = time.time()
with torch.no_grad():
for _ in range(num_runs):
_ = model(input_tensor)
end_time = time.time()
return (end_time - start_time) / num_runs
print("Before optimization:")
print(f"Inference time: {measure_inference_time(model, input_tensor):.6f} seconds")
# Optimize the model
model.eval() # Set to evaluation mode
optimized_model = torch.jit.script(model) # Apply TorchScript
# Measure inference time after optimization
print("\nAfter optimization:")
print(f"Inference time: {measure_inference_time(optimized_model, input_tensor):.6f} seconds")
# Further optimization: Move to GPU if available
if torch.cuda.is_available():
optimized_model = optimized_model.cuda()
input_tensor = input_tensor.cuda()
print("\nAfter moving to GPU:")
print(f"Inference time: {measure_inference_time(optimized_model, input_tensor):.6f} seconds")
Real-life Example: Natural Language Processing
Let's optimize a BERT model for faster text classification inference.
import torch
from transformers import BertForSequenceClassification, BertTokenizer
import time
# Load pre-trained BERT model and tokenizer
model = BertForSequenceClassification.from_pretrained('bert-base-uncased')
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
# Prepare input
text = "This is a sample sentence for classification."
inputs = tokenizer(text, return_tensors="pt")
# Measure inference time before optimization
def measure_inference_time(model, inputs, num_runs=10):
start_time = time.time()
with torch.no_grad():
for _ in range(num_runs):
_ = model(**inputs)
end_time = time.time()
return (end_time - start_time) / num_runs
print("Before optimization:")
print(f"Inference time: {measure_inference_time(model, inputs):.6f} seconds")
# Optimize the model
model.eval() # Set to evaluation mode
optimized_model = torch.jit.script(model) # Apply TorchScript
# Measure inference time after optimization
print("\nAfter optimization:")
print(f"Inference time: {measure_inference_time(optimized_model, inputs):.6f} seconds")
# Further optimization: Quantization
quantized_model = torch.quantization.quantize_dynamic(
model, {torch.nn.Linear}, dtype=torch.qint8
)
print("\nAfter quantization:")
print(f"Inference time: {measure_inference_time(quantized_model, inputs):.6f} seconds")
# Move to GPU if available
if torch.cuda.is_available():
optimized_model = optimized_model.cuda()
inputs = {k: v.cuda() for k, v in inputs.items()}
print("\nAfter moving to GPU:")
print(f"Inference time: {measure_inference_time(optimized_model, inputs):.6f} seconds")
Optimizing Data Loading
Efficient data loading can significantly impact overall inference speed, especially when dealing with large datasets.
import torch
from torch.utils.data import Dataset, DataLoader
import time
class DummyDataset(Dataset):
def __init__(self, size=10000):
self.data = torch.randn(size, 100)
self.labels = torch.randint(0, 2, (size,))
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx], self.labels[idx]
# Create dataset and dataloaders
dataset = DummyDataset()
batch_size = 64
# Standard DataLoader
standard_loader = DataLoader(dataset, batch_size=batch_size, shuffle=False)
# Optimized DataLoader
optimized_loader = DataLoader(dataset, batch_size=batch_size, shuffle=False,
num_workers=4, pin_memory=True)
def measure_loading_time(dataloader):
start_time = time.time()
for _ in dataloader:
pass
return time.time() - start_time
print(f"Standard DataLoader time: {measure_loading_time(standard_loader):.4f} seconds")
print(f"Optimized DataLoader time: {measure_loading_time(optimized_loader):.4f} seconds")
Half-Precision Inference
Using half-precision (FP16) can speed up inference and reduce memory usage, especially on GPUs that support it.
import torch
import time
model = torch.nn.Sequential(
torch.nn.Linear(1000, 500),
torch.nn.ReLU(),
torch.nn.Linear(500, 10)
)
input_tensor = torch.randn(64, 1000)
def measure_inference_time(model, input_tensor, num_runs=100):
start_time = time.time()
with torch.no_grad():
for _ in range(num_runs):
_ = model(input_tensor)
return (time.time() - start_time) / num_runs
fp32_time = measure_inference_time(model, input_tensor)
print(f"FP32 inference time: {fp32_time:.6f} seconds")
model_fp16 = model.half()
input_tensor_fp16 = input_tensor.half()
fp16_time = measure_inference_time(model_fp16, input_tensor_fp16)
print(f"FP16 inference time: {fp16_time:.6f} seconds")
print(f"Speedup: {fp32_time / fp16_time:.2f}x")
# GPU comparison (if available)
if torch.cuda.is_available():
model_cuda = model.cuda()
input_tensor_cuda = input_tensor.cuda()
model_fp16_cuda = model_fp16.cuda()
input_tensor_fp16_cuda = input_tensor_fp16.cuda()
cuda_fp32_time = measure_inference_time(model_cuda, input_tensor_cuda)
cuda_fp16_time = measure_inference_time(model_fp16_cuda, input_tensor_fp16_cuda)
print(f"CUDA FP32 time: {cuda_fp32_time:.6f} seconds")
print(f"CUDA FP16 time: {cuda_fp16_time:.6f} seconds")
print(f"CUDA Speedup: {cuda_fp32_time / cuda_fp16_time:.2f}x")
Model Distillation
Model distillation involves training a smaller, faster model (student) to mimic a larger, more accurate model (teacher). This can result in a model with faster inference times while maintaining good performance.
import torch
import torch.nn as nn
import torch.optim as optim
# Define teacher and student models
class TeacherModel(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(784, 1200)
self.fc2 = nn.Linear(1200, 1200)
self.fc3 = nn.Linear(1200, 10)
def forward(self, x):
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
return self.fc3(x)
class StudentModel(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(784, 400)
self.fc2 = nn.Linear(400, 10)
def forward(self, x):
x = torch.relu(self.fc1(x))
return self.fc2(x)
# Initialize models
teacher = TeacherModel()
student = StudentModel()
# Distillation process (simplified)
def distill(teacher, student, data, num_epochs=10):
optimizer = optim.Adam(student.parameters())
criterion = nn.KLDivLoss(reduction='batchmean')
for epoch in range(num_epochs):
for inputs, _ in data:
optimizer.zero_grad()
with torch.no_grad():
teacher_outputs = teacher(inputs)
student_outputs = student(inputs)
loss = criterion(student_outputs.log_softmax(dim=1),
teacher_outputs.softmax(dim=1))
loss.backward()
optimizer.step()
print(f"Epoch {epoch+1}/{num_epochs}, Loss: {loss.item():.4f}")
# Example usage (assuming 'data' is your DataLoader)
# distill(teacher, student, data)
Optimizing Model Architecture
Redesigning your model architecture can lead to significant speed improvements. This might involve using more efficient layer types or reducing the model's depth and width.
import torch
import torch.nn as nn
import time
class StandardConvNet(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 64, 3, padding=1)
self.conv2 = nn.Conv2d(64, 64, 3, padding=1)
self.fc = nn.Linear(64 * 32 * 32, 10)
def forward(self, x):
x = torch.relu(self.conv1(x))
x = torch.relu(self.conv2(x))
x = x.view(x.size(0), -1)
return self.fc(x)
class EfficientConvNet(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 32, 3, padding=1)
self.conv2 = nn.Conv2d(32, 64, 3, stride=2, padding=1)
self.fc = nn.Linear(64 * 16 * 16, 10)
def forward(self, x):
x = torch.relu(self.conv1(x))
x = torch.relu(self.conv2(x))
x = x.view(x.size(0), -1)
return self.fc(x)
def measure_inference_time(model, input_tensor, num_runs=100):
start_time = time.time()
with torch.no_grad():
for _ in range(num_runs):
_ = model(input_tensor)
return (time.time() - start_time) / num_runs
# Compare models
standard_model = StandardConvNet()
efficient_model = EfficientConvNet()
input_tensor = torch.randn(1, 3, 32, 32)
standard_time = measure_inference_time(standard_model, input_tensor)
efficient_time = measure_inference_time(efficient_model, input_tensor)
print(f"Standard model time: {standard_time:.6f} seconds")
print(f"Efficient model time: {efficient_time:.6f} seconds")
print(f"Speedup: {standard_time / efficient_time:.2f}x")
Additional Resources
For more in-depth information on optimizing PyTorch models, consider exploring these resources:
- PyTorch documentation on performance optimization: https://pytorch.org/tutorials/recipes/recipes/tuning_guide.html
- "Optimizing Computer Vision Models for Deployment" (arXiv:2204.07196): https://arxiv.org/abs/2204.07196
- "Efficient Deep Learning: A Survey on Making Deep Learning Models Smaller, Faster, and Better" (arXiv:2106.08962): https://arxiv.org/abs/2106.08962
- PyTorch Forum for community discussions and tips: https://discuss.pytorch.org/
These resources provide additional techniques, best practices, and research findings to further enhance your PyTorch model's inference speed.