歡迎關注我的微信公衆號:FunnyBigData
作爲打着 “內存計算” 旗號出道的 Spark,內存管理是其非常重要的模塊。作爲使用者,搞清楚 Spark 是如何管理內存的,對我們編碼、調試及優化過程會有很大幫助。本文之所以取名爲 "Spark 內存管理的前世今生" 是因爲在 Spark 1.6 中引入了新的內存管理方案,而在之前一直使用舊方案。
剛剛提到自 1.6 版本引入了新的內存管理方案,但並不是說在 1.6 及之後的版本中不能使用舊的方案,而是默認使用新方案。我們可以通過設置 spark.memory.userLegacyMode
值來選擇,該值爲 false
表示使用新方案,true
表示使用舊方案,默認爲 false
。該值是如何發揮作用的呢?如下:
val useLegacyMemoryManager = conf.getBoolean("spark.memory.useLegacyMode", false)
val memoryManager: MemoryManager =
if (useLegacyMemoryManager) {
new StaticMemoryManager(conf, numUsableCores)
} else {
UnifiedMemoryManager(conf, numUsableCores)
}
根據 spark.memory.useLegacyMode
值的不同,會創建 MemoryManager 不同子類的實例:
- 值爲
false
:創建UnifiedMemoryManager
類實例,爲新的內存管理的實現 - 值爲
true
:創建StaticMemoryManager
類實例,爲舊的內存管理的實現
不管是在新方案中還是舊方案中,都根據內存的不同用途,都包含三大塊。
- storage 內存:用於緩存 RDD、展開 partition、存放 Direct Task Result、存放廣播變量。在 Spark Streaming receiver 模式中,也用來存放每個 batch 的 blocks
- execution 內存:用於 shuffle、join、sort、aggregation 中的緩存、buffer
storage 和 execution 內存都通過 MemoryManager 來申請和管理,而另一塊內存則不受 MemoryManager 管理,主要有兩個作用:
- 在 spark 運行過程中使用:比如序列化及反序列化使用的內存,各個對象、元數據、臨時變量使用的內存,函數調用使用的堆棧等
- 作爲誤差緩衝:由於 storage 和 execution 中有很多內存的使用是估算的,存在誤差。當 storage 或 execution 內存使用超出其最大限制時,有這樣一個安全的誤差緩衝在可以大大減小 OOM 的概率
這塊不受 MemoryManager 管理的內存,由系統預留以及 storage 和 execution 安全係數之外的內存組成,這個會在下文中詳述。
接下來,讓我們先來看看 “前世”
前世
舊方案的內存結構如下圖所示:
讓我們結合上圖做進一步說明:
execution 內存
execution 最大可用內存爲 jvm space * spark.storage.memoryFraction * spark.storage.safetyFraction
,默認爲 jvm space * 0.2 * 0.8
。
spark.shuffle.memoryFraction
很大程度上影響了 spill 的頻率,如果 spill 過於頻繁,可以適當增大 spark.shuffle.memoryFraction
的值,增加用於 shuffle 的內存,減少Spill的次數。這樣一來爲了避免內存溢出,可能需要減少 storage 的內存,即減小spark.storage.memoryFraction
的值,這樣 RDD cache 的容量減少,在某些場景下可能會對性能造成影響。
由於 shuffle 數據的大小是估算出來的(這主要爲了減少計算數據大小的時間消耗),會存在誤差,當實際使用的內存比估算大的時候,這裏 spark.shuffle.safetyFraction
用來作爲一個保險係數,增加一定的誤差緩衝,降低實際內存佔用超過用戶配置值的可能性。所以 execution 真是最大可用的內存爲 0.2*0.8=0.16
。shuffle 時,一旦 execution 內存使用超過該比例,就會進行 spill。
storage 內存
storage 最大可用內存爲 jvm space * spark.storage.memoryFraction * spark.storage.safetyFraction
,默認爲 jvm space * 0.6 * 0.9
。
由於在 cache block 時大小也是估算的,所以也需要一個保險係數用來防止誤差引起 OOM,即 spark.storage.safetyFraction
,所以真實能用來進行 memory cache block 的內存大小的比例爲 0.6*0.9=0.54
。一旦 storage 使用內存超過該比例,將根據 StorageLevel 決定不緩存 block 還是 OOM 或是存儲到磁盤。
storage 內存中有 spark.shuffle.unrollFraction
的部分是用來 unroll,即用於 “展開” 一個 partition 的數據,這部分默認爲 0.2
不由 MemoryManager 管理的內存
系統預留的大小爲:1 - spark.storage.memoryFraction - spark.shuffle.memoryFraction
,默認爲 0.2。另一部分是 storage 和 execution 保險係數之外的內存大小,默認爲 0.1。
存在的問題
舊方案最大的問題是 storage 和 execution 的內存大小都是固定的,不可改變,即使 execution 有大量的空閒內存且 storage 內存不足,storage 也無法使用 execution 的內存,只能進行 spill,反之亦然。所以,在很多情況下存在資源浪費。
另外,舊方案中,只有 execution 內存支持 off heap,storage 內存不支持 off heap。
今生
上面我們提到舊方案的兩個不足之處,在新方案中都得到了解決,即:
- 新方案 storage 和 execution 內存可以互相借用,當一方內存不足可以向另一方借用內存,提高了整體的資源利用率
- 新方案中 execution 內存和 storage 內存均支持 off heap
這兩點將在後文中進一步展開,我們先來看看新方案中,默認的內存結構是怎樣的?依舊分爲三塊(這裏將 storage 和 execution 內存放在一起講):
- 不受 MemoryManager 管理內存,由以下兩部分組成:
- 系統預留:大小默認爲
RESERVED_SYSTEM_MEMORY_BYTES
,即 300M,可以通過設置spark.testing.reservedMemory
改變,一般只有測試的時候纔會設置該配置,所以我們可以認爲系統預留大小爲 300M。另外,executor 的最小內存限制爲系統預留內存的 1.5 倍,即 450M,若 executor 的總內存大小小於 450M,則會拋出異常 - storage、execution 安全係數外的內存:大小爲
(heap space - RESERVED_SYSTEM_MEMORY_BYTES)*(1 - spark.memory.fraction)
,默認爲(heap space - 300M)* 0.4
- 系統預留:大小默認爲
- storage + execution:storage、execution 內存之和又叫 usableMemory,總大小爲
(heap space - 300) * spark.memory.fraction
,spark.memory.fraction
默認爲 0.6。該值越小,發生 spill 和 block 踢除的頻率就越高。其中:- storage 內存:默認佔其中 50%(包含 unroll 部分)
- execution 內存:默認同樣佔其中 50%
由於新方案是 1.6 後默認的內存管理方案,也是目前絕大部分 spark 用戶使用的方案,所以我們有必要更深入且詳細的展開分析。
初探統一內存管理類
在最開始我們提到,新方案是由 UnifiedMemoryManager
實現的,我們先來看看該類的成員及方法,類圖如下:
通過這個類圖,我想告訴你這幾點:
- UnifiedMemoryManager 具有 4 個 MemoryPool,分別是堆內的 onHeapStorageMemoryPool 和 onHeapExecutionMemoryPool 以及堆外的 offHeapStorageMemoryPool 和 offHeapExecutionMemoryPool(其中,execution 和 storage 使用堆外內存的方式不同,後面會講到)
- UnifiedMemoryManager 申請、釋放 storage、execution、unroll 內存的方法(看起來像廢話)
- tungstenMemoryAllocator 會根據不同的 MemoryMode 來生成不同的 MemoryAllocator
- 若 MemoryMode 爲 ON_HEAP 爲 HeapMemoryAllocator
- 若 MemoryMode 爲 OFF_HEAP 則爲 UnsafeMemoryAllocator(使用 unsafe api 來申請堆外內存)
如何申請 storage 內存
有了上面的這些基礎知識,再來看看是怎麼申請 storage 內存的。申請 storage 內存是通過調用
UnifiedMemoryManager#acquireStorageMemory(blockId: BlockId,
numBytes: Long,
memoryMode: MemoryMode): Boolean
更具體的說法應該是爲某個 block(blockId 指定)以那種內存模式(on heap 或 off heap)申請多少字節(numBytes)的 storage 內存,該函數的主要流程如下圖:
對於上圖,還需要做一些補充來更好理解:
MemoryMode
- 如果 MemoryMode 是 ON_HEAP,那麼 executionMemoryPool 爲 onHeapExecutionMemoryPool、storageMemoryPool 爲 onHeapStorageMemoryPool。maxMemory 爲
(jvm space - 300M)* spark.memory.fraction
,如果你還記得的話,這在文章最開始的時候有介紹 - 如果 MemoryMode 是 OFF_HEAP,那麼 executionMemoryPool 爲 offHeapExecutionMemoryPool、storageMemoryPool 爲 offHeapMemoryPool。maxMemory 爲 maxOffHeapMemory,由
spark.memory.offHeap.size
指定,由 execution 和 storage 共享
要向 execution 借用多少?
計算要向 execution 借用多少內存的代碼如下:
val memoryBorrowedFromExecution = Math.min(executionPool.memoryFree, numBytes)
爲 execution 空閒內存和申請內存 size 的較小值,這說明了兩點:
- 能借用到的內存大小可能是小於申請的內存大小的(當
executionPool.memoryFree < numBytes
),更進一步說,成功借用到的內存加上 storage 原本空閒的內存之和有可能還是小於要申請的內存大小 - execution 只可能把自己當前空閒的內存借給 storage,即使在這之前 execution 已經從 storage 借來了大量內存,也不會釋放自己已經使用的內存來 “還” 給 storage。execution 這麼不講道理是因爲要實現釋放 execution 內存來歸還給 storage 複雜度太高,難以實現
還有一點需要注意的是,借用是發生在相同 MemoryMode 的 storageMemoryPool 和 executionMemoryPool 之間,不能在不同的 MemoryMode 間進行借用
借到了就萬事大吉?
當 storage 空閒內存不足以分配申請的內存時,從上面的分析我們知道會向 execution 借用,借來後是不是就萬事大吉了?當然······不是,前面也提到了即使借到了內存也可能還不夠,這也是上圖中紅色圓框中問號的含義,在我們再進一步跟進到 StorageMemoryPool#acquireMemory(blockId: BlockId, numBytes: Long): Boolean
中一探究竟,該函數主要流程如下:
同樣,對於上面這個流程圖需要做一些說明:
計算要釋放的內存量
val numBytesToFree = math.max(0, numAcquireBytes - memoryFree)
如上,要釋放的內存大小爲再從 execution 借用了內存,使得 storage 空閒內存增大 n(n>=0) 後,還比申請的內存少的那部分內存,若借用後 storage 空閒內存足以滿足申請的大小,則 numBytesToFree 爲 0,無需進行釋放
如何釋放 storage 內存?
釋放的方式是踢除已緩存的 blocks,實現爲 evictBlocksToFreeSpace(blockId: Option[BlockId], space: Long, memoryMode: MemoryMode): Long
,有以下幾個原則:
- 只能踢除相同 MemoryMode 的 block
- 不能踢除屬於同一個 RDD 的另一個 block
首先會進行預踢除(所謂預踢除就是計算假設踢除該 block 能釋放多少內存),預踢除的具體邏輯是:遍歷一個已緩存到內存的 blocks 列表(該列表按照緩存的時間進行排列,約早緩存的在越前面),逐個計算預踢除符合原則的 block 是否滿足以下條件之一:
- 預踢除的累計總大小滿足要踢除的大小
- 所有的符合原則的 blocks 都被預踢除
若最終預踢除的結果是可以滿足要提取的大小,則對預踢除中記錄的要踢除的 blocks 進行真正的踢除。具體的方式是:如果從內存中踢除後,還具有其他 StorageLevel 或在其他節點有備份,依然保留該 block 信息;若無,則刪除該 block 信息。最終,返回踢除的總大小(可能稍大於要踢除的大小)。
若最終預踢除的結果是無法滿足要提取的大小,則不進行任何實質性的踢除,直接返回踢除size 爲 0。需要再次提醒的是,只能踢除相同 MemoryMode 的 block。
以上,結合兩幅流程圖及相應的說明,相信你已經搞清楚如何申請 storage 內存了。我們再來看看 execution 內存是如何申請的
如何申請 execution 內存
我們知道,申請 storage 內存是爲了 cache 一個 numBytes 的 block,結果要麼是申請成功、要麼是申請失敗,不存在申請到的內存數比 numBytes 少的情況,這是因爲不能將 block 一部分放內存,一部分 spill 到磁盤。但申請 execution 內存則不同,申請 execution 內存是通過調用
UnifiedMemoryManager#acquireExecutionMemory(numBytes: Long,
taskAttemptId: Long,
memoryMode: MemoryMode): Long
來實現的,這裏的 numBytes 是指至多 numBytes,最終申請的內存數比 numBytes 少也是成功的,比如在 shuffle write 的時候使用的時候,如果申請Å的內存不夠,則進行 spill。
另一個特點是,申請 execution 時可能會一直阻塞,這是爲了能確保每個 task 在進行 spill 之前都能佔用至少 1/2N 的 execution pool 內存數(N 爲 active tasks 數)。當然,這也不是能完全確保的,比如 tasks 數激增但老的 tasks 還沒釋放內存就不能滿足。
接下來,我們來看看如何申請 execution 內存,流程圖如下:
從上圖可以看到,整個流程還是挺複雜的。首先,我先對上圖中的一些環節進行進一步說明以幫助理解,最後再以簡潔的語言來概括一下整個過程。
MemoryMode
同樣,不同的 MemoryMode 的情況是不同的,如下:
- 如果 MemoryMode 爲 ON_HEAP:
- executionMemoryPool 爲 onHeapExecutionMemoryPool
- storageMemoryPool 爲 onHeapStorageMemoryPool
- storageRegionSize 爲 onHeapStorageRegionSize,即
(heap space - 300M) * spark.memory.storageFraction
- maxMemory 爲 maxHeapMemory,即
(heap space - 300M)
- 如果 MemoryMode 爲 OFF_HEAP:
- executionMemoryPool 爲 offHeapExecutionMemoryPool
- storageMemoryPool 爲 offHeapStorageMemoryPool
- maxMemory 爲 maxOffHeapMemory,即
spark.memory.offHeap.size
- storageRegionSize 爲 offHeapStorageRegionSize,即
maxOffHeapMemory * spark.memory.storageFraction
這一小節描述的內容非常重要,因爲之後所有的流程都是基於此,看到後面的流程時,還記着會有 ON_HEAP 和 OFF_HEAP 兩種情況
maybeGrowExecutionPool(向 storage 借用內存)
只有當 executionMemoryPool 的空閒內存不足以滿足申請的 numBytes 時,該函數纔會生效。那這個函數是怎麼向 storage 借用內存的呢?流程如下:
- 計算可從 storage 回收的內存 memoryReclaimableFromStorage,爲 storage 當前的空閒內存和之前 storage 從 execution 借走的內存中較大的那個
- 如果 memoryReclaimableFromStorage 爲 0,說明之前 storage 沒有從 execution 這邊借用過內存並且 storage 自己已經把內存用完了,沒有任何內存可以借給 execution,那麼本次借用就失敗,直接返回;如果 memoryReclaimableFromStorage 大於 0,則進入下一步
- 計算本次真正要借用的內存 spaceToReclaim,即 execution 不足的內存(申請的內存減去 execution 的空閒內存)與 memoryReclaimableFromStorage 中的較小值。原則是即使能借更多,也只借夠用的就行
- 執行借用操作,如果需要 storage 的空閒內存和之前 storage 從 execution 借用的的內存加起來才能滿足,則會進行踢除 cached blocks
以上就是整個 execution 向 storage 借用內存的過程,與 storage 向 execution 借用最大的不同是:execution 會踢除 storage 已經使用的向 execution 的內存,踢除的流程在文章的前面有描述。這是因爲,這本來就是屬於 execution 的內存並且通過踢除來實現歸還實現上也不復雜
一個 task 能使用多少 execution 內存?
也就是流程圖中的 maxMemoryPerTask 和 minMemoryPerTask 是如何計算的,如下:
val maxPoolSize = computeMaxExecutionPoolSize()
val maxMemoryPerTask = maxPoolSize / numActiveTasks
val minMemoryPerTask = poolSize / (2 * numActiveTasks)
maxPoolSize 爲從 storage 借用了內存後,executionMemoryPool 的最大可用內存,maxMemoryPerTask 和 minMemoryPerTask 的計算方式也如代碼所示。這樣做是爲了使得每個 task 使用的內存都能維持在 1/2*numActiveTasks ~ 1/numActiveTasks
範圍內,使得在整體上能保持各個 task 資源佔用比較均衡並且一定程度上允許需要更多資源的 task 在一定範圍內能分配到更多資源,也照顧到了個性化的需求
最後到底分配多少 execution 內存?
首先要計算兩個值:
- 最大可以分配多少,即 maxToGrant:是申請的內存量與
(maxMemoryPerTask-已爲該 task 分配的內存值)
中的較小值,如果maxMemoryPerTask < 已爲該 task 分配的內存值
,則直接爲 0,也就是之前已經給該 task 分配的夠多了 - 本次循環真正可以分配多少,即 toGrant:maxToGrant 與當前 executionMemoryPool 空閒內存(注意是借用後)的較小值
所以,本次最終能分配的量也就是 toGrant,如果 toGrant 加上已經爲該 task 分配的內存量之和
還小於 minMemoryPerTask 並且 toGrant 小於申請的量,則就會觸發阻塞。否則,分配 toGrant 成功,函數返回。
阻塞釋放的條件有兩個,如下:
- 有 task 釋放了內存:更具體的說是有 task 釋放了相同 MemoryMode 的 execution 內存,這時空閒的 execution 內存變多了
- 有新 task 申請了內存:同樣,更具體的說是有新 task 申請了相同 MemoryMode 的 execution 內存,這時 numActiveTasks 變大了,minMemoryPerTask 則變小了
用簡短的話描述整個過程如下:
- 申請 execution 內存時,會循環不停的嘗試,每次嘗試都會看是否需要從 storage 中借用或回收之前借給 storage 的內存(這可能會觸發踢除 cached blocks),如果需要則進行借用或回收;
- 之後計算本次循環能分配的內存,
- 如果能分配的不夠申請的且該 task 累計分配的(包括本次)小於每個 task 應該獲得的最小值(1/2*numActiveTasks),則會阻塞,直到有新的 task 申請內存或有 task 釋放內存爲止,然後進入下一次循環;
- 否則,直接返回本次分配的值
使用建議
首先,建議使用新模式,所以接下來的配置建議都是基於新模式的。
-
spark.memory.fraction
:如果 application spill 或踢除 block 發生的頻率過高(可通過日誌觀察),可以適當調大該值,這樣 execution 和 storage 的總可用內存變大,能有效減少發生 spill 和踢除 block 的頻率 -
spark.memory.storageFraction
:爲 storage 佔 storage、execution 內存總和的比例。雖然新方案中 storage 和 execution 之間可以發生內存借用,但總的來說,spark.memory.storageFraction
越大,運行過程中,storage 能用的內存就會越多。所以,如果你的 app 是更吃 storage 內存的,把這個值調大一點;如果是更吃 execution 內存的,把這個值調小一點 -
spark.memory.offHeap.enabled
:堆外內存最大的好處就是可以避免 GC,如果你希望使用堆外內存,將該值置爲 true 並設置堆外內存的大小,即設置spark.memory.offHeap.size
,這是必須的
另外,需要特別注意的是,堆外內存的大小不會算在 executor memory 中,也就是說加入你設置了 --executor memory 10G
和 spark.memory.offHeap.size=10G
,那總共可以使用 20G 內存,堆內和堆外分別 10G。
總結&引子
到這裏,已經比較籠統的介紹了 Spark 內存管理的 “前世”,也比較細緻的介紹了 “今生”。篇幅比較長,但沒有一大段一大段的代碼,應該還算比較好懂。如果看到這裏,希望你多少能有所收穫。
然後,請你在大致回顧下這篇文章,有沒有覺得缺了點什麼?是的,是缺了點東西,所謂 “內存管理” 怎麼就沒看到具體是怎麼分配內存的呢?是怎麼使用的堆外內存?storage 和 execution 的堆外內存使用方式會不會不同?execution 和 storage 又是怎麼使用堆內內存的呢?以怎麼樣的數據結構呢?
如果你想搞清楚這些問題,關注公衆號並回復 “內存管理下”。
歡迎關注我的微信公衆號:FunnyBigData