背景
[作者:DeepLearningStack,阿里巴巴算法工程師,開源TensorFlow Contributor]
歡迎大家關注我的公眾號,“互聯網西門二少”,我將繼續輸出我的技術干貨~
在前一篇文章中,我們梳理了TensorFlow中各種異構Device的添加和注冊機制,通過使用預先定義好的宏,各種自定義好的Device能夠將自己注冊到全局表中。TensorFlow期望通過這種模式,能夠讓Device的添加和注冊於系統本身更好的解耦,從而體現了較好的模塊化特性。在這篇文章中,我們選擇直接去窺探TensorFlow底層架構較為復雜的一個部分——StreamExecutor框架。我們已經知道TensorFlow是一個異構的並行執行框架,對於異構Device的管理是一件非常復雜的事,不僅包括Device的添加、注冊、刪除、屬性的管理,還必須要對Device的並行執行過程做進一步抽象形成統一的框架,才能實現更好的解耦。通過閱讀這部分源碼不但可以對執行引擎的管理有很深的理解,還可以體驗學習到各種設計模式。如果想要對TensorFlow底層甚至是XLA做一些性能上的深度優化,那么這一部分則是必須要了解的內容。
Stream
Stream存在於計算機相關的各種技術中,比如在操作系統、流式計算、計算機網絡傳輸或是CUDA編程中都有涉及。Stream從抽象角度來看其本質是定義了一個操作序列,處於同一個Stream的操作必須按順序執行,不同Stream之間的並無順序關系。在TensorFlow中存在一些高性能的並行編程設備,所以需要有一套抽象框架對這些設備的執行過程管理起來,這就是StreamExecutor的用武之地了。
StreamExecutor簡介
其實StreamExecutor本身就是一個在Google內部為並行編程模型開發的單獨的庫,感興趣的可以直接參考GitHub。在TensorFlow中的StreamExecutor是一個開源StreamExecutor的簡版,並且並不是以第三方庫的形式出現,而是在源碼中單獨放了一個stream_executor的文件夾,里面的代碼非常的精簡,目錄結構部分截圖如下圖所示。
StreamExecutor為TensorFlow的執行層面提供了較為統一的抽象,而在底層各種Device的執行管理細節卻完全不同。我們可以看到stream_executor下面有cuda和host兩個子目錄,他們分別是GPU執行引擎和CPU執行引擎所使用的子模塊。下面我們先從統一的抽象層面來梳理該框架的結構。
StreamExecutor對外提供的句柄——Stream對象
為了隱藏StreamExecutor框架管理的復雜性,它對外暴露的handler必須足夠簡單。事實也確實如此,StreamExecutor通過暴露Stream對象作為操作底層的handler。一般而言,在TensorFlow的框架中都是使用Stream對象來調用底層計算庫,進行設備間數據拷貝操作等過程。比如調用Stream對象的ThenMemcpy即可完成異步的數據傳輸拷貝過程,調用ThenConvolveXXX等函數即可完成DNN庫中的卷積調用。事實上,TensorFlow中很多Op的C++實現中,其Compute函數內就是通過使用Stream對象來完成某些實際計算或數據拷貝的過程,下圖展示了Stream對象、StreamExecutor框架以及其他模塊的關系。
Stream對象是通過持有StreamInterface的具體實現對象來獲得實際平台的Stream,進而通過Stream這個統一的handler完成與底層的交互,下面試這一子模塊的類圖結構。
StreamExecutor框架內的層次結構
熟悉GPU編程的同學都知道,CUDA程序的編寫是相對復雜的,不但要針對某種任務設計特定的並行編程思路,還要管理Event,Stream等較為底層的對象。為了能夠減輕StreamExecutor用戶的使用負擔,也為了能夠給上層調用者即TensorFlow引擎提供更加統一的接口,一些抽象分層的工作是非常有必要的。總體上StreamExecutor框架由三個層次組成,從上到下依次為Platform層(平台描述)、StreamExecutor Core層(執行引擎)和LibrarySupport層(基礎庫)。如果需要為TensorFlow添加新的計算設備種類,不但要向TensorFlow中注冊Device的定義,還需要在StreamExecutor框架中提供負責管理該Device計算的代碼。
Platform層
在StreamExecutor中Platform指的是計算所使用設備平台的抽象,每種Device對應一種Platform。比如GPU對應的是CudaPlatform,而CPU對應的是HostPlatform等。一旦獲得了某種Device的Platform,就可以獲取和該Platform對應的StreamExecutor Core以及相應的LibrarySupport。在TensorFlow的代碼實現中,所有Platform類都是通過宏定義和MultiPlatformManager管理類的靜態方法主動注冊到系統中的,下面是這一層次的類圖表示。
CudaPlatform和HostPlatform繼承自公共父類Platform,如果有新的Platform出現,依然可以沿用這樣的設計直接繼承並給出實現。所有的Platform都通過MultiPlaftormManager調用RegsiterPlatform函數主動注冊到系統中並做初始化,下面代碼段是CudaPlaftorm的注冊過程,注冊使用了Initializer模塊及相應的宏定義,這些代碼比較簡單,這里就不再詳細展開了。
1 static void InitializeCudaPlatform() { 2 // Disabling leak checking, MultiPlatformManager does not destroy its 3 // registered platforms. 4 5 std::unique_ptr<cuda::CudaPlatform> platform(new cuda::CudaPlatform); 6 SE_CHECK_OK(MultiPlatformManager::RegisterPlatform(std::move(platform))); 7 } 8 9 } // namespace stream_executor 10 11 REGISTER_MODULE_INITIALIZER(cuda_platform, 12 stream_executor::InitializeCudaPlatform()); 13 14 // Note that module initialization sequencing is not supported in the 15 // open-source project, so this will be a no-op there. 16 REGISTER_MODULE_INITIALIZER_SEQUENCE(cuda_platform, multi_platform_manager); 17 REGISTER_MODULE_INITIALIZER_SEQUENCE(multi_platform_manager_listener, 18 cuda_platform);
MultiPlatformManager提供了兩種獲取具體Platform的方式,一種是通過name,另一種是通過Id,如下代碼段所示。
1 // Retrieves the platform registered with the given platform name (e.g. 2 // "CUDA", "OpenCL", ...) or id (an opaque, comparable value provided by the 3 // Platform's Id() method). 4 // 5 // If the platform has not already been initialized, it will be initialized 6 // with a default set of parameters. 7 // 8 // If the requested platform is not registered, an error status is returned. 9 // Ownership of the platform is NOT transferred to the caller -- 10 // the MultiPlatformManager owns the platforms in a singleton-like fashion. 11 static port::StatusOr<Platform*> PlatformWithName(absl::string_view target); 12 static port::StatusOr<Platform*> PlatformWithId(const Platform::Id& id);
StreamExecutor Core層
從源代碼上看這一層非常復雜,因為它涉及到的類最多,但是當我們把Platform層和Library層分開看待后,這一層就變得非常簡單了。對於外部使用者來說,獲取Platform就是為了獲取對應的執行引擎。對於TensorFlow這種存在多種Platform和執行引擎的異構框架來說,必須為每一種執行引擎提供完整的實現,這具有一定的復雜度。為了讓代碼結構更有層次感,也為了向Platform層隱藏底層的設計復雜度,該層選擇只向上層暴露StreamExecutor類,而涉及到具體實現的StreamExecutorInterface以及各種具體的實現將由StreamExecutor類統一控制,這種代理的方式讓這一層的架構更加干凈,下面是涉及到這一層的類圖。
CudaExecutor和HostExecutor繼承自StreamExecutorInterface后,由StreamExecutor持有,並暴露給上一層Platform使用。同各種Platform類似,每個具體的StreamExecutor也需要注冊到系統中,但他們卻沒有依賴於任何控制類,直接通過宏定義將自己注冊到全局工廠中,注冊過程也是借助Initializer模塊實現的。下面的代碼段展示了CudaExecutor的注冊過程。
1 void initialize_cuda_gpu_executor() { 2 *internal::MakeCUDAExecutorImplementation() = [](const PluginConfig &config) { 3 return new cuda::CUDAExecutor{config}; 4 }; 5 } 6 7 } // namespace stream_executor 8 9 REGISTER_MODULE_INITIALIZER(cuda_gpu_executor, { 10 stream_executor::initialize_cuda_gpu_executor(); 11 });
initialize_cuda_gpu_executor函數中定義了一個創建CUDAExecutor的匿名函數,而MakeCUDAExecutorImplementation函數實際上創建了一個全局的table,中間的等號賦值操作實際上就是把該匿名函數放到了全局instance中,這實際上就是一種簡單的工廠模式,在StreamExecutor中存在多種類似的工廠,下面代碼段展示了這些工廠的本質。
1 using StreamExecutorFactory = 2 std::function<StreamExecutorInterface *(const PluginConfig &)>; 3 using EventFactory = std::function<EventInterface *(StreamExecutor *)>; 4 using StreamFactory = std::function<StreamInterface *(StreamExecutor *)>; 5 using TimerFactory = std::function<TimerInterface *(StreamExecutor *)>; 6 using KernelFactory = std::function<KernelInterface*()>; 7 8 StreamExecutorFactory* MakeCUDAExecutorImplementation();
StreamExecutor框架使用Cache機制避免為同一種StreamExecutor Core被重復創建,這個Cache就是ExecutorCache,下面代碼展示了Platform從Cache獲取StreamExecutor Core的內容,當Cache中不存在所需要的StreamExecutor時,會創建新的對象並放入cache中,並以config作為key。
1 port::StatusOr<StreamExecutor*> CudaPlatform::GetExecutor( 2 const StreamExecutorConfig& config) { 3 return executor_cache_.GetOrCreate( 4 config, [&]() { return GetUncachedExecutor(config); }); 5 }
Library層
這一層提供的是各種底層加速庫的接入,當前該層主要負責接入Dnn,Blas,Rng和Fft模塊,每個模塊和對應的類說明如下表所示 。
子模塊名稱 | 功能說明 |
DNNSupport | DNN計算模塊,主要包含DNN計算的基本操作。在GPU實現中,它將作為CuDNN的封裝 |
RngSupport | 隨機數生成模塊 |
BlasSupport | 基礎線性代數庫模塊,主要包含矩陣系列的計算,在CPU實現中它可以是Eigen,mkl等;在GPU實現中,它將作為CuBLAS的封裝 |
FFTSupport | FFT系列運算模塊 |
因為這些基礎庫同StreamExecutor類似,都具有平台屬性,例如在CUDAHostPlatform中使用的Blas庫應為CuBLAS,而HostPlatform中對應的可能是OpenBlas,MKL等。雖然StreamExecutorInterface創建出來的各種Library指針均由StreamExecutor持有,但是他們卻由StreamExecutorInterface的實現類負責創建,所以從邏輯上看他們處於StreamExecutor Core的下一層,下圖展示了Library層的類圖。
Library層將這些基礎庫統一作為插件(Plugin)來管理,用以應對未來出現的各種各樣的基礎庫。他們通過PluginRegister模塊注冊。和StreamExecutor Core中的管理方式相同,依然要先創建插件的Factory,Factory的創建也通過宏實現。以CudnnSupport為例,通過向通用初始化模塊Intializer傳入initialize_cudnn函數並調用,將創建CudnnSupport的函數作為DnnFactory放到PluginRegister模塊中,至此完成了DnnFactory的創建。使用時,只需要拿到PluginRegister的key(即要求拿到何種插件)即可取出對應的LibrarySupport。下面展示了CudnnSupport的工廠注冊代碼。
1 void initialize_cudnn() { 2 port::Status status = 3 PluginRegistry::Instance()->RegisterFactory<PluginRegistry::DnnFactory>( 4 cuda::kCudaPlatformId, cuda::kCuDnnPlugin, "cuDNN", 5 [](internal::StreamExecutorInterface* parent) -> dnn::DnnSupport* { 6 cuda::CUDAExecutor* cuda_executor = 7 dynamic_cast<cuda::CUDAExecutor*>(parent); 8 if (cuda_executor == nullptr) { 9 LOG(ERROR) << "Attempting to initialize an instance of the cuDNN " 10 << "support library with a non-CUDA StreamExecutor"; 11 return nullptr; 12 } 13 14 cuda::CudnnSupport* dnn = new cuda::CudnnSupport(cuda_executor); 15 if (!dnn->Init().ok()) { 16 // Note: Init() will log a more specific error. 17 delete dnn; 18 return nullptr; 19 } 20 return dnn; 21 }); 22 23 if (!status.ok()) { 24 LOG(ERROR) << "Unable to register cuDNN factory: " 25 << status.error_message(); 26 } 27 28 PluginRegistry::Instance()->SetDefaultFactory( 29 cuda::kCudaPlatformId, PluginKind::kDnn, cuda::kCuDnnPlugin); 30 } 31 32 } // namespace stream_executor 33 34 REGISTER_MODULE_INITIALIZER(register_cudnn, 35 { stream_executor::initialize_cudnn(); });
再看總體類圖
在StreamExecutor框架中還存在其他模塊,比如XLA的支持,比如Event的管理,在逐個梳理StreamExecutor框架的三個層次后再看其余部分就非常清晰明了了,下面的兩張圖展示了整體類圖和一些繼承結構。
StreamExecutor的調用棧
在完整的理解了StreamExecutor框架的內部結構和外部句柄后,我們就可以非常清晰地trace其調用棧了。最后,我們以調用Cudnn中的FusedConvolveWIthAlgorithm為例,畫出完整的調用時序圖。FusedConvolveWIthAlgorithm是將Convolution計算,Bias計算以及Activation計算fuse在一起的優化版本CUDA kernel,它的效率相對於分開調用相比更高。
總結
StreamExecutor是一個相對獨立的項目,在TensorFlow中所使用的StreamExecutor是精簡之后的版本。正是因為異構框架管理每種Device的並行執行過程非常繁雜,所以需要StreamExecutor向上層調用者隱藏底層的復雜性。在架構設計上,StreamExecutor選擇向上層暴露簡單的Stream對象handler實現了這一封裝。事實上,TensorFlow中所有需要調用與Device相關的第三方高性能計算庫的Op都使用Stream這一handler輕松完成Op的編寫。從StreamExecutor框架內部看,可以分為Platform層、StreamExecutor Core層和LibrarySupport層,每層的核心組件都通過Initializer模塊和宏定義主動注冊到系統Factory中,從上層Op對Stream的調用棧中也可以清晰地感受到這層次分明的架構設計。掌握並理解StreamExecutor的調用棧是非常重要的,因為無論是為TensorFlow底層做XLA優化還是為某些Op提供Int8計算支持,都需要改寫這一部分。將來我們在梳理XLA整體框架時還會回過頭來窺探StreamExecutor框架中的其他部分。