Sansan Tech Blog

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

「量子化済みLLM+LoRA」 vs 「量子化なしLLM+LoRA」、RPSとlatencyはどう変わるか?

Sansan株式会社 技術本部 研究開発部の齋藤慎一朗です。

この記事は、Sansan Advent Calendar 2025 22日目の記事です。

結論

最初に、この記事の概要を図1にまとめます。

図1 本記事の概要

はじめに

最近、ファインチューニングしたLLMを使ってプロダクトを改善するお仕事をしています。ファインチューニングしたLLMを運用するフェーズでは、クラウド環境にGPUを用意し、LLMが推論できる環境を用意しています。環境について、基本的に次の2点が求められます。

  1. LLMが提供する価値に対して運用費が高すぎないこと
  2. 高速に推論できること

1を満たすために、多くの場合はH200のようなハイスペックなGPUではなく、L4のような一般的なスペックのGPUを用いた運用が求められます。参考までに、2025年12月18日時点で、AWS EC2のus-east-1におけるオンデマンド費用は、p5en.48xlarge(H200×8)は時間あたり約1万円であり、g6.xlarge(L4×1)は時間あたり約125円となります。

2を考える際には、スループットレイテンシの観点があります。スループットは、単位時間あたりに処理できる量のことを指します。具体的な指標としては、1秒あたりに処理できるリクエスト数を示すRPS(Requests Per Second)などがあります。レイテンシは、1つのリクエストに対して、結果が返ってくるまでにかかる時間を指します。具体的な指標としては、リクエストを送信してからレスポンスを受信するまでの総時間であるEnd-to-End Latencyなどがあります。スループットとレイテンシは、どちらかが改善すればもう片方も常に改善するという単純な関係ではなく、レイテンシが悪化する代わりにスループットが改善する方法などもあります。

現実的な運用費で高速に推論するためには、推論エンジンを活用することが有効です。推論エンジンの一つであるvLLMは、KVキャッシュを効率的に管理するPagedAttentionという技術を用いることで、動的バッチ処理を可能にし、高いスループットでの推論を実現しています。動的バッチ処理を行う場合、GPUメモリに余裕があるほど同時に処理できるリクエスト数が増え、結果としてスループットが向上しやすくなると考えられます。

GPUメモリに余裕を持たせる方法の一つに量子化があります。量子化は、LLMの重みを高精度から低精度の表現に変換することで、LLMを読み込むのに必要なGPUメモリ使用量を削減する技術のことです。本記事において、高精度とはFP32やFP16などの表現を意味し、低精度とはINT8やINT4の表現を指します。

また、量子化したモデルに対して低ランク行列(LoRA)のみを計算し、省メモリでファインチューニングを行う方法をQLoRAと呼びます。量子化されたモデルが低精度で表現され、LoRAが高精度で表現される場合、推論時に低精度の表現の一部を高精度に戻す計算が発生します。本記事ではこの処理をdequantizeと呼びます。 dequantize処理は、スループットの悪化に繋がることが L4Q などの研究で報告されています。

LoRAを用いたLLMを運用する場合、大きく2つの選択肢があります。

a. ベースモデルを量子化(低精度化)し、LoRAを加算して推論する。
b. ベースモデルは高精度のまま保持し、LoRAを加算して推論する。

(ベースモデルとLoRAをマージする方法もありますが、bとほぼ同じ傾向になると考え、今回は省略します。)

a、bの違いを、概念図として図2にまとめます。

図2 a、bによる推論方法の違いとそのメリット・デメリット

aは、量子化済みLLMとLoRAの精度が異なるため、dequantizeによるスループットの悪化が予想されます。一方、量子化済みLLMを利用しているため、GPUメモリ使用量には余裕が生まれ、動的バッチ処理が可能な状況においてはスループットが改善すると予想されます。

bは、量子化なしLLMとLoRAの精度が一致するため、dequantizeによるスループットの悪化は起きません。一方、量子化なしLLMを利用しているため、GPUメモリ使用量はaよりも多くなり、動的バッチ処理が可能な状況においてはスループットの改善幅がaよりも低いと予想されます。

