Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

LVLMをローカル運用する際に、精度を保ちつつレイテンシをどこまで改善できるか(vLLM, Flash Attention, 量子化)

はじめに

Sansan 研究開発部の齋藤慎一朗です。

次のブログでは、本格的なLVLMの検証の前に、運用に必要な環境をどう見積もるかについて、メモリの観点で紹介しました。

buildersbox.corp-sansan.com

今回は、Fine-Tuningを行ったLVLMをローカル運用する際のレイテンシをどこまで改善できるかを検証したため、その手法と結果について共有します。 オフラインバッチ推論の前提での検証となります。

前提

利用するモデル

Full Fine-TuningされたQwen/Qwen2.5-VL-7B-Instruct1

タスク

人事異動情報が記載されたPDFから、対象となるデータのみをデータ化します。 具体的に、input/outputは以下となります。

input

  • PDFのうち、データ化対象の画像1枚 + データ化対象のページごとに遡って最大4枚
  • データ化対象部分のPDF内でのページ番号
  • 企業名
  • 作業日時
  • 前工程を行ったオペレーターさんからのフリーテキスト

output

  • 構造化された人事異動情報

次のブログにて、その概要が説明されています。 buildersbox.corp-sansan.com

inputデータ、outputデータとモデル構成のイメージは以下のようになります。ChatGPT o3に生成してもらったダミー画像を利用しています。

inputデータ、outputデータとモデル構成のイメージ

本実験の評価指標

以下3つを指標にします。

  • レイテンシ
  • 精度
  • GPUメモリ使用量

それぞれ説明していきます。 まずレイテンシは、画像が入力されてから、データ化された人事異動情報を出力するまでに必要な時間(秒)です。

次に精度は、正しく人事異動情報をデータ化できたかの割合を示します。人事異動情報の中には項目が複数存在しており、それぞれを文字列完全一致で正しく出力できたかを測る指標となります。具体的には、正しく抽出できた項目数÷対象項目数で計算します。

最後にGPUメモリ使用量は、利用したインスタンスのGPUメモリをどこまで利用したかの目安の指標となります。

今回は、ベースラインに対して精度を担保しつつ、どこまでレイテンシを改善できるかを実験します。

実験設定

試す手法

モデル側の変更と、インスタンス側の変更の 2 つを実験します。

モデル側の変更

高速化に繋がりそうな手法をいくつかピックアップしました。

  • KV Cache
  • Flash Attention 22
  • vLLM3
  • 量子化(bitsandbytes4 8bit, bitsandbytes 4bit, AWQ5 4bit)

簡単にそれぞれの意味を説明すると、KV CacheはAttentionに必要なQuery, Keyをキャッシュし、LLM, LVLMの生成を早くする仕組みです。また、Flash AttentionはGPUのSRAMを利用し、Query, Key, Valueの計算を効率よく行う方法です。量子化は、LLMやLVLMのFloatで表現された重みをIntで丸めることです。vLLMは、LLMとLVLMを高速にサービングする仕組みとなります。

bitsandbytesは以降bnbと省略します。

インスタンスの変更

今回は、運用費用が現実的であり、Flash Attention を利用可能な以下2つに限定して検証しました。

  • g6.xlarge

    • NVIDIA L4 Tensor Core GPUs
    • 24 GB
    • オンデマンド料金: 0.805 USD / 時間(blog 執筆時点)
  • g5.xlarge

    • NVIDIA A10G Tensor Core GPU
    • 24 GB
    • オンデマンド料金: 1.006 USD / 時間(blog 執筆時点)

試さない手法

インスタンスの並列化については検証しません。運用時には確実に効果がある手法ですが、今回はローカルの実験環境で比較しやすい内容に限定しました。

評価データ

データ数

100データ。

1データには、タスクで説明したinputが含まれます。テキスト情報の文字数は数十文字程度となります。

画像

入力枚数は2~5枚です。 解像度は、PDFに対し、前工程で指定された矩形で画像をまず切り出しました。その後、アスペクト比を維持したまま、最大幅1024、最大高さ768のどちらかを満たすようにリサイズしました。

バージョン

  • transformers: 4.50.0
  • vllm: 0.8.5.post1

結果概要

  • ベースラインでは 18.24s / データ かかっていましたが、1.60s / データまで高速化できることが分かりました。
  • 手法は、Flash Attention 2 + vLLM(max_num_seq=14)をg5.xlargeで運用する想定となります。

結果詳細1 モデル側の変更

インスタンスはg6.xlargeを利用しています。 結果は次の表のようになりました。Flash Attention 2 を利用しない場合はOOMとなってしまうため、KV Cache + Flash Attention 2 を用いた結果をベースラインとしました。

