浅谈在线并行计算框架

浅谈在线并行计算框架

1. 背景

并行计算我们并不陌生,Hadoop, Spark,甚至最近特别火的深度学习模型的 GPU 加速,都是充分发掘了并行计算的潜力。业界涌现出很多多线程编程框架,典型的如brpc。但是随着业务的越来越复杂,以单条请求维度的并行处理已经不能满足业务在请求过程中的延时需求。在这种情况下一般有两种的优化思路:

  • 数据并行化:请求数据横向拆分,通过多个同构服务的RPC请求的扇出来降低延时要求。
  • 计算并行化:通过数值计算并行优化例如使用SIMD和GPU加速运算,或者使用openMP或者自己多线程来实现

2. 关键问题

上面提到的都是通用的常见的做法,对于复杂的C++业务系统编程,需要解决业务抽象和并行计算的适配问题。

2.1 链式处理

每个请求的处理往往包含着多个阶段,而可能同时有上百人在开发各个阶段。怎么对各个阶段进行抽象,才能使所有人的开发能有序开展,互不冲突,也使请求的处理逻辑清晰有序,秩序井然?

在早期的时候,很容易想到的办法就是管道。就像 POSIX 的 PIPE 一样,上游的输出作为下游的输入,把整个过程分为多个阶段。对离线数据处理来说,管道的方式非常理想,即使到了现代,Spark RDD 的数据处理也是管道化的。

但对于在线服务程序来说,管道的方式就有些不足:如果想在管道中插入一个环节,就必须得适配上游管道的输出下游管道的输入,即增加环节的输入输出已经无法自定义了,必须服从已有管道的设定。而且还有一个缺点,就是管道的处理过程无法随意组装、合并

既然每个阶段的输入输出无法自定义了,那么干脆大家就共享同样的输入输出数据结构得了。这样反而可以把处理过程进行一定的组合、合并。这样就产生了我们目前最常见的处理方式,链式的请求处理过程。拉链上的每个算子,都共享同一个输入和输出。每个算子,其实就是一个接口相同的函数,这样就可以随意地调整函数指针的组合,形成不同的处理链。比如:一个请求走 A 模型排序,一个请求要走 B 模型排序,他们可以共享前序的算子,只在最后一个算子有所不同。

在链式处理基础上,算子也可以是继承同一基类接口的派生类,或者 lambda 表达式。结合工厂模式等一些编程技巧,处理链的调整可以配置化、动态化、脚本化。

2.2 并行拆分

2.2.1 数据横向拆分

前面讲到 数据可以横向拆分 到同构的服务里进行并行处理,但这样做代价并不低。比如 RPC 增加了很多 拆包封包解包合并 的处理过程,同时并行的 Processor 数量增多也会消耗更多的内存 Quota。当数据已经足够小时,是否还有其它的数据并行办法可以降低整体处理的时延呢?

2.2.2 流水线并行

如果为了避免问题,一些框架中使用了流水线并行的办法来解决这个问题:

流水线并行类似于 CPU 的指令流水线设计, 如忘记了可以参考这篇回顾一下https://www.cnblogs.com/myseries/p/14458367.html。

  • 链式处理的每个环节可以在单独的线程中执行,这样算子在处理不同的数据时可以并行起来。
  • 而对同一个数据来说,它在不同时刻只会被一个算子(线程)处理,经历的算子处理过程完全符合算子链的顺序,避免了并行计算的数据一致性问题。

高内聚无副作用算子

然后处理链越来越长、越来越长,忽然有一天,我们发现已经无法理解这个处理链了。每个算子都可以共享同一个输入输出,那么每个算子都可能会修改其中的任何一个数据。别说并行化可能会产生的数据一致性问题了,即使某个数据出了问题,我们也不知道到底被哪个算子给改了。

所以,在解决并行计算问题之前,对于复杂的 C++ 业务系统,首先要解决业务计算的抽象问题。

