masonry的约束应该写在哪里_约束选择

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

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

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

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

841a4ddc8e755ecd25b40dc6bf685285.png

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可能可以提高通讯带宽,而且增加加速器可以提高算力。

49a8c7b699fe6998c5a970919d9fb15b.png

这时我们有两个选择:

  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做,也只能这样。

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

fe111c1b85c3aedb49778fc624b1bbc2.png

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

  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的实时性,影响其他人的使用:

81cfe98f5bbcffbfb37f02331f5b7413.png

如果有这种情况,用户做还是我们做,结果都是另起一个处理线程,然后在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()来补充这个抽象。


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

41c4114712ad80d379f0b99fd402b70f.png

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

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

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

  • wd_lib依靠vfs接口和内核wd设备提供的公共能力,吃下wd的vfs接口,封装为“有算法名字和要求就得到一个访问的上下文,上下文提供设置设备和直接和设备通讯的接口”
  • wd_comp要求每个vendor_drv提供把压缩请求发下去和收上来的能力,吃下这个接口和线程调度的复杂度,提供在不同的请求进来的时候,调度多个CPU(线程)和多个加速器的执行时机。
  • wd_stm_comp完全依赖wd_comp的封装,独立管理一个公共的stream概念,避免每次下发

补充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这一层把它吃下去。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值