暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

TF中令人困惑的通信机制——Rendezvous(一)本地传输

西门宇少 2020-11-02
1474

二少决定不定期和大家分享TensorFlow底层的架构设计和源码,这些可能有助于做性能优化,推进落地~


建议阅读时对着代码梳理~


讲技术,也谈风月,更关注程序员的生活状况,

欢迎联系二少投稿你感兴趣的话题。





1

 高逼格的Rendezvous




这本是一个和系统八竿子打不着关系的词——Rendezvous。它是法语词,译为:“约会、相会”。


在TensorFlow系统里,这个词却成为了通信的抽象。不得不说,代码中甩几个法语词,逼格确实升了那么一些,它能让一些源码读者望而却步。


但,通信就是通信,换个名字也不会改变它本身的作用。从今天开始,我将Rendezvous分成多个系列,由浅入深分开梳理其源码和结构。


今天这一篇,我们将揭开Rendezvous整体结构的面纱,并详细阐述最简单的场景——本地传输消息的过程。




2

 唯一标识符——ParsedKey




我们先暂且把Rendezvous放到一边,思考一个通信的基本问题。


想象一下,当一个十分复杂的系统中,同时存在多组Send方和Recv方时,它们如何知道每一个消息应该被谁接收,保证不会错位?


在TensorFlow中,如果Recv端本打算接收的消息是A,但由于消息对应错误导致接收到了B,那么训练过程就完全错乱了。这里的关键所在,就是为每一对Send和Recv梳理一个对应关系。



其实解决这个问题也非常简单,因为每一对Send和Recv所处理消息都是同一个,所以只要让某个消息在被Send前加上一个唯一标识符而Recv在接收消息前也能够按照某种规则拼出一样的唯一标识符,这个对应关系就完美解决了。在TensorFlow中确实定义了这样一种标识符,它就是结构体ParsedKey。


1. ParsedKey结构体

我们可以直接在tensorflow/core/framework/rendezvous.h类内找到ParsedKey的定义,它分为以下六个部分。我们大概了解一下他的结构,并不用记住它,因为我们后面构造Key的时候并不需要依次填入这六个field。



结构非常简单,一个完备的ParsedKey要包括六个部分。

  • src_device:消息发送源的字符串信息,形如/job:localhost/replica:0/task_id:0/device:GPU:0

  • src:和src_device的信息量相同,只不过是结构体的表示方法

  • src_incarnation:一般来说这个字段没有什么作用,但是当某个worker重启后,该值会发生变化,用来和之前挂掉的worker做区分,这便于debug

  • dst_device:消息发送的接收方字符串信息,格式和src_device相同

  • dst:和dst_device的信息量相同,只不过是结构体的表示方法

  • edge_name:这个字段是该Key最特殊的地方,它可以灵活指定为任何字符串,实现不同Key的区分。比如它可以是Tensor的名字,也可以是具有某种特殊意义的固定字符串


2. CreateKey过程

在TensorFlow中,构造ParsedKey,要通过官方安全的静态API函数——CreateKey,而不是手动set。


下面是CreateKey函数构造Key字符串的过程展现。


CreateKey只要接受五个参数即可安全构造字符串形式的Key,这里面特殊之处有两个。

  1. 参数中frame_and_iter一般直接取自OpKernelContext中的FrameAndIter对象(暂且不用管其意义);

  2. src_incarnation要做一个十六进制的字符串转换。CreateKey函数的输出是以分号(";")为分隔符的字符串,该字符串同样包含五个域。


3. ParseKey过程

同样,解析ParsedKey,也要通过官方安全的静态API函数——ParseKey(注意不是ParsedKey哈!)。


下面是ParseKey函数构造Key字符串的过程展现。



ParseKey对输入字符串的前四个域做了映射,抛弃了第五个域,但是在提供Key字符串时需要提供完整的五个域,否则会检查报错。




3

 消息的约会——Rendezvous




