💾

サーバレスGPUにModalがいいぞ!

2024/10/10に公開

GPUを使いたいこと、あると思います。
ただしご家庭に強いGPUはないこともあるでしょうし、かといってGPU搭載したインスタンスを立て続けているととてつもないお金がかかります。

そんなあなたにサーバレスGPU、使った秒数分だけ課金が発生するので、いきなりすごい金額がかかることにはならず手軽にGPUを使ったプログラミングが始められます。

私は今の所ModalとRunPodを試したのですが、ModalのDXが良すぎるため今回イントロ記事を書く筆を取りました。
https://modal.com/

始めにざっくりいいところを述べると

  • 一つのファイルで完結する。
    • RunPodとの比較だと「RunPodではDockerのイメージを自前でビルドしてどこかしらのregistryにpush、都度タグを付け替えてRunPodの方にも反映」みたいなのが必要だったがModalでは全部要らない
  • ローカルパッケージをちょろっとした関数で送れたり、テキスト以外のPythonオブジェクト実行時の引数に詰められて細かいところが色々便利

などなどです。

ちなみにお値段なのですが、他のサーバレスGPUサービスと比べると高めなのですが、どのくらい使うかにも寄りますがそれを補ってあまりあるDXの高さがあると感じています。

A100で1時間という条件で比較してみますと次のような料金です。(2024年10月確認時点での料金です)

  • Lambda Labs(40GB): $1.29
  • Cudo Compute(80GB): $1.59
  • RunPod(80GB): $2.17
  • Modal(40GB): $3.17
  • Modal(80GB): $4.75

他のサービスとの比較については下記の記事も参考にしてみてください。
https://qiita.com/nekoniii3/items/344f0c0eb7e6e71c0243

Getting Started

ModalのGetting Startedをなぞるだけではあるのでサクッと行きます。
https://modal.com/docs/guide

まずはSign Upアカウントを作りましょう。
https://modal.com/

pip install modal でインストールします。これでCLIから modal コマンドが使えるようになります。

modal setup を実行するとブラウザから認証ができるので、アカウントを選ぶとローカルにトークンが保存され、modal上の処理を実行できるようになります。

ちなみにこれだと手動で認証した自分の手元からしか実行できないので、実際にプロダクトで動かす時には次のような環境変数を仕込みます。

MODAL_TOKEN_ID=
MODAL_TOKEN_SECRET=

中身の値は https://modal.com/settings/{user_name}/tokens からNew Tokenをクリックすることで取得できます。

ではとりあえずHello Worldしてみましょう!
まずはスクリプトを用意します。

import sys
import modal

# Modalで事前に作る必要などはなくこちらで自由に名前をつけられます
# 名前を変えると別アプリと判定され、ログも分かれますし、後述するDockerイメージのビルドも再起動されます
app = modal.App("example-hello-world")

# こちらがModal上で実行されます
@app.function()
def f(i):
    if i % 2 == 0:
        print("hello", i)
    else:
        print("world", i, file=sys.stderr)

    return i * i

# こちらは自分の実行環境なので、ローカルのパッケージなりファイルなりにアクセスできます
@app.local_entrypoint()
def main():
    # run the function locally
    print(f.local(1000))

    # run the function remotely on Modal
    print(f.remote(1000))

    # run the function in parallel and remotely on Modal
    total = 0
    for ret in f.map(range(200)):
        total += ret

    print(total)

そして実行します。

modal run test.py

するとこんな感じでログが表示されて実行されているのた見て取れます。

✓ Initialized. View run at https://modal.com/apps/kazuyaseki/main/ap-xxxxxxxxxxxxxxxxxx
✓ Created objects.
├── 🔨 Created mount /modal/test.py
└── 🔨 Created function f.
hello 1000

@functionのパラメータ

