perf(python): use the native lance reader for local 2-phase blocks

f5e23ab0 routed 2-phase block reads through the backend-agnostic query
builder so they would also work against a RemoteTable, which `to_lance()`
does not support. That is the right default, but it costs a measurable
amount on the local path: the generic builder re-plans each positional
range read, and the filtered path has to select `_rowoffset` and sort by
it afterwards because `take_offsets` makes no ordering guarantee.

Resolve the table once per dataset instance instead. A local table yields
a pylance handle and reads go straight to `scanner(offset=, limit=)` or
`take()`, both of which already return rows in the requested order; a
RemoteTable raises NotImplementedError, is cached as None, and keeps the
generic path unchanged.

The cached handle is Rust-backed and not picklable, so __getstate__ drops
the key entirely rather than setting it to None -- a worker that inherited
a None would read it as "confirmed remote" and never take the fast path
against its own reconnected table.

Local-NVMe 2-phase read throughput 78.7 -> 100.6 MB/s, which with the
preceding commit restores the pre-regression 2.3x speedup over 1-phase
(now 2.0x, within trial-to-trial noise of the September baseline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jasonz-lance
2026-10-08 16:35:37 -07:00
co-authored by Claude Opus 5
parent e907ff8c0e
commit d268300a4a
+42 -4
View File
@@ -1056,14 +1056,41 @@ class StreamingDataset(IterableDataset):
pinned.checkout(self._pinned_version)
return pinned
def _pinned_lance_dataset(self, pinned_table):
"""Native pylance handle for ``pinned_table``, if it's a local table.
``to_lance()`` only works against a local table (it raises
``NotImplementedError`` on ``RemoteTable``); cached per dataset
instance (keyed by nothing -- there's only ever one pin per
instance) so the conversion runs at most once rather than once per
block read. ``None`` means ``pinned_table`` is remote and callers
must use the backend-agnostic query builder instead.
"""
if not hasattr(self, "_pinned_lance_ds_cache"):
try:
self._pinned_lance_ds_cache = pinned_table.to_lance()
except NotImplementedError:
self._pinned_lance_ds_cache = None
return self._pinned_lance_ds_cache
def _read_block(
self, pinned_table, block_id: int, columns: Optional[list[str]]
) -> pa.Table:
"""Read one 2-phase block's live rows, in natural (ascending) order."""
lance_ds = self._pinned_lance_dataset(pinned_table)
if self._filter is None:
# offset/limit is a positional, sequential read, so its output
# order is exactly storage order -- no restoration needed.
row_start, row_end = self._block_ranges[block_id]
if lance_ds is not None:
# Local table: pylance's native scanner is a direct,
# purpose-built positional range read -- substantially
# faster than routing the same offset/limit request through
# the generic, backend-agnostic query builder below, which
# exists so this also works against a remote (Cloud) table.
return lance_ds.scanner(
offset=row_start, limit=row_end - row_start, columns=columns
).to_table()
q = pinned_table.search().offset(row_start).limit(row_end - row_start)
if columns is not None:
q = q.select(columns)
@@ -1071,12 +1098,18 @@ class StreamingDataset(IterableDataset):
# Filtered: this block's exact live-row dataset offsets were
# already computed once, up front (from the "_rowoffset" system
# column -- see __init__), so take_offsets fetches just the live
# rows themselves, skipping any dead-row gaps entirely. Unlike
# offset/limit, take_offsets makes no ordering guarantee for an
# column -- see __init__), so a sparse take fetches just the live
# rows themselves, skipping any dead-row gaps entirely.
offsets = self._block_live_offsets[block_id]
if lance_ds is not None:
# Local table: pylance's native take() preserves the requested
# offset order already, unlike the generic take_offsets() path
# below, so no extra sort-and-rebuild pass is needed here.
return lance_ds.take(offsets.tolist(), columns=columns)
# Remote table: take_offsets makes no ordering guarantee for an
# arbitrary index list, so "_rowoffset" is always also selected
# and used to restore the request's ascending order locally.
offsets = self._block_live_offsets[block_id]
select_cols = (columns if columns is not None else self._all_columns) + [
"_rowoffset"
]
@@ -1914,6 +1947,11 @@ class StreamingDataset(IterableDataset):
):
state[key] = None
state["_consumer_iterator_lock"] = None
# Non-picklable Rust-backed handle (see _pinned_lance_dataset);
# dropped rather than set to None so the worker's first _read_block
# call recomputes it fresh against its own reconnected table instead
# of permanently misreading an absent cache as "confirmed remote".
state.pop("_pinned_lance_ds_cache", None)
return state
def __setstate__(self, state):