终于轮到Rendezvous了,最基本的Rendezvous类被定义在了tensorflow/core/framework/rendezvous.h文件中,它对外提供了最基本的Send、Recv和RecvAsync接口和实现。


接口代码如下:


// Send() never blocks.
virtual Status Send(
    const ParsedKey& key,
    const Args& args,
    const Tensor& val,
    const bool is_dead)
 
= 0;
 
virtual void RecvAsync(
    const ParsedKey& key,
    const Args& args,
    DoneCallback done)
 
= 0;

// Synchronous wrapper for RecvAsync.
Status Recv(const ParsedKey& key,
    const Args& args,
    Tensor* val,
    bool* is_dead, int64 timeout_ms)
;

Status Recv(const ParsedKey& key,
    const Args& args,
    Tensor* val, bool* is_dead)
;

复制


总体来说这个类还是比较抽象的,在不同的通信场景下需要提供不同的实现。


  • 本地传输场景,TensorFlow提供了LocalRendezvousIntraProcessRendezvous实现类。

  • 跨进程通信场景,TensorFlow提供了RemouteRendezvous实现系列。


不同通信场景的实现细节差别相当大,所以本系列将对这些做逐个梳理,本文只关注本地传输部分。如果对跨进程传输感兴趣,那么请关注该系列的下一篇文章。



1. Rendezvous相关类结构

在了解通信过程之前,应该先熟悉下Rendezvous相关的类结构。下面的类图展示了当期TensorFlow系统中所有的Rendezvous相关类图结构。



所有的Rendezvous相关类都以Rendezvous基类为核心,LocalRendezvous和IntraProcessRendezvous是我们本文分析的重点,SimpleRendezvous实现非常简单,读者可以在熟悉前两个实现之后自行分析该类。而BaseRemoteRendezvous类以及相关类是跨进程通信相关的组件,这部分内容将在下一篇文章中分析。


2. Rendezvous基类中的Recv函数


抓住Recv函数,你就抓住了TensorFlow中通信的本质。为什么这么说?因为真正传输数据的是Recv在做。


先摆出一个实质原因:


Send只是把消息挂到队列里,而Recv主动过来拿数据!


Send只是把消息挂到队列里,而Recv主动过来拿数据!


Send只是把消息挂到队列里,而Recv主动过来拿数据!


重要的事说三遍!


从源码上看,同步版本的Recv函数封装调用了RecvAsync函数,所以子类只需要重写RecvAsync函数即可。下面是基类中,Recv函数的代码实现。


1 Status Rendezvous::Recv(const ParsedKey& key, const Args& recv_args,
2                         Tensor* val, bool* is_dead, int64 timeout_ms) {
3   Status ret;
4   Notification n;
5   RecvAsync(key, recv_args,
6             [&ret, &n, val, is_dead](const Status& s, const Args& send_args,
7                                      const Args& recv_args, const Tensor& v,
8                                      const bool dead) {
9               ret = s;
10               *val = v;
11               *is_dead = dead;
12               n.Notify();
13             });
14   if (timeout_ms > 0) {
15     int64 timeout_us = timeout_ms * 1000;
16     bool notified = WaitForNotificationWithTimeout(&n, timeout_us);
17     if (!notified) {
18       return Status(error::DEADLINE_EXCEEDED,
19                     "Timed out waiting for notification");
20     }
21   } else {
22     n.WaitForNotification();
23   }
24   return ret;
25 }

复制


可以看出,无论RecvAsync的实现内容是什么,Recv函数都可以将RecvAsync视为黑盒,在其上层封装成为与RecvAsync相同实现的同步函数版本。




4

 本地传输过程




重点来了!


使用本地传输过程包括LocalRendezous和IntraProcessRendezvous两个实现类,但是后者是前者的封装,因此本文分析的重点在于LocalRendezvous实现类。


1. 消息队列的缓存——Table

在TensorFlow中,几乎每个Rendezvous实现类都有自己的消息队列缓存,而几乎每种消息队列缓存都是依靠Table实现的。


Rendezvous的发送(Send)和接收(Recv)都将通过在Table中完成“约会”


