| name | modal |
| description | [Applies to: **/*] This guide provides opinionated, actionable best practices for writing high-performance, cost-effective, and maintainable AI/ML applications on Modal. |
| source | cursor_mdc |
modal Best Practices
Modal is the definitive platform for deploying AI/ML workloads. To leverage its full potential – sub-second cold starts, instant autoscaling, and GPU acceleration – you must adhere to these best practices. This guide cuts through the noise, providing the exact patterns your team will use daily.
Code Organization and Structure
A well-structured Modal application is modular, explicit, and easy to debug.
1. Centralize Your modal.Stub
Always define a single, well-named modal.Stub at the top level of your main application file. This Stub is the entry point for all your Modal functions, images, and volumes.
❌ BAD: Multiple Stub definitions or generic names
import modal
stub_a = modal.Stub("my-app-part-a")
import modal
stub_b = modal.Stub("my-app-part-b")
✅ GOOD: Single, descriptive Stub
import modal
stub = modal.Stub("my-inference-service")
2. Modularize Your Application
For larger applications, separate your core logic (e.g., model loading, inference pipeline) into distinct Python modules. Import these modules into your main app.py where your modal.Functions are defined. This keeps your Modal definitions clean and your business logic testable.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
class ModelLoader:
def __init__(self, model_id: str):
self.model_id = model_id
self.model = None
self.tokenizer = None
def load(self):
if self.model is None:
print(f"Loading model {self.model_id}...")
self.tokenizer = AutoTokenizer.from_pretrained(self.model_id)
self.model = AutoModelForCausalLM.from_pretrained(self.model_id, torch_dtype=torch.bfloat16)
print("Model loaded.")
return self.model, self.tokenizer
import modal
from .model_loader import ModelLoader
stub = modal.Stub("my-inference-service")
inference_image = modal.Image.from_registry("nvcr.io/nvidia/pytorch:23.09-py3") \
.pip_install("torch", "transformers")
model_volume = modal.Volume.from_name(, create_if_missing=)
():
model_id =
model_loader = ModelLoader(model_id)
model, tokenizer = model_loader.load()
inputs = tokenizer(prompt, return_tensors=).to(model.device)
outputs = model.generate(**inputs, max_new_tokens=)
tokenizer.decode(outputs[], skip_special_tokens=)
():
(generate_text.remote())
Common Patterns and Anti-patterns
These patterns ensure your Modal functions are robust, performant, and cost-efficient.
3. Explicit Resource Specification
Always declare the exact GPU type, CPU cores, and memory your function needs. This allows Modal's scheduler to provision precisely what's required, optimizing performance and cost. Never rely on defaults for production workloads.
❌ BAD: Default (unspecified) resources
@stub.function()
def process_data(data):
pass
✅ GOOD: Specific GPU, CPU, and Memory
@stub.function(
gpu="A10G",
cpu=4,
memory="16Gi",
timeout=600
)
def run_gpu_inference(input_data):
pass
4. Reproducible Environments with modal.Image
Build custom Docker images or pin existing ones to guarantee identical dependencies across runs. This is critical for reproducibility and avoiding "works on my machine" issues. Use modal.Image to define your environment once.
❌ BAD: Installing dependencies inside the function or relying on pip_install directly on the function decorator
@stub.function(image=modal.Image.debian_slim().pip_install("numpy", "pandas"))
def analyze_data(df):
import numpy as np
✅ GOOD: Pre-build your modal.Image with all dependencies
inference_image = (
modal.Image.from_registry("nvcr.io/nvidia/pytorch:23.09-py3")
.pip_install(
"torch==2.1.0",
"transformers==4.35.2",
"accelerate==0.24.1",
"sentencepiece==0.1.99"
)
.apt_install("git", "ffmpeg")
)
@stub.function(image=inference_image, gpu="A10G")
def run_llm_inference(prompt: str):
import torch
from transformers import pipeline
5. Persistent Model Weights with modal.Volume
Store large model checkpoints in Modal's persistent modal.Volumes. Load them lazily inside your functions. This avoids repeated uploads, speeds up cold starts, and decouples model versions from code deployments.
❌ BAD: Downloading models on every cold start or bundling in the image
@stub.function(image=inference_image, gpu="A10G")
def infer_with_model(input_data):
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "mistralai/Mistral-7B-Instruct-v0.2"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id)
✅ GOOD: Lazy loading from a modal.Volume
import modal
from pathlib import Path
stub = modal.Stub("my-inference-service")
model_volume = modal.Volume.from_name("my-llm-weights", create_if_missing=True)
@stub.function(image=inference_image, volumes={"/models": model_volume}, gpu="A10G")
def download_model_to_volume(model_id: str):
from transformers import AutoModelForCausalLM, AutoTokenizer
local_model_path = Path("/models") / model_id
if not local_model_path.exists():
print(f"Downloading {model_id} to volume...")
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(model_id)
tokenizer.save_pretrained(local_model_path)
model.save_pretrained(local_model_path)
print("Download complete.")
else:
print(f"{model_id} already exists in volume.")
@stub.function(image=inference_image, volumes={"/models": model_volume}, gpu="A10G")
def perform_inference(prompt: str, model_id: = ):
transformers AutoModelForCausalLM, AutoTokenizer
local_model_path = Path() / model_id
local_model_path.exists():
()
download_model_to_volume.remote(model_id)
()
tokenizer = AutoTokenizer.from_pretrained(local_model_path)
model = AutoModelForCausalLM.from_pretrained(local_model_path)
():
download_model_to_volume.remote()
(perform_inference.remote())
6. Optimize Concurrency and Dynamic Batching
For high-throughput inference, configure max_concurrent_inputs and enable dynamic batching (beta) to let Modal automatically group requests. This significantly reduces per-request latency and increases GPU utilization.
❌ BAD: Default concurrency for a busy API
@stub.function(gpu="A10G")
def process_single_request(input_data):
pass
✅ GOOD: High concurrency with dynamic batching
@stub.function(
image=inference_image,
gpu="A10G",
volumes={"/models": model_volume},
max_concurrent_inputs=100,
allow_concurrent_inputs=True,
batch_size=32,
batch_timeout=100,
idle_timeout=300
)
def batched_inference(inputs: list[str]):
print(f"Processing batch of size {len(inputs)}")
results = [f"Processed: {i}" for i in inputs]
return results
@stub.local_entrypoint()
def main():
results = list(batched_inference.map(["prompt 1", "prompt 2", "prompt 3"]))
(results)
7. Secure Secrets Management
Never hardcode API keys, tokens, or sensitive configuration directly in your code or environment variables. Use modal.Secret to securely inject secrets into your functions.
❌ BAD: Hardcoding secrets or using insecure environment variables
OPENAI_API_KEY = "sk-..."
✅ GOOD: Use modal.Secret.from_name
import modal
import os
stub = modal.Stub("my-secure-app")
openai_secret = modal.Secret.from_name("my-openai-secret")
@stub.function(secrets=[openai_secret])
def call_openai_api(prompt: str):
import openai
openai.api_key = os.environ["OPENAI_API_KEY"]
response = openai.Completion.create(engine="davinci", prompt=prompt)
return response.choices[0].text
@stub.local_entrypoint()
def main():
print(call_openai_api.remote("Tell me a joke."))
Performance Considerations
8. Optimize Cold Starts with Snapshotting
Modal excels at cold starts, but you can further optimize by ensuring your image is lean and by using modal.Image.run_commands for heavy pre-computation if needed. For large models, lazy loading from modal.Volume is paramount (see #5).
optimized_image = (
modal.Image.from_registry("ubuntu:22.04", force_build=True)
.apt_install("python3-pip", "git")
.pip_install("fastapi", "uvicorn")
.run_commands(["python -c 'import torch; print(torch.__version__)'"])
)
@stub.function(image=optimized_image)
def fast_startup_function():
pass
Common Pitfalls and Gotchas
9. Don't Forget stub.local_entrypoint() or stub.serve()/stub.deploy()
Your Modal application won't run or deploy without a designated entry point. For local testing and development, use local_entrypoint. For deploying as a persistent service, use serve (for webhooks) or deploy.
❌ BAD: Missing an entry point
import modal
stub = modal.Stub("my-app")
@stub.function()
def my_func():
print("Hello")
✅ GOOD: Define a local_entrypoint for development
import modal
stub = modal.Stub("my-app")
@stub.function()
def my_func():
print("Hello from Modal!")
@stub.local_entrypoint()
def main():
my_func.remote()
✅ GOOD: Define a serve entrypoint for web services
import modal
stub = modal.Stub("my-api")
@stub.function()
@modal.web_endpoint(method="GET")
def hello():
return {"message": "Hello, world!"}
10. Debugging Remote Functions
Debugging on Modal requires a different mindset. Leverage print statements, Modal's integrated logs, and modal.lookup() for inspecting deployed objects. Avoid complex interactive debugging directly on remote functions.
@stub.function(image=inference_image, gpu="A10G")
def debuggable_function(input_data):
print(f"Received input: {input_data}")
try:
result = some_complex_calculation(input_data)
print(f"Calculation successful: {result}")
return result
except Exception as e:
print(f"Error during calculation: {e}")
raise
After running, check modal logs <app-id> or the Modal dashboard for detailed output.
Testing Approaches
11. Unit Test Your Core Logic Locally
Before deploying to Modal, thoroughly unit test the pure Python logic of your application. This includes model loading, data preprocessing, and post-processing. Modal functions should primarily orchestrate these well-tested components.
import pytest
from src.my_ml_app.model_loader import ModelLoader
def test_model_loader_initialization():
loader = ModelLoader("test/model")
assert loader.model_id == "test/model"
assert loader.model is None
12. Use f.local() for Quick Local Modal Function Testing
For quick checks of how your modal.Function interacts with your local environment and stub, use f.local(). This executes the function directly on your machine, bypassing Modal's cloud infrastructure.
import modal
stub = modal.Stub("my-app")
@stub.function()
def add_one(x: int) -> int:
return x + 1
@stub.local_entrypoint()
def main():
result = add_one.local(5)
print(f"Local result: {result}")
13. Integration Test Deployed Endpoints
For end-to-end validation, deploy your application and write integration tests that hit the live Modal endpoints (webhooks or deployed functions). This verifies the entire stack, including environment setup, resource allocation, and data flow.
import requests
app_url = "https://<your-app-name>-<user-id>.modal.run"
def test_hello_webhook():
response = requests.get(f"{app_url}/hello")
assert response.status_code == 200
assert response.json() == {"message": "Hello, world!"}