Nemotron 3 Embed 8Bを16GB×2枚で動かす|NVLinkの無いRTX 5080+5060 Tiではテンソル並列よりパイプライン並列が速かった

vLLMに関する記事のアイキャッチ画像 - Nemotron 3 Embed 8Bを16GB×2枚で動かす GPU・グラフィックボード

この記事の要点

Nemotron-3-Embed-8B-BF16 は、vLLM (WSL2) で重みを丸ごと GPU 1枚に載せると実用にならず、2枚に分けるとテンソル並列でもパイプライン並列でも動いた。NVLink も GPU 間の直接転送も無いこの2枚では、パイプライン並列のほうが速く、速い GPU に多くの層を割り振るとさらに速くなった。

NVLink の無い2枚なら、パイプライン並列から試し、速い GPU に多くの層を割り当てて (VLLM_PP_LAYER_PARTITION)、手元の文書で測り直す。NVLink や GPU 間の直接転送 (P2P) が使える環境は測っていないので、その場合はテンソル並列も並べて測る。

GPU 1枚と CPU オフロード (重みの一部をメインメモリへ逃がす) の組み合わせは、RTX 5080 ではテンソル並列を上回ったがパイプライン並列には届かず、外付け接続の RTX 5060 Ti ではテンソル並列より遅かった (GPU の速さと接続のどちらが影響したかは分けていない)。

NVLinkの無い16GB×2枚なら、パイプライン並列を先に試す

Nemotron-3-Embed-8B-BF16 は、NVIDIA のモデルカードによると約 8B パラメータの埋め込みモデルで、入力ごとに 4096 次元のベクトルを出す。重みだけで約 15GiB あり、この記事の検証環境の vLLM 0.30.0 (WSL2) では、重みを丸ごと載せる VRAM 16GB の GPU 1枚では実用にならなかった。起動前の空き容量の確認で止まるか、起動しても30分待って埋め込みが終わらないか、終わっても2枚に分けたときの20分の1以下の速さだった。重みの一部をメインメモリへ逃がせば、1枚でも動いた (「1枚+CPUオフロード」の節)。

GPU 2枚に分けると、テンソル並列 (TP、各層の計算を2枚で分担する) でも、パイプライン並列 (PP、モデルを層の前半と後半に分けて別々の GPU に置く) でも動いた。この構成では PP のほうが TP より速く、速い GPU に多くの層を割り振ったときが、試した構成の中では最も速かった。vLLM の公式ドキュメントも、1枚の GPU に入らないモデルを同じノードの複数 GPU で動かすなら TP を使うとしたうえで、GPU に NVLink が無い場合は、スループットを上げて通信の負担を下げるために TP ではなく PP を使うよう案内している。

測ったのは RTX 5080 (内蔵の PCIe 5.0 x16) と RTX 5060 Ti (OCuLink の外付けで PCIe 4.0 x4) の2枚で、VRAM はどちらも 16GB。Windows 11 の WSL2 上で vLLM 0.30.0 を動かした。

この2枚の間では、GPU 間の直接転送 (P2P) も使えなかった (torch.cuda.can_device_access_peer(0, 1) が False)。ただし P2P が使えることと NVLink があることは別の話で、PCIe 経由で P2P が使えても NVLink は無い構成もある。PP を先に試す根拠は NVLink が無いことで、P2P の可否は、TP と比べるときの通信条件として控えておけばよい。P2P が使える環境は測っていない。

このモデルの概要と、1B モデルや NVFP4 版を含めた1枚での実測は、先に公開したNemotron 3 EmbedをVRAM 16GBで動かすにまとめている。この記事は 8B モデルを2枚に分けて動かす場合に絞り、起動の仕方と、どんな条件で失敗したかを掘り下げる。TP・PP・CPU オフロードを切り替えて使える起動スクリプトは「起動の仕方」の節に載せている。

3つの分け方と、1枚では実用にならなかった理由

1枚に載らないモデルを動かす方法として、この記事では次の3つを試した。表の「公式ドキュメントの案内」は、vLLM の公式ドキュメントの記述をまとめたもの。

方法 モデルの置き方 GPU 間・CPU との受け渡し 公式ドキュメントの案内
テンソル並列 (TP) 各層の計算を2枚で分担する 2枚の結果を足し合わせる処理 (all-reduce) に GPU 間の通信を使う 1枚に入らず、同じノードの複数 GPU に入るなら TP を使う
パイプライン並列 (PP) 層を前半と後半に分けて2枚に置く 層の境目で、前半の GPU の出力を後半の GPU へ渡す モデルを層で分割し、uneven splits にも対応する。GPU に NVLink が無い場合は TP の代わりに PP を使う
CPU オフロード GPU は1枚で、重みの一部をメインメモリに置く 推論のたびに、メインメモリにある重みを GPU へ運ぶ cpu_offload_gb で GPU 1枚あたり何 GiB をメインメモリへ逃がすかを指定する (既定は 0 で、逃がさない)。CPU と GPU の間の速い接続が要る

