Spark性能優化指南(官網文檔)

本篇文章翻譯之 Tuning Spark

由於大多數Spark計算基於內存的特性,Spark程序可能會因爲集羣中的任何資源而導致出現瓶頸:CPU、網絡帶寬或內存。通常情況下,如果數據適合於放到內存中,那麼瓶頸就是網絡帶寬,但有時,你還是需要一些調優的,比如以序列化的形式保存RDDs,以便減少內存佔用。這篇調優指南主要涵蓋兩個主題:數據序列化和內存調優。數據序列化對於良好的網絡性能是至關重要的,而且還可以減少內存的使用。
 

數據序列化 - Data Serialization

序列化在任何的分佈式應用中都扮演着重要的角色。將對象序列化的比較慢的格式,或者耗費大量字節的格式,都會大大降低計算速度。通常,這應該是您在優化Spark應用時首先要考慮的事情。Spark旨在在便利性(允許你使用任何Java類型)和性能之間取得平衡。它提供了連個序列化庫:

  • Java serialization:默認情況下,Spark使用Java的ObjectOutputStream框架來序列化對象,而且可以使用任何你通過實現java.io.Serializable來創建的類。你還可以通過繼承java.io.Externalizable來控制序列化的性能。Java序列化是靈活的,但通常很慢,而且對於很多類會導致大的序列化格式。
  • Kryo serialization:Spark也可以使用Kryo庫(version 4)來更快的序列化對象。Kryo明顯要比Java序列化更快,更緊湊,但不支持所有序列化類型,並且要求你提前註冊你將在程序中使用的類,以獲得最佳性能。

您可以通過使用SparkConf初始化job,並調用conf.set(“spark.serializer”, “org.apache.spark.serializer.KryoSerializer”)來切換到使用Kryo。這個設置配置的序列化器不僅可以用於worker節點之間的shuffle數據,還可以用於序列化到磁盤的RDDs。Kryo不是默認值的唯一原因是因爲其要自定義註冊,但是我們建議在任何網絡密集型應用中嘗試使用它。從Spark2.0.0開始,我們在基於基本數據類型、基本數據類型或字符串類型的數組來shuffle RDDs時,使用Kyro序列化器。

Spark對於包含在AllScalaRegistrar(Twitter chill library)中的常用核心Scala類,都自動包含了Kryo序列化器。

使用registerKryoClasses方法,向Kryo註冊您自己的自定義類。

val conf = new SparkConf().setMaster(...).setAppName(...)
conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))
val sc = new SparkContext(conf)

Kryo Document描述了更高級的註冊選項,比如添加自定義的序列化代碼。

如果你的對象很大,你可能需要增加配置(spark.kryoserializer.buffer)的值。這個值要足夠大到能容納你要序列化的最大的對象。

最後,如果你沒有註冊你的自定義類,Kryo將仍然生效,但是它將不得不存儲每個對象的完整類名,那將會非常浪費。
 

內存調優 - Memory Tuning

調優內存使用時需要考慮三個因素:對象佔用的內存數(你可能想要將整個dataset放到內存中),訪問這些對象的成本以及垃圾收集的開銷。

默認情況下,Java對象訪問速度很快,但是很容易的比對象中存儲的數據多消耗2~5倍的空間。這時由於以下幾個原因:

  • 不同的Java對象都有一個"對象頭",大約是16個字節,幷包含指向其類的指針等信息。對於一個只有很少數據的對象(比如一個Int字段),對象頭可能會比數據更大。
  • Java 字符串在其原始數據上大約有40個字節的開銷(因爲它們是將原始數據保存在字符數組中的,並且保存長度等額外的數據),由於字符串內部使用UTF-16編碼,所以每個字符都存儲爲兩個字節。因此,一個10字符的字符串可以很容易的消耗60個字節。
  • 通用集合類,如HashMap和LinkedList,使用鏈式數據結構,其中每個條目(例如Map.Entry)都有一個"wrapper"對象。這個對象不僅有對象頭,還有指向列表中下一個對象的指針(通常每個指針8個字節)。
  • 基本數據類型的集合通常將它們存儲爲裝箱對象,如java.lang.Integer。

