This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 8dc607336d [python] Support read.parallelism=auto for single-process
reads (#8637)
8dc607336d is described below
commit 8dc607336d1e1494aecd38b06a6d1d32e02305c8
Author: chaoyang <[email protected]>
AuthorDate: Thu Jul 16 22:10:57 2026 +0800
[python] Support read.parallelism=auto for single-process reads (#8637)
---
.../pypaimon/common/options/core_options.py | 13 +--
paimon-python/pypaimon/read/table_read.py | 36 +++++----
.../pypaimon/tests/reader_parallel_test.py | 92 ++++++++++++++++++++--
3 files changed, 112 insertions(+), 29 deletions(-)
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index dfcb3a6861..b21a74a798 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -854,13 +854,14 @@ class CoreOptions:
READ_PARALLELISM: ConfigOption[int] = (
ConfigOptions.key("read.parallelism")
.int_type()
- .default_value(1)
+ .no_default_value()
.with_description(
"Parallelism for reading splits within a single TableRead call. "
- "The value 1 (default) keeps reads serial. Values >= 2 enable a "
- "thread pool that reads splits concurrently and assembles the "
- "result in input order. Has no effect when fewer than 2 splits "
- "are passed.")
+ "When unset (the default), reads auto-scale to "
+ "min(number of splits, CPU count). Set to 1 to force serial "
+ "reads, or to a specific value >= 1 to cap the thread pool that "
+ "reads splits concurrently and assembles the result in input "
+ "order. Has no effect when fewer than 2 splits are passed.")
)
ADD_COLUMN_BEFORE_PARTITION: ConfigOption[bool] = (
@@ -1349,7 +1350,7 @@ class CoreOptions:
def read_batch_size(self, default=None) -> int:
return self.options.get(CoreOptions.READ_BATCH_SIZE, default or 1024)
- def read_parallelism(self, default=None) -> int:
+ def read_parallelism(self, default=None) -> Optional[int]:
return self.options.get(CoreOptions.READ_PARALLELISM, default)
def add_column_before_partition(self) -> bool:
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index d95dfe556b..89ca539b73 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import os
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict, Iterator, List, Optional
@@ -180,11 +181,14 @@ class TableRead:
splits: scan-plan splits returned from a ``TableScan``.
parallelism: optional runtime override of the
``read.parallelism`` table option. ``None`` (default) falls
- back to the table option; a non-None value temporarily
- overrides it for this call. ``1`` keeps reads serial;
- ``>= 2`` enables a thread pool that reads splits
- concurrently and assembles the final table in input order.
- Must be ``>= 1``.
+ back to the table option; when that is also unset the read
+ auto-scales to ``min(number of splits, CPU count)``. ``1``
+ keeps reads serial; ``>= 2`` caps the thread pool that reads
+ splits concurrently and assembles the final table in input
+ order. Must be ``>= 1``. Note that with ``>= 2`` (or auto)
+ and a ``limit`` set, the returned rows are an arbitrary
+ subset of the requested size, since which splits fill the row
+ quota first is non-deterministic.
blob_parallelism: number of threads for concurrent blob reads
within each batch. ``None`` or ``1`` (default) reads blobs
serially; ``>= 2`` uses a thread pool with ``pread`` for
@@ -195,8 +199,7 @@ class TableRead:
shrunk to stay within it.
"""
effective_bp = self._resolve_blob_parallelism(blob_parallelism)
- # TODO: default read.parallelism to min(splits, cpu_count()) once
stable
- effective = self._resolve_parallelism(parallelism)
+ effective = self._resolve_parallelism(parallelism, len(splits))
schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
if self.include_row_kind:
schema = self._add_row_kind_to_schema(schema)
@@ -281,20 +284,23 @@ class TableRead:
finally:
reader.close()
- def _resolve_parallelism(self, runtime: Optional[int]) -> int:
+ def _resolve_parallelism(self, runtime: Optional[int], num_splits: int) ->
int:
"""Pick the effective parallelism and reject illegal values.
Priority: explicit ``parallelism`` argument > ``read.parallelism``
- table option > built-in default of 1. The validation message names
- whichever source produced the offending value, so users know where
- to fix it.
+ table option > auto. When neither the argument nor the option is set
+ the read auto-scales to ``min(num_splits, CPU count)``. A value >= 1
+ caps the thread pool; ``1`` forces serial reads. The validation
+ message names whichever source produced the offending value.
"""
if runtime is not None:
- value = runtime
- source = "parallelism"
+ value, source = runtime, "parallelism"
+ elif self._read_parallelism is not None:
+ value, source = self._read_parallelism, "read.parallelism"
else:
- value = self._read_parallelism
- source = "read.parallelism"
+ # os.cpu_count() may return None on exotic platforms; fall back
+ # to 1. min() with num_splits avoids more workers than work.
+ return max(1, min(num_splits, os.cpu_count() or 1))
if value < 1:
raise ValueError(f"{source} must be >= 1, got {value}")
return value
diff --git a/paimon-python/pypaimon/tests/reader_parallel_test.py
b/paimon-python/pypaimon/tests/reader_parallel_test.py
index 9e9897fb50..ea0c3d8f61 100644
--- a/paimon-python/pypaimon/tests/reader_parallel_test.py
+++ b/paimon-python/pypaimon/tests/reader_parallel_test.py
@@ -19,6 +19,7 @@ import os
import shutil
import tempfile
import threading
+import types
import unittest
from unittest import mock
@@ -28,6 +29,50 @@ from pypaimon import CatalogFactory, Schema
from pypaimon.read.table_read import TableRead, _RemainingRows
+class ResolveParallelismTest(unittest.TestCase):
+ """Pure unit tests for parallelism resolution — no Paimon table needed.
+
+ ``_resolve_parallelism`` only reads ``self._read_parallelism``, so a
+ lightweight stand-in with that attribute is enough to exercise it.
+ """
+
+ @staticmethod
+ def _resolve(runtime, num_splits, option=None):
+ stub = types.SimpleNamespace(_read_parallelism=option)
+ return TableRead._resolve_parallelism(stub, runtime, num_splits)
+
+ def test_runtime_int_passthrough(self):
+ self.assertEqual(self._resolve(4, 10), 4)
+ self.assertEqual(self._resolve(1, 10), 1)
+
+ def test_runtime_overrides_option(self):
+ self.assertEqual(self._resolve(2, 10, option=8), 2)
+
+ def test_option_used_when_no_runtime(self):
+ self.assertEqual(self._resolve(None, 10, option=8), 8)
+
+ def test_none_resolves_to_auto_min_splits_cpu(self):
+ cpu = os.cpu_count() or 1
+ # Neither runtime nor option set => auto.
+ # More splits than CPUs => capped at CPU count.
+ self.assertEqual(self._resolve(None, cpu + 100), cpu)
+ # Fewer splits than CPUs => capped at split count.
+ self.assertEqual(self._resolve(None, 1), 1)
+ # Zero splits never yields a sub-1 worker count.
+ self.assertEqual(self._resolve(None, 0), 1)
+
+ def test_invalid_runtime_raises_with_source(self):
+ with self.assertRaises(ValueError) as ctx:
+ self._resolve(0, 10)
+ self.assertIn("parallelism", str(ctx.exception))
+ self.assertNotIn("read.parallelism", str(ctx.exception))
+
+ def test_invalid_option_raises_with_source(self):
+ with self.assertRaises(ValueError) as ctx:
+ self._resolve(None, 10, option=0)
+ self.assertIn("read.parallelism", str(ctx.exception))
+
+
class RemainingRowsTest(unittest.TestCase):
"""Pure unit tests for the row-quota counter — no Paimon table needed."""
@@ -109,11 +154,15 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
'dt': dts,
}, schema=cls.pa_schema)
- # Default table — read.parallelism unset (defaults to 1, i.e. serial).
+ # Default table — read.parallelism unset => auto (parallel when
+ # there are >= 2 splits and >= 2 CPUs).
cls.table = cls._build_table('append_parallel_default', None, data)
# Option-set table — read.parallelism=4 baked into the table schema.
cls.table_opt_4 = cls._build_table(
'append_parallel_opt4', {'read.parallelism': '4'}, data)
+ # Option forcing serial reads.
+ cls.table_opt_1 = cls._build_table(
+ 'append_parallel_opt1', {'read.parallelism': '1'}, data)
@classmethod
def _build_table(cls, name, options, data):
@@ -152,7 +201,7 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
rb = self.table.new_read_builder()
splits = self._scan_splits(rb)
read = rb.new_read()
- serial = read.to_arrow(splits)
+ serial = read.to_arrow(splits, parallelism=1)
parallel = read.to_arrow(splits, parallelism=4)
# Same split order preserved => byte-identical tables.
self.assertEqual(serial, parallel)
@@ -169,13 +218,38 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
splits_serial = self._scan_splits(rb_serial)
splits_parallel = self._scan_splits(rb_parallel)
- serial_df = rb_serial.new_read().to_pandas(splits_serial) \
+ serial_df = rb_serial.new_read().to_pandas(splits_serial,
parallelism=1) \
.sort_values('user_id').reset_index(drop=True)
# No explicit parallelism — must pick up read.parallelism=4 from the
table option.
parallel_df = rb_parallel.new_read().to_pandas(splits_parallel) \
.sort_values('user_id').reset_index(drop=True)
self.assertTrue(serial_df.equals(parallel_df))
+ def test_default_none_runs_auto_parallel(self):
+ # No runtime arg and no table option => auto. With >= 2 splits on a
+ # multi-core box this takes the parallel fan-out path; the result
+ # must still match a forced-serial read row for row.
+ read = self.table.new_read_builder().new_read()
+ splits = self._scan_splits(self.table.new_read_builder())
+ self.assertGreaterEqual(len(splits), 2)
+ serial = read.to_arrow(splits, parallelism=1)
+ auto = read.to_arrow(splits)
+ self.assertEqual(serial, auto)
+ self.assertEqual(auto.num_rows, self.expected_rows)
+
+ def test_default_none_auto_takes_parallel_path_when_multicore(self):
+ read = self.table.new_read_builder().new_read()
+ splits = self._scan_splits(self.table.new_read_builder())
+ # Only assert fan-out where the box actually has >= 2 CPUs, else
+ # auto legitimately resolves to 1 and stays serial.
+ if (os.cpu_count() or 1) < 2:
+ self.skipTest("single-core runner: auto resolves to serial")
+ with mock.patch.object(
+ read, '_to_arrow_parallel', wraps=read._to_arrow_parallel
+ ) as spy:
+ read.to_arrow(splits)
+ spy.assert_called_once()
+
# ------------------------------------------------------------------
# Priority: method arg > table option > built-in default.
# ------------------------------------------------------------------
@@ -190,9 +264,9 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
read.to_arrow(splits, parallelism=1)
def test_method_arg_overrides_option_to_parallel(self):
- # option=1 (default) but caller passes 4: should enable parallelism.
- read = self.table.new_read_builder().new_read()
- splits = self._scan_splits(self.table.new_read_builder())
+ # option=1 (forces serial) but caller passes 4: should enable
parallelism.
+ read = self.table_opt_1.new_read_builder().new_read()
+ splits = self._scan_splits(self.table_opt_1.new_read_builder())
with mock.patch.object(
read, '_to_arrow_parallel', wraps=read._to_arrow_parallel
) as spy:
@@ -204,8 +278,10 @@ class ParallelReaderAppendOnlyTest(unittest.TestCase):
# Boundary / invalid value handling.
# ------------------------------------------------------------------
- def test_parallelism_one_equals_serial(self):
- rb = self.table.new_read_builder()
+ def test_option_one_equals_serial(self):
+ # Table option read.parallelism=1 must behave exactly like an
+ # explicit parallelism=1 (serial) read.
+ rb = self.table_opt_1.new_read_builder()
splits = self._scan_splits(rb)
read = rb.new_read()
self.assertEqual(read.to_arrow(splits),