FastAPI vLLM Performance Optimization GPU Utilization System Architecture

Part 1:翻訳推論サーバーのスケールを阻むボトルネック

Ashar Mirza - VoicePing 3 分で読めます
Part 1:翻訳推論サーバーのスケールを阻むボトルネック

FastAPI + マルチプロセッシング構成における、GPU効率化を妨げるアーキテクチャ上のボトルネックの特定と分析

課題

私たちはFastAPIとvLLMを使用して翻訳マイクロサービスを運用しています。高負荷時にサーバーのレイテンシが増大しましたが、GPU使用率の指標からは説明がつきませんでした。

GPU使用率は不安定なパターンを示していました。93%まで急上昇した後、0%に低下し、再び急上昇するという繰り返しです。期待していた安定した高使用率とは程遠い状態でした。

疑問:GPUにアイドル期間があるなら、ボトルネックはどこにあるのか?

本記事では、FastAPI + マルチプロセッシング構成において、GPU効率化を妨げていたアーキテクチャ上の問題をどのように特定したかを解説します。


システム構成

翻訳サービスは、ロードバランサーの背後で複数のAPIサーバーとして稼働しています。

図1:クライアントアプリケーション、プロキシ/ロードバランサー、複数APIサーバーを含む全体システムアーキテクチャ

  • クライアント: Web、モバイル、バックエンドサービス
  • プロキシ: 言語ペアとサーバーの稼働状況に基づいてリクエストを振り分け
  • APIサーバー: 複数のFastAPIインスタンス、各々がvLLMを実行

本記事では、単一のAPIサーバーの内部アーキテクチャとボトルネックに焦点を当てます。

APIサーバーのアーキテクチャ

1台のAPIサーバーの内部構造を示します。

図2:FastAPI、マルチプロセッシングキュー、ワーカープロセス、vLLMインスタンスを含む単一APIサーバーアーキテクチャ

コンポーネント

1. FastAPIメインプロセス

1
2
3
4
5
# シングルスレッドの非同期イベントループ
@app.post("/translate")
async def translate_endpoint(request: TranslateRequest):
    result = await translation_service.translate(request)
    return result
  • async/awaitによるHTTPリクエスト処理
  • 単一Pythonプロセス、1つのイベントループ
  • ノンブロッキングI/Oによる並行リクエスト処理

2. TranslationService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class TranslationService:
    def __init__(self, worker: TranslationWorker):
        self.worker = worker

    async def translate(self, request: TranslateRequest) -> TranslateResponse:
        # 翻訳タスクの作成
        event_task = self.worker.add_translation_task(
            text=request.text,
            source_lang=request.source_lang,
            target_lang=request.target_lang,
            timeout=30
        )

        # 結果を非同期で待機
        await event_task.event.wait()
        return TranslateResponse(translation=event_task.result.translation)
  • 翻訳タスクの作成
  • asyncio.Eventを持つEventTaskオブジェクトの管理
  • async/awaitとマルチプロセッシングの橋渡し

3. TranslationWorker(メインプロセス)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class TranslationWorker:
    def __init__(self):
        self.ctx = multiprocessing.get_context("spawn")
        self.translation_queue = None  # run()内で作成
        self.event_queue = None
        self.event_tasks: Dict[str, EventTask] = {}

    def _initialize(self):
        # メインプロセスでキューを作成
        self.translation_queue = self.ctx.JoinableQueue(maxsize=300)
        manager = self.ctx.Manager()
        self.translation_tasks = manager.dict()  # 共有状態
        self.event_queue = self.ctx.Queue()

    def add_translation_task(...) -> EventTask:
        key = "t_" + generate_random_key(10)
        # 共有辞書に格納
        self.translation_tasks[key] = TranslationTask(...)

        # キュー経由でワーカーに送信
        self.translation_queue.put(key)  # シリアライゼーション

        # 非同期待機用イベントの作成
        event_task = EventTask(key)
        self.event_tasks[key] = event_task
        return event_task
  • メインプロセスでキューを作成(ワーカーと共有)
  • タスク配分用のJoinableQueue
  • 共有タスク状態のためのmanager().dict()
  • 結果返却用のEvent queue

4. ワーカープロセス

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def run(self):
    for worker_id in range(self.num_workers):
        worker = self.ctx.Process(
            target=self.process_queue,
            args=(worker_id, ready_event)
        )
        worker.start()