本節將首先概述Spark的內存管理,然後討論用戶可以採取的具體策略,,以便更有效地使用應用程序中的內存。我們將描述如何確定對象的內存使用,以及如何改進內存使用——通過改變數據結構,或以序列化格式存儲數據。然後,我們將概括調優Spark的緩存大小和Java垃圾收集器。
 

內存管理 - Memory Management Overview

Spark中的內存使用主要分爲兩類:execution和storage。Execution memory指的是,在shuffle、join、sort和aggregation中用於計算的內存,而storage memory指的是用來在集羣中緩存和傳輸內部數據的內存。Spark中,execution和storage共享一個統一的區域(M)。當沒有execution memory被使用時,storage可以獲取所有可用內存,反之,如果沒有storage memory被使用時,execution也可以獲取所有可用的內存。如果在Execution storage不夠用時,可驅逐storage區域佔用Execution區域的一部分內存,但僅在總的storage memory佔用低於某個閾值®之前纔會這麼做。換句話說,R是M中的一個子區域,是在默認情況下分配給storage的內存,閾值R內緩存的塊是永遠不會被驅逐的。

這種設計確保了幾個想要的特性。首先,不使用緩存的應用程序可以拿整個內存空間給execution用,從而避免不必要的磁盤溢出。其次,如果應用程序確實要使用緩存,可以保留一個最小的storage空間®,這裏的數據塊不會被驅逐。

雖然有兩個相關的配置,但由於默認值已適用於大多數情況,一般用戶是不需要調整這兩個參數的:

  • spark.memory.fraction代表統一共享區域M佔Java堆內存-300MB的比例(默認是0.6)。剩餘40%的空間是留給用戶數據結構、Spark內部元數據和防止OMM用的。
  • spark.memory.storageFraction代表R區域佔M區域的比例(默認是0.5)。R中的緩存塊時不會被Execution驅逐的。

spark.memory.fraction的值應滿足JVM老年代的堆空間大小。有關詳細信息,請參考下面關於高級GC調優的討論。
 

確定內存佔用 - Determining Memory Consumption

衡量一個dataset所需內存的最好的方法就是創建一個RDD,將其放入緩存中,然後到web UI中查看"Storage"頁面。這個頁面會告訴你,這個RDD佔用了多少內存。

要估計一個特定對象的內存佔用,可以使用SizeEstimator的estimate方法,這對於嘗試用不同的數據設計來調整內存使用是非常有用的,還可以確定廣播變量在每個executor上佔的堆大小。
 

數據結構調優 - Tuning Data Structures

減少內存消耗的第一種方法是,避免那些會增加開銷的Java特性,比如基於指針的數據結構和包裝對象。有幾種方式可以做到這一點:

  1. 設計你的數據結構以優先選擇對象數組和基本類型,而不是標準的Java或Scala集合類型(比如HashMap)。fastutil庫爲與Java標準庫兼容的基本類型提供了方便的集合類。
  2. 儘可能避免使用包含大量小對象和指針的嵌套結構。
  3. 對於主鍵字段,考慮使用數字類型的ID或枚舉對象來代替字符串。
  4. 如果內存少於32GB,可以設置JVM參數-XX:+UseCompressedOops,來使指針由8個字節變爲4個字節。您可以在spark-env.sh中添加這個選項。

 

序列化RDD存儲 - Serialized RDD Storage

當進行了調優之後,對象太大還是無法有效地存儲時,一個更簡單的減少內存佔用的方式就是使用RDD持久化API中的序列化存儲級別(比如MEMORY_ONLY_SER)以序列化形式存儲對象。Spark將每個RDD分區存儲爲一個大的字節數組。以序列化形式存儲數據的唯一缺點就是訪問時間慢,由於必須動態地反序列化對個對象。我們強烈建議您使用Kryo,如果您想以序列化的形式緩存數據,因爲它比Java序列化佔用小的多的空間。
 

垃圾收集調優 - Garbage Cllection Tuning

