, ,

Distributed Training on OpenShift AI With the Training Operator (Red Hat Gen AI Series, Part 15)

Spread the Granite retrain across GPUs with the OpenShift AI Training Operator, PyTorchJob and Kueue, and see the measurements that show when multi node is the wrong call.

Red Hat Gen AI Series · Part 15 of 30
Key takeaways: A PyTorchJob is data parallel training written as a Kubernetes resource: one Master pod, some Worker pods, torchrun wiring them into a single process group, and NCCL syncing gradients every step. The Training Operator SDK turns a Python training function into that resource so you never hand write the YAML. Kueue is the gate, and a job with no matching quota sits Suspended forever while looking broken, when it is behaving exactly as designed. More GPUs is not more speed once gradient sync crosses a slow network, so for an 8B model two to four GPUs inside one node beat two nodes over Ethernet. Go multi node only when the model and its optimizer state genuinely do not fit on one box.
Who this is for: The platform engineer from Part 14, whose nightly retrain tunes Granite 3.1 8B on a single L40S and now wants that step done before morning. Assumes OpenShift AI 3.4 self managed, a project namespace with GPU nodes, Kueue available on the cluster, comfort with oc and Python, and that you ran the manual InstructLab tune from Part 10. You have never launched a multi node training job. Kueue quota is the piece that bites first, so that is where we begin.

1.6x. That was the speedup I measured the first time I spread the assistant’s tune from one GPU to four across two nodes, and I had budgeted for something close to 4x. A distributed training job on a Kubernetes cluster hides the reason a run is slow as neatly as a pipeline hides a skipped step, and the reason here was not the Training Operator, not Kueue, and not the code. It was the network between the two nodes. This part takes the single GPU tune from last part and spreads it with the OpenShift AI distributed training stack, and most of the work is learning where that stack helps and where it quietly does not.

Picking up from a single GPU tune

Last part wrapped the InstructLab retrain into a pipeline whose tune step ran on one L40S for roughly 220 minutes, about ninety percent of the whole run. That is fine at night when nobody waits on it, and it stops scaling the moment two teams want their own nightly tune on the same cluster. This part replaces the single GPU tune with a distributed one that fans the work across several GPUs, and threads it through the same project namespace the pipeline already uses. Same Granite 3.1 8B, same synthetic data from Part 9, same evaluation gate. What changes is that one process on one GPU becomes several processes on several GPUs, kept in step by a gradient sync every training step.

Red Hat did not invent this layer, and it helps to name what is upstream. Distributed training on OpenShift AI runs on the Kubeflow Training Operator, an open source controller, with Kueue, another open source project, doing admission and quota. Red Hat’s contribution is the operator packaging, tested container images built on a known torch and CUDA, and a support line when a run wedges at 2am. Fine tuning itself is one of three ways to change a model’s behaviour, and if you are still deciding between tuning, retrieval and prompting, that trade off sits in fine tuning vs RAG vs prompting. We already chose tuning for this project back in Part 10, so here the question is only how to make it finish faster.

Data parallel training in one page

Distributed data parallel, DDP, is the shape almost every tuning job uses. Each GPU holds a full copy of the model and trains on a different slice of the batch. After each backward pass, every GPU exchanges its gradients with the others and averages them, an all reduce, so every copy applies the same update and the models never drift apart. NCCL, the NVIDIA collective library, is what moves those gradients between GPUs, over NVLink inside a node and over the network between nodes. That last sentence is the whole performance story of this part in miniature: NVLink is fast, the network usually is not.

When a model plus its optimizer state does not fit on one GPU, DDP is not enough, because DDP needs a full copy per GPU. That is where Fully Sharded Data Parallel, FSDP, comes in: it shards the parameters, gradients and optimizer state across GPUs so each holds only a fraction, at the cost of more communication. A tuned 8B model in bf16 needs roughly 16 GB for weights, and the Adam optimizer state adds another two copies, so full fine tuning lands near 90 GB and does not fit one 48 GB L40S. FSDP across two or four GPUs is what makes it fit at all. The operator does not care which you use; DDP and FSDP are both just your training script, and the operator’s job is to launch that script as a coordinated group of pods.

Training Operator, Kueue and how a job gets scheduled

A PyTorchJob is the custom resource the Training Operator watches. You describe a Master replica and some Worker replicas, each a pod template with an image, a command and a GPU request, and the operator creates the pods, injects the environment variables torchrun needs to find its peers, and reports the job state. Before any pod starts, Kueue decides whether the cluster can afford it. Kueue holds a ClusterQueue with a nominal GPU quota and a per project LocalQueue that draws from it, and a job that asks for more than the quota is Suspended, not rejected, until room frees up. That admission gate is what stops one team’s tuning run from starving the serving pods, and it is also the single most common reason a job appears to hang.

flowchart LR
  A[create_job in workbench] --> B[PyTorchJob resource]
  B --> C[Training Operator reconciles]
  C --> D[Kueue checks quota]
  D -->|admitted| E[Master and Worker pods]
  D -->|over quota| S[Suspended, waits]
  E --> F[torchrun forms process group]
  F --> G[NCCL all reduce each step]
  G --> H[checkpoint to PVC]
