深度學習模型日益增大,單一 GPU 的記憶體容量往往難以負荷。為此,開發者需要採用各種技術來最佳化記憶體使用,才能順利訓練大型模型。梯度累積是一種常見的方法,它透過累積多個小批次的梯度,模擬更大的批次大小,從而在不增加 GPU 記憶體負擔的前提下提升訓練效果。此外,在 CPU 和 GPU 之間交換資料也是一種有效的策略,例如 L2L 技術,它只將當前計算所需的資料保留在 GPU 上,其餘資料則儲存在 CPU 記憶體中,藉此降低 GPU 記憶體壓力。更進一步的技術是模型平行,將模型分割成多個子網路,分別在不同的 GPU 上執行。然而,模型平行存在 GPU 使用率低的問題,因為子網路之間的計算存在依賴性。為瞭解決這個問題,Pipeline平行技術,例如 Google 提出的 GPipe,應運而生。它將每個批次分割成更小的微批次,並在不同的 GPU 上重疊執行這些微批次的計算,從而提高 GPU 使用率。
4.4 在單一GPU無法載入的大型模型訓練
在深度學習訓練中,我們經常遇到模型太大而無法載入單一GPU的情況。為瞭解決這個問題,人們開發了多種技術,如NVIDIA的vDNN(https://arxiv.org/abs/1602.08124)。然而,由於本文重點不在於深度學習演算法,我們將這些技術留待讀者自行研究。
梯度累積
在深度學習訓練中,資料集被分成批次。在每個訓練步驟中,為了計算損失、梯度和更新模型引數,我們一次性將整個批次的樣本(訓練資料)載入記憶體並進行計算。
減少批次大小可以減輕記憶體壓力,例如,將批次大小從32減少到16。但是,減少批次大小會導致模型收斂速度變慢。這時,梯度累積可以提供幫助。
梯度累積將批次樣本分成可組態數量的小批次,然後在每個小批次後計算損失和梯度。但它不是立即更新模型引數,而是等待並累積所有小批次的梯度。最終,它根據累積的梯度更新模型引數。
讓我們看一個例子來瞭解這個過程是如何加速的。假設由於GPU記憶體限制,我們無法以批次大小32進行訓練。使用梯度累積,我們可以將每個批次分成四個小批次,每個小批次大小為8。由於我們累積所有四個小批次的梯度,並且只在四個小批次都完成後才更新模型,因此這個過程幾乎等同於以批次大小32進行訓練。不同的是,我們一次只在GPU中計算8個樣本,而不是32個,因此成本是批次大小32的4倍。
# 梯度累積範例程式碼
def train_with_gradient_accumulation(model, device, batch_size, gradient_accumulation_steps):
# 初始化梯度累積步數
gradient_accumulation_steps = gradient_accumulation_steps
# 遍歷資料集
for batch_idx, batch in enumerate(dataset):
# 將資料載入GPU
inputs = batch.to(device)
# 前向傳播
outputs = model(inputs)
loss = loss_fn(outputs, labels)
# 反向傳播
loss.backward()
# 梯度累積
if (batch_idx + 1) % gradient_accumulation_steps == 0:
optimizer.step()
optimizer.zero_grad()
#### 內容解密:
1. `train_with_gradient_accumulation` 函式接受模型、裝置、批次大小和梯度累積步數作為引數。
2. 在遍歷資料集的過程中,將資料載入GPU並進行前向傳播和反向傳播。
3. 使用 `loss.backward()` 計算梯度。
4. 每隔 `gradient_accumulation_steps` 個批次,就執行一次梯度更新和梯度清零操作。
### GPU與CPU之間的記憶體交換
記憶體交換方法非常簡單:它在CPU和GPU之間來回複製啟動值。如果你不熟悉深度學習術語,可以將啟動值視為神經網路每個節點的計算輸出。這個想法是隻保留當前計算步驟所需的資料在GPU中,並將計算結果交換到CPU記憶體中供未來步驟使用。
在此基礎上,一種新的繼電器式執行技術稱為L2L(層到層),它只保留執行層和傳輸緩衝區在GPU上。整個模型和最佳化器(持有狀態)都儲存在CPU空間中。L2L可以大大提高GPU吞吐量,並允許我們在經濟實惠的裝置上開發大型模型。
```python
# L2L範例程式碼(概念性,非實際實作)
def l2l_execution(model, device):
# 將模型和最佳化器儲存在CPU中
model_cpu = model.to('cpu')
optimizer_cpu = optimizer.state_dict()
# 只將執行層載入GPU
executing_layers = get_executing_layers(model, current_step)
executing_layers_gpu = executing_layers.to(device)
# 進行前向傳播和反向傳播
outputs = executing_layers_gpu(inputs)
loss = loss_fn(outputs, labels)
loss.backward()
# 更新模型引數
update_model_parameters(model_cpu, executing_layers_gpu.gradients)
#### 內容解密:
1. `l2l_execution` 函式將模型和最佳化器儲存在CPU中。
2. 只將當前執行層載入GPU。
3. 在GPU上進行前向傳播和反向傳播。
4. 將梯度更新回CPU中的模型引數。
### 管道模型平行
在第4.2節中,我們討論了最常用的分散式訓練方法:資料平行。這種方法在每個裝置上保留整個模型的副本,並將資料分配到多個裝置上。然後,它彙總梯度並在每個訓練步驟中更新模型。只要整個模型可以載入一個GPU,資料平行的方法就非常有效。然而,在本文中,我們將看到,並非總是能夠做到這一點。這時,管道平行就可以派上用場。
#### 模型平行
模型平行的想法是將神經網路分成較小的子網路,並在不同的GPU上執行每個子網路。圖4.6展示了模型平行的方法。
```python
# 模型平行範例程式碼(虛擬PyTorch程式碼)
class ModelParallel(nn.Module):
def __init__(self):
super(ModelParallel, self).__init__()
self.subnet1 = Subnet1().to('cuda:0')
self.subnet2 = Subnet2().to('cuda:1')
def forward(self, x):
x = self.subnet1(x.to('cuda:0'))
x = self.subnet2(x.to('cuda:1'))
return x
#### 內容解密:
1. `ModelParallel` 類別繼承自 `nn.Module`。
2. 將子網路分配到不同的GPU上。
3. 在 `forward` 方法中,將輸入資料傳遞到不同的子網路,並在不同的GPU上進行計算。
## 4.4 訓練無法載入單一GPU的大型模型
在處理大型深度學習模型時,單一GPU的記憶體限制成為一大挑戰。為瞭解決這個問題,我們可以採用模型平行(Model Parallelism)或管線平行(Pipeline Parallelism)的方法。
### 模型平行
模型平行是一種將大型模型分割成多個子網路,並將這些子網路分配到不同的GPU上進行訓練的方法。以下是一個簡單的PyTorch範例,展示如何實作模型平行:
```python
gpu1 = 0
gpu2 = 1
class LargeModel(nn.Module):
def __init__(self):
super().__init__()
# 初始化網路為兩個子網路
self.subnet1 = ...
self.subnet2 = ...
# 將子網路1和2放到不同的GPU上
self.subnet1.cuda(gpu1)
self.subnet2.cuda(gpu2)
def forward(self, x):
# 將資料載入GPU 1並計運算元網路1的輸出,此時GPU 2閒置
x = x.cuda(gpu1)
x = self.subnet1(x)
# 將子網路1的輸出移到GPU 2並計運算元網路2的輸出,此時GPU 1閒置
x = x.cuda(gpu2)
x = self.subnet2(x)
return x
內容解密:
- 在
__init__方法中,我們初始化了兩個子網路subnet1和subnet2,並將它們分配到不同的GPU上。 - 在
forward方法中,我們首先將輸入資料x載入到GPU 1上,並計算subnet1的輸出。 - 然後,我們將
subnet1的輸出移到GPU 2上,並計算subnet2的輸出。 - 這種方法雖然可以處理大型模型,但由於子網路之間的依賴關係,GPU的使用率往往很低。
模型平行的問題
模型平行的主要問題是GPU資源的嚴重浪費。由於各個子網路之間存在先後依賴關係,在任何時候只有一個GPU在工作,其他GPU都處於閒置狀態。
管線平行
管線平行是對模型平行的改進,它透過將每個訓練批次劃分為多個微批次(microbatches),並在不同的GPU上重疊計算這些微批次,從而提高GPU的使用率。
GPipe的工作原理
GPipe是Google提出的一種管線平行方法,它透過以下步驟提高訓練效率:
- 將每個訓練批次劃分為多個微批次。
- 在不同的GPU上平行計算這些微批次的前向傳遞和反向傳遞。
- 累積所有微批次的梯度,並在每個訓練批次結束時更新模型引數。
# 使用PyTorch GPipe實作管線平行訓練Transformer模型的範例程式碼
from torch.distributed.pipeline.sync import Pipe
# 定義模型
class TransformerModel(nn.Module):
def __init__(self):
super().__init__()
# 初始化Transformer模型
self.layers = nn.Sequential(
# ...
)
def forward(self, x):
return self.layers(x)
# 將模型分割到不同的GPU上
model = TransformerModel()
model = Pipe(model, chunks=4) # 將輸入批次劃分為4個微批次
# 進行訓練
for batch in dataset:
input_ids = batch['input_ids'].cuda(0)
labels = batch['labels'].cuda(0)
output = model(input_ids)
loss = criterion(output, labels)
loss.backward()
optimizer.step()
內容解密:
- 我們定義了一個Transformer模型,並使用
Pipe類別將其分割到不同的GPU上。 - 在訓練迴圈中,我們將輸入資料和標籤載入到GPU上,並進行前向傳遞和反向傳遞。
- GPipe會自動處理微批次之間的依賴關係,並在每個訓練批次結束時更新模型引數。
分散式訓練的進階技術:模型平行與Pipeline平行
在深度學習模型的訓練過程中,分散式訓練扮演著至關重要的角色。當模型規模龐大或資料集極為龐雜時,單一GPU的運算能力往往捉襟見肘。為瞭解決這一問題,研究人員提出了多種分散式訓練策略,其中最為常見的有資料平行(Data Parallelism)和模型平行(Model Parallelism),而Pipeline平行(Pipeline Parallelism)則是模型平行的一種最佳化實作。
資料平行 vs. 模型平行
資料平行的核心思想是將資料集分割成多個子集,並在不同的計算節點上平行訓練模型。這種方法的優點在於實作簡單,能夠有效地加速訓練過程。然而,當模型過大無法載入單一GPU時,資料平行就顯得力不從心。
模型平行則是將模型本身進行分割,將不同的部分佈署在不同的GPU或計算節點上。這種方法能夠有效地處理超大模型,但其實作複雜度較高,需要精細的同步機制來協調不同部分之間的計算。
Pipeline平行的實作與優勢
Pipeline平行是模型平行的一種特殊形式,它透過將模型分割成多個階段,並在不同的GPU上順序執行,從而實作了計算資源的高效利用。以PyTorch為例,實作Pipeline平行需要經過以下幾個步驟:
初始化遠端通訊
首先,需要初始化RPC(Remote Procedure Call)通訊框架,以實作不同計算節點之間的協調與通訊。
rpc.init_rpc(
name="worker",
rank=0,
world_size=1,
# 其他初始化引數
)
模型分割與載入
接下來,將模型分割成多個子網路,並將其載入到不同的GPU上。
num_gpus = 2
partition_len = ((nlayers - 1) // num_gpus) + 1
for i in range(nlayers):
transformer_block = TransformerEncoderLayer(emsize, nhead, nhid, dropout)
# 載入到不同的GPU
device = i // (partition_len)
tmp_list.append(transformer_block.to(device))
構建Pipeline
使用Pipe模組構建Pipeline,將分割好的模型階段串聯起來。
chunks = 8 # 設定微批次數量
model = Pipe(torch.nn.Sequential(*module_list), chunks=chunks)
訓練流程
在訓練過程中,透過Pipeline執行前向傳播和反向傳播。
def train():
model.train()
for batch, i in enumerate(range(0, nbatches, bptt)):
data, targets = get_batch(train_data, i)
optimizer.zero_grad()
output = model(data).local_value()
loss = criterion(output.view(-1, ntokens), targets.cuda(1))
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()
內容解密:
rpc.init_rpc初始化遠端通訊:此步驟建立了不同計算節點之間的通訊基礎,使得分散式訓練得以進行。- 模型分割:將模型按照一定的規則分割成多個部分,分別載入到不同的GPU上,實作了模型的平行處理。
Pipe模組構建Pipeline:透過將分割好的模型階段串聯起來,形成Pipeline,使得資料能夠在不同GPU上順序處理。- 訓練流程:在訓練過程中,利用Pipeline機制執行前向傳播和反向傳播,實作了高效的模型訓練。
軟體工程師如何支援Pipeline平行
作為軟體工程師,可以從以下幾個方面支援Pipeline平行訓練:
- 自動化訓練執行:開發訓練服務,自動化分配計算資源、啟用節點間通訊,並將訓練程式碼分發到各個工作節點。
- 與資料科學家團隊溝通:向資料科學家介紹新的分散式訓練方法,推動團隊嘗試Pipeline平行等先進技術。
- 提升模型訓練的可用性:加強訓練過程的監控、容錯和故障還原能力,減少因單一節點故障導致的訓練失敗。
超引數最佳化服務:深度學習的關鍵工程實踐
在前兩章中,我們探討了模型的訓練過程:訓練服務如何在遠端計算叢集中管理訓練程式。然而,模型演算法和訓練服務並不是模型訓練的全部。還有一個重要的組成部分尚未討論——超引數最佳化(Hyperparameter Optimization, HPO)。資料科學家經常忽視超引數的選擇對模型訓練結果的顯著影響,特別是當這些決策可以透過工程方法自動化時。
超引數的重要性
研究表明,超引數的選擇會影響模型的訓練品質,以及訓練演算法的時間和記憶體需求。因此,超引數必須調整到最佳狀態,以適應模型訓練。如今,HPO 已成為深度學習模型開發流程中的標準步驟。
為什麼工程師需要關注 HPO
作為深度學習的組成部分之一,HPO 對軟體工程師來說非常重要。這是因為 HPO 不需要深入理解深度學習演算法,因此工程師經常被分配到這項任務。大多數情況下,HPO 可以像黑盒一樣執行,無需修改訓練程式碼。此外,工程師有能力建立自動化的 HPO 機制,使 HPO 成為可能。
超引數的定義
訓練深度學習模型的過程使用兩種型別的引數或值:模型引數和超引數。模型引數是可訓練的,即它們的值可以在訓練過程中學習得到。相反,超引數的值必須在訓練過程開始前設定。學習率、批次大小和隱藏層數量都是超引數的例子。
超引數最佳化的兩種常見方法
使用函式庫進行 HPO:有多個流行的開源 HPO 函式庫,如 Hyperopt、Optuna 和 Ray Tune,可以用於最佳化模型訓練過程中的超引數。
建立 HPO 服務:除了使用現有的函式庫,工程師還可以設計和建立自己的 HPO 服務。這需要考慮到多個設計原則,包括可擴充套件性、可移植性和高效性。
設計 HPO 服務的五大原則
通用性:HPO 服務應該能夠支援多種不同的模型和訓練框架。
可擴充套件性:服務應該能夠隨著工作負載的增加而擴充套件,支援分散式計算。
高效性:最佳化過程應該盡可能高效,減少不必要的計算資源浪費。
可移植性:HPO 服務應該能夠在不同的環境和基礎設施上執行。
使用者友好性:提供簡單易用的介面,讓使用者能夠輕鬆定義超引數搜尋空間和最佳化目標。
使用開源 Kubeflow Katib
本章不會從頭開始構建一個新的範例服務,而是建議使用開源的 Kubeflow Katib。Katib 是一個設計良好的、可擴充套件的、高度可移植的 HPO 服務,幾乎可以用於任何 HPO 專案。
超引數最佳化
隨著深度學習技術的不斷進步,超引數最佳化的重要性將越來越被重視。未來,我們可以期待看到更多高效、自動化的 HPO 方法和工具的出現,以幫助研究人員和工程師更好地最佳化模型的效能。
@startuml
skinparam backgroundColor #FEFEFE
title 大型模型訓練 GPU 記憶體最佳化技術
|開發者|
start
:提交程式碼;
:推送到 Git;
|CI 系統|
:觸發建置;
:執行單元測試;
:程式碼品質檢查;
if (測試通過?) then (是)
:建置容器映像;
:推送到 Registry;
else (否)
:通知開發者;
stop
endif
|CD 系統|
:部署到測試環境;
:執行整合測試;
if (驗證通過?) then (是)
:部署到生產環境;
:健康檢查;
:完成部署;
else (否)
:回滾變更;
endif
stop
@enduml此圖示展示了超引數最佳化的基本流程,從定義搜尋空間到評估最佳超引數。
內容解密:
此圖示清晰地展示了超引數最佳化的步驟。首先,需要定義超引數的搜尋空間;接著,選擇適合的 HPO 方法;然後,執行 HPO 程式;最後,評估得到的最佳超引數。每一步都是整個最佳化過程中不可或缺的一部分。透過這種視覺化的方式,可以更直觀地理解 HPO 的流程。