• 教程 >
  • 從基礎原理深入理解 PyTorch 在 Intel CPU 上的效能表現
快捷方式

從基礎原理深入理解 PyTorch 在 Intel CPU 上的效能表現

建立日期:2022 年 4 月 15 日 | 最後更新:2025 年 1 月 23 日 | 最後驗證:2024 年 11 月 5 日

一篇關於使用 Intel® Extension for PyTorch* 最佳化的 TorchServe 推理框架的案例研究。

作者:Min Jean Cho, Mark Saroufim

審閱者:Ashok Emani, Jiong Gong

在 CPU 上為深度學習獲得強大的開箱即用效能可能有些棘手,但如果您瞭解影響效能的主要問題、如何衡量它們以及如何解決它們,就會容易得多。

摘要

問題

如何衡量

解決方案

GEMM 執行單元瓶頸

透過核心繫結將執行緒親和性設定為物理核心,從而避免使用邏輯核心

非統一記憶體訪問 (NUMA)

  • 本地記憶體訪問 vs. 遠端記憶體訪問

  • UPI 利用率

  • 記憶體訪問延遲

  • 執行緒遷移

透過核心繫結將執行緒親和性設定為特定 Socket,從而避免跨 Socket 計算

GEMM (通用矩陣乘法) 執行在融合乘加 (FMA) 或點積 (DP) 執行單元上,當啟用超執行緒時,這些單元會成為瓶頸,導致執行緒在同步屏障處空轉或等待延遲——因為使用邏輯核心會導致所有工作執行緒併發度不足,每個邏輯執行緒都爭用相同的核心資源。相反,如果我們每個物理核心使用 1 個執行緒,就可以避免這種爭用。因此,我們通常建議透過設定 CPU 執行緒親和性到物理核心來實現核心繫結避免使用邏輯核心

多 Socket 系統具有非統一記憶體訪問 (NUMA),這是一種描述主存模組相對於處理器的放置方式的共享記憶體架構。但如果程序不是 NUMA 感知的,當執行緒在執行時透過 Intel Ultra Path Interconnect (UPI) 跨 Socket 遷移時,會頻繁訪問慢速遠端記憶體。我們透過設定 CPU 執行緒親和性到特定 Socket 來實現核心繫結,以此解決這個問題。

牢記這些原則,適當的 CPU 執行時配置可以顯著提升開箱即用效能。

在這篇部落格中,我們將引導您瞭解 CPU 效能調優指南中需要注意的重要執行時配置,解釋它們的工作原理、如何進行效能分析以及如何透過易於使用的啟動指令碼將它們整合到像 TorchServe 這樣的模型服務框架中,我們已經將其原生整合 1

我們將從基礎原理出發,透過大量效能分析視覺化地解釋所有這些概念,並向您展示我們如何應用所學知識來改善 TorchServe 在 CPU 上的開箱即用效能。

  1. 該功能必須透過在 config.properties 中設定 cpu_launcher_enable=true 來顯式啟用。

避免在深度學習中使用邏輯核心

在深度學習工作負載中避免使用邏輯核心通常會提升效能。為了理解這一點,讓我們回到 GEMM。

最佳化 GEMM 就是最佳化深度學習

深度學習訓練或推理的大部分時間都花在數百萬次重複的 GEMM 操作上,這是全連線層的核心。自多層感知機 (MLP) 被證明是任何連續函式的通用逼近器以來,全連線層已被使用了數十年。任何 MLP 都可以完全表示為 GEMM。甚至卷積也可以透過使用 Toeplitz 矩陣來表示為 GEMM。

回到最初的主題,大多數 GEMM 運算元受益於不使用超執行緒,因為深度學習訓練或推理的大部分時間都花在數百萬次重複的 GEMM 操作上,這些操作執行在超執行緒核心共享的融合乘加 (FMA) 或點積 (DP) 執行單元上。啟用了超執行緒時,OpenMP 執行緒將爭用相同的 GEMM 執行單元。

../_images/1_.png

