chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,96 @@
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
from ray.data._internal.arrow_block import ArrowBlockAccessor
from ray.data._internal.block_batching.block_batching import batch_blocks
def block_generator(num_rows: int, num_blocks: int):
for i in range(num_blocks):
yield pa.table({"foo": list(range(i * num_rows, (i + 1) * num_rows))})
class TestBatchBlocks:
"""Tests for batch_blocks function."""
@pytest.mark.parametrize("batch_format", ["pandas", "numpy", "pyarrow"])
def test_basic(self, batch_format):
"""Test that batch_blocks yields all data in the requested format."""
blocks = block_generator(num_rows=3, num_blocks=2)
batches = list(batch_blocks(blocks, batch_format=batch_format))
assert len(batches) == 2
if batch_format == "pandas":
assert isinstance(batches[0], pd.DataFrame)
assert isinstance(batches[1], pd.DataFrame)
pd.testing.assert_frame_equal(
batches[0],
ArrowBlockAccessor(pa.table({"foo": [0, 1, 2]})).to_pandas(),
)
pd.testing.assert_frame_equal(
batches[1],
ArrowBlockAccessor(pa.table({"foo": [3, 4, 5]})).to_pandas(),
)
elif batch_format == "numpy":
assert isinstance(batches[0], dict)
assert isinstance(batches[1], dict)
np.testing.assert_array_equal(batches[0]["foo"], np.array([0, 1, 2]))
np.testing.assert_array_equal(batches[1]["foo"], np.array([3, 4, 5]))
elif batch_format == "pyarrow":
assert batches == [
pa.table({"foo": [0, 1, 2]}),
pa.table({"foo": [3, 4, 5]}),
]
else:
pytest.fail(f"Unsupported batch format {batch_format}")
@pytest.mark.parametrize(
"batch_size,drop_last,expected_values",
[
# 6 rows, batch_size=2: yields 3 full batches
(2, False, [[0, 1], [2, 3], [4, 5]]),
# 6 rows, batch_size=4: yields 1 full + 1 partial batch
(4, False, [[0, 1, 2, 3], [4, 5]]),
# 6 rows, batch_size=4, drop_last: drops partial batch
(4, True, [[0, 1, 2, 3]]),
# 6 rows, batch_size=10, drop_last: no batches (all dropped)
(10, True, []),
],
)
def test_batch_size(self, batch_size, drop_last, expected_values):
"""Test batch_size and drop_last parameters."""
blocks = block_generator(num_rows=3, num_blocks=2)
batches = list(
batch_blocks(
blocks,
batch_size=batch_size,
drop_last=drop_last,
batch_format="numpy",
)
)
assert len(batches) == len(expected_values)
for batch, expected in zip(batches, expected_values):
np.testing.assert_array_equal(batch["foo"], np.array(expected))
def test_collate_fn(self):
"""Test that collate_fn transforms batches."""
def double_values(batch):
return {"foo": [x * 2 for x in batch["foo"].tolist()]}
blocks = block_generator(num_rows=3, num_blocks=2)
batches = list(batch_blocks(blocks, collate_fn=double_values))
assert len(batches) == 2
assert batches[0]["foo"] == [0, 2, 4]
assert batches[1]["foo"] == [6, 8, 10]
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,170 @@
"""Tests for ray.data._internal.block_batching.interfaces."""
import pytest
from ray.data._internal.block_batching.interfaces import (
Batch,
BatchMetadata,
BatchStageTimings,
BlockStageTimings,
)
from ray.data._internal.stats import IterationStage, TimeSpan
class TestAccumulateBlockTimings:
"""Tests for BatchStageTimings.accumulate_block_timings().
accumulate_block_timings appends each block's spans to the batch's lists
(no merging) so that overlap attribution can sum non-overlapping spans
without double-counting.
"""
def test_single_block(self):
"""Accumulating a single block appends its span."""
dst = BatchStageTimings()
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=1.0, end_s=2.0),
data_transfer=TimeSpan(start_s=2.0, end_s=3.0),
)
)
assert len(dst.production_wait) == 1
assert dst.production_wait[0].start_s == 1.0
assert dst.production_wait[0].end_s == 2.0
def test_multiple_blocks_kept_separate(self):
"""Multiple blocks produce a list of separate spans (no merge)."""
dst = BatchStageTimings()
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=1.0, end_s=2.0),
data_transfer=TimeSpan(start_s=2.0, end_s=3.0),
)
)
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=3.0, end_s=4.0),
data_transfer=TimeSpan(start_s=4.0, end_s=5.0),
)
)
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=5.0, end_s=6.0),
data_transfer=TimeSpan(start_s=6.0, end_s=7.0),
)
)
assert len(dst.production_wait) == 3
assert [s.start_s for s in dst.production_wait] == [1.0, 3.0, 5.0]
assert [s.end_s for s in dst.production_wait] == [2.0, 4.0, 6.0]
def test_overlapping_blocks_kept_separate(self):
"""Overlapping windows are NOT merged — kept as separate spans."""
dst = BatchStageTimings()
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=1.0, end_s=5.0),
data_transfer=TimeSpan(start_s=5.0, end_s=6.0),
)
)
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=3.0, end_s=7.0),
data_transfer=TimeSpan(start_s=7.0, end_s=8.0),
)
)
assert len(dst.production_wait) == 2
assert dst.production_wait[0].start_s == 1.0
assert dst.production_wait[1].end_s == 7.0
def test_into_empty_destination(self):
"""Accumulating into an empty BatchStageTimings appends the span."""
dst = BatchStageTimings()
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=10.0, end_s=20.0),
data_transfer=TimeSpan(start_s=20.0, end_s=30.0),
)
)
assert len(dst.production_wait) == 1
assert dst.production_wait[0].start_s == 10.0
def test_data_transfer_multiple_blocks(self):
"""data_transfer spans are kept separate across multiple blocks."""
dst = BatchStageTimings()
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=0.0, end_s=1.0),
data_transfer=TimeSpan(start_s=1.0, end_s=2.0),
)
)
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=2.0, end_s=3.0),
data_transfer=TimeSpan(start_s=3.0, end_s=4.0),
)
)
assert len(dst.data_transfer) == 2
assert [s.start_s for s in dst.data_transfer] == [1.0, 3.0]
def test_both_stages_independent(self):
"""production_wait and data_transfer lists are independent."""
dst = BatchStageTimings()
# Block 1: prod [1,2], xfer [2,3]
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=1.0, end_s=2.0),
data_transfer=TimeSpan(start_s=2.0, end_s=3.0),
)
)
# Block 2: prod [5,6], xfer [6,7]
dst.accumulate_block_timings(
BlockStageTimings(
production_wait=TimeSpan(start_s=5.0, end_s=6.0),
data_transfer=TimeSpan(start_s=6.0, end_s=7.0),
)
)
assert len(dst.production_wait) == 2
assert len(dst.data_transfer) == 2
assert [s.start_s for s in dst.production_wait] == [1.0, 5.0]
assert [s.start_s for s in dst.data_transfer] == [2.0, 6.0]
class TestStageTimingsFields:
"""Tests that BatchStageTimings fields are accessible via stages()."""
def test_batch_carries_timings_through_pipeline(self):
"""A Batch's metadata.stage_timings carries all stage windows."""
timings = BatchStageTimings()
timings.production_wait.append(TimeSpan(start_s=1.0, end_s=2.0))
timings.batching = TimeSpan(start_s=2.0, end_s=3.0)
timings.format = TimeSpan(start_s=3.0, end_s=4.0)
timings.collate = TimeSpan(start_s=4.0, end_s=5.0)
timings.finalize = TimeSpan(start_s=5.0, end_s=6.0)
batch = Batch(
BatchMetadata(batch_idx=0, num_rows=50, stage_timings=timings), None
)
# Verify all stages are accessible via stages() iterator
stage_dict = dict(batch.metadata.stage_timings.stages())
assert len(stage_dict) == 6
assert stage_dict[IterationStage.PRODUCTION_WAIT][0].start_s == 1.0
assert stage_dict[IterationStage.BATCHING][0].end_s == 3.0
assert stage_dict[IterationStage.FORMAT][0].start_s == 3.0
assert stage_dict[IterationStage.COLLATE][0].end_s == 5.0
assert stage_dict[IterationStage.FINALIZE][0].start_s == 5.0
assert batch.metadata.num_rows == 50
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,874 @@
import queue
import threading
import time
from typing import Iterator, List, Optional
from unittest.mock import patch
import pandas as pd
import pyarrow as pa
import pytest
import ray
from ray.data._internal.block_batching.interfaces import (
Batch,
BatchMetadata,
BatchStageTimings,
BlockPrefetcher,
)
from ray.data._internal.block_batching.iter_batches import (
BatchIterator,
prefetch_batches_locally,
restore_original_order,
)
from ray.data._internal.block_batching.util import (
WaitBlockPrefetcher,
)
from ray.data._internal.execution.interfaces.ref_bundle import BlockEntry, RefBundle
from ray.data._internal.stats import DatasetStats, TimeSpan
from ray.data.block import Block, BlockAccessor, BlockMetadata
from ray.types import ObjectRef
# Sleep duration injected into each scenario's bottleneck stage. Picked to be
# large enough to dominate scheduling/measurement noise but small enough to
# keep the test fast (5 batches × 0.3s ≈ 1.5s per scenario).
SLEEP_S = 0.3
def ref_bundle_generator(num_rows: int, num_blocks: int) -> Iterator[RefBundle]:
for i in range(num_blocks):
block = pa.table({"foo": [i] * num_rows})
metadata = BlockMetadata(
num_rows=num_rows,
size_bytes=0,
input_files=[],
exec_stats=None,
)
schema = block.schema
yield RefBundle(
blocks=(BlockEntry(ray.put(block), metadata),),
owns_blocks=True,
schema=schema,
)
@pytest.mark.parametrize("num_batches_to_prefetch", [1, 2])
@pytest.mark.parametrize("batch_size", [None, 1, 4])
def test_prefetch_batches_locally(
ray_start_regular_shared, num_batches_to_prefetch, batch_size
):
class DummyPrefetcher(BlockPrefetcher):
def __init__(self):
self.windows = []
def prefetch_blocks(self, block_refs: List[ObjectRef[Block]]):
if batch_size is None:
assert len(block_refs) == num_batches_to_prefetch
else:
assert (
sum(len(ray.get(block_ref)) for block_ref in block_refs)
>= batch_size * num_batches_to_prefetch
)
self.windows.append(block_refs)
num_blocks = 10
num_rows = 2
prefetcher = DummyPrefetcher()
ref_bundles = list(ref_bundle_generator(num_blocks=num_blocks, num_rows=num_rows))
prefetch_block_iter = prefetch_batches_locally(
iter(ref_bundles),
prefetcher=prefetcher,
num_batches_to_prefetch=num_batches_to_prefetch,
batch_size=batch_size,
)
block_count = 0
prefetched_blocks = []
previous_num_windows = 1
for block in prefetch_block_iter:
prefetched_blocks.append(block)
block_count += 1
remaining_rows = (num_blocks - block_count) * num_rows
if batch_size is None and block_count < num_blocks - num_batches_to_prefetch:
# Test that we are actually prefetching in advance if this is not the last
# block.
assert len(prefetcher.windows) == previous_num_windows + 1
previous_num_windows = len(prefetcher.windows)
elif (
batch_size is not None
and remaining_rows > batch_size * num_batches_to_prefetch
):
# Test that we are actually prefetching in advance if this is not the last
# batch.
assert len(prefetcher.windows) == previous_num_windows + 1
previous_num_windows = len(prefetcher.windows)
# Test that original blocks are unchanged.
expected_blocks = []
for ref_bundle in ref_bundles:
expected_blocks.extend(ref_bundle.block_refs)
assert prefetched_blocks == expected_blocks
def test_restore_from_original_order():
base_iterator = [
Batch(BatchMetadata(batch_idx=1), None),
Batch(BatchMetadata(batch_idx=0), None),
Batch(BatchMetadata(batch_idx=3), None),
Batch(BatchMetadata(batch_idx=2), None),
]
ordered = list(restore_original_order(iter(base_iterator)))
idx = [batch.metadata.batch_idx for batch in ordered]
assert idx == [0, 1, 2, 3]
def test_attribute_blocked_time_overlap_attribution():
stats = DatasetStats(metadata={}, parent=None)
batch_iterator = BatchIterator(iter([]), stats=stats)
timings = BatchStageTimings()
timings.production_wait.append(TimeSpan(start_s=10.0, end_s=20.0))
timings.batching = TimeSpan(start_s=20.0, end_s=30.0)
timings.format = TimeSpan(start_s=30.0, end_s=40.0)
timings.finalize = TimeSpan(start_s=50.0, end_s=60.0)
batch = Batch(BatchMetadata(batch_idx=0, num_rows=8, stage_timings=timings), None)
batch_iterator._attribute_blocked_time(
batch, blocked_start_s=15.0, blocked_end_s=35.0
)
assert stats.iter_blocked_production_wait_s.get() == pytest.approx(5.0)
assert stats.iter_blocked_batching_s.get() == pytest.approx(10.0)
assert stats.iter_blocked_format_s.get() == pytest.approx(5.0)
assert stats.iter_blocked_collate_s.get() == 0
assert stats.iter_blocked_finalize_s.get() == 0
assert stats.iter_batches_total == 1
assert stats.iter_rows_total == 8
def _make_span(start: Optional[float], end: Optional[float]) -> Optional[TimeSpan]:
"""Create a TimeSpan, or None if the stage didn't run."""
if start is None or end is None:
return None
return TimeSpan(start_s=start, end_s=end)
def _make_batch_with_timings(
production_wait_start: Optional[float] = None,
production_wait_end: Optional[float] = None,
data_transfer_start: Optional[float] = None,
data_transfer_end: Optional[float] = None,
batching_start: Optional[float] = None,
batching_end: Optional[float] = None,
format_start: Optional[float] = None,
format_end: Optional[float] = None,
collate_start: Optional[float] = None,
collate_end: Optional[float] = None,
finalize_start: Optional[float] = None,
finalize_end: Optional[float] = None,
num_rows: int = 0,
):
"""Helper to construct a Batch with specific stage timing windows."""
timings = BatchStageTimings()
pw = _make_span(production_wait_start, production_wait_end)
if pw is not None:
timings.production_wait.append(pw)
dt = _make_span(data_transfer_start, data_transfer_end)
if dt is not None:
timings.data_transfer.append(dt)
timings.batching = _make_span(batching_start, batching_end)
timings.format = _make_span(format_start, format_end)
timings.collate = _make_span(collate_start, collate_end)
timings.finalize = _make_span(finalize_start, finalize_end)
return Batch(
BatchMetadata(batch_idx=0, num_rows=num_rows, stage_timings=timings), None
)
def _make_test_iterator(stats):
"""Create a BatchIterator wired to the given stats without a real pipeline."""
it = BatchIterator.__new__(BatchIterator)
it._stats = stats
return it
class TestAttributeBlockedTimeEdgeCases:
"""Edge case tests for overlap-based blocked attribution."""
def test_zero_overlap_stage_finished_before_blocked(self):
"""Fetch [0, 1.5] finished before training blocked at t=2 → 0 attribution."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(
production_wait_start=0.0, production_wait_end=1.5
)
it._attribute_blocked_time(batch, blocked_start_s=2.0, blocked_end_s=3.0)
assert stats.iter_blocked_production_wait_s.get() == 0.0
def test_zero_overlap_blocked_before_stage(self):
"""Training blocked [0, 1], stage ran [2, 3] → 0 attribution."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(format_start=2.0, format_end=3.0)
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=1.0)
assert stats.iter_blocked_format_s.get() == 0.0
def test_partial_overlap(self):
"""Fetch [0, 2], blocked [1, 3] → overlap = min(2,3)-max(0,1) = 1.0."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(
production_wait_start=0.0, production_wait_end=2.0
)
it._attribute_blocked_time(batch, blocked_start_s=1.0, blocked_end_s=3.0)
assert stats.iter_blocked_production_wait_s.get() == pytest.approx(1.0)
def test_full_overlap_stage_inside_blocked(self):
"""Stage [1, 2] entirely inside blocked [0, 3] → full 1.0 credit."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(batching_start=1.0, batching_end=2.0)
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=3.0)
assert stats.iter_blocked_batching_s.get() == pytest.approx(1.0)
def test_no_collate_fn_zero_attribution(self):
"""collate stage has start_s=0 → skipped, 0 attribution."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(format_start=1.0, format_end=2.0)
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=3.0)
assert stats.iter_blocked_format_s.get() == pytest.approx(1.0)
assert stats.iter_blocked_collate_s.get() == 0.0
def test_no_finalize_fn_zero_attribution(self):
"""finalize stage has start_s=0 → skipped, 0 attribution."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(collate_start=1.0, collate_end=2.0)
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=3.0)
assert stats.iter_blocked_collate_s.get() == pytest.approx(1.0)
assert stats.iter_blocked_finalize_s.get() == 0.0
def test_prefetch_hides_fetch_from_training(self):
"""Effective prefetch: fetch done before training blocks → 0 fetch attribution."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(
production_wait_start=0.0,
production_wait_end=1.5,
collate_start=2.3,
collate_end=2.6,
)
# Training only starts blocking at t=2 (prefetch worked)
it._attribute_blocked_time(batch, blocked_start_s=2.0, blocked_end_s=2.6)
assert stats.iter_blocked_production_wait_s.get() == 0.0
assert stats.iter_blocked_collate_s.get() == pytest.approx(0.3)
def test_accumulation_across_batches(self):
"""Two batches each contribute to fetch — values accumulate."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
# Batch 1: fetch [0,1], blocked [0,2] → overlap 1.0
b1 = _make_batch_with_timings(
production_wait_start=0.0, production_wait_end=1.0, num_rows=10
)
it._attribute_blocked_time(b1, blocked_start_s=0.0, blocked_end_s=2.0)
# Batch 2: fetch [5,6], blocked [5,7] → overlap 1.0
b2 = _make_batch_with_timings(
production_wait_start=5.0, production_wait_end=6.0, num_rows=20
)
it._attribute_blocked_time(b2, blocked_start_s=5.0, blocked_end_s=7.0)
assert stats.iter_blocked_production_wait_s.get() == pytest.approx(2.0)
assert stats.iter_batches_total == 2
assert stats.iter_rows_total == 30
def test_overlap_invariant_sum_leq_total(self):
"""sum(iter_blocked_*) <= iter_total_blocked_s holds for non-overlapping stages."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
stats.iter_total_blocked_s.add(5.0)
batch = _make_batch_with_timings(
production_wait_start=0.0,
production_wait_end=1.0,
batching_start=1.0,
batching_end=2.0,
format_start=2.0,
format_end=3.0,
num_rows=5,
)
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=5.0)
total = stats.iter_total_blocked_s.get()
sum_stages = (
stats.iter_blocked_production_wait_s.get()
+ stats.iter_blocked_batching_s.get()
+ stats.iter_blocked_format_s.get()
+ stats.iter_blocked_collate_s.get()
+ stats.iter_blocked_finalize_s.get()
)
assert sum_stages <= total + 1e-9
def test_blocked_inside_stage(self):
"""Stage [0, 10] fully contains blocked [3, 5] → overlap = 2.0."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(
production_wait_start=0.0, production_wait_end=10.0
)
it._attribute_blocked_time(batch, blocked_start_s=3.0, blocked_end_s=5.0)
assert stats.iter_blocked_production_wait_s.get() == pytest.approx(2.0)
def test_all_stages_simultaneous_overlap(self):
"""Multiple stages overlap with blocked window simultaneously."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
batch = _make_batch_with_timings(
production_wait_start=0.0,
production_wait_end=1.0,
batching_start=1.0,
batching_end=2.0,
format_start=2.0,
format_end=3.0,
collate_start=3.0,
collate_end=4.0,
finalize_start=4.0,
finalize_end=5.0,
num_rows=100,
)
# Blocked window covers all stages
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=5.0)
assert stats.iter_blocked_production_wait_s.get() == pytest.approx(1.0)
assert stats.iter_blocked_batching_s.get() == pytest.approx(1.0)
assert stats.iter_blocked_format_s.get() == pytest.approx(1.0)
assert stats.iter_blocked_collate_s.get() == pytest.approx(1.0)
assert stats.iter_blocked_finalize_s.get() == pytest.approx(1.0)
assert stats.iter_batches_total == 1
assert stats.iter_rows_total == 100
def test_overlapping_spans_not_double_counted(self):
"""Two overlapping production_wait spans: union, not sum."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
# Block 1: prod [0, 100], Block 2: prod [50, 150] — overlap [50, 100]
# Blocked [0, 200] covers both
batch = _make_batch_with_timings(
production_wait_start=0.0,
production_wait_end=100.0,
num_rows=10,
)
# Add a second production_wait span (multi-block batch)
batch.metadata.stage_timings.production_wait.append(
TimeSpan(start_s=50.0, end_s=150.0)
)
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=200.0)
# Union of [0,100] and [50,150] = [0,150] = 150, NOT 100+100=200
assert stats.iter_blocked_production_wait_s.get() == pytest.approx(150.0)
def test_attribute_blocked_time_all_stages_full_overlap():
"""All stages with realistic timing, full overlap with blocked window."""
stats = DatasetStats(metadata={}, parent=None)
it = _make_test_iterator(stats)
stats.iter_total_blocked_s.add(5.0)
batch = _make_batch_with_timings(
production_wait_start=0.0,
production_wait_end=0.5,
batching_start=0.5,
batching_end=1.0,
format_start=1.0,
format_end=2.0,
collate_start=2.0,
collate_end=2.5,
finalize_start=2.5,
finalize_end=3.0,
num_rows=256,
)
# Blocked window covers all stages
it._attribute_blocked_time(batch, blocked_start_s=0.0, blocked_end_s=5.0)
# Each stage gets its full duration
assert stats.iter_blocked_production_wait_s.get() == pytest.approx(0.5)
assert stats.iter_blocked_batching_s.get() == pytest.approx(0.5)
assert stats.iter_blocked_format_s.get() == pytest.approx(1.0)
assert stats.iter_blocked_collate_s.get() == pytest.approx(0.5)
assert stats.iter_blocked_finalize_s.get() == pytest.approx(0.5)
assert stats.iter_batches_total == 1
assert stats.iter_rows_total == 256
# Invariant: sum = 3.0 <= total_blocked = 5.0
sum_stages = (
stats.iter_blocked_production_wait_s.get()
+ stats.iter_blocked_batching_s.get()
+ stats.iter_blocked_format_s.get()
+ stats.iter_blocked_collate_s.get()
+ stats.iter_blocked_finalize_s.get()
)
assert sum_stages == pytest.approx(3.0)
assert sum_stages <= stats.iter_total_blocked_s.get() + 1e-9
def test_finalize_fn_uses_single_thread(ray_start_regular_shared):
"""Tests that finalize_fn is not run with multiple threads."""
ref_bundles_iter = ref_bundle_generator(num_blocks=20, num_rows=2)
q = queue.Queue()
semaphore = threading.Semaphore(value=1)
def finalize_enforce_single_thread(batch):
already_acquired = not semaphore.acquire(blocking=False)
if already_acquired:
e = AssertionError("finalize_fn is being run concurrently.")
q.put(e, block=True)
semaphore.release()
return batch
# Test that finalize_fn is called in a single thread,
# even if prefetch_batches is set.
output_batches = BatchIterator(
ref_bundles_iter,
collate_fn=lambda batch: batch,
finalize_fn=finalize_enforce_single_thread,
prefetch_batches=4,
)
# Force execution of the iterator.
# This step should not raise an exception.
list(output_batches)
try:
e = q.get(block=False, timeout=0.1)
raise e
except queue.Empty:
pass
# Test for 3 cases
# 1. Batch size is less than block size
# 2. Batch size is more than block size
# 3. Block size is not divisble by batch size
@pytest.mark.parametrize("batch_size", [1, 4, 3])
@pytest.mark.parametrize("drop_last", [True, False])
@pytest.mark.parametrize("prefetch_batches", [0, 1])
def test_iter_batches_e2e(
ray_start_regular_shared, batch_size, drop_last, prefetch_batches
):
def collate_fn(batch: pd.DataFrame):
return batch + 1
ref_bundles_iter = ref_bundle_generator(num_blocks=4, num_rows=2)
output_batches = BatchIterator(
ref_bundles_iter,
batch_size=batch_size,
prefetch_batches=prefetch_batches,
batch_format="pandas",
collate_fn=collate_fn,
drop_last=drop_last,
preserve_order=True,
)
output_batches = list(output_batches)
assert len(output_batches) > 0
for df in output_batches:
# Check batch formatting.
assert isinstance(df, pd.DataFrame)
# Check batch size.
if batch_size == 3 and not drop_last:
assert len(df) in {2, 3}
else:
assert len(df) == batch_size
concat_df = pd.concat(output_batches)
# Test that collate_fn is applied.
assert concat_df["foo"].iloc[0] == 1
# Make sure order is preserved.
for i in range(len(concat_df) - 1):
assert concat_df["foo"].iloc[i + 1] >= concat_df["foo"].iloc[i]
def test_iter_batches_counts_rows_at_pipeline_exit(ray_start_regular_shared):
stats = DatasetStats(metadata={}, parent=None)
ref_bundles_iter = ref_bundle_generator(num_blocks=4, num_rows=2)
output_batches = list(
BatchIterator(
ref_bundles_iter,
stats=stats,
batch_size=3,
prefetch_batches=0,
batch_format="pandas",
drop_last=True,
)
)
assert len(output_batches) == 2
assert [len(batch) for batch in output_batches] == [3, 3]
assert stats.iter_batches_total == 2
assert stats.iter_rows_total == 6
def test_iter_batches_e2e_async(ray_start_regular_shared):
"""We add time.sleep in 3 places:
1. In the base generator to simulate streaming executor blocking on next results.
2. In the collate_fn to simulate expensive slicing/formatting/collation
3. In the user thread to simulate training.
"""
def collate_fn(batch):
time.sleep(2)
return batch
ref_bundles = ref_bundle_generator(num_blocks=20, num_rows=2)
start_time = time.time()
output_batches = BatchIterator(
ref_bundles,
batch_size=None,
collate_fn=collate_fn,
prefetch_batches=4,
)
batches = []
for batch in output_batches:
time.sleep(1.5)
batches.append(batch)
end_time = time.time()
# 20 batches, 1.5 second sleep. Should be less than 45 seconds, even with some
# overhead.
# If there was no overlap, then we would expect this to take at least 20*2.5 = 50
assert end_time - start_time < 45, end_time - start_time
assert len(batches) == 20
assert all(len(batch) == 2 for batch in batches)
@pytest.mark.parametrize("preserve_order", [True, False])
def test_iter_batches_preserve_order_flag(
ray_start_regular_shared, preserve_order, restore_data_context
):
"""When `execution_options.preserve_order` is True, batches must come
out in input order even with a multi-worker format threadpool. When
False, ordering is not guaranteed (but the full set of batches must
still be produced)."""
# Variable per-batch collate cost makes worker-completion order
# arbitrary so the reorder path actually does work when enabled.
def collate_fn(batch):
idx = int(batch["foo"][0])
time.sleep(0.05 * (idx % 4))
return batch
num_blocks = 16
ref_bundles = ref_bundle_generator(num_blocks=num_blocks, num_rows=1)
output_batches = list(
BatchIterator(
ref_bundles,
batch_size=1,
collate_fn=collate_fn,
batch_format="pandas",
prefetch_batches=4,
preserve_order=preserve_order,
)
)
indices = [int(df["foo"].iloc[0]) for df in output_batches]
assert sorted(indices) == list(range(num_blocks))
if preserve_order:
assert indices == list(range(num_blocks)), indices
def test_finalize_fn_runs_after_restore_original_order(ray_start_regular_shared):
"""When preserve_order=True, finalize_fn must run after the reorder
buffer so that the buffer holds CPU batches rather than finalize_fn
outputs (e.g., GPU tensors). Asserts finalize_fn sees batches in
monotonically increasing order even when the format/collate threadpool
completes them out of order."""
def collate_fn(batch):
# Variable per-batch cost so worker-completion order is arbitrary.
idx = int(batch["foo"].iloc[0])
time.sleep(0.05 * (idx % 4))
return batch
seen_by_finalize = []
seen_lock = threading.Lock()
def finalize_fn(batch):
idx = int(batch["foo"].iloc[0])
with seen_lock:
seen_by_finalize.append(idx)
return batch
num_blocks = 16
ref_bundles = ref_bundle_generator(num_blocks=num_blocks, num_rows=1)
list(
BatchIterator(
ref_bundles,
batch_size=1,
collate_fn=collate_fn,
finalize_fn=finalize_fn,
batch_format="pandas",
prefetch_batches=4,
preserve_order=True,
)
)
assert seen_by_finalize == list(range(num_blocks)), seen_by_finalize
def _ref_bundles_with_size(
num_blocks: int, num_rows: int, size_bytes_per_block: int
) -> Iterator[RefBundle]:
"""Create ref bundles with explicit size_bytes for testing."""
for i in range(num_blocks):
block = pa.table({"foo": [i] * num_rows})
metadata = BlockMetadata(
num_rows=num_rows,
size_bytes=size_bytes_per_block,
input_files=[],
exec_stats=None,
)
schema = block.schema
yield RefBundle(
blocks=(BlockEntry(ray.put(block), metadata),),
owns_blocks=True,
schema=schema,
)
@pytest.mark.parametrize(
"num_batches_to_prefetch,expected_bytes_sequence",
[
# No prefetching: all 5 blocks report 0 prefetched bytes
(0, [0, 0, 0, 0, 0]),
# prefetch 2 blocks: with 5 blocks of 100 bytes each
# After yield block 0: window has 1,2 -> 200 (added block 2)
# After yield block 1: window has 2,3 -> 200 (added block 3)
# After yield block 2: window has 3,4 -> 200 (added block 4)
# After yield block 3: window has 4 -> 100 (no more to add)
# After yield block 4: window empty -> 0
(2, [200, 200, 200, 100, 0]),
],
)
def test_prefetch_bytes_tracking(
ray_start_regular_shared, num_batches_to_prefetch, expected_bytes_sequence
):
"""Test iter_prefetched_bytes is set correctly during prefetching.
Tests prefetch_batches_locally directly to verify exact values,
bypassing async BatchIterator which has non-deterministic timing.
"""
stats = DatasetStats(metadata={}, parent=None)
# Create 5 ref bundles, each with size_bytes=100
num_blocks = 5
ref_bundles = list(
_ref_bundles_with_size(num_blocks, num_rows=2, size_bytes_per_block=100)
)
prefetcher = WaitBlockPrefetcher()
block_iter = prefetch_batches_locally(
iter(ref_bundles),
prefetcher=prefetcher,
num_batches_to_prefetch=num_batches_to_prefetch,
batch_size=None,
stats=stats,
)
# Track iter_prefetched_bytes after each block is yielded
recorded_bytes = []
for _ in block_iter:
recorded_bytes.append(stats.iter_prefetched_bytes)
assert recorded_bytes == expected_bytes_sequence, f"Got {recorded_bytes}"
@pytest.mark.parametrize("prefetch_batches", [0, 2])
def test_prefetch_bytes_callback(ray_start_regular_shared, prefetch_batches):
"""Test prefetch_bytes_callback is invoked correctly by BatchIterator."""
reported_bytes = []
def prefetch_callback(num_bytes: int):
reported_bytes.append(num_bytes)
stats = DatasetStats(metadata={}, parent=None)
# Create 5 ref bundles
num_blocks = 5
ref_bundles = list(
_ref_bundles_with_size(num_blocks, num_rows=2, size_bytes_per_block=100)
)
output_batches = BatchIterator(
iter(ref_bundles),
stats=stats,
batch_size=None,
prefetch_batches=prefetch_batches,
prefetch_bytes_callback=prefetch_callback,
)
# Consume all batches
batches = list(output_batches)
assert len(batches) == 5
# Callback is called 5 times (per batch) + 1 time at epoch end
assert len(reported_bytes) == 6, f"Expected 6, got {len(reported_bytes)}"
# All values should be non-negative
assert all(b >= 0 for b in reported_bytes), f"Negative: {reported_bytes}"
# Last value should be 0 (after_epoch_end)
assert reported_bytes[-1] == 0, f"Last should be 0: {reported_bytes}"
@pytest.mark.parametrize(
"scenario,bound_stage",
[
("production", "iter_blocked_production_wait_s"),
("data_transfer", "iter_blocked_data_transfer_s"),
("batching", "iter_blocked_batching_s"),
("collate", "iter_blocked_collate_s"),
("format", "iter_blocked_format_s"),
("finalize", "iter_blocked_finalize_s"),
],
)
def test_e2e_blocked_attribution_by_scenario(
ray_start_regular_shared, scenario, bound_stage
):
"""E2e: when a specific stage is the bottleneck, its blocked metric
should be the largest among all stages, and at least SLEEP_S."""
from ray.data._internal.stats import _StatsManager
iter_kwargs = {"batch_size": 10, "prefetch_batches": 0}
patches = []
if scenario == "production":
# Slow upstream map → production_wait dominates.
def slow_map(batch):
time.sleep(SLEEP_S)
return batch
ds = ray.data.range(50, override_num_blocks=5).map(slow_map)
elif scenario == "data_transfer":
# Patch ray.get ONLY in util.resolve_block_refs (not globally) so the
# streaming executor's own ray.get calls aren't slowed (which would
# inflate production_wait). We replace util_mod.ray with a proxy that
# has a slow `get` but delegates everything else to the real ray.
from ray.data._internal.block_batching import util as util_mod
orig_get = ray.get
class _SlowGetRayProxy:
"""Proxy that sleeps on `.get` but delegates everything else."""
def __getattr__(self, name):
return getattr(ray, name)
@staticmethod
def get(ref):
time.sleep(SLEEP_S)
return orig_get(ref)
patches.append(patch.object(util_mod, "ray", _SlowGetRayProxy()))
ds = ray.data.range(50, override_num_blocks=5)
elif scenario == "batching":
# Patch Batcher.next_batch to inject slow batching.
from ray.data._internal.batcher import Batcher
orig_next_batch = Batcher.next_batch
def slow_next_batch(self):
time.sleep(SLEEP_S)
return orig_next_batch(self)
patches.append(patch.object(Batcher, "next_batch", slow_next_batch))
ds = ray.data.range(50, override_num_blocks=5)
elif scenario == "collate":
# Pass _collate_fn via _iter_batches (private signature accepts it;
# public iter_batches does not).
def slow_collate(batch):
time.sleep(SLEEP_S)
return batch
iter_kwargs["_collate_fn"] = slow_collate
ds = ray.data.range(50, override_num_blocks=5)
elif scenario == "format":
# Patch BlockAccessor.to_batch_format — it's called INSIDE
# _format_batch's _maybe_time context, so the sleep is captured by
# the format timing span.
orig_to_batch_format = BlockAccessor.to_batch_format
def slow_to_batch_format(self, batch_format):
time.sleep(SLEEP_S)
return orig_to_batch_format(self, batch_format)
patches.append(
patch.object(BlockAccessor, "to_batch_format", slow_to_batch_format)
)
ds = ray.data.range(50, override_num_blocks=5)
elif scenario == "finalize":
# Pass _finalize_fn via _iter_batches (private signature accepts it).
def slow_finalize(data):
time.sleep(SLEEP_S)
return data
iter_kwargs["_finalize_fn"] = slow_finalize
ds = ray.data.range(50, override_num_blocks=5)
it = ds.iterator()
captured = []
orig = _StatsManager.update_iteration_metrics
def spy(stats, dataset_tag):
captured.append(stats)
return orig(stats, dataset_tag)
patches.append(patch.object(_StatsManager, "update_iteration_metrics", spy))
import contextlib
with contextlib.ExitStack() as stack:
for p in patches:
stack.enter_context(p)
# Use _iter_batches (private) so we can pass _collate_fn / _finalize_fn
# which the public iter_batches signature does not expose.
for _ in it._iter_batches(**iter_kwargs):
pass
stats = captured[-1]
all_stages = [
stats.iter_blocked_production_wait_s.get(),
stats.iter_blocked_data_transfer_s.get(),
stats.iter_blocked_batching_s.get(),
stats.iter_blocked_format_s.get(),
stats.iter_blocked_collate_s.get(),
stats.iter_blocked_finalize_s.get(),
]
bound_value = getattr(stats, bound_stage).get()
# The bottleneck stage should be at least the sleep time we injected,
# proving the timing capture is actually recording the stall.
assert bound_value >= SLEEP_S, (
f"{scenario}-bound: {bound_stage}={bound_value} < SLEEP_S={SLEEP_S}; "
"timing capture missed the injected stall"
)
# The bottleneck stage should be strictly greater than all others.
for v in all_stages:
if v == bound_value:
continue
assert (
bound_value > v
), f"{scenario}-bound: {bound_stage}={bound_value} not > {v}"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,660 @@
import logging
import random
import sys
import threading
import time
from collections import Counter
from os import urandom
from typing import Callable, Iterator
import numpy as np
import pandas as pd
import pyarrow as pa
import pytest
import ray
from ray.data._internal.block_batching.interfaces import (
Batch,
BatchMetadata,
ResolvedBlock,
)
from ray.data._internal.block_batching.util import (
_calculate_ref_hits,
blocks_to_batches,
collate,
finalize_batches,
format_batches,
iter_threaded,
resolve_block_refs,
)
from ray.data._internal.stats import DatasetStats
from ray.data._internal.util import make_async_gen
logger = logging.getLogger(__file__)
def block_generator(num_rows: int, num_blocks: int):
for _ in range(num_blocks):
yield pa.table({"foo": [1] * num_rows})
def test_resolve_block_refs(ray_start_regular_shared):
block_refs = [ray.put(0), ray.put(1), ray.put(2)]
resolved_iter = resolve_block_refs(iter(block_refs))
resolved = list(resolved_iter)
assert all(isinstance(b, ResolvedBlock) for b in resolved)
assert [b.block for b in resolved] == [0, 1, 2]
def test_resolve_block_refs_accumulates_data_transfer_timer(
ray_start_regular_shared,
):
"""resolve_block_refs accumulates ray.get() time into iter_get_s and
captures a per-block data_transfer TimeSpan."""
block_refs = [ray.put(i) for i in range(3)]
stats = DatasetStats(metadata={}, parent=None)
resolved = list(resolve_block_refs(iter(block_refs), stats=stats))
assert len(resolved) == 3
# data_transfer TimeSpan captured per block.
for r in resolved:
assert r.stage_timings is not None
assert r.stage_timings.data_transfer is not None
assert r.stage_timings.data_transfer.duration >= 0.0
def test_resolve_block_refs_captures_production_wait_span(
ray_start_regular_shared,
):
"""resolve_block_refs captures a per-block production_wait TimeSpan
around ``next(block_ref_iter)`` (manual capture, no Timer accumulation)."""
block_refs = [ray.put(i) for i in range(3)]
stats = DatasetStats(metadata={}, parent=None)
resolved = list(resolve_block_refs(iter(block_refs), stats=stats))
assert len(resolved) == 3
for r in resolved:
assert r.stage_timings is not None
assert r.stage_timings.production_wait is not None
assert r.stage_timings.production_wait.duration >= 0.0
@pytest.mark.parametrize("block_size", [1, 10])
@pytest.mark.parametrize("drop_last", [True, False])
def test_blocks_to_batches(block_size, drop_last):
num_blocks = 5
block_iter = block_generator(num_rows=block_size, num_blocks=num_blocks)
# Wrap raw blocks in ResolvedBlock (stage_timings=None) as blocks_to_batches now expects
wrapped_blocks = (ResolvedBlock(block=b) for b in block_iter)
batch_size = 3
batch_iter = list(
blocks_to_batches(wrapped_blocks, batch_size=batch_size, drop_last=drop_last)
)
if drop_last:
for batch in batch_iter:
assert len(batch.data) == batch_size
else:
full_batches = 0
leftover_batches = 0
dataset_size = block_size * num_blocks
for batch in batch_iter:
if len(batch.data) == batch_size:
full_batches += 1
if len(batch.data) == (dataset_size % batch_size):
leftover_batches += 1
assert leftover_batches == 1
assert full_batches == (dataset_size // batch_size)
assert [batch.metadata.batch_idx for batch in batch_iter] == list(
range(len(batch_iter))
)
@pytest.mark.parametrize("batch_format", ["pandas", "numpy", "pyarrow"])
def test_format_batches(batch_format):
block_iter = block_generator(num_rows=2, num_blocks=2)
batch_iter = (
Batch(BatchMetadata(batch_idx=i), block) for i, block in enumerate(block_iter)
)
batch_iter = list(format_batches(batch_iter, batch_format=batch_format))
for batch in batch_iter:
if batch_format == "pandas":
assert isinstance(batch.data, pd.DataFrame)
elif batch_format == "arrow":
assert isinstance(batch.data, pa.Table)
elif batch_format == "numpy":
assert isinstance(batch.data, dict)
assert isinstance(batch.data["foo"], np.ndarray)
assert [batch.metadata.batch_idx for batch in batch_iter] == list(
range(len(batch_iter))
)
def test_collate():
def collate_fn(batch):
return pa.table({"bar": [1] * 2})
batches = [
Batch(BatchMetadata(batch_idx=i), data)
for i, data in enumerate(block_generator(num_rows=2, num_blocks=2))
]
batch_iter = collate(batches, collate_fn=collate_fn)
for i, batch in enumerate(batch_iter):
assert batch.metadata.batch_idx == i
assert batch.data == pa.table({"bar": [1] * 2})
def test_finalize():
def finalize_fn(batch):
return pa.table({"bar": [1] * 2})
batches = [
Batch(BatchMetadata(batch_idx=i), data)
for i, data in enumerate(block_generator(num_rows=2, num_blocks=2))
]
batch_iter = finalize_batches(batches, finalize_fn=finalize_fn)
for i, batch in enumerate(batch_iter):
assert batch.metadata.batch_idx == i
assert batch.data == pa.table({"bar": [1] * 2})
@pytest.mark.parametrize("preserve_ordering", [True, False])
@pytest.mark.parametrize("buffer_size", [0, 1, 2])
def test_make_async_gen_fail(buffer_size: int, preserve_ordering):
"""Tests that any errors raised in async threads are propagated to the main
thread."""
def gen(base_iterator):
raise ValueError("Fail")
iterator = make_async_gen(
base_iterator=iter([1]),
fn=gen,
preserve_ordering=preserve_ordering,
buffer_size=buffer_size,
)
with pytest.raises(ValueError) as e:
for _ in iterator:
pass
assert e.match("Fail")
@pytest.mark.parametrize("preserve_ordering", [True, False])
def test_make_async_gen_varying_seq_length_stress_test(preserve_ordering):
"""This test executes make_async_gen against a function generating variable
length sequences to stress test its concurrency control.
"""
num_workers = 4
c = 0
# Roll the dice 100 times
for i in range(100):
# Fetch 8b seed from urandom
seed = int.from_bytes(urandom(8), byteorder=sys.byteorder)
r = random.Random(seed)
print(f">>> Seed: {seed}")
# NOTE: Number of seqs >> number of workers
# to saturate the input queue
num_seqs = num_workers * 10
lens = list(range(num_seqs))
r.shuffle(lens)
source = [range(len_) for len_ in lens]
print("===" * 8)
print(source)
print("===" * 8)
def flatten(list_iter):
for l in list_iter:
print(f">>> Flattening: {l}")
yield from l
it = make_async_gen(
iter(source),
flatten,
preserve_ordering=preserve_ordering,
num_workers=4,
buffer_size=1,
)
total = 0
for i in it:
total += i
assert total == 9880
c += 1
assert c == 100
@pytest.mark.parametrize("preserve_ordering", [True, False])
def test_make_async_gen_non_reentrant(preserve_ordering):
"""This test is asserting that make_async_gen iterating over the
sequence as a whole and not re-entering provided transformation,
as this might have substantial performance impact in extreme case
of re-entering for every element of the sequence
"""
logs = []
finished = False
def _transform_inner(it):
nonlocal finished
assert not finished
logs.append(">>> Entering Inner")
for i in it:
logs.append(f">>> Inner: {i}")
yield i
logs.append(">>> Leaving Inner")
# Once this transform finishes
finished = True
def _transform_b(it):
logs.append(">>> Entering Outer")
for i in _transform_inner(it):
logs.append(f">>> Outer: {i}")
yield i
logs.append(">>> Leaving Outer")
for _ in make_async_gen(
iter(range(3)),
_transform_b,
preserve_ordering=preserve_ordering,
):
pass
assert [
">>> Entering Outer",
">>> Entering Inner",
">>> Inner: 0",
">>> Outer: 0",
">>> Inner: 1",
">>> Outer: 1",
">>> Inner: 2",
">>> Outer: 2",
">>> Leaving Inner",
">>> Leaving Outer",
] == logs
@pytest.mark.parametrize("preserve_ordering", [True, False])
@pytest.mark.parametrize(
"buffer_size, expected_gen_time",
[
(0, 5.5), # 5 x 1s + 0.5s buffer
(1, 7.5), # 3 x 1s + 2 x 2s (limited buffer delay) + 0.5s buffer
(2, 5.5), # 5 x 1s + 0.5s buffer
],
)
def test_make_async_gen_x(buffer_size: int, expected_gen_time, preserve_ordering):
"""Tests that make_async_gen overlaps compute."""
num_items = 5
def gen(base_iterator):
gen_start = time.perf_counter()
for i in base_iterator:
time.sleep(1)
yield i
print(f">>> ({time.time()}) Generating {i}")
gen_finish = time.perf_counter()
# 0.5s buffer
assert gen_finish - gen_start < expected_gen_time
def sleepy_udf(item):
time.sleep(2)
return item
iterator = make_async_gen(
base_iterator=iter(range(num_items)),
fn=gen,
preserve_ordering=preserve_ordering,
num_workers=1,
buffer_size=buffer_size,
)
outputs = []
iter_start = time.perf_counter()
for item in iterator:
print(f">>> ({time.time()}) Iterating over {item}")
print(item)
outputs.append(sleepy_udf(item))
iter_finish = time.perf_counter()
dur_s = iter_finish - iter_start
print(f">>> Took {dur_s}")
# 1s to yield first element
# 10s to iterate t/h all 5
# 0.5s extra buffer
assert dur_s < num_items * 2 + 1.5
# Assert ordering is preserved
assert outputs == list(range(num_items))
@pytest.mark.parametrize("preserve_ordering", [True, False])
@pytest.mark.parametrize("buffer_size", [0, 1, 2])
def test_make_async_gen_multiple_threads(buffer_size: int, preserve_ordering):
"""Tests that using multiple threads can overlap compute even more."""
num_items = 5
gen_sleep = 2
iter_sleep = 3
def gen(base_iterator):
for i in base_iterator:
time.sleep(gen_sleep)
yield i
def sleep_udf(item):
time.sleep(iter_sleep)
return item
# All 5 items should be fetched concurrently.
iterator = make_async_gen(
base_iterator=iter(range(num_items)),
fn=gen,
preserve_ordering=preserve_ordering,
num_workers=5,
buffer_size=buffer_size,
)
start_time = time.time()
# Only sleep for first item.
elements = [sleep_udf(next(iterator))] + list(iterator)
# All subsequent items should already be prefetched and should be ready.
end_time = time.time()
# Assert ordering is preserved
if preserve_ordering:
assert elements == list(range(num_items))
# - 2 second for every worker to handle their single element
# - 3 seconds for overlapping one
# - 0.5 seconds buffer
assert end_time - start_time < gen_sleep + iter_sleep + 0.5
@pytest.mark.parametrize("preserve_ordering", [True, False])
@pytest.mark.parametrize("buffer_size", [0, 1, 2])
def test_make_async_gen_multiple_threads_unfinished(
buffer_size: int, preserve_ordering
):
"""Tests that using multiple threads can overlap compute even more.
Do not finish iteration with break in the middle.
"""
num_items = 5
def gen(base_iterator):
for i in base_iterator:
time.sleep(4)
yield i
def sleep_udf(item):
time.sleep(5)
return item
# All 5 items should be fetched concurrently.
iterator = make_async_gen(
base_iterator=iter(range(num_items)),
fn=gen,
preserve_ordering=preserve_ordering,
num_workers=5,
buffer_size=buffer_size,
)
start_time = time.time()
# Only sleep for first item.
sleep_udf(next(iterator))
# All subsequent items should already be prefetched and should be ready.
for i, _ in enumerate(iterator):
if i > 2:
break
end_time = time.time()
# 4 second for first item, 5 seconds for udf, 0.5 seconds buffer
assert end_time - start_time < 9.5
def test_calculate_ref_hits(ray_start_regular_shared):
refs = [ray.put(0), ray.put(1)]
hits, misses, unknowns = _calculate_ref_hits(refs)
# With ctx.enable_get_object_locations_for_metrics set to False
# by default, `_calculate_ref_hits` returns -1 for all, since
# getting object locations is disabled.
assert hits == 0
assert misses == 0
assert unknowns == 0
ctx = ray.data.DataContext.get_current()
prev_enable_get_object_locations_for_metrics = (
ctx.enable_get_object_locations_for_metrics
)
try:
ctx.enable_get_object_locations_for_metrics = True
hits, misses, unknowns = _calculate_ref_hits(refs)
assert hits == 2
assert misses == 0
assert unknowns == 0
finally:
ctx.enable_get_object_locations_for_metrics = (
prev_enable_get_object_locations_for_metrics
)
def _identity(it: Iterator[int]) -> Iterator[int]:
return it
def _duplicate_each(it: Iterator[int]) -> Iterator[int]:
for item in it:
yield item
yield item
class TestIterThreaded:
"""Unit tests for ``iter_threaded``."""
@pytest.mark.parametrize("num_workers", [1, 2, 4])
@pytest.mark.parametrize("output_buffer_size", [1, 2, 4])
@pytest.mark.parametrize(
"fn,multiplier",
[(_identity, 1), (_duplicate_each, 2)],
ids=["identity", "duplicate"],
)
def test_processes_all_exactly_once(
self,
num_workers: int,
output_buffer_size: int,
fn: Callable[[Iterator[int]], Iterator[int]],
multiplier: int,
):
"""Every input item is consumed and produced exactly the expected
number of times across the worker pool (no losses, no duplicates).
Output ordering is not required."""
items = list(range(50))
output = list(
iter_threaded(
iter(items),
fn,
num_workers=num_workers,
output_buffer_size=output_buffer_size,
)
)
assert len(output) == len(items) * multiplier
assert Counter(output) == Counter(items * multiplier)
def test_stateful_base_iterator_thread_safe(self):
"""Python generators are not thread-safe; concurrent ``next()``
calls raise ``ValueError: generator already executing`` without
a lock. This test passes only if ``iter_threaded`` serializes
the underlying ``next()`` properly."""
def stateful_gen():
for i in range(200):
# Encourage interleaving across workers.
time.sleep(0.001)
yield i
output = list(iter_threaded(stateful_gen(), _identity, num_workers=4))
assert sorted(output) == list(range(200))
@pytest.mark.parametrize("num_workers", [1, 4])
def test_fn_exception_propagates(self, num_workers: int):
"""An exception raised inside ``fn`` is surfaced to the consumer
rather than silently swallowed or hanging the iterator."""
def fn(it: Iterator[int]) -> Iterator[int]:
for i, item in enumerate(it):
if i >= 3:
raise ValueError("boom")
yield item
it = iter_threaded(iter(range(100)), fn, num_workers=num_workers)
with pytest.raises(ValueError, match="boom"):
list(it)
@pytest.mark.parametrize("num_workers", [1, 4])
def test_non_generator_fn_construction_raises(self, num_workers: int):
"""When ``fn`` is a non-generator function that raises during
construction (e.g., setup code before returning the iterator), the
exception must surface to the consumer rather than hang. Regression
for the case where ``fn(_locked_iter())`` was called outside the
worker's try/finally."""
def fn(it: Iterator[int]) -> Iterator[int]:
# Body runs eagerly at call time (not a generator function).
raise ValueError("boom in fn construction")
it = iter_threaded(iter(range(100)), fn, num_workers=num_workers)
with pytest.raises(ValueError, match="boom in fn construction"):
list(it)
@pytest.mark.parametrize("num_workers", [1, 4])
def test_consumer_break_stops_workers(self, num_workers: int):
"""When the consumer breaks early and the iterator is no longer
referenced, CPython GCs the generator immediately, which runs the
``finally: stopped.set()`` cleanup path. Worker threads should
terminate within the ``_put`` poll interval (~100ms) rather than
leak."""
def slow_fn(it: Iterator[int]) -> Iterator[int]:
for item in it:
time.sleep(0.05)
yield item
# Inline so `break` drops the last reference → GC → finally.
for i, _ in enumerate(
iter_threaded(iter(range(10_000)), slow_fn, num_workers=num_workers)
):
if i >= 5:
break
# Workers poll `stopped` every 100ms inside `_put`; give a generous
# margin for CI under load.
deadline = time.time() + 5.0
while time.time() < deadline:
alive = [t for t in threading.enumerate() if t.name == "iter_threaded"]
if not alive:
break
time.sleep(0.05)
else:
pytest.fail(
f"iter_threaded workers did not exit within 5s: "
f"{[t.name for t in threading.enumerate() if t.name == 'iter_threaded']}"
)
def test_num_workers_validation(self):
with pytest.raises(ValueError, match="num_workers must be at least 1"):
list(iter_threaded(iter([1]), _identity, num_workers=0))
def test_output_buffer_size_validation(self):
with pytest.raises(ValueError, match="output_buffer_size must be at least 1"):
list(iter_threaded(iter([1]), _identity, output_buffer_size=0))
def test_empty_base_iterator(self):
output = list(iter_threaded(iter([]), _identity, num_workers=4))
assert output == []
@pytest.mark.parametrize("num_workers,output_buffer_size", [(1, 1), (2, 2), (4, 2)])
def test_in_flight_items_bounded_by_output_buffer_size(
self, num_workers: int, output_buffer_size: int
):
"""Without consumption, workers must not pull more than
``output_buffer_size`` items from the base iterator. Pulled-but-not-
consumed items are 'in flight', and the bound caps them."""
pulled = 0
pulled_lock = threading.Lock()
def counting_iter() -> Iterator[int]:
nonlocal pulled
for i in range(1_000_000):
with pulled_lock:
pulled += 1
yield i
it = iter_threaded(
counting_iter(),
_identity,
num_workers=num_workers,
output_buffer_size=output_buffer_size,
)
# Trigger the generator body (which starts the workers), then stop
# consuming. Workers fill in-flight up to the bound and then block
# on _acquire_slot.
next(it)
time.sleep(0.3)
with pulled_lock:
# Consumer took 1 → in-flight ≤ K. Plus the 1 already consumed.
assert (
pulled <= 1 + output_buffer_size
), f"Pulled {pulled}, expected <= {1 + output_buffer_size}"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", __file__]))