下图形象化的表示了Table以及Table中的每个Item。



在LocalRendezvous实现类中,Send端和Recv端使用的是同一个Rendezvous对象,所以他们共享同一个Table,所以Table属于临界资源,应该加锁形成互斥访问。Item这个结构中其实有很多内容,在上图中只解释两个比较重要的部分。


  • Value:这就是参与通信Tensor本体

  • Waitor:这是在确认Tensor被接收端完成接收后的处理函数,也就是consumer处理该Tensor的函数过程


2. 传输过程分析

无论是Send过程还是Recv过程,它们都将借助Table完成Tensor的转发。


Send过程作为Tensor的生产者,它负责将待发送的Tensor送入Table中,并将ParsedKey作为该Item的键。而Recv过程作为消费者,它也会根据自己所需拼出相同的ParsedKey,然后从Table中查看是否已经存在该项。



但应该注意的是,Tensor虽然由Send端生产,但是Table中的Item却不一定是由Send端插入


因为在TensorFlow中,Send和RecvAsync二者的相对顺序是不能保证先后的,经常出现需求比供给在时间片上先到的情况,那么这时就会出现RecvAsync先拼出了ParsedKey然后立即查表的情况。


我们知道,在Send和RecvAsync顺序相对异步的情况下,waitor函数的执行时机只有两种情况,它取决于Send的供给和RecvAsync的需求哪一个先到达。若生产者先到达,那么waiter函数的调用由RecvAsync执行。若消费者的需求先到达,那么waiter函数的调用由Send执行


简而言之,总是迟到的一方执行waiter函数


那么可以这样设计:和Send端相同,允许RecvAsync将所需的Item插入到Table中,并连同waiter函数一起发送到该表里。如果Send端后到达,那么Send函数将从表中取出该Item,并执行waiter函数。反之,则由RecvAsync函数取出自己所需要的Item,然后执行waiter函数,下面的图展示了这个过程。




3. 关于IntraProcessRendezvous的Send和RecvAsync函数

其实本质上IntraProcessRendezvous和LocalRendezvous是同一个函数实现,只是前者对后者做了一层封装。


我们从源码中看到,LocalRendezvous是IntraProcessRendezvous的成员之一,只是在回调函数中多了一些简单的处理而已,比如它会仔细考量Tensor的生产方和消费方是存在于CPU还是GPU,是否可以通过P2P直接拷贝,还是需要通过Host做中转,关于拷贝过程使用的是下面的函数,其他地方大同小异,因此不再赘述。


有兴趣的读者可以到tensorflow/core/common_runtime/目录下参考rendezvous_mgr.h、rendezvous_mgr.cc和copy_tensor.h与copy_tensor.cc这几个文件。




5

 总结




本文是TensorFlow通信机制系列的第一篇文章,先通过抛出高并发情况下消息通信两端的对应问题引出TensorFlow中的ParsedKey结构设计的必要性,然后给出了Rendezvous全局类图,最后详细的分析了LocalRendezvous的消息传输实现过程。


TensorFlow的通信机制的完美的阐释了Rendezvous一词的含义——无论是Send端还是Recv端都需要在临界资源Table中“约会”,进行消息的传输。随后还着重分析了异步情况下,本属于consumer的waiter函数调用时机设计问题——为了保证waiter函数的执行不被阻塞,从设计上采取Late invoke的方案。


IntraProcessRendezous本质是LocalRendezvous的一层封装,它在数据拷贝上面做了更多的工作,借助LocalRendezvous实现了Send和Recv处于不同或相同种类Device情况下,对上层完全透明的拷贝过程。


由于篇幅原因,特意将TensorFlow通信机制分为多个系列分析,作为第一篇文章,本篇介绍了Rendezvous的基本框架。在该系列之后的文章中,还会对跨进程的通信进行详细地分析。



讲技术,也谈风月,更关注程序员的生活状况,欢迎联系二少投稿你感兴趣的话题。

文章转载自西门宇少,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论