运行时(Runtime)抽象出计算模型,提供更通用的接口,进一步屏蔽硬件细节。
在深度学习硬件加速系统中,如果仅依赖驱动接口,业务层/编译层代码中仍然要编入硬件相关代码,需要管理设备,记录内存,整理数据等,耦合较重,迁移困难。
在驱动之上封装一层runtime有利于进一步提供代码复用程度,将硬件实现和上层应用完全解耦。
在后端硬件系统设计早期,很可能因为项目压力而倾向于把所有功能写在一起:
- 程序读入模型并统一转换为内部定义的模型标记格式。
- 接着程序根据标记构建计算图,优化使得计算图的pattern符合硬件需求。
- 随后程序根据硬件结构执行算子融合,并生成对应的硬件指令。
此时,应用层、编译层、运行时完全耦合,专为应用定制设计,模型结构也可能硬编码在代码里。
这样的设计会带来不少问题:
- 整个程序更重,基本上要保证能执行全部算子,否则需要将框架反向接入,使用框架的算子覆盖长尾逻辑。
- 代码高度耦合,换个模型,换个应用就要重写大量代码,为了同时支持多个应用,需要把所有代码全部展开,不利于版本管理和代码复用。
- 调试极不方便,模型,编译,运行时每一层都有可能有出错的可能,但因为缺少明确的接口,基本上只能端到端调试。
因此,需要分层设计软件,解耦逻辑,复用代码。
从运行时的角度看,用户可能以多种形式使用:
- 直接使用,对硬件结构熟悉,通过运行时提供的接口简化操作。
- 接入框架,需要在算子内生成运行时所需的数据。
- 接入编译,接受编译层的通用中间表达(IR1),根据硬件结构转换成指令,屏蔽硬件细节。
- 调试使用,提供Python接口,方便运行调试。
通过合理的抽象,就可以满足使用需求,并保留足够的扩展性。
# 抽象
- 内存抽象
对驱动来说,其并不关心DMA下发的数据具体是什么类型,也不会关心内存和哪个具体模型对应。
对用户来说,其并不关心内存分配地址,也不关心硬件运行细节,最好作为函数接口调用。
因此,可以将内存抽象为三种类型:
- Blob。动态数据块,仅记录形状信息,需要在运行时传输,每次调用计算均会被改变。
- Data。静态模型参数,编译时确定,声明周期和模型相等。
- Instructions。计算指令,其中内存地址需要动态映射。
- 设备
设备(Device)抽象负责硬件设备初始化,设备分配,拥有设备相关执行逻辑。
设备抽象和硬件实现相关,通常和计算核对应,提供计算核申请与维护,内存申请与维护,指令解释(内存地址映射),数据重排,量化,精度转换等功能。
- 多模型
使用Executor机制,根据IR标识(比如哈希值或者校验和)区分不同模型。
对一个新IR,创建对应Executor以及State,并在Executor首次调用时调用Device的初始化接口,IR信息(比如计算核handler以及内存地址映射表等)保存在State中。之后调用Device的运行接口,考虑到多线程支持,State信息可能会被同时访问,仅设置为只读。
对单个模型的多线程调用,分配线程级数据结构保存当前调用的Blob分配信息,调用结束后销毁,避免加锁。
多后续标识相同的IR,通过cache表查到对应的Executor调用。
- 接口
也就是IR定义,主要包括:
- IR标示符
- 设备类型
- 动态数据表
- 静态数据表
- 计算指令表
其中还可以详细定义是否支持Dynamic Shape,是否需要完全内存映射等,量化精度,数据精度,数据对齐等。
# 优化
运行时作为调用的关键路径之一,性能优化也相当重要,常用的优化手段包括:
- 资源池。
考虑到多线程调用时,每次调用的动态内存申请开销。在专用场景下,每次调用动态内存尺寸不变,可以使用资源池提前分配,并在每次调用时申请。资源池一般需要加锁,资源池大小可根据IR信息配置,也可开放接口供用户配置。
- CPU Affinity
考虑到线程中断开销通常集中于CPU0,多线程使用时希望规划计算使用,也就是设置线程级CPU亲核性。开放亲核性配置供用户配置。
- 硬件Warm-UP
硬件pipeline填充也需要时间,首次启动时需要从DDR加载数据,针对特定应用,数据加载完成后不会反复swap,可以提供warm up接口,使用atomic变量,在首次运行时填充特定寄存器。
- DMA合并
DMA的读写开销并不低,如果是IO数据块(处于计算首尾的需要和主存swap的动态数据块)较多,则可能拉低系统整体性能。驱动对内存连续性无法判断,可以在runtime内存申请时提前合并IO数据块,将DMA读写次数合并为1次,提高系统整体性能。
# 调试
主要是C++编程的一些吐槽。
- 多线程安全
合理规划数据,优先选择不加锁。如果一定要加,根据场景加合适的锁,合理使用线程级数据/原子变量。
- 指针安全
野指针很危险。有时候系统通过原始指针对外需要暴露C API,但在进入C++部分后,需要合理使用unique_ptr,shared_ptr,std::move等限定和转移指针使用权。不然double free,空指针报错就很有可能出现,许多小项目在运行完之后抱一个指针错误,反正也查不出来是哪,就凑合着用了。
- 内存访问
数组下标越界引起的segment fault,在stack trace中并不能反应出来,很可能是程序欢快地运行很久之后突如其来,记得检查。查错时也可以上valgrind之类的内存统计工具,不过程序过大时,各种loss就可能让人眼花缭乱了。
- 内存泄露
内存泄露的排查也比较艰难,使用内存统计工具,排查各种指针使用情况之后,大概能解决绝大多数bug。但有些因为内存分配策略导致的内存使用量持续升高可能就和模型相关了(比如Tensorflow),需要具体分析。