No. 手法 処理方式 レイテンシ(秒 / データ)↓ 利用メモリ 精度↑
1 ベースライン(KV Cache + Flash Attention 2) リアルタイム 18.24s 約15.51GB 0.961
2 KV Cache + Flash Attention 2 + bnb量子化 8bit リアルタイム 30.74s 約8.97GB 0.955
3 KV Cache + Flash Attention 2 + bnb量子化 4bit リアルタイム 11.79s 約5.88GB 0.957
4 vLLM (Flash Attention 2, max_num_seq=1) バッチ処理 15.94s ※2 約21GB ※1 0.960
5 vLLM (Flash Attention 2, max_num_seq=4) バッチ処理 5.01s ※2 約21GB ※1 0.960
6 vLLM (Flash Attention 2, max_num_seq=8) バッチ処理 3.13s ※2 約21GB ※1 0.961
7 vLLM (Flash Attention 2, max_num_seq=14) バッチ処理 2.35s ※2 約21GB ※1 0.963
8 vLLM (Flash Attention 2, max_num_seq=16) バッチ処理 1.45s ※2 約21GB ※1 0.000
9 vLLM (Flash Attention 2, max_num_seq=14, AWQ 4bit 量子化) バッチ処理 4.41s ※2 約21GB ※1 0.952

※1 vLLMは、自身で指定した割合までGPUを使う仕様です。本実験の設定では利用可能なメモリの95%まで利用します。

※2 対象となるデータ100件が既に存在する、という前提での数値です。リアルタイムで1件ずつ入ってくる場合、バッチ処理が効率的にできず、少し遅くなることが予想されます。

まず注意点として、今回示す精度は最終的なデータ化精度を意味しません。この出力を活用し、後の工程で人の手も介しながらデータ化精度を高め、ユーザーに提供することを想定しています。

ベースラインである「1.KV Cache + Flash Attention 2」では1データに対するレイテンシが18.24s でした。

「2. KV Cache + Flash Attention 2 + bnb量子化 8bit」では、18.24s -> 30.74sとレイテンシが悪化してしまいました。

一方、「3. KV Cache + Flash Attention 2 + bnb量子化 4bit」については、18.24s -> 11.79sとレイテンシ改善に効果がありました。

次に、vLLM を利用した場合、最大バッチサイズを意味するmax_num_seqを変更することで、レイテンシが大きく変わりました。具体的には、「7. vLLM (Flash Attention 2, max_num_seq=14)」の設定において、ベースラインと比較した際のレイテンシは18.24s -> 2.35sまで改善し、精度は0.961 -> 0.963と悪化しませんでした。

一方、vLLM にて、max_num_seq=16 に設定すると正しく出力されなくなり、レイテンシは改善するものの精度は0.000となってしまいました。

最後に、AWQ 4bit 量子化 を行なった場合、レイテンシは、No.7に対して、2.35s -> 4.41s まで悪化しました。

結果詳細2 インスタンスの変更

「結果詳細1 モデル側の変更」において、精度を保ちつつレイテンシが最も良かった「7. vLLM (Flash Attention 2, max_num_seq=14) 」をベースに、インスタンスを変更した場合を検証しました。 結果は次の表のようになります。

No. 手法 処理方式 レイテンシ(秒)↓ 利用メモリ 精度↑
10 vLLM (Flash Attention 2, max_num_seq=14, g6.xlarge) バッチ処理 2.35s ※3 約21GB ※1 0.963
11 vLLM (Flash Attention 2, max_num_seq=14, g5.xlarge) バッチ処理 1.60s ※3 約21GB ※1 0.961

結果は、g6.xlargeよりもg5.xlarge のレイテンシが優れていました。

考察

vLLMを用いると、レイテンシの改善のためにはmax_num_seqの値が非常に重要となり、max_num_seq=14まではレイテンシの改善につながることが分かりました。一方、max_num_seqを16以上にすると、生成結果がおかしくなるような挙動が確認されました。色々調べましたが原因不明でした。

また、今回の実験においては、量子化を行なってもレイテンシが改善されませんでした。これは、g6.xlargeがInt8やInt4ではなく、Float16 の計算に最適化されていることが原因だと考えます。

最後に、g6.xlargeよりもg5.xlargeの方が処理が高速であることが分かりました。これは、メモリ帯域幅が影響していると考えます。g6.xlargeで利用されているL4は、GPU メモリ帯域幅が300GB/sです。6 次にg5.xlargeで利用されているA10はGPUメモリ帯域幅が600GB/sです。7 Flash AttentionやvLLMを用いるとGPUのSRAMとGPUのHBM間のデータのやり取りが増えます。結果としてGPUメモリ帯域幅が大きいA10の方が高速に処理を行えたのだと考えています。

