Accelerate 文件

梯度同步

Hugging Face's logo
加入 Hugging Face 社群

並獲得增強的文件體驗

開始使用

梯度同步

PyTorch 的分散式模組透過在系統中所有 GPU 之間來回通訊來執行。這種通訊需要時間,並且在使用 `ddp` 模組時,確保所有程序瞭解彼此的狀態是在特定的觸發點發生的。

這些觸發點被新增到 PyTorch 模型中,特別是它們的 `forward()` 和 `backward()` 方法。這在模型被 `DistributedDataParallel` 包裝時發生。

import torch.nn as nn
from torch.nn.parallel import DistributedDataParallel

model = nn.Linear(10, 10)
ddp_model = DistributedDataParallel(model)

在 Accelerate 中,當呼叫 prepare() 並傳入你的模型時,這個轉換會自動發生。

+ from accelerate import Accelerator
+ accelerator = Accelerator()
  import torch.nn as nn
- from torch.nn.parallel import DistributedDataParallel

  model = nn.Linear(10,10)
+ model = accelerator.prepare(model)

梯度累積中的減速

你現在明白了,在分散式設定中訓練時,PyTorch 會向你的 PyTorch 模型的 `forward` 和 `backward` 方法新增鉤子。但這如何會減慢你的程式碼呢?

在 DDP(分散式資料並行)中,程序執行和執行的特定順序是在特定點上預期的,而且這些點必須在繼續下一步之前大致同時發生。

最直接的例子是透過 `optimizer.step()` 更新模型引數。在沒有梯度累積的情況下,所有模型例項都需要在進入下一批資料之前完成梯度的計算、整理和更新。當執行梯度累積時,你會累積 `n` 個損失梯度,並跳過 `optimizer.step()` 直到達到 `n` 個批次。由於所有訓練程序只需要在 `optimizer.step()` 被呼叫時同步,不對你的訓練步驟做任何修改,這種不必要的程序間通訊可能會導致顯著的減速。

你如何避免這種開銷?

解決減速問題

由於你在訓練這些批次時跳過了模型引數更新,它們的梯度直到 `optimizer.step()` 實際被呼叫時才需要同步。PyTorch 無法自動判斷你何時需要這樣做,但他們提供了一個工具來幫助,即透過`no_sync`上下文管理器,它在你將模型轉換為 DDP 後被新增到模型中。

在此上下文管理器下,當呼叫 `.backward()` 時,PyTorch 將跳過同步梯度,而在此上下文管理器之外的第一次 `.backward()` 呼叫將觸發同步。請看下面的例子。

ddp_model, dataloader, optimizer = accelerator.prepare(model, dataloader, optimizer)

for index, batch in enumerate(dataloader):
    inputs, targets = batch
    # Trigger gradient synchronization on the last batch
    if index != (len(dataloader) - 1):
        with ddp_model.no_sync():
            # Gradients only accumulate
            outputs = ddp_model(inputs)
            loss = loss_func(outputs)
            accelerator.backward(loss)
    else:
        # Gradients finally sync
        outputs = ddp_model(inputs)
        loss = loss_func(outputs)
        accelerator.backward(loss)
        optimizer.step()

在 Accelerate 中,為了使其成為一個無論訓練裝置如何都可以呼叫的 API(儘管如果你不在分散式系統中,它可能什麼也不做!),`ddp_model.no_sync` 被替換為 no_sync(),並且操作方式相同。

  ddp_model, dataloader, optimizer = accelerator.prepare(model, dataloader, optimizer)

  for index, batch in enumerate(dataloader):
      inputs, targets = batch
      # Trigger gradient synchronization on the last batch
      if index != (len(dataloader)-1):
-         with ddp_model.no_sync():
+         with accelerator.no_sync(model):
              # Gradients only accumulate
              outputs = ddp_model(inputs)
              loss = loss_func(outputs, targets)
              accelerator.backward(loss)
      else:
          # Gradients finally sync
          outputs = ddp_model(inputs)
          loss = loss_func(outputs)
          accelerator.backward(loss)
          optimizer.step()
          optimizer.zero_grad()

