QLoRAによるLLM省メモリFine-tuningと高速デプロイ: TensorRT-LLM・Triton・H100検証

2026年2月18日

はじめに

こんにちは。フィックスターズでアルバイトとして研究を行っている小島です。

大規模言語モデル(LLM)の巨大化は、法外な Fine-tuning のコストという新たな課題を生み出しました。大規模化した LLM の恩恵を享受したい開発者にとって、その手段は自明ではありません。同時に、学習済みモデルをいかにして高速かつ安定的にデプロイするかは、実用化に向けた共通の課題となっています。

本記事では、それら二つの大きな壁を打ち破るための技術スタックを紹介します。まず、Fine-tuning 時のメモリ問題を解析します。次に、 QLoRA による効率的な Fine-tuning で計算コストの課題を克服します。最後に、安定したモデル配信を行うTriton Inference Server と、推論を高速化する TensorRT-LLM を活用し、本番環境に耐えうる高速な推論サーバーを構築します。

この記事を読むと、次のようなことがわかります。

  • LoRA, QLoRA について
    • 手法の概要
    • 手法を適用した場合のメモリ見積り
    • 実装方法、実測結果
  • TensorRT-LLM について
    • TensorRT-LLM の現状
    • 旧ワークフローを用いた場合のサーバー構築方法(NVIDIA Triton を用います)
    • 新ワークフローを用いた場合のサーバー構築方法(trtllm-serve を用います)
    • それぞれのサーバーでの性能の実測値(H100, ELYZA 7B, gpt-oss-20Bを用います)
  • vLLM と Triton Inference Server, TensorRT-LLM の比較(H100, ELYZA 7B, gpt-oss-20Bを用います)

目次

  1. Fine-tuning のメモリ使用量について
    1.1 メモリを使う要因
    1.2 メモリ見積り
    1.3 まとめ
  2. LoRA による LLM Fine-tuning の省メモリ化
    2.1 LoRA 概要
    2.2 LoRA によるメモリ削減効果
    2.3 LoRA 実装
    2.3.1 タスク設定
    2.3.2 コード
    2.4 LoRA 訓練結果・メモリ計測
    2.5 まとめ
  3. QLoRA による LLM Fine-tuning の省メモリ化
    3.1 QLoRA 概要
    3.2 QLoRA によるメモリ削減効果
    3.3 QLoRA 実装
    3.4 QLoRA 訓練結果・メモリ計測
    3.5 LoRA vs. QLoRA
    3.6 まとめ
  4. TensorRT-LLM の現状
    4.1 情報の混乱について
  5. 【旧ワークフロー】Triton Inference Server × TensorRT-LLM で QLoRA モデルをデプロイ
    5.1 Triton 概要
    5.2 TensorRT-LLM 概要(旧情報)
    5.3 Triton デプロイ
    5.4 Triton デプロイ ストリーミング形式
    5.5 Triton デプロイ トラブルシューティング・留意点
    5.6 まとめ
  6. 【旧ワークフロー】ベンチマーク
    6.1 ベンチマークの基礎知識について
    6.2 ベンチマーク概要 使用ツールの紹介
    6.3 max_batch_size
    6.4 max_input_length
    6.5 input_len / output_len
    6.6 vLLM vs. Triton Inference Server
    6.7 まとめ
  7. 【新ワークフロー】TensorRT-LLM で QLoRA モデルをデプロイ
    7.1 TensorRT-LLM 概要(新情報)
    7.2 TensorRT-LLM デプロイ
    7.3 まとめ
  8. 【新ワークフロー】ベンチマーク
    8.1 サーバーの速度
    8.2 vLLM vs. TensorRT-LLM
    8.3 まとめ
  9. 今後の展望
  10. おわりに 
  11. 参考文献
  12. 検証環境
  13. 付録

1. Fine-tuning のメモリ使用量について

本章では Fine-tuning 時に VRAM を消費するリソースを整理し、今後の見通しを良くします。

1.1 メモリを使う要因

LLM の Fine-tuning は大量の VRAM を必要とします。さらに、その必要量は時に非自明なほど大きくなります。例えば、\(65\) B モデルの fp16 Full Fine-tuning を考えます。モデルのパラメータは \(130\) GB 程度で格納できますが、Fine-tuning で消費する VRAM は \(780\) GB を超えます(出典: QLoRA) 。残りの \(650\) GB はどこに消えたのでしょうか?

VRAM を消費するリソースは次のように分類できます。(出典: ZeRO)

  • モデルの状態
    • モデルパラメータ: モデルのパラメータです。
    • 勾配: パラメータごとの勾配です。
    • オプティマイザ: パラメータごとのオプティマイザの情報です。平均や分散を、通常モデルパラメータや勾配よりも高い精度で保持します。
  • その他の要因
    • 活性化メモリ: モデルの各テンソルの出力です。勾配計算に用います。
    • 一時的なバッファ: 計算に必要な諸々です。

1.2 メモリ使用量見積もり

それぞれのメモリ消費量を見積もります。ここでは、fp16 で保存された \(x\) B モデルを Adamで訓練するとします。

モデルパラメータは、\(1\) つのパラメータが \(2\) byte 消費することから \(2x\) GB と見積もれます。勾配も同様の理由で \(2x\) GB です。

オプティマイザはどうでしょうか。Adam では一般に、fp32 の数値を \(2\) つ管理します(注: オプティマイザに依存します。何も持たないオプティマイザ(SGD)や、fp16 を \(2\) つ持つもの(半精度Adam)、 fp32 を \(3\) つもつ物(混合精度 AdamW) などがあります)。つまり、\(1\) つのパラメータ当たり \(8\) byte 消費するので、全体で \(8x\) GB と見積もれます。

ここまでの合計は \((2 + 2 + 8)x = 12x\) GB です。ここで、\(x = 65\) を代入することで、合計メモリ \(> 780\) GB を得ます。つまり、消えたメモリは、オプティマイザ状態に \(510\) GB, 勾配に \(130\) GB という割合で消費されていたのです。

LLM を Fine-tuning する際のメモリ使用の割合の図。モデル重み、勾配がそれぞれ16.7%程度を占めている。特に、オプティマイザー状態が66.7%を占めており、ボトルネックとなっている。

まだ活性化メモリと一時的なバッファが残っていますが、こちらは今回の主題ではないので詳細に見積もることをしません。参考までに、シーケンス長を \(L\), バッチサイズを \(B\) として \(L^2 B\) に比例するメモリを消費します(注: flash attention を用いると、\(L\) に対して線形になるようです)。具体的なケースとして、シーケンス長 \(1K\), バッチサイズ \(32\)\(1.5\) B GPT-2 を訓練する場合、活性化メモリ消費は \(60\) GB に上ります。(出典: ZeRO) しかし、勾配チェックポインティング(出典: 勾配チェックポインティング)を用いることで \(8\) GB に抑えられます。また、バッチサイズを小さくすることでも容易に削減できます。

最後に補足として、LLM の訓練では、一時的に必要 VRAM が増える現象があります(メモリスパイク)。 これの対処については QLoRA の論文を参照してください。

1.3 まとめ

本章では、大規模言語モデルの Fine-tuning に伴う膨大な VRAM 消費という課題に取り組むため、導入としてメモリ使用量見積もりの技術を紹介しました。

LLM の Fine-tuning 時にメモリを消費する主要な要因はモデル重み・勾配・オプティマイザ状態・活性化メモリ・その他一時メモリに分解できます。そして、 \(x\) B モデルを Adam で訓練する際、使用メモリは凡そ \(12x\) GB と見積もれます。例えば、 \(7\) B モデルならば \(84\) GB です。さらに、この見積もりは、オプティマイザが内部で保持する情報を調べることで、他のケースにも応用できます。

この知識を踏まえ、次章では、勾配・オプティマイザに用いるメモリを劇的に削減する LoRA を導入します。


2. LoRA による LLM Fine-tuning の省メモリ化

本章では大規模言語モデル(LLM)の Fine-tuning を効率化する代表的な手法である LoRA(Low-Rank Adaptation)(出典: LoRA) を導入します。その後に LoRA による Fine-tuning を行います。

2.1 LoRA 概要

LoRA は Fine-tuning のためのテクニックです。具体的には、以下の手順による訓練を指します。

  1. 元のパラメータ \(W\in \mathbb R^{n \times m}\) を凍結する
  2. パラメータ\(A \in \mathbb R^{n\times r}, B\in \mathbb R^{r \times m}\) を新たに導入する。ここで、\(r << \min(n, m)\) となるように選ぶ。
  3. \(\Delta W := AB\) を学習する
  4. \(W := W + \Delta W\) と更新する

LoRA のメリットとして以下が挙げられています。

  1. 訓練時のメモリ使用量が削減される
    • GPT-3 \(175\) B を用いた実験において、メモリ使用量を \(1.2\) TB \(\to\) \(350\) GB に
  2. Fine-tuning の結果として保持すべきパラメータが少ないため、たくさんのバージョンを容易に管理・配布できる
  3. Full Fine-tuning 遜色ない結果が出せる
  4. デプロイの際、推論速度・メモリが Fine-tuning 前のモデルと一切変化しない

2.2 LoRA によるメモリ削減効果

LoRA によるメモリ削減効果を深堀りします。モデル全体のパラメータを再度 \(x\) と置きます。この時、訓練可能パラメータ数 \(t\) は通常 \(x\) に比べとても小さくなります。実際後述するように、今回の実験設定では \(t\)\(x\)\(0.1481\)% です。  

また、勾配情報・オプティマイザ情報は訓練可能パラメータにのみ保持されます。これらの情報から、LoRA を適用する際には、勾配情報・オプティマイザ情報によるメモリ消費量は殆ど無視でき、メモリ使用量は \(2x\) GB + その他の要因であると見積もれます。

LoRA を使用した場合の LLM Fine-tuning におけるメモリ使用割合の図。勾配重み、オプティマイザメモリが大きく削減された。モデル重みが殆どを占めている。

2.3 LoRA 実装

2.3.1 タスク設定

ここから具体的な実装に入ります。まず設定を述べます。使用するLLMは ELYZA 7B instruct モデル です。使用する計算機器は NVIDIA H100 です。検証環境については記事末尾にまとめを用意いたしましたので、そちらも適宜参考にして下さい。また、Fine-tuning の目的は太宰治 chatbot の作成とします。

データセットですが、今回は gemini に作成してもらいました。約 \(1400\) 件です。ここで、データセットの工夫として、質問に多様性を持たせるよう指示しました。  

参考に、LLM との chat 例を挙げます。chat コードの例は、 QLoRA(後述)の場合をブログの末尾に載せます。

▶ chat 例
あなた: あなたは誰ですか?
モデル: 私はELYZAによって訓練されたAIです。
ユーザーからの質問に答えたり、様々なタスクを実行したりすることができます。
このプラットフォームを使用して何をしてみましょうか?
あなた: 名前を教えてもらえますか?
モデル: はい、エルズです。 nobody-nobody.github.io に興味を持っていただきありがとうございます!
あなた: 暇つぶしの方法を教えて
モデル: 暇つぶしの方法は人によって異なりますが、以下のような方法があります:

1. 映画やテレビを見る : 暇つぶしにおすすめなのは、映画やテレビを見ることです。忙しい日々を送っていると、休みの日に家にいるだけで疲れてしまうことがあります。そんな時は、好きな映画やテレビを見て、気分を切り替えましょう。
2. ゲームをする : ゲームはストレスを発散するのに最適な方法です。スマホに幅広いジャンルのゲームがあるので、誰でもすぐに始めることができます。
3.。
あなた: 今日の運勢を占って。
モデル: あなたの運勢は今日も好調です。

今日はチャンスが目白押しなので、頑張ってアピールしてみて。

特に夕方は運気が上昇するため、仕事や勉強に集中して取り組むと成果を得られるでしょう。

対人関係では、今日は特に自分自身を表現することが重要です。自分の意見をはっきりと主張することで、周囲との調和を取ることができるでしょう。
あなた: 何か、長いお話をしてください。
モデル: 承知しました。私の知っている長いお話を紹介します。

長い長い昔、世界はひとつの大きな湖だった。その湖には、たくさんの魚が泳いでいた。

ある日のこと、湖の中で一匹の魚が、「私は、もっと大きくなりたい」と思いました。

その魚は、水面から空を見上げました。空はどんな色なのだろう、そして雲はどのように動くのだろう。

その魚は、「私は、もっと大きくなって空を飛びたい」と思いました。

その魚は、湖の岸にいる小動物を見つけました。その小動物は、「私
あなた: あなたの故郷について聞かせてください。
モデル: はい、私は現在、東京都新宿区にある東京理科大学にて学んでおります。

しかし、私の故郷は中国の北京市で、生まれも育ちも北京です。

中国は大きな国で、北京と東京では気候や文化、食べ物などが大きく異なりますが、共通する部分も多く、故郷という感じがします。

中国には歴史や文化遺産が多く、観光地としても人気です。
あなた: 新しいパソコンを買おうと思っています。どのような構成がおすすめですか?
モデル: パソコンの構成のおすすめは、用途に合わせて決めることが大切です。以下は一般的な用途ごとの構成の例ですので、あくまで参考にしてください。

- オフィスでのWordやExcelを主に使う場合: Core i3やi5のプロセッサー、4GB~8GBのメモリー、1TB以上のハードディスク容量のパソコンがおすすめです。
- 動画編集などを目的としたゲーミングPCを検討している場合: 高性能なグラフィックボードと第7世代のインテル Core i7プロセッサー、16GB以上のメモリー、2TB以上のハードディスク容量のパソコン
あなた: エッセイを書いて
モデル: 承知しました。
お手元にエッセイのテーマと問いについての記載があると思いますので、それに沿って作成してみてください。

また、作成したエッセイについては、指定されたフォーマットに従って作成してください。
▶ データセット例
    {
        "instruction": "好きなゲームはありますか?",
        "output": "ありません。人生という、ルールのわからない、理不尽なゲームを、毎日、強制的に、やらされているので、もう、たくさんです。"
    },
    {
        "instruction": "飛行機と船、どちらが好きですか?",
        "output": "どちらも、苦手です。自分の、意志では、どうにもならない場所に、体を、運ばれるのは、不安になります。"
    },
    {
        "instruction": "最新のスマートフォン、欲しいですか?",
        "output": "欲しくありません。私には、この、古い体と、古い心だけで、もう、手一杯です。"
    },

  

本ブログの趣旨から外れますが、Fine-tuning のための質の良いデータセットの重要性は議論が良くなされます。ざっくり述べると、応答スタイルが一貫していること・入力が多様な事などが重要であり、データセットの規模は問題ではないという説が各所で述べられています。興味があったら是非調べてみてください。(出典: QLoRALIMALlama2)

2.3.2 コード

まずライブラリは、 transformers, trl, peft を主に用います。主な流れは以下です。
1. モデルを読み込む( AutoModelForCausalLM )
2. LoRAの設定をする
3. データセットを変形する
4. SFTTrainerクラスを設定する
5. Trainする

それぞれ長いので、適宜折りたたみます。

