仓库源文

.. Kenneth Lee 版权所有 2018-2020

:Authors: Kenneth Lee :Version: 1.0

约束选择


介绍

本文用WarpDrive作为例子讨论一下模块设计的思路。这种问题是一线软件设计工程师在设 计中天天面对的问题,但这个东西很难告诉你:第一步怎么做,第二步怎么做。所以你也 别来找我要什么“:doc:“解决方案” ”,如果我能给你解决方案,我自己就做了,犯不着 跟你浪费时间。你做多了,看多了别人的设计,可能就有机会懂了。但所谓学而不思则罔 ,思而不学则殆。如果你不断看别人的设计,而不去思考别人的重点在哪里,你也只能学 会一些形式。这是本文希望提供的帮助:通过一个例子指出在具体的情形下怎么考虑这些 问题,突出重点在哪里。

这里选择WarpDrive作为例子,是因为它是个公开的平台,而且有足够的复杂度和自由度, 比较容易举例子。这个项目现在是我们的开发团队和Linaro在共同维护,我并不直接干预 其内部设计,所以这里的推演都仅仅是从逻辑上来说,不代表它的实际设计选择。

本文也不需要读者先去了解那个设计的所有背景,在我的逻辑中要用到某个信息,我会直 接在这里提供。

问题背景

WarpDrive本身是个很简单的功能,它就解决一个直白的问题:无论在Host本机中,还是在 虚拟机中,你都可以随时申请加速器设备的支援,把你进程中的指针丢给它,让它完成相 应的计算。WarpDrive提供的是这个地址空间的自动管理能力:只要你申请了和这个设备通 讯的上下文(以下称为Context,Ctx,本体是一个open这个设备得到的fd),你就和这个 设备共享整个进程的地址空间。另外,你还可以mmap它的设备空间,你可以把你要计算的 内存(指针)从这里丢给它,然后等它算完用它的结果就可以了。所以,这个算不上是架 构设计,就是个简单的模块设计而已。但即使如此,作为基础模块,它仍需要非常多的架 构思维才能控制得住方向。

    .. figure:: _static/wd的问题.jpg

WarpDrive的设计基于这样一个高层逻辑:未来的计算多样化,很多专有的计算,比如AI, 压缩解压缩,加密解密,向量计算,循环unrolling等,用特殊的计算单元算,收益比用 CPU算高得多。比如算卷积,CPU需要一个单元一个单元做乘法,然后一个单元一个单元算 加法,每个都要独立的计算Cycle,就算依靠OoO进行自动调度,它和其他功能组合在一起 ,也很难做到高效,而AI计算单元则不同,你把指针给它,它可以一次调度数百个单元, 在一个时钟周期内完成所有的乘加操作。

所以,WarpDrive的设计对象有这么一个特点:申请加速器去做的事情,其实CPU也可以干 。如果加速器的通讯成本太高,那CPU不如自己干。这是这个模块接口设计围绕的中心,是 整个设计的核心约束:一切设计必须以比CPU有优势为目的。

但每种设计都是有边际效应的,比如有些IO方案也能从WarpDrive上获益。比如你做一个网 络通讯,每次发送报文m你在内核中都要经过这样一个过程:::

    dma=dma_map(m)    #让设备看见m
    doorbell(dev, dma)
    ... #等设备读完数据
    dma_unmap(dma)    #让设备看不见m
    free(m)           #释放m

这里通常性能最低的是这里的dma_unmap(),因为你要告诉设备释放所有的相关页表,还要 等它生效和回应。我们看到如果没有这一步,很多IO的性能可以提高70%以上。但不立即 unmap设备又不安全,你让设备持续看到它不应该看到的内存。这种情况下,如果你用的是 WarpDrive,因为设备的信任被限制在用户态了,设备和进程共享页表,设备一直看到整个 进程的空间,根本就没有这个dma_map/unmap()的需要,这样反而可以提高性能。

设计思路

好,背景说完了。我们现在来看我们怎么考虑这个接口封装。我们先用最小的约束来封装 最底层的功能。这样,无论你最终封装出什么接口,反正这些代码你总是要写的,我们可 以有这么几个接口集合:::

    // 设备查找接口
    dev_path[] find_dev(paths_in_sysfs);   //查找所有符合条件的设备

    // 设备访问接口
    fd open(dev_path);             //获得ctx fd
    close(fd);
    virtaddr mmap(fd, offset, len);//获得和设备通讯的的空间
    ioctl(fd, cmd, arg...);        //对设备进行一些受约束的控制(需要内核控制)

