本文主要分享一下我们之前在开发Softmax Regression算法中的一些性能调优的经验(主要是基于0.6.1版本)。
- 调优经验
应该说,Spark开发中,具体采用什么调优方法去优化性能,需要根据具体算法和实现而定,适合我们这个问题的方法不一定就适合其他问题,但希望我们的经验可以让其他人少踩点坑,更多的调优方法还可以参考官方文档中的 Configuration 和 Tuning 部分。
(1)配置项的使用
熟悉Hadoop开发的同学应该对配置项不陌生。根据不同问题,调整不同的配置项参数,是比较基本的调优方案。配置项可以在脚本文件中添加,也可以在代码中添加。如果是在代码中添加,则需要在 SparkContext 定义 之前 进行配置项的修改,例如:
1
2
System . setProperty ( “spark.akka.frameSize” , “100” )
val sc = new SparkContext ( args ( 0 ) , “SparkSR” )
其中每一个配置项的修改都通过setProerty来实现。下面介绍几个我们用到的比较重要的配置项:
spark.akka.frameSize: 控制Spark中通信消息的最大容量 (如 task 的输出结果),默认为10M。当处理大数据时,task 的输出可能会大于这个值,需要根据实际数据设置一个更高的值。如果是这个值不够大而产生的错误,可以从 worker的日志 中进行排查。通常 worker 上的任务失败后,master 的运行日志上出现”Lost TID: “的提示,可通过查看失败的 worker 的日志文件($SPARK_HOME/worker/下面的log文件) 中记录的任务的 Serialized size of result 是否超过10M来确定。
spark.storage.memoryFraction: 控制用于 Spark 缓存的 Java 堆空间,默认值是0.67,即 2/3 的 Java 堆空间用于 Spark 的缓存。如果任务的计算过程中需要用到较多的内存,而 RDD 所需内存较少,可以调低这个值,以减少计算过程中因为内存不足而产生的 GC 过程。在调优过程中我们发现,GC 过多是导致任务运行时间较长的一个常见原因。如果你的任务运行较慢,想确定是否是GC太多导致的,可以在 spark-env.sh 中设置 JAVA_OPTS 参数以打印 GC 的相关信息,设置如下:
1
JAVA_OPTS = ” -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps”
这样如果有GC发生,就可以在master和work的日志上看到。
spark.default.parallelism: 控制Spark中的分布式shuffle过程默认使用的task数量,默认为8个。如果不做调整,数据量大时,就容易运行时间很长,甚至是出Exception,因为8个task无法handle那么多的数据。 注意这个值也不是说设置得越大越好。
spark.local.dir:Spark 运行时的临时目录,例如 map 的输出文件,保存在磁盘的 RDD 等都保存在这里。默认是 /tmp 这个目录,而一开始我们搭建的小集群上 /tmp 这个目录的空间只有2G,大数据量跑起来就出 Exception (”No space left on device”)了。
(2)Broadcast 变量
Spark 所支持的两种共享变量 (shared variables) 的一种,主要用于共享分布式计算过程中各个 task 都会用到的只读变量,broadcast 变量只会在每台计算机器上保存一份,而不会每个task都传递一份,节省空间,效率也高。Spark 的HadoopRDD 的 实现 中,就采用 broadcast 进行 Hadoop JobConf 的传输。 官方文档的说法 是当task的大小大于20k时,就可以考虑用 broadcast 进行优化。
在我们的实现中,权重矩阵是一个 100 * 1000000 的Float矩阵,Spark 默认进行压缩后大约是400M左右,由于梯度计算时每个样本都需要跟整个权重矩阵进行计算,因此权重矩阵的传输我们通过 broadcast 实现。而由于权重矩阵在每次迭代后都会更新,因此在每次迭代后都会重新 broadcast 一次。每次 worker 读取 broadcast 的时间短则几秒,长则二三十秒,相比序列化传参的方式,要快得多。但是这种实现也不完美,因为每次迭代所传输的 broadcast 变量都会保存在 worker 的内存中,直至内存不够用,spark 才会把旧的 broadcast 变量释放掉,不能提前release掉。
(3)accumulator 变量
另外一种 Spark 所支持的共享变量。accumulator 支持在 worker 端进行对其累加操作 +=,但不能读取数据,类似 hadoop 中的counter,但其除了支持 Int 和 Double 类型外,用户还可以自定义类型,只要该类实现相应接口即可。此外,它还支持 type A += type B,即累加结果的类型和累加的值的类型无须一致。
在解决权重更新的问题时,accumulator 是我们尝试过的一种方式,即在 driver 程序中定义一个保存权重矩阵的 accumulator ,然后 worker 计算的梯度直接通过 += 操作,更新该 accumulator 。但尝试没有成功,在不停的 GC 之后,还没到 worker 执行代码的阶段,程序就挂了。
(4)reduce 和 reduceByKey
reduce 是 Action 操作,reduceByKey 是 Transformation 操作。
reduce 是一种聚合操作,可以把各个 task 的结果汇集到 master 节点,执行自定义的 function 操作。reduce的实现做了优化,可以把同一个task内的结果先在本地执行聚合function,再把结果传给master节点执行聚合操作。但由于始终还是要汇总到master节点,且reduce会把接收到的数据保存到内存直到所有task都完成为止,因此当task很多,task的结果数据又比较大时,master容易造成性能瓶颈。
reduceByKey 也是聚合操作,是根据key聚合对应的value。同样的,在每一个mapper把数据发送给reducer前,会在mapper本地先进行merge,类似mapreduce中的combiner。跟reduce不同的是,reduceByKey不是把数据汇集到master节点,是分布式进行的,因此不会存在reduce那样的性能瓶颈。
在我们的softmax regression算法的实现中,需要对worker上计算的梯度进行累加,由于每个 DataPoint 的特征向量是稀疏向量,其计算所得的梯度矩阵也是稀疏的。故我们把稀疏矩阵按照 (列id,列向量) 的形式进行分发,再通过 reduceByKey 对这些 pair 进行聚合累加,得到梯度矩阵。
- 总结
当面对的数据规模较大时,调优是必经之路,尤其跟Spark打交道的主要是内存。开发过程中,尤其是性能调优时,Spark 的文档可能无法满足需求,特别是出现各种错误时,往往一头雾水,这时最好还是上 google group 上搜索或提问,他们的回复一般还是比较及时的,而且现在用Spark的人相比以前已经多了起来,你踩到的坑估计很多已经早被人踩过了。另外,本文提到的主要是Spark层面上的调优经验,至于机器学习算法的并行设计方面的经验,还请各位高手不吝指点。