Kaldi内存泄漏问题排查
轉載自:https://www.baidu.com/link?url=uUnBEi2XoXwkMYf_mLzKuZmdz8auQ5mjvwYE0c5zsKS2kUcEMv3fo9wUmva2S84mX9GcchAup9S_y9iYp1OzBz0rhERJS3QImQNQxrmhTx7&wd=&eqid=b4847b6b0000c23c000000065fb21f0c
2019-08-05
賓狗
?工程
?Kaldi??內存泄漏
- 0x00 情況概述
- 0x01 排查過程
- valgrind的簡單使用
- 正確釋放STL當中vector的內存
- 堆內存碎片無法釋放
- 多線程中的陷阱
- 0x02 更進一步?
- 參考資料
最近在做Kaldi相關開發的過程中,遇到了一個非常棘手的內存問題,現將整個排查解決過程梳理一下,希望對有類似問題的同學有幫助。
0x00 情況概述
Kaldi是一個語音識別的C++開發框架,集成了非常多的工具和模塊。由于項目需要,希望能夠將CVTE開源的模型部署到內部線上測試使用,且能夠充分利用GPU加速,而網上的教程大多都是基于offline模式,使用的是nnet3和nnet3bin下面的模塊和程序。
當然其中nnet3bin也有一個使用了GPU顯卡的示例程序nnet3-latgen-faster-batch,但它并不是一個充分利用GPU計算的實現,整體還是比較低效的。NVIDIA的工程師在今年GTC19上開源了他們的實現,但是想要將CVTE模型在這個版本的實現上跑起來,需要定制化的做一些開發,具體的hacking過程就按下不表,我們的主題是排查內存的泄漏問題。程序運行起來之后,一個很顯著的問題是內存占用太高了,在原來的nnet3-latgen-faster-batch下占用內存差不多在17G左右,不會超過20G,而在使用了NVIDIA這個版本后,內存占用一度超過了35G,峰值甚至在40G以上,而且隨著需要識別音頻不斷被輸入進來,內存占用還在緩慢的上升。所以,非常有必要解決其中隱藏的內存問題。
0x01 排查過程
經過一番搜索,在Linux下常用的內存泄露檢查工具箱是valgrind,這是一個非常強大的工具,光說明使用手冊就有400多頁。
比較傻瓜的使用方式是直接使用valgrind下面的memcheck工具,當然這個工具并不是萬能的,在Kaldi這種比較復雜龐大的工程下面,想要定位出問題所在并不容易;所以我所使用的工具是massif,它能夠在程序執行的過程中截取快照,記錄每個快照當中,程序內存的詳細使用情況,精確到哪個模塊的哪一行代碼申請占用了內存。
valgrind的簡單使用
安裝配置過程可參考這個鏈接,如果沒有權限的話,安裝到自己目錄后配置好環境變量即可使用
執行命令
# 說明 # ./后面可以跟你需要tracking內存的程序及其參數 # 注意這個模式下只tracking堆內存,如果想要記錄所有內存情況,需要加上一個--page-as-heap選項 # threshold表示追蹤的最低的內存百分比,例如設為1.0表示占比在1.0%以下的內存就不記錄了 # time-unit是一個比較迷的參數,影響的是最后畫出內存變化趨勢圖,可選單位的有時間(ms)、指令數(默認)、內存量(B) # massif-out-file指定輸出的文件名 valgrind --tool=massif --threshold=0.0 --time-unit=ms --massif-out-file=memory_footprint ./batched-wav-nnet3-cuda config.yaml# valgrind提供了一個命令行版的可視化工具,可以簡單查看剛才的tracking結果文件 # 如果使用的是帶可視化界面的Linux,可以使用相應的圖形化工具 # 這里我們把結果輸出到一個文本文件里面,以便后續分析 ms_print memory_footprint > vis.txt基本的情況如下所示,可以看到堆內存隨時間的變化情況,下面還有詳細的每個snapshot內部的內存分部情況
一個詳細的snapshot內存記錄如下所示:
只要在編譯C++程序的時候加上了-g選項,就可以從這里詳細的看到是程序的哪一行代碼申請占用了內存;例如上圖有好處幾resize函數,那么很顯然就是std::vector里面的resize函數申請占用了內存
正確釋放STL當中vector的內存
在這個工具的幫助下,我很快定位到了一類比較常見的問題。只需重點關注std::vector的reserve和resize這兩個函數,如果某行代碼通過這種方式申請了內存,在后續的穩定運行過程中又沒有用到,且在程序快結束的時候依然占用著,那么這就是一個可以改進的點了。
這類問題網上一搜就能找到很多,主要是vector的STL實現當中函數名太有誤導性了
std::vector<obj> test = ...; ... test.clear();很多人以為調用了clear函數之后,其占用的內存空間就自然而然的釋放掉了,其實不然,vector當中有一個capacity的概念,調用clear函數只是使用vector的size變為了0,而其capacity沒有變化,也就是所占用的內存沒有發生變化,正確的釋放方式如下:
std::vector<obj>().swap(test);當然,在C++11當中,我們又多了一種選擇
test.clear(); test.shrink_to_fit();在NVIDIA工程師的實現當中,有好幾處類似這種沒有正確釋放vector內存的情況,一一更正之后內存占用下降到了30G左右。由于這部分不是本文的重點,具體的截圖就不貼出來了。
堆內存碎片無法釋放
接下來的這個問題就非常詭異了,為了說清楚情況,我先簡單介紹一下NVIDIA這個GPU實現和原來的相比有什么區別改進。
語音識別的流程大體上可以分為三個步驟(本人非專家,可能不嚴謹,只是從代碼的角度敘述),第一步是計算抽取聲學特征(features),第二步是根據聲學特征inference(NBatchCompute),第三步是解碼過程(decode),這個過程比較復雜也是最耗時耗內存的部分。在解碼的時候有用到一個非常大的文件HCLG.fst,可以把它理解為一個非常龐大的狀態轉移圖,解碼的過程就是在這個狀態圖當中搜索的過程。
在原來的非GPU版本中,kaldi使用了openfst來讀取并存儲這個狀態轉移圖;而在NVIDIA實現的GPU版本中,依然是先用openfst讀取并存儲這個狀態轉移圖,但是隨后會轉換成自己實現的一個cudafst,用于在GPU上進行高效的解碼。顯然,在后者的解碼過程當中,openfst那部分所占用的內存應該是完全不需要的,然而從htop中觀察到的內存占用情況卻晰的表明,在代碼中顯示的使用delete xxx(xxx為openfst對象)并沒有產生任何效果,也就是說相應的內存并沒有被釋放掉?于是我又使用valgrind再跑了一次,詭異的事情發生了,在valgrind的結果里面顯示,openfst那部分內存是被釋放掉了,可同時在htop里面觀察到的結果依然是內存占用居高不下,這個矛盾的結果讓我卡了很久。
折騰了很久之后,我瞎折騰把openfst對象里面的狀態轉移圖簡單log了一下,于是發現了一個很有意思的事情:所謂的狀態轉移圖在代碼層面,是一個有長度為1億多的std::vector,里面存儲的是對象指針,每個對象指針指向的對象又包含著一個小std::vector,每個大小不固定,徘徊在2~60之間
但是這個發現并沒有直接幫助我解決問題,我在瞎折騰時嘗試了另一種方法,delete掉openfst之后,再次創建一個同樣的openfst對象,并觀察htop中的內存占用情況,在這個情況下,delete之后,內存占用沒有減少,而創建一個新的openfst對象,內存幾乎沒有發現變化,因此這個嘗試告訴我們:內存并非沒有釋放,而是由于某種原因沒有返還給操作系統,依然由程序自身占用并管理著
于是,我又有針對性的在網上做了一番搜索,找到了解決方案。相關的資料我列在這里供大家參考:
- https://stackoverflow.com/questions/10943907/linux-allocator-does-not-release-small-chunks-of-memory
- https://www.cnblogs.com/lookof/archive/2013/03/26/2981768.html
簡單來說,“罪魁禍首”正是glibc的一個默認機制:非常小的內存塊在釋放時不返還給操作系統,而由程序自己管理,下次需要使用時直接分配,無需再向操作系統申請。這個機制的出發點的是好的,為了提高效率嘛,畢竟調用操作系統API沒有那么高效,何況一個普通的程序產生的內存碎片并不會太多,影響也無傷大雅。誰知道Kaldi所使用的這個openfst產生的內存碎片有1億多份,加起來總量有10多個G,而且幾乎全部在閾值以下,全都沒有釋放掉……
(ps:在Linux下,malloc()/free()的實現是由glibc負責的。這是一個相當底層的庫,它會根據一定的策略,與系統底層通信(調用系統API)。因為glibc的這層關系,在涉及到內存管理方面,用戶程序并不會直接和linux kernel進行交互,而是交由glibc托管,所以可以認為glibc提供了一個默認版本的內存管理器。它們的關系就像這樣:用戶程序 —> glibc —> linux kernel)
解決方法也非常簡單,只需要在原來的代碼中加一行malloc_trim(0)(依賴malloc.h)即可,這行代碼會將剛才所有的內存碎片返還給操作系統。
多線程中的陷阱
前面我們說到還有一個問題是隨著程序的運行,內存會不斷增長,顯然也是某個地方內存未被釋放累積引起的,但是遺憾的是,觀察valgrind的tracking結果之后發現,這部分內存不是在堆上,而是在棧上。而下面的這個問題需要我們深入到代碼層面,逐步調試。
具體的方法其實難度不大,但是對耐心要求較高……這里我所使用的方法是gdb外加watch監視內存變化。
# 先啟動gdb gdb ./batched-wav-nnet3-cuda config.yaml # 打開另外一個shell 可以利用tmux的panel,這樣在同一個界面里比較容易觀察 watch -n 0.1 -d cat /proc/[pid]/statm # statm顯示的結果的含義(注意這里的單位是頁,也就是4KB,換算內存占用的時候要乘以4) # size (1) total program size # (same as VmSize in /proc/[pid]/status) # resident (2) resident set size # (same as VmRSS in /proc/[pid]/status) # share (3) shared pages (i.e., backed by a file) # text (4) text (code) # lib (5) library (unused in Linux 2.6) # data (6) data + stack # dt (7) dirty pages (unused in Linux 2.6)# 上面的幾個結果,我們重點關注第二項resident即可如果目標程序是一個單線程的程序,那么這個方法可以說是非常perfect的,哪一行代碼申請占用了內存,所見即所得。
遺憾的是,我們面對的程序是一個多線程程序,所以使用gdb的step和continue調試當前線程的時候,其他線程也是同步執行的;也就是說如果你發現監視窗口當中內存占用增加了,那并不一定是當前代碼引起的……所以我們在調試當前線程時,必須鎖定其他線程,如下
set scheduler-locking off|on|step # off 不鎖定任何線程,也就是所有線程都執行,這是默認值。 # on 只有當前被調試程序會執行。 # step 在單步的時候,除了next過一個函數的情況(熟悉情況的人可能知道,這其實是一個設置斷點然后continue的行為)以外,只有當前線程會執行。但是這個方案并不完美,我們面對的這個多線程程序涉及到多個隊列,如果鎖死了其他線程,某些前置的任務沒有放進隊列,當前調試的線程也會卡住不動。總之,需要來回切換,非常折磨耐心,在一番折騰之后總算是定位到了問題所在,問題還是出在和STL相關的地方,std::unordered_map里面存儲的任務狀態未被釋放引起的。
更多gdb調試的tips可以參考下面這個文章
- https://coolshell.cn/articles/3643.html
0x02 更進一步?
當然,如果我們僅僅于滿足解決幾個小問題是遠遠不夠的,我們希望的是掌握一套方法論,在面對同樣甚至更復雜問題時能夠有條理和步驟的各個擊破。現在問題來了,valgrind的massif工具是自己決定在程序的哪個階段做snapshot,這個粒度是非常粗線條的。而單獨使用gdb的話又不能充分利用valgrind的優勢,只能單步慢慢分析,還要面對多線程的難題,效率低下。有沒有一種方法,能夠像外科手術一樣精確,比如執行到某行代碼時停住,然后用valgrind做snapshot,并用于后續對比分析?
這個時候如果仔細閱讀一下valgrind的說明文檔,就會發現這個工具的強大遠遠超出你的想像。valgrind集成了一個vgdb工具,能夠很好的和gdb配合起來,在調試程序的過程中,利用gdb步進到需要snapshot的位置,利用內置的命令即可snapshot,具體操方式如下所示:
# 基本命令和剛才類似,使用vgdb需要加入--vgdb=yes和--vgdb-error=0選項 valgrind --tool=massif --pages-as-heap=yes --threshold=0.0 --time-unit=ms --vgdb=yes --vgdb-error=0 --massif-out-file=memory_footprint ./batched-wav-nnet3-cuda config.yaml # 執行以上命令后,會啟動vgdb,同時出現一行提示信息,類似 # target remote | /path/to/your/vgdb --pid=xxxxx # 這個命令等下需要用到# 啟動另一個shell,執行 gdb ./batched-wav-nnet-cuda # 然后再執行剛才的命令 target remote | /path/to/your/vgdb --pid=xxxxx # 然后就可以像調試正常程序一樣下斷點、步進、步過等等 # ……# 當我們到達需要做snapshot的位置時 # 在gdb的shell里面輸入 monitor detailed_snapshot [file_name] # 就可以將當前狀態下的內存快照保存下來當我們有了幾個需要重點關注的狀態的內存snapshot以后,即可通過diff命令找出發生變化的地方,再進行具體的分析。
有了這樣一套強大的分析工具和流程,我覺得在面對大多數內存泄漏問題時,只要有足夠的耐心, 一定能夠準確定位到有問題的代碼部分~
總結
以上是生活随笔為你收集整理的Kaldi内存泄漏问题排查的全部內容,希望文章能夠幫你解決所遇到的問題。