From Slurm to Flyte
A guide to moving batch and ML workloads off a Slurm cluster onto Flyte 2.
The compute code mostly survives the move. The training loop, the preprocessing script, and the eval harness come over unchanged. What changes is everything around them: where the environment comes from, how a job gets its inputs, how one job triggers the next, and how you ask for capacity.
Three shifts account for most of the work:
module loadand conda environments become container images. You declare the environment once, in Python, instead of relying on what happens to be installed on the login node.- The shared filesystem becomes explicit inputs and outputs. A script that assumes
/scratchexists on every node needs to take its data as arguments instead. sbatchchains become function calls. Dependency flags, sentinel files, and the cron entries that glue them together become ordinary Python.
The rest of this guide maps Slurm constructs onto their Flyte equivalents.
The
flyte-migrate-slurm skill
automates the mechanical part of this translation: #SBATCH directives become task environment
configuration, job arrays become flyte.map or asyncio.gather, and dependency chains become
plain Python. Treat its output as a first pass to review against this guide, not a finished port.
The mapping at a glance
| Slurm | Flyte |
|---|---|
sbatch train.sh |
flyte run train.py main |
#SBATCH --gres=gpu:a100:8 |
flyte.Resources(gpu="A100:8") |
#SBATCH --cpus-per-task=16 --mem=64G |
flyte.Resources(cpu=16, memory="64Gi") |
#SBATCH --time=04:00:00 |
@env.task(timeout=timedelta(hours=4)) |
#SBATCH --requeue |
@env.task(retries=3) |
#SBATCH --array=0-999 |
flyte.map(step, range(1000)) |
#SBATCH --nodes=4 --ntasks-per-node=8 |
ClusteredTaskEnvironment(replicas=4, nproc_per_node=8) |
#SBATCH --dependency=afterok:$JOBID |
await the upstream task |
#SBATCH --begin=..., cron on the login node |
@env.task(triggers=flyte.Trigger(...)) |
module load cuda && source venv/bin/activate |
flyte.Image.from_debian_base().with_pip_packages(...) |
$SLURM_PROCID, $SLURM_NNODES |
flyte.ctx().rank, flyte.ctx().nnodes |
squeue, sacct |
flyte get run, flyte get logs, the UI |
/scratch/$USER/data.parquet |
flyte.io.File passed between tasks |
The job script becomes a task
A representative Slurm job:
#!/bin/bash
#SBATCH --job-name=train
#SBATCH --partition=gpu
#SBATCH --gres=gpu:a100:8
#SBATCH --cpus-per-task=16
#SBATCH --mem=64G
#SBATCH --time=04:00:00
#SBATCH --requeue
module load cuda/12.1
source ~/venvs/train/bin/activate
srun python train.py --lr 3e-4The same job in Flyte:
from datetime import timedelta
import flyte
env = flyte.TaskEnvironment(
name="training",
image=flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("torch"),
resources=flyte.Resources(cpu=16, memory="64Gi", gpu="A100:8"),
)
@env.task(retries=3, timeout=timedelta(hours=4))
async def train(lr: float = 3e-4) -> flyte.io.File:
...The #SBATCH block splits in two. Anything that describes the environment (image, resources)
belongs on the flyte.TaskEnvironment, which is shared by every task declared against it. Anything
that describes this job (retries, timeout, caching) belongs on the @env.task decorator. Both
can be overridden per invocation with task.override(...), which has no Slurm equivalent: the same
task can run with 1 GPU in one call and 8 in the next.
The function signature also does more work than a job script’s argv. lr: float and
-> flyte.io.File are the task’s interface, and Flyte records the value of every input and output
of every run, so a checkpoint six months old can be traced back to the arguments that produced it.
Docs: TaskEnvironment · Resources · Overrides
module load becomes an image
This is usually the slowest part of a Slurm migration, and the one worth doing carefully. On a Slurm cluster the environment is ambient: modules, a conda env on NFS, whatever the sysadmin installed. In Flyte the environment is part of the task definition.
You do not need to write a Dockerfile. flyte.Image builds one for you from Python:
image = (
flyte.Image.from_debian_base(python_version=(3, 12))
.with_apt_packages("git")
.with_pip_packages("torch", "transformers", "datasets")
)If you already keep your dependencies in a pyproject.toml, point at it directly with
.with_uv_project("pyproject.toml"). If your organization already publishes a blessed CUDA image,
pass it by reference instead and skip the build entirely:
env = flyte.TaskEnvironment(
name="training",
image="registry.example.com/ml-platform/cuda-torch:2026.04.01",
)Two practical notes for people coming from modules:
- Different tasks can use different images. There is no single cluster-wide Python environment to keep everyone happy. A preprocessing task on a slim CPU image and a training task on a CUDA image are part of the same run.
- Images are content-hashed. Rerunning with an unchanged spec reuses the previous build instead of rebuilding.
Where that build runs depends on your deployment.
image.builder to remote in your config file to build with Union’s ImageBuilder, which runs
the build on the cluster and needs no Docker and no registry credentials on your machine. Set it to
local to build with Docker locally and push to a registry your cluster can pull from.Docs: Container images
Job arrays become fan-out
A Slurm job array indexes into work with $SLURM_ARRAY_TASK_ID and leaves collection of the
results to you, usually as files in a shared directory plus a script that reads them back.
#SBATCH --array=0-999%50
python process.py --shard $SLURM_ARRAY_TASK_IDIn Flyte the fan-out is a call, and the results come back as return values:
@env.task
async def process(shard: int) -> int: ...
@env.task
async def main(n_shards: int = 1000) -> int:
counts = flyte.map(process, range(n_shards), concurrency=50)
return sum(c for c in counts if not isinstance(c, Exception))concurrency=50 is the equivalent of the %50 throttle: at most 50 shards run at once, and the
rest wait. flyte.map yields results in input order and returns an exception object in place of a
result for shards that failed, so a partial failure does not cost you the whole array.
When the items are not uniform, or you want to fan out across different tasks, use asyncio.gather
instead:
results = await asyncio.gather(*(process(s) for s in shards), return_exceptions=True)Docs: Mapping over inputs · Fanout · Controlling parallelism
Job dependencies become ordinary Python
--dependency=afterok:$JOBID handles a linear chain. Anything past that (a fan-out that joins, a
branch on the result of an earlier step, a retry of just one stage) tends to become a driver bash
script, some sentinel files, and a wiki page describing the arrangement.
In Flyte, a pipeline is a task that calls other tasks. There is no workflow DSL and no graph to compile:
@env.task
async def main(ds: str) -> Report:
raw = await ingest(ds)
clean = await filter_rows(raw)
shards = await asyncio.gather(*(tokenize(clean, i) for i in range(8)))
model = await train(shards)
return await evaluate(model)Because the driver runs at execution time as normal Python, control flow is normal Python too.
Branching is if. Early exit is return. Failure handling is try/except/finally, and
specific failure modes are catchable by type:
import flyte.errors
@env.task
async def main(ds: str) -> int:
try:
return await transform(ds)
except flyte.errors.OOMError:
return await transform.override(resources=flyte.Resources(memory="64Gi"))(ds)Scheduled submission moves from a cron entry on the login node to a trigger on the task itself:
@env.task(triggers=flyte.Trigger("nightly", flyte.Cron("0 2 * * *")))
async def nightly_eval() -> Report: ...Docs: Triggers · Error handling
--requeue becomes retries, spot handling, and checkpoints
--requeue restarts the script from the top and leaves the rest to you. Flyte splits the problem
into pieces that can be configured separately.
Retries are declarative, and count only against failures your code is responsible for:
@env.task(retries=3, timeout=timedelta(hours=4))
async def train(cfg: TrainConfig) -> flyte.io.File: ...Spot capacity is a flag. interruptible=True schedules the task on spot or preemptible
instances. Preemptions are recorded as system failures rather than task failures, so they do not
consume the retry budget, and the last attempt falls back to on-demand so a task cannot loop
forever on reclaimed capacity.
Checkpoints make the retry cheap. flyte.ctx().checkpoint writes to object storage rather than
a shared filesystem, so the next attempt resumes on whatever node it lands on:
@env.task(retries=5)
async def train(steps: int) -> flyte.io.File:
ckpt = flyte.ctx().checkpoint
start = 0
if (prev := await ckpt.load()) is not None:
start = load_state(prev)
for step in range(start, steps):
...
if step % 100 == 0:
await ckpt.save(state_path)Task-level caching covers the other half of the problem. A cached task with unchanged inputs is skipped on re-execution, so rerunning a twelve-hour pipeline after fixing step nine starts at step nine instead of step one.
Docs: Retries and timeouts · Interruptible tasks · Intra-task checkpoints
Multi-node jobs become clustered tasks
--nodes=4 --ntasks-per-node=8 maps onto a ClusteredTaskEnvironment, which launches all replicas
together as a single Kubernetes JobSet with torchrun handling rendezvous:
import flyte
from flyte.clustered import ClusteredTaskEnvironment, ClusterFailurePolicy, TorchRun
env = ClusteredTaskEnvironment(
name="pretrain",
image=image,
resources=flyte.Resources(cpu=16, memory="64Gi", gpu="H100:8", shm="auto"),
replicas=4, # nodes
nproc_per_node=8, # processes per node, so world size is 32
runtime=TorchRun(rdzv_backend="c10d"),
failure_policy=ClusterFailurePolicy(max_restarts=2, restart_on_host_maintenance=True),
)
@env.task
async def pretrain(steps: int) -> flyte.io.File:
import torch.distributed as dist
dist.init_process_group(backend="nccl")
...Training code that already runs under srun with torchrun needs no changes: RANK, WORLD_SIZE,
MASTER_ADDR, and MASTER_PORT are populated in each worker as usual. The same values are
available from flyte.ctx() (rank, local_rank, node_rank, nnodes, world_size,
master_addr) if you would rather read them from Python. Only rank 0 uploads the task’s outputs.
ClusterFailurePolicy distinguishes between two things Slurm treats alike.
restart_on_host_maintenance=True restarts the job when the underlying node is preempted or drained
for maintenance, without spending the max_restarts budget you set aside for actual crashes.
Clustered tasks are new and currently target torchrun workloads. There is no MPI launcher, so
mpirun-based applications stay on Slurm. For Ray, Spark, or Dask, use the corresponding
integration, which brings up a per-task cluster and tears it down when the task finishes.
Docs: Clustered task environments
Partitions and QOS become queues
A queue is a named scheduling lane bound to a cluster pool, which is the closest analogue to a Slurm partition with a QOS attached. Targeting one is a single parameter, settable on the environment, the task, the invocation, or a trigger:
@env.task(queue="research-h100")
async def evaluate(model: flyte.io.File) -> EvalReport: ...
await train.override(queue="prod-high")(cfg)| Slurm | Flyte |
|---|---|
--partition=gpu |
queue="gpu", bound by an admin to a cluster pool |
--qos=high |
queue priority |
MaxJobsPerAccount |
run concurrency on the queue |
MaxSubmitJobs |
queue depth, which rejects past the limit rather than queueing forever |
squeue -p gpu |
flyte get queue gpu --watch |
Two differences from Slurm’s accounting model will affect how you plan capacity. Queue priority
controls ordering, not preemption: a running low-priority task is not evicted when higher-priority
work arrives. And when a queue hits its depth limit, new submissions are rejected immediately with
RESOURCE_EXHAUSTED instead of sitting in PENDING for an unknown length of time. That rejection
is a back-pressure signal for the submitting process to slow down.
Docs: Queues · Managing queues
The shared filesystem becomes explicit data
This mapping has no one-line translation. Doing it properly is what buys you lineage, caching, and reproducible reruns; working around it costs you all three.
On Slurm, your home directory and /scratch are visible from the login node and every compute node,
so a job reads and writes paths and the filesystem does the rest. There is no equivalent guarantee
in Flyte. Data moves because a task takes it as an argument or returns it.
For most cases, the change is mechanical. Values that fit in a return type (numbers, strings,
dataclasses, Pydantic models) travel as return values. Anything larger travels as flyte.io.File
or flyte.io.Dir, which are typed references to object storage:
from flyte.io import Dir, File
@env.task
async def tokenize(raw: Dir) -> File:
out = File.new_remote()
async with out.open("wb") as f:
...
return out
@env.task
async def train(tokens: File) -> File:
local = await tokens.download()
...A File passes between tasks the way an int does. The upload on write and the download on read
are handled for you, and the object supports streaming, so reading a range out of a 500 GB file does
not require pulling the whole thing to local disk first.
Workloads that genuinely need a filesystem view have an escape hatch. If you already run a parallel filesystem such as FSx for Lustre or a shared NFS export, mount it into tasks with a pod template. Pod templates give you the full Kubernetes pod spec: volumes, node selectors, tolerations, service accounts, and sidecars.
Docs: Files and directories · Pod templates
Watching and debugging jobs
squeue and sacct map onto the CLI and the UI:
flyte get run # recent runs and their phases
flyte get run <run-name> # actions within a run
flyte get logs <run-name> # streaming logsRuns are also visible in the UI with per-task logs, GPU and memory utilization, and inputs and outputs for every action.
srun --pty bash and ssh node042 both have direct equivalents. You can
SSH into a running task, currently in
beta, which is the closest match to an interactive Slurm session. A run launched with --debug (or
debug=True from the SDK) goes further and starts a browser-based VS Code session inside the task
pod, where you can set breakpoints and step through the code on the same hardware, against the same
data and dependencies the run uses. Any running action can also be opened from the UI with one click.
What differs is where you land. An interactive session on a Slurm node puts you on a shared machine whose environment may not match the job; here you land inside the failing task’s own container, with the image and paths that task actually ran with.
Docs: Debug a run · View logs
Cold start and warm pools
A Slurm allocation feels instant because the nodes are already yours and already running. In Flyte, each task call gets a fresh pod by default, which means scheduling, an image pull, and interpreter startup before your code runs. On a task that runs for hours, that overhead is noise. On a fan-out of hundreds of short tasks against a heavy image, it dominates.
A reuse policy keeps a pool of warm containers alive across invocations:
env = flyte.TaskEnvironment(
name="rollouts",
image=flyte.Image.from_debian_base().with_pip_packages("unionai-reuse", "vllm"),
resources=flyte.Resources(gpu="L4:1"),
reusable=flyte.ReusePolicy(replicas=(2, 10), concurrency=4, idle_ttl=300),
)The pool autoscales between the given bounds and shuts down after idle_ttl seconds of inactivity.
Because each replica is a long-lived Python process, a model loaded once serves many subsequent
invocations, and scheduling a task onto a warm replica costs milliseconds rather than a pod startup.
The trade-off is the same one you would accept in any long-running server: in-memory state survives
between invocations on a replica, so global state deserves care.
Docs: Reusable containers
Staging the migration
Migrations tend to go badly when the first workload moved is the most expensive one. A sequence that works:
- Start with pipeline-shaped work. Data processing, evaluation, hyperparameter sweeps, batch
inference. These gain the most from typed inputs and outputs, caching, and retries, and they are
the workloads where the
sbatchglue was worst. Nothing expensive is at risk while you settle the image and data-access questions. - Move single-node training next. By this point the image is validated and the data is coming in as arguments, so what you pick up is reproducible environments, spot with automatic fallback, checkpoint recovery, and run metadata.
- Move multi-node training last. It is the most performance-sensitive workload in the stack, and by the time you get to it, everything underneath it has already been exercised.
Existing binaries do not have to be rewritten to come along. A
container task runs an arbitrary image with typed
inputs and outputs, whatever language the tool is written in, which covers the bioinformatics
binaries and vendor CLIs that tend to be wrapped in srun today.
For the inner loop, flyte run --local executes the same code in your local Python process with no
cluster involved, which is the closest thing to iterating on the login node before submitting.
Docs: Running locally · Container tasks
Scheduler features without an equivalent
Three things Slurm’s scheduler does are not available, and if your workload depends on them, plan around them explicitly:
- Gang admission. A clustered task’s replicas are launched together and
torchrunwaits for the full group before training starts, but there is no scheduler-level all-or-nothing admission. - Topology-aware placement. There is no way to request workers on the same rack or switch.
- Preemption. Nothing evicts running low-priority work to make room for high-priority work the way a Slurm QOS can. Priority affects the order work starts in, not what happens to work that has already started.
These are gaps in the Kubernetes batch ecosystem rather than anything specific to Flyte.