这是OS(VFS和sysfs)出的标准接口,它的封装是没有“这是一个加速器”这个约束的,既 然我有了这个约束,我就可以做这个封装:::

    // 设备查找接口
    dev_list wd_find_dev(dev_spec); // 我已经知道设备在sysfs中的位置
                                    // 和它的属性,我可以帮你找设备,
                                    // 存list的数据结构可以用户分配,
                                    // 也可以我们分配,选哪个都有优劣,随便选就行

    // 设备访问接口
    ctx wd_open(dev);               //基于dev这个封装打开ctx,ctx可以封装掉fd和设备属性
    wd_close(ctx);
    wd_mmap(ctx, type);             //对于特定的设备,它的共享空间有哪些,有多大,是固定的,我可以封装掉
    wd_ctl(ctx, cmd, arg...)        //我在ctx中已经有部分参数了,可以为用户封装掉部分的参数,变成wd自己的参数序列

这是底层向上找约束,一般还是比较容易的。

现在我们从顶层向下找约束。比如我要用加速器做一个压缩,最原始的接口需求是这样的:::

    wd_compress(input, output);

如果你能用一个cycle完成这个压缩,这个就是用户要求的接口。但你不是,你需要10s才 能完成这个动作,你看,这是你“下层平台”引入的约束。这个约束到底是否应该引入或者 消除?这我们要盯着竞争对手。

    | 我这里说竞争对手,是个虚拟的概念,不一定是你现在的“友商”,
    | 而是未来你希望获得的客户选择的任何一种方案。如果客户是你的土豪儿子,
    | 你写成什么垃圾,他都得用,而且用了你的垃圾他也不会被其他对手逼死,
    | 这你就没有竞争对手了。否则,只要未来客户换了方案,你的设计就失败了。
    | 我们在面对自由度的时候,最大的约束是这个“竞争问题”:我们希望发展下去,
    | 我们是所有可能的方案中,最有竞争力的一个,用户没有其他选择。

盯着竞争对手,我们假定对手在硬件设计上也没有办法一个Cycle搞定这件事,大家都要 10s,最多优化差别是一两秒。既然都要这么久,你对用户可以有两个预期:在10s内等你 ;请求给你,自己切换出去干点别的,等你搞定再回来。

哪个才是你的目标市场?暂时让我认为这两个都是。那么我们要看怎么调度用户的请求让 他“觉得”更快。

上面定义了问题的模型,具体怎么做我们先放一下,我们先讨论怎么把底层的ctx放上来。

底层需要有ctx这个概念。ctx是一个设备和一个进程匹配的接口。但高层需求中,并没有 这个概念。那么我们是否需要把这个概念暴露出来?这就成了一个“竞争问题”了。不暴露 ctx,实现的时候我申请多少个ctx?有这几种选择:

  1. 每次wd_compress的时候申请一个(用后释放)?这个建立成本很高。

  2. 全局申请一个组,后续请求在这个组里面挑?这个可能过度申请,可能申请不足。

  3. 让用户决定这个组的多少,根据业务量来调整?增加了用户的决策成本。而且业务量动 态变化的时候可能有问题

  4. 动态维护这个组?这个运行复杂度很高,而且可能做了也不讨好。

选择引入哪个约束?

如果只考虑比如鲲鹏的压缩器的能力,这个问题还稍好决定一些,但如果有其他的压缩器 引入呢?这就很难想了。

这种时候我们就要给我们的场景“画像”了。这个“画像”需要覆盖我们眼下马上要响应的一 些市场情形,同时要在概念上有一定的合理性,这样我们才能长远。比如我随便画一个像 是这样的:

硬件的ctx是高成本资源,最小依赖是一个进程要有一个(否则无法通讯),少用一个可以 多支持一个进程,增加ctx不一定能提高算力,但增加ctx可能可以提高通讯带宽,而且增 加加速器可以提高算力。

    .. figure:: _static/加速器模型.jpg 

这时我们有两个选择:

  1. 让用户看见dev和ctx,他自己把业务分解到不同的dev和ctx上。他的工作量大一点。而 且我的数据被天然独立分解了,我不需要调用锁操作(锁变成客户的问题)

  2. 我来给他管dev和ctx,可配置(但有默认值),这样他的工作量少。问题是我的调度算 法能不能至少不比他的调度差?

