mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 15:22:30 +00:00
a
This commit is contained in:
@@ -34,6 +34,7 @@ from copy import deepcopy
|
||||
from multiprocessing import RawArray
|
||||
from typing import Any, Callable, cast, Iterator, Literal, NamedTuple, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pyarrow as pa
|
||||
import pyarrow.compute as pc
|
||||
import torch
|
||||
@@ -55,18 +56,177 @@ logger = logging.getLogger(__name__)
|
||||
# distinct seeds for any practically encountered epoch count.
|
||||
_EPOCH_PRIME = 100003
|
||||
|
||||
# Multiplier used to give each split its own in-RAM row permutation seed in
|
||||
# 2-phase mode (distinct from _EPOCH_PRIME so the two offsets can't collide).
|
||||
_SPLIT_PRIME = 1000003
|
||||
|
||||
# Number of blocks kept in RAM per split at once for 2-phase's row-shuffle
|
||||
# window. Fixed for now; will become a real sliding window later.
|
||||
_TWO_PHASE_WINDOW_BLOCKS = 3
|
||||
# Offsets added when seeding each split's 2-phase bounded shuffle
|
||||
# permutation (distinct from _EPOCH_PRIME and from each other so they
|
||||
# can't collide).
|
||||
_SIGMA_PRIME = 1000003
|
||||
_SIGMA_SPLIT_PRIME = 3000017
|
||||
|
||||
DEFAULT_READ_BATCH_SIZE = 64
|
||||
DEFAULT_PREFETCH_BATCHES = 4
|
||||
|
||||
|
||||
def _build_bounded_permutation(n: int, max_distance: int, seed: int) -> np.ndarray:
|
||||
"""Build a permutation of ``[0, n)`` with ``|sigma(k) - k| < max_distance``.
|
||||
|
||||
Partitions ``[0, n)`` into consecutive chunks of ``max_distance`` entries
|
||||
(the last chunk may be shorter) and fully shuffles each chunk
|
||||
independently. Since ``k`` and ``sigma(k)`` always fall in the same
|
||||
chunk, this trivially satisfies the displacement bound. Vectorized
|
||||
(one random key matrix, sorted per row) so it stays cheap even sized to
|
||||
an entire split's live-row count.
|
||||
"""
|
||||
if n == 0:
|
||||
return np.array([], dtype=np.int64)
|
||||
rng = np.random.default_rng(seed)
|
||||
sigma = np.arange(n, dtype=np.int64)
|
||||
num_full = n // max_distance
|
||||
full_len = num_full * max_distance
|
||||
if num_full:
|
||||
# Sorting i.i.d. random keys within each row of a
|
||||
# (num_full, max_distance) matrix gives, per row, a uniformly
|
||||
# random permutation of that row's column indices -- i.e. one
|
||||
# independent shuffled chunk per row, all at once.
|
||||
keys = rng.random((num_full, max_distance))
|
||||
chunk_perms = np.argsort(keys, axis=1)
|
||||
bases = (np.arange(num_full, dtype=np.int64) * max_distance)[:, None]
|
||||
sigma[:full_len] = (chunk_perms + bases).reshape(-1)
|
||||
if full_len < n:
|
||||
tail = np.arange(full_len, n, dtype=np.int64)
|
||||
tail_perm = np.argsort(rng.random(n - full_len))
|
||||
sigma[full_len:] = tail[tail_perm]
|
||||
return sigma
|
||||
|
||||
|
||||
class _TwoPhaseSplitReader:
|
||||
"""Sequential-only stand-in for ``Permutation`` driving 2-phase reads.
|
||||
|
||||
Duck-types ``Permutation.__getitems__`` so 2-phase's shuffled reads flow
|
||||
through the same stage-1 I/O machinery (``io_queue_depth`` prefetch,
|
||||
``Future``-based fetches) used by 1-phase, instead of a separate ad hoc
|
||||
path.
|
||||
|
||||
This split's assigned blocks, concatenated in order and restricted to
|
||||
live rows, give every row a dense natural rank in ``[0, num_rows)``. A
|
||||
single permutation ``sigma`` of that rank space -- built once, with
|
||||
``|sigma(rank) - rank| < max_shuffle_distance`` for every rank -- decides
|
||||
each row's output position. A row may move anywhere within that bound,
|
||||
including into a different block's range entirely; the only restriction
|
||||
is the distance from its own natural rank, not which block it started
|
||||
in. A block is evicted, and the next block assigned to this split read
|
||||
in to replace it, as soon as every one of its rows has been read past
|
||||
in the output order -- which happens naturally once ``sigma`` has been
|
||||
applied, without any extra bookkeeping about "generations" or "slots".
|
||||
|
||||
Only one ``__getitems__`` call executes at a time (serialized by
|
||||
``_lock``): the block cache and read plan are shared, mutable state,
|
||||
and letting ``io_queue_depth`` submit multiple concurrent calls for the
|
||||
same split would race on them. ``io_queue_depth`` therefore does not
|
||||
add fetch parallelism within a single 2-phase split (a known
|
||||
limitation) -- only across splits, which each get their own reader and
|
||||
lock. Blocks are also still loaded strictly on demand (the first time
|
||||
a gather references them), not eagerly prefetched ahead of
|
||||
``window_blocks``, so a single split's own I/O does not yet overlap
|
||||
with itself either -- a separate follow-up from this pass.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
block_ids: list[int],
|
||||
block_live_counts: list[int],
|
||||
shuffle: bool,
|
||||
max_shuffle_distance: int,
|
||||
seed: int,
|
||||
read_block_fn: Callable[[int], tuple[pa.Table, list[int]]],
|
||||
columns: Optional[list[str]],
|
||||
):
|
||||
self._block_ids = block_ids
|
||||
self._read_block_fn = read_block_fn
|
||||
self._columns = columns
|
||||
self._lock = threading.Lock()
|
||||
self._loaded: dict[int, pa.Table] = {}
|
||||
|
||||
counts = np.asarray(block_live_counts, dtype=np.int64)
|
||||
self.num_rows = int(counts.sum())
|
||||
num_blocks = len(block_ids)
|
||||
|
||||
# Natural (block, then row) order: block_pos[k]/local_offset[k]
|
||||
# describe the row whose dense natural rank is exactly k.
|
||||
block_pos = np.repeat(np.arange(num_blocks, dtype=np.int64), counts)
|
||||
local_offset = (
|
||||
np.concatenate([np.arange(c, dtype=np.int64) for c in counts])
|
||||
if num_blocks
|
||||
else np.array([], dtype=np.int64)
|
||||
)
|
||||
|
||||
if shuffle and self.num_rows:
|
||||
sigma = _build_bounded_permutation(
|
||||
self.num_rows, max_shuffle_distance, seed
|
||||
)
|
||||
else:
|
||||
sigma = np.arange(self.num_rows, dtype=np.int64)
|
||||
|
||||
# sigma is itself a permutation of dense ranks, so inverting it
|
||||
# (argsort) directly gives, for each output position, which
|
||||
# natural rank -- and thus which (block, local offset) -- to draw.
|
||||
order = np.argsort(sigma, kind="stable")
|
||||
self._plan_block_pos = block_pos[order]
|
||||
self._plan_local_offset = local_offset[order]
|
||||
|
||||
# Last plan position (inclusive) at which each block is still
|
||||
# needed, so it can be evicted as soon as we've read past it.
|
||||
self._block_last_pos: dict[int, int] = {}
|
||||
for pos, bp in enumerate(self._plan_block_pos.tolist()):
|
||||
self._block_last_pos[bp] = pos
|
||||
|
||||
def __getitems__(self, indices) -> pa.RecordBatch:
|
||||
start = indices[0]
|
||||
fetch = len(indices)
|
||||
with self._lock:
|
||||
block_pos_slice = self._plan_block_pos[start : start + fetch]
|
||||
local_off_slice = self._plan_local_offset[start : start + fetch]
|
||||
needed = sorted(set(block_pos_slice.tolist()))
|
||||
for bp in needed:
|
||||
if bp not in self._loaded:
|
||||
block_id = self._block_ids[bp]
|
||||
raw, live_positions = self._read_block_fn(block_id)
|
||||
self._loaded[bp] = raw.take(
|
||||
pa.array(live_positions, type=pa.int64())
|
||||
)
|
||||
|
||||
# Group by source block (stable, so each block's rows keep
|
||||
# their relative order), gather from each, then invert the
|
||||
# grouping to restore this chunk's actual priority order.
|
||||
group_order = np.argsort(block_pos_slice, kind="stable")
|
||||
grouped_block_pos = block_pos_slice[group_order]
|
||||
grouped_local_off = local_off_slice[group_order]
|
||||
tables = []
|
||||
pos = 0
|
||||
n = len(grouped_block_pos)
|
||||
while pos < n:
|
||||
bp = grouped_block_pos[pos]
|
||||
run_end = pos + 1
|
||||
while run_end < n and grouped_block_pos[run_end] == bp:
|
||||
run_end += 1
|
||||
offs = grouped_local_off[pos:run_end]
|
||||
tables.append(
|
||||
self._loaded[int(bp)].take(pa.array(offs, type=pa.int64()))
|
||||
)
|
||||
pos = run_end
|
||||
combined = pa.concat_tables(tables)
|
||||
inverse = np.argsort(group_order)
|
||||
result = combined.take(pa.array(inverse, type=pa.int64()))
|
||||
|
||||
watermark = start + fetch - 1
|
||||
for bp in list(self._loaded):
|
||||
if self._block_last_pos.get(bp, -1) <= watermark:
|
||||
del self._loaded[bp]
|
||||
|
||||
if self._columns is not None:
|
||||
result = result.select(self._columns)
|
||||
return result.combine_chunks().to_batches()[0]
|
||||
|
||||
|
||||
class _WorkerSample(NamedTuple):
|
||||
data: Any
|
||||
dataset: "StreamingDataset"
|
||||
@@ -283,7 +443,28 @@ class StreamingDataset(IterableDataset):
|
||||
When set, rows are shuffled in contiguous groups of this size rather
|
||||
than individually. Larger clumps improve I/O locality (important on
|
||||
object storage) at the cost of reduced randomness. ``None`` (the
|
||||
default) shuffles rows individually.
|
||||
default) shuffles rows individually. Ignored when ``block_size`` is
|
||||
set (2-phase mode has its own locality/randomness knobs, below).
|
||||
block_size:
|
||||
Enables 2-phase shuffled reads: rows are shuffled at the granularity
|
||||
of contiguous blocks of (approximately, or exactly when ``filter``
|
||||
is unset) this many rows, and each split reads a rolling window of
|
||||
``window_blocks`` blocks at a time instead of taking individual rows
|
||||
by row-id. This trades some randomness for I/O that is almost
|
||||
entirely contiguous range scans rather than row-id takes, which is
|
||||
far cheaper on object storage. ``None`` (the default) uses 1-phase
|
||||
shuffling, which permutes every row individually up front.
|
||||
window_blocks:
|
||||
Number of blocks kept in RAM per split at once in 2-phase mode.
|
||||
Larger windows shuffle across more rows at the cost of more memory;
|
||||
must be at least 1. Requires ``block_size``. Defaults to 4.
|
||||
max_shuffle_distance:
|
||||
Bounds how far, in rows, 2-phase shuffling may displace a row from
|
||||
its position in unshuffled (block, then row) order. Requires
|
||||
``block_size``. Smaller values shuffle less but let a block's RAM
|
||||
be reclaimed and refilled with the next block sooner; larger values
|
||||
shuffle more but hold blocks in RAM longer. Defaults to
|
||||
``block_size // 4`` (at least 1).
|
||||
filter:
|
||||
Optional SQL filter expression (e.g. ``"label = 'dog'"``). Only rows
|
||||
that satisfy the predicate are included in the permutation. The filter
|
||||
@@ -407,6 +588,8 @@ class StreamingDataset(IterableDataset):
|
||||
columns: Optional[list[str]] = None,
|
||||
shuffle_clump_size: Optional[int] = None,
|
||||
block_size: Optional[int] = None,
|
||||
window_blocks: Optional[int] = None,
|
||||
max_shuffle_distance: Optional[int] = None,
|
||||
filter: Optional[str] = None,
|
||||
transform: Optional[Callable] = None,
|
||||
transform_parallelism: Optional[int] = None,
|
||||
@@ -441,6 +624,18 @@ class StreamingDataset(IterableDataset):
|
||||
raise ValueError("io_queue_depth must be greater than 0")
|
||||
if block_size is not None and block_size <= 1:
|
||||
raise ValueError("block_size must be greater than 1")
|
||||
if block_size is None:
|
||||
if window_blocks is not None:
|
||||
raise ValueError("window_blocks requires block_size to be set")
|
||||
if max_shuffle_distance is not None:
|
||||
raise ValueError(
|
||||
"max_shuffle_distance requires block_size to be set"
|
||||
)
|
||||
else:
|
||||
if window_blocks is not None and window_blocks < 1:
|
||||
raise ValueError("window_blocks must be at least 1")
|
||||
if max_shuffle_distance is not None and max_shuffle_distance <= 0:
|
||||
raise ValueError("max_shuffle_distance must be greater than 0")
|
||||
if transform_parallelism is not None and transform_parallelism <= 0:
|
||||
raise ValueError("transform_parallelism must be greater than 0")
|
||||
if pack_sequences is not None:
|
||||
@@ -606,6 +801,7 @@ class StreamingDataset(IterableDataset):
|
||||
(i * block_size, min((i + 1) * block_size, num_rows))
|
||||
for i in range(num_blocks)
|
||||
]
|
||||
block_live_counts = [end - start for start, end in block_ranges]
|
||||
else:
|
||||
# One pass, computed once here and never repeated at read
|
||||
# time: every fragment's raw and live row counts are read
|
||||
@@ -613,30 +809,37 @@ class StreamingDataset(IterableDataset):
|
||||
# data once) to lay out block boundaries on fragment edges,
|
||||
# so later block reads are plain offset/limit scans that
|
||||
# Lance can seek to directly, instead of re-scanning from
|
||||
# the start of the table on every read.
|
||||
# the start of the table on every read. The exact live
|
||||
# count of each block is also recorded here, from the same
|
||||
# pass, so read time never has to guess how many live rows
|
||||
# a block will actually yield.
|
||||
fragments = table.to_lance().get_fragments()
|
||||
frag_raw_lens = [frag.count_rows() for frag in fragments]
|
||||
frag_live_lens = [frag.count_rows(filter) for frag in fragments]
|
||||
num_rows = sum(frag_live_lens)
|
||||
|
||||
block_ranges = []
|
||||
block_live_counts = []
|
||||
raw_pos = 0
|
||||
block_start = 0
|
||||
block_live = 0
|
||||
for raw_len, live_len in zip(frag_raw_lens, frag_live_lens):
|
||||
if block_live > 0 and block_live + live_len > block_size:
|
||||
block_ranges.append((block_start, raw_pos))
|
||||
block_live_counts.append(block_live)
|
||||
block_start = raw_pos
|
||||
block_live = 0
|
||||
block_live += live_len
|
||||
raw_pos += raw_len
|
||||
if block_live > 0:
|
||||
block_ranges.append((block_start, raw_pos))
|
||||
block_live_counts.append(block_live)
|
||||
num_blocks = len(block_ranges)
|
||||
|
||||
self._num_rows = num_rows
|
||||
self._num_blocks = num_blocks
|
||||
self._block_ranges: list[tuple[int, int]] = block_ranges
|
||||
self._block_live_counts: list[int] = block_live_counts
|
||||
if self._num_blocks < num_splits:
|
||||
raise ValueError(
|
||||
f"block_size={block_size} yields only {self._num_blocks} "
|
||||
@@ -652,6 +855,18 @@ class StreamingDataset(IterableDataset):
|
||||
# Arrow-backed permutation table like the 1-phase row mapping.
|
||||
self._block_perm: list[int] = block_order
|
||||
self._perm_table = None
|
||||
|
||||
# window_blocks currently only sets an expectation for how many
|
||||
# blocks are resident at once; peak residency is actually
|
||||
# driven by max_shuffle_distance relative to block_size (see
|
||||
# _TwoPhaseSplitReader), a known gap between this knob and
|
||||
# actual behavior.
|
||||
self._window_blocks = window_blocks if window_blocks is not None else 4
|
||||
self._max_shuffle_distance = (
|
||||
max_shuffle_distance
|
||||
if max_shuffle_distance is not None
|
||||
else max(1, block_size // 4)
|
||||
)
|
||||
else: #1-phase shuffled read
|
||||
# Build the permutation table once, deterministically.
|
||||
builder = permutation_builder(table)
|
||||
@@ -851,13 +1066,13 @@ class StreamingDataset(IterableDataset):
|
||||
# self._block_perm[k * (b // num_splits) : (k + 1) * (b // num_splits)]
|
||||
# -- the surplus b % num_splits blocks are dropped, mirroring how
|
||||
# 1-phase drops surplus rows to keep all splits the same length.
|
||||
#
|
||||
# Each split's blocks are wrapped in a _TwoPhaseSplitReader,
|
||||
# which duck-types Permutation.__getitems__ -- so from here on,
|
||||
# 2-phase flows through the exact same stage-1 I/O machinery
|
||||
# (_fill_io/_submit_io/_drain_io, io_queue_depth prefetch) as
|
||||
# 1-phase. No 2-phase-specific code exists past this point.
|
||||
blocks_per_split = self._num_blocks // self._num_splits
|
||||
split_blocks: list[list[int]] = []
|
||||
for split_idx in my_splits:
|
||||
start = split_idx * blocks_per_split
|
||||
end = start + blocks_per_split
|
||||
split_blocks.append(self._block_perm[start:end])
|
||||
|
||||
lance_ds = self._table.to_lance()
|
||||
from . import connect as _connect
|
||||
|
||||
@@ -866,14 +1081,13 @@ class StreamingDataset(IterableDataset):
|
||||
# scan that Lance can seek to directly, and it pulls in
|
||||
# dead rows right along with live ones -- that's fine, they
|
||||
# never get selected below and cost nothing extra to fetch.
|
||||
# Bytes/time accounting happens generically in _io_call,
|
||||
# below, not here.
|
||||
row_start, row_end = self._block_ranges[block_id]
|
||||
t0 = time.perf_counter()
|
||||
raw = lance_ds.scanner(
|
||||
offset=row_start,
|
||||
limit=row_end - row_start,
|
||||
).to_table()
|
||||
self._bytes_loaded += raw.nbytes
|
||||
self._fetch_time += time.perf_counter() - t0
|
||||
|
||||
if self._filter is None:
|
||||
return raw, list(range(raw.num_rows))
|
||||
@@ -899,66 +1113,38 @@ class StreamingDataset(IterableDataset):
|
||||
)
|
||||
return raw, live_positions
|
||||
|
||||
def _fetch_and_permute_window(split_pos: int) -> pa.Table:
|
||||
# Read this split's first _TWO_PHASE_WINDOW_BLOCKS blocks as
|
||||
# contiguous range reads (each is one object-storage scan,
|
||||
# not a row-id take), concatenate them into one in-RAM
|
||||
# buffer. Dead rows ride along in that buffer as dead
|
||||
# weight -- they are never removed from it -- and the
|
||||
# permutation below is built only from live positions, so
|
||||
# dead rows simply never get selected by the final take().
|
||||
block_ids = split_blocks[split_pos][:_TWO_PHASE_WINDOW_BLOCKS]
|
||||
raw_tables = []
|
||||
live_positions: list[int] = []
|
||||
raw_offset = 0
|
||||
for block_id in block_ids:
|
||||
raw, block_live = _read_block(block_id)
|
||||
live_positions.extend(p + raw_offset for p in block_live)
|
||||
raw_offset += raw.num_rows
|
||||
raw_tables.append(raw)
|
||||
window = pa.concat_tables(raw_tables)
|
||||
|
||||
if self._shuffle:
|
||||
split_idx = my_splits[split_pos]
|
||||
row_seed = (
|
||||
self._shuffle_seed
|
||||
+ self._epoch * _EPOCH_PRIME
|
||||
+ (split_idx + 1) * _SPLIT_PRIME
|
||||
)
|
||||
random.Random(row_seed).shuffle(live_positions)
|
||||
|
||||
live = window.take(live_positions)
|
||||
if self._columns is not None:
|
||||
live = live.select(self._columns)
|
||||
return live
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(my_splits)) as window_pool:
|
||||
windows = list(
|
||||
window_pool.map(_fetch_and_permute_window, range(len(my_splits)))
|
||||
permutations: list[_TwoPhaseSplitReader] = []
|
||||
initial_samples = []
|
||||
initial_positions = []
|
||||
for split_idx in my_splits:
|
||||
start = split_idx * blocks_per_split
|
||||
end = start + blocks_per_split
|
||||
block_ids = self._block_perm[start:end]
|
||||
live_counts = [self._block_live_counts[bid] for bid in block_ids]
|
||||
sigma_seed = (
|
||||
self._shuffle_seed
|
||||
+ self._epoch * _EPOCH_PRIME
|
||||
+ _SIGMA_PRIME
|
||||
+ split_idx * _SIGMA_SPLIT_PRIME
|
||||
)
|
||||
permutations.append(
|
||||
_TwoPhaseSplitReader(
|
||||
block_ids=block_ids,
|
||||
block_live_counts=live_counts,
|
||||
shuffle=self._shuffle,
|
||||
max_shuffle_distance=self._max_shuffle_distance,
|
||||
seed=sigma_seed,
|
||||
read_block_fn=_read_block,
|
||||
columns=self._columns,
|
||||
)
|
||||
)
|
||||
# No resume/skip support yet in 2-phase: every split starts
|
||||
# fresh regardless of any loaded checkpoint.
|
||||
initial_samples.append(0)
|
||||
initial_positions.append(0)
|
||||
|
||||
# No resume/skip support yet in 2-phase: every split starts fresh.
|
||||
n = len(my_splits)
|
||||
initial_samples = [0] * n
|
||||
initial_positions = [0] * n
|
||||
# NOTE: for now a split's "size" is just its first window (until
|
||||
# the sliding window is implemented), so a split is exhausted
|
||||
# after its first _TWO_PHASE_WINDOW_BLOCKS blocks -- epochs are
|
||||
# temporarily truncated to that many blocks per split.
|
||||
split_sizes = [window.num_rows for window in windows]
|
||||
|
||||
# Chop each split's permuted window into read_batch_size chunks,
|
||||
# in the same (abs_start, RecordBatch) shape stage 1 normally
|
||||
# produces, so stage 2 and the main loop's round-robin need no
|
||||
# changes at all.
|
||||
two_phase_initial_batches: list[list[tuple[int, pa.RecordBatch]]] = []
|
||||
for window in windows:
|
||||
pos = 0
|
||||
chunks = []
|
||||
for batch in window.to_batches(max_chunksize=self._read_batch_size):
|
||||
chunks.append((pos, batch))
|
||||
pos += batch.num_rows
|
||||
two_phase_initial_batches.append(chunks)
|
||||
n = len(permutations)
|
||||
split_sizes = [reader.num_rows for reader in permutations]
|
||||
local_consumed = [0] * n
|
||||
# Permutation position each split has consumed through (absolute,
|
||||
# i.e. counted from the start of the unskipped split). Runs ahead of
|
||||
@@ -1007,16 +1193,6 @@ class StreamingDataset(IterableDataset):
|
||||
tx_pending = [deque() for _ in range(n)] # Future[list[(abs_pos, row)]]
|
||||
cooked = [deque() for _ in range(n)] # (abs_pos, row) ready to yield
|
||||
|
||||
if self._block_size is not None:
|
||||
# 2-phase already fetched+permuted each split's window above;
|
||||
# seed raw_batches with it directly and mark stage 1 "done" for
|
||||
# now (fetch_head == split_sizes) so _fill_io/_submit_io, which
|
||||
# only know how to read via the 1-phase `permutations` list,
|
||||
# are never invoked in this mode.
|
||||
for i in range(n):
|
||||
raw_batches[i].extend(two_phase_initial_batches[i])
|
||||
fetch_head[i] = split_sizes[i]
|
||||
|
||||
# Limit simultaneous transforms to transform_workers across all splits.
|
||||
tx_semaphore = threading.Semaphore(transform_workers)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user