「上司を説得して予算を確保し、ようやくGPUを追加したにもかかわらず、推論速度がほとんど変わらない。それどころか、逆に遅くなったように感じる」
LLM(大規模言語モデル)活用プロジェクトにおいて、このような状況に直面することは決して珍しくありません。多くのAIエンジニアが、単一GPUでのPoC(概念実証)からマルチGPU環境への移行時に、この「スケーリングの壁」に直面します。
PyTorchやTransformersといった高度なライブラリを利用することで、通常はハードウェアの物理的な制約を意識せずに開発を進めることが可能です。しかし、LLMのような巨大なデータを扱う場合、ライブラリによる抽象化の裏側で起きている物理的な挙動を無視することはできません。
「GPU使用率が低いまま張り付いている」「原因不明のメモリエラー(OOM)が頻発する」。これらの現象は、GPUの性能不足ではなく、データの通り道やPythonの制御ロジックにボトルネックがある証拠です。
この記事では、コードの書き方を変更する前に理解しておくべき「並列推論のメカニズム」と、ハードウェア投資を無駄にしないための5つの実践的な最適化戦略を解説します。闇雲なチューニングで時間を浪費する前に、まずは「なぜ遅いのか」という原理から要素ごとに分析していきましょう。
なぜGPUを増やしてもLLMは速くならないのか?
前提として、単純にGPUを2枚に増やしても、処理速度が比例して2倍になることはほとんどありません。特にPythonで素朴な実装を行った場合、通信オーバーヘッドによってかえって処理が遅延するケースも存在します。
「GPU使用率0%」の時間の正体
nvidia-smiコマンドで監視している際、GPU使用率が断続的に0%に落ちる現象が見られることがあります。これはGPUが処理を怠っているのではなく、CPUからの「指示待ち」や「データ待ち」が発生している状態を示しています。
LLMの推論処理は、GPU内部での行列演算(計算)の時間よりも、メモリから演算ユニットへデータを運ぶ時間(メモリアクセス)の方が支配的になりがちです。これを「メモリバウンド」な処理と呼びます。GPUを増やして計算能力(FLOPS)を上げても、データを運ぶ道路(帯域幅)が混雑していれば、速度は向上しません。
データ転送ボトルネックの基礎知識
マルチGPU環境では、GPU同士の通信(インターコネクト)が新たなボトルネックになります。ここで理解しておくべきなのが、一般消費者向けのPCやワークステーションと、データセンター向けサーバーとの決定的な「道路の太さ」の違いです。
一般的な環境では、PCIe(Peripheral Component Interconnect Express)バスを経由してデータをやり取りしますが、この帯域幅はサーバー向けのインターコネクト技術に比べて圧倒的に狭いのが現実です。
最新の技術動向を見ると、その差は広がる一方です。例えば、NVIDIAのVera Rubinアーキテクチャに採用されている最新世代のNVLink(NVLink 6など)では、GPU間の帯域幅が数TB/sクラス(従来の倍以上)に達し、ラックスケールでの通信最適化が進んでいます。これに対し、一般的なPCIe接続はその数十分の一程度の速度に留まります。
モデルを複数のGPUに分割して載せた場合、推論の各ステップでGPU間のデータ同期が必要になります。サーバークラスの専用バスを持たない環境では、この同期待ち時間が計算時間の短縮分を相殺してしまい、これが「GPUを増やしても速くならない」主要因の一つとなっています。
PythonのGILと推論処理
さらに、Python環境における開発で課題となるのがGIL(Global Interpreter Lock)の存在です。Pythonは原則として一度に一つのスレッドしかバイトコードを実行できません。GPUへの命令発行(カーネルローンチ)自体は非同期で行えますが、その前後のデータ前処理やトークナイズ処理がCPU上でシーケンシャル(順次)に実行されるため、そこが律速となりGPUの待機時間を生むケースが多々あります。
Tips 1: モデルロード戦略を見直す(Accelerateの活用)
マルチGPU環境で最初に直面するのが、モデルをどうメモリに載せるかという問題です。「とりあえずロードしてから考える」というアプローチは、パラメータ数が数百億を超える巨大モデルでは通用しません。
「とりあえずロード」がメモリを食いつぶす
PyTorchの標準的なモデルロードでは、一度モデルの全パラメータをCPUメモリ(RAM)に展開してから、.to('cuda')でGPUへ転送しようとします。しかし、巨大モデルの場合、この時点でCPUメモリが溢れてプロセスが強制終了(OOM Kill)されてしまいます。
device_map='auto' の落とし穴と正しい挙動
Hugging FaceのTransformersライブラリ(Accelerateバックエンド)には、この問題を解決するdevice_map='auto'という便利なオプションがあります。これは利用可能なGPUとCPUメモリを計算して、モデルを自動的に分割配置してくれる機能です。
しかし、ここには注意すべき点があります。auto設定は「エラーを出さずに実行できること」を最優先するため、GPUメモリに入りきらないレイヤーをCPUやディスク(HDD/SSD)にオフロード(退避)する挙動をとります。推論中にGPUとCPU間で頻繁なデータ交換が発生すると、PCIeバスの帯域がボトルネックとなり、パフォーマンスは著しく低下します。「動作するが遅い」という事象の多くはこれが原因です。
空きメモリを計算して配置する仕組み
意図した通りのパフォーマンスを出すには、max_memory引数を使って、各GPUで使用するメモリ量の上限を明示的に指定することが重要です。
特に最新のGPU環境(RTX 50シリーズなど)ではVRAM容量が増加傾向にあり、NVFP4などの新しい量子化技術と組み合わせることで、以前より多くのレイヤーをGPUに収められるようになっています。しかし、OSやディスプレイ表示用のVRAMを考慮せずにギリギリまで割り当てると、予期せぬOOMが発生します。
以下のように、「GPU0にはシステム用のマージンを残し、GPU1以降はフルに割り当てる」といった制御が有効です。
from transformers import AutoModelForCausalLM
import torch
# 各デバイスへの割り当てを手動で制御
# 例: GPU0は表示用などに使用されるため少し余裕を持たせ、GPU1はフル活用
max_memory_mapping = {0: "20GB", 1: "24GB"}
model = AutoModelForCausalLM.from_pretrained(
"model_name",
device_map="auto",
max_memory=max_memory_mapping,
# 必要に応じてデータ型を指定
torch_dtype=torch.float16
)
このように、ライブラリの自動機能に完全に依存するのではなく、ハードウェアの特性(VRAM容量やシステム使用分)を理解した上で、意図的にメモリリソースを制御することが、高速な並列推論を実現するための第一歩となります。
Tips 2: 推論エンジンの選択を変える(vLLMの導入)
標準のTransformersパイプライン(pipeline("text-generation"))を用いてプロダクションレベルの推論サーバーを構築しようとしている場合は、アプローチの再考を推奨します。Pythonの標準実装は研究や実験には適していますが、高負荷な推論処理には最適化されていません。
標準のTransformersパイプラインの限界
標準の実装では、リクエストごとにメモリを確保・解放したり、バッチ処理におけるパディング(長さ合わせ)の無駄が発生したりします。これらは積み重なると大きなロスになります。
PagedAttention技術とは何か?
ここで導入を検討すべきなのが、「vLLM」などの推論専用ライブラリです。vLLMの革新性は、OSのメモリ管理手法であるページング方式を応用した「PagedAttention」にあります。
LLMの推論では、KVキャッシュ(過去の計算結果)というデータがメモリを大量に消費します。従来はこれに連続したメモリ領域を割り当てていましたが、PagedAttentionではメモリを細切れのブロックとして管理し、必要に応じて動的に割り当てます。これにより、メモリの断片化(フラグメンテーション)を防ぎ、同じVRAM容量でもより多くのリクエストを同時に処理できるようになります。
数行の変更でスループットを倍増させる方法
vLLMへの移行は比較的容易です。多くのモデルにおいて、既存のコードを数行書き換えるだけで対応可能です。
from vllm import LLM, SamplingParams
# vLLMを用いた初期化(内部で高度なメモリ管理が行われる)
llm = LLM(model="model_name", tensor_parallel_size=2)
output = llm.generate("Hello, world!", SamplingParams(temperature=0.7))
このように、Pythonコード側でのチューニングではなく、アーキテクチャレベルで最適化されたエンジンを採用することが、最もコスト対効果の高い高速化手段となります。
Tips 3: 量子化(Quantization)でメモリ帯域を節約する
「精度を維持するためにFP16(16ビット浮動小数点)で稼働させたい」という要件が、結果として速度低下の主要な原因となっているケースがあります。
FP16からINT8/4へ:精度と速度のトレードオフ
前述の通り、LLM推論は「メモリバウンド」な処理です。つまり、計算速度そのものよりも「データを転送する帯域」がボトルネックとなります。
モデルを4ビット量子化(INT4)すれば、データのサイズは1/4になります。これは単純にVRAMの使用量が減るだけでなく、メモリから演算ユニットへ転送するデータ量が1/4になることを意味します。結果として、理論上は4倍近い速度でデータを供給できるようになり、推論速度が向上します。
bitsandbytesライブラリの実践的活用
Pythonではbitsandbytesライブラリを使うことで、ロード時にオンザフライで量子化を行えます。
from transformers import BitsAndBytesConfig
import torch
quantization_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16
)
最近の研究では、大規模なモデル(70B以上など)であれば、4ビット量子化しても実用上の精度劣化は軽微であることが分かっています。特にRAG(検索拡張生成)などのタスクでは、モデルの微細な表現力よりも、コンテキストを多く詰め込めるメリットの方が大きい場合が多いのです。
「読み込み速度」が推論速度を決める理由
高速道路に例えるなら、FP16は大型トラック、INT4は軽自動車です。道路の幅(帯域幅)が決まっているなら、小さい車の方が一度にたくさん通過できます。GPUの計算コアを待たせないためには、データを小さくして次々と送り込む戦略が有効なのです。
Tips 4: テンソル並列とパイプライン並列の使い分け
マルチGPU環境を構築する際、「Tensor Parallelism (TP)」と「Pipeline Parallelism (PP)」の違いを明確に理解し、適切に使い分けることが重要です。この選択を誤ると、期待するパフォーマンス向上は得られません。
モデルを「縦」に切るか「横」に切るか
- Pipeline Parallelism (PP): モデルをレイヤー方向(縦)に分割します。GPU0が前半の層、GPU1が後半の層を担当します。バケツリレー方式になるため、GPU1はGPU0が終わるまで待機する必要があり、単一リクエストのレイテンシ(応答速度)は改善しません。
- Tensor Parallelism (TP): 各レイヤーの行列演算自体を分割(横)します。一つの巨大な行列計算を複数のGPUで手分けして行います。計算能力を合算できるため、単一リクエストの推論速度そのものが向上します。
推論時におけるレイテンシ重視のTP推奨理由
リアルタイムな対話応答が求められるチャットボットなどの用途では、Tensor Parallelism (TP) が圧倒的に有利です。ユーザーは「最初のトークンが表示されるまでの時間(TTFT)」を重視するからです。
一方、バッチ処理で大量のドキュメントを解析するような「スループット重視」の用途であれば、Pipeline Parallelismも選択肢に入ります。
通信オーバーヘッドを最小化する構成
ただし、TPはGPU間で頻繁な通信(All-Reduce操作)が発生します。ここで決定的な差を生むのが、GPU間のインターコネクト技術です。
第1章で解説した通り、最新のNVLinkやラックスケールでのスイッチング技術を活用できる最先端の環境であれば、大規模なMoE(Mixture of Experts)モデルであっても通信ボトルネックを極小化し、TPの恩恵を最大限に引き出すことが可能です。
しかし、こうした高速なNVLinkがないPCIe接続のみの環境では、状況が異なります。PCIeの帯域幅はNVLinkに比べて桁違いに狭いため、TPを行うと通信待ちが計算時間を上回る可能性があります。
- 2枚構成: PCIeでも比較的TPの効果が出やすいです。
- 4枚〜8枚構成: NVLink BridgeやNVSwitchがない場合、通信オーバーヘッドが激増します。
特にMoEモデルのような複雑な通信パターンを持つLLMを扱う際は、単にGPUを増やすだけでなく、サーバーの物理配線(トポロジー)やインターコネクトの帯域幅を考慮した並列化戦略が必要です。
Tips 5: バッチ処理と非同期リクエストの実装
最後は、アプリケーション層、すなわちPythonコードの実装による最適化です。高性能な推論エンジンを採用しても、リクエストを一つずつ順次処理していては、ハードウェアのポテンシャルを十分に引き出せません。
1件ずつ処理することの非効率性
GPUは「大量の単純計算を同時にこなす」のが得意なハードウェアです。1つの質問に対して推論を行っている間、GPUのメモリ帯域や計算コアにはまだ余裕があることが多いのです。
動的バッチングの仕組み
サーバーとして運用する場合、複数のユーザーからのリクエストを少しだけ待機させ、まとめて一つのバッチ(束)としてGPUに送る「動的バッチング(Dynamic Batching)」が有効です。vLLMなどの推論サーバーはこれを自動で行ってくれますが、クライアントサイド(Pythonスクリプト)でも非同期処理を意識する必要があります。
Pythonのasyncioを活用したリクエスト管理
asyncioを使って、GPUが推論している間に、次のリクエストの前処理(DB検索やプロンプト構築)をCPUで行うように設計しましょう。
import asyncio
async def process_request(query, llm_engine):
# I/Oバウンドな処理(DB検索など)は非同期で待機
context = await search_database(query)
# 推論リクエストを投げる
response = await llm_engine.generate(context)
return response
# 複数のリクエストを並行してスケジュール
async def main():
queries = ["Q1...", "Q2...", "Q3..."]
# 実際にはここでllm_engineの初期化などが必要
tasks = [process_request(q, llm_engine) for q in queries]
results = await asyncio.gather(*tasks)
このように、GPUの空き時間を極限まで減らす設計こそが、システム全体のスループットを最大化します。
まとめ:まずは計測とボトルネック特定から
ここまで、マルチGPU環境での推論を高速化するための5つの実践的な戦略を解説してきました。しかし、これらを無計画に適用する前に、推奨されるステップがあります。それは「計測」です。
推論環境の健全性チェックリスト
最後に、環境のボトルネックを特定するためのチェックリストを提示します。
- ボトルネックはどこか?
nvtopやnvidia-smiでGPU使用率とメモリ使用量を確認しましたか? - ロード戦略は適切か?
device_mapの挙動を理解し、不要なオフロードが発生していませんか? - 推論エンジンは最適か? 素のTransformersではなく、vLLMなどの専用エンジンを検討しましたか?
- データサイズは適正か? 精度要件に対して過剰なビット数(FP16/FP32)を使っていませんか?
- 並列化戦略は合っているか? 用途に合わせてTensor Parallelismを選択していますか?
次に学ぶべき最適化技術
LLMの最適化技術は日進月歩です。今回紹介した内容に加え、投機的サンプリング(Speculative Decoding)や、より高度な量子化手法(AWQ, GPTQ)など、学ぶべきトピックは尽きません。
コメント