Files
ray-project--ray/python/ray/data/tests/block_batching/test_iter_batches.py
T
2026-07-13 13:17:40 +08:00

875 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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__]))