公式ドキュメントにある uneven splits は、同じページの「Edge case: uneven GPU splits」という見出しの下で、GPU の枚数でモデルの層数が割り切れない場合の話として出てくる。性能の違う GPU に合わせて層を配分する指針ではない、と読める。

そもそも2枚目の GPU を足すかどうかから考えたい場合は、AI用途にGPUは2枚必要かで別に検討している。

1枚では実用にならなかった理由

TP の起動ログでは、GPU ごとに 7.51GiB、合わせて約 15GiB の重みが載った。VRAM 16GB の1枚にこれを丸ごと載せようとすると、次の結果になった。gpu_memory_utilization は、vLLM が起動時に確保しようとする GPU メモリの割合を指定する引数。

GPU と設定 結果
RTX 5080・gpu_memory_utilization 0.97 起動前の空き容量の確認で止まった (空き 14.6GiB に対し要求 15.44GiB)
RTX 5080・0.90 重み 14.94GiB を読み込んで起動した。2回測って、1回は起動から30分待っても埋め込みが終わらず、もう1回は終わったが毎秒148トークンだった
RTX 5060 Ti・0.97 起動前の空き容量の確認で止まった (空き 14.8GiB に対し要求 15.45GiB)
RTX 5060 Ti・0.92 重み 14.94GiB を読み込んで起動したが、3回測って3回とも、起動から30分待っても埋め込みが終わらなかった

gpu_memory_utilization を 0.97 にすると、どちらの GPU でも起動前の空き容量の確認で止まった。画面表示を兼ねる RTX 5080 は空きが 14.6GiB、画面表示をしていない RTX 5060 Ti でも 14.8GiB で、要求に届かなかった。値を下げる (RTX 5080 は 0.90、RTX 5060 Ti は 0.92) と、どちらの GPU も起動まではしたものの、512 トークンの文書 256 件の埋め込みは、RTX 5060 Ti では30分待っても終わらなかった。RTX 5080 では2回のうち1回が同じく終わらず、終わったもう1回も 883 秒かかった。毎秒148トークンで、2枚に分けた TP (毎秒3,321) の約22分の1の速さになる。起動した直後の VRAM は、GPU 全体で RTX 5080 が 15,947 MiB、RTX 5060 Ti が 15,858 MiB と、どちらも容量の上限に張り付いていた。終わらなかった理由と、RTX 5080 で回によって結果が分かれた理由は確かめていない。いずれも1台の検証環境での結果になる。

Windows では WSL2 の上で動かす

vLLM のインストールのページには、vLLM は Windows にネイティブには対応しておらず、Windows で使うには WSL などを使う、と書かれている。この記事でも Windows 11 の上の WSL2 (Ubuntu) で動かした。

モデルカードに書かれていること

モデルカードによると、このモデルの最大系列長は 32768 トークン、ライセンスは OpenMDW-1.1 で商用利用ができる。対応 OS は Linux とされている。vLLM の LLM.embed を使う例では、query: と passage: の接頭辞を自分で付けるよう案内している。モデルカードで vLLM の検証に挙がっているのは 0.21.0・0.24.0・0.25.0 と NVIDIA の vLLM コンテナ (26.06) で、この記事で使った 0.30.0 は含まれない。0.30.0 でも動くかどうかは、後の「出力は分け方で変わるか、vLLM のバージョンで変わるか」の節で確かめた。

実測: 分け方ごとの速さ

64〜8192 は系列長 (1件の文書のトークン数) で、値は1秒あたりに処理したトークン数。「512トークンの文書/秒」は、512 トークンの文書を1秒に何件埋め込めたか。

構成 64 512 2048 8192 512トークンの文書/秒
テンソル並列 (TP=2) 毎秒3,302トークン 毎秒3,321トークン 毎秒3,244トークン 毎秒2,903トークン 6.5件
パイプライン並列 (PP=2、17層/17層、先頭が5080) 毎秒5,888トークン 毎秒5,890トークン 毎秒5,600トークン 毎秒4,695トークン 11.6件
パイプライン並列 (PP=2、17層/17層、先頭が5060 Ti) 毎秒5,952トークン 毎秒5,970トークン 毎秒5,698トークン 毎秒4,620トークン 11.7件
パイプライン並列 (PP=2、5080に20層/5060 Tiに14層) 毎秒7,013トークン 毎秒7,130トークン 毎秒6,788トークン 毎秒5,639トークン 14.0件
RTX 5080 1枚 + 4GiBをメインメモリへ (enforce_eager) 毎秒5,238トークン 毎秒4,038トークン 毎秒4,972トークン 毎秒4,326トークン 7.9件
RTX 5060 Ti 1枚 + 4GiBをメインメモリへ (enforce_eager) 毎秒2,472トークン 毎秒2,478トークン 毎秒2,391トークン 毎秒2,064トークン 4.9件

表に載せていない 4096 トークンの値は、どの構成も 2048 と 8192 の間だった (TP=2 は毎秒3,124、PP=2 の 20層/14層は毎秒6,349)。3回のばらつきは、RTX 5080 1枚+オフロードの 512 トークンを除くと、中央値の±3.2%以内に収まった。その 512 トークンは3回とも毎秒 3,991〜4,316 トークンだった。