當您的應用程序存儲了大量的RDD時,JVM垃圾收集可能會成爲問題。當Java需要驅逐舊對象來爲新對象騰出空間時,它將跟蹤所有Java對象,並找到未使用的對象。這裏要記住的要點是,垃圾收集的成本與Java對象的數據成正比,使用更小對象的數據結構(比如,用int類型的數組代替LinkedList)可以大大降低垃圾收集的成本。一個更好的方法是以序列化的形式持久化對象,如上所述:現在每個RDD分區只有一個對象(一個字節數組)。如果存在GC問題,在嘗試使用其他技術之前,首先要嘗試使用序列化緩存。

由於任務工作內存(運行task所需的內存空間)和緩存在節點上的RDD之間存在衝突,也可能會導致GC問題。我們將討論如何控制分配給RDD的緩存空間來緩解這種問題。
 

衡量GC影響 - Measuring the Impact of GC

GC調優的第一步是收集統計垃圾收集的頻率和GC所耗費的時間。這可以通過添加Java gc選項-XX:+PrintGCDetails和-XX:+PrintGCTimeStamps來實現。(有關給Spark job傳遞Java選項的信息,請查看configuration guide)。在下次Spark job運行時,您將在發生垃圾收集時看到被打印到work檢點上的日誌信息。注意,這些GC日誌是打印在集羣的worker節點而不是driver節點。
 

高級GC調優策略 - Advanced GC Tuning

爲了更進一步地調優垃圾收集,我們首先需要了解一些關於JVM內存管理的基本信息:

  • Java堆空間被劃分爲年輕代和年老代兩個區域。年輕代用來保存存活時間短的對象,而年老代保存壽命更長的對象。
  • 年輕代被進一步劃分成Eden,Survivor1和Survivor2三個區域。
  • 垃圾收集過程的簡單描述:當Eden空間已滿時,會在Eden空間觸發一次minor GC,然後將Eden和Survivor1中仍然存活的對象複製到Survivor2區域。如果一個對象達到了所設定的最大年齡或者Survivor2區滿了,就會將對象移動到年老代。最終,當年老代空間快要滿了時,將會觸發一次full GC。

Spark中進行GC調優的目標是確保只有存活時間長的RDD存儲在年老代,年輕代足以存儲存活時間短的對象。這將有助於避免full GC去收集任務執行期間創建的臨時對象。下面是一些有用的GC調優方法:

  • 通過收集GC統計信息來檢查是否有太多的垃圾收集發生。如果在一個task執行完成之前,觸發了多次full GC,這意味着沒有足夠的內存可用來執行tasks。
  • 如果觸發了太多的minor GC,而沒有太多major GC,那麼爲Eden區分配更多內存將會有所幫助。您可以將Eden區的大小設置爲高於每個task預估所佔用的內存。如果Eden區的大小被確定爲E,那麼可以使用選項-Xmn=4/3*E來這是年輕代的大小。
  • 在打印的GC統計信息中,如果發現年老代將要滿了,則通過降低spark.memory.fraction來減少用於緩存的內存佔用;緩存更少的對象比降低task的執行速度要更好。或者,考慮減少年輕代的大小。如果你已經設置了-Xmn的值,這意味着降低它的大小。如果沒有設置-Xmn的值,嘗試蓋面JVM的NewRatio參數的值,許多JVM將這個參數的默認值設爲2,這表明年老代佔整個堆空間的2/3,它應該足夠大,以超過spark.memory.fraction的值。
  • 嘗試使用G1GC垃圾收集器-XX:+UseG1GC。它可以在垃圾收集成爲瓶頸的情況下提高性能。注意,對於那些堆內存大的executor來說,增加G1 的region size(-XX:G1HeapRegionSize)可能很重要。
  • 舉個例子,如果您的task是從HDFS讀取數據,那麼就可以使用從HDFS讀取數據的block大小來估計這個task所使用的內存。需要注意的是,block解壓縮之後的大小通常是原來的2或3倍。因此,如果我們希望有3或4個task的工作空間,並且HDFS block大小爲128MB,我們就可以估算Eden區大小爲43128。
  • 監視垃圾收集的頻率和時間如何隨着設置的變化而變化。

