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,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__]))