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 4c74241db0 [python] Reduce chunk-shuffle planning memory (#10232)
4c74241db0 is described below
commit 4c74241db0c81455ba937eb84ad10ddfb122c00a
Author: chaoyang <[email protected]>
AuthorDate: Sat Oct 3 17:05:05 2026 +0800
[python] Reduce chunk-shuffle planning memory (#10232)
---
.../read/scanner/chunk_shuffle_split_generator.py | 53 +++++++++-------
.../scanner/chunk_shuffle_split_generator_test.py | 70 ++++++++++++++++++++++
2 files changed, 101 insertions(+), 22 deletions(-)
diff --git
a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
index 66b6e1867b..b34d6c4a77 100644
--- a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
@@ -178,6 +178,8 @@ class _Chunk:
subclass-specific payload (file segments for append, aligned-group
segments for data evolution).
"""
+ __slots__ = ('partition', 'bucket', 'segments')
+
partition: GenericRow
bucket: int
segments: List[Any]
@@ -190,13 +192,14 @@ class
ChunkShuffleSplitGeneratorBase(AbstractSplitGenerator):
1. Stable-sort entries (key from :meth:`_sort_key`) so manifest-read
parallelism cannot bleed into the output.
2. Group by (partition, bucket); iterate groups in sorted-key order.
- 3. Per group, call :meth:`_slice_group_into_chunks` to produce a list
- of segment lists — one segment list per chunk.
+ 3. Per group, iterate :meth:`_slice_group_into_chunks` to produce
+ one segment list per chunk.
4. Wrap each chunk with its (partition, bucket) into ``_Chunk``,
concatenate across groups.
5. ``random.Random(seed).shuffle`` all chunks.
6. If sharded, take this worker's slice via balanced
``_compute_shard_range``.
- 7. Map each chunk through :meth:`_chunk_to_split`.
+ 7. Consume chunks through :meth:`_chunk_to_split`, releasing their
+ temporary descriptors as splits are created.
Subclasses implement the three abstract hooks. Chunks ride on existing
reader wrapper (``IndexedSplit``).
@@ -219,7 +222,7 @@ class
ChunkShuffleSplitGeneratorBase(AbstractSplitGenerator):
self.chunk_size = chunk_size
def create_splits(self, file_entries: List[ManifestEntry]) -> List[Split]:
- """TODO: Lazily initialize DataSplits to avoid creating too many
objects."""
+ """Shuffle chunk descriptors, then materialize only this shard's
splits."""
if not file_entries:
return []
@@ -259,17 +262,23 @@ class
ChunkShuffleSplitGeneratorBase(AbstractSplitGenerator):
start, end = self._compute_shard_range(len(all_chunks))
all_chunks = all_chunks[start:end]
- return [self._chunk_to_split(c) for c in all_chunks]
+ # Pop in shuffle order so temporary segments/ranges can be freed while
+ # the result grows, instead of retaining both complete representations.
+ all_chunks.reverse()
+ splits: List[Split] = []
+ while all_chunks:
+ splits.append(self._chunk_to_split(all_chunks.pop()))
+ return splits
@abstractmethod
def _sort_key(self, entry: ManifestEntry):
"""Return a comparable, deterministic key for stable sort."""
@abstractmethod
- def _slice_group_into_chunks(self, entries: List[ManifestEntry]) ->
List[List[Any]]:
+ def _slice_group_into_chunks(self, entries: List[ManifestEntry]) ->
Iterator[List[Any]]:
"""Cut one (partition, bucket) group into chunks of segments.
- Each returned inner list represents one chunk; segment shape is
+ Each yielded list represents one chunk; segment shape is
subclass-defined.
"""
@@ -333,6 +342,8 @@ class
ChunkShuffleSplitGeneratorBase(AbstractSplitGenerator):
@dataclass
class _FileSegment:
"""Visible file-local row ranges from a data file inside one chunk."""
+ __slots__ = ('file', 'row_ranges', 'live_row_count')
+
file: DataFileMeta
row_ranges: List[Range]
live_row_count: int
@@ -350,13 +361,12 @@ class
AppendChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
def _slice_group_into_chunks(
self, entries: List[ManifestEntry]
- ) -> List[List[_FileSegment]]:
+ ) -> Iterator[List[_FileSegment]]:
"""Cut a (partition, bucket) group into chunks of at most
``self.chunk_size`` live rows. ``chunk_size`` is a hard upper bound:
the last chunk may be smaller, but no chunk exceeds it after DV
filtering.
"""
- chunks: List[List[_FileSegment]] = []
current: List[_FileSegment] = []
current_rows = 0
@@ -374,7 +384,7 @@ class
AppendChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
while True:
avail = self.chunk_size - current_rows
if avail <= 0:
- chunks.append(current)
+ yield current
current = []
current_rows = 0
avail = self.chunk_size
@@ -394,9 +404,7 @@ class
AppendChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
current_rows += physical_slice.live_row_count
if current:
- chunks.append(current)
-
- return chunks
+ yield current
def _chunk_to_split(self, chunk: _Chunk) -> Split:
files: List[DataFileMeta] = []
@@ -459,6 +467,8 @@ class _AlignedGroupSegment:
group's row_id range lands in this chunk. ``row_ranges`` are the
inclusive global row-id ranges of visible rows this segment owns.
"""
+ __slots__ = ('files', 'row_ranges', 'live_row_count')
+
files: List[DataFileMeta]
row_ranges: List[Range]
live_row_count: int
@@ -494,18 +504,19 @@ class
DataEvolutionChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
def _slice_group_into_chunks(
self, entries: List[ManifestEntry]
- ) -> List[List[_AlignedGroupSegment]]:
- files = [e.file for e in entries]
+ ) -> Iterator[List[_AlignedGroupSegment]]:
# (Range, [files]) pairs sorted by row_id — see helper docstring.
- aligned_groups = self._split_by_row_id_with_range(files)
+ aligned_groups = self._split_by_row_id_with_range([e.file for e in
entries])
+ # Release consumed group descriptors as chunks are yielded to the
caller.
+ aligned_groups.reverse()
- chunks: List[List[_AlignedGroupSegment]] = []
current: List[_AlignedGroupSegment] = []
current_rows = 0
partition = entries[0].partition
bucket = entries[0].bucket
- for group_range, group_files in aligned_groups:
+ while aligned_groups:
+ group_range, group_files = aligned_groups.pop()
anchor = None
deletion_file = None
if self.deletion_files_map:
@@ -541,7 +552,7 @@ class
DataEvolutionChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
while True:
avail = self.chunk_size - current_rows
if avail <= 0:
- chunks.append(current)
+ yield current
current = []
current_rows = 0
avail = self.chunk_size
@@ -559,9 +570,7 @@ class
DataEvolutionChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
current_rows += physical_slice.live_row_count
if current:
- chunks.append(current)
-
- return chunks
+ yield current
def _chunk_to_split(self, chunk: _Chunk) -> Split:
segments = chunk.segments
diff --git
a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
index 4feaa940a6..973799f3a9 100644
--- a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
@@ -23,6 +23,7 @@ workers together cover the data exactly once.
"""
import os
+import random
import shutil
import tempfile
import unittest
@@ -165,6 +166,75 @@ def _bitmap_deletion_vector(*positions):
return deletion_vector
+class ChunkShuffleOrderCompatibilityTest(unittest.TestCase):
+
+ def _check_order(self, factory, entries, ordered_signatures):
+ first_dv = DeletionFile('dv.index', 0, 20, cardinality=1)
+ second_dv = DeletionFile('dv.index', 20, 20, cardinality=None)
+ deletion_files = {((None,), 0): {'a.parquet': first_dv, 'b.parquet':
second_dv}}
+ vectors = {
+ first_dv: _bitmap_deletion_vector(1),
+ second_dv: _bitmap_deletion_vector(0, 2),
+ }
+ for seed in (0, 7, 42):
+ expected = list(ordered_signatures)
+ random.Random(seed).shuffle(expected)
+ # Include unsharded reads and empty workers (four chunks, six
workers).
+ for workers in (1, 3, 6):
+ combined = []
+ for worker in range(workers):
+ with self.subTest(seed=seed, workers=workers,
worker=worker):
+ gen = factory(seed=seed, chunk_size=3,
deletion_files_map=deletion_files)
+ if workers > 1:
+ gen.with_shard(worker, workers)
+ with patch(
+
'pypaimon.read.scanner.chunk_shuffle_split_generator.DeletionVector.read',
+ side_effect=lambda _, dv: vectors[dv],
+ ) as read:
+ # Manifest input order must not affect the shuffle.
+ splits = gen.create_splits(list(reversed(entries)))
+ self.assertEqual(read.call_count, 2)
+ signatures = [_split_signature(split) for split in
splits]
+ size, remainder = divmod(len(expected), workers)
+ start = worker * size + min(worker, remainder)
+ end = start + size + (worker < remainder)
+ self.assertEqual(signatures, expected[start:end])
+ combined.extend(signatures)
+ self.assertEqual(combined, expected)
+
+ def test_append_order_across_partitions_files_and_deletions(self):
+ entries = [
+ _mock_entry([None], 0, 'a.parquet', 5),
+ _mock_entry([None], 0, 'b.parquet', 4),
+ _mock_entry(['p'], 2, 'c.parquet', 5),
+ ]
+ # Non-null partitions sort first. The second null-partition chunk
+ # spans files: b's file-local offsets 1 and 3 follow a's five rows.
+ expected = [
+ (('p',), 2, ('c.parquet',), ((0, 2),)),
+ (('p',), 2, ('c.parquet',), ((3, 4),)),
+ ((None,), 0, ('a.parquet',), ((0, 0), (2, 3))),
+ ((None,), 0, ('a.parquet', 'b.parquet'), ((4, 4), (6, 6), (8, 8))),
+ ]
+ self._check_order(_make_generator, entries, expected)
+
+ def
test_data_evolution_order_preserves_blob_siblings_and_row_id_gaps(self):
+ entries = [
+ _mock_de_entry([None], 0, 'a.parquet', 1000, 5),
+ _mock_de_entry([None], 0, 'a.blob', 1000, 5),
+ _mock_de_entry([None], 0, 'b.parquet', 2000, 4),
+ _mock_de_entry(['p'], 2, 'c.parquet', 3000, 5),
+ ]
+ expected = [
+ (('p',), 2, ('c.parquet',), ((3000, 3002),)),
+ (('p',), 2, ('c.parquet',), ((3003, 3004),)),
+ ((None,), 0, ('a.blob', 'a.parquet'), ((1000, 1000), (1002,
1003))),
+ ((None,), 0, ('a.blob', 'a.parquet', 'b.parquet'),
+ ((1004, 1004), (2001, 2001), (2003, 2003))),
+ ]
+ self._check_order(_make_de_generator, entries, expected)
+
+
class LiveRowRangeSlicerTest(unittest.TestCase):
def test_slices_by_live_rows_and_absorbs_deletion_runs(self):