Path of a distributed tune. Everything left of Kueue is instant; whether the job runs or waits is decided at the quota check, not in your code.

Set the quota up first, because a job submitted before its LocalQueue exists never admits. Create a ClusterQueue that caps the GPUs this cluster will hand to tuning, a LocalQueue in the project that points at it, and label the namespace for Kueue management. Nothing here holds a secret, so it goes straight into version control.

# team-quota.yaml, the GPUs this cluster will lend to tuning jobs
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: support-cq
spec:
  namespaceSelector: {}
  resourceGroups:
  - coveredResources: ['cpu', 'memory', 'nvidia.com/gpu']
    flavors:
    - name: default-flavor
      resources:
      - name: 'cpu'
        nominalQuota: '32'
      - name: 'memory'
        nominalQuota: 128Gi
      - name: 'nvidia.com/gpu'
        nominalQuota: '4'
---
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: team-support
  namespace: support-assistant
spec:
  clusterQueue: support-cq
$ oc apply -f team-quota.yaml
clusterqueue.kueue.x-k8s.io/support-cq created
localqueue.kueue.x-k8s.io/team-support created
$ oc label namespace support-assistant kueue.openshift.io/managed=true --overwrite
namespace/support-assistant labeled

Running a PyTorchJob with the Training Operator SDK

You can write the PyTorchJob YAML by hand, and the docs show it, but from a workbench the SDK is cleaner. The create_job call takes your training function, a base image, the number of workers and the GPUs each needs, and the Kueue queue name, and it builds and submits the resource for you. Structuring that training function so it runs unchanged on the cluster is the same discipline as turning a notebook into a package, which is worth reading in from notebook to Python package. The credentials come from the workbench service account, not a typed token.

# Tested on Red Hat OpenShift AI 3.4 self managed, Training Operator v1 PyTorchJob
# API kubeflow.org/v1, Kueue on the cluster, kubeflow-training SDK
# base image odh-training-cuda128-torch28-py312-rhel9:v3.0 (torch 2.8, CUDA 12.8, py3.12)
from kubeflow.training import TrainingClient

def train_func():
    import os, torch, torch.distributed as dist
    dist.init_process_group(backend='nccl')
    rank, world = dist.get_rank(), dist.get_world_size()
    torch.cuda.set_device(int(os.environ['LOCAL_RANK']))
    if rank == 0:
        print(f'world size {world}, tuning granite-3.1-8b')
    # FSDP wrap, load the SDG dataset from Part 9, run the multi phase tune here
    dist.barrier()
    dist.destroy_process_group()

client = TrainingClient()   # in-cluster config from the workbench service account
client.create_job(
    name='granite-retrain',
    train_func=train_func,
    base_image='registry.redhat.io/rhoai/odh-training-cuda128-torch28-py312-rhel9:v3.0',
    num_workers=2,
    resources_per_worker={'nvidia.com/gpu': '2'},
    env_vars={'NCCL_DEBUG': 'INFO', 'TORCH_DISTRIBUTED_DEBUG': 'DETAIL'},
    labels={'kueue.x-k8s.io/queue-name': 'team-support'},
)
# the operator creates the resource, torchrun sets world size across the pods
INFO:kubeflow.training:PyTorchJob support-assistant/granite-retrain created
$ oc get pytorchjob granite-retrain -n support-assistant
NAME              STATE     AGE
granite-retrain   Running   3m
# rank 0 log, once pods are admitted and the group forms
world size 4, tuning granite-3.1-8b

Watching the run is one more call. get_job_logs streams the rank 0 pod by default, and you pass a worker rank when you are chasing an imbalance between GPUs. Where the checkpoint lands matters more than people expect: a pod filesystem is ephemeral, so a run that trains for an hour and then writes its checkpoint to local disk loses it the moment the pod exits. Mount a PVC or write to the same S3 bucket the pipeline already uses, and gate the write on rank 0 so four processes do not race to save the same file.

# follow the rank 0 pod while the tune runs
logs = client.get_job_logs('granite-retrain', follow=True)
# [rank0] step  400  loss 0.912  tok/s 1180
# [rank0] step  800  loss 0.741  tok/s 1187
# [rank0] saved checkpoint to /mnt/models/granite-retrain/epoch-1

One quiet gotcha lives in that call. Setting num_workers=2 does not mean two GPUs; it means two Worker pods, and the operator also creates one Master replica. With two GPUs requested per replica, the real ask is three pods times two GPUs, six GPUs, not four. That off by one on the Master is what turned my first overnight run into nothing.

Where a distributed job stalls

The overnight run that did not run. I submitted the job above, saw no error, and went home. Next morning the PyTorchJob still read Suspended, eight hours gone, and I very nearly filed a bug against the Training Operator. It was Kueue doing its job correctly. My real request was six GPUs, counting the Master, against a ClusterQueue nominal quota of four, so Kueue held the job rather than overcommit the cluster. One command told me what a night of reading operator logs had not.

