Resilient Training with Ray and Jax
This guide demonstrates how resilient training of models written in libraries built on Jax can be achieved using Ray. More specifically, we will delve into the following:
- What a Ray cluster is and how to bring a Ray cluster up on underlying hardware infrastructure
- How to leverage the brought up Ray cluster to implement fault tolerant finetuning of a pretrained LLM; this includes:
- automatic recovery from failures and hangs by leveraging Ray’s machinery for failure detection and recovery from a model checkpoint
- fast recovery from failures and hangs by leveraging model compilation caching
For a full runnable example of resilient JAX training using Ray see the simple toy example, which includes a Docker image you can try out on a single node with a least two GPUs. The guide below gives an overview of the example code and how you might want to extend it to your own model.
Why Ray?
Ray’s runtime is based on a “head”/“worker” architecture where a “head” process called the coordinator, spawns “worker” processes called actors. This architecture allows computation to be run across the workers and the coordinator to act as a watchdog and manage the control plane. As a result, the coordinator can detect any crash or hang experienced by an actor and start the recovery process. This can be viewed as a single-controller runtime where the coordinator is the single-controller. We are going to build our resilient training system using Ray’s single-controller runtime, with the actors being responsible for training the model and the coordinator being responsible for failure detection and recovery.
The workflow for implementing resilient training with Ray and JAX can be broadly split up into the following steps:
- Launching a Ray cluster
- Implementing the coordinator and actors
- Launching a job with the coordinator and actors on the Ray cluster
We will delve into each of these steps in detail in that order in the rest of this guide.
Starting the Ray Cluster
The first step to launching any workload using Ray is to start a Ray Cluster. The Ray cluster consists of a single “head node” and a number of “worker nodes”. There is a slight abuse of terminology that can be a little confusing here with the usage of the word “node” but both Ray head nodes and Ray worker nodes can in general be thought of as sub-allocations within a physical node. This means that on a physical node with 8 GPUs and 128 CPUs, a Ray worker node could pertain to a sub-allocation of up to 8 GPUs and 128 CPUs. And similarly for the Ray head node. The coordinator process always runs on the Ray head node and actors always run on Ray worker nodes. In this guide we will assume that each actor gets 1 GPU, 16 CPUs and that we operate in a 1 GPU per process setting.
Starting a Ray Cluster manually
We will begin with a simple example of how to manually start a Ray cluster on 2 physical nodes. This will involve a single Ray head node and 2 Ray worker nodes, where each Ray worker node is allocated all GPUs of the node it runs on. Moreover, each worker node will have as many actors scheduled on it as it has GPUs available, with each actor being able to address only a single GPU. This ensures that we are indeed operating in a 1 GPU per process setting. We will assume the IP addresses of the physical nodes are IP_ADDR_1 and IP_ADDR_2 and that the head node will be allocated on the physical node with IP_ADDR_1.
First, run the following script on one physical node:
The script above will allocate the Ray head node on the physical node with IP_ADDR_1 and also start 1 ray worker node on the same physical node. We note the following about the script above:
- The address argument to
ray startrefers to the address of the Ray head node. - We specify
CUDA_VISIBLE_DEVICES=""when allocating the Ray head node. This is because the coordinator will not be performing any GPU computation and thus the Ray head node required no GPUs. - Resources like GPUs, CPUs and memory are physically afforded to Ray worker nodes by the underlying physical node. But Ray allows users to define custom virtual resources to give users more finegrained control of actor scheduling. In this case we define a physical resource called a
worker_unit. Since we are going to be assigning one process per GPU we give each ray worker node 8 units of the customworker_unitresource. This allows us to “tag” the ray worker nodes that will be used as the primary workers for our finetuning job. To ensure that actors only ever gets scheduled on these ray worker nodes we will request a single worker_unit for each actor during scheduling.
At this point the Ray cluster has 1 Ray head node and 1 Ray worker node. We will allocate the second Ray worker node on the physical node with ip address IP_ADDR_2 by running the following script:
Now the Ray cluster consists of 1 Ray head node and 2 Ray worker nodes, all allocated across the 2 physical nodes with IP addresses IP_ADDR_1 and IP_ADDR_2.
Implementing the coordinator and actors
Now that we have an understanding of how to bring up a ray cluster, we are almost in a position to be able to launch a job on this cluster. Before we do so we need to define the actions of the coordinator and the actors.
Designing the coordinator
Let us start defining a class called RayClusterCoordinator, that encapsulates the functionality of the coordinator. We will start simple and add functionality as we go:
The first job of the coordinator is to spawn actors and have them be scheduled on Ray worker nodes. This happens in the constructor of the RayClusterCoordinator. worker_cls is the class that encapsulates the functionality of each actor. Notice how we request 1 GPU, 16 CPUs and 1 worker_unit per actor as mentioned earlier. There are two additional mandatory methods that RayClusterCoordinator implements:
initialize_workers: the purpose of this method is to trigger the initialize method of theworker_clsrepresenting an actorrun: the purpose of this method is to launch computation on all the scheduled actors. We haven’t included the details of the run method quite yet, nor have we discussed why it is an async method. We will get to both after defining some of the functionality of the class representing an actor. In the class definition aboveNGPUSis an environment variable that represents the total number of GPUs available to the job across all nodes.
Designing the actor
Our design of the actor involves defining two classes: ResilientWorker and ModelTrainer. ResilientWorker will define a high level interface for the role an actor plays in this system and ModelTrainer is a subclass of ResilientWorker that will implement specific functionality to train a model in a fault-tolerant manner, including model checkpointing and compilation caching. Let’s start with ResilientWorker.
The ResilientWorker interface defines a number of methods, the most important of which are the initialize and run methods:
initialize: is responsible for initializingjax.distributedacross all the spawned actors. Every class that inherits from ResilientWorker can initialize any task specific state in its initialize method, but must call the base class’ initialize method to setup the jax.distributed runtime.run: every class that inherits from ResilientWorker must implement its own run method. This is the entry point to the computation that the actors will perform.
To understand how this class and inheritance structure works, let’s start implementing the ModelTrainer class.
In this guide, the objective of the actors is to finetune a pretrained LLM. Additionally, they have to do so in a fault tolerant manner. ModelTrainer is the class that encapsulates this behavior and inherits from ResilientWorker. Its responsibility to finetune an LLM while being able to recover quickly and automatically when met with a failure. The class is decorated with ray.remote since instances of this class are to be scheduled as actors.
As a subclass of ResilientWorker, ModelTrainer must implement its own run method. But before we look at the details of the run method
let’s look at the details of the overridden initialize method in ModelTrainer. It is not mandatory for subclasses of ResilientWorker to override initialize, but it is often useful to initialize child class specific state by doing so. In this example, given our objective, in addition to calling the base class’ initialize method to initialize jax.distributed, ModelTrainer initializes the following in its initialize method:
- model checkpointing state: initialization of an Orbax checkpoint manager. One of the important aspects of fault tolerant training is to be able to recover from the latest checkpoint. Hence our model is checkpointed at fixed intervals during the finetuning process
- jax compilation cache: to avoid the penalty of recompilation when restoring the training run from the latest checkpoint after a crash or a hang
We will now look at the details of the run method of ModelTrainer with the following caveat: we will merely gloss over parts of the run method that don’t pertain directly to fault tolerant training, these include:
- Dataset creation and tokenizing
- Sharding model parameters
- Dataloading details
- Implementation of a single training step
The methods below provide signatures for some of these functions (that will be used in run):
Details on how to implement these aspects of model training can be found in many excellent guides online, one of which is this tutorial on finetuning a Gemma model. We will instead focus on the aspects that demonstrate how Ray can be used to achieve fault tolerant training. With the helper methods defined as above, the run method looks as follows:
Let’s examine the details of the run method above. It takes two arguments num_nodes and restore. num_nodes is the number of physical nodes that all the actors are spawned across and is only used to create the mesh that computation is going to be spread out across. restore is a parameter that lets run know if run has been called during the recovery process after a failure or if it is at the start of a training job. If restore is false, the method looks very familiar to a standard training loop: a model is created, a data loader is created, parameters are sharded, the optimizer state is initialized and the model is optimized in the training loop with checkpointing being facilitated by the following method:
When restore is true, there are a number of differences. The first is that parameters are not loaded from the pretrained checkpoint, but are instead loaded from the latest saved checkpoint. The latest saved checkpoint also contains the latest optimizer state and some additional parameters (epoch, iteration anhd ckpt_step) that help pick up training from the point at which the failure took place. The restore method looks as follows:
So now that we have the run method for our actors in place, how exactly do we launch them? And more importantly, how do we detect and recover from failures? To do so, we have to go back to the coordinator and flesh out the run method of the RayClusterCoordinator class.
Revisiting the coordinator
As mentioned earlier, the purpose of the run method of the RayClusterCoordinator is to launch computation on actors. Now that we have the computation that each actor is going to perform defined in the run method of ModelTrainer, we will have the RayClusterCoordinator call it to launch computation on the actors. Additionally, we will also include logic to detect failures and start recovery in the run method of the RayClusterCoordinator. The first version of this method looks as follows:
Let’s examine this method. The first detail to note (as mentioned when RayClusterCoordinator was introduced) is that the method is an async method. The reason for this is that we want the coordinator to be able to perform multiple non-blocking tasks. One of which is launching actors and being able detect when any of them fail. This is what the _run_workers_async method does:
In this method each w is a reference to a ModelTrainer actor and [w.run.remote(self.num_physical_nodes, **kwargs) for w in self.workers] calls the run method of each actor to start the finetuning job. When a Ray actor fails, it sends a signal back to the coordinator that can be handled. This happens whether the failure is “graceful”, in that the Actor is able to throw an exception; or if the failure is less “graceful” like a segmentation fault. Calling ray.get(wf, timeout=0) on any future from an actor that has not failed, will always result in a ray.exceptions.GetTimeoutError, if the actor’s run method has not completed execution. However, if the actor has failed in any manner, graceful or not, ray.get(wf, timeout=0) will always throw one of ray.exceptions.RayTaskError or ray.exception.ActorDiedError, which propagates back to the coordinator.
When the coordinator receives a failure signal from the _run_workers_async task, it can start the recovery process. This is what the code in the except block of the run method in RayClusterCoordinator does.
Adding hang detection capabilities
In its current state, this system can recover from crashes in any of the actors. But another important, and trickier class of failures to be able to detect and recover from is hangs. Hangs can occur for a variety of reasons during a run and so it’s important for the system to be able to detect them in a general sense.
Being able to detect hangs requires more proactivity on the part of the coordinator, for the simple reason that if any actor is in a hanged state, it cannot send any signals to the coordinator to communicate that it is in such a state. So in addition to the crash detection logic outlined in _run_workers_async we will have the coordinator perform its second non-blocking task via another async function called _detect_worker_hang_async, into which we will add the logic to detect hangs. At a high level, the logic to detect hangs depends on the actors “checking in” periodically with the coordinator via a distributed key-value store. This is what we will use Redis for. To start the redis-server simply run the following on the same physical node that the ray head node is running on.
With the redis server running we need to have both the coordinator and each actor connect to the server, which we can do by adding the following two lines to the constructor of RayClusterCoordinator and ResilientWorker:
As mentioned, the the hang detection logic we are yet to implement in _detect_worker_hang_async depends on actors “checking in” with the coordinator. This is facilitated through a heartbeat in the form of a timestamp from each actor being written to the redis KV store. They key is the process ID and the value is the timestamp of the last time the actor was alive. This is implemented through a context manager that we add to the ResilientWorker class:
and then wrapping the computation in the the main training loop in the run method of ModelTrainer with this context manager:
That’s it for the actors. Now we can implement _detect_worker_hang_async to complete the hang detection feature. The logic here is straightforward, wait a certain amount of time t seconds and then check the timestamp of each actor’s heartbeat in the redis KV store. If an actor hasn’t checked in in t seconds, it’s likely hanged. It looks as follows:
A couple of points to note about the method above:
- The first is that the
asyncio.sleepserves two purposes. One if to wait fortas part of the hang detection logic. The other is to give control back to the asyncio event loop so that it can switch contexts to the_run_workers_asyncmethod to check if any actors have failed. - The chosen time of
t=300does mean that it will take at least 5 minutes to detect a hang after its occurence. This is the best case and occurs if and only if an actor hangs at the exact moment theasyncio.sleep(300)command is issued. In the worst case, the time from occurrence to detection is 10 minutes and happen if the hang occurs at the exact moment theasyncio.sleep(300)command finishes. However,tis configurable and the chosen value here is just meant to illustrate functionality.
With _detect_worker_hang_async implemented, we just need to change the run method of RayClusterCoordinator to launch the task. The final updated method looks as follows:
When the coordinator determines an actor has hanged, it raises an exception that is handled exactly like a crash is handled, except during the recovery process it needs to kill two tasks instead of one.
Launching a job Ray job
With that we’ve described every important aspect of the coordinator and the actors that help them work together to achieve failure resilient training. The final piece of the puzzle is to launch the training job that leverages all the logic implemented in RayClusterCoordinator, ResilientWorker and ModelTrainer, on the Ray cluster. This is achieved through the following entrypoint (which could be in its own main.py file or all the code above as well as the entrypoint could be implemented in a large main.py file):
and a script called the driver as shown below:
The driver script is run on the same physical node as the Ray head node and is the reason the address to connect the JobSubmissionClient to the Ray cluster is http://127.0.0.1 or localhost.
Starting a Ray Cluster with a SLURM allocation
Sometimes it is likely that physical resources are allocated to an application by a scheduler like SLURM or Kubernetes. The script below is a template of a SLURM batch script that sets up a ray cluster with the specification as above (1 Ray worker node per physical node / 8 GPUs per Ray worker node), but over <NUM_NODES> physical nodes.
Sharp bits and current limitations
This tutorial is meant to serve as a guide for how to use Ray to implement a fault tolerant training framework with JAX, but we’d like to note some limitations of the infrastructure explained in this tutorial:
-
While most aspects of the design are agnostic to the type of hardware being used, it has only been tested on GPUs. It is likely that the same principles can be applied to make the design applicable with minimal change to run on TPUs and other accelerators but that has not been tested yet.
-
The implicit assumption in the design is that the same number of physical nodes is available before and after failure; meaning that it doesn’t support recovery in the case of dead nodes unless hot spares are available.