如果 2 個邏輯執行緒同時執行 GEMM,它們將共享相同的核心資源,導致前端限制,這種前端限制帶來的開銷大於同時執行兩個邏輯執行緒帶來的收益。

因此,我們通常建議在深度學習工作負載中避免使用邏輯核心,以獲得良好的效能。啟動指令碼預設只使用物理核心;但是,使用者可以很容易地透過簡單地切換 --use_logical_core 啟動指令碼選項來實驗邏輯核心與物理核心。

練習

我們將使用以下示例,向 ResNet50 饋送虛擬張量

import torch
import torchvision.models as models
import time

model = models.resnet50(pretrained=False)
model.eval()
data = torch.rand(1, 3, 224, 224)

# warm up
for _ in range(100):
    model(data)

start = time.time()
for _ in range(100):
    model(data)
end = time.time()
print('Inference took {:.2f} ms in average'.format((end-start)/100*1000))

在整個部落格中,我們將使用 Intel® VTune™ Profiler 進行效能分析和驗證最佳化。我們將在配有兩顆 Intel(R) Xeon(R) Platinum 8180M CPU 的機器上執行所有練習。CPU 資訊如 圖 2.1 所示。

環境變數 OMP_NUM_THREADS 用於設定並行區域的執行緒數。我們將比較 OMP_NUM_THREADS=2 在 (1) 使用邏輯核心和 (2) 僅使用物理核心的情況。

  1. 兩個 OpenMP 執行緒試圖利用超執行緒核心 (0, 56) 共享的同一 GEMM 執行單元

我們可以透過在 Linux 上執行 htop 命令來視覺化,如下所示。

../_images/2.png
../_images/3.png

我們注意到“空轉時間”被標記出來,並且“失衡或序列忙等”佔了其中的大部分——8.982 秒總時間中的 4.980 秒。使用邏輯核心時的“失衡或序列忙等”是由於工作執行緒併發度不足,因為每個邏輯執行緒都爭用相同的核心資源。

執行摘要的“熱點Top”部分表明 __kmp_fork_barrier 花費了 4.589 秒的 CPU 時間——在 9.33% 的 CPU 執行時間內,執行緒由於執行緒同步在此屏障處空轉。

  1. 每個 OpenMP 執行緒利用各自物理核心 (0,1) 中的 GEMM 執行單元

../_images/4.png
../_images/5.png

我們首先注意到,透過避免使用邏輯核心,執行時間從 32 秒下降到 23 秒。雖然仍然存在一些不可忽略的“失衡或序列忙等”,但我們注意到它從 4.980 秒相對改善到 3.887 秒。

透過不使用邏輯執行緒(而是每個物理核心使用 1 個執行緒),我們避免了邏輯執行緒爭用相同的核心資源。“熱點Top”部分也表明 __kmp_fork_barrier 時間從 4.589 秒相對改善到 3.530 秒。

本地記憶體訪問總是比遠端記憶體訪問快

我們通常建議將程序繫結到本地 Socket,以防止程序跨 Socket 遷移。這樣做的目標通常是利用本地記憶體上的快取記憶體,並避免可能慢約 2 倍的遠端記憶體訪問。

../_images/6.png

圖 1. 雙 Socket 配置

圖 1. 顯示了典型的雙 Socket 配置。注意,每個 Socket 都有自己的本地記憶體。Socket 之間透過 Intel Ultra Path Interconnect (UPI) 連線,這允許每個 Socket 訪問另一個 Socket 的本地記憶體,稱為遠端記憶體。本地記憶體訪問總是比遠端記憶體訪問快。

../_images/7.png

圖 2.1. CPU 資訊

使用者可以透過在其 Linux 機器上執行 lscpu 命令來獲取其 CPU 資訊。圖 2.1. 顯示了在配有兩顆 Intel(R) Xeon(R) Platinum 8180M CPU 的機器上執行 lscpu 的示例。注意,每個 Socket 有 28 個核心,每個核心有 2 個執行緒(即啟用了超執行緒)。換句話說,除了 28 個物理核心外,還有 28 個邏輯核心,每個 Socket 總共有 56 個核心。共有 2 個 Socket,總共有 112 個核心(Thread(s) per core x Core(s) per socket x Socket(s))。

