流式语音算法工程落地框架

2021/09/16 c++与高性能计算 共 3360 字,约 10 分钟 Read:

九月几乎大部分精力集中在开发语音算法流式框架,工作中积累一些通用的方法和经验, 总结梳理如下,可能后续还会不断更新添加新的内容。

传送: 关于流式算法的部分 流式语音算法总结

持续更新中

需求设计

这套框架设计的核心是提供一套语音算法的通用框架,通过复用通用组件,从而实现算法的快速落地。

  • 满足各种输入输出。例如音频进音频出(vc),音频进文本出(asr),文本进音频出(tts)等。主要是满足流式算法的需求,同时也兼顾非流式的情况。
  • 模型/SDK/服务尽量解耦。特别是模型和其他模块解耦,方便对接不同模型。算法开发迭代只需要在模型层面迭代即可。
  • 提供通用的设计,例如模型的内存共享,SDK和服务之间的衔接,性能压力测试等。

对于刚刚入行算法坑不久的同学,模型训得很溜,写cpp却很痛苦。希望这一套能缩短模块落地的流程,同时让更多同学把精力focus到模型和算法设计本身。

分层结构

整个系统按照如下分层结构设计。其中顶层模块调用底层模块,不允许跨层调用。同层直接不能互相调用。底层模块不能调用顶层模块。

以下是模块设计的细则:

Nnet model

神经网络的封装层,接口和 python 相同,不涉及其他的逻辑。 建议使用与框架无关的参数类型与外界交互(推荐使用 eigen)。这样做的好处是,方便对 nnet 使用的框架进行替换, 例如从 pytorch 改成 tensorflow or ONNX,外界无感知。

该模型层只读不写,使用 share_ptr 传递对象,每个模型只在 server 上产生一份内存占用(支持水平扩展)。

Sub module

该模块是具有独立功能的算法模块,例如声码器,简单的子模块只包括一个 nnet model, 复杂的可能包括数据的前后处理,多个 nnet model 的串联或并联。流式实现,大多数在此模块内完成。 为了简洁,注意控制 sub module 的对外接口。

对于流式模型,格外注意输入和输出的参数形式,建议可以接收变长度的输入类型,这样后期处理有很大的灵活性。

engine

engine 实现独立算法功能的单元。例如 AsrEngine or VcEngine,是整个项目的主体。 engine 包含多个子模块(sub module)结构。每个 eigine 对于一路独立的服务。

在此层级,可以进行完整的单路性能测试,从而评估模型的基本性能和内存占用。

engine pool

集成多个 engine 的pool 类。在此层级,主要完成:1. host Nnet model 等共享内容。2. 不同 engine 的创建、调用和回收。 每路服务应该在同一 engine 中完成。

一般来说,server 上不会启动多于一个 engine_pool.

在此层级,重点测试多路服务并发运行的相关内容。

Trick: eigen 的使用技巧

个人很喜欢使用 eigen ,一个显而易见的好处是其 2D/3D 矩阵的格式很容易理解,写法简洁可读性高。 和其他数据格式相比,额外性能开销不多,因为其背后调用了 mkl,所以纯矩阵运算性能也不差。

备注:之前在某个项目中尝试计算矩阵的 exp 运算,对比了一些不同的数学库和一些网上比较 fancy 的 实现(充斥着大量不容易读懂的位运算),发现性能上并不比 eigen 更快。

一些使用上的注意要点如下:

  1. 矢量化运算(SIMD),矩阵和向量的维度是16的倍数(如果使用AVX512,最好是32的倍数)。
  2. 使用动态分配内存,可以避免数据存储对齐的问题(不用手工对齐)。实测动态分配内存和静态分配内存效率基本没差别。
  3. lazy evaluation还是有用的,即尽量用一个长的表达式来代替短的。不过要和可读性权衡一下。 例如 T1=AX+B;T2=CT1+D 写成T2=C(AX+B)+D,让编译器进行优化。 注意,在这个例子中,对于矩阵乘法采用不同的结合方式,可能在计算次数产生巨大的差别。 当然,有时候不确定编译器是否会优化,自己手动指定可能是更好的选择。
  4. segment和block函数效率较低,尽量减少使用。
  5. 尽量避免大矩阵的拷贝。可以使用引用或指针来代替。(重要)
  6. 使用A.data()来获得数据,方便和其他库混用。可以使用eigen来管理整个计算,对于个别的计算可以在调用其他库(例如mkl)
  7. eigen能支持的操作比较少,int8计算的优化和稀疏矩阵的优化都不太好。因为eigen自己的设计理念是,要不做到最快要么不做。
  8. eigen可以使用mkl作为内核,配合eigen一些自己的上层优化,速度可以更快一点。