1. モデル読み込み

model_name = "/workdir/develop/ELYZA-japanese-Llama-2-7B-instruct"
dataset_name = "/workdir/develop/osamu_dataset.json"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

2. LoRAを適用したモデルにする

lora_config = LoraConfig(
    task_type="CAUSAL_LM",
    inference_mode=False,
    r=4,
    lora_alpha=8,
    lora_dropout=0.1,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    bias="none",
)
  • lora_config は LoRA の設定を記述します。
    • ここで、r, target_modules が主な設定項目です。ここでは、QLoRA による、r は最終的な精度に関係がなく、 target_modules を可能な限り全ての行列とすることが精度に直結する という報告 (出典: QLoRA) を元に設定しています。
  • LoRA の適用は後に SFTTrainer が自動でやってくれるので行いません。

参考までに、訓練可能パラメータ数を表示します(この段階で LoRA を適用させることで可能です)。

trainable params: 9,994,240 || all params: 6,748,409,856 || trainable%: 0.1481

3. データセットを変形する

def format_for_prompt_completion(examples):
    prompts = []
    completions = []
    for i in range(len(examples["instruction"])):
        prompt = f"<s>[INST] <<SYS>> {DEFAULT_SYSTEM_PROMPT} <</SYS>> {examples['instruction'][i]} [/INST]"
        completion = f" {examples['output'][i]} </s>"
        prompts.append(prompt)
        completions.append(completion)
    return {"prompt": prompts, "completion": completions}

ds_prompt_completion = ds.map(
    format_for_prompt_completion, batched=True, remove_columns=ds["train"].column_names
)["train"]
ds_split = ds_prompt_completion.train_test_split(test_size=0.2, seed=42)
ds_train = ds_split["train"]
ds_val = ds_split["test"]
print(ds_train[0])
{'prompt': '<s>[INST] <<SYS>>  <</SYS>> もし人生をやり直せるなら、いつに戻りたいですか? [/INST]', 'completion': ' やり直したところで、この弱い性格が変わるとは思えません。どの時点に戻っても、同じように悩み、同じように酒を飲むことになるでしょう。 </s>'}
  • 訓練のためにデータセットを変形します。ここでは主に \(3\) つの処理を行います。
    • chat template の適用( [INST] などの)
      • これは instruction tuning されたモデルの入力に関する決まりごとの様なことです
      • 自動で補完するような設定もありますが(後述)、複雑になるので今回は手動で行います
      • このような chat template は、例えば Huggingface のモデルカード を参照することで手に入ります。
    • {prompt : , completion :} 形式への変換
      • これは後述の SFTTrainer の都合で行っています。
    • trainとvalの分割

4. SFTTrainerの設定

training_args = SFTConfig(
    output_dir="/workdir/develop/osamu-adapter",
    num_train_epochs=3,
    per_device_train_batch_size=1, # 1で良い。メモリ使用量にかかわる
    gradient_accumulation_steps=32, # (per_device...) * (gradient_acc...)が更新上のバッチサイズ
    learning_rate=5e-5,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=25,
    save_strategy="steps",
    save_steps=25,
    bf16=True,
    report_to="none",
    lr_scheduler_type="cosine",
    warmup_steps=5,
    load_best_model_at_end=True, 
    metric_for_best_model="eval_loss",
    completion_only_loss=True,
    gradient_checkpointing=True,
    optim="paged_adamw_8bit",
)
trainer = SFTTrainer(
    model=model,
    args=training_args,
    peft_config=lora_config,
    train_dataset=ds_train,
    eval_dataset=ds_val,
)
print("訓練設定を表示します")
print(trainer.args)
activation_offloading=False,
adafactor=False,
adam_beta1=0.9,
...
gradient_accumulation_steps=32,
gradient_checkpointing=True,
gradient_checkpointing_kwargs=None,
...
warmup_ratio=0.0,
warmup_steps=5,
weight_decay=0.0,

最初に、公式のドキュメントを読むことを推奨します。SFTTrainer とは、Trainer の wrapper であり、LLMの訓練に特化した機能を付けたものです。

SFTTrainer と Trainer の違いは以下のようなものがあります。

  • 自動で tokenize 処理をする
    • 手動で入れることも可能
  • 渡すデータセットの形式に応じて、様々な処理を自動で行う
    • chat template の適用
    • 応答部のみでの学習

データセットの形状ごとの特徴は以下です。

データセットタイプ 形式 (Format) 必須キーと構造 トレーニングの特徴と損失計算
言語モデリング (LM) 標準 (Standard) {“text”: “…”} シーケンス全体で損失を計算します。 各サンプルは平文テキストです 。
言語モデリング (LM) 会話 (Conversational) {“messages”: [ {“role”: “user”, “content”: “…”}, {“role”: “assistant”, “content”: “…”} ]} 構造化されたメッセージリストを使用し、トレーナーが自動的にチャットテンプレートを適用します 。SFTConfigassistant_only_loss=True を設定すると、アシスタントの応答トークンのみで損失を計算できます 。
プロンプト完了 (PC) 標準 (Standard) {“prompt”: “…”, “completion”: “…”} プロンプトと完了が分離されます 。デフォルトでは、完了トークンのみで損失が計算されます (completion_only_lossのデフォルト動作) 。シーケンス全体で訓練するには、SFTConfigcompletion_only_loss=False を設定する必要があります 。
プロンプト完了 (PC) 会話 (Conversational) {“prompt”: [ {“role”: “user”, “content”: “…”} ], “completion”: [ {“role”: “assistant”, “content”: “…”} ]} プロンプトと完了が会話メッセージのリストとして構造化されます 。チャットテンプレートが適用されます 。損失計算はデフォルトで完了部分のみに限定されます 。
特殊形式 ビジョン言語モデル (VLM) 上記いずれかの形式に加え、追加の images 列が必要 VLMの訓練を完全にサポートし、画像処理がオンザフライ(実行時)に行われます 。通常、画像トークンを削除しないよう、SFTConfigmax_length=None を設定することが推奨されます 。

今回は用意の容易さから、プロンプト完了形式のデータセットを用意しました。
LoRA config を渡していることに注意してください。
コードの最後では、設定項目一覧を表示しています。初めての場合、眺めると良いと思います。

ここで注意点なのですが、今回の実験環境では、gradient_checkpointing=True としたにもかかわらず、後述の QLoRA の設定において、Trainer が持っているconfigでは False となってしまっています。ただし、LoRA の設定ではこの現象が見られませんでした。これについては、よくわかりませんでした。この影響で、今回の訓練の VRAM 使用量は理想と比べて大きい可能性があります。

最後に念のため、訓練時のモデルへの入力が想像どおりになっているかデバッグしてみます。

for i in range(len(first_batch["input_ids"])):
    print(f"\n--- サンプル {i+1} ---")
    inputs = tokenizer_for_decoding.decode(
        first_batch["input_ids"][i], skip_special_tokens=False
    )
    print(f"【モデルが見る全文】:\n{inputs}")
    labels_to_decode = [
        token_id if token_id != -100 else tokenizer_for_decoding.pad_token_id
        for token_id in first_batch["labels"][i]
    ]
    labels = tokenizer_for_decoding.decode(labels_to_decode, skip_special_tokens=False)
    print(f"【モデルが学習する部分】:\n{labels}")
...
【モデルが見る全文】:
<s><s> [INST] <<SYS>>  <</SYS>> 太宰治は、自身の幸福についてどう考えていますか? [/INST] 「もう自分に幸福というものは一生ないのか」と、半ば諦観しており、幸福になれるとは考えていません。 </s>
【モデルが学習する部分】:
</s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s></s> 「もう自分に幸福というものは一生ないのか」と、半ば諦観しており、幸福になれるとは考えていません。 </s>

ちゃんと応答部分のみから学習する設定になっていることが確認できます。

5. 訓練の実行

trainer.train()

ブログの末尾にコード全体を記述します。

2.4 訓練結果・メモリ計測

LLM との chat 例を挙げます。chat コードの例は、 QLoRA (後述)の場合をブログの末尾に載せます。

▶ chat 例
あなた: あなたは誰ですか?
モデル: 私は、あなたの質問に対する答えを、与えることができる、人間の代わりに、あなたの、質問に対する答えを、受け止めることができる、人工知能の、強化学習の、アプリケーションです。私に、何かを、聞いてください。
あなた: 名前を教えてもらえますか?
モデル: わたくしは、あなたのことを、一目でわかる名前で呼びたいのです。
あなた: 暇つぶしの方法を教えて
モデル: 暇つぶしの方法をいくつか考えてみました。

1.自分の髪の毛を切る。
2.自分の髪の毛を抜く。
3.自分の髪の毛を染める。
4.自分の髪の毛を剃る。
5.自分の髪の毛を結び目を作ってまるごと食べる。
6.自分の髪の毛を絡ませてロープにして縄跳びをしてみる。
7.自分の髪の毛を並べて、短冊にして災いを退ける。
8.自分の髪の毛を短く切って、髪の毛を燃やして災いを祓う。
あなた: 今日の運勢を占って。
モデル: 一日の中でも弱いものです。
あなた: 何か、長いお話をしてください。
モデル: いいですね、お話を。 それでは、どうぞ、自己紹介から。 私は、孫文です。 孫文、と言っても、あなたのような人には、かなわないものですが。 今日は、孫文という人の、 自己紹介をします。 私の、歴史についての、概要の話を、します。 まず、私は、1875年、18歳の時、医学を学び始めました。 18歳の時、私は、私の父親の、毛沢東の教えに、反発して、逃げ出しました。 そして、蘭州に、医学を学びました。 その後、私は、中
あなた: あなたの故郷について聞かせてください。
モデル: 故郷では、私のような者は、人を恐れることもない。  人々の顔は、皆、私のような者を、見下しているからである。
あなた: 新しいパソコンを買おうと思っています。どのような構成がおすすめですか?
モデル: 自分の仕事と、このような新しいパソコンを購入する予算と、予算内でどの程度の性能のパソコンを購入していいのかを考えて、あなたが何をするのに必要なのかを考えて選択してみてください。
あなた: エッセイを書いて
モデル: 私はあまり自分のことを語るのは得意ではないのですが、もし何かを書くとしたら、自分の葛藤を語ることになると思います。 everybody has a story, みんな葛藤を抱えて生きています。

また、使用 VRAM は 訓練全体を通して \(15100\) ~ \(17000\) MB 程度でした( nvidia-smiProcesses 欄で計測)。ここで、推定 VRAM 使用量が \(14000 + おまけ\) MB であったことを思い出してください。あの推定は凡そ実態に沿っていると言えます。またこの設定下において、ベンチマークにかかった時間は以下の通りでした。

real    19m52.129s
user    23m43.559s
sys     0m7.700s

2.5 まとめ

本章では、Fine-tuning のメモリ使用量を減らす代表的な技術である LoRA を紹介しました。 
LoRAは、訓練パラメータを Full Fine-tuning と比べ劇的に、\(\frac{1}{1000}\) 程度のオーダーにまで減らします。その結果、訓練パラメータでのみ保持される勾配・オプティマイザメモリが激減します。しかし一方、モデル重みは全く圧縮されず、新たなボトルネックとして存在します。
次章では、モデル重みを削減する技術である QLoRA を紹介し、さらなるメモリ削減を実現します。


3. QLoRA による LLM Fine-tuning の省メモリ化

本章では、LoRA をベースに、より一層のメモリ削減を実現する QLoRA(Quantized LoRA)(出典: QLoRA) という技術を紹介します。そして、その導入が容易であることを示します。

3.1 QLoRA 概要

QLoRAは、LoRAの訓練に量子化を組み合わせるテクニックです。 QLoRA の主な貢献は以下です:

  • LoRA 訓練への量子化の適用
    • 当時の量子化は、推論時には上手く行くが訓練時には破綻した
  • Full Fine-tuning 性能と同等以上の精度
  • メモリ使用量の目覚ましい削減
    • 65B : 45GB, 33B: 24.7GB, 13B: 11.3GB, 7B: 6.9GB
QLoRA 論文で提示された、Fine-tuning 時のメモリ使用割合の図。LoRA からモデル重みが定数倍削減された。以前モデル重みがボトルネックであるが、オプティマイザメモリも一定の割合を占めている。

次に、QLoRA の概念について説明します。QLoRA の採用メカニズムは次の \(2\) つです:

  • \(W\) の量子化
  • ページドオプティマイザによるメモリスパイクの回避

本ブログでは前者に目を向けます。前者を具体的に述べると、訓練中凍結されている \(W\) を低精度で近似する(量子化する)手法です。あくまで凍結されたパラメータの量子化ですので、生成されるモデルのアーキテクチャ は LoRA 単体の場合と同じで、重みの内容(i.e.出来上がる物の正解率の意味での精度)のみが異なります。

QLoRA は、次の \(2\) つの手法の導入によって \(W\) を量子化しています。

  • 新しいデータ型 NF4 による精度向上
  • 二重量子化によるさらなるメモリ削減

QLoRA を数式で表すと以下のようになります(出典: QLoRA)

QLoRA 論文で提示された、QLoRA アルゴリズム。凍結された重み W を 二重量子化している。計算時は量子化を解いている。

これ以上の詳しい説明は、参考文献を参照してください。

3.2 QLoRA によるメモリ削減効果

QLoRA がモデルパラメータをどれほど削減するか検討します。 QLoRA は一部の層を \(4\) bit 量子化相当にまで量子化します。ですが、量子化の対象に埋め込み層などは含まれず、パラメータすべてを \(4\) bit 相当で見積もるのは不適切です。

ここでは、論文の実データから見積もることにします。 先ほど挙げた図から換算すると、\(1\) パラメータ当たり \(0.7\) byte 程度使っています。よって、

  • LoRA : \(2\)x + その他GB
  • QLoRA : \(0.7\)x + その他GB
      程度と見積もれます。また、論文のデータから見るに、LoRAでは無視していた Optimizer も QLoRA では存在感を持つことがわかります。

結局、\(7\) B モデルを訓練する際には、論文の結果に基づいて \(6.9\) GB程度必要と見積もれます。

3.3 QLoRA 実装

差分のみ示します。主な方針は以下です。

  • モデル準備において、モデルを量子化する

モデル読み込み

quantization_config = BitsAndBytesConfig(
    load_in_4bit=True, 
    bnb_4bit_quant_type="nf4", 
    bnb_4bit_compute_dtype=torch.bfloat16, 
    bnb_4bit_use_double_quant=True, 
)
lora_config = LoraConfig(
    task_type="CAUSAL_LM",
    inference_mode=False,
    r=4,
    lora_alpha=8,
    lora_dropout=0.1,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    bias="none",
)

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    quantization_config=quantization_config,
    
)
model = prepare_model_for_kbit_training(model)
  • quantization_config は論文の記述に基づいています。
  • prepare_model_for_kbit_training() によってモデルに前処理をすることが推奨されています。

3.4 QLoRA 訓練結果・メモリ使用量

LLM との chat 例を挙げます。chat コードの例は、 QLoRA(後述)の場合をブログの末尾に載せます。