512トークンの文書を埋め込む速さ(構成別)512トークンの文書を埋め込むときの速さを、GPU の使い方ごとに比べるテンソル並列 (TP=2)毎秒3,321トークンパイプライン並列 (PP=2、17層/17層、先頭が5080)毎秒5,890トークンパイプライン並列 (PP=2、17層/17層、先頭が5060 Ti)毎秒5,970トークンパイプライン並列 (PP=2、5080に20層/5060 Tiに14層)毎秒7,130トークンRTX 5080 1枚 + 4GiBをメインメモリへ (enforce_eager)毎秒4,038トークンRTX 5060 Ti 1枚 + 4GiBをメインメモリへ (enforce_eager)毎秒2,478トークン単位: トークン/秒
図: Nemotron-3-Embed-8B-BF16 を vLLM 0.30.0 で動かしたときの速さ (3回の中央値)。RTX 5060 Ti は OCuLink の外付け接続 (2026年10月1日測定)

RAG で扱う文書が数百トークンなら、512 トークンの値を見ればよい。ここから言えることは次の4つ。

  • TP は系列長によらず毎秒2,903〜3,321トークンだった。PP の既定の分け方 (17層/17層) は、2048 トークンまでが毎秒5,600〜5,970トークン、8192 トークンで毎秒4,620〜4,695トークンだった。
  • PP の前半を担当する GPU を RTX 5060 Ti に入れ替えても (表の「先頭が5060 Ti」)、速さはほとんど変わらなかった。
  • RTX 5080 に 20 層、RTX 5060 Ti に 14 層を割り振った PP が、試した構成の中ではどの系列長でも最も速かった。
  • どの構成も、8192 トークンでは 2048 トークンより遅かった。RTX 5080 1枚+オフロードだけは、512 トークンが前後の長さ (64 と 2048) より遅かったが、理由は確かめていない。

実際の量に置き換えると、512 トークンの文書を 10 万件埋め込むには、PP の 20層/14層で約2時間、TP で約4時間かかる計算になる (表の文書/秒からの換算)。比べる先として、1枚に載る 1B モデルは RTX 5080 1枚で 512 トークンが毎秒49,073トークンだった (表は「層の割り振りを変えると速くなる」の節に載せた)。8B を2枚の PP 20層/14層で動かしたときの毎秒7,130トークンは、その約7分の1にあたる。1B と 8B の精度の差は測っていないので、2枚で 8B を動かすか、1枚で 1B を動かすかは、手元の検索データで確かめて決めることになる。

CUDA グラフを止めても (enforce_eager) 速さはほとんど変わらなかった

CPU オフロードの構成だけは enforce_eager=True (CUDA グラフと torch.compile の両方を止める設定) で測っている。ほかの構成と同じ土俵で比べられるかを確かめるため、この設定の有無を TP=2 と PP=2 (17層/17層) で比べた。

構成 512トークンの速さ モデルの読み込み時間
TP=2 enforce_eager なし (既定) 毎秒3,321トークン 51.5秒
TP=2 enforce_eager=True 毎秒3,312トークン 24.6秒
PP=2 enforce_eager なし (既定) 毎秒5,890トークン 47.5秒
PP=2 enforce_eager=True 毎秒5,800トークン 24.5秒

速さの差は TP でも PP でも小さく、読み込み時間のほうが半分ほどに縮んだ。読み込み時間は LLM() を作り終えるまでの秒数で、CUDA グラフの取り込みと torch.compile にかかる時間を含む。

この節の数字は、1台の検証環境・1つのモデル・合成した入力での結果で、各構成3回ずつ測った。速さは vLLM のオフラインの API を1プロセスから呼んだときの値で、サーバーとして複数の要求を受けたときの値ではなく、検索精度も測っていない。vllm serve でサーバーとして動かすときに同じ指定が使えるかどうかも試していない。

層の割り振りを変えると速くなる

vLLM 0.30.0 では、環境変数 VLLM_PP_LAYER_PARTITION で、PP で各 GPU に割り当てる層の数をカンマ区切りで指定できる。ソースの実装では、要素の数が PP の GPU の数と合わないとき、合計がモデルの層の数と合わないときにエラーになる。このモデルの層の数は 34 (config.json の num_hidden_layers) で、既定では 17 層ずつに分かれる。これを RTX 5080 に 20 層、RTX 5060 Ti に 14 層 (VLLM_PP_LAYER_PARTITION=20,14) にすると速くなった。