../_images/8.png

圖 2.2. CPU 資訊

2 個 Socket 分別被對映到 2 個 NUMA 節點(NUMA 節點 0、NUMA 節點 1)。物理核心的索引優先於邏輯核心。如圖 2.2. 所示,第一個 Socket 上的前 28 個物理核心 (0-27) 和前 28 個邏輯核心 (56-83) 位於 NUMA 節點 0 上。第二個 Socket 上的第二個 28 個物理核心 (28-55) 和第二個 28 個邏輯核心 (84-111) 位於 NUMA 節點 1 上。同一個 Socket 上的核心共享本地記憶體和末級快取 (LLC),這比透過 Intel UPI 進行跨 Socket 通訊快得多。

現在我們瞭解了 NUMA、跨 Socket (UPI) 流量以及多處理器系統中的本地與遠端記憶體訪問,接下來進行效能分析並驗證我們的理解。

練習

我們將複用上面的 ResNet50 示例。

由於我們沒有將執行緒繫結到特定 Socket 的處理器核心,作業系統會定期將執行緒排程到位於不同 Socket 的處理器核心上。

../_images/9.gif

圖 3. 非 NUMA 感知應用的 CPU 使用情況。啟動了 1 個主工作執行緒,然後它在所有核心(包括邏輯核心)上啟動了物理核心數量 (56) 的執行緒。

(旁註:如果執行緒數未透過 torch.set_num_threads 設定,則在啟用了超執行緒的系統中,預設執行緒數是物理核心的數量。這可以透過 torch.get_num_threads 進行驗證。因此,我們看到上面大約一半的核心忙於執行示例指令碼。)

../_images/10.png

圖 4. 非統一記憶體訪問分析圖

圖 4. 比較了本地記憶體訪問與遠端記憶體訪問隨時間的變化。我們驗證了遠端記憶體的使用,這可能導致次優效能。

設定執行緒親和性以減少遠端記憶體訪問和跨 Socket (UPI) 流量

將執行緒繫結到同一 Socket 上的核心有助於保持記憶體訪問的區域性性。在此示例中,我們將繫結到第一個 NUMA 節點 (0-27) 上的物理核心。透過啟動指令碼,使用者可以很容易地透過簡單地切換 --node_id 啟動指令碼選項來實驗 NUMA 節點配置。

現在讓我們視覺化 CPU 使用情況。

../_images/11.gif

圖 5. NUMA 感知應用的 CPU 使用情況

啟動了 1 個主工作執行緒,然後它在第一個 NUMA 節點上的所有物理核心上啟動了執行緒。

../_images/12.png

圖 6. 非統一記憶體訪問分析圖

如圖 6 所示,現在幾乎所有的記憶體訪問都是本地訪問。

透過核心繫結實現多 worker 推理的高效 CPU 使用

執行多 worker 推理時,核心會在 worker 之間重疊(或共享),導致 CPU 使用效率低下。為了解決這個問題,啟動指令碼將可用核心數平均分配給 worker 數,以便每個 worker 在執行時被繫結到指定的核心。

TorchServe 練習

在此練習中,我們將把迄今討論的 CPU 效能調優原則和建議應用於 TorchServe apache-bench 基準測試

我們將使用 ResNet50,設定 4 個 worker,併發度 100,請求數 10,000。所有其他引數(例如 batch_size、輸入等)與預設引數相同。

我們將比較以下三種配置:

  1. 預設 TorchServe 設定(無核心繫結)

  2. torch.set_num_threads = 物理核心數 / worker (無核心繫結)

  3. 透過啟動指令碼進行核心繫結(需要 Torchserve >= 0.6.1)

完成此練習後,我們將透過一個真實的 TorchServe 用例來驗證我們更傾向於避免使用邏輯核心,並透過核心繫結來實現本地記憶體訪問。

1. 預設 TorchServe 設定(無核心繫結)

