mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-18 20:18:37 +00:00
7813907eb7
## Problem `table.add(dataset)` with a `pyarrow.dataset.Dataset` OOMs the client during bulk ingestion of wide rows (e.g. embedding columns), even against a remote table where the upload itself is streaming. The cause is in `to_scannable`: a `Dataset` is scanned with pyarrow's default scanner settings (`batch_size=131072` rows, `batch_readahead=16`, `fragment_readahead=4`). pyarrow's internal threads prefetch that read-ahead window independently of LanceDB's backpressure, so for wide rows a large fraction of the dataset is held in memory. On the remote path this is then multiplied across the multipart write partitions (one in-flight batch per partition, up to CPU-core count). Reproduced on a 10 GB / 1.55M-row dataset with two 768-dim float32 embeddings: peak client RSS ~11.7 GB for the scan alone (6.8 GB after consuming a *single* batch), ~15.4 GB for the full remote `add()`. ## Fix `to_scannable` now sizes the scanner from an estimate of bytes-per-row derived from the schema: - **Narrow datasets keep pyarrow's defaults** (empty scanner kwargs) — no throughput regression. The bound only engages above ~410 bytes/row. - **Wide rows** get a smaller `batch_size` (~16 MiB/batch) and reduced read-ahead (`batch_readahead=2`, `fragment_readahead=1`) so peak in-flight memory stays near a ~1 GiB budget. Read-ahead (not just batch size) has to drop, because pyarrow pins whole row-group buffers. On the 10 GB dataset this drops peak client RSS to ~1.4 GB, and it stays flat as the dataset grows. The `Dataset`/`LanceDataset` scannables remain rescannable (retry-safe). ## Also: expose `write_parallelism` on `add()` `AddDataBuilder::write_parallelism` already existed in Rust but was not exposed in Python. This PR forwards it through the async, sync, and remote `add()` methods, so users can cap the number of parallel write partitions (each buffers data in flight) to trade throughput for memory on large uploads. ## Tests - `test_scannable.py`: bytes-per-row estimation; narrow → defaults; wide → bounded; `Dataset` reader streams bounded batches and stays rescannable. - `test_table.py`: `write_parallelism` on sync and async `add()`, and that `write_parallelism=0` is rejected. Fixes ENT-1883 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
6.7 KiB
Python
184 lines
6.7 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import numpy as np
|
|
import pyarrow as pa
|
|
import pyarrow.dataset as ds
|
|
import pyarrow.parquet as pq
|
|
|
|
from lancedb.scannable import (
|
|
_PA_DEFAULT_BATCH_ROWS,
|
|
_SAMPLE_ROWS,
|
|
_VARIABLE_WIDTH_ESTIMATE,
|
|
_WIDE_BATCH_READAHEAD,
|
|
_WIDE_FRAGMENT_READAHEAD,
|
|
_bounded_scanner_kwargs,
|
|
_estimate_bytes_per_row,
|
|
_sample_head,
|
|
to_scannable,
|
|
)
|
|
|
|
|
|
def test_estimate_bytes_per_row():
|
|
# fixed-width scalars
|
|
assert _estimate_bytes_per_row(pa.schema([("a", pa.int64())])) == 8
|
|
assert (
|
|
_estimate_bytes_per_row(pa.schema([("a", pa.int32()), ("b", pa.float64())]))
|
|
== 12
|
|
)
|
|
assert _estimate_bytes_per_row(pa.schema([("a", pa.bool_())])) == 1
|
|
# fixed-size list (embedding) dominates
|
|
assert (
|
|
_estimate_bytes_per_row(pa.schema([("v", pa.list_(pa.float32(), 768))]))
|
|
== 768 * 4
|
|
)
|
|
# struct sums its children
|
|
struct = pa.struct([("x", pa.int32()), ("y", pa.int32())])
|
|
assert _estimate_bytes_per_row(pa.schema([("s", struct)])) == 8
|
|
# variable-width columns get a flat estimate, not zero
|
|
assert _estimate_bytes_per_row(pa.schema([("s", pa.string())])) > 0
|
|
|
|
|
|
def test_estimate_bytes_per_row_uses_sample_for_variable_length_lists():
|
|
# A vector column without a fixed size (e.g. `list<float32>` instead of
|
|
# `list<float32, 768>`) has no width the schema alone can tell us.
|
|
schema = pa.schema([("v", pa.list_(pa.float32()))])
|
|
assert _estimate_bytes_per_row(schema) == _VARIABLE_WIDTH_ESTIMATE
|
|
|
|
sample = pa.table({"v": pa.array([[0.0] * 768], type=pa.list_(pa.float32()))})
|
|
assert _estimate_bytes_per_row(schema, sample) == 768 * 4
|
|
|
|
|
|
def test_estimate_bytes_per_row_sample_ignores_missing_or_null_lists():
|
|
schema = pa.schema([("v", pa.list_(pa.float32()))])
|
|
# an all-null sample column can't tell us anything either
|
|
sample = pa.table({"v": pa.array([None], type=pa.list_(pa.float32()))})
|
|
assert _estimate_bytes_per_row(schema, sample) == _VARIABLE_WIDTH_ESTIMATE
|
|
|
|
|
|
def test_bounded_scanner_kwargs_narrow_uses_defaults():
|
|
# Narrow rows stay on pyarrow defaults (empty kwargs) so throughput is
|
|
# unchanged.
|
|
for schema in [
|
|
pa.schema([("a", pa.int64()), ("b", pa.int32()), ("c", pa.string())]),
|
|
pa.schema([("a", pa.int64()), ("t", pa.string()), ("u", pa.string())]),
|
|
# a 100-dim float32 vector is still under the per-row budget
|
|
pa.schema([("id", pa.int64()), ("v", pa.list_(pa.float32(), 100))]),
|
|
]:
|
|
assert _bounded_scanner_kwargs(schema) == {}, schema
|
|
|
|
|
|
def test_bounded_scanner_kwargs_wide_is_bounded():
|
|
schema = pa.schema(
|
|
[
|
|
("uid", pa.string()),
|
|
("img", pa.list_(pa.float32(), 768)),
|
|
("txt", pa.list_(pa.float32(), 768)),
|
|
]
|
|
)
|
|
kwargs = _bounded_scanner_kwargs(schema)
|
|
assert kwargs, "wide schema should be throttled"
|
|
assert kwargs["batch_readahead"] == _WIDE_BATCH_READAHEAD
|
|
assert kwargs["fragment_readahead"] == _WIDE_FRAGMENT_READAHEAD
|
|
# batch is capped well below the pyarrow default for wide rows
|
|
assert kwargs["batch_size"] < _PA_DEFAULT_BATCH_ROWS
|
|
|
|
|
|
def test_bounded_scanner_kwargs_variable_length_list_needs_sample():
|
|
# Without a sample, a variable-length (not fixed-size) vector column looks
|
|
# narrow because its true width is unknown from the schema alone.
|
|
schema = pa.schema([("uid", pa.string()), ("vec", pa.list_(pa.float32()))])
|
|
assert _bounded_scanner_kwargs(schema) == {}
|
|
|
|
sample = pa.table(
|
|
{
|
|
"uid": pa.array(["a"]),
|
|
"vec": pa.array([[0.0] * 768], type=pa.list_(pa.float32())),
|
|
}
|
|
)
|
|
kwargs = _bounded_scanner_kwargs(schema, sample)
|
|
assert kwargs, "sample should reveal the wide vector column"
|
|
assert kwargs["batch_size"] < _PA_DEFAULT_BATCH_ROWS
|
|
|
|
|
|
def _write_wide_dataset(
|
|
path, *, files=2, rows_per_file=20_000, dim=768, fixed_size=True
|
|
):
|
|
rng = np.random.default_rng(0)
|
|
for i in range(files):
|
|
emb = rng.standard_normal((rows_per_file, dim), dtype=np.float32)
|
|
vec_type = pa.list_(pa.float32(), dim) if fixed_size else pa.list_(pa.float32())
|
|
vec_array = (
|
|
pa.FixedSizeListArray.from_arrays(pa.array(emb.reshape(-1)), dim)
|
|
if fixed_size
|
|
else pa.array(emb.tolist(), type=vec_type)
|
|
)
|
|
table = pa.table(
|
|
{
|
|
"uid": pa.array([f"{i}_{j}" for j in range(rows_per_file)]),
|
|
"vec": vec_array,
|
|
}
|
|
)
|
|
pq.write_table(table, f"{path}/part-{i}.parquet")
|
|
|
|
|
|
def test_dataset_reader_respects_bounded_batch_size(tmp_path):
|
|
# The Dataset path should stream small batches for wide rows, not pyarrow's
|
|
# 131072-row default, and still return every row.
|
|
_write_wide_dataset(str(tmp_path))
|
|
dataset = ds.dataset(str(tmp_path), format="parquet")
|
|
|
|
expected = _bounded_scanner_kwargs(dataset.schema)["batch_size"]
|
|
|
|
scannable = to_scannable(dataset)
|
|
assert scannable.rescannable
|
|
assert scannable.num_rows == 40_000
|
|
|
|
total = 0
|
|
for batch in scannable.reader():
|
|
assert batch.num_rows <= expected
|
|
total += batch.num_rows
|
|
assert total == 40_000
|
|
|
|
# factory can be called again (rescannable)
|
|
assert sum(b.num_rows for b in scannable.reader()) == 40_000
|
|
|
|
|
|
def test_dataset_reader_samples_variable_length_list_width(tmp_path):
|
|
# A vector column stored without a fixed size (e.g. produced by tools that
|
|
# don't tag list columns with their length) is invisible to the
|
|
# schema-only estimate, so `to_scannable` must peek a sample of rows to
|
|
# detect that it's wide and bound the scanner accordingly.
|
|
_write_wide_dataset(str(tmp_path), fixed_size=False)
|
|
dataset = ds.dataset(str(tmp_path), format="parquet")
|
|
|
|
schema_only_kwargs = _bounded_scanner_kwargs(dataset.schema)
|
|
assert schema_only_kwargs == {}, "schema alone can't see the list width"
|
|
|
|
scannable = to_scannable(dataset)
|
|
assert scannable.rescannable
|
|
assert scannable.num_rows == 40_000
|
|
|
|
total = 0
|
|
for batch in scannable.reader():
|
|
assert batch.num_rows < _PA_DEFAULT_BATCH_ROWS
|
|
total += batch.num_rows
|
|
assert total == 40_000
|
|
|
|
|
|
def test_sample_head_is_bounded_rows(tmp_path):
|
|
# The peek itself must not read the whole dataset.
|
|
_write_wide_dataset(str(tmp_path), files=1, rows_per_file=1000, fixed_size=False)
|
|
dataset = ds.dataset(str(tmp_path), format="parquet")
|
|
|
|
sample = _sample_head(dataset.head)
|
|
assert sample.num_rows == _SAMPLE_ROWS
|
|
|
|
|
|
def test_sample_head_returns_none_for_empty_dataset(tmp_path):
|
|
table = pa.table({"v": pa.array([], type=pa.list_(pa.float32()))})
|
|
pq.write_table(table, f"{tmp_path}/empty.parquet")
|
|
dataset = ds.dataset(str(tmp_path), format="parquet")
|
|
|
|
assert _sample_head(dataset.head) is None
|