Files
2026-07-13 13:17:40 +08:00

1024 lines
38 KiB
Python

"""Unit tests for ShuffleStrategy.GPU_SHUFFLE.
These tests do NOT require GPUs or the rapidsmpf/cudf/ucxx packages.
All Ray actor calls are mocked so the tests run on a standard CPU cluster.
"""
from typing import List
from unittest.mock import MagicMock, patch
import numpy as np
import pyarrow as pa
import pytest
import ray
import ray.data._internal.gpu_shuffle.hash_shuffle as hash_shuffle
from ray.data._internal.execution.interfaces import (
BlockEntry,
ExecutionResources,
PhysicalOperator,
RefBundle,
)
from ray.data._internal.gpu_shuffle.hash_shuffle import (
GPURankPool,
GPUShuffleActor,
GPUShuffleOperator,
_derive_num_gpu_ranks,
)
from ray.data._internal.logical.interfaces import LogicalOperator
from ray.data._internal.logical.operators import Repartition
from ray.data._internal.planner.plan_all_to_all_op import plan_all_to_all_op
from ray.data._internal.util import explain_plan
from ray.data.block import BlockMetadata
from ray.data.context import DataContext, ShuffleStrategy
from ray.data.tests.conftest import noop_counter
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_input_op_mock(num_blocks=None, size_bytes=None):
"""Return a minimal PhysicalOperator mock compatible with HashShufflingOperatorBase."""
logical_mock = MagicMock(LogicalOperator)
logical_mock.infer_metadata.return_value = BlockMetadata(
num_rows=None,
size_bytes=size_bytes,
exec_stats=None,
input_files=None,
)
logical_mock.estimated_num_outputs.return_value = num_blocks
op_mock = MagicMock(PhysicalOperator)
op_mock._output_dependencies = []
op_mock._logical_operators = [logical_mock]
op_mock.num_output_splits.return_value = 1
return op_mock
def _make_bundle(num_blocks: int = 1) -> RefBundle:
"""Return a RefBundle with *num_blocks* placeholder block refs."""
meta = BlockMetadata(num_rows=10, size_bytes=100, exec_stats=None, input_files=None)
blocks = [
BlockEntry(ray.ObjectRef(bytes([i % 256]) * 28), meta)
for i in range(num_blocks)
]
return RefBundle(blocks, schema=None, owns_blocks=False)
def _make_data_context(
*,
gpu_shuffle_num_actors: int = 4,
gpu_shuffle_rmm_pool_size="auto",
gpu_shuffle_spill_memory_limit="auto",
) -> DataContext:
ctx = DataContext()
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
ctx.gpu_shuffle_num_actors = gpu_shuffle_num_actors
ctx.gpu_shuffle_rmm_pool_size = gpu_shuffle_rmm_pool_size
ctx.gpu_shuffle_spill_memory_limit = gpu_shuffle_spill_memory_limit
return ctx
# ---------------------------------------------------------------------------
# Enum / DataContext field tests (no Ray required)
# ---------------------------------------------------------------------------
class TestDataContextGpuFields:
def test_gpu_shuffle_default_values(self):
ctx = DataContext()
assert ctx.gpu_shuffle_num_actors is None
assert ctx.gpu_shuffle_rmm_pool_size is None
assert ctx.gpu_shuffle_spill_memory_limit == "auto"
def test_gpu_shuffle_fields_settable(self):
ctx = DataContext()
ctx.shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
ctx.gpu_shuffle_num_actors = 8
ctx.gpu_shuffle_rmm_pool_size = 4 * 1024**3
ctx.gpu_shuffle_spill_memory_limit = None
assert ctx.shuffle_strategy == ShuffleStrategy.GPU_SHUFFLE
assert ctx.gpu_shuffle_num_actors == 8
assert ctx.gpu_shuffle_rmm_pool_size == 4 * 1024**3
assert ctx.gpu_shuffle_spill_memory_limit is None
# ---------------------------------------------------------------------------
# Import isolation — gpu_shuffle.py must be importable without GPU packages
# ---------------------------------------------------------------------------
class TestImportIsolation:
def test_module_importable_without_rapidsmpf(self):
"""The gpu_shuffle module must not import rapidsmpf at module level."""
import ray.data._internal.gpu_shuffle.hash_shuffle as mod
# If we got here the import succeeded on a CPU-only env.
assert hasattr(mod, "GPUShuffleOperator")
assert hasattr(mod, "GPURankPool")
assert hasattr(mod, "GPUShuffleActor")
def test_ray_data_importable_without_gpu_packages(self):
import ray.data # noqa: F401 — must not raise
# ---------------------------------------------------------------------------
# _derive_num_gpu_ranks
# ---------------------------------------------------------------------------
class TestDeriveNumGpuRanks:
def test_explicit_count_used(self):
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 7
assert _derive_num_gpu_ranks(ctx) == 7
def test_auto_detect_from_cluster(self):
ctx = DataContext()
ctx.gpu_shuffle_num_actors = None
with patch(
"ray.data._internal.gpu_shuffle.hash_shuffle"
"._get_total_cluster_resources"
) as mock_res:
mock_res.return_value = ExecutionResources(cpu=8, gpu=4)
assert _derive_num_gpu_ranks(ctx) == 4
def test_zero_gpus_raises(self):
ctx = DataContext()
ctx.gpu_shuffle_num_actors = None
with patch(
"ray.data._internal.gpu_shuffle.hash_shuffle"
"._get_total_cluster_resources"
) as mock_res:
mock_res.return_value = ExecutionResources(cpu=8, gpu=0)
with pytest.raises(RuntimeError, match="GPU resources"):
_derive_num_gpu_ranks(ctx)
def test_fractional_gpu_count_truncated(self):
"""ExecutionResources.gpu may be fractional; int() truncates."""
ctx = DataContext()
ctx.gpu_shuffle_num_actors = None
with patch(
"ray.data._internal.gpu_shuffle.hash_shuffle"
"._get_total_cluster_resources"
) as mock_res:
mock_res.return_value = ExecutionResources(cpu=4, gpu=3.9)
assert _derive_num_gpu_ranks(ctx) == 3
# ---------------------------------------------------------------------------
# GPURankPool
# ---------------------------------------------------------------------------
class TestGPURankPool:
def _make_pool(self, nranks=4, total_nparts=8):
return GPURankPool(
nranks=nranks,
total_nparts=total_nparts,
setup_timeout_s=60.0,
actor_cls_factory=lambda: hash_shuffle.GPUShuffleActor,
actor_kwargs={
"key_columns": ["user_id"],
"columns": None,
"rmm_pool_size": "auto",
"spill_memory_limit": "auto",
},
log_label="GPUShufflePool",
)
def test_actors_empty_before_start(self):
pool = self._make_pool()
assert pool.actors == []
def test_start_creates_correct_number_of_actors(self):
pool = self._make_pool(nranks=3)
mock_actor_handles = [MagicMock() for _ in range(3)]
mock_root_address = b"ucxx://fake-address"
# setup_root.remote() on rank 0 returns a future for (rank, address)
mock_actor_handles[0].setup_root.remote.return_value = MagicMock()
for handle in mock_actor_handles:
handle.setup_worker.remote.return_value = MagicMock()
with patch(
"ray.data._internal.gpu_shuffle.hash_shuffle.GPUShuffleActor"
) as mock_actor_cls, patch(
"ray.data._internal.gpu_shuffle.hash_shuffle.ray.get"
) as mock_ray_get, patch(
"ray.data._internal.gpu_shuffle.hash_shuffle.ray.wait"
) as mock_ray_wait:
mock_actor_cls.options.return_value.remote.side_effect = mock_actor_handles
# First ray.get returns (rank, root_address); second returns None list (setup done)
mock_ray_get.side_effect = [(0, mock_root_address), [None, None, None]]
# ray.wait returns all refs as ready
worker_refs = [
h.setup_worker.remote.return_value for h in mock_actor_handles
]
mock_ray_wait.return_value = (worker_refs, [])
pool.start()
assert len(pool.actors) == 3
assert mock_actor_cls.options.call_count == 3
def test_get_actor_for_block_round_robin(self):
pool = self._make_pool(nranks=3)
mock_actors = [MagicMock(name=f"actor_{i}") for i in range(3)]
pool._actors = mock_actors
# Blocks 0,1,2,3,4 should map to actors 0,1,2,0,1
expected = [mock_actors[i % 3] for i in range(5)]
actual = [pool.get_actor_for_block(i) for i in range(5)]
assert actual == expected
def test_shutdown_force_kills_actors(self):
pool = self._make_pool(nranks=2)
mock_actors = [MagicMock(), MagicMock()]
pool._actors = mock_actors
with patch("ray.data._internal.gpu_shuffle.hash_shuffle.ray.kill") as mock_kill:
pool.shutdown(force=True)
assert mock_kill.call_count == 2
assert pool.actors == []
assert pool.is_shutdown
def test_shutdown_without_force_clears_actors(self):
pool = self._make_pool(nranks=2)
pool._actors = [MagicMock(), MagicMock()]
with patch("ray.data._internal.gpu_shuffle.hash_shuffle.ray.kill") as mock_kill:
pool.shutdown(force=False)
mock_kill.assert_not_called()
assert pool.actors == []
assert pool.is_shutdown
# ---------------------------------------------------------------------------
# GPUShuffleOperator constructor
# ---------------------------------------------------------------------------
class TestGPUShuffleOperatorConstructor:
def _make_op(self, num_partitions=None, nranks=4, default_parallelism=200):
ctx = _make_data_context(gpu_shuffle_num_actors=nranks)
ctx.default_hash_shuffle_parallelism = default_parallelism
input_op = _make_input_op_mock()
return GPUShuffleOperator(
input_op,
ctx,
key_columns=("user_id",),
num_partitions=num_partitions,
)
def test_name_contains_key_columns(self):
op = self._make_op(num_partitions=8)
assert "user_id" in op.name
def test_name_contains_num_partitions(self):
op = self._make_op(num_partitions=8)
assert "8" in op.name
def test_num_partitions_clamped_to_nranks(self):
"""When requested partitions < nranks, partitions is raised to nranks."""
op = self._make_op(num_partitions=2, nranks=8)
assert op._num_partitions == 8
def test_num_partitions_exceeds_nranks_unchanged(self):
op = self._make_op(num_partitions=16, nranks=4)
assert op._num_partitions == 16
def test_num_partitions_defaults_to_context_parallelism(self):
op = self._make_op(num_partitions=None, nranks=4, default_parallelism=200)
assert op._num_partitions == 200
def test_base_resource_usage_is_nranks_gpus(self):
op = self._make_op(nranks=6, num_partitions=6)
usage = op.base_resource_usage
assert usage.gpu == 6
def test_current_logical_usage_reserves_nranks_before_pool_start(self):
"""Empty actor list before start must still reserve configured GPUs."""
op = self._make_op(nranks=5, num_partitions=5)
assert op.current_logical_usage().gpu == 5
def test_current_logical_usage_matches_len_actors_when_running(self):
op = self._make_op(nranks=4, num_partitions=4)
op._rank_pool._actors = [MagicMock() for _ in range(4)]
assert op.current_logical_usage().gpu == 4
def test_current_logical_usage_zero_after_pool_shutdown(self):
"""Early actor release must drop logical GPU usage for the scheduler."""
op = self._make_op(nranks=4, num_partitions=4)
op._rank_pool._actors = [MagicMock() for _ in range(4)]
op._rank_pool.shutdown(force=False)
assert op.current_logical_usage().gpu == 0
def test_incremental_resource_usage_is_one_gpu(self):
op = self._make_op()
usage = op.incremental_resource_usage()
assert usage.gpu == 1
def test_progress_bar_names(self):
op = self._make_op()
names = op.get_sub_progress_bar_names()
assert names == ["GPU Shuffle", "GPU Reduce"]
def test_set_sub_progress_bar_shuffle(self):
op = self._make_op()
mock_bar = MagicMock()
op.set_sub_progress_bar("GPU Shuffle", mock_bar)
assert op._shuffle_bar is mock_bar
def test_set_sub_progress_bar_reduce(self):
op = self._make_op()
mock_bar = MagicMock()
op.set_sub_progress_bar("GPU Reduce", mock_bar)
assert op._reduce_bar is mock_bar
def test_initial_state(self):
op = self._make_op()
assert op._next_block_idx == 0
assert op._insert_tasks == {}
assert op._extraction_tasks == {}
assert not op._finalization_started
assert len(op._output_queue) == 0
# ---------------------------------------------------------------------------
# GPUShuffleOperator: _add_input_inner block routing
# ---------------------------------------------------------------------------
class TestGPUShuffleOperatorInputRouting:
def _make_op_with_mock_pool(self, nranks=3, num_partitions=6):
ctx = _make_data_context(gpu_shuffle_num_actors=nranks)
input_op = _make_input_op_mock()
op = GPUShuffleOperator(
input_op, ctx, key_columns=("k",), num_partitions=num_partitions
)
# Replace the real pool with a mock
mock_actors = [MagicMock(name=f"actor_{i}") for i in range(nranks)]
for actor in mock_actors:
actor.insert_batch.remote.return_value = MagicMock()
op._rank_pool._actors = mock_actors
op._rank_pool._nranks = nranks
return op, mock_actors
def test_single_block_routed_to_first_actor(self):
op, actors = self._make_op_with_mock_pool(nranks=3)
bundle = _make_bundle(num_blocks=1)
op._add_input_inner(bundle, input_index=0)
actors[0].insert_batch.remote.assert_called_once()
def test_round_robin_across_three_ranks(self):
op, actors = self._make_op_with_mock_pool(nranks=3)
# Submit 6 single-block bundles
for _ in range(6):
op._add_input_inner(_make_bundle(1), input_index=0)
# Each actor should have received exactly 2 blocks
for actor in actors:
assert actor.insert_batch.remote.call_count == 2
def test_block_idx_increments_per_block(self):
op, actors = self._make_op_with_mock_pool(nranks=3)
bundle_with_2 = _make_bundle(num_blocks=2)
op._add_input_inner(bundle_with_2, input_index=0)
assert op._next_block_idx == 2
def test_insert_tasks_tracked(self):
op, actors = self._make_op_with_mock_pool(nranks=2)
op._add_input_inner(_make_bundle(1), 0)
assert len(op._insert_tasks) == 1
def test_insert_task_callback_removes_task(self):
op, actors = self._make_op_with_mock_pool(nranks=2)
op._add_input_inner(_make_bundle(1), 0)
# Grab the callback and invoke it
task = list(op._insert_tasks.values())[0]
assert 0 in op._insert_tasks
task._task_done_callback()
assert 0 not in op._insert_tasks
# ---------------------------------------------------------------------------
# GPUShuffleOperator: finalization and completion
# ---------------------------------------------------------------------------
class TestGPUShuffleOperatorFinalization:
def _make_op(self, nranks=2, num_partitions=4):
ctx = _make_data_context(gpu_shuffle_num_actors=nranks)
input_op = _make_input_op_mock()
op = GPUShuffleOperator(
input_op, ctx, key_columns=("k",), num_partitions=num_partitions
)
mock_actors = [MagicMock(name=f"actor_{i}") for i in range(nranks)]
for actor in mock_actors:
actor.finish_and_extract.options.return_value.remote.return_value = (
MagicMock()
)
op._rank_pool._actors = mock_actors
op._rank_pool._nranks = nranks
op._block_ref_counter = noop_counter()
return op, mock_actors
def test_finalization_not_started_until_inputs_complete(self):
op, _ = self._make_op()
op._inputs_complete = False
op._try_finalize()
assert not op._finalization_started
def test_finalization_not_started_while_inserts_pending(self):
op, _ = self._make_op()
op._inputs_complete = True
op._insert_tasks[0] = MagicMock() # fake pending insert
op._try_finalize()
assert not op._finalization_started
def test_finalization_starts_after_all_inserts_done(self):
op, mock_actors = self._make_op(nranks=2)
op._inputs_complete = True
# No pending inserts
with patch.object(op._reduce_metrics, "on_task_submitted"):
op._try_finalize()
assert op._finalization_started
def test_finish_and_extract_called_on_all_ranks(self):
op, mock_actors = self._make_op(nranks=2)
op._inputs_complete = True
with patch.object(op._reduce_metrics, "on_task_submitted"):
op._try_finalize()
for actor in mock_actors:
actor.finish_and_extract.options.assert_called_once()
def test_try_finalize_idempotent(self):
op, mock_actors = self._make_op(nranks=2)
op._inputs_complete = True
with patch.object(op._reduce_metrics, "on_task_submitted"):
op._try_finalize()
op._try_finalize() # second call should be no-op
# finish_and_extract should only be called once per actor
for actor in mock_actors:
assert actor.finish_and_extract.options.call_count == 1
def test_has_next_false_initially(self):
op, _ = self._make_op()
op._inputs_complete = False
assert not op.has_next()
def test_has_next_true_when_output_queued(self):
op, _ = self._make_op()
bundle = _make_bundle(1)
op._output_queue.add(bundle, key=0)
op._output_queue.finalize(key=0)
op._finalization_started = True
assert op.has_next()
def test_get_next_inner_dequeues(self):
op, _ = self._make_op()
b1 = _make_bundle(1)
b2 = _make_bundle(1)
op._output_queue.add(b1, key=0)
op._output_queue.finalize(key=0)
op._output_queue.add(b2, key=1)
op._output_queue.finalize(key=1)
with patch.object(op._reduce_metrics, "on_output_dequeued"), patch.object(
op._reduce_metrics, "on_output_taken"
):
result = op._get_next_inner()
assert result is b1
assert op._output_queue.has_next()
def test_has_completed_false_while_extracting(self):
op, _ = self._make_op()
op._finalization_started = True
op._extraction_tasks[0] = MagicMock() # still running
assert not op.has_completed()
def test_output_order_is_partition_order_regardless_of_arrival(self):
"""Bundles arriving out of order must be emitted in ascending partition order."""
op, _ = self._make_op(nranks=2, num_partitions=4)
op._finalization_started = True
# Build 4 bundles, insert in reverse order (3, 2, 1, 0)
bundles = {}
for partition_id in reversed(range(4)):
meta = BlockMetadata(
num_rows=1, size_bytes=8, exec_stats=None, input_files=None
)
bundle = RefBundle(
[BlockEntry(ray.ObjectRef(bytes([partition_id]) * 28), meta)],
schema=None,
owns_blocks=False,
)
bundles[partition_id] = bundle
op._output_queue.add(bundle, key=partition_id)
op._output_queue.finalize(key=partition_id)
with patch.object(op._reduce_metrics, "on_output_dequeued"), patch.object(
op._reduce_metrics, "on_output_taken"
):
results = [op._get_next_inner() for _ in range(4)]
# Output must be in partition order 0, 1, 2, 3 — not insertion order 3, 2, 1, 0
assert results == [bundles[i] for i in range(4)]
def test_get_active_tasks_combines_both_phases(self):
op, _ = self._make_op()
insert_task = MagicMock()
extract_task = MagicMock()
op._insert_tasks[0] = insert_task
op._extraction_tasks[0] = extract_task
active = op.get_active_tasks()
assert insert_task in active
assert extract_task in active
assert len(active) == 2
def test_shutdown_clears_tasks_and_kills_actors(self):
op, mock_actors = self._make_op(nranks=2)
op._insert_tasks[0] = MagicMock()
op._extraction_tasks[0] = MagicMock()
expected_kill_count = len(mock_actors)
with patch(
"ray.data._internal.gpu_shuffle.hash_shuffle.ray.kill"
) as mock_kill, patch.object(
PhysicalOperator, "_do_shutdown", return_value=None
):
op._do_shutdown(force=True)
assert op._insert_tasks == {}
assert op._extraction_tasks == {}
assert mock_kill.call_count == expected_kill_count
# ---------------------------------------------------------------------------
# plan_all_to_all_op routing
# ---------------------------------------------------------------------------
class TestPlanAllToAllOpRouting:
"""Verify that plan_all_to_all_op routes GPU_SHUFFLE to GPUShuffleOperator."""
def _make_repartition_op(self, keys=("user_id",), num_outputs=8):
return Repartition(
num_outputs=num_outputs,
input_dependencies=[MagicMock(LogicalOperator)],
shuffle=True,
keys=list(keys),
)
def test_gpu_shuffle_routes_to_gpu_operator(self):
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
logical_op = self._make_repartition_op(keys=["user_id"], num_outputs=8)
input_physical_op = _make_input_op_mock()
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert isinstance(op, GPUShuffleOperator)
def test_hash_shuffle_routes_to_shuffle_reduce_op(self):
"""V2 hash shuffle is a two-op DAG; planner returns the ShuffleReduceOp
with the ShuffleMapOp as its upstream input dependency."""
from ray.data._internal.execution.operators.shuffle_operators.shuffle_map_operator import ( # noqa: E501
ShuffleMapOp,
)
from ray.data._internal.execution.operators.shuffle_operators.shuffle_reduce_operator import ( # noqa: E501
ShuffleReduceOp,
)
ctx = DataContext()
ctx._shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
logical_op = self._make_repartition_op(keys=["user_id"], num_outputs=8)
input_physical_op = _make_input_op_mock()
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert isinstance(op, ShuffleReduceOp)
assert isinstance(op.input_dependencies[0], ShuffleMapOp)
def test_unsupported_strategy_with_keys_raises(self):
ctx = DataContext()
ctx._shuffle_strategy = ShuffleStrategy.SORT_SHUFFLE_PULL_BASED
logical_op = self._make_repartition_op(keys=["user_id"], num_outputs=8)
input_physical_op = _make_input_op_mock()
with pytest.raises(ValueError, match="HASH_SHUFFLE"):
plan_all_to_all_op(logical_op, [input_physical_op], ctx)
def test_gpu_shuffle_respects_num_outputs(self):
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
logical_op = self._make_repartition_op(keys=["id"], num_outputs=16)
input_physical_op = _make_input_op_mock()
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert op._num_partitions == 16
def test_gpu_shuffle_key_columns_normalised(self):
"""Key columns from SortKey.get_columns() should propagate correctly."""
ctx = DataContext()
ctx.gpu_shuffle_num_actors = 4
ctx._shuffle_strategy = ShuffleStrategy.GPU_SHUFFLE
logical_op = self._make_repartition_op(keys=["col_a", "col_b"], num_outputs=8)
input_physical_op = _make_input_op_mock()
op = plan_all_to_all_op(logical_op, [input_physical_op], ctx)
assert "col_a" in op._key_columns
assert "col_b" in op._key_columns
# ---------------------------------------------------------------------------
# GPUShuffleActor: deferred import guard
# ---------------------------------------------------------------------------
class TestGPUShuffleActorImportGuard:
"""GPUShuffleActor.__init__ must raise ImportError with a helpful message
when rapidsmpf is not installed, not a generic ModuleNotFoundError."""
def test_missing_rapidsmpf_raises_import_error(self):
from ray.data._internal.gpu_shuffle.hash_shuffle import GPUShuffleActor
# Access the underlying class (bypass Ray actor wrapper)
ActorClass = GPUShuffleActor.__ray_actor_class__
with patch.dict(
"sys.modules",
{"ray.data._internal.gpu_shuffle.rapidsmpf_backend": None},
):
with pytest.raises(ImportError, match="rapidsmpf"):
ActorClass(
nranks=2,
total_nparts=4,
key_columns=["k"],
)
# ---------------------------------------------------------------------------
# GPU fixtures — shared by all real-GPU test classes below
# ---------------------------------------------------------------------------
def _num_cluster_gpus() -> int:
"""Return the number of GPUs in the Ray cluster (0 if Ray not initialised)."""
if not ray.is_initialized():
return 0
return int(ray.cluster_resources().get("GPU", 0))
@pytest.fixture(scope="module")
def ray_with_gpu():
"""Skip the test if GPU packages or GPU hardware are absent.
Imports ``cudf``, ``rapidsmpf``, and ``ucp`` (ucxx Python bindings) with
``pytest.importorskip`` so the skip message clearly names the missing
package. Also initialises Ray (if not already running) and checks that
at least one GPU is visible in the cluster.
"""
pytest.importorskip("cudf", reason="cudf (GPU DataFrame library) not installed")
pytest.importorskip("rapidsmpf", reason="rapidsmpf not installed")
if not ray.is_initialized():
ray.init()
num_gpus = _num_cluster_gpus()
if num_gpus < 1:
pytest.skip("No GPU resources found in the Ray cluster")
yield num_gpus
# ---------------------------------------------------------------------------
# GPUShuffleActor — real GPU paths (conditional)
# ---------------------------------------------------------------------------
@pytest.mark.gpu
class TestGPUShuffleActorReal:
"""Exercises GPU actor methods on actual hardware.
All tests are skipped automatically when GPU packages or GPU resources are
absent. Run them explicitly with ``pytest -m gpu``.
"""
def _make_setup_actor(self, total_nparts: int = 2, key_columns=None):
"""Create, UCXX-initialise, and return a single-rank GPUShuffleActor."""
key_columns = key_columns or ["id"]
actor = GPUShuffleActor.options(num_gpus=1).remote(
nranks=1,
total_nparts=total_nparts,
key_columns=key_columns,
)
_, root_address = ray.get(actor.setup_root.remote())
ray.get(actor.setup_worker.remote(root_address))
return actor
def test_actor_init_succeeds(self, ray_with_gpu):
"""GPUShuffleActor.__init__ succeeds (rapidsmpf import guard passes)."""
actor = GPUShuffleActor.options(num_gpus=1).remote(
nranks=1,
total_nparts=2,
key_columns=["id"],
)
ray.kill(actor)
def test_setup_root_returns_rank_and_address(self, ray_with_gpu):
"""setup_root() returns a (rank, address_bytes) tuple for UCXX setup."""
actor = GPUShuffleActor.options(num_gpus=1).remote(
nranks=1, total_nparts=1, key_columns=["k"]
)
rank, addr = ray.get(actor.setup_root.remote())
assert isinstance(rank, int)
assert isinstance(addr, bytes)
assert len(addr) > 0
ray.kill(actor)
def test_insert_batch_returns_row_count(self, ray_with_gpu):
"""insert_batch() returns the number of rows in the Arrow batch."""
actor = self._make_setup_actor()
table = pa.table({"id": [1, 2, 3], "val": [0.1, 0.2, 0.3]})
count = ray.get(actor.insert_batch.remote(table))
assert count == 3
ray.kill(actor)
def test_insert_batch_large_table(self, ray_with_gpu):
"""insert_batch handles a larger Arrow Table without error."""
n = 5_000
actor = self._make_setup_actor(total_nparts=4)
table = pa.table(
{
"id": pa.array(np.arange(n, dtype=np.int64)),
"val": pa.array(np.random.rand(n)),
}
)
count = ray.get(actor.insert_batch.remote(table))
assert count == n
ray.kill(actor)
def test_insert_batch_multiple_batches(self, ray_with_gpu):
"""Multiple insert_batch calls each return the correct row count."""
actor = self._make_setup_actor(total_nparts=2)
sizes = [3, 7, 5]
offset = 0
for size in sizes:
table = pa.table(
{
"id": list(range(offset, offset + size)),
"label": ["x"] * size,
}
)
count = ray.get(actor.insert_batch.remote(table))
assert count == size
offset += size
ray.kill(actor)
def test_finish_and_extract_succeeds_after_inserts(self, ray_with_gpu):
"""finish_and_extract() completes without error after a batch insert."""
actor = self._make_setup_actor()
table = pa.table({"id": [0, 1, 2], "v": [10, 20, 30]})
ray.get(actor.insert_batch.remote(table))
gen = actor.finish_and_extract.options(num_returns="streaming").remote()
# Drain the generator to ensure it completes.
for ref in gen:
ray.get(ref)
ray.kill(actor)
# ---------------------------------------------------------------------------
# Single-rank end-to-end roundtrip (conditional)
# ---------------------------------------------------------------------------
@pytest.mark.gpu
class TestGPUSingleRankRoundtrip:
"""Full insert → finish_and_extract roundtrip (1 GPU)."""
@staticmethod
def _collect_partitions(actor) -> List[pa.Table]:
"""Drain a streaming finish_and_extract generator.
finish_and_extract follows the Ray Data streaming protocol: each
partition yields a block (pa.Table) followed by a BlockMetadataWithSchema.
Collect only the blocks.
"""
gen = actor.finish_and_extract.options(num_returns="streaming").remote()
return [
item for ref in gen for item in [ray.get(ref)] if isinstance(item, pa.Table)
]
def _actor_with_data(
self,
table: pa.Table,
key_columns: List[str],
total_nparts: int = 2,
):
"""Create a single-rank actor, feed *table* into it, return ready actor."""
actor = GPUShuffleActor.options(num_gpus=1).remote(
nranks=1,
total_nparts=total_nparts,
key_columns=key_columns,
)
_, root_address = ray.get(actor.setup_root.remote())
ray.get(actor.setup_worker.remote(root_address))
ray.get(actor.insert_batch.remote(table))
return actor
def test_roundtrip_preserves_row_count(self, ray_with_gpu):
"""All inserted rows appear in the extracted partitions."""
n_rows = 30
table = pa.table(
{
"key": list(range(n_rows)),
"data": [float(i) for i in range(n_rows)],
}
)
actor = self._actor_with_data(table, ["key"], total_nparts=3)
partitions = self._collect_partitions(actor)
assert sum(t.num_rows for t in partitions) == n_rows
ray.kill(actor)
def test_roundtrip_output_is_arrow_tables(self, ray_with_gpu):
"""finish_and_extract yields pyarrow.Table objects."""
table = pa.table({"id": [1, 2, 3, 4], "name": ["a", "b", "c", "d"]})
actor = self._actor_with_data(table, ["id"], total_nparts=2)
partitions = self._collect_partitions(actor)
for part in partitions:
assert isinstance(part, pa.Table)
ray.kill(actor)
def test_roundtrip_multiple_batches_no_rows_lost(self, ray_with_gpu):
"""Rows from multiple insert_batch calls are all recovered."""
actor = GPUShuffleActor.options(num_gpus=1).remote(
nranks=1, total_nparts=2, key_columns=["k"]
)
_, root_address = ray.get(actor.setup_root.remote())
ray.get(actor.setup_worker.remote(root_address))
batch_sizes = [4, 6, 10]
offset = 0
for size in batch_sizes:
table = pa.table({"k": list(range(offset, offset + size)), "v": [0] * size})
ray.get(actor.insert_batch.remote(table))
offset += size
partitions = self._collect_partitions(actor)
assert sum(t.num_rows for t in partitions) == sum(batch_sizes)
ray.kill(actor)
def test_roundtrip_column_names_preserved(self, ray_with_gpu):
"""Column names in extracted partitions match the inserted schema."""
col_names = ["alpha", "beta", "gamma"]
table = pa.table({"alpha": [1, 2], "beta": [3.0, 4.0], "gamma": ["x", "y"]})
actor = self._actor_with_data(table, ["alpha"], total_nparts=1)
partitions = self._collect_partitions(actor)
for part in partitions:
if part.num_rows > 0:
for name in col_names:
assert name in part.schema.names
ray.kill(actor)
def test_roundtrip_key_column_hash_partitions_consistently(self, ray_with_gpu):
"""Each key value is always routed to exactly one partition."""
# Hash partitioning guarantees that all rows sharing a key land in the
# same partition, but makes no promise that *distinct* keys go to
# *distinct* partitions (collisions are possible, especially with few
# partitions). Test the actual guarantee: no key is split across
# multiple partitions.
n_rows, n_keys = 100, 10
table = pa.table(
{
"group": [i % n_keys for i in range(n_rows)],
"val": list(range(n_rows)),
}
)
actor = self._actor_with_data(table, ["group"], total_nparts=2)
all_partitions = self._collect_partitions(actor)
ray.kill(actor)
# For each key, collect the set of partition indices it appears in.
key_to_part_indices: dict = {}
for idx, part in enumerate(all_partitions):
for key in part.column("group").unique().to_pylist():
key_to_part_indices.setdefault(key, set()).add(idx)
for key, part_indices in key_to_part_indices.items():
assert (
len(part_indices) == 1
), f"Key {key!r} was split across partitions {part_indices}"
# ---------------------------------------------------------------------------
# GPURankPool — real GPU lifecycle (conditional)
# ---------------------------------------------------------------------------
@pytest.mark.gpu
class TestGPURankPoolReal:
"""Tests that exercise GPURankPool with actual GPU actors."""
def _make_pool(self, nranks: int = 1, total_nparts: int = 2) -> GPURankPool:
return GPURankPool(
nranks=nranks,
total_nparts=total_nparts,
setup_timeout_s=60.0,
actor_cls_factory=lambda: hash_shuffle.GPUShuffleActor,
actor_kwargs={
"key_columns": ["id"],
"columns": None,
"rmm_pool_size": "auto",
"spill_memory_limit": "auto",
},
log_label="GPUShufflePool",
)
def test_pool_start_creates_actors(self, ray_with_gpu):
"""GPURankPool.start() creates the expected number of actors."""
pool = self._make_pool(nranks=1)
pool.start()
assert len(pool.actors) == 1
pool.shutdown(force=True)
def test_pool_shutdown_clears_actors(self, ray_with_gpu):
"""GPURankPool.shutdown(force=True) kills actors and empties the list."""
pool = self._make_pool(nranks=1)
pool.start()
pool.shutdown(force=True)
assert pool.actors == []
def test_pool_actors_respond_after_start(self, ray_with_gpu):
"""Actors returned by the pool respond to remote calls after start()."""
pool = self._make_pool(nranks=1, total_nparts=1)
pool.start()
actor = pool.actors[0]
# Actor is fully set up by pool.start(); insert_batch should work immediately
table = pa.table({"id": [1], "v": [2]})
ray.get(actor.insert_batch.remote(table))
pool.shutdown(force=True)
# ---------------------------------------------------------------------------
# GPU Hash Shuffle - end to end
# ---------------------------------------------------------------------------
@pytest.mark.gpu
class TestGPUHashShuffle:
def test_hash_shuffle(self, ray_with_gpu):
"""Test that hash shuffle works end to end."""
# ray.init(num_gpus=1)
num_gpus = ray_with_gpu
ray.data.context.DataContext.get_current().shuffle_strategy = (
ShuffleStrategy.GPU_SHUFFLE
)
num_rows = 10000
parallelism = 1000
num_blocks = int(parallelism / 10)
ds = ray.data.range(num_rows, parallelism=parallelism).materialize()
ds = ds.repartition(keys=["id"], num_blocks=num_blocks)
assert "GPUShuffle" in explain_plan(ds._logical_plan)
ds = ds.materialize()
assert ds.num_blocks() == max(num_blocks, num_gpus)
assert ds.count() == num_rows
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))