+ 作る
図を読み込んでいます…
1 / 16
GPUとLLM:グラフィックボードはなぜAIを動かせるのか / 全5章

1枚に載らないモデルを動かす:量子化と並列化

発行
かえで
版
第2版
改訂
分量
16コマ・4,587字

70Bのモデルは16ビットで約140GB。GPU 1枚(例:80GB)に載らないモデルを、量子化(x ≈ s × q、ブロックごとのスケール)と、テンソル並列・パイプライン並列・データ並列でどう動かすのかを、図が動く形で一段ずつたどります。『GPUとLLM』第4章。

1枚に載らないモデルを動かす

第3章では、文章を1トークンずつ作る速さが、重みをメモリから読む速さでほぼ決まることを見ました。この章ではその手前の問題、つまり重みがそもそもGPUのメモリに入らないときにどうするかを考えます。図のように、700億パラメータ(70B)のモデルを16ビットで持つと約140GB。青い箱で描いたGPU 1枚のメモリ(例として80GB)から、紫のバーが大きくはみ出します。

打つ手は大きく2つです。1つは、重み1つあたりのビット数を減らして全体を小さくする量子化。もう1つは、何枚ものGPUに分けて持つ並列化です。まず、メモリに何がどれだけ載るのかを足し算するところから始めます。

  • 『GPUとLLM』 第4章
  • 1枚に載らないモデルを動かす
  • 量子化と並列化:重みを小さくし、何枚ものGPUに分けて持つ
  • GPU 1枚のメモリ 80GB
  • 70Bモデルの重み(16ビット)140GB
  • はみ出す
  • ① 小さくする:量子化 重み1つのビット数を減らす
  • ② 分けて持つ:並列化 何枚ものGPUで1つのモデルを動かす

容量の足し算

GPUのメモリに置くものは重みだけではありません。第2章で見たとおり、重みの容量は「パラメータ数 × 1個あたりのバイト数」で、70BモデルをBF16(1個16ビット=2バイト)で持つと 700億 × 2 = 140GB です。図の上の16個のマスが、重み1個ぶんのビットです。

これに、過去のトークンのK・Vを覚えておくKVキャッシュと、計算の途中で一時的に置く値(活性化など)が加わります。積み上げバーの紫が重み、橙がKVキャッシュ、緑が途中の値です。KVキャッシュは、たとえば1トークン約0.33MBのモデルで3.2万トークンぶん持つと約10GB。合計は約155GBで、青い箱を約75GBはみ出します。

  • 容量の足し算:重み + KVキャッシュ + 途中の値
  • 重みの容量 = パラメータ数 × 1個のバイト数 = 700億 × 2バイト = 140GB
  • 1個 = 16ビット(BF16)= 2バイト
  • GPU 1枚のメモリ 80GB(例:H100)
  • 重み 140GB(16ビット)
  • はみ出し 約75GB
  • 重み 140GB
  • KVキャッシュ 約10GB
  • 計算途中の値 約5GB
  • KVキャッシュは「会話の長さ × 同時に扱う数」で増える(例:0.33MB/トークン × 3.2万トークン ≈ 10GB)
  • 合計 約155GB > 80GB → 1枚には載らない

量子化:重み1つを8ビットで

量子化は、重み1個を表すビット数を減らすことです。16ビットを、8ビットの FP8(小さな浮動小数点数)や INT8(整数)にすると1個1バイトになり、重み全体は 700億 × 1 = 70GB と半分になります。図の上のマスが16個から8個に減り、紫のバーも半分の長さに縮みました。

それでも、橙と緑を足した合計は約85GBで、箱の右端から約5GBはみ出しています。重みだけを見て「80GBに入る」と考えると、KVキャッシュや途中の値の分を忘れてしまいます。容量は、いつも足し算で考えるのが大事です。

  • 量子化:重み1つを8ビットで表す
  • 重みの容量 = 700億 × 1バイト = 70GB(半分に)
  • 1個 = 8ビット(FP8・INT8)= 1バイト
  • GPU 1枚のメモリ 80GB(例:H100)
  • 重み 70GB(8ビット)
  • まだ 約5GB はみ出す
  • 重み 70GB
  • KVキャッシュ 約10GB
  • 計算途中の値 約5GB
  • FP8 は8ビットの小さな浮動小数点数、INT8 は8ビットの整数。どちらも1個1バイト
  • 合計 約85GB > 80GB → 重みは入るが、まだ少しはみ出す

