blob: 41879bd6322a58ca469f03742ab85e633ac6ba56 [file]
# 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, -1, 0).serialize(),
VideoFrameDescriptor("oss://bucket/a", 20, 100, 1, -1, 0).serialize(),
VideoFrameDescriptor("oss://bucket/b", 30, 100, 0, -1, 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, -1, 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)])
@unittest.skipIf(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()