【大規模モデル】Tensor/Pipeline並列化の理論と実装

大規模言語モデル(LLM)は数十億から数千億のパラメータを持ち、単一のGPUメモリには収まりません。このような巨大なモデルを学習・推論するために、モデル並列化(Model Parallelism)という技術が使われます。

モデル並列化には主にTensor並列(Tensor Parallelism)とPipeline並列(Pipeline Parallelism)の2種類があります。本記事では、これらの手法の仕組み、トレードオフ、実装の考え方を解説します。

本記事の内容

  • データ並列化の限界
  • Tensor並列(モデルの幅方向の分割)
  • Pipeline並列(モデルの深さ方向の分割)
  • 各手法の比較とトレードオフ
  • 実用的な並列化戦略

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

モデルサイズとメモリ

LLMのパラメータ数

代表的なモデルのパラメータ数:

モデル パラメータ数 必要メモリ(FP16)
GPT-2 1.5B 3 GB
Llama 2 7B 7B 14 GB
Llama 2 70B 70B 140 GB
GPT-4 (推定) 1.7T 3.4 TB

float16で1パラメータ = 2バイトなので、70Bモデルでも140GBのメモリが必要です。現在の最大級GPU(H100)でも80GBなので、単一GPUには収まりません。

学習時のメモリ

学習時には、パラメータに加えて以下も必要です:

  • 勾配: パラメータと同サイズ
  • オプティマイザ状態: Adamの場合、パラメータの2倍(モメンタムと二乗平均)
  • アクティベーション: バッチサイズと系列長に依存

Adam + FP16の場合、パラメータサイズの約16倍のメモリが必要になることがあります。

データ並列化とその限界

データ並列化の仕組み

最も基本的な並列化はデータ並列化(Data Parallelism)です。

GPU 0: 全パラメータのコピー, バッチの1/N
GPU 1: 全パラメータのコピー, バッチの1/N
...
GPU N-1: 全パラメータのコピー, バッチの1/N

各GPUで独立に順伝播・逆伝播を計算し、勾配を集約(All-Reduce)してパラメータを更新します。

限界

データ並列化は、モデル全体が各GPUに収まることが前提です。70Bのモデルは単一GPUに収まらないため、データ並列化だけでは対応できません。

Tensor並列(幅方向の分割)

基本アイデア

Tensor並列は、モデルの各層を複数のGPUに分割します。例えば、行列積 $\bm{Y} = \bm{X}\bm{W}$ を複数GPUで分散計算します。

行列積の分割

行列 $\bm{W} \in \mathbb{R}^{m \times n}$ を列方向に分割:

$$ \bm{W} = [\bm{W}_1, \bm{W}_2, \ldots, \bm{W}_N] $$

各GPU $i$ は $\bm{W}_i \in \mathbb{R}^{m \times (n/N)}$ を保持し、部分積を計算:

$$ \bm{Y}_i = \bm{X} \bm{W}_i $$

結果を結合: $$ \bm{Y} = [\bm{Y}_1, \bm{Y}_2, \ldots, \bm{Y}_N] $$

MLPレイヤーの分割

Transformerの Feed-Forward Network を例に考えます。

$$ \bm{Y} = \text{GeLU}(\bm{X}\bm{W}_1)\bm{W}_2 $$

ここで $\bm{W}_1 \in \mathbb{R}^{d \times 4d}$, $\bm{W}_2 \in \mathbb{R}^{4d \times d}$ です。

分割戦略:

  1. $\bm{W}_1$ を列方向に分割(各GPUが $d \times 2d$ を保持)
  2. GeLUを各GPUで独立に適用
  3. $\bm{W}_2$ を行方向に分割(各GPUが $2d \times d$ を保持)
  4. 結果を All-Reduce で集約
GPU 0: X @ W1_0 -> GeLU -> @ W2_0 -> 部分和
GPU 1: X @ W1_1 -> GeLU -> @ W2_1 -> 部分和
                                       ↓
                                   All-Reduce
                                       ↓
                                       Y

この戦略では、各GPUが独立に計算でき、最後にAll-Reduceで結果を集約します。

Self-Attentionの分割

Multi-Head Attentionは自然にヘッド方向に分割できます。

$$ \text{MultiHead}(\bm{Q}, \bm{K}, \bm{V}) = \text{Concat}(\text{head}_1, \ldots, \text{head}_h)\bm{W}^O $$

$h$ 個のヘッドを $N$ 台のGPUに分散:

GPU 0: head_1, head_2, ..., head_{h/N}
GPU 1: head_{h/N+1}, ..., head_{2h/N}
...