カンマ区切りの最初の数字は、PP で前半を担当する GPU (CUDA の 0 番) が持つ層の数になる。CUDA の 0 番は、CUDA_DEVICE_ORDER=PCI_BUS_ID を指定していれば PCI バス番号の小さい GPU、CUDA_VISIBLE_DEVICES で順番を入れ替えていればそこで最初に書いた GPU になる。この記事の検証環境では 0 番が RTX 5080 で、20,14 にしたときに RTX 5080 の重みが 9.25GiB、ピークが 13,502 MiB に増えたことから、20 層が RTX 5080 に載ったことを確かめた。「実測: 分け方ごとの速さ」の表の「先頭が5060 Ti」は、CUDA_VISIBLE_DEVICES=1,0 で順番を入れ替えて作った。手元で試すときは nvidia-smi --query-gpu=index,name,pci.bus_id,uuid --format=csv で GPU と PCI バス番号の対応を確かめ、速い GPU が 0 番になるように指定する。NVIDIA の nvidia-smi のドキュメントによると、nvidia-smi の番号はドライバが列挙した順で、再起動をまたいで同じ順になる保証は無く、識別には UUID か PCI バス ID を使うよう勧めている。そのため CUDA の番号と nvidia-smi の番号が常に一致するとは限らない。この検証環境では、CUDA_DEVICE_ORDER=PCI_BUS_ID での CUDA の 0 番も nvidia-smi の 0 番も RTX 5080 だった。

構成 RTX 5080 の重み RTX 5060 Ti の重み RTX 5080 のピーク RTX 5060 Ti のピーク
テンソル並列 (TP=2) 7.51GiB 7.51GiB 11,027 MiB 9,210 MiB
パイプライン並列 (17層/17層、先頭が5080) 8.03GiB 7.03GiB 11,881 MiB 9,318 MiB
パイプライン並列 (5080に20層/5060 Tiに14層) 9.25GiB 5.81GiB 13,502 MiB 8,054 MiB
RTX 5080 1枚 + 4GiBをメインメモリへ 10.88GiB – 15,000 MiB –
RTX 5060 Ti 1枚 + 4GiBをメインメモリへ – 10.88GiB – 13,016 MiB

「重み」は vLLM の起動ログの Model loading took の値 (GPU ごと)。「ピーク」は測定中に nvidia-smi の memory.used を 0.2 秒ごとに読んだ最大値で、RTX 5080 は画面表示の分 (約 1.3〜1.5GB) を含む。ピークは GPU 全体の値なので vLLM が確保した量だけではなく、重み以外の確保量は gpu_memory_utilization の指定によって変わる。17層/17層でも前半を担当した GPU のほうが重みが多かったが、その理由は確かめておらず、ログの値をそのまま載せている。

速い GPU に多くの層を割り当てると速くなった理由の手がかりとして、1枚に載る 1B モデルを GPU 1枚ずつで動かした結果を並べる (「実測: 分け方ごとの速さ」の表と同じ条件で、CUDA グラフあり)。

GPU 512トークンの速さ 8192トークンの速さ
RTX 5080 毎秒49,073トークン 毎秒27,912トークン
RTX 5060 Ti 毎秒24,071トークン 毎秒13,878トークン

1B モデルでは RTX 5080 が RTX 5060 Ti の約2倍の速さで、速い GPU に多くの層を割り振ると速くなったことと向きは合う。ただしこれは 1B のモデルでの比較で、8B で各 GPU が受け持つ部分の速さを直接測ったものではなく、20層/14層が速くなった理由をこれで言い切ることはできない。

試した割り振りは 17層/17層と 20層/14層の2つだけで、20,14 がほかの GPU の組み合わせでも当てはまるとは限らない。自分の2枚で割り振りを探すなら、17/17 から始めて、速い GPU 側の層を数層ずつ増やしては測る、という進め方が考えられる (この記事では試していない手順)。

1枚+CPUオフロードは、GPUによって結果が分かれた

vLLM の Engine Arguments のページによると、cpu_offload_gb は GPU 1枚あたり何 GiB をメインメモリへ逃がすかの指定で、既定は 0 (逃がさない)。推論のたびにモデルの一部をメインメモリから GPU へ運ぶため、CPU と GPU の間の速い接続が要る、とされている。この指定で 4GiB をメインメモリへ逃がすと、GPU 1枚でもこのモデルが動いた。

RTX 5080 では、どの系列長でも TP を上回った。ただし 512 トークンの文書は毎秒7.9件で、PP 17/17 の11.6件、PP 20/14 の14.0件には届かなかった。64・2048・8192 トークンでも、PP 17/17 の9割前後だった。RTX 5060 Ti では、どの系列長でも TP より遅かった。

GPU によって差が出た理由は切り分けていない。候補は2つある。

  • GPU そのものの速さ。1B モデルを1枚ずつで動かすと RTX 5080 は RTX 5060 Ti の約2倍で、オフロード同士の比も 64・2048・8192 トークンで約2.1倍と近かった。
  • CPU と GPU の間の転送の速さ。ピン留めしていないメモリからの転送は (「テンソル並列が遅かった背景」の表)、RTX 5080 が毎秒12.2GB、RTX 5060 Ti が毎秒7.0GB だった。

