一文讀懂 PyTorch 顯存管理機制
最近在做 RLHF 場景下的訓練和推理複用機器的優化,需要及時釋放掉不用的 GPU 資源。翻出了之前和同事一起寫的這篇文章,雖然過去 2 年了,但依然好看,因此轉載到這裏。
本文使用的 PyTorch 源碼爲 master 分支,commit id 爲 a5b848aec10b15b1f903804308eed4140c5263cb。
背景介紹
剖析 PyTorch 顯存管理機制主要是爲了減少顯存碎片化帶來的影響。一個簡單示例爲:
如上圖所示,假設當前想分配 800MB 顯存,雖然空閒的總顯存有 1000MB,但是上方圖的空閒顯存由地址不連續的兩個 500MB 的塊組成,不夠分配這 800MB 顯存;而下方的圖中,如果兩個 500MB 的空閒塊地址連續,就可以通過顯存碎片的整理組成一個 1000MB 的整塊,足夠分配 800MB。上方圖的這種情況就被稱爲顯存碎片化。
解決方法: 當有多個 Tensor 可以被釋放時,可以優先釋放那些在內存空間上前後有空閒內存的 Tensor。這樣便於 PyTorch 整理這些空閒塊組成的碎片,讓三個塊組成一整個更大的塊。
核心問題 / 前提: 能否以非常小的代價( O(1) 或 O(logn) 複雜度)拿到當前想要釋放的塊,以及它前後的空閒空間大小?有了這個,我們可以對 Tensor 排序,優先選擇:“前後空閒塊 + 自己大小” 最大的 Tensor 來釋放。
這一前提可以轉化爲:找到下面兩個函數(pseudo code):
block = get_block(tensor) # 找到存儲該 Tensor 的 Block
size = get_adjacent_free_size(block) # 返回該 Block 前後的空餘空間,便於排序
研究 PyTorch 顯存管理機制後可能能回答的問題:
-
爲什麼報錯信息裏提示顯存夠,但還是遇到了 OOM?
-
顯存的多級分配機制是怎樣的?爲什麼要這樣設計?
源碼解讀
主要看 c10/cuda/CUDACachingAllocator.cpp
裏面的 DeviceCachingAllocator 類(L403)
主要的數據結構
Block:
-
分配 / 管理內存塊的基本單位,(stream_id, size, ptr) 三元組可以特異性定位一個 Block,即 Block 維護一個 ptr 指向大小爲 size 的內存塊,隸屬於 stream_id 的 CUDA Stream。
-
所有地址連續的 Block(不論是否爲空閒,只要是由 Allocator::malloc 得來的)都被組織在一個雙向鏈表裏,便於在釋放某一個 Block 時快速檢查前後是否存在相鄰碎片,若存在可以直接將這三個 Block 合成爲一個。
struct Block {
int device; // gpu
// Block 和被分配時調用者的 stream 是綁定的,即使釋放了也無法給其他 stream 使用
cudaStream_t stream; // allocation stream
stream_set stream_uses; // streams on which the block was used
size_t size; // block size in bytes
BlockPool* pool; // owning memory pool
void* ptr; // memory address
bool allocated; // in-use flag
Block* prev; // prev block if split from a larger allocation
Block* next; // next block if split from a larger allocation
int event_count; // number of outstanding CUDA events
};
BlockPool:
- 內存池,用
std::set
存儲 Block 的指針,按照 (cuda_stream_id -> block size -> addr) 的優先級從小到大排序。所有保存在 BlockPool 中的 Block 都是空閒的。
struct BlockPool {
BlockPool(
Comparison comparator,
bool small,
PrivatePool* private_pool = nullptr)
: blocks(comparator), is_small(small), owner_PrivatePool(private_pool) {}
std::set<Block*, Comparison> blocks;
const bool is_small;
PrivatePool* owner_PrivatePool; // 供 CUDA Graphs 使用,此處不詳講
};
DeviceCachingAllocator
中維護兩種 BlockPool (large_blocks, small_blocks),分別存放較小的塊和較大的塊(爲了分別加速小需求和大需求),簡單地將 <= 1MB 的 Block 歸類爲小塊,> 1MB 的爲大塊。
直觀理解 Block、BlockPool 見下圖:
BlockPool 示意圖,左邊兩個 500MB 的塊 size 相同,因此上面的比下面的地址更小
總結:Block 在 Allocator 內有兩種組織方式,一種是顯式地組織在 BlockPool(紅黑樹)中,按照大小排列;另一種是具有連續地址的 Block 隱式地組織在一個雙向鏈表裏(通過結構體內的 prev, next 指針),可以以 O(1) 時間查找前後 Block 是否空閒,便於在釋放當前 Block 時合併碎片。
malloc 函數解析:返回一個可用的 Block(L466)
一個區分:(出於簡潔考慮,下面全文都對這兩個表述進行區分,不多贅述)
-
size:請求分配的顯存大小
-
alloc_size:需要 cudaMalloc 時的顯存大小
一個 hard-coded: 根據 size 決定實際上的 alloc_size(get_allocation_size 函數,L1078):
-
爲小於 1MB 的 size 分配 2MB;
-
爲 1MB ~ 10MB 的 size 分配 20MB;
-
爲 >= 10MB 的 size 分配 {size 向上取整至 2MB 倍數} MB。
尋找是否有合適的 block 會有五個步驟,如果這五個步驟都沒找到合適的 Block,就會報經典的 [CUDA out of memory. Tried to allocate ...] 錯誤(具體見下 “分配失敗的情況”)。
步驟一:get_free_block 函數(L1088)
TLDR:嘗試在 Allocator 自己維護的池子中找一個大小適中的空閒 Block 返回。
-
TLDR = Too Long; Didn't Read
-
用當前的 (size, stream_id) 這二元組製作 Block Key 在對應的 BlockPool 中查找;
-
環境變量
PYTORCH_CUDA_ALLOC_CONF
中指定了一個閾值max_split_size_mb
,有兩種情況不會在此步驟分配:
-
需要的 size 小於閾值但查找到的 Block 的比閾值大(避免浪費 block);
-
兩方都大於閾值但 block size 比需要的 size 大得超過了 buffer(此處是 20MB,這樣最大的碎片不超過 buffer 大小)。
-
這裏的這個閾值
max_split_size_mb
涉及一個有趣的特性,後面會講到。 -
若成功分配一個 Block,則將這個 Block 從 BlockPool 中刪除。後續用完釋放(free)時會再 insert 進去,見 free_block : L976)
步驟二:trigger_free_memory_callbacks 函數(L1106)
TLDR:手動進行一波垃圾回收,回收掉沒人用的 Block,再執行步驟一。
-
若第一步的
get_free_block
失敗,則在第二步中先調用trigger_free_memory_callbacks
,再調用一次第一步的get_free_block
; -
trigger_free_memory_callbacks
本質上通過註冊調用了CudaIPCCollect
函數,進而調用 CudaIPCSentDataLimbo::collect 函數(torch/csrc/CudaIPCTypes.cpp : L64); -
CudaIPCSentDataLimbo
類管理所有 reference 不爲 0 的 Block,所以實際上 collect 函數的調用算是一種懶更新,直到無 Block 可分配的時候才調用來清理那些 reference 已經爲 0 的 Block(值得一提的是,該類的析構函數會首先調用 collect 函數,見 torch/csrc/CudaIPCTypes.cpp : L58); -
相關源碼可以看
torch/csrc/CudaIPCTypes.h
&.cpp
。
步驟三:alloc_block 函數(L1115)
TLDR:Allocator 在已有的 Block 中找不出可分配的了,就調用 cudaMalloc 創建新的 Block。
-
步驟一、二中重用 block 失敗,於是用 cudaMalloc 分配內存,大小爲 alloc_size;
-
注意有一個參數 set_fraction 會限制可分配的顯存爲當前剩餘的顯存 * fraction(若需要分配的超過這一限制則失敗),但還沒搞清楚哪裏會指定這個(TODO);
-
新分配的內存指針會被用於創建一個新 Block,新 Block 的 device 與 cuda_stream_id 與 caller 保持一致。
上面幾個步驟都是試圖找到一些空閒顯存,下面是兩個步驟是嘗試進行碎片整理,湊出一個大塊顯存
步驟四:release_available_cached_blocks 函數(L1175)
TLDR:先在自己的池子裏釋放一些比較大的 Block,再用 cudaMalloc 分配看看
-
如果上面的
alloc_block
失敗了,就會嘗試先調用這一函數,找到比 size 小的 Block 中最大的,由大至小依次釋放 Block,直到釋放的 Block 大小總和 >= size(需要注意,這一步驟只會釋放那些大小大於閾值max_split_size_mb
的 Block,可以理解爲先釋放一些比較大的); -
釋放 block 的函數見
release_block
(L1241),主要就是 cudaFree 掉指針,再處理一些 CUDA graphs 的依賴,更新其他數據結構等等,最後把這個 Block 從 BlockPool 中移除; -
當前整個 BlockPool(作爲提醒,Allocator 有兩個 BlockPool,這裏指的是最初根據 size 指定的大 pool 或小 pool)中,可以釋放的 Block 需要滿足兩個條件:cuda_stream_id 相同的,且大小要大於閾值
max_split_size_mb
。如果將這樣的 Block 全部釋放的空間仍比 size 小,那麼這一步就會失敗。 -
釋放掉了一批 Block 之後,再次執行步驟三中的
alloc_block
函數,創建新 Block。
步驟五:release_cached_blocks 函數(L1214)
- 如果釋放一些 Block 還不夠分配,則把整個 Allocator 中的 large / small pool 全部釋放掉(同樣調用 release_block:L1241),再次調用
alloc_block
函數。
malloc 分配失敗的情況
會報經典的 CUDA out of memory. Tried to allocate ...
錯誤,例如:
CUDA out of memory. Tried to allocate 1.24 GiB (GPU 0; 15.78 GiB total capacity; 10.34 GiB already allocated; 435.50 MiB free; 14.21 GiB reserved in total by PyTorch)
-
Tried to allocate:指本次 malloc 時預計分配的 alloc_size;
-
total capacity:由 cudaMemGetInfo 返回的 device 顯存總量;
-
already allocated:由統計數據記錄,當前爲止請求分配的 size 的總和;
-
free:由 cudaMemGetInfo 返回的 device 顯存剩餘量;
-
reserved:BlockPool 中所有 Block 的大小,與已經分配的 Block 大小的總和。
即 [reserved] = [already allocated] + [sum size of 2 BlockPools]
注意,reserved + free 並不等同於 total capacity,因爲 reserved 只記錄了通過 PyTorch 分配的顯存,如果用戶手動調用 cudaMalloc 或通過其他手段分配到了顯存,是沒法在這個報錯信息中追蹤到的(又因爲一般 PyTorch 分配的顯存佔大部分,分配失敗的報錯信息一般也是由 PyTorch 反饋的)。
在這個例子裏,device 只剩 435.5MB,不夠 1.24GB,而 PyTorch 自己保留了 14.21GB(儲存在 Block 裏),其中分配了 10.3GB,剩 3.9GB。那爲何不能從這 3.9GB 剩餘當中分配 1.2GB 呢?原因肯定是碎片化了,而且是做了整理也不行的情況。
實現自動碎片整理的關鍵特性:split
前面提到,通過環境變量會指定一個閾值 max_split_size_mb
,實際上從變量名可以看出,指定的是最大的可以被 “split” 的 Block 的大小。
在本節最開頭解釋過,步驟三 alloc_block
函數通過 cuda 新申請的 Block(這是 Allocator 唯一一個創建新 Block 的途徑)大小是計算出的 alloc_size 而不是用戶通過 malloc 請求的大小 size。因此,如果 malloc 在上述五個步驟中成功返回了一個 Block,Allocator 會通過函數 should_split
(L1068) 檢查:
-
由於過量分配內存,Block 內部產生的大小爲 alloc_size - size 的碎片大小稱爲 remaining;
-
如果這個 Block 屬於 small_blocks 且 remaining >= 512 Bytes;或者 remaining >= 1MB 且該 Block 大小沒有超過上述的閾值,則這個 Block 需要被 split。
被 split 的操作很簡單,當前的 Block 會被拆分成兩個 Block,第一個大小正好爲請求分配的 size,第二個則大小爲 remaining,被掛到當前 Block 的 next 指針上(這一過程見源碼 L570~L584)。這樣一來,這兩個 Block 的地址自然而然成爲連續的了。隨着程序運行,較大的 Block(只要仍小於閾值 max_split_size_mb
)會不斷被分成小的 Block。值得注意的是,由於新 Block 的產生途徑只有一條,即通過步驟三中的 alloc_block
函數經由 cudaMalloc 申請,無法保證新 Block 與其他 Block 地址連續,因此所有被維護在雙向鏈表內的有連續地址空間的 Block 都是由一個最初申請來的 Block 拆分而來的。
一段連續空間內部(由雙向鏈表組織的 Block 們)如下圖所示:
連續空間內部的雙向鏈表(鏈表箭頭已省略)
當 Block 被釋放時,會檢查其 prev、next 指針是否爲空,及若非空是否正在被使用。若沒有在被使用,則會使用 try_merge_blocks (L1000) 合併相鄰的 Block。由於每次釋放 Block 都會檢查,因此不會出現兩個相鄰的空閒塊,於是只須檢查相鄰的塊是否空閒即可。這一檢查過程見 free_block 函數(L952)。又因爲只有在釋放某個 Block 時纔有可能使多個空閒塊地址連續,所以只需要在釋放 Block 時整理碎片即可。
關於閾值 max_split_size_mb
,直覺來說應該是大於某個閾值的 Block 比較大,適合拆分成稍小的幾個 Block,但這裏卻設置爲小於這一閾值的 Block 才進行拆分。個人理解是,PyTorch 認爲,從統計上來說大部分內存申請都是小於某個閾值的,這些大小的 Block 按照常規處理,進行拆分與碎片管理;但對大於閾值的 Block 而言,PyTorch 認爲這些大的 Block 申請時開銷大(時間,失敗風險),可以留待分配給下次較大的請求,於是不適合拆分。默認情況下閾值變量 max_split_size_mb
爲 INT_MAX,即全部 Block 都可以拆分。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/2Mx-6Tf95Pku3QqqzjAo7A