以上图为例,方块表示op,一共有4个op,圆圈表示data(从生产者视角看A实际上只有一份,从消费者视角看有两份,因此画了两遍)。如图所示,{B=A+1}和{C=A+2}之间没有依赖关系,它们之间的执行顺序是未规定的,只要计算资源充足,它们可以并行的执行。如果翻译成命令式程序,以下两个代码片段都是合法的:
基于静态图的深度学习框架都借助一个依赖引擎(Dependency Engine)模块来管理操作之间的依赖关系以及每个操作的触发时机。
依赖引擎通常的实现方法是:为每个代表数据的圆圈设置一个计数器,用于表示该数据是否就绪。计数器初始值是0,当数据的生产者执行结束会把计数器变更为1,当数据的消费者看到计数器变成1了,也就可以读取这个数据了。上图这个例子中,{D=B*C} 这条操作的触发条件是B和C对应的计数器都变为1。
基于静态图机制的深度学习框架,包括TensorFlow和MXNet,执行引擎(executor)都是采用这样的机制来管理op之间的依赖以及每个op的触发时机。
如果不同的op在同一个进程内的不同线程上执行,多个线程可以通过共享内存机制并发访问计数器,只不过为了解决并发读写间的竞争,每个计数器需要加锁或使用原子变量。
如果不同的op分布在不同的进程乃至不同的机器上,跨机器的依赖关系该怎么表达和管理呢/p>
3
问题:分布式版本的依赖引擎
对于可以借助NCCL这样的集群通信原语(all-reduce, all-gather, reduce-scatter等)支持的分布式场景(数据并行、模型并行等),各个机器上的子图在计算图层面“看上去”并没有依赖关系,每个节点可以当作完全独立的计算图来管理(这个依赖关系是通过底层集群通信隐式实现的),在执行引擎层面的复杂度反而不大。
但是,当面临”不规则的通信模式“,一个跨机器的依赖关系是通过peer to peer通信来实现生产者和消费者的交互时(生产者在一台机器上,消费者在另一台机器上),就引入一些微妙的问题,复杂性还超过了那些可以被集群通信原语解决的场景,下面只讨论这种情况。
当计算图中的op以peer to peer的方式被划分到不同的机器上时,功能上需要支持:(1)生产者的数据从生产者所在的机器发送到消费者所在的机器上;(2)状态计数器到底该放在生产者所在的进程还是消费者的进程上3)如果放在消费者的进程上,生产者通过什么办法去修改这个计数器/p>
4
TensorFlow对跨机器数据搬运的抽象
让我们看看TensorFlow如何解决生产者-消费者跨 络问题。
6
send和recv:二选一
从抽象层面看,如果我们定义send操作的职责是把数据发送(Push)到另外一台机器上,以及定义recv操作的职责是把另外一台机器上的数据拉取(Pull)到本地,会发现无论是生产者主动推送(Push),还是消费者主动拉取(Pull),send和recv二者只需其一。如果非要同时引入,那么send和recv中总有一个操作仅仅扮演了placeholder的角色,不做实际的操作。
更进一步,同时引入send和recv不仅仅是引入多余概念的问题,而是破坏了系统的一致性(回想一下《人月神话》如何强调概念一致性的重要性):一个使用数据流抽象的系统可以使用Push语义,也可以使用Pull语义,但同时使用Push语义和Pull语义,会让系统难以理解。
如果是消费端主动把数据Pull过来,那么send就是多余的,TensorFlow的send端总是等待recv端发拉取数据的请求,可以认为采取了Pull语义,send是多余的, send仅仅是占位符的作用。
7
Push or Pull/strong>
单纯从跨机器的数据搬运的功能需求来看,Push和Pull二者是平等的,并没有优劣之分 (扩展一下,RDMA同时提供了READ和WRITE的单边操作)。要做出抉择,就需要在更大的上下文去考察哪个语义更好了,我们希望使用一个语义可以同时cover单机内部的操作和跨机器操作。
单机器内部的op之间有两种类型的交互行为,一类是状态的传递,在这个层面是一种Push语义,譬如生产者执行完毕之后会主动修改消费者所依赖的计数器,从而把状态Push给消费者;另一类是数据的传递,在这个层面是一种Pull语义,譬如生产者并不会把数据推送给消费者,而是消费者根据自己的需要去读取生产者产出的数据,这种Pull语义的出发点是Zero-copy,效率高。
显然,我们在选择send op还是recv op时,功能需求是数据传递,我们应该选择Pull语义。从这个角度看,PyTorch的Push语义是不自然的,TensorFlow的Pull语义是自然的,不过它的send是多余的。
OneFlow采用了Pull语义,无论是机器内部还是跨机器的关系,消费者op总是从生产者op那里拉取数据,因此只引入了recv,而没有引入send。
通过统一采用Pull语义,在计算图层面的概念上实现了统一,用一句话就可以把系统的数据流动机制解释清楚:机器内部消费者读取生产者产出的数据和跨机器的数据拉取在语义上是一致的,只不过生产者和消费者同在一台机器时,消费者从自身所在的机器读取,并写入到同一台机器上,而当生产者和消费者不在同一台机器时,消费者(recv)是从另一台机器读取而写到所在的机器上而已。
这种概念的一致性会降低认知负担,只需要用最简单的Pull规则就能同时解释单机和多机的情况。
以上讨论的是抽象层面,在实现层面是另一个问题。如果底层通信使用RDMA,那么一个READ的单边操作就可以把Pull支持起来,如果底层是套接字(socket),那么底层要通过send和recv的配合来实现Pull的语义,OneFlow的CommNet模块恰恰是这么实现的。
8
小结
依赖引擎是深度学习框架的核心模块,基于共享内存的计数器机制是实现单机依赖引擎的常见方法。
当引入分布式计算时,跨机器的依赖关系通常借助“消息传递”来实现(当然,如果有分布式共享内存机制的支持,继续使用共享内存的计数器机制也不是不可以)。
TensorFlow在使用“消息传递”实现分布式依赖引擎时引入了多个多余的概念,并不符合“如无必要,勿增实体”。当去除多余的抽象之后,TensorFlow的实现上可以被简化。
借助这个”吹毛求疵“的过程,我想分享的是:只有对极简的偏执追求,才会得到事物的本质。
你可能注意到:单机器内部依靠共享内存的编程模式,跨机器使用消息传递的编程模式,这里又引入了不一致性,这是不是一个问题呢们在下一篇文章继续讨论,敬请期待。
注:题图源自Pixabay, xresch
其他人都在看
-
再谈“去虚拟化”对深度学习系统的必要性
-
OneFlow v0.4.0 正式发布
-
动态调度的“诅咒”③
-
数据搬运的“诅咒”②
-
资源依赖的“诅咒”①
点击“阅读原文”,欢迎下载体验OneFlow新一代开源深度学习框架
文章知识点与官方知识档案匹配,可进一步学习相关知识Java技能树首页概览92312 人正在系统学习中
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!