他有一组线程,做某个计算到某个时候,需要压缩了,调我的函数,如果用第一个方案, 先要找有多少dev和ctx,然后调度,判断也只能是谁当前压力更轻,不会有别的,这个算 法不受其他要素的影响了,他如果能做好,这种策略我也可以用,那么怎么看,我都有能 力给他做这个逻辑代理,不会造成他用我的功能,结果使用成本被收益还大。

所以我们选2,接口变成这样:::

    wd_compress_setup(ctx[], scheduler); //全局初始化
    wd_compress_release();
    wd_compress(input, output);          //数据路径主函数

这个setup全局准备一次,而且未来可以升级为setup2, setup3,换调度算法也不影响程序 的主逻辑。这个自由度还是足够的。

这就同时选择了要我来选定线程库(比如pthread),也限定了用户的选择了。

有了这个基础,我们就可以考虑线程支持模型了。如果只是一个线程来调,这个好办,继 续是这个wd_compress()就行,input下去以后,预期时间长就挂起等设备通知,预期时间 短就直接轮询等回应就可以了。他自己基于wd做,也只能这样。

如果是多个线程来请求,我们就会有流水线问题:一个线程请求下去了,占据某个加速器 计算单元,另一个线程有请求,就只能等着,硬件的计算单元利用起来。这我们会有“竞争 问题”:

    .. figure:: _static/竞争模型.jpg

这唯一的办法是把等回应的线程拆出来。这也有两个选择:

  1. 给个函数,让用户自己去调我的等回应函数;

  2. 我来创建这个线程。

既然我已经选择绑定线程库了,本来我来创建线程也没啥,但我来创建线程,用户处理 signal,是否需要线程合并,设置线程优先级之类的控制,都要我来代理,这个控制成本 又上去了,所以,还是让他来搞,这样我们的接口变成这样:::

    wd_compress_setup(ctx[], scheduler); // 全局设置
    wd_compress_poll(step_count, flags); // 全局设置,要求用户用一个线程调用,
                                         // 以便实现我的polling过程,是否等待,
                                         // poll多少个设备,用flags来控制,
                                         // 让用户有控制的余地
    wd_compress(input, output);          // 数据路径主函数

(如果用户不喜欢,我大不了未来在setup2()增加一个“自动创建poll线程”的功能。)

这个设计有一个破绽:我们前面说,短请求当场等回应,长请求流水线排队,如果我手上 只有一个ctx,两个线程,一个发长请求,一个发短请求,我应该怎么处理?

我们补上这个破绽:如果有长请求已经下去,短请求变成长请求。如果短请求已经下去, 长请求会被阻塞。

    | 不少人觉得这种是“内部实现”,实际上这是外部接口,
    | 因为你需要用户注意到这一点,针对性进行编程。
    | 这种东西不能认为是“内部实现”,如果你认为是内部实现,
    | 就不要事后说“用户不会(懂)用我的接口”,
    | 你需要自己彻底吞下这个逻辑才能认为是内部实现。

    | 注:这里还有一个下一层的推演需要做,就是input和output如
    | 何描述才能适应这种异步抽象。这个问题在高层推演的时候是需
    | 要做的,否则我们对这个接口仍没有信息。但它又确实是一个下
    | 层的逻辑,我把它独立放在补充1中。

这样放约束,后面我们的自由度已经很低了,设计基本上已经完成了。我们最后来看异步 行为怎么做:部分用户会把收尾工作和请求工作分开,希望wd_compress只给input,给完 可以马上给下一个,回应用另一个线程去处理。

这里的关键问题是这个“另一个线程是谁”,一种选择是这个poll,一种选择是用户另外创 建的线程。选那个?我这样判断:如果poll线程的算力足够,都在poll里面做就好了,大 家都方便,唯一的问题是如果poll里面回调output处理,会影响poll的实时性,影响其他 人的使用:

    .. figure:: _static/polling时间轴.jpg

如果有这种情况,用户做还是我们做,结果都是另起一个处理线程,然后在poll线程里面 notify它,这个问题我们去代理它,不会减轻用户的工作量,那不如不做。最后我们的接 口就变成这样了:::

wd_compress_setup(ctx[], scheduler); //全局设置
wd_compress_release();
wd_compress_poll(step_count, flags)  //全局设置,要求用户用一个线程调用,
                                     //以便实现我的polling过程,是否等待,
                                     //poll多少个设备,用flags来控制,
                                     //让用户有控制的余地
wd_compress(input, output);          //数据路径主函数
wd_compress_async(input, call_back); //异步请求 

这样,我们在这一层的定义推演就完成了。

响应变化