共享内存的设计

多路服务下,我们希望共享模型数据在内存,水平扩展时内存不会随之增加。 一种简单的实现是使用 cpp 中的共享指针(shared_ptr)。

示例如下:

# init model with shared ptr, only load parameters to memory once!
auto p_nnet = std::make_shared<tts::NnetFeat>();
p_nnet->initialize();

# using p_nnet to intialize multi engine!
auto p_engine_1 = std::make_shared<tts::VcEngine>();
p_engine_1->initialize();
auto p_engine_2 = std::make_shared<tts::VcEngine>();
p_engine_2->initialize();

Trick: cpp中数据存放在堆or栈?

cpp 中 shared_ptr/unique_ptr 是导致内存泄漏堆重灾区(已经比裸指针好了很多,但架不住开发者菜)。 坑踩的多了,总结了一个比较简洁的写法:所有变量都放堆上(而非栈上),接受一点点性能损失换取稳定。

变量寄存在堆or栈?一般情况下,变量会寄存在栈上,编译器自动决定何时回收。 如果存在指针指向该变量,变量已经被释放但指针继续调用,则会栈溢出报错。 堆是栈的下一级存储单位,在整个函数周期内都不会被释放。因此,把变量放到堆上更安全,效率也更低。

例如:

VcEngine eng = VcEngine();  // stack
std::shared_ptr eng_p = &eng;  // mistake for stack overflow
std::shared_ptr eng_p = std::make_shared<VcEngine>();  // heap

第二行是错误用法,eng 可能会先于 eng_p 释放。建议直接使用第三种方式创建和使用,eng_p 指向对象在整个生命周期内有效。

服务层

  • websocket 是一个不错的选择,类似线程调度,并发阻塞之类的问题,websocket都已经帮忙考虑好了。 建议 cpp 启动 server,py/cpp client开发。 一个简单明了的范例,可供参考(短连接长连接都有)。
  • 对应写好服务协议和接口。

测试

  • 合理尺度的单元测试,确保结果的正确性。
  • 算法性能的 benchmark 测试,评估单个算法的性能
  • 内存等系统资源的占用情况
  • 并发性测试,多路服务下的性能指标。确定是否需要绑核,给出推荐的合理配置 附录:绑核操作命令 taskset -c processor_idx0,processor_idx1,…
  • 内存自检,推荐使用 valgrind 工具,检查 definitely/invalid read/invalid write 问题, 确保进程服务一定时间内稳定(一般valgrind可以检测绝大部分内存泄漏问题)

软件系统

这里真正的开始,会像做一个软件(起码也是比较完整的 SDK)来看待整个系统,而不只是一个算法。 从而会考虑更多的一些内容。

  • 服务端/客户端,两侧对应的文档。如上所述,客户端提供 py 和 cpp 两个版本的实例 demo
  • 相对完备的测试,如上文所述
  • 通用操作脚本,例如新环境下一键部署的shell文件(下载/安装必备的外部库/配置环境/编译/启动等)
  • 容器的自愈/扩容/更新等功能(这块还未完成)
  • 发布版本对应文档
  • 运行日志系统等

工程上的东西,零零散散做了快两个半月,整体上收获还是不小的。 语音框架的设计/流式算法的实现/torch模型底层api调用/cpp 语言熟悉/高性能计算和优化/websocket对应框架/现代软件SDK搭建相关内容。

林林总总,皆为学问,踏实做事,终有成长。

Search

    Table of Contents

    本站总访问量