Skip to content

Commit 3cf7e53

Browse files
committed
[python] Add parallel split reading to to_pandas / to_arrow
Today TableRead.to_pandas / to_arrow iterate splits serially in _arrow_batch_generator, so wall time scales linearly with the number of splits even though PyArrow's parquet/orc readers release the GIL during decode. Unlike Java, where Flink/Spark fan splits out across TaskManagers/Executors, PyPaimon has no external framework above the SDK; split-level parallelism therefore has to live inside the SDK. This commit adds an opt-in dual-track API for split-level parallelism: 1. A new table option `read.parallelism` (default 1) sets the persistent default for a table: options={'read.parallelism': '4'}. 2. A new method argument `parallelism` on to_pandas / to_arrow temporarily overrides the option for a single call: read.to_pandas(splits, parallelism=8). Priority: method argument > table option > built-in default of 1. This covers both "configure once, all reads benefit" (option) and ad-hoc tuning without altering the table schema (argument). Behavior: - effective == 1 (default or explicit) keeps the serial path unchanged; no thread pool is created. - effective >= 2 with at least 2 splits runs splits through a ThreadPoolExecutor and assembles the final Table in the input splits' order (results collected by submission index). - effective < 1 (from either source) raises ValueError naming whichever source produced the value. Limit pushdown stays correct under parallelism via _RemainingRows, a thread-safe row-quota counter. Quota is pre-debited under a single lock so the combined output never exceeds self.limit, even if individual readers decode one extra batch after the quota is gone - that batch is simply dropped instead of being emitted. Reader resource handling matches the serial path: each worker uses try/finally to close its reader, and ThreadPoolExecutor's wait-on- exit guarantees every started reader is closed before the call returns, even when one worker raises. Other to_* methods (to_arrow_batch_reader, to_iterator, to_duckdb, to_ray, to_torch) are deliberately not touched in this round - their order-preserving / streaming semantics deserve a separate look. Tests cover: - _RemainingRows correctness under unbounded, bounded, zero-request, and 8-thread contention scenarios. - Append-only multi-partition: parallel via method argument matches serial byte-for-byte; parallel via table option also matches. - Priority matrix: method argument overrides option (both directions), option overrides default, explicit 1 keeps serial path. - PK merge-on-read multi-bucket: parallel + serial produce the same merged rows. - Limit + parallel: 10 repeated runs return exactly the configured row count. - Edge cases: empty splits with parallelism=4, parallelism exceeding split count, invalid method argument and invalid option value each raise ValueError with a source-specific message. - Reader error propagation: when one split's create_reader raises, the exception surfaces from to_pandas and sibling readers are cleaned up.
1 parent be19168 commit 3cf7e53

3 files changed

Lines changed: 631 additions & 4 deletions

File tree

paimon-python/pypaimon/common/options/core_options.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,18 @@ class CoreOptions:
449449
.with_description("Read batch size for any file format if it supports.")
450450
)
451451

