深度學習模型日益增大,單一 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

內容解密:

  1. __init__方法中,我們初始化了兩個子網路subnet1subnet2,並將它們分配到不同的GPU上。
  2. forward方法中,我們首先將輸入資料x載入到GPU 1上,並計算subnet1的輸出。
  3. 然後,我們將subnet1的輸出移到GPU 2上,並計算subnet2的輸出。
  4. 這種方法雖然可以處理大型模型,但由於子網路之間的依賴關係,GPU的使用率往往很低。

模型平行的問題

模型平行的主要問題是GPU資源的嚴重浪費。由於各個子網路之間存在先後依賴關係,在任何時候只有一個GPU在工作,其他GPU都處於閒置狀態。

管線平行

管線平行是對模型平行的改進,它透過將每個訓練批次劃分為多個微批次(microbatches),並在不同的GPU上重疊計算這些微批次,從而提高GPU的使用率。

GPipe的工作原理

GPipe是Google提出的一種管線平行方法,它透過以下步驟提高訓練效率:

  1. 將每個訓練批次劃分為多個微批次。
  2. 在不同的GPU上平行計算這些微批次的前向傳遞和反向傳遞。
  3. 累積所有微批次的梯度,並在每個訓練批次結束時更新模型引數。
# 使用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()

內容解密:

  1. 我們定義了一個Transformer模型,並使用Pipe類別將其分割到不同的GPU上。
  2. 在訓練迴圈中,我們將輸入資料和標籤載入到GPU上,並進行前向傳遞和反向傳遞。
  3. 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()

內容解密:

  1. rpc.init_rpc 初始化遠端通訊:此步驟建立了不同計算節點之間的通訊基礎,使得分散式訓練得以進行。
  2. 模型分割:將模型按照一定的規則分割成多個部分,分別載入到不同的GPU上,實作了模型的平行處理。
  3. Pipe 模組構建Pipeline:透過將分割好的模型階段串聯起來,形成Pipeline,使得資料能夠在不同GPU上順序處理。
  4. 訓練流程:在訓練過程中,利用Pipeline機制執行前向傳播和反向傳播,實作了高效的模型訓練。

軟體工程師如何支援Pipeline平行

作為軟體工程師,可以從以下幾個方面支援Pipeline平行訓練:

  1. 自動化訓練執行:開發訓練服務,自動化分配計算資源、啟用節點間通訊,並將訓練程式碼分發到各個工作節點。
  2. 與資料科學家團隊溝通:向資料科學家介紹新的分散式訓練方法,推動團隊嘗試Pipeline平行等先進技術。
  3. 提升模型訓練的可用性:加強訓練過程的監控、容錯和故障還原能力,減少因單一節點故障導致的訓練失敗。

超引數最佳化服務:深度學習的關鍵工程實踐

在前兩章中,我們探討了模型的訓練過程:訓練服務如何在遠端計算叢集中管理訓練程式。然而,模型演算法和訓練服務並不是模型訓練的全部。還有一個重要的組成部分尚未討論——超引數最佳化(Hyperparameter Optimization, HPO)。資料科學家經常忽視超引數的選擇對模型訓練結果的顯著影響,特別是當這些決策可以透過工程方法自動化時。

超引數的重要性

研究表明,超引數的選擇會影響模型的訓練品質,以及訓練演算法的時間和記憶體需求。因此,超引數必須調整到最佳狀態,以適應模型訓練。如今,HPO 已成為深度學習模型開發流程中的標準步驟。

為什麼工程師需要關注 HPO

作為深度學習的組成部分之一,HPO 對軟體工程師來說非常重要。這是因為 HPO 不需要深入理解深度學習演算法,因此工程師經常被分配到這項任務。大多數情況下,HPO 可以像黑盒一樣執行,無需修改訓練程式碼。此外,工程師有能力建立自動化的 HPO 機制,使 HPO 成為可能。

超引數的定義

訓練深度學習模型的過程使用兩種型別的引數或值:模型引數和超引數。模型引數是可訓練的,即它們的值可以在訓練過程中學習得到。相反,超引數的值必須在訓練過程開始前設定。學習率、批次大小和隱藏層數量都是超引數的例子。

超引數最佳化的兩種常見方法

  1. 使用函式庫進行 HPO:有多個流行的開源 HPO 函式庫,如 Hyperopt、Optuna 和 Ray Tune,可以用於最佳化模型訓練過程中的超引數。

  2. 建立 HPO 服務:除了使用現有的函式庫,工程師還可以設計和建立自己的 HPO 服務。這需要考慮到多個設計原則,包括可擴充套件性、可移植性和高效性。

設計 HPO 服務的五大原則

  1. 通用性:HPO 服務應該能夠支援多種不同的模型和訓練框架。

  2. 可擴充套件性:服務應該能夠隨著工作負載的增加而擴充套件,支援分散式計算。

  3. 高效性:最佳化過程應該盡可能高效,減少不必要的計算資源浪費。

  4. 可移植性:HPO 服務應該能夠在不同的環境和基礎設施上執行。

  5. 使用者友好性:提供簡單易用的介面,讓使用者能夠輕鬆定義超引數搜尋空間和最佳化目標。

使用開源 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 的流程。