OneFlow的官方文档中提供了一个构造global tensor的例子。构造时指定placement
和sbp
参数就是全局视角的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共享存储。
其中broadcast和partial_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对象。第一个参数是设备类型,目前支持cpu
或cuda
。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中。主要的调用流程如下:
上述各个部分的主要职能如下:
- DataConsistencyCheck会在tensor的placement涉及的各个节点间拷贝数据、校验数据是否一致。
- functional::Empty这几行代码会构造一个local tensor并填充数据。这里和之前讨论local tensor的过程一致。
- functional::Cast进行数据类型dtype的转换。
- LocalToConsistent把local tensor转为global tensor。
- ToConsistent根据placement和sbp对tensor数据和shape进行裁剪。
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。对应的逻辑链条如下:
- GetTensorDevice4CurrentProcessCtx不会设置parallel_id的值。
- GetTensorDevice4CurrentProcessCtx给id_val赋一个空的Optional。
- 在rank=2的进程中,TryGetParallelId返回false。
之前说过,所有进程执行的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这个解释器时,会先获取BoxingExpr,RawMainBoxingExpr是将多个表达式通过Or运算串起来构成一个复合表达式,所以返回的是一个OrBoxingExpr。
得到表达式之后的一个重要步骤,就是获取BoxingFunction,这是实际执行boxing的函数。对于OrBoxingExpr来说,就是逐个校验各子表达式与输入输出是否匹配。找到一个子表达式后,就调用这个子表达式的GetBoxingFunction函数。
RawMainBoxingExpr的每个表达式都有一个名字,可以知道本文例子对应的表达式是名字为symmetric-b-to-s
的AtomicBoxingExpr。这是一个预先注册的函数,对应的BoxingFunction是SymmetricB2S。b-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的数据。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。