AI 訓練新趨勢:基於 Kubernetes 的雲上深度學習
1.背景介紹
近些年,以深度學習為代表的人工智能技術取得了飛速的發展,正落地應用於各行各業。隨著深度學習的廣泛應用,眾多領域產生了大量強烈的高效便捷訓練人工智能模型方面的需求。另外,在雲計算時代,以 Docker、Kubernetes 以主的容器及其編排技術在應用服務自動化部署的軟件開發運維浪潮中取得了長足的發展。Kubernetes 社區對於 GPU 等加速計算設備資源的支持方興未艾。鑑於雲環境在計算成本和規模擴展方面的優勢,以及容器化在高效部署和敏捷迭代方面的長處,基於“容器化彈性基礎架構+雲平臺 GPU 實例”進行分佈式深度學習模型訓練成為了業界生成 AI 模型的主要趨勢。
為了兼顧資源擴展的靈活性,雲應用大多采用計算和存儲分離的基本架構。其中,對象存儲因為能夠有效地降低存儲成本、提升擴展彈性,經常用來存儲管理海量訓練數據。除了採用單一雲上存儲之外,很多雲平臺的用戶因為安全合規、數據主權或者遺產架構方面的因素,大量數據還存儲在私有數據中心。這些用戶希望基於混合雲的方式構建人工智能訓練平臺,利用雲平臺的彈性計算能力滿足高速增長的 AI 業務模型訓練方面的需求,然而這種“本地存儲+雲上訓練”的訓練模式加劇了計算存儲分離架構帶來的遠程數據訪問的性能影響。計算存儲分離的基本架構雖然可以為計算資源和存儲資源的配置和擴展帶來更高的靈活性,但是從數據訪問效率的角度來看,由於受限於網絡傳輸帶寬,用戶不經調優簡單使用這種架構通常會遇到模型訓練性能下降的問題。
2.常規方案面臨的數據訪問挑戰
目前雲上深度學習模型訓練的常規方案主要採用手動方式進行數據準備,具體是將數據複製並分發到雲上單機高效存儲(例如,NVMe SSD)或分佈式高性能存儲(例如 GlusterFS 並行文件系統)上。這種由用戶手工或者腳本完成的數據準備過程通常面臨如下三個問題:
1.數據同步管理成本高: 數據的不斷更新需要從底層存儲定期進行數據同步,這個過程管理成本較高;
2.雲存儲成本開銷更多: 需要為雲上單機存儲或高性能分佈式存儲支付額外費用;
3.大規模擴展更加複雜: 隨著數據量增長,難以將全部數據複製到雲上單機存儲;即使複製到 GlusterFS 這樣的海量並行文件系統也會花費大量的時間。
基於容器和數據編排的模型訓練架構方案
針對雲上深度學習訓練常規方案存在的上述問題,我們設計並實現了一種基於容器和數據編排技術的模型訓練架構方案。具體系統架構如圖 1 所示:
系統架構核心組件
- Kubernetes:是一種流行的深度神經網絡訓練容器集群管理平臺,它提供了通過容器使用不同機器學習框架的靈活性以及按需擴展的敏捷性。阿里雲容器服務 ACK(Alibaba Cloud Kubernetes)是阿里雲提供的 Kubernetes 服務,可以在阿里雲平臺的 CPU、GPU、NPU(含光 800 芯片)、神龍裸金屬實例上運行 Kubernetes 工作負載;
Kubeflow:是開源的基於 Kubernetes 雲原生 AI 平臺,用於開發、編排、部署和運行可擴展的便攜式機器學習工作負載。Kubeflow 支持兩種 TensorFlow 框架分佈式訓練,分別是參數服務器模式和 AllReduce 模式。基於阿里雲容器服務團隊開發的 Arena,用戶可以提交這兩種類型的分佈式訓練框架;
Alluxio:是面向混合雲環境的開源數據編排與存儲系統。通過在存儲系統和計算框架之間增加一層數據抽象層,提供統一的掛載命名空間、層次化緩存和多種數據訪問接口,可以支持大規模數據在各種複雜環境(私有云集群、混合雲、公有云)中的數據高效訪問。
Alluxio 發軔於大數據時代,流觴自誕生了 Apache Spark 的 UC Berkeley AMP 實驗室。Alluxio 系統設計的初衷是為了解決大數據處理流水線中不同計算框架在通過磁盤文件系統(如 HDFS)互換數據,造成整個分析性能瓶頸耗時在 I/O 操作方面的問題。Alluxio 項目開源於 2013 年,經過 7 年的不斷開發迭代,在大數據處理場景下的應用日趨成熟。另外,近些年隨著深度學習的崛起,Alluxio 分佈式緩存技術正逐步成為業界解決雲上 I/O 性能問題的主流解決方案。進一步地,Alluxio 推出基於 FUSE 的 POSIX 文件系統接口,為雲上 AI 模型訓練提供了高效的數據訪問手段。
為了能夠更好的將 Alluxio 融入 Kubernetes 生態系統發揮兩者結合的優勢,Alluxio 團隊和阿里雲容器服務團隊協作開發提供了 Alluxio 的 Helm Chart 方案, 極大地簡化了在 Kubernetes 內的部署和使用。
雲上訓練——Alluxio 分佈式緩存初探
1.深度學習實驗環境
- 我們使用 ResNet-50 模型與 ImageNet 數據集,數據集大小 144GB,數據以 TFRecord 格式存儲,每個 TFRecord 大小約 130MB。每個 GPU 的 batch_size 設置為 256;
模型訓練硬件選擇的是 4 臺 V100(高配 GPU 機型),一共 32 塊 GPU 卡;
數據存儲在阿里雲對象存儲服務中,模型訓練程序通過 Alluxio 讀取數據,並在讀取過程中將數據自動緩存到 Alluxio 系統。Alluxio 緩存層級配置為內存,每臺機器提供 40GB 內存作為內存存儲,總的分佈式緩存量為 160GB,沒有使用預先加載策略。
2.初遇性能瓶頸
在性能評估中,我們發現當 GPU 硬件從 NVidia P100 升級到 NVidia V100 之後,單卡的計算訓練速度得到了不止 3 倍的提升。計算性能的極大提升給數據存儲訪問的性能帶來了壓力。這也給 Alluxio 的 I/O 提出了新的挑戰。
下圖是在分別在合成數據(Synthetic Data)和使用 Alluxio 緩存的性能對比,橫軸表示 GPU 的數量,縱軸表示每秒鐘處理的圖片數。合成數據指訓練程序讀取的數據有程序自身產生,沒有 I/O 開銷,代表模型訓練性能的理論上限; 使用 Alluxio 緩存指訓練程序讀取的數據來自於 Alluxio 系統。
在 GPU 數量為 1 和 2 時,使用 Alluxio 和合成數據對比,性能差距在可以接受的範圍。但是當 GPU 的數量增大到 4 時,二者差距就比較明顯了,Alluxio 的處理速度已經從 4981 images/second 降到了 3762 images/second。 而當 GPU 的數量達到 8 的時候,Alluxio 上進行模型訓練的性能不足合成數據的 30%。而此時通過系統監控,我們觀察到整個系統的計算、內存和網絡都遠遠沒有達到瓶頸。這間接說明了簡單使用 Alluxio 難以高效支持 V100 單機 8 卡的訓練場景。
為了能夠深入瞭解是什麼因素影響了性能並進行調優,需要首先研究分析 Alluxio 在 Kubernetes 下支持 FUSE 的整個技術棧。如下圖所示:
3.原因剖析
通過深度分析整個技術棧和Alluxio內核,我們將造成相關性能影響的原因總結如下:
1.Alluxio 文件操作引入多次 RPC 交互,在訓練場景下引入性能開銷。
Alluxio 不只是一個單純的緩存服務。它首先是一個分佈式虛擬文件系統,包含完整的元數據管理、塊數據管理、UFS 管理(UFS 是底層文件系統的簡稱)以及健康檢查機制,尤其是它的元數據管理實現比很多底層文件系統更加強大。這些功能是 Alluxio 的優點和特色,但也意味著使用分佈式系統帶來的開銷。例如,在默認設置下使用 Alluxio 客戶端來讀一個文件,即便數據已經緩存在本地的 Alluxio Worker 中,客戶端也會和 Master 節點有多次 RPC 交互來獲取文件元信息以保證數據的一致性。完成整個讀操作的鏈路額外開銷在傳統大數據場景下並不明顯,但是深度面對學習場景下高吞吐和低延時的需求就顯得捉襟見肘了。
2.Alluxio 的數據緩存和驅逐策略會頻繁觸發節點數據緩存震盪。
深度學習場景數據冷熱經常不明顯,因此每個 Alluxio Worker 都會完整讀取數據。而 Alluxio 默認模式會優先數據本地讀取,即使數據已經保存在 Alluxio 集群中,也會從其他緩存節點拉取到本地存一份副本。這個特性在我們的場景下會帶來兩個額外開銷:
- 異步數據緩存的額外開銷;
本地空間不足會觸發自動數據驅逐的開銷,特別當節點緩存數據接近飽和的情況下性能開銷巨大。
3.基於 FUSE 進行文件系統的開發、部署、使用都很簡單,但是默認性能並不理想,原因如下:
-
- FUSE 讀操作效率不高,每次 read 最多隻能讀 128KB,讀一個 128MB 的文件需要 1000 次調用 read;
FUSE 讀操作屬於非阻塞行為,由 libfuse 非阻塞線程池處理,一旦併發請求數量遠超過線程池 (max_idle_threads) 的大小,就會觸發頻繁的大量線程創建和刪除,從而影響讀性能。而在 FUSE 中,這個默認配置是 10;
- FUSE 讀操作效率不高,每次 read 最多隻能讀 128KB,讀一個 128MB 的文件需要 1000 次調用 read;
元數據的頻繁訪問,因為 FUSE 內核模塊是個橋樑角色,連接了應用程序和 Alluxio 的文件系統,而每一次讀獲取文件/目錄的 inode 以及 dentry,FUSE 內核模塊都會到 Alluxio 系統運行一趟,增加了系統壓力。
4.Alluxio 和 FUSE 的集成(下文簡稱為 AlluxioFUSE)在深度學習中常見的多線程高併發場景下性能有待優化,甚至需要深度定製:
- Alluxio 目前僅支持在 FUSE 中使用 direct_io 模式,而不能使用 kernel_cache 模式來藉助 page cache 進一步提高 I/O 效率。這是因為 Alluxio 當前設計要求在多線程場景下,每個線程都必須使用自己的文件輸入句柄(FileInputStream)。而如果打開 page cache,當前的 AlluxioFUSE 會有些併發預先讀到 cache 的操作,從而產生報錯;
數據從被 Alluxio 客戶端讀入後,到進入 FUSE 要經歷多次拷貝。這些額外的拷貝通常是由於 AlluxioFUSE 使用到的第三方 Java 庫 API 限制;
AlluxioFUSE 實現中使用到的第三方庫 JNRFuse 只能適配較低版本的 FUSE,並且在高併發場景下有較大的性能負擔。
5.Kubernetes 對於 Alluxio 的線程池影響。
Alluxio 基於 Java 1.8 版本實現,其中的一些線程池的計算會依賴於 Runtime.getRuntime().availableProcessors(),但是在 Kubernetes 環境下,默認配置中 cpu_shares 的值為 2,而 JVM 對於 cpu 的核心數的計算公式 cpu_shares()/1024,導致結果是 1。這會影響 java 進程在容器內的併發能力。
雲上模型訓練的性能優化
在分析了上述性能問題和因素之後,我們將設計了一系列性能優化策略以提升雲上模型訓練的性能。首先,需要明白數據訪問的“多快好省”是無法全部兼顧,我們針對的主要是模型訓練下只讀數據集的數據訪問加速。優化的基本思路是關注高性能和數據一致性,而犧牲一部分靈活的自適應性(比如讀寫同時發生,數據內容不斷更新等場景)。
基於上述思路,我們設計了具體的性能優化策略,這些策略遵循以下核心原則:
-
-
- 尋找資源限制,包括線程池以及 JVM 在容器中的配置;
藉助各級緩存,包括 FUSE 層和 Alluxio 元數據緩存;
- 尋找資源限制,包括線程池以及 JVM 在容器中的配置;
-
避免額外開銷,減少非必須的調用鏈路。比如避免不必要的元數據交互,引入上下文切換的 GC 線程和 compiler 進程;以及 Alluxio 內部的一些可以簡化的操作。
下面將從各層的組件優化角度,對這些優化策略逐一介紹:
1.對 FUSE 的優化
升級 Linux Kernel 版本
FUSE 實現分為兩層:運行在用戶態的 libfuse 和運行在內核態的 FUSE Kernel。高版本的 Linux Kernel 針對 FUSE 做了大量的優化。我們對比了 Kernel 3.10 和 4.19 的性能,發現讀性能可以達到 20% 的提升。
優化 FUSE 參數
1.延長 FUSE 元數據有效時間
Linux 中每個打開文件在內核中擁有兩種元數據信息:struct dentry 和 struct inode,它們是文件在內核的基礎。所有對文件的操作,都需要先獲取文件這兩個結構。所以,每次獲取文件/目錄的 inode 以及 dentry 時,FUSE 內核模塊都會從 libfuse 以及 Alluxio 文件系統進行完整操作,這樣會帶來數據訪問的高延時和高併發下對於 Alluxio Master 的巨大壓力。可以通過配置 –o entry_timeout=T –o attr_timeout=T 進行優化。
2.配置 max_idle_threads 避免頻繁線程創建銷燬引入 CPU 開銷。
這是由於 FUSE 在多線程模式下,以一個線程開始運行。當有兩個以上的可用請求,則 FUSE 會自動生成其他線程。每個線程一次處理一個請求。處理完請求後,每個線程檢查目前是否有超過 max_idle_threads (默認 10) 個線程;如果有,則該線程回收。而這個配置實際上要和用戶進程生成的 I/O 活躍數相關,可以配置成用戶讀線程的數量。而不幸的是 max_idle_threads 本身只在 libfuse3 才支持,而 AlluxioFUSE 只支持 libfuse2, 因此我們修改了 libfuse2 的代碼支持了 max_idle_threads 的配置。
2.對 Alluxio 的優化
Alluxio 和 FUSE 的集成通過一個名為 AlluxioFuse 的進程實現。該進程在運行期會通過調用內嵌的 Alluxio 客戶端和運行的 Alluxio Master 以及 Worker 交互。我們針對深度學習的場景,定製 AlluxioFuse 所使用的 Alluxio 屬性來優化性能。
避免頻繁逐出(Cache Eviction)造成緩存抖動
由於深度學習訓練場景下,每次訓練迭代都是全量數據集的迭代,緩存幾個 TB 的數據集對於任何一個節點的存儲空間來說都是捉襟見肘。而 Alluxio 的默認緩存策略是為大數據處理場景(例如查詢)下的冷熱數據分明的需求設計的,數據緩存會保存在 Alluxio 客戶端所在的本地節點,用來保證下次讀取的性能最優。具體來說:
1.alluxio.user.ufs.block.read.location.policy 默認值為 alluxio.client.block.policy.LocalFirstPolicy, 這表示 Alluxio 會不斷將數據保存到 Alluxio 客戶端所在的本地節點,就會引發其緩存數據接近飽和時,該節點的緩存一直處於抖動狀態,引發吞吐和延時極大的下降,同時對於 Master 節點的壓力也非常大。因此需要 location.policy 設置為 alluxio.client.block.policy.LocalFirstAvoidEvictionPolicy 的同時,指定 alluxio.user.block.avoid.eviction.policy.reserved.size.bytes 參數,這個參數決定了當本地節點的緩存數據量達到一定的程度後,預留一些數據量來保證本地緩存不會被驅逐。通常這個參數應該要大於 節點緩存上限 X (100% - 節點驅逐上限的百分比) 。
2.alluxio.user.file.passive.cache.enabled 設置是否在 Alluxi 的本地節點中緩存額外的數據副本。這個屬性是默認開啟的。因此,在 Alluxio 客戶端請求數據時,它所在的節點會緩存已經在其他 Worker 節點上存在的數據。可以將該屬性設為 false,避免不必要的本地緩存。
3.alluxio.user.file.readtype.default 默認值為 CACHE_PROMOTE。這個配置會有兩個潛在問題,首先是可能引發數據在同一個節點不同緩存層次之間的不斷移動,其次是對數據塊的大多數操作都需要加鎖,而 Alluxio 源代碼中加鎖操作的實現不少地方還比較重量級,大量的加鎖和解鎖操作在併發較高時會帶來不小的開銷,即便數據沒有遷移還是會引入額外開銷。因此可以將其設置為 CACHE 以避免 moveBlock 操作帶來的加鎖開銷,替換默認的 CACHE_PROMOTE。
緩存元數據和節點列表
在深度學習訓練場景下,每次訓練任務開始前會列出所有訓練數據文件並讀取其元數據,然後運行訓練任務的進程會進一步讀取訓練數據文件。通過 Alluxio 讀取文件訪問時默認會完成如下操作:首先從 Master 獲取文件元數據,從中獲取 block 元數據,再從 Worker 獲取 block 的具體位置,最後真正從獲取的位置讀取 block 數據。完成完整的操作鏈路包括多次 RPC 開銷,引入明顯的文件訪問延時。如果能將該數據文件的 block 信息緩存到客戶端內存中,會非常明顯的提升文件的訪問性能。
1.將 alluxio.user.metadata.cache.enabled 設置為 true, 可以在 Alluxio 客戶端開啟文件以及目錄的元數據緩存,避免二次訪問時仍需要通過 RPC 訪問元數據的問題。結合分配給 AlluxioFUSE 的堆大小,用戶可以配置 alluxio.user.metadata.cache.max.size 來設置最多緩存文件和目錄的元數據數量,也可以配置 alluxio.user.metadata.cache.expiration.time 調整元數據緩存的有效時間。同時在每次選擇讀取數據的 Worker 節點時,Alluxio Master 節點也會不斷去查詢所有 Worker 節點的狀態,這也會在高併發場景下引入額外開銷。
2.將 alluxio.user.worker.list.refresh.interval 設置為 2min 或者更長。
3.讀取文件也會不斷更新 last accesstime,實際上在高併發的場景下,這會對 Alluxio Master 造成很大壓力。我們通過修改 Alluxio 代碼增加了開關,可以關閉掉 last accesstime 的更新。
充分利用數據本地性
1.數據本地性就是儘量將計算移到數據所在的節點上進行,避免數據在網絡上的傳輸。分佈式並行計算環境下,數據的本地性非常重要。在容器環境下支持兩種短路讀寫方式:Unix socket 方式和直接文件訪問方式。
- Unix Socket 的方式好處在於隔離性好,不需要 Alluxio Client 和 Alluxio Worker 容器運行在同樣的 Network,UTS,Mount 的 Namespace。但是它的性能比直接文件訪問要差一些,同時會引發 netty 的 OutOfDirectMemoryError
而直接訪問文件的方式則所以需要確保同一臺機器上運行的 Alluxio Worker 和 AlluxioFUSE 的主機名和 IP 地址一致,同時要保證 Alluxio Client 和 Worker 共享同樣緩存目錄,這種方式性能更好同時更加穩定。但是它實際上犧牲了隔離性,需要二者共享 Network,UTS,Mount 的 Namespace
我們目前選擇的方案是優先採用後者。
3.對 Java & Kubernetes 的優化
配置 ActiveProcessorCount
1.Runtime.getRuntime().availableProcessors() 控制的;而如果通過 Kubernetes 部署容器而不指定 cpu 資源的 request 數量,容器內 Java 進程讀到 proc 文件系統下的 cpushare 數量為 2, 而此時的 availableProcessors() 來自於 cpu_shares()/1024,會被算成 1。實際上限制了容器內 Alluxio 的併發線程數。考慮到 Alluxio Client 屬於 I/O 密集型的應用,因此可以通過 -XX:ActiveProcessorCount 設置處理器數目。這裡的基本原則是 ActiveProcessorCount 儘量設置得高些。
調整 GC,JIT 線程
2.JVM 的缺省 GC,JIT 編譯線程數量取決於 -XX:ActiveProcessorCount 的數量,但實際上也可以通過 -XX:ParallelGCThreads -XX:ConcGCThreads -XX:CICompilerCount 等參數配置,可以將其設置的小些,避免這些進程頻繁的搶佔切換,導致性能下降。
4.性能優化效果
在優化 Alluxio 之後,ResNet50 的訓練性能單機八卡性能提升了 236.1%,並且擴展性問題得到了解決,訓練速度在不但可以擴展到了四機八卡,而且在此場景下和合成數據相比性能損失為 3.29%(31068.8 images/s vs 30044.8 images/s)。相比於把數據保存到 SSD 雲盤,在四機八卡的場景下,Alluxio 的性能提升了 70.1% (雲 SSD 17667.2 images/s vs 30044.8 images/s)。
而實際訓練時間方面,使用 Alluxio 需要 65 分鐘(合成數據場景耗時 63 分鐘),和通過雲上 SSD 進行模型訓練相比節省了 45 分鐘,節省成本 40.9%。
5.總結與進一步工作
在本文中,我們總結了 Alluxio 在高性能分佈式深度學習模型訓練場景中落地的挑戰點,以及我們在優化 Alluxio 的實踐。進一步地,我們介紹瞭如何從多個層面提升 AlluxioFUSE 在高併發讀場景下性能優化的經驗。最後,我們實現的基於 Alluxio 優化的分佈式模型訓練方案,並在 4 機 8 卡的 ResNet50 場景下進行了性能驗證,取得了很好的效果。
在進一步工作方面,對於高吞吐海量規模的小文件和高併發讀場景,Alluxio 還有一些在 page cache 的支持和 FUSE 層的穩定性方面的工作,我們阿里雲容器服務團隊也會和 Alluxio 開源社區以及南京大學戴海鵬、顧榮等老師一起繼續合作努力改進。我們相信通過工業界、開源社區和學術界和聯合的創新力量,能夠逐步降低計算存儲分離場景下深度學習訓練的數據訪問高成本和複雜度,進一步助力雲上普惠 AI 模型訓練。
6.致謝
感謝 Alluxio 團隊的範斌,邱璐,Calvin Jia,常鋮在整個方案的設計和優化過程中的巨大幫助,從 Alluxio 自身能力上對於元數據緩存系統做了顯著的提升,為 Alluxio 落地 AI 場景開啟了可能性。
本文轉自<阿里巴巴雲原生技術圈>——阿里巴巴雲原生小助手