从110ms到28ms:PyTorch实时推理优化深度解析
了解如何缩短实时应用程序的 PyTorch 模型延迟。本针对 ML 工程师的指南涵盖了训练后量化、ONNX 运行时加速和动态批处理,以构建高性能 Python 推理后端。
本页内容
数字健身行业正在蓬勃发展,由 AI 驱动的应用程序可以实时反馈运动姿态。想象一下,用户正在做深蹲;AI 教练可以立即说:"臀部再往下一点。"这种体验需要计算机视觉模型来处理视频流、分析姿态并在毫秒内提供反馈。如果反馈延迟,用户已经移动了位置,使建议变得毫无用处。
对于机器学习工程师来说,这带来了一个关键挑战:如何以实时交互所需的超低延迟来服务复杂的 PyTorch 计算机视觉模型?在 Python 后端中服务的标准 PyTorch 模型通常太慢。
在本次深入探讨中,我们将直面这个问题。我们将采用预训练的 PyTorch 姿态估计模型,并系统地对其进行优化,以用于实时健身分析后端。我们将探索三种强大的技术:模型量化、转换为 ONNX Runtime,以及高效批处理。
前提条件:
- 熟悉 Python 和 PyTorch。
- 了解 REST API(我们将使用 FastAPI)。
- 已安装 Docker 以便轻松设置环境。
- 了解计算机视觉概念的基础知识。
了解问题
在生产环境中服务深度学习模型不仅仅是调用 model(input)。对于实时健身应用,我们的 Python 后端将接收来自多个用户的持续视频帧流。目标是快速处理每一帧并返回姿态分析结果,使交互感觉即时完成。
核心挑战:
- 高延迟:以 eager 模式运行的标准 PyTorch 模型带有显著的开销。对于计算机视觉模型,在单个 CPU 核心上进行推理可能需要数百毫秒,这对于实时反馈来说太长了。
- 大模型尺寸:ResNet 或用于姿态估计的自定义 CNN 等模型可能达到数百兆字节。这会增加内存使用量,并可能减慢加载时间。
- CPU 利用率低:一次一个地处理请求无法充分利用现代 CPU 的完整并行处理能力,尤其是在处理并发用户时。
我们的方法将系统地解决这些瓶颈,以构建高性能推理服务。
前提条件
在我们开始之前,让我们设置环境。我们将使用 torchvision 中的预训练 ResNet 模型来模拟我们的姿态估计模型,因为优化原则是相同的。
所需库:
torch和torchvisiononnx和onnxruntimefastapi和uvicornnumpypsutil(用于性能监控)
您可以使用 pip 安装它们:
pip install torch torchvision onnx onnxruntime fastapi "uvicorn[standard]" numpy psutil
让我们从创建基线应用程序开始。这将是一个简单的 FastAPI 服务器,使用标准、未优化的 PyTorch 模型进行推理。
步骤 1:建立基线
首先,我们需要测量我们的起点。标准 PyTorch 模型在简单 Web 服务器中有多慢?
我们在做什么
我们将创建一个 FastAPI 端点,它接受一个虚拟输入张量(模拟图像帧),使用预训练的 ResNet50 模型运行推理,并返回结果。
实现
将以下代码保存为 baseline_server.py:```python
baseline_server.py
from fastapi import FastAPI import torch import torchvision.models as models import time import numpy as np
app = FastAPI()
1. Load pre-trained FP32 model
model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) model.eval() # Set to evaluation mode
Use a context manager to disable gradient calculations for inference
@torch.inference_mode() @app.post("/predict") async def predict(): # 2. Create a dummy input tensor (batch size 1) # This simulates a single video frame from a user dummy_input = torch.randn(1, 3, 224, 224)
# 3. Benchmark the inference time
start_time = time.perf_counter()
output = model(dummy_input)
end_time = time.perf_counter()
latency_ms = (end_time - start_time) * 1000
# 4. Return results
# In a real app, this would be pose data (e.g., joint coordinates)
probabilities = torch.nn.functional.softmax(output, dim=0)
top5_prob, top5_catid = torch.topk(probabilities, 5)
return {
"latency_ms": f"{latency_ms:.2f}",
"top_category": int(top5_catid),
"top_probability": float(top5_prob)
}
if name == "main": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)
### 工作原理
1. 我们从 `torchvision` 加载标准的 32 位浮点(FP32)ResNet50 模型。
2. `/predict` 端点模拟接收请求。我们使用 `@torch.inference_mode()`,这是一种禁用梯度计算的性能最佳实践。
3. 我们测量 `model()` 前向传播所需的时间。
4. 我们返回延迟和一个示例预测。
运行服务器:`uvicorn baseline_server:app --reload`
现在,使用 `curl` 从另一个终端测试它:
```bash
curl -X POST http://127.0.0.1:8000/predict
预期输出(根据您的 CPU 会有所不同):
{"latency_ms":"110.54","top_category":714,"top_probability":0.0069...}
在现代笔记本电脑 CPU 上,延迟超过 100ms。这是我们的基线。对于 30 FPS 视频流,我们每帧只有约 33ms。我们太慢了。
步骤 2:使用模型量化进行优化
我们的第一个优化是量化。我们将把模型的权重和激活从高精度 FP32 格式转换为低精度 8 位整数(INT8)格式。这可以显著加速现代 CPU 上的计算并减小模型大小。
量化主要有两种类型:
- 训练后量化(PTQ):一种在模型训练后应用的简单方法。实现速度快,但有时可能会导致精度下降。
- 量化感知训练(QAT):在训练过程中模拟量化。需要重新训练,但通常会使量化模型获得更好的精度。
对于后端部署,当我们经常收到预训练模型时,PTQ 是一个强大且常见的第一步。我们将使用训练后静态量化,它涉及在代表性数据的小样本上校准模型。
我们在做什么
我们将编写一个脚本,使用 PyTorch 内置的量化工具将我们的 FP32 ResNet50 模型转换为 INT8 模型。
实现
创建一个新脚本 quantize_model.py:
# quantize_model.py
import torch
import torchvision.models as models
from torch.quantization import quantize_dynamic, get_default_qconfig, prepare, convert
def calibrate_model(model, data_loader, num_batches=10):
model.eval()
with torch.no_grad():
for i, (images, _) in enumerate(data_loader):
if i >= num_batches:
break
model(images)
def main():
# 1. Load the original FP32 model
float_model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
float_model.eval()
# 2. Specify quantization configuration
# 'fbgemm' is the recommended backend for x86 CPUs
qconfig = get_default_qconfig('fbgemm')
float_model.qconfig = qconfig
# 3. Prepare the model for static quantization
# This inserts 'observer' modules that will collect statistics
prepared_model = prepare(float_model, inplace=False)
# 4. Calibrate with sample data
# In a real scenario, use a representative validation dataset
# Here, we just use random data for demonstration
print("Calibrating the model...")
dummy_dataset = [(torch.randn(3, 224, 224), 0) for _ in range(100)]
dummy_loader = torch.utils.data.DataLoader(dummy_dataset, batch_size=10)
calibrate_model(prepared_model, dummy_loader)
print("Calibration complete.")
# 5. Convert the calibrated model to INT8
quantized_model = convert(prepared_model, inplace=False)
# 6. Save the quantized model
torch.jit.save(torch.jit.script(quantized_model), "quantized_resnet50.pt")
print("Quantized model saved as quantized_resnet50.pt")
if __name__ == '__main__':
main()
运行此脚本:python quantize_model.py
工作原理
- 我们为
fbgemm后端指定量化配置(qconfig),该后端针对服务器进行了优化。 torch.quantization.prepare将观察器插入模型中,以观察激活值的范围。- 然后我们通过运行几批数据来"校准"模型。观察器记录值分布。
- 最后,
torch.quantization.convert使用这些统计信息将模型的权重和激活函数转换为 INT8,计算适当的缩放因子。 - 我们使用
torch.jit.save保存生成的模型,以便于加载。
原始 ResNet50 模型约 98MB。量化模型 quantized_resnet50.pt 只有 约 25MB——减少了 4 倍!
现在让我们更新服务器以使用这个新模型。创建 quantized_server.py:```python
quantized_server.py
from fastapi import FastAPI import torch import time
app = FastAPI()
1. Load the JIT-scripted quantized model
model = torch.jit.load("quantized_resnet50.pt")
No need for model.eval() or torch.inference_mode() with a JIT model
@app.post("/predict") async def predict(): # Input tensor must be created on the CPU for a CPU-quantized model dummy_input = torch.randn(1, 3, 224, 224)
start_time = time.perf_counter()
output = model(dummy_input)
end_time = time.perf_counter()
latency_ms = (end_time - start_time) * 1000
probabilities = torch.nn.functional.softmax(output, dim=0)
top5_prob, top5_catid = torch.topk(probabilities, 5)
return {
"latency_ms": f"{latency_ms:.2f}",
"top_category": int(top5_catid),
"top_probability": float(top5_prob)
}
运行它(`uvicorn quantized_server:app`)并使用 `curl` 测试。
**预期输出:**```json
{"latency_ms":"45.12","top_category":714,"top_probability":0.0071...}
我们的延迟从 约 110ms 降低到 约 45ms。仅通过量化我们就将速度提高了一倍以上!✨
步骤 3:使用 ONNX Runtime 进一步加速
虽然量化有助于 PyTorch 运行得更快,但 PyTorch 本身是一个为训练和推理设计的框架,带有一些开销。ONNX(开放神经网络交换) 是 ML 模型的开放格式,而 ONNX Runtime (ORT) 是一个高性能推理引擎,专门为以最高速度运行这些模型而构建。
将模型转换为 ONNX 允许运行时执行强大的图优化并利用硬件特定的加速库,通常优于原生框架执行。
我们在做什么
我们将把量化的 PyTorch 模型转换为 ONNX 格式,并更新我们的服务器以使用 onnxruntime 进行推理。
实现
首先,创建一个导出脚本 export_to_onnx.py:
# export_to_onnx.py
import torch
import onnxruntime
# Load the quantized model we saved earlier
quantized_model = torch.jit.load("quantized_resnet50.pt")
quantized_model.eval()
# Provide a dummy input for tracing the model graph
dummy_input = torch.randn(1, 3, 224, 224)
onnx_path = "quantized_resnet50.onnx"
# Export the model
print("Exporting model to ONNX format...")
torch.onnx.export(
quantized_model,
dummy_input,
onnx_path,
export_params=True,
opset_version=13, # A common, stable version
do_constant_folding=True,
input_names=['input'],
output_names=['output'],
dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}
)
print(f"Model saved to {onnx_path}")
# Optional: Verify the ONNX model
print("Verifying the ONNX model...")
ort_session = onnxruntime.InferenceSession(onnx_path)
ort_inputs = {ort_session.get_inputs().name: dummy_input.numpy()}
ort_outs = ort_session.run(None, ort_inputs)
print("ONNX model verified successfully.")
运行脚本:python export_to_onnx.py。这将创建 quantized_resnet50.onnx。
现在,让我们构建我们最终的优化服务器 onnx_server.py:
# onnx_server.py
from fastapi import FastAPI
import onnxruntime as ort
import numpy as np
import time
app = FastAPI()
# 1. Create an ONNX Runtime inference session
ort_session = ort.InferenceSession("quantized_resnet50.onnx")
input_name = ort_session.get_inputs().name
@app.post("/predict")
async def predict():
# 2. Create a dummy input, now as a NumPy array
dummy_input = np.random.randn(1, 3, 224, 224).astype(np.float32)
# 3. Run inference with ONNX Runtime
start_time = time.perf_counter()
ort_inputs = {input_name: dummy_input}
ort_outs = ort_session.run(None, ort_inputs)
end_time = time.perf_counter()
latency_ms = (end_time - start_time) * 1000
# 4. Process output (ONNX returns NumPy arrays)
output = ort_outs
# Softmax and top-k logic is the same, just with numpy
exp_output = np.exp(output)
probabilities = exp_output / np.sum(exp_output)
top5_catid = np.argsort(probabilities)[-5:][::-1]
return {
"latency_ms": f"{latency_ms:.2f}",
"top_category": int(top5_catid),
}
运行它(uvicorn onnx_server:app)并使用 curl 测试。
预期输出:
{"latency_ms":"28.31","top_category":714}
难以置信!我们的延迟从 约 45ms(量化 PyTorch)降到了 30ms 以下。我们现在接近实时应用所需的速度。
步骤 4:使用高效批处理最大化吞吐量
我们的服务器对于单个请求很快,但当 10 个用户同时锻炼时会发生什么?顺序处理请求会造成瓶颈。解决方案是批处理:将多个请求分组在一起,并在单次传递中将它们馈送到模型。这能更好地利用 CPU/GPU 上的并行计算。
静态批处理简单但效率低;它等待固定数量的请求,这会增加延迟。动态批处理更智能:它等待非常短的时间(例如 5-10ms)来收集任何传入的请求,并处理它拥有的任何内容。这完美地平衡了延迟和吞吐量。
从头开始实现一个健壮的动态批处理系统是复杂的。像 NVIDIA Triton Inference Server 这样的生产系统会自动处理这一点。然而,我们可以模拟我们的 Python 后端中的核心逻辑来演示这个概念。
我们在做什么
我们将重构我们的 ONNX 服务器以使用队列和后台工作线程。API 端点将快速将请求添加到队列中,工作线程将在运行批处理推理之前收集几毫秒的请求。
实现
这是我们迄今为止最先进的服务器 batched_onnx_server.py:
# batched_onnx_server.py
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor
from queue import Queue, Empty
import numpy as np
import onnxruntime as ort
from fastapi import FastAPI, HTTPException
# --- Configuration ---
MAX_BATCH_SIZE = 16
BATCH_TIMEOUT_MS = 5 # 5ms window to collect requests
# --- Global Resources ---
app = FastAPI()
request_queue = Queue()
# Using a dictionary to pass results back to the correct request
results_dict = {}
# --- ONNX Model Loading ---
ort_session = ort.InferenceSession("quantized_resnet50.onnx")
input_name = ort_session.get_inputs().name
# --- Background Worker for Batching ---
def inference_worker():
while True:
batch_items = []
# Wait for the first request
request_id, input_data = request_queue.get()
batch_items.append((request_id, input_data))
# Collect more requests within the time window
start_time = time.perf_counter()
while (time.perf_counter() - start_time) * 1000 < BATCH_TIMEOUT_MS and \
len(batch_items) < MAX_BATCH_SIZE:
try:
# Non-blocking get
next_id, next_data = request_queue.get_nowait()
batch_items.append((next_id, next_data))
except Empty:
break # No more items in the queue
# Prepare batch for inference
batch_size = len(batch_items)
if batch_size > 0:
batched_input = np.concatenate([item for item in batch_items], axis=0)
# Run batched inference
ort_inputs = {input_name: batched_input}
ort_outs = ort_session.run(None, ort_inputs)
# Distribute results back to individual requests
for i in range(batch_size):
request_id = batch_items[i]
result = ort_outs[i]
results_dict[request_id] = result
# --- FastAPI Endpoint ---
@app.on_event("startup")
async def startup_event():
# Start the background worker in a separate thread
executor = ThreadPoolExecutor(max_workers=1)
executor.submit(inference_worker)
@app.post("/predict")
async def predict():
request_id = time.time() # Unique ID for this request
input_data = np.random.randn(1, 3, 224, 224).astype(np.float32)
# Put the request into the queue
request_queue.put((request_id, input_data))
# Wait for the result from the inference worker
while request_id not in results_dict:
await asyncio.sleep(0.001) # Poll for the result
# Retrieve and remove the result
result = results_dict.pop(request_id)
# Process and return the result as before
exp_output = np.exp(result)
probabilities = exp_output / np.sum(exp_output)
top_category = int(np.argmax(probabilities))
return {"top_category": top_category}
工作原理
/predict端点现在非常轻量。它生成一个唯一 ID,创建输入数据,并将(id, data)元组放入Queue。然后它等待结果出现在results_dict中。- 一个单一的
inference_worker在后台运行。它从队列中拉取一个项目,然后在 5 毫秒内尝试拉取更多项目。 - 它使用
np.concatenate将所有收集的输入堆叠成一个大批次。 - 它在整个批次上运行一次推理调用。
- 然后它遍历输出批次,将每个单独的结果与其对应的请求 ID 一起放入
results_dict。
这种架构显著提高了吞吐量。虽然单个用户的延迟可能因 5ms 等待而略高,但服务器现在可以比逐个处理方式更高效地处理许多并发用户。
总结
让我们回顾我们的历程和每个阶段的性能提升。
| 阶段 | 模型格式 | 推理引擎 | 延迟(单个请求) | 吞吐量(并发用户) | 模型大小 |
|---|---|---|---|---|---|
| 1. 基线 | PyTorch FP32 | PyTorch | 约 110 ms | 低 | 约 98 MB |
| 2. 量化 | PyTorch INT8 | PyTorch | 约 45 ms | 低 | 约 25 MB |
| 3. ONNX Runtime | ONNX INT8 | ONNX Runtime | 约 28 ms | 低 | 约 25 MB |
| 4. 批处理 ONNX | ONNX INT8 | ONNX Runtime | 约 33 ms(5ms 等待) | 高 | 约 25 MB |
我们成功地将单个请求延迟从 110ms 降低到 30ms 以下,并构建了一个能够处理高吞吐量场景的系统。我们现在完全在实时健身分析的预算范围内!
安全最佳实践
- 输入验证:在真实应用程序中,始终验证输入图像数据。在将其馈送到模型之前,确保它具有正确的尺寸、类型和归一化,以防止错误和潜在漏洞。
- 资源限制:在您的 API 端点上实施速率限制,以防止拒绝服务(DoS)攻击使您的推理服务器不堪重负。
- 模型完整性:保护您的模型文件。确保只有授权服务可以访问或修改
.pt或.onnx文件。
生产部署技巧
- 使用生产服务器:使用生产级 ASGI 服务器(如由 Gunicorn 管理的 Uvicorn)运行 FastAPI,以实现稳健性和可扩展性(
gunicorn -w 4 -k uvicorn.workers.UvicornWorker)。 - 考虑使用 Triton Inference Server:对于复杂的、高需求的生产环境,使用像 NVIDIA Triton 这样的专用推理服务器。它开箱即用地提供动态批处理、多 GPU 支持和模型版本控制。
- CPU vs. GPU:虽然我们专注于 CPU 优化,但这些技术对 GPU 同样强大。ONNX Runtime 有针对 NVIDIA GPU(CUDA、TensorRT)的执行提供程序,可以带来更大的性能提升。
结论
优化 PyTorch 模型以进行实时推理是一个多步骤过程,可以带来巨大回报。通过从基线开始,我们系统地应用量化来缩小模型并加速计算,然后切换到高度优化的 ONNX Runtime 来削减关键毫秒。最后,我们实现了一个动态批处理策略,以确保我们的服务器能够处理具有许多并发用户的实际应用需求。
从 110ms 的庞然大物到低于 30ms 的高吞吐量推理引擎的旅程,是任何将模型投入生产的 ML 工程师的关键技能。现在您拥有了使您自己的模型飞速运行所需的工具和技术。
资源
- 官方 PyTorch 量化文档:https://pytorch.org/docs/stable/quantization.html
- ONNX Runtime 官方网站:https://onnxruntime.ai/
- PyTorch 到 ONNX 导出教程:https://pytorch.org/tutorials/advanced/super_resolution_with_onnxruntime.html
常见问题
Q:我怎么知道这是否适用于我? A:与您的医疗保健提供者讨论您的个人和家族病史,以评估您的个人风险因素并确定适当的下一步。
Q:我应该问医生什么? A:好的问题包括:"我的结果意味着什么?""我有哪些选择?""哪些生活方式的改变会有帮助?"以及"我什么时候应该随访?"
Q:有哪些可以提供帮助的生活方式改变? A:大多数健康状况都会从基础措施中受益:均衡营养、定期体育活动(每周 150 分钟以上)、优质睡眠(7-9 小时)、压力管理以及避免吸烟。
医学免责声明
本文仅供教育目的,不构成医疗建议。在就您的健康、治疗或护理做出决定之前,请务必咨询合格的医疗保健提供者。个人情况各异,对一个人合适的东西可能对另一个人不合适。
如果您遇到医疗紧急情况,请立即拨打 911(或您当地的紧急电话号码)。