4ビットなら1枚に収まる

さらに1個を4ビット(INT4 や FP4)まで減らすと、重みは 700億 × 0.5 = 35GB、もとの4分の1です。紫のバーがさらに縮み、合計約50GBで青い箱の中に収まりました。赤かったはみ出しの線は、緑の「空き約30GB」に変わっています。この空きは、より長い会話や、同時にたくさんのリクエストを扱うためのKVキャッシュに回せます。

ただし、4ビットで表せる値は16通りしかありません。ビットを減らすほど元の値とのずれ(丸め誤差)が大きくなり、答えの質が落ちることがあります。どうやって16通りに押し込むのかは、速さへの効果を見たあとで確かめます。

  • 4ビットなら1枚に収まる
  • 重みの容量 = 700億 × 0.5バイト = 35GB(4分の1に)
  • 1個 = 4ビット(INT4・FP4)= 0.5バイト
  • GPU 1枚のメモリ 80GB(例:H100)
  • 重み 35GB
  • 空き 約30GB
  • 重み 35GB
  • KVキャッシュ 約10GB
  • 計算途中の値 約5GB
  • 表せる値が16通りしかないので、元の値とのずれ(丸め誤差)は大きくなる
  • 合計 約50GB < 80GB → 1枚に収まり、余りをKVキャッシュに回せる

量子化は速さにも効く

第3章で見たように、1トークンずつ文章を作る decode はメモリ律速です。1トークンを作るたびに重みをほぼ全部メモリから読むので、速さの上限はおおよそ「メモリ帯域 ÷ 重みのバイト数」で決まります。

帯域 3.35TB/s(H100 の公表値)で計算すると、16ビットの140GBなら1トークンぶん読むのに約42ms、上限は毎秒約24トークンです。8ビットで読む量が半分になると上限は約2倍の約48トークン、4ビットなら約96トークン。図の3本のバーの長さが、そのまま1トークンぶんの読み込み時間です。実際には4ビットの値を計算用の形に戻す手間などがあるので、これは「これより速くはならない」という上限です。

  • 量子化は速さにも効く
  • 速さの上限(トークン/秒)≈ メモリ帯域 ÷ 1トークンで読む重みのバイト数(第3章)
  • 帯域 3.35TB/s(H100の公表値)のGPUで、70Bモデルの重みを1トークンごとに全部読むとすると
  • バーの長さ = 読むバイト数 = 1トークンぶんの読み込み時間
  • 16ビット
  • 140GB
  • 約42ms → 上限 毎秒約24トークン
  • 8ビット
  • 70GB
  • 約21ms → 上限 毎秒約48トークン
  • 4ビット
  • 35GB
  • 約10ms → 上限 毎秒約96トークン
  • 読むバイト数が半分になれば、速さの上限は約2倍になる
  • ※ 140GBは実際には1枚に載らないので仮の計算。実際の速さは、4ビットを計算用の形に戻す手間などがあり、 この上限より遅くなる

量子化の中身:x ≈ s × q

では、16ビットの小数をどうやって4ビットに押し込むのでしょうか。基本の形は x ≈ s × q です。x がもとの重み、q が4ビットで表せる小さな整数(−8〜7 の16段階)、s はスケールと呼ぶ倍率で、いくつかの重みが1つの s を共有します。