オフロードの2つの構成の起動ログには『Pinned memory is not available on this platform; falling back to device memory for UVA buffers.』という警告が出ていた。vLLM 0.30.0 のソースのコメントによると、WSL2 では既定でピン留めメモリを使わず、環境変数 VLLM_WSL2_ENABLE_PIN_MEMORY=1 で有効にできる (WSL2 のカーネルが 4.19.121 以上のとき)。同じコメントは、CPU オフロードなどでピン留めメモリや UVA が要るときに 1 にするよう書いている。この記事の測定はこの環境変数を設定しない既定のままなので、転送速度を比べるときに条件が近いのは、ピン留めなしの値のほうになる (転送速度は「テンソル並列が遅かった背景」の表)。ただし、オフロードした重みが実際にどの経路で運ばれたかは確かめていない。

オフロードを使うときは enforce_eager を付けた。vLLM の Engine Arguments のページでは、enforce_eager を True にすると CUDA グラフを使わず、常に eager モードで実行する (torch.compile も止まる) と説明されている。付けずに CUDA グラフありで起動すると、『Cannot copy between CPU and CUDA tensors during CUDA graph capture unless the CPU tensor is pinned』で起動時に止まった。

NVIDIA の CUDA on WSL のガイドは、Known Limitations の中で、WSL2 ではアプリケーションが使えるピン留めのシステムメモリに制限があるとしている。この制限と、上の vLLM の既定や起動時のエラーとの関係は確かめていない。

起動の仕方

次のスクリプトを embed_two_gpus.py という名前で保存し、vLLM を入れた WSL2 か Linux の Python で実行する。--input は1行1文書のテキストファイル。--mode で tp (テンソル並列)・pp (パイプライン並列、--pp-layers で各 GPU の層数)・offload (1枚+メインメモリへ逃がす) を選ぶ。結果のベクトルは .npy に保存する (numpy が無ければ件数と次元だけを表示する)。

"""GPU 2 枚で埋め込みモデルを動かし、文書のリストをベクトルにする (vLLM オフライン API)。

使い方の例 (モデルは例として nvidia/Nemotron-3-Embed-8B-BF16):

  # テンソル並列 (2 枚で各層を分割)
  python embed_two_gpus.py --mode tp \
      --model nvidia/Nemotron-3-Embed-8B-BF16 --input docs.txt --output vecs.npy

  # パイプライン並列 (層を前半/後半で分ける。20,14 は各 GPU の層数で、
  # 最初の数字は前半を担当する GPU = CUDA の 0 番 (PCI バス番号の小さい GPU) の層数)
  python embed_two_gpus.py --mode pp --pp-layers 20,14 \
      --model nvidia/Nemotron-3-Embed-8B-BF16 --input docs.txt --output vecs.npy

  # GPU 1 枚 + 重みの一部をメインメモリへ (CUDA の 0 番の GPU を使い、4GiB を逃がす)
  python embed_two_gpus.py --mode offload --gpu 0 --offload-gb 4 \
      --model nvidia/Nemotron-3-Embed-8B-BF16 --input docs.txt --output vecs.npy

--input は 1 行 1 文書のテキストファイル (UTF-8、空行は無視)。
"""

import argparse
import os
import sys
import time

# CUDA の GPU 番号を PCI バス番号の順にする。
# 既定の CUDA は「速い GPU から順」に番号を振るため、性能の違う 2 枚だと
# PCI バスの順と番号が食い違うことがある。nvidia-smi の番号とも一致するとは
# 限らないので、nvidia-smi --query-gpu=index,name,pci.bus_id,uuid --format=csv
# で GPU と PCI バス番号の対応を確かめておく。
# CUDA が初期化される前 (= vllm を import する前) に設定しておく必要がある。
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")

PREFIX = "passage: "


def parse_args():
    p = argparse.ArgumentParser(description="vLLM で GPU 2 枚を使って文書を埋め込みにする")
    p.add_argument("--mode", choices=["tp", "pp", "offload"], required=True,
                   help="tp=テンソル並列 / pp=パイプライン並列 / offload=GPU 1 枚+CPU オフロード")
    # 例: nvidia/Nemotron-3-Embed-8B-BF16
    p.add_argument("--model", required=True, help="Hugging Face のモデル名またはローカルのディレクトリ")
    p.add_argument("--input", required=True, help="1 行 1 文書のテキストファイル")
    p.add_argument("--output", default="embeddings.npy", help="保存先の .npy")
    p.add_argument("--max-len", type=int, default=8192, help="max_model_len")
    p.add_argument("--gpu-util", type=float, default=0.90, help="gpu_memory_utilization")
    p.add_argument("--pp-layers", default=None,
                   help="--mode pp のとき各 GPU の層数 (例: 20,14)。最初の数字が前半を担当する GPU = CUDA の 0 番 (PCI バス番号の小さい GPU)")
    p.add_argument("--offload-gb", type=float, default=4, help="--mode offload のとき CPU へ逃がす GB")
    p.add_argument("--gpu", type=int, default=0, help="--mode offload のとき使う GPU の番号")
    return p.parse_args()


def load_texts(path):
    with open(path, encoding="utf-8") as f:
        lines = [line.strip() for line in f]
    texts = [t for t in lines if t]
    # モデルの推奨に合わせて文書側に "passage: " を付ける (既に付いていれば付けない)
    return [t if t.startswith(PREFIX) else PREFIX + t for t in texts]


