驱动是对硬件接口的抽象和封装,设计时需要充分考虑分层和解耦。
Linux系统将设备1抽象为文件,比如字符设备、块设备和网络接口,保持和文件一致的读写接口。
拓展卡类型的FPGA加速卡使用GPIO和PCIe相连,内置PCIe Controller和DMA提供统一寻址。DMA提供多组AXI总线(通常与DDR空间对应,DDR空间不足可高位截断)以及一组AXI-lite总线(通常和用户寄存器空间对应)。
FPGA硬件设备驱动的核心模块是DMA驱动,配合scatter-gather DMA,负责数据在主存和设备存储间的交换。
用户软件通过设备文件的读写接口发起大规模DMA读写,通过ioctl等系统调用定制接口,比如寄存器读写等。
FPGA芯片厂商通常提供DMA驱动,对FPGA开发者屏蔽了PCIe/DMA寄存器的操作细节。因此在驱动和硬件两侧看来,驱动设备文件的数据读写接口和AXI总线接口完全对应。
硬件加速器会基于FPGA厂商提供的驱动对应硬件功能做二次开发,一是向runtime屏蔽硬件操作的复杂性,复用操作逻辑,二是保护硬件,防止用户软件对硬件执行非法操作。
对于深度学习硬件后端,驱动的功能主要分为:内存管理,引擎管理,指令调度等。
# 内存管理
驱动最简单的管理策略就是直接放开所有物理内存空间,用户可通过读写接口访问任意内存地址。
但这是个危险的操作,多个用户进程可能直接地址冲突,相互干扰,导致硬件逻辑错误。否则就要修改代码,对每个进程配置一个基地址。对于同构逻辑可能还行,如果是通用逻辑则无法保证均匀切割的地址空间能满足所有进程的需要。
因此,驱动应提供内存管理的功能,在用户进程发起写请求前,显式申请内存空间,并在进程退出后清理所有残留内存空间。
更为安全的做法是使用虚拟内存,将硬件的物理内存地址空间(有可能不连续)映射到连续的内存空间,相应的返回给用户的是虚拟内存地址。用户不能直接操作物理内存地址,同时对用户读写请求做内存地址所有权检查,禁止非法访问。
不过通常安全性和性能是相互限制的,在高频读写场景下,可能会影响系统整体吞吐。对于延迟敏感型应用可能有较大影响,应根据实际场景取舍。可以加入安全模式或低延迟模式的选项,满足不同场景的需要。
内存管理的另一个问题是,内存碎片。
如果采用直白的顺序分配方式,不仅在碎片数量上升的情况下,内存分配时间逐步上升,而且可能会在多轮地址全部分配后出现大量碎片,难以重新分配。
因此一个可以直接想到的方法是,预先将内存切割为大小均等的块,在内存分配时给出适合大小的块,使用bitmap记录内存使用情况。
指令内存和数据内存的大小通常相差很多,可以根据业务场景预设多种尺寸的内存块。这样会浪费一些内存空间,极端情况是场景所需内存恰好超出预设尺寸一些,必须要分配更大尺寸的空间,不过这样设计的好处是,内存管理逻辑相对简单。
如果要进一步优化内存使用率,可以使用均等切块,不连续内存访问的方式,由驱动控制数据的拆分,可能会将单次DMA读写拆分为多次。如此引入的额外性能开销,驱动复杂性和调试难度等,需要结合业务通用程度和团队产能平衡。
# 引擎管理
密集型算子的计算核心对应硬件的计算引擎(计算核),同构结构下多个计算引擎的结构与性能完全一致,可能分别置于多个die上(有时候,为了压低单位算力成本需要高超的FPGA后端技巧)。异构结构可能分为大小核结构和完全异构结构。
使用大小核是为了充分满足设计上限(受限于给定可用资源或面积)。设计标准核的性能算力最佳,但占用资源较多。在设计上限内,不能完整容纳另一个标准核,但可以容纳阉割核(砍计算位宽,降频等)。但大小核功能基本一致,但是性能有差异。
完全异构核是指设计功能不同的计算核,比如拆分计算密集型和长尾通用型计算核,或者针对特定场景(比如推荐)设计特定计算核(比如Embedding)。
驱动设计时需要考虑上述各种情况,可以根据实际应用场景取舍。
一个硬件版本会对应一组硬件结构,可以通过配置文件的形式对应硬件寄存器版本信息。驱动在初始化设备时,创建对应的数据结构,不同类型的核对应不同的引擎类型。对外提供引擎分配,释放,启动,重置接口等。
# 指令调度
对于完全同构引擎,驱动在分配时可以使用独占或共享策略。
独占策略声明引擎的排他性,通常是处于应用低延迟需要。由其是在没有引入指令交叉发射的机制下,共享同一个引擎的应用可能会被block较长时间,造成较高的延迟抖动。
共享策略是为了最大化吞吐,多个计算指令填充队列,屏蔽数据交换的overhead。
异构结构引擎的分配策略类似,需要考虑是否需要区分大小核。
实际上提前由用户进程分配物理引擎并不一定是最优方案,尤其是在用户进程没有全局信息的情况下。驱动可以负责指令的动态调度,使用虚拟引擎屏蔽物理引擎细节。
在分配引擎时,并不直接分配物理引擎,而是分配一个虚拟的引擎(持有类型信息),在实际启动引擎时,使用round-robin策略、按优先级或按负载调度指令。
在某些场景下,硬件物理引擎只有一份,但最大可以处理N条指令(队列深度)。驱动可以通过维护指令队列的方式和硬件结构对应,实时查看硬件的队列深度,但这样会引入轮询开销。
另一种做法是分配N个虚拟引擎对应一个物理引擎,并使用独占的方式,每个引擎阻塞执行,如此一来驱动就不必关心指令队列的实时状态了。总体上看,由软件调度的性能开销和抽象复杂度都较高。
另一种思路是将指令调度下沉至硬件,驱动单纯负责内存分配和数据交换,以及简单的引擎管理功能。
# 性能优化
性能优化是必然要提到的,大体说来就是锁和合并。
多线程应用/多进程应用在驱动中的行为基本一致(除了内存共享),那么最应该注意的就是加锁问题了。
一个是mutex和spinlock,mutex会调用sleep,并切换context,spinlock则会反复尝试unlock直到成功。
另一个是精细加锁,不要在整个函数出入口加一把大锁,非常容易引起性能问题。
中断处理是需要消耗资源的,硬件过多的中断信号可能会丢失,或者造成CPU0占用过高,硬件可以提供中断合并的机制,降低实际中断次数。DMA的启动也有开销,因此需要考虑如何减少DMA读写次数,或者overlap启动开销。