Spark 內存管理的前世今生(上)

歡迎關注我的微信公衆號: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.fractionspark.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 借用內存的呢?流程如下:

  1. 計算可從 storage 回收的內存 memoryReclaimableFromStorage,爲 storage 當前的空閒內存和之前 storage 從 execution 借走的內存中較大的那個
  2. 如果 memoryReclaimableFromStorage 爲 0,說明之前 storage 沒有從 execution 這邊借用過內存並且 storage 自己已經把內存用完了,沒有任何內存可以借給 execution,那麼本次借用就失敗,直接返回;如果 memoryReclaimableFromStorage 大於 0,則進入下一步
  3. 計算本次真正要借用的內存 spaceToReclaim,即 execution 不足的內存(申請的內存減去 execution 的空閒內存)與 memoryReclaimableFromStorage 中的較小值。原則是即使能借更多,也只借夠用的就行
  4. 執行借用操作,如果需要 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 則變小了

用簡短的話描述整個過程如下:

  1. 申請 execution 內存時,會循環不停的嘗試,每次嘗試都會看是否需要從 storage 中借用或回收之前借給 storage 的內存(這可能會觸發踢除 cached blocks),如果需要則進行借用或回收;
  2. 之後計算本次循環能分配的內存,
    • 如果能分配的不夠申請的且該 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 10Gspark.memory.offHeap.size=10G,那總共可以使用 20G 內存,堆內和堆外分別 10G。

總結&引子

到這裏,已經比較籠統的介紹了 Spark 內存管理的 “前世”,也比較細緻的介紹了 “今生”。篇幅比較長,但沒有一大段一大段的代碼,應該還算比較好懂。如果看到這裏,希望你多少能有所收穫。

然後,請你在大致回顧下這篇文章,有沒有覺得缺了點什麼?是的,是缺了點東西,所謂 “內存管理” 怎麼就沒看到具體是怎麼分配內存的呢?是怎麼使用的堆外內存?storage 和 execution 的堆外內存使用方式會不會不同?execution 和 storage 又是怎麼使用堆內內存的呢?以怎麼樣的數據結構呢?

如果你想搞清楚這些問題,關注公衆號並回復 “內存管理下”。


歡迎關注我的微信公衆號:FunnyBigData

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