def build_llm_kwargs(args):
    kwargs = dict(
        model=args.model,
        runner="pooling",
        dtype="bfloat16",
        max_model_len=args.max_len,
        gpu_memory_utilization=args.gpu_util,
    )
    if args.mode == "tp":
        kwargs["tensor_parallel_size"] = 2
    elif args.mode == "pp":
        kwargs["tensor_parallel_size"] = 1
        kwargs["pipeline_parallel_size"] = 2
    elif args.mode == "offload":
        kwargs["cpu_offload_gb"] = args.offload_gb
        # CUDA グラフの取り込み中はピン留めされていないメモリとのコピーができずに
        # 起動時に止まる環境があるため、CUDA グラフを使わない eager 実行にする
        kwargs["enforce_eager"] = True
    return kwargs


def main():
    args = parse_args()

    if args.mode == "offload":
        # 使う GPU を 1 枚に絞る。vllm を import する前に設定する必要がある
        os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)

    if args.mode == "pp" and args.pp_layers:
        # 各 GPU に割り当てる層の数をカンマ区切りで渡す (例: 20,14)。
        # 合計はモデルの層数と一致させ、要素数は PP の GPU の数 (ここでは 2) と一致させる必要がある
        parts = [s.strip() for s in args.pp_layers.split(",")]
        if len(parts) != 2 or not all(s.isdigit() for s in parts):
            sys.exit("--pp-layers は 2 つの整数をカンマ区切りで指定してください (例: 20,14)")
        os.environ["VLLM_PP_LAYER_PARTITION"] = ",".join(parts)

    texts = load_texts(args.input)
    if not texts:
        sys.exit(f"{args.input} に文書がありません")

    # 環境変数を設定し終えてから import する
    from vllm import LLM

    llm = LLM(**build_llm_kwargs(args))

    # 合計トークン数 (prefix を付けた後の文字列で数える)
    tokenizer = llm.get_tokenizer()
    # encode() の長さで数える (特殊トークンを含む数)
    total_tokens = sum(len(tokenizer.encode(t)) for t in texts)

    # ウォームアップ (初回呼び出しの準備時間を計測から外す)
    llm.embed(texts[:4])

    start = time.perf_counter()
    out = llm.embed(texts)
    elapsed = time.perf_counter() - start

    vectors = [o.outputs.embedding for o in out]
    n = len(vectors)
    dim = len(vectors[0]) if vectors else 0

    print(f"mode={args.mode}  文書数={n}  合計トークン={total_tokens}  次元={dim}")
    print(f"処理時間: {elapsed:.2f} 秒")
    print(f"文書/秒: {n / elapsed:.1f}   トークン/秒: {total_tokens / elapsed:.0f}")

    try:
        import numpy as np
    except ImportError:
        print("numpy が無いため保存しません (件数と次元のみ表示)")
        return

    arr = np.asarray(vectors, dtype=np.float32)
    np.save(args.output, arr)
    print(f"保存しました: {args.output}  shape={arr.shape}")


# WSL2 などワーカーを spawn で起こす環境では、ワーカーがこのスクリプトを
# 読み直すので、ガードが無いと main() が再実行されて起動時に止まる
# (vLLM 公式のトラブルシューティングも if __name__ == "__main__": の中に置くよう案内している)
if __name__ == "__main__":
    main()

2026年10月1日に、この記事の検証環境 (WSL2 の Ubuntu 24.04・vLLM 0.30.0) で、Nemotron-3-Embed-8B-BF16 と 300 件・合計 39,628 トークンの文書を使って3つのモードを実行した。tp は毎秒3,255トークン、pp --pp-layers 20,14 は毎秒6,602トークン、offload --gpu 0 --offload-gb 4 は毎秒5,011トークンで、どれも 300×4096 のベクトルを保存できた。いずれも1回ずつの参考値で、「実測: 分け方ごとの速さ」の表 (3回の中央値) とは測り方が違うため、数字は直接比べない。

つまずきやすい点は次の3つ。

  • if __name__ == "__main__": のガードが無いスクリプトで TP=2 を起動すると、ワーカーの起動時に『An attempt has been made to start a new process before the current process has finished its bootstrapping phase』で止まった。WSL2 ではワーカーを spawn で起こすため、ワーカーがスクリプトを読み直して処理をもう一度始めてしまう。
  • CUDA_DEVICE_ORDER=PCI_BUS_ID で、CUDA の GPU 番号を PCI バス番号の順にする。--pp-layers の最初の数字は、PP で前半を担当する GPU (PCI バス番号の小さい GPU) の層の数になり、--gpu で指定する番号もこの順で数える。nvidia-smi の番号と一致するとは限らないので、nvidia-smi --query-gpu=index,name,pci.bus_id,uuid --format=csv で対応を確かめる。
  • vLLM の Pooling Models のページでは、埋め込みモデルは --runner pooling (Python では runner="pooling") の pooling モードで動かし、LLM.embed がプロンプトごとに埋め込みベクトルを返す、と説明されている。多くの場合は runner auto で自動判定されるが、スクリプトでは明示した。文書の側には、モデルカードの案内どおり passage: を付けている。

