Fenrier Lab

TensorRT 使用指南(4):SadTalker 模型加速方案

介绍

SadTalker 是一个 face reenactment 项目,来自论文 SadTalker: Learning Realistic 3D Motion Coefficients for Stylized Audio-Driven Single Image Talking Face Animation。本文不研究具体的算法原理,而是专注于使用 TensorRT 对原模型推理进行加速。

正确部署后,直接运行示例程序 python inference.py,出现下图所示的任务日志

可以看到,在这几个推理阶段中,最后的 Face Renderer 耗时最严重,因此我们这里主要来对这个模型进行优化。

当前测试的系统环境为:

System: Centos 7
GPU: V100 16G
Python Version: 3.8.8 / 3.10.2
CUDA Version: 11.7 / 12.0
Torch Version: 2.0.0+cu117 
TensorRT Version: 8.6.1
ONNX Version: 1.15.0

在其他环境下可能会出现不一样的行为。

修改 forward 方法的参数参数

对代码进行分析后,可以找到 src/facerender/modules/generator.py 中的 OcclusionAwareSPADEGenerator,这就是 Face Renderer 阶段的推理模型,它的 forward 方法为

def forward(self, source_image, kp_driving, kp_source):

其中,kp_drivingkp_source 都是 dict 类型,包含的 key 可能有 valuejacobian,为了方便使用 torch.onnx.export,这里我们复制一份 generator_v2.py,把以上 forward 的参数拆出来,全部变成 Tensor 类型,也就是

def forward(self, source_image, kp_driving, kp_source, kp_driving_jacobian = None, kp_source_jacobian = None)

其中 kp_driving 是原 kp_drivingvalue 部分,kp_driving_jacobianjacobian 部分,kp_source 也类似。然后对后续代码进行相应修改即可。

导出 generator 的模型权重和样本输入

我们首先在原文件夹下创建目录 tensorrt/generator 作为工作空间存放相关代码和数据。参考 inference.py 的代码加载模型

sadtalker_paths = init_path(args.checkpoint_dir, os.path.join(current_root_path, 'src/config'), args.size, args.old_version, args.preprocess)

animate_from_coeff = AnimateFromCoeff(sadtalker_paths, device)

然后,通过 anime_from_coeff.generator 获得 OcclusionAwareSPADEGenerator 的实例,然后调用 state_dict 方法获得模型权重,最后保存为 pth 文件。

torch.save(animate_from_coeff.generator.state_dict(), "tensorrt/generator/model.pth")

对于样本输入,为了方便起见,我们再次运行 inference.py,并在原 OcclusionAwareSPADEGeneratorforward 方法中存储输入和输出

torch.save(source_image, './tensorrt/generator/source_image.pt')
torch.save(kp_driving, './tensorrt/generator/kp_driving.pt')
torch.save(kp_source, './tensorrt/generator/kp_source.pt')

为了后续验证模型的正确性,还可以用同样的方法保存输出张量。

PyTorch 模型转 ONNX

tensorrt/generator 下面创建 export_onnx.py 文件,假设我们已经加载了模型和数据,使用如下代码导出 ONNX 模型

symbolic_names = {0: "batch_size"}
torch.onnx.export(model=model,
    args = (source_image, kp_driving, kp_source, kp_driving_jacobian, kp_source_jacobian),
    f = "./model.onnx",
    opset_version=16,
    do_constant_folding=True, 
    input_names=["source_image", "kp_driving", "kp_source", "kp_driving_jacobian", "kp_source_jacobian"],
    output_names=["prediction"],
    dynamic_axes={
        "source_image": symbolic_names,
        "kp_driving": symbolic_names,
        "kp_source": symbolic_names
    }
)

其中 symbolic_names 表明 batch_size 为动态维度。注意这里的 opset_version 为 16,因为在这个版本中才支持 torch.nn.functionalgrid_sample 算子。但即便这样,直接运行仍然会报错:

torch.onnx.errors.OnnxExporterError: Unsupported: ONNX export of operator GridSample with 5D volumetric input. Please feel free to request support or submit a pull request on PyTorch GitHub: https://github.com/pytorch/pytorch/issues

这是因为 ONNX 暂时还不支持三维的 grid_sample 操作,这个算子在 src/facerender/modules/dense_motion.pycreate_deformed_feature 方法中有用到。PyTorch 目前对这种情况做了限制[1],但实际上直接把 pytorch 安装目录下的 torch/onnx/symbolic_opset16.py 中的检查代码直接注释掉就可以了。

if symbolic_helper._get_tensor_rank(input) == 5:
    return symbolic_helper._onnx_unsupported("GridSample with 5D volumetric input")

因为 ONNX 本身是支持二维 grid_sample 操作的,而我们并不会使用 ONNX Runtime 运行模型,所以直接导出没有问题,导出后可以使用 Netron 找到 GridSample 节点

当然我们这里捡了一点便宜,如果是 ONNX 完全不支持的算子,就要用点其他手段了。

解决掉 grid_sample 算子的问题之后,可能会遇到另外一个报错:

torch.onnx.errors.SymbolicValueError: Unsupported: ONNX export of instance_norm for unknown channel size.

这涉及到 instance_norm 算子,位于 src/facerender/modules/util.py 下的 SPADE 模块

self.param_free_norm = nn.InstanceNorm2d(norm_nc, affine=False)

貌似这个实现是有点问题,解决方案是我们自己实现这个 InstanceNorm2d 模块[2]。