s は、グループの中でいちばん大きな値が目盛りの端(7)に来るように決めます。図の8個の重みでは絶対値の最大が 0.42 なので、s = 0.42 ÷ 7 = 0.06。下の数直線の目盛りは0.06おきに並び、−8〜7 の16本が「4ビットで表せる値」のすべてです。上に並んだ紫の点が、もとの重みです。

  • 量子化の中身:x ≈ s × q
  • x(もとの重み)≈ s(スケール)× q(小さな整数)
  • q:4ビットなら −8〜7 の16段階
  • ① s = max|x| ÷ 7 = 0.42 ÷ 7 = 0.06
  • s:いくつかの重みで1つを共有する倍率
  • ② q = round(x ÷ s)(いちばん近い整数)
  • ③ 計算に使うときは s × q に戻す
  • ● もとの重み x(8個の例)
  • 0.42
  • 4ビットの目盛り q
  • −8
  • −7
  • −6
  • −5
  • −4
  • −3
  • −2
  • −1
  • 0
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • いちばん大きい 0.42 が、目盛りの端(q=7)に来るように s を決める
  • −8〜7 の16本の目盛りが、4ビットで表せる値のすべて(目盛りの間隔 = s)

いちばん近い目盛りにスナップする

各重みは、x ÷ s を四捨五入した整数 q に置き換えます。図では、上にあった紫の点が下の数直線に落ち、いちばん近い目盛りにぴたりと収まりました。上に残った白抜きの丸が、もとの値の場所です。

たとえば 0.10 は 0.10 ÷ 0.06 ≈ 1.67 なので q = 2。戻すと 2 × 0.06 = 0.12 で、0.02 だけずれます。これが丸め誤差です。目盛りの間隔が s なので、誤差はいつも s の半分(ここでは0.03)以内に収まります。保存するのは8個の q(各4ビット)と、s が1つだけです。

  • 丸め誤差:いちばん近い目盛りにスナップする
  • x(もとの重み)≈ s(スケール)× q(小さな整数)
  • q:4ビットなら −8〜7 の16段階
  • ① s = max|x| ÷ 7 = 0.42 ÷ 7 = 0.06
  • s:いくつかの重みで1つを共有する倍率
  • ② q = round(x ÷ s)(いちばん近い整数)
  • ③ 計算に使うときは s × q に戻す
  • ○ もとの値 x(白抜き)
  • 0.42
  • ● 4ビットにした値 s × q
  • −8
  • −7
  • −6
  • −5
  • −4
  • −3
  • −2
  • −1
  • 0
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 例:0.10 ÷ 0.06 ≈ 1.67 → q = 2 → 戻すと 2 × 0.06 = 0.12(誤差 0.02)
  • 誤差はいつも s の半分(0.03)以内。保存するのは 4ビットの q が8個と、s が1個だけ

外れ値1つで、目盛りが粗くなる

困るのは、飛び抜けて大きな値(外れ値)が混じっているときです。8個のうち1個が 2.1 だったとすると、s = 2.1 ÷ 7 = 0.30。目盛り1つぶんが表す幅が5倍に広がり、上の白抜きの丸は真ん中にぎゅっと寄りました。

その結果、残りの7個は −1・0・1 の3つの目盛りに押し込まれ、0.04 も 0.10 も −0.10 も同じ 0 になってしまいます。目盛りの上に縦に積み重なった点が、区別がつかなくなった値です。LLM の重みや計算途中の値には、こうした外れ値がときどき現れることが知られています。

  • 外れ値が1つあると、目盛りが粗くなる
  • x(もとの重み)≈ s(スケール)× q(小さな整数)
  • q:4ビットなら −8〜7 の16段階
  • ① s = 2.1 ÷ 7 = 0.30(外れ値に引っ張られる)
  • s:いくつかの重みで1つを共有する倍率
  • ② q = round(x ÷ s)(いちばん近い整数)
  • ③ 計算に使うときは s × q に戻す
  • ○ もとの値 x(白抜き)
  • 2.1(外れ値)
  • ● 4ビットにした値 s × q
  • −8
  • −7
  • −6
  • −5
  • −4
  • −3
  • −2
  • −1
  • 0
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 残りの7個が、−1・0・1 の3つの目盛りに押し込まれる
  • 0.04 も 0.10 も −0.10 も、同じ 0 になって区別がつかない(積み重なった点)

ブロックごとにスケールを持つ

そこで、重みを小さなブロックに区切り、ブロックごとに別々の s を持たせます。図では4個ずつ2つのブロックに分けました。外れ値のないブロック1は s = 0.06 の細かい目盛りを使えて、粗くなるのは外れ値のいるブロック2だけです。実際には32個や128個ごとに区切ることが多く、外れ値の影響をそのブロックの中に閉じ込められます。