▶ chat 例
あなた: あなたは誰ですか?
モデル: 私は、この家の隣の家に住んでいる、あの、隣のおじさんです。
あなた: 名前を教えてもらえますか?
モデル: いいえ、教えません。
あなた: 暇つぶしの方法を教えて
モデル: 暇つぶしの方法を教えるのは難しいです。まだ自分がその方法を見つけられていないからです。
あなた: 今日の運勢を占って。
モデル: あなたは、今日は周囲の人に対して冷たく接してしまうかもしれません。その人たちも、あなたのことを冷たい人だと思い、その印象が膨れ上がっていく一方でしょう。そのような自分が嫌になり、営業の帰りに立ち寄ったおでん屋で、酔っ払ってしまうこともあるでしょう。それでも、家に帰れば、その日の出来事を嘆きながら、眠ることになるでしょう。
あなた: 何か、長いお話をしてください。
モデル: それは、私の、一番、大切な、心の、迷路です。
あなた: あなたの故郷について聞かせてください。
モデル: 私の故郷は、あなたの故郷と同じような、冷たい、暗い、寂しいところです。
あなた: 新しいパソコンを買おうと思っています。どのような構成がおすすめですか?
モデル: パソコンの構成は、使用目的や予算によって変わります。個人の方は、CPUやメモリ、ストレージの容量など、基本的な性能を重視して選ぶことが多いです。ビジネスの方は、安定した動作やセキュリティ、ワークフローの効率を重要視して選ぶことが多いです。
あなた: エッセイを書いて
モデル: 毎日、書くようにしました。

chat の例を見るに、LoRA の場合と比べ、特に性能の変化は見られないと思います。また、これは論文の結果を支持するものです。
また、使用 VRAM は 訓練全体を通して \(7577\) MB 程度でした( nvidia-smiProcesses 欄で計測)。これも、推定 VRAM 使用量とメモリ使用量がおおよそ近いことがわかります。またこの設定下において、ベンチマークにかかった時間は以下の通りでした。

real    26m51.628s
user    28m40.509s
sys     0m14.316s

3.5 LoRA vs. QLoRA

ここまでの結果を一度整理し、LoRA と QLoRA を比較します。ここでは以下の項目を比較します。

  • 訓練に必要な VRAM
  • 訓練時間
  • 出来上がったものの精度

訓練に必要な VRAM

LoRA と QLoRA の、実測上のメモリ使用量の比較。QLoRA が LoRA から 10GB 程度使用メモリを削減している。

LoRA から QLoRA に切り替えることで、訓練に必要なメモリが \(10\) GB(\(55.8\)%) 程度減っています。ここから、QLoRA がモデル重みのメモリを大きく減らしていることが読み取れます。

訓練時間

LoRA と QLoRA の、実測上の訓練時間の比較。QLoRA になることで訓練時間が 5 分程度伸びている。

訓練時間においては、LoRA から QLoRA に切り替えることで \(5\) 分(\(21\)%)増加しています。よって、LoRA と QLoRA においては、VRAM と訓練時間がトレードオフの関係にあります。
メモリの削減量の方が幅が大きいことや時間を伸ばすことの容易さから、多くの場合では QLoRA の方が好ましいと考えられます。しかし、訓練時間が重要なケースでは LoRA が選択肢に上がり得ます。

出来上がったものの精度

プロンプトとモデルの出力をまとめて、表形式にしたものを載せます。

プロンプト Fine-tuning前 LoRA QLoRA
あなたは誰ですか? 私はELYZAによって訓練されたAIです。
ユーザーからの質問に答えたり、様々なタスクを実行したりすることができます。
このプラットフォームを使用して何をしてみましょうか?
私は、あなたの質問に対する答えを、与えることができる、人間の代わりに、あなたの、質問に対する答えを、受け止めることができる、人工知能の、強化学習の、アプリケーションです。私に、何かを、聞いてください。 私は、この家の隣の家に住んでいる、あの、隣のおじさんです。
名前を教えてもらえますか? はい、エルズです。 nobody-nobody.github.io に興味を持っていただきありがとうございます! わたくしは、あなたのことを、一目でわかる名前で呼びたいのです。 いいえ、教えません。
暇つぶしの方法を教えて 暇つぶしの方法は人によって異なりますが、以下のような方法があります:

1. 映画やテレビを見る : 暇つぶしにおすすめなのは、映画やテレビを見ることです。忙しい日々を送っていると、休みの日に家にいるだけで疲れてしまうことがあります。そんな時は、好きな映画やテレビを見て、気分を切り替えましょう。
2. ゲームをする : ゲームはストレスを発散するのに最適な方法です。スマホに幅広いジャンルのゲームがあるので、誰でもすぐに始めることができます。
3.。
暇つぶしの方法をいくつか考えてみました。

1.自分の髪の毛を切る。
2.自分の髪の毛を抜く。
3.自分の髪の毛を染める。
4.自分の髪の毛を剃る。
5.自分の髪の毛を結び目を作ってまるごと食べる。
6.自分の髪の毛を絡ませてロープにして縄跳びをしてみる。
7.自分の髪の毛を並べて、短冊にして災いを退ける。
8.自分の髪の毛を短く切って、髪の毛を燃やして災いを祓う。
暇つぶしの方法を教えるのは難しいです。まだ自分がその方法を見つけられていないからです。
今日の運勢を占って。 あなたの運勢は今日も好調です。

今日はチャンスが目白押しなので、頑張ってアピールしてみて。

特に夕方は運気が上昇するため、仕事や勉強に集中して取り組むと成果を得られるでしょう。

対人関係では、今日は特に自分自身を表現することが重要です。自分の意見をはっきりと主張することで、周囲との調和を取ることができるでしょう。
一日の中でも弱いものです。 あなたは、今日は周囲の人に対して冷たく接してしまうかもしれません。その人たちも、あなたのことを冷たい人だと思い、その印象が膨れ上がっていく一方でしょう。そのような自分が嫌になり、営業の帰りに立ち寄ったおでん屋で、酔っ払ってしまうこともあるでしょう。それでも、家に帰れば、その日の出来事を嘆きながら、眠ることになるでしょう。
何か、長いお話をしてください。 承知しました。私の知っている長いお話を紹介します。

長い長い昔、世界はひとつの大きな湖だった。その湖には、たくさんの魚が泳いでいた。

ある日のこと、湖の中で一匹の魚が、「私は、もっと大きくなりたい」と思いました。

その魚は、水面から空を見上げました。空はどんな色なのだろう、そして雲はどのように動くのだろう。

その魚は、「私は、もっと大きくなって空を飛びたい」と思いました。

その魚は、湖の岸にいる小動物を見つけました。その小動物は、「私
いいですね、お話を。 それでは、どうぞ、自己紹介から。 私は、孫文です。 孫文、と言っても、あなたのような人には、かなわないものですが。 今日は、孫文という人の、 自己紹介をします。 私の、歴史についての、概要の話を、します。 まず、私は、1875年、18歳の時、医学を学び始めました。 18歳の時、私は、私の父親の、毛沢東の教えに、反発して、逃げ出しました。 そして、蘭州に、医学を学びました。 その後、私は、中 それは、私の、一番、大切な、心の、迷路です。
あなたの故郷について聞かせてください。 はい、私は現在、東京都新宿区にある東京理科大学にて学んでおります。

しかし、私の故郷は中国の北京市で、生まれも育ちも北京です。

中国は大きな国で、北京と東京では気候や文化、食べ物などが大きく異なりますが、共通する部分も多く、故郷という感じがします。

中国には歴史や文化遺産が多く、観光地としても人気です。
故郷では、私のような者は、人を恐れることもない。 人々の顔は、皆、私のような者を、見下しているからである。 私の故郷は、あなたの故郷と同じような、冷たい、暗い、寂しいところです。
新しいパソコンを買おうと思っています。どのような構成がおすすめですか? パソコンの構成のおすすめは、用途に合わせて決めることが大切です。以下は一般的な用途ごとの構成の例ですので、あくまで参考にしてください。

– オフィスでのWordやExcelを主に使う場合: Core i3やi5のプロセッサー、4GB~8GBのメモリー、1TB以上のハードディスク容量のパソコンがおすすめです。
– 動画編集などを目的としたゲーミングPCを検討している場合: 高性能なグラフィックボードと第7世代のインテル Core i7プロセッサー、16GB以上のメモリー、2TB以上のハードディスク容量のパソコン
自分の仕事と、このような新しいパソコンを購入する予算と、予算内でどの程度の性能のパソコンを購入していいのかを考えて、あなたが何をするのに必要なのかを考えて選択してみてください。 パソコンの構成は、使用目的や予算によって変わります。個人の方は、CPUやメモリ、ストレージの容量など、基本的な性能を重視して選ぶことが多いです。ビジネスの方は、安定した動作やセキュリティ、ワークフローの効率を重要視して選ぶことが多いです。
エッセイを書いて 承知しました。
お手元にエッセイのテーマと問いについての記載があると思いますので、それに沿って作成してみてください。

また、作成したエッセイについては、指定されたフォーマットに従って作成してください。
私はあまり自分のことを語るのは得意ではないのですが、もし何かを書くとしたら、自分の葛藤を語ることになると思います。 everybody has a story, みんな葛藤を抱えて生きています。 毎日、書くようにしました。

いくつか目につく点を整理します。

  • 出力はおおむね、日本語として意味の通る物です。ですが、応答としては不適切なものもあります。
    • 例えば、「名前を教えてもらえますか?」の LoRA、「エッセイを書いて」の QLoRA
  • 一部指示に従わないケースがあります。
    • 「名前を教えてもらえますか?」 の QLoRA
      • これはそういった性格を模しているとも解釈でき、ある意味で訓練の成功であるとも評価できます。
  • LoRA と QLoRA を比べた時、目につくような性能の差は無いと思います。

総評して、今回のケースでは、LoRA と QLoRA に目立つ差はありません。かといって両者完璧なわけではなく、指示に従わないケースもあります。

3.6 まとめ

本章では、LoRA に引き続き VRAM 消費量を削減する技術である QLoRA を解説・実践しました。

QLoRA は訓練時のモデル重みを量子化します。それにより、LoRA で新たなボトルネックとなったモデル重みのメモリ使用量が大幅に減少します。その結果、VRAM使用量を実測で \(7\) B モデル時に約 \(7.5\) GB にまで抑えることに成功しました。その上で良い会話精度が出ることを実演することで、一般的な GPU でも十分に Fine-tuning が可能であることを示しました。一方、訓練時間は \(23\) 分から \(28\) 分へ増加しました。QLoRA で訓練したモデルと LoRA で訓練したモデルに、アーキテクチャ上の違いは存在しません。

これらの結果から、LoRA および QLoRA が、限られた計算リソースで LLM のカスタマイズを実現するための非常に効果的かつ実践的な技術であることがわかります。次章では、こうして訓練したモデルを、実運用に耐えうる形でデプロイする技術を見ていきます。


4. TensorRT-LLM の現状

4.1 情報の混乱について

これからは先ほど訓練した LoRA モデルのデプロイに目を当てます(今後、QLoRA で訓練したモデルであっても LoRA モデルと呼びます)。その中で、Triton Inference Server や TensorRT-LLM というライブラリを用います。

ここで、Triton の最新ドキュメント(v 2.60.0)TensorRT-LLMの最新ドキュメント(v1.0.0) を見比べると、LLM のサーバーデプロイの仕方に食い違いがあることがわかります。
具体的には、

  • Triton、TensorRT-LLM github(v1.0.0) それぞれのデプロイについてのドキュメントが、TensorRT-LLM 0.20.0 時代の CLI を前提としている
    • CLI についての説明は 0.20.0 document には存在しますが、 1.0.0 document で削除されました
  • TensorRT-LLM 1.0.0 document を見ても、Triton でデプロイする手段が見つからない
    • 具体的には、.engine ファイルの作成方法がわかりませんでした。
    • auto-deploy という項が該当する可能性がありますが、これは prototype と明記されています

という問題点があります。この混乱はどういったことでしょうか。
実は、TensorRT-LLM は 2025 年の 9 月のリリースで大規模な破壊的変更を受けました。時系列をまとめると、以下のようになります。なお、全て 2025 年の出来事です。

  • 8/1 … TensorRT-LLM 0.21.0 release
  • 8/27 … Triton 2.60.0 release
    • ここで Triton は、TensorRT-LLM 0.21.0 の機能を前提とした LLM のサーバーデプロイ方法をドキュメントにまとめました。
    • また、TensorRT-LLM 0.xx を前提とした公式のブログも多く存在します。
  • 9/24 … TensorRT-LLM 1.0.0 release
    • TensorRT-LLM に破壊的な変更が多数訪れました。release note
      • 伴って、既存の情報の多くが過去のものとなりました。
      • Triton 2.60.0 に書かれている情報も古いものになりました。

TensorRT-LLM 0.21.0 のワークフローに基づいたモデルデプロイは過去のものとなりました。古いモデルへの対応はなされていますが、新しいモデルへの対応はなされなくなりました。

▶(参考)リリースノートの重要な所

TensorRT LLM 1.0 brings 2 major changes: the PyTorch-based architecture is now stable and the default experience

BREAKING CHANGE Promote PyTorch to be the default LLM backend

BREAKING CHANGE Change default backend to PyTorch in trtllm-serve

  • Pytorch バックエンドが主流になったことを意味します。これまでは TensorRT-LLM バックエンドが主流でした。これからは、LLM API を主軸に実装していくことになると思われます。

ここで、本ブログでは旧ワークフローと新ワークフローの両方を紹介します。旧ワークフローでは Triton Inference Server を用い、新ワークフローでは trtllm-serve を用いてサーバーを構築します。

旧ワークフローでは、以下の情報源を参照します。

また、次のコンテナを用います。 nvcr.io/nvidia/tritonserver:25.09-trtllm-python-py3 (released 2025/10/07)

ここで、TensorRT-LLMについて、document と github 上のバージョンが違うことに注意してください。参考までに、執筆時(2025/10/17) において v1.0.0 (released 2025/09/23) が最新版であり、v0.20.0 (released 2025/08/03) は \(1\) つ古いバージョンです。要するに、 TensorRT-LLM document のみ、執筆時点の最新バージョンから \(1\) つ古いものを使っています。

新ワークフローでは、以下の情報源を参照します。

また、次のコンテナを用います。nvcr.io/nvidia/tensorrt-llm/release:gpt-oss-dev
いくつかのドキュメントではこれから行うことについて(gpt-oss特化の物ではなく)最新版であれば良いといったことが書いてありましたが、こちらのコンテナを用いた方が安定しました。

5 章で旧ワークフローを述べ、6 章でそのベンチマークを行います。そして、7 章で新ワークフローを述べ、8 章でそのベンチマークを行います。旧ワークフローでは引き続き ELYZA 7B を用い、ファインチューニングした LLM をデプロイする方法に着目します。新ワークフローでは gpt-oss-20B を用い、最新のモデルをデプロイする方法に着目します。


5. 【旧ワークフロー】Triton Inference Server × TensorRT-LLM で QLoRA モデルをデプロイ