base_handler 沒有顯式設定 torch.set_num_threads。因此,如此處所述,預設執行緒數是物理 CPU 核心的數量。使用者可以在 base_handler 中透過 torch.get_num_threads 檢查執行緒數。4 個主 worker 執行緒中的每一個都啟動了物理核心數量 (56) 的執行緒,總共啟動了 56x4 = 224 個執行緒,這超過了總核心數 112。因此,核心肯定會嚴重重疊,邏輯核心利用率很高——多個 worker 同時使用多個核心。此外,由於執行緒未繫結到特定的 CPU 核心,作業系統會定期將執行緒排程到位於不同 Socket 的核心上。

  1. CPU 使用情況

../_images/13.png

啟動了 4 個主 worker 執行緒,然後每個都在所有核心(包括邏輯核心)上啟動了物理核心數量 (56) 的執行緒。

  1. 核心限制停頓

../_images/14.png

我們觀察到非常高的核心限制停頓,高達 88.4%,降低了流水線效率。核心限制停頓表明 CPU 中可用執行單元的使用次優。例如,一系列 GEMM 指令爭用超執行緒核心共享的融合乘加 (FMA) 或點積 (DP) 執行單元可能導致核心限制停頓。如前一部分所述,使用邏輯核心會加劇此問題。

../_images/15.png
../_images/16.png

流水線中未填充微操作 (uOps) 的空槽歸因於停頓。例如,未進行核心繫結時,CPU 使用可能不是有效地用於計算,而是用於 Linux 核心的執行緒排程等其他操作。我們看到上面 __sched_yield 導致了大部分的空轉時間。

  1. 執行緒遷移

未進行核心繫結時,排程器可能將在一個核心上執行的執行緒遷移到不同的核心。執行緒遷移可能導致執行緒與其已獲取到快取中的資料分離,導致更長的資料訪問延遲。在 NUMA 系統中,當執行緒跨 Socket 遷移時,此問題會加劇。已獲取到本地記憶體快取記憶體中的資料現在變為遠端記憶體,這會慢得多。

../_images/17.png

通常,執行緒總數應小於或等於核心支援的執行緒總數。在上面的示例中,我們注意到 core_51 上執行的執行緒數量很大,而不是預期的 2 個執行緒(因為 Intel(R) Xeon(R) Platinum 8180 CPU 中啟用了超執行緒)。這表明存線上程遷移。

../_images/18.png

此外,注意執行緒 (TID:97097) 在大量 CPU 核心上執行,表明 CPU 遷移。例如,此執行緒在 cpu_81 上執行,然後遷移到 cpu_14,再遷移到 cpu_5,等等。此外,注意此執行緒多次來回跨 Socket 遷移,導致非常低效的記憶體訪問。例如,此執行緒在 cpu_70 (NUMA 節點 0) 上執行,然後遷移到 cpu_100 (NUMA 節點 1),再遷移到 cpu_24 (NUMA 節點 0)。

  1. 非統一記憶體訪問分析

../_images/19.png

比較本地記憶體訪問與遠端記憶體訪問隨時間的變化。我們觀察到大約一半,即 51.09% 的記憶體訪問是遠端訪問,表明 NUMA 配置次優。

2. torch.set_num_threads = 物理核心數 / worker (無核心繫結)

為了與啟動器的核心繫結進行同等比較,我們將執行緒數設定為核心數除以 worker 數(啟動器在內部執行此操作)。在 base_handler 中新增以下程式碼片段

torch.set_num_threads(num_physical_cores/num_workers)

與之前未進行核心繫結一樣,這些執行緒未繫結到特定的 CPU 核心,導致作業系統定期將執行緒排程到位於不同 Socket 的核心上。

  1. CPU 使用情況

../_images/20.gif

啟動了 4 個主工作執行緒,然後每個執行緒在所有核心(包括邏輯核心)上啟動了 num_physical_cores/num_workers 個(14 個)執行緒。

  1. 核心限制停頓

../_images/21.png

儘管核心受限(Core Bound)停頓的百分比從 88.4% 下降到 73.5%,但核心受限仍然非常高。

