サマリー
- VRAM目的で仕入れていた Tesla V100 (Volta, Compute Capability 7.0) で最新の vLLM を動かしたくて、フォークにパッチを当てました。
- V100 には BF16ハードウェアも FlashAttention 2/3 も無い ので、最新 vLLM をそのまま
git cloneして動かすと一瞬で詰みます。 - 「Python側でちょっと分岐すればいいでしょ」で済むと思っていたら、C++ アダプタ層を書いてビルドシステムに組み込むという、ただのバグ修正のつもりが中規模プロジェクトになりました。
- 最終的に
Qwen2.5/Qwen3.5(Mamba+Attentionハイブリッド) /Gemma4(マルチモーダル)の3アーキテクチャで、実機V100上での生成まで確認できました。 - コードはこちら: https://github.com/nayukinet-AI-Lab/vllm (ブランチ:
feature/v100-fp16-patch)
詳細な設計ドキュメントは README_v100.jp.md(English: README_v100.md)にまとめてあります。本記事はその「裏側で何にハマったか」を時系列で振り返る読み物です。
なぜ今さらV100なのか
2017年発売のVoltaアーキテクチャ、CUDA Compute Capability 7.0。もうFlashAttentionすら正式サポート外になって久しいGPUです。それでも:
- 16GB HBM2、SXM2接続で意外と転がっている(クラウドの型落ちインスタンスや中古で安い)
- 推論だけなら7B前後のモデルをFP16で十分動かせるVRAM
- 「枯れたハードウェアで最新ソフトウェアがどこまで動くか」を突き詰めるのはシンプルに楽しい
という理由で、手元のV100を腐らせずに最新vLLMで使えるようにする、というのが今回のモチベーションです。
第一の壁: BF16が無い、FlashAttentionが無い
まず最初にぶつかるのはこれです。最新vLLMは多くのモデルでBF16をデフォルト重みdtypeとして使いますが、VoltaにはそもそもBF16を計算するハードウェアが存在しません。さらにFlashAttention 2/3もCC 8.0 (Ampere) 以降が前提。何も考えずに動かすと
AttributeError: '_OpNamespace' '_C' object has no attribute '<op_name>'
のようなエラーで華麗に落ちます。
ここまでは想定内でした。vllm/platforms/cuda.py で device_capability < (8, 0) のときに
- 重み/活性化/KVキャッシュをBF16→FP16にキャストする
- アテンションバックエンドの優先度リストから
FLASH_ATTN/FLASHINFERを外しTRITON_ATTN/FLEX_ATTENTIONにフォールバックさせる
という2点を仕込めば済むだろう、と。実際ここは大きな問題にはなりませんでした。
第二の壁: 「Pythonでちょっと分岐」が禁じ手だった
次に、BF16前提で書かれたC++カスタムオプ(rms_norm, silu_and_mul など)がV100上の torch.ops._C に存在せず呼び出し時に死ぬ、という問題に当たります。
最初は「Python側のモデル定義で hasattr チェックして代替実装に分岐すればいいのでは」と思ったのですが、これをやると
-
vllm/model_executor/models/qwen2.pyのようなモデル定義ファイルがVolta専用の条件分岐で汚染される - upstream (
vllm-project/vllm) からのマージのたびにコンフリクトする - モデルが増えるたびに同じ分岐をコピペする羽目になる
というのが見えていたので、却下。代わりに採用したのが 「Invasive-Free C++ Adapter Pattern」 です。
- Pythonのモデル層は一切触らない。upstreamとbyte-for-byteで一致させる。
- sm_70で欠けているオプは
csrc/v100_adapter/にATenネイティブ実装を書き、TORCH_LIBRARY_IMPLで ビルド時に 差し替える。
Python側の呼び出し (torch.ops._C.rms_norm(...)) は一切変更せず、ディスパッチャのレベルで「Volta専用ビルドのときだけこっちの実装を使う」ようにする、という設計です。
二重登録地獄
ここで素朴にハマったのが 二重登録エラー です。rms_norm や silu_and_mul は、実はネイティブ側 (csrc/libtorch_stable/torch_bindings.cpp) で既に CUDA ディスパッチキーに登録済みでした。そしてそのネイティブ実装自体はsm_80固有の命令に依存していないので、sm_70でも普通にコンパイルが通ってしまうのです。
つまり「オプが無い」のではなく「オプはあるが中身がBF16前提で壊れる」が正しい状態で、アダプタ側で同じ (_C::rms_norm, CUDA) に追加登録すると、PyTorchのディスパッチャが「同じキーに2つ実装があるぞ」と言って全GPUで拡張のロードに失敗するようになりました。V100専用のつもりが、H100まで巻き込んで壊すというオチです。
解決策はビルド時ゲートマクロ VLLM_V100_ADAPTER です。CMakeで「ビルド対象の全アーキがCC<8.0のときだけ」このマクロを定義し、
- アダプタ側の登録を
#ifdef VLLM_V100_ADAPTERで囲む - ネイティブ側の該当登録を
#ifndef VLLM_V100_ADAPTERで囲む
とすることで、「1つのオプにつき常にCUDAカーネルは1つだけ登録される」状態を機械的に保証できるようにしました。
第三の壁: torchバージョン探しの迷宮
ここから先が今回一番消耗した部分です。
vLLMの新しめのC++拡張 (_C_stable_libtorch) は、PyTorchのlibtorch安定ABI (torch::stable::*) で書かれています。これはPythonバージョンをまたいでABI互換なバイナリを作れる仕組みですが、当然ながら対応torchバージョンに下限があります。フォークが元々ピンしていた torch==2.6.0+cu124 ではこのヘッダ群 (torch/csrc/stable/library.h など) がそもそも存在せず、ビルドシステムは黙ってこの拡張全体をスキップするようになっていました。
つまり「アダプタは実装したけど、一度も実ビルドに組み込まれたことが無い」状態のまま放置されていたわけです。これに気づかず進めると後で痛い目を見ます(実際見ました)。
ラウンド1: 「torch 2.7以上ならいけるでしょ」→ 甘かった
CMakeのゲートは torch/csrc/stable/library.h の有無だけをチェックしていたので、「torch 2.7+ なら動くだろう」と torch==2.8.0+cu128 にアップグレードしてビルドしてみました。結果:
fatal error: torch/headeronly/util/Exception.h: No such file or directory
...
fatal error: torch/csrc/stable/ops.h: No such file or directory
library.h と tensor.h はあるのに、実際にアダプタが使う ops.h や headeronly/util/Exception.h がまだ存在しない。CMakeのゲート判定が甘すぎました。ヘッダを手で find して確認したところ、これらが揃うのはだいたい torch 2.9 以降のようです。
ラウンド2: torch 2.11に上げたら、今度はVoltaが消えた
じゃあ最新を使えばいいだろうと torch==2.11.0+cu128 を試したところ、ヘッダ問題は解消したのに今度はこうなりました。
>>> torch.cuda.get_arch_list()
['sm_75', 'sm_80', 'sm_86', 'sm_90', 'sm_100', 'sm_120']
sm_70 が無い。 PyTorchのcu128ビルドは torch 2.11 で Volta サポートを打ち切っていました(cuDNNのバージョンアップが理由らしい)。せっかく手に入れたV100が、torch側の都合でGPUとして認識すらされない状況です。
ここで少し調べて分かったのは、「Voltaを切ったのはcu128/cu129ビルドだけで、cu126ビルドならtorch 2.11以降でもVoltaを維持している」ということでした。CUDA 13系はそもそも最初からVolta非対応なので除外、残るは cu126。
>>> torch==2.11.0+cu126
>>> torch.cuda.get_arch_list()
['sm_50', 'sm_60', 'sm_70', 'sm_75', 'sm_80', 'sm_86', 'sm_90']
sm_70 あり、かつ ops.h / Exception.h もあり。ようやく両方の条件を満たすバージョンにたどり着きました。
| sm_70あり | stable ABIヘッダ完備 | |
|---|---|---|
| torch 2.6 (元々のピン) | ✅ | ❌ |
| torch 2.8+cu128 | ✅ | ❌ |
| torch 2.11+cu128 | ❌ | ✅ |
| torch 2.11+cu126 | ✅ | ✅ |
この表を作るのに、都度 数GB のwheelをダウンロードしては torch.cuda.get_arch_list() と find .../torch/csrc/stable -name ops.h を叩く、を3周くらいしました。
第四の壁: アダプタ、一度もコンパイルされたことがない説
torchバージョン問題を片付けて、満を持して TORCH_CUDA_ARCH_LIST="7.0" python setup.py develop を実行したところ——
csrc/v100_adapter/v100_fallback_ops.cpp:17:
torch/csrc/api/include/torch/all.h:27:2: error:
#error "This file should not be included when either TORCH_STABLE_ONLY
or TORCH_TARGET_VERSION is defined."
アダプタ自体がコンパイルできませんでした。
理由はシンプルで、アダプタが組み込まれる _C_stable_libtorch ターゲットは -DTORCH_TARGET_VERSION=... 付きでビルドされる(=ABI安定版の縛りが入る)のに、アダプタの実装は普通のレガシーATen API (at::Tensor, torch/library.h) で書かれていたからです。レガシーヘッダはこの縛りの下では #error で弾かれる仕様でした。
冷静に考えれば当然で、torch 2.6では拡張自体が常にスキップされていたので、この非互換性は一度も実行時に検出される機会が無かったわけです。test_v100/ の単体テストも、実は本番のビルドパスを通さずに torch.utils.cpp_extension.load() で別立てJITコンパイルしていたため(ABI制約が緩い)、「テストは通ってるのに本番ビルドは一度もしたことがない」という状態が生まれていました。テストがグリーンでも安心できない好例です。
torch::stable::Tensor への全面書き直し
というわけでアダプタ本体を torch::stable::Tensor ベースに書き直すことになりました。このAPIは意図的に薄く作られていて、普通の at::silu(x) のような便利関数はほとんどありません。代わりに3通りの呼び方を使い分けます。
-
torch::stable::ops.hにある既製ラッパー(narrow,copy_,to,full,sumなど)をまず探す。 - 無ければディスパッチャへ直接スタックを組んで投げる:
torch_call_dispatcher("aten::silu", "", stack.data(), TORCH_ABI_VERSION)。ただしこれは Scalar型の引数を持つオプには使えません(ScalarがまだStableIValue経由で汎用的にボックス化できないため)。 - Scalar引数があるオプ(
addのalpha、clampのmin/maxなど)は、コード生成済みのCシム関数 (aoti_torch_cuda_mul_Scalarのような) を直接叩く。clamp/clamp_maxにはシムすら無かったので、full()で定数テンソルを作ってminimum/maximumで代用する羽目になりました。
地味に嬉しかったのは、rms_norm を手書きの pow→mean→rsqrt→mul の合成で実装する必要が無く、aoti_torch_cuda__fused_rms_norm という既製の複合オプを1回呼ぶだけで済んだことです。念のため手動実装と数値を突き合わせて max_abs_diff == 0.0 を確認してから採用しました。
第五の壁: アダプタ以外のファイルもVoltaで壊れていた
アダプタが直ったので意気揚々と再ビルドしたら、今度はアダプタと関係ない「素の」カーネルファイルでコンパイルエラーが出ました。
cuda_vec_utils.cuh(341): error: identifier "__float22bfloat162_rn" is undefined
cuda_vec_utils.cuh(330): error: identifier "__bfloat1622float2" is undefined
原因は、BF16のパックド(2要素SIMD)変換命令がそもそもsm_80未満のGPUには存在しないことです。V100はBF16のハードウェアサポートを一切持たないので当然なのですが、問題はこれらの関数が scalar_t=BFloat16 のテンプレートインスタンス化経路に無条件で現れることでした。実行時にはV100では絶対にBF16テンソルがここまで来ない(FP16にキャスト済みだから)のに、コンパイル時には型ごとに実体化されてしまうため、到達しないはずのコードでもビルドが落ちます。
幸い、同じリポジトリの別ファイル (type_convert.cuh) に既に前例がありました。
#if (defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 800) || defined(USE_ROCM)
// CUDA_ARCH < 800 does not have BF16 support
template <> struct _typeConvert<BFloat16> { ... };
#endif
これと同じ精神で、cuda_vec_utils.cuh の該当3関数と、MoEルーティングカーネル2本の該当箇所を #if __CUDA_ARCH__ >= 800 で分岐し、#else 側には __trap()(到達したら即死させる)を仕込みました。「到達しないことが設計上保証されているなら、コンパイルさえ通してしまえばいい」という割り切りです。
一方で、DeepGEMM向けのFP8量子化カーネル1ファイルは、こういう局所パッチでは対応しきれないほど大量にBF16/FP8専用命令を使っていました。幸い呼び出し元を調べたところ has_device_capability(90) (Hopper以降限定) で既にガードされていることが分かったので、個別修正は諦めてファイルごとビルド対象から除外する方針にしました。「直せるものは直す、直せないものは呼ばれないことを確認して切り捨てる」のバランス感覚が地味に重要でした。
ようやく動いた
長い旅でしたが、最終的に以下がすべて実機V100で確認できました。
.venv/bin/python -m pytest test_v100/ -v
# 8 passed
from vllm import LLM, SamplingParams
llm = LLM(model="Qwen/Qwen2.5-0.5B-Instruct", dtype="float16", enforce_eager=True)
llm.generate(["The capital of France is"], SamplingParams(max_tokens=32))
# => ' Paris. It is the largest city in Europe...'
さらに、せっかくなので新しめのアーキテクチャでも動くか試してみました。
-
Qwen3.5 (
Qwen3_5ForConditionalGeneration, Mamba+Attentionハイブリッド): Gated DeltaNetの融合CUDAデコードカーネルが「CC>=8.0必須」と自己申告してTriton実装にフォールバックし、問題なく生成できました。 -
Gemma4 (
Gemma4ForConditionalGeneration, マルチモーダル): FA4非対応と判定されTRITON_ATTNへ。チャットテンプレート経由ではきれいな出力("The capital of France is **Paris**.")が得られました。ちなみに生テキスト補完だと"France is France is France is..."と壊れたループに入ったのですが、これはV100固有の不具合ではなく instruct モデルに素の補完プロンプトを与えたときのよくある挙動で、チャット形式に直したら即座に解決しました(一瞬「アダプタのバグか」と焦りましたが)。
3系統とも rms_norm / silu_and_mul 系のV100アダプタ経路を実際に通っているので、特定モデル専用の場当たり対応ではなく、ちゃんと汎用的に効いていると言えそうです。
振り返り
今回の作業を一言でまとめると、「動かない」の原因が毎回一段深いところにあったという感じでした。
- BF16/FlashAttentionが無い → プラットフォーム層でdtype/バックエンドをフォールバック(ここは想定内)
- それでもオプが足りない → C++アダプタ層が必要(想定よりは重い)
- アダプタを素朴に足すと二重登録で死ぬ → ビルド時マクロゲートが必要(設計判断)
- ゲートに必要なtorchバージョンが思ったより高い、しかも上げすぎるとVoltaそのものが切られる → ピンポイントの組み合わせ探し
- やっと通った条件でビルドしたら、アダプタ自身が一度も実ビルドを通ったことが無く、API自体が間違っていた → 全面書き直し
- アダプタを直したら、無関係な素のカーネルまでBF16命令で壊れていた → アーキテクチャガードの横展開
「枯れたGPUで最新ソフトウェアを動かす」というテーマは、こうやって各レイヤーに薄皮一枚ずつ隠れている前提(BF16がある前提、最新GPUである前提、テストが本番パスを通っている前提)を一枚ずつ剥がしていく作業だったように思います。
手元にV100が転がっている方、あるいは同じような「枯れたハードウェアで頑張る」系のパッチを書いている方の参考になれば幸いです。コード・詳細な設計ドキュメントは以下にあります。Issue/PR歓迎です。
- リポジトリ: https://github.com/nayukinet-AI-Lab/vllm
- ブランチ:
feature/v100-fp16-patch - 設計ドキュメント:
README_v100.jp.md/README_v100.md(English)