好了,我们再看这个基础推演在遇到新需求的时候是怎么去响应的。

假定这是来这样一个需求:要处理流式请求。比如我们用哈夫曼编码来做这个压缩,那么 我压缩第一段的时候生成的哈夫曼树,压缩第二段的时候要读取和更新这个上下文。这种 处理模型应该如何封装?

这在硬件上首先有两个选择,一种是这个流的上下文和ctx绑定,这种情况下,每个流需要 一个ctx,我们的所有假设都不成立。但我们前面的推演没有覆盖这种能力,那这个 wd_compress的库整个都应该放弃,我们应该在wd的基础接口上重建一个模型来处理这种情 形。

这就是架构控制要起的作用,一旦约束形成了,你如果不控制建构,强行把两个不能组合 的概念空间组合在一起,这个东西玩不远。

第二个选择是发请求的时候,每次都把流的状态发下来。我们加速器无条件用这个流状态 来完成算法,这种情况下,这个流状态,只是input的一部分。我们前面的逻辑全部仍成立 。这种把新的功能全部适配到原来做过的一个抽象概念中,设计上是最安全中的,我们前 面保证逻辑严密性的推演全部成立。

如果我们实在想封装一下,让用户感受更好,我们可以独立与前面这个抽象,再拉高一层 ,增加这样的接口:::

    stm wd_stm_compress_create_stream(); //创建带上下文记录句柄
    wd_stm_compress_destroy_stream(stm);
    wd_stm_compress(stm, input, output);  //可以组合stm的input
    wd_stm_compress_async(stm, input, call_back); //异步接口

我们不一定可以直接把这个抽象在wd_compress上,因为原来的接口可能没法让stm和input 合并,但这个就是细节问题了。因为我们完全可以在wd_compress()上加一个 wd_compress_with_stream_ctx()来补充这个抽象。

总结

总结一下,我们的分层模型在前面的推演中,就自动被分离出来了:

    .. figure:: _static/wd开发模型.jpg

它成了这个样子,全部都是细节决定的,你在业务抽象的时候就想决定这一个结论,就只 能犯错。我们能这样封装,是把很多个细节组合在一起,进行挑选,找到有共性的地方进 行设计补充,让原来没有规律的细节,变得有规律。

以为架构设计可以用一个忽略具体细节的原则,定义成1,2,3,4,5的原则和步骤的,只 是懒人的望天打卦,异想天开。

我们从这个结构上也可以看到,这样的一个分解过程,每个模块其实都吃下了一组依赖, 用这组依赖形成一个复杂逻辑的简化:

补充1:关于input和output数据的表达方式

我们前面用input和output这两个很粗糙的概念表示给加速器的输入和输出数据,它可以是 个链表,连续的内存,树等等数据结构。但这个通用的压缩接口,要变成不同的硬件合适 的方式,它的抽象也是一个重大的决策。

我们应该要求用户一层如何提供input和output?当我们把输入数据和polling的过程分开 的时候,output如何找到原来发进来的input?

首先,这个问题硬件肯定是有办法的,否则它干脆就支持不了上面说的这种使用方式。( 而且我们不担心他做成这样,因为这样肯定可以被看作后面无论我们用什么方法解决,它 的队列长度为1的特例)。

硬件通过一个tag(比如一个动态分配的id),下发input,然后里面肯定有待压缩数据, 这个数据可能是连续的,也可能是scatter-gather的,如果硬件不支持scatter-gather, 用户给这样的数据下来,硬件也处理不了。

对于WarpDrive这样一个具体的情形,我们代表的是用户的利益,不是代表硬件的利益,硬 件不支持某种能力,让硬件自己死去。所以,我们可以把input和ouput设计成 scatter-gather-base的,非sg数据是sg的一种特殊情形,如果硬件不支持,下面就用 Bounce Buffer(人为拷贝在一起)来支持好了(这个动作初期可以交给驱动自己)。

这样,每个input我们都有我们的格式要求,我们可以在这个数据结构中留一些私有空间放 这个tag,给驱动用,这样两者的对应关系就可以建立了。这其中我们可能还需要给 wd_comp框架的私有空间,用来放比如线程管理的信息,比如pthread_cond,用于通知等待 的线程等。

但我们还需要一个池子:每次有一个请求下去了,我们要把请求放到池子中,以便output 回来的时候我们可以从池子中匹配对应的input。

这个结构需要的所有信息都在wd_compress_xxx这一层的逻辑中,很显然,我们要 wd_compress这一层把它吃下去。