各GPUで部分的なヘッドを計算し、$\bm{W}^O$ も分割して、最後にAll-Reduceします。

通信パターン

Tensor並列では、以下の通信が発生します:

  1. Forward時: 各レイヤーの終わりでAll-Reduce(または All-Gather)
  2. Backward時: 勾配のAll-Reduce

通信は頻繁に発生するため、高速な相互接続(NVLink、NVSwitchなど)が必要です。

Pipeline並列(深さ方向の分割)

基本アイデア

Pipeline並列は、モデルを層(レイヤー)ごとに分割し、各GPUが異なる層を担当します。

GPU 0: Layer 0-7   (Embedding + 最初の8層)
GPU 1: Layer 8-15
GPU 2: Layer 16-23
GPU 3: Layer 24-31 (最後の8層 + Output)

データは「パイプライン」のように各GPUを順番に流れていきます。

ナイーブな実装の問題

素朴な実装では、1つのGPUが計算している間、他のGPUはアイドル状態になります。

時間 →
GPU 0: [計算] [待機] [待機] [待機]
GPU 1: [待機] [計算] [待機] [待機]
GPU 2: [待機] [待機] [計算] [待機]
GPU 3: [待機] [待機] [待機] [計算]

GPU使用率が非常に低く、効率的ではありません。

マイクロバッチ分割

効率を上げるため、バッチを複数のマイクロバッチに分割します。

時間 →
GPU 0: [μ1] [μ2] [μ3] [μ4] [待機] ...
GPU 1: [待機] [μ1] [μ2] [μ3] [μ4] ...
GPU 2: [待機] [待機] [μ1] [μ2] [μ3] ...
GPU 3: [待機] [待機] [待機] [μ1] [μ2] ...

パイプラインが「満たされる」と、すべてのGPUが並列に動作します。

バブル(Pipeline Bubble)

パイプラインの開始時と終了時には、一部のGPUがアイドル状態になります。これをバブルと呼びます。

バブルの割合: $$ \text{Bubble ratio} = \frac{(N_{\text{stages}} – 1)}{N_{\text{microbatches}}} $$

マイクロバッチ数を増やすとバブルは減りますが、メモリ使用量(アクティベーション保存)が増えます。

1F1B スケジュール

GPipe では、すべての Forward を完了してから Backward を開始します。より効率的な 1F1B(One Forward One Backward)スケジュールでは、Forward と Backward を交互に実行し、アクティベーションのメモリ使用量を抑えます。

時間 →
GPU 0: [F1] [F2] [F3] [F4] [B4] [B3] [B2] [B1]
GPU 1: [  ] [F1] [F2] [F3] [B3] [B2] [B1] [B4] ...

通信パターン

Pipeline並列では、隣接するGPU間でのみ通信が発生します:

  • Forward時: GPU $i$ から GPU $i+1$ へアクティベーションを送信
  • Backward時: GPU $i+1$ から GPU $i$ へ勾配を送信

通信は隣接間のみなので、NVLinkがなくてもある程度動作します。

各手法の比較

特性 データ並列 Tensor並列 Pipeline並列
分割方向 データ(バッチ) モデル(幅) モデル(深さ)
通信パターン All-Reduce All-Reduce Point-to-Point
通信頻度 各ステップ1回 各層で発生 各マイクロバッチ
必要な相互接続 中程度 高速(NVLink推奨) 低速でもOK
効率低下要因 勾配通信 頻繁なAll-Reduce バブル
スケーラビリティ バッチサイズに制限 通信がボトルネック バブル増加

組み合わせ戦略:3D並列

実際の大規模学習では、3種類の並列化を組み合わせて使用します。

例:8ノード × 8GPU(64GPU)

  • Tensor並列: ノード内の8GPUで分割(NVLinkで高速通信)
  • Pipeline並列: 2ノードで1パイプライン(4ステージ)
  • データ並列: 4パイプラインで同じモデルを複製
ノード0-1: Pipeline Stage 0-3, Data Replica 0
ノード2-3: Pipeline Stage 0-3, Data Replica 1
ノード4-5: Pipeline Stage 0-3, Data Replica 2
ノード6-7: Pipeline Stage 0-3, Data Replica 3

各ステージ内ではTensor並列(8-way)を使用。

メモリ効率化技術

アクティベーションチェックポイント

すべてのアクティベーションを保存する代わりに、一部のみ保存し、必要に応じて再計算します。

$$ \text{Memory} = O(\sqrt{L}) \quad (\text{通常は } O(L)) $$