テンソル並列が遅かった背景

この2枚で TP が PP より遅かったことに関係しそうな事実を、測った値と公式の記述から4つ並べる。

  1. この2枚の間では P2P が使えなかった。vLLM 0.30.0 は、GPU 間の P2P が使えないか P2P のテストに失敗すると、独自の高速な all-reduce (custom allreduce) を無効にしてその旨を警告で出し、代わりに NCCL (NVIDIA の GPU 間通信のライブラリ) を使う。この記事の TP=2 の起動ログにも、この警告が出ていた。
  2. NCCL は2枚の間をホストの共有メモリ経由でつないでいて、2枚の間の all-reduce は毎秒約 3〜4GB だった (下の表)。
  3. NVIDIA の RTX 5080 の製品ページの仕様表では、NVLink (SLI-Ready) が No になっている。
  4. vLLM の公式ドキュメントは、GPU に NVLink が無い場合は TP の代わりに PP を使うよう案内しており、そのほうがスループットが上がり通信の負担が小さいとしている。
項目 値
P2P (GPU間の直接アクセス) 使えない (どちら向きも False)
NCCL の GPU 間の経路 ホストの共有メモリ経由 (via SHM)
GPU 間の all_reduce (1MiB / 256MiB) 毎秒2.95GB / 毎秒4.00GB
RTX 5080 のリンク / CPU→GPU (ピン留め) / CPU→GPU (ピン留めなし) PCIe 5.0 x16 / 毎秒41.2GB / 毎秒12.2GB
RTX 5060 Ti のリンク / CPU→GPU (ピン留め) / CPU→GPU (ピン留めなし) PCIe 4.0 x4 / 毎秒7.3GB / 毎秒7.0GB

表の RTX 5080 と RTX 5060 Ti の欄は、「/」で区切った順に、リンク (PCIe の世代と幅)、ピン留めしたメモリからの転送、ピン留めしていないメモリからの転送の値。ピン留めメモリ (pinned memory) は、OS がディスクへ退避させないように固定したメインメモリのこと。all_reduce は torch.distributed の NCCL を 2 プロセスで動かし、bfloat16 で20回測った中央値 (algbw)。CPU→GPU は 256MiB の転送を20回測った中央値で、GB は 10 の9乗バイト。NCCL の経路はログの via SHM の表示から判断した。ホスト側のチップセットやマザーボードの構成によって、これらの値は変わりうる。

ここに並べたのは、TP が遅かったことと同時に見られた事実で、TP の通信にかかった時間そのものは測っていない。P2P が使えない理由が WSL2 にあるのか、接続の仕方にあるのかも切り分けていない。

llama.cpp で2枚に分けて文章を生成する処理と、OCuLink の帯域を扱った記事としてローカルLLMを2枚のGPUのVRAMプールで動かすもある。そちらは生成、この記事は vLLM での埋め込みで、測っている処理が違う。

出力は分け方で変わるか、vLLM のバージョンで変わるか

同じ入力に対する埋め込みベクトルが構成によってどれだけ違うかを、各構成・各系列長で1件目の文書のベクトル (4096 次元) の先頭8要素を記録し、TP=2 (CUDA グラフあり) の値との差の絶対値の最大で比べた。

比べたもの TP=2 との差の最大
8B の全構成 (TP・PP・オフロード・vLLM のバージョン違い) × 系列長 64〜4096 0.0014
参考: 先頭8要素の値の大きさ 絶対値でおよそ 0.002〜0.09

差の最大 0.0014 は、値の大きい要素に対しては小さい差になる。一方、値が 0.002 前後の小さい要素では、差が値そのものと同じくらいの大きさになる箇所もありうる。比べたのは先頭8要素だけで、ベクトル全体の類似度と検索精度は測っていない。索引を作ったときの構成と、問い合わせのときの構成を変えてよいかどうかは、手元のデータでベクトル全体の類似度や検索結果を比べて確かめる必要がある。

vLLM のバージョンの違いについては、0.25.1 と 0.30.0 のどちらも、同じ条件 (TP=2・enforce_eager=True・最大系列長 4096) で 4094 トークンの入力を3回とも最後まで処理でき、速さの差は約5%だった。

vLLM 512トークンの速さ 4096トークンの速さ 4094トークンの入力
0.25.1 毎秒3,152トークン 毎秒2,972トークン 3回とも完了
0.30.0 毎秒3,338トークン 毎秒3,131トークン 3回とも完了

4094 トークンは最大系列長 (4096) に近い長さの入力で、途中で止まらないかを確かめた。モデルカードで検証に使われた 0.25.0 に近い 0.25.1 と、新しい 0.30.0 のどちらでも、同じ設定で長い入力まで処理できた。どちらも max_num_batched_tokens は 4096 にした。0.25.1 は PyTorch 2.11.0+cu130、0.30.0 は PyTorch 2.13.0+cu130 で動かしたので、バージョンの違いには PyTorch のバージョンの違いも含まれる。冒頭で紹介した1枚での実測の記事 (7月の測定) とは入力の作り方と件数が違うため、そちらの数字とはここでは直接比べない。

