C---深度学习-六-
C++ 深度学习(六)
译者:飞龙
- **步骤 2**:计算分片大小
```text
total_size = 118,016
world_size = 4
shard_size = (118,016 + 4 - 1) / 4 = 29,504 // Ceiling division
-
步骤 3:分发分片
Rank 0: params[0:29504] // 29,504 parameters Rank 1: params[29504:59008] // 29,504 parameters Rank 2: params[59008:88512] // 29,504 parameters Rank 3: params[88512:118016] // 29,504 parameters
下面是一个用于实现模型参数分片的 C++ 函数:
void shard_parameters() {
// 1. Collect all parameters
std::vector<torch::Tensor> all_params;
for (const auto& param : model->parameters()) {
all_params.push_back(param.data().flatten());
}
// Concatenate into single tensor
torch::Tensor concat_params = torch::cat(all_params);
int64_t total_size = concat_params.numel();
// 2. Calculate this rank's shard boundaries
int64_t shard_size = (total_size + world_size - 1) / world_size;
int64_t start_idx = rank * shard_size;
int64_t end_idx = std::min(start_idx + shard_size, total_size);
// 3. Extract and store this rank's shard
if (start_idx < total_size) {
torch::Tensor shard = concat_params.slice(0, start_idx, end_idx).clone();
sharded_params.push_back(shard);
}
}
在反向传播之后,FSDP 会聚合所有 GPU 上的梯度并分发结果,使每个 GPU 只接收其参数分片对应的梯度。
FSDPTrainer 类
让我们通过一个具体示例来了解 FSDP 实现。完整的代码以及补充文档可在本章附带的 GitHub 仓库中找到。为了便于解释和说明,我们将为模型设定以下参数:
- 模型:1000 个参数
- GPU:4(Rank 0、1、2、3)
- 数据集:100 个样本
- 训练轮数:3
在初始化过程中,四个终端命令会分别启动四个独立的训练进程。每个终端运行相同的程序 fsdp_training。第一个参数是 GPU 的秩(rank),第二个参数是 GPU 总数。
# Terminal 1
./fsdp_training 0 4
# Terminal 2
./fsdp_training 1 4
# Terminal 3
./fsdp_training 2 4
# Terminal 4
./fsdp_training 3 4
下一步是创建模型并将其移动到相应的 GPU 上:
auto model = std::make_shared<SimpleNet>(784, 128, 10);
model->to(device); // Each rank moves to its GPU
让我们初始化 FSDPTrainer 类。与之前(在 DDP 中)一样,它通过 FileStore 建立进程组。
FSDPTrainer trainer(model, rank, world_size, "/tmp/fsdp_store");
与 DDPTrainer 类似,NCCL 通信器建立 GPU 到 GPU 的通信,并调用 broadcast_parameters() 函数从秩 0 广播初始参数。与 DDP 不同,在 FSDP 中,模型通过 shard_parameters() 函数进行分片共享。
在建立这些基础组件之后,我们就可以着手训练模型了。这里我将描述一个典型的训练步骤。下面的 train_step 函数在 FSDP 设置中编排一次完整的训练迭代,协调多个 GPU 之间的协作,每个 GPU 仅持有模型的一部分(分片)。
void train_step(torch::Tensor input, torch::Tensor target,
torch::optim::Optimizer& optimizer) {
// Step 1: All-Gather - Reconstruct full model from shards
all_gather_parameters();
// Step 2: Forward Pass - Compute loss with full model
auto output = model->forward(input);
auto loss = torch::nn::functional::cross_entropy(output, target);
// Step 3: Backward Pass - Compute gradients
optimizer.zero_grad();
loss.backward();
// Step 4: Reduce-Scatter
reduce_scatter_gradients();
// Step 5: Optimizer Update
optimizer.step();
// Step 6: Free full parameters to save memory
full_params.clear();
}
正如你在代码块中看到的,接下来发生的事件序列如下:
-
全收集参数(All-Gather parameters):在开始任何计算之前,每个 GPU 都需要完整的模型。此操作收集所有 GPU 上的所有参数分片,并在每个设备上重建完整模型。这是必要的,因为前向传播需要访问所有模型权重。
all_gather_parameters(); -
前向传播(Forward Pass):现在完整模型已可用,每个 GPU 独立地将其分配到的数据批次输入模型进行处理并计算损失。每个 GPU 处理不同的数据(数据并行),但使用相同的完整模型。
auto output = model->forward(input); auto loss = torch::nn::functional::cross_entropy(output, target); -
反向传播(Backward Pass):将优化器的梯度清零,然后反向传播计算所有参数的梯度。此时,每个 GPU 已根据其本地数据批次计算出梯度。
optimizer.zero_grad(); loss.backward(); -
归约-散播梯度(Reduce-Scatter Gradients):如前所述,这是全收集的逆操作。所有 GPU 的梯度被加在一起(以聚合来自所有批次的学习),然后将结果分割并分发,使得每个 GPU 只接收到与其参数分片对应的梯度分片。这确保了每个 GPU 拥有其负责更新的参数的平均梯度。
reduce_scatter_gradients(); -
优化器更新(Optimizer Update):每个 GPU 仅使用其接收到的聚合梯度更新其参数分片。由于所有 GPU 在不同数据上计算梯度并对其求平均,因此产生的结果与在单个大批次上训练相同。
optimizer.step(); -
释放完整参数(Free Full Parameters):反向传播之后,在下一次前向传播之前不再需要完整的模型参数,因此将它们从内存中释放。每个 GPU 现在只存储其参数分片,从而大幅减少内存使用。这正是训练那些无法容纳在单个 GPU 上的模型成为可能的原因。
full_params.clear();
该循环在每个 epoch 中重复,直到训练完成。
本节介绍了 FSDP 并行训练策略的基本组成部分。我们的目标是让读者深入理解支撑分布式训练的并行化技术和通信流模式。有了这个基础,你就可以探索更高级的实现和优化。
这里展示的 FSDP 实现仅用于教学目的,不应部署到生产环境中。一个关键的限制是全收集操作期间出现的内存峰值。当完整模型大小超过单个 GPU 的内存容量时,此峰值将触发内存不足(out-of-memory)(OOM)错误,导致训练失败。
为了解决这一限制,生产系统采用了更精细的分片策略。随附的代码仓库中包含一个演示程序,该程序实现了逐层全收集操作与流水线并行相结合。这些技术逐层增量地重建模型,而不是一次性全部重建,从而显著降低峰值内存使用。我们强烈建议你研究该实现,以理解 FSDP 如何扩展到无法容纳在单个 GPU 上的模型。
为你的模型选择 DDP 还是 FSDP 取决于多种因素。总的来说,模型大小和基础设施决定了你的选择。下表将有助于指导你的决策过程。
| 因素 | 选择 DDP | 选择 FSDP |
|---|---|---|
| 模型大小 | 适合单张 GPU | 不适合单张 GPU |
| 互连 | 任意 | 高带宽(NVLink/IB) |
| 优先级 | 速度 | 内存效率 |
| 扩展规模 | <8 张 GPU | >8 张 GPU |
| 模型类型 | 中小型 | 大型至超大型 |
表 9.1:FSDP 与 DDP 决策矩阵
虽然 DDP 和 FSDP 等分布式训练策略使我们能够在多个 GPU 上训练越来越大的模型,但由此产生的模型通常仍然过大且计算成本过高,无法实际部署。这在训练能力与实际应用需求之间造成了一个关键差距。模型压缩技术通过减小模型大小、内存占用和计算需求来弥合这一差距,同时保持准确性,从而使得在内存、带宽和能量有限的边缘设备、移动平台和资源受限环境中部署此类大型模型成为可能。
模型压缩技术
现代 Transformer 模型的卓越能力伴随着一个重大挑战:其巨大的规模。最先进的语言模型可能包含数十亿甚至数万亿参数,需要大量的计算资源和内存。虽然这种规模使其在复杂任务上表现出色,但也为实际部署造成了障碍——尤其是在边缘设备、移动平台或延迟和能耗至关重要的场景中。
试想一下当今从业者面临的约束:内存和电池寿命有限的智能手机、在本地处理数据的物联网设备,或管理着数百万次请求推理成本的云服务。在每种情况下,全尺寸模型都变得不切实际。模型压缩技术正是为了解决这一差距,使我们能够在资源受限的环境中部署此类大型模型,而不会牺牲准确性。
在本节中,我们将讨论三种常用的模型压缩技术。我们的目标是向你介绍相关概念,并提供一些示例代码展示如何构建这些技术。你可以在此基础上继续构建。让我们从知识蒸馏开始讨论。
知识蒸馏
如今的大型语言模型不仅从原始文本中学习——它们还相互学习。Google 的 Gemma 3 由 Gemini 训练而成。Meta 较小的 Llama 4 模型(Maverick 和 Scout)则由更大的 Behemoth 训练而成。这一过程被称为知识蒸馏,尽管它引发了大量讨论,但这一概念经常被误解。
知识蒸馏是一种模型压缩技术,它将知识从大型的“教师”模型迁移到更小、更高效的“学生”模型。这一方法由 Geoffrey Hinton、Oriol Vinyals 和 Jeff Dean 在他们 2014 年的开创性论文 蒸馏神经网络中的知识 中率先提出,该论文发表于 NIPS 深度学习与表示学习研讨会。
在训练过程中,我们会采用资源密集型技术——集成方法、深层架构、海量数据集——来最大化学习效果。在部署过程中,我们需要能在资源受限设备上运行的高效模型。关键问题在于:我们能否从强大的模型中提取已学到的模式,并将其迁移到紧凑的替代模型中?
早期的模型压缩研究(Caruana,2006)表明这是可行的,但也存在局限性。当教师模型非常准确时,其输出近似于独热编码,从而丢失了类别之间有价值的关系信息。
教师模型的输出包含比单独硬标签更丰富的信息。以对一张“2”的图像进行分类为例:
- 类别 2:0.85
- 类别 3:0.10
- 类别 7:0.03
- 其他类别:~0.02
这表明“2”与“3”的相似程度高于“7”——这种关系信息被称为 暗知识,是独热标签无法捕捉的。标准的交叉熵损失只专注于最大化正确类别的概率,忽略了这些有价值的类别相似性。为了形式化这一概念,知识蒸馏使用了温度缩放,正如 第 8 章 中关于自回归生成部分所讨论的那样。与之前一样,较高的温度会揭示类别之间的相对相似性,否则这些相似性会隐藏在尖锐的分布中。
训练过程:双重目标
知识蒸馏采用了一种精妙的双重目标训练方法,在向教师模型学习和从原始数据学习之间取得平衡。
第 1 步:训练教师模型
首先,针对你的任务训练一个庞大而笨重的模型(或模型集成),使其达到较高精度。该教师模型能够捕捉数据中复杂的模式和关系。
第 2 步:生成软目标
使用升高的温度 T 将训练数据输入教师模型,生成保留暗知识的软概率分布。
第 3 步:使用组合损失训练学生模型
学生模型同时从两个来源学习:
-
a. 软目标损失:学生模型从教师模型的软预测中学习(两个模型都使用升高的温度 T)。这通常使用 KL 散度计算:
L_soft = KL_divergence(student_T || teacher_T) -
b. 硬目标损失:学生模型还从真实标签中学习(使用 T = 1)。这是标准的交叉熵损失:
L_hard = CrossEntropy(student, ground_truth)
最终的损失函数结合了两个目标:
L_total = α * L_soft * T² + (1 - α) * L_hard
其中:
α 是一个权重因子(通常为 0.5-0.9),用于平衡从教师模型和真实标签中学习;T² 缩放因子用于补偿温度缩放带来的量级缩减。
当在 softmax 中使用温度 T 时,梯度会按 1/T² 进行缩放。乘以 T² 可确保软目标损失相对于硬目标损失以适当的量级发挥作用。下图解释了这一训练过程:
Transfer
Data Set
Layer 1
Layer 2
Layer n
......
Teacher Model (pre-trained)
Layer 1
Layer 2
Layer m
......
Student Model (to be trained)
Softmax
T=t
Knowledge
Softmax
T=t
Softmax
T=1
Soft
Targets
Ground
Truth Label
Student
Loss
Soft
Targets
Soft
Targets
Distillation
Loss
图 5 基准知识蒸馏的具体架构(Hinton 等人,2015)
图 9.9:用于知识蒸馏的教师-学生模型。
我们在 https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/knowledge_distillation.cpp 提供了上述代码的 C++ 示例实现。基于之前关于模型训练的讨论,你应该能够理解。不过,我们将在下面重点介绍学生模型的训练:
for (int epoch = 0; epoch < 100; ++epoch) {
student_opt.zero_grad();
auto student_logits = student->forward(data);
torch::Tensor teacher_logits;
{
torch::NoGradGuard no_grad;
teacher_logits = teacher->forward(data);
}
auto loss = distillation_loss(student_logits, teacher_logits,
labels, temperature, alpha);
loss.backward();
student_opt.step();
if (epoch % 20 == 0)
std::cout << " Epoch " << epoch << ", Loss: " << loss.item<float>() << std::endl;
}
该训练循环通过迭代 100 个 epoch 来实现知识蒸馏,在此过程中,学生模型同时从教师模型的指导和真实标签中学习。每次迭代都会清除梯度、生成两个模型的预测(教师模型处于无梯度模式以防止权重更新)、计算结合了来自教师模型的软目标和来自真实标签的硬标签的组合蒸馏损失,然后反向传播以仅更新学生模型的权重。每 20 个 epoch 记录一次进度以监控收敛情况,损失值反映了学生模型在模仿教师模型精细的类别关系以及正确分类方面的学习效果。
知识蒸馏的另一种变体是 基于特征的蒸馏。在这种变体中,学生模型学习的不是教师模型的输出,而是其特征。实际上,学生模型试图匹配教师模型的中间特征。蒸馏过程本身可以是离线的(使用预训练模型作为教师来教导学生)、在线的(教师和学生同时训练)或自蒸馏(模型向自己学习)。我们鼓励读者参阅 延伸阅读 部分,以获取有关该主题的补充阅读材料。
我们知道,知识蒸馏帮助我们从较大的模型训练出较小的模型,同时保持相似的精度指标。这些较小的模型也适合在边缘设备(或 GPU 较小的设备)上部署。然而,这种性能提升并非没有局限性。具体来说,训练过程对温度和超参数非常敏感。额外增加的复杂度可能会导致知识蒸馏的效果不佳。训练过程和计算成本可能会抵消在生产环境中部署稍大模型的便利性。因此,知识蒸馏是否是一个可行的选项,需要根据具体情况进行评估。
剪枝
在大规模数据集上训练的神经网络通常包含大量冗余。一个在 ImageNet 的 1,000 个类别上训练、用于区分犬种的模型,不需要区分公交车与小轿车的特征也能正确识别出比格犬。这一观察催生了 剪枝:系统地移除不必要的参数,以创建更小、更快且精度损失不大的模型。
考虑一个权重矩阵为 W^(m×n) 的简单全连接层。m×n 个参数中的每一个都对层的输出有所贡献,但它们的贡献差异巨大。有些权重在重要特征上持续产生较大的激活值,而另一些权重在各种输入下都接近为零。剪枝就是要识别并移除这些低影响的参数。在思考剪枝时,你应该考虑以下三个问题:
- 剪什么?
- 何时剪?
- 如何决定要剪什么?
要回答第一个问题,主要有两种剪枝范式:非结构化剪枝和结构化剪枝。非结构化剪枝通过将单个权重设为零来消除它们,从而产生稀疏的权重矩阵。结构化剪枝则移除整个架构组件:卷积层中的滤波器、Transformer 中的注意力头或完整的神经元。
第9章:C++中的Transformer与大语言模型微调
关于剪枝,下一个关键问题是何时进行剪枝。可以在训练之前、训练期间或训练之后进行。像单次网络剪枝(SNIP)和梯度信号保留(GraSP)等方法会在任何训练开始之前,识别随机初始化网络中的重要连接。这些技术通过分析梯度流或连接敏感性来决定保留哪些参数。训练期间剪枝通过在训练过程中定期移除低幅值权重,同时在有潜力的位置添加新连接,从而使网络在整个训练过程中保持稀疏性。训练后剪枝作用于完全训练好的稠密网络,在微调以恢复精度之前,识别并移除最不重要的组件。由于这种传统方法可靠性高且实证表现优异,因此在实际应用中占据主导地位。我们鼓励读者阅读Frankle和Carbin在2019年发表的论文彩票假说(https://arxiv.org/abs/1803.03635)。该论文为以下观点提供了理论依据:稠密网络包含稀疏的“中奖彩票”子网络,当从适当的初始化开始训练时,这些子网络能够达到与完整网络相当的精度。
上述所有方法在剪枝过程中都涉及某种形式的训练。然而,还有一种训练后剪枝的变体,它不需要重新训练。其基本概念借鉴了线性代数。这类方法的例子包括SparseGPT和Wanda。SparseGPT利用逐层重构目标:对于每一层(l),最小化:
[
| W_l X - \hat{W}_l X |^2
]
其中(W_l)是原始权重矩阵,(\hat{W}_l)是剪枝后的版本,(X)表示校准激活。Wanda使用一种更简单的评分启发式方法来对权重进行排序。它通过权重与激活幅值的乘积来定义权重的重要性。然后,它会将重要性较低的权重置为零。
最后,这引导我们回答第三个也最具技术性的问题:如何决定剪掉什么。到目前为止,你可能已经猜到了一些方法。
- 基于幅值的剪枝
- 基于梯度的重要性剪枝
- 激活感知剪枝
顾名思义,基于幅值的剪枝是最简单的方法,即移除绝对值最小的权重。在基于梯度的剪枝中,我们近似估计剪掉每个权重所带来的损失变化。我们使用泰勒级数展开来近似移除权重时的损失变化。以下是移除权重时损失变化的一阶和二阶近似:
[
\Delta L \approx \left| \frac{\partial L}{\partial w} \cdot w \right| \quad \text{(一阶)}
]
[
\Delta L \approx \frac{1}{2} \left| \frac{\partial^2 L}{\partial w^2} \cdot w^2 \right| \quad \text{(二阶)}
]
我们在随附的GitHub仓库(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9)中提供了这些方法的C++实现,对应文件为magnitude_pruning.cpp、gradient_pruning.cpp和wanda_pruning.cpp。基于你目前对C++的掌握程度,你应该能够结合内联文档理解这些代码。
到目前为止,我们看到的都是非结构化剪枝,即通过将权重设为零来移除它们。我们看到了几种用于确定哪些权重应被置零、何时置零以及为何应剪掉某些权重的判据。接下来,我们将研究Transformer中的一些结构化剪枝方法。完整的Transformer剪枝方法可在GitHub(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/transformer_pruning_pipeline.cpp)中获取。该代码实现了一种三层次结构化剪枝策略,与Transformer的架构层次相对应:
流水线概览
- 层级别剪枝(最粗粒度): 根据Transformer块对模型输出的影响,识别并移除整个Transformer块。
- 头级别剪枝(中等粒度): 在剩余层中,移除冗余的注意力头。
- 神经元级别剪枝(最细粒度): 在剩余的前馈网络块中,移除单个中间神经元。
这种层级顺序至关重要:移除一个层会同时消除其所有的头和神经元,因此层级别剪枝必须先于更细粒度的方法进行。这种级联方法通过结构化稀疏性实现了大幅压缩(60-80%的稀疏度),同时保持了计算效率。组合后的流水线展示了这一序列:
// Step 1: Remove 1 entire layer
LayerPruner::prune_least_important_layer(*combined, data, combined_pruned);
// Step 2: Remove 4/8 heads in remaining layers
AttentionHeadPruner::prune_heads(*combined->layers->ptr<TransformerBlock>(l), 4);
// Step 3: Remove 50% FFN neurons in remaining layers
FFNPruner::prune_neurons(*combined->layers->ptr<TransformerBlock>(l), 0.5f);
该代码包含说明文档,你应该能够理解。我们还将讨论一种称为迭代剪枝的剪枝方法。该方法采用渐进式剪枝,并辅以中间微调。和之前一样,完整代码可在https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/iterative_pruning.cpp获取。
这段代码演示了一个完整的迭代剪枝流水线,它通过微调在逐步压缩神经网络的同时恢复精度。该实现遵循了一种结构化方法,与模型压缩中使用的生产级剪枝工作流相对应。
代码定义了一个简单的三层前馈网络(SimpleNet),使用ReLU激活函数,作为演示剪枝效果的基线模型。该网络通过一个64节点的隐藏层处理20维输入,以生成5类预测。它实现了以下步骤:
- 渐进式稀疏度计划(30% → 50% → 70% → 90%):每次迭代都会提高稀疏度,使模型能够逐步适应。
- 剪枝 → 评估 → 微调循环:在剪枝到每个目标稀疏度后,对模型进行评估以衡量精度的即时下降,然后以较小的学习率(1e-4)微调20个周期以恢复性能。
- 微调期间的梯度掩蔽:每次优化器步骤后关键的
reapply_masks()调用可确保被剪枝的连接保持失活——如果没有这一步,优化器会复活被置零的权重。
主函数首先训练一个稠密基线模型50个周期,确立精度目标。然后,迭代流水线系统地压缩模型,同时在每个稀疏度级别跟踪三个指标:剪枝后的即时精度(显示下降)、微调后的精度(显示恢复),以及“恢复”指标(恢复的百分点)。
这种方法在压缩比与精度保持之间取得平衡,说明神经网络包含大量冗余——代码显示,即使在90%稀疏度(移除90%的参数)下,通过精心剪枝和微调也能保持合理的性能。
在本节中,我们研究了各种基于剪枝的模型压缩技术,以及结构化剪枝和非结构化剪枝的子类型。我们还分享了一些用于Transformer剪枝和迭代剪枝策略的示例实现。
接下来,我们将研究另一种流行的模型压缩方法——量化。该技术基于模型权重的数值精度实现模型压缩,并且已成为每一位数据科学家工具包中的重要工具。
量化
量化通过将高精度浮点数(通常为32位)转换为低精度整数(通常为8位),减少了表示模型参数所需的比特数。这种转换利用了神经网络在数值表示上的冗余性,许多应用能够容忍精度降低而不会出现显著的性能下降。
其根本动机源于两个关键观察:在大多数硬件架构上,整数运算的执行速度明显快于浮点运算;降低数值精度可直接转化为更小的内存占用和更低的带宽需求。从32位浮点数转换为8位整数可实现4×的压缩比,将28 GB的模型缩减至约7 GB。
在本节中,我们将探讨量化技术的主要类别,并理解量化范围的概念以及范围选择策略。之后,我们将研究量化训练及其C++实现。让我们从两种量化技术开始:
非对称量化
非对称量化将浮点范围([β, α])映射到完整的整数范围,对于8位无符号整数,通常为([0, 255])。该变换使用两个参数:缩放因子((S))和零点((Z)),定义如下:
[
S = \frac{\alpha - \beta}{2^n - 1}
]
[
Z = \operatorname{round}\left(-\frac{\beta}{S}\right)
]
量化公式:
[
\text{quantized_value} = \operatorname{clamp}\left[\operatorname{round}\left(\frac{\text{float_value}}{S}\right) + Z,; 0,; 255\right]
]
clamp(钳位)函数通过将值“夹取”在最小值(0)和最大值(255)之间,来限制其不超过指定范围。反量化将上述操作反转,以恢复上述float_value(s):
[
\text{float_value} = S \times (\text{quantized_value} - Z)
]
这种方法通过充分利用整个量化范围(无论原始值是否以零为中心),能够有效处理非对称分布的数据。接下来,我们将研究另一种称为对称量化的方法。
对称量化
对称量化假设输入范围围绕零对称,将 [-α, α] 映射到 [-127, 127](8 位有符号整数)。这通过消除零点参数简化了量化过程。缩放系数计算公式:
S = α / (2^(n-1) - 1)
量化公式
quantized_value = clamp(round(float_value / S), -127, 127)
其主要优势在于计算效率,因为缺少零点参数减少了推理过程中的算术运算。然而,对于不对称分布的数据,它可能会引入更高的量化误差。这些量化方法的 C++ 实现可在 https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/basic_quantization_functions.cpp 获取。
在量化过程中,你可能会思考为什么我们分别选择 (0, 255) 和 (-127, 127) 作为非对称与对称量化的范围。接下来,我们将探讨不同的范围选择策略,这对量化质量有着重要影响。
范围选择策略
范围参数(α 和 β)的选择显著影响量化质量:
- 最小值-最大值策略将 α 设为张量中的最大值,β 设为最小值。虽然简单直接,但该方法对异常值高度敏感。单个极端值可能迫使量化范围过宽,从而导致大多数值的量化误差较高。
- 百分位策略使用百分位值(例如第 99.99 百分位)而非绝对极值来缓解异常值敏感性。这牺牲了异常值的精度,但显著降低了大部分数据分布的量化误差。
- 均方误差(MSE)策略搜索能够最小化原始值与反量化值之间 MSE 的 α 和 β 值。通常通过网格搜索实现,它为范围选择提供了原则性框架,但需要额外的计算开销。
- 交叉熵策略与具有 softmax 输出的语言模型尤为相关,它保留值的相对分布而非绝对幅度。这确保量化后最大值依然是最大值,从而维持模型输出的概率解释。
最后,我们将介绍何时应用量化的一些方法,包括训练后或训练过程中。
训练后量化(PTQ)
训练后量化在无需修改训练过程的情况下,对已完全训练好的模型应用量化。具体流程为:获取一个预训练的浮点模型,在小型校准数据集(未标注数据即可)上运行推理,使用观察器收集每一层的统计信息,基于收集的统计信息计算缩放系数和零点参数,然后将模型转换为其量化表示形式。
动态量化是 PTQ 的一种变体,它在推理过程中即时量化激活值,而非预先计算量化参数。当激活分布在不同输入之间差异显著时,这种方法尤为有用。
量化感知训练(QAT)
量化感知训练将量化模拟集成到训练过程中。该方法在训练期间在各层之间插入“伪量化”操作(先量化紧接着反量化)。这些操作在前向传播中模拟量化误差,同时允许梯度在反向传播中流动。
QAT 引导优化过程朝着损失景观中更宽的极小值方向发展。训练后量化可能使模型陷入狭窄的极小值,此时量化引起的小扰动会导致损失大幅增加。QAT 则鼓励模型找到更宽的极小值,使得量化引起的权重偏移对性能的影响最小化。
必须指出的是,通过量化操作的反向传播需要特殊处理,因为量化函数(舍入和截断)是不可微的。
我们想说明的是,我们仅触及了模型量化的皮毛,整个领域非常广阔,我们在本章末尾提供了一些进一步阅读的资源。
现在让我们聚焦于神经网络量化技术的实现。我们将首先在一个简单神经网络的背景下研究非对称和对称量化,然后实现 QAT。
神经网络量化的 C++ 实现
对于计算 Y = W × X + B 的典型线性层,量化过程如下:训练后一次性量化权重(W)并以量化格式存储,类似地量化偏置(B),推理过程中使用校准参数动态量化输入(X),利用硬件优化的整数算术执行整数矩阵乘法,将输出(Y)反量化回浮点形式以传递给下一层,并透明地传递至后续层。
现代 GPU 和专用加速器包含针对 8 位整数运算优化的乘累加模块。这些单元对量化矩阵中的 8 位整数进行乘法运算,并将结果累加在 32 位寄存器中以防止溢出,从而能够在矩阵元素间实现大规模并行化。想象一下将许多小数字相加,最终总和会大到无法放入小容器中。当两个 8 位数字相乘时,结果可能达到 16 位。如果持续累加这些乘积(矩阵乘法中会发生成千上万次),很快就会超过 16 位。32 位累加寄存器就像一个“更大的桶”,能够容纳不断增长的总和而不丢失信息。由于这些运算简单且统一(全部为 8 位整数运算),GPU 可以在矩阵的不同元素上同时执行数千次此类运算。一个低层 CUDA 代码可在 https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/int8_matmul_accumulate.cu 获取。
Transformer 的逐通道量化
逐通道量化为层中的每个输出通道或滤波器计算独立的量化参数(缩放系数和零点),而非对整个张量使用单一参数集。在神经网络中,不同通道往往学习到幅度范围差异巨大的特征。例如,在卷积层或 Transformer 的线性层中:
- 通道 1 的权重范围可能在 [-0.5, 0.5]
- 通道 2 的权重范围可能在 [-5.0, 5.0]
如果使用逐张量量化(整个权重矩阵使用一个缩放系数),就需要容纳最宽的范围([-5.0, 5.0])。这会浪费通道 1 的量化精度,因为其较小的值被压缩到更少的量化级别,从而增加误差。让我们看一个 C++ 中逐通道量化的实现。
void calibrate_linear_layer(const torch::Tensor& weight) {
int64_t num_channels = weight.size(0);
scales.resize(num_channels);
zero_points.resize(num_channels);
for (int64_t i = 0; i < num_channels; ++i) {
auto channel = weight[i];
float min_val = channel.min().item<float>();
float max_val = channel.max().item<float>();
scales[i] = (max_val - min_val) / 255.0f;
if (scales[i] == 0.0f) scales[i] = 1.0f; // avoid division by zero
zero_points[i] = static_cast<int>(-std::round(min_val / scales[i]));
}
}
calibrate_linear_layer 函数(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/per_channel_quanitzation_transformer.cpp)为权重矩阵中的每个输出通道(行)计算独立的量化参数。它从权重张量的第一维提取通道数,然后遍历每个通道,找出其最小值和最大值。对于每个通道,它通过将数值范围除以 255(8 位量化范围)来计算缩放因子,并计算零点偏移,以将最小值映射到量化空间中的零。该函数包含一个安全检查,当通道内所有权重相同时将缩放因子设为 1.0,以防止除零。
尽管该方法需要稍多的存储和计算,但精度上的提升远超这些微小缺点。这些方法在 Transformer 的注意力头和前馈网络层中得到了有效应用。
接下来,我们将研究 Transformer 的训练后量化,以及如何利用它加速推理。
C++ 中的训练后量化实现
PTQ 是另一种模型压缩方法,可在无需重新训练的情况下减少内存占用并加速推理。我们鼓励读者阅读完整的训练后实现(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/post_training_quantization_transformer.cpp)。这里,我们重点介绍 calibrate 和 quantize_model 两个函数。首先,让我们看看 calibrate 函数,以理解逐层量化是如何工作的:
void calibrate(std::shared_ptr<Transformer> model,
const torch::Tensor& calibration_data) {
torch::NoGradGuard no_grad;
// Run a forward pass so batch-norm / layer-norm stats are
// populated
model->forward(calibration_data);
for (const auto& named_param : model->named_parameters()) {
if (named_param.value().dim() == 2) {
AsymmetricQuantizer quantizer;
quantizer.calibrate(named_param.value());
layer_quantizers[named_param.key()] = quantizer;
}
}
calibrate函数通过分析Transformer层中的权重分布来建立量化参数,无需重新训练。使用校准数据的前向传播会填充归一化层的统计信息(批归一化/层归一化的运行平均值),确保这些层在量化前具有真实值。然后,该函数遍历模型参数,筛选出表示线性层权重的二维张量,同时排除偏置和归一化参数。对于每个权重矩阵,AsymmetricQuantizer计算最小-最大范围以确定最优的缩放因子和零点值,并将这些值存储在以层名作为键的映射(layer_quantizers)中,供模型转换为压缩表示时在量化阶段检索使用。
quantize_model函数应用校准后的量化参数,对Transformer模型进行原地压缩。
void quantize_model(std::shared_ptr<Transformer> model) {
torch::NoGradGuard no_grad;
for (auto& named_param : model->named_parameters()) {
auto it = layer_quantizers.find(named_param.key());
if (it != layer_quantizers.end()) {
auto& quantizer = it->second;
auto quantized = quantizer.quantize(named_param.value());
named_param.value().data().copy_(quantizer.dequantize(quantized));
}
}
}
该函数遍历所有模型参数,并尝试使用layer_quantizers.find(named_param.key())在校准期间创建的映射中找到对应的量化器。只有经过校准的参数(二维权重矩阵)会被处理,而没有校准量化器的参数(偏置、归一化参数)保持不变。
顾名思义,PTQ是在模型训练完成后实施的。不过,了解量化对训练的影响并看看能否进一步改进将是很有意义的。
接下来,我们将探讨QAT在C++中的实现。
QAT在C++中的实现
QAT在我们之前讨论的基础上,在训练过程中实现量化。如上文所有章节所述,请参阅相关GitHub仓库(https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9/qat_transformers.cpp)中的完整实现。该流程在训练期间使用fake_quantization来让模型学会处理精度损失:
torch::Tensor fake_quantize(const torch::Tensor& input, float scale, int zero_point) {
// Quantize: map float to integer grid
auto quantized = torch::clamp(
torch::round(input / scale) + zero_point, 0, 255
);
// Dequantize: map back to float (now restricted to quantization levels)
return scale * (quantized - zero_point);
}
fake_quantize函数实现了一种量化模拟,它在保持浮点精度的同时,将值限制为8位整数空间可表示的值。该函数实现了非对称量化-反量化公式。结果是得到一个浮点张量,其值仅限定为通过8位量化所能达到的值。它也被用于PTQ,以保持与现有FP32推理管道的兼容性,从而测试模型在量化精度损失下的表现。
接下来,QAT实现遵循train_with_qat函数中常规的训练循环。该函数在每次训练迭代中编排并模拟量化效果。对于每个批次,函数先将原始的全精度权重保存为主副本,然后根据当前值范围为每个二维权重张量校准量化参数(缩放因子和零点)。接着对所有权重应用伪量化,使前向传播在保持与标准浮点运算兼容的同时经历量化噪声。以下是训练循环中为每个层应用伪量化的代码片段:
{
torch::NoGradGuard no_grad;
for (const auto& name : linear_names) {
auto& w = params[name];
auto [scale, zp] = calibrate(w.data());
w.data().copy_(fake_quantize(w.data(), scale, zp));
}
}
在计算损失并反向传播梯度之后,函数在优化器更新之前恢复原始的FP32主权重。这个循环确保优化器始终更新全精度权重,同时模型学习对量化约束的鲁棒性,最终在训练完成后应用真正的训练后量化时实现最小精度损失。
至此,我们关于模型压缩量化技术的讨论就结束了。量化是一种强大且实用的模型压缩方法,能够在精度影响极小的情况下大幅减少内存占用和计算需求。训练后量化与量化感知训练之间的选择取决于具体的应用需求:PTQ提供简单性和速度,而QAT以重新训练为代价提供更优的精度保持。通过仔细选择量化类型、范围选择策略和粒度级别,您可以根据特定的部署约束定制量化方案,同时保持模型性能。还可以级联和组合不同的压缩技术,以在保持精度的同时实现更高的压缩比。
最优选择取决于部署约束:可用内存、延迟要求、目标硬件能力以及可接受的精度权衡。现代部署场景通常受益于组合多种技术,以在保持模型质量的同时实现激进压缩。
总结
本章带领你全面深入地探索了Transformer架构及其对现代人工智能的深远影响。我们首先考察了循环神经网络的根本局限性——其顺序处理瓶颈以及处理长距离依赖的困难——这些局限性促使了注意力机制的发展。自注意力机制作为一种优雅的解决方案应运而生,使模型能够通过并行计算直接捕捉序列中所有位置之间的关系。
我们探索了缩放点积注意力的数学基础,理解了查询、键和值如何协同工作以创建上下文感知的表示。多头注意力扩展了这一概念,使模型能够同时捕捉多样化的语言模式——从句法结构到语义关系。位置编码解决了将序列信息融入与位置无关的注意力机制这一关键挑战,而RoPE等现代方法则提供了对更长序列更优异的泛化能力。
在这些组件的基础上,我们使用LibTorch在C++中实现了完整的编码器和解码器架构。编码器通过堆叠的自注意力和前馈网络层处理输入序列,而解码器则使用掩码自注意力和交叉注意力机制自回归地生成输出序列。这些实现展示了残差连接和层归一化如何使深层网络的训练成为可能,同时保持梯度流动。
随后,本章考察了预训练语言模型,将BERT的双向编码器架构——针对理解任务优化——与GPT的自回归解码器设计——擅长文本生成——进行对比。这种区别凸显了架构选择如何与特定应用需求保持一致,从问答和分类到创意写作和对话。
随着模型规模扩展到数十亿参数,分布式训练变得至关重要。我们探讨了DDP训练,它跨GPU复制模型并并行处理数据,以及FSDP,它对模型参数、梯度和优化器状态进行分片,从而能够训练超出单GPU内存容量的模型。理解all-gather、reduce-scatter和all-reduce等通信原语对于实现高效的分布式训练流程被证明至关重要。
最后,我们通过压缩技术解决了部署大模型的实际挑战。知识蒸馏将大型教师模型学到的表示迁移到高效的学生模型中。剪枝在保持准确率的同时移除冗余连接。量化降低数值精度以减少内存占用并加速推理。低秩近似利用矩阵冗余来压缩权重矩阵。结合这些技术,可以在资源受限的设备上部署强大的模型。
本章涵盖的原理与实现,为你提供了构建、训练、优化和部署基于 Transformer 的模型所需的理论理解与实践技能。随着该领域持续发展——高效注意力机制、混合专家架构和多模态学习等创新不断涌现——这里探讨的基础概念对于理解并推动下一代 AI 系统仍将至关重要。
到目前为止,本书已经学习了针对不同用例(图像和文本)训练模型的方法。我们还了解了可应用于这些数据类型的预处理方式,并探索了包括 Transformer 在内的多种常用网络架构。在下一章中,我们将从训练模型转向在生产环境中提供服务。你将学习如何在 C++ 中导出、部署、优化和运行深度学习模型,以满足速度、可扩展性和可靠性方面的实际需求。
延伸阅读
如果你希望加深理解,以下资源提供了清晰的解释和有用的背景信息:
-
- Transformer 解释器:Transformer 架构的交互式可视化
https://poloclub.github.io/transformer-explainer/。
- Transformer 解释器:Transformer 架构的交互式可视化
-
- 图解 Transformer:理解 Transformer 的视觉指南
https://jalammar.github.io/illustrated-transformer/。
- 图解 Transformer:理解 Transformer 的视觉指南
-
- 可视化神经机器翻译:注意力机制可视化
http://jalammar.github.io/visualizing-neural-machine-translation-mechanics-of-seq2seq-models-with-attention/。
- 可视化神经机器翻译:注意力机制可视化
-
- 详解 Transformer:来自哈佛 NLP 的逐行实现指南
https://nlp.seas.harvard.edu/annotated-transformer/。
- 详解 Transformer:来自哈佛 NLP 的逐行实现指南
-
- Vaswani et al. (2017):注意力即一切:原始 Transformer 论文
https://arxiv.org/abs/1706.03762。
- Vaswani et al. (2017):注意力即一切:原始 Transformer 论文
-
- Su et al. (2021):RoFormer:通过旋转位置嵌入增强的 Transformer:RoPE 位置编码
https://arxiv.org/abs/2104.09864。
- Su et al. (2021):RoFormer:通过旋转位置嵌入增强的 Transformer:RoPE 位置编码
-
- Devlin et al. (2018):BERT:深度双向 Transformer 的预训练
https://arxiv.org/abs/1810.04805。
- Devlin et al. (2018):BERT:深度双向 Transformer 的预训练
-
- Radford et al. (2018):通过生成式预训练改进语言理解:GPT 原始论文
https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf。
- Radford et al. (2018):通过生成式预训练改进语言理解:GPT 原始论文
-
- Hinton et al. (2014):蒸馏神经网络中的知识:蒸馏基础论文
https://arxiv.org/abs/1503.02531。
- Hinton et al. (2014):蒸馏神经网络中的知识:蒸馏基础论文
-
- Frankle & Carbin (2019):彩票假说:寻找稀疏子网络
https://arxiv.org/abs/1803.03635。
- Frankle & Carbin (2019):彩票假说:寻找稀疏子网络
-
- Frantar & Alistarh (2023):SparseGPT:大规模语言模型可以一次性精确剪枝
https://arxiv.org/abs/2301.00774。
- Frantar & Alistarh (2023):SparseGPT:大规模语言模型可以一次性精确剪枝
-
- Sun et al. (2023):一种简单有效的大语言模型剪枝方法:Wanda 剪枝方法
https://arxiv.org/abs/2306.11695。
- Sun et al. (2023):一种简单有效的大语言模型剪枝方法:Wanda 剪枝方法
-
- Jacob et al. (2018):仅整数运算高效推理的神经网络量化与训练
https://arxiv.org/abs/1712.05877。
- Jacob et al. (2018):仅整数运算高效推理的神经网络量化与训练
-
- Dettmers et al. (2022):LLM.int8():大规模 Transformer 的 8 位矩阵乘法
https://arxiv.org/abs/2208.07339。
- Dettmers et al. (2022):LLM.int8():大规模 Transformer 的 8 位矩阵乘法
-
- NVIDIA Megatron 文档:CPU 卸载与内存优化
https://docs.nvidia.com/nemo/megatron-bridge/0.2.0/training/cpu-offloading.html。
- NVIDIA Megatron 文档:CPU 卸载与内存优化
-
- Rajbhandari et al. (2020):ZeRO:面向训练万亿参数模型的内存优化:FSDP 基础
https://arxiv.org/abs/1910.02054。
- Rajbhandari et al. (2020):ZeRO:面向训练万亿参数模型的内存优化:FSDP 基础
-
- Li et al. (2021):PyTorch 分布式:加速数据并行训练的经验
https://arxiv.org/abs/2006.15704。
- Li et al. (2021):PyTorch 分布式:加速数据并行训练的经验
-
- Transformer 神经网络播放列表:关于 Transformer 的全面视频系列
https://www.youtube.com/playlist?list=PL4bm2lr9UVG0HvePBXvsceO4yuLC8HhUh。
- Transformer 神经网络播放列表:关于 Transformer 的全面视频系列
-
- Press et al. (2021):短训练,长测试:带线性偏置的注意力实现输入长度外推:ALiBi 位置编码
https://arxiv.org/abs/2108.12409。
- Press et al. (2021):短训练,长测试:带线性偏置的注意力实现输入长度外推:ALiBi 位置编码
-
- Raffel et al. (2020):探索统一文本到文本 Transformer 迁移学习的极限:T5 与相对位置偏置
https://arxiv.org/abs/1910.10683。
- Raffel et al. (2020):探索统一文本到文本 Transformer 迁移学习的极限:T5 与相对位置偏置
-
- Tay et al. (2020):高效 Transformer:综述:效率技术的全面综述
https://arxiv.org/abs/2009.06732。
- Tay et al. (2020):高效 Transformer:综述:效率技术的全面综述
-
- Dao et al. (2022):FlashAttention:具有 IO 感知的快速且内存高效的精确注意力
https://arxiv.org/abs/2205.14135。
- Dao et al. (2022):FlashAttention:具有 IO 感知的快速且内存高效的精确注意力
-
- Hugging Face Transformers 文档:实用实现指南
https://huggingface.co/docs/transformers/。
- Hugging Face Transformers 文档:实用实现指南
-
- PyTorch Transformer 教程:PyTorch 官方指南
https://pytorch.org/tutorials/beginner/transformer_tutorial.html。
- PyTorch Transformer 教程:PyTorch 官方指南
-
- LibTorch C++ 文档:C++ API 参考
https://pytorch.org/cppdocs/。
- LibTorch C++ 文档:C++ API 参考
-
- Papers with Code - Transformers:最新研究与基准
https://paperswithcode.com/methods/category/transformers。
- Papers with Code - Transformers:最新研究与基准
-
- 本章代码:
https://github.com/PacktPublishing/Deep-Learning-with-C-/blob/main/chapter9。
- 本章代码:
获取本书 PDF 版本及更多内容
扫描二维码(或访问 packtpub.com/unlock)。按名称搜索本书,确认版本,然后按照页面上的步骤操作。
https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_402_0.png
https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_402_1.png
注意:请保管好您的发票。直接从 Packt 购买的订单无需发票。
第三部分:在生产环境中部署、监控和解释深度学习系统
在本书的最后一部分,你将专注于 C++ 中深度学习的运维层面。我们将介绍如何导出和部署模型以进行推理,针对生产性能优化模型,调试故障,随着数据变化重新训练模型,并长期监控质量与系统健康。你还将学习如何通过可解释性技术让模型行为更加透明,这些技术在关键领域尤为重要。完成本部分后,你将能够在生产环境中运行具有更强性能、可靠性和可解释性的深度学习系统。
本书的这一部分包括以下章节:
- 第 10 章,部署和优化推理模型
- 第 11 章,调试和重新训练已部署的模型
- 第 12 章,监控已部署的模型
- 第 13 章,深度学习模型中的可解释性与透明度
10. 部署和优化推理模型
你训练了一个在离线状态下表现出色的模型。现在困难的部分来了:将其交付上线——并在使用量增长时保持其快速、可靠且经济高效。本章讨论真实世界中的推理:将检查点转换成一个可预测的 C++ 服务,满足严格的延迟/吞吐量服务水平目标(SLOs),能够干净地扩展,并长期保持健康。
这里的“推理”是指运行训练好的模型来产生预测;“服务”则是将该计算暴露在 API 或流之后,同时管理版本、扩展和可观测性。我们将介绍你可能会遇到的常见模式:在线/实时(低延迟请求)、流式(例如,LLM 的 token 输出)、批量(吞吐优先的任务)和边缘/端侧(隐私/延迟)。在生产中重要的是 SLO:延迟(p50/p95/p99;首字节/首 token 时间)、吞吐量(QPS/每秒 token 数)、可用性、每请求成本和质量(准确性/安全性)。要达到这些目标,需要消除端到端的瓶颈——预处理/后处理、模型计算、CPU↔GPU 传输、框架/内核开销、网络/序列化、冷启动以及排队/批处理。
在本章中,你将学习如何将训练好的模型转变为 C++ 中可用于生产的推理系统。我们首先将模型导出为便于部署的格式,如 TorchScript 和 ONNX,验证数值一致性,并在 C++ 运行时中加载产物。接下来,我们将探讨如何将模型部署到真实环境中——云、本地基础设施或边缘设备——同时介绍打包策略、微批处理和并发控制。
接着,我们重点讨论性能和成本优化技术,包括量化、剪枝、蒸馏,以及对精度、布局和线程数的运行时调优。之后,我们使用C++结合HTTP或gRPC构建实时服务端点,支持流式响应、批处理和背压机制,以维持低尾部延迟。最后,我们涵盖运维实践,例如指标、日志和追踪的埋点与采集,数据漂移检测,通过金丝雀发布和回滚执行安全部署,以及维护再训练闭环以保持模型长期准确性。
请牢记这个心智模型:定义SLO | 导出与验证 | 优化 | 服务 | 观测 | 迭代。服务是模型与真实世界交汇的环节;我们将使其快速、可靠且可度量。
一个典型的生产工作流从定义SLO开始,经历导出已训练模型、验证数值一致性、部署产物、优化运行时性能、通过API提供预测服务,并随着数据和需求演变持续观测与再训练系统。
在本章中,我们将讨论以下主题:
- 为C++推理导出模型
- 生成TorchScript产物(从C++)
- 一致性:产物是否与原生计算图匹配
- 将模型部署到真实目标环境
- 为速度和成本优化模型
- 实时提供模型服务
- 运维并演进生产系统
学完本章,你将能够将训练好的模型导出为TorchScript和ONNX格式,并使用HTTP或gRPC构建满足具体SLO的C++推理服务。你还将学习如何通过量化、剪枝和蒸馏等技术降低延迟和成本,以及如何使用指标、日志、追踪、金丝雀发布和漂移监控来维护系统健康。我们将从锁定导出格式和一致性测试开始,接着进行打包和部署,应用模型/运行时优化,构建实时服务器,最后讨论云、本地和边缘环境下的监控与持续更新。
为C++推理导出模型
训练产物(检查点文件、优化器状态)非常适合研究循环,但生产环境需要一种稳定、自包含的模型表示。在C++中,我们关注两种这样的表示:
- TorchScript (
.ts):一个序列化的PyTorch模块,可以直接使用LibTorch加载。它完整保留了PyTorch语义,是纯C++技术栈最直接的路径。 - ONNX (
.onnx):一种由ONNX Runtime(以及TensorRT、OpenVINO等加速器)消费的交换格式。当你需要跨运行时和硬件的可移植性时,它是理想选择。
训练检查点包含优化器状态和研究元数据,而生产系统需要诸如TorchScript或ONNX等稳定产物,这些产物可以被C++运行时直接加载。
在本节中,我们将使用PyTorch C++前端定义一个微型模型,从C++导出TorchScript(不涉及Python),在C++中加载并运行该产物,并可选择从C++使用ONNX以进行一致性和可移植性验证。在此过程中,我们将引入一个简洁的、纯C++的一致性检查,以便你确信导出的产物与原生前向传播匹配。
在C++前端中定义一个紧凑模型
我们将使用一个小型卷积神经网络(CNN)作为贯穿示例。该架构刻意保持简单——两个卷积层后接全局平均池化和一个线性分类器——但它抓住了生产系统中真实推理图的典型结构。以下头文件使用LibTorch C++前端定义了该模型。
c++ tinynet.h:一个用LibTorch实现的微型CNN:
#pragma once
#include <torch/torch.h>
// A tiny CNN: Conv-ReLU-Conv-ReLU -> GlobalAvgPool -> Linear
struct TinyNetImpl : torch::nn::Module {
torch::nn::Conv2d c1{nullptr}, c2{nullptr};
torch::nn::Linear fc{nullptr};
TinyNetImpl(int in_ch=3, int num_classes=10)
: c1(torch::nn::Conv2dOptions(in_ch, 8, 3).padding(1)),
c2(torch::nn::Conv2dOptions(8, 16, 3).padding(1)),
fc(16, num_classes) {
register_module("c1", c1);
register_module("c2", c2);
register_module("fc", fc);
}
torch::Tensor forward(torch::Tensor x) {
x = torch::relu(c1->forward(x));
x = torch::relu(c2->forward(x));
x = torch::adaptive_avg_pool2d(x, {1,1}); // [N,16,1,1]
x = x.view({x.size(0), 16}); // [N,16]
return fc->forward(x); // [N,C]
}
};
TORCH_MODULE(TinyNet);
图 10.3 展示了我们使用上述代码实现的TinyNet架构。
TinyNet架构由两个卷积层后接全局平均池化和一个线性分类器组成。尽管简单,但该结构捕捉了推理系统中使用的卷积神经网络(CNN)的典型流程。
模型定义完成后,下一步是将其转换为一个可直接加载并在生产推理服务中执行的、可部署的产物。
生成TorchScript产物(从C++)
导出是一次性操作,可在CI中运行。我们切换到eval()模式,创建一个示例输入,并追踪该模块以生成tinynet.ts。
C++ trace_and_save_ts.cpp:追踪并序列化:
#include <torch/script.h>
#include <iostream>
#include <unordered_map>
#include "tinynet.h"
int main() {
torch::NoGradGuard ng;
TinyNet model(/*in_ch=*/3, /*num_classes=*/10);
model->eval();
// If you trained with LibTorch, you can restore weights here:
// torch::load(model, "tinynet_state.pt");
// Example input for tracing (matches your deployment contract)
auto example = torch::randn({1,3,224,224});
// Trace the module's "forward" from C++
std::unordered_map<std::string, std::vector<torch::jit::IValue>> inputs;
inputs["forward"] = {example};
auto traced = torch::jit::trace_module(*model, inputs);
traced.save("tinynet.ts");
std::cout << "Saved TorchScript artifact: tinynet.ts\n";
return 0;
}
构建说明(仅一次): 链接LibTorch的头文件和库文件;对于GPU构建,请链接 -ltorch_cuda -lc10_cuda。确保编译器/标准库版本与你的LibTorch发行版保持一致。
产物生成后,下一步是验证它能在C++推理程序中被正确加载和执行。
c++ infer_torchscript.cpp:加载并推理:
#include <torch/script.h>
#include <iostream>
int main() {
torch::NoGradGuard ng;
torch::jit::script::Module m = torch::jit::load("tinynet.ts");
m.eval();
auto x = torch::randn({1,3,224,224}, torch::kFloat);
if (torch::cuda::is_available()) {
m.to(torch::kCUDA);
x = x.to(torch::kCUDA).to(torch::MemoryFormat::ChannelsLast);
}
// Warm-up passes to remove cold-start effects
for (int i = 0; i < 5; ++i) (void)m.forward({x});
auto y = m.forward({x}).toTensor();
std::cout << "TorchScript output shape: " << y.sizes() << "\n";
return 0;
}
TorchScript产物成功加载并执行后,下一步是验证其行为是否与原始LibTorch模型一致。这一步验证(通常称为一致性检查)可确保序列化后的产物在部署到生产推理服务之前产生数值上一致的输出。
一致性:产物是否与原生计算图匹配?
在发布之前,请确认原生LibTorch前向传播与TorchScript前向传播在代表性输入上数值一致。对于FP32模型,最大绝对差在1e-4到1e-6量级是典型的;更大的差异几乎总是可追溯到前后处理不匹配、形状/数据类型漂移或不支持的操作。
c++ parity_check.cpp:原生 vs TorchScript:
#include <torch/script.h>
#include <iostream>
#include <vector>
#include <cmath>
#include "tinynet.h"
static float max_abs_diff(const std::vector<float>& a, const std::vector<float>& b);
std::vector<float>& b) {
float m = 0.f;
for (size_t i = 0; i < a.size(); ++i) m = std::max(m, std::fabs(a[i] - b[i]));
return m;
}
int main() {
torch::NoGradGuard ng;
// Native LibTorch forward
TinyNet model(3, 10);
model->eval();
auto x = torch::randn({2,3,224,224});
auto ref = model->forward(x).contiguous();
std::vector<float> ref_v(ref.data_ptr<float>(), ref.data_ptr<float>() + ref.numel());
// TorchScript forward
auto ts = torch::jit::load("tinynet.ts");
ts.eval();
auto ts_out = ts.forward({x}).toTensor().contiguous();
std::vector<float> ts_v(ts_out.data_ptr<float>(), ts_out.data_ptr<float>() + ts_out.numel());
std::cout << "max|native - ts| = " << max_abs_diff(ref_v, ts_v) << "\n";
return 0;
}
图 10.4:原生模型与序列化模型之间的数值一致性验证
在部署之前,导出的产物应产生与原始模型实现一致的输出。一致性检查使用相同的输入比较两条执行路径的预测结果。
如果一致性检查失败,不要猜测——记录输入张量的元数据(形状、dtype、内存格式、归一化范围),并仔细比较训练路径与服务路径之间的前后处理步骤。
从 C++ 使用 ONNX 以提升可移植性
虽然 ONNX 的导出通常在 CI 阶段用 Python 完成,但 ONNX 的服务化在 C++ 中借助 ONNX Runtime 非常直接。下面的加载器展示了你的 C++ 服务如何运行预先生成的 tinynet.onnx,并且可以与 TorchScript 并排使用以进行 A/B 一致性验证。
#include <onnxruntime_cxx_api.h>
#include <iostream>
#include <vector>
#include <array>
int main() {
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "onnxrt");
Ort::SessionOptions opt;
opt.SetIntraOpNumThreads(4); // tune for your CPU
Ort::Session sess(env, "tinynet.onnx", opt); // assumes artifact exists
std::array<int64_t,4> shape{1,3,224,224};
std::vector<float> data(1*3*224*224, 0.5f);
Ort::MemoryInfo mem = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
Ort::Value input = Ort::Value::CreateTensor<float>(mem, data.data(), data.size(), shape.data(), shape.size());
Ort::AllocatorWithDefaultOptions alloc;
const char* in_name = sess.GetInputName(0, alloc);
const char* out_name = sess.GetOutputName(0, alloc);
auto outs = sess.Run(Ort::RunOptions{}, &in_name, &input, 1, &out_name, 1);
auto& y = outs.front();
float* yptr = y.GetTensorMutableData<float>();
std::cout << "ONNX Runtime ran; first logit = " << yptr[0] << "\n";
return 0;
}
对于 GPU 目标,请使用带有 CUDA 或 TensorRT 执行提供程序的 ONNX Runtime 构建,并在构造会话时传入提供程序选项。
需要保持一致的事项
在部署模型之前,重要的是在训练、导出和服务环境中保持若干运行时假设的一致性。应仔细控制以下方面,以避免隐蔽的错误和性能回退:
- I/O 契约: 锁定形状、dtype、布局和归一化。大多数“一致性错误”出在这里,而不是在网络结构中。
- 精度与布局: 如果之后为了速度启用 FP16/BF16 或 channels-last,请对 FP32 基线重新运行一致性检查。
- 预热: 服务启动时运行几次前向传播,以避免冷延迟(cuDNN 启发式算法、内核 JIT)。
- 线程设置: 根据部署的核数调整
at::set_num_threads()(LibTorch CPU)和SetIntraOpNumThreads()(ONNX Runtime)。
现在你拥有了数值上一致的导出产物和 C++ 加载器——这是下一节我们将继续构建的基础。在下一节中,我们将模型打包成一个精简的 C++ 服务,并部署到云端、本地或边缘,同时根据你的延迟 SLO 调整微批处理和并发度。
在确立了导出格式和运行时一致性之后,我们现在可以专注于如何打包模型并部署为真实环境中可靠的推理服务。
将模型部署到真实目标环境
生产环境推理是一个披着模型问题的系统问题。你导出的产物只有在被包装成可预测的服务后才有用:该服务需要处理突发流量、为加速器合并计算工作,并保护尾部延迟。无论你运行在云端、数据中心机架还是边缘设备上,相同的架构模式总会出现:
- 一个加载模型并完成预热的小型 C++ 二进制程序,
- 一个请求前端(进程边缘的 HTTP/gRPC),以及
- 一个将大量小请求转化为高效微批次、同时强制实施并发限制和背压的调度器。
下面,我们用 C++ 构建这个调度器,并展示它如何插入到 API 层和模型之间。在下一节中,你将添加 HTTP/gRPC 外壳;这里我们专注于云端/本地/边缘都适用的核心机制。
打包二进制程序(你要交付的那一个)
从部署的角度来看,你会希望有一个完全自包含、没有任何意外的单一可执行文件。以下要点概述了实现这一目标的关键步骤:
- 嵌入契约:输入形状/dtype/布局、归一化常量以及任何类别映射。
- 就近放置产物:将
tinynet.ts(TorchScript)或model.onnx与二进制程序放在一起,而不是放在慢速网络路径上。 - 预热:启动时运行几次前向传播以消除冷延迟。
- 固定并限制:显式设置 CPU 线程数限制、GPU 设备和内存格式(例如 channels-last)。
容器化(例如使用包含 LibTorch/ONNX Runtime 的最小基础镜像)在云端/本地部署中很常见。在边缘,你可能会交付一个带产物文件夹的静态二进制程序。打包问题解决后,下一个挑战是管理实时流量。在生产系统中,推理请求往往逐个到达,但 GPU 和向量化 CPU 在处理批次时性能最佳。下面的示例展示了一个紧凑的 C++ 微批处理调度器,它收集请求、组成小批次、每批执行一次推理,并将结果返回给各个调用方。
服务的核心:带并发控制的微批处理器
真实流量以单个样本的流形式到达。GPU(甚至向量化 CPU)最喜欢批量处理。微批处理器会短暂等待(例如最多 8 毫秒)以收集少量请求,将它们堆叠起来,执行一次推理,然后将每个切片的结果返回给调用方。由于窗口很小,p95/p99 保持平稳;吞吐量大幅提升。
图 10.5:微批处理期间请求到达的时间线
在短暂批处理窗口内到达的请求会被分组成一个批次。当达到最大批次大小或批处理延迟到期时,调度器会刷新该批次。
我们将实现的设计选择如下:
- 有界队列 | 当服务饱和时提供背压(返回“忙碌”而不是永远排队)。
- 延迟或大小触发 | 当收集到 max_batch 个条目或窗口定时器到期时刷新批次。
- 单工作线程起步;根据 VRAM 余量扩展到 N 个工作线程或多个模型实例。
- 使用 Futures 将结果返回给调用线程(即你的 API 处理器)。
c++ micro_batcher_demo.cpp(自包含调度器 + LibTorch 推理):
包含指令和辅助别名:这些头文件提供了批处理和并发请求处理所需的 LibTorch 运行时、同步原语、futures/promises 以及标准容器。
#include <torch/script.h>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <future>
第10章 部署与优化推理模型
#include <iostream>
#include <mutex>
#include <optional>
#include <thread>
#include <vector>
using Clock = std::chrono::steady_clock;
using ms = std::chrono::milliseconds;
定义微批处理器接口:我们首先定义一个小的结构体来表示一次推理请求。每个任务包含一个预处理后的输入张量和一个用于异步返回对应输出的 promise。
struct InferenceTask {
torch::Tensor sampleCHW; // [C,H,W] float, preprocessed
std::promise<torch::Tensor> promise; // fulfills with [C_out] or one row slice
};
定义微批处理器接口:接下来,我们定义调度器的公共接口。请求通过 submit() 提交,该方法要么将任务加入队列,要么在有界队列已满时立即拒绝该任务。
class MicroBatcher {
public:
MicroBatcher(torch::jit::script::Module& module,
int maxBatch, int maxDelayMs, size_t queueCap)
: mod_(module), maxBatch_(maxBatch),
maxDelay_(maxDelayMs), cap_(queueCap), stop_(false) {
worker_ = std::thread(&MicroBatcher::run, this);
}
~MicroBatcher() { shutdown(); }
// Submit one request; returns a future to the result tensor (one row of logits)
std::future<torch::Tensor> submit(torch::Tensor chw) {
std::promise<torch::Tensor> p;
auto fut = p.get_future();
{
std::unique_lock<std::mutex> lk(mu_);
if (queue_.size() >= cap_) {
// back-pressure: reject quickly instead of inflating tail latency
p.set_exception(std::make_exception_ptr(std::runtime_error("server busy")));
return fut;
}
queue_.push_back(InferenceTask{std::move(chw), std::move(p)});
}
cv_.notify_one();
return fut;
}
void shutdown() {
bool expected = false;
if (stop_.compare_exchange_strong(expected, true)) {
cv_.notify_all();
if (worker_.joinable()) worker_.join();
}
}
展示批处理工作线程逻辑:工作线程实现了核心的批处理逻辑。它等待请求,开启一个短暂的批处理窗口,将多个任务聚合成单个张量,执行一次前向传播,然后将每个输出行路由回对应的调用方。
private:
void run() {
torch::NoGradGuard ng;
while (!stop_) {
std::vector<InferenceTask> batch;
batch.reserve(maxBatch_);
{
std::unique_lock<std::mutex> lk(mu_);
cv_.wait(lk, [&]{ return stop_ || !queue_.empty(); });
if (stop_) break;
// start window
auto t0 = Clock::now();
// pull first item
if (!queue_.empty()) {
batch.push_back(std::move(queue_.front()));
queue_.pop_front();
}
// accumulate until full or delay exceeded
while (batch.size() < (size_t)maxBatch_) {
auto elapsed = std::chrono::duration_cast<ms>(Clock::now() - t0).count();
if (elapsed >= maxDelay_) break;
if (queue_.empty()) {
// brief wait to accumulate more within window
cv_.wait_for(lk, ms(maxDelay_ - (int)elapsed), [&]{ return stop_ || !queue_.empty(); });
if (stop_) break;
if (queue_.empty()) break;
}
batch.push_back(std::move(queue_.front()));
queue_.pop_front();
}
}
if (batch.empty()) continue;
// Build [N,C,H,W] batch
std::vector<torch::Tensor> rows;
rows.reserve(batch.size());
for (auto& t : batch) rows.push_back(t.sampleCHW.unsqueeze(0)); // [1,C,H,W]
auto x = torch::cat(rows, /*dim=*/0); // [N,C,H,W]
// Device/format knobs
if (torch::cuda::is_available()) {
x = x.to(torch::kCUDA).to(torch::MemoryFormat::ChannelsLast);
mod_.to(torch::kCUDA);
}
// First few iterations may be a bit slower; consider warm-up at process start
torch::Tensor y = mod_.forward({x}).toTensor().contiguous(); // [N,classes]
// Split and fulfill promises
for (size_t i = 0; i < batch.size(); ++i) {
torch::Tensor row = y.index({(int64_t)i}).cpu(); // per-request slice
batch[i].promise.set_value(row);
}
}
}
torch::jit::script::Module& mod_;
const int maxBatch_;
const int maxDelay_; // ms window
const size_t cap_;
std::mutex mu_;
std::condition_variable cv_;
std::deque<InferenceTask> queue_;
std::atomic<bool> stop_;
std::thread worker_;
};
展示演示程序 main():最后,以下驱动程序加载 TorchScript 模型,构建微批处理器,并模拟多个生产者线程并发提交请求。
// --- Demo main: loads TorchScript and simulates concurrent callers ---
int main() {
try {
torch::jit::script::Module net = torch::jit::load("tinynet.ts");
net.eval();
MicroBatcher mb(net, /*maxBatch=*/16, /*maxDelayMs=*/8, /*queueCap=*/256);
// Simulate N producer threads submitting work
const int producers = 4;
std::vector<std::thread> threads;
for (int p = 0; p < producers; ++p) {
threads.emplace_back([&mb, p]{
for (int i = 0; i < 8; ++i) {
// Pretend this came from your HTTP/gRPC handler (already preprocessed)
auto chw = torch::randn({3,224,224}, torch::kFloat);
auto fut = mb.submit(chw);
try {
auto out = fut.get(); // [classes]
if (p == 0) std::cout << "producer " << p << " logit[0]=" << out[0].item<float>() << "\n";
} catch (const std::exception& e) {
std::cerr << "submit failed: " << e.what() << "\n";
}
std::this_thread::sleep_for(ms(2));
}
});
}
for (auto& t : threads) t.join();
mb.shutdown();
} catch (const std::exception& e) {
std::cerr << "fatal: " << e.what() << "\n";
return 1;
}
return 0;
}
上面的示例展示了调度器的核心机制。在生产系统中,该组件位于 API 层和模型运行时之间。
如何映射到真实服务:
你的 HTTP/gRPC 处理器会将载荷预处理为约定形状/数据类型/布局的 torch::Tensor,然后调用 submit()。微批处理器负责合并请求并将结果路由返回。仅 8 ms 的窗口足以吸收突发流量而不会损害 p95;16 条的上限可防止产生耗尽内存或拉高尾延迟的“超大批次”。
照常编译(无需 CMake):
export TORCH=/path/to/libtorch
clang++ -std=c++17 -O2 micro_batcher_demo.cpp \
-I"$TORCH/include" -I"$TORCH/include/torch/csrc/api/include" \
-L"$TORCH/lib" -Wl,-rpath,"$TORCH/lib" \
-ltorch -lc10 -pthread -o mb_demo
./mb_demo
如果你使用 CUDA 构建了 LibTorch,请添加 -ltorch_cuda -lc10_cuda,并确保驱动/工具链正确。
并发控制(保证 p95/p99 真实可靠)
- 有界队列:当队列满时,快速拒绝(HTTP 429)或向客户端发送“忙碌”信号,而不是让队列无限制增长。
- 微批窗口:小而固定(例如 2–8 ms)。较大的窗口有助于提高吞吐量,但会损害尾延迟。
- 每设备实例数:最初每张 GPU 运行一个模型实例;只有当显存和流允许且不会导致抖动时,才添加第二个实例。
- 线程配置:使 CPU 线程数与核心数匹配(不要过度订阅),并将 I/O 线程与推理线程分离。
- 热路径:通过运行预热和定期保活,让热内核、分配器和算法启发式策略保持“热度”。
在支持多租户的 GPU 上(例如支持 MIG 的 A100/H100),你可能更倾向于每个 MIG 切片一个实例,以实现强隔离。在边缘 CPU 上,调高 ONNX Runtime(ORT)中的 SetIntraOpNumThreads() 或 LibTorch 中的 at::set_num_threads(),并保持较小的批次以减少抖动。
云、本地部署与边缘:实际有什么区别?
虽然核心推理代码通常保持不变,但部署环境会影响你打包、扩缩容和调优服务的方式。以下考虑因素突出了云、本地部署和边缘部署中优先级通常如何不同:
- 云:容器镜像 + 产物包、自动扩缩容,以及将指标发送到可观测性技术栈。GPU 节点在突发流量下受益于动态批处理。
- 本地部署:硬件稳定、变更窗口更严格。针对持续吞吐量进行调优;你通常可以承受稍大一些的微批次。
- 边缘:内存/计算预算紧张,网络连接具有间歇性。优先选择 TorchScript CPU 或 ONNX Runtime CPU/NNAPI;微批次大小可能为 1–4,窗口为 0–2 ms,优先保证响应性而非吞吐量。
第10章:部署和优化模型以进行推理
图10.6:云、本地和边缘部署之间的差异
尽管推理代码通常保持不变,但部署环境对硬件资源、扩展行为和延迟要求施加了不同的约束。
在所有情况下,微批处理 + 并发核心保持不变。您添加的“外壳”——HTTP/gRPC处理器、传输层安全(TLS)、身份验证——属于边界部分,不应渗透到调度逻辑中。
现在您拥有了一个可部署的C++核心,可以将大量小请求转化为高效、有界延迟的推理。在下一节中,我们将使用实时服务器(HTTP/gRPC)包装这个核心,在网络边界添加超时和背压,并接入指标,以便您在生产环境中监控p50/p95/p99、利用率和队列深度。
优化模型以提高速度和降低成本
推理性能取决于两个方面:您交付的模型和运行方式。在模型方面,我们可以移除或压缩工作(量化、剪枝、蒸馏)。在运行时方面,我们可以高效地供给硬件(精度、布局、线程、批处理、CUDA图)。本节重点介绍提高推理效率的实用C++技术。在以下小节中,您将学习如何部署更轻量的模型工件、应用运行时优化,以及调整批处理、精度和系统设置,以在保持数值正确性的同时改善延迟和吞吐量。
图10.7:常见推理优化技术之间的权衡
量化、剪枝、蒸馏和运行时调整对延迟、吞吐量、内存使用和准确性的影响各不相同。生产系统通常结合多种技术来平衡性能与模型质量。
量化:更少的位,更高的吞吐量
量化用较低精度(FP16/BF16或INT8)替换浮点运算和存储。您可以通过两种方式获益:更小的权重/激活(更少的带宽)以及针对这些精度优化的硬件上更快的内核。
FP16混合精度(TorchScript、CUDA)
在NVIDIA GPU上,以FP16运行CNN的大部分计算可以降低延迟并提高吞吐量。在C++中,您可以将模块和输入都转换为半精度;在消费端将数值敏感的步骤(如softmax、归约)保留为FP32。
在C++中使用FP16进行TorchScript推理(CNN采用channels-last)
以下示例演示了如何在FP16下运行TorchScript推理,同时使用channels-last内存格式,这可以提高CNN在现代GPU上的性能。
#include <torch/script.h>
#include <iostream>
#include <chrono>
using clk = std::chrono::steady_clock;
int main() {
torch::NoGradGuard ng;
auto m = torch::jit::load("tinynet.ts");
m.eval();
if (!torch::cuda::is_available()) {
std::cerr << "GPU required for FP16 speedups.\n";
return 1;
}
// Move model & example input to GPU + half + NHWC (channels-last) for CNN perf
m.to(torch::kCUDA);
auto x = torch::randn({8,3,224,224}).to(torch::kCUDA).to(torch::kHalf);
x = x.to(torch::MemoryFormat::ChannelsLast);
// Warm-up
for (int i=0;i<10;++i) (void)m.forward({x});
// Timed run
auto t0 = clk::now();
auto y = m.forward({x}).toTensor(); // y is FP16 logits
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(clk::now()-t0).count();
// If you need FP32 downstream (e.g., for softmax), upcast once here
auto y32 = y.to(torch::kFloat);
std::cout << "batch=8 elapsed(ms): " << ms << " first=" << y32[0][0].item<float>() << "\n";
return 0;
}
注意: FP16推理在GPU上效果最佳,通常应避免在CPU上使用,因为半精度在CPU上往往收效甚微,甚至可能降低性能。对于卷积密集的模型,将FP16与channels-last内存布局结合可以进一步提高吞吐量。在启用降低精度或更改内存布局后,始终重新运行数值一致性测试,以确认优化后的配置仍能产生可接受的结果。
INT8(ONNX Runtime,Q/DQ图)
INT8通常在CPU和基于TensorRT的GPU上能带来最大的速度和内存收益。在实践中,量化的ONNX模型使用Q/DQ节点,因此您的C++服务可以继续传递浮点输入;量化和反量化已嵌入图中。
在C++中运行量化的ONNX模型
下一个示例展示了如何使用ONNX Runtime在C++中加载并运行量化的ONNX模型。它设置运行时、创建会话、准备输入并执行推理。
#include <onnxruntime_cxx_api.h>
#include <iostream>
#include <vector>
#include <array>
int main() {
Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "int8");
Ort::SessionOptions opt;
opt.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
opt.SetIntraOpNumThreads(8);
// If built with CUDA/TensorRT EPs, append providers here for GPU INT8
// OrtSessionOptionsAppendExecutionProvider_CUDA(opt, device_id);
Ort::Session sess(env, "model.int8.onnx", opt);
// Float input (Q/DQ inside graph)
std::array<int64_t,4> shape{16,3,224,224};
std::vector<float> data(16*3*224*224, 0.5f);
auto mem = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);
auto inp = Ort::Value::CreateTensor<float>(mem, data.data(), data.size(), shape.data(), shape.size());
Ort::AllocatorWithDefaultOptions a;
const char* in_name = sess.GetInputName(0, a);
const char* out_name = sess.GetOutputName(0, a);
// Warm-up
for (int i=0;i<5;++i) (void)sess.Run(Ort::RunOptions{}, &in_name, &inp, 1, &out_name, 1);
auto outs = sess.Run(Ort::RunOptions{}, &in_name, &inp, 1, &out_name, 1);
auto& y = outs.front();
std::cout << "ran INT8 graph; first=" << y.GetTensorMutableData<float>()[0] << "\n";
return 0;
}
INT8最有效的情况:INT8量化通常对以矩阵乘法和卷积运算为主的模型收益最大。当与oneDNN等优化库结合用于大规模CPU推理时,效果尤为显著,因为较低精度可以大幅提高吞吐量。在GPU上,INT8通常与TensorRT结合使用,通过校准或量化感知训练(QAT)在保持可接受准确性的同时实现降低精度。
剪枝:移除结构,而不仅仅是数值
“剪枝”删除参数;只有结构化剪枝(丢弃整个通道/滤波器/头)才能真正提升推理性能,因为它减少了张量形状和内核工作量。剪枝后,重新导出精简模型,使计算图与更小的形状匹配。
在C++中加载剪枝后的TorchScript模型(更小的通道)
下一个示例演示了如何在C++中加载剪枝后的TorchScript模型并运行推理。它加载经过微调的精简模型,准备输入,可选地使用CUDA,并在执行推理之前进行几次预热运行。
#include <torch/script.h>
#include <iostream>
int main() {
torch::NoGradGuard ng;
// tinynet_slim.ts was saved after channel pruning & fine-tuning
auto m = torch::jit::load("tinynet_slim.ts");
m.eval();
auto x = torch::randn({8,3,224,224});
if (torch::cuda::is_available()) {
m.to(torch::kCUDA);
x = x.to(torch::kCUDA).to(torch::MemoryFormat::ChannelsLast);
}
for (int i=0;i<5;++i) (void)m.forward({x}); // warm-up
auto y = m.forward({x}).toTensor();
std::cout << "slim output: " << y.sizes() << "\n";
return 0;
}
指南: 在应用剪枝时,首选结构化剪枝——移除整个通道或滤波器——然后进行一段时间的微调,再重新导出优化后的模型。除非您的部署运行时提供了专门的稀疏内核,否则避免使用非结构化稀疏掩码,因为大多数稠密运行时不会从中受益,速度提升可能微乎其微甚至没有。最后,维护一个一致性测试框架,将剪枝后的模型与基线进行比较,确保优化后质量指标保持在可接受的范围内。
蒸馏:部署学生模型
知识蒸馏训练一个较小的学生模型来模仿较大的教师模型。在服务时,您只需部署学生模型——在有限的准确性损失下,缩小内存占用和延迟。
- 适用于跨架构(例如,ConvNeXt | MobileNet,大型MLP | 小型MLP)。
- 与量化结合时尤其有效:例如,INT8学生模型。
- 在C++中无需任何更改——只需加载student.ts或student.onnx即可受益。
图 10.8:用于模型压缩的知识蒸馏
一个较小的学生模型学习模仿较大的教师模型的输出,从而在减少内存使用的情况下实现更快的推理。
C++:一个小型基准测试工具(学生模型与基线模型对比)
以下示例展示了一个极简的 C++ 基准测试工具,用于比较基线模型与蒸馏后的“学生”模型的推理性能。它会重复执行前向传播、测量延迟,并帮助验证优化后的模型在提升速度的同时,没有引入不可接受的精度或数值差异。
#include <torch/script.h>
#include <chrono>
#include <iostream>
using clk = std::chrono::steady_clock;
static double bench(torch::jit::script::Module& m, int B, int iters) {
torch::NoGradGuard ng;
auto x = torch::randn({B, 3, 224, 224});
if (torch::cuda::is_available()) { m.to(torch::kCUDA); x = x.to(torch::kCUDA); }
for (int i = 0; i < 10; ++i) (void)m.forward({x}); // warm-up
auto t0 = clk::now();
for (int i = 0; i < iters; ++i) (void)m.forward({x});
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(clk::now() - t0).count();
return 1000.0 * iters * B / (double)ms; // images/sec
}
int main() {
auto base = torch::jit::load("teacher.ts"); base.eval();
auto stud = torch::jit::load("student.ts"); stud.eval();
std::cout << "teacher img/s: " << bench(base, 16, 50) << "\n";
std::cout << "student img/s: " << bench(stud, 16, 50) << "\n";
}
使用此工具测量速度,并在 CI 中保留精度检查,以确认学生模型满足你的服务等级目标(SLO)。
运行时调优:高效喂给硬件
即使不改变模型本身,运行时配置 也会对尾部延迟产生很大影响——尤其是在 p95 或 p99 级别。调整内存布局、线程使用和内核选择,可以在保持正确性的同时显著提高吞吐量。
布局、线程、算法(LibTorch)
LibTorch 中的几个运行时选项可以根据硬件和模型架构来提升性能:
- 通道在后的布局(Channels-last): 对于卷积密集型网络,使用
x.to(torch::MemoryFormat::ChannelsLast)并在 CUDA 上运行模型,可以改善内存局部性和内核效率。 - 线程配置(CPU): 根据可用的 CPU 插槽和核心数量,调整
at::set_num_threads(n);和at::set_num_interop_threads(k);。 - cuDNN 自动调优: 对于固定形状,启用基准模式让 cuDNN 选择优化的内核:
at::globalContext().setBenchmarkCuDNN(true); - CUDA 图: 捕获静态前向传播可以消除稳定工作负载中每次迭代的内核启动开销。
为固定形状推理捕获 CUDA 图(C++)
以下示例演示了如何在 LibTorch 中为固定形状推理捕获并重放 CUDA 图。这种方法可以显著减少高度重复工作负载中的内核启动开销。
#include <torch/script.h>
#include <ATen/cuda/CUDAEvent.h>
#include <ATen/cuda/CUDAGraph.h>
#include <iostream>
int main() {
if (!torch::cuda::is_available()) return 0;
torch::NoGradGuard ng;
auto m = torch::jit::load("tinynet.ts"); m.eval().to(torch::kCUDA);
auto static_in = torch::randn({8, 3, 224, 224}, torch::kCUDA);
auto static_out = torch::empty({8, 10}, static_in.options());
// Warm-up
for (int i = 0; i < 5; ++i) (void)m.forward({static_in});
at::cuda::CUDAGraph g;
{
at::cuda::CUDAStreamCaptureModeGuard guard(at::cuda::CaptureMode::Relaxed);
g.capture_begin();
auto y = m.forward({static_in}).toTensor();
static_out.copy_(y);
g.capture_end();
}
// Now replay the whole forward with near-zero launch overhead
for (int i = 0; i < 100; ++i) g.replay();
std::cout << "Captured/replayed CUDA graph.\n";
}
CUDA 图需要固定形状和预热;将其与微批次处理配合使用,以获得稳定的流水线。
使用 ONNX Runtime 进行运行时调优
在使用 ONNX Runtime 部署模型时,多个运行时配置选项会显著影响推理性能和资源使用。以下设置通常在生成环境中进行调整:
- 图优化: 启用
ORT_ENABLE_ALL以激活算子融合和常量折叠。 - 线程数: 配置
SetIntraOpNumThreads并可选地设置SetInterOpNumThreads,以匹配可用的 CPU 核心。 - 执行提供程序: 选择合适的后端,如 CPU(oneDNN)、CUDA 或 TensorRT。
- 内存池: 在高吞吐量工作负载下保持内存池启用,并在可能的情况下预分配缓冲区。
C++ —— 会话选项一览
以下 C++ 代码片段展示了创建 ONNX Runtime 会话时配置这些选项的一个简单示例。
Ort::SessionOptions opt;
opt.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL);
opt.SetIntraOpNumThreads(8);
// 对于并行图执行:
// opt.SetExecutionMode(ExecutionMode::ORT_PARALLEL);
// 示例:如果可用则追加 CUDA EP
// OrtCUDAProviderOptions cuda_opts;
// cuda_opts.device_id = 0; cuda_opts.arena_extend_strategy = 0;
// OrtSessionOptionsAppendExecutionProvider_CUDA(opt, &cuda_opts);
整合起来:一个简单、可复现的基准
优化应该是可测量的,而不是假设的。在你的代码库中保留一个小的基准测试工具,它可以加载任何产物,进行预热,并在几个批次大小下报告 img/s(或 seq/s、tokens/s)。用它来比较:
- FP32 vs FP16(TorchScript)
- 基线 vs 精简(剪枝后)
- 浮点 vs INT8(ONNX Runtime)
- 无图 vs CUDA 图
在代码库中保留这个基准工具,有助于确保性能提升是真实的,并便于轻松检测回归。
实用行动手册
以下步骤总结了优化推理性能同时保持正确性和可衡量改进的一个实用工作流:
- 先达到基线同等效果(FP32、稳定布局)。
- 调整运行时参数:通道在后、线程、cuDNN benchmark、微批次。
- 尝试 FP16(GPU)并测量;将数值敏感的后续处理保留为 FP32。
- 在支持的地方采用 INT8(ONNX/TensorRT/CPU);验证精度损失。
- 进行结构化剪枝并蒸馏为小型学生模型;重新导出并重新基准测试。
- 将获胜的变体锁定在功能标志之后,并在预发布环境中将其余变体保留用于 A/B 测试。
在下一节中,我们将把这个引擎作为实时服务来呈现:支持流式输出的 C++ HTTP/gRPC 端点,以及针对延迟 SLO 调优的背压和批处理。
实时模型服务
快速推理是基本要求;可预测的 推理才是让系统在生产环境中长久运行的关键。实时服务是将你导出的模型工件转变为一个可靠的服务,该服务必须满足 95 和 99 百分位的延迟 SLO,在爆发性流量下保持平稳扩展,并在饱和时优雅降级。在 C++ 中,最健壮的设计会分离三个关注点:
- 推理核心 —— 最小化的二进制程序,负责加载模型、预热模型,并确定性地执行前向传播。
- 调度器 —— 一个微批处理器,将许多小请求转换为高效的批次,同时实施并发限制和背压。
- API 边缘 —— HTTP 或 gRPC 端点,负责验证输入、应用截止时间,并在适当的情况下流式输出部分结果以减少感知延迟。
图 10.9:实时推理服务的分层架构
生产级推理系统将 API 边缘、请求调度器和推理核心分离。这种模块化设计提升了可扩展性、可观测性和可维护性。
下面的细节不是一个配方,而是一个模式:少量纪律性的选择可以保持尾部延迟平稳,同时不牺牲吞吐量。
第10章
在接下来的小节中,我们将考察构建此类服务所需的实际组件。首先定义服务契约,它规定了输入、截止时间和批处理策略如何处理。然后我们研究诸如微批处理和并发控制之类的调度策略,接着是服务边缘如何通过 HTTP 或 gRPC 暴露推理能力,同时保持可预测的延迟并在负载下提供安全的背压。
图10.10:推理服务内部的微批处理调度器
传入请求被放入一个有界队列中,并短暂累积成小批量。调度器每批执行一次模型前向传播,并将相应输出返回给每个请求。
边界契约
在编写代码之前,让服务契约变得明确且可测试:
- 张量约定:形状/布局/数据类型(例如,NCHW float32、ImageNet 均值/标准差归一化、RGB 顺序)。
- 截止时间:客户端的预算时间(例如 150 毫秒)应传递到服务器并影响调度。
- 批处理策略:固定的微批窗口(例如“≤16 个请求或 ≤8 毫秒,以先到者为准”)。
- 背压语义:饱和时快速失败(HTTP 429;gRPC RESOURCE_EXHAUSTED),而不是让队列膨胀。
尽早锁定这些约定可以防止“差之毫厘”的等同性问题,并为运维人员提供清晰的 SLO(服务等级目标)控制杠杆。
调度层:带并发控制的微批处理
流量很少以预批处理的形式到达。另一方面,加速器在处理多个样本时效率最高。微批处理器 协调了这两个世界:
- 它持有一个有界队列来存放请求。
- 当队列非空时,它启动一个短收集窗口(例如,最多 8 毫秒),并聚合到最大批大小(例如,16)。
- 它执行一次前向传播,并将结果切片返回给调用方。
- 如果队列已满,它会立即拒绝——背压保护尾部延迟。
在 CPU 方面,future/promise 提供了一种简单机制,将结果返回给每个请求的处理线程。在 GPU 方面,保持模型常驻,对于卷积网络优先使用 channels-last 内存格式,并在进程启动时预热几次运行,以避免冷启动尾部延迟。
C++ —— 一个紧凑的微批处理器(仅核心)
以下示例展示了微批处理调度器的简化 C++ 实现。它演示了生产级推理调度器的关键组件:有界任务队列、短批处理窗口,以及一个将请求聚合为单个批次执行的工作线程。
首先,我们创建任务定义:每个请求都被包装在一个 Task 对象中,该对象存储预处理后的张量,以及一个用于将结果返回给调用方的 std::promise。
// Essence of a scheduler: bounded queue, short window, single worker.
struct Task {
torch::Tensor chw; // [C,H,W] preprocessed
std::promise<torch::Tensor> done; // fulfills with one row of logits
};
然后,我们构建 MicroBatcher 接口:submit() 方法由请求处理器(例如 HTTP 或 gRPC 处理器)调用。每个请求被放入有界队列中,并返回一个将包含推理结果的 future。
class MicroBatcher {
public:
MicroBatcher(torch::jit::script::Module& m, int maxB, int windowMs, size_t cap)
: mod_(m), maxB_(maxB), window_(windowMs), cap_(cap), stop_(false),
worker_(&MicroBatcher::run, this) {}
~MicroBatcher() { shutdown(); }
std::future<torch::Tensor> submit(torch::Tensor chw) {
std::promise<torch::Tensor> p;
auto f = p.get_future();
{
std::lock_guard<std::mutex> lk(mu_);
if (q_.size() >= cap_) {
p.set_exception(std::make_exception_ptr(std::runtime_error("busy")));
return f;
}
q_.push_back(Task{std::move(chw), std::move(p)});
}
cv_.notify_one();
return f;
}
void shutdown() {
if (!stop_.exchange(true)) {
cv_.notify_all();
if (worker_.joinable()) worker_.join();
}
}
最后,我们构建调度器工作线程和批处理逻辑:工作线程在短时间窗口内累积请求,构建批量张量 [N,C,H,W],执行一次模型推理,并将结果张量的每一行返回给对应的调用方。
private:
void run() {
torch::NoGradGuard ng;
if (torch::cuda::is_available()) mod_.to(torch::kCUDA);
while (!stop_) {
std::vector<Task> batch;
batch.reserve(maxB_);
{
std::unique_lock<std::mutex> lk(mu_);
cv_.wait(lk, [&]{ return stop_ || !q_.empty(); });
if (stop_) break;
auto t0 = std::chrono::steady_clock::now();
// pull at least one
batch.push_back(std::move(q_.front()));
q_.pop_front();
// accumulate within window
while ((int)batch.size() < maxB_) {
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - t0).count();
if (elapsed >= window_) break;
if (q_.empty())
cv_.wait_for(lk,
std::chrono::milliseconds(window_ - (int)elapsed),
[&]{ return stop_ || !q_.empty(); });
if (stop_ || q_.empty()) break;
batch.push_back(std::move(q_.front()));
q_.pop_front();
}
}
if (batch.empty()) continue;
// Build [N,C,H,W]
std::vector<torch::Tensor> rows;
rows.reserve(batch.size());
for (auto& t : batch) rows.push_back(t.chw.unsqueeze(0));
auto X = torch::cat(rows, 0).contiguous();
if (torch::cuda::is_available()) {
X = X.to(torch::kCUDA).to(torch::MemoryFormat::ChannelsLast);
}
auto Y = mod_.forward({X}).toTensor().contiguous(); // [N,classes]
for (size_t i = 0; i < batch.size(); ++i)
batch[i].done.set_value(Y.index({(int64_t)i}).cpu());
}
}
torch::jit::script::Module& mod_;
const int maxB_, window_;
const size_t cap_;
std::mutex mu_;
std::condition_variable cv_;
std::deque<Task> q_;
std::atomic<bool> stop_;
std::thread worker_;
};
上述代码的设计说明:
- 短窗口(2–8 毫秒)可以在捕获有意义的突发流量的同时保持 p95/p99 延迟。
- 队列满时的拒绝是特性而非缺陷:调用方可以加入抖动后重试。
- 从每个加速器一个工作线程开始;只有当内存和内核重叠确实能改善 p99 时,才增加更多。
将 HTTP 和 gRPC 作为服务边缘
API 边缘应保持精简且可预测。它将网络负载转换为约定的张量约定,转发给调度器,并渲染结果。两种传输方式占主导地位:
在接下来的小节中,我们探讨对外提供推理服务的两种最常见方式——HTTP 和 gRPC——并讨论每种传输方式分别适用于哪种实时模型服务场景。
HTTP(REST 与服务器发送事件)
HTTP 仍然是跨语言集成和外部客户端的最简单选择。典型设计通常会提供:
- 对一元请求使用 POST /predict——遵循每个请求的截止时间请求头,并在饱和时返回 429。
- 当响应性很重要(例如逐 token 解码)时,通过 GET /stream(服务器发送事件)以流式方式返回部分结果。
在 C++ 中,仅头文件服务器(例如 cpp-httplib)足以满足嵌入需求;成熟框架(Boost.Beast、Pistache)在需要时可提供更多控制。将 I/O 线程与调度线程分离,这样慢客户端永远不会阻塞批处理。
gRPC(一元调用与服务器流式)
对于内部微服务和多语言集群,gRPC 提供:
- 通过 .proto 定义的强类型契约。
- 可传递的截止时间,服务器可以读取并遵守。
- 支持服务器流式输出部分结果,并带有内置流控。
- 对背压有清晰的映射:当队列满时返回 RESOURCE_EXHAUSTED。
一元 Predict 与 HTTP POST 一一对应;StreamPredict 可以在不重新建立连接的情况下流式传输 token 或中间置信度。
尾部延迟纪律
在负载下,p50 很容易实现;p99 则是一种文化。几个习惯可以带来不同:
-
限制一切:通过截止时间来限制队列长度、批大小、负载大小和服务时间。
-
预热路径:在启动时进行 JIT 特化、分配器池、cuDNN 启发式优化——将冷启动从关键路径中移除。
-
分离 I/O 与计算:不要让慢速的 socket 处理器阻塞批处理循环。
-
在热点路径中优先使用固定形状,并考虑使用 CUDA Graphs 来消除稳定流水线的启动开销。
-
衡量正确的指标:队列深度、队列停留时间、批大小分布、p50/p95/p99(对于流式场景还包括 TTFB/TTFT)。
当系统真正饱和时,快速失败。快速的 429/RESOURCE_EXHAUSTED 响应加上重试预算,远比让延迟爆炸对用户更有利。
云、本地与边缘
服务核心在不同目标环境中保持一致;变化的是运行约束:
- 云 – 容器化二进制 + 制品、自动伸缩、集中式可观测性。动态微批处理在突发流量下表现出色。
- 本地 – 硬件稳定,负载可预测。更大的微批次可以用几毫秒换取显著的吞吐量提升。
- 边缘 – 内存紧张、网络间歇性。推荐在 CPU 上使用 TorchScript/ONNX,批大小较小(1–4),窗口接近零;流式通常不必要,但截止时间仍然重要。
在每个环境中,保持服务单一职责。除了推理之外,你的二进制承载的职责越少,就越容易预测、剖析和加固。
最小端到端形态
把所有部分组合起来:
- 模型加载器:读取 tinynet.ts 或 model.onnx,设置评估模式,预热。
- 调度器:
MicroBatcher(mod, /*maxB=*/16, /*window=*/8 ms, /*cap=*/512)。 - API 边缘:HTTP/gRPC 处理器,负责 (a) 校验/解析载荷,(b) 执行截止时间,(c) 调用
submit()并响应或在超时/饱和时返回快速错误,(d) 在改善用户体验时进行流式输出。 - 插桩:用于队列深度和拒绝次数的计数器,队列停留时间和端到端延迟的直方图,批大小和设备利用率仪表。
实现细节会因你的代码库而异,但模式是稳定的:机会式批处理、约束系统、尊重时间。
有了这一服务层,你导出的模型就成为一个产品可以依赖的服务:突发流量下低尾延迟,饱和时优雅失败,为客户端和运维人员提供清晰的契约。
在下一节中,我们将加入可观测性——指标、日志和追踪——让你能在生产环境中看到这些行为,并在用户之前发现回归。
运维与发展生产系统
部署后的模型不是一件制品;它是一个嵌入变化世界中的活系统。评判你的不是上个季度取得的 AUC,而是你的服务今天履行承诺的连续性——延迟、可用性、成本——以及其预测反映不断变化的数据和业务目标的忠实度。因此,推理运维是一场长期游戏:度量一切、安全发布、及早发现变化、有目的地重建。
在以下小节中,我们将探讨让生产推理系统长期保持可靠的运维实践。你将学习如何通过可观测性工具让系统行为可见,检测并应对数据和概念漂移,安全部署新模型版本,并维护一个再训练循环,使模型与不断演化的数据和业务需求保持一致。
让行为可见
生产性能始于可观测性。指标、日志和追踪回答不同的问题;它们结合在一起,让你无需猜测就能从症状走向根因。
- 指标捕获系统随时间变化的形态。追踪“黄金信号”:延迟(p50/p95/p99,流式场景还包括首字节/首 token 时间)、吞吐量(QPS/每秒 token 数/每秒图像数)、错误(按类别划分——超时、校验失败、资源耗尽、推理失败)和饱和度(GPU/CPU/内存利用率、队列深度、批大小分布)。添加模型感知的指标:置信度直方图、校准误差、拒绝/弃权率、缓存命中率和每 1k 请求成本。
- 日志讲述指标所概括掉的故事。要使日志结构化。每个请求都应携带一个关联 ID,从边缘到模型再到后处理,包含输入模式版本、模型版本和决策。出于隐私考虑,记录解释行为所需的信息,而不是原始 PII:特征哈希、分桶值和数据治理策略下的采样示例。
- 追踪连接路径:解析 | 预处理 | 调度/排队 | 推理 | 后处理 | 网络写入。尾延迟尖峰出现时,追踪可以一键回答“在哪里”。对于 LLM,追踪 token 吞吐量和 KV 缓存事件;对于视觉任务,将解码/调整大小与推理分开测量。
图 10.11:生产推理服务的监控看板示例
运营看板跟踪延迟、吞吐量、硬件利用率、队列深度和错误率,以确保 SLO 得到满足(https://en.wikipedia.org/wiki/Grafana#/media/File:Grafana_dashboard_for_MusicBrainz_Hetzner_Yamaoka_server_screenshot.webp)。看板应读起来像服务的 SLO 契约:一个页面显示你是否在履行承诺,如果没有,下一步该看哪里。
在用户之前发现漂移
世界在变化。输入分布会偏移(季节性、新用户群体、新设备),标签会演化(策略、欺诈手段),而模型的决策边界会过时。将漂移视为一级信号,而不是事后剖析的产物。
漂移通常以几种形式出现,可以在生产推理期间监控:
- 数据漂移(协变量偏移): 在服务边缘监控特征分布——数值的分桶、Top-K 类别以及可行的联合统计。简单的检验(PSI、KS、KL)足以标记变化;目标是早期预警,而不是法庭级别的证明。
- 预测漂移: 随时间跟踪类别/分数直方图和校准。输入世界稳定但输出偏移,通常表明模型脆弱、阈值错位或反馈循环。
- 概念漂移: 当正确的含义发生变化时(新的欺诈手法、产品更新),离线准确率保持绿色,而业务 KPI 变红。通过影子标签(对采样流进行人工裁决)、延迟的真实标签关联,以及先行指标(申诉率、退款率、用户报告的质量)来弥合差距。
建立一个小的“漂移笔记本”,团队中任何人都可以每周运行:特征增量、输出增量、校准曲线的图表,以及一个明确说明“什么变了、变了多少、从何时开始”的表格。
安全发布:影子、金丝雀、回滚
大多数故障都是因为“好心”发布得太快而引入的。将每个新模型视为一个假设,并在生产条件下证明它。
- 影子模式将线上流量的副本发送给新模型,而不影响用户体验。记录预测和延迟,与冠军模型进行比较,并在相同载荷上计算分歧和成本差异。影子模式是真实分布下的一致性测试。
- 金丝雀将一小部分真实用户(1–5%)切换到新模型。定义自动中止条件,使其反映你的 SLO 和业务指标:p99 延迟、错误率、收入代理指标、安全阈值。如果任何触发条件被命中,回滚才是核心功能——立即切回并离线调查。
- 蓝绿部署(或 A/B)使切换明确:两套相同的堆栈,一套在线,一套预热。当暗侧健康时进行切换。保持旧版本热状态,直到新版本经过完整的负荷周期验证自己。
纪律在于提交前:在推送之前写下成功标准、观察窗口和回滚规则。这样自动化就可以变得主观而乏味——这正是你在凌晨两点想要的。
闭环:有目的地重新训练
从不改变的模型会失败;不断改变的模型会喧嚣地失败。你想要的是有原因有节奏。
- 触发器: 将基于时间的重训(例如,每月一次,以分摊成本并刷新季节性)与基于事件的重训(漂移警报、KPI回退、重大功能或策略变更)结合起来。对频率设定上限,以便系统其余部分能够稳定。
- 管道: 重训应该是可复现的。对数据集、特征、代码和超参数进行版本控制;在注册表中记录谱系。每个候选者都成为一个带有元数据和冻结评估报告的产物。
- 门禁: 离线评估必须包含准确性和运营指标(延迟、内存、冷启动时间、成本),以及与你领域相关的公平性/安全性检查。一个准确率提高0.2%但成本增加40%的候选者可能无法满足产品SLO。
- 冠军/挑战者: 你很少完全在离线状态下加冕赢家。将挑战者提升为影子,然后是金丝雀,只有在其通过与前身相同的生产门禁之后,才能提升为冠军。
- 人在回路: 当标签稀缺或缓慢时,投入资源从高不确定性或高商业价值的区域进行定向采样。这是低成本的准确性。
在许多团队中,最成功的模式是每季度一次重大重训,配合每周小更新(阈值、小功能修复)以及针对概念冲击的按需热修复。重点不在于精确的时间表,而在于它是有意的。
可靠性、成本与治理
模型并非在真空中运行;它们在预算中、在共享硬件上、在策略下运行。
- 可靠性: 编写从症状(“p99跃升了2倍”)开始、并逐渐进入你可以在几分钟内执行的检查(队列深度、批大小分布、最近的部署、GPU限流)的运行手册。进行事件演练。保持“大红按钮”(流量削减、功能开关、缓存回退)可见。
- 成本: 关注每1k请求/令牌的成本以及延迟。量化、批处理窗口和并发上限比架构辩论更能影响这一指标。将预算线放入与延迟相同的仪表板中:工程师会优化他们能看到的东西。
- 治理: 了解你记录什么、为什么记录、记录多长时间;将原始用户数据与派生信号分离;自动化保留。记录哪个模型版本服务了哪个请求。当你能在几秒钟内回答“哪个模型在何种输入下做出了什么决策”时,你就能赢得用户和审计人员的信任。
稳定的系统是可测量的系统
本章开始时,你导出模型并将其包装在快速的C++服务中。到现在应该清楚,发布只是中点。让服务保持价值的循环是:观察 | 比较 | 更改 | 验证。将仪表板和警报放在人们真正会看的地方。让安全发布成为默认。重训是因为你本就想这么做,而不是因为世界让你感到意外——尽管世界确实会让你感到意外。
推理的技艺不仅关乎数学或内核;它关乎管理。模型会老化。流量会激增。硬件会故障。真正持久的是你嵌入代码中的文化:设计上的可见性、默认的安全性,以及在每次变更后重新测量的谦逊。
总结
在本章中,你学习了如何将训练好的模型转变为C++中可投入生产的推理系统。你首先将模型导出为部署友好的格式,如TorchScript和ONNX,并验证原生执行与序列化产物之间的数值一致性。然后你探索了如何在云、本地和边缘环境中打包和部署这些模型,使用微批处理和并发控制等技术来维持高效的吞吐量和稳定的延迟。本章还介绍了优化方法——包括量化、剪枝、蒸馏和运行时调优——以提高速度并降低成本。最后,你学习了如何通过C++ HTTP或gRPC端点提供服务,并使用可观测性、漂移检测、安全发布和重训循环在生产中运行它们。在下一章中,你将在此基础上进一步探索GPU加速和高级系统设计如何进一步扩展和优化模型推理工作负载。
在下一章中,我们将扩展这些想法,探讨大规模推理系统如何利用GPU加速和分布式架构来高效服务越来越复杂的模型。你将学习如何管理GPU资源、优化数据移动,并在多个设备和节点之间扩展推理工作负载。
问题
在继续之前,为了帮助你检查理解情况,请思考以下问题:
- 权重和偏置在神经元中的作用是什么?
- 为什么我们需要非线性激活?
- 解释批量梯度下降(batch GD)、随机梯度下降(SGD)和小批量随机梯度下降(mini-batch SGD)——每种方法分别何时更可取?
- 学习率控制什么?如果太大或太小会怎样?
延伸阅读
如果你想加深理解,以下资源提供了清晰的解释和有用的背景:
- 深度学习 —— 古德费洛(Goodfellow)、本吉奥(Bengio)、库维尔(Courville)(第6–8章:MLP、优化、正则化)
- 模式识别与机器学习 —— 毕晓普(Bishop)(第4–5章:线性/逻辑模型、神经网络)
- 斯坦福CS231n笔记:反向传播与神经网络优化
- PyTorch(LibTorch)C++ API教程:模型、优化器与张量基础
- 反向传播入门风格的博客文章,用于建立直觉(链式法则、计算图)
答案
当你准备好后,将你的回答与下面的简要答案进行对比,以确认你的理解:
- 权重缩放输入以形成特征;偏置移动激活,使神经元不必强制通过原点。
- 没有非线性,堆叠的仿射层会坍缩为单个仿射映射,无法建模弯曲的决策边界。
- 批量梯度下降:使用整个数据集的梯度——稳定但每步较慢。随机梯度下降:每步使用一个样本——快速但有噪声。小批量:在一个小批量(例如32–256)上取平均——最佳折衷,是深度学习的默认选择。
- 它控制步长。太大→发散/震荡;太小→收敛缓慢或卡住。
获取本书PDF版本及更多
扫描二维码(或访问packtpub.com/unlock)。按名称搜索这本书,确认版本,然后按照页面上的步骤操作。
https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_445_0.png
https://github.com/OpenDocCN/freelearn-dl-zh/tree/master/docs/deep-learning-with-c/img/f45df257dfb11cd9b58ac2c5f6935210_445_1.png
注意:请备好发票。直接从Packt购买不需要发票。
11 调试与重新训练已部署模型
在实验室中表现良好的模型最终会在生产中出现问题——不是因为模型“变坏了”,而是因为其周围的环境发生了变化。新的用户行为、产品更新、变化的流量模式以及不断演进的数据源,会逐渐改变模型训练时所依据的统计假设。
这些变化表现为分布漂移。输入p(x)可能发生偏移(协变量漂移),类别频率p(y)可能改变(先验偏移),或者输入与结果之间的关系p(y|x)可能演变(概念漂移)。甚至在标签到达之前,警告信号常常表现为预测分布或校准的变化。
与此同时,生产系统引入了自身的动态。排队、批处理、缓存行为和硬件条件会影响延迟、可靠性和成本。由于这些影响只有在真实流量下才会出现,调试已部署模型需要将推理服务视为一个可观察的系统,而不是一个静态产物。
本章聚焦于在该环境中运营模型。你将学习如何通过轻量级监控检测和分类漂移,使用结构化日志、指标和追踪来调试故障,并使用可复现的管道和受控的发布来有意识地重新训练模型。
在本章中,我们将涵盖以下主题:
- 识别和处理漂移
- 用证据进行调试
- 有目的地重新训练
调试与重新训练已部署模型
读完本章后,你将理解如何将已部署的 ML 模型作为生产系统来运维。你将学会如何检测和解释漂移,包括协变量(特征)漂移 p(x)、先验(标签)偏移 p(y) 和概念漂移 p(y|x),并使用轻量级监控技术(如滚动直方图)和统计检验(如总体稳定性指数(PSI)、柯尔莫哥洛夫-斯米尔诺夫(KS)检验和卡方(χ²)检验)来量化数据分布是否随时间发生了变化。
你还将学会如何利用结构化日志、指标和追踪来诊断运维问题,以及如何通过审慎的重新训练工作流和受控发布(如影子测试和金丝雀发布)来维持模型质量。这些实践共同帮助将模型准确率、延迟和可靠性维持在定义的服务级别目标(SLOs)内,即生产系统预期要达成的性能目标。
识别并处理漂移
在生产系统中,模型性能下降通常不是因为它“遗忘”,而是因为周围环境发生了变化。数据生成过程随时间演化,这意味着联合分布 p(x, y),包括输入分布 p(x)、类别频率 p(y) 和条件关系 p(y|x),可能不再与训练时的数据匹配。当这种情况发生时,模型决策边界当初被验证所依赖的统计条件开始发生偏移。在实践中,这种现象被称为分布漂移。
由于漂移是真实世界系统的一种正常属性,应当对其进行度量和管理,而不是将其视为故障。不同类型的漂移往往需要不同的缓解措施。例如,在电商系统中,节假日期间礼品购买数量飙升,改变了类别频率 p(y)。在许多情况下,这种先验(标签)偏移可以通过调整决策阈值或对模型重新校准来处理。在移动遥测中,新的操作系统版本可能会引入以前未见过的设备类别。这种协变量(特征)漂移通常指向特征管道问题,例如词汇表过时或归一化常数过期。在其他情况下,输入与结果之间的关系发生变化,例如欺诈模式演变或政策重新定义了什么算作有害内容。这种情况被称为概念漂移,此时条件关系 p(y|x) 本身发生改变,重新训练变得必要。
因此,处理漂移的第一步是在干预之前先进行度量。输入分布 p(x) 可以通过滚动直方图和统计比较(如 PSI 或 KS 检验)来监控,这些方法用于衡量两个分布是否存在显著差异。预测行为可以通过类别分布和校准诊断(如期望校准误差(ECE))来追踪,后者衡量预测概率与观测结果之间的匹配程度。
当真值标签最终到达时,它们允许直接检验 p(y|x) 关系是否发生了变化。
这些度量有助于确定适当的应对方式。有时修复很小,例如重新校准概率或更新特征变换。在其他情况下,需要在更新的数据上重新训练。将漂移视为可度量的信号,而不是立即以重新训练应对,有助于保持生产系统稳定,同时允许模型随着环境演进进行适应。在实践中,这些不同形式的漂移在真实系统中会以可识别的模式出现。下一节将说明漂移如何在生产环境中显现,以及工程师通常如何识别它。
在实践中观察漂移
在生产环境中,模型运行在非平稳的数据生成过程之下。支配训练的联合分布 p(x, y) 很少保持不变;相反,它的一个或多个组成部分会发生移动:
-
特征(协变量)漂移:输入分布 p(x) 发生变化,而输入与标签之间的关系 p(y|x) 大致保持稳定。
以下是一些示例:
- 广告点击率(CTR)模型在操作系统发布后观察到新设备类型激增;
device_model分类特征突然包含许多以前从未见过的 token - 价格弹性模型现在摄入以欧元而非美元报价的数据源,导致价格相关特征的数值范围和缩放发生变化
- 计算机视觉管道更换了摄像头:亮度和噪声特性的变化改变了像素直方图,但标签没有改变
- 广告点击率(CTR)模型在操作系统发布后观察到新设备类型激增;
-
预测漂移:这发生在模型输出的分布发生变化时(类别构成、置信度形态或校准),甚至在标签到达之前就可能出现。它可能表现为预测类别频率、置信度分数或校准的偏移。
以下是一些示例:
- 在相同的产品表面上,分类器的 top-1 类别频率从 [30%, 70%] 倾斜到 [55%, 45%]
- 置信度分数聚集在 0.95 附近,而不是分布在 [0.5, 0.9] 之间,这可能表明过度自信或群体偏移
-
先验(标签)偏移:当类别分布 p(y) 发生变化,而条件分布 p(x|y) 大致不变时,就会发生这种情况。
以下是一些示例:
- 在假日季节,“礼品购买”事件的基础发生率显著上升
- 在电子邮件服务商加强上游过滤规则后,垃圾邮件过滤器的正类率下降
-
概念漂移:当条件关系 p(y|x) 发生变化时,就会发生这种情况,这意味着模型原来的决策边界不再反映当前环境。
以下是一些示例:
- 欺诈者采用新的行为模式,导致以前表示合法活动的特征现在与滥用行为相关
- 医学影像站点升级扫描仪,改变了定义“异常”外观的放射密度分布和细微纹理
- 政策更新重新定义了什么算“有害内容”,即使底层文本特征保持不变,标注标准也发生了变化
不同类型的漂移通常需要不同的应对方式。先验(标签)偏移通常可以通过调整决策阈值或重新校准模型输出来解决。协变量(特征)漂移通常指向特征管道中的问题,例如过时的词汇表或归一化常数。概念漂移更为严重,因为 p(y|x) 关系本身已经改变,通常需要重新训练模型,有时需要加入新特征,甚至可能需要对特征进行重新设计。在生产环境中,关键挑战是利用轻量级监控信号尽快识别出正在发生哪种类型的漂移。
图 11.1:漂移分类及应对措施
特征(协变量)漂移、预测漂移、先验(标签)偏移和概念漂移分别对应典型的生产环境示例及其常用干预手段。该图强调,并非所有异常都需要重新训练:某些情况更适合通过特征管道更新、校准调整或阈值调优来解决,而概念漂移通常需要模型重新训练,并可能需要对特征进行重新设计。
构建常驻漂移监控
漂移检测必须廉价、可解释且持续。不要存储大量原始负载,而是在服务层直接维护滚动摘要。这些摘要使系统能够高效监控分布变化,而不会增加存储或延迟开销。以下是特征类型及相应的监控策略示例:
-
数值特征:对于数值特征,维护汇总分布的轻量级统计:
- 每个特征的固定分箱直方图(例如 20–50 个分箱)
- 通过 Welford 算法计算流式均值和方差,该算法提供数值稳定的在线更新
- 最小值和最大值,以及在需要跟踪百分位数时可选择的分位数草图
-
分类特征:应通过频率摘要来监控这些特征:
- Top-K 频率表加上一个“其他”桶
- 显式跟踪 未知/未见过 token 的占比(数据契约金丝雀)
-
预测:监控模型输出以检测预测行为的变化:
- 类别直方图;分数十分位数
- 校准桶:一旦标签到达,计算每个分数桶中观测到的正样本,以观察 ECE 漂移
-
健康信号:运维信号可以揭示在统计漂移之前出现的问题:
NaN/Inf、全零向量、空字符串、模式不匹配和归一化失败的比率
调试与重新训练已部署模型
-
比较与测试:将最近的时间窗口(例如,过去30–60分钟)与参考窗口(例如一周前的同一小时)进行比较,以考虑季节性因素。监控应依赖稳定的统计度量,以量化当前分布与参考分布之间的差异:
-
对分箱数值特征使用PSI。PSI将生产环境中特征的分布与参考分布(通常是训练数据)进行比较。其计算公式为
PSI = Σ_{i=1}^{k} (P_i - Q_i) ln(P_i / Q_i),其中P_i是当前分布中第 i 个分箱的比例,Q_i是参考分布中对应分箱的比例。在实践中,当 PSI 在多个监控窗口中超过 0.2 时,系统应向工程或机器学习运维团队触发告警。 -
库尔贝克–莱布勒(KL)散度度量一个概率分布相对于参考分布的差异程度。其定义为
D_KL(P ∥ Q) = Σ P(i) log(P(i) / Q(i)),其中P(i)表示观测概率,Q(i)表示参考概率。KL散度是非对称的,即从 P 到 Q 的散度不一定等于从 Q 到 P 的散度。 -
詹森–香农(JS)散度源自KL散度,用于度量两个概率分布之间的相似性。与KL散度不同,它是对称且始终有限的,因此更适用于监控:
JS(P, Q) = 1/2 D_KL(P ∥ M) + 1/2 D_KL(Q ∥ M),其中M = 1/2(P + Q)。 -
针对连续分数分布的KS检验度量两个累积分布函数之间的最大距离:
KS = sup_x |F_P(x) - F_Q(x)|,其中F_P(x)和F_Q(x)分别是当前样本和参考样本的累积分布。 -
针对类别分布的卡方(χ²)检验比较观测频率与期望频率:
χ² = Σ_{i=1}^{k} (O_i - E_i)² / E_i,其中O_i是观测计数,E_i是参考分布下的期望计数。 -
告警:应仅在持续偏差时触发,并且系统应采用滞回机制,使得信号恢复正常范围后告警才能解除。
-
分组(Cohorting):始终按地域、设备、应用版本、获客渠道或任何能划分用户群体的因素进行分层;漂移通常在扩散之前先局部化。
下表总结了生产机器学习系统中需要监控的主要信号,以及对应的汇总统计、更新频率和典型告警条件。
| 特征类型 | 汇总统计 | 更新频率 | 典型告警信号 |
|---|---|---|---|
| 数值特征 | 固定分箱直方图(20–50箱);流式均值/方差(Welford算法);最小值/最大值;可选分位数草图 | 滚动窗口(如30–60分钟) | 群体稳定性指数(PSI)> 0.2;均值/方差偏移;百分位数漂移 |
| 类别特征 | Top-K频率表及“其他”分箱;未知标记比例 | 持续聚合 | 未知标记增加;卡方(χ²)检验检测到变化 |
| 模型预测 | 类别分布直方图;分数十分位数;校准分箱 | 持续;当标签到达时评估 | ECE上升;类别构成偏移 |
| 运行健康信号 | NaN/Inf值计数;全零向量;模式不匹配;归一化失败 |
实时计数器 | 无效值激增;模式违规告警 |
| 延迟/吞吐量指标 | 延迟百分位数(p50/p95/p99);队列深度;批量大小分布 | 每30–60秒导出指标 | p95/p99延迟增加;队列增长 |
| 群体指标 | 按地域/设备/应用版本/渠道计算的统计量 | 滚动窗口与全局监控对齐 | 漂移局限于特定群体 |
表11.2:持续漂移监控的汇总统计
该表将每种特征或信号类型映射到生产监控中使用的轻量级汇总、更新频率和典型告警条件。它展示了如何持续监控数值特征、类别特征、预测结果、运行健康信号、延迟指标和群体级汇总,以便在不对推理路径增加显著开销的情况下及早检测漂移。
实现说明:将计数器和环形缓冲区保留在C++推理进程内。即使有数十个特征,每个特征有数十个分箱,监控状态也足够小,可以容纳在二级(L2)缓存中,因此更新成本很低。每30–60秒导出一次汇总,以便仪表盘和告警直接消费指标,而不是从日志中重建。
以下示例展示了如何在C++中实现轻量级监控结构。固定分箱直方图高效地跟踪特征分布,PSI将参考分布与当前分布进行比较以检测漂移:
-
固定分箱直方图结构:
Histogram结构在有限的数值范围内存储固定数量分箱的计数:

浙公网安备 33010602011771号