Train with GPUs
Train ZenML pipelines on GPUs and scale out with 🤗 Accelerate.
Need more compute than your laptop can offer? This tutorial shows how to:
Request GPU resources for individual steps.
Build a CUDA‑enabled container image so the GPU is actually visible.
Reset the CUDA cache between steps (optional but handy for memory‑heavy jobs).
Scale to multiple GPUs or nodes with the 🤗 Accelerate integration.
Go multi-node by wrapping a distributed launcher (TorchX, Ray) in a
CommandStep.
1 Request extra resources for a step
If your orchestrator supports it you can reserve CPU, GPU and RAM directly on a ZenML @step:
from zenml import step
from zenml.config import ResourceSettings
@step(settings={
"resources": ResourceSettings(cpu_count=8, gpu_count=2, memory="16GB")
})
def training_step(...):
... # heavy training logic👉 Check your orchestrator's docs; some (e.g. SkyPilot) expose dedicated settings instead of ResourceSettings.
If your orchestrator can't satisfy these requirements, consider off‑loading the step to a dedicated step operator.
2 Build a CUDA‑enabled container image
Requesting a GPU is not enough—your Docker image needs the CUDA runtime, too.
Use the official CUDA images for TensorFlow/PyTorch or the pre‑built ones offered by AWS, GCP or Azure.
Optional – clear the CUDA cache
If you squeeze every last MB out of the GPU consider clearing the cache at the beginning of each step:
Call cleanup_memory() at the start of your GPU steps.
3 Multi‑GPU / multi‑node training with 🤗 Accelerate
ZenML integrates with the Hugging Face Accelerate launcher. Wrap your training step with run_with_accelerate to fan it out over multiple GPUs or machines:
Common arguments:
num_processes: total processes to launch (one per GPU)multi_gpu=True: enable multi‑GPU modecpu=True: force CPU trainingmixed_precision:"fp16"/"bf16"/"no"
Accelerate‑decorated steps must be called with keyword arguments and cannot be wrapped a second time inside the pipeline definition.
Prepare the container
Use the same CUDA image as above plus add Accelerate to the requirements:
4 Distributed training across processes and nodes
Section 3 (Accelerate) already fans a single step out over the GPUs on one machine. More generally there are two cases, and they call for different tools:
Single node, multiple GPUs → a native step (your own multiprocessing, or the Accelerate integration), or a
CommandSteprunningtorchrun. No external launcher or gang scheduler needed.Multiple nodes → wrap a distributed launcher in a
CommandStep: ZenML owns the run, the launcher owns the worker processes.
Single node, multiple GPUs
For several GPUs on one machine you don't need an external launcher or a gang scheduler — every process lives in the step's own container. There are three patterns, and all three work today; pick whichever fits how you already train.
1. A native step that spawns the processes itself. ZenML supports multi-process training out of the box — request the GPUs with ResourceSettings and let your code start one process per GPU (for example torch.multiprocessing.spawn, setting up DDP inside). No extra libraries, and you keep ZenML's input/output and log tracking.
runtime="isolated" tells the orchestrator to run the step in a fresh container (sized for the GPUs it requests) instead of inline in the orchestration process — which is what you want for a heavy training step. It's a dynamic-pipeline feature, so the pipeline is dynamic=True. The isolated container uses the pipeline image, so make sure that image carries CUDA and torch (see section 2).
2. The Accelerate integration. If you'd rather not wire up the process group yourself, decorate the step with run_with_accelerate (see section 3) and it handles the fan-out:
3. A CommandStep running torchrun. If you already drive training with torchrun (or any launcher CLI), wrap it in a command step. The launcher and its workers share the one container, so a single image carries zenml + torch + train.py:
The trade-off: patterns 1 and 2 keep ZenML's artifact and log tracking; pattern 3 treats training as an opaque command (logs land in the backend, no inputs/outputs) but lets you reuse an existing torchrun entrypoint unchanged. The same vanilla train.py shown below works with pattern 3.
Multiple nodes — wrap a launcher in a CommandStep
CommandStepOnce training spans more than one machine, the cleanest approach is to let a dedicated launcher own the worker gang and let ZenML own the run.
Why a launcher (and not ZenML) starts the workers
torch.distributed / torchrun only coordinate ranks through a rendezvous — they assign RANK, WORLD_SIZE and LOCAL_RANK and wire the processes together. They do not provision machines or start processes on other nodes. Something has to launch N worker processes across N nodes that can all reach the rendezvous endpoint — a launcher that provisions and gang-schedules them: TorchX, Ray, Slurm, and so on.
ZenML doesn't reimplement that. Instead it gives you a clean seam:
A
CommandStepruns an opaque command in a container on a step operator.Point that command at the launcher. The launcher schedules and starts the worker gang.
ZenML records the run and tracks the launcher process through the step operator's
submit/get_status/cancellifecycle. The launcher pod blocks until the whole job finishes, so the ZenML step succeeds or fails with the job.
The skeleton is always the same — only the command changes:
Pick a launcher
TorchX (dist.ddp)
Kubernetes, Slurm, local
Volcano for gang scheduling on K8s
Multi-node on bare Kubernetes with plain torch.distributed
Ray (ray job submit)
A Ray cluster / KubeRay
A running Ray cluster
You already run Ray; Ray Train sets up torch.distributed for you
Worked example: TorchX + Volcano on Kubernetes
Your training script is vanilla torch.distributed — it reads the rank/world-size that the launcher injects and contains nothing ZenML- or launcher-specific:
The pipeline wraps torchx run in a CommandStep. TorchX's dist.ddp builtin uses torchelastic and gang-schedules the workers on Volcano:
You only hand-build the worker image (CUDA + torch + your script — no zenml needed, workers aren't ZenML steps):
The same pattern with Ray
Only the command changes. With Ray (submitting to an existing cluster, letting Ray Train own torch.distributed):
Things to keep in mind
List the command step in
depends_on. A command step runs on a step operator, and in a dynamic pipeline only the steps named independs_onget a dedicated image built — otherwise the step falls back to the orchestrator image and won't have the launcher installed.@pipeline(dynamic=True, depends_on=[train])builds the right image;dynamic=Truealso unlocks resource pools. (Regular steps like the single-node examples above don't needdepends_on— they use the pipeline image.)The image must carry the launcher (and
zenml). TheCommandStepruns through ZenML's entrypoint on the step operator, so the launcher image needs bothzenmland the launcher package. Either let ZenML install them withrequirements=[...](as above), or bake your own image and useDockerSettings(skip_build=True, parent_image=...)— a customparent_imagemust already containzenml. The worker image (passed to the launcher, e.g.--image) only needs your training stack, notzenml.Logs live in the launcher's backend. Command-step logs are not tracked by ZenML — worker logs stay where the launcher puts them (pod logs, the Ray dashboard, etc.). See the command steps limitations.
Two capacity managers, two jobs. The launcher's gang scheduler (e.g. Volcano) reserves the worker capacity all-or-nothing; ZenML resource pools (if you use them) govern the launcher step. They don't overlap.
5 Troubleshooting & Tips
GPU is unused
Verify CUDA toolkit inside container (nvcc --version), check driver compatibility
OOM even after cache reset
Reduce batch size, use gradient accumulation, or request more GPU memory
Accelerate hangs
Make sure ports are open between nodes; pass main_process_port explicitly
Need help? Join us on Slack.
Last updated
Was this helpful?