神经网络模型在云端的优化技巧。
之前几个月花了相当多精力在wavernn方案的推理优化,借此机会摸索了一下在深度学习场景下速度优化的一些技巧。大部分是通用的,少部分是针对wavernn特定的优化方法。 同时,也希望借此机会,整理总结一些神经网络推理加速的方法论和注意事项。
WaveRnn
wavernn 是 vocoder 中的一个经典结构,广泛用于语音合成和语音转换等生成类任务中。其功能是通过自回归结构完成mel谱到音频信号的映射。 由于其音频信号采样点较高,因此传统的自回归结构通常具有性能较低的缺点,一定程度上限制了Wavernn的广泛使用。因此,基于工程层面的推理优化就成为了声码器上线工作中的重要一环。
Note: pytorch实现中和原始论文结构不完全一样。
分析
profile-tools
- 搭建好用的性能测试分析工具,精确测试每个模块的用时。
- 使用一些简单的模块,来验证测试工具的有效性,并了解测试工具的精度偏差。
- 注意测试语句本身的耗时。
- 神经网络模型使用前要warm-up,否侧首次计算耗时偏长,带来错误的结果。
- 稳定可复现的测试环境,可以多次测试取平均来境地测试带来的误差。
模型
- 了解常见模型参数量和flops,有助于快速估算。例如GRU每层参数量为2x3xNxN,计算量为6xNxN。
- 尽量尝试降低模型尺度来获得性能成倍提升。
- 使用更快的算法(RNN->CNN,自回归->非自回归)
推理流程的优化
- 查表代替某些数值计算
- 重复计算单元的存储到内存
eg: wavernn推理过程中的一个片段,矩阵中某个区域发生了重复计算,优化后节约了1/3的计算量:
# vanilla algorithm
aux2, aux3 = func(...)
for i in range(point_len):
t1 = relu(W1 x concat(input, aux2) + b1)
t2 = relu(W2 x concat(t1, aux3) + b2)
output = W3 x t2 + b3
# optimized algorithm
aux2, aux3 = func(...)
aux2_proj, aux3_proj = W1b x aux2, W2b x aux3 # put same result into memory
for i in range(point_len):
t1 = concat(relu(W1a x input), aux2_proj)
t2 = concat(relu(W2 x t1) + aux3_proj)
output = W3 x t2 + b3
实现
tensorflow与自定义op
- tf设计时为了通用性,牺牲了部分效率。例如,某些简单op,造成的数据传输耗时。
- 可以考虑采用自定义op来完成融合,只要实现正向计算。
- 推理过程中可以针对特定尺寸的gemm(gemv)进行优化。
Note: 自回归声码器中,不要频繁调用模型。
# bad case:
define static graph
for i in range(point_max):
point = sesson.run(last_point, mels)
# good case:
define loop in graph
point_list = sesson.run(mels)
编译
- 使用不同的指令集(如avx2 or avx512),以及O3 or Ofast编译优化,注意对多线程的支持。
C++手动实现推理
优点
- 灵活的处理op,可以根据计算资源和时间情况来进行优化。例如并行时核id和计算进程/线程绑定。
- 对模型定制化选择最优的方案。例如推理过程中可以针对特定尺寸的gemm(gemv)进行优化,以及针对不同的操作选择对应的高效计算库。
缺点
- 重新算法开发周期较长
- 可能结果对不齐。
手动实现推理过程的几个要点
- 代码的清晰与可读性。推荐使用Eigen作为矩阵数据结构。
- 调用已有的计算库,例如eigen or mkl等,避免重写过于底层且成熟的算法。
- 遵循SIMD优化计算守则。(重要!!)
- 合并相似性计算,提高数据复用率并减少换入换出导致的切换开销
- 避免线程迁移导致频繁切换开销
SIMD
现代优化体系中最重要的概念。
- 使用尺寸为16(or 32 for AVX512)倍数的矩阵。
- 使用矩阵运算操作代替 element-wise 操作。
eigen的使用技巧
- 矢量化运算(SIMD),矩阵和向量的维度是16的倍数(如果使用AVX512,最好是32的倍数)。
- 使用动态分配内存,可以避免数据存储对齐的问题(不用手工对齐)。实测动态分配内存和静态分配内存效率基本没差别。
- lazy evaluation还是有用的,即尽量用一个长的表达式来代替短的。不过要和可读性权衡一下。 例如 T1=AX+B;T2=CT1+D 写成T2=C(AX+B)+D,让编译器进行优化。 注意,在这个例子中,对于矩阵乘法采用不同的结合方式,可能在计算次数产生巨大的差别。
- segment和block函数效率较低,尽量减少使用。
- 尽量避免大矩阵的拷贝。可以使用引用或指针来代替。(重要)
- 使用A.data()来获得数据,方便和其他库混用。可以使用eigen来管理整个计算,对于个别的计算可以在调用其他库(例如mkl)
- eigen能支持的操作比较少,int8计算的优化和稀疏矩阵的优化都不太好。因为eigen自己的设计理念是,要不做到最快要么不做。
- eigen可以使用mkl作为内核,配合eigen一些自己的上层优化,速度可以更快一点。
高性能加速库
使用之前要重新评估,不要轻信网上的各种benchmark!原因:
- 环境差异,是否使用AVX2/AVX512指令集,以及使用cpu核心数不同。单线程和多线程等差异。
- 矩阵维度不同,以及矩阵计算形式不同。例如计算y=Ax+B的gemv和正常的gemm可能都不一样。不同尺度的矩阵可能会有不同的测试方法。
几个比较常用的库: eigen/mkl/cbla-gemm/dnn-gemm/fbgemm等
一个推荐的benchmark测试脚本: https://github.com/XapaJIaMnu/gemmBench (之前是重点来测试量化矩阵性能的,也可以测常规矩阵性能。)
自己的测试经验:对实稠密矩阵gemv乘法来说(y=AX+b),eigen调mkl库是最快的。
其他一些常见的trick
稀疏
- 稀疏矩阵的快速计算方案。稠密项最好以1x16块(或者16x16)形式存在(SIMD)。
- 怎样尽可能避免稀疏矩阵的训练带来的精度损失。大稀疏Vs小稠密矩阵的差异。
量化
post-quant Vs training-aware quant
- post-quant: 怎样避免精度损失,合理的选取截断值和量化方案。
- 训练过程的量化(fake quant思想)
infer using quant
- mkl gemm-s8u8s32,实测加速比x3
- mkl gemm-s16s16s32, 精度无损,加速比较低(未具体测).
并行or多进程
- 系统层级的绑核操作,带来额外的性能提升。
- mkl内部的并行矩阵运算 Vs 进程/线程级别的并行匀速。
附录
加速库proj:
- https://github.com/kpu/intgemm/tree/9650312105071fa610afceaecef58ab8060082a4
- https://github.com/google/gemmlowp