作者 | , & 譯者 |彎月,責編 | 夕顏出品 | CSDN(ID:)
簡介
背景介紹
我們有一個成熟的多線程框架:,用于在測序流水線中準備SAM和BAM文件。為了獲得良好的性能,我們的軟件體系結構希望一次通過一個SAM/BAM文件執行多個準備步驟,并盡可能地將測序數據保留在主存儲器中。與其他SAM/BAM工具類似,中堆的內存管理是一項復雜的任務,而且在近期的開發中,這個問題成為了該項目實現編程語言中嚴重的生產力瓶頸。因此,我們研究了三種可以替代的編程語言:首先是Go和Java(使用并發以及并行垃圾回收器),還有C++ 17(使用引用計數來處理大量堆對象)。我們用這三種語言重新實現了,并分別測試了運行時的性能和內存使用。
結果
Go實現的性能最佳,可在運行時性能和內存使用之間達到最佳平衡。盡管Java基準報告的運行時間比Go快一些,但運行Java的內存使用量明顯偏高。C++ 17的運行速度明顯慢于Go和Java,而且使用的內存卻比Go還要多。我們的分析表明,對于我們這種情況來說,與引用計數相比,通過并發以及并行垃圾回收管理大量對象的效果更好。
結論
根據我們的基準測試結果,我們選擇Go作為新的實現語言,而且我們認為Go是開發其他處理SAM/BAM數據生物信息學工具的理想選擇。
背景介紹
序列比對/映射格式(SAM / BAM)是生物信息學界存儲映射測序數據的標準。分析SAM/BAM文件的工具有很多。Broad和研究所開發的、以及 (GATK)軟件包被視為SAM/BAM文件許多操作的參考實現。這些操作包括讀取排序、標記聚合酶鏈反應和光學復制、重新校準基質量得分、插入缺失重新排列以及各種過濾選項。
很多替代軟件包專門提供這些操作的優化,這些軟件包或提供替代算法,或使用并行化、分布或其他特定于實現語言(通常是C、C++或Java)的優化技術。
我們開發的是一個開源的多線程框架,用于在測序流水線中處理SAM/BAM文件,專門為優化計算性能而設計。它可以代替、和GATK實現的許多操作,而且還會產生相同的結果。允許用戶通過一個命令在一個流水線中指定任意組合的SAM/BAM操作。然后,獨特的軟件體系結構可確保無論你指定了多少操作,運行這樣的流水線時只需傳遞一次SAM/BAM文件。該框架能夠合并和并行執行操作,明顯加快流水線的整體執行速度。
雖然我們并沒有將精力集中在優化單個SAM/BAM操作上,但結果表明,我們合并操作的方法更優。例如,與使用GATK4相比,執行4個步驟的廣泛最佳實踐的速度提高了13倍,整個基因組數據的處理速度提高了7.4倍,而耗費的計算資源則更少。
大多數編程語言都包含類似的通過顯式或隱式分配內存存儲堆對象的方法,堆對象與堆棧值不同,并不局限于函數或方法調用的生命周期。然而,各個編程語言在如何重新分配堆對象的內存方面有很大不同。
簡單來說,主要有三種方法:手動管理內存必須在程序源代碼中顯式釋放內存(例如,在C中調用free)。垃圾回收內存由運行時庫中單獨的組件(稱為垃圾收集器)自動管理。在任意時間點,它都需要遍歷對象圖,以確定正在運行的程序仍可直接或間接訪問哪些對象,并回收不可訪問的對象占用的內存。使用這種方法時不必明確地針對對象生命期進行建模,并且可以在程序中更自由地傳遞指針。大多數垃圾回收器實現都會中斷正在運行的程序,然后在垃圾回收完成后繼續執行,并使用順序算法執行對象圖遍歷。
然而,Java和Go所采用的高級實現技術可以在運行程序的同時遍歷對象圖,盡可能減少中斷,并使用多線程并行算法加速現代多核處理器上的垃圾收集。引用計數通過維護每個堆對象的引用計數來管理內存。每當發生指針分配時,引用計數就會根據每個對象引用的指針數目相應的增加或減少。每當引用計數降至零時,就釋放對應的對象。
最初(直到2.6版)是使用 Lisp編程語言實現的。大多數現有的 Lisp實現都使用會中斷應用程序的順序垃圾收集器。為了獲得良好的性能,我們有必要明確控制垃圾收集器運行的頻率和時間,以避免不必要地中斷主程序,尤其是在并行階段。因此,我們還必須避免不必要的內存分配,并盡可能重用已分配的內存,以減少垃圾收集器運行的次數。
然而,我們最近在嘗試為添加更多功能(例如光學重復標記、基本質量得分重新校準等)時,需要為這些新步驟分配額外的內存,于是情況更加復雜,并且保持內存分配和垃圾回收檢查成為了嚴重的生產力瓶頸。因此,為了繼續開發并實現良好的性能,我們開始尋找其他擁有不同的內存管理方法的編程語言。
現有關于比較編程語言及其實現性能的文獻通常都會使用特定的算法或內核,而根本不關心這些語言是否涵蓋特定領域,如生物信息學、經濟學或數值計算,還是只與編程有關的一般編程語言。僅有一篇文章考慮了并行算法。比較編程語言性能的在線資源也將重點放在特定的算法或內核上。的性能不僅來自并行排序或并發重復標記等步驟的高效并行算法,還源于將這些步驟整理成單通道、多線程流水線的整體軟件架構。
由于現有的文獻未涵蓋此類軟件體系結構,因此在本文中我們進行了一番研究。
是一個開放式軟件框架,允許流水線中不同功能步驟的任意組合,例如重復標記、讀取排序、替換讀取組等;另外,還提供了編寫第三方工具的功能步驟。這種開放性導致很難在程序運行期間準確地確定分配對象的生存周期。眾所周知,在開發此類軟件框架時,手動管理內存會導致生產力極其低下。例如,IBM的舊金山項目,從手動管理內存的C++轉換到擁有垃圾回收的Java后生產力提高了大約300%。其他處理SAM/BAM文件的開放式軟件框架包括GATK4、以及。
因此,手動管理內存對來說并不實際,而且并發、并行的垃圾回收和引用計數是唯一的選擇。我們希望選擇能夠得到社區長期支持的成熟編程語言,最后我們發現Java和Go是唯一支持并發以及并行垃圾回收的編程語言,而C++則采用引用計數。
我們的研究包括使用C++ 17、Go和Java中重新實現,并對運行時的性能和內存使用進行基準測試。這些都是非常成熟的編程語言,從某種意義上說,它們完全支持各種調用的常見準備流水線工作,包括讀取排序、重復標記和其他一些常用步驟。盡管的這三種重新實現僅支持有限的功能集,但在每種情況下,我們都可以通過額外的努力來完善軟件體系結構,以支持 2.6版及更高版本的所有功能。
結果
我們使用的軟件體系結構,以選定的三種編程語言運行了一條常見的準備流水線工作,結果表明Go實現的性能最佳,其次是Java實現,最后是C++ 17實現。
為了確認這個結果,我們針對全基因組測序數據集執行了五步準備流水線。該準備流水線包括以下步驟:
我們針對每個實現,運行了30次該流水線作業,并使用Unix time命令記錄了每次運行所耗費的時鐘時間和最大內存使用量。此外,我們還確定了每組運行的標準偏差和置信區間。C++ 17和Java允許對內存管理進行細致的調整,因此各自產生了四種變化。在最終排名中,我們挑選出了每種變化的最佳結果,一個是C++ 17的變化,一個是Java的的變化。Go的基準測試是使用默認設置執行的。三種實現的運行時性能的基準測試結果如下圖所示。Go平均需要7分56.152秒,標準偏差為8.571秒;Java平均需要6分54.546秒,標準偏差為5.376秒;C++ 17平均需要10分23.603秒,標準偏差為22.914秒。Go和Java的置信區間非常窄,而C++ 17的置信區間稍微寬一些。
關于最大內存使用情況的基準測試結果如下圖所示。Go平均需要221.73GB,標準偏差為6.15GB;Java平均需要大約335.46GB,標準偏差為 0.13GB;而C++ 17平均大約需要 255.48GB,標準偏差約為 2.93GB。置信區間非常窄。
的目標是同時降低運行時間和內存使用。因此,為了確定最終排名,我們用實際流逝的時間(以小時h為單位) 乘以平均最大內存使用量(以千兆字節GB為單位)。最后得出的結果如下圖所示(千兆字節小時,GBh的值越低越好):
這從一定程度上反映了基準測試的結果:盡管Java基準測試報告的運行時間比Go基準測試快一些,但Java的內存使用量明顯偏高,因此Java的GBh值高于Go。C++ 17的運行速度比Go和Java慢很多,這也就是解釋了為什么C++ 17在報告中得出的GBh值最高。因此,我們認為Go是最佳選擇,它在運行時性能和內存使用之間實現了最佳平衡,其次是Java,最后是C++ 17。
討論
關于中內存管理的細節在的所有步驟中,最常見的用例是執行讀取排序和重復標記。這樣的流水線分兩個階段執行:在第一個階段,讀取BAM輸入文件,將讀取的條目解析為對象,并即時執行重復標記和一些過濾步驟。一旦所有讀取都作為堆對象存儲在RAM中后,就使用并行排序算法對它們進行排序。然后,在第二個階段,將修改后的讀取轉換回BAM輸出文件的條目并回寫。將讀取的處理分為兩個階段,因為只有在完全掌握重復項,并在RAM中排序讀取項后,才能將讀取回寫到輸出文件。第一個階段分配各種數據結構,同時將BAM文件中的讀取表示形式解析為堆對象。在第一個階段完成之后,這些對象的子集就被淘汰了。上述“背景介紹”提到了如何以不同的內存管理方法處理這些臨時的對象。垃圾收集器需要花費時間,將這些過時的對象分類到不可訪問,并回收為其分配的資源。中斷應用程序的順序垃圾回收器會導致嚴重的停滯,而且在停滯期間主程序無法進行。以前的版本(直到2.6版)就屬于這種情況,因此我們為用戶提供了一個選項,可以在這些版本中完全禁用垃圾收集。而并發并行垃圾收集器可以與在第二個階段同時執行垃圾收集的工作,因此可以立即執行。使用引用計數時,每當對象的引用計數降至零,該對象就會被列入淘汰之列。回收為這些對象分配的資源會引發一連串其他對象的資源回收,因為這些對象的引用計數也會間接地降至零。因為這是一個固有的順序過程,所以也會導致與中斷應用程序垃圾回收器類似的重大停滯。關于C++ 17性能的細節在大多數特定的算法或內核的基準測試中,C和C++的性能通常都會超越其他編程語言。由于的C ++17實現使用引用計數,因此引用計數引發的一連串資源回收會造成停滯,最終導致性能降低,如上一小節所述。為了驗證該理論,我們分別測量了C++ 17實現中每個階段耗費的時間以及回收資源導致的停滯時間,并再次重復執行了30次基準測試,最終確定了耗費時間、標準偏差和置信區間。結果如下圖所示。第一階段平均所需時間為4分26.657秒,標準偏差為6.648秒;回收資源導致的停滯平均耗費2分18.633秒,標準偏差為4.77秒;第二階段平均所需時間為3分33.832秒,標準偏差為17.376秒。
30次C++ 17運行時的平均總和為10分19.122秒,標準偏差為22.782秒。如果我們從平均總運行時間中減去回收資源導致停滯的時間,結果為8分0.489秒,標準偏差為20.605秒。這與Go的基準非常接近——Go的基準平均需要7分56.152秒。因此,我們得出的結論是:導致C++ 17與Go和Java之間的性能差距的罪魁禍首確實是C ++ 17中引用計數機制的資源回收導致的停滯。除了引用計數之外,C++還提供了許多功能來實現更明確的內存管理。例如,它提供的分配器能夠解耦合內存管理與容器中的對象處理。從原則上來說,我們可以使用這種分配器來分配已知的臨時對象,而這些對象在上述回收資源導致的停滯期間已被淘汰。因此這種分配器可以被立即釋放,從而避免運行時中的停滯。但是,這種方法將需要非常詳細且非常容易出錯的分析,這些對象不能由此類分配器管理,而且也不能很好地轉換到此特定用例之外的其他種類的流水線。由于的重點是成為開放式軟件框架,因此這種方法不切實際。調整C++ 17中的內存管理并行C/C ++程序的性能通常會受到C/C++標準庫提供的低級內存分配器的影響。我們可以將高級內存分配器鏈接到程序來減輕這種影響,減少同步、錯誤共享和內存消耗等。這種內存分配器還可以將大小相似的對象分到不同的組,然后以較大的塊進行分配和釋放,以有效地處理程序中大量的小規模堆分配。這些技術在垃圾收集的編程語言中也很常見,但是在很大程度上與自動或手動管理內存無關。在我們的研究中,我們使用默認的未經修改內存分配器,因特爾 的 分配器、 的 分配器以及分配器,對C++ 17實現進行了基準測試。結果如下表所示。根據表中的GBh值,表現最佳。
分配器
平均運行時間
平均內存使用量
乘積
默認的內存分配器
16 mins 57.467 secs
233.63 GB
66.03 GBh
16 mins 26.450 secs
233.51 GB
63.96 GBh
11 mins 24.809 secs
246.78 GB
46.94 GBh
10 mins 23.603 secs
255.48 GB
44.26 GBh
調整Java中的內存管理Java的內存管理提供了許多調整選項。由于的Java實現使用的平均最大內存明顯超過了C++ 17和Go實現,因此我們更詳細地研究了如下兩個選項:我們使用以下配置分別運行30次Java基準測試:使用默認選項;僅使用字符串去重復選項;僅使用free-heap選項;以及同時使用字符串去重復和free-heap選項。對于free-heap選項,我們遵循Java文檔的建議,在不引起性能下降過多的情況下,盡可能降低堆的大小。測量結果如下表所示:free-heap選項對運行時性能或內存使用沒有明顯的影響,字符串去重復選項導致實際流逝的時間有所增加應用程序并行配置錯誤,但內存使用量有輕微的減少。根據表中的GBh值,Java默認選項的表現最佳。
平均運行時間
平均內存使用量
乘積
默認選項
6 mins 54.546 secs
335.46 GB
38.63 GBh
字符串去重復
7 mins 30.815 secs
338.74 GB
42.42 GBh
free-heap選項
6 mins 55.842 secs
335.45 GB
38.75 GBh
同時使用字符串去重復和free-heap選項
7 mins 25.415 secs
338.74 GB
41.91 GBh
總結
由于Go和Java的垃圾收集器具有并發性和并行性,因此用這兩種編程語言重新實現的執行速度遠勝于C++ 17的實現。而且由于Go實現使用的堆內存遠低于Java實現,因此我們決定從3.0版開始官方的使用Go實現。根據我們的經驗應用程序并行配置錯誤,我們建議其他處理SAM/BAM數據生物信息學工具的開發都使用Go語言。
原文鏈接: