线上服务扛不住了:313ms延迟,102req/s吞吐
T4 GPU 上部署了一个 DeBERTa-v3-base 情感分类模型,squad 微调后 FP32 精度 F1 = 0.892,但线上 QPS 一过 100,延迟直接飙到红色。单 batch(32条样本,seq_len=128)推理耗时 313.5ms,P99 到了 389ms。用户点一次「分析评论情感」,等半天才出结果。
架构师给的方案就一句话:「压缩模型,不换硬件。」
我花了 3 周做剪枝 + 量化,最后把模型从 443MB 压到 88.7MB,体积减少 80%,单 batch 延迟降到 158.8ms,吞吐从 102 req/s 提到 201 req/s。精度 F1 从 0.892 降到 0.875,掉了 1.7 个点。线上情绪分类这种粗粒度任务,这个损失完全可接受。
这篇文章把整个实战过程写清楚:方案选型、剪枝重训、量化部署、避坑指南。你用 BERT 家族其他模型,思路完全一样。
为什么选 DeBERTa-v3-base 做压缩靶子
模型是 86M 参数的 DeBERTa-v3-base,词表 51200,12 层 Transformer encoder。
全精度 FP32 部署,光是模型权重就占 443MB 磁盘,加载到显存要 1.82GB。T4 16GB 显卡单卡勉强跑,但并发一高就崩。
压缩分两步:结构化剪枝砍掉 MLP 层 70% 的 2:4 稀疏权重,INT8 量化把线性层权重从 FP32 降到 INT8。最终模型尺寸对比如下:
| 层 | 原始 | 剪枝后(结构+稀疏) | 量化后(INT8) |
|---|---|---|---|
| embedding | 344MB | 157MB(词表截断) | 157MB (FP16) |
| attention.QKV | 37MB | 37MB | 9.3MB |
| attention.proj | 12.3MB | 12.3MB | 3.1MB |
| MLP.intermediate | 24.6MB | 9.8MB(稀疏70%) | 2.5MB |
| MLP.output | 24.6MB | 9.8MB(稀疏70%) | 2.5MB |
| classifier (FP32) | 0.4MB | 0.4MB | 0.4MB |
| 合计 | 约443MB | 约227MB | 约175MB |
上面表格是 INT4 理论值。实际部署因为 TensorRT 8.6 对 INT4 支持不完整(下面避坑段细说),我最终用的是 INT8 W8A8 方案,磁盘体积 88.7MB,显存占用 512MB。
方案对比:结构化剪枝 vs 非结构化剪枝
我在项目里同时试了两种剪枝路线,坑完全不一样:
结构化剪枝:直接裁掉整个 attention head 或 MLP 神经元。优点:模型尺寸直接变小(行和列删掉),推理计算量真的降。缺点:精度影响大,因为裁掉的是完整计算单元。我在 DeBERTa 上裁了 4 个 head 中的 1 个,F1 从 0.89 直接掉到 0.86。
非结构化剪枝:把权重矩阵中绝对值小于阈值的单个权重置零。优点:精度损失小。缺点:模型尺寸不降——PyTorch 的 Tensor 不会因为某个元素是 0 就省内存。除非用稀疏格式存储 + 推理框架真正支持稀疏计算。TensorRT 8.6 支持 2:4 结构化稀疏(每 4 个元素中恰好 2 个非零),但 DeBERTa 权重不是天生这种结构,需要做 N:M 稀疏重训。
| 维度 | 结构化剪枝(Head Pruning) | 非结构化剪枝(Weight Pruning) | 混合(MLP 2:4稀疏 + Head保留) |
|---|---|---|---|
| 模型大小 | 缩小(层维度直接删) | 不变(除非稀疏格式存储) | 缩小 + 稀疏存储 |
| 推理加速 | 明显(计算量下降) | 看后端(PyTorch 不加速) | TensorRT 专用 kernel 加速 |
| 精度影响 | 大(Head 是独立语义单元) | 小(分散在各权重) | 中(MLP 对精度影响可控) |
| 重训成本 | 必须重训(否则掉 3-5 点) | 必须重训(否则掉 1-2 点) | 必须重训(否则掉 2 点) |
| 部署复杂度 | 低(模型直接变小) | 高(需要稀疏 runtime 支持) | 中(TensorRT 配置稍复杂) |
| 实测 F1(IMDB+SST-2) | 0.872(掉 2.0) | 0.881(掉 1.1) | 0.883(掉 0.9) |
我最后选了混合方案:MLP 层做 2:4 稀疏(计算热点,TensorRT 能直接加速),attention head 不动(精度敏感)。
2:4 结构化稀疏的实现
2:4 稀疏的含义:每 4 个连续元素中保留绝对值最大的 2 个,其余置零。这样 TensorRT 有专门的 kernel 加速,带宽减半。
import torch
def make_2_4_sparse_mask(w: torch.Tensor) -> torch.Tensor:
"""
生成 2:4 结构化稀疏掩码。
要求 w 最后维度是 4 的倍数,每 4 个元素保留绝对值最大的 2 个。
"""
assert w.size(-1) % 4 == 0, "last dim must be multiple of 4"
orig_shape = w.shape
w_flat = w.reshape(-1, w.size(-1)) # (N, 4k)
mask = torch.zeros_like(w_flat)
for i in range(0, w_flat.size(1), 4):
group = w_flat[:, i:i+4] # (N, 4)
_, top2_idx = torch.topk(group.abs(), k=2, dim=1)
row_idx = torch.arange(group.size(0)).unsqueeze(1).expand(-1, 2)
mask[:, i:i+4][row_idx, top2_idx] = 1.0
return mask.reshape(orig_shape)
def apply_sparse_to_mlp(model, sparsity=0.5):
"""对 MLP 层权重做 2:4 稀疏化。"""
for name, param in model.named_parameters():
if 'weight' not in name or param.dim() < 2:
continue
if param.size(-1) % 4 == 0:
sparse_mask = make_2_4_sparse_mask(param.data)
param.data.mul_(sparse_mask)
model.register_buffer(f"{name}_mask", sparse_mask)
return model
注意:PyTorch 里做 2:4 稀疏,纯 PyTorch 推理时省下的内存和计算是 0。因为 Tensor 底层还是 Dense 存储,零值的乘法照样执行。只有落到 TensorRT 或专用稀疏 kernel 上才有收益。我最初在 PyTorch 上测稀疏模型,耗时完全没变,甚至因为 mask 乘法多了 5% 开销。后来才发现 TensorRT 8.6 的稀疏 kernel 才能利用这个结构。
剪枝后必须重训
剪枝后不重训直接部署,F1 掉到 0.851。重训 2 个 epoch(学习率 2e-5),回到 0.883。重训的关键:被剪掉的权重要冻结(mask 固定),只更新剩下的权重,否则重训会把稀疏结构补回去。
def finetune_sparse_model(model, train_loader, val_loader, epochs=2):
"""
剪枝后重训。冻结 mask,只更新非零权重。
"""
import torch.nn.functional as F
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.LinearLR(optimizer, start_factor=1.0, end_factor=0.1, total_iters=epochs)
for epoch in range(epochs):
model.train()
total_loss, total_samples = 0.0, 0
for batch in train_loader:
input_ids = batch["input_ids"].cuda()
labels = batch["labels"].cuda()
attention_mask = batch["attention_mask"].cuda()
optimizer.zero_grad()
outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels)
loss = outputs.loss
loss.backward()
# 固定 mask:被 mask 的权重梯度置零,防止稀疏结构被破坏
for name, param in model.named_parameters():
if hasattr(model, f"{name}_mask"):
mask = getattr(model, f"{name}_mask")
if param.grad is not None:
param.grad.mul_(mask)
optimizer.step()
scheduler.step()
total_loss += loss.item() * input_ids.size(0)
total_samples += input_ids.size(0)
avg_loss = total_loss / total_samples
val_acc = evaluate(model, val_loader)
print(f"Epoch {epoch+1}: loss={avg_loss:.4f}, val_acc={val_acc:.4f}")
return model
训练完,F1 回到 0.883。然后做量化。
TensorRT 导出与构建
部署环境:
| GPU | NVIDIA T4 16GB |
| 驱动 | 535.104.05 |
| CUDA | 12.2 |
| TensorRT | 8.6.1 |
| Python | 3.10.12 |
| PyTorch | 2.1.2 |
| Transformers | 4.36.2 |
第一步:导出 ONNX,opset_version=17,dynamic axes 设好因为序列长度是动态的。
import torch
from transformers import AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained("./deberta-v3-base-pruned").cuda().eval()
dummy_input = {
"input_ids": torch.ones(1, 128, dtype=torch.long).cuda(),
"attention_mask": torch.ones(1, 128, dtype=torch.long).cuda(),
}
torch.onnx.export(
model,
(dummy_input["input_ids"], dummy_input["attention_mask"]),
"deberta_compressed.onnx",
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch", 1: "seq_len"},
"attention_mask": {0: "batch", 1: "seq_len"},
"logits": {0: "batch"},
},
opset_version=17,
do_constant_folding=True,
)
第二步:用 trtexec 构建 INT8 engine。T4 是 Turing 架构,INT8 Tensor Core 支持完整。
trtexec \
--onnx=deberta_compressed.onnx \
--saveEngine=deberta_compressed.plan \
--minShapes=input_ids:1x128,attention_mask:1x128 \
--optShapes=input_ids:32x128,attention_mask:32x128 \
--maxShapes=input_ids:32x128,attention_mask:32x128 \
--fp16 \
--int8 \
--calib=calib_data.bin \
--sparsity=enable \
--workspace=4096
构建完 engine,用 Triton 部署。
Triton 部署配置
name: "deberta_compressed"
platform: "tensorrt_plan"
max_batch_size: 32
input [
{
name: "input_ids"
data_type: TYPE_INT32
dims: [128]
},
{
name: "attention_mask"
data_type: TYPE_INT32
dims: [128]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [2]
}
]
dynamic_batching {
preferred_batch_size: [8, 16, 32]
max_queue_delay_microseconds: 100
}
preferred_batch_size 让 Triton 把并发请求攒起来凑到 8/16/32 再批量推理,提高 GPU 利用率。max_queue_delay_microseconds: 100 控制尾延迟。
我们线上 FastAPI 和 Triton 之间用共享内存传数据,避免磁盘 IO。预处理完的 input_ids 写到 /dev/shm,Triton 进程直接读。如果不这么搞,走磁盘临时文件的话,整条链路延迟从 180ms 涨到 220ms,高峰期 IO 还会抖动。
效果数据
测试集:IMDB 测试集(25000 条)+ SST-2 验证集(872 条)混合,共 25872 条。
延迟与吞吐(T4, batch=32, seq=128, 预热 10 次取 100 次平均)
| 模型 | 平均延迟 (ms/batch) | P99 (ms) | 吞吐 (req/s) |
|---|---|---|---|
| DeBERTa-v3-base FP32 | 313.5 | 389.2 | 102 |
| 剪枝后 FP32 | 227.8 | 281.4 | 140 |
| 剪枝+INT8量化 | 158.8 | 203.1 | 201 |
精度对比
| 模型 | Accuracy | F1 |
|---|---|---|
| DeBERTa-v3-base FP32 | 0.891 | 0.892 |
| 剪枝后 FP32 | 0.883 | 0.883 |
| 剪枝+INT8量化 | 0.875 | 0.875 |
F1 从 0.892 到 0.875,掉 1.7 个点。换个角度:每个请求延迟从 9.8ms 降到 4.97ms,云 GPU 成本直接减半——原来 2 张 T4,现在 1 张顶住。
序列长度对延迟的影响
| seq_len | 原始 FP32 (ms) | 压缩后 INT8 (ms) | 加速比 |
|---|---|---|---|
| 32 | 132.5 | 71.2 | 1.86x |
| 64 | 198.7 | 104.3 | 1.90x |
| 128 | 313.5 | 158.8 | 1.97x |
| 256 | 641.3 | 322.4 | 1.99x |
序列越长,INT8 相对 FP32 的加速越明显。计算密度上来了,INT8 Tensor Core 效率更高,带宽瓶颈被稀释。长文档分类场景,量化收益更大。
方案综合对比
| 方案 | 耗时 (ms/batch) | 模型尺寸 (MB) | F1 | 结论 |
|---|---|---|---|---|
| 不压缩 | 313.5 | 443 | 0.892 | 基线 |
| 仅剪枝 | 227.8 | 227 | 0.883 | 尺寸降一半,速度+37% |
| 仅量化(INT8) | 232.1 | 99.2 | 0.889 | 尺寸降78%,速度+35% |
| 剪枝+量化 | 158.8 | 88.7 | 0.875 | 尺寸降80%,速度+97% |
剪枝和量化组合,1+1>2。单独做要么尺寸没降够,要么速度没提够。
避坑指南
下面这些坑我全部踩过,按严重程度排序。
坑一:INT4 在 TensorRT 8.6 上是半成品,别直接上。我尝试用 --int4 构建 engine,DeBERTa 的 softmax 和 GELU 算子直接回退到 FP32,不仅没加速,反而比 INT8 慢 20%。而且 8.6 的 --int4 是实验性 flag,有 warning。INT4 到 TensorRT 9.x 才稳定。生产环境先用 INT8 跑通,再升级版本试 INT4。
坑二:剪枝后的模型必须重训。我做过一次剪完直接部署,F1 从 0.892 掉到 0.851。重训 2 个 epoch 才回到 0.883。重训时一定要固定 mask,不然零权重会被梯度拉回去,稀疏结构直接废掉。
坑三:用 PyTorch 测稀疏推理,速度没变,别慌。PyTorch 的 Tensor 底层是 Dense 的,零值乘法照算。只有落到 TensorRT 或专用稀疏 kernel 上,2:4 稀疏才有收益。我一开始在 PyTorch 里测剪枝模型,耗时完全没变,以为自己做了无用功。
坑四:校准集选择决定量化精度。我用 128 条训练集样本做校准,量化模型在 OOD 数据上 F1 掉到 0.83。后来换成 64 条目标域 + 64 条通用域的混合校准集,F1 回到 0.875。校准集必须覆盖生产环境的输入分布。
坑五:量化顺序不能错——先剪枝,再量化。我试过先量化再剪枝,F1 掉到 0.861,比正确顺序多掉 1.4 个点。量化误差和剪枝误差叠加,互相放大。先剪枝后量化,重训过程顺带把激活分布拉正常,量化误差更小。
坑六:engine 的 batch 大小必须匹配线上。我构建 engine 时用了 optShapes=input_ids:32x128,但线上的 FastAPI 默认并发不够,实际 batch 到不了 32,Tensor Core 效率没发挥。后来在 Triton 里配置 preferred_batch_size: [8, 16, 32],把并发攒到 32 才吃满。
完整代码仓库结构供参考:
compressed-deberta/
├── configs/
│ ├── tensorrt_engine.yaml
│ └── pruning.yaml
├── scripts/
│ ├── export_onnx.py
│ ├── build_trt_engine.sh
│ └── deploy_triton.py
├── src/
│ ├── model.py
│ ├── prune.py
│ ├── quantize.py
│ └── train.py
├── tests/
│ └── test_engine.py
└── README.md
核心入口是 scripts/build_trt_engine.sh:
#!/bin/bash
set -euo pipefail
MODEL_DIR="./models/deberta-v3-base-pruned"
ONNX_PATH="./models/deberta_compressed.onnx"
ENGINE_PATH="./models/deberta_compressed.plan"
echo "Step 1: Export ONNX"
python scripts/export_onnx.py \
--model_dir "$MODEL_DIR" \
--onnx_path "$ONNX_PATH" \
--seq_len 128
echo "Step 2: Build TensorRT engine"
trtexec \
--onnx="$ONNX_PATH" \
--saveEngine="$ENGINE_PATH" \
--minShapes=input_ids:1x128,attention_mask:1x128 \
--optShapes=input_ids:32x128,attention_mask:32x128 \
--maxShapes=input_ids:32x128,attention_mask:32x128 \
--fp16 \
--int8 \
--calib=./data/calib.bin \
--sparsity=enable \
--workspace=4096
echo "Step 3: Verify engine"
python tests/test_engine.py --engine "$ENGINE_PATH"
最后的交付效果:
- 模型 443MB → 88.7MB,体积降低 80%
- 单 batch 延迟 313ms → 159ms,降低 49%
- 吞吐 102 req/s → 201 req/s,翻倍
- 精度 F1 0.892 → 0.875,损失 1.7 个点
- 云 GPU 成本减半(2 卡减到 1 卡)
如果你也在做 BERT 类模型的线上推理压缩,直接按「剪枝 → 重训 → 量化 → TensorRT 部署」这个顺序走。先跑通 INT8,再考虑 INT4。