というのが至極シンプルな例ですが、このままだとpythonの諸々便利なパッケージが参照できなかったり、他にも必要なファイルを送りたかったりするかもしれません。なので必要なパラメータを一通り紹介していきます。

一通りのパラメータはドキュメントのこちらに全部載ってますが、よく使うものだけ抜粋していきます。
https://modal.com/docs/reference/modal.App#function

gpu

やはりサーバレスGPUを使うのでどのGPUを使うかは非常に重要でしょう。
現状では次の種類のGPUたちがサポートされています。

  • “t4” → GPU(T4, count=1)
  • “l4” → GPU(L4, count=1)
  • “a100” → GPU(A100-40GB, count=1)
  • “h100” → GPU(H100, count=1)
  • “a10g” → GPU(A10G, count=1)

https://modal.com/docs/reference/modal.gpu

こんな感じでfunctionの引数として渡します。

@app.function(
    gpu="A10G"
)

もちろんGPUによって費用は変わります。(2024年10月時点での料金)

GPU モデル 価格(/秒) 価格(/時間)
Nvidia H100 $0.001644 $5.92
Nvidia A100, 80 GB $0.001319 $4.75
Nvidia A100, 40 GB $0.000881 $3.17
Nvidia A10G $0.000306 $1.10
Nvidia L4 $0.000222 $0.80
Nvidia T4 $0.000164 $0.59

https://modal.com/pricing

image

Modalでは環境を整えていくにあたってDockerを使います。
https://modal.com/docs/guide/custom-container

  • .from_registry でDocker Hubに公開されてるimageを指定できます
  • apt_install でシステムパッケージをインストールできます
  • pip_install でPythonパッケージをインストールできます。バージョンを指定しない場合最新のものがインストールされます。
import modal

@app.function(
    image=modal.Image.from_registry("python:3.11-slim")
    .apt_install("ffmpeg")
    .pip_install(
        "ffmpeg-python==0.2.0",
        "pillow==10.4.0",
    ),
    gpu="A10G",
    timeout=7200,
)
def my_function():
    # 何かやる

こう書いて実行するだけでビルドしてくれます。最高だ...

Building image im-xxxxxxxxxxxxxx

=> Step 0: FROM base

=> Step 1: RUN python -m pip install ffmpeg-python==0.2.0 pillow==10.4.0 
Looking in indexes: http://pypi-mirror.modal.local:5555/simple
Collecting ffmpeg-python==0.2.0
  Downloading http://pypi-mirror.modal.local:5555/simple/ffmpeg-python/ffmpeg_python-0.2.0-py3-none-any.whl.metadata (1.7 kB)
Collecting pillow==10.4.0
  Downloading http://pypi-mirror.modal.local:5555/simple/pillow/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl.metadata (9.2 kB)
Collecting future (from ffmpeg-python==0.2.0)
  Downloading http://pypi-mirror.modal.local:5555/simple/future/future-1.0.0-py3-none-any.whl.metadata (4.0 kB)
Downloading http://pypi-mirror.modal.local:5555/simple/ffmpeg-python/ffmpeg_python-0.2.0-py3-none-any.whl (25 kB)
Downloading http://pypi-mirror.modal.local:5555/simple/pillow/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl (4.5 MB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.5/4.5 MB 671.2 MB/s eta 0:00:00
Downloading http://pypi-mirror.modal.local:5555/simple/future/future-1.0.0-py3-none-any.whl (491 kB)
Installing collected packages: pillow, future, ffmpeg-python
Successfully installed ffmpeg-python-0.2.0 future-1.0.0 pillow-10.4.0
Saving image...
Image saved, took 881.08ms

Built image im-xxxxxxxxxxxxxx in 4.83s
✓ Created objects.
├── 🔨 Created mount /modal/test.py
└── 🔨 Created function f.

また、内容が変わった時でも再度実行した時に再ビルドしてくれます。
注意点としてはビルドには当然のことながらそれなりに時間がかかるので、ユーザに提供するところで使う時は何らか先んじでビルド実行しておくことです。

