OneFlow的官方文档中提供了一个构造global tensor的例子。构造时指定placementsbp参数就是全局视角的global tensor,数据可以分布在多个节点上。
在单机CPU环境下,可以启动3个进程、每个进程设置不同的环境变量,运行如下Python代码就可以创建一个简单的global tensor。

# export MASTER_ADDR=127.0.0.1 MASTER_PORT=17789 WORLD_SIZE=3 RANK=0 LOCAL_RANK=0
# export MASTER_ADDR=127.0.0.1 MASTER_PORT=17789 WORLD_SIZE=3 RANK=1 LOCAL_RANK=1
# export MASTER_ADDR=127.0.0.1 MASTER_PORT=17789 WORLD_SIZE=3 RANK=2 LOCAL_RANK=2
import oneflow as flow
P0 = flow.placement("cpu", ranks=[0, 1])
a0_sbp = flow.sbp.split(0)
A0 = flow.Tensor([[1,2,3],[4,5,6]], placement=P0, sbp=a0_sbp)

1 sbp

sbp由split, broadcast, partial的首字母组合而成

  • split表示物理设备上的tensor,是将global tensor切分得到的。
  • broadcast表示global tensor会复制并广播到所有的物理设备上。
  • partial表示global tensor与物理设备上的tensor的形状相同,但是物理设备上的值,只是global tensor的一部分。

Python端flow.sbp包定义了split等3种类型。其C++ binding代码在sbp_symbol.cpp中。这些类型都是SbpParallel类型,是protobuf message对象。三种类型通过oneof parallel_type共享存储。

