| # 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 json | |
| import multiprocessing | |
| import os | |
| import pickle | |
| import random | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import unittest | |
| from types import SimpleNamespace | |
| from unittest.mock import patch | |
| import pyarrow as pa | |
| from parameterized import parameterized | |
| import torch | |
| from torch.utils.data import DataLoader | |
| from pypaimon import CatalogFactory, Schema | |
| from pypaimon.catalog.table_query_auth import TableQueryAuthResult | |
| from pypaimon.multimodal.table import MultimodalTable | |
| from pypaimon.read.datasource.torch_dataset import ( | |
| SplitRangeIndex, | |
| TorchIterDataset, | |
| TorchBatchIterDataset, | |
| TorchShuffledBatchIterDataset, | |
| TorchShuffledIterDataset, | |
| _resolve_distributed_context, | |
| _shuffle_record_batches, | |
| ) | |
| from pypaimon.read.table_read import TableRead | |
| from pypaimon.table.file_store_table import FileStoreTable | |
| from pypaimon.utils.range import Range | |
| def _collect_spawned_worker_splits(dataset, output): | |
| os.environ.pop("RANK", None) | |
| os.environ.pop("WORLD_SIZE", None) | |
| output.put(dataset._worker_splits(None)) | |
| class _EmptyTableRead: | |
| read_type = [] | |
| def __init__(self, limit=None): | |
| self.limit = limit | |
| def _output_arrow_schema(self): | |
| return pa.schema([]) | |
| class TorchDistributedShardingTest(unittest.TestCase): | |
| @staticmethod | |
| def _table_read(limit=None): | |
| return _EmptyTableRead(limit) | |
| @staticmethod | |
| def _worker(worker_id, num_workers): | |
| return SimpleNamespace(id=worker_id, num_workers=num_workers) | |
| def _dataset( | |
| self, | |
| splits, | |
| limit=None, | |
| dataset_type=TorchIterDataset, | |
| **kwargs | |
| ): | |
| return dataset_type( | |
| self._table_read(limit), | |
| splits, | |
| auto_detect_rank=True, | |
| **kwargs | |
| ) | |
| def _assignments(self, split_count, world_size, num_workers): | |
| splits = list(range(split_count)) | |
| assignments = {} | |
| for rank in range(world_size): | |
| dataset = self._dataset(splits) | |
| for worker_id in range(num_workers): | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(rank, world_size), | |
| ): | |
| assignments[(rank, worker_id)] = dataset._worker_splits( | |
| self._worker(worker_id, num_workers) | |
| ) | |
| return assignments | |
| def assertCompleteNonOverlapping(self, assignments, expected): | |
| assigned = [ | |
| split for splits in assignments.values() for split in splits | |
| ] | |
| self.assertCountEqual(assigned, expected) | |
| self.assertEqual(len(assigned), len(set(assigned))) | |
| @parameterized.expand([ | |
| ("single", 7, 1, 1, [7]), | |
| ("workers", 10, 1, 3, [4, 3, 3]), | |
| ("ranks", 10, 3, 1, [4, 3, 3]), | |
| ("rank_workers", 17, 3, 2, [3, 3, 3, 3, 3, 2]), | |
| ("uneven", 11, 2, 2, [3, 3, 3, 2]), | |
| ("sparse", 3, 2, 3, [1, 1, 0, 1, 0, 0]), | |
| ]) | |
| def test_balanced_assignments( | |
| self, _, split_count, world_size, num_workers, expected_sizes | |
| ): | |
| assignments = self._assignments( | |
| split_count, world_size, num_workers | |
| ) | |
| self.assertCompleteNonOverlapping( | |
| assignments, list(range(split_count)) | |
| ) | |
| self.assertEqual( | |
| [len(splits) for splits in assignments.values()], expected_sizes | |
| ) | |
| def test_binding_limit_rejects_distributed_sharding(self): | |
| splits = [SimpleNamespace(row_count=10) for _ in range(4)] | |
| dataset = self._dataset(splits, limit=5) | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(0, 2), | |
| ), self.assertRaisesRegex(ValueError, "limit is not supported"): | |
| dataset._worker_splits(None) | |
| def test_zero_limit_returns_no_splits(self): | |
| dataset = self._dataset([SimpleNamespace(row_count=10)], limit=0) | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(1, 2), | |
| ): | |
| self.assertEqual(dataset._worker_splits(None), []) | |
| def test_initialized_distributed_context_precedes_environment(self): | |
| with patch.dict( | |
| os.environ, {"RANK": "4", "WORLD_SIZE": "5"}, clear=True | |
| ), patch.object( | |
| torch.distributed, "is_available", return_value=True | |
| ), patch.object( | |
| torch.distributed, "is_initialized", return_value=True | |
| ), patch.object( | |
| torch.distributed, "get_rank", return_value=1 | |
| ), patch.object( | |
| torch.distributed, "get_world_size", return_value=3 | |
| ): | |
| context = _resolve_distributed_context(True) | |
| self.assertEqual(context, (1, 3)) | |
| def test_explicit_context_ignores_global_process_group(self): | |
| with patch.object( | |
| torch.distributed, "is_available", return_value=True | |
| ), patch.object( | |
| torch.distributed, "is_initialized", return_value=True | |
| ), patch.object( | |
| torch.distributed, "get_rank", return_value=3 | |
| ), patch.object( | |
| torch.distributed, "get_world_size", return_value=4 | |
| ): | |
| dataset = TorchIterDataset( | |
| self._table_read(), | |
| list(range(8)), | |
| sharding_rank=1, | |
| sharding_world_size=2, | |
| ) | |
| assigned = dataset._worker_splits(None) | |
| self.assertEqual((dataset.rank, dataset.world_size), (1, 2)) | |
| self.assertEqual(assigned, [4, 5, 6, 7]) | |
| def test_context_is_resolved_after_dataset_construction(self): | |
| dataset = TorchIterDataset( | |
| self._table_read(), list(range(8)), auto_detect_rank=True | |
| ) | |
| with patch.dict( | |
| os.environ, {"RANK": "2", "WORLD_SIZE": "4"}, clear=True | |
| ), patch.object( | |
| torch.distributed, "is_available", return_value=True | |
| ), patch.object( | |
| torch.distributed, "is_initialized", return_value=False | |
| ): | |
| context = _resolve_distributed_context(True) | |
| assigned = dataset._worker_splits(None) | |
| self.assertEqual(context, (2, 4)) | |
| self.assertEqual(assigned, [4, 5]) | |
| def test_constructor_context_is_preserved_in_spawned_worker(self): | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(0, 1), | |
| ): | |
| dataset = TorchIterDataset( | |
| self._table_read(), list(range(8)), auto_detect_rank=True | |
| ) | |
| context = multiprocessing.get_context("spawn") | |
| output = context.Queue() | |
| process = context.Process( | |
| target=_collect_spawned_worker_splits, | |
| args=(dataset, output), | |
| ) | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(1, 2), | |
| ): | |
| process.start() | |
| process.join(30) | |
| if process.is_alive(): | |
| process.terminate() | |
| process.join() | |
| self.assertEqual(process.exitcode, 0) | |
| self.assertEqual(output.get(timeout=5), [4, 5, 6, 7]) | |
| output.close() | |
| def test_same_process_uses_latest_context(self): | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(1, 2), | |
| ): | |
| dataset = TorchIterDataset( | |
| self._table_read(), list(range(8)), auto_detect_rank=True | |
| ) | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(0, 1), | |
| ): | |
| self.assertEqual(dataset._worker_splits(None), list(range(8))) | |
| def test_auto_falls_back_to_single_process(self): | |
| with patch.dict(os.environ, {}, clear=True), patch.object( | |
| torch.distributed, "is_available", return_value=False | |
| ): | |
| context = _resolve_distributed_context(True) | |
| self.assertEqual(context, (0, 1)) | |
| def test_disabled_preserves_worker_sharding(self): | |
| splits = list(range(8)) | |
| with patch.dict( | |
| os.environ, {"RANK": "1", "WORLD_SIZE": "2"}, clear=True | |
| ), patch.object( | |
| torch.distributed, "is_available", return_value=True | |
| ), patch.object( | |
| torch.distributed, "is_initialized", return_value=True | |
| ): | |
| dataset = TorchIterDataset( | |
| self._table_read(), splits, auto_detect_rank=False | |
| ) | |
| assigned = dataset._worker_splits(self._worker(1, 2)) | |
| self.assertEqual(assigned, list(range(4, 8))) | |
| def test_shuffled_dataset_is_reproducible_and_rank_local(self): | |
| splits = list(range(20)) | |
| datasets = [ | |
| self._dataset( | |
| splits, | |
| dataset_type=TorchShuffledIterDataset, | |
| seed=17, | |
| buffer_size=20, | |
| ) | |
| for _ in range(2) | |
| ] | |
| local_splits = [] | |
| for rank, dataset in enumerate(datasets): | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(rank, 2), | |
| ): | |
| local_splits.append(dataset._worker_splits(None)) | |
| self.assertTrue(set(local_splits[0]).isdisjoint(local_splits[1])) | |
| self.assertCountEqual(local_splits[0] + local_splits[1], splits) | |
| restored = pickle.loads(pickle.dumps(datasets[1])) | |
| self.assertTrue(restored.auto_detect_rank) | |
| rows = [{"id": value} for value in range(20)] | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(0, 2), | |
| ): | |
| first = list(datasets[0]._iter_buffer_shuffled_rows(iter(rows), 0)) | |
| repeat = list(datasets[0]._iter_buffer_shuffled_rows(iter(rows), 0)) | |
| other_worker = list( | |
| datasets[0]._iter_buffer_shuffled_rows(iter(rows), 1) | |
| ) | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(1, 2), | |
| ): | |
| other_rank = list( | |
| datasets[1]._iter_buffer_shuffled_rows(iter(rows), 0) | |
| ) | |
| self.assertEqual(first, repeat) | |
| self.assertNotEqual(first, other_rank) | |
| self.assertNotEqual(first, other_worker) | |
| datasets[0].set_epoch(1) | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(0, 2), | |
| ): | |
| next_epoch = list( | |
| datasets[0]._iter_buffer_shuffled_rows(iter(rows), 0) | |
| ) | |
| self.assertNotEqual(first, next_epoch) | |
| def test_invalid_distributed_context(self): | |
| with self.assertRaisesRegex(ValueError, "auto_detect_rank"): | |
| TorchIterDataset( | |
| self._table_read(), [], auto_detect_rank="auto" | |
| ) | |
| with self.assertRaisesRegex(ValueError, "must be set together"): | |
| TorchIterDataset( | |
| self._table_read(), [], sharding_rank=0 | |
| ) | |
| with self.assertRaisesRegex(ValueError, "cannot be combined"): | |
| TorchIterDataset( | |
| self._table_read(), | |
| [], | |
| auto_detect_rank=True, | |
| sharding_rank=0, | |
| sharding_world_size=1, | |
| ) | |
| with patch.dict(os.environ, {"RANK": "one"}, clear=True), patch.object( | |
| torch.distributed, "is_available", return_value=False | |
| ), self.assertRaisesRegex(ValueError, "must be set together"): | |
| _resolve_distributed_context(True) | |
| with patch.dict( | |
| os.environ, {"RANK": "one", "WORLD_SIZE": "2"}, clear=True | |
| ), patch.object( | |
| torch.distributed, "is_available", return_value=False | |
| ), self.assertRaisesRegex(ValueError, "must be integers"): | |
| _resolve_distributed_context(True) | |
| for environment, message in [ | |
| ({"RANK": "0", "WORLD_SIZE": "0"}, "greater than 0"), | |
| ({"RANK": "2", "WORLD_SIZE": "2"}, "0 <= rank"), | |
| ]: | |
| with self.subTest(environment=environment), patch.dict( | |
| os.environ, environment, clear=True | |
| ), patch.object( | |
| torch.distributed, "is_available", return_value=False | |
| ), self.assertRaisesRegex(ValueError, message): | |
| _resolve_distributed_context(True) | |
| @unittest.skipUnless( | |
| torch.distributed.is_available(), "torch.distributed is unavailable" | |
| ) | |
| def test_torchrun_rank_and_worker_sharding(self): | |
| script = os.path.join( | |
| os.path.dirname(__file__), "torch_distributed_sharding_worker.py" | |
| ) | |
| python_root = os.path.abspath( | |
| os.path.join(os.path.dirname(__file__), "..", "..") | |
| ) | |
| with tempfile.TemporaryDirectory() as output_dir: | |
| env = os.environ.copy() | |
| env["PYTHONPATH"] = os.pathsep.join( | |
| filter(None, [python_root, env.get("PYTHONPATH")]) | |
| ) | |
| process = subprocess.run( | |
| [ | |
| sys.executable, | |
| "-m", | |
| "torch.distributed.run", | |
| "--standalone", | |
| "--nproc-per-node=2", | |
| script, | |
| output_dir, | |
| ], | |
| env=env, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| timeout=180, | |
| ) | |
| self.assertEqual( | |
| process.returncode, | |
| 0, | |
| "torchrun failed:\n%s\n%s" % ( | |
| process.stdout, process.stderr | |
| ), | |
| ) | |
| rows = [] | |
| for rank in range(2): | |
| with open( | |
| os.path.join(output_dir, "rank-%d.json" % rank), | |
| encoding="utf-8", | |
| ) as result_file: | |
| rows.extend(json.load(result_file)) | |
| split_ids = [row["split_id"] for row in rows] | |
| self.assertCountEqual(split_ids, list(range(11))) | |
| self.assertEqual(len(split_ids), len(set(split_ids))) | |
| assignments = {} | |
| for row in rows: | |
| assignments.setdefault( | |
| (row["rank"], row["worker"]), [] | |
| ).append(row["split_id"]) | |
| self.assertEqual( | |
| {key: sorted(values) for key, values in assignments.items()}, | |
| { | |
| (0, 0): [0, 1, 2], | |
| (0, 1): [3, 4, 5], | |
| (1, 0): [6, 7, 8], | |
| (1, 1): [9, 10], | |
| }, | |
| ) | |
| class TorchBatchShuffleTest(unittest.TestCase): | |
| @parameterized.expand([1, 3, 8, 50]) | |
| def test_columnar_shuffle_matches_existing_row_shuffle(self, buffer_size): | |
| rows = [{"id": i, "nested": None if i % 4 == 0 else {"values": [i, None]}, | |
| "text": None if i % 3 == 0 else str(i)} for i in range(25)] | |
| table = pa.Table.from_pylist(rows) | |
| source = table.to_batches(max_chunksize=4) | |
| source.insert(1, source[0].slice(0, 0)) | |
| actual = pa.Table.from_batches(list(_shuffle_record_batches( | |
| iter(source), buffer_size, random.Random(17)))) | |
| legacy = TorchShuffledIterDataset( | |
| _EmptyTableRead(), [], seed=17, buffer_size=buffer_size) | |
| expected = list(legacy._iter_buffer_shuffled_rows(iter(rows), 0)) | |
| self.assertEqual(actual.schema, table.schema) | |
| self.assertEqual(actual.to_pylist(), expected) | |
| def test_shuffle_does_not_collect_whole_input(self): | |
| consumed = [] | |
| def source(): | |
| for start in range(0, 300, 3): | |
| consumed.extend(range(start, start + 3)) | |
| yield pa.record_batch([pa.array(range(start, start + 3))], names=["id"]) | |
| batches = _shuffle_record_batches(source(), 5, random.Random(0)) | |
| self.assertEqual(next(batches).num_rows, 5) | |
| self.assertLessEqual(len(consumed), 12) | |
| batches.close() | |
| def test_shuffle_drains_before_arrow_offset_overflow(self): | |
| table = pa.table({"text": [str(i).zfill(4) for i in range(12)]}) | |
| with patch("pypaimon.read.datasource.torch_dataset._MAX_ARROW_OFFSET", 12): | |
| batches = list(_shuffle_record_batches( | |
| iter(table.to_batches(max_chunksize=3)), 3, random.Random(3))) | |
| self.assertEqual(sorted(row["text"] for b in batches for row in b.to_pylist()), | |
| table["text"].to_pylist()) | |
| self.assertTrue(all(sum(len(v) for v in b["text"].to_pylist()) <= 12 for b in batches)) | |
| @parameterized.expand(["complete", "close", "read_error", "convert_error"]) | |
| def test_interleaved_readers_are_bounded_and_closed(self, mode): | |
| opened, closed, active = [], [], set() | |
| maximum = [0] | |
| def read(dataset, splits): | |
| split = splits[0] | |
| opened.append(split) | |
| active.add(split) | |
| maximum[0] = max(maximum[0], len(active)) | |
| try: | |
| for i in range(10): | |
| if mode == "read_error" and split == 1 and i == 1: | |
| raise RuntimeError("read failed") | |
| yield pa.record_batch([pa.array([split * 10 + i])], names=["id"]) | |
| finally: | |
| active.remove(split) | |
| closed.append(split) | |
| def convert(batch): | |
| if mode == "convert_error": | |
| raise RuntimeError("conversion failed") | |
| return batch | |
| dataset = TorchShuffledBatchIterDataset( | |
| _EmptyTableRead(), list(range(8)), | |
| buffer_size=3, max_buffer_input_splits=2, batch_size=2, | |
| batch_format="torch", to_tensor_fn=convert) | |
| with patch.object(TorchBatchIterDataset, "_arrow_batches_for_splits", read): | |
| batches = iter(dataset) | |
| if mode == "close": | |
| next(batches) | |
| batches.close() | |
| elif mode.endswith("error"): | |
| with self.assertRaisesRegex(RuntimeError, "failed"): | |
| list(batches) | |
| else: | |
| rows = [row["id"] for batch in batches for row in batch.to_pylist()] | |
| self.assertEqual(sorted(rows), list(range(80))) | |
| self.assertFalse(active) | |
| self.assertCountEqual(opened, closed) | |
| self.assertEqual(maximum[0], 2) | |
| def test_empty_input(self): | |
| self.assertEqual(list(_shuffle_record_batches(iter([]), 5, random.Random(0))), []) | |
| class TorchReadTest(unittest.TestCase): | |
| @classmethod | |
| def setUpClass(cls): | |
| cls.tempdir = tempfile.mkdtemp() | |
| cls.warehouse = os.path.join(cls.tempdir, 'warehouse') | |
| cls.catalog = CatalogFactory.create({ | |
| 'warehouse': cls.warehouse | |
| }) | |
| cls.catalog.create_database('default', True) | |
| cls.pa_schema = pa.schema([ | |
| ('user_id', pa.int32()), | |
| ('item_id', pa.int64()), | |
| ('behavior', pa.string()), | |
| ('dt', pa.string()) | |
| ]) | |
| cls.pk_pa_schema = pa.schema([ | |
| pa.field('user_id', pa.int32(), nullable=False), | |
| ('item_id', pa.int64()), | |
| pa.field('behavior', pa.string(), nullable=False), | |
| ('dt', pa.string()) | |
| ]) | |
| cls.expected = pa.Table.from_pydict({ | |
| 'user_id': [1, 2, 3, 4, 5, 6, 7, 8], | |
| 'item_id': [1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008], | |
| 'behavior': ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], | |
| 'dt': ['p1', 'p1', 'p2', 'p1', 'p2', 'p1', 'p2', 'p2'], | |
| }, schema=cls.pa_schema) | |
| @classmethod | |
| def tearDownClass(cls): | |
| shutil.rmtree(cls.tempdir, ignore_errors=True) | |
| def test_nested_projection_uses_output_names_and_duplicate_values(self): | |
| schema = pa.schema([ | |
| ('id', pa.int32()), ('payload', pa.struct([('value', pa.int32()), ('unused', pa.string())]))]) | |
| self.catalog.create_table('default.torch_canonical_read_type', Schema.from_pyarrow_schema( | |
| schema, options={'file.format': 'parquet'}), False) | |
| table = self.catalog.get_table('default.torch_canonical_read_type') | |
| builder = table.new_batch_write_builder() | |
| writer, commit = builder.new_write(), builder.new_commit() | |
| try: | |
| writer.write_arrow(pa.Table.from_pylist([ | |
| {'id': 1, 'payload': {'value': 10, 'unused': 'x'}}, | |
| {'id': 2, 'payload': None}], schema=schema)) | |
| commit.commit(writer.prepare_commit()) | |
| finally: | |
| writer.close() | |
| commit.close() | |
| builder = table.new_read_builder().with_projection(['payload.value', 'id', 'id']) | |
| read, splits = builder.new_read(), builder.new_scan().plan().splits() | |
| self.assertEqual([field.name for field in read.read_type], ['payload', 'id']) | |
| expected = [{'payload_value': 10, 'id': 1, 'id__0': 1}, | |
| {'payload_value': None, 'id': 2, 'id__0': 2}] | |
| for streaming in (False, True): | |
| with self.subTest(streaming=streaming): | |
| dataset = read.to_torch(splits, streaming=streaming) | |
| self.assertEqual(sorted(list(dataset), key=lambda row: row['id']), expected) | |
| dataset = read.to_torch(splits, streaming=True, batch_format='pyarrow') | |
| actual = pa.Table.from_batches(list(dataset)).sort_by('id') | |
| self.assertEqual(actual.to_pylist(), expected) | |
| @parameterized.expand([True, False]) | |
| def test_torch_read(self, is_streaming: bool = False): | |
| schema = Schema.from_pyarrow_schema(self.pa_schema, partition_keys=['user_id']) | |
| self.catalog.create_table(f'default.test_torch_read_{str(is_streaming)}', schema, False) | |
| table = self.catalog.get_table(f'default.test_torch_read_{str(is_streaming)}') | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection(['user_id', 'behavior']) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| dataset = table_read.to_torch(splits, streaming=is_streaming) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=2, | |
| shuffle=False | |
| ) | |
| # Collect all data from dataloader | |
| all_user_ids = [] | |
| all_behaviors = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| user_ids = batch_data['user_id'].tolist() | |
| behaviors = batch_data['behavior'] | |
| all_user_ids.extend(user_ids) | |
| all_behaviors.extend(behaviors) | |
| # Sort by user_id for comparison | |
| sorted_data = sorted(zip(all_user_ids, all_behaviors), key=lambda x: x[0]) | |
| sorted_user_ids = [x[0] for x in sorted_data] | |
| sorted_behaviors = [x[1] for x in sorted_data] | |
| # Expected data (sorted by user_id) | |
| expected_user_ids = [1, 2, 3, 4, 5, 6, 7, 8] | |
| expected_behaviors = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] | |
| # Verify results | |
| self.assertEqual(sorted_user_ids, expected_user_ids, | |
| f"User IDs mismatch. Expected {expected_user_ids}, got {sorted_user_ids}") | |
| self.assertEqual(sorted_behaviors, expected_behaviors, | |
| f"Behaviors mismatch. Expected {expected_behaviors}, got {sorted_behaviors}") | |
| if not is_streaming: | |
| self.assertIsInstance(dataset._data, pa.Table) | |
| print(f"✓ Test passed: Successfully read {len(all_user_ids)} rows with correct data") | |
| def test_non_streaming_row_tracking_reads_batches_lazily(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| partition_keys=['dt'], | |
| options={ | |
| 'data-evolution.enabled': 'true', | |
| 'row-tracking.enabled': 'true', | |
| }, | |
| ) | |
| identifier = 'default.test_torch_lazy_row_tracking' | |
| self.catalog.create_table(identifier, schema, False) | |
| table = self.catalog.get_table(identifier) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior']) | |
| splits = read_builder.new_scan().plan().splits() | |
| self.assertGreater(len(splits), 1) | |
| table_read = read_builder.new_read() | |
| expected = table_read.to_arrow(splits).to_pylist() | |
| with patch.object( | |
| TableRead, 'to_arrow', side_effect=AssertionError( | |
| 'dataset construction must not read table data')): | |
| dataset = table_read.to_torch(splits, streaming=False) | |
| self.assertIsNone(dataset._data) | |
| self.assertEqual(len(expected), len(dataset)) | |
| indices = [len(dataset) - 1, 1, 1, 0] | |
| self.assertEqual( | |
| [expected[index] for index in indices], | |
| dataset.__getitems__(indices), | |
| ) | |
| self.assertEqual(expected[-1], dataset[-1]) | |
| restored = pickle.loads(pickle.dumps(dataset)) | |
| self.assertEqual(expected[1], restored[1]) | |
| row_id_builder = table.new_read_builder().with_projection( | |
| ['user_id', '_ROW_ID']) | |
| row_id_splits = row_id_builder.new_scan().plan().splits() | |
| row_id_read = row_id_builder.new_read() | |
| expected_row_id = row_id_read.to_arrow( | |
| row_id_splits).to_pylist()[0] | |
| row_id_dataset = row_id_read.to_torch( | |
| row_id_splits, streaming=False) | |
| self.assertEqual(expected_row_id, row_id_dataset[0]) | |
| loader = DataLoader( | |
| dataset, | |
| batch_size=3, | |
| num_workers=2, | |
| shuffle=True, | |
| generator=torch.Generator().manual_seed(7), | |
| ) | |
| actual_ids = [] | |
| for batch in loader: | |
| actual_ids.extend(batch['user_id'].tolist()) | |
| self.assertEqual( | |
| sorted(row['user_id'] for row in expected), | |
| sorted(actual_ids), | |
| ) | |
| def test_non_streaming_row_tracking_without_data_evolution_materializes(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| options={'row-tracking.enabled': 'true'}, | |
| ) | |
| identifier = 'default.test_torch_row_tracking_without_data_evolution' | |
| self.catalog.create_table(identifier, schema, False) | |
| table = self.catalog.get_table(identifier) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior']) | |
| splits = read_builder.new_scan().plan().splits() | |
| table_read = read_builder.new_read() | |
| expected = table_read.to_arrow( | |
| splits, parallelism=1).to_pylist() | |
| with patch.object( | |
| table_read, 'to_arrow', wraps=table_read.to_arrow) as read: | |
| dataset = table_read.to_torch(splits, streaming=False) | |
| read.assert_called_once_with(splits) | |
| self.assertIsInstance(dataset._data, pa.Table) | |
| self.assertIsNone(dataset.table_read) | |
| self.assertIsNone(dataset.splits) | |
| with patch.object( | |
| TableRead, 'to_arrow', side_effect=AssertionError( | |
| 'materialized dataset must not read another batch')): | |
| self.assertEqual(expected, dataset[:]) | |
| def test_non_streaming_row_id_masking_materializes(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| options={ | |
| 'data-evolution.enabled': 'true', | |
| 'row-tracking.enabled': 'true', | |
| }, | |
| ) | |
| identifier = 'default.test_torch_masked_row_id' | |
| self.catalog.create_table(identifier, schema, False) | |
| table = self.catalog.get_table(identifier) | |
| self._write_test_table(table) | |
| auth = TableQueryAuthResult( | |
| filter=None, | |
| column_masking={ | |
| '_ROW_ID': json.dumps({'name': 'NULL'}), | |
| }, | |
| ) | |
| table.catalog_environment.table_query_auth = ( | |
| lambda options, table_identifier: lambda select: auth | |
| ) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior']) | |
| splits = read_builder.new_scan().plan().splits() | |
| table_read = read_builder.new_read() | |
| expected = table_read.to_arrow( | |
| splits, parallelism=1).to_pylist() | |
| dataset = table_read.to_torch(splits, streaming=False) | |
| self.assertIsInstance(dataset._data, pa.Table) | |
| self.assertEqual(expected, dataset[:]) | |
| def test_non_streaming_dataset_with_cache_is_pickleable(self): | |
| with tempfile.TemporaryDirectory() as tempdir: | |
| catalog = CatalogFactory.create({ | |
| 'warehouse': os.path.join(tempdir, 'warehouse'), | |
| 'local-cache.enabled': 'true', | |
| 'local-cache.whitelist': 'meta,global-index,data', | |
| }) | |
| catalog.create_database('default', True) | |
| for suffix, options, lazy in [ | |
| ('arrow', {}, False), | |
| ('lazy', { | |
| 'data-evolution.enabled': 'true', | |
| 'row-tracking.enabled': 'true', | |
| }, True), | |
| ]: | |
| with self.subTest(suffix=suffix): | |
| identifier = 'default.test_torch_cache_' + suffix | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, options=options) | |
| catalog.create_table(identifier, schema, False) | |
| table = catalog.get_table(identifier) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior']) | |
| splits = read_builder.new_scan().plan().splits() | |
| dataset = read_builder.new_read().to_torch( | |
| splits, streaming=False) | |
| self.assertEqual(lazy, dataset._data is None) | |
| restored = pickle.loads(pickle.dumps(dataset)) | |
| self.assertEqual(dataset[0], restored[0]) | |
| def test_split_range_index(self): | |
| index = SplitRangeIndex([ | |
| [Range(split * 10, split * 10 + 9)] | |
| for split in range(10000) | |
| ]) | |
| self.assertEqual([5000], index.find([Range(50003, 50005)])) | |
| self.assertEqual( | |
| [0, 9999], index.find([Range(0, 0), Range(99999, 99999)])) | |
| def test_non_streaming_row_tracking_preserves_filter_and_limit(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| options={ | |
| 'data-evolution.enabled': 'true', | |
| 'row-tracking.enabled': 'true', | |
| }, | |
| ) | |
| identifier = 'default.test_torch_lazy_filter_limit' | |
| self.catalog.create_table(identifier, schema, False) | |
| table = self.catalog.get_table(identifier) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior']) | |
| predicate = read_builder.new_predicate_builder().greater_than( | |
| 'user_id', 2) | |
| read_builder.with_filter(predicate).with_limit(3) | |
| splits = read_builder.new_scan().plan().splits() | |
| table_read = read_builder.new_read() | |
| expected = table_read.to_arrow( | |
| splits, parallelism=1).to_pylist() | |
| dataset = table_read.to_torch(splits, streaming=False) | |
| self.assertIsNone(dataset._data) | |
| self.assertEqual(3, len(dataset)) | |
| self.assertEqual( | |
| expected, | |
| dataset.__getitems__(range(len(dataset))), | |
| ) | |
| def test_non_streaming_row_tracking_respects_deletion_vectors(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| options={ | |
| 'data-evolution.enabled': 'true', | |
| 'row-tracking.enabled': 'true', | |
| 'deletion-vectors.enabled': 'true', | |
| }, | |
| ) | |
| identifier = 'default.test_torch_lazy_deletion_vector' | |
| self.catalog.create_table(identifier, schema, False) | |
| table = self.catalog.get_table(identifier) | |
| self._write_test_table(table) | |
| write_builder = table.new_batch_write_builder() | |
| messages = write_builder.new_update().delete_by_row_id([2]) | |
| table_commit = write_builder.new_commit() | |
| table_commit.commit(messages) | |
| table_commit.close() | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior']) | |
| splits = read_builder.new_scan().plan().splits() | |
| table_read = read_builder.new_read() | |
| expected = table_read.to_arrow(splits).to_pylist() | |
| dataset = table_read.to_torch(splits, streaming=False) | |
| self.assertIsNone(dataset._data) | |
| self.assertIsInstance(dataset._row_ids, pa.Array) | |
| self.assertEqual(expected, dataset[:]) | |
| def test_torch_streaming_prefetch_concurrency(self): | |
| schema = Schema.from_pyarrow_schema(self.pa_schema, partition_keys=['user_id']) | |
| self.catalog.create_table('default.test_torch_prefetch_concurrency', schema, False) | |
| table = self.catalog.get_table('default.test_torch_prefetch_concurrency') | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection(['user_id', 'behavior']) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| self.assertGreater(len(splits), 0, "Need at least one split to test prefetch") | |
| dataset = table_read.to_torch(splits, streaming=True, prefetch_concurrency=4) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=0, | |
| shuffle=False | |
| ) | |
| all_user_ids = [] | |
| all_behaviors = [] | |
| for batch_data in dataloader: | |
| all_user_ids.extend(batch_data['user_id'].tolist()) | |
| all_behaviors.extend(batch_data['behavior']) | |
| sorted_data = sorted(zip(all_user_ids, all_behaviors), key=lambda x: x[0]) | |
| sorted_user_ids = [x[0] for x in sorted_data] | |
| sorted_behaviors = [x[1] for x in sorted_data] | |
| expected_user_ids = [1, 2, 3, 4, 5, 6, 7, 8] | |
| expected_behaviors = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] | |
| self.assertEqual(len(all_user_ids), 8, "Should read 8 rows with prefetch_concurrency") | |
| self.assertEqual(sorted_user_ids, expected_user_ids) | |
| self.assertEqual(sorted_behaviors, expected_behaviors) | |
| def test_torch_streaming_pyarrow_batches(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, partition_keys=['user_id'] | |
| ) | |
| self.catalog.create_table( | |
| 'default.test_torch_pyarrow_batches', schema, False | |
| ) | |
| table = self.catalog.get_table( | |
| 'default.test_torch_pyarrow_batches' | |
| ) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior'] | |
| ) | |
| splits = read_builder.new_scan().plan().splits() | |
| dataset = read_builder.new_read().to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='pyarrow', | |
| batch_size=3, | |
| ) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=None, | |
| num_workers=2, | |
| shuffle=False, | |
| ) | |
| batches = list(dataloader) | |
| self.assertTrue(batches) | |
| self.assertTrue( | |
| all(isinstance(batch, pa.RecordBatch) for batch in batches) | |
| ) | |
| self.assertTrue(all(0 < batch.num_rows <= 3 for batch in batches)) | |
| result = pa.Table.from_batches(batches).sort_by('user_id').to_pydict() | |
| self.assertEqual(result['user_id'], list(range(1, 9))) | |
| self.assertEqual(result['behavior'], list('abcdefgh')) | |
| def test_torch_streaming_tensor_batches(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, partition_keys=['user_id'] | |
| ) | |
| self.catalog.create_table( | |
| 'default.test_torch_tensor_batches', schema, False | |
| ) | |
| table = self.catalog.get_table( | |
| 'default.test_torch_tensor_batches' | |
| ) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'item_id'] | |
| ) | |
| splits = read_builder.new_scan().plan().splits() | |
| dataset = read_builder.new_read().to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='torch', | |
| batch_size=3, | |
| ) | |
| batches = list(dataset) | |
| self.assertEqual([len(batch['user_id']) for batch in batches], [3, 3, 2]) | |
| self.assertTrue( | |
| all(batch['user_id'].dtype == torch.int32 for batch in batches) | |
| ) | |
| self.assertTrue( | |
| all(batch['item_id'].dtype == torch.int64 for batch in batches) | |
| ) | |
| user_ids = torch.cat( | |
| [batch['user_id'] for batch in batches] | |
| ).sort().values.tolist() | |
| self.assertEqual(user_ids, list(range(1, 9))) | |
| def test_torch_streaming_batches_respect_limit(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, partition_keys=['user_id'] | |
| ) | |
| self.catalog.create_table( | |
| 'default.test_torch_batch_limit', schema, False | |
| ) | |
| table = self.catalog.get_table('default.test_torch_batch_limit') | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id'] | |
| ).with_limit(5) | |
| splits = read_builder.new_scan().plan().splits() | |
| dataset = read_builder.new_read().to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='pyarrow', | |
| batch_size=3, | |
| ) | |
| batches = list(dataset) | |
| self.assertEqual([batch.num_rows for batch in batches], [3, 2]) | |
| def test_torch_streaming_batches_respect_limit_with_workers(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, partition_keys=['user_id'] | |
| ) | |
| self.catalog.create_table( | |
| 'default.test_torch_batch_worker_limit', schema, False | |
| ) | |
| table = self.catalog.get_table( | |
| 'default.test_torch_batch_worker_limit' | |
| ) | |
| self._write_test_table(table) | |
| predicate = ( | |
| table.new_read_builder().new_predicate_builder() | |
| .greater_than('item_id', 0) | |
| ) | |
| read_builder = ( | |
| table.new_read_builder() | |
| .with_filter(predicate) | |
| .with_projection(['user_id']) | |
| .with_limit(5) | |
| ) | |
| splits = read_builder.new_scan().plan().splits() | |
| self.assertGreater(len(splits), 1) | |
| dataset = read_builder.new_read().to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='pyarrow', | |
| batch_size=3, | |
| ) | |
| self.assertEqual( | |
| dataset._worker_splits(SimpleNamespace(id=1, num_workers=2)), | |
| [], | |
| ) | |
| batches = list(DataLoader( | |
| dataset, batch_size=None, num_workers=2 | |
| )) | |
| user_ids = [ | |
| value | |
| for batch in batches | |
| for value in batch.column('user_id').to_pylist() | |
| ] | |
| self.assertEqual(len(user_ids), 5) | |
| self.assertEqual(len(set(user_ids)), 5) | |
| def test_non_binding_limit_preserves_worker_splits(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, partition_keys=['user_id'] | |
| ) | |
| self.catalog.create_table( | |
| 'default.test_torch_non_binding_limit', schema, False | |
| ) | |
| table = self.catalog.get_table( | |
| 'default.test_torch_non_binding_limit' | |
| ) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_limit(1000) | |
| splits = read_builder.new_scan().plan().splits() | |
| self.assertGreater(len(splits), 1) | |
| table_read = read_builder.new_read() | |
| for batch_format in ['row', 'pyarrow']: | |
| dataset = table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format=batch_format, | |
| ) | |
| assigned = [ | |
| dataset._worker_splits( | |
| SimpleNamespace(id=worker_id, num_workers=2) | |
| ) | |
| for worker_id in range(2) | |
| ] | |
| self.assertTrue(all(assigned)) | |
| self.assertCountEqual( | |
| [id(split) for group in assigned for split in group], | |
| [id(split) for split in splits], | |
| ) | |
| def test_non_binding_limit_uses_merged_row_counts(self): | |
| from pypaimon.read.datasource.torch_dataset import TorchIterDataset | |
| table_read = _EmptyTableRead(limit=8) | |
| splits = [ | |
| SimpleNamespace(row_count=10, merged_row_count=lambda: 4), | |
| SimpleNamespace(row_count=10, merged_row_count=lambda: 4), | |
| ] | |
| dataset = TorchIterDataset(table_read, splits) | |
| assigned = [ | |
| dataset._worker_splits( | |
| SimpleNamespace(id=worker_id, num_workers=2) | |
| ) | |
| for worker_id in range(2) | |
| ] | |
| self.assertTrue(all(assigned)) | |
| self.assertCountEqual( | |
| [id(split) for group in assigned for split in group], | |
| [id(split) for split in splits], | |
| ) | |
| def test_torch_batch_sizing_respects_arrow_offset_limit(self): | |
| from pypaimon.read.datasource.torch_dataset import ( | |
| _sized_record_batches) | |
| batches = iter([ | |
| pa.record_batch([pa.array(['aaaa'])], names=['value']), | |
| pa.record_batch([pa.array(['bbbb'])], names=['value']), | |
| ]) | |
| with patch( | |
| 'pypaimon.read.datasource.torch_dataset._MAX_ARROW_OFFSET', 4 | |
| ): | |
| actual = list(_sized_record_batches(batches, batch_size=2)) | |
| self.assertEqual( | |
| [batch.column('value').to_pylist() for batch in actual], | |
| [['aaaa'], ['bbbb']], | |
| ) | |
| def test_default_tensor_converter_supports_fixed_size_list(self): | |
| from pypaimon.read.datasource.torch_dataset import _default_to_tensor | |
| values = pa.array([1, 2, 3, 4, 5, 6], type=pa.int32()) | |
| features = pa.FixedSizeListArray.from_arrays(values, 3) | |
| batch = pa.RecordBatch.from_arrays([features], ['features']) | |
| result = _default_to_tensor(batch) | |
| self.assertEqual(result['features'].dtype, torch.int32) | |
| self.assertEqual(result['features'].tolist(), [[1, 2, 3], [4, 5, 6]]) | |
| def test_torch_streaming_custom_tensor_conversion(self): | |
| schema = Schema.from_pyarrow_schema(self.pa_schema) | |
| self.catalog.create_table( | |
| 'default.test_torch_custom_tensor_batch', schema, False | |
| ) | |
| table = self.catalog.get_table( | |
| 'default.test_torch_custom_tensor_batch' | |
| ) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection( | |
| ['user_id', 'behavior'] | |
| ) | |
| splits = read_builder.new_scan().plan().splits() | |
| def to_tensor(batch): | |
| return { | |
| 'user_id': torch.from_numpy( | |
| batch.column('user_id').to_numpy(zero_copy_only=False) | |
| ), | |
| 'behavior': batch.column('behavior').to_pylist(), | |
| } | |
| dataset = read_builder.new_read().to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='torch', | |
| batch_size=5, | |
| to_tensor_fn=to_tensor, | |
| ) | |
| batches = list(dataset) | |
| self.assertEqual([len(batch['user_id']) for batch in batches], [5, 3]) | |
| self.assertEqual( | |
| sorted(value for batch in batches for value in batch['behavior']), | |
| list('abcdefgh'), | |
| ) | |
| default_dataset = read_builder.new_read().to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='torch', | |
| ) | |
| with self.assertRaisesRegex(ValueError, "batch_format='pyarrow'"): | |
| next(iter(default_dataset)) | |
| def test_torch_batch_options_validation(self): | |
| schema = Schema.from_pyarrow_schema(self.pa_schema) | |
| self.catalog.create_table( | |
| 'default.test_torch_batch_validation', schema, False | |
| ) | |
| table = self.catalog.get_table( | |
| 'default.test_torch_batch_validation' | |
| ) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| splits = read_builder.new_scan().plan().splits() | |
| table_read = read_builder.new_read() | |
| with self.assertRaisesRegex(ValueError, 'batch_format must be one of'): | |
| table_read.to_torch( | |
| splits, streaming=True, batch_format='numpy' | |
| ) | |
| with self.assertRaisesRegex(ValueError, 'requires streaming=True'): | |
| table_read.to_torch(splits, batch_format='pyarrow') | |
| with self.assertRaisesRegex(ValueError, 'requires streaming=True'): | |
| table_read.to_torch(splits, auto_detect_rank=True) | |
| with self.assertRaisesRegex(ValueError, 'requires streaming=True'): | |
| table_read.to_torch( | |
| splits, | |
| sharding_rank=0, | |
| sharding_world_size=1, | |
| ) | |
| with self.assertRaisesRegex(ValueError, 'batch_size must be'): | |
| table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='torch', | |
| batch_size=0, | |
| ) | |
| with self.assertRaisesRegex(ValueError, 'batch_size requires'): | |
| table_read.to_torch(splits, streaming=True, batch_size=2) | |
| with self.assertRaisesRegex(ValueError, 'buffer_size'): | |
| table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='torch', | |
| shuffle=True, | |
| buffer_size=0, | |
| ) | |
| for invalid in [0, -1, 1.9, True, 2]: | |
| with self.subTest(prefetch_concurrency=invalid): | |
| with self.assertRaisesRegex( | |
| ValueError, 'prefetch_concurrency' | |
| ): | |
| table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format='pyarrow', | |
| prefetch_concurrency=invalid, | |
| ) | |
| def test_torch_distributed_sharding_public_api(self): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, partition_keys=['user_id'] | |
| ) | |
| self.catalog.create_table( | |
| 'default.test_torch_distributed_api', schema, False | |
| ) | |
| table = self.catalog.get_table( | |
| 'default.test_torch_distributed_api' | |
| ) | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| splits = read_builder.new_scan().plan().splits() | |
| table_read = read_builder.new_read() | |
| with patch( | |
| "pypaimon.read.datasource.torch_dataset." | |
| "_resolve_distributed_context", | |
| return_value=(1, 2), | |
| ): | |
| datasets = [ | |
| table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| batch_format=batch_format, | |
| shuffle=shuffle, | |
| auto_detect_rank=True, | |
| ) | |
| for batch_format, shuffle in [ | |
| ('row', False), | |
| ('row', True), | |
| ('pyarrow', False), | |
| ('pyarrow', True), | |
| ] | |
| ] | |
| expected = splits[(len(splits) + 1) // 2:] | |
| for dataset in datasets: | |
| self.assertTrue(dataset.auto_detect_rank) | |
| self.assertEqual(dataset._worker_splits(None), expected) | |
| pre_sharded = splits[::2] | |
| with patch.dict( | |
| os.environ, {"RANK": "1", "WORLD_SIZE": "2"}, clear=True | |
| ): | |
| dataset = table_read.to_torch(pre_sharded, streaming=True) | |
| self.assertFalse(dataset.auto_detect_rank) | |
| self.assertEqual(dataset._worker_splits(None), pre_sharded) | |
| dataset = table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| sharding_rank=1, | |
| sharding_world_size=2, | |
| ) | |
| self.assertFalse(dataset.auto_detect_rank) | |
| self.assertEqual( | |
| dataset._worker_splits(None), | |
| splits[(len(splits) + 1) // 2:], | |
| ) | |
| self.assertIsNotNone(table_read.to_torch(splits)) | |
| def test_blob_torch_read(self): | |
| """Test end-to-end blob functionality using blob descriptors.""" | |
| import random | |
| from pypaimon import Schema | |
| from pypaimon.table.row.blob import BlobDescriptor | |
| # Create schema with blob column | |
| pa_schema = pa.schema([ | |
| ('id', pa.int32()), | |
| ('picture', pa.large_binary()), | |
| ]) | |
| schema = Schema.from_pyarrow_schema( | |
| pa_schema, | |
| options={ | |
| 'row-tracking.enabled': 'true', | |
| 'data-evolution.enabled': 'true', | |
| 'blob-as-descriptor': 'true' | |
| } | |
| ) | |
| # Create table | |
| self.catalog.create_table('default.test_blob_torch_read', schema, False) | |
| table: FileStoreTable = self.catalog.get_table('default.test_blob_torch_read') | |
| # Create test blob data (1MB) | |
| blob_data = bytearray(1024 * 1024) | |
| random.seed(42) # For reproducible tests | |
| for i in range(len(blob_data)): | |
| blob_data[i] = random.randint(0, 255) | |
| blob_data = bytes(blob_data) | |
| # Create external blob file | |
| external_blob_path = os.path.join(self.tempdir, 'external_blob') | |
| with open(external_blob_path, 'wb') as f: | |
| f.write(blob_data) | |
| # Create blob descriptor pointing to external file | |
| blob_descriptor = BlobDescriptor(external_blob_path, 0, len(blob_data)) | |
| # Create test data with blob descriptor | |
| test_data = pa.Table.from_pydict({ | |
| 'id': [1], | |
| 'picture': [blob_descriptor.serialize()] | |
| }, schema=pa_schema) | |
| # Write data using table API | |
| write_builder = table.new_batch_write_builder() | |
| writer = write_builder.new_write() | |
| writer.write_arrow(test_data) | |
| # Commit the data | |
| commit_messages = writer.prepare_commit() | |
| commit = write_builder.new_commit() | |
| commit.commit(commit_messages) | |
| # Read data back | |
| read_builder = table.new_read_builder() | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| result = table_read.to_torch(table_scan.plan().splits()) | |
| dataloader = DataLoader( | |
| result, | |
| batch_size=1, | |
| num_workers=0, | |
| shuffle=False | |
| ) | |
| # Collect and verify data | |
| all_ids = [] | |
| all_pictures = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| ids = batch_data['id'].tolist() | |
| pictures = batch_data['picture'] | |
| all_ids.extend(ids) | |
| all_pictures.extend(pictures) | |
| # Verify results | |
| self.assertEqual(len(all_ids), 1, "Should have exactly 1 row") | |
| self.assertEqual(all_ids[0], 1, "ID should be 1") | |
| # Verify blob descriptor | |
| picture_bytes = all_pictures[0] | |
| self.assertIsInstance(picture_bytes, bytes, "Picture should be bytes") | |
| # Deserialize and verify blob descriptor | |
| from pypaimon.table.row.blob import BlobDescriptor | |
| read_blob_descriptor = BlobDescriptor.deserialize(picture_bytes) | |
| self.assertEqual(read_blob_descriptor.length, len(blob_data), | |
| f"Blob length mismatch. Expected {len(blob_data)}, got {read_blob_descriptor.length}") | |
| self.assertGreaterEqual(read_blob_descriptor.offset, 0, "Offset should be non-negative") | |
| # Read and verify blob content | |
| from pypaimon.common.uri_reader import UriReaderFactory | |
| from pypaimon.common.options.config import CatalogOptions | |
| from pypaimon.table.row.blob import Blob | |
| catalog_options = {CatalogOptions.WAREHOUSE.key(): self.warehouse} | |
| uri_reader_factory = UriReaderFactory(catalog_options) | |
| uri_reader = uri_reader_factory.create(read_blob_descriptor.uri) | |
| blob = Blob.from_descriptor(uri_reader, read_blob_descriptor) | |
| # Verify blob data matches original | |
| read_blob_data = blob.to_data() | |
| self.assertEqual(len(read_blob_data), len(blob_data), | |
| f"Blob data length mismatch. Expected {len(blob_data)}, got {len(read_blob_data)}") | |
| self.assertEqual(read_blob_data, blob_data, "Blob data content should match original") | |
| print(f"✓ Blob torch read test passed: Successfully read and verified {len(blob_data)} bytes of blob data") | |
| def test_video_frame_rows_through_streaming_dataloader(self): | |
| from pypaimon.table.row.blob import Blob, VideoFrameDescriptor | |
| pa_schema = pa.schema([ | |
| ('episode_id', pa.int64()), | |
| ('video', pa.large_binary()), | |
| ]) | |
| schema = Schema.from_pyarrow_schema( | |
| pa_schema, | |
| options={ | |
| 'row-tracking.enabled': 'true', | |
| 'data-evolution.enabled': 'true', | |
| 'video-frame-field': 'video', | |
| # ScanQuery.to_torch must override this read setting. | |
| 'blob-as-descriptor': 'false', | |
| }, | |
| ) | |
| identifier = 'default.test_shared_video_torch_read' | |
| self.catalog.create_table(identifier, schema, False) | |
| raw_table = self.catalog.get_table(identifier) | |
| table = MultimodalTable(self.catalog, identifier, raw_table) | |
| video_path = os.path.join(self.tempdir, 'shared-video.mp4') | |
| video_bytes = b'one-physical-video' | |
| with open(video_path, 'wb') as output: | |
| output.write(video_bytes) | |
| video = Blob.from_local(video_path) | |
| table.add_video(video, [{'episode_id': 7} for _ in range(8)]) | |
| dataset = table.scan().select([ | |
| 'episode_id', 'video' | |
| ]).to_torch(streaming=True) | |
| rows = [] | |
| for batch in DataLoader( | |
| dataset, batch_size=2, num_workers=2, shuffle=False): | |
| rows.extend(zip( | |
| batch['episode_id'].tolist(), | |
| batch['video'], | |
| )) | |
| descriptors = [ | |
| VideoFrameDescriptor.deserialize(row[1]) | |
| for row in rows | |
| ] | |
| descriptors.sort(key=lambda value: value.frame_index) | |
| self.assertEqual(list(range(8)), [d.frame_index for d in descriptors]) | |
| self.assertTrue(all( | |
| value.payload_descriptor == descriptors[0].payload_descriptor | |
| for value in descriptors | |
| )) | |
| self.assertTrue(descriptors[0].uri.endswith('.video')) | |
| self.assertEqual(len(video_bytes), descriptors[0].length) | |
| def test_torch_read_pk_table(self): | |
| """Test torch read with primary key table.""" | |
| # Create PK table with user_id as primary key and behavior as partition key | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| primary_keys=['user_id', 'behavior'], | |
| partition_keys=['behavior'], | |
| options={'bucket': 2} | |
| ) | |
| self.catalog.create_table('default.test_pk_table', schema, False) | |
| table = self.catalog.get_table('default.test_pk_table') | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection(['user_id', 'behavior']) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=3, | |
| shuffle=False | |
| ) | |
| # Collect all data from dataloader | |
| all_user_ids = [] | |
| all_behaviors = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| user_ids = batch_data['user_id'].tolist() | |
| behaviors = batch_data['behavior'] | |
| all_user_ids.extend(user_ids) | |
| all_behaviors.extend(behaviors) | |
| # Sort by user_id for comparison | |
| sorted_data = sorted(zip(all_user_ids, all_behaviors), key=lambda x: x[0]) | |
| sorted_user_ids = [x[0] for x in sorted_data] | |
| sorted_behaviors = [x[1] for x in sorted_data] | |
| # Expected data (sorted by user_id) | |
| expected_user_ids = [1, 2, 3, 4, 5, 6, 7, 8] | |
| expected_behaviors = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] | |
| # Verify results | |
| self.assertEqual(sorted_user_ids, expected_user_ids, | |
| f"User IDs mismatch. Expected {expected_user_ids}, got {sorted_user_ids}") | |
| self.assertEqual(sorted_behaviors, expected_behaviors, | |
| f"Behaviors mismatch. Expected {expected_behaviors}, got {sorted_behaviors}") | |
| print(f"✓ PK table test passed: Successfully read {len(all_user_ids)} rows with correct data") | |
| def test_torch_read_large_append_table(self): | |
| """Test torch read with large data volume on append-only table.""" | |
| # Create append-only table | |
| schema = Schema.from_pyarrow_schema(self.pa_schema, partition_keys=['dt']) | |
| self.catalog.create_table('default.test_large_append', schema, False) | |
| table = self.catalog.get_table('default.test_large_append') | |
| # Write large amount of data | |
| write_builder = table.new_batch_write_builder() | |
| total_rows = 100000 | |
| batch_size = 10000 | |
| num_batches = total_rows // batch_size | |
| print(f"\n{'=' * 60}") | |
| print(f"Writing {total_rows} rows to append-only table...") | |
| print(f"{'=' * 60}") | |
| for batch_idx in range(num_batches): | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| start_id = batch_idx * batch_size + 1 | |
| end_id = start_id + batch_size | |
| data = { | |
| 'user_id': list(range(start_id, end_id)), | |
| 'item_id': [1000 + i for i in range(start_id, end_id)], | |
| 'behavior': [chr(ord('a') + (i % 26)) for i in range(batch_size)], | |
| 'dt': [f'p{i % 4}' for i in range(batch_size)], | |
| } | |
| pa_table = pa.Table.from_pydict(data, schema=self.pa_schema) | |
| table_write.write_arrow(pa_table) | |
| table_commit.commit(table_write.prepare_commit()) | |
| table_write.close() | |
| table_commit.close() | |
| if (batch_idx + 1) % 2 == 0: | |
| print(f" Written {(batch_idx + 1) * batch_size} rows...") | |
| # Read data using torch | |
| print(f"\nReading {total_rows} rows using Torch DataLoader...") | |
| read_builder = table.new_read_builder().with_projection(['user_id', 'behavior']) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| print(f"Total splits: {len(splits)}") | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=1000, | |
| num_workers=4, | |
| shuffle=False | |
| ) | |
| # Collect all data | |
| all_user_ids = [] | |
| batch_count = 0 | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| batch_count += 1 | |
| user_ids = batch_data['user_id'].tolist() | |
| all_user_ids.extend(user_ids) | |
| if (batch_idx + 1) % 20 == 0: | |
| print(f" Read {len(all_user_ids)} rows...") | |
| all_user_ids.sort() | |
| # Verify data | |
| self.assertEqual(len(all_user_ids), total_rows, | |
| f"Row count mismatch. Expected {total_rows}, got {len(all_user_ids)}") | |
| self.assertEqual(all_user_ids, list(range(1, total_rows + 1)), | |
| f"Row count mismatch. Expected {total_rows}, got {len(all_user_ids)}") | |
| print(f"\n{'=' * 60}") | |
| print("✓ Large append table test passed!") | |
| print(f" Total rows: {total_rows}") | |
| print(f" Total batches: {batch_count}") | |
| print(f"{'=' * 60}\n") | |
| def test_torch_read_large_pk_table(self): | |
| """Test torch read with large data volume on primary key table.""" | |
| # Create PK table | |
| large_pk_pa_schema = pa.schema([ | |
| pa.field('user_id', pa.int32(), nullable=False), | |
| ('item_id', pa.int64()), | |
| ('behavior', pa.string()), | |
| ('dt', pa.string()) | |
| ]) | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| primary_keys=['user_id'], | |
| partition_keys=['dt'], | |
| options={'bucket': '4'} | |
| ) | |
| self.catalog.create_table('default.test_large_pk', schema, False) | |
| table = self.catalog.get_table('default.test_large_pk') | |
| # Write large amount of data | |
| write_builder = table.new_batch_write_builder() | |
| total_rows = 100000 | |
| batch_size = 10000 | |
| num_batches = total_rows // batch_size | |
| print(f"\n{'=' * 60}") | |
| print(f"Writing {total_rows} rows to PK table...") | |
| print(f"{'=' * 60}") | |
| for batch_idx in range(num_batches): | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| start_id = batch_idx * batch_size + 1 | |
| end_id = start_id + batch_size | |
| data = { | |
| 'user_id': list(range(start_id, end_id)), | |
| 'item_id': [1000 + i for i in range(start_id, end_id)], | |
| 'behavior': [chr(ord('a') + (i % 26)) for i in range(batch_size)], | |
| 'dt': [f'p{i % 4}' for i in range(batch_size)], | |
| } | |
| pa_table = pa.Table.from_pydict(data, schema=large_pk_pa_schema) | |
| table_write.write_arrow(pa_table) | |
| table_commit.commit(table_write.prepare_commit()) | |
| table_write.close() | |
| table_commit.close() | |
| if (batch_idx + 1) % 2 == 0: | |
| print(f" Written {(batch_idx + 1) * batch_size} rows...") | |
| # Read data using torch | |
| print(f"\nReading {total_rows} rows using Torch DataLoader...") | |
| read_builder = table.new_read_builder() | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| print(f"Total splits: {len(splits)}") | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=1000, | |
| num_workers=8, | |
| shuffle=False | |
| ) | |
| # Collect all data | |
| all_user_ids = [] | |
| batch_count = 0 | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| batch_count += 1 | |
| user_ids = batch_data['user_id'].tolist() | |
| all_user_ids.extend(user_ids) | |
| if (batch_idx + 1) % 20 == 0: | |
| print(f" Read {len(all_user_ids)} rows...") | |
| all_user_ids.sort() | |
| # Verify data | |
| self.assertEqual(len(all_user_ids), total_rows, | |
| f"Row count mismatch. Expected {total_rows}, got {len(all_user_ids)}") | |
| self.assertEqual(all_user_ids, list(range(1, total_rows + 1)), | |
| f"Row count mismatch. Expected {total_rows}, got {len(all_user_ids)}") | |
| print(f"\n{'=' * 60}") | |
| print("✓ Large PK table test passed!") | |
| print(f" Total rows: {total_rows}") | |
| print(f" Total batches: {batch_count}") | |
| print(" Primary key uniqueness: ✓") | |
| print(f"{'=' * 60}\n") | |
| def test_torch_read_with_predicate(self): | |
| """Test torch read with predicate filtering.""" | |
| schema = Schema.from_pyarrow_schema(self.pa_schema, partition_keys=['user_id']) | |
| self.catalog.create_table('default.test_predicate', schema, False) | |
| table = self.catalog.get_table('default.test_predicate') | |
| self._write_test_table(table) | |
| # Test case 1: Filter by user_id > 4 | |
| print(f"\n{'=' * 60}") | |
| print("Test Case 1: user_id > 4") | |
| print(f"{'=' * 60}") | |
| predicate_builder = table.new_read_builder().new_predicate_builder() | |
| predicate = predicate_builder.greater_than('user_id', 4) | |
| read_builder = table.new_read_builder().with_filter(predicate) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=0, | |
| shuffle=False | |
| ) | |
| all_user_ids = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| user_ids = batch_data['user_id'].tolist() | |
| all_user_ids.extend(user_ids) | |
| all_user_ids.sort() | |
| expected_user_ids = [5, 6, 7, 8] | |
| self.assertEqual(all_user_ids, expected_user_ids, | |
| f"User IDs mismatch. Expected {expected_user_ids}, got {all_user_ids}") | |
| print(f"✓ Filtered {len(all_user_ids)} rows: {all_user_ids}") | |
| # Test case 2: Filter by user_id <= 3 | |
| print(f"\n{'=' * 60}") | |
| print("Test Case 2: user_id <= 3") | |
| print(f"{'=' * 60}") | |
| predicate = predicate_builder.less_or_equal('user_id', 3) | |
| read_builder = table.new_read_builder().with_filter(predicate) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=0, | |
| shuffle=False | |
| ) | |
| all_user_ids = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| user_ids = batch_data['user_id'].tolist() | |
| all_user_ids.extend(user_ids) | |
| all_user_ids.sort() | |
| expected_user_ids = [1, 2, 3] | |
| self.assertEqual(all_user_ids, expected_user_ids, | |
| f"User IDs mismatch. Expected {expected_user_ids}, got {all_user_ids}") | |
| print(f"✓ Filtered {len(all_user_ids)} rows: {all_user_ids}") | |
| # Test case 3: Filter by behavior = 'a' | |
| print(f"\n{'=' * 60}") | |
| print("Test Case 3: behavior = 'a'") | |
| print(f"{'=' * 60}") | |
| predicate = predicate_builder.equal('behavior', 'a') | |
| read_builder = table.new_read_builder().with_filter(predicate) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=0, | |
| shuffle=False | |
| ) | |
| all_user_ids = [] | |
| all_behaviors = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| user_ids = batch_data['user_id'].tolist() | |
| behaviors = batch_data['behavior'] | |
| all_user_ids.extend(user_ids) | |
| all_behaviors.extend(behaviors) | |
| expected_user_ids = [1] | |
| expected_behaviors = ['a'] | |
| self.assertEqual(all_user_ids, expected_user_ids, | |
| f"User IDs mismatch. Expected {expected_user_ids}, got {all_user_ids}") | |
| self.assertEqual(all_behaviors, expected_behaviors, | |
| f"Behaviors mismatch. Expected {expected_behaviors}, got {all_behaviors}") | |
| print(f"✓ Filtered {len(all_user_ids)} rows: user_ids={all_user_ids}, behaviors={all_behaviors}") | |
| # Test case 4: Filter by user_id IN (2, 4, 6) | |
| print(f"\n{'=' * 60}") | |
| print("Test Case 4: user_id IN (2, 4, 6)") | |
| print(f"{'=' * 60}") | |
| predicate = predicate_builder.is_in('user_id', [2, 4, 6]) | |
| read_builder = table.new_read_builder().with_filter(predicate) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=0, | |
| shuffle=False | |
| ) | |
| all_user_ids = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| user_ids = batch_data['user_id'].tolist() | |
| all_user_ids.extend(user_ids) | |
| all_user_ids.sort() | |
| expected_user_ids = [2, 4, 6] | |
| self.assertEqual(all_user_ids, expected_user_ids, | |
| f"User IDs mismatch. Expected {expected_user_ids}, got {all_user_ids}") | |
| print(f"✓ Filtered {len(all_user_ids)} rows: {all_user_ids}") | |
| # Test case 5: Combined filter (user_id > 2 AND user_id < 7) | |
| print(f"\n{'=' * 60}") | |
| print("Test Case 5: user_id > 2 AND user_id < 7") | |
| print(f"{'=' * 60}") | |
| predicate1 = predicate_builder.greater_than('user_id', 2) | |
| predicate2 = predicate_builder.less_than('user_id', 7) | |
| combined_predicate = predicate_builder.and_predicates([predicate1, predicate2]) | |
| read_builder = table.new_read_builder().with_filter(combined_predicate) | |
| table_scan = read_builder.new_scan() | |
| table_read = read_builder.new_read() | |
| splits = table_scan.plan().splits() | |
| dataset = table_read.to_torch(splits, streaming=True) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=2, | |
| num_workers=0, | |
| shuffle=False | |
| ) | |
| all_user_ids = [] | |
| for batch_idx, batch_data in enumerate(dataloader): | |
| user_ids = batch_data['user_id'].tolist() | |
| all_user_ids.extend(user_ids) | |
| all_user_ids.sort() | |
| expected_user_ids = [3, 4, 5, 6] | |
| self.assertEqual(all_user_ids, expected_user_ids, | |
| f"User IDs mismatch. Expected {expected_user_ids}, got {all_user_ids}") | |
| print(f"✓ Filtered {len(all_user_ids)} rows: {all_user_ids}") | |
| print(f"\n{'=' * 60}") | |
| print("✓ All predicate test cases passed!") | |
| print(f"{'=' * 60}\n") | |
| @parameterized.expand([(1, None), (7, None), (7, 3), (11, 17)]) | |
| def test_torch_batch_shuffle_formats_and_epochs(self, buffer_size, batch_size): | |
| table = self._create_shuffle_append_table( | |
| 'default.batch_shuffle_%s_%s' % (buffer_size, batch_size)) | |
| builder = table.new_read_builder().with_projection(['user_id', 'item_id']) | |
| splits = builder.new_scan().plan().splits() | |
| read = builder.new_read() | |
| outputs = [] | |
| for batch_format in ['pyarrow', 'torch']: | |
| dataset = read.to_torch( | |
| splits, streaming=True, shuffle=True, batch_format=batch_format, | |
| batch_size=batch_size, seed=23, buffer_size=buffer_size, | |
| max_buffer_input_splits=3) | |
| def collect(): | |
| batches = list(dataset) | |
| if batch_format == 'pyarrow': | |
| return pa.Table.from_batches(batches).to_pydict(), [b.num_rows for b in batches] | |
| return {name: torch.cat([b[name] for b in batches]).tolist() | |
| for name in ['user_id', 'item_id']}, [len(b['user_id']) for b in batches] | |
| with patch.object(TableRead, 'to_arrow', side_effect=AssertionError('whole table')), \ | |
| patch.object(TableRead, 'to_iterator', side_effect=AssertionError('row conversion')): | |
| data, sizes = collect() | |
| self.assertEqual(data, collect()[0]) | |
| self.assertEqual(sorted(data['user_id']), list(range(80))) | |
| self.assertEqual(data['item_id'], [1000 + i for i in data['user_id']]) | |
| self.assertTrue(all(size <= (batch_size or buffer_size) for size in sizes)) | |
| if batch_size: | |
| self.assertTrue(all(size == batch_size for size in sizes[:-1])) | |
| outputs.append(data) | |
| dataset.set_epoch(1) | |
| if buffer_size > 1: | |
| self.assertNotEqual(data, collect()[0]) | |
| dataset.set_epoch(0) | |
| self.assertEqual(data, collect()[0]) | |
| self.assertEqual(outputs[0], outputs[1]) | |
| def test_torch_batch_shuffle_filter_limit_and_empty(self): | |
| table = self._create_shuffle_append_table('default.batch_shuffle_limit') | |
| builder = table.new_read_builder().with_projection(['user_id']) | |
| predicate = builder.new_predicate_builder().greater_or_equal('user_id', 10) | |
| builder.with_filter(predicate).with_limit(9) | |
| splits = builder.new_scan().plan().splits() | |
| read = builder.new_read() | |
| expected = read.to_arrow(splits)['user_id'].to_pylist() | |
| dataset = read.to_torch(splits, streaming=True, shuffle=True, batch_format='pyarrow', | |
| batch_size=4, buffer_size=3, max_buffer_input_splits=3) | |
| actual = [row['user_id'] for batch in dataset for row in batch.to_pylist()] | |
| self.assertCountEqual(actual, expected) | |
| for limit in [0, 9]: | |
| read.limit = limit | |
| empty = read.to_torch([], streaming=True, shuffle=True, batch_format='pyarrow', buffer_size=3) | |
| self.assertEqual(list(empty), []) | |
| def test_torch_batch_shuffle_persistent_spawn_workers(self): | |
| table = self._create_shuffle_append_table('default.batch_shuffle_spawn', partition_keys=['dt']) | |
| builder = table.new_read_builder().with_projection(['user_id']) | |
| splits = builder.new_scan().plan().splits() | |
| self.assertGreaterEqual(len(splits), 4) | |
| all_ids = [] | |
| for rank in range(2): | |
| dataset = builder.new_read().to_torch( | |
| splits, streaming=True, shuffle=True, batch_format='torch', | |
| batch_size=4, buffer_size=7, seed=23, | |
| sharding_rank=rank, sharding_world_size=2) | |
| loader = DataLoader(dataset, batch_size=None, num_workers=2, | |
| persistent_workers=True, multiprocessing_context='spawn') | |
| try: | |
| epoch0 = self._collect_torch_user_ids_from_dataloader(loader) | |
| self.assertEqual(epoch0, self._collect_torch_user_ids_from_dataloader(loader)) | |
| dataset.set_epoch(1) | |
| epoch1 = self._collect_torch_user_ids_from_dataloader(loader) | |
| self.assertCountEqual(epoch0, epoch1) | |
| self.assertNotEqual(epoch0, epoch1) | |
| all_ids.extend(epoch0) | |
| finally: | |
| if loader._iterator is not None: | |
| loader._iterator._shutdown_workers() | |
| self.assertEqual(sorted(all_ids), list(range(80))) | |
| def test_torch_batch_shuffle_vectors_deletions_and_snapshot(self): | |
| import pypaimon.multimodal as pm | |
| schema = pa.schema([('id', pa.int64()), ('vector', pa.list_(pa.float32(), 2))]) | |
| table = pm.connect(options={'warehouse': self.warehouse}).create_table( | |
| 'batch_shuffle_vectors', schema=schema, options={'file.format': 'parquet'}) | |
| table.add(pa.table({'id': list(range(8)), 'vector': [[i, i + 1] for i in range(8)]}, schema=schema)) | |
| snapshot = table.raw_table.snapshot_manager().get_latest_snapshot().id | |
| table.delete('id = 2') | |
| for snapshot_id, expected in [(snapshot, list(range(1, 8))), (None, [1, 3, 4, 5, 6, 7])]: | |
| dataset = table.scan(snapshot_id=snapshot_id).where('id >= 1').to_torch( | |
| streaming=True, batch_format='torch', batch_size=3, shuffle=True, | |
| seed=7, buffer_size=4, max_buffer_input_splits=1) | |
| batches = list(dataset) | |
| ids = torch.cat([batch['id'] for batch in batches]).tolist() | |
| vectors = torch.cat([batch['vector'] for batch in batches]) | |
| self.assertEqual(vectors.dtype, torch.float32) | |
| self.assertEqual(vectors.tolist(), [[i, i + 1] for i in ids]) | |
| self.assertEqual(sorted(ids), expected) | |
| def test_torch_streaming_shuffle_single_worker(self): | |
| table = self._create_shuffle_append_table('default.test_torch_shuffle_single') | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| table_read = read_builder.new_read() | |
| splits = read_builder.new_scan().plan().splits() | |
| expected = list(range(80)) | |
| for max_buffer_input_splits in [1, 3]: | |
| with self.subTest(max_buffer_input_splits=max_buffer_input_splits): | |
| dataset = table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| seed=17, | |
| buffer_size=7, | |
| max_buffer_input_splits=max_buffer_input_splits, | |
| ) | |
| ids = self._collect_torch_user_ids(dataset, num_workers=0) | |
| self.assertEqual(sorted(ids), expected) | |
| self.assertNotEqual(ids, expected) | |
| def test_torch_streaming_shuffle_seed_and_epoch(self): | |
| table = self._create_shuffle_append_table('default.test_torch_shuffle_epoch') | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| table_read = read_builder.new_read() | |
| splits = read_builder.new_scan().plan().splits() | |
| dataset = table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| seed=23, | |
| buffer_size=11, | |
| max_buffer_input_splits=4, | |
| ) | |
| epoch0 = self._collect_torch_user_ids(dataset, num_workers=0) | |
| epoch0_again = self._collect_torch_user_ids(dataset, num_workers=0) | |
| self.assertEqual(epoch0, epoch0_again) | |
| dataset.set_epoch(1) | |
| epoch1 = self._collect_torch_user_ids(dataset, num_workers=0) | |
| self.assertEqual(sorted(epoch1), list(range(80))) | |
| self.assertNotEqual(epoch0, epoch1) | |
| dataset.set_epoch(0) | |
| self.assertEqual(epoch0, self._collect_torch_user_ids(dataset, num_workers=0)) | |
| other_seed_dataset = table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| seed=24, | |
| buffer_size=11, | |
| max_buffer_input_splits=4, | |
| ) | |
| self.assertNotEqual( | |
| epoch0, | |
| self._collect_torch_user_ids(other_seed_dataset, num_workers=0), | |
| ) | |
| def test_torch_streaming_shuffle_epoch_with_persistent_workers(self): | |
| table = self._create_shuffle_append_table('default.test_torch_shuffle_persistent_epoch') | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| table_read = read_builder.new_read() | |
| splits = read_builder.new_scan().plan().splits() | |
| dataset = table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| seed=23, | |
| buffer_size=11, | |
| max_buffer_input_splits=4, | |
| ) | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=8, | |
| num_workers=2, | |
| persistent_workers=True, | |
| shuffle=False, | |
| ) | |
| epoch0 = self._collect_torch_user_ids_from_dataloader(dataloader) | |
| self.assertEqual(epoch0, self._collect_torch_user_ids_from_dataloader(dataloader)) | |
| dataset.set_epoch(1) | |
| epoch1 = self._collect_torch_user_ids_from_dataloader(dataloader) | |
| self.assertEqual(sorted(epoch1), list(range(80))) | |
| self.assertNotEqual(epoch0, epoch1) | |
| def test_torch_streaming_shuffle_multi_worker(self): | |
| table = self._create_shuffle_append_table('default.test_torch_shuffle_multi') | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| table_read = read_builder.new_read() | |
| splits = read_builder.new_scan() \ | |
| .with_chunk_shuffle(seed=31, chunk_size=5) \ | |
| .plan() \ | |
| .splits() | |
| dataset = table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| seed=31, | |
| buffer_size=13, | |
| max_buffer_input_splits=4, | |
| ) | |
| ids = self._collect_torch_user_ids(dataset, num_workers=2) | |
| expected = list(range(80)) | |
| self.assertEqual(len(ids), len(expected)) | |
| self.assertEqual(sorted(ids), expected) | |
| def test_torch_streaming_shuffle_rejects_non_streaming(self): | |
| table = self._create_shuffle_append_table('default.test_torch_shuffle_non_streaming') | |
| read_builder = table.new_read_builder() | |
| table_read = read_builder.new_read() | |
| splits = read_builder.new_scan().plan().splits() | |
| with self.assertRaisesRegex(ValueError, "streaming=True"): | |
| table_read.to_torch(splits, streaming=False, shuffle=True) | |
| def test_torch_streaming_shuffle_accepts_pk_table_splits(self): | |
| pa_schema = pa.schema([ | |
| pa.field('user_id', pa.int32(), nullable=False), | |
| ('item_id', pa.int64()), | |
| ('behavior', pa.string()), | |
| ('dt', pa.string()) | |
| ]) | |
| schema = Schema.from_pyarrow_schema( | |
| pa_schema, | |
| primary_keys=['user_id'], | |
| options={'bucket': '1'}, | |
| ) | |
| self.catalog.create_table('default.test_torch_shuffle_pk', schema, False) | |
| table = self.catalog.get_table('default.test_torch_shuffle_pk') | |
| self._write_test_table(table) | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| splits = read_builder.new_scan().plan().splits() | |
| dataset = read_builder.new_read().to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| seed=7, | |
| buffer_size=3, | |
| ) | |
| ids = self._collect_torch_user_ids(dataset, num_workers=0) | |
| self.assertEqual(sorted(ids), [1, 2, 3, 4, 5, 6, 7, 8]) | |
| def test_torch_streaming_shuffle_rejects_invalid_dataset_options(self): | |
| table = self._create_shuffle_append_table('default.test_torch_shuffle_invalid_options') | |
| read_builder = table.new_read_builder().with_projection(['user_id']) | |
| table_read = read_builder.new_read() | |
| splits = read_builder.new_scan().plan().splits() | |
| with self.assertRaisesRegex(ValueError, "prefetch_concurrency"): | |
| table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| prefetch_concurrency=2, | |
| ) | |
| with self.assertRaisesRegex(ValueError, "buffer_size"): | |
| table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| buffer_size=0, | |
| ) | |
| with self.assertRaisesRegex(ValueError, "max_buffer_input_splits"): | |
| table_read.to_torch( | |
| splits, | |
| streaming=True, | |
| shuffle=True, | |
| max_buffer_input_splits=0, | |
| ) | |
| def _create_shuffle_append_table( | |
| self, | |
| identifier, | |
| total_rows=80, | |
| rows_per_commit=10, | |
| partition_keys=None, | |
| ): | |
| schema = Schema.from_pyarrow_schema( | |
| self.pa_schema, | |
| partition_keys=partition_keys or [], | |
| ) | |
| self.catalog.create_table(identifier, schema, False) | |
| table = self.catalog.get_table(identifier) | |
| write_builder = table.new_batch_write_builder() | |
| for start in range(0, total_rows, rows_per_commit): | |
| end = min(start + rows_per_commit, total_rows) | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| pa_table = pa.Table.from_pydict({ | |
| 'user_id': list(range(start, end)), | |
| 'item_id': [1000 + i for i in range(start, end)], | |
| 'behavior': [chr(ord('a') + (i % 26)) for i in range(start, end)], | |
| 'dt': [f'p{i % 4}' for i in range(start, end)], | |
| }, schema=self.pa_schema) | |
| table_write.write_arrow(pa_table) | |
| table_commit.commit(table_write.prepare_commit()) | |
| table_write.close() | |
| table_commit.close() | |
| return table | |
| @staticmethod | |
| def _collect_torch_user_ids(dataset, num_workers=0): | |
| dataloader = DataLoader( | |
| dataset, | |
| batch_size=8, | |
| num_workers=num_workers, | |
| shuffle=False, | |
| ) | |
| all_user_ids = [] | |
| for batch_data in dataloader: | |
| all_user_ids.extend(batch_data['user_id'].tolist()) | |
| return all_user_ids | |
| @staticmethod | |
| def _collect_torch_user_ids_from_dataloader(dataloader): | |
| all_user_ids = [] | |
| for batch_data in dataloader: | |
| all_user_ids.extend(batch_data['user_id'].tolist()) | |
| return all_user_ids | |
| def _write_test_table(self, table): | |
| write_builder = table.new_batch_write_builder() | |
| table_pa_schema = self.pk_pa_schema if table.primary_keys else self.pa_schema | |
| # first write | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| data1 = { | |
| 'user_id': [1, 2, 3, 4], | |
| 'item_id': [1001, 1002, 1003, 1004], | |
| 'behavior': ['a', 'b', 'c', 'd'], | |
| 'dt': ['p1', 'p1', 'p2', 'p1'], | |
| } | |
| pa_table = pa.Table.from_pydict(data1, schema=table_pa_schema) | |
| table_write.write_arrow(pa_table) | |
| table_commit.commit(table_write.prepare_commit()) | |
| table_write.close() | |
| table_commit.close() | |
| # second write | |
| table_write = write_builder.new_write() | |
| table_commit = write_builder.new_commit() | |
| data2 = { | |
| 'user_id': [5, 6, 7, 8], | |
| 'item_id': [1005, 1006, 1007, 1008], | |
| 'behavior': ['e', 'f', 'g', 'h'], | |
| 'dt': ['p2', 'p1', 'p2', 'p1'], | |
| } | |
| pa_table = pa.Table.from_pydict(data2, schema=table_pa_schema) | |
| table_write.write_arrow(pa_table) | |
| table_commit.commit(table_write.prepare_commit()) | |
| table_write.close() | |
| table_commit.close() | |
| def _read_test_table(self, read_builder): | |
| table_read = read_builder.new_read() | |
| splits = read_builder.new_scan().plan().splits() | |
| return table_read.to_arrow(splits) |