DeBERTa模型量化与剪枝压缩实战:吞吐翻倍
发布日期: 2026/08/12 阅读总量: 1

线上服务扛不住了: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)
embedding344MB157MB(词表截断)157MB (FP16)
attention.QKV37MB37MB9.3MB
attention.proj12.3MB12.3MB3.1MB
MLP.intermediate24.6MB9.8MB(稀疏70%)2.5MB
MLP.output24.6MB9.8MB(稀疏70%)2.5MB
classifier (FP32)0.4MB0.4MB0.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 导出与构建

部署环境:

GPUNVIDIA T4 16GB
驱动535.104.05
CUDA12.2
TensorRT8.6.1
Python3.10.12
PyTorch2.1.2
Transformers4.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 FP32313.5389.2102
剪枝后 FP32227.8281.4140
剪枝+INT8量化158.8203.1201

精度对比

模型AccuracyF1
DeBERTa-v3-base FP320.8910.892
剪枝后 FP320.8830.883
剪枝+INT8量化0.8750.875

F1 从 0.892 到 0.875,掉 1.7 个点。换个角度:每个请求延迟从 9.8ms 降到 4.97ms,云 GPU 成本直接减半——原来 2 张 T4,现在 1 张顶住。

序列长度对延迟的影响

seq_len原始 FP32 (ms)压缩后 INT8 (ms)加速比
32132.571.21.86x
64198.7104.31.90x
128313.5158.81.97x
256641.3322.41.99x

序列越长,INT8 相对 FP32 的加速越明显。计算密度上来了,INT8 Tensor Core 效率更高,带宽瓶颈被稀释。长文档分类场景,量化收益更大。

方案综合对比

方案耗时 (ms/batch)模型尺寸 (MB)F1结论
不压缩313.54430.892基线
仅剪枝227.82270.883尺寸降一半,速度+37%
仅量化(INT8)232.199.20.889尺寸降78%,速度+35%
剪枝+量化158.888.70.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。