146 lines
5.2 KiB
Python
146 lines
5.2 KiB
Python
import gc
|
|
import weakref
|
|
from collections import deque
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from ray.data._internal.execution.interfaces.task_context import TaskContext
|
|
from ray.data._internal.execution.operators.map_transformer import (
|
|
BatchMapTransformFn,
|
|
MapTransformer,
|
|
)
|
|
from ray.data._internal.output_buffer import OutputBlockSizeOption
|
|
from ray.data._internal.planner.plan_udf_map_op import (
|
|
_generate_transform_fn_for_map_batches,
|
|
)
|
|
from ray.data.block import BlockAccessor, DataBatch
|
|
|
|
|
|
def _create_chained_transformer(udf, n):
|
|
"""Create a MapTransformer with chained batch transforms that track intermediates."""
|
|
transform_fns = [
|
|
BatchMapTransformFn(
|
|
_generate_transform_fn_for_map_batches(udf),
|
|
batch_format="pandas",
|
|
batch_size=1,
|
|
output_block_size_option=OutputBlockSizeOption.of(target_max_block_size=1),
|
|
)
|
|
for _ in range(n)
|
|
]
|
|
return MapTransformer(transform_fns)
|
|
|
|
|
|
def test_chained_transforms_release_intermediates_between_batches():
|
|
"""Test that chained transforms release intermediate refs when moving to next batch.
|
|
|
|
This test uses `_generate_transform_fn_for_map_batches` to wrap UDFs,
|
|
which is the same code path used in production by `map_batches`.
|
|
"""
|
|
NUM_BATCHES = 1
|
|
NUM_CHAINED_TRANSFORMS = 5
|
|
|
|
input_intermediates: deque = deque()
|
|
|
|
def udf(batch: DataBatch) -> DataBatch:
|
|
# Append received batch into a list
|
|
#
|
|
# NOTE: Every of the chained UDFs will be appending into this list in
|
|
# order, meaning that in 1 iteration N refs will be added, where
|
|
# N is the number of chained UDFs
|
|
input_intermediates.append(weakref.ref(batch))
|
|
|
|
return pd.DataFrame({"id": batch["id"] * 2})
|
|
|
|
transformer = _create_chained_transformer(udf, NUM_CHAINED_TRANSFORMS)
|
|
ctx = TaskContext(task_idx=0, op_name="test")
|
|
|
|
# Use a generator instead of a list to avoid list_iterator holding references
|
|
def make_input_blocks():
|
|
for i in range(NUM_BATCHES):
|
|
yield pd.DataFrame({"id": [i + 1]})
|
|
|
|
result_iter = transformer.apply_transform(make_input_blocks(), ctx)
|
|
|
|
for i in range(NUM_BATCHES):
|
|
# Consume batch
|
|
result = next(result_iter)
|
|
assert result is not None
|
|
|
|
# apply_transform returns Arrow blocks, convert to pandas to test the correctness of the result
|
|
result_df = BlockAccessor.for_block(result).to_pandas()
|
|
expected_df = pd.DataFrame(
|
|
{"id": [(i + 1) * 2**NUM_CHAINED_TRANSFORMS]}
|
|
).astype(result_df.dtypes.to_dict())
|
|
pd.testing.assert_frame_equal(result_df, expected_df)
|
|
|
|
# Trigger GC
|
|
gc.collect()
|
|
|
|
# Extract current set of intermediate input refs
|
|
cur_intermediates = [
|
|
input_intermediates.popleft() for _ in range(NUM_CHAINED_TRANSFORMS)
|
|
]
|
|
assert len(input_intermediates) == 0
|
|
|
|
alive_after_first = sum(1 for ref in cur_intermediates if ref() is not None)
|
|
|
|
if alive_after_first > 0:
|
|
print(">>> Found captured intermediate references!")
|
|
|
|
_trace_back_refs(cur_intermediates, "After first batch")
|
|
|
|
pytest.fail(
|
|
f"Expected 0 intermediates alive after first batch, found {alive_after_first}"
|
|
)
|
|
|
|
|
|
def _trace_back_refs(intermediates: list, label: str = ""):
|
|
"""Debug utility to show which intermediates are alive and what holds them.
|
|
|
|
Args:
|
|
intermediates: List of weakrefs to track
|
|
label: Optional label for the debug output
|
|
"""
|
|
if label:
|
|
print(f"\n{label}:")
|
|
for i, ref in enumerate(intermediates):
|
|
obj = ref()
|
|
print(f" intermediate[{i}]: {'ALIVE' if obj is not None else 'dead'}")
|
|
if obj is not None:
|
|
referrers = gc.get_referrers(obj)
|
|
for r in referrers:
|
|
if isinstance(r, list):
|
|
print(f" -> list (len={len(r)}, id={id(r)})")
|
|
# Find what holds this list - 2 levels up
|
|
list_referrers = gc.get_referrers(r)
|
|
for lr in list_referrers:
|
|
if hasattr(lr, "gi_frame") and lr.gi_frame:
|
|
print(
|
|
f" held by generator: {lr.__name__} at "
|
|
f"{lr.gi_frame.f_code.co_filename.split('/')[-1]}:"
|
|
f"{lr.gi_frame.f_lineno}"
|
|
)
|
|
elif hasattr(lr, "__class__") and not isinstance(
|
|
lr, (dict, list, tuple)
|
|
):
|
|
print(f" held by {type(lr).__name__}")
|
|
elif isinstance(r, dict):
|
|
# Skip frame dicts
|
|
pass
|
|
elif hasattr(r, "gi_frame"):
|
|
frame = r.gi_frame
|
|
if frame:
|
|
print(
|
|
f" -> generator: {r.__name__} at "
|
|
f"{frame.f_code.co_filename.split('/')[-1]}:{frame.f_lineno}"
|
|
)
|
|
else:
|
|
print(f" -> {type(r).__name__}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|