はじめに: こんなことができます
12GB の GPU 2枚(RTX 5070 × 2)を挿した Windows PC で、27B(270億パラメータ)の LLM を、社内の何人かで同時に使っています。
| できること | 数字(手元の実測) |
|---|---|
| 1人で使ったときの生成速度 | 約90 トークン/秒(日本語で1秒に約100文字) |
| 同時に使える人数 | 24本まで同時に生成(合計 約600 トークン/秒) |
| 読める長さ | 13万トークン(文庫本1冊くらい) |
| 画像入力 | できる(写真・スクリーンショットの説明、文字の書き起こし) |
| ツール呼び出し・思考 | できる(OpenAI 互換 API) |
| 混んでいるとき | 対話の人を優先して、待ち時間 1秒以下 |
モデルは Prism ML の Ternary-Bonsai-2-27B です。重みが {-1, 0, +1} の3値(2bit)で、27B なのに重みが約10GB しかありません。
この記事は、同じ構成をまねするための手順と、なぜ速く・たくさん入るのかの解説です。改造は全部 GitHub に置きました。コマンドを3つ実行すれば動きます。
- 一式: Nakanokensetsu/bonsai-27b-wsl-tp2
- プラグイン: Nakanokensetsu/bonsai-vllm(
bonsai-tp2-wslブランチ) - 日本語用の先読みヘッド: nakakennnn/Ternary-Bonsai-2-27B-MTP-ja
- 前回の記事(GPU 間通信の改造): こちら
数字は全部、自分の環境での実測です。測っていないことは「未確認」と書きました。
1. 必要なもの
| 項目 | 内容 |
|---|---|
| GPU | VRAM 12GB 以上 × 2枚(私は RTX 5070。RTX 50 シリーズ以外は未確認) |
| OS | Windows 11 + WSL2(Ubuntu)。ネイティブの Linux でも動くはずです(未確認) |
| メモリ | 32GB 以上(私は 64GB を WSL に割り当て) |
| ディスク | 空き 40GB 以上(SSD 推奨) |
| ドライバ | Windows 側に、CUDA 13.0 対応の NVIDIA ドライバ |
| ツール |
git、patch、uv(Python の環境を作るツール) |
WSL2 の Ubuntu で、足りないツールを入れておきます。
sudo apt install -y git patch
curl -LsSf https://astral.sh/uv/install.sh | sh
nvidia-smi を実行して、GPU が2枚見えていれば準備完了です。
2. 手順(3コマンド)
git clone https://github.com/Nakanokensetsu/bonsai-27b-wsl-tp2.git
cd bonsai-27b-wsl-tp2
./setup.sh # ① vLLM とパッチ、プラグインを入れる(15〜30分)
./model/prepare_model.sh # ② モデルの重みを用意する(約10.5GB のダウンロード)
./serve.sh # ③ サーバーを起動する
それぞれの中身は次のとおりです。
-
setup.sh:venv/に vLLM 0.32.0 を入れて、私のパッチを当てます。続けて、2bit の重みを計算するプラグイン(私のフォーク)を入れます。 -
prepare_model.sh: Hugging Face から vLLM 用の重みを落とします。画像を読むための部品(ビジョンタワー)と、日本語用の先読みヘッド(6章)を足して、単語の埋め込みを SSD 用に書き出します。 -
serve.sh: 起動します。初回は GPU 用のプログラムのコンパイルがあるので、3〜5分かかります。
ログに次の行が出たら起動完了です。
INFO: Application startup complete.
動いたか確かめる
別のターミナルで聞いてみます。
curl -s http://127.0.0.1:8000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"bonsai-27b","messages":[{"role":"user","content":"17×23は?"}]}' \
| python3 -m json.tool
391 が返ってくれば成功です。思考の中身は reasoning_content、答えは content に入ります。
チャット画面から使う
OpenAI 互換なので、Open WebUI などのチャット画面から、そのまま使えます。接続先の URL に http://<このPCのアドレス>:8000/v1 を、モデル名に bonsai-27b を入れてください。
他の PC から使うときは、
HOST=0.0.0.0 ./serve.shで起動します。WSL2 では、Windows 側のファイアウォールとポート転送の設定も必要です。
うまく動かないとき
| 症状 | 対処 |
|---|---|
起動中に out of memory / KV が足りない |
MEM=0.65 ./serve.sh のように下げる。画面表示に使っている GPU は、他のアプリ(ゲーム、3D ソフト)も VRAM を使います |
| 遅い(1本で 60 tok/s を切る) | 画面表示側の GPU で重いアプリが動いていないか確認。私の環境では、3D アプリが 1.5GB 使うだけで約30%遅くなりました |
| 数学など難しい問題で間違える | 要求に "reasoning_effort": "xhigh" を付けると、よく考えるようになります(時間は約2倍) |
3. 混んでいるときに、対話の人を先にする
何人かで使うと、長く考える要求(エージェントやまとめ処理)が枠を埋めて、後から来た短い質問が待たされます。私の環境では 30〜58秒 待たされていました。
要求の JSON に priority を入れると、小さい数字の要求が先になります。
{"model": "bonsai-27b", "priority": 0, "messages": [...]}
素の vLLM では、すでに動いている要求は追い出されないので、枠が埋まっていると待つだけです。私のパッチでは、待っている要求のほうが優先度が高ければ、動いている一番低い要求を1本ずつ追い出します。
| 条件 | 対話(priority 0)の待ち時間 |
|---|---|
| 追い出しなし(16本が動いている) | 30〜58 秒 |
| 追い出しあり | 0.2〜0.3 秒 |
追い出された要求は、後で続きから再開します。背景の20本も、全部最後まで正常に終わりました。
私は手前に小さなプロキシを置いて、送信元ごとに priority を付けています(チャット画面 0、エージェント 5、測定 10)。
4. 解説: なぜ 12GB × 2 で 24本も入るのか
ここからは、前回の記事(2026-10-29)から2週間でやった改善の解説です。
4.1 そもそも何が足りないのか
LLM が文章を作るときは、**それまでの文章を覚えておくメモ(KV キャッシュ)**を GPU に置きます。長い文章や同時に何本も処理するほど、このメモが大きくなります。
12GB × 2 では、重みを置いた残りが KV キャッシュの場所です。ここに何トークン分入るか(KV プール)で、同時に何本・どれだけ長く扱えるかが決まります。
| 時期 | KV プール(同時16本の設定) |
|---|---|
| 前回(10/29) | 282,089 トークン |
| 今回 | 424,423 トークン(1.50倍) |
増やした方法は2つです。
4.2 KV キャッシュを小さく書く(OSCAR)
KV キャッシュは、ふつうは16bit の数字で持ちます。これを K は3bit、V は2bit まで縮めました。そのまま縮めると精度が落ちるので、次の工夫をしています。
- 縮める前に、ベクトルを回転して値の偏りをならす(外れ値が減って、少ないビットでも表しやすくなる)
- 直近のトークンだけは16bit のまま持つ(直近の文脈ほど大事なため)
考え方は vLLM の未マージ PR #46774(OSCAR)のものです。この PR は CUDA グラフ・投機デコード・このモデルの形(head_dim=256、GDN との混成)に対応していなかったので、そこを作り足しました。
| 形式 | 1トークン・1ヘッドのバイト数 | KV プール | 数学(AIME)の正答率 |
|---|---|---|---|
| TurboQuant 4bit(前回) | 262 | 282,089 | 82.5% |
| OSCAR K2V2 | 136 | 458,752 | 76% |
| OSCAR K3V2(採用) | 168 | 378,652 | 83.3% |
最初はいちばん小さい K2V2 を使っていました。ふつうの質問や長文の検索テスト(ニードル)では差が見えなかったのですが、数学の問題で初めて差が出ました。K のビット数が効くことが分かったので、K3V2 にしています。
教訓: 量子化の比較は、ふつうの質問だけでなく、数学のような厳しいテストでもやる。
4.3 単語の埋め込みを SSD に置く
モデルの重みの内訳を調べたら、単語の埋め込み表(単語 → ベクトルの対応表)だけで 2.37GiB ありました。本体の層(5.66GiB)の4割にもなります。
埋め込み表は「引くだけの表」で、計算には使いません。しかも、実際の利用ログ(5,456万トークン)を数えると、よく使う上位 32,000 語で 99.86% をカバーしていました(語彙は全部で約25万語)。
そこで、次のようにしました。
- 全部の表は SSD 上のファイルに置く(OS のキャッシュ越しに読む)
- GPU には上位 32,000 語の分だけ置く(2.37 → 0.31GiB)
- 表に無い語が来たら、CPU がファイルから読んで差し込む
| 項目 | 前 | 後 |
|---|---|---|
| 重み(GPU 1枚あたり) | 5.24 GiB | 4.36 GiB |
| KV プール | 458,752 | 627,633(1.37倍) |
| 生成速度(中央値) | 79.3 tok/s | 87.7 tok/s |
速度は落ちていません。本体の計算は正確なので、出力も変わりません。
先読み(MTP)の部分だけは GPU の表しか見ませんが、先読みの結果は本体が必ず確かめるので、出力には影響しません。
増えた KV プールは、同時処理の本数(16 → 24)に回しました。
5. 解説: 長文を読む速度(prefill)
int8 AllReduce を Triton で書き直して採用
GPU 2枚で1つのモデルを動かすと、層ごとに GPU 同士で計算結果を足し合わせる通信(AllReduce)が入ります。WSL2 では GPU 同士の直接通信が使えないので、これが遅いのが前回からの課題でした。
前回は、この通信を int8 に縮めて半分にする案を試しましたが、縮める・戻す処理(PyTorch で書いた)が遅くて効果が出ず、見送りました。
今回は、縮める・戻す処理を Triton(GPU 用のプログラムを Python で書ける仕組み)の1カーネルずつに書き直しました。
| 処理 | PyTorch 版 | Triton 版 |
|---|---|---|
| 縮める(1584行) | 0.47 ms | 0.018 ms |
| 戻して足す | 0.57 ms | 0.018 ms |
| AllReduce 全体(16bit 比) | 1.12〜1.28 倍速 | 1.61〜1.70 倍速 |
| 長文を読み終わるまでの時間 | 前 | 後 |
|---|---|---|
| 8,000 トークン | 5.3 秒 | 3.9 秒 |
| 32,000 トークン | 22.4 秒 | 16.9 秒 |
| 100,000 トークン | 81.3 秒 | 64.8 秒 |
長文を読むとき(prefill)だけに使うので、文章を書く速度(decode)と品質は変わりませんでした。
6. 解説: 先読み(MTP)を日本語に合わせる
このモデルは、次の1トークンを先に予想する小さな頭(MTP ヘッド)を持っています。予想が当たれば、1回の計算で2トークン進めます。
元の MTP ヘッドは英語中心のデータで作られています。私の使い方はほとんど日本語なので、このモデル自身が書いた日本語の文章で、MTP ヘッドだけを学習し直しました(GPU 2枚で約10分)。
| 項目 | 元のヘッド | 学習し直したヘッド |
|---|---|---|
| 予想が当たる率 | 65.9% | 70.0% |
| 生成速度(平均) | 90.8 tok/s | 93.6 tok/s |
学習し直したヘッドは Hugging Face に置きました。prepare_model.sh が自動で落として差し替えます(JA_MTP=0 で元のヘッドのまま)。
罠: 学習データを作るとき、vLLM の
extract_hidden_statesの層 ID は「その層への入力」です。最終層の出力が欲しければ、63 ではなく 64 を指定します。
7. 試したが採用しなかったこと
| 案 | 結果 |
|---|---|
| vLLM 0.32 の NVFP4 KV | KV プールが20%減る |
| OSCAR K4V2(K を4bit に) | 数学 80.0%、プールも小さい(K3V2 に劣る) |
| 別の人が配布している MTP ヘッド | 当たる率が上がらず |
| mamba 状態を32bit に | 品質の差が見えず、プールが16.5%減る |
| vLLM 0.33 への更新 | 効く変更が少なく、パッチ移植の手間に見合わない(未実施) |
8. つまずきやすい点(私が実際にはまったこと)
- 画面表示と兼用の GPU: 他のアプリが VRAM を使うと、速度が約30%落ちたり、起動に失敗したりします。WSL2 では VRAM が足りなくても、数字にほとんど出ません。
-
xxhashの入れ忘れ: 起動に失敗して、再起動を繰り返しました(setup.shでは入れています)。 - 優先度の追い出しが連鎖: 追い出した要求が空いた枠をすぐ取り直して、追い出しが300回連鎖しました。パッチでは修正済みです。
-
数学の正答率が低く見える: チャットテンプレートの「どれだけ考えるか」の既定が medium だと、モデルカードの値より低く出ます。
"reasoning_effort": "xhigh"で 93.3% でした。
まとめ
- 12GB × 2 の GPU で、27B の LLM を 24本同時・13万トークン・画像入力付きで動かせました。
- KV キャッシュの縮小(OSCAR K3V2)と埋め込みの SSD 化で、KV プールは 1.5倍になりました。
- int8 AllReduce で、長文の読み込みが 20〜25% 速くなりました。
- 優先度による追い出しで、混雑時の対話の待ち時間が数十秒から1秒以下になりました。
- 手順と改造は全部 GitHub にあります。
付録: serve.sh の主な設定(本番と同じ)
| 環境変数 | 既定 | 内容 |
|---|---|---|
KV |
turboquant_k3v2_oscar |
KV キャッシュの形式 |
MEM |
0.72 | GPU メモリのうち vLLM が使う割合 |
SEQS |
24 | 同時に処理する本数 |
PRISM_AR_INT8 |
1 | prefill の AllReduce を int8 で |
VLLM_PRIO_PREEMPT |
1 | 優先度による追い出し |
VLLM_SSD_EMBED_DIR |
./embed_ssd |
埋め込みを SSD に(空で無効) |
KV_OFFLOAD |
1 | 追い出された KV を RAM → SSD に退避 |