試した環境と条件

  • OS: Windows 11 の WSL2 (Ubuntu 24.04、カーネル 6.6.87.2-microsoft-standard-WSL2、WSL から見えるメインメモリ 62GiB)
  • 検証環境: Intel Core i7-14700F、メインメモリ 96GB、NVIDIA ドライバ 610.47
  • GPU: RTX 5080 16GB (内蔵の PCIe 5.0 x16。画面表示を兼ねるため、測定前から約 1.3〜1.5GB が使われていた) と RTX 5060 Ti 16GB (OCuLink の外付けで PCIe 4.0 x4。画面表示なし)。GPU の番号は CUDA_DEVICE_ORDER=PCI_BUS_ID で PCI バスの順にした (この検証環境では CUDA の 0 番も nvidia-smi の 0 番も RTX 5080)
  • ソフトウェア: vLLM 0.30.0 (PyTorch 2.13.0+cu130)。バージョンの比較だけ vLLM 0.25.1 (PyTorch 2.11.0+cu130) も使った
  • モデル: nvidia/Nemotron-3-Embed-8B-BF16 (Hugging Face の revision 2b29550c)、dtype は bfloat16、runner="pooling"。比較用の 1B モデルは Nemotron-3-Embed-1B-BF16 (revision 0677b202)
  • 設定: 最大系列長 8448、gpu_memory_utilization 0.90 (オフロードの構成は 0.88)。prefix caching は無効。max_num_batched_tokens は指定せず、既定で 8448 になっていた。VLLM_WSL2_ENABLE_PIN_MEMORY は未設定 (既定)。オフロードの構成と CUDA グラフなしの構成は enforce_eager=True
  • 入力: 固定の英文から切り出したトークン列で、1件の長さは表の系列長より2トークン短い (62・510・2046・4094・8190)。1回あたり合計約13万トークンになる件数 (64トークンは2048件、8192トークンは16件) を llm.embed に1回で渡した
  • 回数と集計: 4件でウォームアップした後に3回測った中央値
  • 時間に含まれるもの: 速さは合計トークン数を所要時間で割った値で、所要時間にはベクトルを Python のリストに変換する時間も含む
  • 構成の作り方: 「先頭が5060 Ti」の構成は CUDA_VISIBLE_DEVICES=1,0 で GPU の順番を入れ替えて作った。RTX 5080 に 20 層の構成は VLLM_PP_LAYER_PARTITION=20,14
  • 帯域の単位: GB は 10 の9乗バイト
  • 測定日: 2026年10月1日

本記事の実測値は2026年10月1日に、上記の検証環境・設定で計測したもの。検索精度、ベクトル全体の類似度、量子化版、1B モデルの2枚構成、17/17 と 20/14 以外の割り振り、Linux ネイティブ、NVLink や P2P のある GPU、vllm serve でのサーバー動作は測っていない。検証環境の詳しい構成は検証環境のページにまとめている。

まとめ

NVLink の無い GPU を組み合わせて、1枚に丸ごと載せると実用にならない埋め込みモデルを動かすなら、vLLM 公式の案内どおり PP から試し、速い GPU に多くの層を割り当てて、手元の文書で測り直す。P2P が使える環境では TP が追いつく可能性もあるので、その場合は TP も並べて測る。2枚目を足す前に、速いほうの GPU (この記事では RTX 5080) 1枚 + CPU オフロードも同じ入力で測っておくと、2枚目を足す価値を比べられる。構成を変えても検索の品質に影響が出ないかは、手元の検索データで確かめることになる。

よくある質問

NVLink が無いと、2枚目の GPU を足しても無駄になる?

この記事の2枚では、PP にすれば、vLLM で重みを丸ごと載せる1枚では実用にならなかったモデルを動かせ、速さも TP を上回った。遅かったのは TP のほうで、2枚の間のやりとりがホストのメモリを経由する経路に乗っていた (P2P が使えなかった)。NVLink や P2P のある GPU では測っていないので、そうした環境での順位はこの記事からは分からない。

2枚目を足すのと、1枚+CPU オフロードはどちらがよい?

RTX 5080 1枚+オフロードは TP よりは速かったが、512 トークンの文書は毎秒7.9件で、2枚の PP (17/17 で11.6件、20/14 で14.0件) には届かなかった。RTX 5060 Ti (OCuLink の外付けで、GPU 自体も RTX 5080 より遅い) 1枚+オフロードは TP より遅かった。接続の違いと GPU の速さの違いは分けて測っていない。

Windows でそのまま動かせる?

vLLM は Windows にネイティブには対応していないため、この記事では WSL2 の上で動かした (「Windows では WSL2 の上で動かす」の節を参照)。

参考資料

タイトルとURLをコピーしました