class InstanceNorm2dAlt(nn.InstanceNorm2d):

    def forward(self, inp: torch.Tensor) -> torch.Tensor:
        self._check_input_dim(inp)
        desc = 1 / (inp.var(axis=[2, 3], keepdim=True, unbiased=False) + self.eps) ** 0.5
        retval = (inp - inp.mean(axis=[2, 3], keepdim=True)) * desc
        return retval    

解决了这两个问题之后,后面应该能正常导出了。

使用 onnx_graphsurgeon 对 ONNX 模型进行修改

前面我们提到 ONNX 不支持三维 grid_sample 操作,其实 TensorRT 也不支持。。所以直接往 TenosrRT 转的话又会报错,所以我们的方案是修改这个节点的名称,避免 TensorRT 使用自己的 grid_sample 算子,,然后使用插件来实现这个算子。Nvidia 提供了一个比较方便的工具 onnx_graphsurgeon 可以帮助我们来修改 ONNX 计算图,由于这个包是托管在 Nvidia 自己的服务器上,所以使用 pip 安装需要额外指定 url

python3 -m pip install onnx_graphsurgeon --index-url https://pypi.ngc.nvidia.com

当然很可能因为网络问题下载不下来,备选方案是用下面这个地址

https://developer.download.nvidia.com/compute/redist/onnx-graphsurgeon/

安装完成之后,只需要修改 GridSample 节点的名称

import onnx
import onnx_graphsurgeon as gs

model = onnx.load("model.onnx")
graph = gs.import_onnx(model)
for node in graph.nodes:
    if "GridSample" in node.name:
        node.attrs = {"name": "GridSample3D", "version": 1, "namespace": ""}
        node.op = "GridSample3D"

onnx.save(gs.export_onnx(graph), "./model_gs.onnx")

这样 TensorRT 就不会尝试用自己的 GridSample 实现去检查这个节点了,而是尝试找到 GridSample3D 这个插件。

使用自定义插件实现 GridSample3D 算子

这部分内容可以参考上一节以及相应的代码库

将 ONNX 模型转换到 TensorRT Engine

编译出插件的 .so 动态链接库之后,可以在 python 代码中加载

trt.init_libnvinfer_plugins(logger, "")
handle=ctypes.CDLL("../plugin/build/libgrid_sample_3d_plugin.so", mode = ctypes.RTLD_GLOBAL)
if not handle:
    print("load grid_sample_3d plugin error")

其中 plugin 为插件的目录,位于 tensorrt 下面。剩下的就是正常的转换流程,可以参考第一节的内容。

编写基于 TensorRT 的 generator

为了充分利用原有代码框架,我们在 src/facerender/modules 目录下创建 generator_trt.py 文件,并实现自己的 OcclusionAwareSPADEGenerator

class OcclusionAwareSPADEGenerator:
    def __init__(self, engine_path: str, plugin_path: str):
        logger  = trt.Logger(trt.Logger.VERBOSE)
        success = ctypes.CDLL(plugin_path, mode = ctypes.RTLD_GLOBAL)
        if not success:
            print("load grid_sample_3d plugin error")
            raise Exception()

        trt.init_libnvinfer_plugins(logger, "")

        self.engine, self.context = load_engine(engine_path, logger)
        
    def __call__(self, source_image, kp_driving, kp_source):
        
        kp_driving_jacobian = kp_driving["jacobian"] if "jacobian" in kp_driving else None
        kp_source_jacobian = kp_source["jacobian"] if "jacobian" in kp_source else None
        kp_driving = kp_driving["value"]
        kp_source = kp_source["value"]

        inputs = {
            "source_image": source_image,
            "kp_driving": kp_driving,
            "kp_source": kp_source,
            "kp_driving_jacobian": kp_driving_jacobian,
            "kp_source_jacobian": kp_source_jacobian
        }

        """
        {
            "mask": mask,
            "occlusion_map": occlusion_map,
            "prediction": prediction 
        }
        """
        output = inference(self.engine, self.context, inputs)
        return output

其中 load_engineinference 方法可以参考第一节。

然后再复制一份 src/facerender/animate.py 文件为 animate_trt.py,并将其中的 generator 修改为

from src.facerender.modules.generator_trt import OcclusionAwareSPADEGenerator

generator = OcclusionAwareSPADEGenerator("./tensorrt/generator/model.engine", "./tensorrt/plugin/build/libgrid_sample_3d_plugin.so")

并删除原来的 generator 相关代码,比如加载权重等等,然后在 inference.py 文件中替换导入

from src.facerender.animate_trt import AnimateFromCoeff

最后运行 python inference.py,可以看到在 fp16 精度下有大致 2.5 倍的速度提升

如果按 24 帧的视频来看,已经达到了实时生成的目的。

总结

本文介绍了如何使用 TensorRT 对 SadTalker 模型进行加速,比较新的知识点在于 grid_sample 算子的实现,以及如何修改 ONNX 模型,让 TensorRT 使用自定义插件来实现这个算子。

目前来看,以上介绍的方案在 float32 精度下加速效果不是很明显,可能只有 1.x 倍的提升,而在 fp16 精度下虽然有 2.5 倍的提升,但是生成的视频质量会有所下降,是一个待解决的问题。

参考

[1] https://github.com/pytorch/pytorch/pull/92212

[2] https://stackoverflow.com/questions/72187686/exception-when-converting-unet-from-pytorch-to-onnx

本文遵守 CC-BY-NC-4.0 许可协议。

Creative Commons License

欢迎转载,转载需注明出处,且禁止用于商业目的。