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 872f7e8bae [python][ray] Reduce Blob request QPS with URI affinity 
(#9130)
872f7e8bae is described below

commit 872f7e8bae83e8cb400303ee703269a4d125053b
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Sep 23 22:46:07 2026 +0800

    [python][ray] Reduce Blob request QPS with URI affinity (#9130)
---
 docs/docs/pypaimon/multimodal-reading.md           |  11 +
 paimon-python/pypaimon/ray/ray_paimon.py           | 205 ++++++++++++++++-
 .../pypaimon/tests/multimodal_table_test.py        |  21 +-
 .../pypaimon/tests/ray_blob_affinity_test.py       | 250 +++++++++++++++++++++
 4 files changed, 475 insertions(+), 12 deletions(-)

diff --git a/docs/docs/pypaimon/multimodal-reading.md 
b/docs/docs/pypaimon/multimodal-reading.md
index fd3685ba27..e4b02f70dc 100644
--- a/docs/docs/pypaimon/multimodal-reading.md
+++ b/docs/docs/pypaimon/multimodal-reading.md
@@ -497,3 +497,14 @@ Best practices:
 - Use business keys or application columns when writing inference or training
   outputs back to a table.
 - Reorder `take_row_ids` results client-side when input order matters.
+
+### BLOB URI affinity
+
+For small inference batches, `map_with_blobs(..., batch_size=32,
+blob_uri_affinity=True)` sorts scalar BLOB descriptors by URI and offset to
+coalesce reads across inference batches. This distributed sort may reorder 
rows;
+use it when fewer storage requests outweigh the shuffle cost. `prefetch_bytes`
+defaults to 64 MiB per payload window, except for an oversized inference batch.
+Descriptor batches contain up to `max(1024, batch_size)` rows; the UDF still
+receives at most `batch_size` rows. MAP and ARRAY BLOB columns use the default
+mapping path without URI affinity.
diff --git a/paimon-python/pypaimon/ray/ray_paimon.py 
b/paimon-python/pypaimon/ray/ray_paimon.py
index 58358fe81c..f14bc5a0ae 100644
--- a/paimon-python/pypaimon/ray/ray_paimon.py
+++ b/paimon-python/pypaimon/ray/ray_paimon.py
@@ -26,7 +26,9 @@ Usage::
     write_paimon(ds, "db.table", catalog_options={"warehouse": "/path"})
 """
 
+import hashlib
 import importlib
+import uuid
 from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
 
 from pypaimon.common.predicate import Predicate
@@ -151,6 +153,8 @@ def map_with_blobs(
     array_blob_columns=None,
     parallelism: int = 64,
     batch_size: Optional[int] = 1024,
+    blob_uri_affinity: bool = False,
+    prefetch_bytes: int = 64 * 1024 * 1024,
     fn_kwargs: Optional[Dict[str, Any]] = None,
     ray_remote_args: Optional[Dict[str, Any]] = None,
     **map_args,
@@ -167,7 +171,10 @@ def map_with_blobs(
     Supply ``map_blob_columns`` and ``array_blob_columns`` when transforms
     erase the source metadata and Arrow nested types. The table method
     supplies this information automatically.
-    Tune ``batch_size`` for BLOB size and worker memory.
+    Tune ``batch_size`` for BLOB size and worker memory. Set
+    ``blob_uri_affinity=True`` to shuffle descriptors by URI and offset before
+    reading. This lets each worker coalesce adjacent ranges across multiple
+    ``fn`` batches, bounded by ``prefetch_bytes``.
     """
     _require_ray_data()
 
@@ -183,6 +190,14 @@ def map_with_blobs(
         raise ValueError("parallelism must be at least 1, got 
{}".format(parallelism))
     if batch_size is not None and batch_size < 1:
         raise ValueError("batch_size must be at least 1, got 
{}".format(batch_size))
+    if not isinstance(blob_uri_affinity, bool):
+        raise ValueError("blob_uri_affinity must be a boolean")
+    if blob_uri_affinity and batch_size is None:
+        raise ValueError("blob_uri_affinity requires batch_size")
+    if (isinstance(prefetch_bytes, bool)
+            or not isinstance(prefetch_bytes, int)
+            or prefetch_bytes < 1):
+        raise ValueError("prefetch_bytes must be a positive integer")
 
     resolved_file_io = file_io
     if resolved_file_io is None:
@@ -198,7 +213,7 @@ def map_with_blobs(
 
     kwargs = dict(map_args)
     kwargs["batch_format"] = "pyarrow"
-    if batch_size is not None:
+    if batch_size is not None and not blob_uri_affinity:
         kwargs.setdefault("batch_size", batch_size)
     if ray_remote_args is not None:
         _set_map_batches_remote_args(dataset, kwargs, ray_remote_args)
@@ -225,19 +240,90 @@ def map_with_blobs(
             or (map_blob_columns | array_blob_columns) - all_blob):
         raise ValueError("Nested BLOB columns must be disjoint subsets of 
all_blob_columns.")
 
+    mapper = _map_blob_batch
+    affinity_cols = []
+    if blob_uri_affinity:
+        if (map_blob_columns | array_blob_columns).intersection(blob_cols):
+            raise ValueError("blob_uri_affinity supports scalar BLOB columns 
only")
+        dataset, affinity_cols = _cluster_by_blob_uri(dataset, blob_cols)
+        mapper = _map_blob_affinity_block
+        # Batch descriptors separately from inference; Ray requires an explicit
+        # outer batch size when GPU resources are requested.
+        kwargs["batch_size"] = max(1024, batch_size)
+
+    mapper_kwargs = {
+        "file_io": resolved_file_io,
+        "blob_cols": blob_cols,
+        "all_blob_cols": list(all_blob_cols),
+        "map_blob_cols": list(map_blob_columns),
+        "array_blob_cols": list(array_blob_columns),
+        "parallelism": parallelism,
+        "fn": fn,
+        "fn_kwargs": dict(fn_kwargs or {}),
+    }
+    if blob_uri_affinity:
+        mapper_kwargs.update({
+            "fn_batch_size": batch_size,
+            "prefetch_bytes": prefetch_bytes,
+            "affinity_cols": affinity_cols,
+        })
     return dataset.map_batches(
-        _map_blob_batch,
+        mapper, fn_kwargs=mapper_kwargs, **kwargs)
+
+
+def _cluster_by_blob_uri(dataset, blob_cols):
+    token = uuid.uuid4().hex
+    key_col = "__paimon_blob_key_{}".format(token)
+    offset_col = "__paimon_blob_offset_{}".format(token)
+    with_keys = dataset.map_batches(
+        _append_blob_affinity_keys,
         fn_kwargs={
-            "file_io": resolved_file_io,
             "blob_cols": blob_cols,
-            "all_blob_cols": list(all_blob_cols),
-            "map_blob_cols": list(map_blob_columns),
-            "array_blob_cols": list(array_blob_columns),
-            "parallelism": parallelism,
-            "fn": fn,
-            "fn_kwargs": dict(fn_kwargs or {}),
+            "key_col": key_col,
+            "offset_col": offset_col,
         },
-        **kwargs)
+        batch_format="pyarrow",
+        zero_copy_batch=True,
+    )
+    return with_keys.sort([key_col, offset_col]), [key_col, offset_col]
+
+
+def _append_blob_affinity_keys(batch, blob_cols, key_col, offset_col):
+    import pyarrow as pa
+    from pypaimon.table.row.blob import BlobDescriptorSerde
+
+    empty_key = b"\0" * 16
+    uri_keys = {}
+    keys = []
+    offsets = []
+    columns = [batch.column(name) for name in blob_cols]
+    if any(not (pa.types.is_binary(column.type) or 
pa.types.is_large_binary(column.type)
+                or pa.types.is_null(column.type)) for column in columns):
+        raise ValueError("blob_uri_affinity supports scalar BLOB columns only")
+    for row in range(batch.num_rows):
+        descriptor = None
+        for column in columns:
+            value = column[row]
+            raw = value.as_py() if value.is_valid else None
+            if raw is not None and BlobDescriptorSerde.is_descriptor(raw):
+                descriptor = BlobDescriptorSerde.deserialize(raw)
+                break
+        if descriptor is not None:
+            key = uri_keys.get(descriptor.uri)
+            if key is None:
+                key = hashlib.blake2b(
+                    descriptor.uri.encode("utf-8"), digest_size=16).digest()
+                uri_keys[descriptor.uri] = key
+            keys.append(key)
+            offsets.append(descriptor.offset)
+        else:
+            keys.append(empty_key)
+            offsets.append(-1)
+    return batch.append_column(
+        key_col, pa.array(keys, type=pa.binary(16))
+    ).append_column(
+        offset_col, pa.array(offsets, type=pa.int64())
+    )
 
 
 def _set_map_batches_remote_args(dataset, kwargs, ray_remote_args):
@@ -303,6 +389,103 @@ def _map_blob_batch(
     return result
 
 
+def _map_blob_affinity_block(
+        batch, file_io, blob_cols, all_blob_cols, parallelism, fn, fn_kwargs,
+        fn_batch_size, prefetch_bytes, affinity_cols,
+        map_blob_cols=(), array_blob_cols=()):
+    from pypaimon.multimodal.blob_read import fetch_blob_bodies
+
+    if batch.num_rows == 0:
+        return
+
+    scalar_cols = _blob_scalar_columns(
+        batch, blob_cols, all_blob_cols, affinity_cols)
+
+    for start, end in _blob_prefetch_windows(
+            batch, blob_cols, fn_batch_size, prefetch_bytes):
+        window = batch.slice(start, end - start)
+        bodies = fetch_blob_bodies(
+            file_io,
+            window.select(blob_cols).to_pydict(),
+            blob_cols,
+            parallelism,
+        )
+        scalar = window.select(scalar_cols)
+        for batch_start in range(0, window.num_rows, fn_batch_size):
+            size = min(fn_batch_size, window.num_rows - batch_start)
+            fn_bodies = {
+                name: values[batch_start:batch_start + size]
+                for name, values in bodies.items()
+            }
+            yield _call_blob_fn(
+                fn, scalar.slice(batch_start, size), fn_bodies, fn_kwargs)
+        del fn_bodies, bodies
+
+
+def _blob_scalar_columns(batch, blob_cols, all_blob_cols, internal_cols=()):
+    missing = [name for name in blob_cols if name not in batch.schema.names]
+    if missing:
+        raise ValueError("BLOB column(s) not found in Ray Dataset: {}".format(
+            ", ".join(missing)))
+
+    all_blob = set(all_blob_cols)
+    excluded = all_blob | set(internal_cols)
+    scalar_cols = [name for name in batch.schema.names if name not in excluded]
+    unknown = _unknown_blob_descriptor_columns(batch, scalar_cols)
+    if unknown:
+        raise ValueError(
+            "Column {!r} holds BLOB descriptors this table does not own "
+            "(likely from a joined BLOB table). Fetch it with its own "
+            "table.map_with_blobs() in a separate pass, or drop it before "
+            "mapping.".format(unknown[0]))
+    return scalar_cols
+
+
+def _call_blob_fn(fn, scalar, bodies, fn_kwargs):
+    result = fn(scalar, bodies, **fn_kwargs)
+    if result is None:
+        raise ValueError(
+            "map_with_blobs UDF must return a Ray-compatible batch, such as a "
+            "pyarrow.Table. For side-effect-only processing, return an empty "
+            "pyarrow.Table instead of None.")
+    return result
+
+
+def _blob_prefetch_windows(batch, blob_cols, fn_batch_size, max_bytes):
+    start = 0
+    end = 0
+    size = 0
+    while end < batch.num_rows:
+        next_end = min(end + fn_batch_size, batch.num_rows)
+        next_size = _blob_payload_size(
+            batch.slice(end, next_end - end), blob_cols, max_bytes)
+        if end > start and size + next_size > max_bytes:
+            yield start, end
+            start = end
+            size = 0
+        size += next_size
+        end = next_end
+    if end > start:
+        yield start, end
+
+
+def _blob_payload_size(batch, blob_cols, unknown_size):
+    from pypaimon.table.row.blob import BlobDescriptorSerde
+
+    total = 0
+    for name in blob_cols:
+        for value in batch.column(name):
+            if not value.is_valid:
+                continue
+            raw = value.as_py()
+            if BlobDescriptorSerde.is_descriptor(raw):
+                length = BlobDescriptorSerde.deserialize(raw).length
+                total += length if length >= 0 else unknown_size
+            else:
+                total += len(raw)
+    return total
+
+
 def _unknown_blob_descriptor_columns(batch, scalar_cols):
     return [
         name for name in scalar_cols
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py 
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index 2e7e680f72..a61477f100 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -1856,10 +1856,12 @@ class MultimodalTableTest(unittest.TestCase):
                 collect_batch,
                 parallelism=2,
                 batch_size=1,
+                blob_uri_affinity=True,
+                prefetch_bytes=32,
                 fn_kwargs={"prefix": b"got-"},
                 ray_remote_args={"num_cpus": 1},
             )
-            rows = sorted(result.to_pandas().to_dict("records"), key=lambda 
row: row["idx"])
+            rows = sorted(result.take_all(), key=lambda row: row["idx"])
 
             self.assertEqual(
                 [
@@ -2086,6 +2088,23 @@ class MultimodalTableTest(unittest.TestCase):
                     file_io=obs.raw_table.file_io,
                 )
 
+            with self.assertRaisesRegex(ValueError, "requires batch_size"):
+                obs.map_with_blobs(
+                    ds,
+                    ["image"],
+                    return_none,
+                    batch_size=None,
+                    blob_uri_affinity=True,
+                )
+
+            with self.assertRaisesRegex(ValueError, "prefetch_bytes"):
+                obs.map_with_blobs(
+                    ds,
+                    ["image"],
+                    return_none,
+                    prefetch_bytes=0,
+                )
+
             with self.assertRaisesRegex(Exception, "must return"):
                 obs.map_with_blobs(
                     ds,
diff --git a/paimon-python/pypaimon/tests/ray_blob_affinity_test.py 
b/paimon-python/pypaimon/tests/ray_blob_affinity_test.py
new file mode 100644
index 0000000000..a63f2e628c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_blob_affinity_test.py
@@ -0,0 +1,250 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+import weakref
+from unittest.mock import patch
+
+import pyarrow as pa
+
+try:
+    import ray
+except ImportError:
+    ray = None
+
+from pypaimon.ray.ray_paimon import (
+    _append_blob_affinity_keys,
+    _blob_prefetch_windows,
+    _map_blob_affinity_block,
+)
+from pypaimon.table.row.blob import BlobDescriptor, VideoFrameDescriptor
+
+
+def _descriptor(uri, offset, length):
+    return BlobDescriptor(uri, offset, length).serialize()
+
+
+class _ReadCounter:
+    def __init__(self):
+        self.reads = 0
+
+    def add(self, count):
+        self.reads += count
+
+    def get(self):
+        return self.reads
+
+    def reset(self):
+        self.reads = 0
+
+
+class _CountingFileIO:
+    def __init__(self, counter):
+        self.counter = counter
+
+    def read_ranges_coalesced(self, ranges, parallelism):
+        paths = {value[0] for value in ranges if value is not None}
+        ray.get(self.counter.add.remote(len(paths)))
+        return [
+            None if value is None else bytes([value[1] + 1]) * value[2]
+            for value in ranges
+        ]
+
+
+class BlobAffinityHelperTest(unittest.TestCase):
+    def test_appends_uri_and_offset(self):
+        batch = pa.table({
+            "id": [1, 2, 3],
+            "thumbnail": [None, None, None],
+            "image": [
+                _descriptor("oss://bucket/a", 20, 2),
+                None,
+                b"inline",
+            ],
+        })
+
+        result = _append_blob_affinity_keys(
+            batch, ["thumbnail", "image"], "key", "offset")
+
+        keys = result.column("key").to_pylist()
+        self.assertEqual(len(keys[0]), 16)
+        self.assertEqual(keys[1], b"\0" * 16)
+        self.assertEqual(keys[2], b"\0" * 16)
+        self.assertEqual(result.column("offset").to_pylist(), [20, -1, -1])
+
+    def test_video_frames_share_payload_uri_key_and_preserve_offsets(self):
+        batch = pa.table({"image": [
+            _descriptor("oss://bucket/a", 10, 100),
+            VideoFrameDescriptor("oss://bucket/a", 20, 100, 0).serialize(),
+            VideoFrameDescriptor("oss://bucket/a", 20, 100, 1).serialize(),
+            VideoFrameDescriptor("oss://bucket/b", 30, 100, 0).serialize(),
+        ]})
+        result = _append_blob_affinity_keys(batch, ["image"], "key", "offset")
+        keys = result.column("key").to_pylist()
+        self.assertEqual(keys[:3], [keys[0]] * 3)
+        self.assertNotEqual(keys[3], keys[0])
+        self.assertTrue(all(key != b"\0" * 16 for key in keys))
+        self.assertEqual(result.column("offset").to_pylist(), [10, 20, 20, 30])
+
+    def test_video_prefetch_windows_use_payload_length(self):
+        batch = pa.table({"image": [
+            VideoFrameDescriptor("oss://bucket/video-{}".format(i),
+                                 0, 10 * 1024 * 1024, 0).serialize()
+            for i in range(3)
+        ]})
+        windows = list(_blob_prefetch_windows(
+            batch, ["image"], fn_batch_size=1, max_bytes=1024 * 1024))
+        self.assertEqual(windows, [(0, 1), (1, 2), (2, 3)])
+
+    def test_previous_payload_window_is_released_before_next_read(self):
+        class Payload(bytearray):
+            pass
+
+        refs = []
+        reads = []
+        test = self
+
+        class FileIO:
+            def read_ranges_coalesced(self, ranges, parallelism):
+                test.assertTrue(all(ref() is None for ref in refs))
+                payloads = [Payload(b"x") for _ in ranges]
+                refs.extend(weakref.ref(value) for value in payloads)
+                reads.append(len(ranges))
+                return payloads
+
+        batch = pa.table({
+            "id": list(range(4)),
+            "image": [_descriptor("file:///video", i, 1) for i in range(4)],
+        })
+        outputs = list(_map_blob_affinity_block(
+            batch, FileIO(), ["image"], ["image"], 1,
+            lambda scalar, blobs: scalar, {},
+            fn_batch_size=1, prefetch_bytes=2, affinity_cols=[]))
+        self.assertEqual(reads, [2, 2])
+        self.assertEqual([result.num_rows for result in outputs], [1] * 4)
+        self.assertTrue(all(ref() is None for ref in refs))
+
+    def test_affinity_rejects_nested_columns(self):
+        for data_type, value in [
+                (pa.list_(pa.binary()), [b"image"]),
+                (pa.map_(pa.string(), pa.binary()), [("key", b"image")])]:
+            batch = pa.table({"image": pa.array([value], type=data_type)})
+            with self.assertRaisesRegex(ValueError, "scalar BLOB"):
+                _append_blob_affinity_keys(batch, ["image"], "key", "offset")
+
+    def test_prefetch_windows_end_on_function_batch_boundaries(self):
+        batch = pa.table({
+            "image": [
+                _descriptor("oss://bucket/a", i * 4, 4)
+                for i in range(5)
+            ],
+        })
+
+        windows = list(_blob_prefetch_windows(
+            batch, ["image"], fn_batch_size=2, max_bytes=8))
+
+        self.assertEqual(windows, [(0, 2), (2, 4), (4, 5)])
+
+
[email protected](ray is None, "ray is not installed")
+class BlobAffinityRayTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.started_ray = not ray.is_initialized()
+        if cls.started_ray:
+            ray.init(ignore_reinit_error=True, num_cpus=2)
+
+    @classmethod
+    def tearDownClass(cls):
+        if cls.started_ray:
+            ray.shutdown()
+
+    def test_gpu_arguments_keep_explicit_outer_and_inner_batch_sizes(self):
+        from pypaimon.ray import map_with_blobs
+
+        source = ray.data.from_arrow(pa.table({"image": [b"inline"]}))
+        original = ray.data.Dataset.map_batches
+        calls = []
+
+        def capture(dataset, fn, **kwargs):
+            calls.append(kwargs)
+            return original(dataset, fn, **kwargs)
+
+        for resources in ({"num_gpus": 1}, {"ray_remote_args": {"num_gpus": 
1}}):
+            with self.subTest(resources=resources), \
+                    patch.object(ray.data.Dataset, "map_batches", capture):
+                # Construct a real Ray plan without scheduling GPU work.
+                map_with_blobs(
+                    source, ["image"], lambda scalar, blobs: scalar,
+                    file_io=object(), all_blob_columns=["image"],
+                    batch_size=32, blob_uri_affinity=True, **resources)
+                self.assertGreater(calls[-1]["batch_size"], 32)
+                self.assertEqual(calls[-1]["fn_kwargs"]["fn_batch_size"], 32)
+                self.assertEqual(calls[-1]["num_gpus"], 1)
+
+    def test_uri_affinity_coalesces_across_function_batches(self):
+        from pypaimon.ray import map_with_blobs
+
+        counter = ray.remote(num_cpus=0)(_ReadCounter).remote()
+        file_io = _CountingFileIO(counter)
+        source = pa.table({
+            "id": [1, 2, 3, 4],
+            "image": [
+                _descriptor("oss://bucket/a", 0, 1),
+                _descriptor("oss://bucket/b", 0, 1),
+                _descriptor("oss://bucket/a", 1, 1),
+                _descriptor("oss://bucket/b", 1, 1),
+            ],
+        })
+
+        def consume(scalar, blobs):
+            return pa.table({
+                "id": scalar.column("id"),
+                "image_size": [len(value) for value in blobs["image"]],
+                "fn_batch_size": [scalar.num_rows] * scalar.num_rows,
+            })
+
+        baseline = map_with_blobs(
+            ray.data.from_arrow(source),
+            ["image"],
+            consume,
+            file_io=file_io,
+            all_blob_columns=["image"],
+            batch_size=1,
+        )
+        self.assertEqual(len(baseline.take_all()), 4)
+        self.assertEqual(ray.get(counter.get.remote()), 4)
+
+        ray.get(counter.reset.remote())
+        clustered = map_with_blobs(
+            ray.data.from_arrow(source),
+            ["image"],
+            consume,
+            file_io=file_io,
+            all_blob_columns=["image"],
+            batch_size=1,
+            blob_uri_affinity=True,
+            prefetch_bytes=16,
+        )
+        rows = sorted(clustered.take_all(), key=lambda row: row["id"])
+
+        self.assertEqual([row["id"] for row in rows], [1, 2, 3, 4])
+        self.assertEqual([row["fn_batch_size"] for row in rows], [1, 1, 1, 1])
+        self.assertEqual(ray.get(counter.get.remote()), 2)
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to