我們的經驗表明,GC調優的效果取決於你的應用程序和可用內存的大小。網上有許多調優選項,但是管理full GC發生的頻率有助於減少開銷。
 

其他優化技巧 - Other Considerations

任務並行度 - Level of Parallelism

除非爲每個操作設置足夠高的並行度,否則集羣資源不會得到充分利用。Spark根據每個文件的大小自動設置要在每個文件上運行的map task的數量。對於分佈式的reduce操作,例如groupByKey和reduceByKey,它使用最大的父RDD的分區數。你可以將並行度作爲第二個參數傳遞,或設置屬性spark.default.parallelism來更改默認值。通常,我們建議集羣中每個CPU xore執行2-3個task。

reduce端task內存佔用 - Memory Usage of Reduce Tasks

有時候,您的應用程序發生OOM錯誤並不是因爲RDD無法放入內存中,而是因爲其中一個task的工作集太大,例如groupByKey中的一個reduce task數據太多。Spark的shuffle操作(sortByKey,groupByKey,reduceByKey,join等)在每個task中構建了一個hash table來執行聚合分組,這通常會包含大量的數據。緩解這種情況最簡單的方法就是增加並行度,這樣每個task的處理的數據就會變少。Spark可以有效地支持短至200ms的task,因爲它可以對許多tasks重用一個executor JVM,而且啓動task成本很低,因此你可以安全將並行度增加到集羣core數量以上。

廣播大變量 - Broadcasting Large Variables

使用SparkContext中的廣播功能可以極大地減少每個序列化task的大小和集羣啓動job的成本。如果你的task使用了driver端任何的大對象,可以考慮將這些對象轉換爲廣播變量。Spark在master節點打印每個task的序列化大小,因此您可以查看來確定task是否太大,一般來說,大於20KB的task值得去優化。

數據本地性 - Data Locality

數據所在的位置對Spark作業的性能有很大的影響。如果數據和要處理數據的代碼在同一個地方,那麼計算速度往往就很快。但是,如果代碼和數據不在同一個地方,那麼其中一個必須移動到另外一個所在的地方。通常情況下,移動代碼比移動數據要快得多,因爲代碼的大小要比數據小的多。Spark就是根據這種原則來進行調度的。

數據所在的位置就是指數據與處理數據的代碼之間的距離。根據數據當前的位置,有幾個級別的距離,按順序從最近到最遠:

  • PROCESS_LOCAL 數據和運行代碼位於同一個JVM中。這是最好的情況。
  • NODE_LOCAL 數據和運行代碼位於同一個節點。這會比PROCESS_LOCAL 慢一點,因爲數據要在進程之間傳輸。
  • NO_PREF 從任何地方訪問數據都是一樣快的。
  • RACK_LOCAL 數據位於同一個服務器機架上。數據位於同一機架的不同服務器上,因此需要通過網絡傳輸數據,通常是經過一個交換機。
  • ANY 數據位於其他機架上。

Spark會優先調度task在最佳的位置級別,但這並不總是可能的。在任何空閒executor上都沒有未處理的數據的情況下,Spark會切換到更低的位置級別。有兩種選擇:a) 等待CPU空閒下來,在同一服務器上啓動一個task,或b) 立即在遠端啓動一個task,並要求將數據移動到那裏。
Spark通常的策略就是,先等待一段時間,希望繁忙的CPU能得到釋放,一旦超過指定時間,就開始將數據從遠端移動到空閒的CPU。每個位置級別之間的超時時間都可以單獨配置,也可以全部配置在一個參數中。關於spark.locality參數的詳細信息,請查看configuration page。如果您的tasks運行時間很長並且位置級別很差,那麼可以增加配置的值,但是默認的設置通常就能滿足多數的情況。
 

總結 - Summary

這篇簡短的調優指南指出了在調優Spark應用程序時,應該關注的主要的點——最重要的是數據序列化和內存調優。對於大多數應用程序,切換到Kryo序列化,並以序列化的形式持久化數據就能解決大多數常見的性能問題。

 

參考

Tuning Spark

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章