4、模型加载与初始化:序列化格式、加载方式与预热策略

模型训练好了,怎么把它请进推理环境?这一步看似简单,但坑不少。我见过不少团队,模型在训练时跑得飞起,一到生产就崩,十有八九是加载和初始化环节出了问题。

说白了,模型加载就是「把硬盘上的二进制文件,还原成内存里的计算图」。但不同的序列化格式、不同的硬件拓扑,处理方式天差地别。今天我们就聊聊这三件事:格式选型、加载方式、预热策略。

4.1 模型序列化格式:三足鼎立

目前主流格式就三个:ONNX、TorchScript、SavedModel。我个人的选择原则很简单——看你的部署后端。

格式 来源框架 核心特点 我推荐的使用场景
ONNX PyTorch/TF → 转换 中间表示,跨框架、跨硬件 需要跑在TensorRT、OpenVINO等加速库上
TorchScript PyTorch原生 JIT编译,Python无关,C++部署 纯PyTorch链路,追求低延迟
SavedModel TensorFlow原生 包含模型+签名+资源,服务化友好 TF Serving、Kubernetes部署

4.1.1 ONNX:通用但需小心算子兼容

ONNX 的好处是「一次导出,到处运行」。但我在项目中遇到过一个问题:PyTorch 里的 torch.topk 导出成 ONNX 后,在 TensorRT 上跑不起来。原因是 TensorRT 的 ONNX 解析器不支持动态 k 值。

避坑指南:导出前先用 onnxruntime 跑一遍推理,验证结果是否对齐。我曾经因为跳过这步,上线后才发现精度偏差,回滚了一整个版本。

# 导出 ONNX 时的常见做法
import torch
import torch.onnx

model = MyModel().eval()
dummy_input = torch.randn(1, 3, 224, 224)

torch.onnx.export(
    model,
    dummy_input,
    "model.onnx",
    opset_version=17,
    input_names=["input"],
    output_names=["output"],
    dynamic_axes={"input": {0: "batch_size"}, "output": {0: "batch_size"}}
)

# 验证
import onnxruntime as ort
session = ort.InferenceSession("model.onnx")
output = session.run(None, {"input": dummy_input.numpy()})
print(output[0].shape)

4.1.2 TorchScript:PyTorch 部署的首选

如果你整个链路都是 PyTorch,TorchScript 是最省心的。它有两种导出方式:trace 和 script。

我个人习惯用 trace 做快速验证,用 script 处理带控制流的模型。你想想看,如果你的模型里有 if 语句,trace 是抓不到的,必须用 script。

# TorchScript 导出
model = MyModel().eval()

# 方式一:trace(适合无分支模型)
traced_model = torch.jit.trace(model, torch.randn(1, 3, 224, 224))
traced_model.save("model_traced.pt")

# 方式二:script(适合有控制流)
scripted_model = torch.jit.script(model)
scripted_model.save("model_scripted.pt")

# C++ 加载
# torch::jit::load("model_scripted.pt");
注意:TorchScript 不支持所有 Python 特性。比如 torch.Tensor.item() 在 trace 里会报错。导出前最好用 torch.jit.script 的 check_trace 参数做一次校验。

4.1.3 SavedModel:TF 生态的完整方案

SavedModel 不只是模型权重,它还保存了输入输出的签名信息。这意味着你不需要在服务端写额外的预处理代码。TF Serving 可以直接根据签名自动匹配请求。

# TensorFlow SavedModel 导出
import tensorflow as tf

model = tf.keras.models.load_model("training_model.h5")
tf.saved_model.save(model, "saved_model/1/")

# 查看签名
loaded = tf.saved_model.load("saved_model/1/")
print(loaded.signatures)

4.2 模型加载方式:单卡 vs 多卡

加载方式取决于你的推理服务器有几张卡。嗯,这里要注意:训练时的多卡和推理时的多卡,逻辑完全不同。

场景 加载方式 内存占用 典型延迟
单卡推理 直接 model.to(device) 1x 模型大小 低(无通信开销)
多卡数据并行 每卡一份完整模型 Nx 模型大小 中等(负载均衡好)
多卡模型并行 每卡一部分层 1x 模型大小 高(跨卡通信)

4.2.1 单卡加载:最直接的方式

单卡没什么好说的,就是 torch.load 然后 model.to(device)。但有一个细节:先加载到 CPU,再转移到 GPU。直接 map_location='cuda:0' 虽然可以,但如果你后续要做模型预热,CPU 加载更灵活。

# 推荐的单卡加载方式
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
model = MyModel()
checkpoint = torch.load("model.pt", map_location="cpu")
model.load_state_dict(checkpoint)
model.to(device)
model.eval()

4.2.2 多卡加载:数据并行 vs 模型并行

数据并行是最常见的。每张卡上放一份完整的模型,输入数据按 batch 维度切分。我建议用 torch.nn.DataParallel 做快速原型,生产环境用 torch.distributed 或者 Ray Serve。

模型并行则适用于超大模型(比如 70B 参数)。每张卡只放一部分层,推理时数据要串行经过所有卡。延迟会高一些,但能跑单卡放不下的模型。