では、dequantizeによるスループットの悪化幅と、量子化にてGPUメモリ使用量の余裕が生まれることによるスループットの改善幅はどちらが大きいのでしょうか?

本記事では、この疑問を解決するために実験した結果を共有します。なお便宜上、aを量子化済みLLM+LoRA、bを量子化なしLLM+LoRAと呼びます。

実験

手法

量子化済みLLM+LoRA、量子化なしLLM+LoRAに対して、負荷試験を行います。負荷試験にはLocustという負荷試験用のライブラリを使用します。同時リクエスト数は1、10、100と分けて実施します。同時リクエスト数は、リクエストが同時に来る数を意味します。負荷試験は5分間実施します。

負荷試験には、次のコードを利用します。このコードをlocustfile_for_vllm.pyとします。

import random
from locust import HttpUser, between, task

CANDIDATE_TEXT = [
    YOUR_TEXT_1,
    YOUR_TEXT_2,
    YOUR_TEXT_3,
    ...,
    YOUR_TEXT_10,
]

random.seed(42)

class ChatAPIUser(HttpUser):
    # ユーザごとのリクエスト間隔(例: 1〜3秒)
    wait_time = between(1, 3)

    @task
    def chat_completion(self):
        # CANDIDATE_TEXTからランダムに1つ選択
        input_text = random.choice(CANDIDATE_TEXT)
        
        # curl のリクエストに相当する処理
        payload = {
            "model": "adapter",
            "messages": [{"role": "user", "content": input_text}],
        }
        headers = {"Content-Type": "application/json"}

        # レスポンスを受け取る
        response = self.client.post("/v1/chat/completions", json=payload, headers=headers)

        # レスポンスボディを出力
        try:
            print(response.json())  # JSONとして表示
        except Exception:
            print(response.text)  # JSONでない場合はテキスト表示

負荷試験を始める際には、次を実行します。-uは同時リクエスト数、-tは負荷試験を行う時間となります。同時リクエスト数を変更したい場合は、-uの引数を変更します。

uv run locust -f locustfile_for_vllm.py \
    --headless -u 1 -t 5m \
    --csv=YOUR_RESULT_PATH \
    --host=http://localhost:8000

負荷試験の方法について、研究開発部の石井が書いた次のブログを参考にしました。

buildersbox.corp-sansan.com

リクエスト、レスポンス

リクエスト(locustfile_for_vllm.pyにおけるCANDIDATE_TEXT)には、平均7,500トークン程度のテキストを10個用意し、ランダムに選択します。レスポンスとして生成するトークンは平均30トークン程度です。

推論エンジン

vLLMを利用します。バージョンは0.12.0です。

量子化済みLLM+LoRAは、次のコマンドにて立ち上げます。

uv run vllm serve <QUANTIZED_SWALLOW_PATH> \
    --enable-lora \
    --lora-modules adapter=<LORA_ADAPTER_PATH> \
    --load-format bitsandbytes \
    --gpu-memory-utilization 0.95 \
    --max-model-len 8192 \
    --trust-remote-code \
    --enforce-eager \
    --enable-prefix-caching \
    --max_num_seqs 1024

量子化なしLLM+LoRAは、次のコマンドにて立ち上げます。

uv run vllm serve tokyotech-llm/Llama-3-Swallow-8B-Instruct-v0.1 \
    --enable-lora \
    --lora-modules adapter=<LORA_ADAPTER_PATH> \
    --gpu-memory-utilization 0.95 \
    --max-model-len 8192 \
    --trust-remote-code \
    --enforce-eager \
    --enable-prefix-caching \
    --max_num_seqs 1024

max-model-lenmax_num_seqsは同じ値を利用し、prefix cachingをどちらの状況においても有効にしています。

利用するLLM、LoRA

量子化済みLLM+LoRAでは、LLMとしてtokyotech-llm/Llama-3-Swallow-8B-Instruct-v0.1bitsandbytesで4bit量子化したものを用います。LoRAには、TRLのSFTTrainerを利用し、QLoRA Fine-Tuningを行った結果を利用します。

量子化なしLLM+LoRAでは、LLMとしてtokyotech-llm/Llama-3-Swallow-8B-Instruct-v0.1を用います。LoRAについては、量子化なしLLM+LoRAと同じものを使います。

