项目背景
随着深度学习技术的发展,语音合成技术也经历了从传统的基于参数合成(HTS)至基于深度神经网络的样本级合成(Parallel WaveNet)的变革。相比与传统方法,基于神经网络的新方法在语音的自然度与可理解性上都有了突破性的提升;然而,新方法的计算开销非常大。当微信AI需要将其应用于海量在线系统中,非常难以用于生产系统。
微信AI与微信读书合作在听书这一重要业务场景中提供语音合成服务,将Parallel WaveNet的语音合成算法应用到了生产环境中。同时,微信AI也提供了方便第三方业务接入的提问,可以通过小程序、原生app、页面等方式进行接入。除此之外,在工程化过程中,我们发现从“面向科研”的模型训练至“面向海量服务”的在线服务有着一个不小的鸿沟。因此,微信AI也积累了一套方法论,可以更好的将一个预训练好的模型与微信原有系统进行结合,并对海量用户提供服务。
微信语音合成 微信读书
业务模型
通常来说,除了语音的自然度和可理解度之外,工程方面评价一个在线语音合成系统的核心指标主要分为2个,实时率及吞吐量。实时率的含义是说合成一秒的语音需要多久的时间,吞吐的含义为单位计算单元可以处理的并发请求数。因此,在保证实时率情况之下增加吞吐是整个语音合成项目落地的重点。
在读书场景下,为了减少实时率对最终效果的影响,微信AI采用和语音预拉取的策略。即当用户开始每一句的朗读请求时,终端都同时将次句发至后台进行合成。在这种情况下,用户最多只可能在第一句话时有小小的卡顿。用户平均上行的字数约为20左右,平均语速约为360字/分钟,考虑到最终系统在最大模型上的实时率依然有5倍,因此即使在线合成,也可以在1秒内完成合成。
微信读书的每日语音合成请求峰值已达数万每分钟,若将如此大量的请求全部进行在线合成是不现实的。经过数据分析后发现,用户对前100的热门书籍的听书请求占了总请求的50%。另外,热门书籍中的每一句话,平均合成次数为21,即缓存命中率可超过95%。因此在读书场景下,微信AI采用了仅对热门书籍开启将语音进行预先合成并缓存的方式。
微信AI通过“书籍id”来作为是否支持“微信语音合成”的判断条件。热门书籍基本上每周都会有相应的更新,为了保证更新的内容也可以使用“微信语音合成”来进行朗读,我们同时增加了在线合成的模块,当用户打开白名单书籍的新更新章节时,请求将会被在线集群进行处理。
系统构架
如上文所述,微信AI将语音合成系统分为了在线系统及离线系统。顾名思义,在线系统的职责是直接处理用户发起的语音合成请求。而离线系统的职责则是将书本的原稿按约定的分句分段策略,合成语音后存入语音缓存。整体架构如下所示。
在线系统由“语音合成逻辑”、“语音缓存”及“语音缓存cdn”模块组成。语音缓存的键值则采用了key = prefix model_id md5(text)的方式。离线系统采用了将数据写入离线任务中心,再由多个对等的worker进行摘取任务并执行的方式进行设计。值得注意的是,为了方便部署与监控,此处的离线合成worker组和在线合成单元采用了微信自研的TFCC进行编码,其设计如下图所示。
该计算单元中同时包括了tacotron和pwavenet的前向计算部分,并由tfcc底层提供了完整的跨平台的支持,最终以库的形式打包发布。其中依赖到的模型的权重数据由转换程序来从tensorflow的checkpoint中转出。
TFCC
在将深度学习模型应用于工程服务中,我们往往会遇到以下几个问题:
模型多为python实现,而在线服务为c ,因此需要实现c - python的通信;
使用TF-serving的时候可能会遇到protobuf版本不兼容的问题,因此即使使用tf-serving依然需要将模型的inference放在一个单独的进程中;
不同业务用法不尽相同,增加了运维部署及扩容的成本与风险;
当业务需要在同一台机器部署多个模型时,无法按业务的模型定制化加载策略;
模型的前向计算是一个黑盒,要接入监控系统需要大量工作。
微信AI将上面的问题分为了主要的2类,一类是需要制定现网部署模型的运维规范。二是需要良好地与微信监控系统相结合,避免“盲跑”的程序。因此,我们基于c 研发了专门用于深度学习前向计算的TFCC框架。为了保证该框架的通用性,微信AI在该框架中提供了以下几点能力:
该框架可以应用于多种常用模型,如seq2seq,wavenet等;
使用该框架可以低成本地将一个tensorflow模型的前向计算转写为c 实现;
该框架原生支持tensorflow的checkpoint;
该框架可以提供给使用者自由选择在cpu或是gpu上完成前向计算。
TFCC的主要分为4层。分别是物理设备相关接口实现层、物理设备抽象层、神经网络基本操作层及深度神经网络封闭层,如下图所示。
最底层是物理设备相关接口实现层,它是对gpu、cpu的资源管理及对设备相关代码的封装。这里提到的在GPU的计算是依赖了Cuda、Cudnn及Cublas,而在CPU的实现则使用了MKL及MKL-DNN。
物理设备抽象层的主要职责是将设备相关代码转为设备无关代码,主要由interface、session及device三个模块组成。微信AI在interface中实现了对设备相关的封装,从而对上面的调用者屏蔽了设备相关的信息。为了兼容在tensorflow的变量命名,在session中也提供了命名空间的支持,等价于tf.variable_scope()。同时,在session模块也管理了cpu及gpu的流,处理了同步的逻辑。Device模块管理了进程和显卡之间的使用关系,同时屏蔽了cuda-malloc及malloc。
往上的两层为神经网络基本操作层和深度神经网络层,其职责是兼容tensorflow。首先是接口层面的兼容。神经网络的基本操作层封闭了tf.nn的主要接口,而网络层完成tf.layers的主要接口。同时,我们在toolkit模块中增加了tf.math及tf.contrib中部分数值运算的接口。其次,是模型方面的兼容。通用的Model Dumper模块可以将一个tensorflow的预训练模型转换为一个tfcc可以理解的中间表示。为了降低内存(或显存)频繁申请及释放的开销,微信AI提供了一个内存池来管理所有用到的内存。为了提供代码转写的成本,微信AI结合了c 11的一些语法特性来完成行对行的替换。如下面是一个tensorflow模型转换为tfcc代码的例子。
Tensorflow(python)
With variable_scope(“dilated_conv_%d” % i)
C = conv1d(mel, gate_width, fiter_length, dilation)
D = d c;
D = tf.sigmoid(D[:,:,:m]) * tf.tanh(D[:,:,m:]
TFCC(c )
{
Auto x = tfcc::Scope::scope(“dilated_conv_” tostr(i));
c = tfcc::layers::conv1d(l, args…);
}
D = d c;
D = tfcc::math::sigmoid(tfcc::slice(d, 2, 0, m))
* tfcc::math::tanh(tfcc::slice(d, 2, m, -1));
性能及成本优化
语音合成整个链路中计算量最大的模型为wavenet,微信AI的优化分为以下4步,分析、估算、消除复算和提升并行。
分析
分析的目的是为了直观地知道整个程序中不同接口的开销如何分布。在cpu中我们可以通过perf工具来完成。类似地,在nvidia的cuda开发包中也提供了相应的工具,名为nvperf。
估算
神经网络的前向计算主要是浮点运算,因此微信AI使用FLOP的次数来进行对模型计算量的估算。然后来和显卡或cpu的标称数据进行对比。如Tesla P4 8G的卡官方性能为5.5 TFLOPs。在对模型进行估算的时候,我们采用了自底向上(bottom-up)的方法。我们先进行最基础op的估算,再逐步组合成更复杂模型的估算。如下图的是微信AI给出的常用网络操作的flop次数。
对于较大的网络模型,如wavenet,我们可以依据其网络结构,给出更细致的估算,如下是计算过程。首先我们会列出网络结构,再逐层计算。
GPU的优化
下面是我们在gpu优化中几个具有代表性的优化点,会介绍nvperf的数据及具体的优化策略。
1)显存分配
在我们加入显存池之后则几乎没有cudaMalloc,可见下图。
2)NCHW --> NHWC的转换
4D转置是一个开销极大的操作,它的瓶颈主要是在于显存的读取。我们将运算模型统一改为nchw,降低了transpose的调用。
3)tensor的slice操作
Wavenet的门网络需要将tensor进行split操作,即沿Channels维度将张量从中间分为2个。当我们将模型改为nchw之后,该操作可以做到zero-copy,因此降低了slice的开销。
4)async接口的使用
为了减少cudastreamsynchronize,我们尽量使用了cuda的异步api。
5)总结
在整个优化过程中,我们可以从下图中看到每一步优化后的性能提升过程,其中tensorflow是直接使用tensorflow的python代码运行的性能。
CPU的优化
下面是我们在CPU优化中几个具有代表性的优化点,会介绍perf的数据及具体的优化策略。
1)多核并发的开销
使用MKL和MKL-DNN的时候,我们需要选择合适的并发数,并设置cpu逻辑核的亲和性来减少多个计算单元在不同核之间切换的开销。主要可以通过以下几个环境变量来设置:
export OMP_NUM_THREADS=12
export KMP_BLOCKTIME=0
exportKMP_AFFINITY=granularity=fine,verbose,compact,1,0
2)broadcast add
上述的分析说明了在模型的前向计算中大量使用了扩散加操作,主要是使用在卷积后加bias的操作,由于CPU已经支持FMA指令,因此我们将conv和biasadd进行了合并,使conv和bias的加法在一个指令周期内完成,大幅提升了性能。
3)nchw8c的消除
为了提升cache的命中率,mkl在计算过程中有可能会将一个4d张量从NCHW转为N*C/8*H*W*8,并称该结构体为NCHW8c,类似地,在avx512的时候会转为NCHW16c。如下图所示。
为了减少操作,我们将可以合并op一起计算,减少了每一层conv的操作都需要进行nchw -> nchw8c的转换。
4)mkl-dnn的使用
最早的版本我们仅使用了mkl,在使用了mkldnn之后,性能也有了一个大幅的提升。当前我们最新的cpu的实时率为1.2倍。
总结
谢谢大家耐心阅读,这里放上微信AI的语音合成小程序,欢迎大家体验。