計算量は増えますが、メモリ使用量が大幅に減少します。

ZeRO(Zero Redundancy Optimizer)

データ並列化において、オプティマイザ状態・勾配・パラメータの冗長コピーを排除します。

  • Stage 1: オプティマイザ状態を分割
  • Stage 2: 勾配も分割
  • Stage 3: パラメータも分割

Stage 3では、各GPUがパラメータの一部のみを保持し、必要に応じて通信で取得します。

実装の考え方(PyTorch)

Tensor並列の概念的実装

import torch
import torch.nn as nn
import torch.distributed as dist


class ColumnParallelLinear(nn.Module):
    """列方向に分割されたLinear層"""

    def __init__(self, in_features, out_features, world_size, rank):
        super().__init__()
        # 各GPUは out_features / world_size の列を担当
        self.out_features_per_rank = out_features // world_size
        self.weight = nn.Parameter(
            torch.randn(in_features, self.out_features_per_rank)
        )
        self.rank = rank

    def forward(self, x):
        # 各GPUで部分的な計算
        output = x @ self.weight
        return output  # (batch, out_features_per_rank)


class RowParallelLinear(nn.Module):
    """行方向に分割されたLinear層"""

    def __init__(self, in_features, out_features, world_size, rank):
        super().__init__()
        # 各GPUは in_features / world_size の行を担当
        self.in_features_per_rank = in_features // world_size
        self.weight = nn.Parameter(
            torch.randn(self.in_features_per_rank, out_features)
        )
        self.rank = rank

    def forward(self, x):
        # 入力は分割されている想定
        output = x @ self.weight

        # All-Reduce で結果を集約
        dist.all_reduce(output, op=dist.ReduceOp.SUM)
        return output


class ParallelMLP(nn.Module):
    """Tensor並列化されたMLP"""

    def __init__(self, d_model, d_ff, world_size, rank):
        super().__init__()
        self.fc1 = ColumnParallelLinear(d_model, d_ff, world_size, rank)
        self.fc2 = RowParallelLinear(d_ff, d_model, world_size, rank)
        self.gelu = nn.GELU()

    def forward(self, x):
        # x: (batch, seq, d_model) - 全GPUで同じ
        h = self.fc1(x)        # (batch, seq, d_ff // world_size)
        h = self.gelu(h)       # 各GPUで独立に適用
        out = self.fc2(h)      # All-Reduce で集約
        return out             # (batch, seq, d_model) - 全GPUで同じ

Pipeline並列の概念的実装

class PipelineStage(nn.Module):
    """パイプラインの1ステージ"""

    def __init__(self, layers):
        super().__init__()
        self.layers = nn.ModuleList(layers)

    def forward(self, x):
        for layer in self.layers:
            x = layer(x)
        return x


def pipeline_forward(stages, x, device_ids):
    """
    パイプライン実行(概念的な実装)

    Args:
        stages: 各ステージのモデル
        x: 入力テンソル
        device_ids: 各ステージのデバイスID
    """
    activations = [x]

    # Forward pass
    for i, (stage, device) in enumerate(zip(stages, device_ids)):
        x = x.to(device)
        x = stage(x)
        activations.append(x)

    return x, activations

実践的なツール

Megatron-LM

NVIDIAが開発したLLM学習フレームワーク。Tensor並列とPipeline並列をサポート。

DeepSpeed

Microsoftが開発した分散学習ライブラリ。ZeRO、Pipeline並列、混合精度学習をサポート。

FSDP(Fully Sharded Data Parallelism)

PyTorch標準のZeRO-3相当の実装。パラメータ、勾配、オプティマイザ状態をシャーディング。

from torch.distributed.fsdp import FullyShardedDataParallel as FSDP

model = FSDP(model)

まとめ

本記事では、大規模モデルのための並列化手法について解説しました。

  • データ並列: バッチを分割し、同じモデルを複数GPUで複製。モデルがGPUに収まることが前提
  • Tensor並列: モデルの各層を複数GPUに分割。頻繁な通信が発生するため、高速な相互接続が必要
  • Pipeline並列: モデルを層ごとに分割。通信は隣接GPU間のみだが、バブルが効率を低下させる
  • 3D並列: 3種類を組み合わせて大規模モデルを効率的に学習
  • ZeRO: オプティマイザ状態の冗長コピーを排除してメモリ効率を向上

大規模LLMの学習・推論には、これらの技術を適切に組み合わせることが不可欠です。ハードウェア構成(GPU数、相互接続速度)に応じて最適な並列化戦略を選択しましょう。

次のステップとして、以下の記事も参考にしてください。