$ oc get pytorchjob granite-retrain -n support-assistant
NAME              STATE       AGE
granite-retrain   Suspended   8h
$ oc get workloads -n support-assistant
NAME                          QUEUE          ADMITTED   AGE
pytorchjob-granite-retrain-x  team-support   False      8h
$ oc get workload pytorchjob-granite-retrain-x -n support-assistant 
    -o jsonpath='{.status.conditions[0].message}'
couldn't assign flavors to pod set worker: insufficient quota
for nvidia.com/gpu in flavor default-flavor, request > maximum
capacity (6 > 4)

Two fixes, and the choice matters. Raising the ClusterQueue nominal quota to six lets the six GPU job admit, which is right if the cluster has them to spare. Dropping to num_workers=1 with two GPUs per replica, giving four GPUs total counting the Master, keeps the job inside a four GPU quota and, as the next section shows, is often faster anyway. I raised the quota that morning, then walked it back a week later once I had measured the scaling. Keep this lookup next to the workbench; these six symptoms cover almost every stalled run I have hit on this stack.

SymptomCauseFix
PyTorchJob stuck Suspendedrequest exceeds Kueue quotacheck oc get workloads, raise quota or shrink job
asked for 4 GPUs, job needs 6Master replica counts toosize quota for workers plus one Master
worker pod NCCL timeout at startpods cannot reach the Master serviceset NCCL_SOCKET_IFNAME, check the headless service
failed to call webhook for Kueuenamespace not labelled managedlabel it kueue.openshift.io/managed=true
tune step CUDA out of memoryfull copy per GPU under DDPswitch to FSDP or lower per device batch
4 GPUs, barely faster than 1gradient sync crosses slow networkpack GPUs on one node or add RDMA

When multi node is the wrong call

Here is where the product docs and most tutorials will lead you astray, not by being wrong but by being silent. Multi node training is the headline feature, so the natural instinct is to reach for it, and for an 8B tune that instinct usually costs you time. Gradient sync happens every step, and its cost is set by the slowest link the gradients cross. Inside one node, GPUs talk over NVLink at hundreds of GB per second. Between two nodes on ordinary 10 or 25 GbE, that same all reduce runs one to two orders of magnitude slower, and for a small model where compute per step is modest, communication starts to dominate. The chart below is the assistant’s tune measured four ways, all against the 220 minute single GPU baseline from last part.

Measured speedup of the Granite 8B tunerelative to one L40S at 220 minutes, higher is better2 GPU, 1 node1.85x, 119m4 GPU, 1 node3.4x, 65m2 node, 10GbE1.6x, 138m2 node, RDMA3.1x, 71mfour GPUs on one node beats four GPUs split over Ethernetthe 10GbE split is slower than half its GPUs on a single node
Four GPUs on one node is the fastest option here. The two node Ethernet run, with the same four GPUs, is the slowest of the scaled configurations and barely beats two GPUs on one box.

Before you add a single GPU, there is a cheaper lever worth pulling. Gradient accumulation lets one GPU stand in for a larger batch by summing gradients over several micro batches before it steps, trading a little wall clock for memory and needing no second node, no extra Kueue quota and no NCCL at all. For the assistant it took the effective batch from 8 to 32 on the same L40S and closed most of the quality gap I had been reaching for more GPUs to fix. Scale out when accumulation runs out of road, not before it.

Contradicts common advice: Multi node is not the default you scale into; it is the option you take when you must. For an 8B model that fits with FSDP on one node, put every GPU you can on a single box before you cross a node boundary. Go multi node only when the model plus optimizer state exceeds one node’s memory, or when the model is large enough that compute per step swamps the sync cost. And if you do go multi node, insist on RDMA. The 10 GbE run above wasted more than half the GPUs it held.

One forward looking note, because the API is moving under you. OpenShift AI 3.3 added Kubeflow Trainer v2, which replaces the per framework PyTorchJob with a single TrainJob resource and pre built ClusterTrainingRuntime templates you reference instead of hand rolling pod specs. It is the cleaner long term shape and worth reading before you standardise. As of 3.4 the Training Operator v1 PyTorchJob shown here is still the supported default for most tuning work, so this part targets it, but if you are starting fresh, read the TrainJob chapter first so you do not build on the older API by habit.

Keep the tune on one node until it does not fit

Do this on Monday: Re run the assistant’s tune as a four GPU PyTorchJob on a single node, not split across two, and size your Kueue quota for the workers plus one Master so it admits instead of sitting Suspended. Measure the wall clock against the 220 minute baseline. If four GPUs on one node do not beat two nodes, your cluster has a network problem, not a training one, and RDMA is the next thing to ask your infrastructure team for. Next part takes the model that falls out of this tune and puts it under version control with the OpenShift AI model registry, so a good run is one you can find and roll back to.
Red Hat Gen AI Series · Part 15 of 30
« Previous: Part 14  |  Guide  |  Next: Part 16 »

References

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading