GPUが15秒で動く! Modalを使ってお手軽GPUプログラミング
PredNext代表の徳永です。GPUを使うコードを(おうちのGPUではなく)高性能なGPUでちょっとだけ動かしたいこと、よくありますよね。でも、NVIDIA H100みたいな強力なGPUは、一般のご家庭にはありません。そんなときに便利なのがModalです。
Modalは、Pythonなどでスクリプトを書いて、それをそのままサーバーで動かすタイプのクラウドサービスです。GPUクラウド界のHerokuのようなイメージを持っていただければ、だいたい合ってます。
まずはModalでGPUを動かしてみよう
難しい説明は後回しにして、まずは実際にModalを使ってGPUを動かしてみましょう。
1. 必要なもの
- Python 3.9以上
- Modalアカウント
- 基本的なPython知識
2. セットアップ
まず、Modalのライブラリをインストールします。今回はuvを使っていますので、使っていない人は適宜読み替えて下さい。もしくはこれを機にuvを使いはじめましょう。
uv init --app
uv add modal
Modal公式サイトでアカウントを作成し、APIキーを取得してください。
ターミナルで以下のコマンドを実行してログインします:
uv run modal setup
3. 最初のGPUプログラム
以下のコードをgpu_test.py
として保存してください:
import modal
app = modal.App()
image = modal.Image.debian_slim().pip_install("torch", "numpy")
@app.function(gpu="T4", image=image)
def train_simple_model():
"""簡単な機械学習処理をGPUで実行"""
import torch
import torch.nn as nn
print(f"使用デバイス: {torch.cuda.get_device_name(0)}")
print(f"PyTorchバージョン: {torch.__version__}")
# データセットを作成
X = torch.randn(1000, 20).cuda()
y = torch.randn(1000, 1).cuda()
# シンプルなニューラルネットワーク
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 1)).cuda()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
loss_fn = nn.MSELoss()
# GPU時間測定を開始
start_time = torch.cuda.Event(enable_timing=True)
end_time = torch.cuda.Event(enable_timing=True)
start_time.record()
for epoch in range(100):
optimizer.zero_grad()
pred = model(X)
loss = loss_fn(pred, y)
loss.backward()
optimizer.step()
if epoch % 20 == 0:
print(f"Epoch {epoch}, Loss: {loss.item():.4f}")
end_time.record()
torch.cuda.synchronize()
elapsed_time = start_time.elapsed_time(end_time)
return f"訓練完了!最終loss: {loss.item():.4f}, 実行時間: {elapsed_time:.2f}ms"
@app.local_entrypoint()
def main():
print("GPUでの機械学習を開始します...")
result = train_simple_model.remote()
print(f"{result}")
スクリプトを保存した後、以下のコマンドで実際にクラウド上のGPUを使って実行することができます。
uv run modal run gpu_test.py
4. 実行結果
うまく行けば、上記のコードを実行すると、以下のような出力が得られます:
GPUでの機械学習を開始します...
使用デバイス: Tesla T4
PyTorchバージョン: 2.7.1+cu126
Epoch 0, Loss: 0.9950
Epoch 20, Loss: 0.9431
Epoch 40, Loss: 0.9039
Epoch 60, Loss: 0.8565
Epoch 80, Loss: 0.8005
訓練完了!最終loss: 0.7446, 実行時間: 913.92ms
たったこれだけで、クラウド上にあるGPUを使ってPyTorchを動かすことができてしまいました。
Modalのメリット
Modalの最も驚くべき点は、コマンド呼び出しからクラウド側でのコード実行開始までの時間が非常に短いことです。コマンド実行からわずか15秒程度でクラウド側のコード実行が始まります。(初回実行時はイメージの作成のため、もう少し時間がかかります)
開発中にはこの実行の手軽さが極めて重要です。通常のクラウドサービスでは、インスタンスの確保と起動だけで約5分の待ち時間が必要です。例えば、実行に30秒かかるスクリプトを繰り返し実行しながら開発する場合、従来のサービスでは1時間に10回程度しか試行錯誤できません(インスタンスを起動しっぱなしという手もありますが、個人開発だと怖いですよね)が、Modalでは20〜30回の試行錯誤が可能になります。さらに、インスタンスを並列に立ち上げることもできるため、パラメーター探索などを並列実行すれば、効率に劇的な差が生まれます。
また、毎月$30のフリークレジットが付与されるため、少量の利用であれば実質無料で活用できます。
Modalの仕組み
シンプルなアノテーションで魔法のようにメソッドのリモート実行ができるModalですが、その裏側の仕組みは簡単ではありません。
Modalでは、ローカル環境とクラウド側環境の両方で同じスクリプトが実行されます。スクリプト内でapp.run()
が呼ばれると、実行中のスクリプトとその依存スクリプトが自動的にクラウド側に転送され、同じスクリプトがクラウド側でもロードされます。その後、func.remote()
などのリモートメソッド呼び出しを行うと、実行の主体がクラウド側
に移ります。(app.run()
は上のhello worldの例では@app.local_entrypoint()
デコレータの中で呼び出されているため、直接は使っていません。)
Modalが提供するmodalパッケージは、リモート実行の複雑な部分の多くを抽象化してくれますが、すべての問題を完全に隠蔽できるわけではありません。そのため、いくつかの微妙な問題が発生することがあります。例えば、ローカル環境とクラウド環境でPythonのバージョンが異なると、実行結果に差異が生じることがあります。このような問題への対応はユーザー側の責任となります。
Modalを便利に使うためのテクニック
基本的な使い方はhello, world! の例でわかったと思いますが、便利に使うためにはもう少しの知識や工夫が必要になります。次のステップとしてはA simple web scraperの例を見るのが良いでしょう。
以下では、公式文書で紹介されているものもされていないものも含め、Modalの便利な機能や、知っておかないと困りそうな事柄をいくつか紹介します。
独自コンテナイメージの作成
ModalはDockerイメージを使用してクラウド上の環境を構築しています。公式に提供されているコンテナイメージの他に、カスタマイズしたDockerイメージを自作することもできます。次の例は、公式イメージにpandasとnumpyをpipでインストールし、aptを使ってffmpegもインストールしてしまう例です。
my_image = (
modal.Image.debian_slim()
.pip_install("torch", "numpy")
.apt_install("ffmpeg")
)
GPUの指定
使えるGPUはT4からB200まで、それなりに幅広く準備されています。お試しにはとりあえず価格の安いT4などを使うとよいでしょう。
@app.function(gpu="T4", image=my_image)
def my_function():
# カスタム環境で実行
pass
複数GPUの使用
一つのインスタンスで最大で8枚までGPUを使うことができます。H100x8くらいになると、そこそこの規模の実験を回すことができますね。ただ、1時間で数十ドルくらいのコストがかかるので、無駄使いしないように気をつけましょう。以下はA100を2つ確保する例です。
@app.function(gpu="A100:2") # A100を2つ確保
def multi_gpu_training():
import torch
if torch.cuda.device_count() > 1:
print(f"{torch.cuda.device_count()}個のGPUを使用中")
コマンドライン引数を自分でパースする
公式サイトのサンプルでよく登場する@app.local_entrypoint()
を使用すると、自動的にコマンドライン引数をパースしてメソッド引数に設定してくれます。これはこれで便利ですが、細かい制御ができず、使いにくい場面もあります。以下のようにapp.run()
を自分で呼び出せば、@app.local_entrypoint()
を呼び出さずに済むので、コマンドラインのパースを自分で行うことができます。
def main(args):
with app.run():
func.remote(args)
ローカル環境でも同じスクリプトを動かす
app.run()
を自分で呼び出すことにすれば、同じスクリプトをローカルでもリモートでも動かしたい場合も簡単です。ローカルで動かしたい場合は、app.run()
を呼び出さなければいいだけです。.remote
の付け外し等、他にも若干の手間はありますが、それほど大きな問題ではありません。
def main(args):
if args.remote == True:
with app.run():
func.remote(args)
else:
func.local(args)
リモートオブジェクトのメソッドを呼び出す
メソッドに@app.function
デコレータを付けるのではなく、クラスに@app.cls
デコレータを付けると、リモートオブジェクトを定義してそのオブジェクトのメソッドを呼び出せます。この場合、呼び出したいリモートメソッドには@modal.method()
デコレータを付ける必要があります。
@app.cls(gpu="T4", image=image)
class TrainingModel:
def __init__(self):
import torch
self.model = torch.nn.Linear(10, 1).cuda()
self.optimizer = torch.optim.Adam(self.model.parameters())
@modal.method()
def train_step(self, x, y):
import torch
loss = torch.nn.functional.mse_loss(self.model(x), y)
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
return loss.item()
@app.local_entrypoint()
def main():
trainer = TrainingModel()
loss = trainer.train_step.remote(x_data, y_data)
print(f"Loss: {loss}")
ローカルからリモートへのファイルアップロード
Image.add_local_dir
を使用すれば、実行時にファイルをModal側にアップロードできます。パラメーターや設定などを別ファイルにしている場合、それらは自動ではアップロードされないので、こういった仕組みを使ってアップロードする必要があります。
# ローカルのディレクトリをアップロード
image_with_data = modal.Image.debian_slim().add_local_dir(
local_path="./data", # ローカルのディレクトリ
remote_path="/modal/data" # Modal側のパス
)
@app.function(image=image_with_data)
def process_data():
import os
# /modal/data内のファイルが利用可能
files = os.listdir("/modal/data")
print(f"利用可能なファイル: {files}")
リモートからローカルへのファイルダウンロード
Volumeを使用すると、書き込んだファイルを永続化することができ、後でmodal volume get
コマンドでファイルを手元にダウンロードしてくることができます。Volumeは裏側がS3のようなオブジェクトストレージで構成された、ファイルシステムのようなものです。
# Volumeを作成
volume = modal.Volume.from_name("my-volume", create_if_missing=True)
@app.function(volumes={"/results": volume})
def save_model():
import torch
model = torch.nn.Linear(10, 1)
# このへんでモデルを学習(省略)
torch.save(model.state_dict(), "/results/model.pth")
# ログファイルも保存
with open("/results/training.log", "w") as f:
f.write("Training completed successfully")
return "ファイル保存完了"
実行後、以下のコマンドでファイルをダウンロード:
modal volume get my-volume model.pth ./local_model.pth
modal volume get my-volume training.log ./training.log
トラブルシューティング
以下では、私がModalで遭遇したいくつかのトラブルと、その解決方法を解説します。
インスタンスが自動的に複数立ち上がる問題
Modalはデフォルトでオートスケーリング機能が有効になっています。例えば、リモートオブジェクトを生成してtraining_stepを100イテレーションごとに呼び出すような処理を行うと、Modal側が「負荷が上昇している」と判断し、自動的に新しいインスタンスを立ち上げることがあります。Modalは並列リクエスト数ではなくrequests/min(もしくはrequests/sec)を監視しているので、こういう現象が発生します。
@app.cls
デコレータのオプションでallow_concurrent_inputs
とconcurrency_limit
を設定することでこの問題に対処できます。特にconcurrency_limit
が重要です。
@app.cls(
gpu="T4",
allow_concurrent_inputs=100, # キューに溜める呼び出し数
concurrency_limit=1 # 実際の同時実行は1つまで
)
class Trainer:
@modal.method()
def training_step(self, data):
import time
time.sleep(1)
return "処理完了"
@app.local_entrypoint()
def main():
trainer = Trainer()
for i in range(100):
result = trainer.training_step.remote(f"data_{i}")
print(f"Step {i}: {result}")
nfsとVolumeの名前の衝突に注意
nfsについては今回説明していません(Volumeへの移行が推奨されているため)が、nfsとVolumeで同じ名前のものを作成してはいけません。nfsとVolumeは別のシステムですが、同名のものを作成すると機能しなくなるため注意が必要です。現在は修正されている可能性もありますが、2024年の時点では原因不明のエラーが発生していました。トラブルシ ューティングが困難になるため、同名での作成は避けるか、そもそもnfsを使用しないことをおすすめします。
料金について
Modalの料金体系はちょっとややこしいですが、基本的には、使ったら使った分だけ、秒単位で課金されます。うれしい点として、毎月$30のフリークレジットが付与されます。これは小規模な実験やプロトタイプ作成には十分な金額で、個人開発者でも気軽に最新GPUを試すことができます。
実際に今回のテストで発生したコストを見てみると、機械学習訓練のテストでは約2分の実行時間(環境構築含む)でした。これにかかった推定コストは約0.02ドルです。2回めからはディスクイメージ作成のコストはかからないので、実行時間は大幅に短縮されます。
このように、短時間の処理であれば1回あたり数セント程度で最新GPUが利用できるため、開発段階での試行錯誤も気軽に行えます。T4 GPUの場合は約0.59ドル/時間、A100 GPUは約2.5ドル/時間となっており、他サービスと比較すると高額ですが、セットアップ時間の短縮や運用の手軽さを考慮すれば、十分に価値のある投資と言えるでしょう。
まとめ
今回は、GPUが異常にお手軽に使えるクラウドサービスとしてModalを紹介しました。価格はRunpodやLambda Labsなどと比較してしまうと少々お高めですが、お手軽かつ実用的という点ではModalの充実度は無視できません。ぜひ一度、使ってみてくださいね。
お仕事募集中
PredNextでは現在、お仕事の依頼を募集しています。得意分野は自然言語処理や画像処理を中心とするAI関連技術ですが、その中でもとくに軽量化、高速化を得意としています。ご興味のある方はお問い合わせフォームからご連絡ください。