def process_queue(self, worker_id, ready_event):
    # 各ワーカーが独自のvLLMインスタンスをロード
    translation_processor = TranslationProcessor(
        worker_id=worker_id,
        model_key=self.model_key,
        gpu_memory_utilization=self.gpu_memory_per_worker
    )

    # 共有キューから処理
    while True:
        key = self.translation_queue.get()  # デシリアライゼーション
        task = self.translation_tasks[key]

        # vLLMで翻訳
        result = translation_processor.translate(
            task.text,
            task.source_lang,
            task.target_lang
        )

        # 結果を返送
        self.event_queue.put((key, EventType.completed, result))  # シリアライゼーション
  • 独立プロセスとしてスポーン(ctx.Process)
  • 各ワーカーが独自のvLLMモデルインスタンスをロード
  • 共有translation_queueからプル
  • 共有event_queue経由で結果を返却

5. EventTask(非同期同期機構)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class EventTask:
    def __init__(self, key: str):
        self.key = key
        self.event = asyncio.Event()  # 非同期同期
        self.event_type = EventType.waiting
        self.result = None

    def update(self, event_type, result):
        self.event_type = event_type
        self.result = result
        self.event.set()  # 待機中のコルーチンを起動
  • マルチプロセッシングとasync/awaitの橋渡し
  • 各リクエストにEventTaskを割り当て
  • await event.wait()でワーカーの完了までコルーチンをブロック

リクエストフロー

1件の翻訳リクエストの処理の流れを示します。

図3:シリアライゼーションポイントと非同期待機を示すステップバイステップのリクエストフロー

処理の流れ:

  1. クライアントがPOST /translate → FastAPIが非同期コルーチンを作成
  2. async translate() → TranslationServiceがリクエストを処理
  3. create_task() → IDを生成し、共有辞書にTranslationTaskを作成
  4. queue.put(key) → タスクキーをシリアライズしてワーカーに送信(IPCオーバーヘッド)
  5. ワーカー: vllm.translate() → ワーカーが翻訳を処理
  6. event_queue.put(result) → 結果をシリアライズして返送(IPCオーバーヘッド)
  7. event.set() → EventTaskを更新し、コルーチンを起動
  8. await event.wait()のブロック解除 → 結果を取得
  9. レスポンスを返却 → クライアントに送信

オーバーヘッドポイント:

  • ステップ4:シリアライゼーション(タスクキーのpickle)
  • ステップ6:シリアライゼーション(結果のpickle)
  • ステップ8:マルチプロセッシング結果の非同期待機
  • 全体にわたるIPCコーディネーション

ベースラインの性能

最適化前の状態:

図4:負荷下でのスループット低下とレスポンスタイム増加を示すベースライン性能

パターン:

  • レスポンスタイムが線形に増加(1.4秒 → 11.3秒)
  • 負荷下でスループットが低下(3.3 → 2.2 RPS)
  • 実際のvLLM翻訳時間:リクエストあたり300〜450ms

図5:最適化前(スパイク状)と最適化後(安定)のGPU使用率パターン

スパイク状パターン: GPUがビジーとアイドルを交互に繰り返す。これはGPUが処理を待っている状態であり、計算能力がボトルネックではないことを示しています。

試行1:複数ワーカー

最初の仮説:ワーカーを増やせば並列化が向上する。

ワーカー数を1から2に増やしました。

設定

1
2
num_workers = 2
gpu_memory_per_model = 0.3
  • ワーカー1:モデルA+B
  • ワーカー2:モデルC
  • 両方が同一GPUを共有

結果

図6:2つ目のワーカープロセス追加時の性能劣化

翻訳時間の中央値も悪化:452ms → 2,239ms

すべての負荷レベルで性能が低下しました。

複数ワーカーが失敗した理由

GPUの動作と私たちのアーキテクチャを理解すれば、この結果は納得できます。

図7:GPUの計算能力を奪い合う複数ワーカープロセス

問題:計算リソースの競合

1つのワーカーが翻訳を処理しているとき:

  • GPU計算能力の約90%を使用
  • 他のワーカーは残りの容量を効果的に並列利用できない
  • ワーカーはGPUの空きを待つことになる

並列化の効果がない理由:

  • ワーカー1がvLLM生成を開始 → GPU計算能力の約90%を使用
  • ワーカー2が開始しようとする → GPU計算能力は約10%しか利用可能でない
  • ワーカー2は低速で実行されるか、待機状態になる
  • 別プロセスにもかかわらず、実質的にシーケンシャル実行

追加のオーバーヘッド:

  • プロセスのスポーンと管理
  • ワーカー間でのGPUメモリ分割(各々がモデルウェイトをロード)
  • IPCキューのコーディネーション
  • プロセス間のコンテキストスイッチ

GPUは技術的には複数のCUDAカーネルを同時に実行できますが、1つのワーカーが計算能力の約90%を使用している状態では、もう1つのワーカーが効率的に並列実行できるだけの残容量がありません。

その他のアーキテクチャ上の問題