../_images/22.png
../_images/23.png
  1. 執行緒遷移

../_images/24.png

與之前類似,在沒有進行核心繫結(core pinning)的情況下,執行緒 (TID:94290) 在大量 CPU 核心上執行,這表明存在 CPU 遷移。我們再次注意到跨插槽(cross-socket)執行緒遷移,導致記憶體訪問非常低效。例如,該執行緒先在 cpu_78 (NUMA node 0) 上執行,然後遷移到 cpu_108 (NUMA node 1) 上。

  1. 非統一記憶體訪問分析

../_images/25.png

儘管較原始的 51.09% 有所改善,但仍有 40.45% 的記憶體訪問是遠端訪問,這表明 NUMA 配置不是最優的。

3. launcher 核心繫結

Launcher 將在內部將物理核心平均分配給 worker,並將它們繫結到每個 worker。提醒一下,launcher 預設僅使用物理核心。在本例中,launcher 將 worker 0 繫結到核心 0-13 (NUMA node 0),worker 1 繫結到核心 14-27 (NUMA node 0),worker 2 繫結到核心 28-41 (NUMA node 1),以及 worker 3 繫結到核心 42-55 (NUMA node 1)。這樣做可以確保核心在 worker 之間不重疊,並避免使用邏輯核心。

  1. CPU 使用情況

../_images/26.gif

啟動了 4 個主工作執行緒,然後每個執行緒啟動了 num_physical_cores/num_workers 個(14 個)與分配的物理核心親和(affinitized)的執行緒。

  1. 核心限制停頓

../_images/27.png

核心受限停頓從原始的 88.4% 顯著下降到 46.2% - 幾乎是 2 倍的改進。

../_images/28.png
../_images/29.png

我們驗證了透過核心繫結,大部分 CPU 時間被有效用於計算 - 自旋時間(Spin Time)為 0.256 秒。

  1. 執行緒遷移

../_images/30.png

我們驗證了 OMP 主執行緒 #0 被繫結到分配的物理核心 (42-55),並且沒有發生跨插槽遷移。

  1. 非統一記憶體訪問分析

../_images/31.png

現在幾乎所有的記憶體訪問(89.52%)都是本地訪問。

結論

在這篇博文中,我們展示了正確設定 CPU 執行時配置可以顯著提升開箱即用的 CPU 效能。

我們介紹了一些通用的 CPU 效能調優原則和建議

  • 在啟用超執行緒(hyperthreading)的系統中,透過核心繫結將執行緒親和性設定為僅物理核心,以避免使用邏輯核心。

  • 在具有 NUMA 的多插槽系統中,透過核心繫結將執行緒親和性設定為特定的插槽,以避免跨插槽遠端記憶體訪問。

我們從基本原理出發直觀地解釋了這些想法,並透過效能分析(profiling)驗證了效能提升。最後,我們將所有學到的知識應用於 TorchServe,以提升開箱即用的 TorchServe CPU 效能。

這些原則可以透過一個易於使用的啟動指令碼自動配置,該指令碼已整合到 TorchServe 中。

對於感興趣的讀者,請查閱以下文件

請繼續關注後續文章,瞭解透過 Intel® Extension for PyTorch* 在 CPU 上進行的最佳化核心以及諸如記憶體分配器等高階 launcher 配置。

致謝

我們要感謝 Ashok Emani (Intel) 和 Jiong Gong (Intel) 在本博文的許多步驟中提供的巨大指導和支援,以及詳盡的反饋和審閱。我們還要感謝 Hamid Shojanazeri (Meta)、Li Ning (AWS) 和 Jing Xu (Intel) 在程式碼審閱中提供的有益反饋。同時感謝 Suraj Subramanian (Meta) 和 Geeta Chauhan (Meta) 對博文提供的有益反饋。

文件

訪問 PyTorch 的全面開發者文件

檢視文件

教程

獲取面向初學者和高階開發者的深度教程

檢視教程

資源

查詢開發資源並獲得問題解答

檢視資源