代わりに、s を保存する分だけ少し大きくなります。32個ごとに16ビットの s を1つ持つなら、1個あたり 16 ÷ 32 = 0.5ビット増えて実質4.5ビット。70Bなら 35GB が約39GB になる程度で、守れる精度に比べれば小さな代償です。

  • ブロックごとにスケールを持つ
  • x(もとの重み)≈ s(スケール)× q(小さな整数)
  • q:4ビットなら −8〜7 の16段階
  • ① s = そのブロックの max|x| ÷ 7(ブロックごとに別)
  • s:いくつかの重みで1つを共有する倍率
  • ② q = round(x ÷ s)(いちばん近い整数)
  • ③ 計算に使うときは s × q に戻す
  • ブロック1:s = 0.42 ÷ 7 = 0.06
  • ブロック2:s = 2.1 ÷ 7 = 0.30
  • 2.1
  • −8
  • 0
  • 7
  • −8
  • 0
  • 7
  • 粗くなるのは外れ値のいるブロックだけ(図は4個ずつ。実際は32個や128個ごと)
  • s の分だけ少し増える:16ビット ÷ 32個 = 1個あたり0.5ビット → 実質4.5ビット

それでも載らないなら、分けて持つ

量子化しても入らない大きなモデルや、品質のために16ビットのまま動かしたいモデルは、何枚ものGPUに分けて持ちます。分け方にはいくつか種類があり、最初に見るのは1つの行列を切り分けるテンソル並列です。

LLMの計算のほとんどは、入力 x に重み行列 W を掛ける y = x × W でした(第2章)。図のように W を横向きの帯4本(行のまとまり)に分け、x も同じように4つに分けると、W1 は x1 とだけ、W2 は x2 とだけ掛け合わされます。つまり y は、4つの小さな掛け算 x1×W1 〜 x4×W4 を足したものです。

  • それでも載らないとき:テンソル並列
  • y = x × W = x1×W1 + x2×W2 + x3×W3 + x4×W4
  • x
  • W(1つの層の重み行列)
  • x1
  • W1
  • W の大きさの例:8,192 × 8,192 16ビットなら 8,192 × 8,192 × 2バイト ≈ 134MB
  • x2
  • W2
  • 横の帯(行のまとまり)4本に分けると、 W1 は x1 とだけ、W2 は x2 とだけ 掛け合わされる
  • x3
  • W3
  • → 帯ごとに、別々のGPUで  計算できる
  • x4
  • W4
  • y(出力)

GPUに配り、all-reduce で足し合わせる

4本の帯がそれぞれ別のGPUへ移りました。各GPUは自分の担当分(GPU1なら x1 × W1)だけを計算すればよく、重みのメモリも計算も4分の1になります。ただし各GPUが作るのは、y と同じ長さの部分和 y1〜y4 です。本当の y を得るには、4つを足し合わせなければなりません。

全員の部分和を足して、その結果を全員に配る通信を all-reduce と呼びます。次の層もまた4枚で分けて計算するので、y は全員が持っている必要があるのです。なお W を縦(列)で分けると、各GPUは y の一部分を作るのでつなげるだけで済みます。実際の Transformer では両方を組み合わせて、通信の回数を減らしています。

  • 4枚のGPUに配り、all-reduce で足し合わせる
  • y = x × W = x1×W1 + x2×W2 + x3×W3 + x4×W4
  • GPU1
  • GPU2
  • GPU3
  • GPU4
  • x1
  • W1
  • x2
  • W2
  • x3
  • W3
  • x4
  • W4
  • 部分和 y1
  • 部分和 y2
  • 部分和 y3
  • 部分和 y4
  • y = y1 + y2 + y3 + y4
  • all-reduce:全員の部分和を足し合わせ、その合計を全員が受け取る
  • 重みも計算も1枚あたり4分の1に。代わりに、層ごとにGPUどうしの通信が必要になる

通信のコスト:線の太さが効く