正如你可能預期的,accumulate() 函式透過跟蹤當前批次號來包裝這個條件檢查,最終為你提供了梯度累積 API。

ddp_model, dataloader, optimizer = accelerator.prepare(model, dataloader, optimizer)

for batch in dataloader:
    with accelerator.accumulate(model):
        optimizer.zero_grad()
        inputs, targets = batch
        outputs = model(inputs)
        loss = loss_function(outputs, targets)
        accelerator.backward(loss)
        optimizer.step()
        optimizer.zero_grad()

因此,在 API 選擇方面,你應該使用 *`accelerator.accumulate` 或 `accelerator.no_sync`*。

減速有多嚴重,以及你可能犯的簡單錯誤

為了建立一個現實的例子,考慮以下設定:

  • 兩個單 GPU T4 節點和一個擁有兩個 GPU 的節點
  • 每個 GPU 都是 T4,並託管在 GCP 上
  • 使用的指令碼是NLP 示例指令碼的修改版
  • 每個 GPU 的批次大小為 16,梯度每 4 步累積一次

所有指令碼都可以在這個倉庫中找到。

如果不注意梯度同步和 GPU 通訊,在不必要的時期內,這些 GPU 之間的通訊可能會浪費*大量*時間。

到底有多少?

參考

  • 基線:不使用此處討論的任何同步實踐
  • no_sync 不當使用:`no_sync` 僅圍繞 `backward` 呼叫,而不是 `forward`
  • no_sync:正確使用 `no_sync` 模式
  • accumulate:正確使用 accumulate()

以下是在單節點和雙節點設定中,對每種設定迭代 29 批資料時,每批次的平均秒數。

基線 no_sync 不當使用 no_sync accumulate
多節點 2±0.01s 2.13±0.08s 0.91±0.11s 0.91±0.11s
單節點 0.50±0.01s 0.50±0.01s 0.41±0.015s 0.41±0.015s

如你所見,如果你不注意梯度同步的設定,訓練期間可能會出現超過 2 倍的減速!

如果你擔心確保一切都正確完成,我們強烈建議使用 accumulate() 函式,並將 `gradient_accumulation_steps` 或 `gradient_accumulation_plugin` 傳遞給 Accelerator 物件,以便 Accelerate 為你處理這些。

使用 FSDP 時,no_sync 需要額外的 GPU 記憶體

請注意,在執行 FSDP 訓練時,不同步梯度可能會產生負面影響。正如 `torch` 中所警告的,FSDP 的`no_sync` 上下文管理器將需要額外的記憶體。

因此,在使用 FSDP 且記憶體密集的情況下,我們建議在 GradientAccumulationPlugin 中將 `sync_each_batch` 設定為 `True` 以停用 `no_sync`。

請看下面的例子,我們在 8 個 A100-80GB GPU 上微調 Mixtral(47B 引數)。我們看到,即使對於適度的 `gradient_accumulation_steps=2`,如果啟用了 `no_sync`,我們很快就會記憶體不足(OOM)。同樣,這是由於 FSDP 的 `no_sync` 帶來的額外記憶體開銷。但是,如果透過 `sync_each_batch=True` 停用了 `no_sync`,那麼 `gradient_accumulation_steps=16` 的記憶體消耗將恢復到 `gradient_accumulation_steps=1` 的水平。

模型 no_sync (accum=1) no_sync (accum=2) no_sync 停用 (accum=16)
mixtral 8x7B 69G OOM 69G

停用 `no_sync` 意味著由於額外的資料同步,*將會出現減速*,正如本指南前面部分所解釋的。

< > 在 GitHub 上更新

© . This site is unofficial and not affiliated with Hugging Face, Inc.