複数ワーカーが同一リソースを奪い合うことで:

  • コンテキストスイッチのオーバーヘッド: OSがワーカープロセス間を切り替え
  • メモリ使用量の倍増: 各ワーカーがモデルウェイトの全体をロード
  • 実効的な並列性なし: 並列アーキテクチャにもかかわらずシーケンシャルなGPU実行

すべてのワーカーが同じキュー(translation_queueとevent_queue共有)を使用するため、リクエストあたりのIPCオーバーヘッドは一定です。しかし、プロセス管理、コンテキストスイッチ、メモリ重複による追加オーバーヘッドと、GPU並列化の恩恵がないことが相まって、性能が悪化しました。

特定されたボトルネック

この実験を通じて、根本的な問題を特定しました。

1. IPCシリアライゼーションのオーバーヘッド

  • 全リクエストで:タスクのシリアライズ → ワーカー、結果のシリアライズ → メイン
  • Pythonのマルチプロセッシングキューはpickleを使用
  • リクエストごとにオーバーヘッドが発生

2. 計算リソースの競合

  • 1つのワーカーがGPU計算能力の約90%を使用
  • 他のワーカーは効果的に並列実行できない
  • マルチプロセッシングにもかかわらずシーケンシャル実行

3. Async/Await + マルチプロセッシングのブリッジ

  • asyncio.Eventがマルチプロセッシングの結果を待機
  • スレッドベースのイベントキューコンシューマ
  • 非同期モデルとマルチプロセスモデル間のコーディネーションオーバーヘッド

4. GPUサイクルの浪費

  • キュー操作の待機中にGPUがアイドル
  • スパイク状の使用率(93% → 0% → 93%)
  • 翻訳時間は約400msなのに、合計レスポンスタイムは11秒以上
  • 大半の時間がキューに費やされ、計算には使われていない

5. アーキテクチャの複雑さ

  • FastAPI(async/await)
  • TranslationService(ブリッジ)
  • TranslationWorker(コーディネーション)
  • JoinableQueue(IPC)
  • ワーカープロセス(マルチプロセッシング)
  • Event queue(IPC)
  • EventTask(非同期同期)
  • vLLM(実際の処理)

各レイヤーがレイテンシを追加していました。

重要な知見

1. Async/Await + マルチプロセッシング = オーバーヘッド

この2つの並行モデルを橋渡しするにはコーディネーションが必要です:

  • 非同期待機のためのasyncio.Event
  • イベントキュー消費のためのスレッドプール
  • プロセス境界でのシリアライゼーション

このブリッジにはコストがかかります。

2. 複数プロセス ≠ GPU並列化

ワーカープロセスの追加がGPU使用率の向上に直結しないケース:

  • 1つのワーカーがGPU計算能力の約90%を使用
  • 並列処理に十分な残容量がない
  • マルチプロセッシングのオーバーヘッドがあるのにシーケンシャル実行

3. キューのオーバーヘッドが支配的

25の同時リクエスト時:

  • vLLM翻訳時間:約400ms
  • 合計レスポンスタイム:11,258ms
  • キューのオーバーヘッド:合計時間の約97%

大半の時間が計算ではなく、キューとコーディネーションに費やされていました。

4. スパイク状のGPU使用率 = アーキテクチャの問題

  • 安定したGPU使用率(例:90〜95%)は計算律速のワークロードを示す
  • スパイク状のパターン(93% → 0% → 93%)はGPUが処理を待っていることを示す。ボトルネックは別の場所にある(私たちの場合はキューとIPC)

まとめ

ボトルネックはGPUの処理能力ではありませんでした。マルチプロセッシングアーキテクチャそのものが原因でした。

特定された問題:

  1. キューのシリアライゼーションによるIPCオーバーヘッド
  2. 実効的な並列性のないGPU計算リソースの競合
  3. Async/await + マルチプロセッシングのコーディネーションオーバーヘッド
  4. vLLMの処理ではなくキュー待ちがレイテンシの大半を占める

症状:

  • スパイク状のGPU使用率
  • キュー待ちがレスポンスタイムの大部分を占める
  • ワーカーの追加で性能がさらに悪化

注: Part 2では、マルチプロセッシングを排除し、vLLMのAsyncLLMEngineを直接使用することで、本番環境でスループット82%向上を達成した解決策を紹介します。

次回の内容:

  • マルチプロセッシングアーキテクチャの全面撤廃
  • vLLMのAsyncLLMEngineをFastAPIと直接統合
  • Continuous Batchingの適正な設定
  • 本番環境での成果:スループット82%向上

続きはこちら: Part 2:翻訳推論のスケーリング:スループット+82%

この記事をシェア

VoicePingを無料で試す

AI翻訳で言語の壁を超えましょう。今すぐ無料でお試しください。

無料ではじめる