452+
READ_PARALLELISM: ConfigOption[int] = (
453+
ConfigOptions.key("read.parallelism")
454+
.int_type()
455+
.default_value(1)
456+
.with_description(
457+
"Parallelism for reading splits within a single TableRead call. "
458+
"The value 1 (default) keeps reads serial. Values >= 2 enable a "
459+
"thread pool that reads splits concurrently and assembles the "
460+
"result in input order. Has no effect when fewer than 2 splits "
461+
"are passed.")
462+
)
463+
452464
ADD_COLUMN_BEFORE_PARTITION: ConfigOption[bool] = (
453465
ConfigOptions.key("add-column-before-partition")
454466
.boolean_type()
@@ -702,6 +714,9 @@ def local_cache_whitelist(self) -> str:
702714
def read_batch_size(self, default=None) -> int:
703715
return self.options.get(CoreOptions.READ_BATCH_SIZE, default or 1024)
704716

717+
def read_parallelism(self, default=None) -> int:
718+
return self.options.get(CoreOptions.READ_PARALLELISM, default)
719+
705720
def add_column_before_partition(self) -> bool:
706721
return self.options.get(CoreOptions.ADD_COLUMN_BEFORE_PARTITION, False)
707722

paimon-python/pypaimon/read/table_read.py

Lines changed: 211 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
# specific language governing permissions and limitations
1616
# under the License.
1717

18+
import threading
19+
from concurrent.futures import ThreadPoolExecutor, as_completed
1820
from typing import Any, Dict, Iterator, List, Optional
1921

2022
import pandas
@@ -32,6 +34,41 @@
3234
ROW_KIND_COLUMN = "_row_kind"
3335

3436

37+
class _RemainingRows:
38+
"""Thread-safe remaining-rows counter for parallel reads.
39+
40+
Row quota is pre-debited under a single lock so that any rows that
41+
threads commit to emit are guaranteed not to overshoot the limit, even
42+
if individual readers keep decoding one extra batch after the quota is
43+
exhausted.
44+
45+
When ``limit`` is None the counter is unbounded and ``try_consume``
46+
always returns the requested row count.
47+
"""
48+
49+
def __init__(self, limit: Optional[int]):
50+
self._lock = threading.Lock()
51+
self._remaining = limit # None == unlimited
52+
53+
def try_consume(self, requested: int) -> int:
54+
if self._remaining is None:
55+
return requested
56+
if requested <= 0:
57+
return 0
58+
with self._lock:
59+
if self._remaining <= 0:
60+
return 0
61+
allowed = min(requested, self._remaining)
62+
self._remaining -= allowed
63+
return allowed
64+
65+
def exhausted(self) -> bool:
66+
if self._remaining is None:
67+
return False
68+
with self._lock:
69+
return self._remaining <= 0
70+
71+
3572
class TableRead:
3673
"""Implementation of TableRead for native Python reading."""
3774

@@ -52,6 +89,7 @@ def __init__(
5289
self.include_row_kind = include_row_kind
5390
self.nested_name_paths = nested_name_paths
5491
self.limit = limit
92+
self._read_parallelism = self.table.options.read_parallelism()
5593

5694
def to_iterator(self, splits: List[Split]) -> Iterator:
5795
limit = self.limit
@@ -104,13 +142,34 @@ def _try_to_pad_batch_by_schema(batch: pyarrow.RecordBatch, target_schema):
104142

105143
return pyarrow.RecordBatch.from_arrays(columns, schema=target_schema)
106144

107-
def to_arrow(self, splits: List[Split]) -> Optional[pyarrow.Table]:
108-
batch_reader = self.to_arrow_batch_reader(splits)
145+
def to_arrow(
146+
self,
147+
splits: List[Split],
148+
parallelism: Optional[int] = None,
149+
) -> Optional[pyarrow.Table]:
150+
"""Read ``splits`` into a single arrow ``Table``.
109151
152+
Args:
153+
splits: scan-plan splits returned from a ``TableScan``.
154+
parallelism: optional runtime override of the
155+
``read.parallelism`` table option. ``None`` (default) falls
156+
back to the table option; a non-None value temporarily
157+
overrides it for this call. ``1`` keeps reads serial;
158+
``>= 2`` enables a thread pool that reads splits
159+
concurrently and assembles the final table in input order.
160+
Must be ``>= 1``.
161+
"""
162+
# TODO: default read.parallelism to min(splits, cpu_count()) once stable
163+
effective = self._resolve_parallelism(parallelism)
110164
schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
111165
if self.include_row_kind:
112166
schema = self._add_row_kind_to_schema(schema)
113167

168+
if self._should_run_parallel(splits, effective):
169+
return self._to_arrow_parallel(splits, schema, effective)
170+
171+
batch_reader = self.to_arrow_batch_reader(splits)
172+
114173
table_list = []
115174
for batch in iter(batch_reader.read_next_batch, None):
116175
if batch.num_rows == 0:
@@ -183,6 +242,146 @@ def _arrow_batch_generator(self, splits: List[Split], schema: pyarrow.Schema) ->
183242
finally:
184243
reader.close()
185244

245+
def _resolve_parallelism(self, runtime: Optional[int]) -> int:
246+
"""Pick the effective parallelism and reject illegal values.
247+
248+
Priority: explicit ``parallelism`` argument > ``read.parallelism``
249+
table option > built-in default of 1. The validation message names
250+
whichever source produced the offending value, so users know where
251+
to fix it.
252+
"""
253+
if runtime is not None:
254+
value = runtime
255+
source = "parallelism"
256+
else:
257+
value = self._read_parallelism
258+
source = "read.parallelism"
259+
if value < 1:
260+
raise ValueError(f"{source} must be >= 1, got {value}")
261+
return value
262+
263+
def _should_run_parallel(
264+
self,
265+
splits: List[Split],
266+
effective: int,
267+
) -> bool:
268+
"""Decide whether to take the parallel read path.
269+
270+
``effective == 1`` falls back to the serial path (no thread pool
271+
overhead, no behavior change). A single split is never
272+
parallelized since there is nothing to fan out across.
273+
"""
274+
return effective >= 2 and len(splits) >= 2
275+
276+
def _to_arrow_parallel(
277+
self,
278+
splits: List[Split],
279+
schema: pyarrow.Schema,
280+
effective: int,
281+
) -> pyarrow.Table:
282+
"""Read ``splits`` concurrently and assemble the result in input order.
283+
284+
Each split is read in its own worker thread; row quota for ``limit``
285+
is shared through :class:`_RemainingRows` so the combined output
286+
never exceeds ``self.limit`` rows. Per-split batches are collected
287+
by submission index, so the merged table preserves the order of the
288+
input ``splits`` list.
289+
"""
290+
remaining_state = _RemainingRows(self.limit)
291+
results: List[Optional[List[pyarrow.RecordBatch]]] = [None] * len(splits)
292+
workers = min(effective, len(splits))
293+
with ThreadPoolExecutor(
294+
max_workers=workers,
295+
thread_name_prefix="pypaimon-read",
296+
) as executor:
297+
futures = {
298+
executor.submit(
299+
self._read_one_split_to_batches,
300+
split,
301+
schema,
302+
remaining_state,
303+
): idx
304+
for idx, split in enumerate(splits)
305+
}
306+
for fut in as_completed(futures):
307+
results[futures[fut]] = fut.result()
308+
309+
table_list: List[pyarrow.RecordBatch] = []
310+
for split_batches in results:
311+
if split_batches is None:
312+
continue
313+
for batch in split_batches:
314+
if batch.num_rows == 0:
315+
continue
316+
table_list.append(self._try_to_pad_batch_by_schema(batch, schema))
317+
318+
if not table_list:
319+
return pyarrow.Table.from_arrays(
320+
[pyarrow.array([], type=field.type) for field in schema],
321+
schema=schema,
322+
)
323+
return pyarrow.Table.from_batches(table_list)
324+
325+
def _read_one_split_to_batches(
326+
self,
327+
split: Split,
328+
schema: pyarrow.Schema,
329+
remaining_state: _RemainingRows,
330+
) -> List[pyarrow.RecordBatch]:
331+
"""Read a single split into arrow batches under soft-stop control.
332+
333+
Row quota is debited against the shared ``remaining_state``; once a
334+
request returns 0, the worker stops emitting further batches. The
335+
reader is always closed via ``finally``.
336+
"""
337+
chunk_size = 65536
338+
out: List[pyarrow.RecordBatch] = []
339+
reader = self._create_split_read(split).create_reader()
340+
try:
341+
if isinstance(reader, RecordBatchReader):
342+
for batch in iter(reader.read_arrow_batch, None):
343+
allowed = remaining_state.try_consume(batch.num_rows)
344+
if allowed == 0:
345+
break
346+
if allowed < batch.num_rows:
347+
batch = batch.slice(0, allowed)
348+
if self.include_row_kind:
349+
batch = self._add_row_kind_column_to_batch(batch, "+I")
350+
out.append(batch)
351+
if remaining_state.exhausted():
352+
break
353+
else:
354+
row_tuple_chunk: List[tuple] = []
355+
row_kind_chunk: List[str] = []
356+
stop = False
357+
while not stop:
358+
row_iterator = reader.read_batch()
359+
if row_iterator is None:
360+
break
361+
for row in iter(row_iterator.next, None):
362+
if not isinstance(row, OffsetRow):
363+
raise TypeError(
364+
f"Expected OffsetRow, but got {type(row).__name__}")
365+
if remaining_state.try_consume(1) == 0:
366+
stop = True
367+
break
368+
row_tuple_chunk.append(
369+
row.row_tuple[row.offset: row.offset + row.arity])
370+
if self.include_row_kind:
371+
row_kind_chunk.append(row.get_row_kind().to_string())
372+
373+
if len(row_tuple_chunk) >= chunk_size:
374+
out.append(self._convert_rows_to_arrow_batch_with_row_kind(
375+
row_tuple_chunk, row_kind_chunk, schema))
376+
row_tuple_chunk = []
377+
row_kind_chunk = []
378+
if row_tuple_chunk:
379+
out.append(self._convert_rows_to_arrow_batch_with_row_kind(
380+
row_tuple_chunk, row_kind_chunk, schema))
381+
finally:
382+
reader.close()
383+
return out
384+
186385
def _convert_rows_to_arrow_batch_with_row_kind(
187386
self,
188387
row_tuples: List[tuple],
@@ -216,8 +415,16 @@ def _add_row_kind_column_to_batch(
216415
columns = [row_kind_array] + [batch.column(i) for i in range(batch.num_columns)]
217416
return pyarrow.RecordBatch.from_arrays(columns, schema=new_schema)
218417

219-
def to_pandas(self, splits: List[Split]) -> pandas.DataFrame:
220-
arrow_table = self.to_arrow(splits)
418+
def to_pandas(
419+
self,
420+
splits: List[Split],
421+
parallelism: Optional[int] = None,
422+
) -> pandas.DataFrame:
423+
"""Read ``splits`` into a pandas ``DataFrame``.
424+
425+
See :meth:`to_arrow` for the semantics of ``parallelism``.
426+
"""
427+
arrow_table = self.to_arrow(splits, parallelism=parallelism)
221428
return arrow_table.to_pandas()
222429

223430
def to_duckdb(self, splits: List[Split], table_name: str,

0 commit comments

Comments
 (0)