530 lines
17 KiB
Python
530 lines
17 KiB
Python
import time
|
|
from typing import TYPE_CHECKING
|
|
|
|
import numpy as np
|
|
|
|
import ray
|
|
import ray.experimental.internal_kv as internal_kv
|
|
from ray.util.collective import (
|
|
allreduce,
|
|
broadcast,
|
|
create_collective_group,
|
|
init_collective_group,
|
|
)
|
|
from ray.util.collective.backend_registry import (
|
|
_global_registry,
|
|
register_collective_backend,
|
|
)
|
|
from ray.util.collective.collective_group.base_collective_group import BaseGroup
|
|
from ray.util.collective.types import (
|
|
AllGatherOptions,
|
|
AllReduceOptions,
|
|
BarrierOptions,
|
|
BroadcastOptions,
|
|
RecvOptions,
|
|
ReduceOp,
|
|
ReduceOptions,
|
|
ReduceScatterOptions,
|
|
SendOptions,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
|
|
|
|
def _unregister_collective_backend(name: str) -> None:
|
|
"""Helper function to unregister a backend for testing purposes."""
|
|
upper_name = name.upper()
|
|
if upper_name in _global_registry._map:
|
|
del _global_registry._map[upper_name]
|
|
|
|
|
|
def get_data_key(group_name: str, rank: int, op_name: str):
|
|
return f"collective_mock_{group_name}_{op_name}_rank_{rank}"
|
|
|
|
|
|
def get_barrier_key(group_name: str, barrier_id: int):
|
|
return f"collective_mock_{group_name}_barrier_{barrier_id}"
|
|
|
|
|
|
class MockInternalKVGroup(BaseGroup):
|
|
def __init__(self, world_size: int, rank: int, group_name: str):
|
|
super().__init__(world_size, rank, group_name)
|
|
self._barrier_counter = 0
|
|
|
|
@classmethod
|
|
def backend(cls):
|
|
return "MOCK"
|
|
|
|
@classmethod
|
|
def check_backend_availability(cls) -> bool:
|
|
return True
|
|
|
|
def _check_tensor_input(self, tensor):
|
|
assert isinstance(tensor, list) and len(tensor) == 1
|
|
t = tensor[0]
|
|
if isinstance(t, np.ndarray):
|
|
return t
|
|
try:
|
|
import torch
|
|
|
|
if isinstance(t, torch.Tensor):
|
|
return t
|
|
except ImportError:
|
|
pass
|
|
raise ValueError(
|
|
f"MockInternalKVGroup only only accepts numpy.ndarray or torch.Tensor, received {type(t)}"
|
|
)
|
|
|
|
def _serialize_tensor(self, tensor):
|
|
if isinstance(tensor, np.ndarray):
|
|
return tensor.tobytes(), tensor.shape, tensor.dtype
|
|
try:
|
|
import torch
|
|
|
|
if isinstance(tensor, torch.Tensor):
|
|
return (
|
|
tensor.cpu().numpy().tobytes(),
|
|
tensor.shape,
|
|
tensor.cpu().numpy().dtype,
|
|
)
|
|
except ImportError:
|
|
pass
|
|
raise ValueError(f"Unsupported tensor type: {type(tensor)}")
|
|
|
|
def _deserialize_tensor(self, data: bytes, shape, dtype, target_tensor):
|
|
if isinstance(target_tensor, np.ndarray):
|
|
np_array = np.frombuffer(data, dtype=dtype).reshape(shape)
|
|
target_tensor[:] = np_array
|
|
else:
|
|
try:
|
|
import torch
|
|
|
|
if isinstance(target_tensor, torch.Tensor):
|
|
np_array = np.frombuffer(data, dtype=dtype).reshape(shape)
|
|
target_tensor.copy_(torch.from_numpy(np_array))
|
|
except ImportError:
|
|
pass
|
|
|
|
def broadcast(self, tensor, broadcast_options=BroadcastOptions()):
|
|
tensor = self._check_tensor_input(tensor)
|
|
root_rank = broadcast_options.root_rank
|
|
|
|
data_key = get_data_key(self._group_name, root_rank, "broadcast")
|
|
|
|
if self._rank == root_rank:
|
|
data, shape, dtype = self._serialize_tensor(tensor)
|
|
internal_kv._internal_kv_put(data_key, data)
|
|
internal_kv._internal_kv_put(f"{data_key}_shape", str(shape))
|
|
internal_kv._internal_kv_put(f"{data_key}_dtype", dtype.name)
|
|
else:
|
|
deadline_s = time.time() + 30.0
|
|
while True:
|
|
data = internal_kv._internal_kv_get(data_key)
|
|
if data is not None:
|
|
break
|
|
if time.time() > deadline_s:
|
|
raise TimeoutError(
|
|
f"Timed out waiting for broadcast data from rank {root_rank}"
|
|
)
|
|
time.sleep(0.01)
|
|
|
|
deadline_s = time.time() + 30.0
|
|
while True:
|
|
shape_data = internal_kv._internal_kv_get(f"{data_key}_shape")
|
|
dtype_data = internal_kv._internal_kv_get(f"{data_key}_dtype")
|
|
if shape_data is not None and dtype_data is not None:
|
|
break
|
|
if time.time() > deadline_s:
|
|
raise TimeoutError(
|
|
f"Timed out waiting for broadcast metadata from rank {root_rank}"
|
|
)
|
|
time.sleep(0.01)
|
|
|
|
shape_str = shape_data.decode()
|
|
shape = eval(shape_str)
|
|
dtype_name = dtype_data.decode()
|
|
dtype = np.dtype(dtype_name)
|
|
|
|
self._deserialize_tensor(data, shape, dtype, tensor)
|
|
|
|
def allreduce(self, tensor, allreduce_options=AllReduceOptions()):
|
|
tensor = self._check_tensor_input(tensor)
|
|
reduce_op = allreduce_options.reduceOp
|
|
|
|
data_key = get_data_key(self._group_name, self._rank, "allreduce")
|
|
done_key = (
|
|
f"collective_mock_{self._group_name}_allreduce_done_rank_{self._rank}"
|
|
)
|
|
|
|
data, shape, dtype = self._serialize_tensor(tensor)
|
|
internal_kv._internal_kv_put(data_key, data)
|
|
internal_kv._internal_kv_put(f"{data_key}_shape", str(shape))
|
|
internal_kv._internal_kv_put(f"{data_key}_dtype", dtype.name)
|
|
internal_kv._internal_kv_put(done_key, b"1")
|
|
|
|
deadline_s = time.time() + 30.0
|
|
while True:
|
|
all_done = True
|
|
for r in range(self._world_size):
|
|
key = f"collective_mock_{self._group_name}_allreduce_done_rank_{r}"
|
|
if internal_kv._internal_kv_get(key) is None:
|
|
all_done = False
|
|
break
|
|
if all_done:
|
|
break
|
|
if time.time() > deadline_s:
|
|
raise TimeoutError(
|
|
"Timed out waiting for allreduce data from all ranks"
|
|
)
|
|
time.sleep(0.01)
|
|
|
|
result = None
|
|
|
|
for r in range(self._world_size):
|
|
rank_data_key = get_data_key(self._group_name, r, "allreduce")
|
|
rank_data = internal_kv._internal_kv_get(rank_data_key)
|
|
rank_shape_data = internal_kv._internal_kv_get(f"{rank_data_key}_shape")
|
|
rank_dtype_data = internal_kv._internal_kv_get(f"{rank_data_key}_dtype")
|
|
|
|
rank_shape_str = rank_shape_data.decode()
|
|
rank_shape = eval(rank_shape_str)
|
|
rank_dtype_name = rank_dtype_data.decode()
|
|
rank_dtype = np.dtype(rank_dtype_name)
|
|
|
|
if isinstance(tensor, np.ndarray):
|
|
rank_tensor = np.frombuffer(rank_data, dtype=rank_dtype).reshape(
|
|
rank_shape
|
|
)
|
|
else:
|
|
import torch
|
|
|
|
rank_np = np.frombuffer(rank_data, dtype=rank_dtype).reshape(rank_shape)
|
|
rank_tensor = torch.from_numpy(rank_np)
|
|
|
|
if result is None:
|
|
result = (
|
|
rank_tensor.copy()
|
|
if isinstance(rank_tensor, np.ndarray)
|
|
else rank_tensor.clone()
|
|
)
|
|
else:
|
|
if reduce_op == ReduceOp.SUM:
|
|
result += rank_tensor
|
|
elif reduce_op == ReduceOp.PRODUCT:
|
|
result *= rank_tensor
|
|
elif reduce_op == ReduceOp.MAX:
|
|
if isinstance(result, np.ndarray):
|
|
result = np.maximum(result, rank_tensor)
|
|
else:
|
|
import torch
|
|
|
|
result = torch.maximum(result, rank_tensor)
|
|
elif reduce_op == ReduceOp.MIN:
|
|
if isinstance(result, np.ndarray):
|
|
result = np.minimum(result, rank_tensor)
|
|
else:
|
|
import torch
|
|
|
|
result = torch.minimum(result, rank_tensor)
|
|
|
|
if isinstance(tensor, np.ndarray):
|
|
tensor[:] = result
|
|
else:
|
|
import torch
|
|
|
|
if isinstance(result, np.ndarray):
|
|
tensor.copy_(torch.from_numpy(result))
|
|
else:
|
|
tensor.copy_(result)
|
|
|
|
def barrier(self, barrier_options=BarrierOptions()):
|
|
barrier_id = self._barrier_counter
|
|
barrier_key = get_barrier_key(self._group_name, barrier_id)
|
|
rank_key = f"{barrier_key}_rank_{self._rank}"
|
|
|
|
internal_kv._internal_kv_put(rank_key, b"1")
|
|
|
|
deadline_s = time.time() + 30.0
|
|
while True:
|
|
all_arrived = True
|
|
for r in range(self._world_size):
|
|
key = f"{barrier_key}_rank_{r}"
|
|
if internal_kv._internal_kv_get(key) is None:
|
|
all_arrived = False
|
|
break
|
|
if all_arrived:
|
|
break
|
|
if time.time() > deadline_s:
|
|
raise TimeoutError("Timed out waiting for barrier")
|
|
time.sleep(0.01)
|
|
|
|
self._barrier_counter += 1
|
|
|
|
def reduce(self, tensor, reduce_options=ReduceOptions()):
|
|
raise NotImplementedError("reduce is not implemented in MockInternalKVGroup")
|
|
|
|
def allgather(self, tensor_list, tensor, allgather_options=AllGatherOptions()):
|
|
raise NotImplementedError("allgather is not implemented in MockInternalKVGroup")
|
|
|
|
def reducescatter(
|
|
self, tensor, tensor_list, reducescatter_options=ReduceScatterOptions()
|
|
):
|
|
raise NotImplementedError(
|
|
"reducescatter is not implemented in MockInternalKVGroup"
|
|
)
|
|
|
|
def send(self, tensor, send_options: SendOptions):
|
|
raise NotImplementedError("send is not implemented in MockInternalKVGroup")
|
|
|
|
def recv(self, tensor, recv_options: RecvOptions):
|
|
raise NotImplementedError("recv is not implemented in MockInternalKVGroup")
|
|
|
|
|
|
def test_mock_backend_create_group():
|
|
"""Test using create_collective_group (driver-managed approach).
|
|
|
|
In this approach:
|
|
- Driver calls create_collective_group() to declare the group
|
|
- Workers only need to register the backend
|
|
- Workers do NOT call init_collective_group()
|
|
- The group is automatically initialized when workers call collective ops
|
|
"""
|
|
|
|
ray.init()
|
|
register_collective_backend("MOCK", MockInternalKVGroup)
|
|
|
|
@ray.remote
|
|
class Worker:
|
|
def __init__(self, rank):
|
|
self.rank = rank
|
|
|
|
def setup(self):
|
|
from ray.util.collective.backend_registry import register_collective_backend
|
|
|
|
register_collective_backend("MOCK", MockInternalKVGroup)
|
|
|
|
def broadcast_test(self):
|
|
if self.rank == 0:
|
|
tensor = np.array([42.0, 43.0, 44.0], dtype=np.float32)
|
|
else:
|
|
tensor = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
|
|
|
broadcast(tensor, src_rank=0)
|
|
return tensor.tolist()
|
|
|
|
def allreduce_test(self):
|
|
tensor = np.array([float(self.rank + 1)], dtype=np.float32)
|
|
allreduce(tensor, op=ReduceOp.SUM)
|
|
return tensor.item()
|
|
|
|
actors = [Worker.remote(rank=i) for i in range(3)]
|
|
|
|
create_collective_group(
|
|
actors=actors,
|
|
world_size=3,
|
|
ranks=[0, 1, 2],
|
|
backend="MOCK",
|
|
group_name="default",
|
|
)
|
|
|
|
ray.get([a.setup.remote() for a in actors])
|
|
|
|
results = ray.get([a.broadcast_test.remote() for a in actors])
|
|
expected = [[42.0, 43.0, 44.0]] * 3
|
|
if results == expected:
|
|
print("Broadcast test passed!")
|
|
else:
|
|
print(f"Broadcast test failed! Expected {expected}, got {results}")
|
|
|
|
results = ray.get([a.allreduce_test.remote() for a in actors])
|
|
|
|
if results == [6.0, 6.0, 6.0]:
|
|
print("AllReduce test passed!")
|
|
else:
|
|
print(f"AllReduce test failed! Expected [6.0, 6.0, 6.0], got {results}")
|
|
|
|
ray.shutdown()
|
|
_unregister_collective_backend("MOCK")
|
|
print("test_mock_backend_create_group completed!")
|
|
|
|
|
|
def test_mock_backend_init_group():
|
|
"""Test using init_collective_group (worker-managed approach).
|
|
|
|
In this approach:
|
|
- Workers call init_collective_group() inside their setup method
|
|
- Driver does NOT call create_collective_group()
|
|
- Each worker explicitly initializes its own group membership
|
|
"""
|
|
|
|
ray.init()
|
|
|
|
@ray.remote
|
|
class Worker:
|
|
def __init__(self, rank):
|
|
self.rank = rank
|
|
|
|
def setup(self, world_size):
|
|
from ray.util.collective.backend_registry import register_collective_backend
|
|
|
|
register_collective_backend("MOCK", MockInternalKVGroup)
|
|
|
|
init_collective_group(
|
|
world_size=world_size,
|
|
rank=self.rank,
|
|
backend="MOCK",
|
|
group_name="default",
|
|
)
|
|
|
|
def broadcast_test(self):
|
|
if self.rank == 0:
|
|
tensor = np.array([42.0, 43.0, 44.0], dtype=np.float32)
|
|
else:
|
|
tensor = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
|
|
|
broadcast(tensor, src_rank=0)
|
|
return tensor.tolist()
|
|
|
|
def allreduce_test(self):
|
|
tensor = np.array([float(self.rank + 1)], dtype=np.float32)
|
|
allreduce(tensor, op=ReduceOp.SUM)
|
|
return tensor.item()
|
|
|
|
actors = [Worker.remote(rank=i) for i in range(3)]
|
|
|
|
# Do NOT call create_collective_group here
|
|
ray.get([a.setup.remote(3) for a in actors])
|
|
|
|
results = ray.get([a.broadcast_test.remote() for a in actors])
|
|
expected = [[42.0, 43.0, 44.0]] * 3
|
|
if results == expected:
|
|
print("Broadcast test passed!")
|
|
else:
|
|
print(f"Broadcast test failed! Expected {expected}, got {results}")
|
|
|
|
results = ray.get([a.allreduce_test.remote() for a in actors])
|
|
|
|
if results == [6.0, 6.0, 6.0]:
|
|
print("AllReduce test passed!")
|
|
else:
|
|
print(f"AllReduce test failed! Expected [6.0, 6.0, 6.0], got {results}")
|
|
|
|
ray.shutdown()
|
|
_unregister_collective_backend("MOCK")
|
|
print("test_mock_backend_init_group completed!")
|
|
|
|
|
|
def test_mock_backend_worker_not_registered():
|
|
"""Test error handling when backend is not registered in worker.
|
|
|
|
This test uses create_collective_group (driver-managed approach).
|
|
The driver registers the backend, but workers do not.
|
|
When workers try to call collective ops, they should fail.
|
|
|
|
Note: We use world_size=1 to avoid "Unhandled error" messages
|
|
from multiple workers failing simultaneously.
|
|
"""
|
|
|
|
ray.init()
|
|
register_collective_backend("MOCK", MockInternalKVGroup)
|
|
|
|
@ray.remote
|
|
class Worker:
|
|
def __init__(self, rank):
|
|
self.rank = rank
|
|
|
|
def broadcast_test(self):
|
|
tensor = np.array([0.0, 0.0, 0.0], dtype=np.float32)
|
|
broadcast(tensor, src_rank=0)
|
|
return tensor.tolist()
|
|
|
|
# Use single actor to avoid multiple "Unhandled error" messages
|
|
actors = [Worker.remote(rank=0)]
|
|
|
|
create_collective_group(
|
|
actors=actors,
|
|
world_size=1,
|
|
ranks=[0],
|
|
backend="MOCK",
|
|
group_name="default",
|
|
)
|
|
|
|
test_passed = False
|
|
try:
|
|
ray.get([a.broadcast_test.remote() for a in actors])
|
|
print("ERROR: Should have raised an exception for missing registration!")
|
|
except Exception as e:
|
|
if "not registered" in str(e) or "not initialized" in str(e):
|
|
print(
|
|
"Test passed! Correctly raised error for missing worker registration."
|
|
)
|
|
test_passed = True
|
|
else:
|
|
print(f"ERROR: Unexpected error: {e}")
|
|
|
|
ray.shutdown()
|
|
_unregister_collective_backend("MOCK")
|
|
|
|
if not test_passed:
|
|
print("Test failed!")
|
|
|
|
|
|
def test_mock_backend_driver_not_registered():
|
|
"""Test error handling when backend is not registered on driver.
|
|
|
|
This test uses create_collective_group, but the driver doesn't
|
|
register the backend first, so it should fail immediately.
|
|
"""
|
|
|
|
ray.init()
|
|
|
|
@ray.remote
|
|
class Worker:
|
|
def __init__(self, rank):
|
|
self.rank = rank
|
|
|
|
actors = [Worker.remote(rank=i) for i in range(2)]
|
|
|
|
try:
|
|
create_collective_group(
|
|
actors=actors,
|
|
world_size=2,
|
|
ranks=[0, 1],
|
|
backend="MOCK",
|
|
group_name="default",
|
|
)
|
|
print("ERROR: Should have raised an exception for missing registration!")
|
|
except Exception as e:
|
|
if "not registered" in str(e):
|
|
print(
|
|
"Test passed! Correctly raised error for missing driver registration."
|
|
)
|
|
else:
|
|
print(f"ERROR: Unexpected error: {e}")
|
|
|
|
ray.shutdown()
|
|
_unregister_collective_backend("MOCK")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("=" * 60)
|
|
print("Test 1: create_collective_group approach (driver-managed)")
|
|
print("=" * 60)
|
|
test_mock_backend_create_group()
|
|
|
|
print("\n" + "=" * 60)
|
|
print("Test 2: init_collective_group approach (worker-managed)")
|
|
print("=" * 60)
|
|
test_mock_backend_init_group()
|
|
|
|
print("\n" + "=" * 60)
|
|
print("Test 3: Error handling - worker not registered")
|
|
print("=" * 60)
|
|
test_mock_backend_worker_not_registered()
|
|
|
|
print("\n" + "=" * 60)
|
|
print("Test 4: Error handling - driver not registered")
|
|
print("=" * 60)
|
|
test_mock_backend_driver_not_registered()
|