# 多卡数据并行加载
model = MyModel()
checkpoint = torch.load("model.pt", map_location="cpu")
model.load_state_dict(checkpoint)

# 方式一:DataParallel(简单,但主卡负载高)
model = torch.nn.DataParallel(model).cuda()

# 方式二:DistributedDataParallel(推荐生产)
# 需要先初始化进程组
# torch.distributed.init_process_group(backend="nccl")
# model = torch.nn.parallel.DistributedDataParallel(model.cuda())
我的经验:多卡推理时,batch_size 要能被卡数整除。否则最后一批数据会不均匀,导致某些卡空闲。我曾经因为 batch_size=7 配 4 张卡,最后一批只有 3 张卡在工作,吞吐量直接掉了 25%。

4.3 模型预热(Warm-up)策略

模型刚加载完,第一次推理通常很慢。为什么?因为 GPU 的 CUDA 内核是懒加载的。第一次调用时,驱动要编译 kernel、分配显存、建立缓存。这个过程可能耗时几百毫秒甚至几秒。

预热,就是提前跑几次推理,让这些「一次性开销」在服务启动时完成,而不是在第一个用户请求时发生。

4.3.1 预热的核心逻辑

说白了,预热就是「用假数据跑几遍推理」。但要注意:预热的数据形状必须和线上一致。如果线上是 224x224,预热用 256x256,那预热就白做了——CUDA 会重新编译 kernel。

# 模型预热示例
def warm_up(model, device, input_shape=(1, 3, 224, 224), num_iterations=10):
    """
    预热模型,确保 CUDA kernel 被编译
    """
    model.eval()
    dummy_input = torch.randn(*input_shape).to(device)
    
    # 先跑一次,触发 kernel 编译
    with torch.no_grad():
        _ = model(dummy_input)
    
    # 再跑几次,让缓存稳定
    for _ in range(num_iterations - 1):
        with torch.no_grad():
            _ = model(dummy_input)
    
    # 同步 CUDA,确保所有操作完成
    torch.cuda.synchronize()
    print(f"Warm-up completed with {num_iterations} iterations.")

# 使用
device = torch.device("cuda:0")
model = load_model().to(device)
warm_up(model, device, input_shape=(1, 3, 224, 224), num_iterations=10)

4.3.2 预热的最佳实践

我建议预热时考虑以下几点:

  • 预热次数:3-10 次就够了。太多浪费启动时间,太少可能没完全触发。
  • 预热数据:用随机噪声即可,不需要真实数据。但形状必须匹配。
  • 多 batch 预热:如果线上有不同 batch size,每个 size 都要预热一次。
  • 多卡预热:每张卡都要单独预热,因为每张卡的 CUDA context 是独立的。
避坑指南:我曾经在一个项目中,预热只跑了 1 次。结果第一个请求延迟 800ms,后续请求 50ms。后来改成预热 5 次,第一个请求延迟降到 60ms。所以,预热次数别省。

4.3.3 预热与模型加载的整合

在实际部署中,我会把预热和加载写成一个启动脚本。服务启动时自动完成,用户无感知。

# 完整的加载+预热流程
def load_and_warm_up(model_path, device, batch_size=1):
    # 1. 加载模型
    model = MyModel()
    checkpoint = torch.load(model_path, map_location="cpu")
    model.load_state_dict(checkpoint)
    model.to(device)
    model.eval()
    
    # 2. 预热
    dummy_input = torch.randn(batch_size, 3, 224, 224).to(device)
    for _ in range(5):
        with torch.no_grad():
            _ = model(dummy_input)
    torch.cuda.synchronize()
    
    print(f"Model loaded and warmed up on {device}")
    return model

# 启动时调用
model = load_and_warm_up("model.pt", "cuda:0", batch_size=8)

4.4 本章知识体系

下面这张图总结了模型加载与初始化的核心流程。你可以把它当作一个检查清单:

模型加载与初始化流程 1. 选择序列化格式 ONNX / TorchScript / SavedModel 根据部署后端选择 2. 加载模型 单卡 / 多卡数据并行 / 模型并行 先CPU加载,再转移到GPU 3. 模型预热 3-10次推理 数据形状必须匹配线上 格式选择决策树 • 部署后端是 TensorRT / OpenVINO → 选 ONNX • 纯 PyTorch 链路,C++ 部署 → 选 TorchScript • TF Serving / Kubernetes 部署 → 选 SavedModel ⚠️ 常见陷阱 • ONNX 算子兼容性:导出后务必用 onnxruntime 验证 • 多卡预热:每张卡都要单独预热,不能只预热主卡 • 预热数据形状:必须和线上请求完全一致,否则 CUDA 会重新编译

模型加载与初始化,看似是部署中最简单的环节,但细节决定成败。格式选错了,加载方式不对,预热没做好——任何一个环节出问题,都会直接影响线上延迟和吞吐量。

我个人习惯在每次部署前,都跑一遍完整的加载+预热流程,记录下首次推理延迟和稳定后的延迟。如果差距超过 5 倍,说明预热没到位,需要调整。

最后一个小建议:把预热逻辑写进 Docker 容器的启动脚本里。这样每次重启服务,预热自动完成,不会因为忘记预热而踩坑。

可可精品资料库