chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
import ray
|
||||
from ray.data._internal.execution.interfaces import ExecutionOptions
|
||||
from ray.data._internal.execution.operators.input_data_buffer import InputDataBuffer
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_map_operator import ( # noqa: E501
|
||||
ShuffleMapOp,
|
||||
make_partition_sentinel,
|
||||
)
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_reduce_operator import ( # noqa: E501
|
||||
ShuffleReduceOp,
|
||||
)
|
||||
from ray.data._internal.execution.operators.shuffle_operators.shuffle_tasks import (
|
||||
_encode_partition_ipc,
|
||||
_get_shard_batch,
|
||||
_ipc_write_options,
|
||||
)
|
||||
from ray.data._internal.execution.util import make_ref_bundles
|
||||
from ray.data.block import BlockMetadata
|
||||
from ray.data.context import DataContext, ShuffleStrategy
|
||||
from ray.data.tests.conftest import * # noqa: F401, F403
|
||||
from ray.data.tests.conftest import noop_counter
|
||||
from ray.data.tests.util import run_op_tasks_sync
|
||||
from ray.exceptions import GetTimeoutError
|
||||
from ray.tests.conftest import * # noqa: F401, F403
|
||||
|
||||
|
||||
def _keys_per_block(ds, columns):
|
||||
"""Return, for each output block, the set of distinct key tuples it holds.
|
||||
|
||||
Used to assert the hash-shuffle co-location guarantee: a key must appear in
|
||||
exactly one block.
|
||||
"""
|
||||
per_block = []
|
||||
for ref_bundle in ds.iter_internal_ref_bundles():
|
||||
for block_ref in ref_bundle.block_refs:
|
||||
block = ray.get(block_ref)
|
||||
cols = [block[c].to_pylist() for c in columns]
|
||||
per_block.append(set(zip(*cols)))
|
||||
return per_block
|
||||
|
||||
|
||||
def _assert_keys_colocated(per_block):
|
||||
"""Every key tuple appears in at most one block."""
|
||||
all_keys = [k for block in per_block for k in block]
|
||||
assert len(all_keys) == len(
|
||||
set(all_keys)
|
||||
), f"A key landed in more than one block: {per_block}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("num_partitions", [1, 4, 8])
|
||||
def test_repartition_keys_preserves_rows(
|
||||
ray_start_regular_shared_2_cpus,
|
||||
restore_data_context,
|
||||
disable_fallback_to_object_extension,
|
||||
num_partitions,
|
||||
):
|
||||
"""No rows are lost or duplicated; key totals are preserved."""
|
||||
ctx = DataContext.get_current()
|
||||
ctx.shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
|
||||
|
||||
ds = ray.data.range(1000, override_num_blocks=10)
|
||||
out = ds.repartition(num_partitions, keys=["id"])
|
||||
assert out.count() == 1000
|
||||
assert out.sum("id") == sum(range(1000))
|
||||
|
||||
|
||||
def test_repartition_block_number_matched(
|
||||
ray_start_regular_shared_2_cpus,
|
||||
restore_data_context,
|
||||
disable_fallback_to_object_extension,
|
||||
):
|
||||
"""All-non-empty partitions => exactly num_partitions output blocks."""
|
||||
ctx = DataContext.get_current()
|
||||
ctx.shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
|
||||
|
||||
# 1000 distinct keys over 8 buckets => all 8 partitions are non-empty.
|
||||
ds = ray.data.range(1000, override_num_blocks=20)
|
||||
out = ds.repartition(8, keys=["id"]).materialize()
|
||||
assert out.num_blocks() == 8
|
||||
|
||||
|
||||
def test_same_key_lands_in_same_block(
|
||||
ray_start_regular_shared_2_cpus,
|
||||
restore_data_context,
|
||||
disable_fallback_to_object_extension,
|
||||
):
|
||||
"""All rows sharing a key should end up in one block."""
|
||||
ctx = DataContext.get_current()
|
||||
ctx.shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
|
||||
|
||||
ds = ray.data.range(500, override_num_blocks=10).map(
|
||||
lambda row: {"k": row["id"] % 25, "v": row["id"]}
|
||||
)
|
||||
out = ds.repartition(5, keys=["k"])
|
||||
|
||||
_assert_keys_colocated(_keys_per_block(out, ["k"]))
|
||||
assert out.count() == 500
|
||||
|
||||
|
||||
def test_multi_column_keys(
|
||||
ray_start_regular_shared_2_cpus,
|
||||
restore_data_context,
|
||||
disable_fallback_to_object_extension,
|
||||
):
|
||||
"""Composite keys hash on all columns: every distinct (a, b) tuple lands in
|
||||
exactly one block."""
|
||||
ctx = DataContext.get_current()
|
||||
ctx.shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
|
||||
|
||||
ds = ray.data.range(500, override_num_blocks=10).map(
|
||||
lambda row: {"a": row["id"] % 5, "b": row["id"] % 7, "v": row["id"]}
|
||||
)
|
||||
out = ds.repartition(4, keys=["a", "b"])
|
||||
|
||||
_assert_keys_colocated(_keys_per_block(out, ["a", "b"]))
|
||||
assert out.count() == 500
|
||||
|
||||
|
||||
def test_more_partitions_than_keys_emits_empty_blocks(
|
||||
ray_start_regular_shared_2_cpus,
|
||||
restore_data_context,
|
||||
disable_fallback_to_object_extension,
|
||||
):
|
||||
"""Requesting more partitions than there are distinct keys emits the extra
|
||||
partitions as empty (0-row) blocks that still carry the dataset schema."""
|
||||
ctx = DataContext.get_current()
|
||||
ctx.shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
|
||||
|
||||
# 3 distinct keys into 50 partitions => at most 3 non-empty, >=47 empty.
|
||||
ds = ray.data.range(600, override_num_blocks=10).map(
|
||||
lambda row: {"k": row["id"] % 3, "v": row["id"]}
|
||||
)
|
||||
out = ds.repartition(50, keys=["k"]).materialize()
|
||||
|
||||
assert out.count() == 600
|
||||
assert out.num_blocks() == 50
|
||||
|
||||
rows_per_block = []
|
||||
schemas = []
|
||||
for ref_bundle in out.iter_internal_ref_bundles():
|
||||
for block_ref in ref_bundle.block_refs:
|
||||
block = ray.get(block_ref)
|
||||
rows_per_block.append(block.num_rows)
|
||||
schemas.append(block.schema)
|
||||
|
||||
assert rows_per_block.count(0) >= 47
|
||||
assert all(schema.equals(schemas[0]) for schema in schemas)
|
||||
|
||||
_assert_keys_colocated(_keys_per_block(out, ["k"]))
|
||||
|
||||
|
||||
def test_repartition_empty_dataset(
|
||||
ray_start_regular_shared_2_cpus,
|
||||
restore_data_context,
|
||||
disable_fallback_to_object_extension,
|
||||
):
|
||||
"""Empty dataset should still output N blocks"""
|
||||
ctx = DataContext.get_current()
|
||||
ctx.shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
|
||||
|
||||
ds = ray.data.range(100, override_num_blocks=4).filter(lambda row: False)
|
||||
out = ds.repartition(4, keys=["id"]).materialize()
|
||||
assert out.count() == 0
|
||||
assert out.num_blocks() == 4
|
||||
rows_per_block = [
|
||||
ray.get(block_ref).num_rows
|
||||
for ref_bundle in out.iter_internal_ref_bundles()
|
||||
for block_ref in ref_bundle.block_refs
|
||||
]
|
||||
assert rows_per_block == [0, 0, 0, 0]
|
||||
|
||||
|
||||
def test_repartition_with_sort_produces_sorted_partitions(
|
||||
ray_start_regular_shared_2_cpus,
|
||||
restore_data_context,
|
||||
disable_fallback_to_object_extension,
|
||||
):
|
||||
"""Check that rows are sorted in every partition."""
|
||||
ctx = DataContext.get_current()
|
||||
ctx.shuffle_strategy = ShuffleStrategy.HASH_SHUFFLE
|
||||
|
||||
ds = ray.data.range(200, override_num_blocks=4)
|
||||
out = ds.repartition(4, keys=["id"], sort=True)
|
||||
|
||||
for ref_bundle in out.iter_internal_ref_bundles():
|
||||
for block_ref in ref_bundle.block_refs:
|
||||
ids = ray.get(block_ref)["id"].to_pylist()
|
||||
assert ids == sorted(ids)
|
||||
|
||||
|
||||
def test_get_shard_batch_no_timeout(ray_start_regular_shared_2_cpus):
|
||||
"""timeout_s <= 0 fetches in a single blocking ray.get."""
|
||||
refs = [ray.put(i) for i in range(4)]
|
||||
out = _get_shard_batch(
|
||||
refs,
|
||||
partition_id=0,
|
||||
batch_index=0,
|
||||
num_batches=1,
|
||||
timeout_s=0,
|
||||
)
|
||||
assert out == [0, 1, 2, 3]
|
||||
|
||||
|
||||
def test_get_shard_batch_returns_ready_values(ray_start_regular_shared_2_cpus):
|
||||
"""A timeout that is never hit returns the values unchanged."""
|
||||
refs = [ray.put(i) for i in range(3)]
|
||||
out = _get_shard_batch(
|
||||
refs,
|
||||
partition_id=1,
|
||||
batch_index=0,
|
||||
num_batches=1,
|
||||
timeout_s=30.0,
|
||||
)
|
||||
assert out == [0, 1, 2]
|
||||
|
||||
|
||||
def test_get_shard_batch_warns_then_raises_on_stall(
|
||||
ray_start_regular_shared_2_cpus, propagate_logs, caplog
|
||||
):
|
||||
"""A stalled fetch warns partway through, then raises at the timeout."""
|
||||
|
||||
@ray.remote
|
||||
def _never_ready():
|
||||
import time
|
||||
|
||||
time.sleep(1000)
|
||||
|
||||
ref = _never_ready.remote()
|
||||
with caplog.at_level(
|
||||
"WARNING",
|
||||
logger="ray.data._internal.execution.operators.shuffle_operators.shuffle_tasks",
|
||||
):
|
||||
with pytest.raises(GetTimeoutError):
|
||||
_get_shard_batch(
|
||||
[ref],
|
||||
partition_id=7,
|
||||
batch_index=0,
|
||||
num_batches=1,
|
||||
timeout_s=0.3,
|
||||
)
|
||||
assert [r.levelname for r in caplog.records].count("WARNING") == 1
|
||||
assert [r.levelname for r in caplog.records].count("ERROR") == 1
|
||||
assert "partition 7" in caplog.records[0].message
|
||||
ray.cancel(ref, force=True)
|
||||
|
||||
|
||||
# --- Multi-input reduce -------------------------------------------------------
|
||||
# TODO: move these multi-input ShuffleReduceOp tests (and the _get_shard_batch
|
||||
# shuffle_tasks tests above) into a dedicated operator/task-level test file --
|
||||
# they aren't specific to hash-shuffle-v2.
|
||||
def _ipc_shard_bundle(partition_id, table):
|
||||
"""One partition's shard as a ShuffleMapOp emits it: an IPC-encoded buffer
|
||||
stamped with the partition id."""
|
||||
from ray.data._internal.execution.interfaces import BlockEntry, RefBundle
|
||||
|
||||
buf = _encode_partition_ipc(table, _ipc_write_options("none"))
|
||||
meta = BlockMetadata(
|
||||
num_rows=table.num_rows,
|
||||
size_bytes=table.nbytes,
|
||||
exec_stats=None,
|
||||
input_files=make_partition_sentinel(partition_id),
|
||||
)
|
||||
return RefBundle(
|
||||
(
|
||||
BlockEntry(
|
||||
ref=ray.put(buf), # pyrefly: ignore[bad-argument-type]
|
||||
metadata=meta,
|
||||
),
|
||||
),
|
||||
schema=table.schema,
|
||||
owns_blocks=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_multi_input_reduce_op(reduce_fn, num_inputs=2, num_partitions=2):
|
||||
ctx = DataContext.get_current()
|
||||
maps = [
|
||||
ShuffleMapOp(
|
||||
InputDataBuffer(ctx, make_ref_bundles([[0]])),
|
||||
ctx,
|
||||
num_partitions=num_partitions,
|
||||
partition_fn=lambda t: {},
|
||||
)
|
||||
for _ in range(num_inputs)
|
||||
]
|
||||
return ShuffleReduceOp(
|
||||
maps,
|
||||
ctx,
|
||||
num_partitions=num_partitions,
|
||||
reduce_fn=reduce_fn,
|
||||
disallow_block_splitting=True,
|
||||
)
|
||||
|
||||
|
||||
def _drain_reduce_op(op, feed):
|
||||
"""Run `op` over `feed` (bundle, input_index) pairs and return output tables."""
|
||||
op.start(ExecutionOptions(), noop_counter())
|
||||
for bundle, input_index in feed:
|
||||
op.add_input(bundle, input_index)
|
||||
op.all_inputs_done()
|
||||
run_op_tasks_sync(op)
|
||||
tables = []
|
||||
while op.has_next():
|
||||
for ref in op.get_next().block_refs:
|
||||
tables.append(ray.get(ref))
|
||||
return tables
|
||||
|
||||
|
||||
def _concat_inputs_reduce_fn():
|
||||
def _reduce(partition_id, tables_by_input):
|
||||
tables = [t for shards in tables_by_input for t in shards]
|
||||
if tables:
|
||||
yield pa.concat_tables(tables)
|
||||
|
||||
return _reduce
|
||||
|
||||
|
||||
def test_reduce_op_combines_all_inputs(ray_start_regular_shared_2_cpus):
|
||||
"""Both inputs' shards for a partition reach the reducer, in input order."""
|
||||
op = _make_multi_input_reduce_op(_concat_inputs_reduce_fn(), num_inputs=2)
|
||||
feed = [
|
||||
(_ipc_shard_bundle(0, pa.table({"src": ["L"], "v": [1]})), 0),
|
||||
(_ipc_shard_bundle(0, pa.table({"src": ["R"], "v": [2]})), 1),
|
||||
]
|
||||
out = pa.concat_tables(_drain_reduce_op(op, feed))
|
||||
assert sorted(out.column("src").to_pylist()) == ["L", "R"]
|
||||
assert sorted(out.column("v").to_pylist()) == [1, 2]
|
||||
|
||||
|
||||
def test_reduce_op_runs_when_an_input_is_missing(ray_start_regular_shared_2_cpus):
|
||||
"""A partition that never receives one input (a block-less side) is still
|
||||
reduced -- the reducer sees an empty shard list for the missing input rather
|
||||
than the op hanging on a never-paired partition."""
|
||||
op = _make_multi_input_reduce_op(_concat_inputs_reduce_fn(), num_inputs=2)
|
||||
# Only input 0 delivers partition 0; input 1 never does.
|
||||
feed = [(_ipc_shard_bundle(0, pa.table({"src": ["L"], "v": [1]})), 0)]
|
||||
out = pa.concat_tables(_drain_reduce_op(op, feed))
|
||||
assert out.column("src").to_pylist() == ["L"]
|
||||
assert op.has_completed()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(pytest.main(["-v", __file__]))
|
||||
Reference in New Issue
Block a user