なお、bitsandbytesで4bit量子化したモデルを保存する際には、次のコードを利用しました。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

MODEL_NAME="tokyotech-llm/Llama-3-Swallow-8B-Instruct-v0.1"
OUTPUT_PATH = <YOUR_OUTPUT_PATH>

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_use_double_quant=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    dtype=torch.bfloat16,
    trust_remote_code=True,
    quantization_config=bnb_config,
)

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

model.save_pretrained(OUTPUT_PATH)
tokenizer.save_pretrained(OUTPUT_PATH)

GPU環境

GPUとして、AWSのg6.xlarge(GPUメモリ24GB)とg6e.xlarge(GPUメモリ48GB)を利用します。

スループット、レイテンシの指標

スループットの性能指標としてRPSを、レイテンシの性能指標としてEnd-to-End Latency(以降、latencyと呼ぶ)を計測します。

実験結果

モデルの読み込みに必要なGPUメモリ

モデルの読み込みにて、量子化済みLLM+LoRAは約5.7GBのGPUメモリを消費し、量子化なしLLM+LoRAは約15.7GBのGPUメモリを消費しました。

性能

同時リクエスト数とRPSを図3に示します。RPSは大きい方が良い数値となります。縦軸は異なるため注意してください。

図3 同時リクエスト数とRPS

図3より、g6.xlarge(GPUメモリ24GB)の環境では、量子化済みLLM+LoRAの方がRPSが良いことが分かります。一方、g6e.xlarge(GPUメモリ48GB)の環境では結果が逆転し、量子化なしLLM+LoRAの方がRPSが良いことが分かります。

次に、同時リクエスト数とlatency平均値を図4に示します。

図4 同時リクエスト数とlatency平均値

図4より、latencyについても、RPSと同様の傾向が確認できます。

参考として、詳細な結果を表1、表2としてまとめます。

表1 g6.xlarge(GPUメモリ24GB)における、量子化済みLLM+LoRAと量子化なしLLM+LoRAの、同時リクエスト数の違いによるRPS、latencyの変化

表2 g6e.xlarge(GPUメモリ48GB)における、量子化済みLLM+LoRAと量子化なしLLM+LoRAの、同時リクエスト数の違いによるRPS、latencyの変化

なお、エラーとなった処理は1件もありませんでした。

生成されたテキストの違い

定量的な確認はしていませんが、目視で確認した範囲では、量子化済みLLM+LoRA、量子化なしLLM+LoRAの間で生成されたテキストの違いはあまりありませんでした。

考察

図3の左のグラフと図4の左のグラフより、g6.xlarge(GPUメモリ24GB)の環境では、量子化済みLLM+LoRAの方がRPS、latencyともに良いことが分かりました。これは、g6.xlarge(GPUメモリ24GB)では、量子化なしLLM+LoRAはモデルの読み込み時点でGPUメモリを約15.7GB消費しており、同時リクエスト数を増やしても動的バッチ処理を十分に活用できるだけの余剰メモリがなかったと考えられます。 一方、量子化済みLLM+LoRAではモデル読み込み時の GPU メモリ使用量が約5.7GBと小さく、同時リクエスト数の増加に伴って動的バッチ処理の効果を得やすかったと考えられます。

次に、図3の右のグラフと図4の右のグラフより、g6e.xlarge(GPUメモリ48GB)の環境では結果が逆転し、量子化なしLLM+LoRAの方がRPS、latencyともに良いことが分かりました。これは、g6e.xlarge(GPUメモリ48GB)の環境において、リクエストを処理できるだけのGPUメモリの余裕が生まれた結果、dequantize処理によるRPSの悪化の影響が相対的に強くなったことが原因と考えます。

結論(再掲)

図1 本記事の概要

最後に

検証前は、「g6.xlarge(GPUメモリ24GB)の環境において、量子化なしLLM+LoRAでも、同時リクエスト数を増やしたらスループットが改善する」と思い込んでいたため、(当たり前ですが)実際に試すことの重要さを再確認しました。また、実験をすると色々なことに気づけて理解が深まり楽しいです。

© Sansan, Inc.