all-reduce を、部分和をリング状に回して行うと、各GPUが送る量は約 2 × (p−1)/p × データ量 になります(p はGPUの数)。GPUを増やしても、データ量の2倍を超えません。たとえば4,096トークンぶんの y(約64MB)なら、4枚で1枚あたり96MBです。

問題は、それを運ぶ線の太さです。H100 どうしをつなぐ NVLink は片方向450GB/s(公表値は双方向で900GB/s)、一般的な PCIe Gen5 x16 は片方向約64GB/s。右の2本の線の太さの差が、その違いです。96MBなら約0.2msと約1.5msで、1層に2回、80層ぶん繰り返すと差は大きく開きます。だからテンソル並列は、太い線でつながった1台のサーバー(ノード)の中で使うのが普通です。

  • 通信のコスト:GPUをつなぐ線の太さ
  • 各GPUが送る量 ≈ 2 × (p−1)/p × データ量(p = GPUの数)
  • GPU1
  • GPU2
  • 例:y ≈ 64MB(4,096トークン × 8,192 × 2バイト)
  • p=4:1枚が送る量 = 2 × 3/4 × 64MB = 96MB
  • 部分和 y1
  • 部分和 y2
  • NVLink(H100の例):片方向 450GB/s
  • 96MB ÷ 450GB/s ≈ 0.2ms
  • GPU3
  • GPU4
  • PCIe Gen5 x16:片方向 約64GB/s
  • 部分和 y3
  • 部分和 y4
  • 96MB ÷ 64GB/s ≈ 1.5ms
  • 部分和をリング状に回しながら足し合わせる(太い線=NVLink)
  • 1層に2回 × 80層 = 160回 → NVLink なら約34ms、PCIe なら約240ms だからテンソル並列は、太い線でつながった1台(ノード)の中で使う
  • 帯域は代表例(NVIDIA H100 SXM の公表値:NVLink 双方向900GB/s、PCIe Gen5 双方向128GB/s)。実際の通信は理想より遅い

パイプライン並列:層で分ける

もう1つの分け方は、モデルを層の順に区切るパイプライン並列です。80層のモデルなら、GPU1が層1〜20、GPU2が層21〜40…と受け持ち、前のGPUの出力を次のGPUに渡します。渡すのは層と層のあいだの値だけなので通信は少なく、ノードをまたいで分けるのにも向いています。

ただし、そのままでは順番待ちが起きます。下の図は横が時間で、GPU2 は GPU1 が終わるまで何もできません。灰色の部分が待ち時間(バブル)で、4枚なら全体の4分の3がバブル。4枚あっても、同時に働いているのはいつも1枚だけです。

  • パイプライン並列:層で分ける
  • GPUどうしで渡すのは、層と層のあいだの値だけ(通信は少ない)
  • GPU1:層1〜20
  • GPU2:層21〜40
  • GPU3:層41〜60
  • GPU4:層61〜80
  • 全体:4T(T = 1枚が1バッチを処理する時間)
  • GPU1
  • バッチ
  • GPU2
  • バッチ
  • GPU3
  • バッチ
  • GPU4
  • バッチ
  • 時間
  • 灰色=前のGPUを待っている時間(バブル)。4枚なら全体の3/4が待ち

マイクロバッチでバブルを埋める

そこで、バッチを小さなマイクロバッチに分けて次々に流します。GPU1 は1つ目を渡したらすぐ2つ目に取りかかれるので、図のように色のマスが斜めにずれて重なり、灰色が減りました。全体の時間も、理想的には 4T から 1.75T に縮みます。

GPUの数を p、マイクロバッチの数を m とすると、バブルの割合は (p−1) ÷ (m+p−1) です。p=4 なら、m=1 で 3/4、m=4 で 3/7(約43%)、m=16 で 3/19(約16%)。m を増やすほどバブルは小さくなります。推論では、別々のリクエストを次々に流して埋めることもできます。

  • マイクロバッチで、待ち時間を埋める
  • バブルの割合 = (p−1) ÷ (m+p−1) (p = GPUの数、m = マイクロバッチの数)
  • GPU1:層1〜20
  • GPU2:層21〜40
  • GPU3:層41〜60
  • GPU4:層61〜80
  • 全体:1.75T(4T から短縮)。1マス = マイクロバッチ1つ
  • GPU1
  • p=4, m=1 :3 ÷ 4 = 75% が待ち
  • GPU2
  • p=4, m=4 :3 ÷ 7 ≈ 43%
  • GPU3
  • p=4, m=16:3 ÷ 19 ≈ 16%
  • GPU4
  • 推論では、別々のリクエストを 次々に流して埋めることもできる
  • 時間
  • マイクロバッチを増やすほど、灰色(バブル)の割合が減る

