Scaling AI Workloads: Understanding the Architecture of Ray
Scaling AI Workloads: Understanding the Architecture of Ray
In the world of modern AI, particularly Reinforcement Learning (RL), we face a paradoxical challenge. We need massive parallelism to simulate thousands of environments (stateless work), but we also need centralized state to maintain and update a global model (stateful work).
Traditional distributed frameworks often force a choice: use a MapReduce-style engine for data processing or a dedicated parameter server for training. Ray breaks this dichotomy.
In this post, we dive deep into the architecture of Ray, exploring how it unifies stateless tasks and stateful actors to create a high-performance execution engine for AI.
The Core Intuition: Beyond Centralized Bottlenecks
Most distributed systems rely on a centralized scheduler. While this works for batch processing (like Spark), it becomes a bottleneck for RL, where millisecond-level latency is required for the "Simulation $\rightarrow$ Training $\rightarrow$ Serving" loop.
Ray's primary thesis is to distribute the control plane. By sharding the metadata store and employing a bottom-up scheduling strategy, Ray removes the central bottleneck, allowing it to scale to thousands of nodes while maintaining low latency.
The Dual-Primitive Model
Ray achieves this flexibility through two fundamental primitives:
- Tasks (Stateless): Fine-grained, asynchronous functions. Think of these as "fire-and-forget" computations. They are ideal for environment rollouts or data preprocessing.
- Actors (Stateful): Long-running services that maintain internal state. These are essentially distributed classes. They are ideal for parameter servers, model weights, or database connections.
Architectural Blueprint
The following diagram illustrates how the User Application interacts with the Ray Runtime, separating the Control Plane (metadata) from the Execution Plane (compute).
How it Works: The Execution Flow
- Submission: The user defines a function as a
Taskor a class as anActor. - Distributed Scheduling: Instead of one master node deciding everything, Ray uses a distributed strategy to assign work to available resources instantly.
- State Management: The Global Control Store (GCS) keeps track of where actors live and where data is stored, but the actual data lives in a distributed shared-memory object store.
- The RL Loop:
- Simulation: Parallel Tasks generate trajectories (rollouts).
- Training: Trajectories are sent to an Actor (Parameter Server) to update model weights via SGD.
- Serving: The updated weights are served back to the Tasks for the next iteration.
Implementation: Simulating Ray in Python
To truly understand the difference between Tasks and Actors, let's implement a simplified version of the Ray runtime using Python's multiprocessing.
import uuid
import time
import random
import concurrent.futures
from typing import Any, Dict, Callable
from dataclasses import dataclass
from multiprocessing import Manager
@dataclass
class ObjectRef:
"""A handle to a result that may not yet be computed (a Future)."""
id: str
class RayRuntime:
"""Simplified simulation of the Ray Distributed Execution Engine."""
def __init__(self, num_workers: int = 4):
self._manager = Manager()
self._object_store = self._manager.dict()
self._actor_registry = self._manager.dict()
self._executor = concurrent.futures.ProcessPoolExecutor(max_workers=num_workers)
print(f"[RayRuntime] Initialized with {num_workers} workers.")
def remote_task(self, func: Callable, *args, **kwargs) -> ObjectRef:
"""Schedules a stateless TASK."""
ref_id = str(uuid.uuid4())
ref = ObjectRef(ref_id)
future = self._executor.submit(self._execute_task, func, *args, **kwargs)
future.add_done_callback(lambda f: self._object_store.update({ref_id: f.result()}))
return ref
def _execute_task(self, func, *args, **kwargs):
return func(*args, **kwargs)
def create_actor(self, actor_class, *args, **kwargs):
"""Instantiates a stateful ACTOR."""
actor_id = str(uuid.uuid4())
actor_instance = actor_class(*args, **kwargs)
self._actor_registry[actor_id] = actor_instance
return RayActorHandle(actor_id, self)
def get(self, ref: ObjectRef) -> Any:
"""Blocking call to retrieve value from the object store."""
while ref.id not in self._object_store:
time.sleep(0.01)
return self._object_store[ref.id]
class RayActorHandle:
"""Proxy handle to route calls to a specific remote Actor."""
def __init__(self, actor_id: str, runtime: RayRuntime):
self.actor_id = actor_id
self.runtime = runtime
def __getattr__(self, name):
def wrapper(*args, **kwargs):
actor = self.runtime._actor_registry[self.actor_id]
method = getattr(actor, name)
return method(*args, **kwargs)
return wrapper
# --- RL Pipeline Demonstration ---
class ParameterServer:
"""Stateful Actor: Maintains global model weights."""
def __init__(self, lr=0.01):
self.weights = 0.0
self.lr = lr
def apply_gradients(self, grad):
self.weights -= self.lr * grad
return self.weights
def get_weights(self):
return self.weights
def simulate_environment(policy_weight: float, env_id: int):
"""Stateless Task: Simulates an environment rollout."""
time.sleep(random.uniform(0.1, 0.3))
gradient = (policy_weight - 10.0) * 1.0 # Target weight is 10.0
print(f"[Task] Env {env_id} computed gradient: {gradient:.4f}")
return gradient
if __name__ == '__main__':
ray = RayRuntime(num_workers=4)
ps = ray.create_actor(ParameterServer, lr=0.1)
for epoch in range(3):
print(f"\n--- Epoch {epoch} ---")
current_weights = ps.get_weights()
# Parallel Simulation (Tasks)
task_refs = [ray.remote_task(simulate_environment, current_weights, i) for i in range(4)]
# Collect and Update (Actor)
gradients = [ray.get(ref) for ref in task_refs]
avg_grad = sum(gradients) / len(gradients)
new_weight = ps.apply_gradients(avg_grad)
print(f"Updated Weight: {new_weight:.4f}")
Key Takeaways for Engineers
When to use Tasks vs. Actors?
| Feature | Tasks | Actors |
|---|---|---|
| State | Stateless | Stateful |
| Lifecycle | Short-lived (Ephemeral) | Long-lived |
| Scaling | Highly parallel (thousands) | Limited by state management |
| Use Case | Data processing, RL rollouts | Model weights, DB connections |
Why this matters for AI
The ability to mix these two primitives allows Ray to handle the entire ML lifecycle. You can use Tasks for the heavy lifting of data ingestion and simulation, and Actors for the coordination of training and the serving of the final model—all within a single, unified framework.
By decoupling the control plane from the execution plane, Ray ensures that as your AI models grow in complexity, your infrastructure doesn't become the bottleneck.