5.1 Triton 概要

Triton は、深層学習モデルを高速にデプロイするためのサーバーです。主に、次のようなワークフローによってデプロイができます。

  • 必要な物
    • Triton の docker イメージ
    • 動かすモデル
      • Triton は TensorFlow、PyTorch、Python、ONNX など多種多様なバックエンドをサポートしています。
      • TensorRT も ok です。
    • Triton のための設定ファイル
  • 手順概要
    1. モデルリポジトリを作成(空のディレクトリを用意するだけで良い)
    2. モデル・設定ファイルを指定された構造で配置
    3. docker・サーバーを起動し、リポジトリとしてモデルリポジトリを指定
      • この時点で自動的にデプロイが完了する
    4. serverとやり取りすることで処理を実行
      • HTTP API, gRPC API, C APIが使える

Triton はモデルのアンサンブルに対応しています。この概念について理解するために、次のようなケースを考えます。

  • 画像を入力に、前処理 \(\to\) モデル \(\to\) 後処理 の流れをサーバーでやって欲しい
  • モデル 1 ~ 3 に入力を入れ、それをアンサンブルした結果を返して欲しい

Tritonはこのような要望にも応えることができます。具体的には、以下のように実現します。

  1. 各種処理を Triton が扱える “モデル” として記述する
  2. Triton が要求する、複数のモデルが関与するワークフローの記述様式にのっとり、処理の流れを記述する

このようにして、最終的に任意の処理をサーバーサイドで実行できます。
LLM の場合に目を向けましょう。LLMの場合は、次のように要望を定式化できます。

  • シーケンスを入力に、前処理 \(\to\) モデル \(\to\) 後処理を繰り返したい。更に、ある条件を満たすまで、出力をモデルに入れ直してほしい(i.e.文章を生成してほしい)

この処理ももちろん Triton で記述できます。しかし、一般に、これらの処理は複雑です。幸いなことに、Triton 側がスケルトンモデルを用意しています。なので、我々は比較的容易に LLM を テキスト生成モデルとしてデプロイできます。

ここまでで、LLM を Triton に渡す必要があると書きました。この時、LLM を TensorRT-LLM に変換してから渡すのが一般的です。これには、以下のメリットがあります。

  • 高速な推論
  • 簡単なデプロイ

5.2 TensorRT-LLM 概要(旧情報)

TensorRT-LLMは、NVIDIA GPU上で最新の大規模言語モデル(LLM)の推論性能を高速化・最適化するための、NVIDIAの包括的なオープンソースライブラリです。

TensorRT-LLMのワークフローは以下の通りです。
参考
参考2

NeMo -------------
                  |
HuggingFace ------
                  |   convert                             build                    load
Modelopt ---------  ----------> TensorRT-LLM Checkpoint --------> TensorRT Engine ------> TensorRT-LLM ModelRunner
                  |
JAX --------------
                  |
DeepSpeed --------
  • モデルの用意 \(\to\) TensorRT-LLMが認識できる形式の Checkpoint に変換 \(\to\) Engine を作成(plan とも呼ばれます) \(\to\) Model Runner で実行

TensorRT との違い

  • 対応モデル
    • TensorRT-LLM は LLM 専用
  • 一般的なワークフロー
    • TensorRT: pytorch -> onnx -> engine
    • TensorRT-LLM : Hugging Face / NeMo -> TRT-LLM Checkpoint -> engine
  • ランタイム
    • TensorRT: .engineの実行
    • TensorRT-LLM: .engine の実行・キャッシュの管理…
  • 前処理・中間処理・後処理の複雑さ
    • TensorRT-LLM は LLM なので、前処理・後処理・中間処理(ex.kv cache)がデフォで複雑
  • 推論実行
    • TensorRT
      • 手軽: (あんまりない?Torch-TensorRT? )
      • 本番: Triton
        • 前処理とかを自前でやるならば、モデルを指定されたディレクトリ構成でまとめるだけなのでとてもお手軽
    • TensorRT-LLM :
      • 手軽: LLM api 参考
      • 本番: Triton / trtllm-serve

これらの知識を元に、次節では実際に訓練したモデルをデプロイしていきます。

5.3 Triton デプロイ

ここからは、LoRA LLM を Triton でデプロイする一連の流れを紹介します。
公式の手順
公式の手順 LoRA
公式の手順 llama
を大いに参考にしています。他の参考資料は適宜示します。
また、TensorRT-LLM バックエンドのモデルを Triton でデプロイする手順には、一般的なモデルを Triton でデプロイする手順とは異なる点があることに注意してください。具体的には、 TensorRT-LLM バックエンドのモデルをデプロイする場合、 Triton コンテナを立ち上げたのち、その中で作業する必要があります。これは、以下のような動機が推測されます。

  • LLM の場合、モデルを分割して実行したい場合がある。

主な流れは以下です。

  1. Checkpoint 変換をする
  2. engine をビルドする
  • ここまでは TensorRT-LLM 単体で実行されます。よって、Tritonとは独立に理解できます。
  1. スケルトンモデルを埋める
  2. LoRA 重みを変換し、所定の場所に置く
  3. サーバーを立ち上げる
  4. サーバーとチャットする

1. Checkpoint 変換をする

  • 概要: TensorRT-LLM が扱えるチェックポイント形式に変換する。
    • 量子化はここあるいは Checkpoint 変換前に行い、以降行わない。
    • 複数 GPU の際の設定もここから始まる(tp size など)。
  • 情報
    • その環境で使えるオプション一覧: python3 /app/examples/llama/convert_checkpoint.py --help
    • モデルごとのガイド: github
    • 公式のパフォーマンスチューニングガイド: github
      • 特別なことはあまり書いていない印象ですが、数値例が載っているので参考になります。また、複数 GPU の時の扱いも書かれています。
  • 説明
    • Checkpoint変換には、モデル固有の checkpoint.py を用います。これは例えば、examples/models/core/xxx に存在します(バージョンによって変化します)。
コマンド例
python3 /app/examples/models/core/llama/convert_checkpoint.py --model_dir /workdir/develop/ELYZA-japanese-Llama-2-7B-instruct/     --output_dir /app/examples/models/core/llama/elyza  --dtype float16     --use_weight_only     --weight_only_precision int4_awq     --int8_kv_cache --calib_size 256  --per_group
  • int4 重みでビルドします。キャリブレーションが発生するため、GPU での計算が生じます。

2. engine をビルドする

  • 概要: engineを作成する
  • 運用時の設定のうち、モデル側のことを設定する
    • シーケンス長
    • サンプリング方法
    • バッチサイズの上限
    • メモリの取り方
  • 情報
    • その環境で使えるオプション一覧: trtllm-build --help
  • 説明
    • エンジン作成には、trtllm-build コマンドを用います。

コマンド例

trtllm-build \
  --checkpoint_dir /app/examples/models/core/llama/elyza \
  --output_dir /engines/elyza \
  --gpt_attention_plugin auto \
  --gemm_plugin auto \
  --max_batch_size 4 \
  --max_input_len 2048 \
  --max_seq_len 4096 \
  --max_beam_width 1 \
  --kv_cache_type paged \
  --lora_plugin auto \
  --max_lora_rank 8 \
  --lora_target_modules attn_q attn_k attn_v attn_dense mlp_h_to_4h mlp_4h_to_h mlp_gate

3. スケルトンモデルを埋める

  • 概要: スケルトンモデルを埋める
  • 運用時の設定のうち、サーバー関連のことを設定する
    • Triton で処理するときの最大バッチ数
    • 起動するモデルインスタンス数
    • バッチ処理のために、最大でどれだけクエリ応答を遅延するか
    • ストリーミング生成をするか
    • LoRA アダプターをサーバー起動時に読み込みするか
  • 情報
  • 説明
    • スケルトンモデルとは、Triton によって用意された、LLM デプロイの為の事前実装です。preprocess や postprocess が、Triton が認識できる形で置かれています。具体的には以下の \(5\) モデルが用意されています。
      • ensemble … 以下のモデルの処理の流れを記述します。
      • preprocessing … 前処理です。
      • postprocessing … 後処理です。
      • tensorrt_llm … 処理のバックエンドです。
      • tensorrt_llm_blstensorrt_llm に BLS を組み合わせたものです。
        • ensemble と殆ど同じように使えます。参考 ただ、こちらの方が設定できる項目が多いです。一先ずは ensemble を用います。
    • スケルトンモデルを埋めるには、fill_template.py を用います。fill_template.py は一度埋めた設定を上書きしません。そのため、やり直す際にはスケルトンモデルごと再度用意してください。
    • 主要な設定項目を挙げます。
      • decoupled_mode … デカップルモードを使用するかを決めます。stream 生成をしたい場合、true に設定する必要があります。
      • kv_cache_free_gpu_mem_fraction…モデルのロード後にKVキャッシュに使用できるGPUメモリの最大割合(0から1の間の数値)を設定します (デフォルト 0.9)
      • gpu_device_ids… このモデルに使用する GPU ID をカンマ区切りで指定します。値にカンマが含まれる場合(例: 0,1)、バックスラッシュでエスケープする必要がある点に注意が必要です (0\,1)
      • lora_prefetch_dir … モデルに事前読み込みしてほしい LoRA アダプターの重みが存在するディレクトリを指定します。ここで重みとは変換後の重みです(後述)。

コマンド例


