这里是 王喆的机器学习笔记 的第二十五篇文章。接下来的几篇文章希望与大家一同讨论一下机器学习模型的分布式训练的问题。这个问题在推荐、广告、搜索领域尤为突出,因为在互联网场景下,动辄TB甚至PB级的数据量,几乎不可能利用单点完成机器学习模型的训练,分布式机器学习训练成为唯一的选择。
在笔者看来,分布式机器学习训练有三个主要的方案,分别是Spark MLlib,Parameter Server和TensorFlow,倒不是说他们是唯三可供选择的平台,而是因为他们分别代表着三种主流的解决分布式训练方法。今天我们先从Spark MLlib说起,看看最流行的大数据计算平台是如何处理机器学习模型的并行训练问题的。
说起Spark,我想不会有任何算法工程师是陌生的。作为流行了至少五年的大数据项目,虽然受到了诸如Flink等后起之秀的挑战,但其仍是当之无愧的业界最主流的计算平台。而且为了照顾数据处理和模型训练平台的一致性,也有大量公司采用Spark原生的机器学习平台MLlib进行模型训练。选择Spark MLlib作为机器学习分布式训练平台的第一站,不仅因为Spark是流行的,更是因为Spark MLlib的并行训练方法代表着一种朴素的,直观的解决方案。
在介绍Spark MLlib的分布式机器学习训练方法之前,让我们先回顾一下Spark的分布式计算原理,这是分布式机器学习的基础。
Spark,是一个分布式的计算平台。所谓分布式,指的是计算节点之间不共享内存,需要通过网络通信的方式交换数据。要清楚的是,Spark最典型的应用方式是建立在大量廉价计算节点上,这些节点可以是廉价主机,也可以是虚拟的docker container;但这种方式区别于CPU+GPU的架构,或者共享内存多处理器的高性能服务器架构。清楚这一点,对于理解后续的Spark的计算原理是重要的。
图1 Spark架构图
从图1的Spark架构图中可以看到,Spark程序由Manager node进行调度组织,由Worker Node进行具体的计算任务执行,最终将结果返回给Drive Program。在物理的worker node上,数据还可能分为不同的partition,可以说partition是spark的基础处理单元。
在执行具体的程序时,Spark会将程序拆解成一个任务DAG(有向无环图),再根据DAG决定程序各步骤执行的方法。如图2所示,该程序先分别从textFile和HadoopFile读取文件,经过一些列操作后再进行join,最终得到处理结果。
图2 DAG,Spark任务的有向无环图
在Spark平台上并行处理图2的DAG时,最关键的过程是找到哪些是可以并行处理的部分,哪些是必须shuffle和reduce的部分。
这里的shuffle指的是所有partition(数据分片)的数据必须进行洗牌后才能得到下一步的数据,最典型的操作就是图2中的groupByKey和join操作。拿join操作来说,必须通过在textFile数据中和hadoopFile数据中做全量的匹配才可以得到join后的dataframe。而groupby操作需要对数据中所有相同的key进行合并,也需要全局的shuffle才能够完成。
与之相比,map,filter等操作仅需要逐条的进行数据处理和转换就可以,不需要进行数据间的操作,因此各partition之间可以并行处理。
除此之外,在得到最终的计算结果之前,程序需要进行reduce的操作,从各partition上汇总统计结果,随着partition的数量逐渐减小,reduce操作的并行程度逐渐降低,直到将最终的计算结果汇总到master节点上。
所以可以说shuffle和reduce操作的发生决定了纯并行处理阶段的边界。如图3所示,Spark的DAG被分割成了不同的并行处理阶段(stage)。
图3 被shuffle操作分割的DAG stages
需要强调的是shuffle操作需要在不同计算节点之间进行数据交换,非常消耗计算、通信及存储资源,因此shuffle操作是spark程序应该尽量避免的。一句话总结Spark的计算过程就是:Stage内部数据高效并行计算,Stage边界处进行消耗资源的shuffle操作或者最终的reduce操作。
有了Spark分布式计算过程的基础,下面就可以更清楚的理解Spark MLlib并行训练的原理。
在所有主流的机器学习模型中,Random Forest的模型结构特点决定了其可以完全进行数据并行的模型训练,而GBDT的结构特点则决定了树之间只能进行串行的训练,这里就不再赘述其spark的实现方式,我们将重点放在梯度下降类方法的实现上,因为梯度下降的并行程度实现质量直接决定了以Logistic Regression为基础,以Multiple Layer Perceptron为代表的深度学习模型的训练速度。
这里,我们深入到Spark MLlib的源码中,直接把Spark做mini Batch梯度下降的源码贴出如下(代码摘自Spark 2.4.3 GradientDescent 类的 runMiniBatchSGD 函数):
while (!converged && i <= numIterations) {
val bcWeights = data.context.broadcast(weights)
// Sample a subset (fraction miniBatchFraction) of the total data
// compute and sum up the subgradients on this subset (this is one map-reduce)
val (gradientSum, lossSum, miniBatchSize) = data.sample(false, miniBatchFraction, 42 + i)
.treeAggregate((BDV.zeros[Double](n), 0.0, 0L))(
seqOp = (c, v) => {
// c: (grad, loss, count), v: (label, features)
val l = gradient.compute(v._2, v._1, bcWeights.value, Vectors.fromBreeze(c._1))
(c._1, c._2 + l, c._3 + 1)
},
combOp = (c1, c2) => {
// c: (grad, loss, count)
(c1._1 += c2._1, c1._2 + c2._2, c1._3 + c2._3)
})
bcWeights.destroy(blocking = false)
if (miniBatchSize > 0) {
/**
* lossSum is computed using the weights from the previous iteration
* and regVal is the regularization value computed in the previous iteration as well.
*/
stochasticLossHistory += lossSum / miniBatchSize + regVal
val update = updater.compute(
weights, Vectors.fromBreeze(gradientSum / miniBatchSize.toDouble),
stepSize, i, regParam)
weights = update._1
regVal = update._2
previousWeights = currentWeights
currentWeights = Some(weights)
if (previousWeights != None && currentWeights != None) {
converged = isConverged(previousWeights.get,
currentWeights.get, convergenceTol)
}
} else {
logWarning(s"Iteration ($i/$numIterations). The size of sampled batch is zero")
}
i += 1
}
乍一看比较复杂,这里可以为大家做一个精简,只列出关键的操作部分,大家就可以一目了然Spark在做什么。
while (i <= numIterations) { //迭代次数不超过上限
val bcWeights = data.context.broadcast(weights) //广播模型所有权重参数
val (gradientSum, lossSum, miniBatchSize) = data.sample(false, miniBatchFraction, 42 + i)
.treeAggregate() //各节点采样后计算梯度,通过treeAggregate汇总梯度
val weights = updater.compute(weights, gradientSum / miniBatchSize) //根据梯度更新权重
i += 1 //迭代次数+1
}
经过精简的代码非常简单,Spark的mini batch过程制作了三件事:
这样一来,每次迭代的Stage和Stage的边界就非常清楚了,Stage内部的并行部分是各节点分别采样并计算梯度的过程,Stage的边界是汇总加和各节点梯度的过程。这里再强调一下汇总梯度的操作treeAggregate,该操作是进行类似树结构的逐层汇总,整个操作流程如图4所示。
图4 treeAggregate过程示意图
事实上,treeAggregate是一次reduce操作,本身并不包含shuffle操作,再加上采用分层的树形操作,在每层中都是并行执行的,因此整个过程是相对高效的。
在迭代次数达到上限或者模型已经充分收敛后,模型停止训练。这就是Spark MLlib进行mini batch梯度下降的全过程,也是Spark MLlib实现分布式机器学习的最典型代表。
总结来说,Spark MLlib的并行训练过程其实是“数据并行”的过程,并不涉及到过于复杂的梯度更新策略,也没有通过“参数并行”的方式实现并行训练。这样的方式简单、直观,易于实现,但也存在着一些局限性。
虽然Spark MLlib基于分布式集群,利用数据并行的方式实现了梯度下降的并行训练,但是有Spark MLlib使用经验的同学应该都清楚,使用Spark MLlib训练复杂神经网络时,往往力不从心,不仅训练时间过长,而且在模型参数过多时,经常会存在内存溢出的问题。具体来讲,Spark MLlib的分布式训练方法有下面几个弊端:
因为这些原因,如果想寻求更高效的训练速度和更灵活的网络结构,势必需要寻求其他平台的帮助。在这样的情势下,Parameter Server凭借其高效的分布式训练手段成为分布式机器学习的主流,而TensorFlow,PyTorch等深度学习平台则凭借灵活可调整的网络结构,完整的训练、上线支持,成为深度学习平台的主要选择。下两篇内容,本专栏将分别介绍Patameter Server和TensorFlow的并行训练原理。
例行的问题讨论时间,其他与你一起讨论和分享业界相关的经验:
作者:王喆
链接:https://zhuanlan.zhihu.com/p/81784947
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。