Skip to content

Nodus for Modal users

View Markdown

The Nodus Python SDK keeps Modal’s names wherever the concept is the same: App, @app.function, .remote(), .map(), .spawn(), @app.cls with enter and exit hooks, Image, Volume, Secret and Sandbox. Most apps move by changing import modal to import nodus. This page lists what is the same, what is spelled differently and what Nodus adds.

Terminal window
pip install nodus-compute
nodus login # opens the console, stores a key for each org you pick
nodus run app.py # like `modal run`: an ephemeral App, deleted when the entrypoint returns
nodus deploy app.py # like `modal deploy`: a persistent App
nodus serve app.py # like `modal serve`: redeploys when a file changes

In CI, set NODUS_API_KEY instead of running nodus login.

examples/python/quickstart/app.py
"""Quickstart: one Function called three ways.
Run it with `nodus run examples/python/quickstart/app.py --n 10`.
"""
import nodus
app = nodus.App("quickstart")
@app.function(cpu=1, memory="1Gi", max_cost=1)
def square(x: int) -> int:
return x * x
@app.local_entrypoint()
def main(n: int = 10) -> None:
print("remote:", square.remote(7)) # one call; blocks for the result
call = square.spawn(8) # start without waiting
print("spawned:", call.get(timeout=600))
print("map:", list(square.map(range(n)))) # one call per input, results in input order
Modal Nodus
import modal import nodus
app = modal.App("x") app = nodus.App("x")
@app.function(gpu="H100", timeout=3600) @app.function(gpu="H100", timeout="1h") (numbers are also seconds)
f.remote(x), f.map(xs), f.spawn(x), call.get() The same
f.local(x) The same
@app.local_entrypoint() The same
modal.Function.from_name("app", "f") nodus.Function.from_name("app", "f") (Function.lookup also works)
@app.cls() with @modal.enter(), @modal.method(), @modal.exit() @app.cls() with @nodus.enter(), @nodus.method(), @nodus.exit()
modal.Image.debian_slim().pip_install("torch") nodus.Image.debian_slim().pip_install("torch")
modal.Image.from_registry(...), .apt_install, .run_commands, .env, .add_local_dir The same
modal.Volume.from_name("v", create_if_missing=True) The same; vol.commit() and vol.reload() behave as in Modal
modal.Secret.from_name("hf"), Secret.from_dict({...}) The same, plus Secret.from_dotenv(".env")
modal.Sandbox.create(app=app, image=...) nodus.Sandbox.create(image=...) (no App needed)
sb.exec("python", "-c", "..."), p.stdout.read(), p.wait() The same
sb.open(path, "w") The same
sb.tunnels() Returns a list of Tunnel(port, url, public); sb.tunnels.open(8080) is not available yet
sb.snapshot_filesystem() The same (Beta)
@modal.experimental.clustered(size=2) @nodus.clustered(size=2) (Beta)
min_containers, max_containers, scaledown_window The same, or min_workers and max_workers
await f.remote.aio(x) The same: every blocking call has an .aio form

GPU strings use Modal’s spellings: "H100", "H100:2", "A100-80GB", "A10G", "L40S". A family such as H100 matches any of its variants; "H100!" pins the exact variant. A list such as ["H100", "H200"] accepts either.

Nodus places every call on the cheapest capacity that finishes it on time, and it stops work before money runs out. These arguments have no Modal equivalent:

@app.function(
gpu="H100",
max_cost=40, # a hard cap in USD across this Function's workers
checkpoint="/nodus/state", # files here are saved and restored if capacity is reclaimed
interruptible=True, # allow cheaper interruptible capacity; progress is kept through the checkpoint
region=["us", "eu"], # region classes, not provider regions
)
def train(lr: float) -> dict: ...
print(train.estimate(3e-4)) # dry-run: expected cost, cold and warm start, the hold it needs
  • f.estimate(...) returns the expected cost and start time of one call before you run it.
  • A cold start on a GPU with no warm worker prints its expected wait, so a long first call is not a surprise.
  • Errors are typed: nodus.errors.InsufficientCredits states the amount needed and how to add credit.
  • nodus.Job, nodus.Workspace and nodus.llm cover batch jobs, development machines and inference with the same credentials.
  • Parametrized classes (modal.parameter()) are not supported. Configure the class in its @nodus.enter() hook instead. Calling MyClass(arg=...) raises nodus.errors.Unsupported.
  • The Python minor version of the image must equal yours, as in Modal. Image.debian_slim() defaults to your version; a mismatch is refused before anything runs.
  • Web endpoints (@modal.web_endpoint, @modal.asgi_app) are not available. Use nodus.InferenceEndpoint for model serving.
  • modal.Dict and modal.Queue have no equivalent. Pass data through return values, a Volume or your own database.
  • Clustered Functions (Beta) run each .remote() or .spawn() as one gang; .map() over a clustered Function raises nodus.errors.Unsupported.
  • Timeouts and durations accept Go-style strings ("90s", "6h") as well as seconds. Days are not a unit.
  • Money is always a decimal amount in USD (max_cost=40 or "40.00").