CUDA入れる

CUDA入ってるイメージ引っ張ってくるのが楽です。add_pythonを足すことでPythonを入れられます。

@app.function(
    image=modal.Image.from_registry("nvidia/cuda:12.4.0-devel-ubuntu22.04", add_python="3.11")
)

https://modal.com/docs/guide/cuda

カスタムDockerfile読む

他にも色々カスタマイズしたいので最早普通にDockerfileを書きたいこともあるかもしれません。その場合は次のようにDockerfileへのパスを通すだけでOKです。

@app.function(
    image=modal.Image.from_dockerfile("Dockerfile_custom", add_python="3.11")
)

ローカルのデータを渡す

まず一つに、シンプルに実行元環境からリモート関数を呼び出す時の引数に入れるのが一つの方法です。
地味に聞こえるかもしれませんがこれがめちゃくちゃ便利です。他のサービスでは基本文字列として渡してます。

import json

@app.function()
def foo(a):
    print(sum(a["numbers"]))

@app.local_entrypoint()
def main():
    data_structure = json.load(open("blob.json"))
    foo.remote(data_structure)

ドキュメントには次のように書いており、

Any data of reasonable size that is serializable through cloudpickle is passable as an argument to Modal functions.

こちらのライブラリでシリアライズできるものであれば何でも渡せるみたいです。音声や動画などのバイナリまで渡せるかは分からないですが、Pythonオブジェクトなら基本なんでも渡せそうです。
https://github.com/cloudpipe/cloudpickle

次にmountsを使って渡す方法です。
実行元の環境から必要なファイルを渡すことができるようになります。
https://modal.com/docs/guide/local-data

ディレクトリ毎送る方法とファイル単体で送ることができます。remote_pathがModal実行環境内でのパスなので、送ったファイルを使う時はこちらで指定したパスを参照します。

@app.function(mounts=[
    # ディレクトリ毎渡す
    modal.Mount.from_local_dir("/hoge/fuga", remote_path="/root/"),
    # ファイルを渡す
    modal.Mount.from_local_file(
        local_path="~/hoge.yml",
        remote_path="/root/hoge.yml",
    )
])
def some_func():
    # 何かやる

そして結構大事なのがローカルのPythonパッケージを渡す方法です。
手元で開発しているものを後々Modalに処理を移行したいとなった時に、逐一コピペは存外面倒なので、こうやってパッケージを気軽に移せるのは大変重宝します。

import modal
import my_local_module

app = modal.App()

@app.function(mounts=[modal.Mount.from_local_python_packages("my_local_module", "my_other_module")])
def f():
    my_local_module.do_stuff()

secrets

シークレットを渡すにあたっては二つ方法があり、一つはModalに登録しておく方法と、もう一つは実行元の環境から渡す方法です。

https://modal.com/docs/guide/secrets

Modalをブラウザから開くと Secrets タブがあるのでそちらを開いて新しいシークレットを追加します。

実行時に登録した名前を使うことでシークレットを os.environ に仕込むことができます。

@app.function(secrets=[modal.Secret.from_name("my-custom-secret")])
def some_function():
    secret_key = os.environ["KEY_NAME"]

実行時に追加することもできます。

import os
import modal

app = modal.App()

local_secret = modal.Secret.from_dict({"FOO": os.environ["LOCAL_FOO"]})


@app.function(secrets=[local_secret])
def some_function():
    print(os.environ["FOO"])

また、.env があれば .Secret.from_dotenv() を使うと読み込むことができます。

@app.function(secrets=[modal.Secret.from_dotenv()])
def some_other_function():
    print(os.environ["USERNAME"])

まとめ

以上、Modalの基本的な使い方の紹介でした。
かなり簡単に使い始められるのでオススメのサーバレスGPUです。
皆さんもぜひ使ってみてください!

Discussion