376 lines
15 KiB
Python
376 lines
15 KiB
Python
import warnings
|
|
from typing import Optional
|
|
|
|
import numpy as np
|
|
|
|
from ray.data._internal.arrow_block import ArrowBlockAccessor
|
|
from ray.data._internal.arrow_ops import transform_pyarrow
|
|
from ray.data._internal.arrow_ops.transform_pyarrow import try_combine_chunked_columns
|
|
from ray.data._internal.delegating_block_builder import DelegatingBlockBuilder
|
|
from ray.data._internal.execution.util import memory_string
|
|
from ray.data._internal.util import get_total_obj_store_mem_on_node
|
|
from ray.data.block import Block, BlockAccessor
|
|
from ray.util import log_once
|
|
|
|
# Delay compaction until the shuffle buffer has reached this ratio over the min
|
|
# shuffle buffer size. Setting this to 1 minimizes memory usage, at the cost of
|
|
# frequent compactions. Setting this to higher values increases memory usage but
|
|
# reduces compaction frequency.
|
|
SHUFFLE_BUFFER_COMPACTION_RATIO = 1.5
|
|
|
|
# Ratio of remaining compacted rows to shuffle_buffer_min_size at which
|
|
# compaction (and re-shuffling of indices) is triggered. Experiments show 0.5
|
|
# is a good trade-off between throughput and randomness.
|
|
SHUFFLE_BUFFER_COMPACTION_THRESHOLD = 0.5
|
|
|
|
|
|
class BatcherInterface:
|
|
def add(self, block: Block):
|
|
"""Add a block to the block buffer.
|
|
|
|
Args:
|
|
block: Block to add to the block buffer.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def done_adding(self) -> bool:
|
|
"""Indicate to the batcher that no more blocks will be added to the buffer."""
|
|
raise NotImplementedError()
|
|
|
|
def has_batch(self) -> bool:
|
|
"""Whether this Batcher has any full batches."""
|
|
raise NotImplementedError()
|
|
|
|
def has_any(self) -> bool:
|
|
"""Whether this Batcher has any data."""
|
|
raise NotImplementedError()
|
|
|
|
def next_batch(self) -> Block:
|
|
"""Get the next batch from the block buffer.
|
|
|
|
Returns:
|
|
A batch represented as a Block.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
|
|
class Batcher(BatcherInterface):
|
|
"""Chunks blocks into batches."""
|
|
|
|
# Implementation Note: When there are multiple batches per block, this batcher will
|
|
# slice off and return each batch and add the remaining block back to the buffer
|
|
# instead of optimally slicing and returning all batches from the block at once.
|
|
# This will result in extra (and nested) block slicing. However, since slices are
|
|
# zero-copy views, we sacrifice what should be a small performance hit for better
|
|
# readability.
|
|
|
|
def __init__(self, batch_size: Optional[int], ensure_copy: bool = False):
|
|
"""
|
|
Construct a batcher that yields batches of batch_sizes rows.
|
|
|
|
Args:
|
|
batch_size: The size of batches to yield.
|
|
ensure_copy: Whether batches are always copied from the underlying base
|
|
blocks (not zero-copy views).
|
|
"""
|
|
self._batch_size = batch_size
|
|
self._buffer = []
|
|
self._buffer_size = 0
|
|
self._done_adding = False
|
|
self._ensure_copy = ensure_copy
|
|
|
|
def add(self, block: Block):
|
|
"""Add a block to the block buffer.
|
|
|
|
Note empty block is not added to buffer.
|
|
|
|
Args:
|
|
block: Block to add to the block buffer.
|
|
"""
|
|
if BlockAccessor.for_block(block).num_rows() > 0:
|
|
self._buffer.append(block)
|
|
self._buffer_size += BlockAccessor.for_block(block).num_rows()
|
|
|
|
def done_adding(self) -> bool:
|
|
"""Indicate to the batcher that no more blocks will be added to the batcher."""
|
|
self._done_adding = True
|
|
|
|
def has_batch(self) -> bool:
|
|
"""Whether this Batcher has any full batches."""
|
|
return self.has_any() and (
|
|
self._batch_size is None or self._buffer_size >= self._batch_size
|
|
)
|
|
|
|
def has_any(self) -> bool:
|
|
"""Whether this Batcher has any data."""
|
|
return self._buffer_size > 0
|
|
|
|
def next_batch(self) -> Block:
|
|
"""Get the next batch from the block buffer.
|
|
|
|
Returns:
|
|
A batch represented as a Block.
|
|
"""
|
|
assert self.has_batch() or (self._done_adding and self.has_any())
|
|
needs_copy = self._ensure_copy
|
|
# If no batch size, short-circuit.
|
|
if self._batch_size is None:
|
|
assert len(self._buffer) == 1
|
|
block = self._buffer[0]
|
|
if needs_copy:
|
|
# Copy block if needing to ensure fresh batch copy.
|
|
block = BlockAccessor.for_block(block)
|
|
block = block.slice(0, block.num_rows(), copy=True)
|
|
self._buffer = []
|
|
self._buffer_size = 0
|
|
return block
|
|
output = DelegatingBlockBuilder()
|
|
leftover = []
|
|
needed = self._batch_size
|
|
for block in self._buffer:
|
|
accessor = BlockAccessor.for_block(block)
|
|
if needed <= 0:
|
|
# We already have a full batch, so add this block to
|
|
# the leftovers.
|
|
leftover.append(block)
|
|
elif accessor.num_rows() <= needed:
|
|
output.add_block(accessor.to_block())
|
|
needed -= accessor.num_rows()
|
|
else:
|
|
# Try de-fragmenting table in case its columns
|
|
# have too many chunks (potentially hindering performance of
|
|
# subsequent slicing operation)
|
|
if isinstance(accessor, ArrowBlockAccessor):
|
|
accessor = BlockAccessor.for_block(
|
|
transform_pyarrow.try_combine_chunked_columns(
|
|
block, min_chunks_to_combine=1
|
|
)
|
|
)
|
|
|
|
# We only need part of the block to fill out a batch.
|
|
output.add_block(accessor.slice(0, needed, copy=False))
|
|
# Add the rest of the block to the leftovers.
|
|
leftover.append(accessor.slice(needed, accessor.num_rows(), copy=False))
|
|
needed = 0
|
|
|
|
# Move the leftovers into the block buffer so they're the first
|
|
# blocks consumed on the next batch extraction.
|
|
self._buffer = leftover
|
|
self._buffer_size -= self._batch_size
|
|
needs_copy = needs_copy and not output.will_build_yield_copy()
|
|
batch = output.build()
|
|
if needs_copy:
|
|
# Need to ensure that the batch is a fresh copy.
|
|
batch = BlockAccessor.for_block(batch)
|
|
batch = batch.slice(0, batch.num_rows(), copy=True)
|
|
return batch
|
|
|
|
|
|
class ShufflingBatcher(BatcherInterface):
|
|
"""Chunks blocks into shuffled batches, using a local in-memory shuffle buffer.
|
|
|
|
Uses an **incremental index** approach: on each compaction a permutation
|
|
array is generated over the buffer rows, and each ``next_batch()`` call
|
|
gathers a small slice of that permutation via ``take``.
|
|
|
|
Properties of this approach:
|
|
|
|
* **Memory-efficient** -- the data buffer is kept as-is; only a
|
|
lightweight int64 index array is allocated on top.
|
|
* **Smooth per-batch latency** -- each ``take`` operates on a small
|
|
slice of indices, so per-batch work is short and uniform, making it
|
|
easy to hide behind prefetch threads.
|
|
|
|
Example with ``batch_size=3`` and a 9-row buffer::
|
|
|
|
buffer: [A, B, C, D, E, F, G, H, I]
|
|
indices: [4, 7, 1, 0, 8, 3, 6, 2, 5] # random permutation
|
|
|
|
next_batch() -> take([4, 7, 1]) -> [E, H, B] # batch_head 0 -> 3
|
|
next_batch() -> take([0, 8, 3]) -> [A, I, D] # batch_head 3 -> 6
|
|
next_batch() -> take([6, 2, 5]) -> [G, C, F] # batch_head 6 -> 9
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
batch_size: Optional[int],
|
|
shuffle_buffer_min_size: int,
|
|
shuffle_seed: Optional[int] = None,
|
|
):
|
|
"""Constructs a random-shuffling block batcher.
|
|
|
|
Args:
|
|
batch_size: Record batch size.
|
|
shuffle_buffer_min_size: Minimum number of rows that must be in the local
|
|
in-memory shuffle buffer in order to yield a batch. When there are no
|
|
more rows to be added to the buffer, the number of rows in the buffer
|
|
*will* decrease below this value while yielding the remaining batches,
|
|
and the final batch may have less than ``batch_size`` rows. Increasing
|
|
this will improve the randomness of the shuffle but may increase the
|
|
latency to the first batch.
|
|
shuffle_seed: The seed to use for the local random shuffle.
|
|
"""
|
|
if batch_size is None:
|
|
raise ValueError("Must specify a batch_size if using a local shuffle.")
|
|
self._batch_size = batch_size
|
|
self._rng = np.random.default_rng(shuffle_seed)
|
|
if shuffle_buffer_min_size < batch_size:
|
|
# Round it up internally to `batch_size` since our algorithm requires it.
|
|
# This is harmless since it only offers extra randomization.
|
|
shuffle_buffer_min_size = batch_size
|
|
self._shuffle_buffer_min_size = shuffle_buffer_min_size
|
|
|
|
self._min_rows_to_yield_batch = max(
|
|
1, int(shuffle_buffer_min_size * SHUFFLE_BUFFER_COMPACTION_THRESHOLD)
|
|
)
|
|
self._min_rows_to_trigger_compaction = int(
|
|
shuffle_buffer_min_size * SHUFFLE_BUFFER_COMPACTION_RATIO
|
|
)
|
|
self._builder = DelegatingBlockBuilder()
|
|
self._shuffle_buffer: Block = None
|
|
self._shuffled_indices: Optional[np.ndarray] = None
|
|
self._batch_head = 0
|
|
self._done_adding = False
|
|
|
|
self._total_object_store_nbytes = get_total_obj_store_mem_on_node()
|
|
self._total_num_rows_added = 0
|
|
self._total_nbytes_added = 0
|
|
|
|
def add(self, block: Block):
|
|
"""Add a block to the shuffle buffer.
|
|
|
|
Note empty block is not added to buffer.
|
|
|
|
Args:
|
|
block: Block to add to the shuffle buffer.
|
|
"""
|
|
# Because Arrow tables are memory mapped, blocks in the builder reside in object
|
|
# store memory and not local heap memory. So, if you specify a large buffer size
|
|
# and there isn't enough object store memory on the node, you encounter
|
|
# spilling.
|
|
if (
|
|
self._estimated_min_nbytes_in_buffers is not None
|
|
and self._estimated_min_nbytes_in_buffers > self._total_object_store_nbytes
|
|
and log_once("shuffle_buffer_mem_warning")
|
|
):
|
|
warnings.warn(
|
|
"The node you're iterating on has "
|
|
f"{memory_string(self._total_object_store_nbytes)} object "
|
|
"store memory, but the shuffle buffer is estimated to use "
|
|
f"{memory_string(self._estimated_min_nbytes_in_buffers)}. If you don't "
|
|
f"decrease the shuffle buffer size from "
|
|
f"{self._shuffle_buffer_min_size} rows, you might encounter spilling."
|
|
)
|
|
|
|
block_accessor = BlockAccessor.for_block(block)
|
|
if block_accessor.num_rows() > 0:
|
|
self._builder.add_block(block)
|
|
self._total_num_rows_added += block_accessor.num_rows()
|
|
self._total_nbytes_added += block_accessor.size_bytes()
|
|
|
|
@property
|
|
def _average_row_nbytes(self) -> Optional[int]:
|
|
"""Return the average number of bytes per row added to this batcher."""
|
|
return (
|
|
self._total_nbytes_added // self._total_num_rows_added
|
|
if self._total_num_rows_added > 0
|
|
else None
|
|
)
|
|
|
|
@property
|
|
def _estimated_min_nbytes_in_buffers(self) -> Optional[int]:
|
|
"""Return the estimated minimum number of bytes across all buffers.
|
|
|
|
This includes data in both the compacted and uncompacted buffers.
|
|
"""
|
|
if self._average_row_nbytes is None:
|
|
return None
|
|
|
|
return self._average_row_nbytes * self._min_rows_to_trigger_compaction
|
|
|
|
def done_adding(self) -> bool:
|
|
"""Indicate to the batcher that no more blocks will be added to the batcher.
|
|
|
|
No more blocks should be added to the batcher after calling this.
|
|
"""
|
|
self._done_adding = True
|
|
|
|
def has_any(self) -> bool:
|
|
"""Whether this batcher has any data."""
|
|
return self._num_rows() > 0
|
|
|
|
def has_batch(self) -> bool:
|
|
"""Whether this batcher has any batches."""
|
|
num_rows = self._num_rows()
|
|
|
|
if not self._done_adding:
|
|
# Delay pulling of batches until the buffer is large enough in order to
|
|
# amortize compaction overhead.
|
|
return num_rows >= self._batch_size and (
|
|
self._num_compacted_rows() >= self._min_rows_to_yield_batch
|
|
or num_rows - self._batch_size >= self._min_rows_to_trigger_compaction
|
|
)
|
|
else:
|
|
return num_rows >= self._batch_size
|
|
|
|
def _num_rows(self) -> int:
|
|
"""Return the total number of rows that haven't been yielded yet.
|
|
|
|
This includes rows in both the compacted and uncompacted buffers.
|
|
"""
|
|
return self._num_compacted_rows() + self._num_uncompacted_rows()
|
|
|
|
def _num_compacted_rows(self) -> int:
|
|
"""Return number of unyielded rows in the compacted buffer."""
|
|
if self._shuffle_buffer is None:
|
|
return 0
|
|
return max(0, len(self._shuffled_indices) - self._batch_head)
|
|
|
|
def _num_uncompacted_rows(self) -> int:
|
|
"""Return number of unyielded rows in the uncompacted buffer."""
|
|
return self._builder.num_rows()
|
|
|
|
def next_batch(self) -> Block:
|
|
"""Get the next shuffled batch from the shuffle buffer.
|
|
|
|
Returns:
|
|
A batch represented as a Block.
|
|
"""
|
|
assert self.has_batch() or (self._done_adding and self.has_any())
|
|
if self._num_uncompacted_rows() > 0 and (
|
|
self._done_adding
|
|
or self._num_compacted_rows() <= self._min_rows_to_yield_batch
|
|
):
|
|
if self._shuffle_buffer is not None and self._batch_head < len(
|
|
self._shuffled_indices
|
|
):
|
|
remaining_indices = self._shuffled_indices[self._batch_head :]
|
|
remaining_block = BlockAccessor.for_block(self._shuffle_buffer).take(
|
|
remaining_indices
|
|
)
|
|
self._builder.add_block(remaining_block)
|
|
self._shuffle_buffer = self._builder.build()
|
|
|
|
accessor = BlockAccessor.for_block(self._shuffle_buffer)
|
|
if isinstance(accessor, ArrowBlockAccessor):
|
|
self._shuffle_buffer = try_combine_chunked_columns(
|
|
self._shuffle_buffer, min_chunks_to_combine=1
|
|
)
|
|
accessor = BlockAccessor.for_block(self._shuffle_buffer)
|
|
|
|
num_rows = accessor.num_rows()
|
|
self._shuffled_indices = self._rng.permutation(num_rows)
|
|
|
|
self._builder = DelegatingBlockBuilder()
|
|
self._batch_head = 0
|
|
|
|
assert self._shuffle_buffer is not None
|
|
assert self._shuffled_indices is not None
|
|
remaining = len(self._shuffled_indices) - self._batch_head
|
|
batch_size = min(self._batch_size, remaining)
|
|
batch_indices = self._shuffled_indices[
|
|
self._batch_head : self._batch_head + batch_size
|
|
]
|
|
self._batch_head += batch_size
|
|
return BlockAccessor.for_block(self._shuffle_buffer).take(batch_indices)
|