Spark性能調優
spark程序可能被集羣中任何資源託慢速度,比如CPU, 網絡帶寬,內存。 如果數據能全部放入內存, 那麼瓶頸很可能就會是帶寬。但是有的時候,你需要用序列化(serialization)的形式存儲RDD, 這樣可以節約內存, 本文關注兩個主題, 數據序列化(對提高網絡性能和降低內存都非常重要) 和 內存調優。
Data Serialization
序列化對分佈式應用的性能有重要影響。不已序列化 或者 佔用很多字節的 格式 會顯著脫慢計算。一般來說,如果你向調優spark應用,那麼你首先應該調優序列化。spark 試圖在方便和性能間平衡,spark提供兩個序列化庫:
- Java serialization:默認情況下,spark使用java的ObjectOutputStream框架來序列化對象, 只要你實現了java.io.Serializable接口。你也可以實現java.io.Externalizable來更加精確的控制性能。java serialization 非常靈活,但是有點慢,而且對很多類會產生很大的序列化格式。
- Kryo serialization: spark支持Kryo 庫。kryo比java 序列化更加快,而且更加緊湊(經常超過10倍)。但是不支持所有的類型,而且對要在程序中使用的類要進行註冊。
你可以在初始化你的job時 切換成使用kryo序列化,使用的方法是調用
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
此項配置不僅對工作節點間傳輸數據生效, 同時對序列化RDD到磁盤也生效。之所以Kryo不是默認選項,完全是因爲需要註冊(custom registration requirement)。如果你的應用是網絡密集型,我們推薦你使用Kryo 序列化。對大多數經常使用的核心scala 類,spark 都包括了他們的Kryo serializers, 你可以在Twitter chill 中找到他們
要想使用Kryo註冊你自己的類, 只需要實現org.apache.spark.serializer.KryoRegistrator
,然後設置spark.kryo.registrator
屬性。如下所示:
import com.esotericsoftware.kryo.Kryo
import org.apache.spark.serializer.KryoRegistrator
class MyRegistrator extends KryoRegistrator {
override def registerClasses(kryo: Kryo) {
kryo.register(classOf[MyClass1])
kryo.register(classOf[MyClass2])
}
}
val conf = new SparkConf().setMaster(...).setAppName(...)
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
conf.set("spark.kryo.registrator", "mypackage.MyRegistrator")
val sc = new SparkContext(conf)
Kryo 文檔 描述了其他的註冊選項,包括如何添加自己的序列化代碼
如果你的對象很大, 你需要增加 spark.kryoserializer.buffer.mb
屬性來放置你的對象, 這個屬性默認是2。
最後,如果你不註冊你的類, Kryo仍然能工作, 不過會存儲每個對象的類全名, 有點浪費。
Memory Tuning
在內存調優時有3點考慮: 你的所有對象要使用的內存和,訪問對象的花費, 內存回收的開銷。
默認情況下,訪問java對象是很快的, 但是可能會消耗raw 數據的2到5倍空間,這是因爲:
- 每個java對象都有一個對象頭(object header), 這是一個16字節的數據,包含了指向類的指針等數據。對那種只有很少數據的對象(比如一個
Int
),對象頭可能比數據還要大 - Java String 比 raw 數據多40個字節,而且用2個字節存儲每個字符(因爲String 使用UTF-16編碼)。比如一個10個字符的String消耗60字節
- 普通的容器類,比如HashMap, LinkedList, 使用鏈接數據結構, 對每個數據進行封裝。這中對象不僅有header, 而且有指向下一個對象的指針(8個字節)。
- 原生類型的容器也會使用封裝存儲數據, 比如
java.lang.Integer
。
本節會討論如何確定你的對象的內存用量, 以及如何優化。 可以通過改變數據結構, 或者用序列化格式存儲數據。 本節包含了spark緩存調優 和 Java垃圾回收。
Determining Memory Consumption
測量你的數據集內存消耗的最好方法是, 創造一個RDD, 放入cache, 然後看你的驅動程序的SparkContext的日誌。日誌會告訴你每個partition使用了多少內存, 你可以累加partition數量來算出RDD的總內存需求。就向下面一樣:
INFO BlockManagerMasterActor: Added rdd_0_1 in memory on mbk.local:50311 (size: 717.5 KB, free: 332.3 MB)
這表示,RDD0的partition 1消耗了717.5KB。
Tuning Data Structures
減少內存消耗的第一部是避免使用增加開銷的Java 特性, 比如基於指針(pointer-based)的數據結構和封裝對象。
- 儘量使用對象數組和原始類型,不要使用Java 和Scala容器(比如HashMap),fastutil 庫提供了方便使用的原始類型容器類,而且和Java標準庫兼容。
- 避免使用包含很多小對象和指針的嵌套數據結構
- 使用數字ID或者枚舉類型來表示key, 而不要使用string
- 如果你的RAM低於32GB, 設置JVM flag
-XX:+UseCompressedOops
來保證指針長度爲4而不是8。 你可以在spark-env.sh
中添加這個選項。
Serialized RDD Storage
如果你安裝以上內容調優還是無法有效存儲你的對象, 那麼一個簡單的方法就是將對象以序列化的形式存儲。方法是在RDD persistence API 中使用序列化存儲級(serilizedStorageLevels),例如 MEMORY_ONLY_SER。Spark會把每個RDD
partition 以字節數組的形式存儲。採用此方法的唯一壞處是會降低訪問效率。我們強烈建議使用Kryo庫來序列化數據,因爲他比Java序列化節省更多的空間。
Garbage Collection Tuning
當你程序中存在大量不會使用的RDD時,JVM垃圾回收器會成爲一個問題。當JAVA 需要彈出舊對象來爲新對象騰出空間時, 它會追蹤所有的JAVA對象來尋找不使用的對象。你要搞明白,垃圾回收的花費正比與JAVA對象的數量, 所以不要用有很多小對象的數據結構(使用Int數組, 而不要使用LinkedList)。更好的方法是以序列化的形式來存儲對象,這樣垃圾回收的時候每個partition就只有一個對象了(因爲整個partition被存爲一個大數組)。在嘗試其他技術來解決垃圾回收器之前,最好縣使用serialized
caching。
當你程序的工作內存 和 緩存到節點的RDD 間有內存競爭時,垃圾回收也會成爲一個問題。我們會討論如何控制分配給RDD的內存來緩解這個問題。
Measuring the Impact of GC
垃圾回收調優的第一步是統計垃圾回收的頻率和垃圾回收花費的時間。 你可以在你的SPARK_JAVA_OPTS
環境變量中添加-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
來統計。運行你的spark任務時,你會在你的每個工作節點上找到關於垃圾回收的日誌。
Cache Size Tuning
對垃圾回收來說一個重要的配置是RDD緩存內存量, 默認情況下,Spark使用60%的配置執行內存(spark.executor.memory
)來緩存RDD, 也就是說工作程序有40%的內存來存放所需要的對象。
如果你的程序很慢,或者你發現JVM經常回收內存,或者經常耗盡內存。你可以降低RDD緩存內存量來降低RDD對內存的消耗。比如說降低到50%, 你可以通過調用conf.set("spark.storage.memoryFraction", "0.5")
來完成。使用以上技術,可以緩解大部分垃圾回收問題。以下時垃圾回收進階調優。
Advanced GC Tuning
爲了進一步垃圾回收調優, 我們需要理解JVM中內存管理的基本信息:
- Java 堆 被分成 新 和 舊 兩個區域, 新區域存放短暫的對象,而舊區域存放長生存時間的對象。
- 新區域又被分爲3個區域 [Eden, Survivor1, Survivor2]。
- 垃圾回收進程的一個簡單描述:當Eden滿了時,一個次級垃圾回收器會在Eden中調用,那些生存下來的對象和Survivor1中的對象被拷貝到Survivor2。然後兩個Survivor區域交換,如果一個對象存活了足夠的時間,或者Survivor2滿了,它會被移動到舊區。 最後當舊區快要滿的時候,一個完整的垃圾回收器會被調用。
Spark 的垃圾回收調優的目的時保證只有長時間生存的RDD會被存放在舊區,而新區要足夠容納短暫的對象。這避免了完整垃圾回收器區收集任務執行過程中產生的對象。以下步驟可能有用:
- 收集垃圾回收的數據,檢查是否進行了過多的垃圾回收。如果任務完成前,垃圾回收被調用很多次,那麼說明沒有足夠的內存運行任務。
- 檢查垃圾回收的數據,如果舊區域接近滿的話(即沒滿),降低緩存RDD的內存量。你可以使用
spark.storage.memoryFraction
來修改。因爲少緩存幾個對象總比拖慢任務執行來的好! - 如果有過多的次級回收,卻不是很多完整回收。你可以爲Eden分配更多的內存。你可以設置Eden的大小爲略微超過任務執行所需要的內存。如果任務執行需要E, 那麼你可以設置新區的大小通過選項
-Xmn=4/3*E
。 - 如果你需要從HDFS中讀數據,可以用從HDFS中讀到的數據快大小來估計任務所需的內存。一般,解壓過的塊會佔據2到3倍原始快的內存。所以如果你有3到4個任務要使用工作內存, 你可以估計Eden的大小爲 4*3*64MB, 每個HDFS的塊約等於64MB。
- 監視新的設置後內存回收的頻率和花費。
經驗告訴我, 內存回收調用取決於你的應用以及可用的內存。網上有很多調優的方法, 總的來說,管理內存回收的次數可以有效降低開銷。
Other Considerations
Level of Parallelism
調高操作的並行性可以有效利用集羣。Spark根據文件大小設置每個文件"map"任務的數量(你也可以通過SparkContext.textFile
的選項來手動控制)。對於"reduce"操作,比如groupByKey, reduceByKey。Spark根據父親RDD最大的partition數來設置"reduce"任務。你可以傳遞操作的第二個參數來控制並行數(參考spark.PairRDDFunctions
),或者設置spark.default.parallelism
來改變默認並行數。我們推薦每個CPU
2到3個任務。
Memory Usage of Reduce Tasks
有時,你得到OutOfMemoryError並不是因爲你的RDD無法放入內存,而是因爲你的一個任務(比如groupByKey)太大。Spark的混洗(shuffle)操作(sortByKey
,groupByKey
,
reduceByKey
, join
等)會建立一個供group使用的哈希表,這個哈希表有時會很大。最簡單的解決辦法是提高並行級,這樣每個任務的輸入集就會比較小。 因爲每個工作節點的所有任務都重複利用JVM, 以至於Spark Spark能有效支持200ms級的任務,因此你可以放心的提高並行等級哪怕超過你集羣的核數。
Broadcasting Large Variables
利用好SparkContext的廣播功能可以有效降低序列化的大小, 和開啓一個job的開銷。如果你的任務使用驅動程序中的大對象(比如靜態查找表),你應該考慮把它裝入廣播變量。Spark會在驅動程序中打印每個任務的序列化大小,你可以通過這個來判斷你的任務是否過大。總的來說, 超過20KB的任務值得優化。
Summary
對大多數程序,使用Kryo序列化以及以序列化的方式存儲數據足夠解決一般的性能問題。如果你還有問題,可以發郵件到
Spark mailing list來提問。
### 本文中的序列化指 serialization