LLM（Python）を動かせるGPUサーバー【Modal】に入門してみた

# 概要

ModalというGPUサーバを使ってオープンソースのローカルLLMを動かします。
できるだけ安く済ませたいなどいくつかの要望（後述）があったので、良さそうなサービスを探して、利用してみました。


## 要望

- ローカルで動かせるオープンソースLLMを試したいけど、GPUが手元にない。
- できるだけ安く使えるGPU環境が欲しい。
- クラウドのセットアップのために複雑なことをしたくない。
- インスタンスの切り忘れ等で無駄なコストが発生するのを避けたい。


# 調べて候補に上がったGPUサービス

- Modal
    - https://modal.com/
- GPUSOROBAN
    - https://soroban.highreso.jp/
- LambdaCloud
    - https://lambdalabs.com/
- Vast.ai
    - https://vast.ai/
- RunPod
    - https://www.runpod.io/
- Replicate
    - https://replicate.com/

# Modalを選んだ理由

- 安さだけなら、「Vast.ai」ですが、個人が所有しているGPUを間借りするような形で利用することになるので、利用するたびに都度設定が必要になるのが面倒かもしれないと思いました。
    - サービスとしての面白さや安さというメリットが大きいのですが、今回の私の用途には向いていませんでした。
- クラウドのセットアップを楽にするという点はどれも簡単にセットアップできそうなので、同列なのですが、インスタンスの切り忘れが起き得ない、という意味ではModalが一番確実だと判断して、今回はModalを利用することにしました。
- **Modalは30ドルの無料クレジットが付きます。しかもクレカ登録などは不要です。**
    - 30ドルまでは課金せずに色々と遊び倒せるので、そこが大きなメリットだと思います。