其中broadcastpartial_sum都是空消息,赋值时需要调用mutable方法显式表明oneof字段具体是哪种类型。
split的值表示在tensor的哪个轴上切分数据。轴的index值是一个[[0, 5]之间的整数](https://github.com/Oneflow-In...)。所有的split SbpParallel对象被保存到一个静态vector中。

2 placement的构造

placement属性指定tensor存放在哪些物理设备上。或者,在纯CPU环境下,tensor存放在哪些进程中。
在上述例子中,flow.placement("cpu", ranks=[0, 1])创建一个placement对象。第一个参数是设备类型,目前支持cpucuda。ranks表示设备列表,tensor将分布在这些设备上(根据sbp的指示切分或广播数据)。
ranks只列出了rank id(全局唯一),没有指定节点host。rank与host关系是根据环境变量确定的。环境变量RANK表示全局唯一的rank id,LOCAL_RANK表示节点内的本地rank id。在GPU环境下,一般一个进程对应一块设备WORLD_SIZE表示所有节点的设备(进程)总数。

oneflow包在初始化时,会根据环境变量在各个节点间建立控制面通信连接,以及数据面通信连接。这样每个进程就知道有多少个节点、有多少个设备/进程、当前进程在整个集群的位置。

通过placement的构造函数绑定可以知道,其对应的C++类型是ParallelDesc。对象构造由函数CreateParallelDescSymbol完成。主要调用流程如下:

2.1 确定machine和device

ParseAndFormatRanks会将ranks数组[0, 1]转为形如"machine_id:device_id"的字符串数组,供后续处理使用。这里的逻辑决定了如何根据ranks中的id,确定tensor数据在节点和设备上的分布:

从上述公式可以看出,各个节点的设备/进程数量需要是一致的。

2.2 构造并缓存ParallelDesc对象

CreateParallelDesc函数完成ParallelDesc的构造。其中MakeParallelConf会先根据"machine_id:device_id"等数据构造一个cfg::ParallelConf对象,这是一个类似oneflow::ParallelConf的类型,文件位于build/oneflow/core/job/placement.cfg.h,是cmake构建过程中自动生成的文件。

cfg::ParallelConf等对象的接口类似protobuf message,但实现了hash方法,可以作为hash map的key。

之后的PhysicalRun虽然涉及虚拟机,但指令列表应该是空的,实质性的逻辑只是调用builder->GetParallelDescSymbol,其中的核心逻辑是FindOrCreateSymbolId,创建对象并缓存,后面的Storage::Get只是获取结果。

FindOrCreateSymbolId主要实现两级缓存:

  • 首先通过IdCache<cfg::ParallelConf>实现从cfg::ParallelConf到symbol id(64位整数)的映射。这可能也是为何不直接用protobuf的原因之一(因为protobuf不能实现稳定的hash)?
  • 其次通过Storage<ParallelDesc>实现从symbol id到ParallelDesc的映射。

最终根据cfg::ParallelConf可以直接获取之前缓存的ParallelDesc对象。

CreateParallelDesc的详细调用流程如下:

3 global tensor构造调用流程

下面以本文开始的例子分析一下构造global tensor的调用流程。这可能不是一个典型的场景,只是人为指定的数据便于debug。

通过之前讨论local tensor时的类关系图可以知道,EagerConsistentTensorImpl内含一个local tensor的变量。可以想象,构造global tensor时,会先构造一个local tensor、再做一些后续处理。

Python端创建tensor对象时,如果像本文开始的例子那样指定placement、sbp和数据,对应的Functor是ConsistentTensorWithDataCtorFunctor。核心逻辑在MakeConsistentTensorFromData中。主要的调用流程如下:

上述各个部分的主要职能如下:

3.1 数据一致性校验:DataConsistencyCheck

OneFlow在各个节点的所有进程都执行相同的Python用户脚本。本文开始的这个例子,tensor的数据是人为指定的(也可能是从record文件读取的),所以相关节点需要校验一下数据的一致性。通过其它方式构建tensor可能就不需要这个步骤(比如randn)。

数据的传输和比较只在与该placement相关的进程之间进行。比如rank=2的节点就不会处理示例tensor的数据校验,在这里会直接返回。

placement相关的进程构成一个环(Ring),环中的每个节点给下一个节点发送数据,从上一个节点接收数据,并比较数据是否一致

3.2 数据类型转换:Cast

在这个例子中,Functor强制指定了tensor的数据类型是float

而Python端如果用整数,local_tensor的dtype是int64;如果用浮点数,dtype是double。所以Cast就是将这些数据类型统一转为float。

这里其实没想太明白,local tensor不需要cast,为何global tensor需要cast呢?毕竟各个进程执行的代码都是一致的。

Cast执行的主要流程如下。CPU下的转换操作在CastCpu内完成。

3.3 local到global的转换

LocalToConsistentFunctor的op就不是UserOpExpr了,而是CastToConsistentOpExpr。而当前的inputs tensor还是local的,所以会进入对应版本的ApplyImpl执行。在ApplyImpl中,主要执行LocalToConsistent函数。WithConsistencyChecked这一大段代码,因为下面两处的作用会直接返回

LocalToConsistent是在RawLocalToConsistent上加了一层装饰

RawLocalToConsistent的作用主要是构造一个global tensor并与之前的local tensor建立关联。但返回时,global tensor的数据和shape仍和之前完整的local tensor一样,tensor_meta也保留了tensor所在的设备等元数据信息。

RawLocalToConsistent的调用流程如下:

需要注意的是,对于rank=2的进程来说,示例中的tensor与它无关,不会设置local tensor。对应的逻辑链条如下:

之前说过,所有进程执行的Python代码都是一样的,但是对于rank=2的进程来说:

  • 从开始到CastFunctor,执行的逻辑与ranks=[0,1]的进程一致。
  • 但是在LocalToConsistent这一步,不再保留local tensor。后续的global tensor就是一个空的tensor,但是shape等信息仍与其它节点一致。

3.4 shape和数据的裁剪:ToConsistent

ToConsistent根据placement和sbp对tensor数据和shape进行裁剪。其对应的Functor是ToConsistentFunctor。因为输入本身就是global tensor,所以会转给ConsistentToConsistent执行。其中的op类型是ConsistentToConsistentOpExpr。经过Dispatch后会进入对应的ApplyImpl执行,再进入RawConsistentToConsistent

RawConsistentToConsistent核心逻辑在于调用RecursiveGetBoxingOutput,对tensor进行裁剪。这之后,如果进程与tensor相关(rank=0或1),进入if的第一个分支,将tensor结果赋值给outpus。如果进程与tensor无关(rank=2),进入第二个分支,给outputs赋一个空的tensor。

RecursiveGetBoxingOutput是用CheckConsistentTensorMeta修饰的CalcBoxingOutput。裁剪的核心逻辑由CalcBoxingOutput实现。

3.4.1 tensor元数据校验:CheckConsistentTensorMeta

顾名思义,CheckConsistentTensorMeta的作用是校验各个节点的tensor的元数据,核心是调用LaunchTensorMetaConsistencyCheck,tensor相关的每个进程会执行4次节点间通信以及数据比较。如果元数据被缓存后,就只需要LaunchTensorMetaConsistencyCheck中的一次通信。

通信需要传输校验如下数据:

3.4.2 tensor数据裁剪:CalcBoxingOutput

Boxing是不同sbp的tensor之间的自动匹配机制。CalcBoxingOutput应该是Boxing机制实现一部分。这个例子的情形也比较简单,只是对broad tensor进行裁剪。CalcBoxingOutput的调用流程如下:

首先会获取一个EagerBoxingInterpreter对象boxing_interpreter,实际的类型是NaiveEagerBoxingInterpreter

构造boxing_interpreter这个解释器时,会先获取BoxingExprRawMainBoxingExpr是将多个表达式通过Or运算串起来构成一个复合表达式,所以返回的是一个OrBoxingExpr

得到表达式之后的一个重要步骤,就是获取BoxingFunction,这是实际执行boxing的函数。对于OrBoxingExpr来说,就是逐个校验各子表达式与输入输出是否匹配。找到一个子表达式后,就调用这个子表达式的GetBoxingFunction函数。

RawMainBoxingExpr的每个表达式都有一个名字,可以知道本文例子对应的表达式是名字为symmetric-b-to-s的AtomicBoxingExpr。这是一个预先注册的函数,对应的BoxingFunction是SymmetricB2Sb-to-s应该是broad to split的缩写。整个意思应该是把一个完整的广播tensor均匀切分。在这个函数中会确定切分的参数start, stop和step

在CPU环境下,流程会执行到SliceKernelUtil::Forward。其中SwitchDoForward是通过宏定义的一个函数。宏展开后的代码类似下面这样。实际会进入DoForward执行裁剪后的数据拷贝。

  template<typename... Args>
  static void SwitchDoForward(const std::tuple<int32_t>& switch_tuple, Args&&... args) {
    static const std::map<std::tuple<int32_t>, std::function<void(Args && ...)>> case_handlers{
        {SwitchCase(1),
         [](Args&&... args) {
           return SliceKernelUtil<DeviceType::kCPU, T>::DoForward<1>(std::forward<Args>(args)...);
         }},
    };
    return case_handlers.at(switch_tuple)(std::forward<Args>(args)...);
  };

4 用randn构造随机tensor

randn这种操作就不需要进程间的数据交互,各个进程只生成自己负责的数据就可以了。比如下面的例子:

import oneflow as flow
P0 = flow.placement("cpu", ranks=[0, 1])
a0_sbp = flow.sbp.split(0)
A0 = flow.randn(4, 5, placement=P0, sbp=a0_sbp)
# print(A0)

randn是一个UserOpExpr,global版本对应的Functor是ConsistentRandNFunctor,会进入Interpret执行。
EagerConsistentTensorImpl::New时会调用GetPhysicalShape获取local tensor的shape。randn的kernel只生成local tensor的数据

参考资料


郑建华
1 声望4 粉丝