データ並列と、学習のメモリ

データ並列は、同じモデルのコピーを何組も並べ、それぞれに別のデータを処理させる方法です。モデルが1枚(またはテンソル並列などで組んだ1組)に入るなら、組の数だけ多くのデータをさばけます。学習では、各組が計算した勾配を all-reduce で平均してから重みを更新します。MoE の専門家をGPUに振り分けるエキスパート並列もあり、第5章でも触れます。

学習はメモリの面でもずっと重くなります。Adam を混合精度で使うと、重みと勾配に加えて、FP32 の重みのコピーと2つの移動平均(m と v)を持つので、1パラメータ約16バイトが目安です。70Bなら約1.1TBで、16ビットの推論の8倍になります。

  • データ並列:コピーを並べる。学習はさらに重い
  • データA
  • データB
  • データC
  • データD
  • GPU1
  • GPU2
  • GPU3
  • GPU4
  • モデル全体のコピー
  • モデル全体のコピー
  • モデル全体のコピー
  • モデル全体のコピー
  • 学習では、各GPUの勾配を all-reduce で平均し、全員が同じ重みに更新する
  • 1パラメータあたりのメモリ(Adam・混合精度の目安)
  • 推論(16ビット)
  • 重み 2
  • = 2バイト
  • 学習
  • 重み 2
  • 勾配 2
  • Adamの状態 12(FP32の重み・m・v が各4)
  • 合計16バイト × 700億 ≈ 1.1TB(推論の8倍)→ 80GBのGPUなら最低14枚ぶん
  • そこで学習では、勾配や状態もGPUに分けて持つ工夫(ZeRO・FSDP)と組み合わせる

まとめ

1枚のGPUに載らないモデルを動かすには、まず容量を足し算で見積もります。重みはパラメータ数 × バイト数で、そこにKVキャッシュと計算途中の値が加わります。

1つ目の手は量子化です。x ≈ s × q の形で重みを小さな整数に置き換え、ブロックごとのスケールで外れ値の影響を閉じ込めます。読むバイト数が減るので、メモリ律速の decode も速くなります。2つ目の手は並列化です。テンソル並列は行列を分けて all-reduce で足し合わせるので太い線のノード内で使い、パイプライン並列は層で分けてマイクロバッチでバブルを埋め、データ並列はコピーを並べて量をさばきます。

次の第5章では、最先端のモデルがメモリと待ち時間をさらに削るために使っている工夫を見ていきます。

  • まとめ:1枚に載らないモデルを動かす
  • ① 容量は足し算で考える
  • ② 量子化:x ≈ s × q
  • 重み(パラメータ数 × バイト数)に、 KVキャッシュと計算途中の値を足す。 70Bを16ビットで持つと重みだけで140GB
  • 重みを小さな整数 q と倍率 s で表す。 s をブロックごとに持てば、 外れ値の影響をそのブロックに閉じ込める。 読むバイト数が減り、decode も速くなる
  • ③ テンソル並列とパイプライン並列
  • ④ データ並列と学習のメモリ
  • テンソル並列:行列を分けて all-reduce。 通信が多いので太い線(NVLink)の中で パイプライン並列:層で分けて渡す。 マイクロバッチでバブルを埋める
  • データ並列:コピーを並べて量をさばく。 学習は1パラメータ約16バイトが目安で、 推論よりずっと多くのメモリが要る
  • 次章:フロンティアモデルは、メモリと待ち時間をどう削っているのか
  • 『GPUとLLM』 第4章 おわり

0 件のコメント

読み込み中…

権利侵害・不適切な内容の連絡: [email protected]