32B(パラメーター数 320億)の「qwen2.5-bakeneko-32b-instruct」をVRAM 16GBのノートPCで動かす

はじめに

vLLMを使ってVRAM 16GBのノートPCで「qwen2.5-bakeneko-32b-instruct」を動かしてみました。

当然量子化が必要になってきます。どこまでビット数をさげるかは難しいところですが、あまり下げすぎると推論精度が落ちるリスクがあります。

今回はautoawqライブラリを使って4bitに量子化しました。
その方法はこちらを見てください。

max-model-lenは2048で固定としました。
max-model-lenでユーザーのプロンプト(入力)とモデルの回答(出力)の合計トークン数の最大値を指定します。

使用したPC

プロセッサ	Intel(R) Core(TM) i7-12700H
実装 RAM	32.0 GB
GPU		RTX 3080 Laptop (VRAM 16GB)

このPCのWSL2上のUbuntu 24.04でvLLMを使用しました。

実行

まずはcpu-offload-gbを8→6→4と下げていきました。

--cpu-offload-gb 8

vllm serve qwen2.5-bakeneko-32b-instruct-awq \
--max-model-len 2048 \
--cpu-offload-gb 8
INFO 02-21 09:35:39 model_runner.py:1115] Loading model weights took 10.0567 GB
INFO 02-21 09:35:46 worker.py:266] Memory profiling takes 7.44 seconds
INFO 02-21 09:35:46 worker.py:266] the current vLLM instance can use total_gpu_memory (16.00GiB) x gpu_memory_utilization (0.90) = 14.40GiB
INFO 02-21 09:35:46 worker.py:266] model weights take 10.06GiB; non_torch_memory takes 0.04GiB; PyTorch activation peak memory takes 1.41GiB; the rest of the memory reserved for KV Cache is 2.89GiB.
INFO 02-21 09:35:46 executor_base.py:108] # CUDA blocks: 740, # CPU blocks: 1024
INFO 02-21 09:35:46 executor_base.py:113] Maximum concurrency for 2048 tokens per request: 5.78x
Avg generation throughput: 1.2 tokens/s

--cpu-offload-gb 6

vllm serve qwen2.5-bakeneko-32b-instruct-awq \
--max-model-len 2048 \
--cpu-offload-gb 6
INFO 02-21 09:40:44 model_runner.py:1115] Loading model weights took 12.0328 GB
INFO 02-21 09:40:52 worker.py:266] Memory profiling takes 7.65 seconds
INFO 02-21 09:40:52 worker.py:266] the current vLLM instance can use total_gpu_memory (16.00GiB) x gpu_memory_utilization (0.90) = 14.40GiB
INFO 02-21 09:40:52 worker.py:266] model weights take 12.03GiB; non_torch_memory takes 0.04GiB; PyTorch activation peak memory takes 1.41GiB; the rest of the memory reserved for KV Cache is 0.92GiB.
INFO 02-21 09:40:52 executor_base.py:108] # CUDA blocks: 235, # CPU blocks: 1024
INFO 02-21 09:40:52 executor_base.py:113] Maximum concurrency for 2048 tokens per request: 1.84x
Avg generation throughput: 1.5 tokens/s

--cpu-offload-gb 4

vllm serve qwen2.5-bakeneko-32b-instruct-awq \
--max-model-len 2048 \
--cpu-offload-gb 4
ValueError: No available memory for the cache blocks. Try increasing `gpu_memory_utilization` when initializing the engine.

なんとか「--cpu-offload-gb 4」で動かしたい

どうしても「--cpu-offload-gb 4」で動かしたかったのでいろいろVRAM使用量を減らすパラメータを指定しました。

vllm serve qwen2.5-bakeneko-32b-instruct-awq \
--max-model-len 2048 \
--cpu-offload-gb 4 \
--gpu-memory-utilization=0.98 \
--max-num-seqs 64
INFO 02-21 10:02:36 model_runner.py:1115] Loading model weights took 14.1031 GB
INFO 02-21 10:02:43 worker.py:266] Memory profiling takes 6.69 seconds
INFO 02-21 10:02:43 worker.py:266] the current vLLM instance can use total_gpu_memory (16.00GiB) x gpu_memory_utilization (0.98) = 15.68GiB
INFO 02-21 10:02:43 worker.py:266] model weights take 14.10GiB; non_torch_memory takes 0.04GiB; PyTorch activation peak memory takes 0.76GiB; the rest of the memory reserved for KV Cache is 0.78GiB.
INFO 02-21 10:02:43 executor_base.py:108] # CUDA blocks: 198, # CPU blocks: 1024
INFO 02-21 10:02:43 executor_base.py:113] Maximum concurrency for 2048 tokens per request: 1.55x
Avg generation throughput: 2.2 tokens/s

考察

「--max-num-seqs」のデフォルト値は256のようです。この値を下げると一般的にはVRAM使用量が下げられる反面、推論速度は遅くなるようです。
ただし、今回の場合は64を指定してCPUにoffloadするサイズが縮小できたので速度上昇につながったものと思います。

今回は「--enforce-eager」があってもなくても実行可能でした。付けないほうが速度が速いです。どうしてもOOM(out of memory)が発生してしまうときに考慮すれば良いと思います。

クライアント側のPythonスクリプト

Gradioを使いました。

from openai import OpenAI
import gradio as gr

openai_api_key = "EMPTY"
openai_api_base = "http://localhost:8000/v1"

client = OpenAI(
    api_key=openai_api_key,
    base_url=openai_api_base,
)

system_prompt_text = "あなたは誠実で優秀な日本人のアシスタントです。"
init = {
    "role": "system",
    "content": system_prompt_text,
}

def make_message(
    message: str,
    history: list[dict]
):    
    if len(history) == 0:
        history.insert(0, init)
    history.append(
        {
            "role": "user", 
            "content": message
        }
    )
    return "", history

def bot(
    history: list[dict]
):
    stream = client.chat.completions.create(
        model="qwen2.5-bakeneko-32b-instruct-awq",
        messages=history,
        temperature=0.5,
        max_tokens=1024,
        #frequency_penalty=1.1,
        stream=True
    )
    history.append({"role": "assistant","content": ""})
    for chunk in stream:
        history[-1]["content"] += chunk.choices[0].delta.content
        yield history
    
with gr.Blocks() as demo:
    chatbot = gr.Chatbot(type="messages")
    msg = gr.Textbox()
    clear = gr.ClearButton([msg, chatbot], value="新しいチャットを開始")

    msg.submit(make_message, [msg, chatbot], [msg, chatbot], queue=False).then(
        bot, chatbot, chatbot
    )

demo.launch()