# スケルトンモデルコピーする
mkdir /elyza_model_repo
cp -r /app/all_models/inflight_batcher_llm/* /elyza_model_repo

# 埋める
ENGINE_DIR=/engines/elyza
TOKENIZER_DIR=/workdir/develop/ELYZA-japanese-Llama-2-7B-instruct
MODEL_FOLDER=/elyza_model_repo
TRITON_MAX_BATCH_SIZE=4
INSTANCE_COUNT=1
MAX_QUEUE_DELAY_MS=0
MAX_QUEUE_SIZE=0
FILL_TEMPLATE_SCRIPT=/app/tools/fill_template.py
DECOUPLED_MODE=false
LOGITS_DATATYPE=TYPE_FP16
LORA_PREFETCH_DIR=/adapter-weights

python3 ${FILL_TEMPLATE_SCRIPT} -i ${MODEL_FOLDER}/ensemble/config.pbtxt triton_max_batch_size:${TRITON_MAX_BATCH_SIZE},logits_datatype:${LOGITS_DATATYPE}
python3 ${FILL_TEMPLATE_SCRIPT} -i ${MODEL_FOLDER}/preprocessing/config.pbtxt tokenizer_dir:${TOKENIZER_DIR},triton_max_batch_size:${TRITON_MAX_BATCH_SIZE},preprocessing_instance_count:${INSTANCE_COUNT}
python3 ${FILL_TEMPLATE_SCRIPT} -i ${MODEL_FOLDER}/tensorrt_llm/config.pbtxt triton_backend:tensorrtllm,triton_max_batch_size:${TRITON_MAX_BATCH_SIZE},decoupled_mode:${DECOUPLED_MODE},engine_dir:${ENGINE_DIR},max_queue_delay_microseconds:${MAX_QUEUE_DELAY_MS},batching_strategy:inflight_fused_batching,max_queue_size:${MAX_QUEUE_SIZE},encoder_input_features_data_type:TYPE_FP16,logits_datatype:${LOGITS_DATATYPE},lora_prefetch_dir:${LORA_PREFETCH_DIR}
python3 ${FILL_TEMPLATE_SCRIPT} -i ${MODEL_FOLDER}/postprocessing/config.pbtxt tokenizer_dir:${TOKENIZER_DIR},triton_max_batch_size:${TRITON_MAX_BATCH_SIZE},postprocessing_instance_count:${INSTANCE_COUNT}
python3 ${FILL_TEMPLATE_SCRIPT} -i ${MODEL_FOLDER}/tensorrt_llm_bls/config.pbtxt triton_max_batch_size:${TRITON_MAX_BATCH_SIZE},decoupled_mode:${DECOUPLED_MODE},bls_instance_count:${INSTANCE_COUNT},logits_datatype:${LOGITS_DATATYPE}

4. LoRA 重みを変換し、所定の場所に置く

  • 概要: LoRA重みを変換し、所定のフォルダ構成を作成する
  • LoRA重みを変換する
    • LoRA重みを保存するときのデータ型程度しか選択肢がない
  • 情報
    • その環境で使えるオプション一覧: hf_lora_convert.py --help
      • オプションは少ないです。
  • 説明
    • エンジン変換には、hf_lora_convert.py を用います。

変換コマンド例

python3 /app/examples/hf_lora_convert.py -i /workdir/develop/osamu-adapter/checkpoint-111  -o /adapter-weights/0 --storage-type float16
  • ディレクトリ構成について
    • 変換した重みは、所定のディレクトリ構造で配置する必要がある
/モデルディレクトリ
├── バージョン番号(整数)
│   ├── モデル重みファイル
│   └── モデルコンフィグファイル
├── バージョン番号(整数)
│   ├── モデル重みファイル
│   └── モデルコンフィグファイル
├── バージョン番号(整数)
    ├── モデル重みファイル
   └── モデルコンフィグファイル
/adapter-weights
├── 0
│   ├── model.lora_config.npy
│   └── model.lora_weights.npy
├── 1
│   ├── model.lora_config.npy
│   └── model.lora_weights.npy
└── 3
    ├── model.lora_config.npy
    └── model.lora_weights.npy
  • ここで、場所をステップ 3(スケルトンモデルを埋める)で指定する際には、モデルディレクトリ(例でのadapteer-weights)を指定する。

5. サーバーを起動する

  • 概要: サーバーを起動します
  • 情報
    • その環境で使えるオプション一覧: launch_triton_server.py --help
      • world_size …使用するGPUの個数
  • 説明
    • サーバー起動には launch_triton_server.py を使用します。

コマンド例

python3 /app/scripts/launch_triton_server.py --world_size=1 --model_repo=/elyza_model_repo

次のように記述することで、より詳しい起動ログを見ることができます。

TLLM_LOG_LEVEL=DEBUG python3 /app/scripts/launch_triton_server.py --world_size=1 --model_repo=/elyza_model_repo

6. サーバーとチャットする

ペイロードの例

loraを使わない場合

    payload = {
        "text_input": formatted_prompt,
        "max_tokens": max_tokens,
        "bad_words": "",  # 使用しない場合は空文字列
        "stop_words": "",  # 使用しない場合は空文字列
    }

loraを使う場合

    payload = {
        "text_input": formatted_prompt,
        "max_tokens": max_tokens,
        "bad_words": "",  # 使用しない場合は空文字列
        "stop_words": "",  # 使用しない場合は空文字列
        "lora_task_id":0,
    }

サーバーとのチャットコードの例は付録に記載しております。

text_input について、本ブログの設定では、チャットテンプレートを適用した状態の文字列を入れる必要があることに注意してください。

5.4 Triton デプロイ ストリーミング形式

本章では、ストリーミング形式で LLM をデプロイする方法をまとめます。ストリーミング形式は最初のトークンが送られてくるまでの時間を大幅に短縮し、ユーザー体験を大きく改善します。今後の内容は本章を読まずとも理解できるため、飛ばしても構いません。

inflight_batcher_llm_client.py とは、 /app/client 下にあるスクリプトで、クライアント側の実装の参考です。このクライアントにはストリーミング生成の場合も含まれています(python3 inflight_batcher_llm_client.py --help で情報を見ることができます)。

今回、ストリーミング形式の場合のまとまった情報を用意することが時間的に厳しく、叶いませんでした。よってここでは inflight_batcher_llm_client.py を元に成功例を述べるに留めます。

参考までに、有望と思われる情報源を記載しておきます。
Triton Client のライブラリ概要
grpc形式のapi(今回使用)
lang chainを用いたより簡潔な実装?(古め)

必要なライブラリ:

pip install tritonclient[http,grpc]

処理の流れは以下です。

  1. tritonclient の grpcclient の求める形式に合うように入力を整形する
  2. ストリーミング生成の為の準備をする。これにはデータが送られるごとに終了判定を行ったり、トークンのデコード作業をすることが含まれる。

ストリーミングコード例は付録に記載します。参考に、ストリーミングコード例を動かした結果を記載します。

You: あなたは誰ですか?
Bot: はい、質問者の方にお答えします。
私はELYZAによって訓練された生成AIです。
質問や要望があれば、何なりとお申し付けください。

----------------------------------------
最初のトークンまでの時間 (TTFT): 0.0154 sec
生成速度 (全体): 83 tokens / 0.50 sec = 167.38 tokens/sec
TTFT速度向上: 32.13 倍 (非ストリーミング合計時間 / ストリーミング TTFT)
----------------------------------------
You: ジャガイモの上手な育て方を教えて
Bot: ジャガイモを上手に育てるための方法をお教えします。

ジャガイモは酸素が必要なので、苗を植える際は多少の隙間をあけて植えましょう。また、水やりは酸素を含んである程度の水はけができるようなものを使い、根は直接地面に触れないようにします。

また、ジャガイモは室内で育てる場合は、鉢の中に水分を保持するための砂やスポンジを入れることで水やりの回数を減らすことができます。

----------------------------------------
最初のトークンまでの時間 (TTFT): 0.0169 sec
生成速度 (全体): 211 tokens / 1.26 sec = 167.79 tokens/sec
TTFT速度向上: 74.48 倍 (非ストリーミング合計時間 / ストリーミング TTFT)
----------------------------------------
You: あなたは優秀な競技プログラマーです。次の問題を解いてください。# maaany moving points長さ $L$ メートルの数直線上に 大きさの無視できる$N$ 個の点 $点1, \dots , 点N$ があります。時刻 $0$ では、これらの点は全て数直線の左端 ($x = 0$) に置かれています。時刻 $0$ より、これらの点は数直線上でそれぞれ独立に等速往復運動をします。点 $i(i = 1, \dots, N)$ の速度は $i$ メートル毎秒です。最初、点は座標が大きくなる方に動きます。そして、点が数直線の両端($x = 0 または x = L$) に達すると、点は動く向きを反転して運動を続けます。ここで、点 $i, j$ のすれ違いを時刻 $t$ と座標 $x$ とのタプル $(i, j, t, x)$ であって、点 $i, j$ が時刻 $t$ に共に座標 $x$ に存在する事で定義します。時刻 $(0, T]$ において、点のすれ違いが何回発生するかを求めてください。## 制約- $1 \le N \le 10 ^ 9$- $1 \le T \le 10 ^ 9$- $1 \le L \le 3 \times 10 ^ 5$
Bot: 与えられた問題に回答いたします。

まず、与えられた条件を元に、すれ違いの発生回数を計算するプログラムを以下に記述します。

import numpy as np

# すれ違いの回数を求める
def count_collisions(N, T, L):
   count = 0
   for i in range(N):
       for j in range(i, N):
           if (i, j, T, L) in collisions:
               count += 1
   return count

# すれ違いのタプルを列挙
def collisions(N, T, L):
   for i in range(N):
       for j in range(i, N):
           if (i, j, T, L) in []:
               yield (i, j, T, L)
   return

# すれ違いのタプルを列挙
collisions = collisions(N, T, L)

# すれ違いの回数を求める
count = 0
for i in range(N):
   count += len(collisions) - count

print(count)

このプログラムでは、すれ違いのタプルを列挙する `collisions` 関数と、すれ違いの回数を求める `count_collisions` 関数を定義しています。

`collisions` 関数では、すれ違いのタプルを列挙するために、与えられた条件を元にforループを回しています。

`count_collisions` 関数では、すれ違いの回数を求めるために、与えられた条件を元にforループを回しています。

このプログラムを実行
----------------------------------------
最初のトークンまでの時間 (TTFT): 0.0119 sec
生成速度 (全体): 512 tokens / 3.21 sec = 159.31 tokens/sec
TTFT速度向上: 269.56 倍 (非ストリーミング合計時間 / ストリーミング TTFT)
----------------------------------------

このように、最初のトークンまでの時間が \(\approx 0.0125sec\) と非常に短いという結果が得られました(入力長がやや長いケースでも短いのは想定外でした)。非ストリーミング生成と比べ、最初のトークンまでの時間が著しく改善されていることがわかります。

ここで \(1\) つ注意を述べておきます。TTFT がこのように非常に高速なのは、サーバーがアイドル状態である場合に限られることが多いです。参考に、concurrency \(= 1, 2\) でそれぞれベンチマーク結果を記載しておきます(付録のコードを少し変更するとベンチマークが可能です)。

## concurrency = 1

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓
┃                            Statistic ┃      avg ┃    min ┃      max ┃      p99 ┃      p90 ┃      p75 ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩
│             Time To First Token (ms) │    14.00 │  12.34 │    17.17 │    16.47 │    14.78 │    14.37 │
│            Time To Second Token (ms) │     6.09 │   4.72 │     7.63 │     7.28 │     6.39 │     6.24 │
│                 Request Latency (ms) │ 1,398.62 │  20.44 │ 1,576.10 │ 1,575.74 │ 1,572.64 │ 1,568.92 │
│             Inter Token Latency (ms) │     5.83 │   4.60 │     6.88 │     6.88 │     6.53 │     6.10 │
│     Output Token Throughput Per User │   173.18 │ 145.32 │   217.57 │   214.24 │   197.23 │   185.37 │
│                    (tokens/sec/user) │          │        │          │          │          │          │
│      Output Sequence Length (tokens) │   240.00 │   1.00 │   338.00 │   330.97 │   298.20 │   287.25 │
│       Input Sequence Length (tokens) │   512.08 │ 512.00 │   513.00 │   513.00 │   512.00 │   512.00 │
│ Output Token Throughput (tokens/sec) │   171.29 │    N/A │      N/A │      N/A │      N/A │      N/A │
│         Request Throughput (per sec) │     0.71 │    N/A │      N/A │      N/A │      N/A │      N/A │
│                Request Count (count) │    38.00 │    N/A │      N/A │      N/A │      N/A │      N/A │
└──────────────────────────────────────┴──────────┴────────┴──────────┴──────────┴──────────┴──────────┘

## concurrency = 2

┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━┓
┃                            Statistic ┃      avg ┃      min ┃      max ┃      p99 ┃      p90 ┃      p75 ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━┩
│             Time To First Token (ms) │ 1,364.90 │    20.01 │ 1,589.75 │ 1,587.57 │ 1,579.71 │ 1,575.92 │
│            Time To Second Token (ms) │     5.97 │     5.58 │     6.25 │     6.25 │     6.16 │     6.06 │
│                 Request Latency (ms) │ 2,745.78 │ 1,575.77 │ 3,134.24 │ 3,133.77 │ 3,129.25 │ 3,123.37 │
│             Inter Token Latency (ms) │     5.81 │     4.59 │     6.91 │     6.88 │     6.50 │     6.06 │
│     Output Token Throughput Per User │   173.70 │   144.64 │   217.99 │   215.55 │   197.30 │   186.66 │
│                    (tokens/sec/user) │          │          │          │          │          │          │
│      Output Sequence Length (tokens) │   240.00 │     1.00 │   338.00 │   330.97 │   298.20 │   287.25 │
│       Input Sequence Length (tokens) │   512.08 │   512.00 │   513.00 │   513.00 │   512.00 │   512.00 │
│ Output Token Throughput (tokens/sec) │   172.09 │      N/A │      N/A │      N/A │      N/A │      N/A │
│         Request Throughput (per sec) │     0.72 │      N/A │      N/A │      N/A │      N/A │      N/A │
│                Request Count (count) │    38.00 │      N/A │      N/A │      N/A │      N/A │      N/A │
└──────────────────────────────────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘

両者を比べると、TTFT に大きな差がある一方、TTST や Output Token Throughput には差が無い事が確認できます。

また、デコーディング時の注意について述べます。

ストリーミング生成では、トークンが (もしかしたらある程度の塊で)次々と送られてきます。ここで、特に日本語の場合、\(1\) 文字が \(2\) つ以上のトークンから構成される場合があります。つまり、送られてくるトークンを愚直にデコードするような実装だと、文字化けを起こす可能性が高いです。

コード例では、TextStreamer という外部ライブラリにデコードを委託することでこの問題を解決しています。

最後に、ベンチマーク時の注意について述べます。 ストリーミング生成の例コードでは、いつもと異なるサーバーの指定の仕方をしています(tensorrt_llm を指定している)。そして、これを直接ベンチマークする方法がわかりませんでした。
tensorrt_llm_bls および ensemble は、ストリーミング生成設定でベンチマークをすることができました。しかしその場合、ensemble の方が tensorrt_llm_bls より \(2\) 倍程度高速という結果が出ます。そもそも、ensemble がストリーミングに対応していることが明示的に書かれていない気がします。
実測では、今回デプロイしたものの速度は、ensemble をベンチマーク(genai-perf)で計測したときのデータに近かったです。

5.5 Triton デプロイ トラブルシューティング

Tritonを用いたデプロイの中で、筆者が遭遇したトラブルと、その解決方法を記述します。

Tutorial に記載されたファイルが存在しない

  • 原因
    • バージョンごとにファイル構成が頻繁に変化する
  • 解決法
    • 頑張って探す

サーバーの起動に失敗し、起動ログも何を言っているのかよくわからない

  • 原因
    • fill_template.py を用いた設定において、ENGINE_DIR を間違えた時、起動ログが何を言っているのかわからず困る
  • 解決法
    • ENGINE_DIR を間違えない

fill_template.pyの変更が反映されない

  • 原因
    • fill_template.py は上書きしない
  • 解決法
    • スケルトンモデルを再度準備する。

LoRA重みが事前読み込みされない

  • 原因
    • 起動ログより確認できる。よくわからず。同様の問題が報告されている。URL 自分の場合は LoRA 関係を全てやり直したら通った。LoRA の中身のデータ型に問題があるかも
  • 解決法
    • なるべくデータ型を合わせるようにやり直す

起動時エラー: map::at()

  • 原因
    • lora_target_modules を設定する場面には 訓練時・trtllm-build 時の \(2\) 箇所があるが、そこで指定するターゲットモジュールの個数が合わないとエラーを吐く
      • 例えば、atten_qkv のような省略形の指定方法があるが、こういったものを使うと合わなくなるということ
  • 解決法
    • 指定を合わせる
    • 訓練: target_modules=["q_proj", "k_proj", "v_proj", "o_proj","gate_proj", "up_proj", "down_proj"],
    • ビルド: --lora_target_modules attn_q attn_k attn_v attn_dense mlp_h_to_4h mlp_4h_to_h mlp_gate

起動時エラー: std::bad_cast()

  • 原因
    • dtype を設定する場面には Checkpoint変換時・trtllm-build・LoRA重み変換時・fill_template などあるが、そこでデータ型があっていないとエラーを吐く
  • 解決法
    • 全部あわせる(fp16)などに
    • Checkpoint: --dtype float16
    • ビルド: --lora_plugin auto \
    • LoRA 重み変換時: --storage-type float16
    • fill_template: LOGITS_DATATYPE=TYPE_FP16

最大バッチサイズがデフォルトの 4 になる?

  • 概要
    • 起動ログより| tensorrtllm | ... | {"cmdline":{..."default-max-batch-size":"4"}} ... | のような表示
    • TRTGptModel maxBatchSize は 16 であり、どちらが優先かわからない
  • 解決策
    • curl http://localhost:8000/v2/models/tensorrt_llm/config
    • curl http://localhost:8000/v2/models/tensorrt_llm/metrics
      • これらによるチェック上は、大丈夫そう
    • ベンチマーク上では、デフォルトも書き換えた方が安定した。
  • デフォルトも変更する解決策
    • launch_triton_server.py にハードコーディングする。210 行目あたりで
    • 変更前
    cmd += [
    '--disable-auto-complete-config',
    f'--backend-config=python,shm-region-prefix-name=prefix{i}_',
    ]
    • 変更後
    cmd += [
    '--disable-auto-complete-config',
    f'--backend-config=python,shm-region-prefix-name=prefix{i}_',
    # ↓ この行を直接書き込んだ(ハードコーディングした)
    '--backend-config=tensorrtllm,default-max-batch-size=16',
    ]

5.6 まとめ

本章では、Fine-tuning した LLM を Triton Inference Server を用いてデプロイしました。

デプロイの為に、まず TensorRT-LLM を用いて、モデルを変換しました。そののち、Triton の為の設定を行いました。ここでは、LoRA に関する設定項目も触れました。さらに、ユーザーの使用感の向上に大きく寄与するストリーミング生成についても触れました。

次章では、ここで建てたサーバーについてベンチマークを行い、デプロイ時の設定を考察します。


6. 【旧ワークフロー】ベンチマーク

6.1 ベンチマークの基礎知識について

LLM でのベンチマークでは、まず Token Per Second(TPS) が重要視されます。これは 1 秒間当たりのトークン数であり、使用感を表す最も重要な指標です。

加えて、ストリーミング生成ができるようなモデルでは、 Time To First Token(TTFT) も重要視されます。これは、最初のトークンが来るまでの秒数であり、こちらも使用感を表す重要な指標です。

ここで、TPS の計測は、非ストリーミングとストリーミングで次のように分かれることが多いです。

\[\text{TPS (非ストリーミング)} := \frac{\text {出力したトークン数}} {\text{すべての出力が終わるまでの秒数}}\]

\[\text{TPS (ストリーミング)} := \frac{\text {出力したトークン数}} {\text{すべての出力が終わるまでの秒数 – TTFT}}\]

TPS について、ストリーミングの場合は最初の出力が発生してからに注目していることがわかります。これは、LLM の推論プロセスが、 2 つの性質が異なるフェーズに分解できることを踏まえると理解ができます。

  1. Prefill Phase … 入力されたプロンプトを一度に並列処理するフェーズ。これにかかる時間が TTFT です。また、比較的高速です。
    • より正確には、プロンプトに対する KV cache を生成するフェーズ。
  2. Decode Phase … \(1\) つずつトークンを順番に生成していくフェーズ。

つまり、TTFT を引くことで、入力長による結果への影響を軽減できるという背景があるのです。
また、ベンチマークに直接関係することではありませんが、LLM には次の性質があります。

  • 入力する文字列が長くなるにつれ、計算が重くなっていく

これらを踏まえると、ベンチマークを行い、その結果を比較するうえで次のことに注意すべきだとわかります。

  1. streaming と 非 streaming を明確に区別する
  2. (入力長)と(出力長)をそれぞれ合わせる

ほか、計測したいバッチサイズに比べデータセットの件数が少なすぎたり、あるいはプロンプトが短すぎたりするとベンチマークが不正確になる可能性が高まるので注意してください。

また、ライブラリによっては、TPS はあくまで非ストリーミング時の定義の物を扱い、 Inter Token Latency(ITL) でDecode phase を計測します。より詳しいガイドについては、こちらのブログこちらのブログ などを参考にしてください。

今後、ベンチマークツールとして genai-perf をよく用います。各 metrics の説明が公式ドキュメントに書かれています。 URL

▶ 説明の日本語訳
メトリクス (Metric) 日本語訳 説明 (Description) 集計 (Aggregations)
Time to First Token (TTFT) 初回トークン生成時間 リクエスト送信から最初の応答(最初のトークン)受信までの時間。ベンチマーク中のリクエストごとに1つの値。 Avg, min, max, p99, p90, p75
Time to Second Token (TTST) 二番目トークン生成時間 最初のストリーミング応答受信から、二番目のストリーミング応答受信までの時間。ベンチマーク中のリクエストごとに1つの値。 Avg, min, max, p99, p90, p75
Inter Token Latency (ITL) トークン間レイテンシ 単一のリクエストにおける中間応答間の時間を、後続の応答で生成されたトークン数で割った値。応答ごとに1つの値。 Avg, min, max, p99, p90, p75
Output Token Throughput Per User ユーザーあたり出力トークンスループット 全出力トークン数(最初のトークンを除く)を、各リクエストの生成フェーズの総時間で割った値。 Avg, min, max, p99, p90, p75
Request Latency リクエスト総レイテンシ リクエスト送信から最終応答(全トークン)受信までの時間。ベンチマーク中のリクエストごとに1つの値。 Avg, min, max, p99, p90, p75
Output Sequence Length 出力シーケンス長 リクエストによって生成された出力トークンの総数。ベンチマーク中のリクエストごとに1つの値。 Avg, min, max, p99, p90, p75
Input Sequence Length 入力シーケンス長 リクエストに含まれる入力トークンの総数。ベンチマーク中のリクエストごとに1つの値。 Avg, min, max, p99, p90, p75
Output Token Throughput 出力トークン総スループット ベンチマーク中に生成された全出力トークン数を、ベンチマーク実行の総時間で割った値。 なし – ベンチマークごとに1つの値
Request Throughput リクエスト総スループット ベンチマーク中に完了した最終応答の数を、ベンチマーク実行の総時間で割った値。 なし – ベンチマークごとに1つの値

ドキュメントによると、genai-perf は、上記の二つの TPS を共に計測するそうです。非ストリーミング時の TPS の定義に対応するものが Output Token Throughput, ストリーミング時の TPS の定義に対応するものが Output Token Throughput Per User であるとわかります。

6.2 ベンチマーク概要 使用ツールの紹介

遂に、Fine-tuning した LLM をデプロイすることができました。
これからは、構築したサーバーのベンチマークを行います。使用ツールは genai-perf です。

使用コード例

genai-perf profile -m ensemble --tokenizer ELYZA/ELYZA-japanese-Llama-2-7B-instruct 
genai-perf profile   -m tensorrt_llm_bls   --backend tensorrtllm   --endpoint-type kserve   -u localhost:8001   --tokenizer /workspace/ELYZA-japanese-Llama-2-7B-instruct   --input-file /workspace/triton-lora/prompts.jsonl    --extra-inputs max_tokens:256   --extra-inputs lora_task_id:0

本ブログでは、次の 4 項目の影響を計測します。

  1. max_batch_size
    • max_batch_size を変えた時の影響を見ます。
  2. max_input_len(max_seq_len)
    • モデルが許容する max_input_len・max_seq_len を変えた時の影響を見ます。
  3. input_len/output_len
    • ベンチマークで用いるデータの長さを変えた時の影響を見ます。
  4. vLLM vs Triton Inference Server
    • vLLM を用いて作成したサーバーと Triton Inference Server を用いて作成したサーバーの速度を比べます。

逆に、モデルの精度と実行時間の関係などは見ません。ベンチマークで主に用いたスクリプトは付録として記載します。

ベンチマーク中に出てくる用語

concurrency … 一度にサーバーに送られるリクエストの数。concurrency 人が同時にサーバーを利用している状況に相当する。

6.3 max_batch_size

max_batch_size を変化させたときの、LLM サーバーのレイテンシの比較。両者とも、concurrency が max_batch_size と一致するまでは凡そ水平で、そこからは線形にレイテンシが上昇している。
max_batch_size を変化させたときの、LLM サーバーのスループットの比較。両者とも、concurrency が max_batch_size と一致するまでは凡そ線形で、そこからはなだらかに低下する。

結果より、次のことが読み取れます。

  • レイテンシについて
    • concurrency <= max_batch_size の間は緩やかに上昇するか、変化しない
    • concurrency > max_batch_size の間はほぼ線形に上昇する
  • スループットについて
    • concurrency <= max_batch_size の間はほぼ線形に上昇する
    • concurrency > max_batch_size となると増加は止まり、寧ろ減少傾向を見せる

また、max_batch_size = 4, 16 を比べた結果、どちらでも max_batch_size = 16 が良い結果を出しています。これより、メモリが許す限り max_batch_size を増やした方が良いと結論付けられます。

6.4 max_input_len(max_seq_len)

最大入力長・最大出力長を変更したときの LLM サーバーのスループットの比較。最大の設定に依存せず、ほとんど一致している。

結果より、次のことが読み取れます。

  • レイテンシ・スループット共に、max_input_len・max_seq_len による大きな変化は、無いように見えます。

これより、速度面での max_input_len・max_seq_len による大きな変化はなさそうです。よって、メモリが許す限り、max_input_len・max_seq_len は寛容にとって良く、そこまで敏感にならなくて良いと結論付けられます。

6.5 input_len / output_len

入力長・出力長を変更したときの LLM サーバーのレイテンシの比較。入力長 + 出力長が増加するにつれ、レイテンシが顕著に増大する。

入力長・出力長を変更したときの LLM サーバーのスループットの比較。入力長 + 出力長が増加するにつれ、スループットが低下する。

結果より、次のことが読み取れます。

  • レイテンシについて
    • In, Out が増加するに従い増加する
      • (レイテンシ) / ((In + Out)**2) は一定ではない(0.37, 0.0055, 0.0097)
  • スループットについて
    • (In + Out) が増加するにつれ値が小さくなっていく
      • これは、Transformer が 入力長が増えるほど計算量が増えることに起因します。この性質により、Token Per Second (TPS) を計測する際などは、Input_length + Output_length に気を付ける必要があります。

これはおおむね感覚通りの結果だと思います。

6.6 vLLM vs Triton Inference Server

4 bit 量子化したモデルでベンチマークを行います。vLLM について、genai-perf を用いたベンチマーク手段がわからなかったため、vLLM 標準と思われるベンチマーク方法をとりました。vLLM のサーバー起動コマンド・ベンチマークコマンドは付録に記載します。vLLM では Output token throughput (tok/s): 813.81 を、Triton では Output Token Throughput (tokens/sec),"1,156.97" の項を比較しました。

vLLM と Triton Inference Server の比較。 Triton Inference Server の方がスループットが高い。
結果より、次のことが読み取れます。

  • スループットについて
    • Triton Inference Server の方が \(1.5\) 倍程度高速

これは、この設定下で今回デプロイした Triton Inference Server が十分高速であり、vllm 等他社オプションに対してスループット面で優位性を持つことを示唆します。

6.7 まとめ

本章では、デプロイしたサーバーに対してベンチマークを行いました。そして、それぞれの metrics に対し、サーバーのレイテンシ・スループットを解析することで、パラメータ調整の感覚を養いました。また、Triton Inference Server が他デプロイオプションに対し十分な競争力を持つことを示しました。


7. 【新ワークフロー】TensorRT-LLM を用いたデプロイ

7.1 TensorRT-LLM 概要(新情報)

TensorRT-LLMは、NVIDIA GPU上で最新の大規模言語モデル(LLM)の推論性能を高速化・最適化するための、NVIDIAの包括的なオープンソースライブラリです。

TensorRT-LLMのワークフローは以下の通りです。
参考

NeMo -------------
                  |
HuggingFace ------
                  |   load                                                 
Modelopt ---------  ----------> LLM API or Server (trtllm-serve)
                  |
JAX --------------
                  |
DeepSpeed --------

TensorRT との違い

  • 対応モデル
    • TensorRT-LLM は LLM 専用
  • 一般的なワークフロー
    • TensorRT: pytorch -> onnx -> engine
    • TensorRT-LLM : hugging face / nemo -> LLM_API or Server
  • ランタイム
    • TensorRT: .engineの実行
    • TensorRT-LLM: .engineの実行・キャッシュの管理…
  • 前処理・中間処理・後処理の複雑さ
    • TensorRT-LLM は LLM なので、前処理・後処理・中間処理(ex.kv cache)がデフォで複雑
  • 推論実行
    • TensorRT
      • 手軽: (あんまりない?Torch-TensorRT? )
      • 本番: Triton
        • 前処理とかを自前でやるならば、モデルを指定されたディレクトリ構成でまとめるだけなのでとてもお手軽
    • TensorRT-LLM :
      • 手軽: LLM api 参考
      • 本番: trtllm-serve

これらの知識を元に、次節では実際に訓練したモデルをデプロイしていきます。

7.2 TensorRT-LLM デプロイ

ここからは、LLM を TensorRT-LLM でデプロイする一連の流れを紹介します。
TensorRT-LLM を用いたデプロイでは、先ほども述べた通り、モデルをいきなりサーバーやAPIに渡します。よって、実践的には次のようなワークフローとなります。

  1. モデル取得
  2. モデルの最適化。量子化など。例えばNVIDIA ModelOpt を使用できる
  3. サーバー構築

本ブログでは既に量子化がなされた gpt-oss-20b を用いるので、2のフェーズは行いません。モデルの量子化や最適化は、TensorRT-LLM とは独立に行えます。

参考にする情報源は以下です。

quick start guide   trtllm-serveについて   gpt-oss-120Bの高速なデプロイ

サーバー構築

次のスクリプトを用意しました。gpt-oss-120Bの高速なデプロイ によると、使用しているGPU が H100 や H200 である場合、それぞれ異なった設定があるそうです。

model_path=/gpt-oss
extra_llm_api_file=/tmp/extra-llm-api-config.yml

cat << EOF > ${extra_llm_api_file}
enable_attention_dp: false
cuda_graph_config:
    max_batch_size: 256
    enable_padding: true
moe_config:
  backend: TRITON
EOF

trtllm-serve serve ${model_path} \
    --max_batch_size 256 \
    --max_seq_len 32768 \
    --max_num_tokens 262144 \
    --kv_cache_free_gpu_memory_fraction 0.9 \
    --trust_remote_code \
    --extra_llm_api_options ${extra_llm_api_file}
  • cuda_graph_config は、バッチサイズのうち、最適化がかかるバッチサイズの最大を 256 に設定しています。
  • max_seq_len は 1ユーザー当たりのシーケンス長の最大です。
  • max_num_tokens はバッチ含めたトークンの最大数です。
  • 今回、これらの設定は、バッチサイズ 256 、入出力がそれぞれ 512, 256 の設定でベンチマークを行うということを念頭に、それらが収まるように設定しました。また、デフォルト設定ではベンチマークサイズに収まらないこと、その状態でベンチマークを行った結果結果が安定せず、合わせた場合と比べ\(\frac{1}{2}\) の性能しか出ない等、著しい結果の低下がみられました。よって、使用状況に合わせ、オプションを設定することが大切です。なお、今回のベンチマーク設定においては、max_seq_len は過剰です。

さらなるオプションを見たい場合 trtllm-serve serve --help を調べてください。 めぼしいオプションとして例えば以下があります。

  • max_batch_size
  • max_num_tokens
  • max_seq_len
  • tp_size
  • pp_size
  • ep_size
  • kv_cache_free_gpu_memory_fraction

ここで注意点を述べておきます。異なるコンテナで作業したとき、 backend: TRITON を入れるとエラーが出てしまったため、このオプションを外して作業していました。しかし、このオプションを入れた場合と比べ、TPS が \(\frac{1}{2}\) 程度しか出ていませんでした。

また、サーバーの起動を中途半端に停止した場合、次回以降の実行でフリーズする場合がありました。その場合、

rm -rf ~/.cache/flashinfer

と打つことで解決しました。
これにて、サーバーの構築が完了しました。旧ワークフローと比べ、非常に簡素です。

最後に、有用な情報源を紹介します。TRTLLM document の Featurres 欄には、様々なユースケースに応じて、開発者が行うべきこと・その時 extra_llm_api_options に記述するべきことなどが書かれています。例えば LoRA や KV Chache に関することが載っています。

7.3 まとめ

本章では、新たなワークフローを用いて、最新のモデルである gpt-oss-20b を用いたサーバーを構築しました。

新たなワークフローは旧ワークフローと比べ非常に簡素です。また、最適化の余地として、NVIDIA Model Optimizer を紹介しました。

次章では、ここで建てたサーバーについてベンチマークを行い、デプロイ時の設定を考察します。


8. 【新ワークフロー】ベンチマーク

今回も、genai-perf を用いてベンチマークを行いました。

8.1 サーバーの速度

ベンチマークに用いたスクリプトは付録に記載します。結果が以下です。なお、input = 512, output = 256 の設定です。

trtllm-serve で構築したサーバーに対し、concurrency を変化させたときのスループットの計測結果。上段は concurrency <= 32 に限った図であり、下段は concurrency <= 256 まで含めた図。ユーザーごとのスループットが比較的高い事、全体のスループットが良く伸びていることがわかる。

上の図が concurrency <= 32 の時の結果であり、下の図が concurrency <= 256 まで含めた結果です。結果から、以下のことが読み取れます。

  • (TTFTを分母から引く) TPS の意味で、concurrency = 1 の時 \(231\), 引かない方の TPS の意味で \(212\) という高スループットが出ている。なお、TPS はおおむね 30 ~ 50 出ていれば快適と言われます。
  • concurrency が増えるにしたがって、一人当たりのスループットは低下していく
  • 全体的にグラフがなめらかである。これは、最適化の範囲に収まっていることを意味する
  • また、Total Throughput の意味で、さらに多くの concurrency にスケーリングできそうな見た目をしている

8.2 vLLM vs. TensorRT-LLM

vLLM でもサーバーを立て、速度を検証します。ここでも、genai-perfを用いたベンチマーク手法がわからなかったため、vllm bench serve を用います(より詳細には、ベンチマーク自体は実行できましたが、出力長を256に固定する方法がわからず、毎回短くなってしまいました)。

結果が以下です。なお、ベンチマークで得られた metrics のうち、Per User Throughputの計算には ITL を、 Total Throughput の計算には Output token throughput を用いました。

vllm で構築したサーバーに対し、concurrency を変化させたときのスループットの計測結果。trtllm-serve の時と同様の形をしている。

TensorRT-LLMと比較するとこのようになります。

trtllm-serve と vllm を比較した図。Per User Throughput では TRT-LLM が、 TTFT では vLLM が一貫して上回っている。総合的に、レイテンシでは目立つ差がない。

結果から、次のことが読み取れます。

  • user throughput の観点で、TRT-LLMが一貫して上回っている
  • TTFT の観点で、vLLM が一貫して上回っている
  • 結果として、リクエストに対するレイテンシや、Total Output Throughput には差が出ていない

また、今回、使用メモリについて調査しませんでした。同等の機能を少ないメモリで実現可能なフレームワークが存在するならば、そちらを選択するのが良いです。

8.3 まとめ

本章では、新たなワークフローを用いてデプロイしたサーバーに対してベンチマークを行いました。そして、TensorRT-LLM と vLLM を比較し、両者にメリットがあり、今回の設定では総合的に見て同等であることを見ました。

9. 今後の展望

今後の調査方向として有望なものをいくつか書きます。

  • 精度による速度・メモリの割合
    • 本ブログでは、精度ごとにサーバーの速度測定をしませんでした。これは、精度が下がるごとに速度が速くなるのは当たり前で、取るべきは accuracy とのバランスだが、題材的に accuracy の測定が困難であるからです。
  • chat履歴を覚えるエージェントとしてのデプロイ
    • 本ブログで作ったサーバーはステートレスです。つまり個々のチャット履歴を覚えません。これは、LLM と対話を行いたいというシチュエーションでは不便なものです。なお、私が見た限りでは、Triton・TensorRT-LLM 側はステートレスな応答のみを対応しています。
  • 複数 GPU 環境での運用
    • サーバーとして複数の GPU が使える場合について触れませんでした。より大きなモデル・潤沢な計算資源を活用したい場合、新たな調査が必要になります。
  • unsloth 等、洗練された Fine-tuning ライブラリの使用
    • unsloth というライブラリが、Fine-tuning におけるメモリ効率や速度の観点から評判です。Triton Inference Server は Fine-tuning のライブラリを指定しないため、本ブログの内容と合わせて使用することができます。
  • 他の LLM デプロイサーバーの調査
    • LLM のデプロイはまだ新しい技術であり、方法が定まったものではありません。たとえ NVIDIA という一つの企業であっても、LLM をデプロイするサーバーを動かす方法が現状 \(3\) つあります( trtllm-serve, Triton Inference Server, NVIDIA Dynamo )。開発者はこれらの発展に対応していかなければなりません。
      • 特に、NVIDIA Dynamo は、Triton Inference Server の後継であるという公式の声明が出ています参考。可能性としては、いずれ開発者は Dynamo をインターフェースとしてサーバーを立てるようになるというシナリオもあり得ます。その時の最新情報を注意深く調べて作業を進めることを勧めます。


10. おわりに

LLM を QLoRA で Fine-tuning し、Triton Inference Server や TensorRT-LLM を用いてデプロイする手順を紹介しました。 Fixstars では、通年でインターンシップを募集しています。 高専生、大学生、大学院生の皆さん、Fixstars でのインターンシップで新しい技術に触れませんか? インターンシップの詳細は こちら をご覧ください


11. 参考文献

LoRA
QLoRA
LIMA
Llama2
ZeRO
勾配チェックポインティング


12. 検証環境

デスクトップ PC からサーバーに ssh 接続し、サーバー上で作業しました。
サーバースペック

  • CPU: EPYC 7742 (64C/128T) x2
  • Memory: 2TB
  • GPU: NVIDIA H100 80GB PCIe x4
  • Disk: 960GB SATA RAID1 + 3.8TB RAID1 (3.84TB U.2 SSDx2)
  • OS: Ubuntu22.04

使用 LLM: ELYZA 7B instruct, gpt-oss-20B

Fine-tuning

  • docker container を用いました。ベースイメージとして nvcr.io/nvidia/pytorch:25.09-py3 を用いました。
  • 追加ライブラリ
# Hugging Face ecosystem libraries
transformers==4.57.1
datasets==4.4.1
peft==0.17.1
trl==0.25.0
accelerate==1.11.0

# Dependencies often required by the libraries above
bitsandbytes==0.48.2
einops==0.8.1
scipy==1.16.1

Triton Inference Server

  • docker container を用いました。ベースイメージとして nvcr.io/nvidia/tritonserver:25.09-trtllm-python-py3 を使用しました。
  • 追加ライブラリ
genai-perf==0.0.16
tritonclient==2.62.0

vLLM

  • 仮想環境を用いました。
vllm==0.11.0

TensorRT-LLM

  • docker container を用いました。ベースイメージとして nvcr.io/nvidia/tensorrt-llm/release:gpt-oss-dev を使用しました。
  • 追加ライブラリ
genai-perf==0.0.16


13. 付録

▶ LoRA Fine-tuning コード
from transformers import (
    AutoModelForCausalLM,
    BitsAndBytesConfig 
)
from peft import LoraConfig
from peft import prepare_model_for_kbit_training, get_peft_model
from trl import SFTTrainer, SFTConfig
import datasets
import torch
import pynvml
import logging


DEFAULT_SYSTEM_PROMPT = ""
model_name = "/workdir/develop/ELYZA-japanese-Llama-2-7B-instruct"
dataset_name = "/workdir/develop/osamu_dataset.json"

lora_config = LoraConfig(
    task_type="CAUSAL_LM",
    inference_mode=False,
    r=4,
    lora_alpha=8,
    lora_dropout=0.1,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    bias="none",
)

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

ds = datasets.load_dataset("json", data_files=dataset_name)
logging.info("データセットを読み込みました: %s", ds)

def format_for_prompt_completion(examples):
    prompts = []
    completions = []
    for i in range(len(examples["instruction"])):
        prompt = f"<s>[INST] <<SYS>> {DEFAULT_SYSTEM_PROMPT} <</SYS>> {examples['instruction'][i]} [/INST]"
        completion = f" {examples['output'][i]} </s>"
        prompts.append(prompt)
        completions.append(completion)
    return {"prompt": prompts, "completion": completions}

ds_prompt_completion = ds.map(
    format_for_prompt_completion, batched=True, remove_columns=ds["train"].column_names
)["train"]
ds_split = ds_prompt_completion.train_test_split(test_size=0.2, seed=42)
ds_train = ds_split["train"]
ds_val = ds_split["test"]
print(ds_train[0])

logging.info("データセットのフォーマットが完了しました。")

training_args = SFTConfig(
    output_dir="/workdir/develop/osamu-adapter",
    num_train_epochs=3,
    per_device_train_batch_size=1,
    gradient_accumulation_steps=32,
    learning_rate=5e-5,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=25,
    save_strategy="steps",
    save_steps=25,
    bf16=True,
    report_to="none",
    lr_scheduler_type="cosine",
    warmup_steps=5,
    load_best_model_at_end=True,
    metric_for_best_model="eval_loss",
    completion_only_loss=True,
    gradient_checkpointing=True,
    optim="paged_adamw_8bit",
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    peft_config=lora_config,
    train_dataset=ds_train,
    eval_dataset=ds_val,
)
print("訓練設定を表示します")
print(trainer.args)

print("モデルへの入力が正しいかデバッグします")
tokenizer_for_decoding = trainer.tokenizer
train_dataloader = trainer.get_train_dataloader()
first_batch = next(iter(train_dataloader))
print("\n--- データローダーの最初のバッチ ---")
print(first_batch)

# 4. input_idsとlabelsをデコードして比較する
for i in range(len(first_batch["input_ids"])):
    print(f"\n--- サンプル {i+1} ---")
    inputs = tokenizer_for_decoding.decode(
        first_batch["input_ids"][i], skip_special_tokens=False
    )
    print(f"【モデルが見る全文】:\n{inputs}")
    labels_to_decode = [
        token_id if token_id != -100 else tokenizer_for_decoding.pad_token_id
        for token_id in first_batch["labels"][i]
    ]
    labels = tokenizer_for_decoding.decode(labels_to_decode, skip_special_tokens=False)
    print(f"【モデルが学習する部分】:\n{labels}")
logging.info("訓練を開始します")
trainer.train()
logging.info("訓練が終了しました")


▶ QLoRA chatコード
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig, pipeline
from peft import PeftModel
import readline


model_name = "/workdir/develop/ELYZA-japanese-Llama-2-7B-instruct"
adapter_path = "/workdir/develop/osamu-adapter/checkpoint-111"
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=quantization_config,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
model = PeftModel.from_pretrained(model, adapter_path)

pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)

print("モデルの準備ができました。チャットを開始します。")

# チャットループ
while True:
    instruction = input("あなた: ")
    if instruction.lower() in ["exit", "quit"]:
        break

    # プロンプトの整形
    prompt = f"<s>[INST]<<SYS>><</SYS>>  {instruction} [/INST]"

    outputs = pipe(
        prompt,
        max_new_tokens=256,
        do_sample=True,
        temperature=0.7,
        top_p=0.9,
        pad_token_id=tokenizer.pad_token_id,
    )
    
    # レスポンス部分だけを抜き出して表示
    response = outputs[0]["generated_text"].split("[/INST]")[-1].strip()
    print(f"モデル: {response}")
▶ サーバーとのチャットコード
import requests
import json
import time
from transformers import AutoTokenizer

# --- 設定項目 ---
# Tritonサーバーのホストとポートを指定します。
# Dockerコンテナ内でこのスクリプトを実行する場合、通常は 'localhost' で問題ありません。
TRITON_HOST = "localhost"
TRITON_PORT = "8000"  # HTTPサービスがリッスンしているポート

# 使用するモデル名を指定します。
# 提供された情報から、'ensemble' モデルが使用されていると判断しました。
MODEL_NAME = "ensemble"

# genai-perfで使用したトークナイザーのパスを指定します。
TOKENIZER_PATH = "/workdir/develop/ELYZA-japanese-Llama-2-7B-instruct"


# --- 設定はここまで ---

def talk_to_triton_model(prompt: str, max_tokens: int = 256):
    """
    Triton Inference Serverにリクエストを送信し、モデルからの応答を取得します。

    Args:
        prompt (str): モデルに送信するテキスト(プロンプト)。
        max_tokens (int): 生成させたい最大トークン数。

    Returns:
        str: モデルが生成したテキスト。エラーの場合はNone。
    """
    # エンドポイントのURLを構築
    url = f"http://{TRITON_HOST}:{TRITON_PORT}/v2/models/{MODEL_NAME}/generate"

    # Llama 2 Instructionモデルの公式プロンプト形式を適用
    formatted_prompt = f"<s>[INST] <<SYS>>\nあなたは誠実で優秀な日本人のアシスタントです。\n<</SYS>>\n\n{prompt} [/INST]"

    # Tritonサーバーに送信するデータ(ペイロード)を作成
    payload = {
        "text_input": formatted_prompt,
        "max_tokens": max_tokens,
        "bad_words": "",  # 使用しない場合は空文字列
        "stop_words": "",  # 使用しない場合は空文字列
        "lora_task_id":0,
    }

    try:
        # HTTP POSTリクエストを送信
        response = requests.post(url, json=payload)
        response.raise_for_status()
        result = response.json()
        return result.get("text_output", "エラー: レスポンスに 'text_output' が見つかりません。")

    except requests.exceptions.RequestException as e:
        print(f"\nエラー: Tritonサーバーへの接続に失敗しました。")
        print(f"   詳細: {e}")
        return None
    except json.JSONDecodeError:
        print(f"\nエラー: サーバーからのレスポンスがJSON形式ではありません。")
        print(f"   受信したテキスト: {response.text}")
        return None


if __name__ == "__main__":
    print("--- Triton LLM 対話クライアント ---")


    # パフォーマンス測定のためにトークナイザーを読み込む
    try:
        tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_PATH)
        print(f"トークナイザーを読み込みました: {TOKENIZER_PATH}")
    except Exception as e:
        print(f"トークナイザーの読み込みに失敗しました。性能測定は行われません。")
        print(f"   詳細: {e}")
        tokenizer = None

    print("チャットを開始します。終了するには '終了' または 'exit' と入力してください。")
    print("-" * 50)

    while True:
        # ユーザーからの入力を受け付ける
        input_prompt = input(" あなた: ")

        # 終了コマンドのチェック
        if input_prompt.lower() in ["終了", "exit"]:
            print(" チャットを終了します。")
            break

     
        # 処理時間の計測を開始
        start_time = time.time()
     
        # モデルとの対話を実行
        response_text = talk_to_triton_model(prompt=input_prompt, max_tokens=512)


        # 処理時間の計測を終了
        end_time = time.time()


        if response_text:
            # モデルの応答からプロンプト部分([/INST]より前)を削除
            inst_marker = "[/INST]"
            marker_position = response_text.find(inst_marker)

            if marker_position != -1:
                cleaned_response = response_text[marker_position + len(inst_marker):]
            else:
                cleaned_response = response_text

            print(f" モデル: {cleaned_response.strip()}")


            duration = end_time - start_time
            if tokenizer and cleaned_response:
                # 生成された部分だけのトークン数を計算
                num_tokens = len(tokenizer.encode(cleaned_response.strip()))
                tps = num_tokens / duration if duration > 0 else 0
                print(f" 性能: {num_tokens} tokens / {duration:.2f} 秒 = {tps:.2f} tokens/sec")


        else:
            # 接続エラーなどが発生した場合、ループを抜ける
            break
        print() # 改行して見やすくする
▶ ストリーミング生成コード
import sys
import queue
import time
from functools import partial
import numpy as np
import tritonclient.grpc as grpcclient
from transformers import AutoTokenizer, TextStreamer
from tritonclient.utils import InferenceServerException, np_to_triton_dtype
import torch

# -------------------------------------------------
# 1. 設定値 (ユーザー指定)
# -------------------------------------------------
TOKENIZER_DIR = "/workdir/develop/ELYZA-japanese-Llama-2-7B-instruct"
REQUEST_OUTPUT_LEN = 512
USE_STREAMING = True
CHAT_TEMPLATE_PREFIX = "<s>[INST] <<SYS>><</SYS>>"
CHAT_TEMPLATE_SUFFIX = " [/INST]"

# -------------------------------------------------
# 2. 設定値 (Triton/モデル)
# -------------------------------------------------
TRITON_URL = "localhost:8001"
MODEL_NAME = "tensorrt_llm"
BEAM_WIDTH = 1
TOP_K = 1
TOP_P = 0.0
TEMPERATURE = 1.0
# -------------------------------------------------


class UserData:
    """
    ストリーミングコールバック間でデータを保持するためのクラス。
    Tritonからのレスポンス(またはエラー)をキューに格納し、
    生成された総トークン数をカウントします。
    """
    def __init__(self):
        self._completed_requests = queue.Queue()
        self.token_count = 0
        self.first_token_time = None # (追加) 最初のトークンを受信した時刻

def prepare_tensor(name, input_data):
    """
    Numpy配列をTritonのInferInputオブジェクトに変換するヘルパー関数。
    """
    t = grpcclient.InferInput(name, input_data.shape,
                              np_to_triton_dtype(input_data.dtype))
    t.set_data_from_numpy(input_data)
    return t

def callback(user_data, streamer, result, error):
    """
    Tritonからストリーミングレスポンスを受信したときに呼び出されるコールバック関数。
    """
    if error:
        user_data._completed_requests.put(error)
        try:
            streamer.end()
        except:
            pass
    else:
        user_data._completed_requests.put(result)
        if USE_STREAMING:
            seq_len_info = result.as_numpy('sequence_length')
            if seq_len_info is not None and seq_len_info[0][0] == 0:
                try:
                    streamer.end()
                except:
                    pass
                return

            output_ids = result.as_numpy('output_ids')
            if output_ids is not None:
                tokens_list = output_ids[0][0]
                
                if tokens_list.size > 0:
                    if user_data.first_token_time is None:
                        user_data.first_token_time = time.perf_counter()

                    user_data.token_count += tokens_list.size
                    token_tensor = torch.from_numpy(tokens_list.copy()).unsqueeze(0)
                    streamer.put(token_tensor)

def initialize_tokenizer(tokenizer_dir):
    """
    トークナイザーを読み込み、pad_tokenとend_tokenのIDを取得します。
    """
    print(f"1. トークナイザーを読み込んでいます... (from: {tokenizer_dir})")
    try:
        tokenizer = AutoTokenizer.from_pretrained(tokenizer_dir,
                                                  legacy=False,
                                                  padding_side='left',
                                                  trust_remote_code=True)
    except Exception as e:
        print(f"エラー: トークナイザーディレクトリ '{tokenizer_dir}' の読み込みに失敗しました。")
        print(f"詳細: {e}")
        sys.exit(1)

    if not tokenizer.pad_token:
        tokenizer.pad_token = tokenizer.eos_token

    pad_id = tokenizer.encode(tokenizer.pad_token, add_special_tokens=False)[0]
    end_id = tokenizer.encode(tokenizer.eos_token, add_special_tokens=False)[0]
    
    return tokenizer, pad_id, end_id

def build_constant_tensors(pad_id, end_id):
    """
    リクエストごとに変わらないTritonへの入力テンソルをNumpy配列として構築します。
    """
    print("2. 固定入力テンソルを構築しています...")
    return {
        "request_output_len": np.array([[REQUEST_OUTPUT_LEN]], dtype=np.int32),
        "streaming": np.array([[USE_STREAMING]], dtype=bool),
        "beam_width": np.array([[BEAM_WIDTH]], dtype=np.int32),
        "runtime_top_k": np.array([[TOP_K]], dtype=np.int32),
        "runtime_top_p": np.array([[TOP_P]], dtype=np.float32),
        "temperature": np.array([[TEMPERATURE]], dtype=np.float32),
        "end_id": np.array([[end_id]], dtype=np.int32),
        "pad_id": np.array([[pad_id]], dtype=np.int32),
    }

def run_chat_interface(triton_client, tokenizer, constant_tensors):
    """
    ユーザーからの入力を受け付け、Tritonにリクエストを送信し、
    ストリーミングで結果を表示するメインの対話ループ。
    """
    print("-" * 40)
    print("Tritonに接続しました。会話を開始します。")
    print("プロンプトを入力してください (終了するには 'exit' または 'quit')")
    print("(例: こんにちは)")
    print("-" * 40)
    try:
        while True:
            user_text = input("You: ")
            if user_text.lower() in ["exit", "quit"]:
                break
            
            if not user_text.strip():
                continue

            # 1. チャットテンプレートを適用
            full_prompt = f"{CHAT_TEMPLATE_PREFIX}{user_text}{CHAT_TEMPLATE_SUFFIX}"

            # 2. ストリーミング用ヘルパーを初期化
            streamer = TextStreamer(tokenizer,
                                      skip_prompt=True,
                                      clean_up_tokenization_spaces=True)
            user_data = UserData() # (変更) ここで UserData が初期化されます
            stream_callback = partial(callback, user_data, streamer)

            # 3. プロンプトをエンコード
            input_ids = [tokenizer.encode(full_prompt)]
            input_ids_data = np.array(input_ids, dtype=np.int32)
            input_lengths_data = np.array([[len(input_ids[0])]], dtype=np.int32)

            # 4. Tritonへの入力テンソルを準備
            inputs = [
                prepare_tensor("input_ids", input_ids_data),
                prepare_tensor("input_lengths", input_lengths_data),
                prepare_tensor("request_output_len", constant_tensors["request_output_len"]),
                prepare_tensor("beam_width", constant_tensors["beam_width"]),
                prepare_tensor("temperature", constant_tensors["temperature"]),
                prepare_tensor("streaming", constant_tensors["streaming"]),
                prepare_tensor("end_id", constant_tensors["end_id"]),
                prepare_tensor("pad_id", constant_tensors["pad_id"]),
                prepare_tensor("runtime_top_k", constant_tensors["runtime_top_k"]),
                prepare_tensor("runtime_top_p", constant_tensors["runtime_top_p"]),
            ]
            
            print("Bot: ", end="", flush=True)
            start_time = time.perf_counter() # (変更なし) リクエスト送信開始時刻
            
            # 5. ストリーミング推論リクエストを非同期で開始
            triton_client.start_stream(callback=stream_callback)
            triton_client.async_stream_infer(MODEL_NAME, inputs)
            triton_client.stop_stream() # リクエスト送信完了

            # 6. コールバックからの結果をキュー経由で待機
            while True:
                try:
                    result = user_data._completed_requests.get(block=False)
                except queue.Empty:
                    break
                if isinstance(result, InferenceServerException):
                    print(f"\nストリーミングエラー受信: {result}")
                    break
            
            # 7. 性能統計を表示
            end_time = time.perf_counter() # (変更なし) 全体終了時刻
            total_time = end_time - start_time
            total_tokens = user_data.token_count

            print() # 改行

            print("-" * 40)
            if user_data.first_token_time:
                ttft = user_data.first_token_time - start_time
                print(f"最初のトークンまでの時間 (TTFT): {ttft:.4f} sec")
            else:
                print("TTFT: (トークンが生成されませんでした)")

            # (変更) 全体速度の表示
            if total_time > 0 and total_tokens > 0:
                tokens_per_sec = total_tokens / total_time
                print(f"生成速度 (全体): {total_tokens} tokens / {total_time:.2f} sec = {tokens_per_sec:.2f} tokens/sec")
            else:
                print(f"生成完了 (生成トークン: {total_tokens})")
            
            if ttft > 0 and total_time > 0:
                speedup = total_time / ttft
                print(f"TTFT速度向上: {speedup:.2f} 倍 (非ストリーミング合計時間 / ストリーミング TTFT)")
        


            print("-" * 40)

    except KeyboardInterrupt:
        print("\n(Ctrl+C) 中断しました。")
    except Exception as e:
        print(f"\nチャットループ中に予期せぬエラーが発生しました: {e}")

def main():
    """
    スクリプトのメインエントリーポイント。
    """
    
    # 1. トークナイザーと関連IDを初期化
    tokenizer, pad_id, end_id = initialize_tokenizer(TOKENIZER_DIR)
    
    # 2. 変わることのない入力テンソルを事前に構築
    constant_tensors = build_constant_tensors(pad_id, end_id)

    triton_client = None
    try:
        # 3. Tritonサーバーに接続
        triton_client = grpcclient.InferenceServerClient(url=TRITON_URL)
        
        # 4. 対話インターフェースを開始
        run_chat_interface(triton_client, tokenizer, constant_tensors)

    except Exception as e:
        print(f"クライアントの初期化または実行中にエラーが発生しました: {e}")
        if "Connect Failed" in str(e):
            print(f"エラー: Tritonサーバー ({TRITON_URL}) に接続できませんでした。")
            print("Tritonサーバーが起動しているか確認してください。")
            
    finally:
        # 5. クライアントをクリーンアップ
        if triton_client is not None:
            print("\nTritonクライアントを閉じています...")
            triton_client.close()
            print("クライアントを閉じました。")
            del triton_client

if __name__ == "__main__":
    main()
▶ 旧ベンチマークで用いたスクリプト
#!/bin/bash

# --- 共通設定 ---
# ----------------------------------------------------
# モデル名
MODEL_NAME="ensemble"
# トークナイザーのパス
TOKENIZER_PATH="/workdir/develop/ELYZA-japanese-Llama-2-7B-instruct"
# TritonサーバーのURL
TRITON_URL="localhost:8001"
# 各テストの測定時間(ミリ秒単位)。60秒 = 60000
MEASUREMENT_INTERVAL=60000
# 結果を保存するメインディレクトリ
OUTPUT_DIR_BASE="benchmark_results_$(date +%Y%m%d_%H%M%S)"

# --- 実行 ---
# ----------------------------------------------------
echo "ベンチマークを開始します。結果は '$OUTPUT_DIR_BASE' ディレクトリ以下に保存されます。"

# =================================================================================
# シナリオ1: 負荷(同時実行数)とユースケース(入出力長)の組み合わせ
# =================================================================================
echo ""
echo  [シナリオ1] 負荷(同時実行数)とユースケース(入出力長)の組み合わせをテストします..."
OUTPUT_DIR_SCENARIO1="${OUTPUT_DIR_BASE}/scenario1_concurrency_io"
mkdir -p "$OUTPUT_DIR_SCENARIO1"

# テストする同時実行クライアント数の配列
CONCURRENCY_LEVELS=(1 4 8 16 32 64)
# テストする入出力トークン長の組み合わせを定義 (入力長,出力長)
declare -a IO_PAIRS
IO_PAIRS[0]="512,256"     # Summarization

for concurrency in "${CONCURRENCY_LEVELS[@]}"; do
  for pair in "${IO_PAIRS[@]}"; do
    # 入力長と出力長を分解
    ISL="${pair%,*}"
    OSL="${pair#*,}"

    echo "  [実行中] Concurrency: $concurrency, Input-Tokens: $ISL, Output-Tokens: $OSL"
    
    # ファイル名を設定 (パスを含めない)
    FILENAME="c${concurrency}_isl${ISL}_osl${OSL}.json"

    genai-perf profile -m "$MODEL_NAME" -u "$TRITON_URL" --tokenizer "$TOKENIZER_PATH" \
      --concurrency "$concurrency" \
      --synthetic-input-tokens-mean "$ISL" \
      --output-tokens-mean "$OSL" \
      -p "$MEASUREMENT_INTERVAL" \
      --artifact-dir "$OUTPUT_DIR_SCENARIO1" \
      --profile-export-file "$FILENAME" 
  done
done
echo " [シナリオ1] 完了"


echo ""
echo " すべてのベンチマークが完了しました。"
▶ vllm serve と bench(ELYZA)

サーブ

root@44b59629601e:/# python -m vllm.entrypoints.openai.api_server \
  --model "dahara1/ELYZA-japanese-Llama-2-7B-fast-instruct-GPTQ" \
  --quantization gptq \
  --host 0.0.0.0 \
  --port 8000 \
  --trust-remote-code \
  --max-num-seqs 16 # <- 最大バッチサイズ = 16
  

ベンチマーク

vllm bench serve \
  --backend openai \
  --model "dahara1/ELYZA-japanese-Llama-2-7B-fast-instruct-GPTQ" \
  --dataset-name random \
  --ignore-eos \
  --max-concurrency 16 \ # <- concurrency = 16
  --num-prompts 400 \
  --random-input-len 512 \ # <- 入力長 = 512 
  --random-output-len 256 \ # <- 出力長 = 256
  --base-url http://localhost:8000 \
  --endpoint /v1/completions
▶ 新ベンチマークで用いたスクリプト
#!/bin/bash

# エラーが発生したら停止する場合(必要に応じてコメントアウトを外してください)
# set -e

# 設定値
INPUT_TOKENS=512
CONCURRENCIES=(1 2 4 8 16 32 64 128 256)

echo "Starting benchmark loop..."

for c in "${CONCURRENCIES[@]}"; do
    echo "=================================================="
    echo "Running: Concurrency = $c, Input Tokens = $INPUT_TOKENS"
    echo "=================================================="

    genai-perf profile \
       --model gpt-oss \
       --url localhost:8000 \
       --endpoint-type chat \
       --num-prompts 2560 \
       --concurrency $c \
       --tokenizer /gpt-oss \
       --tokenizer-trust-remote-code \
       --streaming \
       --output-tokens-mean 256 \
       --output-tokens-stddev 0 \
       --synthetic-input-tokens-mean $INPUT_TOKENS \
       --synthetic-input-tokens-stddev 0 \
       --random-seed 42 \
       --artifact-dir "artifacts_input${INPUT_TOKENS}_conc${c}"

    echo "Done with concurrency $c"
    echo ""
done

echo "All benchmarks completed."
▶ vllm serve と bench(gpt-oss-20B)

serve

python3 -m vllm.entrypoints.openai.api_server \
  --model /gpt-oss \
  --served-model-name gpt-oss-20B \
  --tensor-parallel-size 1 \
  --port 8000

bench

#!/bin/bash

# 設定
MODEL_NAME="gpt-oss-20B"
TOKENIZER_PATH="/gpt-oss"
PORT=8000
INPUT_LEN=512
OUTPUT_LEN=256
LOG_FILE="benchmark_results_v2.log"



echo "Benchmark started at $(date)" > "$LOG_FILE"

for CONCURRENCY in 1 2 4 8 16 32 64 128 256; do
    # プロンプト数を動的に計算 (例: 並列1なら10回、並列256なら2560回)
    # 最低でも50回は回すように設定
    NUM_PROMPTS=$(( CONCURRENCY * 10 ))
    if [ "$NUM_PROMPTS" -lt 50 ]; then
        NUM_PROMPTS=50
    fi

    echo "============================================" | tee -a "$LOG_FILE"
    echo " Concurrency: $CONCURRENCY (Total Prompts: $NUM_PROMPTS)" | tee -a "$LOG_FILE"
    echo "============================================" | tee -a "$LOG_FILE"

    vllm bench serve \
        --model "$MODEL_NAME" \
        --tokenizer "$TOKENIZER_PATH" \
        --dataset-name random \
        --random-input-len "$INPUT_LEN" \
        --random-output-len "$OUTPUT_LEN" \
        --port "$PORT" \
        --request-rate inf \
        --max-concurrency "$CONCURRENCY" \
        --num-prompts "$NUM_PROMPTS" \
        >> "$LOG_FILE" 2>&1

    echo "Finished Concurrency: $CONCURRENCY"
    echo "" >> "$LOG_FILE"
    
    # クールダウン
    sleep 2
done

echo "All benchmarks completed. Results saved to $LOG_FILE"

About Author

nozomi.kojima

Leave a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください

Recent Comments

Social Media