また、g6.xlarge -> g5.xlargeに変更すると検証結果では精度が0.963 -> 0.961に下がりますが、本タスクにおいてこの程度の精度低下は問題ないと判断しました。

終わりに

今回はvLLMやFlash Attention, 量子化など、現時点で広く行われている高速化手法を検証しました。他にもLLMの高速推論のためのフレームワークとしてTensorRT-LLM8があります。TensorRT-LLMは特定の条件下ではvLLMよりも高速であることが報告されています。9 また、同様の推論フレームワークLMDeployも、特定の条件下ではvLLMよりも高速だと報告されています10。さらに高速にテキスト生成が可能な拡散モデルが注目を集めています。

早くローカルLLMを運用する際のレイテンシに何の不安もない世界が訪れてほしいなと思います。

Sansan技術本部の採用

また、Sansan技術本部ではカジュアル面談を実施しています。

Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。本ブログの著者(齋藤)もお話できます。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

おまけ: サンプルコード

今回の実験で使ったサンプルコードを載せます。分かりやすくするため、ドメインに強く依存する箇所を省略しています。

torch.cuda.synchronize() は CUDA の動作を同期するために追加しています。同期を行わないと、まだ処理は終わっていないのに時間を計測してしまうことがあることに注意しましょう。

from transformers import Qwen2_5_VLForConditionalGeneration,
import polars as pl

# 中略

if __name__ == "__main__":
    make_prompter = MakePrompter(ANSWER_DATA_PATH)

    test_data = pl.read_csv(TEST_DATA_PATH)

    model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
        FINE_TUNED_PATH,
        attn_implementation="flash_attention_2",
        torch_dtype="auto",
        device_map="auto",
        local_files_only=True
    )
    processor = AutoProcessor.from_pretrained(FINE_TUNED_PATH)

    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize()
    start = time.time()

    for row in tqdm(test_data.iter_rows(named=True), total=test_data.height):
        predict_text  = predict( # 推論を行うための関数
            ...
        )

    torch.cuda.synchronize()
    end = time.time()
    elapsed_time = end - start
    elapsed_time_per_workspace = elapsed_time / test_data.height

    elapsed_time_text = f"Elapsed time: {end - start:.4f} sec"
    print(elapsed_time_text)

    # メモリ使用量
    max_mem_mb = torch.cuda.max_memory_allocated() / 1024 / 1024  # MB単位
    cur_mem_mb = torch.cuda.memory_allocated() / 1024 / 1024      # MB単位
    max_mem_gb = max_mem_mb / 1024
    cur_mem_gb = cur_mem_mb / 1024

    mem_text = (
        f"Max memory used: {max_mem_mb:.2f} MB ({max_mem_gb:.2f} GB), "
        f"Current memory: {cur_mem_mb:.2f} MB ({cur_mem_gb:.2f} GB)\n"
    )
    print(mem_text)

また、実験用のvLLMのサンプルコードは以下です。一部省略しています。

from vllm import LLM, SamplingParams
from transformers import AutoProcessor
import polars as pl

# 中略

if __name__ == "__main__":
    make_prompter = MakePrompter(ANSWER_DATA_PATH)

    test_data = pl.read_csv(TEST_DATA_PATH)

    preprocessor = AutoProcessor.from_pretrained(FINE_TUNED_PATH)

    model = LLM(
        model=FINE_TUNED_PATH,
        gpu_memory_utilization=0.95,
        trust_remote_code=True,
        max_model_len=8192,
        tensor_parallel_size=1,
        dtype=torch.bfloat16,
        limit_mm_per_prompt={"image": MAX_NUM_PAGE}, 
        seed=SEED,
        max_num_seqs=MAX_NUM_SEQS
    )

    sampling_params = SamplingParams(
        temperature=0.0,
        top_p=1,
        top_k=1,
        max_tokens=MAX_TOKEN
    )

    make_prompter = MakePrompter(ANSWER_DATA_PATH)

    all_inputs = []
    for row in tqdm(test_data.iter_rows(named=True), total=test_data.height):
        messages = get_messages(
            ...
        )

        image_inputs, _ = process_vision_info(
            messages
        )

        prompt = preprocessor.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=True,
        )

        mm_data = {}
        if image_inputs is not None:
            mm_data["image"] = image_inputs

        llm_inputs = {
            "prompt": prompt,
            "multi_modal_data": mm_data,
        }

        all_inputs.append(llm_inputs)

    torch.cuda.empty_cache()
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize()

    start = time.time()

    responses = model.generate(all_inputs, sampling_params=sampling_params)

    torch.cuda.synchronize()
    end = time.time()
    elapsed_time = end - start
    elapsed_time_per_workspace = elapsed_time / test_data.height

    elapsed_time_text = f"Elapsed time: {end - start:.4f} sec"
    print(elapsed_time_text)

© Sansan, Inc.