回到管道式结构?嗯,其实是可以的。但除了上面讲的问题外,还有一个成本问题,如果每个算子的输入输出都不同,那就意味着额外的内存消耗,以及额外的内存分配和释放消耗。对于离线任务来说,由于它们追求的是吞吐,可以攒一批一起处理,启动、传输延迟稍微大一点也没关系,任务经常启停,也不会存在严重的内存碎片问题。但对于在线的时延敏感的 C++ 服务来说,内存的管理会是一个严峻的挑战,需要仔细地去处理。

那么有没有一种办法,既能让同一个数据在不同的算子间自由地流动,又能限制算子只访问需要的数据(高内聚),且能限制算子不访问未在算子参数中声明的数据(无副作用)?这个问题我思考了半年,一直没想到好的办法,直到我看到了 Fugue 框架。

我用一个小技巧把 Fugue 对数据处理的基本思想做了一点简化:数据仍是那个数据,但参数不是那个参数。在算子之间流动的,仍然是同样的一块内存,但每个算子看到的数据结构是不同的。大略可以和 BERT 模型训练的思想相类比:每次训练用的还是那段文本,但是 MASK 掉的部分不一样。

简单来说,每个算子处理的数据,仍然是同一个 struct/class,但是每个算子看到的只是这个 class 的一个派生类,这个派生类中只有对 class 中有限个成员变量的访问接口。其它的成员变量都被 MASK 掉了。

但将父类指针强转成派生类指针,是有风险的。万一这个派生类增加了一个父类中没有的成员变量,将派生类指针指向父类对象时,对这个成员变量的访问是很容易出问题的。我们用一个现代 C++ 的编程技巧,解决了这个指针强转的安全性问题。如果一个指针的类型是 A 类型或者 A 类型的派生类型(std::is_convertible<>),而且这个指针的类型大小和 A 类型一样(is_all_eq<>),那么就可以安全地将 A 类型对象(父类对象)的指针强转成该类型的指针。这里的判断都是模板元编程,一旦函数的参数类型有问题,在程序的编译期就会报错。

// 编译期检查可变模板参数列表是否全为真
template <bool…> struct bool_pack;
template <bool… v>
using is_all_true = std::is_same<bool_pack<true, v…>, bool_pack<v…, true>>;
// 编译期检查可变模板参数列表数值是否与 a 一致
template <int…> struct int_pack;
template <int a, int… v>
using is_all_eq = std::is_same<int_pack<a, v…>, int_pack<v…, a>>;

图执行引擎

在管道式算子结构下,图执行引擎的设计相对简单,完全可以自动做计算图的推导,Spark 就是一个成熟的例子。Fugue 也是使用了 Single Assignment 的指导原则,保证每个数据只被赋值一次,也实现了自动的计算图推导。基于模板元编程的编译期自动计算图推导,非常厉害。

但是在很多业务中,大部分算子的处理可能都集中在同一个数据上。比如搜索的排序过程可能就是对队列的各种修改。如果基于 Single Assignment 的原则,每次修改都产生一个新的队列,那么内存的消耗,或者内存重分配的消耗是相当高的。

不过一般在线的图执行引擎设计没有追求这种极致。因为在设计上考虑对于在线服务来说,自动推导计算图的价值没有那么高。在线服务的计算图在线上往往需要重复执行上亿次,研发人员完全有动力去自己优化计算图,而且手动指定的计算图也更可控一些。

既然无法实现自动的计算图推导,为什么又要设计前面的高内聚、无副作用的算子呢?原因有两个:

  • 一是它本身就是一种优雅的业务计算抽象方案。从编程模型上来说,希望限制算子的维护者不乱来。当前人已经通过参数显式限制了某个算子的输入输出,那么后人在维护算子时如果想修改其它的数据,必须显式地修改参数,这对复杂系统的维护来说更友好。

  • 二是虽然它不能直接推导计算图,但可以使计算图的人工构建更容易。如果每个算子的输入输出都是明确无副作用的,那么研发人员可能更好地去设计计算图。如果把自动计算图推导的图引擎视为 L4/L5 自动驾驶的话,在线的图执行引擎大概只有 L3 级别。

后续

这些东西都是在阅读学习到高级的C++在线图计算引擎的一些心得,一定还有一些不完善的地方

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

敦兮其若朴,旷兮其若谷

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值