個人的には、Notebook形式で安く使いたい、という場合は、Modalではなく[GPUSOROBAN](https://soroban.highreso.jp/)だと思いました。
もちろん、AWS SageMakerやVertexAI、Azure Machine Learningなど、3大クラウドサービスのNotebookでGPUを利用することもできます。

ただ、ちょっとGPUを使ってローカルLLMの検証をしたいだけなのに、フルサービスのクラウドサービスを利用して（個人的にわかりづらいと思っている）各種セキュリティやサービスの設定方法をしっかり調べないといけないのは、面倒くさいと思ったので、ModalまたはGPUSOROBANが良いと思っています。


ここまでは、Modalのメリットや他のサービスのディスりをしてしまいましたが、Modalのデメリットもあります。

- ①Notebook形式で使えない。
    - Notebook形式で使えないのはデータサイエンス系の人が（LLMを使った）データ分析がしづらいと思います。
- ②独特なコードが必要である。
    - 後述しますが、Modalを利用するためにインスタンスの設定をpython上で書く必要があります。別のサービス等でのコードの再利用性は低いです（個人でちょっと使う分には良いと思いますが、コードの持ち運びを考えると、Modalはあまり良い選択肢ではないかもしれません）。


# Modalとは？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/687015/0fd4eed4-77e2-f5bd-b40b-8f443c0a1867.png)
ModalのHPの画面のスクショ。かっこいいですね。

- GPUを使えるクラウドのサーバ
- Pythonが動かせる
    - そのため、LlamaのようなオープンソースのLLMモデルを動かすのに適していると言えます。
    - もちろん、画像生成AIも使えます。
- 他のGPUサーバとの違いの特徴を一般的なGPUサーバ提供サービスとの違いを記載すると、
    - 一般的なサーバは従量課金制であれば、インスタンスの設定と起動をした後にpythonコードの開発や実行をするため、インスタンスを立ち上げてから終了させるまでの間の時間に対してお金がかかります（厳密にはストレージやネットワークのデータ転送量なども課金対象ですが）。
    - Modalは違います。インスタンスを利用する時間に対して課金されるのは同じなのですが、インスタンスは自動で起動して自動で終了してくれる点が大きな違いです。切り忘れることが防げます。
        - 個人的な失敗談ですが、AWSのSageMakerを利用した際に、インスタンスを切ったと思っていたら、別のサービスが切れていなかったせいで、ムダなお金を発生させてしまったという苦い経験があります。



# まずはチュートリアルをしてみる

## ライブラリのインストール

```bash
pip install modal
python3 -m modal setup
```

コマンドプロンプト（またはターミナル）にて、URLと一緒にトークンを発行してくれ、的なことが書かれているので、URLにアクセスして、トークンを発行しましょう。
（トークンは、Modalと接続するために必要です。ユーザがトークンを保存する必要は無いです。システム側で自動的に保存してくれます。）


## pythonコード

```python:get_started.py
import modal

app = modal.App("example-get-started") # 実行するインスタンス（アプリ）の名称

@app.function()
def square(x):
    print("This code is running on a remote worker!")
    return x**2

@app.local_entrypoint()
def main():
    print("the square is", square.remote(42))
```

### 解説

```python
app = modal.App("example-get-started")
```

ここで、インスタンス（アプリ）の名称を決めています。指定の名前をつける必要などはないので、自由に決めてしまってOKです。

Pythonを以下のコマンドで実行させると、Modalのダッシュボード画面上で表示されます。アプリ名でModalダッシュボード画面上でログを確認したりできるので、そういうときに区別しやすい名称にしておくのが良いと思います。

```python
@app.function()
def square(x):
    print("This code is running on a remote worker!")
    return x**2

@app.local_entrypoint()
def main():
    print("the square is", square.remote(42))
```

作成したコードの中で最初に動くのは、`main`という関数です。mainという名前だからというわけではないです。特定のデコレータ（`@app.local_entrypoint()`）がついている関数が最初に実行されます。

local entry pointという名前から分かるように、ローカル（手元の端末）でコードを実行したときに、最初に動く場所（エントリーポイント）ということですね。

その関数の中で、`square`という関数が呼び出されています。今回の場合は、42の2乗を計算するコードとなっています。

ポイントは2つです。

- ①関数の定義にもデコレータがついていること（`@app.function()`）。`@app.function()` というデコレータが付いている関数はリモート（Modalのサーバ）上で実行されます。
- ②local entrypointから 呼び出すときに、`関数の名前.remote(関数の引数)` という形で呼び出していること。Modalサーバ上で動かしたい関数は `関数の名前.remote(関数の引数)` という形で呼び出します。※補足）あえてローカルで動かしたい場合は `関数の名前.local(関数の引数)` という形で呼び出すことで、利用できます。

## 実行させる

作成したコードは、以下のようなコマンドで実行することで、Modalサーバ上で動かせます。

```bash
modal run get_started.py
```

# ローカルLLMを動かすためのコード

```python:app.py

import os
import modal
from modal import Image, Stub, method, gpu

app = modal.App("Llama-test")

# ローカルLLMをDLしたときの保存先（この保存先は、Modalのインスタンス上に保存されて、実行完了したら削除される）
MODEL_DIR = "/model"

# 利用したいLLMモデルの名前。今回はLlama3.1 8B Instructを使うことにします。
BASE_MODEL = "meta-llama/Llama-3.1-8B-Instruct"

def get_answer(result: str, anchor_str: str = "
assistant"):
	"""LLMからの回答をパースするための関数"""
    index = result.find(anchor_str)
    # インデックスが見つかった場合、インデックスより後の部分を取得
    if index != -1:
        start_position = index + len(anchor_str)
        result = result[start_position:].strip()  # strip()を使って前後の空白を削除
    else:
        result = result
    return result

def setup_llama_model() -> None:
	"""Modalのインスタンスを立ち上げるときに実行する関数。今回はHugging FaceからモデルをDLする"""
    # hugging faceにログインする。
    from huggingface_hub import login
    HUGGING_FACE_TOKEN = "hf_**********" # ← hugging faceのトークンに置き換えてください。
    login(HUGGING_FACE_TOKEN)

    # モデルをDLしてローカル（Modalのサーバ側）に保存しておく
    from huggingface_hub import snapshot_download
    from transformers.utils import move_cache

    os.makedirs(MODEL_DIR, exist_ok=True)

    snapshot_download(
        BASE_MODEL,
        local_dir=MODEL_DIR,
    )

    move_cache()

# Modalで使うimageの設定
# Modalのインスタンスは自分でカスタマイズすることができます。
# そのカスタマイズの設定です。
# LLMを使うためには、GPU利用のための環境が必要なので、
# - GPUを利用できるようにするための設定と、
# - pythonのライブラリのインストール
# - 上で定義している関数（LLMをDLする）を実行する
# という3つを実行しておく。
# Dockerに慣れている人はDockerっぽさを感じるのではないでしょうか？
# Modalのdocsにも書いてあるのですが、Modal上ではDockerコンテナが立ち上がるらしく
# そのための設定をユーザがする必要があるみたいです。
image = (
    Image.from_registry("nvidia/cuda:12.1.0-base-ubuntu22.04", add_python="3.12")
    .apt_install("git")
    .pip_install("torch", "huggingface_hub[cli]", "transformers", "accelerate", "bitsandbytes", "huggingface_hub", "hf-transfer", gpu="A10G")
    .env({"HF_HUB_ENABLE_HF_TRANSFER": "1"}) # これがないとインスタンスの立ち上げにすごい時間がかかる : Use the barebones hf-transfer package for maximum download speeds. No progress bar, but expect 700MB/s.
    .run_function(
        setup_llama_model,
        timeout=60 * 20,
    )
)

# 上で設定したModalのインスタンスの中で動く関数
# デコレータの引数として
# - `image = image`としているのは、上で定義したインスタンスを指定しています。
# - `gpu = "A10G"`としているのは、Modalインスタンスで実際に利用するGPUの名前を指定しています。
# 利用できるGPUの一覧は、（https://modal.com/docs/reference/modal.gpu）に記載されています。
@app.function(image=image, gpu=gpu.A10G())
def run_llm():
	"""LLMを動かします。"""
    from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
    import torch

    # Acceleration with quantization configurations
    # 検証のために動かすので、量子化してモデルサイズを軽くしておきます。
    quantization_config = BitsAndBytesConfig(
        load_in_4bit=True,  # Enable 4-bit quantization
        llm_int8_threshold=6.0,  # Int8 mode threshold
        llm_int8_has_fp16_weight=True  # If true, reduce precision of weight to FP16
    )

		# モデルを定義します。
		# 通常、hugging faceからモデルをDLしてきて利用する場合、モデルのリポジトリの名前を指定することが多いと思いますが、
		# 今回は、setup_llama_model()関数でモデルをMODEL_DIRにダウンロード済みなので、
		# モデルのディレクトリを指定しています。
	model = AutoModelForCausalLM.from_pretrained(
        MODEL_DIR,
        quantization_config=quantization_config,
        device_map="auto", # Automap to available devices (CPU or GPU)
        low_cpu_mem_usage=True  # Reduce CPU memory usage,
    )
    
		# model同様、ダウンロード済みのモデルのディレクトリを指定します。
    tokenizer = AutoTokenizer.from_pretrained(MODEL_DIR)

    # CUDAが使えることを確認しておきます。
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model.to(device)

    messages = [
        {"role": "system", "content": "あなたは親切なAIアシスタントです。"},
        {"role": "user", "content": "AIによって私たちの暮らしはどのように変わりますか？"}
    ]

    input_ids = tokenizer.apply_chat_template(
        messages,
        add_generation_prompt=True,
        return_tensors="pt"
    ).to(model.device)

    output_ids = model.generate(
        input_ids,
        max_new_tokens=1024,
        temperature=0.5,
    )

    result = tokenizer.decode(output_ids[0], skip_special_tokens=True)

    return get_answer(result)

@app.local_entrypoint()
def main():
    for _ in range(3):
        print("="*10)

    llm_answer = run_llm.remote()

    print(llm_answer)
    # ファイルに書き込む
    with open('output.txt', 'w', encoding='utf-8') as file:
        file.write(llm_answer)

    for _ in range(3):
        print("="*10)
```

## 実行する

```bash
modal run app.py
```

## 解説
コードの中にほとんどの解説を記載しています。Modalの独特の書き方に面食らうかもしれませんが、丁寧に読むと難しくないです。

多分、最初に見て「え？」となる驚きポイントは、Modalのインスタンスの設定周りだと思います。

上記の中で具体的には、

- `setup_llama_model()`
    - Modal独特の書き方というよりもHugging faceを利用してかつModalを利用する時に、インスタンス上にモデルをDLしておく方が実行時間を短くできる（＝安くできる）ので、そのようにしています。
- `image`
    - Dockerに慣れている人であれば、なんとなく何をしているかわかると思います。
    - 慣れていない人からすると何をしているかわからない部分かもしれません。
    - `Image.from_registry("nvidia/cuda:12.1.0-base-ubuntu22.04", add_python="3.12")`
        - CUDAを利用できるインスタンス（誤解を恐れずに言えば、パソコンのスペック）を設定しています。
    - `.apt_install("git")`
        - gitを利用できるようにしています。
        - hugging faceからモデルをDLしてくるので、Gitが必要ということですね。
    - `.pip_install("torch", "huggingface_hub[cli]", "transformers", "accelerate", "bitsandbytes", "huggingface_hub", "hf-transfer", gpu="A10G")`
        - 書き方がそのままなのでわかると思いますが、pip installで入れるライブラリを指定しています。
    - `.env({"HF_HUB_ENABLE_HF_TRANSFER": "1"})`
        - 私の方ではこれがまだ何なのかわかっていないのですが、これを設定しないと、インスタンスの立ち上げにものすごい時間がかかります。
    - `.run_function(
            setup_llama_model,
            timeout=60 * 20,
        )`
        - 『`setup_llama_model()` 』を実行します。
        - Docker関係の技術で言えば、Dockerfileに近い役割を果たしていますね。

これらがわかってしまえば、あとは通常のPythonと同じなので、読み解けるのではないかと思います。



# まとめ

- GPUを利用できるサービスを調べました。
- その中で安さと、インスタンスの切り忘れを防ぎやすい、という2点をメインにModalをチョイスしました。
- Modal自体は一部、独特な書き方をする必要がありますが、インスタンスの設定のためにそれが必要なだけで、他の主となるコードの部分に関しては通常のpythonと同じように書けます。
- そこが気にならなければ、Modalを利用してローカルLLMを試すハードルは下がるのではないかと思います。